一. C++11的constexpr

C++ 11中,constexpr首次进入C++标准,其意义是使得一些变量能在编译器常量求值,从而优化运行时间。
但是此时的constexpr有诸多限制,列举如下:

1. constexpr变量

对于constexpr变量:

  • 变量的类型必须是字面类型,所谓的字面类型主要包括三部分:
    1. 内置简单类型,包括C++内置的不需要分配内存的类型,不包括string和容器等。
    2. 聚合类型。对于类来说这包括四个条件:所有成员(变量)都是public的、没有定义构造函数、没有基类、没有虚函数。数组、union、结构体还有符合上述条件的类都属于聚合类型。C++ 11中还有额外的一条限制:不能有就地初始化?C++ 14没有这条限制,C++17移除了“没有基类”这一条,但只允许公共继承的基类。
    3. 非聚合类型但是可以通过常量表达式构造的类,这应该满足四个条件:数据成员必须都是字面值类型、类必须至少有一个constexpr构造函数、如果数据成员含有类内初始值则初始值必须是字面值(常量表达式,对于类成员则必须调用该类的constexpr构造函数)、类必须使用析构函数默认定义。
      需要注意的是字面值类型都是可以直接被字面值初始化的:
    class A {
    public:
        constexpr A() {}
        /* constexpr A(int a_, int b_): a(a_), b(b_) {} */
        constexpr A(int a_, int b_) {
            a = a_;
            b = b_;
        }
        int a, b;
    };
    
    class B {
    public:
        constexpr B() {
        }
        A as[2]{ {5, 9}, {5, 9} };
        Student stus[5]{5, 8};
    };
    
  • 变量必须立即被初始化(毕竟首先它得是const)
  • 变量的初始化包括所有隐式转换、构造函数调用等的全表达式必须是常量表达式
  • 变量的类型不能是类类型或类类型的数组,除非该类是字面值类型。初始化的方式见下一篇 C++学习笔记3 —— 变量初始化

constexpr表达式是指值不会改变并且在编译过程就可求值的表达式。声明为constexpr的变量一定const变量,而且必须用常量表达式初始化。

对于constexpr指针,其和const修饰的指针不太一样,只是限定指针不可变,并未限制指针指向的内存不可变,因此constexpr const int* ptr这样的写法是合理的,相当于const int* const ptr,这和普通变量不一样!并且由于编译期必须要能获取指针的值,因此只能够初始化为nullptr0或者全局/静态非动态分配变量的地址(因为这些变量在编译器就被初始化,因此在编译期可知道地址,但是受内存布局影响,因此程序修改后的运行结果可能是不一致)

2. constexpr函数

对于constexpr函数:

  • 函数必须为非虚函数,因为此时C++还没有实现编译期多态(栈上虚指针)
  • 函数体不能包含try和goto语句
  • 函数的返回值和入参必须都是字面值类型,返回值类型不可为void
  • 函数体只能包含:
    – 空语句(仅分号)
    – static_assert 声明
    – 类或枚举的 typedef 声明及别名声明
    – using 声明
    – using 指令
    – 如果函数不是构造函数,函数体仅能存在一条return 语句,因为构造函数不能有return语句,并且由于constexpr函数内不能有赋值语句,constexpr构造函数只能用参数列表来初始化。
    class A{
        public:
            // 在C++11中是必要的!
            constexpr A(int a_, int b_): a(a_), b(b_) {}
            int a, b;
    }
    
  • 构造函数可声明为constexpr(而且该类必须无虚基类),析构函数不可声明为constexpr。constexpr构造函数要么声明为defaultdelete,要么满足如下条件:
    – 含constexpr构造函数的类其所有基类都必须包含constexpr构造函数
    – 含constexpr构造函数的类不能有虚基类。原因是在虚继承(菱形继承)中,最派生类和两个次派生类都维护了基类的同一份副本,一个次派生类如果释放了基类对象,另一个次派生类析构时会产生问题(?)为避免这一情况在编译期发生,因此禁止含constexpr构造函数的类有虚基类。
    – 每个子对象和成员都必须初始化,这意味着每个成员和子对象不是在构造函数中初始化就是就地初始化(当然基类子对象必须在构造函数参数列表中初始化),不然constexpr构造将没有意义(成员未定义,无法在编译期求值),并且对于类成员或者子对象,初始化时必须使用相应的constexpr构造函数。

