F-2 constexpr 函数(第二部分)

非必需常量表达式中的 constexpr 函数调用

您可能认为 constexpr 函数会在可能时尽早于编译时求值,但实际情况并非如此。

第 5.5 课——常量表达式中,我们提到当表达式所在上下文不要求其为常量时,编译器可选择在编译时或运行时求值常量表达式。因此,任何作为非必需常量表达式组成部分的 constexpr 函数调用,都可能在编译时或运行时进行求值。

例如:

#include <iostream>

constexpr int getValue(int x)
{
    return x;
}

int main()
{
    int x { getValue(5) }; // may evaluate at runtime or compile-time

    return 0;
}

在上例中,由于 getValue() 是 constexpr 函数,调用 getValue(5) 即构成常量表达式。然而由于变量 x 并非 constexpr,其初始化器无需常量表达式。因此即使我们提供了常量表达式初始化器,编译器仍可自由选择在运行时或编译时求值 getValue(5)。

关键要点
仅当常量表达式被强制要求时,编译器才保证对 constexpr 函数进行编译时求值。


constexpr 函数在必需常量表达式中的诊断

编译器无需在编译时预先判定 constexpr 函数是否可求值,直至实际在编译时进行求值。编写出能在运行时成功编译的 constexpr 函数却在编译时求值失败的情况相当常见。

以下是一个简单的示例:

#include <iostream>

int getValue(int x)
{
    return x;
}

// This function can be evaluated at runtime
// When evaluated at compile-time, the function will produce a compilation error
// because the call to getValue(x) cannot be resolved at compile-time
constexpr int foo(int x)
{
    if (x < 0) return 0; // needed prior to adoption of P2448R1 in C++23 (see note below)
    return getValue(x);  // call to non-constexpr function here
}

int main()
{
    int x { foo(5) };           // okay: will evaluate at runtime
    constexpr int y { foo(5) }; // compile error: foo(5) can't evaluate at compile-time

    return 0;
}

image

在上例中,当 foo(5) 用作非 constexpr 变量 x 的初始化器时,将在运行时求值。这运行正常,返回值为 5。

然而,当 foo(5) 用作 constexpr 变量 y 的初始化器时,必须在编译时求值。此时编译器将判定对 foo(5) 的调用无法在编译时求值,因为 getValue() 并非 constexpr 函数。

因此编写 constexpr 函数时,务必通过在需要常量表达式的上下文中调用(如 constexpr 变量初始化)来显式验证其编译时可求值性。

最佳实践
所有 constexpr 函数都应能在编译时求值,因为它们将在需要常量表达式的上下文中被强制要求如此。
请始终在需要常量表达式的上下文中测试 constexpr 函数,因为该函数可能在运行时求值时正常工作,但在编译时求值时失败。

对于进阶读者
在 C++23 之前,若不存在可使 constexpr 函数在编译时求值的实参值,程序即为格式错误(无需诊断)。若缺少 if (x < 0) return 0 这行代码,上述示例将不存在任何可在编译时求值的实参组合,导致程序格式错误。鉴于无需诊断,编译器可能不会强制执行此规则。
该要求已在C++23中废除(P2448R1)。


constexpr/consteval 函数形参本身并非 constexpr

关键要点
constexpr 函数形参意味着该函数只能被 constexpr 实参调用。但实际情况并非如此——当函数在运行时被求值时,constexpr 函数可以接受非 constexpr 实参。

由于此类形参不具备 constexpr 属性,它们无法用于函数内部的常量表达式。(此处添加了<iostream>)

#include <iostream>
consteval int goo(int c)    // c is not constexpr, and cannot be used in constant expressions
{
    return c;
}

constexpr int foo(int b)    // b is not constexpr, and cannot be used in constant expressions
{
    constexpr int b2 { b }; // compile error: constexpr variable requires constant expression initializer

    return goo(b);          // compile error: consteval function call requires constant expression argument
}

int main()
{
    constexpr int a { 5 };

    std::cout << foo(a); // okay: constant expression a can be used as argument to constexpr function foo()

    return 0;
}

image

在上例中,函数形参 b 并非 constexpr(尽管实参 a 是常量表达式)。这意味着 b 无法用于任何需要常量表达式的位置,例如常量表达式变量的初始化(如 b2)或常量表达式函数的调用(如 goo(b))。

常量表达式函数的形参可声明为 const,此时它们将被视为运行时常量。

相关内容
若需使用常量表达式形参,请参阅11.9节——非类型模板形参


constexpr函数默认内联

当constexpr函数在编译时被求值时,编译器必须在函数调用前看到该函数的完整定义(以便自行执行求值)。此时仅靠前向声明不足以满足要求,即使实际函数定义出现在同一编译单元后部。

这意味着在多个文件中调用的 constexpr 函数需将其定义包含在每个翻译单元中——这通常会违反单定义规则。为避免此类问题,constexpr 函数被隐式视为内联函数,从而豁免于单定义规则。

