think-cell
秋季会议

旅行报告:波兰弗罗茨瓦夫秋季 ISO C++ 会议

 

上周,我参加了在波兰弗罗茨瓦夫举行的 ISO C++ 标准化委员会 2024 年秋季会议。这是针对即将推出的 C++26 标准和主要 C++26 功能冻结的第五次会议。有关所有取得进展的论文的概述,请阅读 Herb Sutter 的旅行报告。 合同和档案是本次会议取得最大进展的大项目。合同被转发到措辞审查,但仍然受到一些人的强烈反对。Profiles 本质上是标准化的静态分析,以提高内存安全性,尽管有些人认为它无效。由于各种日程安排冲突,我没有参加这些空间的任何相关讨论。相反,我将分享我对范围、重新定位和反射的想法。

范围

我花了第一天半的时间共同主持了第 9 研究组,这是 的研究小组。在讨论过程中,我们意识到 range 库中的两个大漏洞需要尽快解决。它们都涉及大小范围的概念。std::ranges

第一个漏洞与提案 P3179—C++ parallel range algorithms有关,该提案为 中的算法添加了执行策略。这使得运行多线程算法变得轻而易举:您不是 write,而是 write,现在您的输入被拆分为多个块并并行处理。std::rangesstd::ranges::transform(vec, output, fn)std::ranges::transform(std::execution::par, vec, output, fn)

这种将 input 拆分为多个块需要具有已知大小的随机访问范围,因此与单线程算法相比,并行算法具有更严格的要求。对于采用两个 input range 的算法,都需要调整 both for the size 。否则,计算所有 的 ,首先需要一个串行循环来找到正确的大小,然后才能拆分输入。std::ranges::transform(std::execution::par, a, b, output, fn)fn(a[i], b[i])0 ≤ i < min(std::ranges::size(a), std::ranges::size(b))

一家供应商不喜欢两个范围都需要调整大小的要求。他们有一些客户想要编写类似 或 的代码。这两个函数只是永远重复,并且生成一个无限的数字序列,显然都比 长。因此,明显的语义是 call 或 for all 。但是,视图不提供 ,因为它们是无限的。因此,供应商的实现要求只调整一个范围的大小,并假设另一个范围更长,但无法静态检查。更好的解决方案需要 新概念 .然后我们可以要求 它采用两个大小范围或一个大小范围和一个无限范围。std::ranges::transform(std::execution::par, vec, std::views::repeat(n), output, fn)std::ranges::transform(std::execution::par, vec, std::views::iota(0), output, fn)std::views::repeat(n)nstd::views::iota(0)std::ranges::size(vec)fn(vec[i], n)fn(vec[i], i)0 ≤ i < std::ranges::size(vec)std::ranges::sizestd::ranges::infinite_rangestd::ranges::transform

这样的概念也有助于解决一些库问题,例如 LWG4019 指出这只是一个无限循环,因为它试图找到无限范围的终点。编译器错误会带来更好的用户体验,并且可以通过要求将非无限范围传递给 来实现。std::views::iota(0) | std::views::reversestd::views::reversestd::views::reverse

std::simd 提案(在 C++26 中被接受为工作草案)的作者 Matthias Kretz 发现了第二个漏洞。在 P3299 中,他想用更安全的范围构造函数替换 的不安全迭代器构造函数。理想的语义是允许从大小与 SIMD 大小匹配的范围进行隐式转换,否则允许使用自定义越界语义进行显式转换。但是,要有条件地允许隐式转换,我们需要的范围不仅在没有循环 () 的情况下动态已知,而且在编译时静态知道其大小。现在,所能做的就是对类型进行硬编码,比如 and 一个静态大小的实例化。std::simdstd::ranges::sized_rangestd::arraystd::span

这里需要一个适当的概念。在 think-cell,我们在 的基础上构建了这样一个概念,它允许我们查询给定范围类型的大小。我之前曾在博客中介绍过该领域的一个有用 ifor,以及如何将其无缝扩展到所有范围适配器中。tc::constexpr_size

