Constexpr和consteval函数
本节阅读量:在前面的章节中,我们介绍了 constexpr 关键字,它用于创建编译时(符号)常量。我们还介绍了常量表达式,这类表达式可以在编译时计算,而不必等到运行时才计算。
考虑下面这个使用了两个 constexpr 变量的程序:
|
|
运行后会得到如下结果:
|
|
由于 x 和 y 都是 constexpr,编译器可以在编译时直接计算常量表达式 (x > y ? x : y),得到结果 6。因为这个表达式不再需要在运行时求值,所以程序会运行得更快。
然而,把一个较为复杂的表达式塞在 print 语句中间并不理想——如果能将它封装成一个具名函数就更好了。下面是使用函数改写后的同一示例:
|
|
该程序产生的输出与之前的版本相同。但把表达式放进函数里有一个缺点:对 greater(x, y) 的调用会在运行时执行。通过引入函数(这有利于代码的模块化和可读性),我们失去了在编译时进行计算的能力(这对性能有害)。
那么我们该如何解决这个问题呢?
Constexpr 函数可以在编译时计算
constexpr 函数是指其返回值可以在编译时计算的函数。要将一个函数声明为 constexpr 函数,只需在返回类型前加上 constexpr 关键字即可。下面是一个与前面类似的程序,改用了 constexpr 函数:
|
|
该程序的输出与前一个示例相同,但函数调用 greater(x, y) 会在编译时计算,而不是在运行时计算!
当编译到对应的函数调用时,编译器会计算该函数调用的返回值,然后用该返回值替换整个函数调用。
因此在我们的示例中,对 greater(x, y) 的调用将被其返回值——整数 6——所替换。换句话说,编译器实际上编译的内容等价于:
|
|
要想有资格进行编译时计算,函数必须具有 constexpr 返回类型,并且在编译时求值过程中不能调用任何非 constexpr 函数。此外,对函数的调用必须传入 constexpr 参数(例如 constexpr 变量或字面量)。
上面示例中的 greater() 函数定义和函数调用都满足这些要求,因此可以在编译时求值。
注
在本文后面,我们会使用“有资格进行编译时求值”这一术语,请记住它的含义。
Constexpr 函数也可以在运行时求值
具有 constexpr 返回值的函数同样可以在运行时求值,此时它返回的是非 constexpr 的结果。例如:
|
|
在本例中,由于参数 x 和 y 不是 constexpr,因此无法在编译时调用该函数。函数将在运行时被调用,并以非 constexpr 的 int 作为返回值。
关键点
允许 constexpr 返回类型的函数在编译时或运行时求值,这样同一个函数便可以同时满足两种场景。
否则,你就得写两个独立的函数(一个有 constexpr 返回类型,一个没有)。这不仅会导致代码重复,而且这两个函数还必须使用不同的名字!
这也解释了 C++ 为何不允许 constexpr 的函数参数。因为如果参数是 constexpr,就意味着只能用 constexpr 实参来调用函数。但事实并非如此——当函数在运行时求值时,可以用非 constexpr 的实参来调用 constexpr 函数。
那么,constexpr 函数何时会在编译时求值?
你可能以为 constexpr 函数会尽可能地在编译时求值,但不幸的是,实际情况并非如此。
根据 C++ 标准:当返回值被用在需要常量表达式的地方时,有资格进行编译时计算的 constexpr 函数必须在编译时计算。否则,编译器可以自行决定是在编译时还是运行时计算该函数。
让我们通过几个案例来进一步说明:
|
|
在情况 1 中,我们用 constexpr 参数调用 greater(),因此它有资格在编译时求值。constexpr 变量 g 的初始化值必须是常量表达式,这里返回值正好被用在需要常量表达式的上下文中。因此,greater() 必须在编译时计算。
在情况 2 中,我们用一个非 constexpr 的参数调用 greater()。因此 greater() 无法在编译时求值,只能在运行时求值。
情况 3 比较有趣。我们再次使用 constexpr 参数调用 greater() 函数,因此它有资格进行编译时计算。但返回值并没有用在需要常量表达式的上下文中(<< 运算符总是在运行时执行),因此编译器可以自行选择是在编译时还是运行时计算 greater() 的调用!
需要注意的是,编译器的优化级别设置可能会影响它在编译时还是运行时计算函数的决定。这也意味着,对于调试构建和发布构建,编译器可能做出不同的选择(因为调试构建通常关闭了优化)。
关键点
只有当返回值被用在需要常量表达式的地方时,有资格进行编译时计算的 constexpr 函数才一定会在编译时求值。否则,无法保证一定会在编译时计算。
因此,把 constexpr 函数理解为“可以在常量表达式中使用”比理解为“会在编译时求值”更合适。
最佳实践
除非有特别的理由不这样做,否则通常应把可以声明为 constexpr 的函数都声明为 constexpr。
判断 constexpr 函数的调用是在编译时还是运行时求值
在 C++20 之前,语言并没有提供标准手段来做这件事。
C++20 引入了 std::is_constant_evaluated()(定义在 <type_traits> 头文件中),它会返回一个布尔值,指示当前函数调用是否在常量上下文中执行。结合条件语句使用,就可以让函数在编译时和运行时展现不同的行为。
|
|
巧妙地使用这一特性,可以让函数在编译时求值时产生一些可观察到的差异(例如返回一个特殊的值),然后根据这个结果反推它是在哪种场景下被求值的。
强制让 constexpr 函数在编译时计算
目前没有办法告诉编译器:constexpr 函数应尽可能在编译时求值(特别是返回值未被用在常量表达式的场合)。
不过,我们可以通过确保在需要常量表达式的位置使用返回值,来强制有资格在编译时计算的 constexpr 函数在编译时实际被计算。
最常用的方法是用其返回值去初始化一个 constexpr 变量(这也是我们在前面示例中使用变量 g 的原因)。不过这样做需要在程序中额外引入一个新变量来强制编译时求值,不够美观,并且会降低代码的可读性。
C++20 为这个问题提供了一个更好的解决方案,我们稍后会介绍。
Consteval(C++20)
C++20 引入了关键字 consteval,用于指定函数必须在编译时求值,否则会导致编译错误。此类函数被称为即时函数(immediate functions)。
|
|
在上面的示例中,前两次对 greater() 的调用都可以在编译时计算。而对 greater(x, 6) 的调用无法在编译时计算,因此会导致编译错误。
最佳实践
如果出于某种原因(例如性能)要求函数必须在编译时执行,请使用 consteval。
C++20 中利用 consteval 使 constexpr 在编译时执行
consteval 函数的缺点是它无法在运行时求值,因此不如 constexpr 函数灵活。于是,人们希望有一种便捷方式来强制 constexpr 函数在编译时求值(即使其返回值被用在不需要常量表达式的场合),这样我们就可以在条件允许时进行编译时计算,而在无法编译时计算时也能正常地进行运行时求值。
consteval 函数提供了一种实现这一点的方法,即使用一个辅助函数:
|
|
这满足了我们的需求,因为 consteval 函数要求其参数必须是常量表达式——因此,如果我们将 constexpr 函数的返回值作为 consteval 函数的参数,那么这个 constexpr 函数就必须在编译时求值!consteval 函数只不过是把这个参数原样返回,所以调用方依旧能拿到它。
注意,consteval 函数是按值返回的。在运行时这样做可能效率不高(特别是当值是复制代价较高的类型,例如 std::string),但在编译时这无关紧要,因为对 consteval 函数的整个调用会被直接替换为其计算后的返回值。
相关内容
我们会在——函数的类型推导——一节中介绍 auto 返回类型。并在——具有多个模板类型的函数模板——一节中介绍简写函数模板(auto 参数)。
Constexpr/consteval 函数是隐式内联的
因为 constexpr 函数可以在编译时求值,所以编译器必须在每一个调用该函数的地方都能看到它的完整定义。前向声明是不够的,即便该函数的实际定义稍后在同一个编译单元中出现也不行。
这意味着,在多个文件中被调用的 constexpr 函数,需要在每个调用它的文件中都包含其定义——这通常会违反单一定义规则。为避免这一问题,constexpr 函数是隐式内联的,这样它们就不再受单一定义规则的约束。
因此,constexpr 函数通常定义在头文件中,这样就可以通过 #include 被任何需要其完整定义的 .cpp 文件使用。
出于同样的原因,consteval 函数也是隐式内联的。
规则
编译器必须能看到 constexpr 或 consteval 函数的完整定义,仅有前向声明是不够的。
最佳实践
如果 constexpr/consteval 函数只在单个源文件(.cpp)中使用,则需要在使用之前就在该文件中定义它。
如果 constexpr/consteval 函数会在多个源文件中使用,则应将其定义在头文件中,以便能够被各个源文件包含。
constexpr/consteval 函数的参数不是 constexpr,但可以作为参数传给其他 constexpr 函数
constexpr 函数的参数不是 constexpr(因此不能在常量表达式中使用)。参数可以声明为 const(此时它们被视为运行时常量),但不能声明为 constexpr。这是因为 constexpr 函数允许在运行时求值(如果参数是编译时常量,那就无法在运行时求值了)。
不过有一种例外情况:constexpr 函数可以将自身的参数作为实参传递给另一个 constexpr 函数,并且仍然可以对后续调用的 constexpr 函数进行编译时求值。这使得 constexpr 函数在调用其他 constexpr 函数(或递归地调用自己)时,仍可参与编译时计算。
也许会令人意外的是,consteval 函数的参数同样不被视为 constexpr 变量(尽管 consteval 函数本身只能在编译时求值)。这一决定是为了保持一致性。
相关内容
如果在函数中需要 constexpr 参数(例如需要在常量表达式的位置使用),请参阅后续的——非类型模板参数——一节。
constexpr 函数可以调用非常量表达式函数吗?
答案是肯定的,但前提是 constexpr 函数是在非常量上下文中被求值。当 constexpr 函数在常量上下文中被求值时,就不能调用非常量表达式的函数了(否则,constexpr 函数就无法生成编译时常量值)。
允许调用非常量表达式函数,是为了让 constexpr 函数能写出如下形式的代码:
|
|
再考虑一下这个变体:
|
|
只要永远不在常量表达式中调用 someFunction(false),这段代码就是合法的。
C++ 标准规定,constexpr 函数必须至少为某组参数返回 constexpr 值,否则它在技术上就属于格式错误的程序。因此,在 constexpr 函数中无条件地调用非常量表达式函数会导致该 constexpr 函数本身格式错误。不过,编译器并不要求必须为这种情况生成错误或警告——因此,除非你尝试在常量上下文中调用这样的 constexpr 函数,编译器可能并不会报错。
基于上述原因,建议:
- 尽量避免在 constexpr 函数中调用非 constexpr 函数。
- 如果 constexpr 函数需要根据运行时与编译时计算区分不同的行为,请使用 std::is_constant_evaluated()。
- 测试 constexpr 函数时,请在常量上下文中测试。因为 constexpr 函数在非常量上下文中可能可以编译通过,但在常量上下文中未必能够编译通过。