专栏目录
2 gofmt:Go代码风格的唯一标准 13 Go 函数是“一等公民”的理解 43 让你的Go包拥有个性化的导入路径 10 Go 字符串是原生类型 34 一文告诉你如何在 Go 中实现 HTTPS 通信 14 defer 让你的代码更清晰 23 Go channel 的常见使用模式 41 与时俱进!使用module管理依赖包 37 time包,你用对了吗 17 go变长参数函数的妙用 45 未雨绸缪!Go语言常见“坑”大汇 27 不要让 panic 掺和到正常错误处理中 22 Go 并发模型和常见并发模式 25 别笑!这就是 Go 的错误处理哲学 18 定义小接口是 Go 的惯例 4 变量声明形式尽量保持一致 20 要提高代码可测试性,请使用接口 38 小心被kill!不要忽略对系统信号的处理 35 告别乱码!GO语言字符集编码方案间转换 6 Go“枚举常量”的惯用实现方法 16 方法集合决定接口实现 39 慎用reflect包提供的反射能力 9 深入理解和高效运用切片 21 面试必考!掌握 goroutine 的调度原理 1 参考 Go 项目布局设计你的项目结构 19 不要在函数参数中使用空接口(interface{}) 11 理解包导入路径的含义 26 if err != nil 重复太多可以这么办 24 sync 包的正确使用姿势 15 Go 方法的本质 30 Go 惯例:将测试依赖的外部数据文件放在 testdata 下面 31 为被测对象建立性能基准 33 掌握 Go 代码调试利器:delve 29 Go 单元测试惯例:表驱动 42 小即是美?构建最小Go程序容器镜像 7 go语言定义“零值可用”的类型 12 go语言 init 函数的妙用 44 利其器!Go常用工具大检阅 28 一文告诉你测试包的包名要不要带“\_test”后缀 5 无类型常量让代码更简化 36 像极!bytes包和strings包的那些相似操作 3 Go 标识符的命名惯例 32 掌握 Go 代码性能剖析神器:pprof 8 用复合字面值作初值构造器 40 与C互操作不是免费的!疑问了解cgo的使用成本

16 方法集合决定接口实现

九路
• 阅读 814

方法集合决定接口实现

自定义类型的方法和接口(interface)都是 Go 语言中的重要概念,并且它们之间存在千丝万缕的联系。我们来看一个例子:

// method_set_1.go
package main

type Interface interface {
    M1()
    M2()
}

type T struct{}

func (t T) M1()  {}
func (t *T) M2() {}

func main() {
    var t T
    var pt *T
    var i Interface

    i = t
    i = pt
} 

我们运行一下该示例程序:

$ go run method_set_1.go 
# command-line-arguments
./method_set_1.go:18:4: cannot use t (type T) as type Interface in assignment:
    T does not implement Interface (M2 method has pointer receiver) 

我们看到示例程序没有通过编译器的检查,编译器给出的错误信息是:不能使用变量 t 给接口类型变量 i 赋值,因为 t 没有实现 Interface 接口方法集合中的 M2 方法。

如果你是 Go 语言初学者,那么遇到这样的编译器错误提示信息后你一定很疑惑:我们明明为自定义类型 T 定义了 M1 和 M2 方法,为何说尚未实现 M2 方法?为何 *T 类型的 pt 就可以被正常赋值给 Interface 类型变量 i,而 T 类型的 t 就不行?

带着这些问题,我们开启本节的内容。

1. 方法集合 (Method Set)

在 “理解方法本质以正确选择 receiver 类型” 一节中我们曾提到过选择 receiver 类型除了考量是否需要对类型实例进行修改、类型实例值拷贝导致的性能损耗之外,还有一个重要考量因素,那就是类型是否要实现某个接口(interface)类型。

Go 语言的一个创新就是自定义类型与接口之间的实现关系是松耦合的:如果某个自定义类型 T 的方法集合是某个 interface 类型的方法集合的超集,那么就说类型 T 实现了该接口,并且类型 T 的变量可以被赋值给该接口类型的变量了,即我们说的方法集合决定接口实现

