聊聊 C++ 編譯期能做的事
我自己在意的不是語法炫不炫,而是哪些東西真的值得提早到編譯期做。 這篇挑五個我覺得最常用、也最有感的點來講。
先分清楚:constexpr、const、#define
三者的差異
#define kWL_COST 1.0 // ❌ 預處理器替換,無 type、無 scopeconst double kWL_COST = 1.0; // ⚠ 有 type,但不保證編譯期可用constexpr double kWL_COST = 1.0; // ✅ 有 type、有 scope、編譯期求值為什麼 const 不夠?
const int x = rand(); // ← 合法!const 只保證「不可修改」,不保證編譯期已知constexpr int y = 42; // ← 保證編譯期確定值constexpr 才能用在:if constexpr、template non-type parameter、static_assert
Repo 實例
// src/utils/config.hppconstexpr double kWL_COST = 1.0;constexpr double kVIA_COST = 1.0;constexpr size_t kMAX_BBOX_PADDING = 20;constexpr bool kBUDGET_REWARD = true;唯一用 #define 的地方 - 必須做 #if 條件編譯:
#define GRAPH_FOURIER_FEATURE 1 // 需要 #if,constexpr 做不到編譯產物對比
=== const double COST = 1.0; (-O0) ===
f(x): load COST → reg ← 從記憶體讀取(可能 cache miss) mul x, reg ret
=== constexpr double COST = 1.0; (-O0) ===
f(x): mul x, 1.0 ← 值直接嵌入指令,零記憶體存取 retconst的值存在記憶體,用時要 load;constexpr的值直接嵌進指令。
constexpr 函數到底幫你省了什麼
核心特性
- 所有參數都是編譯期已知 → 整個函數在編譯期求值,結果變 constant
- 參數是 runtime 值 → 退化為普通函數,沒有額外成本
- 同一份 source code,完全不同的機器碼
Repo 實例 - Sorting Network
// src/utils/math.hpptemplate <typename T>inline constexpr std::pair<T, T> TwoMedians(T a, T b, T c, T d) { if (a > b) std::swap(a, b); if (c > d) std::swap(c, d); if (a > c) std::swap(a, c); if (b > d) std::swap(b, d); if (b > c) std::swap(b, c); return {b, c};}為什麼適合 constexpr?- 只有比較和 swap,no heap allocation、no I/O
Repo 實例 - Bitwise 方向反轉
// src/utils/direction.hppconstexpr AxialDir ReverseDir(AxialDir dir) { constexpr uint32_t hor_mask = AxialDir::kLeft | AxialDir::kRight; // 0b0011 constexpr uint32_t ver_mask = AxialDir::kTop | AxialDir::kBottom; // 0b1100 uint32_t dir_val = static_cast<uint32_t>(dir); return static_cast<AxialDir>( dir_val ^ ((dir_val & hor_mask) ? hor_mask : ver_mask));}ReverseDir(kLeft) 在編譯期直接展開為 kRight,不產生任何指令。
編譯產物對比
=== 編譯期:constexpr auto result = TwoMedians(3, 1, 4, 2); ===
result.first = 2 ← 直接寫常數,函數體完全消失result.second = 3 ← 沒有任何比較指令
=== Runtime:auto result = TwoMedians(a, 1, 4, 2); ===
if a > 1: swap(a, 1) ← 生成 5 組比較 + 交換if 4 > 2: swap(4, 2)if a > 4: swap(a, 4)...用 static_assert 把 bug 擋在編譯期
三種 Assert 的比較
| 方式 | 時機 | 生產環境 | 成本 |
|---|---|---|---|
static_assert | 編譯期 | 不可能出錯(build 不過) | 零 |
assert | Runtime (debug) | Release 可能被關掉 | Debug only |
throw / .at() | Runtime (always) | 會觸發 | 每次都檢查 |
Repo 實例 - LUT 大小保護
// src/utils/fast_sincos.hppstatic constexpr size_t TABLE_SIZE = 4096;static_assert((TABLE_SIZE & (TABLE_SIZE - 1)) == 0, "TABLE_SIZE must be a power of 2 for bitmask optimization");後面用 & (TABLE_SIZE - 1) 取餘數 - 如果改成 4000 就 build 不過。
Repo 實例 - STL Concept 檢查
// src/layer/dir_layer.hppstatic_assert(std::bidirectional_iterator<iterator>);少實作 operator-- → 編譯期直接報錯,不會到 runtime 才發現不能用 std::prev()。
編譯產物對比
=== static_assert((4096 & 4095) == 0, "..."); ===
; 輸出:(什麼都沒有); 完全不產生任何機器碼,只在編譯階段做檢查
=== runtime assert((TABLE_SIZE & (TABLE_SIZE - 1)) == 0); ===
load TABLE_SIZE → reg sub reg, 1 and reg, TABLE_SIZE cmp reg, 0 jne crash ← 每次 runtime 都要花幾條指令if constexpr + requires 真正好用的地方
先理解問題
你想寫一個泛型函數,同時處理兩種容器:
std::map<int, int> 的元素 → pair<int,int> → 取 .firststd::set<int> 的元素 → int → 直接回傳為什麼普通 if 不行?
auto get_key = [](const auto& elem) { if (/* 某條件 */) { return elem.first; // ← int 沒有 .first! } else { return elem; }};你可能覺得:set 不會走 if 分支,那就沒問題吧?
錯。 C++ 規則:兩個分支都必須能編譯,即使 runtime 永遠走不到。
error: 'int' has no member named 'first'if constexpr 在做什麼?
auto get_key = [](const auto& elem) { if constexpr (requires { elem.first; }) { return elem.first; // 只有 pair type才會生成這段 } else { return elem; // 只有非 pair type才會生成這段 }};requires { elem.first; } = 編譯期語法測試:這個type能不能寫 elem.first?
普通 if vs if constexpr 的處理流程
普通 if:
template instantiation ↓ 兩條分支都生成 ← 兩邊都要能編譯 ↓ type檢查(兩邊都查) ↓ runtime 選一條
if constexpr:
template instantiation ↓ 編譯期判斷條件 ↓ 只保留 true 的分支 ← false 分支直接丟棄(discarded statement) ↓ 另一條根本不存在 ← 連type檢查都不做實際 instantiation 結果
// 對 std::map<int,int> 呼叫時,編譯器生成:get_key(elem): return elem.first; // else 分支完全不存在
// 對 std::set<int> 呼叫時,編譯器生成:get_key(elem): return elem; // if 分支完全不存在同一份 source code → 編譯器根據type生成完全不同的版本,零 runtime 成本。
以前怎麼做?- C++11 SFINAE
// 需要寫兩個 overload + decltype + SFINAE fallbacktemplate<typename T>auto get_key(const T& elem) -> decltype(elem.first) { return elem.first; }
template<typename T>auto get_key(const T& elem) -> /* SFINAE fallback */ { return elem; }C++20 if constexpr + requires - 一個 lambda 搞定:
auto get_key = [](const auto& elem) { if constexpr (requires { elem.first; }) { return elem.first; } else { return elem; }};if constexpr = 模板世界的 dead code elimination,不是 runtime 分支,而是type導向的編譯期剪枝。用 static inline + IIFE 生成查表(LUT)
這段 code 在做什麼?
static inline std::array<double, 4096> sin_table_ = []() { std::array<double, 4096> table; for (size_t i = 0; i < 4096; ++i) { table[i] = std::sin(2 * std::numbers::pi * i / 4096); } return table;}(); // ← 注意這個 ()拆開看兩件事:
A. []() { ... } ← 定義一個 lambdaB. () ← 立刻呼叫它(IIFE)為什麼要 static inline?
如果在 header 裡直接寫全域變數 - 每個 .cpp include 都產生一份定義 → ODR violation → linker 爆炸。
// ❌ 傳統做法:很麻煩// header.hppextern std::array<double, 4096> sin_table_; // 宣告// impl.cppstd::array<double, 4096> sin_table_ = ...; // 定義(破壞 header-only)
// ✅ C++17 解法:static inline std::array<double, 4096> sin_table_ = ...;inline 變數 = 多個 TU 可以定義,linker 幫你合併成同一份。跟 inline function 同概念。
查表 vs 每次計算
=== LUT 查表:FastSin(angle) ===
index = angle * (4096 / 2π) ← 1 次乘法 index = index & 4095 ← 1 次 AND(1 cycle) return sin_table_[index] ← 1 次 memory load ; 總共 ~3 條指令
=== 每次呼叫 std::sin(angle) ===
; 內部做 argument reduction + polynomial approximation ; ~50-100 條指令,多次乘法、加法、分支 ; 慢 10~20 倍為什麼用 & 4095 而不是 % 4096?
index % 4096 → div 指令 ← ~20-30 cycleindex & 4095 → and 指令 ← 1 cycle因為 4096 = 2^12,所以 4095 = 0b111111111111。前提是 4096 必須是 2 的冪 → 所以 Item 3 才有 static_assert 保護。
Cache 行為:為什麼 4096 剛好?
4096 entries × 8 bytes (double) = 32 KB32 KB ≈ 很多 CPU 的 L1 data cache 大小。表小到可以全部放進 L1 → memory load 很快(~4 cycle)。如果開 1M table → cache miss → 反而變慢。
工程判斷:LUT 適合高頻熱路徑 + 允許精度誤差 + 輸入範圍固定。
進階:C++20 真正的編譯期 LUT
static constexpr auto sin_table_ = []() constexpr { std::array<double, 4096> table{}; for (size_t i = 0; i < 4096; ++i) { table[i] = /* constexpr sin implementation */; } return table;}();整個 table 放進 .rodata section,完全沒有 runtime initialization。但 std::sin 不是 constexpr(標準未規定),所以目前用 inline 版本。
這段 LUT 實作其實只靠三件事
| 技術 | 解決什麼問題 |
|---|---|
inline variable | Header-only 全域變數不違反 ODR |
IIFE [](){}() | 初始化邏輯乾淨寫在一行 |
| LUT 查表 | 用空間換時間 - 把 expensive 計算換成 cheap memory load |
我自己最常用的幾個點
我平常不會為了「看起來很 compile-time」硬寫一堆 template trick。 真正常用的通常就這幾種:constexpr 常數、constexpr 小函數、static_assert, 還有 if constexpr / requires 這種能直接把分支剪掉的工具。
- 常數和純計算,能前移就前移。
- type、size、invariant 這種錯誤,盡量在 build 時就擋掉。
- generic code 真的分型別走不同邏輯時,再用
if constexpr。 - 固定範圍、又很常查的 expensive 計算,才值得做 LUT。
重點不是語法炫,而是把不必留到 runtime 的工作提前做掉。