GoLang接口---上

x33g5p2x  于2022-08-17 转载在 其他  
字(7.6k)|赞(0)|评价(0)|浏览(262)

代码必须能够被人阅读,只是机器恰好可以执行

定义

Go 语言不是一种 “传统” 的面向对象编程语言:它里面没有类和继承的概念。

但是 Go 语言里有非常灵活的 接口 概念,通过它可以实现很多面向对象的特性。接口提供了一种方式来 说明 对象的行为:如果谁能搞定这件事,它就可以用在这儿。

接口定义了一组方法(方法集),但是这些方法不包含(实现)代码:它们没有被实现(它们是抽象的)。接口里也不能包含变量。

不像大多数面向对象编程语言,在 Go 语言中接口可以有值,一个接口类型的变量或一个 接口值 :var ai Namer,ai 是一个多字(multiword)数据结构,它的值是 nil。它本质上是一个指针,虽然不完全是一回事。指向接口值的指针是非法的,它们不仅一点用也没有,还会导致代码错误。

此处的方法指针表是通过运行时反射能力构建的。

类型(比如结构体)可以实现某个接口的方法集;这个实现可以描述为,该类型的变量上的每一个具体方法所组成的集合,包含了该接口的方法集。

实现了 Namer 接口的类型的变量可以赋值给 ai(即 receiver 的值),方法表指针(method table ptr)就指向了当前的方法实现。当另一个实现了 Namer 接口的类型的变量被赋给 ai,receiver 的值和方法表指针也会相应改变。

隐式接口

很多面向对象语言都有接口这一概念,例如 Java 和 C#。Java 的接口不仅可以定义方法签名,还可以定义变量,这些定义的变量可以直接在实现接口的类中使用:

public interface MyInterface {
    String hello = "Hello";
    public void sayHello();
}

上述代码定义了一个必须实现的方法 sayHello 和一个会注入到实现类的变量 hello。在下面的代码中,MyInterfaceImpl 就实现了 MyInterface 接口:

public class MyInterfaceImpl implements MyInterface {
    public void sayHello() {
        System.out.println(MyInterface.hello);
    }
}

Java 中的类必须通过上述方式显式地声明实现的接口,但是在 Go 语言中实现接口就不需要使用类似的方式。首先,我们简单了解一下在 Go 语言中如何定义接口。定义接口需要使用 interface 关键字,在接口中我们只能定义方法签名,不能包含成员变量,一个常见的 Go 语言接口是这样的:

type error interface {
    Error() string
}

如果一个类型需要实现 error 接口,那么它只需要实现 Error() string 方法,下面的 RPCError 结构体就是 error 接口的一个实现:

type RPCError struct {
    Code    int64
    Message string
}
func (e *RPCError) Error() string {
    return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}

细心的读者可能会发现上述代码根本就没有 error 接口的影子,这是为什么呢?Go 语言中接口的实现都是隐式的,我们只需要实现 Error() string 方法实现了 error 接口。Go 语言实现接口的方式与 Java 完全不同:

  • 在 Java 中:实现接口需要显式的声明接口并实现所有方法;
  • 在 Go 中:实现接口的所有方法就隐式的实现了接口;

我们使用上述 RPCError 结构体时并不关心它实现了哪些接口,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函数中返回;从类型检查的过程来看,编译器仅在需要时才对类型进行检查,类型实现接口时只需要实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。

类型

接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中有两种略微不同的接口,一种是带有一组方法的接口,另一种是不带任何方法的 interface{}:

Go 语言使用 iface 结构体表示第一种接口,使用 eface 结构体表示第二种空接口,两种接口虽然都使用 interface 声明,但是由于后者在 Go 语言中非常常见,所以在实现时使用了特殊的类型。

需要注意的是,与 C 语言中的 void 不同,interface{} 类型不是任意类型,如果我们将类型转换成了 interface{} 类型,这边变量在运行期间的类型也发生了变化,获取变量类型时就会得到 interface{} 。

