Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

流程控制

C++ 流程控制:讓程式自己決定走哪條路

從 if/else、switch 到 range-based for,掌握 C++ 流程控制的語言特色,並從編譯器與 CPU 的視角理解分支預測與迴圈最佳化

讓程式自己決定走哪條路

想像你在寫一支判斷成績等第的小工具:分數 90 以上是 A、80 到 89 是 B、不及格還要印出警告。程式不能只會「從上往下一行一行讀」,它得學會看情況轉彎重複做某件事直到完成。這就是流程控制(control flow)。

如果你讀過本專區的 Python 篇,這些概念對你不陌生——條件、迴圈、跳出。但 C++ 把同樣的概念交給一個靜態型別、會編譯成機器碼的語言時,故事就變得不太一樣了。在 Python 裡 if 後面接什麼幾乎都能跑;在 C++ 裡,編譯器會在你按下執行之前,先把你的條件式「攤開來檢查型別」,甚至幫你預測「這個 if 比較可能成立還是不成立」,再決定產生什麼樣的機器指令。

這篇文章帶你把 C++ 的流程控制寫熟,同時看清楚它跟動態語言在底層的根本差異。我們會大量動手寫可以直接編譯執行的程式碼,請打開你的編譯器(g++clang++)跟著敲。

C++ 流程控制概念示意圖

if / else:條件是「布林」,不是「真假值」

最基本的分支從 if 開始。先看一段完整可編譯的程式:

#include <iostream>

int main() {
    int score = 76;

    if (score >= 90) {
        std::cout << "等第 A\n";
    } else if (score >= 60) {
        std::cout << "等第 B(及格)\n";
    } else {
        std::cout << "不及格,請加油\n";
    }
    return 0;
}
// 輸出:等第 B(及格)

語法上跟很多語言一樣,但有幾個 C++ 的眉角值得停下來看。

第一,條件式的型別是 bool score >= 90 這個運算式的結果型別是 bool,只有 truefalse 兩個值。相較於 Python 用「真假值(truthiness)」把空字串、0None 一律當成假,C++ 沒有這套規則:能放進 if 的,是任何「可以轉成 bool」的東西。整數 0 會轉成 false、非零轉成 true,但這是隱式型別轉換,不是 truthiness。

第二,當心 === 這是 C/C++ 初學者最經典的雷:

int x = 5;
if (x = 0) {            // 反模式:這是「賦值」,把 0 指派給 x,整個式子的值是 0(false)
    std::cout << "永遠不會印\n";
}

x = 0 是賦值,它的結果是被賦的值 0,再被當成條件——於是永遠是 false,而且 x 還被你偷偷改掉了。正確的相等比較是 ==。現代編譯器加上 -Wall 會對這種寫法發出警告,請務必開啟:

g++ -Wall -Wextra -std=c++17 main.cpp -o main

第三,大括號別省。 C++ 允許 if 後面只接單一句而省略 {},但這會埋下著名的「goto fail」類錯誤。請養成永遠寫大括號的習慣。

switch:跳表式的多路分支

當你要對「同一個整數型變數的多個離散值」分別處理時,switch 比一長串 else if 更清楚,而且編譯器有機會把它最佳化成跳轉表(jump table),達到接近 $O(1)$ 的分派:

#include <iostream>

int main() {
    int menu = 2;

    switch (menu) {
        case 1:
            std::cout << "新增資料\n";
            break;
        case 2:
            std::cout << "查詢資料\n";
            break;
        case 3:
            std::cout << "刪除資料\n";
            break;
        default:
            std::cout << "未知選項\n";
            break;
    }
    return 0;
}
// 輸出:查詢資料

break 是必要的,漏掉會「貫穿(fall-through)」。 這是 C++ 跟 Python(沒有 switch,但有 match)很不一樣的地方:少了 break,程式會繼續往下一個 case 執行,直到遇到 breakswitch 結束。貫穿偶爾是刻意設計(多個 case 共用同一段邏輯),但更多時候是忘了寫 break 的 bug。C++17 起可以用 [[fallthrough]]; 屬性明確標示「我是故意的」:

switch (grade) {
    case 'A':
    case 'B':                       // A 和 B 共用:刻意貫穿,無程式碼即合法
        std::cout << "表現優異\n";
        break;
    case 'C':
        std::cout << "尚可,";
        [[fallthrough]];            // 明示故意貫穿,編譯器不警告
    case 'D':
        std::cout << "建議加強\n";
        break;
    default:
        std::cout << "無效等第\n";
}

switch 只能用在整數型(含 charenum),不能直接 switch 字串——這又是靜態型別與底層思維的結果:跳轉表需要的是「可以當索引的整數」。

