CppOnSea-2025-笔记-全-

CppOnSea 2025 笔记(全)

001:更高阶发送者与异步程序的形态 🚀

在本教程中,我们将学习C++26标准库 std::execution 中的核心异步原语,并深入探讨如何构建更复杂的异步算法,特别是能够处理动态执行和循环的算法,如 repeatbranch。我们将从基础概念开始,逐步深入到具体实现,最终理解如何利用这些工具构建一个高效的并发网络服务器。


回顾 std::execution 基础原语 🔧

上一节我们介绍了本教程的目标。本节中,我们来看看C++26 std::execution 提供的基础异步原语。

以下是 std::execution 提供的核心原语家族:

  • justjust_errorjust_stopped:这些是最简单的原语。每个都形成一个异步操作,该操作在开始时立即结束。它们通过提供给定值、错误或发送停止信号,将我们引导至异步领域。
  • thenupon_errorupon_stopped:这些原语允许更复杂的操作。每个都有一个前驱异步操作,该操作运行至完成。之后,调用一个常规的同步可调用对象,并传入前驱操作提供的值。这个同步可调用对象可以执行某些操作并返回一个新值,该值在管道中移动至完成时被替换进去。
  • let_valuelet_errorlet_stopped:这些原语进一步扩展了异步能力。同样,一个前驱操作导致同步操作的调用。但这个同步操作不返回要替换到输出中的值,而是返回一个发送者。这个发送者封装了基于前驱值的新异步计算,然后被连接、启动并允许运行至完成。
  • when_all:这是实现并发的重要原语。它允许子操作同时向前推进,但遵循结构化并发的严格规定,只有在每个子操作都结束后,整个操作才会结束。

通过这次回顾,我们现在必须问自己一个重要问题:真实的程序看起来是这样的吗?回顾我们检查过的所有图表,它们有一个共同点:箭头只从左向右延伸,没有从右向左的。我们没有动态性,没有循环。而这些对于我们在现实世界中编写的程序至关重要。

在幻灯片上,您可以看到一个同步网络服务器,它接受传入连接,处理它,然后返回以再次接受。这种模式并非网络所独有,GUI应用程序也完全遵循相同的模式:重复等待传入事件,处理它,然后返回等待更多。


尽管我们刚刚回顾了原语,但我们知道实际上可以在 std::execution 中对此建模,因为我去年在特拉维夫的一次两小时演讲中说过我们可以。但不幸的是,即使是两个小时也不足以全面探讨这个主题。该演讲中没有详细描述几种重要算法。因此,我现在重返讲台来详细讲解,因为我们需要的算法之一叫做 repeat


引入 repeat 算法 🔄

上一节我们指出了基础原语在建模真实循环场景时的不足。本节中,我们来看看解决这个问题的第一个关键算法:repeat

以下是其面向用户的API。如果这看起来令人生畏,请不要担心,这只是为了确保这个新算法在函数式风格和使用范围(ranges)中我们期望的管道风格时都能良好工作而必须编写的样板代码。

// 示例:repeat 算法的用户API示意
auto repeat_sender = repeat(child_sender);

我们可以看到它是一个一元发送者工厂,当给定一个发送者时,它返回一个建模了 repeat 算法的发送者。但我们也可以看到我们可以重新调用它,这是允许它参与管道、使用从左到右可读语法的粘合剂。

无论API如何,repeat 算法的异步执行结果是一个子操作被反复连接和启动,最终为我们提供了一个从右向左延伸的箭头,这使我们能够对现实世界的问题进行建模。





但在深入 repeat 的实现之前,我们需要简要回顾一下 std::execution 生态系统,专注于重要的部分,以便我们能在仅一小时内完成本次讲解。


理解异步执行的核心:操作状态 ⚙️

上一节我们引入了 repeat 算法。本节中,我们深入探讨 std::execution 生态系统的核心,理解异步执行真正发生的地方。

尽管人们通常将其称为发送者和接收者,但我认为发送者和接收者可能是这个生态系统中最不有趣的部分。

  • 一个发送者只是一个完全柯里化的异步函数——要调用的函数与所有参数结合在一个不可分割的单元中。
  • 一个接收者只是同步函数返回通道的异步版本——封装了完成时将采取的操作。

单独来看,这两者什么都不做,它们根本不是异步的。只有当我们用 connect 将它们连接起来并获得一个操作状态时,我们才真正获得了异步执行的场所。因此,我认为我们应该专注于操作状态,因为这才是激动人心的异步操作的实际实现所在。

如果您想了解构成发送者和接收者的样板代码,我鼓励您查阅我今年早些时候在阿斯彭的演讲,我在那里详细探讨了所有这些内容。



目前,我们将直接深入 repeat 操作状态的实现。


实现 repeat 的操作状态 🛠️

上一节我们明确了操作状态的核心地位。本节中,我们开始剖析 repeat 算法的操作状态实现。

我们立即看到一张充满代码的幻灯片。暂时忽略两个基类,我们只浏览这里所有的类型别名和成员。

// repeat 操作状态的核心成员示意
class repeat_op_state {
    using base1_t = ...; // 存储接收者的基类
    using base2_t = ...; // 管理子操作状态的基类
    using get_receiver_t = ...;

    Sender sender_; // 要反复连接的发送者
    bool engaged_ = false; // 手动生命周期管理标志
    // ... 其他成员
};

我们有每个基类的别名,以便更方便地引用它们,并且我们引入了 get_receiver,以便在底部无需限定即可使用它。我们有类的重要成员变量:我们将反复连接的 sender_,以及一个唤起我们正在做手动生命周期管理想法的 engaged_ 标志。

这让我们回到幻灯片的顶部,回到那两个不透明的基类。这两者都抽象了在每个操作状态中都需要编写的样板代码。

  1. 第一个处理需要存储接收者的问题——需要存储向其传递完成的对象。这促使我们引入该基类的 get_receiver 成员函数,以便在我们想要检索该接收者并对其采取行动时调用它。
  2. 第二个抽象了需要为反复运行的操作存储操作状态的事实。仅有一个发送者是不够的,因此我们需要能够将其连接到一个操作状态,这个基类抽象了这一点。

如果您想了解这些实用程序的必要性或它们是如何实现的,可以阅读Brian Stmore和Louis Baker撰写的论文,该论文讨论但未正式提出它们,我在今年早些时候阿斯彭的演讲中也详细介绍了。

现在,我们只需继续完成实现,开始查看此操作状态的公共成员。


repeat 操作状态的公共接口与启动 🔌

上一节我们介绍了 repeat 操作状态的基本结构。本节中,我们来看看它的公共接口,特别是关键的 start 函数。

为了成为一个操作状态,必须通过拥有一个必要的嵌套类型别名(称为 operation_state_concept)向 std::execution 的概念机制公开这一事实。我们这样做了。并且因为操作状态是从连接发送者和接收者的二元操作 connect 返回的,所以我们有一个模拟这一事实的构造函数。

但仅仅通过嵌套类型别名声明自己是操作状态还不足以使其成为操作状态。还需要提供操作状态的基本操作:一个名为 start 的一元可调用成员函数。从 connect 返回的操作状态仅封装潜在的异步执行,没有与之关联的前向进展。只有在调用其上的 start 时,异步前向进展才真正开始,这就是我们在此成员函数中封装的内容。

我们访问那个为我们抽象掉操作状态管理的基类,并要求它在给定发送者的情况下构造一个包含的操作状态。但我们知道操作状态是发送者和接收者的产物。那么在这个表述中,接收者在哪里?这是该基类带给我们的一个功能,样板代码被抽象掉了。该基类为我们合成接收者,并通过CRTP简单地将此接口转发给我们。我们不需要担心自己编写样板代码,只需编写对 construct 的调用。

此后,我们现在手动管理生命周期,将 engaged_ 设为 true,然后当然需要要求基类中的那个操作状态开始向前推进。


这一切乍一看都说得通。但当你长时间仔细审视时,会遇到一个问题,因为在 construct 内部,我们在发送者和接收者上调用 connect,而 connect 是一个可能抛出异常的操作。如果它抛出异常,该异常将冒泡并击中函数顶部的 noexcept 屏障,导致调用 terminate——这不是期望的结果。

你的第一反应可能是:好吧,我们移除 noexcept 或使其成为有条件的。但这也是行不通的。操作状态的 start 操作被强制要求为 noexcept,因为它代表了从同步领域到异步领域的移交。让 start 使用像抛出异常这样的同步错误报告机制是不合适的。一旦调用 startstd::execution 生态系统就是全包的,唯一的出路是通过异步报告机制(例如通过接收者发送错误)向下传递。

也许我们在这里有了答案。也许我们只需要重写这个函数,使其具有 try-catch,当捕获到异常时,现在以异步方式处理,而不是同步方式,强制将其传递给接收者。

但这又带来了一个问题,一个你可能很长时间都不会注意到的问题。问题是用户可能提供给你一个不可无异常连接的发送者。他们可能提供一个实际上不允许用异常调用 set_error 的接收者,因为他们静态地知道不可能抛出异步异常。

但编译器不知道这一点。无论如何,它都会生成红色轮廓的代码。在最合理的情况下,当我们的用户合理地期望他们不需要处理异常时,它将无法编译。

因此,我们需要再次编写幻灯片上的代码。我们需要使用 if constexpr 来分割我们的编译时执行。如果我们知道连接发送者和接收者不会抛出异常,我们只需调用没有 try-catchconstruct,不发出那个有问题的 set_error 调用。另一方面,如果我们知道连接可能抛出异常,现在就有必要进行 try-catch,我们现在合理地期望我们的消费者处理这个。



但关于这张幻灯片的一系列问题还没有结束。那个 is_nothrow_connectable_v 是什么?为什么它不在 execution 命名空间中?也许更奇怪的是,我们知道 connect 是发送者和接收者上的二元操作,但这个特征是关于发送者和环境的。原因是我写的一篇论文,该论文已通过库演化工作组转发,它使得确定 connect 何时控制异常变得更容易,因为在某些重要情况下你无法访问接收者的实际类型,但你需要找出这个问题的答案。

该论文提出的解决方案是定义接收者的所谓显著属性——那些允许影响 connect 抛出性的属性。论文说,就 connect 的抛出性而言,接收者唯一的显著属性是其环境。因此,如果你知道接收者的环境,你可以合成一个原型并检查它,从而得到问题的答案。这正是 is_nothrow_connectable_v 的实现方式。我们有一个详细的命名空间,它内部包含一个原型接收者。





这个接收者接受任何完成签名,但重要的是具有正确的环境。然后,在幻灯片的底部,我们通过检查将具体发送者连接到具有适当环境的接收者的语句的抛出性,来合成特征的值。

完成这段旅程后,我们现在可以回到操作状态的实现,当然,我们提供了一个析构函数,因为我们在做手动生命周期管理。如果我们没有在基类中参与,我们就去基类并销毁它。

但所有这些都没有帮助我们实际处理子操作的完成——我们异步循环的主体。为此,我们需要处理我们的CRTP基类正在将其接口转发给我们的事实。我们将像接收者一样接收那些成员函数调用。

因此,在 set_value 内部(当循环体成功完成时执行的代码),我们只需在无限异步循环中设置另一次迭代。我们去基类,要求它销毁与当前调用关联的操作状态,将 engaged_ 标记为 false,然后继续下一次迭代。

另一方面,处理错误和停止涉及我们实际结束循环。当循环体请求停止或失败时,我们继续循环是不合适的。因此,我们只需将其传递给我们的接收者,结束整个异步执行。我们尚未处理的接收者接口的唯一元素当然是 get_env,因此我们只需返回与接收者关联的环境,将我们的子操作参数化为从接收者接收到的相同参数。

这实际上完成了 repeat 的操作状态。我们现在可以回去,将我们的同步示例重新想象为异步示例。我们将 accepthandle_connection 重新想象为发送者工厂,它们返回一个发送者,该发送者可以被连接和启动以形成这些函数结果的异步执行。然后,在底部,我们合成一个封装服务器循环的发送者。我们异步接受,并将由此生成的值通过管道传输到 let_value。回想一下,let_value 调用某个函数并期望它返回一个封装成功执行的发送者,而这正是 handle_connection 所做的。它接受文件描述符,返回一个发送者,连接并启动它,然后我们将这个组合操作通过管道传输到 repeat 中,一遍又一遍地执行。

因此,我们终于编写了我们的异步服务器。

或者我们真的写好了吗?让我们回顾一下我们实际编写的内容。我们知道这是 repeat 封装的异步执行图,我们知道这是 let_value 封装的异步执行图。但这个 let_value 的渲染是通用的。我们有关于我们情况的具体细节,我们可以说的不仅仅是“子操作”,我们可以说是 accepthandle_connection,而那个可调用对象只是无关紧要的粘合剂,只是视觉噪音。所以我们移除它。

现在我们准备合并这两个图表。我们将它们合并成一个我们在前一幻灯片上实际编写的异步执行图。也许从这个图中可以清楚地看出问题。但如果还不清楚,我将把它展平,将其渲染为一条直线。我将向您展示运行此程序时实际发生的情况:您接受,然后处理一个连接。只有在完成处理该连接后,您才再次接受。我们编写的是一个一次只处理一个客户端的异步网络服务器,这种服务器可能不会被大多数人认为有用或有趣。


我们需要回过头来思考我们是如何被误导得出这个结论的。我想我们大多数人看着这张幻灯片,直觉上认为 handle_connection 会脱离并在后台执行某些操作。所以让我们明确地表达这个期望。让我们把它包装在一个线程中并调用 detach。但我们已经遇到了一个问题,因为我们调用了线程上的一个邪恶成员函数。我们将其从前景范围分离,它现在在后台运行。我们如何推理它何时完成?我们如何知道何时可以安全地离开这个范围?我们如何知道何时可以安全地清理它可能正在使用的对象?

也许我们仍然致力于这种临时设计程序的方法。我们说我们可以通过添加自己的手动跟踪属性来解决这个问题。我们有一些互斥锁和一些计数器以及一个等待lambda。在底部,我们调用 wait 以确保在我们离开范围时没有后台运行的内容。但就像在所有临时探索问题领域的情况一样,我们遗漏了一些东西。我们遗漏了至少在这个函数的两个点可能抛出异常的事实。如果我们这样做了,我们就不等待,我们又回到了有后台运行而它可能使用的对象被清理的退化情况。




但没关系,这张幻灯片上的字体可以更小,这样我们就可以尝试修复这个问题,所以我们把东西包装在 try-catch 中,并在每个退出点等待。但再一次,我们学到了关于临时解决问题方案的教训,因为这里仍然存在异常安全问题。我们仍然可以在线程内部抛出异常,这将冒泡到线程顶部并导致调用 terminate


也许在我们尝试解决这个问题之前,我们应该退一步问自己:这是好代码吗?这是可维护、可重用的代码吗?我实际上能测试这段代码吗?我为这段代码感到自豪吗?

最重要的是,如果我只想关闭服务器会发生什么?我如何取消我启动的所有工作?不,我必须等待它们全部自然完成。我们需要做的是放弃临时设计方法,问自己:我们实际想做什么?我们实际想做的是建模看起来像这样的异步执行:我们接受,然后基于我们接受的内容处理连接,但我们也立即再次接受。我们不等待完成处理连接,我们继续那个主循环,不受我们正在生成工作这一事实的干扰。

我们之前做的变量更改,将我们从“子操作”更改为我们知道在这种情况下正在执行的具体操作,我们可以做相反的转换。我们可以将我们的特定图表转换回通用图表,现在突然我们看到一个通用异步算法的形状从这个问题基础中浮现出来,这给我们留下了两个问题。

第一个也是最重要的一个,尤其是如果你坐在LWG(库工作组)中:我们给这个算法起什么名字?我们可以无休止地争论这个问题。诚然,我以前也曾无休止地争论过名字,但我要说我们就根据它看起来的样子来命名,我们就叫它 branch,因为它看起来像一个分支。


这给我们带来了第二个技术上更有趣的问题:我们如何将这个图展现在C++ API中?这个操作实际上如何行为?这就是我要开始讲解的内容。

repeat 一样,branch 是一个一元发送者工厂,意味着它接受一个子发送者。但与 repeat 不同,它允许发送者不仅以无参数的 set_valueset_errorset_stopped 完成。它还允许发送者以 set_value 完成,其参数是一个或多个发送者。发送者期望生成异步执行,该执行将在完成时发送发送者。这是一个高阶发送者。它是同步函数的异步模拟,该函数在完成时返回另一个函数。

但理解这一点并不能帮助我们理解实际的执行,它只是帮助我们理解函数签名。因此,我们需要思考当我们连接和启动一个 branch 发送者时会发生什么。

事情开始时看起来与 repeat 非常相似。我们获取主发送者,连接并启动它,并允许它运行至完成。但当它完成并向我们发送发送者时,事情开始发生巨大变化。我们遍历发送给我们的每一个发送者,并连接和启动该操作。我们不等它完成就关闭循环。我们允许所有这些操作开始向前推进,然后立即再次连接和启动那个主发送者。我们允许那个循环继续生成工作,尽管之前生成的工作仍在运行。

另一方面,如果它向我们发送无参数的 set_value,我们将其解释为相当于文件结束。这是主发送者告诉我们它没有更多工作要生成,我们应该开始优雅的扇入。这是优雅的扇入,因为如果我们有未完成的作业仍在后台运行,我们希望允许它们运行至完成。这与我们将要考虑的下一种扇入形式形成对比。

当主发送者发送错误的完成签名时——它发送 set_errorset_stopped。在这种情况下,我们想要进行中止扇入。操作的结论已经决定,它将以该错误或停止结束。因此,我们去找所有未完成的工作,并要求它扇入。我们请求它停止。我们利用取消是 std::execution 生态系统中的一等公民这一事实。

但我们之前讨论过我们需要结构化并发。我们不能只是让这些子操作脱离并在后台运行。这意味着我们需要考虑它们也会完成的事实。因此,当一个子操作完成并发送 set_value 时,在某种意义上,我们什么也不做。我们允许其他一切继续工作。子操作成功完成了一项工作,但这并不影响我们整体的异步执行。另一方面,从子操作发送的错误和停止信号不应被忽略。因此,当这些被发送时,我们再次开始中止扇入。我们请求操作尽快结束,并将该错误或停止信号向下游传递给我们的消费者。

现在,尽管我们理解了我们试图构建的形状,我们可以继续并开始思考我们将如何构建它,并且我们从之前知道,在 execution 中,操作状态是异步执行的场所。

但操作状态只是一个C++类。它由部分组成。我们可以推理它内部有什么。因此,在我们的图表上,我们不应该有一个单一的、不可分割的整体,而应该有组成该操作状态的各个部分:一个接收者、一个发送者、一个停止源以及待处理的错误或停止。最后两个是由于在中止扇入情况下,我们想要请求所有子操作停止执行。为此,我们需要一个可以请求停止的停止源,该停止源将传递给所有子操作。而待处理的错误或停止反映了当某物以错误或停止结束时,它可能不是最后一个子操作。我们不能阻塞等待所有人完成,那不会是异步的。因此,我们需要某个地方来记录该完成,以便我们稍后可以检索它并将其发送给我们的消费者。

但这个图表仍然掩盖了 branch 所做的最有趣的事情:它生成一个子操作,然后另一个子操作。然后如果子操作成功结束,它只是让它消失并继续执行。在我们的图表中反映了这一点,我们现在有了一个地图,将指导我们完成这个算法的实现。我们可以访问每个我们不理解的节点,看看它是如何实现的,退一步,然后看看如何将它们组合在一起。

所以让我们现在就开始这段旅程。让我们试着想想那个停止源会是什么样子。实际上,我在今年早些时候阿斯彭的演讲中介绍了这个原语,一个称为 chain_stop_source 的原语。我们知道它派生自一个 in_place_stop_source,因此它本身是一个停止源,我们可以向其提交停止请求。但它也有一个一元构造函数,接受一个停止令牌。这反映了我们的消费者也向我们提供了一个停止令牌的事实。我们不仅有为子操作准备的停止逻辑,我们还有一个父操作有为我们的停止逻辑,我们需要通过提供从接收者环境获得的停止令牌来将它们组合在一起。

但在我今年早些时候在阿斯彭发表演讲并展示这个接口之后,有人提醒我标准中有一个有问题的部分。标准说我不能以这种方式实际使用停止令牌,因为它掩盖了操作状态有两个生命周期的事实。它有常规的同步生命周期,由其构造函数和析构函数界定,但它也有第二个生命周期。它有一个异步生命周期,从调用 start 时开始,到通过接收者发送完成信号时结束。正如您在屏幕上看到的,标准说操作状态对停止令牌的使用必须由异步生命周期界定,而不是同步生命周期。如果我们使用前一幻灯片上的方法,在构造时传递停止令牌给我们的停止源,我们将通过同步生命周期界定该使用,我们将违反标准的要求。因此,我们需要做一些事情,当您编写基于 std::execution 的代码时,这对您来说将变得常规。我们需要更改接口,以便它允许我们反映异步生命周期的界限。

我们现在有一个默认构造函数,但我们有两个新的成员函数,在异步生命周期开始和结束时调用。attach 提供停止令牌并构造内部停止回调,在我们启动分支操作时调用。另一方面,detach 在我们完成该操作之前调用,并销毁停止回调。这样我们就遵守了标准的要求。


这让我们回到了我们的地图,回到了待处理的错误或停止——存储我们子操作可能错误完成的所有方式的存储。我在今年早些时候阿斯彭的演讲中也提出了这个问题的解决方案,我称之为 storage_for_completion_signatures。我们在完成签名上实例化它,它有两个相关的成员函数:一个允许我们以某个签名到达,传递指示它被发送的通道的标签类型及其参数(如果有的话);此后,我们可以调用 visit,我们将调用我们存储的那些参数到一个访问者中。然后访问者可以将它们转发给我们的接收者。


但在我们访问它之前,并不要求我们已经到达 storage_for_completion_signatures。可能的情况是我们没有在这里存储任何东西。在这种情况下,visit 返回 false,由我们决定下一步做什么。

但回顾这张幻灯片时,有些奇怪的地方。我说我们在完成签名上实例化它,但这是我们迄今为止尚未讨论过的。我们知道常规同步函数有一个签名,我们可以用它来推理它们完成的方式。它们有一个返回类型,它们可以是 noexcept 或不是,但在 std::execution 给我们的异步宇宙中,是否存在类似的东西?答案是肯定的,你可以查询一个发送者关于它可以完成的所有方式,它返回给你一个 std::execution::completion_signatures 的实例化,传达此信息。那里的每个模板参数都表示该发送者可能完成的一种方式。

在这里,我们已经可以看到 std::execution 给我们带来了表达力,而这在同步领域是我们所缺乏的。因为我们可以作为一等公民报告我们以多种方式成功完成,我们不需要 std::variant。并且我们可以报告当我们以某种方式成功完成时,我们只是发送多个值,再次作为一等公民,不需要任何 std::tuple。也许最重要的是,我们还可以传达。




我们有一种方式来报告我们只是放弃了向前推进,而不是我们成功或失败。

但这种类型和这种机制的存在引发了我们 branch 实现的一个方面。我们现在需要弄清楚我们可以完成的所有方式,以便向我们的消费者宣传这一点。所以让我们看看那可能是什么样子。我们有一个主发送者,它可以以两种方式完成。它可以发送 set_value(T),或者它可以发送 my_sender。注意这是一个高阶发送者,这正是我们要求的。

看着这个,我们可以立即开始填写我们的候选完成签名集。如果主发送者可以发送 set_value(T),那意味着它可以报告文件结束。那意味着我们可以到达工作的末尾。那意味着我们可以反过来发送 set_value(T)。但我们知道我们将分支出一堆子操作,为此,我们将需要分配动态内存,而这可能抛出异常。所以我们还知道我们可以通过发送 set_errorstd::exception_ptr 来异步抛出异常。


但在这个表述中,我们遗漏了一些东西,因为我们不只是要运行主发送者。branch 的整个要点是我们还要运行那个子发送者 my_sender。所以我们检查它,我们看到它的完成签名。我们看到它可以向我们报告成功完成,但它也可以使用错误代码报告错误完成,而不是使用 exception_ptr。并且由于我们想要尊重主发送者及其生成的子操作的错误完成,我们需要更新我们的输出完成签名集以包含这个新的错误签名。

在幻灯片上挥手用箭头和写在屏幕上的类型来讲解是一回事,但我们如何在C++代码中实现这种转换?我们要编写繁琐的常规模板元编程吗?那种冗长到我不想在一个小时的演讲中做的程度。


但然后我们想,这是一个关于 std::execution 的演讲。std::execution 是一个C++26特性。这是一个C++26演讲,这意味着我可以使用C++26的所有特性来实现这个转换。例如,如果我愿意,可以使用反射。所以这正是我要做的。我将编写一个基于反射的辅助函数,然后我们将编写一个 consteval 函数,在反射空间中执行此转换。

我需要的辅助函数是 make_completion_signature,它合成表示特定完成签名的类型。回想一下,这是一个函数类型,发送它的通道由返回值指示,传输的值表示为参数。因此,在这个函数内部,我获取发送的参数,并将它们展平为单个向量。我将它们替换到我的 make_completion_signature 实现中,该实现将它们渲染为函数类型。但因为这是一个别名,我从该替换中得到的是别名的反射,而不是类型的反射,因此我剥离那一层并返回它。

有了这个原语,我现在可以开始编写 completion_signatures 的实现,它在编译时合成我的操作完成签名。注意,它参数化了主发送者完成签名的反射和环境的反射。随着我们继续前进,我们将需要这两者。我们从一个候选集开始,其中包括 set_errorexception_ptr,因为知道我们可能抛出异常,因为我们将进行动态内存分配。然后我们定义一个辅助lambda add 来反映 std::execution::completion_signatures 的有效实例化不包含重复项的事实。因此,add 只是忽略添加任何重复的签名请求。

有了这些样板代码,我们可以继续函数的底部,在那里我们实际上开始做有趣的事情。我们将遍历第一个参数指示的类型的每个模板参数。我们知道第一个参数指示 std::execution::completion_signatures 的一个实例化,并且我们知道其每个模板参数都是一个完成签名。因此,通过遍历这些模板参数,我们正在遍历主发送者的每个完成签名。对于每一个,我们简单地调用 signature,然后不透明地继续。

如果我们没有找到任何高阶完成签名,found_sender 由于我们将在接下来的幻灯片中看到的原因将为 false。在这种情况下,我们不能有效地实现 branch,一个不分支出任何东西的分支。因此,我们抛出一个异常,以防止我们在该场景中报告无意义的答案。否则,我们获取收集到的所有签名,将它们替换到 std::execution::completion_signatures 中,并返回结果类型的反射。

但这只是掩盖了很多实现细节。signature 内部到底隐藏着什么,实际上帮助我们为从主发送者提取的每个签名执行转换?所以我们继续深入查看。我们看到我们使用 return_type_of 来提取函数类型的返回类型的反射,回想一下,这是发送该完成签名的通道。如果是 set_value(T),我们需要做一些复杂的处理。回想一下,来自主发送者的 set_value(T) 有两个含义:它可以表示文件结束,也可以传输新工作。因此,我们特化没有参数的情况。这只是主发送者让我们知道它可以报告文件结束。所以我们只是将其复制到输出集,然后继续下一个。


另一方面,如果它有发送的参数,每一个都必须是子发送者。因此,我们遍历它们并调用 child,然后我们就完成了。另一方面,如果发送签名的通道不是 set_value(T),它必须是错误的完成签名。因此,我们调用 copy_signature 简单地将它们传播到输出集。这是我们要深入的下一个实现细节lambda。当我们尝试将这些错误签名传输到输出集时,我们做什么?往里面看,我们可以看到 set_stopped 很容易。set_stopped 从来没有参数。所以我们只是将其复制到输出集。

另一方面,set_error(T) 实际上可以有一个表示错误的参数。事实上,它被强制要求这样做。这给我们提出了一个有趣的问题。我们想要获取这个错误类型并将其传播到存储中。此后,我们想要检索它并将其发送到下游。要做到这一点,需要我们获得的不是类型 T,而是其关联的存储类型,这是通过衰变该类型获得的。因此,我们衰变该类型。但然后我们意识到,衰变复制并不总是可能的。所以我们检查是否根本可以衰变复制这个类型。如果不能,我们就不能实现 branch。因此,我们抛出一个异常,不输出无用的答案。否则,我们创建一个新的完成签名,即 set_error 与衰变类型,因为那是我们将从存储中检索存储错误时要获取的类型。

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/fa556fad1494e7a

002:不要被 C++ 重载集淹没

在本节课中,我们将要学习 C++ 中一个强大但有时令人困惑的特性:函数重载集。我们将从基础概念开始,逐步深入到重载解析的规则、名称查找、以及一些高级技巧和常见陷阱。通过本教程,你将理解什么是重载集,如何找到它们,以及编译器如何选择调用哪个函数。

什么是重载集?🤔

函数重载是 C++ 从 C 语言继承并增强的一个强大特性。它允许我们定义多个同名但参数列表不同的函数。这些同名函数构成的集合,就称为“重载集”。

让我们看一个简单的例子。在 C 标准库中,计算自然对数的函数有三个变体:

  • log:接受 double 参数,返回 double
  • logf:接受 float 参数,返回 float
  • logl:接受 long double 参数,返回 long double

在 C 中,你必须根据参数类型选择正确的函数名。而在 C++ 中,我们有一个更优雅的解决方案:

// C++ 标准库中的 log 函数重载集
double log(double);
float log(float);
long double log(long double);
template <typename T> double log(T); // 用于整数类型,返回 double

现在,你只需要调用 std::log,编译器会根据你传入的参数类型自动选择正确的函数版本。这就是重载集的便利之处。

重载的规则与限制 📏

上一节我们介绍了重载集的基本概念,本节中我们来看看重载需要遵循哪些规则。

首先,重载函数必须通过参数类型(数量或类型)来区分。仅返回值类型不同的函数不能重载。这是因为在底层,函数签名(用于链接的名称)通常不包含返回类型信息。

// 错误:仅返回值不同,无法重载
float fromString(const std::string&);
double fromString(const std::string&);
long double fromString(const std::string&);

但是,有一个有趣的例外:函数模板。模板允许你通过模板参数来指定返回类型,因为模板本身是一种特殊的“配方”,用于生成具体的函数。

构造函数是重载的常见场景。以 std::vector 为例:

std::vector<int> v1;          // 调用默认构造函数
std::vector<int> v2(5);       // 调用填充构造函数,创建5个元素(值为0)
std::vector<int> v3{1,2,3,4,5}; // 调用初始化列表构造函数

注意 v2(5)v3{...} 的区别。使用圆括号 () 调用的是填充构造函数,而使用花括号 {} 调用的是初始化列表构造函数。当使用空花括号 {} 时,会优先调用默认构造函数。我们稍后在讨论重载解析优先级时会再次看到这一点。

深入理解“集合”概念 🧩

“重载集”的核心在于“集”这个字。它不是一个单一的实体,而是通过名称查找找到的一组同名函数。

考虑以下代码:

namespace A {
    void foo(int) {}
    namespace B {
        void foo(char) {}
        void foo(long) {}
        void bar(int x) {
            foo(x); // 错误:调用歧义!int 可以转换为 char 或 long
        }
    }
}

在函数 bar 内部调用 foo(x) 时,编译器会在当前作用域(namespace B)内查找名称 foo,并找到了两个候选函数(foo(char)foo(long))。这就是 foo 在此上下文中的重载集。由于 int 可以隐式转换为 charlong,编译器无法决定使用哪一个,因此报错。

我们可以通过强制类型转换来消除歧义:

foo(static_cast<long>(x)); // 明确选择 foo(long)

如果 bar 在另一个命名空间 C 中,而 C 中没有 foo,那么名称查找会向上层命名空间 A 搜索,并找到 foo(int),调用就不会有歧义。

重载集本身不是一个可以直接操作的类型。例如,你不能直接获取一个重载集的类型信息:

// 错误:无法解析对重载函数的引用
auto t = typeid(A::B::foo);
// 正确:必须通过调用或强制转换来指定具体函数
auto t = typeid(static_cast<void(*)(long)>(A::B::foo));

名称查找:如何找到重载集?🔍

在编译器决定调用哪个函数之前,它必须先找到候选函数集,即重载集。这个过程称为“名称查找”。

基本规则是:从调用点所在的当前作用域开始,逐级向外层作用域搜索,直到找到该名称的声明。一旦在某个作用域找到了该名称,搜索就会停止,并将该作用域内所有该名称的函数(以及通过参数依赖查找找到的函数,见下文)加入重载集。

对于类成员函数的调用(如 obj.foo()ptr->foo()),搜索范围限定在该类及其基类中。

参数依赖查找(ADL)

ADL 是 C++ 中一个强大且有时令人惊讶的特性。对于未限定的函数调用(如 foo(arg)),除了常规的作用域搜索,编译器还会检查函数参数类型的所属命名空间,并在那些命名空间中搜索同名函数,将它们也加入重载集。

namespace Other {
    struct S {};
    void foo(S) {} // (3)
}
namespace A {
    void foo(int) {} // (1)
    namespace B {
        void foo(char) {} // (2a)
        void foo(long) {} // (2b)
        void bar(Other::S s) {
            foo(s); // 重载集包含 (2a), (2b), (3)
        }
    }
}

bar 中调用 foo(s),首先在 namespace B 中找到 foo(char)foo(long)。然后,因为参数 s 的类型 Other::S 属于 Other 命名空间,ADL 会将该命名空间中的 foo(Other::S) 也加入重载集。最终,foo(Other::S) 是精确匹配,会被调用。

ADL 是实现运算符重载泛型编程的关键。例如,std::cout << myType 能正常工作,正是因为 operator<< 通过 ADL 在 myType 所在的命名空间中被找到。

重载解析:如何选择最佳函数?🥇

找到重载集后,编译器需要从中选出“最佳匹配”的函数。选择过程基于一系列优先级规则:

以下是匹配优先级从高到低的排序:

  1. 精确匹配:参数类型与形参类型完全一致。
  2. 提升:不会丢失精度的内置类型转换(如 bool/char/short 提升为 int)。
  3. 标准转换:其他内置隐式转换(如 intdouble,数值类型转换等)。
  4. 用户定义转换:通过构造函数或转换运算符实现的转换(如 std::stringstd::string_view)。
  5. 省略号匹配:匹配 C 风格的可变参数 ...(应尽量避免)。

让我们通过一个例子来理解:

void select(int) {}      // 1. 精确匹配 int
void select(short) {}    // 1. 精确匹配 short
void select(double) {}   // 3. 标准转换 (float->double)
void select(std::string_view) {} // 4. 用户定义转换 (char*->string_view)
void select(...) {}      // 5. 最后的选择

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/850194d5ca28e415c2cdfff332ac5be1_101.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/850194d5ca28e415c2cdfff332ac5be1_103.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/850194d5ca28e415c2cdfff332ac5be1_105.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/850194d5ca28e415c2cdfff332ac5be1_107.png)

int main() {
    select(42);          // 调用 select(int),精确匹配
    short s = 42;
    select(s);           // 调用 select(short),精确匹配
    select('A');         // 调用 select(int),字符提升为 int
    select(3.14f);       // 调用 select(double),float 标准转换为 double
    select("hello");     // 调用 select(string_view),用户定义转换
    struct Mystery{};
    select(Mystery{});   // 调用 select(...),无其他匹配
}

花括号初始化的特殊规则

使用花括号 {} 进行初始化(列表初始化)有特殊的优先级:

  • 如果一个函数接受 std::initializer_list 参数,并且调用时使用了花括号,那么只要列表中的元素类型可以转换,这个函数将优先于其他所有函数被选择。
  • 对于构造函数,使用空花括号 {}优先调用默认构造函数,而不是接受 std::initializer_list 的构造函数。

这解释了为什么 std::vector<int> v{5} 创建一个包含单个元素 5 的向量,而 std::vector<int> v(5) 创建五个值为 0 的元素。

当重载遇到模板 🌀

模板函数也参与重载解析,但规则更复杂一些。核心思想是:函数模板是生成重载集的“配方”

选择过程分为两步:

  1. 模板特化选择:对于所有匹配的模板,选出“最特化”的那个。简单理解是,能接受参数类型范围更小的模板更特化。
  2. 重载解析:将上一步选出的最特化模板实例化后的函数,与所有普通(非模板)函数放在一起,按照之前的优先级规则进行重载解析。

一个重要规则:在重载解析排名相同时,非模板函数优先于模板特化。

考虑以下例子:

template<typename T> T sqrt(T); // (1) 通用模板
template<typename T> std::complex<T> sqrt(std::complex<T>); // (2) 针对 complex 的模板
float sqrt(float); // (3) 普通函数

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/850194d5ca28e415c2cdfff332ac5be1_143.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/850194d5ca28e415c2cdfff332ac5be1_145.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/850194d5ca28e415c2cdfff332ac5be1_147.png)

std::complex<float> z;
auto r1 = sqrt(42);    // 调用 (1),int 匹配 T
auto r2 = sqrt(3.14f); // 调用 (3),普通函数优于模板 (1)
auto r3 = sqrt(z);     // 调用 (2),模板 (2) 比模板 (1) 更特化(只接受 complex)

模板重载的陷阱

一个常见的陷阱是,过于通用的模板可能会“劫持”你期望的调用。

// 一个设计不佳的“可选类型”
struct MaybeInt {
    int value;
    bool valid = false;
    operator bool() const { return valid; }
    operator int() const { return value; } // 隐式转换到 int
    operator std::optional<int>() const { return valid ? std::optional{value} : std::nullopt; }
};

std::optional<int> o = MaybeInt{}; // 我们期望调用 operator optional<int>(),得到一个空 optional
// 但实际上可能调用了 optional 的通用构造函数 template <typename U> optional(U&&),然后通过 operator int() 得到了 optional(0)

这里,std::optional 有一个通用构造函数模板,可以接受任何类型 UMaybeIntU 是精确匹配(模板参数推导),而到 std::optional<int> 需要一次用户定义转换。根据优先级,精确匹配的模板被选中,导致了非预期的行为。

成员函数重载与继承 👨‍👦

对于类的成员函数,重载规则基本类似,但增加了作用域(类内)和继承的复杂性。

名称隐藏

在继承体系中,派生类中定义的函数会隐藏基类中同名的函数(即使参数列表不同),而不是重载它们。

