判断go对象是否能直接赋值进行深拷贝

判断go对象是否能直接赋值进行深拷贝

golang中可以使用a := b这种方式将b赋值给a,只有当b能进行深拷贝时ab才不会互相影响,否则就需要进行更为复杂的深拷贝。

下面就是Go赋值操作的一个说明:
Go语言中所有赋值操作都是值传递,如果结构中不含指针,则直接赋值就是深度拷贝;如果结构中含有指针(包括自定义指针,以及切片,map等使用了指针的内置类型),则数据源和拷贝之间对应指针会共同指向同一块内存,这时深度拷贝需要特别处理。目前,有三种方法,一是用gob序列化成字节序列再反序列化生成克隆对象;二是先转换成json字节序列,再解析字节序列生成克隆对象;三是针对具体情况,定制化拷贝。前两种方法虽然比较通用但是因为使用了reflex反射,性能比定制化拷贝要低出2个数量级,所以在性能要求较高的情况下应该尽量避免使用前两者。

现在我需要判断某个对象是否可以直接用赋值进行深拷贝,如果不能直接进行深拷贝时,到底是哪个字段影响了深拷贝,下面就是判断的代码:

package main

import (
	"bytes"
	"fmt"
	"reflect"
)

type (
	PerA struct {
		A int
		B string
		c []byte
	}
	Per struct {
		PerA
		Name string
		Age  int
	}
	BarA struct {
		A string
		b *int
	}
	Bar struct {
		A int64
		BarA
	}
	CatA struct {
		name string
		age  int
	}
	Cat struct {
		name string
		age  int
		CatA
	}
)

