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::string
和std::to_string
也就行不通——我们必须为std::string
和std::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函数的参数的。
因此,原array
的Size
和返回值类型我们最好让编译期为我们做决定,这里我们使用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
的技巧,我们仍然可以在编译期做很多事,巧妙地提高我们运行时的性能。