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

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

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

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


引子


今天的文章从我周六加班改的一个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中,指针、slice、channel、interface、map、函数都是浅拷贝。最容易出问题的就是指针、切片、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

相关文章
|
7月前
|
存储 安全 Java
【Golang】(4)Go里面的指针如何?函数与方法怎么不一样?带你了解Go不同于其他高级语言的语法
结构体可以存储一组不同类型的数据,是一种符合类型。Go抛弃了类与继承,同时也抛弃了构造方法,刻意弱化了面向对象的功能,Go并非是一个传统OOP的语言,但是Go依旧有着OOP的影子,通过结构体和方法也可以模拟出一个类。
375 2
|
9月前
|
Cloud Native 安全 Java
Go:为云原生而生的高效语言
Go:为云原生而生的高效语言
569 1
|
9月前
|
Cloud Native Go API
Go:为云原生而生的高效语言
Go:为云原生而生的高效语言
579 0
|
9月前
|
Cloud Native Java Go
Go:为云原生而生的高效语言
Go:为云原生而生的高效语言
405 0
|
9月前
|
Cloud Native Java 中间件
Go:为云原生而生的高效语言
Go:为云原生而生的高效语言
453 0
|
9月前
|
Cloud Native Java Go
Go:为云原生而生的高效语言
Go:为云原生而生的高效语言
504 0
|
9月前
|
数据采集 Go API
Go语言实战案例:多协程并发下载网页内容
本文是《Go语言100个实战案例 · 网络与并发篇》第6篇,讲解如何使用 Goroutine 和 Channel 实现多协程并发抓取网页内容,提升网络请求效率。通过实战掌握高并发编程技巧,构建爬虫、内容聚合器等工具,涵盖 WaitGroup、超时控制、错误处理等核心知识点。
|
编译器 Go
揭秘 Go 语言中空结构体的强大用法
Go 语言中的空结构体 `struct{}` 不包含任何字段,不占用内存空间。它在实际编程中有多种典型用法:1) 结合 map 实现集合(set)类型;2) 与 channel 搭配用于信号通知;3) 申请超大容量的 Slice 和 Array 以节省内存;4) 作为接口实现时明确表示不关注值。此外,需要注意的是,空结构体作为字段时可能会因内存对齐原因占用额外空间。建议将空结构体放在外层结构体的第一个字段以优化内存使用。
|
运维 监控 算法
监控局域网其他电脑:Go 语言迪杰斯特拉算法的高效应用
在信息化时代,监控局域网成为网络管理与安全防护的关键需求。本文探讨了迪杰斯特拉(Dijkstra)算法在监控局域网中的应用,通过计算最短路径优化数据传输和故障检测。文中提供了使用Go语言实现的代码例程,展示了如何高效地进行网络监控,确保局域网的稳定运行和数据安全。迪杰斯特拉算法能减少传输延迟和带宽消耗,及时发现并处理网络故障,适用于复杂网络环境下的管理和维护。
|
11月前
|
开发框架 JSON 中间件
Go语言Web开发框架实践:路由、中间件、参数校验
Gin框架以其极简风格、强大路由管理、灵活中间件机制及参数绑定校验系统著称。本文详解其核心功能:1) 路由管理,支持分组与路径参数;2) 中间件机制,实现全局与局部控制;3) 参数绑定,涵盖多种来源;4) 结构体绑定与字段校验,确保数据合法性;5) 自定义校验器扩展功能;6) 统一错误处理提升用户体验。Gin以清晰模块化、流程可控及自动化校验等优势,成为开发者的优选工具。