淺談 C 語言和 Java 的記憶體管理(五)

<< 上一篇

C 語言程式設計期中考--整數交換

話說如果你曾經在大學修過 C 語言相關課程的話,應該有很大的機會在課堂上或期中考中,看到下面這個考題:

void swap(int x, int y)
{
    int tmp = x;
    x = y;
    y = tmp;
}

int main()
{
    int x = 3;
    int y = 5;

    swap(x, y);

    // 請問下面這一行程式碼會印出什麼
    printf("x = %d, y = %d\n", x, y);
}

當你把這個問題拿去給任何一個稍具經驗的 C Programmer 的話,他會立刻回答你印出來的一定是:

x = 3, y = 5

而且這個 Programmer 甚至可以不用去看你的 swap 函式裡寫了什麼,就告訴你答案一定是這樣。

為什麼?我們不是在 swap 裡面把 x 和 y 的變數交換了嗎?為什麼 main() 裡印出來的卻還是原來的 3 和 5 呢?

C 語言函式參數的存放位置與 Call By Value

如你問那個 C Programmer 為什麼會是這樣,他可能會回答你「因為傳進去函式的參數與 main 裡面的變數無關」……但這句話到底是什麼意思呢?

要回答這個問題,仍然要回到一切的原點--你傳進去的函式的參數到底存在記憶體的哪裡,而你的每一行 C 語言敘述,操作的又是記憶體的哪裡。

針對第一點,在 C 語言和 Java 裡面,你函式的參數,都被放在你呼叫函式時被配置給那個函式的 Stack 空間上,還記得區域變數與是被放在 Stack 上嗎?沒錯--在 C 語言裡面,函式的參數和區域變數其實是相同的東西,所以你的函式參數也會在函式執行完之後消滅。

有了上面的認知,我們現在知道當我們在 main() 裡呼叫 swap() 時,我們的 Call Stack 應該會長得像下面一樣:

Stack Status

現在問題來了,當我們在 main() 裡面呼叫 swap(x, y); 的時候,一開始到底發生了什麼事,Stack 上屬於 swap() 函式的 x 和 y 又會是什麼呢?

答案很簡單--C 語言會把你丟進去給參數的「值」複製一份到現在 Stack 上新的 x 和 y--這種在函式呼叫的時候把傳進去的值複製一份的做法,就叫做 Call by value

C 語言裡面所有的參數傳遞都是 Call by value

接下來,既然當你在 main() 裡呼叫 swap(x, y) 的那個時間點,x 和 y 分別是 3 和 5,所以現在你的 Stack 會長得像下面一樣:

Stack Status

接下來要注意的是,你在 C 語言函式裡存取的參數,都是 Stack 上屬於你的函式區塊的那一個

有了這樣的認知,我們就知道接下來所有的動作,都不會影響到 Stack 上屬於 main() 函式的記憶體區塊了。

接下來就一步一步來看記憶體中的變化吧!當我們執行到 int tmp = x; 的時候,會把 swap() 上面的 x 存放的值複製一份到 tmp 當中:

Stack Status

然後 x = y 時,會把 swap()y 的值複製到 swap() 上的 x 當中:

Stack Status

之後的 y = tmp 則會把 swap() 上的 tmp 的值複製到 swap() 上的 y 當中:

Stack Status

最後,當 swap() 函式返回之後,Call Stack 上配置給 swap() 函式的空間就被消滅了,所以現在 Call Stack 上只剩下 main() 函式裡的 xy 變數了,而且他們從頭到尾都沒被動過

Stack Status

現在,你應該知道為什麼這樣的 swap() 函式並不能做兩個數字的交換了吧?因為你改到的,都是在 Stack 上屬於 swap() 的變數,而不是 main() 裡面的變數。

如果我就是要更動原來的變數呢?

但在有的情況之下,我們想要的結果就是 main() 裡的 xy 兩個東西被交換,這個時候應該要怎麼辦呢?

嗯……如果我們可以想辦法直接操作 main() 裡的 xy 這兩塊記憶體好像就可以做到耶……

沒錯!這個時候就是 Pointer 派上用場的時候了!

還記得我們之前講過,Pointer 可以存記憶體的地址,然後我們可以利用 Pointer 來操作被指到的記憶體的內容嗎?如果我們能想辦法找到 main() 裡的 xy 的記憶體地址,那就可以在函式裡直接操作他!

有了這樣的觀念之後,我們就可以把我們的 swap 函式改掉,改成用 Pointer 來寫--

