go中多态的实现

无论是哪种面向对象语言,都有多态的实现,我们需要首先了解一下多态的定义。
多态:指的是同一个方法调用可以根据对象的不同类型而具有不同的行为。简而言之,多态允许
同样的方法在不同的对象上表现出不同的行为。
在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息,接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。

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,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;
上面的分析解释了指针类型的现象,当我们使用指针实现接口时,只有指针类型的变量才会实现该接口;当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值