查看原文
其他

go-clone:深拷贝 Go 数据结构

Go开发大全 2021-08-09

(给Go开发大全加星标)

来源:杜欢

https://zhuanlan.zhihu.com/p/107216411

【导读】golang浅拷贝深拷贝的常识在编程中经常用到,本文对浅拷贝深拷贝做了详细介绍。

继续闲来写写码,这次来介绍一下半年多以前写的一个库,最近工作中发现真的有用,还是值得推荐一下的。

背景

这个库是 github.com/huandu/go-clone,主要用途是对任意的 Go 结构进行深拷贝,创造一个内容完全相同的副本,得到的值可通过 reflect.DeepEqual 检查。

这个功能看起来挺常用的,不过很奇怪在 Go 世界里面可用的实现却很少,在动手实现之前我调查了几个类似的库或者可用来做深拷贝:

  • encoding/gob 或 encoding/json:先将数据结构进行编码(gob.Encoder 或 json.Marshal),得到 []byte之后再解码(gob.Decoder 或 json.Unmarshal)。这种做法的好处是简单粗暴,基本上能够应对大部分的需求,缺点则是性能极低,且有各种限制,比如无法处理递归指针、无法处理 interface 类型数据,特别是 JSON,会丢失缺少大部分数据类型甚至精度。
  • github.com/jinzhu/copier 或 github.com/ulule/deepcopier:这两个库都实现了基本的 struct 拷贝能力,不过缺乏递归指针的处理,也不能作为通用的深拷贝来使用。

如果大家有看到其他类似功能的库,欢迎留言,我会去研究学习一下。当前所看到的几个库都不太能满足需求。

实现思路

要实现深拷贝函数 Clone(v interface{}) interface{},其基本思路很简单:

  • 首先通过函数 val := reflect.ValueOf(v) 拿到 v 的反射值;
  • 根据 val.Kind() 区分各种类型,主要分两种:一种是 scala 类型,即数值类型,包括各种整型、浮点、虚数、字符串等,直接返回原值即可;一种是复杂类型,每种类型用对应的反射方法来创建,包括 reflect.New/reflect.MakeMap/reflect.MakeSlice / reflect.MakeChan 等方法;
  • 通过各种反射方法来将新申请 val.Set* 方法将新值设置到新申请的变量里面。

这里面比较麻烦的是处理 struct,为了深拷贝结构,必须首先通过 val.NumField() 得到 struct field 个数,然后用循环不断的将 val.Field(i) 的值拷贝到新申请的变量对应字段里面去,这里递归调用深拷贝方法即可。

思路看起来很简单,似乎都是些体力活,但做了之后就会发现有一些特殊情况还得多加小心,真要实现好不容易。

处理递归数据

当我们使用循环链表的时候就会遇到递归数据。一个首尾相连的链表,如果一直跟着指针深拷贝所有数据,那么深拷贝函数一定会陷入死循环而无法退出。

下面是一个例子。

type ListNode struct {
    Data int
    Next *ListNode
}
node1 := &ListNode{
    Data: 1,
}
node2 := &ListNode{
    Data: 2,
}
node3 := &ListNode{
    Data: 3,
}
node1.Next = node2
node2.Next = node3
node3.Next = node1

其中 node1 \-> node2 \-> node3 \-> node1 \-> ... 形成了一个循环链表。

如果直接按照一般思路来实现 Clone,这个函数会因为不断的深度遍历 Next *ListNode 而陷入死循环,永远无法返回。

为了解决这个问题,应该使用经典的有向图检查环路的方法来实现,需要记下那些会产生循环的类型的访问记录,下次再访问到同样的数据时直接返回之前记录的结果即可打破循环。

可能会循环的类型其实不多,只有mapslice 和指针总共三种。这个事实可能会有点违反直觉:structinterface 都不会造成循环?这还真不会。

我们无法仅通过 struct 嵌套来构造出一个循环结构,这是无法通过编译器检查的。我们也无法通过 struct + interface 构造出循环结构,考虑以下代码:

type T struct {
    Loop interface{}
}

// 无法不使用 map、slice 和指针构造出循环结构。
t := T{}
t.Loop = t
t.Loop = t

fmt.Println(t == t.Loop.(T)) // false

// t 实际内容是:
// t == T{
//     Loop: T{
//         Loop: T{},
//     },
// }

可以看到,在 Go 里面并不能把 interface 当做一种万能指针,当我们将一个结构 T 赋值给 interface{} 时候,Go 内部会将 T 的内容拷贝一份再赋值,而不是「引用」T 的原值。

