C++ 17 template <auto> 非型別樣版參數型別推導

在 C++ 17 標準下,我們能以 auto 關鍵字宣告非型別樣版參數(Non-Type Template Argument)。編譯器會以「樣版的實際參數」推導「樣版參數」的型別:

template <auto Arg>
struct Example {
  static constexpr auto value = Arg;
};

enum TestEnum { VAL1, VAL2, };

int main() {
  Example<42> a;    // Arg == 42,   decltype(Arg) == int
  Example<VAL1> b;  // Arg == VAL1, decltype(Arg) == TestEnum
}

本文會先介紹 template <auto> 和「函式樣版參數推導(Function Template Argument Deduction)」的差異。然後會介紹 template <auto> 的實際應用。最後再介紹 template <auto> 與 Variadic Template 的結合。

template <auto>

在 C++ 語言,樣版參數可以是:

  • 型別樣版參數(Type Template Argument)#1
  • 樣版樣版參數(Template Template Argument)#2
  • 非型別樣版參數(Non-type Template Argument)#3
template <
  typename T,  // #1
  template <class U, class A> class Container,  // #2
  int K  // #3
>
struct Example {};

Example 樣版的實際樣版參數依序可以是:

  • 一個型別。例如:intstd::stringstd::vector<int> 等等。
  • 一個帶有二個型別樣版參數的樣版。例如:std::vectorstd::deque 等等[1]
  • 一個 int 整數。例如:-1047 等等。

例如:

#include <vector>

int main() {
  Example<int, std::vector, 47> ex;
}

在宣告非型別樣版參數(Non-type Template Argument)的時候,我們必須指定一個 Literal Type 作為樣版參數的型別[2]。上面的 Example 樣版就是以 int 作為樣版參數 K 的型別。

C++ 17 語言標準讓我們能直接以「auto 關鍵字」宣告非型別樣版參數。編譯器會以樣版實際參數推導該樣版形式參數的型別。例如:

template <auto Arg>  // #1
struct AutoExample {
  static constexpr auto value = Arg;
};

int main() {
  AutoExample<1> a;  // #2
  AutoExample<'c'> b;  // #3
}