struct Base {
    void foo(int) {}
};
struct Derived : Base {
    void foo(double) {} // 隐藏了 Base::foo(int)
};
Derived d;
d.foo(42); // 调用 Derived::foo(double),int 被转换为 double
// d.foo(42); 如果想调用基类的,需要显式指定:d.Base::foo(42);

要引入基类的重载集,需要在派生类中使用 using 声明:

struct Derived2 : Base {
    using Base::foo; // 引入 Base 中的 foo 重载集
    void foo(double) {}
};
Derived2 d2;
d2.foo(42); // 现在重载集包含 foo(int) 和 foo(double),调用 foo(int)

const 和引用限定符

成员函数可以通过 constvolatile 以及引用限定符(&&&)进行重载,这允许根据对象的常量性或值类别来提供不同的实现。

class DataHolder {
    std::vector<char> data;
public:
    // const 重载
    std::vector<char> data() const { return data; } // 返回副本
    std::vector<char>& data() { return data; }      // 返回可修改的引用

    // 引用限定符重载 (较少使用)
    std::vector<char> data() && { return std::move(data); } // 临时对象,可以移动数据
};

实用技巧与总结 🛠️

最后,我们来看一些关于设计和使用重载集的实用技巧。

以下是几个关键建议:

  • 使用默认参数替代重载:如果函数区别仅在于某些参数是否有默认值,使用默认参数更简洁。
  • 避免令人困惑的重载:只对语义基本相同的操作进行重载。例如,open(file)open(gate) 虽然都是“打开”,但行为迥异,应使用不同名称。
  • 使用标签分发进行行为控制:当你需要根据某种“策略”选择不同实现,但又想保持接口统一时,可以使用空结构体作为“标签”参数。标准库中的执行策略(如 std::execution::par)就是例子。
  • 谨慎处理继承中的重载:避免在派生类中添加与基类虚函数同名的非虚重载,这容易导致混淆。尽量保持虚函数的重载集在基类中完整。
  • 使用概念约束模板:对于函数模板,使用 C++20 的概念可以更清晰、更安全地约束模板参数,避免意外的模板匹配。
  • 极端情况:禁用重载:如果你真的不希望一个函数被重载,可以将其定义为 lambda 并赋值给一个变量。变量名在名称查找中只会找到这一个实体,不会形成重载集。(这是一个小众技巧,谨慎使用。)


本节课中我们一起学习了 C++ 重载集的方方面面。我们从基本概念出发,探讨了名称查找(包括 ADL)如何构建重载集,以及重载解析的详细规则如何选出最佳函数。我们还深入研究了模板、继承带来的复杂性,并分享了一些避免陷阱的实用技巧。

理解重载集是掌握 C++ 强大表达能力的关键。虽然规则有时显得复杂,但遵循良好的设计准则(如语义一致性、优先使用默认参数等)可以让你有效地利用这一特性,同时保持代码的清晰和可维护性。希望本教程能帮助你在 C++ 的海洋中,不被“重载集”淹没。

003:利用原子操作获得乐趣与收益

概述

在本教程中,我们将学习C++内存模型中的不同内存排序选项,并通过一个具体的无锁环形缓冲区数据结构的性能对比,来理解不同内存排序对程序性能的实质性影响。我们将从核心概念开始,逐步深入到实现细节和性能分析。

核心概念与术语定义

在深入探讨具体实现之前,我们需要明确一些核心概念。无锁编程通常指避免使用互斥锁和系统调用来进行同步。更正式地说,无锁算法保证至少有一个线程总能取得进展。

以下是一个简单的计数器示例,展示了有锁和无锁实现的区别:

// 有锁实现(可能阻塞)
std::mutex mtx;
int counter = 0;
void increment(int value) {
    std::lock_guard<std::mutex> lock(mtx);
    counter += value;
}

// 无锁实现(使用原子操作)
std::atomic<int> counter(0);
void increment(int value) {
    int expected = counter.load();
    while (!counter.compare_exchange_strong(expected, expected + value)) {
        // 如果交换失败,expected已被更新为当前值,循环继续尝试
    }
}
// 实际中更简单的写法
void increment_simple(int value) {
    counter += value; // 原子操作
}

无锁实现的优势在于,即使一个线程的compare_exchange_strong操作失败,也意味着另一个线程成功修改了计数器并取得了进展,从而提供了更强的进度保证。

C++内存模型简介

从C++11开始,标准正式定义了内存模型,提供了多种内存排序选项。这包括:

  • 顺序一致性 (memory_order_seq_cst)
  • 获取-释放 (memory_order_acquire, memory_order_release, memory_order_acq_rel)
  • 宽松排序 (memory_order_relaxed)

(注:memory_order_consume已被弃用,等同于memory_order_acquire)。

现代多核处理器中,每个核心都有自己的缓存,这带来了数据一致性和操作可见性的挑战。C++内存模型提供了细粒度的控制,允许程序员声明所需的内存排序约束,编译器和硬件则负责利用平台特定的原语来实现这些约束。

内存屏障(或内存栅栏)是实现这些约束的低级原语,用于限制编译器和CPU在编译时和运行时对指令的重排序。

深入理解内存排序

上一节我们介绍了内存模型的基本概念,本节中我们来看看三种主要的内存排序语义及其区别。

顺序一致性 (memory_order_seq_cst)

顺序一致性提供了最强的保证。在整个程序中,所有标记为顺序一致性的操作存在一个全局的总序,所有线程都同意这个顺序。这是原子操作的默认行为,也是最容易推理的,但同时也是开销最大的。

std::atomic<int> x{0}, y{0}, z{0};
// 多个线程并发执行 ++x, ++y, ++z
// 所有线程对 x, y, z 的自增顺序达成一致

获取-释放排序 (memory_order_acquire/memory_order_release)

获取-释放排序在特定的原子变量上建立线程间的同步点。如果线程A以release语义向变量X写入,线程B以acquire语义从X中读取到A写入的值,那么线程B保证能看到线程A在写入X之前所有(在A看来)已完成的写入操作。

std::atomic<bool> sync{false};
bool data_ready = false; // 非原子

// 线程A (生产者)
data_ready = true;              // 1. 准备数据
sync.store(true, std::memory_order_release); // 2. 发布信号

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/a0af202987865020f48185835fd1de15_3.png)

// 线程B (消费者)
if (sync.load(std::memory_order_acquire)) { // 3. 获取信号
    assert(data_ready == true); // 4. 断言成立:能看到第1步的写入
}

这种方式比顺序一致性更弱,但允许在未同步的线程间进行更多的优化。

宽松排序 (memory_order_relaxed)

宽松排序只提供最基本的原子性保证(读写不可分割)和单个变量的修改顺序一致性。它不建立任何线程间的同步关系,也不禁止任何重排序,因此是性能最高的模型,但也最难正确使用。

std::atomic<int> x{0}, y{0};
// 线程A
x.store(1, std::memory_order_relaxed); // A1
int r1 = y.load(std::memory_order_relaxed); // A2

// 线程B
y.store(1, std::memory_order_relaxed); // B1
int r2 = x.load(std::memory_order_relaxed); // B2
// 结果 r1 == r2 == 0 是可能的,因为A1/A2和B1/B2可能被重排序

案例研究:无锁环形缓冲区

了解了基本概念后,我们来看一个可以应用这些内存排序的具体数据结构:无锁环形缓冲区(队列)。这是一个经典的单生产者-单消费者(可扩展至多消费者)数据结构。

基本设计与朴素实现

其核心思想是使用两个原子计数器:生产者索引和消费者索引,它们逻辑上指向一个“无限数组”,通过取模运算映射到有限的循环缓冲区中。索引只增不减。

以下是使用默认顺序一致性的朴素实现核心:

class NaiveQueue {
    std::atomic<size_t> producer_idx{0};
    std::atomic<size_t> consumer_idx{0};
    std::array<int, CAPACITY> buffer;
public:
    bool push(int val) {
        while (size() == CAPACITY) backoff();
        buffer[map_idx(producer_idx)] = val;
        ++producer_idx; // 顺序一致性,隐含完整内存栅栏
        return true;
    }
    bool pop(int& val) {
        while (size() == 0) backoff();
        val = buffer[map_idx(consumer_idx)];
        ++consumer_idx; // 顺序一致性,隐含完整内存栅栏
        return true;
    }
private:
    size_t size() const { return producer_idx - consumer_idx; }
};

对这个队列进行基准测试(传递整数),性能大约为 1000万次操作/秒。分析汇编代码发现,瓶颈在于lock add指令(实现原子递增的读-修改-写操作),它发出了一个完整的内存栅栏。

优化一:使用获取-释放语义

由于生产者和消费者各自是单线程的,我们不需要全局的顺序一致性。我们只需要确保:1)数据存入缓冲区先于生产者索引更新(对消费者可见);2)消费者读取索引先于从缓冲区读取数据。这正好匹配获取-释放语义。

优化后的实现:

class RelaedQueue {
    std::atomic<size_t> producer_idx{0};
    std::atomic<size_t> consumer_idx{0};
    std::array<int, CAPACITY> buffer;
public:
    bool push(int val) {
        while (size() == CAPACITY) backoff();
        buffer[map_idx(producer_idx)] = val;
        // 使用 release 语义:之前的写入(存数据)对此后以 acquire 读取本索引的线程可见
        producer_idx.store(producer_idx.load(std::memory_order_relaxed) + 1,
                           std::memory_order_release);
        return true;
    }
    bool pop(int& val) {
        while (size() == 0) backoff();
        val = buffer[map_idx(consumer_idx)];
        // 使用 release 语义:更新消费位置
        consumer_idx.store(consumer_idx.load(std::memory_order_relaxed) + 1,
                           std::memory_order_release);
        return true;
    }
private:
    // 读取对方索引时使用 acquire 语义
    size_t size() const {
        return producer_idx.load(std::memory_order_acquire) -
               consumer_idx.load(std::memory_order_acquire);
    }
};

在x86-64上,此优化后的实现没有生成任何原子指令或显式内存栅栏,因为x86架构本身就提供了较强的内存顺序保证。性能跃升至约 1.7亿次操作/秒,提升了17倍

优化二:解决伪共享

上面的两个索引很可能位于同一个缓存行(通常64字节)。当生产者和消费者在不同核心上修改它们时,会导致缓存行在核心间频繁“乒乓”,即伪共享。

解决方案是让它们对齐到不同的缓存行:

alignas(64) std::atomic<size_t> producer_idx{0};
alignas(64) std::atomic<size_t> consumer_idx{0};

仅此一项改动,性能进一步提升了约20%,达到约 2.2亿次操作/秒

性能趋势与扩展性

我们测试了传递不同大小数据记录时的性能:

  • 64字节: 优化队列速度是朴素队列的 7倍
  • 128字节5倍
  • 256字节2倍
  • 1024字节: 优化队列仍快 25-30%,此时内存复制成为主要开销。

将队列扩展为单生产者-多消费者广播队列(每个消费者有自己的索引,一个“收集者”线程跟踪最慢的消费者索引供生产者参考)后,在32个线程的场景下,优化版本(获取-释放+缓存行对齐)相比朴素版本仍有5倍的性能提升。同时,伪共享在多线程环境下成为更主导的性能因素。

跨平台考量

一个有趣的现象是,在x86-64上,获取-释放语义的优化实现没有产生任何栅栏指令。如果我们激进地尝试完全不用std::atomic,而使用volatile加编译器屏障,在x86上也能工作且性能相同。

// 未定义行为!切勿在生产中使用!
volatile size_t producer_idx{0};
volatile size_t consumer_idx{0};
// ... 在 push/pop 关键位置使用 std::atomic_signal_fence 作为编译器屏障

然而,在ARM64架构上,这种“投机取巧”的代码会立即失败。因为ARM属于弱内存序架构,需要显式的LDAR(加载-获取)和STLR(存储-释放)指令来保证必要的顺序。这正是std::atomic配合memory_order_acquire/release所保证的——它在所有平台上都能生成正确的代码。

在ARM平台(Apple M3)上的测试表明,即使对于64字节的记录,从顺序一致性优化到获取-释放排序,仍能带来超过100% 的性能提升。

进阶优化:缓存索引

一个更极致的优化是为生产者和消费者引入对方索引的缓存副本。这些缓存副本仅在需要等待(队列空或满)时才从真正的原子索引更新。

class CacheOptimizedQueue {
    alignas(64) std::atomic<size_t> producer_idx{0};
    alignas(64) size_t producer_cached_consumer_idx{0}; // 消费者索引的本地缓存
    alignas(64) std::atomic<size_t> consumer_idx{0};
    alignas(64) size_t consumer_cached_producer_idx{0}; // 生产者索引的本地缓存
    // ... 其他成员
};

这样,在大部分情况下,生产者和消费者都只访问自己核心上的本地缓存变量,彻底消除了核心间缓存一致性的通信开销。在极端优化的微基准测试中(处理小数据),这带来了近3倍的额外性能提升,使每秒操作数达到惊人的6亿次。当然,在真实的、有其他工作负载的应用中,这种程度的提升可能难以完全复现。

总结与核心要点

本节课中我们一起学习了C++内存模型和原子操作的高级用法。我们来总结一下关键点:

  1. 正确选择内存排序至关重要: 使用最符合算法需求的、最弱但足够的内存排序,可以带来巨大的性能收益(在本例中高达17倍)。
  2. 警惕伪共享: 将频繁被不同线程修改的、无关的数据隔离到不同的缓存行中,是多线程性能优化的关键步骤。
  3. 平台差异显著: x86是强内存序架构,而ARM、PowerPC等是弱内存序架构。std::atomic和标准内存模型保证了代码在所有平台上的正确性,避免了自己尝试使用volatile等非标准手段带来的未定义行为和平台依赖。
  4. 基准测试是金科玉律: 性能特性常常出人意料,且高度依赖于具体的使用场景、数据大小和硬件。必须进行测量。
  5. 无锁编程复杂度高: 它提高了正确性证明、维护和测试的难度。应在确实需要其提供的进度保证或低延迟特性时才考虑使用。
  6. 标准库是你的朋友: C++标准内存模型设计精良,通常比自己尝试钻平台空子更安全、更可移植。

通过这个从理论到实践,从简单到复杂的案例,我们希望你能更深刻地理解如何利用C++的内存排序工具,在保证正确性的前提下,最大限度地挖掘硬件性能。

004:常见错误与最佳实践

在本节课中,我们将学习 C++ 中智能指针的核心概念、常见错误以及如何避免它们。我们将从原始指针的问题出发,逐步探讨 unique_ptrshared_ptrweak_ptr 的用法与陷阱,并介绍用于检测问题的工具和迁移遗留代码的策略。

概述:为什么需要智能指针?

内存管理是 C++ 编程中的核心挑战。原始指针要求开发者手动进行 newdelete 操作,这在复杂流程或异常发生时极易导致内存泄漏。智能指针基于 RAII(资源获取即初始化)原则,将资源生命周期与对象绑定,从而实现自动、安全的内存管理。

然而,“智能”并不意味着“万能”。错误地使用智能指针同样会引入难以调试的问题。本节课旨在揭示这些常见陷阱,并提供清晰的解决方案。

原始指针与 RAII 原则

在深入智能指针之前,理解其基础——原始指针和 RAII 原则——至关重要。

原始指针直接引用内存地址,但其手动管理方式在异常安全方面几乎不可能做到完美。请看以下代码片段:

void process() {
    int* ptr = new int(42); // 分配内存
    if (error_condition) {
        return; // 错误!此处未调用 delete,导致内存泄漏。
    }
    delete ptr; // 正常流程下释放内存
}

如上所示,若在 delete 之前因错误条件提前返回,分配的内存将无法释放,造成内存泄漏。在大型生产系统中,此类微小泄漏累积可导致严重问题。

RAII 原则通过将资源获取与对象初始化绑定来解决此问题。当对象离开作用域时,其析构函数会自动释放资源,确保了异常安全。C++ 标准库中的 vectorstring、文件流以及互斥锁都是 RAII 的典型应用。

智能指针正是自动化实现 RAII 以管理内存资源的工具。

独占所有权:std::unique_ptr

std::unique_ptr 实现了独占所有权语义。同一时间,一个 unique_ptr 唯一拥有其指向的对象。

基本用法与特性

unique_ptr 位于 <memory> 头文件中。推荐使用 std::make_unique 进行创建。

#include <memory>
// 创建独占指针
auto ptr1 = std::make_unique<int>(42);
// 移动语义:所有权转移
auto ptr2 = std::move(ptr1); // ptr1 现在为 nullptr
// 用于数组
auto arr_ptr = std::make_unique<int[]>(10);

其核心特性包括:

  • 独占所有权:不可复制,只可移动。
  • 零开销:在优化构建下,性能与原始指针相当。
  • 异常安全
  • 自动清理:离开作用域时自动调用 deletedelete[]

常见陷阱与规避方法

尽管 unique_ptr 设计精良,误用仍会引发问题。

陷阱一:双重删除
get() 方法返回底层原始指针,但不转移所有权。对此指针进行 delete 或将其用于构造另一个智能指针会导致双重删除。

auto ptr = std::make_unique<int>(42);
int* raw = ptr.get();
// 错误!ptr 离开作用域时会再次删除 raw。
delete raw;

// 同样错误
auto ptr2 = std::unique_ptr<int>(ptr.get());

规避方法:仅将 get() 用于向不取得所有权的 API(如某些 C 接口)传递指针。切勿对 get() 返回的指针进行 delete 操作。

陷阱二:混淆对象与数组
unique_ptr<T> 默认使用 delete,而 unique_ptr<T[]> 使用 delete[]。混用会导致未定义行为。

// 错误!使用 new[] 分配,但 unique_ptr<int> 期望 delete
std::unique_ptr<int> ptr(new int[10]);

// 正确:使用数组版本
std::unique_ptr<int[]> ptr(new int[10]);
// 或更佳:使用 make_unique
auto ptr = std::make_unique<int[]>(10);

规避方法:为数组显式使用 unique_ptr<T[]>make_unique<T[]>()

陷阱三:自定义删除器的错误捕获
自定义删除器功能强大但需谨慎。若通过引用捕获局部变量,而该变量先于删除器被销毁,将导致悬垂引用。

std::unique_ptr<int, void(*)(int*)> dangerous() {
    int cleanup_count = 0;
    // 错误!lambda 捕获了局部变量 cleanup_count 的引用
    auto deleter = [&](int* p) { delete p; ++cleanup_count; };
    std::unique_ptr<int, decltype(deleter)> ptr(new int(42), deleter);
    return ptr; // ptr 的删除器持有对已销毁 cleanup_count 的引用!
}

规避方法:确保自定义删除器捕获的所有变量在其被调用时依然有效。优先按值捕获或避免捕获局部变量。

上一节我们介绍了具有独占所有权的 unique_ptr,本节中我们来看看允许共享所有权的 shared_ptr

共享所有权:std::shared_ptr

std::shared_ptr 通过引用计数实现共享所有权。多个 shared_ptr 可以指向同一对象,当最后一个 shared_ptr 被销毁时,对象才会被释放。

基本用法与内部机制

使用 std::make_shared 是创建 shared_ptr 的推荐方式。

auto ptr1 = std::make_shared<int>(42); // 引用计数 = 1
{
    auto ptr2 = ptr1; // 引用计数 = 2
} // ptr2 析构,引用计数 = 1
// ptr1 析构,引用计数 = 0,对象被销毁

shared_ptr 的控制块存储引用计数、弱引用计数、自定义删除器等元数据。make_shared 通常能将对象和控制块分配在连续内存中,效率更高。

常见陷阱与解决方案

陷阱一:循环引用
这是 shared_ptr 最经典的问题。如果两个对象互相持有对方的 shared_ptr,引用计数永不为零,导致内存泄漏。

struct Parent {
    std::shared_ptr<Child> child;
};
struct Child {
    std::shared_ptr<Parent> parent; // 导致循环引用
};

解决方案:使用 std::weak_ptr 打破循环。weak_ptr 是一种不增加引用计数的“弱”引用。

struct Child {
    std::weak_ptr<Parent> parent; // 弱引用,不增加计数
};

陷阱二:别名构造函数陷阱
shared_ptr 的别名构造函数允许创建一个指向不同对象(通常是某大对象的成员)但共享原对象控制块的 shared_ptr。这可能导致无意识地将大对象的生命周期延长。

struct BigData { std::vector<int> hugeVec; int smallValue; };
auto big = std::make_shared<BigData>();
// aliasing constructor: sp 指向 big->smallValue,但共享 big 的控制块
std::shared_ptr<int> sp(big, &big->smallValue);
// 即使 big 的其他引用消失,只要 sp 存在,整个 BigData 对象都留在内存中。

规避方法:谨慎使用别名构造函数,明确其会延长整个原始对象的生命周期。

陷阱三:线程安全误解
shared_ptr 的引用计数操作是原子的、线程安全的。但 shared_ptr 实例本身(如拷贝赋值)并非线程安全,指向的对象数据也需要额外的同步机制。

std::shared_ptr<int> global_ptr;
// 线程1
global_ptr = std::make_shared<int>(1);
// 线程2
auto local = global_ptr; // 非原子操作,需要外部同步(如互斥锁)

规避方法:多线程环境下操作同一 shared_ptr 实例时,需使用互斥锁等同步原语。

陷阱四:异常安全
使用 new 直接构造 shared_ptr 可能存在异常安全问题。

// 不安全:如果 new B 抛出异常,new A 分配的内存可能泄漏
process(std::shared_ptr<A>(new A), std::shared_ptr<B>(new B));

// 安全:使用 make_shared
process(std::make_shared<A>(), std::make_shared<B>());

规避方法:始终优先使用 std::make_shared(和 std::make_unique)。

弱引用与循环破解者:std::weak_ptr

std::weak_ptrshared_ptr 的配套工具,它观察一个共享对象但不拥有其所有权,因此不会增加引用计数。

生命周期与用法

weak_ptr 需通过 lock() 方法尝试获取一个有效的 shared_ptr 来访问对象。

std::weak_ptr<int> wptr;
{
    auto sptr = std::make_shared<int>(42);
    wptr = sptr; // 弱引用观察
    if (auto locked = wptr.lock()) { // 成功提升为 shared_ptr
        std::cout << “Value: “ << *locked << std::endl;
    }
} // sptr 析构,对象销毁
// 此时 wptr.lock() 返回空的 shared_ptr
if (wptr.expired()) {
    std::cout << “Object has been destroyed.” << std::endl;
}

典型应用场景:观察者模式

在观察者模式中,主题(Subject)持有观察者(Observer)的 weak_ptr 列表,可以安全地清理已失效的观察者,避免循环引用。

class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
public:
    void notify() {
        auto it = observers.begin();
        while (it != observers.end()) {
            if (auto obs = it->lock()) {
                obs->update();
                ++it;
            } else {
                // 观察者已失效,从列表中移除
                it = observers.erase(it);
            }
        }
    }
};

性能考量与工具链

了解不同指针的开销有助于做出正确选择。

开销比较

指针类型 典型大小 (64位) 关键开销
原始指针 (T*) 8 字节
unique_ptr<T> 8 字节 几乎为零(优化后)
shared_ptr<T> 16 字节 控制块内存、原子引用计数操作

shared_ptr 的原子操作在高度争用的场景下可能成为性能瓶颈。因此,在不需要共享所有权的场景,应优先使用 unique_ptr 或原始指针。

检测与预防工具

借助工具可以在问题发生前将其捕获。

静态分析工具(编译时)

  • Clang Static Analyzer:内置于 Clang/LLVM,能捕获许多智能指针问题。
  • PVS-Studio:商业工具,提供专门的智能指针检查。
  • Cppcheck:免费开源,提供基础的智能指针验证。

动态分析工具(运行时)

  • AddressSanitizer (ASan):用于检测释放后使用、双重删除等错误。使用 -fsanitize=address 编译。
  • Valgrind (Memcheck):功能全面的内存错误检测器,速度较慢但深入。
  • ThreadSanitizer (TSan):用于检测数据竞争。使用 -fsanitize=thread 编译。

建议将静态分析集成到 CI/CD 管道中,并对测试套件运行动态分析工具。

最佳实践总结

综合以上讨论,以下是使用智能指针的核心最佳实践:

  1. 优先使用 make_uniquemake_shared:避免直接使用 new/delete,确保异常安全。
  2. 明确所有权语义:设计时清晰界定资源的所有者。单所有者用 unique_ptr,共享所有者用 shared_ptr
  3. 使用 weak_ptr 打破循环:在可能存在循环引用的父子关系或观察者模式中,使用 weak_ptr
  4. 慎用 get() 方法:仅将其用于与不取得所有权的旧式 API 交互。切勿对其结果进行 delete 或用于构造新智能指针。
  5. 注意自定义删除器:确保删除器捕获的变量在其被调用时有效。
  6. 理解线程安全shared_ptr 的引用计数是线程安全的,但实例本身和指向的数据需要额外保护。
  7. 善用分析工具:在开发流程中集成静态和动态分析工具,及早发现问题。
  8. 进行严格的代码审查:将智能指针的使用规范纳入团队代码审查清单。

遗留代码迁移策略

对于已有大量原始指针操作的遗留代码库,向智能指针迁移需要谨慎的策略:

  1. 从叶子函数开始:先修改那些不调用其他复杂函数的独立、简单的函数。
  2. 小步前进,频繁测试:每次做小的改动,并运行完整的测试套件(单元测试、集成测试)以及静态/动态分析工具,确保没有引入回归错误。
  3. 一个模块一个模块地迁移:集中精力完成一个相对独立模块的迁移,验证无误后再进行下一个。
  4. 利用工具验证:使用前述的分析工具确保迁移过程中没有引入新的内存错误。
  5. 模式替换示例:将接收原始指针的函数,逐步改为接收 shared_ptrunique_ptr,以明确所有权。例如:
    // 之前
    void processData(Data* data);
    // 之后
    void processData(std::shared_ptr<Data> data); // 或 unique_ptr,取决于所有权
    

总结

本节课中我们一起学习了 C++ 智能指针的强大功能与潜在陷阱。我们从原始指针的缺陷和 RAII 原则入手,深入探讨了 unique_ptrshared_ptrweak_ptr 的正确用法及常见错误,如双重删除、循环引用、线程安全误解等。我们还介绍了用于检测问题的工具链和一套实用的最佳实践。

智能指针是编写现代、安全、高效 C++ 代码的基石。通过理解其原理,遵守最佳实践,并利用好自动化工具,我们可以极大地减少内存管理相关的错误,构建出更健壮的系统。记住,智能指针的目标是让代码“默认安全”,而非在异常发生后艰难调试。

005:重新思考 C++ 中的多态性 🧬

在本节课中,我们将要学习 C++ 中多态性的不同实现方式。多态性是指一个接口可以有多种实现,在编程中,它允许我们在运行时或编译时决定调用哪个函数。我们将探讨传统的基于继承的运行时多态、使用智能指针的管理方式,以及使用 std::variant 的现代值语义多态,并分析它们各自的优缺点。

什么是多态性? 🤔

多态性意味着“一个事物可以具有多种形态”。例如,“关闭”这个词可以表示关门、关闭文件或结束会议,其具体含义取决于上下文。在编程中,多态性允许我们根据上下文调用不同的函数。

在 C++ 中,最经典的多态性实现是通过虚函数和继承。

传统的运行时多态:虚函数与继承 🏛️

上一节我们介绍了多态性的概念,本节中我们来看看 C++ 中最经典的多态实现方式。

我们通过一个抽象基类来定义接口,然后派生出具体的类来实现这些接口。

class GeometricObject {
public:
    virtual void draw() const = 0; // 纯虚函数,定义接口
    virtual ~GeometricObject() = default; // 虚析构函数
};

class Circle : public GeometricObject {
public:
    void draw() const override {
        // 绘制圆的实现
    }
};

class Line : public GeometricObject {
public:
    void draw() const override {
        // 绘制线的实现
    }
};

这种方法的优势在于,我们可以创建异构集合(即包含不同类型对象的容器),并通过基类指针或引用来统一操作它们。

std::vector<GeometricObject*> picture;
picture.push_back(new Circle());
picture.push_back(new Line());

for (auto obj : picture) {
    obj->draw(); // 运行时决定调用 Circle::draw 还是 Line::draw
}

然而,这种方法存在一个根本问题:对象生命周期管理。在上面的例子中,picture 存储的是原始指针,我们需要手动管理这些在堆上分配的对象的生命周期,这容易导致内存泄漏或悬垂指针。

管理对象生命周期:RAII 与智能指针 🛡️

上一节我们看到了使用原始指针管理多态对象带来的问题,本节中我们来看看如何利用 RAII(资源获取即初始化)原则和智能指针来安全地管理资源。

RAII 是 C++ 的核心思想之一,它利用对象的构造函数获取资源,并利用析构函数自动释放资源。智能指针(如 std::shared_ptrstd::unique_ptr)是 RAII 的典型应用。

以下是使用 std::shared_ptr 的示例:

using GeoPointer = std::shared_ptr<GeometricObject>;
std::vector<GeoPointer> picture;

picture.push_back(std::make_shared<Circle>());
picture.push_back(std::make_shared<Line>());

// 无需手动 delete,当 picture 离开作用域或 clear 时,对象会自动销毁

std::shared_ptr 通过引用计数自动管理内存,但它也带来了性能开销,特别是在多线程环境下复制 shared_ptr 时,需要对引用计数进行原子操作,这可能成为性能瓶颈。

std::unique_ptr 则提供了独占所有权的语义,它禁止拷贝,只能移动,因此没有引用计数的开销,但这也限制了它的使用场景(例如不能直接用于需要拷贝的容器初始化列表)。

另一种思路:编译时多态与值语义 🧩

上一节我们讨论了使用智能指针管理运行时多态对象,本节中我们来看看一种不同的范式:使用 std::variant 实现的、基于值语义的编译时多态。

std::variant 是一个类型安全的联合体,它可以持有其声明类型列表中的任何一种类型的值。这允许我们在不使用继承和虚函数的情况下,实现多态行为。

首先,我们定义具体的类,它们不需要继承自同一个基类:

class Circle {
public:
    void draw() const { /* ... */ }
};

class Line {
public:
    void draw() const { /* ... */ }
};

然后,我们使用 std::variant 来定义一个可以容纳这些类型的容器:

using GeoVariant = std::variant<Circle, Line>;
std::vector<GeoVariant> picture;

picture.push_back(Circle{});
picture.push_back(Line{});

要调用 draw 函数,我们需要使用 std::visit 和一个访问者(visitor)。访问者通常是一个泛型 lambda,它为 variant 可能持有的每一种类型提供处理逻辑:

// 使用泛型lambda作为访问者
auto drawVisitor = [](const auto& shape) {
    shape.draw(); // 编译器会为 Circle 和 Line 分别生成代码
};

for (const auto& geoObj : picture) {
    std::visit(drawVisitor, geoObj); // 根据实际类型调用对应的 draw
}

这种方法的好处包括:

  • 值语义:对象存储在容器内部,无需堆分配和指针,避免了内存泄漏和悬垂指针。
  • 内存局部性:对象在内存中连续存储,有利于缓存。
  • 无虚函数开销:函数调用在编译时确定,可能被内联优化。
  • 类型安全variant 明确知道其可能持有的类型集合。

其缺点包括:

  • 封闭的类型集合variant 的类型列表必须在编译时确定,无法像继承那样在运行时动态扩展新的派生类型。
  • 对象大小variant 的大小是其所能容纳的最大类型的大小,对于小型类型可能造成空间浪费。
  • 必须重新编译:添加新类型需要修改 variant 的类型列表并重新编译所有使用它的代码。

性能考量与教学思考 ⚖️

上一节我们介绍了使用 variant 的值语义多态,本节中我们来简单比较一下性能,并思考对于初学者而言的最佳教学路径。

简单的性能测试表明,对于创建、移动、向下转换和销毁等操作,使用 std::variant 的方案通常比基于继承和智能指针的方案更快,尤其是在析构和向下转换时,优势可能非常明显。这是因为 variant 避免了动态内存分配、虚函数表查找和引用计数的原子操作。

然而,性能并非唯一的考量因素。两种方案在 API 设计、扩展性和易错性上各有千秋。

那么,在教学中应该先教哪一种呢?

  • 先教继承和虚函数:这是经典的 OOP 范式,被众多语言广泛采用,有助于建立面向对象的基础概念。但其资源管理复杂,容易出错。
  • 先教 variant 和值语义:更符合现代 C++ 强调安全、简单和性能的趋势。它绕开了令初学者头疼的指针和内存管理问题,但概念上可能更新颖。

没有绝对的答案。一种可能的路径是:先介绍基于 variant 的简单、安全的多态,让初学者快速上手并建立信心;然后再深入讲解继承、虚函数和智能指针,解释其原理、适用场景以及需要警惕的陷阱。

总结 📚

本节课中我们一起学习了 C++ 中多态性的多种实现方式。

  1. 传统的运行时多态:通过虚函数和继承实现,灵活但需要手动管理对象生命周期,容易出错。
  2. 使用智能指针管理的多态:利用 std::shared_ptrstd::unique_ptr 自动管理内存,提高了安全性,但 shared_ptr 可能带来性能开销,unique_ptr 则限制了拷贝语义。
  3. 基于 std::variant 的值语义多态:一种现代替代方案,使用封闭的类型集合和 std::visit,提供值语义、内存安全性和潜在的性能优势,但类型集合必须在编译时确定。

每种方法都有其适用的场景。理解这些不同的工具及其权衡,有助于我们根据具体需求(如性能、安全性、扩展性)选择最合适的多态实现策略。对于现代 C++ 开发,将 std::variant 纳入你的工具箱,无疑是明智之举。

006:C++26 契约内部概述 🧩

在本节课中,我们将要学习 C++26 标准中引入的契约(Contracts)功能。契约是一种在代码中声明和检查前置条件、后置条件以及断言的方法,旨在提高程序的正确性和可维护性。我们将从基本概念出发,逐步深入到其语法、语义、评估机制以及设计背后的考量,力求让初学者也能理解这一强大工具的核心。

契约类型与术语 📝

首先,我们来了解 C++26 契约中的三种基本类型及其相关术语。

前置条件 在函数被调用时、进入函数体之前进行评估。在 C++ 规范中,它被称为 precondition specifier,由 pre 关键字、括号和一个布尔表达式(谓词)组成。如果该表达式求值为 false 或抛出异常,则发生契约违规。

int divide(int x, int y) pre(y != 0) { // 前置条件:y 不能为 0
    return x / y;
}

后置条件 在函数正常返回(而非抛出异常)时进行评估。它使用 post 关键字,并且可以访问函数的返回值(通过 -> 符号后的标识符)。

int increment(int x) post(r: r == x + 1) { // 后置条件:返回值 r 等于 x+1
    return x + 1;
}

断言语句 使用 contract_assert 关键字表示,可以出现在代码中任何允许语句出现的地方。它直接替换了传统的 assert 宏,但本身是一个语句而非表达式。

void process(int* ptr) {
    contract_assert(ptr != nullptr); // 断言:ptr 不为空
    // ... 处理逻辑
}

所有这三种类型(前置条件、后置条件、断言语句)统称为 契约断言。而每个契约断言内部的布尔表达式被称为 契约谓词

语法限制与重复声明 🔒

上一节我们介绍了契约的基本类型,本节中我们来看看对契约语法的一些限制,以及关于重复声明的规则。

一个函数的前置或后置条件,必须至少在其 首次声明 处指定。首次声明是指包含函数声明的第一个位置,它可能包含也可能不包含函数体。在此位置之后,该函数的其他声明都称为 重声明,其中包括函数体(实现)。

允许(但不是必须)在重声明中重复契约,这有助于提高代码可读性,并支持在多处头文件中声明的函数。如果重复契约,它们必须是 等价的。等价性主要通过标记相等性(即完全相同的字符串)来判断,但在函数参数和返回值的名称上允许有一定的灵活性。

以下是等价重声明的示例:

// 首次声明
void func(int x) pre(x > 0);

// 重声明 - 形参名不同,但指向同一实体,是等价的
void func(int y) pre(y > 0); // 良构

如果重声明中的契约引用的是不同的参数,则代码是病构的:

void func(int x) pre(x > 0);
void func(int x) pre(x < 0); // 病构:谓词不等价

此外,契约中不能包含 lambda 表达式,因为即使内容完全相同,每个 lambda 都会生成一个唯一的类型,从而无法满足等价性要求。

常量化(Constification)⚖️

契约语义中一个颇具争议的特性是 常量化,其目的是帮助捕获错误并避免契约的副作用。

常量化隐含地应用于契约断言外部的所有实体,包括外部对象(全局变量)和函数参数。其效果是将这些对象视为 const 的,但编译器会记住原始对象是否本来就是 const

需要注意的是,对于指针,常量化应用于指针本身,而非其所指的对象(浅常量化)。这与引用不同,引用的常量化始终作用于被引用的对象本身。

int global = 10;
int* ptr = &global;

void example(int& ref, int* p) pre(ref == 10 && *p == 10) {
    ref = 20; // 错误:ref 在谓词中被视为 const
    p = nullptr; // 正确:指针本身可以被修改(浅常量化)
    *p = 20; // 正确:修改指针所指对象(浅常量化不保护所指对象)
}

目前,退出常量化的唯一(且有争议的)方法是使用 const_cast 来去除 const。如果原始对象本来就是 const,这会导致未定义行为;否则,代码虽然不推荐,但语言上是良构且安全的。

常量化引发了一些反对意见,例如它会改变重载决议,可能导致契约检查的路径与实际代码路径不同。支持者则认为,常量化有助于发现隐藏的副作用,并鼓励正确的编程实践。

后置条件的特殊规则 🔄

后置条件具有一些独特的规则,特别是关于返回值命名和对值传递参数的常量性要求。

后置条件可以为返回值指定一个名称,该名称仅在谓词范围内有效。即使函数返回一个未命名的临时对象,后置条件也能为其提供一个可引用的名称。

std::string generate() post(r: !r.empty()) {
    return “Hello”;
}

在后置条件中,对返回值使用 const_cast 去除常量性是定义良好的行为,即使该返回值最终被赋值给一个 const 变量。这是因为在后置条件求值时,返回值对象尚未绑定到调用方的目标变量。

一个重要的新规则是:任何在函数中 按值传递 且在后置条件中被 ODR 使用 的参数,在函数声明中必须被声明为 const。这是语言中的新要求,因为之前 const 对于按值传递的参数并不影响类型系统。

// 参数 `val` 在后置条件中被 ODR 使用,因此必须为 const
int process(const int val) post(r: r > val) {
    int temp = val * 2;
    return temp;
}

