潮.C++20 | Concepts - C++ 編譯期檢查的正派道路

TJSW
15 min readApr 25, 2020

--

Photo by Markus Spiske on Unsplash

C++20 四大革命的首位就當屬這位 Concepts,從古時候的 C++0x 開始到當代各種妖魔鬼怪勸退新手的樣板檢查,都要被這位新功能一刀收頭走向官方標準制定的語法正途了。就先來看看 C++ 編譯期檢查到底是指什麼呢?

編譯期檢查顧名思議就是當你 g++ xxx.cpp 的時候就提早噴出一些你想限制的條件導致的錯誤。這些錯誤有可能本身就是語法不合導致的編譯器報錯,或是語法完全正確,但可能在運行期 (run-time) 不合你的業務邏輯。包含但不限於這幾種應用情境

  1. 關於一個類別的物件的語法是否合法,比如物件能不能「相加」。
  2. 一群類別有沒有某個名字的成員函式,比如是否都有 get_name() 函式。也就是 Detection idiom (偵測慣用語)。
  3. 一個函式模板,吃特定幾種型別才能呼叫。限定型別參數的特性 (是不是指標阿,陣列阿…)。
  4. 非型別參數的樣板限定參數的範圍。

族繁不及備載,編譯期檢查只有你想不到,沒有做不到。以下就講講 C++ 20 的 Concepts 和 Constraints 語法來帶到上面幾種應用在 C++20 相比 C++17 之前如何簡潔呈現。

註:以下語法在寫文當下只有 clang 10.0.0 能編譯通過。gcc 9 實做的是 TS 版本的 concept,語法會略有不同。

Concept 是什麼

如同上面列舉的幾種情境,concept 概念,就是一篇描述模板型別 / 非型別擁有哪些特性的語法說明書。不過我們先不帶同學看 concept 的語法,而先帶同學看一下我們需要 concept 的需求是什麼。例如上面提到的,物件能不能「相加」

一個泛型的相加函式:相加兩個參數

上面這代碼非常簡單,泛型函式 sum() 將參數相加傳回。相信同學們一眼就能看出問題在於將結構 S 物件呼叫 sum(s1, s2) 上,因為 S 結構沒有實作運算子重載不能「相加」顯然編譯器會叫你別鬧,然後吐出一大串你看都不想看的廢話。相信這也是同學們使用各種 STL 庫會遇到的錯誤訊息

編譯錯誤時 C++ 編譯器的一連串廢話

而我們的需求就在於:不想看到這堆廢話,也不想因為語法上的錯誤最終讓編譯器報錯,而是我們想從語義上使 sum() 函式的參數擁有我們限定的特性:型別支援相加,也就是 t1 + t2 這表達式合法。Concept 就是在說這件事,讓我們定義自己想要的語義和意圖,變成可被重複利用的小語法。

Simple Requirement

定義上面所說的 Concept 的語法如下:

一個 Concept 的基本宣告語法

這樣的 Concept 語法,稱為 Simple requirement。你可以直觀理解為:一種新的「樣版」叫 concept 我們宣告了他的「變數」叫 addable,而他的值依賴著傳進來的型別 T 有可能為 truefalse,這裡假造了兩個型別 T 的變數 ab,當 a + b 這個表達式合法時addable<T> 的值就會是 true,反之 false。注意!是表達式合法,所以這個 a + b 並沒有任何計算的動作喔。

那麼接下來就是該把這個 addable 的 concept 放上我們的 sum() 了。

就是這麼簡單明暸,從英文角度來看也簡單易懂:sum() 函式樣版需要 (requires) 滿足 addable 這個 concept,concept 的參數就是 sum() 推導的型別 T

回到最上面的例子,sum(s1, s2) 這行現在就會導致編譯器跟你說:

concept1.cpp:28:11: error: no matching function for call to 'sum'
cout << sum(s1, s2) << endl; // !!
^~~
concept1.cpp:12:6: note: candidate template ignored: constraints not satisfied [with T = S]
auto sum(T t1, T t2) requires addable<T>
^
concept1.cpp:12:31: note: because 'S' does not satisfy 'addable'
auto sum(T t1, T t2) requires addable<T>
^
concept1.cpp:8:5: note: because 'a + b' would be invalid: invalid operands to binary expression ('S' and 'S')
a + b;

非常完整明確地表明,S 為什麼不適配 sum() 。比起上面那坨可怕的錯誤訊息真的好多了。

接下來,我們想要限定這個 a+b 他必須可以被印出來,不能說相加完不印出來那不是搞事嗎?在 Concept 框架底下,一切都很無腦直接暴力:

