理解兩種變數模型和三種傳參模式

bitlogic發表於2021-07-03

在學習 Python 過程中,關於引用式變數與物件之間的關係,以及 Python 社群中習慣於被稱呼為共享傳參(Call by sharing)的傳參方式,感覺上和之前接觸的 C/C++ 不同,但具體的本質區別是什麼呢?本文主要總結了從 C → C++ → Python 學習過程中,如何理解變數和值之間的關係以及相應的傳參模式,最後簡要補充了函式返回相關的疑惑。

1. 變數是盒子

在 C 語言中,什麼是資料型別呢?資料型別可以理解為固定記憶體大小的別名,資料型別就是建立變數的模子。int a;實際上通過定義變數a申請了一段連續儲存的空間,並命名為a,後續通過變數的名字a便可以使用該儲存空間。所以在 C 語言中,不同的變數就是不同的盒子,用來儲存各自的資料。定義指標也是普通的變數,只不過這個盒子中儲存的是地址資料。

#include <stdio.h>

int main(void)
{
    int a = 10;
    int b = a;
    // 變數 a 和 變數 b 的地址不同
    printf("addr[a]=0x%p, addr[b]=0x%p\n", &a, &b);
    return 0;
}

在 C 語言中,每定義一個變數名都建立一個不同的盒子,這個盒子具有固定的記憶體地址;而所謂的資料型別,就是固定記憶體大小的別名,也就是用來決定盒子的大小。

在 C 語言中,函式實參的傳參方式只有一種,即按值傳遞(Call by value),也就是說實際引數會被求值,然後將其值繫結到函式中對應的變數上(通常是把值複製到新記憶體區域),即傳遞的是值,而非變數本身;同理,返回值也是按值傳遞(Call by value)的,即return x;返回的是變數x的值,而非變數本身,因為變數x馬上就要被釋放了。

2. 不是所有變數都是盒子

在 C++ 中,除了和 C 語言相同的部分外,新增加了引用的概念。C++ 中的引用只能在定義時被初始化一次,之後不可變。實際上,C++ 中的引用的內部實現是一個常指標,即Type &name = varType *const name = &var,比如int &A = a;int *const A = &a;;也就是說,引用一個變數,就相當於指向這個變數的指標,只不過這個指標本身不可變,而指向的資料可變,即可以通過解引用*A來改變變數a的值;而在 C++ 中int &A = a;,使用時可以直接操作A(無需解引用),就可以修改變數aA就像變數a的別名一樣。即變數A是對變數a的引用(別名),雖然 C++ 編譯器對Aa的內部實現方式不同,但從使用的角度,Aa之間沒有任何語義上的區別,可以應用於他們的操作完全一樣,得到的結果也完全一樣;同時,對Aa的任何修改都可以從對方看到。所以從使用的角度而言,C++ 中定義的變數,並不都是建立了盒子,引用作為一個已定義變數的別名而存在。

#include <stdio.h>

int main(void)
{
    int a = 10;
    int &b = a;
    // 變數 a 和 變數 b 的地址相同
    printf("addr[a]=0x%p, addr[b]=0x%p\n", &a, &b);
    // 對於 a 或 b 的任何修改都可以從對方看到
    b = 20;
    printf("a=%d, b=%d\n", a, b); // a=20, b=20
    return 0;
}

在 C++ 中,存在基本型別的變數(值變數),可以看成不同的盒子;也存在引用變數,通過在名字前面放一個&符號來區別,如int &b = a;b就是一個引用變數,可以看成變數a的別名,ba可以看成是同一個盒子。

因此,在 C++ 中,函式的傳參方式組合了傳值呼叫(Call by value)和傳引用呼叫(Call by reference)。在傳引用呼叫(Call by reference)求值中,傳遞給函式的是實參的引用而不是實參的值拷貝,通常函式能夠修改這些引數(比如賦值),而且該改變對於呼叫者而言是可見的,即傳引用呼叫(Call by reference)提供了一種呼叫者和函式交換資料的方法。如下示例,實現交換兩個變數的值:

#include <stdio.h>

void swap_1(int *a, int *b)
{
    int t = *a;
    *a = *b;
    *b = t;
}

void swap_2(int &a, int &b)
{
    int t = a;
    a = b;
    b = t;
}

int main(void)
{
    int a = 10;
    int b = 20;
    printf("a=%d, b=%d\n", a, b);

    swap_1(&a, &b);
    printf("a=%d, b=%d\n", a, b);

    swap_2(a, b);
    printf("a=%d, b=%d\n", a, b);

    return 0;
}

