likes
comments
collection
share

Go 结构体(其三):结构体比较

作者站长头像
站长
· 阅读数 4

Go 语言中结构体的比较,重点在于确定结构体之间的相等性,而不是大小比较。尽管某些情况下可能需要比较结构体的大小,这取决于具体的应用场景和需求。然而,在一般情况下,我们更关注结构体之间的相等性。讨论Go结构体的比较时,必须考虑到结构体的语义和应用背景。而在深入讨论之前,我们先要了解结构体类型的零值,这将有助于我们更好地理解结构体之间的比较。

1. 结构体的"零值"

在Go语言中,当我们声明变量但未显式初始化时,变量会被赋予默认值。这个默认值即为对应类型的零值:布尔类型为 false,数值类型为 0,字符串类型为空字符串"",而指针、函数、接口、切片、通道和映射类型则为nil[1]

Go 数据类型的零值如下:

类型零值
整数类型0
浮点数类型0
byte0
rune0
布尔类型false
字符串类型""
复数类型(0+0i)
指针类型nil
切片类型nil
映射类型nil
管道类型nil
接口类型nil
函数类型nil
数组类型每个元素都是其类型的零值
结构体类型每个字段都是其类型的零值

对于结构体,由于它是一种复合类型,所以它的零值与其他基本数据类型的零值不同,结构体的零值并非是一个特定的预定义值,而是在创建结构体时,根据每个字段的类型确定的默认值。这意味着结构体的零值是由其各个字段类型的零值组成,而不是简单地由每个字段的零值直接组合而成的。下面是一个例子:

type Student struct {
    Name  string
    Sex   string
    Age   int
    Class int
}

// main:
var s Student
fmt.Printf("StructVariable=%#v\n", s) 

// output: StructVariable=main.Student{Name:"", Sex:"", Age:0, Class:0}

在这个例子中我们可以看到Student的零值是一个具有空字符串和零值整数的复合结构。如果结构体的字段本身是结构体类型,则这些字段的零值也是它们相应类型的零值,如下:

type Monitor struct{
    ClassID uint64
    GradeID *uint64
    Student
}

// main:
var m Monitor
fmt.Printf("StructVariable=%#v\n", m) 

// output: StructVariable=main.Monitor{ClassID:0x0, GradeID:nil, Student:main.Student{Name:"", Sex:"", Age:0, Class:0}}

2. 结构体的比较

在许多编程语言中,包括Go语言,不同类型的变量之间是不能直接比较的。在比较时变量时,比较操作符(如==、!=、> 等)通常要求操作数的类型相同或可以隐式转换为相同类型。如果两个变量的类型不同,编译器无法确定如何比较它们,因为它们可能具有不同的内部表示和语义。

结构体的比较也受到同样的限制,不同结构体类型之间可能有着不同的内部结构和语义,编译器难以确定如何正确地对它们进行比较,因此,不同结构体类型也不能进行直接比较。

// 不同类型不能比较
type Student struct {
    Name  string
    Sex   string
    Age   int
    Class int
}

type Monitor struct{
    ClassID uint64
    GradeID *uint64
    Student
}

// main:
var (
    s Student
    m Monitor
)
ok := s == m
// output: invalid operation: s == m (mismatched types Student and Monitor)

此外,在 Go 语言中,若结构体包含了不可比较类型的字段时,则结构体也无法直接比较。不可比较类型是指那些不能直接使用比较操作符(<、<=、>、>=、==和!=)进行比较的类型,Go 的不可比较类型有slicemapfunc[2]

// 含有不可比较字段
type Student struct {
	Fn    func()
	Ch    chan int
	Slice []int
}
// main:
var (
   s1,s2 Student
)
ok := s1 == s2

// output: invalid operation: s1 == s2 (struct containing func() cannot be compared)

综上所知,结构体的比较通常是在不包含不可比较类型的相同结构体类型的结构体之间进行的。而结构体的比较过程可以类比两个相同类型相同长度的数组之间的比较,需要依次比较各个同名字段的类型和值是否相同。若所有字段的类型和值都匹配,那么就意味着两个结构体是相等的;倘若有一个字段不匹配,则认为两个结构体不相等。

下面我们看一个例子,来比较初始化后的结构体s1与"零"值的结构体s2

type Student struct {
    Name  string
    Sex   string
    Age   int
    Class int
}

// main:
var s = Student{
    Name:  "Ayanokoji",
    Sex:   "man",
    Age:   18,
    Class: 1,
}
ok := s == Student{}
// output: false

在这个例子中,Student类型不包含不可比较类型的字段,因此其实例之间可以进行比较。当我们将实例s与一个"零"值结构体(即Student{})进行比较时,编译器会逐个比较它们的字段值。在这种情况下,由于s实例的字段值与零值结构体的字段值不完全相同,因此比较的结果是 false。

结构体的"零"值比较问题

针对特定的使用场景,我们有时会考虑将结构体类型更改为指针形式。这样做的好处在于,我们不再需要逐一比较结构体的所有字段是否为对应的零值,而只需判断结构体的指针是否为nil即可。这种方法不仅方便了零值判断,还可以减少因元素复制而带来的开销[3]

例如,假设我们有以下代码片段:

var s = &Student{
    Name:  "Ayanokoji",
    Sex:   "man",
    Age:   18,
    Class: 1,
}

ok := s == nil
// output: false

在这个例子中,我们通过检查指向结构体的指针s是否为nil来判断结构体是否被初始化,这种方式简洁而有效。然而,值得注意的是,将结构体类型更改为指针形式并不是适用于所有情况的通用解决方案。在某些情况下,使用指针可能会增加代码的复杂性,并引入空指针异常等问题。因此,在选择是否使用指针时,我们应该综合考虑代码的性能需求以及潜在的错误。

3. 深度相等

Go语言的 reflect 包提供了DeepEqual(x, y interface{}) bool函数,用于深度比较两个变量的相等性。这个函数在比较结构体时会递归地比较它们的字段,包括字段的类型和值等。即使结构体原本是不可比较的,DeepEqual函数也能够准确比较它们。举例来说,如果结构体的字段包含切片、映射或其他不可比较类型的结构体,DeepEqual函数依然会递归地比较它们的成员,只要字段类型和值都相同,DeepEqual函数就会返回 true。

func DeepEqual(x, y any) bool {
	if x == nil || y == nil {
		return x == y
	}
	v1 := ValueOf(x)
	v2 := ValueOf(y)
	if v1.Type() != v2.Type() {
		return false
	}
	return deepValueEqual(v1, v2, make(map[visit]bool))
}

在深度相等的比较中,DeepEqual函数会优先判断两个变量xy是否为nil以及其类型是否相等,若都不为nil且类型相等,才会真正进入深度比较,调用deepValueEqual函数。

deepValueEqual函数使用了一个映射map[visit]bool来跟踪已经遍历过的值,在进行深度比较时记录已经比较过的值的指针和它们的类型。这样做的目的是为了避免在深度比较过程中出现循环引用或无限递归的情况(例如:有环的数据结构),从而保证比较的正确性和结束[4]。该映射的键类型为visit,它是一个结构体,包含了指针和类型信息,如下:

type visit struct {
	a1  unsafe.Pointer	// v1
	a2  unsafe.Pointer	// v2
	typ Type	// 用于标识比较值的类型
}

下面我们开始正式了解deepValueEqual函数。该函数的核心是一个switch语句,它会识别输入参数的类型,并根据不同类型合理地递归调用deepValueEqual函数。这种递归处理方式主要适用于复合类型(如结构体、切片、数组等)、指针以及接口,且该递归调用会一直至到最基本的数据类型[5]。 因此,deepValueEqual函数的递归调用是对所有类型进行深度比较的关键。通过递归调用,函数可以比较复杂数据结构的各个部分,并最终得出它们是否深度相等的结果。

在该深度比较中,对于基本类型的比较会使用比较运算符==来直接比较它们的值,以确定它们是否相等。如下是deepValueEqual函数基本类型的比较内容:

func deepValueEqual(v1, v2 Value, visited map[visit]bool) bool {
    if !v1.IsValid() || !v2.IsValid() {
		return v1.IsValid() == v2.IsValid()
	}
	if v1.Type() != v2.Type() {
		return false
	}
    ...
    
	switch v1.Kind() {
	...
	case Int, Int8, Int16, Int32, Int64:
		return v1.Int() == v2.Int() // 转换为 int64 进行比较
        
	case Uint, Uint8, Uint16, Uint32, Uint64, Uintptr:
		return v1.Uint() == v2.Uint() // 转换为 uint64 进行比较
        
	case String:
		return v1.String() == v2.String()
	case Bool:
		return v1.Bool() == v2.Bool()
	case Float32, Float64:
		return v1.Float() == v2.Float() // 转换为 float64 进行比较
        
	case Complex64, Complex128:
		return v1.Complex() == v2.Complex() // 转换为 complex128 进行比较
	...
}

Go语言中,指针和接口类型具有动态性,其具体类型在程序运行时可能会发生变化,但它们指向的值或者包含的值在运行时是静态的。因此,在进行深度比较时,通常会直接比较指针和接口类型指向的具体值。而在反射包中,也提供了可以获取指针指向的具体值的方法Elem()

由于指针类型可能指向任意类型的数据,甚至是复合类型,因此需要调用深度比较函数对指针指向的值进行比较。接口类型同理,它可能包含任意类型的值,因此我们同样使用Elem()方法来获取接口持有的具体值,并递归调用深度比较函数以进一步比较其内容。如下:

func deepValueEqual(v1, v2 Value, visited map[visit]bool) bool {
    ...
    
	switch v1.Kind() {
	...
	case Interface:
		if v1.IsNil() || v2.IsNil() {
			return v1.IsNil() == v2.IsNil()
		}
		return deepValueEqual(v1.Elem(), v2.Elem(), visited)
        
	case Pointer:
		if v1.UnsafePointer() == v2.UnsafePointer() {
			return true
		}
		return deepValueEqual(v1.Elem(), v2.Elem(), visited)
	...
}

对于复合类型而言,它们往往是由多种基本类型或者其他复合类型组成的。例如,一个结构体可能包含各种不同类型的字段,一个切片可能包含多种元素类型,一个映射可能将不同类型的值关联在一起。由于复合类型的灵活性,其结构可能会相当复杂。因此,在进行深度比较时,我们需要逐个比较复合类型中的每个成员。这意味着我们需要递归地遍历复合类型的所有层级,直到比较到最基本的数据类型为止。具体代码如下:

func deepValueEqual(v1, v2 Value, visited map[visit]bool) bool {
    ...
	switch v1.Kind() {
	case Array:
		for i := 0; i < v1.Len(); i++ {
			if !deepValueEqual(v1.Index(i), v2.Index(i), visited) {
				return false
			}
		}
		return true
        
	case Slice:
		if v1.IsNil() != v2.IsNil() {
			return false
		}
		if v1.Len() != v2.Len() {
			return false
		}
		...
		for i := 0; i < v1.Len(); i++ {
			if !deepValueEqual(v1.Index(i), v2.Index(i), visited) {
				return false
			}
		}
		return true
        
	case Struct:
		for i, n := 0, v1.NumField(); i < n; i++ {
			if !deepValueEqual(v1.Field(i), v2.Field(i), visited) {
				return false
			}
		}
		return true
        
	case Map:
		if v1.IsNil() != v2.IsNil() {
			return false
		}
		if v1.Len() != v2.Len() {
			return false
		}
		...
		for _, k := range v1.MapKeys() {
			val1 := v1.MapIndex(k)
			val2 := v2.MapIndex(k)
			if !val1.IsValid() || !val2.IsValid() || !deepValueEqual(val1, val2, visited) {
				return false
			}
		}
		return true  
	...
}

以上就是对于深度相等的了解,它通过反射获取目标变量信息,并将其与待比较的变量进行比较,一旦有所差异就会直接返回 false。对于复合类型而言,这种比较是递归地比较其每个成员。因此,在使用函数DeepEqual比较结构体时,我们实际上就是在比较所有对应成员是否相等,从而准确判断两个结构体类型的实例是否相等。

深度比较中对于函数类型的比较

在Go语言中,函数类型的比较是一项相对复杂的任务,因为函数是一种引用类型,且其实现和内容几乎不可比较。Go 1.20 官方版本的处理如下:

func deepValueEqual(v1, v2 Value, visited map[visit]bool) bool {
    ...
	switch v1.Kind() {
    ...
     case Func:
		if v1.IsNil() && v2.IsNil() {
			return true
		}
		// Can't do better than this:
		return false   
	...
}

在给定的代码片段中,我们可以看到针对函数类型的比较处理:如果两个函数值都是nil,则被视为相等;否则,返回false。因为在此情况下,由于函数的动态性和实现的不可比较性,我们无法深入函数的内容进行比较。因此,对于函数类型的深度比较,我们只能检查它们是否都为nil,而无法进一步比较它们的内容。