这项规则的动机很直观:为了能够对后置条件进行推理并保证其正确性,如果函数参数可能在函数体执行期间发生改变,我们就无法做到这一点。因此,必须禁止对这些参数的修改,并且编译器需要在所有声明(包括函数体)中一致地强制执行此限制。

评估语义与违规处理 ⚡

契约的核心在于其评估语义以及检测到违规时的处理方式。C++26 定义了四种评估语义。

以下是函数内部存在契约时的事件序列粗略草图:

  1. 函数被调用,参数初始化。
  2. 评估前置条件。
  3. 执行函数体(可能包含断言语句)。
  4. 函数体结束,初始化返回值对象。
  5. 销毁局部变量。
  6. 评估后置条件。
  7. 销毁函数参数。
  8. 将结果对象绑定到目标变量。

这种设计确保了前置条件和后置条件都能在其作用域内访问函数参数,并且后置条件能在返回值初始化后、绑定到调用方之前对其进行检查。

四种评估语义如下:

  • 观察语义:检查契约,如果违规,则调用 违规处理程序,然后继续正常执行。
  • 强制语义:检查契约,如果违规,则调用违规处理程序,待其正常返回后,以实现定义的方式终止程序
  • 快速强制语义:检查契约,如果违规,则尽可能快速、小内存占用地终止程序,不调用违规处理程序。
  • 忽略语义:不检查契约,什么都不做。

实现被赋予了极大的自由来选择和应用这些语义。语义可以在构建时(如 Debug/Release 模式)、甚至运行时选择,并且同一翻译单元内不同的契约断言可以采用不同的语义。

重复评估与副作用 🚫

契约断言可能被评估零次、一次或多次。这主要是为了解耦调用方和被调用方,因为双方可能独立地选择不同的评估语义(例如都选择“观察”语义,则契约会被评估两次)。

因此,强烈反对在契约谓词中包含副作用。如果副作用影响了程序的正确性(即程序行为),则被称为“破坏性副作用”,这是 P2900 提案原则 6 所禁止的。你无法预知契约会被评估多少次,依赖副作用会导致不确定的行为。

int i = 0;
void bad_idea() pre(++i == 1) { // 糟糕的副作用!
    // ...
}

编译器在优化时,如果可以证明结果已预定且没有副作用(如抛出异常、长跳转或终止),则可以省略契约评估。它甚至可以用一个能产生相同结果的等效表达式替换原始谓词。

违规处理程序 🛡️

当契约违规发生且评估语义为“观察”或“强制”时,会调用一个全局命名空间中的函数 handle_contract_violation

该函数接受一个 contract_violation 类型的参数,返回 void,并且可以不是 noexcept。实现必须提供一个默认的处理程序(通常建议是记录违规信息且为 noexcept),但允许用户通过 可替换性 机制提供自定义版本,类似于全局 operator new/delete

自定义处理程序可以正常返回、抛出异常或执行长跳转。这为应用程序提供了决定如何处理违规的灵活性,而无需库作者关心此细节。安装自定义处理程序通常在链接时完成,无需重新编译所有代码。

contract_violation 对象包含了违规的详细信息(如源位置、谓词语句等)。注意,在契约谓词中抛出的异常也会被捕获,并作为另一种契约违规交给处理程序处理。

contract_assertnoexcept 的交互 🤔

一个复杂的问题是:包含 contract_assert 的表达式是否是 noexcept 的?

根据契约的“首要原则”,契约断言不应改变程序的正确行为。如果原本不抛出的表达式因为加入了可能抛出异常(如果违规处理程序抛出)的契约而改变了 noexcept 性质,这就违反了原则。

经过长期争论,最终的解决方案是:contract_assert 定义为语句,而非表达式。这样,你就不能对其直接应用 noexcept 运算符,从而绕开了这个问题。这确实使得 contract_assert 比传统的 assert 宏限制更多,但可以通过立即调用 lambda 表达式来轻松解决。

// 无法直接对语句使用 noexcept
contract_assert(x > 0);

// 使用立即调用 lambda 来获得一个表达式
auto&& result = []() -> decltype(auto) {
    contract_assert(x > 0);
    return some_expression;
}();
// 现在可以对 result 的求值过程考虑 noexcept

总结 🎯

本节课中我们一起学习了 C++26 契约编程的核心概念。我们了解了三种契约类型(前置条件、后置条件、断言语句)及其语法,探讨了常量化带来的影响和后置条件的特殊规则。我们深入研究了契约的四种评估语义(观察、强制、快速强制、忽略)以及它们如何被应用和优化。我们还明确了必须避免在契约中使用副作用,并介绍了自定义违规处理程序的机制。最后,我们理解了 contract_assert 作为语句的设计决策及其与 noexcept 的交互方式。

契约是一个强大而复杂的工具,虽然其内部机制繁多,但在日常使用中,你通常只需要关注如何声明你的契约。希望本教程能帮助你为使用 C++26 这一重要新特性做好准备。

007:隐藏符号的力量与痛苦

在本教程中,我们将探讨C++编程中一个常被忽视但至关重要的主题:符号的可见性管理。我们将学习如何通过隐藏符号来优化二进制文件大小、提升运行时性能,并构建更健壮的API接口。同时,我们也会了解这一实践可能带来的挑战。

概述:符号可见性的重要性

在构建共享库时,默认情况下,编译器会将所有函数和变量符号导出到最终的二进制文件中。这可能导致不必要的符号暴露,增加二进制文件大小,并阻碍编译器进行跨翻译单元的优化(如内联)。本节将介绍如何通过控制符号可见性来解决这些问题。

符号可见性基础

内联函数与静态函数

上一节我们介绍了符号可见性的基本概念。本节中,我们来看看内联(inline)和静态(static)关键字对符号处理的影响。

内联函数 意味着编译器可能会将函数体直接插入到每个调用点,而不是生成一个独立的函数调用。这可以减少函数调用的开销,并可能使二进制文件更小。

inline int add(int a, int b) {
    return a + b;
}

静态函数(或在匿名命名空间中的函数)意味着该符号的链接性是内部的。每个翻译单元(.cpp文件)都会获得该函数的一个私有副本,这些副本在链接时不会合并。

static void helper() { /* ... */ }
// 或
namespace {
    void helper() { /* ... */ }
}

以下是关键区别:

  • 内联定义:在多个翻译单元中定义时,链接器会合并为一个定义。
  • 静态/匿名命名空间定义:每个翻译单元保留自己的独立副本,可能导致代码膨胀。

控制符号导出(Windows示例)

在Windows平台上开发共享库(DLL)时,开发者通常使用 __declspec(dllexport/import) 来显式控制哪些符号是公开的API。

// 常见的跨平台宏示例
#ifdef _WIN32
    #ifdef MYLIB_BUILDING
        #define MYLIB_API __declspec(dllexport)
    #else
        #define MYLIB_API __declspec(dllimport)
    #endif
#else
    #define MYLIB_API // 对于GCC/Clang,通常先留空
#endif

class MYLIB_API MyClass {
    // ...
};

这种模式强制开发者思考接口边界,但传统上在Unix/Linux平台(使用GCC/Clang)中并未广泛应用。

隐藏符号的力量:最佳实践

默认隐藏,显式导出

上一节我们看到了Windows的实践。本节中我们来看看如何将其理念应用到GCC/Clang编译链中,以获得最佳效益。

核心策略是:编译共享库时,默认隐藏所有符号,只显式导出那些构成公共API的符号。

这可以通过编译器和源码属性实现:

  1. 编译器标志:使用 -fvisibility=hidden
  2. 源码属性:使用 __attribute__((visibility("default"))) 标记需要导出的符号。

更新之前的跨平台宏以支持此功能:

#ifdef _WIN32
    // ... Windows部分保持不变
#else
    #define MYLIB_API __attribute__((visibility("default")))
#endif

并在编译共享库时添加 -fvisibility=hidden 标志。

带来的优势

以下是采用“默认隐藏”策略的主要好处:

  • 更小的二进制文件:未导出的内部辅助函数和变量不会出现在动态符号表中,减少了文件大小。
  • 更多的内联优化:编译器知道隐藏符号不会被外部覆盖,因此更积极地进行内联优化。
  • 更清晰的API:公共接口被明确界定,降低了用户误用内部实现细节的风险。
  • 更快的加载时间:动态链接器需要处理的符号更少。

隐藏符号的痛苦:挑战与陷阱

未被导出的依赖项

隐藏符号的主要挑战在于确保所有必要的符号都被导出。这不仅仅是直接公开的函数,还包括其传递依赖

例如,如果公共API抛出或返回一个自定义类型,那么这个类型本身也必须被导出,否则在运行时链接时会发生错误。

// 自定义异常类型 - 必须导出!
class MYLIB_API MyException : public std::exception { /* ... */ };

MYLIB_API void publicFunc() {
    // ...
    throw MyException("error"); // 如果MyException未导出,链接/运行时会出错
}

与测试的交互

如果单元测试代码需要链接到内部函数(例如为了白盒测试),而这些函数已被隐藏,测试将无法链接。解决方案包括:

  • 将测试需要的内部函数也放入公共API(不理想)。
  • 为测试构建专门版本,放宽符号隐藏策略。
  • 将测试作为库本身的一部分进行编译(静态链接)。

与链接时优化(LTO)的协同

链接时优化(Link Time Optimization, LTO)允许编译器在链接阶段查看整个程序,进行跨翻译单元的优化,如内联和死代码消除。

当与隐藏符号结合使用时,效果更佳。编译器可以确信隐藏符号不会被外部修改,从而进行更激进的跨模块优化。

启用LTO可能暴露潜在的ODR(单一定义规则)违规,例如在不同翻译单元中同一符号有不同定义。这虽然是个“痛苦”,但有助于发现隐藏的bug。

实战案例与性能收益

在一个大型能源模拟项目(EnergyPlus)中应用此策略:

  1. 仅修改宏,在GCC/Clang端添加 __attribute__((visibility("default"))) 并启用 -fvisibility=hidden
  2. 结果:二进制文件大小减少6.2%,运行时性能提升3%。
  3. 结合LTO后,性能提升达到10%。

这个案例表明,对于已经做好跨平台接口管理的项目,启用符号隐藏可以带来显著的免费性能提升。

总结与核心要点

本节课中我们一起学习了C++中管理符号可见性的强大技术和相关注意事项。

以下是关键行动要点:

  • 主要应用于共享库:此技术对动态库收益最大。
  • -fvisibility=hidden 开始:使所有符号默认隐藏。
  • 显式导出公共API:使用 __attribute__((visibility("default"))) 或类似机制。
  • 启用链接时优化(LTO):与隐藏符号结合,获得最大性能收益。
  • 小心传递依赖:确保异常、返回值类型等依赖项也被正确导出。
  • 利用工具:使用CMake等构建工具可以简化跨平台符号导出管理。

通过有意识地管理符号可见性,开发者可以构建出更高效、更健壮且更易于维护的C++库。

008:核心概念与应用

在本节课中,我们将要学习 C++ 中一个非常强大且现代的特性:可变参数模板和参数包。它们是现代 C++ 设计的基石,不仅支撑了许多标准库和第三方库的实现,也能帮助我们创建灵活且高效的自定义工具。

模板基础回顾

在深入可变参数模板之前,我们先简要回顾一下模板的基础知识。相信大家对以下语法都很熟悉:类模板和函数模板。

template <typename T, size_t Capacity>
class StaticVector {
    // ... 实现细节
};

这里我们有一个 StaticVector 类模板。它有两个模板参数:一个是类型参数 T,另一个是非类型模板参数 Capacity,用于指定容器预分配存储的最大元素数量。

当我们实例化模板并提供参数时,编译器会用这些参数替换模板体中的参数名,从而生成一个具体的类。例如,StaticVector<int, 10> 会生成一个能存储最多10个整数的具体类。

在 C++11 之前,模板可以拥有多个参数,但无法拥有任意数量的参数。虽然有一些技术可以绕过这个限制,但在语言层面并不支持。C++11 引入了可变参数模板和参数包,解决了这个问题。

可变参数模板与参数包

可变参数模板允许模板接受任意数量的参数。其语法如下:

template <typename... Ts>
void func(Ts... args) {
    // ... 处理参数包
}

这里,typename... Ts 是一个模板参数包,它代表一组类型。Ts... args 是一个函数参数包,它代表一组值,这些值对应着实例化函数时提供的参数。

关于参数包,有几个关键点:

  • 参数包是一个命名实体,代表一组参数(类型或值)。
  • 参数包可以为空(包含零个或多个元素)。
  • 在模板体内,参数包必须被显式展开才能使用。编译器不会像处理普通模板参数那样自动替换它们。

接下来,我们将通过示例探索参数包的各种展开方式和使用场景。

参数包的展开上下文

参数包可以在多种语言指定的上下文中展开。以下是几个最常用的例子。

示例:包装 std::tuple

假设我们要实现一个包装 std::tuple 的容器。

template <typename... Ts>
class TupleWrapper {
    std::tuple<Ts...> storage; // 上下文1:展开到模板实参列表
public:
    TupleWrapper(Ts... args)   // 上下文2:展开到函数形参列表
        : storage(args...) {   // 上下文3:展开到初始化器列表
    }
};

在这个例子中,我们展示了三种展开上下文:

  1. 模板实参列表std::tuple<Ts...> 将类型包 Ts 展开,作为 tuple 的模板参数。
  2. 函数形参列表:构造函数 TupleWrapper(Ts... args) 将类型包展开为函数的形参类型。
  3. 成员初始化器列表storage(args...) 将函数参数包 args 展开,用于初始化 storage 成员。

当我们实例化 TupleWrapper<int, float>(1, 2.0f) 时,编译器会进行如下替换:

  • Ts... 被替换为 int, float
  • 构造函数签名变为 TupleWrapper(int, float)
  • 初始化变为 storage(1, 2.0f)

使用操作进行展开

展开参数包时,我们可以对每个元素应用操作,而不仅仅是简单列出。

template <typename... Ts>
auto make_tuple(Ts&&... args) {
    return std::tuple<Ts...>(std::forward<Ts>(args)...);
}

这里,std::forward<Ts>(args)... 是一个展开模式。它同时展开了类型包 Ts 和函数参数包 args,对每个参数进行完美转发。

使用 sizeof... 操作符

sizeof... 是一个特殊的操作符,用于获取参数包中元素的数量。

template <typename T, typename... Args>
auto make_array(Args&&... args) -> std::array<T, sizeof...(Args)> {
    return { T(std::forward<Args>(args))... };
}

在这个 make_array 函数中,sizeof...(Args) 在编译时计算出参数包 Args 的大小,并将其用作返回的 std::array 的大小。同时,T(std::forward<Args>(args))... 将每个参数完美转发并构造为类型 T 的对象。

展开到初始化列表

将参数包展开到初始化列表是一种非常实用的技术,它允许我们在函数体内以统一的方式处理参数,例如使用循环。

示例:求和函数

template <typename T, typename... Args>
constexpr auto sum(T first, Args... rest) {
    // C++14 起,初始化列表可用于常量表达式
    std::common_type_t<T, Args...> result = first;
    for (const auto& val : {rest...}) { // 展开到初始化列表
        result += val;
    }
    return result;
}

这里,{rest...} 将参数包展开为一个初始化列表。由于初始化列表要求所有元素类型相同,我们使用 std::common_type_t 来获取一个公共类型。从 C++14 开始,这种写法可以在常量表达式中使用。

对每个参数执行操作

有时我们需要对参数包中的每个参数执行某个操作(例如打印),而这些参数的类型可能各不相同。

template <typename... Args>
void print_all(Args&&... args) {
    (void)std::initializer_list<int>{ (std::cout << std::forward<Args>(args) << ‘ ‘, 0)... };
}

这个技巧利用了逗号运算符:(std::cout << arg << ‘ ‘, 0)。逗号运算符会执行左边的表达式(输出到标准输出),然后丢弃其结果,最终整个表达式的结果是右边的 0。这些 0 被收集到初始化列表 std::initializer_list<int> 中(其值被忽略)。这样,我们就对每个参数执行了输出操作,而无需关心它们的具体类型。

C++17 折叠表达式

上一节我们介绍了使用初始化列表处理参数包的方法。在 C++17 中,引入了一种更优雅、更强大的工具来处理参数包:折叠表达式。它可以直接对参数包应用二元运算符。

使用折叠表达式,之前的求和函数可以简化为:

template <typename... Args>
auto sum(Args... args) {
    return (args + ...); // 一元右折叠
}
// 或者带有初始值
template <typename T, typename... Args>
auto sum(T first, Args... args) {
    return (first + ... + args); // 二元左折叠
}

折叠表达式 (args + ...) 是一个一元右折叠。对于调用 sum(1, 2, 3),它会被展开为 1 + (2 + 3)
(first + ... + args) 是一个二元左折叠,对于同样的调用,它会被展开为 ((1 + 2) + 3)

对于空参数包,只有三个运算符在折叠表达式中是有效的,它们有默认值:

  • 逻辑与 (&& ...) 求值为 true
  • 逻辑或 (|| ...) 求值为 false
  • 逗号运算符 (, ...) 求值为 void()

折叠表达式的复杂应用

折叠表达式可以用于实现更复杂的逻辑。例如,查找第一个满足条件的参数:

template <typename Pred, typename... Args>
constexpr auto find_first_if(Pred pred, Args&&... args) -> std::common_type_t<Args...> {
    std::common_type_t<Args...> result{};
    ( (pred(args) ? (result = args, true) : false) || ... );
    return result;
}
// C++20 可选版本
template <typename Pred, typename... Args>
constexpr auto find_first_if(Pred pred, Args&&... args) -> std::optional<std::common_type_t<Args...>> {
    std::optional<std::common_type_t<Args...>> result;
    ( (pred(args) ? (result = args, true) : false) || ... );
    return result;
}

这个函数使用折叠表达式配合逻辑或 || 和三元运算符进行短路求值。一旦谓词 pred 对某个参数返回 true,就会将该参数赋值给 result,并且由于逻辑或的短路特性,后续参数不会再被求值。C++20 的 std::optional 让我们可以返回一个可能为空的值,避免了类型必须可默认构造的限制。

类型列表与参数包存储

有时,我们需要将参数包(特别是类型包)存储起来以备后用。一个简单的方法是使用一个空的可变参数模板类,它被称为类型列表

template <typename...>
struct TypeList {};

TypeList<int, float, char> 就是一个包含了 int, float, char 三种类型的类型列表。我们可以用它来存储一组类型。

示例:事件调度器

假设我们有一个调度器,它支持调度特定类型的事件。

template <typename... Events>
class Scheduler {
    using SupportedEvents = TypeList<Events...>; // 存储支持的事件类型
    // ... 其他实现
};

template <typename SchedulerT, typename Event>
void schedule_event(const SchedulerT& sched, const Event& ev) {
    static_assert(is_in_typelist_v<Event, typename SchedulerT::SupportedEvents>,
                  “Event type not supported by this scheduler”);
    // ... 调度事件
}

这里,调度器内部使用 TypeList 存储了它支持的事件类型。schedule_event 函数在编译时检查要调度的事件类型是否在支持的类型列表中。

实现 is_in_typelist

is_in_typelist 的功能是检查一个类型是否在类型列表中。随着 C++ 标准的演进,它的实现方式也在不断简化。

C++11 递归版本:受限于常量表达式的功能,需要使用递归模板元编程。
C++14 版本:可以利用 constexpr 函数和普通循环在编译期计算。
C++17 版本:使用折叠表达式,代码变得非常简洁。

// C++17 使用折叠表达式
template <typename T, typename... Ts>
struct IsInTypeList {
    static constexpr bool value = (std::is_same_v<T, Ts> || ...);
};
template <typename T, typename... Ts>
inline constexpr bool is_in_typelist_v = IsInTypeList<T, Ts...>::value;

现代 C++ 中的增强

C++ 标准的发展为可变参数模板带来了更多便利。

std::apply 与元组

std::tuple 本身就是一个可变参数模板类。std::apply 函数可以将一个元组的元素展开,作为参数调用一个可调用对象。

auto tup = std::make_tuple(1, 2.0, “hello”);
std::apply([](auto&&... args) {
    // args... 就是元组中的每个元素
    ( (std::cout << args << ‘ ‘), ... );
}, tup);

这让我们可以方便地将存储在元组中的值转换回参数包进行处理。

C++20 简写模板语法与概念

C++20 引入了简写函数模板语法和概念,它们同样适用于可变参数模板。

// 简写语法
void print(auto&&... args) {
    ( (std::cout << args << ‘ ‘), ... );
}

// 使用概念约束参数包中的所有类型必须相同
void print_same(std::same_as<int> auto... args) {
    // 所有 args 都是 int 类型
    ( (std::cout << args << ‘ ‘), ... );
}

在 C++20 之前,要强制函数的所有参数类型相同,需要复杂的 SFINAE 或静态断言技巧。现在,使用 std::same_as 概念可以轻松实现这一约束。

C++26 展望:包索引与可变结构化绑定

未来的 C++26 标准计划引入更多操作参数包的能力:

  1. 包索引:允许直接通过索引访问参数包中的特定元素。
    template <typename... Ts>
    using SecondType = __type_pack_element<1, Ts...>; // 获取类型包中第二个类型
    
  2. 可变结构化绑定:可以将一个结构化绑定的所有元素捕获到一个参数包中。
    template <typename T>
    void print_fields(T&& obj) {
        auto&& [...elems] = std::forward<T>(obj); // 可变结构化绑定
        ( (std::cout << elems << ‘ ‘), ... ); // 打印所有字段
    }
    
    这使得编写通用的、支持自省功能的代码变得更加容易。

总结

本节课我们一起深入探讨了 C++ 可变参数模板和参数包的核心概念与应用。我们从模板基础回顾开始,学习了参数包的定义与基本特性。接着,我们探索了多种参数包的展开上下文,包括模板实参列表、函数形参列表、初始化列表以及 C++17 引入的强大工具——折叠表达式。我们还了解了如何使用类型列表来存储和操作类型包,并看到了现代 C++(C++20/26)如何通过简写语法、概念和新的语言特性来简化可变参数模板的使用。

可变参数模板是编写灵活、通用和高效 C++ 代码的关键工具。掌握它们,将极大地提升你进行现代 C++ 库设计和元编程的能力。

009:嵌入式系统中抽象的成本

在本课程中,我们将探讨在资源受限的嵌入式系统中使用C++高级特性(如封装、继承和多态)所带来的成本。我们将从最底层的硬件抽象层(HAL)入手,分析这些抽象对二进制大小和运行时性能的实际影响,并学习如何利用C++的现代特性(如模板、概念和编译时计算)来编写高效且可维护的嵌入式代码。

自我介绍

我的名字是Marcel Juhasz,拥有维也纳技术大学嵌入式系统硕士学位,使用C++超过七年,近年来主要专注于嵌入式系统领域。我的主要兴趣在于将C++应用于资源受限的环境。

我的联系方式如上图所示。如果您对这个演讲感兴趣,相关的完整项目已发布在我的GitHub上,欢迎查看。

我在T工程公司担任嵌入式软件开发工程师。我们是一家全球性的创新服务提供商,致力于将客户的想法转化为产品与商业模式。如果您有兴趣合作或需要项目帮助,欢迎通过上图信息联系我们。

研究动机

既然这是一个C++会议,我们都喜欢C++,那么您会同意我的观点:C++提供了许多强大且多功能的特性。有些特性是我们嵌入式开发者不可或缺的,有些特性是我们不被允许使用的,还有一大批特性在社区中经常引发争论,每个人都有自己的看法。

有些人认为C语言已经达到了效率的极限,而C++因其抽象性带来了固有的运行时开销。另一些人则持完全相反的观点。因此,有时很难决定哪些特性可以使用,哪些应该避免。

然而,有一点是明确的:当谈论嵌入式系统时,最关键的因素之一就是开销——内存使用和运行时性能方面的开销。因为对于某些嵌入式系统应用而言,可用内存和执行时间都是有限的资源。

为了澄清这些误解,我着手研究这个问题。通过这项研究和准备本次演讲,我获得了一些有价值的见解。我希望本次演讲也能为您提供一些有价值的知识。

嵌入式系统视角

首先,必须指出,本主题将从嵌入式系统的角度进行讨论。我希望尽可能接近底层地使用C++,并尽可能靠近硬件地观察抽象的效果。

因此,我将主要关注硬件抽象层(HAL),因为这是直接位于微控制器之上的层。我们将主要关注封装、继承和多态。同时,我们也将识别当前硬件抽象层实现中的低效之处,并学习如何利用C++的一些更高级特性来处理这些低效问题。

研究方法论

接下来,我想谈谈研究方法,包括我在研究过程中使用的设置、工作流程和测量指标。

我首先从源代码开始,这大概不会让您感到意外。我使用提供的硬件抽象层,用C语言实现了一些功能,然后开始向代码库中引入C++,逐步添加抽象。

在分析抽象效果时,我认为能够孤立地分析它们非常重要。因此,最佳做法是尽可能保持原始代码库不变,只更改绝对必要的代码部分。

我获取源代码,为STM32微控制器进行交叉编译,并针对大小进行优化(因为大小通常是受限资源)。在此步骤中,我还可以测量编译时间。

编译完成后,评估二进制文件大小就相当直接了。一方面,我查看了反汇编的二进制文件,以了解微控制器上实际执行了什么。通过这种方式,我可以从分析角度获得关于运行时性能的宝贵见解。

为了获得实证结果,我将固件烧录到目标设备上,在目标上执行固件,并测量执行时间。我将主要关注二进制文件大小和运行时性能,因为这两者通常是某些嵌入式系统应用中受限的资源。有些微控制器的闪存容量低至16KB甚至更低。至于运行时性能,某些项目可能存在实时性要求,但即使没有这些要求,通常也需要尽可能短的反应时间。

基准固件

一个非常重要的概念是“基准固件”,因为它将作为二进制文件大小和反汇编二进制文件的比较基准。我简单地将基准固件定义为一个绝对最小的嵌入式项目,仅包含一个带有空无限循环的main函数。

然而,嵌入式工程师可能最清楚,main函数并不是应用程序的真正入口点。首先执行的是启动脚本,这通常用汇编语言编写,负责定义向量表、设置堆栈指针、在闪存和RAM之间复制数据、初始化全局和静态变量、调用静态构造函数,最终调用main函数。

对于任何嵌入式系统项目,第三个非常重要的部分是链接脚本,它基本上指定了应用程序的内存布局。

正如您所见,即使在调用main函数之前,也发生了很多事情。对于一个main函数基本什么都不做的空项目,C和C++的二进制文件大小都已经在2KB左右。

硬件抽象层示例

在开始构建抽象层之前,我们需要确定可以有用且有意义地集成这些抽象的合适位置。

因此,我想快速浏览一个硬件抽象层的示例实现,向您展示它们今天是如何实现的。

硬件抽象层提供了数据结构。我们作为嵌入式开发者,可以根据必要的配置实例化并填充这些数据结构。然后,我们可以使用这些数据结构,调用硬件抽象层函数,并将这些数据结构作为参数传递给这些函数。

这些函数接收我们填充的数据结构,进行一些初始输入验证,然后分支并执行必要的计算。硬件抽象层为我们做的最重要的事情之一,就是隐藏了实际寄存器操作的复杂性。正如您所见,硬件抽象层充满了此类寄存器操作。对于这些操作,硬件抽象层会计算必要的位掩码,这些位掩码的计算也基于我们刚刚传递的已填充数据结构。

构建抽象层

现在我们对处理的对象有了概念,可以跳入本次演讲的主题,开始构建抽象层。

问题是微控制器是否包含源代码或作为二进制可执行文件的程序?答案是它位于内存中。它不像FPGA,它是一个微控制器。当我们编译项目时,我将固件加载到闪存中,微控制器就开始从内存中读取并执行指令。

正如您所见,硬件抽象层充满了此类寄存器操作。如果我们观察这样的寄存器操作,至少可以识别出两个可以有意义地集成抽象的地方。寄存器操作有通用部分,也有寄存器特定的部分。

寄存器操作的通用部分处理寄存器中每一位的操纵。它独立于寄存器的类型。因此,无论这个寄存器是处理某些GPIO的电压,还是处理模数转换器的配置,从寄存器操作通用部分的角度来看,它读取值、修改它,然后更新寄存器的值。

这就是寄存器特定部分发挥作用的地方。它负责计算位掩码。此时,重要的是该寄存器的第5位是将GPIO的电压从低设置为高,还是启动另一个数字转换周期。此时,这个寄存器特定的实现必须知道寄存器中每一位的含义,并且必须提供有意义的成员函数来与寄存器交互。

封装

让我们看看我想介绍的第一个抽象:封装,即类。我们已经看到,寄存器操作的通用部分和特定部分可以有效地封装到类中。用UML图表示,它看起来像这样(这只是一个示例)。有一个CRegister类,它负责通用寄存器操作。然后还有这些寄存器特定的类,它们引入了独特的寄存器特定行为。

我想浏览一个简化的实现,以便我们都明白我的意思。CRegister类,如前所述,负责通用寄存器操作。微控制器中映射到内存的每个寄存器都有一个内存地址,以便可以通过代码中的简单内存访问操作进行访问。这也意味着每个寄存器在内存中必须有一个地址,现在它被存储为一个成员变量。

如前所述,这个类负责以通用方式处理寄存器,这意味着设置整个寄存器、根据位掩码设置寄存器的部分(我们稍后会讲到)、清除寄存器以及其他类似操作。

至于其他寄存器类,这些是负责独特寄存器特定行为的类。它们仍然是寄存器。归根结底,它们必须设置实际寄存器中的实际值。您可能已经想到,这将是继承的教科书案例。但我想提醒您,我希望孤立地分析抽象的效果,我们从封装开始,所以稍后会讨论继承。

这些类负责计算用于寄存器操作的位掩码,并且它们还为与底层寄存器交互提供了一些有用且清晰的接口。例如,在这里调用带有相应值的set_mode函数可能比仅仅移动一些位更清晰、更易读。

将这些更改应用到代码库,我们已经得到了更可读、更清晰的实现。代码中不再到处都是位掩码计算,也不再暴露寄存器操作。

在回答这个抽象的成本之前,我只想提一下以这种方式封装寄存器操作的一个很好的副作用。您已经看到硬件抽象层充满了这些寄存器操作,这些操作访问固定的内存位置,这些固定位置就是寄存器在内存中的地址。您可能都知道,桌面应用程序不太喜欢您访问不属于您的内存。因此,一般来说,这些固件应用程序不能在主机机器上执行。

但是,当寄存器操作以这种方式封装时,这也意味着所有的内存访问操作都集中在这个单一的类中。这允许我们简单地替换,或者换句话说,模拟这个通用CRegister类的实现。我在准备本次演讲或相关源代码时也使用了这个功能。因为有一次模数转换器产生了一些可疑的值,我想找出问题所在。您可能知道,在目标设备上进行测试可能相当复杂和耗时。通过这种方式,我可以简单地用不再访问内存的实现替换CRegister类的实现。它只是记录我访问了哪些地址、向哪些地址写入了哪些值。我可以在我的主机机器上为我的主机机器编译这段代码,直接执行它,查看输出,并且可以很快发现我遗漏了哪些配置步骤。这只是一个有用的副作用。

回到最初的问题:这个抽象的成本是什么?答案是零。它编译出的二进制文件与原始C代码相同。我认为,为了我们刚刚获得的所有好处(如可读性、可维护性甚至可测试性),这是一个相当不错的代价。

静态成员函数

这一切都很好,但对我个人而言,让寄存器类拥有静态成员函数更有意义,这样我就可以在不首先实例化对象的情况下调用这些函数。从技术角度来看,这是可能的,因为寄存器无论如何都没有内部状态。这是个人偏好,但对我来说,这段代码更易读。

从实现来看,这相当直接。我只是将CReg对象作为特定寄存器类的静态成员,并且也将成员函数设为静态。

当我编译这段代码时,我注意到二进制文件大小略有增加。这种增加乍一看可能不多,但如果您回想演讲开头,我们讨论过基准固件的二进制大小已经在2KB左右,所以二进制大小增加的差异是相当显著的。

当然,我想知道为什么会发生这种增加,所以我查看了反汇编的二进制文件。我发现二进制文件中出现了一个带有这些前缀的例程。如果您仔细想想,这很有道理,因为我们刚刚所做的,是将CReg类作为特定寄存器类的静态成员。这也意味着这些对象现在具有静态存储期。静态存储期意味着它们的生命周期必须在main函数之前开始,在main函数之后结束。因此,这也意味着必须存在一段负责初始化这些对象的代码。

如果我们查看二进制文件,查看应用程序,我们可以看到在调用main函数之前执行的部分。这就是启动脚本或启动代码执行的地方,也是C运行时初始化发生的地方,同样是作为C++运行时初始化的一部分,全局和静态对象被构造的地方。这正是这个函数负责的。

我还必须补充,这不会影响应用程序的运行时性能,因为这段代码只在main函数被调用之前执行一次。它只增加了启动时间,并略微增加了二进制文件大小。

问题是:我们能否在保持静态函数调用的同时摆脱这种二进制大小的增加?答案是肯定的。我们拥有的一个选择是将CRegister类设为模板类,并将寄存器的地址作为模板参数传递。

从技术角度来看,这有意义吗?如果您想一想,因为地址不是运行时变量,它们在编译时已知,并且在运行时不会改变。所以这绝对是一个值得考虑的选择。

现在,当我再次编译代码时,二进制大小恢复到原始大小,并且二进制文件包含与原始C代码相同的内容。

现在,您可能想问的问题(至少我问了这个问题)是:为什么这实际上有帮助?因为CRegister对象仍然是静态对象,为什么它们现在不必像以前那样在main函数之前初始化?

答案在于模板。如果您还记得,每个寄存器在内存中都有一个地址。在原始实现中,我添加了一个const成员变量来存储地址。但我们必须记住,在C++中,const并不意味着编译时常量。即使会导致未定义行为,您仍然可以在运行时更改const变量的值。

但是将其作为模板参数传递,现在意味着这是一个编译时参数。现在这些类,或者说CRegister类,在编译时完全定义,编译器可以为我们做更多的优化。

继承

我们已经看了封装。现在,让我们看看继承带来了什么。如前所述,这是继承的教科书案例。有CRegister类,它是基类,处理通用寄存器操作。所有其他寄存器特定的类都是CRegister,它们需要访问通用寄存器操作,但也通过其独特的寄存器特定实现扩展了这些寄存器的行为。

这里的实现同样直接,是简单的继承。我只是让CRegister类成为每个特定寄存器类的基类。除此之外,其他保持不变。

现在我们为这个抽象付出的代价是什么?再次,是零。代码仍然编译出与原始C代码相同的二进制文件。我实际上很高兴看到这些结果,因为当我们在大学或学校学习C++时,也许作为初学者,我们经常被警告与动态多态相关的运行时开销。由于动态多态需要继承,相关的开销指南可能会与纯继承混淆。但正如这里所演示的,当您单独使用继承时,它不会引入任何运行时惩罚。

多态

多态是一个更大、更复杂的主题,当从运行时开销的角度讨论时尤其如此。封装和继承可以在尽可能低的级别(即寄存器操作级别)成功分析,但为了有意义地将多态集成到代码库中,我们需要稍微提高一点抽象级别。

让我们想象一个非常常见的场景:我们有一个以印刷电路板(PCB)形式存在的嵌入式系统应用程序。在这个PCB的某个地方有一个微控制器,并且有多个外设连接到这个微控制器。现在,在这个场景中,我们作为嵌入式开发者的工作是实现微控制器和外设之间的接口,形式是一些硬件模块,如GPIO、模数转换器或某种通信总线。

我们在这里的一个选择是在我们的实现中直接使用硬件抽象层。这当然可行。如果您想在相同制造商的不同设备之间移植此代码,这可能很容易,因为硬件抽象层的接口在不同设备之间保持一致正是出于这个原因。但是,当您想在不同制造商的不同设备之间移植此代码时,问题仍然会出现,因为那时您必须为新的硬件调整此前端外设接口的实现。如果外设很复杂,这可能相当耗时。

我们可以实现相同目标的另一种方式是使用某种间接层。正如您在这里看到的,这个基本图中显示的外部外设接口不再与硬件抽象层交互。在某种意义上,我们使其独立于硬件。我们在代码库中引入了间接层。这也意味着将该项目移植到新硬件更容易,并且可能也更容易在GitHub上找到现有的实现。当您看到这样的图表时,可能首先想到的是动态多态。

在动态多态中,我们有虚拟接口,外部外设接口通过这些虚拟接口与底层硬件抽象层交互。因此,这些接口是诸如GPIO和ADC之类的硬件模块。如果您想将该项目移植到新硬件,您所需要做的就是为这些接口实现具体类以支持这些硬件模块。

我认为这是多态开始有意义的地方,也是我分析其效果的地方。让我们看一个简化的示例实现。如前所述,我们有诸如IPin接口。这是一个硬件模块的接口,这是一个抽象类,它提供了必要的虚函数,以便您可以与底层硬件交互。

CLed类,在这种情况下,是外部外设接口。LED可能是您可以连接到微控制器的最简单的设备,但对于演示目的来说足够了。正如您所见,这个CLed类在实现中没有直接使用硬件抽象层,它所做的就是通过IPin接口与底层硬件交互。

CPin类。这是在其实现中直接使用硬件抽象层的类,所以这段代码直接与硬件交互,这也是如果您想移植到另一个设备时必须重新实现的代码段。当然,移植这段代码的工作量可能比移植所有其他实现要少得多。

在这一点上,了解您的语言、编译器标志和编译器也变得很重要,因为正如您在此图中所见,运行时类型信息是否包含在二进制中,对二进制大小的影响是相当大的。阅读编译器的文档可能总是最好的,但对于GCC来说,它指出如果未使用typeiddynamic_cast,则可以排除运行时类型信息。

如果我们查看没有运行时类型信息的实现,从二进制大小可以看出,它编译出的二进制文件几乎与原始C代码相同。二进制大小略有增加,这意味着二进制文件中存在一些差异。

当然,我想看看这个差异是什么,所以我继续查看了反汇编的二进制文件。我能找到的只是setreset函数(这些是虚函数的实现)在二进制中没有被内联。这对我来说很奇怪,也令人惊讶,因为当我们谈论动态多态时,我们经常谈论代码中的间接性,即虚表和虚函数调用。但这些在二进制文件中都找不到。那么,这个承诺的开销去哪儿了?