方法集合(Method Set)是 Go 语言中一个重要的概念,在为接口类型变量赋值、使用结构体嵌入 / 接口嵌入、类型别名(type alias)和 method expression 等时都会用到方法集合,它像 “胶水” 一样将自定义类型与接口 隐式地粘结在一起。

要判断一个自定义类型是否实现了某接口类型,我们首先要识别出自定义类型的方法集合以及接口类型的方法集合。但有些时候它们并非那么明显(比如:若存在结构体嵌入、接口嵌入、类型别名时)。这里我们实现了一个工具函数可以方便输出一个自定义类型或接口类型的方法集合。

package main

import (
        "fmt"
        "reflect"
)

// method_set_utils.go
func DumpMethodSet(i interface{}) {
        v := reflect.TypeOf(i)
        elemTyp := v.Elem()

        n := elemTyp.NumMethod()
        if n == 0 {
                fmt.Printf("%s's method set is empty!\n", elemTyp)
                return
        }

        fmt.Printf("%s's method set:\n", elemTyp)
        for j := 0; j < n; j++ {
                fmt.Println("-", elemTyp.Method(j).Name)
        }
        fmt.Printf("\n")
} 

接下来,我们就用该工具函数输出一下本节开头那个示例中的接口类型和自定义类型的方法集合:

// method_set_2.go
package main

type Interface interface {
    M1()
    M2()
}

type T struct{}

func (t T) M1()  {}
func (t *T) M2() {}

func main() {
    var t T
    var pt *T
    DumpMethodSet(&t)
    DumpMethodSet(&pt)
    DumpMethodSet((*Interface)(nil))
} 

运行上述代码:

$ go run method_set_2.go method_set_utils.go
main.T's method set:
- M1

*main.T's method set:
- M1
- M2

main.Interface's method set:
- M1
- M2 

通过上述输出结果,我们可以一目了然地看到 T、*T 和 Interface 各自的方法集合。我们看到 T 类型的方法集合中只包含 M1,无法成为与 Interface 类型的方法集合的超集,因此这就是开篇例子中编译器认为变量 t 不能赋值给 Interface 类型变量的原因。在输出的结果中,我们还看到 * T 类型的方法集合为 [M1, M2]。*T 类型没有直接实现 M1,但 M1 仍出现在 * T 类型的方法集合中了。这符合 Go 语言规范中的说法:对于非接口类型的自定义类型 T,其方法集合为所有 receiver 为 T 类型的方法组成;而类型 * T 的方法集合则包含所有 receiver 为 T 和 * T 类型的方法。也正因为如此,pt 才能成功赋值给 Interface 类型变量。

到这里,我们完全明确了为 receiver 选择类型时需要考虑的第三点因素:是否支持将 T 类型实例赋值给某个接口类型变量。如果需要支持,我们就要实现 receiver 为 T 类型的接口类型方法集合中的所有方法。

2. 类型嵌入与方法集合

Go 的设计哲学之一就是偏好组合,Go 支持用组合的思想来实现一些面向对象领域经典的机制,比如:继承。而具体的方式就是利用类型嵌入(type embeding)。

Go 支持三种类型嵌入:接口类型中嵌入接口类型、结构体类型中嵌入接口类型以及结构体类型中嵌入结构体类型,下面我们分别看一下经过类型嵌入后的类型的方法集合是什么样子的。

1) 接口类型中嵌入接口类型

按 Go 语言惯例,Go 中的接口类型中仅包含少量方法,并且常常仅是一个方法。通过在接口类型中嵌入其他接口类型可以实现接口的组合,这也是 Go 语言中基于已有接口类型构建新接口类型的惯用法,比如 io 包中的 ReadWriter、ReadWriteCloser 等接口类型就是通过嵌入 Reader、Writer 或 Closer 三个基本的接口类型组合而成的:

// $GOROOT/src/io/io.go

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

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

type Closer interface {
        Close() error
}

// 以上为三个基本接口类型
// 下面的接口类型通过嵌入上面基本接口类型而形成

