Go学习第四篇文章已经学习了类型的方法集,分值接收者和指针接收者,而且值和指针变量都可以自由调用这些方法。但接口的变量却不能随意调用实现者的方法集,这里有文章。

接口的认识

Go语言中接口(interface)非常重要,他被用来约定一组行为,凡是具备这一组行为的类型,都可以看做是该接口的派生类型。利用这种特性,我们就能抽象出一类行为,将来功能的实现可以完全取决于具体的调用者。这种具备不同行为能力的特性叫多态。这也是Go语言中为数不多的典型的面向对象特性。他简单易懂功能强大,为Go的设计理念点赞。

也就是说接口只定标准,不管具体的实现。接口有下面一些特点:

  • 不能有字段
  • 只声明,不实现方法
  • 可以嵌入(mixin)其它接口类型

下面看一个例子

 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
type Food interface {
	canEat() bool
}

type Apple struct {
	price int
	color int
}

func (a Apple) canEat() bool {
	if a.color == 1 {
		fmt.Println("This apple is eatable.")
	} else {
		fmt.Println("This apple inedible.")
	}
	return true
}

func InterfaceTest() {
	var fruit Food
	fruit = Apple{price: 10, color: 2}
	fruit.canEat()

	fmt.Println(unsafe.Sizeof(fruit))
	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", &fruit, fruit, fruit)

	fmt.Println("-------")

	var fruit2 Food
	fruit2 = &Apple{price: 20, color: 1}
	fruit2.canEat()

	fmt.Println(unsafe.Sizeof(fruit2))
	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", fruit2, fruit2, fruit2)
}

运行结果如下:

This apple inedible.
16
Addr: 0xc00002e1f0, Values: {10 2}, Type: types.Apple
-------
This apple is eatable.
16
Addr: 0xc00000a0b0, Values: &{20 1}, Type: *types.Apple

分析:

  • interface变量居然既可以用值来赋值,也可以用指针来赋值。
  • interface变量占用2个字长,其实是2个指针。

再来个测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func InterfaceTest2() {
	apple := &Apple{price: 10, color: 2}
	fmt.Println(unsafe.Sizeof(apple))
	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", apple, apple, apple)

	var fruit Food
	fruit = *apple	// 值
	fmt.Println(unsafe.Sizeof(fruit))
	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", &fruit, fruit, fruit)

	var fruitP Food
	fruitP = apple	// 引用
	fmt.Println(unsafe.Sizeof(fruitP))
	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", fruitP, fruitP, fruitP)
}

结果如下

8
Addr: 0xc00000a090, Values: &{10 2}, Type: *types.Apple
16
Addr: 0xc00002e1f0, Values: {10 2}, Type: types.Apple
16
Addr: 0xc00000a090, Values: &{10 2}, Type: *types.Apple

大家看到没有,看看7行和12行,接口的变量既可以是值类型,也可以是引用;这也太灵活了吧。因为一个接口变量存放的就是两个指针而已,指向什么地方都可以;下面先给出两种情况的内存结构示意图:

image-20241023161413511

两种情况几乎一模一样,只是标红的地方有些差异。我们用unsafe代码来猜测一下是不是这样的内存结构。

 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
40
41
42
43
func Test222() {
	theApple := Apple{price: 10, color: 2}
	var foodV, foodP Food
	foodV = theApple
	foodP = &theApple

	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", &theApple, theApple, theApple)
	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", &foodV, foodV, foodV)
	fmt.Printf("Addr: %p, Values: %v, Type: %T\n", &foodP, foodP, foodP)

	// foodV 对应 iTable地址
	foodVItab := *(*unsafe.Pointer)(unsafe.Pointer(&foodV))
	println(foodVItab)
	println(*(*unsafe.Pointer)(foodVItab))
	println("++++++++")

	// foodP 对应 iTable地址
	foodPItab := *(*unsafe.Pointer)(unsafe.Pointer(&foodP))
	println(foodPItab)
	println(*(*unsafe.Pointer)(foodPItab))
	println("++++++++")

	// 原变量地址
	println(unsafe.Pointer(&theApple))
	println("++++++++")

	// 接口值变量
	var foodVData = unsafe.Pointer(uintptr(unsafe.Pointer(&foodV)) + uintptr(8))
	println(foodVData)
	var fvField = *(*unsafe.Pointer)(foodVData)
	println(fvField)
	println(*(*int)(fvField))
	println(*(*int)(unsafe.Pointer(uintptr(fvField) + 8)))
	println("++++++++")

	// 接口指针变量
	var foodPData = unsafe.Pointer(uintptr(unsafe.Pointer(&foodP)) + uintptr(8))
	println(foodPData)
	var fpField = *(*unsafe.Pointer)(foodPData)
	println(fpField)
	println(*(*int)(fpField))
	println(*(*int)(unsafe.Pointer(uintptr(fpField) + 8)))
}