答案是虚函数调用的去虚拟化。简而言之,去虚拟化是编译器在编译时可以静态决定应该调用哪些函数的过程,以便它可以生成对该函数的直接调用,而不是通过接口调用虚函数。在我的场景中,这正是发生的情况,如这段代码片段所示,CPinCLed对象都是静态分配的,因此编译器可以在编译时决定应该调用哪些函数。

为了给您完整的画面,我还必须补充,去虚拟化对于不同的编译器工作程度不同。一些编译器可以对您的代码进行更深入的分析,并解析更深层次的间接性,而其他编译器则不能。因此,出于这个原因,我也想看看动态多态的教科书案例,并想了解虚函数调用的实际开销是什么。

我修改了源代码,创建了一个去虚拟化无法应用的场景。我编译了它,并查看了反汇编的二进制文件,我找到了虚函数的实现。如果您回想一下,这些是CPin类的setreset函数。这些函数只是驻留在二进制文件的某个内存位置。当我进一步查看时,我也找到了虚表。虚表并不神奇,它只是一个内存块,位于二进制文件中的某个地方,包含某个类的虚函数实现的内存地址。不要被欺骗,我没有说谎。这些确实是那些函数的地址。只有最低有效位被设置为1,这只是针对STM32微控制器上的Thumb指令集,用于跳转指令。

让我们看看反汇编的二进制文件,看看这种函数调用的开销是什么。首先发生的是虚表的地址被加载到某个寄存器中。现在,这是在代码中,如果您回想一下,这是我们作为接口将CPin指针传递给CLed类的地方。当我们想调用引号中的set函数时,它获取虚表,并从虚表中加载正确函数的正确地址。当我们有了这个函数的地址,执行就简单地跳转到这个地址,有效地调用了该函数。set函数不接受输入参数,所以在这个例子中没有参数传递。这就是通过接口简单调用虚函数的开销。

这种开销乍一看似乎不大,但它阻止了许多编译时优化沿着链条进行。

问题是:我们是否有其他选项来实现类似的间接性,可能没有任何运行时惩罚?答案是肯定的。现代C++提供了一种新的解决方案来处理这种间接性,并且这种解决方案保证在编译时解析。我们可以使用概念来代替传统的虚拟接口。这些不再是抽象类,而是可以应用于模板参数的类型约束或一组要求。

如果我们想移植这段代码,我们需要添加实现,即满足这些要求的类。我们可以将这些视为这些接口的具体实现。这种层次结构通常被称为静态多态。让我们看一个实现。它与之前的类层次结构相同。我们有这个IPin接口。它不再是接口,不再是抽象类,没有虚函数。它是一个概念,是一组可以应用于模板参数的类型约束要求。

我还必须补充,这里的要求不像抽象类的要求那样强,因为在这里,这个IPin概念要求每个想要满足此概念要求的类型都必须定义一个set函数。这个set函数必须返回void,并且必须可以在不传递任何输入参数的情况下调用。这是关键区别,它没有说明set函数不能接受输入参数,它说明必须可以在不传递参数的情况下调用。因此,如果我们有一个set函数接受一个整数输入参数,默认值为0,那么它将满足此要求,因为该函数可以在不传递输入参数的情况下调用。类似地,如果我们要求一个函数接受一个32位有符号整数,而我们有一个接受8位有符号整数的函数,那也将满足约束,因为类型之间存在隐式转换。

CPin类。您可以将其视为IPin接口的具体实现。但它实际上只是一个满足IPin概念所声明的所有要求的类,因此它拥有IPin概念所要求的setreset函数。抱歉,我漏掉了这个额外的static_assert。它是可选的,但我们也可以在这里明确声明我们想要实现什么。

CLed类。这是外部外设接口的实现。现在这是一个模板类。它将适用于满足IPin概念所声明要求的任何类型。如果您用一个不满足所有要求的类型实例化这个类,那么您将得到一个清晰易懂的编译时错误消息,而不是我们在处理模板时习惯的那些晦涩难懂的错误消息。

也许在这里不那么明显,但在这段代码中,不再有运行时间接性,所有这些关系都由编译器在编译时解析。

那么我们为这个抽象付出的代价是什么?再次,是零。它编译出的代码与原始C源代码相同。我个人非常喜欢这种方法,因为我认为它对如何为微控制器编写可移植代码具有巨大的影响。因为这允许我们实现硬件独立的外部外设接口或驱动程序,并保证零运行时开销。

固件架构

我们已经看了封装、继承和多态。现在,在演讲的下一部分,我想谈谈固件架构,为什么它很重要,以及为什么架构决策也会影响运行时性能。

必须指出,到目前为止,我们讨论了抽象在硬件抽象层中的效果。但事实是,嵌入式开发者并不真正修改硬件抽象层,我们通常按原样使用它。C++是在硬件抽象层之上引入的这种包装器,在此基础上可以构建进一步的软件架构。这是一个巨大的话题。我不想深入二进制分析和源代码的细节,而是想从不同的角度来探讨,我想谈谈为什么架构决策很重要。

我们已经看到,抽象(封装、继承和多态)引入了很少甚至没有开销。所以在这里也不会有什么不同。

为了更好地理解我在说什么,让我们想象这个例子:我们有一个微控制器,一个七段数码管连接到这个微控制器。如果我们想改变七段数码管上显示的数字,我们必须做的是改变微控制器引脚的电平。

如果我们想高效地做到这一点,那么最优化的方法是使用单个位掩码来清除寄存器中所有必要的位,然后使用另一个单个位掩码将位设置为所需的值。这被认为是一次寄存器操作。这就是硬件抽象层处理这些操作的方式。

但是,如果您首先从抽象的角度思考,那么将单个GPIO的行为封装到CPin类中是有意义的,因为这将为与单个GPIO交互提供一个清晰易懂的接口。

然而,如果我们采用这种架构决策,那么我们一次只能修改一个引脚的值。这意味着我们取第一个引脚,改变它,清除其在寄存器中的相应位,然后将其设置为所需的值。然后我们继续,取下一个引脚,再次清除其相应位,并将其设置为所需的值,我们一遍又一遍地这样做,直到最后一个引脚被设置。

正如您所见,我们刚刚实现了与之前单次寄存器操作相同的目标,但我们没有使用一次寄存器操作,而是使用了8次寄存器操作。如果我们从硬件抽象层之上的包装器的角度思考,那么情况更糟,因为我们刚刚对硬件抽象层进行了八次函数调用,这将引入巨大的运行时开销。更不用说在设置GPIO的第一个引脚和设置GPIO的最后一个引脚之间,七段数码管上显示了无效的值。

因此,在这个场景中,使用或在端口级别或引脚集合级别上进行操作可能是更好的架构决策,因为这将再次允许我们使用单个位掩码同时设置所有位。所以我们使用一个位掩码清除所有位,使用另一个位掩码将它们设置为必要的值,这效率高得多。我想通过这个简化示例说明的是,由架构或不良架构决策引起的潜在开销可能比C++抽象引起的开销要显著得多。所以我们可能担心错了事情。

优化硬件抽象层

在演讲的剩余部分,我想看看传统的硬件抽象层是如何实现的。我想识别这些实现中的低效之处,并向您展示我们可以用来处理这些低效之处的方法。

在演讲开始时,我已经向您展示了这段源代码。这是当前硬件抽象层实现的样子。正如您所见,它充满了运行时低效。首先,有无法避免的函数调用。如前所述,我们实例化并填充这些数据结构,然后调用硬件抽象层并将这些数据结构作为参数传递。然后这些函数进行一些运行时输入验证。当然这是必要的,但这也是运行时开销。然后它们分支并执行必要的计算,所以二进制文件中有比较和跳转指令。它们还进行运行时位掩码计算,这些是我已经展示过的,用于寄存器操作的位移位。

我想在这里提出的问题是:我们能对这些低效做些什么吗?那么C语言真的达到了效率的极限吗?或者我们也许可以用C++做得更好?“零成本抽象”这个词在C++中被大量提及,但我想在这里分析的问题是:我们能比零成本抽象做得更好吗?我们能实现负成本吗?我们能实现高度优化的固件吗?

为此,您必须记住,本次演讲是关于嵌入式系统的。什么是嵌入式系统?它通常是一个印刷电路板(PCB)。在这个PCB的某个地方有一个微控制器,然后有多个外设连接到这个微控制器。有若干铜走线在微控制器和外设之间运行。

如果我们这样看待一个项目,那么您可能也有一种感觉,即它本质上具有某种编译时特性。铜走线不会在运行时神奇地重新布线。如果它们会,那么您就不应该担心固件的运行时开销。

因此,当您为这样的项目编写固件时,您可能已经知道哪些引脚将用作GPIO,哪些引脚将是ADC,哪些引脚将是通信总线的一部分。您可能已经知道模数转换器的分辨率,您可能知道I²C总线的频率,您可能已经知道总线上设备的地址。

因此,关于这样的项目,有很多事情您可以在编译时知道,也应该知道。如果您考虑到嵌入式系统项目这种固有的编译时特性,那么所有这些我们在当前硬件抽象层实现中刚刚识别的低效都是不必要的。如果我们从应用程序的角度思考,那么花费在硬件抽象层中的每一个CPU周期都只是浪费时间和能源。

幸运的是,C++提供了一些非常酷且强大的特性,允许我们处理这些低效问题,同时保持硬件抽象层的多功能性和灵活性,但同时它们也改进了错误处理,可能还提高了可读性。

示例项目

为了证明C++的强大功能,我在一个实验板上实现了这个示例项目。我有一些模拟输入,这些只是简单的电位器,以便我可以手动设置它们,但您可以想象它们同样可以测量某种物理值,如温度或湿度。至于输出,我在一个输出上连接了一些LED,有一个LED阵列,您可以将其视为某种电平指示器。在另一个输出上,有一个单独的LED,由脉宽调制信号控制,您可以将其视为泵控制信号或风扇信号。我想说明的是,尽管这只是实验板上的一个示例应用程序,但您同样可以想象这可能是某个地方的真实设备。

固件所做的是初始化外设,然后进入无限循环。它读取模拟输入,设置输出,并测量每次迭代的时间。一方面,我使用C语言和提供的硬件抽象层实现了此功能,并按照其预期用途使用了这些硬件模块。然后,我开始向代码库中添加C++,开始一个接一个地替换这些不同的硬件模块。

我没有替换所有模块,但即便如此,结果也很有说服力。“替换”在这里不是一个好词,因为我没有删除原始实现,它们仍然存在。我只是用我自己的实现扩展了硬件抽象层,这也有帮助。例如,如果嵌入式项目的某些方面在编译时未知,那么我可以回退到原始实现并使用提供的硬件抽象层。

我还必须补充,我将向您展示的一些特性可能不适用于高度动态的嵌入式系统应用程序,但它们也并非嵌入式系统独有。我认为在这里更明显是因为嵌入式应用程序这种固有的编译时特性,但我相信其中一些特性也可以用于软件开发的其他领域。因此,如果在您的项目中,有些东西可以在编译时评估,那么我认为它应该被评估。

使用的C++特性

让我们看看其中一些特性。当然,我使用的第一个特性是C++类型系统本身。它非常强大,不应被低估。我想向您展示的一个例子是枚举,因为正如您在原始硬件抽象层实现中所见,经常使用枚举值。与C枚举不同,C++枚举类不再是普通整数,枚举值和底层整型之间没有隐式转换。因此,这也意味着您必须付出额外的努力才能在这种类型系统中引入某种错误,所以它更不容易出错。

现在我们武器库中两个非常重要且非常强大的工具是模板和概念,因为模板允许我们进行编译时参数传递。因此,如果您知道某个GPIO的功能,或者知道嵌入式系统项目的某些方面在编译时,那么将这些参数在编译时传递是有意义的,以便编译器可以看到它们。这就是概念真正派上用场的地方,因为正如我们所讨论的,概念是一组可以应用于模板参数的要求或类型约束。这对于非类型模板参数也是如此,因此我们可以使用概念将运行时参数验证推到编译时。当然,如果某些值超出范围或可接受范围,那么您将得到一个清晰易懂的编译时错误消息。

我使用的下一个特性是另一个较新的特性,即立即函数。这些函数保证在编译时执行。我广泛使用这些函数将运行时位掩码计算推到编译时。然后这些函数与模板、可能的模板参数包和constexpr表达式结合使用,它们还允许您同时计算一组引脚(而不仅仅是单个GPIO)的位掩码。

当我们从嵌入式系统应用程序的角度思考时,再次强调,如果我们已经知道时钟或ADC的配置,那么就没有理由在运行时计算位掩码,您可以简单地在编译时计算它们,然后在二进制文件中,代码可以使用预先计算的值。

问题是:C编译器不优化这些移位操作吗?答案是,是的,它会优化,只要您以清晰的方式操作。例如,如果您在main函数中操作,那么C编译器当然会看到它并优化该操作。但是,如果您用不同的输入参数多次调用同一个函数,那么C编译器只会简单地将该函数作为函数放入代码中并调用它,然后所有这些计算都发生在运行时。

我想提到的最后一件事是C++17的编译时分支,即编译时if语句。因此,如果配置在编译时已知,那么就没有理由在运行时进行分支操作。使用constexpr if语句,您不仅可以节省比较和跳转指令,而且不活动的代码段也不会包含在二进制文件中,因此这也可能节省一些内存空间。

通过积极使用这些C++特性(再次快速回顾一下),我们使用模板进行编译时参数传递,使用概念进行编译时参数验证。我们使用立即函数将原本运行时的位掩码计算推到编译时。我们使用constexpr if语句在编译时进行尽可能多的分支操作。应用了所有这些优化后,这也是我想回到您问题的地方,编译器将能够将这些冗长复杂的硬件抽象层函数简化为仅使用预先计算位掩码的一堆寄存器操作。因此,它也更有可能内联这些函数,并且您可能还节省了函数调用的开销。

性能对比

如前所述,我使用原始硬件抽象层用C语言实现了这个项目,并且也使用我自己的、针对嵌入式项目固有编译时特性优化的C++硬件抽象层实现了它。正如您所见,差异相当显著。我节省了大约2KB的二进制大小,这大约是20%。如果您回想演讲开头,我曾说过有些设备的闪存容量低至16KB甚至更低。因此,如果应用程序变得更复杂,这种差异最终也可能决定它是否能装入闪存。

差异不仅体现在二进制大小上,而且在运行时性能上也相当显著。在这里您可以看到,它也节省了大约20%,因为原始C实现可以进行4次迭代,而新的可以进行5次,所以运行时性能也提高了20%。

代码膨胀

现在,为了完整性,我想非常快速地讨论最后一件事:代码膨胀。因为如果您谈论C++抽象的开销,那么代码膨胀是经常出现的一件事。在前面的例子中,我广泛使用了模板,因为前提是项目的某些方面在编译时已知。现在,如果情况并非如此。例如,如果振荡器的配置在编译时未知,或者可能在运行时变化,那么调用模板化函数就不是一个好主意。这就是您回退到原始实现的地方,因为如果您调用模板化函数,那么编译器将为每个特化生成一个独特的函数调用。在这种情况下,三个特化对应三个函数。这当然会增加二进制大小和编译时间。

结论

通过准备这次演讲和完成这个项目,我得出了几个结论。其中之一是零成本抽象是有效的。它们甚至在如此接近硬件的地方(即在寄存器操作级别)也有效。我们已看到它们引入了很少甚至没有运行时开销,同时提高了代码库的可读性和可维护性。对我个人而言,它们也使开发更有趣。

我们还看到固件架构很重要,架构决策甚至在如此接近硬件的地方也很重要。我们经常将软件架构视为组织代码以提高可读性和可维护性的方式,但正如这里所示,它也可能对运行时性能产生巨大影响。

我们还看到,在某些场景下,如果项目的某些方面在编译时已知,那么我们甚至可以超越C++的零成本抽象概念,通过将一些传统的运行时操作推到编译时,实现高度优化的固件应用程序。

最后,我只想补充,我认为C++对于嵌入式系统应用程序非常有用。我个人希望未来能在硬件抽象层以及嵌入式社区中看到更多对C++的支持,并且我鼓励每个人在嵌入式系统中更多地使用C++。

感谢您的关注。希望您喜欢这次演讲。如果您有任何问题,请随时现在提问,或者您也可以稍后找我。


在本节课中,我们一起学习了在嵌入式系统中应用C++抽象(封装、继承、多态)的实际成本。我们从最底层的硬件抽象层入手,分析了这些特性对二进制大小和运行时性能的影响,发现它们通常带来零或极低的开销,同时显著提升了代码的可读性、可维护性和可测试性。我们还探讨了如何利用C++的现代特性(如模板、概念、编译时计算和constexpr if)来识别并消除传统硬件抽象层中的运行时低效,从而生成高度优化的固件。关键要点是:零成本抽象在嵌入式系统中是切实可行的,良好的架构决策对性能至关重要,并且通过利用编译时已知信息,C++能够帮助开发者编写出比传统C代码更高效的嵌入式软件。

010:契约、安全与驯猫的艺术

概述

在本节课中,我们将探讨如何编写更好的 C++ 程序。我们将从定义“好程序”开始,深入理解程序正确性的概念,并介绍 C++26 中的契约断言功能。接着,我们将辨析“安全”一词在 C++ 社区中的多重含义,并讨论如何系统地减少 C++ 语言中的未定义行为。最后,我们将分享在标准化过程中协调不同意见、达成共识的实用方法。


什么是好的 C++ 程序?

一个优秀的 C++ 程序具备多种属性。效率,尤其是性能,是 C++ 的独特卖点,也是许多讨论的焦点。此外,良好的代码还应具备可维护性、可读性、可扩展性、可移植性、可重用性和可扩展性。成本效益同样重要,如果超出预算,程序再好也无法交付。近年来,安全性和安全性在 C++ 社区中受到了特别关注。

然而,这里缺少了一个至关重要的属性。这个属性就是正确性。如果程序不能完成其预定任务,那么其他所有属性都无关紧要。


什么是正确的程序?

在计算机科学文献中,程序正确性有多种定义。功能正确性是指对于任何输入,程序的输出都符合规范要求。这又分为完全正确性和部分正确性。完全正确性要求算法总能返回一个正确的输出,但这涉及到停机问题,通常是不可判定的。部分正确性则只要求如果算法返回,其结果就是正确的。

然而,现实世界是复杂的。规范是什么?输入和输出具体指什么?是否包括副作用?程序可能还有实时性、资源使用限制等其他要求。因此,我们需要一个更实用的定义。

我们可以说,程序、函数或类有一组对其行为的期望,这些期望构成了它的契约。契约包括基本行为、前置条件、后置条件、类不变量、循环变体和不变量等。一个正确的程序或函数就是满足其契约的程序或函数。反之,违反契约总是一个缺陷,需要通过修复代码来纠正。


契约断言:在代码中表达期望

契约有许多部分,有些是隐式的(如编码惯例),有些是显式的(如代码注释或独立文档)。在 C++ 中,我们可以直接在代码中使用契约断言来表达这些期望。

在 C++26 之前,我们可以使用 assert 宏或自定义宏。从 C++26 开始,我们有了正式的语言特性来表达契约断言,包括三种类型:前置条件断言、后置条件断言和契约断言。它们是在代码中指定契约期望子集的一种方式。

以下是几个例子:

  • 对于一个类似 vector 的类,可以在 operator[] 中添加前置条件断言,检查索引是否在边界内。
  • 对于 clear 函数,可以添加后置条件断言,保证操作后容器为空。
  • 对于 empty 函数,可以添加后置条件断言,说明其返回值当且仅当 size() == 0 时为真。

契约断言在运行时是否检查、检查失败时如何处理,是可以配置的。C++26 定义了四种评估语义:ignore(忽略)、observe(观察)、enforce(强制)和 terminate_enforce(强制终止)。具体使用哪种语义通常由编译器标志控制。当检查失败时,会调用一个可链接时替换的契约违反处理程序

需要注意的是,契约断言只能表达平白语言契约的一个子集。有些契约(如 push_back 后大小加一)目前语法尚不支持,有些(如强异常保证)则难以用当前机制表达。


契约断言是什么,不是什么?

让我们来总结一下契约断言的核心特性。

契约断言是:

  • 一种用于表达函数正确性人类期望的语法。
  • 部分契约期望的可选运行时检查。
  • 可移植、可扩展、可自由配置的,优于传统的断言宏。
  • 不仅用于运行时检查,也能被静态分析工具和 IDE 利用。
  • 在正确程序中是冗余的,这正是它们可以被关闭的原因。

契约断言不是

  • 一个证明正确性的工具。
  • 一个表达非契约性内容(如错误处理逻辑)的工具。缺陷错误有本质区别,缺陷需要修复代码,错误则需要被程序处理。
  • 一个提供语言层面保证的工具(例如,保证 operator[] 永远不会导致未定义行为)。它需要被显式添加,并且可以被关闭。

契约断言的目标是提高程序的正确性,帮助发现程序不正确的情况。虽然你永远无法证明程序完全正确,但每添加一个契约断言,你就能渐进地发现更多缺陷,使程序更加正确。


辨析“安全”的多重含义

“安全”是 C++ 社区近年来的热点话题,但不同的人在说“安全”时,可能指代完全不同的概念,这导致了大量低效的沟通。

  1. 无未定义行为:指操作不会导致 C++ 中那种编译器可以为所欲为的未定义行为。许多关于内存安全、类型安全的讨论都发生在这个语境下。
  2. 功能安全:指当出现问题时,不会对人员、设备或环境造成伤害。这是安全关键领域和大多数行业外的通用含义。
  3. 系统安全:这是一个由策略统计衡量、通常由政府机构裁定的系统涌现属性。C++ 委员会中的安全研究组曾采用类似定义。
  4. 兰波特安全:源于 Leslie Lamport 的形式化框架,指在有效输入下,某些坏事不会发生的属性(例如,自动驾驶汽车不会驶离道路)。编程语言的“安全”则是指其可表达的任何程序都保证满足的一组安全属性。

为了避免误解,建议在讨论时使用更具体的术语,如“功能安全”、“无 UB”等,而不是泛泛地使用“安全”。

这些概念相互关联但不重合。例如:

  • 无 UB 不是功能安全的必要条件:人们用 C++ 编写安全关键代码已有数十年,通过流程、测试和工具来保证安全。
  • 无 UB 也不是功能安全的充分条件:许多安全相关的缺陷与 UB 或编程语言无关,而是业务逻辑错误(如单位混淆、浮点误差累积)。
  • 安全性则关注恶意攻击。无 UB 可能是安全性的必要条件(因为 UB 常被利用),但绝非充分条件。攻击者会寻找任何可利用的漏洞。

程序正确性是更根本的属性。一个正确的程序,自然在所有定义下都是安全和安全的。契约断言等工具旨在提高正确性,而减少 UB 等工作则是在程序不正确时,限制可能发生的损害。它们是互补的“瑞士奶酪”模型中的不同层次。


如何驯服未定义行为这只“猫”?

既然契约断言已纳入语言,下一个焦点就是减少语言中的未定义行为。一个常见的问题是:为什么不把所有 UB 都定义为有明确行为呢?

根本原因在于,UB 是一个运行时属性。编译器通常无法在编译时证明某个表达式在运行时是否会导致 UB,因为这取决于未知的运行时数据。如果将所有可能 UB 的情况都定义为有明确行为,将会破坏大量现有代码。

以内存安全为例,已知的解决方案包括:

  • 运行时方案:如垃圾回收、引用计数、消毒剂。它们有效但可能有性能开销,且不提供语言级保证。
  • 编译时方案:主要是强制执行“排他性法则”(如 Rust 的所有权系统)。但这要求重写所有现有 C++ 代码,使其变成另一种语言。

C++ 的现状是充满 UB 的“海洋”。我们无法一口吃掉整根香肠,但可以采取“香肠切片”策略,一次消除一点 UB,渐进地改善状况。

C++ 委员会已经开始这样做(如 C++20 的隐式生存期类型,C++23 修复的基于范围的 for 循环问题)。但我们需要更系统、更全面的方法。


P3100:系统性处理未定义行为的提案

提案 P3100 旨在为 C++ 引入一个标准化的框架,用于运行时检测和缓解核心语言中的未定义行为。我们完成了以下工作:

  1. 枚举了 C++ 核心语言中所有 90 个显式的 UB 案例,并为它们分配了唯一标识符。
  2. 将这些案例分为 12 个类别(如类型与生存期、算术、线程、序列点等)。
  3. 根据一系列标准对每个案例进行分类:是否与安全相关、是否可本地检查、检查成本、是否可定义明确的后备行为等。

分析发现:

  • 大多数 UB 属于“类型与生存期”类别。
  • 只有约 20% 的 UB 可以在编译时本地诊断
  • 对于约 14% 的 UB,可以定义明确的、非 UB 的后备行为(如将有符号整数溢出定义为补码环绕)。
  • 对于其余大多数,唯一合理的缓解措施是运行时检查并在失败时终止。

提案包含三部分:

  1. 系统性引入运行时检查:在所有可以插入运行时检查检测 UB 的地方,标准文本将进行转换,视为存在一个隐式契约断言。这些断言的行为与显式契约断言完全一样(可忽略、可观察、可强制等),允许编译器/消毒剂选择性地实现它们。
  2. 系统性用明确行为替换 UB:对于可以定义后备行为的情况,将 UB 替换为该行为。
  3. 提供选择退出的机制:引入第五种评估语义 assume(不检查谓词,但假设其为真;若为假,则行为未定义)。这对于隐式断言是保持现状(编译器可基于无 UB 进行优化),同时允许用户为性能原因选择退出检查。

该提案提供了一个标准框架来描述现有的编译器标志和消毒剂行为,实现了可移植的命名和分类,并能与契约标签、配置文件等未来特性无缝集成。


更大的蓝图:应对 UB 的策略菜单

处理 UB 需要根据代码是否可修改来采取不同策略:

对于可修改的新代码

  • 语言子集化:禁用某些可能导致 UB 的特性,要求显式选择加入“不安全”操作。
  • 注解:添加注解(如 [[bounded]])帮助编译器进行边界检查。
  • 用新特性替换:提供有明确行为的新操作(如饱和运算)来替代不安全的旧操作。

对于不可修改的遗留代码

  • 重定义行为:直接将少数 UB 案例重定义为有明确行为(已基本完成)。
  • 错误行为:定义为错误但明确的行为(如 C++26 的未初始化读取)。
  • 运行时检查:P3100 提案提供的框架。

配置文件的角色应是上述策略的命名配置预设。一个配置文件名称应扩展为一组关于启用哪些检查、应用哪些语言子集的语句。

这是 C++ 委员会未来几年处理 UB 的路线图。


驯猫的艺术:在委员会中达成共识

技术问题固然重要,但人的因素往往是更大的挑战。在标准化过程中,如何沟通、决策和组织工作是关键。以契约标准化耗时 21 年为例,许多时间都花在了解决分歧上。

在 C++ 标准委员会(WG21)这样的环境中工作,需要耐心和技巧。以下是一些经验法则:

一般规则

  • 始终保持尊重和专业。
  • 切勿假定他人有恶意。
  • 保持过程透明并公平适用。
  • 准备好反复解释,因为人们可能不阅读论文。
  • 当有人反对时,请他们明确阐述替代方案并付诸文字。
  • 当两人有分歧时,鼓励他们私下解决并达成一致。

技术讨论规则

  • 聚焦于事实、数据和可客观验证的内容,避免带有价值判断的表述。将“A 比 B 好”重构为“X 优先考虑 A,Y 优先考虑 B,哪个优先级更重要?为什么?”
  • 学会区分事实与观点,并勇于挑战模糊的陈述。
  • 识别逻辑谬误,例如:
    • 契约的目的是 X,契约没做 X,所以契约不好。(前提错误)
    • 契约存在 Y 问题,所以在解决 Y 之前不应标准化契约。(Y 可能无关)
  • 不要试图直接说服对方他们错了。相反,帮助他们自己看到正确的解决方案。可视化是极佳的工具。

决策算法:一个可视化工具

一个有效的决策方法是构建一个需求-解决方案矩阵。该方法基于 John Lakos 的设计原则,但进行了简化。

步骤如下:

  1. 陈述问题:明确要决定的具体设计问题。
  2. 列出需求:所有利益相关者列出其客观、可验证的需求。不要对需求进行重要性排序,避免价值判断。只包含有数据支持或可测量的需求。
  3. 列出所有提议的解决方案
  4. 构建演化图:展示哪些解决方案可以从更保守的方案演化而来。
  5. 构建矩阵:行是需求,列是解决方案。在每个单元格中,用 是(绿)否(红)可能/部分(黄) 标记该方案是否满足该需求。
  6. 分析与决策
    • 如果某个方案明显满足所有或大多数需求,它就是最佳选择。
    • 如果需要在冲突的需求间权衡,矩阵将冲突清晰地可视化出来,使讨论聚焦于这一个关键权衡。
    • 如果无法就权衡达成一致,则回退到演化图中更保守的方案。

这个可视化方法曾成功帮助一群固执己见的工程师就 noexcept 与契约断言的交互问题达成共识。它有助于将复杂的争论分解为可管理的、基于事实的讨论。


总结

本节课我们一起学习了如何构建更好的 C++ 程序。我们从程序正确性和契约的核心概念出发,深入了解了 C++26 的契约断言功能及其定位。我们辨析了“安全”一词的多种含义,并认识到减少未定义行为与使用契约断言是提高程序正确性的互补手段。我们探讨了通过提案 P3100 等努力系统性处理 UB 的策略,并展望了未来的工作方向。最后,我们分享了在复杂的技术社区中协调分歧、达成共识的“驯猫艺术”和实用的决策可视化工具。希望这些内容能帮助你编写更正确、更健壮的 C++ 代码,并在技术讨论中更有效地沟通与协作。

011:核心概念与实现

在本节课中,我们将探讨如何从其他现代编程语言(如Rust、Swift、Circle)的线程安全模型中汲取灵感,并尝试在C++中应用类似的概念。我们将重点关注如何通过编译时检查和运行时策略来避免数据竞争,提升C++代码的线程安全性。


演讲者介绍

我是Dave Roland,音频集团的首席技术官。我们公司主要分为两部分:硬件方面,我们生产专业数字音频接口和多通道转换器;软件方面,我们开发一系列虚拟乐器和名为Waveform的多轨录音工作站。其后台是一个开源项目,负责处理数据模型、实时元素和设备交互。工作之余,我喜欢从事建筑工作,我发现解决工程问题与编程有相似之处,但更偏重体力劳动,能让我暂时离开电脑。

线程安全定义与重要性

上一节我们介绍了演讲背景,本节中我们来看看线程安全的准确定义及其重要性。

一个程序是线程安全的,如果它没有数据竞争。数据竞争是指两个线程访问同一内存位置,且至少有一个是写操作。因此,线程安全的编程语言应使开发者无法表达出会导致数据竞争的代码。

为什么线程安全值得关注?根据谷歌去年的安全报告,线程安全问题虽然目前只占所有安全漏洞的5.6%,但这个比例可能会增长。原因如下:

  • 随着边界检查等“低垂果实”类安全措施被引入C++,线程安全问题的相对占比会上升。
  • 机器核心数不断增加,语言本身(如std::execution)也让多线程编程更普遍。
  • 线程安全与生命周期安全紧密相连,改进一方可能惠及另一方。
  • 线程相关的Bug通常难以发现和调试,因为问题往往在数据竞争发生很久后才显现。

其他语言的线程安全策略

几乎所有现代语言都通过某种方式避免共享状态来防止数据竞争。我们可以将它们分为几个类别:

以下是几种主要的线程安全模型:

  • 纯函数式(如Haskell):通过纯函数和值语义完全避免共享可变状态。
  • 消息传递(如Erlang, Elixir):通过进程/actor模型传递消息,而非直接共享内存。
  • 独占性法则(如Rust, Swift, Circle):允许线程,但通过编译时检查确保对可变数据的独占访问。

本教程将聚焦于最后一类,特别是Rust/Circle和Swift所采用的策略。

Sync与Send:编译时安全协议

上一节我们了解了不同语言的高层策略,本节中我们来深入看看实现这些策略的核心编译时概念:SyncSend

SyncSend是两种在编译时检查的协议或特征(Traits)。

  • 一个Sync对象可以安全地在多个线程间共享
  • 一个Send对象可以安全地在线程间转移所有权。
  • 通常,Sync对象也隐含着是Send的。

在不同语言中,这些概念的具体实现如下:

  • Swift:通过Sendable协议实现。只有符合Sendable的类型才能跨越actor隔离边界传递。
  • Rust:通过标记特征(marker traits)SyncSend实现。具有纯值语义的类型会自动实现这些特征。可变借用(&mut T)不能在线程间共享。
  • Circle:遵循与Rust相似的模型,但使用C++语法。它使用const来施加不可变性约束。

在C++中模拟Send/Sync

看到其他语言的思路后,一个自然的想法是:能否在C++中用类型特征(type traits)模拟Send/Sync?我尝试构建了一个“安全并发库”来进行探索。

核心思想是创建现有C++构造的安全包装器。我们从std::jthread开始,并约束传递给它的参数和可调用对象必须符合Send概念。

Send概念的初步定义旨在防止数据共享:

template<typename T>
concept Send = is_send_type_v<T>;

is_send_type_v的实现逻辑如下:

  1. 不允许左值引用或指针(可能指向共享数据)。
  2. 不允许类似lambda的可调用对象(可能捕获this指针或引用)。
  3. 允许可移动构造的类型(通过右值转移所有权)。
  4. 允许非成员函数指针。

Sync概念则标识那些本身无数据竞争的类型。最初,我们只认为std::atomic<T>Sync的。另一个有用的类型是synchronized_value<T>(一个包装了互斥锁的类型),它来自一个尚未标准化的提案。

为了使Sync类型有用,我们需要一种在线程间传递它们的方式。由于禁止了引用,我们可以使用std::shared_ptr。因此,我们规定:std::shared_ptr<T>Send的,当且仅当TSync的。

通过这种方式,我们重写了Circle中的示例,强制使用shared_ptr<synchronized_value<string>>来安全地在线程间传递和访问字符串。

C++初步方案的局限性

上一节我们实现了一个初步的安全线程模型,本节中我们来看看这个方案存在哪些问题和局限性。

尽管上述方案提供了一些安全性,但它存在明显缺陷:

  1. 嵌套指针问题:类型特征无法递归检查所有成员。一个包含原始指针的Node对象可能通过Send检查,但其指针可能指向别处导致数据竞争。
  2. this指针问题:常见的[this]() { memberFunc(); }模式会捕获this指针,这无法通过我们的Send检查。
  3. 指针泄漏问题:即使通过synchronized_value访问,我们仍能获取内部对象的地址并存储到全局变量,从而绕过保护。
  4. 非强制性:依赖于自定义的safe_thread类,无法强制他人使用。
  5. 编译错误不友好:复杂的模板和概念检查会导致难以理解的错误信息。
  6. 性能开销:处处使用原子引用计数和互斥锁带来额外开销。

总之,我们得到了一个不防弹、对初学者不友好、非默认且性能有代价的方案。与Circle的例子相比,虽有相似思路,但C++缺乏表达生命期和独占引用的核心机制(借用检查),难以达到同等级别的安全与性能。

利用反射改进Send检查

C++26引入的反射功能为我们解决前述问题提供了新工具。我们可以递归地检查类型的所有成员是否符合Send

利用反射,我们可以将is_send重写得更清晰:

constexpr bool is_send(const meta::info refl) {
    // 检查是否为非成员函数指针、算术类型等...
    if (meta::is_class(refl)) {
        // 递归检查所有数据成员
        for (auto member : meta::data_members_of(refl)) {
            if (!is_send(member)) return false;
        }
        return true;
    }
    return false;
}

这样,之前有问题的Node类型、捕获了this或非法引用的lambda都会被正确拒绝。我们修复了嵌套指针和this指针的问题。

通过代码生成封装安全性

要解决指针泄漏和生命周期问题,我们需要将指针封装在值类型中,并避免暴露原始地址。C++26/29的反射和代码生成功能(如元类)使之成为可能。

我们可以定义元类来自动生成线程安全的包装类型。例如:

  • synchronized元类:生成一个包装类,内部使用synchronized_value,并对外提供相同的API,所有调用都通过安全的apply进行。
  • mutexed元类:更轻量,仅为类添加一个互斥锁成员,并转发调用。
  • arc(自动引用计数)元类:模仿Swift的类,内部使用shared_ptr,提供类似值类型的语法但具有引用语义。符合Send的条件是内部类型T符合Sync
  • copy_on_write元类:为昂贵复制的类型提供写时复制语义,在非const成员函数中检查引用计数并在需要时创建副本。

这些元类可以组合使用,例如person [[arc, mutexed]],从而极大地简化代码,让开发者只需关注业务逻辑,而由编译器保证线程安全。

处理特殊用例:inout参数与循环引用

在实现类Swift的引用语义时,我们需要处理两个特殊问题:安全的类指针语义和循环引用。

inout参数:Swift使用inout参数提供安全的“类指针”语义,用于修改函数参数。在C++中,我们可以通过生成一个特殊的inout包装器来模拟。它接受一个指针,但在内部创建自己的安全副本用于操作,最后在析构时将结果复制回原指针。我们甚至可以重载operator&来返回这个inout包装器,从而实现类似Swift的语法。

循环引用:自动引用计数(ARC)可能导致循环引用和内存泄漏。Swift使用弱引用(weak)来打破循环。在C++中,我们可以利用std::weak_ptr来实现相同的模式。通过元类生成一个weak_ref包装器,它提供get()方法返回一个std::optional<T>,使用者必须检查值是否存在后才能使用。

Actor模型:更高层的并发抽象

除了底层的Send/Sync,我们还可以借鉴Swift的Actor模型,在更高层次组织并发。Actor将其状态隔离,所有对其成员的访问都序列化到一个内部队列(线程)上。

在C++中,我们可以利用反射和std::execution来实现一个actor元类。这个元类会将生成的每个成员函数调用包装成一个发送者(sender),并调度到一个单线程的调度器上执行,从而保证串行访问。结合C++协程,我们可以使用co_await来异步调用actor的方法,语法上接近Swift。

未来的优化方向包括:根据注解将不同actor分发到不同优先级队列、实现“主Actor”(运行于主线程)、以及在调用者已处于actor内部线程时避免不必要的调度开销。

运行时检查与契约

完全的编译时安全可能难以在现有C++中实现,因此运行时检查是必要的补充。现有的主要工具是ThreadSanitizer(TSan),但它有局限性:仅Clang/GCC支持、需要单独运行、与ASan等工具互斥、且性能开销巨大(5-15倍减速)。