package main

func main() {
    type Test struct{}
    v := Test{}
    Print(v)
}

func Print(v interface{}) {
    println(v)
}

上述函数不接受任意类型的参数,只接受 interface{} 类型的值,在调用 Print 函数时会对参数 v 进行类型转换,将原来的 Test 类型转换成 interface{} 类型。

接口底层数据接口

Go 语言根据接口类型『是否包含一组方法』对类型做了不同的处理。我们使用 iface 结构体表示包含方法的接口;使用 eface 结构体表示不包含任何方法的 interface{} 类型,eface 结构体在 Go 语言的定义是这样的:

type eface struct { // 16 bytes
    _type *_type
    data  unsafe.Pointer
}

由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 Go 语言中的任意类型都可以转换成 interface{} 类型。

另一个用于表示接口的结构体就是 iface,这个结构体中有指向原始数据的指针 data,不过更重要的是 itab 类型中的 tab 字段。

type iface struct { // 16 bytes
    tab  *itab
    data unsafe.Pointer
}


接下来我们将详细分析 Go 语言接口中的这两个类型,即 _type 和 itab

类型结构体

_typeGo 语言类型的运行时表示。下面是运行时包中的结构体,结构体包含了很多元信息,例如:类型的大小、哈希、对齐以及种类等。

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 字段存储了类型占用的内存空间,为内存空间的分配提供信息;
  • hash 字段能够帮助我们快速确定类型是否相等;
  • equal 字段用于判断当前类型的多个对象是否相等

我们只需要对 _type 结构体中的字段有一个大体的概念,不需要详细理解所有字段的作用和意义。

itab 结构体

itab 结构体是接口类型的核心组成部分,每一个 itab 都占 32 字节的空间,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter 和 _type 两个字段表示:

type itab struct { // 32 bytes
    inter *interfacetype
    _type *_type
    hash  uint32
    _     [4]byte
    fun   [1]uintptr
}

除了 inter 和 _type 两个用于表示类型的字段之外,上述结构体中的另外两个字段也有自己的作用:

  • hash 是对 _type.hash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 _type 是否一致;
  • fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的;

接口嵌套接口

一个接口可以包含一个或多个其他的接口,这相当于直接将这些内嵌接口的方法列举在外层接口中一样。

比如接口 File 包含了 ReadWrite 和 Lock 的所有方法,它还额外有一个 Close() 方法。

type ReadWrite interface {
    Read(b Buffer) bool
    Write(b Buffer) bool
}

type Lock interface {
    Lock()
    Unlock()
}

type File interface {
    ReadWrite
    Lock
    Close()
}

通过接口嵌套接口这种组合代替继承的方式,可以间接实现接口的多重继承功能。

指针和接口

在 Go 语言中同时使用指针和接口时会发生一些让人困惑的问题,接口在定义一组方法时没有对实现的接收者做限制,所以我们会看到『一个类型』实现接口的两种方式:

这是因为结构体类型和指针类型是完全不同的,就像我们不能向一个接受指针的函数传递结构体,在实现接口时这两种类型也不能划等号。

上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错: method redeclared

对 Cat 结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。下

面的代码总结了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量。

