0%

Go 语言学习笔记(3):方法与接口

方法是与对象实例绑定的特殊函数,方法是面对对象编程的基本概念,用于维护和展示对象的自身状态。对象是内敛的,每个实例都有各自不同的独立特征,以属性和方法来暴露对外通信接口,而普通函数则专注于算法流程,通过接收参数来完成特定逻辑运算,并返回最终结果。也就是说,方法是有关联状态的,而函数则没有。

方法

定义

方法和函数定义的区别在于前者有前置实例接收参数(receiver),编译器以此确定方法所属类型。可以为当前包,以及除接口和内置简单类型以外的任何类型定义方法。方法不支持重载(overload),receiver 参数名没有限制,按惯例会选用简短有意义的名称。如果方法内部并不引用实例,可以省略参数名,仅保留类型。

方法可以作为特殊的函数,receiver 的类型自然可以是基础类型或指针类型,这会关系到调用对象时实例是否被复制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

type N int

func (n N) value() {
n++
fmt.Printf("v: %p, %v\n", &n, n)
}

func (n *N) pointer() {
(*n)++
fmt.Printf("p: %p, %v\n", n, *n)
}

func main() {
var a N = 25

a.value()
a.pointer()

fmt.Printf("a: %p, %v\n", &a, a)
}

可以使用实例值或指针调用方法,编译器会根据 receiver 类型自动在基础类型和指针类型间转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

type N int

func (n N) value() {
n++
fmt.Printf("v: %p, %v\n", &n, n)
}

func (n *N) pointer() {
(*n)++
fmt.Printf("p: %p, %v\n", n, *n)
}

func main() {
var a N = 25

a.value()
a.pointer()

fmt.Printf("a: %p, %v\n", &a, a)
}
1
2
3
4
./receiver
v: 0xc00012a010, 26
p: 0xc00012a008, 26
a: 0xc00012a008, 26

不能用多级指针调用方法。指针类型的 receiver 必须是合法指针,或者能够获取其地址的实例。那么如何选择方法的 receiver 的类型:

  • 要修改实例状态,*T
  • 无需修改状态的小对象或规定值,建议用 T
  • 大对象建议用 *T,以减少复制成本
  • 引用类型、字符串、函数等指针包装对象,直接用 T
  • 如果包含 Mutex 等同步手段,用 *T,避免因复制造成锁操作无效
  • 其他无法确定的情况,都用 *T

调用匿名字段的方法

可以像访问匿名字段成员那样调用其方法,由编译器负责查找。方法也会有同名遮蔽问题,但是利用这个特性,可以实现类似覆盖(override)操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

type user struct{}

type manager struct {
user
}

func (user) toString() string {
return "user"
}

func (m manager) toString() string {
return m.user.toString() + "; manager"
}

func main() {
var m manager
println(m.toString())
println(m.user.toString())
}

尽管能直接访问匿名字段的成员和方法,但是它们依然不属于继承关系。

方法集

类型有一个与之相关的方法集,这决定了它是否实现了某个接口

  • 类型 T 方法集包含了所有 receiver T 的方法
  • 类型 *T 方法集包含所有 receiver T + *T 方法
  • 匿名嵌入 S,T 方法集包含所有 receiver S 方法
  • 匿名嵌入 *S,T 方法集包含所有 receiver S + *S 方法
  • 匿名嵌入 S 或 S,T 方法集包含所有 receiver S + *S 方法

可以使用反射实现这些测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
"fmt"
"reflect"
)

type S struct{}

type T struct {
S
}

func (s S) SVal() { fmt.Println("SVal") }
func (s *S) SPtr() { fmt.Println("SPtr") }
func (t T) TVal() { fmt.Println("TVal") }
func (t *T) TPtr() { fmt.Println("TPtr") }

func methodSet(a interface{}) {
t := reflect.TypeOf(a)

for i, n := 0, t.NumMethod(); i < n; i++ {
m := t.Method(i)
fmt.Println(m.Name, m.Type)
}
}

func main() {
var t T

t.SVal()
t.SPtr()
t.TVal()
t.TPtr()

methodSet(t)
println("----------")
methodSet(&t)
}

方法集仅影响接口实现和方法表达式转换,与通过实例或实例指针调用方法无关。方法集是与类型相关联的概念,而与实例没有关系。通过 可寻址的 T 类型实例,可以调用 receiver 类型为 T 和 *T 的方法。

面对对象的三大特征:封装、继承、多态。Go 仅仅实现了部分特征,它更倾向组合优于继承的思想,将模块分解成相互独立的更小单元,分别处理不同方面的需求,最后以嵌入的方式组合到一起,共同实现对外接口。而且其简短一致的调用方式,更是隐藏了内部细节。组合没有父子依赖,不会破坏封装,且整体和局部松耦合,可任意增加来实现扩展,各单元持有单一职责,互不关联,实现和维护更加简单

