來寫一下很久沒有更新的 C 語言系列好了。
在先前本系列的九篇文章裡面,我們講了一些 C 語言的基礎,包括資料型態、條件分歧、函數、陣列、迴圈等等的主題。一般而言,只要掌握了這些觀念,大部分的簡單任務都可以完成。然而,光是會了這些還不能說自己已經學會 C 語言!今天就來講講 C 語言裡最重要的精髓——指標。
在這之前,我們先來複習一些變數的觀念吧。
。變數的地址
我們知道,在程式裡宣告的每一個變數,都各自佔用了一點點電腦的記憶體空間(不然是要存去哪裡?)既然使用到了記憶體空間,那麼為了不把這堆資料搞混,每個變數都有自己專屬的地址,就好像我們在使用置物櫃的時候要記得自己用了編號幾號的櫃子,如此一來要取用的時候才不會誤開其他人的置物櫃。
那麼,要怎麼知道變數的編號呢?其實很簡單,我們只要在變數前面加上一個「&」符號,就可以得到它的地址了。我們宣告一個變數 x,隨意地給它賦值為 1564545789:
透過把 &x 輸出,可以看到它的地址以十六進制表示是 0x0065FE1C,存放了 5D4112FD(也就是十進位的 1564545789 這個數)。
(%08X 的意思:以八位數的大寫十六進位制來表示數值,未達位數補 0)
如果記憶體是一個超大的置物櫃,每一格都是一個 byte(一個 byte 可以放得下十六進位的 2 個位數,或是二進位的 8 個位數),你可以這樣理解它:
可以想像成在記憶體的第 0x0065FE1C 格的位置開始,存放了變數 x 的內容。一般而言,在 32 位元或 64 位元的電腦,一個 int 佔用 4 bytes,因此記憶體位址 0x0065FE1C、0x0065FE1D、0x0065FE1E、0x0065FE1F 這四個 bytes,都屬於變數 x。
不過實際情況可能還會更複雜,有些系統是把這四個 byte 倒過來存放的,這個因使用者的 CPU 設計而異:
上面兩例中正序放置的方法,稱為 Big-Endian;反序放置的方法,則稱為 Little-Endian。總之,你只要至少能知道在這個例子裡我們可以看到變數 x 的值被存放在 0x0065FE1C 開始的四個 bytes 裡面就好了。
當然,如果你不求甚解,只要知道求取 x 的時候指的是 x 變數裡存放的值、求取 &x 的時候指的是 x 在記憶體當中的地址,這樣就已經很足夠。
。傳值(Pass by value)
在過去談過的函數用法,一般來說我們都是直接把一個數值傳進函數裡,讓它能夠運算。比如說,我們定義了攝氏溫度轉華氏溫度的函數,然後在 main 裡面呼叫它:
float CtoF(float C) {
return C*9/5+32;
}
int main(void) {
float m = 30;
float n = CtoF(m);
printf("攝氏 %.2f 度等於華氏 %.2f 度\n", m, n);
return 0;
}
雖然我們看來是把 m 丟進函式裡,實際上傳進去的東西只有「存放在 m 變數裡的 30」這個數值。那麼,接下來「30」這個數值被傳到函數裡面,電腦把它算完以後把結果傳回來。無論你在 CtoF 函式裡面如何去修改變數的值,這個外面的變數 m 的值依舊是 30,不會有任何改變。
你可以想像,上例的「CtoF」這個函式只有收到「30」,它並不知道 m 這個變數的地址在哪裡,因此它能做的事情就有限——比如說,我們想要利用函式來「修改」一個原有的變數的值,那麼單憑這樣的寫法就做不到了。
像這種平時最常見的用法,我們稱之為 pass by value(以值傳遞)。
。指標的宣告
有了上面兩章節的觀念之後,我們就可以準備實作「以地址來操控變數」這件事了。
在 C 語言裡面,我們知道 int 用來存放整數、float 用來存放浮點數、char 用來存放字元……不過,如果我們想要用來存放「地址」的話,該用什麼樣的變數呢?我們這麼做:
int main(void) {
int x = 48763;
int *p = &x;
printf("The address of x: %08X\n", p);
}
沒錯,用來存放 int 類型變數地址的變數,在宣告的時候只要在名稱前面加上一個「*」,用來表示它是專門存放 int 變數的地址,然後把 x 的地址放進去就可以了。上面這段相當於:
int main(void) {
int x = 48763;
int *p;
p = &x;
printf("The address of x: %08X\n", p);
}
到目前為止,這裡都跟一般的變數一樣,僅僅是差在宣告的時候前面多了一個 * 字號而已。只要呼叫 p 就可以得到它裡面存放的地址編號,也同樣只要直接用 p = &x; 就可以把 x 的地址存進 p 這個變數。
既然 p 本身用來儲存別的地址,那麼它當然也會佔用空間。我們使用 &p,同樣也可以找到它的地址:
那也就說明了記憶體當中的狀態是這樣子的:
由於這樣一個存放地址的變數,它存在的意義就是用來指向一個變數,所以這種變數就稱為「指標(pointer)」。
。操作指標指向的變數內容
不過,單就上面這一段還僅僅是前置工作,畢竟你只是把變數的地址存進去而已。
要想控制指標 p 所指向的變數 x,我們一樣使用「*」符號就可以了,只是這個符號用在這裡的時候,它的意義和宣告指標的時候是不同的:
在 p 存放了 &x 的情況下,我們對「*p」的任何存取,都相當於是對「x」的操作。所以我們如果執行「*p = 5;」,那這句話的意思就相當於「x = 5;」了。
在同一個函式裡直接這樣做的話當然不太有意義,一般來說,指標都會用在有函數傳入、傳出的情況。直接舉個實例吧!有的時候,我們會需要把兩個變數的值交換(例如排序演算法),假設現在有一個 x 變數和一個 y 變數,我們希望把它們的值互換,我們可以這麼做:
int main(void) {
int x = 5, y = 3;
printf("x: %d, y: %d\n", x, y);
int temp;
temp = x;
x = y;
y = temp;
int temp;
temp = x;
x = y;
y = temp;
printf("x: %d, y: %d\n", x, y);
}
不過,要是我們需要做很多次這樣的事,這種寫法實在太麻煩了。有了指標,我們可以直接讓函數對兩個地址的內容進行操作:
void swap(int*, int*);
int main(void) {
int x=5, y=3;
printf("x: %d, y: %d\n", x, y);
swap(&x, &y);
printf("x: %d, y: %d\n", x, y);
}
void swap(int *x, int *y) {
int temp;
temp = *x;
*x = *y;
*y = temp;
}
}
不過,要是我們需要做很多次這樣的事,這種寫法實在太麻煩了。有了指標,我們可以直接讓函數對兩個地址的內容進行操作:
void swap(int*, int*);
int main(void) {
int x=5, y=3;
printf("x: %d, y: %d\n", x, y);
swap(&x, &y);
printf("x: %d, y: %d\n", x, y);
}
void swap(int *x, int *y) {
int temp;
temp = *x;
*x = *y;
*y = temp;
}
我們可以看到,實際上我們是把 x 和 y 的地址直接傳進去,然後在函數裡操作它們。要是這種場合下沒有使用指標的功能,那我們是沒辦法把這段 swap 行為寫成函式的(當然如果你硬要用 define 的方式來達成也不是不行啦)。
你還可以利用陣列(array)一次宣告很多個指標:
其他的操作方法都一樣。
像這種把地址傳進函式裡,讓函數能夠參照地址來操作變數,我們可以說它是一種 pass by reference 的實作。不過,這個名詞其實在 C 語言裡還是有點爭論,有人認為既然傳入的是一個地址(address),那就該稱為 pass by address,而 C 語言的發明人則是說 C 語言本身只有 pass by value(只是傳入的是一個 address),大家的講法都不太一樣,所以不要太糾結哪個正確,總之我是都叫它 IKEA 啦。
。指標的指標
是的,既然指標本身是一個變數,那麼「指標的指標」這種東西也是存在的。我們可以設一個用來儲存指標地址的指標(即雙重指標):
既然如此,當然也就可以設指標的指標的指標:
1. 可以看到 ppp 的地址 0x0065FE00 存了 pp 的地址:00 65 FE 08
2. 可以看到 pp 的地址 0x0065FE08 存了 p 的地址:00 65 FE 10
3. 可以看到 p 的地址 0x0065FE10 存了 x 的地址:00 65 FE 1C
4. 可以看到 x 的地址 0x0065FE1C 存了整數 5:00 00 00 05
看起來很拗口,其實指標就跟一般的變數沒有太大的差別,只是使用的時候要稍微想一下自己在寫什麼,不要指到最後不知道自己在幹嘛。
指標的應用當然不是只有這篇講的這樣,畢竟它能做的事情太多了,包括動態記憶體分配、多維陣列、連結串列,單單用一篇文章是絕對不可能講完的。我手上的旗標出版社的 C 語言教科書《C 語言教學手冊》光是講指標就花了整整 60 頁的篇幅才講完基礎(這還不包括應用)。
那麼,對於指標的基本認識,差不多就講到這裡。指標的應用就之後有機會再來說說吧,希望這次的文章有讓你變得更好入眠。
。細節補充
1. 宣告指標的時候把 * 符號和 int 擺在一起其實也是一種合規的寫法,也就是說「int *p;」和「int* p;」等價。但當我們使用「int* a, b;」的時候,實際上這句話代表的意思是「int *a; int b;」,一個是指標但一個不是,因此在宣告指標的時候我個人偏好讓 * 符號緊鄰變數名稱,如此一來也會比較易於理解。除此之外,多數情況下使用「int *a, *b;」這種方式來宣告多個指標是合法的。
2. 記憶體圖例中灰色部分不全然都會是 0。一般來說沒有經過賦值的記憶體空間,可能會有先前其他程式使用過的痕跡,稱為「殘值」,這個觀念也是以前講過的。
那麼,對於指標的基本認識,差不多就講到這裡。指標的應用就之後有機會再來說說吧,希望這次的文章有讓你變得更好入眠。
。細節補充
1. 宣告指標的時候把 * 符號和 int 擺在一起其實也是一種合規的寫法,也就是說「int *p;」和「int* p;」等價。但當我們使用「int* a, b;」的時候,實際上這句話代表的意思是「int *a; int b;」,一個是指標但一個不是,因此在宣告指標的時候我個人偏好讓 * 符號緊鄰變數名稱,如此一來也會比較易於理解。除此之外,多數情況下使用「int *a, *b;」這種方式來宣告多個指標是合法的。
2. 記憶體圖例中灰色部分不全然都會是 0。一般來說沒有經過賦值的記憶體空間,可能會有先前其他程式使用過的痕跡,稱為「殘值」,這個觀念也是以前講過的。
3. 正確來說在輸出地址的時候應該要用 %p 而不是 %08X,只是單純為了方便展示而這麼寫。