學習目標

讀完本章,你將能夠:

  1. 清楚區分 const(執行期常數)與 constexpr(編譯期常數)
  2. 宣告並使用 constexpr 變數、函式與建構子(literal type)
  3. 了解 C++11 → C++14 → C++17 對 constexpr 函式逐步放寬的限制
  4. 判斷 constexpr 函式何時在編譯期執行、何時退化為執行期函式
  5. 使用 if constexpr(C++17)做編譯期分支與死碼消除(dead code elimination)
  6. static_assert 做編譯期檢查
  7. 認識 C++20 的 constevalconstinit(前瞻)
  8. 透過範例(階乘、費氏數、質數判定、編譯期字串雜湊、查找表)體會編譯期計算的效能優勢

一、為什麼要在編譯期計算?

傳統程式在執行期(runtime)做所有計算。但有許多值其實在編譯期(compile time)就已確定——例如緩衝區大小、查找表、數學常數、設定參數。把這些計算「搬到編譯期」有四大好處:

  1. 零執行期開銷:結果直接以常數嵌入二進位碼,程式啟動即可用,無需重算。
  2. 更早抓錯:搭配 static_assert,把錯誤從「使用者執行時才崩潰」提前到「編譯時就無法通過」。
  3. 更安全:編譯期常數天生不可變,不會被意外修改,也可安全用於多執行緒。
  4. 最佳化友善:編譯器握有完整常數資訊,能做更積極的最佳化(如常數摺疊、迴圈展開)。
        執行期計算                     編譯期計算
   ┌──────────────────┐         ┌──────────────────┐
   │ 程式啟動          │         │ 編譯時就算完      │
   │   ↓ 每次都重算    │         │   ↓               │
   │ CPU 花時間運算    │   VS    │ 結果寫死在程式裡  │
   │   ↓               │         │   ↓               │
   │ 得到結果          │         │ 執行期直接取用 O(1)│
   └──────────────────┘         └──────────────────┘

核心心法constexpr 是「可以在編譯期求值」的承諾。能在編譯期算的就別留到執行期。


二、const vs constexpr

const — 「不可修改」,但不保證編譯期可知

const 表示變數初始化後不可再修改,但其值不一定在編譯期已知

const int a = 42;               // 編譯期已知(但 const 不強制這點)
const int b = get_value();      // 執行期才確定,之後不可改
const int c = std::cin.get();   // 執行期讀入

constexpr — 保證「編譯期可求值」

constexpr 保證該值必須能在編譯期求得。若初始化器無法在編譯期算出,直接編譯失敗。

constexpr int a = 42;              // OK:編譯期已知
constexpr int b = 10 * 20 + 3;     // OK:編譯期可計算
// constexpr int c = get_value();   // 錯誤!除非 get_value() 也是 constexpr

一句話總結

const 說的是「唯讀」(read-only),constexpr 說的是「編譯期已知」(known at compile time)。所有 constexpr 變數都隱含 const,但反之不然。

比較表

特性 const constexpr
何時確定值 編譯期執行期 必須編譯期
隱含唯讀
可用於陣列大小 僅當值為編譯期常數時 一定可以
可用於模板非型別參數 僅當值為編譯期常數時 一定可以
可用於 static_assert 僅當編譯期常數 一定可以
隱含 const
constexpr int n = 8;
int arr[n];                 // OK:n 是編譯期常數
static_assert(n == 8);      // OK

const int m = std::cin.get();
// int arr2[m];             // 錯誤:m 的值執行期才知道

三、constexpr 變數

constexpr int    MAX_SIZE = 100;
constexpr double PI = 3.14159265358979;
constexpr int    ARRAY_SIZE = MAX_SIZE * 2;   // constexpr 可引用其他 constexpr

int arr[ARRAY_SIZE];        // OK:用作陣列大小

搭配 static_assert 做編譯期檢查

static_assert(條件, "訊息") 在編譯期檢查條件,不成立就中止編譯並印出訊息。它是編譯期計算的「單元測試」:

