golang拾遗:指针和接口

这是本系列的第一篇文章,golang拾遗主要是用来记录一些遗忘了的、平时从没注意过的golang相关知识。想做本系列的契机其实是因为疫情闲着在家无聊,网上冲浪的时候发现了zhuihu上的go语言爱好者周刊和Go 101,读之如醍醐灌顶,受益匪浅,于是本系列的文章就诞生了。拾遗主要是收集和golang相关的琐碎知识,当然也会对周刊和101的内容做一些补充说明。好了,题外话就此打住,下面该进入今天的正题了。

指针接口

golang的类型系统其实很有意思,有意思的地方就在于类型系统表面上看起来众生平等,然而实际上却要分成普通类型(types)和接口(interfaces)来看待。普通类型也包含了所谓的引用类型,例如slice和map,虽然他们和interface同为引用类型,但是行为更趋近于普通的内置类型和自定义类型,因此只有特立独行的interface会被单独归类。

那我们是依据什么把golang的类型分成两类的呢?其实很简单,看类型能不能在编译期就确定以及调用的类型方法是否能在编译期被确定。

如果觉得上面的解释太过抽象的可以先看一下下面的例子:

package main import "fmt" func main(){ m := make(map[int]int) m[1] = 1 * 2 m[2] = 2 * 2 fmt.Println(m) m2 := make(map[string]int) m2["python"] = 1 m2["golang"] = 2 fmt.Println(m2) }

首先我们来看非interface的引用类型,m和m2明显是两个不同的类型,不过实际上在底层他们是一样的,不信我们用objdump工具检查一下:

go tool objdump -s 'main\.main' a TEXT main.main(SB) /tmp/a.go a.go:6 CALL runtime.makemap_small(SB) # m := make(map[int]int) ... a.go:7 CALL runtime.mapassign_fast64(SB) # m[1] = 1 * 2 ... a.go:8 CALL runtime.mapassign_fast64(SB) # m[2] = 2 * 2 ... ... a.go:10 CALL runtime.makemap_small(SB) # m2 := make(map[string]int) ... a.go:11 CALL runtime.mapassign_faststr(SB) # m2["python"] = 1 ... a.go:12 CALL runtime.mapassign_faststr(SB) # m2["golang"] = 2

省略了一些寄存器的操作和无关函数的调用,顺便加上了对应的代码的原文,我们可以清晰地看到尽管类型不同,但map调用的方法都是相同的而且是编译期就已经确定的。如果是自定义类型呢?

package main import "fmt" type Person struct { name string age int } func (p *Person) sayHello() { fmt.Printf("Hello, I'm %v, %v year(s) old\n", p.name, p.age) } func main(){ p := Person{ name: "apocelipes", age: 100, } p.sayHello() }

这次我们创建了一个拥有自定义字段和方法的自定义类型,下面再用objdump检查一下:

go tool objdump -s 'main\.main' b TEXT main.main(SB) /tmp/b.go ... b.go:19 CALL main.(*Person).sayHello(SB) ...

用字面量创建对象和初始化调用堆栈的汇编代码不是重点,重点在于那句CALL,我们可以看到自定义类型的方法也是在编译期就确定了的。

那反过来看看interface会有什么区别:

package main import "fmt" type Worker interface { Work() } type Typist struct{} func (*Typist)Work() { fmt.Println("Typing...") } type Programer struct{} func (*Programer)Work() { fmt.Println("Programming...") } func main(){ var w Worker = &Typist{} w.Work() w = &Programer{} w.Work() }

注意!编译这个程序需要禁止编译器进行优化,否则编译器会把接口的方法查找直接优化为特定类型的方法调用:

go build -gcflags "-N -l" c.go go tool objdump -S -s 'main\.main' c TEXT main.main(SB) /tmp/c.go ... var w Worker = &Typist{} LEAQ runtime.zerobase(SB), AX MOVQ AX, 0x10(SP) MOVQ AX, 0x20(SP) LEAQ go.itab.*main.Typist,main.Worker(SB), CX MOVQ CX, 0x28(SP) MOVQ AX, 0x30(SP) w.Work() MOVQ 0x28(SP), AX TESTB AL, 0(AX) MOVQ 0x18(AX), AX MOVQ 0x30(SP), CX MOVQ CX, 0(SP) CALL AX w = &Programer{} LEAQ runtime.zerobase(SB), AX MOVQ AX, 0x8(SP) MOVQ AX, 0x18(SP) LEAQ go.itab.*main.Programer,main.Worker(SB), CX MOVQ CX, 0x28(SP) MOVQ AX, 0x30(SP) w.Work() MOVQ 0x28(SP), AX TESTB AL, 0(AX) MOVQ 0x18(AX), AX MOVQ 0x30(SP), CX MOVQ CX, 0(SP) CALL AX ...

这次我们可以看到调用接口的方法会去在runtime进行查找,随后CALL找到的地址,而不是像之前那样在编译期就能找到对应的函数直接调用。这就是interface为什么特殊的原因:interface是动态变化的类型。

可以动态变化的类型最显而易见的好处是给予程序高度的灵活性,但灵活性是要付出代价的,主要在两方面。

一是性能代价。动态的方法查找总是要比编译期就能确定的方法调用多花费几条汇编指令(mov和lea通常都是会产生实际指令的),数量累计后就会产生性能影响。不过好消息是通常编译器对我们的代码进行了优化,例如c.go中如果我们不关闭编译器的优化,那么编译器会在编译期间就替我们完成方法的查找,实际生产的代码里不会有动态查找的内容。然而坏消息是这种优化需要编译器可以在编译期确定接口引用数据的实际类型,考虑如下代码:

type Worker interface { Work() } for _, v := workers { v.Work() }

因为只要实现了Worker接口的类型就可以把自己的实例塞进workers切片里,所以编译器不能确定v引用的数据的类型,优化自然也无从谈起了。

而另一个代价,确切地说其实应该叫陷阱,就是接下来我们要探讨的主题了。

golang的指针

指针也是一个极有探讨价值的话题,特别是指针在reflect以及runtime包里的各种黑科技。不过放轻松,今天我们只用了解下指针的自动解引用。

我们把b.go里的代码改动一行:

p := &Person{ name: "apocelipes", age: 100, }

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wsfjfz.html