08-编译期编程

  • C++有许多支持编译期编程的特性
    • C++98前,模板提供了编译期计算的能力,包括使用循环和执行路径选择
    • 使用偏特化,可以在编译期根据特定的约束或要求,在不同的类模板实现之间进行选择
    • 使用SFINAE,允许在不同的函数模板实现之间对不同的类型或不同的约束进行选择
    • 在C++11和C++14中引入了constexpr特性,编译期计算得到了更好的支持
    • C++17引入了编译期if来禁用依赖于编译期条件或限制的语句,它也可以用于模板之外的场景

模板元编程

  • 模板在编译期实例化,这就启发把模板的一些特性结合到实例化过程中,以此产生一系列递归。下例在编译期找出质数
template<unsigned p, unsigned d> // p是要检测的数,d是现在的除数
struct DoIsPrime { // 用d到2依次去除p,对所有结果求与
  static constexpr bool value = (p % d != 0) && DoIsPrime<p, d-1>::value; 
}; 

template<unsigned p> // 除数减到2为止
struct DoIsPrime<p, 2> {
  static constexpr bool value = (p % 2 != 0);
}; 

template<unsigned p>
struct IsPrime { // 除数从p/2开始
  static constexpr bool value = DoIsPrime<p, p / 2>::value;
}; 

// 对于几个特殊用例要单独特化
template<>
struct IsPrime<0> { static constexpr bool value = false; }; 
template<>
struct IsPrime<1> { static constexpr bool value = false; }; 
template<>
struct IsPrime<2> { static constexpr bool value = true; }; 
template<>
struct IsPrime<3> { static constexpr bool value = true; }; 
  • 下面是计算IsPrime<9>::value的过程
IsPrime<9>::value
// 扩展为
DoIsPrime<9, 4>::value
// 扩展为
9 % 4 != 0 && DoIsPrime<9, 3>::value
// 扩展为
9 % 4 != 0 && 9 % 3 != 0 && DoIsPrime<9, 2>::value
// 扩展为
9 % 4!=0 && 9 % 3 != 0 && 9 % 2 != 0
// 因为9%3为0,结果为false

constexpr函数

  • C++11的constexpr函数仅能由一条return语句组成
constexpr bool doIsPrime (unsigned p, unsigned d)
{
  return d != 2 ? (p % d != 0) && doIsPrime(p, d - 1) : (p % 2 != 0);
}

constexpr bool isPrime (unsigned p)
{
  return p < 4 ? !(p < 2) : doIsPrime(p, p / 2);  // 处理特殊用例
}
  • C++14中则不限制只能写一条return语句
constexpr bool isPrime (unsigned p)
{
  for (unsigned d = 2; d <= p/2; ++d)
  {
    if (p % d == 0) return false; 
  }
  return p > 1;
}
  • constexpr函数并不一定在编译期计算。在要求编译期值(如数组长度或非类型模板实参)的上下文中,将在编译期调用constexpr函数,如果不能调用则发出错误。在其他上下文中,不一定会在编译期计算,但如果计算失败则不会发出错误,而是把编译期调用替换为运行期调用
constexpr bool b1 = isPrime(9); // 编译期计算
const bool b2 = isPrime(9); // 同样在编译期计算
// 在块作用域中编译器会决定在编译期还是运行期计算
bool fiftySevenIsPrime()
{
  return isPrime(57); // 在编译期或运行期计算
}
// 下面无论x是否为质数都会在运行期计算
int x = 42;
std::cout << isPrime(x); // 运行期计算

使用偏特化选择执行路径

  • 使用偏特化可以在编译期进行不同实现间的选择,比如根据模板实参是否为质数选择不同的实现
// 基本辅助模板
template<int SZ, bool = isPrime(SZ)>
struct Helper;

// 如果SZ不是质数的实现
template<int SZ>
struct Helper<SZ, false> {
  ...
};

// 如果SZ是质数的实现
template<int SZ>
struct Helper<SZ, true> {
  ...
};

template<typename T, std::size_t SZ>
long f(const std::array<T,SZ>& coll)
{
  Helper<SZ> h; // 实现依赖于数组大小是否为质数
  ...
}
  • 也可以用基本模板实现一种情况,用偏特化实现另一种情况
// 基本辅助模板(如果没有特化匹配)
template<int SZ, bool = isPrime(SZ)>
struct Helper {
  ...
};

// 如果SZ是质数的实现
template<int SZ>
struct Helper<SZ, true> {
  ...
};
  • 函数模板不支持偏特化,必须使用其他机制改变函数实现,可选如下
    • 使用带static函数的类
    • 使用std::enable_if
    • 使用SFINAE特性
    • 使用if constexpr(C++17)

SFINAE(Substitution Failure Is Not An Error)

  • 在重载解析的匹配中,为了防止替换过程产生无意义的错误,这样的替换会被忽略,这个原则就是SFINAE
// 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(const T& t)
{
  return t.size();
}
  • 第一个函数模板声明参数为T(&)[N],代表参数必须是一个N个T类型元素的数组。第二个简单把参数声明为T,但返回类型为T::size_type,这就要求传递的实参必须有对应的size_type成员。当传递原始数组或字符串字面值时,只有第一个模板匹配
int a[10];
std::cout << len(a); // OK:只有第一个模板匹配
std::cout << len("tmp"); // OK:只有第一个模板匹配
  • 如果上面的例子要匹配第二个模板,把T替换为int[10]const char[4]也可行,但是返回类型T::size_type导致了隐藏的错误,因此第二个模板会被忽略。同理,传递std::vector时,只有第二个模板匹配
std::vector<int> v;
std::cout << len(v); // OK:只有第二个模板匹配
  • 传递原始指针时,编译器将提示两个模板都不匹配