也就是說,雖然 C 語言缺少引用引數,但總可以通過指標來修改變數。即在 C 語言中,若要修改變數,實參可以傳入變數的地址值,此時要求形參必須是指標,且在使用時必須顯式的做間接操作。C++ 引入了一種顯式的引用記法,即通過&符號,形參可以被描述為引用引數,如上述程式碼void swap(int &a, int &b) { int t = a; a = b; b = t; },子程式swap程式碼中的abint,而不是指向int的指標,不需要對他們做間接操作,而且在呼叫時,也只需將需要交換值的變數名字傳入即可,而不再需要傳入他們的地址。

注意:在 C++ 中,宣告引用變數需要顯式的在名字前面放一個&符號,且引用只能在定義時被初始化一次,之後不可變,所以其僅僅作為一個已定義變數的別名而存在,而實際很少有理由需要在直接程式碼中建立別名,因此,C++ 中引用的主要用途是修飾函式的形參和返回值,使用引用既具有指標的效率,又具有變數使用的方便性和直觀性。

補充:Java 對內部型別使用值模型,對使用者定義的型別(類)使用引用模型,其引用式變數與 C++ 中的引用用法不同,比如不需要特殊的語法來顯式定義為引用變數,且定義後可變(之後允許重新賦值);另外,C# 和 Eiffel 允許程式設計師為每個使用者定義的型別選擇使用值模型或者引用模型;C# 中的class使用引用模型,而struct使用值模型。【並不瞭解 Java、C# 及 Eiffel,在這裡註記一下方便後續查閱】

3. 變數不是盒子

在 C/C++ 中,int a = 10;int b = a;,這裡ab是兩個不同的變數,有不同的記憶體地址,只不過儲存的資料值相同而已;而在 Python 中,若有a = 10b = a,則b is a結果為True。也就是說 Python 不是使用變數的值模型的語言,而是使用變數的引用模型的語言,即 Python 中的變數本身已經是物件的引用。因此,“變數是盒子”這樣的比喻,將有礙於理解類似 Python 這種面嚮物件語言中的引用式變數。在 Python 中,最好把變數理解為附加在物件上的標註(便利貼),而不是盒子,而且可以為同一物件貼上多個標註(便利貼),而所貼的多個標註,就是別名。如下示例(圖片來源於《流暢的 Python》),變數b和變數a引用同一個列表物件,b並不是列表a的副本:

實際上,在處理不可變的物件時,變數儲存的是真正的物件(盒子)還是共享物件的引用(便利貼)無關緊要,關於 Python 可變性的相關內容不在本文討論範圍之內。

在 Python 中,由於變數本身儲存的都是引用,這一點對程式設計時有很多實際的影響:

  • 變數之間的簡單賦值不會建立副本(如b = a,不會建立a的副本)。
  • +=*=所做的增量賦值來說,如果左邊的變數繫結的是不可變物件,會建立新物件;如果是可變物件,會就地修改。
  • 為現有的變數賦予新值(賦值語句),不會修改之前繫結的變數,這叫重新繫結:現在變數繫結了其他物件。如果變數是之前那個物件的最後一個引用,則之前那個物件會被當作垃圾回收。
  • 函式的引數以別名的形式傳遞,這意味著,函式可能會修改通過引數傳入的可變物件。這一行為無法避免,除非在本地(函式內)建立副本,或者使用不可變物件(例如,傳入元組,而不傳入列表)。

關於傳參方式,既然變數本身儲存的是物件的引用,那麼最自然的做法就是傳遞引用本身,並讓實參和形參引用同一個物件。在 Python 中,唯一支援的引數傳遞模式是共享傳參(Call by sharing),即函式的各個形式引數獲得實參中各個引用的副本;也就是說,函式得到引數的副本,但是引數始終是引用。因此,如果引數引用的是可變物件,那麼物件可能會被修改,但是物件的標識不變。【注:因為函式得到的是引數引用的副本,所以重新繫結(賦予新值)對函式外部沒有影響。】有如下示例:

def f(l):
    l.append(10)
    l = [20]

m = []
f(m)
print(m)

變數m是列表[]的引用,呼叫f(m),引用的副本傳給形參l,現在函式內的區域性變數l和函式外部的變數m引用了同一個列表物件[],又因為列表是可變物件,所以append方法修改了物件,而l = [20]為區域性變數l賦值(賦值是給變數繫結一個新物件,而不是改變物件),現在區域性變數l繫結了新的物件(即重新繫結),這將對函式外的作用域沒有影響,因此,最後列印m,會輸出[10]而不是[20]

