likes
comments
collection
share

Go基础:接口

作者站长头像
站长
· 阅读数 9

初识接口

什么是接口

接口是一种标准化的约定或规范,它规定了两个个体之间如何进行通信和交互。

假设你要与一个陌生人交谈,你可能需要遵守一些基本的礼仪规范,比如问候、介绍自己、交换名片等等。这些规范可以帮助你和陌生人之间建立信任和合作关系,同时也确保了交流的顺畅和准确。

在实际编程中,实现一个接口通常需要实现接口定义的所有方法,以确保对象可以被正确使用和调用。这也意味着接口可以被用来强制规范对象的行为,以确保它们符合特定的要求和标准。

接口被认为是一种抽象的工具,因为它并不关心具体的实现细节,而只关注于对象或类应该实现的方法或功能。

举个例子,假设你正在编写一个程序,需要处理一些动物对象,比如狗、猫、鸟等等。你可以定义一个名为“动物”的接口,它规定了这些对象应该实现哪些方法,比如“吃饭”、“睡觉”、“叫声”等等。这些方法只是描述了动物应该具有的行为,而并不关心这些行为如何被实现。不同的动物可以根据自己的特点来实现这些方法,比如狗可能会发出“汪汪”声,而猫则可能会发出“喵喵”声。

总的来说,接口是一种必须遵守的约定与规范,这使得两个个体之间的交互得到了保证,并且其抽象的特性,又是规范具有灵活性而不用遵守死规矩。

怎么声明一个接口类型

接口类型的定义通常由一组方法签名组成,这些方法签名定义了接口的行为规范。

接口作为一个未命名类型,没有名字的时候会被称为匿名类型:

interface {
 MethodSignature1
 MethodSignature2
}

也可以用type关键字声明一个自定义接口类型:

type Shape interface{
 Area() float64
 Perimeter() float64
}

在声明接口类型时,需要注意以下几点:

  1. 接口中只包含方法签名,不包含实现。
  2. 方法签名中的方法名应该是唯一的,且应为大写字母开头。
  3. 接口类型的名称通常以“er”结尾,例如 ReaderWriterFormatter 等等。

并且在 Go 语言中,接口也可以像结构体一样进行嵌套。通过接口的嵌套,我们可以将多个接口组合成一个更大的接口,并继承各自的方法集合。具体来说,如果一个接口类型嵌套了另一个接口类型,那么该接口类型就会包含被嵌套接口类型的所有方法。示例代码如下:

type Reader interface {
   Read(s string)
}
​
type Writer interface {
   Write(s string)
}
​
type ReadWriter interface {
   Reader
   Writer
}

在上面的代码中,我们定义了三个接口类型ReaderWriterReadWriter。其中ReaderWriter分别定义了ReadWrite两个方法,而ReadWriter则通过嵌套ReaderWriter两个接口类型合成了一个更大的接口类型。

怎么定义一个接口变量

声明一个接口变量

var 变量名 接口类型
  1. 赋值字面常量——这个字面常量需要实现了当前接口

    type Runner interface{
     Run()
    }
    ​
    type Person struct{}
    ​
    func (p Person) Run(){}
    ​
    var runner Runner = Person{}
    

    在上述代码中,我们声明了一个接口类型和一个结构类型,分别是RunnerPerson,而Person实现了RunnerRun当法。在接口变量赋值的时候,我们声明了一个Runner接口类型的runner,并将实现了RunnerPerson类的实例赋值给runner

  2. 接口赋值接口——右值的方法集必须是左值的超集

    type Reader interface {
       Read(s string)
    }
    ​
    type ReadWriter interface {
       Reader
       // Writer
    }
    ​
    type Person struct{ }
    ​
    func (p Person) Read(s string){ }
    ​
    var rw ReadWriter = Person{}
    var r Reader = rw
    

    在上面的代码中,我们定义了两个接口类型ReaderReadWriter。其中Reader定义了Read方法,而ReadWriter则嵌套了Reader接口,并且在下面定义了一个Person类,其实现了ReadWriterReader。然后定义了一个ReadWriter的变量rw,用Person实例进行赋值,并将rw赋值给为Reader接口类型的变量r。我们需要注意的是:

    1. 右值需要已经被定义(被绑定具体类型的实例);
    2. 右值的方法集必须是左值的超集,也就是说左值是被组合与右值内部的。

Go为接口提供的方法

接口实际上是什么类型

