think-cell

旅行报告:奥地利哈根贝格冬季 ISO C++ 会议

 

2025-02-28 更新:更正了有关编译器能够根据内联函数的后置条件检查进行优化的错误信息。

上周,我参加了在奥地利哈根贝格举行的 ISO C++ 标准化委员会 2025 年冬季会议。这是即将发布的 C++26 标准的第六次会议,我们在会上最终确定了 C++26 的功能冻结。任何尚未获得语言或库发展工作组批准的功能将不会出现在 C++26 中。

我们在 C++26 中添加了 Contracts、简单的重新定位和标准库强化。Reflection 仍在等待措辞审查,但希望在 6 月的下一次会议上添加。不幸的是,模式匹配没有进入 C++26。

像往常一样,您可以在 Reddit 上找到已批准或讨论的论文的完整列表。在这里,我想重点介绍 Contracts、配置文件和无限范围的问题。

合同和标准库强化

P2900 为 C++26 添加了一个最小的协定实现,本质上是宏的更高级版本。然后,P3471 在其基础上构建,标准化了 Contract 的使用,以检查标准库中安全关键前提条件的子集,例如(如果启用所谓的 hardened implementation)。这是朝着提高 C++ 内存安全性迈出的一大步:通过强化的标准库实现,缓冲区溢出等错误不太可能导致安全漏洞,并且使用 Contract,您也可以将相关前提条件添加到自己的代码中。assertstd::vector::operator[]

但是,您需要注意两个陷阱。

第一个与关闭合同的能力有关。具体来说,协定定义了四种评估语义:

  • ignore,它根本不评估您的合同
  • observe,它会检查您的合同,如果违反合同,则调用用户定义的合同冲突处理程序,并继续执行
  • enforce,它会检查您的合同,如果违反合同,则调用用户定义的合同冲突处理程序,并终止执行
  • quick_enforce,它会检查您的合同并在违反合同时终止执行

