CppCon-2024-笔记-全-

CppCon 2024 笔记(全)

001:Eigen库与线性代数简史 🧮

概述

在本节课中,我们将学习C++中线性代数的发展简史,并重点介绍Eigen库的基本情况。我们还将简要了解C++26标准中即将引入的新线性代数接口。

线性代数与C++的历史回顾

上一节我们介绍了本课程的整体目标,本节中我们来看看线性代数在C++中的发展历程。

让我们回到1998年。当时C++的普及度正在快速增长,特别是在金融编程领域。然而,对于从事量化编程(如期权定价模型和风险管理)的程序员来说,C++缺乏对线性代数的原生支持。

当时的主要选择包括:

  • 自行编写矩阵类和运算
  • 尝试说服上级购买商业库

早期开源线性代数库

以下是早期出现的一些开源线性代数库(非详尽列表):

Blitz++库(2002年11月发布):

  • 提供BLAS(基础线性代数子程序)功能
  • 支持矩阵和向量表示、加减法、乘法(矩阵乘矩阵、矩阵乘向量、向量点积)及标量乘法
  • 可计算矩阵和向量范数
  • 用户需自行实现线性求解器和矩阵分解
  • 作者在官网上表示其性能“良好但不出众”
  • 最后一次重大更新在2008年

新一代线性代数库

随着时间推移,出现了基于表达式模板的新一代库:

Eigen(2006年发布)
Armadillo(2009年发布)
Blaze(2012年发布)

这些库不仅包含之前提到的BLAS功能,还提供了多种分解方法和求解器(类似于Fortran中的LAPACK库)。

当前发展:MD Span

现在让我们回到当下,看看C++标准中的新进展。

MD Span(多维跨度)在提案中被描述为:它可以在一个一维连续内存容器上施加一个非拥有的多维数组视图。

例如,你可以对一个std::vector施加二维结构来表示矩阵。你也可以使用Eigen的VectorXd类型(我们稍后会讨论)。

总结

本节课我们一起学习了C++中线性代数支持的发展历程,从早期的匮乏到开源库的出现,再到当前标准的发展。我们了解到Eigen库是其中一个重要且高性能的选择,同时C++26标准也将引入新的线性代数接口。

002:课程概述与C++优势 🚀

在本节课中,我们将学习如何利用C++高效地实现机器人运动规划,特别是快速探索随机树算法。我们将从C++在机器人领域的优势讲起,逐步深入到运动规划的基础概念和RRT算法的具体实现。

CppCon会议在一个非常棒的场地举行,几乎所有参会者都住在会议酒店。这意味着在会议结束后,人们会出去喝杯啤酒,继续交流。这是一种极佳的体验方式。

大家好,我是Addihi。我今天要分享的主题是“利用C++实现高效运动规划:RRT算法在机械臂上的应用”。作为一名初级工程师,这是我使用C++编写机器人代码的历程。我之前使用其他语言编写代码,但它们不够健壮。现在,我正尝试完全转向C++。让我们先看大纲:首先,为什么机器人需要C++,或者说它为什么有用;其次是运动规划的必要性;运动规划基础;RRT算法;我的C++实现;输出结果;以及我的关键收获。

C++机器人运动规划:02:为何选择C++?⚡

上一节我们介绍了课程概览,本节中我们来看看为什么C++是机器人领域的理想选择。

众所周知,C++是一种非常快速的语言,因为它编译并生成机器码。它让我们对硬件有更多的控制权,这是我们一直追求的。它更高效,并且可以在不同的设备上运行,可移植性非常好。

C++机器人运动规划:03:运动规划基础 🤖

了解了C++的优势后,本节我们将探讨运动规划的基本概念。

运动规划是使用计算方法计算从一个位置到另一个位置的无碰撞路径的过程。我们用来计算这个路径的算法被称为规划器。

以下是不同类型的规划器:

  • 基于图的规划器
  • 基于采样的规划器
  • 基于优化的规划器

我们今天要讨论的RRT是一种基于采样的规划器。

C++机器人运动规划:04:RRT算法原理 🌳

上一节我们提到了RRT是一种基于采样的规划器,本节中我们来深入了解它的工作原理。

RRT用于通过随机构建空间树来高效地搜索路径。我将为你概述这个算法。

它从起点开始,目标是到达目标节点,但不是通过一条固定的已知路径。它的做法是在空间中随机采样点,并尝试构建一棵能通向目标点的树。如果任何树路径最终碰到障碍物,它就会丢弃该路径。之后,它会回溯并找到连接起点和终点的唯一路径。

现在,让我们深入了解一些机械臂运动规划的基础知识。

构型空间是机器人能够达到的所有构型的集合。当我们为高维机器人做运动规划时,我们总是希望通过降低维度来简化问题,以便于编写代码。我们通过定义构型空间障碍物来实现降维。构型空间障碍物是机器人无法达到的所有构型的集合,因为会与障碍物发生碰撞。当然,自由空间是剩余的可达空间。

因此,我们将现实问题转化为包含构型空间障碍物的构型空间问题。现在,我们的规划问题被简化为仅为单个点寻找从点A到点B的路径。

C++机器人运动规划:05:C++实现与数据结构 🧱

理解了算法原理后,本节我们将开始探讨具体的C++代码实现。

这是我正在使用的构型空间示例,代码中会生成起点和目标节点。

我使用OpenCV来显示输入图像、获取输入并输出结果。OpenCV会获取输入图像,生成一个地图数组传递给算法,然后执行算法的每个步骤,我会在讲解过程中逐一解释。

让我们从使用的基本数据结构开始。

RRT本质上是一种树实现,其中树的每个父节点只有一个子节点,该子节点也只有一个子节点,它不像二叉树那样可以向多个方向延伸。因此,我使用一个Node类来存储树中每个节点的对象。它包含x、y坐标、父节点以及指向父节点的指针。

然后,我使用一个节点指针的向量来存储树中所有节点的地址。

这里的一个关键学习点是使用类和面向对象编程。我们都知道什么是面向对象编程,但关键在于何时实际使用类,何时使用私有数据成员来保护并限制对它们的访问。在这个案例中,我将顶点向量作为私有成员,并限制对它的访问,只允许类的成员函数访问该对象。

我使用指针在函数之间传递节点,以提高内存使用效率。

C++机器人运动规划:06:核心函数详解 ⚙️

上一节我们介绍了数据结构,本节我们将深入探讨每个核心函数。我们采用自底向上的方式来构建代码,因此我将逐一解释每个函数。

checkCollision函数检查传递给它的两个点之间是否存在碰撞。这两个点作为两个节点传入。它首先检查目标节点。我们从OpenCV获得的地图基本上是一个二进制地图,它只包含0和1:有障碍物的地方是1,无障碍物的地方是0。因此,它首先检查目标点是否在障碍物上。然后,它检查该节点与目标点之间的每个点。我的做法是使用线性插值。我在那两个点之间进行线性插值,得到它们中间的数百个点,然后检查每个点。如果其中任何一个点发生碰撞,就意味着不应选择该路径。

接下来是随机点生成函数。顾名思义,它在地图矩阵的自由空间中生成一个随机点。这里有一个概念叫做目标偏向。如前所述,如果树是随机构建的,并且图像很大,但你需要朝特定方向前进,可能需要很长时间才能到达目标。因此,我们引入目标偏向。这意味着,如果你的目标偏向是10%(即0.1),那么有10%的时间它会直接选择最终目标节点作为目标点,以便朝那个方向尝试。其他时间,它会找到一个随机节点并选择它。

下一个是最近节点函数。一旦我们找到了一个随机节点并选择了它,我们需要找到将它连接到树上的位置。我们的做法是:遍历树中的所有点,尝试找出哪个点与新节点之间的距离最小,那个点就是最近节点,我们返回该值。

现在来到RRT的实现。正如我解释过的,这个算法首先生成一个随机点,检查碰撞,找到树中可以连接到该点的最近点,然后连接它们。但RRT只以固定步长延伸。它总是连接点。但如果新点距离我们的最近节点有15个单位,而我们的步长是5个单位,那么它将在那条路径上绘制另一个点。

C++机器人运动规划:07:总结与收获 🎯

本节课中,我们一起学习了如何利用C++实现高效的机器人运动规划。我们从C++在机器人领域的性能和控制优势开始,逐步深入到运动规划的核心概念,特别是RRT算法。我们探讨了如何将高维的机械臂规划问题通过构型空间降维简化,并详细拆解了用C++实现RRT时涉及的数据结构和关键函数,如碰撞检测、随机采样和节点连接。关键收获包括在实践中应用面向对象编程原则来保护数据,以及使用指针来优化内存管理。通过本教程,初学者可以理解运动规划的基本流程和C++在实现复杂算法时的强大能力。

003:选择有符号整数还是无符号整数?

在本节课中,我们将探讨C++编程中一个看似简单却至关重要的问题:何时使用有符号整数(int),何时使用无符号整数(unsigned int)。我们将通过分析代码示例、汇编指令和性能考量,来理解不同类型的选择如何影响程序的行为、正确性和效率。

首先,感谢各位选择参加本次讲座。本次会议中有许多精彩的演讲,而你们选择来到这个大房间,我对此感到非常惊讶和感激。

关于我自己,我是Alex Dathskovsky,拥有超过17年的系统编程经验。我从事过多种系统开发,从拯救生命的医疗系统到涉及安全领域的系统。目前,我在一家名为Speedeed Data的初创公司工作,我们正在开发下一代大数据处理技术,具体来说是设计一种能够大幅加速分析任务的通用CPU。

我经常在LinkedIn上讨论C++相关的话题、谜题以及一些备受争议的内容。此外,我开设了一个博客(CPP next)和一个YouTube频道,旨在更深入地解释C++概念。

现在,让我们开始探讨“选择有符号整数还是无符号整数”这个主题。

讲座预期与问题引入

本次讲座并非传统意义上的教学,更像是一次关于C++整数类型及其复杂规则的探讨。我们必须理解这些类型,才能真正欣赏它们并明白它们对程序的影响。

许多开发者在考虑整数时,第一反应就是到处使用 int。这种从学校教育延续下来的思维模式,并不总是正确的选择。我们将讨论选择错误类型可能带来的许多陷阱,包括未定义行为等。语言中最简单的事物也可能导致惊人的错误。

让我们从两位专家的观点开始:

  • Bjarne Stroustrup(C++之父) 认为:积分类型太多,理解其使用规则过于宽松。他的建议是:保持简单,始终使用有符号类型。
  • 知名博主Dale(专注于图形和浮点运算)则持完全相反的观点:你几乎不需要使用有符号整数。索引、大小等都应使用无符号类型。

他们谁是正确的?本次讲座的目的并非给出一个非黑即白的答案,而是让大家理解选择正确整数类型的重要性,以及这个简单选择背后复杂的考量。

免责声明:本讲座中的示例将针对x86架构进行编译。不同的机器指令集架构(ISA)可能表现略有不同。选择x86是因为它是目前最广泛使用的架构。大部分编译将使用Clang 12完成。

一个简单的性能对比示例

为了开始我们的探索,让我们看一个简单的函数示例。以下是两个功能相同的函数,一个使用有符号整数,另一个使用无符号整数。它们都接收两个相同类型的参数,计算它们的和,然后除以2。

// 无符号整数版本
unsigned int average_unsigned(unsigned int a, unsigned int b) {
    return (a + b) / 2;
}

// 有符号整数版本
int average_signed(int a, int b) {
    return (a + b) / 2;
}

现在,请大家思考:哪个版本的函数可能会生成更优的汇编代码,运行得更快?

让我们查看它们为x86生成的汇编代码。理解这些代码需要一点x86汇编知识,但别担心,我会简要解释关键部分。在x86中,以R开头的通常是64位寄存器,以E开头且有三个字母的是32位寄存器。

首先是无符号整数版本的汇编代码:

average_unsigned:
    lea    eax, [rdi + rsi]   ; 计算 rdi 和 rsi 的和,结果存入 eax
    shr    eax, 1             ; 将 eax 中的值右移1位(相当于除以2)
    ret                        ; 返回结果

汇编代码非常简洁,只有三行。

  • 第一条指令 lea(加载有效地址)实际上被编译器用来高效地计算两个寄存器值的和。
  • 第二条指令 shr(逻辑右移)将结果右移一位,这相当于进行除以2的操作,但通常比除法指令更快。
  • 然后函数返回。

接下来,我们看看有符号整数版本的汇编代码:

average_signed:
    lea    eax, [rdi + rsi]   ; 计算 rdi 和 rsi 的和,结果存入 eax
    mov    ecx, eax           ; 将和复制到 ecx 寄存器
    shr    ecx, 31            ; 将 ecx 右移31位,提取符号位
    add    eax, ecx           ; 将原和与符号位相加
    sar    eax, 1             ; 将结果算术右移1位
    ret                        ; 返回结果

这个版本明显更长,指令更多。它同样使用 lea 计算和,但之后进行了额外的操作:

  1. 将和复制到另一个寄存器。
  2. 将该值右移31位(对于32位整数),这实际上是将符号位(最高位)移动到最低位。如果和为负数,则ecx变为1;如果为正数或零,则为0。
  3. 将原和与这个符号位值相加。这是一个关键的修正步骤,用于确保当和为负数时,右移操作能实现“向零取整”的除法语义。
  4. 最后使用 sar(算术右移)一位来完成除以2的操作。

结果分析与核心差异

对比两者,无符号版本显然更简单、指令更少。有符号版本需要额外的步骤来处理符号位,以确保整数除法符合C++标准规定的“向零取整”行为。

那么,为什么无符号整数除法可以用简单的移位,而有符号整数却需要修正呢?核心原因在于两种移位操作的本质差异:

  • shr(逻辑右移):用0填充空出的高位。这对于无符号数的除法是正确的。
  • sar(算术右移):用符号位(原最高位)填充空出的高位。这对于保持负数的符号是正确的,但对于实现“向零取整”的除法,当被除数为负数时,直接算术右移会导致结果向下取整(更负),而不是向零取整。因此需要先加一个修正值(符号位)。

这个简单的例子揭示了第一个重要区别:在某些操作(如除以2的幂)上,无符号整数可能因为不需要处理符号问题而获得更优的编译结果

选择类型时的考量因素

上一节我们通过一个具体例子看到了性能差异。本节中,我们来更系统地看看在选择整数类型时应考虑哪些因素。以下是主要的考量点:

  • 数据的自然属性:如果所表示的数值从逻辑上永远不可能为负(如大小、长度、索引、容量、位掩码),那么使用无符号类型可以更准确地表达意图,并能在编译时捕获一些意外的负值赋值错误。
  • 溢出行为
    • 无符号整数:溢出是定义良好的,遵循模运算规则。例如,UINT_MAX + 1 等于 0。
    • 有符号整数:溢出是未定义行为。编译器可以假设其不会发生,并基于此进行激进的优化,这可能导致意想不到的结果。
  • 混合类型运算:当有符号和无符号整数在表达式中混合使用时,C++有一套复杂的“通常算术转换”规则,这常常会导致令人困惑的行为和潜在错误。例如,在比较 intunsigned int 时,int 值可能会被隐式转换为 unsigned int,导致 -1 > 0U 这样的比较结果为 true
  • 库和API约定:标准库容器(如 std::vector::size())返回的是无符号类型。许多系统API和索引也使用无符号类型。与之保持一致可以避免不必要的类型转换。
  • 性能考量:如第一个例子所示,在某些特定操作(尤其是位操作和除以2的幂)上,无符号整数可能允许编译器生成更高效的代码。但这并非绝对,需要具体分析。
  • 可读性与避免错误:使用最能体现数据范围和不变量(invariant)的类型。例如,用 unsigned int 表示年龄,可以清晰地告诉阅读者该值不为负。

总结与建议

本节课中,我们一起学习了C++中有符号整数和无符号整数之间的核心区别和选择考量。

我们从一个简单的平均值函数开始,发现无符号版本生成了更简洁高效的汇编代码,这源于其无需处理符号修正的优势。接着,我们探讨了选择类型时需要权衡的多个因素,包括数据自然属性、溢出行为、混合运算风险、库约定以及性能等。

核心结论是:没有一种类型在所有情况下都是最好的。 Bjarne的“始终用有符号”和Dale的“几乎只用无符号”都是过于简化的指导。

更实用的建议是:

  1. 根据数据的含义选择类型:如果值不应为负,优先考虑无符号类型以增加类型安全性。
  2. 警惕混合类型运算:尽量避免在表达式或比较中混合使用有符号和无符号类型,必要时使用显式类型转换,并注意转换的安全性。
  3. 了解溢出语义:清楚你使用的类型的溢出行为是定义良好(无符号)还是未定义(有符号)。
  4. 在性能关键路径上,结合上下文和基准测试来判断。像除法、循环计数器等场景,无符号类型有时确实有优势。
  5. 保持一致性:特别是在与标准库或团队代码库交互时。

最终目标是选择那个能使代码意图最清晰、最不容易出错,并且在特定上下文中足够高效的类型。理解这些基础类型的细微差别,是编写健壮、高效C++代码的重要一步。

004:超越内存安全 - 使用现代C++从设计上避免漏洞 🛡️

在本教程中,我们将探讨如何超越内存安全,利用现代C++的设计理念来构建更安全的软件。我们将首先理解内存安全的现状与局限,然后建立安全设计的基本概念,最后学习如何应用现代C++特性来避免漏洞。

内存安全的现状与局限 🔍

上一节我们介绍了课程概述,本节中我们来看看内存安全。目前关于C++的讨论几乎都会涉及内存安全。如果你搜索C++,尤其是新手的体验,你会看到类似“用C++花式给自己脚上来一枪”的描述。虽然这可能源于经验不足,但新手如何首先获得经验呢?也许很多过时的教程是原因之一,但很大程度上要归因于内存安全,或者说内存安全的缺失。对于那些只习惯高级抽象语言、一切由语言负责的开发者来说,进入C++语言是非常困难的。

不仅仅是新手,长期使用C++的人也是如此。你可能见过今年二月白宫的新闻稿,其中提到未来的软件应该是内存安全的。因为使用C++构建产品的用户也关心内存安全,毕竟很多漏洞都与此相关。但更重要的是下面这句话:“解决许多最严重网络攻击的根本原因”。这正是我更感兴趣的地方。

让我们看看存在哪些攻击。这是常见弱点枚举(CWE)的Top 25列表。我不希望你阅读整个列表,而是关注标题,因为它写着“顽固弱点”。他们定义“顽固弱点”为过去五年中,每次Top 25漏洞列表都出现的15个弱点。他们分析这个列表后指出,在这15个弱点中,有5个与内存安全相关。所以占了三分之一,这是一个相当大的比例。

这不仅仅是学术结构。就在最近,2024年8月14日,有一个影响Windows的零点击TCP/IP RCE漏洞。Windows IPv6栈中的一个整数下溢导致了缓冲区溢出,并允许任意代码执行。攻击者只需向你的机器发送几个IPv6数据包就能完全控制它。这是由于内存不安全造成的。

那么,直接使用Rust,一切就都好了,对吗?问题在于,即使我们想用Rust做所有事情,目前也做不到。如果你从事嵌入式开发,其他平台的编译器还不成熟。如果你有一个数百万行代码的庞大代码库,你不可能将其转换为Rust,成本太高了。此外还有人员培训等问题。

在C++内部,我们也知道投入了很多努力,并且不是最近才开始的。我们有C++核心准则,它们已经存在很长时间,专注于安全性。从C++11开始,我们有了智能指针等特性。我们有了标准范围库,帮助我们减少错误,避免这些问题。现在,C++26将引入未定义行为消毒剂,这将是一个巨大的改进。甚至在C++生态系统周围,如Cppfront、Carbon、Circle等,也有很多旨在解决内存安全问题的活动。

因此,在本教程的剩余部分,让我们假设内存安全这个热点问题已经解决,或者至少我们无需再为此担忧。有很多聪明人正在研究这个问题,希望他们能找到解决方案。但解决方案尚未到来。我们仍然需要思考如何与现在、过去和未来编写的代码一起工作,以实现超越内存安全的安全性。因为即使我们现在修复了所有内存安全问题,我们的软件仍然不会安全。从数字来看,三分之一的漏洞与内存安全相关,但还有更多其他问题。

为了说明这一点,2015年有一个著名的吉普车黑客事件,黑客能够远程控制多辆吉普车,并完全启动或完全禁用刹车,无需任何交互。这导致克莱斯勒召回了140万辆汽车,想象一下这个成本。今年还有一个有趣的例子,一篇名为《我是如何黑掉我的车》的博客文章。作者想在现代汽车的信息娱乐系统上安装自定义固件,进行了一些逆向工程,发现更新是经过签名的。他们没有私钥,无法签名。于是他们进一步逆向工程,找到了用于验证的公钥,然后去谷歌搜索这个公钥。第一个匹配结果是一个OpenSSL教程。这个密钥是直接从教程中复制粘贴的,而教程里当然也写了私钥。这导致了灾难性的后果。

关于演讲者与安全定义 👨‍💻

这个例子正是激励我的原因。大家好,我是Max Sofman,来自德国。与在座的许多人不同,我根本没有学过计算机科学。我一开始就在波鸿鲁尔大学学习网络安全,这是该领域的领先学府之一。我还在那里攻读博士学位,并与马克斯·普朗克研究所合作。我目前仍作为外部讲师在那里任教,向下一代传授安全知识。除此之外,我在IA(博世的一家子公司)担任安全经理,我们为汽车行业编写软件,我负责所有车载核心安全产品的安全管理工作。我处理从建立流程、进行设计和架构及代码的安全审查,到处理漏洞等所有事务。需要声明的是,我不是受雇主派遣来这里的,因此我表达的观点是我个人的,不一定是IA的观点。

现在让我们谈谈安全。如果我做一个调查,询问漏洞和缺陷之间的关系,我猜我们描绘的画面可能是这样的:漏洞是缺陷的一个子集。至少这是我经常看到的。但这是完全错误的。让我们看一个简单的真实图片,它显示漏洞实际上与缺陷有重叠,但并非其子集。这是德国波鸿鲁尔大学的一张真实图片。你可以看到这个系统是无缺陷的。栏杆按照规格升降,没有ID卡无法升起。一切正常,但它显然存在漏洞。原因是,缺陷是关于违反预期行为、违反规格说明的,而规格说明描述了我们意图做什么。但安全不是关于意图,而是关于可能发生什么。我们可以清楚地看到这里的错配。大学也看到了这一点,并通过放置这些石柱来修补漏洞。但显然,他们花了相当长的时间。

那么,他们最初是如何陷入这种不安全的路障安装情况的呢?我们不知道。但一种解释可能是他们拥有一个不正确的攻击者模型。因为如果没有一个正确的攻击者模型,关于安全的讨论大多是没有意义的。看看这个,请举手:这个安全吗?这个不安全吗?你无法举手回答,因为你不知道是针对谁。如果这是一个用来阻挡动物的门,我想它是安全的,即使用拉链绑带固定也是如此。

建立安全基线:攻击者模型与安全设计 🎯

上一节我们看到了安全与缺陷的区别,本节中我们来看看如何系统地讨论安全。为了论证某物是否安全,我们需要一个共同的基础。这通常通过定义攻击者模型来实现。攻击者模型描述了对手的能力、目标和资源。例如,攻击者是本地的还是远程的?他们拥有什么样的计算能力?他们知道系统的哪些信息?

有了攻击者模型,我们就可以定义安全目标。安全目标是我们希望保护免受攻击者侵害的属性。常见的例子包括机密性(信息不泄露)、完整性(信息不被篡改)和可用性(服务可访问)。

最后,我们可以分析安全机制。这些是系统中为实现安全目标而实施的特定部分。例如,加密提供机密性,数字签名提供完整性,冗余系统提供可用性。

一个强大的安全设计会清晰地阐明其攻击者模型、安全目标,并选择能够在该模型下实现这些目标的机制。现代C++提供了许多工具,可以帮助我们以更清晰、更不易出错的方式实现这些机制。

使用现代C++实现安全设计 ⚙️

在建立了安全讨论的基线之后,我们现在可以探讨如何利用现代C++的特性来支持安全设计。核心思想是:让正确的事情容易做,让错误的事情难以做(或不可能做)

以下是现代C++中一些有助于实现这一目标的关键特性:

  • 资源管理:使用RAII(资源获取即初始化)和智能指针(std::unique_ptr, std::shared_ptr)自动管理内存、文件句柄、网络连接等资源,消除资源泄漏和双重释放。

    // 使用 unique_ptr 自动管理内存
    auto data = std::make_unique<char[]>(buffer_size);
    // 无需手动 delete,超出作用域自动释放
    
  • 类型安全:利用强类型枚举(enum class)、std::variantstd::optional等来更精确地表达数据,减少因类型混淆或无效状态导致的错误。

    std::optional<int> parse_number(const std::string& str) {
        try {
            return std::stoi(str);
        } catch (...) {
            return std::nullopt; // 明确表示无值
        }
    }
    
  • 边界安全:使用std::arraystd::vector::at()(带边界检查)、std::span(C++20)以及算法库中的范围操作,避免缓冲区溢出和越界访问。

    std::vector<int> vec = {1, 2, 3};
    // 安全访问,越界会抛出 std::out_of_range
    int value = vec.at(10);
    
  • 不变式与契约:通过构造函数、私有成员和assert(或在未来使用契约)来维护类的不变式,确保对象始终处于有效状态。

    class SecureBuffer {
        std::vector<char> data_;
        size_t size_;
    public:
        SecureBuffer(size_t size) : data_(size), size_(size) {
            // 构造后不变式成立:data_.size() == size_
        }
        // ... 其他成员函数维护不变式 ...
    };
    
  • 避免未定义行为:使用标准库设施,如<bit>头文件中的字节操作、std::countl_zero等,代替手动移位和位操作,减少未定义行为风险。

通过将这些特性融入设计,我们可以创建出本质上更健壮、更能抵御各类攻击(包括但不限于内存损坏攻击)的系统。安全不再是事后添加的补丁,而是贯穿于整个设计和实现过程的核心原则。

总结 📝

在本节课中,我们一起学习了超越内存安全的重要性。我们首先认识到,即使解决了所有内存安全问题,软件仍可能因设计缺陷而不安全。关键在于区分缺陷(违反规格)和漏洞(存在可利用的可能性)。为了进行有意义的安全讨论,我们需要建立攻击者模型、定义安全目标并选择适当的安全机制

最后,我们探讨了如何利用现代C++的特性(如RAII、强类型、安全的容器和算法)来支持安全设计,其核心是让正确的用法变得简单,让错误的用法变得困难或不可能。通过将安全思维融入软件开发生命周期的每个阶段,我们可以构建出更可靠、更能抵御复杂攻击的系统。

005:使用以数据为中心的函数接口实现轻量级算子融合 🚀

概述

在本节课中,我们将探讨如何通过组合现有的高性能函数库接口来执行计算,并理解这种组合方式可能带来的性能问题。我们将重点关注“数据局部性”这一核心概念,并学习一种名为“算子融合”的优化技术,它可以在不修改库源代码的情况下,显著提升组合操作的性能。


能够与众多业内人士以及志同道合的人士交流并建立联系,这非常棒。

我是Mania。我刚刚在麻省理工学院完成了博士第一年的学习。今天,我将与大家讨论高性能代码。我不会教大家如何编写高性能代码。相反,我们将讨论如何使用高性能代码。

让我们从一个典型的例子开始。例如,我想进行矩阵乘法。我在某个C++接口的实现中找到了一个高性能的矩阵乘法实现,比如CBLAS的gemm函数,它为我执行矩阵乘法。

那个实现与你可能在第一节C或C++课程中编写的简单、幼稚的嵌套循环完全不同。但它会给出正确答案。

实际的实现将是数千行极其复杂和深奥的代码。这种复杂性直接源于希望最大限度地利用硬件。如果不了解硬件,就无法编写高性能代码。因此,在我的例子中,这个实现会调整其输入,使得下次读取输入时,数据已存在于缓存中。它会使用向量化指令,例如SSE、AVX、Neon等。在多核环境下,它会并行化你的代码。

它会利用缓存分块技术。当然,它会调整输入以利用缓存,同时也会确保指令流能很好地适应指令缓存,并且与分支预测器良好配合。

最后,它会利用某种软件流水线技术。现代硬件可以乱序执行指令,它们可以利用指令流内部的并行性。这个实现将利用所有这些特性来获得非常好的性能。

我想通过这个例子说明的是,编译器无法凭空创造出这些实现。即使是一个强大的指令选择器,也无法通过AST上的一系列漂亮的组合重写规则,将那个简单的嵌套循环转换成这数千行极其复杂的代码。你需要手写这些代码。而且硬件在不断演进,即使你能将这些模式整合到像编译器这样的单一庞大机器中,如果硬件发生变化,或者你的计算不符合这些模式,你就会陷入困境。

在实践中,最终的情况是,这些高性能实现通常通过函数接口提供。本质上,就像我使用CBLAS那样,你找到一个库,并使用其函数接口来获得你想要的任何计算的快速高效实现。

这非常强大,因为这些函数接口是不透明的。它们隐藏了底层的复杂性,而我作为程序员,不需要每次想使用时都重新发明一个快速的矩阵乘法轮子。

许多库都这样做。例如英特尔的oneDNN库、MKL库、优秀的BLAS库等。所有这些库都通过函数接口提供高性能实现,正是因为你不能依赖像编译器这样的机器来自动为你生成这些实现。

所以,让我们一起来尝试这样做。屏幕上有一个简单的计算。我尝试将两个向量相乘。我将一个向量与另一个向量的转置相乘,这将产生一个矩阵。我将用某个alpha值缩放该矩阵,然后累加到某个结果中。在累加过程中,我还会用某个标量beta进行缩放。

计算很简单,非常直接。

我说过高性能与硬件息息相关,这是我正在运行的机器。我将使用Apple M1。它采用ARM64指令集架构。事实证明,针对我所拥有的这种线性代数风格的计算,最好的实现存在于两个库中。首先是ARM性能库,由ARM公司的人员实现;其次是Accelerate库,由苹果公司的人员实现。

让我们从ARM性能库开始。使用ARM库时,我非常幸运。我找到了一个可以执行我精确计算的接口。我只需要理解如何使用它,就可以开始了。

然而,对于Accelerate库,情况略有不同。对于Accelerate库,我实际上需要两个函数:一个函数执行向量到向量的乘法,另一个执行累加到最终结果的操作。所以Accelerate库没有那种单一的好用函数,但我可以用两个函数来完成。我的意思是,理解一个接口和两个接口,真的有那么大区别吗?其实没有。

但这两个实现的性能出现了一个有趣的情况。ARM性能库比Accelerate库快30%。那里的Y轴是实际时间,所以越低越好。因此,ARM性能库击败了Accelerate库。

一个合理的疑问是:为什么会发生这种情况?一个好的初步猜测是:也许Accelerate的向量到向量乘法并没有真正优化。事实上,它是我们应用程序中计算最密集的部分。如果你搞砸了那个计算,就无法获得良好的性能。

因此,我继续只实现了向量到向量的乘法,并尝试查看Accelerate的实现是否真的比ARM的实现差。情况并非如此。除了些许噪音,它们基本上紧密匹配。这确实是我所期望的。这两个库都经过了几十年、由许多才华横溢的工程师优化,它们不会在向量到向量乘法上损失性能。

另一个好的猜测是,我们实际上是将两个接口组合在一起,而不是一个,这导致了一些有趣的问题。性能下降的原因可能不是这些函数内部的问题,而是这种组合导致了性能损失。我的意思是,当你准备好执行beta乘法和加法时,你生成的中间结果的第一个元素可能已经不在缓存中了。所以你会遇到缓存未命中,这对你来说是个问题。

让我们实际测试一下这个理论。你可能会说,那个beta和alpha操作其实很简单,是逐元素操作,为什么不直接修改Accelerate的实现来为你完成呢?

问题是Accelerate接口是专有的。我没有它的源代码访问权限。所以它是闭源的。但不幸的是,它又极其重要,因为它是针对Apple机器上专门的、未公开的加速器(如神经引擎或AMX指令集)的少数几种方法之一。因此,如果你想利用该硬件,你就必须绑定到这个库。

另一个想法是:如果我的函数性能问题真的是因为缓存未命中,那么如果我分块处理数据会怎样?我只计算数据的一小部分,希望它们能留在缓存中,当我准备好对那一小部分数据执行操作时,我就能获得缓存命中,而不是缓存未命中了。

这实际上是一个相当简单的转换来实现。我把它放在屏幕上。首先,我们将遍历输入的列块,只在该块上计算向量到向量的乘法,执行那个小的saxpy操作(beta乘法和与矩阵A的加法),然后继续这样做,直到我们完成整个输出。这就是我所说的“分块处理数据”。

我想在这里强调这个想法,称之为“融合”。这是一个非常古老和传统的优化。如果你以前听说过融合,你可能习惯于在类似LLVM MLIR的风格中看到它,基本上你有一个外层循环,并将计算内联到那个外层循环中。我没有内联计算。但基本思想是相同的:让我们计算输出的独立子集,以利用局部性。这就是我所说的“融合”。事实证明,这种分块的Accelerate方法将为我恢复性能。所以,局部性实际上是我们案例中的问题。在不理解源代码或不做任何修改的情况下,我能够使其工作。

