以下内容转载medium, 字体为繁体, 不喜欢勿入, 无奈许多博客的介绍都写的太差, 这位台湾小哥是看过写的最清晰最棒的, 由简入深 !
Part1
近代的 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++ 內建型別變數都能掛上 constexpr
, constexpr
的變數也只能被常數表達式 (常數變數的運算,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
的結果。這裡就不再舉例了。
總結地來說,
constexpr
擴展了原本對於const
的限制,並且明確地給了編譯器更多在編譯時期就可以做的計算空間,讓執行期減少更多不必要的計算。以往需要藉由面目全非的 template meta programming 才能完成的編譯期計算現在可以我們最平常的函式語言就能夠完成。- 也大大地讓程式的語意更完整,不再只有以前 const 那樣子的硬性文法規定,而更加多元地讓「常數」的語義出現在各個地方,讓開發者遵守。
- constexpr function 可以用 macro 函式來想像,但是避掉了非常多使用 macro 的困擾,比如定義的展開結尾分號爆炸,忘記對參數加上括號導致不同優先級的運算子攪和在一起…。
- 更加彈性地,不只是編譯時期的語義遵守和優化。就算當作一般普通函式在別的上下文呼叫也是完全 OK,完全體現出一套語法但依情境做不同事情。
Part2
constexpr 的用法中我們提過一個 C++ 函式只要滿足一些簡單的限制,現代 C++ 編譯器就可以幫你在編譯期算出函式的結果。而我們一直沒有提到的類別的 constructor 以及類別的 member function 成員函式甚至是 operator overloading 運算子重載,他也是一個函式啊!是不是也能加上 constexpr
修飾呢?
答案當然是可以,不然這篇就不用寫了。
那麼具體來說 constexpr
加在類別的成員函式們可以幹嘛?就是希望我們開發 者自定義的類別也可以宣告成 constexpr
變數,達到編譯期的運算效果。
#include <cstdio>constexpr int sq(int n) { return n * n; }constexpr CClass cube(const CClass &c) { return c * c * c; }int main()
{
constexpr CClass c1(sq(2));
constexpr CClass c2_6 = cube(c1);
constexpr CClass c3_6 = cube(CClass(sq(3))); static_assert(c2_6.n_ == 64, “!!!”);
static_assert(c3_6.n_ == 729, “!!!”);
}
如果一切順利的話,我們應該要可以算出 c2_6
代表 2 的 6 次方,c3_6
代表 3 的 6 次方,並且最後通過 printf
印出來。
我們觀察一下上面的半殘代碼:
- 上面的
cube()
涉及了對 CClass 物件的「乘法」,很明顯這裡我們需要實作CClass
的乘法 operator overloading。 cube()
還是一個標上constexpr
的函式,代表了裡面的運算元 ©,以及運算 () 都要是編譯時期可知的操作,也就是c * c * c
必須是一個 constant expressionc1
也掛上constexpr
,因此他的初始化操作也必須是一個 constexpr function。
結上所述,我們必須對 CClass
的 constructor,以及 operator()
加上 constexpr
的修飾。
一個 constructor 畢竟代表了整個類別的性質,掛上 constexpr 的 constructor 就代表使用者自定義的類別是一個字面型別 (literal type)。
因此整個類別被拿用初始化 constexpr 變數時 (例如 main()
的 c1
) ,必須確保除了 constexpr constructor 裡面沒有奇怪的操作,而且所有的成員變數都要被初始化列表 (member initialization list) 以編譯時期可知的值初始化。(或是成員宣告直接賦值也行)
完整的 CClass 定義應該是這樣的:
class CClass
{
public:
constexpr explicit CClass(int n) : n_(n) {}
constexpr CClass operator*(const CClass &rhs) const {
return CClass(n_ * rhs.n_);
}
int n_;
};
CClass
的 constructor 完全沒有操作,也初始化了所有,也是唯一的成員變數n_
,因此自然就符合了constexpr
的限制。(可以試試看把 n_ 寫在 constructor 函式體裡面賦值,就會吃 compile error 了)operator*()
就是個成員函式,做了一個constexpr
物件回來,也很乖巧。
所以我們再度利用組語編譯,也能發現編譯器的確把 c2_6
, c3_6
在編譯時期就算出來了。
.section __TEXT,__const
.p2align 2 ## @_ZZ4mainE2c1
__ZZ4mainE2c1:
.long 4 ## 0x4.p2align 2 ## @ZZ4mainE4c2_6
ZZ4mainE4c2_6:
.long 64 ## 0x40.p2align 2 ## @ZZ4mainE4c3_6
ZZ4mainE4c3_6:
.long 729 ## 0x2d9
也可以不要編譯期算
constexpr CClass c2_6 = cube(c1);
constexpr CClass c3_6 = cube(CClass(sq(3)));
CClass c88(88);static_assert(c2_6.n == 64, “!!!”);
static_assert(c3_6.n == 729, “!!!”);printf("%d %d %d\n", c2_6.n, c3_6.n, c88.n);
就像 constexpr function 一樣,根據我們在什麼時候使用,就可以編譯期算,或是執行期才算。比如上面這個 c88
,他就不會是一個在 TEXT 段的常數。而是執行期呼叫 printf()
的時候才把 n
從 register 裡面拿出來壓進 stack 裡呼叫 printf()
。
今天的內容很簡單,就是再把 constexpr
的適用範圍再擴展