for 迴圈:經典三段式

for 是 C++ 最常用的計次迴圈,三個區段以分號隔開:初始化、條件、更新。

#include <iostream>

int main() {
    int sum = 0;
    for (int i = 1; i <= 10; ++i) {   // i 的作用域只在這個迴圈內
        sum += i;
    }
    std::cout << "1 到 10 的總和 = " << sum << "\n";
    return 0;
}
// 輸出:1 到 10 的總和 = 55

注意 int i = 1 把計數變數宣告在 for 內部,它的作用域(scope)就只在這個迴圈,迴圈結束就消失。這是好習慣:避免變數外洩污染外層。也請偏好 ++i(前綴遞增)而非 i++;對於基本型別兩者效能相同,但對複雜的迭代器型別,前綴版可省下一次拷貝,寫成習慣不吃虧。

相較於 Python 的 for i in range(1, 11),C++ 的三段式更貼近底層:你親手控制起點、終止條件與步進,編譯器幾乎能一對一翻成「比較 + 條件跳轉」的機器指令。

while 與 do-while:條件先行 vs. 至少做一次

當迴圈次數不固定、由條件決定時,用 while

#include <iostream>

int main() {
    int n = 27;
    int steps = 0;
    // Collatz 序列:奇數 *3+1,偶數 /2,直到變成 1
    while (n != 1) {
        n = (n % 2 == 0) ? n / 2 : n * 3 + 1;   // 三元運算子
        ++steps;
    }
    std::cout << "經過 " << steps << " 步抵達 1\n";
    return 0;
}
// 輸出:經過 111 步抵達 1

while 是「先檢查條件,再決定要不要進迴圈」。如果你需要「先做一次、再檢查要不要重複」,就用 do-while——它至少執行一次,很適合「輸入驗證」這類情境:

#include <iostream>

int main() {
    int age;
    do {
        std::cout << "請輸入年齡(0~120):";
        std::cin >> age;
    } while (age < 0 || age > 120);   // 不合法就再問一次

    std::cout << "已收到年齡:" << age << "\n";
    return 0;
}

別忘了 do-while 結尾的分號,漏掉是常見編譯錯誤。

range-based for:C++11 給你的現代寫法

從 C++11 起,遍歷容器有了更簡潔的語法,概念上最接近 Python 的 for x in list

#include <iostream>
#include <vector>
#include <string>

int main() {
    std::vector<std::string> fruits = {"蘋果", "香蕉", "芭樂"};

    // 唯讀遍歷:用 const 參考避免拷貝
    for (const std::string& f : fruits) {
        std::cout << f << " ";
    }
    std::cout << "\n";

    // 需要修改元素時:用非 const 參考
    std::vector<int> nums = {1, 2, 3, 4};
    for (int& x : nums) {
        x *= 10;
    }
    for (int x : nums) {
        std::cout << x << " ";
    }
    std::cout << "\n";
    return 0;
}
// 輸出:蘋果 香蕉 芭樂
// 輸出:10 20 30 40

這裡藏著 C++ 與動態語言最深刻的差別:參考(reference)與拷貝(copy)的選擇是你的責任

  • for (std::string f : fruits):每次迭代都拷貝一份字串,浪費效能。
  • for (const std::string& f : fruits):用唯讀參考,零拷貝,這是遍歷大型物件的慣例寫法。
  • for (int& x : nums):用可寫參考,迴圈內的修改會真的改到容器裡的元素。

在 Python 裡你不需要、也無法做這種區分,因為一切都是物件參考;C++ 把這個控制權還給你,代價是你得想清楚要不要拷貝。這正是「零成本抽象(zero-cost abstraction)」哲學的體現:方便的語法不該偷偷產生你看不見的開銷。

C++17 起還能搭配結構化綁定(structured bindings)遍歷 map

#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<std::string, int> scores = {{"小明", 88}, {"小華", 92}};
    for (const auto& [name, point] : scores) {   // 同時取出 key 與 value
        std::cout << name << ":" << point << " 分\n";
    }
    return 0;
}
// 輸出:小明:88 分
// 輸出:小華:92 分

break 與 continue:跳出與跳過

break 立刻離開最內層迴圈;continue 跳過本次剩餘的程式碼、直接進入下一輪:

#include <iostream>

int main() {
    // 找出 1~100 中第一個能同時被 7 和 11 整除的數
    for (int i = 1; i <= 100; ++i) {
        if (i % 7 != 0) continue;      // 不是 7 的倍數就跳過
        if (i % 11 == 0) {
            std::cout << "找到了:" << i << "\n";
            break;                      // 找到就離開
        }
    }
    return 0;
}
// 輸出:找到了:77

