C++一直以来都包含一些在编译期计算数值的简单方法,模板的使用则扩大了这方面的可能性,一般情况下,只要我们提供了所有必要的输入数据,编译期就可以在编译期计算出所有结果。

C++支持编译期编程的多个特性:
1.从C++98之前,模板就提供了编译期计算的能力,包括使用循环和执行路径选择。

2.通过偏特化,可以根据特定的限制或需求,在不同的类模板之间进行选择。

3.利用SFINAE原则,可以根据不同类型或者不同限制,在不同的类模板之间做选择。

4.C++11和C++14提供了constexpr,有更直观的执行路径选择和支持更多的语句类型。

5.C++17引入了可以在模板之外使用的编译期if,通过它可以根据编译期的条件或限制弃用一些语句。

模板元编程

模板是在编译期来实例化的,这一点与动态语言截然不同,动态语言是在编译期处理泛型的。事实证明C++模板的一些特性可以结合实例化过程,进而从C++语言自身产生一种原始递归的“编程语言”。以下给出一个在编译期确定给定的数字是否是质数的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>

template <unsigned n, unsigned p>
struct DoIsPrime
{
static const bool value = (n % p != 0) && DoIsPrime<n, p - 1>::value;
};
template <unsigned n>
struct DoIsPrime<n, 2>
{
static const bool value = (n % 2 != 0);
};
template <unsigned n>
struct IsPrime
{
static const bool value = DoIsPrime<n, n / 2>::value;
};
template <>
struct IsPrime<0>
{
static const bool value = false;
};
template <>
struct IsPrime<1>
{
static const bool value = false;
};
template <>
struct IsPrime<2>
{
static const bool value = true;
};
template <>
struct IsPrime<3>
{
static const bool value = true;
};
int main()
{
std::cout << IsPrime<18>::value << std::endl;
std::cout << IsPrime<19>::value << std::endl;
return 0;
}

1.IsPrime::value为判断它是否是质数的类。

2.在调用IsPrime<n>::value之后,调用DoIsPrime<n, n / 2>::value这个模板的第一个模板参数是被除数,也就是需要判断的数,第二个是当前除数。

3.进入DoIsPrime<n, p>::value后,先查看当前n能否被p整除,如果能,则判为false,停止递归;如果不能,则再递归调用DoIsPrime<n, p - 1>::value

4.终止条件为p == 2时,特例化出模板用作终止条件。

5.特殊判断0, 1, 2, 3来防止n / 2从一开始就小于2的情况。

这便是最早的编译期求值的例子,从C++98前就已经开始使用了。

使用constexpr计算

C++11起引入了constexpr,极大地简化了各种形式的编译期计算,特别是只要给定适当的输入,constexpr函数就可以在编译期完成所有的计算。不过在C++11中,constexpr函数的定义限制很严格,只能包含一条return语句,但随着C++14的发布,这些限制大多已被移除。当然,成功计算一个constexpr函数仍然需要所有计算步骤在编译期就已经可行有效。目前堆分配内存和抛出异常仍不在支持之列。

优化判断质数的程序,C++11:

1
2
3
4
5
6
7
8
constexpr bool isPrime(unsigned n, unsigned p)
{
return (p == 2) ? (n % 2 != 0) : (n % p != 0) && isPrime(n, p - 1);
}
constexpr bool isPrime(unsigned n)
{
return (n < 4) ? (n > 1) : isPrime(n, n / 2);
}

由于只能有一条返回语句的限制,我们不得不使用?:做为执行选择机制,并且仍然需要递归来遍历元素。

在C++14中,constexpr函数支持绝大多数控制结构,如for循环,switch选择等:

1
2
3
4
5
6
7
8
9
10
11
constexpr bool isPrime2(unsigned n)
{
for (unsigned i = 2; i <= n / 2; ++i)
{
if (n % i == 0)
{
return false;
}
}
return true;
}

注意constexpr只是告诉编译期可以选择在编译期求值,而不是强制性的。如果在需要得到编译期数值的上下文中(如需要得到一个编译期数值以便定义栈数组),编译器会尝试编译期求值,如果不可行则报错;在其他上下文中,尝试编译期求值失败后,则会转化为运行期求值。

偏特化的执行路径选择

对于isPrime()这类编译期求值的有趣应用是,使用偏特化在编译器选择不同的模板实现方案,如我们可以根据传入的模板实参是否是质数来调用不同的实现方案:

1
2
3
4
5
6
7
8
9
10
11
template <int SZ, bool = IsPrime<SZ>::value>
struct Helper;

