constexpr 函数(第二部分)

你可能认为只要函数被声明为 constexpr,就总能在编译期求值;事实并非如此。 在《常量表达式》中我们指出:若上下文要求常量表达式,编译器可自行决定在编译期或运行期求值。因此,任何属于“非强制常量表达式”的 constexpr 函数调用,既可能编译期求值,也可能运行期求值。

示例:

#include <iostream>

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

int main()
{
    int x{ getValue(5) }; // 可在运行期或编译期求值
    return 0;
}

此处 getValue() 虽为 constexpr,但其调用位于非强制上下文中(变量 x 不是 constexpr),故编译器可自由选择求值时机。

关键要点 constexpr 函数的编译期求值仅在需要常量表达式时才得到保证。

强制常量表达式中 constexpr 函数的诊断

编译器并不强制在函数定义阶段就验证其是否一定可在编译期求值;只有在真正需要编译期求值的上下文中,才会进行完整检查。 因此,很容易写出对运行期使用完全合法,却在编译期求值时报错的 constexpr 函数。

举一示例:

#include <iostream>

int getValue(int x) // 普通函数,非 constexpr
{
    return x;
}

// 运行期可正常调用
// 若以编译期方式调用,则因调用非 constexpr 函数 getValue(x) 而报错
constexpr int foo(int x)
{
    if (x < 0) return 0;   // C++23 前需此行,见文末说明
    return getValue(x);    // 此处调用非 constexpr 函数
}

int main()
{
    int  x{ foo(5) };           // 合法:运行期求值
    constexpr int y{ foo(5) };  // 编译错误:无法在编译期求值
    return 0;
}

foo(5) 用于初始化 constexpr 变量 x 时,它在运行期求值,一切正常。 当用于初始化 constexpr 变量 y 时,则必须编译期求值;此时编译器发现 getValue()constexpr,于是报错。

因此,编写 constexpr 函数后,务必在强制常量表达式场景(如初始化 constexpr 变量)中显式测试其可编译性。

最佳实践 所有 constexpr 函数都应确保能够在编译期求值,因为在强制常量表达式上下文中这是必需的。 务必在需要常量表达式的上下文中测试你的 constexpr 函数,以免出现“运行期可用、编译期不可用”的情况。

(高级说明) 在 C++23 之前,若不存在任何参数组合能使 constexpr 函数在编译期求值,则程序为病态(ill-formed),且无需诊断。上例若去掉 if (x < 0) return 0;,即没有任何参数可使函数在编译期成功,程序即属病态。C++23(P2448R1)已撤销此要求。

constexpr / consteval 函数形参并非 constexpr

constexpr 函数的形参不是隐式 constexpr,亦不可显式声明为 constexpr

关键要点 若形参为 constexpr,则意味着只能以 constexpr 实参调用该函数;事实并非如此——constexpr 函数在运行期求值时,完全可以接受非 constexpr 实参。

因此,这些形参不能用于函数体内的常量表达式。

consteval int goo(int c)   // c 不是 constexpr,不能在常量表达式中使用
{
    return c;
}

constexpr int foo(int b)   // b 不是 constexpr,不能在常量表达式中使用
{
    constexpr int b2{ b }; // 错误:constexpr 变量需常量表达式初始化
    return goo(b);         // 错误:consteval 函数需常量表达式实参
}

int main()
{
    constexpr int a{ 5 };
    std::cout << foo(a);   // 合法:常量表达式 a 可作为实参
    return 0;
}

形参可声明为 const,此时其为运行期常量。

相关内容 若需“常量表达式形参”,请参见 11.9 课《非类型模板形参》。

constexpr 函数隐式内联

constexpr 函数在编译期求值时,编译器必须在使用点之前看到其完整定义(仅前向声明不足)。若该函数在多个源文件中使用,则每个翻译单元都需包含其定义,否则违反一次定义规则(ODR)。

为此,constexpr 函数被隐式声明为 inline,从而免受 ODR 限制。因此,constexpr 函数通常置于头文件中,以供各 .cpp 文件包含。

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

最佳实践

  • 仅在单个源文件中使用的 constexpr / consteval 函数,应在该文件中且在使用前定义。
  • 在多个源文件中使用的 constexpr / consteval 函数,应定义于头文件,以便包含。

若某 constexpr 函数仅在运行期调用,则前向声明即可满足编译器需求;此时可在其他翻译单元中定义其实现。

(高级说明) 根据 CWG2166,当且仅当“在导致调用的最外层求值发生之前”该函数已定义即可。因此,以下代码合法:

constexpr int foo(int);

constexpr int goo(int c)
{
    return foo(c);   // foo 尚未定义
}

constexpr int foo(int b)
{
    return b;        // 在 goo 被求值前已定义
}

int main()
{
    constexpr int a{ goo(5) }; // 最外层求值点
    return 0;
}

此举旨在支持相互递归的 constexpr 函数。

再举一例

通过下例进一步说明 constexpr 函数的求值时机:

#include <iostream>

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

int main()
{
    constexpr int g{ greater(5, 6) };                 // 情况 1:强制编译期求值
    std::cout << g << " is greater!\n";

    std::cout << greater(5, 6) << " is greater!\n";   // 情况 2:可编译期或运行期

    int x{ 5 }; // 非 constexpr,但值在编译期可知
    std::cout << greater(x, 6) << " is greater!\n";   // 情况 3:通常运行期

    std::cin >> x;
    std::cout << greater(x, 6) << " is greater!\n";   // 情况 4:必定运行期
    return 0;
}
  • 情况 1:调用位于强制常量表达式上下文,必编译期求值。
  • 情况 2:上下文不要求常量表达式,实参为常量表达式,编译器可任选求值时机。
  • 情况 3:实参非常量表达式,通常运行期求值。虽值在编译期已知,但 xconstexpr,故编译器“多半”运行期求值(as-if 规则下也可能优化)。
  • 情况 4x 的值编译期不可知,必运行期求值。

关键要点 可将函数是否真正编译期求值的概率归纳如下:

  • 必编译期(标准强制): • 在需要常量表达式的上下文中调用 constexpr 函数; • 在被其他已确定编译期求值的函数内部调用。

  • 极可能(无理由不): • 不要求常量表达式,但所有实参均为常量表达式。

  • 可能(as-if 规则优化): • 不要求常量表达式,部分实参虽非常量表达式但其值编译期可知; • 非 constexpr 函数,所有实参为常量表达式。

  • 必运行期(不可能编译期): • 存在实参值编译期不可知。

编译器的优化级别设置也会影响上述决策;调试版(通常关闭优化)与发布版可能表现不同。例如,GCC 与 Clang 若未开启优化(如 -O2),在不要求常量表达式的上下文中不会把 constexpr 函数编译期求值。

(高级说明) 编译器也可能内联或完全消除某函数调用,从而影响求值时机或是否真正发生求值。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"