要注意 C++ 沒有 Python 的 for...else,也沒有帶標籤的 break(Java 有)。如果你要一次跳出多層巢狀迴圈,慣用的乾淨做法是把那段邏輯抽成函式、用 return 跳出,或在較罕見情況下審慎使用 goto(多數風格指南不建議)。

邏輯運算子與短路求值

&&(且)、||(或)、!(非)組合多個條件。C++ 對 &&||短路求值(short-circuit evaluation)

#include <iostream>
#include <vector>

bool isValid(const std::vector<int>& v, int idx) {
    // 短路:先確認 idx 在範圍內,才會去存取 v[idx]
    return idx >= 0 && idx < (int)v.size() && v[idx] > 0;
}

int main() {
    std::vector<int> data = {5, -3, 8};
    std::cout << std::boolalpha;          // 讓 bool 印成 true/false
    std::cout << isValid(data, 0) << "\n";   // 5 > 0
    std::cout << isValid(data, 1) << "\n";   // -3 > 0 為假
    std::cout << isValid(data, 9) << "\n";   // 越界,但短路擋下了
    return 0;
}
// 輸出:true
// 輸出:false
// 輸出:false

短路的關鍵價值在這個例子很明顯:idx < v.size() 為假時,&& 右邊的 v[idx] 根本不會被執行,於是不會發生越界存取。這個順序不能顛倒——把邊界檢查放後面就會踩記憶體地雷。短路不只是效能小技巧,常常是正確性的防線

請避免把 &&/|| 跟位元運算子 &/| 搞混:後者是逐位元運算、不會短路,兩邊都會求值,語意完全不同。

動手寫一段:質數判斷器

把目前學到的東西組合起來,寫一支判斷使用者輸入數字是否為質數的完整程式:

#include <iostream>

bool isPrime(int n) {
    if (n < 2) return false;              // 0、1 與負數都不是質數
    if (n == 2) return true;              // 2 是唯一的偶數質數
    if (n % 2 == 0) return false;         // 其他偶數直接排除

    // 只需檢查到 sqrt(n):若 i*i 已超過 n 就不可能有因數
    for (int i = 3; i * i <= n; i += 2) { // 只試奇數
        if (n % i == 0) {
            return false;                 // 找到因數,提早結束
        }
    }
    return true;
}

int main() {
    int nums[] = {1, 2, 17, 18, 97, 100};
    for (int x : nums) {
        std::cout << x << (isPrime(x) ? " 是質數\n" : " 不是質數\n");
    }
    return 0;
}
// 輸出:1 不是質數
// 輸出:2 是質數
// 輸出:17 是質數
// 輸出:18 不是質數
// 輸出:97 是質數
// 輸出:100 不是質數

這支程式同時用上了 if/else 早期返回、for 配合 i += 2 步進、break 概念(用 return 提早結束)、% 取餘、三元運算子與 range-based for。其中 i * i <= n 而非 i <= sqrt(n) 是刻意的:避免重複呼叫浮點 sqrt 與型別轉換,是 C++ 慣用的效能寫法,把迴圈次數從 $O(n)$ 降到約 $O(\sqrt{n})$。

常見錯誤

把這幾條貼在螢幕邊:

  1. = 寫成條件if (x = 0) 是賦值不是比較。永遠記得相等是 ==,並開 -Wall 讓編譯器幫你抓。
  2. switchbreak:少一個 break 就會貫穿到下一個 case。刻意貫穿請用 [[fallthrough]]; 標明。
  3. range-based for 忘了用參考for (BigObject x : container) 會逐個拷貝。唯讀就用 const auto&,要改就用 auto&
  4. 迴圈條件忘記讓變數變化while (n != 1) 但迴圈內沒有更新 n,就是無窮迴圈。每寫一個 while 都自問「這個條件最終會變成假嗎?」
  5. 混用 &&&:邏輯且是 &&(短路),位元且是 &(兩邊都算)。在 if 條件裡幾乎一定要用前者。

深入探討(研究所視角)

寫得出來只是起點。當你的迴圈每秒要跑數十億次時,理解編譯器與硬體如何看待你的流程控制,才能寫出真正快的程式。

分支預測:CPU 在賭你會走哪條路

現代 CPU 採用深度管線(pipeline),會在前一條指令還沒算完時就預先抓取、解碼後續指令。但遇到 if、迴圈條件這類條件分支時,CPU 還不知道結果是真是假,於是它會——這就是分支預測(branch prediction)。猜對,管線全速前進;猜錯,已經預取進管線的指令全部作廢、回頭重來,這個「分支誤判懲罰(branch misprediction penalty)」在現代處理器上往往是 10~20 個時脈週期。

