首頁 > 軟體

深入瞭解Golang的指標用法

2022-07-08 18:04:48

與C語言一樣,Go語言中同樣有指標,通過指標,我們可以只傳遞變數的記憶體地址,而不是傳遞整個變數,這在一定程度上可以節省記憶體的佔用,但凡事有利有弊,Go指標在使用也有一些注意點,稍不留神就會踩坑,下面就讓我們一起來細嗦下。

1.指標型別的變數

在Golang中,我們可以通過**取地址符號&**得到變數的地址,而這個新的變數就是一個指標型別的變數,指標變數與普通變數的區別在於,它存的是記憶體地址,而不是實際的值。

圖一

如果是普通型別的指標變數(比如 int),是無法直接對其賦值的,必須通過 * 取值符號才行。

func main() {
	num := 1
	numP := &num
	
	//numP = 2 // 報錯:(type untyped int) cannot be represented by the type *int
	*numP = 2
}

但結構體卻比較特殊,在日常開發中,我們經常看到一個結構體指標的內部變數仍然可以被賦值,比如下面這個例子,這是為什麼呢?

type Test struct {
	Num int
}

// 直接賦值和指標賦值
func main() {
	test := Test{Num: 1}
	test.Num = 3
	fmt.Println("v1", test) // 3

	testP := &test
	testP.Num = 4           // 結構體指標可以賦值
	fmt.Println("v2", test) // 4
}

這是因為結構體本身是一個連續的記憶體,通過 testP.Num ,本質上拿到的是一個普通變數,並不是一個指標變數,所以可以直接賦值。

圖二

那slice、map、channel這些又該怎麼理解呢?為什麼不用取地址符號也能列印它們的地址?比如下面的例子

func main() {
	nums := []int{1, 2, 3}
	fmt.Printf("%pn", nums)     // 0xc0000160c0
	fmt.Printf("%pn", &nums[0]) // 0xc0000160c0

	maps := map[string]string{"aa": "bb"}
	fmt.Printf("%pn", maps) // 0xc000076180

	ch := make(chan int, 0)
	fmt.Printf("%pn", ch) // 0xc00006c060
}

這是因為,它們本身就是指標型別!只不過Go內部為了書寫的方便,並沒有要求我們在前面加上 *** 符號**。

在Golang的執行時內部,建立slice的時候其實返回的就是一個指標:

// 原始碼  runtime/slice.go
// 返回值是:unsafe.Pointer
func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.size, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		// NOTE: Produce a 'len out of range' error instead of a
		// 'cap out of range' error when someone does make([]T, bignumber).
		// 'cap out of range' is true too, but since the cap is only being
		// supplied implicitly, saying len is clearer.
		// See golang.org/issue/4085.
		mem, overflow := math.MulUintptr(et.size, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

而且返回的指標地址其實就是slice第一個元素的地址(上面的例子也體現了),當然如果slice是一個nil,則返回的是 0x0 的地址。slice在引數傳遞的時候其實拷貝的指標的地址,底層資料是共用的,所以對其修改也會影響到函數外的slice,在下面也會講到。

map和slice其實也是類似的,在在Golang的執行時內部,建立map的時候其實返回的就是一個hchan指標:

// 原始碼  runtime/chan.go
// 返回值是:*hchan
func makechan(t *chantype, size int) *hchan {
	elem := t.elem

	// compiler checks this but be safe.
	if elem.size >= 1<<16 {
		throw("makechan: invalid channel element type")
	}
	...
	return c
}

最後,為什麼 fmt.Printf 函數能夠直接列印slice、map的地址,除了上面的原因,還有一個原因是其內部也做了特殊處理:

// 第一層原始碼
func Printf(format string, a ...interface{}) (n int, err error) {
	return Fprintf(os.Stdout, format, a...)
}

// 第二層原始碼
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
	p := newPrinter()
	p.doPrintf(format, a)  // 核心
	n, err = w.Write(p.buf)
	p.free()
	return
}

