用汇编带你看Golang里到底有没有值类型、引用类型

2022-08-10 19:30:24 浏览数 (1)

  • 缘起
  • 验证
    • 数据类型
    • 函数调用
  • 总结
  • 参考

缘起

不管使用什么语言,日常生活中能常在技术群中看到类似这样的问题(当然这个图是我瞎编的,真实的讨论会比图中 peace 一些~):

本人在这个话题上被别人鄙视过,这次写一篇文章,好好研究一下这个话题~ 这张图的问题是:T类型在函数调用中是引用传递还是值传递。想要弄清这个问题,需要明确什么是引用,什么是值,所以本文会先讨论一下 T类型的数据类型是值类型还是引用类型。另外,文章只针对Golang这门语言进行探索。那么,什么是值类型,引用传递又是怎么回事呢?下面就跟小编一起来了解一下吧(~:

验证

数据类型

关于值类型、引用类型,维基百科中这样定义:

“In computer programming, data types can be divided into two categories: A value of value type is the actual value. A value of reference type is a reference to another value。

定义中把数据类型分为值类型和引用类型两类,然后介绍 值类型的值是信息本身;引用类型来的值是引用,这个引用可以为 nil,也可以是一个引用值,用户可以根据引用值找到信息本身。

举个例子,现在有个变量需要存不同类型的值。对于一些占用空间比较小的类型,比如 整数、浮点数和bool类型,变量存的是这些值本身;而对于一些占用空间较大的类型,变量存的是类型的指针,用户可以根据指针找到这个值,这样的好处之一是可以节省内存。注意对于引用类型,如果两个变量都保存某个值的引用,一个变量通过引用把信息改变后,用户可以通过另一个变量看到信息的变化。

为啥会有引用类型呢,如果需要在多个过程中针对某个数据进行计算,那就得用地址作为信息去传递。达到的效果是 两个变量都保存某个值的引用,一个变量通过引用把信息改变后,用户通过另一个变量看到改变后的信息。这样做还有个好处是可以节省空间,因为你可以使用指针来代替一个占用空间很大的结构体的传递。

简单通过图片看一下这两种分类的区别:

值类型(Golang代码)

引用类型(C 代码)

从图片上不能直观看出数据类型地址分布,接着通过代码来观察一下,C 中有引用类型,通过&符号即可声明,例子如下:

代码语言:javascript复制
#include <stdio.h>

int main() {
  int a = 10;
  int &b = a; // 定义了一个引用变量b去引用a的值, 下同
  int &c = b;

  printf("%d %d %dn", a, b, c);
  printf("%p %p %pn", &a, &b, &c);
  a = 100;
  printf("%d %d %dn", a, b, c); 
  return 0;
}

这段代码的运行结果为

代码语言:javascript复制
>  ~ g   main.cpp -o fk1 && ./fk1
>  10 10 10
>  0x7ffee11148c8 0x7ffee11148c8 0x7ffee11148c8
>  100 100 100

Golang中没有&T类型,按照内置类型做分类,Golang里有int、float、string、map、slice、channel、struct、interface、func等数据类型,首先用int写一个和上文C 代码类似的例子:

int
代码语言:javascript复制
package main

import "fmt"

func main() {
 a := 10086
 var b, c = &a, &a   // b、c变量存的都是a的地址
 fmt.Println(b, c)   // b、c变量保存的地址相同
 fmt.Println(&b, &c) // b、c变量本身的值不相同

 d := 100
 b = &d       // b改变,a c的值不变
 fmt.Println(a, *b, *c)
}

输出结果:

代码语言:javascript复制

> 0xc00001a0b0 0xc00001a0b0
> 0xc00000e028 0xc00000e030
> 10086 100 10086

在这段代码中,b和c都保存了a的地址,但是b、c本身是独立的,改变b的值不会对a、c产生影响。所以可以把Golang中的int类型归为值类型之内。

int这种数据类型比较简单,一般不会对其产生疑问,比较有争议的map、slice、channel这些数据类型的分类,这些类型只靠打印地址不够的。俗话说,源码面前了无秘密,虽然 Golang 号称在1.5版本就实现了自举,但源码中至今还有大量的平台相关的汇编代码。如果我们现在想了解一下这个问题:make函数为啥能初始化map、slice、chan这三种不同的数据类型。只看golang源码就回答不了这个问题。所以俗话又说了:如果源码解决不了问题,就用go tool compile命令看一下plan9汇编。通过汇编,我们可以观察到指令级别的代码行为。只要看懂了汇编码,任何花里胡哨的技术名词在你面前就好像嗷嗷待哺的小鸡仔一样不堪一击。所以让我们直接通过汇编来看一下上面的例子具体做了啥:

代码语言:javascript复制
package main

func main() {
 var a = 10086
 b := &a

 print(b, ",", *b)
}

我们使用go tool compile -S -N -l main.go 打印汇编信息,简单说明一下:go tool compile命令用于调用Golang的底层命令工具,-S参数表示输出汇编格式,-N参数表示禁用优化 ,-l参数表示禁用内联,有的函数会用inline函数关键字修饰,这样编译器在编译过程中会直接展开函数的代码,降低函数调用开销。n个汇编指令表示一行语句的执行,这里主要关注第4行和第5行的指令即可:

代码语言:javascript复制
➜  fk git:(master) ✗ go tool compile -S -N -l main.go
"".main STEXT size=143 args=0x0 locals=0x30
------------------------------------------------调度相关代码 头部 start ------------------------------------------------
// 00000~00013主要作用: 检查是否函数栈帧够用,不够用跳到尾部进行扩容
 0x0000 00000 (main.go:3) TEXT "".main(SB), ABIInternal, $48-0 // 声明main函数, $48-0中:$48代表函数栈空间大小是48字节 ,0代表函数没有参数和返回值
 0x0000 00000 (main.go:3) MOVQ (TLS), CX   // 把当前g的地址赋给CX寄存器
 0x0009 00009 (main.go:3) CMPQ SP, 16(CX)  // 16(CX)对应g.stackguard0, 与SP寄存器进行比较
 0x000d 00013 (main.go:3) JLS 133           // 如果SP寄存器小于stackguard0,跳转到133这个位置 //00013代表位置
------------------------------------------------调度相关代码 头部 end ------------------------------------------------
 0x000f 00015 (main.go:3) SUBQ $48, SP   // SP-48 使其指向栈顶位置,这行命令是为了设置stack frame空间, 让SP指向栈顶位置
 0x0013 00019 (main.go:3) MOVQ BP, 40(SP)  // *(SP 40) = BP
 0x0018 00024 (main.go:3) LEAQ 40(SP), BP // 把*(SP 40) 的地址赋值给BP寄存器, 使BP寄存器指向当前函数栈帧的栈底位置
 0x001d 00029 (main.go:3) FUNCDATA $0, gclocals·69c1753bd5f81501d95132d08af04464(SB) // FUNCDATA 和 PCDATA均是gc使用,可忽略,后以...代替
 0x001d 00029 (main.go:3) FUNCDATA $1, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
 0x001d 00029 (main.go:3) FUNCDATA $2, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
 0x001d 00029 (main.go:4) PCDATA $0, $0       // FUNCDATA 和 PCDATA均是gc使用,忽略
 0x001d 00029 (main.go:4) PCDATA $1, $0
 0x001d 00029 (main.go:4) MOVQ $10086, "".a 16(SP)  // a = 10086 
 0x0026 00038 (main.go:5) PCDATA $0, $1
 0x0026 00038 (main.go:5) LEAQ "".a 16(SP), AX      // 把 a变量的地址 赋给AX寄存器
 0x002b 00043 (main.go:5) PCDATA $1, $1
 0x002b 00043 (main.go:5) MOVQ AX, "".b 32(SP)      // 把 AX寄存器的值 赋给b变量
 0x0030 00048 (main.go:7) PCDATA $0, $0
 0x0030 00048 (main.go:7) TESTB AL, (AX)
 0x0032 00050 (main.go:7) MOVQ "".a 16(SP), AX    
 0x0037 00055 (main.go:7) MOVQ AX, ""..autotmp_2 24(SP)
 0x003c 00060 (main.go:7) CALL runtime.printlock(SB)
 0x0041 00065 (main.go:7) PCDATA $0, $1
 0x0041 00065 (main.go:7) PCDATA $1, $0
 0x0041 00065 (main.go:7) MOVQ "".b 32(SP), AX
 0x0046 00070 (main.go:7) PCDATA $0, $0
 0x0046 00070 (main.go:7) MOVQ AX, (SP)
 0x004a 00074 (main.go:7) CALL runtime.printpointer(SB)
 0x004f 00079 (main.go:7) PCDATA $0, $1
 0x004f 00079 (main.go:7) LEAQ go.string.","(SB), AX
 0x0056 00086 (main.go:7) PCDATA $0, $0
 0x0056 00086 (main.go:7) MOVQ AX, (SP)
 0x005a 00090 (main.go:7) MOVQ $1, 8(SP)
 0x0063 00099 (main.go:7) CALL runtime.printstring(SB)
 0x0068 00104 (main.go:7) MOVQ ""..autotmp_2 24(SP), AX
 0x006d 00109 (main.go:7) MOVQ AX, (SP)
 0x0071 00113 (main.go:7) CALL runtime.printint(SB)
 0x0076 00118 (main.go:7) CALL runtime.printunlock(SB)
 0x007b 00123 (main.go:8) MOVQ 40(SP), BP
 0x0080 00128 (main.go:8) ADDQ $48, SP
 0x0084 00132 (main.go:8) RET
 0x0085 00133 (main.go:8) NOP
------------------------------------------------调度相关代码 尾部 start ------------------------------------------------
// 00133 主要作用:1.栈扩容;2.被runtime管理调度
 0x0085 00133 (main.go:3) PCDATA $1, $-1           // FUNCDATA 和 PCDATA均是gc使用,忽略
 0x0085 00133 (main.go:3) PCDATA $0, $-1           // FUNCDATA 和 PCDATA均是gc使用,忽略
 0x0085 00133 (main.go:3) CALL runtime.morestack_noctxt(SB) // morestack but not preserving ctxt. 执行栈空间扩容
 0x008a 00138 (main.go:3) JMP 0
------------------------------------------------调度相关代码 尾部 end ------------------------------------------------

通过汇编我们可以看到b变量保存的是a变量的地址,这个过程是用AX寄存器实现的(附录部分会介绍Plan9指令,理解有问题的同学可以先看附录)。

string

让我们接着看一下string这种数据结构底层做了啥:

代码语言:javascript复制
package main

func main() {
 var a = "hello"
 b := &a

 c := "world"
 b = &c

 println(*b, b) // world 0xc000044730
 println(a, &a) //  hello 0xc000044740
}

汇编分析(只要分析main.go:4和main.go:5):

代码语言:javascript复制
➜  fk git:(master) ✗ go tool compile -S -N -l main.go | grep -v PCDATA 
 0x0021 00033 (main.go:4) LEAQ go.string."hello"(SB), AX  // AX 取hello这个.rodata段数据的地址
 0x0028 00040 (main.go:4) MOVQ AX, "".a 24(SP)       // 把AX 赋给a变量 位置:SP 24byte
 0x002d 00045 (main.go:4) MOVQ $5, "".a 32(SP)       // 把5(字符串长度)赋给a变量 位置:SP 32byte
 0x0036 00054 (main.go:5) LEAQ "".a 24(SP), AX       // AX取 "".a 24(SP) 的地址
 0x003b 00059 (main.go:5) MOVQ AX, "".b 16(SP)       // 把AX的值赋给b变量

从汇编中可以看到b:=&a语句实际上是拷贝a变量的地址。在汇编层面 string是一个指针和len长度,赋值时会取个复合结构的地址,这也符合runtime.string.go的定义,其中str这个指针会执行字节数组。

代码语言:javascript复制
type stringStruct struct {
 str unsafe.Pointer
 len int
}

把代码稍微改一下:

代码语言:javascript复制
package main

func main() {
 var a = "hello"
 b := a

 println(a, ",", b)   // hello , hello
 println(&a, ",", &b)  // 0xc000044740 , 0xc000044730
}

汇编分析

代码语言:javascript复制
➜  fk git:(master) ✗ go tool compile -S -N -l main.go
 0x0021 00033 (main.go:4) LEAQ go.string."hello"(SB), AX // AX 取hello这个.rodata段数据的地址
 0x0028 00040 (main.go:4) MOVQ AX, "".a 48(SP)      // AX 赋值给a   位置: sp 48byte
 0x002d 00045 (main.go:4) MOVQ $5, "".a 56(SP)      // 长度5赋值给a  位置: sp 56byte
 0x0036 00054 (main.go:5) MOVQ AX, "".b 32(SP)      // AX 赋值给b   位置: sp 32byte
 0x003b 00059 (main.go:5) MOVQ $5, "".b 40(SP)      // 长度5赋值给b  位置: sp 40byte

当b是string类型时,执行b := a时,b的值是信息本身对b的修改都不会影响到a;

当b取string地址时,执行b = &c 只是让b保存另一份指针,也不会影响到a本身的值,说明string是值类型。

slice

代码:

代码语言:javascript复制
package main

import "fmt"

func main() {
 a := make([]int, 10)
 a[0] = 1

 b := a

 fmt.Println(a, b)
}

汇编:

代码语言:javascript复制
 0x002f 00047 (main.go:6) LEAQ type.int(SB), AX     // 把type.int值的指针赋给AX
 0x0036 00054 (main.go:6) MOVQ AX, (SP)       // 把寄存器里的值赋给sp
 0x003a 00058 (main.go:6) MOVQ $10, 8(SP)      // 把len的值赋给sp 8的位置
 0x0043 00067 (main.go:6) MOVQ $10, 16(SP)      // 把cap的值赋给sp 16的位置  (以上这几行都是为了给makeslice准备参数)
 0x004c 00076 (main.go:6) CALL runtime.makeslice(SB) // 调用makeslice
 0x0051 00081 (main.go:6) MOVQ 24(SP), AX      // AX = *(sp 24) 把makeslice的结果赋给AX
 0x0056 00086 (main.go:6) MOVQ AX, "".a 96(SP)    // AX 赋给变量a     位置:sp   96byte
 0x005b 00091 (main.go:6) MOVQ $10, "".a 104(SP)   // len 10 赋给变量a 位置:sp   104byte
 0x0064 00100 (main.go:6) MOVQ $10, "".a 112(SP)   // cap 10 赋给变量a 位置:sp   112byte
 0x006d 00109 (main.go:7) JMP 111           // 这行感觉没啥卵用
 0x006f 00111 (main.go:7) MOVQ $1, (AX)       // a[0] = 1
 0x0076 00118 (main.go:9) MOVQ "".a 104(SP), AX   // 把len赋给AX
 0x007b 00123 (main.go:9) MOVQ "".a 96(SP), CX    // 把指针赋给CX
 0x0080 00128 (main.go:9) MOVQ "".a 112(SP), DX   // 把cap赋给DX
 0x0085 00133 (main.go:9) MOVQ CX, "".b 72(SP)    // CX赋给b
 0x008a 00138 (main.go:9) MOVQ AX, "".b 80(SP)    // AX赋给b
 0x008f 00143 (main.go:9) MOVQ DX, "".b 88(SP)    // DX赋给b

makeslice函数签名为func makeslice(et *_type, len, cap int) unsafe.Pointer。通过汇编可以看到,初始化slice的步骤为: 1.准备信息,2. 调用makeslice函数,3. 把函数的结果指针、len信息、cap信息赋给变量。在执行b := a语句时,又继续把指针信息、长度、容量赋给另一个变量。其中slice的底层数据结构如下所示:

代码语言:javascript复制
type slice struct {
 array unsafe.Pointer 
 len   int 
 cap   int  
}

这样的表现让slice这种数据类型似乎属于引用类型这个种类,在Go语言的官方文档有段声明map的定义中能找到类似的描述:

“Map types are reference types, like pointers or slices, and so the value of m above is nil; it doesn't point to an initialized map.

遗憾的是,slice在某些场合的表现并不属于引用类型:

代码语言:javascript复制
package main

func fk(a []int) {
 a = make([]int, 0)
 println(a == nil)  // false
}

func main() {
 var a []int
 println(a == nil)  // true
 fk(a)
 println(a == nil)  // true
}

实际上,早在13年,Go语言之父之一就在go spec中声明:

“spec: Go has no 'reference types'

在描述slice时,也把之前的reference to这种偏“清晰”的词汇改为了descriptor for。并特地删掉了Slices, maps and channels are reference types

map

代码:

代码语言:javascript复制
package main

import "fmt"

func main() {
 a := make(map[string]int)
 b := a
 fmt.Println(a, b)
}

汇编(非相关汇编代码已删去):

代码语言:javascript复制
$ go tool compile -S -N -l func-param.go 
 0x002f 00047 (main.go:6) CALL runtime.makemap_small(SB) // 调用 makemap_small 函数
 0x0034 00052 (main.go:6) MOVQ (SP), AX         // AX = *(BP)
 0x0038 00056 (main.go:6) MOVQ AX, "".a 56(SP)      // 把 AX 赋给a变量
 0x003d 00061 (main.go:7) MOVQ AX, "".b 48(SP)      // 把 AX 赋给b变量

其中 makemap_small 的函数签名为func makemap_small() *hmap,可以看到在不管是初始化a,还是执行b的赋值语句,底层都是在把指针赋给变量。map类型本质上是一个指向hmap的指针。具有指针的性质。

这让它看起来像是引用类型,但是它同样有非引用类型的表现:

代码语言:javascript复制
package main

func fk(m map[string]int) {
 m = make(map[string]int)
 println(m == nil)   // false
}

func main() {
 var a map[string]int
 println(a == nil)   // true
 fk(a)
 println(a == nil)   // true
}
channel

代码

代码语言:javascript复制
package main

func main() {
 a := make(chan int)
 b := a
 println(a, b)
}

汇编:

代码语言:javascript复制
0x001d 00029 (main.go:4) LEAQ type.chan int(SB), AX  // 把type.chan int值的指针赋给AX
0x0024 00036 (main.go:4) MOVQ AX, (SP)        // *(SP) = AX
0x0028 00040 (main.go:4) MOVQ $0, 8(SP)        // *(SP 8) = 0
0x0031 00049 (main.go:4) CALL runtime.makechan(SB)  // 调用runtime.makechan
0x0036 00054 (main.go:4) MOVQ 16(SP), AX       // AX = *(SP 16) 即把makechan的结果赋给AX寄存器
0x003b 00059 (main.go:4) MOVQ AX, "".a 32(SP)     // a = AX
0x0040 00064 (main.go:5) MOVQ AX, "".b 24(SP)     // b = AX

chan和slice有类似,都是调用runtime里面的函数并把结果指针赋给变量,makechan的函数签名为:func makechan(t *chantype, size int) *hchan

struct

代码:

代码语言:javascript复制
package main

import "fmt"

type F struct {
 A int
}

func main() {
 a := F{A: 1}
 b := a

 b.A = 2

 fmt.Println(a, b) // {1} {2}
}

汇编:

代码语言:javascript复制
 0x002f 00047 (main.go:10) MOVQ $0, "".a 56(SP)  // 这行估计是为了初始化a
 0x0038 00056 (main.go:10) MOVQ $1, "".a 56(SP)  // 把 1 赋值给a
 0x0041 00065 (main.go:11) MOVQ $1, "".b 48(SP)  // 把 1 赋值给b
 0x004a 00074 (main.go:13) MOVQ $2, "".b 48(SP)  // b的值修改为2

结构体这种数据类型没什么争议,不管在什么层面上都更像值类型

小结

经过上面对各种数据类型在运行时地址、源码以及汇编层面的表现,并结合Go官方文档,有的读者可能还是有点懵逼,我觉得这是正常的。即使Go语言之父之一的大佬13年举大旗明确说明Go中没有引用类型,但是在18年的文档中还是反水说xx type is reference type 。这篇文档也许是其他人写的,侧面说明这个概念确实是confused~

函数调用

同样先来看看定义:

“By definition, pass by value means you are making a copy in memory of the actual parameter's value that is passed in, a copy of the contents of the actual parameter. ... In pass by reference (also called pass by address), a copy of the address of the actual parameter is stored.

中文意思是:

值传递会在内存中拷贝一份实参的值,值是指实参的内容。引用传递会拷贝一份实参的地址。

通过图片看一下两种调用的区别:

值传递(Go代码):

引用传递(c ):

通过c 代码看一下引用传递的实际表现:

代码语言:javascript复制
#include <stdio.h>

void fk(int & count)// & 使其进行引用传递
{
  count=count 1;    
  printf("fk: %p, %dn",&count, count); // 把各种变量信息打印出来
}

int main()
{
  int count=0;  //
  printf("before call fk: %p, %dn",&count, count); //调用函数前看一下各个变量信息
  fk(count);  
  
 printf("after call fk: %p, %dn",&count, count); //调用函数后看一下各个变量信息
  return 0;
}

输出结果:

代码语言:javascript复制
> $ g   main.cpp -o fk1 && ./fk1
> before call fk: 0x7ffee90b57f8, 0
> fk: 0x7ffee90b57f8, 1
> after call fk: 0x7ffee90b57f8, 1

Go语言中是没有引用传递的,官方文档中Q&A部分对函数调用中参数传递早有定义:

When are function parameters passed by value? As in all languages in the C family, everything in Go is passed by value. That is, a function always gets a copy of the thing being passed, as if there were an assignment statement assigning the value to the parameter. For instance, passing an int value to a function makes a copy of the int, and passing a pointer value makes a copy of the pointer, but not the data it points to.

大概翻译一下:Golang中函数传递都是值传递,也就是说函数总是获得传入参数的副本,就如同一个赋值语句讲值分配给参数一样。举例来说:在函数里传入一个 int 类型时会拷贝一个 int 类型的副本,传入一个指针将会拷贝一份指针副本,但并不会拷贝指针指向的值。

经过前面的分析,相信读者对一些基本数据类型已经有一定的想法。让我们看一下答案中专门强调的指针类型在函数传参中的表现:

代码语言:javascript复制
package main

import "fmt"

func fk(a *int) {
 fmt.Printf("func a'value: %pn", a)   // func a'value: 0xc00001a0a0
 fmt.Printf("func a'address: %pn", &a)  // func a'address: 0xc00000e030 // 指针指向的值一样,但是会copy一个新的指针
}

func main() {
 a := 10086
 fmt.Printf("main a'adreess: %pn", &a)  // main a'adreess: 0xc00001a0a0
 fk(&a)
}

指针类型作为函数参数在传递时会拷贝一份新的指针,只不过两份指针指向同一个值。从结果来看符合值传递的概念。

总结

以一些词汇对事物做分类的目的是要降低用户的理解成本,但是 引用类型值类型 对变量分类, 引用传递值传递 对函数调用分类,不仅没有降低成本,反而让人更困惑了。所以个人认为对于数据类型、函数调用这部分知识理解底层原理即可,不要为几个概念来回撕逼了。

参考

spec: Go has no 'reference types'

About the terminology "reference type" in Go

pass_by_value

Value_type_and_reference_type

golang-has-no-reference-values

There is no pass-by-reference in Go

[]T 还是 []*T, 这是一个问题

Golang汇编命令解读

关于引用(reference)这个术语

Go语言参数传递是传值还是传引用

0 人点赞