template <int SZ>
struct Helper<SZ, true>
{
};
template <int SZ>
struct Helper<SZ, false>
{
};

如此,我们可以根据第二个模板参数是否是质数,定义不同的Helper 类。我们也可以只特化一个版本,只在符合条件的特殊情况调用偏特化版本,其他情况使用默认版本:

1
2
3
4
5
6
7
8
9
template <int SZ, bool = IsPrime<SZ>::value>
struct Helper
{
};

template <int SZ>
struct Helper<SZ, true>
{
};

因为函数模板不支持偏特化,所以必须使用其他机制来基于约束条件实现执行路径选择:
1.将函数放入到模板类中,用偏特化的模板类调用相应函数。

2.使用std::enable_if<>忽略某些条件下的函数模板。

3.使用SFINAE特性。

4.使用C++17开始使用的编译期if

SFINAE

在C++中,重载函数以支持各种实参类型是很常见的,当编译器看到对重载函数的调用时,它必须分别考虑每个重载函数,评估调用实参并挑出最匹配的那一个

在调用的候选函数集合中如果包含函数模板,编译期首先必须决定应该为该候选函数模板使用哪些模板实参,其次在函数参数列表及其返回类型中替换掉这些实参,最后再向普通函数一样评估其匹配度。然而,错误可能发生在替换实参的过程中:替换产生的代码可能没有意义。语言规则不认定这种无意义的替换会导致错误,而仅仅忽略有这种替换问题的候选函数。

我们称这一原则为SFINAE(发音类似于sfee-nay),是“substitution failure is not an error”的缩写。

注意这里的替换过程不同于按需实例化过程(详见该帖二):即使对那些不会真正被使用到的模板也会进行替换。这一过程的替换只会替换直接出现在函数声明中的代码,而不会考虑吧函数体内部实现。

考虑如下示例:

1
2
3
4
5
6
7
8
9
10
template <typename T, unsigned SZ>
std::size_t len(T (&arr)[SZ])
{
return SZ;
}
template <typename T>
typename T::size_type len(T const &arr)
{
return arr.size();
}

我们定义了两个均带有一个泛型实参的函数模板len()。

(1).第一个函数模板声明参数类型为T(&)[SZ] ,因此参数应该是一个长度为SZ的T类型原始数组。

(2).第二个函数模板仅声明参数类型为T,除返回类型应该是T::size_type外没有其他任何约束,这要求传入类型T应该有一个成员类型size_type

假如main函数有以下语句:

1
2
3
int a[10];
std::cout << len(a) << std::endl;
std::cout << len("Hello") << std::endl;

这两句在调用len()函数时均调用了第一个版本的len函数,根据函数签名,第二个len函数模板也会对T进行替换,分别替换为int[10]char const[4]。但这些替换会导致返回类型T::size_type出现潜在错误,因此编译器将忽略第二个len函数模板。

当传递std::vector<>时,只有第二个函数模板匹配,不会对第一个函数模板进行替换。

1
2
std::vector<int> v(10);
std::cout << len(v) << std::endl;

当传递一个原始指针时,两个模板都不匹配。因此编译器会提示找不到匹配的len函数

1
error: no matching function for call to 'len(int*&)'

这种报错和具有size_type成员类型但没有size()成员函数的类型报错并不相同。如std::allocator<>有相应的size_type成员,却没有size()成员。

1
2
std::allocator<int> alloc;
std::cout << len(alloc) << std::endl;

报错

1
error: 'const class std::allocator<int>' has no member named 'size'

这次不同于上面的未找到匹配的函数,而是编译期错误,对于std::allcoter<>没有成员函数size()。

当替换某个候选函数的传入参数更匹配但是返回类型没有意义时忽略该函数,选择其他的传入参数匹配度低但返回类型有效的候选函数:

1
2
3
4
5
template <typename T>
std::size_t len(...)
{
return 0;
}

我们为len()函数准备了一个用于其它类型的后备函数,它能够匹配所有调用,但在重载解析中匹配度最低。传入的原始指针便可以调用这个函数。

综上所述,我们总结出以下几点重载解析规则:
(1).重载解析只关注函数签名(传入参数和返回类型)。

(2).调用优先级主要看传入参数声明和传入实参的匹配度,匹配度高的优先调用。

(3).如果返回类型无效,则忽略这个函数模板,再尝试其他匹配度低的函数。

(4)....作为传入参数声明的函数模板能够匹配所有入参,但匹配度最低。

对于SFINAE我们会在之后讲解更详细的用法。

SFINAE和重载解析

