深度探索 Go 对象模型

来源:cyningsun.github.io/01-12-2020/…
源代码:github.com/cyningsun/g…html

目录

了解一门语言的高级特性,仅仅从浮于表面,是没法把握住语言的精髓的。学习过 C++ 的高阶开发者,必定读过神书《Inside The C++ Object Model》,本文的目标是同样的:经过对象模型,掌握 Go 语言的底层机制,从更深层次解释语言特性。git

编译与执行

众所周知,Go 源码并不能直接运行,全部代码必须一行行,经过“编译”——“汇编”——“连接” 阶段 转化为低级的机器语言指令,便可执行程序。程序员

compile.png

“汇编”和“连接”阶段各类语言并没有区别,因此通常经过“编译”和“执行”阶段来支持各类语言特性。对于 Go 语言,执行过程并没有法直接修改执行指令,所以全部语言特性都是“编译”相关的。理解这一点很重要,由于下面依赖“编译”的产物 汇编代码 来解读对象模型。github

什么是对象模型?

何为 Go 对象模型? Go 对象模型能够归纳为如下两部分:golang

  1. 支持面向对象程序设计的部分编程

    • 封装
    • 继承
    • 多态
  2. 各类特性的底层实现机制bash

    • 反射

下面分别从 struct 和 interface 来解释模型如何支持以上两部分。ide

Struct 语意学

struct.png

面向对象编程,把对象做为程序的基本单元,一个对象包含了数据和操做数据的函数,前者为成员变量,后者为成员函数。因此研究对象须要分别从成员变量和成员函数入手。函数

成员变量

如下有三段程序:布局

// First: global varible
var (
   X,Y,Z float32
)

// Second: simple type
type point3d struct {
	X, Y, Z float32
}

// Third: inherit type
type point struct {
	X float32
}

type point2d struct {
	point
	Y float32
}

type point3d struct {
	point2d
	Z float32
}
复制代码

从风格来看,三段程序大相径庭。有许多使人信服的讨论告诉咱们,为何“数据封装”(Second & Third)要比使用“全局变量”好。但,从程序员的角度看,会有几个疑问:

  1. “数据封装” 以后,内存成本增长了多少?
  2. “数据封装” 以后,在执行过程当中,变量的存储效率是否下降了?
内存布局

先看内存变化。了解内存变化最好的办法就是经过代码打印对象的内存大小,先看全局变量大小

var (
	X, Y, Z float32
)

func main() {
	fmt.Printf("X size:%v, Y size:%v, Z size:%v\n", unsafe.Sizeof(X), unsafe.Sizeof(Y), unsafe.Sizeof(Z))
	fmt.Printf("X addr:%v, Y addr:%v, Z addr:%v\n", &X, &Y, &Z)
}
复制代码

执行程序输出为:

$ go run variable.go
X size:4, Y size:4, Z size:4
X addr:0x118ee88, Y addr:0x118ee8c, Z addr:0x118ee90
复制代码

能够看到,X、Y、Z三个字段大小均为4字节,且三个字段内存地址顺序排列。

再看第二段代码的输出

func TestLayout(t *testing.T) {
	p := point3d{X: 1, Y: 2, Z: 3}
	fmt.Printf("point3d size:%v, align:%v\n", unsafe.Sizeof(p), unsafe.Alignof(p))
	typ := reflect.TypeOf(p)
	fmt.Printf("Struct:%v is %d bytes long\n", typ.Name(), typ.Size())
	fmt.Printf("X at offset %v, size=%d\n", unsafe.Offsetof(p.X), unsafe.Sizeof(p.X))
	fmt.Printf("Y at offset %v, size=%d\n", unsafe.Offsetof(p.Y), unsafe.Sizeof(p.Y))
	fmt.Printf("Z at offset %v, size=%d\n", unsafe.Offsetof(p.Z), unsafe.Sizeof(p.Z))
}
复制代码

执行程序输出为:

$ go test -v -run TestLayout
=== RUN   TestLayout
point3d size:12, align:4
Struct:point3d is 12 bytes long
X at offset 0, size=4
Y at offset 4, size=4
Z at offset 8, size=4
复制代码

能够看到,X、Y、Z三个字段大小同样为4字节,内存排列也与上一个版本同样。

继续,第三段代码