type ReadWriter interface {
        Reader
        Writer
}

type ReadCloser interface {
        Reader
        Closer
}

type WriteCloser interface {
        Writer
        Closer
}

type ReadWriteCloser interface {
        Reader
        Writer
        Closer
} 

我们再来看看通过嵌入接口类型后的新接口类型的方法集合是什么样的,我们就以 Go 标准库中 io 包中的几个接口类型为例:

// method_set_3.go 
package main

import "io"

func main() {
    DumpMethodSet((*io.Writer)(nil))
    DumpMethodSet((*io.Reader)(nil))
    DumpMethodSet((*io.Closer)(nil))
    DumpMethodSet((*io.ReadWriter)(nil))
    DumpMethodSet((*io.ReadWriteCloser)(nil))
} 

运行该示例得到以下结果:

$ go run method_set_3.go method_set_utils.go
io.Writer's method set:
- Write

io.Reader's method set:
- Read

io.Closer's method set:
- Close

io.ReadWriter's method set:
- Read
- Write

io.ReadWriteCloser's method set:
- Close
- Read
- Write 

通过输出结果我们可以看出:通过嵌入其他接口类型而创建的新接口类型(比如:io.ReadWriteCloser)的方法集合包含了被嵌入接口类型(比如:io.Reader)的方法集合。

不过这种通过嵌入其他接口类型创建新接口类型的方式有一个约束,那就是被嵌入的接口类型的方法集合不能有交集 (如下面例子中的 Interface1 和 Interface2 的方法集合有交集,交集是方法 M1),同时被嵌入的接口类型的方法集合中的方法名字不能与新接口中其他方法名同名(如下面例子中的 Interface2 的 M2 与 Interface4 的 M2 重名):

// method_set_4.go
package main

type Interface1 interface {
    M1()
}

type Interface2 interface {
    M1()
    M2()
}

type Interface3 interface {
    Interface1
    Interface2 // Error: duplicate method M1
}

type Interface4 interface {
    Interface2
    M2() // Error: duplicate method M2
}

func main() {
    DumpMethodSet((*Interface3)(nil))
} 

2) 结构体类型中嵌入接口类型

在结构体类型中嵌入接口类型后,该结构体类型的方法集合中将包含被嵌入的接口类型的方法集合。比如下面这个例子:

// method_set_5.go
package main

type Interface interface {
    M1()
    M2()
}

type T struct {
    Interface
}

func (T) M3() {}

func main() {
    DumpMethodSet((*Interface)(nil))
    var t T
    var pt *T
    DumpMethodSet(&t)
    DumpMethodSet(&pt)
} 

运行该示例得到以下结果:

$ go run method_set_5.go method_set_utils.go
main.Interface's method set:
- M1
- M2

main.T's method set:
- M1
- M2
- M3

*main.T's method set:
- M1
- M2
- M3 

输出的结果与预期一致。

但有些时候结果并非总是这样,比如:当结果体嵌入多个接口类型且这些接口类型的方法集合存在交集时。为了方便后续说明,这里不得不提一下嵌入了其他接口类型的结构体类型的实例在调用方法时,Go 选择方法的次序:

  • 优先选择结构体自身实现的方法;
  • 如果结构体自身并未实现,那么将查找结构体中的嵌入接口类型的方法集中是否有该方法,如果有,则提升(promoted)为结构体的方法;

比如下面例子:

// method_set_6.go 
package main

type Interface interface {
    M1()
    M2()
}

type T struct {
    Interface
}

func (T) M1() {
    println("T's M1")
}

type S struct{}

func (S) M1() {
    println("S's M1")
}
func (S) M2() {
    println("S's M2")
}

func main() {
    var t = T{
        Interface: S{},
    }

    t.M1()
    t.M2()
} 