想要 a+b 能被印出來?那就是 cout << a+b 要合法啊!

上面這句話其實包含了兩件事:

  • cout << a + b 這個表達式合法,這個抄上面就好。
  • cout << a + b 的表達式結果型別依然是 ostream&,才能 cout 串接,這點需要 Simple Requirement 語法的第二種形式。

Return-Type Requirement

我們的 addable 變成兩行了,表示要滿足這個 addable concept 必須兩個條件通通滿足缺一不可。其中第二行 { 表達式 } -> Concept 稱為 return-type requirement。表示要求前面的表達式的結果型別要能夠滿足後頭的那個 concept。

後頭的 concept 是自己寫的另一個 is_same_as concept,就只是檢查兩個型別是不是一樣 (藉助 C++ type traits 的 std::is_same)。所以第二行意思就是

  • cout << a + b 要合法。
  • 而且回傳的型別必須是 ostream& 代表原來的那個 cout

所以在 struct S 實作了相加之後,如果今天有人把 Soperator << 這樣亂搞回傳一個 int

int operator << (ostream& os, const S &rhs) {
os << 100;
return 10;
}

那麼編譯在編譯到 cout << sum(s1, s2) << endl 的時候,就會這樣說

concept1.cpp:40:11: error: no matching function for call to 'sum'
cout << sum(s1, s2) << endl; // !!
^~~
concept1.cpp:16:6: note: candidate template ignored: constraints not satisfied [with T = S]
auto sum(T t1, T t2) requires addable<T>
^
concept1.cpp:16:31: note: because 'S' does not satisfy 'addable'
auto sum(T t1, T t2) requires addable<T>
^
concept1.cpp:12:24: note: because type constraint 'is_same_as<int, decltype(cout) &>' was not satisfied:
{ cout << a + b } -> is_same_as<decltype(cout)&>;
^
concept1.cpp:7:22: note: because 'std::is_same_v<int, std::__1::basic_ostream<char> &>' evaluated to false
concept is_same_as = std::is_same_v<T, U>;

哪個條件違反了,原因是什麼全都跟你人性化的說出來啦!

另一個使用 concept 的語法是 ad-hoc 的語法,直接把 concept 的 requires 語句內容貼到樣版後面:

ad-hoc 的套用 constraint 語法

不是打錯字,真的是兩個 requires。個人不是很喜歡這招,因為太醜了,而且型別的限定條文也不能重用。

不過我們還漏了一個還沒提到的 concept 定義語法,就是上面的 is_same_as 定義。

Atomic Constraint

template<typename T, typename U>
concept is_same_as = std::is_same_v<T, U>;

所謂 atomic 就是原子啦,在這邊指的是「最小單位」的 constraint 限制。這種方式的宣告要求的是等號右邊要是編譯期可知的 bool 值,也就是 bool 型別的 constant expression。可以看到我們使用到了標準庫自帶的 std::is_same_v 這個樣版變數來幫助我們宣告一個吃兩個型別參數的 concept。

意思就是當 TU 兩個型別一樣時,這個 concept 就會被滿足,否則不滿足。

事實上這些使用了標準庫的 type traits 的 concept 也跟著 C++20 定義在標準庫裡面,只要 #include <concepts> 就完事了。包含 same_as,integral, constructible_from 等等一堆,可以參考 cppreference。寫文當下,還沒有任一家編譯器有實作標頭檔 <concepts>… 只好自己寫一個順便當教學示範 XD。

和 Variadic Template / Fold Expression 應用

講了一堆樣版,怎麼能不講到現代 C++ 最閃亮的一顆星 variadic template呢!畢竟現代的泛型 C++ 編程有一大部份通過 variadic template 幫助延展了代碼的彈性。

我們今天有幾個類別的定義是這樣子的,有著互相繼承的家族關係。

我想要通過多型機制自動把不定個數的不同衍生型別的指標全部轉成基底類 B 的指標裝在一個 vector 裡,並且迭代這個 vector 呼叫 B 的虛函式 get_name()

利用多型去呼叫不同類別的 get_name

我們在這個 as_vector 函式可以檢查的當然就是:所有的 Args 型別是否都為基底類 B 的小孩。這邊我們需要藉助 C++ type traits 的 std::is_base_of 來判斷一個類別是否為另一個類的小孩。

我們的 atomic constraint 就取名為 is_base_type_of,並且可以接收不定個數個型別。

template<typename Base, typename... Derived>
concept is_base_type_of = (std::is_base_of_v<Base, Derived> && ...);

這邊藉助了 C++17 的 fold expression 一行展開 parameter pack,做到判斷「 Derived 裡的所有型別都要是 Base 型別的小孩」這件事。

