上一章中对于golang的数据结构的说明如下:

接下来我们来对golang的语言基础进行说明,主要内容有:

  • 1 函数调用
  • 2 接口
  • 3 反射

函数是 Go 语言中的一等公民,理解和掌握函数的调用过程是我们深入学习 Go 无法跳过的,本节将从函数的调用惯例和参数的传递方法两个方面分别介绍函数的执行过程。

1. 调用惯例

无论是系统级编程语言 C 和 Go,还是脚本语言 Ruby 和 Python,这些编程语言在调用函数时往往都使用相同的语法:

somefunction(arg0, arg1)

虽然它们调用函数的语法很相似,但是它们的调用惯例却可能大不相同。调用惯例是调用方和被调用方对于参数和返回值传递的约定,我们将在这里为各位读者分别介绍 C 和 Go 语言的调用惯例。

C 语言

我们先来研究 C 语言的调用惯例,使用 GCC1 和 Clang2 编译器将 C 语言编译成汇编代码是分析它调用惯例的最好方法,从汇编语言中可以一窥函数调用的具体过程。

GCC 和 Clang 编译相同 C 语言代码可能会生成不同的汇编指令,不过生成的代码在结构上不会有太大的区别,所以对只想理解调用惯例的人来说没有太多影响。作者在本节中选择使用 GCC 编译器来编译 C 语言:

$ gcc --version
gcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2
Copyright (C) 2013 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
mainmy_function
// ch04/my_function.c
int my_function(int arg1, int arg2) {
    return arg1 + arg2;
}

int main() {
    int i = my_function(1, 2);
}
cc -S my_function.c
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp
	movl	$2, %esi  // 设置第二个参数
	movl	$1, %edi  // 设置第一个参数
	call	my_function
	movl	%eax, -4(%rbp)
my_function:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)    // 取出第一个参数,放到栈上
	movl	%esi, -8(%rbp)    // 取出第二个参数,放到栈上
	movl	-8(%rbp), %eax    // eax = esi = 1
	movl	-4(%rbp), %edx    // edx = edi = 2
	addl	%edx, %eax        // eax = eax + edx = 1 + 2 = 3
	popq	%rbp
my_function
my_functionmainmy_functionmy_functionaddlmy_functionmainmy_functioni
int my_function(int arg1, int arg2, int ... arg8) {
    return arg1 + arg2 + ... + arg8;
}
my_function
main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp     // 为参数传递申请 16 字节的栈空间
	movl	$8, 8(%rsp)   // 传递第 8 个参数
	movl	$7, (%rsp)    // 传递第 7 个参数
	movl	$6, %r9d
	movl	$5, %r8d
	movl	$4, %ecx
	movl	$3, %edx
	movl	$2, %esi
	movl	$1, %edi
	call	my_function
mainmy_function
mainmainmy_function
图 - C 语言 main 函数的调用栈
mainmain
my_functionmainsubq $16, %rspcall my_functionmy_function
my_function:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)    // rbp-4 = edi = 1
	movl	%esi, -8(%rbp)    // rbp-8 = esi = 2
	...
	movl	-8(%rbp), %eax    // eax = 2
	movl	-4(%rbp), %edx    // edx = 1
	addl	%eax, %edx        // edx = eax + edx = 3
	...
	movl	16(%rbp), %eax    // eax = 7
	addl	%eax, %edx        // edx = eax + edx = 28
	movl	24(%rbp), %eax    // eax = 8
	addl	%edx, %eax        // edx = eax + edx = 36
	popq	%rbp
my_function

我们可以将本节的发现和分析简单总结成 —— 当我们在 x86_64 的机器上使用 C 语言中调用函数时,参数都是通过寄存器和栈传递的,其中:

  • 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递;
  • 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;

而函数的返回值是通过 eax 寄存器进行传递的,由于只使用一个寄存器存储返回值,所以 C 语言的函数不能同时返回多个值。

Go 语言

分析了 C 语言函数的调用惯例之后,我们再来剖析一下 Go 语言中函数的调用惯例。我们以下面这个非常简单的代码片段为例简要分析一下:

package main

func myFunction(a, b int) (int, int) {
	return a + b, a - b
}

