无论是哪种面向对象语言,都有多态的实现,我们需要首先了解一下多态的定义。
多态:指的是同一个方法调用可以根据对象的不同类型而具有不同的行为。简而言之,多态允许
同样的方法在不同的对象上表现出不同的行为。
在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息,接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。
go中多态实现
那么go中多态是如何实现的呢?也和java类似通过接口实现
在 Java 中:实现接口需要显式地声明接口并实现所有方法;
在 Go 中:实现接口的所有方法就隐式地实现了接口;
package main
import "fmt"
// 定义一个接口 Animal
type Animal interface {
Speak() string
}
// 定义结构体 Dog
type Dog struct{}
// Dog 实现了接口 Animal 的 Speak 方法
func (d Dog) Speak() string {
return "Woof!"
}
// 定义结构体 Cat
type Cat struct{}
// Cat 实现了接口 Animal 的 Speak 方法
func (c Cat) Speak() string {
return "Meow!"
}
// 接收一个 Animal 类型的参数,并调用其 Speak 方法
func LetAnimalSpeak(a Animal) {
fmt.Println(a.Speak())
}
func main() {
// 创建一个 Dog 实例
dog := Dog{}
// 创建一个 Cat 实例
cat := Cat{}
// 调用 LetAnimalSpeak 函数,传入不同类型的参数
LetAnimalSpeak(dog) // 输出: Woof!
LetAnimalSpeak(cat) // 输出: Meow!
}
例如上述例子,对于动物Animal这个接口来说,cat和dog这两个类实现了它的方法,**在 Go 语言中,接口的实现是隐式的,而不是像某些其他语言一样显式地声明。**当一个类型实现了接口中的所有方法时,它就被认为是实现了该接口。如果接口定义了多个方法,但是类型只实现了其中的一部分方法,那么该类型就不会被认为是接口的实现类型。编译器会在编译时检查是否所有必要的方法都已经实现了。
当一个类没有完全实现该接口方法时,此时进行 var animal Animal = cat赋值时,编译阶段就会报错
package main
import "fmt"
type Animal interface {
Speak() string
Run() string
}
type Cat struct{}
func (c Cat) Speak() string {
return "Meow!"
}
func main() {
cat := Cat{}
var animal Animal = cat // 编译错误: Cat does not implement Animal (missing Run method)
fmt.Println(animal.Speak())
fmt.Println(animal.Run())
}
Go语言进行接口类型检查的机制
Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查,这里举几个例子来演示发生接口类型检查的时机:
func main() {
var rpcErr error = NewRPCError(400, "unknown err") // typecheck1
err := AsErr(rpcErr) // typecheck2
println(err)
}
func NewRPCError(code int64, msg string) error {
return &RPCError{ // typecheck3
Code: code,
Message: msg,
}
}
func AsErr(err error) error {
return err
}
Go 语言在编译期间对代码进行类型检查,上述代码总共触发了三次类型检查:
将 *RPCError 类型的变量赋值给 error 类型的变量 rpcErr;
将 *RPCError 类型的变量 rpcErr 传递给签名中参数类型为 error 的 AsErr 函数;
将 *RPCError 类型的变量从函数签名的返回值类型为 error 的 NewRPCError 函数中返回;
Go语言接口的具体类型
接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中有两种略微不同的接口,一种是带有一组方法的接口,另一种是不带任何方法的 interface{}:
Go 语言使用 runtime.iface 表示第一种接口,使用 runtime.eface 表示第二种不包含任何方法的接口 interface{},两种接口虽然都使用 interface 声明,但是由于后者在 Go 语言中很常见,所以在实现时使用了特殊的类型。
需要注意的是,与 C 语言中的 void * 不同,interface{} 类型不是任意类型。如果我们将类型转换成了 interface{} 类型,变量在运行期间的类型也会发生变化,获取变量类型时会得到 interface{}。
package main
import "fmt"
func main() {
var i int = 42
var f float64 = 3.14
var any interface{}//eface接口
any = i
fmt.Printf("Type: %T, Value: %v\n", any, any) // Type: int, Value: 42
any = f
fmt.Printf("Type: %T, Value: %v\n", any, any) // Type: float64, Value: 3.14
any = "hello"
fmt.Printf("Type: %T, Value: %v\n", any, any) // Type: string, Value: hello
}
在上面的示例中,any 变量的静态类型是 interface{},当它被赋值为不同的类型时,它的动态类型也会随之改变。但是,即使 any 变量实际上存储了不同的值,它的静态类型仍然是 interface{}。因此,尽管 interface{} 可以存储任意类型的值,但在使用时需要注意动态类型和静态类型之间的区别。
(这里注意在编程中,"动态类型"和"静态类型"是两个概念,用于描述变量在不同阶段的类型状态。
静态类型:在编译时,变量被声明时的类型称为静态类型。静态类型是在编写代码时确定的,并且通常在编译时就已经确定了。在静态类型语言中(如Go、C、Java等),变量的静态类型通常不能改变。
动态类型:变量在运行时实际存储的类型称为动态类型。动态类型是在运行时根据程序运行过程中的实际赋值情况确定的。在动态类型语言中(如Python、JavaScript等),变量的动态类型可以随着程序的执行而改变。)
eface
表示没有(empty)没有方法的空接口(empty interfac)类型变量,即interface{}类型的变量
func DoSomething(v interface{}) {
// ...
}
这里是让人困惑的地方:在 DoSomething 函数内部,v 的类型是什么?新手们会认为 v 是任意类型的,但这是错误的。v 不是任意类型,它是 interface{} 类型。 对的,没错!当将值传递给DoSomething 函数时,Go 运行时将执行类型转换(如果需要),并将值转换为 interface{} 类型的值。所有值在运行时只有一个类型,而 v 的一个静态类型是 interface{} 。
内存结构:runtime.eface 结构体在 Go 语言中的定义是这样的:
type eface struct { // 16 字节
_type *_type//指向数据类型,具体的具体类型信息
data unsafe.Pointer//指向底层数据
}
由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 — Go 语言的任意类型都可以转换成 interface{}。runtime._type 是 Go 语言类型的运行时表示。其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。
注意:任意变量都可以转化为interface{}类型变量,这是向上转型(向上转型(Upcasting):向上转型是指将一个具体类型转换为它所实现的接口类型),而向下转型则需要用到断言(向下转型是指将一个接口类型转换为它所包含的具体类型。这种转换需要显式的类型断言,并且在运行时可能会出现错误。在 Go 中,向下转型通过类型断言实现(接口转接口也可以断言实现)。)
iface
表示拥有方法的接口类型变量
另一个用于表示接口的结构体是 runtime.iface,这个结构体中有指向原始数据的指针 data,不过更重要的是 runtime.itab 类型的 tab 字段。
type iface struct { // 16 字节
tab *itab
data unsafe.Pointer
}
runtime.itab 结构体是接口类型的核心组成部分,tab字段不仅被用来存储接口本身的信息(例如接口的类型信息、方法集信息等),还被用来存储具体类型所实现的信息。每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter 和 _type 两个字段表示:
type itab struct { // 32 字节
inter *interfacetype//8字节存储就是接口本身的信息
_type *_type//8字节具体的具体类型信息
hash uint32//4字节,字段是_type.hash的缓存,当需要将接口类型转换成具体的类型时,使用该字段判断转换的目标类型是否和具体类型_type一样
_ [4]byte//4字节
fun [1]uintptr//8字节存储一组函数指针,是一个用于动态分发的虚函数表
}
除了 inter 和 _type 两个用于表示类型的字段之外,上述结构体中的另外两个字段也有自己的作用:
hash 是对 _type.hash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 runtime._type 是否一致;
fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的;
下图是一个很清晰的例子:
例如下图,对于非空接口rw,其中的tab指向itab结构体,我们可以看到其中的fun[0]、fun[1]存储了动态变量f的方法信息的拷贝地址,这样就不需要再到元数据中找其方法地址。
关于itab,还要额外关注一点,我们知道一旦接口类型确定了,动态类型也确定了,那么itab的内容就不会改变了,所以这个不会改变的这个itab结构体是可复用的,所以就引出了go语言中针对itab的缓存技术。
实际上Go语言会把用到的itab结构体缓存起来**,并且以接口类型和动态类型的组合为key,以itab结构体指针为value,构造一个哈希表**,用于存储与查询itab缓存信息。需要一个itab时,会首先去这里查找。这里的哈希表和map底层的哈希表不同,是一种更为简便的设计。key的哈希值是这样计算的,用接口类型的类型哈希值,与动态类型的类型哈希值,进行异或运算,如果已经有对应的itab指针,就直接拿来使用。若itab缓存中没有,就要创建一个itab结构体,然后添加到这个哈希表中
指针和接口
在 Go 语言中同时使用指针和接口时会发生一些让人困惑的问题,接口在定义一组方法时没有对实现的接收者做限制,所以我们会看到某个类型实现接口的两种方式:
这是因为结构体类型和指针类型是不同的,就像我们不能向一个接受指针的函数传递结构体一样,在实现接口时这两种类型也不能划等号。虽然两种类型不同,但是上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 “method redeclared”。
对 Cat 结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。下面的代码总结了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量。
type Cat struct {}
type Duck interface { ... }
func (c Cat) Quack {} // 使用结构体实现接口
func (c *Cat) Quack {} // 使用结构体指针实现接口
var d Duck = Cat{} // 使用结构体初始化变量
var d Duck = &Cat{} // 使用结构体指针初始化变量
实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:
结构体实现接口 | 结构体指针实现接口 | |
---|---|---|
结构体初始化变量 | 通过 | 通过 |
– | – | – |
结构体指针初始化变量 | 通过 | 不通过 |
四种中只有使用指针实现接口,使用结构体初始化变量无法通过编译,其他的三种情况都可以正常执行。当实现接口的类型和初始化变量时返回的类型时相同时,代码通过编译是理所应当的:
方法接受者和初始化类型都是结构体;
方法接受者和初始化类型都是结构体指针;
而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?我们先来看一下能够通过编译的情况,即方法的接受者是结构体,而初始化的变量是结构体指针:
type Cat struct{}
func (c Cat) Quack() {
fmt.Println("meow")
}
func main() {
var c Duck = &Cat{}
c.Quack()
}
这种情况下,使用的是结构体实现接口,指针初始化变量,作为指针的 &Cat{} 变量能够隐式地获取到指向的结构体,所以能在结构体上调用 Walk 和 Quack 方法。我们可以将这里的调用理解成 C 语言中的 d->Walk() 和 d->Speak(),它们都会先获取指向的结构体再执行对应的方法。
但是如果是这种情况:
type Duck interface {
Quack()
}
type Cat struct{}
func (c *Cat) Quack() {
fmt.Println("meow")
}
func main() {
var c Duck = Cat{}
c.Quack()
}
$ go build interface.go
./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment:
Cat does not implement Duck (Quack method has pointer receiver)
Cat 类型没有实现 Duck 接口,Quack 方法的接受者是指针。这两个报错对于刚刚接触 Go 语言的开发者比较难以理解,如果我们想要搞清楚这个问题,首先要知道 Go 语言在传递参数时都是传值的。
这张图片解释了原因,对于指针初始化变量,如上图左侧,对于 &Cat{} 来说,这意味着拷贝一个新的 &Cat{} 指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用(dereference)获取指针指向的结构体;如上图右侧,对于 Cat{} 来说,这意味着 Quack 方法会接受一个全新的 Cat{},因为方法的参数是 *Cat,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;
上面的分析解释了指针类型的现象,当我们使用指针实现接口时,只有指针类型的变量才会实现该接口;当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。