把他安裝到 as_vector 上面會有點辛苦,因為我們注意到,我們傳給 as_vector 的參數是一群指標而不是原型別,所以安裝時需要再套一層 std::remove_pointer_t

template<typename... Args>
requires is_base_type_of<B, std::remove_pointer_t<Args>...>
auto as_vector(Args&&... args)
{
return vector<B*>{args...};
}

一但有人這樣子呼叫 as_vector(new B, new D1, new int); 那編譯器的報錯就很明暸了

concept8.cpp:23:14: error: no matching function for call to 'as_vector'
auto vec = as_vector(new B, new D1, new int);
^~~~~~~~~
concept8.cpp:16:6: note: candidate template ignored: constraints not satisfied [with Args = <B *, D1 *, int *>]
auto as_vector(Args&&... args)
^
concept8.cpp:15:12: note: because 'is_base_type_of<B, std::remove_pointer_t<B *>, std::remove_pointer_t<D1 *>, std::remove_pointer_t<int *> >' evaluated to false
requires is_base_type_of<B, std::remove_pointer_t<Args>...>
^
concept8.cpp:12:28: note: because 'std::is_base_of_v<B, int>' evaluated to false
concept is_base_type_of = (std::is_base_of_v<Base, Derived> && ...);
^
1 error generated.

Concept 之間的組合 (Conjunction and Disjunction)

Conjunction

以上定義了一堆 concept,就是一堆編譯期的 truefalse。顯然是可以組合一起做更多重用性更高變化更多的編譯期判斷,用的運算子就是同學最熟悉的 &&||,意思也一樣是「且」和「或」。

假設啦,今天同學們想寫一個在任意型別的 std::vector 裡面找東西的函式。

很簡單易懂吧,不限定 vector 容器裝的型別的泛型找東西函式。這函式 find_vec() 看來又有幾項東西我們可以在編譯期好好限定一下:

  • 這個型別 T 必須能夠被「比較是否相等」,也就是 == 可以套在型別 T 物件上,而且表達結果是一個 bool
  • 型別 T 可以被 cout 印出來,這個 concept 上面寫過了 coutable

有了上面的 addable 經驗,我們現在多寫一個 has_equal 就直覺多了。

has_equal 還有 coutable 組合上我們的 find_vec,就像這樣

重點就在那個 requires has_equal<T> && coutable<T>。意思很簡單,代表同時滿足。而如同一般的邏輯運算,concept 的 &&|| 也是有短路機制的,當 has_equal 不成立時,後面的 coutable 就不會再被編譯器檢查是否滿足。

最後 vector 一定是可以迭代的不要怕,如果今天同學們連容器都想變得泛型,那請自己再檢查 std::begin(cont)std::end(cont) 兩個表達式是否有效 ^^。

Disjunction

再給個例子吧~文章前頭說了,我們也能通過 concept 限定一個樣板函式能被傳入的型別,相信有了以上的例子做基礎,同學們可以很快地反應出「只接受 int, char 兩種特別型別的樣板函式」要怎麼實做。

template<typename T, typename U>
concept is_same_as = std::is_same_v<T, U>;
template<typename T>
void func(T t)
requires is_same_as<int, T> || is_same_as<char, T>
{
}
int main()
{
int n = 10;
int *p = &n;
func(n);
func('R');
func(p); // error
}

非型別樣板參數

前面提了這麼多關於型別的編譯器檢查,其實 concept 的魔手還可以伸到非型別樣板上。我們這次的實作的函式是「取餘的 sum()」。而這個取餘的模就帶在 sum() 的樣板上。

顯然取餘數不能除以 0 對吧~所以為了防止你的開發小夥伴手殘,我們可以在編譯期就做好這個不為 0 的檢查。

小結

今天就先講到這兒了,還有更多複雜的語法可以講,但基本的為什麼要用 concept,還有 concept 的精神,基本語法限制,使用情境都涵蓋到了。有些使用上的好處是能使得編譯器的錯誤訊息更加明確聚焦在我們聚焦的事務上。另外的好處,比如上面的「取餘版 sum」則是把原本可能需要在執行期檢查的 M != 0 移到編譯期檢查。能在編譯期就擋下的錯誤,為何要在執行期和業務邏輯的代碼攪和在一起呢?

經由上面的例子也讓我們看到 C++20 完全是官方的制定了語法來做了一整套各式各樣的編譯期檢查,而不用靠 enable_if_t,SFINAE 的機制, void_t ,再配上預設引數,函式重載騙編譯器這些妖魔鬼怪來做檢查了。讓我們一起大聲的說

走好,enable_if!

--

--