我希望你记住的另一个观察是:即使这些函数非常棒,为我们隐藏了极其复杂的代码,但如果我们天真地组合它们,我们将遭受局部性损失。

我刚才已经向你展示了这对性能有多糟糕,对吧?我们刚刚看到了30%的差异。但更重要的是,这对能耗也不利。在当前芯片中,数据移动主导了能耗成本。将数据从本地结构(如缓存)移动的成本,几乎是使用该数据进行计算的成本的500倍。因此,如果我们想编写快速高效的代码,我们真的需要关心局部性和数据移动。

好的,我们一直在做这些简单的计算,我做了这个转换。我也告诉过你,编译器不可能为你做这种高性能代码。但现在我想问一个不同的问题:这对编译器来说是一个简单的计算吗?我们能否期望编译器介入并重写我们的代码,使其能够利用这种局部性?

让我们看看。

006:超越基础反射的展望

在本教程中,我们将学习C++26中即将引入的反射功能,特别是基于核心提案P2996的基础概念,并展望其未来的扩展可能性。我们将了解如何将源代码转换为数据,以及如何将这些数据重新转换回代码结构。

反射基础:从源代码到数据

上一节我们概述了课程内容,本节中我们来看看反射的核心概念。反射是指程序能够将自身视为数据的能力,即在编译时检查自身的结构。

P2996提案引入了一个新的运算符,用于将源代码域转换到值域。这个运算符被称为反射运算符,目前拼写为双尖括号 ^^

// 反射运算符示例
^^std::vector<int>; // 反射一个类型
^^std::cout;        // 反射一个变量
^^std::vector;      // 反射一个模板
^^std;              // 反射一个命名空间

应用此运算符后,会得到一个名为 std::meta::info 的“黑盒”类型。选择这种不透明的类型设计,是为了给编译器和语言未来的演进留下空间,避免过早定型。

该类型及相关功能定义在新的头文件 <meta> 中。伴随该类型,标准库提供了数十个函数,用于分析和拆解程序结构。

以下是 <meta> 头文件中部分关键函数的介绍:

  • is_template(info): 判断反射对象是否为模板。
  • enclosing_namespace(info): 获取反射对象所在的命名空间。
  • enclosing_class(info): 获取反射对象所在的类。
  • substitute(template_info, args...): 给定模板反射和一系列模板参数的反射,返回对应特化的反射。

例如,substitute(^^std::vector, ^^int) 将返回 std::vector<int> 的反射。

从数据回到代码:拼接

上一节我们介绍了如何获取程序的反射数据,本节中我们来看看如何将这些数据重新转换回代码。P2996提案中,将反射值转换回源代码的主要机制是拼接。

拼接使用新的标记 [],它是反射操作的逆操作。

// 拼接示例
using T = typename []{ ^^int }(); // 拼接出一个类型,等同于 `int`
int x = []{ ^^42 }();            // 拼接出一个表达式,等同于 `42`

在需要帮助编译器解析的上下文中(例如在模板中),可能需要使用 typenametemplate 关键字,其规则与常规模板代码中的规则相同。

拼接可以用于生成类型、命名空间限定符、表达式、变量和函数等任何代码结构。

一个简单的反射示例

为了巩固理解,让我们看一个简单的示例。这个示例没有实际用途,仅用于演示反射功能。

struct Entry {
    int value : 4;
    bool valid : 1;
    int status : 2;
};

void demo() {
    // 获取 Entry 类型的反射
    constexpr std::meta::info entry_info = ^^Entry;

    // 获取类型的名称并输出(假设有输出流支持)
    // std::cout << name_of(entry_info);

    // 获取所有非静态数据成员的反射
    constexpr std::vector<std::meta::info> members = nonstatic_data_members_of(entry_info);
    // members 现在包含三个 info 对象,对应 value, valid, status

    // 选择第三个成员(status)
    constexpr std::meta::info status_member_info = members[2];

    // 获取并输出该成员的名称
    // std::cout << name_of(status_member_info); // 输出 "status"

    Entry e{};
    // 通过反射编程式地访问成员 e.status
    // e.[] { status_member_info } = 1;
}

这个示例展示了如何获取一个结构体的反射,遍历其成员,并编程式地引用特定成员。请注意,示例中注释掉的代码行依赖于尚未完全实现的辅助函数(如 name_of)和拼接语法对成员的直接访问,它们用于说明概念。

总结与展望

本节课中我们一起学习了C++26计划引入的基础反射功能。我们了解了反射运算符 ^^ 如何将代码实体转换为 std::meta::info 类型的值,以及如何通过拼接操作 [] 将这些值转换回代码。我们还通过一个简单示例演示了拆解类型结构的过程。

P2996旨在提供一个最小但功能完备的反射基础。在此之上,更多高级功能(例如讲者提到的将枚举值转换为字符串等“炫酷示例”)正在其他提案中探讨,它们有望在C++26之后的标准中打开一个全新的元编程世界。要深入了解所有可用的反射函数和细节,阅读P2996提案原文是最佳途径。

007:编译性能分析与可视化 🚀

概述

在本节课中,我们将学习如何分析和可视化C++项目的编译过程。我们将探讨编译缓慢的常见原因,并介绍使用工具来识别瓶颈的方法。通过本教程,您将能够理解编译流程,并掌握提升编译速度的基本技巧。


为什么要关注编译速度? ⏱️

上一节我们介绍了课程概述,本节中我们来看看为什么编译速度对开发效率至关重要。

开发者在面对长时间编译时,可能会利用这段时间进行其他活动,例如休息或交流。然而,长期来看,频繁的编译等待会打断开发者的“心流状态”。心流状态是指开发者高度专注于编码任务,思维流畅且高效的状态。长时间的编译会破坏“编写代码”与“快速测试”之间的反馈循环,从而影响整体开发效率。


编译时间增长的趋势 📈

在深入分析工具之前,我们需要理解编译时间通常如何随着项目发展而变化。

代码行数与编译时间通常呈线性关系。编译器需要处理、解析源代码,进行词法分析并构建抽象语法树。因此,代码行数越多,编译时间越长。

只要软件持续有用,项目就会存在增加代码行数的压力。这可能源于修复漏洞、添加新功能等需求。因此,随着时间的推移,代码行数通常会增长,编译时间也随之增加。

如果绘制编译时间随时间变化的图表,它可能呈现波动上升的趋势。虽然期间可能存在因重构或删除无用代码而导致的编译时间下降,但总体趋势是上升的。这类似于“温水煮青蛙”现象——问题可能在变得非常严重之前不易被察觉。


可视化编译过程 🛠️

了解了问题背景后,本节我们将探讨如何实际可视化编译过程。

如果您使用CMake、Ninja和Clang,您的构建流程大致如下:首先调用CMake生成Ninja构建文件;然后通过Ninja(直接或通过CMake)调用Clang编译各个源文件;最后调用链接器将所有目标文件链接成最终二进制文件。

当Ninja执行这些命令时,它会生成一个.ninja_log文件。该文件以毫秒为单位记录了各个构建命令的开始和结束时间、输出文件名以及所用命令的哈希值。这些信息默认保存在构建目录中,是Ninja判断构建是否“脏”的机制之一。


使用Perfetto进行可视化分析 📊

以下是使用Perfetto工具分析编译过程的步骤。

Perfetto是Google开发的开源交互式追踪查看器。它支持Chrome事件追踪格式(JSON),并具有丰富的功能。您可以在ui.perfetto.dev在线使用,或在本地搭建服务器。

  1. 导入日志文件:将.ninja_log文件导入Perfetto,即可获得一个时间线追踪视图。
  2. 解读视图:视图顶部显示了构建的总时间线。从左到右是构建的顺序。不同的色块代表不同的任务(如编译各个源文件、链接)。这使您能直观地了解构建中各部分耗时。

然而,仅知道某个目标文件编译耗时较长可能不够。幸运的是,Clang(自9.0版本起)提供了-ftime-trace标志。该标志会为每个输出的.o文件生成一个对应的.json文件,其中包含了编译器在各阶段耗时的详细信息。


Clang编译架构与详细追踪 🔍

在查看详细追踪结果前,我们先简要了解Clang的架构。

当您调用Clang时,源代码首先被送入前端。前端负责解析代码,构建抽象语法树,并生成LLVM中间表示。然后,后端接收IR,将其转换为机器码,最终生成目标文件。

使用-ftime-trace生成的JSON文件可以在Perfetto中打开,呈现为火焰图。在火焰图中:

  • 最顶层的紫色条代表了编译器执行的总时间。
  • 其下的蓝色部分代表了在前端所花费的时间。

这种详细的视图帮助我们深入理解编译过程中的具体瓶颈所在。


总结 🎯

本节课中我们一起学习了C++编译性能分析与可视化的基础知识。我们探讨了编译速度对开发效率的影响,理解了编译时间增长的趋势,并介绍了如何使用Ninja日志和Clang的-ftime-trace功能,借助Perfetto工具来可视化编译过程。在接下来的章节中,我们将深入分析导致编译缓慢的具体问题及其解决方案。

008:如何隐藏 C++ 实现细节

在本节课中,我们将学习如何有效地隐藏 C++ 代码中的实现细节。我们将探讨隐藏细节的重要性、实践中遇到的挑战以及具体的解决方案。通过理解这些概念,你将能够编写出更健壮、更易于维护和更安全的代码。

概述:为何要隐藏实现细节?

上一节我们介绍了课程主题,本节中我们来看看隐藏实现细节的重要性。

隐藏实现细节与封装原则密切相关。封装通过隐藏用户不应接触的代码部分来创造价值。这带来了多重好处:

以下是封装带来的主要优势:

  • 保护对象完整性:确保对象内部状态不会被外部代码意外修改。
  • 提升易用性:只暴露必要的接口,避免用户误用不应调用的函数或访问不应访问的成员。
  • 提高可维护性:内部实现的更改不会影响外部代码。
  • 便于调试:可以轻松地在函数内部添加断点,观察实际执行流程,而不是通过直接修改数据成员来追踪问题。

此外,隐藏细节也促进了解耦。一旦你隐藏了内部细节,只暴露对方真正需要的东西,你就将接口的使用与具体实现分离开来。所有不必要的细节不被暴露,因此无法被使用,从而减少了依赖关系。我不依赖于我没有暴露的东西。

这同样便于后续修改,因为改变外部未知的部分更容易。同时,它增强了可复用性,因为代码不会附带太多“噪音”,只包含必要的部分,从而更容易被其他场景复用和重新实现。最后,它提高了稳定性,因为我只需要测试我实际暴露的部分,无需测试那些被隐藏的内部实现。

挑战:为何隐藏细节并不简单?

既然我们理解了隐藏实现细节的重要性,并且通常也会尝试这样做(例如将成员设为私有),那么这是否足够?我们是否总能做到?实际上,这并不像看起来那么简单。

软件模块化思想的先驱之一 David Parnas 就曾指出,隐藏实现细节比看起来要困难。这在 20 世纪 70 年代是难题,在今天同样具有挑战性。

那么,是什么让这件事变得不简单呢?

以下是几个主要原因:

  • 惰性(或优先级问题):有时我们因为要处理其他更重要的事情,或者不确定某个设计是否真的重要,而暂时采用简单直接(但暴露细节)的设计,并打算以后有需要再改。但后期修改往往并不容易。
  • 未意识到暴露了细节:例如,一个返回 std::map<std::string, int>& 的成员函数。虽然 map 成员本身可能是私有的,但这个 API 已经暴露了该类内部很可能持有一个 stringint 的映射这一事实。如果未来想改用其他关联容器,或者改变键的类型,修改将非常困难。
  • 技术限制:有时我们想隐藏,但受限于语言特性而无法做到。例如,一个工厂方法 create 用于创建 Foo 对象。理想情况下,我们希望将 Foo 的构造函数设为私有,强制用户通过工厂创建。但是,工厂内部使用 std::make_unique,而 make_unique 需要调用构造函数,你无法将 make_unique 设为友元。这导致构造函数不得不保持公开,用户仍然可以直接调用它,违背了隐藏构造细节的初衷。
  • 权衡与有意暴露:在某些情况下,我们可能有意暴露一些信息。例如,一个返回 std::vector<T> 的函数,我们可能希望用户知道返回的是向量,以便他们可以直接使用向量的相关方法。

总结

本节课中,我们一起学习了在 C++ 中隐藏实现细节的核心价值与主要挑战。我们了解到,良好的封装不仅能保护代码、提升易用性和可维护性,还能促进解耦和代码复用。然而,在实践中,惰性、无意识的信息泄露、语言技术限制以及必要的设计权衡,都会让完全隐藏细节变得困难。认识到这些挑战是迈向编写更优秀 C++ 代码的第一步。在后续的探讨中,我们可以针对这些具体挑战,寻找更巧妙的模式和解决方案。

009:从URDF生成加速代码

在本教程中,我们将学习如何从统一机器人描述格式(URDF)生成优化的C++代码,以加速机器人学中的核心计算,特别是正向运动学。我们将探讨其背景、动机、实现细节,并通过一个名为“Fast Forward Kinematics”的库来具体说明。

概述

机器人运动规划是自主系统的核心能力,它涉及在避开障碍物的同时,为机器人找到一条从起点到目标点的路径。这个过程通常需要成千上万次地采样机器人的状态空间,而每次采样都需要计算正向运动学、碰撞检测等。这些重复的计算往往成为性能瓶颈。通过从声明式的URDF文件生成高度优化的、特定于机器人的C++代码,我们可以显著提升这些计算的效率,从而实现实时运动规划。

背景:机器人学基础

上一节我们概述了目标,本节中我们来看看机器人学的一些基础概念,特别是运动规划和运动学。

运动规划

运动规划的核心问题是在考虑障碍物的场景中,为机器人找到一条从初始状态到目标状态的无碰撞路径。机器人的状态完全由其关节角度定义。

  • 状态空间:所有可能的关节角度组合构成了机器人的状态空间。
  • 路径搜索:规划算法在这个高维空间中搜索一条连接起点和终点的路径。简单的规划可能采样数千次,复杂的规划可能需要采样数十万次。
  • 计算瓶颈:每次采样通常都需要进行以下计算,这些是机器人学中反复出现的子程序:
    • 计算正向运动学(确定机器人连杆在空间中的位置)。
    • 进行碰撞检测。
    • 执行最近邻查找。

正向运动学与逆运动学

以下是机器人学中两个核心的计算问题。

正向运动学:给定一组机器人关节角度,计算机器人各个连杆末端在三维空间中的位置。

  1. 计算每个关节的变换矩阵,这些矩阵是关节角度的函数。例如,一个绕Z轴旋转θ角的旋转关节,其变换矩阵为:
    R_z(θ) = | cosθ  -sinθ  0  0 |
             | sinθ   cosθ  0  0 |
             |   0      0   1  0 |
             |   0      0   0  1 |
    
  2. 将所有关节的变换矩阵依次相乘,得到从机器人基座到末端执行器的总变换矩阵,从而确定末端位置。

逆运动学:给定末端执行器在三维空间中的期望位置,反推出一组能够达到该位置的关节角度。一种常见方法需要计算机器人的雅可比矩阵。

  • 雅可比矩阵:该矩阵描述了末端执行器位置如何随每个关节的微小变化而变化。矩阵的每一列对应一个关节,可以通过关节轴和从关节到末端执行器的向量叉乘来计算。例如,对于关节i,其在雅可比矩阵中的列向量 J_i 可以近似计算为:
    J_i = axis_i × (p_end - p_i)
    
    其中 axis_i 是关节i的旋转轴向量,p_end 是末端位置,p_i 是关节i的位置。

动机:为何需要代码生成?

上一节我们介绍了运动学和规划的基础,本节中我们来看看为什么传统的实现方式可能成为瓶颈,以及代码生成如何解决这个问题。

我们的目标是实现实时运动规划。这意味着机器人需要在动态变化的环境中(例如,当障碍物移动时)快速重新规划路径。传统基于采样的规划算法由于计算量大,往往难以达到实时性要求。

代码生成提供了以下关键优势:

  • 利用编译时信息:在生成代码时,我们知道机器人的固定结构(如关节数量、类型、固定连杆)。这允许我们进行如循环展开、消除固定关节计算等优化。
  • 内存布局优化:可以设计数据结构和内存访问模式,以更好地利用现代CPU的SIMD指令集进行并行计算。
  • 消除运行时开销:无需在运行时解析URDF文件或进行动态类型检查,所有结构在编译时都已确定。
  • 平衡点:它介于为每个机器人手动编写高度优化代码(不可扩展)和使用完全通用的、运行时解释的库(性能较低)之间,实现了自动化与性能的良好平衡。

一项来自莱斯大学的研究展示了这种方法的潜力,他们通过向量化规划和代码生成,在某些情况下将性能提升了500倍,实现了微秒级的运动规划。

实现:Fast Forward Kinematics 库

受到上述研究的启发,我开发了一个名为 Fast Forward Kinematics 的库。接下来,我们将探讨这个库的构建过程和关键设计决策。

构建过程

该库的构建流程是一个从声明式描述到高效可执行代码的管道。

  1. 输入:用户提供一个标准的URDF文件,这是一个XML格式的文件,声明式地描述了机器人的运动学结构(连杆、关节、几何形状)。
  2. 代码生成器:一个自定义的工具(代码生成器)会解析这个URDF文件。
  3. 生成C++代码:基于解析出的机器人模型,代码生成器输出高度优化的、特定于该机器人的C++源代码文件。这些代码实现了正向运动学和雅可比矩阵计算。
  4. 编译与使用:用户将这些生成的C++文件与他们自己的应用程序一起编译,得到一个针对其机器人硬件优化过的可执行程序。

代码实现与设计决策

为了获得最佳性能,在生成代码时做出了以下关键设计决策:

1. 编译时常量
将所有在机器人生命周期内不变的值(如连杆长度、固定变换矩阵)定义为编译时常量或模板参数。这使编译器能够进行积极的优化。

2. 循环展开
由于机器人的关节数量在编译时是已知的,因此可以完全展开计算变换矩阵连乘的循环,消除循环控制和条件判断的开销。

// 生成的代码示例:为3关节机器人展开的正向运动学计算
Transform3f fk(const JointAngles& angles) {
    Transform3f T = Transform3f::Identity();
    T = T * computeTransformJoint1(angles[0]); // 关节1变换
    T = T * computeTransformJoint2(angles[1]); // 关节2变换
    T = T * computeTransformJoint3(angles[2]); // 关节3变换
    return T;
}

3. 高效的数据结构
使用适合线性代数和SIMD操作的数据结构来存储向量和矩阵(例如,使用Eigen库并确保内存对齐)。

4. 避免虚拟函数和动态分配
生成的代码使用静态多态性和栈上分配,以减少运行时开销。

5. 特定优化

  • 跳过固定关节:如果关节是固定的(非运动关节),在生成代码时直接使用其常量变换,不进行任何运行时计算。
  • 简化雅可比计算:利用机器人模型的结构信息,生成直接计算雅可比矩阵各列的代码,而不是通过通用的循环。

性能评估与总结

最后,我们通过一个简单的基准测试来评估代码生成的效果,并总结本课程的核心要点。

基准测试结果

我将生成的代码与一个广泛使用的开源机器人学库KDL进行了性能比较。测试内容是重复计算特定机器人的正向运动学。

  • 测试设置:在同一台机器上,对相同的关节角度输入,计算10万次正向运动学。
  • 结果Fast Forward Kinematics 生成代码的执行时间显著少于KDL。具体加速比取决于机器人模型的复杂程度,但对于测试的中等自由度机器人,通常有数倍到数十倍的性能提升。这验证了代码生成在计算密集型运动学计算中的有效性。

总结

在本节课中,我们一起学习了:

  1. 问题背景:机器人实时运动规划需要高效执行大量正向运动学和相关计算。
  2. 核心方案:通过从声明式的URDF格式生成优化的C++代码,可以利用编译时信息进行深度优化。
  3. 关键优化技术:包括使用编译时常量、循环展开、设计高效数据结构以及避免运行时开销。
  4. 实践效果:如 Fast Forward Kinematics 库所示,这种方法能显著提升计算性能,为复杂的实时机器人应用奠定了基础。

代码生成是连接机器人领域知识(URDF)与高性能计算(C++)的强大桥梁。对于追求极致性能的机器人软件开发者来说,这是一个值得掌握的重要技术。

010:概述与背景 🧮

在本教程中,我们将学习如何在C++中快速且可靠地将浮点数转换为字符串。这是一个看似复杂,但通过理解其底层原理,可以变得简单明了的问题。

C++浮点数转换:第2章:问题的复杂性 🐉

上一节我们概述了本教程的目标,本节中我们来看看为什么浮点数转换会成为一个“棘手”的问题。

在标准C++中,有多种方法可以将浮点数转换为字符串,但它们的输出并不总是一致。这是因为没有一种“一刀切”的解决方案。有时我们需要更多的自定义能力,有时则需要更高的性能或更可靠的结果。

以下是几种标准C++转换方法及其不同输出:

// 示例:不同转换方法的输出可能不同
std::to_string(0.1);
std::ostringstream{} << 0.1;
std::format("{}", 0.1);
std::println("{}", 0.1);

C++浮点数转换:第3章:核心挑战——二进制到十进制 🔄

上一节我们看到了转换方法的不一致性,本节我们来探讨其根本挑战:将计算机存储的二进制数转换为我们阅读的十进制数。

我们都知道计算机以二进制存储数字,而我们希望看到十进制表示。将整数从二进制转换为十进制是直接的,可以通过一系列的除法和取模操作完成,每一步得到一个数字。

// 整数转换的教科书式代码(非最优)
std::string int_to_string(int n) {
    if (n == 0) return "0";
    std::string result;
    while (n > 0) {
        result = char('0' + n % 10) + result;
        n /= 10;
    }
    return result;
}

然而,浮点数带来了新的问题。考虑数字 float x = 2e-126。它有126位小数。如果使用上述循环计算126位数字,然后丢弃大部分,这将非常低效且浪费。因此,我们需要更聪明的方法。

C++浮点数转换:第4章:浮点数的内部表示 🧠

上一节我们明确了整数与浮点数转换的不同,本节中我们来深入了解浮点数在计算机中是如何表示的。

为了理解高效的转换算法,我们必须先理解浮点数的工作原理。让我们从一个简单的模型开始:一个只能存储和显示4位数字的CPU。

我们首先遇到的问题是希望看到分数,而不仅仅是整数。一个廉价的解决方案是在数字之间插入一个小数点。但这个点只是概念上的,CPU并不存储它。

通过这种存储方式,我们可以从0.01开始,以0.01为步长,计数到接近10。但这范围太小了。为了扩大范围而不增加成本,我们可以重新利用第一个数字,将其视为科学计数法中的指数,而其他数字则构成尾数。

同样,指数和科学计数法也只是我们用于理解的概念,并非CPU的存储格式。引入一条规则:当指数不为零时,尾数的第一位数字不能为零(即规范化的科学计数法)。这样,数字范围可以扩大到接近100亿。

如果我们想要更高的精度(更小的步长),可以牺牲一些范围。例如,将所有内容重新缩放(相当于给指数加一个偏置)。这样,步长可以变得更精细,但最大范围会相应减小。

这基本上就是浮点数(如IEEE 754标准)的工作方式:通过一个指数和一个尾数,在精度和范围之间进行权衡。

C++浮点数转换:第5章:高效转换的关键思路 💡

上一节我们解释了浮点数的表示,本节我们来看看如何利用这种表示进行高效转换。

核心思路是避免为那些最终会被四舍五入或格式化掉的数字进行冗长的计算。对于像 2e-126 这样的数字,我们不需要精确计算出全部126位小数,而是直接计算出其最短的十进制表示,该表示在转换回二进制时能唯一确定原值。

高效的算法(如Dragonbox、Ryu)正是基于这一原则。它们直接操作浮点数的二进制位(指数和尾数),通过数学方法快速确定所需的十进制数字的数量和值,从而避免了低效的循环。

C++浮点数转换:第6章:总结 🎯

在本教程中,我们一起学习了C++中浮点数到字符串转换的复杂性及其解决方案。

我们了解到:

  1. 标准C++提供了多种转换方法,其输出和性能特性各不相同。
  2. 浮点数转换的核心挑战在于将二进制表示高效、准确地转换为十进制字符串,特别是对于具有很多小数位的数字。
  3. 浮点数内部使用科学计数法(指数+尾数)表示,以在数值范围和精度之间取得平衡。
  4. 高效转换算法的关键在于直接操作浮点数的二进制表示,避免计算不必要的精度位数。

因此,虽然浮点数转换问题初看可能像“九头蛇”一样复杂,但通过理解其底层表示并采用正确的算法,它并非“火箭科学”。对于追求性能和可靠性的场景,应当选择像 std::to_chars 这样设计精良的专用函数。

011:基础概念与原则 🧠

在本章中,我们将学习 C++ 中生命周期管理的基础概念。我们将从最简单的值类型开始,理解对象从创建到销毁的三个核心阶段,并探讨如何遵循 C++ 的设计原则来编写易于使用且不易出错的代码。


走廊里经常发生有趣的讨论。你走出一个会议,大家刚刚共同经历了一场演讲,于是走廊里就会自发地展开关于演讲内容的对话。

欢迎来到 C++ 的生命周期管理课程。这是“回归基础”系列的一部分。如果你是生命周期管理方面的专家,欢迎留下,但请不要打断。这个主题即使在基础层面讲解也颇具挑战性,所以请多包涵。在某些方面,这确实有点矛盾:生命周期管理是 C++ 中较为复杂的领域之一,但同时也是我们所有人都应该相当熟悉并能理解其运作原理的部分。

幸运的是,我们将探讨一些能让它变得更简单的方法。但我想先强调这个警告:C++ 是一门复杂的语言,原因很多,大部分是历史遗留问题,同时也因为它高度关注效率和性能。这些因素与历史交织在一起,共同造就了它的复杂性。生命周期管理正是这种复杂性的一个体现。

然而,有一件事将帮助我们简化理解:默认情况下,C++ 是基于值的语言。它像一门“值优先”的语言。它也支持其他模式,而复杂性正是从这里开始产生的。但如果我们能拥抱 C++ 基于值的本质,它将对我们大有裨益,因为值很简单。

让我们先看一个整数。我们知道如何使用整数。我们可以用一个值初始化它。如果它不是常量,我们可以给它赋一个新值。如果它位于某个作用域内,我们知道在那个作用域结束时,它就消失了。它没有需要管理的内存,你无法再访问它。这很简单,易于推理。这就是我们想要的。

即使是整数,我们也能看到它们经历的几个阶段:构造阶段(我们给它一个初始值)、可能的赋值阶段(我们可能给它赋一个新值),以及它离开作用域的时刻。虽然整数没有实际的析构函数运行,但那里确实存在一个销毁阶段——它变得不可访问。如果其他东西(比如指针或引用)指向它,此时就会出问题,因为它已经不存在了。

这些阶段同样适用于 std::stringstd::vector 这样的类型。我们可以构造它们、给它们赋值,当然,在它们的作用域结束时,它们变得不可访问,并且实际上会运行析构函数来完成一些工作。它们都表现为值类型,你可以像值类型一样推理它们,尽管在底层有更复杂的事情发生。这些就是我们要遵循的模型。

到目前为止,这些内容都不应让人感到意外。即使是绝对的初学者,这可能也非常熟悉。这正是关键所在——我们希望这些行为是符合直觉的。

有多少人知道《Effective C++》这本书?这是一本较老的书(C++98 时代),但其中仍有很多智慧。我记得书中有这样一句话:“让类像 int 一样工作”。我们刚刚讨论了 int 如何工作,以及 stringvector 如何以相同的方式建模。这是一条非常好的建议。对于作者 Scott Meyers 来说,这非常重要,以至于在续作《More Effective C++》中他重复了这句话。

我想引用书中的几段话,因为它们值得深思:

  • 最小惊讶原则:我们知道 int 如何工作,因此很大程度上也知道 stringvector 如何工作。我们对它们管理自身的方式不会感到惊讶。
  • 认识到人们会做任何他们能做的事:他们会抛出异常,会将对象赋值给自身,会在给对象赋值前就使用它们。我相信我们都见过这些情况。我们需要能够处理这些事情。
  • 让你的类易于正确使用,难以错误使用。这真是金玉良言。

但要真正遵循这些建议,我们需要真正理解生命周期如何运作,以及如何使用 C++ 提供的工具来管理它们。

智能指针也遵循相同的模式。指针本身也是值类型。指针本身只是一个地址,一个数字。它本身并不隐含所有权,所有权是我们附加在它之上的概念。记住这一点对我们也有帮助,因为指针是让事情变得棘手的原因之一。

所以,三个核心阶段:构造、赋值、析构。这相当简单。但接下来事情会变得有点混乱。

处理这些阶段有 8 种不同的方式,它们可以稍作细分:

  • 构造:包括默认构造函数和自定义构造函数(即传递参数来初始化成员变量)。可以将默认构造函数视为自定义构造函数的特例。
  • 赋值:这里出现了“拷贝”和“移动”的维度区分,包括拷贝赋值运算符和移动赋值运算符。
  • 析构:只有一个析构函数,再次变得简单。

除了自定义构造函数,所有这些都被称为特殊成员函数。它们具有特殊的属性,最主要的是:如果你给编译器留出空间,它通常会为你自动生成这些函数。这是一件好事,也是我们希望发生的。但我们需要理解它是如何发生的,以及它生成这些函数时做了什么。

在理想情况下,我们不应该自己实现任何这些函数,这就是我们追求的“黄金标准”。当我们无法达到这个标准时,就需要知道如何自己实现它们。这就是警告所在,也是复杂性开始的地方。但请记住,一旦我们翻过这个坎,另一边将是平坦大道。


上一节我们介绍了生命周期管理的核心阶段和原则。本节中,我们将深入一个具体的例子,看看当类包含指针成员时,默认行为会带来什么问题,以及为什么理解构造函数的细节至关重要。

让我们深入兔子洞。回到我之前介绍的包含指针的 Gadget 类型。

最简单的类当然是空类。我们可以将其作为值类型在栈上实例化。它什么都不做,但可以工作,并且易于推理,只是没什么用。

所以让我们给它一个成员:一个整数。我们知道整数如何工作,因此我们现在也知道 Gadget 类型如何工作,因为它实际上继承了整数能做的事情。这是什么意思呢?

让我们问问 Gadget 它的整数值是多少,并尝试将其打印到流中。思考一下这实际上会做什么。你很可能猜对了:是的,这是未定义行为,因为我们没有初始化那个整数。我们知道整数的工作原理:如果我们不给它们初始值,它们就从内存中获取垃圾值。类本身并不会自动改变这一点。

编译器在这里为我们生成了一个构造函数,一个默认构造函数。它实际上调用了 int 的默认构造函数,但 int 没有默认构造函数,所以这实际上是一个空操作。

(为了节省版面,我将所有成员都设为 public,这样我就不用写访问修饰符了。)


在本章中,我们一起学习了 C++ 生命周期管理的基础。我们从最简单的值类型(如 int)出发,理解了对象生命周期的三个核心阶段:构造、赋值和析构。我们探讨了让自定义类型像内置类型一样工作的重要性,并引用了“最小惊讶原则”和“易于正确使用,难以错误使用”等关键设计思想。最后,我们通过一个包含未初始化成员的简单类,看到了默认构造函数可能带来的问题,为后续深入探讨特殊成员函数的自动生成与手动实现打下了基础。

012:问题诊断与内存分析 🧠

在本章中,我们将学习如何诊断一个C++软件系统的性能问题,特别是与内存管理相关的不稳定性和数据丢失问题。我们将从一个真实案例入手,了解问题背景,并学习如何使用内存分析工具来定位根本原因。

概述

本次分享的主题是软件开发。我花费两年时间改进一个产品,并显著提升了它的能力。我希望分享这段经历,为大家提供有用的思路和灵感,以改进自己的产品。

我的名字是Gilicma,拥有20年行业经验。我拥有电子工程学士学位,但更偏爱软件。我热衷于改进事物和解决问题,目前是Right Priority Software的团队负责人。

项目背景

我的故事始于几年前。我举家搬到丹麦,并开始在Camp Stop工作。Camp Stop的业务是计量,这里指的不是距离,而是水、热和电的计量,具体来说是智能计量。

智能计量是一种使用数字设备自动将公用事业数据发送给供应商的系统。这是一个端到端的系统。在一端,我们有水表。它们安装在房屋内,测量用水量,并持续通过无线方式传输测量数据。频率可以是每16秒、一分钟或两分钟,取决于配置。

在另一端,我们有读数管理器。读数管理器是信标,也就是计费系统。他们负责在月底向用户发送账单。在中间,是中间人。这就是我负责的产品——读数集中器。它的工作是无线收集来自水表的测量数据,并通过蜂窝网络、调制解调器或以太网将其发送到云端。

需要理解的关键点是,业务的核心是数据。因此,数据丢失意味着金钱损失。总的来说,我们有两个接口:一个面向持续提交数据的水表,另一个面向读数管理器。

面临的问题

最初,该系统设计用于处理少于1000个水表,并且运行良好。但几年后,它被期望能处理7500个水表。一些工程师经过计算,认为这是可能的,于是就这样卖给了客户。

然而,当我们尝试用7500个水表运行时,产品变得不稳定。产品不稳定意味着客户不满意,当然,开发人员也不满意。

