細嗦 Golang 的指針

與 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("%p\n", nums)     // 0xc0000160c0
 fmt.Printf("%p\n"&nums[0]) // 0xc0000160c0

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

 ch := make(chan int, 0)
 fmt.Printf("%p\n", 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: %p\n", user)  // 0xc0000a4018  與 init的地址一致
 fmt.Printf("v1_p: %p\n"&user) // 0xc0000ac020
 user.Name = "test_v1"
}

// 值傳遞
func setNameV2(user User) {
 fmt.Printf("v2_p: %p\n"&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: %p\n"&u)
  fmt.Printf("val: %s\n", 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 = %d\n", *x)
 }
}
func incr2(x int) func() {
 return func() {
  x = x + 1
  fmt.Printf("incr normal x = %d\n", 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 // 被引用的指針
}

歡迎大家繼續補充指針的其他注意事項~

參考

又吵起來了,Go 是傳值還是傳引用?

GO 語言變量逃逸分析

轉自:

https://juejin.cn/post/7114673293084819492

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/RdURFSUJEAnWZEASuCec_Q