`Constexpr` 是一个平台(2020)

2021-08-10 00:13:29

就像您编写针对 Windows 或微控制器的代码一样,您编写针对编译时执行的代码。在这两种情况下,您都将自己限制在可在目标平台上运行的 C++ 子集,如果您的代码需要可移植,请使用条件编译,并在所需的目标平台上执行它。因此,您可以将 constexpr 视为您可以定位的另一个平台;它恰好由您的编译器运行。编译时编程的能力随着 C++ 的每个版本而扩展,越来越多的标准库函数被标记为 constexpr。这就提出了一个问题:什么不应该是 constexpr?让我们将 constexpr 视为一个平台并将其与微控制器进行比较。哪些 C++ 函数可以移植到它?这里的答案要简单得多。对于初学者来说,所有不与操作系统接口的可移植 C++ 都可以正常工作。而且甚至可以实现某些操作系统功能:打印到标准输出可以是某种调试输出,如果芯片具有适当的硬件,我们可以拥有网络 API,等等。其他 API 无法完成或没有意义,例如线程在单核处理器上或在没有显示器的系统上创建窗口。因此,在平台上,我们可以使用可移植的 C++ 代码以及可以在系统提供给我们的 API 之上构建的所有内容。这同样适用于 constexpr:所有可移植的、标准 C++ 都应该在编译时可用,以及构建在系统 API 之上的每个功能。这里的“系统”是编译器,它可以提供发布接口诊断、源代码反射和潜在的调试输出。constexpr 平台与传统平台之间的一大区别是 constexpr 函数不能以任何方式与全局(运行时)状态交互。因此,如果我们使用(C++17/20 后)C++ 库,那么期望所有没有副作用或操作系统交互的函数都是 constexpr 是合理的。该库还需要仅标头或使用模块来应用该假设。当然,库作者是否认为有必要真正使之成为 constexpr 是一个不同的问题。毕竟,编译时编程目前仅限于简单的事物或更深奥的库,因此需求并不多。