// 第三層原始碼
func (p *pp) doPrintf(format string, a []interface{}) {
	 ...
	default:
			// Fast path for common case of ascii lower case simple verbs
			// without precision or width or argument indices.
			if 'a' <= c && c <= 'z' && argNum < len(a) {
				...
				p.printArg(a[argNum], rune(c))   // 核心是這裡
				argNum++
				i++
				continue formatLoop
			}
			// Format is more complex than simple flags and a verb or is malformed.
			break simpleFormat
		}

}

// 第四層原始碼
func (p *pp) printArg(arg interface{}, verb rune) {
	p.arg = arg
	p.value = reflect.Value{}
  ...
	case 'p':
		p.fmtPointer(reflect.ValueOf(arg), 'p')
		return
	}
	...
}

// 最後了
func (p *pp) fmtPointer(value reflect.Value, verb rune) {
	var u uintptr
	switch value.Kind() {
  // 這裡對這些特殊型別直接獲取了其地址
	case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer:
		u = value.Pointer()
	default:
		p.badVerb(verb)
		return
	}
  ...
}

2.Go只有值傳遞,沒有參照傳遞

值傳遞和參照傳遞相信大家都比較瞭解,在函數的呼叫過程中,如果是值傳遞,則在傳遞過程中,其實就是將引數的值複製一份傳遞到函數中,如果在函數內對其修改,並不會影響函數外面的引數值,而參照傳遞則相反。

type User struct {
	Name string
	Age  int
}

// 參照傳遞
func setNameV1(user *User) {
	user.Name = "test_v1"
}

// 值傳遞
func setNameV2(user User) {
	user.Name = "test_v2"
}

func main() {
	u := User{Name: "init"}
	fmt.Println("init", u)  // init {init 0}

	up := &u
	setNameV1(up)
	fmt.Println("v1", u) // v1 {test_v1 0}

	setNameV2(u)
	fmt.Println("v2", u) // v2 {test_v1 0}
}

但在Golang中,這所謂的“參照傳遞”其實本質上是值傳遞,因為這時候也發生了拷貝,只不過這時拷貝的是指標,而不是變數的值,所以**“Golang的參照傳遞其實是參照的拷貝”。**

圖三

可以通過以下程式碼驗證:

type User struct {
	Name string
	Age  int
}

// 注意這裡有個誤區,我一開始看 user(v1)列印後的地址和一開始(init)是一致的,從而以為這是參照傳遞
// 其實這裡的user應該看做一個指標變數,我們需要對比的是它的地址,所以還要再取一次地址
func setNameV1(user *User) {
	fmt.Printf("v1: %pn", user)  // 0xc0000a4018  與 init的地址一致
	fmt.Printf("v1_p: %pn", &user) // 0xc0000ac020
	user.Name = "test_v1"
}

// 值傳遞
func setNameV2(user User) {
	fmt.Printf("v2_p: %pn", &user) //0xc0000a4030
	user.Name = "test_v2"
}

func main() {
	u := User{Name: "init"}

	up := &u
	fmt.Printf("init: %p n", up) //0xc0000a4018
	setNameV1(up)
	setNameV2(u)
}

注:slice、map等本質也是如此。

3.for range與指標

for range是在Golang中用於遍歷元素,當它與指標結合時,稍不留神就會踩坑,這裡有一段經典程式碼:

type User struct {
	Name string
	Age  int
}

func main() {
	userList := []User {
		User{Name: "aa", Age: 1},
		User{Name: "bb", Age: 1},
	}

	var newUser []*User
	for _, u := range userList {
		newUser = append(newUser, &u)
	}

	// 第一次:bb
	// 第二次:bb
	for _, nu := range newUser {
		fmt.Printf("%+v", nu.Name)
	}
}