具体到技术问题,我们遇到了内存问题。系统没有足够的内存,并且出现了一些无法解释的崩溃。有时,在网络出现问题时,我们还会丢失数据,尽管只是偶尔发生。当然,这些问题在实验室环境中都没有出现。

技术栈分析

系统基于Linux内核和Yocto发行版构建。整个镜像是预编译的,没有源代码。它运行得很好,但我们无法修改它。系统拥有32MB RAM,这个数字很重要,我稍后会谈到。代码风格是C++,但更像是C++11甚至更早的风格,完全没有使用智能指针。

此外,系统大量使用了Qt。Qt是一个应用程序框架,它降低了底层编程的门槛。Qt的好处是它用起来像C#,所以即使你不是嵌入式专家,也可以在嵌入式设备上编写代码。但Qt的坏消息是,它用起来像C#,却没有垃圾回收器,因此性能不佳。它鼓励你不断动态创建对象,却不鼓励你删除它们。

明确目标

我的目标是:将数据丢失降为零,并创建一个稳定的产品。听起来很简单,对吧?那么问题来了:我该如何实现?

初步代码审查

对我来说,这是一个新公司、新产品。我开始查看代码,当然,没有文档是常态。看了几天代码后,我的第一印象是:我看到了大量的 newdelete。我当时想:他们在一个嵌入式系统里到底想做什么?我对此感到非常震惊,以前从未见过这种情况。

但随后我恍然大悟。这说得通,因为正如我所说,有大量的 newdelete,很可能意味着存在内存泄漏(即我忘记释放分配的所有内存)。同时,很可能存在内存碎片化。

理解内存碎片化

让我们花20秒理解内存碎片化。假设我们有100字节的内存。现在我分配40字节,然后20字节,再40字节。接着我释放了40字节和40字节。现在我想分配50字节,这就出问题了,对吧?即使我有足够的内存。想象一下你的整个内存都是这种情况,这就是内存碎片化。

这解释了为什么我没有足够的内存,也解释了为什么分配会失败。当然,我并没有在任何地方检查分配是否成功。所以我访问了空指针,然后导致了不受控制的重启。问题解决了,对吧?不对,在这个案例中,这个思路是错的。

深入调查

经过调查,我发现并没有很多内存泄漏,所以这不是我的问题。我也没有内存碎片化。但在当时,我确信这就是问题所在。我以为只需要清理内存泄漏,找到内存碎片化的问题,一切就都解决了。

无论如何,我需要测试我的假设。为此,我开发了一个内存分析器。这个内存分析器让我很好地理解了分配的大小和数量。需要说明的是,由于Linux内核版本非常旧,我无法使用标准的内存分析器。

内存分析原理

一般来说,当有分配和释放时,内存使用情况应该如下图所示。内存使用量会达到某个最大值,然后在某个点后,我期望它能稳定下来。

但当存在内存泄漏时,情况会像这样。内存使用量会持续上升,因为我并没有释放所有分配的内存。

关键测量指标

使用内存分析器时,有几个有趣的测量指标。

首先是每秒分配次数。假设我每秒只有两次动态分配。我不喜欢这样,但我们明白这不是问题,系统可以承受每秒两次动态分配。现在考虑另一种情况,假设我每秒有2000次分配。这时,我认为系统根本无法工作。所以这真的取决于具体情况。

其次是当前和最大分配数量。因为如果存在内存泄漏,最大分配数量应该会持续不断地越来越高。如果一切正常,它应该在某个点后稳定并保持不变。

观察当前值与最大值之间的差距,以及这个差距如何随时间变化和破裂,也非常有趣。

关于分配的字节数也是如此。分配次数和字节数本身是相似的指标,但不完全相同。

我认为最有趣的是按大小分类的当前和最大分配数量。这意味着有多少次100字节的分配,有多少次1000字节的分配。为什么这如此有趣?有两个原因。

按大小分析的优势

第一,这非常容易发现内存泄漏。当你有了这些数值,因为如果一切正常,分配模式应该是稳定的。但如果存在泄漏,你会看到特定大小的分配数量持续增长。

第二,这有助于识别内存碎片化的模式。如果大量的小块分配和释放交织在一起,即使总内存足够,也可能无法满足一个稍大的连续内存请求,这就是碎片化的典型表现。


在本章中,我们一起学习了如何为一个不稳定的C++嵌入式系统进行初步问题诊断。我们了解了项目背景、面临的技术挑战,并重点介绍了通过自定义内存分析器来量化内存行为的方法。我们探讨了内存泄漏和内存碎片化的概念,并学习了如何通过分析分配频率、分配数量以及按大小分类的分配模式来定位问题。下一章,我们将深入探讨具体的优化策略和代码层面的改进。

013:课程概述与引言 🚀

在本节课中,我们将一起探究C++中的新旧设计趋势,分析“新”是否总是意味着“更好”。我们将通过对比历史遗留模式与现代替代方案,理解其背后的设计动机、优缺点,并尝试为代码库的演进找到平衡点。


C++设计趋势探究:第2章:演讲者与背景介绍 👩‍💻

上一节我们概述了课程目标,本节我们来认识一下本次分享的演讲者及其背景。

我的名字是凯瑟琳·罗查。我目前在科罗拉多州布鲁姆菲尔德的一家小型初创公司At Tamo Space担任软件工程师。

我目前在一个运行C++23的四年代码库上工作,代码量大约只有10万行。这个规模使我们能够进行许多在大型遗留代码库中难以实现的更改。

然而,我之前曾在一个拥有20多年历史的代码库中工作,它虽然也运行现代C++,但由于其悠久的历史,代码中仍然充斥着宏和其他遗留问题,这使得革新和编写新代码变得非常困难。

这种经历激发了我进行这次分享:我们拥有大量遗留代码,如何向前迈进?如何编写新代码?新的东西一定更好吗?旧的东西是否仍有价值?我自诩为软件历史学家或系谱学家,我的分享通常聚焦于如何更好地编写代码,以及理解我们为何要这样做。


C++设计趋势探究:第3章:新旧思维的碰撞与分析方法 ⚖️

上一节我们了解了演讲者的背景,本节中我们来看看驱动本次探究的核心问题以及我们将采用的分析方法。

我注意到行业中通常存在两个阵营。一方是拥有多年行业经验、长期使用某些设计模式的人,他们坚信“既然我们多年来一直这样做,就应该继续这样做”。另一方则是刚毕业的软件工程师,他们带着许多新想法加入,并告诉你:“我们应该这样做,因为我看到它是这样做的,这看起来很酷。”这两者经常发生冲突。

我们需要找到一个中间立场,理解双方的视角,以便为代码库做出最佳决策,同时兼顾一些遗留代码,并努力保持整个代码库的一致性。

这种现象不仅存在于软件中,也存在于现实生活中。例如,几年前人人都有Hydro Flask水杯,那是潮流。但现在它不再是潮流。你是应该继续使用仍然功能完好的旧水杯,还是仅仅因为它“新”就升级到新水杯?我们在生活中没有明确答案,但或许可以在软件工程中分析并找到更好的方法。

以下是我们的分析流程:

  1. 查看时间线:了解原始趋势和新兴趋势出现的时间。
  2. 分析原始趋势:理解它为何被使用,它是什么。
  3. 分析新趋势:理解我们为何替换原始趋势,以及为何不再使用它。
  4. 查看源代码:分析代码的优雅程度、存在的问题、令人满意和不满意之处。我们将进行代码审查。
  5. 综合分析:理解每种趋势的优缺点。

我们将以互动的方式进行,并会查看一些我编写的代码。这些代码并不完美,目的是让我们作为一个小组进行讨论和代码审查,共同理解代码的好坏。


C++设计趋势探究:第4章:案例研究 - 索引for循环 vs. 基于范围的for循环 🔄

上一节我们介绍了分析方法,本节我们来看第一个具体案例:传统的索引for循环与C++11引入的基于范围的for循环。

基于范围的for循环在C++11中生效。

索引for循环的优点

  • 它有一个可用于访问单个元素的索引。
  • 该索引也可用于其他副作用操作。这个索引不仅用于访问元素,如果我们想将该数字用于其他计算或做其他事情,我们也可以做到。
  • 它不一定需要一个项目集合。

索引for循环的缺点

  • 访问操作可能更危险。例如,有两种访问元素的方式:使用方括号[]或使用.at()方法。其中一种在索引无效时会抛出异常,而另一种则会导致严重问题。这两种情况都可能不是你代码中想要的,或者你可能无法处理。

基于范围的for循环的优点

  • 它更偏向数据导向。
  • 可读性更强。你看到基于范围的for循环,就能立刻明白它在做什么。

例如,我们有一个包含sunearthmoonjupiter的向量。我们可以写:

for (const auto& object : space_objects) {
    std::cout << object;
}

这非常清晰,因为我们能直观地看到在做什么。我们也不担心索引被用于其他用途,因为我们实际上无法访问它。

对比总结

  • 索引for循环:可以添加许多用于数学运算的副作用,这很棒。然而,进行一些复杂的检查并准确理解发生了什么可能很困难,因为你需要查看循环并确保索引使用正确。
  • 基于范围的for循环:非常易于阅读和访问。但如果你试图利用索引做一些“偷偷摸摸”的副作用操作,就会遇到问题。不过,如果你确实遇到需要副作用的情况,也许应该考虑重构架构来避免这种情况。

C++设计趋势探究:第5章:案例研究 - 全局数据的管理模式演进 🌍

上一节我们分析了循环的演变,本节我们来看看代码库中必须处理的全局数据,并探索其管理模式的演进。

许多代码都以某种形式包含需要处理的全局数据。我们主要关注两类:

  1. 全局接口:如外部I/O,我们只想访问其唯一副本。
  2. 全局数据:如参数。以我从事的航天领域为例,我们有位置、速度、加速度等参数。这些参数可能在整个软件栈中传递,但可能只有少数几个模块会更新它们。我们希望每个人都能访问这些参数,但只有少数人能写入,并确保每个人都能获得最新的数据。

以下是处理全局数据的时间线演进:

  1. 传统的全局变量,到处传递。
  2. “四人帮”设计模式中的单例模式
  3. 迈耶斯单例
  4. 我们将要分析的单态模式依赖注入

原始趋势:单例模式
单例模式的思想是将所有全局数据保存在一个副本中,每个人都可以访问它。

单例模式的优点是,你通过一个getInstance()instance()函数来访问代码。这非常容易识别,因为当我在代码库中浏览时,看到一个instance()函数,我就会知道“哦,这里在使用单例”,这对任何工作的人来说都很好识别。


本节课中我们一起学习了课程引言、分析方法,并通过两个具体案例(循环与全局数据管理)初步探究了C++设计趋势的演变。我们看到了新旧模式各有其适用场景和优缺点,关键在于理解其背后的设计考量,而非盲目追求“新”或固守“旧”。在接下来的课程中,我们将继续深入分析更多设计模式。

014:std::function的局限性与问题 🧠

在本节课中,我们将探讨C++标准库中std::function及其类似构造的局限性。我们将从实际开发场景出发,分析其潜在的性能开销,并了解如何识别和解决这些问题。课程内容基于大型跨平台应用开发的经验,旨在帮助初学者理解这些高级概念。

概述

std::function是C++中用于包装可调用对象的强大工具,广泛应用于任务队列、回调机制等场景。然而,它在提供灵活性的同时,也带来了某些不易察觉的开销。本节将深入分析这些局限性。

Lambda表达式基础

在深入std::function之前,我们需要理解Lambda表达式。Lambda是匿名函数,可以在使用点定义,使代码更简洁可靠。

以下是Lambda的基本示例:

auto lambda = []() { /* 函数体 */ };

捕获方式与影响

Lambda可以捕获外部变量,捕获方式(按值或按引用)会影响其行为和大小。

我们使用一个工具类来观察背后的操作:

class Instrumented {
    // ... 省略具体实现
    // 移动构造会改变ID,拷贝构造会记录拷贝
};

按引用捕获

以下代码演示了按引用捕获:

Instrumented a(“A”), b(“B”);
auto lambda = [&a, &b]() { /* 使用a和b */ };
lambda();

输出结果:无拷贝或移动操作发生。Lambda的大小为两个指针的大小(在64位系统上为16字节)。

按值捕获

如果将捕获方式改为按值:

auto lambda = [a, b]() { /* 使用a和b的副本 */ };
lambda();

输出结果:会发生两次拷贝操作(分别对ab)。Lambda的大小变为两个Instrumented对象的大小之和(例如64字节)。

Lambda的局限性

尽管Lambda非常有用,但它存在一个关键限制:每个Lambda表达式的类型都是唯一的、未命名的闭包类型。

这意味着:

  • 它们不能直接作为类的数据成员。
  • 它们不能直接存储在标准容器(如std::vectorstd::queue)中。
  • 在需要统一类型的任务调度框架中,无法直接传递Lambda。

为了解决这个问题,我们需要一个类型擦除的包装器,这就是std::function的用武之地。

引入 std::function

std::function是一个多态的函数包装器,它可以存储、复制和调用任何可调用目标(如函数、Lambda、绑定表达式等)。

其基本声明如下:

std::function<返回类型(参数类型列表)> func;

std::function 的开销

上一节我们介绍了使用std::function的必要性,本节中我们来看看它可能带来的开销。主要开销来自以下几个方面:

以下是使用std::function时需要注意的关键点:

  1. 内存分配:如果捕获的可调用对象大于某个阈值(通常是一个或两个指针的大小),std::function可能会在堆上分配内存来存储它。这会导致动态内存分配的开销。
  2. 类型擦除:为了实现多态性,std::function使用了类型擦除技术。这通常涉及通过虚函数表进行间接调用,比直接调用函数或Lambda有额外的开销。
  3. 拷贝成本:拷贝一个std::function对象可能意味着拷贝其内部状态,如果状态在堆上,则可能涉及深拷贝。

实际场景中的影响

在一个跨平台的应用架构中(例如包含主机应用、服务提供者和核心模块),任务队列被广泛用于线程间通信。服务模块不被允许自行创建线程,而是由主机应用提供任务队列。

典型的任务队列接口可能如下:

void postTask(std::function<void()> task);

当大量的小任务被提交时,std::function潜在的内存分配和拷贝开销可能会在资源受限的平台(如移动设备)上成为性能瓶颈。

解决方案与模式

认识到问题后,我们可以探索一些解决方案和优化模式。

1. 使用小缓冲区优化

一些实现(或自定义的函数包装器)会采用“小缓冲区优化”技术。它在std::function对象内部预留一小块内存,如果可调用对象能放入这块内存,则直接存储在其中,避免堆分配。

2. 传递轻量级可调用对象

尽可能设计捕获状态很少的Lambda,使其尺寸小于std::function内部缓冲区的大小。

3. 使用模板接受任意可调用对象

对于性能关键的代码路径,可以考虑使用模板来接受任意类型的可调用对象,从而避免类型擦除。但这会牺牲一些接口的通用性。

template<typename Callable>
void postTaskTemplated(Callable&& task) {
    // 直接转发调用,无类型擦除
    std::forward<Callable>(task)();
}

4. 自定义函数包装器

在极端情况下,可以针对特定用例设计自定义的、开销更低的函数包装器。

总结

本节课中我们一起学习了std::function在C++开发中的角色及其局限性。我们了解到:

  • Lambda表达式虽然灵活,但其独特类型限制了在容器和类成员中的直接使用。
  • std::function通过类型擦除提供了统一的包装接口,但引入了潜在的内存分配、间接调用和拷贝开销。
  • 在构建高性能、跨平台的应用时,特别是在资源受限的环境中,需要警惕这些开销。
  • 通过采用小缓冲区优化、设计轻量级捕获、使用模板或自定义包装器等模式,可以有效缓解这些问题。

理解这些底层细节有助于我们做出更明智的编码决策,编写出既高效又健壮的C++代码。

015:右值与移动语义 🚀

在本节课中,我们将要学习C++中一个核心且强大的特性:右值(Rvalues)与移动语义(Move Semantics)。我们将从理解其动机开始,逐步探讨其概念、术语以及如何在实际编程中应用它们,以提升代码效率。

动机:为何需要移动语义?💡

移动语义是C++11引入的一项重要特性。它的核心目标是避免不必要的对象拷贝,从而提升程序性能。让我们通过一个例子来理解其必要性。

假设我们有一个代表“哥斯拉”(Godzilla)的类,它是一个“大对象”,复制成本很高。我们有一个工厂函数来创建它:

Godzilla createGodzilla() {
    Godzilla localGodzilla; // 在函数内部创建一个局部哥斯拉
    // ... 一些初始化操作 ...
    return localGodzilla; // 按值返回
}

int main() {
    Godzilla g1 = createGodzilla(); // 情况1:初始化
    Godzilla g2;
    g2 = createGodzilla(); // 情况2:赋值
}

在C++11之前,上述两种情况通常都会触发拷贝构造函数或拷贝赋值运算符,将函数内部创建的 localGodzilla 完整地复制到 g1g2。然而,函数内部的 localGodzilla 是一个临时对象,在函数返回语句结束后就会被销毁。

核心问题:我们能否不进行昂贵的复制,而是“接管”这个即将销毁的临时对象的资源(例如其内部动态分配的内存)?答案是肯定的,这就是移动语义要解决的问题。

另一个常见场景是向标准库容器中添加元素:

std::list<Godzilla> monsterList;
Godzilla tempGodzilla;
monsterList.push_back(tempGodzilla); // 情况A:传递左值
monsterList.push_back(Godzilla()); // 情况B:传递右值(临时对象)

在情况B中,我们直接向 push_back 传递了一个临时创建的 Godzilla 对象。在C++11之前,容器内部也会对其进行拷贝。有了移动语义,容器就可以“移动”而非“拷贝”这个临时对象的资源,从而避免不必要的开销。

总结动机:移动语义允许我们在对象是“将亡值”(即将被销毁的临时对象)时,将其资源“移动”到新对象,从而避免昂贵的复制操作。

核心概念:左值 vs 右值 📚

上一节我们介绍了移动语义的动机,本节中我们来看看如何区分“可以移动的对象”和“不可以移动的对象”。编译器通过“值类别”来识别它们。

考虑以下两个字符串操作的例子:

std::string s1 = "Hello";
std::string s2 = "World";

std::string s3 = s1; // 情况1:s1 是左值
std::string s4 = s1 + s2; // 情况2:`s1 + s2` 的结果是右值
  • 情况1 (s1)s1 是一个有名字的变量,在赋值语句结束后它仍然存在并可被后续代码使用。我们不能移动它的资源,否则 s1 会处于无效状态。
  • 情况2 (s1 + s2):表达式 s1 + s2 的结果是一个临时的、无名的字符串对象。在完成对 s4 的初始化后,这个临时对象就会被销毁。我们可以且应该移动它的资源。

C++ 为这两种情况赋予了不同的术语:

  • 左值 (Lvalue):像 s1 这样的表达式。它代表一个持久存在、有标识(通常有地址)的对象。可以出现在赋值运算符的左边。
  • 右值 (Rvalue):像 s1 + s2 这样的表达式。它代表一个临时的、即将销毁的值。传统上通常出现在赋值运算符的右边。

重要澄清

  1. “左值”并不意味着它必须出现在赋值号左边(例如 x = y 中的 y 也是左值)。它意味着它具有出现在左边的“潜力”,即代表一个持久对象。
  2. “右值”同样可以出现在某些赋值操作的左边(例如移动赋值)。其核心特征是“临时性”和“资源可被接管”。

编译器能够自动识别表达式的值类别。在C++11中,我们通过引用类型来利用这种识别:

  • T&左值引用,只能绑定到左值。
  • T&&右值引用,只能绑定到右值。

正是通过右值引用,我们得以编写特定的函数(如移动构造函数和移动赋值运算符)来高效地“接管”临时对象的资源。

总结 🎯

本节课中我们一起学习了C++中右值与移动语义的基础知识。我们首先探讨了移动语义出现的动机——为了优化性能,避免对临时对象进行不必要的深拷贝。接着,我们学习了区分“可移动对象”与“不可移动对象”的关键:值类别。左值代表持久的、有标识的对象,而右值代表临时的、资源可被安全接管的值。理解左值和右值是掌握移动语义、完美转发等现代C++高级特性的基石。在后续的课程中,我们将学习如何具体实现移动构造函数和移动赋值运算符。

016:概述与基础

在本节课中,我们将学习C++抽象在嵌入式系统中的成本。我们将从嵌入式开发者的视角出发,分析在资源受限环境下使用C++高级特性(如封装、继承和多态)所带来的内存与运行时开销。课程将基于一个具体的硬件抽象层案例,通过对比C与C++的实现,量化这些抽象的实际影响。

演讲者介绍

我是Marcell Juhasz。今年早些时候,我从维也纳科技大学获得了硕士学位。我使用C++已超过六年,近年来主要专注于嵌入式系统领域。我的主要兴趣在于将C++应用于资源受限的环境。

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

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

研究动机

既然这是一个C++会议,在座的各位大多会认同:C++提供了大量强大且有用的特性。对于嵌入式开发者而言,有些特性不可或缺,有些则被禁止使用,还有一部分特性在社区中存在广泛争议。

有人认为C语言已达到效率的极限,而C++因其抽象带来了固有的运行时开销。也有人持完全相反的观点。因此,有时很难决定哪些特性可以使用,哪些应该避免。为了澄清这些误解,我进行了深入研究。通过这项研究,我获得了宝贵的见解,并希望本次演讲也能为您提供有价值的知识。

嵌入式系统的核心考量

当我们谈论嵌入式系统时,有一点很明确:开销是最重要的因素之一。这里指的内存使用和运行时性能两方面的开销。可用内存和执行时间都可能成为受限资源。

因此,首要任务是明确,本次讨论将从嵌入式系统的角度出发。我希望尽可能深入地使用C++,并尽可能接近硬件地分析抽象带来的影响。为此,我将主要关注硬件抽象层,因为这是直接与微控制器交互的层。

我们将看到传统的硬件抽象层是如何实现的。我们将在代码库中找到合适的位置来集成抽象,然后在尽可能低的层次上分析它们的影响。我们将主要考察封装继承多态

同时,我们也会识别当前硬件抽象层实现中存在的低效之处,并探索如何利用C++的一些更高级特性来处理这些低效率问题。

研究方法论

接下来,我想简要介绍一下本次研究的方法论,包括设置、工作流程和测量指标。

我首先从源代码开始。我使用提供的硬件抽象层,用C语言实现了一些功能。然后,在保持功能不变的前提下,我将C++抽象引入了代码库。

我将项目交叉编译到STM32微控制器,并针对代码大小进行了优化,因为这通常是受限资源。在此步骤中,我也可以测量编译时间。评估编译后项目的二进制文件大小相对直接。

一方面,我分析了反汇编后的二进制文件。我想看看微控制器上实际执行了什么。通过这种方式,我可以以分析的方式评估运行时性能。

另一方面,为了获得经验性结果,我将程序烧录到目标设备上,执行固件,并测量其在目标设备上的执行时间。

本次演讲将主要关注代码大小运行时性能,因为这两者通常是某些嵌入式系统应用中受限的资源。有些设备的闪存可能低至16kB甚至更少。至于运行时性能,某些项目可能存在实时性约束。即使没有明确要求,通常也期望获得尽可能短的反应时间。

基准固件

在深入主题之前,我想简要介绍基准固件的概念,它将作为后续二进制大小和反汇编代码比较的基础。

我将基准固件定义为绝对最小的嵌入式项目,它只包含一个带有空无限循环的main函数。

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

构成基准固件的第三个重要部分是链接脚本,它基本上指定了应用程序的内存布局。

可以看到,即使在main函数被调用之前,已经发生了很多事情。即便如此,对于一个基本上什么都不做的空main函数,C和C++的二进制文件大小都已经达到了约2kB。

硬件抽象层现状

在开始构建抽象层之前,我们需要为抽象找到一个合适的位置,以便有意义地集成到代码库中。

为此,我想简要介绍一下当前硬件抽象层的实现方式。简单总结一下:硬件抽象层提供数据结构,我们作为开发者可以根据想要应用的配置来实例化并填充这些数据结构。

然后,我们可以使用这些填充好的数据结构来调用硬件抽象层的函数。这些函数接收我们的输入参数,进行一些验证,然后分支执行必要的计算。

硬件抽象层为我们做的最重要的事情之一,就是抽象了所需的寄存器操作的复杂性。可以看到,硬件抽象层充满了这样的寄存器操作。对于每一个寄存器操作,硬件抽象层都会计算某种位掩码,以清除或设置寄存器中的位。

构建抽象层

现在我们对要处理的内容有了概念,让我们直接进入本次演讲的主题:构建抽象层

正如我们之前所见,硬件抽象层充满了寄存器操作。观察这个寄存器操作,我们至少可以识别出两个可以有意义地集成抽象的地方。

观察寄存器操作的这一部分,我们可以看到这是通用部分。它简单地处理每个寄存器的位操作,与其类型无关。

然后,如果我们看寄存器操作的第二部分,这是寄存器特定部分。它负责处理寄存器操作的位掩码计算,这部分必须知道寄存器中每一位的含义,并且应该提供有意义的成员函数来与底层寄存器交互。

如果要分析抽象的影响,一个关键因素是:我们希望尽可能少地修改原始代码库。保持原始代码库不变,然后以尽可能少的代码更改引入抽象。

让我们从封装开始,即使用类。我们在上一张幻灯片中看到,寄存器操作有通用部分和寄存器特定部分,这两者我们都可以有意义地进行封装。用UML图表示,类层次结构大致如下所示。

这是一个概览。CRegister类实现了通用的寄存器行为,然后这些寄存器特定类实现了独特的、寄存器特定的行为。

现在,我想快速展示一个简单的示例实现,以便大家都能理解我的意思。

让我们从CRegister类开始。如前所述,这是用于基本寄存器操作的类。每个寄存器都映射到内存中,因此每个寄存器必须有一个内存地址,以便通过简单的内存访问操作进行访问。在本例中,这是通过一个指向无符号整数的私有成员指针来实现的。

这个类实现了通用的寄存器行为。它提供了成员函数,允许我们以通用的方式与每个寄存器交互。例如,设置整个寄存器。


本节课中,我们一起学习了本次演讲的背景、动机、研究方法以及构建抽象层的基础。我们明确了从嵌入式系统角度分析C++抽象成本的重要性,并介绍了基准固件和当前硬件抽象层的实现方式,为后续深入分析封装、继承和多态等具体抽象机制做好了准备。

017:无模板元编程入门 🚀

在本教程中,我们将学习C++中一种被称为“无模板元编程”的技术。我们将探讨传统模板元编程的挑战,并了解如何利用现代C++特性编写更简洁、更易读的元程序。课程将从概述元编程的重要性开始,逐步深入到具体的技术实现。


C++社区联系紧密,举办此类活动非常棒,每个人都能沉浸其中,在社交和专业层面相互交流,同时也能学习到优质内容。

感谢各位的到来。我是Chris,我相信元编程是一种超能力,今天我将尝试与大家分享其中的一部分。

我并没有说“模板元编程”。但我们会谈到它。在开始之前,我们先明确一下今天要讨论的内容。

模板元编程是指在编译时生成代码的能力。实现这一目标有多种方式。其中一种方式被C++选择,这就是为什么我们从一开始就有模板,这也是本幻灯片上的第一个“T”。但这并不是C++真正困难或错误的地方。

std::vector<T>,以及特化模板,这些完全没问题。这是我们的设计。然而,我们今天要讨论的,也是本次演讲的主题,是这些类型列表(Ts...)和这些元函数,即操纵类型、从类型中提取信息的能力。这其中蕴含着巨大的力量,我将尝试展示并改进它。希望这听起来令人兴奋。

在深入之前,我想做个调查。请举手,如果你认为C++中的模板元编程太难或者可以变得更简单。好的,我注意到这些幻灯片是预编译的,所以我已有答案。我完全同意它很难。但另一方面,我们为什么要处理所有这些?我们为什么要做这些“技巧”?

在座有多少人认为模板元编程实际上很强大?谢谢。我们站在同一阵营。我相信编译时元编程可以变得更简单、更强大,同时还能获得清晰的错误信息和快速的编译速度。谁想要这样?每个人。抱歉,这毕竟是C++,但我们会看看能走多远。

在展示示例之前,我想指出,Sean Baxter在C++ Now 2022上做了一个关于C语言元编程的主题演讲。她是Circle编译器的作者,她提到“更好的元编程特性造就更好的库”。我认为这是关于元编程的一个非常重要的观点。元编程的重点不在于你的生产代码,而在于让库变得更好。C++在构建库方面很出色,但要实现更精妙的细节、更好的接口和更高性能,就需要元编程,因为我们有模板。如果没有强大的工具来做这件事,就会很困难。

那么,让我们开始吧。我将展示一些模板元编程被使用以及如何使用它的例子。

我们不必看得太远。让我们看看标准库。这是我从标准库中拿来的一个variant实现。它有一个find_index函数,我们有一个类型T和一个类型列表Ts...,我们想获取与T匹配的类型在Ts...中的索引。原理上很简单,但在元编程世界里有点困难。有很多解决方案,我不会一一展示,但你必须足够聪明才能实现它。这是例子之一,标准模板库中充满了这样的例子。

另一个例子。谁喜欢使用tuple?我相信你的编译时间正在爆炸式增长。std::get<2>在C++26引入[]索引之前,通常是通过模板递归实例化实现的,这是进行模板元编程最糟糕的方式。这是来自标准库的例子。

另一个来自微软STL的例子。即使是RAII也包含模板元编程。你必须强制类型相同,还有无数其他例子,我不会深入探讨,但你应该能理解,STL充满了模板元编程,因为它是一个库,而元编程赋予了库力量。

但有一个观察结果。标准模板库本身并没有一个标准的模板元编程库。我不知道你们以前是否注意到这一点,但这是事实。所有这些标准库的实现者,他们的实现非常强大,但要做得正确却非常棘手。他们需要很多“魔法”才能实现。顺便说一下,曾经有提案要将元编程库加入语言,由Boost.Mp11的作者Peter Dimov提出,但最终没有通过,因为C++正试图朝着不同的方向发展,我们稍后会讨论。这是一个非常有趣的观点。我认为,观察到STL本身有大量的模板元编程,却没有提供实际执行它的工具,因此实现者必须处理内部机制和技巧来实现它,这一点很重要。

另一件我非常关心的事情是性能。元编程能提升性能。

让我们看一个简单的struct内存布局的例子。sizeof(MyStruct)会是多少?答案是12。对于那些不理解的人,你可以查阅标准。但关键是,这个布局并不最优。如果你遵循数据驱动开发或数据导向设计,你可能会感到惊讶。如果我们有一个元函数,例如pack,它能为我们打包结构体,即解构struct然后重新打包,这不是很好吗?这由模板元编程驱动。如果你有反射,我们甚至可以从结构中获取字段名称。在反射之前,我们可以获取打包后的tuple,但有了反射,我们也能获取名称。这非常强大,能给我们带来更好的缓存利用率等好处。

性能很重要。我还有一张关于它的幻灯片,因为我非常关心它。

我主张,有远比我们想象的更多的事情可以在编译时完成,而这一点尚未被充分利用。我相信这是因为我们目前拥有的工具还不够。

例如,让我们看看这些协议。我们可能想添加一个运行时查找,将string_view匹配到相应的协议并返回。这是非常简单的事情。但如果你深入实现和开销,你会发现需要大量代码才能实现。但我想指出的是,如果你有基于内省的政策设计(由Andrei Alexandrescu设计),你可以根据你的关注点做出选择。你关心性能、关心大小、关心内存,你可以自己做出选择。

为了稍微“吓唬”一下大家,我将展示一些汇编代码,因为我认为这非常强大。这个“魔法查找表”,如果你对它的工作原理感兴趣,可以在会后找我,我会展示给你。请注意,顶部的第一个实现甚至没有查找表。它没有表。所有东西都在寄存器中。这是你能得到的最小的哈希表。没有查找,没有比较,什么都没有。它只是用几条指令就从协议中获取了信息。而另一个实现则需要额外的查找表,因此可能会有缓存未命中等问题。我认为这很强大。

最后,作为一个让你对元编程感到兴奋的例子,我想提一下状态机。我喜欢状态机,我认为它们非常强大。但要高效地实现它们有点困难。如果你使用switch-case和基于variant的解决方案,你会得到这种开销。但想象一下,我们有一个没有实际值的状态。

018:在嵌入式设备中驾驭 C++ 并发

概述

在本节课中,我们将探讨如何在嵌入式设备环境中使用 C++ Sender 模式来处理并发问题。我们将了解嵌入式系统的独特约束,并通过一个生动的互动演示来理解异步并发中的核心挑战。


我的名字是 Michael Case。我在英特尔工作,我喜欢这张图片。

因为大约三年前,我开始参与这个项目。这是 Lunar Lake 晶圆。这是我参与开发的第一个设备。在半导体领域,你开发一个产品,大约五年后它才会面世。我非常兴奋,因为它也运行着我编写的软件。

接下来,我们来谈谈嵌入式世界中的一些约束和问题。我所在的团队负责电源管理。我们的任务是让设备启动,成为第一个运行起来的部件。此时没有其他东西在运行。芯片上有成百上千个组件,其中许多你可能知道,更多你可能不知道。它们都需要在特定时间按顺序启动。在运行时,我们希望你的 YouTube 视频能长时间流畅播放,让你看完电影。因此,我们也负责运行时的电源管理。

