潮.C++ | Detection Idiom 偵測語義:物件能不能轉型成字串?Expression SFINAE

TJSW
12 min readApr 25, 2021

--

Photo by nine koepfer on Unsplash

同學們大家好,今天要講的 Detection Idiom 偵測語義以及 Expression SFINAE 是一系列 C++ 的小技巧:偵測一個物件是否具有某(一些)函式。

為何要偵測

同學們可能有疑問:知道物件有沒有某個函式能幹啥?呼叫他?我直接寫 code 呼叫不就行了?我自己寫的 code 我會不知道物件有沒有某些函式嗎?

這些問題並不是沒有道理,不過一切一切都來自於:我們想要用統一的外殼包裝一件功能,可以接受各式各樣的物件輸入,但底下依據不同的物件型別去完成這個外殼所表達的功能

所以偵測的用意其實是,判別不同物件型別的特性,選擇實作適當的對應邏輯完成外殼封裝底下的功能。

從變數轉換成字串開始

熟悉 STL 的學霸一定都知道有個神奇的函式叫做 std::to_string()。他可以吃很多有的沒的數字 (int, double, float) 吐出 std::string 字串。

支援更多型別

假設今天同學們想實作一個泛型的 ToString() 函式,為了支援更多除開 to_string() 支援的數字變數型別,所以我們寫成一個函式模板 (function template) 接受 universal reference

template<typename T>
std::string ToString(T&& t)
{
return std::to_string(t);
}
int main() {
int a = 10;
double d = 1.67;
std::cout
<< ToString(a) << std::endl // 10
<< ToString(d) << std::endl // 1.67000
;
}

像這樣,目前只包裝了 STL 的 to_string() 簡單轉呼叫一次。

物件自帶轉字串方法

實作泛型的函式就是為了接受更多的苦難,喔不是,是更多型別。今天我們自訂的型別 S 就已經自帶了一個成員函式叫做 ToString()。長得就像這樣

struct S
{
S(std::string a) : a(std::move(a)) {}
std::string ToString() { return a + "!!!!"; }
std::string a;
};

這個 SToString() 轉換就只是把成員加上幾個驚嘆號吐出來相信很簡單易懂的。

Expression SFINAE

今天我們想要針對擁有 ToString() 成員函式的物件自動呼叫物件的 ToString();而其他型別還是呼叫 std::to_string()。同學們,這不就是 SFINAE 做能做的事情嗎?在編譯期 (compile-time) 根據傳入型別的不同特性,特化 (template specialize) 不同的函式樣板實作不同邏輯。

只是今天我們判斷依據是,型別有沒有一個函式,也就是依據一個表達式來做 SFINAE 的條件,這就是這個技巧的命名由來 expression SFINAE。

如何判斷有沒有 ToString() 成員函式?

Expression SFINAE 需要用到的小工具是 C++11 引入的 std::declval 還有 decltype

  • std::declval<T>() 允許你在 T 型別沒有預設 constructor 的情況,不用傳參數就做出一個 T 的右值引用 rvalue reference T&&
  • decltype 相信大家相對熟悉惹,就是得到一個表達式 expression 的結果型別。

利用 SFINAE 的特性,組合起來大概概念是這樣的

  • 當傳入 ToString(t)t 呼叫 t.ToString() 是合法的,也就是這個 expression t.ToString() 合法會動,我們會得到一個回傳型別,用這個型別去特化一個 function template 在裡面呼叫 tToString()
  • 不然就判斷 std::to_string(t) 這個表達式 484 合法的,特化另外一個 ToString() 的實作邏輯。

直接上代碼看看

template<typename T>
auto ToString(T&& t) -> decltype(std::to_string(t)) {
return std::to_string(t);
}
template<typename T>
auto ToString(T&& t) -> decltype(std::declval<T>().ToString()) {
return t.ToString();
}
int main() {
int a = 10;
double d = 1.67;
S s{"Bambi"};
std::cout
<< ToString(a) << std::endl // 10
<< ToString(d) << std::endl // 1.67000
<< ToString(s) << std::endl // Bambi!!!!
;
}

這邊藉助了 C++11 的 trailing return type 格式,讓函式的回傳型別寫在尾巴,其實同學們高興像傳統那樣寫在前面也無傷大雅。我只是喜歡把長一點的回傳型別寫在後頭看起來兩個 ToString() 的特化比較整齊。

簡單來說上面的代碼

  • 第一個 ToString() 的特化回傳型別是 decltype(std::to_string(t))。也就是一個字串 std::string但是前提是 t 代進去呼叫合法
  • 第二個 ToString() 的特化回傳型別是 decltype(std::declval<T>().ToString())只要當 T 型別呼叫 ToString() 合法,就會掉進來這個特化的分支。

這邊可以發現使出了 decltype 只是用來輔助將裡面的表達式拿到型別,變成函式回傳型別。

如此一來我們就成功完成了偵測物件有沒有 ToString() 啦!可喜可賀!

偵測物件有沒有自帶轉型 operator

今天我們有一個新的物件他有點炫炮,他實作了轉型運算子 (casting operator)。而轉型的目標型別就是 std::string。所以我們手癢了,也想要讓我們的 ToString(T&&) 偵測一下去呼叫。

這個物件是長得這樣的

struct T
{
T(std::string a) : a(std::move(a)) {}
operator std::string() { return "[" + a + "]"; }
std::string a;
};
T t{"Candy"};
std::string str_t = static_cast<std::string>(t); // [Candy]

