1. 程式人生 > >Go 語言結構之棧和指標

Go 語言結構之棧和指標

前言

本系列文章總共包括4篇,主要幫助大家理解Go語言中一些語法結構和其背後的設計原則,包括指標、棧、堆、指標逃逸分析和值傳遞/地址傳遞。這一篇是本系列的第一篇,主要介紹棧和指標

以下是本系列文章的索引
1) Go語言結構之棧與指標
2) Go語言結構之指標逃逸分析
3) Go語言結構之記憶體剖析
4) Go語言結構之資料和語法的設計哲學

簡介

我不打算說指標的好話,它確實很難理解。如果應用不當,會產生惱人的bug,甚至是導致效能問題。當寫併發和多執行緒的軟體時更是如此。所以許多語言試著用其它方法讓程式設計人員避免指標的使用。但如果你是在用Go語言的話,你就不得不使用它們。如果不能很好的理解指標,很難寫出乾淨、簡單並且高效的程式碼。

Frame Boundaries

Frame Boundaries(以下簡稱Frame)為每個函式提供了它自己獨有的記憶體空間,函式就是在這個記憶體空間內執行的。每個Frame除了可以讓函式在自己的上下文環境中執行還提供一些流程控制功能。函式可以通過Frame指標直接訪問自己Frame中的記憶體,但如果想要訪問自己Frame之外的記憶體,就需要用間接訪問來實現了。要實現間接訪問,被訪問的記憶體必需和函式共享,要想弄清楚是怎麼實現共享的,首先我們需要了解一下由這些Frame建立起來的記憶體結構和一些限制

當一個函式被呼叫時,兩個相關的Frame之間會發生上下文切換。從呼叫函式切換到被呼叫函式,如果函式呼叫時需要引數,那麼這些引數值也要傳遞到被呼叫函式的Frame中。Go語言中Frame間的資料傳遞是按值傳遞的。

按值傳遞的好處是可讀性好,拷貝並被函式接收到的值就是在函式呼叫時傳入的值 。這就是為什麼我把按值傳遞叫做WYSIWYG(what you see is what you get 的縮寫)。這樣上下文環境轉換髮生時,我們可以很清楚的知道呼叫一個函式會怎樣影響程式的執行

讓我們看一下下面這個小程式,主程式用按值傳遞的方式呼叫了一個函式

Listing 1

package main

func main() {

    // Declare variable of type int with a value of 10.
    count := 10

    // Display the "value of" and "address of" count.
println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]") // Pass the "value of" the count. increment(count) println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]") } //go:noinline func increment(inc int) { // Increment the "value of" inc. inc++ println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]") }

程式啟動後,語言執行環境會建立主函式的goroutine來執行包括在main函式內的所有初始化程式碼。goroutine是放置在作業系統執行緒上的可執行序列,在Go語言的1.8版本中,為每一個goroutine分配了2048 byte的連續記憶體作為它的棧空間。這個初始化的記憶體大小几年來一直在變化,而且未來很有可能繼續變化。

棧在Go語言中是非常重要的,因為它為分配給每個函式的Frame提供了實體記憶體空間。當主函式的goroutine執行 Listing 1中的函式程式碼時,goroutine的棧看起來像下面這個樣子(在一個比較高的語言層次)

Figure 1

在Figure 1中可以看到,一部分棧空間被框了起來,作為main函式的可用空間,這塊棧區域叫做“stack frame”,正是它界定了main函式在棧上的邊界。這塊棧空間是在函式被呼叫後,隨著一些初始化程式碼執行一併被建立的。可以看到變數count的被放置到了main函式farme中的地址0x10429fa4中

在Figure 1中可以看到,在棧上為main函式框出了一個區塊。這個區塊叫做 “stack frame”,就是這個區塊定義了main函式的在棧上的可用範圍。這個區塊是在函式被呼叫的時候建立的。能看到count變數已經被放置到了main函式地址空間中的0x10429fa4地址上

在Figure 1中也可以發現另外一點,就是在活動Frame之下的棧空間是不可用的,只在活動Frame以及它之上的棧空間是可用的。這個 可用棧空間與不可用棧空間的邊界我們需要明確一下。

地址

變數名是為了標識一塊記憶體,使程式碼更具可讀性而存在的。一個好的變數名可以讓程式設計人員清楚的知道它代表的什麼。如果你已經有了一個變數,那在記憶體中就有一個數值與它對應,反之,如果在記憶體中有一個數值,你必需能一個與之對應的變數才能訪問這個記憶體值。在第9行,主函式呼叫了內建函式printn來顯示變數count的值和地址

Listing 2

    09    println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")

用&操作符來獲取變數的地址並不新鮮,許多其它語言也同樣用這個操作符取變數地址。如果你在32位機器上執行這段程式碼(例如playgournd),第9行的輸出應該和下面的很像