constexpr int buffer_size = 1024;
static_assert(buffer_size >= 512, "緩衝區太小!");
static_assert(buffer_size % 4 == 0, "緩衝區大小必須是 4 的倍數");

C++17 起 static_assert 的訊息可省略:static_assert(buffer_size >= 512);


四、constexpr 函式

基本規則:編譯期與執行期「雙棲」

constexpr 函式很特別:

  • 當以編譯期常數為引數呼叫、且結果用在需要常數的情境(如初始化 constexpr 變數)時,會在編譯期求值。
  • 當以執行期值呼叫時,自動退化為普通函式,在執行期執行。
constexpr int square(int x) { return x * x; }

constexpr int a = square(5);   // 編譯期計算:25
int n = 10;
int b = square(n);             // 執行期計算(n 非編譯期常數)

重點:同一個 constexpr 函式可以「兩棲」。要強制編譯期求值,就把結果指派給 constexpr 變數或用在 static_assert、陣列大小等情境。

何時在編譯期、何時在執行期?

constexpr int f(int x);

  使用情境                          求值時機
  ─────────────────────────────────────────────
  constexpr int a = f(5);          編譯期(被強制)
  static_assert(f(5) == 25);       編譯期(被強制)
  int arr[f(5)];                   編譯期(被強制)
  int b = f(5);                    編譯期或執行期(編譯器決定)
  int c = f(runtime_var);          執行期(引數非常數)

若想「保證」在編譯期執行(否則報錯),C++20 提供 consteval(見第八節)。

C++ 各版本對 constexpr 函式的放寬

版本 constexpr 函式允許
C++11 函式體幾乎只能有一個 return;不可有區域變數、迴圈、if 語句(可用三元運算子與遞迴)
C++14 允許多條語句、區域變數、if/switchfor/while 迴圈、多個 return;可修改區域變數
C++17 if constexprconstexpr lambda
C++20 允許 try/catch(不丟例外)、動態配置(new/deletestd::vector/std::string 於 constexpr 中)、虛擬函式、constexprstd:: 演算法等

C++11 寫法(受限,用遞迴)

constexpr int factorial_cpp11(int n) {
    return n <= 1 ? 1 : n * factorial_cpp11(n - 1);
}

C++14 寫法(自然,用迴圈)

constexpr int factorial(int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i) result *= i;
    return result;
}

constexpr 函式內不允許的操作

即使到 C++17,constexpr 函式中仍不可有:

  • I/O 操作(std::coutstd::cin、檔案)
  • 動態記憶體配置(new/delete,C++20 才放寬)
  • 呼叫非 constexpr 的函式(在編譯期求值路徑上)
  • staticthread_local 區域變數
  • goto、未初始化的變數

五、constexpr 類別與字面值型別(literal type)

若一個類別的建構子是 constexpr、成員函式也是 constexpr,便能建立編譯期物件。這類別稱為「字面值型別」(literal type)。

class Point {
    double x_, y_;
public:
    constexpr Point(double x = 0, double y = 0) : x_(x), y_(y) {}
    constexpr double x() const { return x_; }
    constexpr double y() const { return y_; }
    constexpr Point operator+(const Point& o) const { return {x_ + o.x_, y_ + o.y_}; }
    constexpr double distance_squared() const { return x_ * x_ + y_ * y_; }
};

constexpr Point origin;             // 編譯期建構
constexpr Point p(3.0, 4.0);
constexpr double d2 = p.distance_squared();   // 編譯期計算:25.0
static_assert(d2 == 25.0);

預期:d2 在編譯期就是 25.0,執行期零計算。

規則重點

  • 建構子可為空(成員以初始化列表設定);C++14 起建構子體可含語句。
  • 所有在 constexpr 求值路徑上被呼叫的成員函式都必須是 constexpr
  • constexpr 物件不可有 mutable 成員。
  • 成員型別本身也必須是字面值型別。