随着时间的推移,SFINAE原则在模板设计者变得非常流行和重要,以至于这个缩写已经演变出了动词的意思。如果我们想表达在某些约束条件下,通过SFINAE原则使模板代码生成无效代码,从而确保忽略这些约束条件下的函数模板,那我们就称SFINAE掉了一个函数模板。并且无论何时你读到函数模板“不应该参与重载解析过程,除非……”,都意味着在某些情况下,使用SFINAE原则“SFINAE掉”了那个函数。

例如,类std::thread声明了一个构造函数:

1
2
template<typename _Callable, typename... _Args>
explicit thread(_Callable&& __f, _Args&&... __args);

如果这时std::thread作为唯一的传入参数进入该构造函数,那么它可能会发生之前提到的:比任何其他预定义的拷贝构造(已经被删除)或者移动构造函数优先级都要高,就会导致错误,这时候我们就需要通过SFINAE来忽略掉这个情况下的该构造函数,而去使用其他的构造函数。

运用该技术逐个禁用模板非常不方便,幸运的是,标准库提供了更为容易禁用模板的工具。如std::enable_if<>enable_if的内部实现就是借助了SFINAE和偏特化),让我们看看实际的std::thread构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename _Callable, typename... _Args,
typename = _Require<__not_same<_Callable>>>
explicit thread(_Callable &&__f, _Args &&...__args)
{
static_assert(__is_invocable<typename decay<_Callable>::type,
typename decay<_Args>::type...>::value,
"std::thread arguments must be invocable after conversion to rvalues");

using _Wrapper = _Call_wrapper<_Callable, _Args...>;
// Create a call wrapper with DECAY_COPY(__f) as its target object
// and DECAY_COPY(__args)... as its bound argument entities.
_M_start_thread(_State_ptr(new _State_impl<_Wrapper>(
std::forward<_Callable>(__f), std::forward<_Args>(__args)...)),
_M_thread_deps_never_run);
}

传入第一个参数如果类型与std::thread相同,则忽略该构造函数。

通过decltype来SFINAE掉表达式

回顾之前的代码:

1
2
3
4
5
template <typename T>
typename T::size_type len(T const &arr)
{
return arr.size();
}

调用

1
2
std::allocator<int> alloc;
std::cout << len(alloc) << std::endl;

时会报错,原因是std::allocator类型能满足函数标签的所有条件,因此选择调用这个函数模板,但却因为其没有成员函数size()导致错误。在处理这类问题时,我们有一个常用的做法:

  1. 函数名前用auto修饰,通过尾置返回类型语法来指定返回类型。

  2. 使用decltype和逗号运算符来定义返回类型。

  3. 将所有必须成立的表达式放置于逗号运算符的开头(表达式为void类型,以防逗号运算符的重载)。

  4. 在逗号运算符末尾定义一个需要的实际返回类型的对象(其类型是所需要的返回类型)。

例如:

1
2
3
4
5
template <typename T>
auto len(T const &arr) -> decltype((void)(arr.size()), T::size_type())
{
return arr.size();
}

由于decltype内部的表达式是使用逗号分隔,因此会选择最后一个操作数作为结果,随后调用decltype获取它的类型作为最后的返回值。在它之前我们可以填写这个函数模板中所用到的必须的操作,强转为void类型是为了避免逗号运算符重载。这样我们就巧妙地将原来没有的判断纳入了SFINAE中,从而及时地禁用不匹配的模板。

注意decltype的实参是一个未求值的操作数,这意味着可以在不调用构造函数的情况下创建“虚拟对象”。

编译期if简介

偏特化,SFINAE,std::enable_if<>,允许我们在整体上启用或禁用模板。C++17进一步引入了编译期if语句,它允许基于编译期条件来启用或禁用特定的语句,语法为if constexpr()

此帖1.3节中,我们提到不能用if(sizeof...)之类的语句来作为模板递归终止条件,因为编译期所有分支内容都将会实例化,但constexpr if特性允许我们在编译期决定是否继续递归,而不需要单独提供一个终止递归模板。

1
2
3
4
5
6
7
8
9
10
void print(const T &firstArg, const Types &...args)
{
std::cout << firstArg << std::endl;
std::cout << "sizeof(Types): " << sizeof...(Types) << std::endl;
if constexpr(sizeof...(Types) > 0)
{
print(args...);
}
}

如果if内为false,那么就会丢弃内部的语句,其代码也不会实例化。并且if constexpr语句不仅可以用在模板中,它还可以在任何一个函数中使用,只需要表达式是一个编译期求值的布尔类型表达式即可。