我们的程序不会结束。如果我们的程序结束,意味着你的 CPU 正在关闭。所以我们不希望它结束。我们生活在一个程序一旦启动就持续运行的环境中。我们不使用异常。我们生活在一个没有动态内存分配器、没有 RTOS 的世界里。我们决定采用裸机环境。

人们选择 RTOS 通常有很多原因,主要是因为他们需要任务以及允许任务间协调的某种原语。没有这些,事情也能发生。

过去以及现在,本次演讲主要围绕这个开发板展开。公众可以轻松获取它。所有代码都能在上面运行。这是一个基于 ARM 的核心,拥有大量不同的 GPIO 通信接口,以及许多非常复杂的定时器、模数转换器、数模转换器。这些设备价格低廉,你可以大量获取,它们很有趣,可以用来做很多事情。但问题是它们非常复杂。当我提到定时器时,指的是极其复杂的定时器。你可以查看我之前的演讲来了解这一点。

当我们思考这些设备如何工作时,所有这些内部机制,这些试图执行某些任务的不同组件,都需要以某种方式被服务。它们要么有数据要给你,要么希望以某种方式获得服务。我们处理这个问题的方式是使用中断。

就我们目前的讨论而言,我们将中断视为进入设备的硬件线路。当该硬件线路变为活动状态时,我们可能正在执行一条指令。一旦指令完成,我们将进入中断服务例程。对于这个特定的 CPU,它知道需要保存一些状态,所以它会处理保存一些寄存器。然后它确定需要跳转到一个位置,这是基于内存中加载的某个向量表。它会说,哦,这是我要去的地址,然后开始在那个位置运行代码。当它完成后,你表示完成,CPU 本身会恢复之前保存的寄存器,然后你可以继续执行。

因此,中断是一种非常棒的任务切换方式。你可以在它们之上编写任务管理器。你可以用它们来了解硬件的状态。中断有很多很好的用途。

中断有不同的优先级。优先级数字越低,优先级越高。所以数字 0 是最高优先级,数字越大,优先级越低。如果我们有一个中断发生,我们正在主程序中,此时一个优先级为 4 的中断发生,需要一些时间才能切换到你的代码,即保存寄存器。在我们处理这个中断的过程中,如果另一个中断到来。

我找不到我的翻页器了。我睡着了。那更糟。好吧,一个优先级 0 的中断来了。优先级 0 是更高的优先级。所以我们再次切换上下文,保存我们正在处理的寄存器集,然后继续执行代码,之后恢复,再跳回优先级 4 的中断。所以优先级 4 的中断,我们继续执行绿色的部分。但与此同时,在处理绿色优先级 4 中断的过程中,另一个优先级 4 的中断来了。

现在它只是被保存下来,我们稍后会处理它,因为我们已经在处理一个优先级 4 的中断了。所以你不能抢占它。你会看到中间有一条非常短的线,但没有需要恢复寄存器的灰色部分,因为那时没有什么需要恢复。我们可以继续执行,然后返回到主程序。

这就是中断工作的基本概念。我们将大量使用它们。

现在,我们有一个练习。在我分享这个练习之前,Ben 不知道发生了什么,但他笑了,因为他非常聪明。我和 Ben Dean 一起工作。Ben Dean 是一个非常非常聪明的人。聪明到我还没完全确定他的能力边界在哪里。我想今天你们也许能帮我。

我们需要四位志愿者。其中两位我直接指定,因为我想指定。Neil,恭喜你,你是志愿者一号生产者。Harold,你是志愿者二号生产者。我需要一个客户端一和一个客户端二。有谁自愿当客户端吗?好的,Ian 当客户端一,后面那位,客户端二。好了,现在有一些基本规则,让你们提前知道需要做什么。你们的协议是:以相反的顺序给出所有字符。不幸的是,Ian 喜欢按正确的顺序拼写东西,而不是相反的顺序。你明白了吗?好的,今天 Ben 将扮演代理和所有事务的协调者。谢谢你,Ben。

结果是,如果你计算出一个数字,你不能直接发送这个数字。你必须一次发送一个数字。然而,后面的客户端希望直接接收一个数字。好了,接下来我将切换到下一张幻灯片,我们会发现数据生产者已经同时从 Ben 那里收到了他们的消息,因为他是客户端的代理。现在他们将尝试弄清楚如何处理他们的消息,然后大声喊出来。然后,用你们最好的电子世界争霸战的声音,当结束时,就说“In the blind”。明白了吗?你们不需要喊出来,我嗓子快哑了,我不会喊的。大家都明白各自的角色了吗?1,2,3,开始。尽快,大声喊出来。

大声喊给 Ben。还有一位。你是在耳语吗?你为什么要耳语?哦,你得从头开始。E L B,1 结束行,0。In divine。Ben 非常困惑。Ben,你有你的指令,我想我们找到了 Ben 的极限,完美。好了,我们明白了,我其实以为他懂了。我本来想再加第三个人,第三条路线。那样可能更好。是的,也许每次我玩这个游戏,我们都能弄明白。

所以 Ben 基本上只有一个核心,他一次只能说话,至少他必须序列化数据。他遇到了一些问题,Harold 在发送数据,Neil 在发送数据,Ben 试图接收这些输入,并且他必须在不同的地方将它们组合起来。他不能把它们搞混。

这就是异步系统中的并发问题。我们的系统中有不同的事件发生,我们必须将这些事件与它们所属的数据段匹配起来。这说得通,对吧?这就是并发的问题。所有事情都在发生,但它们需要到达正确的数据存储位置。

有两种不同的方法来解决这个问题。实际上有很多方法。但基本上你可以说。

总结

本节课中,我们一起学习了嵌入式设备开发中的独特约束,例如无动态内存、无异常、程序持续运行等。我们深入探讨了中断的工作原理,包括其优先级和上下文切换机制。通过一个生动的志愿者互动演示,我们直观地理解了异步并发系统中的核心挑战:如何将同时发生的多个事件正确地与它们各自的数据流关联起来。这为后续引入 C++ Sender 模式来解决此类问题奠定了基础。

019:概述与动机 🎯

在本教程中,我们将学习现代C++中的单子操作,特别是std::optionalstd::expected类的实际应用。我们将通过简单的示例,了解它们如何帮助处理可能失败的操作,并编写更健壮、更清晰的代码。

课程概述

大家好,欢迎来到关于单子操作的课程。今天,我们将讨论这种技术的实际应用方法。

首先让我介绍一下自己。我的名字是Vitaly,我从事C++开发已经相当长一段时间了,拥有超过10年的经验,开发过从框架库到VFX和地理空间系统等各种项目。目前,我在一家挪威公司Remarkable担任高级软件工程师,我们的主要产品是一款用于记笔记、画草图和制作图表的墨水平板电脑。

作为今天的议程,我们将简要讨论两个支持单子操作的类:第一个是optional,第二个是expected。本次课程主要关注expected的单子操作。然后,我们将简要讨论常见的用例以及一些技巧和窍门。这里不会涉及任何理论,甚至不会涉及范畴论。示例将全部来自C++,没有Haskell或其他函数式语言。我将展示大量实际示例。

技术背景

我们使用C++20开发产品,使用vcpkg作为包管理器。我们使用了30多个不同的第三方库,如ranges、fmt、expected和Catch2用于测试等。在设备上,我们使用Qt进行UI开发。

一个有趣的部分是它有两个“世界”:第一个是C++世界,我们在这里定义业务逻辑、与第三方库集成以及一些系统级或非平台特定的抽象。另一个是QML代码,QML是一种用于描述UI的声明性语言。在这部分代码中也可以有一些逻辑,比如与动画、状态相关的逻辑等。这两个部分之间存在连接,因为从C++代码可以创建和操作QML对象,而从QML对象可以使用以某种方式暴露的C++对象。

这种差异是我决定讨论这个特定系统的动机之一,因为在错误处理、代码工作方式以及范式方面,QML和C++完全不同。例如,在QML中,将所有错误、警告等信息打印到用户输出中是完全可以接受的默认行为,而在C++中,我们可以使用单子、抛出异常和错误代码等多种方式。

核心类介绍

上一节我们介绍了课程的整体背景和动机,本节中我们来看看我们将要使用的两个核心类。

std::optional

std::optional最接近的抽象可以被认为是某种类型和布尔值的配对(尽管并非所有实现都如此)。它可以包含一个值,也可以什么都不包含。

我们可以把它想象成一个盒子。例如,这里有一个optional<int>盒子,我可以在里面放一个值,比如42,也可以什么都不放(nullopt)。然后我们可以从这个盒子里提取值。

我们通常在操作可能失败,但我们不关心失败原因时使用optional。例如,std::vector类的find方法可以返回一个optional<iterator>。如果元素未找到,则返回一个特殊的nullopt对象,表示结果的缺失;如果元素存在,则返回其迭代器。在这个特定案例中,我们并不关心失败的具体原因。

有时,我们也使用optional来传递一些额外的可选参数。例如,这里有一个解析URL的函数,它有一个可选的配置参数。如果提供了配置,我们就根据它来解析URL;如果没有,我们就使用一些默认设置。

从C++26或下一个标准开始,optional将成为一个范围视图。但有一个区别:其内容的生命周期与这个范围对象本身的生命周期绑定。这是一个有趣的功能,因为它将是一个包含零个或一个元素的范围。

std::expected

std::expectedoptional非常相似,但有一个关键区别:它可以包含一个类型为T的值对象,或者一个类型为E的错误对象。你可以把它想象成std::variant<T, E>或一个标签联合。

如果我们有一个expected<int, Error>盒子,默认情况下我们放入一个值。我们也可以放入一个错误(稍后会展示如何操作),然后我们可以从盒子中提取它。

我们在操作可能失败,并且我们可能想知道失败原因时使用它。例如,这里有一个加载小部件的函数,它返回一个std::expected<Widget, LoadError>。如果加载成功,我们得到小部件;如果失败,我们得到一个具体的错误对象,告诉我们哪里出了问题。

总结

本节课中我们一起学习了现代C++中单子操作的基本概念,重点介绍了std::optionalstd::expected这两个类。我们了解了它们的设计初衷、基本用法以及适用场景:optional用于不关心失败细节的场景,而expected用于需要明确错误信息的场景。我们还了解了它们在实际项目(如结合C++和QML的UI系统)中处理错误范式差异的价值。在接下来的章节中,我们将深入探讨它们的单子操作(如and_thentransformor_else等)的具体用法和实际案例。

020:构建 XOffset 数据结构 - 零编码与零解码高性能库 🚀

在本节课中,我们将学习如何使用现代 C++ 构建一个名为 XOffset 的数据结构库。这个库的核心目标是实现零编码零解码,从而在游戏开发等高性能场景中实现极速的数据移动。


1.1:标题解析与核心概念

上一节我们介绍了课程概述,本节中我们来详细解析项目标题,理解其中的核心概念。

标题包含了三个关键要素:

  1. 我们做了什么:为游戏行业创建了一个名为 XOffset 的卫星库。
  2. 我们为何这么做:为了实现高性能,这对游戏应用至关重要。
  3. 我们如何做到:通过使用零编码零解码技术,移除了不必要的处理步骤。

以下是标题中三个核心概念的详细解释:

X 在 XOffset 中的含义

X 代表 Extreme(极致)。这体现在两个方面:

  • 设计上:我们的目标不是渐进式优化(保持 O(N)),而是追求从 O(N) 到 O(1) 的飞跃式改进,这能带来效率的极大提升。
  • 实现上:我们改进了数据处理和管理方式,确保每一步都具备高性能。简而言之,X 代表我们对设计和实现中极致效率的承诺。

Offset(偏移量)的含义

我们使用基于偏移量的指针,而不是原始指针。这种方法使我们能够创建可内存拷贝的数据结构。这一点非常重要。

想象一个拥有数千名玩家的大型游戏世界。单个进程无法处理所有负载。因此,我们将世界划分为多个小区域,每个区域由自己的进程管理。当玩家在这些区域间移动时,他们的数据需要被轻松、即时地迁移。

零编码与零解码

我们的方法很简单:

  1. 序列化时无需编码:我们直接存储数据。
  2. 反序列化时无需解码:我们直接访问数据。

结果是:更快的处理速度和更少的资源消耗。在游戏行业,性能至关重要,我们需要每个操作(包括序列化、反序列化、读取和写入)都快速运行。


1.2:项目动机与要解决的问题

现在我们已经了解了标题的含义,接下来谈谈我们创建 XOffset 数据结构的原因。

在本部分,我们将探讨项目背后的动机以及我们试图解决的具体问题。

序列化与反序列化的重要性

序列化是将数据结构转换为数据缓冲区的过程。这种转换允许数据在不同计算环境(甚至不同时间)中被恢复。我们将序列化与反序列化分解为三个部分:输入、处理和输出。

  • 输入:序列化的输入是精心设计的数据结构,图中连接不同字段的线条代表了指针和引用,设计时兼顾了时间和空间效率。
  • 输出:序列化的输出是一个数据缓冲区,它是一个扁平的、连续的数据块,用于存储和传输。
  • 处理:处理过程涉及在结构化数据和扁平数据之间进行转换。序列化将结构化数据转换为扁平数据;反序列化则执行反向操作。

为何序列化如此重要?

以下是三个关键数据:

  1. 一项研究发现,序列化/反序列化消耗了 Google 所有应用程序中 12% 的处理能力。
  2. 另一项研究表明,大数据应用可能花费 18% 到 90% 的 CPU 时间在序列化数据上。
  3. 在游戏中,超过 20% 的 CPU 时间花费在序列化/反序列化上,这直接影响游戏的运行流畅度。

游戏中的数据移动场景

在游戏中,我们需要在多个层面移动数据:

  1. 服务器与客户端之间:例如,加载新场景时的玩家和 NPC 数据。
  2. 服务器之间:包括不同服务之间的 RPC 调用。
  3. 数据迁移:在游戏世界中的区域和线路之间迁移数据。

当单个进程负载过重时,我们会将世界划分为不同的区域和线路。一个区域是世界的一部分,由一个进程管理;一条线路是一组逻辑上的玩家,也由一个进程管理。这种划分有助于管理稳定负载并保持游戏流畅。然而,这也意味着我们需要在这些划分之间高效地移动数据。当玩家在区域间移动或切换线路时,他们的数据需要被快速序列化、传输和反序列化。


1.3:现有序列化方法概览

上一节我们探讨了项目动机,本节中我们来看看现有的序列化方法,它们主要分为两类。

以下是两类主要序列化方法的对比:

第一类:MessagePack 和 Protocol Buffers

这类方法的特点如下:

  • 输入:接受结构化数据。
  • 输出:生成编码后的数据缓冲区。
  • 处理:在序列化和反序列化过程中,需要进行复杂的编码和解码操作。

第二类:FlatBuffers 和 Cap‘n Proto

这类方法的特点如下:

  • 输入:接受结构化数据。
  • 输出:生成扁平的数据缓冲区。
  • 处理:访问数据时通常需要解码步骤。

我们的 XOffset 方法旨在结合两者的优点,并消除其瓶颈:实现真正的零编码和零解码,让数据像在高速公路上无阻塞地流动一样高效。


本章总结

本节课中,我们一起学习了构建 XOffset 数据结构库的核心理念。我们首先解析了项目标题,理解了 X(极致)Offset(偏移量指针) 以及 零编码/零解码 的含义。接着,我们探讨了在游戏等高性能领域,高效序列化的重要性以及现有方法存在的瓶颈。XOffset 的目标就是通过创新的设计,移除这些瓶颈,实现数据的极速处理与迁移。在接下来的章节中,我们将深入其实现原理。

021:使用C++20模块工具实现反射

概述

在本节课中,我们将学习如何使用C++20的新工具——模块,来实现C++的反射功能。反射允许程序在运行时检查和操作其自身的结构,这对于序列化、UI绑定、脚本系统等高级功能至关重要。我们将从反射的基本概念讲起,逐步深入到具体的实现技术。

什么是反射?

反射是代码在运行时可用的元数据。它允许程序向自身提问,例如“这个类有哪些成员?”。

以一个简单的实体脚本为例:

class EntityScript {
    int health;
    std::string tag;
public:
    void EatBurger();
};

通过反射系统,我们可以得知EntityScript类有两个数据成员:一个名为health的整型和一个名为tag的字符串。此外,还能知道它有一个名为EatBurger的无参数成员函数。

反射的用途

反射的核心价值在于编写不依赖于特定数据顺序或结构的系统,它只关心数据以某种形式存在。

一个典型的应用是序列化系统。该系统能将对象的内存表示扁平化为二进制流或JSON等格式,以便网络传输或磁盘存储。

以下是使用反射进行序列化的伪代码示例:

void SerializeToJson(const ReflectionData& object, JsonValue& json) {
    for (auto& field : object.GetFields()) {
        json[field.GetName()] = SerializeValue(field.GetValue(object));
    }
}

更实际的实现会包含一个ValueToJson核心序列化函数,它能处理已知的基本类型(如整型、双精度浮点型、字符串)。通过递归进入复合类型(即包含这些基本类型的类),我们就能序列化任何已知类型的组合。

反射可以看作是类型系统的扩展。它允许我们基于代码结构信息自动生成其他系统,例如:

  • UI绑定:使用简单的声明式文本格式直接绑定到数据模型,类似于WPF的做法。
  • 语言绑定:自动生成库到其他语言(如Python)的绑定,无需手动编写胶水层代码。
  • 插件系统:在应用程序内部暴露API给小型脚本语言,实现自动化的插件功能。

在数据模型方面,反射可以用于自动生成完整的UI。例如,在游戏编辑器中,即使不知道组件内部的具体结构,编辑器也能通过反射理解并显示、编辑其值。

反射还能替代许多需要大量手动工作的传统系统。例如,撤销/重做栈通常需要为每个操作手动编写命令类和对应的撤销逻辑。使用反射,我们可以自动获取数据状态的快照,修改值后,通过比较两个状态的所有对象的差异,自动生成命令,从而实现完全自动且一致的撤销系统。

实现反射的步骤

上一节我们介绍了反射的概念和强大用途,本节中我们来看看如何实际实现它。

我们将按以下几个步骤进行:

  1. 建立一个关于反射实现的心理模型。
  2. 重点讨论运行时反射的实现技术(本教程主要聚焦于此,但相关技术也可用于编译时构造)。
  3. 最终,介绍如何利用C++20的模块系统来构建反射系统,这种方法能解决当前许多其他技术的缺点。

总结

本节课我们一起学习了C++反射的基本概念、其广泛的应用场景(如序列化、UI生成、语言绑定和撤销系统),并概述了实现反射的路径。我们了解到,利用C++20的模块工具,可以构建一个强大的反射系统,以更优雅的方式解决许多工程问题。在接下来的章节中,我们将深入具体的实现细节。

022:std::mdspan 的崛起 🚀

在本节课中,我们将学习 C++23 标准引入的多维视图类型 std::mdspan。我们将探讨其核心概念、定制点,并通过实际例子理解如何利用布局策略和访问器来灵活地处理多维数据。


1.1:历史与动机 📜

上一节我们介绍了课程概述,本节中我们来看看 std::mdspan 出现的原因。

我们是否已经在 C++ 中拥有多维数组?至少有两种常见方式:C 风格的多维数组和 std::vector<std::vector<T>>

C 风格数组的内存是连续的,但其维度是静态的,必须在编译时确定。std::vector<std::vector<T>> 支持动态分配,但行与行之间的内存不连续,可能导致性能损失,并且无法保证每行的长度相同。

计算机内存本质上是线性的。我们通常通过一个类来手动映射多维索引到线性存储位置。例如,一个 N x M 的矩阵可以分配一个大小为 N * Mstd::arraystd::vector,并通过调用运算符计算索引。

然而,处理多维数据时,我们通常需要更多工具,例如:

  • 重塑:将 2x3 的矩阵视为 3x21x6 的视图。
  • 降维:将 1x6 的矩阵视为纯粹的 6 元素序列。
  • 切片:查看矩阵中每隔一个的元素。

手动实现这些功能需要大量代码。因此,C++ 需要一个更灵活的多维视图类型,这就是 std::mdspan 的用武之地。

std::mdspan 是一个多维视图类型,对底层内存布局没有限制。与手动编写的代码类似,它定义了数据的逻辑布局而非物理布局。这意味着重塑视图通常不需要分配内存,实例化或复制 mdspan 的成本很低,并且它提供了通过策略进行定制的点。


1.2:核心模板与定制点 ⚙️

上一节我们了解了 mdspan 的动机,本节中我们来看看它的核心模板声明和定制点。

std::mdspan 的模板声明如下:

template <
    class T,
    class Extents,
    class LayoutPolicy = std::layout_right,
    class AccessorPolicy = std::default_accessor<T>
>
class mdspan;

以下是各个模板参数的含义:

  • T:视图中元素的类型,例如 intdouble 等。
  • Extents:描述数据形状的参数。它定义了:
    • :维度的数量。
    • 范围:每个维度的长度。
    • 索引类型:用于表示维度的类型(如 size_tint)。

Extents 可以是静态的或动态的:

  • 静态范围:在编译时同时指定秩和每个维度的长度。
    • 示例:std::extents<size_t, 2, 3> 表示一个 2x3 的矩阵。
    • 示例:std::extents<int, 100, 200, 800> 表示一个 100x200x800 的三维数组。
  • 动态范围:在编译时指定秩,但维度的长度在运行时确定。
    • 示例:std::dextents<double, 3> 表示一个三维数组,其每个维度长度在运行时设定,理论上可用于表示连续的三维空间。

第一个定制点是 布局策略。布局策略负责将多维索引映射到线性存储中的位置。例如,对于一个线性存储的 9 个元素,可以按行优先列优先的方式将其解释为 3x3 矩阵。

第二个定制点是 访问器策略。访问器定义了如何从指针或类似指针的对象中读取或写入数据。默认访问器 std::default_accessor 假设数据是通过指针连续存储的,但我们可以定制访问器以支持更复杂的场景,例如处理跨步数据或特殊内存空间。


1.3:总结 📝

本节课中我们一起学习了 std::mdspan 的基本概念。我们了解到它是 C++23 中引入的灵活多维视图类型,用于解决传统多维数组(如 C 风格数组和 vector of vectors)的局限性。其核心在于通过 Extents 描述数据形状,并通过 LayoutPolicyAccessorPolicy 这两个定制点,将多维索引灵活地映射到底层存储,支持重塑、切片等操作而无需复制数据。在接下来的章节中,我们将深入探讨如何具体使用和定制这些策略。

023:掌握异步控制流 🚀

在本教程中,我们将深入探讨C++协程,特别是如何利用它们来实现异步控制流。我们将从第一部分的基础知识快速回顾开始,然后逐步构建一个用于异步操作的协程框架。通过本教程,你将理解如何将C++协程应用于异步编程场景。


第一部分:快速回顾与目标设定 🔄

上一部分我们介绍了C++协程的基本语言机制。本节中,我们将简要回顾这些核心概念,并明确本教程的目标。

C++协程本质上是一个可以中途暂停,稍后恢复执行的函数。其状态在暂停期间会被完整保存。

协程机制包含三个主要部分:

  1. 返回类型:即协程函数的返回类型。这是一个用户定义的类型,定义了调用者与协程交互的接口。其设计空间非常灵活。
    • 代码示例:MyCoroutineReturnType my_coroutine() { ... }
  2. 承诺类型:这是编译器侧的接口,用于定制协程启动和关闭时的行为。
  3. 可等待体:同样是编译器侧的接口,用于定义协程挂起或恢复时发生的行为。任何可以与之配合使用的类型都是一个可等待体。
    • 核心操作符:co_await

自C++23起,标准库提供了 std::generator 这一协程返回类型,它允许函数中途生成值,并实现了范围概念。

本教程的目标是展示如何利用这些基础机制,构建类似其他语言中 async/await 模式的异步编程能力。我们将专注于单线程下的异步控制流,不涉及多线程。


第二部分:构建异步协程的心智模型 🧠

上一节我们回顾了协程的组成部分,本节中我们来看看如何将其应用于异步场景。

我们常将协程比作“协作式线程”。假设我们有一个复杂任务,它由多个函数调用链构成:外层函数依赖中层函数的结果,中层函数又调用一个执行I/O操作的内层函数。

最简单的实现方式是让内层函数使用阻塞式I/O。但问题在于,当内层函数等待时,整个线程都被占用,无法执行其他有用工作。

我们的目标是:当需要等待时,不是阻塞线程,而是挂起整个调用链,将其状态暂存。这样,主线程就可以在等待期间去处理其他任务。这就是异步协程的核心动机。

一种直接的替代方案是为每个任务创建新线程。但这会引入线程管理和同步的复杂性。协程提供了一种更轻量级的解决方案。


第三部分:设计异步任务类型 ⚙️

上一节我们明确了异步等待的目标,本节中我们开始设计一个具体的协程返回类型来实现它。

我们需要一个 Task 类型作为协程的返回类型。调用者获得这个 Task 后,应该能够启动它并等待其完成。

以下是 Task 类型的基本框架和其关联的承诺类型:

struct Task {
    struct promise_type {
        // 协程启动时调用
        Task get_return_object() { return {}; }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

目前,这个 Task 在创建后即挂起,且没有提供恢复它的方法。我们需要为其添加存储和操作协程句柄的能力。


第四部分:存储与传递协程句柄 🔗

上一节我们定义了一个基础的 Task,本节中我们为其添加存储协程句柄的能力,以便后续恢复执行。

承诺类型需要存储其产生的协程句柄,而 Task 对象则需要持有这个句柄。我们修改代码如下:

struct Task {
    struct promise_type {
        // 存储本协程的句柄
        std::coroutine_handle<> handle;
        Task get_return_object() {
            // 创建Task时,将承诺对象自身的句柄传递过去
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        ... // initial_suspend, final_suspend 等保持不变
    };

    // Task对象持有一个泛化的协程句柄
    std::coroutine_handle<> handle;
    // 构造函数
    explicit Task(std::coroutine_handle<> h) : handle(h) {}
};

现在,当一个 Task 协程被调用时,它会返回一个持有其自身句柄的 Task 对象。但目前调用者仍然无法等待或启动这个任务。


第五部分:使Task可等待 ⏳

上一节 Task 对象持有了句柄,本节中我们让 Task 类型本身成为一个可等待体,这样另一个协程就可以使用 co_await 来等待它完成。

为了使 Task 可等待,我们需要为其实现 await_ready, await_suspend, await_resume 这三个成员函数。

struct Task {
    ...
    bool await_ready() const noexcept { return false; } // 通常不准备就绪,需要挂起
    void await_suspend(std::coroutine_handle<> awaiting_coroutine) noexcept {
        // 当有人等待此Task时,我们需要保存等待者的句柄。
        // 但目前我们还没有地方存储它。这是下一步要解决的问题。
    }
    void await_resume() noexcept {} // 此Task不产生值,恢复时无操作
};

关键点在于 await_suspend:当协程A co_await 一个 Task B时,编译器会将协程A的句柄传递给 Task B.await_suspend()。我们需要一种机制,在 Task B完成后,用这个句柄来恢复协程A。


第六部分:连接等待链——简单的解决方式 🔄

上一节我们遇到了如何存储“等待者”句柄的问题,本节中我们先探讨一种简单直接的解决方案。

一个简单的方法是:让 Task 的承诺类型直接存储一个“继续执行”的句柄。当 Task 完成时,就恢复这个句柄。

struct Task {
    struct promise_type {
        std::coroutine_handle<> continuation; // 新增:存储谁在等我
        ...
        void return_void() {
            // 协程完成时,如果有等待者,则恢复它
            if (continuation) {
                continuation.resume();
            }
        }
        ...
    };
    ...
    void await_suspend(std::coroutine_handle<> awaiting_coroutine) noexcept {
        // 将等待者的句柄存入承诺对象
        handle.promise().continuation = awaiting_coroutine;
    }
};

这种设计在简单链式调用时有效(A 等待 B,B 等待 C)。但它有一个明显缺陷:它只能存储一个“继续”句柄。如果一个 Task 被多个其他协程等待,或者我们需要实现更复杂的调度,这种结构就不够用了。


第七部分:引入调度器概念 🗂️

上一节的简单方案暴露了管理的局限性,本节中我们引入一个中心化的调度器来管理协程的恢复。

调度器负责维护一组准备就绪、可以运行的协程。当一个异步操作完成时,它通知调度器,调度器则决定恢复哪个(或哪些)等待中的协程。

我们需要修改设计:

  1. Task 不再直接恢复其等待者。
  2. Task 完成时,它将自己(或其代表的完成事件)提交给调度器。
  3. 调度器根据策略(如FIFO队列)选择下一个要执行的协程并恢复它。

这更接近真实的异步运行时(如IOCP、epoll事件循环)的工作方式。await_suspend 的逻辑将变为:将当前等待的协程句柄注册到与异步操作关联的回调或调度队列中。


总结 📝

本节课我们一起学习了如何将C++协程用于异步控制流。

我们从回顾协程的三个核心部分(返回类型、承诺类型、可等待体)开始。然后,我们构建了实现异步操作的心智模型,目标是挂起等待中的调用链以释放线程。

接着,我们逐步设计了一个基础的 Task 类型,使其能够存储协程句柄并成为可等待体。我们首先尝试了一种简单的直接恢复方案,但发现了其在管理多个依赖关系时的不足。最后,我们引入了调度器的概念,这是构建健壮异步系统的关键,它负责管理协程的恢复顺序,解耦了操作完成与协程恢复之间的直接联系。

通过这个过程,你应该对如何在C++中利用协程构建异步编程抽象有了更深入的理解。真正的实现(如cppcoroBoost.Asio协程支持)会在此基础上增加错误处理、内存管理、调度策略等更多复杂而必要的功能。

024:增量改进的故事 🛠️

在本节课中,我们将跟随演讲者Roth Michaels的分享,探讨处理大型、历史悠久的C++遗留代码库的经验与策略。我们将了解遗留代码的常见特征,并学习如何在合并多个庞大代码库时,以务实、渐进的方式进行改进。


概述:遗留代码的现实与挑战

身处技术前沿,与领域内的杰出人物交流,发现他们也是拥有共同兴趣的普通人,并且非常乐意与你讨论这些话题,这是一种非常奇妙的体验。

我是Roth Michaels,Native Instruments的首席软件工程师。你或许在过去的CppCon上听过我的演讲。几年前,我在iZotope工作。Native Instruments、iZotope以及后来的Brainworx和Plugin Alliance品牌,这三个数字音频领域的重要参与者经历了一次合并。因此,今天演讲的一大主题就是:这些都是拥有超过20年历史的公司,我们在一处处理遗留代码已有经验,那么当我们将所有这些代码整合在一起时,该怎么做呢?

对于那些不了解Native Instruments、iZotope和Brainworx的人,我们为音乐和音频制作提供硬件和软件。涵盖从混音、母带制作、节拍制作、DJ软件,到为电影、电视、广播等进行音频后期处理的所有环节。这张图片展示了我们的一些产品在苹果音频编辑器Logic中的运行情况。我们世界的另一大部分是:我们确实制作一些应用程序,但我们制作的很多产品是DLL插件,它们运行在别人的进程和内存空间中,与许多其他插件共存。这常常让初次了解我们行业的人感到惊讶。不过,这不是我们今天要深入讨论的话题。但稍后你会看到,我们以这种方式工作的经验,导致了我们的一些代码共享技术。

为了结束你在CppCon的周三,本次演讲的代码内容会相对较少。虽然有一些非零代码的幻灯片,但这主要是故事时间。我们将回顾我在iZotope的波士顿分部尝试改进自身遗留代码库的一系列经历,以及我们尝试过的四种不同策略,用于在三个拥有20到25年历史的巨型C++代码库之间共享代码。

在开始之前,我想在我所有演讲开头做一件事:鼓励在座的每一位也来做一次演讲。就像我说的,今天是故事时间,分享的是我在过去几年中发现、处理、以及成功或失败的有趣事情。我相信在座的或在线收听的许多同行,在个人实验或工作中都有有趣的故事可以分享。如果你从未在会议上演讲过,我鼓励你考虑尝试一下。有很多很好的资源可以帮助你获得关于提案的反馈。另外,如果你还没报名,今年可能太晚了,但闪电演讲也是让你小试牛刀的好方法。我想提一下,我的职业生涯并非全在遗留代码中。我在CppCon的第一次演讲实际上是关于编写新库的。所以,虽然我们时不时有机会编写使用最新、最伟大C++最佳实践的新库,但我们仍然要处理大量、大量、大量的旧代码。


什么是遗留代码?🤔

我想在这里请大家参与一下。谁能说出当你听到“遗留代码”时,脑海中会浮现出什么?我不需要你到麦克风前,我会复述你的话。

  • “没有测试的代码。”——我认为这是我的第一个要点。
  • “基于旧标准构建的。”
  • “技术债务。”
  • “在产线中运行。”——一旦投入生产就将其视为遗留代码,这个想法很好。
  • “来自你职业生涯初期的代码。”
  • “没有文档。”——很多新代码也没有文档,也许这本身就是遗留代码的特征。

这些是我清单上的一些内容,当然不全面,但启发了我们将要讨论的一些话题。