按照正常的理解,應該第一次輸出aa,第二次輸出bb,但實際上兩次都輸出了bb,這是因為 for range 的時候,變數u實際上只初始化了一次(每次遍歷的時候u都會被重新賦值,但是地址不變),導致每次append的時候,新增的都是同一個記憶體地址,所以最終指向的都是最後一個值bb。

我們可以通過列印指標地址來驗證:

func main() {
	userList := []User {
		User{Name: "aa", Age: 1},
		User{Name: "bb", Age: 1},
	}

	var newUser []*User
	for _, u := range userList {
		fmt.Printf("point: %pn", &u)
		fmt.Printf("val: %sn", u.Name)
		newUser = append(newUser, &u)
	}
}

// 最終輸出結果如下:
point: 0xc00000c030
val: aa
point: 0xc00000c030
val: bb

類似的錯誤在Goroutine也經常發生:

// 這裡要注意下,理論上這裡都應該輸出10的,但有可能出現執行到7或者其他值的時候就輸出了,所以實際上這裡不完全都輸出10
func main() {
	for i := 0; i < 10; i++ {
		go func(idx *int) {
			fmt.Println("go: ", *idx)
		}(&i)
	}
	time.Sleep(5 * time.Second)
}

4.閉包與指標

什麼是閉包,一個函數和對其周圍狀態(lexical environment,詞法環境)的參照捆綁在一起(或者說函數被參照包圍),這樣的組合就是閉包closure)。也就是說,閉包讓你可以在一個內層函數中存取到其外層函數的作用域

當閉包與指標進行結合時,如果閉包裡面是一個指標變數,則外部變數的改變,也會影響到該閉包,起到意想不到的效果,讓我們繼續在舉幾個例子進行說明:

func incr1(x *int) func() {
	return func() {
		*x = *x + 1   // 這裡是一個指標
		fmt.Printf("incr point x = %dn", *x)
	}
}
func incr2(x int) func() {
	return func() {
		x = x + 1
		fmt.Printf("incr normal x = %dn", x)
	}
}

func main() {
	x := 1
	i1 := incr1(&x)
	i2 := incr2(x)
	i1() // point x = 2
	i2() // normal x = 2
	i1() // point x = 3
	i2() // normal x = 3

	x = 100
	i1() // point x = 101  // 閉包1的指標變數受外部影響,被重置為100,並繼續遞增
	i2() // normal x = 4
	i1() // point x = 102
	i2() // normal x = 5
}

5.指標與記憶體逃逸

記憶體逃逸的場景有很多,這裡只討論由指標引發的記憶體逃逸。理想情況下,肯定是儘量減少記憶體逃逸,因為這意味著GC(垃圾回收)的壓力會減小,程式也會執行得更快。不過,使用指標又能減少記憶體的佔用,所以這本質是記憶體和GC的權衡,需要合理使用。

下面是指標引發的記憶體逃逸的三種場景(歡迎大家補充~)

第一種場景:函數返回區域性變數的指標

type Escape struct {
	Num1  int
	Str1  *string
	Slice []int
}

// 返回區域性變數的指標
func NewEscape() *Escape {
	return &Escape{}   // &Escape{} escapes to heap
}

func main() {
	e := &Escape{Num1: 0}
}

第二種場景:被已經逃逸的變數參照的指標

func main() {
	e := NewEscape()
	e.SetNum1(10)

	name := "aa"
	// e.Str1 中,e是已經逃逸的變數, &name是被參照的指標
	e.Str1 = &name  // moved to heap: name
}

第三種場景:被指標型別的slice、map和chan參照的指標

func main() {
	e := NewEscape()
	e.SetNum1(10)

	name := "aa"
	e.Str1 = &name

	// 指標型別的slice
	arr := make([]*int, 2) 
	n := 10  // moved to heap: n
	arr[0] = &n // 被參照的指標
}

以上就是深入瞭解Golang的指標用法的詳細內容,更多關於Golang指標的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com