现代 C++ 之 constexpr 编程
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 引入的一个概念,指的是那些可以在编译期进行初始化的类型。字面类型包括:
- 基本类型:如
int、char、float、double等。 - 枚举类型:如
enum。 - 类类型:如
std::array、std::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 构造函数,并且它的成员变量 x 和 y 都是基本类型 int。
当我们声明一个 constexpr 变量时,编译器会在编译期对其进行初始化,并且保证其值在运行期不可修改。例如:
constexpr Point p1(10, 20); // 在编译时初始化
运行后我们转移到反汇编查看:
; 编译期完成初始化,直接写入内存
mov dword ptr [p1], 0Ah ; 直接写入x=10
mov dword ptr [rbp+0Ch], 14h ; 直接写入y=20
上面的汇编代码显示,p1 变量的成员 x 和 y 的值在编译期就已经确定,并且直接写入了内存中。如果觉得现象不够明显,我们初始化一个普通的 Point 变量并查看它的汇编:
; 运行时初始化
mov r8d, 14h ; 准备参数
mov edx, 0Ah ; 准备参数
lea rcx, [p1] ; 准备this指针
call Point::Point ; 运行时函数调用!
上面的汇编代码显示,普通的 Point 变量 p1 的成员 x 和 y 的值是在运行期通过函数调用来初始化的。
这里的两个 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::find、std::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 函数,因此它可以在编译期进行计算,并返回正确的值。