乐趣区

关于golang:用汇编带你看Golang里到底有没有值类型引用类型

缘起

不论应用什么语言,日常生活中能常在技术群中看到相似这样的问题(当然这个图

是我瞎编的,实在的讨论会比图中 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++ 中有援用类型,通过 & 符号即可申明,例子如下:

#include <stdio.h>

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

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

这段代码的运行后果为

~ 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

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)
}

输入后果:

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 汇编。通过汇编,咱们能够察看到指令级别的代码行为。只有看懂了汇编码,任何花里胡哨的技术名词在你背后就如同嗷嗷待哺的小鸡仔一样不堪一击。所以让咱们间接通过汇编来看一下下面的例子具体做了啥:

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 行的指令即可:

➜  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 这种数据结构底层做了啥:

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):

➜  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 这个指针会执行字节数组。

type stringStruct struct {
    str unsafe.Pointer
    len int
}

把代码略微改一下:

package main

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

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

汇编剖析

➜  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

代码:

package main

import "fmt"

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

    b := a

    fmt.Println(a, b)
}

汇编:

    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 的底层数据结构如下所示:

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 在某些场合的体现并不属于援用类型:

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

代码:

package main

import "fmt"

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

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

$ 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 的指针。具备指针的性质。

这让它看起来像是援用类型,然而它同样有非援用类型的体现:

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

代码

package main

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

汇编:

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

代码:

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}
}

汇编:

    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 ++ 代码看一下援用传递的理论体现:

#include <stdio.h>

void fk(int & count)// & 使其进行援用传递
{
  count=count+1;    
  printf("fk: %p, %d\n",&count, count); // 把各种变量信息打印进去
}

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

输入后果:

$ 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 类型的正本,传入一个指针将会拷贝一份指针正本,但并不会拷贝指针指向的值。

通过后面的剖析,置信读者对一些根本数据类型曾经有肯定的想法。让咱们看一下答案中专门强调的指针类型在函数传参中的体现:

package main

import "fmt"

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

func main() {
    a := 10086
    fmt.Printf("main a'adreess: %p\n", &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 语言参数传递是传值还是传援用

退出移动版