一旦你對 t 強制轉型成 C++ 字串:static_cast<std::string>(t),他就會加上一個方括號送給你。很簡單吧。好所以同學們已經看到了關鍵的轉型表達式,要怎麼偵測相信不是難事了,就把上面的偵測方法 decltype 裡面的 expression 換一下就成了。

template<typename T>
auto ToString(T&& t) -> decltype(static_cast<std::string>(t)) {
return static_cast<std::string>(t);
}
int main() {
int a = 10;
double d = 1.67;
S s{"Bambi"};
T t{"Candy"};
std::cout
<< ToString(a) << std::endl // 10
<< ToString(d) << std::endl // 1.67000
<< ToString(s) << std::endl // Bambi!!!!
<< ToString(t) << std::endl // [Candy]
;
}

Expression SFINAE 的另個流派:enable_if 和 void_t

講到 SFINAE,有經驗的同學可能會想到 std::enable_if 去做分支特化。不是不行,只是在 expression SFIANE 的情況需要再加上 void_t 這個輔助用的型別樣板,再把 ToString() 參數多加上一大坨東西去做預設參數讓他匹配函式多載;不是我喜歡的樣子,有興趣的同學可以隨意 google 一下,這邊就不展開了。

判斷物件的 ToString() 回傳是不是正常的

如果今天同學們怕你的同學搞事,他的物件實作了一個 ToString() 回傳型別是一個 std::vector<int>,那我肯定不能拿來當字串用啊。這個時候別怕,加上點小技巧,就可以跟著 expression SFINAE 一起判斷。

同學們記得上面,我們的模板分支特化回傳型別利用了 decltype 小技巧將表達式的型別變成函式回傳型別。這邊我們多做一件事情,把 decltype 的回傳型別拿來檢查能不能建構一個 std::string,這邊會藉助一個 C++ type trait 中的 std::enable_ifstd::is_convertible。不熟悉 enable_if 的同學可以到這裡看看

template<typename T>
using EnableIfConvertibleToStringT =
std::enable_if_t<
std::is_convertible_v<T, std::string>, std::string>;
template<typename T>
auto ToString(T&& t) -> EnableIfConvertibleToStringT<decltype(std::declval<T>().ToString())> {
return t.ToString();
}

這樣的效果是:當傳入的 T 型別符合

  • 可以呼叫 ToString()
  • 且呼叫 ToString() 的回傳型別可以建構 std::string

那麼這個特化的 ToString() 的回傳型別就是 std::string。例如如果某個 T 的 ToString() 回傳的是某個 const char* 指標,那他就會被丟進去 std::string 的 constructor 做出一個字串回傳出來。

C++20 現代的 detection idiom 做法

對於「某一條表達式 484 合法,以及他的回傳型別有沒有符合某些需求」,這件事,略懂 C++20 的同學可能知道這其實就是 Concepts 最擅長做的事。沒有看過 Concepts 的同學可以先到這裡看看再往下看比較會懂。

以下我會給出上面文章提到的三種需要偵測檢查的表達式在 Concept 的限制寫法。

template<typename T>
concept StdToStringAble = requires (T t) {
std::to_string(t);
};
template<typename T>
concept SelfToStringAble = requires (T t) {
{ t.ToString() } -> std::convertible_to<std::string>;
};
template<typename T>
concept StaticCastToStringAble = requires (T t) {
static_cast<std::string>(t);
};

相對簡明很多吧!不用 decltype 還有 declval 的冗餘技巧就做到這三類的檢查

  • 可否呼叫 std::to_string()concept StdToStringAble
  • 物件 484 有成員函式 ToString();而且呼叫得到的回傳型別能建構一個 std::stringconcept SelfToStringAble
  • 物件 484 有 std::string 的 casting operator;能轉型成 std::stringconcept StaticCastToStringAble

再利用一下 C++20 在 template function 的語法糖,我們就可以不用寫一堆反覆的 template<T> 還有 T&& 了。也就是 type-constraint auto。長得就會像這樣子:

auto ToString(StdToStringAble auto t) {
return std::to_string(t);
}
auto ToString(SelfToStringAble auto t) {
return t.ToString();
}
auto ToString(StaticCastToStringAble auto t) {
return static_cast<std::string>(t);
}

簡直就是變身成為另一個語言啦,恭喜各位同學在現代化 C++ 的路上又往前邁進了一小步。

小結

其實這是小弟我一個改善工作上的項目內一個小工具所用到的其中一個小技巧,剖析乾淨之後拿出來給各位同學分享。

畢竟產業上並不是說 C++20 出來了馬上就能用在產品發行環境上。所以分享了 C++11 時代的作法 (雖然偷吃步用了一點 std::enable_if_t 還有 std::is_converitble_v 屬於 C++14 的變量模板啦)讓同學們動點腦筋用邏輯組成的方式去理解搞出 detection idiom,個人還是很喜歡這個過程的。

不過後來 C++20 的語法糖也是另一個劃時代的發明,把繁複且反覆的模板語法濃縮掉變成 C++11 已有的 auto 關鍵字,賦予 auto 更多的設定但又不超越原有語義,又巧妙配合了 Concepts 的型別限制,實在讓讀整個代碼的過程乾淨利索又直覺啊!這期就講到這兒了大家掰掰

--

--