我们可以考虑一种更轻量级的、基于类型契约的运行时检查。思路是:在类型的每个const成员函数入口检查是否有并发的写入者,在每个非const成员函数入口检查是否有并发的读取者或写入者。这实际上是检查标准库容器已有的线程安全契约(例如,const成员函数可并发调用)。

我们可以通过元类自动注入这种检查逻辑。为了保持ABI兼容性,检查状态可以通过Herb Sutter提出的“外置存储”方案(一个无锁的、由地址映射到的对象)来存储,而不是作为类的直接成员。更进一步,这可以转化为C++契约(Contracts)中的[[assert: ...]],在违反契约时调用用户定义的处理器。

这种检查虽然有限(仅作用于函数入口/出口,不跟踪内存),但能有效捕获高层设计上的线程安全契约违反,成本远低于TSan,甚至可用于生产环境进行监控。

总结与展望

本节课中我们一起学习了从Rust、Swift等语言借鉴线程安全思想的旅程。

我们深入探讨了Sync/Send这对核心概念,它们通过建立隔离边界和编译时检查来防止数据竞争。我们看到了如何在C++中利用类型特征和C++26的反射来模拟这些概念,并通过元类生成安全包装类型来封装指针、管理生命周期,从而模仿其他语言的安全模式。

我们还探讨了更高层的Actor模型实现,以及通过轻量级运行时契约检查来补充编译时安全的不足。

当前C++标准的发展方向(如配置文件)也提及了静态检测跨线程别名等想法。我认为,C++需要一种语言机制来识别线程隔离边界(即Send概念),这将引入严格的别名和生命周期要求。反射能帮助我们以其他语言的风格编写更安全的代码,但如果想要纯粹C++的性能和“零开销抽象”,我们可能需要类似借用检查器的根本性机制。

展望未来,C++29或许能引入类似Send/Sync的原语,并通过并发配置文件启用。标准库实现可以加入轻量级数据竞争检查来验证契约。一旦语言提供了检查这些属性的设施,我们很可能会看到一系列符合这些新约束的安全并发库涌现出来,推动C++向更安全、更易于编写正确并发代码的方向发展。

012:缺失(以及未来)的 C++ 范围概念

在本教程中,我们将探讨 C++ 标准库中缺失的一些范围概念,以及未来可能的发展方向。我们将重点关注性能优化和元编程这两个核心问题,并介绍一些已在 think-cell 库中实现的概念,这些概念有望被纳入标准。

概述

本次课程将分为两个主要部分。首先,我们将讨论如何通过新的概念和定制点来优化范围算法的性能,特别是针对迭代器状态机带来的开销。其次,我们将探讨如何在编译时利用范围进行元编程,处理依赖于类型的操作。最后,我们会简要提及标准中关于无限范围定义的一些悬而未决的问题。

问题一:性能优化

上一节我们概述了课程内容,本节中我们来看看第一个核心问题:性能优化。当前基于迭代器的范围模型在某些场景下会引入不必要的性能开销。

示例:文件处理与连接视图

考虑一个常见的任务:读取文件并将所有制表符(\t)替换为四个空格。使用范围管道可以简洁地实现:

auto result = std::ranges::to<std::string>(
    read_file(path)
    | std::views::transform([](char c) -> std::string_view {
        return c == '\t' ? "    " : std::string_view(&c, 1);
      })
    | std::views::join
);

read_file 函数返回一个输入范围。transform 视图将每个字符映射为一个 std::string_view(单个字符或四个空格)。join 视图将这些字符串视图扁平化为一个字符范围。最后,std::ranges::to 将惰性范围急切地转换为 std::string

大小信息的丢失与近似大小范围

std::ranges::to 在构造字符串时,如果输入范围是 sized 的,它可以预先分配足够内存,效率很高。然而,经过 transformjoin 后,我们失去了大小信息,因为 join 需要求和内部各个字符串的大小,而这是未知的。

如果文件大部分不含制表符,那么输出大小近似等于输入大小。C++26 引入了 approximately sized range 概念和 size_hint 函数来解决这类问题。

以下是 size_hint 的简化实现思路:

template<typename R>
constexpr auto size_hint(const R& r) -> std::optional<std::ranges::range_size_t<R>> {
    if constexpr (std::ranges::sized_range<R>) {
        return std::ranges::size(r);
    } else if constexpr (requires { r.size_hint(); }) { // ADL 查找
        return r.size_hint();
    } else {
        return std::nullopt;
    }
}

许多视图(如 transformtake)可以传播近似大小信息,但 join 通常不能。我们可以创建一个包装器来手动传播大小:

// 一个简单的包装器,存储范围及其近似大小
template<std::ranges::input_range R>
struct approximately_sized_view : std::ranges::view_interface<...> {
    R base_;
    std::optional<std::size_t> size_hint_;

    // ... 迭代器实现需转发 size_hint
};

// 使用示例(破坏了管道流畅性)
auto base_range = read_file(path) | std::views::transform(transformer);
auto sized_view = approximately_sized_view(base_range, file_size);
auto result = std::ranges::to<std::string>(sized_view | std::views::join);

为了保持管道风格,可以定义一个 join_with_unchanged_size 适配器,但其实现涉及复杂的闭包对象,错误信息不友好。这暗示此类功能或许更适合作为语言特性。

迭代器状态机的开销

当前的范围模型基于 拉取模型(pull model),消费者通过迭代器完全控制迭代过程。但对于像 std::ranges::for_each 这样的算法,消费者只需要所有元素,并不需要这种控制力。

迭代器本质上是一个状态机。例如,join 视图的迭代器需要维护外层迭代器和内层迭代器两个状态,并在内层结束时切换到下一个外层元素。这比简单的嵌套循环要复杂。经过多层视图变换后,会形成一个深层嵌套的状态机,编译器需要大量内联和优化才能将其恢复为高效的循环代码。

更好的方案是 推送模型(push model) 或直接定制循环。生产者主动将值推送给消费者,消费者通过回调函数处理每个元素。

定制点:for_each_while

我们可以引入一个名为 for_each_while 的定制点。它接受一个范围和一个“接收器(sink)”可调用对象,遍历范围并对每个元素调用接收器。如果接收器返回 false,则停止迭代。

其默认实现基于迭代器:

template<std::ranges::input_range R, typename Sink>
constexpr bool for_each_while(R&& r, Sink sink) {
    for (auto&& elem : r) {
        if (!sink(std::forward<decltype(elem)>(elem))) {
            return false;
        }
    }
    return true;
}

关键在于,视图可以为此定制点提供更高效的实现:

  • transform 视图:直接在基范围的循环中应用变换函数。
  • join 视图:实现为两个嵌套循环,分别遍历外层和内层范围,完全避免状态机。
  • read_file 视图:直接嵌入读取缓冲区和遍历缓冲区的命令式代码。

当算法(如 for_eachcopytransform)使用 for_each_while 而不是基于迭代器的循环时,经过编译器内联,最终能得到与手写命令式代码几乎相同的优化结果。

处理连续内存块

许多范围虽然不是完全连续的,但由连续的“块”组成,例如 deque、拼接的向量、或 read_file 的缓冲区。如果能以块为单位处理,就可以使用 memcpy 等高效操作。

for_each_while 定制点可以扩展,让接收器优先处理整个块。我们修改 for_each_while,让它首先尝试调用接收器的 .chunk() 成员函数(如果存在),并传入一个代表连续块的子范围。如果接收器不支持块操作,则回退到逐元素调用。

例如,字符串追加器可以实现为:

struct string_appender {
    std::string& str;
    // 处理单个字符
    bool operator()(char c) { str.push_back(c); return true; }
    // 处理连续字符块(更高效)
    bool chunk(std::span<const char> s) { str.append(s.data(), s.size()); return true; }
};

join 视图的 for_each_while 实现遇到一个连续块(如 std::span)时,它会调用 string_appender::chunk,从而一次性追加整个块,避免了逐字符的 push_back

通过结合 for_each_while 和块处理,我们可以编写出既表达力强又极其高效的代码。

问题二:元编程与编译时计算

上一节我们探讨了运行时性能优化,本节中我们来看看如何利用范围在编译时进行元编程。

编译时字符串处理

假设我们需要在编译时根据类型描述生成 SQL 语句。字符串字面量在常量表达式函数中作为参数时,其值(const char*)不是常量表达式,但它的类型(char[N])是。因此,我们需要将字符串编码到类型系统中。

字面量范围(literal range) 将一系列值编码在模板参数中:

template<auto... Values>
struct literal_range {
    // 提供迭代器,迭代时从静态存储中返回值
    // size() 是编译时常量
};

使用用户定义字面量可以方便地创建此类范围:"table_name"_tc。这样,我们就能在常量表达式函数内部获取其大小等信息。

编译时连接视图

我们希望在编译时连接多个字符串:

constexpr auto create_table_sql = concat_to_array(
    "CREATE TABLE "_tc,
    table_name,
    " (id INT)"_tc
);

concat_to_array 需要知道结果数组的大小,这依赖于输入范围的大小。在 C++26 之前,由于参数不是常量表达式,无法在函数体内直接计算其大小。C++26 放宽了约束,只要不访问参数的 odr-used 值,就可以在常量表达式中使用局部对象和参数。

因此,我们可以定义 constant-sized range 概念,表示即使只有该类型的运行时引用,也能在常量表达式中获取其大小。

template<typename R>
concept constant_sized_range = std::ranges::sanged_range<R> &&
    requires { { std::type_identity<std::ranges::size(std::declval<R&>())>() } -> constant; };

利用这一点,可以实现 concat_to_array

template<constant_sized_range... Rs>
constexpr auto concat_to_array(Rs&&... rs) {
    constexpr std::size_t total_size = (std::ranges::size(rs) + ...);
    std::array<char, total_size> result{};
    // ... 复制逻辑
    return result;
}

异构生成器与元编程

标准范围要求所有元素类型相同。但为了编译时元编程,我们需要处理类型序列。异构生成器(heterogeneous generator) 只提供 for_each_while 接口,其元素类型可以不同。

std::tuple 就是一个典型的异构生成器。我们可以为其实现 for_each_while,对元组中的每个元素(可能类型不同)调用接收器。

这开启了强大的编译时元编程能力:

  • 过滤类型:可以创建一个过滤视图,根据类型特征(如 std::is_integral_v)在编译时跳过某些类型的元素。
  • 编译时索引:结合 std::index_sequence,可以在迭代时获得编译时索引值。
  • 类型列表算法:将类型列表视为范围,使用范围算法进行变换、过滤等操作。

例如,解码所有 256 个字节码的操作函数:

constexpr auto opcode_table = std::views::iota(0, 256)
    | std::views::transform([]<std::uint8_t I> {
        return +[] { /* 执行操作码 I 的函数体 */ };
      })
    | to_array; // 生成 std::array<FuncPtr, 256>

实现 concat_nonempty_with

回到同事的问题:连接一系列字符串,忽略空字符串,并用分隔符连接。最初的实现基于 joinfilter,但丢失了编译时大小信息。

通过引入 元组生成器(tuple generator) 概念(一种支持编译时索引访问的异构生成器),我们可以为 filterjoin 视图实现编译时大小计算:

  • filter 的编译时大小:基于类型谓词,在编译时计算满足条件的类型数量。
  • join 的编译时大小:对于元组生成器,在编译时对每个内部范围的大小求和。

最终,concat 可以被视为 join 的特例(连接一个元组中的多个范围)。经过一系列库基础设施的更新后,concat_nonempty_with 可以用纯粹的范围算法实现,并且完全在编译期工作,代码与手写的元编程版本逻辑等价,但更清晰。

标准中的未决问题:无限范围

标准中关于“范围”的定义要求存在一个哨兵(sentinel),并且通过有限次递增迭代器可以到达该哨兵。根据此定义,像 std::views::iota(0u) 这样的无限范围是 无效范围,对其调用任何范围算法(包括 .begin())理论上都是未定义行为。

这显然不符合直觉和实际使用。我们需要更精细的定义来区分:

  1. 真正无限范围:如 std::views::repeat(42)std::views::iota(0u)(无符号整数不会溢出)。
  2. 有界但未知范围:如 std::views::iota(0)int 会溢出,行为未定义),或指向数组的指针与 unreachable_sentinel 构成的子范围。
  3. 无效范围:如两个无关容器的迭代器构成的子范围。

澄清这些定义有助于:

  • 使 std::views::iota 等常用视图合法化。
  • 为并行算法提供优化可能(例如,当连接一个有限范围和一个无限范围时,结果大小就是有限范围的大小)。
  • 允许视图进行优化(例如,对无限范围进行 take 操作时可以省略边界检查)。

总结

本节课中我们一起学习了 C++ 范围库可能的发展方向。

我们首先探讨了通过引入 for_each_while 定制点和块处理机制来优化性能,这能将复杂的状态机转换为高效的循环,并支持对连续内存块进行批量操作。

接着,我们研究了如何利用 C++26 的常量表达式放宽约束,实现编译时大小范围,并结合异构生成器进行强大的编译时元编程,使得像 concat_nonempty_with 这样的操作可以用优雅的范围语法表达并在编译期完成。

最后,我们指出了当前标准中关于无限范围定义的模糊之处,以及澄清该定义带来的好处。

一些概念,如 conditional_rangefor_each_while,非常实用且易于集成,有望进入标准。而像完整的异构生成器这样的概念,由于与现有模型差异较大,可能更适合通过未来的反射功能来实现。

013:注意差距(你的代码与你的工具链之间) 🛠️

在本教程中,我们将探讨C++开发中工具链的优化实践。内容主要分为三个部分:如何处理庞大的调试符号、如何实现高效的构建缓存,以及介绍几个每位开发者都应了解的工具。我们将深入浅出地讲解核心概念,帮助你提升开发效率。

调试符号与银河系漫游指南 🪐

上一节我们概述了本教程的主要内容,本节中我们来看看调试符号的管理。当你的项目代码量达到数百万行时,即使只是降低优化级别或添加调试符号,也可能使二进制文件大小超过链接器默认的约2GB限制。

链接器会因此报错,但这本质上掩盖了真正的问题:二进制文件内部的地址映射不应取决于优化开关或是否包含调试符号。

一个简单的解决方案是使用调试信息分离。这指示编译器将所有调试符号输出到一个完全独立的文件中,只在主二进制文件中添加对该文件的引用。

以下是GCC和Clang中启用此功能的编译选项:

-g -gsplit-dwarf

编译器会为每个输出的目标文件生成一个.dwo文件。主二进制文件的大小将降至不包含任何调试符号时的约10%到15%,这为二进制文件中的其他内容留出了充足空间。

这项技术已存在约十年,自GCC 4.8和Clang 3.8/3.9起便已支持。它不需要任何后处理步骤或额外的IDE设置,对于软件交付尤其有用:你可以交付给客户一个完全相同的二进制文件,并在调试时使用,因为它包含了对调试信息的引用,而无需交付整套调试符号。

此外,还有一些工具可以将所有.dwo文件打包成单个档案,便于归档和未来使用。

调试体验与你所熟知的几乎完全一致,只需将调试器指向独立的调试信息文件即可。

总结来说,当链接器因符号过多而“尖叫”时,答案很少是“42”。请使用调试信息分离功能,无需惊慌。

构建缓存:搞定你的缓存 ✅

现在,我们将从遥远的银河系冒险回到现实,探讨构建中的缓存技术。缓存本身在多个层面上都相当棘手,而在构建上下文中,要使其既可靠又高效,需要考虑许多细节。

核心问题在于确定何时可以重用缓存的构建产物,尤其是在涉及多种不同配置时。例如,你的计算机可能与同事的略有不同,这使得在他们之间重用缓存产物变得困难。你需要在“获得足够多的缓存命中”和“平衡不同环境差异”之间找到平衡。

我们不深入复杂的理论,而是快速概述一种解决这些问题的实践方法。

首先,我们通过将源代码镜像到一个确定的、不变的位置,为构建创建一个干净、可复现的起点。这确保了源代码在每个人的计算机上构建时都位于完全相同的位置。

这个过程会生成一个基于已镜像源代码内容的,该键稍后用于访问缓存。我们通过一些符号链接技巧,使得最终用户无需感知文件复制的编排过程,体验保持不变。

我们对所谓的环境描述进行类似处理。这结合了CMake工具链文件和对特定执行环境的描述。控制环境至关重要,在最简单的情况下,这个执行环境规范就是一个Dockerfile,它包含了构建所需的所有工具、编译器等指令。

通过以上步骤,我们为可复现的构建奠定了良好基础:源代码位于已知且不变的位置,CMake文件也位于受控的已知位置,并且所有工具都在一个容器中。

在此基础上,我们可以讨论实际的缓存策略。在我们的产品中,我们结合了多个具有不同目标和操作模式的缓存。

  • L1缓存:存储整个项目的构建树和依赖项的安装树。我们以版本跟踪的方式使其可访问,将版本控制历史映射到缓存状态。这样,在进行下一次增量构建或频繁切换分支时,可以从最接近你或同事之前工作的基础开始构建。
  • L2缓存:在更细粒度上运行,通过查看创建单个输出所需的所有输入(如头文件、其他目标文件)来缓存构建过程的输出,并使用这些输入作为键来访问产物。

真正的魔力在于我们可以结合这两种技术。由于系统对单个翻译单元的实际执行位置提供了强有力的保证,因此可以在多个非常不同的执行环境之间共享缓存,无论是在本地Docker容器中构建,还是在运行相同镜像的云资源或成千上万个核心的云计算机上构建。缓存内容可以在所有这些上下文之间轻松共享,且对用户完全透明。

必备工具推荐 🔧

上一节我们探讨了构建缓存,本节中我们来看看几个能极大提升效率的开发工具。首先是构建性能分析工具。最近有多少人对自己的构建进行过性能分析?如果你做过,恭喜你!我们强烈建议你尝试这个。

这类工具旨在帮助你精确定位构建中异常缓慢的部分。对于使用Clang的用户,你们拥有世界上最好的工具。你可以通过-ftime-trace选项获得函数级别翻译单元级别的计时信息,这能轻松锁定让你头疼的模板实例化等问题。

GCC对相同开关-ftime-trace的支持稍欠成熟,但我们希望未来它能得到完善。在更高抽象层次上,你可以从Ninja构建日志中获取类似信息,也有像ninja-tracing这样的工具可以轻松提供大量信息。即使是MSVC用户也没有被遗忘,微软的VCPerf工具虽然使用稍显繁琐,但结果非常出色。

我的建议是:现在就去做。获取这些信息非常简单,而且极其有用。你将更清楚地了解编译器在你的构建中实际在做什么。它能帮助你判断是受CPU限制还是I/O限制,这是一个信息金矿。此外,这些功能很快也将集成到我们的云端构建仪表板中,如果你使用远程执行或CMake,只需点击即可获得。

第二个推荐的工具是Bloaty McBloatface(Google开发的一个二进制文件大小分析器)。它是一个二进制文件大小分析器,能解析你的ELF/Mach-O文件,告诉你各个段和符号占用了多少空间。

它有一个非常棒的功能:差异分析。你可以将同一二进制文件的两个版本扔给它,它会告诉你哪些部分发生了变化。我认为每个人都应该将其集成到CI/CD流水线中,让它始终与最新的发布版本进行比较。你可能需要在剥离了调试符号的发布版本上进行此操作。这对于早期发现错误的哈希嵌入或其他类似问题非常有用。当你真的想知道“那块‘饼干’去哪了”时,它会非常棒。

总结 📝

在本教程中,我们一起学习了三个关键的C++工具链优化主题。

  1. 调试符号管理:通过使用-gsplit-dwarf(调试信息分离)技术,可以将庞大的调试符号移至独立文件,显著减小主二进制文件体积,避免链接器限制,并简化交付和调试流程。
  2. 构建缓存策略:通过镜像源代码、控制构建环境(如使用Docker),并结合L1(项目/依赖树级别)和L2(细粒度输入/输出级别)多级缓存,可以实现高效、可复现、可在不同环境间共享的构建缓存,极大加速构建过程。
  3. 实用工具推荐
    • 使用-ftime-trace(Clang)或类似工具进行构建性能分析,定位编译瓶颈。
    • 使用Bloaty McBloatface进行二进制文件大小分析和差异比较,监控代码体积变化。

我的呼吁是:提升你的工具使用水平。这是对你时间和日常开发幸福感的一项极佳投资。希望本教程对你有所帮助,祝你享受高效的编码过程。

014:缺失(以及未来)的 C++ 范围概念

概述

在本节课中,我们将探讨 C++ 标准库中当前缺失的一些范围(range)概念,这些概念在 Think-cell 库中已经实现,并可能对未来 C++ 标准有所启发。我们将重点关注两个核心问题:性能优化元编程,并讨论一些悬而未决的标准规范问题。课程内容将尽可能简单直白,以便初学者理解。


第一部分:性能优化问题

上一节我们介绍了课程概述,本节中我们来看看第一个核心问题:如何通过新的范围概念来优化性能。

示例问题:文件处理与规范化

我们以一个具体问题开始:读取文件并将所有制表符(\t)规范化为四个空格。使用范围管道(range pipeline)可以简洁地实现:

auto result = std::ranges::to<std::string>(
    read_file(path)
    | std::views::transform([](char c) -> std::string {
        return c == '\t' ? "    " : std::string(1, c);
      })
    | std::views::join
);

read_file 函数返回一个输入范围(input range)。transform 视图将每个字符转换为一个 std::string(单个字符或四个空格),join 视图将这些字符串扁平化为一个字符范围,最后 std::ranges::to 将其转换为 std::string

大小(Sized)范围的优化与局限

std::ranges::to 在构造容器时,如果输入范围是 sized range(已知大小),则可以预先分配(reserve)内存,然后直接拷贝,这非常高效。如果是 forward range,则可以使用 distance 计算大小,虽然需要遍历一次,但仍比多次重新分配(push_back)高效。然而,对于纯 input range,它只能遍历一次,无法预先分配内存,只能使用低效的 push_back

在我们的管道中,read_file 可能是一个 sized input range(例如,可以通过文件系统查询文件大小)。但经过 transformjoin 后,我们失去了大小信息,因为 join 需要将内部多个字符串的大小相加,而转换函数可能将单个字符扩展为多个字符,我们无法预先知道总大小。

近似大小(Approximately Sized)范围概念

在许多情况下(例如,格式良好的文件不含制表符),转换后的范围大小与原始范围大小近似相同。如果能够传播这种近似大小信息,我们仍然可以进行有效的预分配。

C++26 引入了 approximately sized range 概念和 reorin(原为 approximate_size)函数。其核心思想是:如果一个范围是 sized range,那么它也是 approximately sized range,reorin 返回其精确大小;否则,可以通过 ADL 查找自定义的 reorin 实现来获取近似大小。

许多视图(如 transformreversetakedrop)可以传播近似大小。但 join 视图通常不能,因为内部各子范围的大小关系不明确。

为了实现传播,我们可以创建一个包装器 approx_size_unchanged,它包装一个范围并声明其大小在转换后保持不变。以下是其使用方式:

auto pipeline = read_file(path)
              | std::views::transform(/* ... */);
auto approx_sized_pipeline = approx_size_unchanged(pipeline | std::views::join);
auto result = std::ranges::to<std::string>(approx_sized_pipeline);

其实现涉及范围适配器闭包对象,虽然代码有些复杂,但核心思想是让 join 在特定条件下传播外部范围的近似大小提示。

拉取模型(Pull Model)的状态机开销

当前 C++ 范围基于拉取模型(pull model)和迭代器(iterator)。消费者通过迭代器完全控制遍历过程(例如,前进、跳过、重复读取)。这种灵活性对于算法如 find 是必要的,但对于简单的遍历(如 for_eachcopy)则过于复杂。

使用迭代器实现的范围管道(如 transform -> join)在底层会形成一个状态机,而不是直观的嵌套循环。例如,join 的迭代器需要管理外层迭代器和内层迭代器两种状态。编译器优化器需要内联并识别出这个状态机实际上等价于嵌套循环,但这并非总是有效,可能带来开销。

推送模型(Push Model)与 for_each_while 定制点

对于不需要消费者控制的遍历,推送模型(push model)更高效。生产者主动将值“推”给消费者回调函数。

我们可以引入一个名为 for_each_while 的定制点(customization point)。它接受一个范围和一个“接收器”(sink)函数,遍历范围并对每个元素调用接收器。如果接收器返回 false,则停止遍历。

其默认实现使用基于迭代器的循环。但关键是可以为特定视图定制实现,从而直接表达底层循环逻辑,避免状态机。

以下是几个视图的定制实现思路:

  • transform_view: 在其底层范围上调用 for_each_while,并将每个元素通过转换函数后再传递给接收器。
  • join_view: 直接实现为两层嵌套循环:外层遍历外部范围,内层遍历每个内部范围元素,并传递给接收器。这完全避免了迭代器状态机。
  • read_file 迭代器: 实现为读取缓冲区,然后对缓冲区中的每个字符调用接收器。

当算法(如 std::ranges::copystd::ranges::for_each)使用 for_each_while 而不是基于迭代器的循环时,经过编译器内联,最终生成的代码与手写的命令式嵌套循环完全相同,效率极高。

生成器(Generators)的局限

你可能会想,C++23 的协程生成器(std::generator)是否解决了这个问题?实际上,生成器仍然是拉取模型,编译器会为我们生成状态机。它提供了更自然的语法(co_yield),但可能仍有性能开销和额外的分配。for_each_while 是一种库解决方案,可以在当前语言标准下提供推送模型的性能。

处理连续内存块(Chunking)的优化

许多范围虽然不是连续范围(contiguous range),但由连续的“块”组成。例如:

  • std::deque: 由多个连续数组块链接而成。
  • 连接两个 std::vector: 两个独立的连续内存块。
  • read_file: 读取到连续的缓冲区。
  • join 一个 std::span 的范围: 每个 span 都是连续的。

如果我们能识别并利用这些连续块,就可以用更高效的方式处理数据,例如使用 memcpy 复制整个块,而不是逐个元素复制。

幸运的是,for_each_while 定制点天然支持块处理。在 join_view 的定制实现中,我们对外部范围的每个元素(一个内部范围)调用 for_each_while。如果这个内部范围是连续的(如 std::span),那么这次调用就是在处理一个连续块。

我们可以扩展 for_each_while:在调用范围的定制实现之前,先检查接收器是否有一个 chunk 成员函数。如果有,则尝试将整个连续范围作为一块传递给 chunk 函数;否则,回退到逐个元素调用接收器。

例如,为 std::string 实现一个接收器,其 chunk 函数可以接受一个连续范围并调用 append,这比逐个字符 push_back 高效得多。

通过结合 for_each_while 和块处理,我们可以写出既优雅又高性能的范围代码,直接处理连续内存块,避免迭代器开销。

优化后的文件处理方案

回到最初的例子,transform 将每个字符转换为一个小字符串,导致 join 后只有许多很小的连续块,优化收益有限。

我们可以重构算法:使用 split 在制表符处分割,然后用 join_with 插入分隔符。如果文件没有制表符,结果就是一个大连续块。join_with 可以传播连续性。但 split 需要前向范围(forward range),而 read_file 是输入范围。

更好的设计是提供 read_file_buffers,它返回一个 std::span 的范围(每个 span 代表一个文件缓冲区)。然后,我们在这个“缓冲区范围”上进行 transform(处理每个缓冲区)、splitjoin_with,最后再将所有缓冲区 join 起来。这样,内部操作都在大连续块上进行,非常高效。

read_file 可以基于 read_file_buffers 实现,只需一个特殊的 for_each_while 接收器,将每个缓冲区作为单个元素转发。


第二部分:元编程问题

上一节我们探讨了通过推送模型和块处理来优化性能,本节中我们来看看如何利用范围概念进行编译期元编程。

编译期字符串与类型编码问题

假设我们想用 DSL 描述数据库模式,并在编译期生成 SQL 语句。例如:

auto stmt = create_table<Person>();

create_table 需要生成 "CREATE TABLE Person (...)" 这样的字符串。

这里有两个挑战:

  1. 编译期上下文: 在 consteval 函数中,函数参数的值在函数体内不是常量表达式(即使函数是 consteval 的)。我们无法基于参数值进行实例化。解决方案是将必要信息编码到参数的类型中,因为类型在函数体内是常量表达式。
  2. 字符串字面量sizeof("ABC") 是 4(包含空终止符),而不是字符串长度 3。我们需要一种能正确表示编译期字符串长度的类型。

字面量范围(Literal Range)

我们将这两个问题结合,定义 literal range。它是一个模板,将一系列值编码到类型中。迭代时,这些值被填充到一个静态存储区。这样,我们就能得到一个类型,其 size() 在编译期是常量表达式,即使在一个 consteval 函数内部也能使用。

编译期大小(Constexpr Size)范围概念

从 C++26 开始,规则有所放宽:只要不访问任何运行时的值(即,仅依赖类型的属性),就可以在常量表达式中使用局部对象甚至函数参数。

这意味着,对于一个 literal range,或者一个其 size() 不访问元素值(仅依赖类型)的视图,我们可以在常量表达式中调用其 size(),即使我们只有一个该类型的运行时引用。

我们可以定义一个概念 constexpr-sized range,它满足 sized_range,并且其 size() 在给定一个该类型的(可能无效的)引用时,是常量表达式。

利用这个特性,我们可以实现编译期连接的 to_array

template <constexpr-sized_range R>
constexpr auto to_array(R&& r) -> std::array</*...*/, constexpr_size<R>> {
    // 使用 constexpr_size<R> 作为数组大小,并在编译期拷贝元素
}

这样,连接字符串字面量的代码就能在编译期高效运行。

异构生成器(Heterogeneous Generators)与元编程

标准范围是同质的(homogeneous):所有元素类型相同。但有时我们需要处理异构的元素序列,例如一个包含不同类型值的 std::tuple

std::tuple 不是范围,因为它没有迭代器。但是,我们可以为它实现 for_each_while!我们可以用一个泛型 lambda 遍历 tuple,lambda 会对每种不同类型的元素进行实例化。我们称这种只有 for_each_while 而没有迭代器的类型为 生成器(generator)。如果生成器输出多种类型,则称为异构生成器

异构生成器非常强大,可以用于编译期元编程:

  • 遍历索引序列std::make_index_sequence 可以作为生成器,在编译期提供每个索引值。
  • 遍历类型列表: 我们可以有“类型范围”,并使用范围算法进行类型层面的操作(如过滤、转换)。
  • 编译期查找表生成: 例如,遍历 0 到 255 的索引,为每个索引编译期生成一个解码函数指针,然后存入数组。

实现编译期 concat_non_empty_with

回到数据库的例子,我们需要 concat_non_empty_with:连接一系列范围,忽略空的范围,并在中间插入分隔符。最初的实现使用 make_range 将参数包转为范围,然后 filter 掉空范围,最后 join。但这会丢失编译期大小信息。

问题出在 make_range。如果它返回 std::span 或类似物,会丢失输入范围的个体类型信息。我们需要一个能保持各范围类型的结构——std::tuple。但 tuple 不是同质范围。

解决方案是使用异构生成器。我们将参数包存储为 tuple,并为其实现 for_each_whilefilter 视图也可以为异构生成器实现编译期大小的版本:它检查每个元素的类型(编译期信息),并统计满足谓词(仅基于类型)的元素数量,这个计数是编译期常量。

类似地,join 视图对于由异构生成器组成的外部范围,其大小可以在编译期通过将各内部范围的大小相加得到(这不再是循环,而是折叠表达式)。

最终,我们发现 concat(连接一个 tuple 中的多个范围)本质上就是 join 一个 tuple!因此,concat_non_empty_with 可以通过过滤掉空范围后,再 join 这个 tuple 来实现。所有操作都发生在类型层面,最终生成的编译期代码与手写的复杂元编程代码完全相同,但前者使用了声明式的范围接口,更加清晰。


第三部分:未解决的标准规范问题

上一节我们看到了范围在元编程中的潜力,本节最后,我们简要讨论一个 C++ 标准中关于范围定义的悬而未决问题。

无限范围(Infinite Ranges)与未定义行为

考虑以下代码,它查找大于某个阈值的最小斐波那契数:

auto fib = std::views::iota(0)
         | std::views::transform(fibonacci)
         | std::views::take_while([threshold](auto i) { return i <= threshold; });

根据当前 C++ 标准,一个范围由迭代器 i 和哨兵 s 定义,如果存在有限的 ++i 操作序列能使 i == s,则称 s 可从 i 到达(reachable)。标准还规定,对无效范围调用库函数是未定义行为。

对于 std::views::iota(0u)(无符号整数),其哨兵 std::unreachable_sentinel_t不可到达的。因此,根据定义,这个范围是无效的。那么,在其上调用 begin()take_while 或任何算法,按照字面解读,都是未定义行为。这显然不是设计意图。

定义“无限”

我们需要更精确地定义“无限范围”:

  • 真无限范围: 无论进行多少次 ++i,都不会导致未定义行为,且 i == s 永远为 false。例如 std::views::repeat(42)std::views::iota(0u)(无符号环绕在语言中是定义良好的)。
  • 无界范围i == sfalse,但最终 ++i 会导致未定义行为(例如有符号整数溢出)。我们不知道其有效前缀的边界。例如 std::views::iota(0)(有符号 int)。
  • 潜在无效范围: 如 subrange{arr+10, arr} 或跨越不同容器的迭代器对。这些应该被禁止。

当前标准缺乏这些区分,导致像 std::views::iota 这样有用的设施处于尴尬的灰色地带。需要修正标准措辞,明确允许“无限范围”和“无界范围”作为有效范围,并定义在其上操作的语义(例如 distance 的返回值)。

为何需要修正

修正这个问题有实际好处:

  1. 并行算法: C++26 的并行算法要求输入是 sized range。如果其中一个范围是真无限的,算法可以选取另一个(有限)范围的大小作为界限。
  2. 优化: 对于真无限范围,take 视图可以省略边界检查。
  3. 规范清晰: 消除未定义行为,使标准库更健壮。

这项工作正在进行中,是未来 C++ 标准演进的一部分。


总结与展望

本节课我们一起学习了 C++ 范围生态系统中一些缺失但很有价值的概念:

  1. 性能优化

    • for_each_while 定制点: 推送模型的关键,能消除迭代器状态机开销,直接生成高效循环。
    • 块处理(Chunking): 基于 for_each_while,允许算法高效处理连续内存块。
    • 近似大小范围: 允许传播大小提示,改善容器构造性能。
  2. 元编程

    • 编译期大小范围: 利用 C++26 新规则,使范围属性在编译期可用。
    • 异构生成器: 扩展范围概念以处理不同类型序列, enabling powerful compile-time meta-programming with a range-like interface.
  3. 标准规范

    • 无限范围: 当前标准定义存在缺陷,导致常用设施处于未定义行为状态,需要修正。

哪些可能进入标准?

  • conditional_range(一个返回两种视图之一的视图)非常有用,应该加入。
  • for_each_while 定制点对性能至关重要,迫切需要。
  • 编译期大小范围的概念是 C++26 规则的直接应用,应该标准化。
  • 无限范围的定义问题必须解决。
  • 异构生成器是一个更大的范式转变,可能更适合作为库扩展或在反射特性辅助下实现。

C++ 范围的演进仍在继续,社区欢迎对这些想法提出提案和讨论。通过引入这些概念,我们可以使 C++ 范围更强大、更高效,同时保持其声明式和组合式的优雅。

015:使用 C++23 中的单子操作编写安全、可读的代码

在本节课中,我们将要学习如何使用 C++23 标准库中的单子操作来编写更安全、更易读的代码。我们将从分析传统代码的问题开始,逐步介绍函子和单子的核心概念,并通过具体的代码示例展示如何应用它们来简化错误处理、数据转换和异步操作。

概述:为什么需要新的编程范式

想象一下,你遇到了一段传统的 C++ 代码。这段代码将核心逻辑与错误处理混杂在一起,使得一眼难以看出函数的主要功能。此外,它的返回类型只是一个表示成功与否的布尔值,而真正的返回值则隐藏在参数链的末尾。这种风格不仅难以阅读,也容易出错。

上一节我们介绍了传统代码的痛点,本节中我们来看看有哪些替代方案。使用异常是一种选择,但它要求你在调用栈的某处抛出,在另一处捕获,如果忘记捕获就会导致程序崩溃。而且,关于是否应该将异常用于主要错误处理,业界一直存在讨论。

另一种方法是使用类的成员变量作为错误标志,但这只是将检查代码移动了位置,并未真正解决问题,反而创建了一个共享的可变状态,这对局部推理和线程安全都是有害的。

函子:分离“做什么”与“在哪做”

让我们退一步,先看看返回类型的问题。我们可以将成功标志与实际输出合并到一个 std::optional 中。如果 optional 为空,则意味着失败;如果它持有值,我们就可以使用它。这样,我们就能扔掉所有的 if 语句,让 std::optional 为我们处理错误。

这种方式清晰地展示了操作序列,并且 optional 为我们处理了错误,我们不会忘记它。这就是单子操作在起作用。

在深入单子之前,我们需要先理解函子,因为每个单子都是一个函子。

函子是什么?

函子可以被看作一个“魔法盒子”或容器。你放入一袋建筑材料,送入一个带有图纸的工人,然后就能取出一袋房子。在代码中,函子是一个类,它持有我们想要在下一步转换的值作为成员,并通常有一个名为 transform 的成员函数。这个函数接受一个能转换单个参数的函数,然后函子将这个函数应用到整个容器(例如 vector)上。

以下是函子必须遵循的两条规则:

  1. 组合规则:如果我们能将两个独立的函数组合成一个,并将其应用到函子上,那么结果必须与我们在管道中分阶段应用这两个函数得到的结果完全相同。
  2. 恒等规则:应用恒等函数(即接收一个参数并原样返回的函数)必须得到与初始输入完全相同的结果。这保证了函子不能使用任何内部或外部状态来影响结果,只能使用你传递的函数。

标准库中的函子:Ranges 视图

我们不需要自己实现函子。C++ 标准库的 Ranges 提供了 std::views::transform,它就是一个函子。我们可以使用管道操作符 | 来构建清晰的数据处理管道。

auto result = input_range
            | std::views::transform(function1)
            | std::views::transform(function2);

这种方式表达了清晰的意图。但需要注意的是,视图(views)通常是惰性求值的,并且不拥有底层数据,你必须注意数据的生命周期。视图返回的类型是编译器相关的,通常需要使用 auto,这可能会影响代码的模块化。

