原文出處:http://www.bfilipek.com/2016/02/notes-on-c-sfinae.html
Notes on C++ SFINAE
這次我想對付稍微複雜的問題: SFINAE。 雖然不是常用到它,但我曾因為它而跌倒好幾次,
所以它應該是值得去了解。
- 什麼是SFINAE?
- 我能在哪用得上它?
- 常常用到它嗎?
讓我們一起試著回答上面的疑問吧。
簡介
第一件事:如果你有很多時間,請讀讀Jean Guegant所著的An introduction to C++’s SFINAE concept: compile-time introspection of a class member
這個是我在別處找不到更深度討論SFINAE的相關文章,強烈推薦。
你願意繼續看我寫的啊?我好高興!:) 讓我們先從SFINAE背後的一些核心概念著手 。
其實很簡短: 編譯器「拒絕處理某一型別」所形成的程式碼。
維基百科寫道:
替換失敗並非錯誤(Substitution failure is not an error)(簡寫SFINAE)是指
替換失敗的Template參數其本身並非錯誤。 David Vandevoorde是第一個使用
SFINAE這個縮寫來講解此編程技術。
我們將會在這討論Template相關的東西,光是Template替換和相關的編譯時期...可能就會嚇死人了 。
一個小範例:在coliru編譯結果
struct Bar
{
typedef double internalType;
};
template <typename T>
typename T::internalType foo(const T& t) {
cout << "foo<T>" << endl;
return 0;
}
int main()
{
foo(Bar());
foo(0); // << error!
}
我們現在有個返回型別是T::internalType的函式,然後用int跟Bar來當成函式的參數型別。
這個程式碼想當然,不能編譯啦。第一個foo(Bar());呼叫是會產生合理的結構,但第二個呼叫就無法編譯且出現以下錯誤訊息(GCC):
no matching function for call to 'foo(int)'
...
template argument deduction/substitution failed:
我們簡單補上適合int運作的同名函式:
int foo(int i) { cout << "foo(int)" << endl; return 0; }
能成功編譯跟執行了。
為何這樣就順利了呢?
很明顯能看出,我們針對int寫出重載函式後,編譯器就找到最吻合的函式了。
但編譯過程中,編譯器也看到Template函式了。這函式對int來說是無效的,
編譯器為何甚至連個相關警告都沒有呢(像是當我們提供了第二個函式後),
為了瞭解原因,我們需看看「從函式呼叫所建立的重載決議名單」的過程。
函式重載決議(Overload Resolution)
當編譯器試著編譯一個函式呼叫時,會(我把步驟精簡化了):
- 啟動函式名稱的搜索。
- Template函式根據傳入的引數值而被推導出來。
當這推導過程引出之無效型別(像int::internalType)的函式,會從函式重載決議裡剔 除掉。(SFINAE)
- 最後我們有了一份特定函式呼叫所形成的「可行函式名單」。如果名單是空的,那麼就無法編譯。如果這名單有1個以上的函式被選中,那我們陷入模擬兩可的局面(譯:仍無法編譯)。一般情況下這些候選者中,參數能最吻合引數的函式只能有一個。
(譯:整個圖是精簡過的重載決議過程)
我們的範例: typename T::internalType foo(const T& t) 並不是吻合int的函式,
所以被重載決議否決了。但在最後,int foo(int i) 是這裡面唯一的選擇,所以編譯器
不會回報任何問題。
我能在哪運用它?
我希望您已理解SFINAE的基礎概念,但我們能在哪用到這項技術呢?
一般的回答都是:不管何時,我們想選出「能適合指定型別」的函式/特化。
一些例子如下:
- 呼叫函式時,函式內會呼叫參數型別T的方法(像呼叫toString()時,函式內會呼叫T的toString方法)。
- tackoverFlow網站有極佳的例子,檢驗傳進建構式裡初始列(initializer list)中的物件數量。
- 針對全部種類的型別特性而特化的函式(is_integral, is_array, is_class, is_pointer, etc… 更多型別特性介紹)
- Foonathan的部落格: 有個例子是如何計算輸入的算數型別占有幾bits。SFINAE是這一解決方案的某部份(跟Tag dispatching技術一起)。
- Foonathan部落格的其他例子: 如何運用 SFINAE 和 Tag dispatching 在一塊「尚未建構內容物的記憶體配置空間」內建構出有個數範圍的物件們。
enable_if
主要使用到SFINAE的某一應用就是enable_if運算式。
enable_if是一套內建了SFINAE的工具,它們允許接受或排斥「可能的Template函式重載」或class Template的特化體。
舉個例子:
template <class T>
typename enable_if<is_arithmetic<T>::value, T>::type
foo(T t)
{
cout << "foo<arithmetic T>" << endl;
return t;
}
這函式靠著算術型別T運作著(int, long, float…)。如果你使用別的型別(像MyClass具現體),函式將無法具現化。也就是說會從重載決議裡「拒絕非算術型別」的Template具現體。enable_if能用在template參數列,函式參數,函式返回的型別。
在enable_if<condition, T>::type, condition結果為true能產生出型別T,或condition為false話,,則會拒絕替換。
enable_if能與其他type traits工具提供「以型別特性做為準則」的最佳函式版本。
就我看來, enable_if在大多數時候都勝過你自己刻的SFINAE程式碼。
enable_if運算式看起來並不是很美觀,在C++17或C++20的Concept出現前,我們也只能這樣了。
SFINAE運算式
C++11加入了以SFINAE所做出更複雜的選擇。
基本上,這文件已釐清複雜的規範,並讓你能夠在decltype和sizeof中使用運算式。
舉個例子:
template <class T> auto f(T t1, T t2) -> decltype(t1 + t2);
上面的例子中,t1+t2的運算式需要被型別檢驗。它對兩個int做出推斷( + operator 的返回型別仍是int),但decltype推斷結果並不會是int和std::vector。
編譯器加入了更複雜的運算式型別檢驗。關係到前面所提簡單的Template參數替換所需的重載決議。現在,編譯器需要關注運算式和執行全部語意檢查。
BTW: VS2013 和 VS2015 只支援了部份功能(msdn張貼於此相關的一份VS2015 update1),一些運算式能檢驗,一些(可能更複雜)卻辦不到。Clang (2.9版) and GCC (4.4版)編譯器則能處理全部的SFINAE運算式。
它有缺點嗎?
SFINAE跟enable_if都是非常強大的特色,但卻難以用得正確。簡單的例子或許能運行,
但在真實的情況下你會遇到以下幾種問題:
- Template錯誤訊息:你喜歡編譯器產生的Template錯誤訊息嗎?尤其是STL型別參與時。
- 可讀性。
- 在有enable_if的程式碼區段裡,裡面的Template時常無法運作順利。
取代SFINAE的方案
- tag dispatching - 更明瞭的函式呼叫挑選。我們可定義一個核心的函式,然後呼叫 「依賴於編譯期條件判斷」而選出此函式的A或B版本。
- static_if - D語言有這個功能(see it here),但在C++我們得用稍微複雜些的語法來得到類似的效果。
- concepts(希望近期能出現!) - 現有解決方案都是非法入侵的手法。Concepts透過一種明瞭方法來表達所能接受編譯的型別。你可在GCC trunk中體驗Concepts劣質實作品。
應用小例子:
整合我上述提到的東西,就能做出能良好運作的小程式,並看看SFINAE如何被使用。
使用SFINAE的class:
template <typename T>
class HasToString
{
private:
typedef char YesType[1];
typedef char NoType[2];
template <typename C> static YesType& test( decltype(&C::ToString) ) ;
template <typename C> static NoType& test(...);
public:
enum { value = sizeof(test<T>(0)) == sizeof(YesType) };
};
上面template class被用來測試型別T中有無ToString()方法。
我們看看程式碼有什麼東西...和哪裡用到SFINAE概念?你看得出來嗎?
當我們想測試型別T時,就需寫:
HasToString<T>::value
當我們把T換成int時會發生什麼事呢?它會產生類似文章開頭的範例一樣。
編譯器將嘗試template替換並在這段敘述失敗了:
template <typename C> static YesType& test( decltype(&C::ToString) ) ;
很明顯並沒有int::ToString方法,所以第一個test重載方法將從重載決議名單剔除。
但另一方面,第二個方法(NoType& test(...))就被採用了,因為它能被其他型別所呼叫。
所以我們得到SFINAE了!第一個方法被拒絕,第二個對int是有效的。
在class區塊末端計算enum的值:
enum { value = sizeof(test<T>(0)) == sizeof(YesType) };
會返回NoType並計算它的大小,sizeof(NoType)不同於sizeof(YesType),最後value為0。
我們提供和測試以下class會發生什麼事呢?
class ClassWithToString
{
public:
string ToString() { return "ClassWithToString object"; }
};
現在template替換會產生兩個候選人: 兩個test方法都是有效的,
第一個方法勝出且將被拿來使用(譯:因為...「省略號」參數是重載優先序中最低的)。我們得到YesType且最後HasToString<ClassWithToString>::value返回1的結果。
如何使用這種負責檢驗的class?
理想上它能被靈巧地寫在if敘述中:
if (HasToString<decltype(obj)>::value)
return obj.ToString();
else
return "undefined";
不幸的是,我們通常想從編器期獲得檢查結果而非執行期if。
不過我們能用ebable_if和兩個函式來達成:一個是能接受擁有ToString方法的class,
另一個則接受缺少ToString的class。
template<typename T>
typename enable_if<HasToString<T>::value, string>::type
CallToString(T * t) {
return t->ToString();
}
string CallToString(...)
{
return "undefined...";
}
SFINAE再一次出現在上面程式碼中,當你送入型別到 HasToString<T>::value產生false後,enable_if 將拒絕具現化第一個CallToString函式。
還有些尚未解決的問題:如何約束ToString方法的返回型別? 完整的函式/方法簽名實際上應該要...?
總結
在這篇文章裡,我稍微展現SFINAE背後的原理。使用此技術(加上enable_if)能夠「創造對一些型別起作用」的特化函式。SFINAE能對重載決議名單作出裁決。我們可能大部份用不到SFINAE。但了解它背後的通用規則是蠻實用地。
請記得:
SFINAE只運作在編譯期,
當Template替換發生時,
能夠對重載名單裡選出
一個最佳的函式。
參考
- Working Draft, Standard for Programming Language C++, 14.8.2 ( [temp.deduct]), read the current working standard here
- paragraph 8 in that section lists all possible reasons that type deduction might fail.
- Overload resolution, cppreference.com
- C9 Lectures: Stephan T. Lavavej - Core C++ - part 1, s and 3 especially.
- To SFINAE or not to SFINAE
- MSDN: enable_if Class
- foonathan::blog() - overload resolution set series
- Akrzemi C++ Blog: Overload resolution
Thanks for comments: @reddit/cpp thread
譯者補充:
如果覺得最後一段Template返回不美觀,且太沉長的話,
可以用C++11跟14新增的語法來試圖簡化。
原本的程式碼:
template<typename T>
typename enable_if<HasToString<T>::value, string>::type
CallToString(T * t) {
return t->ToString();
}
修改後:
template<typename T>
enable_if_t<HasToString_v<T>, string>
CallToString(T * t) {
return t->ToString();
}
先從
typename enable_if<condition ,T>::type說起,在C++14標準裡,已寫好enable_if_t了,此方式優點就是可以省略開頭的typename跟尾端的::type,就變成enable_if_t<condition ,T>。萬一使用的不是C++14的話,也可以用11的Template aliases語法輕鬆達成:
template< bool B, typename T = void >
using enable_if_t = typename enable_if<B,T>::type;
而HasToString<T>::value,可在C++14的Variable Templates語法下達成HasToString_v<T>效果(C++17會在type_traits裡的組件加入此種表達方式):
template<typename T>
constexpr bool HasToString_v=HasToString<T>::value;
要注意一定要寫constexpr,因為enable_if是在編譯期就下判斷了。
如果沒寫,enable_if判斷永遠都是否定的...
參考出處: Effective Modern C++ 跟 cppreference.com
沒有留言:
張貼留言