C++ 17 if constexpr 述句

C++ 17 新增的 if constexpr 述句能讓我們用更簡潔的方式編寫樣版特化(Template Specialization)。本文會介紹它的語法與應用。

語法

if constexpr 述句的語法與一般的 if 述句相似。兩者的差異在於 if 關鍵字與刮號之間多了一個 constexpr 關鍵字:

if constexpr ( condition ) then-statement

if constexpr ( condition ) then-statement else else-statement

其中,condition 必須是編譯期能確定數值的 constexpr 且該數值必須能被轉型為 boolconstexpr 包含以下常用的表達式:

  • <type_traits> 標頭檔定義的 Type Traits:
    • std::is_integral_v<T>
    • std::is_array_v<T>
    • std::is_reference_v<T>
    • std::is_lvalue_reference_v<T>
    • std::is_rvalue_reference_v<T>
    • std::is_same_v<U, V>
    • 等等
  • sizeof(TYPE)sizeof(NAME)
  • sizeof...(TYPES)
  • constexpr 常數
  • 滿足特定條件的 constexpr 函式呼叫(被呼叫者是正確的 constexpr 函式且實際參數都是 constexpr 數值)

當編譯器在處理 if constexpr 述句的時候,編譯器會先計算 condition 的數值。若 conditiontrue,編譯器會具現化(Instantiate)then-statement,捨棄 else-statement。反之,則會具現化 else-statement,捨棄 then-statement。編譯器只會為被具現化的子句生成目的碼。然而,不論是否被捨棄,編譯器都會檢查程式碼是否有誤,這必須特別留意。

取代類別樣版特化

if constexpr 的第一個用途是取代類別樣版特化(Class Template Specialization)。舉例來說,std::next(iter, n) 能將迭代器 iter 向前移動 n 次。如果傳入的迭代器是隨機存取迭代器(Random Access Iterator),我們能直接回傳 iter + n。直接使用 operator+ 的效率會比較好。然而,如果傳入的迭代器只是向前迭代器(Forward Iterator),我們就只能執行 n++iter

在 C++ 98,這通常以類別樣版特化來實現:

#include <iterator>

namespace impl {
template <typename Category>
class Helper {
public:
  template <typename Iterator>
  static Iterator next(Iterator iter, size_t n) {
    for (size_t i = 0; i < n; ++i) {
      ++iter;
    }
    return iter;
  }
};

template <>
class Helper<std::random_access_iterator_tag> {
public:
  template <typename Iterator>
  static Iterator next(Iterator iter, size_t n) {
    return iter + n;
  }
};
}  // namespace impl

template <typename Iterator>
Iterator next(Iterator iter, size_t n) {
  typedef typename std::iterator_traits<Iterator>::iterator_category
      Category;
  return impl::Helper<Category>::next(iter, n);
}

以上程式碼我們先宣告一個以迭代器分類(Iterator Category)為參數的類別樣版 Helper,再為 std::random_access_iterator_tag 編寫一個類別樣版特化。最後,在 next() 函式的實作中,先以 std::iterator_traits<Iterator>::iterator_category 取得迭代器分類,再呼叫 impl::Helper<Category>::next() 函式。

在 C++ 17,我們能以 if constexpr 改寫上面的類別樣版特化:

#include <iterator>
#include <type_traits>

template <typename Iterator>
Iterator next(Iterator iter, size_t n) {
  using Category =
      typename std::iterator_traits<Iterator>::iterator_category;
  if constexpr (
      std::is_same_v<Category, std::random_access_iterator_tag>) {
    return iter + n;
  } else {
    for (size_t i = 0; i < n; ++i) {
      ++iter;
    }
    return iter;
  }
}

以上程式碼我們還是以 std::iterator_traits<Iterator>::iterator_category 取得迭代器分類。但是接著改以 if constexpr 與 std::is_same_v() 判斷 Iterator 是否為隨機存取迭代器。如果是隨機存取迭代器就直接回傳 iter + n。因為 if constexpr 會捨棄無關的子句,所以當 Iterator 不是隨機存取迭代器時,iter + n 不會造成編譯錯誤。

備註:作為綀習,你可以刪除 constexpr 關鍵字,觀察編譯器的錯誤訊息,並比較「if 述句」與「if constexpr 述句」的差異。

我們能以下面的程式碼驗證 C++ 98 與 C++ 17 實作的正確性:

