2016年3月1日 星期二

認識SFINAE

作者:Bartlomiej Filipek
原文出處: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函式根據傳入的引數值而被推導出來。      
                所有出現的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時常無法運作順利。
    StackOverlow有相關的討論:為何我該避免在函式簽名中使用std::enable_if 


    取代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替換發生時
    能夠對重載名單裡選出
    一個最佳的函式。

    參考
    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判斷永遠都是否定的...


    沒有留言:

    張貼留言