作为替代,C++23 提供了 std::ranges::to 来将视图立即转换为容器,但这会失去惰性求值的特性并可能分配内存。

使用函子时的安全陷阱

使用 Ranges 视图时,一个常见的陷阱是悬垂引用。考虑以下场景:

struct Entry { int id; std::string text; };
Entry get_nearest_entry(int id); // 返回值
std::string& get_text(Entry& e); // 返回引用

std::vector<int> ids = {1, 2, 3};
auto view = ids
          | std::views::transform(get_nearest_entry) // 返回临时 Entry 对象
          | std::views::transform(get_text); // 试图获取临时对象成员的引用 -> 悬垂引用!

get_nearest_entry 返回一个临时的 Entry 对象,而 get_text 返回其成员 text 的引用,这就产生了悬垂引用。使用地址清理器(如 ASan)可以帮助发现这类问题。

如何传递函数给函子和单子

开始使用这种编程风格时,有时很难找到传递函数的正确方式。以下是一些便捷的方法:

  • 自由函数/静态成员函数:只要没有重载,可以直接传递函数名。
  • 内联 Lambda 表达式:在需要的地方编写简短的代码,非常适合简单、不重复使用的逻辑。Lambda 对于解决重载问题或绑定额外参数非常有用。
  • 函数对象:带有 operator() 的类,提供比 Lambda 更多的灵活性。
  • std::function:用于从外部注入行为,允许用户自定义算法。它接受任何可调用对象。
  • 模板参数:使用模板参数和 std::invocable 概念可以更好地控制签名并获得更好的运行时性能,但会增加代码复杂性。

总结:Lambda 表达式非常灵活,是传递可调用对象的“瑞士军刀”。std::function 则适合用于注入行为,为你提供了一个良好的入门工具集。

单子:函子的增强版

现在我们已经了解了函子,可以讨论单子了。单子拥有函子的所有属性,外加一些额外能力。简单来说(从实用角度),单子是一个能够解开一层嵌套的函子

例如,我们有一个树形结构,节点有一个 get_children 函数,它返回一个 std::vector<Node>。如果我们对一组节点调用 get_children,会得到一个 vector<vector<Node>>。为了继续处理,我们需要将这个嵌套的向量“展平”成一个 vector<Node>。这个“展平”或“连接”的操作就是单子提供的核心能力。

单子通常将 transform(映射)和 join(连接)操作结合在一个函数里,比如 and_then

示例:使用单子处理编译器诊断信息

假设我们有一个项目,包含多个文件,每个文件编译后可能产生多个诊断信息。传统的循环嵌套写法意图模糊。使用单子风格,我们可以这样写:

// projects 是一个 vector<Project>
auto all_diagnostics = projects
                     | std::views::transform(get_files_in_project) // vector<vector<File>>
                     | std::views::join                            // vector<File> (单子操作:展平)
                     | std::views::transform(compile_file)         // vector<vector<Diagnostic>>
                     | std::views::join                            // vector<Diagnostic> (单子操作:展平)
                     | std::views::transform(print_diagnostic);

这段代码清晰地表达了数据流:获取所有项目的所有文件,编译它们,收集所有诊断信息,然后打印。其逻辑顺序与传统循环完全相同,但意图更清晰。

纯函数:安全性的基石

为了安全地使用函子和单子(特别是 Ranges),纯函数是关键。纯函数有两个特性:

  1. 确定性:对于相同的输入,总是返回相同的输出。
  2. 无副作用:不修改函数栈外的内存,不触发外部 I/O 等操作。

纯函数有很多优点:易于推理、易于单元测试、可以安全地用于并发环境。虽然现实中完全纯的函数很少,但我们应该尽可能让函数“足够纯”,这能显著提高代码的安全性。

回顾之前导致悬垂引用的 Lambda 例子,问题在于它通过引用捕获了外部状态。通过改为按值捕获,我们消除了这个副作用,使函数更接近纯函数,从而解决了问题。

错误处理:std::optionalstd::expected

在生产代码中,错误处理不可避免。如果你不想或不能使用异常,std::optionalstd::expected (C++23) 是很好的选择。

使用 std::optional

将可能失败的函数返回值改为 std::optional<T>。成功时返回包含值的 optional,失败时返回 std::nullopt。然后可以使用 and_then 来构建链式调用:

std::optional<int> get_numeric_value(const Cell& c);

auto result = get_element(db, key)
            .and_then(get_table)          // 如果上一步成功,调用此函数
            .and_then([&loc](const Table& t) { return get_cell(t, loc); }) // 需要额外参数
            .and_then(get_numeric_value)  // 继续链式调用
            .transform(is_negative);      // 此函数不失败,用 transform
// result 是 std::optional<bool>

and_then 会在前一个 optional 有值时调用你给的函数,否则短路整个链条。transform 则用于那些不会失败的操作。

使用 std::expected 获得错误上下文

std::optional 只能告诉我们失败了,但不知道原因。std::expected<T, E> 可以携带错误信息 E

std::expected<Table, MyError> get_table(const Element& e);

auto result = get_element(db, key)
            .and_then(get_table)
            .and_then([&loc](const Table& t) { return get_cell(t, loc); })
            .and_then(get_numeric_value)
            .transform(is_negative)
            .or_else([](MyError err) { // 只在错误时调用
                log_error(err);
                return std::unexpected(err); // 可选择传递或转换错误
            });

选择第一个成功的操作:or_else

有时我们需要尝试一系列操作,直到第一个成功。这可以用 optionalor_else 来实现:

std::optional<Language> get_app_language() {
    return get_language_from_command_line()
          .or_else(get_language_from_registry)
          .or_else(get_language_from_env_var)
          .or_else([]{ return std::optional(Language::English); }); // 默认值
}

严格来说,or_else 并不是单子操作(因为它不涉及类型转换或展平),但它非常实用。

处理常见的编译器错误

使用单子操作时,你可能会遇到不熟悉的编译器错误。以下是一个快速排查清单:

  1. 定位错误阶段:如果错误信息难以理解,尝试拆分管道,直到定位到具体出错的阶段。
  2. 检查重载函数:如果使用了重载的函数名,用 Lambda 包装它来帮助编译器选择正确的重载。
  3. 检查类型匹配:对于 Lambda,尝试用具体的类型替换 auto 参数,明确写出返回类型。
  4. 检查 join 次数:确保你展平了正确层数的嵌套。
  5. 慎用 const:在泛型函数中,避免使用 const 引用,考虑使用转发引用或按值传递(如果拷贝成本低)。
  6. 区分 and_thentransform:确保在应该返回 optional/expected 的地方使用 and_then,在返回普通值的地方使用 transform

组合单子:更强大的抽象

单子本身很有用,但组合它们能实现更强大的功能。例如:

  • 延续单子:用于异步编程,可以将计算轻松转移到其他线程,并在完成后触发延续操作。虽然 C++26 可能有相关支持,但现在可以使用第三方库(如 concurrencpp)。
  • Writer 单子:用于无锁的跟踪和日志记录。它将跟踪信息作为计算的一部分收集起来,而不需要全局锁。
  • 组合它们:你可以将 Writer 单子与延续单子组合,实现带有无锁、按管道跟踪的并发计算。

性能考量

性能总是需要考虑的。基准测试表明:

  • Ranges 视图 vs 手写循环:在热路径上,高度优化的手写循环通常最快。Ranges 视图可能慢 2-13 倍,但仍比经典的、每次创建中间向量的循环快。使用 std::ranges::to 预先转换为容器会带来额外开销。
  • 错误处理机制:在成功路径上,各种方法(布尔标志、optionalexpected、异常)性能接近。在失败路径上,布尔标志最快,optional/expected 稍慢,异常(特别是在 Windows 上)可能非常慢。

关键点:如果你追求极致性能,手写特定优化代码是最好的。然而,单子操作能以合理的性能代价提供更清晰、更模块化的代码。你需要了解应用程序的热路径,并据此做出权衡。

总结与要点

本节课中我们一起学习了如何使用 C++23 的单子操作来编写更安全、更易读的代码。

  1. 函子和单子是有用的概念:函子帮助我们将通用的、重复性的代码(如“应用到容器”)提升出来,分离“做什么”和“在哪做”。单子在此基础上增加了处理嵌套数据结构(展平)的能力,使得构建复杂的数据处理管道成为可能。
  2. 纯函数有益于代码健康:它们使代码更安全、更易于测试。这个原则不仅适用于函子和单子,也适用于所有代码。
  3. C++23 的单子操作立即可用:你不需要深入理解背后的理论就能从这些工具中受益。在编写新代码时,可以考虑它们是否适合你的场景。
    • Ranges 视图:适用于输入输出是范围/容器的管道。
    • std::optional:适用于管道中部分或全部函数可能失败的场景。
    • std::expected:在需要提供详细错误信息时使用。
    • optional + or_else:适用于需要从一系列函数中获取第一个可用结果的场景。

这些工具将帮助你编写出更安全、更易读的代码。

016:C++库未来的10个基本特性 🚀

在本教程中,我们将学习马特乌什·普什在C++-On-Sea-2025大会上分享的关于如何改进C++库开发体验的18个关键特性。我们将探讨如何改进接口设计、提升编译时调试能力、获得更好的编译错误信息、增强安全性、优化模板工具以及更好地分发和消费库。内容面向初学者,力求简单直白。


改进接口设计 🛠️

上一节我们概述了课程内容,本节中我们来看看如何改进库的接口设计。

类型安全与概念

C++是一种类型安全的语言,但使用裸模板参数(如 typenameauto)会破坏这种安全性。它们就像是C++中的 void*,使得接口难以理解,错误信息不友好,并且容易在库的实现深处导致编译崩溃。

示例:不明确的接口

template<typename R1, typename R2>
auto operator/(const quantity<R1>& lhs, const quantity<R2>& rhs);

这个接口没有说明 R1R2 应该是什么,返回值是 auto,用户无法从声明中了解其行为。

使用C++20的概念可以显著改善这一点:

template<Quantity Q1, Quantity Q2>
  requires std::same_as<typename Q1::dimension, typename Q2::dimension>
auto operator/(const Q1& lhs, const Q2& rhs) -> quantity<...>;

概念在接口边界过滤了无效类型,提供了更好的编译时错误信息。

对称接口与值类别

模板参数存在“依赖”与“非依赖”的区别,这会影响参数初始化时的转换规则,可能导致接口不对称。

问题示例:不对称转换

template<typename R, typename Rep>
quantity(const Rep& value); // 非依赖参数,允许隐式转换
template<typename R, typename Rep>
quantity(const quantity<R, Rep>& other); // 依赖参数,转换规则更严格

如果传递一个可转换为 quantity 但不是 quantity 类型的对象,第一个构造函数可以工作,第二个则可能失败。

解决方案:使用概念统一参数类别

template<Quantity Q1, Quantity Q2>
  requires std::same_as<typename Q1::dimension, typename Q2::dimension>
auto operator+(Q1&& lhs, Q2&& rhs);

通过使两个参数都依赖于概念,我们确保了对称的转换规则。

契约(Contracts)

概念处理了编译时要求,但运行时要求(如前置条件)通常通过宏或注释来指定,这不直观且不是声明的一部分。

C++26将引入契约,允许我们在函数声明中直接指定运行时条件:

auto compute_speed(Quantity auto distance, Quantity auto time)
  [[pre: distance > 0 && time > 0]] -> Quantity auto;

契约将在调试构建中被检查,并可能为编译器优化提供依据,极大地改善了接口的自我描述性。

类类型作为非类型模板参数(NTTP)

C++20允许类类型作为非类型模板参数(NTTP),这是过去十年模板元编程最重要的改进之一。

示例:使用NTTP定义单位

using speed = quantity<dimension<length, 1>, dimension<time, -1>, unit<meter_per_second>>;
// 可以简化为:
quantity<isq::speed[km / h]> q;

每个参数(如维度、单位)现在都可以是NTTP,使得代码更直观、类型更丰富。

然而,NTTP要求类型是“结构类型”,即所有成员都是公开且不可变的,这牺牲了封装性。

问题:封装性牺牲

struct point {
    double value; // 必须为public,否则不能作为NTTP
};

用户可能直接访问这些本应是实现细节的公共成员。

未来解决方案:P2489提案
该提案允许具有私有成员的类作为NTTP,通过提供 to_meta_reprfrom_meta_repr 函数(可能利用反射)来实现序列化。这将解决封装性问题。


通用模板参数与概念 🔄

上一节我们介绍了接口改进,本节中我们来看看如何使模板参数和概念更加通用和灵活。

转发引用与概念

使用转发引用(auto&&)与概念结合时,需要注意推导出的类型会包含引用和cv限定符,这可能与概念期望的“纯净”类型不匹配。

问题示例:类型不匹配

template<Quantity Q>
void foo(Q&& q) { // q可能是 `quantity&` 或 `const quantity&`
    // 概念 `Quantity<decltype(q)>` 可能失败
}

通常需要编写 std::remove_cvref_t<decltype(q)> 来匹配概念。

概念模板参数

在C++20中,概念本身不能作为模板参数,这限制了代码的通用性。C++26将允许概念和变量模板作为模板参数。

C++20的局限

template<template<typename> typename Pred, typename... Ts>
constexpr bool all_of = (Pred<Ts>::value && ...); // 只能接受类型模板

为了接受值谓词或概念谓词,需要编写多个重载或包装器。

C++26的改进

template<template auto Pred, auto... Args>
constexpr bool all_of = (check<Pred, Args> && ...);

现在,Pred 可以是一个类型模板、变量模板或概念,Args 可以是类型或值。这使得编写真正通用的算法成为可能。

示例:统一 all_of 算法

template<template auto Pred, auto... Args>
constexpr bool all_of = (check_predicate<Pred, Args> && ...);

// 可以用于各种谓词
static_assert(all_of<std::floating_point, float, double>);
static_assert(all_of<std::is_negative, -1, -2.5>);
static_assert(all_of<Quantity, 1 * m, 2 * s>);

可推导的this(Deducing this)

C++23引入了“可推导的this”特性,它允许在成员函数中推导对象类型,简化了CRTP模式和一些泛型代码的编写。

传统CRTP模式

template<typename Derived>
struct base {
    void interface() {
        static_cast<Derived*>(this)->implementation();
    }
};

使用可推导的this

struct my_type {
    void interface(this auto&& self) {
        self.implementation();
    }
};

这使代码更简洁,并减少了样板代码。


编译时调试与错误信息 🐛

上一节我们讨论了模板的通用性,本节中我们来看看如何提升编译时调试体验和错误信息质量。

编译时打印

运行时调试可以使用打印和调试器,但编译时逻辑(尤其是consteval函数和模板元编程)没有调试器。最简单的调试方法是“打印”类型或值。

当前方法:静态断言或类型依赖

static_assert(false, “Check point”); // 硬错误,不灵活
using DebugType = T; // 在错误信息中查看T

这些方法不灵活,且可能中断编译。

未来希望:编译时诊断信息
提案P2741等旨在允许库作者在编译时生成警告、错误或笔记信息,并可通过编译器标志控制。

if consteval {
    std::compile_warn(“Unexpected path taken”, some_value);
}

这将极大帮助库开发者诊断复杂的模板实例化问题。

改进编译错误信息

泛型库的编译错误信息通常冗长晦涩。通过精心设计概念和约束,可以显著改善。

使用概念提供清晰错误

template<Quantity Q>
auto to_kilometers(const Q& q) -> quantity<kilometer, typename Q::rep>
  requires (Q::unit == meter && std::is_convertible_v<typename Q::rep, double>);

当调用不匹配时,编译器可以明确指出哪个约束未满足,例如“Q::unit (seconds) is not a unit of length”。

静态断言的局限
静态断言(static_assert)会导致硬错误,阻止SFINAE和概念测试,不适用于约束检查。它们更适合验证类不变量。

概念与异常结合(未来)
提案P2564允许在常量求值中抛出异常,结合“可修复的概念表达式”提案,可以实现优雅的错误信息传递。

template<Quantity Q1, Quantity Q2>
auto add(Q1 a, Q2 b) -> decltype(a + b)
  requires (check_preserving_conversion(a, b) || throw std::logic_error(“...”));

当约束失败时,可以抛出一个带有描述性信息的异常,该异常信息会出现在编译错误中,指导用户解决问题。

示例:理想的错误信息

error: no matching function for call to ‘add(meter, second)’
candidate template ignored: constraints not satisfied
note: because ‘unit_of_time’ is not convertible to ‘unit_of_length’
note: with message: cannot add quantities of different dimensions: length and time


安全性增强 🛡️

上一节我们探讨了编译时调试,本节中我们简要讨论如何通过语言特性增强库的安全性。

防止窄化转换

从浮点数到整数的转换是窄化转换,可能丢失信息。目前,标准库没有提供标准方法来检查转换是否值保留。

当前状况

int i = 3.14; // 编译通过,但有警告

库作者需要自己实现检查,例如使用 std::is_convertible_vstd::is_floating_point_v 的组合,但这不完善。

提案P2509:值保留转换
该提案引入特性 is_value_preserving_convertible,它会检查在特定平台上转换是否真的保留值(例如,intdouble 通常是值保留的)。

if constexpr (std::is_value_preserving_convertible_v<From, To>) {
    // 安全转换
}

这将帮助库作者编写更安全的算术运算和转换操作。

文本输入解析

C++有强大的格式化输出库(如 <format>),但文本输入解析仍然薄弱。

提案:文本扫描库
类似于 std::format 的逆向操作,一个标准化的扫描库(可能基于现有的开源库如 scanlib)将提供类型安全、可配置的输入解析。

int i; double d;
std::istringstream iss(“42 3.14”);
iss >> i >> d; // 传统方法,易错
// 未来可能:
std::scan_from(iss, “{} {}”, i, d); // 更安全、更灵活

这将补齐I/O库的短板。

Unicode与国际化的支持

在2025年,C++仍然缺乏对Unicode和国际化的一流支持。这是一个需要社区投入的重大领域,包括字符串编码、区域设置和文本处理工具。


模板工具与库分发 📦

上一节我们讨论了安全性,本节最后我们看看如何改进模板工具和库的分发方式。

变量模板的改进

变量模板比类模板更简洁、编译更快。然而,变量模板必须有一个主模板定义,即使没有合理的默认值。

问题:缺少默认值

template<typename T>
inline constexpr bool is_floating_point_v = std::is_floating_point<T>::value; // 有默认值

template<auto N>
inline constexpr bool is_positive = N > 0; // 对于非数值类型N,没有好默认值

提案P????(已废弃)曾建议允许 = delete 作为变量模板的主模板定义,以表示“无默认值,仅特化有效”。这需要有人重新推动。

关联命名空间与ADL

参数依赖查找(ADL)是编译时开销和歧义的来源之一。对于深度嵌套的模板类型(如发送器),ADL可能查找过多命名空间。

提案P2672:指定关联命名空间
该提案允许在类模板中显式指定哪些命名空间应参与ADL。

template<typename T, typename Alloc>
class my_map
    [[associates(std)]]
    [[associates(typename T::associated_namespace)]] {
    // ...
};

这可以精确控制ADL范围,提升编译速度并避免歧义。

类型排序与规范化

在编译时操作类型列表(如类型集合)时,经常需要对类型进行排序或规范化,以确保相同集合的不同表示(如 A, BB, A)产生相同的规范化类型。这有助于减少模板实例化,提高编译效率。

C++26新特性:std::type_identity_ordered
该特性提供了可移植的类型排序谓词。

using normalized = type_set_sort_t<type_list<B, A, A>, std::type_identity_ordered>;
// normalized 应为 type_list<A, B>

这对于实现 std::variant 的规范化、策略排序等非常有用。

库分发:通用包描述格式

分发C++库通常需要编写复杂的CMake/Conan配置文件。不同构建系统间的配置重复且容易出错。

提案P????:通用包描述格式(CP)
该提案引入一种与构建系统无关的包描述文件(如 package.cp.json),由构建工具(如CMake)自动生成。它包含了库的所有元数据(目标、依赖、编译标志等)。

// CMakeLists.txt
add_library(mylib ...)
install(TARGETS mylib
        PACKAGE_DESCRIPTION_FILE mylib.cp.json)

然后,包管理器或构建系统可以直接消费这个 .cp.json 文件,无需解析复杂的构建脚本。这简化了库的消费和分发。


总结 🎯

在本教程中,我们一起学习了马特乌什·普什提出的改进C++库开发体验的多个关键方向:

  1. 接口设计:使用概念、契约和NTTP来创建更安全、更清晰、更对称的接口。
  2. 通用编程:利用概念模板参数、可推导的this和未来的通用模板参数来编写更灵活、更少重复的代码。
  3. 编译时体验:通过编译时诊断、改进的错误信息(结合概念与异常)来提升调试和排错效率。
  4. 安全性:通过值保留转换检查、文本输入解析库来增强代码的健壮性。
  5. 工具与分发:改进变量模板、控制ADL、使用类型排序来优化编译;采用通用包描述格式来简化库的分发和集成。

C++是构建高性能、零开销抽象泛型库的绝佳语言。随着概念、契约、反射等特性的成熟,以及社区对上述缺失特性的完善,我们将能够为用户提供无比强大且友好的库开发体验。如果你对某个特定领域(如数学库、Unicode、包管理)感兴趣,欢迎参与标准化工作,共同推动C++生态进步。

017:健壮的C++26错误处理 🛡️

在本教程中,我们将学习C++中用于捕获和处理错误的工具。我们将从最古老的C风格错误码开始,逐步探讨异常、std::expected、契约(Contracts)和库强化(Library Hardening)等现代机制。最后,我们将聚焦于最佳实践,讨论应该处理哪些错误以及如何高效地处理它们。

错误处理概述 🎯

错误处理是一个广泛的领域。在本课程中,我们讨论的不是那种关乎生命安全的“硬核模式”可靠性工程。我们处理的是程序错误、不可预见的系统配置和用户行为。我们假设内存正常工作,计算机不会爆炸,即运行在“简单模式”。我们的目标是快速交付稳定、对用户有价值的产品,而良好的错误处理是实现这一目标的关键。

C风格错误码 📜

最古老的错误处理方式是C风格错误码。以open函数为例:

int fd = open(“path/to/file”, O_RDONLY);
if (fd == -1) {
    // 检查 errno 以了解具体错误
    perror(“open failed”);
}

该函数返回一个整数文件描述符,或在失败时返回-1,并通过全局变量errno指示错误类型。错误类型繁多,从“文件不存在”到“地址越界”等程序错误都有。

Windows API采用类似模式,例如CreateFile函数,并通过GetLastError()获取更多错误信息。

标准库函数如strtol(字符串转长整型)则更为复杂。它通过返回值、输出参数和errno共同表示成功、转换失败或溢出等不同情况。开发者必须仔细检查所有情况,这很不方便。

由于操作系统为了最大兼容性将永远使用这些C风格函数,因此这种错误处理方式永远不会消失。我们需要工具来优雅地处理它们。

C++异常机制 ⚡

C++引入了更好的机制:异常。尽管如今异常名声不佳,常被批评为“ glorified goto”、难以推理、性能低下,并且要求编写异常安全代码,但它们在某些情况下仍是合适的工具。

考虑以下场景:执行HTTP请求、解析返回的JSON并将结果写入文件。

try {
    auto response = fetchHttpData(url);
    auto data = parseJson(response);
    writeToFile(“output.txt”, data);
} catch (const NetworkError& e) {
    // 处理网络错误
} catch (const JsonParseError& e) {
    // 处理解析错误
} catch (const FileError& e) {
    // 处理文件错误
}

代码中每一行都可能失败。异常的解栈(unwinding)特性使得库开发者(如JSON解析库)的生活更轻松,也间接使调用方更轻松。与返回码相比,异常的优势在于:

  • 类型丰富:异常类型可以编码错误信息。
  • 信息丰富:作为类,异常可以包含更多关于错误内容和位置的元数据。
  • 代码清晰:快乐路径(happy path)的代码完全清晰,没有任何错误处理控制流,易于推理。

在类似上述的局部上下文中,异常是合适的工具,前提是只在能够理性处理的地方捕获它们。然而,它们的缺点依然存在:错误路径慢、序列化(一次只能抛出一个异常)、以及必须编写异常安全代码。

有一场由Pete Maloon主讲的优秀演讲《Exceptionally》讨论了异常的错误使用方式,并清理了那些不应使用异常的场景,只留下真正适合使用异常的情况。

std::expected:返回值与异常的结合 🔄

那么,有没有办法结合返回值的简单性和异常的语义丰富性呢?这正是std::expected的目标。它本质上是一个返回码,但和异常一样丰富。其理念是将异常作为错误码,从而结合两者优点。这样还可以同时保存多个错误,跨线程传递、收集、分组和转换它们,比实际的异常更灵活。

std::expected是C++23的特性。以下是使用示例:

std::expected<int, ParseError> parseInteger(std::string_view str);

std::expected<double, std::string> calculateInverse(std::string_view str) {
    auto result = parseInteger(str);
    if (!result) {
        return std::unexpected(“Parse failed”);
    }
    int value = *result;
    if (value == 0) {
        return std::unexpected(“Division by zero”);
    }
    return 1.0 / value;
}

std::expected接口简单,可用于标准命令式编程。然而,它更优雅的接口是其单子式(monadic)函数式接口

std::expected<double, std::string> result = parseInteger(str)
    .transform([](int i) { return 1.0 / i; }) // 成功时转换值
    .transform_error([](ParseError e) { return “Parse error”; }); // 错误时转换错误

.transform仅在成功时调用,.transform_error仅在错误时调用。这种方式更优雅,因为它不包含显式的流程控制语句,每个转换都是可以独立推理的函数,形成了优雅的数据流。这使得开发者更不容易在深层的if-else语句中犯错。

std::expected应该是C++中错误报告的新默认方式。如果你只关心操作是否失败,而不关心原因,可以使用std::optional。否则,std::expected是合适的工具。

契约(Contracts) 📝

契约机制刚刚被投票纳入C++26。它本质上是一种更灵活、标准化的断言机制,主要用于检查代码自身的不变量、前置条件和后置条件。其最重要的特性是构建时可自定义行为,这比旧的assert宏(在调试版本中总是终止,在发布版本中不启用)有了巨大改进。

契约的语法如下:

int foo(int x)
    [[pre: x > 0]]          // 前置条件:检查参数
    [[post r: r > 0]]       // 后置条件:检查返回值r
{
    [[assert: x != 42]];    // 断言(契约断言)
    return x * 2;
}
  • 前置条件在函数参数初始化后、函数体运行前求值。
  • 后置条件在返回值初始化后、局部变量析构后求值。
  • 契约断言用于替代C风格断言。
  • 它们支持常量表达式上下文。

契约语义可在构建时配置:

  • ignore:忽略所有契约违反(类似旧assert在发布版本的行为)。
  • observe:调用契约违反处理程序,但程序继续运行(适用于游戏和桌面应用)。
  • enforce:调用处理程序后终止程序(适用于需要严格控制的场景,如Bloomberg的交易处理)。
  • terminate:立即终止(适用于医疗设备、飞行控制系统等安全关键场景)。

契约违反处理程序是可定制的:

void handle_contract_violation(const std::contract_violation& violation) {
    // 记录、上报或执行其他自定义逻辑
}

这为钩入外部库的契约提供了一种通用方式。契约不允许有破坏程序正确性的副作用,并且各个契约之间应该是独立的。如果选择ignore语义,它们应该对程序行为零开销。契约的最大优点可能是为所有库提供了一个标准的、可定制的断言机制。

库强化(Library Hardening) 🛠️

库强化也是C++26的新特性。强化库实现会强制执行标准中当前定义但通常不在运行时检查的前置和后置条件。微软长期以来提供的“调试迭代器”就是类似的强大工具,现在将转向库强化标准。

根据最新提案,库强化基于契约。例如,如果你访问std::vector中不存在的元素,将触发契约违反,从而调用你的契约违反处理程序。这将捕获大量错误。

在实践中,例如对于LLVM的libc++,有不同的强化级别可以启用或禁用,因为某些级别会影响性能。你可以在调试版本中启用有性能影响的强化功能,而在发布版本中禁用。库强化是STL实现的一个属性,可以开关。

最佳实践:处理什么与如何处理 🎯

我们拥有如此多的错误报告选项,但时间有限。我们不是为了处理错误而获得报酬,目标也不是完美的错误处理,而是快速交付稳定产品。糟糕的错误处理会导致更多错误。因此,我们寻找的是如何快速在线发现错误。

错误处理必须简单,否则我们不会去做;必须可测试,否则不会有效;必须聚焦于关键之处,否则会浪费时间。

以下是我们的做法:

首先,检查所有内容。
每个API调用都会被检查。我们为操作系统函数可能返回的每种错误码变体都准备了宏,让检查变得非常容易。我们还有对HRESULT、GError、Mac内核错误等的包装器。我们还会在发布版本中进行积极的断言,断言所有我们能想到的不变量、前置和后置条件。默认情况下,我们使用noexcept,对于我们认为不应抛出异常的地方都进行标记。未处理的异常会导致程序终止,因此我们不如立即终止,并设置终止处理程序以在发现意外异常时获得通知。

默认假设一切正常。
目标是保持代码路径集非常小,保持程序状态集小,从而使推理程序变得容易。当然,这有一个大例外:我们假设一切正常,除非我们知道它不会。

例如,在POSIX系统上打开文件时,我们知道某些错误(如文件不存在、权限不足、磁盘空间不足)总是会在某些机器上发生,因此我们处理这些情况。对于任何其他错误,我们假设不会发生,甚至不处理。

当假设被证明错误时。
检查最终会失败,你会发现假设错误的地方。首要任务是收集尽可能多的信息。在客户端,我们会发送带有内存转储的错误报告到后端。在服务器端,我们会挂起线程并通知管理员进行实时调试。第二个优先级是以某种方式继续运行。在关键错误之后,语义上行为是未定义的,我们会禁用进一步的错误报告,但不会终止程序,因为断言可能是错误的。我们不希望开发者因为害怕断言导致客户投诉而不敢编写断言。

聚焦于真正重要的问题。
我们有一个后端接收所有错误报告。我们可以按构建版本、操作系统、错误信息等进行过滤。我们查看最常发生故障的代码位置,并优先处理它们。我们甚至建立了与客户的双向通信:当错误发生时,客户端调用后端,后端分析问题后可以回调客户端,例如发送包含修复版本下载链接的消息,软件可以静默下载、安装甚至重新加载,在最佳情况下用户完全不会察觉。

错误分类与处理。
我们将错误分为不同类别,具有不同的严重性和行为:

  1. 关键错误:如空指针访问、API调用返回意外错误或断言失败。这些是程序错误,我们预期它们不会发生。发生后程序处于无效状态,我们发送错误报告、禁用后续报告,仅在不可能是误报时才显示用户消息。
  2. 未测试行为错误:例如,从无效UTF-8范围获取代码点失败。错误处理代码很少(例如返回替换字符),但我们想知道何时发生、来自哪个用户,以便了解上下文。我们发送错误报告但不禁用后续报告,但可能会限制报告频率。
  3. 糟糕用户体验错误:已知、可重现、已正确处理并经过测试的第三方错误,但会降低用户体验。我们记录到日志文件但不报告到后端,也不显示错误消息,以便在用户投诉时查看日志。
  4. 环境配置错误:如用户将空格配置为小数点分隔符。我们仅在调试版本中记录,但支持工程师可以通过运行时标志在远程会话中为用户启用相关错误消息,以帮助诊断问题。

错误报告与收集。
我们的错误报告类似于Google的Crashpad,但不仅针对崩溃,也针对服务失败。我们进行进程外错误处理:当错误发生时,启动另一个进程来执行处理,因为遇到错误的进程可能处于未定义状态(无法进行HTTP请求、内存不足等)。错误处理进程会挂起遇到错误的进程,然后创建一个迷你转储(仅包含栈内存的小型转储文件)。在客户同意的情况下,我们将转储上传到后端。

在后端,所有迷你转储都会被调试器自动打开、符号化、加载。我们识别最后一个相关的栈条目(可能不是栈顶),并尝试计算错误发生在函数内的哪一行(这比文件内的绝对行号更稳定)。我们按此对错误进行分组。最后,我们甚至进行统计分析,查看是否加载了其他可能与所发现错误相关的库,试图找出是否应归咎于其他方。

总结 📚

本节课我们一起学习了C++错误处理的演进与最佳实践。

我们回顾了从基础的C风格错误码,到C++异常机制,再到现代、更富表达力的std::expected单子式错误处理。接着,我们探讨了即将到来的C++26特性:契约(Contracts)和库强化(Library Hardening),它们为前置/后置条件检查和标准库安全提供了标准化、可定制的强大工具。

最后,我们深入研究了在实践中应该处理哪些错误以及如何高效处理。核心在于:使错误处理简单易行以确保其被执行;通过分类(关键错误、未测试行为、用户体验问题、环境配置)来聚焦于真正重要的问题;并建立强大的收集、分析和响应机制(如进程外转储、自动化符号化、错误分组和双向用户通信),从而快速定位、修复问题,并最终高效地交付稳定、用户满意的产品。

记住,良好的错误处理不是目标本身,而是实现快速交付稳定软件这一目标的关键手段。

018:欢迎来到元编程宇宙 v1.0

在本教程中,我们将学习 C++ 26 中引入的静态反射功能。我们将从反射的基本概念和历史讲起,逐步深入到其语法、核心组件、使用示例,并探讨它对代码库和开发流程带来的影响。

反射简介与历史 📜

上一节我们介绍了本教程的概述,本节中我们来看看反射功能在 C++ 中的发展历程。

反射是指程序能够检视自身结构的能力。在 C++ 中,我们讨论的是静态反射,即编译器在编译时暴露程序的结构信息。

C++ 社区对反射的探索由来已久:

  • 2006年:Matus Chochlik 首次在标准语境下提及反射,并创建了 Mirror 反射库,该库基于模板元编程。
  • 2012年:Mike Spertus 撰写论文,枚举了编译时反射的用例,如序列化、委托、访问器等。
  • 2014-2016年:尝试标准化基于模板元编程的反射库,但因其对编译器负担过重而转向专用语法。
  • 2018年:讨论了基于值(value-based)与基于类型(type-based)的反射模型,最终倾向于更轻量的基于值的模型,即单一的 std::meta::info 类型。
  • 2019年至今:随着 constexpr 能力的扩展,为反射提供了更好的基础。经过多年讨论和提案迭代,最终形成了 P2996 提案。
  • 2025年:P2996 提案在 ISO C++ 委员会全体会议上以高票通过,被纳入 C++26 草案,这意味着我们很可能在 C++26 中获得原生的反射支持。

核心概念与语法 ⚙️

上一节我们回顾了反射的历史,本节中我们来深入了解其核心概念和基本语法。

反射涉及两个“领域”:常规的 C++ 程序领域和反射领域。我们通过专用语法在这两个领域间“提升(lift)”和“拼接(splice)”信息。

提升操作符与拼接操作符

提升操作符 ^(昵称“帽子操作符”)用于将程序实体(如类型、变量)提升到反射领域,得到一个 std::meta::info 对象。拼接操作符 [: ... :] 用于将反射领域的信息提取回 C++ 程序领域。

// 将类型`int`提升到反射领域,结果是一个`std::meta::info`对象
constexpr std::meta::info refl_int = ^int;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/72fa588386bb12c18d12179186d47d23_2.png)

// 使用拼接操作符将反射信息提取回C++领域,声明一个int变量
typename [: refl_int :] a = 42;

注意:反射表达式必须在常量求值上下文中使用,因此通常需要 constexpr

std::meta::info 对象

std::meta::info 是一个不透明的类型,代表被反射的实体。它可以表示类型、函数、变量、成员、表达式(符合常量求值要求的)、模板、命名空间等。

重要的是,std::meta::info 对象是有状态的,它更像是对编译器内部抽象语法树(AST)节点的一个引用,而非快照。

class R; // 仅声明
constexpr std::meta::info refl1 = ^R;
bool b1 = std::meta::is_complete_type(refl1); // false,R未定义

class R { int member; }; // 给出定义
constexpr std::meta::info refl2 = ^R;
bool b2 = std::meta::is_complete_type(refl2); // true,R已定义

bool b3 = std::meta::is_complete_type(refl1); // true!refl1引用的状态已更新

元函数

标准库提供了一系列元函数来查询 std::meta::info 对象。以下是一些关键元函数:

  • 查询类:如 is_complete_type, is_function, identifier_of(获取标识符名称),members_of(获取类成员)等。
  • 操作类:如 substitute(在反射领域进行模板实例化),extract(尝试从反射对象提取出具体C++值/类型)。

constexpr int a = 42;
constexpr std::meta::info refl_a = ^a;
std::string_view name = std::meta::identifier_of(refl_a); // 得到 "a"

// 使用 substitute 进行“反射领域的模板实例化”
constexpr std::meta::info refl_array_tmpl = ^std::array;
constexpr std::meta::info refl_int = ^int;
constexpr std::meta::info refl_three = std::meta::reflect_value(3);
constexpr std::meta::info refl_array_type = std::meta::substitute(refl_array_tmpl, {refl_int, refl_three});
// refl_array_type 代表了 std::array<int, 3>
using MyArray = [: refl_array_type :]; // 拼接回C++领域

反射的用例与影响 🛠️

上一节我们介绍了反射的核心语法,本节中我们来看看它的实际应用和可能带来的影响。

常见用例

反射可以极大地简化许多需要自省(introspection)的代码:

  1. 序列化/反序列化:自动遍历类的数据成员,生成读写代码。
  2. 日志记录:自动记录函数参数名和值。
  3. 对象关系映射(ORM):根据类结构自动生成数据库表映射。
  4. 测试框架:自动发现和注册测试用例。
  5. 配置绑定:将配置文件自动绑定到结构体成员。

需要关注的边界情况

反射也引入了一些新的考量:

  • 私有成员访问:反射可以访问类的私有成员,这打破了传统的“接口-实现”边界。因此,反射标准库类型是不被保证稳定性的,其内部结构可能在不同版本间变化。
  • 声明与定义的一致性:函数参数名在声明和定义中可能不同。P3096 论文探讨了此问题,目前的倾向是强制要求一致性,否则程序可能非良构(ill-formed)。这可能会影响现有代码库。
  • 编译期与链接期错误:使用反射的库(如日志库)需要其用户代码在编译期可用,以便进行反射分析。错误(如不一致的参数名)将在编译期被发现,而非传统的链接期。

反射作为定制点

