漫谈C++——C++17中的constexpr

C++学习笔记

Posted by Felix Zhang on November 4, 2020

C++17中的constexpr

在一段时间以前,我曾经写过编译期常量和constexpr,那么在这篇文章的上半部分,我们的目光会放在C++17中和constepxr有关的新特性;在文章的后半部分,我们将综合这四篇文章所涉及到的知识点,在编译期解决FizzBuzz问题,我会着重于介绍我在处理编译期问题的过程和思路,而不是简单的把示例代码罗列出来。如果你对编译期常量和constexpr还一无所知,我建议你先看看之前的三篇文章,不会很长。

C++17中的constexpr

constexpr lambda表达式

在C++17后,lambda表达式就已经可以被声明为constexpr了。也就是说,他们可以被用在任何constexpr的上下文当中。同样的,对一个闭包而言,只要被捕获的变量是字面量类型(lieteral type),那么整个闭包也将表现为字面量类型

//显式声明为constexpr类型
template <typename T>
constexpr auto addTo(T i) {
  return [i](auto j) {return i + j;};
}

constexpr auto add5 = addTo(5);

template <unsinged N>
class SomeClass{};

int foo() {
  //在编译期常量中使用
  SomeClass<add5<22>> someClass27;
}

当一个闭包在constexpr环境下被使用时,当它满足了constexpr的条件,无论它有没有被显式地声明为constexpr,它仍然是constexpr的。

//这里没有显式声明为constexpr,但依然可以表现为constexpr
auto answer = [](int n)
{
  return 32 + n;
};

//在一个constexpr环境中被使用
constexpr int response = answer(10);

当一个lambda表达式被显式或隐式地声明为constexpr,它可以被转换成一个constexpr的函数指针:

auto Increment = [](int n)
{
  return n + 1;
};

constexpr int(*int)(int) = Increment;

constexpr if

constexpr if让以前理应被写在一起,却在C++17前都没法被写在一起的情况得到了改善。例如许多tag dispatch, enable_if和其他各种奇奇怪怪的标签被使用的场景,if constexpr都可以大显身手。传统的if-else语句是在执行期进行条件判断与选择的,因而在泛型编程中,无法使用if-else语句进行条件判断,比如下面的代码就无法通过编译:

template <class Head, class... Tail>
void print(Head const& head, Tail const&... tail) {
  std::cout << head;
  if (sizeof...(tail) > 0) {
    std::cout << ", ";
    print(tail...);
  }
}

在C++17标准以前,这些函数会只能被拆分写成一个范型版本的和一个特殊版本,在特殊版本中只有Head会被传进去做参数,而范型版本中还有可变参数Tail被传进来。而constexpr让这两种情况合二为一,做出了编译时的语句判断。

template <class Head, class... Tail>
void print(Head const& head, Tail const&... tail) {
  std::cout << head;
  if constexpr(sizeof...(tail) > 0) {
    std::cout << ", ";
    print(tail...);
  }
}

再比如考虑一个将数值转化成字符串的函数,在C++17之前,我们需要大量的std::enable_if来判断参数类型,如下例:

template <typename T>
std::enable_if_t<std::is_integral<T>::value, std::string>
to_string(T t){
  return std::to_string(t);
}

template <typename T>
std::enable_if_t<!std::is_integral<T>::value, std::string>
to_string(T t){
  return t;
}

在C++17中,constexpr if可以实现相同的功能,不仅缩短代码量,还提高了可读性:

template <typename T>
auto to_string(T t) {
  if constexpr(std::is_integral<T>::value) {
    return std::to_string(t);
  } else {
    return t;
  }
}

在C++17中,我们已经可以在编译期对传统的条件语句作出相应的判断了,相应的,编译器就可以完全忽略那些完全没有被进入的语句。其实即使没有C++17,如果你的if语句的条件是一个编译期常量,你的编译器和优化器也会做出相应的优化,来优化掉那些没有被进入的条件语句。

需要注意的是,在老的标准中,即使使用了if,另一个分支也仍然会被编译,但在C++17中,如果使用if constexpr来代替if,编译器甚至会把编译无效条件这个过程都省略掉了。当然了,另一条分支的语句仍然是要符合C++语法的,因为解释器至少要搞清楚if逻辑到底是在哪里结束的。