int* p;
std::cout << len(p); // 错误:找不到匹配的len函数
  • 这和传递一个有size_type成员但没有size()成员函数的对象不同,比如传递std::allocator类型对象时,编译器将匹配第二个函数模板,不会产生匹配错误,而是产生调用size()无效的错误,这次第二个模板不会被忽略
std::allocator<int> x;
std::cout << len(x); // 错误:能找到匹配的len函数,但不能调用size()
  • 当替换返回类型无意义时而忽略一个匹配,会造成编译器选择另一个更差的匹配
// 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(const T& t)
{
  return t.size();
}

// fallback for all other types:
std::size_t len(...) // 这个省略号是参数包
{
  return 0;
}

int a[10];
std::cout << len(a); // OK: len() for array is best match
std::cout << len("tmp"); //OK: len() for array is best match
std::vector<int> v;
std::cout << len(v); // OK: len() for a type with size_type is best match
int* p;
std::cout << len(p); // OK: only fallback len() matches
std::allocator<int> x;
std::cout << len(x); // ERROR: 2nd len() function matches best, but can't call size() for x
  • 对于allocator,第二个和第三个函数模板匹配,第二个是更好的匹配,但返回类型没有size()成员会产生错误
  • 如果把SFINAE机制用于确保函数模板在某种限制下被忽略,我们就说SFINAE out这个函数,当在C++标准中读到一个函数模板不应该加入重载解析除非...,就表示SFINAE被用来SFINAE out这个函数模板,比如std::thread声明一个构造函数如下
namespace std {
class thread {
 public:
  ...
  template<typename F, typename... Args>
  explicit thread(F&& f, Args&&... args);
  ...
};
}
  • 并带有如下remark
Remarks: This constructor shall not participate in overload resolution if decay_t<F> is the same type as std::thread.
  • 这表示这个模板构造函数被调用时,如果std::thread作为第一个和唯一一个实参,则这个模板会被忽略。调用一个线程时,通过SFINAE out这个模板来保证预定义的拷贝或移动构造函数总被使用。标准库提供了禁用模板的工具,最出名的特性是std::enable_if(它是用偏特化和SFINAE实现的)
namespace std {
class thread {
 public:
  ...
  template<typename F, typename... Args,
    typename = std::enable_if_t<!std::is_same_v<std::decay_t<F>, thread>>>
  explicit thread(F&& f, Args&&... args);
  ...
};
}
  • 指定正确的表达式来SFINAE out函数模板不会总是那么容易,比如想让函数模板len()对有size_type而没有size()的实参被忽略,之前的方案会产生错误
template<typename T>
typename T::size_type len(const T& t)
{
  return t.size();
}

std::allocator<int> x;
std::cout << len(x) << '\n'; // ERROR: len() selected, but x has no size()
  • 对此有一个惯用的解决方法
    • 使用尾置返回类型
    • 使用decltype和逗号运算符定义返回类型
    • 在逗号运算符开始处制定必须有效的全部表达式(转换为void以防逗号表达式被重载)
    • 在逗号运算符结尾处定义一个真正返回类型的对象
template<typename T>
auto len(const T& t) -> decltype((void)(t.size()), T::size_type())
{
  return t.size();
}

if constexpr

  • C++17引入了编译期if语句,允许基于编译期条件,使用或禁用指定的语句
template<typename T, typename... Types>
void print(const T& firstArg, const Types&... args)
{
  std::cout << firstArg << '\n';
  if constexpr (sizeof...(args) > 0)
  {
    print(args...);
  }
}
  • 这里如果print()只有一个实参,args为空参数包,sizeof...(args)为0,递归调用print()的语句变为废弃语句,不会实例化,因此不会要求对应的函数存在,递归结束。不会实例化意味着只执行Two-Phase Translation的第一阶段,检查语法正确性和不依赖于模板参数的名称,比如对于下面的例子的检查
template<typename T>
void f(T t)
{
  if constexpr (std::is_integral_v<T>)
  {
    if (t > 0) f(t - 1); // OK
  }
  else
  {
    undeclared(t); // error if not declared and not discarded (i.e. T is not integral)
    undeclared(); // error if not declared (even if discarded)
    static_assert(false, "no integral"); // always asserts (even if discarded)
    static_assert(!std::is_integral_v<T>, "no integral"); // OK
  }
}
  • if constexpr能用于任何函数而不仅局限于模板,但判断的必须是编译期表达式
int main()
{
  if constexpr (std::numeric_limits<char>::is_signed)
  {
    f(42); // OK
  }
  else
  {
    undeclared(42); // error if undeclared() not declared
    static_assert(false, "unsigned"); // always asserts(even if discarded)
    static_assert(!std::numeric_limits<char>::is_signed, "char is unsigned"); // OK
  }
}
  • 把这个特性用于编译期函数isPrimer,如果给定数组大小不是质数则执行附加代码
template<typename T, std::size_t SZ>
void f(const std::array<T, SZ>& coll)
{
  if constexpr (!isPrime(SZ)) ...
}

文章作者: 张小飞
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 张小飞 !
 上一篇
09-模板实战 09-模板实战
链接错误 不同于一般代码的是,对模板使用头文件声明、源文件定义的组织方式,编译器会接受,但链接器会产生无法找到函数定义的错误 // basics/myfirst.hpp #ifndef MYFIRST_HPP #define MYFIR
下一篇 
7-按值传递与按引用传递 7-按值传递与按引用传递
按值传递 按值传递实参时,原则上会拷贝每个实参,对于类通常还需要调用拷贝构造函数。调用拷贝构造函数可能开销很大,但事实上编译器可能对按值传递做优化,通过对复杂的对象使用移动语义避免高开销 template<typename T> v
  目录