接口的声明是没有意义的,因为它是一种规范,而规范不能独立于某个主体而存在,所以接口必须绑定一个实例。因此会出现一个状况——一个接口变量属于某个接口类型,而绑定的实例是一个实现了该接口的自定义类型。前者被称为接口的静态类型;后者被称为接口的动态类型。

  1. 动态类型

    动态类型是接口变量持有的实例的类型,也就是实现接口的类型。在运行时,这个接口的动态类型是可以改变的,这是实现多态性的关键。

    例如,假设有以下代码:

    type Writer interface {
       Write(p []byte) (n int, err error)
    }
    ​
    type FileWriter struct {
       file *os.File
    }
    ​
    func (fw FileWriter) Write(p []byte) (n int, err error) {
       return fw.file.Write(p)
    }
    ​
    type ConsoleWriter struct {}
    ​
    func (cw ConsoleWriter) Write(p []byte) (n int, err error) {
       fmt.Println(string(p))
       return len(p), nil
    }
    ​
    func main() {
       var w Writer
       w = FileWriter{file: os.Stdout}
       w.Write([]byte("hello, world!\n"))
       w = ConsoleWriter{}
       w.Write([]byte("hello, world!\n"))
    }
    

    在该例子中,FileWriterConsoleWriter 都实现了 Writer 接口中的 Write 方法,因此它们都可以被赋值给 Writer 类型的变量 w。通过这种方式,我们可以使用同一个 Writer 变量调用不同实现类型的 Write 方法,从而实现了多态。

  2. 静态类型

    接口变量的静态类型是指在代码中声明变量时所使用的类型,是接口本身的类型。而静态类型的本质特征就是接口的方法签名集合。 两个接口如果方法签名集合相同(顺序可以不同),则两个接口语义上完全等价,因此我们可以看出,编译器判断两个接口是否相同的依据是其方法签名集合是否相同。

  3. 区分动态类型与静态类型的意义在于:
    • 在编译时,编译器可以检查接口类型是否实现了所有必要的方法,从而避免在运行时出现类型不匹配的错误。
    • Go是强类型系统,会在编译时就检查类型,以提高程序的安全性。

接口有什么方法

接口变量在运行的时候,实际存储的动态类型是会改变的,因此当需要访问的时候,我们可能需要进行类型检查,甚至于类型转换与分类处理——类型断言

Go 语言中有两种类型断言形式,分别是“断言语句”和“类型判断表达式”。

  1. 断言语句
    x.(T)
    
    • x必须是接口变量,否则会产生运行时错误——non-interface type xxx on left
    • T是要断言的类型,可以是接口名,也可以是具体类名

    断言语句有两种形式:

    • 直接赋值型:o := x.(T)

      var i interface{} = "hello"// 将 i 转换为字符串类型,并将结果存储到变量 s 中
      s := i.(string)
      fmt.Println(s)  // 输出:hello// 尝试将 i 转换为整数类型,会导致一个运行时错误
      n := i.(int)
      fmt.Println(n)
      
    • 类型判断语句:o, ok := x.(T)

      我们可以使用类型判断语句来判断接口类型的值是否是目标类型,以避免运行时错误。

      var i interface{} = "hello"// 先判断 i 是否是字符串类型
      if s, ok := i.(string); ok {
         fmt.Println(s)
      } else {
         fmt.Println("not a string")
      }
      ​
      // 先判断 i 是否是整数类型
      if n, ok := i.(int); ok {
         fmt.Println(n)
      } else {
         fmt.Println("not an int")
      }
      
  2. 类型判断表达式
    x.(type)
    
    • x表示要被断言的实例
    • type是一个关键字,用于表示该表达式的动态类型

    ⚠️这个表达式不能在除了switch的其它地方使用, 用于根据接口类型的值的动态类型来执行不同的操作,如:

    var i interface{} = 10switch v := i.(type){
     case int:
       fmt.Println("integer:", v)
    case string:
       fmt.Println("string:", v)
    default:
       fmt.Println("unknown type")
    }
    

    在上面的代码中,首先定义了一个接口类型的变量i,其值为整数 10。然后,使用类型判断表达式将i的动态类型与intstring等类型进行匹配,并根据匹配结果执行相应的操作。由于i的动态类型为int,因此会输出 "integer: 42"。

    那么我们该怎么使用这个转换后的变量呢?首先非常重要的一点,⚠️v是不能直接用的!!! 此时匹配成功之后,v仍然是一个接口类型,而底层绑定的是实例i绑定的具体类型实例的副本,因此我们应该进行以下操作:

    var i interface{} = "hello"switch v := i.(type) {
    case int:
       fmt.Println("integer:", v)
    case string:
       // 将接口类型的值转换为字符串类型
       s := v
       fmt.Println("string:", s)
    default:
       fmt.Println("unknown type")
    }
    

    s := v这一步将会将接口类型的值转换为字符串类型,这是由于在switch之后,编译器会自动将 v 推断为一个 string 类型的值。因此,在这里,我们可以直接将 v 赋值给一个 string 类型的变量 s,从而将 v 转换为一个字符串类型的值。


接口的本质

通过上文我们已经知道,接口类型在语义上会被区分成两种类型——一种是动态类型,它描述的是实现接口的具体实例;一种是静态类型,它描述的是接口本身是什么类型。

//src/runtime/runtime2.go
type iface struct{
 tab *itab
 data unsafe.Pointer
}
​
type itab struct{
 inter *interfacetype
 _type *_type
 hash uint32
 _ [4]byte
 fun [1]uintptr
}

接口的底层数据接口是iface。其中tab存放的是类型信息,包括接口的静态类型、绑定的实例的类型与实例相关的方法集信息;data存放的是绑定的实例的副本,因为Go是值传递的。在itab中,我们能够清楚的看到:

  • inter是接口自身的静态类型;
  • _type是接口存放的实例的类型;
  • hash存放的是具体类型的Hash值,主要是用于类型断言与类型查询是快速访问;
  • fun存放的是实例相关的函数指针,虽然此时我们能够看到长度为1,但是其实指针数组的大小是可以变的,并且在运行时编译器会使用底层指针进行访问与填充。