我将与相关各方合作,将 和 (人名待定) 带到 2 月举行的下一次会议的第 9 研究组。如果运气好,并且国家机构对最终委员会草案进行投票施加了足够的压力,他们可以及时赶上 C++26,这样现有提案就可以从中受益。std::ranges::infinite_rangestd::ranges::constexpr_sized_range

搬迁

自从添加了 move 语义以来,人们一直在要求一种形式的破坏性 move。现在,需要部分形成对象的 moved-from 状态,因为析构函数仍将运行。这意味着不可能实现像 mobileable non-null 这样的东西:添加 move 构造函数需要一个 moved-from 状态,而析构函数是无作的。这需要将其设置为 .std::unique_ptrnullptr

此外,像 这样的作首先分配新内存,然后移动现有元素,最后销毁旧内存中的元素,效率不高。原则上,move + destroy 步骤可以替换为 的 高级版本。move 构造函数只需创建一个 moved-from 状态,以确保析构是无作;如果从未执行过销毁,则通常可以通过复制字节来完成。std::vector::reservestd::memcpy

此功能的当前迭代 P2786 通过引入 “琐碎可重定位” 类型的思想来关注优化方面。在这些类型中,对 move 构造函数的调用后跟对析构函数的调用可以替换为新的 primitive operation ,该作本质上是执行一些额外的魔法来结束和启动生命周期。简单的 relocatable 属性是自动计算的:如果所有成员都是 trivily relocatable 并且没有自定义移动作,则该类是 trivially relocatable。对于具有自定义移动作的类型,您必须通过声明 move + destroy 可以替换为 来选择加入,编译器无法为您确定这一点。std::trivially_relocatestd::memcpystd::trivially_relocate

同样,本文还介绍了 “replaceable” 类型。如果 move 赋值运算符等效于对析构函数的调用,然后对 move 构造函数的调用,则类型是可替换的。对于大多数类型来说,情况都是如此,除非它们包含引用或具有 propagate_on_container_move_assignment 为 false 的分配器。与 trivially relocatable 一样,如果所有成员都是可替换的,并且该类型没有自定义移动运算符,则类型是可替换的,否则该类型必须选择加入。如果类型是可替换的并且很容易重定位,则对 move 赋值运算符后跟析构函数的调用也可以替换为 。否则,只能替换后跟析构函数的 move 构造函数。std::trivially_relocate

显然,这个特定的提案背后有很多戏剧性和历史。它让负责 C++ 语言功能的小组 EWG 达成了非常紧密的共识,并被交给了 LEWG,这是我第一次看到这个功能。除了查看该功能的界面外,EWG 还要求我们查看选择加入所需的关键字。由于没有人想出更好的选择,我们最终得到了一个 and 上下文关键字,放在类的名称之后:std::trivially_relocatetrivially_relocatablereplaceable

class unique_ptr trivially_relocatable replaceable { … };

想做这件事吗?没问题,也添加这些:final[[nodiscard]]

class [[nodiscard]] unique_ptr final trivially_relocatable replaceable { … };

P2822 提出了用于控制对 ADL 可见的命名空间的语法,这是我非常喜欢的一个功能。指定 our 不参与 ADL 意味着我们的类声明如下所示:unique_ptr

class [[nodiscard]] unique_ptr final trivially_relocatable replaceable namespace() { … };

不用说,这太糟糕了,我讨厌它的一切。

在 LEWG 中转发 P2786 的 trivially relocatable 的投票失败,因此现在该论文的状态尚不清楚。

反射

好消息是,P2996—Reflection for C++26 正在按计划用于 C++26。最后一个需要解决的大问题是目标 C 块的语法歧义(是的,真的)。通过将 reflection 运算符从 更改为 ,可以解决此问题。^foo^^foo