func TestLayout(t *testing.T) {
	p := point3d{point2d: point2d{point: point{X: 1}, Y: 2}, Z: 3}
	fmt.Printf("point3d size:%v, align:%v\n", unsafe.Sizeof(p), unsafe.Alignof(p))
	typ := reflect.TypeOf(p)
	fmt.Printf("Struct:%v is %d bytes long\n", typ.Name(), typ.Size())
	fmt.Printf("X at offset %v, size=%d\n", unsafe.Offsetof(p.X), unsafe.Sizeof(p.X))
	fmt.Printf("Y at offset %v, size=%d\n", unsafe.Offsetof(p.Y), unsafe.Sizeof(p.Y))
	fmt.Printf("Z at offset %v, size=%d\n", unsafe.Offsetof(p.Z), unsafe.Sizeof(p.Z))
}
复制代码

执行程序输出为:

$ go test -v -run TestLayout
=== RUN   TestLayout
point3d size:12, align:4
Struct:point3d is 12 bytes long
X at offset 0, size=4
Y at offset 4, size=4
Z at offset 8, size=4
复制代码

能够看到,X、Y、Z三个字段大小同样为4字节,内存排列也与以前两个版本同样。

综上所述,咱们能够看到,不管是否封装,仍是多深的继承层次,对成员变量的内存布局都并没有影响,均按照字段定义的顺序排列(不考虑内存对齐的状况)。即内存布局相似以下:

memory-offset.png

变量存取

成员变量有两种读取方式,既能够经过对象读取,也能够经过对象的指针读取。两种读取方式与直接变量读取会有什么不一样么?使用一段代码再看下:

type point struct {
	X float32
}

type point2d struct {
	point
	Y float32
}

type point3d struct {
	point2d
	Z float32
}

func main() {
	var (
		w float32
	)
	point := point3d{point2d: point2d{point: point{X: 1}, Y: 2}, Z: 3} // L25
	p := &point  // L26
	w = point.Y  // L27
	fmt.Printf("w:%f\n", w)
	w = p.Y     // L29
	fmt.Printf("w:%f\n", w)
}
复制代码

还记得以前提过的“编译”阶段么?咱们使用 go tool 能够查看源代码汇编以后的代码

data_access.go:25	0x10948d8	f30f11442444		MOVSS X0, 0x44(SP)
data_access.go:25	0x10948de	f30f11442448		MOVSS X0, 0x48(SP)
data_access.go:25	0x10948e4	f30f1144244c		MOVSS X0, 0x4c(SP)
data_access.go:25	0x10948ea	f30f10055ab50400	MOVSS $f32.3f800000(SB), X0	
data_access.go:25	0x10948f2	f30f11442444		MOVSS X0, 0x44(SP)
data_access.go:25	0x10948f8	f30f100550b50400	MOVSS $f32.40000000(SB), X0	
data_access.go:25	0x1094900	f30f11442448		MOVSS X0, 0x48(SP)
data_access.go:25	0x1094906	f30f100546b50400	MOVSS $f32.40400000(SB), X0	
data_access.go:25	0x109490e	f30f1144244c		MOVSS X0, 0x4c(SP)
data_access.go:26	0x1094914	488d442444		LEAQ 0x44(SP), AX
data_access.go:26	0x1094919	4889442450		MOVQ AX, 0x50(SP)
data_access.go:27	0x109491e	f30f10442448		MOVSS 0x48(SP), X0	// 读取 Y 到寄存器 X0
data_access.go:27	0x1094924	f30f11442440		MOVSS X0, 0x40(SP)	// 赋值 寄存器 X0 给 w
...
data_access.go:29	0x10949c7	488b442450		MOVQ 0x50(SP), AX	// 读取 对象地址 到寄存器 AX 		
data_access.go:29	0x10949cc	8400			TESTB AL, 0(AX)	
data_access.go:29	0x10949ce	f30f104004		MOVSS 0x4(AX), X0	// 从对象起始地址偏移4字节读取数据到寄存器 X0 	
data_access.go:29	0x10949d3	f30f11442440		MOVSS X0, 0x40(SP)	// 赋值 寄存器 X0 给 w
复制代码

能够看到,每一个成员变量的偏移量在编译时便可获知,无论其有多么复杂的继承,都是同样的。经过对象存取一个data member,其效率和存取一个非成员变量是同样的。

函数调用

前面的例子提过,对象的总大小恰好等于全部的成员变量之和,也就意味着成员函数并不占用对象的内存大小。那成员函数的调用是怎么实现的呢?咱们经过一段代码看下