六、if constexpr(C++17):編譯期分支與死碼消除

if constexpr (條件)編譯期求值條件,未選中的分支會被整段丟棄、不會被編譯(dead code elimination)。這對泛型程式至關重要——因為未選中的分支即使「對該型別無效」也沒關係,編譯器根本不看它。

template <typename T>
auto process(T value) {
    if constexpr (std::is_integral_v<T>) {
        return value * 2;       // 整數分支
    } else if constexpr (std::is_floating_point_v<T>) {
        return value + 0.5;     // 浮點分支
    } else {
        return value;           // 其他
    }
}

對比普通 if:若用普通 if所有分支都必須對 T 合法編譯,否則整個函式編譯失敗。if constexpr 則只編譯被選中的分支。

普通 if            :所有分支都要能編譯(即使執行期永不進入)
if constexpr        :只有被選中的分支會被編譯,其餘整段丟棄

與 SFINAE 的比較

if constexpr 出現前,要「依型別選擇實作」得用繁瑣的 SFINAE:

// SFINAE 寫法(難讀、需要多個多載)
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
T double_value(T x) { return x * 2; }
template <typename T, std::enable_if_t<!std::is_integral_v<T>, int> = 0>
T double_value(T x) { return x; }

// if constexpr 寫法(單一函式、直觀)
template <typename T>
T double_value(T x) {
    if constexpr (std::is_integral_v<T>) return x * 2;
    else                                  return x;
}

注意if constexpr 只有在模板中才能發揮「丟棄未選中分支」的威力。在非模板函式裡,所有分支仍必須能編譯。


七、編譯期計算的優勢與實例

編譯期查找表

最經典的應用:在編譯期把表算好,執行期只做 O(1) 查詢。

#include <array>

constexpr auto make_square_table() {
    std::array<int, 256> table{};
    for (int i = 0; i < 256; ++i) table[i] = i * i;
    return table;
}

constexpr auto squares = make_square_table();   // 256 個值在編譯期算好
static_assert(squares[16] == 256);

編譯期字串雜湊(可用於 switch-on-string)

#include <string_view>
#include <cstdint>

constexpr std::uint32_t fnv1a(std::string_view sv) {
    std::uint32_t hash = 2166136261u;
    for (char c : sv) {
        hash ^= static_cast<std::uint32_t>(c);
        hash *= 16777619u;
    }
    return hash;
}

constexpr auto kConfig = fnv1a("config");   // 編譯期算出雜湊值

// 應用:對字串做「switch」
void dispatch(std::string_view cmd) {
    switch (fnv1a(cmd)) {
        case fnv1a("start"): /* ... */ break;
        case fnv1a("stop"):  /* ... */ break;
    }
}

注意:switchcase 標籤必須是編譯期常數,因此這裡 fnv1a 必須是 constexpr

質數判定與費氏數

constexpr bool is_prime(int n) {
    if (n < 2) return false;
    for (int i = 2; i * i <= n; ++i)
        if (n % i == 0) return false;
    return true;
}

constexpr long long fib(int n) {
    long long a = 0, b = 1;
    for (int i = 0; i < n; ++i) { long long t = a + b; a = b; b = t; }
    return a;
}

static_assert(is_prime(17));
static_assert(!is_prime(18));
static_assert(fib(10) == 55);

constexpr lambda(C++17)

C++17 起,只要 lambda 主體符合 constexpr 規則,它自動就是 constexpr(也可顯式標 constexpr):

constexpr auto square = [](int x) { return x * x; };
constexpr int s = square(9);    // 編譯期:81
static_assert(s == 81);

八、C++20 前瞻:consteval 與 constinit

雖然本章以 C++17 為主(程式以 -std=c++17 編譯),但了解 C++20 的兩個關鍵字有助於完整掌握編譯期程式設計。

consteval — 「立即函式」(immediate function)