坏消息是,我的论文 P3429—Reflection header should minimize standard library dependencies(反射头应最小化标准库依赖关系)被彻底拒绝,除了本质上是对措辞的驱动修复。我预料到会被拒绝:LEWG,负责设计标准库的小组,历来并不真正同情那些不想使用标准库的人,但我仍然感到失望。我真的希望底层编译器内置函数保持解耦状态,以便您可以为它们提供自己的轻量级抽象,而不依赖于在编译时进行构造。这样,如果你对 using for detect 编译器感到满意,你就不会被迫包含 to use reflection。std::vector<meta>#ifdef

一线希望是,由于反射 API 非常昂贵,编译器实现者几乎肯定会投入一些工程工作来加快评估速度。例如,他们可以将类型视为在本机代码中实现的内置类型,或者使用字节码指令来评估 .constexprstd::vector<std::meta::info>constexpr

也许这样我们最终会得到 C++ 作为在 VM 中运行的解释型语言。

夏季会议

旅行报告:美国圣路易斯 ISO C++ 夏季会议

 

两周前,我参加了在美国圣路易斯举行的 ISO C++ 标准化委员会 2024 年夏季会议。这是即将推出的 C++26 标准的第四次会议。有关所有取得进展的论文的概述,请查看 reddit 上的合作之旅报告。像往常一样,我把大部分时间都花在了库演进上,在那里我们讨论 C++ 标准库的提案,我还主持了 SG 9,这是一个为提案提供早期反馈的研究组。这一次,我还在 SG 23 度过了一整天,这是一个专注于安全和安保的研究小组。我参与的大多数讨论都是没有争议的(或者非常有争议的,但最终不像命名那样重要),但我对 C++ 中的发送者/接收者、反射和借用检查有一些想法。std::ranges

发送方/接收方

P2300 是一个大提案,它增加了一种编写并发程序的新方法。就像改变了我们编写范围算法的方式一样,也将改变我们编写并发代码的方式。这个想法是将 senders(本质上是最终会发送一些值的 lazy future)和 receivers(只是花哨的回调)组合在一起来描述数据流。然后,可以使用特殊发送方将执行转移到不同的线程或将它们调度到线程池中。就像组合视图一样,组合 sender 是纯粹的声明性的,并且仅在需要值时才开始执行。这样,您就可以构建执行图,而无需担心同步,并在以后执行它。这是一个绝妙的设计。std::rangesstd::execution

只是细节涉及很多复杂性。

作为一个典型的 C++ 提案,它的目标是成为每个用例的 100% 解决方案。这需要一大堆自定义机制来采用特定执行上下文的算法,大量的元编程来计算完成签名,以及许多新概念和实用函数。如果最终用户想要编写现有发送者,则使用运算符或编写协程并创建发送者非常简单,但下面的实现很复杂,编写自己的发送者并非易事。与 类似,这可能会导致更长的编译时间,优化器需要做更多的工作来消除所有这些抽象,并且如果您做错了什么,则会出现不可读的错误消息。|co_awaitstd::ranges

我不喜欢的一个特别复杂是环境的概念。发送方可以有一个关联的环境,该环境实质上是以命名属性元组的形式进行的编译时键值存储。发件人可以向环境添加新属性或查询现有属性,而组合会保留这些更改。这样,您就可以实现某种形式的依赖关系注入。假设您有一个第三方库,该库实现了基于发送者的算法。你可以通过与你自己的发送者一起编写它来使用它,发送输入值,例如从文件中读取它们:

 
std::execution::sender auto some_algorithm(std::execution::sender auto input); // third-party
std::execution::sender auto read_file(std::string_view path); // your own code

int main() {
	auto input = read_file(path);
	auto value = some_algorithm(input);
	...
}
组合两个发送者以构建算法

假设两者都需要分配内存。既然你自己写了,你就用你花哨的分配器来做这件事,并且也想做这件事。这可以使用环境来完成:如果将分配器放入环境中,则 的实现可以在环境中查询分配器并使用分配器(如果提供)。这样,您可以自定义行为,而无需额外的参数来 ;它们由较早的发送者注入。如果参数很多,那就很方便了。read_filesome_algorithmread_filesome_algorithmread_filesome_algorithmsome_algorithm