Listing 3

    count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

函式呼叫

接下來第12行,main函式呼叫了increment函式

Listing 4

    12    increment(count)

函式呼叫意味著goroutine需要在棧空間中框出一個新的區塊。然而,這裡並沒有這麼簡單。要成功的呼叫 一個函式,資料需要在上下文轉換中跨越Frame傳遞到新建的棧區域中。特別的,對於integer值,在呼叫過程中需要拷貝並傳遞過去,我們可以在18行的increment函式宣告中看到這一點

Listing 5

    18 func increment(inc int) {

如果在再看一下第12行對函式increment的呼叫,可以看到傳遞的正是變數count的值。這個值經過拷貝、傳遞並最終放置到了為increment建立的Frame中。因為函式increment只能直接訪問自己Frame內部的記憶體,所以它用變數inc來接收並存儲和訪問從變數count傳遞過來的值

在函式increment剛剛要開始執行的時候,goroutine的棧結構看起來像下面這個樣子(從一個比較高的語言層次)

Figure 2

可以看到,現在在棧裡有兩個Frame, 一個函式main的和它下的函式increment的。在函式increment的Frame內部,有一個變數inc,它的值是當函式呼叫時從外面拷貝並傳遞過來的10,它的地址是0x10429f98,因為Frame是從上往下佔據棧空間的,所以它的地址比上面的小,不過這只是一個實現細節,並不保證所有實現都這樣。重要的是goroutine把函式main的Frame中變數count的值拷貝並傳遞給了函式increment的Frame中變數inc.

函式increment餘下的程式碼顯示了變數inc的值和地址

Listing 6

    21    inc++
    22    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")

在playground平臺上,第22行的輸出看起來像這樣

Listing 7

    inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]

當執行完了這些程式碼以後,棧結構變成下面這個樣子

Figure 3

執行完第21行和22行後,函式increment返回,控制權重新回到了函式main中,然後main函式再一次顯示了變數count的值和地址

Listing 8

    14    println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")

在playgournd平臺上,程式全部的輸出如下

Listing 9

    count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]
    inc:    Value Of[ 11 ]  Addr Of[ 0x10429f98 ]
    count:  Value Of[ 10 ]  Addr Of[ 0x10429fa4 ]

函式返回

當函式返回,控制權回到呼叫函式後,棧結構發生了什麼變化呢? 答案是什麼也沒有。下面就是當函式increment返回後,棧結構的樣子

Figure 4

除了為函式increment建立的Frame現在變為不可用外,其他和Figure 3 一模一樣。變是因為函式main的Frame現在變成了活動Frame。對為函式incrment建立的記憶體塊沒有做任何處理。

清理已經呼叫完成函式的Frame只是浪費時間,因為你不知道那塊記憶體之後是否會被再次用到。所心相應記憶體就原封不動的留在那裡。只有當發生函式呼叫,這塊記憶體被再次用到時,才會對它進行清理。清理過程是通過拷貝過來的值在這個Frame中的初始化完成的,因為所有的變數至少會被初始化為相應型別的零值,這就保證了發生函式呼叫時,棧空間一定會被合理的處理

值的共享

但是如果我們想在函式increment中直接操作存在於函式main的Frame中的變數count,應該怎麼辦呢?這時候我們就要用到指標了。指標存在在目的就是為了和一個函式共享一個變數,從而讓這個函式可以對這個共享變數進行讀寫,即使這個變數沒有直接放置在這個函式的Frame中

如果當你用指標時,一下子想到的不是”共享“,那就得看看是不是有使用指標的必要了。當我們學習指標的內容時,有一點很重要,就是要用一個明確的單詞而不是操作符或者語法來對待指標。所以請記住,用指標是為了共享,在閱讀程式碼的時候也應該把&操作符當共享來看

指標型別

對每個已經宣告的型別,不管是語言自己定義的還是使用者定義的,都有一個與之對應的指標型別,用它來進行資料共享。比如Go語言中有一個內建的int型別,所以一定有一個與int對應的叫做*int的指標型別。如果你定義了一個叫做User的型別,那麼語言會自動為你生成一個與它對應的叫做*Userr指標型別

所有的指標型別有兩個共同點。一、它們以*開頭。二、它們佔用相同的記憶體大小(4個位元組或者8個位元組)並且表示的是一個地址。在一個32位的系統上(比如playground),一個指標佔用4個位元組,在一個64位的系統上(比如你自己的電腦)佔用8個位元組

規範一點說,指標型別被認為是一個字面型別(type literals),也就是說它是通過對已經有的型別組合而成的

間接記憶體訪問

看下面這段程式,它同樣呼叫 了一個函式,不過這次傳遞的是變數的地址。這樣被呼叫的函式incrment就可以和函式main共享變數count了

Listing 10

package main