func main() {
	var out bytes.Buffer
	ok := CanDeepCopy(Per{}, &out)
	fmt.Println(ok, out.String())

	out.Reset()
	ok = CanDeepCopy(Bar{}, &out)
	fmt.Println(ok, out.String())

	out.Reset()
	ok = CanDeepCopy(Cat{}, &out)
	fmt.Println(ok, out.String())

	bi := 1
	b0 := Bar{A: 1, BarA: BarA{A: "11", b: &bi}}
	b1 := b0
	b1.A, b1.BarA.A, *b1.BarA.b = 2, "22", 2
	fmt.Printf("%#v,%p,%d
", b0, &b0, *b0.BarA.b)
	fmt.Printf("%#v,%p,%d
", b1, &b1, *b1.BarA.b)

	c0 := Cat{name: "1", age: 1, CatA: CatA{name: "1", age: 1}}
	c1 := c0
	c1.name, c1.age, c1.CatA.name, c1.CatA.age = "2", 2, "2", 2
	fmt.Printf("%#v,%p
", c0, &c0)
	fmt.Printf("%#v,%p
", c1, &c1)
}

func CanDeepCopy(v any, path *bytes.Buffer) bool {
	t := reflect.TypeOf(v)
	if path.Len() == 0 {
		path.WriteString(t.Name()) // 记录首次对象名称
	}
	switch t.Kind() {
	case reflect.Pointer: // 指针可比较,但不能深拷贝
		path.WriteString(" is pointer") // 该字段为指针
		return false
	case reflect.Struct: // 结构体需要判断每一个字段
		path.WriteByte(".")
		for i, pn := 0, path.Len(); i < t.NumField(); i++ {
			tf := t.Field(i)
			path.WriteString(tf.Name) // 记录子字段名称
			// 构造一个该字段类型的对象,注意将指针换成值
			fv := reflect.New(tf.Type).Elem().Interface()
			if !CanDeepCopy(fv, path) {
				return false // 递归判断每个字段,包括匿名字段
			}
			path.Truncate(pn) // 回溯时截断没问题的子字段
		}
	}
	if t.Comparable() {
		return true
	}
	path.WriteString(" incomparable") // 该字段不可比较
	return false
}

运行结果:

false Per.PerA.c incomparable # 说明 Per.a.c.cc 字段属于不可比较字段导致不能深拷贝
false Bar.BarA.b is pointer   # 说明 Bar.BarA.b 字段是指针导致不能深拷贝
true Cat.  # 说明 Cat 对象可以直接进行深拷贝

# 由于 Bar 不可以深拷贝
# 可以看到 b1 := b0 之后,两个对象共用 BarA.b 指针指向对象,因此 *b1.BarA.b = 2 之后也影响了b0
main.Bar{A:1, BarA:main.BarA{A:"11", b:(*int)(0xc0000a6148)}},0xc0000a03e0,2
main.Bar{A:2, BarA:main.BarA{A:"22", b:(*int)(0xc0000a6148)}},0xc0000a0400,2

# 由于 Cat 可以深拷贝,因此 c1 := c0 之后这两个对象互不影响,这种对象直接赋值,不用其他方案进行深拷贝
main.Cat{name:"1", age:1, CatA:main.CatA{name:"1", age:1}},0xc0000bc5d0
main.Cat{name:"2", age:2, CatA:main.CatA{name:"2", age:2}},0xc0000bc600

通过研究go赋值逻辑,理解了深拷贝和浅拷贝的逻辑。实际上go的赋值操作只存在值拷贝,由于一些引用类型赋值的是地址导致两个变量共用内存数据才导致需要额外进行深拷贝处理。

同理可得函数传参也是赋值,因此值传递时对象不能自动深拷贝也需要特殊处理,看如下示例:

package main

import (
	"fmt"
)

func main() {
	err := test()
	if err != nil {
		panic(err)
	}
}

type TT struct {
	a int
	b *string
}

func test() error {
	as := "123"
	t := TT{a: 123, b: &as}
	fmt.Printf("t1 %#v,%p,%s
", t, &t, *t.b)
	a(t)
	fmt.Printf("t2 %#v,%p,%s
", t, &t, *t.b)
	return nil
}

func a(t TT) {
	fmt.Printf("a1 %#v,%p,%s
", t, &t, *t.b)
	*t.b = "456"
	fmt.Printf("a2 %#v,%p,%s
", t, &t, *t.b)
}

结果如下,很多人都以为函数参数为值传递时被调函数参数无法影响上层函数,看来这是错的:

t1 main.TT{a:123, b:(*string)(0xc00005a260)},0xc00005a270,123
a1 main.TT{a:123, b:(*string)(0xc00005a260)},0xc00005a2a0,123
a2 main.TT{a:123, b:(*string)(0xc00005a260)},0xc00005a2a0,456
t2 main.TT{a:123, b:(*string)(0xc00005a260)},0xc00005a270,456

如下所示值类型对象方法也是能够影响引用类型数据的:

package main

import (
	"fmt"
)

func main() {
	bs := "123"
	t := TT{a: 1, b: &bs}
	fmt.Printf("1 %#v,%p,%s
", t, &t, *t.b)
	t.A()
	fmt.Printf("2 %#v,%p,%s
", t, &t, *t.b)
	t.B()
	fmt.Printf("3 %#v,%p,%s
", t, &t, *t.b)
}

type TT struct {
	a int
	b *string
}

func (t TT) A() {
	*t.b = "A"
}

func (t TT) B() {
	*t.b = "B"
}

结果如下:

# 虽然 A() 和 B() 都是值对象函数,但是结构体中指针类型属于引用类型
1 main.TT{a:1, b:(*string)(0xc00005a260)},0xc00005a270,123
2 main.TT{a:1, b:(*string)(0xc00005a260)},0xc00005a270,A
3 main.TT{a:1, b:(*string)(0xc00005a260)},0xc00005a270,B

关于字符串的参数赋值:

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	s := "123"

	sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
	fmt.Printf("m1 %#v,%p,%v
", s, &s, sh.Data)
	a(s)

	b := []byte("456")
	s = *(*string)(unsafe.Pointer(&b))

	sh = (*reflect.StringHeader)(unsafe.Pointer(&s))
	fmt.Printf("m2 %#v,%p,%v
", s, &s, sh.Data)
	a(s)

	b[0] = "6" // 修改内存中的数据
	sh = (*reflect.StringHeader)(unsafe.Pointer(&s))
	fmt.Printf("m3 %#v,%p,%v
", s, &s, sh.Data)
	a(s)
}

func a(s string) {
	sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
	fmt.Printf("a %#v,%p,%v
", s, &s, sh.Data)
}

结论是,字符串传参实际底层数据是共用的,因为字符串不可变逻辑,因此这样更省内存:

m1 "123",0xc00005a260,18648789
a "123",0xc00005a280,18648789
m2 "456",0xc00005a260,824633827584
a "456",0xc00005a2b0,824633827584
m3 "656",0xc00005a260,824633827584
a "656",0xc00005a2e0,824633827584

另外还有一个关于错误处理的可比较特性的坑,因此强烈建议自定义error用指针,否则就得确保必须可比较:

package main

import (
	"errors"
	"fmt"
)

func main() {
	err := DoSomething(true)
	ok := errors.Is(err, ErrorA)
	fmt.Println(ok, err)

	err = DoSomething(false)
	ok = errors.Is(err, ErrorB)
	fmt.Println(ok, err)
}

type CustomError struct {
	Metadata map[string]string
	Message  string
}

func (c CustomError) Error() string {
	return c.Message
}

var (
	// ErrorA 包含不可比较字段,在 errors.Is 中
	ErrorA = CustomError{Message: "A", Metadata: map[string]string{"Reason": "A"}}
	ErrorB = &CustomError{Message: "B", Metadata: map[string]string{"Reason": "B"}}
)

func DoSomething(isA bool) error {
	if isA {
		return ErrorA
	}
	return ErrorB
}

引用
https://www.ssgeek.com/post/golang-jie-gou-ti-lei-xing-de-shen-qian-kao-bei/
https://sorcererxw.com/articles/go-comparable-type
https://blog.csdn.net/pengpengzhou/article/details/105839518
https://www.cnblogs.com/gtea/p/16850496.html

hmoban主题是根据ripro二开的主题,极致后台体验,无插件,集成会员系统
自学咖网 » 判断go对象是否能直接赋值进行深拷贝