type Cat struct {}
type Duck interface { ... }
type (c  Cat) Quack {}  // 使用结构体实现接口
type (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 语言在传递参数时都是传值的。

如上图所示,无论上述代码中初始化的变量 c 是 Cat{} 还是 &Cat{},使用 c.Quack() 调用方法时都会发生值拷贝:

  • 如图 4-9 左侧,对于 &Cat{} 来说,这意味着拷贝一个新的 &Cat{} 指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用(dereference)获取指针指向的结构体;
  • 如图 4-9 右侧,对于 Cat{} 来说,这意味着 Quack 方法会接受一个全新的 Cat{},因为方法的参数是 *Cat,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;

上面的分析解释了指针类型的现象,当我们使用指针实现接口时,只有指针类型的变量才会实现该接口;

当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。

golang中的值方法和指针方法

先来看只有结构体的情况:

  • 在给结构体对象添加方法的时候,接收者参数可以有两种类型,一种是值参数,还有一种则是指针参数
func(receiver Type) Method
func(receiver *Type) Method
  • 值对象可以调用值方法&指针方法
type Person struct{
	Name string 
	Age int 
}
// 值方法
func(p Person) SayHello(){
	fmt.Printf("Hello, my name is %s\n", p.Name)
}
// 引用方法
func(p *Person) SetAge(age int){
	p.Age = age
}

func main(){
	var jack = Person{"jack", 10}
	jack.SayHello()
	jack.SetAge(20)
	fmt.Println(jack.Age)  // 20
}

很明显,值类型肯定可以调用值方法,而对于指针方法的调用,其实是golang的语法糖,调用jack.SetAge的时候,会自动转换成(&jack).SetAge

  • 指针对象可以调用值方法&指针方法
type Person struct{
	Name string 
	Age int 
}
// 值方法
func(p Person) SayHello(){
	fmt.Printf("Hello, my name is %s\n", p.Name)
}
// 引用方法
func(p *Person) SetAge(age int){
	p.Age = age
}

func main(){
	var jack = &Person{"jack", 10}
	jack.SayHello()
	jack.SetAge(20)
	fmt.Println(jack.Age)  // 20
}

指针对象调用值方法也是golang中的语法糖,在调用值方法jack.SayHello的时候,会自动转换成(*jack).SayHello

如果我们调用一个接口里面的函数,结构体对象实现接口时的方法可能是指针方法也可以是值方法,那么需要注意:

  • 值类型只能调用值方法
  • 指针类型可以调用值方法和指针方法
package main 

import (
	"fmt"
)
// 接口类型
type Human interface{
	SayHello()
	SetAge(age int)
	GetAge()int 
}
// 结构体对象
type Person struct{
	Name string 
	Age int 
}
// 值方法
func(p Person) SayHello(){
	fmt.Printf("Hello, my name is %s\n", p.Name)
}
// 指针方法
func(p *Person) SetAge(age int){
	p.Age = age
}
// 值方法
func(p Person)GetAge()int{
	return p.Age
}

func main(){
	// jack是个接口,被一个指针对象赋值,下面的方法都可以正确执行
	var jack Human  // 声明一个接口类型的对象
	jack = &Person{"jack", 10} // Person实现了接口
	jack.SayHello() 
	jack.SetAge(20)
	fmt.Println(jack.GetAge())

	// 值类型并没有实现SetAge的方法,所以赋值的时候会报错
	// cannot use Person literal (type Person) as type Human in assignment:
    //    Person does not implement Human (SetAge method has pointer receiver)
	var Tom Human 
	Tom = Person{"Tom", 12}
	Tom.SayHello()
	Tom.SetAge(10)
	fmt.Println(Tom.GetAge())
}

以指针类型接收者实现接口的时候,只有对应的指针类型才被认为实现了该接口。

小结:

  • 当值类型作为接收者时,值类型和指针类型都实现了该接口;
  • 当指针类型作为接收者时,只有指针类型实现了该接口;

那么什么时候使用指针方法,什么时候使用值方法呢,可以考虑:

  • 太多的指针可能会增加垃圾回收器的负担。
  • 如果一个值类型的尺寸太大,那么值传参的时候的复制成本将不可忽略。 指针类型都是小尺寸类型。
  • 在并发场合下,同时调用为值方法和指针方法比较易于产生数据竞争。
  • sync标准库包中的类型的值不应该被复制,所以如果一个结构体类型内嵌了这些类型,则不应该为这个结构体类型声明值方法。
  • 如果实在拿不定主意在一个方法声明中应该使用值类型属主还是指针类型属主,那么请使用指针方法

参考

  • Go入门指南
  • Go语言设计与实现

相关文章