现代 C++ 之 constexpr 编程

现代 C++ 之 constexpr 编程

十月 06, 2025 次阅读

C 语言中的 底层const 和 顶层const

什么是底层 const 和 顶层 const

C 语言中的 const 关键字有两种含义:底层 const 和 顶层 const。

  • 底层 const:表示变量的值不可修改。例如,const int a = 10; 表示变量 a 的值不可修改。
  • 顶层 const:表示指针本身不可修改。例如,int * const p = &a; 表示指针 p 本身不可修改,但可以通过 p 修改 a 的值。
int a = 10;
int* const p = &a; // 顶层 const,指针本身不可变
*p = 20; // 合法,修改指针指向的值
const int* q = &a; // 底层 const,指针指向的值不可变
// *q = 30; // 非法,不能修改指针指向的值

C 语言中 const 的非安全性

C 语言中的 const 只能保证自己所限定的对象不可修改,但不能保证对象在编译期就能确定其值,因此,C 语言中的 const 权利是非常局限的,我们来看如下这个示例:

const int a = 10;
const int* q = &a;

cout << "地址: &a = " << &a << ", q = " << q << endl;

int* p = const_cast<int*>(q); // 去掉底层const

cout << "修改前: a = " << a << ", *p = " << *p << endl;

*p = 20; // 未定义行为!

cout << "修改后: a = " << a << ", *p = " << *p << endl;
cout << "地址验证: &a = " << &a << ", p = " << p << endl;

输出如下:

地址: &a = 0000000653EFFC40, q = 0000000653EFFC40
修改前: a = 10, *p = 10
修改后: a = 10, *p = 20
地址验证: &a = 0000000653EFFC40, p = 0000000653EFFC40

上面这个示例演示了 C 语言中的一个典型的未定义行为,即通过去掉底层 const 来修改一个本应不可修改的变量。这种行为是未定义的,因为编译器可能会对 const 变量进行优化,假设其值不会改变,从而导致程序行为不可预测。

在 MSVC 编译器当中,编译器会将所有直接访问 a 变量的地方都替换为 10,因此,修改 *p 的值并不会影响 a 的值。但如果访问 *p 的值,编译器会在运行期间去内存中读取该值,因此,*p 的值会变成 20。

我们不必在意这个代码运行结果会是什么,因为这是未定义行为,编译器可以做任何事情。按照正常逻辑来说,这段代码是不应该被允许编译通过的,但 C 语言的 const 关键字并没有提供足够的保护。

我们可以顺着 MSVC 编译器的特性,让这个值的修改成为可能,我们给 a 变量加上 volatile 关键字:

const volatile int a = 10;

int* p = const_cast<int*>(&a); // 去掉底层const

cout << "修改前: a = " << a << ", *p = " << *p << endl;

*p = 20; // 未定义行为!

cout << "修改后: a = " << a << ", *p = " << *p << endl;
cout << "地址验证: &a = " << &a << ", p = " << p << endl;

运行后会发现:

修改前: a = 10, *p = 10
修改后: a = 20, *p = 20
地址验证: &a = 1, p = 000000911297F930

我们会发现,因为 volatile 关键字要求编译器每次都从内存中读取变量的值,而不是使用寄存器中的缓存值,因此,修改 *p 的值会影响 a 的值。但正因这个行为是未定义的,因此我们会发现 a 的地址变成了 1,这显然是一个异常的结果。

C++ 语言中的 constexpr

C++ 之所以推出了 constexpr 关键字,是为了弥补 C 语言中 const 关键字的不足。在 C 语言中,const 关键字只能修饰变量,表示该变量的值不可修改,但并不意味着该变量的值在编译期就能确定。 而 C++ 中的 constexpr 关键字不仅可以修饰变量,还可以修饰函数和对象,表示该变量、函数或对象的值在编译期就能确定。

constexpr 变量

在官方文档里面,一个变量可以被声明为 constexpr,需要满足一下所有条件:

  • 声明是一个定义
  • 它是一个字面类型
  • 它已初始化(通过声明)
  • 其初始值是一个常量表达式

其实还有一些条件我并没有全部列出来,这里也不建议去可以记下来其他的条件,因为这些条件有很多都是比较晦涩的,且基本都是 C++26 之后的定义(几乎是完全没有被投入到当前的生产环境当中),实际使用中也很少会遇到。