type point3d struct {
	X, Y, Z float32
}

func (p *point3d) Println() {
	fmt.Printf("%v,%v,%v\n", p.X, p.Y, p.Z)
}

func main() {
	p := point3d{X: 1, Y: 2, Z: 3} // L14
	p.Println()                   // L15
}
复制代码

一样使用 go tool获取对应的汇编代码

call.go:14	0x1094a7d	0f57c0			XORPS X0, X0	
  call.go:14	0x1094a80	f30f1144240c		MOVSS X0, 0xc(SP)
  call.go:14	0x1094a86	f30f11442410		MOVSS X0, 0x10(SP)
  call.go:14	0x1094a8c	f30f11442414		MOVSS X0, 0x14(SP)
  call.go:14	0x1094a92	f30f100592b30400	MOVSS $f32.3f800000(SB), X0	
  call.go:14	0x1094a9a	f30f1144240c		MOVSS X0, 0xc(SP)
  call.go:14	0x1094aa0	f30f100588b30400	MOVSS $f32.40000000(SB), X0	
  call.go:14	0x1094aa8	f30f11442410		MOVSS X0, 0x10(SP)
  call.go:14	0x1094aae	f30f10057eb30400	MOVSS $f32.40400000(SB), X0	
  call.go:14	0x1094ab6	f30f11442414		MOVSS X0, 0x14(SP)
  call.go:15	0x1094abc	488d44240c		LEAQ 0xc(SP), AX //将对象 q 的起始地址保存到寄存器AX
  call.go:15	0x1094ac1	48890424		MOVQ AX, 0(SP)	  //将对象 q 的起始地址 压栈
  call.go:15	0x1094ac5	e8d6fdffff		CALL main.(*point3d).Println(SB)	  // 调用 struct point 的 Println() 函数
复制代码

能够看到成员函数的调用都是先把参数压栈,而后调用对应的的函数。可见,成员函数与普通的函数调用并没有不一样。那么函数的内存在哪里呢?

还记得进程的内存分布么?

process-memory.png

没错,全部的函数都在进程的代码段(Text Segment)

Interface 语意学

第一部分讲了,封装和继承的影响,剩下这部分会讲清楚 Go 如何使用 interface 实现多态反射。其中interface又有两种形式,一种是有函数的非空interface,一种是空的interface(interface{})。话很少说,直接上代码,看下这两种类型的interface的变量在内存大小上有何区别:

type Point interface {
	Println()
}

type point struct {
	X float32
}

type point2d struct {
	point
	Y float32
}

type point3d struct {
	point2d
	Z float32
}

func TestPolymorphism(t *testing.T) {
	var (
		p Point
	)
	p = &point{X: 1}
	fmt.Printf("point size:%v\n\n", unsafe.Sizeof(p))

	p = &point2d{point: point{X: 1}, Y: 2}
	fmt.Printf("point2d size:%v\n\n", unsafe.Sizeof(p))

	p = &point3d{point2d: point2d{point: point{X: 1}, Y: 2}, Z: 3}
	fmt.Printf("point3d size:%v\n\n", unsafe.Sizeof(p))
}
复制代码

执行程序输出为:

$ go test -v -run TestPolymorphism
=== RUN   TestPolymorphism
p size:16, nilP size:16
p size:16, nilP size:16
p size:16, nilP size:16
复制代码

能够看到两种类型的interface 变量大小并没有不一样,均为16字节。能够明确一点:interface 变量中存储的并不是对象的指针,而是特殊的定义类型的变量。那么 interface 是怎么支持多态反射的呢?

经过 reflect 包,咱们找到了答案。原来,针对以上两种类型的interface, Go 语言底层定义了两个结构分别为 iface 和 eface。二者实现是相似的,如下咱们仅针对非空interface进行分析

interface 底层

type iface struct {
    tab  *itab          // 类型信息
    data unsafe.Pointer  // 接口指向对象的指针
}

// 类型信息
type itab struct {
    inter  *interfacetype    // 接口的类型信息
    _type  *_type           // 接口指向对象的类型信息
	hash  uint32 // copy of _type.hash. Used for type switches.
	_     [4]byte
    fun    [1]uintptr       // 接口方法实现列表,即函数地址列表,按字典序排序
}