请看下边这个例子:

template <typename T>
auto someFunc(T t) {
  if constexpr(std::is_same_v<T, X>) {
    return t.some_func_only_for_x();
  } else {
    std::cout << t << std::endl;
    return;
  }
}

void callerFunc() {
  X x;
  auto res = someFunc(x);
  someFunc(25);
}

在上边这个例子中,函数some_func_only_for_x只有类X才有,所以如果使用老式的if语句,对于类似于someFunc(23)的调用会导致一个编译错误。除此之外,你会发现随着编译器进入不同条件语句,someFunc的返回值类型也是在发生变化的。对于传入X类型的参数,返回值类型是int,而对于其他类型则是void

实际上,上边的写法很像是编译器把两个分支分开,并创建了两个完全独立的函数:

auto someFunc(X x) {
  return x.some_func_only_for_x();
}

template<typename T>
auto someFunc(T t) {
  std::cout << t << std::endl;
}

当然,如果这两个函数的功能毫无联系,我们确实也应该把他们分开写(除非X的那个函数一个什么诡异的打印功能),并且明确出不同的返回类型。当这两个函数只有名字很像,其实际功能不同时,就不要再把它们像上边一样写在一起了。

constexpr对STL标准库做出的改进

以前在标准库中,有许多类型和函数都缺乏了constexpr的特性,这些在C++17中都相应做了改进。最著名的就是std::array以及用于范围获取的std::begin()std::end()了。

也就是说,std::array只要其中包含的类型是字面量类型,std::array本身也将成为一个字面量类型,它的绝大多数操作也能在编译期就直接被处理。而std::begin()std::end()等则依赖于容器本身:既然std::vector不是一个字面量类型,std::begin(vec)也就不是constexpr类型的;是std::begin(arr)对于C类型的数组以及std::array而言却是constexpr的。

使用constexpr在编译期解决FizzBuzz问题

FizzBuzz问题简介

这个问题是一个以前面试的时候非常常见的问题:请你写出一个程序,输出从1到N。但是对于每一个能被3整除的数字输出”fizz”,能被5整除的数字输出”buzz”,既能被3也能被5整除的数字输出”fizzbuzz”。

相信如果在run-time的情况下,你可以很轻松地写出如下程序,注意下边的程序中,我没有考虑任何可以优化的地方,你可以认为这只是一个草稿,只是给出一个示例,毕竟这不是我们今天讨论的重点。

std::string nFizzBuzz(unsigned N) {
  std::string str;
  if(N % 3 == 0) {
    str += "fizz";
  }
  if(N % 5 == 5) {
    str += "buzz";
  }
  if(str.empty()) {
    str = std::to_string(N);
  }
  return str;
}

std::string fizzBuzz(unsigned N) {
  if( N <= 0) {
    return "";
  }
  std::string str = nFizzBuzz(1);
  for (unsigned n = 2; n <= N; n++) {
    str += ", " + nFizzBuzz(n);
  }
  return str;
}

那么当你输入7的时候,以上代码就会输出:

1, 2, fizz, 4, buzz, fizz, 7

编译期解法

有了C++17标准后,我们代码的整体结构可以不做大的变动,然而还是有一些run -time的代码我们无法在编译期使用:比如在C++17中编译期堆上的内存分配是不被允许的,因此std::stringstd::to_string也就行不通——我们必须为std::stringstd::to_string寻找合适的替代品。

解决这个问题,最耿直的做法就是使用std::array<char, Size>。基于这个想法,我们就要重写一个to_array()函数,其作用和std::to_string()基本相同。在下面的代码中,我给std::array<char, Size>起了一个别名,用到了using关键字(C++11),让代码更可读一些。基于不太成熟的想法,我们可能会把to_array()的函数原型设计成如下形式:

template<std::size_t Size>
using chars = std::array<char, Size>;

constexpr chars<Size> to_array(unsigned N) {
  /*
  ...
  */
}

