學習目標
讀完本章,你將能夠:
- 清楚區分
const(執行期常數)與constexpr(編譯期常數) - 宣告並使用
constexpr變數、函式與建構子(literal type) - 了解 C++11 → C++14 → C++17 對
constexpr函式逐步放寬的限制 - 判斷
constexpr函式何時在編譯期執行、何時退化為執行期函式 - 使用
if constexpr(C++17)做編譯期分支與死碼消除(dead code elimination) - 用
static_assert做編譯期檢查 - 認識 C++20 的
consteval與constinit(前瞻) - 透過範例(階乘、費氏數、質數判定、編譯期字串雜湊、查找表)體會編譯期計算的效能優勢
一、為什麼要在編譯期計算?
傳統程式在執行期(runtime)做所有計算。但有許多值其實在編譯期(compile time)就已確定——例如緩衝區大小、查找表、數學常數、設定參數。把這些計算「搬到編譯期」有四大好處:
- 零執行期開銷:結果直接以常數嵌入二進位碼,程式啟動即可用,無需重算。
- 更早抓錯:搭配
static_assert,把錯誤從「使用者執行時才崩潰」提前到「編譯時就無法通過」。 - 更安全:編譯期常數天生不可變,不會被意外修改,也可安全用於多執行緒。
- 最佳化友善:編譯器握有完整常數資訊,能做更積極的最佳化(如常數摺疊、迴圈展開)。
執行期計算 編譯期計算
┌──────────────────┐ ┌──────────────────┐
│ 程式啟動 │ │ 編譯時就算完 │
│ ↓ 每次都重算 │ │ ↓ │
│ 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/switch、for/while 迴圈、多個 return;可修改區域變數 |
| C++17 | if constexpr、constexpr lambda |
| C++20 | 允許 try/catch(不丟例外)、動態配置(new/delete、std::vector/std::string 於 constexpr 中)、虛擬函式、constexpr 的 std:: 演算法等 |
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::cout、std::cin、檔案) - 動態記憶體配置(
new/delete,C++20 才放寬) - 呼叫非
constexpr的函式(在編譯期求值路徑上) static/thread_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;
}
}
注意:
switch的case標籤必須是編譯期常數,因此這裡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("Useconstexprfor values that can be computed at compile time")。 - Con.1 / Con.4:預設讓物件不可變;初始化後不再改變的值用
const/constexpr。 - F.4:若函式可能需要在編譯期求值,就把它標成
constexpr("If a function may have to be evaluated at compile time, declare itconstexpr")。但別硬把無法編譯期化的邏輯塞進constexpr。 - Per.6:別在「沒量測前」就為了效能而過度設計——
constexpr雖好,仍以可讀性與正確性為先。 - 善用
static_assert:把編譯期不變式(invariant)寫成static_assert,當作免費的編譯期測試。 - 記得雙棲性:
constexpr函式也會在執行期被呼叫,測試時兩種路徑都要涵蓋。 - 優先
constexpr函式而非 TMP:可讀性與重用性更佳。
十一、常見錯誤與陷阱(擴充)
-
混淆 const 與 constexpr:
const int n = f();不保證編譯期可用;需要編譯期常數請用constexpr。 -
在 constexpr 函式中用了禁止操作:I/O、
new/delete(C++20 前)、呼叫非 constexpr 函式,會使編譯期求值失敗。 -
以為 constexpr 函式「一定」在編譯期執行:以執行期引數呼叫時它就是普通函式。要強制編譯期請用
constexpr變數、static_assert或 C++20 的consteval。 -
只測試編譯期情境:忘了
constexpr函式也可能在執行期被呼叫,導致執行期路徑有 bug 未被發現。 -
if constexpr用在非模板:非模板函式裡,所有分支都必須能編譯,得不到「丟棄分支」的效果。 -
constexpr 物件含 mutable 成員:不允許,
constexpr物件必須完全不可變。 -
回傳 auto 的 constexpr 函式型別不一致:如
if constexpr各分支回傳不同型別時,在非模板中會出錯(模板中因只保留一個分支而 OK)。 -
過深的編譯期遞迴:超過編譯器的 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::array 的 constexpr 函式,在函式內以迴圈填表;C++14 起 constexpr 函式可含迴圈與區域變數。
練習 4(中級):if constexpr 的 to_string
寫一個模板函式 to_string,用 if constexpr 對不同型別(bool、整數、浮點數、std::string)產生不同的轉換邏輯:bool 轉成 "true"/"false",數值用 std::to_string,字串原樣回傳。
提示:用 std::is_same_v、std::is_integral_v、std::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"): 等標籤對字串指令做分派。驗證不同字串得到不同雜湊、相同字串得到相同雜湊。
提示:switch 的 case 必須是編譯期常數,所以 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。