【Go语言测评】传值还是传指针?

577 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 10 天,点击查看活动详情

参数传递

熟悉go语言的朋友们都知道,所有函数参数传递的都是值,也就是一个复制品。那么对于这么多数据类型,我们到底在编程中传值好还是传引用好呢?下面先介绍这两个的区别,加以性能测试对比,最后给出最优选择建议。

传值和传引用

  • 传值:直接整个结构拷贝一份进入函数体,函数内修改,外部不可见。
  • 传引用:传递的参数是一个指针,只对指针进行复制,函数内修改相当于是同一个数据进行了操作,外部是可见的。

从业务上进行选择

根据业务场景和传值、传引用的特点,选择如下:

  • 当函数内数据的修改希望外部可见时,使用传引用
  • 函数内数据的修改不希望外部可见时,使用传值
  • 注意对于slice和map,无论传值传引用修改对外都会可见,引用传递的是底层结构的指针

一个典型的应用场景就是并发编程中对sync各种变量的传递:

  • 如果传值,导复制致锁分类,发生不可预测的错误
  • 应该传引用,这样在不同函数里都是操作的同一把锁
  • 还可以使用闭包

还有一个场景是结构体的方法定义时写结构体指针还是结构体,我们可以把结构体参数当作函数的一个参数看待:

  • 如果传值,相当于函数内用的是一个结构体副本
  • 传引用相当于修改结构体本体,对外可见

从性能上分析

传参

数组

函数内对数组的操作主要有取值和赋值,下面针对传参和传引用,分别在函数内无赋值和有赋值场景下的性能测试:

// 数组定义及操作
var array [1000000]int

func getArr(arr [1000000]int) int {
   tmp := arr[0]
   return tmp
}

func getArrWithPtr(arr *[1000000]int) int {
   tmp := arr[0]
   return tmp
}

func changeArr(arr [1000000]int) {
   arr[100]++
}

func changeArrWithPtr(arr *[1000000]int) {
   arr[100]++
}

测试代码:

func Benchmark_change_array(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeArr(array)
   }
}

func Benchmark_change_array_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeArrWithPtr(&array)
   }
}

func Benchmark_copy_array(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getArr(array)
   }
}

func Benchmark_copy_array_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getArrWithPtr(&array)
   }
}

测试结果:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/array
cpu: AMD Ryzen 7 6800H with Radeon Graphics
Benchmark_change_array-16                   4036            305911 ns/op
Benchmark_change_array_ptr-16           1000000000               0.2327 ns/op
Benchmark_copy_array-16                 1000000000               0.2327 ns/op
Benchmark_copy_array_ptr-16             1000000000               0.2311 ns/op

结果表明,除了传值时并对值进行修改需要对整个数组进行复制,其他的都不用。

这看起来有点奇怪,为什么传数组不修改数组的值不会发生整个数组的赋值?

有人说只有参数进行赋值操作才进行复制,也许是go语言的一个优化吧?

slice

类似的,也对传参和传引用和进不进行值的修改进行分别的测试:

var slice = make([]int, 1000000)

func getSli(arr []int) int {
   tmp := arr[0]
   return tmp
}

func getSliWithPtr(arr *[]int) int {
   tmp := (*arr)[0]
   return tmp
}

func changeSli(arr []int) {
   arr[100]++
}

func changeSliWithPtr(arr *[]int) {
   (*arr)[100]++
}

func Benchmark_change_Sli(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeSli(slice)
   }
}

func Benchmark_change_Sli_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeSliWithPtr(&slice)
   }
}

func Benchmark_copy_Sli(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getSli(slice)
   }
}

func Benchmark_copy_Sli_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getSliWithPtr(&slice)
   }
}

结果如下:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/array
cpu: AMD Ryzen 7 6800H with Radeon Graphics
Benchmark_change_Sli-16         1000000000               0.4782 ns/op
Benchmark_change_Sli_ptr-16     1000000000               0.4524 ns/op
Benchmark_copy_Sli-16           1000000000               0.4635 ns/op
Benchmark_copy_Sli_ptr-16       1000000000               0.4643 ns/op

