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

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

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

  • 引子
  • 何谓浅?何谓深?
  • 深拷贝的四种方式
  • 手写拷贝函数
  • json序列化反序列化
  • gob序列化反序列化
  • 基准测试(性能测试)
  • 小结
  • 拓展资料
  • 往期精彩回顾

引子

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

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

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

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

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

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

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

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

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

何谓浅?何谓深?

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

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

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

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

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

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

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

深拷贝的四种方式

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

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

深拷贝有四种方式

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

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

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

手写拷贝函数

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

type Foo struct {
 List   []int
 FooMap map[string]string
 intPtr *int
}

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

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

测试

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

输出说明深拷贝成功

2
1

json序列化反序列化

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

type Foo struct {
 List   []int             `json:"list"`
 FooMap map[string]string `json:"foo_map"`
 IntPtr *int              `json:"int_ptr"`
}

提供一个直接的方案

func DeepCopyByJson(dst, src interface{}) error {
 b, err := json.Marshal(src)
 if err != nil {
  return err
 }
 err = json.Unmarshal(b, dst)

 return err
}
  • 其中srcdst是同一种结构体类型
  • dst使用时必须取地址,因为要给地址指向的数据变更新值

用法,我省略了错误处理

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

输出

3
3

gob序列化反序列化

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

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

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

用法

a = 4
t1 = Foo{IntPtr: &a}
t2 = Foo{}
_ = DeepCopyByGob(&t2, t1)
fmt.Println(*t1.IntPtr)
fmt.Println(*t2.IntPtr)

输出

4
4

基准测试(性能测试)

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

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

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

运行测试

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

小结

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

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

PS:内置copyreflect.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

原文地址:https://cloud.tencent.com/developer/article/1860585

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340