因此常将 constexpr 函数定义在头文件中,以便通过 #include 引入至任何需要完整定义的 .cpp 文件。

规则
编译器必须能看到 constexpr(或 consteval)函数的完整定义,而不能仅依赖前向声明。

最佳实践
在单个源文件(.cpp)中使用的 constexpr/consteval 函数,应定义在该文件中使用位置的上游。
在多个源文件中使用的 constexpr/consteval 函数应定义在头文件中,以便被每个源文件包含。

对于仅在运行时求值的 constexpr 函数调用,前向声明足以满足编译器要求。这意味着你可以使用前向声明调用另一个翻译单元中定义的 constexpr 函数,但前提是调用时无需进行编译时求值。

对于进阶读者
根据CWG2166规范,编译时求值的constexpr函数前向声明的实际要求是“该函数必须在最终导致调用的最外层求值之前完成定义”。因此以下用法是允许的:

#include <iostream>

constexpr int foo(int);

constexpr int goo(int c)
{
	return foo(c);   // note that foo is not defined yet
}

constexpr int foo(int b) // okay because foo is still defined before any calls to goo
{
	return b;
}

int main()
{
	 constexpr int a{ goo(5) }; // this is the outermost invocation

	return 0;
}

此处的意图是允许相互递归的constexpr函数(即两个constexpr函数相互调用),否则这种情况将无法实现。


要点回顾

将函数标记为 constexpr 意味着它可用于常量表达式中,但并不等同于“将在编译时求值”。

常量表达式(可能包含 constexpr 函数调用)仅在需要常量表达式的上下文中才要求在编译时求值。

在不需要常量表达式的上下文中,编译器可选择在编译时或运行时求值常量表达式(该表达式可能包含 constexpr 函数调用)。

运行时(非常量)表达式(可能包含 constexpr 函数调用或非 constexpr 函数调用)将在运行时进行求值。


另一个示例

让我们通过另一个案例来探索常量表达式函数何时需要或可能进一步求值:

#include <iostream>

constexpr int greater(int x, int y)
{
    return (x > y ? x : y);
}

int main()
{
    constexpr int g { greater(5, 6) };              // case 1: always evaluated at compile-time
    std::cout << g << " is greater!\n";

    std::cout << greater(5, 6) << " is greater!\n"; // case 2: may be evaluated at either runtime or compile-time

    int x{ 5 }; // not constexpr but value is known at compile-time
    std::cout << greater(x, 6) << " is greater!\n"; // case 3: likely evaluated at runtime

    std::cin >> x;
    std::cout << greater(x, 6) << " is greater!\n"; // case 4: always evaluated at runtime

    return 0;
}

image

案例1中,我们在需要常量表达式的上下文中调用greater(),因此该函数必须在编译时求值。

在情况 2 中,调用 greater() 的上下文并不要求常量表达式(因为输出语句必须在运行时执行)。但由于实参是常量表达式,该函数有资格在编译时评估。因此编译器可自由选择此 greater() 调用是在编译时还是运行时求值。

在情况3中,我们调用 greater() 时有一个实参不是常量表达式。因此该调用通常会在运行时执行。

但该实参的值在编译时已知。根据 as-if 规则,编译器可以决定将 x 的求值视为常量表达式,并在编译时求值此 greater() 调用。但更可能的情况是,它会在运行时进行求值。

相关内容
我们在第5.5课——常量表达式 中讲解了as-if规则。
需注意:即使是非constexpr函数,在as-if规则下也可能被编译时求值!

在情况4中,实参x的值无法在编译时确定,因此对greater()的调用将始终在运行时求值。

关键洞察
换言之,函数在编译时实际被求值的可能性可归类如下:
始终求值(标准强制要求):

  • 常量表达式所需处调用constexpr函数
  • 在编译时求值的其他函数中调用constexpr函数

可能求值(无理由不求值):

  • 在非常量表达式要求场景下调用constexpr函数,且所有实参均为常量表达式。

可能(若按“as-if”规则优化):

  • 在非常量表达式要求场景下调用constexpr函数,部分实参虽非常量表达式但其值在编译时已知。
  • 可编译时求值的non-constexpr函数,且所有实参均为常量表达式。

绝不可能(不存在此情况):

  • 在无需常量表达式的情境下调用constexpr函数,且部分实参值在编译时未知。

需注意编译器的优化级别设置可能影响函数在编译时或运行时的求值决策。这也意味着编译器在调试版与发布版构建中可能做出不同选择(因调试版通常禁用优化)。

例如,除非编译器被要求优化代码(例如使用 -O2 编译器选项),否则 gcc 和 Clang 都不会在编译时求值调用常量表达式(constexpr)函数的情况,即使该表达式并非必需。

对于进阶读者
编译器还可能选择内联函数调用,甚至完全优化掉函数调用。这两种情况都会影响函数调用内容的求值时机(或是否求值)。

posted @ 2026-03-08 11:07  游翔  阅读(2)  评论(0)    收藏  举报