  • 没有测试:并非总是如此,但通常有大量、大量、大量的代码没有测试。
  • 代码量巨大:可能你甚至不再使用其中的某些部分,但你不知道。很可能非常古老,作者可能已经不在了。所以你的代码库中可能有巨大的库,它们能工作,我们知道如何使用,但谁写的?我们不知道。当你看到18年前创始CTO写的TODO注释时,你完全不知道这到底重不重要,或者这只是他当年的一个梦想,现在已经没人关心了。
  • 使用旧标准:我认为更棘手的是那些跨越了多个标准的代码库。在我加入iZotope之前很久,我认为他们甚至用过C++98之前的版本,然后经历了所有的过渡。所以我们代码库中肯定有许多不同时代的代码,例如,我们有一个大写的CONSTEXPR宏,因为当时一个编译器支持,另一个不支持,所以我们就这么做了。
  • 新旧风格混杂:我写了两次“新旧”。我认为第二次的“新旧”是指编程范式。
  • 过去的错误决策:这些决策在20年前,结合当时的业务和软件发展方式,可能是有道理的。但随着发展,他们意识到:“哦,天哪,这导致了性能问题。”
  • 可能由技能较弱的工程组织编写:如果你的公司遗留代码来自初创时期,可能是一群刚毕业的大学生创立了公司,对C++还很陌生。随着公司业务发展,雇佣了越来越多有经验的人,公司的C++知识和技能水平会提高,但这可能意味着你在旧代码中会看到一些相当奇怪的东西。

分析遗留代码的工具 💡

我想在这里插一句。这是周一我同事告诉我的,由于我的演讲从周五提前了,我还没来得及试用这个工具,但他向我推荐了这本书《你的代码是犯罪现场》,用于理解如何审视遗留代码。书中附带了一个很酷的工具叫code-maat,它可以通过分析Git历史来告诉你关于代码库的一些事情,比如:有多少代码是由已离职员工编写的?或者,每次你修改这个类时,也总是会修改那边的那个函数?然后你可以分析这些信息,思考:这是好事吗?它们是出于业务原因而关联,还是仅仅是不应该存在的紧耦合?所以我推荐你查看这个工具。我还没机会试用,但听起来很酷。


工程决策的务实考量 ⚖️

需要声明一下,在整个演讲中,我可能会多次提到“出于业务原因”做某些事,当我们进行工程权衡时。显然,并非每个人都在商业环境中,你可能在从事开源工作。但我真正的意思是,做出工程决策是为了尽可能快地为用户交付价值,并对此保持务实态度。所以,你的重点不仅仅是打造最漂亮的代码库。

另一个方面,如果我提到过,我们有大量、大量的代码。这张幻灯片是我几年前为介绍iZotope波士顿分部代码库所做的。我们看到,我们正在处理数百万行自有代码,这些代码遍布我们所有现代和遗留产品线。现在我们是三家合并后的公司,这很容易再增加几百万行代码。


一个警示故事:过度现代化的陷阱 ⚠️

在讨论处理遗留代码的一些好策略之前,我想告诉你一个我最近在Python库中看到的警示故事,关于添加代码检查、类型检查和单元测试。

我们内部希望更新处理Python工具链的方式,想用poetry替代setup.py(如果你不知道这是什么,没关系),使用pyproject.toml文件来重构一些东西。但我们用于新Python库的最佳实践默认模板,被用来推广到旧的库上。这突然激活了新的代码检查规则和类型检查,而要通过这些检查和类型检查,你必须修改代码。

其中一些库本身运行良好。但在进行这些现代化改造、修改代码时,大约有一半的情况,会引入未知的破坏性错误。

另一个类似的例子是,我有一个测试自动化团队,他们的目标是提高其工具集的测试覆盖率。但他们没有仅仅规定“所有新代码都要有测试”,而是回过头去给一堆简单的小库添加了单元测试。但这些库就像是单一用途的Unix风格工具,可能永远不会再增加新功能了。它们可能永远不会有bug修复。那么,你为什么要把精力投入到这些改动上呢?尤其是当你需要重构以使某些东西更具可测试性时。但如果你真的进行了重构,那意味着你没有改变行为。而在测试时做到这一点实际上也非常棘手。

所以,如果你需要为没有测试的代码添加更多测试,最好考虑其他更高层次的测试,比如验收测试,以确保你有一定的覆盖率。因为在未经测试的代码上添加测试,如果你必须修改某些东西,并不一定能保证代码是正确的。

另一件事是,这可能是在不重要的事情上投入大量精力:为那些不会改变的代码添加测试。因为在Benine和Ma Gooldt的播客中(我忘了是哪一集),其中一人提到一个论点:在五年内,你的大部分代码库可能会被触及或重写,这些部分可能是你希望关心现代化和改进的。而那些深藏在核心、默默工作的部分,如果没人碰它,希望它是正确的,那么额外的测试覆盖率能给你带来什么呢?

这就是我关于这个警示故事想说的。


总结

本节课中,我们一起学习了处理C++遗留代码库的初步认识。我们定义了遗留代码的常见特征,如缺乏测试、代码量巨大、标准混杂等。我们了解了一个分析代码历史的工具code-maat,并强调了在现代化改造过程中保持务实态度的重要性。最后,通过一个关于Python库过度现代化的警示故事,我们认识到并非所有“改进”都是必要的,有时专注于真正会发生变化的部分,并采用更高层次的测试策略,可能是更明智的选择。在接下来的章节中,我们将深入探讨具体的代码共享与改进策略。

025:失望处理 😊

在本教程中,我们将学习现代C++中的错误处理技术。课程分为两部分:第一部分探讨“失望”处理,即针对可预测错误条件的处理;第二部分将讨论逻辑错误和契约。我们将通过一个将字符串解析为整数的简单示例,逐步介绍各种错误处理方法。

概述

错误处理是编程的核心部分。本节中,我们将专注于处理“失望”——那些可以预见、可以处理并从中恢复的错误条件。我们将从传统方法开始,逐步过渡到现代C++技术。

解析字符串为整数:初始实现

我们从一个简单的字符串解析函数开始。这个函数没有错误处理,仅作为后续讨论的基础。

int parse_int(std::string_view s) {
    int result = 0;
    for (char c : s) {
        if (c >= '0' && c <= '9') {
            result = result * 10 + (c - '0');
        } else {
            break;
        }
    }
    return result;
}

这个实现有两个退出点,但只有最后一个返回完全解析的整数。它没有错误处理机制。

以下是调用该函数的一些示例:

std::println("{}", parse_int("123"));    // 输出: 123
std::println("{}", parse_int("123abc")); // 输出: 123
std::println("{}", parse_int("abc"));    // 输出: 0

在某些情况下,“尽力而为”并继续执行可能是正确的做法。但通常,我们需要区分这些错误条件并采取适当的措施。

传统错误处理方法

上一节我们看到了一个没有错误处理的简单解析器。本节中,我们来看看一些传统的错误处理方法。

使用单独的函数进行验证

一种方法是使用一个单独的函数来验证字符串是否可以解析为整数。

bool is_int(std::string_view s) {
    // 实现验证逻辑
    // 返回 true 如果字符串可以完全解析为整数
}

这种方法在某些情况下是有效的,但它无法区分不同类型的错误(例如,以非数字字符开头 vs. 中间包含非数字字符)。

使用错误代码

为了提供更多信息,我们可以引入错误代码。

以下是使用枚举作为错误代码的示例:

enum class parse_status {
    ok,
    partial,
    invalid
};

parse_status parse_int_with_status(std::string_view s, int& out) {
    // 解析逻辑
    // 根据解析结果设置 out 并返回相应的 parse_status
}

这种方法允许调用代码根据错误类型做出决策,但它需要处理输出参数,并且组合性不佳。

使用输出参数

另一种常见模式是使用输出参数来返回结果,同时使用返回值表示状态。

parse_status parse_int_out_param(std::string_view s, int& result) {
    result = 0;
    bool has_digits = false;
    
    for (char c : s) {
        if (c >= '0' && c <= '9') {
            result = result * 10 + (c - '0');
            has_digits = true;
        } else {
            break;
        }
    }
    
    if (s.empty() || !has_digits) {
        return parse_status::invalid;
    }
    
    // 检查是否完全解析
    // 简化:假设我们需要检查是否解析了整个字符串
    return parse_status::ok; // 简化版本
}

这种方法有效,但感觉不够现代,组合起来也比较困难。

现代C++方法

上一节我们回顾了传统错误处理方法。本节中,我们将探讨现代C++提供的更优雅的解决方案。

使用 std::optional

C++17 引入了 std::optional,它可以表示一个可能存在也可能不存在的值。

std::optional<int> parse_int_optional(std::string_view s) {
    int result = 0;
    bool has_digits = false;
    
    for (char c : s) {
        if (c >= '0' && c <= '9') {
            result = result * 10 + (c - '0');
            has_digits = true;
        } else {
            break;
        }
    }
    
    if (!has_digits) {
        return std::nullopt;
    }
    
    return result;
}

std::optional 对于简单的“有值/无值”场景很有用,但它无法携带错误详情。

使用 std::expected

C++23 引入了 std::expected,它可以表示一个期望的值或一个错误。

std::expected<int, parse_error> parse_int_expected(std::string_view s) {
    int result = 0;
    bool has_digits = false;
    
    for (char c : s) {
        if (c >= '0' && c <= '9') {
            result = result * 10 + (c - '0');
            has_digits = true;
        } else {
            break;
        }
    }
    
    if (!has_digits) {
        return std::unexpected(parse_error::invalid_input);
    }
    
    return result;
}

std::expected 提供了更丰富的错误处理能力,允许携带详细的错误信息。

错误处理模式比较

以下是不同错误处理方法的比较:

  1. 返回错误代码:传统,明确,但组合性差
  2. 输出参数:避免重复计算,但接口不够优雅
  3. std::optional:现代,简单,但错误信息有限
  4. std::expected:功能丰富,现代,支持错误传播

选择哪种方法取决于具体需求:是否需要错误详情、错误恢复策略、以及代码的组合性要求。

总结

本节课中,我们一起学习了现代C++中的“失望”处理技术。我们从简单的无错误处理实现开始,逐步介绍了传统错误代码方法,最后探讨了现代C++中的 std::optionalstd::expected。这些技术帮助我们处理可预测的错误条件,使代码更加健壮和可维护。

下一部分中,我们将讨论逻辑错误和契约,这些用于处理不可预测的错误条件和程序中的bug。

026:引言与背景 🚀

在本章中,我们将探讨低延迟交易系统的基本概念、其历史背景以及在现代金融市场中的必要性。我们将从古罗马的早期衍生品交易开始,逐步过渡到当今由C++驱动的高频交易世界。

概述

低延迟交易系统是现代金融市场的核心,尤其在市场做市和高频交易领域。本章将介绍为什么速度至关重要,并概述一个典型交易系统的架构。


从古罗马到现代市场

上一节我们提到了低延迟的重要性,现在让我们先回溯历史,看看人类如何管理不确定性。

古罗马帝国以其卓越的组织和规划能力而闻名。他们不仅擅长城市规划、军事行动,还组织大型活动,例如能容纳20万至30万人的竞技场庆典。

为了筹备这些大型活动,罗马人会提前一年向农民、酿酒师等生产者询价,约定未来的商品价格。这种做法实质上创造了最早的衍生品交易形式之一,被称为“罗马期货合约”。

罗马人发现,世界充满不确定性。人类在心理上对潜在损失的重视程度远高于潜在收益。这种对不确定性的管理需求,是衍生品交易诞生的核心原因。然而,这种方法并未完全解决流动性问题——在高度不确定的时期,人们往往更不愿意为远期交易定价。

现代市场与做市商

进入现代世界,市场做市商扮演了关键角色。作者本人就在一家做市商公司工作了约十年。

市场做市被形容为一场“失败者的游戏”。这意味着成功并非依赖于某个“银弹”解决方案,而是需要在所有方面都持续保持优秀。做市商通过提供买卖报价赚取微小价差,这本质上是对其承担市场风险(价格可能上下波动)的补偿。目标是避免重大亏损。

为什么可能亏损?因为做市商需要同时为数以万计的工具提供报价。当新闻事件发生时,如果报价未能及时更新,就可能以不利的价格成交,造成损失。因此,持续的优秀表现是生存的关键。

为何需要低延迟编程?

对低延迟的需求主要源于两个核心原因:

  1. 快速响应不确定事件:这是最直观的原因。当新闻等市场事件发生时,系统必须快速反应,更新报价或取消订单,以避免损失。
  2. 确保决策的准确性:这一点可能不那么直接。准确性同样与延迟密切相关。系统需要快速摄入信息流,基于最新、最准确的数据进行计算并生成报价。延迟会导致决策基于过时信息,从而影响准确性。

以下是现代交易系统的一个简化架构图:

软件与硬件的协同

一个常见的问题是:既然现场可编程门阵列(FPGA)速度极快,为什么我们仍然需要关注C++软件优化?

上图右侧展示了从交易所流入系统的数据。FPGA确实能以极快的速度处理这些数据流。然而,软件仍然不可或缺,原因有二:

  1. 成本与效益的权衡:FPGA不仅硬件成本高,其开发、调试和运维的工程复杂度与成本也远高于软件。软件则更加灵活、易于迭代。两者是相辅相成的关系,关键在于为正确的问题选择正确的解决方案。
  2. 策略的复杂性:请注意上图左侧的黄色“策略”模块。策略引擎负责向FPGA发送简单的规则。例如:
    if (price > 10) { update_quote(); }
    为了让FPGA保持极致的速度,其内部逻辑必须非常简单(尽管工程实现很复杂)。它主要执行“获取比特、比较比特、发送比特”的操作。更复杂的决策逻辑则由软件端的策略引擎完成。因此,策略引擎本身也有低延迟需求,因为它需要快速处理信息并生成这些简单的规则指令给FPGA。

总结

在本章中,我们一起学习了低延迟交易系统的历史渊源和核心需求。我们了解到,从古罗马管理不确定性的智慧,到现代做市商“失败者的游戏”,对速度和准确性的追求始终是核心。现代交易系统是软硬件协同的产物,C++软件在处理复杂策略和平衡系统灵活性方面扮演着不可替代的角色。在接下来的章节中,我们将深入探讨实现这种低延迟系统的具体工程原则和技术。

027:C++ 的超能力 🚀

在本节课中,我们将要学习 C++ 语言设计的核心哲学,特别是其“不妥协的性能”原则。我们将探讨这一原则如何塑造了 C++ 的特性,并通过具体示例来理解其背后的权衡。


参加 C++ 大会的好处是,你能见到许多有趣的人,并了解许多被解决的有趣问题。

本次演讲的标题是“这就是 C++”。我期望在座的每一位都已经知道 C++ 是什么。我将从“C++ 是什么”的角度来介绍 C++,即人们试图用它完成什么,以及它如何实现这些目标。

理解这一点很重要,原因有二。第一,这能让我们更好地理解如何最佳地使用 C++,我想这也是我们大多数人来到这里的原因。第二,这有助于我们思考 C++ 应如何演进。理解 C++ 试图实现的目标,能指导我们设计新的语言和库特性。

在本次演讲中,我将解释 C++ 设计者试图实现的目标以及它如何实现。我会给出一些示例来说明这一点。然后,我将探讨一个我认为是当今 C++ 面临的最重要的争议点之一。我们将看到,对 C++ 定义性方面的理解如何阐明这一争议。最后,我们将讨论这一见解对 C++ 社区面临的最大挑战之一所带来的启示。

那么,第一个问题是:C++ 的超能力是什么?在回答这个问题之前,我想说明 C++ 拥有多种超能力,但没有一种是 C++ 独有的。有人可能会认为 C++ 的超能力是灵活性、可移植性、表达力等等。这些都很重要。但从根本上说,C++ 的超能力是不妥协的性能

我们设计 C++ 时,力求最大化灵活性、可移植性、表达力、安全性等所有方面,但从不以牺牲性能为代价

我们为何做出这个选择?因为这就是 C++。当我们在“更好的安全性但牺牲性能”之间面临权衡时,我们不接受这种权衡。为什么不?因为这就是 C++。标准委员会的成员理解这一点,理解 C++ 的期望,以及 C++ 的定义中“不给低级语言留有余地”的引述。

在实践中,这意味着尽管我们可能会添加各种提供高级抽象的特性,允许我们在高级别上推理代码,但我们不会添加违反零开销原则的特性。

有时,这一点被误解为“特性必须没有开销”,这似乎源于其名称。但零开销原则指出:不使用的特性必须不产生开销;如果使用,其开销不得大于手动实现该特性所需的开销

那么,C++ 如何提供不妥协的性能?我认为有几种方式,但关键的一点是:用户的代码直接由对应的机器语言指令实现(如果存在的话)

例如,如果用户的代码要求将两个整数相加,那么我们就用机器语言的加法指令来实现。除法则用整数除法指令实现(如果存在的话)。你可能会说,每种语言都这样做。但未必如此。让我们看看这些例子。

这些例子说明了我所谈论的内容。这些操作由单条指令实现。但让我们看看这些函数。它们安全吗?add 函数可能如何失败?整数溢出。multiply 函数同样如此。对于 divide 函数,我们不需要担心整数溢出,但潜在问题是什么?除以零。

因此,所有这些函数都存在一些安全隐患。我们的挑战在于,我们希望 C++ 编译器能为所有平台生成高效且可高效生成的代码。除非硬件有缺陷,否则所有平台在非边界情况下都会产生相同的结果。但不同平台处理边界情况的方式不同。

如果所有平台在边界情况下的行为都相同,我们就会定义其为语言的一致行为。当然,我们可以选择一种行为,然后在那些不支持该行为的平台上模拟它。但问题在于,为边界情况模拟特定行为的部分代价是,这样做会减慢非边界情况(即常见情况)的速度。

因此,在 C++ 中,我们不定义边界情况下的行为。你可以想象一种优先考虑安全性的语言,为这些情况定义一些合理的行为。例如,对于除以零,我们可能有几种选择:让结果为最大整数值(尽可能接近无穷大)、抛出异常、设置某个静态错误标志等。实际行为对于这一点并不重要,因为任何实现都需要在所有情况下进行测试,以判断是否为边界情况。

让我们看看我在这里创建的 divide_safe 函数。它的作用是返回最大值。这是你能实现的、处理除以零的最简单方式之一。但请注意其代价:它产生的指令数量是原来的两倍,并且其中包含一个测试分支。这些都会严重损害性能。因为我们添加了这种安全性,代价显著增加。请记住,我们在这里做的是在除以零情况下最简单的事情。我们没有抛出异常,也没有设置错误标志,只是返回一个常量。但代价是指令数量翻倍并带有分支。

因此,C++ 选择了性能,而不是这种安全的 C++ 方法。我们为何选择这个选项?因为这就是 C++。

现在,有人可能会指出,我们并不总是走这条路。考虑 std::vector 的索引运算符 operator[],它提供无范围检查的元素访问。此外,标准库还提供了 at 成员函数,它提供相同的服务但带有范围检查。这是 C++ 同时提供安全和“不安全”操作的罕见情况。

这里需要注意的重要一点是,在实践中,几乎总是使用索引运算符 operator[]。因此,如果只提供一个选项,那会是哪个?会是索引运算符,即“不安全”的选项。我们为何会提供这个选项?因为这就是 C++。

你可能会争辩说,这是一种虚假的性能感觉。因为尝试访问越界索引是一个逻辑错误,所以必须有人进行检查。如果我们不在库中进行检查,我们只是将负担转移给了调用者。但我们并没有因此变得更好。但实际情况并非如此,因为调用者的代码可能对同一个元素进行多次不同的访问。我们可能进行几次读取,然后一次写入。在这种情况下,我们只需要进行一次范围检查。如果我们调用 at 操作,它每次都会进行范围检查。但情况甚至更糟,因为大多数时候我们访问向量是在循环中进行的,并且我们设置循环的方式使得永远不会发生越界访问。在这些情况下,根本没有理由进行范围检查。因此,如果范围检查内置于运算符中,那么在这些情况下每次都是浪费时间。

这引出了一个常被提及的观点:你可以在快速的基础上构建安全,但无法在安全的基础上构建快速


本节课中,我们一起学习了 C++ 的核心设计哲学——“不妥协的性能”。我们探讨了零开销原则的含义,并通过整数运算和 std::vector 访问的例子,理解了 C++ 在性能与安全性之间所做的权衡。这种设计选择使得 C++ 能够为开发者提供底层控制能力,同时允许在需要时构建安全抽象。

028:游戏架构与资源注册表

概述

在本节课中,我们将学习现代AAA游戏的基本架构,并深入了解其中一个核心系统——资源注册表。我们将探讨游戏开发中面临的数据管理挑战,以及如何通过特定的数据结构来解决这些问题。

游戏架构的演变

上一节我们介绍了本课程的主题,本节中我们来看看游戏架构是如何随着时间演变的。

从现代视角回顾复古游戏,会发现它们相对简单。这些游戏大多是2D或2.5D,例如《毁灭战士》并非真正的3D,而是通过巧妙的地图设计营造出3D错觉。它们通常是单线程的,常常从头开始编写,没有现代意义上的游戏引擎。开发周期较短,通常为一到两年,代码库规模也较小。例如,经典版《毁灭战士》总代码量约为6万行,其中约4万行是实际代码。以现代标准来看,这可能只是一个包或库的规模。

时至今日,AAA级游戏已完全不同。它们都是3D游戏,采用基于物理的光线追踪渲染技术。所有游戏都是多线程的,不可能再以单线程运行。开发过程通常依赖游戏引擎,没有引擎几乎不可能制作出AAA游戏。开发周期相比过去大大延长,通常需要四到五年。代码库庞大且复杂。

游戏循环结构

无论游戏新旧,其基本结构大致相同。

以下是经典游戏循环的组成部分:

  1. 初始化:启动游戏,进行初始化过程。
  2. 游戏循环:进入游戏主循环。
  3. 输入处理:循环的第一步总是监听来自消息泵的输入。
  4. 游戏逻辑模拟:处理所有游戏系统,如AI、物理、动画。
  5. 渲染:将所有模拟数据打包并发送给渲染器,由渲染器计算并发送至GPU,最终在屏幕上显示画面。
  6. 音频:处理游戏声音。

这个过程会不断重复,直到玩家退出游戏。

现代游戏的结构仍然类似,但许多部分因为计算负载高而拥有自己的独立线程。主线程的游戏循环负责监听输入,模拟类别有一个线程运行所有游戏系统,完成后将信息打包发送给渲染器。渲染器执行其复杂工作,可能进行一些计算,最终在屏幕上显示画面。音频系统独立运行。此过程同样重复直至玩家退出。

为什么需要资源注册表

介绍完游戏架构后,我们需要理解其中系统面临的问题。本节将聚焦于初始化阶段的一个重要组件。

游戏由大量不同类型的资源构成,包括模型、材质、纹理、音频文件以及AI系统的行为树等。资源注册表的作用是集中存储、管理这些数据,并在系统需要时提供访问。

资源注册表的工作方式如下:每当一个关卡被加载时,通常有一个与该关卡关联的资源包。关卡加载时,资源包被解压,所有数据保存在资源注册表中。当系统请求一个资源时,可以通过某种标识符(如UUID、GUID或资源ID)来请求。

由此可知,资源注册表需要某种形式的关联容器,将标识符映射到资源上。

关联容器的选择与挑战

最常用的关联容器仍然是 std::unordered_map

std::unordered_map 存在一个问题:它是一个链式哈希表,每个桶中的链表用于处理冲突。我们知道链表缓存不友好。特别是当哈希表的负载因子很高时,会有大量的指针跳转。因此,理想情况下我们希望避免使用链表。

std::unordered_map 的第二个问题是,对象在底层数组中分布广泛。这意味着 std::unordered_map 默认具有较高的访问差异,对象存储位置相距较远。

因此,大多数游戏公司转而使用某种开放寻址哈希表。

开放寻址哈希表与罗宾汉哈希

开放寻址哈希表基本上就是没有链表部分的 std::unordered_map,它只是一块连续的内存。这样在遍历容器时能有更好的缓存性能。我们通过在连续内存块中探测来解决冲突。常见的探测方法有线性探测、二次探测和双重哈希。今天我们要讨论的是罗宾汉哈希。

罗宾汉哈希的工作原理如下:你有一块连续的内存,其中包含一系列桶数组。每个桶数组都与某种元数据关联,即PSL(探测序列长度)。一个对象的PSL值基本上表示该对象从其本应放置的初始桶开始,经过了多少次探测才找到存储位置。

其插入过程简述如下:

  1. 对象通过哈希函数计算初始桶位置。
  2. 如果目标桶为空,直接插入,PSL设为0。
  3. 如果发生冲突(目标桶已有元素),则开始探测。
  4. 在探测过程中,比较当前插入元素的探测距离(从初始桶到当前位置的步数)与桶内现有元素的PSL。
  5. 遵循“劫富济贫”原则:如果新元素的探测距离大于桶内旧元素的PSL,则新元素继续向后探测寻找空位;如果新元素的探测距离小于或等于旧元素的PSL,则新元素“抢占”该桶,旧元素被踢出并继续向后探测寻找新位置。
  6. 此过程持续进行,直到所有元素都找到位置,并且尽可能保证每个元素的PSL(即其“贫富”程度)相对平均。

这种方法旨在减少最坏情况下的查找时间,使哈希表的性能更加可预测。

总结

本节课我们一起学习了现代游戏的基本架构,从复古游戏到AAA游戏的演变,以及经典的游戏循环模型。我们重点探讨了资源管理系统的重要性,并分析了 std::unordered_map 在游戏开发中可能遇到的性能瓶颈。最后,我们介绍了开放寻址哈希表作为替代方案,并简要说明了罗宾汉哈希这一优化策略的工作原理,它通过平衡元素的探测距离来提升哈希表的整体性能。理解这些底层数据结构是构建高效、流畅游戏体验的基础。

029:概述与定义

在本节课中,我们将要学习什么是浮点数计算的确定性,以及为什么它在跨平台开发中至关重要。我们将从基本定义开始,探讨其重要性,并了解为何长期以来它被视为一个难以解决的问题。

什么是确定性?

确定性这一术语有多种可能的定义。我们将重点关注其中的三种。

第一种定义是:如果给定完全相同的输入,同一个可执行文件的行为完全一致,我们就称该程序是确定性的。这个定义相对容易实现,主要需要处理未初始化变量和多线程等问题。

第二种定义是:由完全相同的源代码构建,但针对不同平台的可执行文件,在给定相同输入时行为完全一致。对于整数运算,这通常是可能的。但对于浮点数运算,这变成了一项极其困难的任务。据我所知,在我们之前,没有人真正解决过这个问题。

第三种定义实际上更为重要。它意味着同一个函数在不同的上下文中行为完全一致。显然,特别是在浮点数领域,并且考虑到我们生活在非关联运算的世界中,这并不一定成立。同一个浮点函数,如果从代码中两个不同的位置调用,即使参数完全相同,也可能返回不同的结果。这实际上是一个大问题。

为什么确定性如此重要?

那么,根据不同定义,这些确定性为何如此重要呢?

第一种定义(同一可执行文件的确定性)很重要,因为我们希望保持可重现性,并且希望拥有稳定性。如果每次运行时都产生不同的结果,就很难为其制定合理的测试。

第二种定义(跨平台确定性)对于分布式模拟(包括在线游戏,特别是实时战略游戏)至关重要。就在昨天,Eds(他今天也在场)进行了一次演讲,描述了他自己的游戏如何依赖确定性来减少服务器和客户端之间的流量。这个想法非常可靠,然而,为了让其在他的场景中生效,他不得不将所有模拟重写为定点数运算。否则,他将无法实现跨不同平台的确定性。

第三种定义(不同上下文中的确定性)甚至更为重要,因为它关系到算法的鲁棒性,并避免可怕的“幽灵”问题。我们曾以艰难的方式了解到这一点。当时,我们有一个在二维空间中运行的算法,遇到了这样一种情况:有一条线,线附近有一个点。这个“附近”意味着该点有时会落在容差范围内。对于算法目的而言,这个点是在线的左侧还是右侧并不重要。我们的算法无法接受的是,该点同时出现在线的两侧。此时,算法开始崩溃。而这正是因为我们从两个不同的上下文调用了同一个内联函数,尽管参数完全相同,但在不同上下文中它给出了微妙不同的结果。

更普遍地说,我们发现,有些算法在数学上是坚实的,当我们将其视为定点数算法时也是坚实的,但从浮点数的角度来看,它们却会崩溃。如果你仔细想想,这其实相当明显。因为在浮点数的世界里,与数学和定点数不同,加法不存在结合律。每当我们在浮点数领域进行加法时,每次加法后都有一个隐式的舍入操作。而这个舍入操作相对于加法本身并不是线性的,因此加法的顺序在浮点数视图中确实很重要,而在数学和定点数中则不是这样。大多数时候,这类算法可以重写,使其即使在浮点数情况下也能保持坚实,但要做到这一点,我们需要...

030:搭建桥梁的实用指南

在本教程中,我们将学习如何将 C++ 和 Rust 这两种语言连接起来。我们将从最基础的手动方式开始,逐步理解互操作的核心概念,确保初学者也能跟上。

概述

C++ 和 Rust 都是强大的系统编程语言,各有其设计哲学和优势。有时,我们需要在一个项目中同时使用它们,例如在现有的 C++ 项目中集成用 Rust 编写的新模块。本节课的目标是学习如何手动构建一个简单的桥梁,让 C++ 代码能够安全地创建和使用 Rust 对象。

上一节我们介绍了课程目标,本节中我们来看看具体的实现步骤。

第一步:定义 Rust 类型和函数

首先,我们需要在 Rust 端定义一个简单的类型和一个创建该类型实例的函数。假设我们有一个表示机器人关节的类型。

// Rust 端代码
#[repr(C)]
pub struct RobotJoint {
    pub position: f64,
    pub velocity: f64,
}

#[no_mangle]
pub extern "C" fn robot_joint_new(position: f64, velocity: f64) -> *mut RobotJoint {
    Box::into_raw(Box::new(RobotJoint { position, velocity }))
}

代码解释:

  • #[repr(C)]:确保 RobotJoint 结构体使用与 C 语言兼容的内存布局,这是跨语言传递数据的关键。
  • #[no_mangle]:防止 Rust 编译器改变函数名称,确保 C++ 端能通过 robot_joint_new 这个名称找到它。
  • extern “C”:指定函数使用 C 语言的调用约定。
  • Box::into_raw:将 Rust 的 Box(智能指针)转换成一个原始指针 (*mut RobotJoint),以便将其所有权移出 Rust 的安全边界。

第二步:为 C++ 提供销毁函数

在 Rust 中分配的内存必须在 Rust 中释放。因此,我们需要提供一个对应的销毁函数。

// Rust 端代码
#[no_mangle]
pub extern "C" fn robot_joint_free(ptr: *mut RobotJoint) {
    if !ptr.is_null() {
        unsafe { drop(Box::from_raw(ptr)) };
    }
}

代码解释:

  • 此函数接收一个原始指针。
  • 它检查指针是否为空,然后使用 Box::from_raw 将原始指针重新转换回 Box。当 Box 离开作用域时,其 drop 方法会被自动调用,从而释放内存。
  • 整个操作包裹在 unsafe 块中,因为操作原始指针是不安全的。

第三步:创建 C++ 包装类

现在,我们转向 C++ 端。目标是创建一个 C++ 类,它内部持有一个指向 Rust RobotJoint 对象的指针,并管理其生命周期。

以下是 C++ 头文件 robot_joint.h 的内容:

// C++ 头文件 robot_joint.h
#include <memory>

// 前向声明 Rust 中的类型。使用一个命名空间来明确其来源。
namespace rust {
    struct RobotJoint;
}

class RobotJoint {
public:
    // 构造函数:调用 Rust 函数创建对象
    RobotJoint(double position, double velocity);
    // 移动构造函数和移动赋值运算符使用默认实现
    RobotJoint(RobotJoint&&) = default;
    RobotJoint& operator=(RobotJoint&&) = default;
    // 禁止拷贝
    RobotJoint(const RobotJoint&) = delete;
    RobotJoint& operator=(const RobotJoint&) = delete;

    // 可以在此处添加访问 position 和 velocity 的成员函数
    // double get_position() const;

private:
    // 自定义删除器,用于在 unique_ptr 销毁时调用 Rust 的释放函数
    struct Deleter {
        void operator()(rust::RobotJoint* ptr) const;
    };
    // 使用 unique_ptr 管理 Rust 对象指针,并指定自定义删除器
    std::unique_ptr<rust::RobotJoint, Deleter> impl_;
};

// 声明 Rust 端的 C 函数
extern "C" rust::RobotJoint* robot_joint_new(double position, double velocity);
extern "C" void robot_joint_free(rust::RobotJoint* ptr);

代码解释:

