潮.C++11 | constexpr, constexpr function

TJSW
6 min readMar 3, 2019

--

近代的 C++ 中為我們傳統熟悉的語句和修飾子多了非常多的元素,今天就來聊聊 C++11 開始引入的 constexpr 修飾子的用法吧。

constexpr

是 C++11 對於我們已經熟到透的 const 修飾子的一個加強。 const 大家都知道是代表英文中 constant,常數的意思。代表的是被修飾的變數數值編譯期 (compile-time) 已定,也無法再通過語法修改,任何對於標示為常數的變數的嘗試修改都會造成編譯器報錯。

const int n = 10;
n += 3; // compile error

特別強調通過語法是因為你還是可以通過 buffer overflow 等等不小心寫出來的 bug 或特技表演在運行期 (run-time) 把某個 const 裝飾的變數給炸了。

然而對於一個 const 變數來說,我們正常人類智商都可以很輕鬆的知道,他和另個常數的四則運算表達式都應該也是個編譯期的常數。

就像你今天寫了一個 sq 函式想計算一個數平方。當你傳入一個常數,那麼邏輯上,這個函式的返回值也該是個編譯時期就知道的常數。

int sq(int N) {
return N * N;
}
const int N = 123;
const int SQ_N = sq(N);
printf("%d %d\n", N, SQ_N);

像上面這樣就很抱歉了。他能算出個值初始化 SQ_N,但是卻是發生在運行期而不是編譯期。可以通過 g++ a.cpp -S 編出 assembly 查看的確呼叫了 sq 並且把 123 傳進去計算。(這裡不考慮有些編譯器很屌 -O2 優化上面 sq 的計算還有 SQ_N 的初始化在編譯期直接算完)

這也就是 constexpr 所想滿足的語意:常數表達式 (constant expression)。一堆常數可以在編譯時期經過固定確定運算得到確切值的表達式。除了可以在變數掛上 constexpr ,甚至可以在函式的返回值宣告也可以加上 constexpr 來修飾這個函式變成 constexpr function,讓編譯器在編譯時期就能依照 C++ 標準把能算的都算好。

基本上 C++ 內建型別變數都能掛上 constexprconstexpr 的變數也只能被常數表達式 (常數變數的運算,constexpr function) 來初始化。而上面提到的 constexpr function 則有一些特別的限制。直觀上是直接在返回值前面掛上 constexpr 就好。

constexpr int sq(int n)
{
return n * n;
}
int main()
{
constexpr int N = 123;
constexpr int N_SQ = sq(N);
printf("%d %d\n", N, N_SQ);
}

通過 g++ a.cpp -S 我們查看 assembly 發現的確在編譯時期就計算出來 123 的平方 15129 了。

...
movl $123, %esi
movl $15129, %edx ## imm = 0x3B19
movl $123, -4(%rbp)
movl $15129, -8(%rbp) ## imm = 0x3B19
...

顯而易見 constexpr function 有非常硬的限制。從傳入的參數到中間的運算流程都必須是編譯期確切知道的,不然編譯器根本沒辦幫法幫你算。

比如一開始 constexpr function 裡面是不能出現如 if for 這樣的流程控制的,必須一步到位計算結果,函式體中間也不得出現 n++ 這類的表達式,也不能宣告變數。

你可以把 constexpr function 整體想成一個包起來的一行的表達式。一行表達式裡頭不允許你再用假鬼假怪的方式再內嵌一個子表達式。

到了 C++14 之後就解禁大開放, if 可以寫。反正 if 內的關於參數的邏輯陳述只要也是 constexpr statement,他就會幫你編譯期計算。也可以在函式體裡頭宣告你輔助用的變數。也可以寫超過一個 return 敘述。

constexpr int calc(int n)
{
if (n % 2 == 0) { // C++11 compile error
return n * n;
}
int a = 10; // C++11 compile error return n * n + a; // C++11 compile error
}
int main()
{
constexpr int N = 123;
constexpr int N_SQ = sq(N);
constexpr int N_CALC = calc(N);
printf("%d %d %d\n", N, N_SQ, N_CALC); // 123 15129 15139 printf("%d\n", sq(4)); // 編譯期不會計算 sq(4)
}

但反過來說,constexpr function 並不是只能拿來初始化別的 constexpr 變數,在一般使用情境,你也可以直接拿來當作一個執行期的函式來呼叫完全沒有毛病,也體現了 constexpr function 的彈性。

在 enum, switch 的應用

仔細想想,C++ 這兩個語法中, enum 宣告, switch 邏輯判斷分支都需要吃一堆常數。這也是 constexpr 可以發力的地方。下面這個例子,我們利用編譯期計算常數的特性,把一個 FIB_ENUM 列舉的元素宣告成費氏數列的第五、十、二十項。

constexpr int fib(int n)
{
if (n <= 0) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
enum FIB_ENUM {
a = fib(5),
b = fib(10),
c = fib(20),
dummy = fib(0)
};
int main()
{
FIB_ENUM my_fib = a;
printf("%d %d %d\n", my_fib, FIB_ENUM::b, FIB_ENUM::c);
// 8 89 10946
}

同樣地, switch 的邏輯分支也可以把各個 case 替換成各種 constexpr 的結果。這裡就不再舉例了。

總結地來說,

  1. constexpr 擴展了原本對於 const 的限制,並且明確地給了編譯器更多在編譯時期就可以做的計算空間,讓執行期減少更多不必要的計算。以往需要藉由面目全非的 template meta programming 才能完成的編譯期計算現在可以我們最平常的函式語言就能夠完成。
  2. 也大大地讓程式的語意更完整,不再只有以前 const 那樣子的硬性文法規定,而更加多元地讓「常數」的語義出現在各個地方,讓開發者遵守。
  3. constexpr function 可以用 macro 函式來想像,但是避掉了非常多使用 macro 的困擾,比如定義的展開結尾分號爆炸,忘記對參數加上括號導致不同優先級的運算子攪和在一起…。
  4. 更加彈性地,不只是編譯時期的語義遵守和優化。就算當作一般普通函式在別的上下文呼叫也是完全 OK,完全體現出一套語法但依情境做不同事情。

不只是上面提到的用法 constexpr 還有一些巧妙的應用,下期分曉~

--

--