输出结构如下:

Addr: 0xc00008e0b0, Values: {10 2}, Type: main.Apple
Addr: 0xc00008c3b0, Values: {10 2}, Type: main.Apple
Addr: 0xc00008c3c0, Values: &{10 2}, Type: *main.Apple
0xb70418
0xb4b820
++++++++
0xb70438
0xb4b820
++++++++
0xc00008e0b0
++++++++
0xc00008c3b8
0xc00008e0c0
10
2
++++++++
0xc00008c3c8
0xc00008e0b0
10
2

这个试验证实了,用一个变量值或变量地址分别传递给一个接口变量时,主要有两点差异:

  1. 接口变量类型指针分别表示*itab**itab
  2. 接口变量数据指针分别指向变量值的一份拷贝或原始变量值

重点记住下面两句话:

注意:

  • 接口有个重要特征,将对象赋值给接口变量时,会复制该对象。
  • 避免复制对象,就取对象的地址(指针)赋值给接口。此时也会创建一个接口指针。

接口在语言核心层,其实也是一个结构体:

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

接口方法集

先看结论:

结构体实现接口 结构体指针实现接口
结构体初始化变量 通过 不通过
结构体指针初始化变量 通过 通过

方法集定义了一组关联到给定类型的值或者指针的方法。定义方法时使用的接收者的类型决定了这个方法是关联到值,还是关联到指针,还是两个都关联。

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

  • 类型T方法集包含所有 receiver T 方法
  • 类型*T方法集包含所有 receiver T+*T 方法
  • 匿名嵌入ST方法集包含所有receiver S方法
  • 匿名嵌入*ST方法集包含所有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
type Food interface {
	setColor(int)
	setPrice(int)
}

type Apple struct {
	price int
	color int
}

func (a Apple) setColor(cc int) {
	a.color = cc
}
func (a *Apple) setPrice(pp int) {
	a.price = pp
}

func InterfaceTest3()  {
	var apple1 Food
	apple1 = Apple {price: 15, color: 3} // 这里提示错误
	apple1.setColor(1)
	apple1.setPrice(16)

	var apple2 Food
	apple2 = &Apple {price: 15, color: 3}
	apple2.setColor(1)
	apple2.setPrice(16)
}

上面的例子,有个地方编译出现错误,错误信息如下:

# 错误信息,
cannot use Apple literal (type Apple) as type Food in assignment:
Apple does not implement Food (setPrice method has pointer receiver)

提示错误是因为:

  • 第20行Apple{}是值类型,他只包含值接收者方法集,即实现了setColor,没有实现setPrice,不符合Food规范
  • 第25行&Apple{}是指针类型,他包含值和指针接收者方法集,所以setColor和setPrice都实现了,符合Food规范

为什么接口方法集会做这样的约定呢?前面我们不是看到了,值和指针变量不是可以自动做转换然后顺利调用值接收者和指针接收者方法吗?这里为什么不行了呢?

敲黑板,重点来了

2020年12月的理解

网上我看很多人的博客也说不清楚这个问题;我想原因可能是这样的,看下面的例子:

