潮.C++20 | consteval: constexpr 的好兄弟

TJSW
9 min readMay 28, 2022

--

Photo by Scott Graham on Unsplash

C++20 新增了這位 consteval 關鍵字,看來就和 C++11 的 constexpr 大哥有點相似。那麼這位晚了九年誕生的小弟有什麼功能呢?

前情提要 constexpr

如果沒了解過 C++11 constexpr 的同學,可以先去這裡複習一下

懶得複習的同學可以直接看這段代碼,粗略了解一下 constexpr 對我們寫程式的影響。

#include <cstdio>constexpr int fib(int n)
{
if (n <= 0) return 0;
if (n == 1) return 1;
return fib(n - 1) + fib(n - 2);
}
int main()
{
constexpr int f8 = fib(8);
int six = 6;
int f6 = fib(six);
printf("%d\n", f8); // 21
printf("%d\n", f6); // 8
}

簡單的費氏數列遞迴,大家學程式的好朋友。

constexpr 可以幫助我們在編譯期,就算出 f8 的值為 21。但 f6 並不是在常數語境之下呼叫 (參數 six 非常數),所以編譯器幫你編譯成:在運行期幫你真的呼叫 fib(6) 遞迴計算。這點可以由編譯組合語言 (匯編) 看出:

...
mov x9, sp
mov x8, #21
str x8, [x9]
bl _printf
...

所以 consteval 的功能是?

複習完 constexpr 之後就很簡單啦。我們知道掛上 constexpr 的函式有兩種表現型式:

  1. 呼叫時為常數語境,編譯器在編譯期 (compile-time) 幫你全部算出來。
  2. 呼叫時為非常數語境,執行期 (run-time) 真的呼叫函式計算。

consteval 的功能一言以蔽之:就是將函式強制限縮在 constexpr 第 1. 項的功能上。同時也因此要求認定函式傳入參數為編譯期常數 (這兩點對於後面章節的理解是關鍵)。從而讓開發者確保了函式的結果一定是編譯期計算的。

回到上面的代碼,我們試試將 fib()constexpr 替換為 consteval

consteval int fib(int n)
{
if (n <= 0) return 0;
if (n == 1) return 1;
return fib(n - 1) + fib(n - 2);
}
int main()
{
constexpr int f8 = fib(8);
int six = 6;
int f6 = fib(six); // Error
printf("%d\n", f8); // OK, print out 21
printf("%d\n", f6);
}

編譯器會報錯和你說:six 不是一個常數,所以這代碼涼了。

1.cpp:14:11: error: call to consteval function 'fib' is not a constant expression
int f6 = fib(six);
^
1.cpp:14:15: note: read of non-const variable 'six' is not allowed in a constant expression
int f6 = fib(six);
^
1.cpp:13:6: note: declared here
int six = 6;

就這?

如果就這樣,那其實 consteval 這個關鍵字會有點難用沒有彈性。所以以下介紹一些 consteval 的神奇組合,讓同學們更深入理解這個關鍵字。

暫時的非常數呼叫語境

這算是我自創的翻譯吧,看個代碼比較快理解:

#include <cstdio>consteval int max(int a, int b)
{
return a > b ? a : b;
}
consteval int max3(int a, int b, int c)
{
return max(max(a, b), c); // (#1)
}
int main()
{
constexpr int a = max3(34, 56, 78);
printf("%d\n", a); // 78
}

我們寫了一個簡單的 max3() 函式計算三個數最大的數,實作方式是先問前兩數較大者,再和第三數比較誰更大。相信各位同學在大一上完課都能寫得出來。

