Go语言Append缺陷引发的深度拷贝讨论

开发 后端
今天的文章从我周六加班改的一个bug引入,上下文是在某个struct中有个Labels切片,在组装数据的时候需要为其加上配置变量中的标签。

[[414910]]

看完苏炳添进入总决赛,看得我热血沸腾的,上厕所都不敢耽搁超过 5 分钟。

这历史性的一刻,让本决定休息的我,垂死病中惊坐起,开始肝文章。

  • 引子
  • 何谓浅?何谓深?
  • 深拷贝的四种方式
  • 手写拷贝函数
  • json序列化反序列化
  • gob序列化反序列化
  • 基准测试(性能测试)
  • 小结

引子

今天的文章从我周六加班改的一个bug引入,上下文是在某个struct中有个Labels切片,在组装数据的时候需要为其加上配置变量中的标签。

大家看看会出现什么问题。

  1. for i := range m{ 
  2.     m[i].Labels = append(r.Config.Relabel, m[i].Labels...) 
  3.     ... 

debug发现,i=0时正常,但第二次乃至第n次会不断变更之前m[?].Labels的内容。

看了append的源码,原来当容量足够的时候,append会把数据直接添加到第一个参数的切片里。

改为如下代码,调换下了位置,一切正常了。

  1. m[i].Labels = append(m[i].Labels,r.Config.Relabel...) 

这是一个隐含的陷阱,在 go 语言中赋值拷贝往往都是浅拷贝,开发者很容易不小心忽视这一点,导致这种无法预料的问题出现,以后要多多注意了。

借由这个问题以及上一篇文章的作业中,提到的深度拷贝问题展开今天的文章。

何谓浅?何谓深?

我多年以前是做c++的,它的对象拷贝是浅拷贝,原理是调用了默认的拷贝构造函数,需要人为的重写,进行拷贝的过程,特别是指针需要谨慎的生成的释放,来避免内存泄露的发生。

后来接触了Python 发现深浅拷贝的问题在后端语言中都是存在的,Go 也不例外。

浅拷贝对于值类型是完全拷贝一份,而对于引用类型是拷贝其地址。也就是拷贝的对象修改引用类型的变量同样会影响到源对象。

这就是为什么channel在做参数传递的时候,向内部写入内容,接收端可以成功收到的原因。

在Go中,指针、slice、channel、interface、map、函数都是浅拷贝。最容易出问题的就是指针、切片、map这三种类型。

方便的点是作为参数传递不需要取地址可以直接修改其内容,只要函数内部不出现覆盖就不需要返回值。

但作为结构体中的成员变量,在拷贝结构体后问题就暴露出来了。修改一处导致另一处也变了。

深拷贝的四种方式

有一次和女朋友聊到深拷贝的问题,她告诉我最方便的深拷贝方法就是序列化为json再反序列化。

我听到这种方案,顿时惊为天人,确实挺省事的,但由于序列化会用到反射,效率自然不会太高。

深拷贝有四种方式

  • 1、手写拷贝函数
  • 2、json序列化反序列化
  • 3、gob序列化反序列化
  • 4、使用反射

github上的开源库,大多基于 1、4 两种方式做的优化。这里的反射方法后面再做讨论。

我的github https://github.com/minibear2333/ 后续会专门写一个组件,提供深度拷贝的各种现成的方式。

手写拷贝函数

定义一个包含切片、字典、指针的结构体。

  1. type Foo struct { 
  2.  List   []int 
  3.  FooMap map[string]string 
  4.  intPtr *int 

手动拷贝函数,把它取名为Duplicate

  1. func (f *Foo) Duplicate() Foo { 
  2.  var tmp = Foo{ 
  3.   List:   make([]int, 0, len(f.List)), 
  4.   FooMap: make(map[string]string), 
  5.   intPtr: new(int), 
  6.  } 
  7.  copy(tmp.List, f.List) 
  8.  for i := range f.FooMap { 
  9.   tmp.FooMap[i] = f.FooMap[i] 
  10.  } 
  11.  if f.intPtr != nil { 
  12.   *tmp.intPtr = *f.intPtr 
  13.  } else { 
  14.   tmp.intPtr = nil 
  15.  } 
  16.  return tmp 
  • 函数内部初始化结构体
  • copy是标准库自带的拷贝函数
  • map只能range来拷贝,这里map为nil不会报错
  • 指针使用前必须判空,为指针的指向赋值,而不能覆盖指针地址

测试

  1. func main() { 
  2.  var a = 1 
  3.  var t1 = Foo{intPtr: &a} 
  4.  t2 := t1.Duplicate() 
  5.  a = 2 
  6.  fmt.Println(*t1.intPtr) 
  7.  fmt.Println(*t2.intPtr) 

输出说明深拷贝成功

json序列化反序列化

这种方式完成深度拷贝非常简单,但必须结构体加上注解,而且不允许出现私有字段

  1. type Foo struct { 
  2.  List   []int             `json:"list"
  3.  FooMap map[string]string `json:"foo_map"
  4.  IntPtr *int              `json:"int_ptr"

提供一个直接的方案

  1. func DeepCopyByJson(dst, src interface{}) error { 
  2.  b, err := json.Marshal(src) 
  3.  if err != nil { 
  4.   return err 
  5.  } 
  6.  err = json.Unmarshal(b, dst) 
  7.  
  8.  return err 
  • 其中src和dst是同一种结构体类型
  • dst使用时必须取地址,因为要给地址指向的数据变更新值

用法,我省略了错误处理

  1. a = 3 
  2. t1 = Foo{IntPtr: &a} 
  3. t2 = Foo{} 
  4. _ = DeepCopyByJson(&t2, t1) 
  5. fmt.Println(*t1.IntPtr) 
  6. fmt.Println(*t2.IntPtr) 

输出

gob序列化反序列化

这是一种标准库提供的编码方法,类似于protobuf,Gob(即 Go binary 的缩写)。类似于 Python 的pickle和 Java 的Serialization。

在发送端编码,接收端解码。

  1. func DeepCopyByGob(dst, src interface{}) error { 
  2.  var buffer bytes.Buffer 
  3.  if err := gob.NewEncoder(&buffer).Encode(src); err != nil { 
  4.   return err 
  5.  } 
  6.  return gob.NewDecoder(&buffer).Decode(dst) 

用法

  1. func DeepCopyByGob(dst, src interface{}) error { 
  2.  var buffer bytes.Buffer 
  3.  if err := gob.NewEncoder(&buffer).Encode(src); err != nil { 
  4.   return err 
  5.  } 
  6.  return gob.NewDecoder(&buffer).Decode(dst) 

输出

基准测试(性能测试)

这三种方式我分别写了基准测试的测试用例,go会自动反复调用,直到测算出一个合理的时间范围。

基准测试代码,这里仅写一个,其他两个函数的测试方式类似:

  1. func BenchmarkDeepCopyByJson(b *testing.B) { 
  2.  b.StopTimer() 
  3.  var a = 1 
  4.  var t1 = Foo{IntPtr: &a} 
  5.  t2 := Foo{} 
  6.  b.StartTimer() 
  7.  for i := 0; i < b.N; i++ { 
  8.   _ = DeepCopyByJson(&t2, t1) 
  9.  } 

运行测试

  1. $ go test -test.bench=. -cpu=1,16  -benchtime=2s 
  2. goos: darwin 
  3. goarch: amd64 
  4. pkg: my_copy 
  5. cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz 
  6. BenchmarkFoo_Duplicate          35887767                62.64 ns/op 
  7. BenchmarkFoo_Duplicate-16       37554250                62.56 ns/op 
  8. BenchmarkDeepCopyByGob            104292             22941 ns/op 
  9. BenchmarkDeepCopyByGob-16         103060             23049 ns/op 
  10. BenchmarkDeepCopyByJson          2052482              1171 ns/op 
  11. BenchmarkDeepCopyByJson-16       2057090              1175 ns/op 
  12. PASS 
  13. ok      my_copy 17.166s 
  • 在mac环境下单核和多核并没有明显差异
  • 运行速度快慢,手动拷贝方式 > json > gob
  • 拷贝方式都相差了 2 个数量级

小结

如果是偶尔使用的程序可以使用json序列化反序列化的方式进行拷贝,但是除了慢以外还有一个缺陷,就是无法拷贝私有成员变量。

如果是频繁拷贝的程序,建议使用手动拷贝方式进行拷贝,而且可以定制化拷贝的过程。甚至可以完成不同结构体之间,字段细微差异的定制化需求。

PS:内置copy和reflect.copy都只支持切片或数组的拷贝,内置copy速度是反射方式的两倍以上。

拓展资料

  • Go 语言使用 Gob 传输数据 http://c.biancheng.net/view/4597.html)
  • 内建copy函数和reflect.Copy函数的区别 https://studygolang.com/topics/13523/comment/43357
  • 基准测试 https://segmentfault.com/a/1190000016354758

 本文转载自微信公众号「机智的程序员小熊」,可以通过以下二维码关注。转载本文请联系机智的程序员小熊公众号。

 

责任编辑:武晓燕 来源: 机智的程序员小熊
相关推荐

2024-03-08 09:25:18

.NET深度拷贝浅拷贝

2021-06-08 07:45:44

Go语言优化

2021-07-28 07:53:21

Go语言拷贝

2021-07-08 23:53:44

Go语言拷贝

2012-04-09 09:53:56

2013-07-24 15:29:24

思科Sourcefire思科收购Sourcef

2023-12-15 14:38:00

GoRust编程语言

2020-05-07 11:00:24

Go乱码框架

2010-03-01 16:38:08

Linux分区方案

2021-05-12 08:53:54

Go语言调度

2009-11-27 16:07:10

2009-12-29 17:21:24

Ubuntu 8.04

2009-12-29 16:59:17

Ubuntu Vist

2014-05-29 10:54:20

C++构造函数

2010-07-20 10:14:22

苹果天线门

2011-11-02 09:04:15

Node.js

2012-10-08 09:25:59

GoGo语言开发语言

2009-12-24 11:31:52

Linux显卡驱动

2021-06-03 09:40:34

Linux命令拷贝

2022-09-26 00:00:01

Go语言函数
点赞
收藏

51CTO技术栈公众号