c++11-17 模板核心知識(八)—— enable_if<>與SFINAE

張雅宸發表於2020-11-23

引子

class Person {
private:
  std::string name;

public:
  // generic constructor for passed initial name:
  template <typename STR>
  explicit Person(STR &&n) : name(std::forward<STR>(n)) {
    std::cout << "TMPL-CONSTR for '" << name << "'\n";
  }

  // copy and move constructor:
  Person(Person const &p) : name(p.name) {
    std::cout << "COPY-CONSTR Person '" << name << "'\n";
  }

  Person(Person &&p) : name(std::move(p.name)) {
    std::cout << "MOVE-CONSTR Person '" << name << "'\n";
  }
};

建構函式是一個perfect forwarding,所以:

std::string s = "sname";
Person p1(s);            // init with string object => calls TMPL-CONSTR
Person p2("tmp");     // init with string literal => calls TMPL-CONSTR

但是當嘗試呼叫copy constructor時會報錯:

Person p3(p1);    // ERROR

但是如果引數是const Person或者move constructor則正確:

Person const p2c("ctmp");    // init constant object with string literal
Person p3c(p2c);     // OK: copy constant Person => calls COPY-CONSTR


Person p4(std::move(p1));    // OK: move Person => calls MOVE-CONST

原因是:根據c++的過載規則,對於一個nonconstant lvalue Person p,member template

template<typename STR>
Person(STR&& n)

會優於copy constructor

Person (Person const& p)

因為STR會直接被substituted為Person&,而copy constructor還需要一次const轉換。

也許提供一個nonconstant copy constructor會解決這個問題,但是我們真正想做的是當引數是Person型別時,禁用掉member template。這可以通過std::enable_if<>來實現。

使用enable_if<>禁用模板

template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type
foo() {
}

sizeof(T) > 4為False時,該模板就會被忽略。如果sizeof(T) > 4為true時,那麼該模板會被擴充套件為:

void foo() {
}

std::enable_if<>是一種型別萃取(type trait),會根據給定的一個編譯時期的表示式(第一個引數)來確定其行為:

  • 如果這個表示式為true,std::enable_if<>::type會返回:
    • 如果沒有第二個模板引數,返回型別是void。
    • 否則,返回型別是其第二個引數的型別。
  • 如果表示式結果false,std::enable_if<>::type不會被定義。根據下面會介紹的SFINAE(substitute failure is not an error),
    這會導致包含std::enable_if<>的模板被忽略掉。

給std::enable_if<>傳遞第二個引數的例子:

template<typename T>
std::enable_if_t<(sizeof(T) > 4), T>
foo() {
return T();
}

如果表示式為真,那麼模板會被擴充套件為:

MyType foo();

如果你覺得將enable_if<>放在宣告中有點醜陋的話,通常的做法是:

template<typename T,
typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
}

sizeof(T) > 4時,這會被擴充套件為:

template<typename T,
typename = void>
void foo() {
}

還有種比較常見的做法是配合using:

template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;

template<typename T,
typename = EnableIfSizeGreater4<T>>
void foo() {
}

enable_if<>例項

我們使用enable_if<>來解決引子中的問題:

template <typename T>
using EnableIfString = std::enable_if_t<std::is_convertible_v<T, std::string>>;

class Person {
private:
  std::string name;

public:
  // generic constructor for passed initial name:
  template <typename STR, typename = EnableIfString<STR>>
  explicit Person(STR &&n) : name(std::forward<STR>(n)) {
    std::cout << "TMPL-CONSTR for '" << name << "'\n";
  }

  // copy and move constructor:
  Person(Person const &p) : name(p.name) {
    std::cout << "COPY-CONSTR Person '" << name << "'\n";
  }
  Person(Person &&p) : name(std::move(p.name)) {
    std::cout << "MOVE-CONSTR Person '" << name << "'\n";
  }
};

核心點:

  • 使用using來簡化std::enable_if<>在成員模板函式中的寫法。
  • 當建構函式的引數不能轉換為string時,禁用該函式。

所以下面的呼叫會按照預期方式執行:

