← Back to Blog
C++PerformanceCompile-time

聊聊 C++ 編譯期能做的事

我自己在意的不是語法炫不炫,而是哪些東西真的值得提早到編譯期做。 這篇挑五個我覺得最常用、也最有感的點來講。

先分清楚:constexprconst#define

三者的差異

cpp
#define kWL_COST 1.0 // ❌ 預處理器替換,無 type、無 scope
const double kWL_COST = 1.0; // ⚠ 有 type,但不保證編譯期可用
constexpr double kWL_COST = 1.0; // ✅ 有 type、有 scope、編譯期求值

為什麼 const 不夠?

cpp
const int x = rand(); // ← 合法!const 只保證「不可修改」,不保證編譯期已知
constexpr int y = 42; // ← 保證編譯期確定值

constexpr 才能用在:if constexpr、template non-type parameter、static_assert

Repo 實例

cpp
// src/utils/config.hpp
constexpr 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 條件編譯:

cpp
#define GRAPH_FOURIER_FEATURE 1 // 需要 #if,constexpr 做不到

編譯產物對比

x86asm
=== 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 ← 值直接嵌入指令,零記憶體存取
ret
const 的值存在記憶體,用時要 load;constexpr 的值直接嵌進指令。

constexpr 函數到底幫你省了什麼

核心特性

  • 所有參數都是編譯期已知 → 整個函數在編譯期求值,結果變 constant
  • 參數是 runtime 值 → 退化為普通函數,沒有額外成本
  • 同一份 source code,完全不同的機器碼

Repo 實例 - Sorting Network

cpp
// src/utils/math.hpp
template <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 方向反轉

cpp
// src/utils/direction.hpp
constexpr 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,不產生任何指令。

編譯產物對比

x86asm
=== 編譯期: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 不過)
assertRuntime (debug)Release 可能被關掉Debug only
throw / .at()Runtime (always)會觸發每次都檢查

Repo 實例 - LUT 大小保護

cpp
// src/utils/fast_sincos.hpp
static 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 檢查

cpp
// src/layer/dir_layer.hpp
static_assert(std::bidirectional_iterator<iterator>);

少實作 operator-- → 編譯期直接報錯,不會到 runtime 才發現不能用 std::prev()

編譯產物對比

x86asm
=== 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> → 取 .first
std::set<int> 的元素 → int → 直接回傳

為什麼普通 if 不行?

cpp
auto get_key = [](const auto& elem) {
if (/* 某條件 */) {
return elem.first; // ← int 沒有 .first!
} else {
return elem;
}
};

你可能覺得:set 不會走 if 分支,那就沒問題吧?

錯。 C++ 規則:兩個分支都必須能編譯,即使 runtime 永遠走不到。

text
error: 'int' has no member named 'first'

if constexpr 在做什麼?

cpp
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 結果

cpp
// 對 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

cpp
// 需要寫兩個 overload + decltype + SFINAE fallback
template<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 搞定:

cpp
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 在做什麼?

cpp
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. []() { ... } ← 定義一個 lambda
B. () ← 立刻呼叫它(IIFE)

為什麼要 static inline

如果在 header 裡直接寫全域變數 - 每個 .cpp include 都產生一份定義 → ODR violation → linker 爆炸。

cpp
// ❌ 傳統做法:很麻煩
// header.hpp
extern std::array<double, 4096> sin_table_; // 宣告
// impl.cpp
std::array<double, 4096> sin_table_ = ...; // 定義(破壞 header-only)
// ✅ C++17 解法:
static inline std::array<double, 4096> sin_table_ = ...;

inline 變數 = 多個 TU 可以定義,linker 幫你合併成同一份。跟 inline function 同概念。

查表 vs 每次計算

x86asm
=== 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 cycle
index & 4095and 指令 ← 1 cycle

因為 4096 = 2^12,所以 4095 = 0b111111111111。前提是 4096 必須是 2 的冪 → 所以 Item 3 才有 static_assert 保護。

Cache 行為:為什麼 4096 剛好?

4096 entries × 8 bytes (double) = 32 KB

32 KB ≈ 很多 CPU 的 L1 data cache 大小。表小到可以全部放進 L1 → memory load 很快(~4 cycle)。如果開 1M table → cache miss → 反而變慢。

工程判斷:LUT 適合高頻熱路徑 + 允許精度誤差 + 輸入範圍固定。

進階:C++20 真正的編譯期 LUT

cpp
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 variableHeader-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 的工作提前做掉。