定制点(Customization Point)是库为用户提供扩展机制的方式(如 std::swap)。反射理论上可以作为强大的定制点机制,允许用户深度介入库的逻辑。然而,它可能过于“重型”,对于简单的定制需求来说过于复杂。更轻量级的定制点机制可能仍是标准库探索的方向。

与AI代码生成的对比

人工智能(AI)辅助编程(如 GitHub Copilot)可以生成序列化器等代码。然而,AI生成存在非确定性(多次生成结果可能不同)和潜在逻辑错误的问题。反射作为语言特性,提供了确定性类型安全的代码生成方案,两者在未来可能互补而非替代。

与Rust的对比

Rust 通过过程宏和 syn 库提供了强大的元编程能力,允许在编译时操作 AST。C++ 的反射是语言内置的语法,相比之下可能更规范,对用户隐藏了编译器实现细节,提供了更干净的编程模型。

总结与展望 🚀

本节课中我们一起学习了 C++26 静态反射的核心内容。

我们首先回顾了反射从基于模板元编程到专用语法的发展历程。接着,深入探讨了其核心语法:使用提升操作符 ^ 将实体提升到反射领域得到 std::meta::info 对象,使用拼接操作符 [:...:] 将其提取回 C++ 领域;并了解了 std::meta::info 有状态的特性和丰富的元函数。

然后,我们探讨了反射的典型应用场景,如序列化、日志等,并指出了需要特别注意的方面:私有成员的可访问性、声明/定义的一致性要求,以及错误检查阶段从链接期前移到编译期。我们还简要对比了反射与 AI 代码生成、Rust 元编程的异同。

C++ 静态反射的引入是一个里程碑,它将催生一大批新的、强大的库,并改变我们构建和组合代码的方式。虽然它带来了一些新的约束和考量,但其带来的表达能力和开发效率的提升是巨大的。期待社区基于此特性创造出更多优秀的工具和库。

019:知道何时完成以及为何重要 🏗️

在本教程中,我们将学习软件工程中的“完成”概念。我们将探讨如何定义“完成”,为什么明确的定义至关重要,以及如何通过一个分层的“软件工程完整性金字塔”模型,从代码编写到业务战略,系统地构建高质量的软件交付能力。


概述 📋

“我们完成了吗?”这是软件开发中经常被问到的问题。然而,对于“完成”的定义,团队内部、团队之间、工程师与业务方之间常常存在巨大的理解偏差。这种偏差会导致项目延期、质量问题和组织摩擦。

本次课程将深入探讨“完成”的真正含义。我们将介绍一个名为“软件工程完整性金字塔”的模型。这个模型将软件交付的完整性分为多个层次,从最基础的代码开发,到代码健康度、系统可靠性,最终到业务与工程的对齐。理解并实践这些层次,能帮助我们建立共享的“完成”定义,提升交付的可预测性、软件质量以及团队与业务的协作效率。


1: 核心概念与问题引入

1.1: “完成”的定义困境

在软件工程中,“完成”是一个模糊的概念。字典定义是“将某物带到所需的状态或结束”。Scrum指南中的定义是“符合质量标准的增量状态的正式描述”。然而,在实际工作中,开发者对“完成”的理解往往与此不同。

问题在于:缺乏一个在团队内部和整个组织内共享的、明确的“完成”定义。这会导致:

  • 沟通摩擦:当你说“完成”时,你指的是代码已合并,而业务方可能理解为功能已上线并为客户所用。
  • 期望错位:新成员需要花费时间摸索团队的“部落知识”来理解工作标准。
  • 进度误判:例如,一个计划开发10个新功能的项目,如果只完成了5个功能的编码,开发者可能报告“50%完成”。但从业务价值角度看,如果没有任何功能部署给用户,完成度实际上是0%

因此,我们需要建立一个清晰的、分层的“完成”标准。

1.2: 软件工程的业务价值

我们首先需要明确软件工程的核心业务价值。它不仅仅是编写巧妙的代码或使用高级的模板技术。

软件工程的业务价值在于以增量步骤交付期望的业务成果

为什么是增量步骤?

  • 缩短时间范围:减少不确定性,建立信心。
  • 降低风险:每次变更的规模更小,影响可控。
  • 获得更好反馈:可以中途根据用户反馈进行调整。
  • 更准确地规划时间:通过许多小步骤,可以更精确地绘制时间线。

价值何时实现?
业务价值在软件可用、可靠时才真正实现。这意味着:

  • 可用:客户能够访问并使用它,它能完成宣称的功能。
  • 可靠:它必须持续工作,或在客户知晓的预定停机时间内工作。

此外,还有面向未来的价值:

  • 可配置性:通过配置增加新功能。
  • 灵活性:能够适应新需求而无需重写。
  • 可维护性:能够快速定位和修复问题。
  • 可演进性:能够随着市场变化而快速进化。

1.3: 我们交付到生产环境的变更类型

作为工程师,我们向生产环境交付多种类型的改进和变更。

我们交付的改进类型包括

  • 新功能
  • Bug 修复
  • 功能标志变更
  • 新配置
  • 技术债务减少(重构、弃用无用代码)
  • 硬件/生态系统升级(如编译器、操作系统迁移)

我们无意中可能交付的问题类型包括

  • 损坏的功能(Bug)
  • 缺失的功能
  • 性能问题
  • 安全问题
  • 系统不可靠性(使系统更脆弱)

2: 软件工程完整性金字塔 🗼

上一节我们明确了问题和价值,本节我们将引入核心模型——“软件工程完整性金字塔”。这个模型受马斯洛需求层次理论启发,认为软件工程的“完整性”也需要逐层构建,每一层都依赖于下一层的稳固。

2.1: 第一层:代码开发与部署(生存)

金字塔的底层是“生存”,即能够将代码变更从需求一直推进到生产环境。这是最基本的能力。

开发完成了吗?
开发完成意味着你承诺的变更已经过验证并应用。

  • 满足验收标准:变更全部或部分满足了预先定义的验收条件。
  • 通过所有测试:包括单元测试、集成测试,确保系统在变更后仍能像之前一样运行,并为新变更添加了测试。
  • 通过代码审查:确保代码以合理、高效的方式实现,而不仅仅是功能正确。
  • 合并并打包:代码已合并到主分支,并打包好准备发布。

部署完成了吗?
代码在仓库中毫无价值,必须部署才能产生价值。

  • 部署到所有环境:变更必须部署到生产环境的所有阶段,而不仅仅是测试或金丝雀环境。
  • 考虑部署节奏和依赖:了解部署到各个阶段所需的时间,并管理好与其他团队的依赖关系。
  • 考虑代码冻结期:规划需避开节假日、大选等代码冻结期。

功能标志启用完成了吗?
功能标志允许我们在运行时启用或禁用新功能,是降低发布风险的重要工具。

// 示例:功能标志的简单实现
if (feature_flag_is_enabled("new_trading_algorithm")) {
    execute_new_algorithm();
} else {
    execute_legacy_algorithm();
}
  • 启用范围:功能标志是否已在所有需要的地方启用?
  • 启用计划:是否有明确的启用时间表(具体日期)?
  • 清理计划:是否有计划在未来移除已稳定功能的标志,以避免代码中存在死分支?

达到此层意味着:你的团队能够将代码变更交付到生产环境。你是一个合格的编码者

2.2: 第二层:代码健康度(可持续性)

仅仅能交付功能是不够的。如果只专注于功能开发和Bug修复,代码库会随着时间推移变得复杂、脆弱,成为难以维护的“遗留系统”。我们必须平衡快速功能开发与代码库的可持续性。

代码健康基础:软件退役
软件退役是战略性地淘汰过时的软件及其相关基础设施的过程。

  • 结构上无法访问的代码:由于新路径引入而永远无法执行到的代码块。
  • 相信不会被访问的代码:基于用户行为分析,认为不会再被使用的功能(如过时的交易类型)。需要监控一段时间后才能安全移除。
  • 是否完成:在新功能替换旧功能时,是否已计划移除旧功能?是否有待移除的功能标志?

代码健康基础:软件重构
重构是在不改变外部行为的前提下,改善代码内部结构的过程。

  • 为何需要:代码会随着业务复杂化而“衰老”。战术性实现(为求快而写的临时方案)和缺乏标准的代码审查会导致代码质量下降。
  • 如果不重构:代码最终将走向代价高昂的、高风险的重写。
  • 何时需要重构
    • 出现重复的模式。
    • 代码可读性或可维护性差。
    • 存在代码“坏味道”(如滥用原始指针、过度复杂的逻辑)。
    • 范式变更(业务方式发生根本变化)。
    • 技术折旧(语言、第三方库的进步使旧代码显得过时)。

代码健康核心:技术债务
技术债务是由于现在采用了简化或临时的解决方案,而导致未来需要额外返工的成本。

  • 技术债务的产生原因
    • 外部强加的截止日期。
    • 生产环境救火,仓促修复。
    • 过度“炫技”和过早优化。
    • 缺乏良好的工程文化和标准。
    • 有机增长和文档不全。
  • 技术债务的类型
    • 有意技术债务:为了速度(如抢占市场、应对法规)而牺牲质量,并伴有明确的偿还计划(例如,在待办事项中创建后续任务)。
    • 无意技术债务:没有战略收益,纯粹因为工作马虎、代码审查不严而引入,且没有偿还计划
  • 如何管理技术债务
    1. 建立清单:记录债务项,评估其规模(小/中/大)、严重性(高/中/低)和影响。
    2. 保持可见:定期(如每季度)安排“技术债务冲刺”专门处理。
    3. 用数据说服管理层:展示技术债务如何导致系统回滚、宕机、修复周期长,用数据证明投资的必要性。
  • 债务预防措施
    • 充分的时间规划和任务分解。
    • 设计评审。
    • 代码审查指南。
    • 编码标准。
    • 代码审查培训。

代码健康保障:测试
测试是保证代码质量、让客户看到预期行为的基石。

  • 测试三大支柱
    1. 单元测试:测试单一代码单元,快速反馈。
    2. 集成测试:测试模块组合,验证协作。
    3. 端到端测试:在生产前环境用模拟数据验证整个流程。
  • 测试是否充分
    • 是否根据生产环境事故创建了相应的测试用例?
    • 测试覆盖率是否足够?是否涵盖了边界情况和异常路径?
    • 测试是否自动化?能否在CI/CD流水线中运行?

达到此层意味着:你不仅交付功能,还致力于保持代码库的可持续性和健康。你是一个工程师

2.3: 第三层:系统可靠性与前瞻性规划(系统思维)

当我们开始关注代码健康后,视野需要进一步扩大,从“我们完成了吗?”转向“我们准备好了吗?”。这一层关注整个系统的聚合健康与未来准备。

系统可靠性与弹性

  • 可靠性:系统在指定条件下无故障持续运行的能力。
  • 弹性:系统在出现故障时能够优雅降级,而非彻底崩溃。
  • 为何重要:关乎客户信任和业务连续性。系统不可靠会导致业务中断和声誉损失。

如何实现:系统健康监控
通过可观测性(运营指标)来洞察系统运行状态,预测问题。

  • 关键指标
    • 延迟:服务请求所需时间。
    • 吞吐量:系统处理能力。
    • 饱和度:资源使用率(CPU、内存、队列深度)。
    • 错误率:失败请求的比例。
  • 监控的价值
    • 自动告警:在用户发现问题前触发警报。
    • 趋势分析:通过长期监控进行容量规划,预见瓶颈。

生产支持与改进规划

  • 生产支持:新引入的技术或功能是否有运维团队支持?知识是否在团队内部分享?是否有清晰的日志、可观测性和应急预案(Runbook)?
  • 改进规划:识别系统中的高风险区域,规划中长期的、涉及多团队的功能性改进。这需要风险管理、时间预测和大量的协调沟通。

架构设计

  • 局部再工程:改变系统中某个广泛使用的部分(如通信层)。
  • 需要全局视野:通常由资深或首席工程师负责,确保变更具有一致的愿景,并瞄准能带来最大收益的部分,避免全盘重写的高风险。

战略规划
这一层关注超越技术细节的长期战略方向。

  • 关注点:业务发展方向、技术趋势(如云迁移、AI)、大规模变革。
  • 特点:涉及高层级人员、时间跨度大(多年)、决策难以更改、需要进行大量的权衡分析。

达到此层意味着:你关注整个系统的可靠性和未来演进。你是一个合格的系统工程师

2.4: 第四层:业务与工程对齐(愿景)

金字塔的顶层是实现工程努力与业务需求的完美对齐。这是软件工程的“自我实现”阶段。

为何需要对齐?
如果工程团队输出的不是业务所需,或者业务不理解工程面临的约束,就会产生浪费和冲突。双方需要协作、妥协和有效沟通。

实现对齐的工具:路线图会议
路线图会议是讨论大型项目或公司级计划当前进展和未来方向的正式会议。

  • 会议参与者
    • 产品负责人
    • 业务分析师(用户代理)
    • 各主要开发领域的代表
    • 能做出决策的执行层利益相关者
    • 产品经理/交付专家(负责生成甘特图和时间线)
  • 会议议程
    1. 回顾现状:自上次会议以来的进展和当前障碍。
    2. 设定战略方向:明确目标路径,达成共识。
    3. 确定优先级和资源分配:决定做什么、谁来做、何时完成。
    4. 制定行动计划:产出带有明确成功标准和日期的行动项。
  • 未来规划:在路线图中,需要为软件稳定性、安全漏洞、可扩展性、新法规、新业务机会等做好准备。

达到此层意味着:你成功地将业务需求与工程目标相结合,能够引领大规模、战略性的前进。你是一个有远见者


3: 总结与实践指南 🎯

本节课我们一起学习了“软件工程完整性金字塔”模型,它从代码生存、代码健康、系统可靠到业务对齐,层层递进地定义了软件工程的“完成”状态。

如何应用这个模型?

自我评估与提升

  • 你目前主要专注于哪一层?(功能实现/Bug修复 -> 编码者;关注代码健康 -> 工程师;关注系统可靠性 -> 系统工程师;关注业务对齐 -> 有远见者)
  • 你渴望达到哪一层? 可以尝试将上一层的实践引入当前工作,例如在代码审查中关注技术债务,或在团队讨论中引入系统健康监控的视角。

重新定义“完成”
不要再使用模糊的“完成”。具体说明:

  1. 范围:明确的里程碑、交付物、验收标准。
  2. 时间:具体的交付日期和里程碑日期。将大任务分解为小任务来估算。
  3. 债务:明确承认并计划偿还任何有意承担的技术债务。
  4. 沟通:清晰地向利益相关者传达进展,保持透明度。

最终的“完成”标准
一个变更“完成”,当它:

  • 在生产环境所有地方平稳运行
  • 得到团队的良好支持(不依赖单个人)。
  • 技术债务在减少而非增加。
  • 代码结构良好、可维护
  • 最终用户和客户感到满意(可靠、准时、功能完善)。

当客户满意时,业务就会成功,而成功的业务会带来回报。通过实践这个金字塔模型,我们可以更有信心地说:“是的,我们完成了”,并且每个人都知道这意味着什么。


教程内容整理自 Peter Muldoon 在 C++-On-Sea 2025 大会上的演讲《Software Engineering Completeness Pyramid》。

020:面向急躁者的 CMake 🚀

在本教程中,我们将学习 CMake 的基础知识。CMake 是一个强大的跨平台构建系统生成器,它允许你使用简单的配置文件来管理软件构建过程,而无需直接处理不同平台和编译器的复杂性。我们将从最简单的例子开始,逐步介绍核心概念,目标是让你能够理解并开始使用 CMake 来构建自己的 C++ 项目。

为什么选择 CMake?🤔

上一节我们介绍了 CMake 的定位,本节中我们来看看为什么开发者会选择 CMake 而不是其他构建工具。

如果你主要使用 Microsoft Visual Studio 等集成开发环境,通常通过图形界面和属性页来配置项目。虽然功能全面,但这种方式难以进行版本控制,并且在跨平台协作时会造成困难。

CMake 提供了一个统一的、基于文本的配置方式。你可以将 CMakeLists.txt 文件纳入版本控制,确保团队中所有成员,无论使用 Windows、Linux 还是 macOS,也无论使用 MSVC、GCC 还是 Clang 编译器,都能使用完全相同的配置来构建项目。这就是 CMake 的魔力所在。

最简单的 CMake 示例 🎯

让我们从一个最简单的 C++ 程序开始,看看如何用 CMake 构建它。

以下是一个经典的“Hello World”程序:

// main.cpp
#include <iostream>
int main() {
    std::cout << "Hello, world!\n";
}

要构建这个程序,我们需要一个 CMakeLists.txt 文件。这是 CMake 的配置文件。

最简单的 CMakeLists.txt 文件如下:

cmake_minimum_required(VERSION 3.10)
project(Hello)
add_executable(World main.cpp)

这个文件只有三行:

  1. cmake_minimum_required(VERSION 3.10):指定构建所需的最低 CMake 版本。
  2. project(Hello):为项目命名(这里叫“Hello”)。这主要是一个用于在配置中引用的标签。
  3. add_executable(World main.cpp):这是关键指令。它创建一个名为 World目标(一个可执行文件),并从源文件 main.cpp 构建它。

构建流程三步走 🔨

有了源代码和 CMake 配置文件后,构建一个可执行程序通常需要三个步骤。

以下是构建的具体步骤:

  1. 生成构建系统:在项目根目录下运行 cmake -B bin。这个命令会读取 CMakeLists.txt,并根据当前系统环境自动检测可用的编译器(如 Visual Studio 或 GCC),然后在 bin 目录中生成对应的构建系统文件(如 Visual Studio 的 .sln 文件或 Unix 的 Makefile)。只有当你修改了 CMakeLists.txt 文件时,才需要重新运行此命令。
  2. 执行构建:运行 cmake --build bin。这个命令会使用上一步在 bin 目录中生成的构建系统,来编译源代码并生成最终的可执行文件。它会自动检测源文件的更改,只重新编译必要的部分(增量构建)。
  3. 运行程序:进入构建输出目录(例如 bin/Debug/),运行生成的可执行文件(如 World.exe./World)。

关键点cmake -B bin 生成的是“如何构建”的配置和脚本,而 cmake --build bin 才是真正调用编译器进行编译和链接。

CMake 的跨平台威力 🌍

CMake 的核心优势在于其强大的跨平台和跨工具链能力。让我们通过一个例子来感受一下。

假设我们在 Windows 系统上,拥有 Visual Studio 和 MinGW(GCC for Windows)两套工具链。使用同一个 CMakeLists.txt 文件,我们可以:

  • 运行 cmake -B build_vs,CMake 会自动检测到 Visual Studio 并生成 .sln 解决方案文件。
  • 运行 cmake -B build_mingw -G “MinGW Makefiles”,通过 -G 参数指定生成器,CMake 会为 MinGW 生成 Makefile
  • 分别对 build_vsbuild_mingw 目录执行 cmake --build,就能用不同的编译器得到相同的程序。

这意味着,团队中有人用 Windows+Visual Studio,有人用 Linux+GCC,大家都可以使用同一份 CMakeLists.txt 配置文件来构建项目,无需为每个平台维护单独的构建脚本。

核心概念:目标、依赖与变量 🧩

随着项目增长,我们需要引入更多 CMake 概念来管理复杂性。

目标

在 CMake 中,add_executable()add_library() 创建的东西被称为目标。目标是最基本的构建单元。你可以为目标设置属性,例如头文件搜索路径。

add_executable(MyApp main.cpp)
target_include_directories(MyApp PRIVATE “path/to/headers”)

这告诉 CMake,构建 MyApp 目标时,要去 “path/to/headers” 目录下寻找 #include 的头文件。

依赖推断

CMake 能自动处理源文件与头文件之间的依赖。如果你修改了 main.cpp 所包含的 message.h 头文件,CMake 在下次构建时会知道需要重新编译 main.cpp,你无需在配置文件中手动声明这个依赖。

变量

使用变量可以让配置更清晰、更易维护。

set(SFML_DIR “C:/Libs/SFML”) # 设置一个变量
target_include_directories(MyGame PRIVATE ${SFML_DIR}/include)
target_link_directories(MyGame PRIVATE ${SFML_DIR}/lib)

这里,SFML_DIR 变量保存了第三方库 SFML 的根路径,后续的路径都基于这个变量,方便统一修改。

管理第三方库 📦

现代项目很少从零开始,引入第三方库是常态。CMake 提供了优雅的方式来处理它们。

使用 find_package

对于像 SFML 这样提供了 CMake 支持文件的库,你可以使用 find_package 命令。CMake 会去标准路径或你指定的路径下查找该库的配置文件,并自动设置好包含路径、库路径等变量。

find_package(SFML COMPONENTS graphics window system REQUIRED)
...
target_link_libraries(MyGame PRIVATE SFML::Graphics SFML::Window SFML::System)

这种方式比手动指定路径更简洁,也更利于跨平台。

使用 FetchContent

对于没有预编译包或者你想直接使用最新源码的库,CMake 的 FetchContent 模块可以在配置阶段自动下载并集成它们。

include(FetchContent)
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
)
FetchContent_MakeAvailable(googletest)
# 之后就可以像使用普通库一样链接 googletest
target_link_libraries(MyTests PRIVATE GTest::gtest_main)

这对于管理测试框架(如 Google Test)或仅头文件库非常方便。

项目结构:分解 CMakeLists.txt 🏗️

当一个项目包含多个子库、可执行文件和测试时,将所有配置写在一个 CMakeLists.txt 里会变得臃肿。

CMake 支持模块化配置。你可以在项目的子目录中放置各自的 CMakeLists.txt 文件,然后在顶层的 CMakeLists.txt 中使用 add_subdirectory() 命令将它们纳入构建。

MyProject/
├── CMakeLists.txt          # 顶层配置
├── src/
│   ├── CMakeLists.txt      # 主程序配置
│   └── ...
├── libs/
│   ├── MyLib/
│   │   ├── CMakeLists.txt  # 静态库配置
│   │   └── ...
│   └── ...
└── tests/
    ├── CMakeLists.txt      # 测试配置
    └── ...

顶层 CMakeLists.txt:

add_subdirectory(src)
add_subdirectory(libs/MyLib)
add_subdirectory(tests)

这种结构逻辑清晰,允许你单独构建子模块(例如只构建测试),也便于团队协作。

工具与集成 🛠️

虽然可以在命令行中使用 CMake,但集成开发环境能提供更好的体验。

  • CLion:对 CMake 的支持是原生且深度集成的,体验非常出色。
  • Visual Studio:提供了 CMake 项目支持,可以直接打开包含 CMakeLists.txt 的文件夹进行开发。
  • VS Code:通过 CMake Tools 等扩展也能获得强大的支持。

对于初学者,从一个支持 CMake 的 IDE 开始,可以避免初期在命令行中摸索的挫折感。

总结 📝

本节课中我们一起学习了 CMake 的基础知识。我们从 CMake 解决的核心问题——跨平台构建管理——出发,通过一个最简单的 “Hello World” 示例,一步步了解了 CMakeLists.txt 的基本结构、构建流程的三步骤,以及 add_executable 等核心命令。

我们探讨了 CMake 如何通过自动检测工具链和生成对应的构建系统来实现“一次编写,到处构建”。我们还介绍了目标依赖推断变量等关键概念,它们是组织更复杂项目的基础。最后,我们了解了如何使用 find_packageFetchContent 管理第三方依赖,以及如何通过 add_subdirectory 来模块化地组织大型项目的构建配置。

记住,CMake 的学习曲线初期可能有些陡峭,但其带来的构建一致性、可维护性和团队协作效率的提升是巨大的。从一个小项目开始实践,逐步探索更高级的特性,是掌握 CMake 的最佳途径。

021:从纸袋中玩蛇 🐍

在本教程中,我们将学习强化学习的基本概念,并通过一个简单的例子——让一个“小方块”学会在一条线上移动,最终扩展到玩经典的贪吃蛇游戏——来理解其工作原理。我们将从一维空间开始,逐步过渡到二维空间,并最终实现一个简单的贪吃蛇智能体。整个过程将使用C++代码示例来展示核心算法。

概述

强化学习是机器学习的一个分支,其核心思想是让一个智能体(Agent)通过与环境(Environment)的交互来学习。智能体执行动作(Action),环境给予奖励(Reward)或惩罚,智能体根据这些反馈调整其行为策略,目标是最大化长期累积奖励。这类似于人类通过尝试和错误来学习的过程。

我们将从一个极其简单的场景开始:一个智能体只能在一维线上向左或向右移动,目标是移动到线的右侧边缘。然后,我们会逐步增加复杂度,最终构建一个能玩贪吃蛇游戏的智能体。


章节 1:一维随机漫步 🚶

我们首先创建一个最简单的环境:一条长度为5的线。智能体(用X表示)从中间位置开始,可以随机选择向左(-1)或向右(+1)移动。游戏规则是:如果智能体从左侧掉出线外,得分为-1;如果从右侧掉出,得分为+1。

以下是初始的代码框架,智能体只是进行随机移动,没有任何学习能力:

// 伪代码示例:一维随机漫步
int position = line_length / 2; // 起始位置在中间
int score = 0;
while (position >= 0 && position < line_length) {
    int action = get_random_action(); // 随机返回 -1 或 +1
    position += action;
    draw_position(position);
}
if (position < 0) score = -1;
if (position >= line_length) score = +1;

在这个阶段,智能体只是随机游走,大约有50%的概率从右侧离开(得分+1),50%的概率从左侧离开(得分-1)。它还没有任何“学习”发生。


章节 2:引入固定模型 🧠

上一节中,智能体的行为完全是随机的。本节我们将引入一个简单的“模型”,让智能体在大部分时间里采取我们认为“好”的动作(例如,总是向右移动),但偶尔也会进行随机探索。

我们引入一个参数 ε(epsilon),它代表随机探索的概率。例如,设置 ε = 0.1,意味着智能体有10%的时间会随机选择动作,90%的时间会执行我们预设的“最佳动作”(向右移动)。

// 伪代码示例:带有ε-贪婪策略的固定模型
double epsilon = 0.1;
int best_action = +1; // 我们预设的最佳动作是向右

while (game_not_over) {
    int action;
    if (random_number() < epsilon) {
        action = get_random_action(); // 探索
    } else {
        action = best_action; // 利用已知的最佳动作
    }
    // ... 执行动作并更新状态
}

通过这种方式,智能体到达右侧目标的速度会显著快于纯随机游走。但这仍然不是真正的学习,因为“最佳动作”是我们直接告诉它的。


章节 3:构建Q表进行学习 📊

现在,我们不再直接告诉智能体该怎么做,而是让它通过经验自己学习。我们将使用一个 Q表(Q-table) 来记录在特定“状态”下采取特定“动作”所获得的价值(Value)。

  • 状态(State):当前智能体所在的位置(例如,位置索引 0, 1, 2, 3, 4)。
  • 动作(Action):向左(-1)或向右(+1)。
  • Q值:对在某个状态下采取某个动作的长期收益的估计。

初始时,Q表是空的。智能体每次执行一个动作后,会根据得到的即时奖励(掉出左侧得-1,掉出右侧得+1,其他情况得0)来更新Q表中对应的条目。

// 伪代码结构:Q表
std::map<std::pair<State, Action>, double> Q_table;

// 更新Q值(简化版)
void update_Q(State s, Action a, double reward) {
    Q_table[{s, a}] += reward; // 简单累加奖励
}

智能体选择动作时,会查看当前状态下所有可能动作的Q值,并选择Q值最高的那个(利用),同时保留ε概率进行随机探索。这样,通过多次尝试,智能体会逐渐学到“在靠近右侧时向右移动价值更高”。

然而,这种方法有一个问题:只有在掉出边界时才有非零奖励(+1或-1),线中间的所有移动奖励都是0。因此,智能体在线中间的学习效率很低,它需要很长时间才能将边界处的“好”信息传递到中间位置。


章节 4:Q学习与贝尔曼方程 ⚡

为了解决上述问题,我们引入真正的 Q学习(Q-Learning) 算法。其核心思想是更新Q值时,不仅考虑即时奖励,还考虑下一个状态可能带来的未来潜在奖励

这引入了两个新参数:

  • α(alpha):学习率,控制新信息覆盖旧信息的程度(0 ≤ α ≤ 1)。
  • γ(gamma):折扣因子,衡量未来奖励对当前价值的重要性(0 ≤ γ ≤ 1)。

更新Q值的公式(贝尔曼方程的一种形式)如下:

Q(s, a) = Q(s, a) + α * [ R + γ * max(Q(s’, a’)) - Q(s, a) ]

其中:

  • s, a:当前状态和动作。
  • R:执行动作a后获得的即时奖励。
  • s’:执行动作后到达的新状态。
  • max(Q(s’, a’)):在新状态s’下,所有可能动作中的最大Q值。

// 伪代码:Q学习更新规则
double alpha = 0.1; // 学习率
double gamma = 0.9; // 折扣因子

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/fa6f7e9b1fb67db54d197345948984ed_3.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/fa6f7e9b1fb67db54d197345948984ed_5.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/fa6f7e9b1fb67db54d197345948984ed_7.png)

void q_learn_update(State s, Action a, double reward, State next_s) {
    double current_q = Q_table[{s, a}];
    double max_future_q = max(Q_table[{next_s, all_possible_actions}]);
    double new_q = current_q + alpha * (reward + gamma * max_future_q - current_q);
    Q_table[{s, a}] = new_q;
}

这个公式的妙处在于,即使中间状态的即时奖励R为0,如果下一个状态s’有一个很高的max_future_q(例如,因为s’很靠近右侧目标),这个高价值也会通过γ * max_future_q部分“传播”回当前状态s的Q值中。这样,鼓励智能体朝着未来有高回报的方向移动,而不仅仅是追求即时奖励。


章节 5:扩展到二维空间与贪吃蛇 🎮

掌握了Q学习在一维空间的工作原理后,我们可以将其扩展到二维空间。状态变为(x, y)坐标,动作变为上、下、左、右四个方向。奖励函数也需要重新定义:例如,碰到边界或自身(对于蛇来说)给予负奖励,吃到“苹果”给予正奖励。

为了玩贪吃蛇,我们需要定义更复杂的状态。如果只把蛇头的位置作为状态,智能体无法知道苹果在哪里。因此,我们需要在状态中编码更多信息。一个简单(但有点“作弊”)的方法是,将状态定义为:

  1. 蛇头上下左右四个方向的单元格内容(是墙、身体、苹果还是空地)。
  2. 苹果相对于蛇头的水平和垂直距离(dx, dy)。

// 伪代码:贪吃蛇的增强状态定义
struct SnakeState {
    char up, down, left, right; // 四周环境
    int dx_to_apple, dy_to_apple; // 与苹果的相对位置
    // 需要定义比较运算符以便用作map的键
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/fa6f7e9b1fb67db54d197345948984ed_15.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/fa6f7e9b1fb67db54d197345948984ed_17.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/cpponsea-2025/img/fa6f7e9b1fb67db54d197345948984ed_19.png)

std::map<SnakeState, std::map<Action, double>> Q_table; // 二维Q表

智能体根据这个增强后的状态来选择动作。奖励可以设置为:吃到苹果+10,撞墙或撞身体-10,每存活一步-0.1(鼓励快速找到苹果)。通过大量的训练(让程序自己玩很多很多局),Q表会逐渐填充,智能体最终能学会寻找苹果并避免撞墙或撞到自己。

在实现时,可以用std::deque来高效地表示蛇的身体(头部推进,尾部弹出,吃到苹果时不弹出)。


总结

本节课我们一起学习了强化学习的基础知识。我们从最简单的一维随机漫步开始,逐步引入了固定模型、Q表、以及完整的Q学习算法。我们了解了如何通过状态动作奖励Q值更新公式来让智能体从零开始学习解决一个问题。最后,我们探讨了如何将这些原理应用到二维的贪吃蛇游戏中,并讨论了状态表示的重要性。

核心要点回顾:

  1. 强化学习的核心是智能体通过与环境交互获得的奖励信号来学习
  2. ε-贪婪策略平衡了“利用”已知知识和“探索”新可能。
  3. Q表是一种存储状态-动作价值对的简单方法。
  4. Q学习算法使用贝尔曼方程更新Q值,考虑了即时奖励和未来奖励。
  5. 将问题从简单维度开始(如一维),是理解和实现复杂算法(如用于二维贪吃蛇)的有效方法。

通过这个从“纸袋”到“玩蛇”的旅程,希望你对强化学习有了一个直观且实用的初步认识。你可以尝试调整参数(α, γ, ε)、修改奖励函数或状态表示,观察它们对智能体学习效果的影响。

022:理解 C++ 值类别、左值、右值、x值、std::move、std::forward 与最佳实践

在本节课中,我们将要学习 C++ 中一个核心但常被误解的概念:值类别。我们将从基础定义出发,深入探讨左值、右值、x值等概念,并理解 std::movestd::forward 的工作原理及其最佳实践。掌握这些知识将帮助你编写出语义正确且性能优异的现代 C++ 代码。

1: 为什么需要学习值类别?

值类别是关于表达式的属性,而非对象或类型。理解值类别是深入理解移动语义、完美转发等现代 C++ 特性的基石。当编译器报错“左值引用无法绑定到右值”时,明确的值类别知识能帮助你快速定位问题。此外,它也是编写高效、正确代码的关键。

2: 表达式与值类别基础

上一节我们介绍了学习值类别的重要性,本节中我们来看看值类别的基础——表达式。

表达式是运算符和操作数的序列,用于指定一个计算。每个表达式都有两个独立的属性:类型值类别。类型告诉我们值的种类(如 intconst char*),而值类别描述了表达式的值在内存中的“行为”特征,例如它是否具有持久身份,以及其资源是否可以被“移动”。

在 C++11 之前,值类别系统较为简单,只有左值和右值两种。这限制了我们对“即将失效但仍有身份的对象”的表达能力,阻碍了移动语义的实现。

3: C++11 引入的新值类别

上一节我们回顾了旧的值类别系统,本节中我们来看看 C++11 如何通过引入新类别来解决其局限性。

C++11 委员会通过分析表达式的两个核心属性来重新定义值类别:

  1. 是否具有身份:表达式结果是否代表一个具有持久内存位置的对象?
  2. 是否可被移动:能否安全地“窃取”该表达式所代表对象的资源?

基于这两个属性,我们得到了三个主要的基础值类别和两个复合类别

  • 左值:有身份,不可移动(例如:变量名 x)。
  • 纯右值:无身份,可移动(例如:字面量 42,函数返回的非引用临时对象)。
  • 将亡值:有身份,可移动(例如:std::move(x) 的结果)。

复合类别用于简化描述:

  • 泛左值:包括左值和将亡值。泛指所有有身份的表达式。
  • 右值:包括纯右值和将亡值。泛指所有可移动(即将销毁)的表达式。

以下是三种基础值类别的示例:

  • 左值示例:变量 x、解引用 *ptr、前缀 ++a、字符串字面量 "hello"
  • 纯右值示例:字面量 truenullptr、算术运算 a + b、后缀 a++this 指针、Lambda 表达式。
  • 将亡值示例std::move(x)、访问将亡值对象的成员(如 std::move(obj).data_)。

4: 右值引用与移动语义

上一节我们明确了新的值类别,本节中我们来看看如何利用新的工具——右值引用来实现移动语义。

为了解决旧系统中无法绑定并修改右值的问题,C++11 引入了右值引用,语法为 T&&。它专门用于绑定到右值(纯右值或将亡值),从而允许我们安全地“窃取”这些即将失效对象的资源。

关键点:一个变量(如 int&& rr)本身的值类别是左值(因为它有名字和持久存储),但其类型是右值引用。这解释了为何 std::move(x) 是必要的:它将一个左值 x类型转换为右值引用,从而生成一个将亡值表达式,使其能绑定到接受 T&& 的函数。

std::move 的本质是一个静态类型转换,它并不移动任何东西。其简化实现如下:

template <typename T>
typename std::remove_reference<T>::type&& move(T&& t) {
    return static_cast<typename std::remove_reference<T>::type&&>(t);
}

它移除 T 可能带有的引用,然后强制转换为右值引用类型。

移动语义的核心思想是重用临时对象或标记为“将亡”的对象的内部资源。移动构造函数和移动赋值运算符利用右值引用实现资源所有权的转移,避免深拷贝。

以下是一个移动构造函数的示例:

class Buffer {
    int* data_;
public:
    // 移动构造函数
    Buffer(Buffer&& other) noexcept : data_(other.data_) {
        other.data_ = nullptr; // “窃取”资源并使源对象处于有效但空的状态
    }
    // ... 其他成员
};

5: 转发引用与完美转发

上一节我们介绍了右值引用和移动语义,本节中我们来看看一个特殊的引用类型——转发引用,以及如何实现完美转发。

T&& 出现在模板参数推导的上下文中时(例如 template void foo(T&& arg)),它被称为转发引用万能引用。它的特殊之处在于,根据传入实参的值类别,它可以通过引用折叠规则推导出不同的类型:

  • 传入左值时,T 被推导为 T&T&& 折叠为 T&(左值引用)。
  • 传入右值时,T 被推导为 TT&& 保持为 T&&(右值引用)。

因此,转发引用可以绑定到左值或右值。但是,在函数内部,具名的转发引用参数 arg 本身是一个左值。如果我们想将它传递给另一个函数,并保持其原始的值类别(左值保持左值,右值保持右值),就需要使用 std::forward

std::forward 是一个有条件的转换,它仅在参数原始值是右值时才将其转换为右值引用。它通常与转发引用一起使用,以实现完美转发。

以下是一个使用转发引用和 std::forward 的通用函数模板示例,它可以替代接受 const T&T&& 的两个重载:

template <typename T>
void addResource(T&& res) { // T&& 是转发引用
    resources_.push_back(std::forward<T>(res)); // 完美转发 res 的值类别
}

6: 最佳实践与常见陷阱

上一节我们探讨了转发引用和完美转发,本节中我们总结一些围绕值类别和移动语义的最佳实践与常见陷阱。

以下是编写现代 C++ 代码时应遵循的一些关键准则:

  • 返回局部对象时,直接返回值,避免使用 std::move。编译器会进行返回值优化,额外的 std::move 有时反而会阻碍优化。
  • 仅在模板参数推导的上下文中使用 std::forward。对已知类型的左值使用 std::forward 是危险的,因为它会错误地将其转换为右值。
  • 为移动操作标记 noexcept。标准库容器(如 std::vector)在重新分配内存时,会优先使用 noexcept 的移动构造函数,否则将回退到拷贝操作。使用 std::move_if_noexcept 可以查看这一机制。
  • 避免返回 const。返回 const 对象会抑制移动语义,因为 const 临时对象只能绑定到 const T&const T&&,无法调用非 const 的移动操作。
  • 理解何时发生拷贝/移动。结合 C++17 的强制拷贝消除规则,清晰地知道代码中对象构造和赋值的成本。

