Go语言的接口类型
接口泛指实体把自己提供给外界的一种抽象化物(可以为另一实体),用以由内部操作分离出外部沟通方法,使其能被内部修改而不影响外界其他实体与其交互的方式。
比如有了type-c接口,大家都可以用type-c去充电。不用每种手机一种充电器。
电脑有了usb接口,只要是usb接口的硬件,都可以使用。
(1) Go语言接口类型的作用
接口类型的基础知识,包括接口类型的声明、接口类型变量的定义与初始化以及类型断言
接口类型与 Go 并发语法恰分别是耦合设计与并发设计的主要参与者,因此 Go 应用的骨架设计离不开它们。一个良好的骨架设计又决定了应用的健壮性、灵活性与扩展性,甚至是应用的运行效率。
(1.1) 接口
接口类型是由 type 和 interface 关键字定义的一组方法集合,其中,方法集合唯一确定了这个接口类型所表示的接口。
// Animal 动物接口
type Animal interface {
Say() string
Eat() string
}
Go 语言要求接口类型声明中的方法必须是具名的,并且方法名字在这个接口类型的方法集合中是唯一的。
type Interface1 interface {
M1()
}
type Interface2 interface {
M1(string)
M2()
}
type Interface3 interface{
Interface1
Interface2 // 编译器报错:duplicate method M1
M3()
}
空接口
type EmptyInterface interface {
}
如果一个接口类型定义中没有一个方法,那么它的方法集合就为空,方法集合为空的接口类型就被称为空接口类型
(1.2) 泛型能力
如果一个变量的类型是空接口类型,由于空接口类型的方法集合为空,这就意味着任何类型都实现了空接口的方法集合,所以我们可以将任何类型的值作为右值,赋值给空接口类型的变量
var i interface{} = 15 // ok
i = "hello, golang" // ok
type T struct{}
var t T
i = t // ok
i = &t // ok
空接口类型的这一可接受任意类型变量值作为右值的特性,让他成为 Go 加入泛型语法之前唯一一种具有“泛型”能力的语法元素
Go 语言还支持接口类型变量赋值的“逆操作”,也就是通过接口类型变量“还原”它的右值的类型与值信息,这个过程被称为“类型断言(Type Assertion)”。
v, ok := i.(T)
var a int64 = 13
var i interface{} = a
v1, ok := i.(int64)
fmt.Printf("v1=%d, the type of v1 is %T, ok=%t\n", v1, v1, ok) // v1=13, the type of v1 is int64, ok=true
v2, ok := i.(string)
fmt.Printf("v2=%s, the type of v2 is %T, ok=%t\n", v2, v2, ok) // v2=, the type of v2 is string, ok=false
v3 := i.(int64)
fmt.Printf("v3=%d, the type of v3 is %T\n", v3, v3) // v3=13, the type of v3 is int64
v4 := i.([]int) // panic: interface conversion: interface {} is int64, not []int
fmt.Printf("the type of v4 is %T\n", v4)
go1.18 增加了 any 关键字,用以替代现在的 interface{} 空接口类型:type any = interface{},实际上是 interface{} 的别名。
(2) 接口使用
(2.1) 接口组合
Go 语言之父 Rob Pike 曾说过:如果 C++ 和 Java 是关于类型层次结构和类型分类的语言,那么 Go 则是关于组合的语言。
(2.1.1) 垂直组合
传统面向对象编程语言(比如:C++)大多是通过继承的方式建构出自己的类型体系的,但 Go 语言并没有类型体系的概念。Go 语言通过类型的组合而不是继承让单一类型承载更多的功能。由于这种方式与硬件配置升级的垂直扩展很类似,所以这里我们叫它垂直组合。
通过垂直组合定义的新类型与被嵌入的类型之间就没有所谓“父子关系”的概念了,也没有向上、向下转型(Type Casting),被嵌入的类型也不知道将其嵌入的外部类型的存在。调用方法时,方法的匹配取决于方法名字,而不是类型。
// $GOROOT/src/io/io.go
type ReadWriter interface {
Reader
Writer
}
(2.1.2) 水平组合
func Save(f *os.File, data []byte) error
func (f *File) Chdir() error
func (f *File) Chmod(mode FileMode) error
func (f *File) Chown(uid, gid int) error
... ...
Save 函数违背了接口分离原则。
Save 函数对 os.File 的强依赖让它失去了扩展性。像 Save 这样的功能函数,它日后很大可能会增加向网络存储写入数据的功能需求。但如果到那时我们再来改变 Save 函数的函数签名(参数列表 + 返回值)的话,将影响到 Save 函数的所有调用者。
func Save(w io.Writer, data []byte) error
func TestSave(t *testing.T) {
b := make([]byte, 0, 128)
buf := bytes.NewBuffer(b)
data := []byte("hello, golang")
err := Save(buf, data)
if err != nil {
t.Errorf("want nil, actual %s", err.Error())
}
saved := buf.Bytes()
if !reflect.DeepEqual(saved, data) {
t.Errorf("want %s, actual %s", string(data), string(saved))
}
}
通过 bytes.NewBuffer 创建了一个 *bytes.Buffer 类型变量 buf,由于 bytes.Buffer 实现了 Write 方法,进而实现了 io.Writer 接口,我们可以合法地将变量 buf 传递给 Save 函数。
之后我们可以从 buf 中取出 Save 函数写入的数据内容与预期的数据做比对,就可以达到对 Save 函数进行单元测试的目的了。
(2.2) 尽量定义“小接口”
小接口有哪些优势?
第一点:接口越小,抽象程度越高
// 会飞的
type Flyable interface {
Fly()
}
// 会游泳的
type Swimable interface {
Swim()
}
// 会飞且会游泳的
type FlySwimable interface {
Flyable
Swimable
}
第二点:小接口易于实现和测试
第三点:小接口表示的“契约”职责单一,易于复用组合
(3) 接口的静态特性与动态特性
接口类型作为参与构建 Go 应用骨架的重要参与者,在 Go 语言中有着很高的地位。它这个地位的取得离不开它拥有的“动静兼备”的语法特性。Go 接口的动态特性让 Go 拥有与动态语言相近的灵活性,而静态特性又在编译阶段保证了这种灵活性的安全。
(3.1) 静态特性
接口的静态特性体现在接口类型变量具有静态类型,比如var err error中变量 err 的静态类型为 error。拥有静态类型,那就意味着编译器会在编译阶段对所有接口类型变量的赋值操作进行类型检查,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。
var err error = 1 // cannot use 1 (type int) as type error in assignment: int does not implement error (missing Error method)
(3.2) 动态特性
(implements)
go里可以通过接口实现多态
(3.2.1) 基本方法实现多态及扩展
// Animal 动物接口
type Animal interface {
Say() string
Eat() string
}
// Dog
type Dog struct {
Id int
Name string
Age string
}
func (d Dog) Say() string {
return "汪汪"
}
func (d Dog) Eat() string {
return "吃骨头"
}
func (d *Dog) Guard() string {
return "看门"
}
// Cat
type Cat struct {
Id int
Name string
Age string
}
func (c Cat) Say() string {
return "喵喵"
}
func (c Cat) Eat() string {
return "吃鱼"
}
package main
import "fmt"
func main() {
animals := []Animal{Dog{}, Cat{}}
for _, animal := range animals {
fmt.Println(animal.Say())
switch animal.(type) {
case Dog:
fmt.Println("dog")
case Cat:
fmt.Println("cat")
case Animal:
fmt.Println("Animal")
default:
fmt.Println("测试")
}
}
}
(3.2.2) 指针方法多态及switch
// Animal 动物接口
type Animal interface {
Say() string
Eat() string
}
// Dog
type Dog struct {
Id int
Name string
Age string
}
func (d *Dog) Say() string {
return "汪汪"
}
func (d *Dog) Eat() string {
return "吃骨头"
}
func (d *Dog) Guard() string {
return "看门"
}
// Cat
type Cat struct {
Id int
Name string
Age string
}
func (c *Cat) Say() string {
return "喵喵"
}
func (c *Cat) Eat() string {
return "吃鱼"
}
package main
import "fmt"
func main() {
animals := []Animal{&Dog{}, &Cat{}}
for _, animal := range animals {
fmt.Println(animal.Say())
switch animal.(type) {
//case Dog: // Impossible type switch case: 'Dog' does not implement 'Animal'
// fmt.Println("dog")
//case Cat: // Impossible type switch case: 'Cat' does not implement 'Animal'
// fmt.Println("cat")
case *Dog:
fmt.Println("*dog")
case *Cat:
fmt.Println("*cat")
case Animal:
fmt.Println("Animal")
default:
fmt.Println("测试")
}
}
}
看到 Impossible type switch case: 'Dog' does not implement 'Animal'
很不理解,看了半天才知道原因。原来是指针类型的*Dog实现了Animal接口,所以 case 后面要跟 *Dog
而不能用Dog
(4) interface源码解读
// $GOROOT/src/runtime/runtime2.go
type eface struct {
_type *_type
data unsafe.Pointer
}
type iface struct {
tab *itab
data unsafe.Pointer
}
在运行时层面,接口类型变量有两种内部表示:iface和eface,这两种表示分别用于不同的接口类型变量:
eface 用于表示没有方法的空接口(empty interface)类型变量,也就是 interface{}类型的变量;
iface 用于表示其余拥有方法的接口 interface 类型变量。
// $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 implement inter.
}
可以看到,itab 结构中的第一个字段inter指向的 interfacetype 结构,存储着这个接口类型自身的信息。
// $GOROOT/src/runtime/type.go
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}
(5) 接口类型的装箱(boxing)原理
装箱(boxing)是编程语言领域的一个基础概念,一般是指把一个值类型转换成引用类型,比如在支持装箱概念的 Java 语言中,将一个 int 变量转换成 Integer 对象就是一个装箱操作。
在 Go 语言中,将任意类型赋值给一个接口类型变量也是装箱操作。
接口类型的装箱实际就是创建一个 eface 或 iface 的过程。
// interface_internal.go
type T struct {
n int
s string
}
func (T) M1() {}
func (T) M2() {}
type NonEmptyInterface interface {
M1()
M2()
}
func main() {
var t = T{
n: 17,
s: "hello, interface",
}
var ei interface{}
ei = t
var i NonEmptyInterface
i = t
fmt.Println(ei)
fmt.Println(i)
}
这个例子中,对 ei 和 i 两个接口类型变量的赋值都会触发装箱操作,要想知道 Go 在背后做了些什么,我们需要“下沉”一层,也就是要输出上面 Go 代码对应的汇编代码
$go tool compile -S interface_internal.go > interface_internal.s
onvT2E 用于将任意类型转换为一个 eface,convT2I 用于将任意类型转换为一个 iface。
两个函数的实现逻辑相似,主要思路就是根据传入的类型信息(convT2E 的 _type 和 convT2I 的 tab._type)分配一块内存空间,并将 elem 指向的数据拷贝到这块内存空间中,最后传入的类型信息作为返回值结构中的类型信息,返回值结构中的数据指针(data)指向新分配的那块内存空间。由此我们也可以看出,经过装箱后,箱内的数据,也就是存放在新分配的内存空间中的数据与原变量便无瓜葛了
func main() {
var n int = 61
var ei interface{} = n
n = 62 // n的值已经改变
fmt.Println("data in box:", ei) // 输出仍是61
}
(6) 接口应用的几种模式
(6.1) 使用接受接口类型参数的函数或方法
func YourFuncName(param YourInterfaceType)
函数 / 方法参数中的接口类型作为“关节(连接点)”,支持将位于多个包中的多个类型与 YourFuncName 函数连接到一起,共同实现某一新特性。
接口类型和它的实现者之间隐式的关系却在不经意间满足了:依赖抽象(DIP)、里氏替换原则(LSP)、接口隔离(ISP)等代码设计原则
(6.2) 创建模式
Go 社区流传一个经验法则:“接受接口,返回结构体(Accept interfaces, return structs)”,这其实就是一种把接口作为“关节”的应用模式。我这里把它叫做创建模式,是因为这个经验法则多用于创建某一结构体类型的实例。
// $GOROOT/src/sync/cond.go
type Cond struct {
... ...
L Locker
}
func NewCond(l Locker) *Cond {
return &Cond{L: l}
}
// $GOROOT/src/log/log.go
type Logger struct {
mu sync.Mutex
prefix string
flag int
out io.Writer
buf []byte
}
func New(out io.Writer, prefix string, flag int) *Logger {
return &Logger{out: out, prefix: prefix, flag: flag}
}
以上面 log 包的 New 函数为例,这个函数用于实例化一个 log.Logger 实例,它接受一个 io.Writer 接口类型的参数,返回 *log.Logger。从 New 的实现上来看,传入的 out 参数被作为初值赋值给了 log.Logger 结构体字段 out。
// $GOROOT/src/log/log.go
type Writer struct {
err error
buf []byte
n int
wr io.Writer
}
func NewWriterSize(w io.Writer, size int) *Writer {
// Is it already a Writer?
b, ok := w.(*Writer)
if ok && len(b.buf) >= size {
return b
}
if size <= 0 {
size = defaultBufSize
}
return &Writer{
buf: make([]byte, size),
wr: w,
}
}
创建模式通过接口,在 NewXXX 函数所在包与接口的实现者所在包之间建立了一个连接。大多数包含接口类型字段的结构体的实例化,都可以使用创建模式实现。
(6.3) 包装器模式
可以实现对输入参数的类型的包装,并在不改变被包装类型(输入参数类型)的定义的情况下,返回具备新功能特性的、实现相同接口类型的新类型。这种接口应用模式我们叫它包装器模式,也叫装饰器模式。包装器多用于对输入数据的过滤、变换等操作。
// $GOROOT/src/io/io.go
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }
type LimitedReader struct {
R Reader // underlying reader
N int64 // max bytes remaining
}
func (l *LimitedReader) Read(p []byte) (n int, err error) {
// ... ...
}
通过 LimitReader 函数的包装后,我们得到了一个具有新功能特性的 io.Reader 接口的实现类型,也就是 LimitedReader。这个新类型在 Reader 的语义基础上实现了对读取字节个数的限制。
func main() {
r := strings.NewReader("hello, gopher!\n")
lr := io.LimitReader(r, 4)
if _, err := io.Copy(os.Stdout, lr); err != nil {
log.Fatal(err)
}
}
(6.4) 适配器模式
适配器模式的核心是适配器函数类型(Adapter Function Type)。适配器函数类型是一个辅助水平组合实现的“工具”类型。这里我要再强调一下,它是一个类型。它可以将一个满足特定函数签名的普通函数,显式转换成自身类型的实例,转换后的实例同时也是某个接口类型的实现者。
// $GOROOT/src/net/http/server.go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
func greetings(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome!")
}
func main() {
http.ListenAndServe(":8080", http.HandlerFunc(greetings))
}
经过 HandlerFunc 的适配转化后,我们就可以将它的实例用作实参,传递给接收 http.Handler 接口的 http.ListenAndServe 函数,从而实现基于接口的组合。
(6.5) 中间件(Middleware)
中间件(Middleware)这个词的含义可大可小。在 Go Web 编程中,“中间件”常常指的是一个实现了 http.Handler 接口的 http.HandlerFunc 类型实例。实质上,这里的中间件就是包装模式和适配器模式结合的产物。
func validateAuth(s string) error {
if s != "123456" {
return fmt.Errorf("%s", "bad auth token")
}
return nil
}
func greetings(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome!")
}
func logHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t := time.Now()
log.Printf("[%s] %q %v\n", r.Method, r.URL.String(), t)
h.ServeHTTP(w, r)
})
}
func authHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := validateAuth(r.Header.Get("auth"))
if err != nil {
http.Error(w, "bad auth param", http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}
func main() {
http.ListenAndServe(":8080", logHandler(authHandler(http.HandlerFunc(greetings))))
}
所谓中间件(如:logHandler、authHandler)本质就是一个包装函数(支持链式调用),但它的内部利用了适配器函数类型(http.HandlerFunc),将一个普通函数(比如例子中的几个匿名函数)转型为实现了 http.Handler 的类型的实例。
参考资料
[1] 28|接口:接口即契约
[2] 29|接口:为什么nil接口不等于nil?
[3] 30|接口:Go中最强大的魔法
[2] Go语言设计与实现-4.2-接口
[3] Go语言基础之接口
[4] Go语言与鸭子类型的关系