// 接口类型信息
type interfacetype struct {
   typ     _type
   pkgpath name
   mhdr    []imethod      // 接口方法声明列表,按字典序排序
}
复制代码

经过代码,能够看到,iface 类型包含两个指针,恰好为16字节(64位机器)。iface 不但包含了指向对象指向对象的类型,还包含了接口类型。如此

  1. iface 就能够在其中扮演粘结剂的角色,经过 reflect 包在对象、接口、类型之间进行转换了。
  2. iface 的变量能够在编译阶段,在变量赋值处,增长拷贝指向对象(父类或者子类)的类型信息的指令,就能够在运行期完成多态的支持了

interface.png

理论验证

下面咱们仍是经过测试代码来验证咱们的理论,咱们本身定义底层的相关类型,而后经过强制类型转换,来尝试解析interface变量中的数据:

type Iface struct {
	Tab *Itab
	Data unsafe.Pointer
}

type Itab struct {
	Inter uintptr
	Type uintptr
	Hash uint32
	_ [4]byte
	Fun [1]uintptr
}

type Eface struct {
	Type uintptr
	Data unsafe.Pointer
}

func TestInterface(t *testing.T) {
	var (
		p    Point
		nilP interface{}
	)
	point := &point3d{X: 1, Y: 2, Z: 3}
	nilP = point
	fmt.Printf("eface size:%v\n", unsafe.Sizeof(nilP))
	eface := (*face.Eface)(unsafe.Pointer(&nilP))
	spew.Dump(eface.Type)
	spew.Dump(eface.Data)
	fmt.Printf("eface offset: eface._type = %v, eface.data = %v\n\n",
		unsafe.Offsetof(eface.Type), unsafe.Offsetof(eface.Data))

	p = point
	fmt.Printf("point size:%v\n", unsafe.Sizeof(p))
	iface := (*face.Iface)(unsafe.Pointer(&p))
	spew.Dump(iface.Tab)
	spew.Dump(iface.Data)
	fmt.Printf("Iface offset: iface.tab = %v, iface.data = %v\n\n",
		unsafe.Offsetof(iface.Tab), unsafe.Offsetof(iface.Data))
}
复制代码

执行程序输出为:

$ go test -v -run TestInterface
=== RUN   TestInterface
eface size:16
(uintptr) 0x111f2c0
(unsafe.Pointer) 0xc00008e250
eface offset: eface._type = 0, eface.data = 8

point size:16
(*face.Itab)(0x116ec40)({
 Inter: (uintptr) 0x1122680,
 Type: (uintptr) 0x111f2c0,
 Hash: (uint32) 960374823,
 _: ([4]uint8) (len=4 cap=4) {
  00000000  00 00 00 00                                       |....|
 },
 Fun: ([1]uintptr) (len=1 cap=1) {
  (uintptr) 0x10fce20
 }
})
(unsafe.Pointer) 0xc00008e250
Iface offset: iface.tab = 0, iface.data = 8
复制代码

下面咱们再经过汇编代码看下,赋值操做作了什么?

type Point interface {
	Println()
}

type point3d struct {
	X, Y, Z float32
}

func (p *point3d) Println() {
	fmt.Printf("%v,%v,%v\n", p.X, p.Y, p.Z)
}

func main() {
	point := point3d{X: 1, Y: 2, Z: 3} // L18
	var (
		nilP interface{}   // L20
		p    Point        // L21
	)
	nilP = &point         // L23
	p = &point            // L24
	fmt.Println(nilP, p) 
}
复制代码

经过 go tool 查看汇编代码以下:

