潮.C++17 | constexpr if (1) 編譯期就知道結果的 if!?

TJSW
8 min readFeb 29, 2020

--

C++ 這語言在 C++17 為最簡單的流程控制 if 加上了一個無比強大的功能增強:constexpr if。語法和用法上只是在 if 後加上 constexpr,但實際意涵和功能邏輯卻是兩個世界的事情。用法甚至還會產生和傳統 if 不同的坑。今天就來講講 “constexpr if” 的用法吧!

望文生義

C++17 的 constexpr if 和 C++11 引入的 constexpr 有很大程度的關係,所以和 constexpr 不太熟的同學們可以先去看看這篇 C++ constexpr 和 constexpr function 的科普

主要的語法和用法非常直觀,小學生都看得懂,長這樣

if constexpr (condition) {
// ...
}
// 或是加上 elseif constexpr (condition) {
// ...
} else {
// ...
}

意思就是 condition 這個條件需要滿足下列兩條件其中一項:

  1. 如果在編譯期 (compile-time) 就能算出 true 或 false。
  2. 或是通過轉型得出 true 或 false。
    也就是文件上所說的 contextually converted constant expression of type bool。中文解釋大致上是說他是情境上可轉換為 bool (這是個專有概念),並且轉換為 bool 的結果是 constant expression。可以參考這篇的編譯期自訂型別轉換得到更實際的代碼範例。

兩條件其一成立,編譯器便會依照出值 true 或 false 編譯 (而不是傳統的執行) if constexpr 內的區塊內容或是 else 的區塊內容。沒有編譯到的區塊內容可以粗略理解為被編譯器丟棄

來幾個例子

講完了照本宣科的條文,總該來點 constexpr if 的例子給大家聞香。

我們怎麼在編譯期產生上面所述的滿足兩條件的 condition 呢?顯然編譯期已知的東西包含:樣版型別,樣版非型別參數,或是 constexpr function 的運算結果等等。以下給出幾個範例:

編譯期的 4 bytes 向上取整對齊

假設我們要處理記憶體相關的分配或是回收,又或是檔案系統的 4k page 讀寫。我們需要在編譯期對一些數字向上取整到對齊 4 的倍數… (我知道這比喻很爛)。

輸出結果不需要特別解釋,國中生就能看懂了。這邊想要特別強調兩點:

  1. 不同的 N 具現化的 upround_to_4<N> 運行期並不會有 if constexprelse 的分支,而是依照具現化的 N 是否能被 4 整除而只保留該部份的 return 數學四則運算式
  2. 這個結果確實是編譯期就得出來的。因為 upround_to_4 是 constexpr function 並且 main() 裡面的 ur8, … ur16 也都掛上 constexpr

通過 g++ a.cpp -S 編譯出組合語言可以發現這些的確是編譯完就算完了。

...
movl $12, -4(%rbp)
movl $12, -8(%rbp)
movl $12, -12(%rbp)
movl $12, -16(%rbp)
movl $16, -20(%rbp)
movl $20, -24(%rbp)
...

既然 constexpr if 在編譯期就能決定要走哪段邏輯的代碼,某種程度上我們也可以把他視為 macro 的替代品,而且與其用 macro 硬要擠在一行面目全非的 #define,constexpr if 完全就是用一般更人性可讀的程式語言在編譯時期就做好邏輯選擇。

搭配 else if 使用

第二個 constexpr if 的範例是:我們今天想要實作個 get_len 函式,有這兩項功能:

  1. 如果傳入參數型別的是原生字串 const char*,那麼回傳長度。
  2. 如果傳入型別是原生陣列,也回傳長度。
  3. 如果其它情況,回傳 1。

這個範例可以看到:

  1. 我們使用 type traits 的 is_same_v<> 判斷一個型別是不是 const char*,並且使用老朋友 strlen() 來計算長度。
  2. 搭配 else if constexpr 使用 std::is_array_v<> 判斷型別是不是原生陣列,並使用 std::size() 計長度。
  3. 其它情況就 return 1;