1
2
3
4
5
6
7
8
9
type TheAge int
func (mi *TheAge)ShowAge() {
	println(*mi)
}
func MTest1() {
	var T1 TheAge = 100
	T1.ShowAge()
	TheAge(99).ShowAge() // 报错
}

错误信息如下,意思是无法推断出TheAge(99)的地址,因为他是一个常量,只存在于CPU寄存器中,无法取地址;进而无法调用指针接收者方法ShowAge。

cannot call pointer method on TheAge(99)
cannot take the address of TheAge(99)

同样的道理,下面的代码会报错,是因为TheAge(99)其实是一个字面量,无法获取其内存地址,所以非法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Animal interface {
	ShowAge()
}

type TheAge int
func (mi *TheAge)ShowAge() {
	println(*mi)
}

func MTest2() {
	var A1 Animal = TheAge(99)
	A1.ShowAge()
}

因此接口的T变量只包含T接收者方法集;*T变量包含*T、T接收者的方法集。

2023年4月补充

对象赋值给接口时,需要Copy一个新对象;而语言特地让Copy后的对象不可取地址,否则指针接收者方法如果修改对象的值,实际将改的是Copy后对象的值,容易造成非预期结果。结构体允许定义值接收者方法,调用时其实是有一次对象Copy;如果接口也支持值接收者方法,在调用时会再来一次值的Copy,问题更复杂了。更何况还有我上面说的不可取地址的问题,所以语言干脆不支持这种写法。

为啥一定要Copy对象呢?因为Go有个理念,一切传值都是值拷贝,对象传给参数为interface{}的函数时,也只能是对象值拷贝。

经典现象

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

type TestStruct struct{}

func NilOrNot(v interface{}) bool {
	return v == nil
}

func main() {
	var s *TestStruct
	fmt.Println(s == nil)      // #=> true
	fmt.Println(NilOrNot(s))   // #=> false
}

$ go run main.go
true
false

出现上述现象的原因是 —— 调用 NilOrNot 函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,*TestStruct 类型会转换成 interface{} 类型,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 TestStruct,所以转换后的变量与 nil 不相等。

这句fmt.Println(NilOrNot(nil))才会返回 true。所以根本问题是:不要偷换概念。

参考阅读:

https://www.zhihu.com/people/chen-qiang-song/posts

https://zhuanlan.zhihu.com/p/427838620

接口传值编译器优化

按照前面的理解,当一个对象变量值传递给一个interface时,会产生对象的一份拷贝,这在基准测试中应该能体现出一次堆分配,可现实情况却未必如此,看下面的示例:

 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
var oriString = "this is a string"
var item = Mixed{Num: 10, Str: oriString}

type Mixed struct {
	Str  string
	Num  int
	Data [125]int64
}

func (m Mixed) GetStr() string {
	m.Num++
	return m.Str
}

func strCount(m Mixed) string {
	return m.GetStr()
}

func strCountAny(pms any) string {
	return pms.(Mixed).GetStr()
}

func BenchmarkPms(b *testing.B) {
	b.ResetTimer()
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		item.Num++
		strCount(item)
	}
}

func BenchmarkPmsAny(b *testing.B) {
	b.ResetTimer()
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		item.Num++
		strCountAny(item)
	}
}

执行go test -bench=Pms$后,结果如下:

BenchmarkPms-8       	35439627  	33.88 ns/op  		0 B/op 		0 allocs/op
BenchmarkPmsAny-8       34637532	33.72 ns/op     	0 B/op      0 allocs/op

修改代码Data [126]int64,在我笔记本go version go1.23.0 windows/amd64执行结果如下:

BenchmarkPms-8          31548975    37.73 ns/op    	   	0 B/op   	0 allocs/op
BenchmarkPmsAny-8       5461423    213.9 ns/op   	 1152 B/op   	1 allocs/op

为什么?可以分析汇编的情况来确定原因。我这里单纯猜测是编译优化问题,在需要内存不大于1KB的情况下,不用堆分配,直接改在执行栈上分配空间。尽管Go协程栈默认只有2KB。

总之我们写代码时,还是要尽量避免堆分配导致的内存自动管理开销。

(完)