  • std::unique_ptr<rust::RobotJoint, Deleter>:这是一个智能指针,它独占所指向的 rust::RobotJoint 对象的所有权。模板的第二个参数 Deleter 是一个自定义删除器,它指定了当 unique_ptr 需要销毁对象时,应该调用我们之前定义的 robot_joint_free 函数,而不是普通的 delete
  • 删除拷贝构造函数和拷贝赋值运算符,使得这个类成为“只移动”类型,这符合其独占资源所有权的语义。

第四步:实现 C++ 包装类

接下来,我们实现头文件中声明的方法。

以下是 C++ 源文件 robot_joint.cpp 的内容:

// C++ 源文件 robot_joint.cpp
#include “robot_joint.h”

// 实现自定义删除器
void RobotJoint::Deleter::operator()(rust::RobotJoint* ptr) const {
    if (ptr) {
        robot_joint_free(ptr);
    }
}

// 实现构造函数
RobotJoint::RobotJoint(double position, double velocity)
    : impl_(robot_joint_new(position, velocity), Deleter{}) {}

代码解释:

  • 构造函数调用 robot_joint_new 来创建 Rust 对象,并将返回的原始指针与 Deleter 一起传递给 impl_ 的构造函数。
  • RobotJoint 对象被销毁(例如离开作用域)时,impl_ 这个 unique_ptr 会自动调用其删除器 Deleter::operator(),进而调用 robot_joint_free 来安全地释放 Rust 端的内存。

总结

本节课中我们一起学习了 C++/Rust 互操作的基础。我们手动构建了一个桥梁,使得 C++ 代码能够:

  1. 通过 RobotJoint 包装类的构造函数,安全地创建 Rust 对象。
  2. 利用 std::unique_ptr 和自定义删除器,自动管理 Rust 对象的生命周期,确保内存被正确释放。

这个例子虽然简单,但涵盖了跨语言互操作的核心模式:在边界处定义清晰的 C 接口,并用智能指针在 C++ 侧管理资源。掌握了这个基础,你就能理解更复杂的互操作场景和自动化工具(如 bindgencxx)的工作原理。在后续章节中,我们将探讨如何为这个类添加方法、处理更复杂的数据类型以及错误处理。

031:类与封装 🍎

在本节课中,我们将学习C++面向对象编程的基础,特别是类的定义、封装以及成员函数的使用。我们将通过一个简单的例子来理解如何创建类、初始化其成员,并控制对类内部数据的访问。


上一节我们介绍了课程概述,本节中我们来看看如何定义一个基本的类。

大家好,欢迎来到我的C++面向对象编程基础讲座。

我是Andreas Fertig。我创建了一个名为C++ Insights的工具。在本次讲座中,我们将多次使用它来窥探代码背后的机制。我还写了几本关于C++的书。我认为它们已经售罄,但你仍然可以在网上或亚马逊上找到它们。

我的日常工作是为企业提供培训服务,包括现场或远程的内部课程。我也与CPP Khan Academy合作,他们主办会前和会后研讨会。虽然时间很紧,但你仍然可以预订我明天和周日举行的关于高效C++的会后研讨会。你还有机会预订Nikolai Josuttis的课程,他的课程在周一举行,并且是在线的,所以更方便。如果你想按照自己的节奏学习,我有一门自学课程,它实际上是我关于C++20课程的录像,你也可以了解一下。我是德国人。在这次会议和其他会议上,我遇到了很多会说德语、是德国人或者至少懂一点德语的人。如果你不是,你可以把我的姓氏翻译成英语,意思是“完成”、“已经”或“结束”。我认为这是一个非常积极的名字。当然,在德语中它是一个常用的形容词。当我年轻的时候,我和朋友们开玩笑说,如果每次有人使用我们的姓氏我们都能得到一分钱,那我们全家就发财了。但这并没有发生。不过,每当我做这些讲座时,我都会更多地思考我的姓氏。我最近发现,在巴黎夏季奥运会上,他们开始比赛或游泳时的口令是“Ready, Steady, Go”。我刚刚告诉了你我姓氏的翻译,它可以翻译成“Ready”。所以,不仅在奥运会上,每次比赛开始时,他们都会以我的姓氏作为口令序列的开头。这很有趣。在我们深入C++之前,让我们继续这个语言小课堂。我刚刚教了你如何把我的名字翻译成英语。那么,如何把这个口令序列翻译成德语呢?你准备好了吗?在德语中,它是“Auf die Plätze, Fertig, Los”。你会翻译错,对吧?讽刺的是,我们从一个三个词的口令序列开始,在德语中你必须仔细听,否则你会过早开始比赛。而我的姓氏排在第二位。如果你深入研究语言,这是有道理的。但这只表明语言是有趣且复杂的,无论是口语还是我们在计算机上使用的语言。所以,让我们深入探讨你来这里可能想学的内容。我猜我在C++方面比在我的母语方面更擅长。


上一节我们了解了讲师和课程背景,本节中我们来看看面向对象编程的核心——类的定义。

面向对象编程是关于类的。我假设你已经熟悉了。我们通常从类开始,因为它是关于封装的。我们想要建模对象,所以我们使用关键字class,给类起一个名字,在我的例子中是Apple。接下来,我们需要决定将内容放在哪个访问区域。我们基本上有三种选择。我们可以将某些内容放在public作用域,这意味着它对所有人都是可见和可访问的。你也可以将其放在private作用域,表示它只对这个类可访问和可见。这是面向对象编程的主要内容。我们希望生活在一个有封装的世界里,我们可以拥有类的私有数据,并控制对这些单独成员的访问。这与C语言的结构体不同,在结构体中所有内容都是可访问的。技术上我们还有一个访问说明符protected,当我们从基类派生时,它会起作用。protected意味着在该作用域内的任何内容,除了声明它的类及其所有派生类之外,其他任何人都无法访问。这样,你可以给你的“亲属”比其他人更多的访问你私有数据的权限。假设你不会在Facebook上分享一切,这可能是你想要的做法。


上一节我们讨论了访问说明符,本节中我们来看看类的数据成员和构造函数。

由于类包含私有数据,我们也可以在这个类的底部看到,这里有一个私有数据成员。你应该总是有充分的理由才将数据成员声明为public,因为类的一个要点就是保持数据私有并通过访问函数来控制访问。因此,我们需要一个构造函数,你可以在这里顶部的D处看到。由于这是一个单参数构造函数,并且被标记为explicit,因为单参数构造函数也被称为转换构造函数,编译器在寻找转换序列时允许使用它们。为了避免这种情况不必要地或无意地发生,我们倾向于遵循最佳实践,将我们的单参数构造函数标记为explicit,除非我们真的希望这种转换发生。一旦到达这一点,就是关于初始化数据成员的问题。我在C处向你展示了这一点,我以冒号开始一个序列,这就是所谓的构造函数初始化列表。请务必在那里初始化你的数据成员。这是做这件事的地方。如果你类中有一个对象是可默认构造的,并且你将初始化移到了构造函数体内,它仍然会在构造函数的初始化列表中初始化,你无法阻止这一点。如果你的类中的类型不可默认构造,你会遇到编译器错误信息,因为编译器总是尝试在那里初始化对象。所以这是你应该放置它们的地方。否则,你只是冒险让初始化发生两次。这意味着,确实,在D处我们有构造函数体,构造函数体的目的是确保不变性,我们将在接下来的几张幻灯片中看到一个例子。


上一节我们介绍了构造函数初始化列表,本节中我们来看看成员函数。

然后我在这里有两个成员函数setget。顾名思义,一个用于设置数据成员(这个类中我只有一个),另一个用于获取它的值。两者之间的一个区别是,set在这里像普通函数一样写出,而get在函数声明的末尾带有一个const。这向你的同行开发者、你自己和编译器发出信号:当我们调用这个函数时,这个对象的状态不会改变。这是你得到的承诺。所以这是有价值的东西。你可能会听到像“常量正确性”这样的术语,我们可以构建常量对象链。所以,每当你有一个不修改类状态的getter时,请友好地将其设为const,这是正确的做法。在下面的main函数中,你可以看到我使用了这个Apple对象。我在这里使用Alice作为一种苹果,我分别在这个Alice对象上调用setget


上一节我们看到了成员函数的实际使用,本节中我们使用C++ Insights工具来窥探代码背后的机制。

现在,让我们第一次在这里使用C++ Insights来窥探一下幕后的情况。对于那些没有见过C++ Insights的人,它是这样的。你也可以使用命令行版本。它的主要目的是:你在左边输入C++源代码,在右边得到C++源代码输出。这是一个绝妙的主意,对吧?价值百万美元的想法。到目前为止还没有人为它付过钱。C++ Insights真正做的是,它用你编译时使用的设置来注释它在右边输出的内容。在这个特定情况下,C++ Insights以Clang的视角看待一切。你有一个默认模式,可以翻译代码。我还启用了一个特殊模式,显示C++到C的转换。我在C++人群面前展示更多C代码表示歉意,但这里的目的是帮助你理解类在内部是如何真正工作的,你可以在这里看到一些注释。


本节课中我们一起学习了C++中类的基础知识,包括如何定义类、使用访问说明符(publicprivateprotected)实现封装、编写构造函数和成员函数,以及使用const成员函数来保证对象状态不变。我们还简要介绍了C++ Insights工具,它可以用来查看代码的底层转换,帮助我们更好地理解C++的机制。

032:输出范围适配器是C++ Ranges的下一步迭代吗? 🚀

概述

在本节课中,我们将探讨C++ Ranges库中的一个重要概念及其潜在的设计演进。我们将从一个具体的问题出发,深入分析其根源,并了解一种可能的解决方案。课程内容将包含标准库代码分析、核心概念解释以及简单的示例,旨在让初学者能够理解Ranges的工作原理和当前面临的挑战。


我经常在简短的交流中学到东西,这些经验如果靠自己摸索,可能需要数周、数月甚至数年才能获得。



好的。计时器响了,提醒我该开始了。大家今天过得怎么样?这次CPP Con大会进展顺利吗?我知道这是漫长的一周。

如果你坐在后排区域,我想告诉你,这些幻灯片上有很多代码。没有一张幻灯片的代码超过25行,但这仍然相当多。有些代码字体较小,如果你看不清,可以移到前排区域,那样会容易得多。如果因为某种原因无法向前移动,我也在CPP Con Discord上本次演讲的频道里发布了幻灯片。

欢迎在那里留下评论、起哄,或者指出证明我错误的代码。所有这些都很有趣。

我有很多内容要讲,而且语速有点快。如果你完全跟不上了,请举手示意。我会尽量在最后回答问题。如果你在YouTube上观看,可以用较慢的速度播放,因为我说话很快。对此我感到抱歉。我对此感到非常兴奋,这一周我都在大力宣传它。我真的很高兴能讨论这个话题。

让我们直接开始吧。对于那些不认识我的人,我参与C++标准委员会工作已有相当长的时间,八年了。我参与过std::spanstd::atomic_ref、执行策略等标准特性的工作,还有许多其他较小的特性。

我目前是第9研究组的主席,该组负责Ranges库的设计和演进。所以这次演讲有点切题。但这并不意味着我比委员会里的每个人都更了解Ranges。事实上,这恰恰意味着我不是。我的职责是让那些比我更懂Ranges的人达成一致,这相当有挑战性。这也不意味着我是Ranges的超级粉丝。我主张在正确的情况下使用正确的工具,很多时候Ranges并不是那个正确的工具。我认为这次演讲可能会让你相信,在更多情况下它可能不是合适的工具。我对Ranges的某些部分持公开批评态度。这正是重点,我们需要通过讨论来改进现有的库。

这意味着我有机会比大多数人更多地使用C++。我可以在C++26特性远未发布之前就使用它们,因为这是我与委员会实际合作的内容。这次演讲在很大程度上是出于探索的精神。就像在说:“嘿,这东西似乎运行得不错。我们在标准中能用它做什么?它会是什么样子?”

我在演讲者宴会和社区晚宴上听说,总结性的幻灯片很好。所以这里有一张总结幻灯片。我不太擅长做总结,但让我们开始吧。

我们将从描述Ranges设计中的一个主要缺陷开始。像所有C++事物一样,它有一个几乎无法发音的名字:Tpoasi。就像“Spney”一样。有人能立刻说出Tpoasi是什么吗?好吧,读过那篇博客文章的人比我想象的要少。哇,甚至我的一些标准委员会同事也不知道。所以,是的,我们会深入探讨这个问题。然后我们会找出导致这个问题的根本原因。我们将以我的方式来进行。如果你看过我以前的演讲,你知道我喜欢深入代码,深入到最基础的层面。我们将使用真实的代码,这些代码来自标准库。我会把复制粘贴的标准库代码放到这些幻灯片上。如果你觉得我疯了,那可能是因为我确实有点疯。我们将讨论一种解决方案,这种方案从某种意义上颠覆了Ranges。希望到演讲结束时,你能真正理解这意味着什么。然后我们将现场实现其中的一些部分。我很高兴能与大家一起做这件事。我会提问:“嘿,有人能想出这一行该写什么吗?”

然后我们将简要讨论这对C++的未来意味着什么,以及这如何融入我对Ranges的模糊愿景中。这其实并不重要,因为决定方向的人不是我,而是房间里和我一起的大家。

好吧,我们开始。Tpoasi,我猜是这么发音的。它代表“递增智能迭代器的可怕问题”。

这来自John Bakara在2019年的一篇博客文章,他是C++社区中一位很酷的人,我想我从未见过他本人。他说这不是Ranges的致命缺陷,但它可能是一个真正的问题,在所有情况下了解它都是有益的。希望到本次演讲结束时,你能理解Tpoasi是什么。

让我们来阐述一下。首先通过一个例子来说明。同样,这些幻灯片上有很多代码。这是一个非常基础、简单的Ranges用例。transformfilter可能是所有Ranges操作中最基础的两个。许多不同的操作都可以表示为filtertransform

如果我们用C++23来做,我们有println,所以我们可以假装在写Python,取一个向量,通过管道传递给一个transform(我们将元素加倍),然后再通过管道传递给一个filter(我们过滤掉那些模4不等于0的元素)。你可以想象一下:1乘以2是2,模4不等于0,所以不通过;2乘以2是4,模4等于0,所以我们得到4输出;然后3乘以2是6,模4不等于0;4乘以2是8,模4等于0。这就是Ranges的工作原理。

让我们用另一种方式来做,并通过我们经典的printf调试(或者现在叫println调试)来打印,在每个函数中打印出它们被调用了多少次。

那么,你认为transform会被调用多少次?filter又会被调用多少次?有人想猜一下吗?

你可能会希望是5次和5次,对吧?你肯定希望是5次和5次。

不幸的是,是5次和7次,或者7次和5次。我忘了哪个是哪个了。总之,这里发生了奇怪的事情。transform(2)被调用了两次。

我将用一种方式来理解为什么会发生这种情况。如果你想了解另一种很好的解释方式,可以看看本周早些时候Nikco的演讲。Nikco(我不知道他的姓怎么正确发音)做了一个关于“驯服filter视图”的演讲,他详细讲解了这个问题以及filter视图的其他一些问题。他解释得非常出色。他是一位真正的教育者,所以他知道自己在做什么。而我只是为了好玩才做这个。

但我会按照我的理解来解释,希望你能跟上。

那么,filter坏掉了吗?这就是问题。我们将深入真正的标准库代码。我实际上会把一些复制粘贴的代码放到屏幕上,然后我会讲述我在阅读这段代码时大脑做了什么,以及我的大脑忽略了什么。我认为这实际上是一项非常有价值的技能,是你工具箱里应该有的东西。标准库不是教程,标准库并不是为了让普通程序员阅读而设计的,但你仍然可以从中学习。你可以在IDE中点击查看定义,阅读一下。有时候这没有帮助,说实话,有时候标准库里有太多东西,真的很难读,比如std::variantstd::tuple,代码非常难读。但有时候它会帮助你。拥有“这不是不可读的代码,这不是黑盒子,它就像任何其他代码一样,只是有更复杂的要求”这样的想法是很好的。

那么,让我们先看看transform_view。这是Libc++中transform_view的实现。这里有很多内容。当我开始深入研究时,我看到,好吧,不同版本之间有差异。为了理解我正在做的事情,我并不太关心那个。


总结

本节课我们一起探讨了C++ Ranges中一个被称为Tpoasi的问题,即“递增智能迭代器的可怕问题”。我们从一个简单的transformfilter管道示例出发,观察到其调用次数不符合直觉,从而引出了对Ranges内部工作机制的探究。我们简要浏览了标准库中transform_view的实现代码,认识到理解底层代码是深入掌握库特性的一种有效途径。在接下来的章节中,我们将继续深入分析这个问题产生的原因,并探讨可能的解决方案。

033:一个程序员的探索之旅 🚀

在本教程中,我们将跟随演讲者的思路,探索“会话类型”这一数学概念在C++中的实际应用。我们将了解什么是类型,它在编程中的多种用途,并最终理解会话类型试图解决的问题。内容将力求简单直白,适合初学者理解。


概述:什么是类型?🤔

上一节我们提到了会话类型,但要理解它,我们首先需要探讨“类型”本身。类型是编程中的一个核心概念,但它的含义可能因上下文而异。与其纠结于复杂的数学定义,不如从它在编程中的实际用途入手。

在编程语言中,类型扮演着多种角色,类似于货币在经济中的不同功能。理解这些角色,能帮助我们更好地把握“类型”的本质。

以下是类型在编程中的几个主要用途:

  1. 抽象:类型提供了足够的信息,让我们知道如何与某个对象交互,而无需了解其背后的所有实现细节。这简化了复杂系统的理解和使用。
  2. 文档:类型本身是一种文档。它告诉我们如何使用某个对象或如何与之交互。最明显的例子是代码自动补全功能:当你输入一个对象名并加上点操作符时,开发环境会根据其类型提示可用的方法。
  3. 错误检查:类型系统可以在编译时或运行时检查操作的有效性,防止不匹配的操作,从而提前发现错误。
  4. 优化:编译器可以利用类型信息生成更高效的机器代码。
  5. 模块化:类型有助于定义清晰的接口,促进代码的模块化和组件复用。

会话类型的动机与挑战 ⚙️

上一节我们介绍了类型的一般用途,本节中我们来看看“会话类型”这一特定概念。会话类型是一个来自数学和理论计算机科学的概念,用于描述通信协议中交互的结构和顺序。

演讲者探索会话类型的直接动机非常实际:一位朋友断言这在C++中“不可能实现”。这激发了他的挑战欲。虽然在其他语言中已有不同实现,但在C++中似乎鲜有尝试。因此,本次旅程的目标就是探索在C++中实现会话类型的可能性。


总结 🎯

本节课中我们一起学习了:

  1. “类型”在编程中是一个多用途的概念,核心作用包括抽象文档、错误检查和优化等。
  2. “会话类型”是用于规范通信协议交互顺序的一种类型理论。
  3. 在C++中实现会话类型是一个有趣的挑战,其动机源于一个实际的断言,目的是探索C++语言在表达复杂协议方面的能力。

通过理解类型的基础角色,我们为后续深入探讨如何在C++中建模和实现会话类型这一更专业的领域打下了基础。

034:从零开始构建数值积分器

在本教程中,我们将学习如何在C++中从零开始实现一个基础的数值积分器。我们将通过一个计算宇宙年龄的具体例子,来理解数值积分的基本原理、实现步骤以及相关的物理背景。课程内容将尽可能简单直白,适合初学者。

概述

数值积分是计算数学和科学计算中的核心工具,用于求解无法获得解析解的微分方程。在C++中,我们常常需要自己构建这些工具。本节我们将从一个具体的物理问题——计算宇宙的年龄——出发,了解如何用简单的C++代码实现一个基础的数值积分器。

从示例开始

上一节我们概述了课程目标,本节中我们来看看一个具体的C++程序示例。这个程序的编码风格可能不是最优的,但其目的是帮助我们理解数值积分在做什么。

以下是示例程序的核心代码框架:

#include <iostream>
#include <vector>
#include <cmath>

// ... 其他头文件和函数声明

int main(int argc, char* argv[]) {
    // 声明常数和类型
    // 获取参数(从输入或使用默认值)
    // 调用计算函数
    std::vector<double> evolution = compute(...);
    // 输出结果(例如,宇宙年龄)
    std::cout << evolution.back() / 1.0e9 << std::endl;
    return 0;
}

// 计算函数,执行积分
std::vector<double> compute(...) {
    double a = 1.0; // 尺度因子,当前时刻归一化为1
    double t = 0.0; // 时间
    std::vector<double> evolution;
    evolution.reserve(...);

    // 定义待积分的函数 f(a)
    auto da_dt = [&](double a) {
        return a * H0 * std::sqrt(Omega_r / (a*a*a*a) + Omega_m / (a*a*a) + Omega_k / (a*a) + Omega_lambda);
    };

    // 积分循环
    while (a > epsilon) {
        evolution.push_back(t);
        // 数值积分核心步骤
        a -= h * da_dt(a); // 向后积分(回到过去)
        t -= h; // 时间回溯
    }
    return evolution;
}

这个程序计算了一个关键数值。如果运行它,并将结果附上单位,输出大约是 13.7839 billion years(138.39亿年)。这正是我们当前宇宙的年龄。同时,程序计算出的 evolution 向量描述了宇宙从大爆炸至今的尺度因子演化过程。

数值积分的基本原理

上一节我们看到了一个能计算宇宙年龄的程序,本节中我们来剖析其核心——数值积分方法。

程序中使用的是最基础的数值积分方法,称为前向欧拉法(在本例中用于时间回溯,故可视为反向欧拉)。其核心公式如下:

公式:y_{n+1} = y_n + h * f(t_n, y_n)

其中:

  • y_n 是当前步的解(例如尺度因子 a)。
  • h 是步长参数。
  • f(t_n, y_n) 是微分方程定义的导数函数(在本例中是 da_dt)。

这个公式的含义是:利用当前点的导数,沿着该方向前进一小步(h),来估算下一个点的值。通过循环迭代这个过程,我们就可以近似求解整个微分方程。

在我们的宇宙学例子中,方程 da_dt 来源于对爱因斯坦场方程的简化。在假设宇宙是均匀且各向同性的前提下,可以得到弗里德曼方程。再假设宇宙内容物为理想流体混合物,就能推导出我们程序中积分的一阶常微分方程。

软件架构与C++实现考量

理解了基础算法后,本节我们探讨如何为数值积分器设计更好的C++软件架构。

目标是构建一个灵活、高性能且易于使用的积分器库。我们需要考虑以下几个方面:

以下是实现时需要考虑的关键组件:

  1. 微分方程定义:如何让用户方便地定义任意的一阶常微分方程组 dy/dt = f(t, y)
  2. 积分算法抽象:将欧拉法、龙格-库塔法等不同算法抽象为统一的接口。
  3. 状态与结果管理:高效存储积分过程中的状态和输出结果。
  4. 步长控制:实现自适应步长控制,在保证精度的同时提高效率。
  5. 类型与单位:使用模板支持不同的数值类型(如 float, double, std::complex),并考虑物理单位处理。

一个初步的类结构设计可能如下:

template<typename StateType, typename TimeType = double>
class ODEIntegrator {
public:
    using DerivativeFunction = std::function<StateType(TimeType, const StateType&)>;

    struct Result {
        std::vector<TimeType> times;
        std::vector<StateType> states;
    };

    virtual Result integrate(const DerivativeFunction& f,
                             const StateType& y0,
                             TimeType t_start,
                             TimeType t_end,
                             TimeType initial_step) = 0;
    virtual ~ODEIntegrator() = default;
};

// 具体积分器实现:前向欧拉法
template<typename StateType, typename TimeType>
class ForwardEulerIntegrator : public ODEIntegrator<StateType, TimeType> {
public:
    typename ODEIntegrator<StateType, TimeType>::Result integrate(
        const typename ODEIntegrator<StateType, TimeType>::DerivativeFunction& f,
        const StateType& y0,
        TimeType t_start,
        TimeType t_end,
        TimeType initial_step) override {
        // ... 实现欧拉法积分循环
    }
};

展望:C++26的可能特性

上一节我们讨论了架构设计,本节我们简要展望未来C++标准可能带来的便利。

C++26及未来的标准可能会引入更多有助于科学计算的特性,例如:

以下是可能相关的特性方向:

  • 更强大的编译时计算constexpr 功能的持续增强,允许更复杂的微分方程在编译时被部分处理或优化。
  • 线性代数标准库:如果提案被接受,将提供标准的矩阵、向量类型和操作,极大简化科学计算代码。
  • 改进的数值类型:对自定义数值类型(如自动微分、区间算术、带单位的量)提供更好的语言支持。
  • 执行策略与并行算法的增强:使得为数值积分器轻松添加并行化支持变得更加简单。

这些特性将帮助我们构建更强大、更易用且性能更高的数值计算库。

总结

本节课中我们一起学习了数值积分的基础知识。我们从一段计算宇宙年龄的具体C++代码出发,理解了最基础的欧拉积分法及其对应的数学公式 y_{n+1} = y_n + h * f(t_n, y_n)。我们探讨了该问题背后的物理原理——源自弗里德曼方程的微分方程。接着,我们讨论了如何设计一个更通用、更健壮的C++数值积分器软件架构,包括抽象积分算法、管理状态和结果。最后,我们展望了未来C++标准可能为科学计算带来的新工具。通过本课,你应该对如何在C++中从零开始实现并设计一个数值积分器有了初步的认识。

035:C++26原子最小最大值操作

在本节课中,我们将学习C++26标准中即将引入的原子最小值和最大值操作。这些操作是并发编程中的重要工具,能够帮助我们在多线程环境中安全、高效地更新共享变量,而无需使用显式锁。

概述

C++20标准引入了原子操作,如 fetch_addcompare_exchange,但 fetch_minfetch_max 操作直到C++26才被纳入标准库。这些操作在并行计算领域已有悠久历史,许多平台(如OpenCL和CUDA)早已支持。在多线程环境中,当多个线程同时尝试更新一个共享变量时,可能会发生竞态条件,导致数据损坏和程序行为不可预测。原子最小最大值操作通过确保对共享变量的更新是原子性的(即不可分割且不会被其他线程中断)来解决此问题。

上一节我们介绍了原子操作的基本概念,本节中我们来看看具体的接口和用法。

原子最小最大值操作的接口

C++标准库通过 std::atomic 模板类及其成员函数 fetch_maxfetch_min 来提供原子最小最大值操作。这些函数会原子性地将共享变量更新为其当前值与给定值之间的最大值或最小值,其行为类似于已有的 fetch_add 操作。

以下是 fetch_max 的函数原型示例:

T fetch_max(T arg, std::memory_order order = std::memory_order_seq_cst) noexcept;

性能优势

硬件对原子最小最大值操作的原生支持可以带来显著的性能提升。基准测试结果表明,与使用比较并交换循环的自定义实现相比,硬件指令在多核场景下性能更优,并且随着核心数量的增加,其性能扩展性良好。

代码示例

以下是一个演示如何在多线程环境中使用 atomic_fetch_max 的代码示例。该程序使用并行处理在0到999的范围内查找最大值。

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

std::atomic<int> max_value(0); // 共享原子变量,用于存储最大值

// 处理子范围并更新最大值的函数
void find_max_in_range(int start, int end) {
    for (int i = start; i < end; ++i) {
        // 原子性地更新 max_value,如果 i 更大则替换
        max_value.fetch_max(i, std::memory_order_relaxed);
    }
}

int main() {
    const int num_threads = 10;
    const int range_size = 100;
    std::vector<std::thread> threads;

    // 创建10个线程,每个处理100个数字
    for (int t = 0; t < num_threads; ++t) {
        int start = t * range_size;
        int end = start + range_size;
        threads.emplace_back(find_max_in_range, start, end);
    }

    // 等待所有线程完成
    for (auto& th : threads) {
        th.join();
    }

    // 输出最终结果
    std::cout << "Maximum value found: " << max_value.load() << std::endl; // 输出 999
    return 0;
}

代码解析

以下是该示例的关键组成部分解析:

  1. 共享原子变量std::atomic<int> max_value(0) 是一个原子整数,用于安全地在线程间共享和更新最大值。
  2. 工作函数find_max_in_range 函数遍历给定的子范围,并使用 fetch_max 原子性地更新全局最大值。这里使用了 memory_order_relaxed,因为在这种求最大值的归约操作中,操作的精确顺序并不重要。
  3. 线程创建:主函数创建了10个线程,每个线程处理一个互不重叠的100个数字的子范围(例如,线程0处理0-99,线程1处理100-199,以此类推)。
  4. 原子操作max_value.fetch_max(i, std::memory_order_relaxed) 是核心操作。它原子性地比较 max_value 的当前值和 i,并将 max_value 设置为两者中的较大值。
  5. 线程同步:通过 join() 等待所有工作线程完成,确保在所有范围都被处理后才打印最终结果。

此方法的优点