不管我們怎麼呼叫這個 get_len(),任一時間,任一種樣版的具現化,都只會包含其中一個 else if 條件的分支代碼

同學都有印象小時候老師在黑板上教的是 if else if 但進了業界之後怎麼會是一個 if 搭配一個 else 就完成業務邏輯呢 ^^,接下來給一個更多邏輯變化的用法。

萬用 vector 產生器 — 嵌套 constexpr if

最後來一個高級操作的範例。高能警告:這個範例需要同學們對 C++17、樣版、type traits 有一定的了解,不太熟稔的同學可以簡單看過不要揪結在細節上。

今天我們想要寫一個 std::vector 產生器,功能是這樣子的:

  1. 傳入的是個原生一維陣列,把裡頭的值全部做成一個新的 vector 回傳。
  2. 特別的是:如果這個陣列內含是 const char* 型別,也就是傳入的是一個 C++ 原生字串陣列,為了客戶端方便操作考量,我們做出的 vector 想要包含的是 std::string,也就是 vector<string>
  3. 傳入的是一個變數,直接把他包成變數型別的 vector 回傳。

這個產生器使用上的效果寫成 code 會是這樣

我們想要這個 make_vector() 函式在編譯時期就決定要怎麼產生我們希望的 vector<>。依照上面我們寫的邏輯,再加上一點點的 type traits 來輔助,大概可以歸類一下我們要寫的代碼:

為了讓各式個樣的型別都能傳入,而且回傳的型別也不一定,因此make_vector() 必須是個樣版函式,並且回傳值標上 auto 自動推導:

template<typename T>
auto make_vector(T &t)
{
}
  1. 檢查傳入的型別 T 是否為是原生陣列,使用 std::is_array_v<T> 來做編譯期的判斷。
  2. 我們想檢查陣列內含型別是不是 const char* 。所以要先從陣列型別變為內含型別:使用 std::remove_extent_t<T>。再用 std::is_same_v 判斷和 const char* 一不一樣。一樣的話明確指定構造 vector<string>;不然就交由 vector 的 deduction guide 推導。
  3. 不是陣列的話就直接包進 std::vector 的 constructor。

備註一下:vector 的 constructor 可以吃兩個 iterator 把其間的內容抄一份來構建 vector。所以才會有上面 Step 1. 和 Step 2. 傳入 std::begin()std::end() 的用法。

main() 裡面,依照傳入不同參數呼叫的 make_vector,編譯器會具現化不同的 make_vector<T>

auto v_val = make_vector(val); // make_vector<int>
auto v_arr = make_vector(arr); // make_vector<int [5]>
auto v_str = make_vector(str); // make_vector<const char *[3]>

然而各個樣版的具現化只會包含符合 constexpr if 條件的分支的代碼

同學們是不是覺得 C++17 很簡單呢?當然上面這個 make_vector 還不是那麼健壯。為了方便展示大家看 constexpr if 的邏輯,省略很多檢查的情境。對 C++ 夠熟的同學們可以考慮讓 make_vector() 支援傳入右值,或傳入指標、多維度陣列等等的情況…。

小結

個人覺得 constexpr if 就是個給開發者的小福音。

第一點是開發樣版過程中避開 C++11 出現的各種繁複的 type traits 型別特徵,不用再為了不同型別條件去寫各式各樣的樣版特化,利用 SFINAE 加上 std::enable_if 寫得天花亂墜。

第二是,語法上完美地融入了原有習慣的 if 思考邏輯,使用直述式的直觀語法就能做到以前要寫得滿目瘡痍才能做到的事的代碼。

那當然同學們不太需要開發庫或是樣版的話是不用太在意這個新功能啦…

之後會再給同學們帶來 constexpr if 如何取代 std::enable_if,以及 enable_if 和 SFINAE 的原理。

--

--