C++ 陣列、字串與 vector:從手動記憶體到動態容器
用班級成績統計的小程式,搞懂 C 風格陣列的限制、std::string、std::vector 的動態成長與迭代器,並理解為什麼 C++ 把記憶體控制權交回你手上。
你想存 50 個學生的成績,但 C++ 不讓你「先不決定大小」
假設你要寫一支小程式,讀進一個班的成績、算平均、找最高分。在 Python 裡你會直接 scores = [],邊讀邊 append,完全不用想「這個 list 能裝幾個」。但當你第一次用 C++ 寫同樣的東西,編譯器會立刻給你難堪:
int scores[]; // 錯誤:陣列大小未知,無法宣告
scores.append(95); // 錯誤:陣列沒有 append 這個方法
這不是 C++ 在找麻煩,而是它在逼你面對一個 Python 幫你藏起來的問題:資料到底放在記憶體的哪裡、佔多大、由誰負責管理。 這篇文章帶你走過 C++ 處理「一串資料」的三個工具——C 風格陣列、std::string、std::vector——並理解為什麼現代 C++ 程式幾乎只用最後一個。相較於 Python 把一切都當物件動態管理,C++ 給你的是更貼近硬體的控制權,而控制權的代價就是你得多懂一點底層。

C 風格陣列:最接近硬體,也最容易受傷
C++ 從 C 繼承了原生陣列(raw array)。它的宣告需要在編譯期就確定大小:
#include <iostream>
int main() {
int scores[5] = {90, 85, 72, 95, 88}; // 5 個整數,連續排在記憶體裡
std::cout << scores[0] << "\n"; // 輸出:90
std::cout << scores[4] << "\n"; // 輸出:88
return 0;
}
int scores[5] 做的事很單純:在記憶體上劃出一塊「5 個 int 連續排列」的空間。如果一個 int 是 4 個位元組,這塊就是 20 個位元組,緊緊相連。scores[2] 的意思其實是「從起點往後跳 2 個 int 的位置」——這是 C++ 速度快的根本原因,存取任何一個元素都是 $O(1)$,因為位置是用乘法直接算出來的,不需要逐個尋找。
但原生陣列有三個讓初學者吃足苦頭的限制:
第一,大小是固定的,而且通常要編譯期就知道。 你不能跑到一半才決定「其實我需要 100 個」。
第二,它不會記得自己有多大。 這點和 Python 差很多。在 C++,陣列傳進函式時會「退化」成一個指標(pointer,只記得起點位址,忘了長度),所以你得自己另外傳長度:
#include <iostream>
double average(const int arr[], int n) { // arr 其實只是起點指標
int sum = 0;
for (int i = 0; i < n; ++i) {
sum += arr[i];
}
return static_cast<double>(sum) / n; // 強制轉型避免整數除法
}
int main() {
int scores[5] = {90, 85, 72, 95, 88};
std::cout << average(scores, 5) << "\n"; // 輸出:86
return 0;
}
第三,也是最危險的——C++ 不會幫你檢查邊界。 寫 scores[10] 時程式照樣執行,去讀一塊不屬於你的記憶體:
int scores[5] = {90, 85, 72, 95, 88};
std::cout << scores[10]; // 未定義行為!可能印出垃圾值、也可能當機
這在 Python 會直接拋出 IndexError,明確告訴你錯在哪。C++ 為了速度,預設不做這個檢查——這就是所謂「未定義行為」(undefined behavior),是 C/C++ 程式無數安全漏洞的源頭。你拿到了直接操作記憶體的權力,但也擔起了不能越界的責任。
結論先講:除非你有非常明確的理由,現代 C++ 不要直接用原生陣列。 它存在的價值主要是讓你理解底層,以及和舊有的 C 程式碼相容。
std::string:別再用 char 陣列拼字串了
C 處理文字的方式是「一串 char 加上一個結尾的 '\0'」,也就是 C 風格字串(C-string)。它一樣有「不知道自己多長、容易越界、不能輕鬆變長」的所有毛病。C++ 標準函式庫提供了 std::string 來解決這些問題:
#include <iostream>
#include <string>
int main() {
std::string name = "優學院";
std::string greeting = "你好,";
greeting += name; // 字串可以直接相加(串接)
greeting += "!";
std::cout << greeting << "\n"; // 輸出:你好,優學院!
std::cout << greeting.size() << "\n"; // 輸出:bytes 數(中文一字多 byte)
return 0;
}
std::string 用起來和 Python 的 str 很像:可以用 + 串接、用 .size() 問長度、用 [i] 取單一字元。但有兩個關鍵差異要記住:
第一,C++ 的 std::string 是可變的(mutable)。 Python 的字串一旦建立就不能改,每次「修改」其實是產生新字串。C++ 則允許你就地改:
std::string s = "cat";
s[0] = 'b';
std::cout << s << "\n"; // 輸出:bat
第二,雙引號的兩種意義。 "hello" 預設是 C 風格字串字面值(型別接近 const char*),只有在指定型別或上下文需要時才會轉成 std::string。所以判斷型別要小心:
std::string a = "hello"; // a 是 std::string,沒問題
auto b = "hello"; // b 是 const char*,不是 std::string!
auto c = std::string("hello"); // 想用 auto 又要 string,這樣寫
auto 是 C++ 讓編譯器自動推斷型別的關鍵字,但它推出來的是「字面值本身的型別」,初學時這裡很容易踩雷。
std::vector:你 90% 的時間都該用它
終於來到主角。std::vector 是 C++ 的「動態陣列」,它把原生陣列的速度和「可以自動長大、記得自己多大、用起來像 Python list」結合在一起。它幾乎就是你寫一般 C++ 程式時,存放一串同型別資料的預設選擇。
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores; // 一開始是空的,不用先決定大小
scores.push_back(90); // 在尾端加一個元素(像 Python 的 append)
scores.push_back(85);
scores.push_back(72);
std::cout << scores.size() << "\n"; // 輸出:3
std::cout << scores[0] << "\n"; // 輸出:90
return 0;
}
注意 std::vector<int> 裡的 <int>。這是 C++ 的模板(template)語法,意思是「一個裝 int 的 vector」。和 Python 的 list 可以隨意混裝不同型別不同,C++ 的 vector 在編譯期就鎖定一種型別——這是靜態型別語言的特性,雖然少了點彈性,但換來的是編譯器幫你抓型別錯誤,以及更高的執行效率。
vector 也可以一開始就給定初始內容或大小:
std::vector<int> a = {90, 85, 72}; // 用初始化列表
std::vector<int> b(5, 0); // 5 個元素,全部初始化為 0
std::vector<std::string> names = {"Amy", "Bob"}; // 也能裝字串
三種走訪方式,從新手到地道
走訪(traverse)一個 vector 有好幾種寫法,由淺入深正好帶出 C++ 的幾個重要概念。
寫法一:傳統索引迴圈。 最直觀,和原生陣列一樣,但用 .size() 就不必另外記長度:
for (std::size_t i = 0; i < scores.size(); ++i) {
std::cout << scores[i] << " ";
}
這裡用 std::size_t 而不是 int,因為 .size() 回傳的是無號整數型別,型別配對才不會被編譯器警告。這是個常被忽略但很地道的細節。
寫法二:範圍 for 迴圈(range-based for)。 C++11 之後的寫法,最接近 Python 的 for x in list:
for (int s : scores) { // 把每個元素複製一份到 s
std::cout << s << " ";
}
for (const int& s : scores) { // 用 const 參考,避免複製、且不可改
std::cout << s << " ";
}
const int& 這個寫法值得記起來:& 代表「參考」(reference),意思是「不要複製整個元素,直接借用它」;const 代表「我只讀不改」。當 vector 裝的是大型物件(例如 std::string)時,加上 const& 能避免無謂的複製,這是 C++ 注重效能的典型體現。相較之下 Python 的 for 迴圈背後永遠是傳參考,你沒有這個選擇權,C++ 則把選擇權交給你。
寫法三:迭代器(iterator)。 這是底層機制,前兩種寫法其實都是它的包裝:
for (std::vector<int>::iterator it = scores.begin(); it != scores.end(); ++it) {
std::cout << *it << " "; // *it 取出迭代器指向的值
}
// 用 auto 簡化冗長的型別名稱
for (auto it = scores.begin(); it != scores.end(); ++it) {
std::cout << *it << " ";
}
迭代器你可以想成「一個會走動的指針」:begin() 指向第一個元素,end() 指向「最後一個元素的後面一格」(注意是後面,不是最後一個),++it 讓它前進一格,*it 取出它目前指向的值。為什麼要懂這個?因為 C++ 標準函式庫的所有演算法(排序、搜尋、去重……)都是建立在迭代器之上的通用介面。學會它,等於拿到整個 <algorithm> 工具箱的鑰匙:
#include <algorithm>
std::sort(scores.begin(), scores.end()); // 排序
auto it = std::max_element(scores.begin(), scores.end()); // 找最大值
std::cout << *it << "\n";
動手寫一段:班級成績統計
把前面的概念合起來,寫一支完整可執行的小程式。它讀入一串成績、算平均、找最高分、並印出及格名單:
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
int main() {
std::vector<int> scores = {90, 55, 72, 95, 48, 88, 63};
// 1. 算總和與平均
int sum = 0;
for (int s : scores) {
sum += s;
}
double avg = static_cast<double>(sum) / scores.size();
// 2. 找最高分(用標準函式庫的迭代器版本)
int highest = *std::max_element(scores.begin(), scores.end());
// 3. 把及格的挑進另一個 vector
std::vector<int> passed;
for (int s : scores) {
if (s >= 60) {
passed.push_back(s);
}
}
// 4. 輸出結果
std::cout << "人數:" << scores.size() << "\n";
std::cout << "平均:" << avg << "\n";
std::cout << "最高:" << highest << "\n";
std::cout << "及格人數:" << passed.size() << "\n";
std::cout << "及格分數:";
for (int s : passed) {
std::cout << s << " ";
}
std::cout << "\n";
return 0;
}
// 輸出:
// 人數:7
// 平均:73
// 最高:95
// 及格人數:5
// 及格分數:90 72 95 88 63
把這段存成 grades.cpp,用 g++ grades.cpp -o grades 編譯,再執行 ./grades 就能看到結果。試著改改看:把 passed 也排序、或統計不及格人數,感受一下 vector 與標準函式庫怎麼配合。
常見錯誤
寫 C++ 容器時,初學者最常掉進這幾個坑:
-
越界存取不會報錯,但會悄悄毀掉程式。
vector[i]和原生陣列一樣不檢查邊界。需要安全檢查時改用vector.at(i),越界會丟出std::out_of_range例外。別期待 C++ 像 Python 那樣自動給你IndexError。 -
在範圍 for 迴圈裡
push_back會炸掉。 邊走訪邊新增元素,可能觸發 vector 重新配置記憶體,使迴圈正在用的迭代器或參考全部失效(iterator invalidation),導致未定義行為。要新增就走訪舊資料、寫進「另一個」vector。 -
忘了
&,不小心複製了一大份資料。for (std::string line : lines)會把每一行字串完整複製一次;資料量一大就是效能殺手。唯讀就用for (const std::string& line : lines)。這是 C++ 才需要操心、Python 幫你免費搞定的事。 -
用
int接.size()。.size()回傳無號的std::size_t,拿int去比較或相減(尤其空 vector 時size()-1會變成超大正數)容易出意外。索引迴圈用std::size_t。 -
把
auto x = "字串"當成std::string。 它其實是const char*。要std::string就明確寫型別,或用 C++14 的auto x = "字串"s;(需using namespace std::string_literals;)。
深入探討(研究所視角)
前面說 push_back 像 Python 的 append,但 C++ 把背後的機制攤在你面前讓你能看清楚、甚至能調控。理解它,才算真正懂 vector。
記憶體佈局:vector 和原生陣列其實長得一樣。 一個 std::vector<int> 在記憶體裡通常就是三個指標:指向資料起點、指向已用資料的尾端、指向所配置空間的尾端。而它指向的那塊實際資料是連續的,和原生陣列毫無二致。這代表 &vec[0] 拿到的是一塊正規的連續記憶體,可以直接傳給只吃 C 陣列的舊函式。這種「連續性保證」是 vector 被選為預設容器的關鍵——它對 CPU 的快取(cache)非常友善,因為現代 CPU 一次會把相鄰的資料一起載入快取,連續存取時幾乎每次都命中。相比之下,鏈結串列(linked list)的節點散落各處,走訪時不斷快取失誤(cache miss),實務上往往比 vector 慢得多,即使理論上某些操作的複雜度更漂亮。這是「大 O 不是全部」的經典案例。
容量與大小是兩回事。 vector 有兩個數字:size()(目前裝了幾個元素)和 capacity()(這塊記憶體目前能裝幾個元素而不必重新配置)。capacity >= size 永遠成立。觀察它的成長很有啟發:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
for (int i = 0; i < 9; ++i) {
v.push_back(i);
std::cout << "size=" << v.size()
<< " capacity=" << v.capacity() << "\n";
}
return 0;
}
// 典型輸出(依編譯器實作而異,GCC 為 2 倍成長):
// size=1 capacity=1
// size=2 capacity=2
// size=3 capacity=4
// size=4 capacity=4
// size=5 capacity=8
// size=6 capacity=8
// size=7 capacity=8
// size=8 capacity=8
// size=9 capacity=16
攤銷 $O(1)$ 的 push_back。 你可能會困惑:如果 vector 的資料必須連續,那當空間滿了、要新增一個元素時,不就得「配置一塊更大的記憶體、把舊資料全部複製過去」嗎?那單次複製是 $O(n)$ 啊,怎麼說 push_back 是 $O(1)$?
關鍵在於成長策略。vector 不是每滿一格就加一格,而是每次滿了就把容量翻倍(GCC 用 2 倍,部分實作用 1.5 倍)。這樣,從空一路 push_back 到 $n$ 個元素,重新配置只發生在容量為 $1, 2, 4, 8, \dots$ 的時刻,總複製次數約為 $1 + 2 + 4 + \dots + n \approx 2n$。把這 $2n$ 次複製平均分攤到 $n$ 次 push_back,每次平均只多做 2 次複製——也就是常數。這就是攤銷分析(amortized analysis):個別操作偶爾很貴($O(n)$),但長期平均下來是 $O(1)$,記作攤銷 $O(1)$。如果改用「每次只加 1 格」的線性成長,總複製會變成 $1 + 2 + \dots + n \approx n^2/2$,攤銷成本退化成 $O(n)$,效能天差地別。倍增成長正是攤銷 $O(1)$ 成立的數學前提。
你可以介入這個過程。 如果事先知道大概要裝多少,用 reserve() 一次配置好,就能完全避免中途的重新配置與複製:
std::vector<int> v;
v.reserve(1000); // 先把容量開到 1000,後續 push_back 不再搬家
for (int i = 0; i < 1000; ++i) {
v.push_back(i);
}
這種「我知道規模,先把記憶體談好」的最佳化,是 C++ 給你而 Python 通常拿不到的控制權。Python 的 list 背後也是動態陣列、也用類似的倍增策略,但它把 capacity 藏起來,你沒有 reserve 可呼叫。
這正是 C++ 設計哲學「零成本抽象」(zero-overhead abstraction)的縮影:std::vector 用起來像高階語言的 list 一樣方便,但它沒有為這份方便偷偷付出額外的執行成本——你不用的特性不會拖慢你,你想要的控制權(容量、記憶體佈局、是否複製)它都原原本本交給你。當你帶著 Python 的直覺進入 C++,真正要學的不是新語法,而是這份「方便背後的代價,現在由你掌握」的思維轉換。