亲宝软件园·资讯

展开

goalng 结构体 方法集 接口

lambdang 人气:0

一 前序

很多时候我们以为自己懂了,但内心深处却偶有困惑,知识是严谨的,偶有困惑就是不懂,很幸运通过大量代码的磨练,终于看清困惑,并弄懂了。

本篇包括结构体,类型, 及 接口相关知识,希望对大家有所启发。

二 事出有因

搞golang也有三四个年头了,大小项目不少,各种golang书籍资料也阅无数,今天突然被一个报错搞懵了。演示代码如下:

type MyErr struct{}
func(this MyErr)Error()string{
    return "myerr"
}
func main(){
    var Err *MyErr
    errors.As(MyErr{},Err) //这一句
}

errors.As是标准库里的判断错误类型的一个简单函数,按照如上写法他运行报错,报错内容如下:

panic: errors: target must be a non-nil pointer
goroutine 1 [running]:
errors.As({0x107e280, 0x11523f8}, {0x104f3e0, 0x11523f8})
        D:/GO/src/errors/wrap.go:84 +0x3e5
github.com/pkg/errors.As(...)
        D:/GO/gopath/pkg/mod/github.com/pkg/errors@v0.9.1/go113.go:31
main.main()
        H:/information/demo1/main.go:19 +0x31
exit status 2

errors.As 方法签名

func As(err error, target interface{}) bool

起初我没有太关心报错结果,我第一感觉是指针类型实现接口有问题,于是又改实现方法,又折腾变量,有时候ide提示方法未实现,有时候运行报错,偶有成功,为啥成功我也不知道。

突然我发现我对接口一直都停留在会用的基础上,所有结构体方法接受者都用指针,所有结构体实例都用指针,一方面保证接口方法都能实现,另一方面减少对象拷贝,减少内存用量。

于是带着这个问题开始了刨根问题。在查阅资料中又发现了新的问题。

type T struct{}
func (t T) Get() {
	fmt.Println("this is Get")
}
func (t *T) Set() {
	fmt.Println("this is set")
}
func main() {
	var a T
	a.Set()
	a.Get()
	(&a).Get()
	(&a).Set()
}

三 结构体与实例的数据结构

1. 结构体类型

结构体就是一个模板,用于生成实例用的,包括最基本的属性集,值的方法集,指针方法集。

type T struct{
    Num int
}
func (t T) Get() int{
	fmt.Println("this is Get")
        return t.Num
}
func (t *T) Set(i int) {
	fmt.Println("this is set")
        t.Num = i
}

这就是一个定义的结构体。

func (t T) Get() 该方法的接受者 t是一个实例值,所以该方法称为值方法。

func (t *T) Set() 该方法的接受者 t 是一个指针,所以该方法成为指针方法。

2. 实例

实例就是结构体实例化后的变量,用T类型说明。

    var a T
    var b *T
    var c = T{1}
    var d = &T{1}

这四种实例定义发生了什么?数据结构如何?

实例数据结构主要包括三部分。

实例a是一个空结构体实例,其特点是a虽然没有显示赋值,但是会默认创建一个a实例,其中的属性都是"类型零值"。

实例b是一个指针类型,特点是没有被初始化,指针未任何实例。

实例c是一个显示赋值的实例,和a区别就是Num初始化值不再是"类型零值",而是1。

实例d就有点复杂了,他会有个实例及指针两种数据,指针指向实例。实例初始化非"类型零值"。

关于图中地址的说明,所有数据结构最终都是内存中的一段连续代码,都有开始地址,其他需要使用该数据的地方都是通过该地址找到这段内存信息的。当然要说到代码,内存,虚拟地址,堆栈,程序运行,会有很多内容,这里只要知道通过地址能找到该数据信息即可。

注意,上图也仅仅只是示意图,帮助理解。其中类型指针实现并不是一个真的指针而是一个关于类型元信息的偏移地址。

3 方法调用

结合上面的图,说一下方法调用问题。为啥值方法和指针方法都可以调用所有方法,并且都能成功,并且修改都可以成功。

	a.Get()
	a.Set(2)
	// b.Get() 编译器通过 运行不通过
	b.Set(3)
	c.Get()
	c.Set(4)
	d.Get()
	d.Set(5)

3.1 方法表达式

实例的方法调用的本质是函数,类似python,编译器调用该函数时候默认的第一个参数是实例值或者实例指针。