#include <iostream>
#include <vector>
#include <list>

int main() {
  std::vector<int> xs;
  std::list<int> ys;

  for (size_t i = 0; i < 5; ++i) {
    xs.push_back(i);
    ys.push_back(i);
  }

  std::cout << *next(xs.begin(), 2) << std::endl;
  std::cout << *next(ys.begin(), 2) << std::endl;
}

取代樣版函式重載與 std::enable_if

if constexpr 也可以用來取代「函式重載」與「std::enable_if」。在 C++ 11,一個常用的泛型程式設計技巧是以 std::enable_if 啟用或禁用特定函式重載[1]。舉例來說,上一節介紹的 next 類別樣版也能以 std::enable_if 實作:

#include <iterator>
#include <type_traits>

template <typename Iterator>
typename std::enable_if<
    std::is_same<
        typename std::iterator_traits<Iterator>::iterator_category,
        std::random_access_iterator_tag
    >::value,
    Iterator>::type
next(Iterator iter, size_t n) {
  return iter + n;
}

template <typename Iterator>
typename std::enable_if<
    !std::is_same<
        typename std::iterator_traits<Iterator>::iterator_category,
        std::random_access_iterator_tag
    >::value,
    Iterator>::type
next(Iterator iter, size_t n) {
  for (size_t i = 0; i < n; ++i) {
    ++iter;
  }
  return iter;
}

這個技巧是利用 SFINAE(Substitution failure is not an error,代換失敗不是錯誤)挑選重載函式。std::enable_if 的定義如下:

template <bool Condition, typename Type = void>
class enable_if {};

template <typename Type>
class enable_if<true, Type> {
public:
  typedef Type type;
};

如果 Conditionfalseenable_if 不會有 type 成員,因此會造成代換失敗。C++ 編譯器只會將該重載函式自候選清單移除,不會將該程式碼視為錯誤。上面的 next 重載函式就是透過 std::is_same<typename std::iterator_traits<Iterator>::iterator_category, std::random_access_iterator_tag>::value 選擇不同的實作。

對照上一節的 C++ 17 實作(重複如下),我們能觀察到使用 if constexpr 的版本更為簡潔。我們不需要寫兩次 std::is_same<...>也不需要解讀一長串晦澀的 std::enable_if

#include <iterator>
#include <type_traits>

template <typename Iterator>
Iterator next(Iterator iter, size_t n) {
  using Category =
      typename std::iterator_traits<Iterator>::iterator_category;
  if constexpr (
      std::is_same_v<Category, std::random_access_iterator_tag>) {
    return iter + n;
  } else {
    for (size_t i = 0; i < n; ++i) {
      ++iter;
    }
    return iter;
  }
}

另一個例子

部分 C++ 結構(Struct)沒有建構式,所以當我們要以 new 表達式配置記憶體並初始化成員的時候,我們必須使用大刮號而不是小刮號:

struct PODPoint {
  int x_;
  int y_;
};

int main() {
  PODPoint *u = new PODPoint{1, 2};

  // XXX: The following line doesn't compile:
  // PODPoint *v = new PODPoint(1, 2);
}

假設我們想要以 unique_ptr 改寫 main() 函式,我們或許會嘗試:

#include <memory>

struct PODPoint {
  int x_;
  int y_;
};

int main() {
  // XXX: The following line doesn't compile:
  auto u = std::make_unique<PODPoint>(1, 2);
}

然而在 C++ 17 上面的程式碼是有問題的,因為 std::make_unique<T>(Args&&... args) 只會以 new T(std::forward<Args>(args)...) 配置與初始化物件。

如果我們要自己編寫一個 generalized_make_unique 同時支援兩種 new 表達式,我們能以 std::enable_if 加上 is_constructible_v 實作:

#include <memory>
#include <type_traits>

template <typename T, typename... Args>
typename std::enable_if<
    std::is_constructible_v<T, Args...>,
    std::unique_ptr<T>>::type
generalized_make_unique(Args&&... args) {
  return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

template <typename T, typename... Args>
typename std::enable_if<
    !std::is_constructible_v<T, Args...>,
    std::unique_ptr<T>>::type
generalized_make_unique(Args&&... args) {
  return std::unique_ptr<T>(new T{std::forward<Args>(args)...});
}

如果以 if constexpr 改寫,則可以簡化為:

#include <memory>
#include <type_traits>

template <typename T, typename... Args>
std::unique_ptr<T> generalized_make_unique(Args&&... args) {
  if constexpr (std::is_constructible_v<T, Args...>) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
  } else {
    return std::unique_ptr<T>(new T{std::forward<Args>(args)...});
  }
}

