1. 程式人生 > >C++模板進階指南:SFINAE

C++模板進階指南:SFINAE

C++模板進階指南:SFINAE

空明流轉(https://zhuanlan.zhihu.com/p/21314708)

SFINAE可以說是C++模板進階的門檻之一,如果選擇一個論題來測試對C++模板機制的熟悉程度,那麼在我這裡,首選就應當是SFINAE機制。

我們不用糾結這個詞的發音,它來自於 Substitution failure is not an error 的首字母縮寫。這一句之乎者也般難懂的話,由之乎者 —— 啊,不,Substitution,Failure和Error三個詞構成。

 

我們從最簡單的詞“Error”開始理解。Error就是一般意義上的編譯錯誤。一旦出現編譯錯誤,大家都知道,編譯器就會中止編譯,並且停止接下來的程式碼生成和連結等後續活動。

 

其次,我們再說“Failure”。很多時候光看字面意思,很多人會把 Failure 和 Error 等同起來。但是實際上Failure很多場合下只是一箇中性詞。比如我們看下面這個虛構的例子就知道這兩者的區別了。

 

假設我們有一個語法分析器,其中某一個規則需要匹配一個token,它可以是識別符號,字面量或者是字串,那麼我們會有下面的程式碼:

switch(token)
{
case IDENTIFIER:
    // do something
    break;
case LITERAL_NUMBER:
    // do something
    break
; case LITERAL_STRING: // do something break; default: throw WrongToken(token); }

 假如我們當前的token是LITERAL_STRING的時候,那麼第一步,它在匹配IDENTIFIER時,我們可以認為它failure了。

 

但是如果這個Token既不是識別符號,也不是數字字面量,也不是字串字面量的時候,並且,我們的語法規則認為這一條件是無論如何都不可接受的,這時我們就認為它是一個error。

 

比如大家所熟知的函式過載,也是如此。比如說下面這個例子:

struct A {};
struct B: public A {};
struct C {};

void foo(A const&) {}
void foo(B const&) {}

void callFoo() {
  foo( A() );
  foo( B() );
  foo( C() );
}

 那麼 foo( A() ) 雖然匹配 foo(B const&) 會失敗,但是它起碼能匹配 foo(A const&),所以它是正確的; foo( B() ) 能同時匹配兩個函式原型,但是B&要更好一些,因此它選擇了B。而foo( C() ); 因為兩個函式都匹配失敗(Failure)了,所以它找不到相應的原型,這時才會爆出一個編譯器錯誤(Error)。

 

所以到這裡我們就明白了,在很多情況下,Failure is not an error,因為編譯器在遇到Failure的時候,往往還需要嘗試其他的可能性。

 

好,現在我們把最後一個詞,Substitution,加入到我們的字典中。現在這句話的意思就是說,我們要把 Failure is not an error 的概念,推廣到Substitution階段。

 

所謂substitution,就是將函式模板中的形參,替換成實參的過程。C++標準中對這一概念的解釋比較拗口,它分別指出了以下幾點:

 

什麼時候函式模板會發生實參 替代(Substitute) 形參的行為

什麼樣的行為被稱作 Substitution

什麼樣的行為不可以被稱作 Substitution Failure —— 他們叫SFINAE error。

我們在此不再詳述,有興趣的同學可以參照 SFINAE - cppreference.com ,這是標準的一個精煉版本。如果只總結最常見的情況,那就是假設我們有這麼個函式簽名:

template <
  typename T0, 
  // 一大坨其他模板引數
  typename U = /* 和前面T有關的一大坨 */
>
RType /* 和模板引數有關的一大坨 */
functionName (
   PType0 /* PType0 是和模板引數有關的一大坨 */,
   PType1 /* PType1 是和模板引數有關的一大坨 */,
   // ... 其他引數
) 
{
  // 實現,和模板引數有關的一大坨
}

 那麼,所有函式簽名上的“和模板引數有關的一大坨”,基本都是Substitution時要處理的東西(當然也有一些例外)。一個更具體的例子來解釋上面的“一大坨”:

template <
  typename T, 
  typenname U = typename vector<T>::iterator // 1
>
typename vector<T>::value_type  // 1
  foo( 
      T*, // 1
      T&, // 1
      typename T::internal_type, // 1
      typename add_reference<T>::type, // 1
      int // 這裡都不需要 substitution
  )
{
   // 整個實現部分,都沒有 substitution。這個很關鍵。
}

  

嗯,粗糙的介紹完SFINAE之後,我們先來看一個最常見的例子看看它是什麼個行為:

struct X {
  typedef int type;
};

struct Y {
  typedef int type2;
};

template <typename T> void foo(typename T::type);    // Foo0
template <typename T> void foo(typename T::type2);   // Foo1
template <typename T> void foo(T);                   // Foo2

void callFoo() {
   foo<X>(5);    // Foo0: Succeed, Foo1: Failed,  Foo2: Failed
   foo<Y>(10);   // Foo0: Failed,  Foo1: Succeed, Foo2: Failed
   foo<int>(15); // Foo0: Failed,  Foo1: Failed,  Foo2: Succeed
}

 在這個例子中,當我們指定 foo<Y> 的時候,substitution就開始工作了,而且會同時工作在三個不同的foo簽名上。如果我們僅僅因為Y沒有type,就在匹配Foo0時宣佈出錯,那顯然是武斷的,因為我們起碼能保證,也希望將這個函式匹配到Foo1上。

 

實際上,std/boost庫中的enable_if也是借用了這個原理。

 

我們來看enable_if的一個應用:假設我們有兩個不同型別的counter,一種counter是普通的整數型別,另外一種counter是一個複雜物件,它有一個成員叫做increase。現在,我們想把這兩種型別的counter封裝一個統一的呼叫:inc_counter。那麼,我們直覺會簡單粗暴的寫出下面的程式碼:

struct ICounter {
  virtual void increase() = 0;
  virtual ~ICounter() {}
};

struct Counter: public ICounter {
   void increase() override {
      // Implements
   }
};

template <typename T>
void inc_counter(T& counterObj) {
  counterObj.increase();
}

template <typename T>
void inc_counter(T& intTypeCounter){
  ++intTypeCounter;
}

void doSomething() {
  Counter cntObj;
  uint32_t cntUI32;

  // blah blah blah
  inc_counter(cntObj);
  inc_counter(cntUI32);
}

 我們非常希望它可以如我們所願的work —— 因為其實我們是知道對於任何一個呼叫,兩個inc_counter只有一個是正常工作的。“有且唯一”,我們理應當期望編譯器能夠挑出那個唯一來。

 

可惜編譯器做不到這一點。首先,它就告訴我們,這兩個簽名其實是一模一樣的,我們遇到了redefinition。

template <typename T> void inc_counter(T& counterObj);
template <typename T> void inc_counter(T& intTypeCounter);

 所以我們要藉助於enable_if這個T對於不同的例項做個限定:

template <typename T> void inc_counter(
  T& counterObj, 
  typename std::enable_if<
    is_base_of<T, ICounter>::value
  >::type* = nullptr );

template <typename T> void inc_counter(
  T& counterInt,
  typename std::enable_if<
    std::is_integral<T>::value
  >::type* = nullptr );

 關於這個 enable_if 是怎麼工作的,語法為什麼這麼醜,我來解釋一下:

 

首先,substitution只有在推斷函式型別的時候,才會起作用。推斷函式型別需要引數的型別,所以,typename std::enable_if<std::is_integral<T>::value>::type 這麼一長串程式碼,就是為了讓enable_if參與到函式型別中;

 

其次,is_integral<T>::value返回一個布林型別的編譯期常數,告訴我們它是或者不是一個integral,enable_if<C>的作用就是,如果這個C值為True,那麼type就會被推斷成一個void或者是別的什麼型別,讓整個函式匹配後的型別變成 void inc_counter<int>(int & counterInt, void* dummy = nullptr); 如果這個值為False,那麼enable_if<false>這個特化形式中,壓根就沒有這個::type,於是substitution就失敗了 —— 所以這個函式原型根本就不會被產生出來。

 

所以我們能保證,無論對於int還是counter型別的例項,我們都只有一個函式原型是通過了substitution —— 這樣就保證了它的“有且唯一”,編譯器也不會因為你某個替換失敗而無視成功的那個例項。

 

這個例子說到了這裡,熟悉C++的你,一定會站出來說我們只要把第一個簽名改成如下的形式:

void inc_counter(ICounter& counterObj);

 就能完美解決這個問題了,根本不需要這麼複雜的編譯器機制。

 

嗯,你說的沒錯,在這裡這個特性一點都沒用。

 

這也提醒我們,當你覺得需要寫enable_if的時候,首先要考慮到以下可能性:

過載(對模板函式)

偏特化(對模板類而言)

虛擬函式

但是問題到了這裡並沒有結束。因為,increase畢竟是個虛擬函式。假如counter需要呼叫的地方實在是太多了,這個時候我們會非常期望 increase 不再是個虛擬函式以提高效能。此時我們會調整繼承層級:

struct ICounter {};
struct Counter: public ICounter {
  void increase() {
    // impl
  }
};

 那麼原有的void inc_counter(ICounter& counterObj) 就無法再執行下去了。這個時候你可能會考慮一些變通的辦法:

template <typename T>
void inc_counter(ICounter& c) {};

template <typename T>
void inc_counter(T& c) { ++c; };

void doSomething() {
  Counter cntObj;
  uint32_t cntUI32;

  // blah blah blah
  inc_counter(cntObj); // 1
  inc_counter(static_cast<ICounter&>(cntObj)); // 2
  inc_counter(cntUI32); // 3
}

 對於1,因為cntObj到ICounter是需要型別轉換的,所以比 void inc_counter(T&) [T = Counter]要更差一些。然後它會直接例項化後者,結果實現變成了++cntObj,BOOM!

 

那麼我們做2試試看?嗯,工作的很好。但是等等,我們的初衷是什麼來著?不就是讓inc_counter對不同的計數器型別透明嗎?這不是又一夜回到解放前了?

 

所以這個時候,就能看到 enable_if 是如何通過 SFINAE 發揮威力的了。

 

那麼為什麼我們還要ICounter作為基類呢? 這是個好問題。在本例中,我們用它來區分一個counter是不是繼承自ICounter。最終目的,是希望知道counter有沒有increase這個函式。

 

所以ICounter只是相當於一個標籤。而於情於理這個標籤都是個累贅。但是在C++11之前,我們並沒有辦法去寫類似於:

template <typename T> void foo(T& c, decltype(c.increase())* = nullptr);

 這樣的函式簽名,因為假如T是int,那麼 c.increase() 這個函式呼叫並不屬於Type Failure,它是一個Expression Failure,會導致編譯器出錯。所以我們才退而求其次,用一個類似於標籤的形式來提供足夠的資訊。

 

到了C++11,它正式提供了 Expression SFINAE,這時我們就能拋開ICounter這個無用的Tag,直接寫出我們要寫的東西:

struct Counter {
   void increase() {
      // Implements
   }
};

template <typename T>
void inc_counter(T& intTypeCounter, std::decay_t<decltype(++intTypeCounter)>* = nullptr) {
  ++intTypeCounter;
}

template <typename T>
void inc_counter(T& counterObj, std::decay_t<decltype(counterObj.increase())>* = nullptr) {
  counterObj.increase();
}

void doSomething() {
  Counter cntObj;
  uint32_t cntUI32;

  // blah blah blah
  inc_counter(cntObj);
  inc_counter(cntUI32);
}

  

此外,還有一種情況只能使用 SFINAE,而無法使用包括繼承、過載在內的任何方法,這就是Universal Reference。比如,

// 這裡的a是個通用引用,可以準確的處理左右值引用的問題。
template <typename ArgT> void foo(ArgT&& a);

 假如我們要限定ArgT只能是 float 的衍生型別,那麼寫成下面這個樣子是不對的,它實際上只能接受 float 的右值引用。

void foo(float&& a);

 此時的唯一選擇,就是使用通用引用,並增加enable_if限定型別,如下面這樣:

template <typename ArgT>
void foo(
  ArgT&& a, 
  typename std::enabled_if<
    is_same< std::decay_t<ArgT>, float>::value
  >::type* = nullptr
);

 從上面這些例子可以看到,SFINAE最主要的作用,是保證編譯器在泛型函式、偏特化、及一般過載函式中遴選函式原型的候選列表時不被打斷。除此之外,它還有一個很重要的超程式設計作用就是實現部分的編譯期自省和反射。

 

雖然它寫起來並不直觀,但是對於既沒有編譯器自省、也沒有Concept的C++1y來說,已經是最好的選擇了。

 

 

稍後會更新到Github中

 

https://zhuanlan.zhihu.com/p/21314708

 

https://en.cppreference.com/w/cpp/language/sfinae

https://en.cppreference.com/w/cpp/types/enable_if