在解釋 Python 中引數傳遞的方式時,人們經常這樣說:“引數按值傳遞,但是這裡的值是引用”。這麼說沒錯,但是會引起誤解,因為在舊式語言中,最常用的引數傳遞模式有按值傳遞(Call by value)和按引用傳遞(Call by reference):

  • 而共享傳參(Call by sharing)與按值傳遞(Call by value)的不同之處在於,雖然我們確實將實參複製到形參中,但實參和形參都是引用的,因此如果我們在函式內修改了引數引用的物件(如果是可變的話),那麼將可以通過實參看到這些修改【當然,對於不可變物件,實際上共享傳參(Call by sharing)和按值傳遞(Call by value)之間並沒有真正的區別】。
  • 此外,共享傳參(Call by sharing)還與按引用傳遞(Call by reference)不同,因為函式並不會訪問變數本身(例如為變數賦值只是重新繫結),而只是訪問具體的共享物件(引數所引用的物件)【The semantics of call by sharing differ from call by reference because access is not given to the variables of the caller, but merely to certain objects】,如下示例,分別是 C++ 和 Python 程式碼,用來對比Call by sharingCall by reference的不同:
#include <stdio.h>

void f(int &x)
{
    x = 20;
}

int main(void)
{
    int a = 10;
    f(a);
    printf("a=%d\n", a);
    return 0;
}
def f(x):
    x = [20]

a = [10]
f(a)
print(a)

我們嘗試在函式內部為形參變數賦值:C++ 列印結果a=20;而 Python 列印結果為[10]。這是因為:按引用傳遞(Call by reference)的本質其實就是函式得到引數的指標,從而訪問變數本身;而共享傳參(Call by sharing)函式得到的是引數引用的副本,從而使形參和實參引用同一個共享物件,所以函式內的賦值對外部並無影響,而只是將形式引數繫結到一個新物件(參考上文),也就是說,傳遞變數僅意味著傳遞變數所引用的實際物件,雖然函式內可以修改此共享物件(如果是可變的話),但並不能訪問傳遞的原始變數本身。

4. 對比函式返回

既然 Python 變數儲存的是引用,那麼函式內區域性變數新繫結的物件為什麼可以作為返回值被返回而不像 C/C++ 一樣會變成“野指標”呢?這是因為記憶體管理的方式不同:Python 中的物件絕不會自行銷燬(會由垃圾回收器進行回收);而 C/C++ 中不同的變數(如棧變數、靜態變數等)有著不同的生命週期。

在 C/C++ 中,需要我們手動進行記憶體管理,有棧變數、堆變數、靜態變數、全域性變數、生命週期等等概念;而在 Python 等更高階的語言中,一般會有垃圾自動回收程式。例如 Python 中的物件絕不會自行銷燬,僅當無法得到物件時,可能會被當作垃圾回收。在 CPython 的實現中,垃圾回收使用的主要演算法是引用計數,每個物件都會統計有多少引用指向自己,當引用計數歸零時,物件立即就被銷燬。我們現在考慮函式內區域性變數的返回,這裡僅作為對比不進行詳細說明,分別舉例程式碼如下所示:

  • 在 C 語言中,若函式返回棧變數的指標,函式返回後將變成“野指標”,導致程式崩潰
#include <stdio.h>

char *func()
{
    char p[] = "Hello World !";
    return p; // Warning
}

int main(void)
{
    char *s = func();
    printf("%s\n", s); // OOPS !
    return 0;
}
  • 同理,在 C++ 中,根據上文介紹的 C++ 中引用的底層實現方式可知,若函式返回值為引用,當返回棧變數時,則不能作為左值使用,也不能成為其他引用的初始值,同樣地,也可能會導致程式崩潰。
#include <stdio.h>

int &func()
{
    int p = 0;
    return p; // Warning
}

int main(void)
{
    int a = func(); // OOPS !

    int &b = func();
    printf("b = %d\n", b); // OOPS !

    return 0;
}
  • 在 Python 中,使用變數的引用模型,有基於引用計數的垃圾回收器來進行記憶體管理,可以在Python Tutor中執行如下程式碼,注意觀察FramesObjects的變化:
def func():
    l = [3, 2, 1]
    return l

func()
x = func()
x.append(0)
print(x)

參考連結:

  • 視覺化程式碼執行過程的工具
    • Python Tutor(目前支援:Python, Java, C, C++, JavaScript, and Ruby)
  • 變數的值模型 & 變數的引用模型
    • 《程式設計語言——實踐之路(第3版)》 6.1.2 賦值 → §引用和值
    • 《程式設計語言——實踐之路(第3版)》 7.7.1 語法和操作 → §引用模型、§值模型
  • 傳參方式:按值傳遞(Call by value)、按引用傳遞(Call by reference)、共享傳參(Call by sharing)
  • 關於 C/C++ 中的指標解引用(Dereference
  • 關於 C++ 中的引用和指標的區別與聯絡
    • 《高質量程式設計指南—— C++/C 語言(第3版)》 7.5 引用和指標的比較
  • 文中關於 Python 的部分

相關文章