在实现环路检查时还会遇到一个问题:虽然检查循环的关键是发现访问了一个访问过的值,但问题是怎么才知道两个值相等呢?我们总不能用 reflect.DeepEqual 来检查吧,那就太浪费性能了。我们也不能使用 map[interface{}]struct{} 来判断,虽然 Go 允许用 interface{} 作为 mapKeyType,但 Go 编译器和运行时都不允许将 map 类型作为 KeyType,而可循环类型包含 map,所以还得找其他办法

m := map[interface{}]bool{}
key := map[int]string{}
m[key] = true // 可以被编译,但是运行时会 panic。

容易想到,同样的问题 reflect.DeepEqual 也会遇到,那么直接去看一下官方实现就能得到答案。

// During deepValueEqual, must keep track of checks that are
// in progress. The comparison algorithm assumes that all
// checks in progress are true when it reencounters them.
// Visited comparisons are stored in a map indexed by visit.
type visit struct {
    a1  unsafe.Pointer
    a2  unsafe.Pointer
    typ Type
}

func deepValueEqual(v1, v2 Value, visited map[visit]bool, depth int) bool {
    // 略……
}

继续看代码可以发现,官方使用的是 reflect.ValuePointer 方法来得到 unsafe.Pointer,恰好 mapslice 和指针都可以调用这个方法,因此我们可以用类似手法实现。需要注意的是,reflect.DeepEqual 需要判断 v1 / v2 是否不同,所以在 visit 里面同时记录了两个指针,但我们在 Clone 的时候只需要知道变量是否已经遍历过,所以只需要一个指针就可以。

同时,我们也不需要使用 unsafe.Pointer 来记录指针,直接使用 uintptr 即可。这是因为在 Clone 结束前,新申请的变量一定能被当前 goroutine 的 stack 访问到,不会被 GC。当前 Go 的 GC 也不支持内存移动,可预见的将来也不会支持这种能力,所以无需多此一举用 unsafe.Pointer 平添 GC 压力。

还有一个细节必须注意:通过反射拿到的 slice 内部指针只是 slice 第一个元素的地址,不足以区分不同长度的 slice,这会造成误判。reflect.DeepEqual 更注重的是数据「相等」,而不是精确「相同」,不做区分也没事,但 Clone 时候则必须区分同一个数组的不同 slice 的问题。

slice := []int{12345}
s1 := slice[:2]
s2 := slice[:5]
 
p1 := reflect.ValueOf(s1).Pointer()
p2 := reflect.ValueOf(s2).Pointer()
 
fmt.Println(p1 == p2) // true

综上,最后采用如下结构来记录访问过的值。

type visit struct {
    p     uintptr
    extra int
    t     reflect.Type
}

type visitMap map[visit]reflect.Value

这个 extra 里面存储的是 slice 的长度。

在记录时也需要注意,必须先将新数据放入 visitMap 然后再深度遍历和填充数值才行,否则依然会死循环。以指针为例,与 visitMap 相关的实现代码如下:

func clonePtr(v reflect.Value, visited visitMap) reflect.Value {
    t := v.Type()

    if visited != nil {
        visit := visit{
            p: v.Pointer(),
            t: t,
        }

        if val, ok := visited[visit]; ok {
            return val
        }
    }

    elemType := t.Elem()
    nv := reflect.New(elemType)

    if visited != nil {
        visit := visit{
            p: v.Pointer(),
            t: t,
        }
        visited[visit] = nv
    }

    // 省略填充 nv 内容的过程……

    return nv
}

最后,由于记录 visitMap 会有额外内存和性能损耗,而绝大多数数据结构并不会包含任何循环结构,所以 clone.Clone 默认不做任何循环检查,专门提供的 clone.Slowly 则负责解决这种复杂问题。

小结

以上内容已经可以帮助我们了解如何实现一个简单可用的深拷贝的工具库了, 这篇文章就暂时到此为止。

其实 github.com/huandu/go-clone 已经实现的功能远不止如此,还有不少硬核内容值得在未来继续分享。例如,如何深拷贝 struct 里面未导出的私有数据、如何极致优化深拷贝性能、如何实现 immutable struct 数据等。这些内容以后再分享吧。


 - EOF -

推荐阅读(点击标题可打开)

1、使用 kube-vip 管理K8S控制面高可用

2、快速理解 k8s 命名空间

3、云原生微服务 gRPC 如何实现负载均衡


Go 开发大全

参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。

关注后获取

回复 Go 获取6万star的Go资源库



分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存