從一個盒子到一排盒子
你到目前為止認識的每個變數,都剛好只裝一個值——一個 `int`、一個 `double`、一個 `char`。但程式總是需要「很多個」同一種東西:十個考試分數、一千個像素、一則訊息的位元組。陣列就是 C 的答案:用單一名字稱呼一整排連續的盒子,全都是同一種型別,在記憶體裡一個緊接一個排好。寫 `int scores[5];` 你就得到一排五個 `int` 盒子;寫 `int scores[5] = {90, 85, 100, 70, 60};` 則在它們誕生時就填好。
你用索引伸進這排盒子,索引寫在方括號裡:`scores[0]` 是第一個盒子,`scores[1]` 是第二個,`scores[4]` 是最後一個。現在最該刻進腦海的一件事是:C 從零開始數。長度為 5 的陣列,合法索引是 0、1、2、3、4——根本沒有 `scores[5]` 這個東西。索引其實是「距離起點的距離」:`scores[0]` 是往前零個盒子、`scores[3]` 是往前三個盒子。等指標登場時,這幅畫面會無比重要,因為索引在底層其實是對位址做算術。
它有多大?sizeof 與記憶體裡的一排
因為陣列就是 N 個同型別的盒子首尾相連,它的總大小好得出奇地可預測:`sizeof(scores)` 是 `5 * sizeof(int)`,在典型機器上是 `5 * 4 = 20` 個位元組。sizeof 運算子是問這件事最誠實的方式,由它還能導出一個好用的慣用法:陣列的元素個數是 `sizeof(arr) / sizeof(arr[0])`——整塊的大小除以一個盒子的大小。不管元素是什麼型別,這都成立。
int scores[5] = {90, 85, 100, 70, 60};
index: 0 1 2 3 4
+----+----+----+----+----+
scores: | 90 | 85 |100 | 70 | 60 |
+----+----+----+----+----+
offset: +0 +4 +8 +12 +16 (bytes, if sizeof(int)==4)
sizeof(scores) == 20
sizeof(scores)/sizeof(scores[0]) == 5字串是一個尾巴安靜的字元陣列
C 沒有內建的字串型別。它有的是 `char`——一個一位元組的盒子,通常裝一個文字字元——外加一條約定。一個 C 字串只是一個裝著你的字母的 `char` 陣列,後面再跟一個值為 0 的額外位元組來標記結尾。那個結尾字元寫成 `'\0'`,叫做空字元結尾;整套安排就是空字元結尾字串。所以 `char greeting[] = "hi";` 不是 2 個位元組,而是 3 個:`'h'`、`'i'`,以及一個隱藏的 `'\0'`。
為什麼用一個結尾的零,而不直接存長度?因為這樣字串就只是一個起點,任何程式碼都能一個位元組一個位元組往前走,直到遇上 `'\0'`——`strlen()` 正是這樣量出字串長度,`printf("%s", ...)` 也正是這樣知道何時該停止印出。這是個聰明又省的把戲,卻也是一整族 bug 的源頭。一旦 `'\0'` 不見了或被蓋掉,這趟走訪就不會在你資料的結尾停下——它會直直闖進後面任何記憶體,一路讀垃圾,直到撞運氣碰到一個零位元組,或乾脆當掉。
代價就在這裡浮現。當你把文字複製進一個固定大小的 `char` 緩衝區時,你必須替那個隱藏的結尾字元留位置,「並且」絕不寫過緩衝區的尾端。把一個 20 字元的名字複製進 `char name[16];`,你就會衝出陣列、塗掉後面坐著的任何東西——這就是緩衝區溢位,史上整個計算領域裡最常被利用的 bug 之一。它情有可原的表親是差一錯誤:你剛好配置了 `strlen(text)` 個位元組,卻忘了替 `'\0'` 多留的那個 `+1`。替結尾字元算進空間不是可有可無;它本來就是 C 字串「是什麼」的一部分。
結構:把不同的東西收進同一個名字底下
陣列把許多「同一種」型別收在一起。結構做的是相反的工作:它把好幾個「不同」的值,捆成單一個有名字的東西。一個點有 x 和 y;一個人有名字、年齡和身高。與其雜耍三個鬆散的變數、祈禱它們保持同步,不如定義一個結構,把它們當成有名字的成員綁在一塊,從此把這一個物件當成一個整體傳來傳去。
定義一個結構很短。寫 `struct Point { int x; int y; };` 來宣告形狀,再用 `struct Point p = {3, 4};` 造一個物件,把 `x` 設為 3、`y` 設為 4。你用點號取成員:`p.x` 是 3、`p.y` 是 4,而 `p.x * p.x + p.y * p.y` 算出來是 25。形狀只定義一次;從它你能印出任意多個 `Point` 物件,每個都帶著自己捆在一起的那一對值。
結構裡可以裝陣列、裝別的結構、裝任何東西——它確實是你自己設計的一個新的小型別。到處打 `struct Point` 很囉嗦,所以 C 讓你用 typedef 取一個更短的別名,之後你只要寫 `Point` 就好——這就是結構、聯合、列舉與 typedef裡 `typedef` 的那一半。兩個親戚補齊這一家:`union`,它所有成員共用「同一塊」儲存空間,所以同一時間只有一個是有意義的;以及成員以個別位元計大小的結構,也就是位元欄位,方便把旗標緊緊打包起來。
列舉:用名字取代魔術數字
本篇最後一種形狀,是最小也最和善的。常常一個值其實只是一組簡短、固定選項中的一個:紅綠燈不是紅、黃、就是綠;一張牌屬於四種花色之一。你「大可」把它們編成赤裸的整數 0、1、2——但這樣你的程式碼就會散落一地光禿禿的數字,意義只活在你腦袋裡。列舉讓你給這些選項真正的名字;它就是你剛認識的結構/聯合/列舉/typedef 這一家裡 `enum` 的那位成員。
寫 `enum Color { RED, YELLOW, GREEN };`,C 就自動把 0 給 `RED`、1 給 `YELLOW`、2 給 `GREEN`,除非你自己指定數字,否則它就一路往上數。底層上,一個列舉值其實就是個 `int`,所以不花額外的代價——但現在 `if (light == RED)` 讀起來就像它本來該是的那句話,而當 `switch` 漏掉某個情況時,編譯器還能警告你。這跟替變數命名、而非讓一個字面常數在程式碼裡飄著,是同一種直覺:名字讓程式說出它真正的意思。
退一步,你就會看見整篇導引的形狀。陣列給你「很多個同樣的」;結構給你「好幾個不同的、捆在一起」;列舉給你「從少數幾個裡挑一個有名字的」;而字串只不過是其中的第一種——一個 `char` 陣列——披上了一個一位元組的結尾。每一種都直接用第一篇那些帶型別的盒子蓋成,沒有新的魔法。它們也是通往下一階的門:陣列、字串和結構全都住在記憶體的某處,而當你想談「住在哪裡」的那一刻——想廉價地傳遞一個大結構、或在執行時讓陣列長大——你就需要它們的位址,而位址就是指標。