整体没什么差别,这个还是很符合预期的,因为slice相当于只有两个值和一个指针的结构体,与传值的一个指针相比几乎没有差别,可以随意使用。

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

string

对长度100w的字符串进行传值和传引用,因为字符串是不可修改的类型,我们就只对比取值的性能:

var str = "&...&" //长度100w
func getStr(s string) byte {
   return s[0]
}

func getStrFromPtr(s *string) byte {
   return (*s)[0]
}

func Benchmark_copy_str(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getStr(str)
   }
}

func Benchmark_copy_str_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getStrFromPtr(&str)
   }
}

结果如下:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/string
cpu: AMD Ryzen 7 6800H with Radeon Graphics
Benchmark_copy_str-16           1000000000               0.4499 ns/op
Benchmark_copy_str_ptr-16       1000000000               0.4735 ns/op

基本上没有差别,string 类型本质上是一个不可变的字符串。它在内部是一个字节数组,存储字符串的字节数据。

type StringHeader struct {
    Data uintptr
    Len  int
}

map

对map同样进行四种情况的测试:

var m = make(map[int]int)

func Benchmark_init(b *testing.B) {
   for i := 0; i < 1000000; i++ {
      m[i] = i
   }
   for i := 0; i < b.N; i++ {
   }
}

func getMap(m map[int]int) int {
   return m[100]
}

func getMapFromPtr(m *map[int]int) int {
   return (*m)[100]
}

func changeMap(m map[int]int) int {
   m[100]++
   return m[100]
}

func changeMapFromPtr(m *map[int]int) int {
   (*m)[100]++
   return (*m)[100]
}

func Benchmark_copy_from_map(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getMap(m)
   }
}

func Benchmark_copy_from_map_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getMapFromPtr(&m)
   }
}

func Benchmark_change_from_map(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeMap(m)
   }
}

func Benchmark_change_from_map_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeMapFromPtr(&m)
   }
}

结果如下:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/map
cpu: AMD Ryzen 7 6800H with Radeon Graphics
Benchmark_init-16                       1000000000               0.3027 ns/op
Benchmark_copy_from_map-16              257586014                4.478 ns/op
Benchmark_copy_from_map_ptr-16          262115811                4.524 ns/op
Benchmark_change_from_map-16            123438636                9.764 ns/op
Benchmark_change_from_map_ptr-16        123952113                9.700 ns/op

也没太多差别

struct

首先测试一个比较大的结构体,其中包含一个比较长的数组:

type Content struct {
   Detail [10000]int
}

var myStruct Content

func getStruct(myStruct Content) int {
   return myStruct.Detail[0]
}

func getStructFromPtr(myStruct *Content) int {
   return myStruct.Detail[0]
}

func changeStruct(myStruct Content) int {
   myStruct.Detail[0]++
   return myStruct.Detail[0]
}

func changeStructFromPtr(myStruct *Content) int {
   myStruct.Detail[0]++
   return myStruct.Detail[0]
}

func Benchmark_change_struct(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeStruct(myStruct)
   }
}

func Benchmark_change_struct_from_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      changeStructFromPtr(&myStruct)
   }
}

func Benchmark_copy_struct(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getStruct(myStruct)
   }
}

func Benchmark_copy_struct_from_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      getStructFromPtr(&myStruct)
   }
}

结果如下:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/struct
cpu: AMD Ryzen 7 6800H with Radeon Graphics
Benchmark_change_struct-16                941833              1295 ns/op
Benchmark_change_struct_from_ptr-16     1000000000               0.2549 ns/op
Benchmark_copy_struct-16                1000000000               0.2315 ns/op
Benchmark_copy_struct_from_ptr-16       1000000000               0.2323 ns/op

结果数组类似,推荐使用传引用的方式(如果不修改值,也都差不多)

下面使用一个小一点的结构体进行测试,只包含十个数字:

type Content struct {
   Detail  int
   Detail1 int
   Detail2 int
   Detail3 int
   Detail4 int
   Detail5 int
   Detail6 int
   Detail7 int
   Detail8 int
}

