用匯編帶你看Golang裏到底有沒有值類型、引用類型

緣起

無論使用什麼語言,平常生活中能常在技術羣中看到相似這樣的問題(固然這個圖html

是我瞎編的,真實的討論會比圖中 peace 一些~):c++

image.png

本人在這個話題上被別人鄙視過,此次寫一篇文章,好好研究一下這個話題~ 這張圖的問題是: T類型在函數調用中是引用傳遞仍是值傳遞。想要弄清這個問題,須要明確什麼是引用,什麼是值,因此本文會先討論一下 T類型的數據類型是值類型仍是引用類型。另外,文章只針對Golang這門語言進行探索。 那麼,什麼是值類型,引用傳遞又是怎麼回事呢?下面就跟小編一塊兒來了解一下吧(~:git

驗證

數據類型

關於值類型、引用類型,維基百科中這樣定義github

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,也能夠是一個引用值,用戶能夠根據引用值找到信息自己。golang

舉個例子,如今有個變量要去存不一樣類型的值。對於一些佔用空間比較小的類型,好比 整數、浮點數和bool類型,變量存的是這些值自己;而對於一些佔用空間較大的類型,變量存的是類型的指針,用戶能夠根據指針找到這個值,這樣的好處之一是能夠節省內存。注意對於引用類型,若是兩個變量都保存某個值的引用,一個變量經過引用把信息改變後,用戶能夠經過另外一個變量看到信息的變化。api

爲啥會有引用類型呢,若是須要在多個過程當中針對某個數據進行計算,那就得用地址做爲信息去傳遞。達到的效果是 兩個變量都保存某個值的引用,一個變量經過引用把信息改變後,用戶經過另外一個變量看到改變後的信息。這樣作還有個好處是能夠節省空間,由於你可使用指針來代替一個佔用空間很大的結構體的傳遞。數組

簡單經過圖片看一下這兩種分類的區別:數據結構

值類型(Golang代碼)ide

image.png

引用類型(C++代碼)函數

image.png

從圖片上不能直觀看出數據類型地址分佈,接着經過代碼來觀察一下,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

image.png

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

image.png

引用傳遞(c++):

image.png

經過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語言參數傳遞是傳值仍是傳引用

相關文章
相關標籤/搜索