T.Get(a)

(*T).Set(b,2)

通过类型直接调用类型中的函数,这就是方法表达式调用。真实的实例调用,也是通过找到类型并调用类型的方法。关于"方法表达式"这个词出自《go语言核心编程》第三章,类型系统,有兴趣的可以看看。

方法表达式有个特点,就是不会被自动转换,通过方法表达式可以清楚知道值方法集或指针方法集是否有该方法。

在没有说到接口之前,判断一个方法是否属于方法集用这个方法表达式是比较方便的。

3.2 值实例调用所有方法

a和c本质是一样的,只是初始值不一样。拿c做例子进行讲解。

c.Get() == T.Get(a)

上边代码这个不用解释太多,就是c实例通过类型信息找到相关的值方法进行调用。

c.Set() == (&c).Set(4) == (*T).Set(c,4)

上边代码 c中对应的T中方法并不包含Set方法。

T.Set() 你会发现编译器会报错 T中没有Set方法

但*T中有方法Set,这时候编译器会生成一个*c,指针对象,在通过该对象调用Set方法。虽然通过指针对象调用Set但确实把c对象中的Num修改成功了,因为指针指向的正是c实例。如下图:

这就是为啥实例方法集中没有Set方法,也可以调用Set方法,编译器进行了自动转换,而这样设计是合理的,通过Set操作,c实例中的Num确实变成4,符合预期。

3.3 指针实例调用所有方法

b和d都是指针实例,看看上图关于b和d的数据结构示意图,这两个图里最大的区别就是有没有匿名实例,b因为是空指针没有指向任何实例,所以只有类型信息。

编译器知道你是个指针,查看类型中的所有方法,包括值方法和指针方法,有Set和Get所以编译通过,但是在运行的时候,因为是空指针,无法找到值的方法Get,所以运行时候报错 panic: errors: target must be a non-nil pointer

d因为指向一个实例,所以顺着这个实例找到Get方法进行调用,这都是编译器自动进行的。

d.Get() == (&d).Get() == (T).Get(*d)

通用使用方法表达式,也可以知道指针方法集中是没有Get方法的。

(*T).Get() 编译器不会通过 说明指针方法集中确实没有Get函数 所以只能通过转化成实例来调动Get方法

这种自动转化及操作的结果也是符合预期的,拿到了d指针指向的实例的数据。

3.4 空指针无法调用值方法

在回过头看最初的err问题,原因就出在给了一个空指针,要通过一个空指针找到一个值方法,但是运行时候无法找到,所以panic了

四 接口

正常情况下,值实例还是指针实例都可以调用所有方法,并且修改都可以成功,那为什么要区分值的方法集和指针的方法集,这就不得不提接口。

方法集是给接口准备。

方法集是"符合预期"的。

可以说因为接口的需要才会有方法集概念,只有接口中的方法与方法集中的方法相匹配时候,该方法集的实例才是该接口的实现实例。

可是问题又来了,明明一个实例对象不管是指针还是非指针实例都可以执行全部的方法,技术上完全可以实现,为什么还要区分指针非指针方法?这是因为"不符合预期",为什么,为什么"不符合预期",看下边解释。

1 接口数据结构

要说明白接口和方法集的关系不是一件容易的事,先从接口结构说起。

接口类型跟struct类型不同,字面上看,接口只有方法头,没有属性。

接口实例跟一般的struct实例也不一样,它是一种动态的实例,只有接口实例被具体实例(值或指针)赋值的时候,接口实例才能确定。如下图。

接口实例跟结构体实例类似,也包括两部分,值和类型。

接口中的值是动态的,当被具体结构体实例赋值时候才能确定该值。该值就是结构体实例的值的拷贝,当实例是非指针时候会把数据都拷贝过来,当是实例是指针时候会把指针拷贝过来。golang中一切赋值都是拷贝,包括接口赋值,也是因为拷贝才会有很多"不符合预期的"结果。

接口中的类型包括动态类型和自身的接口类型,自身类型没啥好说的,看上图就明白了,主要是动态类型,这个是存储了当前赋值的结构体实例的类型。

2 接口赋值

以下面的接口赋值代码进行说明解释。