总结

本节课中我们一起学习了 C++ 值类别的完整体系。我们从表达式的基础属性出发,理解了左值、纯右值和将亡值的定义与区别。我们探讨了右值引用如何作为绑定并操作可移动对象的工具,以及 std::move 如何生成将亡值来启用移动语义。接着,我们学习了在模板推导中特殊的转发引用,以及如何使用 std::forward 实现参数的完美转发。最后,我们回顾了在实际编码中应用这些概念时应遵循的最佳实践和需要避免的陷阱。掌握这些知识将使你能够更自信地编写高效、健壮的现代 C++ 程序。

023:我们已经拥有的模式匹配 🧩

在本节课中,我们将要学习C++语言中已经存在的几种“模式匹配”机制。尽管标准委员会正在讨论更直观的模式匹配语法提案,但C++早已通过函数重载、模板特化、模板参数推导和类模板参数推导等方式,实现了强大的编译时模式匹配能力。我们将逐一探讨这些机制的基本原理和运作方式。

函数重载解析

上一节我们介绍了课程概述,本节中我们来看看C++中最基础的模式匹配形式:函数重载解析。这是自C++98标准以来就存在的特性,它允许我们定义多个同名但参数列表不同的函数。

函数重载是C++区别于C语言的一个标志性特性。在C语言中,要实现类似效果通常需要手动进行类型检查或使用不同的函数名,而在C++中,编译器可以自动为我们选择最匹配的函数版本。

以下是函数重载的一些关键规则示例:

  • 类型别名视为相同类型void f(int);void f(Int);(其中 using Int = int;)是同一个函数的重复声明。
  • 枚举与基础类型不同void f(int);void f(enum E); 是两个不同的重载,即使枚举的底层类型是 int
  • 顶层const被忽略void f(int);void f(const int); 是同一个函数的重复声明。但 void f(int*);void f(const int*); 则是不同的重载,因为这里的const不是顶层const。
  • 数组到指针的转换void f(char*);void f(char[]);void f(char[10]); 是同一个函数的三种声明方式。

函数重载解析的机制遵循以下步骤:

  1. 收集候选函数:根据调用上下文(如函数调用、运算符重载、构造函数等)找出所有可能匹配的函数。
  2. 确定可行函数:从候选函数中移除参数数量不匹配、约束不满足或参数类型无法转换的函数。
  3. 选择最佳匹配:对剩下的可行函数,根据参数到形参的隐式转换序列进行排序,选择转换“最好”的那个。

让我们通过一个例子来理解这个过程。假设有以下重载函数:

void f(bool);                 // (1)
void f(double);              // (2)
void f(int, int = 0);        // (3)
void f(...);                 // (4)
void f(std::unique_ptr<int>); // (5)
// (6)-(8) 为带约束的模板,略

当我们调用 f(nullptr) 时:

  • 步骤1:移除参数数量错误的(1)和(4)。
  • 步骤2:移除约束不满足的(8)。
  • 步骤3:nullptr 无法转换为 doubleint,因此移除(2)和(3)。nullptr 可以转换为 bool、匹配省略号 ... 以及 std::unique_ptr<int>
  • 最终可行集为:(1), (4), (5)。
  • 排序转换:bool 转换是标准转换序列,std::unique_ptr<int> 转换是用户定义转换序列,... 是省略号转换序列。标准转换序列优于用户定义转换序列,后者又优于省略号转换序列。
  • 因此,最终选择调用 f(bool)

模板特化

上一节我们探讨了函数重载,本节中我们来看看另一种基于模板的模式匹配:模板特化。模板特化同样是C++98就存在的特性,它是C++11类型 Traits 库的基石。

我们可以将模板特化想象为一种针对类型的“模式匹配”。虽然我们不能直接写出 match (T, U) { case (T, T): true; default: false; } 这样的语法,但我们可以通过模板特化来实现类似逻辑。

以下是如何实现一个判断类型是否相同的 Traits:

// 主模板:默认情况,类型不同
template <typename T, typename U>
struct is_same {
    static constexpr bool value = false;
};

// 部分特化:匹配当两个类型参数相同时的情况
template <typename T>
struct is_same<T, T> { // 匹配模式:is_same<T, T>
    static constexpr bool value = true;
};
// 使用
static_assert(is_same<int, int>::value == true);
static_assert(is_same<int, int*>::value == false);

另一个例子是移除const限定符的Traits:

// 主模板:默认情况,直接使用类型T
template <typename T>
struct remove_const {
    using type = T;
};

// 部分特化:匹配带有顶层const的类型
template <typename T>
struct remove_const<const T> { // 匹配模式:const T
    using type = T;
};

模板特化主要分为三类:

  • 主模板:通用的模板定义。
  • 部分特化:为模板参数指定一部分模式,例如 template <typename T> struct Widget<T, T>
  • 显式特化:为模板参数指定全部具体类型,例如 template <> struct Widget<int, char>

当编译器需要实例化一个模板时,它会按以下顺序进行“匹配”:

  1. 首先尝试匹配显式特化
  2. 如果没有匹配的显式特化,则尝试匹配部分特化
  3. 如果也没有匹配的部分特化,则使用主模板

如果多个部分特化同时匹配,编译器需要选择“最特化”的那个。这个过程是通过将类模板特化“重写”为虚构的函数模板,并利用函数重载解析和模板参数推导来完成的。

模板参数推导

上一节我们介绍了模板特化如何匹配类型模式,本节中我们来看看支撑这些匹配过程的核心机制:模板参数推导。它允许编译器在调用函数模板时,根据传入的实参自动推断模板参数的类型。

当我们编写 std::vector<int> v; foo(v);foo 定义为 template <typename T> void foo(std::vector<T>); 时,编译器能推导出 Tint。这就是模板参数推导。

推导的基本机制是,将每个函数参数与对应的函数实参进行匹配。编译器会忽略顶层const/volatile限定符,并处理一系列“特殊形式”,例如指针、引用、数组、函数等。

让我们通过几个步骤示例来理解:

示例1template <typename T> void foo(T*); 调用 foo(&std::vector<int>{})

  • 参数:T*
  • 实参:std::vector<int>*
  • 两者都匹配“指向T的指针”这个特殊形式。去掉指针后,得到 T 匹配 std::vector<int>。因此 T 被推导为 std::vector<int>

示例2template <typename T> void foo(std::vector<T*>); 调用 foo(&std::vector<int>{})

  • 参数:std::vector<T*>,实参:std::vector<int>*
  • 匹配指针形式,去掉指针:std::vector<T> 匹配 std::vector<int>
  • 两者都是类模板 std::vector 的实例化,匹配模板形式,去掉模板:T 匹配 int。因此 T 被推导为 int

有时,我们希望某些参数不参与推导,这可以通过“非推导上下文”实现。最常见的非推导上下文包括:

  • 限定名称中的嵌套名称说明符::左边的部分),例如 typename S<T>::type 中的 S<T>
  • decltype 表达式,例如 decltype(S<T>{})
  • 当形参不是 std::initializer_list,而实参是花括号初始化列表时

例如,std::max 的经典实现可能遇到问题:std::max(1, 2.0) 会因为 T 被分别推导为 intdouble 而失败。我们可以通过将第二个参数设为非推导上下文来解决:

template <typename T>
struct type_identity { using type = T; };

template <typename T>
T max(T a, typename type_identity<T>::type b) {
    return a < b ? b : a;
}
// 现在 max(1, 2.0) 可以工作,T被推导为int,第二个参数也是int

最后,我们谈谈转发引用(T&&)的推导,它涉及引用折叠规则:

  • T& &T& &&T&& & 都会折叠成 T&
  • 只有 T&& && 会折叠成 T&&

这使得 template <typename T> void foo(T&&) 能够根据实参是左值还是右值,将 T 分别推导为引用类型或非引用类型,从而实现完美转发。

类模板参数推导

上一节我们深入了解了函数模板的参数推导,本节中我们来看看C++17引入的类模板参数推导。它允许我们在创建类模板实例时,像使用函数模板一样省略模板参数,让编译器根据构造函数参数进行推导。

例如,我们可以直接写 std::pair p(1, 2.0); 而无需写成 std::pair<int, double> p(1, 2.0);。编译器会推导出 p 的类型是 std::pair<int, double>

CTAD的机制是,编译器为类的每个构造函数以及每个用户定义的“推导指引”生成一个虚构的“指导函数模板”。然后,它对这些指导函数进行重载解析和模板参数推导。获胜的指导函数的返回类型,就是被推导出的类类型。

假设我们有一个类模板:

template <typename T, typename U>
struct MyStruct {
    std::vector<T> vec;
    std::pair<U, double> pr;
    MyStruct(std::vector<T>, std::pair<U, double>); // 构造函数1
    template <typename V>
    MyStruct(T, std::pair<U, V>); // 构造函数2(模板)
};

编译器会生成对应的指导函数(概念上):

  • 对应构造函数1:template <typename T, typename U> MyStruct<T, U> __guide(std::vector<T>, std::pair<U, double>);
  • 对应构造函数2:template <typename T, typename U, typename V> MyStruct<T, U> __guide(T, std::pair<U, V>);

当我们写 MyStruct m(v, p); 时,编译器会用实参 (v, p) 对这些 __guide 进行重载解析。匹配成功的那个 __guide 的返回类型 MyStruct<X, Y> 就是 m 的推导类型。

有时自动生成的推导指引可能产生歧义或不是我们想要的结果。这时,我们可以编写显式的推导指引来引导编译器:

template <typename T, typename U, typename V>
MyStruct(std::vector<T>, std::pair<U, V>) -> MyStruct<T, std::pair<U, V>>;

这个指引告诉编译器:当看到用 (std::vector<T>, std::pair<U, V>) 参数列表来推导 MyStruct 时,应该推导成 MyStruct<T, std::pair<U, V>> 这个类型。推导指引必须出现在类模板的同一作用域(通常是之后),并且最终必须有一个构造函数能与推导出的类型匹配。

总结 🎯

本节课中我们一起学习了C++语言中已经存在的四种“模式匹配”机制。

尽管针对更直观的模式匹配语法(如 inspectmatch 关键字)的提案正在标准化进程中,但C++早已通过现有特性提供了强大的编译时模式匹配能力。函数重载解析允许根据参数类型和数量选择不同的函数。模板特化允许根据类型模式选择不同的类模板或函数模板实现。模板参数推导是编译器根据使用上下文自动推断模板类型参数的核心机制。类模板参数推导则将这种推导能力扩展到了类模板的实例化上。

这些机制的通用规则通常非常优雅,使得我们能够编写出灵活而强大的泛型代码。虽然它们的某些边缘情况可能非常复杂,但理解其基本工作原理,有助于我们进行类比推理,更好地理解和欣赏C++的设计哲学与演进历程。

024:构建健壮的进程间队列教程

概述

在本教程中,我们将学习如何在C++程序中构建健壮的进程间队列。我们将探讨进程间通信的核心概念、常见陷阱,并学习如何设计一个既高效又可靠的队列系统。我们将从分析一个现有的线程间队列开始,逐步深入到跨进程场景下的挑战与解决方案。


第1章:引言与核心挑战

在开始构建队列之前,我们需要理解进程间通信(IPC)与线程间通信的根本区别。线程共享相同的内存地址空间,因此队列可以是一个简单的内存结构,只需关注同步问题。然而,进程拥有独立的内存空间,这使得数据共享变得复杂。

核心挑战:如何在不同进程的独立地址空间之间安全、高效地传递数据?

上一节我们介绍了基本背景,本节中我们来看看进程间队列的具体定义和期望特性。

进程间队列的定义与目标

一个进程间队列本质上是一个先进先出(FIFO) 的消息缓冲区,它支持多种消息类型,并涉及一个或多个独立的进程。

在设计时,我们追求以下几个目标:

  • 低延迟:消息传递必须快速。
  • 可恢复性:当生产者或消费者进程崩溃时,系统应能从中断处恢复。
  • 可轮询:消费者应能高效地检查是否有新消息,通常通过热循环实现。
  • 易用性:遵循“迈尔斯API”原则——易于正确使用,难以错误使用。


第2章:案例分析:Drigaard 队列

为了理解队列的构建原理,我们先分析一个广泛使用的线程间队列——由 Casper Drigaard 设计的 Drigaard 队列。它是一个为单生产者、单消费者模型设计的高性能队列。

队列结构分析

Drigaard 队列的核心是一个环形缓冲区(ring buffer)。它的设计巧妙之处在于将状态分为共享状态本地状态,并利用缓存行对齐来避免伪共享,提升性能。

以下是其状态结构的简化表示:

// 共享状态(生产者与消费者之间同步)
struct SharedState {
    std::atomic<size_t> writer_pos; // 对齐到独立的缓存行
    std::atomic<size_t> reader_pos; // 对齐到独立的缓存行
};

// 本地状态(各自线程独立)
struct LocalState {
    size_t cached_write_pos; // 本地缓存的写位置
    size_t cached_read_pos;  // 本地缓存的读位置
    char* buffer_ptr;        // 指向缓冲区内存的指针
    // ... 其他窗口计算变量
};

关键点

  1. 共享状态:仅包含两个原子变量,用于生产者与消费者之间的进度同步。
  2. 本地状态:每个线程维护自己的缓存位置,大部分操作无需访问共享原子变量,极大提升了性能。
  3. 缓存行对齐writer_posreader_pos 被放置在不同的缓存行上,防止一个线程更新变量时导致另一个线程的缓存失效。

写入流程剖析

写入操作分为两步:prepare_writefinish_write

  1. prepare_write:请求指定大小的缓冲区空间。如果空间不足,则通过自旋等待直到读者释放足够空间。此操作主要更新本地状态
  2. 数据填充:用户将数据复制到 prepare_write 返回的指针处。
  3. finish_write:提交写入。此操作将更新后的位置写入共享状态的原子变量,使新数据对消费者可见。

读取流程与此对称,包含 prepare_readfinish_read

从线程间到进程间的障碍

虽然 Drigaard 队列在单进程多线程环境下表现优异,但直接将其放入共享内存用于进程间通信会遇到几个关键问题:

  1. 指针无效:队列内部的 buffer_ptr 在生产者进程和消费者进程中的虚拟地址值不同,需要进程初始化时重新附着(reattach)。
  2. 非平凡类型:队列的构造函数设置了初始值,这使得它不再是“平凡可默认构造”类型,违反了 C++ 对象在共享内存中隐式生命期的规则(C++20 起)。
  3. std::atomic 问题:从 C++20 开始,std::atomic 的默认构造函数不再是平凡的,因此 std::atomic<size_t> 本身也不能作为隐式生命期类型放入共享内存,这会导致未定义行为。
  4. 内存分配:如果写入的数据类型(如 std::string)在构造时进行了堆内存分配,那么指向堆内存的指针在另一个进程中是无效的,会导致崩溃。

上一节我们深入分析了一个高性能线程队列及其局限,本节中我们来看看 C++ 标准中关于对象生命期和共享内存的关键规定。


第3章:C++ 对象生命期与共享内存

这是将 C++ 对象用于进程间通信时最容易被忽视且最关键的部分。

隐式生命期类型

在 C++20 之前,以下代码是未定义行为:

// 在共享内存区域(例如通过 mmap 获得)
void* shared_mem = get_shared_memory();
int* p_int = static_cast<int*>(shared_mem);
*p_int = 42; // 未定义行为:int 对象的生命期尚未开始

虽然这段代码在许多平台上“似乎”能工作,但 C++ 抽象机认为 int 对象从未被构造,因此访问它是非法的。

C++20 引入了隐式生命期类型规则。像 malloccalloc 等特定操作会为隐式生命期类型隐式创建对象,从而开始其生命期。

何为隐式生命期类型?

一个类型是隐式生命期类型,如果它是:

  • 标量类型(如 int, double, 指针
  • 隐式生命期类类型
  • 上述类型的数组或 CV 限定版本

隐式生命期类类型需满足以下条件之一:

  1. 它是一个聚合体。
  2. 它至少有一个平凡的合格构造函数和一个平凡的、非删除的析构函数

问题诊断

让我们用这个规则检查之前队列的组件:

  1. LocalState:它有一个用户定义的构造函数(即使只是设零),因此不是平凡构造函数。它不是隐式生命期类类型。
  2. std::atomic<size_t>:从 C++20 起,它的默认构造函数是用户定义的(执行默认初始化),因此不是平凡的。它也不是隐式生命期类型。

结论:直接将包含这些类型的结构体放入共享内存(通过 mmap 等),在 C++20 及以后的标准下是未定义行为

解决方案探讨

  1. 使用 std::start_lifetime_as (C++23):这个新函数可以显式地在存储中开始一个对象的生命期。但是,它要求对象类型是隐式生命期类型,所以对 std::atomic 无效。
  2. 使用 std::atomic_ref:不在共享内存中存储 std::atomic,而是存储一个普通的 size_t,然后在每个进程中用 std::atomic_ref<size_t> 来包装并操作它。这要求所有访问都必须通过 atomic_ref 进行。
  3. 自定义包装器:创建一个平凡的包装器来持有数据,并手动实现原子操作(或内部使用 std::atomic_ref)。虽然丑陋,但能保证合法性。
    struct TrivialAtomicWrapper {
        alignas(sizeof(size_t)) char data[sizeof(size_t)];
        // 提供 load, store, exchange 等成员函数,内部使用 std::atomic_ref
    };
    

上一节我们厘清了对象生命期的复杂规则,本节中我们来看看如何通过关注点分离来设计一个健壮的进程间队列 API。


第4章:设计原则:关注点分离

构建健壮的进程间队列不能只关注数据结构和算法,必须将系统层面的关注点分离。

队列管理器

队列本身应作为一个独立的实体进行管理,其职责包括:

  • 生命周期管理:创建、销毁队列。
  • 元数据管理:存储队列容量、对齐方式、唯一标识符(UUID)、签名(防止连接错误队列)。
  • 生产者锁:强制执行“单生产者”规则。任何进程想成为生产者,必须通过队列管理器获取一个进程级锁

进程级锁的实现挑战与方案

如果一个生产者进程崩溃时持有锁,锁可能永远不会被释放,导致备份进程无法接管。我们需要一个健壮锁

解决方案思路(类 Unix 系统):

  1. 在共享内存中存储一个进程唯一标识符作为锁。标识符可包含 PID 和进程启动时间戳。
  2. 获取锁时,使用原子操作尝试将自己的标识符写入。
  3. 如果写入失败(锁已被占),则检查持有锁的进程是否还存活(例如通过 kill(pid, 0))。
  4. 如果进程已死亡,则当前进程可以“夺取”锁,并更新标识符为自己。

注意:计算进程唯一标识符成本较高,应在进程内缓存。同时,需要考虑 fork() 调用后子进程 PID 变化的情况,通常使用 pthread_atfork 处理程序来重置缓存的 PID。

生产者与消费者类

将队列的访问逻辑封装到独立的 ProducerConsumer 类中。

  • Producer:负责向队列写入数据。它必须通过队列管理器获取生产者锁。
  • Consumer:负责从队列读取数据。对于多消费者队列,可能有普通消费者和特殊消费者之分。

这种分离使得 API 更安全,逻辑更清晰,并且更容易实现可恢复性等高级特性。


第5章:多播队列与可恢复性

多播队列(MCast Queue)是进程间通信中一种非常重要的模式,它模拟了网络多播:单生产者发出的每条消息,所有消费者都能收到。

传统多播队列的缺陷

大多数生产环境中的多播队列为了实现最高速度,采用“生产者永不阻塞”的策略。如果消费者太慢,导致缓冲区被覆盖(被“套圈”),消费者通常会检测到这种情况然后崩溃。这不是真正的“可恢复”。

通过“特殊消费者”实现可恢复性

一种改进的设计是引入一个特殊消费者

工作流程

  1. 生产者特殊消费者之间是阻塞同步的。生产者写入时,如果特殊消费者没读完,生产者会等待。这保证了特殊消费者永远不会丢失消息
  2. 普通消费者非阻塞轮询方式读取。它们追求低延迟,但如果处理太慢,可能会丢失消息。
  3. 当普通消费者崩溃或重启后,发现自己丢失了消息,它可以向特殊消费者(或由特殊消费者持久化的存档)请求重传丢失的消息。

优势

  • 生产者在绝大多数情况下(面对普通消费者)不阻塞,保持低延迟。
  • 系统可恢复:通过特殊消费者提供的存档/重传机制,普通消费者可以从错误中恢复。
  • 可监控:通过收集生产者阻塞、消费者延迟等统计信息,可以提前预警性能问题。

这种设计可以通过模板或概念在队列创建时配置,灵活地选择是否需要可恢复性特性。


总结

在本教程中,我们一起学习了构建健壮 C++ 进程间队列的完整路径。

我们从分析一个高效的线程间队列开始,认识到将其直接用于进程间通信时,会遇到指针有效性对象生命期(特别是 C++20 隐式生命期规则)和 std::atomic 的非平凡性等深层次问题。

接着,我们强调了关注点分离的设计原则,提出应由队列管理器负责生命周期、元数据和进程锁,由独立的 ProducerConsumer 类封装访问逻辑,从而构建更安全、更易用的 API。

最后,我们探讨了多播队列的场景,并通过引入特殊消费者的概念,设计了既能保持高性能又能实现可恢复性的方案。

关键要点:

  1. 进程间通信必须考虑独立的地址空间。
  2. C++20 后的对象生命期规则对共享内存使用有重大影响。
  3. 良好的系统设计需要分离队列管理、同步和数据处理等关注点。
  4. 通过巧妙的架构(如特殊消费者),可以兼顾低延迟和高可靠性。

希望本教程能帮助你在下一次需要构建进程间通信设施时,避免“追逐巴士”式的匆忙决策,而是从容地设计出健壮、高效的解决方案。

025:命名空间101

在本节课中,我们将要学习C++中命名空间的核心概念、工作机制以及最佳实践。命名空间是组织代码、避免命名冲突的强大工具,对于编写清晰、可维护的大型项目至关重要。

1:命名空间简介

命名空间是C++中用于封装代码、创建新作用域的关键字。它允许我们将不同的符号(如变量、函数、类)分组在一个指定的名称下。

定义命名空间的语法如下:

namespace MyNamespace {
    int myVariable;
    void myFunction() { /* ... */ }
}

你可以通过作用域解析运算符 :: 来访问命名空间内的成员,例如 MyNamespace::myVariable

使用命名空间的主要目的是:

  • 更好的代码组织:将代码划分为逻辑单元。
  • 提高可读性:清晰的命名空间结构使代码意图更明确。
  • 减少命名冲突:这是使用命名空间最重要的原因,它能有效避免不同库或模块之间的重定义错误和命名冲突。
  • 提升项目可扩展性:使大型代码库更易于管理。

具体来说,命名空间能帮助我们:

  • 避免不同库之间的名称碰撞。
  • 保持大型项目API的可管理性。
  • 在模块之间创建清晰的边界。

2:命名空间的演进

上一节我们介绍了命名空间的基本概念,本节中我们来看看命名空间在C++标准中的发展历程。

命名空间并非最初C++语言的一部分。它们是在20世纪90年代被引入的,并正式成为C++98标准的一部分。早期的实现就包含了防止命名冲突、创建逻辑分组、控制作用域等关键特性,并提供了标准命名空间 stdusing 指令和 using 声明。

以下是不同C++标准对命名空间的增强:

  • C++11 引入了内联命名空间。内联命名空间中的符号会被视为其父命名空间的一部分,这使得API版本控制更加方便。开发者可以将希望默认使用的版本标记为内联。
  • C++17 引入了嵌套命名空间定义的简化语法。过去需要层层嵌套定义,现在可以写为 namespace A::B::C { ... }
  • C++20 进一步允许简化嵌套内联命名空间的定义,可以写为 namespace A::inline B { ... }

3:命名空间机制与作用域规则

了解了命名空间的演进后,本节我们将深入探讨命名空间的核心机制,特别是名称查找规则。

名称查找主要有三种类型:限定名称查找、非限定名称查找和实参依赖查找。

限定名称查找

当名称中包含作用域解析运算符 :: 时,就会发生限定名称查找,例如 A::B::x。查找过程被严格限制在运算符左侧指定的作用域内,不会自动跳转到外层或其他作用域。

以下是几个关键示例:

  • 如果在指定作用域内直接找到了符号,则使用它。
  • 如果该作用域内有 using 指令引入了其他命名空间的符号,且在当前作用域内未找到直接定义,则可能会找到被引入的符号。
  • 限定查找不会自动查找父级命名空间。如果 A::B 中找不到 w,即使 A 中有 w,也会导致编译错误。

非限定名称查找

对于不包含 :: 的名称(如直接使用 x),会发生非限定名称查找。编译器会按照以下优先级顺序向外层作用域搜索:

  1. 局部作用域(从最内层开始)。
  2. 类或结构体作用域。
  3. 命名空间作用域(从最内层到外部)。
  4. 全局作用域。
  5. 通过 using 指令引入的符号(在全局作用域层级被考虑)。

需要注意的是,using 声明(如 using std::cout;)引入的符号,其查找优先级与局部作用域相同。

限定和非限定查找常常结合使用。例如,在表达式 B::x 中,首先对 B 进行非限定查找以确定 B 所指的命名空间,然后再在找到的 B 中进行对 x 的限定查找。

实参依赖查找

实参依赖查找(ADL)是非限定查找的扩展,仅适用于函数调用。它会将函数实参所属的命名空间也纳入查找范围。

ADL非常有用,尤其是在操作符重载和编写泛型代码时。它允许我们直接调用与自定义类型在同一命名空间下的函数或操作符,而无需显式限定命名空间,从而大大提高了代码的可读性。但需注意,ADL不适用于类名。

4:命名空间使用最佳实践

在深入理解命名空间的机制后,本节我们将聚焦于如何在实际项目中有效且安全地使用命名空间。

避免命名空间污染

“污染”指的是在一个作用域内引入了过多标识符,增加了命名冲突和混淆的风险。主要污染源是 using 指令(using namespace ...;)。

using 指令会将指定命名空间的所有符号引入当前作用域,应尽量避免使用,尤其是在头文件中。它会导致:

  • 可读性下降:难以判断符号的来源。
  • 命名冲突:可能引发编译错误或更隐蔽的行为改变。

using 声明using std::cout;)只引入单个符号,优于 using 指令,但仍需谨慎使用,最好仅限于源文件(.cpp)中。

命名空间别名namespace abbr = Very::Long::Namespace;)可以缩短长命名空间名称,提高可读性,但也应避免在头文件中使用,以防重定义或遮蔽。

对于字面值命名空间(如 std::chrono_literals),使用 using 指令是必要且受鼓励的,它能显著提升代码可读性(例如 auto d = 5s;)。

匿名命名空间

匿名命名空间用于限制符号的链接属性,使其仅在当前翻译单元(.cpp文件)内可见。这非常适合放置不需要对外公开的实现细节函数或变量。在头文件中使用匿名命名空间是错误做法,会导致每个包含该头文件的翻译单元都拥有该符号的独立副本,可能违反单一定义规则。

核心指南与行业实践

以下是综合了核心C++指南和行业实践的一些关键建议:

应该做的:

  • 将辅助函数放在其支持的类所在的命名空间:这明确了关系并受益于ADL。
  • 在操作数所在的命名空间内定义重载操作符:同样是为了利用ADL和提高可读性。
  • 使用命名空间来表达逻辑结构:命名空间不仅用于避免冲突,也应反映程序的领域逻辑。
  • 在源文件中使用匿名命名空间:用于隐藏内部实现细节。
  • 在源文件中使用 using 声明:以减少重复的限定,但应靠近使用点。
  • 谨慎使用命名空间别名:以简化长名称,但需确保别名清晰。

不应该做的:

  • 避免使用 using namespace std;(尤其是在头文件中):这是最常见的污染源。
  • 不要向 std 命名空间添加实体:这通常会导致未定义行为(特化模板等少数情况除外)。
  • 不要在头文件中使用匿名命名空间
  • 避免过度嵌套的命名空间:通常2-3层为宜,过深会损害可读性。
  • 不要在宏中省略完全限定:宏在预处理阶段展开,不了解命名空间,必须使用完全限定名。
  • 不要假设读者知道符号来源:清晰的命名和结构比节省打字更重要。

宏与命名空间

宏在预处理阶段展开,完全不了解C++的命名空间。因此,在宏定义中使用符号时,必须使用完全限定名(包括命名空间),否则当宏在不包含该符号定义的作用域内展开时,会导致查找失败。

总结

本节课中我们一起学习了C++命名空间的方方面面。

关键要点:

  • 使用命名空间来防止命名冲突组织代码结构
  • 警惕并避免命名空间污染,慎用 using 指令。
  • 优先使用细粒度、嵌套合理的命名空间,而非庞大单一的空间,但嵌套不宜过深。
  • 保持整个项目中命名空间的命名一致且有意义

通过遵循这些最佳实践,你可以充分利用命名空间的优势,构建出更清晰、健壮且易于维护的C++代码库。

026:安全、反射与 std::execution

概述

在本教程中,我们将学习 C++26 标准中引入的三个重要新特性。这些特性旨在提升代码的安全性、表达能力和并发编程模型,同时注重开发者体验和代码的可迁移性。我们将依次探讨:安全性增强(包括自动初始化与边界检查)、编译时反射,以及 std::execution 异步框架


章节 1:零成本采纳的安全性增强 🛡️

上一节我们概述了本教程的内容,本节中我们来看看第一个“很酷的东西”:一系列旨在提升代码安全性,且几乎无需修改现有代码即可受益的特性。

C++26 引入了几项重要的安全改进,其核心目标是让现有代码在重新编译为新标准后,能自动获得更高的安全性,而无需或仅需极少的代码改动。

1.1 自动初始化与“错误行为”

在 C++26 之前,未显式初始化的局部基本类型变量(如 int、数组)会拥有未定义行为。这意味着它们的值是任意的,可能导致程序崩溃、安全漏洞或难以调试的问题。

C++26 为此类情况引入了新的“错误行为”类别。它不再是未定义的,而是明确定义为错误。编译器被要求执行明确定义的操作,例如注入代码来检测并可能终止程序,或者用特定模式填充这些变量。

核心概念

  • 未定义行为int x; // C++26 前:值未定义,可能导致任何后果
  • 错误行为int x; // C++26:值被明确定义为“错误”,编译器会处理

这意味着,仅将代码重新编译为 C++26,就能自动消除大量因未初始化变量导致的安全漏洞和错误。如果出于性能原因确实需要未初始化变量,可以使用 [[indeterminate]] 属性显式选择退出此安全机制。

int safe_var; // C++26: 自动初始化(例如填充为特定模式)
[[indeterminate]] int fast_var; // 显式选择保留旧有的未定义行为

1.2 标准库的边界检查

C++26 采纳了“强化 C++ 标准库”提案,为标准库中大量常用的下标运算符和范围访问操作(如 vector::operator[]span::operator[]string_view::front/back)添加了可选的边界检查。

这是一个重要的安全增强,因为缓冲区溢出读写是每年最常见的安全漏洞类别之一。虽然标准未规定如何启用此功能(由编译器实现定义),但它为 C++ 最高频使用的内存操作区域铺设了一条安全的“主干道”。

实践表明(例如在 Apple 和 Google 的部署中),启用此类检查通常只带来约 3% 的性能开销,却能发现大量潜在错误和安全漏洞。

1.3 平凡重定位

“平凡重定位”是 C++26 引入的另一个能带来“免费”性能提升的特性。它允许在某些情况下(例如对象被移动后立即销毁),编译器可以执行比普通移动操作更高效的“位拷贝”式转移,而无需调用析构函数等额外操作。

核心概念

  • 移动语义T obj2 = std::move(obj1); // 调用移动构造函数/赋值运算符
  • 平凡重定位:对于可平凡重定位的类型,编译器可以优化为高效的位拷贝,跳过不必要的分支和检查。

重要的是,在 C++26 中,所有可平凡复制的类型现在都是可平凡重定位的。这意味着,当你的标准库实现更新后,重新编译现有代码(例如使用 std::vectorstd::unique_ptr 的代码)就可能自动获得性能提升,无需修改源代码。

本节总结:C++26 通过自动初始化、标准库边界检查和平凡重定位等特性,在几乎零代码改动成本的前提下,为程序提供了显著的安全性和性能提升。这体现了语言演进中对可采纳性的高度重视。


章节 2:改变游戏规则的编译时反射 🔮

上一节我们介绍了无需改动即可获益的安全特性,本节我们将深入探讨 C++26 中可能最具革命性的新特性:编译时反射。这被认为是 C++ 近二十年来最大的变革之一。

2.1 反射是什么?

简单来说,反射允许程序在编译时检视自身的结构。你可以编写代码来遍历类的成员函数、检查类型的属性、获取函数的参数列表等。这就像是程序获得了查询自身抽象语法树的标准化 API。

更强大的是,结合拼接技术,反射还能用于生成新的代码。这使得编写能够读取和修改自身的“元程序”成为可能。

与 C# 或 Java 的运行时反射不同,C++ 的反射完全在编译时进行。这意味着它不会带来任何运行时开销,除非你显式地将编译时计算的结果存储下来供运行时使用。

2.2 C++26 反射的里程碑意义

在 C++26 标准草案中,反射的基础设施被正式采纳。这包括:

  • 反射核心提案
  • 注解反射
  • 基类子对象拼接
  • 静态字符串/对象数组(便于将编译时数据用于运行时)
  • 扩展语句(编译时 for 循环)
  • 函数参数反射
  • 反射中的错误处理

这标志着 C++ 迈入了一个“全新的语言”阶段。正如专家所言,我们可能需要十年时间来充分发掘反射的全部潜力。

2.3 反射的应用示例:元类

一个经典的应用是“元类”概念。设想你可以这样定义一个接口:

class(interface) Widget {
    void f(int);
    std::string g();
};

这里的 interface 不是一个关键字,而是一个编译时常量函数(元函数)。编译器会调用这个函数,它通过反射分析 Widget 的声明,然后自动生成正确的代码:为所有函数添加 virtual= 0,生成虚析构函数,抑制拷贝操作等。

在 C++26 中,虽然我们还不能直接在同一个源文件中注入生成的代码(这预计是 C++29 的特性),但我们已经可以编写元函数,将生成的标准 C++ 代码输出到另一个文件,然后由构建系统编译。这已经能实现许多强大的代码生成场景。

以下是利用反射和注解实现更精细控制的简单示例:

class Widget {
    void f(int);
    [[suppress]] void g(); // 使用自定义注解标记
};

// 元函数可以检查 `suppress` 注解,并决定不将 `g` 纳入生成的接口中。

2.4 反射的广阔前景

反射的潜力远不止于简化类定义。以下是一些可能的应用方向:

以下是反射可能带来变革的领域:

  • 序列化/反序列化:自动生成 JSON、XML 等格式的序列化代码。
  • 测试 Mock:自动生成用于单元测试的 Mock 类。
  • 替代 CRTP:在许多场景下提供更清晰的替代方案。
  • 领域特定语言集成:在 C++ 内直接表达和转换 DSL。
  • 类型擦除:自动生成类型擦除包装器。
  • 多语言绑定:自动生成 Python、JavaScript 等语言的绑定代码。
  • 二进制元数据生成:例如为 Windows WinRT 生成所需的 .winmd 文件。

本节总结:C++26 引入的编译时反射是一个基础性的强大工具,它将极大地改变我们编写和组织 C++ 代码的方式。它不仅能减少样板代码、降低错误率,还能简化整个构建工具链,有望取代许多现有的专用代码生成器。


章节 3:统一的异步编程模型 ⚡

上一节我们探讨了改变语言范式的反射,本节我们来看看第三个“很酷的东西”:std::execution(又称发送者-接收者模型),这是 C++ 标准的异步编程框架。

3.1 异步模型的核心

同步函数调用会阻塞,直到函数执行完毕。异步编程的核心在于解耦任务的启动与完成

在 C++26 中,使用 std::execution 可以这样表达:

// 传统同步
int result = f(); // 调用并等待
use(result);

// C++26 异步
auto sender = std::execution::schedule(pool) // 在线程池调度
           | std::execution::then(f)        // 然后执行 f
           | std::execution::then(use);     // 然后使用结果

// 启动异步操作,并(可选)同步等待完成
std::this_thread::sync_wait(sender);

这里,schedulethen 通过管道符 | 连接,清晰地表达了异步任务流。任务在 pool 上启动后立即返回,调用者可以继续执行,直到需要结果时再通过 sync_wait 进行“汇合”。

3.2 框架的独特优势

std::execution 最引人注目的特点是,它能同时很好地服务于两种不同的并发范式

  1. 并发:管理多个独立、可能长时间运行的任务(如 UI 响应与后台计算)。这通常涉及协程、事件循环等。
  2. 并行:将单个计算任务分解,同时在多个硬件单元上执行以加快速度(如并行排序、GPU 计算)。

令人惊讶的是,同一个 std::execution 框架已被证明既能高效地用于 CPU 上的协程(并发),也能通过特定后端在 GPU 上运行数据并行任务(并行),且性能可与专用框架竞争。这为编写跨 CPU/GPU 的异构计算代码提供了统一的抽象。

本节总结std::execution 为 C++ 提供了一套现代、灵活且功能强大的异步编程模型。它统一了并发和并行编程的体验,是构建高性能、响应式应用程序的重要基础。


总结

本节课中我们一起学习了 C++26 标准中三个关键的新特性:

  1. 安全性增强:通过自动初始化、标准库边界检查和平凡重定位,以极低的采纳成本提升了代码的安全性和性能。
  2. 编译时反射:一个革命性的特性,允许程序在编译时检视和生成自身代码,将极大地改变 C++ 的编程范式、减少样板代码并简化工具链。
  3. std::execution 异步框架:提供了一套统一的模型来处理并发和并行任务,是现代异步 C++ 编程的基础。

C++26 汇集了这些重量级特性,其影响力可能堪比当年的 C++11,标志着 C++ 进入又一个“现代”时代。这些特性将影响我们未来编写的每一行代码,使 C++ 在保持高性能的同时,变得更安全、更富有表现力、更易于构建复杂的并发系统。

posted @ 2026-03-29 09:12  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报