Golang接口

Posted by 淦 Blog on January 30, 2023

使用方法

声明与定义

接口是Go语言中的特殊类型,其包含两种形式:一种是带方法签名的接口,一种是空接口。带方法签名的接口内部包含其他类型可以实现的方法签名的集合。

1
2
3
4
type InterfaceName interface {
	funcNameA()
	funcNameB(a int, b string) error
}
1
type InterfaceName interface{}

如果只对接口进行声明,则当前接口变量为nil

1
var i InterfaceName

实现

接口的实现是隐式的。即不用明确地指出某一个类型实现了某一个接口,只要在某一类型的方法中实现了接口中的全就意味着此类型实现了这一接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Shape interface {
	perimeter() float64
	area() float64
}

type Rectangle struct {
	a, b float64
}

func (r Rectangle) perimeter() float64 {
	return (r.a + r.b) * 2
}

func (r Rectangle) area() float64 {
	return r.a * r.b
}

只要Rectangle实现了Shape接口中所有的方法签名,就说Rectangle实现了Shape接口。即使Rectangle还拥有其他方法,即使没有任何显式的声明,也不受影响。 另外,可以有多个类型实现同一个接口。

1
2
3
4
5
6
7
8
9
10
11
12
type Triangle struct {
	a, b, c float64
}

func (t Triangle) perimeter() float64 {
	return t.a + t.b + t.c
}

func (t Triangle) area() float64 {
	p := t.perimeter() / 2
	return math.Sqrt(p * (p - t.a) * (p - t.b) * (p - t.c))
}

动态类型

一个接口类型的变量能够接收任何实现了此接口的用户自定义类型。

1
2
3
var s Shape
s = Rectangle(3, 4)
s = Triangle(3, 4, 5)

将存储在接口中的类型(例如Rectangle,Triangle) 称为接口的动态类型,而将接口本身的类型称为接口的静态类型(例如Shape)。

动态调用

当接口变量中存储了具体的动态类型时,可以调用接口中所有的方法。接口动态调用的过程实质上是调用当前按口动态类型中具体方法的过程。 在对接口变量进行动态调用时,调用的方法只能是接口中具有的方法,无法调用除接口方法外的其他方法。

多接口

一个类型可以同时实现多个接口。

组合

定义的接口可以是其他接口的组合。

1
2
3
4
5
6
7
8
9
10
11
12
type ReadWriter interface {
	Reader
	Writer
}

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

Go语言的设计者认为间,对于传统拥有类型继承的面向对象语言,必须尽早设计其层次结构,一日开始编写程序,早期决策就很难改变。这种方式导致了早期的过度设计,因为开发者试图预测程序所有可能的行为,增加了不必要的类型和抽象层。

Go语言在设计之初,就鼓励开发者使用组合而不是继承的方式来编写程序。通常使用一种方法的接口来定义琐碎的行为,这些行为充当组件之间清晰、可理解的边界。Go语言中的接口可以使程序自然、优雅、安全地增长,接口的更改仅影响实现接口的直接类型。

类型断言

使用语法i.(Type)在运行时获取存储在接口中的类型。其中i代表接口,Type代表实现 此接口的动态类型。

1
2
3
4
5
6
func main() {
	var s Shape
	s = new(Rectangle)
	rect := s.(Rectangle)
	fmt.Println(rect.perimeter(), rect.area())
}

在编译时会保证类型Type一定是实现了接口i的类型,否则编译不会通过。虽然Go语言在编译时已经防止了此类错误,但是仍然需要在运行时判断一次, 这是由于在类型断言方法m=i.(Type)中,当Type实现了接口i,而接口内部没有任何动态类型(此时为nil)时,在运行时会直接panic,因为nil无法调用任何方法。

1
2
var s Shape
rect := s.(Rectangle)

通过返回的ok变量判断接口变量i当前是否存储了实现Type的动态类型。

1
2
3
4
rect, ok := s.(Rectangle)
if !ok {
	fmt.Println("s.(Rectangle) is not ok")
}

转换

一个接口可以在某些时候转换为另一个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Shape interface {
	perimeter() float64
	area() float64
}