package main
type I interface {
	Get() int
	Set(i int)
}
type T struct {
	Num int
}
func (t T) Get() int {
	return t.Num
}
func (t *T) Set(num int) {
	t.Num = num
}
func main() {
	var a T
	var b *T
	var c = T{}
	var d = &T{}
	var ia I = a //编译不通过 方法集不匹配
	var ib I = b //编译通过 运行会报错 panic: runtime error: invalid memory address or nil pointer dereference
	var ic I = c //编译不通过 方法集不匹配
	var id I = d
}

例子代码很简单,就是一个接口类型I,一个struct类型T,其实现了值Get方法,指针Set方法。

上边代码中a,b,c,d已经在上部分进行过讲解了。

ia,ib,ic,id赋值过程如下图:

值方法集

ia,ic接口对象其实在编辑阶段IDE就会给出报错提示,实例和接口不匹配,因为a和c实例方法集中只有一个Get函数,可以通过前边提到的"表达式方法"进行验证,这里通过IDE提示也知道缺少Set函数。

那么问题来了,在第一部分单独a,c对象是可以调用所有方法,这里接口实现为啥要弄出个方法集进行限制?因为"拷贝"和"不符合预期"。

假设a,c可以成功赋值给接口ia,ic,赋值后a,c中的数据会拷贝到接口的动态值区域,要是成功执行了Set函数,将接口动态值区域的数据进行了修改,那原来的a,c中的数据并未改变,这个是"不符合预期的"。所以干脆就不允许这么操作。

更常用的"不符合预期"解释代码是当接口是参数值时候。如下代码。

func DoT(a I) {
	a.Set(11)
}
func main(){
    ...
    DoT(ic)
    fmt.Println(ic.Get())
}

DoT函数用I做参数,内部对I进行了操作,用ic或者ia做参数,如果可以成功,最后打印ic或者ia中的值,并未改变,这不符合预期,很令人困惑。这段原理可参考<<go核心编程>>第三章类型系统相关描述。

指针方法集

ib和id都是指针类型,其方法集包括所有方法,即Get和Set,其中Get是通过编译器自动转化进行间接调用,值实例不允许调用指针实例的方法集是因为"不符合预期",那指针实例就允许调用值实例的方法了?是的,允许,因为"符合预期"。

还用下面的代码做解释。

func DoT(a I) {
    a.Set(a.Get()++)
}
func main(){
    ...
    DoT(id)
    fmt.Println(id.Get())
}

这里用id做参数,最终执行完,结果id确实增加了1,符合预期。

结合前边接口赋值的图进行分析,接口动态值区域拷贝了一份id的指针值,这个指针指向一个具体的实例。如下图。

从这里可以看出对id的任何操作其实都是对具体的实例进行的操作,所以无论读写都是符合预期的,所以当使用指针调用Get方法时候就会进行自动转化调用值的Get方法。

至于ib为啥编译通过,运行时候就报错,也是因为指针是个nil值,无法自动转化找到Get方法。

总结

翻了好几天资料,本来想把嵌入类型和反射都写进来,但是时间有点仓促,大家可以结合上边的讲解,自行对嵌入类型和反射进行研究,基础原理都一样。

这里总结一下:

实例都包括两部分,值和类型,编译器正是通过实例类型所以才知道了其方法集。

单独实例使用时候,是允许调用所有方法的,调用非自身方法集时候编译器会自动进行转换,并且都会调用成功,符合预期。

实例赋值给接口时候,是把实例信息拷贝到接口中的,其数据结构和原来实例完全不一样了,同时接口会严格检查方法集,以防止不符合预期行为发生。

实例是指针时候,并且为空的时候,并且包含非指针方法时候,无论是该实例的接口还是该实例,都不能进行任何方法调用,否则会有运行时panic发生。未指向任何具体数据变量,无论读写肯定报错。

接口断言知道为啥一定要是接口才能进行断言吧,因为接口的动态值和动态类型要进行动态填充,接口断言也可以判断一个实例的方法集,而且是安全的判断

_,ok:=interface{}(a).(I)

判断一个实例是否有哪个方法,方法集中的方法有哪些,目前看可以通过三种方法"方法表达式"","接口赋值","接口断言"。

其实还有好多知识点比如nil类型,空接口,空指针,相互比较时候真假结果,嵌入结构体方法集,反射操作,等等,只要把原理搞清了都很容易理解的。

加载全部内容

相关教程
猜你喜欢
用户评论