constexpr 是「可以在編譯期求值」;consteval 則是「必須在編譯期求值」。以執行期值呼叫 consteval 函式會直接編譯失敗。

// C++20
consteval int force_compile_time(int x) { return x * x; }

constexpr int a = force_compile_time(5);   // OK:編譯期
// int n = read(); int b = force_compile_time(n);  // 錯誤:n 非編譯期常數

constinit — 保證「編譯期初始化」

constinit 保證變數以常數初始化(消除靜態初始化順序問題),但變數之後仍可修改(不像 constexpr 隱含 const)。

// C++20
constinit int counter = 0;   // 編譯期初始化,但執行期可改
關鍵字 編譯期求值 隱含 const 用途
const 不保證 唯讀
constexpr 可以 編譯期常數/雙棲函式
consteval 強制 (函式) 立即函式
constinit 初始化保證 編譯期初始化、執行期可改

九、模板元程式設計簡介(編譯期計算的另一條路)

constexpr 普及前,編譯期計算靠模板元程式設計(Template Metaprogramming, TMP)。經典例子是用模板遞迴算階乘:

template <int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {           // 特化作為遞迴終止
    static constexpr int value = 1;
};

static_assert(Factorial<5>::value == 120);

對比 constexpr 函式版本:

constexpr int factorial(int n) {
    int r = 1;
    for (int i = 2; i <= n; ++i) r *= i;
    return r;
}
static_assert(factorial(5) == 120);

取捨:現代 C++ 中,能用 constexpr 函式就別用 TMP——constexpr 可讀性遠勝、又能在執行期重用。TMP 仍用於需要「操作型別本身」(type-level computation)的場合,例如 std::tuple、型別特性(type traits)的實作。


十、最佳實踐(對照 C++ Core Guidelines)

  • Con.5:對能在編譯期算出的值使用 constexpr("Use constexpr for values that can be computed at compile time")。
  • Con.1 / Con.4:預設讓物件不可變;初始化後不再改變的值用 constconstexpr
  • F.4:若函式可能需要在編譯期求值,就把它標成 constexpr("If a function may have to be evaluated at compile time, declare it constexpr")。但別硬把無法編譯期化的邏輯塞進 constexpr
  • Per.6:別在「沒量測前」就為了效能而過度設計——constexpr 雖好,仍以可讀性與正確性為先。
  • 善用 static_assert:把編譯期不變式(invariant)寫成 static_assert,當作免費的編譯期測試。
  • 記得雙棲性constexpr 函式也會在執行期被呼叫,測試時兩種路徑都要涵蓋。
  • 優先 constexpr 函式而非 TMP:可讀性與重用性更佳。

十一、常見錯誤與陷阱(擴充)

  1. 混淆 const 與 constexprconst int n = f(); 不保證編譯期可用;需要編譯期常數請用 constexpr

  2. 在 constexpr 函式中用了禁止操作:I/O、new/delete(C++20 前)、呼叫非 constexpr 函式,會使編譯期求值失敗。

  3. 以為 constexpr 函式「一定」在編譯期執行:以執行期引數呼叫時它就是普通函式。要強制編譯期請用 constexpr 變數、static_assert 或 C++20 的 consteval

  4. 只測試編譯期情境:忘了 constexpr 函式也可能在執行期被呼叫,導致執行期路徑有 bug 未被發現。

  5. if constexpr 用在非模板:非模板函式裡,所有分支都必須能編譯,得不到「丟棄分支」的效果。

  6. constexpr 物件含 mutable 成員:不允許,constexpr 物件必須完全不可變。

  7. 回傳 auto 的 constexpr 函式型別不一致:如 if constexpr 各分支回傳不同型別時,在非模板中會出錯(模板中因只保留一個分支而 OK)。

  8. 過深的編譯期遞迴:超過編譯器的 constexpr 求值步數上限(如 GCC 的 -fconstexpr-loop-limit/-fconstexpr-ops-limit)會編譯失敗。


重點整理