以上程式碼宣告了一個 AutoExample 樣版。此樣版有一個非型別樣版參數 Arg(如 #1)。此時,我們還不知道 Arg 的型別。Arg 的型別要等到我們具現化 AutoExample 樣版的時候才能被推導出來。

#2 我們以 1 作為 AutoExample 的樣版參數,因此 Arg 的型別會被推導為 int。同樣地,在 #3 我們以 'c' 作為 AutoExample 的樣版參數,因此 Arg 的型別會被推導為 char

與函式樣版參數推導的差異

值得留意的是前述非型別樣版參數推導(Non-type Template Argument Type Deduction)是以「樣版實際參數(Template Actual Argument)」推導「樣版形式參數(Template Formal Argument)的型別」。這與「函式樣版參數推導(Function Template Argument Deduction)」是兩個不同的概念。

函式樣版參數推導是函式樣版(Function Template)被呼叫的時候(如下 #1),編譯器會比對「函式實際參數(Function Actual Argument)的型別」(如下 #2)與「函式形式參數(Function Formal Argument)的型別」(如下 #3),推導出樣版實際參數(Template Actual Argument)int 作為樣版形式參數(Template Formal Argument)T 的值(如下 #4)。

#include <iostream>
#include <typeinfo>
#include <vector>

template <typename T>  // #4
void example(const std::vector<T> &v) {  // #3
  std::cout << typeid(T).name() << std::endl;
}

int main() {
  std::vector<int> xs;  // #2
  example(xs);  // #1
}

在一些情況下,呼叫函式樣版會讓編譯器依序施行「函式樣版參數推導」與「非型別樣版參數推導」。例如:

#include <iostream>
#include <typeinfo>

template <typename T, auto Size>  // #3
void test(T (&array)[Size]) {  // #2
  std::cout << typeid(decltype(Size)).name()
            << " Size = " << Size << std::endl;
}

int main() {
  int a[3];
  test(a);  // #1
}

編譯器會先以「函式樣版參數推導」推導出 T == intSize == 3,然後再以「非型別樣版參數推導」推導 Size 的型別[3]

不過這個例子沒有什麼實際用途,因為陣列大小是少數能被「函式樣版參數推導」推導出的非型別樣版參數。但是如果已知該樣版參數對應到陣列大小,我們能直接明確地將 Size 的型別宣告為 intunsigned long

以上是「函式樣版參數推導」與「非型別樣版參數推導」兩者的差異。本文接下來只會著重於「非型別樣版參數推導」。

使用情境:將常數表達式包裝為型別

C++ 泛型程式設計(Generic Programming)通常會將計算結果儲存於以 value 為名字的靜態常數成員。C++ 標準函式庫提供的 std::integral_constant 就是典型的範例:

template <typename T, T Value>
struct integral_constant {
  static constexpr T value = Value;
};

我們要使用 integral_constant 的時候,我們必須依序傳入型別 T 與數值 Value

#include <iostream>
int main() {
  std::cout << integral_constant<int, 5>::value << std::endl;
}

或者,我們必須為各個型別定義樣版別名(Template Alias):

template <bool Value>
using bool_constant = integral_constant<bool, Value>;

在 C++ 17 之後,透過 template <auto>,我們能直接將推導型別的工作交給編譯器:

template <auto Value>
struct constant {
  static constexpr auto value = Value;
};

使用上述 constant 樣版的時候,我們只需傳入數值 Value

#include <iostream>
int main() {
  std::cout << constant<5>::value << std::endl;
}

使用情境:泛型成員存取函式

有時候我們會使用 std::transform 搭配 Unary Function(一元函式)提取物件資料成員:

#include <algorithm>
#include <iostream>
#include <iterator>

struct X {
  int a;
  double b;
};

int GetA(const X &x) {
  return x.a;
}

double GetB(const X &x) {
  return x.b;
}

int main() {
  X xs[] = {{1, 2.2}, {3, 4.4}, {5, 6.6}, {7, 8.8}};

  std::cout << "int:" << std::endl;
  std::transform(std::begin(xs), std::end(xs),
                 std::ostream_iterator<int>(std::cout, "\n"),
                 GetA);

  std::cout << "double:" << std::endl;
  std::transform(std::begin(xs), std::end(xs),
                 std::ostream_iterator<double>(std::cout, "\n"),
                 GetB);

  return 0;
}

我們能編寫一個泛型函式同時涵蓋 GetAGetB

template <typename DataType, typename ClassType,
          DataType ClassType::*Ptr>
DataType Getter(const ClassType &obj) {
  return obj.*Ptr;
}

上述 Getter 函式分別有三個樣版參數:

  • DataType 是資料成員的型別
  • ClassType 是物件類別型別
  • Ptr 是資料成員指標(Pointer to Data Member)

透過這三個樣版參數我們可以明確定義 Getter 要從什麼類別存取什麼資料成員:

#include <algorithm>
#include <iostream>
#include <iterator>

struct X {
  int a;
  double b;
};

int main() {
  X xs[] = {{1, 2.2}, {3, 4.4}, {5, 6.6}, {7, 8.8}};

  std::cout << "int:" << std::endl;
  std::transform(std::begin(xs), std::end(xs),
                 std::ostream_iterator<int>(std::cout, "\n"),
                 Getter<int, X, &X::a>);  // Modified

  std::cout << "double:" << std::endl;
  std::transform(std::begin(xs), std::end(xs),
                 std::ostream_iterator<double>(std::cout, "\n"),
                 Getter<double, X, &X::b>);  // Modified

  return 0;
}

但是在這個例子中,我們先宣告 DataTypeClassType 型別樣版參數是為了宣告 Ptr 「非型別樣版參數」的型別。實際上 Ptr 已經提供足夠的資訊。我們能以 template <auto> 改寫:

template <typename T>
struct MemPtrTraits {};

template <typename U, typename V>
struct MemPtrTraits<U (V::*)> {
  typedef V ClassType;
  typedef U DataType;
};

template <auto Ptr>
const typename MemPtrTraits<decltype(Ptr)>::DataType &
Getter(const typename MemPtrTraits<decltype(Ptr)>::ClassType &obj) {
  return obj.*Ptr;
}

首先,我們先定義一個 MemPtrTraits 用以抽取「資料成員指標」的資料成員型別 DataType 與類別型別 ClassType。接著,Getter 函式樣版就只宣告一個 auto Ptr 非型別樣版參數。Getter 函式的參數型別與回傳型別則是透過 MemPtrTraitsdecltype(Ptr) 推導而成。如此一來,我們使用 Getter 就能直接以 Getter<&X::a> 產生存取類別 X 資料成員 a 的 Unary Function:

#include <algorithm>
#include <iostream>
#include <iterator>

struct X {
  int a;
  double b;
};

int main() {
  X xs[] = {{1, 2.2}, {3, 4.4}, {5, 6.6}, {7, 8.8}};

  std::cout << "int:" << std::endl;
  std::transform(std::begin(xs), std::end(xs),
                 std::ostream_iterator<int>(std::cout, "\n"),
                 Getter<&X::a>);  // Modified

  std::cout << "double:" << std::endl;
  std::transform(std::begin(xs), std::end(xs),
                 std::ostream_iterator<double>(std::cout, "\n"),
                 Getter<&X::b>);  // Modified

  return 0;
}

部分特化與非型別樣版參數

在加入 template <auto> 的時候,C++ 17 也改進了「部分特化(Partial Specialization)」的推導規則。部分特化通常分為二個部分:

  1. 類別樣版宣告
  2. 部分特化宣告

例如:前一節的 MemPtrTraits,我們先宣告了 MemPtrTraits 類別樣版:

template <typename T>
struct MemPtrTraits {};

接著,我們宣告一個部分特化處理 U (V::*) 的情況:

template <typename U, typename V>
struct MemPtrTraits<U (V::*)> {
  typedef V ClassType;
  typedef U DataType;
};

編譯器在處理 MemPtrTraits<int (X::*)> 的時候,會比對「實際樣版參數 int (X::*)」與「各個部分特化的型式樣版參數」,推導未知的樣版參數(例如:上例 UV)。

在 C++ 17 以前,非型別樣版參數不會影響部分特化的比對規則。從 C++ 17 開始,「非型別樣版參數」會被作為推導規則的資料來源。舉例來說:前一節的 Getter 函式樣版也能改以類別樣版實作:

template <auto Ptr>
struct GetterImpl {};

template <typename DataType, typename ClassType,
          DataType ClassType::*Ptr>
struct GetterImpl<Ptr> {
  static const DataType &Get(ClassType &obj) {
    return obj.*Ptr;
  }
};

template <auto Ptr>
auto Getter = GetterImpl<Ptr>::Get;

首先,我們先宣告一個帶有一個 auto 參數的類別樣版 GetterImpl。接著,我們宣告一個帶有二個型別樣版參數(DataTypeClassType)和一個非型別樣版參數(Ptr)的部分特化。其中,非型別樣版參數 Ptr 會被作為「部分特化」的第一個樣版參數(即 GetterImpl<Ptr>)連接 DataTypeClassType。當編譯器遇到 GetterImpl<&X::a> 的時候,它會先比對 &X::a 的型別是否能與 DataType ClassType::* 匹配,進而推得 DataTypeClassType 對應的型別。最後,為了簡化呼叫端的工作,我們宣告了一個樣版變數 Getter 用以指稱 GetterImpl<Ptr>::Get 靜態成員函式。

這個 Getter 實作和前一節的實作相似,兩者有相同的使用方法,可以直接使用同一份測試程式碼

使用情境:函數追蹤

如果我們寫的程式必須提供 API 讓第三方開發者呼叫,比較好的設計是把公開介面整理成一個 struct 讓第三方開發者透過 struct 裡面的函式指標(Function Pointer)呼叫我們定義的 API。例如:

struct Env {
  int (*api1)(int);
  const char *(*api2)(int, int);
  const char *(*api3)(int, const char *, const char *);
};

static int api1(int a) {
  return a + 1;
}

static const char *api2(int a, int b) {
  return ((a + b) % 2 == 0) ? "sum-even" : "sum-odd";
}

static const char *api3(int a, const char *b, const char *c) {
  return (a % 2 == 0) ? b : c;
}

const Env *GetAPI() {
  static const Env env = {
    api1,
    api2,
    api3,
  };
  return &env;
}

第三方開發者只需依賴一個 GetAPI 函式:

int main() {
  const Env *env = GetAPI();

  std::cout << env->api1(5) << std::endl;
  std::cout << env->api2(1, 2) << std::endl;
  std::cout << env->api3(5, "even", "odd") << std::endl;
}

日後,若我們想要增加 API,我們只需要增加 Env 的資料成員即可。

如果想要加入一個除錯模式記錄所有 API 的呼叫參數與回傳值,我們可以修改 Env 裡面的函式指標:

struct Env {
  int (*api1)(int);
  const char *(*api2)(int, int);
  const char *(*api3)(int, const char *, const char *);
};

static int api1(int a) {
  return a + 1;
}

static const char *api2(int a, int b) {
  return ((a + b) % 2 == 0) ? "sum-even" : "sum-odd";
}

static const char *api3(int a, const char *b, const char *c) {
  return (a % 2 == 0) ? b : c;
}

static int api1_debug(int a) {
  std::cerr << "[trace] api1(" << a << ")" << std::flush;
  auto result = api1(a);
  std::cerr << " -> " << result << std::endl;
  return result;
}

static const char *api2_debug(int a, int b) {
  std::cerr << "[trace] api2(" << a << ", " << b << ")" << std::flush;
  auto result = api2(a, b);
  std::cerr << " -> " << result << std::endl;
  return result;
}

static const char *api3_debug(int a, const char *b, const char *c) {
  std::cerr << "[trace] api3(" << a << ", " << b << ", " << c << ")"
            << std::flush;
  auto result = api3(a, b, c);
  std::cerr << " -> " << result << std::endl;
  return result;
}

const Env *GetAPI() {
  const bool is_debug_mode = std::getenv("DEBUG") != nullptr;

  static const Env env = {
    api1,
    api2,
    api3,
  };

  static const Env env_debug = {
    api1_debug,
    api2_debug,
    api3_debug,
  };

  return is_debug_mode ? &env_debug : &env;
}

雖然 api1_debugapi2_debugapi3_debug 的程式碼不複雜,但是它們很繁瑣。我們能用 template <auto> 定義一個萬用的 Logger:

#include <iostream>

void dump_values(std::ostream &os) {}

template <typename A0>
void dump_values(std::ostream &os, A0 &&a0) {
  os << a0;
}

template <typename A0, typename A1, typename... Args>
void dump_values(std::ostream &os, A0 &&a0, A1 &&a1, Args &&...args) {
  os << a0 << ", ";
  dump_values(os, std::forward<A1 &&>(a1), std::forward<Args &&>(args)...);
}

template <typename... Args>
void dump_call(std::ostream &os, const char *name, Args &&...args) {
  os << "[trace] " << name << "(";
  dump_values(os, args...);
  os << ")" << std::flush;
}

template <auto Function, const char *Name> struct LoggerImpl;

template <class ReturnType, class... Args,
          ReturnType (*Function)(Args...),
          const char *Name>
struct LoggerImpl<Function, Name> {
  static ReturnType call(Args... args) {
    dump_call(std::cerr, Name, args...);

    auto result = Function(args...);
    std::cerr << " -> " << result << std::endl;
    return result;
  }
};

template <auto Function, const char* Name>
constexpr auto Logger = LoggerImpl<Function, Name>::call;

首先我們先定義三個 dump_values 函式樣版,分別處理沒有參數、一個參數、兩個(或以上)參數的情況。接著,我們定義 dump_call 負責印出函式名稱和所有參數。最後我們宣告一個 LoggerImpl 類別樣版。它的樣版參數分別是「被呼叫的函式指標」與「被呼叫的函式名稱」。接著,我們以部分特化推導函式的參數型別與回傳型別,並以此結果定義 call 靜態成員函式。call 靜態成員函式會以 dump_call 印出參數、呼叫 Function 指向的函式、印出 Function 的回傳值。最後,我們宣告一個 Logger 樣版變數指向 call 靜態成員函式。

有了這個泛型 LoggerGetAPI 可以改寫為:

const Env *GetAPI() {
  const bool is_debug_mode = std::getenv("DEBUG") != nullptr;

  static const Env env = {
    api1,
    api2,
    api3,
  };

  static constexpr char api1_name[] = "api1";
  static constexpr char api2_name[] = "api2";
  static constexpr char api3_name[] = "api3";

  static const Env env_debug = {
    Logger<api1, api1_name>,
    Logger<api2, api2_name>,
    Logger<api3, api3_name>,
  };

  return is_debug_mode ? &env_debug : &env;
}

因為 Logger 能自動地幫我們處理參數與回傳值,所以我們只需要修改函式名稱即可。

然而,美中不足的是中間的 static constexpr char。這是 C++ 17 對樣版參數型別的限制。

不過我們還是可以使用巨集將所有 API 整理成一個列表:

const Env *GetAPI() {
  const bool is_debug_mode = std::getenv("DEBUG") != nullptr;

#define FOR_EACH_API(V) \
  V(api1) \
  V(api2) \
  V(api3) \

  static const Env env = {
#define DEFINE_API(X) X,
    FOR_EACH_API(DEFINE_API)
#undef DEFINE_API
  };

#define DEFINE_API_NAME(X) \
  static constexpr char X##_name[] = #X;
  FOR_EACH_API(DEFINE_API_NAME)
#undef DEFINE_API_NAME

  static const Env env_debug = {
#define DEFINE_API(X) Logger<X, X##_name>,
    FOR_EACH_API(DEFINE_API)
#undef DEFINE_API
  };

#undef FOR_EACH_API

  return is_debug_mode ? &env_debug : &env;
}

template <auto...>

template <auto> 也可以和 Variadic Template(可變長度參數樣版)合併使用。template <auto... xs> 的意思相當於:

template <auto x0, auto x1, ..., auto xn>

換句話說,template <auto...> 能讓我們宣告任意長度的樣版。各個參數能是不同的型別。編譯器會依據實際樣版參數推導它們的型別。

一個簡單的例子是 nth_value

template <int n, auto... xs>
struct nth_value;

template <int n, auto x0, auto... xs>
struct nth_value<n, x0, xs...> {
  static constexpr auto value = nth_value<n - 1, xs...>::value;
};

template <auto x0, auto... xs>
struct nth_value<0, x0, xs...> {
  static constexpr auto value = x0;
};

#include <iostream>

static const char str[] = "abc";

int main() {
  std::cout << nth_value<0, 1, str, 'c'>::value << std::endl;
  std::cout << nth_value<1, 1, str, 'c'>::value << std::endl;
  std::cout << nth_value<2, 1, str, 'c'>::value << std::endl;
}

以上程式碼我們先宣告一個 nth_value 樣版。第一個參數是存取索引,之後則是一系列的樣版參數。接下來我們定義二個樣版特化。若第一個樣版參數為 0,直接將 x0 定義為 value 常數靜態資料成員。若第一個樣版參數不是 0,捨棄 x0 並以遞迴將對應的值定義為 value 資料成員。

我們也能將 template <auto...> 包裝成一個數值列表 values_list,並以樣版別名(Template Alias)定義 get 成員:

// ... definition of nth_value skipped ...

template <auto... xs> struct values_list {
  template <int n>
  using get = nth_value<n, xs...>;
};

#include <iostream>

static const char str[] = "abc";

int main() {
  std::cout << values_list<1, str, 'c'>::get<0>::value << std::endl;
  std::cout << values_list<1, str, 'c'>::get<1>::value << std::endl;
  std::cout << values_list<1, str, 'c'>::get<2>::value << std::endl;
  return 0;
}

至於 values_list 的實際使用情境已經屬於 C++ Metaprogramming 的範疇了。日後有機會再另外開闢一個專欄加以介紹。

參考資料


[1]std::vectorstd::deque 有二個樣版參數,分別為容器元素型別(Element Type)與分配器型別(Allocator Type)。
[2]Literal Type 包含內建的整數型別(例如:intunsignedlongshortchar 等等)、enum 型別等等。
[3]GCC (g++) 會將 Size 的型別推導為 int 而 Clang (clang++) 會推導為 unsigned long

Note

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