结果如下:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/struct2
cpu: AMD Ryzen 7 6800H with Radeon Graphics
Benchmark_change_struct-16              1000000000               1.257 ns/op
Benchmark_change_struct_from_ptr-16     1000000000               0.2322 ns/op
Benchmark_copy_struct-16                1000000000               0.2497 ns/op
Benchmark_copy_struct_from_ptr-16       1000000000               0.2390 ns/op

小的结构体,传值和传引用差距小一些,不过还是传引用更快。

一个特例

刚才的测试发现,对于数组和结构体,即使传值,只要不进行赋值操作,也不会触发,引用。下面举一个反例,当数组存储的包含一个数组的结构体:

const NumOfElems = 1000

type Content struct {
   Detail [10000]int
}

var arr [NumOfElems]Content

func withValue(arr [NumOfElems]Content) int {
   return 1
}

func withReference(arr *[NumOfElems]Content) int {
   return 1
}

func TestFn(t *testing.T) {
   var arr [NumOfElems]Content
   withValue(arr)
   withReference(&arr)
}

func BenchmarkPassingArrayWithValue(b *testing.B) {
   for i := 0; i < b.N; i++ {
      withValue(arr)
   }
}

func BenchmarkPassingArrayWithRef(b *testing.B) {
   for i := 0; i < b.N; i++ {
      withReference(&arr)
   }
}

结果如下:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/array_struct
cpu: AMD Ryzen 7 6800H with Radeon Graphics
BenchmarkPassingArrayWithValue-16             70          16068289 ns/op
BenchmarkPassingArrayWithRef-16         1000000000               0.2346 ns/op

Amazing,结论失效了,即使我们什么都没干,也进行了参数复制,消耗了大量的时间。

这是为啥内?

这样子,在不清楚原理的情况下还是尽量传引用吧。

返回值

还有一个容易忽略的点的,函数的返回值应该传值还是传引用?返回值也是对值的复制,但是函数返回了就原函数就不会继续操作了,也就不必担心返回的值修改影不影响原来的函数。但是他们的性能还是有差别的,下面对于数组返回值和引用进行测试:

var array [1000000]int

func returnArr(arr *[1000000]int) [1000000]int {
   return *arr
}

func returnArrWithPtr(arr *[1000000]int) *[1000000]int {
   return arr
}

var d [1000000]int
var d2 *[1000000]int

func Benchmark_return_array_and_change(b *testing.B) {
   for i := 0; i < b.N; i++ {
      tmp := returnArr(&array)
      tmp[0]++
   }
}

func Benchmark_change_array_ptr_and_change(b *testing.B) {
   for i := 0; i < b.N; i++ {
      tmp := returnArrWithPtr(&array)
      tmp[0]++
   }
}

func Benchmark_return_array(b *testing.B) {
   for i := 0; i < b.N; i++ {
      d = returnArr(&array)

   }
}

func Benchmark_change_array_ptr(b *testing.B) {
   for i := 0; i < b.N; i++ {
      d2 = returnArrWithPtr(&array)
   }
}

结果如下:

goos: windows
goarch: amd64
pkg: learn/basic/prof/padding/param/return
cpu: AMD Ryzen 7 6800H with Radeon Graphics
Benchmark_return_array_and_change-16                 916           1400395 ns/op
Benchmark_change_array_ptr_and_change-16        1000000000               0.2484 ns/op
Benchmark_return_array-16                           1014           1198862 ns/op
Benchmark_change_array_ptr-16                   1000000000               0.4707 ns/op

结果和传参差不多,不过传值就算我们不再赋值,也会引发赋值。所以返回值传值还是传引用应该和传参数差不多。

还有一个刁钻的场景是返回值传值,但是函数调用没有接收返回值,也是不会参数参数复制的消耗的。因为不接收返回值,那就没必要再返回了,这个情况我测试了,不过没必要写在上面了。

总结

经过上面一系列测试,得到如下结论:

  • 首先根据业务场景确定传参还是传引用

  • 其次是性能上的

    • 基本数据类型、slice、map传参传引用都差不多
    • 数组、struct还是建议传引用
  • 最后,返回值的类型选取和参数选取方法一致

ps:有大哥知道上面特例的原因可以在评论区补充,谢谢