#include <stdio.h>

void swap(int * x, int * y)
{
    int tmp = *x;
    *x = *y;
    *y = tmp;
}

int main()
{
    int x = 3;
    int y = 5;

    swap(&x, &y);

    // 請問下面這一行會印出什麼
    printf("x = %d, y = %d\n", x, y);
}

這邊你可能會問,現在 swap() 的參數是 Pointer 耶,那他到底算什麼?答案是--毫無反應,就是個存放記憶體位址的空間。

所以現在你呼叫 swap() 的時候,你的 Call Stack 長得還是像下面一樣:

Stack Status

現在我們再來看看呼叫 swap(&x, &y); 這行時,到底發生了什麼事。

首先我們已經知道 C 語言的函式參數都是 Call By Value,所以我們要先知道我們到底傳了什麼進去,而 &x&y 又是什麼東西。

這個問題的答案也很簡單,當你在 C 語言的變數前加上 & 號時,代表你要取得那個變數的「記憶體地址」,也就是說,現在我們傳進去給 swap() 的,是 main() 裡的 xy 的記憶體位置,假設他們分別位於 0x00010 和 0x00011 的位置的話,那就是傳入 0x00010 和 0x00011。

所以現在我們的記憶體長得像下面這樣,由於 swap() 裡的 x 和 y 是 Pointer,所以我們順便幫他們畫條箭頭到他們所指到的記憶體區塊:

Stack Status

接著因為執行到 int tmp = *x,因為我們已經知道在一個 Pointer 變數前加上 * 號是指尋著 Pointer 找到他所指的那塊記憶體的「值」,所以現在 tmp 會被改成 3:

Stack Status

下一步的 *x = *y 這邊,則會把 x 這個 Pointer 指到的記憶體的內容,改成 y 這個 Pointer 指到的記憶體位置的內容:

Stack Status

最後的 *y = tmp 則會把 y 這個 Pointer 指到的記憶體內容,改成 swap() 上面的 tmp 的值,所以變成這樣:

Stack Status

最後 swap() 返回,所以 Stack 上屬於 swap() 函式的所有東西都被清空:

Stack Status

瞧!現在我們 main() 函式裡的 xy 真的被交換了耶!所以如果執行我們新版的程式的話,印出來的就會是符合我們預期的:

x = 5, y = 3

C 語言當中的陣列

基本的陣列宣告與配置

講到了這邊,我們終於可以開始討論另一個有趣的問題了--C 語言的陣列到底是什麼,當我們把他傳進一個函式的時候,又會發生什麼事呢?

舉例而言,我們可能很習慣寫出像下面的 C 語言程式碼來操作一個整數陣列:

int main()
{
    int x[3] = {1, 2, 3};

    printf("x[1] is %d\n", x[1]);
    x[1] = 10;
    printf("x[1] is %d\n", x[1]);
}

也知道最後的結果會是:

x[1] is 2
x[1] is 10

但問題來了--這個 x 在記憶體裡到底是什麼東西呢?這個問題如果你去翻教科書,標準的答案就是「一塊可以存下 3 個整數的連續記憶體空間」,同樣的,既然這個陣列是被宣告在 main() 函式裡,那他就會存放在 Stack 上配置給 main() 的空間。

所以一開始我們的記憶體長這樣,印出 x[1] 的時候會是 2:

Stack Status

接著 x[1] = 10 會把 10 放到陣列的第二個位置,所以印出來會是 x[1] is 10,而現在 Stack 上則長得像下面一樣:

Stack Status

以上就是我們習慣的陣列的使用方法,但實際上 C 語言對於型態是陣列的變數的處理,有一些需要注意的地方,下面我們就來探討這個問題。

當你寫下 x 的時候到底是啥意思

不知道你有沒有不小心寫過像下面這樣的程式,本來是要取得陣列裡面某一個元素的值,但卻忘記加上陣列的索引了呢?

#include <stdio.h>

int main()
{
    int x[3] = {1, 2, 3};
    int y = x;  // 本來是想寫 int y = x[0] 的

    printf("y = %d\n", y);
}

這個時候如果你是在 Linux 下 GCC 去編譯他的話,會跑出奇怪的警告,而如果你執行他的話,則會跑出一個奇怪的數字:

brianhsu@USBGentoo ~ $ gcc test2.c
test2.c: In function 「main」:
test2.c:6:13: 警告:初始化將指標賦給整數,未作類型轉換