TEXT main.main(SB) /Users/cyningsun/Documents/go/src/github.com/cyningsun/go-test/20200102-inside-golang-object-model/main/build.go
  build.go:17	0x1094de0	65488b0c2530000000      MOVQ GS:0x30, CX
  build.go:17	0x1094de9	488d4424b0              LEAQ -0x50(SP), AX
  build.go:17	0x1094dee	483b4110                CMPQ 0x10(CX), AX
  build.go:17	0x1094df2	0f86b9010000            JBE 0x1094fb1
  build.go:17	0x1094df8	4881ecd0000000          SUBQ $0xd0, SP
  build.go:17	0x1094dff	4889ac24c8000000        MOVQ BP, 0xc8(SP)
  build.go:17	0x1094e07	488dac24c8000000        LEAQ 0xc8(SP), BP
  build.go:18	0x1094e0f	488d05ea1e0200          LEAQ type.*+137216(SB), AX   // point := point3d{X: 1, Y: 2, Z: 3}
  build.go:18	0x1094e16	48890424                MOVQ AX, 0(SP)
  build.go:18	0x1094e1a	e81160f7ff              CALL runtime.newobject(SB)
  build.go:18	0x1094e1f	488b442408              MOVQ 0x8(SP), AX
  build.go:18	0x1094e24	4889442458              MOVQ AX, 0x58(SP)
  build.go:18	0x1094e29	0f57c0                  XORPS X0, X0
  build.go:18	0x1094e2c	f30f11442434            MOVSS X0, 0x34(SP)
  build.go:18	0x1094e32	f30f11442438            MOVSS X0, 0x38(SP)
  build.go:18	0x1094e38	f30f1144243c            MOVSS X0, 0x3c(SP)
  build.go:18	0x1094e3e	f30f1005a6b80400        MOVSS $f32.3f800000(SB), X0
  build.go:18	0x1094e46	f30f11442434            MOVSS X0, 0x34(SP)
  build.go:18	0x1094e4c	f30f100d9cb80400        MOVSS $f32.40000000(SB), X1
  build.go:18	0x1094e54	f30f114c2438            MOVSS X1, 0x38(SP)	
  build.go:18	0x1094e5a	f30f101592b80400        MOVSS $f32.40400000(SB), X2
  build.go:18	0x1094e62	f30f1154243c            MOVSS X2, 0x3c(SP)	
  build.go:18	0x1094e68	488b442458              MOVQ 0x58(SP), AX	
  build.go:18	0x1094e6d	f30f1100                MOVSS X0, 0(AX)	
  build.go:18	0x1094e71	f30f114804              MOVSS X1, 0x4(AX)	
  build.go:18	0x1094e76	f30f115008              MOVSS X2, 0x8(AX)	
  build.go:20	0x1094e7b	0f57c0                  XORPS X0, X0 // nilP interface{}	
  build.go:20	0x1094e7e	0f11442470              MOVUPS X0, 0x70(SP)// nilP 开始地址为0x70	
  build.go:21	0x1094e83	0f57c0                  XORPS X0, X0 // p Point	
  build.go:21	0x1094e86	0f11442460              MOVUPS X0, 0x60(SP)	
  build.go:23	0x1094e8b	488b442458              MOVQ 0x58(SP), AX	// nilP = &point  ;0x58(SP) 为 point 的地址	
  build.go:23	0x1094e90	4889442448              MOVQ AX, 0x48(SP) // SP 指向 point 地址	
  build.go:23	0x1094e95	488d0da4860100          LEAQ type.*+98368(SB), CX // ;从内存加载 Point类型地址 到 CX 寄存器
  build.go:23	0x1094e9c	48894c2470              MOVQ CX, 0x70(SP) // ;将 Point类型地址(8字节) 保存到 0x70(即eface._type)	
  build.go:23	0x1094ea1	4889442478              MOVQ AX, 0x78(SP) // ;将 point 对象地址(8字节) 保存到 0x78(即eface.data)
  build.go:24	0x1094ea6	488b442458              MOVQ 0x58(SP), AX	// p = &point	
  build.go:24	0x1094eab	4889442448              MOVQ AX, 0x48(SP)	// ;SP 指向 point 地址
  build.go:24	0x1094eb0	488d0d09d50400          LEAQ go.itab.*main.point3d,main.Point(SB), CX	// ;从内存加载 Point类型 itab 地址 到 CX 寄存器
  build.go:24	0x1094eb7	48894c2460              MOVQ CX, 0x60(SP)	// ;将 Point类型地址(8字节) 保存到 0x70(即iface.tab)	
  build.go:24	0x1094ebc	4889442468              MOVQ AX, 0x68(SP)	// ;将 point 对象地址(8字节) 保存到 0x78(即iface.data)	
  build.go:25	0x1094ec1	488b442468              MOVQ 0x68(SP), AX	// fmt.Println(nilP, p)	

  ...
复制代码

事实正如理论通常,在编译阶段,赋值命令被转化为类型信息和对象指针的拷贝,保存下来执行期转换所须要的一切信息。

综述

从底层代码和汇编出发,分析 struct 和 interface 的 对象模型,理清了Go 语言高级特性的底层机制。再去学习反射等表层细节,事半功倍。

参考连接:

相关文章
相关标签/搜索