此外,该环境还可用于查询有关当前发送者链本身的信息。例如,在线程池上安排工作的发送方可以将线程池放在环境中。这些计划的发件人可以访问它,以便在他们正在执行的同一池中安排更多工作。在某种程度上,环境等同于发送方/接收方。thread_local

但是,环境也引入了依赖于环境的发件人的概念。主要示例是 。这是从环境中读取指定属性并发送其值的发送程序。它发送的值的类型是什么?嗯,这取决于所讨论的环境。因此,一般来说,在编写完所有发件人并准备好执行工作之前,不知道完整的完成签名。这可能发生在离合成的实际位置很远的地方。假设我们不小心将 a 与实现中的 allocator 属性相关联,而不是 .但是,的实现需要一个分配器,因此在读取属性时,它会获取错误的类型,这将导致编译器错误。仅当我们在 中组合它们时,才会检测到此错误。如果我们使用第四个位置的环境将来自不同库的两个发送者组合到第三个库中,则可能需要进行大量挖掘才能找出到底出了什么问题。std::execution::read_env(property)std::pmr::memory_resource*read_fileAllocatorsome_algorithmmain

在圣路易斯,Eric Niebler 发表了 P3164,这是一篇很棒的论文,旨在改进误用发送方/接收方时的编译器错误消息。处理依赖于环境的发件人必须经历很多麻烦,这让我怀疑环境是否真的值得。

除了发送方/接收方固有的复杂性外,还存在程序问题。P2300 是一篇庞大而复杂的论文,有很多值得信赖的作者。有人担心它可能没有得到适当的审查,因为委员会成员无法完全理解错综复杂的设计细节,而只是相信作者他们做得足够好。我个人也对大量在机上的论文以某种方式修改了主要提案感到担忧。我不想最终出现一些发送方/接收方的情况,但关键修改(例如诊断改进)错过了 C++26 的最后期限,并且当不再可能进行重大更改时,以后无法应用。

因此,虽然 P2300 在全体投票中被通过,现在是将成为 C++26 标准的工作草案的一部分,但这是一个非常微弱的投票,只有 1/3 的投票反对采用。我预计一些争议会持续到下一次会议。

反射

P2996 向 C++ 添加了反射。它具有对实体进行反射的能力,从而生成对象。然后,您可以使用标准库 API 查询有关它的信息,创建新对象并将其转换回代码,或生成全新的代码。然后,这可以用于最终实现枚举到字符串的转换(除其他外)。std::meta::infostd::meta::info

在语言方面,它已经通过了进化小组委员会,目前正在接受规范的审查。但是,选定的反射语法(使用 prefix 运算符,如 in )可能会导致使用 Objective C 块解析歧义,这在 C++ 中由 clang 编译器扩展支持。作者的第二个选择,如 中的 prefix 运算符也不理想,因为您可能希望将结果对象作为模板参数传递,编写类似 .不幸的是,是一个二合字母……^^foo%%foostd::meta::infosome_template<%foo><%

同时,library evolution 审查了提议的 library API 以查询有关对象的信息。从本质上讲,对象是内部编译器 AST 的不透明句柄,并且有一堆函数来获取信息。请注意,你反映什么并不重要,一个函数、一个类型、一个表达式、一个命名空间,它们都会导致相同的类型——。这是为了确保 C++ 中的未来更改不会导致反射 API 中的 API 或 ABI 中断。因此,编译器无法使用类型系统检测无意义的函数调用。例如,调用 integer 的 reflection 没有意义,但调用会编译。我们花了很长时间讨论这应该是前提条件冲突还是返回空字符串(这显然是前提条件冲突)。我们花了更长的时间讨论 的行为,但最终(正确地)决定它应该是一个空字符串,因为匿名命名空间是一个可以有名称的实体,但它就是没有——不像整数那样,要求名称是没有意义的。API 审查远未完成,但它将继续在 Telecons 上进行,以确保反射成为 C++26 的一部分。std::meta::infostd::meta::infostd::meta::infostd::meta::name_of42std::meta::name_of(reflection_of_anonymous_namespace)42