概念 說明
const 唯讀;值可在執行期確定
constexpr 變數 必須編譯期確定值;隱含 const
constexpr 函式 雙棲:傳常數→編譯期,傳變數→執行期
if constexpr 編譯期分支,丟棄未選中分支(C++17)
constexpr 類別 字面值型別,支援編譯期物件建構
constexpr lambda C++17 起 lambda 預設可為 constexpr
static_assert 編譯期斷言,免費的編譯期測試
查找表 編譯期產生,執行期 O(1) 查詢
consteval(C++20) 強制編譯期求值的立即函式
constinit(C++20) 保證編譯期初始化、執行期仍可改
TMP 以模板遞迴做編譯期計算(多用於型別運算)

練習題

難度標示:基礎 / 中級 / 挑戰。所有程式需能以 g++ -std=c++17 -Wall 編譯,並儘量用 static_assert 驗證結果。

練習 1(基礎):constexpr 質數判定

實作 constexpr bool is_prime(int n),並用至少 4 個 static_assert 驗證(含質數、合數、邊界值 0 與 1)。再寫一個迴圈在執行期印出 100 以內的所有質數,驗證同一函式在執行期也能用。

提示:迴圈只需檢查到 i * i <= n;記得處理 n < 2 的情況。


練習 2(基礎):const vs constexpr 辨析

寫一段程式,宣告以下變數並標註哪些能編譯、哪些不能,並說明原因:

const int a = 10;
constexpr int b = a;        // ?
const int c = std::time(nullptr);  // ?
constexpr int d = c;        // ?
constexpr int e = b * 2;    // ?

提示constexpr 變數的初始化器必須是編譯期常數;const 變數只有在以常數初始化時才算編譯期常數。


練習 3(中級):編譯期費氏數陣列

使用 constexpr 函式產生一個包含前 20 個費波那契數的 std::array<long long, 20>,並用 static_assert 驗證第 10 個元素為 55。印出整個陣列確認。

提示:寫一個回傳 std::arrayconstexpr 函式,在函式內以迴圈填表;C++14 起 constexpr 函式可含迴圈與區域變數。


練習 4(中級):if constexpr 的 to_string

寫一個模板函式 to_string,用 if constexpr 對不同型別(bool、整數、浮點數、std::string)產生不同的轉換邏輯:bool 轉成 "true"/"false",數值用 std::to_string,字串原樣回傳。

提示:用 std::is_same_vstd::is_integral_vstd::is_floating_point_v 做判斷;注意分支順序(bool 也是整數型別,要先判斷)。


練習 5(中級):constexpr Color 編譯期混色

設計一個字面值型別 Color(含 RGB),提供 constexpr 建構子與一個 constexpr blend(const Color&, double t) 做線性插值。用 constexpr 變數在編譯期算出兩色的中間色,並以 static_assert 驗證結果。

提示:所有成員函式與建構子都要標 constexpr;插值 a + (b - a) * t 後記得轉回整數型別。


練習 6(挑戰):編譯期字串雜湊與 switch

實作 constexpr 的 FNV-1a 雜湊函式 hash(std::string_view),並寫一個 dispatch(std::string_view cmd),用 switch 搭配 case hash("start"): 等標籤對字串指令做分派。驗證不同字串得到不同雜湊、相同字串得到相同雜湊。

提示switchcase 必須是編譯期常數,所以 hash 必須是 constexpr;用 static_assert 確認 hash("start") != hash("stop")


練習 7(挑戰):模板元程式設計 vs constexpr

分別用「模板遞迴(TMP)」與「constexpr 函式」實作編譯期的 Power<Base, Exp>power(base, exp),計算整數次方。比較兩種寫法的可讀性,並說明各自適用場景。

提示:TMP 版本用 struct Power<Base, Exp> 與特化 Power<Base, 0> 終止遞迴;constexpr 版本用迴圈即可。兩者都用 static_assert(... == 1024) 驗證 2^10


對應程式碼