func main() {

    // Declare variable of type int with a value of 10.
    count := 10

    // Display the "value of" and "address of" count.
    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")

    // Pass the "address of" count.
    increment(&count)

    println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
}

//go:noinline
func increment(inc *int) {

    // Increment the "value of" count that the "pointer points to". (dereferencing)
    *inc++
    println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
}

同原來的程式比起來,新的程式存在3點不同

Listing 11

    12    increment(&count)

在程式的第12行,並沒有像之前一樣傳遞變數count的值,而傳遞的是變數count的地址。現在我們可以說,我將要和函式increment共享變數count,這就是&操作符想要表達的。

變數的傳遞方式仍然是按值傳遞,唯一不同的是,這次傳遞的是一個integer的地址。地址同樣也是一個值;這就是在函式呼叫時跨越兩個Frame被拷貝和傳遞的東西

鑑於有一個值正在被拷貝和傳遞,在函式inrement中我們就需要一個變數來接收並存儲這個基於地址的integer值,所以我們在程式的第18把引數宣告為了integer指標型別

Listing 12

    18 func increment(inc *int) {

如果你傳遞的是User型別的地址值,這裡宣告的型別就應該換成*User,儘管所有的指標儲存的都是地址值,傳遞和接收的必需是同一個型別才可以,這個是關鍵。我們之所心要共享一個變數,是因為在函式內我們要對那個變數進行讀寫操作,我們只有知道了這個型別的具體資訊後才可以這樣做。編譯器會保證傳遞的是同一個指標型別的值。

下面是呼叫了函式increment後,棧結構的樣子

Figure 5

在Figure 5中我們可以看到,當把一個值地址按值進行傳遞後,棧結構會變成什麼樣子。函式increment的Frame中的指標變數inc現在指向了存在於函式main的Frame中的變數count

通過這個指標變數,函式就可以以間接方式讀寫存在於函式main的Frame中的變數count了

Listing 13

    21    *inc++

這個時候,*被用作一個操作符和指標變數一起使用,把*用作操作符,意思是說要取獲取指標變數所指向的內容,在這裡也就是main函式中的count變數。指標變數允許在使用它的Frame中間接訪問此Frame之外的記憶體空間。有時候我們把這種間接訪問叫做指標的解引用。在函式incremen中仍然需要一個可以直接訪問的本地指標變數來執行間接訪問,在這裡就是變數inc.

現在是執行了第21行後,棧結構的樣子

Figure 6

下面是程式的全部輸出

Listing 14

    count:  Value Of[ 10 ]              Addr Of[ 0x10429fa4 ]
    inc:    Value Of[ 0x10429fa4 ]      Addr Of[ 0x10429f98 ]   Value Points To[ 11 ]
    count:  Value Of[ 11 ]              Addr Of[ 0x10429fa4 ]

可以看到,變數inc的值正是變數count的地址值,就是這一個關聯才使得訪問本Frame外的記憶體成為可能。一旦函式increment通過指標變數執行了寫操作,當控制返回到函式main後,修改就會反應到對應的共享變數中。

指標型變數並不特別

指標型別和其它型別一樣,一點也不特殊。它們有一塊分配的記憶體並存放了一個值,拋開它指標的型別,指標型別總是佔用同樣的大小和相同的表示。唯一可能讓我們感覺困惑的是字元*,在函式increment內部,它被用作操作符,在函式宣告時用來宣告指標變數。如果你可以分清指標宣告時和指標的解引用操作時的區別,應該就沒那麼困惑了

總結

這篇文章討論了設計指標背後的目的,以及在Go語言中棧和指標是怎樣工作的。這是理解go語言的語言結構,設計哲學的第一步,也對寫出一致的、可讀性好的程式碼有一定指導作用。

來總結一下我們學到了什麼

1、函式執行在給函式分配的Frame boundaries中。它提供了函式可訪問的實體記憶體空間

2、當呼叫函式時,上下文環境會在兩個Frame中切換

3、按值傳遞的優點是可讀性好

4、棧是非常重要的,因為它為分配給每個函式的Frame boundaries提供了可訪問的實體記憶體空間

5、在活動Frame以下的棧空間是不可用的,只能活動Frame和之上的棧空間是可用的

6、函式呼叫意味著goroutine需要在棧上為函式建立一個新的區塊

7、只有當發生函式呼叫 ,棧區塊被分配的Frame佔用後,相應棧空間才會被初始化

8、指標是為了和被呼叫函式共享變數,使被呼叫函式可以間接訪問自己Frame之外的變數

9、每一個型別,不管是語言內建的還是使用者定義的,都有一個與之對應的指標型別

10、使用指標變數的函式,可以通過它間接訪問函式Frame外的記憶體

11、指標變數和其它變數一樣,並不特殊,同樣是有一塊記憶體,在其中存放數值而已

本文由 GCTT 原創編譯,Go 中文網 榮譽推出