type Areaifer interface {
	area() float64
}

func main() {
	var shape Shape
	var area Areaifer
	// 可以使用如下方式进行转换
	area = shape
	// 反过来却不行
	shape = area
}

空接口

接口中没有任何方法签名,叫作空接口。空接口可以存储结构体、字符串、整数等任何类型。

底层原理

实现算法

通常来说,如果类型的方法与接口的方法是完全无序的状态,并且类型有m个方法,接口声明了n个方法,那么总的时间复杂度的最坏情况应该为o(m*n),即需要分别遍历类型与接口中的方法。Go语言在编译时对此做出的优化是先将类型与接口中的方法进行相同规则的排序,再将对应的方法进行比较。

根据接口的有序规则,遍历接口方法列表,并在类型对应方法列表的序号i后查找是否存在相同的方法。如果查找不到,则说明类型对应的方法列表中并无此方法,因此在编译时会报错。由于同一个类型或接口的排序在整个编译时只会进行一次,因此排序的消耗可以忽略不计。排序后最坏的时间复杂度仅为o(m+n)。

组成

带方法签名的接口

在运行时的具体结构由iface构成。

1
2
3
4
type iface struct {
    tab   *itab
    data unsafe.Pointer
}

data字段存储了接口中动态类型的函数指针。tab 字段存储了接口的类型、接口中的动态数据类型、动态数据类型的函数指针等。 组成接口的itab 类型如下,itab 是接口的核心。

1
2
3
4
5
6
7
type itab struct {
    inter  *interfacetype
    _type  *_type
    hash  uint32
    _     [4]byte
    fun   [1]uintptr
}
  • inter 字段代表接口本身的类型,类型intrerfacetype是对_type 的简单包装。
1
2
3
4
5
type interfacetype struct {
    typ    _type
    pkgpath name
    mhdr  [ ]imethod
}

pkgpath代表接口所在的包名,mhdr []imethod表示接口中暴露的方法在最终可执行文件中的名字和类型的偏移量。

  • _type字段代表接口存储的动态类型。Go语言的各种数据类型都是在_type 字段的基础上通过增加额外字段来管理的,_type包含了类型的大小、哈希、标志、偏移量等元数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
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
}

接口变量中存储了接口本身的类型元数据、动态数据类型的元数据、动态数据类型的值及实现了接口的函数指针。

空接口

对于空接口,Go语言在运行时使用了特殊的eface类型,其在64位系统中占据16字节。

1
2
3
4
type eface struct {
    _type *_type
    data   unsafe.Pointer
}

陷阱

  • 当接口中存储的是值,但是结构体是指针时,动态调用无法编译通过。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Binary struct {
	uint64
}

func (i *Binary) String() string {
	return "hello world"
}

type Stringer interface {
	String() string
}

func main() {
	a := Binary{54}
	b := Stringer(a)
	b.String()
}

报错为:Cannot convert an expression of the type ‘Binary’ to the type ‘Stringer’

Go语言在编译时阻止了这样的写法,原因在于这种写法会让人产生困惑。如果接口中是值,那么其必定已经对原始值进行了复制,在堆区产生了副本。而如果允许这种写法,那么即使修改了接口中的值也不会修改原始值,非常容易产生误解。

  • 将类型切片转换为接口切片
1
2
3
func foo() []interface{} {
	return []int{1, 2, 3}
}

Go语言禁止了这种写法,批量转换为接口是效率非常低的操作。每个元素都需要转换为接口,并且数据需要内存逃逸。

  • 接口与nil之间的关系

在下面的foo函数中,由于返回的err没有任何动态类型和动态值,因此err != nil 为true。

1
2
3
4
5
6
7
8
9
10
11
func foo() error {
	var err error
	return err
}

func main() {
	err := foo()
	if err == nil {
		// true
	}
}

当接口为nil时,代表接口中的动态类型和动态类型值都为nil。由于接口error 具有动态类型*os.PathError,因此err== nil为false。

1
2
3
4
5
6
7
8
9
10
11
func foo() error {
	var err *os.PathError
	return err
}

func main() {
	err := foo()
	if err == nil {
		// false
	}
}