Go的深拷贝库:go-clone
文章目录
背景
这个库是 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)
的值拷贝到新申请的变量对应字段里面去,这里递归调用深拷贝方法即可。
思路看起来很简单,似乎都是些体力活,但做了之后就会发现有一些特殊情况还得多加小心,真要实现好不容易。
处理递归数据
当我们使用循环链表的时候就会遇到递归数据。一个首尾相连的链表,如果一直跟着指针深拷贝所有数据,那么深拷贝函数一定会陷入死循环而无法退出。
下面是一个例子。
|
|
其中 node1 -> node2 -> node3 -> node1 -> ...
形成了一个循环链表。
如果直接按照一般思路来实现 Clone,这个函数会因为不断的深度遍历 Next *ListNode 而陷入死循环,永远无法返回。
为了解决这个问题,应该使用经典的有向图检查环路的方法来实现,需要记下那些会产生循环的类型的访问记录,下次再访问到同样的数据时直接返回之前记录的结果即可打破循环。
可能会循环的类型其实不多,只有map、slice 和指针总共三种。 这个事实可能会有点违反直觉:struct 和 interface 都不会造成循环?这还真不会。
我们无法仅通过 struct 嵌套来构造出一个循环结构,这是无法通过编译器检查的。我们也无法通过 struct + interface 构造出循环结构,考虑以下代码:
|
|
可以看到,在 Go 里面并不能把 interface 当做一种万能指针,当我们将一个结构 T 赋值给 interface{} 时候,Go 内部会将 T 的内容拷贝一份再赋值,而不是「引用」T 的原值。
在实现环路检查时还会遇到一个问题:虽然检查循环的关键是发现访问了一个访问过的值,但问题是怎么才知道两个值相等呢?我们总不能用 reflect.DeepEqual 来检查吧,那就太浪费性能了。我们也不能使用 map[interface{}]struct{}
来判断,虽然 Go 允许用 interface{}
作为 map 的 KeyType,但 Go 编译器和运行时都不允许将 map 类型作为 KeyType,而可循环类型包含 map,所以还得找其他办法
|
|
容易想到,同样的问题 reflect.DeepEqual 也会遇到,那么直接去看一下官方实现就能得到答案。
|
|
继续看代码可以发现,官方使用的是 reflect.Value 的 Pointer 方法来得到 unsafe.Pointer,恰好 map、slice 和指针都可以调用这个方法,因此我们可以用类似手法实现。需要注意的是,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 的问题。
|
|
综上,最后采用如下结构来记录访问过的值。
|
|
这个 extra 里面存储的是 slice 的长度。
在记录时也需要注意,必须先将新数据放入 visitMap 然后再深度遍历和填充数值才行,否则依然会死循环。以指针为例,与 visitMap 相关的实现代码如下:
|
|
最后,由于记录 visitMap 会有额外内存和性能损耗,而绝大多数数据结构并不会包含任何循环结构,所以 clone.Clone 默认不做任何循环检查,专门提供的 clone.Slowly 则负责解决这种复杂问题。
提升深拷贝效率
前文所说的思路和方案,对于熟悉反射的开发者来说非常容易想到,属于常规解法了。不过我们都知道,反射的执行效率有限,对于字段较多的结构来说,深拷贝的效率会远低于浅拷贝。考虑到 Go 数据结构并不存在副作用,对于普通的数值类型可以直接浅拷贝,对于指针、接口、map 等类型也可以先浅拷贝再替换成新内容,这种拷贝方法会比通过反射来拷贝高效很多。
考虑以下数据结构。
|
|
如果我们手动进行深拷贝,最高效的方法如下所示:
|
|
这里需要注意,B string 是可以直接浅拷贝的,在 Go 里面约定 string 是不可变(immutable)的,其中引用的字符串不会被轻易修改,甚至字面常量都放在了只读的内存空间中来确保真正的不可变。
深拷贝私有字段
在这个例子里面暗含了一个「彩蛋」:本来不可见的私有字段(unexported field)d 也被「顺便」深拷贝了。
如果按照反射的方法,所有私有字段都不可写(私有字段的 reflect.Value 中 CanSet 方法始终返回 false), 从而也不能在深拷贝的时候写入数据,但现在使用这种先浅拷贝再深拷贝的方法会造成私有字段也被拷贝,假设其中包含指针之类类型,那么这个指针就会还指向老的数据结构,并没有真正达成深拷贝的目标,会造成潜在的问题。
想要提升拷贝效率,就得考虑怎么样才能完美的拷贝所有私有字段才行,要想做到这点就得了解 Go 数据结构的内存布局细节。
Go 为了能方便的与底层系统进行互操作,在数据结构的内存布局方面保持了非常严格的顺序性,使得我们可以有机会直接通过偏移量来得知每个字段,包括私有字段,在内存中的真实位置,借助 unsafe 库提供的不安全内存访问的能力就可以修改任意的私有字段。
下面这个例子展示了如何纯粹通过 reflect 和 unsafe 来深拷贝一个私有字段的指针。
|
|
很显然上面这段代码非常的折腾,涉及不少 Go runtime 层面上的概念和技巧,我们来逐段仔细看一下。为了方便叙述,上面的代码里直接假定我们已经知道 p 是个 []int,这样就不用写大量 switch…case 判断 Kind,让本来就挺难理解的代码变得更难读了。如果希望看到完整的类型判断逻辑,可以参考源码 https://github.com/huandu/go-clone/blob/v1.1.2/clone.go#L291。
首先,能拷贝私有字段的前提是,我们可以通过 reflect 库读到私有字段的类型定义和数据,只读不能写,假如读都读不到,那就一点办法都没有了。
其次,在拿到字段 p 的类型信息 fieldP 之后,我们就可以轻松通过 field.Type 得知 p 的类型,从而可以通过 Kind 来区分不同类型的不同代码逻辑。在 fieldP 里面有个非常关键点字段 fieldP.Offset,它表示 p 相对结构指针的头部的偏移量。
下面这个等式是始终正确的。
|
|
知道 p 真实内存位置之后就能做很多事情了,比如直接进行内存拷贝。同理,由于 slice 数据的内存是连续的,一旦知道了真实的内存地址之后也可以直接进行数据拷贝,完成 slice 内容的复制。
具体内存拷贝的方法就是下面这段代码。
|
|
它的原理是:先将 dst 和 src 这样的 unsafe.Pointer 强制转成 [math.MaxInt32]byte
类型,然后再对这个伪装的数组进行 slice 操作,将要拷贝的内容切出来生成合法的 []byte,最后交给 copy 来拷贝数据。
最后,还需要注意 slice 结构本身并不是一个指针,而是包含了几个字段的结构,具体定义放在 reflect.SliceHeader 这里。
减少反射使用的次数
反射使用过多就影响效率,我们可以看到,业务中大多数 Go 数据结构的字段类型都是数值类型(比如各种 int、float 等),特别是那些只包含数值类型的 Go 数据结构,简单的做一次浅拷贝就能完成所有工作,这样处理肯定比每次都用 reflect 遍历所有字段进行逐一拷贝来得快很多。
可以想到,如果能预先缓存类型信息,仅仅标记出类型中必须进行深拷贝的字段就好了,这样每个类型至多只做一次反射,剩下的拷贝就可以完全交给各种 unsafe 内存操作就好了。
在当前实现中,我们定义了一个类型 type structType,用于记录结构里面需要进行深拷贝的字段信息,没有记录在内的字段信息就是可以浅拷贝的数值字段。
|
|
具体生成这个类型数据的代码放在 https://github.com/huandu/go-clone/blob/v1.1.2/structtype.go#L51 这里,思路比较简单直接:遍历结构的每个字段,判断是否是数值类型,如果不是,生成 structFieldType 并放入到 structType 的 PointerFields 里面。
由于 Go 数据结构的类型定义不会在运行时进行修改,为了避免经常重复的分析一个类型,实际中我们用了一个 sync.Map 类型的全局变量 cachedStructTypes 类记录历史分析结果。
定义特殊的数值类型结构体
有一些 Go 结构体,看起来像是包含了指针需要深拷贝,但实际上应该始终当做值类型来使用。
这里面有下面几个典型的类型,我们正常使用的时候在函数中都是传值,而不是传指针:
- time.Time 表示时间,这里面虽然有一个 loc *time.Location 字段,但实际上不需要拷贝,这个 loc 指向的是一段只读的内容。
- reflect.Value 表示反射值,这里面有比较复杂的指针信息,但由于这个值仅仅是一个实际类型的「代理」,深度拷贝这个数据并无实际意义。
相信在业务代码中也可能会有类似的数值类型结构体,为了能争取处理这些情况,代码里提供了一个 MarkAsScalar 的函数,将这些类型统统加入到一个全局白名单里面,凡是这些类型的字段都会被看做简单的数值进行浅拷贝。
此外,还有 reflect.Type 这个特殊的 interface 也需要单独处理,它实际是 *reflect.rtype 类型,这个类型指向程序的只读内存空间,也不应该深度拷贝。不过由于它的独特性,没有任何一个类型与之相似,代码中就直接进行了特殊处理。
拷贝函数指针
经过上面的探索,基本解决了大部分的深拷贝问题,但实际中发现还有一个非常难啃的硬骨头,即函数指针的拷贝。
如果我们有下面的数据结构:
|
|
这个 fn 本质上是一个指针,考虑到函数本身在运行时是只读的,一般情况下简单当做 uintptr 拷贝值就好了。
但凡是都有万一,当遇到下面这种非常复杂的情况时,浅拷贝并不可行。
|
|
很显然,在拷贝 m 的时候必须遍历这个 map 所有元素,拿到 interface{} 具体值再进行拷贝。由于 reflect 接口的限制,在这个场景中我们无法拿到 T4 的内存位置,只能用老方法去遍历每个字段进行逐一拷贝,正常情况下一切都没问题,但很不幸的是,唯独只有当 fn 指向一个绑定了 receiver 的方法的时候,reflect.Value 的 Pointer 方法返回的地址是个假地址,这导致我们无法正确拷贝 fn 的值。
Go runtime 里面相关代码如下:
|
|
可以看到,当 flagMethod 标记设上时,即上面所说的这种 fn 的值, Pointer 方法不会诚实的返回函数指针。 Go 这样设计很可能是为了掩盖 runtime 在函数指针上做的 trick:一般来说,fn 指向一个函数指针,即代码段的内存位置,但 Go 为了能让函数指针绑定 receiver, 在这种情况下 fn 会指向一个包含了上下文信息的结构,reflect 为了在这种状况下依然让调用者感觉 fn 是一个代码段地址就做了这个伪装。
关于 Go runtime 怎么实现函数指针,详见官方文档 https://golang.org/s/go11func。
为了解决这个问题,我们重新思考了这个细节的拷贝策略。如果拿不到真实的值同时又不想过度依赖 Go runtime 的具体实现,还有一个简单可行的方法是使用 reflect.Value 的 Set 方法直接设置值(回到最原始的方案),但这里面有个前提是字段必须 CanSet,且设置进去的值不能是私有字段。
以下是 Go reflect 库的相关代码:
|
|
因此,为了能够正常的调用 Set 方法,我们不得不拿出终极大招,直接篡改 reflect.Value 里面的标志位,关键就是去掉 fn 这个私有字段的 flagRO 标志位。
直接去 hack reflect.Value 的私有字段是不靠谱的,未来太难以维护,考虑到我们可以相对安全的浅拷贝 interface{} ,可以通过一个空接口进行中转而间接的去掉这个标记位且不破坏其他的标记。
|
|
至此,我们就可以正常的通过 Set 来设置这个反射值了,最后再将复制出来的值通过内存操作拷贝到结构里面去即可。
使用方法
包clone提供了深度克隆任何 Go 数据的功能。它还提供了一个包装器来保护指针免受任何意外的变化。
Clone/Slowly也可以克隆未导出的字段和“无复制”结构。明智地使用此功能。
安装
使用go get安装该软件包。
|
|
Clone 和 Slowly
如果我们想克隆任何 Go 值,请使用Clone.
|
|
出于性能考虑,Clone不处理包含指针循环的值。如果我们需要克隆这些值,请Slowly改用。
func Clone
|
|
递归深度克隆v为新值。它假设 v 中没有指针循环,例如 v 有一个指向 v 本身的指针。如果存在指针循环,请使用 Slowly。
Clone 分配内存并以深度优先的顺序在 v 内部深度复制值。以下类型有一些特殊规则。
- 标量类型:所有类似数字的类型都按值复制。
- func:按值复制,因为 func 在运行时是一个不透明的指针。
- 字符串:按值复制,因为字符串在设计上是不可变的。
- unsafe.Pointer:按值复制,因为我们不知道里面有什么。
- chan:创建一个新的空 chan,因为我们无法读取旧 chan 中的数据。 与许多其他包不同,Clone 能够克隆任何结构体的未导出字段。明智地使用此功能。
func Slowly
|
|
Slowly递归地将v 深度克隆到一个新值。它在内部标记所有克隆的值,因此它可以使用循环指针克隆 v。
Slowly与Clone完全相同。有关更多详细信息,请参阅克隆文档。
Example
|
|
Output:
|
|
将结构类型标记为标量
一些结构类型可以被视为标量。
一个众所周知的案例是time.Time。虽然time.Time中有一个loc *time.Location
指针,我们总是用time.Time的值。克隆time.Time时,返回一个浅拷贝副本应该是可以的。
目前,以下类型默认标记为标量。
- time.Time
- reflect.Value
如果内置包中定义的任何类型应视为标量,请打开新问题让我知道。我会更新默认值。
如果有任何自定义类型应视为标量,则调用MarkAsScalar手动标记它。
func MarkAsScalar
|
|
MarkAsScalar 将 t 标记为标量类型,以便所有克隆方法都将按值复制 t。如果 t 不是结构体或指向结构体的指针,MarkAsScalar 将忽略 t。
在大多数情况下,没有必要显式调用它。如果 struct 类型仅包含标量类型字段,则该结构将自动标记为标量。
以下是默认标记为标量的类型列表:
|
|
Example
|
|
Output:
|
|
将指针类型标记为不透明
一些指针值用作可枚举的常量值。
一个众所周知的案例是elliptic.Curve
。在crypto/tls
包中,通过将值与预定义的曲线值进行比较来检查证书的曲线类型,例如elliptic.P521()。在这种情况下,作为指针或结构体的曲线值不能被深度克隆。
目前,以下类型默认标记为标量。
- elliptic.Curve,即
*elliptic.CurveParam
或elliptic.p256Curve
。 - reflect.Type,在
*reflect.rtype
中定义runtime。
如果有任何自定义指针类型应该被认为是不透明的,请调用MarkAsOpaquePointer
手动标记它。
func MarkAsOpaquePointer
|
|
Example
|
|
Output:
|
|
克隆sync和sync/atomic中定义的“no-copy”类型
有一些“no-copy”类型,如sync.Mutex、atomic.Value等。它们不能通过将所有字段一一复制来克隆,但我们可以分配一个新的零值并调用方法来进行适当的初始化。
目前,所有“不复制”类型的定义sync,并sync/atomic能正常使用以下策略来克隆。
- sync.Mutex:克隆值是新分配的零互斥锁。
- sync.RWMutex:克隆值是新分配的零互斥锁。
- sync.WaitGroup:克隆值是新分配的零等待组。
- sync.Cond: 克隆值是一个带有新分配的零锁的 cond。
- sync.Pool: 克隆值是一个具有相同New功能的空池。
- sync.Map:克隆值是具有克隆键/值对的同步映射。
- sync.Once: 克隆值是具有相同完成标志的一次类型。
- atomic.Value: 克隆值是具有相同值的新原子值。
如果内置包中定义的任何类型应视为“no-copy”类型,请打开新issue让我知道。我会更新默认值。
设置自定义克隆功能
如果默认克隆策略不适用于结构类型,我们可以调用SetCustomFunc以实现自定义克隆逻辑。 Clone并且Slowly可以在自定义克隆函数中使用。
有关更多详细信息,请参阅SetCustomFunc 示例代码。
Wrap,Unwrap和Undo
clone包提供Wrap/Unwrap函数来保护指针值免受任何意外变化。当我们想要保护一个设计为不可变的变量时,它很有用,例如全局配置、存储在上下文中的值、发送到 chan 的值等。
|
|
func Undo
|
|
Undo放弃对包装值所做的任何更改。如果 v 不是包装值,则什么也不会发生。
func Unwrap
|
|
如果 v 是一个包装值,则 Unwrap 返回 v 的原始值。否则,简单地返回 v 本身。
func Wrap
|
|
Wrap 创建 v 的包装器,它必须是一个指针。如果 v 不是指针,Wrap 只返回 v 并且什么都不做。
包装器是 v 值的深度克隆。它在内部保存了 v 的影子副本。
|
|
Example:
|
|
Output:
|
|
转载
文章作者 Forz
上次更新 2021-06-08