在 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
樣版的實際樣版參數依序可以是:
- 一個型別。例如:
int
、std::string
、std::vector<int>
等等。 - 一個帶有二個型別樣版參數的樣版。例如:
std::vector
、std::deque
等等[1]。 - 一個
int
整數。例如:-1
、0
、47
等等。
例如:
#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 == int
與 Size == 3
,然後再以「非型別樣版參數推導」推導 Size
的型別[3]。
不過這個例子沒有什麼實際用途,因為陣列大小是少數能被「函式樣版參數推導」推導出的非型別樣版參數。但是如果已知該樣版參數對應到陣列大小,我們能直接明確地將 Size
的型別宣告為 int
或 unsigned 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;
}
我們能編寫一個泛型函式同時涵蓋 GetA
與 GetB
:
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;
}
但是在這個例子中,我們先宣告 DataType
與 ClassType
型別樣版參數是為了宣告 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
函式的參數型別與回傳型別則是透過 MemPtrTraits
與 decltype(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)」的推導規則。部分特化通常分為二個部分:
- 類別樣版宣告
- 部分特化宣告
例如:前一節的 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::*)
」與「各個部分特化的型式樣版參數」,推導未知的樣版參數(例如:上例 U
與 V
)。
在 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
。接著,我們宣告一個帶有二個型別樣版參數(DataType
與 ClassType
)和一個非型別樣版參數(Ptr
)的部分特化。其中,非型別樣版參數 Ptr
會被作為「部分特化」的第一個樣版參數(即 GetterImpl<Ptr>
)連接 DataType
與 ClassType
。當編譯器遇到 GetterImpl<&X::a>
的時候,它會先比對 &X::a
的型別是否能與 DataType ClassType::*
匹配,進而推得 DataType
與 ClassType
對應的型別。最後,為了簡化呼叫端的工作,我們宣告了一個樣版變數 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_debug
、api2_debug
與 api3_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
靜態成員函式。
有了這個泛型 Logger
,GetAPI
可以改寫為:
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 的範疇了。日後有機會再另外開闢一個專欄加以介紹。
參考資料
- P0127R2, P0127R1: Declaring non-type template parameters with auto
- ISO C++ Standard Future Proposals mailing list, auto in template parameter lists
- N3601: Implicit template parameters
- Exploring Template Template Parameters
[1] | std::vector 與 std::deque 有二個樣版參數,分別為容器元素型別(Element Type)與分配器型別(Allocator Type)。 |
[2] | Literal Type 包含內建的整數型別(例如:int 、unsigned 、long 、short 、char 等等)、enum 型別等等。 |
[3] | GCC (g++) 會將 Size 的型別推導為 int 而 Clang (clang++) 會推導為 unsigned long 。 |
Note
如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。