brianhsu@USBGentoo ~ $ ./a.out
y = 1754525472

這是為什麼呢?關鍵就在於 GCC 給我們的警告「初始化將指標賦給整數,未作類型轉換」這句話--看到「指標 (Pointer)」這個關鍵字了嗎?

咦?!我們的程式裡沒有用到指標啊!為什麼會出現和指標相關的警告呢?

這是因為,在 C 語言當中,當你宣告這個 x 變數是個陣列後,以後每當你在程式碼裡寫 x 的時候,事實上他會被視為(精確的說法是 evaluate)成「指向陣列開頭的記憶體地址」……而記憶體地址換句話說,就是 Pointer。

而當你寫 x[1] 的時候,實際上指的就是「取得從陣列開頭的記憶體位置再加上一個整數位移後的地址裡面的值」,也就是說,如果你現在有一個指到陣列開頭的指標 p,那麼 *(p+1)x[1] 實際上是等價的東西。

由於上面的特性,你可以在 C 語言裡面做一些很好玩的事情,例如下面這樣:

#include <stdio.h>

int main()
{
    int x[3] = {1, 2, 3};  // 宣告一個陣列
    int * p = x;           // 將一個 Pointer 指到陣列開頭

    // 用 Pointer 的方式使用陣列
    printf("x[0] = %d \n", *(x+0));
    printf("x[1] = %d \n", *(x+1));
    printf("x[2] = %d \n", *(x+2));

    // 用陣列的方式使用 Pointer
    printf("p[0] = %d \n", p[0]);
    printf("p[1] = %d \n", p[1]);
    printf("p[2] = %d \n", p[2]);
}

將陣列當作參數

上面我們已經看過 C 語言陣列的特性了,現在就來看一下,如果你把 C 語言的陣列傳到一個函式裡的時候,會發生什麼事。

這次我們的程式碼如下:

#include <stdio.h>

void addByOne(int p [])
{
    p[0] = p[0]+1;
    p[1] = p[1]+1;
    p[2] = p[2]+1;
}

int main()
{
    int x[3] = {1, 2, 3};  // 宣告一個陣列

    addByOne(x);

    printf("x[0] = %d \n", x[0]);
    printf("x[1] = %d \n", x[1]);
    printf("x[2] = %d \n", x[2]);
}

現在我們已經知道 main() 裡的 x 陣列是被放在 Stack 上屬於 main() 的記憶體裡的一塊連續的空間了,所以剩下來的就是……p 是存在哪裡,存的又是些什麼東西呢?

還記得我們強調過 C 語言只有 Call By Value 嗎?所以事實上 p 和其他所有函式的參數一樣,被存在 addByOne 這個函式被分配到的 Stack 空間上,所以當我們呼叫 addByOne 的時候,記憶體會長得像這樣子:

Stack Status

現在問題來了,addByOne 裡的 p 存的到底是啥呢?我們在呼叫這個函數時寫的是 addByOne(x) 然後 x 是一個陣列,我們又知道單寫 x 時會被視為記憶體位址……沒錯!就是 x 陣列開頭的記憶體位址!

假設現在 x 是位在 0x00010 這個位置的話,那 p 裡面就會存 0x00010 這個數值,而我們知道存記憶體位置的東西本質上就是 Pointer,所以可以幫他畫條線,變成像下面這樣:

Stack Status

接著進到 addByOne 函式裡面,我們已經知道 p[0] 實際上的翻譯就是 *(p+0),所以是更改 p 所指到的記憶體位置的內容,所以會變成這樣:

Stack Status

依此類推執行 p[1] = p[1] + 1p[2] = p[2] + 1 時的記憶體狀態會像下面一樣:

Stack Status
Stack Status

最後 addByOne() 函式返回後,Stack 上關於 addByOne() 的東西就會消滅掉,而我們在 main() 裡印出的陣列的值的時候,就會發現原來放在 main() 裡的陣列內容被改變囉。

Stack Status

小結

這次我們看了在 C 語言裡面,傳進函式的參數到底是放在哪裡。

同時我們也知道了 C 語言在做函式呼叫的時候,參數一律是用 Call By Value 的方法,將傳入的東西的值複製一份到自己的 Stack 上。

最後,我們還討論到了 C 語言裡的陣列在記憶體中是如何表示的,以及在函式呼叫時他所具有的特性。

然而這些東西,在 Java 裡又會是如何呢?這就是我們下一次的主題。

下一篇 >>

回響