对于 constexpr 函数模板和类模板的 constexpr 函数成员,必须至少有一个特化满足上述要求。

  • constexpr非构造函数最多只能包含一行return语句,例如constexpr 斐波那契数列。
    // 此斐波那契数列实现的复杂度等同于迭代的方法,基本上为O(n)。
    constexpr long int fibonacci(int n) 
    { 
        return (n <= 1)? n : fibonacci(n-1) + fibonacci(n-2); //只能包含一个retrun语句
    }
    
  • constexpr函数只能引用全局字面值常量(编译阶段即可确定取值的变量)
  • constexpr只能调用其他constexpr函数,不能调用非constexpr函数。
  • constexpr可以用于模板类和函数模板,而且可将非 constexpr 模板的显式特化声明为 constexpr,例如:
    template<typename T> 
    T getT(T t) {
        return t;
    }
    
    template<>
    constexpr int getT(int i) {
        return i;
    }
    
    反之也成立:
    template<typename T> 
    constexpr T getT(T t) {
        return t;
    }
    
    template<>
    int getT(int i) {
        return i;
    }
    

这可以说明constexpr实际上不影响模版特化。

3. 局限和意义

从C++11 constexpr问世以来,constexpr函数就不一定返回常量表达式,因为传入的参数可能不是常量表达式,此时constexpr函数回退为普通函数。要想强制函数返回常量表达式,请使用C++20 consteval
在程序中使用constexpr,可以使用编译期优化,节省运行时开销,相比于宏来说,不仅类型安全,并且保证了被修饰变量的不可修改性。

二、C++14的constexpr

1. 限制放宽

C++ 11标准中,constexpr 修饰函数除了可以包含 using 指令、typedef 语句以及 static_assert 断⾔ 外,只能包含⼀条 return 语句。而C++14标准允许 constexpr函数使用局部变量,同时实现对其他函数的支持,所以constexpr 修饰的函数可包含 if/switch 等条件语句,也可包含 for 循环。

虽然C++ 14放开了很多限制,但是依然存在部分C++11存在的严格限制:

  • 函数体内不能有goto和try块,以及任何static和局部线程变量;
  • 在函数中只能调用其他constexpr函数;
  • 该函数也不能有任何运行时才会有的行为,比如抛出异常、使用new或delete操作等等;
  • constexpr函数依然必须是纯函数,所谓纯函数:
    1. 可以读取全局变量或者通过指针传递的变量,但不可以进行写操作
    2. 不可以读取volatile变量和外部资源(文件)
    3. 函数结果依赖于参数和全局以及内存变量

另外,C++14 中,constexpr函数可以是void类型,并且由于函数体内允许其他语句,constexpr构造函数可以直接在构造函数体内对成员进行赋值,但是仍然必须要初始化!(无论是参数列表初始化,还是就地初始化)。
聚合类型中“不能拥有就地初始化”的要求被移除。

2. 常量模版

常量模板是变量模板的一种特别形式,常量模板的表示常量的类型是模板,但是常量的值是const属性。我们可以利用constexpr 实现常量模板。

template<typename T>
constexpr T pi = T(3.1415926535897932385);

auto pi_double = pi<double>,;  // 类型为double
auto pi_int = pi<int>;  // 类型为int

template<typename T, int N>
T array[N];

注意模版参数只能是类型、模版、非类型(之后再讨论)三者之一!(整型包含所有的整数字类型,包括bool,有/无符号整型,各种位数的整型)。

三、C++17的constexpr

1. 引入constexpr if

C++ 17 基于C++ 14,将 constexpr 这个关键字引⼊到 if 语句,允许在代码中声明常量表达式的判断条件。我们称constexpr if这种结合方式叫静态if,静态if格式为:

template <typename T>
auto getValue(T t) {
    if constexpr (std::is_pointer_v<T>) {
        return *t;
    }
    else {
        return t;
    }
}

有点像#ifdef,但类型安全,实际上是生成了两份模版。

2. 引入constexpr lambda

C++ 17标准允许lambda expression在编译期求值,但是constexpr lambda expression也准寻下述C++17标准:

  • Lambda Expression捕获的参数必须是字面值类型(Literal Type)
  • 如果 lambda 结果满足 constexpr 函数的要求,则 lambda 是隐式的 constexpr;
  • 如果 lambda 是隐式或显式的 constexpr,并且将其转换为函数指针,则生成的函数也是 constexpr