這裡的關注點在於 max3() 裡面的第 (#1) 行,a, b 轉呼叫 max()結果再和 c 呼叫一次 max()。如果單純看這行,看起來我正在拿各種非常數變數和其運算結果呼叫另一個 consteval 函式 max()。應該要直接報錯的呀!

但標準允許我們創造這種暫時的環境,只要你整個函式的運算情境是在常數語境之下,那就可以通。也就是從 main()constexpr = max3() 一路呼叫下來。

於是編譯器就可以在編譯期直接算出結果是 78。

constexpr 函式和 consteval 函式互動

我們來做個實驗來更加理解 consteval 函式的規範:把 max3()consteval 放寬為 constexpr:(但 max() 仍為 consteval)

consteval int max(int a, int b) { // ... }constexpr int max3(int a, int b, int c)
{
return max(max(a, b), c); // Error, non-constant a, b, c
}

前面提到:consteval 會認定參數 a, b, c 為編譯期常數。但 constexpr 函式因為允許開發者在常數及非常數語境呼叫,所以在這裡,a, b, c 只能當作一般變數看待。因此這邊轉呼叫 consteval 函式 max() 時就直接涼了。

反過來如果 max3() 維持 consteval,但 max() 改掛上 constexpr,這樣則是完全沒有問題的,因為 max3() 認定 a, b, c 為編譯期常數之下,去呼叫 constexpr 函式 max(),肯定就是觸發編譯期的計算。

consteval 函式、函式指標以及 lambda

今天假設我們不只是取三個數的最大值,而是想依傳入的比較函式來決定是最大值或最小值。那我們基本上要傳入一個 C++ 的可呼叫物件 (Callable)。我們可以把上面的 max3(),改寫成這樣的 select3()

consteval int select3(auto f, int a, int b, int c)
{
return f(f(a, b), c);
}

這邊用了 C++20 的縮寫函式模板偷懶接了一個任意型別物件 f 進來。因為我們打算讓這個 select3() 可以接函式指標,也可以接 lambda 等等的東西。

consteval int max(int a, int b)
{
return a > b ? a : b;
}
int main()
{
constexpr int a = select3(max, 34, 56, 78);
auto min = [](int a, int b) { return a < b ? a : b; }; int b = select3(min, 34, 56, 78); printf("%d %d\n", a, b); // 78 34
}

這個例子可以看到,consteval 函式的函式指標以及 (non-capture) lambda,是可以被認定為編譯期常數丟給 consteval 函式 select3() 計算的。上面可以讓編譯器算出 78 和 34。

嚴格區分編譯期計算要幹嘛?

於是我們回到了 consteval 函式的最核心功能。為什麼我需要確保我這函式編譯期就計算出來呢?各種同學可以想想,在軟體工程開發中,哪個環節是我們很確定輸入是我們已知的常數,可以編譯期就知道結果,不需真的上線產品才知道結果呢?

單元測試 unit-test

以下用示意代碼來解釋 consteval 函式怎麼幫助我們寫單元測試。

uint64_t num_in_stock(int merchandise_id) {
auto api_future = http_api->num_in_stock(merchandise_id);

if (auto resp = api_future.get(); resp.ok())
return resp.data("num");

throw Container::exception(...);
}

這是一個要拿取商品庫存的 API,通過發 HTTP 到得到商品的庫存量。在單元測試中:我們通常的做法可能是通過依賴注入 (dependency injection),將 http_api 替換成某個 mock 版本的 api 來操控每個網路 API 接口的回應。

所以我們可能會有這樣的一個函式操作商品和庫存的測試假體 (mock):

consteval uint64_t mock_num_in_stock(int merchandise_id) {
if (merchandise_id == 1)
return 200;

if (merchandise_id == 2)
return 1000;

return 0;
}

配套措拖 if consteval (C++23)

這東西在 constexpr 函式裡面可以讓我們判斷當前的 constexpr 函式是通過常數語境還是非常數語境計算。並且在判斷 true 成立的分支,當作常數語境編譯。所以搭配原本想測試的 num_in_stock() 函式,可以如下操作

constexpr uint64_t num_in_stock(int merchandise_id) {
if consteval {
return mock_num_in_stock(merchandise_id);
} else {
auto api_future = http_api->num_in_stock(merchandise_id);
// ... Handle http response
}
}

注意這裡我們可以在 constexpr 函式中傳入其參數 merchandise_id 給一個 consteval 函式,這是因為前面提到 if consteval 為真的分支是視為常數語境的。

小結

這個新的關鍵字 consteval 讓原本 constexpr 函式的功能更限縮於特定的範圍來計算,雖然是限縮,但是仍然設計了一些彈性。也可以和 C++ 其它非變數的東西來配合串接呼叫, 整體彈性滿高的。後續 C++23 的 if consteval 補足了在 C++20 的不足,讓編譯期的運算更加完備,也幫助我們在單元測試上提供一個新的思路不同以往的依賴注入來完成測項。

--

--