其他几条都很好理解,这里我们重点来讲一下第二条:它是一个字面类型。

什么是字面类型

字面类型(Literal Type)是 C++11 引入的一个概念,指的是那些可以在编译期进行初始化的类型。字面类型包括:

  • 基本类型:如 intcharfloatdouble 等。
  • 枚举类型:如 enum
  • 类类型:如 std::arraystd::tuple 等。
  • 指针类型:如 int*char* 等。
  • 引用类型:如 int&const int& 等。

在 C++11 之后,字面类型的定义变得更加宽松,允许包含非静态数据成员的类类型,只要这些成员本身也是字面类型,并且类有一个 constexpr 构造函数。如下:

class Point {
public:
    constexpr Point(int x_val, int y_val) : x(x_val), y(y_val) {}
public:
    int x, y;
};

上面的 Point 类是一个字面类型,因为它有一个 constexpr 构造函数,并且它的成员变量 xy 都是基本类型 int

当我们声明一个 constexpr 变量时,编译器会在编译期对其进行初始化,并且保证其值在运行期不可修改。例如:

constexpr Point p1(10, 20); // 在编译时初始化

运行后我们转移到反汇编查看:

; 编译期完成初始化,直接写入内存
mov  dword ptr [p1], 0Ah      ; 直接写入x=10
mov  dword ptr [rbp+0Ch], 14h ; 直接写入y=20

上面的汇编代码显示,p1 变量的成员 xy 的值在编译期就已经确定,并且直接写入了内存中。如果觉得现象不够明显,我们初始化一个普通的 Point 变量并查看它的汇编:

; 运行时初始化
mov  r8d, 14h        ; 准备参数
mov  edx, 0Ah        ; 准备参数  
lea  rcx, [p1]       ; 准备this指针
call Point::Point    ; 运行时函数调用!

上面的汇编代码显示,普通的 Point 变量 p1 的成员 xy 的值是在运行期通过函数调用来初始化的。

这里的两个 Point 变量使用的是同一套 Point 类定义,但由于一个是 constexpr 变量,另一个是普通变量,因此它们的初始化方式完全不同。所以在这里我想说的是,即便一个变量是字面类型,但如果它不是 constexpr 变量,那么它的初始化依然是在运行期进行的

所以说我们一定要记得在需要声明 constexpr 变量的时候手动加上 constexpr 关键字,否则它依然是一个普通变量,不要以为加了 constexpr 后默认所有的初始化都是 constexpr。不仅仅是类的定义如此,在后面我们会看到,函数的定义也是如此,通常情况下如果我们不手动通过 constexpr 关键字来定义变量接受函数的返回值,那么它依然调用的是普通的函数。这里之所以这么说,是因为有一些特殊的场景,比如模板参数推导,编译器会自动帮我们推导成 constexpr 变量,或数组大小定义时嵌套 constexpr函数 进去,编译器默认会采用 constexpr 函数来实现编译其确定值。

constexpr 函数

constexpr 函数是 C++11 引入的一个概念,表示该函数可以在编译期进行计算,并且其返回值可以用于初始化 constexpr 变量。constexpr 函数必须满足以下条件:

C++11

  • constexpr 普通函数
    • 要求函数声明的参数和返回值都是字面类型。
    • 函数返回值类型不能为空。
    • 要求函数体中,只包含一条 return 语句,且该 return 语句返回的表达式必须是一个常量表达式。
    • 不能定义局部变量,循环条件判断等控制流语句。
  • constexpr 构造函数
    • 类的所有成员变量必须是字面类型。
    • constexpr 构造函数必须在初始化列表中初始化所有成员变量。
    • 构造函数体中不能包含任何语句。
    • 析构函数必须是平凡的不做任何实际清理工作的。
  • constexpr 成员函数(constexpr 成员函数自动成为 const 成员函数)。其要求和 constexpr 普通函数类似,但有以下额外要求:
    • 成员函数必须是 const 成员函数,不能修改成员变量的值。
    • 成员函数不能是虚函数。
    • 成员函数不能是静态成员函数。

下面我们针对普通函数来举一个计算斐波拉契数列的例子:

#include <iostream>

constexpr int factorial(int n) {
    return (n <= 1) ? 1 : (n * factorial(n - 1));
}

int main() {
    constexpr int val = factorial(5); // 在编译时计算
    std::cout << val << std::endl;
    return 0;
}

上面的代码中,factorial 函数被声明为 constexpr,表示它可以在编译期进行计算。在 main 函数中,我们声明了一个 constexpr 变量 val,并将 factorial(5) 的结果赋值给它。由于 factorial 函数是 constexpr 函数,因此它会在编译期计算出结果,并将其赋值给 val

汇编代码如下:

; constexpr int val = factorial(5);
00007FF629CB1EFC  mov  dword ptr [val], 78h  ; 直接写入 120 (0x78)

; std::cout << val << std::endl;  
00007FF629CB1F03  mov  edx, 78h             ; 直接使用 120

上面的汇编代码显示,val 变量的值在编译期就已经确定,并且直接写入了内存中。在输出时,编译器直接使用了这个值,而没有进行任何函数调用。

C++14

C++14 最显著的改进就是大幅放宽了对 constexpr 函数的限制:

  • 局部变量: 允许在 constexpr 函数中定义局部变量。
  • 控制流语句: 允许使用 if、switch、for、while 等控制流语句。
  • 多条语句: 允许函数体中包含多条语句,而不仅限于单一的 return 语句。
  • 支持更复杂的返回类型: 允许返回更复杂的类型,如类类型,STL 容器(std::array),只要这些类型满足字面类型的要求。

例如如下函数:

constexpr std::array<int, 10> generate_fibonacci() {
    std::array<int, 10> fib{};
    fib[0] = 0;
    fib[1] = 1;
    for (int i = 2; i < fib.size(); ++i) {
        fib[i] = fib[i - 1] + fib[i - 2];
    }
    return fib;
}

上面的 generate_fibonacci 函数生成了一个包含前 10 个斐波那契数的数组。由于它是一个 constexpr 函数,因此它可以在编译期进行计算,并且其返回值可以用于初始化 constexpr 变量。

C++17

C++17 对 constexpr 函数的改进主要集中在以下几个方面:

  • if constexpr 语句: 引入了 if constexpr 语句,允许在编译期进行条件判断,从而实现更灵活的编译期逻辑。
  • lambda 表达式: 允许在 constexpr 函数中使用 lambda 表达式,从而实现更简洁的代码。

下面我们针对这两个特性分别举例说明:

1、 if constexpr 语句

template<class T>
auto get_value(T t) {
    if constexpr (std::is_pointer_v<T>) {
        return *t;   // 如果是指针类型,返回指针指向的值
    }
    else {
        return t;    // 否则直接返回值
    }
}

int main() {
    int x = 42;
    auto v1 = get_value(x);
    auto v2 = get_value(&x);
    std::cout << "v1 = " << v1 << ", v2 = " << v2 << std::endl;
    return 0;
}

上面的代码中,get_value 函数使用了 if constexpr 语句,根据模板参数 T 的类型在编译期进行条件判断。如果 T 是指针类型,则返回指针指向的值;否则直接返回值。

2、 lambda 表达式

constexpr auto add = [](int a, int b) {
    return a + b;
};

int main() {
    constexpr int result = add(3, 4); // 在编译时计算
    std::cout << "result = " << result << std::endl;
    return 0;
}

上面的代码中,add 是一个 constexpr lambda 表达式,表示它可以在编译期进行计算。在 main 函数中,我们声明了一个 constexpr 变量 result,并将 add(3, 4) 的结果赋值给它。由于 add 是一个 constexpr lambda 表达式,因此它会在编译期计算出结果,并将其赋值给 result

C++20

C++20标准对constexpr关键字进行了革命性的增强,将编译期计算能力提升到了前所未有的高度。这些改进不仅大幅扩展了constexpr的应用范围,还使其成为现代C++元编程和性能优化的核心工具。下面将从多个维度全面解析C++20中constexpr的关键演进及其深远影响

1、 动态内存分配的编译期支持

C++20引入了对动态内存分配的编译期支持,允许在constexpr函数中使用new和delete操作符。这一改进极大地扩展了constexpr函数的能力,使其能够处理更复杂的数据结构和算法,使得 std::vector 等动态容器的编译期实现成为可能(注意,并不是已经实现了,在 C++23 后这些容器才逐渐可以被编译期使用)。