int main() {
  std::string s = "sname";
  Person p1(s);          // init with string object => calls TMPL-CONSTR
  Person p2("tmp");      // init with string literal => calls TMPL-CONSTR
  Person p3(p1);          // OK => calls COPY-CONSTR
  Person p4(std::move(p1));       // OK => calls MOVE-CONST
}

注意在不同版本中的寫法:

  • C++17 : using EnableIfString = std::enable_if_t<std::is_convertible_v<T, std::string>>
  • C++14 : using EnableIfString = std::enable_if_t<std::is_convertible<T, std::string>::value>
  • C++11 : using EnableIfString = typename std::enable_if<std::is_convertible<T, std::string>::value>::type

使用Concepts簡化enable_if<>

如果你還是覺得enable_if<>不夠直觀,那麼可以使用之前文章提到過的C++20引入的Concept.

template<typename STR>
requires std::is_convertible_v<STR,std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

我們也可以將條件定義為通用的Concept:

template<typename T>
concept ConvertibleToString = std::is_convertible_v<T,std::string>;

...
template<typename STR>
requires ConvertibleToString<STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

甚至可以改為:

template<ConvertibleToString STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

SFINAE (Substitution Failure Is Not An Error)

在C++中針對不同引數型別做函式過載時很常見的。編譯器需要為一個呼叫選擇一個最適合的函式。

當這些過載函式包含模板函式時,編譯器一般會執行如下步驟:

  • 確定模板引數型別。
  • 將函式引數列表和返回值的模板引數替換掉(substitute)
  • 根據規則決定哪一個函式最匹配。

但是替換的結果可能是毫無意義的。這時,編譯器不會報錯,反而會忽略這個函式模板。

我們將這個原則叫做:SFINAE(“substitution failure is not an error)

但是替換(substitute)和例項化(instantiation)不一樣:即使最終不需要被例項化的模板也要進行替換(不然就無法執行上面的第3步)。不過它只會替換直接出現在函式宣告中的相關內容(不包含函式體)。

考慮下面的例子:

// number of elements in a raw array:
template <typename T, unsigned N> 
std::size_t len(T (&)[N]) { 
  return N; 
}

// number of elements for a type having size_type:
template <typename T> 
typename T::size_type len(T const &t) { 
  return t.size(); 
}

當傳遞一個陣列或者字串時,只有第一個函式模板匹配,因為T::size_type導致第二個模板函式會被忽略:

int a[10];
std::cout << len(a);        // OK: only len() for array matches
std::cout << len("tmp");      // OK: only len() for array matches

同理,傳遞一個vector會只有第二個函式模板匹配:

std::vector<int> v;
std::cout << len(v);    // OK: only len() for a type with size_type matches

注意,這與傳遞一個物件,有size_type成員,但是沒有size()成員函式不同。例如:

std::allocator<int> x;
std::cout << len(x);     // ERROR: len() function found, but can’t size()

編譯器會根據SFINAE原則匹配到第二個函式,但是編譯器會報找不到std::allocator<int>的size()成員函式。在匹配過程中不會忽略第二個函式,而是在例項化的過程中報錯。

而使用enable_if<>就是實現SFINAE最直接的方式。

SFINAE with decltype

有的時候想要為模板定義一個合適的表示式是比較難得。

比如上面的例子,假如引數有size_type成員但是沒有size成員函式,那麼就忽略該模板。之前的定義為:

template<typename T>
typename T::size_type len (T const& t) {
    return t.size();
}


std::allocator<int> x;
std::cout << len(x) << '\n';       // ERROR: len() selected, but x has no size()

這麼定義會導致編譯器選擇該函式但是會在instantiation階段報錯。

處理這種情況一般會這麼做:

  • 通過trailing return type來指定返回型別 (auto -> decltype)
  • 將所有需要成立的表示式放在逗號運算子的前面。
  • 在逗號運算子的最後定義一個型別為返回型別的物件。

比如:

template<typename T>
auto len (T const& t) -> decltype( (void)(t.size()), T::size_type() ) {
    return t.size();
}

這裡,decltype的引數是一個逗號表示式,所以最後的T::size_type()為函式的返回值型別。逗號前面的(void)(t.size())必須成立才可以。

(完)

朋友們可以關注下我的公眾號,獲得最及時的更新:

相關文章