另外,C++ 17删除了聚合类型不能有基类的要求,可以有public继承的基类。同时,C++ 标准库的很多内置类型和函数都用constexpr重写了,现在它们支持编译期求值。

四、C++20的constexpr

C++ 20标准兼容C++ 11,C++ 14,C++ 17标准,一方面对constexpr扩展;另一方面引入consteval和constinit解决constexpr存在的缺陷,使之臻于完美。
C++ 20标准的扩展。第一个是非类型模板参数的约束释放;第二个是编译时内存分配;第三个是编译时多态,即constexpr虚拟函数的引入;第四个constexpr允许try-catch;第五个 constexpr 中改变联合体的活跃成员。
虽然C++ 20增加了多项扩展,但是C++20 constexpr关键字依然存在两个缺陷,第一个缺陷是无法强制函数在编译器求值,第二个缺陷是无法解决编译时非常量求值问题。

1. 非类型模板参数

C++ 20 之前,模板的非类型模板参数仅支持简单的数值类型,但是C++20对此做出了重大调整。

  • 在简单数值类型的基础上,增加对float类型的支持。
  • 允许使用auto由编译期进行类型推导的非类型参数
template <auto ...>
struct ArgList
{
};
ArgList<'C', 0, 2L, nullptr> argList;
  • STL对非类型字符串模板参数的支持。 C++20 之前,你不能将字符串用作非类型的模板参数,那现在我们可以使用stl中的basic_fixed_string解决这个问题

2. 编译时内存分配

C++ 20编译时内存分配,constexpr函数可以进行有限制的动态内存分配和和使用std::vector/std::string。C++20之前只有std::array可以在编译期使用。当然这依然是有限制的使用:

  • constexpr函数中不能使用std::unique_ptr / std::shared_ptr
  • 动态内存的生命周期必须在constexpr函数的上下文中,即不能返回动态内存分配的指针
  • 不能返回 std::vector / std::string 对象。如果实在要这样做,需要返回std::array并使用std::copy

3. 编译时多态

constexpr可以在编译时内存分配,那编译时多态有了应用条件。与此同时在编译期使用dynamic_cast和typeid也是可行的。也因此,constexpr函数可以是虚函数了。
下面给一个编译时多态的例子:

#include <iostream>

class Father {
public:
    int a = 9;
    constexpr Father() {}
    virtual int getA() {
        return a;
    }
};

class Test: public Father {
public:
    int b = 18;
    constexpr Test() {}
    constexpr int getA() override {
        return b;
    }
};

constexpr auto f() {
    Father* father;
    Test test;
    father = &test;
    return father->getA();
}

int main() {
    static_assert(f() == 18, "error");
}

4. 允许try-catch

C++20 constexpr函数虽然允许try-catch,但是这依然是有限制的,开发者不能在函数中throw异常。

5. 允许变更union活跃成员

允许变更union活跃成员是C++20标准P1330R0提案,此提案的核心目标是允许在constexpr函数中重新指定union的当前有效成员。

6. consteval

constexpr修饰函数仅表示支持在编译期求值(是否真的在编译期求值,不确定),但是在有些时候我们要求必须在编译期求值。这就是consteval引入的价值。consteval修饰函数,要求函数必须在编译期求值,如果遇到非常量表达式,不会fallback而是报错。

7. constinit

constinit修饰变量,保证变量在编译期初始化。目标是为了解决 Static Initialization Order Fiasco文档,即相互影响的静态存储周期的变量之间,由于动态初始化的不确定性而导致的问题。

  • constinit不能和consteval/constexpr同时使用
  • constinit修饰引入对象,此时constinit与constexpr等价
  • 只能使用constexpr或consteval函数初始化constinit变量
constexpr int sum(int n)
{
    auto p = new int[n];
    std::iota(p, p + n, 1);
    auto t = std::accumulate(p, p + n, 0);
    delete[] p;
    return t;
}

consteval int min(std::initializer_list<int> array)
{
    int low = std::numeric_limits<int>::max();
    for (auto& i : array)
    {
        if (i < low)
        {
            low = i;
        }
    }
    return low;
}

constinit auto g_min = min({ 1, 2 });
constinit auto g_sum = sum(2);

int main()
{
    static_assert(min({ 1, 2, 3 }) == 1);
    return 0;
}

引用:C++20:constexpr、consteval和constinit