我唯一剩下关心的是与标准库其余部分的耦合。例如,返回类成员的编译时。我不喜欢这样。从哲学上讲,用于从编译器查询信息的低级 API 不应依赖于标准库。许多项目不使用 ,因为它们可以更好地实现它。但是,他们无法实现反射 API,只有编译器可以。因此,使用现有的 API 会迫使每个人都依赖 .此外,除非你使用模块,否则如果你小心避免使用标准库头文件,包括 和 (for ) 可能会大大增加编译时间。由于反射代码必须位于头文件中,并且不能隐藏在 中,因此这是一个问题。实现者还告诉我,与编译器知道的一些自定义构建类型相比,在编译时构造 a 的成本真的很高。因此,我将写一篇论文,通过切换到例如 and 而不是 和 。std::meta::members_ofstd::vector<std::meta::info>std::vectorstd::vector<vector><string_view>std::meta::name_of.cppstd::vectorstd::meta::info_arrayconst char*std:: vectorstd::string_view

尽管如此,我还是很高兴在 C++26 中使用反射。

C++ 借用检查

由于对安全编程语言的日益推动,C++ 委员会成立了第 23 研究组 (SG 23) 来讨论安全和安保提案。我参加了他们星期二的会议。

Bjarne Stroustrup 提出了 P3274,这是他关于添加安全配置文件的提案。这个想法是使用 attributes 选择性地选择加入部分代码中的编译时和运行时检查。例如,您可以选择加入执行范围检查以防止越界范围访问的配置文件。这当然是我们可以在 C++ 中采用更多检查而不破坏现有代码的唯一方法,但论文本身还没有提出更详细的具体配置文件。

与此同时,Sean Baxter 在他的 C++ 编译器中实现了 Rust 的整个借用检查器。它引入了带有生命周期注解的 borrow checked 引用,然后像在 Rust 中一样进行检查。这确实是一项令人印象深刻的工作,并且表明添加 C++ 的安全子集不存在技术问题。但是,存在采用问题。类型系统中的这种侵入性更改意味着在调用仍然使用常规引用(即所有引用)的库函数时,您将不会进行借用检查。对于他的 demo,他必须编写自己的 demo 并获得好处。这基本上将语言分叉为安全和不安全的库,并且所有内容都必须重写为安全版本。除非所有调用的函数都是安全的(或者你使用不安全的块),否则你也不能将函数标记为安全。这需要自下而上(部分)重写。但是,如果您无论如何都要重写代码,为什么不在 Rust 中重写它并免费获得采用 Rust 生态系统的所有其他好处呢?std2::string_viewstd2::vector

最终,我认为 C++ 不会或不需要成为一种安全的语言。我们已经有了一个安全的系统编程语言 Rust。如果你在编写安全很重要的代码,你应该使用 Rust。相反,我们应该把精力集中在改进与 Rust 的互作上。这样,C++ 可以轻松地将安全模块用于网络或解析等关键代码,并且 Rust 可以访问大量的遗留 C++ 代码。

春季会议

访问报告:日本东京春季 ISO C++ 会议

 

上周,我参加了在日本东京举行的 ISO C++ 标准化委员会 2024 年春季会议。这是即将出台的 C++26 标准的第三次会议,也是我作为范围研究组 SG 9 助理主席的第一次会议。

