Go基础:接口
初识接口
什么是接口
接口是一种标准化的约定或规范,它规定了两个个体之间如何进行通信和交互。
假设你要与一个陌生人交谈,你可能需要遵守一些基本的礼仪规范,比如问候、介绍自己、交换名片等等。这些规范可以帮助你和陌生人之间建立信任和合作关系,同时也确保了交流的顺畅和准确。
在实际编程中,实现一个接口通常需要实现接口定义的所有方法,以确保对象可以被正确使用和调用。这也意味着接口可以被用来强制规范对象的行为,以确保它们符合特定的要求和标准。
接口被认为是一种抽象的工具,因为它并不关心具体的实现细节,而只关注于对象或类应该实现的方法或功能。
举个例子,假设你正在编写一个程序,需要处理一些动物对象,比如狗、猫、鸟等等。你可以定义一个名为“动物”的接口,它规定了这些对象应该实现哪些方法,比如“吃饭”、“睡觉”、“叫声”等等。这些方法只是描述了动物应该具有的行为,而并不关心这些行为如何被实现。不同的动物可以根据自己的特点来实现这些方法,比如狗可能会发出“汪汪”声,而猫则可能会发出“喵喵”声。
总的来说,接口是一种必须遵守的约定与规范,这使得两个个体之间的交互得到了保证,并且其抽象的特性,又是规范具有灵活性而不用遵守死规矩。
怎么声明一个接口类型
接口类型的定义通常由一组方法签名组成,这些方法签名定义了接口的行为规范。
接口作为一个未命名类型,没有名字的时候会被称为匿名类型:
interface {
MethodSignature1
MethodSignature2
}
也可以用type关键字声明一个自定义接口类型:
type Shape interface{
Area() float64
Perimeter() float64
}
在声明接口类型时,需要注意以下几点:
- 接口中只包含方法签名,不包含实现。
- 方法签名中的方法名应该是唯一的,且应为大写字母开头。
- 接口类型的名称通常以“er”结尾,例如
Reader
、Writer
、Formatter
等等。
并且在 Go 语言中,接口也可以像结构体一样进行嵌套。通过接口的嵌套,我们可以将多个接口组合成一个更大的接口,并继承各自的方法集合。具体来说,如果一个接口类型嵌套了另一个接口类型,那么该接口类型就会包含被嵌套接口类型的所有方法。示例代码如下:
type Reader interface {
Read(s string)
}
type Writer interface {
Write(s string)
}
type ReadWriter interface {
Reader
Writer
}
在上面的代码中,我们定义了三个接口类型Reader
、Writer
和ReadWriter
。其中Reader
和Writer
分别定义了Read
和Write
两个方法,而ReadWriter
则通过嵌套Reader
、Writer
两个接口类型合成了一个更大的接口类型。
怎么定义一个接口变量
声明一个接口变量
var 变量名 接口类型
-
赋值字面常量——这个字面常量需要实现了当前接口
type Runner interface{ Run() } type Person struct{} func (p Person) Run(){} var runner Runner = Person{}
在上述代码中,我们声明了一个接口类型和一个结构类型,分别是
Runner
和Person
,而Person
实现了Runner
的Run
当法。在接口变量赋值的时候,我们声明了一个Runner
接口类型的runner
,并将实现了Runner
的Person
类的实例赋值给runner
。 -
接口赋值接口——右值的方法集必须是左值的超集
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
在上面的代码中,我们定义了两个接口类型
Reader
和ReadWriter
。其中Reader
定义了Read
方法,而ReadWriter
则嵌套了Reader
接口,并且在下面定义了一个Person
类,其实现了ReadWriter
和Reader
。然后定义了一个ReadWriter
的变量rw
,用Person
实例进行赋值,并将rw
赋值给为Reader
接口类型的变量r
。我们需要注意的是:- 右值需要已经被定义(被绑定具体类型的实例);
- 右值的方法集必须是左值的超集,也就是说左值是被组合与右值内部的。
Go为接口提供的方法
接口实际上是什么类型
接口的声明是没有意义的,因为它是一种规范,而规范不能独立于某个主体而存在,所以接口必须绑定一个实例。因此会出现一个状况——一个接口变量属于某个接口类型,而绑定的实例是一个实现了该接口的自定义类型。前者被称为接口的静态类型;后者被称为接口的动态类型。
-
动态类型
动态类型是接口变量持有的实例的类型,也就是实现接口的类型。在运行时,这个接口的动态类型是可以改变的,这是实现多态性的关键。
例如,假设有以下代码:
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")) }
在该例子中,
FileWriter
和ConsoleWriter
都实现了Writer
接口中的Write
方法,因此它们都可以被赋值给Writer
类型的变量w
。通过这种方式,我们可以使用同一个Writer
变量调用不同实现类型的Write
方法,从而实现了多态。 -
静态类型
接口变量的静态类型是指在代码中声明变量时所使用的类型,是接口本身的类型。而静态类型的本质特征就是接口的方法签名集合。 两个接口如果方法签名集合相同(顺序可以不同),则两个接口语义上完全等价,因此我们可以看出,编译器判断两个接口是否相同的依据是其方法签名集合是否相同。
-
区分动态类型与静态类型的意义在于:
- 在编译时,编译器可以检查接口类型是否实现了所有必要的方法,从而避免在运行时出现类型不匹配的错误。
- Go是强类型系统,会在编译时就检查类型,以提高程序的安全性。
接口有什么方法
接口变量在运行的时候,实际存储的动态类型是会改变的,因此当需要访问的时候,我们可能需要进行类型检查,甚至于类型转换与分类处理——类型断言。
Go 语言中有两种类型断言形式,分别是“断言语句”和“类型判断表达式”。
-
断言语句
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") }
-
类型判断表达式
x.(type)
x
表示要被断言的实例type
是一个关键字,用于表示该表达式的动态类型
⚠️这个表达式不能在除了
switch
的其它地方使用, 用于根据接口类型的值的动态类型来执行不同的操作,如:var i interface{} = 10 switch v := i.(type){ case int: fmt.Println("integer:", v) case string: fmt.Println("string:", v) default: fmt.Println("unknown type") }
在上面的代码中,首先定义了一个接口类型的变量
i
,其值为整数 10。然后,使用类型判断表达式将i
的动态类型与int
、string
等类型进行匹配,并根据匹配结果执行相应的操作。由于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
,但是其实指针数组的大小是可以变的,并且在运行时编译器会使用底层指针进行访问与填充。
转载自:https://juejin.cn/post/7215772473164873784