目前,如果函数应该是 constexpr 函数,您需要显式标记它。但是,我们可以想象未来的 C++ 版本不需要这样做:如果我们在编译时调用函数,编译器会尝试在编译时执行它。如果它工作,很好,否则,它会发出诊断信息。这样,我们不需要手动将所有内容标记为 constexpr,这只是不必要的样板。假设函数不需要 constexpr,并且我们有一个提供函数 get_the_answer() 的库:恰好昂贵的计算是 constexpr,因此用户在编译时使用它。 int get_the_answer_impl () { /* 和以前一样 */ } int get_the_answer () { // 延迟计算一次。 static int result = get_the_answer_impl();返回结果;这是一个重大变化:constexpr 函数不能包含静态变量!用户的代码被破坏了。这就是为什么我们需要用 constexpr 显式标记 constexpr 函数。通过这样做,我们记录了哪些函数可以在编译时使用和向我们的用户承诺。但是让我们将 constexpr 与另一个平台进行比较。现在我们有一个用户在 Linux 上使用库的初始版本。这很好用,因为昂贵的计算是跨平台的常规标准 C++ 代码。库作者再次想要优化 get_the_answer()。这一次,他们选择使用内置的 Windows 支持来获取答案:

这也是一个重大变化:调用 WinAPI 的函数不能在 Linux 上编译。用户的代码被破坏。因此,如果函数应该在 Linux 上可用,库作者应该明确标记为 linux。通过这样做,我们记录了哪些函数可以在 Linux 上使用,并承诺给我们的用户。我们没有在源代码中使用强制关键字明确标记哪些函数在哪些平台上可用。相反,除非另有明确说明,否则假定库代码​​是跨平台的。如果库更新在某些平台上破坏代码,影响用户提交问题以修复重大更改。 int get_the_answer () { int 结果; #ifdef WIN32 GetTheAnswerEx2 ( & result , NULL , NULL ); // 仅限 Windows #else /* 昂贵的计算 */ #endif 返回结果; } 所以如果我们没有“OS 标记”,我们为什么要保留恼人的 constexpr 标记?我们可以期望所有的东西都是 constexpr 遵循上一节中陈述的条件,除非库明确地记录了其他情况。在操作系统下中断:我们提出问题,库作者使用条件编译修复它,在我们的例子中使用 std::is_constant_evaluate(): int get_the_answer_impl () { /* as before */ } int get_the_answer () { if ( std :: is_constant_evaluate ()) // 编译时平台 { return get_the_answer_impl (); } else // 其他平台 { // 懒惰计算一次。 static int result = get_the_answer_impl();返回结果;出于文档目的将函数标记为 constexpr 与将函数标记为 linux 或 windows 一样必要。

您可能会说标记函数 constexpr 的另一个好处是编译器可以继续验证它是否在编译时实际工作。然而,这只是部分正确;以下代码编译。该函数被标记为 constexpr,即使它只有在 i 为 0 时才为 constexpr;否则,它执行的 I/O 显然不能在编译时工作。但这完全没问题:如果有一种可能的参数组合在编译时工作,则可以将函数标记为 constexpr。这里就是这种情况.请注意,即使没有在编译时工作的参数组合,编译器甚至不需要发出诊断!好吧,我们这样做的方式与检查我们的函数在 Linux 下是否正常工作的方式相同:我们编写了一个涵盖所有相关参数的测试。 constexpr auto result_constexpr = foo ( 1 , 2 , 3 );检查( result_constexpr == 42 );自动 a = 1 ; auto result_runtime = foo ( a , 2 , 3 );检查( result_runtime == 42 );请注意,我们也使用局部变量来防止编译器在编译时调用第二个 foo。如果我们要测试的函数不使用 std::is_constant_evaluate() 来根据其运行的平台更改实现,则不需要运行时测试,因为它将执行相同的代码,只是在运行时。它只测试是否编译器的 constexpr 实现与您的处理器相匹配,这应该由编译器编写者而不是您来完成。

编写在编译时计算所有结果而仅在运行时进行验证的测试还有一些额外的好处:您的测试运行得非常快,因为它们所做的只是对预先计算的结果进行一些相等性检查。调试失败的测试用例真的很简单:只需从错误的单个结果中删除 constexpr 并使用调试器。由于其他所有内容都是在编译时计算的,因此您只需调用一次需要调试的函数并且不需要跳过所有其他有效的调用。编译时没有 UB;编译器需要在遇到一个诊断时发出诊断。有了足够的覆盖范围,您就可以验证您的函数不包含 UB。当 std::is_constant_evaluate() 被添加到 C++20 作为查询函数调用是否发生在编译时的方式时,有人认为这是一个坏主意。现在可以编写这样的代码,其中在编译时和运行时的行为完全不同:显然,编写这样的代码是不好的,所以我们应该让它不可能这样做。虽然 f() 的这个特定实现很糟糕,但条件编译对于编写跨平台代码是必不可少的。这同样适用于 std::is_constant_evaluated() 和 constexpr 代码。为了利用特定于平台的 API,我们需要一种查询平台的方法我们正在继续并做出相应的决定。

主要示例是 C++20 中添加的位函数,例如 std::countl_zero(x)。在运行时,您希望使用在编译时不可用的专用汇编指令。因此您使用 std::is_constant_evaluated () 切换实现。就像跨平台代码一样,您需要测试两个版本以确保两者都能正常工作。编写constexpr函数就像编写可移植函数:大多数代码应该是constexpr,就像大多数代码是跨平台的一样;constexpr标记应该是不必要的,就像一个假设的linux标记一样;你需要在编译时测试constexpr函数,并且运行时,就像您需要为跨平台代码做的一样;并且您需要一种方法来执行条件编译以选择最佳 API,就像所有其他可移植代码一样。