自古流传着一个传言...在 Go 语言面试的时候必有人会问接口(interface)的实现原理。这又是为什么?为何对接口如此执着?
实际上,Go 语言的接口设计在整体扮演着非常重要的角色,没有他,很多程序估计都跑的不愉快了。
在 Go 语言的语义上,只要某个类型实现了所定义的一组方法集,则就认为其就是同一种类型,是一个东西。大家常常称其为鸭子类型(Duck typing),因为其与鸭子类型类型的定义相对吻合。
在维基百科中,鸭子类型的谚语定义为 "If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.",翻译过来就是 "如果它看起来像鸭子,像鸭子一样游泳,像鸭子一样嘎嘎叫,那他就可以认为是鸭子"。
回归到 Go 语言,在接口之下,接口又蕴含了怎么样的底层结构,其设计原理和思考又是什么呢?我们不能只看表面,接下来在这一章节中都会进行一一分析和道来。看看其深层到底是何 "物"。 Go 语言的接口(interface)是其核心特性之一。与其他语言的接口不同,Go 的接口没有显式声明的继承和实现关系,而是通过隐式实现来工作。这种设计理念使得 Go 的接口更加灵活和简洁。掌握 Go 的接口不仅是成为优秀 Go 开发者的基础,也是在面试中经常考察的重要部分。
本文将详细解密 Go 语言中的接口(interface),从基础到进阶,提供面试中常见的接口题目解析与示例代码。
一、什么是接口(Interface)?
在 Go 语言中,接口是一种抽象类型,它定义了一组方法,但并不提供方法的实现。任何类型只要实现了接口中定义的所有方法,就隐式地实现了该接口,无需显式声明。
Go 语言的接口使得不同的类型可以通过实现同样的接口来共享相同的行为,从而实现多态性。Go 的接口设计非常灵活,不强制要求显式的继承,类型与接口的关系是隐式的。
本文目录:
什么是 interface
Go 语言中的接口声明:
go
复制代码
type Human interface {
Say(s string) error
}
关键字主体为 type xxx interface,紧接着可以在方括号中编写方法集,用于声明和定义该接口所包含的方法集。
更进一步的代码演示:
go
复制代码
type Human interface {
Say(s string) error
}
type TestA string
func (t TestA) Say(s string) error {
fmt.Printf("煎鱼:%s\n", s)
return nil
}
func main() {
var h Human
var t TestA
_ = t.Say("炸鸡翅")
h = t
_ = h.Say("烤羊排")
}
输出结果:
复制代码
煎鱼:炸鸡翅
煎鱼:烤羊排
我们在上述代码中,声明了一个名为 Human 的 interface,其包含一个 Say 方法。同时我们声明了一个 TestA 类型,也有自己的一个 Say 方法。他们两者的方法入参和出参类型均为一样。
而与此同时,我们在主函数 main 中通过声明和赋值,成功将类型为 TestA 的变量 t 赋给了类型为 Human 的变量 h,也就是说两者只因有了个 Say 方法,在 Go 语言的编译器中就认为他们是 "一样" 的了,这也就是业界中常说的鸭子类型。
数据结构
通过上面的功能代码一看,似乎 Go 语言非常优秀。一个接口,不同的类型,2 个包含相同的方法,也能够对标到一起。
接口到底是怎么实现的呢?底层数据结构又是什么?带着问题,我们开始深挖细节之路。
在 Go 语言中,接口的底层数据结构在运行时一共分为两类结构体(struct),分别是:
runtime.eface 结构体:表示不包含任何方法的空接口,也称为 empty interface。
runtime.iface 结构体:表示包含方法的接口。
runtime.eface
首先我们来介绍 eface,看看 "他" 到底是何许人也。源码如下:
rust
复制代码
type eface struct {
_type *_type
data unsafe.Pointer
}
其表示不包含任何方法的空接口。在结构上来讲 eface 非常简单,就两个属性,分别是 _type 和 data 属性,分别代表底层的指向的类型信息和指向的值信息指针。
再进一步到 type 属性里看看,其包含的类型信息更多:
go
复制代码
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str nameOff
ptrToThis typeOff
}
size:类型的大小。
ptrdata:包含所有指针的内存前缀的大小。
hash:类型的 hash 值。此处提前计算好,可以避免在哈希表中计算。
tflag:额外的类型信息标志。此处为类型的 flag 标志,主要用于反射。
align:对应变量与该类型的内存对齐大小。
fieldAlign:对应类型的结构体的内存对齐大小。
kind:类型的枚举值。包含 Go 语言中的所有类型,例如:kindBool、kindInt、kindInt8、kindInt16 等。
equal:用于比较此对象的回调函数。
gcdata:存储垃圾收集器的 GC 类型数据。
总结一句,就是类型信息所需的信息都会存储在这里面,其中包含字节大小、类型标志、内存对齐、GC 等相关属性。而在 eface 来讲,其由于没有方法集的包袱,因此只需要存储类型和值信息的指针即可,非常简单。
runtime.iface
其次就是我们日常在应用程序中应用的较多的 iface,源码如下:
rust
复制代码
type iface struct {
tab *itab
data unsafe.Pointer
}
与 eface 结构体类型一样,主要也是分为类型和值信息,分别对应 tab 和 data 属性。但是我们再加思考一下,为什么 iface 能藏住那么多的方法集呢,难道施了黑魔法?
为了解密,我们进一步深入看看 itab 结构体。源码如下:
kotlin
复制代码
type itab struct {
inter *interfacetype
_type *_type
hash uint32
_ [4]byte
fun [1]uintptr
}
inter:接口的类型信息。
_type:具体类型信息
hash:_type.hash 的副本,用于目标类型和接口变量的类型对比判断。
fun:底层数组,存储接口的方法集的具体实现的地址,其包含一组函数指针,实现了接口方法的动态分派,且每次在接口发生变更时都会更新。
对应 func 属性会在后面的章节进一步展开讲解,便于大家对于接口中的函数指针管理的使用和理解,在此可以先行思考长度为 1 的 uintptr 数组是如何做到存储多方法的?
接下来我们进一步展开 interfacetype 结构体。源码如下:
go
复制代码
type nameOff int32
type typeOff int32
type imethod struct {
name nameOff
ityp typeOff
}
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}
_type:接口的具体类型信息。
pkgpath:接口的包(package)名信息。
mhdr:接口所定义的函数列表。
而相对应 interfacetype,还有各种类型的 type。例如:maptype、arraytype、chantype、slicetype 等,都是针对具体的类型做的具体类型定义:
python
复制代码
type arraytype struct {
typ _type
elem *_type
slice *_type
len uintptr
}
type chantype struct {
typ _type
elem *_type
dir uintptr
}
...
若有兴趣自行翻看 runtime 里相应源码即可,都是一些基本数据结构信息的存储和配套方法,就不在此一一展开讲解了。
小结
总结来讲,接口的数据结构基本表示形式比较简单,就是类型和值描述。再根据其具体的区别,例如是否包含方法集,具体的接口类型等进行组合使用。
值接收者和指针接收者
在接口的具体应用使用场景中,有一个是大家常常会碰到,甚至会对其产生较大纠结心里的东西。那就是到底用值接收者,又或是用指针接收者来声明。
演示说明
演示代码如下:
go
复制代码
type Human interface {
Say(s string) error
Eat(s string) error
}
type TestA struct{}
func (t TestA) Say(s string) error {
fmt.Printf("说煎鱼:%s\n", s)
return nil
}
func (t *TestA) Eat(s string) error {
fmt.Printf("吃煎鱼:%s\n", s)
return nil
}
func main() {
var h Human = &TestA{}
_ = h.Say("催更")
_ = h.Eat("真香")
}
在 Human 接口中,其包含 Say 和 Eat 方法,并且在 TestA 结构体中我们进行了针对性的实现。
具体的区别就是:
在 Say 方法中是值接收对象,如:(t TestA)。
在 Eat 方法中是指针接收对象,如:(t *TestA)。
最终的输出结果:
复制代码
说煎鱼:催更
吃煎鱼:真香
值和指针
如果我们将演示代码的主函数 main 改成下述这样:
ini
复制代码
func main() {
var h Human = TestA{}
_ = h.Say("催更")
_ = h.Eat("真香")
}
你觉得这段代码还能正常运行吗?在编译时会出现如下报错信息:
bash
复制代码
# command-line-arguments
./main.go:23:6: cannot use TestA literal (type TestA) as type Human in assignment:
TestA does not implement Human (Eat method has pointer receiver)
显然是不能的。因为接口校验不对,编译器过不了。其根本原因在于 Eat 是指针接收者。而当声明改为 TestA{} 后,其就会变成值对象,所以不匹配。
这时候又会出现新的问题,为什么在上面代码声明为 &TestA{} 时,那肯定是指针引用了,那为什么 Say 方法又能正常运行,不会报错呢?
其实 TestA{} 实现了 Say 方法,那么 &TestA{} 也能自动拥有该方法。显然,这是 Go 语言自身在背后做了一些事情。
因此如果我们实现了一个值对象的接收者时,也会相应拥有了一个指针接收者。两者并不会互相影响,因为值对象会产生值拷贝,对象会独立开来。
而指针对象的接收者不行,因为指针引用的对象,在应用上是期望能够直接对源接收者的值进行修改,若又支持值接收者,显然是不符合其语义的。
两者怎么用
既然支持值接收,又支持指针接收。那平时在工程应用开发中,到底用谁?还是说随便用?
其实问题的答案,在前面就有提到。本质上还是要看你业务逻辑所期望修改的是什么?还是说程序很严谨,每次都重新 new 一个,是值又或是指针引用对于程序逻辑的结果都没有任何的影响。
总结一下,如果你想使用指针接收者,可以想想是否有以下诉求:
期望接收者直接修改能够直接修改源值。
期望在大结构体的情况下,性能更好,可以在理论上避免每次值拷贝,但也会有增加别的开销,需要具体情况具体权衡。
但若应用场景没什么区别,只是个人习惯问题就不用过于纠结了,适度统一也是很重要的一环。
类型断言
在 Go 语言中使用接口,必搭配一个 "技能"。那就是进行类型断言(type assertion):
go
复制代码
var i interface{} = "吃煎鱼"
// 进行变量断言,若不判断容易出现 panic
s := i.(string)
// 进行安全断言
s, ok := i.(string)
在 switch case 中,还有另外一种写法:
go
复制代码
var i interface{} = "炸煎鱼"
// 进行 switch 断言
switch i.(type) {
case string:
// do something...
case int:
// do something...
case float64:
// do something...
}
采取的是 (变量).(type) 的调用方式,再给予 case 不同的类型进行判断识别。在 Go 语言的背后,类型断言其实是在编译器翻译后,根据 iface 和 eface 分别对应了下述方法:
go
复制代码
func assertI2I2(inter *interfacetype, i iface) (r iface, b bool) {
tab := i.tab
if tab == nil {
return
}
if tab.inter != inter {
tab = getitab(inter, tab._type, true)
if tab == nil {
return
}
}
r.tab = tab
r.data = i.data
b = true
return
}
func assertI2I(inter *interfacetype, i iface) (r iface)
func assertE2I2(inter *interfacetype, e eface) (r iface, b bool)
func assertE2I(inter *interfacetype, e eface) (r iface)
主要是根据接口的类型信息进行一轮判断和识别,基本就完成了。主要核心在于 getitab 方法,会在后面进行统一介绍和说明。
类型转换
演示代码如下:
go
复制代码
func main() {
x := "煎鱼"
var v interface{} = x
fmt.Println(v)
}
查看汇编代码:
scss
复制代码
0x0021 00033 (main.go:9) LEAQ go.string."煎鱼"(SB), AX
0x0028 00040 (main.go:9) MOVQ AX, (SP)
0x002c 00044 (main.go:9) MOVQ $6, 8(SP)
0x0035 00053 (main.go:9) PCDATA $1, $0
0x0035 00053 (main.go:9) CALL runtime.convTstring(SB)
0x003a 00058 (main.go:9) MOVQ 16(SP), AX
0x003f 00063 (main.go:10) XORPS X0, X0
主要对应了 runtime.convTstring 方法。同时很显然其是根据类型来区分来方法:
scss
复制代码
func convTstring(val string) (x unsafe.Pointer) {
if val == "" {
x = unsafe.Pointer(&zeroVal[0])
} else {
x = mallocgc(unsafe.Sizeof(val), stringType, true)
*(*string)(x) = val
}
return
}
func convT16(val uint16) (x unsafe.Pointer)
func convT32(val uint32) (x unsafe.Pointer)
func convT64(val uint64) (x unsafe.Pointer)
func convTstring(val string) (x unsafe.Pointer)
func convTslice(val []byte) (x unsafe.Pointer)
func convT2Enoptr(t *_type, elem unsafe.Pointer) (e eface)
func convT2I(tab *itab, elem unsafe.Pointer) (i iface)
...
动态分派
前面有提到接口中的 fun [1]uintptr 属性会可以存储接口的方法集,但不知道为什么。
接下来我们将进行具体的分析,演示代码:
go
复制代码
type Human interface {
Say(s string) error
Eat(s string) error
Walk(s string) error
}
type TestA string
func (t TestA) Say(s string) error {
fmt.Printf("煎鱼:%s\n", s)
return nil
}
func (t TestA) Eat(s string) error {
fmt.Printf("煎鱼:%s\n", s)
return nil
}
func (t TestA) Walk(s string) error {
fmt.Printf("煎鱼:%s\n", s)
return nil
}
func main() {
var h Human
var t TestA
h = t
_ = h.Eat("烤羊排")
_ = h.Say("炸鸡翅")
_ = h.Walk("去炸鸡翅")
}
存储方式
执行 go build -gcflags '-l' -o awesomeProject . 编译后,再次执行 go tool objdump -s "main" awesomeProject。
查看具体的汇编代码:
scss
复制代码
LEAQ go.itab.main.TestA,main.Human(SB), AX
TESTB AL, 0(AX)
MOVQ 0x10(SP), AX
MOVQ AX, 0x28(SP)
MOVQ go.itab.main.TestA,main.Human+32(SB), CX
MOVQ AX, 0(SP)
LEAQ go.string.*+3048(SB), DX
MOVQ DX, 0x8(SP)
MOVQ $0x9, 0x10(SP)
CALL CX
MOVQ go.itab.main.TestA,main.Human+24(SB), AX
MOVQ 0x28(SP), CX
MOVQ CX, 0(SP)
LEAQ go.string.*+3057(SB), DX
MOVQ DX, 0x8(SP)
MOVQ $0x9, 0x10(SP)
CALL AX
MOVQ go.itab.main.TestA,main.Human+40(SB), AX
MOVQ 0x28(SP), CX
MOVQ CX, 0(SP)
LEAQ go.string.*+4973(SB), CX
MOVQ CX, 0x8(SP)
MOVQ $0xc, 0x10(SP)
CALL AX
结合来看,虽然 fun 属性的类型是 [1]uintptr,只有一个元素,但其实就是存放了接口方法集的首个方法的地址信息,接着根据顺序往后计算并获取就好了。也就是说其是存在一定规律的。在存入方法时就决定了,所以获取也能明确。
我们进一步展开,看看 itab hash table 是如何获取和新增的。
获取 itab 元素
getitab 方法的主要作用是获取 itab 元素,若不存在则新增。源码如下:
scss
复制代码
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
// 省略一些边界、异常处理
var m *itab
t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
if m = t.find(inter, typ); m != nil {
goto finish
}
lock(&itabLock)
if m = itabTable.find(inter, typ); m != nil {
unlock(&itabLock)
goto finish
}
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
m.inter = inter
m._type = typ
m.hash = 0
m.init()
itabAdd(m)
unlock(&itabLock)
finish:
if m.fun[0] != 0 {
return m
}
panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()})
}
调用 atomic.Loadp 方法加载并查找现有的 itab hash table,看看是否是否可以找到所需的 itab 元素。
若没有找到,则调用 lock 方法对 itabLock 上锁,并进行重试(再一次查找)。
若找到,则跳到 finish 标识的收尾步骤。
若没有找到,则新生成一个 itab 元素,并调用 itabAdd 方法新增到全局的 hash table 中。
返回 fun 属性的首位地址,继续后续业务逻辑。
新增 itab 元素
itabAdd 方法的主要作用是将所生成好的 itab 元素新增到 itab hash table 中。源码如下:
scss
复制代码
func itabAdd(m *itab) {
// 省略一些边界、异常处理
t := itabTable
if t.count >= 3*(t.size/4) { // 75% load factor
t2 := (*itabTableType)(mallocgc((2+2*t.size)*sys.PtrSize, nil, true))
t2.size = t.size * 2
iterate_itabs(t2.add)
if t2.count != t.count {
throw("mismatched count during itab table copy")
}
atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2))
t = itabTable
}
t.add(m)
}
检查 itab hash table 的容量情况,查看容量情况是否已经满足大于或等于 75%。
若满足扩容策略,则调用 mallocgc 方法申请内存,按既有 size 大小扩容双倍容量。
若不满足扩容策略,则直接新增 itab 元素到 hash table 中。
深度理解
在本文中,我们先介绍了 Go 语言接口的 runtime.eface 和 runtime.iface 两个基本数据结构,其代表了一切的开端。
随后针对值接受者和指针接收者进行了详细的说明,同时日常用的较多的类型断言和转换也一一进行了描述。
最后对接口的多方法这个神秘的地方进行了基本分析和了解,相信这一番轮流吸收下来,能够打开大家对接口的一个新的理解。 面试题示例:
问题:接口的零值是什么?未初始化的接口如何工作?
回答 :接口的零值是 nil。如果接口没有赋值或赋值为 nil,它将不指向任何类型的实例。尝试调用其方法时会导致运行时错误。
三、接口的面试题解析
1. 接口与多态
Go 中的接口是实现多态的关键。当一个类型实现了某个接口后,可以通过该接口来调用类型的方法,达到不依赖于具体类型的效果。多个不同的类型如果实现了同样的接口,就能以相同的方式进行处理。
面试题示例:
问题:如何在 Go 中实现多态?
回答:Go 通过接口实现多态。不同类型实现了相同的接口后,能够通过该接口调用相同的方法,而不依赖于具体类型。
2. 空接口和类型断言
空接口在 Go 中非常常见,尤其在处理通用数据时。在实际应用中,常常需要使用类型断言来从空接口中提取具体类型。
面试题示例:
问题:什么场景下使用空接口?如何从空接口中提取具体类型?
回答 :空接口 interface{} 用于存储任意类型的数据,常用于通用数据容器或函数参数。通过类型断言可以从空接口中提取具体类型,使用 i.(T) 来执行类型断言。
3. 接口的设计原则
在 Go 中设计接口时,应该遵循一些原则:尽量小巧且具体的接口,避免使用大而全的接口。此外,接口应该只暴露类型的行为而非状态,尽量避免通过接口传递大量的内部数据。
面试题示例:
问题:在设计接口时,应该遵循哪些原则?
回答:设计接口时应遵循:1) 接口应该尽量小,暴露的仅为必要的行为;2) 尽量避免使用空接口或非常大的接口;3) 接口方法应该是类型的行为而非状态。
四、总结
Go 语言的接口是一个强大而灵活的特性,能够有效地实现多态和抽象。通过掌握接口的基本使用方法、空接口的应用、类型断言和接口的设计原则,你不仅能够提高代码的可扩展性和可维护性,还能够应对面试中关于接口的常见问题。通过深入理解接口,你将能够编写更加灵活和高效的 Go 代码,在面试中获得加分。