Go 方法调用与接口

在比较C++和Go的时候,通常会说到Go不支持继承和多态,但通过组合和接口实现了类似的语言特性。总结一下Go不支持的原因:(1) 首先struct是值类型,赋值和传参都会复制全部内容。struct的内存布局跟C几乎一致,没有任何附加的object信息,比如指向虚函数表的指针。(2)其次Go不支持隐式的类型转换,因此用基类的指针指向子类会编译错误。

Go程序抽象的基本原则依赖于接口而不是实现,优先使用组合而不是继承。

struct的方法调用

对象的方法调用相当于普通函数调用的语法糖。Value方法的调用m.Value()等价于func Value(m M) 即把对象实例m作为函数调用的第一个实参压栈,这时m称为receiver。通过实例或实例的指针其实都可以调用所有方法,区别是复制给函数的receiver不同。

通过实例m调用Value时,以及通过指针p调用Value时,receiver是m和*p,即复制的是m实例本身。因此receiver是m实例的副本,他们地址不同。通过实例m调用Pointer时,以及通过指针p调用Pointer时,复制的是都是&m和p,即复制的都是指向m的指针,返回的都是m实例的地址。

1
2
3
4
5
6
7
8
9
10
11
type M struct {
a int
}
func (m M) Value() string {return fmt.Sprintf("Value: %p\n", &m)}
func (m *M) Pointer() string {return fmt.Sprintf("Pointer: %p\n", m)}
var m M
p := &m // p is address of m 0x2101ef018
m.Value() // value(m) return 0x2101ef028
m.Pointer() // value(&m) return 0x2101ef018
p.Value() // value(*p) return 0x2101ef030
p.Pointer() // value(p) return 0x2101ef018

如果想在方法中修改对象的值只能用pointer receiver,对象较大时避免拷贝也要用pointer receiver。

方法集理解

上面例子中通过实例m和p都可以调用全部方法,由编译器自动转换。在很多go的语法书里有方法集的概念。类型T方法集包含全部receiver T方法,类型*T包含全部receiver T和*T的方法。这句话一直不理解,既然通过实例和指针可以访问T和*T的所有方法,那方法集的意义是什么。

定义在M类型的方法除了通过实例和实例指针访问,还可以通过method expression的方式调用。这时Pointer对M类型就是不可见的。

1
2
3
4
(M).Value(m)       // valid
(M).Pointer(m) // invalid M does not have Pointer
(*M).Value(&m) // valid
(*M).Pointer(&m) // valid

再解释一下method value的receiver复制的问题。这里u.Test返回的类型类似于闭包返回的FuncVal类型,也就是FuncVal{method_address, receiver_copy}对象。因此下面例子中mValue中已经包含了实例u的副本。当然如果Test方法的receiver是*User,结果将不一样。

1
2
3
4
5
u := User{1}     // User{Id int}
mValue := u.Test // func(s User) Test() {fmt.Println(s.Id)}
u.Id = 2
u.Test() // output: 2
mValue() // output: 1

匿名字段与组合

Go没有继承,但是有结构体嵌入。当一个类型T被匿名的嵌入另一类型M时,T的方法也就会拷贝到M的方法表当中。这时根据方法集的规则,如果M包含的是*T,则M包含T与*T上所有的方法。

通过匿名字段,Go实现了类似继承的复用能力,并且可以在M上定义相同的方法名实现override

interface接口实现

Go的interface是一种内置类型,属于动态风格的duck-typing类型。接口作为方法签名的集合,任何类型的方法集中只要拥有与之对应的全部方法,就表示它实现了该接口。

interface底层结构

interface是一个结构体,包含两个成员。根据interface是否包含方法,底层又分为两个结构体。eface主要是保存了类型信息,以后总结反射时具体讲,这里先总结带方法的iface。结构体定义在runtime2.go显然iface由两部分组成,data域保存元数据,tab描述接口。

1
2
3
4
5
6
7
8
type eface struct {
_type *_type
data unsafe.Pointer
}
type iface struct {
tab *itab
data unsafe.Pointer
}
1
2
3
4
5
6
7
8
9
10
11
12
13
type itab struct {
inter *interfacetype // 保存该接口的方法签名
_type *_type // 保存动态类型的type类型信息
link *itab // 可能有嵌套的itab
bad int32
unused int32
fun [1]uintptr // 保存动态类型对应的实现
}

type interfacetype struct {
type _type
mhdr []imethod
}