quick_enforce旨在具有非常低的开销,因此可以在生产环境中启用它,而不会对性能产生重大影响。它来自 Apple 提供强化 libc++ 的经验,Google 发现它仅将 Chromium 的速度降低了 0.3%。 当您想要对合同冲突进行自定义日志记录和报告时,这非常有用。 是开始向现有代码库添加 Contract 所必需的:如果之前没有检查过前提条件,则强制执行前提条件将导致良性前提条件冲突(如 .使用 ,您可以将所有前提条件冲突收集到一个日志文件中,并逐个修复它们。最后,仅当 Contract 检查成本太高时(例如,在调用 .此类检查只能在某些调试版本中启用。enforceobserve&vec[vec.size()]observeignorestd::lower_bound

这些语义的问题在于,目前它们是全局性的:没有标准化的方法将不同的合约分组到不同的类别中,并为每个类别使用不同的评估语义。实现可能允许您为强化的标准库前提条件指定不同的 Contract 评估语义,并且 Clang 可能会将自定义属性添加到标签 Contracts。但是,在标准化的 C++26 中,库开发人员不能将 Contract 用作安全功能,即依赖有保证的前提条件检查来强化安全关键代码。当然,有人提议将此功能添加到 C++29 中,但现在,您不走运。

第二个陷阱发生在您链接使用不同 Contract 评估语义编译的多个翻译单元时。考虑一个 Headers,它使用 contract 定义一个内联函数:它有一个不是的前提条件(就像整数溢出一样)和一个结果为非负数的后置条件。absxINT_MIN-INT_MIN

 
inline int abs(int x)
  pre (x != INT_MIN)
  post (result: result >= 0)
{
  return x < 0 ? -x : x;
}
header.h 定义一个带有 Contract 的内联函数。

在 中,用于处理一些数字。出于性能原因,我们将 Contract 评估语义设置为 。fast.cppabsignore

 
#include "header.h"

int number_crunching(std::span<int const> data)
{
    return stdr::fold_left(data | stdv::transform(&abs), 0, std::plus<int>{});
}
fast.cpp 使用 abs 并在禁用 Contract 检查的情况下进行编译。

在 中,用于处理某些用户输入。为避免安全漏洞,我们将合约评估语义设置为 。safe.cppabsenforce

 
#include "header.h"

class MyContainer
{
public:
    int size() const {};

    Data operator[](int idx) const
        pre (0 <= idx && idx < size())
    {
        return data[idx];
    }
};

Data process_user_input(MyContainer const& container, int x)
{
    x = abs(x);
    return container[x];
}
safe.cpp 使用 abs 并在启用合约检查的情况下进行编译。

现在考虑一下如果你将 和 链接到一个可执行文件会发生什么。每个翻译单元定义一个 , 的副本,一次使用 Contract 检查编译,一次没有(为了争论,我们假设它实际上没有内联到调用站点中)。如头文件中所定义,并且由于 P2900 竭尽全力确保两个不同的定义不违反单定义规则,因此允许链接器假定 的两个副本相同,并且只在二进制文件中包含一个副本。如果使用带有语义的定义,我们就不再有 contracts 检查:如果用户输入 ,我们将出现整数溢出!fast.cppsafe.cppabsabs()absabsignoreINT_MIN

在这一点上,这篇博文的早期版本错误地写了如何允许编译器在编译时进一步假设后置条件 为 true(毕竟,否则程序将终止),从而根据该假设进行优化。这可能导致进一步消除前提条件 中的 the 检查,因为它与 的后置条件 .当在链接时将 的 选中版本替换为 中的未选中版本时,这将导致安全漏洞。abssafe.cpp0 <= xoperator[]absabsfast.cpp

Luckily, this is not possible, as has been pointed out to me.

编译器只允许根据后置条件进行优化,即它是否实际内联了 call 或 postcondition 检查。如果它发出对函数的调用,则它无法对其行为做出任何假设,因为内联函数是具有弱链接的符号,可以由链接器替换 — 正是与 .因此,它不能基于后置条件进行优化,除非它确保后置条件实际发生在 中,而不管任何弱品种的定义如何。absfast.cppsafe.cpp

尽管如此,仅仅通过与包含相同标题的不同翻译单元链接来禁用合同检查的情况是很遗憾的。当然,解决方案很简单:永远不要链接使用不同的合约评估语义(或通常不同的编译器标志)编译的代码。如果不允许混合不同的 Contract 评估语义,我们就不会有问题:编译器可以用 Contract 评估语义标记每个翻译单元,然后链接器可以拒绝链接具有不同语义的翻译单元。但是,标准将此代码定义为有效,因此这不是一个选项。

现在 implementations 必须聪明地处理此类错误。例如,他们可以为每个函数提供一个 ABI 标签,并使用合约评估语义对其进行标记。这样,我们就有 和 ,而不是两个定义。但是,如果函数仅调用具有不同 Contract 评估语义的函数,但本身没有 Contracts,则此方法无济于事。absabs.ignoreabs.enforceinline

我(或实施者)不清楚通用解决方案是否可能。

配置 文件

配置文件背后的想法是通过添加静态分析和运行时检查来提高 C++ 的内存安全性。选择加入配置文件将禁止某些不安全的功能(例如,指针算术)并添加对其他功能的运行时检查(例如,数组索引作)。我们在 Hagenberg 中看到了两个关于个人资料的提案。

第一个提案 P3589 为配置文件添加了一个通用框架。其思路是,使用属性为一个翻译单元启用配置文件,使用 在本地抑制语句的配置文件,并要求导入的模块使用 强制执行配置文件。[[profiles::enforce(P)]][[profiles::suppress(P)]][[profiles::require(P)]]

我喜欢这个设计。但是,我表示担心它需要使用模块。每个翻译单元的粒度使其与头文件不兼容:如果包含第三方标头,则还会使用您启用的任何配置文件对其进行检查,并且如果您正在编写仅标头库,则无法在头文件中启用配置文件。会议室同意我的观点,我们投票赞成添加一些机制,以仅在特定范围内启用配置文件。

这意味着我们无法将提案转发给核心工作组以包含在 C++26 中。

第二个提案 P3081 同时添加了框架和特定配置文件。尽管我们已经在圣诞节期间花了很多时间在 Telecons 上回顾它,但它仍然没有准备好。特定规则仍然缺少边缘情况 — 例如,禁止转换为 但不允许 to 的规则,而另一条关于强制转换的规则不考虑如何使用 C 样式强制转换来强制转换为私有基类。其他规则尚未完全考虑清楚 – 例如,禁止指针算术也可能 ban ,而禁止数组到指针的衰减使得使用字符串 Literals 几乎是不可能的。reinterpret_caststd::byte*unsigned char*std::vector::iterator

而这些只是人们在阅读提案时指出的问题!它缺乏任何实现,所以谁知道当它在实际代码上实际启用时会出现什么其他问题。该论文根本没有为 C++26 做好准备,因此我们投票决定将其进一步发展为白皮书,以便在合并到实际标准之前收集实现经验和进一步的反馈。

无限范围

正如上次旅行报告中所述,我自愿写了一篇论文,提出了一个检测无限范围的概念,P3555。在审查过程中,我们发现实际上不允许无限范围!

 

10 – 当且仅当表达式 ++i 的有限应用程序序列使 i == s 时,才可以从迭代器 i 调用哨兵 s。如果 s 可以从 i 到达,则 [i, s) 表示有效范围

11 – […]

12 – 将库函数应用于无效范围的结果是未定义的。

 

[iterator.requirements.general]/10-12

views::repeat(0),这总是导致 forever,不是一个有效的范围:你无法通过增加迭代器有限次数来到达 sentinel。事实上,它永远不会回来。这使得代码类似于 undefined 行为,因为它将库函数 (并最终) 应用于无效范围。从技术上讲,也是未定义的行为 (它调用库函数 ),原样 (它调用库函数 )。0operator==trueviews::repeat(0) | views::take(10)operator|views::takefor (auto x : views::repeat(0)).begin()views::repeat(0);~repeat_view()

鉴于标准库提供了无限范围,最好使用它们而不会立即导致未定义的行为。第 9 研究组投票决定采取一些措施来解决这个问题。

但是,修复它并非易事。

我们不想简单地删除第 10 段。like 应该仍然是未定义的行为,因为不是有效的范围。因此,我们需要扩展有效范围的定义以允许无限范围,例如,如果可以从 到达,或者如果您可以随心所欲地执行,而不会在此过程中达到或调用未定义的行为,则表示范围有效。这允许同时仍然防止 .std::subrange(ptr, ptr - 1)[ptr, ptr - 1)si++isviews::repeat(0)std::subrange(ptr, ptr - 1)

但是,这仍然不允许 ,这会生成递增的整数 、 、 等。最终,递增会导致有符号整数溢出,这是未定义的行为。因此,需要调整定义以允许这一点,也许通过引入类似无界范围的东西,您可以在其中递增直到达到未定义的行为。唉,那又允许了。views::iota(0)012std::subrange(ptr, ptr - 1)

一旦我们有了无限的范围,我们还需要重新考虑它们的 difference type。出于性能原因,我们不希望 difference 类型是任意精度的整数,但如果 difference 类型是有限的,我们就无法表示所有可能的差异。但也许这很好,因为在 32 位系统上,您已经无法表示相距超过 2 GB 的两个指针之间的差异。因此,有先例表明,并非所有差异都可以用 difference 类型表示。我们只是应该明确这一点。然后,这需要仔细审核所有标准库算法,这些算法是根据函数调用的许多应用程序指定的,以考虑或禁止无限范围作为输入。ranges::distance(begin, end)

然后是 ,这是一个真正的无限范围,因为无符号整数溢出被定义为 wrap。但是,迭代器指向的距离和迭代器指向的距离是多少?真的吗?或者也许?我们可能需要一些关于迭代器之间最小距离的措辞。views::iota(unsigned(0))352UINT_MAX + 2

此外,还有 的问题。读取常规文件时,它是有限的,但是当读取或管道文件时,它可以是无限的。如果可能是真的,那么它真的是一个无限范围吗,具体的程序执行永远不会达到那个点?毕竟,在这种情况下,这是一个不确定的问题。std::istream_view/dev/zeroit == endit == end

毋庸置疑,SG9 在未来几年必须解决很多问题。最后,人们将编写与以前完全相同的代码,只是(希望)带着一种温暖的模糊感觉,即它现在是定义明确的行为。