表达式

方法和函数一样,除直接调用外,还可以赋值给变量或作为参数传递。依照具体引用方式的不同,可以分为 expression 和 value 两种状态。

通过类型引用的 method expression 会被还原为普通函数样式,receiver 是第一参数,调用时需要显式传参,且参数类型要和方法的签名保持一致。**至于类型,可以是 T 或 *T,只要目标方法存在于该类型的方法集中**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import "fmt"

type N int

func (n N) test() {
fmt.Printf("test.n: %p, %d\n", &n, n)
}

func main() {
var n N = 25

fmt.Printf("main.n: %p %d\n", &n, n)

f1 := N.test
f1(n)

// *T 的方法集中包含 receiver T 类型的方法
f2 := (*N).test
// 参数类型要和函数签名保持一致
f2(&n)

// compile error: cannot use n (type N) as type *N in argument to f2
f2(n)
}

尽管 *N 方法集包装的 test 方法 receiver 类型不同,但是编译器会保证按原定义类型拷贝传值,因此调用 f2 时,还是会生成新的 receiver 参数 n 的拷贝。

1
2
3
4
$ ./method_expression
main.n: 0xc00001a090 25
test.n: 0xc00001a0a0, 25
test.n: 0xc00001a0a8, 25

当然也可以直接通过表达式调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

type N int

func (n N) test() {
fmt.Printf("test.n: %p, %d\n", &n, n)
}

func main() {
N.test(10)
}

基于实例或指针引用的 method value,参数签名不会改变,依旧按照正常方式调用。但是当 method value 被赋值给变量或作为参数传递时,会立即计算并复制该方法执行所需的 receiver 对象,与其绑定,以便在稍后执行,能隐式传入 receiver 参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import "fmt"

type N int

func (n N) test() {
fmt.Printf("test.n: %p, %v\n", &n, n)
}

func main() {
var n N = 100
p := &n

n++
f1 := n.test

n++
f2 := p.test

n++
fmt.Printf("test.n: %p, %v\n", &n, n)

f1()
f2()
}
1
2
3
test.n: 0xc00001a090, 103
test.n: 0xc00001a0a0, 101
test.n: 0xc00001a0a8, 102

编译器会为 method value 生成一个包装函数,实现间接调用,至于 receiver 复制,和闭包的实现方法基本相同,打包成 funcval,经由 DX 寄存器传递。

当 method value 作为参数时,会复制包含 receiver 在内的整个 method value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import "fmt"

type N int

func (n N) test() {
fmt.Printf("test.n: %p, %v\n", &n, n)
}

func call(m func()) {
m()
}

func main() {
var n N = 100
p := &n

fmt.Printf("main.n: %p, %v\n", p, n)

n++
call(n.test)

n++
call(p.test)
}
1
2
3
4
$ ./call_method_value
main.n: 0xc0000b4008, 100
test.n: 0xc0000b4018, 101
test.n: 0xc0000b4020, 102

其实只要 receiver 参数类型正确,使用 nil 同样可以执行。

接口

接口表示一种契约,是多个方法声明的集合。在某些动态语言中,接口也被称为协议。准备交互的双方,共同遵守事先约定的规则,使得在无须知道对方身份的情况下进行协作。接口要实现的是做什么,而不关心怎么做,谁来做。

接口解除了类型依赖,有助于减少用户可视方法,屏蔽内部结构和实现细节。但是接口实现机制也有运行期开销,对于相同包,或者不会频繁变化的内部模块之间,并不需要抽象出接口来强行分离。接口最常见的使用场景,是对包外提供访问,或者预留扩展空间。

Go 接口实现机制很简单,只要目标类型的方法集内包含接口声明的全部方法,就被认为实现了该接口,无需做显示声明。目标类型可以实现多个接口。对于接口而言,不能定义自己的方法,只能声明方法,不能实现,也可以嵌入其他接口类型

接口通常以 er 作为名称后缀,方法名是声明组成部分,但是参数名可不同或者省略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

type tester interface {
test()
string() string
}

type data struct{}

func (*data) test() {}
func (data) string() string { return "" }

func main() {
var d data

var t tester = &d
t.test()
print(t.string())
}

编译器会根据方法集来判断是否实现了接口,在该例子中,只有 *data 才符合 tester 接口。

如果接口没有声明任何方法,那么就是一个空接口(interface{}),它的用途类似面对对象的根类型 Object,可以被赋值为任何类型的对象。接口变量默认值是 nil,如果实现接口的类型支持,可以做相等运算。