结果我们马上就遇到了第一个难题:Size的值在编译期是什么?这其实取决于N,所以N就不能再是一个普通的函数参数了。这里的逻辑还是比较简单的:因为constexpr有可能在runtime被调用,因此有些值在compile-time我们是无法获取的,所以N必须强制被设为一个编译期常量——没错,就是模板参数

unsigned n;
std::cin >> n;
auto number = to_array(n);

我们在编译期是无法知道n的值的,也就自然而然无法知道Size的大小。通常来说,一个compile-time函数的有关变量(这里的模板参数Size还有函数的返回值类型chars<Size>)是不能依赖于一个run-time函数的参数的。

因此,原arraySize和返回值类型我们最好让编译期为我们做决定,这里我们使用auto作为返回类型。这个函数本身看起来其实比较简单:

template <unsigned N>
constexpr auto to_array() {
  constexpr char lastDigit = '0' + N % 10;
  if constexpr(N >= 10) {
    return conct(to_chars<N / 10>(), chars<1>{lastDigit});
  } else {
    return chars<1>{lastDigit};
  }
}

到这里为止,问题解决一半了。还有一个明显的问题就是,我们仍然需要给array构建一个类似于std::string+=操作符,我们称之为组合操作(concatenation)。因为在array中我们无法使用+=——两个长度不一样的array属于不同类型,无法通过直接相加得到,所以我们必须得手动实现它。

组合操作的思想其实是很简单的:如果我有一个长度分别为5和6的array,那么我就创建一个长度为11的array,再做两次array的拷贝将短数组的值拷贝到长数组中,任务就完成了。不过不幸的是,std::copy并不是constexpr的,因此这个函数我们也要自己实现。

//在编译期拷贝first和last之间的数据到to上
constexpr void copy(char const* first, char const* last, char* to) {
  while(first < last) {
    *to++ - *first++;
  }
}
//在编译期将两个array组合起来,并返回一个组合后的array
template <std::size_t N1, std::size_t N2>
constexpr auto conct(
  chars<N1> const& array1, 
  chars<N2> const& array2) {
  chars<N1 + N2> res{};
  copy(str1.begin(), str1.end(), res.begin());				//begin()和end()函数也是constexpr的了
  copy(str2.begin(), str2.end(), res.begin() + N1);
  return res;
}

其实这里我没有对copy函数和conct函数进行更复杂的泛化,是因为我们没必要让我们的代码更general,这么写也能够减少潜在的bug。

回到FizzBuzz问题

现在我们手上用来处理FizzBuzz问题的工具基本上准备的差不多了。就像to_array一样,nFizzBuzz函数和fizzBuzz函数也会将模板参数作为输入

template <unsigned N>
constexpr auto nFizzBuzz() {
  constexpr chars<4> FIZZ{'f', 'i', 'z', 'z'};
  constexpr chars<4> BUZZ{'b', 'u', 'z', 'z'};
  
  if constexpr (N % 3 == 0 && N % 5 == 0) {
    return conct(FIZZ, BUZZ);
  } else if constexpr (N % 3 == 0) {		//注意else后的if也要写成if constexpr
    return FIZZ;
  } else if constexpr (N % 5 == 0) {
    return BUZZ;
  } else {
    return to_array<N>();
  }
}

template <unsigned N>
constexpr auto fizzBuzz() {
  constexpr chars<2> seperateChar{',', ' '};		//用于不同输出之间的间隔
  static_assert(N > 0);
  if constexpr (N != 1) {
    return conct(fizzBuzz<N - 1>()),
    conct(seperateChar, nFizzBuzz<N>());
  } else {
    return nFizzBuzz<N>();
  }
}

当然,在这个示例中,我们还有很多可以值得改进的地方,比如将递归改进为迭代并在编译期实现多个array的组合,不过这些估计会放在日后自己实现了,因为这篇文章已经够长了。

结语

至今为止,我们使用constexpr都不能像使用run-time的一些工具一样得心应手,这是一件很正常的事。但是我们可以一步一步走向终点,就像上边我们举的例子一样。那么同样的,掌握了constexpr的技巧,我们仍然可以在编译期做很多事,巧妙地提高我们运行时的性能。