func main() {
	myFunction(66, 77)
}
myFunctionmainmyFunctiongo tool compile -S -N -l main.go
注:如果编译时不使用 -N -l 参数,编译器会对汇编代码进行优化,编译结果会有较大差别。
"".main STEXT size=68 args=0x0 locals=0x28
	0x0000 00000 (main.go:7)	MOVQ	(TLS), CX
	0x0009 00009 (main.go:7)	CMPQ	SP, 16(CX)
	0x000d 00013 (main.go:7)	JLS	61
	0x000f 00015 (main.go:7)	SUBQ	$40, SP      // 分配 40 字节栈空间
	0x0013 00019 (main.go:7)	MOVQ	BP, 32(SP)   // 将基址指针存储到栈上
	0x0018 00024 (main.go:7)	LEAQ	32(SP), BP
	0x001d 00029 (main.go:8)	MOVQ	$66, (SP)    // 第一个参数
	0x0025 00037 (main.go:8)	MOVQ	$77, 8(SP)   // 第二个参数
	0x002e 00046 (main.go:8)	CALL	"".myFunction(SB)
	0x0033 00051 (main.go:9)	MOVQ	32(SP), BP
	0x0038 00056 (main.go:9)	ADDQ	$40, SP
	0x003c 00060 (main.go:9)	RET
mainmainmyFunction
图 - Go 语言 main 函数的调用栈
mainSUBQ $40, SP
myFunction
CALL "".myFunction(SB)mainmyFunction
"".myFunction STEXT nosplit size=49 args=0x20 locals=0x0
	0x0000 00000 (main.go:3)	MOVQ	$0, "".~r2+24(SP) // 初始化第一个返回值
	0x0009 00009 (main.go:3)	MOVQ	$0, "".~r3+32(SP) // 初始化第二个返回值
	0x0012 00018 (main.go:4)	MOVQ	"".a+8(SP), AX    // AX = 66
	0x0017 00023 (main.go:4)	ADDQ	"".b+16(SP), AX   // AX = AX + 77 = 143
	0x001c 00028 (main.go:4)	MOVQ	AX, "".~r2+24(SP) // (24)SP = AX = 143
	0x0021 00033 (main.go:4)	MOVQ	"".a+8(SP), AX    // AX = 66
	0x0026 00038 (main.go:4)	SUBQ	"".b+16(SP), AX   // AX = AX - 77 = -11
	0x002b 00043 (main.go:4)	MOVQ	AX, "".~r3+32(SP) // (32)SP = AX = -11
	0x0030 00048 (main.go:4)	RET
mainintmyFunction
图 - myFunction 函数返回前的栈
myFunctionmain
0x0033 00051 (main.go:9)    MOVQ    32(SP), BP
    0x0038 00056 (main.go:9)    ADDQ    $40, SP
    0x003c 00060 (main.go:9)    RET

通过分析 Go 语言编译后的汇编指令,我们发现 Go 语言使用栈传递参数和接收返回值,所以它只需要在栈上多分配一些内存就可以返回多个值。

思考

C 语言和 Go 语言在设计函数的调用惯例时选择也不同的实现。C 语言同时使用寄存器和栈传递参数,使用 eax 寄存器传递返回值;而 Go 语言使用栈传递参数和返回值。我们可以对比一下这两种设计的优点和缺点:

  • C 语言的方式能够极大地减少函数调用的额外开销,但是也增加了实现的复杂度;
    • CPU 访问栈的开销比访问寄存器高几十倍3;
    • 需要单独处理函数参数过多的情况;
  • Go 语言的方式能够降低实现的复杂度并支持多返回值,但是牺牲了函数调用的性能;
    • 不需要考虑超过寄存器数量的参数应该如何传递;
    • 不需要考虑不同架构上的寄存器差异;
    • 函数入参和出参的内存空间需要在栈上进行分配;


Go 语言使用栈作为参数和返回值传递的方法是综合考虑后的设计,选择这种设计意味着编译器会更加简单、更容易维护。

2. 参数传递

除了函数的调用惯例之外,Go 语言在传递参数时是传值还是传引用也是一个有趣的问题,这个问题影响的是当我们在函数中对入参进行修改时会不会影响调用方看到的数据。我们先来介绍一下传值和传引用两者的区别:

  • 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;
  • 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。

不同语言会选择不同的方式传递参数,Go 语言选择了传值的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝。本节剩下的内容将会验证这个结论的正确性。

整型和数组

myFunctioniarrmyFunction
func myFunction(i int, arr [2]int) {
	fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}