可以像匿名字段那样,在接口类型中嵌入其他接口,目标类型方法集中必须拥有包含嵌入接口方法在内的全部方法才算实现了该接口。嵌入其他接口类型,相当于将其声明的方法导入,这就要求不能有同名访问,因为不支持重载,还有不能嵌入自身或循环嵌入,那会导致递归错误。超集接口变量可隐式转换为子集,但是反过来不行。

支持匿名接口类型,可以直接用于变量定义,或者作为结构类型字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

type data struct{}

func (data) string() string {
return ""
}

type node struct {
data interface {
string() string
}
}

func main() {
var t interface {
string() string
} = data{}

n := node{
data: t,
}

println(n.data.string())
}

执行机制

接口使用一个名为 itab 的结构体存储运行期所需的相关类型信息。

type iface struct {
tab *itab // 类型信息
data unsafe.Pointer // 实际对象指针
}

type itab struct {
inter *interfacetype // interface type
_type *_type // 实际对象地址
fun [1]uintptr // 实际对象方法地址
}

itab 用 fun 数组(不定长结构)保存了实际方法地址,从而实现在运行期对目标方法的动态调用。

除此之外,接口还有一个重要特征,将对象赋值给接口变量时,会复制该对象。我们也无法修改接口所存储的复制品,因为它是 unaddressable 的。即便将其复制出来,用本地变量修改后,依然无法对 iface.data 赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

type data struct {
x int
}

func main() {
d := data{100}

var t interface{} = d
p := &t.(data)
t.(data).x = 200
}
1
2
3
4
$ go build data.go
# command-line-arguments
./data.go:11:7: cannot take the address of t.(data)
./data.go:12:13: cannot assign to t.(data).x

解决方法就是将对象指针赋值给接口,那么接口内存储的就是指针的复制品,如下例子使用类型断言来将接口转换为实际类型的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

type data struct {
x int
}

func main() {
d := data{100}

var t interface{} = &d

t.(*data).x = 200
println(t.(*data).x)
}

只有当接口变量内部的两个指针(itab,data)都为 nil 时,接口才等于 nil:

1
2
3
4
5
6
7
8
package main

func main() {
var a interface{} = nil
var b interface{} = (*int)(nil)

println(a == nil, b == nil)
}
1
2
$ ./nil_interface
true false

由此造成的错误也十分隐蔽,尤其是在返回 error 时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

type TestError struct{}

func (*TestError) Error() string {
return "error"

}

func test(x int) (int, error) {
var err *TestError

if x < 0 {
err = new(TestError)
x = 0
} else {
x += 100
}

return x, err
}

func main() {
x, err := test(100)
if err != nil {
println("err != nil")
}
println(x)
}
1
2
3
$ ./error
err != nil
200

这里是因为返回的 err 是有类型的,因此接口不是 nil。正确做法是明确返回 nil

类型转换

类型推断可以将接口变量还原为原始类型,或者用来判断是否实现了某个更具体的接口类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import "fmt"

type data int

func (d data) String() string {
return fmt.Sprintf("data:%d", d)
}

func main() {
var d data = 15
var x interface{} = d

if n, ok := x.(fmt.Stringer); ok {
fmt.Println(n)
}

if d2, ok := x.(data); ok {
fmt.Println(d2)
}

e := x.(error)
fmt.Println(e)
}
1
2
3
4
5
6
7
8
$ ./type_switch
data:15
data:15
panic: interface conversion: main.data is not error: missing method Error

goroutine 1 [running]:
main.main()
/Users/fuchencong/data/workspace/code/private/learn_go/07/type_switch.go:23 +0x14b

使用 ok-idiom 模式,即便转换失败也不会引发 panic。还可以用 switch 语句在多种类型间做出推断匹配,这样空接口就有更多的发挥空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func main() {
var x interface{} = func(x int) string {
return fmt.Sprintf("d:%d", x)
}

switch v := x.(type) {
case nil:
println("nil")
case *int:
println(*v)
case func(int) string:
println(v(100))
case fmt.Stringer:
fmt.Println(v)
default:
println("unkown")
}
}

这里局部变量 v 是类型转换后的结果。而且 typeswitch 不支持 fallthrought。

技巧

让编译器检查,确保类型实现了指定接口:

1
2
3
4
5
6
7
8
9
package main

import "fmt"

type x int

func main() {
var _ fmt.Stringer = x(0)
}
1
2
./type_check.go:8:6: cannot use x(0) (type x) as type fmt.Stringer in assignment:
x does not implement fmt.Stringer (missing String method)

定义函数类型,让相同签名的函数自动实现某个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

type FuncString func() string

func (f FuncString) String() string {
return f()
}

func main() {
var t fmt.Stringer = FuncString(func() string {
return "hello, world!"
})

fmt.Println(t)
}