当通过结构体 T 的实例变量 t 调用方法 M1 时,由于 T 自身实现了 M1 方法,因此调用的是 T.M1 ();当通过变量 t 调用方法 M2 时,由于 T 自身未实现 M2 方法,于是找到结构体 T 的嵌入接口类型 Interface,发现 Interface 类型的方法集合中包含 M2 方法,于是将 Interface 类型的 M2 方法提升为结构体 T 的方法。而此时 T 类型中的匿名字段 Interface 已经赋值为 S 类型的实例,因此通过 Interface 这个嵌入字段调用的 M2 方法实质上是 S.M2 ()。

下面是上面程序的输出结果,与我们的分析一致:

$ go run method_set_6.go 
T's M1
S's M2 
  • 如果结构体嵌入了多个接口类型且这些接口类型的方法集合存在交集,那么编译器将报错,除非结构体自己实现了交集中的所有方法。

我们看一下下面的例子:

// method_set_7.go 
package main

type Interface interface {
    M1()
    M2()
    M3()
}

type Interface1 interface {
    M1()
    M2()
    M4()
}

type T struct {
    Interface
    Interface1
}

func main() {
    t := T{}
    t.M1()
    t.M2()
} 

运行该例子:

$ go run method_set_7.go
# command-line-arguments
./method_set_7.go:22:3: ambiguous selector t.M1
./method_set_7.go:23:3: ambiguous selector t.M2 

我们看到编译器给出错误提示:编译器在选择 t.M1 和 t.M2 时出现分歧,编译器不知道该选择哪一个。在这个例子中结构体类型 T 嵌入的两个接口类型 Interface 和 Interface1 的方法集合存在交集,都包含 M1 和 M2,而结构体类型 T 自身又没有实现 M1 和 M2,因此编译器在结构体类型内部的嵌入接口类型中寻找 M1/M2 方法时发现两个接口类型 Interface 和 Interface1 都包含 M1/M2,于是编译器因无法做出选择而报错。

为了让编译器能找到 M1/M2,我们可以为 T 增加 M1 和 M2 的实现,这样编译器便会直接选择 T 自己实现的 M1 和 M2,程序也就能顺利通过编译并运行了:

// method_set_8.go

... ...

type T struct {
        Interface
        Interface1
}

func (T) M1() { println("T's M1") }
func (T) M2() { println("T's M2") }

func main() {
        t := T{}
        t.M1()
        t.M2()
}

$ go run method_set_8.go            
T's M1
T's M2 

不过,我们还是要尽量避免在结构体类型中嵌入方法集合有交集的多个接口类型。

结构体类型在嵌入某接口类型的同时,它也实现了这个接口。这一特性在单元测试时尤为有用,尤其是应对在下面的场景中:

// method_set_9.go 
package employee

type Result struct {
    Count int
}

func (r Result) Int() int { return r.Count }

type Rows []struct{}

type Stmt interface {
    Close() error
    NumInput() int
    Exec(stmt string, args ...string) (Result, error)
    Query(args []string) (Rows, error)
}

// 返回男性员工总数
func MaleCount(s Stmt) (int, error) {
    result, err := s.Exec("select count(*) from employee_tab where gender=?", "1")
    if err != nil {
        return 0, err
    }

    return result.Int(), nil
} 

在这个例子中,我们有一个 employee 包,该包中的方法 MaleCount 方法通过传入的 Stmt 接口的实现从数据库获取男性员工的数量。

现在我们要对 MaleCount 方法编写单元测试代码。对于这种依赖外部数据库操作的方法,我们的惯例是使用 “伪对象 (fake object)” 来冒充真实的 Stmt 接口实现。不过现在有一个问题,那就是 Stmt 接口类型的方法集合中有四个方法,如果我们针对每个测试用例所用的伪对象都实现这四个方法,那么这个工作量有些大,我们需要的仅仅是 Exec 这一个方法。如何快速建立伪对象呢?结构体类型嵌入接口类型便可以帮助我们:

// method_set_9_test.go 
package employee

import "testing"

type fakeStmtForMaleCount struct {
    Stmt
}

func (fakeStmtForMaleCount) Exec(stmt string, args ...string) (Result, error) {
    return Result{Count: 5}, nil
}

func TestEmployeeMaleCount(t *testing.T) {
    f := fakeStmtForMaleCount{}
    c, _ := MaleCount(f)
    if c != 5 {
        t.Errorf("want: %d, actual: %d", 5, c)
        return
    }
} 

我们为 TestEmployeeMaleCount 测试用例建立了一个 fakeStmtForMaleCount 的伪对象,在该结构体类型时嵌入 Stmt 接口类型,这样 fakeStmtForMaleCount 就实现了 Stmt 接口,我们实现了快速建立伪对象的目的。然后我们仅需为 fakeStmtForMaleCount 实现 MaleCount 所需的 Exec 方法即可。

3) 结构体类型中嵌入结构体类型

在结构体类型中嵌入结构体类型为 Gopher 提供了一种 “实现继承” 的手段,外部的结构体类型 T 可以 “继承” 嵌入的结构体类型的所有方法的实现,并且无论是 T 类型的变量实例还是 * T 类型变量实例,都可以调用所有 “继承” 的方法。

// method_set_10.go 
package main

type T1 struct{}

func (T1) T1M1()   { println("T1's M1") }
func (T1) T1M2()   { println("T1's M2") }
func (*T1) PT1M3() { println("PT1's M3") }

type T2 struct{}

func (T2) T2M1()   { println("T2's M1") }
func (T2) T2M2()   { println("T2's M2") }
func (*T2) PT2M3() { println("PT2's M3") }

type T struct {
    T1
    *T2
}

func main() {
    t := T{
        T1: T1{},
        T2: &T2{},
    }

    println("call method through t:")
    t.T1M1()
    t.T1M2()
    t.PT1M3()
    t.T2M1()
    t.T2M2()
    t.PT2M3()

    println("\ncall method through pt:")
    pt := &t
    pt.T1M1()
    pt.T1M2()
    pt.PT1M3()
    pt.T2M1()
    pt.T2M2()
    pt.PT2M3()
    println("")

    var t1 T1
    var pt1 *T1
    DumpMethodSet(&t1)
    DumpMethodSet(&pt1)

    var t2 T2
    var pt2 *T2
    DumpMethodSet(&t2)
    DumpMethodSet(&pt2)

    DumpMethodSet(&t)
    DumpMethodSet(&pt)
} 

示例运行结果如下:

$ go run method_set_10.go method_set_utils.go
call method through t:
T1's M1
T1's M2
PT1's M3
T2's M1
T2's M2
PT2's M3

call method through pt:
T1's M1
T1's M2
PT1's M3
T2's M1
T2's M2
PT2's M3

main.T1's method set:
- T1M1
- T1M2

*main.T1's method set:
- PT1M3
- T1M1
- T1M2

main.T2's method set:
- T2M1
- T2M2

*main.T2's method set:
- PT2M3
- T2M1
- T2M2

main.T's method set:
- PT2M3
- T1M1
- T1M2
- T2M1
- T2M2

*main.T's method set:
- PT1M3
- PT2M3
- T1M1
- T1M2
- T2M1
- T2M2 

通过输出结果可以看出:虽然通过 T 还是 * T 变量实例,都可以调用所有 “继承” 的方法 (这也是 Go 语法甜头),但是 T 和 * T 类型的方法集合是有差别的:

  • 类型 T 的方法集合 = T1 的方法集合 + *T2 的方法集合;
  • 类型 * T 的方法集合 = *T1 的方法集合 + *T2 的方法集合;

3. defined 类型的方法集合

Go 语言支持基于一个已存在的类型创建新的类型,比如:

type MyInterface I
type Mystruct T 

已存在的类型(比如上面的 I、T)被称为 underlying 类型,而新类型被称为 defined 类型。新定义的 defined 类型与原 underlying 类型是完全不同的类型,那么它们的方法集合上又会有什么关系呢?我们通过下面例子来看一下:

// method_set_11.go
package main

type T struct{}

func (T) M1()  {}
func (*T) M2() {}

type Interface interface {
    M1()
    M2()
}

type T1 T
type Interface1 Interface

func main() {
    var t T
    var pt *T
    var t1 T1
    var pt1 *T1

    DumpMethodSet(&t)
    DumpMethodSet(&t1)

    DumpMethodSet(&pt)
    DumpMethodSet(&pt1)

    DumpMethodSet((*Interface)(nil))
    DumpMethodSet((*Interface1)(nil))
} 

运行该示例程序得到如下结果:

$ go run method_set_11.go method_set_utils.go 
main.T's method set:
- M1

main.T1's method set is empty!

*main.T's method set:
- M1
- M2

*main.T1's method set is empty!

main.Interface's method set:
- M1
- M2

main.Interface1's method set:
- M1
- M2 

从例子的输出结果上来看,Go 对于基于接口类型和自定义非接口类型而创建的 defined 类型给出了 “不一致” 的结果:

  • 基于接口类型创建的 defined 类型与原接口类型的方法集合是一致的,如上面的 Interface 和 Interface1;
  • 而基于自定义非接口类型的 defined 类型则并没有 “继承” 原类型的方法集合,新的 defined 类型的方法集合是空的

方法集合决定接口实现:基于自定义非接口类型的 defined 类型的方法集合为空的事实也决定了即便原类型实现了某些接口,基于其创建的 defined 类型也没有 “继承” 这一隐式关联。新 defined 类型要想实现那些接口,仍需重新实现接口的所有方法。

4. 类型别名的方法集合

Go 在 1.9 版本中引入了类型别名(type alias),支持为已有类型定义别名,如:

type MyInterface I
type Mystruct T 

类型别名与原类型几乎可以理解为是完全等价的。Go 预定义标识符 rune、byte 就是通过 type alias 语法定义的:

// $GOROOT/src/builtin/builtin.go

// byte is an alias for uint8 and is equivalent to uint8 in all ways. It is
// used, by convention, to distinguish byte values from 8-bit unsigned
// integer values.
type byte = uint8

// rune is an alias for int32 and is equivalent to int32 in all ways. It is
// used, by convention, to distinguish character values from integer values.
type rune = int32 

但是在方法集合上面,类型别名与原类型是否有差别呢。我们还是来看一个例子:

// method_set_12.go
package main

type T struct{}

func (T) M1()  {}
func (*T) M2() {}

type Interface interface {
    M1()
    M2()
}

type T1 = T
type Interface1 = Interface

func main() {
    var t T
    var pt *T
    var t1 T1
    var pt1 *T1

    DumpMethodSet(&t)
    DumpMethodSet(&t1)

    DumpMethodSet(&pt)
    DumpMethodSet(&pt1)

    DumpMethodSet((*Interface)(nil))
    DumpMethodSet((*Interface1)(nil))
} 

运行该示例程序得到如下结果:

$go run method_set_12.go method_set_utils.go 
main.T's method set:
- M1

main.T's method set:
- M1

*main.T's method set:
- M1
- M2

*main.T's method set:
- M1
- M2

main.Interface's method set:
- M1
- M2

main.Interface's method set:
- M1
- M2 

通过上述示例输出我们看到,我们的 DumpMethodSet 函数甚至都无法识别出 “类型别名”,无论类型别名还是原类型,输出的都是原类型的方法集合。尤其我们得到一个结论:类型别名与原类型拥有完全相同的方法集合,无论原类型是接口类型还是非接口类型。

5. 小结

本节要点:

  • 方法集合是类型与接口间 “隐式” 关系的纽带,只有类型的方法集合是某接口类型的超集时,我们才说该类型实现了某接口;
  • 类型 T 的方法集合为以 T 为 receiver 类型的所有方法的集合;类型 * T 的方法集合为以 * T 为 receiver 类型的所有方法的集合与类型 T 的方法集合的并集;
  • 了解类型嵌入对接口类型和自定义结构体类型的方法集合的影响;
  • 基于接口类型创建的 defined 类型与原类型具有相同的方法集合;而基于自定义非接口类型创建的 defined 类型的方法集合为空;
  • 类型别名与原类型拥有完全相同的方法集合。
点赞
收藏
评论区
推荐文章

暂无数据