C++17 constexpr的改进

Share on:
1000 Words | Read in about 5 Min | View times

Overview

C++17的constexpr扩展了使用范畴,lambda表达式被纳入了constexpr的表达范畴,弥补了C++17之前无法使用constexpr lambda的遗憾,我们可以在只接受编译器常量的地方调用lambda表达式定义的lambda函数了。另外还提供了constexpr if语法的支持,可以在编译期进行逻辑判定,提供编译效率。本节内容通过回顾C++11和C++14标准定义的constexpr,然后展开介绍C++17对此的改进。

本系列文章将包括以下领域:

本章其他内容请见 《现代C++》

C++11 constexpr

C++11首次引入了constexpr,用来表达常量表达式,使得我们可以定义编译期常量,一般配合static_assert来判断编译期常量是否定义成功:

1constexpr int size = 10;
2
3int main() {
4    static_assert(size > 0, "");
5    int arr[size] = { 0 };
6    return 0;
7}

我们还可以使用constexpr来定义可在编译期求值的常量表达式:

 1#include <iostream>
 2
 3constexpr int fib(int n) {
 4    return n < 2 ? n : fib(n - 1) + fib(n - 2);
 5}
 6
 7int main() {
 8    static_assert(fib(5) == 5, ""); //编译期求值
 9
10    int n = 0;
11    std::cin >> n;
12    std::cout<< fib(n) << std::endl; //运行时求值
13
14    return 0;
15}

在C++11中,constexpr函数只能有一个return语句,不支持多个独立语句,最多也只能通过逗号表达式或三目表达式来表达。

constexpr函数的实参可以是编译期常量,也可以是运行期数值。如果实参是编译期常量,则求值过程在编译期进行,否则在运行期进行。

C++11还支持将类的构造函数和成员函数声明为constexpr,这样就可以在编译期完成对象构造:

 1#include <math.h>
 2
 3class Circle {
 4public:
 5    constexpr Circle(double r) : radius(r) { }
 6    constexpr double Area() {
 7        return M_PI * radius * radius;
 8    }
 9private:
10    double radius;
11};
12
13int main() {
14    static_assert(Circle(2.0).Area() > 20, ""); //error: static_assert failed due to requirement 'Circle(2.).Area() > 20' ""
15    return 0;
16}

以上例子表明在编译期确实构造了对象,并且在编译期调用了函数。

C++14 constexpr

我们在《C++14更多新特性》这篇文章介绍C++14新特性的时候提到过,constexpr在C++14的限制得到放宽:

而在C++14中,constexpr关键字修饰函数不再要求所有逻辑必须在一个return语句中完成,可以使用局部变量和循环等

所以,从C++14开始,constexpr函数可以使用多行语句编写,如上面的fib函数的例子就可以用循环来改写:

 1#include <iostream>
 2
 3constexpr int fib(int n) {
 4    int a = 0, b = 1;
 5    for (int i = 0; i < n; ++i) {
 6        int c = a + b;
 7        a = b;
 8        b = c;
 9    }
10    return a;
11}
12
13int main() {
14    static_assert(fib(5) == 5, ""); //编译期求值
15
16    int n = 0;
17    std::cin >> n;
18    std::cout<< fib(n) << std::endl; //运行时求值
19    return 0;
20}

然而C++14标准在制定时并未将lambda表达式纳入constexpr表达范围之内,lambda表达式无法在编译期求值:

 1constexpr int foo() {
 2    auto lambda = []() { //error: variable of non-literal type '(lambda at main.cpp:19:19)' cannot be defined in a constexpr function
 3        return 5;
 4    };
 5    return lambda();
 6}
 7
 8int main() {
 9    static_assert(foo() == 5, "");
10}