  • 并行性:利用多核CPU加速处理过程。
  • 无锁编程:无需使用互斥锁等显式锁机制,减少了线程争用和死锁风险。
  • 简化实现atomic_fetch_max 提供了一个简洁、安全的原语,简化了并发最大值计算的代码。

总结

本节课中我们一起学习了C++26提案中的原子最小最大值操作。我们了解了它们对于编写高效、安全的多线程程序的重要性,并通过一个具体的代码示例,掌握了如何使用 std::atomic::fetch_max 来在多线程环境中进行无锁的归约计算。这些操作是构建高性能并发数据结构(如无锁队列、栈)的基础工具之一。

036:C++模块与CMake入门指南 🚀

在本教程中,我们将学习C++20模块的核心概念,并了解如何在CMake项目中配置和使用它们。我们将从模块的基本定义开始,逐步探讨其与传统头文件的区别,以及构建系统在处理模块时面临的挑战。

模块基础:告别头文件

上一节我们介绍了本课程的目标,本节中我们来看看什么是C++模块。C++模块旨在替代传统的头文件(#include)。一个简单的模块示例如下:

export module B;
export void b() { /* ... */ }

此代码定义了一个名为B的模块,并导出了一个函数b。其他代码可以通过import B;语句来使用这个函数。

当编译器处理模块时,它不仅编译代码,还会生成一个编译模块接口文件。这个文件在不同编译器中有不同的名称:

  • MSVC: .ifc
  • GCC: .gcm
  • Clang: .pcm

在本教程中,我们将统一称其为BMI文件。

构建系统的挑战:编译顺序问题

理解了模块的基本构成后,我们来看看它对构建流程的影响。与传统头文件不同,模块引入了编译依赖关系。

考虑以下两个文件:

  • 模块 B (B.cpp): 定义并导出模块B
  • 模块 A (A.cpp): 导入并使用模块B

如果尝试先编译A.cpp,编译器会报错,因为它找不到模块B的BMI文件。必须先编译B.cpp生成其BMI文件,然后才能成功编译A.cpp

这个例子揭示了一个核心问题:模块要求构建系统必须知道每个文件生成哪些BMI文件,以及哪些文件消费(导入)这些BMI文件。这需要构建系统能够解析源代码以确定这些依赖关系。

CMake与模块:现状与目标

上一节我们看到了模块带来的新挑战,本节中我们来看看构建工具CMake如何应对。CMake是一个广泛使用的跨平台构建系统生成器。

在2023年,CMake对C++20模块的支持尚不完善。即使询问AI模型,得到的代码示例也往往是错误或无效的。这表明网络上关于“CMake + 模块”的正确实践和示例代码非常稀缺。

因此,本教程的一个重要目标是提供清晰的指南,帮助开发者在CMake项目中成功使用C++模块,并共同丰富这方面的知识库。

关于讲师

最后,我们来了解一下本教程内容背后的分享者。讲师是Kitware公司的联合创始人之一,该公司专注于科学可视化软件。他早期在GE研究院计算机视觉小组的工作经历,促使他深入参与到构建系统和软件流程工具中,这最终为CMake的诞生和发展奠定了基础。CMake的成功离不开整个开源社区的贡献。


本节课中我们一起学习了

  1. C++模块的基本语法和目的,即取代#include
  2. 模块编译后会生成关键的BMI文件。
  3. 模块引入了新的编译顺序依赖,这对构建系统提出了新要求。
  4. CMake正在完善对C++模块的支持,社区需要更多正确的实践范例。

通过掌握这些基础知识,你已经为在实际项目中使用C++20模块并配置相应的CMake构建脚本做好了准备。

037:引言与挑战 🎮

在本节课中,我们将学习如何为快节奏在线多人游戏实现一个确定性的回滚系统。我们将从理解核心挑战开始,逐步深入到实现细节。

概述

在线多人游戏开发面临网络延迟的固有挑战。与本地游戏不同,玩家的输入需要时间才能传递到其他玩家的设备上。这意味着游戏状态必须基于过去的输入进行计算,这给保持所有玩家游戏体验的同步和流畅带来了巨大困难。

上一节我们介绍了课程的整体目标,本节中我们来看看开发快节奏在线多人游戏的具体难点。

为何开发快节奏在线多人游戏如此困难?

我们所说的“快节奏”游戏,通常指实体数量不多的游戏。例如,拥有数万个单位的庞大RTS游戏很难归入此类。我们更多考虑的是格斗游戏、FPS、赛车足球等物理驱动、要求低延迟的游戏。这类游戏实体不多,因为我们即将使用的技术要求游戏状态不能对普通计算机造成过大负担。

让我们谈谈互联网。因为当我们进行在线多人游戏时,必须经过互联网这个庞大的网络,我们会遇到所谓的网络延迟

以下是几种我们必须处理的延迟类型:

  • 传播延迟:这是光速限制带来的固有延迟。信息无法超越光速传播。
  • 处理延迟:数据包在网络中传输时,需要经过路由器处理,这会增加额外延迟。
  • 排队延迟:路由器上并非只有你的游戏数据包,它需要等待其他数据包发送完毕。
  • 传输延迟:互联网所有线路的带宽限制也会造成延迟。

所有这些延迟叠加起来,就构成了巨大的网络延迟。

这意味着你的游戏必须等待其他玩家的输入,因为延迟会产生影响。这与本地多人游戏不同,在本地游戏中,虽然也有延迟,但在实现游戏时,你可以认为所有操作都是瞬间完成的。

换句话说,我们是在基于过去的输入数据进行工作。在本地,当你在玩游戏时,你处于“当前帧”。但在在线环境中,你只能获得过去时间窗口内的输入数据。

本节课中我们一起学习了在线多人游戏开发的核心挑战——网络延迟,它导致我们必须处理过去的输入数据。在接下来的章节中,我们将探讨如何通过确定性模拟和回滚系统来解决这一难题。

038:为CPU和GPU编写可移植的C++程序

概述

在本教程中,我们将学习如何编写能够在CPU和GPU上运行的C++程序。我们将探讨CUDA编程的基本概念,理解主机(host)和设备(device)函数的区别,并学习如何通过模板和函数修饰符来创建可移植的代码。我们的目标是避免代码重复,并确保程序在不同硬件平台上都能正确运行。


为什么需要可移植的代码?

上一节我们介绍了本课程的目标。本节中,我们来探讨为什么需要编写同时支持CPU和GPU的程序。CPU和GPU的设计初衷不同,通常用于解决不同类型的问题。然而,在某些情况下,让同一套代码在两个平台上运行是有益的。

以下是四个主要原因:

  1. 算法相似性:某些算法(例如“令人尴尬的并行”算法)在CPU和GPU上的最佳实现可能非常相似。在这种情况下,维护两套独立的代码是低效的。
  2. 用户体验:在软件推广初期,用户可能没有配备GPU的硬件。提供CPU版本可以让他们在没有GPU的情况下试用软件。
  3. 开发便利性:开发者可以在没有GPU的笔记本电脑上(例如在火车上)运行和测试程序,而不必依赖昂贵的工作站。
  4. 调试与测试:在CPU上调试程序通常比在GPU上更直接、更容易。

CUDA编程快速入门

上一节我们讨论了可移植代码的必要性。本节中,我们来看看实现这一目标所需的基础知识——CUDA编程的核心概念。

在CUDA中,函数主要分为三种类型:

  • 主机函数:使用 __host__ 修饰符(或默认不修饰)。它们在CPU上运行,存储在CPU内存中。
  • 设备函数:使用 __device__ 修饰符。它们在GPU上运行,存储在GPU内存中。
  • 全局函数:使用 __global__ 修饰符。这是一种特殊的设备函数,作为在GPU上启动计算的入口点(称为“内核”)。

调用规则

  • 主机函数可以调用其他主机函数。
  • 设备函数和全局函数可以调用其他设备函数。
  • 主机函数通过启动内核来调用全局函数,但不能直接调用设备函数。

一个简单的代码示例如下:

__device__ int get_zero() {
    return 0;
}

__global__ void my_kernel(int* data) {
    int idx = threadIdx.x;
    data[idx] = get_zero(); // 设备函数调用
}

__host__ void launch_kernel() {
    int data[6];
    my_kernel<<<1, 6>>>(data); // 启动内核(全局函数)
    cudaDeviceSynchronize(); // 等待内核执行完毕
}

int main() {
    launch_kernel(); // 主机函数调用
    return 0;
}

编译:CUDA代码通常使用 nvcc 编译器进行编译。nvcc 会处理设备代码,然后将主机代码传递给像 gcc 这样的主机编译器。


实现可移植性的核心挑战

上一节我们介绍了CUDA的基础。本节中,我们来分析编写可移植代码时遇到的具体挑战。

主要问题在于,某些函数或数据结构可能只设计用于主机或只用于设备。直接交叉调用会导致编译错误。例如,一个标记为 __host__ 的函数无法在设备代码中被调用。

考虑以下代码结构:

struct HostStruct {
    __host__ int func() { return 42; }
};

struct DeviceStruct {
    __device__ int func() { return 42; }
};

template <typename T>
__host__ __device__ int wrapper_func(T obj) {
    return obj.func(); // 这里可能有问题!
}

对于 wrapper_func 函数,我们希望它既能运行在主机上(接受 HostStruct),也能运行在设备上(接受 DeviceStruct)。但是,当 THostStruct 时,obj.func() 是一个主机函数,这意味着 wrapper_func设备版本将尝试调用一个主机函数,这是不允许的。反之亦然。


解决方案:利用SFINAE与模板特化

上一节我们指出了核心挑战。本节中,我们来看看如何利用C++的模板技术来解决这个问题。

解决方案的核心是确保在编译时,只为函数生成与其执行环境兼容的版本。我们可以使用 SFINAEif constexpr 来实现条件编译。

以下是使用 if constexpr 和C++17的 is_same_v 类型特征的示例:

#include <type_traits>

struct HostStruct {
    __host__ int func() { return 42; }
};

struct DeviceStruct {
    __device__ int func() { return 42; }
};

template <typename T>
__host__ __device__ int portable_wrapper(T obj) {
    // 在编译时检查类型,并选择正确的路径
    if constexpr (std::is_same_v<T, HostStruct>) {
        // 此代码块仅存在于函数的host版本中
        return obj.func(); // 调用host函数
    } else if constexpr (std::is_same_v<T, DeviceStruct>) {
        // 此代码块仅存在于函数的device版本中
        return obj.func(); // 调用device函数
    }
    // 对于其他类型,可以返回错误值或静态断言
    return -1;
}

工作原理

  1. portable_wrapper 被主机代码调用并传入 HostStruct 时,编译器只生成函数的 __host__ 版本。if constexpr 在编译时判断 THostStruct,因此只保留第一个分支的代码,第二个分支被丢弃。生成的代码完全合法。
  2. 当它在设备代码中被调用并传入 DeviceStruct 时,编译器生成函数的 __device__ 版本。此时第一个分支被丢弃,只保留第二个分支的代码。

这种方法确保了无论是主机函数还是设备函数,都不会尝试调用对其执行环境无效的函数。


总结

在本节课中,我们一起学习了为CPU和GPU编写可移植C++程序的基本方法。

我们首先理解了为什么需要这种可移植性,包括提升用户体验、方便开发和调试。然后,我们回顾了CUDA编程中主机函数、设备函数和全局函数的关键概念及其调用限制。接着,我们分析了实现可移植性的主要挑战:如何让一个函数模板同时兼容仅限主机或仅限设备的方法。最后,我们探讨了解决方案,即利用 if constexpr 和类型特征在编译时选择正确的代码路径,从而确保生成的函数版本不会违反CUDA的调用规则。

通过掌握这些概念和技术,你可以开始构建能够在不同硬件平台上灵活运行的C++应用程序,减少代码重复,并提高开发效率。

039:构建数据库

在本节课中,我们将要学习编译数据库的概念、它们在 C++ 模块引入后面临的局限性,以及一种名为“构建数据库”的解决方案如何应对这些挑战。

编译数据库简介

上一节我们介绍了课程概述,本节中我们来看看编译数据库。

编译数据库是 JSON 文档。它们是顶层包含对象的数组。每个对象描述构建过程中发生的单个命令。对象包含命令运行的工作目录、命令本身、正在编译的源文件以及将创建的输出文件。然而,输出字段是可选的,这可能导致问题。

编译数据库由 Clang 项目指定,并非 ISO 标准。但它们被广泛使用和提供,大多数构建系统都会生成它们。静态分析工具和集成开发环境使用它们来理解项目,因为仅凭 C++ 源代码本身,在不了解传递给编译器的标志的情况下,意义不大。

以下是编译数据库的一个示例:

[
  {
    "directory": "/home/user/project",
    "command": "/usr/bin/g++ -I./include -c main.cpp -o main.o",
    "file": "main.cpp",
    "output": "main.o"
  }
]

以下是编译数据库的生成方式:

  • 通常由构建系统生成,例如 CMake 或 Meson。
  • 构建工具如 Ninja 也支持生成。
  • 也存在包装构建过程并发现构建命令的工具。

如果由构建系统生成,它们通常与构建规则一起可用。因此,当生成 Makefile 或 Ninja 构建文件时,IDE 可以立即开始理解源代码。

编译数据库的局限性

上一节我们介绍了编译数据库的基本概念,本节中我们来看看其局限性。

编译数据库通常表现良好,但存在例外情况。如果缺少输出字段,源文件可能变得不明确。一个源文件可能被编译多次,例如在发布模式和调试模式下,或者因为被编辑以用于具有不同标志的多个目标。在这种情况下,IDE 需要理解哪个版本是相关的。

命令以字符串形式表示,被指定为 shell 转义。这在 Unix 系统上通常可以假设为 Bash,但在 Windows 上,通常使用 CMD,也有人使用 PowerShell,它们的转义序列非常不同。除了查看字符串并根据启发式方法猜测外,没有地方可以说明正在使用哪种 shell。

编译数据库也没有可扩展性,没有地方用于保留字段名称。因此,向此格式添加任何新字段都充满了与生态系统中其他工具可能已做的内容冲突的风险。

还存在可移植性问题。所有标志都取决于正在使用的编译器。一个标志可能不被特定版本的 GCC 支持,从而导致错误。

此外,编译数据库不提供构建图信息。它不知道是否存在预期的生成头文件,也不知道是否存在尚未找到的生成源文件。对于模块,CMake 在其策略中会在构建期间写出一些文件,然后命令会读取这些文件。因此,配置时的信息是不完整的。

C++ 模块带来的挑战

上一节我们讨论了编译数据库的局限性,本节中我们来看看 C++ 模块如何使情况复杂化。

对于不了解的人来说,模块使编译 C++ 变得复杂。过去那种“令人尴尬的并行”构建方式(我有 N 个核心,就运行 N 个作业)已经结束。这是因为 C++ 采用了 Fortran 的编译瓶颈。

这意味着,当编译一个源文件时,会得到一个目标文件,还会得到一个称为 BMI 的文件。BMI 是构建模块接口或二进制模块接口。某些实现称之为 CMI。当某些代码导入一个模块时,它寻找的就是这个文件。但 BMI 是在编译过程中产生的,并非立即可用。

C++ 模块与 Fortran 模块的相似之处在于,它们都有编译器特定的 BMI。不能将 Clang 的模块用于 GCC,反之亦然。对于 C++,情况甚至更严格,编译标志也很重要。如果一个模块是用 C++23 编译的,而另一个代码试图用 C++26 并导入该 23 模块,这将无法工作,因为 BMI 本质上是抽象语法树的转储,当标准改变时,AST 也会改变,导致无法读取。

C++ 模块与 Fortran 的另一个共同点是,需要导入哪个模块是由源代码内容决定的,无法仅通过某些固有属性预先知道。因此,每次源文件更改时,都必须重新构建依赖图,或者在构建过程中发现依赖图将如何变化。

不同之处在于细节。Fortran 支持所谓的子模块,本质上是强制嵌套的模块,并且支持从单个翻译单元导出多个模块。C++ 不这样做,而是有分区,但分区本质上也是模块,只是不能直接导入。此外,Fortran 没有 C++ 所面临的标志问题。

Fortran 模块构建的历史与启示

上一节我们了解了 C++ 模块的挑战,本节中我们来看看 Fortran 模块的构建历史以获取启示。

Fortran 模块在 90 年代引入。当时一家供应商的官方文档建议“运行并行构建直到成功”。这种方法在遇到循环依赖或导入未构建的模块时会导致构建永不结束。

最终,有人创建了 f90dep 工具,它会查看 Fortran 源代码并写出一个 Makefile 片段,说明编译此目标前需要先编译另一个目标。将这个片段包含在 Makefile 中就能获得可靠的构建。

CMake 的现任维护者 Brad King 在 2000 年代末期将其引入 CMake,并为 Makefile 生成器实现了支持。在 2010 年代中期,他又为 Ninja 实现了依赖支持,并维护了相应的补丁集。

构建数据库:一种解决方案

上一节我们回顾了历史,本节中我们来看看“构建数据库”这一解决方案。

构建数据库旨在解决编译数据库的局限性,特别是针对 C++ 模块。它扩展了 JSON 格式以包含更丰富的构建信息。关键改进包括明确的输出文件映射、构建图依赖关系、编译器标志的规范化表示以及支持生成的文件和模块元数据。

一个构建数据库条目可能如下所示:

{
  "version": 2,
  "entries": [
    {
      "directory": "/path/to/build",
      "command": {
        "executable": "/usr/bin/g++",
        "arguments": ["-std=c++20", "-fmodules-ts", "-c", "main.cpp", "-o", "main.o"]
      },
      "inputs": ["main.cpp"],
      "outputs": ["main.o", "main.pcm"],
      "dependencies": {
        "implicit": ["module.modulemap"],
        "order-only": ["generated.h"]
      },
      "provides": [],
      "requires": ["MyModule"]
    }
  ]
}

构建数据库的核心优势在于它使工具能够理解完整的构建上下文,而不仅仅是单个编译命令。这对于需要知道模块 BMI 文件位置、处理生成的文件或解析复杂编译器标志链的 IDE 和构建工具至关重要。

总结

本节课中我们一起学习了编译数据库的基本概念及其在支持现代 C++ 特性(尤其是模块)方面的局限性。我们探讨了 C++ 模块如何改变了构建过程,使得传统的编译数据库信息不足。最后,我们介绍了一种名为“构建数据库”的扩展解决方案,它通过提供更丰富的构建上下文、依赖关系和元数据,为工具链提供了全面支持 C++ 模块化编程所需的信息。

040:硬件友好编程入门 🚀

概述

在本教程中,我们将探讨软件开发中的性能优化,特别是如何编写对硬件友好的代码。我们将从硬件的基本工作原理入手,理解内存和处理器如何交互,从而帮助您编写出更高效的程序。


硬件世界初探 🖥️

上一节我们概述了本课程的目标。本节中,我们来看看软件运行的实际环境——硬件。

C++代码在编译后,并非运行在一个抽象的“C++机器”上。从实践角度看,它运行在由硬件及其周边组件构成的计算机平台上。

这个平台可以简化为几个核心部分:处理器内存以及连接两者的互连部件。输入/输出(IO)虽然重要,但不在本次讨论的范围内。

处理器和内存的物理形态随着时间推移而演变,但其基本功能保持不变。处理器负责执行指令,而内存则用于存储代码和数据。


深入理解内存 💾

上一节我们介绍了计算机平台的基本构成。本节中,我们将重点剖析内存的工作原理。

动态内存(DRAM)的本质是一个由电容器组成的阵列。每个电容器存储一个电荷,代表一个逻辑电平(0或1)。这些电容器通过内部逻辑电路进行控制,以实现数据的读写。

内存访问遵循特定的电气协议。这个过程涉及发送命令、等待内部操作完成,然后才能获取或写入数据。以下是内存交互的基本步骤:

  1. 激活:发送命令以选择内存阵列中的某一行(Row),并等待其生效。
  2. 列选择:发送命令以选择该行中的特定片段(列,Column),并再次等待。
  3. 数据访问:此时才能实际读取或写入目标地址的数据。
  4. 预充电与刷新:由于DRAM的读取操作是破坏性的,读取后需要将数据写回(预充电)。同时,电容器上的电荷会随时间泄漏,因此必须定期对所有存储单元进行重新读取和写入(刷新),频率大约为几十毫秒一次。

尽管单个步骤的延迟在纳秒级别,但这些延迟会累积起来,对整体性能产生显著影响。内存接口的实际有效数据传输率(有用吞吐量)仅为其理论峰值的大约10%。

这种限制源于物理定律。例如,电容器的充放电速度受到电流大小和晶体管可承受电压的限制。在高度集成的电路内部,无法使用过大的电流或电压。

从市场发展来看,内存的阵列速度(电容单元本身的速度)在过去25年间(从DDR1到DDR5)提升有限。真正的巨大提升在于数据传输速率(即接口带宽),这体现在上图中绿色的部分。然而,如果无法快速地从内存阵列中获取数据,高带宽的优势就无法充分发挥。


内存架构与协议 📡

上一节我们了解了内存访问的物理延迟和限制。本节中,我们来看看这些限制背后的架构和协议原因。

内存采用分层式的二维地址结构,这主要是出于电气工程和制造工艺的考虑。将所有单元线性排列会带来信号完整性问题。

从协议角度看,连接到内存组件的信号引脚数量有限,这也是需要采用这种动态寻址结构的原因之一。此外,我们必须牢记动态内存(DRAM)的“动态”特性——它需要持续的刷新来维持数据。


总结

在本节课中,我们一起学习了硬件友好编程的基础知识。我们了解到,C++程序运行在真实的硬件平台上,而非抽象机器。我们深入探讨了动态内存(DRAM)的工作原理,包括其基于电容的存储方式、包含激活、列选、数据访问和刷新的访问协议,以及由物理定律决定的性能瓶颈。最后,我们明白了内存的二维架构、有限引脚和动态刷新特性是其工作方式的根本原因。理解这些硬件底层机制,是进行有效软件性能优化的第一步。

041:从运动规划领域案例看复杂测试的简化

在本节课中,我们将学习如何为具有复杂输入参数的函数编写测试。我们将通过一个来自自动驾驶运动规划领域的具体案例进行深入探讨,并尝试从中提炼出可推广的通用方法。

测试的层次与结构

上一节我们介绍了本课程的目标。本节中,我们来看看软件测试的一般层次。

测试存在于一个光谱中。它们从端到端测试开始,其范围是整个系统。然后是集成测试,测试各个部分如何协同工作。单元测试用于测试单个组件。甚至编译错误也可以被视为一种在行或子行级别的测试。越靠近光谱顶端,测试范围越大,可以测试的内容越多,但它们往往更慢、更昂贵,需要审慎使用。越靠近光谱底端,测试越细粒度、越快、越便宜,也更容易理解和进行快速迭代。

这里最好的、最重要的测试种类是什么?实际上,它们都很重要。它们是互补的,为我们提供了纵深防御,这对于我们正在尝试做的事情至关重要。

今天讨论的重点位于光谱的中间部分,例如集成测试,或者可能是较大的单元测试。基本上,这些都是你会用经典的C++测试用例来编写的东西。

对于这类测试,一个测试用例通常包含三个阶段:

  • 准备:为你的函数设置输入。
  • 执行:调用你的函数。
  • 断言:在最后,测试你得到的结果是否良好。

今天的问题主要存在于“准备”阶段,可能也涉及一点“执行”阶段。

复杂输入带来的挑战

我们知道现实世界中的函数可能具有非常复杂的输入。这是一个巨大的挑战。

例如,我们有一个函数,它接收一个历史运动轨迹作为输入,以及未来的某个时间间隔 dt。我们想要预测未来的运动。这些输入可能非常复杂。也许我们有一个同时进行变道和加速的交通参与者。你如何通过单元测试来编写这个输入?这真的很难。

坦白说,我们面临两个糟糕的选择:

  1. 我们可以提供精心设置的真实数据,但这会使测试难以理解,失去测试意图,并且随着代码演变变得非常脆弱。
  2. 另一种选择是,我们尝试创建一些简单的、伪造的数据,仅仅满足函数契约即可。但由此得到的测试往往没有太大意义。

那么,我们如何走出这种困境呢?根据托尔斯泰的说法:“所有简单的函数输入都是相似的;每个复杂的输入都以自己的方式复杂。”这意味着这里不太可能有一个通用的理论。

因此,我们将采取的方法是:选取一个复杂的输入,深入分析,并尝试从中归纳。

案例研究:自动驾驶运动规划

我们将要深入分析的例子是自动驾驶汽车中的运动规划组件。如果你不知道如何制造自动驾驶汽车,这没关系。我们将学习足够的知识,以便任何人都能跟上。

首先,我们从一个黑盒图景开始。我们知道输入侧有一些传感器,如摄像头、激光雷达、GPS等,还有一个黑盒的自动驾驶软件栈。输出侧则是油门、刹车和方向盘被驱动。

如果我们细化这个图景,会发现有明确定义的组件通过传递消息进行通信。传感器告诉我们自身的位置、周围有什么、需要获取地图的哪一部分、如何规划到达目的地的路线。所有这些信息都进入运动规划组件,它的职责是评估当前情况,并生成一个未来几秒内要遵循的时空轨迹。

下一个组件是控制器,它知道如何为我们所在的特定平台读取该轨迹,并将其转化为具体的驱动指令。当然,几分之一秒后我们会获得新的传感器数据,然后重复这个过程,重新规划。这就是自动驾驶软件栈的大致图景。

运动规划的实现架构

现在,我们如何实现今天重点关注的运动规划器组件呢?一种常见的方式是采用一种三阶段架构,它非常灵活。

  1. 评估情况:首先,我们将所有输入整合成一个连贯的整体。
  2. 生成想法:然后,我们提出大量想法,成百上千个。
  3. 评估与选择:最后,我们评估所有这些想法,并选出最好的一个。

这是一个优雅、健壮且灵活的架构。例如,在中间“生成想法”的阶段,你可以预留一些能力用于少数几个你总是希望考虑的紧急机动策略。然后,当这些策略比我们能想到的任何其他方案都更好时,我们就会精确地执行它们。这很容易理解,非常棒。

从这些输入一直到输出轨迹,构成了一个规划周期。这个周期将是我们今天关注点的测试单元。我们可以测试整个规划器,也可以测试其中任何一个主要的独立组件,或者它们的子组件。我们注意到,如果我们有了这些最初的输入,通过运行规划器的相应部分,就可以轻松生成任何下游的复杂输入。这很好,但也不好,因为这些输入本身也非常复杂。

简化之道:先易后难

这里的解决之道基于这条永恒的经典建议:当你试图做出改变时,首先,让改变变得容易,然后,进行这个容易的改变。对于那些尚未在日常工作中培养这个习惯的人来说,这就像一个作弊码。它能让你充满信心地快速推进,因此强烈推荐。

那么,如何做到“让改变变得容易”呢?绝对的第一步是,你需要弄清楚“容易”到底应该是什么样子。如果你做不到这一点,就没有必要继续下去了。好消息是,这一步真的很有趣。你可以想象在你理想的世界中会编写什么样的源代码,没有任何限制,只是头脑风暴,没有坏主意。

但在这之后,你需要戴上评判的帽子,退一步审视你写下的东西,并思考:这可行吗?你能想象如何实现这些API吗?它是否易于正确使用,难以错误使用?别忘了考虑范围。在你的脑海中明确哪些用例是核心用例,你必须完美支持;哪些是边缘用例,实现起来可能有点挑战;以及哪些用例完全不在范围内。记住,你总是可以以后再构建另一个库来处理那些超出范围的用例。

一个具体示例

那么,对于运动规划来说,一个例子是什么样的呢?假设我们有一条双车道高速公路。我们在右侧车道以65英里/小时的速度行驶。前面有一辆车只开50英里/小时,周围没有其他人。我们想要运行规划器,并测试它是否提出了变道超车的建议。

我们需要从某个地方获取地图。这个API,我们稍后再讨论。然后,我们将从地图中获取一个车道,这将是我们的车道。


本节课中我们一起学习了测试的基本层次、复杂输入带来的挑战,并通过一个自动驾驶运动规划的具体案例,引入了“先让改变变得容易,再进行改变”的核心思路。我们还开始构思一个理想化的测试构建方式。在接下来的章节中,我们将深入探讨如何具体实现这种“容易”的测试构建方法。

042:概述与核心概念

在本教程中,我们将学习如何使用 C++ 范围库(Ranges)来实现粒子滤波器。粒子滤波器是一种用于估计动态系统内部状态的算法,广泛应用于机器人定位等领域。我们将从核心概念讲起,逐步深入到具体的代码实现。

概述

粒子滤波器是一种贝叶斯滤波器,它使用一组粒子来近似表示系统状态的概率分布。每个粒子代表一个可能的状态假设,并有一个与之关联的权重,该权重与该粒子是系统真实状态的可能性成正比。通过预测和更新两个步骤,粒子滤波器可以随着时间推移,结合新的观测数据来演化这个分布,从而更准确地估计系统状态。

上一节我们介绍了粒子滤波器的基本概念,本节中我们来看看其核心组成部分。

粒子与相关概念

首先,我们定义与粒子相关的核心概念。

粒子对象 是一个包含两个成员的结构:

  • state:代表系统状态,可以是任何类型。
  • weight:代表该粒子的权重,必须可转换为 double 类型。

我们可以用以下代码结构来描述一个简单的粒子:

struct Particle {
    StateType state; // 状态,例如机器人的位置和朝向
    double weight;   // 权重
};

状态更新函数 是一个可调用对象,它接收一个粒子的状态,并根据系统的转移模型计算并返回一个新的状态。这对应粒子滤波器中的预测步骤。

权重更新函数 是一个可调用对象,它接收一个粒子的状态,并根据当前的观测数据,计算该状态是系统真实状态的可能性(似然),并返回一个权重值。这对应粒子滤波器中的更新步骤。

滤波器函数 是主要的算法接口。它接收三个参数:

  1. 一个粒子集合(例如 std::vector<Particle>)。
  2. 一个状态更新函数。
  3. 一个权重更新函数。

它的职责是执行两个核心步骤:更新所有粒子的状态和权重,然后对粒子集合进行重采样。

C++ 范围库简介

在深入实现之前,我们需要了解将要用到的强大工具——C++ 范围库。

范围库是标准算法和迭代器库的扩展与泛化。自 C++20 起,我们有了正式的范围概念,可以直接将范围传递给算法。此外,还有视图,它们是惰性求值的范围,只有在迭代时才会进行计算。

一个接收范围并生成视图的对象被称为范围适配器。这些适配器可以在范围管道中组合,形成简洁、声明式的表达式,例如:

auto result = data | views::filter(pred) | views::transform(fn) | views::take(10);

在 C++23 中,我们获得了实现与标准库兼容的用户自定义适配器的能力。我们还可以使用 ranges::to 适配器将范围具体化为非视图类型(如容器)。

正如你可能开始看到的,粒子集合本质上就是范围,而粒子滤波器就是应用于这些范围的算法。因此,范围库提供了以易于阅读、减少错误且易于重用的方式编写这些算法的工具。

基础实现:使用标准库工具

现在,让我们开始使用标准库中的工具来实现粒子滤波器的基本框架。

首先,我们可以使用 std::ranges::views::transform 来创建访问粒子不同属性的视图。以下代码展示了如何创建粒子状态和权重的视图:

auto states_view = particles | std::views::transform(&Particle::state);
auto weights_view = particles | std::views::transform(&Particle::weight);

请注意,此时尚未进行任何计算,这只是视图的声明。

接下来,我们使用 std::ranges::transform 算法来实际更新每个粒子的状态和权重:

std::ranges::transform(states_view, states_view.begin(), state_update_fn);
std::ranges::transform(weights_view, weights_view.begin(), weight_update_fn);

执行完这一步后,我们的粒子集合中的所有状态和权重都已根据模型和观测数据更新完毕,准备进行重采样。

实现重采样步骤

重采样步骤的目标是保留高权重的粒子,淘汰低权重的粒子。一种方法是将我们的粒子集合视为一个离散分布,并使用权重作为概率从中进行采样。

标准库提供了 std::discrete_distribution 类来完成这个任务。但是,它的构造函数不能直接接受一个范围。我们可以使用 ranges::to 适配器将我们的权重视图具体化,然后用来构造分布实例。

最后,我们需要创建一个新的粒子集合,并使用离散分布返回的索引值,从原始粒子集合中采样粒子来初始化新的集合。

一个直观但不够优雅的实现可能包含一个手写的 for 循环,它同时完成了采样和新集合构建的多个任务。

然而,利用范围库,我们更希望写出如下声明式的管道代码:

auto resampled_particles = original_particles
                         | views::sample_with_replacement(weights_view, num_particles)
                         | ranges::to<std::vector<Particle>>();

这个管道包含三个清晰的步骤:

  1. sample_with_replacement:根据权重,从原始粒子集合中进行有放回地随机采样(这意味着同一个粒子可能被多次抽取)。
  2. ranges::to:将采样结果的具体化视图转换为新的 std::vector<Particle>

这里的关键是,sample_with_replacement 这个适配器在标准库中并不存在,因此我们需要自己来实现它。这正是展示范围库强大扩展能力的地方。

总结

在本节课中,我们一起学习了粒子滤波器的基本原理,并将其核心操作——状态更新、权重更新和重采样——与 C++ 范围库的概念联系起来。我们看到了如何使用现有的范围适配器(如 transformto)来处理粒子集合,并提出了一个理想中的、清晰的重采样管道实现。在接下来的章节中,我们将动手实现那个缺失的 sample_with_replacement 自定义范围适配器,从而完成一个完整、优雅且实用的粒子滤波器。

043:为何选择现代C++构建最快的GameBoy模拟器

在本节课中,我们将探讨为何选择现代C++来构建世界上最快的GameBoy模拟器。我们将从动机、技术背景和项目优势等多个角度进行分析。


为何选择现代C++

这是CppCon大会。我认为如果尝试用Java编写最快的模拟器,大家可能不会感兴趣。我也不想那样做。所以我们选择C++。

为何编写模拟器

作为一名程序员,我注意到我的学生也是如此。他们中的一些人在毕业设计中编写了模拟器或部分模拟器。这让他们对硬件有了更深入的理解。如果你必须模拟硬件,你就必须理解硬件的工作原理。那种“我把C++代码放进去,然后就有结果出来”的神秘感会消失,取而代之的是“我理解这些步骤的作用”。

为何选择GameBoy

顺便说一句,GameBoy生日快乐。它在美国推出已有25年,也就是四分之一个世纪。在座有谁玩过GameBoy?这很好,这让这次演讲变得更容易一些。

GameBoy的销量约为1.2亿台。如果算上向后兼容的GameBoy Advance,销量约为1.97亿台。实际上,GameBoy Advance并不是真正的向后兼容,而是内部集成了GameBoy硬件。因此,总销量大约在2亿台左右。它曾是史上最畅销的游戏机,仅次于PlayStation 2。所以,这是一项非常重要的技术。

它的屏幕效果很差。说它不差的人,可能是因为很久没玩过了。我手头有几台。原版GameBoy的屏幕确实很糟糕。我找到的这张截图拍摄得很好,不知道他们是怎么处理光线的。当画面开始移动时,一切都会变得模糊。分辨率很低。

它有八个按钮:一个方向键、开始、选择、A、B。它是单声道输出,但如果你接上耳机,可以听到立体声。所以,大家都知道它是什么。

它之所以重要,是因为技术相当简单。屏幕分辨率不高,这些都是优势。

很多人已经注意到,为GameBoy编写模拟器相对容易。如果你想自己写一个,你绝不会是第一个。网上有很多开源项目,这是一个巨大的优势。如果你在某个地方卡住了,可以看看别人是怎么做的。

网上还有大量非常详细的技术资料。我们都希望自己能有这样的资料。我不知道你们中有多少人参加了关于单元测试的演讲。那位演讲者讲得很棒,他说最重要的一点是必须编写单元测试。这是对的。我们需要编写单元测试。谁不写单元测试呢?在座的有些人可能在说谎,尤其是在业余项目中。

好消息是,GameBoy有一系列测试程序。虽然不如单元测试那么完善,但仍然很有用。你把这些测试程序放进你的模拟器,如果它能运行,你就知道自己的模拟器处于什么水平。这些测试实际上能提供相当详细的关于哪里正确或错误的信息。总而言之,这非常棒。

它拥有庞大的游戏库。这对我们最终要做的项目来说很有趣。而且,这个庞大的游戏库体积很小。我没有疯。它的体积确实很小。整个包含数千款游戏的库只有800MB,这使得处理起来很合理。仅用800MB你能获得多少乐趣?如果你有一个GameBoy模拟器,乐趣会相当多。

令人惊讶的是,今早我查了一下数据,有一个非常活跃的自制游戏社区。图中蓝色部分代表原版GameBoy。网上有807个自制项目可供使用。所以,除了那数千款商业游戏,以及其中约30%向后兼容的576款游戏之外,所有这些自制游戏也供你享受。其中一些非常出色,当然不是全部。这些自制项目有时也只是一个演示程序。我说“只是一个演示”,但演示程序可能真的很有趣,就像Commodore 64的演示场景一样,人们用这些东西做出疯狂的效果。

对于编写模拟器来说,另一个重要点是它的架构相当简单。GameBoy在80年代末推出,实际上是在8位机时代之后。这意味着在设计它的时候,大家对如何设计一个优秀、精巧的8位系统已经非常了解。这就是为什么你能得到如此简洁的设计。如果你看这里的PCB板,上面有一些标签。是的,不能再比这更简单了:一些RAM,一些VRAM,然后所有东西都连接到接口上。基本上就这些了。板上几乎没有其他芯片。为什么?因为它远远超前于时代,这是一个片上系统。它内置了一个图形处理设备。现在我们会称之为GPU,但当时它被称为PPU(像素处理单元)。

时钟频率是4,194,304 Hz,即4 MHz。这个数字看起来很特别,大家都知道为什么吗?这与PAL或NTSC制式无关吗?实际上,GameBoy没有区域锁。你可以在任何地方购买。我有一台GameBoy Light,带内置背光,非常棒,但只在日本发售。我的一个学生实习时给我带了一台。

这个时钟频率是2的幂次方。作为一个程序员,这让我很高兴。作为一个C++程序员,这也让我很高兴。它没有I/O端口指令。Intel 8080有那些烦人的I/O端口指令,然后你必须将它们映射到内部函数来调用之类的。正如你所见,GameBoy的芯片是Intel 8080和Zilog Z80的子集,并加入了一些自己的东西。他们从Intel 8080中去掉的主要就是那些端口操作。GameBoy是完全内存映射的,这使我们的生活轻松多了。

它拥有8080的寄存器组,以及Z80的一些额外位操作指令,还有一些新增的、旨在帮助图形处理的新指令。

这个片上系统超级简单。正因为如此,很容易理解发生了什么。这里有一张内存映射图,你可以看看。


本节课中,我们一起学习了选择现代C++构建最快GameBoy模拟器的原因。我们探讨了现代C++的适用性、编写模拟器对理解硬件的价值,以及GameBoy平台因其简洁性、丰富的资源和活跃社区而成为理想目标的诸多优势。下一节,我们将开始深入GameBoy的硬件架构细节。

044:C++20创新与跨平台设计原则 🚀

在本教程中,我们将学习如何利用C++20的新特性,设计一个高性能的跨平台架构。我们将从核心设计原则出发,通过一个具体的“四元数”类设计案例,逐步讲解如何实现代码的平台无关性、最小化编译依赖以及遵循开放-封闭原则。

概述

跨平台开发的核心挑战在于如何充分利用不同平台的特性,同时保持代码的简洁和可维护性。传统的解决方案常常依赖大量的预处理器宏和条件编译,导致代码臃肿且难以管理。C++20引入的新特性,如概念(Concepts)和模块(Modules),为我们提供了更优雅的解决方案。本节课将探讨一种基于编译器、最小化构建系统依赖和预处理器使用的系统化方法。

开放-封闭原则(OCP)的两种形态

上一节我们介绍了跨平台架构的目标。在深入技术细节之前,理解驱动本设计的核心原则至关重要,即开放-封闭原则

该原则由Bertrand Meyer于1988年首次提出,并由Robert C. Martin于1996年引入C++社区。其核心思想是:软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这意味着在添加新功能时,应通过扩展而非修改现有代码来实现,从而避免破坏现有客户端代码。

在C++的上下文中,我们可以将OCP分为两种形态:

  • 弱OCP:在添加新功能时,你确实需要修改之前的代码,但这种修改对客户端是透明的。现有接口和行为保持不变,客户端代码无需修改,但可能需要重新编译。
  • 强OCP:在添加新功能时,完全不修改任何已存在的代码。如果现有代码不使用新功能,它们甚至不需要重新编译。这是最理想的状态,能实现最佳的模块隔离性。

本教程所倡导的架构设计,旨在实现强OCP。当我们为架构添加一个新平台或新功能修订时,所有现有平台的代码都无需任何修改,也不会触发不必要的重新编译。

平台与功能的定义

理解了设计原则后,我们需要明确两个基础概念:平台和功能。

  • 平台:一个平台是一组特定功能的集合。它不仅仅指操作系统或硬件,而是指支持某一套特定指令集、API或系统特性的环境。例如,支持SSE指令集的x86 Linux环境可以视为一个平台,支持Neon指令集的ARM Android环境是另一个平台。
  • 功能:功能是一个抽象的功能单元,它代表了一项需要独立实现的能力。例如,“支持SIMD加速的四元数乘法”或“支持特定图形API的纹理渲染”都可以被视为一个功能。

基于这种定义,我们的跨平台架构就变成了:为同一个抽象功能,在不同的平台(功能集合)上提供不同的具体实现,并且这些实现彼此隔离。

系统化的头文件包含方法

要实现强OCP和清晰的平台隔离,组织代码文件是关键。以下是一种推荐的文件组织方式:

project/
├── include/
│   └── MyLib/          # 公共接口目录
│       ├── Feature/    # 功能接口目录
│       │   ├── Interface.hpp  # 功能的主接口(概念、通用声明)
│       │   └── Platform/      # 平台特定实现目录
│       │       ├── Default.hpp # 默认/通用实现
│       │       ├── PlatformA.hpp
│       │       └── PlatformB.hpp
│       └── ... (其他功能)
└── src/
    └── ... (实现文件)

这种结构的核心在于分离接口与实现,并将平台特定的实现放在独立的文件中。通过构建系统仅将当前目标平台对应的实现文件加入编译,可以天然实现代码隔离。

利用C++20概念构建层次结构

C++20的概念(Concepts)特性是实现强类型接口和编译时多态的利器。我们可以用它来定义功能接口,而不是传统的继承体系。

例如,我们可以为一个数学向量库定义概念层次:

// 概念定义:可加
template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};

// 概念定义:可标量乘法
template<typename T, typename Scalar>
concept Scalable = requires(T a, Scalar s) {
    { a * s } -> std::same_as<T>;
    { s * a } -> std::same_as<T>;
};

// 概念定义:线性代数类型
template<typename T, typename Scalar = float>
concept LinearAlgebraType = Addable<T> && Scalable<T, Scalar>;

然后,我们的四元数类只需要满足这些概念即可,无需继承自某个基类。不同平台的实现只要满足相同的概念,就能无缝替换。

四元数类设计实例

现在,让我们将以上所有原则应用到一个具体的Quaternion类设计中。

1. 定义接口(Interface.hpp)
首先,我们定义一个包含核心操作的概念和通用声明。

#pragma once
#include <concepts>

namespace MyLib::Math {
    // 四元数概念
    template<typename Q>
    concept QuaternionType = requires(Q q, Q p, float scalar) {
        { q * p } -> std::same_as<Q>; // 乘法
        { q.conjugate() } -> std::same_as<Q>; // 共轭
        { q.norm() } -> std::floating_point; // 范数
        // ... 其他必需操作
    };