我們能以下面的程式碼測試兩種實作:

#include <iostream>

struct PODPoint {
  int x_;
  int y_;
};

struct Point {
  int x_;
  int y_;
  Point(int x, int y) : x_(x), y_(y) {}
};

int main() {
  auto u = generalized_make_unique<PODPoint>(1, 2);
  auto v = generalized_make_unique<Point>(1, 2);

  std::cout << u->x_ << " " << u->y_ << std::endl;
  std::cout << v->x_ << " " << v->y_ << std::endl;
}

與回傳型別推導的交互作用

前面的程式範例都明確地指定了函式回傳型別,所以 if constexpr 述句的子句回傳的值都會被轉型為回傳型別。然而,如果我們以 auto 關鍵字讓編譯器幫我們推導回傳型別,則各個 if constexpr 子句不需回傳相同的型別。編譯器在推導回傳型別的時候會忽略被捨棄的子句:

#include <iostream>

template <int Case>
auto test() {
  if constexpr (Case == 0) {
    return 1.0;
  } else if constexpr (Case == 1) {
    return 42u;
  } else {
    return "hello world";
  }
}

int main() {
  std::cout << test<0>() << std::endl;
  std::cout << test<1>() << std::endl;
  std::cout << test<2>() << std::endl;
}

以上程式碼之中,不同的樣版參數會讓 test() 有不同的回傳型別:

函式呼叫 回傳型別
test<0>() double
test<1>() unsigned int
test<2>() const char *

不正確的程式碼與編譯錯誤

最後我想要簡單的談一下「不正確的程式碼(Ill-formed Program)」與「編譯錯誤訊息(Diagnostics)」。雖然 if constexpr 述句會依照 condition 的數值決定要具現化哪一個子句,但是編譯器還是會施行許多正確性檢查(不論該子句有沒有被具現化)。例如以下程式碼,雖然 conditionfalse 但是編譯器仍會回報錯誤:

template <typename T>
void example(int i) {
  if constexpr (false) {
    static_assert(false, "error");  // error 1: assertion failure
    char buf[-1];  // error 2: bad array size
    i = j;  // error 3: undeclared name j
  }
}

一些錯誤則會被推遲到真正被使用到的時候,因為只有在具現化之後編譯器才知道各個參數的型別:

struct BadStruct {
  int x;
};

template <typename T>
int example(T t) {
  if constexpr (sizeof(T) >= sizeof(int)) {
    return t.value;  // Depending on T, this may be correct or incorrect
  }
  return 0;
}

int main() {
  example(BadStruct{1});
}

然而有一些錯誤雖然被 C++ 標準視為「不正確的程式碼」,但是 C++ 標準允許編譯器自行決定要不要為「沒被具現化的程式碼」產生錯誤訊息(Ill-formed, no diagnostic required):

#include <type_traits>

template <typename T>
void example(T t) {
  if constexpr (!std::is_same_v<T, T>) {
    char buf[sizeof(T) - sizeof(T) - 1];  // Ill-formed, bad array size
  }
}

int main() {
  example(1);
}

以上是一段不正確的程式碼,但因為有問題的程式碼沒有被具現化,GCC 和 Clang 都不會產生錯誤訊息。會產生錯誤訊息的編譯器也是合於 C++ 標準的,所以我們仍要確實測試所有 if constexpr 的子句並且避免寫出 Ill-formed 的程式碼。

參考資料

  • N4659: Working Draft, Standard for Programming Language C++ (C++ 17 Draft); Section 9.4.1, The if statement; Section 17.6, Name resolution, Clause 8
  • P0292R2, P0128R1, P0128R0: constexpr if: A slightly different syntax
  • N3638: Return type deduction for normal functions
  • N1720: Proposal to Add Static Assertions to the Core Language (Revision 3)

[1]雖然 std::enable_if 是在 C++ 11 之後加入標準函式庫,但是它完全能以 C++ 98 實現。如果你使用的是 C++ 98,你可以改用本文提供的 enable_if 實作或是使用 Boost 函式庫的 boost::enable_if。兩者的原理是一樣的。

Note

如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。