第18章 编译期计算
# 第18章 编译期计算
本章介绍C++为支持编译期计算而引入的几个扩展特性。
本章涵盖两个新关键字constinit
和consteval
,以及允许程序员在编译期使用堆内存、向量和字符串的扩展功能。
# 18.1 关键字constinit
C++20引入的一个新关键字是constinit
。它可用于强制并确保可变的静态或全局变量在编译期初始化。大致来讲,其效果可以这样描述:
constinit = constexpr - const
没错,constinit
修饰的变量并非const
(或许将这个关键字命名为compiletimeinit
会更好)。这个名称源于这些初始化通常发生在编译期常量初始化的时候。
只要声明静态或全局变量,你都可以使用constinit
。例如:
// 在任何函数之外:
constinit auto i = 42;
int getNextClassId() {
static constinit int maxId = 0;
return ++maxId;
}
class MyType {
static constinit long max = sizeof(int) * 1000;
...
};
2
3
4
5
6
7
8
9
10
11
12
constexpr std::array<int, 5> getColl() {
return {1, 2, 3, 4, 5};
}
constinit auto globalColl = getColl();
2
3
4
5
如上述代码所示,你仍然可以修改声明的值。下面这段代码首次使用了上述声明:
std::cout << i << " " << coll[0] << "\n"; // 输出 42 1
i *= 2;
coll = {};
std::cout << i << " " << coll[0] << "\n"; // 输出 84 0
2
3
4
输出如下:
42 1
84 0
2
使用constinit
的效果是,只有当初始值是编译期已知的常量值时,初始化才能通过编译。这意味着,与如下声明不同:
auto x = f(); // f()可能是一个运行时函数
使用constinit
的对应声明要求在编译期进行初始化,即必须能够在编译期调用f()
(这意味着f()
必须是constexpr
或consteval
函数)。
constinit auto x = f(); // f()必须是一个编译期函数
如果使用constinit
初始化一个对象,那么必须能够在编译期使用其构造函数:
constinit std::pair p{42, "ok"}; // 没问题
constinit std::list l; // 错误:默认构造函数不是constexpr
2
使用constinit
的原因如下:
- 你可以要求在编译期初始化可变的全局/静态对象。这样一来,就可以避免在运行时进行初始化。特别地,在使用
thread_local
变量时,这有助于提高性能。 - 你可以确保全局/静态对象在使用时总是已经被初始化。实际上,
constinit
可用于解决静态初始化顺序问题(static initialization order fiasco),当一个静态/全局对象的初始值依赖于另一个静态/全局对象时,就可能出现这个问题。
注意,使用constinit
永远不会改变程序的功能行为(除非存在静态初始化顺序问题),它只会导致代码无法编译通过。
# 18.1.1 在实践中使用constinit
使用constinit
时,有几点需要注意2。
首先,不能用另一个constinit
修饰的值来初始化constinit
变量:
constinit auto x = f(); // f()必须是一个编译期函数
constinit auto y = x; // 错误:x不是常量初始化器
2
原因是初始值必须是编译期已知的常量值,而constinit
修饰的值并非常量。只有如下代码可以编译通过:
constexpr auto x = f(); // f()必须是一个编译期函数
constinit auto y = x; // 没问题
2
在初始化对象时,需要使用编译期构造函数,但不要求编译期析构函数。因此,可以将constinit
用于智能指针:
constinit std::unique_ptr<int> up; // 没问题
constinit std::shared_ptr<int> sp; // 没问题
2
constinit
并不意味着inline
(这与constexpr
不同)。例如:
class Type {
constinit static int val1 = 42; // 错误
inline static constinit int val2 = 42; // 没问题
...
};
2
3
4
5
可以将constinit
与extern
一起使用:
// 头文件:
extern constinit int max;
// 翻译单元:
constinit int max = 42;
2
3
4
5
为了达到同样的效果,也可以在声明时省略constinit
。但是,在定义时省略constinit
将不再强制在编译期进行初始化。
可以将constinit
与static
和thread_local
一起使用:
static thread_local constinit int numCalls = 0;
constinit
、static
和thread_local
的顺序可以任意。
注意,对于thread_local
变量,使用constinit
可能会提高性能,因为这可能避免生成的代码需要一个内部保护机制来判断变量是否已经被初始化:
extern thread_local int x1 = 0;
extern thread_local constinit int x2 = 0; // 更好(可能避免内部保护机制)
2
可以使用constinit
声明引用,但这没有实际意义,因为引用指向的是常量对象。这种情况下应该使用constexpr
。
# 18.1.2 constinit
如何解决静态初始化顺序问题
在C++中,存在一个称为静态初始化顺序问题(static initialization order fiasco)的问题,constinit
可以解决这个问题。该问题是指不同翻译单元中静态和全局变量的初始化顺序是未定义的。因此,以下代码可能会有问题:
- 假设有一个带有构造函数的类型用于初始化对象,并声明一个该类型的
extern
全局对象:
// comptime/truth.hpp
#ifndef TRUTH_HPP
#define TRUTH_HPP
struct Truth {
int value;
Truth() : value{42} { // 确保所有对象都初始化为42
}
};
extern Truth theTruth; // 声明全局对象
#endif // TRUTH_HPP
2
3
4
5
6
7
8
9
10
11
12
13
- 我们在其所在的翻译单元中初始化该对象:
// comptime/truth.cpp
#include "truth.hpp"
// 定义全局对象(其值应为42)
Truth theTruth;
2
3
4
- 然后在另一个翻译单元中,用
theTruth
初始化另一个全局/静态对象:
// comptime/fiasco.cpp
#include "truth.hpp"
#include <iostream>
int val = theTruth.value; // 可能在theTruth初始化之前就被初始化
int main() {
std::cout << val << "\n"; // 糟糕:可能是0或42
++val;
std::cout << val << "\n"; // 糟糕:可能是1或43
}
2
3
4
5
6
7
8
9
10
11
很有可能val
在theTruth
本身被初始化之前就用theTruth
进行了初始化。结果,程序可能会有如下输出(例如,当使用 GCC 编译器时,如果你在将 fiasco.o
传递给链接器之前先传递 truth.o
,就会得到这样的效果。):
0
1
2
当使用constinit
声明val
时,这个问题就不会出现。constinit
确保对象在使用前总是已经被初始化,因为初始化发生在编译期。如果无法保证这一点,代码将无法编译。注意,如果用constexpr
声明val
,也能保证初始化;但在这种情况下,就不能再修改该值了。
在我们的示例中,仅使用constinit
首先会导致一个编译期错误(提示无法在编译期保证初始化):
// truth.hpp:
struct Truth {
int value;
Truth() : value{42} {
}
};
extern Truth theTruth;
// 主翻译单元:
constinit int val = theTruth.value; // 错误:没有常量初始化器
2
3
4
5
6
7
8
9
10
这个错误信息在编译期提示val
无法在编译期被初始化。为使初始化有效,必须修改Truth
类和theTruth
的声明,以便theTruth
能在编译期使用:
// comptime/truthc.hpp
#ifndef TRUTH_HPP
#define TRUTH_HPP
struct Truth {
int value;
constexpr Truth() : value{42} { // 支持编译期初始化
}
};
constexpr Truth theTruth; // 强制编译期初始化
#endif // TRUTH_HPP
2
3
4
5
6
7
8
9
10
11
12
13
现在,程序可以编译,并且val
被保证会用theTruth
的初始化值进行初始化:
// comptime/constinit.cpp
#include "truthc.hpp"
#include <iostream>
constinit int val = theTruth.value; // 在theTruth初始化之后被初始化
int main() {
std::cout << val << "\n"; // 保证输出为42
++val;
std::cout << val << "\n"; // 保证输出为43
}
2
3
4
5
6
7
8
9
10
11
因此,现在该程序的输出保证为:
42
43
2
还有其他方法可以解决静态初始化顺序混乱的问题(比如使用静态函数获取值,或者使用inline
)。尽管如此,如果初始化不需要任何运行时值/特性,你或许应该仔细考虑采用一种总是用constinit
声明全局和静态变量的编程风格。在这样的函数中使用constinit
至少没有坏处:
long nextId() {
constinit static long id = 0;
return ++id;
}
2
3
4
# 18.2 关键字consteval
自C++11起,C++就有了关键字constexpr
,用于支持在编译时计算函数。如果函数的所有相关信息在编译时都是已知的,那么你也可以在编译时上下文(compile-time context)中使用其结果。不过,constexpr
函数也可以作为“普通”的运行时函数使用。
C++20引入了一个类似的关键字consteval
,它强制要求在编译时进行计算。与用constexpr
标记的函数不同,用consteval
标记的函数不能在运行时调用;相反,它们必须在编译时被调用。如果无法在编译时调用,程序就是格式错误的。因为这些函数在编译器看到调用时会立即被调用,所以它们也被称为立即函数(immediate functions) 。
# 18.2.1 第一个consteval示例
考虑以下示例:
// comptime/consteval1.cpp
#include <iostream>
#include <array>
constexpr
bool isPrime(int value) {
for (int i = 2; i <= value/2; ++i) {
if (value % i == 0) {
return false;
}
}
return value > 1; // 0和1不是质数
}
template<int Num>
consteval
std::array<int, Num> primeNumbers() {
std::array<int, Num> primes;
int idx = 0;
for (int val = 1; idx < Num; ++val) {
if (isPrime(val)) {
primes[idx++] = val;
}
}
return primes;
}
int main() {
// 用质数初始化:
auto primes = primeNumbers<100>();
for (auto v : primes) {
std::cout << v << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
在这里,我们使用consteval
定义了函数primeNumbers<N>()
,它在编译时返回前N
个质数的数组:
template<int Num>
consteval
std::array<int, Num> primeNumbers() {
std::array<int, Num> primes;
...
return primes;
}
2
3
4
5
6
7
为了计算质数,primeNumbers()
使用了一个辅助函数isPrime()
,该函数用constexpr
声明,以便在运行时和编译时都能使用(我们也可以用consteval
声明它,但那样它就不能在运行时使用了) 。
然后,程序使用primeNumbers<>()
初始化一个包含100个质数的数组:
auto primes = primeNumbers<100>();
因为primeNumbers<>()
是consteval
函数,所以这个初始化必须在编译时进行。如果primeNumbers<>()
用constexpr
声明,并且在编译时上下文(比如用于初始化一个constexpr
或constinit
数组prime
)中调用该函数,也会有相同的效果:
template<int Num>
constexpr
std::array<int, Num> primeNumbers() {
std::array<int, Num> primes;
...
return primes;
}
...
constinit static auto primes = primeNumbers<100>();
2
3
4
5
6
7
8
9
在这两种情况下,你都可以看到,当为初始化计算的质数数量显著增加时,编译时间会明显变慢。
不过要注意,编译器在计算常量表达式时通常是有限制的。C++标准仅保证在核心常量表达式中计算1,048,576个表达式。
# consteval lambda
现在你也可以将lambda声明为consteval
。这要求该lambda在编译时被求值。
考虑以下示例:
int main(int argc, char* argv[]) {
// 用于计算字符串字面量哈希值的编译时函数:
// (有关算法,请参见http://www.cse.yorku.ca/~oz/hash.html)
auto hashed = [](const char* str) consteval {
std::size_t hash = 5381;
while (*str != '\0') {
hash = hash * 33 ^ *str++;
}
return hash;
};
// 正确(在编译时上下文需要hashed()):
enum class drinkHashes : long { beer = hashed("beer"), wine = hashed("wine"), water = hashed("water"), ... };
// 正确(保证hashed()在编译时被调用):
std::array arr{hashed("beer"), hashed("wine"), hashed("water")};
if (argc > 1) {
switch (hashed(argv[1])) { // 错误:argv在编译时是未知的
...
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这里我们用一个只能在编译时使用的lambda初始化hashed
。因此,对该lambda的使用必须发生在编译时上下文且使用编译时值。只有当调用使用字符串字面量或constexpr const char*
类型的参数时才有效。
如果你用constexpr
声明该lambda,switch
语句就会有效。(用constexpr
声明不是必需的,因为自C++17起,所有lambda在可能的情况下都隐式是constexpr
的。)然而,这样就不能保证arr
初始值的计算发生在编译时了。
有关consteval
lambda的更多细节和另一个示例,请参见所有新lambda特性章节中关于consteval
lambda的讨论。
# 18.2.2 constexpr与consteval
有了constexpr
和consteval
,我们现在可以通过以下几种方式影响函数的调用时机:
- 既不用
constexpr
也不用consteval
:这些函数只能在运行时上下文使用。不过,编译器仍然可以执行在编译时计算它们的优化。 constexpr
:这些函数可以在编译时和运行时上下文使用。即使在运行时上下文,编译器也仍然可以执行在编译时计算函数的优化。编译器也被允许在运行时计算编译时上下文中的函数。consteval
:这些函数只能在编译时使用。不过,其结果可以在运行时上下文使用。
例如,考虑在头文件中声明的以下三个函数(因此,squareR()
用inline
声明):
// comptime/consteval2.hpp
// 仅用于运行时的square()函数:
inline int squareR(int x) {
return x * x;
}
// 用于编译时和运行时的square()函数:
constexpr int squareCR(int x) {
return x * x;
}
// 仅用于编译时的square()函数:
consteval int squareC(int x) {
return x * x;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
我们可以如下使用这些函数:
// comptime/consteval2.cpp
#include "consteval2.hpp"
#include <iostream>
#include <array>
int main() {
int i = 42;
// 在运行时使用运行时值调用square函数:
std::cout << squareR(i) << "\n"; // 正确
std::cout << squareCR(i) << "\n"; // 正确
//std::cout << squareC(i) << ’’; // 错误
// 在运行时使用编译时值调用square函数:
std::cout << squareR(42) << "\n"; // 正确
std::cout << squareCR(42) << "\n"; // 正确
std::cout << squareC(42) << "\n"; // 正确:在编译时计算square
// 在编译时使用square函数:
//std::array<int, squareR(42)> arr1; // 错误
std::array<int, squareCR(42)> arr2; // 正确:在编译时计算square
std::array<int, squareC(42)> arr3; // 正确:在编译时计算square
//std::array<int, squareC(i)> arr4; // 错误
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
constexpr
和consteval
之间的区别如下:
consteval
函数不允许处理编译时未知的参数:std::cout << squareCR(i) << '\n'; // 正确 std::cout << squareC(i) << '\n'; // 错误 std::array<int, squareC(i)> arr4; // 错误
1
2
3consteval
函数必须在编译时执行计算:
std::cout << squareCR(42) << '\n'; // 可能在编译时或运行时计算
std::cout << squareC(42) << '\n'; // 在编译时计算
2
这意味着在两种情况下使用consteval
是有意义的:
- 你希望强制进行编译时计算。
- 你希望禁止函数在运行时使用。
例如,在如下编译时上下文中,函数square()
或hashed()
是constexpr
还是consteval
并没有区别:
enum class Drink = { water = hashed("water "), wine = hashed( "wine ") };
switch (value) {
case square(42):
// ...
break;
}
if constexpr(hashed( "wine ") > hashed( "water ")) {
// ...
}
2
3
4
5
6
7
8
9
然而,在运行时上下文中,consteval
可能会产生差异,因为此时并不要求进行编译时计算:
std::array drinks = { hashed( "water "), hashed( "wine ") };
std::cout << hashed( "water ");
if (hashed( "wine ") > hashed( "water ")) {
// ...
}
2
3
4
5
# 18.2.3 在实践中使用consteval
对于consteval
函数,在实际使用时存在一些限制。
# consteval
的限制
consteval
函数与constexpr
函数在大多数其他方面是相同的(注意,C++20对constexpr
函数的这些限制有所放宽):
- 参数和返回类型(如果不是
void
)必须是字面量类型(literal types)。 - 函数体中只能包含字面量类型的变量,且这些变量既不能是
static
的,也不能是thread_local
的。 - 不允许使用
goto
和标签。 - 只有当类没有虚基类时,构造函数和析构函数才可以是编译时函数。
consteval
函数隐式内联。consteval
函数不能用作协程。
# 带有consteval
函数的调用链
标记为consteval
的函数可以调用其他标记为constexpr
或consteval
的函数:
constexpr int funcConstExpr(int i) {
return i;
}
consteval int funcConstEval(int i) {
return i;
}
consteval int foo(int i) {
return funcConstExpr(i) + funcConstEval(i); // 正确
}
2
3
4
5
6
7
8
9
然而,constexpr
函数不能调用consteval
函数处理变量:
consteval int funcConstEval(int i) {
return i;
}
constexpr int foo(int i) {
return funcConstEval(i); // 错误
}
2
3
4
5
6
函数foo()
无法编译。原因是i
仍然不是编译时的值,因为它可能在运行时被调用。foo()
只能使用编译时变量调用funcConstEval()
:
constexpr int foo(int i) {
return funcConstEval(42); // 正确
}
2
3
注意,在这里std::is_constant_evaluated()
也无济于事。
标记为consteval
的函数也不允许调用纯运行时函数(既未标记constexpr
也未标记consteval
的函数)。不过,只有在实际执行调用时才会检查这一点。对于consteval
函数,只要调用运行时函数的语句不会被执行,包含这些语句就不算错误(这条规则同样适用于在编译时调用的constexpr
函数)。
考虑以下示例:
void compileTimeError() {
}
consteval int nextTwoDigitValue(int val) {
if (val < 0 || val >= 99) {
compileTimeError(); // 调用在编译时无效的函数
}
return ++val;
}
2
3
4
5
6
7
8
这个编译时函数有一个有趣的效果,即它只能用于值在0到98之间的参数:
constexpr int i1 = nextTwoDigitValue(0); // 正确(将i1初始化为1)
constexpr int i2 = nextTwoDigitValue(98); // 正确(将i2初始化为99)
constexpr int i3 = nextTwoDigitValue(99); // 编译时错误
constexpr int i4 = nextTwoDigitValue(-1); // 编译时错误
2
3
4
注意,这里使用static_assert()
不起作用,因为它只能用于编译时已知的值,而consteval
并不会使函数内部的val
成为编译时的值:
consteval int nextTwoDigitValue(int val) {
static_assert(val >= 0 && val < 99); // 总是错误:val不是编译时的值
// ...
}
2
3
4
通过使用这个技巧,你可以将编译时函数限制为某些特定的值。这样,你可以在编译时解析字符串时,对无效格式发出信号。
# 18.2.4 编译时值与编译时上下文
你可能会认为编译时函数中的每个值都是编译时值。然而,事实并非如此。在编译时函数中,代码的静态类型和值的动态计算之间也存在差异。
考虑以下示例:
consteval void process() {
constexpr std::array a1{0, 8, 15};
constexpr auto n1 = std::ranges::count(a1, 0); // 正确
std::array<int, n1> a1b; // 正确
std::array a2{0, 8, 15};
constexpr auto n2 = std::ranges::count(a2, 0); // 错误
std::array a3{0, 8, 15};
auto n3 = std::ranges::count(a3, 0); // 正确
std::array<int, n3> a3b; // 错误
}
2
3
4
5
6
7
8
9
10
尽管我们处于一个只能在编译时使用的函数中,但a2
不是编译时值。因此,它不能用于通常需要编译时值的地方,比如初始化constexpr
变量n2
。
出于同样的原因,只有n1
可以用于声明std::array
的大小。使用不是constexpr
的n3
会失败(即使将n3
声明为const
也不行)。
如果将process()
声明为constexpr
函数,情况也是一样。它是否在编译时上下文被调用并不重要。
# 18.3 对constexpr
函数放宽的限制
从C++11到C++20,每个C++版本都放宽了对constexpr
函数的限制。这也意味着consteval
函数现在也有这些限制。
基本上,constexpr
和consteval
函数现在的限制如下:
- 参数和返回类型(如果有)必须是字面量类型。
- 函数体只能定义字面量类型的变量。这些变量既不能是
static
的,也不能是thread_local
的。 - 不允许使用
goto
和标签。 - 只有当类没有虚基类时,构造函数和析构函数才可以是编译时函数。
- 这些函数不能用作协程。这些函数隐式内联。
# 18.4 std::is_constant_evaluated()
C++20提供了一个新的辅助函数std::is_constant_evaluated()
,它允许程序员为编译时和运行时计算实现不同的代码。该函数定义在头文件<type_traits>
中(尽管它实际上并不是一个类型函数)。
它允许代码在只能在运行时调用的辅助函数和可以在编译时使用的代码之间进行切换。例如:
// comptime/isconsteval.hpp
#include <type_traits>
#include <cstring>
constexpr int len(const char* s) {
if (std::is_constant_evaluated()) {
int idx = 0;
while (s[idx] != '\0') { // 适合编译时的代码
++idx;
}
return idx;
}
else {
return std::strlen(s); // 运行时调用的函数
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在函数len()
中,我们计算原始字符串或字符串字面量的长度。如果在运行时调用,我们使用标准C函数strlen()
。然而,为了使该函数也能在编译时使用,如果处于编译时上下文,我们提供了不同的实现。
下面展示这两个分支的调用方式:
constexpr int l1 = len("hello"); // 使用then分支
int l2 = len("hello"); // 使用else分支(不需要编译时上下文)
2
第一次调用len()
发生在编译时上下文。在这种情况下,is_constant_evaluated()
返回true
,因此我们使用then
分支。第二次调用len()
发生在运行时上下文,所以is_constant_evaluated()
返回false
,并调用strlen()
。即使编译器决定在编译时计算该调用,后者也会发生。关键在于调用是否必须在编译时进行。
下面是一个相反的函数示例:它在编译时和运行时都将整数值转换为字符串:
// comptime/asstring.hpp
#include <string>
#include <format>
// 将整数值转换为std::string
// - 可以在编译时或运行时调用
constexpr std::string asString(long long value) {
if (std::is_constant_evaluated()) {
// 编译时版本:
if (value == 0) {
return "0 ";
}
if (value < 0) {
return "- " + asString(-value);
}
std::string s = asString(value / 10) + std::string(1, value % 10 + '0');
if (s.size() > 1 && s[0] == '0') { // 如果有前导0则跳过
s.erase(0, 1);
}
return s;
}
else {
// 运行时版本:
return std::format("{} ", value);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
在运行时,我们简单地使用std::format()
。在编译时,我们手动创建一个包含可选负号和所有数字的字符串(我们使用递归方法将数字按正确顺序排列)。在关于将编译时字符串导出为运行时字符串的部分,你可以找到使用该函数的示例。
# 18.4.1 std::is_constant_evaluated()详解
根据C++20标准,当std::is_constant_evaluated()
在明显常量求值表达式或转换中被调用时,它返回true
。大致在以下情况下会出现这种情况:
- 在常量表达式中调用。
- 在常量上下文中调用(在
if constexpr
、consteval
函数或常量初始化中)。 - 用于可在编译时使用的变量的初始化器中。
例如:
constexpr bool isConstEval() {
return std::is_constant_evaluated();
}
bool g1 = isConstEval(); // true
const bool g2 = isConstEval(); // true
static bool g3 = isConstEval(); // true
static int g4 = g1 + isConstEval(); // false
int main() {
bool l1 = isConstEval(); // false
const bool l2 = isConstEval(); // true
static bool l3 = isConstEval(); // true
int l4 = g1 + isConstEval(); // false
const int l5 = g1 + isConstEval(); // false
static int l6 = g1 + isConstEval(); // false
int l7 = isConstEval() + isConstEval(); // false
const auto l8 = isConstEval() + 42 + isConstEval(); // true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
如果通过constexpr
函数间接调用isConstEval()
,我们会得到相同的结果。
# std::is_constant_evaluated()与constexpr和consteval
例如,假设我们定义了以下函数:
bool runtimeFunc() {
return std::is_constant_evaluated(); // 始终为false
}
constexpr bool constexprFunc() {
return std::is_constant_evaluated(); // 可能为false或true
}
consteval bool constevalFunc() {
return std::is_constant_evaluated(); // 始终为true
}
2
3
4
5
6
7
8
9
10
11
那么我们有以下情况:
void foo() {
bool b1 = runtimeFunc(); // false
bool b2 = constexprFunc(); // false
bool b3 = constevalFunc(); // true
static bool sb1 = runtimeFunc(); // false
static bool sb2 = constexprFunc(); // true
static bool ab3 = constevalFunc(); // true
const bool cb1 = runtimeFunc(); // 错误
const bool cb2 = constexprFunc(); // true
const bool cb3 = constevalFunc(); // true
int y = 42;
static bool sb4 = y + runtimeFunc(); // 函数返回false
static bool sb5 = y + constexprFunc();// 函数返回false
static bool ab6 = y + constevalFunc();// 函数返回true
const bool cb4 = y + runtimeFunc(); // 函数返回false
const bool cb5 = y + constexprFunc(); // 函数返回false
const bool cb6 = y + constevalFunc(); // 函数返回true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
使用constexpr
或static constinit
代替const
,没有y
参与的初始化会产生与cb1
、cb2
和cb3
相同的效果。然而,当涉及y
时,使用constexpr
或static constinit
总是会导致错误,因为涉及到运行时的值。
一般来说,在以下情况下使用std::is_constant_evaluated()
没有意义:
- 作为编译时
if
的条件,因为这总是返回true
:
if constexpr (std::is_constant_evaluated()) { // 总是为true
...
}
2
3
在if constexpr
内部,我们处于编译时上下文,这意味着关于我们是否处于编译时上下文这个问题的答案总是true
(无论整个函数是从什么上下文被调用的)。
- 在纯运行时函数内部,因为这通常返回
false
。 唯一的例外是is_constant_evaluated()
用于局部常量求值时:
void foo() {
if (std::is_constant_evaluated()) { // 总是为false
...
}
const bool b = std::is_constant_evaluated(); // true
}
2
3
4
5
6
- 在
consteval
函数内部,因为这总是返回true
:
consteval void foo() {
if (std::is_constant_evaluated()) { // 总是为true
...
}
}
2
3
4
5
因此,使用std::is_constant_evaluated()
通常只在constexpr
函数中有意义。在constexpr
函数内部使用std::is_constant_evaluated()
来调用consteval
函数也没有意义,因为一般情况下不允许从constexpr
函数调用consteval
函数(C++23可能会通过if consteval
使类似这样的代码可行。):
consteval int funcConstEval(int i) {
return i;
}
constexpr int foo(int i) {
if (std::is_constant_evaluated()) {
return funcConstEval(i); // 错误
}
else {
return funcRuntime(i);
}
}
2
3
4
5
6
7
8
9
10
11
12
# std::is_constant_evaluated()与运算符?:
在C++20标准中,有一个有趣的示例来阐明std::is_constant_evaluated()
的使用方式。稍微修改后,代码如下:
int sz = 10;
constexpr bool sz1 = std::is_constant_evaluated() ? 20 : sz; // true,所以是20
constexpr bool sz2 = std::is_constant_evaluated() ? sz : 20; // false,所以报错
2
3
这种行为的原因如下:
sz1
和sz2
的初始化要么是静态初始化,要么是动态初始化。- 对于静态初始化,初始化器必须是常量。因此,编译器尝试将
std::is_constant_evaluated()
视为值为true
的常量来计算初始化器。- 对于
sz1
,这是成功的。结果是1
,这是一个常量。因此,sz1
是用20
进行常量初始化的。 - 对于
sz2
,结果是sz
,它不是一个常量。因此,sz2
(理论上)是动态初始化的。因此,之前的结果被丢弃,并且在计算初始化器时std::is_constant_evaluated()
产生false
。因此,初始化sz2
的表达式也是20
。
- 对于
然而,sz2
不一定是常量,因为在这个计算过程中std::is_constant_evaluated()
不一定是常量表达式。因此,用这个20
初始化sz2
无法编译。
使用const
代替constexpr
会使情况更加复杂:
int sz = 10;
const bool sz1 = std::is_constant_evaluated() ? 20 : sz; // true,所以是20
const bool sz2 = std::is_constant_evaluated() ? sz : 20; // false,所以也是20
double arr1[sz1]; // 没问题
double arr2[sz2]; // 可能编译,也可能不编译
2
3
4
5
只有sz1
是编译时常量,并且总是可以用于初始化数组。由于上述原因,sz2
也被初始化为20
。然而,因为初始值不一定是常量,arr2
的初始化可能编译,也可能不编译(这取决于所使用的编译器和优化设置)。
# 18.5 在编译时使用堆内存、向量和字符串
从C++20开始,编译时函数可以分配内存,前提是这些内存也在编译时释放。因此,现在可以在编译时使用字符串或向量。不过,有一个重要的限制:编译时创建的字符串或向量不能在运行时使用。原因是编译时分配的内存也必须在编译时释放。
# 18.5.1 在编译时使用向量
下面是一个在编译时使用std::vector<>
的示例:
[`comptime/vector.hpp`]
#include <vector>
#include <ranges>
#include <algorithm>
#include <numeric>
template<std::ranges::input_range T>
constexpr auto modifiedAvg(const T& rg) {
using elemType = std::ranges::range_value_t<T>;
// 用传递范围的元素初始化编译时向量:
std::vector<elemType> v{std::ranges::begin(rg), std::ranges::end(rg)};
// 进行一些修改:
v.push_back(elemType{});
std::ranges::sort(v);
auto newEnd = std::unique(v.begin(), v.end());
...
// 返回修改后向量的平均值:
auto sum = std::accumulate(v.begin(), newEnd, elemType{});
return sum / static_cast<double>(v.size());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这里,我们用constexpr
定义了modifiedAvg()
,这样它就可以在编译时被调用。在函数内部,我们使用std::vector<>
v
,并用传递范围的元素对其进行初始化。这使我们能够使用向量的完整API(特别是插入和删除元素的功能)。例如,我们插入一个元素,对元素进行排序,并使用unique()
删除连续的重复元素。现在所有这些算法都是constexpr
的,因此我们可以在编译时使用它们。
不过,最后我们并没有返回向量,只是返回了借助编译时向量计算出的值。
我们可以在编译时调用这个函数:
// comptime/vector.cpp
#include "vector.hpp"
#include <iostream>
#include <array>
int main() {
constexpr std::array orig{0, 8, 15, 132, 4, 77};
constexpr auto avg = modifiedAvg(orig);
std::cout << "average : " << avg << "\n";
}
2
3
4
5
6
7
8
9
10
由于avg
是用constexpr
声明的,modifiedAvg()
会在编译时求值。
我们也可以用consteval
声明modifiedAvg()
,在这种情况下,我们可以按值传递参数,因为在运行时不会复制元素:
template<std::ranges::input_range T>
consteval auto modifiedAvg(T rg)
{
using elemType = std::ranges::range_value_t<T>;
// 用传递范围的元素初始化编译时向量:
std::vector<elemType> v{std::ranges::begin(rg),
std::ranges::end(rg)};
...
}
2
3
4
5
6
7
8
9
然而,如果一个向量可以在运行时使用,我们仍然不能在编译时声明并初始化它:
int main() {
constexpr std::vector orig{0, 8, 15, 132, 4, 77}; // 错误
...
}
2
3
4
出于同样的原因,编译时函数只有在返回值在编译时使用的情况下,才能将向量返回给调用者:
// comptime/returnvector.cpp
#include <vector>
constexpr auto returnVector() {
std::vector<int> v{0, 8, 15};
v.push_back(42);
...
return v;
}
constexpr auto returnVectorSize() {
auto coll = returnVector();
return coll.size();
}
int main() {
//constexpr auto coll = returnVector(); // 错误
constexpr auto tmp = returnVectorSize(); // 正确
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 18.5.2 在编译时返回一个集合
虽然不能返回一个编译时向量以便在运行时使用,但有一种方法可以返回在编译时计算出的元素集合:你可以返回一个std::array<>
。唯一的问题是你需要知道数组的大小,因为数组的大小不能由向量的大小来初始化:
std::vector v;
...
std::array<int, v.size()> arr; // 错误
2
3
这样的代码永远无法编译,因为size()
是一个运行时值,而arr
的声明需要一个编译时值。这段代码是否在编译时求值并不重要。因此,你必须返回一个固定大小的数组。例如:
// comptime/mergevalues.hpp
#include <vector>
#include <ranges>
#include <algorithm>
#include <array>
template<std::ranges::input_range T>
consteval auto mergeValues(T rg, auto... vals) {
// 从传递的范围创建编译时向量:
std::vector<std::ranges::range_value_t<T>> v{std::ranges::begin(rg), std::ranges::end(rg)};
(... , v.push_back(vals)); // 合并所有传递的参数
std::ranges::sort(v); // 对所有元素进行排序
// 将扩展后的集合作为数组返回:
constexpr auto sz = std::ranges::size(rg) + sizeof...(vals);
std::array<std::ranges::range_value_t<T>, sz> arr{};
std::ranges::copy(v, arr.begin());
return arr;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
我们在一个consteval
函数中使用向量,将可变数量的传递参数与传递范围的元素合并,并对它们进行排序。不过,我们将结果集合作为std::array
返回,这样它就可以传递到运行时上下文。例如,下面的程序使用这个函数就没问题:
// comptime/mergevalues.cpp
#include "mergevalues.hpp"
#include <iostream>
#include <array>
int main() {
// 数组的编译时初始化:
constexpr std::array orig{0, 8, 15, 132, 4, 77, 3};
// 初始化排序后的扩展数组:
auto merged = mergeValues(orig, 42, 4);
// 打印元素:
for(const auto& i : merged) {
std::cout << i << " ";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
程序输出如下:
0 3 4 4 8 15 42 77 132
如果在编译时不知道数组的最终大小,我们就必须声明一个最大大小的返回数组,并额外返回结果的大小。现在合并值的函数可能如下所示:
// comptime/mergevaluessz.hpp
#include <vector>
#include <ranges>
#include <algorithm>
#include <array>
template<std::ranges::input_range T>
consteval auto mergeValuesSz(T rg, auto... vals) {
// 从传递的范围创建编译时向量:
std::vector<std::ranges::range_value_t<T>> v{std::ranges::begin(rg), std::ranges::end(rg)};
(... , v.push_back(vals)); // 合并所有传递的参数
std::ranges::sort(v); // 对所有元素进行排序
// 将扩展后的集合作为数组返回,并返回其大小:
constexpr auto maxSz = std::ranges::size(rg) + sizeof...(vals);
std::array<std::ranges::range_value_t<T>, maxSz> arr{};
auto res = std::ranges::unique_copy(v, arr.begin());
return std::pair{arr, res.out - arr.begin()};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
此外,我们使用std::ranges::unique_copy()
在排序后删除连续的重复元素,并返回数组和结果元素的数量。
注意,应该用{}
声明arr
,以确保数组中的所有值都被初始化。编译时函数不允许产生未初始化的内存。
我们现在可以如下使用返回值:
// comptime/mergevaluessz.cpp
#include "mergevaluessz.hpp"
#include <iostream>
#include <array>
#include <ranges>
int main() {
// 数组的编译时初始化:
constexpr std::array orig{0, 8, 15, 132, 4, 77, 3};
// 初始化排序后的扩展数组:
auto tmp = mergeValuesSz(orig, 42, 4);
auto merged = std::views::counted(tmp.first.begin(), tmp.second);
// 打印元素:
for(const auto& i : merged) {
std::cout << i << " ";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
通过使用视图适配器std::views::counted()
,我们可以轻松地将返回的数组和返回的元素数量组合成一个单一范围来使用。
现在程序的输出是:
0 3 4 8 15 42 77 132
# 18.5.3 在编译时使用字符串
对于编译时字符串,现在所有操作都是constexpr
的。因此,现在可以在编译时使用std::string
,也可以使用任何其他字符串类型,如std::u8string
。
然而,同样存在限制,即不能在运行时使用编译时字符串。例如:
consteval std::string returnString() {
std::string s = "Some string from compile time ";
...
return s;
}
void useString() {
constexpr auto s = returnString(); // 错误
...
}
constexpr void useStringInConstexpr() {
std::string s = returnString(); // 错误
...
}
consteval void useStringInConsteval() {
std::string s = returnString(); // 正确
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
你也不能通过仅使用data()
、c_str()
或将其作为std::string_view
来返回编译时字符串以解决这个问题。这样做会返回编译时分配的内存地址。如果发生这种情况,编译器会抛出编译时错误。
不过,我们可以使用与上面向量相同的技巧。我们可以将字符串转换为固定大小的数组,并返回数组和向量的大小。
以下是一个完整的示例:
[`comptime/comptimestring.cpp`]
#include <iostream>
#include <string>
#include <array>
#include <cassert>
#include "asstring.hpp"
// 将编译时字符串导出到运行时的函数模板:
template<int MaxSize>
consteval auto toRuntimeString(std::string s) {
// 确保导出数组的大小足够大:
assert(s.size() <= MaxSize);
// 创建一个编译时数组并将所有字符复制到其中:
std::array<char, MaxSize+1> arr{}; // 确保所有元素都被初始化
for (int i = 0; i < s.size(); ++i) {
arr[i] = s[i];
}
// 返回编译时数组和字符串大小:
return std::pair{arr, s.size()};
}
// 在运行时导入导出的编译时字符串的函数:
std::string fromComptimeString(const auto& dataAndSize) {
// 用导出的字符数组和大小初始化字符串:
return std::string{dataAndSize.first.data(), dataAndSize.second};
}
// 测试函数:
consteval auto comptimeMaxStr() {
std::string s = "max int is " + asString(std::numeric_limits<int>::max()) + " ( " + asString(std::numeric_limits<int>::digits + 1)
+ " bits) ";
return toRuntimeString<100>(s);
}
int main() {
std::string s = fromComptimeString(comptimeMaxStr());
std::cout << s << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
同样,我们定义了两个辅助函数,用于将编译时字符串导出为运行时字符串:
- 编译时函数
toRuntimeString()
将字符串转换为std::array<>
,并返回数组和字符串的大小:
template<int MaxSize>
consteval auto toRuntimeString(std::string s) {
assert(s.size() <= MaxSize); // 确保数组大小合适
// 创建一个编译时数组并将所有字符复制到其中:
std::array<char, MaxSize+1> arr{}; // 确保所有元素都被初始化
for (int i = 0; i < s.size(); ++i) {
arr[i] = s[i];
}
return std::pair{arr, s.size()}; // 返回数组和大小
}
2
3
4
5
6
7
8
9
10
通过使用assert()
,我们再次检查数组的大小是否足够大。同样,我们可以在编译时再次检查,确保不会浪费太多内存。
- 运行时函数
fromComptimeString()
获取返回的数组和大小,以初始化一个运行时字符串并返回它:
std::string fromComptimeString(const auto& dataAndSize) {
return std::string{dataAndSize.first.data(), dataAndSize.second};
}
2
3
函数的测试用例使用了辅助函数asString()
,它可以在编译时和运行时将整数值转换为字符串。
例如,该程序的输出如下:
max int is 2147483647 (32 bits)
# 18.6 其他constexpr
扩展
除了能够在编译时使用堆内存之外,自C++20起,编译时函数(无论用constexpr
还是consteval
声明)还可以使用一些额外的语言特性。因此,此外还有一些库特性现在也能在编译时使用。
# 18.6.1 constexpr
语言扩展
自C++20起,以下语言特性可用于编译时函数(无论用constexpr
还是consteval
声明):
- 现在可以在编译时使用堆内存。
- 支持运行时多态性:
- 现在可以使用虚函数。
- 现在可以使用
dynamic_cast
。 - 现在可以使用
typeid
。
- 现在可以使用
try-catch
块(但仍然不允许抛出异常)。 - 现在可以更改联合(union)的活动成员。
注意,在constexpr
或consteval
函数中仍然不允许使用static
。
# 18.6.2 constexpr
库扩展
C++标准库扩展了可在编译时使用的实用工具。
# constexpr
算法和实用函数
<algorithm>
、<numeric>
和<utility>
中的大多数算法现在都是constexpr
的。这意味着现在可以在编译时对元素进行排序和累加(见编译时使用向量的示例)。
不过,并行算法(带有执行策略参数的算法)仍然只能在运行时使用。
# constexpr
库类型
C++标准库的几种类型现在对constexpr
有了更好的支持,使得对象能(更好地)在编译时使用:
- 现在可以在编译时使用向量和字符串。
- 一些
std::complex<>
操作变成了constexpr
的。 - 为
std::optional<>
和std::variant<>
添加了一些缺失的constexpr
支持。 - 为
std::invoke()
、std::ref()
、std::cref()
、mem_fn()
、not_fn()
、std::bind()
和std::bind_front()
添加了constexpr
。 - 在
pointer_traits
中,针对原始指针的pointer_to()
现在可以在编译时使用。
# 18.7 补充说明
关键字constinit
最初由Eric Fiselier在http://wg21.link/p1143r0 (opens new window)中作为一个属性提出。最终被接受的措辞由Eric Fiselier在http://wg21.link/p1143r2 (opens new window)中制定。关键字consteval
最初由Richard Smith、Andrew Sutton和Daveed Vandevoorde在http://wg21.link/p1073r0 (opens new window)中提出。最终被接受的措辞由Richard Smith、Andrew Sutton和Daveed Vandevoorde在http://wg21.link/p1073r3 (opens new window)中制定。后来David Stone在http://wg21.link/p1937r2 (opens new window)中进行了一个小的修正。
std::is_constant_evaluated()
最初由Daveed Vandevoorde在http://wg21.link/p0595r0 (opens new window)中作为一个constexpr
操作符提出。最终被接受的措辞由Richard Smith、Andrew Sutton和Daveed Vandevoorde在http://wg21.link/p0595r2 (opens new window)中制定。
编译期容器(如向量和字符串)最初由Daveed Vandevoorde在http://wg21.link/p0597 (opens new window)中提出。最终被接受的语言特性由Peter Dimov、Louis Dionne、Nina Ranns、Richard Smith和Daveed Vandevoorde在http://wg21.link/p0784r7 (opens new window)中制定。最终被接受的库特性由Antony Polukhin在http://wg21.link/p0858r0 (opens new window)中以及Louis Dionne在http://wg21.link/p1004r2 (opens new window)和http://wg21.link/00980r1 (opens new window)中制定。