func main() {
	i := 30
	arr := [2]int{66, 77}
	fmt.Printf("before calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
	myFunction(i, arr)
	fmt.Printf("after  calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}

$ go run main.go
before calling - i=(30, 0xc00009a000) arr=([66 77], 0xc00009a010)
in my_funciton - i=(30, 0xc00009a008) arr=([66 77], 0xc00009a020)
after  calling - i=(30, 0xc00009a000) arr=([66 77], 0xc00009a010)
mainmyFunction
mainmyFunctioniarrmyFunctionmainmyFunction
func myFunction(i int, arr [2]int) {
	i = 29
	arr[1] = 88
	fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}

$ go run main.go
before calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)
in my_funciton - i=(29, 0xc000072028) arr=([66 88], 0xc000072040)
after  calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)
myFunctionmain

结构体和指针

MyStructmyFunction
type MyStruct struct {
	i int
}

func myFunction(a MyStruct, b *MyStruct) {
	a.i = 31
	b.i = 41
	fmt.Printf("in my_function - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}

func main() {
	a := MyStruct{i: 30}
	b := &MyStruct{i: 40}
	fmt.Printf("before calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
	myFunction(a, b)
	fmt.Printf("after calling  - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}

$ go run main.go
before calling - a=({30}, 0xc000018178) b=(&{40}, 0xc00000c028)
in my_function - a=({31}, 0xc000018198) b=(&{41}, 0xc00000c038)
after calling  - a=({30}, 0xc000018178) b=(&{41}, 0xc00000c028)

从运行的结果我们可以得出如下结论:

  • 传递结构体时:会对结构体中的全部内容进行拷贝;
  • 传递结构体指针时:会对结构体指针进行拷贝;
b.i(*b).ib
type MyStruct struct {
	i int
	j int
}

func myFunction(ms *MyStruct) {
	ptr := unsafe.Pointer(ms)
	for i := 0; i < 2; i++ {
		c := (*int)(unsafe.Pointer((uintptr(ptr) + uintptr(8*i))))
		*c += i + 1
		fmt.Printf("[%p] %d\n", c, *c)
	}
}

func main() {
	a := &MyStruct{i: 40, j: 50}
	myFunction(a)
	fmt.Printf("[%p] %v\n", a, a)
}

$ go run main.go
[0xc000018180] 41
[0xc000018188] 52
[0xc000018180] &{41 52}
MyStructintij
go tool compile
type MyStruct struct {
	i int
	j int
}

func myFunction(ms *MyStruct) *MyStruct {
	return ms
}

$ go tool compile -S -N -l main.go
"".myFunction STEXT nosplit size=20 args=0x10 locals=0x0
	0x0000 00000 (main.go:8)	MOVQ	$0, "".~r1+16(SP) // 初始化返回值
	0x0009 00009 (main.go:9)	MOVQ	"".ms+8(SP), AX   // 复制引用
	0x000e 00014 (main.go:9)	MOVQ	AX, "".~r1+16(SP) // 返回引用
	0x0013 00019 (main.go:9)	RET
MOVQ "".ms+8(SP), AX
图 - Go 语言指针参数

所以将指针作为参数传入某一个函数时,在函数内部会对指针进行复制,也就是会同时出现两个指针指向原有的内存空间,所以 Go 语言中『传指针』也是传值。

传值

当我们对 Go 语言中大多数常见的数据结构进行验证之后,其实就能够推测出 Go 语言在传递参数时其实使用的就是传值的方式,接收方收到参数时会对这些参数进行复制;了解到这一点之后,在传递数组或者内存占用非常大的结构体时,我们在一些函数中应该尽量使用指针作为参数类型来避免发生大量数据的拷贝而影响性能。

3. 小结

这一节我们详细分析了 Go 语言的调用惯例,包括传递参数和返回值的过程和原理。Go 通过栈传递函数的参数和返回值,在调用函数之前会在栈上为返回值分配合适的内存空间,随后将入参从右到左按顺序压栈并拷贝参数,返回值会被存储到调用方预留好的栈空间上,我们可以简单总结出以下几条规则:

  1. 通过堆栈传递参数,入栈的顺序是从右到左;
  2. 函数返回值通过堆栈传递并由调用者预先分配内存空间;
  3. 调用函数时都是传值,接收方会对入参进行复制再计算;

全套教程点击下方链接直达: