接口
认识接口类型
- 接口类型是由 type 和 interface 关键字定义的一组方法集合,其中,方法集合唯一确定了这个接口类型所表示的接口。
type MyInterface interface { M1(int) error M2(io.Writer, ...string) }
- 我们在接口类型的方法集合中声明的方法,它的参数列表不需要写出形参名字,返回值列表也是如此。也就是说,方法的参数列表中形参名字与返回值列表中的具名返回值,都不作为区分两个方法的凭据。
- Go 语言要求接口类型声明中的方法必须是具名的,并且方法名字在这个接口类型的方法集合中是唯一的。
- Go 1.14 版本以后,Go 接口类型允许嵌入的不同接口类型的方法集合存在交集,但前提是交集中的方法不仅名字要一样,它的函数签名部分也要保持一致,也就是参数列表与返回值列表也要相同,否则 Go 编译器照样会报错。
- 在 Go 接口类型的方法集合中放入首字母小写的非导出方法也是合法的。如果接口类型的方法集合中包含非导出方法,那么这个接口类型自身通常也是非导出的,它的应用范围也仅局限于包内。
- 方法集合为空的接口类型就被称为空接口类型,但通常我们不需要自己显式定义这类空接口类型,我们直接使用interface{}这个类型字面值作为所有空接口类型的代表就可以了。
- 接口类型一旦被定义后,它就和其他 Go 类型一样可以用于声明变量,比如:
var err error // err是一个error接口类型的实例变量 var r io.Reader // r是一个io.Reader接口类型的实例变量
- 这些类型为接口类型的变量被称为接口类型变量,如果没有被显式赋予初值,接口类型变量的默认值为 nil。
- 如果要为接口类型变量显式赋予初值,我们就要为接口类型变量选择合法的右值。
- 如果一个类型 T 的方法集合是某接口类型 I 的方法集合的等价集合或超集,我们就说类型 T 实现了接口类型 I,那么类型 T 的变量就可以作为合法的右值赋值给接口类型 I 的变量。
- Go 语言还支持接口类型变量赋值的“逆操作”,也就是通过接口类型变量“还原”它的右值的类型与值信息,这个过程被称为“类型断言(Type Assertion)”。
1 v, ok := i.(T)
- 其中 i 是某一个接口类型变量,如果 T 是一个非接口类型且 T 是想要还原的类型,那么这句代码的含义就是断言存储在接口类型变量 i 中的值的类型为 T。
- 如果接口类型变量 i 之前被赋予的值确为 T 类型的值,那么这个语句执行后,左侧“comma, ok”语句中的变量 ok 的值将为 true,变量 v 的类型为 T,它值会是之前变量 i 的右值。
- 如果 i 之前被赋予的值不是 T 类型的值,那么这个语句执行后,变量 ok 的值为 false,变量 v 的类型还是那个要还原的类型,但它的值是类型 T 的零值。
- 类型断言也支持下面这种语法形式:
1 v := i.(T)
- 但在这种形式下,一旦接口变量 i 之前被赋予的值不是 T 类型的值,那么这个语句将抛出 panic。
- 如果变量 i 被赋予的值是 T 类型的值,那么变量 v 的类型为 T,它的值就会是之前变量 i 的右值。由于可能出现 panic,所以我们并不推荐使用这种类型断言的语法形式。
- 接口类型的背后,是通过把类型的行为抽象成契约,建立双方共同遵守的约定,这种契约将双方的耦合降到了最低的程度。
- 隐式契约,无需签署,自动生效
- Go 语言中接口类型与它的实现者之间的关系是隐式的,不需要像其他语言(比如 Java)那样要求实现者显式放置“implements”进行修饰,实现者只需要实现接口方法集合中的
全部方法便算是遵守了契约,并立即生效了。
- Go 语言中接口类型与它的实现者之间的关系是隐式的,不需要像其他语言(比如 Java)那样要求实现者显式放置“implements”进行修饰,实现者只需要实现接口方法集合中的
- 更倾向于“小契约
- Go 选择了使用“小契约”,表现在代码上就是尽量定义小接口,即方法个数在 1~3 个之间的接口。
- 接口越小,抽象程度越高。
- 小接口易于实现和测试。
- 小接口表示的“契约”职责单一,易于复用组合。
- Go 选择了使用“小契约”,表现在代码上就是尽量定义小接口,即方法个数在 1~3 个之间的接口。
- 隐式契约,无需签署,自动生效
接口的静态特性与动态特性
- 接口的静态特性体现在接口类型变量具有静态类型,比如 var err error中变量 err 的静态类型为 error。拥有静态类型,那就意味着编译器会在编译阶段对所有接口类型变量的赋值操作进行类型检查,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。
- 接口的动态特性,就体现在接口类型变量在运行时还存储了右值的真实类型信息,这个右值的真实类型被称为接口类型变量的动态类型。
- 首先,接口类型变量在程序运行时可以被赋值为不同的动态类型变量,每次赋值后,接口类型变量中存储的动态类型信息都会发生变化,这让 Go 语言可以像动态语言(比如 Python)那样拥有使用 Duck Typing(鸭子类型)的灵活性。
- 所谓鸭子类型,就是指某类型所表现出的特性(比如是否可以作为某接口类型的右值),不是由其基因(比如 C++ 中的父类)决定的,而是由类型所表现出来的行为(比如类型拥有的方法)决定的。
接口类型变量的内部表示
- 在运行时层面,接口类型变量有两种内部表示:iface和eface,这两种表示分别用于不同的接口类型变量:
// $GOROOT/src/runtime/runtime2.go type iface struct { tab *itab data unsafe.Pointer } type eface struct { _type *_type data unsafe.Pointer }
- eface 用于表示没有方法的空接口(empty interface)类型变量,也就是 interface{} 类型的变量;
- iface 用于表示其余拥有方法的接口 interface 类型变量。
- 这两个结构的共同点是它们都有两个指针字段,并且第二个指针字段的功能相同,都是指向当前赋值给该接口类型变量的动态类型变量的值。
- 那它们的不同点在哪呢?
- eface 表示的空接口类型并没有方法列表,因此它的第一个指针字段指向一个 _type 类型结构,这个结构为该接口类型变量的动态类型的信息:
// $GOROOT/src/runtime/type.go type _type struct { size uintptr ptrdata uintptr // size of memory prefix holding all pointers hash uint32 tflag tflag align uint8 fieldAlign uint8 kind uint8 // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? equal func(unsafe.Pointer, unsafe.Pointer) bool // gcdata stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, gcdata is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. gcdata *byte str nameOff ptrToThis typeOff }
- iface 除了要存储动态类型信息之外,还要存储接口本身的信息(接口的类型信息、方法列表信息等)以及动态类型所实现的方法的信息,因此 iface 的第一个字段指向一个 itab类型结构。
// $GOROOT/src/runtime/runtime2.go type itab struct { inter *interfacetype _type *_type hash uint32 // copy of _type.hash. Used for type switches. _ [4]byte fun [1]uintptr // variable sized. fun[0]==0 means _type does not impleme }
- itab 结构中的第一个字段 inter 指向 interfacetype 结构,存储着这个接口类型自身的信息。
- 这个 interfacetype 结构由类型信息(typ)、包路径名(pkgpath)和接口方法集合切片(mhdr)组成。
// $GOROOT/src/runtime/type.go type interfacetype struct { typ _type pkgpath name mhdr []imethod }
- itab 结构中的字段 _type 则存储着这个接口类型变量的动态类型的信息,字段 fun 则是动态类型已实现的接口方法的调用地址数。
- eface 表示的空接口类型并没有方法列表,因此它的第一个指针字段指向一个 _type 类型结构,这个结构为该接口类型变量的动态类型的信息:
- 对于空接口类型变量,只有 _type 和 data 所指数据内容一致的情况下,两个空接口类型变量之间才能划等号。
- Go 在进行空接口类型变量和非空接口类型变量的等值比较时,类型比较使用的是 eface 的 _type 和 iface 的 tab._type。
- 接口类型变量在运行时表示为 eface 和 iface,eface 用于表示空接口类型变量,iface 用于表示非空接口类型变量。只有两个接口类型变量的类型信息(eface._type/iface.tab._type)相同,且数据指针(eface.data/iface.data)所指数据相同时,两个接口类型变量才是相等的。
接口类型的装箱(boxing)原理
- 装箱(boxing)是编程语言领域的一个基础概念,一般是指把一个值类型转换成引用类型。
- 在 Go 语言中,将任意类型赋值给一个接口类型变量也是装箱操作。接口类型的装箱实际就是创建一个 eface 或 iface 的过程。
- 编译器知道每个要转换为接口类型变量(toType)和动态类型变量的类型(fromType),它会根据这一对类型选择适当的 convT2X 函数,并在生成代码时使用选出的 convT2X 函数参与装箱操作。
- 不过,装箱是一个有性能损耗的操作,因此 Go 也在不断对装箱操作进行优化,包括对常见类型如整型、字符串、切片等提供系列快速转换函数。
- Go 建立了 staticuint64s 区域,对 255 以内的小整数值进行装箱操作时不再分配新内存,而是利用 staticuint64s 区域的内存空间。
一切皆组合
- 如果 C++ 和 Java 是关于类型层次结构和类型分类的语言,那么 Go 则是关于组合的语言。
- 构建 Go 应用程序的静态骨架结构有两种主要的组合方式:
- 垂直组合
- Go 语言通过类型的组合而不是继承让单一类型承载更多的功能。由于这种方式与硬件配置升级的垂直扩展很类似,所以这里我们叫它垂直组合。
- 垂直组合更多应用在新类型的定义方面。通过这种垂直组合,我们可以达到方法实现的复用、接口定义重用等目的。
- 在实现层面,Go 语言通过类型嵌入(Type Embedding)实现垂直组合,组合方式主要有以下这么几种。
- 通过嵌入接口构建接口。
- 通过嵌入接口构建结构体类型。
- 通过嵌入结构体类型构建新结构体类型。
- 水平组合
- 接口可以将各个类型水平组合(连接)在一起。通过接口的编织,整个应用程序不再是一个个孤立的“器官”,而是一幅完整的、有灵活性和扩展性的静态骨架结构。
接口应用的几种模式
- 通过接口进行水平组合的基本模式就是:使用接受接口类型参数的函数或方法。
- 基本模式
- 接受接口类型参数的函数或方法是水平组合的基本语法,形式是这样的:
func YourFuncName(param YourInterfaceType)
。 - 函数 / 方法参数中的接口类型作为“关节(连接点)”,支持将位于多个包中的多个类型与 YourFuncName 函数连接到一起,共同实现某一新特性。
- 接受接口类型参数的函数或方法是水平组合的基本语法,形式是这样的:
- 创建模式
- Go 社区流传一个经验法则:“接受接口,返回结构体(Accept interfaces, return structs)”,这其实就是一种把接口作为“关节”的应用模式。
- 这里把它叫做创建模式,是因为这个经验法则多用于创建某一结构体类型的实例。
- 包装器模式
- 在基本模式的基础上,当返回值的类型与参数类型相同时,我们能得到下面形式的函数原型:
func YourWrapperFunc(param YourInterfaceType) YourInterfaceType
。 - 通过这个函数,我们可以实现对输入参数的类型的包装,并在不改变被包装类型(输入参数类型)的定义的情况下,返回具备新功能特性的、实现相同接口类型的新类型。
- 这种接口应用模式我们叫它包装器模式,也叫装饰器模式。
- 包装器多用于对输入数据的过滤、变换等操作。
- 在基本模式的基础上,当返回值的类型与参数类型相同时,我们能得到下面形式的函数原型:
- 适配器模式
- 适配器模式的核心是适配器函数类型(Adapter Function Type)。
- 适配器函数类型是一个辅助水平组合实现的“工具”类型。
- 它可以将一个满足特定函数签名的普通函数,显式转换成自身类型的实例,转换后的实例同时也是某个接口类型的实现者。
- 中间件(Middleware)
- 中间件就是包装模式和适配器模式结合的产物。
- 尽量避免使用空接口作为函数参数类型。