    // 通用函数声明(可能使用默认实现)
    template <QuaternionType Q>
    Q normalize(const Q& q);

    template <QuaternionType Q>
    Q slerp(const Q& a, const Q& b, float t);
}

2. 提供平台特定实现(Platform/SSE.hpp, Platform/Neon.hpp等)
接着,在不同平台的文件中提供具体实现。每个文件独立,互不干扰。

// Platform/SSE.hpp
#pragma once
#include "../Interface.hpp"
#include <xmmintrin.h> // SSE头文件

namespace MyLib::Math::Platform::SSE {
    struct Quaternion {
        __m128 data; // 使用SSE寄存器存储 x, y, z, w
        // 实现 QuaternionType 要求的所有操作
        Quaternion operator*(const Quaternion& other) const;
        Quaternion conjugate() const;
        float norm() const;
        // ...
    };
    // 确保我们的类型满足概念
    static_assert(QuaternionType<Quaternion>);
}

// Platform/Neon.hpp 类似,但使用ARM Neon intrinsics

3. 在项目中选择平台
最后,在项目的顶级配置或通用头文件中,通过一个简单的预处理器宏(这是架构中唯一推荐使用的宏)来选择当前平台的具体实现。

// Config.hpp 或 由构建系统(如CMake)生成
#pragma once
#define MYLIB_TARGET_PLATFORM_SSE
// 或 #define MYLIB_TARGET_PLATFORM_NEON
// 或 #define MYLIB_TARGET_PLATFORM_DEFAULT

// 然后在一个统一的头文件中进行分发
// Quaternion.hpp
#pragma once
#include "Config.hpp"

#if defined(MYLIB_TARGET_PLATFORM_SSE)
    #include “Math/Quaternion/Platform/SSE.hpp”
    namespace MyLib::Math {
        using Quaternion = Platform::SSE::Quaternion;
    }
#elif defined(MYLIB_TARGET_PLATFORM_NEON)
    #include “Math/Quaternion/Platform/Neon.hpp”
    namespace MyLib::Math {
        using Quaternion = Platform::Neon::Quaternion;
    }
#else
    #include “Math/Quaternion/Platform/Default.hpp” // 一个纯C++的通用实现
    namespace MyLib::Math {
        using Quaternion = Platform::Default::Quaternion;
    }
#endif

客户端代码只需包含Quaternion.hpp并使用MyLib::Math::Quaternion,即可自动获得当前平台最优化的实现。当添加一个新平台(如AVX512)时,只需新增一个实现文件并在构建系统中为新平台定义宏,所有现有代码都无需改动。

总结

本节课我们一起学习了如何利用C++20构建高性能跨平台架构。我们首先明确了开放-封闭原则,特别是强OCP的目标,即通过扩展而非修改来增加功能。接着,我们将平台定义为特定功能的集合,并采用系统化的文件组织来隔离不同实现。通过C++20概念,我们定义了清晰的、编译时检查的接口契约。最后,通过一个四元数类的完整实例,展示了如何将接口、平台特定实现和客户端代码解耦。

这种架构的优势在于:最大化利用了各平台特性最小化了构建系统的复杂性预处理器宏的使用显著减少了冗余代码,并且在添加新平台或功能时,实现了对现有代码的零修改和零不必要的重新编译。这为开发高质量、易维护的跨平台C++库提供了一条清晰的路径。

045:使用 C++20 与契约概念提升程序

概述

在本节课中,我们将学习如何利用 C++20 的特性与“契约-概念-实现”模式,来改进和强化进程间通信程序的接口设计、安全性与易用性。


性能是 C++ 的主要支柱之一,也是我们编写代码的核心原因。因此,我确保标题足够吸引人,以引起你的注意。但为了本次讲解的目的,我希望你暂时忘记标题。

我注意到,在生活中与人沟通或获得新工作时,我总是需要签署各种合同。作为一个技术人员,在失眠的夜晚,我试图解决这个问题,并得出结论:我们所做的一切都是一种安排或契约。深入研究后,我发现所有这些契约的核心只是生成新信息或交换现有信息。因此,我认为 C++ 本身作为一种编程语言,就是一份赋予我们表达自我的契约。所以,与其采用通常的演讲者与听众的安排,我希望我们都能戴上“律师”的帽子,一起开始编写一些契约,以增强我们的进程间通信程序。


首先,让我们设定一些规则。IPC 代表进程间通信。“程序”指在受限环境中运行的软件,我称之为嵌入式设备。但对某些人来说,嵌入式设备可能是仅有 64MB 内存的微型设备。在我们的案例中,我们针对的是至少拥有 512MB 内存的嵌入式设备,这意味着它们能够运行完整的 Linux 内核并使用其提供的所有功能。CCI 代表契约、概念和实现,这是我们将用来增强进程内通信的模式。

我们试图用 IPC 解决什么问题?众所周知,图像被广泛应用。对图像的一项基本操作是提取其颜色通道。在计算机世界中,图像由三种主要颜色构成:红、绿、蓝。我们希望有一个服务,允许我们从图像中提取特定通道。

对于进程间通信,我们将使用一个名为 D-Bus 的设施。它是一个用 C 语言编写的抽象层,位于 Linux 套接字之上,引入了诸如总线服务、对象路径、接口和方法等抽象概念,使我们的编程更容易。为了解释这些含义,我将用一个简单的类比:总线可以看作所有网络通信;服务可以看作生活在单一父进程下的进程组;对象路径可以是你的可执行文件;接口是一个结构体或 C++ 类;方法名就是属于这个特定类的方法。

系统 D 的设计者为我们提供了一个接口来实现这一点,我们将使用这个低级契约来提取上传图像中的指定颜色。为此,首先我们需要定义一个名为 error 的结构体,并使用一个宏定义 fin。我们不知道背后发生了什么,但这就是接口的工作方式,这就是我们应用系统 D 提供的契约的方式。然后,我们需要声明一个 SD_bus_message 类型的指针,声明一个 SD_bus 指针来存储总线,声明一个 int32_t 常量来存储操作是否成功的结果,当方法从 D-Bus 执行后,我们将结果存储在变量 R 中。对于输入参数,我们将使用两个静态字符串:第一个是我们要执行操作的图像文件路径;第二个是我们希望保存提取通道后新图像的路径。使用 uint8_t 类型的 channel 参数,我们指定要提取的通道,例如数字 1 代表绿色通道。

为了调用方法,我们调用名为 SD_bus_call_method 的函数。如你所见,我们提供了大量输入参数:总线、三个常量字符串(指定方法名,这里是 extract_channel)、错误消息的引用、一个带有奇怪字母 OOY 的常量字符串(签名),以及我们的三个输入参数。

现在,我想请你们举手,认为这是一个出色的接口吗?你认为它易于使用、易于访问且不易出错吗?正如我所料,没有人举手。那么,相反,谁认为这不是一个好接口,可能导致灾难?所有人。所以答案很明显。

我认为通过应用 CCI 模式,我们可以使这个接口好得多。因为在这个接口中,我看到每个参数都有问题。让我们从 bus 开始:它是一个指针,在 C 语言中我们需要管理其生命周期。无论你多么有经验,当你退休或那天咖啡不够时,你可能会忘记释放它,这很正常。然后我们有三个常量字符串来指定服务名、对象路径和接口名,它们在指定方式上非常特殊:服务名使用点,对象路径使用斜杠。如果你犯错或不遵循约定,代码会编译,运行也正确,但当你将其刷入嵌入式设备并尝试使用时,会遇到运行时错误。所以,这三个参数存在一些需要我们处理的运行时错误。

我看到的最大问题是,对于输入参数(在我们的例子中是文件路径、保存路径和通道),你同时需要提供它们的签名。如果你没有写入 OOY(这三个输入参数的签名),代码会编译,你不会收到任何警告,但当你刷入设备并尝试使用代码时,会遇到错误消息。想象一下,你的嵌入式设备在汽车里,你编译代码,十分钟后上车,希望程序能工作,却遇到了运行时错误,这是巨大的时间浪费。

因此,我们将创建一个契约来指定数据类型的特征,然后创建一个概念来强制执行契约,最后创建一个受概念约束的实现。

我有一个普遍的问题:你们中有谁喜欢编写或阅读合同?我猜没有人喜欢翻阅冗长的合同并试图理解它们。我看到有些人举手了。别担心,我会尽量让这个合同简单易懂。我们将定义一些词汇,以便在讲解中理解我们实际在做什么。

契约只是一堆文件,在我们的案例中,将由结构体或 C++ 类表示。在合同中,有许多段落解释条件或签署合同后会发生什么。所以,我们的条件将是类型的成员或成员函数。它们将被模板化,对于类型,它们将接受我们应用契约的类型作为参数。除非另有说明,它们大多数将始终返回布尔类型,即满足条件为真,不满足为假。

对于总线对象,我想通过契约传达的是:我希望它在一个 unique_ptr 内部,这保证了其生命周期在创建和离开作用域时会得到妥善管理。我还想确保只有一个总线实例,因为如果你创建了两个总线实例,代码会正确编译,但在运行时它会崩溃,因为你不能有两个总线实例。我创建了一个简单的类型深度,称为 bus_inner_type,它是 unique_ptr 的包装。


总结

本节课中,我们一起探讨了传统进程间通信接口存在的问题,并引入了“契约-概念-实现”模式作为解决方案。我们定义了契约的基本构成,并开始为一个总线对象设计契约,旨在通过编译时检查来提升代码的安全性、可维护性和开发者体验。在接下来的章节中,我们将深入构建具体的契约、概念和实现。

046:欢迎与介绍

在本节课中,我们将学习CppCon 2024年ISO C++标准委员会小组讨论的开场部分。我们将了解讨论会的背景、参与讨论的委员会成员,以及他们各自当前最关注的C++语言发展方向。

欢迎各位。现在是晚上8点30分,我们即将开始讨论,并期待大家提出问题。

委员会成员们稍晚到场,但你们已经表现出浓厚的兴趣,我们将尽力不辜负这份期待。

每年在CppCon大会上,我们都会举办一场委员会炉边谈话小组讨论,邀请部分委员会成员参与。成员构成多样,有些是大家熟悉的面孔,有些则是新面孔。我们这里汇集了对核心语言和标准库都感兴趣的人士。有些人深度参与某个特定主题领域,有些则在他们所工作的标准部分领域是广博的专家。有些人从事委员会工作已有四分之一个世纪,有些人尽管使用C++已有四分之一个世纪且在该领域非常杰出,但这仅仅是他们参加的第二次会议。因此,我们这里有一个多元化的组合。我将首先快速介绍一下,以便大家认识他们。他们中的大多数是本次大会的演讲者或曾出席过会议,所以你们可能认识他们。但我还是将逐一介绍,并请每位成员简要说明一两个他们目前最感兴趣的、委员会正在为C++26或更远版本研究的话题。

Lisa Lipincott,你在数值计算方面做了大量出色的工作,并且在这里做了一个关于契约的演讲,因为你在上次标准会议上做了一个非常精彩的演讲,所以我们邀请你在这里做一个更新版本。你最感兴趣的一两件事是什么?
当然是契约。我的意思是,Daisy会提到另一个特性,那也是一个非常好的特性,但契约是……(现在我很感兴趣)。

Tamar Dalor,你是委员会主席之一,担任契约小组的副主席,并且已经在这个委员会工作多年了。你最感兴趣的一两件事是什么?(我开始看到一种模式了)
我最兴奋的是契约。我已经为此工作了一段时间。我认为我们非常接近将其完善到可以加入标准的程度。我对此感到非常兴奋。我想可能还有另一个特性也相当令人兴奋,但我打算……让下一个人来谈论那个。我确实没有让他们提前准备这些“预告”。

Daisy E. Holman,她是Ranges研究组和库演进小组的主席,已经活跃了相当长的时间。你最喜欢的一两个特性是什么?
反射,以及反射。如果我们能有第三个,那就是反射。这简直会改变我们在C++中所做的一切。而且它已经酝酿了太久。

Khalil Estell,我们期待听到更多关于你正在进行的异常处理工作。那么,你目前最关注的一两件事是什么?
既然我们谈论的是委员会内部正在讨论的内容,那必须是契约和反射。我知道这是重复的,但我对这两个话题都非常兴奋。我相信它们将极大地改变语言的使用方式,并能在未来带来许多真正强大的能力。

Deepak Majeti,你在标准库领域活跃了很长时间。我的意思是,甚至在20年前,你就拥有自己的标准库实现,为委员会提供了参考。现在你又实现了发送者-接收者和执行器,因为你似乎总有空闲时间并且非常擅长开发所有这些。那么,你最感兴趣的一两件事是什么?
正如你刚才提到的,执行器词汇的标准化。这绝对是我的首选。我不太关心其他契约或反射,尽管反射可能有助于实现一些发送者……所以反射是第二选择。

Andrei Alexandrescu,我昨天提到过,这实际上只是你参加的第二次委员会会议,是在三个月前的六月。你从事C++已经……我想大概18个月了?自从你学习C++以来。显然你正在推动一些事情。现在你加入了委员会,从事反射方面的工作。你目前最关注的一两件事是什么?
对我来说,就像有些人,你知道,他们结婚、离婚、再结婚几次。我最喜欢的前三个是Daisy。我接下来的三个是反射、反射、反射。另外,我想说,如果你坐得离中间近一点,体验会非常不同,所以我鼓励你过来。我在喜剧表演中看到过,体验会根据你是在后排不关心还是在这里提问而大不相同。而且你离得越近,在这些灯光下我们越能看清你。

Gabriel Dos Reis,Mr. Constexpr, Mr. Modules,在微软现在和之前参与了许多其他工作。你最感兴趣的、目前正在积极进行的一两件事是什么?
富有想象力地说,是契约。以及内存和类型安全。

Michael Wong,在C++领域有什么是你没做过的吗?为什么这么问?不,只是你涉猎如此广泛,分享着SG12(研究组12)如何发展成拥有自己子委员会的委员会,并且现在还在进行并行性、机器学习、人工智能等方面的工作。感谢你所做的一切。那么,你最感兴趣的一两件事是什么?
首先,感谢你给我这个机会。我想我喜欢做那些深入具体的工作,比如机器学习。我们正在做一个图(graph)提案,我非常兴奋。但我也做方向组那种三万英尺高空的宏观视角,我们试图提前思考哪些障碍会阻碍C++的发展。所以我们一直在讨论很多关于安全性和保障性的话题,显然,以及其他事情,以及如何让委员会工作更有效率。

很好,我们看到开始有人排队提问了,我们将接受这边的问题。我认出了那位穿夏威夷衬衫的男士,那不是我。不,这正在成为我的品牌,Herb。我也非常期待契约和反射。但冒着显得有点调皮的风险……为什么花了这么、这么长的时间?

这是一个极好的问题。谁想回答?Lisa,你举手了。
我周四午餐后的演讲就是关于为什么契约花了这么长时间。我不想剧透,但我要说这很大程度上是视角问题。

Michael,我能从另一个三万英尺的高空视角再谈谈吗?我们注意到,特性,尤其是大型特性,通常需要很长时间。有些可能需要六年到十年。这是我们希望设法解决的问题。这背后有一个重要原因,当一个特性很大时,几乎我们过去所有的大型特性在推进过程中总会遇到一些情况,比如出现一个竞争性的提案,有时甚至在最后一刻。这不是他们的错。这是现实世界工作的现实情况。你可能无法预见到有人说,这个明天就要投票表决了。我可以在这里说出我们讨论过的每一个大型特性。情况就是这样。我不知道我们是否有办法让它变得更好,或者即使我们能做到,我们可能也不想。我很想听听其他人的看法,但我们注意到这个趋势已经很久了。我想说,目前最长的特性可能是网络,我想那已经接近15年了。

接下来请Daisy谈谈。
我想在反射方面,既然我显然是“反射女孩”,我认为很大一部分原因在于C++有如此多的实现,而且它们各不相同。不像其他语言有一个主导实现,你可以直接实现特性,向前演进,然后其他实现要么跟进要么过时,我们必须在所支持的语言实现的交集上工作,目前基本上是四个主要实现。这些前端实现都有不同的语法树处理方式。

047:混合断言、日志、单元测试与代码覆盖率

在本节课中,我们将学习如何构建一个名为“零错误”的框架,该框架旨在通过统一断言、日志记录、单元测试和代码覆盖率分析,来帮助开发者构建更安全的现代C++应用程序。我们将从框架的起源故事开始,逐步探讨其设计动机和核心概念。

框架的起源故事

两年前,我还是一名博士生。作为一名研究者,那是一段美好的时光。我可以尝试最新的技术,真正做出一些能影响他人的成果。看到论文产生影响,是工作中最令人愉快的部分。

然而,事情总有两面性。对我来说,也有一些痛苦的时刻。举个例子,想象一下你是一名博士生,这是你毕业前的最后一个学期,再过两周你就要提交论文了。但在收集数据时,有一个实验的日志数据显示了错误代码。我别无选择,必须在截止日期前找出问题并修复它。

调试的难点在于,调试器有时无法正常工作,尤其是在处理复杂的数据结构时。节点之间有太多链接需要检查,信息量巨大。因此,我严重依赖打印这些信息的日志来调试。

日志打印的挑战

让我们看看我需要打印什么。有一些自定义的类结构数据,比如容器类,它们是最常见的。还有一些数据存储在通过字符串索引的映射中。此外,还有智能指针,以及像LLVM这样的第三方库,这是一个我使用的著名编译器基础设施库。

我需要打印许多不同类型的对象。当时,我使用日志库和智能断言库,以便输出既能用于日志也能用于断言。因此,我必须编写一长串打印函数,其中大部分是针对我的自定义数据结构或容器的。

于是我开始思考:我能否创建一个基于模板的库,专注于打印这些类型,并且可以在不同项目中复用?

流操作符的问题

然而,流操作符的实现存在许多问题。首先,存在命名空间污染。如果你编写这个函数,必须在全局作用域中定义它,有时很难控制何时使用这些函数。

其次,用模板实现也很困难。此外,它缺乏可扩展性。如果库定义了一个模板,用户就失去了定义自己的自定义模板来覆盖它的能力。

你无法轻松地为不同场景定制打印相同类型的行为。例如,如果你想打印额外的换行符,很难配置,因为这个函数不是静态的,也没有额外的参数可以传入。

因此我想到,也许我不应该使用流操作符。另一种方法是让日志使用一个带有格式化标签接口的字符串化函数,我们可以将其实现为一个有状态的函数。

统一打印接口的需求

我们注意到,无论是日志记录还是断言,甚至单元测试中的检查,它们都需要漂亮的打印功能。无论是在用户代码中还是在单元测试中,当测试失败时,如果能打印出错误信息,都会非常有帮助。

在这个例子中,断言会打印消息“a 不应为 0”以及输入值。我认为日志和断言宏正是我所寻找的。

因此我开始思考,也许我需要的是一个框架,让断言、单元测试和日志都使用相同的打印接口,而不仅仅是一个简单的字符串化库。

回到调试问题

当我处理紧急任务时,我有个坏习惯,就是容易跑题。你可能会问,那么你找到问题了吗?是的,实际上问题出在一个单元测试用例里。我以为已经测试过了,并且没有检测到错误。

那么问题来了:为什么测试通过了?以下是一个示例,展示了发生的情况。

假设我们有一个需要测试的“pass”函数。函数内部有一个简单的缓存系统,它会缓存结果以加速下次遇到相同输入时的处理。如果缓存未命中,我们将运行执行实际工作的“pass”函数。否则,我们将从缓存系统返回克隆的表达式。

这里有一个bug。我的意思是,每个人都可能犯这种错误。如果你在类中编写了一个克隆函数,一个月后,当你想快速修改一些东西时,你添加了一个成员变量字段,却忘了修改拷贝函数,那么就会丢失部分信息。这是我们可能遇到的常见错误。

然而,为什么测试通过了?这就是发生的情况。看明白了吗?是的,这里有一个空格,所以缓存没有生效,因此 E1 和 E2 都是从“pass”内部函数新创建的。当然,这个bug没有被检测到,因为你没有运行那段代码。

我应该单独测试克隆函数,但我当时想同时测试缓存系统,结果犯了个错误。然而,我的观点是,仅通过查看输出,无法检查缓存系统是否正常工作。

总结

在本节课中,我们一起探讨了构建一个统一断言、日志和单元测试打印接口的框架的动机。我们了解了传统流操作符的局限性,以及创建一个有状态、可配置的字符串化函数的必要性。我们还通过一个具体的调试案例,看到了单元测试覆盖不全可能导致的隐蔽bug。在接下来的章节中,我们将深入探讨这个“零错误”框架的具体设计和实现。

048:构建安全可靠的手术机器人系统概述 🏥

在本节课中,我们将学习如何运用C++构建安全可靠的手术机器人系统。我们将探讨医疗设备领域对安全性的特殊要求、常见的软件故障原因,以及相关的行业标准和编码实践。

为什么关注安全与可靠性?🛡️

上一节我们介绍了课程的整体目标,本节中我们来看看为什么在医疗设备,尤其是手术机器人领域,安全与可靠性至关重要。

现场会议让我意识到线下交流的体验依然无可比拟。再次来到这里感觉非常好。

非常感谢各位的到来。我叫Milad。我在强生医疗科技公司从事机器人与软件工作。今年是我第一次参加CppCon,也是我第一次发表演讲。提交演讲摘要时,只有我一人,但我的朋友兼同事Alex Drew从零到百分百地为整个演讲提供了反馈。我邀请他与我共同演讲,他欣然接受了。

大家好,我是Alex Drew。我在强生医疗科技公司担任软件工程师,与Milad密切合作开发手术机器人。我们团队还有几位成员也在现场,希望大家喜欢这次演讲。

回到演讲标题。这个标题内容很丰富。构建一个大量使用C++的机器人辅助手术平台涉及许多组件。今天,我们主要从高层次概念上进行探讨。对于长期从事医疗设备领域的各位,引言部分可能是一次复习。对于没有医疗设备背景的各位,希望这次分享能有所裨益。

免责声明与演讲大纲 📜

在深入技术细节之前,我们需要明确一些法律事项。

以下是外部参与免责声明:所有内容仅代表我和Alex的个人观点,并不代表强生家族中任何公司的立场、观点或官方立场。

本次演讲希望传达的核心要点如下:
首先,我们将讨论为何要关注安全与可靠性。在这个会议上,过去几年大家可能已经听过很多相关讨论,但我仍会简要重申。
接着,我们将基于美国食品药品监督管理局的一些数据库,探讨医疗设备故障分析以及导致医疗设备警告和故障的原因。
然后,我们将用大约五到七分钟简要介绍,为了将软件作为医疗设备产品发布,需要遵循哪些监管文件、标准和报告。
之后,我们将具体讨论C++中的安全性,即除了指南和标准之外,我们还能做些什么来使C++的使用更安全,尤其是在医疗设备中。
紧接着,我们将讨论一些编码实践,并展示在安全关键路径(特别是医疗设备)中使用C++的代码示例。
最后是总结与问答环节。

我们准备了大约50分钟的内容。最后,可以回答大家的任何问题和反馈。

C++中的安全挑战与行业报告 🚨

现在,让我们谈谈C++中的安全与可靠性。我认为大家在过去两年里已经看到了许多来自美国国家安全局等的相关报告。

这些报告涉及防范软件内存安全问题以及使用内存安全语言。所有报告的共通点是:远离像C/C++这样的内存不安全语言,并使用内存安全语言重写或编写新软件。尽管报告承认可以通过大量软件加固措施使C/C++的使用更安全,但由于无法保证这种安全性,因此建议远离这些语言。

这些报告大多引用了MITRE的通用缺陷枚举或通用漏洞披露数据库。例如,2023年的前25大常见漏洞列表中,许多实际上是语言无关的,也可能发生在内存安全语言中。我在此列出的高亮项目是C或C++特有的。对于我们所有编写过C++的人来说,可能都遇到过许多这类问题,例如缓冲区溢出、条件竞争、释放后使用等。其中大部分基于微软和谷歌Chrome关于内存安全问题的报告。例如,根据谷歌Chrome的安全漏洞报告,近30%纯粹源于释放后使用。

大约从两年前开始,C++标准委员会知名成员及社区就一直在积极探讨从语言层面如何使C++更安全。有些讨论涉及软件加固,有些则从哲学角度探讨“我们能否拯救C++”。但在我们的演讲中,思路是暂时抛开这些,从医疗设备实例出发,讨论通过使用C++,我们实际能做些什么来保证一定程度的安全性。

医疗设备故障分析 📊

接下来,我们转换话题,基于一些数据来讨论医疗设备故障分析,以及实际导致医疗设备故障和召回的原因。

2013年的一篇论文研究了FDA从2006年到2011年的数据库。他们分析了所有医疗设备故障和召回事件,其中一些可能导致伤害、严重故障,甚至在个别案例中导致死亡。在不考虑安全性的前提下,结论是64%的软件相关医疗设备故障实际上是由软件引起的。

另一个来自Stericycle专家解决方案的数据库,涵盖了国防、汽车和医疗设备等多个行业的召回指数数据。针对医疗设备的数据(2016年第二季度至2018年第二季度)显示,软件连续第九个季度成为医疗设备故障和召回的首要原因。每个季度都有数以百万计的医疗设备故障和召回事件。

考虑到医疗设备市场软件日益复杂,对更复杂软件的需求很大,而软件越复杂,问题就越多,因为管理难度极大。此外,我们还面临新的威胁——网络安全。因此,考虑到网络安全以及软件本身的极端复杂性,软件现在是,并且未来仍将是许多问题的核心,甚至是首要原因。当我们谈论软件时,不一定指用户界面部分,而是指软件设计、实现、语言使用等方面。

医疗设备软件标准与规范 📑

现在,让我们转换思路,谈谈医疗设备领域已有的一些标准和文件,它们至少为开发安全的医疗设备提供了一定程度的保证。

我相信大家可能听说过Therac-25,它被称为软件史上最严重的漏洞。Therac-25是一台放射治疗机,由于其软件存在大量问题(核心是80年代的一个竞争条件),导致系统故障,使患者受到过量辐射,造成数十人死亡和许多人受伤。

自此以后,政府、监管机构、医疗设备社区共同努力,着手解决医疗设备中的软件问题。我们拥有了大量不同的标准、报告和文件来确保软件安全。

从质量管理体系标准,到风险管理、可用性、健康软件安全,再到敏捷方法在医疗设备中的应用、人工智能和机器学习指南,特别是在网络安全方面。他们在2023年发布了一份关于医疗设备网络安全的非常全面的报告,并在2024年进行了修订(草案)。鉴于网络安全的复杂性和攻击的增加,他们不断更新这些标准以跟上形势。

在医疗设备中,最重要的文件之一是IEC 62304。这个标准类似于汽车行业的功能安全标准。对于那些在汽车行业工作过的人来说,功能安全标准与此非常相似。

基本上,这份文件不告诉你具体怎么做,而是告诉你从开发、设计到制造、分销乃至上市后发布整个过程中需要做什么。它首先对医疗设备进行分类:

  • A类:软件故障不会造成任何伤害。
  • B类:软件故障可能导致伤害,但伤害轻微。
  • C类:这是风险最高的类别。软件故障可能导致严重伤害,甚至死亡。

本节课中我们一起学习了构建安全可靠的手术机器人系统的背景与挑战。我们了解了医疗设备领域对安全性的极高要求,回顾了由软件问题导致设备故障的历史案例和数据,并初步认识了指导医疗设备软件开发的核心标准IEC 62304及其风险分类。下一节,我们将深入探讨如何在C++编码实践中具体落实这些安全要求。

posted @ 2026-03-29 09:12  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报