這帶來一個反直覺的效能現象:處理「已排序」資料常常比「未排序」快得多,即使演算法完全一樣。考慮這段:

long sum = 0;
for (int i = 0; i < N; ++i) {
    if (data[i] >= 128) {     // 這個分支好不好預測,決定了速度
        sum += data[i];
    }
}

data 已排序,前半段一律 < 128、後半段一律 >= 128,分支結果有強烈規律,預測器幾乎全猜中;若資料隨機,>= 128 大約一半機率成立,預測器形同擲銅板,誤判率飆高,實測可能慢上數倍。這是著名的 Stack Overflow 經典問題背後的真相。

身為程式設計者,你能做的有幾個層次:

  • 讓資料對分支友善:能排序就排序,把可預測的規律餵給硬體。
  • 改用無分支(branchless)寫法:把條件轉成算術。例如 sum += data[i] & -(data[i] >= 128),利用 boolint 為 0/1 的特性,用位元遮罩取代 if,徹底消滅這個分支。這種技巧在效能關鍵路徑很常見,但會犧牲可讀性,請只在量測過、確認是瓶頸後才用。
  • 給編譯器提示:C++20 提供 [[likely]] / [[unlikely]] 屬性,告訴編譯器哪條路較常走,影響它的程式碼佈局(把熱路徑放在一起以利指令快取):
if (ptr != nullptr) [[likely]] {
    process(ptr);
} else [[unlikely]] {
    handleError();
}

這正是 C++ 與 Python 的世界觀差異:Python 的 if 經過位元組碼直譯,分支預測的影響被直譯器開銷淹沒;C++ 的 if 直接對應機器層的條件跳轉,你寫的每個分支都實實在在地與 CPU 管線互動。

迴圈最佳化:編譯器替你重寫程式

當你開 -O2-O3,編譯器(GCC、Clang/LLVM)會對迴圈動一系列手術,常見的有:

  • 迴圈展開(loop unrolling):把 for 迴圈體複製數份,一次處理多個元素,減少分支判斷與計數更新的次數,也給後續最佳化更多空間。
  • 向量化(vectorization):把「對每個元素做相同運算」的迴圈,改用 SIMD 指令(如 SSE、AVX)一次處理 4、8、16 個資料。原本逐元素相加的迴圈,可能被編譯成單一條向量加法指令。
  • 迴圈不變量外提(loop-invariant code motion):把迴圈內每次結果都相同的計算搬到迴圈外,只算一次。這也是為什麼前面質數例子寫 i * i <= n 而非每輪呼叫 sqrt——但即使你寫了 sqrt(n),好的編譯器也可能幫你提出去。
  • 強度削減(strength reduction):把迴圈內昂貴的乘法換成便宜的加法(例如把 i * stride 改成累加)。

關鍵心法是:你的工作是寫出意圖清楚、沒有 undefined behavior 的程式碼,把微觀最佳化交給編譯器。 為什麼「沒有 undefined behavior」如此重要?因為編譯器的許多激進最佳化,正是建立在「程式員保證不會發生未定義行為」這個假設上。例如有號整數溢位在 C++ 是未定義行為,編譯器便可假設 i + 1 > i 永遠成立,從而安全地展開或向量化迴圈;一旦你的程式真的溢位,最佳化過的結果就可能與你預期天差地遠。這是 C++ 「相信程式員」哲學的雙面刃:它換來了極致效能,也要求你對語言規則有更深的敬畏。

想看編譯器到底把你的迴圈變成什麼?把程式碼貼到 Compiler Explorer(godbolt.org),開 -O2,比對組合語言輸出。你會發現一個簡單的累加迴圈,在最佳化後可能完全認不出原貌——這正是理解「零成本抽象」最直接的方式:高階的 range-based for 與手寫指標迴圈,最佳化後往往產生一模一樣的機器碼。抽象是免費的,前提是你信任並善用這套編譯工具鏈。

下次寫迴圈時,不妨多想一層:這個分支好預測嗎?這個迴圈能向量化嗎?我有沒有不小心擋住編譯器的最佳化?把這些問題放進腦中,你就從「會用流程控制」進階到「懂流程控制」了。

AI 共讀助教正在陪你讀:C++ 流程控制:讓程式自己決定走哪條路
嗨!我是這篇文章的共讀助教,只根據〈C++ 流程控制:讓程式自己決定走哪條路〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。