不过这是有替代方案的,lambda函数在本质上会被编译器改写为仿函数,仿函数本质也是由类来实现的。前面我们还提到,C++11支持将类的构造函数和成员函数定义成constexpr,因此我们可以通过定义constexpr的仿函数来间接实现constexpr的lambda表达式:

 1constexpr int foo() {
 2    class Functor {
 3    public:
 4        constexpr int operator()() {
 5            return 5;
 6        }
 7    };
 8    return Functor()();
 9}
10
11int main() {
12    static_assert(foo() == 5, "");
13}

C++17 constexpr

constexpr lambda

C++17对constexpr的适用范围再次进行扩展,已经可以运用在lambda表达式上了。我们曾经在关于闭包的文章中讲过,编译器在处理lambda表达式的时候,会自动为其合成一个闭包类型(仿函数),这个闭包类型包含一个operator()的重载操作符,lambda表达式的捕获列表将转换成该仿函数的成员变量,最后lambda表达式会被这个闭包类型的对象替换。

以下面这个例子为示范:

 1#include <iostream>
 2#include <functional>
 3
 4std::function<int(int)> foo(int a, int b) {
 5    return [a, b](int x) {
 6        return a * x + b;
 7    };
 8}
 9
10int main() {
11    std::cout << foo(1, 2)(2) << std::endl; //4
12}

编译器会将其转换为如下形式:

 1#include <iostream>
 2#include <functional>
 3
 4class SomeFunctor {
 5public:
 6    SomeFunctor(int a, int b)
 7        : m_a(a), m_b(b)
 8    { }
 9
10    int operator()(int x) {
11        return m_a * x + m_b;
12    }
13
14private:
15    int m_a;
16    int m_b;
17};
18
19std::function<int(int)> foo(int a, int b) {
20    return SomeFunctor(a, b);
21}
22
23int main() {
24    std::cout << foo(1, 2)(2) << std::endl; //4
25}

C++17对lambda表达式做了如下改进:

  • 若lambda表达式捕获的变量是字面量类型(literal type),则整个lambda表达式也将表现为字面量类型。

  • 增加constexpr lambda表达式语法。

  • 若一个lambda表达式满足constexpr函数的要求,即使没有明确声明为constexpr,编译器也会将其推导为constexpr lambda表达式。

constexpr lambda表达式的显式语法如下:

1[capture_list] (params_list) constexpr -> return_type {function_body}

在完整体的lambda表达式中,constexpr添加到参数列表和返回值类型之间,举个例子:

 1void foo() {
 2    auto fib = [](int n) constexpr -> int {
 3        int a = 0, b = 1;
 4        for (int i = 0; i < n; ++i) {
 5            int c = a + b;
 6            a = b;
 7            b = c;
 8        }
 9        return a;
10    };
11
12    static_assert(fib(5) == 5, "");
13}

constexpr lambda表达式和constexpr函数一样,需要满足以下条件:

  • 返回值的类型必须是字面量类型

  • 参数类型必须是字面量类型

  • 函数体还需满足:

    • 没有使用inline语句

    • 没有使用goto或label

    • 没有使用try...catch语句

    • 没有声明或使用非字面量类型的变量或使用thread local storage

只要满足上述提到的条件,即使没有将lambda声明为constexpr,编译器也会自动将其视为constexpr lambda表达式:

 1void foo() {
 2    auto fib = [](int n) -> int {
 3        int a = 0, b = 1;
 4        for (int i = 0; i < n; ++i) {
 5            int c = a + b;
 6            a = b;
 7            b = c;
 8        }
 9        return a;
10    };
11
12    static_assert(fib(5) == 5, "");
13}

constexpr函数一样,constexpr lambda表达式中,如果参数或捕获列表的参数不是编译期常量,则constexpr lambda表达式会退化为普通的lambda表达式:

 1#include <iostream>
 2
 3int main() {
 4    int x = 0;
 5    std::cin >> x;
 6
 7    auto f = [x](int a, int b) constexpr {
 8        return a * x + b;
 9    };
10
11    std::cout << f(2, 3) << std::endl; //虽然f声明为constexpr,但由于x是运行期变量,故f退化为普通lambda
12
13    constexpr auto f2 = [x]() { return x; }; //error: constexpr variable 'f2' must be initialized by a constant expression
14
15    return 0;
16}