不过需要注意的是,所有分配的内存在编译期必须得被释放,否则会导致编译错误。

// 编译期间动态内存分配示例
constexpr int dynamic_memory_exampe() {
    int* p = new int{ 42 };
    int value = *p;
    delete p;
    return value;
}

2、标准库的constexpr化

从 C++20 开始,有很多标准库的组件也实现了 constexpr 版本,如 std::findstd::sort 等算法函数。这使得开发者可以在编译期使用这些常用的库功能,极大地提升了代码的灵活性和性能。

下面我们来看一个使用 std::sort 的例子:

constexpr auto sort_example() {
    std::array<int, 6> arr = { 5, 2, 9, 1, 5, 6 };
    std::sort(arr.begin(), arr.end());
    return arr;
}

上面的代码中,sort_example 函数使用了 std::sort 算法对一个数组进行排序。由于 std::sort 在 C++20 中被实现为 constexpr 函数,因此它可以在编译期进行计算,并返回排序后的数组。

3、try-catch 语句的编译期支持

C++20 允许在 constexpr 函数中使用 try-catch 语句,从而实现更复杂的错误处理逻辑。这一改进使得 constexpr 函数能够更好地处理异常情况,提高了代码的健壮性。且这代表着 C++20 允许在编译期进行异常处理,这为编译期计算引入了更高的灵活性和鲁棒性。

constexpr int safe_divide(int a, int b) {
    try {
        if (b == 0) throw std::runtime_error("Division by zero");
        return a / b;
    } catch (const std::exception& e) {
        return -1;  // 错误处理
    }
}

上面的代码中,safe_divide 函数使用了 try-catch 语句来处理除零异常。由于 safe_divide 是一个 constexpr 函数,因此它可以在编译期进行计算,并且能够处理异常情况。

4、constexpr 联合体
C++20 允许联合体(union)被声明为 constexpr,这使得联合体的成员可以在编译期进行初始化和访问,从而扩展了联合体的应用场景。当我们在联合体中定义 constexpr 构造函数时,可以在编译期创建和使用联合体的实例。

constexpr union IntOrFloat {
    int i;
    float f;
    constexpr IntOrFloat(int x) : i(x) {}
    constexpr IntOrFloat(float y) : f(y) {}
};

5、constexpr 可变(mutable)成员
mutable 的出现并不是使得成员变量可以在编译期被修改,而是使得一个 constexpr 对象当中是允许有可以在运行期间被修改的成员变量的。这样设计的目的是为了在保证对象整体不可变的前提下,允许某些特定成员在运行期进行修改,从而提升了代码的灵活性。

如果说我们在外部调用某个 constexpr 对象的成员函数修改了 mutable 成员变量的值,而我们是通过 constexpr 对象去接受这个函数的返回值的,那么编译期会报错,因为 muitable 成员变量的值在编译期是不可确定的。

因此,mutable 成员变量一般来说最好不要出现在 constexpr 对象当中,若一定要使用,也尽量不要让修改 mutable 成员变量的函数被 constexpr 对象调用,一个很简单的解决方法就是将这种函数设置为非 public。

6、constexpr 虚函数
C++20 允许虚函数被声明为 constexpr,这使得多态行为可以在编译期进行计算,从而实现更复杂的编译期逻辑。需要注意的是,constexpr 虚函数必须满足以下条件:

我们来举一个例子:

class Base {
public:
    virtual constexpr int value() {
        return 1;
    }
};

class Derived : public Base {
    public:
    constexpr int value() override {
        return 2;
    }
};

constexpr int get_value(Base&& b) {
    return b.value();
}

int main() {
    constexpr int a = get_value(Base());
    constexpr int b = get_value(Derived());
    std::cout << "Base value: " << a << ", Derived value: " << b << std::endl;
    return 0;
}

上面的代码中,Base 类和 Derived 类都定义了一个 constexpr 虚函数 value。在 get_value 函数中,我们通过基类引用调用了这个虚函数。由于 get_value 是一个 constexpr 函数,因此它可以在编译期进行计算,并返回正确的值。