我从周一开始了 LEWG,这是 C++ 标准库设计的工作组。在通常的论文添加/扩展之后(Victor Zverovich 让我们忙碌),我们批准了一个添加线程属性的提案,并审查了 P2900 合约的库部分。LEWG 就是 LEWG,我们主要抱怨这些名字(里面有太多合同),但总的来说很喜欢它。然而,契约是一种语言功能,真正的争议是在语言设计小组 EWG 结束的。特别是,如果你在前提条件中有未定义的行为,会发生什么情况?请考虑以下示例:std::formatstd::contracts::contract_violation

 
std::string_view slice(std::string_view str, int pos, int length)
	pre (0 <= pos && pos <= std::ssize(str) && 0 <= length && pos + length <= std::ssize(str))
{
	return std::string_view(str.data() + pos, str.data() + pos + length);
}
一个切片函数,用于将有符号整数用于演示目的。std::string_view

前提条件中的 integer overflow 是 undefined 行为。一些人认为,这应该被明确定义,并导致违反前提条件。虽然这很好,并且可以导致 C++ 的通用“安全模式”,该模式也可以(而且应该)在合约之外使用,但我看不出在 C++26 之前如何实现它。我宁愿在 C++26 中拥有具有未定义行为的合约,然后进一步延迟它。undefined 行为的好处是它以后总是可以被很好地指定。pos + length

然后,我在周二和周三(共同)主持了 SG 9,即范围研究小组。我们主要对 std::ranges::cache_last 或 lexicographical_compare_three_way 的 rangized 版本等次要功能提供反馈,但也开始研究并行范围算法,以允许使用 C++17 的执行策略进行简单的并行化。虽然这无疑是一个不错的功能,但它加剧了算法组合的组合爆炸。对于每个算法,如 或 ,我们已经有for_eachtransform

  • 一个采用迭代器的重载 ,stdstd::for_each(begin, end, f)
  • 重载采用迭代器和执行策略 ,stdstd::for_each(std::execution::par, begin, end, f)
  • 一个采用 iterator 和 sentinel 的重载,以及std::rangesstd::ranges::for_each(begin, end, f)
  • 采用范围 .std::rangesstd::ranges::for_each(rng, f)

P3179 想要添加

  • 重载 take iterator、Sentinel 和 execution policy 以及std::rangesstd::ranges::for_each(std::execution::par, begin, end, f)
  • 采用 range 和 execution policy 的重载。std::rangesstd::ranges::for_each(std::execution::par, rng, f)

此外,还有使用发送者 / 接收者 的异步算法的工作。这可能意味着每个标准库算法最多有 8 个副本!我很高兴我不是一个标准的库实现者。

周四和周五,我在 LEWG、SG 21(合约)和 SG 7(反射)之间循环。P2996 的反射版本实际上正在为 C++26 做好准备,实现已准备就绪,这真的很令人兴奋。

最后,我想强调两个提案,我错过了他们的讨论,但真的很想讨论。第一个是 P2786,它支持“重新定位”。通过指定 class 属性,可以告诉编译器可以优化对 .这可以提高作 的效率。该论文在被 EWG 转发给 wording review 后,有望进入 C++26 阶段。trivially_relocatablestd::memcpystd::vector::reserve

第二个是 P2822,它允许控制 ADL。默认情况下,ADL 会查看类的封闭命名空间以及基类和模板参数的命名空间。这通常是不希望的:只有少数函数(如运算符重载)或应该使用 ADL 调用。因此,在 think-cell 中,我们将所有类型放入一个单独的子命名空间中,其中仅包含应通过 ADL 可见的函数。P2822 允许您使用类声明上的说明符指定对 ADL 可见的命名空间集。特别是,如果您编写 ,则不会通过 ADL 看到任何命名空间,并且仅找到隐藏的朋友。这才是它最初应该的工作方式!该论文首次被新语言特性孵化器 EWGI 看到,并将其转发给 EWG。我真的希望它能进入 C++26,这样我就可以摆脱代码库中的所有嵌套命名空间。swapnamespace(A, B, C)namespace()

请注意,由于 C++ 不断弄错默认值,我们很快不仅会编写带有大量修饰符的函数声明

[[nodiscard]] constexpr auto foo();

但也有类……

class foo final trivially_relocatable namespace() {
  …
};