另外,如果编译器在对constexpr lambda表达式对编译期求值过程中,遇到thrownew语句就会造成编译错误,因为这是需要在运行期才能获得的信息,因此编译期自然会报错:

 1int main() {
 2    auto f = [](int a) constexpr -> const char* {
 3        if (a > 0) 
 4            return new char[a];
 5        throw a;
 6    };
 7
 8    static_assert(f(0) != nullptr, ""); //complie error
 9    static_assert(f(1) != nullptr, ""); //complie error
10
11    return 0;
12}

但是只要constexpr lambda表达式在编译期求值时没有经过thrownew语句,就不会产生编译器错误:

 1int main() {
 2    auto f = [](int a) constexpr -> int {
 3        if (a == 0) 
 4            throw a;
 5        return a;
 6    };
 7
 8    static_assert(f(1) == 1, ""); //ok
 9
10    return 0;
11}

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

1auto incr = [](int n) {
2    return n + 1;
3};
4
5constexpr int(*f)(int) = incr;

constexpr if

传统的if-else语句是在运行期进行判断和选择的,无法运用在编译期,所以在泛型编程中,无法使用if-else来直接做一些判断。在C++17之前,只能被拆分为一个泛型版本和一个特化版本:

 1//C++17之前的写法
 2void print()
 3{
 4    std::cout << std::endl;
 5}
 6
 7template <typename T, typename... Ts>
 8void print(T head, Ts... tail)
 9{
10    std::cout << head << std::endl;
11    print(tail...);
12}

C++17引入了constexpr if,支持在编译期进行判断,即在编译期就可以直接知道结果,可以广泛应用于泛型编程。以上例子使用C++17实现则可以将两步合二为一:

1template <typename T, typename... Ts>
2void print(T head, Ts... tail)
3{
4    std::cout << head << std::endl;
5    if constexpr (sizeof...(tail) > 0)
6        print(tail...);
7}

上面的写法还可以通过折叠表达式进一步简化:

1template<typename... T>
2void print(T... t) {
3    ((std::cout << t << std::endl), ...);
4}

再有,constexpr if在泛型编程中的一个应用是,可以替代冗长的std::enable_if的写法。在C++17之前,需要为了不同类型条件去写各式各样的特化模版,利用SFINAE加上std::enable_if,会写得非常复杂:

 1//C++17之前
 2template<typename T> 
 3std::enable_if_t<std::is_integral<T>::value, std::string> to_string(T t) {
 4    return std::to_string(t);
 5}
 6 
 7template<typename T>
 8std::enable_if_t<!std::is_integral<T>::value, std::string> to_string(T t) {
 9    return t;
10}
11
12//C++17
13template <typename T>
14auto to_string(T t) {
15    if constexpr(std::is_integral<T>::value)
16        return std::to_string(t);
17    else //此处else不可省略
18        return t;
19}
20
21int main() {
22    std::cout << to_string("ok") << std::endl;
23    std::cout << to_string(123) << std::endl; //如果省略else则会报错:error: 'auto' in return type deduced as 'int' here but deduced as 'std::string' in earlier return statement
24    return 0;
25}

上面的例子中,else不可省略,否则会产生编译器推导错误,无法编译通过。

编译器在做优化时,甚至会把没有使用的分支省略掉,当然另一个分支必须支持C++语法,而老的C++标准即使使用了if,另一个分支也会被编译。对于constexpr if,编译器只会实例化条件通过的子句。由于返回值类型我们可以用auto让编译器自动推导,所以每个constexpr if子句的返回值类型也不需要完全一致。

Prev Post: 『C++17 string_view的原理』
Next Post: 『C++17更多新特性』