为了理解iface的数据结构,找到一个唐老鸭接口接口的例子,通过gdb看看iface的数据到底是什么。首先dd=&DonalDuck{}这个类型的方法集包括MakeFun Walking Speaking 它实现了DuckActor两个接口。

这时如果dd=DonalDuck{} 则没有实现Duck和Actor接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Duck interface {
GaGaSpeaking()
OfficialWalking()
}
type Actor interface {
MakeFun()
}
type DonaldDuck struct {
height uint
name string
}
func (dd *DonaldDuck) GaGaSpeaking() { fmt.Println("DonaldDuck gaga") }
func (dd *DonaldDuck) OfficialWalking() { fmt.Println("DonaldDuck walk") }
func (dd *DonaldDuck) MakeFun() { fmt.Println("DonaldDuck make fun") }
func main() {
dd := &DonaldDuck{10, "tang lao ya"}
var duck Duck = dd
var actor Actor = dd
duck.GaGaSpeaking()
actor.MakeFun()
dd.OfficialWalking()
}

可以看出来当dd赋值给接口Duck后,接口duck的data域保存的地址就是dd对象指向的地址。tab域的inter字段里保存了实现这个接口的两个方法声明,其中name保存了方法的名字。tab域的func指针指向了具体实现,即这个符号对应的代码段.text地址。

具体T类型到Iface的转换涉及到3个内容的复制 (1) iface的tab域的func字段保存T类型的方法集,即对tab域inter声明的方法的实现。(2) iface的data域指针指向用于赋值的对象的副本。(3) iface的tab域的_type字段保存T类型的_type。

编译期检测

当T类型没有实现I接口中所有方法时,从T到I的赋值将抛出TypeAssertionError编译错误。检查的方法在函数additab当中,即查看T类型的_type方法表uncommentType是否包含了I接口interfacetype中所有的imethod,同时将T类型对方法的实现拷贝到tab的func指向的表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type _type struct {
size uintptr
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32
_unused uint8
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
gcdata *byte
_string *string
x *uncommontype
ptrto *_type
zero *byte // ptr to the zero value for this type
}
type uncommontype struct {
name *string
pkgpath *string
mhdr []method
}

三张方法表的区别

1) 每个具体T类型type结构对应的方法表是uncommontype,类型的方法集都在这里。reflect包中的Method和MethodByName方法都是通过查询这张表实现的。表中每一项都是method

1
2
3
4
5
6
7
8
type method struct {
name *string
pkgpath *string
mtyp *_type
typ *_type
ifn unsafe.Pointer
tfn unsafe.Pointer
}

2) itab的interfacetype域是一张方法表,它声明了接口所有的方法,每一项都是imethod,可见它没有实现只有声明

1
2
3
4
5
type imethod struct {
name *string
pkgpath *string
_type *type
}

3) itab的func域也是一张方法表,表中每一项是一个函数指针,也就是只有实现没有声明。即赋值的时候只是把具体类型的实现,即函数指针拷贝给了itab的func域。

运行时 ConvT2I

Go-internals分析了go编译器在编译期生成的语法树节点。在T2I的转换时,通过getitab产生了中间状态itab。并且调用convT2I完成了运行时数据data域的内存拷贝,以及中间状态itab到tab域的赋值。

可以看到getitab完成了T类型的方法表的实现地址到tab的fnc[0]的赋值。完成getiab需要T类型的_type信息,以及I接口类型的interfacetype方法表,这些都是编译期提供的。因此接口的动态性和反射的实现都是以编译期为运行时提供的类型信息为基础的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
..
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+
uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys))
m.inter = inter
m._type = typ
...
for k := 0; k < ni; k++ {
for ; j < nt; j++ {
*(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn
}
goto nextimethod
}
// didn't find method
if !canfail {
panic(&TypeAssertionError{"", *typ._string, *inter.typ._string, *iname})
}
return m
}

最后的convT2I存在数据的内存拷贝,可见data域是T类型对象的一个拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
func convT2I(t *_type, inter *interfacetype, cache **itab, 
elem unsafe.Pointer, x unsafe.Pointer) (i fInterface) {
tab := (*itab)(atomicloadp(unsafe.Pointer(cache)))
...
if x == nil {
x = newobject(t)
}
typedmemmove(t, x, elem)
pi.tab = tab
pi.data = x
return
}

总结 将对象赋值给接口时,编译期检查对象是否实现了接口所有的方法。运行时将对象的数据、类型、实现拷贝到iface接口当中。