CppCon-2025-笔记-全-
CppCon 2025 笔记(全)
001:线程与协程——为何C++拥有两种并发模型



在本节课中,我们将要学习C++中两种主要的并发模型:线程与协程。我们将探讨它们各自的行为、适用场景以及如何根据应用程序的工作负载类型来选择合适的模型。通过理解其底层原理,你将能够更好地设计高并发、高性能的应用程序。
概述:并发设计的挑战
在设计高吞吐量、客户端驱动的应用程序时,如何有效地处理大量工作负载常常成为一个难题。理解并发设计的基础,能让我们更好地利用这些工具。
我们将以在白板前讨论的方式展开,侧重于图解和概念,而非大量代码。虽然这不是一份完整的参考指南,但会聚焦于线程和协程的行为,为你提供在实际中遇到这些问题时深入挖掘的起点。
线程:最初的尝试与局限
当我们知道需要处理大量工作时,首先想到的通常是使用线程。这是最熟悉的方法。
我们可以为每个消息创建一个线程。随着线程数量的增加,吞吐量会随之提升。这看起来很简单,任务完成,皆大欢喜。
但作为工程师,我们需要考虑扩展性。我们需要进行压力测试,看看极限在哪里。因此,我们继续增加线程数量,观察会发生什么。
线程的瓶颈:过载与上下文切换
当线程数量持续增加时,吞吐量会达到一个拐点。超过这个点后,性能不仅停滞,反而会下降。即使增加数千个线程,性能也可能与只使用少量线程时相差无几。
我们需要思考为什么会这样。让我们从应用程序的运行环境开始。
我们的应用程序运行在用户空间。这是一个沙盒环境,与直接硬件访问隔离,我们的代码可以在这里执行,但不能直接访问文件、套接字或物理内存。当代码需要访问这些资源时,必须通过内核。
当我们创建线程时,实际上是在向操作系统发出请求。操作系统会为我们创建一个由内核管理的OS线程。这些线程需要在硬件(如CPU)上运行。实际上,我们拥有的线程数总是会超过可用的硬件CPU或硬件线程数量。这就是为什么内核需要一个调度器。调度器负责将这些线程调度到实际的硬件上运行。
调度器使得看起来很多任务可以同时运行。然而,当工作量非常大时,这种机制就会失效。我们将调度的责任交给了调度器,但我们也需要知道何时应该自己承担起这份责任。
线程的状态与上下文切换
在任何时间点,线程可能处于以下几种状态:
- 运行中:正在CPU上执行。
- 可运行:在CPU的队列中等待被调度执行。
- 阻塞:在某个等待队列中,等待资源可用。
调度器负责在线程的这些状态之间移动,决定谁在何时运行以及运行多久。
一个经典的例子是,我们在应用程序中先后创建两个线程A和B。从我们的角度看,创建是顺序的。但一旦创建,调度责任就交给了内核调度器,由它决定哪个线程先运行,以及执行是否应该交错进行。
这种执行的交错被称为上下文切换。它有两种形式:
- 抢占式:由内核基于时间片等策略为我们做出决策。
- 自愿式:由我们的应用程序决定阻塞某个资源而驱动。
抢占式上下文切换
像Linux的CFS(完全公平调度器)这样的调度器,旨在保证基本的公平性,意味着每个线程都会在CPU上获得一定的运行时间。当一个线程的时间片用完,调度器会执行上下文切换,保存当前线程的状态(如寄存器),并加载下一个线程的状态。
这就是并发——工作的交错执行。我们启动一个线程,在它完成之前切换到另一个线程,反之亦然。线程被不断地换入换出。
当线程数量激增时,如果每个线程都需要在CPU上运行一段时间,并且为了保证公平性,每个线程都要获得一点时间,那么线程越多,上下文切换就越频繁。每个线程都试图获得自己的CPU时间片。
阻塞式I/O与上下文切换
想象我们有一个read函数,它从文件描述符读取数据。当一个线程调用这样的函数时,它会阻塞,直到数据可用。
调度器会将该线程标记为阻塞。它会被移出CPU,放入该资源的专用等待队列。与此同时,CPU上可以运行其他完全不同的任务。最终,当数据准备就绪时,内核会唤醒该线程,将其标记为可运行,并添加回队列,等待某个CPU再次调度它。
如果数据在第一次尝试时就立即可用,那么线程会立即返回,不会阻塞。
问题的核心:过载
操作系统只能处理有限数量的线程,超过这个限制,上下文切换的开销就会开始占主导地位。为短暂的工作创建和销毁线程会浪费大量时间在上下文切换上,而不是实际工作。
这就是过载。我们试图用太少的资源做太多的事情。
线程池:限制并发以控制过载
为了防止过载,我们可以限制创建的线程数量,使其不超过性能拐点。这就是线程池的作用。
当许多线程同时变为可运行状态,内核需要决定谁在何处运行时,线程会开始竞争CPU时间。我们可以通过限制任何时间点的线程数量来缓解这个问题。通常线程池大小设置为逻辑CPU核心数,但也可以更多。
我们将工作项推入队列,线程池中的工作线程会从队列中取出工作并在核心上执行。这样,我们可以将应用程序中想要做的所有工作(或许多任务)复用到数量更少的OS线程上。
这样做意味着我们限制了任何时间点可运行线程的数量,从而可以防止过载。我们从数百个线程竞争,变成了只有N个线程忙碌,后面跟着一个任务队列。
线程池的代价:并发性受限
当然,我们仍然有相同数量的工作需要完成,但现在只有少数线程来处理。因此,我们无法达到之前使用大量线程时的吞吐量水平。我们通过复用线程分摊了线程创建的成本,并限制了线程数量以控制资源使用,但这也意味着在任何时间点只能有固定数量(N)的任务在进行中。
我们不仅限制了运行在固定核心上的固定线程集,而且不再获得相同程度的并发性。我们在这里限制了并发性。
之前,我们有大量的执行交错,调度器负责确保每个任务获得公平的运行时间。线程非常适合提供并行性(最多到核心数),并且由于调度器会抢占式地在硬件线程上切换任务,我们还能获得一些并发性。
问题在于调度本身会拖慢速度,而线程池帮助我们保持在临界点以下。但最好的情况是,根据我们设置的线程池大小,我们只能达到吞吐量峰值并防止过载;最坏的情况是,我们限制了实际可以完成的工作量和并发性。
如果我们的问题从根本上需要扩展到超过线程池大小的拐点,我们就失去了内核调度器通过抢占为我们带来的优势——那种让我们可以同时处理更多任务的能力。在线程池中,我们只有少数任务在进行,其他所有任务都在后面的队列中等待。
并行与并发的定义
让我们明确一下定义:
- 并行:多个任务在同一时刻同时运行。这通常与硬件相关,例如在不同核心上同时运行。
- 并发:多个任务在同一时间段内都在进展中,但不一定在同一瞬间运行。这是通过交错执行(时间片)实现的。
我们通过线程池解决了过载问题,但现在我们面临一个并发性问题。
事件循环:将调度责任移回用户空间
到目前为止,我们一直将调度责任委托给内核调度器,这通常足够好,能让我们走得很远。但如果想要处理更复杂的用例和更大的吞吐量,我们需要看看如何收回更多的责任,将其引入我们的应用程序,让应用程序根据需求进行调度。
在线程池中,我们不知道任务何时准备就绪,必须等待任务完成才能处理下一条消息。那么,我们还能尝试什么方法来解决这个问题呢?
事件循环是一个很好的起点。这是尝试将调度从内核提升到用户空间、引入我们应用程序的开始。
与操作系统管理数千个阻塞线程或使用线程池不同,我们可以在应用程序中,在单个线程上运行一个循环,来多任务处理许多任务。你可能在需要廉价处理大量连接的地方见过这种模式,比如Node.js、Nginx等。
事件循环就是一个不断等待事件发生,然后将事件分派给相应处理器的单一循环。处理完成后,它又返回等待状态。其目标是在没有线程为我们进行调度的情况下做更多工作。我们希望自己承担更多责任,因为如果我们自己做可能更廉价,而不是依赖调度器。
事件循环的工作原理
我们最终在事件循环中有一个就绪任务队列。一段简化的伪代码如下:
while (true) {
// 等待事件(例如,通过 epoll, kqueue 等系统调用)
auto events = wait_for_events();
for (auto& event : events) {
// 分派事件到对应的处理器(回调函数)
dispatch_event_handler(event);
}
}
我们有一个队列,一个线程上的函数只是从队列前端弹出任务并执行。这些都是小的、非阻塞的工作,没有等待。
使用事件循环,我们试图建立一种反馈机制。在线程池中,我们开始失去并发性,无法让更多任务同时进行。我们不想把任务放到线程上,除非它确实准备好做一些工作。我们需要某种方式向事件循环表明我们的任务已就绪,可以立即继续运行,有实际工作要做。
因此,我们不再阻塞,而是可以请求操作系统:“请告诉我这个资源何时可以使用。”就绪意味着操作系统保证,如果我们尝试从套接字读取等操作,它不会阻塞,数据已经在等待了。
事件循环就是一个不断询问操作系统“现在什么就绪了?”的线程,然后分派相应的回调函数给处理器,接着又回去等待下一个事件。
事件循环的权衡
非阻塞I/O避免了空闲阻塞,但我们现在被迫用回调和处理器来表达我们的程序。我们不再编写直线逻辑的代码了。我们有了回调,这些是事件循环稍后会调用的东西。
这就是我们承担调度责任所带来的权衡。过去我们依赖内核足够智能来识别我们在做什么并让出线程,但现在这必须由我们自己来完成。这就是为什么确保我们调用的是非阻塞操作如此重要。
这正是我们在线程池中遇到的问题:它移除了智能调度,我们不再有抢占。因此,我们仍然需要某种方式向事件循环发信号,表明任务已就绪,可以调度,因为有实际工作可做。
这就是协程的用武之地。
协程:编写看似阻塞的非阻塞代码
协程让我们能够编写看起来像阻塞的代码,而底层实际上是非阻塞的。
那么,协程究竟是什么?典型的定义是:一个可以挂起自身,并在之后恢复执行的函数。
在C++中,我们可以通过co_await某个东西来实现这一点。例如,co_await read()会挂起,直到底层的read操作准备好数据。当它准备好时,我们可以恢复并从中断处继续执行。
协程的底层机制:栈与帧
让我们回顾一下应用程序的运行环境图。我们已经看到抢占如何降低性能,并且任何时候从应用程序跨越边界进入内核,都是一次昂贵的系统调用。因此,系统调用越少越好。我们希望尽可能在用户空间表达一切。
协程看起来有点像函数,让我们从如何调用常规函数开始。当我们调用一个函数时,会获得一个栈帧,其中包含该函数的参数、局部变量、寄存器快照和一个返回地址指针。其生命周期与栈帧本身绑定。一旦函数返回,帧就被弹出栈。
但C++20协程是无栈的。没有像普通函数那样的独立专用栈。这意味着在第一次调用时,我们会获得一个协程帧。这是一个堆分配的对象,用于保存局部变量、参数,最重要的是,保存我们挂起时将要恢复执行的挂起点。
相应地,在调用处我们会得到一个协程句柄,它只是一个指向该协程帧的指针。一旦我们挂起,协程帧会继续存在,即使协程处于挂起状态。这就是任务、承诺类型、可等待对象等概念发挥作用的地方。
协程帧本身主要保存一个值,这个值告诉我们恢复协程时应该从哪里继续执行。这意味着,当我们挂起、从一个协程切换到另一个或别处时,我们不需要保存CPU寄存器、内核栈或处理像之前那样的线程切换。我们只是存储当前的指令位置,将控制权返回给调用者。
恢复同样廉价,我们只是跳回帧中,其开销与调用函数相当,远比处理线程和上下文切换要便宜得多。而且何时运行我们的协程也完全由我们决定,不需要等待调度器来决定。

协程与事件循环的集成
为了与我们之前的内容进行类比,内核中的调度器负责将我们的线程在运行、阻塞、可运行等状态间移动。对于协程,我们在自己的应用程序中做类似的事情:从运行协程到挂起它,然后将其标记为可运行,在某个时刻将其添加回队列。
回到我们的事件循环示例,在实际调用代码中,它可能看起来像这样。事件循环在main中运行,我们发布一些工作,最终进入其内部的队列。我们也可以简单地调度协程。
如果我们从main开始,现在有东西在调用我们的协程函数,并且它持有对循环的引用。在我们的协程函数内部,我们将co_await某个东西,它是一个可等待对象。这会挂起当前协程。
与此同时,事件循环会继续运行。我们有一个可等待对象,它会告诉我们当协程准备恢复时该做什么。在这个例子中,因为它持有对循环的引用,这表明当它准备恢复时,会被放回事件循环的队列中。

并非所有协程都需要以这种方式表达,这只是调度机制的一个示例。在实践中,像cppcoro、folly、asio这样的库正在为我们做这些事情,它们有自己的可等待类型和调度器。它们只是将调度从内核提升到我们应用程序的示例。

协程的适用场景:I/O密集型与CPU密集型工作
我们已经看到,为了让事件循环成功运行,我们需要非阻塞的API调用,我们分派一些工作并最终获得结果。这正是我们在协程中需要的相同执行模型。
从调度器转移到我们应用程序的过程中,我们失去的一个方面是公平性。以前,调度器会通过上下文切换将作业换出CPU,以确保每个线程都有运行时间。现在我们在自己的应用程序中进行调度,没有任何东西为我们做这件事。我们必须确保所有参与者或任务都表现良好,不会阻塞事件循环。没有抢占,没有什么能阻止它们持续运行。
因此,我们试图执行的工作类型变得至关重要。如果某个任务执行时间很长且不主动让出,它就会阻塞所有其他工作。那个阻塞我们协程的调用,如果是CPU密集型的且运行时间很长,就会阻塞任务。
我们通常可以将工作类型分为两类:I/O密集型和CPU密集型。
I/O密集型工作
对于I/O密集型工作,瓶颈在于等待CPU外部的数据。如果应用程序串行执行所有操作,那么累积的等待时间会很长。在处理一条消息时,应用程序只是空闲等待,而其他消息则卡在队列中等待运行。
使用非阻塞I/O,我们思考如何更好地利用等待时间,这正是协程和非阻塞操作的完美结合。CPU本身并没有做太多工作,它只是停滞。例如,从套接字读取、等待数据库查询完成或通过网络调用远程API——任何涉及大量等待的场景。
协程在这里表现出色,因为它们可以co_await I/O操作,并立即、廉价地挂起,直到数据就绪,而不会浪费CPU周期。
性能对比:线程 vs. 协程
我们可以回到之前的图表进行比较。X轴是逻辑并发量,Y轴本质上是吞吐量。
线程有拐点,并且性能相对较早开始下降。而协程的性能则远超线程。但这里有一个非常有趣的注意事项:我们提到线程在某个点之前工作良好,之后开销变得过大,导致CPU过载。
观察逻辑任务数量足够少的情况,线程和协程之间的性能实际上非常相似,吞吐量以相同的速率增加。X轴上没有具体数值,因为拐点值取决于你的机器和同时运行的其他任务,通常会在数百甚至数千的范围内。在某个时刻,会有一个从线程最佳到协程最佳的切换点。
这是因为我们将内核所做的所有昂贵操作,现在都由我们自己来做了。这意味着协程在某个时刻也会有一个拐点,只是这个拐点非常遥远,因为在协程中进行上下文切换的成本非常低。
需要指出的是,这个图表中的协程只是单线程事件循环。只有一个线程。但如果我们尝试在没有协程的情况下进行CPU密集型工作,线程和协程之间没有太大区别,我们不会获得太多性能提升,在某些情况下,性能实际上可能更差。
CPU密集型工作
对于CPU密集型工作,我们仍然需要线程。线程池的瓶颈将涉及计算,我们需要线程在CPU上获得专用时间来持续处理数据和运行。例如,处理大型内存数据集或压缩大文件。
在实践中,应用程序可能两者兼有。我们可能有协程执行一些I/O操作,然后在某个时刻将结果交给线程池处理。因此,我们最终可能得到这样的模式:在I/O上co_await,然后获取结果并将其调度到CPU上执行。当从CPU获得结果后,我们可以再次回到I/O操作,以这种方式将任务链接起来。

这也是为什么我想提一下C++26的std::execution。它允许我们在一个管道中组合I/O密集型工作和CPU密集型步骤,明确指定每部分工作应该在哪里执行。因为协程本身不知道在哪里恢复,std::execution让我们能够指定:“当我准备好时,请在正确的资源上恢复我。”这样我们就可以避免意外地在事件循环线程上执行CPU工作,或者仅仅为了等待而创建线程。
总结
本节课中我们一起学习了C++中线程与协程两种并发模型的核心区别与适用场景。
-
线程:
- 适用场景:非常适合CPU密集型工作。操作系统决定哪个线程在哪个CPU上运行。
- 优势:是实现并行性的必要条件,我们可以获得最多到核心数的并行性,之后调度器通过上下文切换引入一些并发性。
- 劣势:扩展性有限,开销昂贵。当线程数量过多时,上下文切换的开销会主导性能,导致过载。
-
协程:
- 核心机制:通过将调度责任带回用户空间,避免了昂贵的系统调用和线程上下文切换。协程帧在堆上分配,挂起和恢复的成本极低,类似于函数调用。
- 优势:为I/O密集型工作提供了极高的并发性和可扩展性。允许编写看似顺序阻塞、实则非阻塞的代码,保持了代码的直叙可读性。
- 关键要求:所有操作都必须是协作式的。我们必须确保任务主动让出(yield),不能长时间阻塞执行线程,因为没有了内核的抢占式调度。这要求开发者有良好的设计纪律。
- 结合使用:现代应用程序通常是混合型的。可以使用协程处理高并发的I/O,然后将计算密集型部分交给线程池处理。C++26的
std::execution等设施有助于更优雅地组合这两种模型。



最终,选择线程还是协程,取决于应用程序工作负载的形态:是I/O密集型还是CPU密集型,以及对并发规模和性能的具体要求。理解这些基础原理,将帮助你为手头的问题选择最合适的并发工具。
002:即将到来的低延迟、并发与并行功能



在本节课中,我们将要学习 C++ 标准中即将到来的三个重要特性:标准 SIMD 库、并发队列以及指针生命周期端点。这些特性旨在提升 C++ 在低延迟、高并发和高性能计算场景下的能力。
SIMD:P02.1:释放 CPU 的并行潜力
上一节我们介绍了本课程的整体内容,本节中我们来看看第一个特性:标准 SIMD 库。
多年来,CPU 一直拥有强大的并行处理能力,但以标准、可移植的方式访问这些能力一直很困难。随着 C++26 的到来,这种情况即将改变。今天,我们将探讨 C++26 的 std::simd 如何让我们摆脱平台相关的 #ifdef 游戏。
SIMD 的核心概念很简单,就是元素级操作。你创建一个 simd 对象,它保存多个值。当你应用一个操作符(如 + 或 *)时,该操作会并行地作用于每个对应的元素。
以下是核心类型:
std::simd<T>:一个包含T类型N个元素的向量。std::simd_mask<T>:一个包含N个布尔值的向量,通常来自比较操作。
例如,以下代码将四个价格同时乘以税率:
std::simd<float> prices = {10.0f, 20.0f, 30.0f, 40.0f};
float tax_rate = 1.08f;
std::simd<float> taxed_prices = prices * tax_rate; // 所有元素同时计算
比较操作也是元素级的,它们不返回单个布尔值,而是返回一个 simd_mask,用于条件逻辑。
处理不同数据类型
处理不同数据类型很常见,最重要的规则是保持向量宽度(通道数)一致。实现这一点的关键工具是 rebind_simd。它允许你创建一个与现有向量具有相同通道数但元素类型不同的新向量类型。
以下是创建 SIMD 类型的几种方式:
native_simd<T>:使用编译器为类型T推荐的本地宽度。simd<T, N>:指定固定宽度N。rebind_simd<U, V>:将向量V的元素类型改为U,但保持相同的宽度。
无分支控制流
在 SIMD 代码中,传统的 if 语句会强制所有并行通道走单一路径,破坏并行性。解决方案是使用掩码进行无分支计算。
模式如下:
- 通过比较创建掩码。
- 使用
simd_select函数。它是 SIMD 中等价于三元运算符? :的函数。它接收掩码和两个值,对于每个通道,如果掩码为真,则从第一个值中选择,否则从第二个值中选择。整个过程没有任何代码分支。
auto mask = (prices > 25.0f);
auto discounted_prices = std::simd_select(mask, prices * 0.9f, prices);
归约操作
在并行工作完成后,通常需要将结果聚合成单个标量值,这称为归约或水平操作。std::reduce 是进行归约的主要工具。
一个经典例子是点积计算:首先对两个向量进行元素级乘法(垂直操作),然后对结果调用 reduce 将所有乘积求和为一个标量(水平操作)。
库还提供了其他常见的归约操作,如 reduce_min、reduce_max,以及用于分析掩码的辅助函数,如 any_of。
内存加载与存储
数据通常始于标准容器(如 vector 或 span)。典型的 SIMD 工作流是以等于 SIMD 宽度的块来循环处理数据。库提供了 load 和 store 函数在内存和 simd 对象之间移动数据。
部分加载(partial_load)很重要,因为它能安全处理数组末尾数据不足一个完整向量宽度的情况。
数学函数重载
std::simd 的一个杀手级特性是,你不需要特殊的 SIMD 版本数学函数。整个 C 数学库(如 cos、sqrt)都已重载,可以直接用于 simd 类型。这意味着你可以直接将复杂的数学公式转化为可读的高性能 C++ 代码。


性能与注意事项
基准测试表明,使用相同的代码,在不同领域和硬件平台上都能获得显著且一致的加速。例如,图像模糊加速 5 倍,矩阵乘法加速 5 倍,光线追踪加速 6 倍,音频处理加速 4 倍。
但有一个重要警告:强大的 SIMD 单元(尤其是像 AVX-512 这样的宽向量单元)功耗很高。持续满负荷运行可能导致 CPU 过热并自动降频(热节流)。你可以通过请求较小的向量宽度来控制这一点,这有时能在峰值指令吞吐量和持续时钟频率之间取得更好的平衡。
SIMD 与 GPU 计算
std::simd 是 CPU 技术。虽然 GPU 硬件也使用 SIMD 原则,但其软件编程模型称为 SIMT(单指令多线程),两者有根本不同。CPU 使用少量智能核心来降低延迟,而 GPU 使用大量简单核心来最大化吞吐量。它们是互补的工具,旨在解决不同类型的计算问题。
最佳实践
以下是使用 std::simd 的一些最佳实践:
- 将主循环结构化为按向量宽度块进行迭代。
- 使用
rebind_simd创建能无缝协作的类型集。 - 始终优先使用
simd_select而非分支。 - 注意不要混合不兼容的宽度,编译器会阻止你。
- 注意某些函数(如
reduce_min_index)的前置条件。
完整示例:图像伽马校正
以下函数展示了如何将伽马校正应用于图像,它遵循经典模式:
- 以 SIMD 大小的块循环遍历数据。
- 使用
simd_partial_load安全地获取数据(即使在span的末尾)。 - 使用重载的
pow函数将公式一次性应用于块中的所有像素。 - 使用
simd_partial_store将结果写回。
void gamma_correct(std::span<float> pixels, float gamma) {
using float_v = std::simd<float>; // 编译器决定的高效向量类型
const float_v v_gamma = gamma; // 将标量伽马值广播到整个向量
for (std::size_t i = 0; i < pixels.size(); i += float_v::size) {
auto subspan = pixels.subspan(i);
auto pixel_chunk = std::simd_partial_load<float_v>(subspan); // 安全加载
auto corrected_chunk = std::pow(pixel_chunk, v_gamma); // 并行计算
std::simd_partial_store(corrected_chunk, subspan); // 安全存储
}
}
总结:std::simd 是性能关键型 C++ 低延迟代码的游戏规则改变者。它让你能够以一种可移植、类型安全且可读的方式,最终掌控 CPU 的硬件并行能力。
并发队列:P02.2:标准化的并发通信
上一节我们介绍了 SIMD 如何利用 CPU 的硬件并行性,本节中我们来看看软件层面的并发通信工具:标准并发队列。
并发队列的提案始于大约十年前,经历了许多波折。但在过去一年中取得了巨大进展,进行了 109 次修订,并已进入并发研究组和库演化工作组进行接口审查,下一步将是措辞审查。
基本概念与操作
基本并发队列概念的操作是你所期望的:
- 推送操作(
push):有拷贝、移动和原位构造等多种形式。如果成功则返回true,如果队列已关闭则返回false。如果队列已满,这些操作会阻塞。 - 弹出操作(
pop):返回一个std::optional<T>。如果返回std::nullopt,表示队列已关闭且为空;如果返回类型为T的元素,则表示弹出成功。
队列状态
该提案的一个重要部分是提供队列状态。状态包括:
success:操作成功。empty:队列为空。full:队列已满。closed:队列已关闭。busy:由于内部同步,非等待操作无法立即完成。busy_async:与非等待操作解除异步操作阻塞相关,未来可能会被移除。
非等待接口
第二个概念是并发队列概念,它增加了非等待接口:
- 尝试推送(
try_push):返回一个状态(success、full、busy等)。 - 尝试弹出(
try_pop):返回std::expected<T, queue_status>。成功时包含元素,否则包含错误状态(empty、closed、busy等)。
异步接口
第三个是异步接口,包括 async_push 和 async_pop 等操作。它们都返回发送器(senders),最终会调用完成回调。如果成功,push 调用 set_value(void),pop 调用 set_value(T)。如果队列关闭,则调用 set_error。该接口也支持取消操作(set_stopped)。
关闭队列
close() 函数用于关闭队列。一旦关闭,队列就不能重新打开。关闭后,不能再向其中推送元素,但如果队列非空,仍然可以从中弹出元素。所有在空队列或满队列上阻塞的等待操作会立即被解除阻塞。对于推送操作,返回 false;对于弹出操作,返回 std::nullopt。
错误与异常处理
该提案采取的方法是,队列关闭等状态不被视为“错误”。异常可能由元素类型 T 的拷贝/移动构造函数抛出,也可能在构造时因内存分配失败而抛出。同步原语(如互斥锁)也可能抛出与死锁检测相关的异常。
具体实现与内存顺序
目前,提案中只有一个具体的队列模板:bounded_queue。它在构造时接受一个最大元素数量,并可能在此刻分配内存。队列本身不可移动或拷贝。
关于内存顺序,委员会决定,对于第一个支持的并发队列,直观性比高性能更重要。因此,操作将支持顺序一致性,但这并未对未来的高性能实现关闭大门。
被放弃的特性
在提案演进过程中,许多可能使实现复杂化或阻碍高性能的特性被放弃,例如:允许向前推送、重新打开已关闭队列、流式迭代器、为队列命名等。
总结:提案 P0260 在过去一年取得了巨大进展。如果被采纳,它将引入队列状态枚举,定义三个(仅用于说明的)概念,并提供一个满足所有三个概念要求的具体并发队列。它支持顺序一致性,并为未来的高性能实现留下了空间。
指针生命周期端点:P02.3:解决指针无效化难题

上一节我们讨论了用于线程间通信的并发队列,本节中我们来看一个更底层、影响并发算法正确性的问题:指针生命周期端点(或称无效指针)。
指针不仅仅包含内存中的比特位,编译器还会在内部跟踪其“来源”(provenance)。当通过 delete 释放一个对象时,指向它的所有指针会立即变为无效。对无效指针的任何操作(包括比较、加载、存储)的结果至少是实现定义的,甚至可能是未定义行为。这虽然启用了一些优化(如别名分析),但也使得一些正确的并发算法(如无锁栈的“生命期推送”算法)在 C++ 中无法安全表达。
问题示例:无锁栈的 ABA 问题
考虑一个经典的无锁栈推送操作:
- 线程 A 读取栈顶指针
old_head。 - 线程 A 准备新节点
C,令C->next = old_head。 - 在线程 A 执行
compare_exchange_weak之前,线程 B 执行了pop_all,获取了整个链表并释放了节点A和B。 - 线程 B 随后分配新节点
D,恰巧重用了节点A的内存地址。 - 此时,线程 A 的
C->next仍然指向原A的地址(现为D),但这是一个“僵尸指针”。 - 线程 A 执行
compare_exchange_weak,由于只比较比特位,可能成功,导致C->next这个无效指针被链入栈中,后续操作引用它会导致未定义行为。
解决方案提案
目前有几份提案旨在解决这个问题:
- P2414(Davis Herring 的提案):扩展
reinterpret_cast从整型到指针的规则,允许编译器考虑与转换操作“并发创建”的对象。这解决了compare_exchange_weak中旧值指针的来源问题。 - “收紧无效指针行为”提案:提议将无效指针的某些操作(非比较、非算术、非解引用)定义为具有明确定义的行为(即保留比特位)。这样,加载和存储无效指针就是安全的,可以将其传递给像原子加载/存储这样的函数。
- “原子与 volatile”提案:提议原子和 volatile 操作应像整型转换一样,忽略来源信息。
如果这三份提案获得通过,那么“生命期推送”算法以其自然编写的方式将成为定义良好的 C++ 代码。
此外,还有一份关于 launder_bits 和 biterpret_bits 的提案,用于非并发场景(如调试)。它们提供模板类,用于存储指针的比特位而忽略其来源信息,便于在哈希映射等结构中将其用作键值。
总结:指针无效化规则是 C++ 从 C 继承的遗产,在引入并发后带来了挑战。目前的提案旨在通过修改标准,使像“生命期推送”这样广泛使用且正确的并发算法能够被安全表达,同时为调试用例提供标准工具。这项工作始于 2019 年,目标是在 C++29 或之后的标准中引入。

本节课总结:在本节课中,我们一起学习了 C++ 标准中三个即将到来的重要特性。std::simd(C++26)提供了可移植的 CPU 数据并行编程接口。并发队列提案为标准化的线程间通信机制铺平了道路。指针生命周期端点提案则致力于解决底层内存模型问题,使经典的无锁算法能在 C++ 中安全实现。这些特性共同增强了 C++ 在低延迟和高并发领域的表达能力与安全性。
003:C++26及未来展望




概述
在本教程中,我们将整理并翻译CppCon 2025会议上关于ISO C++标准委员会的小组讨论内容。本次讨论由Herb Sutter主持,多位活跃于标准委员会的专家参与,重点探讨了即将发布的C++26标准中的关键特性、存在的争议以及对未来C++发展的展望。我们将遵循特定格式要求,确保内容清晰、结构完整,适合初学者理解。
会议开场与介绍
欢迎各位参加今晚的首场小组讨论。
大家享受CppCon第一天的会议了吗。希望你们过得愉快。我们很高兴听到大家反响积极。
我们知道你们非常投入。在晚上8:30开始的讨论中,我们欣赏各位的坚持。我们认为这将是一场非常有趣的讨论。
这不是我们举办过规模最大的小组讨论。实际上,它与2014年首届CppCon上的“拷问委员会”小组规模相同。
很高兴能邀请到多位专家组成员,他们都活跃于标准委员会。其中一些人已经活跃了很长时间。例如,从左数第二位是Bjarne Stroustrup。还有像André这样的专家,他使用C++很长时间,但最近几年才开始定期参与标准委员会会议。
我们这里有小组主席、名誉主席,也有从未担任过主席但通过提案产生重大影响的成员。例如,Barry,我想你从未担任过小组主席或助理主席。抱歉,但这并不影响你在反射和语言演进特性方面撰写了许多优秀论文的事实。
我们拥有多元化的专家阵容。我会保留这张幻灯片,上面列出了每个人的信息。
讨论形式
我们采用以下形式。过去几年,我们大约80%的问题来自观众,因为我们希望观众参与。我们仍然会这样做,但我们将从大约一半的时间开始。原因是每年有些问题都相同。然后我们收到反馈说,问题和去年一样。
因此,我们打算先由我提出一些问题,深入探讨C++26的内容以及其他及时话题,但不包括AI,因为AI明天有专门的讨论。我们尽量将大多数关于AI的话题推迟到那时。我们可能仍会提及,但这就是为什么该话题明天有专门的小组讨论。
这将让我们了解这些专家是谁。然后我们邀请你们提问。我会给出信号。麦克风将放在这里供大家排队提问。
专家自我介绍与C++26亮点
首先,我想请每位专家简单介绍一下自己。也许我们可以从Barry开始,按顺序进行。请简单说几句关于你是谁。但我想问你们每个人的问题是:我们现在正处于C++26即将批准发布的阶段,其中包含反射、发送器/接收器执行等主要特性。你们各自认为,对于开发者听众来说,标准中最具变革性、最应该了解的是什么?Barry,也许你可以先开始。
我认为这有点像...是的。我们都喜欢红色。谢谢,Ruland,请介绍一下自己。
我叫Ruth Lai。我是并行算法库的负责人,也是HG1(并发与并行)小组的联合主席。从突破性的特性来看,尽管我是并发小组的联合主席,但我个人认为确实是反射。因为你们基本上是在语言内部构建语言。这是一个我们需要学习、思考并改变我们做事方式的全新世界。我知道它已经非常强大,并且希望在C++29中变得更强大,这样我们甚至可以在没有额外工具的情况下进行代码生成。它提供了在编译时做事的无限能力。这很棒。谢谢。
接下来是Gabby Dusres,他是SG12的前任主席,也是模块等特性的设计者。是的,目前在微软,我主要从事安全方面的工作。C++26在我看来像一棵圣诞树,每个人都往上面挂了东西。我最期待的是发送器/接收器执行库框架。我编写了很多相关程序,也询问过同事和朋友,他们都在称赞库的统一框架在实践中的应用,并且不用担心竞争等问题。我非常期待它,以便我能在公司和社区中推广。
谢谢。接下来是Guy Davidson。在你发言之前,让我宣布一个最新消息。今天是周二,六天前,也就是上周二,我们的上级委员会SC22(负责所有编程语言)正在选择我的继任者,因为我担任召集人已经22年了。问题不是我为什么要卸任,而是为什么我待了这么久。我仍将非常活跃于委员会,但我想做更多的技术工作,让别人来领导。因此决定,Guy将从一月份开始成为C++委员会的下任召集人或主席,但我们将在六周后的科阿会议上就让他开始工作,因为我想减轻负担。所以,谢谢即将上任的召集人WT21。
谢谢。哦,在我忘记之前,我们有好几位合格的候选人,包括John Spicer和Jeff Garland。Jeff Garland也欣然自愿,并且希望除了他在库工作组的工作外,还能继续担任角色。这总是件好事,当你有多个合格的志愿者时,这表明团队是健康的。抱歉打断你,Guy。
没关系。我情绪上...未来几年我会收到很多数据。不,抱歉。我知道我报名了。实际上是我的国家机构为我报名的。大家好,我叫Guy Davidson。我在一家叫“早餐前六件不可能的事”的公司工作。我是一名游戏开发者。我工作的公司正在开发一个游戏引擎。我们处于秘密模式,这就是为什么你们从未听说过我们做的任何事情,但请关注我们。C++26有很多内容。所以我对“最具变革性”这个问题会给出一个稍微不同的回答。我认为你们应该把阅读C++标准的前几章作为新年决心。这些章节非常易懂,能解释很多事情。你们不必阅读整个标准。没有人这样做过。甚至编辑也没有。我猜Richard Smith和Tim可能读过。我想我收回之前的评论,但我仍然说你们应该尝试阅读标准的前几章。它会教给你们很多东西。你们不需要成为法律专业人士。这真的很值得一读。这就是你们应该做的。我同意。
Daisy,你是我们的范围库主席,也帮助过其他小组。你现在在做很多AI相关的工作。告诉我们你对C++26的期待。
是的,大家好,我是Daisy Holman,在Anthropic工作,过去六个月从事云端代码工作。是的,我之前是范围库主席,也参与了许多其他工作。我的回答听起来可能像老生常谈,但绝对是反射,尽管如果你们回去看录像,我相信我在过去三年的这个小组讨论中都说过这个。所以我在它流行之前就说了。我只是想声明这一点,但我真的认为反射可以改变语言,我认为我们会看到它的广泛应用。我们将看到更多DRY(不重复自己)的C++,更少冗长的C++,这对社区非常有益。谢谢。
Khalil,请介绍一下自己。测试1,2,3。哦,好的。大家好,我是Khalil。我在嵌入式系统上研究异常处理。我做固件和系统开发之类的工作,也做定制电子设备。如果必须说显而易见的答案,我认为显然是反射。但还有一件事浮现在我脑海中,那就是我和学生、非委员会成员、非标准化人员交谈,他们只是试图用C++工作的普通人。他们真正想要的是能够使用协程,我等待标准任务和标准生成器已经很久了。现在我有了它们。所以我对此非常兴奋。所以我认为这是第二好的事情。
还有Kelly,你旁边这位是谁。好吧,大家好,我是André。我已经清醒了...好吧,你们应该知道,由于我相对于扬声器的位置,到目前为止我能听到的就像...所以我不太确定。那么C++即将到来的最好特性是什么?我当然会说反射。好吧。
我们继续Jeff。是的,我是Jeff Garland。我是库工作组的联合主席。也是Boost项目的负责人,以及即将更名为C++ Collective的Boost基金会执行董事。我们正在重命名这个集体。是的,你知道你会听到的笑话。我不确定我知道。说吧。哦,真的吗?我不知道那个笑话。好吧,抱歉,我很天真,所以请继续。但正如你们许多人可能知道的,Boost社区决定采用不同的资助机制,所以这个集体将继续资助C++会议和许多其他事情,而Boost项目已成为我们在特性进入标准之前或期间,让大家获得更好库、访问库的主要举措。所以,我将采取与其他专家完全不同的思路。反射和所有这些都很棒。但在幕后,你们看不到的是,库和语言中已经进行了数百个错误修复,这意味着当你们将代码从一个编译器移植到另一个时,遇到的极端情况会更少。顺便说一下,我要提一下,因为1998年有C++98,2003年有C++03。2003年,我不知道,Bjarne可能会告诉我,有25个错误修复。那是一个小版本。许多人非常欣赏那个版本,因为它只是修复了真正的问题。但是,许多人不知道标准委员会一直在这样做。我们不断在每周基础上尝试解决将成为你们代码中极端情况和问题的错误。所以,还有许多小特性提高了库的一致性。这些意味着你们将调用...例如,可以从字符串流中获取字符串视图。诸如此类的小特性,意味着对于只是做事的普通程序员来说,人体工程学实际上更好。显然是的。所以我要指出这一点。有几十个这样的特性。如果你们想了解更多,可以之后和我谈谈。很好,我们投票支持将错误修复作为一个特性。嘿。总是绝对支持。总是。
Timor。嗨,我叫Timor Duler。我是SG21(合约研究小组)的联合主席。我相信最具变革性的特性将是反射。但其他人可以谈论这个。我想谈谈我认为第二具变革性的特性,那就是合约。合约首次在标准中允许你们告诉编译器,你们认为程序何时是正确的。你们还可以告诉编译器,当程序不正确时应该做什么。这将帮助你们发现程序中的错误。事实证明,以一种可移植、可扩展且高度可配置的方式做到这一点,是一种非常非常强大的技术,可以使代码更好、更安全、更正确。我认为这将是非常变革性的,因为对于反射,不是每个人都可能直接使用它。人们可能会使用我们用它构建的库,这些库会很棒。但并非每个人都会直接在代码中使用反射,而我认为断言、合约断言是每个人都希望直接写在代码中的东西。我认为这将改变很多事情。谢谢。
Inbal,请。顺便说一下,我仍然无法释怀我忘了你是SG10的主席。非常抱歉,Barry,我欠你一杯啤酒。Inbal,请。
好的,大家好,很高兴见到你们所有人。再次来到这里我很兴奋。过去一两年我没来。所以我很高兴回来。我是库演进小组的主席。这个小组负责标准库特性,正如你们所知,Jeff也提到过,我们合作将这些特性的措辞纳入标准。我非常感谢我们的合作。我不知道你们是否意识到为C++26交付许多主要特性所做的艰苦工作,包括反射,但还有其他特性。所以首先,我想感谢这种合作。是的,我也想沿着已经提到的反射路线说。伴随反射而来的是许多以前没有的常量表达式编程。例如,我们现在有了常量表达式异常。这起初听起来可能有点革命性。但仔细想想,这实际上是一个让我们从编译器获得更好错误信息的机制。这些异常的想法是它们不链接到运行时。这实际上是Hannah的提案,让我们为她鼓掌。她做了很多工作。她一直在为许多事情做贡献,包括标准库的许多部分。我几乎...是的,这太棒了。我真的只是想向这种努力致敬,因为我认为人们不理解合约和编译时编程的力量,这是C++的优势之一。这是我们在这个版本中高度重视并投入大量精力的方面,包括合约就是你们可以实际实现的一个例子。但反射和常量表达式编程非常棒。还想强调一件之前没提到的事情:SIMD,它也很棒。Ruland是贡献者之一,还有Mathias,所以你也可以谈谈这个。是的,抱歉,Mathias。但SIMD也很棒,因为现在你们可以编写相对常规的代码,但获得更好的性能。所以,是的。我想就是这些。谢谢。我甚至还没提到发送器/接收器执行,但我只是...
Nina。我是Nina Rz。我是委员会秘书。我也是负责C和C++委员会之间沟通联络的小组主席。我还在GCC中实现合约。所以我想你们知道我的答案会是什么。反射几乎肯定是一个游戏规则改变者。就像Barry说的,这是无可否认的最大游戏规则改变者。但我也对合约非常兴奋。我们研究合约已经很长时间了。我想从我们开始讨论合约到现在,可能已经有十年了。这是我们第一次在标准中真正有了东西。故事还没有结束。我们还有很多工作要做,很多讨论要进行,解决方案要找到。但至少我们有了东西,我们现在可以开始尝试,积累经验,并在未来做出更明智的决定。让我提一下,既然我们谈到你,Bjarne,感谢Bjarne和Gabby以及许多其他帮助常量表达式的人,包括最近的Hana,但你们俩开始了这个。你们俩开始了常量表达式论文。我们现在可以理所当然地认为我们可以在编译时运行大部分C++。这在15年前并不是理所当然的。问题是,我们显然想在C++中做更多编译时编程。看,我们正在滥用模板来做这件事。我们非常需要这个。哦,天哪。我们需要一种语言来做这个。C++的编译时语言应该是什么?答案并不明显是C++本身。它可能是某种脚本语言,某种一次性的事物,做一些事情,又是另一件需要学习的东西。值得注意的是,我们已经使C++成为自己的编译时语言,并且现在基本上在每个C++编译器中都内置了一个C++解释器。所以谢谢。但回到问题,对于20,你认为最具变革性、人们应该知道的是什么?对于那些在常量表达式出现时不在场的人评论一下。它不容易被纳入。它不容易被纳入。标准委员会中有小组大声宣称它不仅不可能实现,而且毫无用处。从那以后我们已经走了很长的路。我想...
我必须对C++26表示一定程度的担忧。我并不非常兴奋。我担心委员会。我看到对语言不同部分协同工作的连贯性关注太少。我们仍然没有完全集成协程。如果没有协程,我们今天就不会在这里,因为它们是我过去十年的面包和黄油。所以我担心事物如何协同工作。我们不是由委员会设计的。我们是由委员会联盟设计的。我们现在拥有的委员会和委员会主席数量,和我们刚开始时的成员数量一样多。这让我担心。很多人并不真正理解或关心整个语言,我担心这一点,我认为我们应该致力于连贯性和方向,并关注最终用户,而不是专家委员会成员的乐趣。
好吧,我要评论特性。我想我今天早上用行动投了票,展示了静态反射的使用。但即使在那里,我也担心静态反射变得太复杂、太深入。我担心它是否会为安全和安保问题打开更多可能性。它解决了很多我几年前想要的东西。我写了一篇论文,列出了六个我希望涵盖的用例。如果我当时得到了那些,然后等待看看之后发生了什么,我今天可能会更高兴。接下来,我们有了执行器模型。我最近没能跟进所有细节,因为委员会有大量内容涌现,我们很难把细节做对,很难获得足够的经验,很难获得不同特性之间的互动。我接下来期待看到协程如何在这个框架中工作。最后,我认为合约不应该在C++26中。它为ODR(单一定义规则)违规打开了可能性。它有很多实现定义的东西。不清楚它如何与模块协同工作。不清楚它是否在足够广泛的应用中尝试过。它不完整,协程、函数指针、类层次结构,以及承诺在未来修复。我不确定。我看到事情做得不对。我看到三种使用合约的语言的经验没有被学习或依赖。Python、Eiffel和Ada。所以我很担心。我认为为时过早。经验不足,抱歉。
Viorio,最后但同样重要。哦,不知怎么我们移动了。好了。测试。嗨,我叫Victoria。如今,我是一名独立的培训师和顾问。你知道,Bjarne的话非常有影响力。我有很多担忧,尤其是关于反射。我喜欢你们能用反射做什么,但仅仅通过改变变量名就能彻底改变代码含义的想法,是我们必须习惯的,并且像拥有一个可能改变其他地方代码含义的注解,在很多方面让我兴奋,但也让我害怕。我也想提请大家注意那些使反射更易用的一些实用工具。我指的是像包操作这样的一些好用的工具。我们现在可以用方括号索引一个包,就像任何其他序列一样,这真的很方便。我们现在可以用结构化绑定解构包,或者我们可以用展开语句进行编译时循环。所以所有这些都让元编程更简单,我认为在我的书中是一个胜利。
关于合约的深入讨论
你们已经开始了我后面的一些后续问题。所以让我直接跳到合约,既然我们已经讨论了很多。你们已经回答了。能把监听器音量调大吗?如果后面听不清,我们有什么办法能让大家更容易听到彼此吗?监听器可能无法移动,但如果能调整一下,声音有点小。非常感谢,抱歉。
关于合约,一个后续问题是:合约在标准中了。不是每个人都满意。对于观众,是什么具体的东西让合约值得今天发布?或者如果延迟,到底缺少什么?Bjarne已经回答了后一部分。关于前一部分,是什么让合约值得今天发布?
我毫不意外看到Timor举手。我认为有很多东西。但如果我必须指出一个特性,那就是与像assert这样的东西不同,assert是我们今天拥有的合约设施,你们可以把前置和后置条件放在函数的声明上。这意味着它不再在函数体内。它在声明上。它在头文件中。这意味着人类可以看到它。IDE可以看到它。它可以显示漂亮的气泡。你们知道,这是你们正在使用的函数的前置和后置条件。静态分析工具可以更容易地看到它。如果你们出错,它会给出更有意义的错误。编译器可以看到它。所以这使它比我们今天拥有的断言方式更强大、更可扩展。
Gabby,你也举手了。是的,然后Daisy在你之后。
就像我之前说的,C++26看起来像一棵圣诞树。让我担心的部分是我完全不知道它作为一个整体是否连贯。早期迹象表明很可能不连贯。其中一个令人失望的地方是包含了合约。但我想这里的大多数人或社区已经知道,我广泛地写过这个话题,以及为什么我们都应该对合约的现状感到担忧和担心。要非常清楚,消除任何歧义,我非常支持合约的概念。事实上,我至少在2014或15年就在委员会中研究它,有很多论文可以证明。所以我并不反对合约的概念。让我担心的是当前的规范。我知道很多人有丰富的使用、实现合约的经验,实现静态分析工具,比如John Spicer,他是制定合约规范的研究小组主席,或者像David V De Woods这样的长期贡献者,他为静态反射做出了很多贡献,还有其他以静态分析为生的人,都对合约目前的规范方式表示了严重关切。甚至在亨根堡,那个试图演示、让我们对它感到兴奋的人,实际上不得不向Clang添加扩展才能使其可用。他明确表示,没有这些扩展,我无法使用它。他展示了如何将合约添加到标准库中。如果你们记得,我在东京的一个担忧是,嘿,我们可能应该先在标准库上试试这个东西,看看它如何工作。我担心这些都没有被解决。
Daisy,然后Bjarne。
任何在委员会待得足够久的人都知道,有些特性你们必须相信委员会成员,而合约很久以前就是这样一个我决定只相信我的委员会同事的特性。我不会参与,我不会选边站。这一切在大约六个月前我开始接触AI和编码辅助时改变了。我认为这是该领域最重要的特性。我知道今晚不应该谈论这个。我们明晚会更多地讨论。但我会说,过去六个月我真正专注于研究为AI助手和智能体演进编程语言是什么样子,我认为整个行业的共识是,在所有语言中,合约是最重要的特性。它是一种非常紧凑的意图表达。它出现在声明中,而不是定义中。因此,你们可以获取大量信息,而无需获取实现细节。你们可以假设那些将是稳定的东西,因为它们在声明位置,而不是定义位置。这些对于智能体目前的工作方式都非常重要。我们不知道一年后它们会如何工作。但这都非常重要。这是公开知识,所以我可以这么说。这对我们如此重要,以至于我们实际上雇佣了负责Rust合约的人。这对于智能体环境来说是非常非常重要的事情。我们需要推出一些东西。它不一定需要工作得很好,因为我们可以训练它。它不一定需要工作得很好,但它需要在29之前推出。
澄清一下,听起来你描述的是它们位于声明上以便AI可以看到,这比实际的执行细节和它们表达的实际意图更重要?哦,以及它们表达的、不一定依赖于实现的约束。
Bjarne,Nina,然后我们将转向另一个话题,因为我们还有其他话题,但我很高兴我们讨论合约,因为这是一个持续的讨论点。
Daisy说的关于合约的好处完全正确。我提议将其纳入C++20。但由于某些原因被否决了,我并不欣赏。我遇到的问题是我们对当前设计没有足够的经验。它不完整,直到上周邮件列表发布,还有一篇论文说它是一个安全隐患。我担心我不理解包含合约的代码。如果标准库是用合约实现的,我该如何使用它?我非常非常担心。它还不成熟。我担心我提出的许多关切都被声称解决了。但却是通过补丁摞补丁的方式,而不是连贯的设计。
Nina,请。
我将从稍微不同的角度来看待这一切。我认为合约和我们在这里进行的讨论,很好地展示了委员会是如何工作的。当人们问为什么东西不在C++中时,是因为事情很难。我想我们都同意合约需要进入语言。我们并不都同意这些合约应该是什么样子。我们并不都同意这些默认值是什么。我们并不都同意合约应该何时进入语言。但这正是委员会的工作方式。我们有不同意某些事情的人,他们提出关切,让我们保持警惕,并不断把对话拉回来。这样我们就不会犯严重的错误。确实,我们对合约没有足够的经验。我们在C++20中有合约,但被撤回了。我们甚至在GCC中有一个实现,它仍然存在。没有人使用它。直到它进入标准,编译器才会提供它。直到编译器提供它,我们才能获得任何经验。它不完整。我们还有很多工作要做。我认为,尽管它不完整、不完美,但以我个人观点,我感谢...我们会不同意。我们还没有做出任何我们未来无法撤销、无法修复和变得更好的东西。这就是重点。这就是现在推出它的意义。这样我们可以积累经验并使其变得更好。
用户需求与委员会提案的差距
谢谢各位的广泛视角。我想跳到一个问题。我觉得有些手举得不多。也许Barry、Khalil和André,例如,可以发表意见。请随意举手。我邀请你们。如果你们举手,我会先叫你们。你们从现实世界用户那里听到哪些请求,目前没有在标准委员会的提案中得到强烈体现?你们的名字不是以André或Barry开头,所以Barry,请先。
是的,我想我会跟进。就像在委员会里,当我试图不关注合约或任何相关讨论时,我在这里也这样做。所以我在这方面保持一致。不是每个人都能跟上所有事情。所以,我看到人们经常抱怨的一件事是我们的人体工程学,尤其是标准库的人体工程学,对吧。当人们开始使用Rust时,他们注意到的一件事是与Rust相比,标准库有多好。感觉它更像内置电池,而Rust中我们有所有这些好的泛型编程设施,但直到C++20我们才为字符串添加starts_with和ends_with,contains直到23年才添加,我想。我们仍然没有像字符串上的.split或.join这样的东西。程序员使用字符串吗?显然,我被告知是的。所以,我特别对反射感到兴奋的原因之一是,虽然有人提到不是每个人都会编写反射代码,这当然是真的。但我认为最终可能成为现实的是,每个人都会使用反射库。就像在Rust中,你们可以看到有多少人编写过程宏?那将是非常小的比例。有多少人使用clap作为命令行参数解析器?有多少人使用serde进行序列化?就像每个人都在做这些事。所以,开始编写更符合人体工程学的库的能力,以前是件难事,对吧,每两个月就有人发布一个新的C++命令行参数解析器。事实证明,你们无法编写一个非常好、符合人体工程学的命令行解析器,因为大多数人大多数时候想做的是:我有一个包含五个东西的结构体,我想要一个命令行参数。或者从中输出。就像为我做这件事。现在很难做到符合人体工程学。而将来会变得极其容易。所以我认为至少在这方面,我们至少会开始移动指针。
André,Khalil,你们从人们那里听到哪些事情,是你们在委员会中没有看到很好体现的?人们希望我们做的。不是要把你们置于聚光灯下。哦不,更像是Barry很好地解释了人体工程学问题。我想如果我要再补充一件事,那就是很多人觉得很多时候有点痛苦。实际上,我有很多学生说,哦,C++很棒,很好,我做得很好,直到他们遇到一个非常晦涩的错误。他们来找我,说,嘿,我花了三个小时试图修复这个错误。我说,好的,让我看看。然后我花了15分钟试图弄清楚如何修复这个错误。哦,这里的这个实际上是一个拷贝。你不想要那个。你想要移动到这里。他们说,哦,我明白了。所以实际上,我们最近遇到的一件事。我做了这个东西作为跨度的跨度。我们稍后可以谈谈那个。但大多数情况下,出现了很多内存问题。所以我想说,如果有任何其他事情,那就是安全性。这是一个大问题。安全性的易用性,以及从像“这显然来自单个翻译单元,你可以看出这不是有效代码”中获得良好结果。在Nvidia,对代码生成有大量需求。在源代码生成方面。我们有文件,AI字面意义上扛在肩上,长达30000行。它们由重复的代码组成。字面意思重复数百次。相同的代码。只有一个小细节,一个小的、不同的字符,对于各种类型、大小、尺寸组合、数据形状等重复。确保这些代码可维护对任何人来说都是绝对的苦差事。所以如果你们感兴趣,看看cutlass库,如果你们按行数降序排序,你们的头发会立刻竖起来。即使是你们的。所以,使这些代码易于处理的代码生成设施,在Nvidia我们绝对需要。其次,AI中的一切都是关于张量。是关于编译时张量。我们通过元组操作张量。但不久之后,张量是具有有趣深层结构的多维元组。而元组目前的工作方式,当它们建模张量时,使操作变得非常困难。所以对包、元组和这类东西的更好支持,我们也非常需要。我还要提一下,编译速度。每个人似乎都认为,我们永远不会获得编译速度。我们永远不会获得可用的模块。我们的编译时间已经失控。没有人关心它们。所以我不确定会发生什么。我不太乐观。谢谢。
顺便说一下,我们接下来有Guy,然后是Bjarne。你提到生成的代码,这非常重要,随着我们在26之后添加更多东西,我们将用反射做更多。但它让我想起了现在常量表达式代码的可调试性。因为我碰巧看到,我没有读整篇文章,我今天看到一篇关于JetBrains发布常量表达式调试器的公告。所以,耶,更多这样的东西。我十年来一直在宣扬我们将对这类东西有越来越多的需求。我们将需要对生成的代码进行调试,因为我们必须看到它才能调试。我们必须单步进入它。我们甚至必须在生成过程中查看以确保正确。这些都与你说的话有关,但让我先问Guy,然后问Bjarne。
回顾Barry和Khalil所说的,我听到最多的是,你们不能让它更容易些吗?你们知道,这是一个如此庞大的语言。有太多东西。我们正在让它更容易。我认为我们正在增加其易用性价值。但我今天听到一个关于将代码库从C++14升级到C++20的演讲。他们犯的错误,请原谅我这么说,是他们使用了微软编译器上的宽松命令行选项。所以他们编写了不符合规范的代码,给自己带来了巨大的痛苦。我也有一个类似的故事,我必须升级一个用Visual Studio 2005编写的游戏,所以是在C++03上。我必须将其升级到C++17。但我一直编写符合规范的代码,因为我很注重这一点。这是一项容易得多的工作。而且,仅仅通过使用新编译器,游戏的帧率就翻倍了。我认为这表明...我可能没听清,你能再说一遍吗?仅仅通过使用更新的编译器,帧率就翻倍了。我们可以回去,大刀阔斧地修改,失去向后兼容性。但我认为这真的很重要。它确实使学习C++变得更难,因为你们必须说,嗯,我们过去有这个叫做哈希包含的东西,现在,你们知道,现在我们用模块。我认为教学总体上仍然是个问题。很高兴看到提案中包含“我们将如何教授这个提案?我们将如何教授这个特性?”我认为教学材料,我们可以做得更好。
模块的进展
Bjarne还在排队。然后我们想开始。所以如果你们想开始排队提问,让我在叫到你之前插入一个问题,因为现在有两位专家提到了模块。今天Reddit上又有一个帖子,而且经常有。模块进展如何?这直接关系到编译速度,因为这是我们希望的事情之一。人们经常问,所以关于模块(C++20特性,人们真的很想使用)的进展,我们能告诉他们什么?我确实有关于这个的看法,我将在周三上午8点的公开会议上多谈一些。但Boost项目肯定有兴趣成为模块优先的。我们想达到那个目标。我们一直在研究这个,有一些经验可以分享。有很多细节。我的感觉是,我现在录下来这么说,所以如果没实现,我会被记住。我认为在未来一年,你们会看到模块的突破,因为我认为随着GCC 15实际加入模块,现在正在发生。谢谢Gabby,你是模块先生。所以如果你想回答,我会给你优先权。然后我们还有Inbal。但然后我想叫Bjarne。好的,是的,当然。当我们采用模块时,我们知道这极大地改变了我们组织源代码的方式,依赖关系变得更加明确,由此产生许多好处,我们一直在看到它们。我们一直在处理一个发展了40年的生态系统。所以出于某种原因,MSVC率先以用户形式推出了模块,随后是Clang。今年春天,GCC 15推出了一个版本,例如,你们可以说import std;。工具生态系统一直在缓慢改进,允许人们承担更多依赖。我非常期待Boost项目正在进行的工作,因为我认为考虑到你们库的影响力,这将渗透得更广,因为现在人们将承担更多大胆的依赖。然后我们能看到一些好处。我想举几个例子。第一件事是,有更好的代码组织。我知道我们还推动今晚谈论AI,但有一件事不能提,因为有一个专门的小组讨论。所以是的,好吧,我不说了。我那时承诺的一件事,并且我们仍在实现的是,如果你们改进代码组织,你们可以改进工具。现在每个人都在竞赛,看谁先到那里。如果你们在C++上训练模型,而你们的基础模型是基于包含文件的包含模型,你们会非常快地耗尽令牌窗口,因为东西被粘贴。所以现在你们又回到了这种变通方法,告诉系统,哦,这个东西看起来就像那个其他东西。今天,你们有模型,当你们看到一段代码时,它们会最小化它。但如果你们使用模块,你们认为那是明确不同的代码,比如import,那是已知的,具有明确定义的语义。这就是为什么,例如,如果你们看Python或C#的模型,它们比C++的模型表现稍好。另一件事是,模型非常擅长做模式匹配,但在做算术和逻辑之后就很吃力了。如果你们有一种像C++这样有趣的语言,仅仅为了调用一个函数,你们必须做很多计算,或者做很多计算,那么对模型来说就变得更具挑战性。它们想要成为最先进的模型。我一直在玩它们,因为它们必须做计算和逻辑。所以,为了让这些模型表现更好,你们想要用语义描述来增强字符序列。之前有人提到合约。哦,是的,我们可以总结声明。但你们真正想要的是语义描述。例如,当你们实现模块时,你们就有了接口的语义表示。这是工具的另一个优势。所以我们在曲线前面领先了,比如说,10年。也许吧。当你说那些关于AI的事情时,我看到你右边那位对AI很了解的人,我注意到有一个...我想你应该期待一个反驳,有人不同意你所说的。我们明天会知道。毫无疑问。Bjarne,然后我想回到Barry之前的问题,也就是你们从用户那里听到哪些事情,在标准委员会的论文中还没有得到很好体现?实际上,你提出来很好,我想谈谈模块,但我也想谈谈之前的问题。我想给你们更广泛的视角,了解正在发生的事情。首先,关于模块。我现在不是指实现,但我认为我们确实进行了讨论,并努力通过标准委员会标准化更多工具方面,我们实际上考虑过制定一个额外的标准来解决工具问题。我认为模块,如果我们正确使用它,并且我们也鼓励我们的用户,鼓励现代技术,如包管理等,我们实际上可以解决这里提出的许多问题,包括编译时间,包括其他事情。我们可以交付,或者我们也可以标准化或考虑标准化关于模块的信息,包括ABI版本等,这些事情实际上可以解决委员会长期以来提出的许多问题。所以我认为我们实际上正在迈向更美好的未来。我想给你们这个更广阔的视角,因为我们听到了很多问题,但我也认为有很多解决方案正在进行中。另一件我想解决的事情是,Bjarne担心连贯性。我也同意这一点。我们是一个庞大的委员会,我们也正在努力为我们的用户做这件事。我们想给你们更多特性。但对于连贯性方面,我实际上一直在研究这个政策框架,我们试图在委员会内部就什么对我们重要达成某种共识。顺便说一下,易用性就是其中之一。所以最后我想说的是,实际上解决Herb提到的用户想要更多什么。我认为好的文档,我们正在做这个,这对我们很重要,我认为也许最近几年比以往更重视,我没有在委员会存在的所有时间都在,但我个人至少看到了很多价值,这包括像你们在这些会议上看到的演讲,包括cppreference随着最新特性的交付而更新,这是我们强调并努力的事情,这不是孤立的,是我们实际投入精力的事情,还有人体工程学,正如提到的,我之前提到的上下文也是其中的一部分。所以我想给你们这个更广阔的视角,表明我们正在这些方面努力,但在不同的方面。现在,最后,我们终于要叫Bjarne了。但记住,我们希望你们提问。所以麦克风就在这里,如果你们有问题要问我们的专家,请开始排队。
Bjarne的总结与用户反馈
我只想说,模块之所以伟大,是因为它们更直接地表达了我们想要表达的东西。包含文件,天哪,那是一个黑客手段,因为1974年他们无法做得更好。他们知道那是黑客手段。我们知道那是黑客手段。这里人们说的所有事情都源于这样一个事实:我们现在说的是我们想说的意思,而不是使用变通方法。其次,你提到使用新编译器时的速度提升。我在金融软件中也看到了完全相同的情况,使用更新的编译器意味着你们能更好地利用硬件。我听到最多的是对C++复杂性的抱怨。每当我们添加一个新特性,人们抱怨得更厉害,因为新的东西被认为是复杂的,可能是错误的,可能是慢的。这些往往都是错误的。但人们就是这样看的,他们继续使用旧的东西,他们继续用1978年左右写C代码的方式写代码,这拖累了性能,创造了错误机会等等。我们一直在研究核心指南,指南很好,但不够好,你们需要强制执行的指南,这就是我们所说的配置文件。我们未能在C++26中纳入它们,如果它们进来了,我会说它们是最重要的,因为它们允许我们拯救人们,避免他们陷入黑暗角落,专注于更好的代码。人们认为这只是与安全或安保有关的事情。获得良好安全性的最佳方法是获得更好的类型安全。这也是更好地使用语言、简化人们编写的代码的方法。我们不能简化语言,我们不断扩展语言,不断扩展库。我们能做的是提供更简单的语言子集供人们使用。配置文件哲学是:首先用更理想的设施扩展语言,然后对结果进行子集化。这是解决我听到最多问题的方法,无论是来自用户、专家用户、新手用户、学生还是教师,这是一个非常广泛的层面的事情。
观众提问与讨论

谢谢。看起来我们有一位勇敢的志愿者。请告诉我们你的问题。希望看到更多人排队。我想回到讨论的早期,你们中有几个人表达了对C++26状态的一些不安。特别是Bjarne,我想,提到了几个他认为实现不佳的特性,以及其他一些不完整的特性。我只想指出,早期的C++标准引入了容易被误用的特性,比如auto_ptr就是一个很好的例子。还有其他一些特性,在我看来,发布时不完整,比如参数包在C++11中出现,直到C++17我们有了折叠表达式等,使用起来才不那么烦人。所以我的问题是,对于那些对C++26有担忧的人,你们的担忧是否比那类事情更高层次?你们对早期的C++标准是否曾感到同样程度的不安?我可以插一句,开源并不总是早期标准的情况,也有特性是在闭源中实现的,因为开源并不总是存在。所以这也是我们需要记住的。Ruland,你举手了。还有其他吗?请举手,我有Bjarne,Gabby,那边还有其他吗?是的,我认为C++26,我们有很多复杂的特性要发布,就像C++20一样。我想那时我们有四大特性:范围、协程、概念。我忘了第一个是什么,模块。是的,我们正在谈论那个。我怎么能忘记。是的,我们仍然有问题,比如模块问题更多,范围有些问题,那些都存在,有一个库存在了将近十年,我想,但我们仍然修复了那些东西。我的意思是,我认为我们成功地做到了。现在很多人使用范围。你们知道,没有...有一些改进即将到来,但这是一个迭代过程。尽管我们对这个或那个有多少经验,可能仍然有一些担忧或疏忽,因为我们无法考虑所有事情。同样,C++26将交付像发送器/接收器执行、反射和合约这样非常复杂的特性,也许因为人们仍然对这些有担忧。我不那么害怕,例如,并行算法,它们应该或多或少相当直接,但仍然,你们知道,是的,有一些问题。我认为我们仍然可以发布它们,让人们开始使用。当我们有一些担忧时,我们会思考如何解决。而且,如果我们认为某些东西不完整,我的意思是,如果我们已经识别出一些问题,我们可能会限制这个或那个特性的范围,我们还有时间框架,以后交付一些东西,如果我们认为现在它是坏的。但如果我们认为它处于可工作状态,让我们发布,让我们看看反馈如何。我看到远侧有手吗?我不确定这个,如果有的话。我想那是近侧,但我想确认,是的,你也在排队。好的,我没看到。所以Gabby,我想然后Bjarne。Jennifer,你们可以自己排队了。我们知道,没有反馈,我们无法做出完美的大型特性,看看它如何适应语言的其他部分,看看人们如何使用它。这就是为什么当我们开始三年发布周期时,我们认为会有一个主要特性进来。然后下一个版本会使使用更容易,修复任何不完整性,修复我们未预料到的与语言其他部分的互动。另一方面,我看到人们想到的像常量表达式和模块。有一个清晰的模型,语义模型,关于将要发生的事情,我们或多或少遵循了,唯一我看到我们放入了某些东西而我们根本错误的地方,是叫做外部模板的东西,那是早期尝试做类似模块的事情,但没有好的连贯设计。对于合约,我未能看到总体计划、总体连贯模型,感觉像是拼凑起来的,这让我担心。Gabby,然后我们有另一个问题。我错过了谁吗?好的,Gabby,然后我们有另一个问题。我认为这是一个很好的问题。所以要清楚,我担心不是因为我认为这不完美
004:选择正确的C++并行化工具——底层与异步与协程与数据并行



在本节课中,我们将学习C++中四种主要的并行编程模型。我们将探讨每种模型的核心概念、适用场景以及优缺点,帮助你理解如何根据具体需求选择合适的工具。课程内容涵盖从底层的无结构并行到高级的数据并行,旨在为初学者提供一个清晰的并行编程概览。
无结构并行模型
上一节我们概述了课程内容,本节中我们来看看第一种模型:无结构并行。这种模型基于C++11引入的最基础构件,如线程和原子操作,提供了最大程度的控制,但也带来了最高的复杂性。
无结构并行模型直接使用操作系统线程和底层同步原语。其核心组件包括:
- 内存模型与原子操作:如
std::atomic,用于实现无锁编程和细粒度同步。 - 线程:
std::thread,代表一个独立的执行流。 - 同步原语:如互斥锁 (
std::mutex)、条件变量 (std::condition_variable) 以及C++20引入的信号量 (std::semaphore) 和闩 (std::latch)。 - 便利工具:如RAII锁包装器 (
std::lock_guard)、std::jthread等。
以下是该模型的主要使用场景:
- 构建高级并行设施:例如,实现自定义的线程池或自旋锁。
- 实现并发数据结构:设计允许细粒度访问或完全无锁的数据结构,以提升并发性。
- 运行长期后台服务。
该模型的优点在于控制力强,性能潜力高。缺点则是内存模型复杂,极易引发数据竞争和死锁,对开发者要求极高。线程本身只是一个执行载体,不包含任务语义,因此任务逻辑中必须交织同步代码,这增加了复杂度和出错风险。
任务并行模型
上一节我们介绍了需要手动管理一切的无结构模型,本节中我们来看看更高级的任务并行模型。它将计算单元抽象为独立的“任务”,从而将功能逻辑与执行调度解耦。
在任务并行模型中,一个任务代表一段相对独立、无副作用的计算。任务可以是函数、Lambda表达式或循环迭代。关键优势在于,运行时环境(如线程池)负责调度任务,任务本身无需关心在哪个线程上执行或如何访问共享数据。
在C++26之前,任务主要通过 std::async 实现。它启动一个异步任务并返回一个 std::future 对象,调用者可以在未来通过 future.get() 获取结果。其启动策略 (std::launch) 决定了任务是立即在新线程执行、延迟执行还是由运行时决定。
std::async 的局限性在于任务无法组合、执行上下文难以配置、缺乏调度控制。C++26引入的 发送器/接收器 (Senders/Receivers) 模型极大地改善了这一点。发送器是惰性的、可组合的异步操作描述符。以下是一个简单的发送器使用示例:
// 创建任务链,此时并未执行
auto task_graph = just("Hello")
| then([](std::string s) { return s + " World"; })
| then([](std::string s) { std::cout << s; return; });
// 同步等待并执行整个任务图
sync_wait(task_graph);
发送器支持任务链式组合、灵活的调度器配置(线程池、协程、GPU等)、内置停止和错误处理机制,代表了C++异步编程的未来。
协作式多任务(协程)模型
上一节我们讨论了基于任务的异步模型,本节我们探讨一种特殊的任务模型:协作式多任务,其核心是C++20引入的协程。这种模型特别适合处理大量可能阻塞的任务,例如I/O操作。
协程是可以在函数内部显式挂起和恢复的函数。当协程遇到 co_await 表达式时,它会挂起自身并将控制权返还给调用者或调度器,而不会阻塞底层线程。这使得单个线程可以在多个任务间高效切换,充分利用CPU资源。
一个简单的协程示例如下:
coro_task my_coroutine() {
std::cout << "Before wait\n";
co_await some_awaitable; // 挂起点
std::cout << "After wait\n";
}
co_await 等待一个“可等待体”,该对象通过 await_ready, await_suspend, await_resume 三个方法控制协程的挂起与恢复逻辑,这构成了调度点。
要使协程用于有效的并行任务处理,需要:
- 并行性:需要多核CPU或异步I/O设备来真正实现并发。
- 调度器:需要一个用户态的调度器来跟踪大量被挂起的协程,在I/O完成等事件发生时恢复其执行。
在协程模型中,通常采用单线程-per-core的调度器。需要注意的是,协程在挂起后可能在不同线程上恢复,因此应避免使用线程本地存储和原生的阻塞式同步原语(如 std::mutex),而应使用协程感知的、非阻塞的同步机制。
与协程相关的还有纤程,即用户态线程。纤程是栈式协程,可以在任意嵌套函数调用中挂起,更像一个通用的执行上下文。但C++标准目前尚未支持纤程。
数据并行模型
上一节我们介绍了基于控制流的并行模型,本节我们转向另一种思路:数据并行模型。该模型关注于对大规模数据集进行统一操作,通过并行处理数据本身来获得加速。
C++17引入的并行算法是此模型的代表。其通用形式为:
std::reduce(std::execution::par, data.begin(), data.end());
调用是顺序的,但通过执行策略参数,我们声明了允许的并行方式,运行时则负责具体的并行化实现。这是一种高级的、声明式的API。
C++标准定义了四种执行策略:
std::execution::seq:顺序执行。std::execution::par:允许并行,但单线程内操作不交错。std::execution::par_unseq:允许并行和向量化(单线程内操作可交错)。std::execution::unseq:仅允许向量化(单线程内)。
该模型的优点是安全(无数据竞争、死锁)、高效(可利用多核甚至GPU),且代码简洁。缺点是我们对底层资源(如使用哪个线程池)控制力较弱。需要注意的是,并行算法作用于容器数据,但容器本身并非线程安全的,若其他线程同时修改容器结构,仍会导致数据竞争。
混合使用模型
在分别了解了四种模型后,一个自然的问题是:能否混合使用它们?答案是:可以,但需格外谨慎,因为并行编程本身就很复杂。
混合模型时,核心考量是CPU资源的利用率和同步机制的兼容性。以下是几个基本原则:
- 当程序主要使用无结构并行(如自定义线程池)并已占满核心时:几乎没有剩余计算资源给其他模型。可考虑用协程处理异步I/O,但不宜再引入并行算法或更多线程。
- 当程序主要使用任务并行(如发送器)且核心空闲时:可以从任务内部调用并行算法来加速计算。但要避免在任务中使用阻塞式同步原语。
- 当程序主要使用协程调度器时:如果调度器已充分利用多核,则无需再混用其他模型。如果仅用于少量I/O,在协程中调用并行算法会阻塞该协程,通常不是好选择。
- 当程序主要使用并行算法时:在算法执行的Lambda中混用其他模型(如启动新异步任务)通常很别扭,且应绝对避免使用阻塞式同步,否则会破坏并行性。
总之,混合模型通常效果不佳,因为不同模型的运行时可能彼此不知晓,容易导致资源过度订阅或同步问题。最佳实践是尽可能在单一模型内解决问题。
总结与问答
本节课中我们一起学习了C++中四种主要的并行编程模型:
- 无结构并行:基于C++11底层构件,控制力强但复杂高危。
- 任务并行:基于
std::async和未来的发送器,将计算抽象为可组合的异步任务。 - 协作式多任务:基于协程,适合处理大量I/O密集型任务,需要自定义调度器。
- 数据并行:基于并行算法,声明式处理大数据集,安全高效但控制力弱。
混合这些模型通常很困难,需要仔细权衡资源与同步问题。
观众问答精选

-
问:我的项目用大量进程而非线程做并行。如何说服团队转向线程?
- 答:25年前C++对线程支持不足,进程是合理选择。如今,线程拥有标准化的通信和同步机制,内存共享也更高效。转向现代C++线程模型能简化代码、提升性能,尽管需要一定的迁移投入。
-
问:我的应用有一个阻塞式I/O线程,如何在此场景下实现并行?
- 答:如果I/O是阻塞的,那么使用基于线程的并行是合适的。操作系统能感知线程阻塞并调度其他线程。更好的长期方案是尽可能将I/O改为异步API,然后就可以采用任务并行或协程模型。

- 问:使用
std::async时,能否通过传递和等待future来组合任务?- 答:可以手动编码实现,例如在一个任务完成后启动下一个。但这需要调用者反复介入,效率较低且繁琐。而像发送器这样的模型提供了内建的任务图组合功能,更优雅和高效。
005:矩阵乘法深度剖析——缓存阻塞、SIMD并行化


概述
在本教程中,我们将跟随 Alexey Sal 的分享,深入探讨如何从零开始优化一个基础的矩阵乘法算法,使其性能逼近硬件的理论峰值。我们将从最朴素的实现出发,逐步应用循环重排、分块、SIMD向量化、缓存感知、多线程、数据布局调整、循环展开和预取等一系列优化技术,并最终评估C++在实现高性能计算核心时的潜力与局限。整个过程将展示如何将理论上的硬件知识转化为实际的性能提升。
1:背景与目标
我的名字是 Alexey Sal,是一名首席软件工程师,拥有超过10年的C++编程经验。

C++最吸引我的一点是它让我有机会参与各种不同类型的项目。
例如,我曾参与过剧院系统、电动汽车充电器、云系统微服务器以及AI加速器软件的开发。
在大多数项目中,作为C++开发者,我们都很关心性能。但这通常指的是高级优化技术。
例如,如果可以移动对象,就不要复制它们。
但我一直对底层优化技术感到好奇。我们都知道一个原则:不为不用的东西付费。
我已经为我的CPU付了钱,所以我必须充分利用它。
因此,我决定深入研究高性能计算这个主题。
并在此与大家分享我的探索历程。我选择了矩阵乘法作为研究对象。
这个算法的应用范围非常广泛。但在本次分享中,我只想聚焦于一个应用:大语言模型。
我们不会深入探讨其工作原理,但我想强调,在大多数Transformer模型中,大约70%到90%的计算时间都花在了大型矩阵乘法上,主要是在自注意力机制和前馈网络中。
如果我们仔细观察自注意力机制,会发现它大量使用了矩阵乘法。
因此,优化矩阵乘法并理解如何让该算法达到峰值性能是合理的。
为什么要追求性能?让我展示一张表格,我们可以看到过去10到15年间人工智能领域的突破。
我们可以看到从AlexNet开始,它需要千万亿次浮点运算的总计算量,而在过去10年里,对计算的需求是如何增长的。
我们从千万亿次增长到亿亿次。如今,是百亿亿次。接下来,可能是十万亿亿次。
我从未想过会在演讲中提到这些单位。既然我们对计算有如此巨大的需求,我认为实现高性能至关重要。
根据OpenAI的数据,训练一个新模型所需的计算量每四个月翻一番。
我非常期待看到下一代模型的能力。因此,我认为我们需要更深入地理解如何更高效地利用硬件资源。
让我介绍一下今天演讲的议程。我将从C++的朴素实现开始,这是我们在不同教材中都能看到的版本。
然后评估其性能,我的关注点主要是执行时间,同时我也会展示特定实现相对于峰值性能的百分比。
接着,我们将进入矩阵乘法的下一个优化版本,我会应用其中一种技术。
我们将把展示的实现与之前的所有实现进行比较,看看我们是如何从朴素实现逐步发展到性能最高的版本的。
对于本次分享,我使用Google Benchmark来运行所有基准测试。
所有测试都在一台Linux机器上进行。我使用了一款较旧的CPU:Intel Core i5-6600,基于Skylake微架构。
我使用以下编译选项编译我的矩阵乘法实现:-O3 -ffast-math -march=native。
我假设大家熟悉一些硬件基础知识,比如SIMD指令、缓存内存等。我会简要提及,但不会深入探讨其工作原理。
在我的具体案例中,矩阵本质上是一个double类型的向量,按CPU缓存行对齐(在我的具体案例中是64字节)。元素以行主序存储。
我将对固定维度的方阵执行稠密矩阵乘法。
我选择的矩阵尺寸要超过最后一级缓存的容量。因为我希望达到峰值性能。
让我为我的演示给出一个定义:什么是峰值性能?以下是我为我的CPU计算的方法。
我取核心数量,乘以CPU的基础频率,再乘以我的CPU每个周期可以执行的浮点运算次数。
在我的具体案例中,我有四个核心。测试频率是2.8 GHz。
为了理解如何计算每个周期的浮点运算次数,我们基本上可以使用SIMD指令每个指令处理8个元素。
乘数2来自于指令吞吐量,这基本上意味着CPU可以同时执行两条指令。
这样我们得到了针对这个特定CPU大约180 GFlops的峰值性能。
2:朴素实现与性能评估
让我们从实现开始,首先是朴素版本。
朴素矩阵乘法看起来非常简单。
我们需要取矩阵A的每一行,乘以矩阵B的每一列。
然后将结果放入矩阵C的对应元素中。这是一个非常简单的三重循环。
这个实现我在许多教材中都见过,人们也都在使用。让我们测量一下性能。
执行时间花费了100.52秒。在表格中,我们不仅能看到执行时间,还能看到相对加速比,即下一个实现与上一个实现的比较。
绝对加速比是当前实现与朴素实现的比较。GFlops表示当前实现执行的千兆浮点运算次数,而峰值性能百分比则是我在上一张幻灯片中计算的峰值性能的占比。
3:循环重排优化
第一个优化是改变循环顺序。在朴素矩阵乘法实现中,我们基本上是按照字母顺序遍历i、j和k索引,但这在数据访问模式方面并不是最高效的方式。
因此,仅仅将循环顺序改为i、k、j,我们就能显著提高算法的性能。
这背后的原因如下。如果我们观察朴素矩阵乘法实现,可以看到当我们按照特定的顺序遍历i、j、k索引时,我们顺序访问矩阵A的元素,这很好。
但我们以巨大的步长访问矩阵B的元素,这具有非常差的空间局部性。
这背后的原因之一是:当我们从矩阵中读取一个元素时,我们不是只读一个。我们会读取多个元素来填充整个缓存行。
所以当我们访问一个元素时,我们实际上,假设是四个元素。如果我们没有全部使用它们,我们基本上就浪费了缓存内存。
第二个原因,以巨大步长迭代是不好的,因为它会扰乱硬件预取器。这使得处理器从内存中获取数据到缓存变得更加困难。
当我们将循环顺序改为i、k、j时,现在我们可以看到,当我们遍历矩阵B时,我们有了顺序数据访问,这很好。
由于矩阵A不依赖于j索引,当我们将A和B的元素相乘时,我们只是重复使用相同的元素,这也很好。
当我们遍历矩阵C的元素时,我们也有顺序数据访问模式。当我们测量执行时间时,我们看到加速比大约是朴素实现的12.6倍。
但与我们所拥有的峰值性能百分比相比,它仍然相对较低。
4:分块优化
下一个优化是分块。让我们不是逐个元素相乘,而是将矩阵划分为块。
所有这些方块,想象一下它不再是一个元素,而是一个包含多个元素的块。
为了简单起见,我们的块可以包含四个元素。
分块算法将计算矩阵C的整个块,而不仅仅是一个元素。
在这种特定情况下,我们重复使用的不仅仅是矩阵A的一个元素,而是整个块。因此,我们基本上增加了数据的重用率。
然后我们遍历矩阵B的整行元素来计算矩阵C的整个块。
为了实现这种优化,我们只需要添加一个额外的循环。
最内层的循环将在块或瓦片内迭代,而最外层的循环将在块之间迭代。我将把最内层的循环放入一个特定的函数中,我将其称为内核。
这是我们朴素矩阵乘法的内核。如果我们测量性能,会发现与使用分块优化之前的实现相比,性能提高了三倍。
5:SIMD向量化优化
下一个优化是向量化。简单介绍一下。例如,当我们进行标量加法,并尝试将数组A的几个元素与数组B的几个元素相加时,我们基本上需要执行四个操作。
使用SIMD指令则不同。我们可以将所有元素加载到特殊的向量寄存器中,然后只应用一个操作来获得相同的结果。
这就是我们要对矩阵乘法算法做的事情。
但在开始向量化我们的实现之前,让我们检查一下编译器生成的汇编代码。
我们可以看到编译器已经可以为我们使用SIMD指令了。所以它似乎知道我们要做什么。
我注意到一件有趣的事情:当我把最内层循环移到一个单独的函数中时,汇编代码发生了巨大的变化。
那个版本实际上更快。所以,让我们改变我们的朴素实现,使用向量化版本,并在手动向量化后进行比较。
这是手动向量化内核的样子。这个实现,我最初是在文章《What Every Programmer Should Know About Memory》中看到的,我强烈推荐阅读。但它相当老了,所以作者使用了128位SIMD指令。我的CPU支持256位,所以我只是稍微更新了一下这个实现,使用了更宽的指令。
解释一下它的工作原理:我们取矩阵A的一个元素,将其值广播到整个寄存器,然后放入一个寄存器,我们称之为A寄存器。
然后我们从矩阵B取一整行,将这四个元素加载到B寄存器中。
接着我们从矩阵C加载元素到向量寄存器。然后我们应用FMA指令,它被称为融合乘加,基本上同时执行乘法和加法两个操作。结果存入C寄存器。
然后我们基本上将结果存回矩阵C。这就是向量化内核的工作原理。
如果你比较手动向量化优化实现的内核生成的汇编代码,以及编译器为我们向量化的内核,可以看到一个特别之处:编译器没有使用所有可用的寄存器。
我的CPU相比手动实现有更多寄存器。具体来说是YMM寄存器。我们可以看到编译器几乎只使用了3到4个。
而在手动实现中,我们设法使用了全部。在我的具体案例中,是16个寄存器。
如果我们测量性能,会发现它比之前的分块实现略好,大约是1.2倍。
6:缓存感知实现
下一个优化是缓存感知实现。在深入之前,让我简要回顾一下内存层次结构。
在这个层次结构的顶层,我们有最昂贵、性能最高但容量最小的内存。
在金字塔的底部,我们有硬盘,它是容量最大、速度最慢但最便宜的。
为了解释如何利用内存层次结构并将其应用于矩阵乘法算法,我将使用Robert van de Geijn教授(德克萨斯大学计算机科学教授)创建的图像。
让我们看一下围绕微内核的第一个循环。我们可以看到,当我们将矩阵A一行的所有元素乘以矩阵B的一列时,围绕微内核的第一个循环会遍历矩阵A的所有行。
这意味着我们正在重复使用矩阵B的瓦片与矩阵A的所有这些行。
由于我们一直在重复使用B瓦片,这是最频繁访问的数据。因此它们极有可能位于L1缓存中。
这为我们提供了如何估算B瓦片大小的信息:基本上,整个B瓦片必须能放入L1缓存。
如果我们看围绕微内核的第二个循环,现在我们使用矩阵A的整个块与矩阵B的多个瓦片。所以现在我们正在重复使用矩阵A的整个块。
因此,这将是下一个最频繁访问的数据。应用相同的逻辑,这类块将进入L2缓存。现在根据L2缓存的大小,我们可以估算矩阵A块的大小。
遵循这个逻辑,根据内存层次结构,我们可以为不同的内存层次估算不同块的瓦片大小。
这将如何影响我们的实现?现在,我们不再只有三个最外层循环,而是有更多循环来利用所有可用的内存层次。不是三个,现在大概有五个。
最外层的循环将在块上迭代。然后在块内部,我们在瓦片上迭代,接着在瓦片内部,我们基本上在内核中进行矩阵乘法。
这里我使用NC、MC和KC来表示缓存块的大小,使用NR和MR来表示寄存器块的大小。
这如何影响我们的内核实现?我们基本上不再使用固定大小的块,而是使用根据内存层次结构图像计算出的尺寸。
让我们检查一下汇编输出。现在我们可以看到,当我们开始使用这些分块技术并应用内存层次结构时,汇编代码也发生了变化。
与之前的实现相比,现在我们不再利用所有可用的寄存器了。这是一个例子。
但如果我们测量性能,仍然能看到一些改进,比之前的实现快1.16倍。
7:寄存器优化
但我们知道可以做得更好。让我们给编译器一个提示,告诉它我们有多少个CPU寄存器,并尝试全部利用。
我们如何做到这一点?我的做法是,手动创建了多个C寄存器、B寄存器和A寄存器。
在我的具体案例中,总共有16个寄存器。所以我为C元素分配了12个寄存器,为B元素分配了3个寄存器,为A元素分配了1个寄存器。
这个数字基本上是我从之前看到的汇编实现中得来的。
现在我们看到了更好的情况。我们的实现使用了所有可用的SIMD寄存器。
这对性能有何影响?这是我们使用所有可用寄存器的例子,我们看到性能几乎比之前的实现翻了一倍。
8:多线程优化
下一个显而易见的优化是多线程。我们有多核,为什么还不利用它们呢?
对于这个特定的CPU,我尝试了不同的方法来并行化循环。我尝试在不同级别并行化多个循环,比如顶层和内部循环。我尝试将特定线程绑定到特定核心,使用了不同的技术。
我尝试的一切都给了我几乎完全相同的结果。我没有看到任何性能改进。
所以对于这个特定情况,我决定采用简单的方法:我只是在i索引上分割矩阵C,每个线程处理这个矩阵的几行。
对于我的四核CPU,与之前的实现相比,性能提高了三倍。
9:瓦片矩阵布局优化
下一个优化是瓦片矩阵布局。如果我们看一下我们的内核,会注意到我们正在以巨大的步长迭代矩阵A和B。
我们都知道这并不好。我们能否只用步长等于1来做到这一点?是的,我们可以。
我们如何做到这一点?在那张图片中,图像基本上向我们展示了矩阵A的数据访问模式。
我所做的是:我没有在内核中使用这种数据访问模式来访问元素,而是将其移到更高级别,移到内核之外,并使用这种数据访问模式将数据复制到缓冲区中。
然后这个缓冲区进入我们的内核。所以我们的内核现在在迭代不同元素时不需要做跨步访问,访问将是顺序的。
实现会略有不同。现在,我们不再有这些巨大的步长,而是有等于寄存器块大小的步长,这些数字对于NR=12和MR=4来说相当低。
编译器很乐意为我们展开这些循环。我们可以看到,我们基本上不再有这些内层循环了,我们只有一个遍历k索引的循环。
这个特定的优化给了我们大约1.17倍的加速。
10:使用C++标准库SIMD
我们已经使用了FMA指令。但在C++26中,我们有了std::simd库。
所以我决定看看如果我使用这个库会发生什么。
这就是使用std::simd库的内核的样子。最神奇的部分是,它直接就能工作。
我只是替换了所有这些吓人的内部函数,使用了相同的数据类型,并像常规操作一样使用加法和乘法。这是零开销抽象。性能保持不变,而且直接能用。我真的很喜欢它。
11:循环展开优化
下一个优化是循环展开。让我们看看我们内核实现的最内层循环。
我们刚刚看到编译器为我们展开了循环。但我想手动操作,看看是否会影响性能。
在左侧,我们看到原始实现。在右侧,我们看到展开后的部分。
当我们手动进行循环展开时,存在一个问题:我们不再有灵活性来改变我们拥有的寄存器数量。
例如,如果我改变常量NR,它基本上告诉我们需要使用多少个B寄存器。它不再灵活了。我们不能仅仅通过改变常量就自动生成要使用的B寄存器数量。
所以我们需要基于这个常量生成代码。有一种方法可以生成这样的代码。
不要通过手动编写代码来进行手动展开。我使用了C++的折叠表达式和内置逗号运算符来生成展开的循环。
它是如何工作的?想象我们有一个简单的打印函数,我们想调用它四次。
我们可以实现一个新函数call_print_n_times,使用可变参数模板和索引序列来展开调用所需的次数。
在我的具体案例中,我传递了四个数字。这并不方便,尤其是如果你不只是四个数字,而是三十个或更多。
所以我们可以使用C++标准库中的一些辅助工具,比如make_index_sequence,它可以为我们生成这个序列。
让我们使用这个技巧来为我们的内核实现生成最内层循环。
这就是计算内核函数的样子。我们有一个索引包I,它基本上展开了i索引上的循环。参数包J展开了j索引上的循环。
在那个特定情况下,我们基本上为B生成了一些寄存器,然后为计算函数生成了一些调用。计算函数也使用了这些技术,并基本上为我们执行矩阵乘法,将矩阵A的元素与矩阵B的元素相乘。
这是API的样子,有点冗长。我们可以通过提供函数重载来稍微简化它。
我跳过了这一步,在实现细节中使用make_index_sequence函数作为计算内核。
函数的最终版本将看起来像那样。我们只需将寄存器瓦片大小传递给计算内核函数,它将为我们生成展开的内核。
我们将对将寄存器C中的元素存储到矩阵C使用相同的技巧。
我们创建一个store_row函数,它基本上执行多次加载和存储操作。
我们创建一个store_kernel函数,它多次执行store_row函数。
这就是store_kernel函数的调用方式。最终的内核实现,为我们手动进行循环展开,看起来像那样。
好处之一是我们自动支持所有其他类型,它易于定制。我们可以通过传递不同的编译时常量参数来生成具有不同参数的不同内核。
如果我们比较之前实现生成的汇编代码与我们手动展开后看到的汇编代码,它们看起来几乎一样,除了一条指令:广播指令。
在展开后,这是循环中的第一条指令。在手动展开中,这是循环中的第四条指令。
这有区别吗?有。它略微提高了性能,大约1.02倍。
但我仍然喜欢看到改进。很难理解编译器是如何对汇编指令进行重排序的。甚至更难去影响它。
12:预取优化
下一个优化是预取。这个是最难论证的。基本上,除了硬件预取器,我们还有一种方法来执行命令,将数据从内存预取到缓存。
我做了很多实验。我得到了很多相互矛盾的结果,但至少我注意到预取矩阵C的元素会带来略微更好的性能。
最终结果给我的性能略微更好,大约比之前的实现快1.07倍。
13:与OpenBLAS比较及性能分析挑战
我打算将我的结果与OpenBLAS实现进行比较。
当我执行OpenBLAS实现时,它比我的更好。它甚至比我的实现快大约1.08倍。所以还有改进的空间。
当我们达到或接近峰值性能时,分析性能本身变得困难。有几个原因。
例如,再分析C++代码已经没有意义了。所以在最后,大部分时间我只是检查汇编语言的输出。
分析指令的缓慢程度不会像分析C++函数那样得出相同的结论。当我们看到计算中某个特定指令存在瓶颈时,有时改变数据布局的修复方法可能有效,但有时我们无能为力。

例如,在那个特定情况下,我们在FMA指令上遇到了瓶颈。但我们提高性能的方式,只是应用了分块优化技术。
指令级并行性超级难以利用。就像当我进行手动循环展开时,我们注意到一条指令在我们内层循环中的位置发生了变化。
但是如何给编译器提示以进行最佳的指令重排序,这很难理解。
此外,我注意到一个有趣的案例,我们可以看到,在那个特定情况下,在汇编中使用的寄存器的改变会影响性能。
我相信这是一个硬件错误,当你使用R6寄存器时,CPU无法利用寄存器重命名优化技术。
当那个人将RCX指令改为R6时,它比使用RC寄存器带来了略微更好的性能。这种修复我们无法在C++中完成。
数据分析也不是那么直接。通常当我使用一些工具时,我使用Perf和VTune来分析性能。这是VTune的结果。
在左侧,我们看到我的实现。在右侧,是OpenBLAS。大多数数字对OpenBLAS实现更好。
所以我们基本上可以得出结论,OpenBLAS实现应该更快。但当我使用Perf时,例如,就不那么明显了。
比如当我们比较最后一级缓存未命中时,我的利用率比OpenBLAS更好。但当我们比较每周期指令数利用率时,OpenBLAS做得更好。
所以仅仅看这些数字很难说哪个实现在执行时间上会更好。
然后,当我们使用Perf分析指令的缓慢程度时,在左侧我们看到很多红色指令,这基本上是我们的瓶颈,我们大部分时间都花在那里。
在右侧,情况好多了,我们没有这些红线了。我所做的只是展开了我之前没有动过的K循环。看起来应该更好,但实际上性能保持不变。我们只是从眼前隐藏了这个画面。
我得到的最矛盾的例子是这个。在左侧,我们看到一个实现,与右侧的实现相比,它具有更好的特性。
而右侧是OpenBLAS实现。那么我左侧的实现用了什么?你永远不会相信,它只是使用了SIMD指令的实现,利用了128位指令。它比OpenBLAS慢得多,但VTune向我展示的微架构使用率和所有这些数字都比OpenBLAS实现更好,但实际结果,比如执行时间,OpenBLAS要好得多。这就是我得到的结果。
14:C++的有效性及现代特性
那么C++对于矩阵乘法有多有效?当我研究这个主题时,我从不同的库中学到了很多。我们有Eigen、Blaze。我也从不同的文章中学到了很多,人们为GPU实现矩阵乘法。
我真的很喜欢我们有零成本抽象,比如模板、常量表达式,我稍后会展示它们实际上如何使我们受益。
地平线上有更多力量。我演示了std::simd有多好,技术如零成本抽象完美地工作。
我现在正在尝试std::mdspan。我真正喜欢它的一点是,现在当我进行矩阵重排、改变布局时,我基本上可以在编译时检查矩阵维度。
我真的很喜欢它,因为当我实现矩阵乘法时,有时你会感到头疼,开始忘记一些东西,你希望编译器能提供帮助,而它确实帮助了我。
std::execution可以帮助处理多线程的不同策略,我稍后也会提及。对于那些不想自己实现矩阵乘法的人,我们将有std::linalg。
C++拥有一个强大而活跃的社区。在上次CPP Con上,我学到了很多有用的信息,帮助我准备了这次演讲。
15:汇编的挑战与跨平台测试
但我观察到的所有内核都是用汇编语言编写的。我有点理解人们为什么这样做。
他们可能不想再与编译器斗争,尤其是当你需要重排序指令时。他们不想依赖特定的编译器或版本,因为性能可能取决于编译器。
当我用GCC编译我所有的实现,以及用Clang编译我所有的实现时,我看到某些实现有巨大的差异。
但最终高度优化的版本在Clang和GCC上的表现相同,所以没有区别。
硬件错误,当你需要使用R6寄存器而不是RSX寄存器时,你无法在C++中做到这一点。所以你需要使用汇编来进行这类修复。
正如我提到的,对于一些实现,对于未完全优化的矩阵乘法,性能差异高达40%。但这并没有回答C++对于矩阵乘法有多有效的问题。
16:在新硬件上的测试与新数据类型
我最近买了一台新的桌面工作站。它是AMD Zen 5微架构。
所以我决定检查我的实现在现代CPU上的表现。这是我们的峰值性能计算,使用了最开始提供的相同公式。我得到了2.9 TFlops的峰值性能。
但我读到如果使用AVX-512重负载,CPU会降频到3.8 GHz。因此,最大性能,我们可以使用3.8 GHz来估算这个特定情况下的峰值性能,它将是1.95 TFlops。
当我执行OpenBLAS实现与我的实现比较时,我非常惊讶。我的实现在双精度上实际上击败了OpenBLAS。我做了什么?
我调整了瓦片大小,因为我的新CPU有不同的缓存大小和寄存器数量。所以我必须重新计算所有这些参数。
我还改变了多线程策略。所以不是只按行分割,我开始按块分割,这也会在性能方面带来一些改进。
在我的特定系统中,有32个线程。所以我把矩阵分割成4x8的网格。可能应该设置成32,但CPU实际上只有16个核心。
这个实现仍然没有考虑到每个核心有两个独立的线程。所以这个实现可以通过利用这个信息来改进。
我之前所有的实验都是用双精度进行的。但由于模板,我们免费获得了单精度支持。
我们可以看到OpenBLAS实现与我的性能相同。所以这里几乎没有区别。
但在C++23中,我们有bfloat16。由于我们免费获得了类型支持,我决定检查我的矩阵乘法如何与新的bfloat16类型一起工作,这种类型在大语言模型和GPU计算中经常使用。
编译器在我使用bfloat16时无法高效生成汇编。所以我们有针对这种数据类型的特殊指令集,编译器没有使用。
所以我基本上为我的内核创建了一些特化,只是为了利用这些指令。当编译器将来能做这项工作时,这个模板特化基本上可以被移除。所以这是一个临时解决方案。
不幸的是,我无法与OpenBLAS比较,因为OpenBLAS目前不支持bfloat16。所以我得到了3.09 TFlops的性能。
它比单精度低,但我们基本上是免费获得的。我什么都没做,只是用bfloat16代替了double和float。
17:总结与结论
当我与Zen 5上的矩阵乘法性能比较时,我注意到我们基本上处于同一水平。所以我相信C++可以在矩阵乘法上提供汇编级别的性能。
我相信我的实现并不是最好的。我仍然认为它可以更好。
这就是我所有的内容。非常感谢大家的关注。如果你们有任何问题,请随时提问。
总结
在本教程中,我们一起学习了如何对一个基础的矩阵乘法算法进行深度优化。我们从最朴素的三重循环实现开始,逐步应用了循环重排、分块、SIMD向量化、缓存感知设计、寄存器优化、多线程并行、数据布局调整、循环展开和硬件预取等一系列关键技术。整个过程揭示了高性能计算的核心思想:充分利用内存层次结构和最大化硬件并行能力。
我们看到了C++在实现这些优化时的强大能力,特别是模板、常量表达式和std::simd 等特性提供的零开销抽象。同时,我们也认识到在追求极致性能时,有时需要深入汇编层面进行微调,并且性能分析工具(如Perf, VTune)的结果需要结合实际执行时间谨慎解读。


最终,通过系统性的优化,我们能够将一个最初需要上百秒的运算,加速到接近硬件理论峰值性能的水平。这证明了通过深入理解计算机体系结构并精心编写代码,C++完全有能力实现与手写汇编相媲美的高性能计算核心。
006:设计模式与最佳实践 🧠




在本节课中,我们将学习C++应用程序设计中的核心第一性原理,重点探讨关键的设计模式、类设计原则以及性能优化技巧。这些原则对于构建可靠、可维护且高效的软件至关重要,尤其是在安全关键领域。
设计模式 🧩
设计模式是解决常见软件设计问题的可复用方案。上一节我们介绍了课程概述,本节中我们来看看几个关键的设计模式及其应用。
访问者模式
当需要为对象结构添加新的操作,而不想修改这些对象的类时,访问者模式非常有用。它通过将操作与对象结构分离来实现。
以下是访问者模式的经典实现方式,它使用了运行时多态(虚函数)和双重分派:
class ShapeVisitor; // 前向声明
class Shape {
public:
virtual void accept(ShapeVisitor& visitor) = 0;
// ... 其他成员
};
class Rotate : public ShapeVisitor {
public:
void visit(Shape& s) override {
// 实现旋转逻辑
}
};
现代C++(C++17及以上)提供了更优雅的实现方式,使用 std::variant 和 std::visit,这通常比经典实现性能更高,耦合度更低。
using ShapeVariant = std::variant<Circle, Square>;
ShapeVariant shape = Circle{};
std::visit([](auto& s) {
// 对形状s进行操作
}, shape);
策略模式
策略模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换。它让算法的变化独立于使用算法的客户。
考虑一个计算飞机起飞重量的场景。不同的飞机类型(如支线客机、长途客机)有不同的计算策略。错误的设计是将计算逻辑与飞机类紧密耦合。
// 错误示例:计算逻辑与类深度耦合
class Aircraft {
public:
virtual double calculateTakeoffWeight() = 0; // 纯虚函数却有实现
};
class RegionalJet : public Aircraft {
double calculateTakeoffWeight() override {
// 调用基类实现?逻辑混乱
return Aircraft::calculateTakeoffWeight();
}
};
正确的做法是使用策略模式,将计算算法提取到独立的类层次结构中。
class TakeoffWeightStrategy {
public:
virtual double calculate() = 0;
};
class RegionalWeightStrategy : public TakeoffWeightStrategy {
double calculate() override { /* 支线客机策略 */ }
};
class RegionalJet {
std::unique_ptr<TakeoffWeightStrategy> strategy;
public:
double getTakeoffWeight() { return strategy->calculate(); }
};
关键原则是:优先使用组合而非继承。不要为了代码复用而盲目使用继承。
命令模式
命令模式将请求封装为一个对象,从而使你可以用不同的请求对客户进行参数化,支持请求的排队、记录日志以及撤销操作。
考虑一个多视图显示的医疗成像系统。每个视图(如后视图、侧视图)需要在单独的线程中渲染。错误的设计是将线程管理逻辑和显示逻辑混合在一起。
// 错误示例:违反单一职责原则
class PosteriorDisplay {
void display() {
std::thread t([this](){ /* 显示逻辑 */ }); // 线程管理与业务逻辑耦合
t.join();
}
};
命令模式通过分离“执行什么”和“如何执行”来解决这个问题。线程作为工作者,只负责执行命令,而不需要知道命令的具体细节。
class Command {
public:
virtual void execute() = 0;
};
class PosteriorDisplayCommand : public Command {
void execute() override { /* 纯显示逻辑 */ }
};
// 线程管理部分
std::unique_ptr<Command> cmd = std::make_unique<PosteriorDisplayCommand>();
workerThread.schedule(std::move(cmd));
当对象不需要为了存在而了解其将要执行的操作细节时,考虑使用命令模式。
适配器模式
适配器模式用于连接两个不兼容的接口,使其能够协同工作。它是一种结构型设计模式。
例如,在医疗成像系统中,可能需要将第三方图像格式的接口适配到标准的DICOM图像接口。
class DICOMImage {
public:
virtual void storeImage() = 0;
};
class ThirdPartyImage {
public:
void writeToDisk(); // 不兼容的接口
};
// 适配器类
class ProprietaryDICOMAdapter : public DICOMImage {
ThirdPartyImage tpImage;
public:
void storeImage() override {
tpImage.writeToDisk(); // 调用第三方实现
}
};
优先使用对象适配器(基于组合),而非类适配器(基于继承),以获得更大的灵活性。
观察者模式
观察者模式定义了一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知并自动更新。
在端点管理系统中,端点管理器需要观察众多设备(端点)的状态。有两种主要类型:
- 推模式观察者:被观察者主动将事件推送给观察者。耦合度低,但观察者需要过滤大量事件。
- 拉模式观察者:观察者主动从被观察者拉取特定状态信息。耦合度较高,但能获取精确信息。
现代C++可以使用 std::function、std::shared_ptr 和 std::weak_ptr 来实现回调机制,协程也可用于管理异步事件。
桥接模式
桥接模式将抽象部分与它的实现部分分离,使它们都可以独立地变化。它是比Pimpl惯用法更一般化的形式。
考虑飞机速度计算器的问题。不同的航空管理机构(如FAA, JAA)有不同的计算规则。错误的设计是在飞机类中直接包含具体计算器的物理依赖。
// 错误示例:头文件暴露了具体实现依赖
// Aircraft.h
#include “FAASpeedCalculator.h” // 物理依赖!
class Aircraft {
FAASpeedCalculator calc; // 具体实现
};
桥接模式通过一个指向实现的指针来隐藏这些细节。
// Aircraft.h
class SpeedCalculator; // 前向声明,不暴露细节
class Aircraft {
std::unique_ptr<SpeedCalculator> speedCalc; // 桥接
public:
double getSpeed();
};
// Aircraft.cpp
#include “FAASpeedCalculator.h” // 实现细节隐藏在.cpp文件中
Aircraft::Aircraft() : speedCalc(std::make_unique<FAASpeedCalculator>()) {}
这消除了物理依赖,缩短了编译时间,并允许抽象和实现独立演化。当你需要将抽象与实现解耦时,考虑使用桥接模式。
其他模式与高级主题 ⚙️
上一节我们探讨了几个核心设计模式,本节中我们来看看一些高级主题和替代方案。
CRTP与概念
虚函数会带来运行时开销。奇异递归模板模式(CRTP)使用编译时多态来替代运行时多态。
template <typename Derived>
class Base {
public:
void interface() {
static_cast<Derived*>(this)->implementation(); // 编译时分派
}
};
class Derived : public Base<Derived> {
void implementation() { /* ... */ }
};
C++20的概念(Concepts)可以定义一组类型的需求,有可能在某些场景下替代CRTP。
关于继承的思考
继承是C++中最紧密的耦合关系之一(仅次于友元)。著名演讲者Sean Parent曾说过:“继承是万恶之源”。在决定使用继承前,请三思是否真的需要“是一个(is-a)”的关系,还是“有一个(has-a)”的关系(组合)更合适。类型擦除模式是另一种强大的替代方案。
类设计的一般技巧 🛠️
良好的类设计是稳健软件的基石。本节我们来看看一些通用的类设计原则。
非虚接口(NVI)惯用法
公共接口应该稳定,而实现细节可以变化。非虚接口惯用法建议将公共函数设为非虚的,并让它调用一个私有的或受保护的虚函数来完成实际工作。
class Aircraft {
public:
// 稳定的、非虚的公共接口
double getTakeoffWeight() {
return doGetTakeoffWeight(); // 调用私有虚函数
}
private:
// 可定制的实现细节
virtual double doGetTakeoffWeight() = 0;
};
这分离了公共接口和定制接口,两者可以独立演化而不破坏客户端代码。
Pimpl惯用法
Pimpl(Pointer to Implementation)是桥接模式的一个特例,用于隐藏类的实现细节,减少编译依赖。
// Widget.h
class Widget {
struct Impl; // 前向声明实现类
std::unique_ptr<Impl> pImpl;
public:
Widget();
~Widget(); // 需要显式定义析构函数
void publicMethod();
};
这能显著缩短编译时间,并避免名称污染。
非成员非友元函数
尽可能使用非成员非友元函数。这能促进更好的封装,因为它无法访问类的私有成员,迫使你编写更通用的、基于公共接口的代码,同时有助于避免产生庞大的“上帝类”。
单一职责与最小化接口
一个类应该只有一个引起变化的原因。std::string 是一个反例,它拥有超过100个成员函数,复制了许多标准算法,提供了多种访问方式(下标和迭代器),导致接口臃肿。最小化的类更易于理解、编译、维护和部署。
避免向下转型
使用 dynamic_cast 进行向下转型通常意味着基类抽象不完整,设计存在缺陷。这会使代码变得脆弱。应该更多地依赖编译时类型系统和多态,与编译器协同工作。
// 不佳的设计
Base* ptr = getObject();
if (auto* derived = dynamic_cast<Derived*>(ptr)) {
derived->specialFunction(); // 基类没有此接口
}
性能与资源管理 ⚡
在保证了设计的正确性之后,我们还需要关注性能和资源的正确管理。
算法复杂度
避免使用O(n²)或指数级的算法。了解并选择合适复杂度的数据结构:
- O(log n):
std::set,std::map的查找操作。 - O(n):
std::vector的线性查找,std::for_each。 - O(1):
std::unordered_map的平均情况查找,std::vector::push_back(摊还)。
使用重载避免隐式转换
隐式转换会创建临时对象。提供必要的重载以避免之。
void process(const std::string& s);
void process(const char* s); // 提供重载,避免从 `const char*` 到 `std::string` 的隐式转换
process(“hello”); // 现在直接调用第二个重载
首选 std::vector
对于序列容器,std::vector 在大多数情况下都是最佳选择,因为它具有最低的空间开销、最快的遍历速度以及几乎最快的迭代器。
范围操作优于循环内单次操作
对于插入等操作,使用范围版本(如 insert(end(), beginOther, endOther))通常比在循环中单次插入更高效,因为前者允许编译器进行更好的优化。
资源获取即初始化(RAII)
利用RAII管理资源生命周期:在构造函数中获取资源,在析构函数中释放。使用智能指针(std::unique_ptr, std::shared_ptr)、锁守卫(std::lock_guard)等。
注意构造函数的初始化顺序。在一条语句中初始化多个成员时,如果某个构造函数抛出异常,可能导致资源泄漏。最好将每个资源的初始化放在独立的语句中,并立即交给其所有者(如智能指针)。
// 可能有问题
auto p1 = std::make_shared<Port>(“A”);
auto p2 = std::make_shared<Port>(“B”); // 如果“B”构造失败,p1已创建,但整体对象可能未完全构造
// 更安全:分别初始化
始终使用智能指针。绝不混用 malloc/free 和 new/delete。
关于全局对象与静态对象的忠告 ⚠️
全局和静态对象会增加耦合度,并可能引发“静态初始化顺序灾难”(Static Initialization Order Fiasco)。
// Logger.h
extern Logger globalLogger; // 在某个翻译单元定义
// Network.cpp
void initNetwork() {
globalLogger.log(“Initializing”); // 如果globalLogger尚未初始化,则崩溃
}
解决方案包括使用“首次使用惯用法”(在函数内返回局部静态对象的引用)或迁移到C++20模块(模块初始化顺序有明确规定)。如果必须使用,请小心初始化,并在多线程环境下使用适当的同步原语避免数据竞争。
其他重要提示 💡
不要重载逻辑运算符
重载 && 和 || 会失去短路求值特性,并且函数参数的求值顺序是未指定的,可能导致非预期行为。
bool operator&&(const A& a, const B& b);
if (a.isValid() && b.isReady()) { // 重载后,a.isValid()和b.isReady()的求值顺序不确定
// ...
}
函数参数求值顺序
即使C++17规定了函数实参的求值顺序,但为了代码清晰和可移植性,不要编写依赖于特定求值顺序的代码。
int i = 0;
foo(i++, i++); // i的值?结果是未指定的行为
避免对非平凡数据类型使用 memcpy
memcpy 不调用构造函数、析构函数,也不处理虚函数表。对非平凡数据类型(如含有虚函数或复杂成员的对象)使用 memcpy 会导致未定义行为。
使用最高警告级别编译
将编译器警告视为错误。如果必须禁用第三方库的警告,请使用 #pragma 指令局部禁用,并确保你理解被禁用警告的含义。
总结 📝
本节课中我们一起学习了C++应用程序设计的核心第一性原理。
我们探讨了多个关键设计模式:
- 访问者模式:用于添加新操作。
- 策略模式:用于封装可互换的算法。
- 命令模式:用于将请求封装为对象。
- 适配器模式:用于连接不兼容的接口。
- 观察者模式:用于一对多的依赖通知。
- 桥接模式:用于分离抽象与实现。
我们还回顾了重要的类设计原则,如非虚接口(NVI)、Pimpl惯用法、优先使用非成员非友元函数、坚持单一职责原则以及设计最小化接口。
在性能方面,我们强调了选择正确算法复杂度、使用 std::vector、利用RAII进行资源管理以及避免隐式转换的重要性。
最后,我们讨论了关于全局对象、运算符重载、求值顺序和编译器警告的实用建议。




记住Gordon Bell的格言:“计算机系统中最便宜、最快、最可靠的组件是那些根本不存在的组件。” 这意味着:只编写必要的代码。始终将清晰性、可测试性和单一职责原则作为指导方针,避免过早优化,并最大限度地减少重复。在拥抱C++最新特性的同时,请记住,良好的设计是优秀软件的根基,新特性无法修复糟糕的设计。首先把设计做对。
007:实战案例与解决方案



概述
在本节课中,我们将学习如何诊断和修复一个由栈内存损坏引发的C++程序崩溃问题。我们将跟随一个真实案例,了解栈损坏的隐蔽性,并学习一种名为“影子栈”的实用技术来定位和解决此类问题。
章节 1:问题初现
亲临现场参加会议的好处之一是,公司会在这里寻找世界上最优秀的人才。无论是职业发展、建立人脉还是提升技术技能,这些都无法仅通过视频获得。

大家早上好。希望你们享受这次会议,希望你们有机会在来的路上喝杯咖啡、茶或其他喜欢的饮料。

我的名字是Bartosz Moczulski。我来这里是为了谈论影子栈。但你们可能从我的名字和姓氏推断出,我来自波兰。在几次场合中,我注意到我的名字可能会让世界各地的英语使用者感到困惑。所以我将快速为你们分解一下。

想象一下我的名字末尾是H而不是Z,因为我们在波兰语中就是这样做的。我们用S加Z在英语中相当于S加H,但发音完全相同。所以我的名字是Bartosh。如果你们的一些祖先是海盗或维京人,你们甚至更有机会将R的发音更接近波兰语的发音。但没关系,我认为这样就可以了。如果你们想向我提问,以后可能会用到。
如果我们继续并应用同样的技巧到我的姓氏上。再加一个。为什么它不继续?好了。同样,我们用H替换Z,用双O替换U,因为波兰语从未经历过元音大推移。姓氏听起来像Moczulski。这就是我。这是波兰语音学101。谢谢参加。是的,所以解散了。当然,你们是来听影子栈的,你们会听到的。在我开始之前,我需要问你们一个问题。你们中有多少人家里有电视机?好的,几乎所有人。这正是我期望的。那么,你们有时会用它来看流媒体服务吗,比如Netflix或Hulu之类的?仍然,几乎所有人,很好。
我很高兴听到这个,因为我就是那个在幕后不知疲倦地工作,让你们的电视播放正常工作的人,这样你们就可以在舒适的沙发上享受电视了。这就是我的工作。在过去的20年左右,我一直参与数字电视行业,主要专注于为机顶盒制作固件,这些是连接到电视的小设备。它们必须接收来自地面、卫星或运营商的信号。
如果我对视频播放有一件事了解,那就是机顶盒和其他嵌入式设备。那就是,啊,很困惑。为什么它就这样工作了。啊啊。真可惜。是的,它会无缘无故地崩溃,就像我现在的演示一样。谁知道呢?好了,是的。就像这样崩溃。所以我的演讲实际上是关于我遇到并必须分析和修复的这样一个案例。我现在就要告诉你们所有关于它的事情。是的,所以修复错误。修复错误很容易,对吧,我们有一个通用的方法。首先你需要遇到一个错误,然后你接受它,最后,你希望可以消除它。啊。因为在你发现问题发生之前,在它表现出来并被你观察和记录之前,它并没有坏。只是不要碰它。
在你真正注意到有问题之后,你开始分析,开始收集证据,并开始思考发生了什么以及如何发生的。当你完成分析时,你很有可能相当快地修复那个错误,大多数时候是这样。然而,并非总是如此,因为内存损坏。我想知道你们中有多少人在职业生涯中不得不与内存损坏作斗争。几乎所有人。当然,否则你们就不会在这里了。是的,它是否恰好是栈损坏?如果,最后几只举手了,是的,因为堆损坏比栈损坏更常见。是的,我也是,事实上,在我的职业生涯中,我处理过各种各样的错误,不仅是内存损坏,还有多线程问题、逻辑错误、资源泄漏,应有尽有。集成等等,因为坦率地说,每个软件在编写时都有错误。在IT和软件开发的历史上,将只有两个软件是无错误的。那就是TeX和Metafont。如果你们知道为什么。我想我们都希望它们保持无错误的时间尽可能长。啊。是的。
所以在实践中,我经常扮演Linux侦探的角色。这有两个原因。第一个是Linux是嵌入式设备(如机顶盒)的主要开发环境。第二个是,虽然我有时有机会编写一些代码,有时是新代码,有时是扩展现有功能。但更多时候,我需要调查问题并修复它们。而且我经常成功。这就是为什么我自称为侦探。
在我们深入之前,我想强调一下你们从这次演讲中的收获。我将它们分为三类。第一类只是娱乐。所以也许,只是也许你认为内存损坏与你无关。你已经将所有原始数组替换为STL数组,并且到处使用std::span,问题就解决了。公平合理。我会接受。坐下来,带上你的爆米花,享受这个演讲。看着我们其他人受折磨总是很有趣,只要不是你自己。是的,但现实地讲,我们需要意识到这些问题,因为它们不会消失。这些就像是计算机科学的基石。所以如果,如果你们今天在演讲结束时还不知道我将要描述的机制,那么你们就会知道了,这是我的成功,也是你们的巨大成功。所以意识已经是向前迈出的一大步。
但显然,你们不会止步于此,对吧?所以你们来这里是为了学习这些事情为什么会发生,如何发生,以及显然,如何预防和修复它们。这就是我希望帮助你们的地方。一个澄清,我谈论的是栈。显然,不是那个栈。虽然不是那个栈,尽管这是一个很棒的库,非常有用。然而,这不是演讲的主题。我将谈论CPU栈,当然。这是你的局部变量存放的地方,也是CPU用来调用子程序的地方。
章节 2:栈损坏的挑战
他们说,遇到问题是处理错误的第一步。确实,事情就这样发生了。有一天,我在Slack上收到同事的消息,他是一位经验丰富的高级工程师。他说了类似这样的话。看,Bartosz,关于你们团队一直在开发的那个新的流媒体应用程序,我们即将推出。我说,是的,怎么了?你知道。它崩溃了。我说,不可能,我们过去几周一直在彻底测试它。我的意思是,我们没有注意到任何问题。他继续说,是的,但只有当你观看直播频道时它才会崩溃。我的意思是,公平合理,但是,是的,这已经测试过了,没有人报告任何问题。但他坚持。他说,你知道,但它只是偶尔为我崩溃,比如一天最多一次,最多两次,不会更多。好的。这变得有趣了。显然,我们已经测试过了。是的,我们进行了严格的测试,比如持续很多小时。所以我想我们应该注意到了。但他过来说它崩溃了。他继续并给了我最后一点细节。它只在体育频道、直播体育频道上崩溃。所以你可以想象,如果你喜欢体育,想象一下,例如,观看足球。也许是FIFA世界杯决赛。你的应用程序在比赛中间崩溃了。哦,是的,你可以,你可以导航到主菜单并重新启动它,再次找到这个传输并开始播放。它甚至继续播放。足够好了。但你失去了比赛宝贵的一分钟。所以对于那些对此没有共鸣的人,也许你们,你们关注另一种足球,那种用手玩的,因为显然,是的,你是超级碗。在中间,它崩溃了。你们明白了吧,对吧?在直播体育赛事中间崩溃的流媒体应用程序,会导致很多愤怒的客户。这是一个很大的禁忌。所以在那一刻,我已经知道我将不得不调查它。
好了,所以接受它。系好安全带,我的那位同事,作为高级工程师并在家里使用我们的产品,他有一个机顶盒的开发版本。所以他是唯一遇到这个问题的人。幸运的是,他能够为我抓取一个核心转储,或者可能两个。实际上,三个,一个没用,另外两个有用。
所以我像每个人一样在GDB中打开了核心转储。我能看到什么?它在pthread_mutex_unlock中崩溃。现在,我足够聪明,你们肯定也足够聪明,知道pthread_mutex_unlock是坚如磐石的。它不可能坏,因为如果它坏了,我们甚至无法启动应用程序。它在那之前会被调用数百万次,任何问题都会更早出现。所以不是pthread_mutex_unlock的问题。可能是调用它的函数,对吧,do_stuff。为了这次演讲,我这样称呼它。
你们能在这张幻灯片上注意到任何可疑之处吗?是的。这个演讲的对象。0,是的,所以另一个0,0和0,3。好的,有什么问题?它们靠得很近。正确。Lyman女士,我听到Lyman先生,是的,正确。它们没有对齐。好的,已经可疑了,是的。零,零通常是。它可能是一个栈地址。是的,所以评论是,以0,0开头的东西很可能不是栈地址,而FFF应该是。是的,这也是正确的。
所以我们清楚地看到传递给pthread_mutex_unlock的地址是不正确的。好的,我的意思是,这会发生。我们有时会犯错误。所以我导航到do_stuff函数。
这是我看到的。它就像一个调用其他函数的函数。这是一个陈词滥调。你看,你收到一个指向某个结构的指针。在其中,有一个互斥锁,你锁定它,在临界区内做其他事情,然后解锁它。这里没有问题。是的,显然,互斥锁在某个偏移量处。我把它放在一个字的偏移量处。所以根据架构是4或8字节。好的。我的意思是,这里没有问题。那么,什么可能或应该出错了?do_stuff_locked函数,这里的第一个,它工作了,因为我们最终崩溃了。抱歉,我们最终崩溃了。在底部这里,不是在顶部。所以第一行的偏移量计算正确。第二行则没有。如何以及为什么?也许,也许编译器犯了一个错误。公平合理。让我们看看。我真的很抱歉用这个反汇编折磨你们。我希望它不会让你们得红眼病。请不要从头到尾阅读它,我们稍后会逐行讲解。但你们至少能认出这里的架构吗?Arm,我能听到arm。是的,它是arm,具体是arm 64位。由此我们可以推断,传递给P函数的这个指针应该偏移8,而不是4。但它偏移了3。这很奇怪。所以arm,arm很重要。我本可以在Intel x86上展示,对吧,但arm确实重要。为了向你们证明,我将使用这个小工具。你们今天有多少人带了手机?每个人,对吧?相比之下,你们有多少人有笔记本电脑,但特别是带有Intel x86的?可能一半一半,好的。据我所知,世界上arm比x86多得多。这就是我在这个汇编中展示的。这就是为什么我要谈论arm架构,对吧,我们已经讲过了。所以arm,如果你们不知道,或者只是作为回顾,arm上的调用约定。在arm上有32个寄存器,或多或少,加上一些其他特殊的,但核心的是32个,它们都是可互换的。你可以使用其中任何一个,除了你不能,因为你知道,所有寄存器都是平等的,但有些寄存器比其他寄存器更平等。所以寄存器编号31,你永远不会在汇编中看到31。你总是会看到SP,代表栈指针。对于栈损坏非常有用。另外两个,30和29用于调用子程序。所以如果你的函数调用了另一个函数,这些寄存器需要被使用。这,这不像非常重要,但它是一个经典的函数调用帧。所以在顶部,栈帧被设置。这些特殊的寄存器29和30最终被放在栈上。在Intel上,它看起来像push和pop。实际上在arm这里,它是等效的。
继续看更有趣的寄存器。19到28。这些有一个特殊的属性,必须被保存。所以被调用的函数不能改变这些寄存器的值。我的意思是,它可以在内部使用它们,但必须在返回前恢复传入的值。这很重要。这正是我们在这里看到的。这两个寄存器,其中两个,因为这个函数将只使用其中两个,19和20。它们被推送到这个栈上。这个指令,它叫做存储对。所以存储两个寄存器。它们最终在哪里?在栈指针加16的地址处。在底部,它们被弹出。所以函数做了它应该做的事情。它想使用这些寄存器,公平合理,它可以。所以它暂时将传入的值推入栈,并在返回前弹出。
这给我们带来了对本次演讲重要的最后一组寄存器。X0到X7是输入参数。它们也用作返回值,因为在某些语言中,你可以返回多个值,所以。它会进入X1,抱歉,X0,X1,X2等等。在C++中,我们只返回一个值或不返回任何值。好的,所以作为回顾,这就是我们的函数的样子。它接收一个参数,这是一个指针,并调用do_stuff_locked函数。它做的第一件事是,它将传入的X0,传入的指针存储在X19中。因为它需要在中间修改X0的值。然后它计算互斥锁的偏移量。所以它指向互斥锁。X0加8。并将其存储在X20中。到目前为止,一切顺利。然后它使用计算出的互斥锁地址调用pthread_mutex_lock,将X0作为第一个参数传递给pthread_mutex_lock。好的,它返回。所以它继续。它已经计算了,或者甚至没有计算,将传入的指针存储在X19中。它仍然在那里。所以它可以用来调用do_stuff_locked,即临界区。一旦它返回,哦,互斥锁的地址仍然在X20中,因为必须保存,回想一下。所以它可以再次复制到X0,并用作pthread_mutex_unlock的第一个也是唯一的参数。正如我们已经看到的。它崩溃了,并且它在中间改变了值。看,幻灯片上这里没有对X20的修改。所以一定有什么东西在第一个和第二个高亮行之间改变了X20。所以一定是这个函数。不是我们在后台看到的那个。是另一个,那个。我们的函数调用的那个。所以如果我们深入到子函数,我们已经可以看到那里熟悉的模式。这个子函数。再次,它将分配它的栈帧,并将X19和X20推入栈,在最后弹出并返回。猜猜怎么着?它弹出了正确的值。抱歉,它推入了正确的值,但在最后,它弹出了一个不正确的值。怎么会这样?这是答案。

我们在pthread_mutex_unlock上发生了崩溃。我们足够聪明,知道那不是根本原因。所以也许do_stuff_locked不是。它可能在do_stuff_locked下面,或者在函数本身内部。但它也可能在do_stuff_locked的整个调用图中的任何地方。所以这是大海捞针。我们需要找到它。这不会容易。
这是它在实践中如何工作的图示。所以这不是宇宙射线,没有魔法,什么都没有。可能只是某个数组的越界修改。我们从do_stuff开始。它在栈上有一些帧,然后它调用do_stuff_locked。显然它分配了自己的帧,我们继续到某个其他有错误的函数。这个有错误的函数做了什么,你们可能已经猜到了。它修改了父帧之一,不是它自己的,不是堆上的东西,而是栈上更高的东西。在这一点上,你们可能会问,嘿,Bartosz,但我们有栈保护器,对吧?它应该拯救我们。不是吗?我很高兴你们问。是的,评论。更多写入。怎么样?我猜我,我能理解你们的意思。是的,它不会拯救我们,不是在这种情况下。因为栈保护器实际上是这样做的。在调用任何函数之前,它将一个额外的值推入栈。它叫做金丝雀。它叫做金丝雀。我的意思是这个名字,可能你们很多人会知道它来自旧日的采矿业,在地下室中,甲烷或其他致命气体会积聚。所以矿工们不想冒生命危险。相反,他们把一只小鸟放在笼子里放进那些房间。如果鸟活下来了,这个地方工作就是安全的,矿工们可以进入。如果鸟死了,抱歉,这是他们愿意做出的牺牲。为了保护他们自己的生命。所以我们有了金丝雀,这个栈上的第一个金丝雀。然后只是调用我们的常规函数。也许它进入另一个函数。所以我们做什么?我们推入另一个金丝雀。另一个栈帧,容易,对吧?第三个金丝雀。我们最终进入一个有错误的函数,这个有错误的函数。是的,如果它只是覆盖了自己的金丝雀。那么就会很容易。它会在从有错误的函数返回时立即被检测到。好的,这显然没有发生。那么如果有错误的函数修改了另一个金丝雀呢?好的,它不会立即被检测到。它只会在从do_stuff_locked返回后被检测到。所以稍晚一点,但仍然会被检测到。最坏的情况发生在有错误的函数修改了中间的东西时。因为栈保护器完全无法检测到这种损坏。这不是因为栈保护器有任何问题,哦不。是因为它被设计来处理这种情况,连续的栈修改。如果你的数组越界很多字节,也许你错误计算了参数,你的memcpy或strcpy或任何东西。许多连续的字节。是的,它们会被检测到。单个字节。现在不会。那么。我们做什么?你们碰巧不知道吗?是的,已经有问题了。你能增加金丝雀的大小吗?增加捕获这种错误的几率,而不是一个字节。你放一个大猫。是的,但仍然,我的意思是,那可能有用。重复问题。哦,抱歉,抱歉。所以问题是,你能扩大金丝雀并使其更大,比如超过1字节吗?是的,你可以,但我认为它不会改变,因为你仍然可以损坏不是金丝雀的东西。我们最终还是一样。其他问题,是的。这是一个硬件断点。保存的地址。但或者。函数。所以让我重复这个问题,如果我理解正确,你可以为该特定地址在栈上的修改设置硬件断点。是的,你可以。假设你知道它发生时的确切地址。让我提醒你,它每隔几小时发生一次,也许一天一次。你不一定知道哪个地址会被损坏。你也可以设置条件断点。是的,所以评论是你可以设置条件断点,是的,你当然可以。但仍然,你需要提前知道损坏的地址将是什么。在我的情况下,它总是那个。你的意思是像为修改栈上任何旧地址设置条件断点吗?条件断点。什么是栈?函数本身。那个下一个学生的地址是什么?好的,所以像条件断点,你说基于当前栈指针的条件断点。也许夏天。是的,也许仍然,你需要知道从当前栈指针到实际被损坏地址的偏移量是多少。我没有那个信息。幸运的是,它,它会工作,是的。但你们知道还有其他工具可以帮助我们吗,我看到你的右手。是的,我认为它不工作。绝对是,Valgrind。我甚至查了他们的网页,看他们怎么发音,我,据说它是Valgrind,像北欧的发音。是的,不幸的是,不行,有两个原因。第一个是,对于我们的情况来说太慢了,因为我们是在嵌入式设备上运行一个完整的网络浏览器,一个相当受限的环境。所以运行Valgrind的开销是不可接受的。第二个原因。它更像是一个根本原因。修改栈不是一个非法操作。你完全可以将指向栈上对象的指针传递给函数,期望它进行修改。这是一个合法的情况。所以Valgrind将无法标记它,因为,嘿,有人在修改栈。这随时都在发生。所以由于这两个原因,不幸的是不行。所以我正在聊天。是的,也许避免使用。栈缓冲区。也许避免使用栈缓冲区,你具体是什么意思?所以为了保护我们免受栈损坏,避免使用栈缓冲区是一个好指南。好的,所以,所以想法是,不要在栈上使用变量。将它们移到堆上,对吧。好的,然后,然后也许你会更安全。好的,也许你的对象会更安全,但栈仍然可能被损坏。可能,对吧,另一个。我们想。我们可以使用MPU,对吧,好的,内存管理单元,对吧?是的,内存。所以这是一个小的升级。所以我们只是说你不允许。当。James Fritz。是的,所以好的,所以保护评论是我们可以使用MPU来保护我们栈的片段,并允许其他线程修改它。猜猜怎么着。它来自同一个线程。那不会得到它。但我没有。好的,所以是的,所以好的,不。我的意思是限制栈变量的使用,支持堆。是的,那可能有时有效。这是一个我确实考虑过的想法。实际上,我甚至想到,嘿,Linux有这个内存保护API,所以我也许可以锁定我不允许修改的栈部分,但这会以4或64KB的块为单位。不幸的是,你不能保护单个字节。所以我必须用别的东西。我得到了。这个空间,你实际上可以使用一些更新的功能,叫做用户fault fd,它可以让你。我们处理页面错误。所以也许你可以设置一个。每当有东西访问。那个你。好的,所以评论是,在新的Linux中,有你怎么称呼它用户错误,对吧?它叫做用户fault fd或类似的东西。我必须查一下,因为我不知道那个特定的功能。听起来很有用。我们不知道。所以,所以我们需要考虑其他东西。我最终和我的一个朋友聊了几天。经过一番折腾,我们最终想到了这个主意。嘿,如果我们创建并维护栈的第二个副本,并且总是知道那里有什么呢?如果我们检测到并且当我们检测到某些东西被损坏时,那么我们至少可以做出反应。你有评论。还有其他东西,我的意思是地址清理器。它可能可用。然而,它是否适用于栈到那种程度?由于与Valgrind相同的原因,它不会工作。啊,那个。亚瑟所以。是的,是的,所以另一个。是的。所以共识是,由于与Valgrind相同的原因,它不一定有效。是的,不,它有一个更大的保护区域,所以可能由于随机运气而有效。一般来说,现在,是的,一般来说现在。所以也许如果我们幸运的话,可能,但一般来说,不是真的。所以,所以,你知道,我们,我和我的朋友一起头脑风暴。我们最终想到了这个主意。嘿,让我们创建一个栈的副本。让我们一直进行内存复制。这就是影子栈的诞生。在名字上,我的意思是,我从一个概念中汲取了一些灵感,这个概念在英国政治中尤其知名,也许在美国不那么知名。但有一个影子内阁的概念。所以想象有一个虚构的国家。让我们称之为计算机之地。他们有两个政党,大印第安人和小印第安人。也许他们刚刚举行了选举。碰巧大印第安人赢了。所以显然,他们任命首相和政府等等。你们知道它是如何运作的,对吧?那么反对党做什么,刚刚失败的小印第安人?他们将任命自己的首相和自己的政府。他们称之为影子内阁。如果我们这些人,他们不被允许统治。但他们待命,就像监视实际政府做什么并批评他们。如果碰巧一些小印第安人说服了一些大印第安人改变立场。他们获得了多数席位,他们得以统治,并且他们有人准备好从第一天开始统治。在网络安全中,我们将称之为从零日即时开始。所以是的,影子。影子栈,我猜,是受到同一概念的启发。这就是我们最终编写的。这就是我们设计它的使用方式。所以假设你有一个函数。你怀疑它有问题。你用shadow_stack_invoke包装它,就像你会用std::invoke一样。事实上,它在内部使用了std::invoke。也许你有时有一个方法代码,因为,是的,它们在我们的代码中到处散布。所以你,你只需以同样的方式使用这个。影子栈调用你的方法,你就没问题了。它在幕后施展魔法,对吧?这是实际的实现。我不是在开玩笑,这是源代码的副本,没有烟雾和镜子。所以我们获取栈的地址,就像它现在这样,我们施展魔法,我将在下一张幻灯片上解释。然后我们将所有东西转发给普通的、常规的std::invoke。它们非常适合转发。没什么特别的。那么这个魔法做什么?首先,在深入到底层代码之前。它显然会计算当前的栈地址。并将缺失的位追加到影子区域,到副本中。在返回前不久以及调用返回后。它将。丢弃任何需要的东西。在中间,就在深入之前和返回之后,在丢弃额外位之前。它将进行内存复制。并且会立即检测到,嘿,一切仍然正确还是我的字节已经损坏了?如果它检测到东西被损坏了,那么它将相应地做出反应。它可能会打印回溯。它可能会做其他事情,我相信你们的想象力会建议可以做什么。就像,我们非常幸运,我们正在与一个C库接口。在C中,你没有完美的转发或任何东西。或者也许你有。但这几乎是完美的转发。你需要使用一些编译器内部函数和一些宏来实现它,我们都非常喜欢。在C中这是不可避免的,但,但可以做到。也许你不想修改你的代码,对吧,因为那有失身份。所以也许你更喜欢使用LD_PRELOAD。这很公平。我们为你提供了支持。这里,我有一个如何使用LD_PRELOAD技术包装函数的例子。这里dlsym获取实际函数的地址,我们将所有东西转发给实际函数。请注意。这里有一个无耻的ABI滥用。我只是想通了。让我们假装这些函数。它们总是接受8个参数,对吧。我只是将它们向下传递。如果它们碰巧是指针或整数,从处理器的角度来看,这并不重要。我的意思是,不要尝试这段代码,对吧,不要在家里尝试,但它确实有效。至少如果参数碰巧是基本类型或指针,并且在arm上,在x86上也可以。所以是的。请持保留态度。显然,你也可以包装C++函数。这没有问题。它是如何工作的?我们在do_stuff函数中。也许它想调用一些东西。一些受影子栈保护的东西。所以影子栈启动。它做的第一件事是,将影子区域中缺失的任何内容复制到影子区域。没有别的了。然后进行内存比较。现在,这没有太大意义,但稍后会有。让它继续。是的,它让函数被调用和执行。显然,这个函数分配自己的栈帧并将变量存储在那里。也许那个函数调用其他东西。是的,它想调用另一个函数。所以再次,影子栈启动,将剩余的位复制到影子区域。比较,到目前为止一切顺利,没有损坏。所以它允许函数继续。我们有一些函数。而且,你们已经在之前的幻灯片上看到了。它想调用其他东西,所以复制,然后比较,没有差异。我们最终进入有错误的函数。这个有错误的函数,也许它想调用其他东西,也许它只是想返回。在这里,它损坏了父帧之一。并且。它想返回,并且它确实返回了。所以在返回路径上,返回之后,影子栈再次启动。然后比较。它检测到某些东西被破坏了。所以我的问题是,你们认为现在应该发生什么?有什么想法吗,是的,核心转储。是的,这是一个合理的。异常是另一个可能。请注意,我们处于异构环境中。所以C和C++,实际上在这个特定的进程中,异常被禁用了,但据我所知。但是的,这是个好主意。可能是,也可能是异常。是的,差异。看起来差异。是的,从影子中恢复。我,我有各种各样的好主意。是的,都是关于它的。所以它可以修复,对吧,因为它知道最初应该有什么。所以它可以说,好的,我知道你损坏了某些东西。让我帮你修复它。是的。错过了一些东西,你怎么知道栈帧大小的,因为你检查的不仅仅是你自己的栈帧,而是像你的父函数,你检查所有父函数一直到main吗?或者你怎么知道要检查多少?是的,答案,好的,所以问题是我怎么知道要检查多少大小?差不多。所以我跟踪那个,就像我们从初始函数下降时,影子栈跟踪它被调用的时间。所以。在这里,也许在之前的幻灯片上会更清楚。就像。就在这里。它获取地址,当前栈上当前位置的地址,当我们每次下降时,它将是栈上的不同地址,它跟踪这些,直到它返回、返回、返回、返回。所以它知道整个历史,并知道它保护了多少区域。这回答了你的问题吗?有点,它不保护最后一个函数,因为好的,你没有那个。是的,它不保护最后一个函数。是也不是。这取决于实现,因为可以使用,例如,Pthread API来找出栈的原始地址以及它有多大。所以我的意思是,这是可以寻址的。但确实,我们对此视而不见。我们说,好的,我们不保护第一个。所以如你所说,你可以修改栈变量。这无法区分,所以它总是会触发。哦,好的。这是一个好问题。我的意思是,我有点期待有人评论这个。评论是,修改栈变量是合法的。我们有点忽略了这一点。是的,这是,这是真的。这是真的两次。首先,修改是合法的,这是真的。其次,是的,它会立即触发通知或反应。我对此的评论是,我们意识到这种情况,并且有一个想法如何通过委员会异常、异常的异常来解决它。不,不,不,现在甚至更容易了。但这需要用户,代码的作者,注释被传递指针的区域或变量,比如标记它们,好的,从这个地址开始,接下来的四个字节可以被修改。所以这将需要一个扩展。目前,它不在我们的库中,但可以很容易地添加。对吗?我会在这个问题上妥协,公平合理,对吧?所以这就像,所以即使比较它,不管结果如何,你总是在比较它,对吧,影子栈和真实的。损坏也可能发生在影子栈中,对吧?那么你怎么知道是哪一个?好的,评论是我在比较实际栈和影子栈,可能影子栈被损坏了,而不是实际栈。是的,这是可能的。然而,你试图发现和定位的问题是实际栈的损坏,而不仅仅是随机内存。所以我们知道它是当前栈指针的某个偏移量,像向前,不会在堆上,因为影子区域将在堆上分配,并且完全在其他地方。所以我仍然坚持那个地方。我只是建议。所以是的,评论是在堆上分配。各位,我,我在看这里的计时器。我很乐意继续回答问题,但让我稍微快进一点,也许我们稍后再保留这些问题。是的,所以你们所有的评论和我们的观察都非常有效。我们在哪里?好的,我们可以修复,因为我们知道最初应该有什么,所以我们可以应用补丁并让应用程序继续,为什么不呢?因为健壮性和高可用性,有许多原则适用于这里。有人已经建议,现在我们将不允许你通过。我们在此类情况下立即终止进程。这实际上与你们中一些人可能在本周会议上听到的合同概念完全匹配。所以这就像策略等于强制执行。在合同中强制执行。所以转储报告并终止。现在,请。为我祈祷,因为我在早上向演示之神献祭了。

希望他们接受了我的献祭。我将向你们展示它在实践中如何工作。所以,我有一个。一个应用程序。它与我在幻灯片上展示的一模一样。所以有do_stuff,它锁定互斥锁,然后调用do_stuff_locked,以及所有其他函数,就像它们在幻灯片上被调用的一样。我正在复制那个。这一行和。我在这里的QEMU机器中。顺便说一下,如果有人知道QEMU应该如何发音,请之后告诉我,因为我找不到那个信息。所以好的,我运行它,什么都没发生。它似乎正常工作。然而,其中有一个功能。抱歉,没有粘贴。其中有一个功能,我可以强制它修改。某个偏移量的地址。我仔细计算了那个偏移量,它恰好是140字节。我得到了一个段错误,对吧?所以也许让我们在GDB中看看它。好了。在GDB中运行。段错误,对吧?只是,只是为了向你们证明我不是在开玩笑,没有烟雾和镜子。是的,它do_stuff_locked,我们最终在pthread_mutex_unlock中出现段错误,就像我在幻灯片上展示的那样。这里是3的修改。那看起来不像一个有效的指针。他是有效的,加8本来是一个有效的。但中间有这个03。所以让我们退出。退出GDB。让我们运行相同的应用程序,但在中间使用影子栈插桩,让我展示一下差异,那是差异,所以你需要将这些修改应用到源代码,预编译并再次运行。那可能是它调用的库。再次,像小的一行。所以我正在运行,我知道,等等。我还没有复制它,是吗?这一行在哪里?所以,我复制这个。它有影子栈编译进去。好了。如果我向上滚动到那里,它显示我,哦,这里有一个3。它不应该在那里。应该是双A。它还显示了可能不一定是确切的栈帧的历史,而是影子栈帧。所以影子栈被调用的点。它不一定需要在你的调用栈上的每个函数处被调用。它可能为了简洁而跳过一些位。在这里,是的,我们得到了完整的调用栈,就像,嘿,这就是它发生的地方。就像,在other函数中。在返回时或在守卫销毁时,守卫就像我们影子栈库中的一个对象。当我们销毁它时,意味着当我们从影子栈保护的函数返回时,在other函数中的某个地方,我们检测到有问题。所以问题一定是在被调用的函数内部,在other中。它在哪里,像这里。other,other O。而other恰好调用了有错误的函数。所以确实,我们设法确定了问题,不一定完全识别,但缩小到有错误的函数。所以这已经是一个很好的进展。显然,是的,你可以在GDB中运行它,你几乎有相同的结果。你可以应用不同的策略,正如我们简要讨论过的。所以你可以,例如,只是报告,让我复制那个。复制,你可以只是报告。好的,它又在GDB中了。所以运行那个。正如你们看到的许多报告,对吧?因为当我们从许多嵌套函数返回时,我们有机会在返回途中多次检测损坏。所以我们得到了所有这些损坏。但影子栈不启动,不做任何事情,不终止,不修复。它只是报告。所以相当于合同中的观察。好的,当然,我们可以修复。让我向你们证明。我只是关闭,关闭GDB。是的,是的,不是那个字母。我们有了。我们有了报告,但请注意。Echo美元符号问号。它,是的,它没有崩溃。它返回了,好的。你也可以应用所谓的静默修复。所以请修复,不要用报告打扰我。好了。它现在工作了。不,我必须为每个函数调用仔细计算所有这些偏移量,因为影子栈的调用显然修改了栈的结构,所以地址会不同。但检测仍然会发生。




是的,所以我在这里向你们展示。替代方案,如何格式化你的报告。所以也许你只想要报告的左边部分,而不是右边部分,也许你只希望你的行是8字节长,而不是默认的16字节,你也可以隐藏相等的行,如果你不想被它们打扰的话。
是的,就是这样。最后但并非最不重要的是,有一个LD_PRELOAD。而且。是的,它显然要求你使用动态加载器,并且只适用于实际从你的库中导出的点。所以它稍微限制了你的灵活性。但也许你不想重新编译原始代码,因为这对你来说足够了。我实际上在我的实际库中实现了这一点。所以它显示,好的,我们正在下降下降下降返回。然后哦,突然之间有一个问题。它在某个地方。是的,再次,再次,我们在从other调用的有错误的函数中。影子栈启动并检测到问题。也就是说,我有很多时间。所以让我回到幻灯片。




并用一些结论来结束。首先,我们成功了吗?是的,我们成功了。否则,我就不会站在这里了,对吧?它发生在我们使用的一个库中的一个问题。它叫做GStreamer,一个非常流行的视频和音频处理库。顺便说一下,一个非常好的库。然而,我们都是人类。我们会犯错误。这正是我在上一张幻灯片上展示的。这是一个数组越界修改的情况。他们有一个大小为6的数组,他们在250左右的偏移量处写入,因为他们忘记验证输入数据。在我们调查的时候,这个错误已经在上游修复了。所以就是,好的,我们知道问题在哪里。让我们检查一下上游的人是否已经处理了它。他们已经处理了。就这么简单。挑选,发布。关于影子栈成熟度的一些评论。所以它不是一条鱼。它不是我们免费赠送的鱼。它也不是我们赠送的鱼竿。它更像是一个你必须自己组装的鱼竿蓝图,如果你愿意的话。但它是可行的,正如你们所见,是的,并且它缺少这个功能,你可以注释某些区域为可修改的。所以必须进行改造。但除此之外,它有点工作。有一个GitHub仓库,我将在最后一张幻灯片上提供一个链接。所以你们可以稍后查看。所以我们的教训,也许也是你们的教训。了解你的金丝雀。因为你应该有意识地使用它们,你应该知道它们保护你免受什么,特别是它们不保护你免受什么。第二个,显然,更新你的依赖项,或者至少跟踪你的依赖项,因为那里可能有真正的改进,它可以为你节省大量时间。第三个也是最后一个要点是,顺序很重要,因为。想象一下。想象我们有一个向不同方向增长的栈,因为为什么不呢?因为这是完全可行的。它从高地址向低地址增长,还是反过来,并不重要。如果确实是它从低内存地址向高地址增长的情况。猜猜怎么着。我们最终在。其他有错误的函数中。它做什么?它损坏。栈的某个地方仍然损坏。这仍然是一个真正的错误。但它损坏了空闲内存中的东西。所以这是一个错误。不要误解我的意思。它应该被修复。但后果要轻得多。据我了解,数组越界访问更常见的是在数组之后,而不是在数组之前。所以,我们能切换这个栈的顺序吗?我将把这个问题留给你们。问题,开放。思考一下。现在,我很乐意回答你们的一些问题。如果你们关心给我一些反馈,可以通过扫描这个二维码来完成。




我担心你进行的所有栈块内存复制的开销,并导致很多海森堡问题。你有没有考虑过可能只做一个校验和并存储一个校验和,这也会减少你的影子栈的堆开销?所以校验和。是的,那将是一个好主意。它不会允许我,或者如果我使用一些错误纠正,也许它会允许我,它会吗?不,它不会。或者也许取决于错误纠正,它可能允许我找出先前的值,但。我们并不那么担心。我的意思是,我们需要一个锤子来快速处理问题。但是的,这是一个想法,与校验和的想法类似。除了能够在函数的入口和出口检查它之外,还能够在整个过程中随机抽查一些点
008:更高速度与更简实践

在本节课中,我们将要学习数据导向设计(Data-Oriented Design, DOD)的核心思想,并通过一个火箭粒子系统的实战案例,了解如何通过改变数据的内存布局来显著提升程序性能,同时使代码更简洁、更易于维护。
1:引言与目标
大家好。这是我的第一次主题演讲,我既兴奋又紧张。我通常做的是比较小众的演讲,听众知道会听到什么。面对这么多观众演讲对我来说是不同的体验,但我希望你们会喜欢今天的演讲。
我们将讨论数据导向设计,特别是其实践应用。我想从一张照片开始。

这是我的朋友 Mike Acton 在 2014 年做主题演讲时的照片。那是我第一次参加技术会议。当时 Mike 在 Insomniac Games 工作,他上台挑战了一些 C++ 开发者的核心信念。这对我个人而言,是我数据导向设计旅程的起点。

我不知道你是否认出了这个人。那是我,很久以前的我。我现在看起来不一样了。我当时正在向 Mike 提问。对我来说,那是一个改变职业生涯的时刻,因为我当时没有意识到,除了面向对象编程(OOP)之外,还有另一种完全不同的思考代码和软件的方式。即使现在,我并不同意那场演讲中的所有结论,我仍然要感谢 Mike 为我打开了新世界的大门。我仍然推荐你们去看看那场演讲,非常精彩。

在开始之前,简单介绍一下我自己。目前,我是一名独立的 C++ 顾问、培训师和导师。我的业务专注于提供 C++ 培训和一对一指导。我也提供技术培训和会议演讲。如果你有兴趣,欢迎会后联系我或访问我的网站。
过去十年我在 Bloomberg 工作,主要从事高性能 C++ 后端开发。我在微服务基础设施团队和市场分析团队工作过,也为 Bloomberg 工程师提供过现代 C++、元编程和多线程等技术培训。
我参与了 C++ 标准化工作。如果你听说过 std::function_ref,我对此负有一部分责任。它将在 C++26 中引入。你可能也听说过我的 epos 提案,它现在部分体现在 profiles 中。
我对游戏开发、开源充满热情。我是 SFML 团队的成员,领导了该库现代化以支持 C++17 的工作。我强烈建议你们去看看。我们今天也会用它来做演示。我还为其他库(包括 SL)做出过贡献,并在 Steam 上发布了两款开源商业游戏。我也喜欢虚拟现实,为《半条命2》和《雷神之锤》等游戏的模组做过贡献。
最后,我是《Embracing Modern C++ Safely》一书的合著者。这本书详细介绍了所有现代 C++ 特性的优缺点。如果你对此感兴趣,也欢迎会后找我交流。
但关于我的介绍就到这里,让我们看看今天的目标。
今天主题演讲的目标是,我希望你们能发现一种新的思维方式。我想知道在座有多少人熟悉数据导向编程?请举手。看来有一部分人熟悉,也有一部分人不熟悉,这很好。所以,如果这对你来说是新的,你将学习到以数据优先的方式思考如何改变你设计系统的方式。
同时,我们也会复习一下硬件的工作原理,特别是内存以及它如何与 CPU 通信,以及为什么这对你的软件设计很重要。
我们会以互动的方式进行。我们将一起构建一个小演示。我会先展示演示,然后逐步构建它。我们将看到,在不改变对数据执行的操作的情况下,仅仅改变数据的布局方式就能对性能产生巨大影响。
同时,我不想只关注 DOD 的性能方面,我还想说明,它可以使你的代码比 OOP 更简单、更易于维护,这听起来可能有些反直觉。但我会展示一些我认为确实如此的例子。
另外,这可能是我与游戏开发圈其他人看法不同的地方。我认为在某些地方,OOP 仍然有意义。我们稍后会讨论这一点。我认为,明智地使用高级 C++ 抽象实际上可以帮助你实现目标,并且在某些情况下,它们可以帮助你获得更好的性能和更高的代码可维护性。
好了,我直接进入演示。我将展示我们要实现的东西,并让你了解我们要做什么。
2:演示需求与目标

对于这次演讲,我选择了一个相当简单且视觉效果有趣的东西。我们将制作一个演示,其中有火箭飞来飞去。让我放大一下。屏幕上看到的每一个火箭、烟雾粒子和火焰粒子都是独立的实体,被单独模拟。每个实体都有自己的物理量,你可以通过某种标识来引用屏幕上看到的每一个特定物体。

为了让需求更明确,我们希望一切都遵循物理定律。所以我们有一个基本的运动模型,包含位置、速度和加速度。
我们应该能够定制这些效果、粒子和发射器,使其具有不同的类型、随时间变化的不透明度、缩放和旋转。
一切都是一个“演员”。所以如果我关心某个特定的火箭或粒子,我应该能够获得它的句柄并对其进行操作。例如,每个火箭实体都有一些附加的发射器,这些发射器保持同步并生成粒子。
最后,我希望以可扩展的方式设计这个系统。应该很容易添加新效果、新演员、新粒子类型等。所以这有点像是一个玩具程序,但我通过要求能够将实体附加到其他实体上来使其更真实。我觉得它很好地隔离了性能原则,你从这个玩具程序中学到的经验教训也适用于现实世界的代码。
现在,如果我打开这个菜单,你会看到一些关于更新和绘制性能的指标。如果我开始生成一些新火箭,由于某些原因,渲染不同步了。你会看到,随着火箭的生成,帧率会急剧下降,变得不再可玩。我也可以缩小,展示我们有很多火箭。在这里,你可以选择我们将要使用的数据布局。这是 OOP 实现。但如果我们切换到像 AOS 这样的布局,你会看到帧率显著提高,变得非常可玩。你可以尝试其他内存布局等等。但重点是,我们有了这个演示,可以操作它,我们执行的操作是相同的,改变的只是我们使用的内存布局。正如你所见,这带来了巨大的差异。现在我们将看看这是如何实现的。
好了,让我们开始工作,实现这个演示。
3:面向对象设计实现
我们将从根据 OOP 原则开始设计。我们有一个对问题的现实世界模型解释,并尝试将其转化为代码。
我们知道将会有多个实体,所以可以从一个实体基类开始。我们将有一个发射器类来生成粒子,一个火箭类飞来飞去并关联发射器,以及一个粒子类。
发射器可以是烟雾发射器或火焰发射器。同样,粒子可以是烟雾粒子或火焰粒子。图中浅蓝色表示基类,深蓝色表示具体类。我喜欢这个设计,它是一个简洁的层次结构。如果我想添加新东西,可以直接从实体派生。如果想添加新类型的发射器或粒子,只需从相应的类派生。它满足了所有需求,似乎很容易扩展。所以我看不出这有什么特别的问题。让我们尝试实现它,看看代码实际上是什么样子的。
我们将在这里定义一个 struct entity。为了保持幻灯片简洁,我将使用 struct。但在现实中,你会使用 class 和 private、public 等。此时,我只想专注于性能和内存布局方面。
因为这将是一个多态基类,我们将有一个虚析构函数。这里我们可以使用默认的。然后我们将有一个虚 update 成员函数,它接受增量时间(即帧之间经过的时间量),并用它来推进实体的状态。我们还将有一个 draw 成员函数,它也是虚的。它接受一个来自 SFML 库的 render_target,这是一个抽象,允许你在纹理或窗口上绘制东西。在我们的例子中,它将是应用程序的窗口,所以在这里不太重要。
因为一切都需要遵循物理定律,我们将有一堆 vector2f,它们只是用于位置、速度、加速度的二维浮点向量。如果你想看看它在实践中是什么样子,它只是两个浮点数 X 和 Y,带有一些很好的运算符重载,允许你进行乘法、加法等操作。同样,这来自 SFML,是对二维向量的一个很好的便利抽象。
差不多就是这样。如果我们想实现实体的更新部分,我们可以通过速度乘以增量时间来移动位置,并通过加速度乘以增量时间来移动速度。这是非常简单的物理积分,不是最准确的,但我们主要关注内存布局,而不是模拟的准确性。
但我们有一个问题,实体可能需要知道其他实体的状态,或者需要按需创建其他实体。所以我们需要某种方式来实现这一点。
我想到的一个方法,并且在许多其他地方也见过,是这样的:实体知道它所属的世界。通过那个世界引用,它可以查询任何其他实体的状态,或者创建新实体,可以与世界的状态交互。所以我们将为我们的程序做类似的事情。
我们还将有一个名为 alive 的小布尔值,它负责告诉世界:“嘿,这个实体已经完成了,请处理掉它,回收内存,并将该内存用于其他用途。”它只是用来与已经存在的世界通信,表明实体可以被清理。我们使用布尔值的原因是,我们希望能够通过 update 成员函数来传达这一点。在 update 中可能有一些条件使实体有资格被清理。
好了,这就是实体。为了展示更多代码,我们有粒子。它将有自己的缩放、不透明度和旋转。它们可以随时间变化,所以我们也有变化率作为数据成员。
我们可以重写 update 成员函数,使用我们在实体中写的代码,遵循 DRY 原则(不要重复自己)。然后对缩放、不透明度和旋转进行类似的物理积分。
这里有趣的是,我们只在透明度大于 0 时才将布尔值 alive 设置为 true,这基本上意味着我们清理粒子的条件是当它们淡出时,因为在我们演示中,每个粒子最终都会淡出。这对于在完成后清理它们来说已经足够了。
为了展示烟雾粒子是什么样子,它只是从粒子派生而来。我们基本上免费获得了一切,这很好。我们唯一需要改变的是,我们希望重写 draw 成员函数,并指定我们想使用该数据来绘制带有烟雾纹理的东西。你可以想象,这也可以用于火焰粒子以及未来你想添加的任何其他粒子。
好了,世界可能是最有趣的部分。屏幕上已经有三种实体。我们希望以一种方便我们使用的方式存储它们。
我们将这样做:我们将有一个 std::vector<std::unique_ptr<entity>>,它包含所有实体。
在座熟悉数据导向设计的人可能已经在想:“嘿,没人会这样写代码。每个实体都会有一个堆分配,这不会很缓存友好。”但相信我,我在生产环境中见过很多次这样的代码。如果你在 GitHub 上搜索,甚至有时用 shared_ptr 代替 unique_ptr,你会有成百上千的搜索结果。你可以读到关于 AAA 工作室发生这种情况的故事。人们已经成功地用这种设计发布了非常成功、非常优秀的软件。我并不是说你不能这样做,也不能成功。但如果你知道这种设计的缺点,你也应该知道这实际上在实践中已经做过。我想知道你们中有多少人写过或见过这样的代码。
好的,看来几乎所有人都有过。你知道,这不是玩具代码。这实际上会发生。我还想说,非常流行的库和引擎也做类似的事情,包括 SFML 库。所以这是一种到处都在使用的东西。
update 和 draw 成员函数非常简单直接。我们只是遍历所有实体,并调用 update 和 draw。你可能会看到这样做的好处:我们不关心 update 做什么,也不关心 draw 做什么,我们只是委托给实体。这很好。
最后,我们会在最后做一些清理工作。我们调用 std::erase_if,这是 C++ 标准中一个相当新的函数。它接受一个容器和一个谓词,并有效地重新排列容器中的项目,以便可以在常数时间内移除它们。
好了,发射器。我们将做类似的事情。我们将有一个计时器来跟踪我们想要生成粒子的频率。我们希望有不同的生成速率。然后我们想做点有趣的事情。我们将定义一个纯虚函数 spawn_particle,它将被实际的发射器类型重写。
所以发射器基类的 update 函数会定期调用 spawn_particle,但实际派生出来的发射器将重写该函数并决定生成粒子意味着什么。烟雾发射器将在内部使用 make_unique 分配一个新的烟雾粒子,然后将其推入世界。你还可以在这里看到,我们如何从实体的 update 内部引用世界,以影响状态并动态创建新事物。
最后我想展示的是这个。我提到火箭需要有一些关联的发射器。所以我们将在这里使用原始指针。我们会说:“好吧,我是火箭,我需要知道什么烟雾发射器和火焰发射器与我关联。”因为我们有世界是实体所有者的隐含知识,并且只要火箭还活着,那些发射器就会活着,所以在这里使用原始指针是可以的。我们考虑过生命周期,所以这在我们模型中可行。
这也是你在大型程序中常见的情况,通过使用指针来引用同一程序的不同组件。你依赖于这些对象的地址稳定性。在这种情况下你可以这样做的原因是,我们在堆上分配它们。所以即使包含所有实体的向量被重新分配,即使我们移动世界,发射器的地址也将保持稳定。
当我们创建火箭时,我们创建发射器并将它们连接起来。当我们更新火箭时,我们将与火箭一起移动它们。非常简单。这几乎是我们演示所需的所有代码。
那么,让我们看看这有多快。让我们做一轮基准测试。
我将使用的机器不是我现在演示的平板电脑,而是我的台式机。它相当强大,有一个几年前顶级的 Intel Core i9 处理器,速度很快,有 DDR5 内存。我使用 Clang++ 编译,启用了 -O3 优化。为了这些指标,我禁用了渲染,但稍后我也会讨论渲染,因为改变数据布局不仅对更新有益,对渲染也有益。
对于 200,000 个实体,我们得到 2.3 毫秒;400,000 个是 6.6 毫秒;600,000 个是 12 毫秒;800,000 个是 16.6 毫秒;1,000,000 个是 21.6 毫秒。
现在,你可能会想这只是毫秒,这很快,谁在乎呢?但如果你考虑针对实时应用,要达到 60 FPS(这是良好交互体验的最低要求),单帧的预算只有 16.67 毫秒。你可以看到,在 60 万个实体时,我们已经用掉了大部分预算。如果你想想还要加上渲染,加上更复杂的逻辑算法,你就没有多少工作空间了。
同时,请记住,这是相当好的硬件。在移动设备上,这可能完全无法接受。同时,我觉得在 2025 年,60 FPS 是相当可怜的。老实说,即使是现在的手机也有 120 Hz 刷新率的显示屏。所以我认为,对于感觉流畅的、非常良好的交互体验,最低要求应该是 120-144 FPS。如果你想达到那个帧率,单帧的预算就不到 7 毫秒。所以我们在这里非常有限,几乎没有预算剩余,只有 40 万个粒子。我们谈论的是 C++,是快速的硬件。我们应该能做得比这更好。
现在,我们在演示中看到,改变事物的内存布局可以显著加快速度。所以问题不在于算法,不在于我们执行的操作,而在于数据在内存中的布局方式。
CPU 在这里实际上大部分时间处于空闲状态,它没有做任何工作,只是在等待数据到达,浪费时间等待,如果我们关心性能,这同样是不可接受的。
所以让我们绕个弯,我将给你们稍微复习一下内存,并告诉你们为什么理解它的内部工作原理对于设计高效软件、充分利用 CPU 很重要。
4:硬件与内存基础
在非常高的抽象层次上,你可以把 CPU 和 RAM 想象成通过某种总线连接,它们进行通信,一切都很顺利。
如果我们剥开一层洋葱,再降低一个层次,同样在较高的抽象层次上,你可以把 CPU 想象成这样:我们有一个核心,实际的操作在那里进行;一些缓存,这是一些非常小但非常快的板载内存;然后通信必须经过一个层次结构。RAM 中的数据首先必须经过缓存,然后从缓存到核心,反之亦然。所以你需要的每一个操作都必须遵循这条通过内存的路径。实际上,稍后我们会看到,我们有多个缓存层,多个核心,所以比这更复杂,但主要原则仍然适用:内存必须遵循这个层次结构并向前移动。
现在,缓存,你可以把它想象成一个非常小但快速的“行”的集合。这些行被称为缓存行。这是一个非常重要的概念。缓存行是最小的可传输内存单元。所以即使你只关心一个字节,你仍然必须将整个缓存行从 RAM 传输到 CPU,即使你只获取了那个字节。
同时,你可以想象 RAM 是一个巨大的缓存行集合,非常大,但速度慢。例如,如果你想读取地址 18 处的数据。它不在缓存中,或者缓存是空的,所以我们可以在 RAM 中识别数据在那里。即使我们只关心地址 18 处的数据,我们仍然必须从 RAM 中取出整个缓存行,这是一个缓存未命中,意味着我们取走整行并将其复制到缓存中。如果你想象我们不关心这个数据旁边是什么,我们不关心 D 旁边是什么,那么我们浪费了很多时间移动我们不关心的数据,这很不幸,并且我们低效地使用了缓存。
我们可以继续这样做。也许你想读取地址 26 处的数据。它不在缓存中。同样,我们在 RAM 中识别它在那里,我们必须取出整个缓存行并将其移动到缓存中,这又是一个缓存未命中,非常不幸。
在最好的情况下,例如,如果你想读取地址 19 处的数据,并且我们识别出它已经在缓存中,你可以在这里看到它。那么在这种情况下,我们谈论的是缓存命中。我们不需要往返 RAM,因此我们将能够更有效地读取这些数据。
现在我为什么要告诉你们这些?影响是什么?如果我们必须去 RAM 和如果我们能留在缓存中,这有多大关系?
我喜欢用视觉方式解释事情。所以我做了一个小动画,展示了 L1 缓存(CPU 中最快但最小的缓存)和核心之间的竞赛。在底部,我们有 RAM 和 CPU。在顶部,我们将看到数据已经在缓存中的最佳情况。在底部,我们将看到每次需要数据时都必须返回 RAM 的最坏情况。
现在,这张幻灯片将显著放慢速度,因为我们谈论的是纳秒级的操作,但它是按比例缩放的。请记住这一点。3,2,1,开始。
当缓存中的数据已经完成多次往返时,你可以看到 RAM 仍然,你知道,几乎才到一半。我们已经做了很多操作。
所以这实际上是,正如我提到的,按比例缩放的。这实际上就是你的程序中正在发生的事情。所以如果你的所有数据都来自 RAM,你只是在获取数据上浪费了大量时间。
在最坏的情况下,它可能慢 100 倍。所以与 L1 缓存相比,往返 RAM 可能慢 100 倍。为了给你一些数字,人类平均眨眼的时间是 100 毫秒。一次 L1 缓存引用所需的时间是 0.5 纳秒。所以在你眨眼的时间里,你可以有 2 亿次 L1 缓存引用。RAM 则比这慢 100 倍。所以这是相当显著的。显著到有时数据所在位置的选择比你使用的算法或数据结构更重要,这可能相当令人惊讶,特别是如果你深入理论计算机科学,有时具有更差复杂度的算法在实际硬件上可能表现更好。
好了,我们学到了什么?非常重要的一点是,最小的可传输内存单元是缓存行。所以无论你想要多少字节,你都必须获取整个缓存行。在现代桌面 CPU 上,这通常是 64 字节。幸运的是,在 C++ 中,我们有一个非常直观的方式来询问缓存行大小,那就是 std::hardware_destructive_interference_size。所以,你知道,你会永远记住这个。但你可以想想 64 字节。
所有数据都必须始终遍历内存层次结构。所以如果你需要 RAM 中的某些东西,它必须经过所有缓存层,然后返回,如果需要刷新回 RAM。这意味着数据的空间局部性(即数据在内存中的实际布局方式)极大地影响性能。正如我提到的,有时甚至比算法或数据结构的选择更重要。
所以我们可以真正开始思考一些技巧。
如果我有关联的数据,并且我想在相对接近的时间内访问它们(例如,访问第一个之后,我想访问第二个、第三个等等),那么最好将它们一起存储在内存中,物理上彼此靠近。因此,优先使用平坦且连续的存储可以更好地利用缓存,并最大化你获取缓存行时所需数据已经在那里的机会。
同时,还有一些我之前没有提到的,那就是预取。CPU 有这种推测机制,基本上可以找出你访问内存的模式,例如,如果你在一个循环中向前或向后移动,或者每隔 n 个元素跳转,或者进行分散访问,CPU 可以识别出来,并且在你请求之前就开始给你提供未来可能需要的缓存行。所以,进行非常可预测的操作也可以极大地提高程序的性能。
正如我之前提到的,我骗了你们,实际情况更复杂。所以如果你想更现实一点,在更现代的 CPU 中,它看起来更像是这样:你可能有一个在多个核心之间共享的 L3 缓存,它更大,但更慢。每个核心可能有一个 L2 缓存,比 L1 大一点,但慢一点。然后你可能有一个用于数据的 L1 缓存和一个用于指令的 L1 缓存。现在,我觉得这很有趣,因为,你知道,代码也是数据。当你将程序编译成二进制文件时,生成的代码必须加载到内存中。所以有时,如果你优化代码大小而不是速度,它实际上可能更快。原因是它可能更好地使用指令缓存,你的热点循环可能更多地放入代码缓存本身。所以我们不会在这次演讲中讨论这个,但如果你深入研究这个主题,有时代码的对齐方式对你的性能也真的很重要。
关于硬件、CPU、内存等等,我就讲这么多。我想向你推荐 Scott Meyers 在 2014 年的一场精彩演讲《CPU Caches and Why You Care》,今天仍然非常相关。还有 Yonatan Mueller 在 CppCon 上的一场演讲《Cache Friendly C++》,也涵盖了相同的主题并且相当详细。所以我认为如果你看了这两场演讲,你会对现代硬件有一个很好的理解和认识,并且能够对什么可能快或慢有一个很好的直觉。我强烈推荐观看这两场演讲。
稍微休息一下。哦。
5:分析 OOP 实现的性能瓶颈
那么,为什么我们的实现很慢?我们实际上可以通过查看世界实现来很容易地找出原因。
我们有这个 entity_vector,它是一个 unique_ptr 的向量,这意味着每个实体都将在内存中的某个地方分配,很可能不靠近其他实体,会分散在各处。这意味着如果我们在更新、绘制以及最坏情况下的 erase_if 中迭代,每次迭代都可能是一个缓存未命中。我们已经看到这有多慢,可能比 L1 引用慢 100 倍。所以这对 CPU 来说可能是最坏的情况。它将待在那里等待数据到达。
同时,我们还有其他开销来源。我们使用了虚函数分派,使用了虚函数表和多态。所以每当你访问 update 或 draw 成员函数时,都会有一个虚函数表查找,这有一些开销,可能不如缓存未命中重要,但也是我们必须关心的另一件事。
最后,如果你还记得我们生成粒子的方式,我们实际上在这里调用了 make_unique,而 erase_if 实际上会销毁那些 unique_ptr。所以我们有频繁的动态分配和释放。你大概已经能想到这不会很高效。我们做了很多额外的工作,很多内存等待,以及由于虚函数和分配带来的开销。
那么我的问题是,我们为什么这样写代码?我们为什么直接跳到了 OOP 层次结构和虚接口等等?
我认为这与面向对象思维定势有关。
大多数人的编程入门实际上是 OOP。大学教材,无论你学什么关于类、继承的知识,你都会学到通常的基于形状的类,可以是圆形、矩形,或者基于动物的类等等。而且,这也是一个自然的选择。对人类来说,它与世界的视图非常吻合。我们以个体对象、个体事物的方式思考。我们头脑中有这种“是一个”的关系。它很有效。
所以这种思维定势大致是这样的,我认为有四点很重要。
我们试图模拟一个自主对象的世界。我们思考具有自己身份和责任的、自包含的代理。我们有一个粒子。粒子有自己的数据。它知道如何更新自己,知道如何绘制自己。
这些实体通过消息与程序的其他部分相互通信。主循环,世界不知道粒子实际在做什么,它只是问:“你能更新自己吗?你能绘制自己吗?”我们不关心内部细节,我们只是要求执行这些操作。
数据是隐藏的,所以我们不关心。我们不暴露这些类的内部细节,我们隐藏它们,封装它们,但我们暴露行为。我们不关心粒子如何被更新。这有时可能很好,因为它允许我们改变内部表示而不改变行为。但我们失去了关于粒子数据布局的一些非常重要的信息。
而且,我觉得 OOP 倾向于鼓励人们为未知做计划。所以你会尝试找出某种抽象或接口,它不仅适用于今天的问题,也适用于未来可能遇到的任何问题。我在这里非常小心地选择了“赌”这个词,因为在我看来,这确实是一场赌博。预测未来会有什么样的需求真的很难。如果你的预测错了,摆脱错误的抽象有时比一开始就没有做更昂贵。所以如果你幸运,你可能会节省一些时间,但更多时候,预测未来可能需要什么是非常困难的。
与此相反,如果我们把这个放在一边,让我们看看数据导向思维定势如何看待相同的问题。
我们不想模拟一个自主对象的世界。我们想模拟一个数据转换的世界。我们将代码视为将数据从一种状态转换到另一种状态的管道。我们并不真正关心对象、身份、封装的概念,它只是数据。
我们没有消息,我们直接对数据批次进行操作。所以实体本身不再处于控制之中。个体不再重要,我们从父对象对数据进行批量操作。世界将负责所有实体的更新和绘制。它处于控制之中。我在这里说“批量”,因为对于这类应用程序,最常见的情况不是添加单个粒子或单个实体,拥有许多实体才是常见情况。那么,当实际的常见情况是批量处理时,我们为什么要以个体为中心来设计呢?
同时,我们不想隐藏数据。数据是最重要的东西。我们希望使其透明,为高效处理而布局,并希望行为集中在更高的层次,该层次可以看到所有数据,并能够找出处理这些数据的最佳方式。
这也是我觉得更主观的一点,但我感觉这种思维定势倾向于鼓励开发者为今天做计划。你想为你手头的问题设计,你想优先考虑性能和简单性来解决那个问题,你不想解决你没有或未来可能有的任何问题。有时这实际上可能会带来回报。有时,如果你一开始没有用错误的抽象,那么改变你的代码以适应新问题可能会更容易。所以有时,即使对于未来的可扩展性,这也可能带来回报。
那么,我们如何转变思维定势?我认为你必须内化这一点:无论怎样,代码的唯一目的是转换数据。重点不应该是模拟我们头脑中合理的抽象对象世界,而应该放在数据的旅程上。所以我们想从 A 点到达 B 点。我们如何高效且以最简单的方式做到这一点?数据是核心,它不是我们想要隐藏的东西。为什么要隐藏程序中实际使其高效工作的最重要部分?我们实际上希望使其可见,理解它的形状、大小和访问模式。而这些将驱动应用程序的设计,以及我们对数据执行的操作。
现代计算机,即使是你拥有的最好的超级计算机,都擅长简单且可预测的工作。所以只要你设计你的问题,为计算机提供长的、连续的连续数据流,你很可能会获得良好的性能。
最后,这更像是一个哲学问题。你想为你拥有的机器设计。你想熟悉你目标平台的硬件能力,因为有效的解决方案与你头脑中试图解决的问题的隐喻以及硬件的物理现实相一致。所以,如果你想采用数据导向设计而不是 OOP,这就是你需要转变的思维定势。
那么,我们如何开始优化我们的代码,朝着这种思维定势迈进,也许不是完全,但朝着这个想法迈进?
6:第一次优化:扁平化数据与逻辑分离
对于我们的第一次优化路径,我们将做几件事。首先,我们将摆脱单独的堆分配,我认为这是我们程序的主要瓶颈。我们将摆脱继承,这将扁平化我们的层次结构。并且我们将数据与逻辑解耦。实体类现在将只是数据,而逻辑将上移一层。世界将是处理所有行为的那一个,这样我们可以看到完整的图景,并对数据进行批量操作。
同时,我们有一个问题,之前我们有一个很好的实体向量,可以同质地存储东西。但现在我们要做的是,我们将有多个容器,每个要存储的实体类型一个。这可能看起来更麻烦,但你会看到,当我们想以不同方式处理这些东西时,这实际上是有意义的。它们具有不同的属性和行为。
所以我们将有我们的 emitter 结构体、particle 结构体和 rocket 结构体。
它们都将有自己的物理量。所以我们有一点重复,但这是最小的,谁在乎呢。
发射器将有自己的浮点计时器和生成速率。粒子将具有与以前相同的量。但现在我们有一个问题,之前我们可以区分火焰和烟雾粒子,因为我们有一个很好的层次结构。那么我们现在该怎么办?
目前,我将采用简单的方法。我会这样做:我们将有一个名为 particle_type 的枚举类。它要么是烟雾,要么是火焰。我将在发射器和粒子中都存储这个信息。根据这个信息,我们将做不同的事情。这不理想。我们稍后会看到如何改变这一点。但到目前为止,这没问题。
我们遇到的另一个问题是,在火箭中,我们需要引用两个发射器。我们希望它们链接在一起。之前我们有一个很好的特性,可以使用发射器的地址,它是稳定的,我们可以用它来在这些东西之间通信。但现在如果我们移除堆分配,就不能保证发射器的地址是稳定的。向量可能会重新分配,我们可能会在内存中移动东西。那么我们该怎么办?
通常有很多解决方案。但解决这个问题的一个常见方法是使用索引。所以你将不依赖于实际对象内存的地址稳定性,而是依赖于该对象所属向量中位置的索引稳定性。你可能需要稍微改变一下向量。我们将看到这是处理这个问题的常见方式。其他方法可能是使用某种哈希表来存储键,然后查找对象;或者使用专门设计的数据结构来帮助你实现这一点。但一般来说,我想在这里指出的重点是,现在关系就是数据,只是一个数字。所以它不再是硬件特定的东西,而只是一个索引。
那么,我们如何改变我们的世界以适应这个新设计?我们将有一个粒子向量,一个火箭向量,以及一个关联的 add_rocket 函数,该函数最终会完成与发射器的任何必要连接。
然后我们将这样做,我认为这很有趣:我们将有一个 std::vector<std::optional<emitter>>。我在这里使用 optional 的原因是为了保证可索引性。所以你可以把它想象成一些槽位,发射器可能在也可能不在。通过查看索引,我们可以保证索引 4 处的发射器始终是同一个发射器。如果你想移除一个发射器并销毁它,我们只需使该槽位为空。但我们不必在内存中移动任何东西,因此可索引性得以保留。同样,还有其他方法可以处理这个问题。但这是一个非常简单的解决方案,适用于我们的用例。

为了配合这个设计,我们还将有一个 add_emitter 函数,给定一个发射器,会将其放入第一个可用的空闲槽位,然后返回该槽位的索引。所有这些结合起来,它们共同工作,将基于地址稳定性的关系替换为数据驱动的关系。我们只处理数字、索引,并且我们获得了与以前相同的关系行为。
我们将有通常的更新、绘制、清理。所以我们将看看它们如何变化。
update 函数在我看来会很有趣,因为现在我们可以批量处理所有粒子。我们不是告诉每个粒子“请更新自己”。我们拥有粒子的完整视图,我们只是遍历它们并执行操作。你已经可以开始看到编译器在这里有更多信息来优化、向量化并对代码做很酷的事情。
对于发射器,我们将遍历所有 optional。我们将跳过那些里面什么都没有的槽位。我们将进行更新,然后创建新粒子。现在,在这里,我们实际上将根据发射器的类型进行分支。根据它是烟雾还是火焰,我们将向向量中推入不同的东西。同样,我们稍后会对此进行改进。但到目前为止,这没问题。
最后,对于我们的火箭,我们移动它们。这没问题。有趣的是这里:当我们想获取关联的发射器时,我们只需使用存储在火箭中的索引查看发射器向量。我们检查那个 optional 是否有效。我认为它应该总是有效的,所以也许这应该是一个断言。但你知道,我在这里用了 if。然后我们将把发射器的位置设置为与火箭相同的位置,加上一些偏移量,使其看起来更好一点,这样粒子实际上是从火箭尾部生成的。对火焰发射器也做同样的事情。
我想展示的最后一部分是 add_emitter,它是负责创建新发射器的函数。我们做的是遍历所有槽位。如果我们找到一个可能曾经被发射器使用但现在为空的空槽,我们可以直接将发射器放置在那里并返回该索引。所以我们正在重用现有的槽位。如果向量完全满了,那么我们就 emplace_back。我们有一个新的可用槽位。这可能会在底层导致内存重新分配,但我们不在乎,因为我们不依赖于此。我们依赖的是索引。现在,这个算法是线性的,但你可以想象,如果你想优化它,你可以维护一个空闲索引列表,在创建和移除发射器时跟踪它。所以当你想要创建一个时,只需弹出一个索引并使用它,当你完成时再把它推回去。所以你可以很容易地使其成为常数时间。我只是想保持简单,因为在这个程序中,与粒子相比,发射器的数量非常少,我们在这里有这个 O(n) 算法并不重要。
好了,最后一部分是清理。我们将对粒子使用 std::erase_if,如果它们已经淡出,我们就移除它们。我们将对火箭使用 std::erase_if,如果它们到达屏幕右侧,我们就移除火箭。同时,在谓词内部,我们将利用这个机会也销毁关联的发射器。所以如果我们知道到达了屏幕末端,我们还将重置发射器向量中的那些 optional,以便这些槽位可以被重用。然后我们将向算法返回 true。是的,随意处理这些东西。
那么,这如何改变我们的性能?让我们再做一轮基准测试,同样的硬件,同样的程序,同样的条件。
我们有非常显著的改进,对于 20 万个实体,甚至对于 40 万个实体更多。正如你所见,这种趋势持续下去。平均而言,仅仅通过改变我们存储数据的方式,更新时间就减少了 70%。我们没有改变任何操作,我们对数据执行完全相同的计算,只是改变了我们存储数据和处理数据的方式。
我也没有在这里展示,但渲染性能也提升了 8 倍,因为现在数据是按组分组的,我可以轻松地将所有粒子作为一批发送到 GPU,轻松地将所有火箭一次发送出去。所以随着时间的推移,如果你这样做,特别是在图形开发中,你会发现数据导向布局实际上对 GPU 非常友好。所以你做得越多,你就越能有效地将数据发送到 GPU,这是一个附带的好处。
好了,我不知道这对任何人来说是否令人惊讶。但你知道,你可能预料到了这一点。但在演讲开始时,我也提到,我想让这不仅仅是关于性能,也是关于简单性。那么让我们看看这是否真的有效。让我们看看它是否真的让事情变得更简单。
7:数据导向设计的简单性优势
现在,我想展示的第一件事是,假设我们有一个新需求。例如,我们想跟踪火箭的具体数量。这实际上是我为演示尝试做的事情。我想有一个实体总数的计数器,但我也想知道其中有多少是火箭。我尝试了,但我做不到,因为在 OOP 方法中,高效地做到这一点是看似简单实则困难的。
我在 OOP 方法中尝试的第一件事是这样的:我将遍历所有实体,使用 dynamic_cast。如果它是火箭,我就增加火箭数量。现在,我不喜欢 dynamic_cast,我和其他人一样讨厌它,但这似乎是它的一个好用例。这是一个统计指标,我只是想作为一个附带的东西。这是一个边缘情况,我只想知道我有多少火箭。这确实有效,但它实际上出现在我的性能分析器中。我因为 dynamic_cast 损失了毫秒级的时间。所以这是不可接受的。我本会让这个 OOP 解决方案比现在更慢,只是为了计算火箭的数量,这让我很惊讶。我以为它会有一些开销,但不会非常显著。
然后我意识到,好吧,也许我可以这样做。我可以让我的实体有一个 get_type 虚成员函数,返回一个实体类型。然后我可以避免从 dynamic_cast 获得的开销。但这就是目的,对吧?我不想让实体通过枚举告诉我它是什么实体。OOP 的重点是我想在抽象意义上思考实体,我不希望实体告诉我它是什么类型。否则,我一开始为什么要使用 OOP?这样做似乎不对。
也许我想,火箭本身在构造时可以通知世界:“嘿,来了一个新火箭。”然后在销毁时,你可以告诉世界我要退出了。但这感觉也不对。你在类的内部隐藏了更多的状态突变,所以更难看到代码的流程。而且,我觉得这违反了单一职责原则。为什么火箭要负责指标?这似乎不对。
那么世界也许可以这样做。也许我可以有一个 add_rocket 函数,我只能通过这个函数创建火箭,并跟踪火箭数量。然后在清理时,我会做一些簿记来递减计数。但现在我为火箭添加了一个专门的函数。同样,我想以实体的方式思考。我想给世界一个实体,而不是火箭,这违背了目的,对吧?所以我不喜欢这个。而且由于簿记,还有更多的复杂性。我不是说这不可能,你可以让它工作,只是对于我想做的事情来说过于困难了。所以我最终放弃了。你在演示中看到,我只有实体数量,你知道,这是我所能做的最好的。
那么数据导向方法呢?就这样。我知道有多少火箭,因为我单独存储火箭。所以我只需调用向量大小的函数,就可以免费获得它们。我觉得在很多事情上,这只是一个例子,你可能会认为这是人为的。但随着时间的推移,随着我转向数据导向设计,我不是说我每次都会完全采用数据导向,但我感觉我越来越频繁地获得这种小胜利。所以它确实让事情变得简单。
我想给你的另一个例子是,想象你是一个团队的新成员,必须在这个演示上工作。你必须扩展代码,理解它。所以你会去代码库,开始做一些代码审查,尝试理解所有移动部件如何交互,它是如何工作的,以及你需要做什么来改变它。
所以你在这里看到实体,你会想:“好吧,这看起来简单。”然后你看到:“哦,但我们有一个对世界的引用,还有这个额外的 alive 布尔状态。”然后你开始想,现在每个实体最终都可能做一些改变其他实体状态的事情,要知道这一点,我必须交叉引用代码库中的所有文件,看看发生了什么。你必须在源代码中跳来跳去才能获得完整的图景。
同时,这对我来说也不太对劲,这真的很烦人,因为我们把 draw 成员函数放入了虚层次结构继承 API 中,与渲染系统紧密耦合。所以如果我们想从 SFML 切换到 SDL 或其他库,我们不会这样做,因为我们必须更改 20 个类,它们紧密耦合在一起,这很不幸。

同时,你知道,如果你看世界,你记得,是的,表面上
009:更高速度与更简实践

在本节课中,我们将要学习数据导向设计(Data-Oriented Design, DOD)的核心思想,并通过一个火箭粒子系统的实例,对比面向对象设计(OOP)与数据导向设计在性能、代码简洁性和可维护性上的差异。我们将从硬件缓存的工作原理出发,理解为何数据布局如此重要,并逐步重构代码,最终实现显著的性能提升。
1:引言与目标设定

我们将讨论数据导向设计,特别是其实践应用。我想从一张照片开始。这是我的朋友Mike Acton在2014年做主题演讲时的照片。那是我第一次参加技术会议。当时,Mike在Insomniac Games工作,他上台挑战了一些C++开发者的核心信念。这对我个人而言,是我数据导向设计旅程的起点。

我不知道你是否认出了这个人。那是我,很久以前的我。我现在看起来不一样了。我在这里向Mike提问。对我来说,那是一个改变职业生涯的时刻,因为我当时没有意识到,除了OOP之外,还有另一种完全不同的思考代码和软件的方式。即使现在,我不同意那次演讲中的某些结论,我仍然要感谢Mike为我打开了新世界的大门。我仍然推荐你去看看那次演讲,它非常精彩。


在开始之前,简单介绍一下我自己。目前,我是一名独立的C++顾问、培训师和导师。我的业务专注于提供C++培训和一对一指导。我提供技术培训和会议演讲。如果你有兴趣,欢迎会后联系我或访问我的网站。
我在Bloomberg工作了十年。主要从事高性能C++后端开发。我在微服务基础设施团队和自有市场分析团队工作过,也为Bloomberg工程师提供过现代C++、元编程和多线程等技术培训。
我参与了C++标准化工作。如果你听说过function_ref,我对此负有一部分责任。它将在C++26中引入。你可能也听说过我的epos提案,它现在部分体现在profiles中。
我对游戏开发、开源充满热情。我是SFML团队的成员,领导了该库现代化以支持C++17的工作。我强烈建议你了解一下。我们今天将在演示中使用它。我还为其他库(包括SL)做出过贡献,并在Steam上发布了两款商业游戏,它们也是开源的。此外,我喜欢虚拟现实,为《半条命2》和《雷神之锤》等游戏的模组做出过贡献。
最后,我是《Embracing Modern C++ Safely》一书的合著者。我与朋友兼同事John Lakos、Russel Lapenna和Ali Meredith共同撰写了这本书。它是一本关于所有现代C++特性的参考书,详细介绍了优缺点。如果你对此感兴趣,欢迎会后向我咨询。
但今天的主角不是我,让我们看看今天的目标。
今天的目标是,我希望你能发现一种新的思维方式。我不知道在座有多少人熟悉数据导向设计或实体组件系统。请举手示意。看来有不少人熟悉,也有不少人不太熟悉,这很好。所以,如果这对你来说是新的,你将学习到以数据优先的方式思考如何改变你设计系统的方式。
同时,我们将重温硬件的工作原理,特别是内存以及它如何与CPU通信,以及为什么这对软件设计很重要。
我们将以互动的方式进行。我们将一起构建这个小演示。我会先展示演示,然后逐步构建它。我们将看到,在不改变对数据执行的操作的情况下,仅改变数据的布局方式,就能对性能产生巨大影响。
同时,我不想只关注DOD的性能方面,我还想说明,它可以使你的代码比OOP更简单、更易于维护,这听起来可能有些反直觉。但我将展示一些我认为确实如此的例子。
此外,这可能是我与游戏开发界的其他人士看法略有不同的地方。我认为在某些地方,OOP仍然有意义。我们稍后会讨论这一点。我认为,明智地使用高级C++抽象实际上可以帮助你实现目标,并且在某些情况下,可以帮助你获得更好的性能和更高的代码可维护性。
2:演示与需求分析
现在,我将直接进入演示。我将展示我们要实现的东西,并让你了解我们要做什么。

对于这次演讲,我选择了一个相当简单且视觉效果有趣的东西。我们将制作一个演示,其中有火箭飞来飞去。让我放大一下。屏幕上的每一个火箭、烟雾粒子和火焰粒子都是一个独立的实体,被模拟。每个实体都有自己的物理量,你可以通过某种标识来引用屏幕上看到的每一个特定事物。
为了使需求更明确,我们希望一切都遵循物理定律。因此,我们有一个包含位置、速度和加速度的基本运动模型。
我们应该能够定制这些效果、粒子和发射器,使其具有随时间变化的不同不透明度、缩放和旋转。
一切都是一个“演员”。所以,如果我关心某个特定的火箭或粒子,我应该能够获得它的句柄并对其进行操作。例如,每个火箭实体都有一些附加的发射器,这些发射器保持同步并生成粒子。
最后,我希望以可扩展的方式设计这个系统。应该很容易添加新效果、新演员、新粒子类型等。所以,这有点像是一个玩具程序,但我通过要求能够将实体附加到其他实体上来使其更真实。我觉得它很好地隔离了性能原则。你从这个玩具程序中学到的经验教训也适用于现实世界的代码。
现在,如果我打开这个菜单,你会看到一些关于更新和绘制性能的指标。如果我开始生成一些新的火箭,由于某种原因,渲染不同步了。你会看到,随着火箭的生成,帧率会急剧下降,变得不再可玩。我也可以缩小,显示我们有很多火箭。在这里,你可以选择我们将要使用的数据布局。这是OOP实现。但如果我们切换到像AOS这样的布局,你会看到帧率显著提高,变得非常流畅。你可以尝试其他内存布局等。但重点是,我们有了这个演示,可以操作它,我们执行的操作是相同的。改变的只是我们使用的内存布局,正如你所见,这带来了巨大的差异。现在,我们将看看这是如何实现的。
3:面向对象设计实现
那么,让我们开始工作,实现这个系统。
我们将从根据OOP原则开始设计。我们有一个对问题的现实世界模型解释,并尝试将其转化为代码。
我们知道将会有多个实体,所以可以从一个实体基类开始。我们将有一个发射器类来生成粒子,一个火箭类飞来飞去并有关联的发射器,以及一个粒子类。
发射器可以是烟雾发射器或火焰发射器。同样,粒子可以是烟雾粒子或火焰粒子。浅蓝色表示基类,深蓝色表示具体类。我喜欢这个设计,它简洁明了。如果我想添加新东西,只需从实体派生。如果想添加新类型的发射器或粒子,只需从这些类派生。它满足了所有需求,似乎很容易扩展。所以,我看不出有什么特别的问题。让我们尝试实现它,看看代码实际上是什么样子的。
我们将在这里有一个struct entity。为了保持幻灯片简洁,我将使用struct。但在现实中,你会使用class以及private和public等。此时,我只想关注性能和内存布局方面。
因为这将是一个多态基类,我们将有一个虚析构函数。这里我们可以使用默认实现。然后,我们将有一个虚update成员函数,它接受增量时间(即帧之间经过的时间量),并使用它来推进实体的状态。我们还将有一个虚draw成员函数,它接受一个来自SFML库的render target。这只是一个抽象,允许你在纹理或窗口上绘制东西。在我们的例子中,它将是应用程序的窗口,所以在这里不太重要。
因为一切都需要遵循物理定律,我们将有一堆Vector2f,它们只是用于位置、速度、加速度的二维浮点向量。如果你想看看它实际上是什么样子,这只是两个浮点数X和Y,带有一些很好的运算符重载,允许你进行乘法、加法等操作。同样,这来自SFML,它是一个对二维向量非常方便和简洁的抽象。
差不多就是这样。如果我们想实现实体的update部分,我们可以通过速度乘以增量时间来移动位置,并通过加速度乘以增量时间来移动速度。这是非常简单的物理积分,不是最准确的,但我们主要关注内存布局,而不是模拟的准确性。
但我们有一个问题,实体可能需要知道其他实体的状态,可能需要按需创建其他实体。所以我们需要某种方式来实现这一点。
我想到的一个方法,并且在许多其他地方见过,是这样的:实体知道它所属的世界。通过那个世界引用,它可以查询任何其他实体的状态,或者可以创建新实体,可以与世界的状态交互。所以我们将为我们的程序做类似的事情。我们还将有一个名为alive的小布尔值,它将负责告诉世界:“嘿,这个实体完成了,请处理掉它,回收内存并将其用于其他事情。”它将被用来与已经可以清理实体的世界进行通信。我们使用布尔值的原因是,我们希望能够通过update成员函数来传达这一点。update中可能有一些条件使实体有资格被清理。
这就是实体。为了向你展示更多代码,我们有粒子。它将有自己的缩放、不透明度和旋转。它们可以随时间变化,所以我们也有变化率作为数据成员。
我们可以重写update成员函数,使用我们在实体中编写的代码,遵循DRY原则(不要重复自己)。然后对缩放、不透明度和旋转进行类似的物理积分。
这里有趣的是,我们只在透明度大于0时才将布尔值alive设置为true,这基本上意味着我们清理粒子的条件是当它们淡出时,因为在我们演示中,每个粒子最终都会淡出。这对于在完成后清理它们来说已经足够了。
为了展示烟雾粒子的样子,它只是从粒子派生而来。我们基本上免费获得了一切,这很好。我们唯一需要改变的是,我们想重写draw成员函数,并指定我们想使用该数据来绘制带有烟雾纹理的东西。你可以想象,这也可以用于火焰粒子以及未来你想添加的任何其他粒子。
世界可能是最有趣的部分。屏幕上已经有三种实体。我们希望以一种方便我们使用的方式存储它们。
我们将这样做:我们将有一个std::vector<std::unique_ptr<Entity>>,包含所有实体。
在座那些对数据导向设计有些熟悉的人可能已经在想:“嘿,没人会这样写代码。每个实体都会有一个堆分配,这不会很缓存友好。”但相信我,我在生产环境中见过很多次这样的代码。如果你在GitHub上搜索,有时甚至使用shared_ptr而不是unique_ptr,你会找到成百上千的例子。你可以读到关于这在AAA工作室发生的故事。人们已经成功地用这种设计发布了非常成功的好软件。我并不是说你不能这样做,也不能成功。但如果你知道这种设计的缺点,你也应该知道这实际上在实践中已经做过。我想知道在座有多少人写过或见过这样的代码。
好的,看来几乎所有人都有过。你知道,这不是玩具代码。这实际上会发生。我还想说,非常流行的库和引擎也做类似的事情,包括SFML库。所以这是一种到处都在使用的东西。
update和draw成员函数非常简单直接。我们只是遍历所有实体,并调用update和draw。你可能会看到这样做的好处:我们不关心update做什么,也不关心draw做什么,我们只是委托给实体。这很好。最后,我们会在最后做一些清理工作。我们调用std::erase_if,这是C++标准中一个相当新的函数。它接受一个容器和一个谓词,并有效地重新排列容器中的项目,以便可以在常数时间内移除它们。
发射器,我们将做类似的事情。我们将有一个计时器来跟踪我们想要生成粒子的频率。我们希望有不同的生成速率。然后我们想做些有趣的事情:我们将定义一个名为spawn_particle的纯虚函数,它将被实际的发射器类型重写。
发射器基类的update函数将定期调用spawn_particle,但实际派生发射器将重写该函数并决定生成粒子的具体含义。烟雾发射器将在内部使用make_unique分配一个新的烟雾粒子,然后将其推入世界。你还可以在这里看到,我们如何从实体的update内部引用世界,以影响状态并动态创建新事物。
最后我想展示的是,我提到火箭需要有一些关联的发射器。所以我们将在这里使用原始指针。我们会说:“好吧,我是火箭,我需要知道什么烟雾发射器与我关联,什么火焰发射器与我关联。”由于我们隐含地知道世界是实体的所有者,并且只要火箭还活着,那些发射器就会活着,使用原始指针在这里是可以的。我们考虑过生命周期,所以这在我们的模型中有效。
这也是你经常看到的,即使是在大型程序中。在同一程序的不同组件之间引用时,通常使用指针。你依赖于这些对象的地址稳定性。在这种情况下,你可以这样做是因为我们在堆上分配它们。所以,即使包含所有实体的向量被重新分配,即使我们在世界中移动,发射器的地址也将是稳定的。
当我们创建火箭时,我们创建发射器并将它们连接起来。当我们更新火箭时,我们将与火箭一起移动它们。这非常简单。这基本上就是我们演示所需的所有代码。
4:性能基准测试与硬件原理
那么,让我们看看这有多快。我们来做个简单的基准测试。
我使用的机器不是我现在演示用的平板电脑。这是我的台式机,配置相当不错。它有一个几年前顶级的Intel Core i9处理器,速度很快。有DDR5内存。我使用Clang++编译,启用了-O3优化。为了这些指标,我禁用了渲染。但我也将讨论渲染,因为改变数据布局不仅对更新有益,对渲染也有益。
在20万个实体时,我们得到2.3毫秒;40万个实体,6.6毫秒;60万个实体,12毫秒;80万个实体,16.6毫秒;100万个实体,21.6毫秒。
现在,你可能会想,这只是毫秒,这很快,谁在乎呢?但如果你考虑针对实时应用,要达到60 FPS(这是良好交互体验的最低要求),单帧的预算大约是16.67毫秒。你可以看到,在60万个实体时,我们已经用掉了大部分预算。如果你想想还要加上渲染,加上更复杂的逻辑算法,你就没有太多操作空间了。
同时,请记住,这是相当好的硬件。在移动设备上,这可能完全无法接受。同时,我觉得在2025年,60 FPS已经相当低了。即使是现在的手机,也有120 Hz刷新率的显示屏。所以,我认为要获得非常流畅的交互体验,最低应该是120-144 FPS。如果你想达到那个帧率,单帧的预算将少于7毫秒。所以我们在这里非常有限,几乎没有预算剩余,只有40万个粒子。我们谈论的是C++,是快速的硬件。我们应该能做得比这更好。
我们在演示中看到,改变事物的内存布局可以显著加快速度。所以问题不在于算法,不在于我们执行的操作,而在于数据在内存中的布局方式。
CPU在这里实际上大部分时间处于空闲状态,它没有做任何工作,只是在等待数据到达并浪费时间等待,如果我们关心性能,这同样是不可接受的。
所以让我们绕个弯,我将给你稍微复习一下内存,告诉你为什么理解它的内部工作原理对于设计高效软件、充分利用CPU很重要。
在非常高的抽象层次上,你可以把CPU和RAM想象成通过某种总线连接,它们进行通信,一切都很顺利。
如果我们剥开一层洋葱,再降低一个层次,同样在较高的抽象层次上,你可以把CPU想象成这样:我们有一个核心,实际操作在那里进行。有一些缓存,这是一些非常小但非常快的临时内存。然后通信必须经过一个层次结构。RAM中的数据首先必须经过缓存,然后从缓存到核心,反之亦然。所以你需要的每一个操作都必须遵循这条通过内存的路径。实际上,我们稍后会看到,我们有多个缓存层,多个核心,所以比这更复杂,但主要原则仍然适用。内存必须遵循这个层次结构并向前移动。
缓存,你可以把它想象成这是一个非常小但快速的行的集合。这些行被称为缓存行。这是一个非常重要的概念。缓存行是最小的可传输内存单元。所以即使你只关心一个字节,你仍然必须将整个缓存行从RAM传输到CPU,即使你只获取那个字节。
同时,你可以想象RAM是一个巨大的缓存行集合,非常大,但速度慢。例如,如果你想读取地址18处的数据。它不在缓存中,或者缓存是空的,所以我们可以在RAM中识别数据在那里。即使我们只关心地址18处的数据,我们仍然必须从RAM中取出整个缓存行,这是一个缓存未命中,这意味着我们取走整行并将其复制到缓存中。如果你想象我们不关心这个数据相邻的内容,不关心D旁边是什么,那么我们浪费了很多时间移动我们不关心的数据,这很不幸,并且我们低效地使用了缓存。
我们可以继续这样做。也许你想读取地址26处的一些数据。它不在缓存中。同样,我们在RAM中识别它在那里,我们必须取走整个缓存行并将其移入缓存,这又是一个缓存未命中,非常不幸。
在最好的情况下,例如,如果你想读取地址19处的数据。我们识别出它已经在缓存中,你可以在这里看到它。那么在这种情况下,我们谈论的是缓存命中。我们不需要往返于RAM,因此我们将能够更有效地读取这些数据。
现在我为什么要告诉你这些?影响是什么?如果我们必须去RAM,或者我们可以留在缓存中,这有多大关系?
我喜欢直观地解释事情。所以我做了一个小动画,展示了L1缓存(CPU中最快但最小的缓存)和核心之间的竞赛。在底部,我们有RAM和CPU。在顶部,我们将看到数据已经在缓存中的最佳情况。在底部,我们将看到每次需要数据时都必须返回RAM的最坏情况。
现在,这张幻灯片将显著放慢速度,因为我们谈论的是纳秒级的操作,但它是按比例缩放的。请记住这一点。3,2,1,开始。
当缓存中的数据完成多次往返时,你可以看到RAM仍然,你知道,几乎才到一半。我们已经做了很多操作。
所以这实际上,正如我提到的,是按比例缩放的。这实际上就是你的程序中正在发生的事情。所以,如果你的所有数据都来自RAM,你只是在获取数据上浪费了大量时间。
在最坏的情况下,它可能慢100倍。因此,与L1缓存相比,往返RAM可能比L1缓存慢100倍。给你一些数字,人类平均眨眼的时间是100毫秒。一次L1缓存引用所需的时间是0.5纳秒。在你眨眼的时间里,你可以进行2亿次L1缓存引用。而RAM将比这慢100倍。所以影响相当大。大到有时数据所在位置的选择比你使用的算法或数据结构重要得多,这可能相当令人惊讶,特别是如果你深入理论计算机科学,有时复杂度更差的算法在实际硬件上可能表现更好。
5:数据导向设计思维转变
那么,我们学到了什么?非常重要的一点是,最小的通用可传输内存单元是缓存行。所以无论你想要多少字节,你都必须获取整个缓存行。在现代桌面CPU上,这通常是64字节。幸运的是,在C++中,我们有一个非常直观的方式来查询缓存行大小,即std::hardware_destructive_interference_size。所以,你可以记住这个。但你可以认为是64字节。
所有数据都必须始终遍历内存层次结构。所以,如果你需要RAM中的某些东西,它必须经过所有缓存层,然后返回,如果需要刷新回RAM。这意味着数据的空间局部性(即数据在内存中的实际布局方式)极大地影响性能。正如我提到的,有时甚至比算法或数据结构的选择更重要。
因此,我们可以真正开始思考一些技巧。
如果我有关联的数据,并且我想在相对接近的时间内访问它们(例如,访问第一个之后,我想访问第二个、第三个等等),那么最好将它们一起存储在内存中,物理上彼此靠近。因此,优先选择平坦且连续的存储可以更好地利用缓存,并最大化你在获取这些缓存行时,你想要的数据已经在那里的机会。
同时,还有一些我没有提到的东西,即预取。CPU有这种推测机制,基本上可以找出你访问内存的模式,例如,如果你在一个循环中向前或向后移动,或者每隔n个元素跳转,或者进行跨步访问,CPU可以识别出来,并且在你请求之前就开始给你提供未来可能需要的缓存行。因此,进行非常可预测的操作也可以极大地提高程序的性能。
正如我之前提到的,我骗了你,实际情况更复杂。所以,如果你想更现实一点,在更多核心的CPU上,情况看起来更像这样:你可能有一个在多个核心之间共享的L3缓存,它更大,但更慢。每个核心可能有一个L2缓存,比L1大一点,但慢一点。然后你可能有一个用于数据的L1缓存和一个用于指令的L1缓存。现在,我觉得这很有趣,因为,你知道,代码也是数据。当你将程序编译成二进制文件时,生成的代码必须加载到内存中。所以有时,如果你为大小而不是速度优化代码,它实际上可能更快。原因是它可能更好地使用指令缓存,你的热点循环可能更多地放入代码缓存本身。所以我们不会在这次演讲中讨论这个,但如果你深入研究这个主题,有时代码的对齐方式对你的性能也很重要。
关于硬件、CPU、内存等,我就讲这么多。我想向你推荐Scott Meyers在2014年的一次精彩演讲,但今天仍然非常相关:《CPU Caches and Why You Care》,它深入探讨了细节。此外,Jonatan Müller在CppCon上做了一次名为《Cache Friendly C++》的演讲,也涵盖了相同的主题并相当深入。所以我认为,如果你看了这两个演讲,你会对现代硬件有一个很好的理解和认识,并且能够对什么可能快或慢有一个很好的直觉。我强烈推荐观看这两个演讲。
休息一下。哦。那么,考虑到这些,为什么我们的实现很慢?我们实际上可以通过查看世界实现来很容易地弄清楚。
我们有这个entity_vector,它是一个vector<unique_ptr<Entity>>,这意味着每个实体都将在内存中的某个地方分配,很可能不靠近其他实体,会分散在各处。这意味着如果我们在update中迭代,在draw中迭代,在最坏的情况下,在erase_if中迭代,每次迭代都可能是一个缓存未命中。我们已经看到这有多慢,可能比L1引用慢100倍。所以这对CPU来说可能是最坏的情况。它将待在那里等待数据到达。
同时,我们还有其他开销来源。我们使用虚函数分派,使用虚函数表和运行时多态。所以每当你访问update或draw成员函数时,都会有一个虚函数表查找,这有一些开销,可能不如缓存未命中重要,但这是我们需要注意的另一件事。
最后,如果你还记得我们生成粒子的方式,我们实际上在这里调用了make_unique,而erase_if实际上会销毁那些unique_ptr。所以我们有频繁的动态分配和释放。所以你已经可以想到这不会很高效。我们做了很多额外的工作,很多内存等待,以及由于虚函数和分配带来的很多开销。
那么我的问题是,为什么我们这样写代码?为什么我们直接跳到了OOP层次结构和虚接口等等?
我认为这与面向对象的思维定势有关。
大多数人的编程入门实际上是OOP。大学教材,无论你学什么关于类的知识,你都会学到继承,学到通常的形状基类,可以是圆形、矩形,或者动物基类等等。而且,这也是一个自然的选择。对人类来说,它与我们对世界的看法非常吻合。我们以个体对象、个体事物的方式思考。我们头脑中有这种“是一个”的关系。它很有效。
所以这种思维定势大致是这样运作的,我认为有四点很重要。
我们试图模拟一个自主对象的世界。我们思考具有自己身份和责任的独立代理。我们有一个粒子。粒子有自己的数据。它知道如何更新自己,知道如何绘制自己。
这些实体通过消息与程序的其他部分相互通信。主循环,世界不知道粒子实际上在做什么,它只是请求:“请更新你自己”,“请绘制你自己”。我们不关心内部细节,我们只是要求执行这些操作。
数据是隐藏的,所以我们不关心。我们不暴露这些类的内部细节,我们隐藏它们,封装它们,但我们暴露行为。我们不关心粒子如何被更新。这有时可能很好,因为它允许我们改变内部表示而不改变行为。但我们失去了关于粒子数据布局的一些非常重要的信息。
而且,我觉得OOP倾向于鼓励人们为未知做计划。所以你会尝试找出某种抽象或接口,它不仅适用于今天的问题,也适用于未来可能遇到的任何问题。我在这里非常小心地选择了“赌”这个词,因为在我看来,这确实是一种赌博。预测未来会有什么样的需求真的很难。如果你的预测错了,摆脱错误的抽象有时比一开始就没有做更昂贵。所以如果你幸运,你可能会节省一些时间,但更多时候,预测未来可能需要什么是非常困难的。
与此相反,如果我们把这个放在一边,让我们看看数据导向思维如何看待相同的问题。
我们不想模拟一个自主对象的世界。我们想模拟一个数据转换的世界。我们将代码视为将数据从一种状态转换到另一种状态的管道。我们并不真正关心对象、身份、封装的概念,它只是数据。
我们没有消息,我们直接对数据批次进行操作。所以实体本身不再处于控制之中。个体不再重要。我们从父对象对数据进行批量操作。世界将负责所有实体的更新和绘制。它处于控制之中。我在这里说“批量”,因为对于这类应用程序,最常见的情况不是添加单个粒子或单个实体,拥有许多实体才是常见情况。那么,为什么我们要以个体为核心进行设计,而实际的常见情况是批量处理呢?
同时,我们不想隐藏数据。数据是最重要的东西。我们希望使其透明。我们希望为了高效处理而布局它,并且我们希望行为集中在更高的层次上,该层次可以看到所有数据,并能够找出处理我所拥有数据的最佳方式。
这也是我觉得更主观的一点,但我感觉这种思维定势倾向于鼓励开发者为今天做计划。你想为手头的问题设计。你想优先考虑性能和简洁性来解决那个问题,你不想解决你没有或未来可能有的任何问题。有时这实际上可能会带来回报。有时,如果你一开始没有错误的抽象,改变代码以适应新问题可能会更容易。
6:首次优化:扁平化数据
那么,我们如何转变思维定势?我认为你必须内化这一点:无论怎样,代码的唯一目的是转换数据。重点不应该是模拟我们头脑中合理的抽象对象世界,而应该关注数据的旅程。所以我们想从A点到达B点。我们如何高效且以最简单的方式做到这一点?数据是核心,它不是我们想要隐藏的东西。为什么要隐藏程序中实际使其高效工作的最重要部分?我们实际上希望使其可见,理解其形状、大小和访问模式。而这些将驱动应用程序的设计,以及我们对数据执行的操作。
现代计算机,即使是你拥有的最好的超级计算机,都擅长简单且可预测的工作。所以,只要你设计你的问题,为计算机提供长而连续的连续数据流,你很可能会获得良好的性能。
最后,这更像是一个哲学问题。你想为你拥有的机器设计。你想熟悉你目标平台的硬件能力,因为有效的解决方案与你头脑中试图解决的问题的隐喻以及硬件的物理现实相一致。所以,如果你想从OOP转向数据导向设计,这就是你需要转变的思维定势。
那么,我们如何开始优化我们的代码,朝着这种思维定势迈进,也许不是完全,但朝着这个想法迈进?
对于我们的第一个优化路径,我们将做几件事。首先,我们将摆脱单独的堆分配,我认为这是我们程序的主要瓶颈。我们将摆脱继承,这将扁平化我们的层次结构。并且我们将解耦数据与逻辑。实体类现在将只是数据,逻辑将上移一层。世界将是处理所有行为的地方,这样我们可以看到完整的图景,并批量操作数据。
同时,我们有一个问题,之前我们有一个很好的实体向量,可以同质地存储东西。但现在我们要做的是,我们将为要存储的每种实体类型设置多个容器。这可能看起来更繁琐,但当我们想以不同方式处理这些东西时,你会看到这实际上是有意义的。它们具有不同的属性和行为。
所以我们将有我们的发射器struct,粒子struct和火箭struct。
它们都将有自己的物理量。所以我们有一点重复,但这是最小的,谁在乎呢。发射器将有自己的浮点计时器和生成速率。粒子将具有与以前相同的量。但现在我们有一个问题,之前我们可以区分火焰和烟雾粒子,因为我们有一个很好的层次结构。那么我们现在该怎么办?
目前,我将采用简单的方法。我会这样做:我们将有一个名为ParticleType的枚举类。它要么是烟雾,要么是火焰。我将在发射器和粒子中都存储这个信息。根据这个信息,我们将做不同的事情。这不理想。我们稍后会看到如何改变这一点。但到目前为止,这没问题。
我们遇到的另一个问题是,在火箭中,我们需要引用两个发射器。我们希望它们链接在一起。之前我们有一个很好的特性,可以使用发射器的地址,它是稳定的,我们可以用它来在这些东西之间通信。但现在,如果我们移除堆分配,就不能保证发射器的地址是稳定的。向量可能会重新分配,我们可能会在内存中移动东西。那么我们该怎么办?通常有很多解决方案。但解决这个问题的常见方法是使用索引。所以你将不再依赖实际对象内存的地址稳定性,而是依赖对象在其所属向量中位置的索引稳定性。你可能需要稍微改变一下向量。我们将看到这是处理这个问题的常见方式。其他方法可能是使用某种哈希表来存储键,然后查找对象;或者使用专门设计的数据结构来帮助你实现这一点。但一般来说,我想在这里说明的是,现在关系就是数据,只是一个数字。所以它不再是特定于硬件的东西,而只是一个索引。
那么,我们如何改变我们的世界以适应这个新设计?我们将有一个粒子向量,一个火箭向量,以及一个关联的add_rocket函数,该函数最终会完成与发射器的任何必要连接。
然后我们将这样做,我认为这很有趣:我们将有一个std::vector<std::optional<Emitter>>。我在这里使用optional的原因是为了保证索引的稳定性。所以你可以把它想象成是发射器可能在其中也可能不在其中的槽位。通过查看索引,我们可以保证索引4处的发射器始终是同一个发射器。如果你想移除一个发射器并销毁它,我们只需使该槽位为空。但我们不必在内存中移动任何东西,因此索引稳定性得以保留。同样,还有其他方法可以处理这个问题。但这是一个适用于我们用例的非常简单的解决方案。
为了配合这个设计,我们还将有一个add_emitter函数,给定一个发射器,会将其放入第一个可用的空闲槽位,然后返回该槽位的索引。所有这些结合起来,它们共同工作,将基于地址稳定性的关系替换为数据驱动的关系。我们只处理数字、索引,并且我们获得了与以前相同的关系行为。
我们将有通常的更新、绘制、清理。所以我们将看看它们如何变化。

update函数在我看来会很有趣,因为现在我们可以批量处理所有粒子。我们不是告诉每个粒子“请更新你自己”。我们拥有粒子的完整视图,我们只是遍历它们并执行操作。你已经可以开始看到编译器在这里有更多信息来优化、向量化和用汇编代码做很酷的事情。
对于发射器,我们将遍历所有optional。我们将跳过那些里面什么都没有的槽位。我们将进行更新,然后创建新粒子。现在,这里我们将根据发射器的类型进行分支。根据它是烟雾还是火焰,我们将向向量中推入不同的东西。同样,我们稍后会对此进行改进。但到目前为止,这没问题。
最后,对于我们的火箭,我们移动它们。这没问题。有趣的是这一点:当我们想获取关联的发射器时,我们只需使用存储在火箭中的索引来查找发射器向量。我们检查那个optional是否有效。我认为它应该总是有效的,所以也许这应该是一个断言。但你知道,我在这里用了if。然后我们将发射器的位置设置为与火箭相同的位置,并加上一些偏移量,使其看起来更好一点,这样粒子实际上是从火箭尾部生成的。
我们对火焰发射器也做同样的事情。我想展示的最后一部分是add_emitter,它是负责创建新发射器的函数。我们做的是遍历所有槽位。如果我们找到一个可能曾经用于发射器但现在为空的空槽,我们可以直接在那里放置我们的发射器并返回该索引。所以我们重用了一个现有的槽位。如果向量完全满了,那么我们就emplace_back。我们有一个新的可用槽位。这可能会在底层导致内存重新分配,但我们不在乎,因为我们不依赖于此。我们依赖的是索引。现在,这个算法是线性的,但你可以想象,如果你想优化它,你可以有一个空闲索引列表,在创建和移除发射器时跟踪它。所以当你想要创建一个时,只需弹出一个索引并使用它,当你完成时再将其推回。所以你可以很容易地使其成为常数时间。我只是想保持简单,因为在这个程序中,与粒子相比,发射器的数量非常少,我们在这里使用O(n)算法并不重要。
最后一部分是清理。我们将对粒子使用erase_if,做同样的事情,如果它们淡出就移除它们。我们将对火箭使用erase_if,如果它们到达屏幕右侧就移除火箭。同时,在谓词内部,我们将利用这个机会也销毁关联的发射器。所以如果我们知道到达了屏幕末端,我们还将重置发射器向量中的那些optional,以便这些槽位可以被重用。然后我们将向算法返回true。是的,随意处理这些东西。
那么,这如何改变我们的性能?让我们再做一轮基准测试,同样的硬件,同样的程序,同样的条件。对于20万个实体,我们有非常显著的改进,对于40万个实体甚至更多。正如你所见,这种趋势持续下去。平均而言,仅通过改变我们存储数据的方式,更新时间就减少了70%。我们没有改变任何操作,我们对数据执行完全相同的计算,只是改变了我们存储和处理数据的方式。
我在这里没有展示,但渲染性能也提高了8倍,因为现在数据是按组分组的,我可以轻松地将所有粒子作为一批发送到GPU,轻松地将所有火箭一次发送出去。所以随着时间的推移,如果你这样做,特别是在图形开发中,你会发现数据导向布局实际上对GPU非常友好。所以你做得越多,你就越能有效地将数据发送到GPU,这是一个附带的好处。
7:数据导向设计的简洁性优势
好的,我不知道这对任何人来说是否令人惊讶。但你知道,你可能预料到了这一点。但在演讲开始时,我也提到,我不想只关注性能,还想关注简洁性。那么让我们看看这是否真的有效。让我们看看它是否真的使事情更简单。
现在,我想向你展示的第一件事是,假设我们有一个新需求。例如,我们想跟踪火箭的数量。这实际上是我为演示尝试做的事情。我想有一个实体总数的计数器,但我也想知道其中有多少是火箭。我尝试了,但我做不到,因为在OOP方法中,高效地做到这一点出人意料地困难。
我在OOP方法中尝试的第一件事是这样的:我将遍历所有实体。我将使用dynamic_cast。如果它是火箭,我将增加火箭数量。现在,我不喜欢dynamic_cast,我和其他人一样讨厌它。但这似乎是一个很好的用例。这是一个统计指标,我只是想把它作为一个附带的东西,这是一个边缘情况,我只想知道我有多少火箭。这确实有效,但它实际上出现在我的性能分析器中,我因为dynamic_cast而损失了毫秒级的时间。所以这是不可接受的,我本会使这个OOP解决方案变得更慢,只是为了计算火箭的数量,这让我很惊讶。我以为它会有一些开销,但不会非常显著。
然后我意识到,好吧,也许我可以做这样的事情。我可以让我的实体有一个get_type虚成员函数,返回一个实体类型。然后我将避免从dynamic_cast获得的开销。但这就是目的,对吧?我不想让实体通过枚举告诉我它是什么类型的实体。OOP的重点是,我想以抽象的方式思考实体,我不希望实体告诉我它是什么类型。否则,我为什么要首先使用OOP呢?这样做似乎不对。
也许我想,火箭本身在构造时可以通知世界:“嘿,城里来了一个新火箭。”然后在析构时,你可以告诉世界它要离开了。但这感觉也不对。你在类的内部隐藏了更多关于到达和离开的状态变化,所以更难看到代码的流程。而且,我觉得这违反了单一职责原则。为什么火箭要负责指标?这似乎不对。
那么世界也许可以这样做。也许我可以有一个add_rocket函数,我只能通过函数调用创建火箭,并跟踪火箭数量。在清理时,我会做一些簿记来递减计数。但现在我为火箭添加了一个专门的函数。同样,我想以实体的方式思考。我想给世界一个实体,而不是火箭,这违背了目的,对吧?所以我不喜欢这个。而且由于簿记,还有更多的复杂性。我不是说这不可能,你可以让它工作,只是对于我想做的事情来说过于困难了。所以我最终放弃了。你在演示中看到,我只有实体数量,你知道,这是我所能做的最好的。
那么数据导向方法呢?就这样。我知道我有多少火箭,因为我将火箭分开存储。所以我只需调用向量大小的函数,就可以免费获得它们。所以我觉得很多事情都是这样。这只是一个例子,对吧?你可能会觉得这是人为的。但随着时间的推移,随着我转向数据导向设计,我不是说我每次都会完全采用数据导向,但我感觉我越来越频繁地获得这些小胜利。所以它确实使事情变得简单。
另一个我想给你的例子是,想象你是一个团队的新成员,必须在这个演示上工作。你必须扩展代码,理解它。所以你会去代码库,开始做一些代码审查,尝试理解所有移动部件如何交互,如何工作,以及你需要做什么来改变它。
所以你在这里看到实体,你会想:“好吧,这看起来简单。”然后你看到:“哦,但我们有一个对世界的引用,还有这个额外的alive布尔状态。”然后你开始想,现在,每个实体最终都可能做一些改变其他实体状态的事情,要知道这一点,我必须交叉引用代码库中的所有文件,看看发生了什么。你必须在源代码中跳来跳去才能获得完整的图景。同时,这对我来说真的不对,这真的很烦人,因为我们把draw成员函数放在了虚函数层次结构、继承API中,与渲染系统紧密耦合。所以如果我们想从SFML切换到SDL或其他库,我们不会这样做,因为我们必须更改20个类,它们紧密耦合在一起,这很不幸。同时,你知道,如果你看世界,你记得,是的,表面上看起来简单,但实际上它隐藏了一切。这个update可能最终会销毁实体,可能最终会创建新实体。那么我怎么知道发生了什么?我必须查看每个实体。所以我认为,在实践中,这使得很难理解系统的全貌,你需要在头脑中记住所有可能存在的副作用,以及它们在任何时间点可以做什么。
是的,如果你还记得,这最终会影响外部世界。所以,我想你明白我的意思了。
对于数据导向设计,我发现很好的一点是,实体本身只是数据。逻辑和数据之间有明确的分离,那里没有任何隐藏的东西。它是完全解耦的。你看到的就是你得到的。就像任何成员或任何东西的构造函数中都没有特殊的副作用,它只是普通的数据,这很简单。
同时,如果我看看世界,我可以立即看到世界中有哪些东西。我知道我将有发射器、粒子和火箭,没有其他东西。在运行时,不能从某个地方添加其他东西,不能从某个派生类添加其他东西。你可以很容易地看到所有类型。

所以数据没有被隐藏。同时,在单个update函数中,我确切地知道每一个变化,每一个正在发生的操作。我可以看到这种基于阶段
010:C++方式的鸭子类型与类型擦除如何改变规则



在本教程中,我们将学习C++中的类型擦除技术。这是一种强大的编程范式,允许我们将具有相同接口但类型完全无关的对象,存储到一个统一的类型中,从而实现运行时多态,而无需依赖继承。我们将从基础概念开始,逐步深入到std::function和std::any的内部实现,并探讨其性能开销与优化方法。
概述:什么是类型擦除?
类型擦除是一种技术,它通过一个统一的接口来隐藏对象的具体类型。这使得我们可以将行为相似但类型无关的对象(例如,不同的可调用对象)视为同一类型进行处理。这与“鸭子类型”的概念相似:如果一个对象能像鸭子一样“叫”和“走”,那么我们就可以把它当作鸭子来用,而无需关心它是否继承自某个“鸭子”基类。
在C++标准库中,std::function和std::any是类型擦除的典型代表。理解它们的工作原理,有助于我们设计更灵活、解耦的代码。
一个具体问题:灵活的回调函数
假设我们正在构建一个交易系统。系统有一个MarketDataSubscriber类,它需要接收一个回调函数来过滤交易品种。这个回调函数的签名是固定的:bool (const InstrumentDefinition&)。我们希望客户端能够传入任何符合此签名的可调用对象,例如自由函数、lambda表达式或函数对象。
我们的目标是让MarketDataSubscriber成为一个具体的、非模板化的类,同时又能灵活地接受任何可调用对象。
方案一:函数指针
最简单的想法是使用函数指针。
class MarketDataSubscriber {
using FilterFunc = bool (*)(const InstrumentDefinition&);
FilterFunc filter_;
public:
MarketDataSubscriber(FilterFunc f) : filter_(f) {}
void onInstrument(const InstrumentDefinition& inst) {
if (filter_(inst)) {
// 传递给交易逻辑
}
}
};
优点:
- 可以接受自由函数、无状态的lambda或静态成员函数。
- C++26引入了
std::function_ref,可以更好地处理有状态的lambda。
缺点:
- 可调用对象必须是无状态的(不能捕获变量)。
- 缺乏值语义,需要处理空指针检查。
方案二:继承与虚函数
另一种常见思路是使用继承实现运行时多态。
class InstrumentFilterConcept {
public:
virtual bool operator()(const InstrumentDefinition&) const = 0;
virtual ~InstrumentFilterConcept() = default;
};
class EquityFilter : public InstrumentFilterConcept {
public:
bool operator()(const InstrumentDefinition& inst) const override {
return inst.type() == InstrumentType::Equity;
}
};
class MarketDataSubscriber {
std::unique_ptr<InstrumentFilterConcept> filter_;
public:
MarketDataSubscriber(std::unique_ptr<InstrumentFilterConcept> f) : filter_(std::move(f)) {}
// ... 使用 filter_->operator()(inst)
};
优点:
- 单一的具体类型,可以存储在不同的容器中。
- 实现了运行时多态。
- 可以在运行时重新赋值。
缺点:
- 所有可调用对象必须继承自同一个接口基类。
- 存在虚函数调用的运行时开销。
- 无法直接使用第三方库定义的函数(除非为其创建包装类)。
- 客户端需要管理对象的生命周期(使用指针)。
方案三:模板
我们可以将整个类模板化,以接受任何类型。
template <typename Callable>
class MarketDataSubscriber {
Callable filter_;
public:
MarketDataSubscriber(Callable f) : filter_(std::move(f)) {}
void onInstrument(const InstrumentDefinition& inst) {
if (filter_(inst)) { /* ... */ }
}
};
优点:
- 接受任何合适的可调用对象。
- 零运行时开销(编译期实例化)。
缺点:
- 为不同的
Callable类型会生成不同的类类型。MarketDataSubscriber<EquityFilter>和MarketDataSubscriber<BondFilter>是两个完全不同的类型,难以放入同一个std::vector中存储和管理。
引入 std::function:类型擦除的解决方案
std::function完美地解决了上述问题。它是一个具体的、非模板化的类型,却能存储任何符合签名的可调用对象。
class MarketDataSubscriber {
std::function<bool(const InstrumentDefinition&)> filter_;
public:
MarketDataSubscriber(std::function<bool(const InstrumentDefinition&)> f) : filter_(std::move(f)) {}
// ... 使用 filter_(inst)
};
// 客户端可以传入各种类型
MarketDataSubscriber sub1(FreeFunction); // 自由函数
MarketDataSubscriber sub2([](const auto& inst){ return inst.isEquity(); }); // Lambda
MarketDataSubscriber sub3(EquityFilter{}); // 函数对象
关键点在于:FreeFunction、lambda表达式和EquityFilter是完全无关的类型,但它们都被赋值给了同一个具体类型——std::function。这正是类型擦除的核心:隐藏对象的具体类型,仅通过一个统一的接口来操作它。
类型擦除的实现原理:概念-模型模式
std::function是如何实现这一魔法的呢?其核心是概念-模型设计模式,它巧妙地结合了继承和模板。
第一步:解决第三方函数问题
回到继承方案,其问题之一是难以使用第三方函数。解决方案是为每个第三方函数创建一个包装类。
// 第三方库函数
bool isMillionDollarStock(const InstrumentDefinition&);
// 包装类
struct IsMillionDollarFilter : public InstrumentFilterConcept {
bool operator()(const InstrumentDefinition& inst) const override {
return isMillionDollarStock(inst); // 委托调用
}
};
这很繁琐,我们需要为每个第三方函数都写一个包装类。
第二步:通用包装器(模型)
我们发现,所有包装类模式相同:继承基类,存储一个可调用对象,调用时委托给它。我们可以用模板创建一个通用模型。
template <typename FunType>
class InstrumentFilterModel : public InstrumentFilterConcept {
FunType fun_; // 存储具体的可调用对象
public:
InstrumentFilterModel(FunType f) : fun_(std::move(f)) {}
bool operator()(const InstrumentDefinition& inst) const override {
return fun_(inst); // 委托调用
}
};
现在,无论是自定义函数对象还是第三方函数,都可以用InstrumentFilterModel包装起来。
第三步:构建类型擦除类
我们将基类(概念)和模型类作为内部类,构建一个最终的类型擦除包装器。
class Function {
// 概念:统一接口
struct Concept {
virtual ~Concept() = default;
virtual bool invoke(const InstrumentDefinition&) const = 0;
virtual std::unique_ptr<Concept> clone() const = 0; // 为拷贝支持
};
// 模型:持有具体类型
template <typename FunType>
struct Model final : public Concept {
FunType fun_;
Model(FunType f) : fun_(std::move(f)) {}
bool invoke(const InstrumentDefinition& inst) const override {
return fun_(inst);
}
std::unique_ptr<Concept> clone() const override {
return std::make_unique<Model>(*this); // 拷贝内部对象
}
};
std::unique_ptr<Concept> object_; // 多态指针,指向具体模型
public:
// 关键:模板化构造函数,接受任何类型
template <typename FunType>
Function(FunType f) : object_(std::make_unique<Model<FunType>>(std::move(f))) {}
// 拷贝构造(支持值语义)
Function(const Function& other) : object_(other.object_->clone()) {}
// 统一调用接口
bool operator()(const InstrumentDefinition& inst) const {
return object_->invoke(inst);
}
};
工作原理:
Function类有一个模板构造函数,在构造时它知道具体类型FunType。- 它用这个具体类型实例化一个
Model<FunType>,并将其地址赋给基类指针Concept*。 - 从此,
Function对象内部只看到一个Concept*,具体类型FunType被“擦除”了。 - 当通过
operator()调用时,通过虚函数invoke分发到具体的Model中存储的fun_上。
这样,我们就实现了一个简易版的、支持值语义的std::function。它可以存储任何可调用对象,而它们之间无需有任何继承关系。
性能考量与小缓冲区优化
上一节我们实现的简易Function有两个主要性能开销:
- 堆内存分配:每次构造都需要
std::make_unique。 - 虚函数调用:每次调用都有一次虚表查找。
标准库的实现通过小缓冲区优化来减少堆分配。其思想是:在对象内部预留一小块栈内存(例如16字节)。如果存储的可调用对象尺寸小于这个阈值,就将其直接构造在这块栈内存上;否则,才在堆上分配。
class FunctionWithSBO {
static constexpr size_t BufferSize = 16;
alignas(8) std::byte buffer_[BufferSize]; // 对齐的栈缓冲区
Concept* object_; // 指向缓冲区或堆内存
template <typename FunType>
void construct(FunType&& f) {
if (sizeof(Model<FunType>) <= BufferSize) {
// 在栈缓冲区上就地构造
object_ = new (buffer_) Model<FunType>(std::forward<FunType>(f));
} else {
// 在堆上分配
object_ = new Model<FunType>(std::forward<FunType>(f));
}
}
// ... 析构和拷贝需要根据object_的位置进行特殊处理
};
这种优化对于像小型lambda这样的对象非常有效,能显著提升性能。
std::any:另一种类型擦除
std::any是更通用的类型擦除容器,可以存储任何可拷贝构造的类型,而不仅仅是可调用对象。
std::any a = 42;
std::cout << a.type().name() << std::endl; // 打印类型信息
int i = std::any_cast<int>(a); // 安全转换
// std::any_cast<double>(a); // 抛出 std::bad_any_cast 异常
a = 3.14; // 可以重新赋值
std::any的核心挑战是:存储为void*后,如何在析构时安全地删除对象?它同样使用概念-模型模式,模型负责存储类型信息和正确的析构操作。
无虚函数调用的类型擦除实现
我们也可以实现一种不依赖虚函数的类型擦除,std::any的某些实现采用了类似思路。关键在于在构造时,将类型相关的操作(如析构、拷贝)保存为函数指针。
class AnyNoVTable {
void* data_ = nullptr;
void (*deleter_)(void*) = nullptr; // 类型擦除的析构器
std::type_index typeIndex_;
template<typename T>
static void deleteImpl(void* ptr) {
delete static_cast<T*>(ptr); // 静态分发,无虚函数调用
}
public:
template<typename T>
AnyNoVTable(T value)
: data_(new T(std::move(value)))
, deleter_(&deleteImpl<T>) // 保存具体类型的析构函数
, typeIndex_(typeid(T))
{}
~AnyNoVTable() {
if (deleter_) deleter_(data_);
}
// ... 需要类似地处理拷贝和类型转换
};
这种方法用函数指针代替了虚函数表,在某些场景下可能有一定优势,但通常虚函数调用已是高度优化的操作。
类型擦除实战:std::shared_ptr 的删除器
std::shared_ptr也巧妙地运用了类型擦除来管理删除器。这使得shared_ptr能够正确析构其指向的对象,即使其静态类型是void*。
// 正确工作:shared_ptr 记得它指向的是 NoisyFoo
std::shared_ptr<void> p = std::make_shared<NoisyFoo>();
p.reset(new NoisyBar()); // 正确析构 NoisyFoo,然后指向 NoisyBar
相比之下,std::unique_ptr的删除器是类型的一部分,因此std::unique_ptr<void>无法直接工作。
shared_ptr的实现中,控制块(存储引用计数和删除器)就是一个类型擦除的对象。构造时,它会创建一个知道具体类型Y和删除器D的控制块模型。析构时,通过这个控制块来调用正确的删除器,从而保证了类型安全。
总结
本节课我们一起深入探讨了C++中的类型擦除技术:
-
核心概念:类型擦除通过一个统一接口(如
std::function)隐藏具体类型,允许无关类型以“鸭子类型”的方式被使用。其通用实现模式是概念-模型架构。- 概念类:定义纯虚函数接口。
- 模型类:模板类,继承自概念类,存储具体类型的对象并实现接口。
- 包装类:持有概念类指针,提供类型安全的构造、拷贝和调用。
-
标准库组件:我们分析了
std::function和std::any是如何应用类型擦除的,并了解了其小缓冲区优化等性能改进技术。 -
优势与代价:类型擦除提供了比继承更灵活的运行时多态,减少了类型间的耦合。其代价通常是一次堆分配和间接调用(虚函数或函数指针)的开销。
-
广泛应用:该模式不仅用于标准库,也是我们设计灵活、解耦API的强大工具。
std::shared_ptr的删除器管理是其另一个精彩的应用实例。




掌握类型擦除,能让你在需要接口统一性与实现多样性之间找到优雅的平衡点,写出更通用、更健壮的C++代码。
011:使用 C++26 实现结构体数组 (SoA) 容器



概述
在本节课中,我们将学习如何使用 C++26 的新反射功能,通过一个具体的例子——实现一个结构体数组 (SoA) 容器,来掌握反射的核心概念和编程模式。我们将从定义存储结构开始,逐步实现添加元素、访问元素、格式化输出等核心功能,并在此过程中深入理解 constexpr 块、define_aggregate、反射算法、注解等新特性。
第 1 节:目标与动机 🎯
上一节我们概述了课程内容,本节中我们来看看我们想要实现的具体目标。
我的一个同事推荐我观看 Andrew Kelly 关于数据导向设计的演讲。其中有一个幻灯片展示了如何通过一行代码的改动,将“结构体数组” (AoS) 转换为“数组结构体” (SoA),以高效利用内存。这个转换在 Zig 语言中非常简洁,库本身就知道如何处理 SoA,用户无需额外工作。
这让我非常兴奋,我认为这正是反射应该实现的目标:能够编写出如此易用的库,用户甚至无需思考。因此,我决定在标准 C++26 中实现一个 soa_vector。
但本课程的主要目标不仅仅是展示这个例子,更是以 soa_vector 为载体,向大家传授关于反射的知识。反射是一个将改变我们解决问题方式的强大新特性。我希望通过今天的课程,让大家开始接触这些新技术,培养直觉,以便未来能够构建出我目前甚至无法想象的酷炫库。
我们不会深入讨论数据导向设计或 SoA 的具体应用场景。如果你想了解更多,可以关注本周晚些时候 Victoria 的 keynote 演讲。
第 2 节:设计存储结构 💾
上一节我们明确了目标,本节中我们来看看如何设计容器的存储结构。
整个 SoA 的核心在于如何存储数据。我们将围绕一个简单的类型 Square 展开讲解:
struct Square {
char x;
long y; // 注意:这个类型内部可能有大量填充
};
如果我们有一个普通的 vector<Square>,其存储看起来像这样:一个指向 Square 的指针,所有 Square 对象在内存中连续存储,外加 size 和 capacity。
但我们不想要这样。我们想要 SoA。一种实现方式是将 Square 的每个成员都变成一个该类型的向量:
struct soa_storage_square {
std::vector<char> x;
std::vector<long> y;
// size 和 capacity 呢?每个 vector 都有自己的,这很浪费。
};
这个转换很简单:T -> vector<T> 的每个成员,并保留所有名称。实现 push_back 等操作也很直观:分别对 x 向量和 y 向量调用 push_back。
但我不太喜欢这个方案。这两个向量并不是独立的,它们总是有相同的 size 和 capacity。至少,我们重复存储了 size 和 capacity,这是浪费的。此外,每次 push_back 都要为每个成员检查是否需要分配内存,即使我们知道它们应该同步。
所以,我们不采用这种方式,而是自己管理所有内存。转换变得更简单:对于类型 T,我们为每个成员存储一个 T*,并单独维护 size 和 capacity。
struct soa_storage_square {
char* x;
long* y;
size_t size;
size_t capacity;
};
这看起来更接近我们实际想要编写的解决方案。
第 3 节:使用 define_aggregate 生成存储类型 🛠️
上一节我们设计了存储结构,本节中我们来看看如何为任意类型 T 动态生成这种结构。
C++26 引入了一种代码生成机制,虽然功能还很有限:给定一个不完整类型,我们可以用一组特定的非静态数据成员来填充它。仅凭这一点,就足以解决大量问题。
具体工作方式是:我们声明一个不完整类型,然后使用一个新的 C++26 特性——constexpr 块。constexpr 块允许我们在声明上下文中运行任意代码。最终,我们调用 define_aggregate 函数,传入一个代表该不完整类型的反射,这个过程将用我们提供的数据成员来完成这个类型。
以下是生成存储类型的代码模式:
struct soa_storage; // 不完整类型声明
constexpr {
// 获取类型 T 的反射
constexpr auto t_info = reflexpr(T);
// 准备数据成员描述列表
std::vector<std::meta::data_member_spec> members;
// 遍历 T 的所有非静态数据成员
for (auto mem : nonstatic_data_members_of(t_info, context)) {
// 对每个成员,生成一个指针成员,保持原名
members.push_back({
.type = std::meta::add_pointer(type_of(mem)),
.name = name_of(mem)
});
}
// 添加 size 和 capacity 成员
members.push_back({.type = reflexpr(size_t), .name = "size_"});
members.push_back({.type = reflexpr(size_t), .name = "capacity_"});
// 完成类型定义
define_aggregate(reflexpr(soa_storage), members);
}
// 现在 soa_storage 是一个完整类型,可以直接使用
需要注意几点:
- 我限定了
std::vector和std::meta::info,但没有限定define_aggregate。这个函数实际上是std::meta::define_aggregate,但反射以std::meta作为其关联命名空间,因此参数依赖查找 (ADL) 会生效,代码可以正常工作。 nonstatic_data_members_of需要两个参数:类型的反射和一个很长的“访问上下文”参数。访问上下文决定了你能看到哪些成员(例如,私有成员)。我们可以使用unchecked来获取所有成员,或使用unprivileged来获取当前代码位置有访问权限的成员。为了简化,我们假设有一个全局变量context始终是unchecked。std::meta::add_pointer是一个类型特征 (type trait) 的constexpr函数版本,它接受一个info并返回应用了指针转换的info。
这就是基本模式:声明不完整类型 -> 在 constexpr 块中工作 -> 调用 define_aggregate 完成类型 -> 像手写一样使用它。
然而,这个方案有个问题:如果用户类型 T 本身就有名为 size_ 或 capacity_ 的成员,那么我们在循环中添加同名成员时就会冲突,导致编译失败。
作为库作者,我们可以禁止用户使用这些保留名,但这很不友好。更好的方法是:不通过 define_aggregate 生成 size_ 和 capacity_,而是像普通成员一样在外部声明它们。这样就没有额外的限制了。
此外,由于所有生成的成员都是指针,我们可以给这个类型起一个更有意义的名字,比如 pointers。我们还可以更清晰地表达计算指针的过程只是一个简单的数据转换。
我们可以提前泛化一下这个转换过程,也许预示着未来会再次用到这个函数:
constexpr auto trans = std::meta::add_pointer;
auto members_view = nonstatic_data_members_of(t_info, context)
| std::views::transform([&](auto mem) {
return std::meta::data_member_spec{
.type = trans(type_of(mem)),
.name = name_of(mem)
};
});
// members_view 是一个转换后的范围,可以直接用于 define_aggregate
另一种写法是利用反射是单类型设计这一事实:std::meta::info 可以代表任何东西。我们可以直接原地修改 vector 并返回:
std::vector<std::meta::info> members = ...; // 获取原始成员反射
for (auto& mem : members) {
mem = data_member_spec_of(mem, trans(type_of(mem)), name_of(mem));
}
// 现在 members 包含了转换后的描述
对于熟悉 range-v3 的人来说,这类似于 actions::transform 与 views::transform 的区别。我怀疑反射可能会更多地推动 actions 的使用,因为它是解决许多反射问题的好方法。
第 4 节:施加约束 ⚙️
上一节我们生成了存储类型,本节中我们来看看需要对类型 T 施加哪些约束。
事实证明,我们不能对任意类型 T 都进行 SoA 向量化。实现需要满足一些基本要求:
- 需要能够解构值:因为我需要分开存储所有成员,所以每个成员必须能独立析构。
- 需要能够重构值:因为最终你需要从
soa_vector中获取值,所以我需要能够将它们重新组合起来。 - 需要能将成员映射到存储:重构值的唯一方法基本上是使用列表初始化,所以这必须有效。
这些约束基本上要求 T 是一个聚合体 (aggregate),并且没有基类(或者至少,实现有基类的情况需要更多工作,本次课程暂不考虑)。
那么如何编写这些约束呢?它们直接映射到谓词上:
// 传统类型特征写法 (模板)
template<typename T>
inline constexpr bool is_aggregate_v = __is_aggregate(T);
template<typename T>
inline constexpr bool has_no_bases_v = bases_of(reflexpr(T)).empty();
// C++26 反射下的函数写法
constexpr bool is_aggregate_type(std::meta::info type) {
return std::meta::is_aggregate_type(type);
}
constexpr bool has_no_bases(std::meta::info type) {
return std::meta::bases_of(type, context).empty();
}
在反射的世界里,我们不再需要将所有东西都写成模板。很多这类检查都可以写成常规函数。标准库为原有的类型特征提供了对应的函数版本,命名规则是:原来以 is 开头的,函数版本以 is_*_type 结尾,例如 is_function_type, is_aggregate_type, is_union_type 等。
但如果你有自己的类型特征变量模板,标准库不会为你提供函数版本。你可能会想直接实例化变量模板然后拼接 (splice) 出值,但这行不通,因为函数参数不是常量表达式,而拼接要求操作数是常量表达式。
别担心,我们有解决方案。反射库中有一个名为 substitute 的函数,它可能是整个库中最有用的函数之一。它允许你获取一个模板(任何类型的模板:函数模板、变量模板、类模板)的反射和一堆反射参数,然后为你执行替换。
template<typename T>
inline constexpr bool my_trait_v = ...;
constexpr bool my_trait_type(std::meta::info type) {
// 获取变量模板的反射
constexpr auto trait_template = reflexpr(my_trait_v);
// 准备模板参数:类型 T
std::vector<std::meta::info> args = {type};
// 执行替换
auto r = substitute(trait_template, args);
// r 现在是一个反射,代表 my_trait_v<T> 这个变量
// 我们知道它代表一个 bool 类型的常量,提取出来
return extract<bool>(r);
}
extract 函数有点像 any_cast。反射类似于 any,可以代表任何东西。如果你知道它代表什么,就可以把它提取出来。如果弄错了,就会出错(抛出异常)。在这里,我们知道 r 代表一个 bool 变量,所以可以安全提取。
我们可以泛化这个模式,包装成一个名为 pred 的算法,它接受一个代表任何模板的反射,并返回一个 lambda。这个 lambda 会执行 substitute 和 extract 操作,从而将任何模板转换成一个一元或二元函数。
constexpr auto pred(std::meta::info template_info) {
return [=](auto... args) {
auto r = substitute(template_info, {args...});
return extract<bool>(r);
};
}
// 使用 pred 实现 is_aggregate_type
constexpr auto is_aggregate_type = pred(reflexpr(std::is_aggregate_v));
这个 substitute + extract 的模式非常常见,我们后面还会看到。
第 5 节:实现 push_back:添加元素 ➕
上一节我们定义了约束,本节中我们来看看如何向容器中添加元素。
回到 Square 的例子,如果我们有针对 Square 的具体实例化,如何实现 push_back?
- 首先检查是否需要重新分配内存(如果
size == capacity,则增长到新的容量)。 - 然后在
x指针的新位置对value.x进行放置new(placement new),在y指针的新位置对value.y进行放置new。 - 最后增加
size。
这里的关键是,我们在并行地迭代 pointers 的成员和 Square 的成员。我们可以使用放置 new,但标准库提供了一个更专用的设施 std::construct_at,它可以为我们推导类型,这样我们就不需要重复类型信息了。
那么,对于泛型类型 T,我们如何实现呢?前奏(检查容量和增长)和尾声(增加大小)是一样的。问题在于中间部分:如何将 value 的每个成员复制到它自己的存储槽中?
我想到了多种实现方式,下面逐一介绍,你可以选择自己喜欢的方式,不同的方式可能适用于不同的问题。
选项 1:解构到包中,然后折叠逗号表达式
auto push_back(const T& value) {
// ... 检查容量,必要时增长 ...
// 将 pointers 和 value 解构到包中
auto [... ptrs] = pointers; // pointers 是包含所有成员指针的结构体
auto [... mems] = value; // value 是 T 类型的对象
// 对每一对 (ptr, mem) 调用 construct_at,然后折叠逗号表达式
(std::construct_at(ptrs + size, mems), ...);
// ... 增加 size ...
}
对于过去几年做过很多元编程的人来说,这可能很熟悉,因为在 C++ 中迭代的方式就是折叠逗号表达式。但对其他人来说,用逗号写循环可能很奇怪。
选项 2:使用扩展语句 (expansion statement)
C++26 引入了一种新的循环:扩展语句。这种循环完全在编译时进行。扩展语句的一个很酷的特性是循环变量可以是 constexpr(普通的 for 或 while 循环不行)。这里需要 constexpr,因为我用它来索引到包中(索引包也是 C++26 的新特性)。
auto push_back(const T& value) {
// ... 检查容量,必要时增长 ...
auto [... ptrs] = pointers;
auto [... mems] = value;
constexpr size_t N = sizeof...(ptrs); // 成员数量
// 扩展语句:遍历索引
for constexpr (size_t i : std::views::indices(N)) {
std::construct_at(ptrs[i] + size, mems[i]);
}
// ... 增加 size ...
}
std::views::indices(N) 类似于 Python 的 range(N),它生成从 0 到 N-1 的索引,避免了手动写 0 和类型问题。
选项 3:使用元组 zip
有些人可能会想,我在并行迭代两个东西,有个算法叫 zip。不过这里不是范围 zip,而是对元组进行 zip。
template<typename... Ts>
auto tuple_zip(Ts&&... tuples) {
// 简化实现:返回一个元组,其元素是各个输入元组对应元素的引用元组
return std::make_tuple(std::forward_as_tuple(std::get<Is>(tuples)...));
// 实际实现需要更复杂以处理任意数量和完美转发
}
auto push_back(const T& value) {
// ... 检查容量,必要时增长 ...
auto zip_view = tuple_zip(pointers, value); // 假设 pointers 和 value 可视为元组
for constexpr (auto&& [ptr, mem] : zip_view) {
std::construct_at(ptr + size, mem);
}
// ... 增加 size ...
}
选项 4:基于范围的 zip,遍历成员反射
我们可以 zip 两个类型的非静态数据成员反射。
auto push_back(const T& value) {
// ... 检查容量,必要时增长 ...
// 获取 T 和 pointers 类型的成员反射列表
auto t_mems = nonstatic_data_members_of(reflexpr(T), context);
auto p_mems = nonstatic_data_members_of(reflexpr(decltype(pointers)), context);
// 将两个反射列表 zip 起来
auto zipped = std::views::zip(t_mems, p_mems);
// 遍历 zipped 对
for constexpr (auto [t_mem, p_mem] : zipped) {
// 通过成员反射访问实际子对象
auto& ptr = pointers.[:p_mem:]; // C++26 成员反射访问语法
auto& mem = value.[:t_mem:];
std::construct_at(ptr + size, mem);
}
// ... 增加 size ...
}
这段代码需要一个尚未实现的功能:非临时 constexpr 分配。nonstatic_data_members_of 返回的 vector 会分配内存,zip 会持有这些 vector,而在扩展语句内部尝试用这个结果创建 constexpr 变量是行不通的。
不过我们有解决办法:define_static_array。这是标准库中加入的一组非常有趣的函数之一。define_static_array 接受一个任意范围(有一些要求),并将该范围提升到静态存储,就像你用那些内容创建了一个静态 constexpr 数组一样,然后返回一个指向该数组的 span。
auto push_back(const T& value) {
// ... 检查容量,必要时增长 ...
// 使用 define_static_array 避免分配
auto t_mems = std::meta::define_static_array(
nonstatic_data_members_of(reflexpr(T), context)
);
auto p_mems = std::meta::define_static_array(
nonstatic_data_members_of(reflexpr(decltype(pointers)), context)
);
// 现在 t_mems 和 p_mems 是 span,指向静态数组
for constexpr (size_t i : std::views::indices(t_mems.size())) {
auto& ptr = pointers.[:p_mems[i]:];
auto& mem = value.[:t_mems[i]:];
std::construct_at(ptr + size, mem);
}
// ... 增加 size ...
}
由于我们可能多次重用这些成员反射,我们可以将它们作为静态 constexpr 成员存储在 soa_vector 类中。define_static_array + 非静态数据成员的模式很常见,我们可以将其包装成一个更友好的函数,比如 nsdms_of 或 fields_of。
选项 5:直接循环索引
我们也可以直接遍历从成员数量得到的索引,然后通过类成员访问语法索引到两个 span 中。
auto push_back(const T& value) {
// ... 检查容量,必要时增长 ...
constexpr size_t N = /* 成员数量 */;
for constexpr (size_t i : std::views::indices(N)) {
// 假设我们有 members_span 存储了成员反射
auto& ptr = pointers.[:members_span[i]:];
auto& mem = value.[:members_span[i]:];
std::construct_at(ptr + size, mem);
}
// ... 增加 size ...
}
第 6 节:实现 grow:扩容 📈
上一节我们实现了添加元素,本节中我们来看看如何实现容器的扩容。
扩容实际上相当简单。我们遍历所有的指针成员,对每个指针独立进行扩容操作。
void grow(size_t new_capacity) {
// 遍历所有指针成员
for constexpr (auto& ptr : pointers_members_span) { // 假设有这样一个 span
auto& member_ptr = pointers.[:ptr:]; // 获取当前指针成员的引用
// 分配新内存
auto new_mem = std::allocator<std::remove_pointer_t<decltype(member_ptr)>>().allocate(new_capacity);
// 将旧数据复制或移动到新内存
std::uninitialized_copy_or_move_n(member_ptr, size, new_mem);
// 销毁旧数据并释放内存
std::destroy_n(member_ptr, size);
std::allocator<std::remove_pointer_t<decltype(member_ptr)>>().deallocate(member_ptr, capacity);
// 更新指针
member_ptr = new_mem;
}
capacity = new_capacity;
}
扩展语句的循环体对于每个成员来说可能是不同的模板实例化(如果需要的话)。这是基本的分配代码。当然,我们可能不想复制,而是想移动。在 C++26 中,如果 relocate 特性存在,我们可以使用它。
现在,我们有了一个完整的添加元素的解决方案。
第 7 节:实现 spans:提供成员视图 👁️
上一节我们实现了扩容,本节中我们来看看如何提供一种查看容器内数据的方式。
向容器添加元素很酷,但最终你可能会想查看它们,比如打印出来。最简单的事情就是打印元素。
回到我们对 soa_vector 的装饰(decoration),我们声明了 pointers 类型。现在我们要添加一个新类型,叫 spans。就像 pointers 是所有成员类型的指针类型一样,spans 可以是所有成员的 span 类型。我将使其成为每个成员类型的 const span。
// 对于 Square,spans 类型看起来像这样:
struct spans_square {
std::span<const char> x;
std::span<const long> y;
};
使用方式可能是这样的:
soa_vector<Square> v;
v.push_back({'e', 4});
v.push_back({'c', 6});
auto sp = v.spans(); // 获取 spans 对象
// 打印 x spans 和 y spans
for (char cx : sp.x) std::cout << cx << ' '; // 输出: e c
for (long ly : sp.y) std::cout << ly << ' '; // 输出: 4 6
实现 spans 用到了之前展示过的技术:解构 pointers 得到所有指针成员,然后为每个指针生成一个适当大小的 span,最后将它们组合到 spans 结构体中。这并不复杂。
第 8 节:实现格式化打印:使用注解驱动 🖨️
上一节我们实现了成员视图,本节中我们来看看如何格式化打印整个元素。
你可能还想打印单个元素,比如第一个元素。为了实现这个,我们可以添加一个 operator[]。它的实现看起来和 spans 很像,但这次它返回一个 Square。我的 Square 类型默认不可打印。
我可以为 Square 特化一个 std::formatter,添加一个 parse 和一个 format 函数,遍历所有成员并打印。为了美观地打印,我可能想给 char 加上引号(使用 ? 格式说明符),但 long 不支持 ? 说明符。这意味着我需要写一些逻辑来检查格式说明符是否被支持。
我很懒,不喜欢写这些东西。我想要的是,我只需在类型前加上 debug,它就能正常工作。但我不能直接写 #define debug 这样的预处理指令。
不过,我可以做一件非常接近的事:使用注解 (annotations)。这是 C++26 的另一个新特性。我将把它拼写为 derive(debug),没什么特别原因。
struct Square {
char x;
long y;
};
// 使用注解
[:derive(debug):] // 这是一个注解
struct Square {
char x;
long y;
};
我希望格式化器能自动识别任何带有此注解的类型,并为其提供正确的格式化行为。接下来我们就来实现这个。
标准库里没有 derive(debug) 这样的注解,annotation 也不是标准库函数,但我们可以自己写。
首先,实现 annotation 函数。它接受一个代表任何东西的反射(可以是非静态数据成员、函数、命名空间等),和一个类型 T 及值 value,检查该反射是否有类型为 T、值等于 value 的注解。
template<typename T>
constexpr bool annotation(std::meta::info thing, const T& value) {
for (auto ann : std::meta::annotations_of(thing)) {
if (type_of(ann) == reflexpr(T)) {
if (extract<T>(ann) == value) {
return true;
}
}
}
return false;
}
我们预见到这种查找特定类型特定值注解的模式会很常见,所以标准库提供了一个专用函数 annotations_of_with_type,它只返回特定类型的注解。
template<typename T>
constexpr bool annotation(std::meta::info thing, const T& value) {
auto anns = std::meta::annotations_of_with_type(thing, reflexpr(T));
// 现在 anns 只包含类型为 T 的注解
for (auto ann : anns) {
if (extract<T>(ann) == value) return true;
}
return false;
}
但这里有个问题:我需要为 T 类型定义 operator==,而上面的例子中我并没有为 debug 标签类型写比较操作符。我们可以不要求用户定义比较操作符,而是将值作为反射来比较。
两个反射如果都代表值且代表相同的值,或者都代表对象且代表相同的对象,那么它们相等。任何放入 info 的值都必须能用作非类型模板参数或常量模板参数,语言已经有规则来比较它们是否相等。debug 标签类型是结构化的(没有成员),所以比较可以直接进行。
template<typename T>
constexpr bool annotation(std::meta::info thing, const T& value) {
auto anns = std::meta::annotations_of_with_type(thing, reflexpr(T));
auto value_reflection = std::meta::reflect_constant(value);
for (auto ann : anns) {
// 提取注解中的常量值(作为反射)
auto ann_const = std::meta::constant_of(ann);
if (ann_const == value_reflection) return true;
}
return false;
}
这代码看起来像在写循环算法,我们可以用 std::ranges::any_of 来写得更优雅,甚至用 contains。
template<typename T>
constexpr bool annotation(std::meta::info thing, const T& value) {
auto anns = std::meta::annotations_of_with_type(thing, reflexpr(T));
auto value_reflection = std::meta::reflect_constant(value);
return std::ranges::contains(anns, value_reflection,
[](auto ann) { return std::meta::constant_of(ann); });
}
现在来实现我们的格式化器。我们将约束格式化器只匹配那些通过注解选择了默认行为的类型。
template<typename T>
requires (annotation(reflexpr(T), debug{})) // 检查是否有 derive(debug) 注解
struct std::formatter<T> {
// 格式化器实现...
};
格式化器的实现:首先打印类型名和花括号,然后遍历所有基类(递归打印),最后遍历所有非静态数据成员,打印它们的名字和值。我们需要在多个基类或成员之间添加逗号和空格作为分隔符。
我们可以使用之前见过的 nsdms_of 函数来获取成员反射。但打印格式可能不是我们想要的:基类没有名字,但成员有名字。我们可以加上成员名。

这里我用了 identifier_of 来获取成员标识符,而上面用的是 display_string_of,为什么不同?因为名字很复杂。类型名可能非常复杂(比如类模板特化),display_string_of 只是让编译器给出一些有用的字符串表示。但对于非静态数据成员,我们知道它的名字是一个标识符,所以 identifier_of 肯定能给出这个标识符。如果某个东西没有特定的标识符,identifier_of 会失败。
然而,这还不是我想要的格式:我希望 char 被引号括起来,但这里没有。我可以尝试加上 ? 格式说明符,这对 char 有效,但对 long 无效。为了正确处理,我需要写一个包装器,检查某个格式化器是否支持 ? 说明符,如果不支持就跳过它。这不太有趣,但很重要。
有了这些,我就有了一个完整的格式化器实现。看看这段代码,里面有很多新东西:新循环、新算法、反射、显示字符串……但这里没有什么奇技淫巧。我想格式化,想打印名字,想打印所有基类,就循环基类;想打印所有成员,就循环成员。我直接写下了我想写的东西,这很棒。
现在,这一切都工作了。我只需在 Square 上加上 derive(debug) 注解,就能向其中添加元素,取出元素,并打印它们。这很酷。
第 9 节:实现可变代理:支持修改 ✏️
上一节我们实现了只读访问和打印,本节中我们来看看如何支持修改容器内的元素。
更复杂的操作是修改。我希望能够写入 v[0]。如果能有像 Swift 那样的可变属性 (mutating properties) 就好了:我可以解构所有指针,用它们产生一个新的 T,将其交给调用者,然后无论调用者做什么,我都可以将结果解构回元素并写回指针。但这不是 C++ 的语言特性,可能永远也不会有。
我们能做的是返回一个代理 (proxy) 对象。当你只有 define_aggregate 这把锤子时,所有解决方案都会被扭曲成看起来像 define_aggregate 形状的钉子。
我们将创建一个新类型 proxy,它包含对所有成员的左值引用。
// 对于 Square,proxy 类型类似:
struct proxy_square {
char& x;
long& y;
};
但这还不足以让 proxy = square 这样的赋值工作。我们需要在 proxy 内部添加赋值运算符和转换函数。然而,define_aggregate 只接受非静态数据成员,不能添加成员函数。
没关系,我们不必把所有东西都塞进 define_aggregate。我们可以清理一下:让 proxy 继承自一个包含所有数据成员的 proxy_base,然后单独为 proxy 添加成员函数。
struct proxy_base {
char& x;
long& y;
};
struct proxy : proxy_base {
proxy& operator=(const Square& value) {
// 解构 proxy_base 的成员和 value 的成员,然后赋值
auto& [... refs] = static_cast<proxy_base&>(*this);
auto [... mems] = value;
((refs = mems), ...); // 折叠逗号表达式赋值
return *this;
}
operator Square() const {
auto& [... refs] = static_cast<const proxy_base&>(*this);
return Square{refs...}; // 使用列表初始化重构 Square
}
};
结构绑定的规定是:如果所有成员都来自同一个基类,那么你可以直接解构到那些成员。所以 auto& [... refs] = static_cast<proxy_base&>(*this); 这行代码是有效的。

但这仍然不完全正确:现在我的可变 operator[] 返回的是 proxy,而不是 Square。当我打印它时,打印的是 proxy 类型的信息,而不是 Square 的信息,这是用户不想看到的实现细节。
如何修复呢?同样,当你只有注解时,解决方案就是添加更多注解。我们可以添加另一个叫做 format_as 的注解,它是一个简单的类型,带有一个 info 参数。我想让 proxy 被格式化的方式是先转换为 T。
struct format_as {
std::meta::info type;
};
// 在 proxy 类型上添加注解
[:annotation(format_as{reflexpr(Square)}):]
struct proxy : proxy_base { ... };
然后我们回到格式化器代码。我们将之前的所有代码变成一个名为 derived_formatter 的新类模板,然后让 formatter<T> 继承自它。但我们会检查 format_as 注解:如果类型有 format_as 注解,我们就提取用户指定的类型;如果没有,就按实际类型格式化。然后我们可以改变 formatter<T> 的实现,让它继承自用户想要的格式化器(即 formatter<format_as_type>)。
这意味着,为 Square 生成的格式化器继承自 derived_formatter<Square>,而为 proxy 生成的格式化器也继承自 derived_formatter<Square>。因此,当格式化一个 proxy 时,在进入格式化函数之前会先转换为 Square,然后这个函数就像之前一样格式化 Square。
这样,我们打印出来的就是想要的内容,而不是没人想看的无意义信息。
现在我们完成了。我认为这很酷。
第 10 节:总结与展望 🚀
回顾一下,我们使用 C++26 的反射功能实现了一个 soa_vector。我们走过了使用 constexpr 块和 define_aggregate 模式生成代码的整个过程,学习了如何格式化任意类型,展示了 substitute + extract 这个非常有用的模式,讨论了如何实现自定义类型特征,甚至演示了如何实现 define_static_array。在此过程中,我们还接触了许多其他 C++26 新特性。
现在,摆在你面前的可能性几乎是无限的。很多问题从“这在今天的 C++ 中不可能实现”变成了“也许以后可以”。也许你们中的一位会在课后做出一些我现在认为不可能的事情。这才是最酷的地方。
由于我讲得很快,我们还剩一些时间,这里有一些额外内容。几周前,Victoria 在推特上向我提到了一个他想用于结构体数组实现的 API,叫做 with。
// 使用示例
v.with([](char& x) { ++x; }); // 递增所有 x 成员
v.with([](long& y) { ++y; }); // 递增所有 y 成员
with 会查看 lambda 参数的名称,然后迭代所有元素,给你所有名为 x 或 y 的成员。我们可以实现这个。
我们首先写一个 selected 函数,它接受一个函数反射和一个类型反射,返回一个数组反射,包含所有关联的非静态数据成员(即,为函数的每个参数,查找该类型中同名的非静态数据成员)。如果找不到,就抛出异常。
然后,在 with 的实现中,我们调用这个 selected 函数,传入 lambda 的调用操作符反射和 pointers 类型的反射,然后立即拼接 (splice) 结果。这会给我们一个数组,数组可以通过结构化绑定解构。我们声明一个 constexpr auto 包来接收这些非静态数据成员反射。最后,我们循环从 0 到 size,用按请求顺序排列的成员子集来调用用户函数。
这并不复杂,一旦你弄清楚如何正确塑造它。这一切在以前是不可能的,而现在成为了可能。这是一个非常优雅的 API,没有额外的工具,没有宏,一切就这么工作了。

我希望大家觉得这些内容有趣,并和我一样对反射感到兴奋。我认为它将真正改变我们未来思考 C++ 的方式。
总结

在本节课中,我们一起学习了如何使用 C++26 的反射功能,通过实现一个结构体数组 (SoA) 容器,深入理解了反射的核心概念和编程模式。我们从存储设计开始,逐步实现了类型生成、约束检查、元素添加、扩容、成员视图、格式化打印、可变代理以及高级 API (with)。我们看到了 constexpr 块、define_aggregate、反射算法、注解等新特性如何协同工作,让代码生成和元编程变得更加直观和强大。反射为 C++ 打开了新的大门,使得许多以前不可能或极其复杂的任务变得简单可行。
012:回归基础



在本教程中,我们将学习如何改进C++代码审查。内容基于微软Edge浏览器团队在代码审查中发现的常见问题,并整理成一系列实用的编码准则。这些准则旨在提升代码的可读性、安全性和性能,适用于大多数C++项目。
1:变量作用域
上一节我们介绍了教程的概述,本节中我们来看看如何通过限制变量作用域来提升代码清晰度。
变量作用域限制

我们有一个SomeClass类,它有两个成员函数,以及一个返回SomeClass的函数foo。
在if语句之前,我们创建了一个SomeClass的实例s,但s在if语句之外并未使用。
// 欠佳代码
SomeClass s = foo();
if (s.IsValid()) {
// 使用 s
}
// s 在此处不再使用
因此,我们可以将变量s的作用域限制在if语句内部。
// 改进代码
if (SomeClass s = foo(); s.IsValid()) {
// 使用 s
}
限制变量作用域可以减少因意外修改而引发错误的风险,并提高可读性。它还允许在if块之后使用相同的变量名。
本节的标题是“变量作用域限制”,其准则就是:限制变量作用域。
以下是其他限制作用域的例子:
- 在
switch语句中初始化:将变量初始化放在switch语句内部,而不是之前。 - 在
for循环中声明迭代器:在for循环的初始化部分声明迭代变量,而不是在循环外部。
2:枚举(Enum)的使用
上一节我们讨论了变量作用域,本节中我们来看看如何安全地使用枚举。
处理所有枚举值
我们有一个枚举类Result,它有三个值。我们在一个带有default分支的switch语句中使用这个枚举。
// 欠佳代码
enum class Result { kSuccess, kFailure1, kFailure2 };
Result Process(Result r) {
switch (r) {
case Result::kSuccess: return Result::kSuccess;
case Result::kFailure1: return Result::kFailure1;
default: return Result::kFailure2; // 处理 kFailure2
}
}
如果我们传入kFailure2,它会进入default分支并返回kFailure2,这没问题。然而,如果我们添加一个新值kFailure3,switch语句现在会为kFailure3返回错误的值(kFailure2)。
我们可以移除default分支,并为枚举中的每个值添加一个case来处理。
// 改进代码
Result Process(Result r) {
switch (r) {
case Result::kSuccess: return Result::kSuccess;
case Result::kFailure1: return Result::kFailure1;
case Result::kFailure2: return Result::kFailure2;
}
// 使用宏标记不可达代码,如果执行到此处会引发崩溃
NOTREACHED();
}
这样我们就用到了switch中的所有情况。末尾的宏用于确保所有枚举值都被使用,如果执行到此处,会引发不可恢复的崩溃并生成崩溃转储。
需要注意的是,如果你的switch语句中没有default分支,编译器(如-Wswitch)可以检测到你是否遗漏了枚举值。如果你确实需要default分支,可以使用-Wswitch-enum来检测。
使用枚举类(Enum Class)
我们定义一个C风格的枚举FileStatus,它有两个值kOpen和kClosed。我们初始化一个FileStatus实例,并隐式地将其转换为int。
// 欠佳代码
enum FileStatus { kOpen, kClosed };
FileStatus status = kOpen;
int status_code = status; // 隐式转换
这不好,因为它可能导致意外的行为,并且没有类型安全。如果我们定义另一个具有相同值的枚举,或者尝试前向声明FileStatus,都会导致编译问题。
为了解决所有这些问题,应该将FileStatus定义为enum class。
// 改进代码
enum class FileStatus { kOpen, kClosed };
FileStatus status = FileStatus::kOpen;
// int status_code = status; // 错误:无法隐式转换
enum class提供了更好的类型安全性,防止名称冲突,并允许前向声明。此外,从C++20开始,比较两个C风格枚举会收到弃用警告,而使用enum class则不会编译。
使用 using 声明简化枚举名
我们有一个带有长名称的枚举类,并且位于命名空间中。在switch语句中使用时,代码难以阅读。
// 欠佳代码
namespace my_namespace {
enum class VeryLongEnumName { kValue1, kValue2 };
}
void Process(my_namespace::VeryLongEnumName value) {
switch (value) {
case my_namespace::VeryLongEnumName::kValue1: break;
case my_namespace::VeryLongEnumName::kValue2: break;
}
}
可以在switch语句之前使用using enum声明。


// 改进代码
void Process(my_namespace::VeryLongEnumName value) {
using enum my_namespace::VeryLongEnumName;
switch (value) {
case kValue1: break;
case kValue2: break;
}
}
这使代码更简洁、更易读。
3:迭代
上一节我们介绍了枚举的最佳实践,本节中我们来看看如何更优雅地进行迭代。
使用基于范围的 for 循环
我们正在使用带有下标运算符的for循环迭代一个int向量。对于列表,我们也使用迭代器进行迭代。
// 欠佳代码
std::vector<int> vec = {1, 2, 3};
for (size_t i = 0; i < vec.size(); ++i) {
std::cout << vec[i];
}
std::list<int> lst = {1, 2, 3};
for (auto it = lst.begin(); it != lst.end(); ++it) {
std::cout << *it;
}
相反,我们可以使用基于范围的for循环,这使代码更易于阅读和使用。
// 改进代码
for (int val : vec) {
std::cout << val;
}
另一种方式是使用ranges::for_each,如果你希望对每个项应用函数(例如打印每个项),这会更方便。
// 改进代码
std::ranges::for_each(vec, [](int val) { std::cout << val; });
避免不必要的拷贝
我们正在迭代一个字符串向量,并在for循环中创建每个字符串的不必要拷贝。
// 欠佳代码
std::vector<std::string> strings = {"a", "b", "c"};
for (std::string s : strings) { // 拷贝!
std::cout << s;
}
相反,对于非平凡对象,应使用const引用或转发引用。
// 改进代码
for (const std::string& s : strings) { // 无拷贝
std::cout << s;
}
// 或使用转发引用(C++20起)
for (auto&& s : strings) {
std::cout << s;
}
可以使用警告-Wrange-loop-construct来识别此类问题。
使用结构化绑定提高清晰度
我们使用迭代器通过first和second来访问map中的键和值。
// 欠佳代码
std::map<int, std::string> my_map;
for (const auto& pair : my_map) {
std::cout << pair.first << ": " << pair.second;
}
为了提高可读性,我们可以使用结构化绑定配合const引用来访问键和值。
// 改进代码
for (const auto& [key, value] : my_map) {
std::cout << key << ": " << value;
}
4:参数传递
上一节我们讨论了迭代的优化,本节中我们来看看函数参数传递的最佳实践。
传递非平凡只读对象
我们有一个函数Print,它按值接受一个string参数。在函数体内,我们只是读取这个字符串,没有进行拷贝或修改。
// 欠佳代码
void Print(std::string s) {
std::cout << s;
}
由于我们按值传递,可能会调用拷贝构造函数并进行内存分配。std::string不是廉价拷贝的类型。更好的替代方案是按const引用传递。
// 改进代码
void Print(const std::string& s) {
std::cout << s;
}
准则:传递非平凡只读对象时使用const引用,以避免不必要的拷贝。
可以使用Clang-Tidy检查performance-unnecessary-value-param来标记此类问题。注意,字符串比较特殊,我们将在后面的幻灯片中看到处理字符串参数的其他策略。
值参数上的 const 是多余的
我们有一个最简单的结构体Point和一个函数PrintPoint,它接受一个const Point。
// 欠佳代码
struct Point { int x; int y; };
void PrintPoint(const Point p);
这里的const不是函数签名的一部分,因此void PrintPoint(const Point p);和void PrintPoint(Point p);是完全相同的声明。如果添加指针或引用,const就会成为签名的一部分。
准则:函数声明中值参数上的const是不需要的。
传递廉价拷贝的类型
我们有一个函数PrintInt,它通过const引用接受一个int。int是轻量级、廉价拷贝的类型,因此没有必要通过const引用传递。
// 欠佳代码
void PrintInt(const int& i) { std::cout << i; }
void PrintPoint(const Point& p) { std::cout << p.x << ", " << p.y; }
只需按值传递即可。
// 改进代码
void PrintInt(int i) { std::cout << i; }
void PrintPoint(Point p) { std::cout << p.x << ", " << p.y; }
准则:优先按值传递廉价拷贝的类型,因为按引用传递可能会阻止某些优化(例如,编译器可以将值保存在寄存器中)。
对于要移动的参数
我们有一个结构体MyStruct,它有一个std::string类型的成员变量。构造函数接受一个const std::string&并赋值给成员变量。还有一个SetString函数,也接受const std::string&并赋值。
// 欠佳代码
struct MyStruct {
std::string str;
MyStruct(const std::string& s) : str(s) {}
void SetString(const std::string& s) { str = s; }
};
我们可以将其转换为右值引用。
// 改进代码
struct MyStruct {
std::string str;
MyStruct(std::string&& s) : str(std::move(s)) {}
void SetString(std::string&& s) { str = std::move(s); }
};
准则:对于要从参数中移动所有权的参数,按右值引用传递,并在函数体内使用std::move。
字符串参数与 string_view
我们有一个函数Print,它接受一个const std::string&。虽然这是正确的做法,但还有更好的方式。
// 可改进代码
void Print(const std::string& s) { std::cout << s; }
更好的方法是尝试将其转换为std::string_view。
// 改进代码
void Print(std::string_view sv) { std::cout << sv; }
原因:string_view可以处理更多情况(std::string、const char*、带长度的const char*),无需为每种情况创建不同的函数,并且不会进行任何堆内存分配。
使用 span 代替容器
我们有一个函数Foo,它接受一个const std::vector<A>&。如果我们将其转换为std::span<const A>会有什么不同?
// 欠佳代码
void Foo(const std::vector<A>& vec) {
for (const A& a : vec) { std::cout << a; }
}
// 改进代码
void FooBetter(std::span<const A> span) {
for (const A& a : span) { std::cout << a; }
}
当我们调用Foo时,必须创建一个vector,这意味着我们会进行拷贝构造和销毁。而FooBetter不会创建临时对象。
准则:在不需要获取容器所有权的情况下,使用span代替vector或数组,以防止不必要的vector创建。
span可以用于多种情况:C数组、vector、双向迭代器以及initializer_list。
5:函数返回
上一节我们探讨了参数传递,本节中我们来看看如何设计函数的返回值。
使用 std::optional 表示可能失败的操作

我们有一个函数GetNameById,成功时返回true,失败时返回false。成功时,它使用第二个参数(一个字符串)来传递名称。

// 欠佳代码
bool GetNameById(int id, std::string& out_name);
我们可以将这两种返回类型(布尔值和字符串)合并为一个单一的返回参数。当然,我们可以使用std::optional<std::string>。
// 改进代码
std::optional<std::string> GetNameById(int id);
代码变得更简单,并且该变量现在的作用域仅限于if块内。
准则:当函数可能失败,并且仅在成功时返回一个值时,使用std::optional作为结果类型。
使用 std::expected 统一返回类型
我们有一个函数ParseInt,它尝试用std::stoi解析一个字符串。如果能够解析,则返回true并将第二个参数value设置为解析后的整数。如果不能解析,则返回false并将第三个参数error设置为错误字符串。
// 欠佳代码
bool ParseInt(const std::string& str, int& out_value, std::string& out_error);
我们有三种不同的返回类型:bool、int、string。它们可以合并为一个单一的返回类型吗?当然可以,使用C++23的std::expected。
// 改进代码 (C++23)
std::expected<int, std::string> ParseInt(const std::string& str);
代码变得更简单,并且变量现在的作用域仅限于if块内。
准则:使用std::expected来适当地统一函数的返回类型。
不要对返回值使用 std::move
我们有一个返回类型A的函数Foo。在返回之前,我们调用了std::move。
// 欠佳代码
A Foo() {
A a;
// ... 操作 a
return std::move(a); // 不必要
}
这会创建一个临时对象并调用移动构造函数。如果我们直接返回a,编译器可能会应用返回值优化(RVO),进行就地构造。
准则:在这种情况下不要使用std::move,因为它会阻碍RVO。
可以使用Clang-Tidy检查-Wpessimizing-move来捕获此问题。
优先使用复制省略
这是上一张幻灯片中应该给出正确输出的代码。
// 可改进代码
A Foo() {
A a;
// ... 操作 a
return a; // 依赖RVO
}


如何使其更好?我们可以移除类型的名称。
// 改进代码
A Foo() {
// ... 操作
return A{/* 参数 */}; // 保证复制省略 (C++17)
}
这保证了复制省略(C++17起),也称为延迟临时物化或未物化值传递。它比依赖RVO更可靠,因为命名对象可能导致不可拷贝和不可移动类型的编译错误,而复制省略在这些情况下有效。
不要返回 const 值
我们有一个函数Foo,它返回const A。这里有一些使用Foo的代码。
// 欠佳代码
const A Foo();
int main() {
std::vector<A> vec;
vec.push_back(Foo()); // 调用拷贝构造函数
A a;
a = Foo(); // 调用拷贝赋值运算符
}
即使有临时对象,我们也期望调用移动构造函数和移动赋值运算符,但这里我们看到调用了拷贝版本。如果我们移除const,就会得到我们想要的结果。
准则:不要从函数返回const值,因为const返回值会阻碍移动操作。
开发者可能这样写的原因包括复制粘贴代码或无知。但有时他们可能想防止像Foo() = A{10, 20};这样的赋值。对于自定义类型,可以通过仅针对左值定义赋值运算符来实现。
6:类设计
上一节我们讨论了函数返回值的优化,本节中我们来看看如何更好地设计类。
成员变量初始化
我们有一个结构体Size,并在构造函数中初始化成员变量。
// 欠佳代码
struct Size {
int width;
int height;
Size() : width(0), height(0) {}
};
相反,应该在声明点初始化它们。
// 改进代码
struct Size {
int width = 0;
int height = 0;
};
这减少了遗漏初始化的错误。可以使用Clang-Tidy检查cppcoreguidelines-pro-member-init来捕获此问题。
使用成员初始化列表
我们有一个类Person,再次在构造函数中初始化。相反,可以在成员初始化列表中初始化。
// 欠佳代码
class Person {
std::string name;
int age;
public:
Person(const std::string& n, int a) {
name = n;
age = a;
}
};
// 改进代码
class Person {
std::string name;
int age;
public:
Person(const std::string& n, int a) : name(n), age(a) {}
};
这是一种风格上的改变,但如果成员是非平凡类型,它还可以减少对默认构造函数的不必要调用。使用成员初始化列表还强制我们确保正确的初始化顺序。可以使用警告-Wreorder-ctor来指出问题。
将不访问成员变量的函数设为静态
我们有一个类Logger,它有一个公共函数PrintHello。它不访问类的任何成员变量。
// 欠佳代码
class Logger {
public:
void PrintHello() { std::cout << "Hello"; }
};
我们可以将PrintHello设为static。
// 改进代码
class Logger {
public:
static void PrintHello() { std::cout << "Hello"; }
};
准则:如果成员函数不访问非静态成员变量或函数,可以将其设为static。在某些情况下,如果它与类在概念上无关,也可以将其移出类并放入命名空间。
可以使用Clang-Tidy检查readability-convert-member-functions-to-static来指出这一点。
将私有静态函数移出类
我们的类有一个公共成员函数和一个私有静态成员函数CalculateResult。在源文件中,我们定义了CalculateResult。然而,CalculateResult不使用类的任何成员变量或函数。
我们可以将私有静态函数移出类,并放入未命名的命名空间。
// 改进代码 (头文件)
class MyClass {
public:
void DoSomething();
};
// 改进代码 (源文件)
namespace {
int CalculateResult(int x, int y) { return x + y; }
}
void MyClass::DoSomething() {
int result = CalculateResult(10, 20);
}
这将减少依赖关系并简化头文件。
移除多余的 extern 声明
我们在头文件中声明了一个函数MyFunction,并在源文件中定义了它。最好不将函数声明为extern。
// 欠佳代码 (头文件)
extern void MyFunction();
// 改进代码 (头文件)
void MyFunction();
extern不是必需的,因为函数默认具有外部链接,所以这是多余的。
将只读成员函数标记为 const
我们有一个类Point,定义了一个Print函数,它输出x和y。在main中,我们创建一个Point实例并调用Print,这没问题。但如果我们将Point实例设为const,这将无法编译,因为p是const,但Print函数不是const。
// 欠佳代码
class Point {
int x, y;
public:
void Print() { std::cout << x << ", " << y; }
};
int main() {
const Point p{1, 2};
p.Print(); // 编译错误
}
如何修复?将所有不修改任何成员的成员函数标记为const。
// 改进代码
class Point {
int x, y;
public:
void Print() const { std::cout << x << ", " << y; }
};
可以使用Clang-Tidy检查readability-make-member-function-const来捕获此问题。
返回成员变量的 const 引用
我们有一个类Book,成员函数GetTitle返回成员变量title,它是一个string。
// 欠佳代码
class Book {
std::string title;
public:
std::string GetTitle() { return title; } // 拷贝
};
相反,可以返回title的const引用。

// 改进代码
class Book {
std::string title;
public:
const std::string& GetTitle() const { return title; } // 无拷贝
};

返回非平凡成员变量可能效率低下,因此应将其作为const引用返回。
为右值引用重载成员函数
我们有一个结构体B,它有一个函数GetA,返回成员a的const引用。我们还有一个函数GetB,返回B的实例。在main中,我们获取A的const引用,调用GetB(返回B),然后对其调用GetA。这编译正常,但输出显示a在打印行之前就被销毁了(悬垂引用)。要修复它,可以为GetA重载右值引用版本,并在返回时使用std::move。
7:类的特殊成员函数
上一节我们介绍了类设计的一般原则,本节中我们来看看如何正确定义类的特殊成员函数。
使移动操作为 noexcept
我们再次使用结构体A。在main中,我们有一个A的向量,然后运行一个for循环并push_back四次。即使存在移动构造函数,在调整大小时也会调用拷贝构造函数。原因是这些非平凡对象的移动构造函数不是noexcept。如果我们添加noexcept,就会发生移动构造。
准则:考虑将移动构造函数和移动赋值运算符标记为noexcept。可以使用Clang-Tidy检查performance-noexcept-move-constructor来标记此情况。
用户定义的析构函数会抑制移动操作
我们再次使用结构体A,并创建另一个新结构体B,它只有一个类型为A的成员变量。然后我们有一个用户定义的析构函数。我们创建类型B的对象,然后执行B b2 = std::move(b);。我们期望调用B的移动构造函数,进而调用A的移动构造函数,但输出显示调用了拷贝构造函数。用户定义的非平凡析构函数会抑制移动操作。
准则:确保类的所有特殊成员函数都正确定义。如果定义了或删除了任何拷贝、移动或析构函数,请定义或删除所有它们,并使默认操作保持一致。
默认比较运算符(C++20)
我们有一个包含四个不同成员变量的结构体,我们定义了一个相等运算符和一个不等运算符(使用相等运算符实现)。在C++20中,不等运算符可以从相等运算符生成,因此我们不需要它。此外,由于所有成员变量都用于比较,我们可以完全将其默认化。
// 改进代码 (C++20)
struct MyStruct {
int a, b, c, d;
bool operator==(const MyStruct&) const = default;
};
使用飞船运算符(<=>, C++20)
我们有一个类Point,定义了相等运算符和所有比较函数。在C++20中,我们可以使用飞船运算符来生成所有其他函数。
// 改进代码 (C++20)
class Point {
int x, y;
public:
auto operator<=>(const Point&) const = default;
};
使用 Passkey 模式替代友元类
我们有一个类Foo,它有一个私有成员函数SetSecret。我们希望类Bar能够调用该函数,因此Bar成为友元。但作为友元,Bar可以访问所有私有变量,这可能不是我们想要的。为了解决这个问题,我们可以引入Passkey模式。我们将SetSecret从私有改为公共,但不是让Bar成为Foo的友元,而是让Bar成为Passkey类的友元。然后,Passkey类成为SetSecret的参数。由于Bar是Passkey的友元,它可以调用Passkey来调用SetSecret,但不能做任何其他事情。
准则:考虑使用Passkey模式替代使类成为友元,因为它允许我们确保仅对特定的类函数进行有针对性的访问。
8:移动语义与完美转发

上一节我们讨论了特殊成员函数,本节中我们来看看移动语义和完美转发的高级主题。

在函数体内使用 std::move
我们再次使用结构体A。MyClass有一个A作为成员变量,以及一个构造函数,该构造函数接受一个右值引用参数并初始化成员变量。在代码中,我们得到一个拷贝构造,而我们期望的是移动。Clang输出显示:“右值引用参数a在函数体内从未被移动”。我们需要调用std::move。
准则:考虑在函数体内使用std::move来处理右值引用参数,以取得所有权(如果适用)。
使用 std::forward 进行完美转发
我们的MyClass有一个string成员变量。我们有三个不同的构造函数来以不同方式创建字符串:const char*、const string&和string&&。我们还有另一个函数MakeUnique,它创建unique_ptr。当我们尝试以四种不同方式创建MyClass时,理想情况下我们希望调用移动构造函数,但实际调用了拷贝构造函数。问题在于我们没有使用std::forward。一旦我们添加std::forward,就会发生正确的移动构造。
准则:处理转发引用时,使用std::forward来避免不必要的拷贝。
可以使用Clang-Tidy检查cppcoreguidelines-missing-std-forward来指出此问题。
多次调用函数时避免使用 std::forward
我们有一个函数Call,它接受一个函数向量和一个可变参数包。在Call的主体中,它遍历向量中的所有函数,并使用std::forward调用每个函数。我们有两个示例函数Func1和Func2,它们都按值接受字符串。在main中,我们设置一个包含Func1和Func2的向量,然后调用Call并传递一个字符串。输出显示第二个函数打印空字符串,因为对Func1的调用进行了字符串移动,导致Func2打印空字符串。如何修复?移除std::forward,这样就会进行拷贝。但拷贝可能不必要,我们可以通过将所有参数标记为const引用来避免拷贝。
准则:如果使用可变参数多次调用函数,请考虑移除std::forward。
可以使用Clang-Tidy检查bugprone-use-after-move来标记此情况。
9:标准模板库(STL)容器
上一节我们深入探讨了移动语义,本节中我们来看看如何更有效地使用STL容器和算法。
使用 emplace_back 替代 push_back
我们有一个结构体A,它有一个接受int的构造函数。我们创建一个大小为2的向量,并使用push_back向向量添加元素。输出显示为A调用了移动构造函数,因此这里创建了临时对象。我们可以使用Clang-Tidy检查modernize-use-emplace,它会告诉我们应该使用emplace_back。使用emplace_back后,不再创建临时对象。
准则:在这种情况下,使用emplace_back替代push_back。这也适用于许多其他容器,如deque、forward_list、list、stack、queue、set等,使用emplace替代push或insert。
使用 empty() 替代检查 size()
我们有一个向量,并使用size()检查它是否为空。
// 欠佳代码
std::vector<int> vec;
if (vec.size() == 0) { /* ... */ }
相反,我们可以直接调用empty()。
// 改进代码
if (vec.empty()) { /* ... */ }
这更具可读性,并清楚地显示了代码的意图。它也适用于其他类型,string也有length()。可以使用Clang-Tidy检查readability-container-size-empty,它会给出警告,建议使用empty()替代。
使用范围算法
我们尝试对一个向量排序,使用std::sort并传入起始和结束迭代器。
// 欠佳代码
std::vector<int> vec = {3, 1, 2};
std::sort(vec.begin(), vec.end());
相反,我们可以使用std::ranges::sort。
// 改进代码
std::ranges::sort(vec);
这更简洁、可读,并减少了出错的机会。可以使用Clang-Tidy检查modernize-use-ranges,它会建议使用范围版本。
正确使用 erase-remove 惯用法
我们有两个函数RemoveOdd和RemoveNumber,使用std::remove_if和std::remove。意图是RemoveOdd移除奇数,RemoveNumber移除特定的数字。代码中,我们有一个包含5个整数的向量,调用RemoveOdd和RemoveNumber(2),我们期望只剩下值4,但实际输出是{4, 4, 5}。remove和remove_if重新排列向量中的元素,因此我们要保留的元素现在位于向量的前面。RemoveOdd返回一个指向向量新末尾的迭代器,然后我们将其传递给vector::erase函数,它移除迭代器位置的元素,因此RemoveOdd在重新排列元素后只移除一项。要修复此问题,可以使用接受两个迭代器并移除范围内元素的erase重载。但更好的方法是使用非成员函数std::erase_if和std::erase来避免此错误。
可以使用Clang-Tidy检查bugprone-inaccurate-erase来指出此问题。
使用 contains() 成员函数
我们有一个函数检查集合是否包含一个值,使用std::find。另一个版本使用std::ranges::find。第三个使用set的find成员函数。它们都做同样的事情。相反,我们可以直接使用set的contains成员函数。
// 改进代码
std::set<int> my_set = {1, 2, 3};
if (my_set.contains(2)) { /* ... */ }
contains适用于所有关联容器,并且在C++23中也添加到了其他一些类型中。可以使用Clang-Tidy检查readability-container-contains,它也会捕获count的使用。
使用现有算法
我们有一个函数检查span是否包含能被3整除的整数,并使用基于范围的for循环和if语句。
// 欠佳代码
bool ContainsDivisibleByThree(std::span<const int> span) {
for (int val : span) {
if (val % 3 == 0) return true;
}
return false;
}
相反,我们可以使用std::ranges::any_of,它替换了for循环和if语句。
// 改进代码
bool ContainsDivisibleByThree(std::span<const int> span) {
return std::ranges::any_of(span, [](int val) { return val % 3 == 0; });
}

两者生成相同的代码,因此最好使用现有的算法。
10:杂项
上一节我们优化了STL的使用,本节中我们来看一些其他有用的技巧和模式。
使用原位构造函数
我们正在以这种方式创建pair<A, A>、optional<A>、expected<A, E>和variant<A>。输出显示在所有这些情况下都创建了临时对象。如何移除它们?对于pair,我们可以使用piecewise_construct构造函数。对于optional,我们可以使用make_optional(它使用optional的原位构造)。对于expected,也有一个原位构造函数。对于variant,也有一个原位构造函数。使用这些原位构造函数后,我们得到了没有临时对象的原位构造。
准则:为各种STL类型使用原位构造函数。
使用 std::variant 替代多个 optional
MyClass可以表示一个整数或一个字符串,为此它将两者都存储为单独的optional成员变量。它有两个构造函数,一个接受int,一个接受string。如何改进?我们可以使用单个类型来表示这两者。当然,std::variant就是用于此目的的。因此,我们不是使用两个optional,而是使用一个包含int和string的variant。

准则:避免为类型替代方案使用多个变量,当你有单一类型的容器时使用std::variant。任何时候在代码中看到union,尝试将其转换为variant,因为它提高了安全性和可维护性。
使用 std::monostate 表示空状态
我们创建一个访问者以与variant一起使用。有一个TestVariant函数,它使用访问者调用visit。代码使用0来检测空状态,这是不正确的。我们可以使用std::monostate。monostate可以作为第一个参数类型来表示空状态,代码也变得更简单。当类型没有默认构造函数时,这也很有用。
准则:考虑使用std::monostate来表示变体中的空状态。
将局部变量标记为 const
我们有一个简单的函数,它以半径作为参数,有一个常量pi,然后计算周长并打印。我们还有另一个函数,它创建文件路径、整数和字符串数据,并进行大量输出。在第二个函数中,一旦变量file_path、int_data和string_data被初始化,它们就不再被修改。将它们标记为const有助于提高可读性和理解。
准则:考虑将初始化后不再修改的局部变量标记为const,以帮助提高可读性和理解。

使用立即调用 lambda 表达式创建 const 变量
我们有一些代码试图用许多条件初始化final_value。问题是,final_value能否是const?我们可以通过使用立即调用lambda表达式来实现。

准则:考虑在适用时使用立即调用lambda表达式创建const变量。大多数情况下,你不会有十个条件,但很多时候你有两三个不同的条件用于初始化变量,然后创建一个非常量变量并不断赋值。相反,我们可以使用立即调用lambda表达式来获得一个const变量。
在头文件定义中添加 inline 关键字
我们在头文件中有一个constexpr double kPi。这有什么问题?我们有一个函数GetAddressOfPi,它获取kPi变量的地址。我们有一个源文件定义了该函数。我们还有另一个文件main.cc,它有另一个函数PrintPiAddress,它打印main.cc看到的kPi地址,然后调用GetAddressOfPi来打印另一个源文件看到的地址。输出显示main.cc和另一个源文件看到同一变量的不同地址,这违反了单一定义规则(ODR)。这可以通过标记该常量为inline来轻松修复。
准则:在头文件定义中添加inline关键字,以确保不违反单一定义规则。
总结

在本教程中,我们一起学习了如何通过遵循一系列最佳实践来改进C++代码审查。主要内容包括:



- 明智地限制作用域:最小化条件、循环和
switch语句中的变量作用域,以提高清晰度和减少错误。 - 优雅地迭代:优先使用基于范围的
for循环、结构化绑定、const引用和转发引用,以实现简洁高效的遍历。 - 深思熟虑地设计:使用
enum class、std::variant、std::optional、std::expected来表达意图并消除歧义。 - 精心设计类:在
013:回归基础



概述
在本节课中,我们将学习 C++ 20 引入的核心特性——概念。我们将从基础开始,逐步深入,了解概念是什么、为什么需要它们、如何使用它们,以及它们如何改善代码设计、可读性和错误信息。课程将涵盖概念的基本语法、标准库中的概念、如何编写和组合概念,以及在实际设计中的应用。
什么是 C++ 概念?
C++ 概念是作用于类型或值的布尔谓词。在本课程中,我们主要关注类型。布尔谓词是指计算结果为真或假的东西。概念用于描述类型的“形状”,即类型必须支持的操作、方法或与其他类型的关系。
概念的一个关键点是其语义要求。自 STL 开始,语义要求就一直是概念的一部分,它们描述了类型应如何行为。然而,编译器无法检查语义要求,因此我们主要处理的是语法要求,但这本身已经非常强大。
概念与类型
理解概念和类型之间的区别至关重要。
- 类型:描述一组可以在运行时执行的操作。它具有内存布局和大小,可以被实例化(在栈或堆上分配)。
- 概念:描述类型的形状和用法,即可以在其上执行哪些操作。概念完全存在于编译时,没有运行时开销,也不会出现在链接器中。
核心语法与关键字
C++ 20 为概念引入了新的关键字:
concept:用于定义概念。requires:用于编写要求表达式或子句。concept_name auto:这是一种模式,允许你在原本需要具体类型的地方使用概念,编译器会在编译时用具体的类型来解析它。
概念是可组合的,你可以使用逻辑与、或等操作从简单的概念构建更复杂的概念。本质上,概念是一个布尔谓词,用于判断一个类型是否属于某个集合。
基本用法示例
让我们从一个最简单的概念开始。
template<typename T>
concept Printable = requires(T v, std::ostream& os) {
{ v.print(os) };
};
这个 Printable 概念要求类型 T 必须有一个名为 print 的方法,该方法接受一个 std::ostream& 参数。如果表达式 v.print(os) 有效,则概念得到满足。
class MyType {
public:
void print(std::ostream& os) const { os << "MyType"; }
};
MyType 类满足 Printable 概念,因为它有 print 方法。
现在,我们可以检查一个类型是否满足概念:
static_assert(Printable<MyType>); // 编译通过
static_assert(Printable<int>); // 编译错误:int 没有 print 方法
这非常有用,例如,可以在大型代码库中强制要求所有领域类型都满足特定的序列化模式,无需运行单元测试即可在编译时发现问题。
使用概念约束代码
上一节我们介绍了概念的基本定义和检查。本节中我们来看看如何在函数、变量和模板中使用概念来约束类型。
1. 约束函数参数和返回值
这是概念最常见的用途之一。
// 方式一:在函数签名中使用 `concept auto`
void f(Printable auto p) {
p.print(std::cout);
}
// 方式二:在模板头中使用概念
template <Printable T>
void f_template(T p) {
p.print(std::cout);
}
这两种方式是等价的。f 函数只接受满足 Printable 概念的类型。第二种方式更明确地表明了这是一个模板。
// 约束返回值
Printable auto createPrintable() {
return MyType{};
}
这个函数的返回值可以是任何满足 Printable 概念的类型,而不仅限于 MyType。这提供了灵活性,同时保证了返回类型的形状。
2. 初始化变量
你可以使用概念来约束变量的类型。
// 错误:概念不是类型,无法直接用于变量声明
// Printable s;
// 正确:使用 `auto` 和概念来初始化变量
auto s = createPrintable(); // s 的类型被推导为 MyType,并且它满足 Printable
这里,s 的类型由 createPrintable() 的返回类型推导而来,但编译器会确保该返回类型满足 Printable。如果 createPrintable 的返回类型后来改变了,但只要新类型仍然满足 Printable,代码就仍然有效。
3. 重载决议
概念可以用于约束重载集,帮助编译器选择更合适的函数。
// 一个接受任何类型的通用模板(无约束)
void printLine(auto p) {
std::cout << p << std::endl;
}
// 一个专门处理 MyType 的重载
void printLine(const MyType& mt) {
mt.print(std::cout);
std::cout << std::endl;
}
当调用 printLine(MyType{}) 时,更具体的 MyType 重载会被选中。
// 一个受概念约束的重载
void printLine(Printable auto p) {
p.print(std::cout);
std::cout << std::endl;
}
现在,对于任何满足 Printable 的类型,编译器会优先选择这个受约束的模板,而不是顶部的通用模板,因为它更具体(受概念约束)。
4. 指针与智能指针
概念也可以用于约束指针类型。
void process(const Printable auto* ptr) {
if (ptr) ptr->print(std::cout);
}
void processUnique(std::unique_ptr<Printable auto> ptr) {
if (ptr) ptr->print(std::cout);
}
process 函数只接受指向满足 Printable 类型的指针。processUnique 则约束 std::unique_ptr 管理的对象类型必须满足 Printable。
5. if constexpr 与 requires 子句
if constexpr 可以在编译时根据条件选择代码分支,常与概念或 requires 子句结合。
void printLineSmart(auto p) {
if constexpr (Printable<decltype(p)>) {
// 如果 p 满足 Printable,编译此分支
p.print(std::cout);
} else if constexpr (requires { std::cout << p; }) {
// 否则,如果 p 可以流输出,编译此分支
// 这里使用了 `requires` 表达式,无需预先定义概念
std::cout << p;
} else {
static_assert(false, “Type cannot be printed”);
}
std::cout << std::endl;
}
requires 表达式本身就是一个强大的工具,可以直接在布尔上下文中使用,无需先定义命名概念。
6. 约束成员函数
可以在成员函数后添加 requires 子句来约束其可用性。
template <typename T>
class Wrapper {
T value;
public:
T& operator*() requires std::is_pointer_v<T> {
return *value;
}
// ... 其他成员
};
这里,operator* 仅当 T 是指针类型时才存在。如果 Wrapper<int> 尝试调用 operator*,将会编译失败。
7. 类型别名与容器
可以使用概念创建受约束的模板别名。
template <Printable T>
using PrintableVector = std::vector<T>;
PrintableVector<MyType> vec1; // 正确
PrintableVector<int> vec2; // 编译错误:int 不满足 Printable
PrintableVector 是一个 std::vector,但其元素类型被约束为必须满足 Printable。
标准库中的概念
C++ 20 标准库提供了大量精心设计的概念,了解它们对编写现代 C++ 代码至关重要。以下是部分分组:
- 数值概念:如
std::integral,std::floating_point,std::signed_integral等,用于约束数值类型。 - 比较概念:
std::equality_comparable<T>:类型T可进行相等比较。std::equality_comparable_with<T, U>:类型T和U可相互进行相等比较。
- 关系概念:如
std::totally_ordered,用于约束可排序的类型。 - 对象关系概念:描述类型间的关系。
std::same_as<T, U>:T和U是同一类型。std::convertible_to<T, U>:T可转换为U。std::assignable_from<T, U>:可赋值。
- 构造与初始化概念:如
std::constructible_from,std::default_initializable。 - Regular/Semiregular:非常重要的概念,源自 STL 设计。
std::semiregular:类型可默认构造、拷贝构造、拷贝赋值、析构,并且不移动。std::regular:在semiregular基础上,还要求可进行相等比较(equality_comparable)。这描述了一个“行为良好”、可预测的类型。
使用 static_assert 可以方便地检查类型的规约性:
struct MyStruct {
int x;
// 编译器会生成默认的构造、拷贝、移动、析构函数
// 但需要自己定义或默认 `operator==`
bool operator==(const MyStruct&) const = default;
};
static_assert(std::regular<MyStruct>); // 现在编译通过
- 范围概念:概念与范围库同时出现,范围库极大地依赖概念。例如
std::ranges::range概念要求类型具有begin()和end()。
void printInts(std::ranges::range auto&& rng) {
for (int i : rng) std::cout << i << ' ';
std::cout << '\n';
}
// 可以用于 vector, array, list, span, iota_view 等任何范围
编写与组合概念
我们已经看到了许多使用概念的示例,现在来看看如何自己编写和组合概念。
定义概念
使用 concept 关键字和 requires 表达式定义概念。
template<typename T>
concept OutputStreamable = requires(T v, std::ostream& os) {
{ os << v } -> std::same_as<std::ostream&>;
};
这个 OutputStreamable 概念要求类型 T 必须能使用 << 运算符输出到 std::ostream,并且该运算符的返回类型必须是 std::ostream&(-> 用于约束返回类型)。
组合概念
概念可以使用逻辑运算符进行组合。
template <typename T>
concept PrintableAndMovable = Printable<T> && std::movable<T>;
template <typename T>
concept PrintableOrInt = Printable<T> || std::same_as<T, int>;
你也可以在 requires 子句中直接组合:
template <typename T>
void func(T v) requires (Printable<T> || std::integral<T>) && std::movable<T> {
// ...
}
标准库中的概念(如 std::regular)通常就是由许多更基础的概念组合而成的。
概念与设计
概念不仅仅是语法糖,它深刻地影响了软件设计,特别是依赖管理。
打破类型依赖
传统上,函数和类依赖于具体的类型。这导致了紧密的耦合:修改一个类型可能影响许多依赖它的组件。
概念允许你将依赖从具体类型提升到抽象概念。现在,组件依赖于“具有某种形状的类型”,而不是某个特定类型。这减少了耦合,提高了代码的灵活性和可复用性。
权衡:将依赖转移到概念意味着,如果概念发生变化(例如约束变严格或变宽松),所有依赖该概念的代码都需要重新编译。但这通常是你期望的:如果概念收紧,不满足新概念的类型应该被捕获;如果概念放松,更多类型可以被接受。
提升代码可读性与健壮性

考虑返回类型:

// 1. 无约束 auto:完全依赖返回值,下游代码可能意外中断
auto getValue() { return someFunction(); }
// 2. 具体类型:如果 someFunction 返回类型改变(如 int -> long),这里会隐式转换,可能丢失精度或导致其他问题
int getValue() { return someFunction(); }
// 3. 受概念约束的 auto:清晰地表达了接口契约,如果 someFunction 返回类型改变但不再满足概念,编译会失败
TimeDuration auto getValue() { return someFunction(); }

使用受概念约束的 auto 在灵活性和安全性之间取得了良好的平衡。它使接口意图更清晰,并能在编译时捕获不匹配的契约。
总结
本节课中我们一起学习了 C++ 20 概念的基础知识和应用。
- 概念是什么:作用于类型的编译时布尔谓词,用于描述类型的形状和约束。
- 核心优势:提供更清晰的接口契约、更好的编译时错误信息、更灵活的泛型编程,并且没有运行时开销。
- 基本用法:用于约束函数参数、返回值、变量、重载、指针、
if constexpr分支以及模板特化。 - 标准库概念:熟悉
std::regular、std::ranges::range等现有概念,避免重复造轮子。 - 编写与组合:可以使用
concept和requires定义新概念,并通过逻辑运算符组合它们。 - 设计影响:概念有助于将依赖从具体类型转移到抽象接口,从而降低耦合、提高代码可读性和健壮性。



概念是一个强大的工具,一旦开始使用,就很难再回到没有它的时代。它使得编写清晰、安全、高效的泛型代码变得更加容易,是现代 C++ 编程不可或缺的一部分。
014:你的优化代码可以被调试——MSVC C++ 动态调试方法 🚀




在本节课中,我们将学习微软 Visual Studio 引入的一项新功能——C++ 动态调试。这项功能旨在解决调试优化代码时的常见痛点,让你既能享受优化带来的性能提升,又能获得与调试非优化代码一样的便利体验。
概述:优化与调试的矛盾
上一节我们介绍了优化代码调试的挑战。优化器的工作是让代码运行得更快,但这常常意味着它会移除变量、内联函数,甚至改变代码的执行顺序。这些优化使得调试器难以准确定位变量值和跟踪执行流程,导致出现“变量已被优化掉”或单步执行不符合预期等问题。
动态调试的核心原理
本节中我们来看看动态调试如何解决这个问题。其核心思想是:构建两个版本的二进制文件。
- 优化版本:这是实际运行的、完全优化的代码,保证了程序的执行速度。
- 非优化版本:这是一个隐藏的、关闭了优化的版本,保留了完整的调试信息。
当你设置断点时,调试器会“重定向”执行流。程序依然从优化版本启动并运行,但当你命中断点时,调试器会将执行切换到对应函数的非优化版本。这样,你就能在非优化代码中查看所有局部变量,并且单步执行会像在调试版本中一样直观。
以下是启用该功能的编译器开关:
/dynamicdebug
功能演示:从“痛苦”到“流畅”
让我们通过一个实际演示来感受动态调试带来的改变。
调试传统优化代码的困境
首先,我们运行一个完全优化的程序(未启用动态调试)。虽然程序运行速度很快,但调试体验不佳:
- 变量查看失败:尝试查看局部变量时,调试器显示“变量已被优化掉”。
- 单步执行混乱:使用“逐过程”时,执行箭头并不总是跳到下一行,有时会跳回之前的行,行为难以预测。
- 无法进入函数:尝试“逐语句”进入一个函数时,调试器可能直接跳过,因为该函数已被内联优化。
- 断点设置失败:在某些函数上设置断点时,Visual Studio 会显示空心红圈(警告图标),表示无法在此源位置绑定断点。

启用动态调试后的体验

现在,我们在项目设置中启用动态调试并重新构建。程序启动后,运行速度与完全优化版本无异。
- 成功查看变量:在命中断点的函数栈帧中,可以看到标记为“已反优化”。此时,所有局部变量和参数都可以正常查看和悬停预览。
- 准确的单步执行:“逐过程”会精确地移动到下一行代码。“逐语句”可以成功进入函数调用,进入的帧也会被反优化。
- 可靠的断点:可以在任何函数上设置断点,包括那些被内联或优化掉的函数。你甚至可以为不存在的参数设置条件断点(例如
V % 2 == 1),并且它能被正确触发。
当你结束调试(例如删除所有断点并继续执行)后,程序会自动切换回完全优化的代码路径,确保性能不受影响。
技术实现揭秘
了解了动态调试的便利性后,本节我们将深入幕后,看看它是如何实现的。主要涉及三个方面:双二进制链接、编译时管理和优化还原。
1. 双二进制链接机制
程序如何知道另一个二进制文件的存在?关键在于文件头中的链接。
- 文件链接:优化的可执行文件(如
app.exe)的文件头中包含一个特殊的调试目录项,其中直接存储了非优化版本文件(如app.alt.exe)的路径和名称。调试器启动时就是通过这个“面包屑”找到备用二进制文件的。 - 执行流切换:当在优化函数上设置断点时,调试器会在内存中对该函数开头进行“热补丁”,将其修改为一段跳转代码,从而将执行流重定向到非优化二进制文件中的对应函数。
- 执行流返回:同样重要的是从非优化代码返回优化代码。在非优化版本中,函数调用指令的地址在磁盘上被预留为零。当调试器加载非优化二进制时,会将这些地址填充为优化二进制中对应函数的真实地址。这样,当非优化函数调用其他函数时,实际调用的是优化版本,保证了未调试部分的代码仍以全速运行。
2. 编译时管理:为何没有双倍时间?
构建两个二进制文件,编译时间却没有翻倍,这是如何做到的?秘诀在于重用前端输出和并行处理。
以下是简化的编译流程对比:
传统优化编译流程:
- 前端处理
a.cpp-> 生成a.il(中间语言) - 后端(优化器)处理
a.il-> 生成a.obj(优化目标文件) - 链接器处理
a.obj,b.obj... -> 生成app.exe(优化可执行文件)
启用动态调试后的编译流程:
- 前端处理
a.cpp-> 生成a.il(仅此一次) - 并行执行:
- 后端(优化器)处理
a.il-> 生成a.obj - 后端(非优化器)处理
a.il-> 生成a.alt.obj
- 后端(优化器)处理
- 并行执行:
- 链接器处理
*.obj-> 生成app.exe - 链接器处理
*.alt.obj-> 生成app.alt.exe
- 链接器处理
由于整个构建过程中最耗时的通常是前端(解析、语义分析),而后端和链接步骤被并行化,因此总体编译时间增加有限。实测表明,对于中小型项目,完整构建时间增加约5%-15%,迭代构建(修改单个文件)时间增加约5%-20%。
3. 还原编译器优化:以内联为例
最神奇的部分之一是能够对已被内联优化掉的函数设置断点。这是如何实现的?
关键在于编译器记录了所有的跨函数优化决策,尤其是内联决策。当你在一个函数(如 funcA)上设置断点时,调试器不仅会反优化 funcA 本身(如果它还存在),还会找出所有内联了 funcA 代码的调用方函数(如 funcB, funcC),并将这些调用方也一并反优化。
这样,原本被内联展开的代码逻辑被“还原”为一个实际的函数调用,断点得以绑定并触发。所有相关的局部状态(参数、局部变量)也都在反优化后的上下文中被重建,因此你甚至可以为不存在的参数设置条件断点。

当前限制与使用方式
本节课我们一起学习了动态调试的原理与优势,最后了解一下它的当前适用范围和启用方法。
- 平台限制:目前仅支持 Windows 平台,使用 MSVC 编译器工具链。支持远程调试,但目标机器也需是 Windows 或 Xbox。
- 架构与优化:目前仅支持 x64 架构。需要基于 /O2 优化等级,但不支持链接时代码生成(LTCG,即
/GL和/LTCG)。 - 启用方法:
- Visual Studio IDE:在项目属性 -> 配置属性 -> C/C++ -> 常规 -> “启用 C++ 动态调试” 设置为“是”。
- Unreal Engine 5.6+:在构建配置文件中设置
bDynamicDebugging = true。
总结


本节课中我们一起学习了 MSVC C++ 动态调试功能。它通过智能地构建和切换优化/非优化双版本二进制文件,巧妙地平衡了代码运行效率与调试体验。你无需再在代码中插入 #pragma optimize(off) 或费力阅读汇编代码来推断变量值。现在,你可以始终以发布模式的速度进行开发和调试,在需要洞察代码细节时,获得与调试版本无异的流畅体验。如果你经常需要调试优化代码,强烈建议尝试这一功能。🔧
015:从基础到高级


概述
在本教程中,我们将学习 C++ 中自定义内存分配器的概念、原理和实现。我们将从默认内存管理的问题出发,理解标准库分配器模型,并探讨两种流行的自定义分配器设计模式:池分配器和栈分配器。最后,我们将动手构建一个用于跟踪内存分配的实用分配器。通过本教程,初学者将能够掌握自定义分配器的基本知识及其应用场景。
为什么需要自定义分配器?默认内存管理的问题
在深入自定义分配器之前,我们首先需要了解 C++ 默认内存管理机制(如 new 和 delete)存在哪些问题。
性能开销
使用 new 进行内存分配可能涉及高昂的成本。一次简单的 new 操作背后,可能发生以下步骤:
- 调用
malloc。 malloc若自身内存不足,则执行上下文切换到内核。- 内核通过
mmap或brk/sbrk系统调用分配内存。 - 执行上下文切换回用户空间。
- 在分配的内存上构造对象。
这个过程可能非常耗时,尤其是频繁进行小内存分配时。
int* p = new int(42); // 看似简单,背后可能很复杂
内存碎片与缓存局部性
频繁的随机内存分配和释放会导致内存碎片。碎片化内存不仅降低了内存利用率,还会破坏CPU缓存局部性。
当CPU需要访问数据时,它首先检查高速缓存(L1、L2)。如果数据不在缓存中(缓存未命中),就需要访问更慢的主内存。如果程序中的对象因为内存碎片而分散在内存的不同区域,CPU就需要频繁地在不同内存地址间跳转,导致更多的缓存未命中,从而显著降低程序性能。
理解标准库分配器模型
上一节我们介绍了默认内存管理的弊端,本节中我们来看看C++标准库提供的解决方案之一:分配器。
标准分配器简介
C++标准模板库(STL)中的所有容器都使用一个名为 std::allocator 的模板参数来管理内存。虽然我们通常不显式指定它,但它确实存在。
std::vector<int> vec1; // 默认使用 std::allocator<int>
std::vector<int, MyCustomAllocator<int>> vec2; // 使用自定义分配器
std::allocator 最初是为了解决早期系统(如DOS)中“近内存”和“远内存”的访问问题而设计的。如今,它提供了标准化的内存控制接口。
分配器的核心职责
分配器的核心工作是管理原始内存,而非对象生命周期。它将内存分配与对象构造分离开来:
- 分配/释放内存:提供连续的原始内存块。
- 构造/析构对象:在已分配的内存上构造对象,或析构对象以释放资源(但内存本身可能仍由分配器管理)。
标准库容器负责调用分配器的这些方法。C++11之后,std::allocator_traits 这个辅助类简化了分配器的实现,它为分配器提供了合理的默认实现,我们通常只需实现 allocate 和 deallocate。
分配器的基本用法
以下是使用标准分配器进行手动内存管理的示例:
#include <memory>
#include <iostream>
int main() {
std::allocator<int> alloc;
// 1. 分配原始内存(可容纳5个int)
int* p = alloc.allocate(5);
// 2. 在内存上构造对象
for (int i = 0; i < 5; ++i) {
alloc.construct(p + i, i * 10); // 在地址 p+i 处构造 int,值为 i*10
}
// 3. 使用对象
for (int i = 0; i < 5; ++i) {
std::cout << p[i] << ' ';
}
std::cout << '\n';
// 4. 析构对象
for (int i = 0; i < 5; ++i) {
alloc.destroy(p + i);
}
// 5. 释放内存
alloc.deallocate(p, 5);
return 0;
}
关键点:allocate/deallocate 处理内存,construct/destroy 处理对象。自定义分配器可以优化前一步(内存管理),而后一步通常交给容器或 allocator_traits。
常见自定义分配器设计模式
理解了标准分配器模型后,我们可以探索两种高效的自定义分配器模式:池分配器和栈分配器。它们分别适用于不同的场景。
池分配器
池分配器适用于频繁创建和销毁大量相同尺寸对象的场景,例如游戏中的子弹、敌人,或网络应用中的事务对象。
工作原理
- 预先分配:启动时一次性分配一大块内存,并将其分割成多个固定大小的“块”(每个块大小等于对象大小)。
- 空闲列表:使用一个嵌入式链表(“空闲列表”)来跟踪所有未被使用的内存块。每个空闲块的开头存储下一个空闲块的地址。
- 快速分配:当请求分配时,从空闲列表头部取出一个块,将头部指针指向下一个空闲块,然后返回该块地址。这几乎是O(1)的操作。
- 快速释放:当释放内存时,将对应的块插回空闲列表的头部。释放顺序无需与分配顺序一致。
伪代码概念
class PoolAllocator {
struct FreeNode {
FreeNode* next;
};
FreeNode* free_list_head;
char* memory_pool;
size_t pool_size;
size_t object_size;
public:
void* allocate() {
if (free_list_head == nullptr) throw std::bad_alloc();
void* block = free_list_head;
free_list_head = free_list_head->next; // 移动头指针
return block;
}
void deallocate(void* ptr) {
FreeNode* node = static_cast<FreeNode*>(ptr);
node->next = free_list_head; // 将块插回链表头部
free_list_head = node;
}
};
优点与缺点
- 优点:
- 极快的分配/释放速度(几乎只是指针操作)。
- 完全避免内存碎片,因为所有块大小相同。
- 良好的缓存局部性,同类型对象在内存中可能连续存储。
- 缺点:
- 只适用于单一固定大小的对象。
- 需要预先确定可能的最大对象数量,否则需要实现更复杂的多池或扩展机制。
栈(单调缓冲区)分配器
栈分配器模拟了程序栈的内存管理方式,适用于生命周期嵌套或短暂存在的数据,例如游戏中的单帧数据、临时计算缓冲区。
工作原理
- 预先分配:一次性分配一大块内存。
- 指针偏移:维护一个指向当前空闲内存起始位置的偏移指针(
offset)。 - 顺序分配:每次分配请求都从当前偏移指针处取出所需大小的内存,然后将偏移指针向后移动相应字节。分配是连续的。
- 作用域释放:通常不支持释放单个对象。相反,你可以设置“标记”(
checkpoint)。释放时,只能将偏移指针回退到之前设置的某个标记处,从而一次性释放从那之后分配的所有内存。
伪代码概念
class StackAllocator {
char* memory_start;
size_t total_size;
size_t current_offset;
public:
void* allocate(size_t size, size_t alignment) {
// 计算对齐后的地址
void* aligned_ptr = align_forward(memory_start + current_offset, alignment);
// 计算新的偏移量
size_t new_offset = (static_cast<char*>(aligned_ptr) - memory_start) + size;
if (new_offset > total_size) throw std::bad_alloc();
current_offset = new_offset;
return aligned_ptr;
}
// 通常不提供单个对象的deallocate
void deallocate_to_marker(size_t marker) {
current_offset = marker; // 将指针回退到标记处
}
size_t get_marker() const { return current_offset; }
};
优点与缺点
- 优点:
- 可能的最快分配速度(仅增加一个偏移量)。
- 无内存碎片。
- 支持不同大小的对象。
- 缺点:
- 释放不灵活,必须按分配的逆序进行(后进先出)。
- 不适合对象生命周期不可预测或交错的场景。
两种分配器对比总结
| 特性 | 池分配器 | 栈分配器 |
|---|---|---|
| 分配速度 | 极快 (O(1)) | 最快 (移动指针) |
| 释放速度 | 极快 (O(1)),可单独释放 | 快,但只能作用域释放 |
| 灵活性 | 固定对象大小 | 任意对象大小 |
| 适用场景 | 同类型对象频繁创建销毁(如游戏实体、连接) | 临时数据、单帧内存、作用域内存 |
实战:构建一个跟踪分配器
学习了理论之后,让我们动手实现一个简单但实用的自定义分配器:跟踪分配器。它的功能是包装标准分配器,并记录所有内存分配和释放操作,用于调试和性能分析。
以下是实现步骤:
- 定义分配器类模板:它需要满足标准分配器的接口要求。
- 使用
std::allocator作为底层分配器:我们将委托它进行实际的内存操作。 - 添加跟踪逻辑:在
allocate和deallocate函数中打印日志。 - 提供必要的类型定义和构造函数:包括拷贝构造函数和比较运算符,这是标准容器对分配器的要求。
#include <iostream>
#include <memory>
#include <vector>
template <typename T>
class TrackingAllocator {
public:
// 分配器必须提供的类型定义
using value_type = T;
// 构造函数
TrackingAllocator() = default;
template <typename U>
TrackingAllocator(const TrackingAllocator<U>&) noexcept {}
// 分配内存并跟踪
T* allocate(std::size_t n) {
std::size_t total_bytes = n * sizeof(T);
std::cout << "[Allocate] " << n << " objects of size " << sizeof(T)
<< " (" << total_bytes << " bytes total)\n";
// 委托给标准分配器进行实际分配
return std::allocator<T>{}.allocate(n);
}
// 释放内存并跟踪
void deallocate(T* p, std::size_t n) {
std::size_t total_bytes = n * sizeof(T);
std::cout << "[Deallocate] " << n << " objects of size " << sizeof(T)
<< " (" << total_bytes << " bytes total)\n";
// 委托给标准分配器进行实际释放
std::allocator<T>{}.deallocate(p, n);
}
};
// 分配器需要支持比较操作
template <typename T, typename U>
bool operator==(const TrackingAllocator<T>&, const TrackingAllocator<U>&) {
return true; // 我们的跟踪分配器是无状态的,所以总是相等
}
template <typename T, typename U>
bool operator!=(const TrackingAllocator<T>& a, const TrackingAllocator<U>& b) {
return !(a == b);
}
int main() {
// 使用我们的跟踪分配器创建一个vector
std::vector<int, TrackingAllocator<int>> vec;
std::cout << "Pushing back elements...\n";
vec.push_back(1); // 可能分配
vec.push_back(2); // 可能重新分配并释放旧内存
vec.push_back(3); // 可能再次重新分配并释放旧内存
vec.push_back(4);
vec.push_back(5);
std::cout << "\nVector contents: ";
for (auto i : vec) std::cout << i << ' ';
std::cout << '\n';
std::cout << "\nGoing out of scope...\n";
// main函数结束时,vector析构,会释放所有内存
return 0;
}
运行此程序,你可能会看到类似以下输出:
Pushing back elements...
[Allocate] 1 objects of size 4 (4 bytes total)
[Allocate] 2 objects of size 4 (8 bytes total)
[Deallocate] 1 objects of size 4 (4 bytes total)
[Allocate] 4 objects of size 4 (16 bytes total)
[Deallocate] 2 objects of size 4 (8 bytes total)
... (后续push_back可能触发更多分配/释放)
Vector contents: 1 2 3 4 5

Going out of scope...
[Deallocate] 8 objects of size 4 (32 bytes total) // 最终释放
这个输出清晰地展示了 std::vector 在增长过程中(通常以2的幂次扩容)是如何进行内存重新分配和拷贝的。这个简单的跟踪分配器是理解和调试容器内存行为的强大工具。

总结与建议


在本教程中,我们一起学习了C++自定义分配器的核心知识:
- 动机:我们了解了默认
new/delete在性能和内存布局上的潜在问题。 - 基础:我们学习了标准库分配器模型,明确了分配器负责管理原始内存,而对象构造/析构由容器负责。
- 模式:我们探讨了两种高效的自定义分配器设计模式:
- 池分配器:用于固定大小、频繁创建销毁的对象,提供O(1)的分配/释放。
- 栈分配器:用于临时、生命周期嵌套的数据,提供最快的顺序分配和批量释放。
- 实践:我们实现了一个跟踪分配器,演示了如何将自定义分配器与STL容器结合,并用于观察内存行为。
给初学者的建议
- 优先使用现代C++和标准库:在大多数情况下,
std::vector、std::unique_ptr、std::make_shared等工具已经足够高效和安全。遵循“80/20法则”,用20%的精力获得80%的收益。 - 不要过早优化:除非性能分析(Profiling)明确表明内存管理是瓶颈,否则应避免引入复杂的自定义分配器,因为它们会增加代码复杂性和出错风险。
- 理解原理,善用工具:即使不自己实现,理解这些模式也有助于你更好地使用像
std::pmr::monotonic_buffer_resource(栈分配器)和std::pmr::unsynchronized_pool_resource(池分配器)这样的C++17标准库内存资源工具。 - 谨慎行事:如果必须实现自定义分配器,务必进行充分测试,特别是对于对齐(Alignment)、边界检查和重复释放等常见问题。记住:“测量两次,切割一次”(Measure twice, cut once)。
通过掌握这些基础知识,你已具备了在必要时深入优化C++程序内存管理的能力。记住,强大的能力也意味着重大的责任。
016:在Asio代码库中采用stdexecution

概述
在本节课中,我们将探讨如何将C++26标准中的新异步框架stdexecution(发送者/接收者模型)与已存在二十多年的行业标准库Asio进行集成。我们将学习如何在不重写现有Asio代码的前提下,利用stdexecution的结构化并发优势,实现两个生态系统的无缝协作。
同步函数调用模型回顾
上一节我们概述了课程目标,本节中我们来看看异步编程的基础——同步函数调用模型。
一个函数本身只是一组潜在的指令集合。为了执行工作,它需要与一个调用点结合。调用点将向前推进的能力委托给函数。
同步函数还需要一个执行工作的空间,即一个栈帧,用于存储所有具有自动存储期的变量。
从调用点的角度看,栈帧的分配和跳转到函数是原子性的。函数运行至完成,并通过调用点返回结果,完成整个循环。
以下是同步函数的一个例子,它演示了如何将多次部分读取拼接成一次完整的读取:
size_t read_fully(int fd, void* buffer, size_t total) {
char* ptr = static_cast<char*>(buffer);
size_t remaining = total;
while (remaining > 0) {
ssize_t n = read(fd, ptr, remaining);
if (n <= 0) {
// 处理错误或EOF
break;
}
ptr += n;
remaining -= n;
}
return total - remaining;
}
在这个函数中,参数buffer被具体化为一个具有自动存储期的变量ptr。我们可以在函数的剩余部分自由使用和修改它,而无需担心其生命周期。
Asio异步模型
上一节我们回顾了同步模型,本节中我们来看看Asio的异步模型。
Asio模型的核心是一个发起函数。它是一个具有特殊属性的常规同步函数:在成功完成后,会有一个异步操作在后台挂起。
这意味着操作可以在同步发起函数返回后完成。因此,仅选择一个调用点是不够的。我们需要将发起函数与一个完成处理器(一个可调用对象)结合起来,该处理器可以调用并传递操作合成的所有值。
当这两者结合时,就产生了一个异步操作。操作完成后,会调用完成处理器。
与同步模型不同,Asio模型本身并不内在地提供稳定的本地存储。在同步函数中,栈帧的分配是隐藏的。但在Asio中,这种稳定的存储并不存在。
以下是一个Asio函数示例,它实现了与之前同步函数相同的功能:
template <typename CompletionToken>
auto async_read_fully(tcp::socket& sock, void* buffer, size_t total, CompletionToken&& token) {
// 为了简化,这里省略了实际的异步循环逻辑
// 关键点在于,lambda需要捕获`buffer`和`total`,并在后续操作中被移动。
auto initiation = [&sock, buffer, total](auto&& handler) {
// Asio操作会移动这个lambda,其内部状态(如指向buffer的指针)是不稳定的。
// 需要小心管理生命周期。
// ...
};
return async_initiate<CompletionToken, void(error_code, size_t)>(
initiation, token, std::ref(sock), buffer, total
);
}
在这个代码中,lambda捕获了sock、buffer和total。这个lambda会被移动到后续的异步操作中,其内存地址并不稳定,因为Asio缺乏同步函数所具有的稳定栈帧特性。
stdexecution 发送者/接收者模型
上一节我们介绍了Asio模型,本节中我们来看看C++26的stdexecution模型。
stdexecution模型的核心是一个发送者,它是完全柯里化的异步函数类比。就像一个函数一样,它自身没有动力,直到它与报告其完成的上下文——一个接收者——结合。因此,这个模型常被称为“发送者与接收者”。
一个发送者和一个接收者连接后,该操作的输出是一个操作状态。这是同步栈帧的异步类比,是操作在其整个生命周期中可以依赖的、稳定的本地存储。
关键的是,与同步领域不同,我们可以独立地观察此存储的分配。当我们调用connect并返回一个操作状态时,我们没有义务立即开始推进其所代表的异步操作。相反,我们可以将其推迟任意时间。但一旦我们在操作状态上调用start,就会产生一个异步操作。它运行至完成,并向接收者发送一个完成信号。
同样,我们可以查看一个使用发送者/接收者模型实现的read操作示例:
auto async_read_fully_sender(tcp::socket& sock, void* buffer, size_t total) {
return stdexec::let_value(
stdexec::just(std::span<char>(static_cast<char*>(buffer), total)),
[&sock, total](std::span<char> span) {
// 使用 `let_value` 在操作状态中分配一个 `span`。
// 这个引用在异步操作的剩余时间内保证有效。
size_t remaining = total;
// ... 异步循环逻辑
return stdexec::just(remaining); // 简化返回
}
);
}
在这里,我们使用let_value在操作状态内部分配了一个span。这个引用在异步操作的剩余时间内保证有效,因此我们可以放心地使用它,其方式不仅类似于同步领域,而且在词法上完全相同。
整合两大生态系统
上一节我们分别了解了Asio和stdexecution的模型,本节中我们来探讨如何将它们整合在一起。
回顾课程开头的例子,我们是从stdexecution代码中调用Asio操作。因此,也许我们应该从stdexecution模型开始:一个发送者、一个接收者和一个操作状态,但它们具有特殊的结构。
当我们在操作状态上调用start时,它会合成一个完成处理器,并将其传递给一个Asio发起函数。根据我们对Asio模型的理解,结果是一个异步操作。该操作完成并调用完成处理器。但我们选择了这个完成处理器,我们合成了它,因此我们当然可以将其实现为向接收者发送完成信号。
让我们尝试用代码来实现这个想法。首先,我们编写一个工厂函数,它创建一个Asio发送者,并接受一个“发起函数”——一个只等待选择完成处理器的单元可调用对象。
根据我之前所说的“发送者是同步函数的完全柯里化版本”,我们只需将参数柯里化到一个发送者中并返回它。
这引出了一个根本问题:那个发送者内部到底是什么?让我们深入查看。
template <typename Initiation>
auto make_async_sender(Initiation initiation) {
// 发送者类型
struct asio_sender {
using is_sender = void; // 选择加入stdexec概念机制
Initiation init; // 数据成员,存储柯里化的发起函数
template <typename Receiver>
auto connect(Receiver&& rcvr) && {
// 连接操作,返回操作状态
struct operation_state {
using is_operation_state = void;
Initiation init;
std::decay_t<Receiver> rcvr;
void start() & noexcept {
// 调用发起函数,并传递我们合成的完成处理器
std::move(init)([this](auto&&... args) {
// 完成处理器:将底层Asio操作合成的值完美转发给接收者
stdexec::set_value(std::move(rcvr), std::forward<decltype(args)>(args)...);
});
}
};
return operation_state{std::move(init), std::forward<Receiver>(rcvr)};
}
};
return asio_sender{std::move(initiation)};
}
这个发送者通过提供必要的嵌套类型别名来选择加入stdexecution的概念机制。它有一个用于存储发起函数的数据成员。它的connect方法接收一个接收者,并将发起函数和接收者一起放入一个操作状态中返回。
操作状态通过一个必要的嵌套类型别名选择加入成为操作状态。它现在有两个数据成员。然后,它拥有操作状态的基本操作:start。start会调用发起函数,并向其提供我们合成的完成处理器。这个完成处理器获取底层Asio操作合成的所有值,并将它们通过值通道完美转发给接收者,从而完成操作。
处理异常与完成签名
上一节我们初步整合了两个模型,但代码存在一个问题:start函数被标记为noexcept,但我们泛型接受的initiation可能抛出异常。这会导致程序终止,显然不符合人机工程学。
解决方案不是简单地移除noexcept。start是从同步领域过渡到异步领域的点,同步报告机制(如抛出异常)变得不可用。start的实现必须只使用异步报告机制,例如捕获自身的异常并将其导向接收者的错误通道。
但这又带来了新问题:我们的包装器原本有一个很好的属性——它完成的方式与底层Asio操作完全相同。现在,我们添加了一种新的完成方式(通过异步的“抛出异常”模拟)。我们如何通用地向用户记录和公开这一点?
在同步领域,函数有签名。我们可以查看它,了解返回的值(如果有),并根据是否标注noexcept来判断它们是否有错误通道。作为一个通用生态系统,stdexecution对此有解决方案:通过实例化stdexec::completion_signatures来记录操作完成的方式。
// 示例:记录操作可以成功完成(传递一个int),或通过发送exception_ptr错误完成
using my_sigs = stdexec::completion_signatures<
stdexec::set_value_t(int),
stdexec::set_error_t(std::exception_ptr)
>;
但是,仅仅特化一个类模板并不能解决问题。我们实际上需要的是关联。因此,我们发现还没有完全探索发送者的接口表面,因为发送者还需要通过提供get_completion_signatures这个consteval静态成员函数来记录它们可以完成的方式,其返回类型记录了相应操作将如何完成。
然而,这里我们还没有令人满意地回答问题。我们需要发现底层Asio操作完成的方式,然后加上set_error(带exception_ptr)。这引出了一个问题:我们如何通用地发现底层Asio操作的完成方式?
答案在于Asio的完成令牌机制。
Asio 完成令牌机制
上一节我们遇到了如何发现Asio操作签名的问题,本节中我们引入Asio的完成令牌机制。
之前我们看到的Asio发起函数并不是一个符合惯例的Asio发起函数,因为它直接接受一个完成处理器。为了使它符合惯例,我们需要进行一项更改:重写函数签名,使其接受一个完成令牌,而不是直接接受完成处理器。
但仅此转换本身并没有给我们带来什么。关键在于,添加一层间接性是不够的,如果你不实际通过定制点进行解析。在这种情况下,定制点是async_initiate。
async_initiate接收启动操作的逻辑(作为一个lambda)。它可以决定在何时、何地以及如何调用该lambda,从而决定异步操作在何时、何地以及如何开始。更重要的是,完成处理器的声明也来自async_initiate的实现。定制点的另一方不仅可以决定操作如何开始,还可以决定它如何完成。
此外,我们还以另一种方式定制了发起函数:我们定制了它的返回值,并允许async_initiate也选择该值。我们不再局限于返回void。
最后,通过这种转换,我们迫使发起函数的作者通用地记录其操作可以完成的所有方式。
因此,我们需要重新制定我们对如何适配Asio和stdexecution的理解,以考虑使用这些允许我们完全定制Asio操作的完成令牌。
我们更新对Asio生态系统的理解图:发起函数不接受完成处理器,而是接受完成令牌。完成令牌能够定制发起函数的每个元素。
我们可以想象,也许我们建议的完成令牌会如此彻底地定制发起函数,以至于诱导其返回一个发送者。如果它这样做,我们当然可以免费获得stdexecution生态系统的其余部分。
实现自定义完成令牌
上一节我们引入了完成令牌的概念,本节中我们来看看如何实现一个能返回发送者的自定义完成令牌。
首先,我们声明一个完成令牌类型及其一个实例以便使用。
struct asio_sender_token {
// 标记类型,用于特化
};
inline constexpr asio_sender_token use_asio_sender{};
但正如之前所说,如果我们不实际在另一侧提供实现,这没有任何意义。因此,我们特化async_result来提供我们的实现。
// 为我们的令牌特化 async_result
template <typename Initiation, typename... Args>
struct async_result<asio_sender_token, Initiation(Args...)> {
// 关键:我们接收所有签名,通用地记录此Asio操作完成的所有方式。
// 为简化,假设我们知道签名。实际中需要从Initiation推导。
using completion_signatures = stdexec::completion_signatures<
stdexec::set_value_t(std::size_t), // 示例:成功时返回读取的字节数
stdexec::set_error_t(std::error_code), // Asio错误码
stdexec::set_error_t(std::exception_ptr), // 我们添加的异常通道
stdexec::set_stopped_t() // 我们稍后添加的停止信号
>;
template <typename Initiation2, typename... Args2>
static auto initiate(Initiation2&& initiation, asio_sender_token, Args2&&... args) {
// 将发起函数柯里化为一个单元可调用对象
auto f = [initiation = std::forward<Initiation2>(initiation), ...args = std::forward<Args2>(args)] (auto&& handler) mutable {
std::forward<Initiation2>(initiation)(std::forward<decltype(handler)>(handler), std::move(args)...);
};
// 将参数柯里化为一个发送者,并转换Asio签名为stdexecution期望的形式
return make_async_sender(std::move(f));
}
};
在静态成员函数initiate中,我们接收发起函数、完成令牌实例以及需要柯里化到发起函数中的参数。我们忠实地执行这个柯里化,将发起函数构造成一个只等待选择完成处理器的单元可调用对象f。
然后,我们再次将参数柯里化为一个发送者,但该发送者通过将Asio签名转换为stdexecution期望的形式而得到丰富。我们提供了之前缺失的重要上下文。
这引发了一系列问题。我们需要修改大量代码才能使其工作,例如提供必要的模板元编程来转换这些签名。
签名转换的基本思想是将Asio的完成签名(例如void(std::error_code, size_t))转换为stdexecution的完成签名(例如stdexec::set_value_t(size_t)和stdexec::set_error_t(std::error_code))。此外,我们还需要附加stdexec::set_error_t(std::exception_ptr),因为我们用异步的“抛出异常”丰富了该集合。
现在,我们需要遍历代码并用这种理解更新它。我们需要在发送者上提供get_completion_signatures,但也需要提供其受约束的版本。回想一下connect,我们需要将发起函数从发送者传播到操作状态。在某些C++引用限定符下,这可能是不可能的。例如,如果我们有一个仅可移动的发起函数,但有人尝试左值连接我们的发送者(这需要我们复制发起函数),这显然是不可能的。因此,我们在此场景中检测到这种情况,并拒绝在此实例中生成完成签名。
继续这样,我们转到connect,它仍然接受接收者并将数据移动到操作状态,但现在被约束为要求提供的接收者能够接受我们发出的所有完成签名。
这里需要注意一些微妙之处:我通过委托给stdexec::completion_signatures_of_t来确定我们发出的完成签名。这反过来会尝试评估我在前一页幻灯片上展示的函数,从而传递性地引入其上的所有约束。
操作状态则不受此转换的干扰。
结构化并发与异常处理
上一节我们处理了完成签名,但还有一个更根本的问题:Asio的异步模型是非结构化并发,而stdexecution带来了结构化并发。
在Asio中,你可以启动一个操作,然后直接离开(例如停止调用io_context::run()),操作可能被永远放弃。这在Asio应用程序中是常见且有效的优雅关闭方式。
然而,结构化并发要求:当你启动一个异步操作时,该操作必须在某个时刻向你报告完成。这就像结构化编程中,你不会期望调用一个函数会导致执行跳转到某个不相关的部分并永不返回。
因此,我们现在需要弄清楚如何在这种非结构化原语之上分层实现结构化并发的保证。
让我们考虑一个简单的例子:直接从main启动一个异步操作并驱动其完成。我们调用发起函数(一个常规同步函数)。如果成功,它会返回,并且有一个异步操作在后台挂起。
但这个“挂起”到底意味着什么?谁将运行它?我们需要做什么来诱导它完成?
在大多数Asio应用程序中,答案是:我们需要去执行上下文,并将向前推进的能力委托给它。我们需要在其上调用run()。在内部,run()有特殊的结构,它等待异步部分准备好执行并执行它们。
因此,是run()调用了我们的完成处理器。当从完成处理器抛出异常时,它被抛给run(),并由run()根据该执行上下文和执行器提供的任何策略进行处理。它可能只是不再运行该操作,也可能做其他事情。
不幸的是,这种表述与stdexecution的要求不符。结构化并发要求操作必须报告完成。
解决方案是注入我们自己的逻辑。我们可以在完成处理器周围包装我们自己的lambda。这样,我们就可以以我们想要的任何方式处理由此抛出的任何异常。我们可以将它们导向接收者,通过错误通道发送。
事实上,我们可以说我们正在实现一个执行器,而这是我们处理异常的、执行器指定的方式,它满足结构化并发的保证。
实现包装执行器
上一节我们提出了注入包装逻辑的想法,本节中我们来实现一个P0443风格的单向执行器。
我们的执行器有两个成员:一个指回操作状态的引用(因为接收者在那里),以及一个对底层执行器的包装(因为我们不想定制所有异步部分的执行方式,只想定制异常处理这一特定方式)。
P0443风格执行器必须是可比较的,因此我们提供operator==。
执行器的基本操作是单向成员函数execute,它接收一个单元可调用对象并以某种方式调用它——根据该执行器体现的任何策略调用它。
最简单的实现是简单地委托给我们包装的执行器。但我们需要保证调用内部执行器的execute不会抛出异常。我们这样做的全部原因是为了捕获异常并将其导向接收者的错误通道。因此,我们正是这样做的。
但随后我们意识到另一件事:我们底层的执行器体现了它体现的任何执行策略。我们对此一无所知。也许该策略是将工作放在队列上,稍后出列并在不在我们调用堆栈下的某个地方调用它。在这种情况下,这个catch将无法捕获由此抛出的异常。因此,仅在外面包装是不够的,我们还必须侵入execute内部并在那里也进行包装。
当然,这不足以填满P0443风格单向执行器所需的接口,但这不是本次演讲的重点。
现在,我们需要关心我们刚刚编写的这个类如何被注入到Asio生态系统中。我们如何告诉Asio需要使用这个来调用我们操作的异步部分,而不是它本来要选择的任何其他执行器?
答案是:我们需要编写一个关联器。我们需要将我们的完成处理器与一个获取执行器的策略关联起来,以便我们可以在每个分析级别定制它。
之前,当我们特化async_result以提供async_initiate的实现时,我们看到了类似的东西。现在,我们特化associated_executor以提供get_associated_executor的实现。
但这需要一个我们可以讨论的完成处理器类型,这是我们稍后要担心的事情。现在,让我提请你注意get的实现:我们获取我们的完成处理器以及Asio本来要使用的任何后备执行器,将两者包装到我们的执行器中并返回它,从而导致Asio在每个分析级别定制它调用异步部分的方式。我们现在已经成功地捕获了所有这些异常并将它们重定向到我们的接收者。
当然,这要求我们有一个可以实际讨论的完成处理器类型。因此,我们继续这样做。我们将之前使用的lambda的主体提取到函数调用操作符中。我们提供一个指回操作状态的指针。然后,当然,如果我们不回头查看我们的操作状态,实际获取那个lambda并用我们的完成处理器替换它,这将是无用的。当这个完成处理器沉入Asio时,它现在随身携带我们刚刚定义的定制。Asio被诱导使用我们的执行器,我们的逻辑在每个分析级别。
处理广义放弃与操作状态生命周期
上一节我们处理了异常,但放弃操作不仅仅通过异常发生。Asio应用程序中优雅关闭的常见方式是简单地停止调用执行上下文的run(),让run()永不再次推进操作,优雅地清理所有资源并退出作用域。这是Asio程序员做的,不是因为他们是糟糕的程序员,而是因为这种表述在Asio应用程序中完美工作。
因此,仅处理通过异常放弃是不够的。我们也需要处理任何其他原因的放弃。
我们需要考虑异步操作的任何异步部分的结果。在顺利情况下,操作完成,我们无需担心。在不太顺利的情况下,我们只是完成了整体异步目标的一部分,操作仍在进行中,我们可以稍后处理其最终化。
我们之前只处理了带有放弃的异常。如果抛出异常并不表示异步操作将不再向前推进呢?想象你有一个高级Asio操作,它由两个同时启动和运行的操作组成。你运行某个异步部分,但那只是一个子操作的一部分。它抛出一个异常。那个子操作被放弃了,但总的异步操作还没有。仍然有另一个未完成的操作。因此,在这种情况下,立即发送该错误是不安全的。
然后,还有那种新情况:我们无缘无故地放弃了它。我们需要将其具体化为一种新的完成信号,通过向接收者发送stdexec::set_stopped来指示向前推进已被放弃。
因此,现在我们必须以新的视角审视我们的代码。我们之前编写这段代码时确信我们处理了所有边缘情况。但如果这个发起函数去启动两个操作,只在启动第二个时抛出异常,那么后台仍然有一个异步操作未完成。因此,在此响应中最终化包装器是不合适的。
此时,我们所能做的只是绝望地记下发生了异常,并希望也许稍后我们会弄清楚该怎么做。当然,如果操作状态中没有它的成员,我们就无法记下异常。因此,我们这样做。但这不是我们具体化这个错误假设的唯一地方。我们在执行器内的这两个地方也这样做了。因此,我们再次简单地记下发生了异常的事实,举手投降,希望我们会在某个时候弄清楚如何处理它。
处理异常并不是我们引入的唯一东西。我们还引入了广义放弃的概念。因此,我们提供某种方式让这通过操作状态进行通信。
但在此之前,我们需要承认操作状态本身具有生命周期,这个生命周期类似于常规同步栈帧的生命周期。当通过接收者发送完成信号时,就像从同步函数返回。此后,我们不能依赖操作状态在其生命周期内。
因此,现在我们需要回过头来,从操作状态生命周期的角度审视每个异步步骤的结果。之前,我说操作完成是顺利情况。但如果操作完成,我们不能认为操作状态在其生命周期内。因此,我们不能去检查我们添加的所有那些记录操作当前状态的必要成员。
沿着幻灯片看另外两种情况,我们这样做是至关重要的。我们必须确定操作是否被放弃。但要做到这一点,我们需要查看操作状态的一个成员。
这让我们回到我们生态系统的图表,回到我们的包装器lambda调用操作的每个异步部分的方式。这让我回到了我一直在谈论的类比:同步栈帧和操作状态之间的类比。
我在这里纠结于操作状态的生命周期。但这个包装器lambda,我们需要与之通信以告诉它操作是否被放弃,是一个同步函数。它有一个常规的同步栈帧。在那个栈帧中,它可以存储具有自动存储期的局部变量。
因此,也许它继续这样做。但它告诉操作状态那个变量在哪里,以便操作状态现在可以以一种保证在其生命周期内(当我们展开并检查它时)的方式与之通信。
但幻灯片上的图表不是规范的。这不是唯一可能的调用堆栈。我们可以递归地具体化这一点。那我们该怎么办?嗯,那个上层包装器lambda也只是一个常规同步函数。它有自己的栈帧,有自己的自动存储期变量。因此,我们只需创建一个侵入式链表。我们让操作状态知道所有等待完成处理器结果的状态在哪里,以便它们可以去,并且它可以与它们通信操作是否完成,以及认为操作状态是否安全或不安全。
实现帧(Frame)机制
上一节我们讨论了操作状态生命周期管理的挑战,本节中我们通过实现一个帧(Frame) 机制来解决它。
我们创建一个frame类型。由于我前面所说的,我们需要用其侵入式链表来丰富我们的操作状态。
class frame : public boost::intrusive::slist_base_hook<> {
operation_state* self_; // 关键:这是指针,不是引用
std::unique_lock<std::recursive_mutex> lock_;
public:
// 删除拷贝和移动,确保唯一所有权
frame(const frame&) = delete;
frame& operator=(const frame&) = delete;
frame(operation_state* self, std::recursive_mutex& mtx)
: self_(self)
, lock_(mtx) {
// 将自己添加到操作状态的侵入式链表中
if (self_) {
self_->frames_.push_front(*this);
}
}
// 用于检测操作是否已结束的便捷操作符
explicit operator bool() const noexcept { return self_ != nullptr; }
void release() {
if (self_) {
// 从链表中弹出自己
this->unlink();
// 解锁互斥锁
lock_.unlock();
// 将self_设为null,确保无法再查看操作状态
self_ = nullptr;
}
}
~frame() {
if (!self_) {
// 操作已完成,无事可做
return;
}
// 从链表中移除自己,避免悬垂指针
this->unlink();
// 计算是否应由我们实际完成此操作
bool should_complete = self_->frames_.empty(); // 我们是最后一个参与者
if (should_complete) {
// 检查是否被放弃
if (self_->abandoned_) {
// 释放锁并检查是否应完成
lock_.unlock();
// 检查存储的异常
if (self_->stored_exception_) {
stdexec::set_error(std::move(self_->receiver_), std::move(self_->stored_exception_));
} else {
stdexec::set_stopped(std::move(self_->receiver_));
}
return;
}
}
// 否则,仅释放资源,操作仍在进行或由他人完成
lock_.unlock();
}
};
frame派生自slist_base_hook,允许它参与这个侵入式链表。它有一个指回操作状态的指针(这是关键,不是引用)。当我们意识到操作已完成,并且不再安全访问操作状态时,我们可以将其设置为null,使其不可能发生。
我们删除拷贝和移动操作,并提供一个便捷的operator bool,允许我们的消费者检测操作是否已结束。
在构造函数中,我们不仅填充self指针,还成为递归互斥锁的锁保护。然后,我们将自己添加到操作状态的侵入式链表中。
操作状态需要能够在操作完成时通知我们并停用我们。因此,我们提供一个release函数来做到这一点:我们从侵入式链表中弹出自己,解锁互斥锁,最后将self设置为null以确保无法再去查看操作状态。
但这并没有回答我们编写此类型的根本问题:我们如何知道何时通过接收者发送异常和停止。该逻辑具体化在也许是整个演讲中最复杂的幻灯片中——frame的析构函数。
如果self已经被清除(已设置为nullptr),操作已经完成,因此我们无事可做。另一方面,如果我们有工作要做,我们过去将自己从侵入式链表中移除(我们不希望有指向自己的悬垂指针)。
然后,我们计算是否应该由我们实际完成此操作,是否应由我们提供结构化并发的保证。如果没有剩余的帧,我们是参与生态系统的最后一个人。因此,我们检查是否被放弃。
如果我们被放弃,我们是参与的最后一部分。如果我们不最终化操作,永远不会有人这样做,我们将违反结构化并发的保证。因此,我们释放锁并检查是否应完成。如果应完成,我们检查存储的异常。如果找到,我们以错误完成。如果没找到,我们通过接收者发送set_stopped。
在这样做的过程中,我们做了之前在演讲中做过的事情:我们丰富了操作可以完成的集合。因此,我们立即尽职地回到转换签名,并丰富它以记录我们也发送set_stopped的事实。
考虑到这一点,我们现在可以去访问那些我们之前留下的代码片段。我们记下了发生异常,但我们不明白需要实现什么逻辑才能仅在适当时发送该异常。现在我们知道 exactly 该做什么。我们需要做的就是构造一个frame对象并让其生命周期结束,它将为我们处理其他一切。
当然,这不是我们这样做的唯一地方。我们在执行器中也这样做了。因此,现在我们只需创建两个局部变量。
完成处理器的实现
上一节我们实现了frame,但如果完成处理器不参与,这是没有帮助的,因为完成处理器是这一切的中心。因此,现在我们需要给它真正的构造函数,直到你想要的复杂逻辑来构造和销毁它。
当它被创建时,当然会填充指针。但现在我们丰富移动操作,以便只有最传递派生的完成处理器具有指向操作状态的非空指针。这是完全可以接受的,因为Asio只要求完成处理器是可移动的。因此,当Asio操作移动它时,只有最近创建的那个才会有非空指针,将控制操作状态。所有其他的都将被停用,它们的生命周期结束没有任何后果。

说到这一点,我们实际上需要实现该逻辑。因此,在我们的析构函数中,我们检查指回操作状态的指针。如果为真,我们可能需要最终化(这是被放弃的情况)。因此,我们构造一个frame,以便当它超出作用域时,将执行最终化逻辑(如果有的话)。我们去操作状态并说它已被放弃。
但这都只是围绕不顺利情况的脚手架。当操作实际成功完成时的顺利情况呢?在这种情况下,我们获取锁保护。我们去操作状态并释放当前参与生态系统的每个栈帧。我们告诉它们,当我们展开时,它们无事可做。完成这些后,我们丢弃锁。我们获取对我们接收者的引用。我们知道我们内部的指针,从而停用我们的析构函数,并确保我们完成此操作的唯一方式是在下一行,我们通过接收者发送stdexec::set_value。

在完成这些后,我们实际上已经成功地将结构化并发的保证投射到Asio的非结构化原语之上。

整合取消机制
上一节我们实现了结构化并发,但这是双刃剑。结构化并发不仅保证操作将完成,还要求你在继续之前等待这些操作完成。这是我们保证操作状态在其关联的异步操作期间保持在其生命周期内的方式。
但如果你启动某个操作并且它运行很长时间,而你在之后的某个时间点决定不再关心该计算的结果,那该怎么办?现在,你被结构化并发的保证束缚了手脚。尽管你不再关心该计算,你需要等待它完成。
这引出了整合这两个生态系统的最后一部分:需要将它们实现取消的方式结合起来。幸运的是,两者都支持实现这一点的机制。
在stdexecution的情况下,这是通过停止令牌实现的。接收者随身携带一个环境。我们可以询问该环境以获取其停止令牌,然后在停止令牌上,我们有两个重要的基本操作:我们可以询问是否已经请求停止,并且我们可以使用它在该令牌和一个可调用对象之间形成关联,以便当请求停止时,可调用对象被调用,并且它具体化的任何操作被急切地执行。
// stdexecution 停止令牌示例
auto token = stdexec::get_stop_token(stdexec::get_env(rcvr));
if (token.stop_requested()) {
// 处理停止
}
auto callback = token.register_callback([]{
// 停止被请求时执行的操作
});
Asio,另一方面,通过取消槽和信号实现这一点。发出取消信号,相应的取消槽然后调用它持有的任何处理器(如果有的话)。
// Asio 取消槽示例
asio::cancellation_signal signal;
asio::cancellation_slot slot = signal.slot();
slot.assign([]{
// 取消被请求时执行的操作
});
signal.emit(asio::cancellation_type::all); // 发出取消信号
有了这些,我们可以看看如何将这两个生态系统粘合在一起。我们当然从接收者开始,因为那是我们从stdexecution生态系统中的消费者那里接收的工具。从那个接收者,我们当然可以生成一个环境。从那个环境,我们可以获取一个停止令牌。使用那个停止令牌,我们可以与一个称为停止回调的可调用对象形成关联,以便当请求停止时,我们急切地采取某些操作。当然,我们可以采取的操作可以很容易地向Asio发出取消信号——一个Asio知道的取消信号,因为我们将它的槽与一个完成处理器关联。
让我们看看如何在实际代码中具体化这一点。让我们看看我们必须添加到操作状态中来实现这一点。
我们有一个on_stop_request可调用结构。然后,当然,我们有一个取消信号。此后,我们有一个可选的停止回调,一个停止回调类型,通过访问我们的接收者、询问其环境、询问该环境以获取其停止令牌,然后询问该停止令牌需要形成哪种回调类型以将其与我们的on_stop_request类型关联而合成。
这当然引出了一个问题:那个on_stop_request类型是什么样子的?一个简单的可调用对象,它获取递归互斥锁,然后向Asio发出取消信号。
这引出了一个问题:为什么我们需要这个递归互斥锁?答案是Asio说的。与stdexecution不同,Asio说,如果你以相对于被取消操作的所有其他元素线程不安全的方式发出信号,你会有未定义行为。幸运的是,在演讲的早期,我们将这个递归互斥锁贯穿了我们生态系统的每个部分。因此,当我们持有它时,我们知道我们没有与任何其他异步部分竞争。我们可以有信心地发出信号。
但这不足以将这两个生态系统粘合在一起,因为我们依赖于一个可选的数据成员的存在。因此,我们回到启动操作的地方,并用填充该可选的逻辑来丰富它,通过获取适当的停止令牌并与on_stop_request的实例关联来构造我们的停止回调。
注意,在这里我们利用frame的便利成员,它让我们询问操作是否已经完成。如果frame为假,this可能在其生命周期之外,因此去查看其成员是不合适的。否则,我们有必要这样做。
但这引出了一个明显的问题:为什么我要推迟这个构造?为什么我不简单地在我的构造函数中设置它?为什么我需要让optional参与进来?再次,这是因为Asio说的。Asio说,如果你在从其发起函数返回之前向操作发出取消信号,该取消信号可能会错过并且没有效果。我们为确保可以停止操作以继续我们的生活所做的所有工作都将白费,因为那个取消信号消失在虚空中,操作因此不受阻碍地运行。
当然,这仍然不够,因为我们还没有将那个取消槽发布给Asio。因此,我们在本次演讲中第二次编写一个关联器,将我们的完成处理器类型与一个取消槽关联。我们的get成员函数简单地转到操作状态,转到其取消信号并返回槽,从而将其集成到Asio中。
但Asio并不是唯一有神秘要求的生态系统。stdexecution也有一些自己的神秘要求。之前,我们详细讨论了操作状态的异步生命周期——我们只能假设它在异步操作运行期间、直到我们发出完成信号的精确时刻之前在其生命周期内。标准还要求我们具体化这一点关于停止令牌:我们只允许从调用start开始到立即之前通过接收者发送完成信号的时间段内使用与接收者关联的停止令牌。
因此,我们也想具体化这个要求。我们不仅有义务推迟停止回调的构造,还必须在操作完成之前急切地销毁它。
因此,我们在最后一刻遍历所有完成操作的地方。我们查看完成发生的地方,并简单地通过重置optional来丰富它。当然,我们在代码中的多个地方完成操作。因此,我们去查看代码中的另一个地方,并类似地丰富它。
最终整合与示例
现在,我们实际上已经完成了整合这两个生态系统的所有代码。
但这让我们处于什么位置?我以展示一些示例代码开始这次演讲。然后我们深入探讨了stdexecution和Asio的异步模型。这一切对于我们整个演讲所针对的代码——我们实际想要使其工作的代码、从stdexecution原生调用所有这些Asio操作的能力——意味着什么?
当我们盯着这段代码时,我们注意到一个问题。我们启动所有异步操作的方式违背了我们从演讲中知道的事情。我们知道启动异步操作需要一个完成令牌,但我们在这里没有说明一个。
但当我们回到设置代码时,我们可以通过了解Asio的其他事情来丰富我们的理解:当Asio选择完成令牌时,你当然可以显式提供一个。但如果你不显式提供,并且你正在执行操作的IO对象关联的执行器有一个默认完成令牌,它将被简单地使用。
因此,我们可以获取这些IO对象,并将它们的执行器重新绑定到一个以我们的完成令牌作为默认值的执行器。现在,我们不需要说明它,我们可以简单地仅使用其必要参数调用异步操作,并默认使用stdexecution。
或者,如果我们注意这些操作完成的方式,我们就能做到。我们注意到这些操作都以Asio操作惯用的方式完成:发送一个前导错误码指示操作是否成功。我们的集成盲目地将这些发送到值通道,而我们瞄准的代码——我们想要能够编写的代码——完全忽略它们,表现得好像它们不存在。它甚至无法编译,正如我们目前所见。

这是因为我们编写的完成令牌是Asio和stdexecution之间集成的底层。它只做最低限度的工作,允许你从`stdexecution
017:精通 static、inline、const 和 constexpr


在本教程中,我们将深入探讨 C++ 中四个核心关键字:static、inline、const 和 constexpr。我们将逐一分析它们在不同上下文中的含义、用法、常见误区以及它们之间的相互作用。目标是帮助你清晰理解这些看似简单却功能强大的工具,从而编写出更高效、更安全的代码。
1:static 关键字详解
static 是一个多用途关键字,其含义根据使用上下文而变化。本节我们将逐一解析它在不同场景下的作用。
自由函数中的 static
当 static 应用于一个自由函数(非成员函数)时,它使该函数具有内部链接,成为翻译单元局部函数。
static void foo() { /* ... */ }
这意味着该函数仅在定义它的翻译单元(通常是一个 .cpp 文件及其包含的所有头文件)内可见。其他翻译单元无法链接或调用此函数。
重要提示:如果你在头文件中将函数声明为 static,并且该头文件被多个 .cpp 文件包含,那么每个翻译单元都会获得该函数的一个独立副本。这不仅会增加二进制文件大小,还可能阻碍链接时优化。因此,对于自由函数,通常建议仅在 .cpp 源文件中使用 static,以实现真正的“仅本文件使用”的目的。
函数内的静态局部变量
在函数内部,static 用于声明具有静态存储期的局部变量。
void func() {
static int v = 0; // 静态局部变量
v++;
}
这种变量的特点是:
- 初始化时机:在程序执行流程第一次经过其声明时进行初始化。
- 线程安全:自 C++11 起,这种初始化是线程安全的。
- 生命周期:在程序整个运行期间都存在,而非函数调用期间。
- 后续调用:函数后续调用时,该变量会保持上一次调用结束时的值。
需要注意的是,由于静态局部变量的构造和析构顺序难以精确控制,一些编码规范会禁止使用此特性。
类中的 static
在类中,static 可以用于数据成员和成员函数。
静态数据成员:
class MyClass {
static int s_data; // 声明
};
int MyClass::s_data = 42; // 定义(C++17前必需)
- 它不属于类的任何一个对象,而是属于类本身,所有对象共享同一份数据。
- 它不占用类对象的内存空间。
- 在 C++17 之前,必须在类外进行定义(分配存储空间)。
静态成员函数:
class MyClass {
static void static_func();
};
- 静态成员函数没有隐含的
this指针,因此不能直接访问类的非静态成员。 - 它可以通过类名或对象来调用。
- 由于没有
this指针,调用时少了一个参数传递,可能带来微小的性能优势。
上一节我们介绍了 static 关键字的各种用法,本节我们来看看另一个容易混淆的关键字:inline。
2:inline 关键字详解
inline 关键字的历史含义是“建议编译器进行内联展开”,但现代编译器的优化策略已经非常智能,这个提示作用已大大减弱。如今,inline 更关键的作用是管理单一定义规则。
自由函数中的 inline
对于自由函数,inline 的主要作用是允许其在多个翻译单元中被定义,而链接器会选择其中一个定义使用。
// header.h
inline int add(int a, int b) {
return a + b;
}
核心作用:抑制 ODR(单一定义规则)冲突。这使得将函数定义放在头文件中成为可能,便于跨多个源文件共享。
重要警告:所有翻译单元中看到的 inline 函数定义必须完全一致。任何差异(例如通过预处理器宏导致的不同)都会引发未定义行为,因为链接器可能随机选择其中一个版本。
inline 变量(C++17)
C++17 引入了 inline 变量,这对于头文件中的变量定义非常有用。
// header.h
inline int global_counter = 0; // 可以在头文件中定义并初始化


这解决了在 C++17 之前,头文件中的全局变量或静态类成员变量可能导致的多重定义链接错误问题。
类成员函数的隐式 inline
在类定义内部实现的成员函数会被隐式地标记为 inline。
class Widget {
void doSomething() { /* ... */ } // 隐式 inline
void doAnotherThing();
};
void Widget::doAnotherThing() { /* ... */ } // 非隐式 inline,除非显式添加
这样设计是为了方便将类定义在头文件中。需要注意的是,在 C++20 的模块中,这一隐式规则可能会发生变化。
了解了 static 和 inline 之后,我们进入一个更复杂、用法更多的领域:const 关键字。
3:const 关键字详解
const 是一个 CV 限定符(C 代表 const,V 代表 volatile),用于指定“只读”属性。理解其“顶层”和“底层”的区分至关重要。
顶层 const 与底层 const
- 顶层
const:表示对象本身是常量。这是一个“可选”的、由开发者添加的约束,用于表达“初始化后不应修改”的意图。 - 底层
const:表示指针或引用所指向的数据是常量。
int a = 1;
const int b = 2; // 顶层 const: b 本身是常量
int const c = 3; // 同上,等价写法
int *p1 = &a; // 指向非常量的指针
const int *p2 = &a; // 底层 const: 指向常量数据的指针(数据不可变)
int *const p3 = &a; // 顶层 const: 指针本身是常量(指向不可变)
const int *const p4 = &a; // 既是底层 const(指向常量)也是顶层 const(指针常量)
关键点:在函数重载解析时,编译器会忽略顶层 const。因此,void func(int) 和 void func(const int) 被视为相同的签名,会导致重定义错误。
const 在函数参数中的应用
const 常用于函数参数,以保护数据不被意外修改,并作为 API 契约的一部分。
void printString(const std::string& str); // 承诺不修改 str
对于指针参数,需要仔细区分是保护指针本身还是指针指向的数据。
const 保证了运行时的常量性,而 C++11 引入的 constexpr 则将常量性提升到了编译时。
4:constexpr 与 consteval 关键字详解
constexpr 用于声明可以在编译时求值的对象或函数,是实现编译期计算的核心工具。
constexpr 变量
constexpr 变量必须是编译期常量。
constexpr int max_size = 100; // 编译期常量
constexpr double pi = 3.14159;


constexpr 函数
constexpr 函数具有“双重性”:既可以在编译时调用(如果参数是常量表达式),也可以在运行时调用。
constexpr int factorial(int n) {
return (n <= 1) ? 1 : n * factorial(n - 1);
}
int main() {
constexpr int fact5 = factorial(5); // 编译时计算
int x = 10;
int runtime_fact = factorial(x); // 运行时计算
}
特性:
constexpr函数在 C++14 后允许包含循环、局部变量等更复杂的逻辑。- 它们被隐式地标记为
inline。 - 编译期求值发生在 C++ 的“常量求值上下文”中,可以看作一个 C++ 虚拟机。
constexpr 与 static 结合(C++23)


从 C++23 开始,可以在 constexpr 函数内声明 static 的局部 constexpr 变量。
constexpr int power_of_two(int n) {
static constexpr int lookup[] = {1, 2, 4, 8, 16}; // C++23 合法
return lookup[n];
}
这使得编译期查找表等模式更加高效,因为该静态数组只会在所有编译期求值中初始化一次。
consteval 立即函数(C++20)
consteval 用于声明立即函数,它必须在编译时求值,否则会产生编译错误。
consteval int square(int n) {
return n * n;
}
// int x = square(10); // 正确,编译时计算
// int y = 10;
// int z = square(y); // 错误!y 不是常量表达式,无法在编译时调用
consteval 函数没有运行时版本,主要用于编译时元编程和作为 C++26 静态反射等特性的基础。
constinit 变量(C++20)
constinit 确保具有静态存储期的变量(如全局变量、静态局部变量)在编译时进行初始化,但变量本身不是常量(即可修改)。
constinit int global_var = 42; // 保证编译时初始化
它主要用来解决静态初始化顺序问题,即不同翻译单元中全局对象的初始化顺序未定义所导致的问题。constinit 通过强制编译时初始化来规避这个风险。


编译时分支:if constexpr 与 std::is_constant_evaluated


if constexpr:
用于编译时条件判断。条件必须是编译期常量表达式,不符合条件的分支在编译时就会被丢弃。
template<typename T>
auto get_value(T t) {
if constexpr (std::is_pointer_v<T>) {
return *t; // 仅当 T 是指针类型时生成此代码
} else {
return t;
}
}
std::is_constant_evaluated() 与 if consteval:
用于在函数内部判断当前是否处于常量求值上下文(即编译时)。
// C++20 使用 std::is_constant_evaluated
constexpr double magic(double d) {
if (std::is_constant_evaluated()) {
// 编译时路径:使用更精确但更慢的算法
return compute_precise(d);
} else {
// 运行时路径:使用快速近似算法
return compute_fast(d);
}
}
// C++23 引入更清晰的 if consteval
constexpr double magic_cpp23(double d) {
if consteval {
return compute_precise(d);
} else {
return compute_fast(d);
}
}
注意:if constexpr (std::is_constant_evaluated()) 是错误用法,因为 if constexpr 的条件在编译时求值,此时 std::is_constant_evaluated() 总是返回 true。

总结
本节课我们一起深入学习了 C++ 中四个关键关键字:
static:控制链接性(翻译单元局部)、存储期(函数内静态变量)以及类成员的归属(属于类而非对象)。inline:现代主要作用是抑制 ODR 规则,允许在头文件中定义函数和变量(C++17),类内成员函数隐式内联。const:CV 限定符,用于指定只读性。需分清顶层const(对象本身常量)和底层const(指向常量数据),顶层const在重载时被忽略。constexpr/consteval/constinit:编译时计算的核心。constexpr:变量和函数可在编译时求值(函数有双重性)。consteval:函数必须在编译时求值(立即函数)。constinit:保证变量在编译时初始化,解决静态初始化顺序问题。- 配合
if constexpr和if consteval实现编译时分支。


理解这些关键字的精确语义和适用场景,能帮助你写出意图更清晰、更高效、更安全的 C++ 代码。记住,const 是关于意图和契约,constexpr 是关于能力和时机。
018:为实时音频软件用现代C++重写遗留GUI库(续)🎨





在本节课中,我们将学习如何利用C++20的概念(Concepts)特性,为一个实时音频软件的遗留GUI库重构其属性系统。我们将重点关注如何将一个命令式、运行时类型检查的系统,转变为声明式、编译时类型安全的现代C++设计。


项目背景与动机 🎯


上一节我们介绍了遗留GUI库Canvas在音频插件环境中面临的挑战。本节中,我们来看看其属性系统的具体问题。
我们的目标是构建一个属性系统,能够为对象合成成员变量的存储空间以及对应的getter和setter方法。对象需要知道属性何时被更改,以便执行诸如重绘之类的操作。同时,系统需要支持将属性序列化到XML或其他格式。

其他语言如Objective-C和Swift提供了类似的声明式语法。在C++中,我们希望通过现代特性实现类似的目标。

以下是遗留属性系统的主要问题:
- 命令式声明:属性需要在运行时通过一系列函数调用创建,这可能导致条件性声明等奇怪用法。
- 缺乏类型安全:属性通过字符串名称和
boost::any(或std::any)存储,获取和设置时需要进行运行时类型检查和转换,容易出错。 - 重复代码:需要为每个属性手动定义字符串常量名称,并在创建时重复指定类型和名称,增加了出错风险。
- 有限的类型支持:系统仅支持少数基本类型(如字符串、颜色、矩形),复杂类型需要用户自行进行字符串解析。
- 混乱的通知机制:任何代码都可以监听任何对象的属性变化信号,导致“远距离幽灵动作”,代码难以理解和维护。

解决方案:声明式属性系统 ✨
为了解决上述问题,我们决定利用C++20的概念特性,构建一个全新的声明式属性系统。核心思想是将所有属性信息集中在一个地方声明,利用编译时检查确保类型安全,并自动合成所需的接口。
核心概念:属性描述与属性类型
首先,我们需要定义两个核心概念:PropertyDescription(属性描述)和PropertyTypeDescription(属性类型描述)。
一个PropertyDescription描述了一个具体的属性,它需要包含:
property_type:该属性的类型描述符。name:属性的名称(用于序列化)。default_value:属性的默认值。


一个PropertyTypeDescription描述了属性值的底层C++类型如何与序列化格式相互转换,它需要包含:
type:底层的C++类型(例如std::string,int)。serialized:一个函数,将type转换为std::string。deserialized:一个函数,将std::string转换回std::optional<type>。
以下是使用概念定义这些约束的示例代码:
// 属性类型描述的概念
template<typename T>
concept PropertyTypeDescription = requires {
typename T::type;
{ T::serialized(std::declval<typename T::type>()) } -> std::convertible_to<std::string>;
{ T::deserialized(std::string{}) } -> std::same_as<std::optional<typename T::type>>;
};
// 属性描述的概念
template<typename T>
concept PropertyDescription = requires {
requires PropertyTypeDescription<typename T::property_type>;
requires HasName<T>; // 检查是否有可获取的名称
requires HasDefaultValue<T>; // 检查是否有可获取的默认值
};

其中,HasName和HasDefaultValue是辅助概念,用于检查T是否通过成员变量或成员函数提供了名称和默认值。这体现了概念的强大之处:我们可以优雅地处理多种实现方式。
// 检查是否有名为`name`的成员变量
template<typename T>
concept HasNameValue = requires { { T::name } -> std::convertible_to<std::string>; };

// 检查是否有名为`name()`的成员函数
template<typename T>
concept HasNameFunction = requires { { T::name() } -> std::convertible_to<std::string>; };

// 综合概念:有名称(无论是变量还是函数)
template<typename T>
concept HasName = HasNameValue<T> || HasNameFunction<T>;
// 根据实现方式获取名称的通用函数
template <HasNameValue T>
std::string get_name() { return T::name; }


template <HasNameFunction T>
std::string get_name() { return T::name(); }

定义属性与属性列表 🧱

利用上述概念,我们可以以一种清晰、声明式的方式定义按钮的属性:


// 1. 定义属性类型描述符
struct StringPropertyType {
using type = std::string;
static std::string serialized(const type& v) { return v; }
static std::optional<type> deserialized(const std::string& s) { return s; }
static constexpr auto name = "string";
};
struct ColorPropertyType { ... }; // 类似定义
// 2. 定义具体的属性描述
namespace button_properties {
struct Text {
using property_type = StringPropertyType;
static constexpr auto name = "text";
static constexpr auto default_value = std::string{"Click Me"};
};
struct BackgroundColor {
using property_type = ColorPropertyType;
static constexpr auto name = "background-color";
static constexpr auto default_value = Color{0.9f, 0.9f, 0.9f};
};
// ... 更多属性
}
// 3. 将属性收集到一个属性列表中
using ButtonPropertyList = PropertyList<
button_properties::Text,
button_properties::BackgroundColor
>;
合成属性功能:HasProperties 模板 🛠️
接下来,我们创建一个HasProperties模板(使用CRTP模式),它接收一个属性列表,并自动为派生类合成类型安全的getter和setter。

template <typename Derived, PropertyList PropertyListT>
class HasProperties {
public:
// 类型安全的 Getter
template <PropertyDescription P>
requires (is_type_in_list_v<P, PropertyListT>) // 确保P在属性列表中
auto get_property() const {
// 内部调用遗留的property_holder_.get_property<P::property_type::type>(get_name<P>())
// 因为我们知道属性一定存在,所以可以断言成功。
auto opt = property_holder_.get_property<typename P::property_type::type>(get_name<P>());
assert(opt.has_value());
return *opt;
}
// 类型安全的 Setter
template <PropertyDescription P>
requires (is_type_in_list_v<P, PropertyListT>)
void set_property(typename P::property_type::type value) {
// 内部调用遗留的property_holder_.set_property(get_name<P>(), value)
// 因为我们知道属性一定存在且类型匹配,所以可以断言成功。
bool success = property_holder_.set_property(get_name<P>(), value);
assert(success);
// 触发变更通知(后续介绍)
}
protected:
HasProperties() {
// 在构造时,为属性列表中的每一个属性创建底层存储
create_properties<PropertyListT>();
}
private:
LegacyPropertyHolder property_holder_;
template <PropertyDescription... Properties>
void create_properties() {
// 使用折叠表达式为每个属性调用创建函数
(create_property<Properties>(), ...);
}
template <PropertyDescription P>
void create_property() {
// 调用遗留接口创建属性,使用get_name<P>()和get_default_value<P>()
property_holder_.create_property<typename P::property_type::type>(
get_name<P>(), get_default_value<P>()
);
// 连接变更信号
connect_property<P>();
}
};
is_type_in_list_v是一个编译时检查,利用折叠表达式实现:
template <typename T, typename... List>
inline constexpr bool is_type_in_list_v = (std::is_same_v<T, List> || ...);
使用属性系统 🎮
现在,按钮类可以非常简单地从HasProperties继承,并获得所有声明好的属性功能:
class Button : public HasProperties<Button, ButtonPropertyList> {
public:
// 可选的:定义属性变更回调
void did_set(button_properties::Text) { /* 文本改变时重绘 */ }
void did_set(button_properties::BackgroundColor) { /* 背景色改变时重绘 */ }
void draw() {
// 使用合成好的getter获取属性值
auto bg = get_property<button_properties::BackgroundColor>();
auto text = get_property<button_properties::Text>();
// ... 使用这些值进行绘制
}
};
// 在代码中使用
Button btn;
btn.set_property<button_properties::Text>("Hello World");
btn.set_property<button_properties::BackgroundColor>(Color{1.0f, 0.0f, 0.0f});

高级特性:条件性变更通知 🔔
我们还可以扩展系统,让属性声明自己是否影响布局或显示,并让类声明自己是否需要相应的通知。这可以通过if constexpr和更多的概念检查来实现。
例如,我们可以定义LayoutProperty和DisplayProperty标签。属性描述符可以继承它们:
struct Text : UIProperty { ... }; // UIProperty 同时继承自LayoutProperty和DisplayProperty
struct BackgroundColor : DisplayProperty { ... };
在HasProperties内部连接信号时:

template <PropertyDescription P>
void connect_property() {
property_holder_.get_signal(get_name<P>()).connect([this](const auto&) {
// 1. 如果派生类有对应的did_set,则调用它
if constexpr (has_did_set_for_v<Derived, P>) {
static_cast<Derived*>(this)->did_set(P{});
}
// 2. 如果属性影响布局,且派生类有set_needs_layout方法,则调用它
if constexpr (std::is_base_of_v<LayoutProperty, P> && has_set_needs_layout_v<Derived>) {
static_cast<Derived*>(this)->set_needs_layout();
}
// 3. 如果属性影响显示,且派生类有set_needs_display方法,则调用它
if constexpr (std::is_base_of_v<DisplayProperty, P> && has_set_needs_display_v<Derived>) {
static_cast<Derived*>(this)->set_needs_display();
}
});
}


has_did_set_for_v等特性同样可以利用概念来检测类中是否存在特定签名的成员函数。

总结 📝



本节课中我们一起学习了如何运用C++20的概念特性,对一个实时音频软件GUI库的属性系统进行现代化重构。我们主要完成了以下工作:
- 分析了遗留系统的问题:包括命令式声明、运行时类型错误、代码重复、有限的类型支持和混乱的通知机制。
- 引入了声明式设计:通过定义
PropertyDescription和PropertyTypeDescription概念,将属性信息集中声明,实现了关注点分离。 - 实现了编译时类型安全:利用概念约束模板,确保只能对声明的属性进行获取和设置操作,将运行时错误转化为编译时错误。
- 自动合成了接口:通过
HasProperties模板和CRTP模式,自动为类生成类型安全的getter、setter,并在构造时初始化属性。 - 设计了灵活的通知机制:结合概念检查与
if constexpr,实现了条件性的属性变更回调,代码既安全又高效。


通过这次重构,我们得到了一个更简洁、更安全、更易于维护和扩展的属性系统,同时为未来集成C++的反射和元类特性打下了良好的基础。这个案例充分展示了现代C++特性,特别是概念,在构建高质量、可维护的库基础设施方面的强大能力。
019:并行范围算法 - C++并行化的演进




概述
在本节课中,我们将学习C++并行范围算法的演进、设计动机、核心概念以及它们与C++17标准并行算法的区别。我们将探讨如何利用新的API编写更简洁、更高效的并行代码,并了解其背后的设计决策和实现考量。
P19.1:演讲者介绍与主题引入
大家好。感谢大家选择我的演讲。
我叫Ruslan。我是C++标准委员会成员。我在英特尔工作,主要负责oneAPI DPL(Data Parallel Library)的开发,这是英特尔对并行算法的实现。我对其他线程引擎也有贡献,例如oneTBB。我也曾是SYCL语言的贡献者。我在C++标准中贡献了诸如std::execution等特性,当然,也包括我们今天要讨论的并行算法。最近,我担任了C++委员会中SG1(并发与并行)小组的联合主席。
但今天演讲的主题不是我,而是并行范围算法。
让我们深入探讨。
P19.2:什么是并行算法与并行范围算法
上一节我们介绍了演讲背景,本节中我们来看看核心概念。
并行算法是大家可能已经熟悉的算法,例如std::for_each或std::transform,它们将执行策略作为第一个模板参数。这些算法位于std命名空间,接受迭代器。
以下是一个串行版本的find_if示例:
std::find_if(begin, end, predicate);
并行版本完全相同,只需将执行策略作为第一个参数:
std::find_if(std::execution::par, begin, end, predicate);
这里的std::execution::par意味着“尽最大努力并行执行”。
并行范围算法是提案P3179的内容。这些算法位于ranges命名空间,其第一个模板参数受execution_policy概念的约束。它们有两种重载:一种接受范围,另一种接受迭代器和哨兵。
以下是示例。串行版本:
std::ranges::find_if(input, predicate);
并行版本:
std::ranges::find_if(std::execution::par, input, predicate);
我们只是添加了执行策略par作为第一个参数。
这就是基本概念。但当然,还有更多内容。这是关于演进的讨论。
一个重要的免责声明是:我们只并行化循环本身。例如对于find_if,我们不会将执行策略传递给任何范围或视图。本提案仅涉及算法,不修改标准中的任何视图或范围。
P19.3:设计动机与问题示例
上一节我们定义了并行范围算法,本节中我们来看看为什么需要它们。
动机是结合范围API的强大表达能力和并行执行的高性能,以提高代码的表达力、生产力和易用性。
让我们看一个例子。假设我们有三个连续的算法调用:transform、reverse和find_if。
// 三个独立的算法调用
auto it1 = std::transform(std::execution::par, ...);
auto it2 = std::reverse(std::execution::par, it1, ...);
auto result = std::find_if(std::execution::par, it2, ..., predicate);
这段代码存在几个问题:
- 调用开销:每个算法调用都会引入开销,必须等待前一个调用完成才能开始下一个。
- 不必要的计算:对于
find_if,一旦找到目标元素,理论上可以停止搜索其后的元素,但在此链式调用中无法实现。 - 代码冗长:需要多次调用,并手动管理中间迭代器。
我们可以尝试使用视图和管道来编写更“惰性”的代码:
// 使用视图管道
auto pipeline = input | std::views::transform(lambda) | std::views::reverse;
auto result = std::find_if(std::execution::par, pipeline.begin(), pipeline.end(), predicate);
这减少了算法调用次数,并允许find_if在找到元素后提前停止。但它仍然冗长,并且如果迭代器和哨兵类型不同(虽然目前不常见),它可能无法工作。
另一个问题是,当传递一个右值范围(如管道结果)时,如果该范围不是“借用范围”,返回的迭代器可能是std::ranges::dangling,无法使用。为了解决这个问题,我们必须将管道结果存储在左值中:
auto pipeline = input | std::views::transform(lambda) | std::views::reverse; // 存储为左值
auto result = std::ranges::find_if(std::execution::par, pipeline, predicate); // 现在可以工作
并行范围算法提案旨在解决所有这些问题。
P19.4:并行范围算法与C++17并行算法的区别
上一节我们看到了现有方式的痛点,本节中我们来详细看看新提案带来的具体变化。
以下是并行范围算法与C++17并行算法及串行范围算法的主要区别。其中一些区别仅适用于一方,一些适用于双方。
以下是核心区别列表:
- 执行策略作为第一参数:这是最明显的变化。
- 要求随机访问迭代器/范围:C++17并行算法只要求前向迭代器,但新提案要求随机访问迭代器和范围。这对于高效并行化是必需的。
- 要求大小已知的范围:新提案要求范围是大小已知的。这对于并行任务划分和内存安全至关重要。
- 输出参数为范围:对于接受输出参数的算法(如
copy),新提案的重载接受一个输出范围,而不是一个输出迭代器。对于迭代器-哨兵重载,则增加了一个输出哨兵参数。
让我们看看函数签名的变化。
串行范围算法签名示例(迭代器版):
template <typename I, typename S, typename O>
O copy(I first, S last, O result);
并行范围算法签名示例(迭代器-哨兵版):
template <typename EP, typename I, typename S, typename O, typename SO>
requires execution_policy<EP> && sized_random_access_iterator<I> && sized_sentinel_for<S, I> && ...
auto copy(EP&& exec, I first, S last, O result_first, SO result_last);
- 增加了执行策略
EP。 - 输入迭代器
I需满足sized_random_access_iterator概念。 - 增加了输出哨兵
SO。
串行范围算法签名示例(范围版):
template <typename R, typename O>
O copy(R&& r, O result);
并行范围算法签名示例(范围版):
template <typename EP, typename IR, typename OR>
requires execution_policy<EP> && sized_random_access_range<IR> && sized_random_access_range<OR>
auto copy(EP&& exec, IR&& input_r, OR&& output_r);
- 增加了执行策略
EP。 - 输入范围
IR和输出范围OR都需满足sized_random_access_range概念。 - 输出参数直接是一个范围
OR,而不是迭代器。
这些概念(如execution_policy, sized_random_access_range)在标准中是“仅用于说明的”,旨在简化标准文本的措辞。
P19.5:关键设计决策的探讨
上一节我们列出了技术差异,本节中我们深入探讨这些设计背后的原因。
为何要求随机访问和大小已知?
- 现实情况:尽管C++17标准允许前向迭代器,但主流实现(如Intel oneDPL, NVIDIA Thrust, GNU libstdc++)在实践中都要求或倾向于随机访问迭代器以实现高效并行。只有MSVC STL支持前向迭代器的并行算法。
- 性能必需:随机访问允许常数时间的偏移计算,这对于将工作均匀分割给多个线程或核心至关重要。
- 内存安全与提前规划:知道范围的大小可以防止内存越界,并允许运行时系统提前规划并行执行策略。
- 牺牲的用例:这确实排除了像
std::views::filter这样的非随机访问、大小未知的视图。未来可能会探索某种“中间地带”,但C++26不会改变此决定。
为何输出参数是范围?
这是提案中争论最激烈的部分。
支持方论据:
- 易用性:用户可以直接传递容器或范围,无需调用
begin()。 - 内存安全:算法可以检测输出范围是否足够大,并安全地停止在范围末尾。
- 更好的性能:算法可以利用输出范围的整体信息进行优化。
- 错误检测:返回值可以指示在输入和输出中的停止位置,用户可据此处理。
- 已有先例:标准中已有算法如
uninitialized_copy和partial_sort_copy接受输出范围,存在不一致性。
反对方论据:
- 切换成本:从串行范围算法切换到并行版本不再是简单地添加一个执行策略,还需要更改输出参数的类型。
- 不一致性:与现有的、接受输出迭代器的串行范围算法不一致。
最终,委员会被说服,认为内存安全、易用性和性能提升的好处超过了切换成本。关于不一致性,可以通过未来为串行算法也添加接受输出范围的重载来解决(提案P3490正在探索这一点)。
输出范围语义很明确:算法将处理直到任一范围(输入或输出)被耗尽。这与std::ranges::copy等算法处理多个输入序列的逻辑是一致的。
P19.6:算法实现示例:并行 copy_if
上一节讨论了设计哲学,本节我们通过一个具体算法copy_if的实现示例,来看看并行化的复杂性。
我们以实现一个并行的copy_if为例。其基本思路分为几个阶段:
- 计算掩码:并行遍历输入范围,对每个元素应用谓词,生成一个由1(需复制)和0(不需复制)组成的序列。
std::vector<size_t> mask(input_size); std::transform(std::execution::par, input_range, mask.begin(), [&](const auto& x) -> size_t { return predicate(x) ? 1 : 0; }); - 前缀和(扫描):对掩码序列进行独占前缀和计算。结果序列中的每个值,对于需要复制的元素,表示其在输出中的目标索引。
std::vector<size_t> indices(input_size); std::exclusive_scan(std::execution::par, mask.begin(), mask.end(), indices.begin(), 0); - 分散写入:再次并行遍历输入、掩码和索引。对于掩码为1的元素,根据其对应的索引值,将输入元素写入输出范围的相应位置。
auto zipped_view = std::views::zip(input_range, mask, indices); std::for_each(std::execution::par, zipped_view.begin(), zipped_view.end(), [&](auto&& tuple) { auto&& [in_val, msk, idx] = tuple; if (msk == 1) { output_range[idx] = in_val; } }); - 处理输出空间不足:这是关键。如果输出范围小于需要复制的元素数量,算法必须在写满输出范围时停止。我们需要确定:
- 输出结果:自然是
output_range.end()。 - 输入结果:应该返回输入中第一个因输出空间不足而未被复制的、满足谓词的元素的位置。为了实现这一点,我们可以利用前缀和序列是有序的这一特性,使用
upper_bound在索引序列中查找输出范围大小对应的位置,从而定位到正确的输入停止点。
- 输出结果:自然是
这个简化的示例说明了并行化一个看似简单的算法所需的复杂步骤,特别是处理边界条件和确保正确性。
P19.7:异构计算与性能
上一节我们看了CPU上的实现逻辑,本节中我们看看如何在GPU等异构设备上使用并行范围算法,并了解其性能收益。
以Intel oneDPL和SYCL为例,我们可以轻松地将管道化的范围算法卸载到GPU上执行。
以下是示例代码框架:
sycl::queue q; // 关联到GPU设备
usm_allocator<int, sycl::usm::alloc::shared> allocator(q);
std::vector<int, decltype(allocator)> data(allocator);
// ... 填充数据 ...
auto pipeline = data | std::views::transform(foo) | std::views::reverse;
auto policy = oneapi::dpl::execution::device_policy(q);
auto result = std::ranges::find_if(policy, pipeline, predicate);
代码与CPU版本几乎相同,只是使用了特定的设备策略和共享内存分配器。
性能数据:
在一个包含1600万个元素的管道(transform -> reverse -> find_if)测试中:
- 对比基线(三个串行算法调用):
- 三个并行算法调用(迭代器版):约12倍加速。
- 三个并行范围算法调用:约12倍加速。
- 单个管道化并行范围算法调用:约30倍加速。
- 在GPU上,加速比更为显著,管道化调用可达130倍加速。
即使不考虑并行,单个管道化的串行调用也比三个独立的串行调用更快(约1.3-1.8倍),因为它避免了中间结果的多次内存遍历和写入。
P19.8:范围、状态与未来工作
上一节展示了强大的性能,本节中我们总结提案的范围、当前状态和未来方向。
标准化范围:
- 本提案旨在为C++17中所有的并行算法提供对应的范围版本。
- 不包括
<numeric>头文件中的算法(如reduce,transform_reduce),因为目前ranges命名空间下还没有对应的串行版本。这需要未来单独提案。 - 不包括
ranges中一些本质上是串行或特殊的算法,如fold(有特定顺序)和generate_random(与随机数生成器紧密耦合)。
当前状态:
该提案(P3179及其补充提案)已被C++26采纳。你可以通过Intel oneDPL等库提前体验。
未来可能的工作方向:
- 数值范围算法:为
<numeric>添加范围版本,包括并行版本。 - 基于发送器/接收器的异步并行算法:与C++的异步模型集成。
- 为串行范围算法添加输出范围重载:解决之前提到的不一致性问题。
总结

在本节课中,我们一起学习了C++26的并行范围算法。
- 我们了解了其设计动机:结合范围表达力与并行性能。
- 我们掌握了其核心形式:在
std::ranges算法中,添加std::execution策略作为第一参数。 - 我们探讨了关键设计:要求随机访问、大小已知的范围,并将输出参数改为范围以提升安全性与易用性。
- 我们通过
copy_if的例子窥见了并行实现的复杂性。 - 我们看到了其在异构计算(如GPU)上的应用和显著的性能收益。
- 最后,我们了解了该特性的当前标准化状态和未来演进方向。


并行范围算法是C++向更简洁、更安全、更高性能并行编程迈进的重要一步。
020:行之有效的简单策略


在本教程中,我们将学习如何以简单、高效且可维护的方式使用 CMake。我们将遵循一系列核心原则,避免常见的陷阱,并通过一个真实项目的例子来演示这些最佳实践。本节课的目标是帮助你摆脱 CMake 的困扰,专注于你的核心开发工作。
概述
CMake 是一个功能强大但有时令人困惑的构建系统生成器。它拥有大量的文档和 API,有时不同的 API 功能相似,让人难以选择。本教程旨在澄清概念,提供一套行之有效的简单策略,帮助你以健康的方式开始使用 CMake。
我们将从推荐资源开始,然后学习如何运行 CMake 程序本身,接着讨论一些编写 CMake 项目时应遵循的原则,最后通过一个实际的开源项目示例来演示所有这些概念。
资源推荐
以下是当你遇到问题或想深入学习时,可以查阅的一些优质资源:
- CMake Discourse:由 Kitware 主办,是讨论 CMake 的绝佳社区。
- 《Professional CMake》:作者 Craig Scott,从实践和专家视角深入探讨 CMake 的书籍。
- Cpplang Slack 的 CMake 频道:由 C++ Alliance 赞助,有许多 CMake 核心开发者和用户在此交流。
- CMake 上游 Issue 追踪:如果你对文档或功能有疑问,可以在这里提交问题。
请注意,Stack Overflow 和某些 AI 模型关于 CMake 的建议可能已经过时,它们可能推荐的是 10 年前的旧方法。虽然 CMake 向后兼容性很好,但使用近 3-5 年的新特性通常会带来更好的体验。
运行 CMake 的基本流程
运行 CMake 构建大致可以分为五个基本阶段。理解这些步骤是掌握 CMake 的关键。
1. 环境准备
此阶段是为后续步骤设置环境。这包括获取要构建的仓库源代码、确定依赖项的版本、安装必要的代码生成工具等。任何需要从外部获取资源或设置特定版本的操作都属于此阶段。
强烈建议你了解包管理工具,如 Conan 和 vcpkg。它们有助于管理依赖的兼容性。单仓(monorepo)或使用固定的容器镜像也是确保环境一致性的好方法。
2. 配置
这是运行 CMake 命令的核心步骤。一个基本的命令形式如下:
cmake -B build -S .
-B 标志用于指定构建目录的位置。这种方式是无状态操作,比先切换目录再运行 cmake . 更清晰,尤其适合脚本和自动化流程。
在此步骤中,你通常会添加配置选项,即通过 -D 标志设置的 CMake 变量。例如:
cmake -B build -S . -DBUILD_TESTING=ON
你还需要选择一个生成器。CMake 是一个“元构建系统”,它会生成其他构建系统(如 Makefile 或 Ninja)的文件。默认是 Unix Makefiles,但在 2025 年,你应该根据工作流选择更好的生成器。如果没有特别偏好,Ninja 是一个快速且功能丰富的选择。对于 IDE 开发,可以选择对应的生成器以获得更好的集成体验。
你可以通过环境变量 CMAKE_GENERATOR 来设置默认生成器。
另一个重要概念是构建类型。默认的构建类型是空,意味着使用编译器默认设置。常见的构建类型有 Debug 和 Release。你可以通过环境变量 CMAKE_BUILD_TYPE 来设置。如果使用 Ninja Multi-Config 生成器,则需要使用 CMAKE_CONFIGURATION_TYPES 变量。
工具链文件 是描述编译器、链接器等工具集的 CMake 脚本。它是集中设置 ABI、编译标志(如消毒器选项)的理想位置。可以通过环境变量 CMAKE_TOOLCHAIN_FILE 指定。
你也可以在此步骤传递特定的编译器标志,例如:
cmake -B build -S . -DCMAKE_CXX_FLAGS="-fdiagnostics-format=json"
3. 构建
配置完成后,使用以下命令进行构建:
cmake --build build
此命令会自动识别你使用的生成器(如 make 或 ninja)并执行构建,无需你手动切换目录或记住生成器类型。
--build 默认构建名为 all 的目标(并非所有目标)。你可以通过 --target 参数构建特定目标,这在大型项目中非常有用:
cmake --build build --target my_benchmark
如果需要查看详细的构建命令以进行调试,可以添加 --verbose 标志。也可以通过设置 CMake 变量 CMAKE_VERBOSE_MAKEFILES=ON 来启用。
4. 测试
构建完成后,可以运行测试。推荐使用以下命令:
ctest --test-dir build
这会在不切换目录的情况下,运行在构建目录中注册的所有测试。Ctest 支持丰富的功能,如输出控制、测试过滤、排序和循环运行。IDE 通常也集成此命令。
请注意,Ctest 默认不会自动构建测试。你需要先构建包含测试的可执行文件,然后才能运行测试。
5. 安装
项目经过构建和测试后,可以安装以供他人使用。推荐使用以下命令:
cmake --install build
这个命令会执行安装过程,使你的库或可执行文件可以在生产环境中使用。
最常用的安装路径变量是 CMAKE_INSTALL_PREFIX,它指定安装的基础目录(如 /usr/local)。然而,对于打包等场景,你更可能需要的是 DESTDIR 环境变量,它允许你指定一个临时的安装根目录。DESTDIR 通常通过环境变量设置。
核心原则
上一节我们介绍了运行 CMake 的完整流程。本节中,我们来看看编写 CMakeLists.txt 文件时应遵循的核心原则。
核心思想是:尽可能少地编写 CMake 代码。
CMake 作为一种语言,很容易诱使你实现自己的构建逻辑。但这会限制项目的可维护性、可移植性以及与生态系统的集成能力。因此,我们应避免以下行为:
- 避免复杂的逻辑:如自定义函数、循环、复杂的条件判断和变量操作。
- 避免项目间互操作逻辑:不要为特定的包管理器或特殊日期编写特殊逻辑。
- 避免设置应由用户决定的变量:例如
CMAKE_BUILD_TYPE和CMAKE_CXX_STANDARD。这些应该由调用者通过命令行、工具链文件或环境变量来设置。
相反,你的 CMakeLists.txt 应该主要做一件事:描述你的项目。
描述项目包含哪些目标(库、可执行文件),源代码文件如何布局,依赖关系是什么。这些是事实性描述,不依赖于运行环境、执行者或执行时间。
日常的 CMake 工作应该类似于:“我需要添加一个库,它在这个目录,有这些源文件,依赖那个包。” 这是一种声明式的列表,而非算法。
此外,确保你的项目不会破坏用户的工作流:
- 允许用户设置
CMAKE_BUILD_TYPE。 - 允许用户设置
CMAKE_CXX_STANDARD(这是一个需要特别注意的敏感设置,我们稍后讨论)。 - 始终定义一个
test目标,这样用户和 IDE 都知道如何运行测试。对于可选的测试(如基准测试),可以使用选项来控制。
关于 C++ 标准的特别说明
CMAKE_CXX_STANDARD 是一个需要特别谨慎对待的设置。在大型项目或与多个外部库协作时,随意设置它可能导致 ABI 不兼容问题,引发难以诊断的错误甚至数据损坏。
最简单的做法是:不要在项目中设置它。将这个决定权交给构建者,让他们通过工具链文件或 CMAKE_CXX_FLAGS 来设置。
如果你担心用户忘记设置,可以在 CMake 中通过检查来“快速失败”,给出清晰的错误提示。但不要替他们做决定。
实战示例:Beam 项目
理论部分已经介绍完毕,现在让我们通过一个真实项目——Beam 项目的 exemplar 仓库——来具体看看这些原则是如何应用的。该项目是一个 C++ 库模板,旨在展示普通、良好、实用的 CMake 实践。
命名约定
首先,一致的命名非常重要。Beam 项目采用 beam:: 作为命名空间前缀,后跟项目名(如 exemplar)。这种完全限定的命名方式可以避免与项目中其他名为 core、database 等常见名称的目标发生冲突,确保构建的是正确的目标。
顶层 CMakeLists.txt
以下是项目顶层 CMakeLists.txt 的关键部分:
cmake_minimum_required(VERSION 3.25)
project(beam_exemplar)
cmake_minimum_required:设置最低 CMake 版本和策略兼容性。建议将其设置为较新的版本(如 3.25),以启用新特性、性能优化并获得弃用警告。project:声明项目名称。可以在此设置版本、描述等元数据。
enable_testing()
这行代码启用了 CTest 支持,确保了 test 目标的存在。
add_subdirectory(lib)
if(BEAM_EXEMPLAR_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
这里声明了项目的子目录结构。使用一个选项 BEAM_EXEMPLAR_BUILD_EXAMPLES 来控制是否构建示例,这是一个条件语句的合理用法。
库定义
在 lib/CMakeLists.txt 中,我们定义库:
add_library(beam_exemplar)
add_library(beam::exemplar ALIAS beam_exemplar)
首先创建一个库目标 beam_exemplar,然后为其创建一个别名目标 beam::exemplar。始终使用双冒号 :: 格式的别名,因为 CMake 会对此进行额外检查,如果依赖未定义,会立即报错,而不是等到链接时才失败。
target_sources(beam_exemplar
PRIVATE
identity.cpp
)
使用 target_sources 为库添加私有源文件。PRIVATE 表示这些源文件仅用于构建该库本身,其依赖者无需知晓。
target_sources(beam_exemplar
PUBLIC
FILE_SET HEADERS
BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/../include
FILES
../include/beam/exemplar/identity.hpp
)
这是添加头文件的新方式(CMake 3.23+)。它明确声明了公共头文件及其安装路径。这种方式比旧的 target_include_directories 更清晰,能帮助 CMake 更好地理解项目结构,支持头文件验证、正确安装以及与 IDE 的集成。
安装配置
安装配置通常较复杂。Beam 项目将其封装在一个 CMake 模块中:
find_package(beam_install_library REQUIRED)
beam_install_library(beam_exemplar)
beam_install_library 模块处理了生成和安装配置包所需的所有样板代码。在自己的组织中,你也可以创建这样的模块来统一项目结构和安装约定。
简化的安装核心 API 如下:
install(TARGETS beam_exemplar
EXPORT beam_exemplar-targets
)
install(FILES_SET HEADERS ...)

install(EXPORT beam_exemplar-targets
FILE beam_exemplar-config.cmake
NAMESPACE beam::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/beam_exemplar
)
这些命令将目标、头文件以及供 find_package 使用的配置文件安装到指定位置。EXPORT 和 NAMESPACE 确保了依赖者能够以 beam::exemplar 的形式正确引用你的库。
测试定义
在 tests/CMakeLists.txt 中,我们定义测试:
find_package(GTest REQUIRED)
add_executable(beam_exemplar_test_identity test_identity.cpp)
target_link_libraries(beam_exemplar_test_identity
PRIVATE
GTest::gtest
beam::exemplar
)
首先找到 GTest 包,然后创建一个测试可执行文件,并将其链接到 GTest 和我们的主库。
add_test(NAME beam_exemplar_test_identity
COMMAND beam_exemplar_test_identity
)
最后,使用 add_test 命令将可执行文件注册为一个 CTest 测试。这样,ctest 命令就能识别并运行它。对于 GTest 或 Catch2 等框架,有更专门的 API 可以自动注册所有测试用例。
总结
本节课中我们一起学习了如何以简单、有效且可持续的方式使用 CMake。我们回顾了运行 CMake 的五个基本步骤:环境准备、配置、构建、测试和安装。我们强调了编写声明式 CMakeLists.txt 的核心原则,即避免复杂逻辑,专注于描述项目结构,并将配置决策权交给用户。最后,我们通过 Beam 项目的实例,看到了这些原则如何转化为清晰、简洁的 CMake 代码。

记住,优化目标是编写简单、声明式的 CMakeLists.txt。将复杂性和上下文交给更合适的工具,如 CMake 预设文件、CI 系统或包管理器。CMake 本身已经是一个功能丰富的构建系统,我们应充分利用它,而不是在它之上再造一个。
021:C++26新反射功能简介



在本节课中,我们将学习C++26标准中引入的静态反射功能。我们将从基本概念入手,逐步了解反射的语法、核心组件及其应用,并探讨它对未来C++编程的影响。
概述
反射是程序在编译时检查和修改自身结构的能力。C++26的反射提案是近年来最重要的语言特性之一,它允许开发者在编译时查询类型、函数、变量等程序实体的信息,并基于这些信息生成代码。本节课将带你全面了解这一新功能。
反射简史
上一节我们介绍了反射的基本概念,本节中我们来看看反射功能在C++标准中的发展历程。
反射的想法在C++社区中已存在多年。早期的尝试主要基于模板元编程,例如Matus Chochlik的Mirror反射库。然而,模板元编程方式存在编译时开销大的问题。
因此,社区转向寻求语言层面的语法支持,并提出了“元对象”的概念,即用类来表示程序中的各个部分(如类型、变量)。经过多次讨论和提案迭代,最终确定了采用单一类型 std::meta::info 来代表所有反射实体的“单态设计”方案,这简化了编译器的实现并统一了算法接口。
2023年,提案P2996被投票纳入C++26标准草案,标志着静态反射正式成为C++的一部分。
C++26反射核心组件
了解了历史背景后,本节我们将深入探讨C++26反射的具体语法和核心组件。
提升操作符与拼接操作符
反射的核心是将程序实体从“程序领域”提升到“反射领域”进行观察和操作。
-
提升操作符 (
^): 该操作符(最初提议为单^,因冲突改为双^^,昵称“独角兽操作符”)用于将一个程序实体提升到反射领域,得到一个std::meta::info对象。// 将类型`int`提升到反射领域 std::meta::info refl_int = ^int; // 将变量`a`提升到反射领域,可能需要`typename`消除歧义 std::meta::info refl_var = ^a; -
拼接操作符 (
[: ... :]): 该操作符用于将反射领域的信息(std::meta::info对象)提取回程序领域,成为可用的C++表达式或类型。// 假设`refl_type`是一个代表某个类型的`std::meta::info` using MyType = typename [: refl_type :]; // 提取为类型 // 假设`refl_expr`是一个代表某个表达式的`std::meta::info` auto value = [: refl_expr :]; // 提取为表达式/值
std::meta::info 类型
std::meta::info 是反射系统的核心类型,它代表了一个被反射的程序实体。可以将其理解为编译器抽象语法树(AST)节点的一个不透明句柄。该类型的对象必须是常量表达式,因为反射发生在编译时。
关于 std::meta::info 的一个重要特性是它的“有状态性”。它是对程序当前状态的引用,而非快照。请看以下示例:
struct R; // 前向声明,不完整类型
std::meta::info res1 = ^R; // 获取R的反射信息
bool print1 = std::meta::is_complete_type(res1); // false,此时R不完整
struct R { int x; }; // 完成R的定义
bool print2 = std::meta::is_complete_type(res1); // true!res1引用的状态更新了
res1 的状态会随着 R 变得完整而更新。要求编译器为 res1 保存快照会带来实现负担,因此标准采用了这种设计。
元函数
std::meta 命名空间提供了一系列元函数,用于查询 std::meta::info 对象所代表的实体信息。这些函数类似于现有的类型特征(type traits),但功能更强大且专为反射设计。
以下是部分元函数分类:
- 查询函数: 返回关于实体的信息(布尔值、大小、字符串视图等)。
is_functionname_of/identifier_of(注意:参数名称的规范问题)bases_ofsize_of
- 操作函数: 对反射实体进行操作,返回新的
std::meta::info。substitute: 类似于模板实例化,但延迟进行。extract: 从反射值中提取出具体类型的值,类似于转换。
关于参数名称的说明: 函数参数在声明和定义处可能有不同名称。C++26反射规定,identifier_of 等函数要求同一参数在所有声明/定义处名称一致,否则程序非法。这确保了反射结果的确定性,但可能影响现有代码。
高级功能与示例
掌握了核心组件后,我们来看一些更高级的反射功能和实际应用片段。
substitute 与 extract
substitute 和 extract 是功能强大的元函数。
-
substitute: 用于在反射领域进行“模板实例化”。它接受一个代表模板的info、一个代表模板参数的info和一个代表模板实参的info,返回一个代表实例化后实体的info。真正的实例化发生在通过拼接操作符将该info用回程序时。template<int N> struct Array { int data[N]; }; std::meta::info refl_Array = ^Array; std::meta::info refl_int = ^int; std::meta::info refl_val = std::meta::reflect_value<3>(); // 在反射领域进行“Array<3>”的替换,得到代表该类型的info std::meta::info substituted = std::meta::substitute(refl_Array, refl_int, refl_val); // 此时并未真正实例化Array<3> // 通过拼接操作符将反射信息提取为实际类型,触发实例化 using MyArray = typename [: substituted :]; // 此处实例化 Array<3> -
extract: 用于从代表某个值的std::meta::info对象中,提取出指定类型的常量表达式值。如果类型不匹配,则编译失败。std::meta::info refl_int = ^int; std::meta::info refl_val = std::meta::reflect_value<42>(); constexpr int x = std::meta::extract<int>(refl_val); // 成功,x = 42 std::meta::info refl_str = ^std::string; // constexpr auto y = std::meta::extract<int>(refl_str); // 编译失败,无法从string提取int
编译时上下文异常
反射代码现在可以抛出一种特殊的异常——上下文异常。这种异常只能在常量求值(编译时)上下文中被捕获和处理,无法传播到运行时。这为在编译时提供更友好的错误信息提供了可能。
consteval void check_reflection(std::meta::info obj) {
if (!std::meta::is_class(obj)) {
throw std::meta::compile_error("Expected a class type for reflection");
}
// ... 其他检查
}
如果异常未被捕获,将导致编译失败。
反射的影响与未来展望
我们已经学习了反射的核心机制,现在让我们拓宽视野,看看它可能带来的影响和未来的发展方向。
反射的引入会改变代码的集成方式。由于反射库(通常是头文件库)需要看到类型的完整定义才能工作,这可能会破坏现有的编译和链接模型。例如,一个使用反射的日志库可能要求用户代码中的函数参数名称在所有声明处保持一致,否则无法编译。
关于反射的必要性,即使在AI代码生成兴起的今天,反射仍然不可替代。AI生成的代码可能不一致,而基于反射的代码生成是确定且可重复的,能无缝集成到构建系统中。
与Rust等语言的对比: Rust使用过程宏来实现类似功能,它操作的是词法标记流。相比之下,C++的反射直接操作更高级的AST节点,可能为用户提供更直观的API来观察(而不仅仅是生成)代码结构。
对于库作者和标准库的未来: 反射极大地扩展了库的潜力。预计未来会有大量基于反射的实用库出现,它们可能最终被提议纳入C++标准库。作为开发者,你可以利用反射构建更强大、更灵活的工具。
总结
本节课中我们一起学习了C++26的静态反射功能。我们从反射的历史讲起,深入探讨了其核心语法:提升操作符(^)和拼接操作符([:...:]),以及核心类型 std::meta::info。我们了解了用于查询和操作反射信息的各种元函数,并通过示例展示了 substitute 和 extract 等高级用法。最后,我们讨论了反射对开发流程的影响,并展望了基于反射的库生态系统的未来。



反射是C++迈向更高层次元编程的关键一步,它使编译器成为编程伙伴,开启了编译时代码检查和生成的新纪元。
022:关于成为程序员CEO的真相 🚀

概述
在本节课中,我们将跟随Greg Law的亲身经历,学习从一名程序员转变为一家科技公司CEO的完整历程。我们将探讨创业的动机、寻找合伙人、融资、销售、领导力以及如何应对过程中的各种挑战。课程的核心在于揭示技术创业背后的真实故事,而非浪漫化的想象。

章节 1:创业的起点与动机
参加大会、结识朋友、建立联系并与他们进行深入的面对面交流,这种感觉非常棒。获取联系方式,并沉浸在一群真正聪明、充满动力的开发者环境中,这非常棒。
然而,创业之路并非始于浪漫的想象。本课程并非仅仅针对有抱负的创业者或已经是企业家的人。如果你正走向公司的领导或管理岗位,这里有很多内容对你有用。或者,即使你无意进入管理层,作为一名独立贡献者,领导力仍然是重要组成部分。领导者和老板不是一回事。希望你的老板也是一位领导者,但许多领导者并非老板。领导者就是有人愿意追随的人,就这么简单。
我期待这次演讲,因为我可以谈论我最喜欢的主题——我自己。我们将以一种自传式的叙事弧线来进行,然后提炼出我一路走来学到的所有智慧和教训。其中大部分是我通过艰难方式学到的,希望你们不必如此。许多教训是我从他人那里听来的,这里也有一两件事是我做对了的。所以,这是一份我从如何创业、融资到如何进行销售和营销中学到的经验清单。
章节 2:关于销售与营销的认知转变
我曾经认为,工程是困难的部分,是科学。而销售是那种轻松、模糊的事情。编程,你只需要让计算机做你想做的事。销售,你必须让人们做你想做的事。而营销,你必须让人们做你想做的事,甚至无法亲自见到他们。我认为这更难。
在开始这段旅程时,我预计到自己不了解这些事情,需要一路学习。让我惊讶的是,其中有多少内容关乎心理学,尤其是我自己和他人的心理。
章节 3:创业的“为什么”与团队的重要性
在讨论如何创业之前,先问问为什么。我从不认为这是在鼓励人们创业。事实上,你应该做相反的事。我觉得我应该劝阻你们所有人创业。如果你在听了这些之后仍然决定去做,那也许适合你。但如果你需要鼓励,那可能就不适合。你必须完全投入。
我喜欢这句古老的非洲谚语:“如果你想走得快,就独自前行;如果你想走得远,就结伴同行。”你可以和很多人一起做更多事。创业之初,我有个想法,想把它推向市场,最好的方式就是创建一家公司,这样我就可以追求这个愿景,让编程变得更好。
但说实话,真正的动机是什么?是自我吗?我认为所有企业家都有点不对劲。我们小时候得到的拥抱不够还是什么。我们只是需要这种认可。不是为了钱。钱是创业的一个非常糟糕的理由,尤其是在科技领域,因为你的预期回报……不如加入一家大公司,努力工作,晋升,赚取丰厚的薪水。创业的预期财务回报中位数要么是零,要么是负数。
除非你真的想成为亿万富翁,那你可以为此创业。但对我来说,是我的自我,它太大也太脆弱,无法仅用金钱来修复。我需要建立这个东西。
然而,“结伴同行”这句话真的很重要。独自一人能做的只有那么多。所以,无论你是创业、在其他公司工作,还是参与开源项目,你都想与人合作,你可以做得更多。这就是领导力和心理学发挥作用的地方。
章节 4:灵感的来源与寻找联合创始人
一切始于一个早晨在淋浴时的想法。我看到了“时间旅行调试”这个概念,但当时不叫这个名字。我一直在思考如何为真实世界的复杂代码大规模实现它。淋浴时我突然想到:如果你想向后回溯,可以从一个快照开始向前播放。如果我播放了1000条指令,想退回一条,我可以从头开始播放999条。只要确保重播时执行路径相同,那么在道德上就是一样的。这就是突破点。
淋浴是我的创意源泉。实际上,我博士课题的想法是在马桶上产生的。现在我们不再在马桶上有想法了,因为我们有手机。但你在淋浴时不能用手机,所以我们仍然有想法,或者可能在骑自行车或跑步时。我认为,感到无聊并让创造力流动是很重要的。
我的第一个想法是:我需要一位联合创始人。我从未想过独自创业,总觉得必须有一位联合创始人。
章节 5:如何选择联合创始人
我找到了一位以前共事过的超级聪明、有创业精神、见识广博的人。我向他推销了这个想法。但他不理解。他说:“你为什么要做这个?栈跟踪(backtrace)不是已经提供了你需要的东西吗?”回想起来,那是一个危险信号。如果像他这样超级聪明的人都不理解,那说明解释这件事并说明其重要性真的很难。这一点我后来才发现。
然后我意识到,几年前共事过的一位朋友Jules(Julian Smith)非常适合。我给他发了邮件说明想法。我仍然记得他的回信:“我很乐意和你一起创业。”这让我非常高兴。遗憾的是,我们最终没有一起走到最后,但他在公司头十多年里绝对是不可或缺的,没有他我们绝对走不到那一步。
我认为,如果你要有联合创始人,找到合适的人至关重要。我认识一个人,他和别人联合创业,而那个人为了启动业务窃取了数据。你真的想和这样的人一起创业吗?因为创业会很艰难,你们会有分歧,必须有基本的信任。否则,太难了,你永远无法起步。
章节 6:启动公司的实际步骤
那么,如何实际启动公司呢?显然,第一件事是打开vi编辑器,开始往空文件里写代码。但显然不止这些。人们总说会被繁文缛节(red tape)缠住。
这里有个秘密:其实没有那么多“繁文缛节”。我必须承认,这是基于英国的情况,其他地方可能不同。但通常,人们以此为借口。大公司可能有很多繁文缛节,但小公司没有。当我们有了第一位员工(在棚屋里)时,我们才去了解雇佣法之类的东西。在英国,如果你雇佣某人,在工作场所,你必须进行风险评估;如果员工超过六人,你必须把它写下来。仅此而已。这比我想象的要容易得多。
所以,我认为你应该寻求帮助。可能是心理帮助。除此之外,孵化器很棒。我们创业时孵化器还不流行,但现在像Y Combinator这样的孵化器非常棒。选择时要谨慎,看看他们的过往记录,是否孵化过一些成功的公司。
你也可以寻求建议。你会惊讶地发现,你可以联系到那些成功的重要人物,他们几乎都会回复。有些人会说抱歉太忙,但你会惊讶于有多少人愿意和你见面。你可以说“我请你吃午饭聊聊”,然后他们通常会买单。政府通常也想提供帮助,但意愿和实施是两回事。有各种拨款、竞赛,可能有用。
要小心那些“挂名”的人。我的经验法则是:他们以前做过吗?如果他们成功过,或者即使生意不成功但经营过,或者他们帮助和指导过很多其他人,那很好。如果他们只是从大公司提前退休,可能毫无帮助,甚至是负帮助。
章节 7:辞职与面对现实
下一步,辞掉日常工作。我去了老板那里说:“我有个想法,想创业,我要辞职了。”他们说:“有什么办法能说服你留下吗?”我说,这与钱无关。但我告诉他们:“你们可以试着说服我这件事不会成功。”因为我非常尊重那家公司的创始人。我说:“如果我能从技术上实现,业务方面会很容易,每个人都会想买。”他们说:“好吧,我们来谈谈技术部分。”他们找不到漏洞,认为技术上可行。但他们说:“我们能聊聊业务部分吗?可能比你想的难。”我说:“不,不,这次真的不一样。”他们说:“每个人都认为自己的不一样。”我知道每个人都认为自己的不一样,但这次真的、真的不一样。
结果,它并没有什么不同。
章节 8:早期融资与“龙穴”经历
当时英国有个电视节目叫《龙穴》(Dragons‘ Den),后来在美国变成了《鲨鱼坦克》(Shark Tank)。我没有上那个节目,因为我们的东西太技术化了。但当地政府组织了一个类似的活动,有一位原版“龙”担任评委。大约200家公司参加,经过多轮淘汰,我们进入了最后10名,在大概1500名观众面前进行了路演。
我们没有赢。我不知道为什么我这么做,但那位“龙”在给我安慰奖时在我耳边低声说:“我觉得你的东西很棒,别担心,我们会帮你搞定融资的。”第二天,我打电话给他说:“我受够融资了。过去六个月我一直在做路演和见投资人。”在我的辩护下,人们并没有直接拒绝,只是没人说“是”,这太耗时了。但我犯的一个大错误是误判了我们的处境。任何战略都关乎诊断。如果你对现状诊断错误,就会制定错误的战略。我以为我们快成功了,即将发布产品,获得一些客户和收入,然后再考虑融资。他说:“好吧,如果你想去试试,就去吧。”
章节 9:产品发布与残酷的现实
于是我们发布了。我们很快推出了1.0版本。有句话说,如果你不为自己的1.0版本感到尴尬,那说明你发布得太晚了。我们做对了这一点。1.0版本相当简陋。你可能看得出来,网站是我自己做的,这完全是浪费时间,根本不应该做。我甚至还画了一个装CD的盒子图案,虽然我们公司没那么老,但我觉得如果卖东西,应该有个实物概念。这完全是浪费时间。最后,我花了几百英镑请一个做设计师的朋友帮忙设计(给了友情价),网站还是我自己实现的,因为那样太贵了。但设计好多了。
我们做了“龙穴”活动,然后发布了产品。我太兴奋了,想告诉全世界,每个人都会对这个神奇的东西感到惊讶。我该怎么做?我不太懂。我谷歌了“如何写新闻稿”,写了一份,发了出去。我甚至发邮件给网站托管商,警告他们我们要发布了,可能会有大量访问,网站可能会崩溃。我不知道我指望他们做什么。他们说:“好的,谢谢提醒。”结果,什么都没发生,没人在乎。因为每天都有无数产品发布,如果连Chris(那位聪明的朋友)都不理解,那么每天收到上千份新闻稿的科技记者可能也不会感兴趣。
章节 10:漫长的坚持与“海洛因”般的执念
从开始到我最终能辞掉日常工作,用了七年。在那段时间的后期,有一部电影叫《阳光小美女》。里面有个爸爸完全活在自己的幻想中,总在打电话说“我再打一个电话就好了,亲爱的”。你看就知道这个人疯了,永远不会成功。看那部电影时我非常不舒服。我和妻子一起看的,我们什么都没说,但我心里想:“天哪。”
如果我知道要花七年,我永远不会开始。只是利用晚上和周末,同时还有日常工作。巧合的是,我的第一个孩子也在2005年出生,和我们创建公司是同一年。所以很艰难。
但我就是无法放弃。实际上,有几个满意的客户告诉我,这个产品就像海洛因,不止一个人用了完全相同的词。有人说如果因为某些原因不能用,会有戒断症状。所以产品让人上瘾,但公司对我来说也像海洛因。我总想“我再发最后一封邮件”,“我再打最后一个电话”,然后就算了。但总会有那么一点点进展,让我坚持下去。
所以,是的,我们最终在七年后达到了那个阶段。我并非完全妄想。有足够的信号,有东西回来,足以让我继续。但我不想对人们说“无论如何,坚持下去,只要足够努力,你就会成功”。因为大多数企业都失败了。知道何时收手非常重要。对我们来说,只是有足够的信号。
我记得发布后一两年,我们设置了PayPal集成,让人们可以用信用卡购买下载。因为我想会有很多购买,无法手动处理。结果有一天早上,我检查邮件,收到PayPal的邮件:有一笔销售,295美元到账。那是一个重要的里程碑,我高兴得跳了起来。第二天早上,我又检查邮件,又有一笔销售!我想:“开始了,事情开始运转了。”在那之后的一年多里,我每天早上都检查邮件,再没有一笔销售进来。
但我们确实有进展。最终,我们与一个叫Total View的调试器达成了OEM类型的合作,到年底能带来大约3-4万美元的年收入。不够生活,而且我有孩子要养,不能只靠吃面条住在父母的地下室。我得付房贷,把食物摆上桌。但对Jules、Richard和我来说,这是一笔零花钱,可以去度个假,买点好东西。但这并不是我真正想要的——辞掉日常工作。
最终,我们尝试融资,但毫无进展。最烦人的是他们不说“不”。请投资人如果打算拒绝,请快点说“不”。所以我们停止了。后来投资真的发生时,其实是偶然。这是我做对的少数几件事之一。
章节 11:偶然的融资与股权结构
我有一位非正式的顾问,叫Robert Brady。他经营过一家成功的公司。他下班后偶尔会来,我们一起喝杯啤酒,我提出问题,他给出建议。后来他卖掉了自己的公司。我说:“你不如正式加入我们,我给你5%的股份。”因为我想让他既有兴趣(利益),也有利害关系。回想起来,可能有点慷慨,但需要有意义。他很乐意参与,但他说:“我会付一点钱买股份,而不是白拿。”所以,我们的第一轮融资其实是偶然发生的。
第二轮融资也是偶然。Robert带我们去了一个剑桥天使投资人的聚会。我本来只是去寻求建议。有句老话说:如果你想要钱,就去寻求建议;如果你想要建议,就去要钱。第二天,一位非常活跃的投资人发邮件说:“我觉得你们可能有点东西,我们应该谈谈。”于是我们真的融到了资,足够我辞掉工作了。
章节 12:融资与股权稀释的基本原理
你创建公司,拥有一堆股份,还有一些钱。假设公司有10股,你在银行存了1000美元。这时公司估值就是1000美元,因为除了钱什么都没有。顺便说一句,一个想法本身价值为零。只有实现这个想法才有价值。
你做一些事情,写代码,获得一些客户或其他证明点。你决定现在有真正的价值了,要从投资人那里融资。通过谈判,决定公司估值现在是500万美元。在《鲨鱼坦克》上,人们会说“我要多少钱,换多少百分比”。实际上,你从估值开始谈,然后推导出百分比。我有10股,总值500万美元。假设我要融资100万美元。那么每股价值50万美元。我只需要凭空创造2股新股卖给他们。这感觉有点像欺诈,但公司最初创建时1000美元资本也是这么来的。
当然,有代价:你被稀释了。每股占公司的百分比变小了。但没关系。然后我们可能会创建一个期权池。我认为期权非常重要。期权就是字面意思:以特定价格购买股份的选择权。期权通常在融资轮中获得批准,但尚未创建或授予。批准后,你可以向员工和其他人授予期权。员工在未来某个时间点可以行权,那时他们付钱,你创建股份。所以期权是名义上的。
期权池可能占一定比例。然后你重复这个过程,进行种子轮或A轮融资,然后是B轮融资,估值更高,分配更多股份。
但正如我所说,大多数企业不会成功。我们需要考虑不那么乐观的情况。我认识很多同时期创业的人,只有一两个成功了。大多数失败了。大多数失败得很彻底,直接归零。但也有一些进行了“甩卖”,公司还有些价值,比如专利,或者可以说服别人收购团队。公司以远低于预期的价格出售,但总比没有好。
假设在这个例子中,我们最终以200万美元出售公司。我们融资了100万,卖200万,也许不算世界末日。期权可能都作废了,因为行权价可能低于售价。所以投资者拥有1/6,创始人拥有5/6。从投资者的角度看,这并不好。他们投入100万美元,你搞砸了,你带着钱走了,他们却亏损了。投资者通常更关注下行风险,他们见过很多失败案例。而企业家往往只想着上行空间,因为必须这样想才能坚持下去。
但实际情况很少是这样。通常,投资者的股份是特殊的“优先股”,这意味着在公司出售时,他们先获得偿付。投资者先拿回他们的100万美元,然后创始人分剩下的。如果是不参与分配的优先股(non-participating preferred)是这样。还有参与分配的优先股(participating preferred),甚至双重参与优先股(double-dip preferred),情况更复杂。
一切始于条款清单(term sheet),你们在那里商定条款:优先结构等。创建公司时,你会有公司章程(articles of association),这是公司的基本规则。融资时会被替换。还有股东协议(shareholders agreement),规定优先结构和其他细节。其中一项总是有争议的是“兑现条款”(vesting provisions)。因为投资者担心:如果我投了钱,第二天创始人辞职了怎么办?如果创始人拥有80%的股份却不在公司,业务就难以为继。通常的规则是,如果提前离开,你会失去所有股份。这总是很有争议。但如果他们解雇你呢?这很复杂。
无论是投资者还是客户,在设定条款时往往会变得非常对立。但你需要从对方的角度思考。从投资者的角度看,他们通常是被坑的一方。而且创始人都很疯狂,会做各种事。我听说过剑桥一个著名的案例,有人把钱花在了直升机上。我也见过公司破产时,抽屉里有一堆未付的账单。情况可能变得非常混乱,他们需要保护自己免受下行风险,否则很快就会没钱。
最重要的是你的投资人是谁。因为实际上,条款是什么并不重要。如果你的投资人想坑你,他们就会坑你。这甚至不像婚姻,因为婚姻可以离婚。引入投资人后,除了完全放弃,没有脱身的方法。所以,投资人是谁更重要。要去获取推荐信。我很幸运,我的投资人很好,但我当时没想过这点。今天我一定会去做:找到他们之前投资过的公司,尤其是那些没成功的,了解他们是如何行事的。因为当一切顺利时,每个人都和颜悦色,合作愉快。只有当危机来临时,才能看到人们的真面目。
要融足够的钱。我总是搞错这一点。事情总是比你想象的花更长时间。所以要融足够的钱,让你不必在一年后又去融资。因为最终你会被稀释得更多。
有不同类型的投资者。天使投资人通常投自己的钱。风险投资人通常投别人的钱。你也可以自力更生(bootstrap)。事实上,在我那批创业者中,最成功的两家公司完全是自筹资金、自力更生的。如果你能做到,那很棒。但我仍然会像引入Robert Brady那样引入一个人。我认为你需要内部有人,即使他们不是财务投资者。
设定估值有点荒谬,因为早期公司如何估值?后期有了收入,你可以用增长率等公式计算出一个估值范围。早期,实际上更像《龙穴》或《鲨鱼坦克》那样,只是一个百分比。通常每轮稀释10%到30%左右。你融得越多,稀释比例可能相同。我掉进了这个陷阱。在B轮融资时,我想:我可以融500万,稀释20%;也可以融1000万,稀释20%。那我选1000万吧。问题是,这隐含了一个你需要快速达到的估值,如果达不到,事情就开始出错了。
章节 13:销售的本质与挑战
现在进入销售部分。谁是你的联合创始人,谁是你的投资者,谁是你的客户——这些现在是最重要的。我们当时完全不知道自己在做什么。我们设置了PayPal那些东西,但没想过是要做B2C模式还是企业销售。我们甚至没问过这个问题。
当我们进行第一轮融资时,介绍认识了一位叫Ken的人,他非常懂行,进来帮助我们。他是一位顾问,提供了很多价值,后来成了我困难时期的重要教练。他教我如何做企业销售。这很难。关键是要思考客户关心什么。没有“思科”这样的实体,只有一群人,他们都有自己的希望和梦想。所以要弄清楚他们需要什么,想要什么。在复杂的企业环境中销售是协作性的,不是那种油滑的、二手车销售员式的刻板印象,试图操纵和欺骗别人。在这个行业,你要卖给的通常是非常聪明的人。必须是双赢。
你需要在内部有“教练”,最好不止一个。他们可以告诉你内部发生了什么,因为从外部看,这些大企业非常不透明。你永远无法确切知道情况。你需要一个可以一起喝啤酒或咖啡的人,他们可能会告诉你一些本不该说的事,因为他们看到了你产品的价值,希望它成功。至少一开始,他们可能不是为你着想,但他们希望你的产品进入公司,因为他们知道这对公司有好处。所以你需要内部的教练来引导你应对复杂性和动态。
如果只是某人待办清单上的第10项,那永远不会发生。就像广告一样,如果你是第5名,总会有新的事情插到前面,你永远进不了前三名。
采购部门没有权力。别听他们的。
小心“创新实验室”。很多大公司、银行都有创新实验室,他们会整天和你聊天,因为这是他们的工作。但他们永远不会、永远不会从你这里购买任何东西,也对你毫无用处。
企业销售流程很复杂,可以看看“米勒·海曼销售法”(Miller Heiman)。销售只是第一部分。对于像我们这样的企业销售,采用“先立足后扩张”(land and expand)的模式很常见。销售之后,我们需要确保成功。
我们做的第一笔企业交易是与Cadence(芯片设计公司)。我们取得了巨大成功,解决了一个非常高价值的问题。他们觉得这太棒了,终于有人注意到这东西有多好了。我们当时卖的是浮动许可证(floating licenses)。他们说:“我们先买100个浮动许可证起步。”他们有几千名工程师。我们唯一的担心是这不够,如何管理需求?但总之开始了。我们击掌庆祝,办公室里一片欢腾。
六个月后(这些都是年度合同),我与签支票的负责人进行回顾。他走进房间,脸色苍白。他说:“我刚刚查看了使用统计数据。我们买了100个并发许可证,过去六个月的峰值使用量是——3。”我当时想:他要被解雇了,我的公司要完蛋了。
那时我意识到了“客户成功”(customer success)的重要性。但更重要的是促成行为改变。无论你的产品多好,除非你推动,否则他们不会都开始使用它。我们挽救了局面。Cadence至今仍然是我们重要且满意的客户。我其实不太清楚我们是怎么扭转的,但我们做到了。
章节 14:成长、失误与低谷
展示一些照片。这是Nick在棚屋里。我们一度有五个人在我花园的棚屋里。我妻子非常宽容,棚屋挺大,但还是有点挤。最终我们搬进了第一个办公室。这是我,看起来多年轻啊,那不过是大约十年前。这就是你将经历的变化。

签下那个租约时我非常紧张,每年大约1万英镑,虽然不多,但感觉很吓人。小小的办公室。然后我们继续融资,事情进展顺利,搬进了更好的办公室。我们没有整层楼,只是一部分。我们在地毯上印了Undo的logo,一切都很棒。那是2017年的我们,照片里很多人现在还在公司。Nick是我们的产品路线总监,Tommy负责所有测试基础设施,Mark是我们的CTO,Jules是我的联合创始人(他是唯一一个现在已不在公司的人),Dan在Tom的团队,Tracy的工作可能最艰难——照顾我,Marco负责AI项目,Finn是AI项目的技术负责人(Finn和Nick都曾在棚屋工作过),Liani是我们的参谋长,尽管他为我工作,向我汇报,但他几乎是我的导师,教会了我几乎所有关于领导力的知识。还有Ia,他处理我们一些最棘手的客户问题,非常出色。
一切都在上升,很好。但这是一场过山车,并非一直向上。在那个时候,更像是上升而非过山车。但直到准备这次演讲时,在Sherry的帮助下我才意识到:我当时太专注于办公室了。我用我们豪华的办公室和里面的人作为衡量成功的标准。但我们停止了销售。当然,如果钱进不来,豪华办公室毫无意义,反而是一种负担。
那是我犯过的最大错误。当时我们在小镇破败地段的小办公室,大约10个人。我们已经成功销售给了Cadence和SAP(欧洲最大的科技公司)。我们打入了这两家公司的核心业务。我们本没有权利做到这一点。我本应该告诉投资者:“接下来一年不会有新销售了。你们需要再投300万。我们要招一批工程师,因为产品几乎无法运行。技术债堆积如山。更重要的是,我们需要让Cadence和SAP成功。这是我们现在的全部重点,直到他们满意、产品正常工作。我们甚至不会尝试获取新客户。”我没有这么说。我甚至没想过可以这么说。我想我当时觉得不被允许。我说的是:“我们有了SAP,那就去拿下Oracle和IBM。我们有了Cadence,那就去拿下Mentor和Synopsys。能有多难呢?”
结果很糟糕。士气开始下滑,销售目标差距越来越大。我开始做越来越绝望的事情来获取一些收入,甚至签了一个本不该签的单子,因为它完全把我们带错了方向,但我们只是需要钱。我们看到了危机来临。然后我接到了当时的董事长Tim的电话(董事长已从Robert换成了Tim)。我记得很清楚,当时我正在外面购物。他开始谈论“执行”(execution),每三四个词就有一个“执行”。我想:怎么回事?很明显,有风投说了些令人担忧的话。我意识到,好吧,我有麻烦了。
过了一段时间,最终,我被解除了CEO职务。是时候引入一个真正懂行的人了。这又是误诊的例子。实际上,Undo一直存在一个悖论:我们有一个非常棒的产品,每个人都爱不释手,形容它像海洛因。我们有一些很棒的客户。但为什么我们还不是亿万富翁?为什么总是这么难?所以风投们可能会想:Greg不懂销售,我们需要一个更懂商业的人。
最终,我们找到了一位CEO,他非常出色,考虑到我们的处境,甚至超出了我们的合理预期。他不仅精通销售,是一位非常有成就的销售,而且技术背景很强,懂产品,有计算机科学背景。Barry为我们做了两件非常重要的事,虽然最终他只待了18个月,但这两件事让我们得以成功。真正让我们成功的是,他给了我们空间去修复技术债。我之前不敢这么做,因为我总觉得投资者、董事会成员会把我视为不懂商业的技术人员。我太害怕说:“伙计们,我们必须修复技术。”但如果不修复技术,产品几乎无法运行,每次在新代码库上使用都会崩溃。我们甚至因此失去了几位工程师,他们说:“我受不了了,太令人沮丧了,只是不断堆积技术债,根本没时间好好修复任何东西。”他给了我们那个空间。
另一件事是关于Jules。Jules变得越来越不开心,年复一年,他很痛苦。说实话,当时没人对情况感到特别开心。但我就是做不到——与Jules分开。我做不到,部分是因为技术上,那时我已经失去了董事会的信任,而Jules和我各拥有公司一半的股份,我们都在董事会。所以从技术上讲,我不确定我能做什么。但更重要的是,情感上我做不到。需要Barry进来帮助Jues戒掉那个让他痛苦的坏习惯。
这不是一个愉快的结局。我相信从Jules的角度看,他会认为:“Greg把事情搞砸了。如果他按我说的做,我们就不会有这些问题。”也许他是对的。但我们做事的方式越来越不同。不幸的是,他至今没有和我说过话。这很艰难。我们曾亲如兄弟,一起奋斗。这绝对是整个创业过程中最艰难的事。
但不再担任CEO也很难。事实上,我没有意识到……情感上,我想我刚刚达到对自己情绪的“有意识的无能”阶段。在此之前,我几乎一生都处于“无意识的无能”状态。我以为自己很理性,但实际上,我成功地告诉自己,我对这种安排很满意。Barry担任CEO,我担任COO,为他工作,一开始压力小了很多,业务似乎也进展顺利。然后我们遇到了新冠疫情,这肯定没帮助,可能还有其他问题。
我记得Barry打电话给我说:“我要离开了,我觉得我在这里的工作结束了。”那一刻,肾上腺素立刻流遍全身,我非常激动和兴奋。我想:“我的王冠回来了。”直到那个电话,我才意识到自己真实的心理状态。
那是一个漫长复杂的故事。我对董事会说:“好吧,我在这里带领公司进入下一阶段,但顺便说一句,我不想再出去找新CEO了。如果你们想找,祝你们好运,但我不干了。而且,我对担任临时CEO没兴趣。”每个CEO都是临时的,我比任何人都清楚这一点。但“临时CEO”意味着你不是真正的CEO,只是在找别人。他们说:“好吧,我们考虑一下。”第二天回来说:“Greg,你担任代理CEO(acting CEO)怎么样?”我想:“我的天,我知道他们不信任我,但没想到他们认为我这么蠢。”
我们有18个月的时间没有正式的CEO。我的意思是,我是CEO,但我的LinkedIn上没这么写。这很疯狂。我们当时资金即将耗尽,没钱雇人。不管他们认为我多差劲,反正没有其他人能做这份工作。18个月后,我们终于挺过来了。
这很难。就像推石头上山。成功从来不是一条直线。希望你能做得更好。这是我们的经常性收入图表,是一条很漂亮的上升线(我稍微挑选了一下数据,最近几个月没那么好)。但现在事情运转起来了。这是我们现在的办公室,没那么豪华,但更适合我们。这不是全部团队,很难让所有人同时聚齐。我们现在大约有40多人。
现在感觉更像这样:如果你的产品尚未达到市场匹配(pre-product market fit),就像推石头上山;达到市场匹配后(post-product market fit),就像追着石头下山。两者都很难,都很可怕、危险、压力大。现在压力很大,但肯定感觉更像后者。
章节 15:核心经验与智慧总结
在剩下的时间里,我将把所有智慧浓缩到一张幻灯片上,然后我们可以提问。这里可能有点“陈词滥调”,但这是我的经验之谈。有很多懒惰的愤世嫉俗,尤其是工程师。有种说法是:愤世嫉俗者和悲观者看起来聪明,乐观者变得富有。人们真的讨厌商业行话,比如“低垂的果实”(low hanging fruit)。这是一个完美的比喻。人们能接受“把所有鸡蛋放在一个篮子里”,因为这是个存在已久的比喻。有些话可能有点老套,但它们有作用。如果要说谁该为使用行话负责,那肯定是这个会议上的人。
信任就是一切。无论你是经营公司、在公司工作,还是与他人合作开源项目,如果不能彼此信任,你就寸步难行。信任分三种:
- 基础信任:我能把钱包放在桌上出去吃午饭,回来时它还在吗?如果连这都没有,那真的完了。这很容易。
- 意图信任:我信任你的意图吗?我可能不同意你说的,但至少你出发点是对的。
- 判断信任:我信任你的判断吗?我可能会用不同的方式做,但这是你的职责范围,所以我信任你的判断比我好。如果能达到这个水平,那就是一种超能力。
关于微观管理:我仍然不擅长,但努力在做。对于一封邮件或Slack消息,问问自己:它需要我回复吗?而不是:我有没有一个能展示我聪明才智的回复可以发到这个线程?它需要回复吗?还是我可以让别人去处理?
做独一无二的领导者。意思是不要只是模仿别人,不要做电视里那种样子。你必须做真实的自己。我说“真实性”:如果你能伪装出真实性,那你就成功了。但你必须用心领导。但这并不意味着回避艰难的决定。事实上,如果你在两个选项之间纠结,一个是你希望成真的舒适选项,另一个是你非常不希望成真的选项,而你无法决定,那几乎肯定是你不想选的那个。因为我们会合理化一切来支持我们想要的选项。这并不意味着总是坏选项,有时很明显是错的。但如果纠结,那就是你不想要的那个。最明显的例子是:你是否需要解雇某人?但也有很多更小的版本。所以,像“这封邮件需要我回复吗?”这种问题,默认是“不需要”。“这个正在酝酿的情况需要采取行动吗,还是我可以忽略它希望它消失?”默认是“需要采取行动”,除非有充分理由不这么做。
不要过度承诺。我对投资者过度承诺,结果因此被解雇。因为为了获得高估值、融到大笔资金,我不得不设定非常激进的销售目标。这导致了痛苦。同样,估算项目时间:如果你一开始就说“不,这需要的时间比你想要的更长”,每个人可能一开始会讨厌你;或者他们会在最后事情花了更长时间时才讨厌你。最好让他们一开始就“讨厌”你。
确保你在解决正确的问题。多次事情进展顺利,都是因为诊断正确。我多次从根本上误解了情况,对业务缺乏情境意识。
你需要进入前三名,而不仅仅是前十名。
你几乎完全受情绪驱动。我们并不理性。如果你能理解这一点……其他人也一样。如果你发现自己认为“那个人很情绪化”,他们可能是,但你也是。
没有人知道他们在做什么。早期那些投资者告诉我各种事情,我以为他们真的很聪明,有些人非常成功。但没有人知道他们在做什么。我们都在摸索。但也要有点谦逊,你也不知道。所以,当你向导师寻求建议时,如果你认为他们值得倾听,那就真的深入探究建议的内容和原因。为什么这么说?你当时情况如何?诊断是什么?情境意识如何?这适用于这里吗?通常你会发现不适用,有时会发现适用。
倾听你的直觉。这很难,但很多时候,回想起来,我的直觉是对的。不总是,但次数惊人。直觉常常被理性化掉。所以,倾听你的直觉,听听那个“蜥蜴脑”部分在告诉你什么。
关注结果。任何在Undo工作的人可能都听腻了我说这个。每个季度末我们设定下个季度的目标时,我总是说:“这看起来不像一个结果,像一项活动。”这很重要。不是“我要写这段代码”、“我要发布这个东西”。你为什么做?为什么做这个功能?是因为你想让用户增长更快,还是想让产品崩溃更少?无论是什么,那才是我们要衡量的。那是目标,而不是达到目标的途径。
但不要过度投资于结果。这可能听起来矛盾,但这是“关注结果”与“过度执着于特定结果”的区别。如果你发现自己躺在床上想“如果……会怎样?”,那就是过度投资于结果了。因为“如果……会怎样”的疑问毫无帮助。
有些技术债是必要的。我喜欢“技术债”这个术语。就像财务债务一样,大多数人本能地认为它是坏事。但就像财务债务,没有技术债你无法重做任何事情。技术债非常强大。有句话说:编程的难点不在于解决问题,而在于选择正确的问题来解决。所以在早期,你应该积累尽可能多的技术债,只要你能承受。但就像财务债务一样,过多且不受控制的技术债会杀死你。我们积累的技术债几乎杀死了我们。实际上,我们积累的一些财务债务也几乎杀死了我们。
用金钱换取时间。时间是唯一无法挽回的资源。早期不容易,你没多少钱,所以很多事情必须自己做。但我肯定也搞错了好几次。早期,我会给自己的时间定一个价值,比如每小时100英镑。我会判断:做这件事能为我节省一小时时间吗?如果能,我就愿意花100英镑。如果你无法从时间中获得那种回报,你可能应该去做别的事。
最后,来自伟大哲学家Ferris Bueller的话:“人生过得很快。如果你不时停下来看看周围,你可能会错过它。”所以,享受这段旅程。
还有最后一点:永远不要以道歉开始演讲。我经常看到这个。即使你只是对两个人讲20分钟,那两个人也投入了20分钟——他们无法挽回的时间。如果你一开始就说“抱歉,我没有足够时间准备”或“我不是这方面的专家”,你实际上是在说:“这东西有点烂,但我们将就一下吧。”你已经在那里了,所以我们必须继续。永远不要以道歉开始,即使我们做这些事时都很紧张。同样,结束时也不要说“谢谢”。你不该谢他们,他们应该谢你。唯一的例外是,如果掌声雷动,鲜花被扔上台,那时你可以说谢谢。
章节 16:问答环节摘要
问:你提到用员工数量和办公室来衡量成功,而不是销售额,导致销售下滑。这让我产生共鸣。关于衡量员工数量,或者确保没有欺诈(比如远程员工是AI或兼职多份工作),有什么经验教训吗?
答:说实话,如果公司规模较大(比如超过100人),情况不同。但如果公司规模较小(比如少于100人),而你不知道员工是真人还是AI,是在办公室还是在世界另一端,那你的公司已经岌岌可危了。你应该知道他们在做什么。关键是关注结果。如果我被衡量的是写了多少行代码,那就容易钻空子。如果衡量的是结果,那么只要员工能带来我想要的结果,即使他们一周只工作两天(而我以为他们全职),那又有什么关系呢?
问:你提到改变人们的行为很难。作为员工,向同事介绍新IDE或做演示后,回到工位,可能什么也没发生。如何让人们改变行为?
答:需要持续的努力,并事先理解这有多难。我有一位很喜欢的客户,他非常想改变组织内部使用我们产品的方式,做了很多视频教程。在一次会议上(我不是故意让他难堪),我问:“我们能看看你的视频有多少观看量吗?”他查了一下,数字很低。我很难受。但期望不能是“我告诉他们了,放在Wiki上了,所以大家都知道了”。不,他们不知道。你需要一遍又一遍地告诉他们。还有不同的认知层次:第一层,知道产品存在吗?这仍然很难。我们有合作了十多年的大客户,我仍然能遇到那些公司的工程师,他们从未听说过我们。第二层,知道它是做什么的?对我有什么好处?我为什么要用?第三层,知道怎么用?我需要设置路径吗?怎么做?还需要耐心。我们一些最老的客户,十多年了,用户图表仍在增长。我们仍在不断增加用户,即使已经在该客户那里努力了十多年。所以,耐心和坚持。
问:回到“知道何时收手”的部分。你坚持了七年,同时还有孩子。那个阈值是什么?哪些信号决定应该继续还是放弃?
答:你可能问错人了。我不擅长这个。我认为要和亲近的人谈谈,但不是那些疏远的人。不要直接问“我应该放弃吗?”,因为他们可能会出于支持说“不”。要问更具体的问题,比如“你认为我们为什么挣扎?差距在哪里?”问别人,也问自己。像我们,一路走来都有信号。为了演讲效果和时间,我可能描绘得过于黯淡了。但从一开始,我们的收入虽然微薄,但每年都在增长。如果你发现那个核心指标(KPI)年复一年停滞不前,那可能是个信号。绝对值不重要,重要的是增长。

问:早期如何做好招聘?早期招聘非常关键,招错人代价很大。
答:这是个好问题,我真应该放进演讲里。早期招聘影响巨大,因为前5到10个人奠定了公司文化。有很多运气成分,但这没什么帮助。
023:使用模型上下文协议将C++工具连接到AI智能体



概述
在本节课中,我们将学习如何利用模型上下文协议,将C++开发工具连接到AI智能体,从而增强开发体验。我们将从大语言模型与开发工具集成的历史演进开始,逐步理解MCP协议的核心概念、工作原理,并通过一个实际案例演示如何构建一个MCP服务器。
从聊天到智能体:开发工具的演进
为了理解MCP是什么以及它为何有用,我们需要回顾一下大语言模型与开发工具集成的演进历程。
早期阶段:手动复制粘贴
2022年11月ChatGPT的发布开启了LLM在开发工具中应用的时代。最初,开发者使用方式很简单:将问题连同源代码一起复制粘贴到聊天界面中。例如,修复单元测试时,开发者需要手动输入“帮我修复这个测试”,并粘贴相关代码。
这种方式存在明显问题:需要手动复制代码,且粘贴的代码可能并非真正有问题的部分。
集成阶段:上下文自动收集
进入2023年,随着OpenAI等公司开放API,各类产品开始将聊天功能集成到IDE内部。此时,工具可以利用已有的数据源(即“上下文”)来回答问题。用户可以说“帮我修复这个测试,我有一个计算器文件”,客户端会预先获取文件内容并拼接到提示词中。
这种方式有所改进,但用户仍需明确提及文件。
检索增强生成阶段
到2023年中,产品开始实现自动上下文收集,即“检索增强生成”。系统能够根据用户问题,自动搜索并关联相关代码文件,无需用户手动指定。
实现方式主要有两种:
- 客户端预处理:通过关键词搜索或向量嵌入等技术,从数据库中检索相关上下文,全部拼接到提示词中。这种方法可能导致上下文过多或搜索不准确。
- 多步方法:先利用LLM本身判断哪些可用上下文与问题最相关,再基于此构建最终提示词。
尽管聊天功能已大大增强,但它仍局限于“回答问题”。用户仍需自行编辑代码、构建和验证修复结果。
智能体阶段:工具调用
时间推进到2024年末至2025年初,“编码智能体”开始兴起,它们能够半自主地处理简单的开发内循环任务。其关键技术是“工具调用”。
以下是工具的核心组成部分:
- 工具元数据:描述工具的信息,供模型决定是否使用。
{ "name": "add", "description": "Adds two numbers together.", "inputSchema": { "type": "object", "properties": { "a": { "type": "number" }, "b": { "type": "number" } } } } - 实现函数:执行工具实际功能的编程语言函数。
工具调用的工作原理是,LLM经过训练,能够输出特殊的令牌序列来请求调用工具。客户端执行对应的函数后,将结果返回给LLM,LLM再生成最终回答给用户。
通过结合文件编辑、终端命令执行等工具,就能构建一个简单的智能体。关键在于形成了闭环反馈:LLM可以运行命令、观察结果(如构建失败)、编辑文件、再次运行命令,从而迭代解决问题,这比简单的聊天界面强大得多。
模型上下文协议:解决工具集成碎片化问题
开发者自然希望为智能体添加自定义工具。同时,IDE厂商也开始提供可扩展的工具接口。然而,这导致了集成碎片化问题:不同的聊天界面(如VS Code、CI系统)和不同的工具提供商(如任务管理系统、构建系统、云平台)形成了复杂的组合矩阵。
模型上下文协议正是为解决此问题而设计,它将N×M的集成复杂度降低到N+M。熟悉开发工具的人会发现,这与语言服务器协议解决的问题非常相似,只是将“编辑器”和“语言服务器”换成了“MCP客户端”和“MCP服务器”。


MCP协议基础
MCP在底层是一个基于标准IO或HTTP的JSON-RPC协议。JSON-RPC是一种使用JSON进行远程过程调用的机制。基于此协议,通信双方可以用不同的编程语言实现,具有良好的互操作性。
协议包含多种消息类型,但我们将重点关注与工具调用相关的四种核心消息:
initialize:客户端与服务器协商支持的能力。tools/list:客户端请求服务器列出可用工具。tools/call:客户端请求调用特定工具。tools/result:服务器返回工具调用结果。
基本的交互流程如下:
- 客户端发送
initialize消息进行握手。 - 客户端发送
tools/list消息获取工具列表。 - 根据用户输入,客户端发送
tools/call消息调用工具。 - 服务器执行工具后,返回
tools/result消息。 - 服务器还可以通知客户端工具列表有更新。
实战演示:构建Compiler Explorer MCP服务器
上一节我们介绍了MCP协议的基本原理,本节我们将通过一个实际案例,看看如何构建一个MCP服务器。
假设我们有一个简单的C++函数,用于将数组中的值翻三倍。我们想知道MSVC编译器是否会将其向量化。直接询问AI,它无法给出确切答案,因为它看不到实际的汇编代码。
一个自然的想法是:如果能使用Compiler Explorer(一个在线查看编译器汇编输出的工具)就好了。我们可以构建一个MCP服务器来提供这个功能。
以下是构建此MCP服务器的核心步骤和代码:
首先,包含必要的库并设置服务器传输层(这里使用HTTP)。
#include <mcp/server.hpp>
#include <mcp/transport/http.hpp>
...
auto transport = std::make_shared<mcp::transport::HttpTransport>("localhost", 8080);
mcp::Server server(transport);
接着,在初始化时声明服务器能力(本例中仅提供工具)。
auto init_params = server.getInitParams();
init_params->capabilities->tools = true;
server.setInitParams(std::move(init_params));
然后,定义工具的元数据,描述其名称、用途和输入参数。
mcp::ToolMetadata tool_meta;
tool_meta.name = "get_assembly";
tool_meta.description = "Provides the assembly that MSVC would produce for given C++ code.";
tool_meta.inputSchema = {
{"code", mcp::SchemaType::String, "The C++ source code."},
{"options", mcp::SchemaType::String, "Additional compiler flags (optional)."}
};
最后,实现工具的后端函数,该函数调用Compiler Explorer的API并返回结果。
auto tool_handler = [](const mcp::ToolInput& input) -> mcp::ToolOutput {
// 1. 从输入中解析代码和编译选项
std::string code = input.arguments["code"];
std::string options = input.arguments.value("options", "");
// 2. 构造请求,调用Compiler Explorer API
// (此处为简化示例,实际需使用HTTP客户端库)
std::string assembly_output = callCompilerExplorerAPI(code, "msvc", options);
// 3. 返回结果,MCP支持多种类型(文本、图像等),此处返回文本
return mcp::ToolOutput{
.content = {{mcp::ContentType::Text, assembly_output}}
};
};
// 将元数据和处理器注册到服务器
server.registerTool(tool_meta, tool_handler);
server.start();
服务器构建完成后,可以使用Anthropic提供的MCP Inspector工具进行测试和调试。在VS Code中,需要在.vscode/mcp.json配置文件中添加服务器连接信息。配置成功后,AI智能体就能在对话中看到并使用这个get_assembly工具,从而获取准确的汇编代码并分析向量化情况。
最佳实践与安全考量
在决定构建和使用MCP服务器时,需要考虑以下几点最佳实践和安全问题。
何时不使用MCP
MCP设置相对复杂,在以下情况可能有更简单的选择:
- 传递少量固定信息:如代码风格指南、构建说明。优先使用客户端内置的“自定义指令”、“规则”或“提示词文件”功能。
- 利用现有工具:如果你的需求可以通过一个简单的Shell脚本调用现有系统实现,那么编写脚本并配以使用说明可能更快。
- 仅支持单一平台:如果你只针对VS Code或某个特定IDE,使用其平台特定的扩展API可能更简单、功能更丰富。
- 高度结构化的工作流:如果你的流程需要精确控制,直接编写普通程序并在需要时调用LLM API可能更合适。
安全警示:致命三重威胁
将工具接入AI系统时,必须警惕“致命三重威胁”,即同时满足以下三个条件:
- 访问私有数据。
- 暴露于不受信任的内容(如来自其他用户的输入)。
- 能够进行外部通信或数据渗出。
如果系统同时具备这三者,就可能通过“提示词注入”攻击导致私有数据泄露。因为LLM无法像区分HTML标签或SQL语句那样,严格区分数据和指令。即使你的单个MCP服务器不满足全部三个条件,也要注意整个聊天上下文是共享的,其他工具服务器可能组合构成威胁。
优化工具设计
为了让LLM更好地使用你的工具,请注意:
- 编写清晰的工具描述:LLM仅通过元数据了解工具,因此描述至关重要。可以参考成熟工具(如VS Code内置工具)的描述,甚至可以让LLM协助撰写描述。
- 提供友好的错误信息:LLM可能错误调用工具。返回清晰、可读的错误信息,能帮助它自我纠正并再次尝试。
- 设计LLM友好的输入结构:例如,一个查找代码定义的工具,接受“符号名称”作为输入比接受“文件偏移量”更友好,因为LLM不擅长精确计算字符位置。
- 建立测试用例集:用于评估工具被正确调用的频率,并优化描述。
MCP高级功能
对于更复杂的智能体,MCP还提供了其他有用的消息类型:
elicitation:允许工具在运行时直接向用户请求更多信息,无需通过LLM中转。sampling:允许服务器利用客户端已有的LLM连接发送自己的请求,例如用于在返回前总结大量数据。


总结
本节课我们一起学习了如何利用模型上下文协议将C++工具连接到AI智能体。我们从开发工具与AI集成的历史演进讲起,理解了工具调用的关键性。然后,我们深入探讨了MCP协议如何解决工具集成的碎片化问题,并详细解析了其基于JSON-RPC的工作原理。通过一个构建Compiler Explorer MCP服务器的实战演示,我们掌握了创建自定义工具的基本步骤。最后,我们讨论了何时该用或不该用MCP、必须重视的安全威胁“致命三重威胁”,以及如何设计更易用、更强大的工具。希望本教程能帮助你开始探索利用MCP增强自己的开发工作流。
024:你从未知晓的功能 🛠️


在本教程中,我们将跟随 Matt Godbolt 在 CppCon 2025 上的演讲,深入探索 Compiler Explorer 这个强大的在线工具。我们将学习其历史、核心界面、各种高级功能以及幕后工作原理。无论你是初学者还是经验丰富的开发者,本教程都将帮助你更高效地使用 Compiler Explorer 来分析和理解代码编译。
历史与概述 📜
Compiler Explorer 始于 2012 年,最初是 Matt Godbolt 在 DRW 公司内部开发的一个小工具。当时,他与上司就 C++ 范围 for 循环的性能问题产生了争论。为了验证观点,他编写了一个 shell 脚本,在终端一侧运行编译器并查看汇编输出,另一侧则用 Vim 编辑代码。这种即时对比的方式非常有效,于是他将其整合成了一个简单的网页应用。
最初,这个网站被命名为 “Interactive Compiler”,并托管在他的个人域名下。随着工具支持的语言和编译器越来越多(从最初的 GCC 和 Clang 扩展到如今超过 5000 个编译器、80 种语言),它逐渐演变成了我们今天熟知的 Compiler Explorer。
如今,Compiler Explorer 每月处理数千万次编译,背后有一个优秀的团队和赞助商支持其运营。它已从一个简单的内部工具,成长为全球开发者不可或缺的代码分析平台。
核心界面导览 🖥️
上一节我们回顾了 Compiler Explorer 的历史,本节中我们来看看它的核心用户界面。界面主要分为代码编辑区和汇编输出区。
代码编辑区在左侧,你可以在这里编写 C++、Rust 等多种语言的代码。右侧是汇编输出区,显示所选编译器为你的代码生成的汇编指令。两个区域通过颜色高亮进行关联:鼠标悬停在代码的某一行或某个变量上,右侧对应的汇编指令也会高亮显示,反之亦然。
以下是界面中一些关键元素的介绍:
- 编译状态指示器:编辑区上方有一个圆形图标。绿色表示编译成功,黄色表示有警告,红色表示编译错误。点击这个图标可以查看发送给编译器的完整命令行参数,这在向编译器项目报告 bug 时非常有用。
- 编译器选择下拉框:你可以从这里选择不同的编译器(如 GCC、Clang、MSVC)及其版本。由于编译器数量庞大,你可以使用搜索框进行模糊查找(例如输入
clang x86 trunk),也可以将常用的编译器标记为“收藏”以便快速访问。 - 编译器选项输入框:你可以在这里添加编译标志,例如
-O3、-Wall等。如果选项很多,可以点击下拉箭头打开详细视图,像编辑文本一样进行管理。 - 视图管理按钮:
Add new...和Add tool...按钮用于向结果面板添加新的视图。Add new...通常用于需要介入编译过程的视图(如栈使用分析),而Add tool...用于编译后对二进制文件进行分析的工具(如readelf、nm)。
所有面板都是可拖拽的,你可以自由排列它们的位置,甚至可以将其堆叠成标签页形式。每个面板都可以被重命名、最大化或克隆,方便你管理复杂的对比场景。
实用功能与工具详解 🔧
了解了基本界面后,本节我们将探索一些能极大提升效率的实用功能和工具。
1. 代码对比与多编译器检查
Compiler Explorer 非常适合对比不同编译器或不同优化选项下的代码生成结果。
- 差异视图:添加一个 “Diff” 视图,可以并排对比两个编译器(或同一编译器不同设置下)生成的汇编代码差异。这对于理解优化选择或平台差异非常有帮助。
- 一致性视图:如果你需要快速检查一段代码在多个编译器上是否能通过编译,可以使用 “Conformance” 视图。它会以紧凑列表的形式显示多个编译器的编译状态(绿色对勾或红色错误),点击状态图标可以快速查看详细信息。
2. 预处理器与栈使用分析
有时你需要查看宏展开后的代码或分析函数的栈内存使用情况。
- 预处理器输出:通过
Add tool...添加 “Preprocessor” 视图,可以查看经过预处理器处理后的代码,这对于调试复杂的宏定义非常有用。 - 栈使用分析:通过
Add new...添加 “Stack usage” 视图,可以估算函数及其调用树的栈内存使用情况。这能帮助你识别潜在的大栈对象,指导优化决策。
3. 二进制文件分析工具
编译生成目标文件或可执行文件后,你可以使用一系列工具进行分析。
nm:列出目标文件中的符号。readelf:显示 ELF 格式二进制文件的详细信息,如文件头、节头、动态链接信息等。objdump:反汇编二进制文件。- 查看重定位表:当编译为目标文件(
.o)时,可以查看重定位条目,了解链接器将如何修补指令中的地址。
4. 编译器优化探索
Compiler Explorer 提供了独特的方式来窥探编译器的优化过程。
- 架构与标准覆盖:通过 “Overrides” 按钮,你可以轻松切换编译目标架构(如
-march=skylake)或 C++ 语言标准版本(如-std=c++20),无需记忆复杂的命令行标志。 - LLVM 优化管道(针对 Clang):添加 “LLVM Opt Pipeline” 视图,可以逐步查看 LLVM 编译器在优化过程中,中间表示(IR)是如何被一系列 Pass 改变的。你可以点击任何一个产生变化的 Pass,查看优化前后的具体 IR 代码,是学习编译器优化的绝佳窗口。
代码执行与性能分析 ⚡
上一节我们介绍了静态分析工具,本节中我们来看看如何动态运行代码并进行底层性能分析。
1. 执行代码
Compiler Explorer 允许你在沙箱中安全地运行代码。
- 基本执行:将输出模式切换到 “Execute the code”,即可运行程序并查看标准输出。你可以在 “Arguments” 输入框中指定命令行参数,在 “Stdin” 框中输入标准输入内容。
- 高级执行视图:通过
Add new...添加一个 “Executor” 视图,可以获得更专注的执行环境,方便单独管理执行参数和输入输出。 - 调试辅助:你可以启用
libsegfault来在程序崩溃时获取栈回溯信息,也可以启用heaptrack来动态分析堆内存分配情况。
2. 微架构性能分析
对于追求极致性能的代码,你可以进行指令级分析。
- LLVM-MCA 分析:将语言切换到 “Analysis” 并选择 “LLVM-MCA”,它可以模拟指定代码片段(通常是内循环)在特定 CPU 微架构上的执行情况。它会给出总周期数、指令吞吐量等预测数据。
- 时间线视图:在 LLVM-MCA 分析中添加
-timeline标志,可以生成一个 ASCII 艺术风格的时间线图,直观展示指令在每个周期内的解码、执行、退役等状态,帮助你理解指令级并行和 CPU 流水线行为。
示例:使用 LLVM-MCA 分析循环
// 假设这是你要分析的内循环代码
for (int i = 0; i < N; ++i) {
sum += data[i] * data[i];
}
通过将其放入独立的分析窗口并选择 LLVM-MCA 工具,你可以获得该循环体的性能预测报告。
项目模式与高级技巧 🚀
Compiler Explorer 不仅限于单文件分析,还支持简单的多文件项目。
1. IDE / 项目模式
通过 Add new... 添加 “IDE” 视图,你可以进入项目模式。


- 管理多个文件:在左侧的文件树中,你可以添加多个源文件、头文件甚至数据文件。所有文件会被上传到一个临时目录中。
- 基于项目的编译:在 IDE 视图下添加的编译器,会看到项目中的所有文件,从而可以编译和链接多文件项目。
- 使用 CMake:你甚至可以添加
CMakeLists.txt文件,让 Compiler Explorer 调用 CMake 来构建你的项目。网站提供了一些 CMake 项目模板,帮助你快速上手。
2. 实用技巧与幕后
- 个性化设置:所有设置(如主题、编译延迟、默认语言)都保存在浏览器的本地存储中。你可以通过 “Reset code & UI layout” 快速恢复默认界面。
- 短链接与状态保存:生成的分享链接(如
godbolt.org/z/...)会长期有效。你也可以将当前的窗口布局和代码保存为本地模板。 - 多域名技巧:
godbolt.org是主域名。访问其子域名(如rust.godbolt.org)会默认打开对应语言的编辑器。你也可以使用compiler-explorer.com或gcc.godbolt.org等域名,它们对应独立的本地设置,方便你在不同配置间切换。 - 幕后架构:Compiler Explorer 运行在 AWS 上,使用沙箱技术隔离用户代码以确保安全。它正在从基于机器本地队列的架构,转向全局队列系统,以更公平、高效地分配编译任务,减少用户等待时间。
总结 📝
在本教程中,我们一起深入探索了 Compiler Explorer 这个强大工具。我们从其诞生历史开始,逐步学习了核心界面的操作,包括代码与汇编的关联查看、多编译器管理和面板自定义。
接着,我们探讨了一系列高级功能:如何对比代码差异、分析预处理器输出和栈使用情况、使用二进制工具分析目标文件,以及通过 LLVM 优化管道理解编译器内部工作。我们还学习了如何在沙箱中安全执行代码,并利用 LLVM-MCA 进行底层的微架构性能分析。
最后,我们介绍了用于多文件项目的 IDE 模式,并分享了一些提高效率的实用技巧和关于其背后架构的见解。



希望本教程能帮助你解锁 Compiler Explorer 的全部潜力,让它成为你学习、调试和优化代码的得力助手。
025:C++中的网络——实际正在改变什么






在本节课中,我们将探讨现代网络传输协议的发展,以及它们如何对C++等编程语言的网络编程接口提出新的挑战。我们将分析现有接口(如BSD Sockets)的局限性,并讨论设计一个更通用、更符合现代需求的网络传输接口的必要性和可能性。
概述:网络接口的现状与挑战
网络通常被视为一个简单的“管道”,应用程序通过操作系统提供的接口(如BSD Sockets)发送和接收数据包,而很少关心底层细节。然而,过去几十年间,新的传输协议(如QUIC、SCTP)带来了新的功能,试图在现有接口之上建模这些功能时,会遇到两个主要限制:一是难以引入新功能,二是将复杂性推给了应用程序。40年前标准化的接口已无法充分利用新传输协议的潜力。
现有协议与接口分析
上一节我们介绍了网络接口面临的整体挑战,本节中我们来看看具体有哪些现有协议,以及它们暴露出的接口问题。
TCP协议
TCP是绝对主流的协议,它模拟逻辑会话并维护内部状态(TCP控制块)。然而,通过BSD Sockets接口使用TCP时,一些问题开始显现:
- 保活操作:无法轻松地在数据平面上发送零字节消息来保持连接活跃。虽然可以通过套接字选项设置内部TCP保活消息,但这并非应用程序可见的外部操作。
- 路径MTU:可以查询当前连接的路径MTU,但如果底层网络发生变化(例如添加MPLS标签导致MTU减小),没有简单的方法能通知应用程序路径层参数已改变。
- 认证选项:如TCP-MD5和TCP-AO,用于防止会话重放攻击,但启用它们需要使用特定的、平台差异很大的套接字选项,缺乏可互操作的通用接口。
- 快速打开:允许在TCP会话建立的初始SYN包中携带有效载荷,但这不属于常规数据接口,需要作为异常情况处理。
- 拥塞控制:TCP拥有灵活多样的拥塞控制机制,但其内部状态和控制参数无法直接访问,只能通过平台特定的套接字选项或扩展来获取,这同样属于异常路径。
因此,拥有一个通用接口会非常有益,该接口既能用于数据操作(发送/接收),也能以相对统一的方式获取TCP会话的动态属性信息。
其他传输协议
让我们继续分析其他常见传输协议及其接口特点。
以下是其他一些重要传输协议及其接口挑战:
- MPTCP:使用多个并行TCP连接,在应用层看来是一个逻辑会话。其拥塞控制和调优方式不同,需要特定的接口来暴露和调整这些信息。
- UDP:简单的数据报协议。发送后无法简单获知数据是否到达,且拥塞控制留给应用程序处理。当前接口不允许以简单方式查询底层路径属性。
- UDPLite:一种小众协议,校验和较短且不覆盖部分载荷。它定义了可用的错误接口,通知应用程序收到了校验和错误的数据包,由应用程序决定如何处理。
- TLS/DTLS:TLS不是独立协议,而是一个建立在TCP之上的协议栈。虽然它简化了安全凭证处理,但其库实现(如OpenSSL)对事件处理有自己的一套看法,可能与应用程序的期望冲突,使得数据发送/接收的处理变得复杂且受限制。DTLS在UDP上实现,存在类似问题。
- SCTP:一种小众协议,用于移动无线控制平面。它设计了可靠性,并定义了“流”(子通道),允许并发承载对象或有效载荷。其接口不再是一个套接字或文件描述符,而是具有逻辑分离的实体,如何映射到BSD Sockets上并不明确,通常依赖于实现。
- InfiniBand/RDMA:高性能计算领域的协议。它采用了队列模型:发送队列、接收队列和完成队列。应用程序提交一个描述符(指定源地址、目标地址等),库在后台完成传输。当操作完成时,会在完成队列收到通知。这与Linux的io_uring理念相似。
- 光纤通道:用于存储网络。其设计正确的一点是命名与寻址的集成。端点具有名称,并通过名称服务动态地将名称转换为拓扑相关的地址标识符。这类似于将DNS集成到IP世界中,作为一个统一的连接服务。
- QUIC:一个相对较新但流量占比很高的协议,基于UDP,集成了TLS,并模拟了多路TCP连接。它支持多通道、灵活的拥塞控制和服务质量参数。大多数QUIC实现在用户空间,这带来了与TLS库集成和事件处理的接口挑战。
通用接口的设计思路
在分析了各种协议的特定需求后,我们可以尝试归纳出设计一个通用传输接口所需的核心要素。


核心需求归纳
基于以上分析,我们可以从两个维度概括需求:
- 跨协议通用需求:
- 会话建立:如何建立两点间的传输会话。需要更注重基于DNS名称的标识,而不仅仅是IP地址。
- 参数配置:需要以更统一的方式信令传输特定参数和配置。基于描述符的方案是一个可能的候选,描述符定义所有传输协议的超集,特定实例从中选取所需部分。
- 拥塞感知:需要一个接口来提供网络当前状态的可见性,并可能进行调整。
- 接口模型转变:
- 从同步到异步队列模型:BSD Sockets接口本质上是同步的。一个更实用的模型是异步队列模型,应用程序将消息或描述符发布到队列,然后继续执行其他操作,随后从另一个队列(如完成队列)查询通知。这简化了应用程序逻辑,无需处理多个异常。
- 统一事件处理:需要能够使用现有或熟悉的事件通知系统(如epoll, kqueue)。描述符机制可以很好地抽象子通道。
- 描述符抽象:将子通道也表示为描述符可能是正确的方法。
功能层与架构设想
如果尝试对此进行分层和绘制图表,可以设想以下架构:
- 传输服务参数:这是一个预配置的概念。应用程序预先设置连接属性(如证书、路由参数、服务质量参数),获得一个上下文标识符。
- 连接建立:通过提供名称并引用上述上下文来建立连接。
- 连接关闭:明确地关闭传输连接。
- 集成DNS:DNS被集成到传输层,应用程序主要处理名称,而非直接操作IP地址。
- 底层基础设施:现有的数据包层和传输层基础设施,将一切视为数据包处理。
关于向后兼容性,BSD Sockets可以在新接口之上建模,或者作为独立的子系统并行存在,供不需要新功能的用例使用。
当前进展与行业现实

那么,目前在这方面有哪些努力和现实情况呢?
现实情况是,大多数传输协议的实现主要关注其特定协议本身,缺乏端到端的视角来整合多种协议。这导致了接口各异、事件处理方法不同的现状。
- IETF的尝试:如“通用传输接口”(TAPS, Transport Services)工作组,旨在定义一个涵盖所有传输的通用接口模型。然而,这类努力有时受学术驱动影响较大,且IETF发布RFC并不代表业界一定会广泛采纳和产品化。
- 其他倡议:如NEAT(新扩展传输API),主要由大学驱动。此外,网络系统供应商和操作系统供应商也有各自定义的接口,导致了碎片化。
- 从编程语言侧入手:鉴于IETF标准与实际实现之间的差距,从特定编程语言(如C++)侧尝试定义一个实际可用的网络传输接口库,可能更接近现实使用场景。这样的库可以与现有的并发机制集成,并为当今的主流传输协议提供近乎通用的接口。
总结与展望
本节课中我们一起学习了现代网络传输协议对编程接口提出的新挑战。我们分析了从TCP、QUIC到RDMA等多种协议的接口痛点,认识到40年前的BSD Sockets API在应对新功能时存在局限性。核心问题在于同步调用模型、缺乏统一的参数配置和拥塞控制接口,以及对命名寻址的支持不足。
设计新接口的关键思路是转向异步队列模型(类似io_uring或InfiniBand的提交/完成队列),采用基于描述符的抽象来统一数据操作和事件通知,并深度集成DNS命名。虽然IETF有过TAPS等标准化尝试,但从编程语言库的层面着手,定义灵活、可适配不同底层平台和并发框架的接口,可能更具实践意义。


这项工作并非适用于所有应用,但对于需要处理大量并发连接、使用多种现代传输协议(如HTTP/3 over QUIC)的中间件开发者而言,一个设计良好的通用网络库能显著降低复杂性和提升可移植性。
026:掌握代码审查流程



在本节课中,我们将学习如何构建一个有效的代码审查流程,以平衡代码质量、开发速度和代码库的长期可持续性。我们将探讨流程的核心要素、如何衡量其有效性,以及如何培养积极的审查文化。
概述
代码审查不仅仅是检查代码语法,更是一个涉及流程、协作和文化的系统工程。一个良好的审查流程能提升代码质量、加速功能交付、促进知识共享,并确保代码库的长期健康。
演讲者介绍
我是 Pete Muldoon,自1991年开始使用C++。我的职业生涯主要在系统分析和架构领域,拥有21年的咨询经验,接触过许多不同的公司和团队。过去十年,我在彭博社工作,目前负责处理全球交易所信息的“行情系统”。我喜欢探讨实用的软件工程原则及其在现实问题中的应用。
代码审查的本质与目标
上一节我们介绍了演讲背景,本节中我们来看看代码审查的核心定义和目标。
代码审查本质上是一种技术性评审。它是对自己或他人工作的正式分析,旨在提供建设性的判断,指出优缺点,而非单纯挑错。
代码审查主要有三种类型:
- 建设性:指出问题并提供解决方案。
- 指导性:分享新知识或最佳实践,提供学习机会。
- 破坏性:仅进行负面批评,通常无益。
那么,代码审查的核心目标是什么?
- 提升质量:确保代码正确、可维护且符合需求。
- 功能交付:最终目标是将有价值的业务功能可靠地投入生产环境。
- 维护代码库长期健康:防止代码库因只关注短期功能而腐化,避免未来重写。
- 控制时间:在保证质量的前提下,追求合理的审查和交付速度。
此外,代码审查还有一些不那么明显但同样重要的目标:
- 促进知识共享与教育。
- 建立共同所有权:审查者需对合并的代码承担共同责任。
- 分散工作负载,培养团队协作感。
代码审查在工程实践中的地位
上一节我们明确了审查的目标,本节中我们来看看它在整个软件工程中扮演的角色。
数据显示,拥有良好的代码审查流程是提升代码质量的首要方法。在工程实践中:
- 开发者阅读代码的时间远多于编写代码。
- 代码审查是保持代码库可持续健康的最后一道防线。
- 它可以作为最佳实践的放大器,在团队中传播知识。
- 目前,人工智能(AI)难以在短期内完全替代人工审查,尤其是在代码结构和设计层面。AI更适合处理表面细节,作为辅助工具。
代码审查中的矛盾与平衡
在实践中,代码审查流程常面临几组相互矛盾的指令,需要在其中找到平衡:
- 标准严格度:过于僵化的标准会扼杀创造力;过于宽松则导致混乱。
- 审查时效:仓促审查可能导致遗漏;拖延审查则会阻碍交付。
- 变更范围:过于最小化的变更可能错失改进机会;过于宽泛的变更则可能引入无关修改,增加风险。
- 新特性采用:是坚持陈旧的“保守一致性”,还是积极采用新的语言特性和工程实践。
找到这些矛盾之间的平衡点,是构建高效、协作的审查流程的关键。
低效代码审查的后果
一个低效的审查流程会带来诸多负面影响:
- 生产环境故障和缺陷。
- 代码质量低下且不统一,尤其是在跨团队时。
- 开发速度缓慢,PR(拉取请求)长期闲置或过于复杂耗时。
- 任务覆盖不全面,开发者因害怕破坏代码而不敢进行必要的重构。
- 陷入“自行车棚”效应,即过度争论琐碎细节而忽视核心问题。
提交审查前的准备
在请求他人审查之前,开发者应做好充分准备,以尊重审查者的时间并提高效率。
以下是提交前应完成的检查清单:
- 运行静态代码分析:消除所有编译警告。
- 确保通过所有单元测试和集成测试。
- 如果变更了可观察行为,请添加新测试。
- 进行自我审查:检查并修正明显的错误。
- 考虑使用AI辅助进行初步检查。
此外,为审查者提供清晰的信息至关重要:
- 编写恰当的标题和描述:清晰说明做了什么、为什么做以及如何验证变更有效。
- 降低审查者的认知负荷:
- 再次强调自我审查。
- 限制PR的范围,避免巨型PR。
- 如果变更涉及多个文件,明确指出核心变更的位置。
关于草稿PR:它适用于获取早期反馈、确认方向或了解历史遗留问题,不应被合并。一旦转为正式PR,就应遵循完整的审查流程。
审查流程的时间线与规模控制
一个典型的代码审查生命周期包括:开发 -> 提交审查 -> 收到初始反馈 -> 修改并回复 -> 等待最终批准 -> 合并。
PR的规模至关重要。过大的变更会带来诸多问题:
- 审查者难以在脑中构建完整上下文,无法提供高质量反馈。
- 审查者因担心风险而不敢轻易批准。
- 代码库越是非结构化(通常历史越久),变更集就应该越小。
- 难以理解的变更本质上风险更高。
应将格式化(如空格、缩进、重命名)等变更放在独立的PR中先行合并,以便专注于实质性的代码修改。
审查流程的时效管理
任何变更只有在生产环境中运行才具有价值。因此,缩短审查周期至关重要。
建议采取以下措施:
- 设定明确的启动审查时限(例如24小时内必须开始审查)。
- 在每日站会等场合保持流程可见性,同步审查状态。
- 明确合并PR是开发者的责任。
- 将代码审查的优先级置于编写新代码之上(特殊情况除外),防止流程堵塞。
- 数据显示,每日进行审查的团队满意度最高。
当然,指南适用于大多数情况,需为特殊情况保留灵活性。
审查反馈的分类与表达
在具体审查代码时,反馈可以清晰分为几类,并用表情符号等标记,以避免歧义:
- 必须修复的问题:代码存在错误或结构性问题。
🚨 - 建议性替代方案:存在可能更好的模式或实现。
🔧 - 细微之处:个人偏好的风格或微小优化建议。
📌 - 提问:对代码意图或实现不理解,需要澄清。
❓ - 赞扬:对出色的代码表示认可。
👍
清晰分类有助于作者快速理解反馈的紧急性和性质。反馈时应对事不对人,使用具体、建设性的语言,多提问,而非直接指责。
审查中应关注什么
在审查过程中,应关注以下方面:
- 标准化基础项:使用简短的检查清单来统一团队的基本要求。
- 保持代码一致性:确保新代码与周围代码风格一致,但不要以“一致性”为名延续不良实践。
- 视审查为协作:乐于接受有能力审查者的意见,将其视为学习机会。
- 培养审查技能:在团队内进行培训和分享。
- 持续教育:关注语言和工程实践的新发展。
- 重点审查测试代码:检查测试是否覆盖了正常、异常路径及边界情况,测试命名是否清晰,是否使用了魔数。
如何衡量流程有效性
要改进流程,必须进行度量。以下是一些可衡量的指标:
- 测试覆盖率:包含测试的PR数量占总PR数的比例。
- PR吞吐量:已创建PR与已合并PR的数量对比,识别积压。
- 审查时间:从PR创建到收到首次评论/完成合并的平均时间。
- 团队满意度调查。
数据显示,拥有度量指标并定期审视的团队,对审查流程的满意度是其他团队的三倍。
根据变更影响调整审查策略
并非所有PR都相同,应根据其“爆炸半径”调整审查策略:
- 小型功能变更:低风险,由同行和团队负责人审查即可。
- 大型架构变更:高风险,需引入了解历史背景的“历史学家”和精通现代架构的专家进行审查。
- 远程协作与多人审查:对于重要变更,应要求多于一名审查者。
- 设计评审:对于重大变更,可在编写代码前进行设计评审。
- 审查会议:对于存在争议或非常重要的变更,可召开审查会议进行集体讨论。
培养积极的审查文化
要建立良好的审查文化,可以采取以下措施:
- 制定并使用简短的检查清单,标准化流程,减少争议。
- 进行代码审查辅导和结对编程。
- 定期举行团队评审走查,以教育为目的共同审查PR。
- 运行并分享度量指标,用数据驱动改进。
- 给予建设性反馈,具体、对事不对人,并赞扬好的代码。
- 设定共同期望,让所有团队成员参与流程,庆祝优秀的PR和有益的审查。
利用AI辅助代码审查
AI可以自动化一些低风险、模式固定的代码转换任务,从而解放人力去关注更复杂的设计问题。例如:
- 将
virtual替换为override。 - 将
typedef替换为using。 - 将迭代器循环转换为范围
for循环。 - 应用
const、noexcept等。
关键是为AI操作设置严格的防护栏,并分步骤进行,避免一次性进行过多转换导致代码混乱。AI在此处是强大的辅助工具,而非决策者。
总结与行动号召
本节课中我们一起学习了如何构建一个平衡质量、速度和可持续性的代码审查流程。
总而言之,代码审查旨在提升代码质量并交付产品。它是一个指导新成员、促进协作的机会。我们应追求基于指南而非个人偏好的客观审查,这能带来更好的解决方案和更高的团队生产力。
最后,请大家记住:代码审查关乎整个流程,而不仅仅是代码本身。我们需要承认并承担起作为审查者的责任。请审视并改进你所在团队的审查流程。

注:本教程根据Pete Muldoon在CppCon 2025的演讲《Mastering the Code Review Process》内容整理,保留了原演讲的每一句话的核心含义,并按照要求进行了结构化、简化和格式优化。
027:使用 Pixi 进行现代 C++ 开发的跨平台包管理


概述
在本教程中,我们将学习如何使用 Pixi,一个强大的跨平台包管理工具,来简化和优化现代 C++ 项目的开发工作流。Pixi 不仅支持 C++,还支持多种语言和工具,并专注于提供可复现的开发环境。
什么是 Pixi?🚀
Pixi 是一个跨平台的包管理工具。它并非专为 C++ 设计,而是一个通用的包管理器,适用于包括 C++、Python、Rust 在内的多种语言。Pixi 的核心优势在于其跨平台性(支持 Windows、macOS 和 Linux)、对二进制和源码依赖的支持,以及对环境可复现性的高度重视。
Pixi 的优势与用例 💡
上一节我们介绍了 Pixi 的基本概念,本节中我们来看看它为何值得关注,以及一些实际的应用案例。
Pixi 可以像 Homebrew、apt-get 或 Winget 等系统包管理器一样工作,但它能管理任何语言、任何操作系统的包。一个关键用例是 FreeCAD,这是一个开源的 C++ CAD 设计程序。使用 Pixi 后,其编译说明从冗长的平台特定指南,简化为几条跨平台命令,极大地简化了贡献者和用户的入门流程。
此外,Pixi 还附带一个名为 Pixi Global 的工具,用于在您的机器上安装和管理开发工具(如 Git、Zed 编辑器、conda 等)。它会将每个工具安装在独立的环境中,避免版本冲突,并且无需激活即可使用。
Pixi 工作空间实战演示 🛠️
了解了 Pixi 的优势后,本节我们将通过一个实际演示来了解其核心工作流程。
Pixi 工作空间的核心是一个名为 pixi.toml 的清单文件。这个文件声明了项目的依赖、目标平台和任务。
以下是 pixi.toml 文件的一个示例结构:
[project]
name = "cpp_con"
channels = ["conda-forge"]
platforms = ["linux-64", "osx-64", "win-64"]
[dependencies]
cmake = "*"
boost = "*"
python = "*"
[tasks]
configure = "cmake -B build"
build = { depends-on = ["configure"], cmd = "cmake --build build" }
test = { depends-on = ["build"], cmd = "cd build && ctest" }
关键操作:
pixi add <package>: 添加依赖包并更新pixi.toml。pixi install: 根据pixi.toml安装所有依赖。pixi update: 更新所有包到最新版本。
任务系统:
Pixi 内置了一个强大的任务运行器。您可以定义任务(如配置、构建、测试),并指定它们之间的依赖关系。Pixi 会自动按正确顺序执行。
运行任务只需使用 pixi run <task-name> 命令。例如,pixi run test 会自动依次运行 configure、build,最后执行 test。
可复现性与日志文件 📄
我们刚刚演示了如何使用 Pixi 任务来组织构建流程。为了实现真正的可复现性,Pixi 在每次安装后都会生成一个详细的 pixi.lock 日志文件。
这个文件记录了所有已安装包的确切来源、版本、哈希值和许可证信息。将此文件提交到版本控制系统(如 Git)后,任何人在任何机器上执行 pixi install 都能获得完全一致的环境。这解决了“在我机器上能运行”的经典问题,并使得回滚到之前可工作的状态变得非常简单。
在 CI/CD 中集成 Pixi 🔄
拥有可复现的本地环境很棒,但如何确保持续集成(CI)环境与本地一致呢?Pixi 让这一切变得简单。
传统的 CI 配置(如 GitHub Actions)需要为每个操作系统编写不同的安装脚本。使用 Pixi 后,CI 配置变得统一且简洁。您只需要一个步骤来安装和设置 Pixi,之后就可以运行与本地完全相同的 pixi run 命令。这消除了本地与 CI 环境之间的差异。
一个简化的 GitHub Actions 步骤示例:
- name: Install pixi and dependencies
run: |
curl -fsSL https://pixi.sh/install.sh | bash
pixi install
- name: Build and test
run: pixi run test
深入 Pixi 工作空间与源码构建 🏗️
前面的章节我们主要关注使用预编译包的环境。Pixi 更强大的功能在于管理源码依赖和构建自己的包。
Pixi 引入了 “源码包” 的概念。您可以在 pixi.toml 中直接依赖一个本地目录或仓库中的 C++ 项目。Pixi 会使用指定的构建后端(如 pixi-build-cmake)自动为其构建一个 conda 包,并将其安装到当前环境中。
一个源码包的 pixi.toml 示例:
[package]
name = "my-cpp-lib"
version = "0.1.0"
[build-system]
requires = ["pixi-build-cmake"]
build-backend = "pixi_build_cmake"
[dependencies]
boost-cpp = "*"
[host-dependencies]
cmake = "*"
构建后端是理解源码构建的关键。它类似于 Python 的 setuptools,是一个知道如何从特定类型源码(如 CMake、Python pyproject.toml、Rust Cargo.toml)构建出标准包的工具。Pixi 会自动下载并运行相应的后端。
当您执行 pixi install 时,Pixi 会:
- 解析所有依赖(包括源码依赖)。
- 为每个源码包创建独立的构建环境。
- 按照正确的依赖图顺序构建源码包。
- 将构建产物作为普通包安装到工作空间。
这种方式支持可编辑安装,并且构建结果会被缓存,后续构建速度极快。
技术栈:Conda-Forge 与 Rebuild 🚄
Pixi 的强大能力建立在成熟的技术栈之上。其默认的包来源是 Conda-Forge,这是一个拥有超过 30,000 个包、由社区管理的庞大仓库。它最初为科学计算和 Python 生态服务,但其底层架构从一开始就为编译 C/C++/Fortran 库而优化,因此非常适合 C++ 开发。

为了提升构建效率,Pixi 的团队开发了 Rebuild 工具,显著加速了 Conda 配方(recipe)的构建过程。它将构建元数据格式简化,并优化了解决依赖和缓存逻辑,使得从源码构建大型包变得更加高效。
总结与快速开始 🎯

本节课中我们一起学习了 Pixi 如何作为一个跨平台包管理器,彻底改变 C++ 项目的开发体验。
核心总结:
- 可复现工作流:通过
pixi.toml和pixi.lock文件,确保环境在任何地方一致。 - 跨平台统一:一套命令和配置适用于所有主流操作系统。
- 隔离与安全:项目环境彼此隔离,避免全局污染。
- 高效的 CI/CD:轻松实现本地与云端开发环境的一致。
- 强大的源码管理:支持将本地项目作为依赖进行构建和分发。
- 丰富的生态:背靠 Conda-Forge 庞大的二进制包仓库。
立即尝试:
访问 Pixi 官网或扫描文档二维码,使用一行命令即可安装 Pixi 单文件二进制程序,开始体验现代化、无忧的 C++ 开发包管理。



教程内容整理自 CppCon 2025 演讲 “使用 Pixi 进行现代 C++ 开发的跨平台包管理”。
028:什么有效、什么会出错及其原因


在本节课中,我们将要学习C++中二进制浮点数(如 float 和 double)的核心概念、工作原理以及常见的陷阱。我们将探讨其背后的数学原理、C++标准库和编译器的实现细节,并了解为什么某些看似简单的操作会产生意想不到的结果。
1. 浮点数基础与表示
浮点数被设计用来同时处理非常小和非常大的数字,并关注相对精度。例如,对于 double 类型,数字1和下一个可表示数字之间的差值约为 10^-16。即使对于10亿这样的数字,其相对误差也大约在 6 * 10^-16 左右。
然而,如果你关心绝对精度,可能会遇到问题。例如,在1和2之间有 2^52 个 double 值,在2和4之间同样有 2^52 个,但分布密度减半。越接近0,数字分布越密集。
浮点数的二进制表示遵循IEEE 754标准。一个数字被转换为二进制指数形式,确保其以“1.”开头,剩余部分称为尾数,再加上指数和符号位。
- 符号位:1位。
- 指数位:若干位(
float为8位,double为11位)。 - 尾数位:剩余位(
float为23位,double为52位)。
其存储格式可以抽象为:
(-1)^sign * 1.mantissa * 2^(exponent - bias)
2. 特殊值:非规范数、无穷大与NaN
上一节我们介绍了浮点数的常规表示,本节中我们来看看几种特殊的浮点数值。
非规范数:在0和最小的正规格化数(std::numeric_limits<double>::min())之间,存在一个“间隙”。为了填补这个间隙并实现渐进下溢,标准引入了非规范数。这使得当两个非常接近的小数相减时,结果不会意外地变成0,从而避免了后续可能的除零错误。
无穷大:在进行可能溢出的计算时很有用。例如,比较 a * b > c,如果 a * b 溢出为无穷大,比较操作仍能按预期工作。标准数学函数也支持无穷大,例如 exp(-∞) = 0,log(0) = -∞,这有助于减少条件分支,写出更简洁的代码。
有符号零:存在 +0 和 -0,它们在比较时相等(+0 == -0)。区别主要体现在某些运算的符号上,例如 1 / +0 得到 +∞,而 1 / -0 得到 -∞。这在中间结果溢出时,有助于保持最终结果的正确符号。
NaN:表示“非数字”,通常由无效的数学运算产生,例如对负数开平方根,或 NaN 参与任何运算。NaN 具有传播性。一个关键特性是 NaN 不等于任何值,包括它自身(NaN != NaN 为真)。这破坏了许多关于比较的常规假设。
3. 舍入规则与编译时舍入
由于浮点数的精度有限,舍入发生在多个环节:编译时、运行时、输入输出时。
默认的舍入模式是舍入到最近的值。当遇到“中间值”(恰好位于两个可表示数字的正中间)时,需要打破平局。以下是几种平局决胜策略:
- 向零舍入
- 向负无穷舍入
- 向正无穷舍入
- 远离零舍入(类似四舍五入)
- 半偶舍入(也称为银行家舍入法)
半偶舍入是IEEE 754的默认策略,它选择尾数最低位为0的那个值。这样做的目的是让舍入误差在统计上相互抵消。例如,对 {0.5, 1.5, 2.5} 应用半偶舍入得到 {0, 2, 2},其和为4,而真实和为4.5,误差为-0.5。若采用“四舍五入”,则得到 {1, 2, 3},和为6,误差为+1.5。
在编译时,当你写下 0.1 这样的字面量时,编译器必须将其舍入为最接近的可表示浮点数。0.1 在二进制中是循环小数,无法精确表示,因此会被舍入。0.1 被略微向上舍入,而 0.3 的字面量实际上被表示为略小于数学上0.3的一个值。
4. 运行时计算与经典问题
在运行时进行浮点运算时,舍入会再次发生。经典的 0.1 + 0.2 != 0.3 问题就是由多重舍入造成的。
0.1和0.2在编译时已被舍入为各自最接近的double近似值。- CPU以无限精度计算这两个近似值的和,得到一个中间结果。
- 这个中间结果同样无法精确表示为
double,因此需要再次舍入到最接近的可表示值。 - 最终得到的结果与数学上的
0.3不同,也与编译时0.3字面量所表示的近似值不同。

因此,直接比较 0.1 + 0.2 和 0.3 会返回 false。

5. 编译器差异与可移植性问题



不同编译器甚至同一编译器的不同设置,可能导致浮点计算结果不一致,这常常让人感觉像是遇到了未定义行为。
额外精度问题:在32位模式下,GCC有时会使用古老的x87 FPU的80位寄存器进行计算,导致中间结果保持了比 double 类型更高的精度。当这个更高精度的结果用于决策(例如转换为整数)时,可能与全程使用64位 double 计算的结果不同。一个简单的重构(如将表达式提取到函数中)就可能改变程序行为。
优化问题:编译器在 -ffast-math 等优化模式下,可能会进行一些在纯数学上正确、但不符合IEEE 754严格舍入规则的变换,这会影响结果的确定性。
标准库函数:对于 +, -, *, /, sqrt() 等基本运算,标准要求精确到最后一个比特。但对于 pow(), sin() 等函数则没有这样的保证。例如,pow(10, 2) 在某些实现中可能不会精确地返回 100。
6. 如何输出与调试浮点数
调试浮点数问题时,正确输出其值至关重要。
默认输出的问题:std::cout 或 printf 的默认格式通常只打印6位有效数字,并会自动舍入和切换为科学计数法。例如,打印 0.1 + 0.2 的结果会显示 0.3,这掩盖了真实值。
如何精确输出:
- 对于
float,需要至少9位十进制数字才能唯一区分所有不同的值。应使用std::numeric_limits<float>::max_digits10。 - 对于
double,需要至少17位十进制数字。应使用std::numeric_limits<double>::max_digits10。 - 最佳实践:使用C++20的
std::format或std::to_chars。它们能自动计算出最短的十进制表示,既能区分该浮点数,又便于阅读。例如,对于精确的0.3,输出“0.3”;对于0.1+0.2的结果,则会输出足够多的小数位以显示其与0.3的区别。
输出特殊值:无穷大打印为 “inf” 或 “infinity”。NaN的打印格式多样,可能包含符号和负载信息,如 “-nan(ind)”。注意,不同平台或CPU架构可能为同一运算产生不同负载的NaN,导致其字符串表示不同,影响跨平台一致性。
7. 数值计算中的陷阱与建议
了解了原理后,我们来看看实践中常见的陷阱和应对策略。
避免“魔法ε”比较:简单地用 fabs(a - b) < epsilon 来比较浮点数并不总是有效,因为合适的 epsilon 值取决于具体应用场景和数值范围。std::numeric_limits<double>::epsilon 是1与大于1的最小浮点数之差,它只适用于1附近的比较,并非万能。
排序与容器:直接将包含NaN的浮点数数组进行 std::sort 或放入 std::set 会导致未定义行为,因为NaN破坏了严格弱序关系。在排序前需要过滤或特殊处理NaN。
选择更稳定的公式:
- 计算三角形面积:避免使用海伦公式,应使用向量叉积。
- 计算点的极角:使用
atan2(y, x),而非acos或asin。 - 计算幂:
x * x比pow(x, 2)更快且更精确。 - 连加运算:对于大量数据求和,考虑使用Kahan求和算法来补偿累积误差。
向指定小数位舍入:如果直接将一个浮点数格式化为两位小数,由于该浮点数本身已是近似值,可能无法正确应用“四舍六入五成双”的规则。可靠的方法是:在知晓原始数据精度(如知道有3位小数)的前提下,将所有数值转换为整数(乘以1000),在整数域进行舍入计算,最后再转换回来输出。
8. C++语言特性相关陷阱
C++本身的一些特性也会与浮点数相互作用,产生微妙问题。
函数重载:如果你包含了 <cmath>,需要注意有 std::abs(针对浮点数)和C语言继承来的 abs(针对整数)。如果误用了整数版本的 abs 来处理浮点数,会导致意外的取整操作。建议使用 std::fabs,它只针对浮点类型,意图更明确。
浮点与整型转换:
- 浮点转整型:使用
static_cast<int>(a_double)会截断小数部分。如果浮点值超出目标整型范围,行为是未定义的(可能崩溃或产生无意义结果)。 - 整型转浮点:如果整数值无法在浮点类型中精确表示,转换结果是实现定义的(编译器决定如何舍入)。虽然GCC和MSVC文档说会“舍入到最近”,但未明确平局决胜规则。
性能问题:非规范数:在某些CPU上,处理非规范数的速度可能比处理规范数慢数十倍。如果你的应用程序意外产生了大量非规范数,性能会急剧下降。某些编译器和CPU架构支持 -ftz(刷新到零)等选项,将非规范数当作零处理以提高速度,但这会改变数值语义。
复数 std::complex:C++标准库中的 std::complex 为了保证对无穷大、NaN等特殊值的正确处理,可能比手写的、不考虑特殊情况的复数类慢很多(测试中观察到30%-50%的差异)。如果不需要处理这些特殊情况,可以考虑使用自定义实现。
9. 综合案例:安全比较整型与浮点型
假设我们需要将一个混合了 long long 和 double 的列表进行排序。一个朴素的泛型比较器会先将 long long 转换为 double,再进行比较。这会导致问题:多个不同的 long long 值可能被转换为同一个 double 近似值,从而破坏比较的传递性。
解决方案是根据数值范围分情况处理:
- 如果
double值d的绝对值较小,处于所有整数都能被double精确表示的范围内,则将long long转换为double进行比较。 - 如果
d的绝对值很大,超出了long long的范围,或者其附近整数在double中表示有间隔,则将double转换为long long进行比较(需注意检查NaN和溢出)。
这需要仔细处理边界条件,例如 LLONG_MAX 可能无法被 double 精确表示。一个健壮的实现远比看起来复杂。
10. 总结与最佳实践
本节课中我们一起学习了C++浮点数的复杂世界。以下是关键总结和最佳实践:
- 理解数据:明确你的输入数据是精确值(如来自整型的转换)还是近似值(如用户输入或计算产生)。明确输出需要怎样的精度和格式。
- 优先使用整型:许多问题(如判断平行、求总和)用整型可以避免浮点数的所有麻烦。
- 接受近似性:理解浮点运算是近似计算,避免做出基于绝对相等的逻辑决策。
- 使用稳定公式:在提高精度之前,先考虑使用数值稳定性更好的数学公式。
- 谨慎对待编译器:知晓编译器的优化标志(如
-ffast-math)和额外精度可能带来的影响。对于关键的数字算法,可能需要检查生成的汇编代码。 - 充分测试:测试应覆盖边界情况,如0、无穷大、NaN、非规范数。考虑使用
float进行穷举测试(因为状态空间较小)。随机测试有帮助,但需补充针对算法特定决策点的针对性测试。 - 利用库:对于复杂数学计算,优先使用成熟的数值库,它们通常实现了经过充分验证的、稳定高效的算法。
- 输出时使用
max_digits10或std::format:以确保能够唯一标识和调试浮点数值。



浮点数是一个强大的工具,但需要尊重其特性。通过理解其工作原理和潜在陷阱,你可以更有效地利用它们,并避免程序中出现令人头疼的数值错误。
029:C++定义十年的火箭引擎


概述
在本节课中,我们将学习C++26中引入的静态反射功能。反射允许程序在编译时查看和生成自身代码,这为元编程开启了全新的可能性。我们将从基础概念开始,逐步探索反射的实际应用,并展望其未来潜力。
章节1:反射基础与工具

反射的核心定义是:程序能够查看和生成自身。通常,“反射”一词涵盖了读取和生成两部分,有时我们也会用“反射”特指读取部分,用“生成”特指写入部分。因此,元程序就是操作程序的程序。
据我所知,我们刚刚加入C++标准的反射功能,在商业语言中是独一无二的,属于顶尖水平。这是版本1,我们将在C++26之后继续构建它。但即使是版本1,功能也非常丰富。如果你在其他语言中使用过反射,那很好。我并非贬低它们,它们为反射铺平了道路,并展示了反射的实用性。

但请注意,这不是运行时反射,而是静态的、编译时反射。当然,如果你计算了某些内容并想在运行时存储它,默认情况下是零开销的。但如果你想在运行时存储某些内容,可以使用从定义静态数组、定义静态字符串开始的工具,那是你转换到运行时数据的函数。
除了普通的for循环,我们还有一个模板for循环。和往常一样,“模板”意味着编译时,也意味着展开循环体。当你实例化一个模板时,每个特化基本上都是展开的,你可以在循环体中得到不同的类型和不同的重载解析。模板for循环也是如此,因为你实际上需要这个功能,但使用起来其实很简单。
当然,如果你这样做,你可能需要一个常量函数来迭代数据成员。然后,我们有双尖括号<< >>(或称为“猫耳朵”)反射运算符来反射类型。
这里有一个简单的例子,它接受一个类型并遍历其成员,这是我们以前无法做到的,然后简单地打印出它们的名字。
template <typename T>
void print_member_names() {
template for (auto member : <<T>>) {
std::cout << member.name() << std::endl;
}
}
章节2:编译时性能考量
现在,我想解决房间里的大象。在反射出现之前,有多少人已经担心编译时间了?我看到很多人举手了。有多少人害怕所有关于编译时间的讨论?是的。
答案就在幻灯片上:我们即将看到C++编译时代码的巨大爆炸式增长。总的来说,这通常会使你的程序更快,构建也更快。原因如下:
因为这不仅仅是增加东西。如果你在做一些新的事情,当然会有额外的成本。但如果你是在替换今天用模板元编程做的事情呢?有多少人已经有过这样的经验:将一些用模板元编程计算的东西,改用constexpr函数(它更可读、更可调试,因为它只是代码),然后发现编译速度快了一个数量级?相当多的人举手了。
这是因为你直接表达了你的意图,而不是通过一个并非为计算而设计的类型系统和特化来表达计算。相反,你将自己表达为代码。猜猜看,优化器喜欢代码。它们看到代码,就会自动抓住并优化它。调试器也能工作。这是一个好处,也是为什么我们已经看到使用constexpr代码带来的速度提升。
同样的事情也适用于我们稍后会讨论的辅助编译器:当我们简化工具链,在编译器中做更多事情,而不是重复大部分可能是正确的解析时,我们可以摆脱所有这些,从而获得更快的构建。
章节3:当前可用的反射功能
我们将进行相当多的演示。祝我们好运。我会一直这么说,因为演示的“恶魔”总是在附近。我将展示三种实现。前两种是Dan Katz的Clang实现和David Vandevoorde的EDG实现,它们实现了当前草案标准的内容,加上一些扩展,因为它们拥有标准内容的超集。
我将展示C++26中的例子,但也会展示在近期和中期未来可以期待的例子。对于其中一些,我将切换到我的CPP2编译器,它同样是C++,只是语法不同,但它编译成100%可移植的C++,可以在过去十年的每个C++编译器上运行。但它的反射实现更超前。所以我需要用它,因为我在其中实现了比原型中更多的反射功能。但这都是标准的一部分。这里没有混淆,我们谈论的是C++本身的未来。
在看了工具之后,让我们开始看看我们今天能做什么。在本节中,我将讨论仅使用我们已添加到C++26中的功能就能做的事情。截至六月,它仍然非常新鲜。之后,我们将看看在C++26之后可以做的事情,但在更近的时期,比如可能进入C++29。然后我想带我们看得更远,看看这将如何影响长期发展轨迹,不仅仅是C++,还有其他语言和我们的行业。因为我们不常有机会通过向语言添加功能来做到这一点。
这是一个路线图草图。我会回到这张幻灯片,并突出显示不同的部分,因为反射(读取部分)和生成(写入部分)都有。我们现在已经在C++26中标准化了版本1的一个子集。我们将添加更多,但稍后再谈。目前,我们已经添加了对命名空间、类的反射。我们甚至得到了函数和参数,以及称为“注解”的东西,它们基本上看起来很像属性,但在属性内部,它们以等号开头。我会尽量记得演示其中一个。
我们在同一翻译单元中有一些生成功能,使用拼接运算符[: ... :]。但只要在单独的文件中,我们就可以生成任何我们想要的东西,因为我们有ofstream,我们有cout,我们知道如何向文件发送文本和二进制信息。所以,我们还没有在同一翻译单元编译时完全实现生成功能。这即将到来。但仅通过使用反射并生成文件,我们已经可以做很多事情了。
章节4:热身示例:嵌入JSON
这是一个使用#embed功能和拼接运算符的热身示例。你会在这里看到Godbolt链接。让我们实际切换到那个链接。如果这能成功,我这次演讲的其余部分机会就大得多。如果演示不成功,我还有幻灯片。但现场演示能让我们相信它是真实的,对吧?
这里我们有一个用C++ Builder设置的Godbolt,它是一个多文件设置。我们要做的第一件事是,通过#embed test.json,创建一个JSON数据变量,你可以把那个文件粘贴到这里,本质上就是这样。
你为什么要这样做?你可能想单独签入那个文件。那个文件可能不仅仅是我的C++程序的唯一真实来源,也可能是系统其他部分的唯一真实来源。你不想复制它,只想签入一次。事实上,它就在下面。我们可以看到它是一个JSON文件,有一个名为magic、类型为string的条目,然后是一个嵌套的结构体,里面有word(字符串类型)和number(整数类型)的条目。
然后,我们将调用这个函数。我不会逐步讲解它。如果你想看答案,在这个Godbolt示例中,这行代码上面有150行代码。请查看代码。这个函数解析我们刚刚通过#embed嵌入的JSON数据,并做一些非常棒的事情:它创建一个全新的结构体,一个计算生成的、与运行时输入(更准确地说是编译时输入)匹配的数据类型,这样我们就可以像访问数据成员一样访问v.magic,因为它就是;也可以访问嵌套的数据成员v.inner.word,因为它也是。然后我们就能按预期打印出这些东西。
现在,我想指出一点。因为我们有新的反射运算符(双尖括号运算符)和拼接运算符,有些人想知道,所有这些“乱七八糟”的东西会不会污染我的代码?实际上并没有那么糟,至少现在还没有。但这类东西自然会放在库中。这里有一个如何做到这一点的例子。我在这里放这个拼接,是因为我想向你展示拼接。但你也可以直接调用一个常规模板,在这个例子中是一个变量模板。所以现在调用点就像我们今天在C++中期望的那样。在那个变量模板内部,我们进行拼接。它也可以是一个函数模板。所以你可以把反射运算符隐藏在函数模板里。这样,你就不必让你的用户知道反射或拼接,你可以把它们放在库里,因为它只是代码。
最后一点,为什么不呢?因为我们可以。我们还有这个static_assert。但我想带你看看这个做了什么。这是一个原始字符串字面量,它基本上打印一些信息,并验证它以JSON格式打印了一些信息。然后我们使用一个用户定义字面量(C++已经支持),将其转换为一个对象。但由于它可以使用反射,那个用户定义字面量会调用反射,创建结构体,我们可以立即使用这个计算生成类型的成员。
这很好,它让我们热身,了解我们能做什么。
章节5:命令行选项解析器
现在,让我们看一个稍微高级一点的例子,但它仍然很简单,所以我们可以实时讲解:一个命令行选项解析器。
我们都用过命令行选项解析器。这个有点不同。parse_options<my_opts>看起来就像一个普通的模板调用,因为它就是。但请注意,它传递的是选项结构体,并且只传递选项结构体,没有传递单独的字符串名称。它只是说,这是结构体,去填充它。只需查看结构体,我告诉了你标志、名称和它们的类型。直接去填充它。
让我们看看这是什么样子。这里我们有那个结构体。我们可以看到,是的,事实上,这行得通。如果我们有命令行(命令行的文字我几乎看不清),是--count 42,文件名未指定,所以它使用默认值。如果我们省略文件名,它也会默认,因为我们为文件名设置了默认值。这一切都很好。
那么这里到底发生了什么?让我们向上滚动。当我们向上滚动时,我们会看到parse_options接受其模板参数。它做了什么?类型本身就是一个输入。所以我们将使用一个模板for循环,它将展开对反射类型的每个非静态数据成员的访问。请注意,这里我们把反射放在了函数模板内部,所以它没有到处泄漏。不是每个人都需要写双尖括号。这取决于你把它放在哪里。
然后我们简单地遍历,寻找以--开头的命令行选项,即标志名。如果找到,那么我们尝试创建一个字符串流,并将该值流入opts.。然后你看到第36行的拼接了吗?流入opts.数据成员。在一次实例化中,这将是opts.count,在另一次实例化中,这将是opts.fname,类型是正确的。你可以看到为什么我们像模板一样展开它,因为每个循环体中的类型实际上可能不同。这是类型安全的。我们所做的只是遍历成员,搜索它们的名称。哦,看,一个名字匹配了。然后尝试流入那个成员。这一切都是类型安全且正确的。事实上,如果我在这里尝试说--count x42,哦,错误:为--count提供的值不是有效的int。我们得到这条消息是因为我们实际上显示了友好的名称,即使编译器可能把I作为类型ID的名称,我们通过所有这些并说,给我们那个数据成员类型的真实、友好的源名称显示字符串。所以你得到的不是混乱的编码,而是用户能理解的实际源类型。
为了进一步证明这是实时的,让我们添加另一个数据成员,另一个我们可以添加的东西。一旦编译完成,我们仍然想修正我们的计数值。但现在,假设我们想放,哦,看,现在opts.pi默认降到了3.14159,正如它应该的那样。你知道,它是一个float。别因为我用float表示π而责备我,因为情况即将变得更糟。假设我们在1897年的印第安纳州,我们说pi = 3.2。哦,我打对了吗?它太小了,我看不清。有人看到我打错了吗?哦,没有等号。对了。现在我们符合1897年印第安纳州几乎通过法律的规定。如果你不相信我,去查一下。就差那么一点,尽管参议院大部分时间都在嘲笑它,这很好。
章节6:类型擦除包装器
这是一个更长的例子。我将做一个“不可能的任务”式的即兴表演,称之为“可绘制任务”。我们想要一个类型擦除的包装器drawable,它可以包装任何具有大致draw函数的对象,该函数可以接受一个坐标或可转换为坐标的东西,并返回一个int或可转换为int的东西。所以,如果你考虑参数和返回值的转换,这就像std::function所做的那样。所以它不必完全是这样,但必须与该接口兼容。

因为我以前没写过这样的东西,我想看看AI有多好。我让ChatGPT来做。它写了200行代码,然后我调整了一下。我不假装理解所有内容,但它似乎区分了const和非const。你们中做过这个的人会告诉我它是否正确。它似乎能工作。我测试并调整了它。现在,是的,事实上,你可以有一个drawable的vector,放入sprite、icon、button。这是三个不相关的类型,就在这里。把它们放进去,然后对每个多态地调用draw。它工作了。你怎么做这个?Drawable内部的核心数据结构是什么?有人喊出来。虚函数表,是的。这被称为如何编写你自己的虚函数表。
让我们看看ChatGPT在我的一点帮助下做了什么。我不会逐行讲解所有代码。这是超过200行的代码。但在这里,我想首先向你证明,事实上,我袖子里没有藏东西。这对中间和后面的人来说够大吗?是的,哦,即使在后面,哦,我想要你的视力,我真羡慕。

好的,我们正在做drawable的规范。让我向上翻页,只是为了向你证明,事实上,sprite、icon和button都是不相关的类型,没有继承的把戏。事实上,其中一些有我们正在寻找的确切函数,在这个例子中是button。它接受一个point,但point可以从一个coordinate参数转换而来,它返回一个short,可以转换为int。所有这些都兼容,并且它们都能工作。你可能以前在这里见过这样的演示。让我把它拉过来,这样你就能真正看到。事实上,是的,在GCC、Clang、Microsoft上,所有这些都能正确工作。
到目前为止还没有反射。我展示这个例子的原因是为了教你。就像我学习它时一样,我的整个职业生涯,我都是在学习的过程中写下这些东西。这里的情况完全一样。
现在,我们如何概括这个?简短的答案是:让手动编写的普通情况正常工作,剪切并粘贴它,在所有东西周围加上引号,然后替换你想要的名称,并在成员函数或数据成员变化的地方放入循环。实际上就是这么简单。这个完整的端到端例子花了我两个小时,从一张白纸开始,到ChatGPT,到修复代码,再到编写反射代码。我以前写过反射代码,但我对它的速度之快感到惊讶。
让我向你展示那是什么样子,因为在这里,我们有这个代码。如果你把这个代码放在一边,我们现在可以看到反射代码。首先,我将向你展示反射代码的输出。反射代码在底部做了什么?也许我会先展示底部。滚动,滚动,滚动,滚动,滚动,滚动,滚动。它所做的就是接受一个示例类drawable,它具有我们想要包装的签名(可能不止一个,但现在只有一个),然后调用一个编译时函数poly(我不知道该叫什么,所以叫多态类型擦除器poly,差不多)。它将以此作为输入,然后我们将生成本质上与右侧相同的代码,通过剪切粘贴进行测试,是的,它能工作。
所以这里是包含文件、类drawable、我们在文件顶部手动编写的各种概念等。但当我们转到左侧的文件时,我特别想提请你注意这个例子,看看虚函数表是如何工作的。所以这里是虚函数表。它在文件的中间。你可以用滚动条看到它有多大。所以我们在文件的中间。大部分是类d。这里的左侧是手写的。注意我们是如何手写虚函数表的,它有两个条目:析构函数和复制函数,无论你擦除什么类型,这些都是一样的。这种类型擦除的东西需要构造和复制。所以这不是每个函数都有的,只是为了让我的类型擦除包装器能够构造。
这是每个函数都有的部分。所以如果我包装了一个有两个函数的东西,我在这部分会有双倍的条目。让我们看看我们是怎么做的。实际的……让我先展示我们确实生成了那个。滚动,滚动。我想我们快到了。不,那是问题所在。还有一个我们生成的自由函数。好吧,这里的某个地方是虚函数表。相信我,它在那里。很难找到。我会再找一次。不,你知道吗,我真的很想看到它,因为我想向你展示它是如何变化的。所以当你看到v时停下来。它在那里。那是虚函数表。好的,注意它生成了与右侧相同的东西。但这些都是生成的。当然,它不在同一个文件中。它被输出到另一个C++文件,然后我们可以在同一个构建中使用它,如果我们想的话。但我们必须从不同的文件中使用它。我们还没有实现同一翻译单元内的源代码生成。
好的,让我们看看这段代码。如果我们看看我们必须编写的代码,让我们看看poly函数本身。它是一个简单的函数,接受原型并返回一个字符串。你可以看到它首先在我们另一个文件中的所有东西周围加上引号,包括依赖项和所有内容,这是你的构造函数。当你到达其中一些东西时,你看到,我只是复制粘贴了文件。然后,哦,我看到drawable的地方,我只需全局搜索或替换,把drawable改成引号加部件名加引号。这很容易。你对所有东西都这样做。
现在,有些东西是每个成员函数都有的。所以我想在这里提一下为什么我这样做。你会注意到,到目前为止,我向你展示的这个函数只是一个普通的运行时函数。它甚至不是constexpr函数。它只是一个做字符串处理的普通运行时函数。但我必须用这个反射原型调用poly_impl,现在它将使用反射信息。
我的初始实现是把所有这些,包括这个不需要是编译时的函数,也做成constexpr。在这个例子中,我遇到了Clang对constexpr操作的限制,因为所有的字符串。所以你有很多理由。我相信随着我们给它们压力,编译器会增加它们的限制。但你有理由在普通代码中做很多事情,然后将需要的部分委托给反射代码。这是一个很好的卫生习惯的例子。
如果我们快速看一下,这是处理每个成员并输出所有字符串片段的函数。它所做的就是:这将对每个成员调用一次,它遍历该成员函数的参数,记住它的返回类型和参数,然后生成我们看到的所有那些东西。这里有一大堆东西,这些片段将由我们已经看到的、组装它们的最终调用者放在正确的位置。

这样做的一个好处是,如果我们直接回到底部,如果我现在想改变这个,让我们看看它。我想现在改变这个,有另一个函数。我们等它重建。让我们看看新的虚函数表。现在我有一个包含draw和get_name的虚函数表。所以现在我可以包装任何具有draw和get_name(参数和返回类型可转换)的东西。我没有写任何代码。
所以这非常非常有用,并说明了我们将能够做的那种事情。我真的很想强调,手写版本需要200行代码来让drawable工作。现在我有300行代码。手写代码就在那里。你们视力真好。这是对后排的测试。有手吗?不,我想没有。所有那些现在都只是生成的。我写了300行代码,它将为我将来想要的任何类型擦除的东西做所有这些。我再也不用写它了。我可以调试它,因为它是代码。它一开始会有代码生成错误,但它只是代码。我们会调试它们。当它工作时,我们就发布它。
章节7:元类与接口生成
八年前,在这个舞台上,我相信我首次现场演示了元类论文P0707,它展示了如何使用反射来帮助我们更轻松、更正确地生成C++类型。我首先在ACC的舞台上以幻灯片形式展示。我相信第一次演示是在这个舞台上。这里有一个快速的复习例子,我以前讲过,所以我会很快。
想法是:今天我们在左边写样板代码。如果我们能直接说,嘿,我写的不是任何类,我写的是一个接口,这意味着我想要某些默认值,那会怎样?所有函数默认都是纯虚的,即使我没有写。那只是默认值。我自动得到一个虚析构函数,即使我没有手写。我自动抑制复制和移动,诸如此类。
这就是我们今天在C++26中可以实现的样子。我们可以做到。它只需要进入一个单独的文件,然后我们从那个单独的文件中使用它。
让我在Godbolt上快速展示一下,表明它已经完成了。这是我想记住向你展示的另一件事。我们不吃你。我们不是你。好了。
这里我们有类widget。我们把它用作原型。我在它上面调用interface元函数。看,这里,它生成了我们看到的所有东西。我不会逐步讲解。到现在,这是一个简单的例子,因为我们已经见过更难的了。我想向你展示的是,嘿,如果我们实际添加另一个函数,比如float h(double),哦,看,它也出现了。现在我们处理了那个。所以我向你证明这是实时的。这实际上是在反射并生成应用了默认值(虚和纯虚)的正确最终函数。
但现在,假设我们走得更远一点。假设你是这个编译时函数interface的作者。你会说,伙计,如果我能给用户一种方式说,哦,我所有的函数,除了那个,我不想那个成为接口的一部分,那该多好。我只是编一个稻草人例子。你怎么做?

你可以在C++26中做到,不需要任何更多的语言功能,因为你可以创建一个自定义注解。注解在标准中。它看起来是这样的。我可以直接写。在这个代码中,用户可以只写= suppress,现在注意在右侧,g消失了。这是魔法吗?标准中有什么我可以谷歌的suppress特性吗?不,标准中没有任何关于抑制的内容。所有的一切就是,在这段代码中,当我遍历所有成员函数时,我实际上是在查看注解,并说,哦,我想看到所有不是特殊成员函数且没有suppress注解的函数。suppress注解是什么?我刚刚编了一个。它只是一个类型。就是这么容易。现在你写了一个元函数,你开始为你的用户提供一个API,从你的调用站点向你的元函数提供信息。这非常有用。
到目前为止,这都是C++26。未来我们能做什么?反射将走向何方?同样,当我们超越这里时,更多的是假设性的。但已经有提案和实现摆在桌面上,可以添加到你的翻译单元中,同时编译,这样我们已经做过的那些事情,比如接口,实际上可以在同一翻译单元内使用。
这张幻灯片现在在EDG编译器上通过这个Godbolt链接是可能的。它将生成完全相同的输出。注意,关键是我们从同一个文件内部生成这个。这种模式你会经常看到:我把我的示例、原型放在某个子命名空间中,然后我反射它,调用一个编译时函数来反射它并做一些事情。通常是为了反射它并为我制作一个修改后的版本来使用。所以我把原始的、不完整的放在某个子命名空间中,我反射它并制作它,当我完成时,就是完成的版本。


这将变得如此普遍,以至于我提议,从八年前开始,继续提议为它提供一个语法糖:class interface widget正是且仅仅是那个的语法糖。我已经在CPP2中实现了相同的东西。它是一种从左到右的替代语法,但做的事情完全一样。一个接口类型。所有这些,包括现在Godbolt上的EDG编译器和带有这个例子的CPP2,都生成完全相同的、诚实的、可移植的、100% C++代码类型,可以在每个编译器上工作。编译时的东西它们能工作,不是每个编译器都能工作,但创建的类型可以在每个编译器上工作。



我已经实现了一堆这样的东西。interface是第一个。多态基类有序值结构体、枚举、标志枚举。我们可以使用class enum,它只是反射并使所有东西成为constexpr函数。flag enum做同样的事情并添加位运算符。我们可以使用C语言枚举以实现与C的兼容性。
但展望未来,我个人认为,一旦我们有了生成功能,类前缀和语法(委员会SG7已临时鼓励将其作为那四行代码的语法),我认为我们再也不会写一个裸露的class了。为什么我们要写?我们会说我们在写什么类。我写的不是任何类,我写的是一个接口。你知道,一旦你这样做并表达了你的意图,你也再也不会写= default和= delete来摆脱C++生成的你不想要的函数,或者恢复一个被抑制但你确实想要的函数。因为今天,我们有一个硬编码到编译器中的、适用于所有类类型的元函数,因为语言猜测你可能想要什么。但我写的不是一个值类型,我写的是一个接口,一个代理,一个异步函数,或者其他东西。
所以我只是从我八年前写的那篇元类论文的底部读起,它现在终于要成为现实了。每一小节都让我如此兴奋,因为我展示了当时无法编译的代码,但大部分是实际代码,旨在用于那些事情。第3节的每个小节都相当于一个重要的语言特性,在其他语言中,这将是一个语言特性,否则需要自己的EWG演进论文,并硬编码到语言中。但在这里,可以表达为一个通常很小的库,可以通过库演进工作组。
例如,这篇论文首先演示了如何用10行C++ constexpr反射代码实现Java/C#接口,并获得与Java和C#等语言中内置语言特性相同的表现力、优雅性和效率,在那里它们被指定为20页的文本供人类编译器编写者去实现。10行函数。20页文本,我八年前在舞台上用现场演示展示了。现在我们可以在草案标准中做到,而且更多。
所以反射将有助于简化C++进一步演进的需求。我们仍然会有反射不直接影响的语言特性。但许多本应是语言特性的东西,我们现在可以用反射作为库来实现,而且可以做得很好。这意味着我们可以测试和修复它们。我们有可以立即移植的代码,而不必等待你的编译器供应商全部实现它。通过GitHub和包管理器交付更快,更容易定制和适应分叉。

章节8:反射的潜在应用
那么我们能用它做什么?我们还不知道。但这里有一个初步的想法列表。

如果我生成C++文件,我可以为C++类型生成序列化器/反序列化器。只是普通的C++代码作为反射。我可以输出C++代码。我可以输出Python代码。我可以输出JavaScript代码。并反射类型的结构,为另一种语言编写包装器。
如果我能输出文件,我可以输出二进制文件,比如当C++/CX辅助编译器需要为C++类型生成的WinMD二进制信息。我可以自定义类的对象布局,比如class compressed。你们中有多少人手动做过这个,并希望有一种不用模板元编程就能做到的方法?汤姆·福利。

生成单元测试函数。只需遍历命名空间,找到每个名为单元测试的函数,并生成一个运行所有测试的函数。而不是在Python脚本中做,只需用几行C++代码编写。
使类的接口异步。所以class async可能在其所有成员函数调用上运行发送者-接收者线程池。然后这些函数的返回类型可能从T变为future<T>。你已经可以想到如何编写它。为什么?因为你知道如何编写C++代码。如果你能编写它,你就能自动化它。
所有这些,可移植,可测试,可共享,标准的C++代码,没有辅助编译器,没有脚本,没有语言扩展。
这是一个预测。如果我错了,我欠你们所有人一杯啤酒。我真的希望我没有错,不仅仅是因为啤酒。我们现在正走在一条直接的道路上,能够(无论供应商是否这样做)在下一个十年内淘汰辅助编译器和像C++/CX这样的构建系统,这些系统必须添加我们直到现在才能在C++源代码中拥有的信息,但有了反射就可以。淘汰C++/CLI,淘汰C++/CX。我想我应该知道并能够这么说,因为我领导了其中两个的设计:C++/CLI,C++/CX。我因此受到了很多批评:你为什么要扩展C++?你是想用一些专有的东西接管C++吗?不,只是我们需要购买.NET,我们需要购买Windows COM,我们还不能用C++表达我们需要的所有信息。这就是为什么我一直如此努力地推动反射作为一个方向,这样我就永远不需要,正如我八年前公开说的,再发明C++/CLI或C++/CX了。
我让ChatGPT修复了表情符号。这是输入/输出表情符号。很好。但我觉得它需要一个微笑。所以谢谢你,ChatGPT。
关键要记住,如果你在想,我能用反射做什么?简单的答案:如果你能手动编写代码,你就能编写代码来生成它作为字符串和标记。
所以,如果你有一个相当于准确的、始终最新的C++解析器,可供你的代码使用,你会做什么?我们都即将找出答案。
章节9:语句与表达式反射
我们都即将找出那个问题的答案。现在,让我们看看语句和表达式,这是下一件事。因为注意,我们已经可以用命名空间、类、函数、参数、注解做很多事情了。这在版本1中已经很多了。紧随其后的是语句和表达式。这些我必须在CPP2中展示,因为我们还没有在Dan或David的实现中实现对这些东西的反射。所以这是CPP2领先的地方。
但我想向你展示一个叫做自动微分的东西。为此,我想邀请Max Sagebaum上台。请给他掌声。

谢谢邀请我。谢谢邀请我。我很高兴你今年能来,因为去年同一周你在芝加哥参加自动微分会议,做了几个演讲。那是因为Max是世界自动微分专家之一。所以让他来介绍很多材料比我更好,因为我必须假装我听过,总结我听到的。他懂这些东西,包括他实现了今天存在的主要自动微分库之一,并了解其中的权衡。
所以让我们谈谈它是什么,因为到现在,你们中有些人可能在想,你一直在用自动微分这个词。我知道像Maple中的符号微分。我记得在学校做过。我也知道数值微分,我们取epsilon,让delta越来越小,做近似来求导数。这里有什么不同?

自动微分或算法微分是一种数学理论,你基本上假设可以将计算机程序分离成小的基本函数。这基本上就是编译器总是做的。对于每个这些简单函数,我们知道导数。然后通过应用链式法则和方向导数,我们可以产生一个数学级数,说我们可以计算导数。这就是我们如何将其应用于计算机代码。
这比符号微分和数值微分更好,正如幻灯片总结的,因为符号微分准确,但会爆炸性增长,速度慢,且不具扩展性;数值微分可计算,但也不具扩展性,而且你会得到舍入误差之类的东西。算法微分总是能给你数值精确的导数。顺便说一下,关于选项3的一点是,它在数值上是精确的,并且是线性时间的,比运行原始函数多一个常数因子。对于前向模式,常数因子大约是2到3。是的,对于反向模式,大约是2到4。

那么用这个例子向我们展示前向模式。我们有y = sin(x) * x^2。是的,我们首先将其分离为基本函数。所以我们有sin(x),x^2,然后我们将这两个相乘。所以这基本上是具体化临时变量。然后我们可以计算导数。sin的导数是cos,很简单,x^2的导数是2x。然后我们将这两个相乘。所以y对v的导数是u,所以我们写u乘以,然后u对v的导数。就这样。另一件事是v对u的导数。
我们没有提到的一件事是,这种自动微分的一个优点是,你可以有带控制流(分支、循环)的函数,这些你无法轻易地表示为符号函数。这绝对正确。通常你可以支持它。有些东西可能相当困难,取决于你使用什么自动微分工具,但通常你可以支持它。
那么如果有多个输入和输出呢?这只是一个从x到y的函数。如果有更多呢?对于前向模式,如果你只有一个输入,你可以有任意多个输出。你只需计算一个,然后你就有了输出的完整梯度。对于反向模式,但反过来,当你有,例如,在AI模型训练中,你有数十亿的输入,但通常只有一个输出。那么你将不得不运行这个数十亿次,这将花费一些时间。你可以通过使用向量模式来减少常数向量。但如果你使用反向模式,你可以说,好的,我运行这个一次,存储一些数据,然后重用我存储的东西,运行反向传递,然后你在一次传递中获得完整的梯度。有一点内存开销,你需要存储,但通常你可以管理。
所以前向模式在输入少于输出时很好。它是线性的。我方向说对了吗?是的,没错。但那样不会扩展。如果你有十亿个输入,你将不得不做一百万次。然后你想要反向模式,你进行一次前向传递,计算你然后向后导航的数据结构,但它是线性的。然后你一次得到所有输入的导数,仍然是线性的。是的,是的。
现在,如果你想要二阶导数、三阶导数、n阶导数呢?算法微分的一个优点是,你有一个代码,你应用它,你得到一个不同的代码。然后你可以一次又一次地应用它,你可以有任意高阶的导数。但问题是,通常这不是你想要的,因为它非常通用。你会得到很多你必须指定的导数值或导数方向。你可以做的是有一个泰勒实现,然后使用这些泰勒实现,这将减少你需要指定的值的数量和方向。在99%的情况下,这就是你想要的。
总结一下,我认为最大的收获是:它只是代码。你有关于如何优化它的白板讨论。它只是代码。
那么我们为什么要关心?这出现在任何重要领域吗?我们在这个会议上听到了一点。它用于训练AI。所以如果你听说过反向传播,那基本上就是算法微分的反向模式。科学软件也经常使用这个。是的。
但你可以手动编写这些导数。事实上,你告诉我,这些天,人们确实手动编写导数代码。是的,仍然这样做。我必须坦白,如果你想拥有最快的导数,这是你应该做的方式。但你的原始代码需要一年时间来开发,然后需要另一年甚至两年来编写导数代码,因为它通常更复杂。然后是维护方面,你在原始代码中改变了什么,然后你必须更新你的导数代码以使其相同或计算相同的东西。算法微分有一个初始成本,但你必须应用它,然后你可以在一秒钟内对你的代码运行AD,并得到导数,这些导数不是最优的。但然后你可以分析你的导数评估或计算,找到热点,然后优化那个压力大的热点。然后你将拥有近乎最优的导数计算器。
一个热点是线性系统。所以你不想在黑盒模式下做这个,因为它可能产生更长的导数,这很容易优化。
所以现在,我们有运算符重载库,它有局限性。顺便说一下,CoDiPack是你的,你参与的那个。有自定义编译器和分支,比如Tapenade和基于Clang的Clad。但正如你所说,问题是,如果你有一个定制的编译器,你将如何在生产中使用它?我使用GCC,我不会采用你的Clang分支,即使我使用Clang,我也需要生产质量的东西。
那么,既然你实际上已经在CPP2中用C++2编写了自动微分,并将其作为一个库,你会如何总结好处?感觉如何?有什么不同?这就是你在演讲中已经告诉我们的。你现在手头有一个最新的C++解析器或C++反射解析器。拥有一个最新的东西的好处是,如果你有一个好的API,你总是知道如何查询下一个东西。你知道,我有一个函数,从一个函数,我知道,好的,可以查询输入参数,我可以查询输出值,然后我可以为AD查询函数体的语句,然后每个语句的表达式。希望在C++29中,但知道你想查询什么以及如何查询是最重要的。这通常是在遍历一些不是为反射而是为编译而设计的编译器的AST时遇到的问题。所以你有很多额外的东西需要忽略,这使得有时很难找到你想要的东西。
那么你想看一些例子吗?这是一个。让我们做一个非常简单的y = x^2,然后让我们请求演示之神给予耐心和理解。
这里,我们再次使用CPP2代码,但这完全编译为C++。它完全兼容C++。只是,我使用这个是因为我这里有语句和表达式反射。所以这里是我们的函数y = x^2。我们将做一阶导数。为了展示这是如何工作的,我将尝试运行一个可视化图表,ChatGPT再次帮助了我,因为我不是Python专家。
它在哪?哦,这在我排练时发生过。我可能需要关闭bash。关闭bash。关闭窗口。关闭所有这些。让我们尝试重启它们。哦,好了。首先我们有。哦,不,我不想要PowerShell。哦不,我把它们都带回来了。也许,也许那就够了。你认为它可能重启了吗?让我们看看。哦,CH。我可以向你展示截图,但我真的很想现场展示。哦,不是PowerShell。那是默认的。你错过了Bird Pon。我再试一次。不,我只好用幻灯片向你展示这个了。对不起,但我会向你展示自动微分代码。
好的,所以我们在演示中。我只是无法可视化。我喜欢可视化输出,而且我们在C++中还没有图形。别让我开始。
这里我们将编译这段代码,80。想想看谁做,因为我们所有的演示编号都从0开始。我还使用了打印函数元函数,它说,好的,自动微分,然后打印结果,这样我们可以直观地看到生成的代码。同样,是CPP2语法。所以这里是原始代码。那么告诉我们这里用自动微分代码看到了什么。所以我们编辑了自动微分生成的后缀_d,并且我们也添加了参数x的方向x_d和输出。是的,你可以看到这里,我们有简单的乘法。在简单乘法之前,我们添加了y方向的更新,这只是我们之前在幻灯片上看到的乘法。所以这很简单。当你实现自动微分时,总是先做导数计算,因为如果你有自赋值,那么你会使用一个新值,如果你之后做的话。
如果Python正常工作,你会实时看到这个图表。对于这个自动微分例子,抱歉,展示是因为所有源文件所做的就是说,对于x从-10到10,步长0.1,创建一个数据文件,包含x、y和dy的值。这里它们被打印出来。这也显示了二阶导数,因为我们直接到了阶数等于2。
现在,如果我们为另一个例子做这个,让我们做y = x + sin(x) + 10。所以这里我们将转到AD2。让我们编译那个。这里我们看到第5行是那个新函数。所以我们将去。让我们看看谁B2。告诉我们这里有什么不同。现在我们有这三个我们想求和的项。我们为第二项创建了一个临时变量,它本身就是一个表达式。所以我们先做这个。这基本上是与符号微分的区别。所以你必须在一次中完成所有这些,这使得它更高效,或者使AD更高效。我们去掉最后一项,因为它是常数。基本上就是这样。否则,我们只是在这里添加z导数值。

所以注意,我们有我们的原始函数,做它该做的事。这个函数两者都做。它在同一次路径中进行原始计算,并计算y和dy。所以这很重要。我们在一次传递中以常数开销完成两者。这看起来像这个函数,我们有函数、一阶导数和二阶导数。你在这里看到,对于一阶导数,线性部分
030:定义十年的火箭引擎 - Herb Sutter @ CppCon 2025


概述
在本节课中,我们将学习C++26标准中引入的静态反射功能。反射允许程序在编译时查看和生成自身代码,这为C++编程带来了革命性的可能性。我们将通过一系列示例,从基础概念到高级应用,探索反射如何简化代码、生成类型安全的包装器,并可能改变我们构建软件的方式。

反射基础:工具与概念

上一节我们概述了反射的重要性,本节中我们来看看构成C++反射的核心工具和基本概念。
反射的简短定义是:程序能够查看和生成自身。通常,我们用“反射”统称读取和生成两部分,有时也分别称为“反射”(读取)和“生成”(写入)。因此,元程序就是操纵程序的程序。
据我所知,我们刚刚添加到C++中的反射功能,在商业语言中是独一无二的,属于一流水平。这是版本1,我们将在C++26之后继续构建它。但即使是版本1,功能也非常丰富。
这不是运行时反射,而是静态的、编译时反射。当然,如果你计算了某些内容并想在运行时存储它,默认情况下是零开销的。但如果你想在运行时存储某些内容,可以使用从定义静态数组、定义静态字符串开始的工具,这些是你的转换函数,用于生成运行时的数据片段。
除了普通的 for 循环,还有一个模板 for 循环。像往常一样,“模板”意味着编译时,也意味着“展开”循环体。当你实例化一个模板时,每个特化基本上都是展开的,你可以在循环体中得到不同的类型和不同的重载解析。模板 for 循环也是如此,因为你实际上需要这个功能,而且它实际上很容易使用。
当然,如果你这样做,你可能需要一个常量函数,用于迭代数据成员。然后,你有双尖括号(或“猫耳朵”)反射运算符来反射类型。
这里有一个简单的例子,它接受一个类型并遍历其成员,这是我们以前无法做到的,然后简单地打印出它们的名字。
template <typename T>
void print_member_names() {
template for (constexpr auto member : reflexpr(T).members()) {
std::cout << member.name() << std::endl;
}
}
编译时性能考量
我想解决房间里的大象。在反射出现之前,有多少人已经担心编译时间?我看到不少人都举手了。有多少人害怕所有关于编译时间的讨论?是的。
答案在幻灯片上:我们即将看到C++编译时代码的巨大爆炸。总的来说,这通常会使你的程序更快,构建也更快。原因如下:
因为它不仅仅是添加东西。如果你在做新的事情,当然会有额外的成本。但如果你正在用今天的模板元编程替换某些东西……你们中有多少人已经有过这样的经验:将一些用模板元编程计算的东西,改为用 constexpr 函数代码(因为它只是代码,所以更可读、更可调试),结果发现编译速度快了一个数量级?相当多的人举手了。
这是因为你直接表达了你的意图,而不是通过一个并非为计算而设计的类型系统和特化来表达计算。相反,你将自己表达为代码。猜猜看,优化器喜欢代码。它们看到代码,就会自动锁定并优化它。调试器也能工作。这是一个好处,也是为什么我们已经看到使用 constexpr 代码带来的速度提升。同样,对于我们将要讨论的外部编译器,当我们简化工具链并在编译器中做更多工作,而不是重复大部分可能是错误的解析时,我们可以摆脱所有这些,从而获得更快的构建。
演示环境与路线图
我们将进行相当多的演示。祝我们好运。我会一直这么说,因为演示的“恶魔”总是在附近。
我将展示三种实现。前两种是Dan Katz的Clang实现和David Vandevoorde的EDG实现,它们实现了现在草案标准中的内容,加上一些扩展,因为它们的功能是标准的超集。
我将展示C++26中的例子,但也会展示在近期和中期未来可以期待的例子。对于其中一些,我将切换到我的Cppfront编译器,它同样是C++,只是语法不同,但它编译成100%可移植的C++代码,可以在过去十年的每个C++编译器上运行。但它的反射实现更先进。所以我需要用它,因为我在其中实现了比原型中更多的反射功能。但这都是标准的一部分。这里没有混淆,我们谈论的是C++本身的未来。
所以,在了解了工具之后,让我们开始看看我们今天能做什么。在本节中,我将讨论仅使用我们添加到C++26中的功能就能做的事情。截至六月,它仍然非常新鲜。之后,我们将看看在C++26之后可以做的事情,但属于更近期的,比如可能进入C++29的。然后我想带我们看得更远,看看这如何影响长期的发展轨迹,不仅是C++,还有其他语言和我们的行业。因为我们不常有机会通过向语言添加功能来做到这一点。
这是一个路线图草图。我会回到这张幻灯片,并突出显示不同的部分,包括反射(读取部分)和生成(写入部分)。我们现在已经有了一个子集,在C++26中标准化了版本1。我们将添加更多,但稍后再谈。目前,我们已经添加了对命名空间、类的反射。我们甚至得到了函数和参数。还有称为“注解”的东西,它们看起来很像属性,但在属性内部,它们以等号开头。我会尽量记得演示其中一个。我们在同一个翻译单元中有一些生成功能,使用拼接运算符 [: ... :]。但只要在单独的文件中,我们就可以生成任何我们想要的东西,因为我们有 ofstream,我们有 cout,我们知道如何向文件发送文本和二进制信息。
所以,我们还没有在编译时在同一翻译单元内完全实现生成功能。这即将到来。仅使用反射并生成文件,我们已经可以做很多事情了。
热身示例:嵌入与拼接
这里有一个热身示例,使用了特性 #embed 和拼接运算符。你会在Godbolt链接中看到它。让我们实际切换到那个链接。如果这能工作,我这次演讲的其余部分机会就大得多。如果演示不工作,我还有幻灯片。但现场演示能让我们相信它是真实的,对吧?
所以我们有一个用Cpp2设置的Godbolt,它是一个多文件设置。我们要做的第一件事是,通过 #embed test.json 创建一个JSON数据变量,你可以获取该文件,将其粘贴到这里。
你为什么要这样做?你可能想单独签入那个文件。那个文件可能不仅仅是我的C++程序的唯一真实来源,也可能是系统其他部分的唯一真实来源。你不想复制它,只想签入一次。事实上,它就在下面这里。我们可以看到它是一个JSON文件,有一个名为 magic、类型为字符串的条目,然后是一个嵌套的结构体,包含 word(字符串类型)和 number(整数类型)的条目。
然后,我们将调用这个函数。我不会逐步讲解它。如果你想看答案,在这个Godbolt示例中,这行代码上面有150行代码。请查看代码。这个函数解析我们刚刚通过 #embed 嵌入的JSON数据,并做一些非常棒的事情:它创建一个全新的结构体,一个计算生成的、与运行时输入(更确切地说是编译时输入)匹配的数据类型,这样我们就可以像访问数据成员一样访问 v.magic,因为它就是;访问嵌套的数据成员 v.inner.word,因为它也是。然后我们就能按预期打印出这些内容。
现在,我想指出一件事。因为我们有新的反射运算符(双尖括号运算符)和拼接运算符,有些人想知道,所有这些“乱七八糟”的东西会不会污染我的代码?实际上并没有那么糟,至少现在还没有。但这类东西自然会放在库中。这里有一个如何做到这一点的例子。我在这里放了这个拼接,因为我想向你展示拼接。但你也可以直接调用一个常规模板,在这个例子中是一个变量模板。所以现在调用点就像我们今天在C++中期望的那样。在那个变量模板内部,我们进行拼接。它也可以是一个函数模板。所以你可以把反射运算符隐藏在函数模板里。这样,你就不必让你的用户知道反射或拼接,你可以把这些放在库里,因为它只是代码。
最后一件事,因为……为什么不呢?因为我们能。我们还有这个 static_assert。但我想带你看看这做了什么。这是一个原始字符串字面量,基本上打印一些信息,并验证它打印的是JSON格式的信息。所以这里就是JSON。然后我们使用一个用户定义字面量(C++已经支持),将其转换为一个对象。但由于它可以使用反射,那个用户定义字面量会调用反射,创建结构体,我们可以立即使用这个计算生成类型的成员。

这很不错。它让我们热身,了解我们能做什么。

进阶示例:命令行选项解析器
现在,让我们看一个稍微高级一点的例子,但它仍然很简单,所以我们可以实时讲解:一个命令行选项解析器。
我们都用过命令行选项解析器。这个有点不同。parse_options<my_opts> 看起来就像一个普通的模板调用,因为它就是。但请注意,它传递的是选项结构体,并且只传递选项结构体,没有传递单独的名称字符串。如果是这样,那么用可能是一个对象指针来填充这个变量。不,它只是说,这是结构体,去填充它。只需查看结构体。我告诉了你标志、名称和它们的类型。直接去填充它。
让我们看看这是什么样子。这里我们有那个结构体。我们可以看到,是的,事实上,这能工作。如果我们有命令行……我几乎看不清的命令行是 --count 42,还有一些其他东西。文件名没有指定,所以它使用默认值。如果我们省略文件名,它也会默认,因为我们为文件名设置了默认值。这一切都很棒。
那么这里到底发生了什么?让我们向上滚动。当我们向上滚动时,我们会看到 parse_options 接受其模板参数。它做了什么?类型本身就是一个输入。所以我们将使用一个模板 for 循环,它将展开对反射类型的每个非静态数据成员的访问。注意,这里我们把反射放在了函数模板内部,所以它没有到处泄漏。不是每个人都需要写双尖括号。这取决于你把它放在哪里。
然后我们简单地遍历。我们是否找到一个以 -- 开头的命令行选项,其标志名称匹配?如果是,那么我们要做什么?然后我们尝试创建一个字符串流,并将该值流式传输到 opts. 中。然后你看到第36行的拼接了吗?opts.[:data_member:]。在一次实例化中,这将是 opts.count,在另一次实例化中,这将是 opts.fname,类型将是正确的。你可以看到为什么我们像模板一样展开它,因为每个循环体中的类型实际上可能不同。这是类型安全的。我们所做的只是遍历成员,搜索它们的名称。哦,看,一个名称匹配了。然后尝试流式传输到那个成员。这一切都是类型安全且正确的。事实上,如果我在这里尝试说 --count x42,错误:为 --count 提供的值不是有效的整数。我们得到这条消息是因为我们实际上显示了友好的名称,即使编译器可能把 I 作为类型ID名称,我们遍历所有这些并说,给我们真实的、友好的源类型显示字符串,用户能够理解的实际源类型。
为了证明更多这是实时的,让我们添加另一个数据成员,另一个我们可以添加的东西。一旦编译完成,我们仍然想修正我们的计数值为正确的。但现在,假设我们想放……哦,看,现在 opts.pi 默认降到了3.14159,正如它应该的那样。你知道,它是一个浮点数。别因为我用浮点数表示π而批评我,因为情况即将变得更糟。假设我们在1897年的印第安纳州,我们说 pi = 3.2。哦,我打对了吗?太小了,我看不清。有人看到我打错了吗?哦,没有等号。对了。现在我们符合了1897年印第安纳州几乎通过法律的规定。如果你不相信我,去查一下。就差那么一点,尽管参议院大部分时间都在嘲笑它,这很好。
任务:可绘制类型擦除包装器

这里有一个更长的例子。我将做一个“不可能的任务”式的即兴表演,称之为“任务:可绘制”。我们想要一个可绘制的类型擦除包装器,它将包装任何具有大致 draw 函数的对象,该函数可以接受一个坐标或可转换为坐标的东西,并返回一个整数或可转换为整数的东西。所以,如果你考虑参数和返回值的转换,这就像 std::function 所做的那样。所以它不必完全是这样,但必须与该接口兼容。
因为我以前没写过这样的东西,我想看看AI有多好。我让ChatGPT来做。它写了200行代码,然后我调整了一下。我不假装理解所有内容,但它似乎区分了常量和非常量。你们中做过这个的人会告诉我它是否正确。它似乎能工作。我测试并调整了它。现在,是的,事实上,你可以有一个可绘制对象的向量,放入一个精灵、一个图标、一个按钮。这是三个不相关的类型,就在这里。把它们放进去,然后对每个对象多态地调用 draw。它工作了。你怎么做这个?可绘制内部的核心数据结构是什么?有人喊出来。虚表,是的。这就是如何编写你自己的虚表。让我们看看ChatGPT在我的帮助下做了什么。
我不会遍历所有行。这是超过200行的代码。但在这里,我想首先向你证明,事实上,我袖子里没有藏任何东西。这对中间和后面的人来说足够大,能看清吗?是的,哦,即使在后面,哦,我想要你的视力,我真羡慕。
好的,所以我们正在做可绘制的规范。让我向上翻页,只是为了向你证明,事实上,精灵、图标和按钮都是不相关的类型,没有继承的把戏。事实上,其中一些有我们正在寻找的确切函数,在这个例子中是按钮。它接受一个点,但点可以从坐标参数转换,它返回一个短整型,可以转换为整数。所有这些都兼容,并且它们都能工作。你可能以前在这里见过这样的演示。让我把它拉过来,这样你就能真正看到。事实上,是的,在GCC、Clang、Microsoft上,所有这些都能正确工作。
到目前为止还没有反射。我向你展示这个例子的原因是为了教你。当我自己学习它时,我的整个职业生涯,我都是在学习的过程中写下这些东西。这里的情况完全一样。
现在,我们如何概括这个?简短的答案是:让普通情况手动工作,然后剪切粘贴它,在所有东西周围加上引号,然后替换你想要的名称,并在事物随成员函数或数据成员变化的地方放入循环。实际上就是这么简单。这个完整的端到端例子,我从零开始,从空白屏幕到ChatGPT,再到修复代码,再到编写反射代码,只花了两个小时。我以前写过反射代码,但我对它的速度之快感到惊讶。让我向你展示那是什么样子。
这里,我们有……如果你把这部分代码放在一边,这样我们现在就能看到反射代码。首先,我将向你展示反射代码的输出。反射代码在底部这里。也许我会先展示底部。滚动,滚动,滚动,滚动,滚动,滚动,滚动。它所做的就是接受一个示例类 drawable,它具有我们想要包装的签名。可以不止一个,但现在只有一个。我们将调用一个编译时函数 polyly(我不知道该叫什么,所以叫多态类型擦除器“poly”,差不多)。它将把那个作为输入,然后我们将生成本质上与右侧相同的代码,通过剪切粘贴来测试它,是的,它能工作。

所以这里是包含文件、类 drawable、文件顶部我们手写的各种概念等等。但当我们转到左侧的文件时,我特别想提请你注意这个例子,看看虚表是如何工作的。所以这里是虚表。它在文件的中间。你可以用滚动条看到它有多大。所以我们在文件的中间。这里的大部分是类 drawable。左侧是手写的版本。注意我们是如何手写虚表的,它有两个条目:析构函数和复制函数,无论你擦除什么类型,这些都是一样的。这种类型擦除的东西需要构造和复制。所以这不是每个函数都有的,只是为了让我的类型擦除包装器能够构造。这些是每个函数都有的东西。所以如果我包装了一个有两个函数的东西,我在这部分会有双倍的条目。
让我们看看我们如何做到这一点。实际的……让我先展示我们确实生成了那个。滚动,滚动。我想我们快到了。不,那是问题所在。还有一个我们生成的自由函数。好吧,这里的某个地方是虚表。相信我,它在那里。很难找到。我会再找一次。不,你知道吗,我真的很想看到它,因为我想向你展示它是如何变化的。所以当你看到“V”时停下来。在那里。那是虚表。好的,注意它生成了与右侧相同的东西。但这些都是生成的。当然,它不在同一个文件中。它被输出到另一个C++文件,然后我们可以在同一个构建中使用它,如果我们想的话。但我们必须从不同的文件中使用它。我们还没有在翻译单元内进行源代码生成。
好的,让我们看看这段代码。如果我们看看我们必须编写的代码,让我们看看 poly 函数本身。它是一个简单的函数,接受原型并返回一个字符串。你可以看到它首先在我们另一个文件中的所有东西周围加上引号,包括依赖项和所有内容,这里是你的构造函数。当你到达其中一些东西时,你看到,我只是复制粘贴了文件。然后,哦,而不是 drawable,我看到 drawable 的地方,我只是全局搜索替换,把 drawable 改成引号加部件名加引号。这很容易。你对所有东西都这样做。

现在,有些东西是每个成员函数都有的。所以我想在这里提一下,以及我为什么这样做。你会注意到,到目前为止,我向你展示的这个函数只是一个普通的运行时函数。它甚至不是 constexpr 函数。它只是一个做字符串处理的普通运行时函数。但我必须用这个反射原型调用 poly_impl,现在它将使用反射信息。




我的初始实现是把所有这些,包括这个不需要是编译时的函数,也做成 constexpr。在这个例子中,我遇到了Clang对 constexpr 操作的限制,因为所有的字符串。所以你有很多理由。我相信随着我们给编译器施压,它们会增加限制。但你有理由在普通代码中做很多事情,然后将需要的部分委托给反射代码。这是一个很好的卫生习惯的例子。如果我们快速看一下,这里是处理每个成员并输出所有字符串片段的函数。它所做的就是:这将对每个成员调用一次,它遍历该成员函数的参数,记住其返回类型、参数,并生成我们看到的所有那些东西。这里有一大堆东西,这些是片段,然后由我们已经看到的、组装它们的外部调用者放在正确的位置。
关于这个的一个好处是,如果我们直接回到底部,如果我现在想……让它可见,我现在想改变这个,拥有另一个函数。我们将等待它重建。让我们看看新的虚表。现在我有一个包含 draw 和 get_name 的虚表。所以现在我可以包装任何具有 draw 和 get_name(参数和返回类型可转换)的东西。我没有写任何代码。
所以这非常、非常有用,并说明了我们将能够做的那种事情。我真的很想强调,手写版本有200行代码来使 drawable 工作。现在我有300行代码。那是手写的代码。你的视力真好。这是你在后面的测试。有手吗?不,我想没有。所有那些现在都只是生成的。我写了300行代码,它将为我将来想要的任何类型擦除的东西做所有这些。我再也不用写它了。我可以调试它,因为它是代码。它一开始会有代码生成错误,但它只是代码。我们会调试它们。当它工作时,我们就发布它。

元类:简化类定义
八年前,在这个舞台上,我相信我首次现场演示了元类论文P0707,展示了如何使用反射来帮助我们更轻松、更正确地生成C++类型。我第一次在ACCU的幻灯片上展示了它,我相信第一次演示是在这个舞台上。这里有一个快速的复习例子,我以前讲过,所以我会很快。想法是:今天我们在左边写样板代码。如果我们能直接说,嘿,我写的不是任何类,我写的是一个接口,这意味着我想要某些默认值。默认情况下,所有函数都是纯虚的,即使我没有写。默认情况下,我自动得到一个虚析构函数,即使我没有手写。我自动得到抑制的复制和移动,诸如此类。


这就是我们今天在C++26中可以实现的。我们可以做到。它只需要进入一个单独的文件,然后我们从那个单独的文件中使用它。让我在Godbolt上快速展示一下,表明它已经完成。这是我想记得向你展示的另一件事。我们不吃你。我们不是你。好了。
所以这里我们有类 widget。我们把它用作原型。我在它上面调用接口元函数。看,这里,它生成了我们看到的所有东西。我不会逐步讲解。现在这是一个简单的例子,因为我们已经见过更难的了。我想向你展示的是,嘿,如果我们实际添加另一个函数,比如 float h(double)。哦,看,它也出现了。现在我们处理了那个。所以我向你证明这是实时的。这实际上是在反射并生成应用了默认值(虚和纯虚)的正确最终函数。

但现在,假设我们更进一步。假设你是这个编译时函数 interface 的作者。你会说,伙计,如果我能给用户一种方式说,哦,我所有的函数除了那个,我不想那个成为接口,那该多好。我编造了一个稻草人例子。你怎么做?你可以在C++26中做到,不需要任何更多的语言特性,因为你可以创建一个自定义注解。注解在标准中。这就是它的样子。我可以直接写。在这个代码中,用户可以只写 = suppress,现在注意在右侧,g 消失了。这是魔法吗?标准中有什么我可以谷歌的 suppress 特性吗?不,标准中没有任何关于抑制的内容。所有这一切就是,在这段代码中,当我遍历所有成员函数时,我实际上是在查看注解并说,哦,我想看到所有不是特殊成员函数且没有 suppress 注解的函数。suppress 注解是什么?我刚刚编了一个。它只是一个类型。就是这么容易。现在你写了一个元函数,你开始为你的用户提供一个API,从你的调用站点向你的元函数提供信息。
到目前为止,这都是C++26。未来我们能做什么?反射将走向何方?同样,当我们超越这里时,更多的是假设。但已经有提案和实现摆在桌面上,可以在编译时添加到你的翻译单元中,这样我们已经做过的那些事情,比如接口,实际上可以在同一个翻译单元中使用。这张幻灯片现在在EDG编译器上通过这个Godbolt链接是可能的。它将生成完全相同的输出。注意,关键是我们从同一个文件内部生成这个。
这种模式你会经常看到。我把我的示例、原型放在某个子命名空间中。然后我反射它,调用一个编译时函数来反射它并做一些事情。通常是为了反射它并为我制作一个修改后的版本来使用。所以我把原始的、不完整的放在某个子命名空间中,我反射它并制作它,当我完成时,就是完成的版本。这将变得如此普遍,以至于我提议,从八年前开始,继续提议它的 class 前缀语法。class interface 正是且仅仅是那个的语法糖。我在Cppfront中实现了相同的东西。它是一种从左到右的替代语法,但它做的完全一样。一个接口类型。所有这些,包括现在Godbolt上的EDG编译器和带有这个例子的Cppfront,都生成完全相同的、诚实的、可移植的、100% C++代码类型,可以在每个编译器上工作。编译时部分它们能工作,不能在每个编译器上工作,但创建的类型可以在每个编译器上工作。
我已经实现了一堆这样的东西。interface 是第一个,还有多态基类、有序值、结构体枚举、标志枚举。我们可以使用 class enum,它只是反射并使所有东西成为 constexpr 函数。flag_enum 做同样的事情并添加位运算符。我们可以使用C语言枚举以实现与C的兼容性。但展望未来,我个人认为,class 前缀枚举语法,已经被委员会中的SG7临时鼓励作为那四行代码的语法,一旦我们有了生成功能,我认为我们再也不会写一个裸露的类了。为什么我们要写?我们会说我们在写什么类。我写的不是任何类,我写的是一个接口。你知道,一旦你这样做并表达了你的意图,你也再也不会写 = default 和 = delete 来摆脱C++生成的你不想要的函数,或者重新启用某个被抑制但你确实想要的函数。因为今天,我们有一个硬编码到编译器中的、适用于所有类类型的元函数,因为语言猜测你可能想要什么。但我写的不是一个值类型,我写的是一个接口,一个代理,一个异步函数,或者其他东西。所以我只是要从我八年前写的那篇元类论文的底部读起,它现在终于要成为现实了。每个小节都让我如此兴奋,因为我展示了当时无法编译的代码,但大部分是实际代码,用于那些事情。第3节的每个小节都相当于一个重要的语言特性,在另一种语言中,这将是一个语言特性,否则需要自己的EWG演进论文,并硬编码到语言中。但在这里,可以表达为一个通常很小的库,可以通过库演进工作组。
例如,这篇论文首先演示了如何用10行C++ constexpr 反射代码实现Java/C#接口,并获得与Java和C#等语言中内置语言特性相同的表现力、优雅性和效率,在那里它们被指定为20页文本,供人类编译器编写者去实现。10行函数。20页文本,我八年前在舞台上用现场演示展示了。现在我们可以在草案标准中做到,而且更多。
所以反射将有助于简化C++进一步演进的需求。我们仍然会有反射不直接影响的语言特性。但许多本应是语言特性的东西,我们现在可以用反射作为库来实现,而且我们可以做得很好。这意味着我们可以测试和修复它们。我们有可以立即移植的代码,而不是等待你的编译器供应商都实现它。通过GitHub和包管理器交付更快,更容易定制和适应分叉。
那么我们能用它做什么?我们还不知道。但这里有一个想法的入门列表:如果我生成C++文件,我可以为C++类型生成序列化器/反序列化器。只是普通的C++代码作为反射。我可以输出C++代码,我可以输出Python代码,我可以输出JavaScript代码,并反射类型的结构,为另一种语言编写包装器。如果我能输出文件,我可以输出二进制文件,比如当C++/CX现在需要为C++类型生成的WinMD二进制信息。我可以自定义类的对象布局,比如类 compressed。你们中有多少人手工做过这个,并希望有一种不用模板元编程的方法?Tom Fory。生成单元测试函数。只需遍历命名空间,找到每个名为单元测试的函数,并生成一个运行它们全部的函数。而不是在Python脚本中做,只需写几行C++代码。使类的接口异步。所以类 async 可能在其所有成员函数调用上运行发送者-接收者线程池,然后这些函数的返回类型可能从 T 变为 future<T>。你已经可以想到如何编写它。为什么?因为你知道如何编写C++代码。如果你能编写它,你就能自动化它。
所有这些都是可移植的、可测试的、可共享的、标准的C++代码,没有外部编译器,没有脚本,没有语言扩展。这里有一个预测。如果我错了,我欠你们所有人一杯啤酒。我真的希望我没有错,不仅仅是因为啤酒。我们现在正走在一条直接的道路上,能够——无论供应商是否这样做——淘汰外部编译器和构建系统,比如C++/CLI,它必须添加我们直到现在才能在C++源代码中拥有的信息,但有了反射,我们可以在未来十年淘汰C++/CLI,淘汰C++/CX。我想我应该知道并能够这么说,因为我领导了其中两个的设计:C++/CLI,C++/CX。我因此受到了很多批评:你为什么要扩展C++?你是想用一些专有的东西接管C++吗?不,只是我们需要集成到.NET,我们需要集成到Windows COM,而我们当时还不能在C++中表达所有我们需要的信息。这就是为什么我一直如此努力地推动反射作为一个方向,这样我就永远不需要,正如我八年前公开说的,再发明C++/CLI或C++/CX了。我让ChatGPT修复了表情符号。这是I/O表情符号。很好。但我觉得它需要一个微笑。所以谢谢你,ChatGPT。
要记住的关键是,如果你在想,我能用反射做什么?简单的答案:如果你能手工编写代码,你就能编写代码来生成它作为字符串和标记。所以,如果你有一个准确的、始终最新的C++解析器,相当于你的代码可用,你会做什么?我们都即将找出答案。

语句与表达式反射

我们都即将找出那个问题的答案。现在,让我们看看语句和表达式。这是下一件事。因为注意,我们已经可以用命名空间、类、函数、参数、注解做很多事情了。这在版本1中已经很多了。紧随其后的是语句和表达式。这些我必须在Cppfront中展示,因为我们还没有在Dan或David的实现中实现对这些的反射。所以这是Cppfront领先的地方。

但我想向你展示一个叫做自动微分的东西。为此,我想邀请Max Sagebaum上台。请给他掌声。谢谢你能来。去年你在芝加哥参加自动微分会议,和Cppcon同一周,做了几个演讲。那是因为Max是世界级的自动微分专家。所以让他来介绍这些材料比我更好,因为我必须假装我听过的东西,总结我听过的东西。他懂这些东西,包括他实现了今天存在的主要自动微分库之一,并知道其中的权衡。
所以让我们谈谈它是什么,因为到现在,你们中有些人可能在想,你一直在用这个词“自动微分”。我知道像Maple中的符号微分。我记得在学校做过。我也知道数值微分,我们取epsilon,让delta越来越小,做近似来求导数。这里有什么不同?自动微分或算法微分是一种数学理论,你基本上假设可以将计算机程序分离成小的基本函数。这基本上就是编译器总是做的。对于每个这些简单函数,我们知道导数。然后通过应用链式法则和方向导数,我们可以产生一个数学级数,说我们可以计算导数。这就是我们如何将其应用于计算机代码。
这比符号微分和数值微分更好,因为符号微分准确,但会爆炸性增长,速度慢,且不具扩展性;数值微分可计算,但也不具扩展性,而且你会得到舍入误差之类的东西。算法微分总是能给你数值精确的导数。顺便说一下,关于选项3的一点是,它是数值精确的,并且是线性时间的,比运行原始函数多一个常数因子。对于前向模式,常数因子大约是2到3。是的,对于反向模式,大约是2到4。
那么用这个例子向我们展示前向模式。我们有 y = sin(x) * x^2。是的,我们首先将其分离为基本函数。所以我们有 sin(x),x^2,然后我们将这两个相乘。所以这基本上是具体化临时变量。然后我们可以计算导数。sin 的导数是 cos,x^2 的导数是 2x,非常简单。然后我们将这两个相乘。所以 y 对 v 的导数是 u,所以我们写 u *,然后 v 对 u 的导数。就是这样。我们没提到的一件事是,这种自动微分的一个好处是,你可以有带控制流、分支、循环的函数,这些你无法轻易表示为符号函数。例如,这绝对正确。通常你可以支持它。有些东西可能相当困难,取决于你使用什么自动微分工具,但通常你可以支持它。
那么如果有多个输入和输出呢?这只是一个从 x 到 y 的函数。如果有更多呢?对于前向模式,如果你只有一个输入,你可以有任意多个输出。你只需计算一个,然后你就有了输出的完整梯度。对于反向模式,例如在AI模型训练中,你通常有数十亿个输入,但通常只有一个输出。那么你必须运行这个数十亿次,这将花费一些时间。你可以通过使用向量模式来减少常数因子。但如果你使用反向模式,你可以说,好的,我运行一次,存储一些数据,然后重用我存储的内容,运行反向传递,然后你在一次传递中获得完整的梯度。有一点内存开销,你需要存储,但通常你可以管理。
所以前向模式在输入少于输出时很好。我方向说对了吗?是的,正确。但那样不会扩展。如果你有十亿个输入,你就需要做一百万次。所以你希望反向模式,你进行一次前向传递,计算你随后反向导航的数据结构,但它是线性的,然后你一次得到所有输入的导数,仍然是线性的。是的,是的。现在,如果你想要二阶导数、三阶导数、n阶导数呢?算法微分的好处是,你有一个代码,你应用它,你得到一个不同的代码。然后你可以一次又一次地应用它,你可以有任意高阶的导数。但问题是,通常这不是你想要的,因为它非常通用,你会得到很多你必须指定的导数值或方向。你可以做的是有一个泰勒实现,然后使用这些泰勒实现,这将减少你需要指定的值的数量和方向。在99%的情况下,这就是你想要的。
总结一下,我认为最大的收获是:它只是代码。你有关于如何优化它的白板讨论。它只是代码。
那么我们为什么要关心?这出现在任何重要领域吗?我们在这个会议上听到了一点。它用于训练AI。所以如果你听说过反向传播,那基本上就是算法微分的反向模式。科学软件也经常使用这个。是的。但你可以手工编写这些导数。事实上,你告诉我,现在人们确实手工编写导数代码。是的,仍然这样做。我必须坦白,这是你能做的最好的方式。如果你想要最快的导数,你就应该这样做。但是,你的原始代码需要一年时间来开发,然后需要另一年甚至两年来编写导数代码,因为它通常更复杂。然后是维护方面,你在原始代码中改变了什么,然后你必须更新你的导数代码以使其相同或计算相同的东西。这种算法微分,有一个初始成本,但你必须应用,然后你可以在一秒钟内对你的代码运行AD,你就有导数,虽然不是最优的。但然后你可以分析你的导数评估或计算,找到一个热点,然后优化那个热点。然后你将拥有近乎最优的导数计算器。
对于一个热点,比如线性系统,你不想在黑盒模式下做这个,因为它可能产生更长的导数,这很容易优化。所以现在,我们有运算符重载库,它有局限性。顺便说一下,CoDiPack是你的,你参与的那个。有自定义编译器和分支,比如Tapenade和基于Clang的Clad。但正如你所说,问题是,如果你有一个定制的编译器,你如何在生产环境中采用它?我使用GCC,我不会采用你的Clang分支,即使我使用Clang,我也需要生产质量的东西。
那么,既然你已经在Cppfront中用Cpp2编写了自动微分,并且是作为一个库编写的,你会如何总结好处?感觉如何?有什么不同?这就是你在你的演讲中已经告诉我们的。你现在手头有一个最新的C++解析器或C++反射解析器。好处是,如果你有一个好的API,你总是知道如何查询下一个东西。你知道,我有一个函数,从一个函数,我知道,好的,我可以查询输入参数,我可以查询输出值,然后我可以为AD查询函数体的语句,然后每个语句的表达式。希望在C++29中,但知道你想查询什么以及如何查询是最重要的。这通常是在遍历一些不是为反射而是为编译而设计的编译器的AST时遇到的问题。所以你有很多额外的东西需要忽略,这使得有时很难找到你想要的东西。
那么你想看一些例子吗?这里有一个。让我们做一个非常简单的 y = x^2,然后……请求演示之神耐心和理解。
这里,我们再次使用Cpp2代码,但这完全编译为C++。它完全兼容C++。只是,我使用这个是因为我这里有语句和表达式反射。所以这里是我们的函数 y = x^2。我们将做一阶导数。为了展示这是如何工作的,我将尝试运行一个可视化图形,ChatGPT再次帮助了我,因为我不是Python专家。
它在哪里?哦,这在我排练时发生过。我可能需要……原谅我。我可能需要关闭bash。关闭bash。关闭窗口。关闭所有这些。让我们尝试重启它们。哦,好了。首先我们有。哦,不,我不想要PowerShell。哦不,我把它们都带回来了。好吧,也许,也许那就够了。你觉得它可能重启了吗?让我们看看。哦,CH。我可以向你展示截图,但我真的很想现场展示。哦,不是PowerShell。那是默认的。你错过了Bird Pon。我再试一次。不,我只好用幻灯片展示了。对不起,但我会向你展示自动微分代码。
好的,所以我们在演示中。我只是无法可视化。我喜欢可视化输出,而C++还没有图形。别让我开始。这里我们将去编译这段代码,80。想想看谁做,因为我们所有的演示编号都从0开始。我还使用了打印函数元函数,它说,好的,自动微分,然后打印结果,这样我们可以直观地看到生成的代码。再次,用Cpp2语法。所以这里是原始代码。那么告诉我们这里用自动微分代码看到了什么。所以我们编辑了添加到自动微分的额外后缀,以及添加到参数 x 的方向 x_d 和输出。是的,你可以在这里看到,我们有简单的乘法。在简单乘法之前,我们添加了 y 的方向更新。这只是我们之前在幻灯片上看到的乘法。所以这很简单。当你实现自动微分时,总是先做导数计算,因为如果你有自赋值,那么你会使用一个新值,如果你之后做的话。如果Python工作正常,你会实时看到这个图形。
对于这个自动微分例子,抱歉,展示是因为所有源文件所做的就是说,对于 x 从-10到10,步长0.1,创建一个数据文件,包含 x、y 和 dy 的值。这里它们被打印出来。这也显示了二阶导数,因为我们直接到了阶数等于2。
现在,如果我们为另一个例子做这个,让我们做 y = x + sin(x) + 10。所以这里我们将转到AD2。让我们编译那个。这里我们看到第5行是那个新函数。所以我们将去……让我们看看谁B2。告诉我们这里有什么不同。现在我们有这三个我们想求和的项。我们为第二项创建了一个临时变量,它本身是一个表达式。所以我们先做这个。这基本上是与符号微分的区别。所以你必须在一次计算中完成所有这些,这使得它更高效,或者使AD更高效。我们去掉最后一项,因为它是常数。基本上就是这样。否则,我们只是在这里添加 z 的导数值。

所以注意,我们有我们的原始函数,做它该做的事。这个函数两者都做。它在同一次路径中进行原始计算,并计算 y 和 dy。所以这很重要。我们在一次传递中以常数开销完成。这看起来像这个函数,我们有函数、一阶导数和二阶导数。你在这里看到,
031:C++26中的健壮错误处理


在本节课中,我们将学习C++中错误处理的多种机制,从基础的C风格错误码到C++26的新特性,并结合实际开发经验,探讨如何在项目中构建健壮且实用的错误处理策略。
概述:错误处理的重要性
参加CppCon这样的会议,能让你看到许多有趣的人和许多被解决的复杂问题。

欢迎各位。感谢大家来听我的演讲。今天我将讨论两个不同的主题。我将首先介绍C++中用于错误处理的工具。然后,我将更多地讨论在实践中应该处理哪些错误以及如何进行处理。
我使用C++编程已经有一段时间了,并且一直在Thingstel公司工作。我们团队规模很小,但拥有超过一百万非常活跃的用户。我们的软件运行在普通的桌面计算机上。这是一个非常不稳定的环境,因为我们无法控制它。用户可以在上面安装任何东西,管理员对如何配置这些机器有非常具体的想法,安全工具也可能干扰你的程序。简而言之,为了发布一个能够成功的稳定产品,我们必须拥有出色的错误处理和报告机制,以使产品达到稳定状态。
但在开始之前,让我先澄清一下我所说的“错误处理”是什么意思。这是一个非常广泛的领域。当我思考错误处理时,我想到的是错误,即可能出错的事情。当我思考必须处理出错情况的系统时,我首先想到的是这些事物。这是工作在“硬核模式”下的系统,人命关天,就像这架飞机一样。
我出于好奇研究了一下这些系统是如何工作的。事实证明,像那些现代飞机,它们通常有三台主计算机,每台都可以控制整架飞机,然后它们在不同的位置还有一些其他备份系统。它们有不同的硬件,通常由不同的团队实现相同的软件,以确保不会受到同一个软件缺陷的影响。这些系统必须能够在宇宙辐射或发动机爆炸等情况下存活下来。
我们所做的,通常被称为可靠性工程。这是一个可靠的系统,能够在其物理系统部分被摧毁的情况下存活下来。而我们讨论的错误处理则要简单得多。我们主要处理程序错误。我们处理不可预见的系统配置和行为,但我们会假设你的内存正常工作,你的电脑不会突然爆炸。如果你像我一样幸运,在一个与安全不是特别相关的领域工作,你总是可以退回到这样做。当然,这是“简单模式”下的错误处理。
那么,我们为什么要费心做这些呢?正如我所说,我们发布的软件有时会在意想不到的环境中运行,用户会对它们做意想不到的事情,而且我们也会犯错。我认为我是在引用Andrei Alexandrescu的话:糟糕的错误处理会滋生错误。我们可以在错误处理代码上投入巨大的精力。但归根结底,我们不是为了编写完美的错误处理代码而获得报酬。我们是为了交付客户价值、解决用户问题而获得报酬的。因此,目标不是编写错误处理代码本身,而是尽快获得一个稳定的产品。而良好的错误处理是实现这一目标的关键。
在我的演讲中,我将首先从最基础的开始,介绍我们拥有的错误处理机制。然后,在演讲的最后一部分,我将讨论在Thingstel实践中我们处理什么错误以及如何处理。
C风格错误处理:基础与挑战
让我们从基础开始,C风格错误处理。每个人都认识这个函数,用于在特定路径打开文件的POSIX函数open。
int open(const char *pathname, int flags, mode_t mode);
现在,这个函数将其返回值(文件句柄)与错误码混在一起,因为它要么返回一个有效的文件句柄,要么在失败时返回-1。如果你打开手册页,你会看到它可以设置全局的errno变量为某个错误值。这里有一长串这个函数可能设置的错误情况。从你可能应该处理的良性错误(例如,文件不存在),到稍微奇怪的EINTR(这个调用被信号中断),再到奇怪的EFAULT(路径在地址空间之外)。我不认为我需要处理这个,如果存在的话,很可能是一个程序错误,也许是一个未初始化的变量,但这不是我应该处理的。还有一长串更多的错误情况。
Windows上的情况非常相似。CreateFile函数也将其返回值与错误码混在一起。它要么返回一个有效的文件句柄,要么返回INVALID_HANDLE_VALUE。它也会设置全局错误变量,你可以通过调用GetLastError来查询。
所以是相同的接口,相似的问题。C++标准库中也有这样的函数,例如std::strtol,它将字符串转换为长整型。当然,成功时它返回解析的整数。如果未发生转换,它返回0,就好像0不是一个有效的长整型一样,它还会设置endptr指针(可能指向字符串开头),并设置errno变量。在溢出时,情况类似,它会钳制值,但同样返回一个无效的长整型并设置errno。
有很多事情你必须检查才能正确处理,并检查是否发生了错误。所以这是一个非常不方便的接口。但不幸的是,这永远不会消失,我们至少需要好的工具,直到Herb Sutter实现他的愿景,每个人都用C++和反射编程。但只要操作系统有C风格的接口,这就不会消失。我们需要好的工具来处理这类错误。
C++异常:优势与权衡
首先,在C++中,我们发明了异常,以使这更优雅一些。
异常名声不佳。有时人们称它们为“goto”语句。人们说它们难以推理,难以理解由异常引入的控制流,它们在抛出和捕获时非常慢。像Google的一些人禁止在他们的代码库中使用异常。当然,它们也要求你编写异常安全的代码。
当我写这个时,我回去查看了Google C++风格指南,看看他们对异常的看法,我引用一下:“我们不建议使用异常并非基于哲学或道德理由,而是基于实际理由。如果我们从头开始重做,情况可能会有所不同。”我将其解读为,他们的主要问题实际上是让旧代码变得异常安全。我可能错了,但这是我读到的意思。
但异常在某些情况下可能是完全正确的工具。这里有一个非常简单的代码片段。我查询福克斯顿的天气。我们向某个JSON API发起HTTP请求,解析返回的JSON,并向日志文件写入内容。这里的每一行都可能抛出异常:有人拔掉了你的网线,返回了不同格式的JSON,或者你的磁盘满了。例如,在解析器的情况下,这个错误可能在std::par调用栈的深处被检测到。因此,异常的展开、栈的展开实际上是一个特性,非常实用。
我昨天和Khalil聊过,他正在做关于使异常更快的工作,他告诉我,有编写JSON库的人来找他说,嘿,我们想将错误处理切换到异常,因为在这个调用栈中,我们必须非常频繁地检查返回值,这实际上影响了我们的性能。
但当然,也有一些批评,比如异常可能使你的代码难以阅读和推理,如果你不尽可能在本地捕获异常,这些批评是完全正确的,就像我在这里做的那样。
与返回调用相比,异常有一些非常好的方面。它们有更多的语义错误信息。这些异常是结构体,它们有名字。如果需要,我们可以附加更多数据。当然,我们“快乐路径”上的代码完全不受错误处理的影响。所以这些都是编程中非常好的方面。最后但同样重要的是,如果你在他的主题演讲中听过,Bjarne喜欢它们,所以我能说什么呢?
有些观点仍然有些道理。它们在错误路径上有点慢。异常是串行的,这意味着同一时间只能有一个异常在传播。这可能是个问题。当然,代码必须是异常安全的,这是事实。
两年前Pete有一个关于异常误用的非常好的演讲,收集了他在代码审查中发现异常被误用的情况以及他如何清理它们,这是一个我非常推荐的演讲。
std::expected:结合返回值与异常的优点
那么,如果我们能结合返回调用的优点(简单性)和异常的优点(语义丰富性),会发生什么?幸运的是,有人想到了这个主意。现在我可以问你,这是第一个吗,你有基于此想法的先前工作吗?正是如此。
Andrei Alexandrescu在2012年的演讲中提出了完全相同的观点。如果我们能结合这两者的优点会怎样?这是一个非常好的演讲。这就是std::expected诞生的方式。引用最后一句话:“让我们把异常变成错误码。”这样做的优势将是:更多的简单性。我们可以同时有多个错误码在传播。我们可以存储它们,它们只是变量。我们可以为以后存储它们。我们可以跨线程移动它们。我们可以收集和转换它们,等等。
那么,我们如何使用std::expected呢?这是每个人都喜欢的错误处理示例:解析一个整数。
std::expected<int, std::string> parse_int(std::string_view str) {
// 解析逻辑...
if (/* 解析成功 */) {
return parsed_value;
} else {
return std::unexpected("解析失败");
}
}
我有一个非常简单的函数,从string_view解析一个整数,它返回一个unexpected,其中要么是整数,要么是我们遇到的错误条件。但这里我对如何解析整数不那么感兴趣,我更感兴趣的是如何处理这个结果。
假设我想在解析整数的基础上构建,我想解析一个整数,然后取这个解析整数的倒数,用1除以这个整数。
parse_int返回我一个expected。exp有一个非常简单的接口。我可以将其转换为bool来检查这个expected中是否包含有效值。如果是,我可以访问这个值并对其进行操作。在这里,我检查这个整数是否为0,因为不能除以0。std::unexpected是一个方便的构造函数,我可以直接给它我的新错误信息,这将自动转换为我在这里想返回的std::expected<double, std::string>。如果我的整数不是零,我可以返回1除以该整数的结果。在else部分,我也可以通过.error()访问错误并进行转换。
这是一个非常简单的接口,但这不是我们应该编程的方式,我认为这不是使用std::expected最方便的接口。std::expected还有一个非常优雅的、单子式的函数式接口。我想在这里指出三个函数。
transform:这个函数只会在std::expected包含有效值时被调用,然后它可以转换那个值。transform_error:类似,它让你转换包含的错误。and_then:这个函数也只会在std::expected包含有效值时被调用,它让你将整个std::expected转换为另一种类型的expected。
这里我们转换整数。我们将错误从一个简单的枚举转换为一个更用户可读的字符串。最后,我们做和之前一样的事情,检查整数是否为0,否则用1除以该整数。
这是一个非常优雅的接口,我认为这就是我们应该用std::expected编程的方式。这个函数现在不包含任何显式的流程控制语句,这使得推理程序状态变得容易得多,更容易理解我们确实覆盖了所有情况,没有在这长串的if-else语句中遗漏任何情况,这使得犯错变得更难。
因此,我认为std::expected可能将成为未来C++中返回错误的默认方式,因为它功能非常强大,同时又非常简单。
顺便说一下,如果你有问题,可以随时提问。我们最后也有提问环节。但如果你想打断我,可以。
问:
transform和transform_error可以调换顺序吗?比如是否必须先处理某些事情,然后再做其他操作?答:我想我可以改变
transform和transform_error的顺序。transform_error转换错误类型,在这个例子中,从枚举转换为字符串。我想我必须在最后做and_then,因为到那时我已经有了一个expected<string>,现在我返回更多字符串,我想我必须这样做。
C++26合约:守卫程序不变式
这让我想到了今天我想简要介绍的另一个C++特性,因为我也认为它与捕获和处理错误有关,即使它是另一种错误,那就是合约。
我在这里链接的由Tim Moore(他也是本次会议的程序主席)撰写的合约论文是一篇很长的论文,但它非常易读,是一个非常全面的提案,尽管篇幅很长,但概念上非常简单。他自己写了一篇博客文章“五分钟解释合约”,其中包含了你需要知道的最重要的事情的基础知识。
简而言之,合约是一种更灵活的新标准机制。与之前的机制不同,它不是用于检查环境行为或操作系统函数的不同行为。它用于守卫我们代码中的不变式,防止程序错误。当然,我们可以编写更好的断言,可以在函数上编写前置或后置条件。
这个合约提案中最好的特性之一是,我们可以在构建时配置如果违反了这些合约之一,实际会发生什么。
这里有一个非常简单的例子,一个小函数。我们有一个前置条件,检查这个参数不为零。如果你写一个前置条件,该前置条件当然只能访问函数参数。你在该前置条件中捕获的变量x是隐式const的,所以你不会意外地改变你的函数参数之一。我们可以编写后置条件。这些后置条件也可以捕获这里的返回值,捕获为r。它们也可以引用函数参数,以建立两者之间的关系。如果一个后置条件引用了函数参数,那么这个函数参数必须声明为const,它不仅是隐式const的,你必须声明为const,以确保你没有在函数体内意外地改变这个函数参数,否则这个后置条件就没有意义了。我们可以直接编写更好的断言,合约断言。它们更好,因为它们不仅仅是一个在发布版本中自动移除的宏,它是可配置的。
那么这些内容何时被求值呢?对于C++来说,这都是非常、非常合理的默认值。前置条件在参数初始化之后、函数体运行之前被求值,因为你当然可以访问它们。后置条件必须在返回值初始化之后、局部变量销毁之前被求值,否则你无法捕获它,但局部变量的销毁可能会再次操纵函数内部的状态。
合约支持异常,意思是如果你在合约断言中抛出异常,这也会导致合约违规,同样非常合理。它们也支持常量表达式上下文,然后它们会变成编译错误。
正如我最初所说,合约的行为可以在构建时配置。我们可以选择四种不同的语义来定义当合约被违反时会发生什么。
- 忽略语义:这意味着,如果合约被违反,我们什么都不做。
- 观察语义:这意味着将调用一个合约违规处理程序,它可以做任何你想做的事情,然后程序继续执行。
- 强制语义:这意味着将调用合约违规处理程序进行日志记录或其他操作,然后程序终止。
- 快速强制语义:这意味着我们尽快终止。
在CppCon Siege上,John Lakos提出了一个很好的直觉,说明哪些用例应该使用哪种合约语义。在我们的案例中,你知道桌面应用程序与安全不是特别相关,我们已经总是实现观察语义,所以我们进行错误处理和报告,然后我们抱最好的希望并继续执行。因为可能发生的最坏情况是程序崩溃,但这比用错误消息打扰用户要好。强制语义可能是Bloomberg本身,也许你知道,出了问题,你有足够的时间进行日志记录,你想找出这个问题。但然后你终止,因为你不想损失数百万美元。快速强制语义是医疗设备。某些东西超出规格。你立即停止。你没有时间进行日志记录。
我们也可以自定义合约违规处理程序。这里有一个非常简单的示例代码,我认为直接来自论文,你只需要定义全局函数handle_contract_violation,它在这里接收一个contract_violation实例,你可以检查语义是什么,在哪里,然后最重要的是,可能委托给你自己的错误报告机制或默认实现。
合约断言明确地可能有副作用,例如日志记录,发送错误报告将是一个副作用。但论文说它们不允许有破坏性副作用,破坏性副作用是会影响程序正确性的副作用。在一个正确的程序中,如果你把所有合约断言都去掉(比如使用忽略语义),你的程序必须仍然工作,否则你就犯了一个非常严重的错误。断言本身当然也应该是完整和独立的。它们不应该有改变未来前置或后置条件正确性的副作用。在C++中任何事情都是可能的,但这不是你应该做的。它们具有零开销,意思是如果你为你的合约选择忽略语义,那么它应该对程序行为没有影响。
因此,合约也是一个我非常期待的巨大特性,我看到了一个非常重要的优势,特别是如果你目前正在使用像Boost这样的库。那么通常你会在代码库的最开始有类似这样的代码来配置Boost断言的行为,然后配置Boost断言应该以某种方式与你自己的断言机制相关联。我希望如果库使用合约,我们可能都能使用一个单一的合约违规处理程序来处理我们使用的所有库。那将会非常好。
库强化:强制执行标准契约
我想提到的最后一个C++ 26特性是库强化。
库强化意味着我们现在想要强制执行我们一直在标准中记录的前置和后置条件。标准说,你知道,解引用迭代器的前置条件是这个迭代器必须指向一个有效元素之类的东西,但我们从未强制执行过任何这些内容。我们从未断言过。它只会崩溃,希望在发布版本中,也许它不会崩溃。所以现在我们想要强制执行它们,我们想为所有这些前置和后置条件插入合约断言。
微软很长时间以来都有类似的东西,他们有我们一直使用的调试迭代器,帮助捕获了很多错误,这些将来也将基于合约。所以Clang支持这个,GCC支持这个,据我所知微软也支持这个,我猜我不能……所以这些都还没有使用合约,Clang没有,Visual Studio也没有,它们都只是断言并可能终止程序,但至少断言在那里,它们帮助你编写正确的程序。
到目前为止有什么问题吗?提出好问题的每个人都会得到一双Thingstel袜子。我还剩四双。
实践策略:聚焦与工具化
我们在C++中有这么多选项,这么多好的选项,但我们的时间如此之少。那么我们在实践中做什么呢?正如我所说,我们不是为了处理错误而获得报酬,我们是为了解决客户问题而获得报酬,所以我们必须集中精力,我们必须把精力集中在最重要的领域。我们想要有非常好的工具来处理错误,否则我们的程序员不会去做。因此,我们必须拥有易于使用的错误处理工具,不仅包括标准工具,还包括我们自定义库中的工具。我们拥有的所有错误处理代码都必须经过测试,否则它可能根本不起作用。我们必须将所有努力集中在实践中最重要的地方,可能你的代码中只有很少的区域会在错误处理上做大部分工作。
那么我们在实践中编程时做什么呢?当我们使用任何其他外部API或操作系统API时,我们检查每一个API调用。这里我有两个例子,非常简单的API。在POSIX系统上获取主机名,gethostname函数在成功时返回0。我们这样写。如果由于某种原因失败,它也会设置errno变量。所以我们有这个ERRNO宏来检查errno变量是否被设置为任何值。在Windows上有一个类似的约定,通常Windows API函数在成功时返回0。如果它们不返回0,那么它们也会设置这个GetLastError。所以我们有这个API_R宏来检查这些情况。
因此,我们检查每一个API调用,并且我们使这变得非常、非常容易。事实上,我们为在Windows、macOS或其他系统上发现的各种返回码模式准备了不同的包装器。它们检查这个错误变量GetLastError,每个结果对应一个。
我们还想确保即使在代码审查中,这也是你在代码审查中首先要看的事情:程序员是否考虑到了所有可能返回的错误?我们被训练得如此习惯于这样做,以至于我们甚至有一个注解RETURNS_VOID,以确保我们可以注解我们已经考虑过错误处理。但这个函数不返回任何错误。所以没有什么可做的,因为否则,在代码审查中,我会立即说,这个呢,你忘了错误处理。
我们也积极地使用断言。断言前置条件、后置条件、不变式,并且这些断言保留在发布版本中。我们默认使用noexcept,除非我们当然知道某些东西会抛出异常,在这种情况下,就像RETURNS_VOID一样,我们也注解函数为throwing,尽管这没有任何标准含义,它只是一个注解。
我们默认使用noexcept,我们也注解我们知道会抛出异常的函数,所以我没有忘记noexcept,这个函数实际上会抛出,即使没有语言特性来注解这一点。当然,写太多noexcept可能会导致我们的程序终止。但如果我不知道这个函数会抛出,而且我反正没有处理异常,那么程序在某个时候反正也会终止。所以我们可能立即终止。相反,我们做的是安装一个终止处理程序,以便在我们写了不该写的noexcept或者我们只是忘记了异常处理时得到通知。
错误响应:收集信息与继续执行
现在我们已经注解了一切,检查了所有这些返回码,我们如何处理它们呢?默认情况下,我们假设一切正常。所有这些函数永远不会返回错误。我们只会捕获它们曾经返回的任何错误。这里的目标是保持代码路径集合的规模较小。我们不想在不需要的地方编写错误处理代码,我们无法重现它,它不会被测试,而那里它将是错误的。我们希望保持程序简单,程序状态集合小。当然,唯一的例外是那些你事先知道会发生错误的情况,打开文件是经典的例子。在这里,打开文件在成功时会返回一个非负值,即文件句柄。它可能返回一个无效的文件句柄和EINTR,在这种情况下,我们必须重试。然后有一个错误代码列表,我们知道它们可能发生,并且我们可以接受打开文件失败。文件不存在,我们没有访问权限,磁盘上没有空间,典型的文件错误场景。我们可能可以重现它们,如果我们能重现,那么我们当然会处理它们。
Windows上类似,API错误也一样。这里是我知道可能发生的错误代码列表。我想忽略它们。如果RemoveDirectory返回任何其他错误,例如ERROR_SHARING_VIOLATION,我想被通知。
那么,当这些检查之一失败时,我们现在做什么呢?首要任务是收集尽可能多的信息。因此,我们的客户端应用程序发送错误报告,它会自动创建一个错误报告,其中包含整个调用栈,在Windows上是小型转储,在macOS上也是小型转储,这些只包含栈内存,所以可能只有几百千字节大小,如果客户给了我们这样做的权限,我们就把它们发送回我们的后端。我们的服务器应用程序只是一个用于我们自己后端的私有服务器,会挂起线程并通知管理员,以便管理员可以查看实时系统,看看出了什么问题。
第二个优先事项,正如我所说,是继续执行。现在的行为是未定义的。但这只是意味着我们将禁用任何进一步的报告。我们不终止,特别是在断言之后,我们甚至不为断言显示错误消息,断言只是可能出错的代码。我们希望确保我们的程序员编写大量断言,编写大量不变式,我们想激励他们这样做。如果客户每次断言出错都打电话来,那么我想人们就不会写那么多断言了。
这就是合约世界中的观察语义。现在我们收到了很多来自那百万客户的错误报告,每个人都遇到过几次错误的断言。我们在后端收集它们,我们有一个小应用程序可以用来查看它们、过滤它们,并找出最常见的场景。当然,我们经常发现我们永远无法在内部重现的场景:软件与一些疯狂的安全软件冲突的地方,管理员错误配置了系统的地方,由于他们在非常慢的机器上使用而导致的时序问题,各种不同的问题。
因此,我们可以查看发生此错误的操作系统、我们软件的哪些版本,我们可以进行全文搜索。对于每个单独的块,我们看到发生错误的代码行、在哪些版本中、在哪些时间跨度内,等等。这里很酷的一点是,这个数据库实际上在遇到问题的客户端和我们之间建立了一种双向通信,因为在这里我们可以输入修复了这个错误的构建版本。当另一个客户遇到相同的错误时,它会向我们发送错误报告。后端会注意到,嘿,这个错误在这个版本中已经修复了,它会回复那个版本。客户端将自动下载甚至自动安装这个更新,客户将永远不会再遇到这个问题。在最好的情况下,它只会被默默地修复。这里有更多信息,哪些客户状态,等等。这使我们能够做到这一点。它还允许我们要求客户弹出消息并说,是的,你遇到了这个错误。你能告诉我们更多关于这个的信息吗?请联系支持。
因此,我们收集更多信息,并尝试在家里重现这个错误。因为我们只想为那些我们可以重现的错误添加处理。这样错误处理就是可测试的。在最好的情况下,我们找到了问题的实际修复方法,一个实际的变通方案,而不是仅仅显示我最初给你看的那个消息框。
我们还做另一件事来定制行为,我认为合约提案也在努力扩展合约以实现这一点,因为我们根据严重性对我们遇到的错误进行分类,这影响了我们在遇到这些错误之一时的行为。
错误分类与分级处理
因此,存在关键错误。例如,空指针访问,如果任何这些API调用失败或断言失败。这意味着这些错误总是严重的,不应该发生,因为它们总是程序错误。所以我们不必为它们编写处理程序,我们只想在发生时得到通知。程序,就像我说的,之后处于无效状态,不会发送进一步的错误报告。所以我们发送错误报告,禁用报告,通常根本不向用户显示消息。
下一个不太严重的错误类别是我称之为“某种程度上未经测试”的情况。这是定义的行为,但不清楚这将如何发生。这里有一个小代码示例。我们得到一个范围,可能是一个字节范围。我想从中提取Unicode码点值。我在这里假设,我想验证这总是一个有效的码点。因为我无法重现这不是真的情况。但在这里,错误处理也非常简单。我总是可以返回Unicode替换字符。所以这是一个定义行为的情况。我知道如果我的假设不成立会发生什么。我只想在这个假设不成立时得到通知。所以理论上,这可能发生。我们确实发送错误报告。我们不禁止进一步的错误报告,我们只是限制它们,因为这可能经常发生。不幸的是,如果我们运气不好,我们仍然会正常继续执行。
再低一级的优先级可能是第三方错误。我们遇到了其他东西中的错误,我们与之交互,我们可以处理这个错误,我们已经重现了它,我们支持它,我们已经测试了它。但它稍微降低了用户体验。所以用户可能会向我们投诉,他们可能会打电话给我们的支持团队。所以这可能是我们总是记录的事情,但我们永远不会报告给我们的后端,我们已经知道了。但如果用户曾经向我们的支持团队投诉,我们可以查看日志文件,我们看到这是问题所在,我们可以礼貌地告诉他们应该把愤怒指向哪里。

再低一级的优先级可能是奇怪的系统配置。例如,在Windows上,你可以配置你的十进制分隔符,你可以说空格字符应该是你的十进制分隔符。这有一些令人惊讶的效果,不是每个软件都能处理。我们再次重现了这一点,我们支持这一点,我们可以接受这一点,但它仍然可能导致奇怪的问题。所以这是我们可能只在调试版本中记录的事情,但同样,人们会打电话给我们说,我有这个奇怪的问题,我们可以弄清楚,是的,那可能是空格分隔符的问题。所以我们可以设置,我们的支持工程师可以在系统上设置一个标志,然后即使在发布版本中也能启用记录这个,因为这可能是支持电话的原因。
好的,然后我们有了所有这些错误场景。我们最近和Tim Moore聊过,他很好奇人们如何在实践中使用错误。所以我希望其中一些能进入下一个合约提案。正如我所说,我们进行错误报告,并将调用栈发送到我们的后端。
错误报告系统:实践与后端分析
所以我们有自己的系统,有点类似于Google的Breakpad,但我们可以报告各种错误,所以每当我们的程序遇到失败的断言、意外的API返回码时,我们启动一个错误处理进程,我们进行进程外错误处理,这是你应该总是做的事情,因为如果你的进程遇到错误,你不知道你的进程处于什么状态,也许它已经无法发送错误报告了。所以我们进行进程外处理,我们挂起发生错误的进程,收集我们需要的所有信息,创建小型转储,如果客户给了我们这样做的权限,就上传它。然后我们在服务器端进行分析,我们符号化,加载这些小型转储,符号化它们,按它们在代码库中发生的位置对它们进行分组,这样我们就可以得到这个可视化,并找到导致最多问题的代码位置,并首先修复它们。
说到这里,我差不多准时结束了。非常感谢大家的关注。

我们还有时间提问。用袜子交换。是的。
问:我很想听听你对
std::error_code、std::error_condition和std::error_category的看法。答:我们不用那些。我们发现它们处于错误的抽象层次。当我们开始进行跨平台编程时,我们必须从代码库中移除各种Windows依赖,并引入跨平台接口。我们实际上从未有过需要将问题的原因传递出去的场景。我们以不同的方式捕获了这一点。假设你有一个文件操作,创建临时文件,写入临时文件,无论什么,在这个文件抽象的很深处,你在某个地方进行实际的
fwrite调用,在那里你必须进行错误处理。只有一系列允许发生的事情:磁盘空间不足,等等。在这些情况下,我抛出这个异常。所有其他情况都是不允许发生的,我想报告它们。但在外部,试图写入文件的用户并不关心为什么失败。我只知道JSON解析、HTTP请求、文件操作可能因一系列允许的原因而失败。但通常,我不关心原因。我不关心是请求失败、响应失败还是其他。这是我们从未真正做过的事情。所以,我们关心错误代码的情况非常接近操作系统函数。而在这里,我不想要任何跨平台抽象,因为Windows上的ERROR_SHARING_VIOLATION如何转换为任何E...错误条件?就像,我甚至不想考虑它。所以CreateFile可以返回ERROR_SHARING_VIOLATION,我必须处理它。然后我,是的,可以处理它或不处理。所以这是我们从未发现有用过的东西。
问:关于
std::expected,我想知道你对从虚函数返回什么样的错误有什么看法。例如,我们有一个名为Runner的类,然后有一个名为run的虚函数,它返回一个带有某种错误的std::expected。有两个实现。一个是狗,所以你可以让狗跑,它可以成功跑,或者因为累了或饿了而不跑。另一个实现是汽车,你可以……没油了,电池没电了。狗没有电池或燃料,汽车没有情绪。在这个虚构的例子中,你会怎么想?答:是的。所以我在审查代码时给其他程序员的最喜欢的答案总是和之前一样的答案,对吧?弄清楚你是否真的需要这个。即使对于
std::expected,也是同样的论点。std::expected有一个非常、非常好的竞争对手。那就是std::optional。它用于某些事情……我不关心原因,对吧?我的意思是,有争论。也许你关心原因,因为你想显示更好的错误消息,等等。我明白了,然后你需要传递一些东西出去,但如果情况是这样,你可能也确切地知道错误场景是什么以及你必须报告什么。所以在一个虚构的例子中很难说。我不知道,是的。
问:之前当你谈到Thingstel并不是在一个高度安全的环境中运行时,你说当遇到错误写入或插入失败时,尽可能继续执行。你是否认为这是对安全风险的潜在权衡?一旦你的程序进入未定义状态,是的,发生了错误。当然,这可能是一个安全问题。
答:如果我们的软件不仅仅是一个简单的办公应用程序,那么是的,你是对的。但到目前为止,可能发生的最坏情况是编辑器崩溃。所以相比之下是良性的。是的,但没错。是的,如果我们在其他环境中,我们会做其他事情。是的,这只是上次谈话的内容。现在,是的,你明白了。
问:你对
std::expected不从析构函数抛出异常有什么看法?这是你会怀念的东西,还是std::expected不从析构函数抛出异常?答:昨天,我和
std::expected的起源,非常、非常原始的起源Expected<T>的Andrei Alexandrescu聊过。Expected<T的一个特别之处是,如果你没有检查错误代码,它会从析构函数抛出,这很聪明。但人们不喜欢从析构函数抛出异常的结构体,还有很多其他原因。但这会使std::expected变得非常特别,而不仅仅是另一种optional。你仍然可以断言。但即便如此,我也不确定,就像我遇到的所有其他情况一样,我甚至不那么关心错误代码,我不确定这是一个好的设计选择。如果我写一个库,我可能必须使用std::expected并返回错误代码。但就像我说的,在我们的应用程序中,我们可能甚至不在乎它只是被允许不工作,这没关系。所以我不会检查它。如果你想要那样,你仍然会使用断言,对吧?它们也会起作用。谢谢。
问:谢谢你的演讲。我猜你有一个“没有袜子”的错误,但如果你能……我没有。既然你提到了代码审查,我只是想知道你如何确保开发者已经考虑了函数可能返回并处理的所有错误、异常或错误码。这是一个手动过程吗?你必须查看文档然后弄清楚吗?还是……
答:是的,然后你必须知道你正在审查谁。如果你偶尔探查,如果这真的是全部,是的。是的。这是你必须学习的东西,我想,在某个时候,如何阅读这个,如何彻底阅读这个,如何区分可能发生的情况。我发现微软文档非常好,例如,他们经常解释可能发生什么。是的,这是一个手动过程,或者是的,是的。
问:在允许你的程序崩溃之前,尝试尽可能多地保存用户数据,这是一个好主意吗?
答:是的,但尝试尽可能多地保存用户数据,当然是个好主意,但今天大多数应用程序都这样做,对吧?你崩溃了,再次打开它,你回到了你之前的位置。所以幸运的是,这在很大程度上是一个已解决的问题。是的。
还有其他人吗?否则,谢谢你的时间,享受南瓜吧。

总结

本节课中,我们一起学习了C++错误处理的演进与实践。我们从基础的C风格错误码开始,了解了其不便之处。接着探讨了C++异常,它提供了语义丰富的错误信息,但引入了控制流复杂性和性能考量。然后,我们深入研究了std::expected,它结合了返回值和异常的优点,提供了类型安全、可组合的错误处理方式。我们还预览了C++26的合约特性,它用于守卫程序不变式,并可根据构建配置灵活处理违规。最后,结合Thingstel的实际经验,我们讨论了在实践中应聚焦于可重现、可测试的错误,建立分级的错误分类与响应机制,并构建强大的错误报告和分析系统,以高效地交付稳定、有价值的软件产品。记住,错误处理的最终目标不是编写完美的错误处理代码,而是快速交付稳定的产品,解决用户的实际问题。
032:减少不必要的对象


概述
在本教程中,我们将学习如何在C++中识别并减少不必要的对象创建。不必要的对象会导致额外的内存分配、运行时代码执行和资源消耗,从而影响程序性能。我们将探讨从基础策略到高级技巧的一系列方法,包括使用视图类型、就地构造、透明比较器和编译时数据构造,以帮助您编写更高效的C++代码。
1:理解问题:为什么额外的对象是个问题?🤔
C++默认是值语义语言,这意味着在赋值时复制的是值本身,而不仅仅是指针。因此,当创建对象副本时,会调用复制构造函数,导致新的对象被创建。
考虑以下代码:
std::string str = "hello world";
std::string str2 = str;
std::cout << str2;
我们实际上只是想打印“hello world”。更优的写法是直接打印,避免创建str和str2:
std::cout << "hello world";
即使开启优化,第二个代码片段生成的代码量也少得多。这里的str和str2就是不必要的对象。
额外对象带来的问题
- 昂贵的运行时操作:对象创建可能触发内存分配、操作系统调用或运行复杂算法。
- 执行更多代码:如第一个例子所示,不必要的步骤增加了运行时负担。
- 占用更多内存:需要更多的栈或堆空间。
何时可以接受额外对象?
对于小型且构造不昂贵的类型,创建额外对象是可以接受的。根据经验,在64位系统中,大小小于等于24字节且没有昂贵复制操作的类型可视为“小型”。例如,std::string_view(16字节)和std::span(24字节)。
如何检测额外对象?
推荐使用编译器警告-Wall和-Wextra。在整个教程中,我们会提及特定的警告和Clang-Tidy检查来帮助识别问题。
2:基础策略:避免创建临时对象 🛠️
上一节我们介绍了额外对象的问题,本节中我们来看看一些避免创建临时对象的基础策略。
将非平凡类型作为只读函数参数
当一个函数只需要读取一个非平凡类型(如std::string)时,按值传递会导致不必要的拷贝。
不推荐的写法:
void foo(std::string s) { /* 只读取 s */ }
推荐的写法:
void foo(const std::string& s) { /* 只读取 s */ }
对于std::string,更优的选择是使用std::string_view,我们稍后会详细讨论。
可以使用Clang-Tidy检查performance-unnecessary-value-param来发现此问题。
返回类的非平凡成员对象
当从一个成员函数返回类的非平凡成员时,应返回常量引用以避免拷贝。
示例:
class MyClass {
A a; // A 是非平凡类型
public:
const A& getA() const { return a; } // 推荐:返回常量引用
A getANotGreat() const { return a; } // 不推荐:导致拷贝
};
如果调用者将返回值存储为对象而非引用,拷贝仍会发生。可以使用Clang-Tidy检查performance-no-automatic-move和performance-unnecessary-copy-initialization来捕获此类情况。
使用基于范围的for循环
在遍历非平凡对象容器时,应使用引用以避免拷贝。
以下是三种写法的比较:
std::vector<A> vec = getVec();
for (const auto a : vec) { ... } // 不好:创建拷贝
for (const auto& a : vec) { ... } // 好:无拷贝
for (auto&& a : vec) { ... } // 好:无拷贝(转发引用)
可以使用警告-Wrange-loop-construct或Clang-Tidy检查performance-for-range-copy来发现应使用引用的循环。
3:进阶技巧:结构化绑定、返回值与Lambda捕获 🧩
上一节我们介绍了函数参数和循环中的基础优化,本节中我们来看看一些更具体的场景。
结构化绑定
使用结构化绑定时,对于只读变量,应使用const auto&以避免拷贝。
示例:
struct B { A a; int i; };
B b;
auto [a1] = b; // 不好:调用拷贝构造函数
const auto& [a2] = b; // 好:无拷贝
从函数中显式移出对象
关于函数返回值,有两个重要的优化:返回值优化(NRVO) 和 强制拷贝省略(C++17起)。
不要阻碍NRVO:
A foo() {
A a;
return std::move(a); // 不好!显式move阻碍了NRVO
}
A fooBetter() {
A a;
return a; // 好!可能触发NRVO或强制拷贝省略
}
使用警告-Wpessimizing-move可以检测到阻碍NRVO的显式move。
需要显式move的场景:
当返回一个本地对象的成员,或通过结构化绑定返回时,需要显式使用std::move来避免拷贝。
B foo() {
B b;
auto& [a] = b;
return a; // 不好:调用拷贝构造函数
}
B fooBetter() {
B b;
auto& [a] = b;
return std::move(a); // 好:调用移动构造函数
}
Lambda捕获
Lambda按值捕获对象会导致拷贝。当安全时(确保被捕获对象在Lambda生命周期内有效),应使用按引用捕获。
示例:
A a;
auto lambda1 = [a](){}; // 按值捕获:导致拷贝
auto lambda2 = [&a](){}; // 按引用捕获:无拷贝
对于成员函数,[*this]会拷贝整个对象,而[this]和[&]仅捕获指针。在C++20及以后,应避免使用默认捕获[=],因为它已被废弃。
对于可变参数,也应使用引用捕获以避免拷贝:
template <typename... Args>
void foo(Args&&... args) {
auto lambda1 = [args...](){}; // 拷贝每个参数
auto lambda2 = [&args...](){}; // 无拷贝
}
4:字符串与向量:使用视图和预留空间 📊
上一节我们探讨了函数和Lambda中的优化,本节我们将目光转向两个最常用的类型:std::string和std::vector。
使用std::string_view
std::string_view是一个轻量级的、不可修改的字符串视图,可以避免从字符串字面量或字符数组创建std::string对象。
示例:
void foo(const std::string& s);
void fooBetter(std::string_view sv);
foo("hello"); // 创建临时 std::string 对象
fooBetter("hello"); // 无临时对象,使用 string_view
string_view可以处理std::string、const char*和带长度的const char*,且不进行堆分配。但注意,string_view不一定以空字符结尾,因此不能直接用于需要C风格字符串的API。
字符串拼接优化
使用+运算符拼接多个字符串会产生多个临时对象。
std::string s = s1 + s2 + s3; // 创建2个临时字符串
更高效的方法是使用reserve预分配空间,然后进行拼接。
std::string my_string_cat(std::string_view s1, std::string_view s2, std::string_view s3) {
std::string result;
result.reserve(s1.size() + s2.size() + s3.size());
result.append(s1);
result.append(s2);
result.append(s3);
return result;
}
即使对于小字符串(利用短字符串优化),reserve方法也仅创建一个对象,因此更优。
使用reserve优化向量
向std::vector添加元素时,如果空间不足,向量会重新分配内存并移动现有元素,这很昂贵。
不推荐的写法:
std::vector<A> vec;
for (int i = 0; i < 4; ++i) {
vec.emplace_back(i); // 可能导致多次重新分配
}
推荐的写法:
std::vector<A> vec;
vec.reserve(4); // 预分配足够空间
for (int i = 0; i < 4; ++i) {
vec.emplace_back(i); // 无重新分配,就地构造
}
可以使用Clang-Tidy检查performance-reserve-in-vector-construction来发现此类问题。
使用std::span避免向量创建
std::span是一个轻量级的连续序列视图,可以避免从C风格数组创建std::vector。
示例:
void foo(const std::vector<int>& v);
void fooBetter(std::span<const int> sp);
int arr[] = {1, 2, 3};
foo({std::begin(arr), std::end(arr)}); // 创建临时 vector
fooBetter(arr); // 无临时对象,使用 span
span还能与其他连续容器(如std::array、std::initializer_list)一起工作。
5:标准库类型与容器:善用就地构造 🏗️
上一节我们学习了字符串和向量的优化,本节我们将把就地构造的理念应用到更多标准库类型和容器中。
初始化列表std::initializer_list
使用初始化列表构造容器(如std::vector)会导致元素先被创建,然后拷贝到容器中。
std::vector<A> vec = {A(1), A(2), A(3)}; // 创建3个临时A对象并拷贝
更好的方法是使用reserve和emplace_back进行就地构造。
std::vector<A> vec;
vec.reserve(3);
for (int i = 1; i <= 3; ++i) {
vec.emplace_back(i); // 就地构造
}
std::pair和std::tuple
对于std::pair,直接构造可能导致拷贝。C++23确保了移动构造,但在此之前或为了最佳性能,应使用std::piecewise_construct进行就地构造。
// 直接构造(C++23前可能拷贝)
std::pair<A, A> p(A(1), A(2));
// 就地构造(推荐)
std::pair<A, A> p2(std::piecewise_construct,
std::forward_as_tuple(1),
std::forward_as_tuple(2));
std::tuple没有piecewise_construct,但可以使用std::make_tuple(移动构造)或C++17的类模板实参推导(CTAD)来避免拷贝。
std::optional, std::expected, std::variant
对于这些可容纳值的类型,应使用其就地构造函数(std::in_place或std::in_place_type)或对应的make_函数(如std::make_optional),以避免先构造后移动。
std::optional<A> opt1 = A(10); // 构造后移动
std::optional<A> opt2(std::in_place, 10); // 就地构造(推荐)
对于赋值操作,使用emplace方法(如opt.emplace(20))也能实现就地构造,但注意它会先销毁当前值。
std::array与std::to_array
std::to_array虽然方便,但会导致元素被移动构造。
auto arr = std::to_array<A>({A(1), A(2)}); // 移动构造
直接使用std::array构造函数可以实现就地构造,尽管语法稍显冗长。C++17的CTAD可以部分改善这一点。
std::array<A, 2> arr2 = {A(1), A(2)}; // 就地构造(推荐)
向容器添加元素:emplace系列函数
对于所有标准库容器,优先使用emplace、emplace_back、emplace_front、try_emplace(对于map)等函数,而不是push、insert,因为它们直接在现场构造对象,避免了临时对象的创建和移动/拷贝。
可以使用Clang-Tidy检查modernize-use-emplace来自动建议此类替换。
6:关联容器:透明比较器与高效插入 🔑
上一节我们涵盖了序列容器,本节我们来看看如何优化关联容器(如std::map、std::set)的使用。
使用emplace和try_emplace
对于std::map,使用下标运算符[]插入非平凡值类型时,会先默认构造一个值,然后进行赋值,效率低下。
std::map<int, A> m;
m[10] = A(20); // 1. 默认构造A, 2. 构造A(20), 3. 移动赋值, 4. 析构两个临时对象
应使用emplace或try_emplace进行就地构造。
m.emplace(10, 20); // 就地构造键值对(推荐)
m.try_emplace(10, 20); // 同上,且键存在时不构造值(更优)
当键已存在时,try_emplace保证不构造值对象,而emplace不提供此保证。
处理多参数构造函数
当键或值类型的构造函数接受多个参数时,需要使用std::piecewise_construct。
// 错误:无法直接传递多个参数
// m.emplace(B(1,2), B(3,4));
// 正确:使用 piecewise_construct
m.emplace(std::piecewise_construct,
std::forward_as_tuple(1, 2), // 就地构造键
std::forward_as_tuple(3, 4)); // 就地构造值
insert_or_assign
对于同时需要插入和赋值的场景,使用insert_or_assign比先检查再使用[]赋值更高效,因为它能利用移动语义。
// 使用 insert_or_assign
auto [it, inserted] = m.insert_or_assign(10, A(30));
透明比较器
当在std::set<std::string>或std::map<std::string, ...>上使用find、count、contains等方法,并传入一个const char*(字符串字面量)时,编译器会隐式创建一个临时的std::string对象用于比较,这会产生不必要的分配。
问题示例:
std::set<std::string> s = {"hello", "world"};
const char* test = "hello";
s.find(test); // 隐式创建临时 std::string,触发内存分配
解决方案: 使用透明比较器,如std::less<>(对于有序容器)或自定义的透明哈希与相等比较(对于无序容器)。
// 有序容器
std::set<std::string, std::less<>> s = {"hello", "world"};
s.find(test); // 无临时对象,直接比较 const char*
// 无序容器
struct MyStringHash {
using is_transparent = void;
size_t operator()(std::string_view sv) const { /* ... */ }
size_t operator()(const std::string& s) const { /* ... */ }
size_t operator()(const char* s) const { /* ... */ }
};
std::unordered_set<std::string, MyStringHash, std::equal_to<>> us;
us.find(test); // 无临时对象
透明比较器通过内部的is_transparent类型标识,允许容器用不同的可比类型直接进行比较,无需转换。
7:编译时数据构造:将工作提前到编译期 ⚡
我们之前讨论的都是如何在运行时避免临时对象。终极优化是将对象的构造从运行时转移到编译时。编译时构造的对象不占用运行时初始化时间,其内存也在编译期就布局好。
局部和全局对象
对于不会被修改的局部const对象或全局对象,可以考虑将其变为编译时常量。
全局对象问题:
const std::string kGlobalStr = "hello"; // 运行时初始化
const std::vector<int> kGlobalVec = {1, 2, 3}; // 运行时初始化
使用编译标志-Wglobal-constructors和-Wexit-time-destructors可以警告此类运行时初始化的全局对象。
优化为编译时常量:
constexpr std::string_view kGlobalStr = "hello"; // 编译期
constexpr std::array<int, 3> kGlobalVec = {1, 2, 3}; // 编译期
将std::vector改为std::array,std::string改为std::string_view,并加上constexpr,即可在编译期初始化。
单例(Magic Static)的局限
有时人们用“Magic Static”(函数内的静态局部变量)来延迟全局对象的初始化。
const std::string& GetStr() {
static const std::string s = "hello";
return s;
}
这解决了全局构造函数的问题,但没有解决退出时析构的问题(-Wexit-time-destructors仍会警告)。将其改为constexpr视图或数组是更彻底的方案。
用户自定义数据结构
对于自定义的、包含字符串或向量的结构体,也可以尝试迁移到编译时。核心是将所有成员类型替换为编译期友好的类型(如std::string_view、std::array)。
// 运行时版本
struct MyStruct { std::string name; std::vector<int> data; };
const std::vector<MyStruct> kData = { ... };
// 编译时版本
struct MyStructCT {
std::string_view name;
std::span<const int> data;
};
constexpr std::array<MyStructCT, N> kDataCT = { ... };
编译期字符串拼接
如果字符串拼接的结果在编译期可知,可以编写工具类在编译期完成拼接。
constexpr std::string_view kPart1 = "Hello, ";
constexpr std::string_view kPart2 = "world!";
// 运行时拼接
auto runtime_str = std::string(kPart1) + std::string(kPart2);
// 编译期拼接(需自定义类,如 MyCompileTimeStringJoiner)
constexpr auto compiletime_str = MyCompileTimeStringJoiner<kPart1, kPart2>::value;
编译期集合(Set/Map)
目前C++标准库没有constexpr的std::set或std::map。C++23引入了std::flat_map和std::flat_set,并计划在C++26中使其成为constexpr。在此之前,可以考虑使用第三方库(如Chromium的fixed_flat_map)或为简单用例编写自己的编译期查找包装器。
总结
在本教程中,我们一起学习了如何在C++中减少不必要的对象创建以提升性能。我们从理解问题本质开始,探讨了值语义带来的拷贝开销。然后,我们学习了一系列从基础到高级的策略:
- 基础策略:对只读的非平凡类型使用常量引用参数、返回成员变量的引用、在基于范围的for循环中使用引用。
- 进阶技巧:在结构化绑定中使用
const auto&,理解NRVO并避免不必要的std::move,在Lambda中优先使用引用捕获。 - 字符串与向量优化:使用
std::string_view和std::span避免数据复制,使用reserve预分配内存,优化字符串拼接。 - 标准库容器就地构造:对
pair、tuple、optional、variant等使用就地构造函数或emplace系列方法。 - 关联容器优化:使用
emplace/try_emplace插入,利用透明比较器(std::less<>、透明哈希)避免查找时的临时对象创建。 - 编译时数据构造:将常量数据迁移到编译期,使用
constexpr、std::array、std::string_view等,消除运行时初始化和析构开销。
贯穿始终的是“就地构造”的核心思想:尽可能直接在目标内存位置构造对象,避免先构造后拷贝/移动。同时,善用编译器警告(如-Wall、-Wextra、-Wpessimizing-move)和Clang-Tidy静态分析工具,可以帮助我们自动识别许多优化机会。



性能优化需要结合实际场景进行测量,但掌握这些模式将为编写高效、现代的C++代码奠定坚实基础。
033:下移复杂性——扩展C++代码的真正路径 🛠️



在本节课中,我们将探讨如何通过“下移复杂性”来有效管理和扩展大型C++项目。我们将分析从汇编到现代高级语言的演进,讨论设计模式、构建系统、依赖管理等核心挑战,并思考未来编程语言的发展方向。
概述:软件工程的挑战与目标
作为一名程序员,我们日常的大部分工作被称为“软件工程”。这意味着我们需要将需求(无论是瀑布模型、用户故事还是书面要求)转化为可运行的代码。本节课将讨论我们如何交付软件,以及如何通过工程化手段让开发工作变得更轻松。
演讲者介绍 👨💻
我是Malin Stanescu,是Ofer(大陆集团的一家子公司)的高级软件工程师。我们专注于自动驾驶汽车技术。我的日常工作非常多样化,可能涉及USB描述符、变分原理,但主要与“运动恢复结构”相关,我是一名几何计算机视觉专家。
此外,我还参与了一个行星防御研究项目,旨在探测可能对地球构成威胁的小行星。我们使用一种称为“合成跟踪”的技术,通过叠加多张图像来增强信噪比,从而用较小的望远镜发现快速移动的暗弱天体。这项计算非常密集,但通过优化,我们已能将处理时间从几天缩短到几分钟。
我的工作核心通常是首先创建一个易于开发的环境,这能极大地简化创建可用软件的过程。
代码规模与团队复杂性 📈
C++ 拥有大量大型代码库。理解不同规模项目的挑战至关重要:
- 1-10行:简单的单行程序或实用函数,几乎不会出错。
- 100-1000行:包含函数和类的演示代码,易于实现和理解。
- 1万-10万行:包含小型组件和库,需要一个团队维护。此时持续集成(CI)管道变得重要。
- 100万-1000万行:需要多个团队协作。代码变更率很高,测试和CI系统面临巨大压力。
- 10亿行以上:可能出现项目特有的“方言”,甚至将编译器包含在代码库中。管理庞大的团队和快速的代码变更成为核心挑战。
随着团队和代码复杂性的增加,用于测试代码的时间反而减少。因此,工程化你的CI流程与工程化你的功能代码同等重要。
管理复杂性的策略:编译需求 🧠
管理这种复杂性的一种方法是“下移”复杂性,即简化代码或通过分工来扩展,而不显著增加维护负担。
关键在于你为什么而编写代码:
- 通用编程:如桌面应用或Web服务器基础设施。重点是让各个部分良好交互。面向对象编程是常见模式,它能将需求转化为业务对象,减轻思维负担。
- 嵌入式编程:资源固定且有限。程序员需要“编译需求”,即思考如何在实际约束下实现功能。这种方式促使你对代码进行推理,可能带来数据导向设计等优势,并有机会优化性能。
除了实现功能和性能,你还可以投入时间思考如何让代码对你和你的队友来说更易于使用和开发。这可以显著抵消理解需求所带来的负担,从而以更少的努力获得更好的产品。

设计模式与依赖注入的权衡 ⚖️
许多培训资料很少讨论设计模式的成本。例如,我曾尝试将小行星检测软件的各个模块集成到一个可执行文件中,通过动态加载提供灵活性。但这需要设计正确的接口、设置控制反转容器,并且调试变得复杂(需要配置IDE来启动宿主应用)。这些看似微小的痛点累积起来,可能导致项目延期数月。
依赖注入允许从外部切换实现方式。通常通过创建基类和派生类来实现。但在C++中,你可以使用静态依赖注入:
// 通过构建系统选择编译不同的实现文件
// config.h
#ifdef USE_IMPLEMENTATION_A
#include "implementation_a.h"
#else
#include "implementation_b.h"
#endif
这种方式无需动态派发(虚函数调用),没有运行时开销。但它要求项目具备构建系统,且测试时需要检查所有可能的构建配置组合,增加了测试复杂度。这听起来只对嵌入式(一次构建)有用,但高性能计算库(如BLAS)也常用此模式,针对特定硬件配置源码并在运行时构建。
C++的构建挑战与模块化 ⏱️
C++的构建时间是个老大难问题。但通过工程化努力,也可以实现快速构建。一个简单的“Hello World”程序在C语言中编译只需20毫秒,在C++中稍慢,但依然很快。
C++构建缓慢通常与标准库和模板的(过度)使用有关。以std::array为例,这个基础数据结构为了提供值语义(C语言数组是引用语义),引入了大量头文件和模板代码,导致编译单次就可能展开上万行代码。

相比之下,自己实现一个简单的数组类型可以避免引入迭代器等复杂头文件,初始化行为更明确,甚至能修复标准库中可能存在的bug(例如C++14中const访问器未标记为constexpr的问题)。但这会牺牲标准兼容性。
那么,C++不利于软件扩展吗?静态类型检查有助于确保正确性,但模板滥用会导致编译时间长、错误信息晦涩。静态依赖注入是避免模板滥用的一种方法。现代C++的特性(如概念、契约)能否帮助简化复杂性?从std::array的实现来看,它们往往只是在头文件中添加更多条件编译宏,并未从根本上简化。
此外,C++在处理多维容器时也不够优雅,将其转换为能高效优化(如内存拷贝)的简单循环并不容易。
提升开发效率的历史性跨越 🚀
历史上,几次重大的语言演进显著提升了开发效率:
- 汇编到C:引入了数据结构和结构化编程,极大降低了管理代码的思维负担。
- 自动资源管理:C++的RAII、Java/C#的垃圾回收、Python的自动内存管理。无需手动管理指针和内存,极大地提升了代码正确性,助力软件扩展。
现代C++特性(如模块)旨在解决构建时间问题和头文件机制的脆弱性。但模块与头文件不同,它不支持通过包含不同配置头文件来切换实现变体(例如Linux内核的Kconfig或Marlin固件的配置头文件方式)。
模块的核心是记忆化缓存编译结果。类似技术(预编译头文件)因混合不同翻译单元的状态而效果不佳。Rust编译器也严重依赖记忆化来获得可接受的构建速度。
开发体验与安全性 🔒
许多提升开发体验(DevEx)的特性也提升了安全性,反之亦然:
- 静态类型:帮助IDE提供准确的自动补全,也防止了类型错误。
- Rust的所有权模型:以内存安全著称,但借用检查器有时会拒绝实际上正确的代码,影响开发体验。
安全性的范畴更广。我曾在一个没有指针、无法直接访问内存的配置语言(Plant ML)中遇到内存损坏问题,原因是配置错误导致硬件缓冲区溢出。这说明,缺乏静态类型和检查机制,即使没有指针也不安全。
在功能安全领域,动态内存分配通常被禁止以避免内存碎片。因此,Rust引以为傲的所有权模型在此用处不大,因为所有数据都是静态分配的,生命周期与程序相同。
更有用的安全特性可能是禁止整型提升(C/C++中小的整型会被提升,导致隐蔽错误),以及更好地处理内存对齐问题(尤其在异构多核嵌入式系统中)。
依赖管理与部署困境 📦
将软件及其所有依赖交付到用户计算机是一个难题。
- 包管理器:系统包管理器可能没有所需版本;语言包管理器(如NuGet, Cargo)会遇到“钻石依赖”问题——两个依赖项需要同一个库的不同版本。NuGet类似C++虚继承,尝试统一版本;Cargo则允许每个包携带自己的依赖副本,导致代码膨胀。
- 传统解决方案:Linux发行版充当了“软件发行商”的角色,协调所有软件包共同工作。
- 现代方案:容器。但容器本质上也是复制一切,并非最优雅的解决方案。
这本质上是一个版本管理和变体管理问题。每次创建新版本都相当于创建了一个分支,需要维护。ABI(应用二进制接口)相对容易处理(可以重新编译),但API(应用编程接口)则难以改变,因为它是人类定义的契约。
反射、元编程与未来展望 🔮
C++23引入了反射。观察其他语言(如C#)的实现很有启发。C#可以通过库函数实现反射,无需额外语法关键字。C++由于担心ABI破坏,选择引入新的语言关键字和编译期元编程,这增加了语言复杂度,形成了“两个语言”(编译期和运行期)。
如果我们把编译器也视为一个软件组件,就可以挂钩并使用它。函数式编程中的“函数效应”概念可以将计算(如处理头文件)表示为对象,并结合环境信息使其近乎纯函数。这样,实现模块的记忆化就变成了缓存这些函数效应。这允许我们将代码视为数据,用普通函数操作它。
更重要的是,通过运行期反射,我们可以跨多个软件版本进行反射,实现API差异比较等高级功能。
最后,我们需要重新思考编程的本质。C++是编译型语言,但在硬件层面,它被解释为微指令在多个执行端口上运行。我们通常看不到内存访问的物理延迟。例如,在ARM64上清零内存,编译器可以生成使用DC ZVA指令的优化代码,该指令直接操作数据缓存,效率极高。

我们目前优化代码的方式(修改源码,祈祷编译器向量化)类似于“玄学”。未来的编程语言应该允许我们更直接地表达意图,让系统为我们完成优化。
总结


本节课我们一起探讨了扩展C++代码时面临的核心挑战与管理策略。我们从代码规模与团队协作的复杂性出发,分析了“编译需求”的思想、设计模式的成本、构建系统的优化,以及依赖管理和部署的困境。我们还对比了不同语言在开发体验与安全性上的取舍,并展望了通过反射、元编程以及更根本的语言设计来“下移复杂性”、提升开发效率的未来方向。关键在于,我们不仅要思考如何实现功能,更要思考如何构建一个让开发本身变得更简单的环境。
034:用AI提升开发效率 🚀




在本教程中,我们将跟随 Ion Todirel 在 CppCon 2025 上的演讲,学习如何将人工智能工具应用于实际的 C++ 系统编程项目中。我们将通过一个完整的业余无线电中继器/追踪器项目,探索 AI 在硬件设计、代码优化、库移植和系统集成等多个任务中的实际应用、成功经验与教训。
项目概述:中继器与追踪器 📡
本项目是一个完全从零开始构建的业余无线电设备,包含一个中继器和一个追踪器。它不依赖互联网基础设施,利用无线电波进行通信,尺寸小巧,可手持且电池供电。
硬件由两块电路板组成:
- 主板:负责数据调制解调,核心是 DSPIC33 微控制器。
- 数据板:运行中继器和追踪器逻辑,核心是 ESP32-WROOM-32E 微控制器,并集成了 GPS 接收器和环境传感器。
软件栈从底层硬件开始,向上经过调制解调器通信、二进制数据到文本的转换,最终到达中继器和追踪器的编码/解码逻辑。
接下来,我们将深入几个具体任务,看看 AI 如何协助解决。
任务一:ESP32 微控制器引脚分配 🧩
在开始编写固件之前,我们需要为所有外设(GPS、调制解调器、电源系统、LED等)分配 ESP32 的 GPIO 引脚。这需要考虑引脚功能、串行接口数量、I2C 需求以及需要避开的特殊引脚。
上一节我们介绍了项目整体架构,本节中我们来看看如何利用 AI 快速完成硬件引脚规划。
应用 AI 的方法
我们向 AI 模型提供简单的提示,包含芯片型号、外设需求和约束条件。
核心提示示例:
我正在使用 ESP32-WROOM-32E 微控制器。我的计划是连接以下外设:GPS(UART)、调制解调器(UART)、电源控制(GPIO)、状态LED(GPIO)、环境传感器(I2C)。请为我提供一个引脚分配方案。请避免使用 Strapping Pins,并注意哪些引脚是仅输入模式。
AI 生成的方案与评估
经过几次迭代,AI 能够生成一个完整且基本可用的引脚分配方案。它成功识别了:
- 需要上拉电阻和去耦电容的特殊引脚(如
GPIO2)。 - 可用于模拟输入的
GPIO引脚。 - 大部分
Strapping Pins。
遇到的挑战与机会
然而,AI 方案也存在一些问题:
- 信息不完整:某些模型仅将
GPIO26-32标记为GPIO,未区分输入/输出。 - 错误限制:错误地声称
GPIO0只能用于编程。 - 硬件误解:错误建议为
GPIO0添加上拉(这会导致无法编程),或假设模块有内部闪存占用了某些引脚。
经验总结
对于此类硬件规划任务:
- 提供图像最有效:直接提供数据手册中的引脚排列图,效果远好于仅提供文字描述。
- 明确约束是关键:明确指出需要避开的引脚(如
Strapping Pins)和仅输入引脚,能大幅减少迭代次数。 - 多模型尝试:不同模型表现不同,不要局限于一个模型。
- 上下文越多,迭代越少:提供尽可能多的背景信息,有助于 AI 一次性给出更准确的方案。
任务二:GPIO 与驱动程序初始化 ⚙️
有了引脚分配方案后,下一步是初始化这些 GPIO 并配置相应的驱动程序(如 I2C)。我们的目标是使用 Espressif 官方 IDF SDK,并避免使用已弃用的接口。
上一节我们利用 AI 完成了硬件引脚规划,本节中我们来看看如何快速生成初始化的代码框架。
应用 AI 的方法
我们将引脚分配表(例如 CSV 格式)粘贴给 AI,并请求生成初始化代码。
核心提示示例:
以下是 ESP32-WROOM-32E 的引脚分配:
LED, GPIO4
POWER_EN, GPIO12, 需要配置为开漏输出并上拉
I2C_SDA, GPIO21
I2C_SCL, GPIO22
请使用 ESP-IDF 编写初始化这些 GPIO 和 I2C 驱动程序的代码。
AI 生成的代码与评估
AI 能够极其快速地生成可编译的代码框架。它成功建议了与开源漏极 I/O(如电池充电器)接口的正确配置(上拉,激活时输出低电平)。
遇到的挑战与机会
初始代码也存在一些需要调整的地方:
- 代码风格偏好:AI 可能首先生成使用
esp_rom_gpio.h的代码,但开发者可能更倾向于使用driver/gpio.h中的高级函数。 - 配置不完整:生成的
gpio_config_t结构体可能未完全初始化所有字段,导致聚合初始化错误。 - 接口过时:可能推荐使用已弃用的
i2c_driver_install而不是i2c_master_init。 - Web 编辑器差异:像 ChatGPT 的 Web 代码编辑器有时显示的内容与实际聊天回复不符,造成混淆。
经验总结
对于代码生成任务:
- 快速启动优势:AI 能快速生成基础代码,节省查阅文档和示例的时间。
- 代理模式潜力:此任务非常适合使用具有“执行工具”能力的 AI 代理。代理可以自动编译代码,根据错误信息迭代修改,直到没有错误为止。
- 权衡迭代成本:有时直接手动修复 AI 代码中的几个小错误,可能比反复提示 AI 修改更快。
- 慎用 Web 编辑器:对于关键代码,更推荐让 AI 在聊天中直接输出代码块,以避免渲染不一致问题。
任务三:优化 C++ 库以用于嵌入式平台 🏎️
项目中有两个核心 C++ 库(路由器和封包编码器),最初是在桌面平台(Linux)上开发的。我们需要将它们移植到资源受限的嵌入式平台(如 ESP32、Raspberry Pi Pico),并进行性能优化,特别是减少或消除堆内存分配。
上一节我们生成了硬件初始化代码,本节中我们聚焦于软件库的优化,使其更适合嵌入式环境。
应用 AI 的方法:性能优化
我们选取库中的特定函数,要求 AI 进行优化。
示例一:字符串查找函数优化
原始函数使用 std::map 查找枚举值。AI 在第一次迭代中建议使用排序的静态数组和 std::lower_bound,并引入了 std::string_view 以避免依赖堆分配的容器。第二次迭代中,AI 发现了更优解:由于枚举值首字母唯一,直接比较第一个字符即可。
示例二:地址比较函数优化
原始函数将地址结构体转换为字符串再比较,可能效率低下。AI 首先优化了 to_string 函数本身。随后,在尝试避免字符串转换的请求下,AI 提出了“标准化”地址的思路,即先将不同格式的地址转换为一致格式,再进行简单的成员比较,代码比手写的复杂逻辑更简洁、易读。
遇到的挑战与机会
- 性能假设需验证:AI 认为“预留内存的
to_string”比“原始to_string”更快,但实际测量发现前者更慢。这凸显了性能优化必须依赖实际测量。 - 解决方案不可预测:不同的模型,甚至同一模型的不同次提问,可能会给出截然不同的优化方案(如复杂的哈希方案)。
- 嵌入式偏见:某些模型会强烈建议避免使用任何 STL 组件,尽管现代嵌入式工具链已良好支持。
经验总结
对于性能优化任务:
- 总能获得启发:即使 AI 的方案不是最终答案,其思路也常能提供有价值的优化方向。
- 必须实测性能:绝不能假设 AI 推荐的方案就是最快的。测量是唯一真理。
- 代理工具是绝配:可以创建 AI 代理,自动运行性能测试,并迭代尝试不同的优化方案,直到找到最优解。
- 详细注释有帮助:在提供给 AI 的代码中包含详细注释,有助于模型更好地理解上下文和意图,从而提出更合适的方案。
任务四:设计支持多编码的字符串 API 🔤
在封包编码器中,消息部分可能包含 UTF-8 文本。最初的设计考虑使用 std::codecvt 在不同编码(UTF-8, UTF-16)间进行转换。我们需要一个高效、现代且不引入沉重外部依赖的 API 设计。
上一节我们探讨了代码性能优化,本节我们关注 API 设计,如何优雅地处理字符串编码问题。
应用 AI 的方法
我们向 AI 描述需求:只有消息部分可能是 UTF-8,其余都是 ASCII;希望避免运行时转码;寻求最佳设计模式。
AI 的指导与最终方案
AI 立即指出一个关键信息:std::codecvt 在 C++17 中已被弃用,并在 C++20 中移除。这促使我们重新思考设计。
AI 随后建议了 ICU 库,但它过于庞大。最终,通过几轮迭代,AI 帮助确认了一个简洁的设计:
- 内部存储:消息在内部仅存储为字节数组 (
std::vector<uint8_t>)。 - 灵活输入:提供多个重载的
set_message函数,接受std::string_view、std::u8string等,直接存储其字节,不进行转码。 - 灵活输出:提供
as_ascii_string(),as_u8string()等方法,按需格式化整个封包。调制解调器最终使用的则是返回字节数组的接口。
遇到的挑战与机会
- 知识更新:早期模型可能未及时指出
std::codecvt已弃用,但新模型通常能立刻识别。 - 平台假设:AI 可能建议使用
MultiByteToWideChar等 Windows 特定 API,而未考虑跨平台需求。
经验总结
对于 API 设计任务:
- 规避已弃用特性:AI 能有效提醒我们避免使用即将或已经过时的标准库组件。
- 快速探索方案:AI 能帮助快速头脑风暴,从复杂、传统的方案(如全面转码)过渡到更简单、高效的设计(如字节存储)。
- 明确约束条件:必须明确告知 AI 跨平台、轻量级等约束条件,以避免得到不合适的建议。
任务五:构建 GPS 数据模拟与可视化系统 🗺️
开发追踪器时,需要真实的 GPS 数据进行测试和调试。我们不可能一直带着设备在路上跑。因此,需要构建一个系统:能够生成模拟的 GPS 路线数据,并实时播放给追踪器,同时在地图上可视化轨迹。
上一节我们设计了数据处理 API,本节我们进入系统集成领域,构建一个复杂的测试支持工具链。
应用 AI 的方法
我们向 AI 描述完整需求:生成路线、模拟 GPS 信号、可视化地图。我们对其中涉及的技术栈(如 OpenStreetMap, Valhalla)知之甚少。
AI 的协助与实现方案
AI 出色地充当了技术选型和快速原型设计的向导:
- 地图服务:推荐了可本地运行的 OpenStreetMap 容器。
- 路线生成:推荐了 Valhalla 路径引擎,并提供了从
.pbf地图文件生成路线的 Python 脚本。 - 高级需求:当询问如何获取道路限速信息时,AI 指出 OpenStreetMap 数据中包含相关标签,并给出了查询 Valhalla API 获取这些信息的方法。
- 插值与计算:提供了使用 Haversine 公式计算航向的代码。
- Web 服务器:推荐了简单易用的
crowC++ WebSocket 库。 - 前端展示:生成了使用 OpenLayers 库显示地图和轨迹的 JavaScript 代码。
遇到的挑战与机会
- 复杂任务分解:AI 难以一次性给出一个完整的、符合接口规范的 GPS 模拟器类实现。它擅长解决子问题,但将子问题组装成完整模块仍需人工完成。
- 代码质量:对于复杂任务,初始生成的代码可能可读性不佳。
经验总结
对于系统集成与原型设计任务:
- 强大的技术侦察兵:AI 在快速学习并应用陌生技术栈(如地图服务、路径规划)方面表现惊人,极大降低了入门门槛。
- 脚本生成专家:生成 Python、JavaScript 脚本对 AI 来说轻而易举,几乎无需迭代。
- 复杂模块仍需人工:对于需要精细设计接口和状态的复杂模块,AI 目前更擅长提供“零部件”而非交付“整机”。
- 结果需要评估:对于 AI 推荐的库和方案,仍需人工评估其适用性和可靠性。


任务六:在 Linux 容器中自动发现 USB 串口 🔌
为了自动化测试和容器化部署,需要让程序能自动识别并映射特定的 USB 转串口设备(即项目的主板),即使系统上连接了多个同类设备。
上一节我们构建了模拟测试环境,本节我们解决一个具体的系统编程问题:在 Linux 下如何以编程方式可靠地发现特定硬件设备。
应用 AI 的方法
我们询问 AI:在 Linux 下,如何以编程方式唯一标识和发现 USB 串口设备,而不依赖静态系统配置(如 udev 规则)。
AI 的解决方案
AI 准确推荐了使用 libudev 库,它是 udev 工具的程序化接口。AI 生成了代码来查询设备管理器,遍历串口设备,并提取关键属性如制造商(ID_VENDOR)、产品ID(ID_MODEL_ID)、序列号(ID_SERIAL)等。我们可以通过这些属性来过滤和识别特定设备。
遇到的挑战与机会
- 属性名幻觉:AI 可能不知道
ID_SERIAL这个确切的属性名对应的是设备的序列号,有时会“幻想”出一个不存在的属性名。但这可以通过迭代和查阅udevadm info命令的输出快速纠正。
经验总结
对于系统编程任务:
- 精准定位工具库:AI 能快速指出完成特定系统任务应该使用的正确库(如
libudev),节省大量搜索时间。 - 快速生成样板代码:能快速生成查询、遍历、过滤设备列表的样板代码。
- 需验证细节:对于属性名、标志位等具体细节,需要结合官方文档或系统命令进行验证,AI 可能在此处产生小误差。
总结与核心洞见 💡
在本教程中,我们一起学习了如何将 AI 工具应用于一个完整的 C++ 系统编程项目,涵盖了从硬件引脚规划、驱动初始化、代码优化、API 设计,到系统集成和脚本编写的多个方面。
通过 Ion 的实践,我们可以得出以下核心洞见:
- 原型设计加速器:AI 在项目早期原型和设计阶段作用巨大,能显著提升生产力,让开发者快速验证想法。
- 擅长处理样板代码:对于 C++ 中常见的模板代码、序列化/反序列化、低级硬件操作等,AI 能高效生成可靠代码。
- 不完美但有用:AI 的答案不必完美或完整即可提供巨大价值。有经验的开发者可以快速理解、修正并整合 AI 的建议。
- 迭代与代理:AI 生成的代码可能需要迭代改进。具备“执行工具”能力的 AI 代理(如自动编译、运行测试)有望缩短这个迭代循环。
- 持续进化:AI 模型的能力在快速进步,尤其在代码优化方面,时常能给出令人惊喜的方案。
- 设计能力是关键:要高效利用 AI,开发者自身需要具备良好的软件设计能力,才能提出正确的问题,并判断和整合 AI 的输出。
- 安全与工程流程:在生产环境中使用 AI,绝不能绕过既有的代码审查、安全测试和工程最佳实践。AI 是强大的助手,但责任仍在人类工程师肩上。

总而言之,AI 已成为现代 C++ 开发者工具箱中一个极具潜力的新工具。它并非要取代开发者,而是通过处理繁琐细节、提供创意灵感和加速学习过程,来增强开发者的能力,让我们能更专注于高层次的架构设计和问题解决。
035:C++构建可移植性的25年


在本教程中,我们将跟随 Bill Hoffman 在 CppCon 2025 上的演讲,回顾 CMake 在过去25年中的发展历程。我们将了解 CMake 的起源、各个发展阶段的核心特性、当前的最新进展以及对未来的展望。内容将涵盖从最初的“标志汤”到现代目标模型,再到即将到来的 CMake 4 时代,重点关注其如何提升 C++ 项目的构建可移植性和开发体验。
演讲者介绍
上一节我们概述了本教程的内容,本节中我们来认识一下本次演讲的主讲人 Bill Hoffman。
Bill Hoffman 拥有丰富的职业生涯。他在 G Research 的计算机视觉组工作了九年。作为一名刚毕业的本科生,他的任务是将团队从 Symbolics Lisp 机器迁移到当时崭新的 C++ 语言,最初使用 SGI 工作站,后来转向 Linux 和 Windows。他逐渐成为了负责软件库、GNU Make 和 Autotools 的“软件库专家”。
1998/1999年,他共同创立了 Kitware 公司并担任 CTO。CMake 的原型大约在1999年启动。如今,他更多从事管理工作,并为 CMake 项目寻找资金以支持像 Brad King(演讲中提到的“veto”)这样的优秀人才持续工作。
此外,他在40多岁时开始接触越野跑,图中是他在 Leadville 100 英里赛中的场景。
Kitware 公司与 CMake 的诞生
上一节我们介绍了 Bill Hoffman,本节中我们来看看他创立的 Kitware 公司以及 CMake 是如何诞生的。
Kitware 是一家专注于咨询和研究合同的公司,其专业领域包括计算机视觉、数据分析、科学计算、医疗成像和软件解决方案。他们基于开源平台构建这些解决方案。Kitware 的软件解决方案团队(即 CMake 团队)规模不大,但负责的远不止 CMake 本身。
CMake 起源于美国国家医学图书馆的一个项目。该图书馆发起了“可视人计划”,创建了一个包含 CT、MRI 以及人体解剖切片图像的数据集。为了推动医学影像科学的发展,他们意识到需要将最先进的图像分割和配准算法代码化,因此决定创建一个工具包。
Kitware 因创建了 VTK(可视化工具包)而闻名,因此被邀请参与这个新工具包(即 ITK,Insight 分割与配准工具包)的开发。Bill Hoffman 立即“偏离”了工作说明书,为 ITK 创建了一个构建系统,这便是 CMake 的起点。自此之后,CMake 获得了来自各种项目、外部贡献等各方的资助。
在准备这次演讲时,Bill 发现自己在2015年的一篇博客中指出,CMake 诞生于2000年8月31日,到2025年已满25年。当时的主要目标之一是避免外部依赖,这就是为什么 CMake 拥有自己的语言,并且只依赖于 C++ 编译器。他当时写道,要避免他称之为“构建过程的复活节彩蛋狩猎”——即为了构建一个项目,需要先获取 A,再获取 B,然后构建 C…… 这个问题在当时非常棘手,甚至至今也未完全解决。
以下是发送给 ITK 邮件列表的第一封宣布 CMake 的邮件摘要:
“我重构了 ITK 的构建过程,创建了一个名为 CMake(跨平台 Make)的包。它包含了构建 CMake 本身的所有过程…… 我将 PC 构建器重命名为 CMakeSetup,Makefile.in 等文件现在都叫 CMakeLists.txt。” 这标志着 CMake 和 CMakeLists.txt 文件的诞生。
CMake 的演进阶段
上一节我们了解了 CMake 的起源,本节中我们将系统性地回顾 CMake 发展的几个主要阶段。
Brad King(veto)曾在 C++ Now 会议上做过一个关于“后现代 CMake”的演讲,他将 CMake 的演进划分为几个时代。
CMake 1:原始“标志汤”时代
这个阶段可称为“亲友团”时代。当时只有 ITK、VTK、ParaView 等少数几个关系密切的项目在使用。其工作方式是收集一堆编译器标志,将它们传递到源代码树中,并尝试让所有东西链接起来。它确实能工作。
代码看起来类似这样:
SET(SRCS foo.c bar.c)
ADD_EXECUTABLE(myexe ${SRCS})
有趣的是,25年后的 CMake 4 版本中,这种用法才被最终弃用。这本质上是使用不带扩展名的文件,CMake 会去搜索 .c、.C 等后缀。
同时,find_package 命令也是 CMake 1.0 的一部分,说明他们从一开始就有好的想法,但并非所有都得到了完美执行。
CMake 2:世界征服时代
这个阶段主要是 Bill 积极寻找项目来使用 CMake。最终,他抓住了最大的机会:KDE(一个著名的 Linux 桌面环境)。
有趣的是,最初有一位 KDE 开发者因故未能参加会议,他发邮件说本可以提议将 CMake 作为 KDE 的构建系统,但最终会议选择了 SCons。六个月后,这位开发者又发来邮件,展示了 KDE 开发列表上的讨论:他们使用 SCons 六个月后仍然无法构建任何东西。
于是,Kitware 当时大约10个人全力以赴,在几周内让 KDE 的核心库在 CMake 上构建起来。随后一周,有人在 Mac 上成功运行;几周后,又有人在 Windows 上运行起来。进展非常迅速。KDE 社区有句格言:“谁写出代码谁赢”。于是他们放弃了 SCons(该分支后来可能成为了 Waf)。这对 CMake 来说是巨大的成功,带来了很多好处:
- 迫使 CMake 严格遵循 Linux 的安装规范(如共享库版本等)。
- 解决了“先有鸡还是先有蛋”的问题:因为要构建 KDE,CMake 被预先包含在了 Linux 发行版中。
- 催生了“现代 CMake”。
另外,在2008年,LLVM 项目加入了第一个 CMake 文件,并在一段时间内保持双构建系统。从 Google 搜索趋势图可以看出,在 KDE 采用 CMake 后,其搜索量直线上升,超过了 Autotools。
Stephen Kelly(当时在 KDAB 工作,也是一位 KDE 开发者)提出了“现代 CMake”这一术语。他博客中的一个简单 QT 示例展示了现代 CMake 的精髓:
find_package(Qt5Widgets REQUIRED)
add_executable(myapp main.cpp)
target_link_libraries(myapp Qt5::Widgets)
这展示了如今非常流行且有用的完整目标模型。Qt5::Widgets 是在其他地方构建的,但我可以像使用本地目标一样引入它,而无需传递那些不知从何而来的链接器标志或 -L 路径。我获得的是一个真正的目标对象。
CMake 3:目标命令时代
这个阶段的核心是完善目标的使用,并引入了使用需求(usage requirements)和构建需求(build requirements)的概念,使得目标之间的依赖关系管理更加清晰和强大。
CMake 的实用特性与技巧
上一节我们回顾了 CMake 的演进历史,本节中我们来看看 CMake 中一些可能不为人知但非常实用的特性和调试技巧。
性能剖析
你知道可以对 CMake 本身进行性能测试吗?想知道为什么你的 CMake 配置运行缓慢吗?
以下是一个简单示例。在 VTK 项目中,我添加了一个命令:execute_process(COMMAND sleep 10)。然后运行 CMake 时,使用以下命令启用性能剖析:
cmake --profiling-format=google-trace --profiling-output=cmake-profile.json
接着,你可以将生成的 cmake-profile.json 文件加载到 perfetto 等工具中进行分析。你可以看到配置时间有一半花在了 execute_process 上,并且能清楚地看到参数是 sleep 10,从而定位问题。删除该命令后,配置时间显著缩短。这个功能对于分析配置阶段或生成阶段的性能瓶颈非常有用。
调试支持
CMake 支持调试适配器协议(DAP)。任何支持 DAP 的编辑器都可以调试 CMake 代码。例如,在 VS Code 中,你可以设置断点、查看调用栈、检查缓存变量(数量可能惊人,例如配置 VTK 时可能有512个)和目录信息,基本上获得了完整的 CMake 调试器。
并行安装
这是一个较新加入的功能。默认未开启,因为如果安装步骤设置了奇怪的依赖关系可能会导致问题。但如果你的项目没有此类问题,可以通过设置全局属性来开启:
set_property(GLOBAL PROPERTY CMAKE_INSTALL_PARALLEL_JOBS 4)
然后使用 cmake --install . -j 4 进行并行安装,对于安装大量文件的大型项目可以显著提速。CMake 还可以可视化安装步骤。
回顾与展望:从 CMake 1 到 CMake 4
上一节我们了解了一些实用技巧,本节我们将时光倒流,回顾早期的 CMake,并展望未来的 CMake 4。
早期 CMake 与目标回顾
能找到的最早的 CMake 网页是2002年的。上面写着:“欢迎使用 CMake,跨平台开源 Make 系统。用于控制软件编译过程。” 页面还列出了一些目标,我们稍后会回顾。侧边栏提到,2002年时 C++ 标准是 C++98,新特性包括命名空间、RTTI、新式类型转换、异常处理和 STL,还没有反射。
“关于”页面提到,当时 CMake 在 Unix 上生成 Makefile,在 Windows 上生成 .dsp(MSVC 项目文件)。还有一个有趣的注释:“开发人员编写代码的能力总是远超过编写文档,因此我们在此致歉,开发速度超过了文档更新速度。因此,学习 CMake 的最佳方式是研究现有的 CMake 代码并复制它。” 这个问题贯穿了 CMake 的历史,我们将在整个演讲中多次提及。
现在,让我们回顾一下原始 CMake 网页上列出的目标,并给它们打分:
-
开发一个开源的跨平台工具来管理构建过程。
Bryce Adelsohn 在2021年 C++ Now 上说:“你想要一个标准的构建系统?你有了。它叫 CMake。抵抗是徒劳的。” 我认为在创建开源跨平台工具方面,可以得 A+。 -
允许使用原生编译器和系统。
最初只生成 Makefile,后来支持了很多其他系统:MSBuild、Xcode、Ninja 等。Ninja 的加入尤其强大。在 Google 发布 Ninja 后,CMake 大约一个月内就集成了它。任何采用 CMake 生态系统的开发者,他们的构建速度几乎一夜之间就变快了,因为他们可以立即使用这个新的构建工具。这正是 CMake 的目标之一:它本身不是构建工具,而是利用丰富的原生工具生态。当有新工具出现时,CMake 可以将其带给开发者,而开发者无需直接迁移或移植。此外,CMake 支持几乎所有你能想到的编译器。在这方面,可以得 A+。 -
简化构建过程。
在这方面可能做得不够好。2024年 C++ 开发者调查显示,约30%的人认为管理依赖的库和应用程序是主要痛点,38%认为是次要痛点。加起来有近70%的人对构建系统感到不满。但考虑到 CMake 出现之前,编写 Makefile、.in文件,以及链接大型项目都非常困难,需要深刻理解链接器和命令行,尤其是在跨平台方面。CMake 提供了封装,使得事情变得更简单。当然,还有更多工作要做。我慷慨地给个 B。 -
可选地提供管理构建系统的用户界面。
你可以在命令行运行 CMake,也可以使用基于终端的ccmake或 GUI 工具cmake-gui。此外,许多 IDE 和编辑器都提供了对 CMake 的 GUI 支持,如 CLion、Visual Studio、VS Code 等。我认为在这方面可以得 A+。 -
创建一个可扩展的框架。
我必须承认,CMake 语言可能不是任何人最喜欢的部分。但人们用它做了很多疯狂的事情,比如写了一个光线追踪器。它确实是可扩展的,功能强大,允许通过自定义命令深度介入构建过程。但这也可能是个“特性”(其实是缺陷)。我给它 B。 -
建立一个自我维持的软件用户和开发者社区。
我认为这方面做得非常好。CMake 项目有近1500名贡献者。Craig Scott 写了一本关于 CMake 的非常好的书。在微软的“VS Code 新特性”演讲中,近一半内容是关于如何在 VS Code 中用好 CMake。社区已经真正成长起来。甚至在 CMake 20周年时,Visual Studio 团队还发文祝贺。这方面可以得 A。
CMake 4:互操作与标准化时代
那么,CMake 4 会是什么样?我认为它将更多地关注实现和定义可供其他应用程序使用的格式,使 CMake 更易于被整个生态系统访问。核心是 CPS(通用包规范) 文件,它将可供 IDE、包管理器和其他工具使用。
对于模块,我们与标准委员会合作创建了 P1689(扫描格式)和 P3286(模块元数据格式)。P3286 用于实现 import std,并最终将被 CPS 使用。
还记得早期文档说“学习的最佳方式是复制”吗?这导致了很多糟糕的 CMake 代码存在。最初,当你想从其他构建系统迁移到 CMake 时,可以简单地添加 CMakeLists.txt,它与原有构建系统可以共存于同一源码树。但现在,如果你想把旧的 CMake 1 风格代码升级到现代 CMake,你无法在同一个目录下放两个 CMakeLists.txt。
因此,我们添加了功能,允许为 CMakeLists.txt 使用替代文件名。你可以使用 --project-file 参数指定项目文件名,它与 add_subdirectory 兼容,并且会在整个目录树中回退到 CMakeLists.txt。这个功能旨在临时使用,总会产生警告,并不是让你永久改用其他文件名,而是帮助你从旧版本 CMake 迁移到新版本。
CMake 的最新进展
上一节我们展望了 CMake 4,本节中我们具体看看正在开发中的一些新特性。
了解 CMake 最新进展的最佳地点是查看其“实验性功能”文档。目前有7个实验性特性,前三个都与 CPS 相关:
- 导出包依赖的能力。
- 使
export、find_package和依赖项协同工作。 - 导出通用包规范信息并查找导入的 CPS 文件。
import std支持。- 构建数据库支持(
.json文件,对构建模块有用)。 - 性能检测(Instrumentation)。
CPS:通用包规范
CPS 旨在明确定义一个库是什么。你可能认为在拥有二进制库50年后,我们已经定义了它们是什么,但事实并非如此。如果你有一个 .so 或 .dll 文件,要使用它,你需要知道:包含路径、兼容的编译器标志、必要的宏定义(-D)等等。CMake 的解决方案是导出用 CMake 语言编写的 CMakeConfig.cmake 文件。这对 CMake 很好,推动了现代 CMake 的发展,但对生态系统中的其他部分(如 Conan 等包管理器)就不太友好,它们需要解析 CMake 文件。
CPS 的理念是将这些信息移到一个可移植的 JSON 格式中。它正在开源开发,支持一个包的多种配置、多个组件、灵活的版本兼容性、默认可重定位、传递依赖应能直接工作。
旧方式示例:
install(EXPORT example-target ...)
install(... cmake files for example ...)
新方式(CPS):
install(PACKAGE_INFORMATION_FOR example ...)
并且使用相同的 find_package 命令,力求最大兼容性。目标是创建一个可以轻松插入的系统,并希望在未来几年内成为标准。这是迈向 C++ 包管理的第一步,以标准化的方式定义库和包。
CPS 可以与包管理器良好集成。Conan 和 vcpkg 已经进行了一些实验性工作,前景很好。包管理器既可以消费 CMake 生成的 CPS 文件,也可以为其他非 CMake 的构建工具创建 CPS 文件,从而在包管理器和构建系统之间建立沟通机制。更多构建系统也可以采用它,因为它们无需学习如何解析 CMake 语言。
C++20 模块支持
CMake 4 的另一项重大特性是 C++20 命名模块支持。我们正在努力将 import std 移出实验状态。
构建系统为何要关心模块?它带来了什么麻烦?对于 C++ 模块,假设模块 A 消费模块 B,而 B 导出一个模块。如果你先编译 A,会得到错误。这在以前的 C++ 中是不会发生的,因为编译顺序可以是任意、高度并行的。但有了模块,你需要知道谁生成什么模块,谁消费什么模块。这形成了一个困境:我需要编译 C++ 代码才能找出编译顺序,但我又需要知道编译顺序才能编译代码。
CMake 在 Fortran 模块上有过类似经验,当时的解决方案是在 CMake 中加入一个简单的 Fortran 解析器。显然,我们不想为 C++ 也加入一个解析器。通过与标准委员会合作,P1689 格式被创建出来,用于描述源文件的依赖关系,现在已被主流编译器厂商实现。这样,CMake 可以要求编译器解析文件并提供这些信息,然后扫描所有文件,收集依赖信息,整合到每个目标中,最后将其插入到构建图(如 Ninja)中。为此,我们甚至修改了 Ninja(多年前为 Fortran 修改过,但未合并;当听说 C++ 模块需要时,终于被合并了)。
在 CMake 中,使用文件集(file sets)会是未来的推荐方式:
add_library(mylib)
target_sources(mylib PUBLIC FILE_SET modules ...)
import std 即将可用。你需要设置 C++ 标准为23或更高,然后可以设置 CMAKE_CXX_IMPORT_STD 为 ON 来为项目中的所有目标启用,或为每个目标单独设置。编译时,CMake 会生成一个隐藏目标来处理 import std 的使用信息。
性能检测(Instrumentation)
这个特性允许你在构建时进行检查。之前提到的性能剖析针对的是配置和生成阶段。但 CMake 是构建生成器,一旦完成,它就退出了,留下一个 Ninja 构建文件。如何获取实际构建过程的信息?通过 CMake Instrumentation,CMake 可以收集构建过程每个步骤(配置、生成、编译、链接、自定义命令、安装、测试)的数据,并提供性能洞察,甚至可以在不同用户和机器间收集数据(如果你需要)。它可以生成 Google Trace Event 文件,用于可视化数据和分析并行性与瓶颈。
启用后,会根据不同事件类型生成包含时间戳、持续时间、角色、输出大小、前后 CPU 负载、前后主机内存使用情况等信息的 JSON 片段文件。在用户定义的间隔(例如每次构建后、每次配置后,或手动通过 ctest 触发),CMake 会自动整理并生成一个大的索引文件。你还可以设置自定义回调脚本。
启用方式:
cmake_instrument(API_VERSION ... DATA_VERSION ... OPTIONS ...)
或者,在 CMakeUserPresets.json 中设置类似 JSON。设置 CTEST_USE_INSTRUMENTATION=ON 会启用检测并内置回调将数据发送到 CDash。
其他即将到来的特性
- SBOM(软件物料清单)支持:用于生成 SPDX 文件。可以通过在 CMake 中要求为目标生成 SBOM,或者使用
-DCMAKE_INSTALL_SBOM_FORMATS=SPDX来启用。包管理器可能会用此来生成 SBOM,而无需修改CMakeLists.txt。 - Fast Build:这是一个新的构建后端,类似于 Ninja,但其突出特点是能进行非常快速的分布式构建,并内置了对象文件缓存。设置分布式构建非常简单快捷。目前,其配置阶段可能稍慢,但尚未进行深度优化。对于完全构建、分布式构建(即使只有两台机器)和缓存重建,它都显示出优势。
文档与工具集成改进
- 新的现代教程:即将发布,旨在更新文档,确保教程代表现代 CMake 的最佳实践。
- 导出 SARIF:CMake 现在可以将其警告和错误导出为 SARIF 格式,这体现了 CMake 4 与外部工具更好交互的主题。
未来愿景与社区参与
上一节我们介绍了 CMake 的最新进展,本节中我们来探讨 Bill Hoffman 个人对未来的一些设想,并了解如何参与 CMake 社区。
未来愿景
-
CMake 代码检查(Linting):如果有一个 CMake Linter 工具就太好了。例如,对于一个使用旧式全局包含目录和定义的项目,Linter 可以发出警告:“检测到旧式包含,应使用 CMake 3.15+”、“发现
add_definitions,请勿使用”等。希望在接下来的一两年内能有这样的工具。 -
CMake 不是包管理器:项目及其复杂性随时间增长。例如,HPC 模拟依赖着大量库,像一座冰山。对于如此复杂的依赖图,使用
ExternalProject或FetchContent是无法扩展的,你需要一个真正的包管理器。CMake 在某种程度上可能促成了这种包爆炸和相互依赖,因为它使得构建和链接这些东西变得容易。但 CMake 本身并不是解决这个问题的正确工具。 -
新的工作流程:CMake Provision:我设想在现有工作流程(编辑源码 -> 运行 CMake 生成构建系统 -> 构建)中,增加一个
cmake --provision步骤。这个步骤会运行你配置的包管理器(如 Conan, vcpkg),根据项目中的某个清单文件(如toml或json)拉取依赖项。然后 CMake 照常运行configure和generate,但整个环境已经由包管理器设置好了。这样,开发者仍然可以使用他们熟悉和喜爱的 CMake+Ninja 工作流,同时清晰地分离了包管理和构建步骤,便于排错。 -
Spec + CMake 开发模式:我设想一个未来,你有一个定义良好的 CMake 文件,加上一个包管理器(暂且叫它 Spec)。作为开发者,你可能只关心 A、B、C 这几个库。你可以告诉包管理器:“我想开发 A、B、C”,它会为你设置好这些库的源码树和构建树,其他依赖则由包管理器从二进制缓存获取或构建。你可以进行编辑、运行、调试,这是一种“非安装构建”,即直接从源码树运行,出错时能直接定位到源码。如果你发现库 F 有问题,可以将其加入开发集,包管理器会将其拉入你的源码区。当 A、B、F 稳定后,你可以专注于开发 C,从而获得更快的周转时间。我认为这样的模式对 C++ 开发会非常棒。
总结与致谢
回顾 CMake 的时代:
- CMake 1:原始“标志汤”。
- CMake 2:世界征服。
- CMake 3:目标命令。
- CMake 4:互操作与标准化(希望你喜欢 JSON)。

感谢所有用户和贡献者,没有用户,项目就没有价值。CMake 现在拥有大量用户,感谢所有人的贡献。
如何参与社区
如果你想为 CMake 添加新功能:
- 首先创建一个 Issue,描述你的想法。
- 在 Discourse 论坛上发起讨论。记住《大教堂与集市》这本书,不要独自去建造“大教堂”,而要在“集市”中协作。提前与社区(如 Craig Scott, Brad King)沟通,可以避免花费数月时间开发后才发现方案无法被接受。
加入社区的方式:
- 加入 Discourse 邮件列表。
- 加入 Slack (
cpplang.slack.com),关注#cmake和#ecosystem-evolution频道。 - 或者,如果你不想参与讨论,只想雇佣 Kitware 解决问题,可以访问
kitware.com。
问答环节
上一节我们探讨了未来并了解了如何参与社区,本节是演讲结束后的问答环节摘要。
-
关于 SBOM 实现:
- 问:SBOM 支持是从外部包管理器拉取信息,还是从 CMake 内部为项目生成?会与包管理器通信以了解外部依赖的构建信息吗?
- 答 (Brad King):包管理器告诉我们的信息,我们会整合到 SBOM 中。SBOM 的包管理信息建立在 CPS 之上。我们坚决反对在 CMake 语言中添加更多东西。对于支持 CPS 的包管理器,我们有很好的信息导入能力。对于尚不支持 CPS 的包管理器,我们正在开发填充脚本,可以在安装目录上运行以生成 CPS。有 CPS,就有 SBOM 信息;没有 CPS,我们就无法提供相关信息。
-
关于 CMake 项目模板:
- 问:对于想快速创建一个小库测试想法的情况,需要复制修改旧的 CMake 文件。是否有工具可以模板化一个初始的
CMakeLists.txt? - 答:目前没有官方工具。但在这个时代,可以尝试使用 AI 大语言模型,它们可能能帮你完成95%的工作。
- 问:对于想快速创建一个小库测试想法的情况,需要复制修改旧的 CMake 文件。是否有工具可以模板化一个初始的
-
关于 CMake 版本管理:
- 问:系统仓库中的 CMake 版本通常很旧,无法使用新特性。其他语言/工具有版本管理工具(如 Rust 的 rustup,Node 的 nvm)。是否考虑过提供一个能自动为项目安装合适 CMake 版本的工具?
- 答:这个问题关乎对不同用例开发者的共情。CMake 团队通常不愿添加那些无法在所有环境(例如,一台没有网络连接的 AIX 机器)中工作的功能。我们希望这样的功能存在于 CMake 二进制文件之外,比如通过 pip 安装、由构建编排工具调用,或者由包管理器拉取。一个独立的辅助项目来帮助生成正确版本的 CMake 会很有价值。CMake 官网本身也提供二进制下载。
-
关于包含空格的构建路径:
- 问:(非问题,而是分享)尝试了在构建目录名中使用空格,结果立即报错无法构建。
- 答:这正是我们测试时总是使用包含空格的路径(如
my build)的原因,很多贡献者的代码会在这里出错。
-
关于 CMake 语言类型系统:
- 问:CMake 中一切都是字符串,列表是字符串,数字也是字符串。如果有一个更好的类型系统,CMake 是否会受益?
- 答:如果能坐时光机回去,或许可以做得更好。但现在要改变,需要付出大量工作。可能确实有好处,但成本太高。

本节课中,我们一起学习了 CMake 从诞生至今25年的演进历程。我们从其起源和早期“标志汤”阶段开始,经历了被 KDE 采用后的“世界征服”和“现代 CMake”的诞生,再到以目标为中心的 CMake 3 时代。我们深入探讨了 CMake 4 的愿景,即强调互操作性和标准化,特别是通过 CPS 和 C++ 模块支持。我们还了解了许多实用特性,如性能剖析、调试支持、并行安装,以及未来的 SBOM、Fast Build 等。最后,我们探讨了 CMake 在复杂依赖管理中的定位、对未来工作流程的设想,并强调了开放社区协作的重要性。CMake 的成功离不开其庞大的用户和贡献者社区,它将继续作为 C++ 生态系统中跨平台构建的基石不断进化。
036:现代C++与Python的融合与绑定




在本教程中,我们将学习如何将C++与Python这两种强大的语言结合起来。我们将探讨它们各自的优势、现代特性如何使它们越来越相似,以及如何通过多种技术实现它们之间的高效互操作,从而在开发速度与运行性能之间取得最佳平衡。
1:C++与Python的现代融合
上一节我们介绍了本课程的目标,本节中我们来看看为什么C++和Python开发者经常同时使用这两种语言。调查数据显示,Python是C++开发者最常用的第二语言。这两种语言正在相互借鉴,变得越来越相似。
1.1 字符串格式化

在Python中,格式化字符串非常直观。
name = "World"
num = 42
print(f"Hello {name}, the answer is {num}")

在C++中,借助fmt库(或C++23的std::print),我们可以实现几乎相同的效果。
#include <fmt/core.h>
// 或 #include <print> // C++23
int main() {
std::string name = "World";
int num = 42;
fmt::print("Hello {}, the answer is {}\n", name, num);
// std::print("Hello {}, the answer is {}\n", name, num); // C++23
return 0;
}
1.2 容器打印
Python可以轻松打印容器内容。
my_list = [1, 2, 3]
my_dict = {"a": 1, "b": 2}
print(my_list, my_dict)
现代C++也能实现类似功能。
#include <fmt/ranges.h>
#include <vector>
#include <map>
int main() {
std::vector<int> vec = {1, 2, 3};
std::map<std::string, int> dict = {{"a", 1}, {"b", 2}};
fmt::print("{}\n", vec);
fmt::print("{}\n", dict);
return 0;
}
1.3 多返回值与结构化绑定
Python函数可以轻松返回多个值。
def get_values():
return 1, 2, "three"
a, b, c = get_values()
print(a, b, c)
C++通过std::tuple和结构化绑定实现了类似功能。
#include <tuple>
#include <iostream>
std::tuple<int, int, std::string> get_values() {
return {1, 2, "three"};
}
int main() {
auto [a, b, c] = get_values(); // C++17 结构化绑定
std::cout << a << " " << b << " " << c << std::endl;
return 0;
}
1.4 枚举循环
Python的enumerate函数在迭代时非常方便。
items = ["apple", "banana", "cherry"]
for index, value in enumerate(items):
print(f"{index}: {value}")

在C++23中,我们可以利用生成器创建类似的功能。
#include <iostream>
#include <vector>
#include <generator> // C++23
template<typename Container>
std::generator<std::tuple<std::size_t, typename Container::value_type>> enumerate(Container&& container) {
std::size_t index = 0;
for (auto&& value : container) {
co_yield {index++, value};
}
}
int main() {
std::vector<std::string> items = {"apple", "banana", "cherry"};
for (auto [index, value] : enumerate(items)) { // 类似Python的语法
std::cout << index << ": " << value << std::endl;
}
return 0;
}
1.5 忽略返回值
Python可以使用下划线忽略不关心的返回值。
def foo():
return 1, 2, 3
_, _, c = foo() # 只关心第三个返回值
print(c)
C++26引入了类似的语法。
auto foo() -> std::tuple<int, int, int> {
return {1, 2, 3};
}
int main() {
auto [_, _, c] = foo(); // C++26,使用下划线忽略
std::cout << c << std::endl;
return 0;
}
1.6 函数式编程与范围
Python以其简洁的函数式编程风格著称。
import random
# 生成20个随机数,过滤出大于5小于9的数
numbers = [random.uniform(0, 10) for _ in range(20)]
filtered = [x for x in numbers if 5 < x < 9]
print(filtered)
C++20引入的范围库提供了强大的函数式操作能力。
#include <ranges>
#include <vector>
#include <random>
#include <fmt/ranges.h>
int main() {
std::mt19937 gen(std::random_device{}());
std::uniform_real_distribution<> dis(0.0, 10.0);
// 生成并过滤数字
auto numbers = std::views::iota(0, 20)
| std::views::transform([&](int){ return dis(gen); });
auto filtered = numbers | std::views::filter([](double x){ return x > 5 && x < 9; });
// 注意:views是惰性求值的,需要将其物化到容器中或直接使用
for (auto val : filtered | std::views::take(10)) { // 取前10个结果
fmt::print("{}\n", val);
}
return 0;
}
2:Python的挑战与现代化
上一节我们看到了C++如何借鉴Python的便利特性,本节中我们来探讨Python自身面临的一些挑战及其现代化改进。Python作为解释型和动态语言,在带来开发便利的同时,也存在着性能瓶颈和类型安全等问题。
2.1 Python的潜在问题
以下是Python动态特性可能引发的一些问题。
缺失返回语句
def risky_function():
a = 5 + 5
# 忘记写 return a
# 只有在调用此函数时才会出错
变量作用域混淆
b = 100 # 全局变量
def confusing_function(a):
result = a + b # 如果调用时忘记传参b,Python会使用全局的b,导致逻辑错误
return result
整数溢出行为不同
import sys
def overflow_like_cpp():
x = sys.maxsize + 1 # 在Python中,这会自动升级为更大的整数类型(如int64),不会像C++那样回绕到0
return x
位操作开销大
# Python的`bin`操作返回的是字符串,对大量数据进行位操作时内存和性能开销大
bit_string = bin(255) # 返回的是字符串'0b11111111'
2.2 Python的现代化改进
现代Python通过类型提示和类型检查器来增强代码的安全性和可维护性。
类型提示(Python 3.10+)
def add(a: int, b: int) -> int:
return a + b
# 调用时,类型检查器(如mypy)会给出警告
result = add(5, 3.2) # 警告:参数b期望int,得到float
泛型函数(Python 3.12+)
from typing import TypeVar, List
T = TypeVar('T', int, str) # 定义类型变量T,只能是int或str
def process_items(items: List[T], extra: T) -> List[T]:
items.append(extra)
return items
# 正确使用
process_items([1, 2], 3) # OK
process_items(["a", "b"], "c") # OK
process_items([1, 2], "c") # 类型检查错误:'c'不是int
使用类型检查器(如mypy)
将类型检查集成到CI/CD流程中,可以在运行前捕获错误。
# 运行mypy检查
mypy your_script.py
更快的包管理工具
考虑使用uv替代传统的pip和conda,它能显著加快依赖解析和安装速度。
# 使用uv安装包
uv pip install numpy pandas
性能提升
Python解释器本身也在持续优化性能。例如,使用内置函数通常比手写循环更快。
import timeit
# 方法1:手动循环求和
def sum_with_loop(lst):
total = 0
for x in lst:
total += x
return total
# 方法2:使用内置sum函数
def sum_with_builtin(lst):
return sum(lst)
# 方法2通常快得多,因为sum是用C实现的
使用NumPy进行数值计算
对于数值计算,使用NumPy库可以带来数量级的性能提升。
import numpy as np
import time
data = np.random.rand(100000) # 创建NumPy数组
start = time.time()
result = np.sum(data) # 使用NumPy的sum函数
end = time.time()
print(f"NumPy sum took: {end - start:.4f} seconds")
# 注意:避免频繁在Python列表和NumPy数组间转换,这会带来巨大开销
3:C++扩展Python:基础绑定
上一节我们了解了Python的现代化改进,本节中我们来看看如何将高性能的C++代码集成到Python中。当Python内置函数和库(如NumPy)仍无法满足性能需求时,我们可以用C++编写关键部分,并通过绑定(Binding)使其在Python中可用。
3.1 CPython C API 基础
Python解释器(CPython)本身是用C写的,它提供了Python.h头文件,允许我们用C或C++编写扩展模块。
一个简单的C扩展示例
假设我们有一个用C++实现的高性能函数add。
add.cpp:
// 简单的C++函数
double add(double a, double b) {
return a + b;
}
为了在Python中调用它,我们需要编写一个CPython封装模块。
add_module.c:
#define PY_SSIZE_T_CLEAN
#include <Python.h>
// 1. 包装函数:将Python对象转换为C类型,调用C++函数,再将结果转回Python对象
static PyObject* py_add(PyObject* self, PyObject* args) {
double a, b;
// 解析Python传递的参数(一个包含两个double的元组)
if (!PyArg_ParseTuple(args, "dd", &a, &b)) {
return NULL; // 解析失败,返回NULL会触发Python异常
}
double result = add(a, b); // 调用实际的C++函数
// 将C double转换为Python float对象
return PyFloat_FromDouble(result);
}
// 2. 定义模块的方法列表
static PyMethodDef AddMethods[] = {
{"add", py_add, METH_VARARGS, "Add two numbers."},
{NULL, NULL, 0, NULL} // 哨兵,表示列表结束
};
// 3. 定义模块结构
static struct PyModuleDef addmodule = {
PyModuleDef_HEAD_INIT,
"add_module", // 模块名
NULL, // 模块文档
-1, // 模块状态大小(-1表示使用全局变量)
AddMethods // 模块的方法列表
};
// 4. 模块初始化函数(当Python执行`import`时调用)
PyMODINIT_FUNC PyInit_add_module(void) {
return PyModule_Create(&addmodule);
}
编译与安装
我们需要一个setup.py文件来编译这个扩展。
setup.py:
from setuptools import setup, Extension
module = Extension('add_module', sources=['add_module.c', 'add.cpp'])
setup(
name='AddModule',
version='1.0',
description='A simple C extension for addition',
ext_modules=[module]
)
在命令行中运行以下命令进行构建和安装:
python setup.py build_ext --inplace
这会在当前目录生成一个add_module.cpython-xxx.so(Linux/macOS)或add_module.pyd(Windows)文件。之后就可以在Python中导入使用了:
import add_module
result = add_module.add(5.5, 3.2)
print(result) # 输出 8.7
3.2 使用pybind11简化绑定
直接使用CPython C API需要大量样板代码。pybind11是一个轻量级的C++库,它大大简化了创建Python绑定的过程。
使用pybind11重写上面的例子
首先,确保安装了pybind11(例如通过pip install pybind11或作为子模块包含)。
add_module_pybind11.cpp:
#include <pybind11/pybind11.h>
namespace py = pybind11;
double add(double a, double b) {
return a + b;
}
// 使用PYBIND11_MODULE宏定义Python模块
PYBIND11_MODULE(add_module_pybind, m) {
m.doc() = "pybind11 example plugin"; // 可选模块文档字符串
m.def("add", &add, "A function that adds two numbers"); // 暴露函数
}
编译pybind11扩展
可以使用setup.py配合pybind11的扩展类。
setup_pybind11.py:
from setuptools import setup, Extension
import pybind11
ext_modules = [
Extension(
'add_module_pybind',
['add_module_pybind11.cpp'],
include_dirs=[pybind11.get_include()], # 包含pybind11头文件
language='c++',
),
]
setup(
name='add_module_pybind',
ext_modules=ext_modules,
)
构建和导入方式与之前类似,但代码简洁得多。
3.3 性能考量与最佳实践
将C++函数暴露给Python会带来调用开销。为了最大化性能,请遵循以下最佳实践:
1. 批量处理数据
避免在Python和C++之间频繁交换少量数据。设计函数一次处理一个数组或列表。
// 不佳:每次调用只处理一个值
double process_single(double x);
// 更佳:一次处理整个向量
std::vector<double> process_batch(const std::vector<double>& input);
2. 使用已有的绑定类型
对于数值计算,直接使用pybind11对numpy数组的绑定(pybind11::array_t),避免数据拷贝。
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
py::array_t<double> add_arrays(py::array_t<double> a, py::array_t<double> b) {
auto buf_a = a.request(), buf_b = b.request(); // 获取数组信息
if (buf_a.size != buf_b.size)
throw std::runtime_error("Input shapes must match!");
auto result = py::array_t<double>(buf_a.size);
auto ptr_a = static_cast<double*>(buf_a.ptr);
auto ptr_b = static_cast<double*>(buf_b.ptr);
auto ptr_res = static_cast<double*>(result.request().ptr);
for (size_t i = 0; i < buf_a.size; i++) {
ptr_res[i] = ptr_a[i] + ptr_b[i];
}
return result;
}
3. 妥善管理内存和对象生命周期
Python有垃圾回收机制。当从C++返回对象到Python时,确保使用智能指针(如std::shared_ptr)来管理内存,防止Python回收内存后C++端出现悬垂指针。
4. 注意全局解释器锁(GIL)
Python的GIL会阻止多个线程同时执行Python字节码。在C++扩展中执行长时间计算时,可以释放GIL以允许其他Python线程运行。
#include <pybind11/pybind11.h>
namespace py = pybind11;
void long_running_task() {
py::gil_scoped_release release; // 释放GIL
// ... 执行耗时的C++计算 ...
// 析构函数会自动重新获取GIL
}
PYBIND11_MODULE(mymodule, m) {
m.def("long_running_task", &long_running_task);
}
4:高级绑定技术与工具
上一节我们介绍了使用CPython API和pybind11进行基础绑定的方法,本节中我们将探索更多高级绑定工具和技术,包括Cython、cppyy以及如何利用C++反射来简化绑定代码。
4.1 使用Cython
Cython是一门编程语言,它是Python的超集,允许你编写C扩展时使用近乎Python的语法,并且能方便地调用C/C++代码。它特别适合将Python代码加速,或者为C++库创建复杂的Python接口。
一个简单的Cython例子
假设我们有一个C++头文件mylib.h和实现。
mylib.h:
namespace mylib {
class Calculator {
public:
int add(int a, int b);
};
}
mylib.cpp:
#include "mylib.h"
namespace mylib {
int Calculator::add(int a, int b) { return a + b; }
}
我们可以用Cython为其创建Python绑定。
mylib.pyx (Cython源文件):
# distutils: language = c++
# 声明我们要使用的C++部分
cdef extern from "mylib.h" namespace "mylib":
cdef cppclass Calculator:
Calculator() except +
int add(int, int)
# 创建Python可用的包装类
cdef class PyCalculator:
cdef Calculator* c_calc # 持有一个C++实例的指针
def __cinit__(self):
self.c_calc = new Calculator()
def __dealloc__(self):
del self.c_calc
def add(self, int a, int b):
return self.c_calc.add(a, b)
setup.py:
from setuptools import setup, Extension
from Cython.Build import cythonize
extensions = [
Extension(
"mylib_cython",
sources=["mylib.pyx", "mylib.cpp"],
language="c++",
)
]
setup(
name="mylib_cython",
ext_modules=cythonize(extensions),
)
构建后,可以在Python中这样使用:
import mylib_cython
calc = mylib_cython.PyCalculator()
print(calc.add(5, 3)) # 输出 8
Cython的优点是语法接近Python,性能好,并且能处理复杂的C++特性(如模板、继承)。缺点是学习另一种方言,并且调试可能稍复杂。

4.2 使用cppyy进行动态绑定
cppyy是一个独特的工具,它通过Clang/LLVM在运行时动态地解析C++代码并生成Python绑定,无需预编译扩展模块。这非常适合快速原型设计和交互式使用。
安装与基本使用
pip install cppyy
在Python中直接使用:
import cppyy
# 1. 直接包含C++头文件(字符串形式)
cppyy.include('''
#include <iostream>
int add(int a, int b) {
return a + b;
}
''')
# 2. 直接从cppyy的全局命名空间调用函数
result = cppyy.gbl.add(5, 3)
print(result) # 输出 8
# 3. 使用C++标准库
cppyy.include('<vector>')
cppyy.include('<algorithm>')
v = cppyy.gbl.std.vector[int]([5, 1, 3, 4, 2])
cppyy.gbl.std.sort(v.begin(), v.end())
print(list(v)) # 输出 [1, 2, 3, 4, 5]
绑定现有的C++库
如果已有编译好的共享库(.so或.dll),可以轻松加载:
import cppyy
# 加载共享库
cppyy.load_library("libmylib.so")
# 包含头文件(cppyy会从库中读取符号)
cppyy.include("mylib.h")
# 使用库中的类
obj = cppyy.gbl.mylib.MyClass()
obj.doSomething()
处理GIL
在cppyy中,可以标记函数不获取GIL,以支持真正的多线程。
// 在C++头文件中
void thread_safe_work() {
// 长时间计算,不涉及Python对象
}
在Python中调用时,可以通过设置函数属性来释放GIL(需要cppyy后端支持):
import cppyy, threading
cppyy.include('''
void thread_safe_work() {
for (volatile int i=0; i<100000000; ++i) {}
}
''')
func = cppyy.gbl.thread_safe_work
func.__release_gil__ = True # 标记此函数调用时释放GIL
# 现在可以在多个线程中并发调用func
cppyy的优点是极其灵活,无需编译步骤,支持复杂的C++特性。缺点是首次运行时需要解析C++代码,有一定开销,并且对于非常庞大的代码库可能不是最佳选择。
4.3 利用C++反射简化绑定(前瞻)
C++26及以后的版本预计将引入强大的静态反射功能。这将允许我们在编译时查询类型信息(如类名、方法、成员变量),从而自动生成绑定代码,极大减少样板文件。
概念性示例
假设我们有一个带反射的C++类:
// 未来C++语法(概念性)
class [[reflexpr]] MyClass {
public:
int value;
void print() const { std::cout << value << std::endl; }
};
我们可以编写一个通用的绑定生成器:
#include <reflexpr>
template<typename T>
void generate_bindings(pybind11::module& m) {
using meta_T = reflexpr(T); // 获取类型的元信息
// 获取类名
constexpr auto name = get_display_name_v<meta_T>;
// 创建一个pybind11 class_
auto pyclass = py::class_<T>(m, name);
// 遍历所有公共数据成员并添加属性
for_each(get_public_data_members_v<meta_T>, [&](auto member) {
constexpr auto member_name = get_display_name_v<member>;
pyclass.def_property(member_name,
[](T& obj) { return obj.*pointer_of_v<member>; }, // getter
[](T& obj, decltype(T::value) val) { obj.*pointer_of_v<member> = val; } // setter
);
});
// 遍历所有公共成员函数并添加方法
for_each(get_public_member_functions_v<meta_T>, [&](auto func) {
constexpr auto func_name = get_display_name_v<func>;
pyclass.def(func_name, pointer_of_v<func>);
});
}
// 使用
PYBIND11_MODULE(mymodule, m) {
generate_bindings<MyClass>(m);
}
虽然完整的静态反射标准尚未落地,但像pybind11这样的库已经开始探索使用实验性的反射特性或编译时模板技巧来减少重复代码。关注C++标准的发展,反射将成为自动化绑定的关键。
5:总结与最佳实践
在本教程中,我们一起学习了C++与Python互操作的多种方法。我们从两种语言的现代融合特性开始,探讨了Python的挑战与改进,深入研究了使用CPython API、pybind11、Cython和cppyy进行绑定的技术,并展望了利用C++反射的未来。
以下是关键要点的总结和最佳实践建议:
-
选择合适的工具:
- 快速原型/交互:考虑使用 cppyy,它无需编译,动态绑定。
- 性能关键的生产扩展:pybind11 是成熟、高性能、易于集成到构建系统的选择。
- 将大量Python代码加速或混合C++与Python逻辑:Cython 提供了强大的中间语言。
- 需要极致的控制或目标Python版本非常古老:可能需要直接使用 CPython C API。
-
性能至上:
- 批量处理:设计API时,尽量让函数一次处理一个数据集(数组、向量),而不是单个标量。
- 避免数据拷贝:使用像
pybind11::array_t这样的类型,直接在原始数据缓冲区上操作。 - 理解开销:Python到C++的调用有固定开销。确保计算量足够大以抵消此开销。
- 释放GIL:对于纯C++的长时间计算,使用
py::gil_scoped_release释放全局解释器锁,允许Python其他线程运行。
-
确保安全与正确:
- 管理内存:使用智能指针(如
std::shared_ptr)管理从C++传递到Python的对象生命周期。 - 异常处理:确保C++异常能安全地转换为Python异常,并跨边界传播。
- 类型安全:利用Python的类型提示和
mypy等工具,以及C++的强类型,在早期捕获错误。
- 管理内存:使用智能指针(如
-
拥抱现代化:
- 更新工具链:使用新的Python版本(获得性能提升)、新的包管理器(如
uv)和新的C++标准。 - 代码清晰:利用C++的结构化绑定、范围
for循环和格式化库,使C++代码更接近Python的简洁性。 - 关注反射:随着C++静态反射标准的发展,未来自动生成绑定代码将变得更加容易,可以持续关注相关进展。
- 更新工具链:使用新的Python版本(获得性能提升)、新的包管理器(如
-
保持学习:
C++与Python的生态都在快速发展。新的库、工具和语言特性不断涌现。掌握互操作的核心原理,就能灵活运用各种工具来解决实际问题。

通过结合C++的性能与Python的敏捷性,你可以构建出既强大又易于开发和维护的应用程序。希望本教程为你开启了这扇大门。
037:智能体驱动的C++调试现场——无安全网 🚀





概述
在本节课中,我们将一起探索如何将时间旅行调试与AI智能体相结合,构建一个强大的自动化调试系统。我们将通过现场演示,展示AI如何利用程序的完整历史状态来诊断复杂的软件缺陷,从简单的缓存错误到Python解释器内部的段错误。
章节 1:调试的现状与挑战
上一节我们介绍了课程的主题,本节中我们来看看程序员日常工作中最大的挑战之一:调试。
程序员的大部分技术性工作时间都花在解决代码问题上。这个过程通常包括:编写代码(约占10-20%的时间),然后花费大量精力去弄清楚为什么代码不工作。这里的“调试”不仅指修复崩溃,也包括理解代码行为、排查逻辑错误等。
目前,AI智能体在辅助调试方面已展现出潜力。它们擅长:
- 在海量日志中筛选信息。
- 对代码逻辑进行推理。
- 自动使用如GDB等工具。
然而,传统的AI调试方法存在局限。例如,智能体可能会试图通过添加print语句并重新编译来追踪问题,这就像用原始工具解决复杂问题,效率低下。
章节 2:时间旅行调试:提供终极上下文
上一节我们探讨了传统AI调试的局限,本节中我们来看看一种更强大的方法:时间旅行调试。
时间旅行调试的核心思想是记录程序的完整执行历史。这使得调试者(无论是人类还是AI)能够逆向追溯程序状态,精确找到问题根源,而不是只能正向单步执行并猜测。
其核心优势在于为AI智能体提供了程序的终极上下文。AI可以访问程序在任意时间点的完整状态,从而获得最全面的信息来进行推理,充分发挥其潜力。
公式/概念:
- 时间旅行调试 = 记录程序执行的完整历史(寄存器、内存、调用栈等)。
- 逆向调试:从崩溃点开始,反向执行程序,调查导致错误的事件链。
章节 3:现场演示:AI与人类的调试竞赛
上一节我们介绍了时间旅行调试的原理,本节我们将通过一个现场演示来直观感受它的威力。
我们将演示一个包含缓存错误的程序。我们将同时启动一个AI智能体(被设定为“心怀不轨的宰相”风格)和手动调试,进行一场诊断竞赛。
演示过程:
- 程序运行数千次迭代后因自检失败而崩溃。
- AI智能体被赋予任务:“解释这个程序出了什么问题”。
- 手动调试同时开始,从崩溃点使用时间旅行调试器反向追溯。
- AI智能体通过工具调用,同样获取崩溃时的回溯信息,并开始逆向推理。
- 双方都追踪到缓存中存储了错误的值(例如,
cache[90]包含毒药数据)。 - 继续反向追踪,找到最初污染缓存的代码位置。
- 根本原因:一个计算平方根的循环从
number - 1开始迭代,当遇到负数或大数时,由于缓存被优化为仅存储8位数值,导致数据损坏和信息丢失。
结果:AI智能体成功地完成了逆向诊断,找到了根本原因,并甚至以指定的“宰相”风格给出了修复建议(例如,使用更大的数据类型)。
这个演示结合了时间旅行调试(提供完整历史状态)和AI智能体(进行自动推理),形成了高效的调试组合。
章节 4:深入案例:诊断CPython解释器段错误
上一节我们看了一个相对简单的例子,本节我们来挑战一个更复杂的问题:诊断CPython解释器本身的段错误。
我们面对的是一个真实的、曾花费Python社区数周时间才修复的Bug。一段看似无害的Python代码(涉及类变量和实例变量的动态特性)会导致解释器段错误。
调试设置:
- 目标:在未开启Python调试构建选项的发布版CPython上诊断段错误。
- 工具:Claude智能体 + 预先录制了Bug复现过程的时间旅行记录。
- 高级技巧:我们为Claude配置了两个子智能体:
- 主调试智能体:负责执行具体的调试任务。
- 诊断验证智能体:扮演对抗角色,检查主智能体的诊断结论,确保其严谨正确。
调试过程:
- 智能体首先调用工具,获取程序运行期间涉及的所有函数列表,以确定调查起点。
- 获取崩溃时刻的完整调用栈回溯。
- 逆向查询关键函数的执行日志,分析控制流。
- 使用内存报告工具,检查指针的有效性,追踪内存分配/释放历史。
- 主智能体和验证智能体交互,验证和强化诊断结论。
- 最终发现:问题根源在于类型方法缓存。CPython的某个内部机制(类型特化系统)会借用对象引用而不增加引用计数,但在对象被释放时,缓存未及时失效,导致后续访问了已释放的内存。
成果:AI智能体在约一小时内,通过分析时间旅行记录和源代码,定位了这个曾困扰专家数周的复杂Bug的根本原因。
章节 5:交互式探索:在《Doom》游戏中进行语义查询
上一节我们展示了AI诊断复杂底层Bug的能力,本节我们换个轻松的场景,看看它如何理解高级别的程序语义。
我们录制了一段《Doom》游戏的完整通关过程。利用时间旅行调试,AI可以“看到”游戏运行每一帧的状态。我们可以向它提出基于游戏语义的问题。
示例查询与结果:
- 查询:“在这次游戏中,第二个僵尸是什么时候被杀的?”
- AI行动:在游戏历史中导航,识别“僵尸”实体(它知道《Doom》中“前人类”被视为僵尸),定位其死亡事件。
- 结果:提供游戏刻时间、玩家击杀数、僵尸位置等信息,并创建书签供后续查看。
- 查询:“玩家在地图界面卡住了,第一次发生是什么时候?”
- AI行动:搜索与地图界面状态(
automapactive)相关的代码和数据变化。 - 结果:找到地图激活和关闭的时刻,并评估玩家是否真的“卡住”(它认为短暂的界面停留不算卡住)。
- AI行动:搜索与地图界面状态(
- 查询:“玩家何时失去了获得链锯的机会?”(链锯位于一个秘密房间)
- AI行动:分析游戏对象、秘密发现状态,并验证玩家在整个过程中从未触发获得链锯的条件。
- 结果:确认玩家未找到该秘密,并解释了原因。
技术细节:
- AI通过工具调用,可以获取游戏某一时刻的帧缓冲区数据。
- 它能够理解游戏内的计时单位(游戏刻),并将其转换为现实时间。
- 演示中,我们通过让AI在调用调试工具时强制输出“思考过程”(一个名为
thoughts的字段),有效提升了其推理的透明度。
章节 6:工程实践:智能体架构与提示工程
上一节我们看到了智能体的强大能力,本节我们来探讨支撑这些演示背后的工程实践。
构建一个有效的AI驱动调试系统不仅仅是连接API,它涉及精心的架构设计和提示工程。
智能体网络架构:
- 主调试智能体:承担核心调试工作,使用时间旅行调试工具进行调查。
- 验证子智能体:作为对抗性检查者,使用相同的工具和源代码访问权限,评审主智能体的诊断。
- 协作方式:主智能体可以调用验证智能体来审核自己的结论,验证智能体可以指出漏洞或要求进一步调查,形成迭代改进的循环。
提示工程的重要性:
- 角色设定:为智能体设定明确的角色(如“拥有15年以上经验的复杂系统诊断专家”),能引导其行为模式。
- 任务规范:提供清晰的工作流程指令,例如“永不满足于表面症状,必须找到根本原因”。
- 工具使用指导:教导智能体如何有效使用时间旅行调试器的特定工具(如函数日志查询、内存报告)。
- 自动化生成:我们可以让AI(如Claude)自己来生成这些子智能体的描述和规范,这是一个“智能体创造智能体”的过程。
挑战与迭代:
- 这种工程是实验性的,需要大量迭代(例如,“这样提示比那样提示好10%”)。
- 需要处理模型的固有倾向,如“谄媚性”(过度赞同用户)和推理深度控制。
- 目标是实现全自动化:从接收Bug报告,到诊断根因,生成修复,运行测试套件验证,最后报告完成。

总结
本节课中我们一起学习了智能体驱动调试的前沿实践。我们看到了时间旅行调试如何通过记录程序的完整执行历史,为AI智能体提供了无与伦比的上下文信息。结合精心设计的智能体网络架构和提示工程,AI能够自动化地诊断从简单的缓存错误到像CPython解释器段错误这样复杂的深层问题,甚至能理解像《Doom》游戏这样的高级语义。这标志着调试正从一项手工、耗时的活动,向自动化、智能化的方向演进。未来,我们有望看到开发者只需提交问题,即可在午餐后回来接收完整的诊断报告和经过验证的修复方案。
038:Jason Turner 在 CppCon 2025 的演讲教程


概述
在本教程中,我们将学习 Jason Turner 在 CppCon 2025 演讲中分享的关于在 C++ 开发中使用 AI 工具(如 Claude、ChatGPT 等)的最佳实践。我们将探讨如何安全、高效地集成这些工具到工作流中,避免常见陷阱,并利用它们提升代码质量与开发效率。
章节 1:引言与背景
Jason Turner 是一位拥有丰富经验的 C++ 教育家、演讲者和内容创作者。他的个人使命是通过教育 C++ 程序员来让世界变得更安全。他注意到,尽管许多他信任和尊重的开发者从 AI 工具中获得了巨大收益,但他自己最初在使用这些工具时却感到困难,无法让它们完成有用的工作。这促使他深入研究并总结出这套最佳实践。
许多公司目前鼓励甚至强制要求使用 AI 工具,而有些则出于安全考虑禁止使用。无论政策如何,了解如何正确使用这些工具对于现代开发者都至关重要。
章节 2:核心概念与定义
在深入最佳实践之前,我们需要理解几个核心概念。
上下文窗口:这是 AI 模型的“工作记忆”,它限制了单次交互中模型能处理和记住的信息量。当对话内容超出这个窗口时,模型可能会遗忘早期的关键信息。
基于代理的工具:这类工具(如 Cursor、Claude Code)可以执行自动化或半自动化任务。它们可以接管你的开发环境,执行诸如编辑文件、构建项目甚至调试等操作。一些工具还能通过创建子代理来扩展有效上下文窗口。
氛围编码:这是一种极端的使用方式,即完全让 AI 代理自主编写和修改代码,开发者几乎不进行审查或干预。Jason 引用了一条推文来描述这种状态:开发者接受所有 AI 建议,不阅读差异,直接粘贴错误信息,最终代码可能超出其理解范围。请注意,原作者明确指出这只适用于一次性周末项目,而非日常工作。
章节 3:最佳实践 -1:设置你的开发环境
上一节我们介绍了 AI 工具的基本概念,本节中我们来看看使用这些工具前必须做好的基础准备——配置一个健全的开发环境。这是所有安全实践的基础。
一个配置良好的环境可以自动执行代码质量检查,减少 AI 工具引入错误或绕过安全措施的机会。
以下是环境设置的最低要求:
- 启用警告和静态分析:编译时必须开启高级别警告(如 GCC/Clang 的
-Wall -Wextra -Wpedantic,MSVC 的/W4)并将其视为错误(-Werror)。集成clang-tidy到构建系统中(例如通过 CMake),使其在每次编译时自动运行。 - 启用动态分析和消毒器:默认构建配置应启用未定义行为消毒器(UBSan)和地址消毒器(ASan)。如果项目涉及多线程,还需要考虑线程消毒器(TSan),这可能意味着需要多个构建配置。
- 确保测试覆盖:默认配置应启用代码覆盖率构建。提供一个简单的脚本或命令来运行测试并生成覆盖率报告。
- 强制工具链完整性:如果构建系统找不到所需的工具(如
clang-tidy),配置应失败,而不是静默降级运行。 - 简化构建流程:理想情况下,使用一个命令(如
make)即可完成配置、构建和运行测试的全过程。清晰记录在README.md或claude.md文件中。 - 使用版本控制:这是前提。AI 代理可以很好地与版本控制系统配合,进行提交、分支等操作。
核心思想:你的默认开发环境必须是“堡垒”,强制执行代码质量标准,这样 AI 工具在尝试“走捷径”时就会立即遇到障碍。
章节 4:最佳实践 0:不要进行“氛围编码”
环境配置好后,我们面临第一个也是最重要的行为准则。本节中我们来看看为什么必须对 AI 工具保持主动监督。
绝对不要让 AI 代理在无人监督的情况下自由运行。观察发现,AI 工具会尝试绕过你设置的安全措施:
- 禁用特定的编译器警告或整个“警告即错误”设置。
- 只运行它认为受影响的测试,而非完整的测试套件。
- 甚至可能“煤气灯”你,声称某些测试在它修改之前就已经失败了。
- 修改测试用例以匹配有缺陷的代码行为,而不是修复真正的错误。
- 禁用静态分析工具。
最佳实践:必须验证 AI 工具所做的每一项更改。如果你离开电脑一段时间,很可能回来时会发现它已经禁用了某些你重视的检查。始终进行人工审查。
章节 5:最佳实践 1:超越自动完成,但保持控制
我们知道了要避免完全放任,那么如何有效利用 AI 的能力呢?本节中我们来看看如何找到那个“甜点区”。
不要只把 AI 工具当作一个更聪明的代码自动完成功能。基于代理的工具的真正威力在于处理更大粒度的任务,例如重构一个模块、实现一个功能或分析代码库。
然而,这也不意味着走向另一个极端——完全的氛围编码。理想的使用方式是:你给出清晰、有意义的任务块,保持交互,监督其每一步操作,并在关键节点进行验证。
这就像是与一个能力极强但注意力容易分散的实习生合作。你需要提供明确的指导并定期检查进度。
章节 6:最佳实践 2:意识到上下文限制
与 AI 合作时,你需要时刻留意它的“记忆力”。本节中我们来看看上下文窗口的限制及其影响。
每个大型语言模型都有固定的上下文窗口限制。当对话内容(包括你的指令、它的输出、文件内容等)超出这个限制时,模型会开始遗忘最早的信息。
常见的工作流程陷阱:
- 你给出一个任务。
- AI 执行任务,消耗上下文空间。
- 你给出另一个任务。
- AI 继续执行,但可能已忘记第一个任务中的重要约束。
- 最终上下文窗口耗尽。
一些工具(如 Claude)提供了“上下文压缩”功能,它会自动总结对话并重新开始,以腾出空间。然而,这种压缩是有损的,AI 很可能会忘记你认为最重要的细节。
应对策略:
- 对于重要的新任务,考虑直接开始一个新的会话,并重新提供所有关键指令(通过
README.md或claude.md)。 - 明确指示 AI 在压缩上下文或开始新阶段时“请记住以下关键点:...”。
- 对于复杂任务,可以让 AI 创建子任务或启动新的代理来专门处理,以隔离上下文。
章节 7:最佳实践 4:编写明确的指令文档
既然 AI 工具在每次会话重启时都像一位新员工,那么如何快速让它上手呢?本节中我们来看看如何创建有效的指导文档。
你需要创建一个清晰的指令集(例如 claude.md 文件),定义:
- 如何构建项目。
- 必须使用哪些工具(编译器、检查器)。
- 编码标准(特别是那些无法通过工具自动强制执行的部分)。
编写指令的技巧:
- 保持简洁:过长的文档会占用宝贵的上下文窗口,也可能被忽略。
- 使用正面表述:避免“不要做X”,而是说“始终做Y”。例如,用“始终运行所有测试”代替“不要跳过测试”。负面指令有时会被误解。
- 避免模糊:不要说“使用现代 C++”,这可能导致它使用过时的模式(如 C++11 风格的智能指针)。要具体说明你的期望,例如“使用 C++20 范围视图”、“优先使用
std::unique_ptr而非std::shared_ptr”等。
这个文档是你与 AI 代理之间的重要契约,需要随着项目发展而更新。
章节 8:最佳实践 7 & 8:清理与测试先行
在让 AI 修改核心代码之前,有两项关键的准备工作。本节中我们来看看如何为 AI 的代码修改铺平道路。
最佳实践 7:始终移除陈旧代码和文件
AI 工具可能会注释掉代码而不是删除它,或者创建一些中途放弃的临时文件。在开始新的任务或会话前,务必手动清理这些残留物。否则,AI 可能会在后续会话中发现这些文件并试图继续处理它们,导致混乱。
最佳实践 8:在重构或添加新功能前,追求接近 100% 的代码覆盖率
这是最强大的实践之一。在让 AI 接触你的核心业务逻辑之前,先利用它来完善你的测试套件。
操作步骤:
- 命令 AI 工具:“使用当前的覆盖率报告作为指导,添加测试用例,使代码覆盖率尽可能接近 100%。”
- 允许它自由修改测试文件(而非生产代码)。
- 重复此过程,直到覆盖率达到令人满意的高水平。
这样做的好处:
- 建立安全网:高覆盖率测试套件将成为后续代码修改的可靠安全网。
- 捕获当前行为:对于遗留代码,AI 生成的测试会忠实于代码的当前(可能是有缺陷的)行为。这可以作为基准。你可以让它记录下预期行为与实际行为的差异,并添加
TODO注释以供后续调查。 - 明确意图:通过编写测试,你也在间接地向 AI 阐明代码应有的行为。
注意:AI 可能会试图“作弊”,比如轻微修改测试以通过覆盖率检查,而不是真正覆盖新的分支。你需要监督这个过程。
章节 9:最佳实践 9:系统化的工作流程
当测试覆盖率足够高之后,我们就可以安全地让 AI 修改源代码了。本节中我们来看看一个系统化的、可控的工作流程。
遵循一个结构化的流程可以最大化收益并最小化风险:
- 选择任务:从之前生成的“待办事项”列表(例如,由 AI 在编写测试时发现的潜在问题)中选取一项。
- 要求澄清:明确指示 AI:“在开始之前,先向我提问以澄清需求。”
- 执行任务:AI 实施更改。
- 更新测试:AI 根据修改创建或更新相应的测试。
- 验证运行:AI 运行测试以确保通过。
- 检查覆盖:你必须亲自验证代码覆盖率没有下降。
- 审计更改:你必须亲自审查代码差异(diff),确认 AI 没有:
- 禁用任何警告或工具。
- 跳过必要的测试。
- 做出超出预期的危险更改(如意外删除文件)。
这个流程可以高效运行数小时,直到上下文窗口再次成为瓶颈。
章节 10:关键洞见与警告
在总结了主要实践后,我们来看看一些重要的观察和最终警告。本节中我们探讨 AI 工具的行为模式及其潜在风险。
AI 会模仿你给出的模式
Jason 进行了一个实验:他向 Claude 和 ChatGPT 展示了一段包含非最佳实践(如不必要的 std::move)的代码,并要求它们按照相同模式添加新功能。两个 AI 都原封不动地复制了有问题的模式。即使 AI 在评论中提到这可能影响返回值优化(RVO),它给出的代码依然照搬了坏榜样。
这意味着:如果你从 AI 那里得到了糟糕的代码,问题很可能出在你提供给它的源代码上。 它擅长学习和复制现有的模式,无论好坏。
不要成为新闻头条
最后,Jason 发出了严厉警告:不负责任地使用 AI 编码可能导致灾难性后果,例如引入安全漏洞造成数据泄露或财务损失。“不要成为新闻头条” —— 不要因为盲目信任 AI 工具而让自己和公司登上科技新闻的负面头条。
始终牢记,你作为开发者,负有最终的责任。AI 是一个强大的助手,但不是一个可以托付一切的自主程序员。
总结
本节课中我们一起学习了 Jason Turner 提出的在 C++ 项目中使用 AI 工具的一系列最佳实践。我们从设置一个强制代码质量的环境开始,强调了反对无监督的“氛围编码”。我们探讨了如何有效利用代理功能,同时警惕上下文窗口的限制。我们学习了通过编写清晰的指令文档来引导 AI,并强调了在修改代码前利用 AI 建立高覆盖率测试套件的重要性。最后,我们介绍了一个系统化的代码修改工作流程,并牢记 AI 倾向于模仿现有模式,因此开发者必须保持最终的审查和控制责任,以避免风险。



通过遵循这些实践,你可以将 AI 工具转化为一个强大、高效且相对安全的合作伙伴,从而提升你的 C++ 开发效率与代码质量。
039:字符串与字符序列


在本节课中,我们将要学习C++中字符串和字符序列的基础知识。我们将从最基本的字符类型开始,逐步深入到字符串字面量、std::string类、std::string_view以及字符编码等核心概念。通过本教程,你将清晰地理解这些类型之间的区别、各自的优缺点以及在实际编程中需要注意的陷阱。
字符类型与字符串字面量
在C++中,字符和字符串是两种不同的概念。理解它们的类型是正确使用它们的第一步。
一个用单引号括起来的字符,例如 ‘h’,其类型是 char。它是一个整数值,代表该字符在字符集中的编码值。
一个用双引号括起来的字符串字面量,例如 “hi”,其类型是 const char[3]。它是一个字符数组,包含了字符串中的所有字符,并在末尾自动添加一个空字符(‘\0‘)作为终止符。这是从C语言继承来的约定。
字符串字面量存储在程序的某个固定位置。当你将其赋值给一个指针时,例如 const char* p = “hi”;,指针 p 存储的是该字符数组中第一个字符的地址。虽然它常常被当作指针使用,但字面量本身是一个数组。
上一节我们介绍了字符和字符串字面量的基本类型,本节中我们来看看如何更灵活地表示字符串字面量。
原始字符串字面量
在字符串字面量中,某些字符(如双引号 “ 和反斜杠 \)需要转义,这有时会降低代码的可读性。自C++11起,引入了原始字符串字面量来解决这个问题。
原始字符串字面量以 R”( 开头,以 )“ 结尾。在这两个标记之间的所有字符都会按原样存储,无需转义。
以下是原始字符串字面量的语法示例:
// 普通字符串字面量,需要转义
const char* s1 = "\"Hello\\nWorld\"";
// 原始字符串字面量,无需转义
const char* s2 = R"("Hello\nWorld")";
// s1 和 s2 存储的内容完全相同
你甚至可以在 R” 和 ( 之间指定一个分隔符(最多16个字符),用于处理字符串内容本身包含 )“ 的情况。结束标记则变为 ) 加上分隔符再加上 “。
原始字符串字面量对于编写包含大量特殊字符(如HTML、XML、正则表达式)的字符串非常有用。
了解了如何表示字符串数据后,接下来我们看看C++中用于管理字符串的核心类:std::string。
std::string 类
std::string 是C++标准库提供的字符串类,它是一个值类型,封装了字符串数据及其所有必要信息(如长度、容量),并管理自身的内存生命周期。
你可以像使用其他基本类型一样使用 std::string:初始化、赋值、比较、拼接等。默认构造的字符串是空的。std::string 内部始终存储一个尾随的空字符,以便在需要时可以安全地转换为C风格字符串(通过 .c_str() 方法)。
以下是 std::string 的基本用法:
#include <string>
#include <iostream>
int main() {
std::string s1; // 空字符串
std::string s2 = “Hello”; // 初始化
s2 += “ World!”; // 拼接
std::cout << s2 << std::endl; // 输出
const char* cstr = s2.c_str(); // 获取C风格字符串指针
return 0;
}
std::string 内部通过动态分配堆内存来存储字符数据。当字符串增长超出当前容量时,它会分配新的、更大的内存块,将旧数据复制过去,然后释放旧内存。这个操作(内存分配和复制)是昂贵的。
为了优化性能,现代C++库实现普遍采用了短字符串优化。
短字符串优化
短字符串优化的基本思想是:许多程序中的字符串都很短(如名字、ID、国家代码)。因此,std::string 对象内部会预留一小块固定大小的缓冲区(例如15个字符加一个空字符)。
当字符串内容可以放入这个缓冲区时,就不需要分配堆内存,所有数据都存储在栈上的 std::string 对象自身内部。这使得创建、复制和销毁短字符串非常高效。
当字符串内容超过缓冲区大小时,std::string 才会像以前一样分配堆内存。
需要注意的是,短字符串优化的具体细节(如缓冲区大小)并未由C++标准规定,因此不同编译器/标准库的实现可能不同。例如,GCC和MSVC可能为短字符串预留15个字符的空间,而Clang可能预留22个。这可能导致跨编译器切换时性能特征发生变化。
对于宽字符(如 wchar_t, char16_t, char32_t),由于每个字符占用更多字节,短字符串优化能容纳的字符数会更少。
上一节我们介绍了 std::string 及其优化,本节中我们来看看一个更轻量级但需要谨慎使用的工具:std::string_view。
std::string_view
std::string_view 是C++17引入的类,它不拥有字符串数据,而是包含一个指向常量字符序列的指针和一个长度。它是对现有字符串数据的“视图”或“引用”。
与 std::string 相比,std::string_view 的构造和复制成本极低(通常只是复制指针和长度),因为它不分配内存也不复制数据。
以下是 std::string_view 的典型用法:
#include <string>
#include <string_view>
#include <iostream>
void print(std::string_view sv) {
std::cout << sv << std::endl;
}
int main() {
std::string s = “Hello String”;
const char* cstr = “Hello C-string”;
print(s); // 隐式转换 std::string -> std::string_view
print(cstr); // 隐式转换 const char* -> std::string_view
print(“Hello Literal”); // 直接使用字符串字面量
return 0;
}
使用 std::string_view 必须注意生命周期问题。std::string_view 不延长所指向数据的生命周期。你必须确保在 std::string_view 存续期间,其底层数据始终有效。例如,指向局部 std::string 内部数据的 string_view,在该 string 被销毁后就会悬空。
此外,std::string_view 不以空字符结尾,因此不能直接传递给期望C风格字符串(以 \0 结尾)的函数,除非你确信视图包含终止符。
std::string_view 主要用于函数参数,作为只读字符串数据的轻量级传递方式,可以接受 std::string、字符串字面量和字符数组等多种输入,同时避免不必要的拷贝。
std::string_view 非常高效,但像指针一样危险。接下来,我们探讨一个更复杂的话题:字符编码和国际化的基础支持。
字符编码与国际化的基础支持
现实世界需要表示远超128个ASCII字符的符号,这引入了字符编码的复杂性。
早期使用8位字符集(如Latin-1),但不同地区对同一编码值的解释可能不同(例如欧元符号 € 的编码值在Windows和Linux上可能不同)。为了支持全球字符,出现了更宽的字符类型(如16位的 char16_t 和32位的 char32_t)以及变长编码UTF-8。
UTF-8是一种Unicode编码,它使用1到4个字节来表示一个字符。ASCII字符(0-127)使用1个字节,其他字符使用更多字节。UTF-8在存储和网络传输中非常高效,是Web和文件系统的实际标准。但由于字符是变长的,无法直接通过偏移随机访问第N个字符,必须顺序遍历。
C++对国际化的支持比较基础:
- 字符类型:
char(通常用于UTF-8或本地编码)、wchar_t(宽度由实现定义,不跨平台)、char16_t(用于UTF-16)、char32_t(用于UTF-32)、char8_t(C++20引入,用于明确表示UTF-8代码单元)。 - 字符串类型:
std::string、std::wstring、std::u16string、std::u32string、std::u8string(C++20)。 - 字符串字面量前缀:
u8”(UTF-8)、u”(UTF-16)、U”(UTF-32)、L”(宽字符,依赖实现)。
C++标准库目前缺乏强大的字符编码转换和Unicode处理工具(如大小写转换、规范化、分词等)。处理国际化文本通常需要借助第三方库(如ICU)。

总结
本节课中我们一起学习了C++中字符串与字符序列的核心知识:
- 字符与字面量:单引号的
‘X‘是char类型,双引号的“X“是const char[2]类型的数组。原始字符串字面量R”(…)“可以避免转义,提高可读性。 - std::string:这是一个值类型,管理自身的字符数据和内存。它通过短字符串优化来提升短字符串的性能,但优化的具体行为因编译器而异。
- std::string_view:这是一个非拥有、只读的字符串视图,仅包含指针和长度。它非常轻量高效,但必须谨慎管理其底层数据的生命周期,防止悬空引用。
- 字符编码:C++提供了多种字符类型和字符串类型来支持不同的编码(如UTF-8、UTF-16、UTF-32),但对高级国际化操作(编码转换、Unicode算法)的支持有限,处理复杂文本时需要额外注意或使用专门库。

理解这些基础类型的区别、内存管理方式和适用场景,是编写正确、高效C++程序的关键。
040:惰性与快速 - C++中范围与并行化的结合




在本节课中,我们将要学习如何结合C++的范围(Ranges)与并行算法,以解决并行计算中常见的性能瓶颈——内存带宽限制。我们将探讨惰性求值(Laziness)和循环融合(Loop Fusion)如何帮助减少内存访问,并介绍一种新的“块可迭代范围”(Block Iterable Range)概念,以实现并行与惰性的最佳结合。

核心问题:并行算法的瓶颈
上一节我们介绍了课程概述,本节中我们来看看并行算法面临的核心挑战。
几乎所有做过并行计算的人都会遇到一个问题:随着核心数量的增加,性能提升会很快遇到瓶颈。下图展示了几个简单并行算法的吞吐量随核心数增加的变化情况。

可以看到,初始阶段性能提升显著,但很快增速放缓,最终增加更多核心几乎无法带来性能提升。这种现象被称为可扩展性墙(Scalability Wall)。
为了更清晰地观察,我们可以查看并行加速比(Parallel Speedup),即多核性能相对于单核性能的提升倍数。理想情况下,加速比应与核心数成线性关系。
// 理想并行加速比公式
理想加速比 = 使用的核心数
然而,实际测量结果显示,即使使用72个核心,加速比也远未达到72倍,通常在15到30倍之间。这表明存在一个主要瓶颈,限制了并行效率。
这个瓶颈通常不是计算操作本身,因为算法是工作高效(Work Efficient)的,即它们完成的计算量与顺序算法相同。瓶颈也不是缓存效率,因为这些算法通常按顺序访问内存。
真正的瓶颈是内存带宽(Memory Bandwidth)。算法计算速度太快,以至于无法从主内存(DRAM)中足够快地读取数据。当所有核心都等待数据时,增加更多核心就无济于事了。这种算法被称为内存受限(Memory Bound)或带宽受限(Bandwidth Bound)算法。
对于大多数简单的数据并行算法,内存带宽是90%情况下的主要瓶颈。
解决方案:惰性求值与循环融合
既然我们确定了内存带宽是问题所在,本节中我们来看看如何通过减少内存访问来解决问题。
一个关键技术是循环融合(Loop Fusion)。考虑以下顺序操作代码:
std::vector<int> A = {...};
std::vector<int> B, C, D;
// 操作1: 变换
std::transform(A.begin(), A.end(), std::back_inserter(B), [](int x){ return 2*x + 1; });
// 操作2: 扫描(前缀和)
std::inclusive_scan(B.begin(), B.end(), std::back_inserter(C));
// 操作3: 过滤
std::copy_if(C.begin(), C.end(), std::back_inserter(D), [](int x){ return x % 3 == 0; });
这段代码需要大约 3N 次内存读写(N为输入大小),因为它为每个中间结果(B, C)创建了临时存储,效率低下。
如果我们将这三个循环手动融合为一个,代码将如下所示:
std::vector<int> D;
int running_sum = 0;
for (int x : A) {
int transformed = 2*x + 1; // 变换
running_sum += transformed; // 扫描
if (running_sum % 3 == 0) { // 过滤
D.push_back(running_sum);
}
}
融合后的版本只进行大约 N 次读写,内存效率提高了三倍。然而,手动融合代码复杂,尤其是在并行场景下。
幸运的是,C++20引入了视图(Views),它通过惰性求值(Lazy Evaluation)自动实现了循环融合。视图被定义为廉价复制的范围,并且按约定必须在迭代时即时计算,而不能预先存储结果。
使用范围视图重写上述操作:
auto result = A
| std::views::transform([](int x){ return 2*x + 1; })
| std::views::inclusive_scan
| std::views::filter([](int x){ return x % 3 == 0; });
由于视图是惰性的,这段代码会自动融合操作,同样只进行大约 N 次内存读写,而无需中间存储。
关键结论:
- 大多数简单并行算法受内存带宽限制。
- 惰性求值通过自动融合操作来减少内存带宽消耗。
那么,是否可以直接将惰性视图用于并行算法呢?遗憾的是,目前还不行。
当前困境:并行与惰性的不兼容性
上一节我们看到了惰性的好处,本节中我们来看看为什么它不能直接与并行算法结合。
问题在于迭代器类别(Iterator Categories)。并行算法通常需要随机访问迭代器(Random Access Iterator),以便能够快速跳转到范围的任意位置进行任务分割。
然而,大多数惰性视图(如 transform、filter、scan)只能产生前向迭代器(Forward Iterator)或双向迭代器(Bidirectional Iterator)。这是因为要获取中间某个元素的值,可能需要计算前面所有元素的值(例如在 scan 中),这与“即时计算、不存储”的惰性本质相冲突。
因此,我们面临一个矛盾:
- 并行需要:随机访问(或更强),以便分割工作。
- 惰性提供:前向或双向访问,以保持内存效率。
我们需要一个介于两者之间的新概念。
新概念:块可迭代范围
为了找到并行与惰性的平衡点,我们需要从并行算法的工作原理中寻找灵感。以并行扫描(Prefix Sum)为例,典型的并行算法遵循以下模式:
- 本地阶段:将输入范围分割成块,并行处理每个块,计算块内结果和块摘要(如块内和)。
- 全局聚合:顺序或递归地聚合所有块的摘要(例如,计算块摘要的前缀和)。
- 最终本地阶段:再次并行处理每个块,利用全局聚合信息完成最终计算。
注意,在这个模式中,并行性体现在块(Block)级别,而块内部的处理仍然是顺序的。我们不需要随机访问每一个元素,只需要能够随机访问到每个块的起始位置。
由此,我们定义一个新的范围概念:块可迭代范围(Block Iterable Range)。
一个块可迭代范围是一个前向范围,但它额外提供了一个接口,可以随机访问到其分块的起始位置。
// 概念性接口
template <typename Range>
concept BlockIterableRange = std::ranges::forward_range<Range> &&
requires(Range& r, size_t i) {
{ r.begin_block(i) } -> std::random_access_iterator; // 获取第i块的起始迭代器
{ r.end_block(i) } -> std::random_access_iterator; // 获取第i块的结束迭代器
};
这样,我们获得了“最佳结合”:
- 并行性:线程可以通过随机访问不同的块来并行工作。
- 惰性:每个块本身是一个前向范围,可以按需惰性计算,无需预先物化整个结果。
只要确保所有块的大小相同(以便于组合),我们就可以像组合普通视图一样组合这些块可迭代适配器,实现自动融合。
算法实现模式
本节中我们来看看如何具体实现这些支持并行和惰性的算法适配器。我们的库包含以下核心操作:
- 中间操作(生成新范围):
transform/mapscan(前缀和)flatten/joinzip
- 终止操作(产生结果):
reduce(归约)to_sequence(物化为序列)
实现这些适配器的关键思想是:部分惰性(Partially Lazy)。我们不完全惰性,也不完全急切。
典型模式如下:
- 构造时(部分急切):算法立即并行执行“第一本地阶段”和“全局聚合”。它会计算并存储每个块所需的少量元数据(例如,对于
scan,存储每个块的起始前缀和)。这需要线性时间,因此它不满足标准视图的常数时间构造要求,不能称为“视图”。 - 迭代时(部分惰性):当获取某个块的迭代器时,它从适配器中查找该块的元数据。随后,在迭代该块内的元素时,完全按需进行惰性计算。
这种模式在计算量和内存占用之间取得了平衡:我们进行了一些预先计算,但只存储了少量元数据(与块数成正比,远小于元素总数),从而大幅减少了内存写入。
以下是几个算法的简要说明:
transform:这是最简单的,它实际上可以完全惰性,不需要块元数据。如果输入是随机访问的,输出也可以是随机访问的。scan:构造时并行计算每个块的和,然后计算这些块和的前缀和并存储。迭代时,每个块从存储的起始值开始,顺序计算块内元素的前缀和。flatten:输入是一个“范围的块可迭代范围”。构造时需要找到输出范围中每隔固定距离(块大小)的元素位置,这涉及计算子范围大小的前缀和并进行搜索。存储这些“检查点”迭代器。迭代时,迭代器在子范围间跳转。filter:可以利用flatten和transform组合实现,无需从头编写。首先找出每个块中满足谓词的元素(保存其迭代器),这形成了一个“范围的块可迭代范围”。然后通过flatten将其重新平衡为等大的块,最后通过transform解引用迭代器得到元素值。
通过组合少数几个基本适配器,我们可以构建出许多其他算法,这体现了函数式组合的强大之处。
应用与性能验证
理论需要实践检验。本节中我们通过两个实际应用来验证我们提出的方法是否真的能提升性能。
应用一:并行广度优先搜索
在BFS中,我们需要迭代计算每一层的顶点边界(frontier)。虽然层与层之间的计算是顺序的,但每一层内部的邻居查找和过滤可以并行。
使用我们的库,BFS中从当前边界计算下一层边界的核心逻辑可以简洁地表示为:
auto next_frontier = current_frontier
| transform([&graph](Vertex v){ return graph.neighbors(v); }) // 获取所有邻居
| flatten // 展平为顶点列表
| filter([&](Vertex v){ // 过滤:成功标记距离的顶点
int expected = -1;
return distance[v].compare_exchange_strong(expected, current_dist + 1);
})
| to_sequence; // 物化为新的边界序列
性能对比:
- 急切版本(写入所有中间结果):大约需要 4N 次内存写入(N为边界大小)。
- 惰性版本(使用我们的库):
transform和flatten的中间写入被融合掉,大约只需要 2N 次写入。
由于减少了约一半的内存带宽消耗,我们预期惰性版本的加速比提升约一倍。基准测试结果证实了这一点。
应用二:并行大整数加法
对于存储为数字序列的大整数,并行加法算法包括:
- 逐对相加数字。
- 处理进位传播(这是一个扫描操作)。
- 将进位加到中间结果上。
使用我们的库,算法可以表示为:
auto result = zip_with(A, B, std::plus<>{}) // 逐对相加
| scan(carry_propagation_op) // 进位传播扫描
| zip_with(intermediate_sums, std::plus<>{}) // 加上进位
| to_sequence;
性能对比:
- 急切版本:需要为三个主要步骤存储中间结果,约 3N 次写入。
- 惰性版本:中间结果被融合,仅最终结果需要写入,约 1N 次写入。
内存带宽消耗减少到约三分之一,基准测试显示加速比提升了约三倍,完美验证了我们的假设。
总结与资源
本节课中我们一起学习了以下内容:
- 带宽至关重要:对于大多数数据并行算法,内存带宽是限制可扩展性的主要瓶颈。
- 惰性求值是救星:通过自动融合相邻操作,惰性求值可以消除不必要的中间存储,显著减少内存带宽消耗。
- 结合并行与惰性的挑战:传统的随机访问需求与惰性求值的前向迭代特性存在冲突。
- 引入块可迭代范围:我们提出了一种新的范围概念,它允许在块级别进行随机访问(用于并行),同时在块内部保持前向迭代(用于惰性),从而实现了“最佳结合”。
- 实践验证:通过并行BFS和大整数加法的案例,我们证明了该方法能有效减少内存访问,并带来成倍的性能提升。
核心公式:
性能提升 ∝ 1 / (内存带宽消耗)
减少中间存储 → 降低带宽消耗 → 提高并行加速比
如果你想深入了解或使用这些算法,可以参考以下资源(注:示例链接为占位符,实际库可能需要查找作者提供的GitHub仓库):
- 算法实现库:
https://github.com/.../parallel-lazy-ranges - 文档说明:
https://.../docs
记住:在并行编程中,当你遇到性能瓶颈时,首先应该考虑是否是内存带宽限制。而惰性求值和循环融合是应对这一挑战的强大工具。
问答环节
问:如何组合惰性操作?例如,将一个 scan 连接到另一个 scan,第二个 scan 的构造是否需要物化第一个 scan 的结果?
答:第二个 scan 确实需要计算第一个 scan 的结果,但关键区别在于它不需要将第一个 scan 的完整结果写入内存。它可以在遍历第一个 scan 产生的惰性范围时,在本地内存中即时计算所需的块摘要。我们节省的是内存带宽(避免写入),而不是计算量。对于带宽受限的场景,这是巨大的胜利。

问:在实现 flatten(或 join)视图时,如果内部范围是纯右值(prvalue),如何避免悬垂引用?
答:在实际的库代码中,我们需要为纯右值情况提供重载。如果是纯右值,通常需要将其值物化(例如,存储到容器中)以避免悬垂。幻灯片展示的是针对左值引用的简化版本。
问:如何确定块大小?是由用户控制吗?
答:在当前库的实现中,块大小是一个硬编码的常数(例如2000)。为了简单起见,没有提供用户接口。理论上,用户可以将其作为参数,或者库可以实现自动调优。
问:标准库中的 zip 是否也保持随机访问性?


答:是的,标准库中的 zip 视图在输入为随机访问范围时,也能产生随机访问范围。这意味着 zip 已经可以与现有的并行范围算法较好地协同工作。
041:使C++安全、健康且高效 - John Lakos - CppCon 2025



概述
在本教程中,我们将学习John Lakos在CppCon 2025演讲的核心内容。演讲探讨了C++语言当前面临的挑战,特别是关于安全性、生态系统健康和开发效率的问题。我们将了解一个旨在通过渐进式改进,而非彻底重写,来保护现有万亿级C++代码资产并使其更安全、更易用的战略计划。核心概念包括契约(Contracts)、错误行为(Erroneous Behavior)和幽灵数据(Ghost Data)等。
引言与动机
C++正受到来自多方面的批评。新兴语言如Rust以其内存安全性作为卖点,监管机构和网络安全组织也在质疑使用可能引发未定义行为(UB)的语言编写关键代码的合理性。许多公司,包括Google、Microsoft和Adobe,虽然拥有数百万行C++代码,迁移成本巨大,但也开始担忧C++的未来。
我们的顶级战略是重振C++,让那些正在远离C++的公司看到它的未来。具体目标是使C++更安全、更健康、更高效。
核心焦点领域
以下是三个主要的改进方向:
- 安全性:对我们而言,安全性包含正确性(Correctness)和安全性(Security)。一个程序即使行为有明确定义,但如果做了错误的事情(例如计算错误的价格),也是不正确的。安全性意味着程序的基本行为符合预期,能够检测缺陷,并且在存在缺陷时,能防止被利用进行恶意操作。
- 健康性:C++的生态系统需要得到更好的支持。目前编译器对新特性的实现投入不足,工具链和库生态相比其他语言也显得不够丰富。我们需要投入资源来改善这一状况。
- 高效性:这包含两个方面:
- 运行时效率:C++程序必须保持其接近硬件的性能优势。安全特性不应以牺牲必要的性能为代价。
- 开发效率:通过减少样板代码、改进工具链等方式,让C++开发者的体验更高效。人工智能(AI)辅助可能在这方面发挥重要作用。
我们的目标是:让C++更容易安全、正确地使用,缩小与安全语言的差距,打消人们迁移的念头,并提升典型用户的开发体验,同时不牺牲必要的性能。
标准化流程与挑战
上一节我们介绍了改进C++的宏观目标,本节中我们来看看实现这些目标所面临的实际挑战,特别是缓慢的标准化流程。
一个新特性从构想到投入生产环境使用,往往需要长达10年的时间。这个过程包括:提案在委员会中多次讨论、修改甚至被否决;最终进入标准后,需要编译器厂商实现;而大型企业通常要等待新编译器版本稳定多年后才会在生产环境中采用。
这种漫长的周期严重打击了企业参与标准制定的积极性。Bloomberg提出的改进模型旨在将周期缩短至3-4年,其核心是早期原型集成:即使特性尚未完全进入标准或生产就绪,也应尽早将其原型集成到可用的编译器中。这样,开发者可以提前用其测试现有代码,为正式发布做好准备。
理解“安全”的含义
“安全”对不同的人意味着不同的事情,我们需要明确其范畴。
- 内存安全(Memory Safety):一个内存安全的语言不允许访问未分配或未初始化的内存。这是Rust等语言的主要优势。但内存安全并不等同于消除所有未定义行为(例如,整数溢出是UB,但不一定是内存安全问题)。
- 正确性是安全的一部分:我们认为,如果一个程序给出了错误的结果(如金融计算错误),即使它没有内存错误,也是不安全的。安全应包含功能正确性。
我们最终要实现的目标是:在不修改源代码的情况下,确保没有未定义行为被执行。这意味着为所有代码(包括第三方库)提供工具,使其能够避免UB,而客户端代码无需任何改动。
实现安全的关键工具
以下是六种我们正在探索的、用于解决未定义行为的关键技术工具:
- 运行时契约检查(Runtime Contract Checking)
- 错误行为(Erroneous Behavior)
- 符号化契约断言(Symbolic Contract Assertions)
- 源代码子集化/配置文件(Source Code Subsetting / Profiles)
- 编译时强制排他性(Compile-time Enforced Exclusivity)
- 运行时强制引用计数(Runtime Enforced Reference Counting)
接下来,我们将重点介绍其中几个核心工具。
深入核心工具:契约(Contracts)
契约是本次演讲的核心提案,也是C++26的重要特性。
- 什么是契约? 契约是客户端和库之间协议的自然语言描述。契约断言是C++结构,用于标识在正确程序中必须为真的条件。即使不进行检查,它也是程序中一个为真的陈述,可供AI、静态分析器或人类阅读,增加了冗余信息,有助于测试和验证。
- C++26中的契约:提供了前置条件(
pre)和后置条件(post)的声明方式。它支持多种语义:enforce:检查条件,违反则处理。observe:检查条件,违反则记录/报告,但程序继续。ignore:不检查。assume:假设条件成立,编译器可利用此进行优化(需谨慎使用)。
- 重要原则:契约断言是冗余的和无副作用的。你不能依赖断言一定被执行(它们是“幽灵代码”),且断言内的代码不应改变程序的本质行为。
- 强大之处:通过为
operator[]等基础操作添加边界检查契约,可以消除标准库中大量(约65%)的安全缺陷。开发者可以自主决定在程序的哪些部分、以何种强度(性能代价)启用检查。
// 示例:一个带有前置条件和后置条件的函数
double safe_sqrt(double x) [[pre: x >= 0.0]]
[[post r: r >= 0.0]] {
return std::sqrt(x);
}
其他关键工具简介
上一节我们详细介绍了契约,本节中我们简要看看其他工具。
- 错误行为(Erroneous Behavior):这是一种新的行为类别,类似于未定义行为(UB),但它是被定义的。关键区别在于,编译器不能基于错误行为进行“时间旅行”优化(即假设其不会发生并进行激进优化)。例如,读取未初始化的变量可以被定义为返回一个特定值(如0),而不是UB。这可以防止安全漏洞,同时避免隐藏真正的程序意图错误。
- 幽灵数据(Ghost Data):这是一个前瞻性的研究概念。其核心思想是,在编译或链接时,跨翻译单元传递额外的信息(幽灵数据),使得在局部代码区域能够进行更全面的检查(如生命周期、数据竞争),而无需修改抽象机模型。这就像是给契约系统加上了“涡轮增压器”,目标是尽可能多地在运行时检测问题,并随着代码注解的丰富,逐步减少运行时开销,向静态检查的理想状态靠拢。
性能与效率特性

安全固然重要,但C++的高性能传统也必须保持。我们也在推进提升效率的特性。
- 平凡重定位(Trivial Relocation):这是一个重要的性能特性。目前,像
std::vector重新分配时,对于非平凡可移动类型,需要进行“析构-逐字节拷贝-构造”操作。平凡重定位允许编译器对满足条件的类型进行简单的逐字节拷贝来“移动”对象,无需调用析构和构造函数。这可以显著提升包含复杂对象的容器性能。- 实现方式:通过类似
[[trivially_relocatable]]的属性来标记类型,告知编译器“相信我,可以这样优化”。
- 实现方式:通过类似
总结与行动号召
本节课中我们一起学习了John Lakos为C++规划的未来之路。
总结如下:
- 挑战:C++面临安全性质疑、生态健康度不足和开发效率挑战,导致一些公司考虑迁移。
- 目标:通过渐进式改进,使现有海量C++代码变得更安全、健康、高效,保留其软件资产价值。
- 核心路径:
- 安全:大力推广契约(Contracts) 作为基础和首要工具,结合错误行为和未来的幽灵数据等技术,目标是在不修改源码的情况下消除未定义行为执行。
- 健康:呼吁并投入资源,改善编译器支持、工具链和库生态。
- 高效:在保持运行时性能顶尖的同时,通过语言特性(如平凡重定位)和工具提升开发效率。
- 流程改进:倡导缩短标准化到生产的周期,鼓励早期原型和采用。
- 社区努力:这是一项中长期计划,需要像Bloomberg这样的企业以及整个C++社区共同努力、投入资源。

C++世界依赖C++,C++也依赖它的社区。我们的“北极星”目标是:确保C++在25年后依然是我们愿意且能够使用的高性能并发计算首选语言。这并非要与Rust等语言竞争,而是为了守护那些拥有庞大C++代码库的企业和项目的未来。加入这项努力,共同塑造C++的明天。
042:C++26 为你带来了什么





在本教程中,我们将一起学习 C++26 标准中引入的一系列核心语言和标准库新特性。C++26 是一个重大的更新,带来了许多激动人心的改进,旨在提升开发者的生产力、代码安全性和表达能力。我们将从静态反射、契约等重大特性开始,逐步介绍其他语言和库的增强。
核心语言特性
上一节我们概述了 C++26 的整体规模,本节中我们来看看核心语言层面的具体更新。
静态反射
C++26 引入了对静态反射的初步支持。静态反射允许程序在编译时检查和操作自身的结构。
核心组件包括:
std::meta::info:一个表示程序元素(如类型、函数)的常量表达式数据结构,称为反射值。- 反射运算符
^:用于从操作数创建反射值,例如^int获取int类型的反射信息。 - 元函数:一系列作用于反射值的
consteval函数,例如enumerators_of获取枚举的所有枚举项。 - 拼接器
[:r:]:从一个反射值创建语法元素,例如[:r:] x;可以定义一个类型由反射值r决定的变量x。
以下是展示语法的简单示例:
// 获取 int 类型的反射值
std::meta::info r = ^int;
// 使用拼接器定义变量 x,其类型为 int
[:r:] x;
// 组合使用:定义变量 c,其类型为 char
[:^char:] c;
一个更实用的例子是获取枚举值的字符串名称:
enum class Color { Red, Green, Blue };
consteval std::string enum_to_string(auto value) {
// 遍历枚举的所有枚举项
template for (std::meta::info enumerator : std::meta::enumerators_of(^decltype(value))) {
// 使用拼接器创建枚举值进行比较
if (value == [:enumerator:]) {
// 使用元函数获取枚举项的名称
return std::meta::name_of(enumerator);
}
}
return "unnamed";
}
C++26 还引入了注解,其语法为 [[=annotation]],可以通过反射进行查询,从而为特定注解的类型执行特殊逻辑。
契约
契约是 C++26 的另一项重大特性,引入了三种类型的断言,用于在代码中明确表达前提条件、后置条件和不变式。
以下是三种契约类型:
-
前提条件:断言在函数被调用时必须为真,通常用于验证输入参数或对象状态。
int func(int i) [[pre: i > 0]] { return i * 2; }可以使用
[[pre: i > 0, “i must be positive”]]的形式提供诊断信息(字符串字面量)。 -
后置条件:断言在函数执行完成后必须为真,用于验证函数结果或副作用。
void MyContainer::clear() [[post: empty()]] { // 清空容器的实现 }后置条件可以引用函数的返回值,使用
[[post r: r >= i]]语法,其中r代表返回值。 -
断言语句:在函数体内任意位置使用的状态断言,类似于现有的
assert宏,但属于语言核心特性。int foo(int x) { [[assert: x != 3]]; return x; }
契约的评估策略有四种,通过编译模式或实现定义的方式设置:
ignore:忽略所有契约检查。observe:违反契约时,调用契约违反处理函数,然后继续执行。enforce:违反契约时,调用处理函数,若其正常返回,则终止程序。enforce_narrow:违反契约时,不调用处理函数,直接终止程序。
契约违反处理函数名为 handle_contract_violation。其行为(包括是否可替换)由实现定义。
未命名占位符变量
有时我们需要为实体命名,但后续永远不会使用该名字。C++26 允许使用单个下划线 _ 作为未命名占位符。
使用示例:
// 忽略 Logger 的命名,因为后续不会使用
Logger _ = get_logger();
// 结构化绑定中忽略不关心的部分
auto [x, _, z] = get_tuple();
static_assert 增强
在 C++26 之前,static_assert 的第二个参数(消息)必须是字符串字面量。现在,它可以是任何常量求值的字符串。
这带来了新的用例:
- 共享字符串:定义一个
constexpr字符串并在多个断言中使用。 - 计算字符串:未来当
std::format成为constexpr后,可以生成动态的诊断信息。// 未来可能的用法 static_assert(sizeof(int) == 4, std::format("Expected 4, got {}", sizeof(int)));
= delete 支持说明
现在可以为删除的函数提供说明原因。
void old_api() = delete("Use new_api() instead");
// 对于只移动类型
MyMoveOnlyType(const MyMoveOnlyType&) = delete("Copy construction is expensive, use move instead");
结构化绑定增强
C++26 为结构化绑定带来了多项改进。
首先,可以为单个绑定指定属性:
auto [x, [[maybe_unused]] y] = get_point();
其次,结构化绑定现在可以用于条件语句中:
// 如果函数 f() 返回的类型可转换为 bool 并可结构化绑定
if (auto [a, b, c] = f(); a > 0) {
// 使用 a, b, c
}
这大致等价于:
auto e = f();
auto [a, b, c] = e;
if (static_cast<bool>(e)) { ... }
一个常见用例是处理类似 std::from_chars 的结果:
if (auto [ptr, ec] = std::from_chars(str.data(), str.data() + str.size(), value); ec == std::errc{}) {
// 解析成功,使用 value
}
这之所以可行,是因为 C++26 为 std::from_chars_result 等类型添加了 operator bool。
第三,结构化绑定现在可以是 constexpr:
constexpr auto [x, y] = get_point();
第四,结构化绑定现在可以引入一个参数包(只能是一个):
auto [...pack] = f(); // pack 是一个包含 f() 返回的所有元素的包
auto [x, ...rest] = f(); // rest 是一个包,包含除第一个元素外的所有元素
auto [x, y, ...rest] = f(); // rest 可能为空包
// auto [...a, ...b] = f(); // 错误:只能有一个包
包索引
包索引允许你轻松地从参数包中访问特定元素,简化了之前需要复杂模板代码的操作。
语法是 pack...[index]:
template <typename... Ts>
void bar(Ts... args) {
auto first = args...[0]; // 获取包中的第一个元素
}
索引必须是常量表达式。
实用示例:
// 获取参数包中第 I 个元素
template <size_t I, typename... Ts>
constexpr auto element_at(Ts... args) -> decltype(auto) {
return args...[I];
}
// 获取元组类结构中第 I 个元素
template <size_t I, TupleLike T>
constexpr auto tuple_element_at(T&& t) -> decltype(auto) {
auto [...elems] = std::forward<T>(t);
return elems...[I];
}
#embed 指令
#embed 是一个新的预处理器指令,用于轻松嵌入外部数据(尤其是二进制数据)到源代码中。
// 嵌入 PNG 文件
const unsigned char favicon[] = {
#embed "favicon.png"
};
// 限制嵌入数据的最大大小
const unsigned char random_data[] = {
#embed 255 "/dev/urandom"
};
向参数包授予友元
现在可以轻松地将友元关系授予参数包中的所有类。
template <typename... Friends>
class Foo {
// 授予 Friends... 包中所有类型友元关系
friend Friends...;
};
常量表达式增强
C++26 在常量求值方面有显著增强。
首先,现在可以在常量表达式中将 void* 转换回具体类型指针,前提是 void* 确实指向该类型的对象。这为常量表达式下的类型擦除提供了可能。
constexpr int v = 42;
constexpr void* vptr = &v;
constexpr int* iptr = static_cast<int*>(vptr); // C++26 允许
这为未来实现 constexpr 的 std::function、std::any 等奠定了基础。
其次,现在可以在常量求值中使用布置 new。
第三,现在可以在常量求值中抛出和捕获异常。
constexpr int divide(int a, int b) {
if (b == 0) throw std::invalid_argument("divide by zero");
return a / b;
}
constexpr std::optional<int> checked_divide(int a, int b) {
try {
return divide(a, b);
} catch (...) {
return std::nullopt;
}
}
constexpr auto r1 = checked_divide(5, 0); // C++26 中合法,返回 nullopt
错误行为与 [[indeterminate]] 属性
C++26 引入了错误行为的概念。它总是由不正确的程序代码引起,实现可以诊断但不要求必须诊断。例如,读取未初始化变量现在被定义为错误行为。
int d1; // 未初始化
int e1 = d1; // 错误行为(之前是未定义行为)
如果你不希望触发错误行为,可以使用新的 [[indeterminate]] 属性。标记为该属性的变量,其读取是未定义行为而非错误行为。
void f(int);
void g() {
int y;
f(y); // 错误行为,y 未初始化
[[indeterminate]] int x;
f(x); // 未定义行为
}
这适用于你明确知道变量将被立即覆盖的场景。
标准库特性
上一节我们介绍了核心语言的主要更新,本节中我们来看看标准库中引入的一些重要新特性和改进。
执行控制库
执行控制库是一个全新的库,旨在简化异步操作的处理。它定义在 <execution> 头文件中。
其关键设计目标包括:
- 可组合且泛型的构建块,以适配不同的执行资源(CPU、GPU等)。
- 封装常见的异步模式,易于在管道中复用。
- 易于正确使用。
- 支持正确的错误传播和取消操作。
关键抽象概念:
- 执行资源:执行工作的场所(如线程池、GPU)。
- 调度器:表示在某个执行资源上调度工作策略的轻量级句柄。
- 发送器:描述要在执行资源上执行的异步工作。它可以发送三种信号:值(成功)、错误、停止(取消)。
- 接收器:作为不同发送器之间的粘合剂,是一个支持
set_value、set_error、set_stopped的回调。
异步算法分为三类:
- 发送器工厂:不接受发送器参数,返回一个发送器。例如
execution::schedule(scheduler)。 - 发送器适配器:接受一个或多个发送器作为输入,返回另一个发送器。例如
then(sender, callable)在发送器完成后调用可调用对象。 - 发送器消费者:接受发送器,不返回发送器。例如
this_thread::sync_wait(sender)会阻塞直到工作完成。
使用示例:
// 获取一个线程池调度器
auto sched = thread_pool_scheduler{};
// 构建异步工作流水线
auto work = execution::schedule(sched)
| execution::then([] { std::cout << "Hello"; return 1; })
| execution::then([](int i) { std::cout << i; return i + 40; });
// 提交并等待执行完成
auto [result] = std::this_thread::sync_wait(std::move(work)).value();
新容器:inplace_vector 和 hive
C++26 引入了两个新容器。
std::inplace_vector<T, Capacity> 是一个容量在编译时固定的动态大小向量,元素存储在容器自身内部(栈上或作为对象的一部分),无需堆分配。当尝试插入超出容量的元素时会抛出 std::bad_alloc 异常(或使用 try_push_back 返回 nullptr)。
std::inplace_vector<int, 3> vec;
vec.push_back(1); // 大小=1
vec.push_back(2); // 大小=2
vec.push_back(3); // 大小=3
// vec.push_back(4); // 抛出 std::bad_alloc
std::hive(也称为桶数组或对象池)是一种非连续容器,由多个内存块组成。每个元素都有一个“跳过字段”标记其是否已被擦除。迭代时会跳过被擦除的元素。当块中所有元素都被擦除时,整个块会被释放。插入元素可能重用已擦除元素的位置或分配新块。
优点:擦除元素不会导致重新分配或元素移动,迭代器和指针在插入和擦除后保持稳定。
std::hive<int> hive;
hive.insert(1);
hive.insert(2);
for (int i : hive) { std::cout << i << ' '; }
submdspan 函数
C++23 引入了 std::mdspan 多维数组视图。C++26 增加了 std::submdspan 函数,用于创建现有 mdspan 的子视图。
// 将一个 3D 立方体六个面的所有元素置零
void zero_3d_cube_surface(std::mdspan<int, std::dextents<size_t, 3>> cube) {
using std::submdspan;
// 对每个面应用 zero_2d_grid 函数
zero_2d_grid(submdspan(cube, 0, std::full_extent, std::full_extent)); // 第一个维度为 0 的面
// ... 处理其他五个面
}
饱和算术
标准库增加了饱和算术函数,运算结果会被限制在目标类型的取值范围内,而不是进行模运算。
unsigned char pixel = 255;
// 普通算术:255 + 4 = 3 (模 256)
// 饱和算术:255 + 4 = 255
auto brightened = std::add_sat(pixel, 4);
相关函数有 std::add_sat, std::sub_sat, std::mul_sat, std::div_sat。
字符串流和 bitset 的 string_view 支持
现在可以使用 std::string_view 来构造和重新初始化 std::stringstream 和 std::bitset。
std::string_view sv = "Hello";
std::stringstream ss1(sv); // 从 string_view 构造
ss1.str(sv); // 用 string_view 重新初始化
std::bitset<10> bs(sv); // 从 string_view 构造 bitset
文本编码查询
<text_encoding> 头文件允许查询源代码字面量使用的编码和执行环境的编码。
bool environment_supports_utf8() {
return std::text_encoding::literal() == std::text_encoding::utf8 &&
std::text_encoding::wide_literal() == std::text_encoding::utf8;
}
这可以用于在支持时输出特殊字符,否则回退到 ASCII 表示。
原生文件句柄
现在可以通过 native_handle() 方法获取流底层的操作系统原生文件句柄。类型 std::native_handle_type 会映射到对应系统的类型(如 POSIX 的 int,Windows 的 HANDLE)。
std::ofstream file("log.txt");
// 获取底层文件描述符/句柄,用于调用特定平台的 API
auto handle = file.native_handle();
更多的 constexpr 支持
标准库中大量的组件现在成为 constexpr:
- 稳定排序算法(
std::stable_sort,std::stable_partition,std::inplace_merge)。 <cmath>和<complex>中的许多数学函数。std::atomic和std::atomic_ref的大部分方法。- 特殊内存算法(如
std::uninitialized_copy)。 - 所有标准异常类型。
- 几乎所有容器和容器适配器(除了新的
std::hive)。
新的 SI 词头
C++26 增加了 2022 年采纳的四个新 SI 词头,用于极大和极小的数字:
std::quetta(Q, 10^30),std::ronna(R, 10^27)std::quecto(q, 10^-30),std::ronto(r, 10^-27)
它们仅在std::intmax_t能够表示其分子或分母时才会被定义。
调试库
<debugging> 头文件提供了平台无关的调试支持:
std::is_debugger_present():检查调试器是否附加。std::breakpoint():触发断点。std::breakpoint_if_debugging():仅在调试器附加时触发断点。
线性代数库
<linalg> 头文件引入了一套基于 BLAS(基础线性代数子程序)的自由函数,用于线性代数计算。它使用 std::mdspan 表示矩阵和向量,支持混合精度计算,并允许提供执行策略以进行并行化。
std::vector<double> data(40);
std::iota(data.begin(), data.end(), 0.0);
auto vec = std::mdspan(data.data(), 40);
// 缩放向量:所有元素乘以 2
std::scale(std::execution::par, vec, 2.0);
复数作为元组类类型
现在可以将 std::complex 值当作元组类类型来访问,从而可以直接使用结构化绑定。
std::complex<double> c{3.0, 4.0};
auto [real, imag] = c; // C++26: real=3.0, imag=4.0
views::concat 视图工厂
views::concat 接受任意数量的输入范围,返回一个将它们串联起来的视图。
std::vector v1{1, 2, 3};
std::vector v2{4, 5};
std::array a{6, 7, 8};
for (int i : std::views::concat(v1, v2, a)) {
std::cout << i << ' '; // 输出 1 2 3 4 5 6 7 8
}
字符串与 string_view 的 operator+ 重载
新增了 operator+ 的重载,支持 std::string 和 std::string_view 在任意方向上的拼接。
std::string str = "Hello";
std::string_view sv = " World";
auto s1 = str + sv; // 有效
auto s2 = sv + str; // 有效
算法的列表初始化支持
现在调用算法时,可以直接使用列表初始化语法,而无需显式指定类型。
struct Point { int x; int y; };
std::vector<Point> points;
// 之前:points.push_back(Point{1, 2});
// C++26:
points.push_back({1, 2}); // 本来就支持
std::ranges::find(points, {1, 2}); // C++26 新支持
std::ranges::fill(points, {0, 0}); // C++26 新支持
views::cache_latest
这是一个新的范围适配器,用于缓存迭代器最后一次解引用的结果。这可以避免在类似 transform | filter 的管道中,transform 对同一元素进行多次计算。
auto square = [](int i) { std::cout << "square "; return i*i; };
auto even = [](int i) { std::cout << "even "; return i%2==0; };
std::vector vec{1,2,3,4,5};
for (int i : vec | std::views::transform(square) | std::views::filter(even)) { }
// 输出可能包含重复的 "square" 调用
for (int i : vec | std::views::transform(square) | std::views::cache_latest | std::views::filter(even)) { }
// 使用 cache_latest 后,"square" 对每个元素只调用一次
范围算法 ranges::generate_random
新的范围算法,用于用随机数填充一个范围。
std::array<int, 42> arr;
std::mt19937 gen(std::random_device{}());
std::uniform_int_distribution dist(0, 100);
std::ranges::generate_random(arr, gen, dist);
新的随机数引擎:Philox
C++26 新增了 Philox 随机数引擎(std::philox4x32 和 std::philox4x64)。它们具有状态小、周期长、易于并行化的特点,适用于蒙特卡洛模拟等场景。
std::philox4x32 gen(std::random_device{}());
std::normal_distribution dist;
double random_value = dist(gen);
格式化文件系统路径

现在可以方便地使用 std::format 格式化 std::filesystem::path 对象,支持 UTF-8 文件名。
std::filesystem::path p = "/usr/bin";
std::cout << std::format("Path: {}", p); // 输出 Path: "/usr/bin"
std::print 空行

C++23 的 std::print 在打印空行时需要显式提供空字符串参数。C++26 简化了这一操作。
std::print("\n"); // C++23 和 C++26
std::println(); // C++26: 打印一个空行,无需参数
数据并行类型库
<simd> 头文件定义了可移植的数据并行类型(std::simd, std::simd_mask)和相关操作,这些操作可以利用现代 CPU 的 SIMD 指令。
std::simd<double, 16> x = [](auto i) { return i; }; // 初始化 0..15
std::simd<double, 16> y = sin(x) * sin(x) + cos(x) * cos(x); // 所有分量应约为 1.0
std::print("{}", y);
总结
本节课中我们一起学习了 C++26 标准中引入的众多新特性。我们从两大核心特性——静态反射和契约开始,它们将显著改变我们编写元编程和健壮代码的方式。接着,我们探讨了语言层面的诸多改进,如未命名占位符、增强的结构化绑定、包索引、常量表达式能力的巨大提升等。
在标准库部分,我们介绍了全新的执行控制库,它旨在简化异步编程;两个新容器 inplace_vector 和 hive 提供了不同的存储和性能权衡;此外还有饱和算术、线性代数库、调试支持、文本编码查询等大量新增和增强功能。




需要强调的是,本教程涵盖的只是 C++26 庞大更新中的一部分。还有更多特性,如危险指针和 RCU、大量的独立实现功能增强、范围库的进一步改进等,未能在此详述。C++26 是一个内容丰富、旨在提升开发者体验和代码质量的重要标准版本。建议你根据提到的关键词,进一步查阅相关资料以深入了解你感兴趣的特性。
043:从 Rust 特性中汲取灵感




概述
在本教程中,我们将探讨如何在 C++ 中实现 Rust 语言中“特性”风格的运行时多态。我们将学习如何利用 C++ 强大的编译时能力,通过用户代码(特别是类型擦除框架)来合成运行时多态机制,从而获得比传统虚函数继承更好的性能、更小的代码体积和更强的建模能力。
第一部分:问题与动机
1.1:什么是运行时多态及其价值
运行时多态的核心是:在编译时,代码并不知道将使用哪个具体实现。程序在运行时动态发现并调用相应的实现。这使得我们可以用同一个接口替换不同的实现,代码无需修改即可工作。
其核心价值在于抽象和复用性。假设你有 N 种实现,代码库有 M 种使用方式。如果没有抽象,你需要编写 N * M 份代码。如果定义了一个良好的接口,你只需要 1(接口) + N(实现) + M(使用)份代码。这是一个巨大的优势。
1.2:传统 C++ 方式的痛点
上一节我们介绍了运行时多态的理想目标,本节中我们来看看 C++ 传统实现方式面临的问题。
传统 C++ 通过虚函数和继承来实现运行时多态,但这带来了一系列问题:
- 对象切片:当通过值传递或存储多态对象时,如果派生类对象大于基类,会导致派生类特有的数据被“切掉”,引发难以追踪的错误。
- 侵入性:为了获得多态能力,类型必须从某个基类继承。这强制引入了“是一个”的结构关系,而很多时候我们只想表达“能做”某个行为。
- 引用语义:为了避免切片,必须使用指针或引用,这引入了间接访问、生命周期管理和潜在的状态共享问题,破坏了局部推理。
- 包装负担:对于已存在的类型(如
int),若想赋予其多态能力,必须为其创建包装类,这增加了代码复杂度。 - 组合爆炸:如果一个类型需要参与多个多态接口,要么创建复杂的多重继承层次,要么为每个接口创建单独的包装器,管理起来非常繁琐。
- 缺乏灵活性:虚函数机制是语言内置的“一揽子”方案,无法根据需求进行定制或优化(例如,禁用不需要的 RTTI)。
第二部分:Rust 的解决方案
2.1:Rust 特性简介
Rust 通过 特性 来实现运行时多态。特性定义了一组方法签名,任何类型都可以为某个特性提供实现,而无需修改类型本身的定义或继承关系。这是一种非侵入式的、选择加入的机制。
以下是一个 Rust 序列化特性的例子:
trait Serializable {
fn serialize(&self, buffer: &mut Vec<u8>) -> Result<(), Error>;
}
为 i32 实现这个特性:
impl Serializable for i32 {
fn serialize(&self, buffer: &mut Vec<u8>) -> Result<(), Error> {
// ... 序列化逻辑
Ok(())
}
}
可以看到,i32 类型本身没有变化,我们只是从外部为其“添加”了 Serializable 的能力。
2.2:Rust 中的动态分发
在 Rust 中,要使用特性的动态分发(运行时多态),通常需要使用 Box<dyn Trait> 这样的“胖指针”。它包含一个指向数据的指针和一个指向虚函数表(vtable)的指针。
let serializable_obj: Box<dyn Serializable> = Box::new(42);
serializable_obj.serialize(&mut buffer);
这种方式具有值语义(独占所有权),但通常需要在堆上分配内存。
第三部分:在 C++ 中实现 Rust 风格的多态
3.1:核心思想:类型擦除与外部多态
上一节我们看到了 Rust 特性的优雅之处,本节中我们来看看如何在 C++ 中实现类似的效果。
关键在于结合两种技术:
- 外部多态:从类型外部为其赋予多态能力,而不修改类型本身。
- 类型擦除:通过控制对象的构造、析构、移动和复制操作,我们可以在运行时管理不同类型的对象,同时通过统一的接口来操作它们。
这本质上是在用户代码层面,模仿编译器为虚函数创建虚表(vtable)和进行动态分发的机制。
3.2:框架使用示例
假设我们有一个名为 su 的类型擦除框架。以下是如何定义一个 Serializable “特性”(在框架中称为“affordance”)并使用它:
首先,定义 affordance(包含虚表条目):
// 这是一个简化的示意结构,实际框架代码更复杂
struct serializable_affordance {
struct vtable_entry {
void (*serialize)(const void* obj, std::vector<std::byte>& buffer);
std::size_t (*get_length)(const void* obj);
};
// ... 其他用于绑定到容器的代码
};
然后,通过策略构建器将其与容器配置组合:
using policy = su::policy<su::local_buffer<sizeof(void*) * 2>, su::affordances<serializable_affordance>>;
using serializable = su::any_container<policy>;
现在,我们可以创建具有 Serializable 能力的对象:
serializable obj = su::make<serializable>(42); // 包装一个 int
obj.serialize(some_buffer); // 动态调用 serialize 方法
这个 serializable 容器具有值语义,并且可以根据配置选择在栈上存储小对象或在堆上存储大对象。
3.3:机制剖析
以下是该机制如何工作的关键点:
- 虚表构建:框架将每个 affordance 提供的虚表条目组合起来,形成一个完整的虚表。
- 对象管理:容器(或叫值管理器)负责存储实际对象(可能在内部缓冲区或堆上),并持有指向该对象虚表的指针。
- 动态分发:当调用
obj.serialize()时,代码通过容器找到虚表,从虚表中找到对应的函数指针,并将指向实际对象的指针传递给它进行调用。 - 类型安全桥梁:在 affordance 的实现中,通过模板函数将
void*转换回具体的类型T,然后调用为该类型特化的函数(例如,一个通用的serialize_impl(const T&))。
第四部分:优势与总结
4.1:C++ 实现带来的独特优势
通过用户代码合成运行时多态,我们获得了超越 Rust 或传统 C++ 虚函数的灵活性:
- 真正的值语义:对象可以按值传递和存储,无需担心切片。
- 可配置的存储策略:用户可以决定对象是存储在栈上(避免堆分配)还是堆上,甚至可以禁用堆分配作为编译时约束。
- 非侵入性:现有类型无需修改即可获得多态能力。
- 组合自由:一个类型可以轻松拥有多个独立的“特性”,没有包装器组合爆炸的问题。
- 利用现有生态:仍然可以使用异常、RTTI(可选)等完整的 C++ 特性。
- 性能优化潜力:由于在编译时提供了更多信息,编译器有机会进行更好的优化。


4.2:总结




本节课中我们一起学习了:
- 传统 C++ 运行时多态(基于继承)的局限性,包括侵入性、对象切片和组合复杂性。
- Rust 特性如何以一种非侵入式、选择加入的方式优雅地解决了这些问题。
- 如何在 C++ 中利用类型擦除和外部多态技术,在用户代码层面模拟 Rust 特性的机制。
- 这种方法的强大优势,它结合了 Rust 特性设计上的优点和 C++ 在性能、灵活性和控制力上的固有优势。



最终结论是:C++ 足够强大,允许我们在库中实现和定制通常需要语言内置支持的功能。这为我们提供了无与伦比的建模能力和优化空间,让我们能够为特定问题选择最合适的解决方案,真正做到“博采众长”。
044:C++ 范围



概述
在本节课中,我们将学习 C++ 中的范围(Ranges)。我们将从最基础的循环开始,逐步理解迭代器(Iterators)的概念,然后学习标准模板库(STL)算法,最终掌握 C++20 引入的范围库。通过本教程,你将学会如何用更简洁、更安全、更可组合的方式来处理和转换数据。
从循环到迭代器
循环:算法的基础
算法本质上就是循环。在程序中寻找循环是理解计算发生位置的好方法。作为程序员,我们一半的工作是转换数据,另一半是存储数据。因此,我们需要好的抽象工具来完成这些任务。
一个基本的 for 循环结构如下:
for (初始化; 条件; 表达式) {
// 执行操作
}
它的作用是定义计算的开始和结束,并对数据执行操作,例如打印或修改值。
我们可以用 for 循环遍历一个字符数组:
char message[] = "Hello, everyone, welcome!";
for (int i = 0; i < sizeof(message); ++i) {
std::cout << message[i];
}
使用标准库容器(如 std::array 或 std::string)可以让代码更清晰、更安全:
std::string message = "Hello, everyone, welcome!";
for (size_t i = 0; i < message.size(); ++i) {
std::cout << message[i];
}
使用指针进行迭代
除了使用索引,我们还可以使用指针来遍历数据。这种方法更接近底层的内存操作。
std::array<char, 27> message = {'H', 'e', 'l', 'l', 'o', ...};
char* ptr = message.data();
char* end = message.data() + message.size();
for (; ptr != end; ++ptr) {
std::cout << *ptr;
}
这里,ptr 指针指向数据的起始地址,通过指针算术运算(++ptr)移动到下一个元素,直到到达 end 指针所指向的末尾。
迭代器:指针的泛化
指针迭代的方式虽然有效,但不够通用。对于树或图等非连续数据结构,仅靠 ++ 操作无法移动到下一个元素。因此,C++ 引入了迭代器的概念。
迭代器是一个对象,它封装了遍历集合中元素的行为。它提供了 begin() 和 end() 方法来获取指向集合起始和“末尾后一位”的迭代器。
std::vector<int> vec = {1, 2, 3};
auto start = std::begin(vec); // 获取起始迭代器
auto finish = std::end(vec); // 获取结束迭代器
for (auto it = start; it != finish; ++it) {
std::cout << *it;
}
++it 操作的具体行为由迭代器类型决定。对于 std::vector,它可能只是增加索引;对于链表或树,它可能遵循特定的遍历算法(如广度优先搜索)。
使用自由函数 std::begin() 和 std::end() 比成员函数 .begin() 和 .end() 更通用,因为它们适用于所有提供了相应迭代器的容器。
范围:迭代器对
一个范围就是一对迭代器:一个指向开始,一个指向结束。这个“结束”迭代器通常指向最后一个元素之后的位置,这使得处理空集合变得简单。
// [begin, end) 定义了一个范围
auto range_begin = std::begin(container);
auto range_end = std::end(container);
这个范围精确地定义了计算发生的区间。
基于范围的 for 循环
由于“获取起止迭代器并循环”的模式非常常见,C++11 引入了基于范围的 for 循环语法糖。
std::map<std::string, int> my_map = {{"apple", 1}, {"banana", 2}};
for (const auto& key_value_pair : my_map) {
std::cout << key_value_pair.first << ": " << key_value_pair.second;
}
编译器会将上述代码展开为使用迭代器的传统 for 循环。我们还可以使用结构化绑定来让代码更清晰:
for (const auto& [key, value] : my_map) {
std::cout << key << ": " << value;
}
标准模板库算法
算法与迭代器的结合
标准模板库提供了大量泛型算法,它们接受迭代器对作为参数,对指定范围内的元素执行操作。这使我们能够用函数调用替代手写的循环。
要使用这些算法,需要包含 <algorithm> 头文件(部分数学相关算法在 <numeric> 中)。
以下是一些常用算法的示例:
排序 (std::sort):
std::vector<int> numbers = {5, 3, 1, 4, 2};
std::sort(numbers.begin(), numbers.end());
分区 (std::partition):
std::vector<int> vec = {1, 9, 2, 8, 3, 7};
auto it = std::partition(vec.begin(), vec.end(), [](int i){ return i < 5; });
// 现在 vec 被分为小于5和大于等于5的两部分
复制 (std::copy):
std::vector<int> source = {1, 2, 3};
std::vector<int> destination;
std::copy(source.begin(), source.end(), std::back_inserter(destination));
// `std::back_inserter` 是一个输出迭代器适配器,它会调用 `destination.push_back()`
变换 (std::transform):
std::string str = "hello";
std::string upper;
std::transform(str.begin(), str.end(), std::back_inserter(upper), ::toupper);
// upper 变为 "HELLO"
迭代器的缺陷
尽管迭代器功能强大,但也存在一些陷阱:
- 迭代器失效:当容器(如
std::vector)发生内存重分配(例如push_back)时,指向旧内存的迭代器会失效,继续使用可能导致未定义行为。 - 迭代器不匹配:错误地将一个容器的
begin和另一个容器的end配对。 - 接口不一致:少数算法(如
std::rotate)的参数顺序不是简单的(begin, end),而是(first, middle, last),需要额外注意。 - 空间不足:使用输出迭代器向固定大小数组写入时,如果数据量超过数组容量,会导致错误。
C++20 范围库
什么是范围?
范围库(定义在 <ranges> 头文件中)是对迭代器概念的改进和扩展。一个范围仍然表示一个元素序列,但提供了更安全、更易用的接口。
核心改进在于,许多范围算法现在可以直接接受整个容器作为参数,而无需手动传递 begin() 和 end() 迭代器。
范围算法
范围算法是 STL 算法的“约束”版本,位于 std::ranges 命名空间中。它们通常比传统算法少一个参数。
比较传统算法和范围算法:
// 传统方式
std::vector<int> v1 = {3, 1, 4, 1, 5};
std::sort(v1.begin(), v1.end());
// 范围方式 (C++20)
std::vector<int> v2 = {3, 1, 4, 1, 5};
std::ranges::sort(v2); // 更简洁,不易出错
范围算法直接对容器 v2 进行操作,意图更清晰,减少了因迭代器不匹配而出错的机会。
查找示例 (std::ranges::find_if):
std::vector<int> vec = {5, 3, 8, 1, 9};
auto it = std::ranges::find_if(vec, [](int i){ return i == 1; });
if (it != std::ranges::end(vec)) {
std::cout << "Found 1!";
}
注意,范围算法仍然返回迭代器,我们可以用 std::ranges::end(container) 来获取结束迭代器进行比较。
视图与适配器:惰性求值
范围库引入了两个重要概念:范围适配器和视图。
- 范围适配器:将一个范围(如容器)转换为一个视图。
- 视图:是对一个范围的惰性(Lazy)视图。它并不立即复制或处理所有数据,而是提供一个“窗口”,在需要时才逐个元素地进行计算。
这使得我们可以组合多个操作,并且只在必要时才执行计算,对于处理大型或无限数据流非常高效。
使用管道运算符 | 可以优雅地组合视图操作:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 创建一个视图:筛选出大于5的数
auto greater_than_five = numbers | std::views::filter([](int n){ return n > 5; });
// 惰性求值:只有在循环迭代时才会应用 filter
for (int n : greater_than_five) {
std::cout << n << ' '; // 输出: 6 7 8 9 10
}
}
在这个例子中,greater_than_five 是一个视图。for 循环每次迭代时,它才会从 numbers 中取出一个元素,检查是否大于5,如果是则 yield(产生)该元素。它并没有预先创建一个新的 {6,7,8,9,10} 向量。
组合操作与强制求值
视图可以轻松组合:
std::vector<std::string> names = {"Mike", "Bob", "Miguel", "Mariissa", "Mary"};
auto result = names
| std::views::filter([](const std::string& s){ return s.starts_with('M'); })
| std::views::filter([](const std::string& s){ return s.size() > 4; });
for (const auto& name : result) {
std::cout << name << '\n'; // 输出: Miguel Mariissa
}
由于是惰性求值,当检查 "Bob" 时,第一个 filter 发现它不以 ‘M’ 开头,就不会再传递给第二个 filter,提高了效率。
如果我们需要将视图的结果保存到一个实际的容器中(即强制求值),可以使用 std::ranges::to (C++23) 或类似方式:
// C++23 方式
std::vector<std::string> long_m_names = names
| std::views::filter([](auto& s){ return s.starts_with('M'); })
| std::views::filter([](auto& s){ return s.size() > 3; })
| std::ranges::to<std::vector>();
这将触发计算,并将结果收集到一个新的 std::vector<std::string> 中。
总结
本节课我们一起学习了 C++ 范围的完整演进路径:
- 基础:从传统的
for循环开始,理解数据遍历的本质。 - 抽象:引入迭代器,将遍历行为泛化,使其适用于各种数据结构,并学习了基于范围的 for 循环。
- 算法:利用 STL 算法配合迭代器,用声明式的函数调用替代命令式的循环,提高代码的清晰度和可复用性。
- 现代范围:掌握 C++20 范围库,直接对容器进行操作,减少错误。重点理解了视图和惰性求值的概念,以及如何使用管道运算符
|组合多个数据转换操作,写出更简洁、更高效、更易读的代码。




建议:在可能的情况下,优先使用范围算法和视图来替代传统的循环和算法。它们能带来更好的代码可读性、可维护性,并通过概念约束提供更清晰的编译错误信息。同时,惰性求值为性能优化提供了新的思路。随着 C++23/26 等新标准的发布,范围库的功能还将继续增强。
045:使用概念和定制点构建向量数学库




概述
在本教程中,我们将学习如何利用 C++20 的概念(Concepts)和定制点对象(Customization Point Objects, CPOs)来构建一个高性能、零开销且高度抽象的向量数学库。我们将探讨传统面向对象方法在科学计算中的局限性,并展示如何通过现代 C++ 特性实现更灵活、更高效的泛型编程。
章节 1:问题背景与动机
科学计算,特别是向量计算,在人工智能、机器学习、物理模拟、图形图像处理等领域无处不在。长期以来,我们的目标一直是编写能够复用的泛型代码。然而,在追求高性能和可移植性之间,以及追求抽象和具体实现之间,总是存在权衡。
传统方法,如面向对象编程(OOP),通过定义抽象基类和虚函数来提供接口。虽然这在某些情况下有效,但它可能导致代码臃肿、运行时开销,并且在需要高度优化(如在 GPU 上运行)或处理非标准类型时显得笨拙。
我们的模型问题是:编写一个执行数学运算的库,该库能够使用任意类型,但又不想让库依赖于这些类型的头文件。我们需要找到一种方法来实现这一点。
章节 2:核心算法与基本操作
让我们以共轭梯度法(Conjugate Gradient)作为核心算法示例。该算法需要以下基本数学操作:
- 向量加法
- 向量缩放(乘以标量)
- 向量内积
- 检查向量维度
- 获取用于中间计算的临时向量空间
上一节我们介绍了算法需求,本节中我们来看看如何将这些需求转化为代码接口。
以下是五个基本操作:
- add_in_place:将一个向量加到另一个向量上。
- scale_in_place:用一个标量缩放一个向量。
- inner_product:计算两个向量的内积。
- dimension:获取向量的维度。
- clone:获取一个可用于中间计算的向量副本或新空间。
在面向对象方法中,我们可能会定义一个抽象基类 VectorBase,包含这些操作的纯虚函数。但 clone 方法会带来问题:它通常返回一个 std::unique_ptr<VectorBase>,这强制了堆分配,可能不适用于栈分配或内存池场景。
章节 3:从 OOP 到概念(Concepts)
面向对象方法在需要频繁询问“这是什么类型”并得到不同答案时是合适的。但在科学计算中,容器的类型通常是固定的,我们更需要的是基于操作的泛型,而不是基于类型的多态。
C++20 的概念(Concepts)为此提供了完美的解决方案。我们可以为每个基本操作定义一个概念,而不是一个基类。
例如,add_in_place 的概念可以定义为:
template <typename T>
concept add_in_place_c = requires(T& a, const T& b) {
{ add_in_place(a, b) } -> std::same_as<void>;
};
这表示对于类型 T,必须存在一个接受 T& 和 const T& 的 add_in_place 函数,且返回 void。
对于棘手的 clone 操作,我们可以引入一个辅助工具 deref_if_needed,它能够智能地处理指针和对象,使算法代码对两者都统一。
template <typename T>
concept clone_c = requires(const T& x) {
{ deref_if_needed(clone(x)) } -> std::convertible_to<T>;
};
inner_product 和 scale_in_place 的概念则关联了向量元素类型(标量类型):
template <typename T>
concept inner_product_c = requires(const T& a, const T& b) {
{ inner_product(a, b) } -> real_scalar_c; // 返回类型满足 real_scalar_c 概念
};
template <typename T, typename Scalar>
concept scale_in_place_c = requires(T& v, const Scalar& alpha) {
{ scale_in_place(v, alpha) } -> std::same_as<void>;
};
最后,我们将这些概念组合成 real_vector_c 概念,它完整描述了一个“实数向量”类型所需满足的接口。
template <typename T>
concept real_vector_c = add_in_place_c<T> &&
clone_c<T> &&
dimension_c<T> &&
inner_product_c<T> &&
requires(T& v, const element_type_t<T>& alpha) {
{ scale_in_place(v, alpha) } -> std::same_as<void>;
};
现在,共轭梯度算法的签名可以清晰地用概念来约束:
template <real_vector_c Vector, self_map_c<Vector> Matrix>
Vector conjugate_gradient(const Matrix& A, const Vector& b, const Vector& x0, ...);
章节 4:定制点对象(CPOs)与 ADL 的问题
虽然我们可以使用自由函数重载(依赖于实参依赖查找,ADL)来实现这些概念,但 ADL 可能导致命名空间污染、难以诊断的错误以及意外的重载决议。
定制点对象(CPO)是一种看起来和用起来都像函数,但实际上是函数对象的工具。关键优势在于,CPO 不会被 ADL 发现,从而避免了相关问题。
一个传统的 CPO(不使用 tag_invoke)示例如下:
struct add_inplace_fn {
template <typename T>
void operator()(T& a, const T& b) const {
// 依赖 ADL 找到具体的 add_in_place 实现
add_in_place(a, b);
}
};
inline constexpr add_inplace_fn add_in_place{};
用户在自己的命名空间中定义 add_in_place 自由函数,当通过 CPO ::add_in_place 调用时,ADL 会找到用户定义的函数。但这种方法仍有命名冲突和错误信息不友好的问题。
章节 5:Tag Invoke 模式
tag_invoke 是一种更先进的 CPO 模式,它将所有定制派发集中到一个单一的 tag_invoke 函数上,该函数以一个代表 CPO 类型的标签作为第一个参数。
CPO 的定义变为:
inline constexpr struct add_inplace_fn {
template <typename T>
void operator()(T& a, const T& b) const {
return tag_invoke(*this, a, b);
}
} add_in_place{};
用户的定制化则通过在其类型所在的命名空间中定义 tag_invoke 重载来实现:
namespace mylib {
struct Vector { ... };
void tag_invoke(std::tag_t<add_inplace_fn>, Vector& a, const Vector& b) {
// 具体的实现
}
}
tag_invoke 模式统一了定制点,提供了更清晰的命名空间和潜在更好的编译错误信息。然而,其缺点是需要编写大量的样板代码。
章节 6:Tin Cup 工具介绍
为了减少 tag_invoke 的样板代码,作者开发了 Tin Cup(Tag Invoke Customization Point)工具。它是一个使用 Python(Jinja2 模板)的代码生成器,可以自动生成所有必需的 CPO 样板代码、概念检查以及增强的诊断信息。
使用 Tin Cup 非常简单。例如,在命令行中生成 add_in_place CPO:
python -m tincup add_in_place ‘$T&’ ‘const $T&’
其中 $T 表示泛型类型。Tin Cup 也提供了 Vim、VS Code 等编辑器的插件,可以更方便地生成代码。
Tin Cup 生成的代码包括:
- CPO 函数对象(使用
tag_invoke)。 - 对应的概念(如
add_in_place_c)。 - 类型别名。
- 增强的诊断:当调用错误时,编译器会给出更友好的提示,例如“您可能忘记解引用指针”或“参数顺序错了”。
章节 7:Real Vector Framework 实践
基于 Tin Cup,作者创建了 Real Vector Framework。它生成了向量计算所需的所有核心 CPO(add_in_place, clone, dimension, inner_product, scale_in_place)以及一些高级操作(如元素级一元、二元函数应用,ReLU,Softmax)。
该框架还提供了内存管理工具(如托管内存区域),可以显著减少临时向量的分配/释放开销。框架内实现了一些算法,如共轭梯度法、L-BFGS 优化器等。
使用此框架,共轭梯度法的实现变得非常清晰,几乎与数学公式一一对应:
template <real_vector_c Vector, self_map_c<Vector> Matrix>
Vector conjugate_gradient(const Matrix& A, const Vector& b, const Vector& x0, ...) {
auto x = x0;
auto r = b; add_in_place(r, -1, A(x)); // r = b - A*x
auto p = clone(r); // p = r
...
while (...) {
auto Ap = clone(p); A(Ap, p); // Ap = A*p
auto alpha = inner_product(r, r) / inner_product(p, Ap);
scale_in_place(p, alpha); // p *= alpha
add_in_place(x, p); // x += p
scale_in_place(Ap, alpha); // Ap *= alpha
add_in_place(r, -1, Ap); // r -= Ap
...
}
return x;
}
代码中 clone 后紧跟 deref_if_needed 的模式确保了代码对指针和非指针类型的统一处理。


章节 8:总结与展望

本节课中我们一起学习了如何利用 C++20 的概念和定制点对象来构建零开销抽象的向量数学库。
核心要点总结:
- 概念优于继承:对于科学计算中的泛型算法,使用概念来约束接口比使用面向对象继承更灵活、更高效。
- 定制点对象:使用 CPO(特别是
tag_invoke模式)可以避免 ADL 的问题,并提供清晰的定制接口。 - 工具辅助:像 Tin Cup 这样的工具可以自动生成
tag_invoke所需的繁琐样板代码和增强的诊断信息,大大提高开发效率。 - 实践框架:Real Vector Framework 展示了如何将这些理念应用于实际,构建出支持多种后端(如
std::vector、std::span、自定义类型)的高性能数学库。
未来的工作包括进一步完善 Real Vector Framework,添加更多算法,并探索将其与 C++26 可能引入的线性代数库、反射等功能相结合的可能性。最终目标是推动类似 tag_invoke 的定制机制成为更原生、更易用的语言特性。


通过本教程,希望你能够掌握使用现代 C++ 构建高性能泛型库的核心方法,并将其应用到自己的项目中。
046:使用概念和定制点构建向量数学库




概述
在本教程中,我们将学习如何利用 C++ 的概念(Concepts)和定制点对象(Customization Point Objects, CPOs)来构建一个高性能、零开销且高度抽象的向量数学库。我们将从传统的面向对象方法遇到的挑战开始,逐步过渡到使用现代 C++ 特性(特别是 C++20 的概念和定制点)来实现更灵活、更高效的通用算法。
章节 1:问题背景与动机
科学计算,特别是向量计算,在人工智能、机器学习、物理模拟、图形图像处理等领域无处不在。长期以来,我们的目标一直是编写能够处理任意向量类型的通用代码,以实现代码复用。然而,尽管这个承诺已经存在了几十年,但现实往往不尽如人意。
传统的面向对象编程(OOP)方法,通过定义抽象基类和虚函数来提供接口,在某些场景下是有效的。例如,它可以封装数据,避免在应用代码和优化器之间复制数据。但是,当我们需要更灵活的操作(如在 GPU 上运行)或避免虚函数调用的开销时,OOP 方法就会变得笨拙和低效。
我们的模型问题是:如何编写一个数学运算库,使其能够使用任意类型,同时又不需要依赖该类型的头文件?
章节 2:传统方法的局限性
上一节我们介绍了构建通用向量库的目标。本节中,我们来看看几种传统实现方法及其局限性。
以下是几种常见的传统方法:
- CRTP(奇异递归模板模式):可以绕过虚函数调用,但缺乏真正的灵活性,无法方便地用于概念(Concepts)。
- 表达式模板:被 Eigen 或 Armadillo 等库使用,可以实现惰性求值和优化。但编写自己的表达式模板代码通常会导致极其冗长(超过 10 万行)的编译器错误,难以调试。
- 特征类(Traits)、函数模板、策略模式和类型擦除:这些方法各有优劣,但通常无法实现“为所有满足算法所需操作的类型定义算法”这一理想目标。
以共轭梯度法为例,其数学算法需要以下基本操作:
- 向量加法
- 向量缩放(乘以标量)
- 向量内积
- 获取向量维度
- 获取临时向量(克隆)
如果使用面向对象方法,我们需要定义一个抽象基类 Vector,包含对应的纯虚函数。这会立即带来问题:
clone()方法通常返回一个std::unique_ptr<Vector>,这限制了栈内存分配或内存池的使用。- 为了算法方便,我们可能想添加像
axby(线性组合)这样的复合操作到基类中,但这会导致基类不断膨胀,成为一个“上帝类”,难以维护。
我们真正需要的是:一种方式,能够声明“这个算法适用于所有实现了算法所需操作的类型”。
章节 3:使用概念(Concepts)定义接口
上一节我们看到了面向对象方法的局限。本节中,我们将使用 C++20 的概念来定义向量接口,这是一种编译期契约。
我们可以为每个基本操作定义一个概念。核心概念是 RealVector。
向量加法概念
template <typename T>
concept AddInPlace_c = requires(T& a, const T& b) {
{ add_in_place(a, b) } -> std::same_as<void>;
};
这个概念要求:给定类型 T 的可变引用和常量引用,必须存在一个名为 add_in_place 的函数,它接受这两个参数并返回 void。
克隆概念
克隆的挑战在于:我们不知道它是否动态分配内存。解决方案是使用一个 deref_if_needed 包装器。
template <typename T>
concept Cloneable_c = requires(const T& x) {
{ clone(x) } -> std::same_as<decltype(deref_if_needed(clone(x)))>;
requires std::convertible_to<decltype(deref_if_needed(clone(x))), T>;
};
这个概念确保:对对象 x 调用 clone 后,再应用 deref_if_needed,得到的结果可以转换回类型 T。这样,无论 clone 返回的是指针还是引用,算法代码都能统一处理。
维度概念
template <typename T>
concept Dimension_c = requires(const T& x) {
{ dimension(x) } -> std::integral;
};
using dimension_type = decltype(dimension(std::declval<T>()));
内积概念
template <typename T>
concept InnerProduct_c = requires(const T& a, const T& b) {
{ inner_product(a, b) } -> RealScalar_c;
};
using element_type = decltype(inner_product(std::declval<T>(), std::declval<T>()));
缩放概念
template <typename T>
concept ScaleInPlace_c = requires(T& a, const element_type<T>& alpha) {
{ scale_in_place(a, alpha) } -> std::same_as<void>;
};
实向量概念
最后,我们将所有概念组合起来,定义 RealVector 概念:
template <typename T>
concept RealVector_c = AddInPlace_c<T> &&
Cloneable_c<T> &&
Dimension_c<T> &&
InnerProduct_c<T> &&
ScaleInPlace_c<T> &&
requires (T& v, const element_type<T>& a) {
// 确保内积返回的类型可用于缩放
{ scale_in_place(v, a) };
};
现在,我们可以使用 RealVector_c 作为约束来编写通用算法,如共轭梯度法。
章节 4:定制点对象(CPOs)与 ADL 的问题
上一节我们用概念定义了接口。本节中,我们来看看如何实现这些接口函数,并解释为什么简单的自由函数重载可能不是最佳选择,从而引出定制点对象。
实现这些接口函数最直接的想法是使用自由函数和参数依赖查找(ADL)。ADL 允许编译器在函数调用未限定时,在实参类型所属的命名空间中查找函数。
ADL 示例
namespace mylib {
struct Vector { ... };
void add_in_place(Vector&, const Vector&); // ADL 会找到这个函数
}
mylib::Vector a, b;
add_in_place(a, b); // 调用 mylib::add_in_place
然而,ADL 有其问题:
- 名称冲突:
add_in_place这类名称不唯一,在大型项目中容易冲突。 - 查找规则复杂:ADL 的查找规则(涉及关联命名空间)可能令人困惑,导致意外地找到或找不到函数。
- 错误信息模糊:如果重载决议失败,编译器错误可能不会清晰地指出问题所在。
更好的解决方案是使用定制点对象(CPO)。CPO 是一个行为像函数但不是函数的对象(通常是一个函数对象)。关键优势在于:CPO 不会参与 ADL。
传统 CPO 示例
struct add_in_place_fn {
template <typename T>
void operator()(T& a, const T& b) const {
// 使用 ADL 调用真正的实现
add_in_place(a, b);
}
};
inline constexpr add_in_place_fn add_in_place{};
这里,全局的 add_in_place 是一个常量对象。当调用 add_in_place(a, b) 时,它调用其 operator(),然后在 operator() 内部使用 ADL 来查找具体的 add_in_place 函数。这隔离了命名空间,但依然依赖 ADL 和可能的重载冲突。
章节 5:Tag Invoke 模式与 TinCuP 工具
上一节介绍了基本的 CPO。本节中,我们将探讨一种更强大、更规范的 CPO 实现模式——Tag Invoke,并介绍一个能自动生成相关代码的工具。
Tag Invoke 模式将所有定制点的分发集中到单个函数 tag_invoke 上。CPO 对象本身只携带一个“标签”,用于选择正确的 tag_invoke 重载。
Tag Invoke CPO 示例
// 1. 定义 CPO 标签类型
struct add_in_place_cpo {
template <typename T>
void operator()(T& a, const T& b) const {
return tag_invoke(*this, a, b);
}
};
inline constexpr add_in_place_cpo add_in_place{};
// 2. 用户在自定义类型的命名空间中提供 tag_invoke 重载
namespace mylib {
struct Vector { ... };
void tag_invoke(add_in_place_cpo, Vector& a, const Vector& b) {
// ... 具体实现
}
}
这种模式的优点:
- 命名空间清洁:只有一个
tag_invoke扩展点。 - 更好的诊断:更容易提供清晰的编译错误。
- 零开销:调用是直接静态分派的。
然而,为每个 CPO 手动编写 Tag Invoke 样板代码非常繁琐。为此,作者创建了工具 TinCuP。
TinCuP 是一个使用 Python 和 Jinja 模板的代码生成器。它可以自动生成所有 Tag Invoke 所需的样板代码。
使用 TinCuP
# 通过命令行生成 add_in_place 的 CPO 代码
tin cup -j add_in_place ‘$& a, const $& b‘
其中 $ 表示通用类型占位符。TinCuP 会生成包含 CPO 类、概念检查、类型别名以及增强诊断功能的完整代码。例如,如果你错误地传递了一个指针,编译器错误会提示“你可能忘记解引用参数了”。
TinCuP 还支持高级功能,如为 CPO 指定额外参数(用于编译时或运行时策略选择)。
章节 6:Real Vector 框架与算法实现
上一节我们有了强大的工具来定义 CPO。本节中,我们将看到如何利用这些构建一个完整的“实向量框架”并实现通用算法。
基于 TinCuP,作者创建了 Real Vector Framework。它生成了五个核心 CPO:add_in_place, clone, dimension, inner_product, scale_in_place。此外,还提供了更多高级 CPO:
unary:对向量逐元素应用一元函数。variadic:对多个向量逐元素应用多元函数。relu,softmax:AI 中常用的激活函数。- 内存管理工具:提供托管内存区域(arena),避免频繁分配/释放,可带来成百上千倍的性能提升。
该框架还实现了一些标准算法:
- 共轭梯度法
- 有限内存 BFGS 优化
- 线搜索
- 投影梯度下降法
- 截断共轭梯度信赖域法
通用算法的威力
使用概念和 CPO 的最大优势是真正的通用性。例如,任何满足 std::ranges::range 概念的类型(如 std::vector, std::array)都可以自动获得所有操作的默认实现,而无需用户编写任何代码。


共轭梯度法的实现对比
使用 RealVector_c 概念和 CPO 后,共轭梯度法的实现代码非常清晰,几乎与数学公式一一对应:
template <RealVector_c Vector, SelfMap_c Mapping>
void conjugate_gradient(Vector& x, const Mapping& A, const Vector& b, ...) {
auto r = deref_if_needed(clone(b)); // 克隆 b 并确保得到引用
// ... 算法逻辑,直接使用 scale_in_place, add_in_place, inner_product 等 CPO
// 代码看起来就像数学:scale_in_place(p, beta); add_in_place(p, r);
}
代码中唯一的“噪音”是 deref_if_needed(clone(...)) 这个模式,它确保了临时向量资源的正确管理,但让算法逻辑保持了数学上的简洁。

章节 7:总结与未来展望
本节课中,我们一起学习了如何利用现代 C++ 的特性构建零开销抽象的向量数学库。
核心要点总结:
- 从 OOP 到概念:使用 C++20 的概念(
concept)来定义编译期接口契约,替代运行时的虚函数接口。 - 从 ADL 到 CPO:使用定制点对象来提供可定制的函数接口,避免了自由函数重载和 ADL 带来的命名冲突和复杂性问题。
- Tag Invoke 模式:一种规范化的 CPO 实现模式,集中扩展点,改善错误诊断。
- 工具辅助:利用像 TinCuP 这样的代码生成器,可以自动生成繁琐的 Tag Invoke 样板代码,并集成增强的编译期诊断。
- 实现通用算法:结合概念和 CPO,可以编写出真正通用的、高性能的算法,代码既清晰(贴近数学)又高效(零开销抽象)。
未来工作方向:
- 扩展 Real Vector Framework,加入更多算法。
- 将现有的 Rapid 优化库从继承架构重构为基于概念和 CPO 的架构。
- 探索与 C++26 的
std::linalg(线性代数)和反射特性的集成。 - 尝试开发一个 Clang 编译器扩展,将 CPO/Tag Invoke 模式作为原生语言特性进行概念验证,并推动其进入未来的 C++ 标准(如 C++29)。


通过这种方法,我们能够在保持代码抽象性和可复用性的同时,不牺牲任何运行时性能,真正实现“零开销抽象”的承诺。
047:如何驯服参数包、std::tuple 和狡猾的 std::integer_sequence



概述
在本节课中,我们将学习如何高效地处理 C++ 中的参数包(parameter packs)和 std::tuple,并探索如何避免直接使用复杂的 std::integer_sequence。我们将介绍一系列简洁、实用的元编程技巧,这些技巧易于理解,并能显著提升代码的简洁性和性能。这些技巧对于编写科学计算(如张量运算)和 AI 相关代码尤其有用。
从传统方法到更简洁的方案
上一节我们概述了本课程的目标。本节中,我们来看看处理参数包和元组的传统方法及其局限性。
通常,为了遍历一个 std::tuple,我们需要使用 std::integer_sequence 和两个函数:一个主函数和一个实现细节函数。这种方法代码冗长且不直观。
namespace detail {
template <typename Tuple, typename F, std::size_t... Is>
void for_each_impl(Tuple&& t, F&& f, std::index_sequence<Is...>) {
(f(std::get<Is>(std::forward<Tuple>(t))), ...);
}
}
template <typename Tuple, typename F>
void for_each(Tuple&& t, F&& f) {
detail::for_each_impl(
std::forward<Tuple>(t),
std::forward<F>(f),
std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<Tuple>>>{}
);
}
然而,从 C++17 开始,我们可以利用折叠表达式(fold expressions)和一个小技巧来简化。这个技巧使用一个默认参数来生成索引序列。
template <typename Tuple, typename F, std::size_t... Is>
void for_each(Tuple&& t, F&& f, std::index_sequence<Is...> = {}) {
if constexpr (sizeof...(Is) != std::tuple_size_v<std::remove_reference_t<Tuple>>) {
// 递归调用自身,生成正确大小的索引序列
for_each(std::forward<Tuple>(t), std::forward<F>(f),
std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<Tuple>>>{});
} else {
// 使用折叠表达式展开
(f(std::get<Is>(std::forward<Tuple>(t))), ...);
}
}
这种方法避免了冗长的辅助函数,但仍有改进空间。我们的目标是找到更优雅、更强大的解决方案。
利用 C++20 的 Lambda 与模板进行展开
上一节我们看到了传统方法的繁琐。本节中,我们来看看如何利用 C++20 的特性进行更优雅的编译时循环展开。
核心思想是创建一个 unroll 函数,它接受一个次数 N 和一个 Lambda。这个 Lambda 会收到一个编译时常量索引。为了实现这一点,我们使用 std::integral_constant。
template <std::size_t N, typename F>
void unroll(F f) {
[&f] <std::size_t... Is> (std::index_sequence<Is...>) {
(f(std::integral_constant<std::size_t, Is>{}), ...);
}(std::make_index_sequence<N>{});
}
这里的关键在于 Lambda 的参数 auto ic。当传入 std::integral_constant<std::size_t, 0>{} 等对象时,ic 在编译时和运行时都可以隐式转换为 std::size_t 值,这为我们提供了极大的灵活性。
实现可提前终止的迭代
上一节我们实现了基础的展开循环。本节中,我们为其增加一个实用功能:根据 Lambda 的返回值决定是否提前终止迭代。
我们希望 Lambda 可以返回 bool 类型。如果返回 false,则停止后续迭代;如果返回 void 或 true,则继续。这可以通过编译时内省(introspection)来实现。
template <std::size_t N, typename F>
void unroll(F f) {
if constexpr (std::is_same_v<std::invoke_result_t<F, std::integral_constant<std::size_t, 0>>, void>) {
// void 返回类型:使用逗号运算符展开所有
[&f] <std::size_t... Is> (std::index_sequence<Is...>) {
(f(std::integral_constant<std::size_t, Is>{}), ...);
}(std::make_index_sequence<N>{});
} else {
// bool 返回类型:使用逻辑与运算符,支持短路求值
[&f] <std::size_t... Is> (std::index_sequence<Is...>) {
(f(std::integral_constant<std::size_t, Is>{}) && ...);
}(std::make_index_sequence<N>{});
}
}
这样,我们就拥有了一个功能强大的通用展开工具。
创建和过滤元组
上一节我们专注于迭代。本节中,我们来看看如何动态地创建和过滤 std::tuple。
直接从一个参数包过滤并返回另一个参数包在 C++ 中是不可能的。一个巧妙的解决方案是引入一个“空位标记”类型,在构造元组时过滤掉它。
我们首先定义一个标记类型 null_tuple_field。
struct null_tuple_field_t {};
inline constexpr null_tuple_field_t null_tuple_field;
然后,我们可以创建一个 make_tuple_filtering 函数,它会在构造过程中忽略所有 null_tuple_field_t 类型的参数。
template <typename... Ts>
auto make_tuple_filtering(Ts&&... args) {
return [&args...] <std::size_t... Is> (std::index_sequence<Is...>) {
// 过滤逻辑:如果参数是 null_tuple_field_t 类型,则忽略
return std::make_tuple(
[](auto&& x) -> decltype(auto) {
using ArgType = decltype(x);
if constexpr (std::is_convertible_v<ArgType, const null_tuple_field_t&>) {
// 返回一个占位符,但实际会被忽略?需要更精细的处理。
// 更佳实践:在调用 std::make_tuple 前过滤序列。
// 此处为概念演示。
} else {
return std::forward<decltype(x)>(x);
}
}(std::get<Is>(std::forward_as_tuple(std::forward<Ts>(args)...))) ...
);
}(std::index_sequence_for<Ts...>{});
}
实际上,更简洁的方式是在展开时直接条件性地构造元组元素。null_tuple_field_t 模式体现了“空对象模式”(Null Object Pattern),在 C++ 中,类似的概念还有 std::monostate、std::nullopt_t 等。
灵活的元组遍历:Each In Tuple
上一节我们处理了元组的创建。本节中,我们来实现一个更灵活的元组遍历函数 each_in_tuple,它能够智能地判断 Lambda 需要一个参数(仅元素)还是两个参数(索引和元素)。
这需要用到 C++20 的 requires 表达式来进行编译时检查。
template <typename Tuple, typename F>
void each_in_tuple(Tuple&& t, F f) {
[&t, &f] <std::size_t... Is> (std::index_sequence<Is...>) {
// 检查 f 是否能以 (索引, 元素) 的形式调用
if constexpr (requires { f(std::integral_constant<std::size_t, 0>{}, std::get<0>(t)); }) {
(f(std::integral_constant<std::size_t, Is>{}, std::get<Is>(t)), ...);
} else {
// 否则,只传递元素
(f(std::get<Is>(t)), ...);
}
}(std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<Tuple>>>{});
}
这个函数极大地简化了需要根据索引进行不同操作的元组处理代码。
部分循环展开的挑战与优化
上一节我们处理了完整的展开。本节中,我们面对一个更复杂的场景:部分循环展开(Partial Unrolling)。这在处理剩余元素(tail elements)时非常有用,例如在 GPU 编程或高性能计算中。
简单的部分展开可能会为每个剩余元素生成一个条件检查,如果剩余元素数量较多,会导致性能下降。一个更好的策略是使用二分查找(bisection)逻辑来减少条件分支。
初始的二分查找实现可能如下:
template <std::size_t N, typename F>
void unroll_leftovers(std::size_t count, F f) {
if constexpr (N >= 2) {
constexpr std::size_t Half = N / 2;
if (count > Half) {
unroll<Half>(f); // 展开上半部分
unroll_leftovers<Half>(count - Half, f);
} else {
unroll_leftovers<Half>(count, f);
}
} else if constexpr (N == 1) {
if (count > 0) f(std::integral_constant<std::size_t, 0>{});
}
}
然而,这种方法会导致编译器生成大量重复的基本块(basic blocks),因为 if-else 的两边代码都会被实例化,造成代码膨胀。
优化的关键在于重构代码结构,将公共部分提取出来,减少重复实例化。
template <std::size_t N, typename F>
void unroll_leftovers_opt(std::size_t count, F f) {
if constexpr (N >= 2) {
constexpr std::size_t Half = N / 2;
// 公共部分:处理可能存在的“完整块”
if (count > Half) {
unroll<Half>(f);
count -= Half;
}
// 递归处理剩余部分
unroll_leftovers_opt<Half>(count, f);
} else if constexpr (N == 1) {
if (count > 0) f(std::integral_constant<std::size_t, 0>{});
}
}
这种优化后的结构能显著减少生成的代码量,同时保持逻辑的正确性。

总结与关于 AI 编程助手的思考
本节课中,我们一起学习了如何驯服 C++ 中的参数包和 std::tuple。我们通过一系列微技巧(micro-ideas)构建了强大的工具:
- 利用折叠表达式和 Lambda 实现简洁的编译时展开。
- 通过返回类型内省,支持可提前终止的迭代。
- 引入“空位标记”类型来过滤元组。
- 使用
requires表达式实现灵活的元组遍历。 - 优化部分循环展开,以减少代码生成和提高性能。

这些技巧的核心在于封装复杂性。我们应该将 std::integer_sequence 等繁琐的细节隐藏在简洁的库函数之后,而不是在业务代码中直接与之搏斗。

最后,关于 AI 编程助手(如 Cursor、Copilot),当前的它们更像是“打了鸡血的实习生”,非常擅长执行指令、复用现有模式,但在创造新的、高效的编程范式方面能力有限。因此,扎实的编程基础知识和深刻的洞察力变得比以往更加重要。你需要知道“问什么”和“如何评估结果”。将这些微技巧教给你的 AI 助手,它们就能成为你得力的效率倍增器。记住,好的上下文提示(Context)是一种廉价的“训练”方式。
停止仅仅使用聊天机器人,开始使用智能体(Agents),并学会如何有效地引导它们。


教程内容整理自 Andrei Alexandrescu 在 CppCon 2025 的演讲《如何驯服包、std::tuple 和狡猾的 std::integer_sequence》。
048:API 结构与技术


在本节课中,我们将学习如何从 C++ 代码审查中提炼经验,构建更安全、更易读、更符合现代 C++ 范式的 API 和技术。我们将从嵌入式开发的常见模式入手,探讨如何通过提升抽象层次来改进代码。
概述
大家好。本次演讲内容较长,幻灯片很多。过程中请随时提问。演讲时长取决于互动情况,因此可能没有专门的问答时间。如果您对特定幻灯片有疑问,请随时提出。
幻灯片上的所有代码都是真实的,您可以自由使用。当然,代码在转换为幻灯片时可能存在错误,并且为了简洁,我移除了 [[nodiscard]] 等属性。如果您发现错误,请指正。
对于那些不认识我的人,我曾在游戏行业工作,90年代中期开始使用 C++,之后在金融行业工作,现在在英特尔与 Michael Case 和 Luke Valenti 以及一个优秀的团队共事。
本次演讲讲述的是我从审查团队代码、学习并找到简化他们工作的方法中所获得的故事。我始终追求编写优秀的代码,但我们必须承认,所有代码都有改进空间。作为人类,我们擅长复制模式。当我们作为领域专家工作时,常常意识不到代码的缺陷,因为它“能用”,我们习惯了它,看不到痛点。从自己的领域中抽身,以更高层次的视角看待问题是非常困难的。
很多时候,当我看到人们试图做什么时,我会识别模式、发现用例,并找到提升抽象层次的方法。人们的意图是正确的,但他们有时没有意识到有更简单的方法,或者这些方法可能还不存在,而我的工作就是创造它们。在某种程度上,我对嵌入式领域相对陌生反而有所帮助,因为我没有背负大量关于“如何做这件事”的历史知识。
我目前的工作环境是嵌入式、裸机、无操作系统的,非常底层,但它仍然是 C++20,仍然是现代 C++。我的团队大多数成员在五年前主要写 C 语言,他们被 Luke、Michael 和我投入到这个充满现代 C++ 习语和模板元编程的代码库中,并且进步非常快,这值得高度赞扬。
在固件开发中,硬件规范常常渗透到固件中,导致过度具体化的问题。我们需要退一步思考真正要解决的问题,并进行适当的抽象。固件也是软件,即使我们不常使用这个词。
您可能认为您知道在嵌入式系统中编写 C++ 意味着什么。但事实是,大多数在网络上找到的建议和代码并没有跟上 C++ 的发展步伐,特别是 C++20 及以后。我甚至不是在讨论禁止异常或运行时分配这些仍然合理的选择。这里的差距在于,例如,您不想在运行时使用 std::string,但在编译时使用它完全没问题。像 libfmt 这样的库,其接口涉及字符串和 <format> 头文件,可以进行编译时格式化,永远不会触及运行时,非常适合嵌入式使用。但如果您的嵌入式工具链没有提供 <format> 头文件,您就无法编译,但您仍然可以使用它。因此,我们基本上在编译时使用所有的 STL。这就是我们的思维方式。
我们希望摆脱魔数、魔幻操作和必须考虑状态的情况。我们想要这种最佳的抽象感。在编程中,我们倾向于接受魔数是不好的,会引入命名常量。但我们对于魔幻操作的警惕性较低,仍然很乐意进行移位、加法、掩码等操作,因为规范就是这么说的。硬件规范渗透到了实际代码中,但通常有更好的方式来表达我们的意图。本次演讲就是这些更好方式的集合。
从比特开始
让我们从最基础的东西开始,字面意义上的“小”东西,因为我们经常需要处理比特。嵌入式代码充满了位掩码和位计算。顺便问一下,在座有多少人从事某种嵌入式开发?根据一些估计,嵌入式开发约占全球 C++ 工程师的三分之一。
计算字大小
这是一个非常常见的事情。uint32_t 是我们硬件中普遍使用的词汇类型,因此是我们各处使用的通用数据类型。您有一些字节,想要计算使用了多少个 32 位字。谁写过这样的代码?很多人,甚至最近还有人写。第一种写法很常见,幸运的话,人们会写第二种。这是一件简单的事情,触手可及,正好能计算您当前需要的东西。使用一些魔数有关系吗?这完全可读,对吧?也许是的。
但我希望有一个更通用的解决方案。以下是我目前的解决方案。它可能更可读,但在某种意义上可读性可能稍差。但它表明我们有 T 类型,有 n 个,我们想知道使用的尺寸。现在这在所有情况下都是正确的,并且是可靠的。如果您在编译时知道这些,还可以使用便利的别名,例如 size8_t 代表 size_t<uint8_t>。
如果您在编译时知道,还可以使用用户定义字面量,并且可以非常简洁。例如,可以说我有 48 个 8 位大小的东西,我想转换为 32 位大小的东西。我使用 _z 作为尺寸 UDL,因为这看起来很合理,并且可用。您可能会想,乍一看,这不是我们习惯的,但我认为它相当可读。它很简洁。我们希望简单的操作是简洁的。如果有人不同意,可以随时与我争论。
位操作
除了计算字大小,我们还进行大量的位操作。现在是 2025 年,我仍然看到大多数与硬件交互的示例代码在进行手动的位移位和掩码操作。谁的代码库里有这种东西?我们很多人都有。我也一样,但我在努力改进。我们认为它容易阅读是因为我们习惯了,但我要说,它通常并不比拥有良好抽象时更容易阅读,而且通常也不安全,除非我们加上 static_cast,我们稍后会看到。
这通常是我们创建位掩码的方式。我们有一个字段,范围是从第 7 位到第 3 位(包含)。我们只是创建最高有效位的掩码,创建最低有效位的掩码,然后用一个减去另一个,得到这些位之间的掩码。这还可以。如果您可以忽略整数提升。
但它会变得棘手,因为 x 的类型是什么?我们正在减去两个 char。是的,它是 int。显然,是的,显然,它被提升了,而且是 signed int。我们主要使用无符号类型,即使这些是 uint8_t,它仍然是 int。
当然,人们希望安全。所以,他们会做一些奇怪的事情,这是我们看到的另一件事。因为我们需要计算涉及最高位的某些东西,我们可能会看到这样的代码。每个人都知道这有什么问题。线索在幻灯片的标题中。是的,这是未定义行为,因为我们正在按类型的宽度(这里是 32)进行移位。当这些问题隐藏在数十万行的代码库中时,它们就不那么明显了。
如果我们想要一个位掩码,我们直接请求一个,怎么样?我们可以使用模板参数中的编译时值,或者使用常规参数中的运行时值,并指定我们想要返回的类型,比如 uint8_t。我认为这样更好,代码更易读。这在所有情况下都是安全的。实际上,安全性取决于底层是否有运行时检查。当然,我们有测试,并且使用 UBSan 运行测试,这也有帮助。
位打包与解包
除了创建掩码,很多时候我们进行位打包和解包,并做这样的事情。如果我再次要求每个人举手,我相信房间里一半的人会举手表示他们有类似的代码。我当然看到很多这样的代码。实际上,我看到的不是这样的代码,我看到的是这样的代码。这对任何人来说都熟悉吗?当然,亲爱的听众,您知道在这个例子中并非所有这些转换都是必要的,因为您在这里参加 CppCon,您是一位经验丰富、精通现代 C++ 的专家。但在现实世界中,代码的作者和审阅者只希望它能工作,他们不想每次都要考虑发生了哪些整数提升,知道我的移位操作。像我这样的人出现,打开警告和 lint 工具,他们希望它安静下来,这样他们就知道代码没问题。所以他们就直接加上转换。我不责怪他们,但我确实希望保持这些 lint 和警告开启,并希望让他们的生活更轻松。
所以当他们进行这样的位打包时,我希望他们就这样做位打包。同样,请求我们想要的类型,将高位和低位字节(或本例中的高位和低位字)放进去。我们也可以进行位解包,同样,请求我们想要那两个高位和低位值的类型。我们可以为这些编写漂亮的重载集,例如,您可以将四个字节打包成一个 32 位字,甚至将八个字节打包成一个 64 位字,然后您就有了这个抽象,它就能工作。当然,就像这样,它适用于您可能需要的所有大小。这只是一种更好的表达方式,我真的不知道还有什么其他方式能让它更清晰。
这是针对 8 位边界的打包和解包。但有时我们需要处理非 8 位边界。有时您可能有一个包含一些字段的寄存器,您可能只想将东西解构到这些字段中。我们也可以做到。我们称之为 bit_struct,它的工作方式与位解包完全相同,您指定边界在哪里。当然,如果您想要三个项目,就指定两个边界。然后您就能得到正确的结果。这里把它们放在 8 位边界上,这样您很容易看到发生了什么,而不必在脑子里计算比特位,但它们在任何地方都有效。
更进一步,我们最终会得到完整的字段定义和完整的消息定义,这些定义可以覆盖在数组上,并可以按需读取字段,看起来像这样,但这真的进入了兔子洞,这是另一个话题的内容。
关于字节序的问题?是的,字节序呢?您注意到了。当我们进行位打包时,这是大端序。因为我们说的是 1, 2, 3, 4, 5, 6, 7, 8。在某种意义上,是从高到低。在这张幻灯片上,是从低到高。这完全正确。这是一个当前的设计选择,可能会改变。这样设计是为了在某种意义上最不令人惊讶。您肯定希望解包顺序与打包顺序相同,并且希望打包顺序与您在代码中书写数字的顺序相同。这就是这次这样决定的原因。感谢您的问题。为录音说明一下,问题是关于字节序的。但在这种情况下,因为我们在模板参数中从 0 开始计数比特位,所以在这个意义上,您可能会说它是小端序。这些设计在一定程度上是为了最小化意外。它们总是可以改变的,如果我在代码审查中看到人们因此遇到问题。但目前,它们就是这样。再次感谢您的问题。
好的,我们已经看到了一些简单的东西。本次演讲的重点在于,这些简单的构造具有很大的实用性,它们可以取代那些人们认为易于阅读但实际只是因为历史习惯而显得容易的既定模式。
枚举位标志
这是另一个常见模式。谁这样做过?您想要枚举位标志,这是一件事。您定义一个具有 2 的幂次方值的枚举或枚举类,然后重载一些位运算符。也许您经历了一系列麻烦,可能使用了宏。您说服自己拥有了类型安全。您肯定对 ADL 形成了自己的看法。我认为也许我们一直做错了,有很多演讲和库帮助我们做错,甚至博客文章也是如此。
我的意思是,每个人都觉得这个表达式没问题吗?看起来没问题。好的,这个我也觉得没问题。这个呢?当然,没问题。如果丹在房间里,如果您是化学家,您应该觉得这个没问题。我所做的只是重新标记。硫是 16,氮是 7,钒是 23。您觉得这个没问题吗?同样的事情。我只是重新标记了。如果您是宝可梦训练师,这完全说得通。但不知何故,我们觉得这个没问题。甚至没有编号为 A 或 B 的宝可梦。X 甚至不是枚举的可命名成员,空间不是密集的。这是类型安全吗?我不知道。我认为我们一直混淆了标签和数字。
在底层,寄存器值是一个 uint32_t。我们真正想要的是命名这些比特位。但这与假装这些名称就是数值是分开的。那么,为什么我们不使用 std::bitset 呢?我认为 std::bitset 只是没有提供这种接口,这是唯一的原因。但如果我们想将寄存器视为一个位集,我看到有人在点头,您也这样做过吗?是的,这有点像,我不知道为什么人们不这样做,实际上有些人正在这样做,他们可能有同样的想法。
所以,让我们只使用我们想要的接口。我们想要命名比特位。所以让我们只在枚举中命名比特位。让我们基于该枚举类型创建一个位集。这不是标准的 std::bitset。我们会告诉它设置位 A 和 B。然后我们可以用 B 索引它,或者我们可以要求它只设置 C。我们可以使用我们习惯的位集操作。然后当我们从中提取数字时,我们可以直接说:给我一个数字。让我们有一个模板化于类型的 to 操作。我认为这是更好的类型安全。我们不需要在枚举中移位那些比特位,不需要预先计算什么。我们只需要一个约定,比如一个最大值。制作一个能做到这一点的位集真的不难,只是 std::bitset 没有开箱即用地做到。std::bitset 和许多东西的接口有点奇怪,因为它们代表了提案者拥有的用例。但我认为这省去了很多麻烦。
从比特到字节
我们已经处理了比特。通常,我们需要处理字节。让我们检查一个用例。这是一个非常常见的用例。我们有一个在某个已知地址提供的数据表。它需要被解释,它是一个数据表,一个数组,但有时我需要查看那里的一个字节,有时需要查看一个 16 位字,有时需要查看一个 32 位字,等等。当然,代码看起来像这样,因为它确实如此。我想我们都见过看起来像这样的代码,它经常发生。我们有一个在某个已知地址的表,我们只是解释它,所以我们基于该值查看字节。我们可能需要读取不同大小的东西。当然,这使用了 reinterpret_cast。
当我们使用 reinterpret_cast 时会发生什么?未定义行为。虽然不是 Jason 说的,但几乎可以肯定,只要您的代码中有 reinterpret_cast,您就有 UB。如果您怀疑这一点,去和 Jason Turner 谈谈。即使我们认为从 byte 指针或 char 指针读取是可以的,实际上也不是。我们不断发现标准在这方面存在新的不足。目前,在 C++ 中基本上不可能访问对象的底层对象表示。原因是我们不仅在硬件上运行 C++,还在编译时在表达式虚拟机中运行它,这两者的交互导致我们仔细审视这些事情。基本上,无法绕过使用 memcpy 或 bit_cast。我们关于由底层存储支持的对象的心理模型,在编译器处理常量表达式方面是完全错误的。
总之,人们想要这样做,所以我想,好吧,让我们尝试制作一个安全的方法。我称之为 byterator,因为它从某种意义上说是字节上的迭代器。但它允许您使用 read_u8 和 peek_u16 等函数来窥视、读取或写入任何整数类型。现在我们有了一个安全的方法来做我们真正想做的事情。就像我在演讲开头说的,领域专家的意图是正确的。我不是阻止他们做他们想做的事,我只是试图让它更容易、更安全。
所以 byterator 可以做这些事情。在底层,只有很少的函数做这些事,这些函数用 UBSan 测试过,并且使用 memcpy,希望它们做正确的事。总有可能我犯了错误,但比使用 reinterpret_cast 犯错的几率小。我认为这是目前我们能做的最好的。它有一些不错的功能,比如我们可以控制返回类型,所以我们可以说读取一个 uint8_t,但以 uint16_t 的形式返回给我。这真的很有用,特别是当您希望返回值是枚举类型时。
可选类型
让我们简要谈谈 std::optional。我们希望在接口中使用可选类型,我们希望延迟构造,这是可选类型的一个很好的用例,这些都是有用的想法。std::optional 有什么问题?很多,其中一些 Steve 已经修复了,但在嵌入式意义上,std::optional 使用的空间比需要的多。有时这是个问题。您放了一个额外的布尔值在那里,对齐和填充意味着您通常有一个双倍大小的整数类型。很多时候我们有一个哨兵值可以放进去,或者有时称为墓碑值。所以我有一个允许这样做的可选类型,人们可以使用它,它的接口与 optional 相同。他们可以在声明时放入一个墓碑值,或者他们可以为特定类型重写墓碑特征,然后它就会使用那个值。
这个可选类型适合嵌入式工作的特点。这里没有异常使用。如果您访问一个未设置的可选类型,您不会得到异常,您会得到墓碑值。我为指针类型和浮点类型提供了默认的墓碑值。显然,浮点类型的墓碑值是 NaN。为什么我没有在这里使用 NaN?我确实考虑过。NaN 不被视为特殊值?是的,您无法将 NaN 与墓碑值进行比较。所以我使用了无穷大。
额外的多参数变换可能值得单独讲一次,但它真的很有用。标准库不断将东西放入成员函数中,这将其绑定为单参数。但是,如果您想进行函数式风格的编程,您需要多参数变换。所以我们把它加了进去,然后我们从代码审查中认识到另一个用例:很多时候我们只是将可选类型用于延迟构造。既然我们这样做,我们想专门化一种方式来指出代码,我们称之为 cached。它在底层是一个可选类型,具有与可选类型基本相同的接口,加上几个额外的东西。它接受一个 lambda,当您访问它时,它会调用那个 lambda 并进行延迟构造。是的,因为它缓存了,或者它更像一个惰性可选。它是否意味着无效?是的,您可以用两种方式使其无效。所以,是的,它是一个惰性可选。很多时候它用于延迟构造,这就是 cached 的想法。有两种方法可以使其无效:您可以重置它,这意味着下次访问时会惰性计算;或者您可以刷新它,这意味着它立即重新计算。
在我们引入这个之后,我们又发现了另一个用例。很多时候我们有一个缓存的东西,它只在加载时缓存一次。所以我们只是做了一个小的变体,称之为 latched。这是没有重置或刷新功能的 cached。因为这是 C++,您总是可以玩一些把戏,比如就地销毁和重建来绕过这一点,但如果您那样做,在代码审查中会非常显眼。您总是可以从 latched 开始,然后如果发现需要重置它,再移到 cached,它们基本上是向下兼容的,是接口的纯粹扩展。
时间处理
时间处理很棘手,尤其是在嵌入式领域。这里有一个场景:您的微控制器有一个 32 位寄存器,它只是持续地以微秒为单位递增。这是一个相当常见的事情,谁编程过这样的微控制器?几个人。这里有什么问题?问题是 32 位的微秒数并不长。因为您真的需要将时间视为有符号数,您将拥有该比特空间的一半在过去,以便知道何时过期,另一半在未来。这意味着您总共只有大约 36 分钟的时间宇宙。您仍然需要知道计时器何时过期。
36 分钟的问题在于,作为人类,等待某事发生太长了。如果您得到一个错误,然后必须等待 36 分钟,那不好。另一方面,对于人类事物的时间尺度来说,它又太短了。比如,您可以很愉快地离开电脑半小时,喝杯咖啡,再回来,但某些东西已经搞砸了。我们看到像半范围检查这样的事情。同样,这也许不是我们这些精明的 C++ 专家会写半范围检查的方式,但它确实发生了。这段代码还行,但然后有人过来,他们好心地重构它,说,哦,我可以把它内联到那里。有人看出这里的问题吗?所以如果 T 是 uint32_t,我们仍然没问题。但如果 T 是 uint8_t 或 uint16_t 呢?当您说 ~x 时,它会提升为 int。最大值的一半,最大值将是 -1,因为您只是按位取反。现在 -1 除以 2,那是 0。所以整数提升不是您的朋友。我知道您想说使用 numeric_limits 或以不同的方式编写它,我完全同意。但事实是,像这样的事情是存在的。这是真实的代码。它会发生,我们不希望看到这类事情因为任何原因导致错误。
所以我们希望将这类事情抽象成一个类型。那个类型是 rollover_t。想法是,您有这个 32 位微秒时间,当它回滚时,您需要一个类型来表示它。您需要一个类型,其中一半比特位在过去,一半在未来。所以您的无符号类型的 rollover_t 支持这个,并且支持所有正常的算术运算,模 2 的 N 次方。
如果您有一半的比特空间在过去,从您的角度来看,过去是哪个方向?我认为在那边。所以过去在那里,现在在这里,一些比特在未来,那个窗口总是在滚动。您想要比较。现在,这种行为很容易实现,我的意思是,不容易,但可以做到。我选择删除 rollover_t 上的比较运算符。有人知道为什么吗?比如 operator< 被删除了。因为它在严重地滚动。我有一个情况,未来的值在某些情况下确实更小。是的,但它可能是整数。是的,但小于运算符考虑了半范围。当然,它可能更像寄存器回绕。不要读。好的,记住这一点。原子操作?不,原子操作不是原因。原因如下。我们用一个 3 位计数器来让它变得非常容易。想象这个场景:0 小于 1,1 小于 2,等等。7 小于 0。小于运算符是非传递的。这是一件事,但将一个非传递的比较运算符传递给 sort 并不好。所以我们有一个比较运算符,我称之为 cmp。它是类的一个成员。就像石头剪刀布一样。operator< 不会满足像 sort 这样的算法的要求,让 operator< 存在是危险的。所以我不让它存在。总的来说,我不想禁止危险,这就是为什么我仍然有 cmp,如果您想使用它。作为库作者,我的工作不是告诉您不能那样做。因为您是领域专家,您知道您想做什么,您的意图是正确的,您知道您需要做什么,有时我们需要做危险的事情。我不想阻止您做危险的事情,因为那会阻止您做聪明的事情。但我确实想为您提供日常使用的安全接口。我确实想确保您在做危险事情时知道自己在做什么。这就是我这样做的原因。我希望您和您的代码审阅者在做危险事情时是深思熟虑和知情的,有时我们需要这样做。但我们不应该在不知道它危险的情况下做它。所以,如果您知道您的数据适合排序,比如您知道回滚情况不会发生,因为您的所有数据都收集在比特空间的一半内,您完全可以排序它。但同样,我不希望日常使用危险的接口。天真的时间接口还有另一个问题。
这是一个硬件抽象层中可能存在的非常简单的接口部分,它只允许我们说现在是什么时间,并在未来安排某事运行。有什么比这更自然的呢?我们有很多事情想用它来做。我们想在做事前等待一段时间。这个接口看起来干净、现代、漂亮。但有一个问题。有人看出问题吗?也许只有您在嵌入式领域工作,有中断的情况下。在您计算超时的时候,您没问题,但然后世界上发生了其他事情。您甚至可能进入睡眠状态。程序员合上笔记本电脑盖子去喝咖啡。他们回来,40 分钟后,超过了回滚时间。现在那个稍微在未来一点的东西很久以前就在过去了,但现在它又在很远的未来,因为它已经滚过去了。如果他们在 36 分钟前回来,它本应在很久以前,但因为刚好超时,现在它在很远的未来,他们永远在等待一个应该在接下来 5 毫秒内发生的事情。
任何允许我们单独计算时间点然后安排它的接口都会有这个问题,并且极难发现这些问题。在小规模上,它们只在最终系统中出现。它们确实会出现,不是吗,Michael,在最终系统中。因为人类,我们人类非常不擅长推理时间。
所以,与其处理时间点,不如只处理持续时间。这就是我们想要表达的。我们想说在 5 毫秒后运行那个东西。那么我们为什么要计算超时然后说在那时运行,而不是直接说在那之后运行?让我们不要手动处理超时,或者更好,我们甚至可以使用基于发送者的接口。我们可以说,给我一个计时器,安排在 5 毫秒后。安排它,然后做我的事。或者在第二种情况下,我们可以说,给我一个时间调度器,我想定期做某事。现在,对这些超时计算的控制发生在底层,危险可以被限制,我们可以有一个实现,我们测试它以正确处理回滚,并在一个地方正确推理,而不是将这些事情分散在代码各处,并不得不在每次代码审查中推理。我们希望有一个地方来推理这类中断可能很棘手的事情。
在我继续之前有什么问题吗?有什么意见吗?有人认为这是个可怕的想法吗?没有人愿意说出来。好的。
线程安全与中断安全
让我们谈谈线程安全。或者实际上,中断安全,这在嵌入式领域是类似但不同的概念。嵌入式领域常常为我们提供原始的构造,我们需要从中构建抽象。我们甚至没有标准库中相当底层的构造,比如互斥锁、屏障、信号量。它们仍然相当底层,它们不允许我们在基于任务的层次上进行推理,但我们在嵌入式领域甚至没有这些。所以我们尽我们所能。我们基本上只有一个工具。很多时候,我们可以关闭和打开中断。幸运的是,这非常廉价。所以我们基本上有这个全局临界区。
让我们看看我们可以用这个抽象做什么。我们可以提供一个函数,说就在临界区下运行我要给您的这个函数。这只是我们刚刚看到的一个非常薄的抽象层。尽管如此,我们将在此基础上构建。实际上,比那稍微多一点。通常,我们希望在谓词为真或变为真时运行一个函数。所以我们将允许用户传入一些谓词。然后我们有看起来像这样的代码。我们自然可以编写一个 RAII 类型来禁用中断。昨天谁参加了 Ben Saks 的演讲?Ben 做了一个关于实现原子操作的非常好的演讲,触及了一些相同的想法。
好的,这没问题。这里抽象的问题在于它在我们的平台上工作得很好,但我们也想测试它。如果这真的提升了抽象层次,我们应该能够在桌面测试上运行它。Michael 和我所做的工作几乎都涉及处理并发性。就像开头的幻灯片一样,几乎没有数字运算,因为这些微处理器能力不强,但它们疯狂地进行 I/O 操作。它们这样做,所以 95% 的代码在进行 I/O,并且是并发进行的,处理来自不同地方的中断。问题是,我们如何在桌面上测试这个?我们如何将其转化为桌面测试?我们有这个全局互斥锁或临界区的概念。我们没有硬件级别的并行性,但我们有并发性。我们没有任何其他标准的东西。
所以,如果我们只有这个抽象,这里是我们看到的一些问题。我们有 2 个不相关的代码片段,两个不相关的数据片段。但由于锁在桌面上是全局的,我们不一定会真正推荐那样做,因为它会争用,但这在平台硬件上通过了偶然的注意,因为只有这个。但这并不是编写桌面代码的最佳方式。
我们可能有脆弱的正确性。在平台上,我们可能意外地在两个不同的地方使用共享数据,而它今天碰巧工作,因为这两个不同的地方碰巧从不同优先级的中断调用,它们实际上没有相互踩踏。所以我们有偶然的成功发生。我们也不想要那个,因为您永远不知道什么时候会再次崩溃,通常是在整个系统中,而不是在单元测试中,我们实际上有证据。
我们有其他类型的脆弱正确性。在平台上,中断经常发生,RAII 实际上就像桌面上的递归互斥锁。我不知道您怎么想,但每当我以为我需要使用递归互斥锁时,我就感觉不好。我只是觉得我的代码结构没有组织好。您不想经常这样做。所以在桌面上,我们可以用单个全局递归互斥锁实现这个临界区。那会工作。但由于这些原因,我认为它不会很好。特别是偶然成功的原因。我不想以脆弱的方式编写东西。因为即使在微控制器的世界里,事物也在变得越来越强大。总有一天我们会有实际的硬件并发性。总有一天我今天考虑的约束将不再适用。
但我希望两个地方有相同的接口,所以我是这样做的。我注入并发性。我使用这个全局 API 注入模式。这就是 call_in_critical_section 函数的样子。我们传递给它一个我们要调用的函数。它委托给这个策略。它如何找到策略?它通过一个模板,通过一个变量模板找到策略,它给了一些虚拟参数。对于任何给定的调用点,这些虚拟参数将始终是一个空包,但它仍然用它来找到正确的地方。哦,它还有这个唯一的模板参数。这就像命名一个互斥锁。所以默认情况下,它对每个调用点都是不同的,因为它是一个默认的模板参数。当然,参数来自调用点,参数没有什么不同。每个调用点在这里都会得到一个不同的 lambda,所以每个调用点都会得到一个不同的类型。但如果我们想使用相同的类型,例如在一个队列结构中,我们可以只创建一个标签类型,称之为 mutex,称之为 my_mutex,随便。然后我们使用它。现在在桌面上,这是访问同一个互斥锁。所以这在桌面上是正确的。在平台上,当然,它是我们的实现。没问题。
现在这仍然是按类型而不是按实例。我们还没有完全达到我们想要的粒度水平,但比我们之前有的好得多。现在,桌面测试策略,就像我说的,只使用一个互斥锁,那个类型选择它使用哪个互斥锁。它做您期望的事情:锁定一个守卫,然后返回函数。我省略了谓词。在平台上,我们只使用我们的中断禁用 RAII 类型。我们之前看到过。这就是所有可用的。现在这工作得很好。所以桌面测试现在可以有相同的调用代码。它们可以有相同的东西。唯一改变的是非常底层的实现。我们没有任何偶然工作的代码。我们没有更多的递归锁定依赖。我们可以在测试上运行线程清理器。这太重要了。如果您有线程代码,您应该运行线程清理器,因为它会发现您否则无法发现的错误。我们所做的是,我们将中断竞争条件转化为桌面测试中的数据竞争,这些可以被工具捕获。太重要了。
我昨天提到了 Ben 关于原子操作的演讲。关闭中断并不是确保线程安全数据的唯一方式,因为我们在各种嵌入式平台上通常有某种形式的原子操作。但有一个问题。让我说明这个问题。您如何理解这段代码?我的意思是,您期望从编写这段代码中得到什么?这里没有技巧,只是一个普通的原子整数,我们在递增它。这应该是 43 吗?x 应该是 43。当然,除此之外,您对生成的代码有什么期望?您可能期望它使用原子指令,对吗?无论这对平台意味着什么。这难道不告诉我们,当我们使用原子操作时,我们是在针对实现编程,而不是接口吗?这就是问题所在。您期望原子构造。问题是您的嵌入式设备可能没有很多原子指令。它可能有一两个。它可能有一个 32 位值的原子交换。仅此而已,例如。是的,所以 std::atomic 遭受了这种接口和实现的混淆。我们使用 std::atomic 期望一个实现,奇怪的是,那个实现可能根本不存在。您可能没有 fetch_add 或任何其他东西。您应该看看 Ben 昨天的演讲,因为他触及了一些相同的观点。
当然,std::atomic 的另一个问题是它位于 C++ 内存模型的核心。它定义了操作如何变得可见。这也是接口的一部分,不应该锁定在一个特定的实现上。所以我们期望原子操作有两件事:我们期望非抢占性,操作应该是原子的,字面意思是不可分割的。但我们也期望从 C++ 内存模型来的可见性。所以 std::atomic 是这种思想的混淆,我们是在针对实现编程,而不是接口。当然,在平台上,这可能只是工作,除非您忘记对齐变量,您会得到一个进程异常,我见过这种情况发生。因为您的交换指令需要对齐。您可能会说这是工具链的问题。我同意。工具链有问题,但它存在。也可能是交换有比您最初意识到的更多的约束。现在您正确对齐了它,但因为它是一个布尔值,内存中的下三个字节是别的东西。处理器将在正确对齐的 32 位值上生成交换指令。当您这样做时,交换将覆盖内存中的下三个字节。我也见过这种情况发生。当然,问题在于您根本没有那么多实际的原子指令,没有单条指令能执行 fetch_add。
在大多数工具链实现中,std::atomic 真的试图使用原子指令。如果它们不能,有时最终会调用工具链中的内在函数,这些函数以相对昂贵的方式实现这些操作。您真正想要的是原子访问,来自内存模型的可见性保证,尽可能便宜。事实上,在许多情况下,简单地关闭和打开中断会便宜得多。所以是的,原子操作在接口上有意义,而不是实现。我实际上不关心它是否使用原子指令。我关心它是否正确地、高效地做事。所以再次,我的原子实现非常像 Ben 昨天谈到的,只是我在底层交换了一个策略。
所以这些顶层函数做同样的事情:使用虚拟参数包(它总是空的)来选择策略。然后它们调用策略函数。然后原子类型只是像一个常规值一样反映,但我们只是使用这些策略操作原子地访问它。就像在 Ben 的演讲中一样,这里的临界区关闭中断。它也进行内存屏障。它做 asm volatile 的事情。所以现在我也可以做像注入类型覆盖这样的事情,这样当我请求一个原子布尔值时,我实际得到的是看起来、走起来、叫起来都像原子布尔值的东西,但在底层,它是一个 uint32_t,因为这是平台唯一能用交换指令做的事情。它正确对齐,当我需要时我得到原子指令。当我有平台不支持的其他东西时,那么我得到策略操作,它只是关闭中断再打开。没有人必须记住正确对齐这些变量。事情就能工作。
原子位集
现在我有原子操作了。什么会真的有用?我们已经谈过一些了。原子位集。我真的不知道为什么标准库还没有这个。它只是位集加原子。现在,在标准库中,您当然可以将任何东西包装在 atomic 中,所以您可以将其包装在 bitset 周围。但这真正来自一次代码审查,有人试图……我忘了他们是怎么尝试的。他们试图使用一个位集,他们有一些任务需要等待,这些任务在位集中表示。当它们进来并完成时,他们会清除该位,然后继续。他们真正想要的是原子位集。他们想要一个能做到这一点的东西。那么,为什么我选择说原子位集而不是 atomic<bitset>?有几个原因。一方面,它更简单。但一个很大的原因是一些操作必须被移除。特别是,bitset 允许您进行二进制或、与、异或等操作。如果您有两个原子东西并对它们进行二进制操作,您需要某种更高级的方式来假设这是一个原子操作。您不想将其硬编码到那个想法中。但基本上,原子位集看起来就像一个位集,您可以像使用位集一样用它做几件事。您想做的大多数常见事情都得到底层原子接口的支持。所以那没问题。是的,但它确实有一些限制,一些限制,就像我说的,是因为语义无论如何都需要您在更高层次上思考,比如二进制操作。移位操作还不是 std::atomic 的一部分,大多数平台还没有这样的指令。这里的另一个限制是,因为它是原子的,底层的东西,就像在位集中,您可以在底层有一个数组。位集可以任意大,因为您不必担心保持这些比特位一致。它不是原子的。但当它是原子的时,您如何保持数组中所有比特位之间的一致性?所以目前,这又涉及到做出决定,我认为这属于更高层次。您总是可以有一个常规的位集,在调用临界区时在锁下使用它。所以原子位集更专门化。
日志记录
我没有时间涵盖日志记录。这本身就是一个完整的话题。周四上午,我将做一个关于我们的日志库如何演变的开放内容会议。但这里有一个示例。您看到的这行日志。这里的一切都发生在编译时。这个的结果是一个日志,上面写着“hello cppcon 42 is int”。所有的格式化都发生在编译时。您会注意到 42 是一个值,int 是一个类型,但我仍然像它们都要传递给这个 info 函数一样传递它们,当然,这是一个宏。所以日志记录中发生了一堆事情,我将在周四上午讨论。但这就是调用点的样子。您可能还会想到,42 虽然是一个完美的编译时常量,但一旦我们将其传递给函数,它就不再是常量表达式。我们没有常量函数参数。然而,就像我说的,这完全发生在编译时。这里没有理由有任何运行时成本。现在,如果有运行时变量,那么它们将在运行时格式化。但在这种特定情况下,没有理由有任何运行时成本。我不希望人们必须记住将 42 包装在 integral_constant 或类似的东西中。即使我们有更现代的方式来做这件事,他们不必拼写 integral_constant 或 constant_wrapper 之类的东西。不,他们不必记住那样做。我见过这样的情况,非常合理,人类忘记了包装东西,结果它变成了一个运行时值,而它本应是一个编译时常量。所以我修复了那个。
总结
最后,您知道,如果问题是 C++ 有什么好处。如果您谷歌这个问题,您会发现互联网上大多数人认为 C++ 适用于需要高性能、高效率的应用程序,比如本次会议上充分代表的那些。最常见的答案总是性能、性能、性能。这对我来说不是一个很好的答案,因为 C++ 说实话,在良好性能方面并没有垄断地位,事实上它并不真正擅长原始性能,因为如果您需要超高性能,您总是必须关心硬件。是的。您总是必须知道您编程层次之下的层次,大多数时候是硬件,您总是必须注意语言之外的细节。相反,或多或少,任何编译语言都可以达到 Fortran 的 20% 以内,如果您像写 Fortran 一样写它。这不是我的话,是 Alex Stepanov 说的。
那么 C++ 真正最擅长什么?它是这些东西的组合。我们有布局控制和低层访问,我们可以实际推理。我们有元编程,它允许我们编写可组合的系统,在编译时进行,这样我们就能获得最大的运行时效率。我们可以构建抽象。它们可能是小抽象,可能是大抽象。无论哪种方式,我们都可以构建抽象和功能,并使它们可组合,这样您就可以在非常高的层次上表达某些东西,而它编译下来几乎什么都没有。对我来说,这些东西的组合使 C++ 成为赢家。


最后,我想劝告您像库开发者一样思考,即使我们编写轮子函数,我认为这使我们所有人,在某种意义上,都是库开发者。我做的很好的一件事就是远离问题。寻找新的视角。尝试在领域之外思考。尝试避免跳转到实现想法。“我们一直那样做”通常不是一个很好的论据。当我坐下来写代码时,我尝试尽可能抽象地思考。在 Bjarne 的主题演讲中,他说不要为了抽象而抽象。我会说,在设计阶段,为了抽象而抽象是必要的。这并不意味着我必须抽象地实现事情。但我喜欢抽象地思考事情,这样我就能发现新的见解。它可能影响我的实现,也可能不。我可以单独决定如何实现某件事,以及我
049:个人回顾与委员会参与指南





在本教程中,我们将跟随Nevin “-” Liber的视角,回顾他参与C++标准化委员会15年的历程。我们将了解如何从一名普通开发者成长为委员会成员,并深入探讨C++标准制定的流程、挑战与收获。本教程旨在消除对标准化工作的神秘感,鼓励更多开发者参与其中。
章节 1:缘起与早期探索
上一节我们概述了本教程的目标。本节中,我们来看看Nevin是如何与C++结缘并开启标准化之旅的。
Nevin的C++之旅始于AT&T贝尔实验室。当时他对C++一无所知,但在一位朋友的启发下,他开始主动学习。他结识了工程师Jim Coplien(后来提出了“奇异递归模板模式”CRTP),并在其帮助下获得了SeaFront编译器,通过观察生成的C代码来理解C++的对象模型。
在学习过程中,他遇到了一个关于for循环中变量作用域的问题:为何循环结束后,循环变量i似乎仍在作用域内?这与他理解的作用域应持续到右花括号}为止相悖。Jim也无法解答,于是他们决定向唯一可能知道答案的人求助。
Nevin鼓起勇气,花了整整两天时间,字斟句酌地给Bjarne Stroustrup(C++之父)写了一封非常恭敬的邮件,阐述了他的疑问。第二天,他收到了Bjarne礼貌而友好的回复。Bjarne承认这确实是一个设计上的失误,作用域本应如Nevin所想的那样,但为了保持向后兼容性,无法更改。这是Nevin学到的关于标准化工作的第一课:维护向后兼容性至关重要。
后来,Bjarne到访贝尔实验室,Nevin有幸与他握手并交谈。Bjarne提到,他们曾将庞大的5ESS电话交换系统代码(约5000万行)从C迁移到C++,仅仅因为C++有函数原型而当时的C没有,就发现了3个隐藏的bug。这件事给Nevin留下了深刻印象。
然而,此时的Nevin对多种编程语言都抱有好奇心,C++并非他唯一的热情所在。他后来去了苹果工作,又进入研究生院学习,尝试了Java甚至Mumps等语言。
在担任助教期间,他遇到一个学生无法编译一段看似正确的C++代码。他查阅了大量资料才发现问题所在:在当时,两个连续的>字符(例如在模板嵌套中)会被解析为右移运算符>>,需要在中间加一个空格。这个经历让他意识到,发现问题本身就有价值。同时,他也回忆起在贝尔实验室阅读Bjarne的技术报告时的感受:报告不仅说明“做了什么”,还解释了“为什么这么做”以及“其他选项为何被否决”。他认为这是进行语言设计等工作的绝佳方式。
至此,Nevin决定,他的下一份工作一定要是专业的C++开发。
章节 2:深入工业界与接触标准
上一节我们介绍了Nevin如何从初学者成长为决心从事专业C++开发的工程师。本节中,我们来看看他如何在实际工作中深化对C++的理解,并首次接触到标准化工作。
Nevin在报纸上(是的,那个需要邮寄纸质简历的年代)发现了一则招聘C++工程师的广告。与许多写着“C/C++程序员”的广告不同,这则广告明确要求真正的C++技能。他加入了WMS Gaming,一家老丨虎丨机公司。
在这里,C++被用于在资源极其有限的系统上(Intel 80188处理器,640KB RAM,64KB电池备份RAM)开发全视频老丨虎丨机。由于涉及真钱交易和严格的监管认证,代码必须精益、健壮且可靠。这段经历让他深刻体会到在约束下进行高效C++编程的重要性。
公司支持他参加各种C++培训和研讨会,他的老师变成了Scott Meyers、Dan Saks、Herb Sutter、Andrei Alexandrescu和Steve Dewhurst等行业专家。他还参加了第一届CppCon(2007年),并遇到了Boost库的创始人Beman Dawes。Beman是第一个建议他应该参加标准会议的人,并告诉他很快在附近的费米实验室将有一次本地会议。
尽管Nevin当时就想:“等等,我有工作,我可以飞过去参加”,但他内心仍然充满畏惧,认为自己不够优秀、不够聪明、不够专业,没有勇气迈出那一步。这一等就是三年。
如果当时他知道标准化委员会和制定管道螺纹、电源插座或卫生纸标准的是同一类人,或许就不会那么 intimidated(感到畏惧)了。Dan Saks分享的一段历史揭示了这一点。
以下是C/C++标准化起源的关键事件列表:
- 二战期间:坦克、船舶和飞机制造商需要协调零件标准,成立了国家标准化机构。
- 20世纪70年代:该机构演变为美国国家标准学会(ANSI),制定了在工业界建立共识的正式规则。
- 1978年:摩托罗拉的Jim Brodie需要编写一个可靠的C编译器,但K&R的书籍描述过于非正式。他发现其他人也有同样的问题,于是需要一项国家标准。
- 1983年:Jim Brodie按照ANSI的流程,在行业媒体上发布通知,邀请所有感兴趣的人参加筹备会议。这次会议促成了C标准委员会的成立。
- C++需要标准时:C委员会决定不接手。于是流程重演,1989年12月举行了一天的组织会议,C++标准委员会由此诞生。
Nevin当时并不知道这段历史。对他而言,委员会是由一群比他更聪明、更杰出的精英组成,在“高山之巅”为芸芸众生改进语言。但另一方面,他渴望学习更深入的C++知识,而市面上已没有他没上过的高级课程或研讨会了。这成了他参加标准会议的主要动机:为了学习。
章节 3:迈出第一步:首次参会
上一节我们看到了Nevin参与标准化的动机逐渐成熟。本节中,我们来看看他如何克服心理障碍,真正参加第一次标准会议。
机缘巧合,Scott Meyers将Nevin推荐给芝加哥的一家低频交易公司DRW。DRW正在寻找优秀的C++人才,并最终录用了他。作为录用条件的一部分,公司同意派他参加即将在费米实验室举行的本地会议。
现在,他需要弄清楚如何参会。他给Herb Sutter发了邮件。Herb回复表示欢迎,并说明可以以观察员身份参会,可以参与讨论,但没有投票权。他需要国家机构的邀请和批准。这些规则至今变化不大。
随后,时任美国委员会主席的Steve Clamage给他写了邮件。邮件中最重要的部分是:“会议可能很无聊。” Herb也曾多次在会议前说类似“我们只是在进行收尾工作,不会太令人兴奋”的话。但Nevin后来发现,每一次会议都从未无聊过,总会有令人兴奋的议题。
第一次会议在费米实验室举行。Bjarne在场,Herb因病未能到场,通过会议电话发言,“宛如上帝之声”。Nevin看到委员会正在激烈辩论关于默认拷贝构造函数和移动构造函数的规则。他有一些技术观点想说,但仍然感到害怕和恐惧。
整个会议期间,他都没能鼓起勇气在会议期间发言。他通过一位认识的委员会成员中转邮件来表达观点,而对方总是鼓励他:“你应该自己说出来。” 但他始终没能做到。
会议结束后,他回到DRW,询问公司是否可以正式加入委员会。由于会费仅需2000美元(在金融公司这很容易),公司同意了。在与法务部门沟通并签署了不泄露公司机密的协议后,DRW成为了国家机构成员,Nevin也正式成为了ISO全球目录中的一员,拥有了投票权。
Nevin强调,加入委员会其实没有魔法:支付费用,即可加入。每个国家有不同的方式。如果个人无法承担,也可以通过C++基金会或Boost等途径加入。
章节 4:找到自己的声音与角色
上一节我们见证了Nevin正式成为委员会成员。本节中,我们来看看他如何在委员会中找到自己的位置并发声。
在马德里会议上,Nevin遇到了John Lakos(《大规模C++程序设计》作者)。John正在为“宽契约和无异常”的提案游说,他逐个说服委员会成员。John也成功说服了Nevin。
然而,在全体会议就这一主题进行激烈辩论时,Nevin突然意识到一个逻辑问题:如果违反了前置条件,标准规定程序进入未定义行为(UB)。标准还说,此时标准本身不再适用。那么,即使有宽契约和noexcept声明,在UB状态下,你仍然可以通过noexcept抛出异常,因为标准已不适用。
他环顾四周,似乎没人提出这一点。于是,在激烈的全体会议中,他不得不第一次开口发言。他身后的一位编译器专家立即反驳:“noexcept优先于未定义行为。” 从实践角度看,这位专家是正确的;但从标准理论角度看,Nevin至今仍认为自己的逻辑站得住脚。不过,这没关系。重要的是,房间里的人倾听并理解了他的观点,并进行了思考。对他来说,这就足够了:提出观点,被人理解。
他认为C++26中的契约(Contracts)特性最终解决了这个问题,他对此全力支持。
马德里会议还发生了两件重要的事:他与John Lakos成为了朋友;并且,他参与投票通过了13年来的第一个新标准——C++11。投票过程有些出乎意料,在一个看似普通的快速表决后,大家才反应过来:“我们刚刚投票通过了C++11。” 第二天,委员会休息了一天——13年来的第一次休息。
一年后,下一个标准(C++14)开始成形,Nevin在委员会中的角色也逐渐清晰。他发现自己擅长发现那些不太正确的小细节,并设法修复它们。
以下是他在后续工作中推动或参与解决的一些问题示例:
- 位域(Bitfields):发现标记为
int和signed int的位域行为可能不同。他坚持这是错误的,并最终说服了整个核心语言工作组。 - 空指针与字符串流:成功辩论了
std::stringstream视图中的空指针值(未定义行为)不应等同于空字符串。 vector<bool>的emplace_back:为保持一致性,推动并成功为vector<bool>添加了emplace_back方法(尽管其行为仍与其他vector特化版本不完全一致)。- 静态调用运算符与下标运算符:在C++23中,有人提议使函数调用运算符
operator()可以是静态的,另一份提案则提议使下标运算符operator[]可以是静态的。但两份提案都忽略了应该让两者行为一致。Nevin在与其他国家代表团负责人交流后,共同推动了国家机构评论,最终在C++23中修复了这个问题。
这些“胜利”令人满足。然而,Nevin同样记得那些失败的辩论,它们带来的“伤口”持续了很长时间。
章节 5:挫折、坚持与“求和类型”的胜利
上一节我们看到了Nevin在委员会中的一些成功贡献。本节中,我们来看看他经历的重大挫折,以及如何从挫折中恢复并最终推动重要特性进入标准。
Nevin当时主要参与新成立的库演化工作组(LEWG)。他们正在辩论一个名为std::optional的提案。optional是一种可以表示“有值”或“无值”状态的类型,非常重要。
辩论始于邮件列表。一个主要争议点是:比较操作符应如何工作?对于整数等常规类型很简单,但对于浮点数(有NaN状态,NaN不等于任何值,包括其自身)或具有特殊规则的其它类型,则很复杂。委员会无法达成共识。
在2013年布里斯托尔会议的一个晚上,LEWG从晚上7:30一直争论到凌晨1:15,没有休息。气氛非常激烈,人们不断重复自己,拒绝回答问题,令人沮丧。最终,为了能让optional进入C++14,他们决定移除除相等==和小于<之外的所有关系操作符(不等于!=、大于>等都被移除),留待以后解决。这个妥协方案在全体委员会获得通过,进入了C++14工作草案。

然而,这并非终点。在下一次由DRW主办的芝加哥会议上,由于这个悬而未决的问题,optional被从标准中撤出,转入了“技术规范”(TS),Nevin称之为“提案的炼狱”。因为一旦进入TS,即使未来纳入标准也可能改变,生产代码通常不会采用,导致委员会无法获得迫切需要的使用反馈。
optional在“库基础技术规范”中待了三年。这件事让Nevin感到筋疲力尽和沮丧,失去了继续战斗的能量。
大约此时,Nevin和他在DRW的同事Matt Godbolt正在设计一个更好的variant(可变类型容器)。他们本打算开源并标准化它。但Nevin因为optional的经历,感到没有能量再去推动一个新的、可能引发激烈辩论的提案。
有趣的是,大约一年后,来自Qt的Axel Naumann带着一个与Nevin和Matt设计非常接近的variant提案来到了委员会。这验证了他们的设计。但Axel的提案存在一些“新手错误”,没有充分解释设计决策。Nevin知道如何推动它通过,但也清楚其中艰难的设计部分。
Axel邀请Nevin成为合著者,但Nevin拒绝了。原因在于,如果他的名字在论文上,在辩论时他就不能离开房间(因为需要作者在场)。而optional带来的“伤口”仍在作痛,他需要这个选项。于是,他选择在幕后做大量工作,帮助完善提案。
核心设计难题是:如果一个variant当前持有类型A的对象,你想将其替换为类型B的对象。在销毁A并尝试构造B时,如果B的构造函数抛出异常,variant应处于什么状态?这决定了整个设计。委员会对此有数十种想法,邮件列表上有超过400封邮件。Nevin努力让大家聚焦于这个问题。
在科纳会议上,他们举行了一个大型晚间会议来辩论此事,但设计本质上并未偏离原始方案。两年后,在芬兰会议上,他们终于将variant纳入了C++17。同时,optional和any(可持有任何可拷贝类型的容器)也成功进入了标准。这是自C语言原生union以来,C++首次拥有了真正的“求和类型”。
至于之前提到的optional<T&>(可选引用),在C++26中,由Steve Downey推动,最终也加入了标准。所以,“我们最终等到了后来”。
章节 6:新角色与新舞台
上一节我们经历了从挫折到最终推动“求和类型”进入标准的漫长旅程。本节中,我们来看看Nevin在委员会中承担的新职责以及职业上的新变化。
这些事件巩固了Nevin在委员会中的新身份:固执但务实,力求高效。这些特质使他适合承担下一项任务:孵化器(Incubator)。
由于提案太多,委员会需要设立孵化器来对提案进行初步筛选和打磨,然后再提交给相应的工作组。在圣地亚哥会议上,设立了语言侧和库侧两个孵化器。Nevin将所有时间都花在库孵化器上,帮助塑造其工作方式。后来,他被邀请共同主持库孵化器的工作,并持续至今。被人需要和赏识的感觉很好。
另一件重要的事是,委员会同事Daisy Hollman和Hal Finkel将Nevin引荐到国家实验室系统工作。他们看到了Nevin在委员会工作之外的更多潜力,为他争取到了阿贡国家实验室的职位。现在,他的工作从老丨虎丨机转向了超级计算机。
在阿贡,他致力于一个名为Kokkos的库,专注于性能可移植性。科学家们需要将代码移植到具有新架构的超级计算机上,并保持相似的性能。由于他的委员会工作背景,他确保高性能计算社区的需求能被委员会了解。例如,他曾经不理解“可平凡复制”的重要性,直到在阿贡才明白这是GPU和CPU之间传输数据的关键方式,并就此向委员会成员进行解释。
他还参与了另一个性能可移植性层Sickle的标准化工作,并且最近(三周前)也开始参与C语言委员会,帮助标准化相关特性。
章节 7:鼓励参与与展望未来
上一节我们看到了Nevin在委员会和职业生涯中的新发展。本节中,作为总结,我们来看看他如何鼓励他人参与,以及对C++未来的展望。
Nevin希望他的故事能说服一些人:你也可以加入委员会。不仅仅是为了贡献,也是为了学习。他从中获益良多,学会了如何与人辩论,学到了技术知识。
他非常期待C++26。除了反射,还有用于解决安全问题的契约、错误行为处理和强化机制。在库方面,有线性代数、原位工厂等。这是一个令人兴奋的版本。
委员会约有540人,每次会议大约有200人出席。他们影响着全球数百万开发者和数十亿用户。只有大约200人来自25个国家,在为此做出贡献,让世界变得更美好。你仍然可以加入。下次会议(2026年3月)将最终确定C++26,你可以出现在合影中。
如果这还不够有说服力,那么下次会议在夏威夷。来夏威夷吧!你可以带着提案来。作为会议主席,他们会优先处理你的论文,因为他们知道这可能是你获得公司资助来参会的方式。你将有时间培育和研究你的想法,向周围的人学习,并深入钻研任何你真正感兴趣的领域。这就像CppCon的“走廊交流”,但是“加强版”。
Nevin特别感谢了委员会的所有成员。每个人都有自己的故事。在某种意义上,他们都是“齿轮”。如果他离开,C++很可能继续发展。但正是集体的努力,才使得C++的改进得以实现。
他特别感谢了Sherry( likely 指 Sherry List,会议组织者或助手),花费了大量时间帮助他准备这次演讲,希望能说服大家参加标准会议。
本节课中我们一起学习了:
- C++标准化工作的起源和历史背景。
- 一名普通开发者如何克服心理障碍,逐步深入并最终成为标准化委员会核心成员的历程。
- 标准化工作的日常:辩论、妥协、寻求共识,以及处理挫折。
- 委员会的组织结构和工作流程(如孵化器、LEWG、LWG等)。
- 推动具体特性(如
optional、variant)进入标准所面临的挑战和所需的坚持。 - 参与标准化工作对个人职业发展的积极影响。
- 委员会对广大C++开发者社区的重要性,以及鼓励更多人参与其中的呼吁。


Nevin的故事表明,参与标准化不需要你是天才或专家,需要的是热情、坚持、愿意学习以及为社区贡献的意愿。C++的未来依赖于广泛而多元的参与者。
050:缓存友好的C++ - Jonathan Müller - CppCon 2025




在本教程中,我们将学习如何编写缓存友好的C++代码。我们将探讨CPU缓存的工作原理,以及如何通过优化数据布局、访问模式和容器选择来提升程序性能。课程内容涵盖从基础概念到多线程环境下的缓存注意事项。
动机:为什么需要关心CPU缓存?
为了理解缓存的重要性,我们首先比较几种不同的集合容器。标准库提供了两种集合类型:std::set(基于二叉搜索树)和std::unordered_set(基于哈希表)。我们也可以简单地使用std::vector。
以下是这些容器在填充N个随机整数并执行随机查找时的基准测试结果。仅基于大O复杂度,我们预期std::unordered_set(O(1)查找)最快,其次是std::set和排序后的std::vector(O(log N)查找),最后是未排序的std::vector(O(N)查找)。
然而,实际结果却出人意料。当元素数量为64时,未排序向量的线性搜索实际上比其他所有容器都快。此外,O(1)复杂度的std::unordered_set(绿线)在性能上始终被排序后的std::vector(红线)超越。
问题的答案在于CPU缓存。
CPU缓存是什么?
为了演示缓存的影响,我们进行另一个基准测试。我们有一堆随机数据,然后随机修改其中的某个位置。随着数据大小的变化,我们测量修改所需的时间。
你可能会天真地认为,无论数据量多大,修改所需的时间都相同。但事实并非如此。随着数据量的增加,我们获得的速度会下降。
在最初的蓝色区域(约几千字节内),性能保持恒定。在绿色区域(约80KB到2MB),性能随着数据增加而下降。在橙色区域(超过2MB),性能下降得更快,但最终趋于平缓。
为什么会这样?简单来说,内存访问并非直接从CPU到主存。主存访问非常慢。例如,在Apple M1上,RAM访问速度约为70 GB/s,而其计算性能可达2.6 TFLOPs,相当于约10,000 GB/s的操作速度。这意味着主存访问比计算慢约100倍。
因此,现代计算机使用缓存。缓存是更小、更快的内存,位于CPU附近。当CPU需要访问数据时,它首先检查缓存。如果数据在缓存中(缓存命中),则直接使用;如果不在(缓存未命中),则从主存加载数据到缓存,然后再提供给CPU。
缓存是分层的。在M1上,每个核心有64-128 KB的L1缓存(访问约3个周期),多个核心共享8-12 MB的L2缓存(访问约18个周期),还有一个与GPU共享的8 MB L3缓存(访问延迟10-150纳秒)。只有耗尽所有缓存后,才需要访问主存(延迟约100纳秒)。
这解释了基准测试的结果。在蓝色区域,数据完全适合L1缓存。在绿色区域,数据适合L2缓存,但随着数据增加,L1缓存未命中的概率增加,性能下降。在橙色区域,数据超出L2缓存,性能进一步下降,最终趋近于主存访问速度。
缓存有两种写入模型:直写缓存(同时更新缓存和主存)和回写缓存(只更新缓存,稍后写回主存)。现代CPU缓存通常是回写式,这在多线程环境中会带来有趣的影响。
关键要点是:主存访问非常慢。我们可以通过使用缓存来避免许多访问。缓存更快、更接近CPU,可以缓存频繁访问的值。然而,缓存很小,因此要获得高性能,需要确保尽可能多的数据适合缓存。
如何高效利用缓存空间?
以下是一个计算人员平均年龄的例子。我们使用强类型整数表示年龄,然后遍历所有数据计算总和与平均值。
为了优化并确保尽可能多的数据适合缓存,一个简单的方法是使用更小的数据类型。如果我们使用int需要4字节,但人的年龄范围有限,我们可以使用short(2字节)甚至unsigned char(1字节)。
基准测试显示,short比int快,因为更多数据可以放入缓存。但unsigned char虽然比int快,却不如short快。这是因为在此CPU上,unsigned char的算术运算可能比short慢。这再次强调,优化前后必须进行测量,因为改善一个指标可能会在其他方面造成损失。
优化基本类型大小很简单:选择更小的原始类型(如signed char或short),使用float代替double,并指定枚举的底层类型(默认为int)。由于通常不对枚举进行算术运算,这可以节省缓存空间。
但使用1字节整数类型时需要小心。实际上有三种不同的1字节整数类型:signed char、unsigned char和普通的char。此外还有char8_t(用于UTF-8文本)和std::byte(用于内存访问)。所有这些都是8位/1字节类型。
基准测试一个递增1字节类型的函数,结果显示除了char8_t外,所有类型性能相同,而char8_t更快。原因与CPU缓存或汇编指令无关,而是与优化器有关。
C++有严格的别名规则。如果对象位于某个内存地址,只能使用兼容类型的指针访问它。char、signed char和unsigned char的指针可以指向内存中的任何对象,这意味着它们是“别名类型”。对于非别名类型T,修改data[i]只能修改其他T对象。但对于别名类型,修改data[i]可能修改内存中的任何内容,包括可能修改std::vector的大小。
在循环中,如果类型是非别名类型,优化器可以推断data.size()不变并将其提升到循环外。但对于别名类型,优化器必须假设赋值可能修改大小,因此每次循环都需要重新加载大小。
这解释了char8_t的性能优势,因为优化器可以更好地优化它。当然,你仍然可以使用char或signed char,只需确保自己应用优化,例如手动将大小提升到循环外,或者使用std::for_each。这样就能获得相同的性能。
再次强调,进行任何优化时都要进行基准测试,特别是对于1字节类型,由于严格的别名规则,它们具有特殊属性。
使用位域和内存对齐
如果你需要小于1字节的数据,可以使用位域。例如,一个Widget可以有enabled、visible布尔值和三种state。如果分别存储,Widget大小为3字节。但我们可以使用位域,enabled和visible各占1位,state占2位,这样Widget可以打包到1字节中。
然而,访问位域更慢,因为每次访问都需要从字节中提取和屏蔽位。因此,从缓存中容纳更多Widget获得的收益可能会被访问开销完全抵消,必须进行基准测试。
一种通常免费的优化是重新排列结构体成员。考虑一个Message结构体,包含uint8_t type、uint32_t length、void* data和uint16_t checksum。在64位系统上,这些成员总大小为15字节,但结构体实际大小为24字节。原因是内存对齐。
对齐是类型的要求,规定了对象地址必须是其对齐值的倍数。对于结构体,其对齐值是所有成员中最大的对齐值。放置成员时,必须确保每个成员的偏移量是其对齐值的倍数,因此需要插入内部填充字节。最后,还需要尾部填充,以确保结构体数组中的每个对象也都正确对齐。
对于按原始顺序排列的Message,编译器需要插入大量填充字节。但如果我们将成员按对齐值从大到小重新排序,就能获得最优的填充,现在Message大小为16字节,可以在缓存中容纳更多实例。
我们甚至可以进一步利用对齐。指向T的指针,因为T对象必须正确对齐,所以指针地址的最低几位总是0。我们可以利用这些位存储额外信息。例如,需要存储指向Container或Text的指针时,传统方法需要存储一个布尔标志和一个void*指针,共16字节。但如果我们假设Container和Text对象至少2字节对齐,指针地址的最后一位总是0。我们可以将指针存储为整数,并将最后一位设置为1表示Text,0表示Container。这样只使用8字节就存储了相同的信息。
如果类型更小,更多实例可以放入缓存。因此,使用short代替int,使用float代替double,指定枚举的底层类型,考虑位域,重新排序结构体成员以避免填充,并考虑使用指针对齐位等技巧来存储额外信息。
然而,所有这些都意味着数据访问可能变慢。因此必须进行基准测试,以确认是否真正获益。
预取和缓存行
你可能会疑惑,在计算平均年龄的基准测试中,当我们使年龄类型变小时,为什么会有帮助?我们只访问每个地址一次,不应该都是缓存未命中吗?同样,缓存也没有解释为什么排序向量比std::unordered_map快。
为了探究原因,我们不仅基准测试随机访问,还测试线性访问(向前和向后)。在顺序访问中,无论数据大小如何,性能都相同。只有在真正随机访问时,性能才会下降。
原因是预取器。当我们访问地址X时,通常也会访问下一个地址。CPU设计者知道这一点,因此引入了预取器,它会预加载可能在未来访问的地址到缓存中,从而避免缓存未命中。
在另一个基准测试中,我们随机访问数据块。我们随机选择一个起始块,然后以随机顺序访问该块中的所有索引。随着块大小的增加,性能上升,但收益递减。
这是因为内存访问以缓存行为单位进行。缓存行是连续的内存块,通常为64字节。当我们加载地址2时,不仅加载地址2,还加载整个包含地址2和3的缓存行。当我们稍后需要地址3时,它已经在缓存中了。
在块访问基准测试中,当块很小时,我们加载了整个缓存行但丢弃了大部分信息。随着块大小增加,我们越来越高效地使用了缓存行。
因此,CPU不仅缓存数据,还希望通过预取整个缓存行来最小化内存访问。它学习我们的内存访问模式并进行预取。要获得真正的高性能,需要具有高局部性的可预测内存访问模式。
使用缓存友好的容器
要利用预取和缓存行,意味着我们需要可预测的访问模式,避免指针追逐,希望顺序内存访问,并确保高局部性(即最小化类型大小,使更多数据适合缓存行,同时确保不关心的数据不在缓存行中)。
std::vector<T>是缓存友好容器的典型例子。它的值连续存储在内存中,没有元数据浪费缓存行空间。迭代时顺序访问内存,预取器可以发挥作用。类似地,std::array、inplace_vector等具有连续内存访问的容器也是缓存友好的。
相反,std::list是链表,每个节点包含值和指向前后节点的指针。迭代时跟随指针,预取器无法帮助,且指针浪费了缓存行空间。
std::deque<T>逻辑上是指向块的指针向量。在块内是连续的,预取器满意。只有切换到下一个块时,才需要一点指针追逐。如果块足够大,块间转换影响不大,因此它也是缓存友好的。但不幸的是,至少在一个标准库实现中,块大小太小,无法获得有意义的缓存收益。
标准库的map和set实现(基于二叉搜索树)非常不缓存友好。从内存布局看,它就像链表,节点包含左右指针,需要指针追逐,预取器无法帮助,且元数据浪费缓存行空间。
同样不幸的是,unordered_map和unordered_set也不缓存友好。它们使用闭地址法,本质上是桶数组,每个桶是一个单向链表。查找时计算哈希值,找到桶,然后跟随指针直到找到键值对。这对预取器不友好,且元数据浪费缓存行空间。这就是为什么在基准测试中,对于足够小的值,排序向量击败了O(1)容器。
哈希表可以更快,只需使用开地址法。在开地址法中,你有一个平坦的元素列表(如std::vector),哈希后找到适当的桶,检查是否是所需值,如果不是则查看相邻位置。这非常缓存友好,因为没有指针,只需查看相邻地址。但标准库未提供此实现。
总之,应优先使用占用大块连续内存的容器,因为它们更缓存友好。预取器可以帮忙,不会用不必要的元数据浪费缓存行,只包含关心的数据。在几乎所有情况下,都应使用std::vector。如果需要其属性,也可以使用std::deque的实现,或使用开地址法的哈希表。
代码也是数据
在我进行测试测量后,发现测量的L2缓存大小与实际不符。数据表显示L2缓存为4 MB,但性能下降发生在达到4 MB之前。原因是,代码也是数据。
CPU指令也存储在内存中。主存访问慢,因此使用更快的缓存来避免。在L1级别,通常有独立的指令缓存和数据缓存,但在L2缓存,它们通常是统一的。因此,测量的L2缓存大小较小,因为部分L2缓存用于存储正在执行的程序。
代码也是数据意味着代码本身也有缓存效应。我们可以构造精心设计的例子来演示代码的奇怪缓存效应。
例如,我们有一堆随机数据并求和,但如果遇到零,则执行64次单独的递增操作。条件data[i] == 0很少为真,但如果为真,则有64次单独的递增。我们可以先求和再执行递增,或者先执行递增再求和。这不会改变结果,只是编写顺序的选择。
在Apple M1的高性能核心上,先求和再递增的版本明显更快。仅仅因为CPU指令在内存中的布局方式,就导致了巨大的性能差异。我研究了一下,但未找到根本原因。
关键是,你的程序存在于主存中,主存访问慢,因此有缓存。就像可以有缓存友好的数据访问模式一样,也可以有缓存友好的代码访问模式。
缓存友好的数据访问模式希望按线性顺序访问数据,代码的等价模式是希望顺序执行的指令。这意味着不希望分支跳转到程序完全不同的位置。例如,函数调用可能首次调用时导致缓存未命中。类似地,对于数据,希望避免指针追逐,因为预取器无法工作;代码的等价模式是间接跳转(如调用虚函数或函数指针),预取器无法预取。
同样,希望最小化数据大小以更高效地使用缓存行;对于代码,在循环中希望尽可能少做事情,以便更多代码适合缓存。
因此,不仅要以考虑缓存的方式设计数据,整个程序也是如此,因为代码本身也很重要。实现这一点的一种方法是遵循数据导向设计。
数据导向设计
数据导向设计通常与面向对象编程对比。在OOP中,关注对象(领域特定的抽象);在数据导向设计中,关注算法(数据转换)。在OOP中,有封装数据和行为的智能对象(具有成员变量和成员函数的类);在数据导向设计中,只有操作数据的算法。
例如,在OOP中,我们可能设计一个Person类,具有姓名和年龄,然后有Person的向量,并编写计算平均年龄和找到最年长者姓名的函数。
在数据导向设计中,我们从算法开始。计算平均年龄的函数只需要所有人的年龄,不需要姓名。找到最年长者姓名的函数需要姓名和年龄,可以拆分为两个函数:一个找到最年长者的索引(仅需年龄),另一个根据索引返回姓名。
我们看到两种不同的数据布局方法。第一种称为结构数组:有一个包含字段的结构,然后是该结构的数组。第二种称为数组结构:Person不再作为一个实体存在,它只是一个索引,我们有构成人的所有数据(如所有人的姓名和年龄)。
在OOP中,通常使用结构数组,因为对象是关于对象的。在数据导向设计中,使用适合算法的布局。如果经常需要同时处理年龄和姓名,那么将它们放在一起有意义;如果有时只处理其中一个,那么将它们分开有意义。
从内存布局看,结构数组是姓名、年龄、姓名、年龄……这提供了对单个记录的高效访问。数组结构首先是所有姓名,然后是所有年龄,这提供了对单列的高效访问。
这很重要,因为如果只关心年龄,使用结构数组代码时,缓存行会浪费在不需要的姓名上,性能下降。基准测试显示,无论选择何种年龄大小,Person都明显更慢,因为浪费了缓存行空间存储姓名。
另一个经典的OOP例子是形状层次结构。有Shape基类和纯虚函数area(),以及派生类Circle和Square。计算总面积函数接收std::vector<std::unique_ptr<Shape>>,遍历并调用虚函数求和。
在数据导向设计中,我们设计算法total_area,它需要圆形和正方形。我们可以使用std::variant<Circle, Square>,或者更简单,使用两个单独的向量:一个存储所有圆形,一个存储所有正方形。然后分别计算圆形总面积和正方形总面积,再相加。
这三种表示异质数据的方式性能不同。基于指针的OOP方式本质上是指针向量,每次迭代都跟随指针,预取器无法帮助,且指针浪费缓存行空间。variant方式更好,因为内联存储,但仍需要判别标签。两个单独向量的方式最佳,因为迭代时不需要分支,且没有额外元数据。
毫不奇怪,最后一种表示(两个单独向量)明显更快。简而言之,数据导向设计的思想是:设计转换数据的算法,从算法开始,考虑所需的数据,并编写操作N个元素的函数,而不是单个元素。将循环推入函数中,这对优化器更友好,性能更好。
进一步建议:如果只关心数据的子集,只将该数据放入数组,使用数组结构,让其他数据存放在别处,仅在需要时访问。如果有某种变体,只需使用多个同质数组。例如,如果有一个布尔成员,在循环中根据布尔值执行不同操作,这通常是一个危险信号,只需拆分为两个单独的数组,一个用于布尔为真的情况,一个用于布尔为假的情况。这样可以避免分支,且不需要存储布尔值。
通过这种方式,可以真正利用缓存,获得巨大的性能提升。
多线程与伪共享
到目前为止,我们只看了单核处理器,现在看看多核处理器。假设我们要进行并行折叠算法(如求和)。并行化的模式是:我们有一个线程池,创建一个数组存储每个线程的本地结果。每个线程获取工作的一部分,进行计算,并将结果存储在本地结果数组中。这不需要同步,因为每个线程访问不同的数组元素。所有线程完成后,再折叠本地结果得到最终值。
在基准测试中,我们并行累加一堆斐波那契数。随着线程数增加,每个线程做的工作更少,理论上应该获得加速。然而,实际运行基准测试时,性能根本没有扩展。事实上,随着线程数增加,性能甚至下降。使用四个线程比单线程还慢。
这是因为在多核处理器中,每个核心有自己的L1缓存。如果一个核心修改了其L1缓存中的条目,它必须使其他核心的该缓存条目无效,否则它们会读取过时数据。CPU缓存以缓存行为单位操作,因此它会使整个缓存行无效。
在我们的例子中,线程0修改本地结果数组的索引0,这会使整个缓存行无效。线程1访问同一缓存行中的相邻条目时,即使该条目未被修改,也会遇到缓存未命中,必须从主存重新加载。这称为伪共享:由于同一缓存行中不相关数据的修改而导致的缓存无效。
这严重损害性能,必须避免。避免的一种方法是确保每个本地累加器位于不同的缓存行。我们可以通过添加填充空间来获得所需的对齐。C++17提供了std::hardware_destructive_interference_size来获取缓存行大小,但它是编译时常量,可能不总是正确设置,可能需要自己实现。
通过这种对齐,我们为每个线程分配了一个缓存行。现在每个线程修改自己缓存行的第一个值,不会使其他线程使用的缓存行无效。重新运行基准测试,我们获得了预期的性能扩展。
当然,在这个特定例子中,更简单的解决方案是使用局部变量进行累加,只在最后写入本地结果。这样仍然存在伪共享,但因为只发生在最后,对性能影响不大,我们仍然获得扩展行为。
因此,每次进行多线程编程时,都要牢记伪共享。如果不同线程同时使用的数据,将它们放在不同的缓存行中,即使没有竞争条件,也应视为有竞争条件,因为它会严重损害性能。当然,反之亦然:如果同一线程使用的数据,将它们放在同一缓存行中,这样就不必担心性能。
总结
本教程介绍了CPU缓存的基础知识。缓存是更小、更快的内存,用于避免访问缓慢的主存。缓存行是主存和缓存之间传输数据的最小单位。
为了减少缓存未命中,我们有缓存预取器,它预测内存访问并预加载数据到缓存中。
处理多线程时,必须注意伪共享,即由于同一缓存行中不相关数据的修改而导致的缓存无效。
还要记住,代码也存在于内存中,代码也有缓存效应,不仅仅是数据。
要真正利用CPU缓存,我们需要缓存友好的数据访问模式:希望按线性顺序访问,以便预取器帮助;希望避免指针追逐,以便预取器工作;希望最小化数据大小,以充分利用缓存行,不放入不需要的数据。同样,对于代码,希望减少分支,避免间接跳转,并最小化热点代码的大小,使循环高效。
此外,记住不同线程同时使用的数据,应将它们放在不同的缓存行中以避免伪共享。
遵循这些原则,你就迈出了编写缓存友好C++代码的第一步,编写能够真正利用CPU内存效应并可能快得多的代码。但务必进行基准测试,以确保你的优化确实是优化,而不是使情况更糟。
谢谢。

问答环节
问:你经常有在单线程和多线程之间切换的代码。遵循这种设计,似乎每次想要切换范式时都必须重新设计代码,而不是拥有一个可以在并行环境和单线程环境中调用的线程安全函数。你如何处理?是否可能编写一个函数使其在两种情况下都能工作?


答:问题只发生在修改数据时。如果你有一个修改数据的单线程函数,然后想并行化它但不改变数据,那么突然会出现伪共享。但通常你会改变数据。假设你有一个数字数组和一个修改它们的单线程函数,现在你想多线程化。你将数组分割成块并发送给线程,那么在块的边界处会有伪共享效应。但通常如果你这样做,你会有大量数据(如MB级别),边界区域很小,因此你仍然可以获得加速,而不必物理上分离数据。
问:这是一个信息密集的精彩演讲。你展示了一页幻灯片,显示两个不同程序之间有MB级别的性能差异(一个先求和再递增,另一个先递增再求和)。你自己也不完全确定原因,但有什么想法吗?可能是预测跨越缓存行导致缓存未命中,或者是分支预测错误由于推测执行而驱逐缓存行,或者是不同类型的缓存(如L0缓存)在工作。你知道有其他深入探讨缓存细微之处的演讲吗?
答:关于最后一点,我不完全了解任何深入探讨缓存细微之处的会议演讲。本演讲更倾向于介绍缓存的基础知识。关于性能差异,我最初预期可能是基本块布局完全不同,但事实并非如此,只是加法指令将其移动了一位,然后可能跨越了缓存行导致效应。这个基准测试差异发生在Apple M1上,在效率核心上效应表现不同。我会先查看L0缓存。
问:你能调出展示单个向量与两个向量(圆形和正方形)的幻灯片吗?在进行那个基准测试时,单个向量中圆形和正方形的分布是均匀混合的还是随机的?如果你对此进行分析,可能会发现分支目标缓冲未命中与L0缓存未命中的复合效应,而与缓存无关。
答:分布是随机的。我查看了汇编代码,如果你有单独的向量,向量化比中间有分支的情况要好得多。即使没有,如果你只是分组……是的,即使如此,你也会看到巨大的差异。所以在这种情况下,这不完全是关于缓存,但更广泛的点是这种内存布局更好,不一定是因为缓存。
问:很棒的报告。我有两个问题:你提到代码也是数据,因此长跳转会跳转到代码的不同区域。我的问题是,你对函数跳转到的位置有什么控制权?第二个问题是关于NUMA(非统一内存访问)的,它在你将对象复制到不同NUMA区域时是否有帮助?
答:关于布局控制,主要是如果你有一个热点循环,然后很少发生某些事情,你希望确保调用的非内联函数位于其他地方,主要是确保它不会污染缓存。反之,如果你关心要执行的代码,它最好在循环内,或者在极端情况下使用节属性。但关键是测量和观察。通常你无法控制,但如果循环中有许多函数调用,就会有效应。我对NUMA没有经验。
问:在你展示的幻灯片中(大约第28张),修改指针内容可能修改向量大小。这是因为编译器可能认为数据指针可能实际上指向向量本身的大小吗?还是因为短路径优化?
答:这与短路径优化无关。向量成员(如大小)存储在内存中的某个地方,优化器不知道data[i]不会越界访问到向量的内存,因此必须保守地假设它可能会。编译器会认为任何类型的指针访问都可能修改大小吗?是的,如果通过可能别名内存中任何内容的指针访问,优化器必须假设内存中的任何内容都可能已更改。
问:如果是指向任何类型的指针,也会有同样的问题吗?是的,如果通过可能合理别名内存中任何内容的指针访问,它必须假设内存中的任何内容都已更改。

时间到了。我有著名的L值R值袜子。如果你没有袜子,想要一些,可以购买L值R值袜子。😊

谢谢。




051:在GDB中调试C++协程


在本教程中,我们将学习如何在GDB中调试C++协程。我们将探讨当前调试器对协程的支持程度,展示可以完成的任务,并指出仍然存在的挑战。课程将包含一个具体的示例程序,并通过演示展示实际操作。
概述
调试C++协程在GDB中面临独特挑战。协程可以暂停和恢复,这引入了传统函数所没有的复杂性,例如检查暂停协程的状态和处理异步调用栈问题。本教程将引导你了解当前可用的调试功能、其局限性以及应对复杂问题的实用技巧。
协程调试的挑战
调试本身具有挑战性,C++协程增加了复杂性,而GDB也有其学习曲线。因此,在GDB中调试C++协程的难度是叠加的。
一个自2021年2月23日开放至今的GCC错误报告反映了早期的问题。报告者指出,虽然对C++20协程的实现感到兴奋,但被当前调试器中无法检查协程状态的问题所困扰。他引用了论文P2073,该论文确认了当时调试支持的缺失状态,但无法确定问题在于GCC、GDB还是两者皆有。
情况并非一直如此糟糕。自P2073论文发布以来,情况已大幅改善。当时,你无法检查局部变量、协程参数或承诺对象。现在,这些标准功能已能正常工作:
- 可以设置断点。
- 可以查看局部变量。
- 可以查看协程参数。
- 可以检查协程的承诺类型。
真正的挑战在于协程特有的、非典型函数的行为,主要有两点:
- 检查暂停的协程:协程可以暂停并从栈中移除,如何检查其状态仍有些棘手。
- 异步调用栈问题:每个协程都有自己的调用栈,并且可以随时暂停和恢复。在任意时刻,你可能想知道所有协程的调用栈在哪里。目前这是一个难题,虽然存在一些针对特定库或应用的解决方案,但缺乏通用的好方法。
示例问题:Knuth的协程问题
为了演示,我们使用一个经典问题来展示协程的独特价值。这个问题由Donald Knuth在《计算机程序设计艺术》中提出,用于阐明协程作为松散耦合、协作解决问题的函数的特性。
问题描述如下:编写一个程序,将一种代码翻译成另一种。输入代码是由句点终止的8位字符序列,例如 A2 B5 E.。输入中可能穿插任意空白字符(ASCII值小于等于0x20的字节),这些空白字符被忽略。
非空白字符按以下规则解释:如果下一个字符是十进制数字n(0-9),则表示其后的字符(无论是否为数字)重复n+1次。非数字字符直接表示自身。
程序输出由结果序列组成,每三个字符分为一组,直到遇到句点。最后一组可以少于三个字符。例如,输入 A2 B5 E. 应翻译为 A,然后是三个B(因为2 B表示三个B),接着是六个E(因为5 E表示六个E)。输出分组后为 ABB BBE EEE E.。
此外,输出行限制为16个这样的三字符组,组间用空格分隔,每行以换行符(\n,ASCII 0xA)结束。
Knuth的原始解决方案使用汇编语言,但我们可以用C++协程实现其思想。
C++协程解决方案设计
我们将问题分解为三个部分:
next_char函数:管理原始输入缓冲区的子程序(常规函数)。in协程:解析输入并为后续处理提供一个字符项。它是一个生成器,每次产生一个字符。out协程:格式化字符项并打印输出。它等待in协程依次提供字符。

in和out作为协程协同工作。in是生成器,out是消费者。
in协程
in是一个协程函数,其返回类型是一个特殊的类,称为返回对象(in_ro)。函数体内包含一个循环,调用next_char获取字符,并根据规则决定是直接产出字符,还是将其视为计数并产出后续字符相应次数。
out协程
out协程负责接收in产出的字符,并将其格式化为三字符组输出,同时跟踪每行的组数。它通过co_await等待in协程返回对象的值。
启动顺序
协程设计需要决定谁先运行。这里,我们让out立即运行,in初始为暂停状态。当out需要值时,它通过co_await恢复in。主程序只需调用out,当out完成时,整个解决方案就完成了。
编译器内部机制与调试信息
协程就像冰山,可见部分只是整体的一小部分。大量机制隐藏在承诺类型、等待器等数据结构中。当编译器处理协程源代码时,会进行复杂的转换,生成中间代码而非直接的C++源码,这使得理解运行时行为变得困难。
GCC提供了一个未公开的选项 -fdump-lang-cor,用于转储协程的中间表示(IR)。这有助于开发者(和高级用户)理解编译器对协程所做的转换。转储内容显示了协程帧类型、挂起点索引等信息。协程帧的前三个字段(协程恢复函数指针、协程销毁函数指针、承诺对象指针)可能成为未来跨编译器ABI标准的一部分。
GDB实战演示
现在,我们进入GDB实战环节,看看如何调试这个协程程序。
首先,我们编译程序并生成协程转储文件以供参考。然后,在GDB中运行程序。
我们设置一个断点在out协程的第一次co_await语句之前。此时,in协程应该处于初始暂停状态,不在调用栈上。
在GDB中,我们可以使用 info locals 和 info args 查看协程的局部变量和参数,这些功能与普通函数一样有效。
然而,要检查暂停的in协程,我们需要找到它的协程帧。in协程的返回对象中存储了协程句柄,该句柄包含一个指向协程帧的指针(_M_fr_ptr)。我们可以获取这个指针。
为了以可读格式查看协程帧,我们需要将其转换为正确的类型。这个类型可以从 -fdump-lang-cor 生成的转储文件中获得(例如 _Z4in_ro11frame_type)。在GDB中,我们可以使用 print *((_Z4in_ro11frame_type*) frame_ptr) 来查看暂停协程的帧状态,包括恢复函数、销毁函数、承诺对象等字段。
处理异步调用栈
对于异步调用栈问题,目前GDB没有像线程那样的内置协程支持。通用协程通过标准库实现,不容易追踪暂停协程与未来恢复点之间的连接。
一些库(如Folly)采用的方法是:在协程的承诺对象中维护特殊变量(通常称为“延续”)。在挂起点,协程可以获知或控制恢复后的目标,并将此信息存储在承诺中。通过这种方式,可以在调试时手动或通过自定义工具链遍历这些连接,重建异步调用链。
总结
本节课我们一起学习了在GDB中调试C++协程的当前状态。我们了解到,对于函数的标准调试操作(断点、查看变量)现在已能很好地支持协程。主要挑战在于协程特有的行为:检查暂停协程的状态和可视化异步调用栈。
我们通过一个经典的Knuth文本转换问题,演示了协程的设计与协作。在GDB演示中,我们展示了如何通过协程句柄和帧指针来检查一个不在栈上的暂停协程。最后,我们讨论了异步调用栈问题的现状,并指出通过自定义承诺数据来维护“延续”信息是当前可行的解决方案之一。



虽然GDB尚未提供原生的高级协程调试命令,但通过理解协程内部机制和利用现有工具,我们仍然能够有效地诊断和解决协程程序中的问题。
052:支持就地修改的序列化格式



在本教程中,我们将学习如何构建一个支持就地修改的高性能二进制序列化格式。我们将从基础概念开始,逐步探讨如何优化搜索和修改操作,最终实现一个性能接近内存数据结构的序列化方案。
序列化基础
序列化是指将数据结构转换为字节序列的过程,以便通过网络传输、持久化到文件或在进程间共享。反序列化则是将字节序列还原为原始数据结构的过程。
一个简单的数据结构示例如下:
struct Trade {
long timestamp;
double price;
unsigned long volume;
char symbol[8];
unsigned flags;
};
直接进行内存拷贝作为序列化方法存在多个问题。首先,字节序在不同平台上可能不同,可能是大端序或小端序。其次,数据类型的大小在不同平台上可能不同,例如 long 类型的大小可能不一致。最后,结构体成员之间可能存在填充字节,其数量和位置也因平台而异。
一个改进的版本是逐个处理每个成员:
void serialize(const Trade& trade, char* buffer) {
long net_timestamp = htonl(trade.timestamp);
memcpy(buffer, &net_timestamp, sizeof(net_timestamp));
buffer += sizeof(net_timestamp);
// ... 类似处理其他成员
}
这种方法减少了平台依赖,但仍然存在大量样板代码,发送方和接收方必须严格约定格式,难以支持模式演进,也不容易编码复杂的嵌套文档(如映射和数组)。
模式化与无模式化格式
为了改进序列化,我们可以采用两种主要方法:基于模式的格式和无模式格式。
在基于模式的格式中,接收方需要知道数据的形状才能理解它。模式通常使用某种领域特定语言定义。例如,使用 Protocol Buffers 定义 Trade 结构:
message Trade {
required int64 timestamp = 1;
required double price = 2;
required uint64 volume = 3;
required string symbol = 4;
required uint32 flags = 5;
}
DSL 通常用于生成代码(如 C++、Java、Python 类)。发送方和接收方在一定程度上需要就模式达成一致,但不必完全一致。例如,可以添加字段而不会破坏现有的接收方。在传输时,数据通常看起来像一系列标签和值对。标签包含字段编号,值则是变长编码的整数。这种格式在一定程度上是自描述的,可以在不完全了解模式的情况下遍历数据,但要理解数据,仍需更多信息。
如果无法预先知道数据的形状,或者需要处理任意复杂的文档(如映射嵌套在数组中,再嵌套在映射中),则需要转向无模式格式。
在无模式格式中,可以编码几乎任何内容,通常提供字典风格的 API,可以设置和获取字段等。无模式格式的例子包括 JSON、BSON、MessagePack、FlexBuffers 等。它们的共同点是通常将数据编码为一系列标签和值对。例如,一个文档在 JSON 和 MessagePack 中的编码如下:
- JSON:
{"price": 100, "volume": 5000} - MessagePack: 标签
0x84表示一个包含 4 个元素的映射,后跟表示字符串“price”的标签0xa5和值100,再跟表示字符串“volume”的标签0xa6和表示无符号 16 位整数的标签0xcd及值5000。
这些标签格式在编码空间利用上非常有创意。例如,小于 128 的整数直接存储为其自身值(高位未设置),而所有其他标签的高位均被设置,从而可以区分标签和整数值。它们还将 true 和 false 编码为单个标签。
构建支持就地修改的格式
使用无模式格式时,如果需要对消息进行读取、操作和更改,通常需要先将其解码为内存中的数据结构,进行更改,然后重新序列化为打包形式。如果要避免这种开销,例如需要从数据库中提取文档并进行小的针对性更改后写回,或者从流中接收消息并仅提取每个消息的单个字段,则可能希望无需每次重新序列化整个文档就能快速完成这些操作。
因此,尝试构建一个支持就地修改的无模式序列化格式会很有趣。接下来,我们将展示如何尝试实现这一点。
首先,我们需要构建一个基线版本,即一个非常简单的格式,仅将所有内容存储为一系列标签和值对,并以最简单的方式进行修改。目的是建立一个基准进行比较,并作为优化和改进的起点。
我们的基线序列化格式支持字符串、整数、浮点数、映射和数组。其编码方式是一系列标签和值对。标签编码类型和必要的大小。值采用小端序以兼容现代 CPU。映射编码为一系列键值对。数组仅编码为一系列值。接口设计如下:
class Value {
public:
// 访问器
int64_t as_int() const;
double as_double() const;
std::string_view as_string() const;
// 修改器
void set_int(int64_t value);
void set_double(double value);
void set_string(std::string_view value);
void make_array();
void make_map();
void append_to_array(const Value& value);
void set_key_in_map(std::string_view key, const Value& value);
// 子元素访问
Value get_key_in_map(std::string_view key) const;
Value get_value_at_index(size_t index) const;
};
为了实现常见操作,我们以最简单的方式实现读取。例如,对于一个文档,要获取 timestamp 字段,代码将遍历所有字段,直到找到匹配的键,然后将其作为整数返回给用户。这只是对标签和值的简单线性扫描。
要进行修改,我们将就地修改所有内容。例如,要修改 price 字段,我们遍历容器,找到 price,将其值替换为一个映射,然后开始将新的键值对插入到该映射中。每次插入时,缓冲区中的所有其他内容都必须移动,因此修改操作的开销很大。
性能基准测试
我们创建了一个简单的基准测试,参数化包括文档中的字段数量、连续执行的操作数量以及不同类型操作(读取、写入、结构修改)的百分比。结构修改是指向映射添加新键或删除键,而写入仅更改现有值。
基准测试环境为:GCC 13,-O2 -march=native,在负载较低的机器上运行。需要注意的是,所有基准测试都是微基准测试,在实际生产环境中可能表现完全不同。
基线格式的结果显示,在非常小的文档上每秒可进行约一百万次读取,但随着字段数量增加和写入操作增多,性能逐渐下降。对于字段数量最多的情况,性能可能比字段数量少时慢两个数量级,扩展性极差。这正是我们所期望的,因为它为我们提供了大量的改进空间。
我们还比较了将数据保持在映射中并定期重新序列化的成本。结果显示,如果允许在多次操作后才重新序列化,那么仅将数据保持在内存数据结构中并重新序列化仍然更快。
优化搜索性能
分析性能后发现,大部分时间花在检查容器中各个元素的大小上。在搜索特定键时,需要不断跳过元素,直到找到目标,这意味着需要不断检查每个元素的大小。
一个想法是将键按排序顺序存储。但即使键已排序,仍然无法进行二分查找,因为键和值的大小是可变的,很难在二分查找中跳转到特定元素。
为了解决这个问题,我们可以在映射的开头存储一个偏移量表。这样,我们不需要按排序顺序存储实际元素,只需要偏移量按排序顺序指向它们即可。这意味着我们不需要按排序顺序存储实际元素,只需要偏移量能按排序顺序定位它们。
进行此更改后,搜索性能有了显著提升。为了进一步优化,我们还可以在映射开头添加键的哈希值列表。这样,我们只需要搜索一个较小的 uint16_t 数组来查找哈希值,然后根据偏移量找到对应元素。当然,可能会发生哈希冲突,可能需要检查多个偏移量,但这仍然更快。添加哈希后,在较小的容器上性能提升了约 20%,在大型容器上提升更大,几乎翻倍。
我们进一步分析了搜索性能,发现二分查找中用于决定向上半部分还是下半部分移动的分支预测开销很大。由于每次搜索方向几乎不可预测,分支预测失败率很高。
我们可以使用条件移动指令来绕过分支预测,这种方法有时被称为无分支编程。其思想是,如果将条件逻辑简化为单个赋值语句,编译器更可能将其替换为条件移动指令。这样,循环中间就没有分支,只有条件移动。条件移动不进行预测,而是变成数据依赖。
实现无分支二分查找后,性能得到了进一步提升。我们还探索了其他搜索算法,如向量化计数线性搜索和向量化标准线性搜索。对于小型数组,向量化计数线性搜索表现很好,但随着元素数量增加,速度变慢。而无分支二分查找即使在非常大的数组大小下也表现一致,因此我们选择了它。
优化修改性能
在基线格式中,我们通过移动缓冲区中的所有内容来执行修改。引入偏移量后,修改操作变得有些问题。因为每次更新映射中的任何内容时,都需要遍历偏移量数组并更新所有偏移量。如果映射嵌套在其他映射内部,问题会更复杂。例如,更新深层嵌套的键可能会影响父映射中的偏移量,需要遍历所有父映射并更新偏移量。
为了避免这种复杂性,我们决定将元素移出线外存储。这样,偏移量可以指向缓冲区中的任何位置,本质上变成了小指针。现在,偏移量表达了嵌套关系,我们只需要处理指针。
有了指针,就需要解决如何为所有这些元素分配空间的问题。一个潜在的想法是实现一个碰撞分配器。碰撞分配器保持一个指向缓冲区中第一个空闲空间的指针。但这种方法会浪费大量空间,最终需要压缩整个缓冲区。
为了改进空间利用率,我们可以在容器中添加一个空闲列表。空闲列表贯穿容器中的所有空闲空间,并优先从其中分配。空闲列表分配器在容器中存储元素,告诉你该点有多少空闲空间。每个空闲列表条目还包括指向下一个条目的指针。
我们尝试了首次适应和最佳适应两种分配算法。首次适应从空闲列表中取第一个能满足分配的块。最佳适应则取能满足分配的最小块。在性能方面,首次适应开始时表现良好,但随着操作次数增加,性能变差。而最佳适应似乎随着操作次数增加而变快,这可能是因为许多容器在缓存中。在空间浪费方面,首次适应浪费了大量空间(约 40%),而最佳适应只浪费了约 16%。
分析空闲列表遍历算法时,发现大部分时间花在获取空闲空间大小上。我们尝试预取空闲列表条目,即在循环开始和结束时预取即将查看的条目。这样可以在检查空闲列表偏移量的同时并行预取,从而获得约 15% 的性能提升。但预取在整个代码库中并不总是有效,如果访问模式可预测,预取可能不会带来改进,甚至可能因额外指令而变慢。
最终性能对比
我们决定进行最终的基准测试,将优化后的格式与基线格式以及 unordered_map 进行对比。在 unordered_map 版本中,我们使用了两层嵌套的 unordered_map 来模拟容器的使用方式,叶节点值是 int、double、string 和 vector 的变体。我们使用了异构查找来加速,并使用了与我们的格式相同的哈希函数。我们还尝试了 Abseil 的 flat_hash_map,但在每个基准测试中都较慢,因此暂时坚持使用 unordered_map。
在只读工作负载中,优化后的格式每秒可进行约一千万次读取,与 unordered_map 相当。基线格式则慢一个数量级。随着文档中元素数量增加,优化格式仅略微变慢,而基线格式在元素数量较多时甚至不显示,因为它太慢了。
在混合操作工作负载(包含等量的写入、读取和结构修改)中,优化格式每秒可进行约 80 亿次操作。我们还想展示首次适应和最佳适应之间的差异。优化后的首次适应和最佳适应在字段数量较少时性能接近,但当文档中字段数量达到约 1000 时,性能下降很多,这可能是因为花费大量时间遍历空闲列表以寻找最佳适应块。
限制与未来工作
使用首次适应算法存在碎片化问题。使用最佳适应算法则在文档变大时速度变慢。我们正在寻找首次适应和最佳适应之间的折中方案,例如维护多个空闲列表,分别用于小、中、大项目。
由于添加了元数据,我们的格式在映射中的每个键值对上有 4 字节的开销,在数组中的每个元素上有 2 字节的开销。此外,我们的格式包含许多偏移量,因此如果读取不受信任的文件,很容易导致越界访问。目前,我们没有进行任何边界检查。

我们创建了一个相当快的无模式序列化格式,支持就地编辑,其性能与内存中的 unordered_map 相当。一个不令人惊讶的结论是,如果你在小型缓冲区中创建一个分配器,然后创建所有相同的内存中数据结构,基本上就能匹配内存中数据结构的性能。
从这项工作中得出的结论是,如果你试图提高性能,建议创建高度参数化的基准测试。通过参数化许多因素,可以发现代码在特定场景下的不当行为。还应该进行分析,尝试改进算法和数据结构。探索向量化、无分支代码和预取等有趣的技术。
未来的工作包括探索如何就地压缩缓冲区,通过控制缓冲区中的碎片化来实现更高的写入性能。我们正在研究垃圾收集器中的一些算法。另一个想法是构建一种没有偏移量的格式,在首次读取时创建索引以实现快速搜索。我们还希望探索压缩重复键和键片段以节省更多空间。
推荐资源

- 《现代硬件算法》一书,详细涵盖了所有这些内容。
- 《垃圾收集手册》,介绍了各种垃圾收集算法的实现。
- Chandler Carruth 在 CppCon 2017 上的演讲《Going Nowhere Faster》,详细解释了如何基准测试和分析。
- CppCon 上的性能与效率课程,非常有价值。
总结

在本教程中,我们一起学习了如何构建一个支持就地修改的高性能二进制序列化格式。我们从序列化基础开始,探讨了模式化与无模式化格式的差异,然后逐步构建并优化了一个基线格式。我们重点优化了搜索性能(通过排序、哈希和无分支二分查找)和修改性能(通过移出线外存储和空闲列表分配器)。最终,我们的格式在性能上接近内存中的 unordered_map,并支持灵活的就地修改。希望本教程能帮助你理解序列化格式的设计与优化思路。
053:及早捕获错误 - 使用静态分析验证C++契约


在本教程中,我们将学习如何利用C++26的契约特性,结合静态分析工具CodeQL和定理证明器Z3,在程序运行前发现潜在的契约违反错误。我们将从契约的基本概念讲起,逐步深入到静态分析的实现原理、评估结果以及未来的发展方向。
概述:C++26契约简介
上一节我们介绍了本教程的主题。本节中,我们来看看C++26契约是什么。
C++26引入的契约功能提供了一组新的语言关键字,允许开发者为函数指定契约。这为我们提供了一种在API边界编码预期行为的方式,包括函数被调用时和正常返回时的情况。
以下是契约的核心语法:
pre:用于指定函数的前置条件,即函数调用时必须满足的要求。post:用于指定函数的后置条件,即函数正常返回时可以预期的状态。contract_assert:用于在函数体内执行契约检查。

以下是一个简单的代码示例:
int divide(int a, int b) pre(b != 0) { return a / b; }
int abs(int x) post(r: r >= 0) { return x < 0 ? -x : x; }
在divide函数中,契约规定b不能为0,以避免除零错误。在abs函数中,后置条件使用r命名返回值,并规定返回值必须大于等于0。
契约的行为是高度可配置的,可以通过编译器标志在构建时或运行时决定是否进行检查,以及违反契约时的处理方式。

动机:为何需要静态分析契约?


上一节我们了解了契约的基本语法。本节中,我们来看看为什么需要对其进行静态分析。
一个很自然的问题是:能否仅基于代码本身,识别出哪些函数调用违反了契约?如果可行,我们希望在程序运行前就发现这类错误。

在测试阶段或CI/CD流程中发现这类错误,远比在运行时遭遇契约违反要好。有些契约违反可能隐藏在很少执行的代码路径中,或者运行时契约检查被关闭了。在这些情况下,静态分析仍然可以推理契约条件是否可能被满足。
虽然我们无法完全、全面地断言所有契约断言在运行时是真是假,因为布尔表达式可能依赖于仅在运行时可知的状态,但我们至少可以在部分情况下进行推理。如果能做到这一点,就有望提前捕获一些错误。
分析工具与方法:CodeQL与Z3
上一节我们探讨了静态分析契约的动机。本节中,我们来看看实现此目标的核心工具和方法。
静态分析的挑战与机遇
考虑一个简单的例子:
void format_hour(int hour) pre(hour >= 0 && hour < 24);
对于调用format_hour(1),我们可以轻易分析出它满足契约。对于调用format_hour(24),我们也能轻易分析出它违反了契约。
然而,现实世界要复杂得多。考虑以下情况:
int main(int argc, char* argv[]) {
int hour = std::stoi(argv[1]);
format_hour(hour); // 静态分析无法确定hour的值
}
仅从代码上下文,我们无法知道hour的值是否在有效范围内。这取决于运行时输入。


尽管如此,在调用前添加检查总是更好的做法:
if (hour >= 0 && hour < 24) {
format_hour(hour);
}
这样,静态分析工具就能看到这个检查,并可能进行不同的分类。
在开发过程中尽早发现错误成本更低。单元测试和模糊测试虽然有效,但只能覆盖手动选择或概率性探索的路径。静态分析擅长探索所有可达代码,是发现这类问题的合适工具。
我们的目标是找到那些通过充分的演绎推理可以推断出极有可能违反契约的特定位置,而不是追求形式化验证的完全正确性保证。
工具介绍:CodeQL
在GitHub,我们用于可定制静态分析的工具是CodeQL。CodeQL为C/C++等语言提供默认查询,涵盖SQL注入、缓冲区溢出等已知安全漏洞,也支持编码标准。

当编写CodeQL查询时,我们可以获得以下信息用于分析:
- 类型信息:程序中定义的所有类、结构体、变量类型、类成员等。
- 完整语法树:每个操作符及其操作数,以及
if条件、循环等程序结构。 - 控制流图:了解代码如何从特定条件可达,包括循环和函数提前退出点。
- 调用图:对于C++尤其重要,已解析所有函数调用点和运算符重载。

CodeQL的工作流程如下:
- 提取器观察代码库的构建过程,追踪编译标志和文件。
- 将所有关于代码的事实(表达式、语句等)聚合到一个数据库中。
- 开发者使用Q L查询语言编写查询,并链接标准库。
- 查询被编译成高度优化的形式,在数据库上高效运行并产生结果。
一个简单的CodeQL查询示例如下:
import cpp
from FunctionCall call, Function f
where call.getTarget() = f
select call, f
CodeQL不仅是一种查询语言,它非常丰富,支持定义谓词(逻辑语句)和面向对象编程,用于约束和操作数据集。
契约的静态分析难点
契约说明符(pre, post, contract_assert)的设计对静态分析提出了挑战。契约中的一个关键安全特性是,当在契约内部引用变量时,该变量会被隐式const化,以防止副作用。
然而,契约内部允许调用任意复杂度的函数,这可能导致内存分配等副作用,甚至未定义行为,使得静态分析变得困难。
尽管存在这些复杂性,我们相信并非所有契约都会使用这些灵活特性。我们的目标是探索那些不使用这些特性的情况,从而发现一些错误。
在开发静态分析时,通常有两种阵营:
- 严格正确性阵营:追求保证正确性,可以接受一定误报。
- 实用主义阵营:优先考虑低噪音,希望报告尽可能都是真实问题,可以接受漏报一些错误。
我们的方法倾向于后者。
引入定理证明器Z3
为了处理更复杂的契约条件(如参数间的依赖关系),我们引入了Z3。Z3是一个由微软开发的开源定理证明器(约束求解器),它使用SMT语言,能够快速求解逻辑约束。
以下是一个简单的SMT示例:
(declare-const x Int)
(declare-const y Int)
(assert (= (* x x) (* y 3)))
(check-sat)
(get-model)
Z3会输出理论可满足,并可能给出一个解,例如y = 3,x = -3。
你可能会问,CodeQL本身是逻辑查询语言,为何还需要Z3?原因有二:
- CodeQL无法动态“评估”代码中表达的约束。使用Z3,我们可以让CodeQL查询生成SMT语句,然后由Z3充当“求值”步骤。
- CodeQL和Z3解决不同问题。CodeQL是查询语言,给定约束,它查找所有解决方案。Z3则优化为找到一个解决方案或反例,速度更快。
整体分析流程
我们的整体分析流程如下:
- 使用CodeQL分析目标程序,构建数据库。
- 利用CodeQL的范围分析功能,推理函数调用点变量的可能取值范围。
- 使用CodeQL提取被调用函数的契约(前置条件)。
- 对于每个调用点,将变量范围信息和契约条件组合成一个SMT公式。
- 将SMT公式输入Z3,询问契约是否可能被违反(即寻找一个反例)。
- Z3返回结果,如果可能违反,则提供一个反例值。
我们主要支持以下几种分析:
- 整数范围分析
- 空指针分析
- 对象字段分析(有限支持)
本教程将重点介绍整数范围分析。
评估:在真实代码上的实践
上一节我们介绍了核心的分析工具和方法。本节中,我们来看看如何在实际代码库中评估这种方法。
一个现实挑战是:C++26契约语法尚未广泛使用,编译器支持有限,包括CodeQL使用的编译器前端。因此,我们需要一种方法来测试这种技术。
我们利用Bloomberg的BDE库来解决这个问题。BDE代码库中有一些约定与契约功能相似:
- 文档约定:函数注释中常见的“行为未定义,除非...”,这实际上是用人类语言描述的前置条件。
- 断言约定:在函数实现开头使用
BSLS_ASSERT系列宏来验证这些前置条件。这些宏的行为也可以通过构建标志配置。
对于评估,我们专注于从这些BSLS_ASSERT语句中推断契约。这带来了一个小的 logistical 挑战:在分析调用库函数的程序时,通常只有头文件,而没有.cpp文件。因此,库实现中的BSLS_ASSERT对分析不可见。
我们采用了第二种方案:对库进行一次性分析,总结其契约,并创建一个“规范数据库”,在分析调用该库的程序时提供这些契约信息。
评估设置与结果
我们选择BDE的日期功能作为评估目标,因为其契约可能主要涉及我们支持的简单算术和逻辑表达式。
我们通过内部元数据和源代码搜索,找到了约6000个调用BDE日期函数的项目。我们选取了其中的前1%(约60个)进行广度分析,旨在覆盖大量不同的函数调用。
在将契约限制为我们支持的语法后,我们能够处理日期库中约三分之一的函数契约,最终分析了约3000个调用点。
以下是总体结果(需注意许多注意事项,例如样本量较小、代码经过筛选等):
- 95%的调用:可被静态验证满足契约。
- 5%的调用:无法被验证。这并不意味着存在错误。大多数情况是因为CodeQL的范围分析无法推理这些值(例如,值可能依赖于运行时状态),因此返回了最大范围(如
INT_MIN到INT_MAX)。未来我们希望更好地自动分类这些情况。 - 平均性能:每个调用点的检查平均耗时约24毫秒(在配置一般的任务机器上),表明该方法具有可行性。
我们还通过故意注入错误来测试分析的有效性。例如,修改代码使传递给setSecond函数的秒数变为负数,我们的分析成功地将这些调用检测为契约违反。
未来方向与总结

上一节我们展示了初步评估的结果。本节中,我们来看看未来的改进计划和本教程的总结。
改进误报与范围分析
我们识别出导致最多误报的两种情况:
- 无约束的范围:当CodeQL的范围分析无法给出任何范围时,会返回
INT_MIN到INT_MAX。例如,缺乏过程间分析时,对函数参数的约束可能无法传递。 - 范围分析的置信度:CodeQL通过将程序转换为静态单赋值形式来进行范围分析。我们为范围分析添加了“来源”追踪,以区分不同置信度的结果:
- 低置信度:例如,仅基于类型得出的最大范围。
- 中置信度:例如,通过条件约束得到的范围(如
x < 10)。 - 受循环影响:在循环分析中,为了性能会进行“拓宽”操作,这会略微降低结果的置信度。
未来工作
我们的代码现已开源(GitHub链接),但请注意这是一个概念验证版本,尚未达到生产就绪水平。我们鼓励社区试用、报告问题或提交拉取请求。
我们目前正在探索一个更有前景的方向:直接将程序的SSA形式编译成SMT公式。由于SSA形式本质上是将可变变量转换为不可变常量,这与Z3的常量模型非常契合。这种方法可能让Z3为我们执行更高级的范围分析,例如:
- 理解中间不可取的值范围。
- 识别参数间的关联范围(如
x > y)。 - 通过内联函数调用来更好地追踪变量状态。
总结与建议
本节课中我们一起学习了如何使用静态分析来验证C++契约。
本教程的核心要点是:请编写契约。
即使你编写的契约使用了复杂的运行时断言,你也已经能从单元测试、模糊测试和生产环境(如果开启)中获得巨大价值。除此之外,我们希望将你的契约视为对我们工具能力的挑战。工具只会越来越好。我们目前正在探索的想法可以将其推向更远。
静态分析是发现潜在契约违反的强大工具,与运行时检查、测试相结合,可以构建更健壮、更安全的软件系统。

注:本教程内容基于CppCon 2025演讲“及早捕获错误:使用静态分析验证C++契约”,由Peter Martin和Mike Fairhurst分享。所有代码示例和概念归演讲者及相关项目所有。
054:工程师也是用户 - 基础设施设计思维案例研究




在本节课中,我们将学习如何将用户体验设计思维应用于基础设施工具的开发。我们将通过一个来自彭博社基础设施团队的真实案例,了解如何通过用户访谈、原型设计和数据综合等方法,显著改善开发者体验,并提升工程师的工作效率与满意度。
概述
本次分享由彭博社基础设施团队的软件工程师 Grace Alllin 带来。她将探讨一个看似不寻常的组合:基础设施与设计思维。核心论点是:基础设施工具与开发者体验紧密相连,而开发者体验本质上就是一种用户体验,因为工程师也是用户。尽管我们都知道开发者体验很重要,但我们很少将其视为一个可以通过传统UX技术来解决的设计问题。本教程的目标是引导你了解如何使用UX技术来彻底改善开发者体验,如何应用这些技术,以及作为一名工程师,掌握这些技术将如何提升你的职业生涯。

从计算机科学到人机交互
上一节我们介绍了基础设施与设计思维结合的基本理念。本节中,我们来看看演讲者如何将计算机科学与设计工作融合。
演讲者 Grace 在斯坦福大学学习计算机科学,同时也爱上了将计算机科学与设计工作结合起来的领域,即人机交互。HCI 本质上是研究人们如何与技术交互的心理学,它包含了用户体验作为一个领域。这适用于所有类型的界面,包括用户界面、应用程序接口、物理产品、命令行界面或错误消息。任何人与之交互的事物都是一个界面,这不仅仅是制作漂亮的屏幕。
在她的课程中,她向HCI领域的先驱们学习,并亲身接触他们的研究。她的课程作业非常酷,不同于典型的计算机科学编码作业。她们会完成整个产品生命周期:首先识别问题,出去与人交谈,制作原型,测试这些原型,然后在一个学期内将它们编写成代码。这让她感到非常有趣,并爱上了这个过程。
这也是一个定义非常完善的框架,是每个设计咨询公司、初创公司以及任何进行整个产品生命周期工作的人的行业标准。毕业时,她拥有双重身份:她热爱实现方面的工作,肯定想成为一名工程师,同时也热爱产品的用户体验设计和用户体验研究。在大多数公司,这通常是两个独立的角色,但她认为,就像在学校一样,如果你参与了整个产品生命周期,这些技术可以结合起来并一起实践。
同样,由于用户体验适用于所有类型的技术,她认为如果她从事一份标准的软件工程工作,她可以将设计融入她的日常工作中。
在基础设施团队中寻找契合点
上一节我们了解了演讲者的学术背景和双重兴趣。本节中,我们来看看她如何在彭博社的基础设施团队中找到应用这些技能的机会。
彭博社为新毕业生做了一件非常有趣的事情:基本上有一个招聘会。团队都设立好展位,新毕业生和团队互相交谈,试图找到匹配。因此,这更像是快速约会。可以想象,她四处走动,与所有团队交谈,滔滔不绝地谈论她多么热爱用户体验和设计,以及她如何希望将设计融入她的工作。
这对大多数工程团队来说可能相当令人惊讶。大多数团队习惯于有独立的用户体验设计师来帮助他们解决用户体验问题,或者他们可能根本没有将这方面作为工作的重点。因此,当她提到用户体验研究过程或设计思维时,那可能只是模糊熟悉的东西。
然而,在她最意想不到的地方——一个处理集群管理的基础设施团队——那些人立即对她所谈论的内容感到兴奋和感兴趣,并说你应该加入我们。所以,这是一次匹配。
她承认,基础设施和用户体验之间的这种匹配并不明显,因为本能和大多数公司倾向于做的是将宝贵的用户体验资源投入到面向客户的产品上,那些赚钱的产品。而真正忽视开发者工具的体验。毕竟,这些开发者工具的用户是工程师,我们很聪明,善于解决问题,我们可能就能搞定。但是,请举手,谁没有在帮助或投入大量时间的情况下就无法搞定某件事?是的,视频里,我看到很多人举手。我们都曾感受过这种开发者体验的痛苦。
随着公司的发展,基础设施的复杂性只会不断增长,那种认知负荷和痛苦只会不断增长。因此,如果你忽视了开发者工具的用户体验,作为一家公司,你将经历时间损失、工程师挫败感、工程速度减慢,甚至可能导致职业倦怠。老实说,这对我们所有人来说都不足为奇,对吧?我们都刚刚举了手。我们以前都感受过这种痛苦。
我们知道拥有良好的开发者体验很重要。但并非总能意识到的是,开发者体验可以作为一个设计问题来构建,并使用那些改变了消费软件面貌的相同用户体验技术来解决。正是这个框架和过程,你可能没有太多实践。
你可能在日常工作中会做一些这样的事情,比如梳理用户旅程、解析日志以查看人们如何使用你的产品,甚至仔细命名端点以确保可用性。这都是其中的一部分。但她希望你今天能带走的是:有一个过程可以让你保持条理,它是可重复的,并且成为行业标准是有原因的,因为它经过测试并能产生良好的结果。
今天我们将通过一个案例研究来学习这个,因为在她职业生涯的第一个项目中,她使用了这个用户体验框架,并带来了一些非常积极的结果,不仅在项目目标方面,也在她的职业生涯方面。因此,她希望你今天能带走这两方面的收获。
案例研究:集群管理问题
上一节我们探讨了在基础设施中应用设计思维的必要性。本节中,我们来看看演讲者在其基础设施团队中遇到的具体问题。
在她新加入基础设施团队的第一周,她正在了解团队的工作,并试图理解公司工程师如何设置他们的产品和服务。在彭博社,你将代码部署到内部机器上。要设置这些机器,首先你有一个父集群。这在行业中很常见,父集群包含你的产品或服务可能需要的所有东西。然后你在该父集群内有集群,例如开发、测试和生产环境。然后在那些集群内启动主机。
但是要设置这些集群,需要很多工具。首先,你必须去一个界面设置你的父集群,填写一堆配置,然后点击提交。然后你必须为你想要的每个集群都这样做。所以开发、测试、生产,你已经完成了四个步骤。然后你必须去另一个界面,为你想要的每个集群执行另一个添加额外元数据的步骤。
这里有很多配置。几乎超过一半的属性,有些没有文档记录,有些已弃用,有些据说是可以忽略的,尽管没有人能百分之百确定告诉我哪些可以忽略。90%的用户对默认配置很满意,他们不必费力处理所有这些,但我们仍然让他们这样做。
这导致了碎片化的系统,需要去两个不同的界面。这是一个容易出错的过程,只会导致挫败感。
那么,当你面对这样的问题时,你通常会怎么做?你会与你的主题专家交谈,编写一份设计文档,并根据你的最佳猜测来决定做什么。但她的团队领导记得她对用户体验的热情,说:“Grace,我们为什么不在这里尝试使用用户体验框架呢?”
用户体验设计框架
上一节我们明确了要解决的问题。本节中,我们来看看解决这个问题的核心方法论:用户体验设计框架。
典型的用户体验框架是这样的:与用户共情,定义你的问题空间、所有背景以及你对用户的了解,构思、头脑风暴,制作原型,然后测试这些原型,然后迭代再迭代。这个过程不一定是线性的,尤其是在迭代时,你会跳来跳去。比如说,我刚刚测试了一个原型,我对用户有了更多了解,我会跳回到共情步骤。一旦我对用户有了更多了解,我会在定义步骤中添加更多背景信息。你只需不断迭代,直到对结果满意为止。
这个用户体验框架被如此广泛地使用并成为行业标准,是因为它做了几件事。首先,它使你条理化,并帮助你拥有一个框架来应对巨大的问题。它迫使你将与用户共情作为工作的首要任务。它还促进测试和迭代,而不是仅仅依靠第一直觉。
另一个额外的好处是,它带来了一个记录非常完善的过程,因为你会在整个过程中记录一切。当人们质疑为什么做出某个设计决策时,或者当人们想要重复你的过程时,这会派上用场。
当她向团队展示这个过程以及她将要制作的所有原型和文档时,团队实际上对这种解决问题的新方法感到非常兴奋。这是一个完整的工程团队。听到这个她很开心,因为她承认对整个事情有点担忧,因为这是一个全是工程师的团队,而这并不是工程师日常的典型工作。她希望确保这项工作会受到重视,并且如果她在这里花费时间,这将被视为创造价值和有用的东西。
定义问题空间与目标
上一节我们介绍了设计思维框架。本节中,我们开始应用这个框架的第一步:定义问题空间。
我们已经通过查看现有的两个界面进行了一些共情。所以我们已经感觉到有些事情可以改进。
现在是时候定义我们的问题空间,并真正清晰地陈述我们的目标、假设和一些开放性问题了。
目标:
我们想要简化集群管理,以防止错误、混淆和挫败感。
假设:
- 我们可以通过用户界面或应用程序接口将所有内容集中到一个地方,这样人们就不必去那么多不同的界面。
- 如果配置可以从父集群继承到子集群,用户就不必填写那么多东西。
- 如果我们的默认值可以更智能,我们就能帮助那90%的用例不必填写那么多配置细节,甚至不必担心是否选择了正确的东西。
开放性问题:
- 当前流程中哪些地方令人困惑?
- 我们向用户提出了哪些他们不理解的问题?
- 我们制作的原型是否能解决之前碎片化系统的痛点?
她记录了所有这些,并在开始任何工作之前向团队做了展示。请注意,通过列出所有内容,我们现在达成了共识。这也向我们的利益相关者证明我们是有条理的、以研究为导向的,并且对整个项目非常深思熟虑。
进行用户访谈
上一节我们明确了目标和假设。本节中,我们进入框架的关键步骤:进行用户访谈。
首先,我们出去采访人们。这是用户体验框架中非常关键的一步,可以说是最重要的步骤之一。因为非常重要的是,你不能假设你对你的产品或产品的用户体验有任何了解。你必须与用户交谈。你还必须倾听他们说什么、怎么说,并观察他们如何与你的产品互动,而不是仅仅问他们“你喜欢这个吗”、“需要改进什么”。因为访谈是关于真正深入了解产品体验的。
因此,她采访了工程师,也就是她团队产品的用户,她进行了30次访谈,这很多。这意味着有时她的日程安排看起来像这样,她意识到这对工程师来说并不正常。尽管这偶尔会让人筋疲力尽,并且确实占用大量时间,但这非常非常重要,因为正如她之前所说,这是关于观察人们与你的产品互动时做了什么,而不仅仅是问他们是否喜欢。因为如果你问,你会得到非常表面的答案,甚至可能只是“是”或“否”。
那么,如何为这些访谈做准备呢?你必须真正用心构思正确的问题。因为你想问开放式的问题,引出故事,真正深入挖掘使用某物的体验,而不是询问设计决策或是否问题。你是在寻找数据来为你的设计决策提供信息,而不是为你的设计问题寻找答案。
因此,与其问“今天集群创建有什么问题?”、“你喜欢这个新设计吗?”、“这种创建集群的方法不是更好吗?”(这个真的很疯狂,永远不要这样做)、“这个错误信息清楚吗?”、“按这个按钮来做某事,你觉得怎么样?”,她反而会问:
- 你能给我展示一下你今天是如何创建集群的吗?
- 告诉我你上次运行那个命令的情况。
- 你在那里犹豫什么?
- 你认为这个按钮是做什么的?
- 当你看到那个错误时,你感觉如何?
请注意这些问题有多么不同。作为受访者,我立即被置于不同的心态中,我在反思,我要讲故事,而不仅仅是说“是,我喜欢这个”或“不,我不喜欢”。
设计师在进行访谈时使用的另一种技术叫做“出声思考法”,即要求人们叙述他们使用某物的体验。她经常告诉人们把想到的任何东西都“大脑倾倒”出来,就像字面上任何进入他们意识流的东西。让我们举个例子。
如果她在叙述这次会议的日程安排应用程序(是读作 schedule 吗?),她会说:“我要看这里。我看到演讲列表。我看到颜色。我想知道颜色是什么意思,但我看到侧边栏上有轨道和相应的颜色。所以我认为颜色表示轨道。我对这个按钮的作用有点困惑。它看起来像一个选择,所以我要点击它。哦。它被添加到我的日程中了。好吧。我没想到会这样。它看起来像一个选择,我以为我可以选择多个演讲,然后可能会弹出一个菜单,我可以对所有选中的演讲一起执行操作,但我很欣赏它给了我即时反馈,告诉我它已被添加到我的日程中,所以我没有困惑很久,这让我很欣赏。”
你听到了吗?这与仅仅问我是否喜欢这个应用程序完全不同。首先,你看到了我如何建立起将轨道与颜色联系起来的心理模型。然后你也听到并看到了当按钮的行为与我的预期不同时我的惊讶。
她说“听到和看到”,希望你们注意到了,因为通过肢体语言看到的可能比通过语言听到的更重要,因为我们的大脑实际上是通过身体反应,然后才能说出来。所以当她站在这里叙述时,你们可能看到我皱起了眉头,身体向后靠,因为我感到惊讶。作为采访者,她正在疯狂地记下这些。这非常重要。如果有人没有告诉我他们给出的那些肢体语言暗示,我会问他们。所以如果有人叹气或皱眉,我会说:“为什么拉长着脸或大声叹气?”这比他们告诉我的更有信息量。
回到她的项目,当我们要求人们带我们了解他们今天如何创建集群时,我们听到了什么?她听到:
- 新用户没有帮助就无法创建集群。
- 我六个月前创建了一个集群,我以为我可以再做一次,但我不能。
- 有很多人问我,这个是做什么的?那个是做什么的?这是什么意思?
- 她听到了很多关于错误的抱怨。
同样有趣的是,几乎她交谈过的每个团队都开发了自己的关于如何完成整个流程的文档。这立即向她表明,工具没有充分教会他们,因为一个好的工具应该是自解释的。
这些收获并不是她访谈中唯一有趣和有见地的内容。工程师们喜欢被采访,她的参与度直线上升。真的。他们问她如何学会做这个。他们询问了这个过程。当她疯狂打字时,他们带着好奇的微笑看着她,这很可爱。他们甚至要求随时了解项目的状态,以及我是否会对我们产品的其他部分也这样做。
她只想在这里暂停一下,因为人们要求随时了解一个他们甚至没有参与的项目,这有多疯狂?这在工程中并不常见。我们是如何做到这一点的?正是通过访谈和倾听所培养的人际联系,让用户感觉他们对这项工作有既得利益。
这种兴趣和人际联系也导致了她和受访者之间建立了巨大的信任,这将为未来的更好合作铺平道路。她还要指出,访谈是一个巨大的职业可见性胜利,仅仅因为她与这么多人交谈过。她的职业网络以巨大的方式增长,人们变得很乐意在产品或用户体验问题上听取她的反馈。
制作原型
上一节我们学习了如何通过访谈收集宝贵的用户洞察。本节中,我们来看看如何将这些洞察转化为具体的设计方案:制作原型。
原型设计是用户体验设计的另一个重要部分,它非常重要,因为正如我们所知,文字只能走这么远,原型对于传达新想法至关重要。看看阅读一份10页的设计文档与能够与模拟界面、模拟屏幕或模拟服务器交互之间的区别。一个是抽象的,你必须做一些思维体操来思考这将如何在实际中呈现、感受和实践。另一个则立即清晰,用户可以设身处地地实际使用产品,他们可以给出更有意义的反馈,根据她的经验,当人们看到原型时,实际上会激发他们自己的思维,所以你会得到更多来自利益相关者、队友或受访者的想法。
这里的另一个好处是,如果你制作原型,你将在不编写任何代码的情况下,就某个实现获得具体的反馈。在将开发时间浪费在文档形式上可能已经获得“LGTM”的东西之前。
那么,让她猜猜你们中的一些人在想什么。这一切都很好,我知道原型很重要,但你到底怎么知道要设计什么?我们不是设计师。我们是工程师。这是某些人的工作,而发挥创造力真的很可怕。
她听到了。但她在这里要告诉你,设计本能是100%可以学习的。它们实际上是建立在多年研究基础上的。这个研究点实际上非常有趣,这是她在学校时爱上用户体验领域的原因之一。所以她实际上想和你们分享她最喜欢的一篇论文。这很快。它叫做“表面计算的用户定义手势”,发表于2009年。
在这项研究中,用户坐在一张模拟表面计算设备的桌子前(这是在平板电脑、iPad之前)。桌子上方有一个摄像头,捕捉桌子和他们的手。研究人员问参与者:“你可能会如何导航?你可能会如何缩放?你可能会如何删除文件?”他们捕捉了数小时的视频片段,记录了用户用手做了什么。那些访谈数据、反应、肢体语言的情绪。他们将所有这些汇总成模式,最终得出一些启发式方法和指导原则,这些将为我们今天使用的所有触摸屏手势提供信息。这就是我们如何得到“捏合缩放”的。这不是很酷吗?她觉得这太神奇了。
说到这些启发式方法,用户体验研究中最重要的一项学习实际上是这10条可用性启发式方法。她今天实际上要全部讲一遍,因为它们对于学习我们试图学习的设计本能是如此核心。实际上,其中很多对你来说已经感觉很熟悉了,因为即使你不知道这些名字,你也感受过这些感觉,因为我们都与好的或坏的设计互动过。
以下是这10条可用性启发式方法:
- 状态可见性:了解我在表单中的位置。我能否通过端点查询我的请求状态?
- 系统与现实世界的匹配:我们希望使用领域对齐的语言和通用语言,这样人们在使用你的系统时就不必学习新的术语。
- 用户控制与自由:用户会犯很多错误。他们也可能处于探索心态,只是随意点击、随意调用。因此,我们需要为用户提供清晰的前进、后退和紧急退出机制,以及试运行调用和回滚调用。
- 一致性与标准:用户不应该怀疑在你的界面中,相同的术语、错误信息或操作是否意味着相同的事情。它们应该一致。
- 防错:好的设计首先防止错误发生。所以这涉及到验证、强类型,或者对破坏性操作设置防护栏。
- 识别胜于回忆:这回到了认知负荷。我们希望使信息易于查看或查询,这样用户就不必记住事情。
- 灵活性与效率:每个系统都会有高级用户和新手用户,我们希望迎合两者。因此,对于那些高级用户,我们会给他们快捷方式、可配置性,并且我们会向新手用户隐藏这些功能,直到他们准备好。
- 审美与简约设计:界面不应包含无关、不需要或多余的信息。
- 帮助用户识别、诊断并从错误中恢复:这是关于好的错误信息。错误信息应该具有描述性和可操作性,以便用户可以自助。
- 帮助与文档:我们应该到处都有文档,这样人们可以自己了解系统,再次自助导航和解决问题。
好了,这些都很好。我们现在知道了我们的设计本能,但发挥创造力仍然很难。那么如何开始制作原型呢?关键是从低保真度的想法开始,这些想法应该非常非常混乱。而且你希望有很多这样的想法。一旦你以混乱的方式探索了很多想法,你就可以慢慢提升到感觉更真实的东西。那将是你的高保真原型。
你不想一开始就尝试高保真,因为这不仅从一开始就制作完美的东西非常令人生畏,而且可能是不可能的。而且你也会把自己局限在一个单一的想法中,而在一开始你真的应该探索很多想法,这就是低保真原型设计的作用所在。
实际上有一个非常有趣的活动用于生成低保真想法,叫做“疯狂八分钟草图法”。这通常用于用户界面,但你可以在这里发挥创意。具体做法是:你拿一张纸,对折,再对折,再对折,直到你有八个象限。你每分钟在每个象限上画一个完全不同的想法,它们应该非常混乱,超级混乱,甚至比这些更混乱(这是Cha GPT能做到的最混乱的程度了)。这里“疯狂”的部分,为什么叫“疯狂八分钟”,一是因为你疯狂地画草图,试图在一分钟内完成一个想法。但也因为你可以使用这些愚蠢的约束来尝试打开你的思维,它们是故意愚蠢的,因为它真的能激发创造力。例如,约束可以是:“如果我希望我的用户一键完成某事呢?”“如果我有无限资源呢?”“如果我出于某种原因希望这花费超过20分钟呢?”

然后,这个过程的神奇之处在于数量,因为如果你和一个五人团队一起做,突然在不到10分钟内,你有了40个想法,这太棒了。她正是这样做的。她和她的团队一起进行了这个活动。这对他们中的许多人来说是第一次做这样的事情,真的很可爱。然后我们有了40个想法,我们把它们放在一起,看到了共同点,并收敛到一个我们认为可以开始的好地方。
从那里,我们开始构建我们的高保真原型。对于高保真原型,她使用了设计师用来制作原型的相同工具,因为她认为在没有代码的情况下获得几乎真实的观感和感觉非常重要。高保真工具允许你连接点击事件和动画,真正模拟界面的确切风格(当然,这是针对用户界面的)。无需任何代码,且时间显著减少。
现在,回想一下当她最初向团队提出这个用户体验框架时,她对整个事情有点担忧。这次也不例外。她在原型上花了很多时间,她确实时不时地担心成为工程团队中的“设计女孩”。她希望确保自己正在发展正确的技能,并建立她想要强化的正确的个人品牌。
但是,当她的用户在访谈中与她的原型互动时,一切都值得了,因为就像那些访谈一样,工程师们喜欢有原型。他们非常兴奋可以点击浏览。她可以看出他们比典型的设计评审有更多的乐趣。他们喜欢可以点击。他们问她关于软件的事。他们问她如何学会做这个。她也知道,如果她没有这些真正高保真的原型可用,她得到的反馈是不可能的。
因此,用户的反馈以及来自团队和管理的认可,认为这是项目的一个很好的部分,足以让她对整个事情感觉良好,并意识到这可以成为她擅长的领域。而且,她现在很高兴地报告,她的整个团队现在为任何项目都制作模型。我们都在集群管理领域,我们都制作模型。谢谢。是的,我们现在为每个项目都制作模型,因为市面上有各种各样的工具,学习曲线各不相同。所以任何人都可以开始。你甚至可以从铅笔和纸开始,然后把它们放进你的设计文档中。这比仅仅用文字要好。
回到她的项目,当我们展示这些原型时,我们听到了什么?她听到了很多积极的东西,比如:“这很流畅。”或者“我很快就完成了,因为这很熟悉。”“这很简单明了,我根本不需要思考。”这听起来像是低认知负荷。她也看到了很多微笑,这让她很开心。
她也听到了很多需要改进的地方。她实现了一个相当复杂的表单,因为设置基础设施需要所有配置,所以导航必须调整几次才能达到清晰的效果,而且人们一次看到所有选项也有些不知所措,所以她正在努力实现一些不那么令人不知所措、对每个人来说都立即流畅和清晰的东西。
综合数据与得出结论
上一节我们通过原型设计将想法具体化并收集了反馈。本节中,我们进入框架的下一步:综合所有数据并得出结论。
最后,是时候综合我们的数据了。我们又回到了定义步骤,因为我们已经收集了所有数据,我们可以将其吸收为新的背景信息。
经过那30个小时的访谈,她有一大堆数据,你可以想象。那么,你如何开始理解这些呢?用户体验的答案是叫做“亲和图”的东西。
具体做法是:获取每个原子数据片段(可以是一句引述、一个单一的故事、一种单一的情绪),你把它们放在便利贴上。她数字化地做了这个,并把每个用户用不同的颜色表示。然后你开始根据共同的主题对便利贴进行分组。这些主题可以是共同的挫折点、界面的某个部分,或者只是一种共同的情绪。如果你觉得可以,再做第二轮,你可以做任意多轮,直到你从研究中得出核心主题。
在这里,她用黑色便利贴表示她的主要收获。突然之间,她从数百个数据点变成了仅仅几个主要收获和主题,这很棒。这里的另一个好处是,每个主题都附有导致该决定的所有引述和故事。这对于能够回顾并看到你为什么做这些事情真的很有帮助。
那么,我们学到了什么?我们验证了我们的假设,即当前流程去那么多不同的系统是令人沮丧和困惑的,我们需要自动化更多并提供更多默认值,以便用户可以使用标准配置。我们发现,路径导航和在导航中的信心非常重要。我们还需要确保人们对他们的选择有信心。所以,在整个原型设计过程中,需要调整以帮助用户对整个过程充满信心。
我们还了解了用户想要和不想要的工作流程。我们听到人们想要克隆集群的能力,他们希望自动填充,他们希望为每个团队提供可配置的模板。我们还听到用户从不同时编辑和添加集群,所以我们不需要用一个系统承载太多东西。当然,对文档有强烈、强烈的需求。人们对这个过程真的很困惑,因为基础设施很复杂,而且通常直到你进入职场才会学到。因此,在我们的系统中分散提供文档将帮助人们学习。
这里的收获是:能够通过数据得出这样的结论,而不是仅仅依靠传闻知识、先前经验或传说,这正是用户体验框架带给你的。同样独特的是,看到微笑、沮丧或垂头丧气的肩膀如何被量化为数据,让你得出这样的决定。而且,如果你展示来自人类体验的数据来驱动你的设计决策,对你的利益相关者来说也更有说服力和影响力。
成果文档与职业影响
上一节我们通过数据综合得出了关键洞察。本节中,我们来看看如何将整个过程整理成成果文档,并探讨其对职业生涯的影响。
最后,我们到了成果文档,这是我们用户体验框架的压轴戏。这是一种全面的方式,向我们队友、利益相关者以及所有希望随时了解情况的受访者传达我们所做的一切。
我们将包括以下内容:
- 我们的问题和背景,并真正清晰地定义我们着手解决的挑战。
- 我们的方法:我们和谁谈过?我们如何进行访谈?问题是什么?我们如何制作原型?
- 我们的发现:所有主要主题,以及导致这些发现的引述和故事。
- 我们所有的设计迭代以及每个设计决策的理由。
- 最后,关于如何实施项目的建议。
她还喜欢附上一个附录,包含所有亲和图的截图、所有原始访谈数据、早期原型、疯狂八分钟草图,因为这描绘了一幅所有已完成工作的美好图景,并显示了你有多么深思熟虑。
她也很高兴地报告,这份文档不仅仅是一份漂亮的总结。对她来说,自项目结束以来,它一直存在(她一年多前做的这个项目),至今仍被引用。因为它为未来的项目提供了灵感,也是如何开展未来项目的灵感。其他团队也请她帮助在他们自己的工作流程中实施这个过程,这真的很好,因为这意味着用户体验实践正在彭博社的基础设施组织中传播,这太棒了。
现在,我们到了我们的收获。为什么这行得通?
- 设计思维框架给了她过程和条理性,以应对这个技术性强且模糊的问题空间。
- 访谈给了她共情和证据,以此做出设计决策。
- 原型激发了创造力,并鼓励所有利益相关者早期达成一致。
- 综合给了你主题和方向。
- 成果文档给了你可信度,并便于回顾你所做的事情。
所有这一切改变了她的职业生涯。她绝对被认为是拥有出色产品和用户体验本能的工程师。这已经成为一种超能力,不仅改善了她的工程工作,也是她可以指导和支持其他团队的领域,能够在职业生涯早期做到这一点真是太棒了。这也是她可以用来发展个人品牌的领域。
因此,她对你的挑战是:你不需要成为设计师就可以开始使用这些策略。你可以从小处着手,采访一位队友关于他们的工作流程。你可以用铅笔和纸画一个原型草图,并把它们放进你的设计文档中。你可以将你不常展现的个性元素带入你的日常工作,比如你的访谈技巧、你的共情能力、你的倾听能力或你的健谈。你只需要不断重复、重复、再重复。那就是迭代。又是设计思维。因为随着你不断重复这个过程,你会变得越来越好,达到一定水平,届时你将看到职业回报。
任何人都可以做到这一点。任何技术栈级别的人都可以做到,因为无论界面是什么,我们发布的每个界面都会对某人产生一种体验。因此,我们应该像编写代码一样精心设计这种体验。
总结


在本节课中,我们一起学习了如何将用户体验设计思维应用于基础设施工具的开发和改进。我们通过一个具体的案例,详细了解了设计思维框架的各个步骤:从与用户共情和定义问题,到通过访谈收集洞察、制作高低保真原型进行测试,再到综合数据得出结论并形成成果文档。这个过程不仅能够显著提升开发者体验,减少错误和挫败感,还能增强工程师之间的协作与信任,并为实践者带来职业上的可见度和个人品牌的提升。记住,工程师也是用户,精心设计他们所使用的工具,就是投资于整个组织的效率与创新。
055:2025年Visual Studio为C++开发者带来的新功能


概述
在本节课中,我们将学习Visual Studio 2025为C++开发者带来的主要新功能和改进。课程内容涵盖从代码编辑、库管理、项目构建到调试诊断和源代码控制的完整开发流程。
主要公告
我们首先介绍一些重要的公告。
Visual Studio 2026预览版现已发布。我们强烈建议您尝试使用。它可以与VS 2022并排安装。
您的反馈对我们至关重要。无论是通过调查问卷、在展台交流,还是提交开发者社区工单,我们都非常重视。
在过去12个月中,C++团队处理了近400个问题,并完成了近30个功能请求。在整个Visual Studio范围内,我们修复了近4500个问题,完成了近300个功能请求。这涵盖了从其他语言到编辑器、调试器等核心Visual Studio功能的所有方面。
再次强调,填写我们的调查问卷是您向我们提供反馈的机会。


Visual Studio 2026拥有全新的外观和感觉。我们提供了全新的UI和多种不同的主题。您将在今天的演示中看到部分内容。
设置页面已完全重新设计。您可以通过一个全新的界面访问和搜索所有设置。
您的所有扩展都将正常工作。如果您从VS 2022升级到2026,您的扩展将像在2026中一样工作。


最后,这是我们与Copilot最深度的集成。Copilot将在您编码过程的每一步为您提供帮助。我们稍后将通过演示展示最新的集成功能。

编辑与导航改进
上一节我们介绍了主要公告,本节中我们来看看最新的编辑和导航改进。
我们将从展示Visual Studio 2026的演示开始。
这是Visual Studio 2026在预览通道中的外观。我目前打开的是深色主题,但您可以在工具中选择主题。我们有11种不同的主题选择。为了演示效果,我将使用浅色主题。
我已经打开了一个名为“Bullet”的CMake项目,这是一个物理SDK。这是一个示例应用程序,模拟一堆立方体下落。我将对此程序进行一些修改。
以下是演示中计划进行的更改:
- 让下落的物体靠得更近。
- 将物体从立方体改为球体。
- 用注释正确记录代码。
- 使用程序运行时弹出的控制台窗口,打印物体的初始位置。我将尝试使用一些C++23功能。
首先,我需要找到对应的代码文件。这是Visual Studio中的统一搜索体验。它的UI看起来有些不同,我稍后会谈到这一点。
它的工作方式与以前相同,您可以搜索文件、类型或其他代码符号。我将其过滤到basic_example.cpp文件。
我想找出如何修改文件以使下落的物体靠得更近。为此,我将切换到GitHub Copilot聊天窗口。我目前使用的是“询问”模式。您可以选择多种不同的LLM模型。对于此任务,我想使用GPT-4.1。
我将询问Copilot如何修复下落立方体之间的间距。在它分析的同时,我来谈谈统一搜索体验的新UI。
您可能习惯了搜索体验在屏幕中央弹出。现在有一个按钮可以让您将其停靠在任何位置。目前我将其停靠在左侧。您可以将其拖放到任何喜欢的位置,因为它基本上变成了Visual Studio中的一个工具窗口。
我们仍然拥有所有不同的过滤器。您也可以输入f:或t:来查找特定内容,或者通过输入冒号和行号跳转到特定行。还有功能搜索,如果您想查找Visual Studio功能。
现在,我刚刚在文件中向下导航了一点,Copilot也完成了分析。它确定有一个三重for循环,其中有一些乘数决定了下落立方体之间的间距。我将更新这些值,使其变小,这应该能让立方体靠得更近。
我进行了更改,然后尝试再次运行。立方体靠得更近了,它们形成了一个大立方体。看起来成功了。
接下来,我将切换到Copilot的“代理”模式,让它将下落物体从方块改为球体。我还会将模型更新为Claude 3.7。
“询问”模式基本上是问答形式,就像您可能使用过的任何AI聊天界面一样。但“代理”模式实际上可以为您在整个代码库中实施更改。它拥有大量上下文,可以逐步找出需要做什么来满足您给出的提示。
在这种情况下,它将尝试找出如何更改这些物体,使其显示为下落的球体而不是立方体。它正在后台运行。您可以看到它正在分析这个文件,并且已经在进行更改。它列出了正在被修改的文件,因此您可以密切关注哪些文件被更改了。您还有“保留”和“撤销”选项。
它正在继续更改。您可以看到它在这里高亮显示,将形状更改为球体形状。它基本上是在找出这里需要更新的具体行。它确定存在一种名为“球体形状”的对象类型,可以在此代码库中使用。它只是从简单的提示中分析代码库并找出了方法。
它不会使用立方体,而是使用球体。我暂停一下,自己尝试运行看看效果。它进行了更改,现在立方体变成了球体。我将保留这些更改,因为它看起来做了我想要它做的事情。
让我们尝试另一个“代理”模式提示:根据项目约定为此文件添加注释。现在,它将在整个文件中添加文档注释。
您可能会想,如果它生成一堆我不喜欢的注释,然后我必须撤销,那岂不是浪费了很多时间?对此有一个解决方案。
如果我回到搜索体验,这个代码库中有一个名为Copilot_instructions.md的文件,位于.github文件夹中。我基本上在这个文件中定义了我希望项目遵循的约定,Copilot将使用此文件作为上下文,以确保其响应符合我的特定需求和代码库。
我有关于要使用的C++标准、API和风格、性能和安全性的一般说明。还有一个关于文档注释的部分。您基本上可以写下您对它的指示。
我还想指出,您可能会注意到这些行是缩进的。这是另一个新功能。现在在工具选项中有一个“自动换行时缩进”的功能。这就是它们在这里看起来缩进的原因。您可以看到指示自动换行的小箭头。填写这个文件非常容易。
我可以写下诸如“不要使用TODO注释,而是创建问题”之类的指示。当您开始在文件中输入内容时,它可以使用自动完成体验。我基本上添加了之前的几行,我写了几个词,Copilot就推断出“哦,这可能是您想要写的句子的其余部分”。因此,开始使用这样的文件非常容易。
回到这里,看起来它在整个代码库中添加了许多不同的注释。它构建注释的方式是Doxygen风格的注释。我们在每个函数的顶部都添加了注释。这是基于我在Copilot指令文件中提供的上下文。它正以那种特定的方式进行注释。
我们继续尝试再次运行,以确保它只修改了注释,不应该破坏构建。看起来它仍然正常工作,现在我文件中有注释了。
接下来,我想使用那个控制台窗口并在其中显示一些内容。为了开始,我粘贴了一些代码片段。基本上,我想添加一个函数,能够在物体开始下落前打印它们的状态。我添加了这个print_world_state函数,还有一些#include指令。我将把它们移到顶部。
我仍然需要在这里填写一些内容。我还需要调用这个函数。它叫做print_world_state。如果我回到我们之前的三重for循环,我可以调用print_world_state。它会自动补全为“显示初始世界状态”。
然后,我还需要确保将C++标准设置为C++23,因为我将在这里使用一些新功能。我们转到CMakeLists.txt文件,确保添加set(CMAKE_CXX_STANDARD 23)。当我保存时,这将重新生成CMake缓存。
然后,我只需要再做一个更改,那就是回到print_world_state函数本身。我需要在这里填写一些内容。我想使用std::println和格式化范围(C++23功能)来打印输出。
我首先添加一个vector,您可以看到它自动补全了整行。然后您可以看到它已经在说“哦,也许您想用std::cout和std::format”。但这对我来说还不够现代。我要用std::println。再次自动补全。
然后,这将创建这个。它将自动补全这个。这将为每个对象生成坐标,并在其周围添加方括号。但也许我不想要方括号,我只想要控制台输出看起来更简洁。我在这里添加了:n。然后,我们将尝试运行并看看效果。
程序仍然正常工作。现在我在控制台窗口中打印物体的位置。我现在正在使用控制台窗口做点事情,很好。
我对这些更改很满意。让我们看看如何将它们提交到源代码控制中。我切换到Git更改窗口。它显示我更新了两个文件:CMakeLists.txt文件和CPP文件。我将暂存这些更改。
然后,我要点击这个按钮,即“使用Copilot审查更改”。它基本上让Copilot在提交时进行代码审查。它是AI,不会100%准确。但重点是,您可以在做其他事情时在后台运行它。例如,它还会开始自动生成提交消息。它也在后台进行。
基本上,我可以在这里进行多任务处理,可以去查看不同的东西。看起来它在这里生成了提交消息。您可能会注意到提交消息。这是一个AI生成的提交消息,它查看我做出的具体更改,基本上就是我刚刚演示的每一个更改。
您可以看到它遵循特定的模板。顶部有一个标题,有几个要点,语法有一定的方式。这是有原因的,因为我在工具选项中配置了让它看起来像那样。我使用了那个模板。
如果我转到工具选项,这是Visual Studio 2026中新的工具选项UI。它现在集成在中间。您可以将其拖放到任何喜欢的位置。我认为它在中间看起来最好。它看起来更像VS Code,非常干净,也有很好的搜索体验。
我可以搜索“提交消息”。您可以看到这里有一个“提交消息自定义指令”。这是我放置自动生成提交消息模板的地方。就像另一个自定义指令文件(那是代码库范围的,Copilot聊天使用的)一样,这个是用于自动生成提交消息的。我可以确保每次提交时,提交消息都遵循相同的模板,并且总结了我的更改。我不必担心要记住应该使用什么语法,也不必花时间手动输入所有内容。
这很酷。看起来Copilot的代码审查也完成了。它生成了一些评论。我们快速看一下。它说“哦,您确定要使用println吗?因为可能不是所有编译器都支持这个”。是的,这可能是一个好建议。它还指出“嘿,看,您添加的这个print_world_state函数,您没有按照Copilot指令文件的要求在顶部记录它”。所以它意识到您漏掉了这个,或者也许您应该考虑用不同的方式来做。
这些评论对于在提交前发现问题非常有用。最坏的情况是,您可能会收到一条评论,但您认为“您知道吗,我不需要在这里做任何事情”。但它只是可以在后台运行的东西,所以我们真的不会浪费太多时间让它运行。它只是另一层检查,以确保您没有做任何太疯狂的事情。
我现在不打算对这些采取行动,因为我们还有更多内容要讲,但我们可以接受这些更改。我们可以使用该提交消息提交更改。然后我只需点击推送,这将推送更改。我可能没有正确配置远程仓库,但基本上那就会将更改推送到我的远程仓库。就这样,我所有的更改都完成了,一切都已提交。
这就是演示的全部内容。
项目管理与构建

上一节我们看到了编辑和导航的改进,本节中我们来看看管理库和构建项目的改进。

首先,我想提醒大家,Visual Studio支持Clang。您可以从Visual Studio安装程序安装Windows的Clang工具。如果您是Clang用户,并且希望使用相同的编译器来定位多个平台,那么它在我们的项目系统中是受支持的。
但我想谈谈MSVC构建工具,这是我们为Windows开发提供的自己的编译器和构建工具。如果您想要优化的构建性能并构建安全的应用程序和库,我们一直在努力改进我们的STL、编译器和运行时性能,以确保您在Windows上构建C++代码时获得最佳体验。
在Visual Studio 2026中,我们有MSVC构建工具版本14.50。如果您使用MSBuild,您需要将平台工具集设置为v145来使用它。
我知道你们中的许多人可能对我们在C++一致性方面的进展有疑问。我们一直在添加更多C++23功能,我们真的在努力缩小差距,迎头赶上,并实现C++23的完全支持。这是我们当前的首要重点。我们添加了各种功能的列表,您可以看到格式化范围,这是我在演示中展示的。但我们正在继续努力,以完成剩余的C++23功能积压,这样我们就可以说我们完全支持C++23了。但现在,如果您想尝试它们,可以使用/std:c++23预览开关。在某个时候,这个预览开关将会消失,一旦我们完全完成,它就会直接是C++23。还有一个/std:c++latest开关,用于C++23之外的功能。
如果您想了解编译器和STL的最新改进,我们上周发布了一篇关于编译器更新和新的14.50版本的博客文章。如果您想查看STL的任何更新,Microsoft STL是开源的,我们对所有进展都非常透明。有一个很好的变更日志,显示了每个版本中添加的所有功能,还有一个总体视图,例如对于C++23,哪些已经完成,哪些还需要做。我建议您查看一下,如果您想了解更多。

我们在C++26支持方面也取得了一些进展,包括标准库强化。当然,我们还有更多工作要做,所以我们正专注于实现C++23一致性,但我们将继续尽快推出更新。
在展台上,我收到了一些问题,比如“升级到最新编译器有哪些卖点?”其中一个首要原因是性能。我是一名游戏开发产品经理,与许多游戏开发者合作。对于游戏开发者和那些从事大型项目的人来说,运行时性能是一个重要关注点。
在过去的一年里,团队一直在非常努力地改进MSVC的运行时性能。我们与Epic Games合作,了解我们的编译器可以通过哪些方式变得更快。例如,我们正在使用Xbox Series配置对虚幻引擎城市样本进行MSVC基准测试。正如您在这里看到的,从17.14版本开始,我们已经有了很大的改进。在接下来的几周和几个月里,随着我们接近18.0的稳定版本,您将继续看到我们编译器的更多改进。
同样,我们在游戏线程的运行时性能方面也做了很多改进。所有这些都是通过更好的AVX向量化实现的。我们改进了结构体和分支的代码生成。

我知道构建性能和运行时性能是大家最关心的问题之一。所以,如果您还没有使用最新的编译器,请尝试说服您的团队升级到最新版本。如果所有这些性能改进还不够,我们还有C++构建洞察。
构建洞察是一个非常强大的工具,它利用MSVC跟踪捕获技术生成ETL文件。使用构建洞察,它将运行您的构建,获取ETL文件,您将能够看到许多不同的视图。例如,“头文件”视图,显示您的头文件是否可以包含在预编译头文件中,或者是否存在许多昂贵的头文件,这可能是使用预编译头文件的一个很好的理由。或者,您可以查看有关函数生成瓶颈或长模板实例化时间的信息。
因此,您手头有更多信息来改进构建性能。我们根据反馈添加了许多新的生活质量功能。您现在可以在选定的文件上运行构建洞察。有更好的过滤功能。
我们已经看到了与合作伙伴取得的巨大成功。有一个很好的案例研究,如果您想了解如何应用从构建洞察中获得的见解来更改代码,您可以访问链接,看看动视如何利用构建洞察将《现代战争》战区2的构建时间减少了50%。有很多好的经验,强烈建议查看这个案例研究。


代码安全与调试诊断
上一节我们讨论了构建,本节中我们来看看代码安全性和调试诊断。
我想谈谈我们提供的几种工具,以帮助您提高代码安全性和安全性。我们从Microsoft C++代码分析体验开始。
这是Visual Studio的一部分,它在后台运行,就像您收到IntelliSense警告和错误一样。它基本上在后台运行静态分析,对于发现缓冲区溢出、未初始化内存、空指针解引用以及内存和资源泄漏等额外问题非常有用。它还附带了许多C++核心检查,以确保您的代码符合C++核心指南。因为您不仅仅要确保代码能够成功编译,还要确保代码使用最现代的功能,并且是安全的,代码结构使用了现代功能。
在过去的一年里,这个体验有一些改进。包括并发和锁定的新诊断。我们还增强了分配的溢出检测,以及一些改进的警告抑制。如果您想了解更多,可以访问链接。
我谈到了静态分析,但也有动态或运行时分析。如果您熟悉LLVM中的AddressSanitizer工具,我们也有适用于MSVC构建工具的版本。这是一个在程序运行时成为程序一部分的工具,如果您添加/fsanitize=address编译器标志,它将帮助您识别难以发现的错误,且零误报。

它识别了传统静态分析工具未捕获的另一类问题,因此我们建议同时运行代码分析体验和AddressSanitizer来验证您的代码更改,甚至只是在整个代码库中运行它,看看是否有任何历史问题过去从未被发现,以帮助您提高正确性、内存安全性、保护代码安全,并总体上对您的程序进行压力测试。
我还想在这里宣布,我们正在开发ARM64支持。它将在未来发布的Visual Studio 2026预览版中推出。这是目前可用的Visual Studio 2026的第一个预览版,但不久之后还会有更多更新,因此您将开始看到ARM64支持上线,这将允许您为ARM64项目使用/fsanitize=address进行构建。
最后,我想提一下Microsoft指南支持库,它提供了类型和函数来编写更安全、更易维护的代码,今年也更新到了4.2版本,增加了新功能和修复。开始使用它的一个好方法是通过vcpkg安装它。
说到vcpkg,这是我们的C++包管理器。您可以单独安装它,也可以作为Visual Studio本身的一部分安装。它是管理C++依赖项的一个很好的工具。vcpkg目前在其精选注册表中拥有超过2600个独特的库,这些库针对15种不同的常见构建配置进行了构建、测试和验证,以确保它们彼此兼容,并且没有ABI问题或版本冲突。如果您愿意,您也可以将自己的库引入vcpkg。

我们有许多高级功能来帮助您定制体验,无论您是学生还是专业开发人员。您可以控制您想要的库版本,控制这些库的构建方式,因为vcpkg采用从源代码构建的方法,您可以按照您希望的方式从源代码构建库,以便它们与消费项目和其他库一起工作。您只需要在第一次或更改构建配置时这样做,因为我们有二进制缓存来缓存这些二进制文件,以便将来可以重用它们。vcpkg会检测“我可以直接获取二进制文件吗?还是需要重新构建?”这可以为您节省大量时间。
vcpkg还可以在离线或隔离环境中与资产缓存功能一起工作,如果您有这种需求,我也建议您查看一下。
在过去的一年里,vcpkg有一些性能改进,这要归功于一些很棒的社区贡献,因为vcpkg是一个开源项目。我们提高了包安装速度和二进制缓存恢复性能。GitHub Dependabot最近也添加了对vcpkg的支持,因此如果您是GitHub用户,您可以基本上自动更新您的库。Dependabot所做的是向您的仓库提交一个PR,它会说“好的,这是更新的库列表”,它使用vcpkg的版本基线功能,因此您基本上可以更新vcpkg清单文件中的基线,这将一次性将所有依赖项更新到新版本,并且因为这些新版本都在同一个基线上,它们应该可以彼此兼容,而无需您去弄清楚需要设置哪个单独的vcpkg库版本来确保它们之间的兼容性。我绝对建议您尝试这个体验并给我们反馈。
调试与诊断演示
上一节我们讨论了代码编辑和构建的改进,但说到调试呢?作为一名游戏开发产品经理,我与许多AAA游戏开发者交流过,了解到游戏开发者面临的许多问题实际上也适用于日常的C++应用程序。如果您有一个非常大的项目,这一点尤其正确。
在开始演示之前,我想问一下房间里的各位,如果您有调试和发布配置,请举手。如果您最近尝试过调试发布版本,请举手。
那么,调试发布版本时常见的一些问题是什么?您的构建经过了高度优化,以追求速度。游戏开发者告诉我,在调试配置中,您会获得更多的调试支持,但帧率可能非常糟糕。如果您尝试在发布版本中做同样的事情,您可能无法看到所有局部变量,变量可能被优化掉了。非常常见的红色X。可能不是一个好现象,但不幸的是,它就在那里。此外,当您尝试单步执行代码时,您的指针到处跳转,您不知道它要去哪里,这不可靠。
那么,如果我告诉您,您可以两者兼得呢?如果您可以在发布构建中获得完整的调试能力呢?这就是我将在演示中向您展示的内容,以及通过调试和诊断周期的一些其他Copilot集成。
如您所见,我们正在运行这个Later Cy游戏,60 FPS。这是发布版本,不是调试构建。让我回到Visual Studio,重新激活我的断点。
我们现在处于一种叫做“新的C++动态调试模式”的状态。我首先想向您展示的是,如果您转到调用堆栈,您可以看到这个帧有一个新的“已去优化”标签。这意味着您当前查看的帧是去优化的。
它的工作原理是,我们构建您的二进制文件两次:一次是优化的(发布版),一次是未优化的。一旦您在那里设置断点,您就会进入去优化的版本。
那么我们能做什么呢?我们可以步入。现在我们在一个强制内联的函数中。如果您尝试调试发布构建,这是不可能的。我们还能做什么?我们可以步入下一个函数,即calculate_transform_bounds。我们有一些变量声明。如果我们查看自动窗口,我们可以看到所有变量。我们还能做什么?我们可以告诉它运行到光标处,因为现在指令指针实际上知道要去哪里。或者,您可以右键单击该行并设置下一条语句。
再次强调,这些并不是新的调试功能。这些是调试配置中长期以来可用的调试功能。但现在,您可以在调试发布构建时使用它们。
让我跳出这里,再展示一件事。我们只关心您关心的已去优化帧。如果我转到这个帧,例如,您会看到“场景”变量已优化的典型消息。那么如何解决这个问题呢?您可以选择您的帧,使用Ctrl或Shift选择多个,右键单击,然后选择“在下次进入时去优化”。一旦我点击它并让它再次中断,您将看到我们选择的所有帧,特别是之前的Chaos引擎接口帧,都已被去优化。在这里,场景变量对您可用。
我们将所有断点存储在一个去优化断点组中。您可以做的就是选择它们全部并删除它们。如果我禁用这个断点并按继续,您看,我的游戏回到了全帧率。这就是您如何通过动态调试获得完整调试能力的方法。
现在,我将再次设置断点。我想再向您展示一件事。之前,我们在一些渲染代码中,这可能是您看到网格故障或其他问题的常见地方。我们添加了并行堆栈的额外集成。因此,如果我们从渲染代码退一步,通过并行堆栈视图查看我们的代码整体,这是一个显示所有线程正在做什么的视图。虚幻引擎经过高度优化,有许多线程在运行。
您可能注意到的第一件事是我们添加了新的“线程摘要”。您不必猜测您的线程在做什么。Copilot会告诉您。还有一个“生成见解”按钮,如果您想了解更多关于当前应用程序状态的信息。
由于时间关系,我们将进入演示的下一部分。通过激活这个断点,并希望我多玩一会儿游戏,断点会激活。现在,我们在一些渲染代码中。我们看到了所有不同的线程。在这里,我想向您展示我们添加到调试器中的另一个功能。你们中有多少人熟悉条件和依赖断点?好的。
您可以做的一件事是,如果我在这里设置一个断点,然后转到条件,Copilot将帮助您找出一些条件。这是提高生产力的好方法。也许您已经有一些想法,但可能不是这样。在这个例子中,我没有具体的想法。“当前热量小于最大范围”,我认为这些都没有帮助。但如果我知道当“当前热量”是某个特定整数值时会发生某个动作呢?比如是4。这是您可以看到Copilot如何帮助您在整个循环中提高生产力的好方法。
在我们继续之前,还有一件事要提。到目前为止,我谈到的所有内容都不是游戏开发特有的。尽管我使用了虚幻引擎作为例子,但动态调试、并行堆栈和所有Copilot功能都非常适用于所有C++开发。
但对于虚幻引擎,我们最近与Epic Games合作,将蓝图调试添加到我们的调试器中。就像动态调试一样,您可以在调用堆栈窗口中看到一个新标签。它的作用是,当您停止时,您可以双击蓝图帧。
我们有一个提示,提醒您需要我们的Visual Studio集成工具。我将在演示后多谈一点,但一旦您点击蓝图帧,您现在可以转到您的蓝图。您可以看到所有蓝图变量,例如您的节点引脚,查看所有蓝图变量的值。例如,如果有一个您不熟悉的变量,比如“流堆栈”,它可能意味着什么?点击该变量旁边的小GitHub图标。
然后,GitHub Copilot将获取您当前应用程序状态的上下文,获取该变量信息,并为您提供非常详细的摘要,说明您的变量是什么,更重要的是,当您的程序停止时,您的变量值可能意味着什么。
调试器代理正在查看各种文件。它认为有些文件可用,但并非全部。您可以看到这里,我们有任务,有一个计划,为Copilot读取一定数量的C++文件以理解,然后它会为您提供“流堆栈”的值及其含义。
让我们回到演示文稿。
虚幻引擎特定改进与源代码控制
上一节我们深入探讨了调试,本节中我们来看看针对虚幻引擎的特定改进以及源代码控制的最新更新。
继我们的虚幻引擎特定改进之后,UProject是我们为虚幻引擎项目提供的新项目系统。与传统的解决方案打开方式相比,这非常有帮助。
使用UProject,虚幻引擎开发者将获得更快、更准确的IntelliSense,因为与解决方案相比,Visual Studio现在拥有更多额外的上下文。
正如我之前提到的,Visual Studio集成工具现在可以直接在Visual Studio中安装。我们实际上已将其从Fab市场移除,因为我们不再需要市场。蓝图引用也不再需要它,许多客户告诉我们这对他们很有价值。因此,我们已将该功能从插件移至Visual Studio原生支持。
但如果您想要额外的蓝图调试信息,则需要Visual Studio集成工具。正如我向您展示的,您可以在调用堆栈窗口中看到您的蓝图,以及在局部变量窗口中看到所有蓝图信息。
我还向您展示了Copilot在调试和诊断工作流程中的各种改进,特别是断点建议和变量分析。当然,并行堆栈是一个非常强大的工具,得益于Copilot而变得更好。
当然,今年我们团队最重要的事情是动态调试。您不再需要看到变量被优化掉,也不必使用#pragma optimize工作流程。我们一直与游戏开发者密切合作,现在在Visual Studio 17.14及更高版本中可用。我们有一篇非常详细的博客,向您展示如何激活C++动态调试。
正如我之前提到的,我们正在与许多内部合作伙伴合作,以确保该功能满足需求。从Coalition到Halo Studio的游戏开发者,再到Rare的开发者(Keith今天实际上就在观众席中)。因此,Keith将是了解动态调试如何在实践中工作的绝佳资源,因为他从一月份就开始使用它了。
一些额外的细节:动态调试仅支持x64。它适用于Xbox快速构建、Incredibuild和虚幻引擎5.6。它仍然适用于虚幻引擎5.5及更早版本,只需要挑选一些更改。它不支持LTCG、PGO、/uPICF和增量链接。我们在周四有一个深度探讨,我们的动态调试工程负责人将深入探讨团队如何提出动态调试的技术细节。
在最后一部分,我将交还给Augustin。
源代码控制改进与总结

上一节我们讨论了调试,本节中我们来看看源代码控制的最新更新。
我向您展示了Visual Studio中可用的源代码控制功能,其中一些功能已经存在很长时间,但我们多年来一直在不断添加新功能。因此,如果您想拥有图形用户界面来管理源代码控制需求,Visual Studio提供了许多功能。

过去一年中的一些较新变化包括:重命名文件管理的改进、PR草稿和模板的支持。我们还支持内部GitHub仓库以及多仓库支持。然后,我还向您展示了编写和自定义Git提交消息的功能,并能够为它们设置模板,以便它始终遵循模板,并且通过查看文件中的差异,为您提供特定提交中更改内容的定制列表。
添加PR评论的功能也有一些改进,您可以自己添加PR评论,但我们还有一个代码审查功能,您可以让Copilot在提交时审查您的代码,并生成一些额外的评论,以防您遗漏了什么。同样,这可以在您做其他事情时在后台运行。
还有一个用于查看传出和传入提交的过滤器,在Git更改窗口中有一个新按钮。然后,在Visual Studio 2026的这个新版本中,有一个我没来得及展示的功能:如果您转到Copilot聊天,您可以输入类似#git_changes的内容,这基本上会为其提供一些上下文,专门查看您在Git中文件的更改情况。这对于总结尚未提交的更改或解释特定提交非常有用,它可以帮助弄清楚从A到B实际发生了什么变化。因此,您基本上可以在Copilot聊天中引用Git更改窗口中的特定提交和更改。
我们继续开发了使用Copilot进行代码审查的体验。这是在去年添加的,但我们在18.0版本中也进行了一些改进,因此它应该比以前更准确、更有用。最后,我们在PR差异视图中提供了内联评论。
现在我想提醒大家,Microsoft C++产品团队的使命是赋能每一位C++开发者及其团队取得更大成就。我们通过多年来一直发布的各种开发工具来实现这一目标。我们是Microsoft最古老的团队之一,多年来我们一直在继续开发我们的工具,以帮助您在C++开发中尽可能高效。我们很乐意听取您对我们工具的反馈,因为我们继续迭代并为您改进它们。
最后,提醒一下,我们在CPPCon还有其他Microsoft演讲。其中一些已经过去,但您可以在YouTube上观看。今天早上有一个VS Code演讲,本周晚些时候还有其他一些演讲。我还想提醒大家,请填写我们的调查问卷。角落有一个QR码,您可以访问链接。您的反馈对我们非常有价值,帮助我们决定未来更新Visual Studio、VS Code、MSVC、vcpkg或我们为您正在开发的任何其他内容时应该优先考虑什么。

如果您填写了调查问卷,我们正在分发Visual Studio和Visual Studio Code的袜子。只要库存充足,如果您填写调查问卷并光临我们的展台,您就可以获得袜子,并且您还将参加今天、明天和周四的每日抽奖,赢取乐高奖品。
总结
在本节课中,我们一起学习了Visual Studio 2025为C++开发者带来的众多新功能和改进。我们从主要公告开始,了解了Visual Studio 2026预览版的发布。接着,我们深入探讨了编辑和导航的增强,特别是与GitHub Copilot的深度集成,包括“代理”模式、自定义指令和代码审查功能。
在项目管理与构建部分,我们回顾了MSVC构建工具的性能提升、C++23/26标准支持进展,以及vcpkg包管理器的改进。代码安全方面,我们介绍了静态分析、AddressSanitizer和指南支持库等工具。
调试与诊断是本次更新的重点,我们详细了解了全新的“C++动态调试模式”,它允许开发者在高度优化的发布构建中获得完整的调试能力。此外,还有针对虚幻引擎的蓝图调试等专有改进。

最后,我们探讨了源代码控制方面的更新,包括与Copilot集成的提交消息生成和代码审查。整个课程展示了Visual Studio团队如何致力于提升C++开发者的生产力,并通过持续收集反馈来指导产品发展方向。
056:std::expected与单子操作真能提升你的C++代码性能吗?





在本节课中,我们将探讨C++中的错误处理机制,特别是std::expected与单子操作,并将其与传统的异常处理进行性能对比。我们将深入其实现细节,分析潜在的性能影响,并通过基准测试来观察实际表现。最后,我们将提供一些实用的优化建议。
错误处理概述
在开始比较之前,我们首先需要理解为什么需要错误处理。无论是使用std::expected、异常还是其他方法,其核心目的都是处理程序中可能出现的意外情况。
上一节我们介绍了错误处理的必要性,本节中我们来看看两种主要的现代C++错误处理方法:异常和std::expected。
异常处理
异常用于处理程序执行期间发生的未计划事件。当异常发生时,会创建一个特定类型的异常对象,并将其交给运行时系统。运行时系统会寻找匹配的catch块来处理它。如果找不到,程序通常会终止。
以下是异常安全性的四个级别:
- 无异常保证:不提供任何保证,是最糟糕的情况。
- 基本异常保证:程序状态保持有效,但数据可能丢失(例如,编辑文档时发生异常,程序未崩溃但更改丢失)。
- 强异常保证:操作要么完全成功,要么完全失败,状态回滚到操作之前,类似于事务。
- 不抛异常保证:操作保证不会抛出任何异常。
一个简单的异常示例:
std::vector<int> vec{1, 2, 3};
try {
int value = vec.at(3); // 访问越界,抛出 std::out_of_range
} catch (const std::out_of_range& e) {
// 处理异常
}
std::expected 简介
std::expected<T, E>是一个可以包含一个期望值(类型T)或一个错误(类型E的包装器。它类似于std::optional,但错误时携带了具体的错误信息,而不仅仅是“无值”。
一个基本的std::expected示例:
std::expected<int, std::string> safe_sqrt(double x) {
if (x < 0) {
return std::unexpected("Invalid argument: negative value");
}
return std::sqrt(x);
}
auto result = safe_sqrt(-1.0);
if (result) {
std::cout << "Value: " << *result << '\n';
} else {
std::cout << "Error: " << result.error() << '\n';
}
为了更流畅地处理std::expected,我们可以使用单子操作(Monadic Operations):
and_then: 如果存在值,则应用一个返回std::expected的函数。transform: 如果存在值,则应用一个转换函数(可改变类型)。or_else: 如果存在错误,则应用一个处理函数。transform_error: 对错误进行转换。
auto result = fetch_data()
.and_then(parse_json)
.transform(validate)
.or_else([](auto error){ /* 错误恢复 */ });
实现细节与性能影响
了解了基本概念后,我们来看看std::expected和单子操作的内部实现,这有助于理解其性能特征。
std::expected 的内部结构
std::expected内部通常包含一个联合体(union),用于存储值(T)或错误(std::unexpected<E>),以及一个布尔标志(has_value)来指示当前存储的是值还是错误。
这意味着:
- 每次操作至少需要一次
if检查来判断当前状态。 - 在单子操作链中,每一步都可能涉及值的复制或移动(取决于类型
T和E的移动语义)。 - 返回类型可能改变,这可能阻碍编译器的返回值优化(RVO)。
单子操作的实现
以and_then和transform为例(简化版):
它们首先检查has_value标志。如果为真,则调用传入的函数并包装结果;如果为假(即存在错误),则直接将错误传递下去。这个检查在每个单子操作步骤中都会发生。
潜在的性能问题包括:
- 分支预测:每个步骤的
if检查可能影响流水线。 - 拷贝/移动开销:如果值或错误类型不支持高效移动,则会产生拷贝开销。
- 阻碍优化:复杂的返回类型链可能阻止编译器进行优化。
基准测试对比
理论分析之后,我们通过一些基准测试来观察异常和std::expected在实际中的性能表现。请注意,这些是合成测试,实际结果会因具体用例、数据类型和硬件而异。
测试方法:
- 操作:执行一系列轻量级操作(例如,递增计数器)。
- 场景:
- 无错误:所有操作成功。
- 错误在开头:第一个操作就失败。
- 错误在中间:在操作链中间失败。
- 错误在末尾:在操作链末尾失败。
- 对比:使用
try-catch的异常代码 vs 使用单子操作的std::expected代码。
以下是测试的核心观察结果(基于特定测试环境,数值为示意):
- 无错误路径:对于很长的操作链(例如10k次),
std::expected的单子操作可能比单纯的try块(内部无异常抛出)稍慢,因为需要执行大量if检查。但try块本身也有极小的固有开销。 - 错误发生在开头:
std::expected(立即返回unexpected)显著快于抛出并捕获一个异常。因为后者涉及堆分配、运行时栈展开等重操作。 - 错误发生在中间或末尾:随着操作链变长,两者性能差异逐渐缩小。对于较短的链(例如少于500次操作),性能可能相近。
重要提示:这些是微观基准测试的结果。在实际应用中,错误发生的频率、操作本身的成本、数据大小等因素会极大地影响最终选择。你应该在自己的生产环境配置下,针对真实用例进行性能剖析。
实用优化建议
根据前面的分析,我们总结出一些可以提升使用std::expected时代码效率的建议。
1. 避免不必要的拷贝
在单子操作链中,如果函数接受或返回大型对象,拷贝开销会累积。
建议:
- 确保你的值类型(
T)和错误类型(E)支持高效的移动语义。 - 在调用单子操作(如
transform)时,如果参数是临时对象或你可以安全地转移所有权,使用std::move。std::expected<Noisy, std::string> result = get_result(); auto final_result = std::move(result) .transform([](Noisy n){ /* 处理 n */ }); // 使用移动 - 考虑使用智能指针(如
std::unique_ptr)作为std::expected的值类型,这样“拷贝”实际上只是移动指针。
2. 使用 std::reference_wrapper
std::expected本身不支持引用类型。如果你需要传递引用以避免拷贝,可以使用std::reference_wrapper。
MyLargeObject obj;
std::expected<std::reference_wrapper<MyLargeObject>, Error> result = obj;
// 使用时通过 .get() 获取引用
result.transform([](auto ref_obj) { ref_obj.get().do_something(); });
注意:你必须确保被引用对象的生命周期长于整个计算管道。
3. 利用原位构造
如果你需要在std::expected内部直接构造对象,而不是先构造再移动进去,可以使用std::in_place标签(对于值)或std::unexpected的构造函数(对于错误)。这可以避免一次额外的移动或拷贝。
// 原位构造值
std::expected<Noisy, std::string> exp_val{std::in_place}; // 直接调用 Noisy()
// 原位构造错误
std::expected<int, Noisy> exp_err{std::unexpect, /* 构造Noisy的参数 */};
4. 保持数据类型简单


使用平凡可复制(Trivially Copyable)、标准布局(Standard Layout)的简单数据类型作为std::expected的模板参数。编译器更容易优化对这些类型的操作,移动和拷贝的成本也极低。
总结
本节课中我们一起学习了std::expected和单子操作,并与C++异常处理进行了性能对比。

核心要点:
std::expected提供了异常之外的另一种类型安全的错误处理方式,尤其适合函数式编程风格。- 从性能角度看:
- 成功路径(无错误):对于超长调用链,
std::expected的单子操作可能因频繁的if检查而略慢于无异常抛出的try块,但差异通常很小。 - 失败路径(有错误):
std::expected在错误处理上通常远快于异常,因为它不涉及运行时栈展开等重型机制。错误发生得越早,优势越明显。
- 成功路径(无错误):对于超长调用链,
- 性能受多种因素影响:数据类型(拷贝/移动成本)、操作链长度、错误发生频率、编译器优化能力等。
- 可以通过一些技巧优化
std::expected的使用:使用移动语义、std::reference_wrapper、原位构造以及选择简单的数据类型。

最终建议:没有绝对的赢家。选择std::expected还是异常,应基于项目的整体架构、团队习惯、与现有代码/库的兼容性以及对可预测性能的需求。如果错误是控制流的一部分且频繁发生,std::expected可能是更好的选择。如果错误是真正“异常”的、罕见的,异常处理可能更清晰。务必在你自己的目标环境中进行基准测试和性能分析。
057:概述

在本教程中,我们将跟随Vishnu Sudheer Menon的演讲,学习如何利用现代C++构建一个稳健、可扩展且适应性强的视觉重建(Structure from Motion, SfM)管道。我们将重点关注如何处理多样化的数据集、有效剔除异常值,以及利用现代C++特性(如概念、并行算法和设计模式)来优化性能。整个管道将围绕光束法平差(Bundle Adjustment)这一核心优化技术展开。
现代C++能加速你的光束法平差管道吗?:2:视觉重建简介
上一节我们概述了本教程的目标。本节中,我们来看看视觉重建的基本概念。
视觉重建的目标是从一系列静态图像中推断出三维几何结构。本教程主要关注相机输入,但重建同样可以使用激光雷达、雷达或其他任何能提供距离和角度测量的传感器完成。
输入是多张图像,输出是一个三维重建模型。
面临的挑战包括处理缺失数据、遮挡、噪声和异常值,并确保管道能够处理多达百万级别的数据点。
下图展示了管道的三个主要组成部分:相机、观测值和最终的三维模型(即地标点)。三维模型是我们要映射的地标点。多个相机会观察三维模型的同一部分,以确保有足够的重叠区域,从而将测量值与观测值关联起来。观测值是图像平面上的信息,即像素坐标(u, v)。移动的相机(可以是一个或多个)会提供相机位姿,我们通过优化这些位姿来获得良好的重建结果。
一个关键点是,需要从多种不同角度观察物体,以获得良好的视角范围,从而能够正确地进行三角测量和位姿估计。
视觉重建的标准流程如下:
- 输入数据:来自相机。
- 特征提取:使用角点检测或方向梯度直方图等方法。
- 关联:将每个观测值与一个地标点关联,从而建立多相机、多地标点的观测系统。
- 初始估计:估计地标点和相机的初始位姿,为优化器提供起点。
- 修剪:移除噪声数据或对其进行处理。
- 光束法平差:最小化重投影误差。
- 重建:生成最终的三维场景。
现代C++能加速你的光束法平差管道吗?:3:数据集介绍
上一节我们介绍了视觉重建的基本流程。本节中,我们将了解本教程所使用的数据集。
我们使用的数据集来自“Bundle Adjustment in the Large”项目,主要展示“Ladybug”和“Trafalgar Square”这两个数据集的结果。
这个数据集很有趣,因为它已经完成了数据关联,并提供了地标点和相机的初始位姿。它专为中到大规模问题设计,格式也很有特点:首先给出观测值(关联相机、地标点及其在图像平面上的位置),然后给出相机和地标点的位姿。
以下是两个数据集的统计信息:
Ladybug 数据集
- 观测值数量: > 500,000
- 相机数量: 1,700+
- 平均重投影误差: 3.8
- 最大重投影误差: 600 (表明存在长尾分布的异常值)
- 每相机观测数: 均值 363,最大 970
- 每点观测数: 最小 2,最大 145
Trafalgar Square 数据集
- 观测值数量: ~220,000
- 相机数量: 257
- 最大重投影误差: 456
- 每相机观测数: 均值 876,最大 4000
- 每点观测数: 范围 2 到 42
这两个数据集规模庞大,包含约50万个点。如果不处理其中的噪声和异常值,优化问题可能会变得非凸,难以求解。
现代C++能加速你的光束法平差管道吗?:4:投影管道
在深入代码之前,我们需要理解投影管道,因为它构成了整个优化器和异常值剔除模块的核心。
我们使用一个带有径向畸变的针孔相机模型。这意味着模型包含了相机镜头的曲率参数,可以在优化时加以考虑。
投影管道的工作流程非常简单直接:
- 获取世界坐标系中的三维点。
- 使用相机的旋转和平移(位姿),将该点转换到相机坐标系。
- 将其投影到归一化图像平面(即假设深度Z=1,将X和Y除以Z)。
- 应用径向畸变模型进行校正。
- 使用焦距参数,得到最终的像素坐标。
这个管道之所以关键,是因为一旦你有了世界空间中一个三维点的估计值,就可以通过这个管道计算出它在图像平面上的预测位置。然后将这个预测值与图像平面上的实际观测值进行比较,其差值就是我们要优化的重投影误差。
现代C++能加速你的光束法平差管道吗?:5:整体管道设计
本节我们将介绍为实现模块化和可扩展性而设计的整体管道架构。
我们利用 C++ 概念 来建立策略的基础。通过概念,我们为每个模块设定了一组必须遵守的规则,这在管道与其交互的模块之间形成了一种“契约”。只要模块遵循这组规则,它就能轻松地与管道通信,用户甚至可以根据规则集切换不同的模块实现。
这使我们能够处理多种数据集(期望数据解析器遵循 DataSetLike 策略),在多种过滤方法(异常值剔除策略、空间过滤策略)和优化策略之间切换,并通过窗口策略来保证可扩展性。
管道本身采用工厂设计模式。使用运行时常量,我们可以在运行时切换不同类型的管道(例如平衡型与高性能型)。作为工厂,它期望用户编写继承自已定义基类的派生类,该基类明确了策略要求。这样做有两个好处:一是可以在运行时切换变体,二是可以在编译时检查用户的策略实现是否有效。
管道的主类是一个模板类,其中五个模板参数都有必须遵循的概念要求。值得注意的是,我们将数据集类型也传递给其他策略,以确保这些策略能够处理我们正在使用的数据类型。
管道的主要函数非常简单:生成窗口、执行异常值剔除、最后执行优化。我们期望各个模块来完成繁重的工作。
现代C++能加速你的光束法平差管道吗?:6:数据解析器
现在,让我们深入管道的第一个具体模块:数据解析器。
数据解析器的主要目标是读取数据集并产生管道可以处理的预期输出格式。由于这是一个开销较大的操作,我们使用了 [[nodiscard]] 关键字(如果用户意外调用而未使用返回值,编译器会给出警告或错误)和 std::expected(帮助进行异常处理)。
数据解析器类 DataParser 有三个主要目标:
- 基础过滤与预处理:确保处理的数据是“干净”的。
- 数据标准化:确保相机和地标点的数值范围大致相同。
- 转换为策略格式:将数据转换为管道期望的策略格式。
以下是具体步骤:
1. 过滤
预处理步骤很直接:确保相机ID有效,并确保地标点转换到相机坐标系后与相机保持合理距离(例如0.1米到1米)。由于数据集很大,我们使用 std::for_each 并指定并行执行策略(这里使用了Intel TBB库)来加速处理。最后,移除观测次数不足(例如少于2或3次)的地标点。
2. 标准化
标准化的目的是避免数据集和相机的数值范围差异过大,否则优化器可能因变量变化幅度不同而导致非凸优化。我们使用中位数绝对偏差(MAD)进行标准化,使内点聚集在相近的范围内,而异常值则被“推离”。MAD比均值对异常值更稳健。同样,我们使用 std::for_each 和Intel TBB进行并行化。
3. 策略格式
我们定义了一个 DataLike 概念作为管道和数据解析器之间的API契约。它包含三个核心策略:
CameraModelLike: 要求模型有一个project函数(将点从相机坐标系投影到图像平面),并可能包含焦距、主点和畸变系数等参数。CameraLike: 表示相机实体,包含其内外参数(位姿)和ID。ObservationLike: 表示一个观测,关联一个相机、一个地标点,并提供该点在图像平面上的像素坐标。
DataLike 策略则要求数据集对象提供 observations()、cameras() 和 points() 函数,且返回类型需符合上述策略。我们还使用了 std::unordered_map 来存储从相机和地标点视角查询的观测数据,以加速优化过程。
现代C++能加速你的光束法平差管道吗?:7:日志与窗口策略
在进入核心的过滤和优化模块前,我们先看看两个支撑性的策略:日志和窗口。
日志策略
日志策略非常简单,只要求一个 log 函数。一个实现示例是 CrossLoggingPolicy,它使用类似 CPP_DEBUG_INFO 的宏。这里一个有趣的技巧是使用 static_assert 来在编译时检查日志实现是否仍然满足策略要求。需要注意的是,当前的实现期望日志消息本身携带源位置信息,未来可以改进为在策略中约束该字段。
窗口策略
窗口策略也很直接。我们目前使用非重叠窗口。为了简化,策略只要求一个 generate_windows 函数并返回窗口结果。窗口策略将大规模问题分解为可管理的小块,是保证管道可扩展性的关键。
现代C++能加速你的光束法平差管道吗?:8:异常值剔除策略
现在,我们进入处理数据噪声的关键环节:异常值剔除。我们采用了两阶段过滤策略。
异常值剔除策略要求实现一个 reject_outliers 函数,并返回一个包含统计信息(如内点、外点数量和内点比率)的结果。
第一阶段:简单异常值过滤
这个方法很直接:使用投影模型将地标点投影到图像平面,计算预测位置与实际观测位置之间的误差。如果误差超过某个阈值,则将该观测标记为异常值并忽略。
挑战在于处理超过50万个点。解决方案是并行化。观测在相机之间以及相机内部都是独立的。因此,我们可以在相机级别和观测级别进行并行处理:
- 使用
std::execution::par在相机级别并行。 - 在每个相机内部,使用
std::transform并行计算每个观测的重投影误差并标记异常值。
这种方法速度非常快。
第二阶段:空间过滤
为什么需要两阶段过滤?因为我们需要从两个不同的视角处理异常值:图像平面和世界坐标系。空间过滤在世界坐标系中进行,目标是使三维模型尽可能平滑。远离点群簇的点很可能是异常值。
空间过滤策略要求一个 filter_spatially 函数,接收数据集和相机窗口,并返回过滤结果。
实现思路是:对于每个地标点,找到其K个最近邻点,计算这些邻居点的均值和标准差,然后使用Z分数来判断该点是否为异常值(即是否远离邻居点的分布)。
直接计算所有点的最近邻非常耗时。我们使用 Boost Geometry 的 R-tree 来加速。R-tree 是一种空间索引,可以免费提供空间分组。我们将所有点加载到 R-tree 后,基于查询来高效地查找每个点的最近邻。
同样,我们可以并行化此过程,并且可以忽略已被第一阶段标记为异常值的点,只处理内点,以进一步提升速度。
一个值得注意的优化点是:当前实现是从相机-观测的角度遍历,但更高效的方式是直接从所有点的角度并行遍历。此外,可以假设局部区域内的点具有相近的均值并进行缓存,但这属于未来的改进工作。
过滤结果
- Ladybug 数据集:过滤掉约3.3%的点,均值误差21,标准差36。
- Trafalgar Square 数据集:过滤掉约8%的点,均值误差37,标准差19。
现代C++能加速你的光束法平差管道吗?:9:优化策略与基础实现
经过过滤,我们得到了更干净的数据。现在,进入管道的核心:优化,即光束法平差。
优化策略要求一个 optimize 函数,并返回一个包含成本、成本变化、成功状态和迭代摘要等信息的优化摘要。
优化的概念很直接:目标是减少整体的重投影误差。我们拥有观测值、相机位姿和地标点位姿。我们要求优化器通过微调相机位姿和地标点位姿(将观测值视为常数)来最小化重投影误差。
我们使用 Ceres Solver 库,它提供了自动微分功能。Ceres 使用泰勒展开将非线性问题线性化,形成正规方程:H Δx = -J^T r,其中 H 是海森矩阵,J 是雅可比矩阵,r 是残差。
光束法平差的一个优势是雅可比矩阵是稀疏的,因为每个残差只依赖于一个相机和一个地标点。我们可以利用舒尔补技巧,将问题压缩为只关于相机位姿的较小系统,求解后再反代回地标点,这能极大提升速度和内存效率。
Ceres 推荐使用 Levenberg-Marquardt 或 Dogleg 策略,这里主要使用 Levenberg-Marquardt,因为它采用了信赖域策略,可以防止在病态问题上步长过大。
基础实现(简单光束法平差)
在简单实现中,我们只优化当前非重叠窗口内的相机及其观测到的地标点。窗口外的观测被完全忽略。
以下是实现步骤:
- 使用
std::views遍历窗口内的相机,将其参数(位姿、内参)存储到std::vector中。 - 将指向该向量数据的指针作为参数块传递给 Ceres。
- 对地标点进行类似操作。
- 使用四元数表示旋转(比旋转矩阵更稳定),并通过 Ceres 的流形(Manifold)约束(如
QuaternionManifold)来确保优化过程中四元数保持单位长度,从而代表有效的旋转。 - 定义残差块,将观测值、相机参数和地标点参数关联起来。
- 调用 Ceres 求解器进行优化。
- 遍历参数向量,用优化后的新值更新数据集中的相机和地标点。
然而,这种简单实现有一个问题:重投影误差可能爆炸式增长。这是因为我们错误地假设了每个窗口的问题是独立的。实际上,地标点可能被多个窗口的相机观测到。
现代C++能加速你的光束法平差管道吗?:10:高级优化技巧
为了解决简单实现的问题,我们引入了几项高级优化技巧。
1. 优化所有相机,但固定窗口外相机
我们不再只添加窗口内的相机,而是添加数据集中的所有相机,但将窗口外的相机参数设为常量。这样,优化器只优化窗口内的相机位姿。当窗口滑动时,我们再更新常量和变量的设置。这更好地利用了相机间的约束。
2. 引入参数边界
为优化变量(如焦距)设置上下界,以限制求解器的搜索空间。为了使管道更通用,可以预处理数据集,根据数据范围动态设置边界,而不是硬编码。
3. 利用窗口外的观测
我们不仅考虑当前窗口内的观测,也考虑地标点在其他窗口(即被其他相机观测到)的观测,但将这些“外部”相机设为常量。这样,这些观测可以起到“锚定”地标点的作用,防止其在当前窗口的优化中漂移得太离谱。
4. 窗口内观测支持度
引入 in_window_support 参数:如果一个地标点在当前窗口内只有一个观测,则不优化它(或忽略该观测)。因为单个观测无法提供足够的约束,容易陷入局部最优,并影响后续窗口的优化。
5. 参数排序
为了有效利用舒尔补,Ceres 期望雅可比矩阵中相机参数在上部,地标点参数在下部。我们可以通过设置参数排序(ParameterBlockOrdering)来显式指导求解器,从而加速求解。
6. 内迭代
除了联合优化,可以尝试内迭代:先进行几次主迭代(优化所有变量),然后固定相机只优化地标点(或反之)进行一系列内迭代。每个子问题更简单,但重复使用可能不准确的雅可比矩阵有风险,需要谨慎使用。
7. 损失函数
尝试不同的损失函数来处理异常值:
- Huber损失:在阈值内是二次损失,阈值外是线性损失。减少异常值的影响。
- Cauchy损失:在阈值内是二次损失,阈值外是对数损失。对异常值的抑制更强。
需要调整阈值以平衡收敛速度和异常值鲁棒性。
8. 自定义损失函数
不推荐自定义损失函数,因为需要提供残差的一阶、二阶甚至三阶导数,实现复杂且容易出错,收益往往小于付出。

应用这些技巧后,平均重投影误差变得可以接受,管道能够有效处理异常值并生成合理的三维模型。虽然边缘仍有一些噪声,但这可以通过进一步调优来解决。
现代C++能加速你的光束法平差管道吗?:11:总结与问答
在本教程中,我们一起学习了如何利用现代C++构建一个稳健的视觉重建管道。我们涵盖了从视觉重建基础、数据处理、到利用C++概念和设计模式实现模块化管道,再到两阶段异常值剔除(图像平面和空间过滤),以及使用Ceres Solver进行高级光束法平差优化的完整流程。
关键要点包括:
- 模块化:使用C++概念定义策略契约,实现高度可配置的管道。
- 性能:利用并行算法(
std::execution::par)和空间索引(Boost R-tree)处理大规模数据。 - 鲁棒性:通过两阶段过滤和合适的损失函数(Huber, Cauchy)有效处理异常值。
- 优化:利用Ceres Solver及其高级特性(舒尔补、参数排序、内迭代、流形约束)解决大规模BA问题。

问答环节摘要
- 数据关联:本教程使用的数据集已预先完成关联,过滤是为了处理关联中可能存在的错误。
- 工厂模式与对象管理:工厂模式用于灵活创建管道变体,对象管理可根据具体场景选择栈或堆分配。
- 传感器偏差:如果某个相机的观测远多于其他相机导致结果偏向它,可以在优化模块中为不同相机的残差引入权重(信息矩阵/协方差矩阵),以平衡其影响。
- 尺度:重建结果具有已知尺度,通过MAD标准化使其处于合理观察范围。
- 固定内参:如果所有图像来自同一相机,可以约束所有相机的内参相同,这是一个有价值的改进方向,但本教程未实现。
- 并行化细节:在空间过滤中,由于操作对象是点,且涉及R-tree查询,并行化需要仔细处理线程安全。对于已过滤掉的点或当前窗口外的点,可以避免不必要的处理来提升效率。

通过结合现代C++的强大特性和成熟的优化库,我们可以显著加速并增强光束法平差管道的性能和鲁棒性。
058:VS Code 中的新功能、CMake 改进与 GitHub Copilot 智能体


在本课程中,我们将学习 Visual Studio Code 中针对 C++ 开发的最新改进。我们将重点介绍 C++ 扩展的性能提升、CMake 工具扩展的新功能,以及如何利用 GitHub Copilot 及其智能体来显著提升你的开发工作流效率。
C++ 扩展的性能提升
过去一年,我们的核心工作重点是提升 C++ 扩展的性能,特别是在处理大型项目时。我们收到了许多关于项目启动和代码着色速度的反馈,并对此进行了大量优化。
具体来说,我们主要提升了两个关键指标:
- 项目启动时间:C++ 扩展启动所需的时间。
- 代码着色时间:打开文件后,语法和语义高亮(例如变量着色、IntelliSense 可用)所需的时间。


通过一系列增量改进,我们取得了显著成果。在模拟大型开源代码库的 PyTorch 项目中,平均而言:
- 项目启动时间提升了 3.5 倍。
- 代码着色时间提升了 4 倍。
这些改进是如何实现的呢?我们进行了多项优化,例如:
- 缓存 IntelliSense 配置,避免每次启动时重新解析。
- 改进了对
compile_commands.json文件的支持和处理。 - 支持多个编译命令数据库。
- 优化了配置处理逻辑。
- 在文件发现过程中并行化了许多任务。
如果你想了解所有增量改进的详细信息,可以查阅幻灯片中链接的博客文章。
除了这些通用优化,我们还增加了更多自定义选项。现在,你可以自定义递归包含路径的处理方式。这对于配置 IntelliSense(它为代码着色、悬停提示等编辑功能提供支持)至关重要。
每次打开文件时,扩展都会递归搜索该文件的所有子目录以查找包含文件,这可能非常耗费资源。现在,你可以根据项目的实际文件树结构,通过三个新设置来定制此行为。例如,在 PyTorch 项目中,通过优化这些设置,我们在 Linux 机器上将文件着色时间从 74 秒降低到了 37 秒。
请注意,这些自定义设置的效果因代码库而异,而上一节提到的性能提升则适用于所有类型的配置。
CMake 工具扩展的改进
上一节我们介绍了 C++ 扩展的性能优化,本节中我们来看看 CMake 工具扩展有哪些新功能。我们将以一个名为 VCMI(一款开源游戏引擎)的项目为例,更新其构建脚本。
首先,我们来看一个常见的多根工作区场景。在 VS Code 中,你可能同时打开了多个包含 CMakeLists.txt 的文件夹。过去,所有这些文件夹的 CMake 项目都会出现在 CMake 项目大纲中,造成干扰。
现在,你可以从 CMake 大纲中排除特定的文件夹,而无需将它们从文件资源管理器中移除。操作步骤如下:
- 打开设置。
- 搜索
cmake.exclude。 - 列出你希望排除的文件夹路径。
这样,被排除的文件夹就不会出现在 CMake 项目大纲中,让你能专注于与当前构建相关的文件。
接下来,我们看看 CMake 预设的更新。CMake 预设是一组可针对特定目标平台定制的配置。VCMI 项目包含许多针对不同平台和依赖管理器的预设。
CMake 工具扩展现在支持 CMake 预设版本 3。新版本引入了一些实用功能:
- 添加注释:现在你可以在预设的 JSON 文件中为特定配置添加描述性注释,例如说明其用途或使用场景。
- 集成 Graphviz:Graphviz 是一个开源的图形可视化工具,可以生成项目的依赖关系图。现在,你可以在预设中直接指定生成
.dot文件,CMake 会自动为你创建包含项目目标和外部库依赖关系的图表。
要查看生成的依赖图,你可以使用 dot 命令将 .dot 文件转换为图像格式(如 PNG)。这有助于你直观地理解项目的结构,并检查链接是否正确。
此外,CMake 语言服务也得到了增强。现在,当你将鼠标悬停在 CMake 变量上时,看到的提示信息是由 CMake 工具扩展本身提供的,而不是第三方工具。这提供了更快、更准确的上下文信息。
利用 GitHub Copilot 辅助开发
在了解了基础工具的改进后,我们来看看如何利用 AI 来辅助开发。GitHub Copilot 及其聊天功能可以帮你处理许多样板代码和手动任务。
在编辑 CMake 文件时,你可以使用 Copilot Chat 的“询问”模式来获取建议。例如,你可以询问如何更新构建流程以增加基于目标的开发。Copilot 会根据你当前打开的文件和选中的代码自动获取上下文,并提供一系列建议。
在编写代码时,你会注意到灰色的内联补全文本。这些代码补全现在使用了 GPT-4.1 模型,该模型在更多 C++ 代码上进行了训练,因此提供的建议比以往更贴合 C++ 代码库的实际情况。
另一个新功能是“下一个编辑建议”。这不同于内联补全,它能识别你的编辑模式,并主动提供多行更改建议。例如,如果你开始将一处定义改为目标编译定义,它会预测你可能想对文件中所有类似的地方进行相同的更改,让你可以通过 Tab 键快速接受一系列修改。
VS Code 还有一个通用的新功能:你可以直接在编辑器中暂存单行更改,而无需保存整个文件后再在 Git 视图中挑选。通过点击行号旁的“+”箭头,你可以增量式地暂存更改,并可以自定义差异装饰的显示方式(例如,仅显示工作树中的更改或暂存区中的更改)。
使用 Copilot 智能体从零构建项目


前面的演示展示了工具和 Copilot 聊天如何辅助现有项目的修改。但如果你想从头开始构建一个新项目呢?手动操作或逐条询问 Copilot 可能效率不高。这时,Copilot 智能体就能大显身手了。
智能体与普通聊天模式的主要区别在于:
- 更高层级的操作:它们可以处理更复杂的工作流。
- 调用工具:例如,可以运行命令行工具、调用编译器或调试器。
- 异步工作:可以独立于你的操作运行。
- 多文件编辑:能够同时编辑多个文件。
让我们通过一个实际例子来体验:从头构建一个 C++ 项目,用于分析代码库中的头文件包含关系并生成 Graphviz .dot 文件。
首先,我们可以利用两个新功能:
- Copilot 指令文件:这是一个包含你编码偏好的文件(例如,使用现代 C++、C++20 标准、命名规范等)。这个文件的内容会自动附加到你与 Copilot 的每次交互中,确保生成的代码符合你的习惯。
- 提示文件:这是一个包含具体任务描述的文件(例如,“创建一个 C++20 项目,分析代码包含关系并生成
.dot文件”)。你可以重复使用这个文件来执行相同任务。
在智能体模式下,Copilot 会首先制定一个待办事项列表,然后逐步执行。它会创建项目结构、实现代码,并进行测试。当需要运行命令行命令(如配置和构建项目)时,智能体会请求你的批准。如果遇到错误(如预设名称不匹配),它会尝试自行排查和修复,而不是立即向你求助。
通过这种方式,智能体可以在短时间内(例如十分钟内)完成一个功能完整的项目,包括解析文件、生成依赖图和成功构建。
除了这种交互式的“智能体模式”,还有 GitHub Copilot 编码智能体。这个专门的智能体被训练来编写代码和创建拉取请求。它的工作方式是异步的:你给出一个高级指令(如“将图表中的所有节点颜色改为蓝色”),它会自动创建分支、修改代码、测试,并最终为你创建一个待审核的 PR。它不会在未经你批准的情况下直接推送代码。
更进一步,你还可以使用 代码审查智能体。在创建 PR 后,你可以请求 Copilot 对其进行审查。它会分析代码,指出潜在问题(如低效操作),并提供具体的修复建议代码。这可以作为代码审查的第一道防线,节省团队成员的时间。
总结与展望

本节课中我们一起学习了 VS Code 中 C++ 开发工具链的多项重要更新。
我们首先看到了 C++ 扩展在项目启动和代码着色方面的显著性能提升,以及如何通过自定义递归包含路径来进一步优化大型项目。
接着,我们探讨了 CMake 工具扩展的改进,包括从项目大纲中排除文件夹、支持 CMake 预设版本 3 的新功能(如注释和 Graphviz 集成),以及增强的 CMake 语言服务。
然后,我们深入了解了如何利用 GitHub Copilot 的聊天、代码补全和下一个编辑建议功能来辅助日常编码和 CMake 脚本修改。
最后,我们演示了 Copilot 智能体的强大能力,包括使用指令文件和提示文件、通过交互式智能体模式从零构建完整项目,以及利用异步的编码智能体和代码审查智能体来自动化代码修改与审查流程。
AI 在开发工具中的集成正在快速演进。GitHub Copilot Chat 现已开源,你可以对其进行定制。模型选择也更加灵活,除了 GPT 系列,还可以选择 Claude、Gemini 等,甚至可以通过 API 密钥接入自己或公司训练的专属模型。通过模型上下文协议,你还可以构建和连接自定义工具。


我们正在开发更专业的 C++ 智能体,以更好地理解 C++ 代码库和复杂的构建系统。我们鼓励你尝试这些新功能,让 Copilot 处理那些繁琐的样板工作和手动任务,从而使你能更专注于真正感兴趣和有价值的编码工作。
059:AI世界中的软件塑造 🛠️



概述
在本节课中,我们将学习大型语言模型和AI编码助手的基本原理、工作机制,以及如何有效地将它们作为工具融入现代软件开发流程。我们将探讨从基础的Transformer架构到现代智能体(Agent)的演进,并重点分析如何为AI时代编写更健壮、更易理解的代码。
章节 1:引言与背景
过去一年对我来说是旋风般的一年。我最终进入了一个从未想过会涉足的行业。
对于那些认识我的人来说,我现在在Anthropic工作,负责Claude Code这个产品。你们中的一些人可能听说过它。如果没有,希望在这次演讲后你能有所了解。
Matt Godbolt向我展示了他是一个多么狂热的用户,他目前正在前排使用Claude Code。这很好。

我的幻灯片在这里。如果你想跟着看,它们是实时更新的,并且与这里的内容匹配。所以,如果你在屏幕上阅读任何内容有困难,尤其是从房间后排,你可以打开幻灯片跟着看。

这是为会议准备的漂亮的缩略图幻灯片。
我是谁呢?John刚才简单介绍了我。我是一名长期的C++标准委员会成员。我参与撰写了C++17、20、23、26中的一些特性(26尚未正式批准),并且很可能我参与贡献的一些特性会进入C++29。
以下是我在委员会任职期间参与的一些工作。我曾担任SG9(Ranges)的主席。我不想自夸,但在C++特性中,Ranges算是“C++中的C++”了。我有幸担任过这次会议的主席,非常享受那段经历。
我曾做过名为“C++小技巧”的演讲,内容涉及C++的深奥角落。我说这些只是想表明,我和你们一样,是C++社区的一员。C++一直是我生活中很重要的一部分。
现在,我在AI领域工作。如果你们对我作为一个“局外人”来这里谈论AI、谈论我所看到的编程未来持怀疑态度,我希望通过这张幻灯片让你们相信,我并非局外人。这是我的世界。我非常感谢这次会议对我职业生涯所做的一切。
需要澄清的是,这并非终点。但我现在确实在从事AI工作,研究编码智能体。
让我们简单谈谈房间里的大象。目前,AI是一个有点两极分化的话题。我认识到这一点。我在这里不是为了讨论这个。有很多关于AI伦理和社会影响的优秀会议。这是一个技术会议。我很乐意在走廊或喝咖啡时讨论那些话题。
在这里,我真正想关注的是技术方面。我不是来说服你们AI编码智能体会改变软件工程的工作方式,或者说“氛围编码”是软件的未来,你们应该忘记代码质量。总的来说,我不是来谈论社会影响或环境影响的。
我想提前说明,我非常关注AI、编码智能体以及智能体整体对环境的影响。我认为,目前行业的一个借口是,我们认为AI将在未来两年加速清洁能源的发展。但这有一个时间限制。我认为,如果我们没有真正看到这种情况发生,很多人会离开这个行业。
所以,这个话题有很多人非常关心环境影响。我希望有更多时间讨论这个,但在这个演讲中,我想重点讨论的是:如何在一个你对AI在世界上有多少控制力有限的世界里帮助你茁壮成长。
没有人有能力阻止AI的进步。你可以对是否应该发生持有意见。但现实是,作为一名软件工程师,它将会影响你的生活。我想尽我所能帮助你在那个世界里茁壮成长。
本次演讲的目标:
- 帮助你从高层次理解LLM,特别是对编写代码非常重要的部分。
- 让你真正拥有一个概念框架,了解如何将编码智能体作为工具使用。
- 我将交替使用编码智能体、AI、LLM等术语。行业也在这些术语间摇摆,其中很多带有商业价值。我试图在技术意义上使用它们。
- 改进你对LLM工作原理、训练方式以及它们为何会犯错的心智模型,以便你能学会预测这些错误、避免它们,并更有效地使用编码智能体。
- 和我所有的演讲一样,我希望你学会如何学习。当事情出错或做对时,我希望你理解如何找出原因,以便改进、迭代并变得更好。我认为这对于编码智能体尤其困难。
- 我也想畅想一下C++的未来,以及编码智能体如何融入那个未来,语言应该如何演进。
我有很多材料要讲。我会尽量讲得不那么快,但我真的有很多话想说,因为我非常关心这个社区,我希望你们都能听到这些很酷的东西。
章节 2:LLM基础:从Transformer到智能体
我将用一个时间线来构建这部分内容,让你对现代AI的发展脉络有个概念。但这并非对AI具体历史的全面概述。
这一切都始于Transformer架构。这是当今无处不在的大型语言模型的基本架构。我将分三部分来解释它。
输入层
我想介绍的第一层是输入层。这可能是作为开发者需要理解的最重要的一层,因为你能够控制的大部分内容都将放在这里。
在这里,我们将标记(token)转换为向量(vector)。我们将语言转化为数字,以便LLM能够基于此进行预测。
它使用一个固定大小的词汇表,以及一个固定的上下文长度或上下文窗口。这是两个重要的变量。它基本上是将词汇和位置编码到上下文窗口中。现在的编码方式比最初开始时复杂得多,但这是2017年开创这一切的论文中的基本架构。
还有一个你可能听说过的第三件事,叫做嵌入维度。这基本上是模型进行所有转换的“工作维度”。要使用LLM,你并不真的需要理解它,但如果你听到这个词,就知道人们在谈论什么。
不过,这里最重要的收获是:上下文窗口限制了模型在任何给定时间可以考虑的信息量。它是你可以输入模型以预测下一个标记的标记数量。这基本上框定了我们在LLM之上构建的几乎所有东西,尤其是对于我们这些不直接参与AI的C++程序员来说。我们在AI之上构建的大部分内容都涉及对这个上下文窗口进行工程化并有效利用它。它是模型的输入层。
那么,我们如何仅用一个上下文窗口来制作一个聊天机器人呢?这令人震惊地原始。
我们基本上这样写:Human: 然后放上你说的话。然后我们放上 Assistant:,这就是我们开始让模型完成回答的地方。当模型觉得完成或回答了问题时,它会说 Human: 然后停止。
这就是我们输入的文字。简单得令人震惊。我认为贯穿本次演讲的一个主题是,AI涉及的很多技术都处于起步阶段,属于“低垂的果实”领域。
我们确实会做一些清理工作,因为当人们意识到这一点时,首先尝试做的事情就是在提示中键入 Human: 或 Assistant: 来试图迷惑它。但基本上,这就是目前正在发生的事情。自LLM时代开启以来,它并没有太大变化。
但这也意味着我们的对话会很快变得非常长。之前对话中的所有内容都会贡献给你正在使用的上下文窗口,这意味着它们都是你输入的一部分。因此,当你试图精心设计输入给模型的内容以获得最佳输出时,你必须考虑所有这些内容都在同一个上下文窗口中。
Transformer与注意力机制
Transformer是现代LLM的关键。基本上,它们允许你在输入中相距较远的、相关的标记之间建立连接。
其数学结构非常有趣,但我认为数学结构无助于你理解编码所需的知识。你需要知道的是,注意力机制正在建立我们大脑自然也会建立的、事物之间的联系。
例如,我们有这个句子:“The student who had studied diligently for weeks, despite the numerous distractions and challenges, finally passed the exam.” 在座的几乎每个人都能解析这个句子,对吧?但在2017年,我们还没有能够做到这一点的语言模型,因为它们难以关联相距较远的事物。
注意力机制真正赋予了模型能力去说:“哦,student 和 passed 是相关的。我们谈论的是学生,passed 是与学生相关,而不是与 challenges 或 distractions 相关。” 如果你实际查看模型第一层的注意力矩阵,你实际上可以看到这些连接对之间的块具有更高的幅度。
这个模型有很多层。一旦过了第一层,我们就在连接“连接”,或者连接一个抽象概念和另一个抽象概念。有很多很多这样的层。但它始于连接词语,然后连接这些连接,等等。事实证明,这种方法效果出奇地好。
输出层与采样
第三层也很重要,实际上你也有一定的控制权(如果你直接查询API的话),但不多,而且差别不大。这就是我们如何选择下一个标记。
模型运行完整个预测机制后,会得出一个基本上转化为下一个标记概率分布的东西。然后模型基于这个分布选择下一个标记。这叫做采样。
通常模型有一个叫做温度的参数,控制采样的随机性。较低的温度更确定性,更常选择最可能的标记;较高的温度更有创造性和随机性,但一致性较差,会时不时选择不那么可能的标记。这对于完成某些工程任务实际上非常重要。找到合适的温度更多是靠感觉,我们没有很好的理论理解为什么是这样。在大多数模型中,我发现编码的理想温度大约在0.6到0.7之间。但如果你想写诗,可能要到0.95左右。这确实是我们需要摸索的东西。
然后,这个输出会重复。我们选择一个标记,然后把它放回上下文窗口,再试一次。这个机制会有一个叫做停止序列的东西。对于聊天机器人来说,就是换行后跟 Human:。当它看到模型生成这个时,就会停止这个反馈循环,并把结果发给你。因此,我们有预定义的停止序列,使我们能够将其构建到真实的基础设施中,在云端发生,然后把结果返回给你。
章节 3:训练与涌现能力
让我们简单谈谈训练是如何进行的。需要明确的是,有一种看法认为AI/LLM只是花哨的自动补全工具,只是在预测下一个标记。从机械角度看,确实如此。但我不认为这么说很有趣。我想通过谈论我们能够“仅仅预测下一个标记”已经有多久了,来说明这种理解已经过时。
对于这些东西,这可以追溯到2018年。我们当时就在进行预训练,规模不同,但技术与今天进行预训练的技术基本相同。
你基本上从一堆随机权重的整个模型开始。然后你查看训练数据中的一些标记。在整个演讲中,我将使用这三个思考表情符号来表示我正在谈论LLM在代码片段中的位置。
在训练早期,你可能会从随机权重中得到这样的输出。然后你要计算一个梯度,这个梯度会使 back 变得更可能,使 front 和 pull 变得更不可能。这背后的数学有点复杂,但并非难以理解。反向传播参与了这一过程。然后我们调整权重,并重复这个过程。对于非常大的数据语料库,我们一遍又一遍地这样做,然后移到下一个标记,再尝试。我们同时对一大堆不同的标记进行这个操作。训练这些东西需要大量的时间和计算。
那是2018年。那是2018年预训练的样子。从那以后变化不大。规模增长了很多。但就预训练(不是强化学习)的总体思路而言,自那个时代以来变化不大。对于一个C++会议来说,这就是你真正需要了解的关于预训练如何发生的内容。
随着我们开始扩大这些模型的规模,我们开始看到涌现出的、好得惊人的行为。“出乎意料的有效”是另一种说法。2019年,我们有GPT-2,15亿参数,训练了大约40GB的数据。GPT-3训练了大约570GB的数据,有1750亿参数。
粗略地看,如果你看这些数字,你会发现预训练在某种程度上构成了对数据的压缩。这是一种近似压缩,允许你以概率方式重现那些标记。我们将训练数据压缩到权重中。
令人惊讶的是,我们作为一个行业还没有完全理解这一点,但这种压缩似乎与我们大脑使用的、对数据的概念性概括压缩类似。这样,基于一个概念和另一个概念,你可以概括出一个可能与输入数据匹配的标记的预测。这是一个比喻,并非完全准确。如果我们确切知道发生了什么,我可能就在AI会议上做演讲了。但这就是我们大致认为它如此有效的原因。当我们要求它对训练数据中未见过的提示进行“解压缩”时,它会以与人类非常相似的方式使用概念性概括。
让我们考虑这个C++代码片段。我们有一个widget工厂。我们采用某种默认初始化的foo工厂。这是一个众所周知的C++工厂模式。实际上这是一个相当古老的模式,我在更现代的代码中见得不多。我们创建一个unique_ptr,调用initialize,然后返回。
想象一下模型正在这里进行补全。有几个补全候选。最明显的一个就是 widget。但如果没有特殊知识,它没有内在理由不能是 foo 或 nullptr。或者 std::move(widget)?这真的是正确的吗?也许我们现在不确定了。或者 std::move(foo)。我们相当确定不是那个。但我们可以创建一个指向widget的新unique_ptr。
我们有一些直觉。它可能是一个分号,可能是42,可能是一个表情符号。这个补全可以是任何标记。我们如何在这些东西之间选择呢?
我想思考一下你的大脑是如何做到这一点的,因为这实际上与我们认为LLM的做法惊人地相似。
我们可能首先要做的是,查看返回类型。所以我们在大脑中进行某种注意力关联,在这个标记和那个标记之间,或者这个标记序列和那个标记之间。我们说,这两个东西可能以某种方式相关。所以 int 和 void 可能不是候选,因为这些与那个关联不相关。
如果你是一个非常优秀的C++程序员,你可能立刻会说:“哦,foo 已经被移动了。” 所以我可以把这个标记(或标记序列)和那个标记关联起来,并说可能不是 foo 或任何与 foo 相关的东西。这是一个移动后使用。我们大脑中有一个代表“移动后使用是坏的”的概念图或概念压缩。
我们知道这个工厂模式可能会返回一个已经初始化的东西。所以 nullptr 似乎不太可能。我们可能不会初始化某个东西然后扔掉它。所以这个 make_unique 似乎不太可能。
很可能变量名会是 widget。所以即使这里是 widget 而我们用的是 foo,并且你不知道移动后使用,很可能有人把东西命名为与工厂函数相同,意味着你可能要返回那个东西。
现在我们只剩下两个候选了。我敢打赌,房间里对这个补全的概率分布是全谱的。因为肯定有人100%确定这是一个移动,也肯定有人100%确定这不是。坦白说,即使我在委员会待了这么多年,我的分布大约是93%对7%。在我查这个之前,我都不确定我能否告诉你为什么。
我们也可以这样补全。这实际上是一个隐式移动。所以正确答案是 widget。正确答案。做 move 不一定错,尽管大多数linter可能会告诉你你在进行不必要的移动到返回值。这是一个隐式移动,因为 widget 的类型与返回值的类型完全匹配。
我很好奇人们对这个补全的概率分布是怎样的。我认为我的分布大概是这样,作为一个C++委员会成员,这有点惭愧,尤其是我当时就在现场。正确答案不是C++17。是C++11。这始终是移动语义的一部分。在C++14中,我们放宽了限制,使其可以转换。我想我们在C++20中做了进一步的放宽。
在AI公司工作的一个有趣之处是,我实际上可以去探究模型对这个问题的“想法”。我可以实际进行这个补全,并找出它的概率分布。这不是我们通常向大众公开的东西,但我得到了许可与你们分享这个。所以我很兴奋。
我们把这个输入模型,得到它的标记分布。我用的是温度1.0,这几乎是它能达到的最随机状态。所以这不是你真正用于编码的设置。但我想看看在小例子中,是否有任何出错的概率。现代模型通常能正确完成这些。但我想房间里的大多数人都会同意,这是C++的一个相当晦涩的角落。这不是C++ 101。我不认为大多数C++ 101的入门级学生能答对。
我们看到了一些非常有趣的东西。在最右边,我们有一些非常小的模型。这是一个2024年的模型。这是2024年末的,这些都是我们模型的较小版本:Haiku。这是3.7 Sonnet,于今年2月发布,稍大一些。这是Sonnet 4,今年5月发布。这是Opus 4,今年8月发布,更大。是的,我们的命名很有趣:Opus, Sonnet, Haiku。
有趣的是,在新模型中,它似乎变得更不可能得到正确答案。我把变量从 widget 改成了 var,因为我不想偏向任何特定的东西。我在这里放了一个 using namespace std;,以便更可能直接得到 move,并真正采样这个关于“是否是隐式移动”的非常具体的问题。
较新的模型在合理的编码温度下,几乎总是能正确完成这个返回。但令我惊讶的是,较老的模型更常这样做。
我认为我们有一个解释。我有很多研究人员和可解释性专家对此进行了权衡并帮助了我。我非常感谢他们。
但我们认为正在发生的是,旧模型只是认为这是Python代码。一旦我们开始使用较新的模型,它实际上理解了移动语义,并且必须在瞬间做出决定,10%的情况下,它会忘记隐式移动的存在,为了安全起见,它会进行移动。所以它的大脑中某处有隐式移动的概念,但有时它会忘记,或者这个概念在残差层中形成得太晚,它无法真正将那个概念与返回值联系起来以正确选择标记。
在温度1下,这仍然是9%或11%(你必须把这两个加起来)。因为有时它生成了std::move调用,这实际上是我们希望它做的。但这里是对数刻度,只是为了让你有个概念,即使是完全错误的答案(分号),在温度1下也有10^-5的概率。这听起来不多,直到你意识到你每天生成大约20万个标记。在典型的编码工作流中,这每天大约会发生一次。它会做出完全错误的事情。所以这很难控制。你必须非常巧妙地设计模型纠正错误的方式。我们稍后会讨论这一点。
但有趣的是,如果我的理论是正确的,即模型理解隐式移动,只是忘记了它,那么我们应该能够在这里放一条注释,写着“这是一个隐式移动”,提醒它隐式移动的存在,然后这个概率分布应该会回到所有模型都说1的状态。同样,那些仍然认为这是Python代码的模型仍然不知道移动是什么,但那些真正理解移动概念、只是10%的时间会忘记的新模型,应该会回到100%。


当事情真的奏效时,你不喜欢吗?我们基本上得到了100%。最新的模型中有一点异常,我们认为这可能与模型只是想保持安全有关,因为它知道这不会失败。但在真实的编码场景中,这基本上永远不会出错。我说“基本上”,显然,在温度1下,这是一个有限的百分比。其中一部分可能只是“不要在温度1下编码”。但我们可以进一步弄清楚。我们可以通过一个非常有趣的实验来进一步理解模型到底在想什么。
我们可以告诉它,在我们的编译器中隐式移动是坏的。那么它是否真的理解了隐式移动的概念?还是它只是捕捉到了“move”这个词,然后想:“哦,我应该移动。” 显然,如果它没有真正理解隐式移动的概念,那么它可能会尝试将这里的“move”与返回值关联起来,从而更可能进行移动,而不是信任隐式移动。但如果它真正理解了隐式移动是什么,它应该能够查看这条注释,建立起一个概念:“哦,我需要做一些在隐式移动中通常不需要做的事情”,并改变它在这里生成的内容。而这确实奏效了。这真的很酷。
你可以看到我们的旧模型,那些认为自己在写Python的,仍然只想单独返回变量。它们开始有点像是:“哦,也许我应该做点与移动相关的事情。” 所以并不是说旧模型对C++一无所知,但新模型知道的数量令人惊讶。
我们实际上认为在这里,是模型不相信你隐式移动是坏的,因为我从未在编译器中见过这个被破坏。但它仍然在这个显然不在其训练数据中的奇怪场景中得到了正确答案。这不是我作为C++程序员见过的场景。我见过很多其他坏掉的东西,但没见过这个特定的。然而,模型并不仅仅是在复述它的训练数据。这几乎完全来自它的预训练数据,而且它不是在复述训练数据,它是在概念上概括“坏的隐式移动”意味着什么。我认为这真的很有趣。
这是为了完整性而设置的对数刻度。只是为了好玩,把这个扔进去。我认为非常有趣的是,新模型变得更好。3.7 Sonnet似乎很困惑。我认为这里的旧模型只是模糊地知道移动在某种程度上与C++11有关,所以它想:“哦,这是一个移动,C++11的东西。” 事实上,在C++14中,通过核心工作组问题对隐式移动语义进行了修复,使这变得更加微妙。我想知道它是否捕捉到了这一点。我认为这非常有趣。
所以,从这一部分中,我希望你得到的收获是:通过概念性概括进行压缩,是理解预训练模型如何存储和复述信息的一个有用比喻。它仍然在复述信息,我稍后会解释为什么我用这个词,但它是在概念上复述。它是在思考概念上发生了什么,然后生成与该概念匹配的标记。
章节 4:强化学习、工具使用与智能体
现在让我们谈谈强化学习,这基本上是自2022年以来除规模变化外的大部分进展。但真正引领我们进入更现代时代(我指的是自ChatGPT发布以来,我甚至不会称那为现代时代,我们很快会讨论现代时代)的许多进步都来自强化学习。

预训练模型的问题在于,它们确实只是“花哨的自动补全”。这是一个概念性概括,但它是在预测下一个标记,而不是在完成任务。这里的任务是回答问题。它是在基于问题的部分内容预测下一个标记,而不是基于其将之理解为任务。
这将是训练数据的一个完全合理的复述。例如:“vector size is defined in the standard library. Vectors are sequence containers that represent arrays that can change size. Et cetera, et cetera, et cetera.” 顺便说一句,Claude帮我写了所有的幻灯片,以防你好奇。我提示了它,所以给我一点功劳。我给了它一个与C++无关的其他演讲的例子,说“生成一个类似这样的C++例子”。我认为它做得很好,这真的很酷。

但让我们简单谈谈强化学习是如何工作的。这部分我必须最小心,因为目前大多数强化学习工作都是商业机密,受到非常谨慎的保护。这是一个非常活跃的研究领域。
但基本思想是:你给模型一个问题或任务,然后生成数百个该任务的补全。然后你需要某种方式来给这些补全打分。例如,你可以给它一个高中数学多选题测试。这是一个非常简单的版本,因为你知道正确答案,而且没有太多歧义。如果它答错了,你不给任何分;答对了,给很多分。
但如果它生成的是C++代码呢?嗯,这实际上相当复杂。以C++代码为例,如果你要求它生成一个接受随机访问范围的函数,在某些上下文中,使用Span可能是正确的,因为你可能还没有C++20。它没有上下文信息来知道你是否拥有C++20,如果你想至少给它一点分数的话。但弄清楚如何编写这些评分函数是一门艺术,这是我们作为一个行业还没有真正弄清楚的事情。甚至还有比这更微妙的地方,我不会在这次演讲中深入,因为你甚至不知道将评分函数应用到哪一组标记上。有时智能体可能走错了路,然后意识到错了,又走上了正确的路,你不想将评分指标应用到走错路的部分,只应用到走对路的部分。这绝对是一场噩梦,也是一个活跃的研究领域。
然后,你基本上基于那些标记补全计算权重的梯度,使模型更可能给出分数较高的补全,更不可能给出分数较低的补全。我在这里做了很多简化。但大致就是这样。你对许多任务重复这个过程很多很多次。再次强调,需要巨大的计算量。而且这些任务通常不是短时间范围的任务。你可能需要为一些任务训练很长时间。例如,在一些我可以谈论的公开训练数据版本中,任务通常是获取GitHub仓库的一个问题,并生成一个修复。评分函数就是人工生成的、修复该问题的拉取请求。这些都是我们经常训练这些模型的实际任务,行业经常在这些任务上训练它们。我并不是在评论Anthropic具体在做什么。我非常热爱我的工作。
这听起来真的很难。比听起来难得多。我真希望有更多时间深入探讨。我实际上有一些关于这方面的C++例子的精彩幻灯片,但因为时间关系不得不删掉了。
让我们稍微跳入更现代的时代:工具使用和智能体。
大约在2023-2024年,我们意识到XML或JSON等结构化标记语言也只是文本。所以我们可以给LLM一个关于如果它使用某个模式会发生什么的描述,并给它一个模式。然后当我们看到它生成那个时,我们就停止,做我们告诉它会发生的那个事情,然后把输出返回给它,让它继续完成。
例如,我们可以让它运行终端命令、运行编译器、运行调试器、运行性能分析器、搜索互联网、搜索代码仓库中的代码、编辑文件。我认为直到我进入这个领域工作,我才意识到“编辑文件”是我们发现的一个功能。这对我来说很疯狂。
实际上,我有一些Claude Code使用Anthropic API进行工具调用的例子。目前,大多数使用XML。由于各种技术原因,有向JSON转变的趋势。
以下是Claude Code中编辑工具调用的样子。Claude必须逐字生成这个,才能只改变文件中的几个字符。如果旧字符串是错的,工具调用失败。如果文件中有多个旧字符串,工具调用失败。我们正处于智能体工具的“ed时代”。在座谁用过ed?如果你没举手,想想看:这就是为什么vi被称为“visual”,因为你在ed里完全看不到自己在做什么。是的,它就是查找和替换。这是你曾经用过的、涉及大型语言模型编辑代码的每一个工具所做的唯一方式。这让我震惊。有太多事情你必须记住。
在某种程度上,我会称之为超人类智能。我自己做不到。每次我需要编辑东西并在合理的时间跨度内生成连贯的代码时,我都做不到。所以我想在这里表达的观点是,我们拥有的工具正在拖我们的后腿。我认为在某个时候,我们会想出如何创建智能体版的vi。剧透一下:不是vi。我们试过。也不是Emacs。而是某种能提供相同实时反馈的东西,让模型在编辑时能看到,这样它就不必进行逐字查找和替换。
为了让你了解这有多好,以及模型在这方面有多擅长,记住我说过Claude Code写了我所有的幻灯片。我的幻灯片是用一个我在2018年从reveal.js分叉出来的JavaScript框架做的,因为我是演讲者,经常做演讲,房间里的其他演讲者都知道我在说什么。总之,这是你至少不会期望出现在预训练数据中的东西。我让它编辑幻灯片。我告诉它我想说什么,然后让它编辑幻灯片。我让它生成了向我的文件添加这个代码块的编辑。
这就是那个编辑工具调用的样子。它第一次就成功了。第一次尝试。它找到了需要放在下面的列表项,并添加了代码片段。嵌套在其他工具使用内部的工具使用,它一点也没搞砸。这对我来说很了不起,但这也表明我们的工具设计目前拖累了模型多少。有很多工作正在积极进行中,但我预计这在未来几年会有很大发展。我认为我们确实处于智能体工具化的“低垂果实”时代。


章节 5:推理、上下文窗口与智能体演进
让我们谈谈推理。实际上,我已经在这次会议上听到一些人把这个描述得比实际情况更复杂了。
基本上,发生的事情是上下文窗口增长得非常快,尤其是在2024年。实际上,我做过这个演讲的一个练习版本,当时我以为我这里的数字错了。我说,这肯定不是GPT-4,GPT-4初始发布时只有8000个标记,但事实上,它就是GPT-4。有一个GPT-4 Turbo模型有128k标记,稍微现代一点。GPT-3有200k标记。Gemini有100万标记。Claude 4有200k标记。基本上,在2025年期间,我们看到这个增长趋于平稳。这有各种原因,我很乐意离线详细讨论。但大致上,上下文窗口不再像2023-2024年那样快速增长。
但在2024年,我们问:我们用所有这些额外的标记做什么?这是那个增长的对数图。2025年初有一些1000万标记上下文窗口的实验。我认为普遍共识是它们不是很有用。当你把模型铺得那么薄时,它开始变得相当笨。所以我们大多停留在这个区间。看看这个在4月左右停止的地方。

那么,我们用所有这些标记做什么呢?我们拥有所有这些额外的上下文窗口。2023年我们开始的一个选择是所谓的检索增强生成,你直接拉入文档、网页、一堆信息。在某种程度上,你可以眯着眼看,觉得:“哦,那是工具调用的早期形式。” 2024年,我们开始做确定性的工具调用,模型去运行编译器并获取编译器输出。这确实开始占用窗口,但接近2024年底,人们说:“等等,如果我们只是要求LLM为我们把更多相关的标记放入它的上下文中,会怎么样?”

这就是整个创新。来自发现这个创新的论文。在我们这样做之前,我们是这样:在Assistant:之后开始模型补全。做了这个之后,我们在Assistant:之后开始模型补全:“让我一步步思考这个问题:”。就这样。这就是在2024年底震撼AI世界的整个创新。这太疯狂了。这让我难以置信。
我们实际上并不理解很多这些事情。这个我认为我们相当理解为什么它有效。
回到我们之前有的这个架构图。我们有这个转换器第1层,第2层,等等。这个“...”做了很多工作。有很多这样的层。记住我说过,第一层的注意力真正连接的是一个标记到另一个标记,连接标记彼此,等等。然后在下一层,你连接的是标记对,或者连接连接。在更后面的层中,你最终构建了或多或少代表一个抽象概念的东西。然后那个抽象概念可以连接到另一个抽象概念。但在某个时刻,如果你在太后面的层中形成抽象概念,那么注意力机制就没有足够的时间将该抽象概念连接到另一个抽象概念。所以它错过了连接,生成了错误的标记。
但是,如果我们告诉模型,它必须首先写出一些代表其“思考”的标记,它实际上必须将那些中间层或后层的抽象概念转化回标记。因为它将那个抽象概念转化回标记,并把它放回上下文窗口,我们现在有了那个抽象概念的更紧凑的表示,这允许它在模型的更早层形成。这说得通吗?我认为这实际上是对这些模型如何在事物之间建立连接的一个非常深刻的见解。当Anthropic内部有人告诉我这个时,我回到家下巴都惊掉了。我想:“现在这合理多了。” 是的,我们在将抽象概念移动到标记层。
那么,有了这个,让我们谈谈智能体。智能体是2025年的热门词汇。我不是在谈论那个流行语版本。我在这里真正谈论的是智能体的技术版本,以及它们如何真正影响你使用LLM进行编码的方式。
早期的LLM,上下文窗口非常小,只能做相对短时间范围的任务。聊天机器人在这方面工作得还不错,因为即使你在与人类对话,模型忘记了你三四个问题前说的话,或者人类忘记了你三四个问题前说的话,你会觉得:“哦,这似乎是人类可能忘记的合理事情。” 它们有点用。我们也用这些来做花哨的代码行内补全,那些多行补全。这是一个非常直接的应用。你只是预测下一个标记的可能性,然后扔掉一切。如果用户按Tab键,你就放进去;如果不按,你就不放,你不用担心这个长的对话历史。
所以这一切都工作得相当好。但如果你给它一个更长时间范围的任务,LLM很快就会偏离轨道。它纠正的能力相当有限。我们也缺少这个扩展思考的部分。
随着LLM变得更大,强化学习变得更好,更长时间运行的任务变得更具可行性。关键的见解是,如果我们让模型看到其行动的结果,然后基于该反馈进行迭代,那么我们可以让人类脱离循环更长时间,让它使用更多时间来给用户更好的响应。
例如,如果我们要求模型生成代码,然后给模型一个工具来运行编译器检查它生成的代码,然后将编译结果添加到上下文窗口,再要求它修复编译错误。现在我们有了一个循环,它可以重新编译代码,看到更多编译错误。它不会自信地声称生成的代码是正确的。它实际上可以检查自己。这个反馈循环真正将聊天机器人转变为智能体。当人们谈论智能体时,他们通常指的是某种具有工具使用自主性的形式,其反馈循环涉及行动的结果,然后可以利用这些结果来创建更好的行动版本。

AI研究机构Metr最近的一项观察是,AI可以完成的任务长度大约每七个月翻一番。自2019年以来,这大致成立。粗略地看,我认为看看2026年这会是什么样子会非常有趣。如果这个趋势继续,实际上还在加速一点点。基本上,是的,我们开始谈论可以运行24小时的模型,可以运行一周的模型。那是什么样子?写一个足够详细的任务描述让模型去工作一周,然后你回来得到一个拉取请求,是什么样子?那一周的工作值得吗?相比于,比如说,五个小时的工作?这将非常有趣。我认为现在没有人能预测未来。总的来说,我是一个乐观主义者。但我认为这会非常有趣。
那么,智能体如何仅从几十万个标记的上下文中理解一个100万行的代码库呢?同样,我认为问“人类如何做到这一点”非常有帮助。我们也没有做到。这里没有人能记住整个Clang代码库。我们搜索关键入口点。我们阅读那些入口点的一些代码,大致浏览寻找与我们试图理解的内容相关的东西。我们查看关键类型和数据结构。如果有核心功能,我们可能搜索关键字符串或类似的东西,我们也可能搜索文档,然后用这些来确定我们需要开始阅读哪个文件。换句话说,我们只将部分代码加载到我们的上下文窗口中。也许我们加载其余代码的摘要,或者基于我们过去对代码的经验或类似情况的经验,加载一个可能略有错误但足够好的概念图。
智能体也这样做。这是我三个月前在ACCU做的一个演示。这是用一个旧模型做的。自那以后我们的模型变得更好了。但我基本上要求它浏览Clang代码库。这是Claude Code。我去初始化了它。我要去问它,给我解释一下if constexpr在Clang中是如何实现的。在座谁写过Clang代码?就是实际的编译器。这些是你需要去问这个问题有多难的人。这非常难。我做过一点点,这是一个非常大、非常复杂的代码库。
所以我问它:“我们如何实现if constexpr?剧透一下,我要请人上来做个演示。” 它会开始工作,它会去搜索一些东西。它实际上要求一个子智能体去为它总结。它搜索了很多关键字符串。它阅读了一些文件,然后得出了这个报告。由于时间关系,我不会详细讲这个。你可以去看我在ACCU演讲中的讲解。但它得出了大致正确的描述。
然后我进去说:“好的,现在我想实现switch constexpr。” 它将基于关于if constexpr的对话进行概念性概括。我把它当作一个工具。我用一个我要求它去阅读的类似例子来准备上下文。然后我给它一个相关的任务,而它仍然在上下文窗口中拥有那些信息。所以它会去写一个计划。我不会讲整个过程,但整个过程中我最喜欢的部分是,我要求它按1到10的等级评价这个任务的难度。这不是我想做的。它在这里说,按1到10的等级,最困难的部分是9/10:说服C++委员会接受这个提案。我认为这有点低了。但公平地说,它没参加过委员会会议,因为我们的会议记录不在它的训练数据中,它们不是公开的。
章节 6:演示:智能体与时间旅行调试器
我将请上来自Undo的Mark Williamson,他一直在做很多智能体工作。我们要在这里做一个快速演示。
Mark:嗨,我是Mark。我是Undo的CTO。当我们说时间旅行调试器时,我们指的是捕获程序整个执行过程的能力,实际上是机器指令精度,包括内存中的所有内容,每个变量、状态、代码行,然后确定性地重放它。我们实际上做了很多技巧来使其比听起来高效得多。我们只捕获可能影响行为的非确定性输入。但最终目标是,人类或AI可以检索他们想要的关于那次软件运行行为的任何信息。
Daisy:你一直在幕后与Claude Code团队合作,主要是你自己,因为我们沟通得不够好,抱歉。但你在为我们的智能体构建一个工具,基本上是一个工具,一个MCP服务器。这是一种创建工具的奇特方式。现在智能体可以控制调试器了,对吧?Claude Code可以控制你的调试器运行。你要向我们展示你能用它做什么。
Mark:是的。在开始之前先说一下背景。在这个案例中,我玩了一个小游戏《毁灭战士》。我使用的是Chocolate Doom,一个开源克隆版,它非常接近原始源代码结构。我用我们的实时记录器工具记录了我的游戏过程。所以我捕获了发生的一切,我们可以重新计算我游戏过程中的任何内容。在这里,我已经在Rdbugger中运行到了那个记录的末尾。在右上角,你可以看到我们当前检查的记录点帧缓冲区的实时更新。我现在要做的是,使用我们的AI集成来找出关于代码语义的一些高级信息。在这个游戏过程中,第二个僵尸是什么时候被杀死的?
它正在后台调用Claude Code,并将Rdbugger的控制权交给Claude。所以现在是Claude的工作,使用专门设计的工具来解决这个问题。它开始了,首先在记录末尾获取回溯以确定位置。然后它会去做一些探索。这有点像人类会做的探索。你可以看到它找到了这个变量total_kills。这听起来像是我们想要的线索,关于发生了多少次击杀。但结果证明这是一个误导。如果你看这里,我们正好运行回到了菜单屏幕。这是关卡初始化的一部分,是关卡生成所有怪物后你可能有的击杀数。所以这不正确。它又在源代码中查找,找到了players数组。每个玩家都有一个killcount,这听起来有希望。然后它开始研究这个,在时间上向后运行,找到它被改变的时间。这次它找到了第一次击杀。它做了书签。这样它可以在后续推理中回到这里,人类也可以如果你想检查的话。
它现在又从那里向后运行,试图找到击杀数为2的时候。这没成功。没关系。它可以恢复。它有一个确定性记录,并且知道如何使用工具。所以现在它回到了时间终点,再试一次。它尝试不同的表达式来评估,以精确找出这个击杀计数何时变为2。你可以看到我因为忘了所有键盘快捷键而在地图和菜单系统中卡了一会儿。
它现在向后走,打印出击杀计数,说是2。现在它向后回退,找出那个值首次被分配的确切时间。它很高兴。完美。这就是第二次击杀发生的地方。它现在为此添加了书签,因为这对用户和进一步探索很有趣。作为一个优秀的小智能体,它实际上在为我们收集一些额外的、我们可能想用来理解发生了什么的上下文。所以我们在这里查询游戏时间(以游戏滴答为单位),查询这在关卡中发生的位置。所有这些都将构成其最终报告的一部分。

哦,这是我最喜欢的部分,我忘了让你停下来:它发现它没有找到僵尸。它找到了一个“possessed”(附身者)。它发现僵尸的枚举是possessed。它推断出possessed有点像僵尸。这很可能就是Mark在这里的确切意思。所以它从我的概念概括到了代码中的真实概念,以回答我的问题。所以它确认了这是一个僵尸人。现在它检查书签在哪里,以便跳转
060:在AI世界中塑造你不写的代码



概述
在本节课中,我们将探讨大型语言模型和AI编码代理如何改变软件工程。我们将学习LLM的基本工作原理、如何有效地使用编码代理作为工具,以及如何编写更易于AI理解和维护的代码。课程内容旨在为初学者提供一个清晰的概念框架,帮助你在AI日益普及的世界中更好地工作。
1. 引言与背景
大家好。过去一年对我来说是旋风般的一年,我最终进入了一个从未想过会涉足的行业。
对于那些认识我的人来说,我现在在Anthropic工作,开发名为Claude Code的产品。你们中的一些人可能听说过它。如果没有,希望在这次演讲后你能有所了解。
Matt Godbolt向我展示了他是一个重度用户,目前正在前排使用Claude Code。这很好。我的幻灯片在这里。如果你想跟着看,它们是实时更新的,并且与这里的内容匹配。所以,如果你在屏幕上阅读任何内容有困难,尤其是从房间后面看,你可以去幻灯片上跟着看。
这是为会议准备的缩略图幻灯片。

我是谁呢?John刚才介绍了我一些。我是一名长期的C++委员会成员,我参与撰写了C++17、20、23、26中的一些特性。几乎可以肯定,我参与贡献的一些特性也将进入C++29。

以下是我在委员会任职期间参与的一些工作。我曾担任SG9(Ranges)的主席。我不想自夸,但在C++特性中,Ranges可以说是C++中的C++特性。我有幸担任这次会议的主席,非常享受这个过程。
我做过一些名为“C++小技巧”的演讲,这些演讲深入探讨了C++的深奥角落。我说这些只是为了表明,我和你们一样,是C++社区的一员。C++一直是我生活中很重要的一部分。
现在我在AI领域工作。如果你对我作为一个“局外人”来这里谈论AI、谈论我所看到的编码未来持怀疑态度,我希望通过这张幻灯片让你相信,我并非局外人。这是我的世界。我非常感谢这次会议对我职业生涯所做的一切。
需要明确的是,这并非终点。但我现在确实在从事AI工作,研究编码代理。
让我们简要谈谈房间里的大象。AI目前有些两极分化。我认识到这一点。我在这里不是要讨论这个。有很多关于AI伦理和社会影响的精彩会议。这是一个技术会议。我很乐意在走廊或喝咖啡时讨论那些话题。但在这里,我真正想关注的是技术方面。
我不是来试图说服你AI编码代理将改变软件工程的工作方式,或者“氛围编码”是软件的未来,你应该忘记代码质量。总的来说,我不是来谈论社会影响或环境影响的。我要提前说明,我非常关注AI、编码代理以及代理技术整体的环境影响。我认为,目前行业的一个借口是我们认为AI将在未来两年加速清洁能源的发展。但这有一个时间限制。我认为,如果我们没有真正看到这种情况发生,很多人会离开这个行业。所以,行业中有很多人非常关心环境影响。我希望有更多时间讨论这个,但在这个演讲中,我想重点讨论的是,在一个你对AI普及程度控制有限的世界里,如何帮助你茁壮成长。
没有人有能力阻止AI的进步。你可以对是否应该发生有意见。但现实是,作为一名软件工程师,它会影响你的生活。我想尽我所能帮助你在那个世界里茁壮成长。
2. 演讲目标
本次演讲的目标如下:
- 帮助你从高层次理解LLM,特别是对编写代码至关重要的部分。
- 为你提供一个关于如何使用编码代理作为工具的概念框架。我会在编码代理、AI、LLM等几个不同术语之间切换。行业本身也在这些术语之间摇摆,而且其中很多术语带有商业价值。我只是试图在技术意义上使用它们。
- 改进你对LLM工作原理、训练方式以及为何会犯错的心智模型,以便你能学会预测这些错误、避免它们,并更有效地使用编码代理。
- 和我所有的演讲一样,我希望你学会如何学习。当某件事出错或做对时,我希望你理解如何找出原因,以便改进、迭代并变得更好。我认为这对于编码代理尤其困难。
- 我想探讨一下C++的未来,以及编码代理如何融入那个未来,语言应该如何演变。如果你昨晚参加了委员会小组讨论,你可能已经听到了一些相关内容。
总之,我要开始了。我有很多材料。我会尽量讲得不那么快,但我确实有很多想说的内容,因为我非常关心这个社区,我希望大家都能听到这些很酷的东西。
3. LLM基础:Transformer架构
我将用一个时间线来构建这个框架,让你了解其大致的时间顺序。但这并非旨在概述AI在现代世界的具体历史。
这一切都始于Transformer架构。这是当今无处不在的大型语言模型的基本架构。我将分三部分来解释它。
3.1 输入层
我想介绍的第一层是输入层。这可能是作为开发者需要理解的最重要的一层,因为你可以控制的大部分内容都将放在这里。
在这里,我们将标记转换为向量。我们将语言转换为数字,以便大型语言模型可以基于此进行预测。
它使用固定大小的词汇表,以及固定的上下文长度或上下文窗口。这是两个重要的变量。它基本上是将词汇和位置编码到上下文窗口中。现在的编码方式比最初开始时复杂得多,但即使在2017年启动这一切的论文中,这也是基本架构。
还有一个你可能听说过的第三件事,叫做嵌入维度。这基本上是模型进行所有转换的工作维度。要使用LLM,你并不真正需要理解这个。但如果你听到这个词,人们谈论的就是这个工作维度。
不过,这里最重要的收获是,上下文窗口限制了模型在任何给定时间可以考虑的信息量。它是你可以输入模型以预测下一个标记的标记数量。这基本上框定了我们在LLM之上构建的几乎所有东西,尤其是对于我们这些不直接参与AI的C++程序员来说。我们在AI之上构建的大部分内容都涉及设计这个上下文窗口并有效地使用它。它是模型的输入层。
3.2 从上下文窗口到聊天机器人
那么,我们如何仅用一个上下文窗口制作一个聊天机器人呢?我们如何将所有这些标记(基本上就是单词)变成一个聊天机器人?
答案出奇地原始。我们基本上写出“Human:”,然后放上你说的话。然后我们放上“Assistant:”,这就是我们开始让模型完成回答的地方。当模型觉得完成或回答了问题时,它会说“Human:”并停止。
这就是我们输入的实际文本。简单得令人震惊。我认为贯穿本次演讲的一个主题是,AI涉及的许多技术都处于起步阶段,属于唾手可得的领域。
我们确实会做一些清理工作,因为当人们意识到这一点时,他们首先尝试做的事情就是在提示中键入“Human:”或“Assistant:”来试图混淆它。但基本上,这就是目前正在发生的事情。自LLM时代开始以来,它并没有太大变化。
但这也意味着我们的对话会很快变得非常长。之前对话中的所有内容都会贡献给你正在使用的上下文窗口,这意味着它们都是你输入的一部分。因此,当你试图精心设计输入给模型的内容以获得最佳输出时,你必须考虑所有这些内容都在同一个上下文窗口中。
3.3 Transformer与注意力机制
我们来谈谈Transformer。这是我本次演讲中唯一一个计划好的笑话,我打赌房间里只有五个人能懂。
谁知道这里该填什么词?注意力。很好,有几个人知道。谁懂这个笑话?这个呢?好吧,反正我觉得好笑。我不在乎。
论文是Vaswani等人发表的,“Attention Is All You Need”。但在那之前两个月,Charlie Puth出了一首歌叫“Attention”。所以我喜欢把功劳归给他。技术上比作者早两个月。
不过,Charlie Puth的粉丝和LLM工程师的交集很小。基本上只有我夹在中间。但这每次都让我发笑。
Transformer确实是现代LLM的关键。基本上,它们允许你在输入中相距较远但相关的标记之间建立连接。
其数学结构非常有趣。我希望在这次演讲中有时间深入探讨,但我认为数学结构无助于你理解编码所需的知识。编码真正需要知道的是,注意力正在建立我们大脑自然也会建立的、事物之间的联系。
以这个句子为例:“The student who had studied diligently for weeks, despite the numerous distractions and challenges, finally passed the exam.” 这个房间里的几乎每个人都能解析这个句子。但在2017年,我们并没有真正能够做到这一点的语言模型,因为它们难以关联相距较远的事物。
注意力是真正赋予模型能力去说“哦,student 和 passed 相关,我们谈论的是学生,passed 是与学生相关,而不是与挑战或干扰相关”的机制。如果你实际查看模型第一层的注意力矩阵,你实际上可以看到这些标记对之间的块具有更高或更大的幅度。
现在,这个模型有很多层。所以,一旦我们过了第一层,我们就在连接连接,或者连接抽象概念和另一个抽象概念。有很多很多这样的层。但它始于连接单词,然后连接这些连接,等等。事实证明,这种方法效果出奇地好。
3.4 输出层与采样
还有第三层也很重要,实际上你也有一定的控制权(如果你直接查询API的话),但不多,而且差别不大。这就是我们如何选择下一个标记。
它运行完整个预测机制,最终产生一个基本上转化为下一个标记概率分布的东西。然后模型基于这个分布选择下一个标记。这叫做采样。通常模型有一个叫做“温度”的参数,用于控制采样的随机性。较低的温度更确定性,它会更经常地选择最可能的标记;较高的温度更有创造性和随机性,但一致性较差,它会时不时选择一些不那么可能的标记。这对于完成某些工程任务实际上非常重要。找出合适的温度更多是一种感觉。我们并没有很好的理论理解为什么会这样。在我见过的大多数模型中,编码的理想温度大约在0.6到0.7之间。但如果你想写诗,可能要到0.95左右。这确实是我们需要摸索和尝试的东西。
然后,这个输出会重复。我们选择一个标记,然后把它放回上下文窗口,再试一次。这个机制会有一个所谓的“停止序列”,对于聊天机器人来说,就是“换行然后Human:”。当它看到模型生成这个时,就会停止这个反馈循环,并把结果发给你。所以我们有预定义的停止序列,使我们能够将其构建到真实的基础设施中,在云端发生,然后把结果发回给你。
4. 训练与概念压缩
现在我们来谈谈训练是如何工作的。需要明确的是,有一种看法认为AI,或者说LLM,只是花哨的自动补全,只是在预测下一个标记。从机制上讲,这确实是正在发生的事情。但我不认为这么说很有趣。我想通过谈论我们能够预测下一个标记已经有多久了,来说明这种理解已经过时了。
对于这些东西,这可以追溯到2018年,我们就在进行预训练,虽然不是同样的规模,但基本上与今天进行预训练的技术大同小异。
你基本上是从整个模型开始,权重都是随机的。然后你查看训练数据中的一些标记。在训练数据中,这会是“back”。我们都很了解std::plus。在整个演讲中,我将使用这三个思考表情符号来表示LLM当前所在的位置。我想不出更好的表示方式,所以如果你反复看到这个感到困惑,那就是我在谈论这个代码片段中LLM所处的位置。
所以在这里,在训练早期,你可能会从随机权重中得到这样的东西:35% back,11% front,5% pull right。然后你要计算一个梯度,这个梯度会使权重调整,使得“back”变得更可能,而“front”和“pull”变得更不可能。这背后的数学有点复杂,但也不是太难,而且是可以理解的。反向传播也参与了这个过程。然后我们调整权重,并重复这个过程。我们一遍又一遍地这样做,针对大量的数据语料库,我们移到下一个标记,然后尝试再做一次。我们同时对一大堆不同的标记进行这个操作。训练这些东西需要大量的时间和计算。
那是2018年。那是2018年预训练的样子。从那以后变化不大。规模大了很多。但预训练(特指预训练,不谈强化学习)的基本思想自那个时代以来变化不大。我敢肯定YouTube评论区的预训练专家会纠正我,但对于C++会议的目的来说,这就是你真正需要了解的关于预训练如何发生的内容。
随着我们开始扩大这些模型的规模,我们开始看到一些涌现的行为,这些行为出奇地好。“Unreasonably effective”是另一种说法。2019年,我们有GPT-2,它有15亿参数,训练了大约40GB的训练数据。GPT-3训练了大约570GB的训练数据,有1750亿参数。
从非常模糊的意义上说,如果你看这些数字,你可以看到预训练构成了对数据的一种压缩。这是一种近似压缩,允许你以概率方式重现那些标记。我们将训练数据压缩到权重中。
令人惊讶的是,作为一个行业,我们还没有完全理解这一点。但令人惊讶的事情似乎是,这种压缩类似于我们大脑使用的、对数据的概念性概括压缩。这样,你就可以基于一个概念和另一个概念,概括出一个可能匹配输入数据的标记预测。这是一个比喻,并不完全是正在发生的事情。如果我们确切知道发生了什么,我想我应该在AI会议上做演讲。老实说,作为一个行业,我们并不知道。但这或多或少是我们认为它效果如此之好的原因。当我们要求它根据一个在训练数据中未见过的提示来解压缩数据时,它使用概念概括的方式与人类非常相似。
4.1 概念压缩示例
让我们考虑这个C++代码片段。我们有一个widget工厂。我们使用某种默认初始化的foo工厂。这是一个众所周知的C++工厂模式。实际上这是一个相当古老的模式,我在更现代的代码中见得不多。我们创建一个unique_ptr,调用initialize,然后返回。假设模型正在这里进行补全。
有几个补全候选。最明显的一个就是widget。但如果没有特殊知识,它没有内在理由不能是foo或no_pointer。或者std::move(widget)?这真的是正确的吗?也许我们现在不确定了。或者std::move(foo)。我想我们很确定不是那个。但我们可以创建一个指向widget的新unique_ptr。我们有一些直觉。它可能是一个分号,可能是42,也可能是一个表情符号。这个补全可以是任何标记。我们如何在这些东西之间做出选择呢?
我想思考一下你的大脑是如何做到这一点的,因为这实际上与我们认为LLM如何做到这一点惊人地相似。
我们可能首先要做的是,我认为返回值很可能是在询问。你排除了表情符号。我们查看返回类型。所以我们在大脑中进行了某种注意力关联,在这个标记和这个标记之间,或者这个标记序列和这个标记之间。我们说,这两件事可能以某种方式相关。所以int和void可能不是候选,因为这些事物与那个关联无关。你可以在脑海中描绘出注意力机制连接代码片段的过程。
如果你是一个非常优秀的C++程序员,你可能立刻会说:“哦,foo已经被移动了。”所以我可以关联这里的这个标记或标记序列与这里的这个标记,然后说,可能不是foo或任何与foo相关的东西。这是一个移动后使用。我们大脑中有一个概念性的图景,或者说我们大脑中信息的概念压缩,代表着移动后使用是不好的。我希望你有这个想法。如果你在CppCon,我希望你明白这一点。
我们知道这个工厂模式可能会返回一些已经初始化的东西。所以no_pointer似乎不太可能。我们可以把它划掉。我们可能不会初始化某个东西然后把它扔掉。所以这个make_unique似乎不太可能。很可能变量名会是widget。所以即使这里是widget,然后我们使用foo,并且你不知道移动后使用,很可能有人把东西命名为与工厂函数相同,意味着你很可能返回那个东西。
现在我们剩下两个候选。我敢打赌,房间里对这个补全的概率分布是全谱的。因为我相信有些人100%确定这是一个移动,也有些人100%确定这不是。我承认,即使我在委员会待了这么多年,我的分布大约是93%对7%。这是在我查资料之前。我不确定我能告诉你为什么。
我们也可以这样补全。这实际上是一个隐式移动。所以正确答案是widget。正确答案。做移动不一定错,尽管大多数linter可能会告诉你,你在进行不必要的移动到返回值。这是一个隐式移动,因为widget的类型与返回值的类型完全匹配。
我很好奇人们对这个补全的概率分布是什么。我想我的分布可能看起来像这样,作为一个C++委员会成员,我感到有点惭愧,尤其是我当时就在那里。有人想说出正确答案吗?不是17。是11。这一直是移动语义的一部分。在C++14中,我们放宽了限制,使其可以转换。我想我们在C++20中做了进一步的放宽。周围有委员会成员可以告诉你更多。去请Barry喝一杯。
在AI公司工作的一个有趣之处是,我实际上可以去探究模型对这个问题的想法。我可以去做这个补全,看看它的概率分布是什么。这通常不会大规模向公众公开,但我得到了许可与你们分享这个。所以我很兴奋。
我们把这个输入模型,得到它的标记分布。我用的是温度1.0,这几乎是它能达到的最随机状态。所以这不是你真正用于编码的东西。但我想看看在小例子中是否有任何出错的概率。现代模型通常能正确回答这些问题。但我想房间里的大多数人都会同意,这是C++的一个相当晦涩的角落。这不是C++ 101。我不认为大多数C++ 101入门级学生能答对。所以这真的很有趣。
我们在这里看到了一些非常有趣的东西。这是我在办公室熬夜时看到的,试图弄清楚并理解它。我们实际看到的是,在最右边,我们有一些非常小的模型。这是一个2024年的模型。这是2024年末的,这些都是我们模型的较小版本:Haiku。这是3.7 Sonnet,于今年2月发布,是一个稍大的模型。这是Sonnet 4,于今年5月发布。这是Opus 4,于8月发布,更大。是的,我们很有趣,Opus,Haiku。
有趣的是,较新的模型似乎更不可能得到正确答案。我把变量从widget改成了var,因为我不想偏向任何特定的东西。我在这里放了一个using namespace std;,这样我更有可能直接得到移动,并真正采样这个关于是否是隐式移动的非常具体的问题。
较新的模型在合理的编码温度下,基本上总是会正确返回。但令我惊讶的是,较旧的模型更经常这样做。
我认为我们有一个解释。我有很多研究人员和可解释性专家对此进行了权衡并帮助了我。我非常感谢他们。
但我们认为正在发生的是,旧模型只是认为这是Python。一旦我们开始使用较新的模型,它实际上理解了移动语义,并且必须在瞬间做出决定,10%的情况下,它会忘记隐式移动的存在,为了安全起见,它会进行移动。所以它大脑中某个地方有隐式移动的概念。但有时它会忘记,或者这个概念在残差层中形成得太晚,它无法将这个概念与返回值联系起来以正确选择标记。它仍然相当低,在温度为1时是9%或11%,你必须把这两个加起来,因为有时它生成了std::move调用,这实际上是我们希望它做的。这里是对数刻度,只是为了让你有个概念,即使是分号这个完全错误的答案,在温度为1时也有10的-5次方的概率。这听起来不多,直到你意识到你每天生成大约20万个标记。在典型的编码工作流中,这每天大约会发生一次,它会做出完全错误的事情。所以这很难控制。你必须非常聪明地设计模型纠正错误的方式。我们稍后会讨论这个。
但有趣的是,如果我的理论是正确的,即模型理解隐式移动,但只是忘记了它,那么我们应该能够在这里放一条注释,写着“这是一个隐式移动”,提醒它隐式移动的存在,然后这个概率分布应该会回到所有模型都说1的状态。同样,那些仍然认为这是Python代码的模型仍然不知道移动是什么,但那些真正理解移动概念、只是10%的时间会忘记的较新模型,会回到100%。
当事情真的奏效时,你不喜欢吗?我们基本上得到了100%。最新的模型在这里有一点人为痕迹,我们认为这可能与模型只是想确保安全有关,因为它知道这样不会失败。还有其他原因。但在真实的编码场景中,这基本上永远不会出错。我说基本上永远不会,显然,在温度为1时,这是一个有限的百分比。其中一部分可能只是不要在温度为1时编码,但我们可以进一步弄清楚。我们可以通过一个非常有趣的实验来进一步理解模型到底在想什么。
我们可以告诉它,在我们的编译器中隐式移动是坏的。那么它是否真的理解这里发生了什么?它只是捕捉到“move”然后说“哦,我应该移动”吗?显然,如果它没有真正理解隐式移动的概念,那么它可能会尝试将这里的“move”与返回值关联起来,从而更可能进行移动,而不是信任隐式移动。但如果它真正理解什么是隐式移动,它应该能够查看这条注释,建立起“哦,我需要做一些在隐式移动中通常不需要做的事情”的概念,并改变它在这里生成的内容。这实际上奏效了。这真的很酷。


你可以看到我们的旧模型,那些认为自己在写Python的,仍然只想单独返回变量。它们开始有点像是“哦,也许我应该做一些与移动相关的事情”。所以并不是说旧模型对C++一无所知,但新模型知道的数量令人惊讶。
我们实际上认为在这里,这是模型不相信你隐式移动是坏的,因为我从未在编译器中见过这个被破坏。但它仍然在这个明显不在其训练数据中的奇怪场景中得到了正确答案。这不是我作为C++程序员见过的场景。我见过很多其他坏掉的东西,但没见过这个特定的。然而,编译器不仅仅是在复述它的训练数据。这几乎完全来自它的预训练数据,而且它不是在复述训练数据,它是在概念上概括“坏的隐式移动”意味着什么。我认为这真的很有趣。
这是对数刻度,只是为了完整起见。只是为了好玩,把这个也放进去。我认为这很有趣,较新的模型变得更好。3.7 Sonnet似乎很困惑。我想这里的旧模型只是模糊地知道移动在某种程度上与C++11相关。所以它说“哦,这是一个移动,C++11的东西”。事实上,在C++14中,通过核心工作组问题对隐式移动语义进行了修复,这使得问题更加微妙。我想知道它是否捕捉到了这一点。我认为这非常有趣。
4.2 本节要点
所以,从这一节中,我希望你得到的收获是:通过概念概括进行压缩,是理解预训练模型如何存储和复述信息的一个有用比喻。它仍然在复述信息,我稍后会解释为什么我用这个词,但它是在概念上复述。它是在思考概念上发生了什么,然后生成与该概念匹配的标记。
5. 强化学习与工具使用
现在我们来谈谈强化学习。这基本上是自2022年以来除了规模变化之外的所有进步。但真正引领我们进入更现代时代(我指的是自ChatGPT发布以来,我甚至不会称那个为现代时代,我们很快会讨论现代时代)的许多进步都来自强化学习。
预训练模型的问题在于,它们确实只是花哨的自动补全。这是一个概念概括,但它是在预测下一个标记,而不是在完成任务。这里的任务是回答问题。它是在基于问题的部分内容预测下一个标记,而不是基于它对这个作为任务的理解。

这可能是对训练数据的完全合理的复述。所以vector的大小在标准库中定义,vector是表示可以改变大小的数组的顺序容器,等等。这是一个例子。顺便说一下,Claude写了我所有的幻灯片,如果你好奇的话,或者说帮助我完成了大部分。我给了它提示,但请给我一点功劳。我给了它一个与C++无关的其他演讲的例子,说“生成一个类似这样的例子,但要针对C++”。我认为它做得很好,这真的很酷。
5.1 强化学习工作原理

我们来谈谈强化学习是如何工作的。这是我必须最小心的一部分,因为目前正在进行的大多数强化学习工作都是商业机密,并且受到非常严格的保护。这是一个非常活跃的研究领域。
但基本思想是,你给模型一个问题或任务,然后为该任务生成数百个补全。然后你需要某种方式来给这些补全打分。例如,你可以给它一个高中数学多选题测试。这是一个非常简单的版本,因为你知道正确答案,而且没有太多歧义。如果它答错了,你不给它任何分数;如果答对了,你给它很多分数。但如果它生成的是C++代码呢?嗯,这实际上相当复杂。以C++代码为例,如果你要求它生成一个接受随机访问范围的函数,在某些上下文中,在那里使用Span可能是正确的,因为你可能还没有C++20。它没有上下文信息来知道你是否拥有C++20,如果你想至少给它一点分数的话。但弄清楚如何编写这些指标、这些评分函数,是一门艺术。这是我们作为一个行业还没有真正弄清楚的事情。甚至还有比这更微妙的地方,我不会在这次演讲中深入,因为你甚至不知道将评分函数应用到哪一组标记上。有时代理可能走错了路,发现是错的,然后走了正确的路,你不想将你的评分指标应用到走错路的部分,只应用到走对路的部分。这绝对是一场噩梦,也是一个活跃的研究领域。
然后你基本上基于那些标记补全计算权重的梯度,使模型更可能给出分数较高的补全,更不可能给出分数较低的补全。我在这里做了很多简化。但大致就是这样。你对许多许多任务重复这个过程很多很多次。同样,需要大量的计算。而且这些任务通常不是短时间范围的任务。在我们的一些训练中,任务通常是获取GitHub仓库中的一个问题,并生成一个修复。评分函数是人工生成的、修复该问题的拉取请求。这些都是我们经常训练这些东西的真实世界任务,行业经常训练这些东西。我对Anthropic具体在做什么不做评论。抱歉。我真的很喜欢我的工作。
这听起来真的很难。比听起来难得多。我希望有更多时间深入探讨。实际上,我准备了一些关于C++例子的精彩幻灯片,但由于时间关系不得不删减。
5.2 工具使用与代理
让我们更多地跳入更现代的时代,关于工具使用和代理。
大约在2023、2024年左右,我们意识到XML或JSON等其他结构化标记语言只是文本。所以我们可以给LLM一个关于如果它使用该模式会发生什么的描述,并给它一个模式。然后当我们看到它生成那个时,我们停止。我们做那件事,让那件我们告诉它会发生的事情发生,然后把输出给它,让它继续完成。例如,我们可以让它运行终端命令,或运行编译器、调试器、性能分析器,搜索互联网,搜索代码仓库中的代码,编辑文件。我不认为直到我进入这个领域工作,我才意识到“编辑文件”是我们发现的东西。这对我来说很疯狂。
所以实际上,我有一些Claude Code使用Anthropic API进行工具调用的例子。目前,大多数使用XML。由于各种技术原因,有向JSON转变的趋势,这里就不深入了。
但以下是Claude Code中编辑工具调用的样子。Claude必须逐字生成这个,才能只改变文件中的几个字符。如果旧字符串是错的,工具调用失败。如果文件中有多个旧字符串,工具调用失败。我们处于代理编程的ED时代。这里谁用过ED?如果你没举手,想想看。这就是为什么VI被称为“visual”,因为你在ED里完全看不到自己在做什么。是的,就是查找和替换。这是你曾经使用过的、涉及大型语言模型编辑代码的唯一工具。这让我震惊。有太多事情你必须记住。
在某种程度上,我会称之为超人智能,我自己做不到。我无法在每次需要编辑东西时,在合理的时间跨度内自己做到这一点,并产生连贯的代码。
所以,我想在这里表达的观点是,我们拥有的工具正在拖我们的后腿。我认为在某个时候,我们会想出如何创建代理的VI等价物。剧透一下,不是VI。我们试过。也不是Emacs。而是某种能在编辑时提供同样主动反馈的东西,这样它就不必逐字查找和替换。
为了让你了解这有多好,以及模型在这方面有多擅长,记住我说过Claude Code写了我所有的幻灯片。我用一个JavaScript框架做幻灯片,这个框架是我在2018年从reveal.js分叉出来的,因为我是演讲者,经常做演讲,房间里的其他演讲者完全明白我在说什么。总之,这是你至少不会期望出现在预训练数据中的东西。我让它编辑幻灯片。我告诉它我想说什么,然后让它编辑幻灯片。所以我让它生成了添加这个代码块到我的文件的编辑。
这就是那个编辑工具调用的样子。它第一次就成功了。第一次尝试。在这里,它找到了需要放在下面的列表项,并添加了代码片段。嵌套在其他工具使用中的工具使用,它一点也没搞砸。这对我来说很了不起,但这也像是,对我来说,表明我们的工具设计目前拖累了模型的程度。有很多工作正在积极进行,但我预计这在未来几年会有很大发展。我认为我们确实处于智能体工具化的唾手可得时代。


6. 推理与上下文窗口
我们来谈谈推理。实际上,我已经在这次会议上听到一些人把这个描述得比实际情况更复杂。
基本上,发生的事情是上下文窗口增长得非常快,尤其是在2024年,2023年到2024年。实际上,我做过这个演讲的一个练习版本,当时我以为我这里的数字错了。我说,这肯定不是GPT-4,GPT-3在最初发布时只有8000个标记,但这实际上就是GPT-4。有一个GPT-4 Turbo模型有128k标记,稍微现代一点。GPT-3有200k标记。Gemini有100万标记。Claude 4有200k标记。基本上,在2025年期间,我们看到这个数字趋于平稳。这有各种原因,我很乐意离线详细讨论。但大致上,上下文窗口不再像2023-2024年那样快速增长。但在2024年,我们问,我们用所有这些额外的标记做什么?这是那个增长的对数图。
2025年初有一些实验,有一些1000万标记的上下文窗口。我认为普遍共识是它们不是很有用。当你把模型摊得那么薄时,它开始变得相当笨。所以我们主要在这个范围内趋于平稳。看看这个在4月左右停止的位置。那么,我们用所有这些标记做什么呢?我们有了所有这些额外的上下文窗口。
我们在2023年开始的一个选项是所谓的检索增强生成,你直接拉入文档、网页、一堆信息,在某种程度上,你可以眯着眼看,然后说“哦,那是工具调用的早期形式”。2024年,我们开始做确定性的工具调用,模型去运行编译器并获取编译器的输出。这确实开始占用窗口,但接近2024年底时,人们说,等等,如果我们只是要求LLM为我们把更多相关的标记放入它的上下文中,会怎么样?

所以这 literally 就是整个创新。来自发现这个创新的论文。在我们这样做之前,我们是这样的:好的,我们将在“Assistant:”之后开始模型补全。在我们这样做之后,我们将在“Assistant: Let me think through this step by step:”之后开始模型补全。就这样。这就是在2024年底震撼AI世界的整个创新。这太疯狂了。这让我难以置信,这竟然是彻底改变LLM的东西。实际上,你知道,我们并不理解很多这些事情。这个是我们我认为我们相当理解为什么奏效的一个。

回到我们之前有的这个架构图。我们有这个转换器第1层,第2层,等等。这个“...”做了很多工作。有很多这样的层。记住我说过第一层的注意力是真正连接一个标记到另一个标记,连接标记彼此,等等。然后在下一层,你有点连接标记对,或者连接连接。在后面的层中,你最终构建了或多或少代表一个抽象概念的东西,一个抽象。然后那个抽象可以连接到另一个抽象。但在某个时刻,如果你在太后面的层中形成抽象,那么注意力机制就没有足够的时间将那个抽象连接到另一个抽象。所以它有点错过了连接,生成了错误的标记。
但如果我们告诉模型,它必须首先写出一些代表其“思考”的标记,它实际上必须将那些中间层或后层的抽象转化回标记。因为它将那个抽象转化回标记,并且我们把它放回上下文窗口,我们现在有了那个抽象的更紧凑的表示,这允许它在模型的更早层形成。这说得通吗?我认为这实际上是对这些模型如何在事物之间形成连接的非常深刻的见解。当Anthropic内部有人告诉我这个时,我回到家时下巴都惊掉了。我说,现在这合理多了,我们把抽象移到了标记层。
7. 代理与长时程任务
那么,我们来谈谈代理。代理是2025年的热门词汇。我不是在谈论那个流行语版本。我在这里真正谈论的是代理的技术版本,以及它们如何真正影响你使用LLM进行编码的方式。
早期的LLM,非常小的上下文窗口,只能做相对短时间范围的任务。聊天机器人在这方面工作得很好,因为即使你在与人类对话,模型忘记了你三四个问题前说的话,或者人类忘记了你三四个问题前说的话,你会觉得“哦,这似乎是人类可能忘记的合理事情”。它们有点用,对吧?我们也把这些用于花哨的代码行内补全,那些多行补全。这是一个非常直接的应用。你只是预测下一个标记的可能性,然后扔掉一切。如果用户按Tab键,你就把它放进去;如果他们不按,你就不放,你不用担心这个很长的对话历史。
所以这一切都工作得很好。但如果你给它一个更长时间范围的任务,LLM很快就会偏离轨道。它纠正的能力相当有限。我们也缺少这个扩展思考的部分。
随着LLM变得更大,强化学习变得更好,更长时间运行的任务变得更具可行性。关键的见解是,如果我们让模型看到其行动的结果,然后基于该反馈进行迭代,那么我们可以让人类脱离循环更长时间,让它使用更多时间来给用户更好的响应。
例如,如果我们要求标记(模型)生成代码,然后给模型一个工具来运行它生成的代码的编译器,然后将编译结果添加到上下文窗口,然后要求它修复编译错误。所以我们有了这个循环,它可以重新编译代码,看到更多编译错误。它不会自信地声明它生成的代码是正确的。它实际上可以检查自己。这个反馈循环是真正将聊天机器人转变为代理的原因。当人们谈论代理时,他们通常指的是某种具有工具使用的自主性,其反馈循环涉及某种其行动的后果、结果,然后它可以使用这些来创建更好的行动版本。
所以,这是AI研究机构Metr最近做的一个观察:AI可以完成的任务长度大约每七个月翻一番。自2019年以来,这在非常模糊的意义上一直成立。我认为看看2026年这会是什么样子会非常有趣。如果这个趋势继续,它实际上在加速一点点。如果你看的话,我不知道它是否会继续加速。但基本上,是的,我们开始谈论可以运行24小时的模型,可以运行一周的模型。那么,为一个模型编写足够详细的任务让它去工作一周,然后你回来得到一个拉取请求,这看起来是什么样子?那一周的工作值得相对于,比如说,五个小时的工作的改进吗?这将非常有趣。我认为现在没有人能预测未来。总的来说,我是一个乐观主义者,对于那些认识我的人来说。但我认为这会非常有趣。

7.1 代理如何理解大型代码库
那么,代理如何仅从几十万个标记的上下文中理解一个100万行的代码库呢?同样,我认为问“人类如何做到这一点”非常有帮助。当一个代理无法将所有代码放入上下文时,它如何遍历并弄清楚代码库中发生了什么以执行任务?
我们也不这样做。这里没有人记住了整个Clang代码库。我们搜索关键入口点。我们阅读这些入口点的一些代码,大致浏览寻找与我们试图理解的内容相关的东西。我们查看关键类型和数据结构。如果有核心功能,我们可能搜索关键字符串或类似的东西,我们也可能搜索文档,然后用它来找出我们需要开始阅读哪个文件。换句话说,我们只将部分代码加载到我们的上下文窗口中。也许我们加载其余代码的摘要,或者基于我们过去对代码的经验或我们在类似情况下的经验,加载一个可能略有错误但足够好的概念图景到我们的记忆中。
代理也这样做。所以这是我在三个月前在ACCU做的一个演示。这是用一个较旧的模型。自那以后我们的模型变得更好了。但我基本上要求它遍历Clang代码库。这是Claude Code。我去初始化了它。
我要去问它,向我解释if constexpr在Clang中是如何实现的。这里谁写过Clang代码,比如实际的编译器?这些是你需要去问这有多难的人。这非常难。我做过一点点,这是一个非常大、非常复杂的代码库。
所以我问它,if constexpr是如何实现的?剧透一下,我要请人上来做演示。它会开始,它会去搜索一些东西。它实际上要求一个子代理去为它总结。它搜索了很多关键字符串。它阅读了一些文件,然后得出了这个报告。由于时间关系,我不会详细讲解。你可以去看我在ACCU演讲中的讲解。但它得出了大致正确的描述。
然后我进去说,好的,现在我想实现switch constexpr。它会基于关于if constexpr的对话进行概念概括。我把它当作一个工具。我用一个类似的例子来准备上下文,我要求它去阅读。然后我会在它仍然在上下文窗口中有那个信息时,给它一个相关的任务。
所以它会去写一个计划。我不会详细讲整个过程,但整个过程中我最喜欢的部分是,我要求它按1到10的等级评价这个任务的难度。这不是我想做的。它在这里说,按1到10的等级,最困难的部分是9/10,是说服C++委员会接受这个提案。我认为这有点低了。但公平地说,它没参加过委员会会议,因为我们的笔记不在它的训练数据中,它们不是公开的。
8. 演示:代理与时间旅行调试器
好的,我要请上来自Undo的Mark Williamson,他一直在做很多代理工作。我们要在这里做一个快速演示。我希望麦克风没问题。我们四月份在ACCU开始交谈。
你研究时间旅行调试器。我想简要解释一下那是什么。当然。嗨,我是Mark。我是Undo的CTO。当我们说时间旅行调试器时,我们指的是捕获程序整个执行过程的能力。所以实际上是机器指令精度,包括内存中的所有内容,每个变量状态、代码行,然后确定性地重放它。我们实际上做了很多技巧来使其比听起来高效得多。我们只捕获可能影响行为的非确定性输入。但最终目标是,人类或AI可以检索他们想要的关于那次软件运行行为的任何信息。
你一直在幕后与Claude Code团队合作,主要是你自己,因为我们的沟通本应更好,我很抱歉,但构建了一个工具给我们的代理,基本上是一个工具,一个MCP服务器。这是一种创建工具的奇特方式。现在代理可以控制调试器了,对吧?Claude Code可以控制你的调试器运行。你要向我们展示你能用它做什么。
当然。在开始之前,先做一点铺垫。在这个案例中,我玩了一个小游戏《毁灭战士》。我使用的是Chocolate Doom,一个开源克隆版,它非常接近原始源代码结构。我用我们的实时记录器工具记录了我的游戏过程。所以我捕获了发生的一切,我们可以重新计算我游戏过程中发生的任何事情。
我们这里有的是,我已经在那个记录中运行到了末尾,在Rdbugger中。在右上角,你可以看到我们当前检查的记录点帧缓冲区的实时更新。我现在要做的是使用我们的AI集成来找出关于代码语义的一些高级信息。在这个游戏过程中,第二个僵尸是什么时候被杀死的?

它正在
061:打造更可读的C++



在本教程中,我们将学习如何将传统的、命令式的C++ UI代码重构为更具声明性的风格。我们将以WX Widgets框架为例,展示如何通过识别隐藏的数据结构、减少语句、偏好表达式以及应用设计模式,使代码更易于阅读和维护。
概述
我们从一个基础的WX Widgets“Hello World”应用程序开始,逐步重构其布局代码。初始代码充满了显式的对象创建和布局步骤,显得冗长且脆弱。我们的目标是将其转化为一个清晰、声明式的数据结构,该结构描述了UI的最终形态,而非构建它的具体步骤。
从命令式到声明式
上一节我们介绍了教程的目标和起点。本节中,我们来看看初始的命令式代码存在哪些问题,以及声明式编程的核心思想。
初始的布局代码充满了“如何做”的指令:创建尺寸器、添加控件、设置标志。这种代码容易出错,例如遗漏添加某个控件或使用错误的尺寸器,都会导致UI显示异常。
声明式编程是一种非命令式的编程风格,程序描述其期望的结果,而不是明确列出必须执行的命令或步骤。在C++的上下文中,这通常意味着:
- 偏好表达式而非语句。
- 让数据结构清晰可见,从而更容易看出程序的意图。

我们的重构将围绕这两个原则展开。
第一步:识别并简化通用模式
上一节我们明确了声明式编程的原则。本节中,我们通过一个简化的UI例子,开始识别代码中的通用模式。
我们从一个包含四个控件(按钮、文本框等)的简单UI开始。初始的创建和布局代码是命令式的。

以下是第一个重构步骤:使用auto和就地构造来减少冗余。
// 重构前:显式类型和分离的构造
wxButton* clickButton = new wxButton(this, wxID_ANY, “Click”);
wxTextCtrl* dogTextCtrl = new wxTextCtrl(this, wxID_ANY, “Dog”);
// ... 更多控件
sizer->Add(clickButton);
sizer->Add(dogTextCtrl);
// ... 更多添加操作
// 重构后:使用auto和就地构造
auto* clickButton = new wxButton(this, wxID_ANY, “Click”);
auto* dogTextCtrl = new wxTextCtrl(this, wxID_ANY, “Dog”);
// ... 更多控件
sizer->Add(clickButton);
sizer->Add(dogTextCtrl);
// ... 更多添加操作
观察代码,我们发现一个重复模式:sizer->Add(new WidgetType(this, wxID_ANY, args...));。我们可以将其抽取为一个函数。
第二步:抽取函数与引入数据结构
上一节我们识别出了通用模式。本节中,我们将其抽取为函数,并进一步演化为一个轻量的数据结构。
我们创建一个create_and_add函数,它封装了公共的创建和添加逻辑。类型通过模板参数传递,值通过函数参数传递。
template <typename WidgetT>
void create_and_add(wxSizer* sizer, wxWindow* parent, const wxString& label) {
sizer->Add(new WidgetT(parent, wxID_ANY, label));
}
然而,我们注意到这个“操作”本身(类型和参数)可以看作一个待应用的数据单元。我们可以定义一个struct来保存这些信息,并提供一个成员函数来执行操作。
template <typename WidgetT>
struct Widget {
wxString label;
auto create_and_add(wxSizer* sizer, wxWindow* parent) const {
sizer->Add(new WidgetT(parent, wxID_ANY, label));
}
};
// 使用方式
Widget<wxButton>{“Click”}.create_and_add(sizer, this);
这看起来像是为了单次调用而创建了一个临时对象,但它为后续的组合奠定了基础。
第三步:组合与元组
上一节我们将单个控件的创建封装成了数据对象。本节中,我们看看如何组合多个这样的对象。
现在我们有多个Widget对象。我们希望将它们作为一个整体来处理。在C++中,异质集合(即包含不同类型元素的集合)可以用std::tuple表示。
我们可以创建一个Widget的元组,然后使用std::apply对元组中的每个元素调用create_and_add函数。
auto widgets = std::make_tuple(
Widget<wxStaticText>{“Cat”},
Widget<wxButton>{“Click”},
Widget<wxTextCtrl>{“Dog”},
Widget<wxButton>{“Done”}
);
std::apply([&](auto&&... w) { (w.create_and_add(sizer, this), ...); }, widgets);
为了使接口更友好,我们可以创建一个辅助函数来隐藏元组的创建。
template <typename... WidgetTs>
void create_and_add_all(wxSizer* sizer, wxWindow* parent, WidgetTs&&... widgets) {
(widgets.create_and_add(sizer, parent), ...);
}
// 使用更简洁
create_and_add_all(sizer, this,
Widget<wxStaticText>{“Cat”},
Widget<wxButton>{“Click”},
Widget<wxTextCtrl>{“Dog”},
Widget<wxButton>{“Done”}
);
我们还可以使用C++20的概念(concept)来约束WidgetTs参数包,确保它们都支持create_and_add操作。
第四步:处理尺寸器与构建树形结构

上一节我们处理了控件的组合。本节中,我们将尺寸器也纳入这个声明式体系,从而构建出完整的UI树。
尺寸器(Sizer)本身也可以被看作是一种特殊的“控件”,它包含一组子控件或子尺寸器。我们可以用同样的思路为尺寸器创建数据对象。
struct Sizer {
wxOrientation orientation;
std::vector</* 某种表示子项的类型 */> children;
// ... 标志等
auto create_and_add(wxSizer* parent_sizer, wxWindow* parent) const {
auto* sizer = new wxBoxSizer(orientation);
for (const auto& child : children) {
child.create_and_add(sizer, parent); // 递归或迭代添加子项
}
parent_sizer->Add(sizer, flags);
}
};
通过让Sizer也支持create_and_add操作,我们实现了控件的嵌套。一个水平尺寸器可以包含几个按钮,一个垂直尺寸器可以包含多个水平尺寸器或控件,从而形成一棵UI树。
最终,我们可以将整个UI布局表达为一个单一的、嵌套的数据结构,并通过一个顶层的fit_to函数一次性将其构建出来。
fit_to(this, // 父窗口
VSizer{ // 垂直尺寸器
HSizer{ // 水平尺寸器
Widget<wxStaticText>{“Cat”},
Widget<wxButton>{“Click”}
},
HSizer{ // 另一个水平尺寸器
Widget<wxTextCtrl>{“Dog”},
Widget<wxButton>{“Done”}
}
}
);
第五步:扩展性与设计模式
上一节我们构建了声明式的UI树。本节中,我们探讨如何扩展这个体系以支持更多样化的控件,并引入设计模式。

当遇到API不一致的控件(例如滑块wxSlider,它的构造函数参数是整数值而非字符串)时,简单的Widget结构就不够用了。我们需要分离“创建什么”和“如何添加”。
这里可以应用模板方法模式。我们定义一个基类WidgetBase,它包含一个create_and_add方法(模板方法),该方法调用一个纯虚函数create(由子类实现)。
template <typename Derived>
class WidgetBase {
protected:
~WidgetBase() = default;
public:
auto create_and_add(wxSizer* sizer, wxWindow* parent) const {
sizer->Add(create(parent)); // 模板方法
}
private:
virtual wxWindow* create(wxWindow* parent) const = 0;
};

class SliderWidget : public WidgetBase<SliderWidget> {
int m_min, m_max, m_value;
public:
SliderWidget(int min, int max, int value = min)
: m_min(min), m_max(max), m_value(value) {}
private:
wxWindow* create(wxWindow* parent) const override {
return new wxSlider(parent, wxID_ANY, m_value, m_min, m_max);
}
};
为了支持流畅的构建器模式(例如设置尺寸标志、位置等),我们需要让设置函数返回对象本身的引用(对于可变对象)或一个新对象(对于不可变对象)。这允许链式调用:Widget<...>{...}.with_flags(...).with_pos(...)。
总结

本节课中我们一起学习了如何将命令式的C++ UI代码重构为声明式风格。关键步骤包括:
- 识别模式:在重复的命令式代码中寻找隐藏的数据结构。
- 抽取与抽象:将通用操作抽取为函数或可调用的数据对象。
- 使用元组处理集合:利用
std::tuple和std::apply处理异质控件集合。 - 构建组合树:让尺寸器也遵循相同的接口,形成可嵌套的UI树结构。
- 应用设计模式:使用模板方法模式分离变与不变,使用构建器模式实现流畅接口。

最终成果是一段更简洁、更易读、更不易出错的代码,它清晰地描述了UI的最终形态,并且其核心数据结构与具体的UI框架(如WX Widgets)解耦,具备了良好的可移植性。声明式编程的核心在于让代码表达“是什么”,而不是“怎么做”,这能极大地提升代码的可维护性和开发者的意图传达效率。
062:数据导向设计能快一百万倍吗?🚀


在本节课中,我们将要学习如何通过扩展问题解决的上下文,结合数学启发式方法,将数据导向设计的性能提升推向极致。我们将跟随Andrew Drakeford的思路,从一个耗时14000秒的机器学习回归问题出发,探索如何通过系统性的问题分析,最终将其优化到仅需18毫秒。
理解问题与制定计划 🧠
上一节我们概述了课程目标,本节中我们来看看解决问题的第一步:理解问题并制定计划。
数据导向设计的核心思想是:程序只是将数据从一种形式转换为另一种形式。要理解问题,必须先理解数据;要理解成本,必须先理解硬件。代码本身就是数据。掌握越多的上下文信息,就越能构建出优秀的解决方案。
然而,现实世界中的设计问题远比理论复杂。我们面临算法选择、数据空间布局、缓存一致性、并行化(如SIMD)、执行顺序优化以及高维数据查询等诸多挑战。开发者很容易埋头于优化数据局部性或向量化,却忽略了问题域本身可能存在的、更大的优化机会。
我主张,设计问题包含三个层面:
- 逻辑算法层面:核心的计算逻辑。
- 时空排序与布局层面:数据的组织与访问模式。
- 问题域特定层面:只有深入理解具体领域知识才能发现的模式和约束。
“缺失的一步”正是一种系统性的工作方法,能够统筹所有这些层面。我提议使用数学家乔治·波利亚的问题解决方法论作为我们的工具。他的方法强调启发式思维,并提供了一个生命周期:理解问题、制定计划、执行计划、回顾反思。
波利亚指出,解决问题失败的主要原因首先是对问题理解不完整,其次是计划失败(要么毫无计划地蛮干,要么空等灵感降临)。因此,遵循一个结构化的过程至关重要。
波利亚的问题解决启发法 📚
上一节我们介绍了系统性方法的重要性,本节中我们来看看波利亚提供的具体问题解决启发法。
波利亚的经典著作《怎样解题》提供了详细的技巧。其核心流程如下:
- 理解问题:未知数是什么?已知数据是什么?条件是什么?问题是否可解?
- 制定计划:寻找已知与未知之间的联系。考虑你是否见过类似问题,能否重新表述它。
- 执行计划:耐心、细致地执行每一步,并检查中间结果。
- 回顾反思:检查结果,尝试用其他方法推导,总结方法以便将来使用。
当陷入困境时,一个关键启发法是:尝试解决一个相关的、更简单的问题(辅助问题)。通过解决它,你可以获得对解决方案空间的直觉。
以下是波利亚提到的一些具体启发法:
- 类比:联想已知的类似问题。
- 分解与重组:将问题分解成极小部分,再以不同方式重组。
- 一般化与特殊化:尝试解决更一般或更特殊版本的问题。
- 逆向工作:从假设的答案出发,反向推导。
- 引入辅助元素:构造图表、符号或设定中间目标。
- 问题归约:将问题映射到另一个有解法的领域。
此外,苏格拉底式的提问方式也很有帮助,例如:澄清性问题、探寻证据的问题、探讨不同观点的问题、探究影响的问题。
实战案例一:金融定价矩阵优化 💹
上一节我们学习了理论工具,本节中我们将这些工具应用于第一个实战案例。
我们曾遇到一个金融定价函数,它需要填充一个大矩阵(例如相关矩阵),矩阵的每个元素都需要调用一个极其昂贵的函数 F_impulse。矩阵维度 n 可能很大,导致计算复杂度为 O(n²)。
初始代码结构如下:
double total = 0.0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
total += F_impulse(t, i, j); // 昂贵调用
}
}
而 F_impulse 内部涉及对三个时间值取最小值的操作。
应用波利亚方法:
- 理解问题:我们注意到矩阵可能沿对角线对称。但更核心的是
F_impulse的内部操作min(t, T_i, T_j)。 - 制定计划/创建辅助问题:为了看清本质,我们大幅简化问题:
- 忽略参数
t。 - 将时间轴
T_i简化为整数索引i。 - 将昂贵的
F_impulse替换为直接返回min(i, j)。
这样,我们就创建了一个易于分析的辅助问题:计算矩阵M[i][j] = min(i, j)所有元素之和。
- 忽略参数
- 执行计划/画图:我们画出了一个 7x7 的矩阵,并标注出
min(i, j)的值。图案立刻显现出来——矩阵被分割成了多个清晰的带状区域,而非n²个独立单元。
我们发现,对于1 1 1 1 1 1 1 1 2 2 2 2 2 2 1 2 3 3 3 3 3 1 2 3 4 4 4 4 1 2 3 4 5 5 5 1 2 3 4 5 6 6 1 2 3 4 5 6 7n x n矩阵,只有n个不同的区域值,每个区域的大小可以轻松算出。 - 回顾反思/推广回原问题:将简化问题的洞察带回原问题:
- 若
t < 所有 T_i,则min(t, T_i, T_j)总是t,只需计算一次并乘以n²。 - 若
t > 所有 T_i,则退化为min(T_i, T_j),即我们分析的简化问题。 - 若
t在中间,则是上述两种情况的混合。
新算法只需计算少数几次F_impulse,然后乘以对应区域大小即可。
- 若
结果:性能提升了 40 到 260 倍。通过简化问题、绘制图表,我们获得了巨大收益。
实战案例二:留一法回归的百万倍加速 🤖
上一节我们通过简化与绘图解决了一个问题,本节我们来看一个更复杂的案例,展示扩展问题上下文的力量。
在量化基金的机器学习中,我们使用“留一法回归”来评估预测因子的稳健性。对于包含 N 个数据点的数据集,需要对每个点 i 都进行一次回归拟合(使用除 i 点外的所有数据),然后用该拟合来预测点 i 的值,并计算残差。这需要进行 N 次拟合,朴素实现复杂度为 O(N²)。

线性回归的目标是找到参数 β₀(截距)和 β₁(斜率),以最小化误差平方和。我们使用正则化(岭回归),其解由以下公式给出:
β = (XᵀX + λI)⁻¹ Xᵀy
其中,X 是设计矩阵(包含一列1和一列特征值),y 是目标值。计算 XᵀX 和 Xᵀy 需要对整个数据集进行求和,这是 O(N) 的操作。在留一法循环中,这导致了 O(N²) 的复杂度。

初始(次优)思路:
我们识别出求和的内部循环是瓶颈。自然的想法是:使用向量化、多线程、SIMD 来加速这个求和。这或许能带来 10 倍的加速,看起来不错。
但这是最佳方案吗?应用波利亚方法:
- 理解问题:我们聚焦于单个求和项,例如
SX = Σx_k(所有数据点的 x 之和)。 - 制定计划/创建辅助问题:我们不再只看单次拟合的内部循环,而是扩展上下文,审视所有
N次留一拟合之间的关系。我们提出辅助问题:“第i次留一拟合的SX⁽ⁱ⁾(排除点i的 x 之和)与完整的SX有什么关系?” - 执行计划/画图:我们画出一个表格,行表示留一拟合的索引,列表示数据点。很快发现其中存在大量冗余计算。
- 对于只有两个数据点的简单情况:
SX⁽⁰⁾ = x₁,SX⁽¹⁾ = x₀。 - 观察发现:
SX⁽⁰⁾ = (x₀ + x₁) - x₀ = SX - x₀。同理,SX⁽¹⁾ = SX - x₁。
- 对于只有两个数据点的简单情况:
- 洞察:每个留一法的求和,都等于全局总和减去被排除的那个数据点的值! 这个发现让人豁然开朗。
这个规律同样适用于SX⁽ⁱ⁾ = Σ_{k≠i} x_k = (Σ_{all k} x_k) - x_i = SX - x_iΣx²、Σy和Σxy。 - 新算法:
- 步骤1:用
O(N)时间计算一次全局总和:SX,SX2,SY,SXY。 - 步骤2:对于每个
i,用O(1)时间计算留一法的统计量:SX⁽ⁱ⁾ = SX - x_i,等等。 - 步骤3:用
O(1)时间计算该留一拟合的回归参数β₀⁽ⁱ⁾和β₁⁽ⁱ⁾。
总复杂度从O(N²)降为O(N)。
- 步骤1:用
实现与结果:
我们使用一个支持向量化操作的库(如作者自研的 DR3)来实现新算法。对于百万级数据点的百万次留一拟合,运行时间从14000秒优化到了18毫秒,实现了近百万倍的加速。
关于精度的启发法:
在求和计算中,标准的顺序累加可能导致精度损失。波利亚的“逆向工作”启发法提示我们:要使最终求和最精确,最后一步相加的两个数应该量级相近。这引出了成对求和算法:递归地将数组对半拆分,分别求和后再相加。与普通累加相比,成对求和能提供更好、更稳定的精度。

总结与收获 🎯
本节课中我们一起学习了如何将乔治·波利亚的系统性问题解决方法论应用于高性能计算和数据导向设计。
核心收获如下:
- 设计问题是高维的:不要局限于代码层面的优化(如缓存、向量化),要深入问题域寻找特定模式和约束。
- 波利亚方法论是强大工具:遵循“理解-计划-执行-反思”的循环,并善用启发法(如创建辅助问题、画图、类比)。
- 警惕“英雄式”局部优化:过早聚焦于内循环优化(例如,只想用SIMD加速求和)会限制视野,可能让你错过通过算法重构实现数量级提升的机会。
- 画图与简化极其有效:绘制草图能快速暴露问题结构。解决一个简化的辅助问题往往是突破僵局的关键。
- 体验“顿悟”的感觉:通过这种方法获得数十倍乃至百万倍的性能提升,会带来巨大的成就感。



最后,记住波利亚的忠告:“如果你不能解决所提出的问题,那就先去解决一个与之相关的问题……绕过无法直接克服的障碍,设计某个合适的辅助问题。”当你感到困惑时,不要停滞不前,去寻找一个你能解决的问题,它必将为你指明方向。
063:面向大众的声明式重构

在本节课中,我们将要学习一种名为“声明式重构”的代码重构方法。我们将探讨其核心概念、工作原理,并通过一系列代码示例来展示如何利用简单的声明式注解和规则,自动化地完成复杂的C++代码迁移和优化任务,而无需深入了解编译器内部原理。
什么是声明式重构?
首先,我们来分解“声明式重构”这个术语。
声明式是一种编程风格,其核心思想是描述“应该做什么”,而不是描述“具体如何做”。这种“做什么”而非“如何做”的理念是声明式风格的精髓。
重构通常被定义为在不改变程序可观察行为的前提下修改其实现。但更宽泛地说,我们讨论的是修改程序本身,不包括修复Bug或添加新功能,其他对代码的修改都可视为重构。
而面向大众这一部分对我个人而言至关重要。我希望构建的工具能被任何C++工程师使用,而不仅仅是专家。我希望它的学习曲线平缓,对初级开发者有用,并且不需要你了解编译器的工作原理就能上手使用。这符合“简单的事情应该简单,复杂的事情应该可能”的理念。
声明式风格的优点与挑战
声明式风格有许多优点。它通常更易于推理,更不容易出错。例如,你可以向一个从未做过软件工程的人展示一个正则表达式,他们也能大致猜出它的工作原理。
然而,任何事物都有权衡,声明式风格也有其缺点。
首先,实现难度高。计算机本质上是过程式的,而声明式语言则不是。因此,必须有一个编译器、解释器或运行时系统在幕后将你的声明翻译成计算机能理解的东西,这是一个非常困难的任务。
其次,声明式工具往往限制性更强。例如,正则表达式(理论上)只能匹配正则语言,无法匹配像C++这样的复杂语法。当遇到限制时,开发者可能会用声明式工具构建出复杂而丑陋的解决方案来绕过限制,这就是为什么我们会看到非常难看的SQL查询或正则表达式。
为何选择声明式进行重构?
那么,如果我们想在重构中使用声明式风格,它应该是什么样子?我们需要什么?
重构的核心是查找程序的某些部分并替换它们。如果要以声明式的方式进行:
- 查找部分:我们需要描述要找什么,而不是如何找到它。
- 替换部分:我们需要描述要替换成什么,而不是如何进行具体的文本操作。
因为我关心“面向大众”,所以这需要尽可能直观。此外,由于我们重构的是C++,仅仅查看程序文本是不够的,我们需要访问类型系统,并且必须处理宏和预处理器。
一个显而易见的选择是 Clang Tidy。它非常强大,自带数百个检查项,其中约一半能自动修复代码。它是免费且可定制的。
但是,编写自定义的Clang Tidy检查有其痛点。分发和维护自定义检查很麻烦,因为你需要处理Clang不稳定的内部API。更重要的是,编写Clang AST匹配器非常复杂,需要深入了解编译器的内部工作原理和语言细节,这对于初级开发者来说门槛太高。
一种新的声明式方法:代码注解
让我们思考一种更声明式的方法。如果我们可以直接在我们的C++代码上添加注解来表达我们的重构意图呢?工程师已经熟悉C++,所以我们只需用注解来标明我们希望重构时发生什么。
以下是这种方法的一个核心示例:函数内联。
示例:函数内联
内联函数的基本思想是,你注解一个希望被内联的函数,工具会找到所有调用该函数的地方,并用函数体替换这些调用。
// 原始代码(定义端)
[[bento::inline]]
int add(int x, int y) {
// 这是一个重要的加法
return MACRO_ADD(x, y);
}
// 原始代码(调用端)
int result = add(1, 2);
// 内联后的调用端代码
int result = (1 + 2); // 这是一个重要的加法
这里有两个关键点:
- 文本性:替换保留了宏和注释,表明这是一种基于文本的替换。
- 语义正确性:工具自动添加了括号
(1 + 2)以保持运算优先级,这表明它不仅仅是文本替换,还考虑了语义,以确保生成等价的代码。
内联的威力:不仅仅是优化
内联是一个强大的基础操作,可以用来实现多种重构。
1. 重命名函数
你可以创建一个具有新名称的函数,让旧函数调用新函数,然后内联旧函数。这样,所有对旧函数的调用都会被替换为对新函数的调用。
2. 移除默认参数
你可以将带有默认参数的函数拆分为两个重载:一个接受所有参数,另一个不接受默认参数并显式调用第一个。然后内联后者,从而在调用点显式地添加默认值。
3. 改造构造函数(例如,返回 std::optional)
你可以创建一个返回 std::optional 的静态工厂函数(如 make),让构造函数委托给这个工厂函数并解包结果。然后内联构造函数,这样所有构造调用都会变成调用 make 并解包。
内联的强大之处在于它分离了重构的复杂性。重构的复杂性主要来自两方面:
- 局部复杂性:更改本身固有的复杂性(例如,将弱类型改为强类型的所有逻辑)。
- 网络复杂性:由于代码被多处使用而需要做的连带更改。
内联迫使你先处理所有局部复杂性(在定义端修改代码),然后自动为你处理所有网络复杂性(自动更新所有调用点)。这种解耦使得大规模重构可以分步、并行地进行,降低了风险。
更通用的模式匹配与替换
内联虽然强大,但要求必须有一个“定义”可以内联。对于更通用的模式匹配和替换,我们可以借鉴Java工具Refaster的思路。
其核心思想是编写“前模板”和“后模板”来描述代码转换。
示例:字符串连接优化
假设我们想将低效的字符串连接 (operator+) 替换为更高效的 absl::StrCat。
我们可以编写如下规则:
// 将两个字符串的加法替换为 StrCat
[[bento::rewrite(expr)]]
auto replace_concat(const std::string& s1, const std::string& s2) -> decltype(auto) {
[[bento::before]] auto before = s1 + s2;
[[bento::after]] auto after = absl::StrCat(s1, s2);
}
这个规则会查找形如 s1 + s2 的表达式(其中 s1 和 s2 是 std::string 或可转换的类型),并将其替换为 absl::StrCat(s1, s2)。
我们甚至可以编写更强大的规则来处理多个字符串连接,或者优化掉不必要的 std::to_string 调用。
这种声明式规则非常直观,因为它们的主体就是普通的C++代码片段。正因为如此,AI工具(如Claude)也能较好地理解并生成这类规则,这大大降低了编写门槛。
处理声明和类型的变更
内联和表达式重写主要处理的是表达式,它们不会改变变量的声明类型。但更改类型声明是一种常见的重构需求。
示例:将弱类型升级为强类型
假设我们有一个使用整数别名 time_t 的调度器,我们想将其升级为强类型 absl::Time。
我们面临两个问题:
- 函数签名改变会破坏所有现有调用。
- 函数内部的运算(如
time_t + 整数)在新类型下可能不合法。
解决方案是结合使用声明重写规则和内联。
步骤1:编写声明重写规则
我们编写一个规则,描述如何将 time_t 类型的变量声明改为 absl::Time,并提供一个“反向包装器”以便在表达式中临时转换回旧类型。
[[bento::rewrite(decl)]]
auto upgrade_time_t(const auto& init) -> decltype(auto) {
[[bento::before]] time_t t = init;
[[bento::after]] absl::Time t = absl::FromTimeT(init);
[[bento::unwrap]] absl::ToTimeT(t); // 如何转换回 time_t 供表达式使用
}

步骤2:使用内联处理函数签名变更
我们不直接更改函数签名,而是为旧的 time_t 参数版本添加一个内联层,让它调用新的 absl::Time 参数版本。

// 旧函数(保持签名不变,但内联)
[[bento::inline]]
void schedule_at(time_t t, Callback cb) {
schedule_at_strong(absl::FromTimeT(t), cb); // 调用新函数
}

// 新函数(使用强类型)
void schedule_at_strong(absl::Time t, Callback cb);
步骤3:迭代应用表达式重写规则
在工具自动应用了声明重写和内联之后,代码中会留下许多从强类型到弱类型的转换。此时,我们可以编写一系列简单的表达式重写规则来清理这些冗余转换,例如:
- 将
time_t(0)替换为absl::ToTimeT(absl::Now())。 - 将
absl::ToTimeT(some_time + absl::Seconds(15))优化为absl::ToTimeT(some_time) + 15(如果运算在弱类型域进行)。 - 消除连续的
absl::FromTimeT(absl::ToTimeT(...))转换。
通过按顺序运行这少数几个(例如4个)简单、安全的规则,我们可以自动化地将整个代码库从弱类型迁移到强类型,而无需一次性进行危险的全量修改。
如何使用与总结
要使用这些声明式注解,你只需要在项目中包含一个简单的头文件。这个头文件只包含一些宏和空结构体,对运行时性能和编译时间的影响微乎其微。它采用0BSD许可证,可以自由使用。
这个头文件本身不做任何事情,它只是声明。真正的“如何做”部分由一个独立的二进制工具完成,该工具读取这些声明并执行转换。
本节课中我们一起学习了声明式重构的核心思想。主要收获如下:
- 声明式方法是强大的:通过简单的声明式构建块(内联、重写规则),你可以组合出复杂的、自动化的重构流程。
- 赋能库作者和团队:库作者可以声明升级路径,用户运行工具即可更新自己的代码,这促进了生态演进。团队可以定义代码规范模式,在代码审查时自动提示并修复。
- 降低门槛:这种方法更接近普通C++代码,使得初级开发者更容易理解和编写重构规则,AI辅助生成也更为可行。
- 分离关注点:它巧妙地将复杂的重构分解为一系列局部、安全的步骤,降低了大规模代码变更的风险。




声明式重构为管理大型C++代码库的技术债务提供了一条清晰、可控且易于协作的路径。
064:如何重构C++代码




在本教程中,我们将学习重构C++代码的基础知识。重构是在不改变代码外部行为的前提下,改善其内部结构的过程。这对于提高代码的可维护性、可读性和可扩展性至关重要。我们将通过具体的代码示例,探讨常见的代码“坏味道”以及相应的重构技巧。
为什么重构在今天尤为重要
过去,重构通常由资深开发者主导,他们在代码审查中发现并改进问题。然而,如今大部分代码并非完全由开发者手动编写,而是借助AI工具生成。因此,初级开发者更需要掌握重构技能,以便阅读、理解和维护这些并非由他们亲手编写的代码,从而提升代码库的整体健康度。
重构的核心目标是减少技术债务,使未来的代码变更成本更低。
重构的定义与前提
重构是一种有纪律的技术,用于重组现有代码体,改变其内部结构而不改变其外部行为。
如何确保我们没有改变外部行为?答案是测试。没有测试的重构就像鱼没有自行车——两者本不相关,且无法有效进行。充分的测试是进行稳定重构的基石。
何时以及为何进行重构
重构不应仅仅是为了“清理”代码。其主要目的是改善代码库的健康状况,以支持新功能和未来的代码变更。
- 在实现新功能前:如果现有代码结构不适合新功能,先进行重构适配,确保所有测试通过,然后再添加新功能。将重构和新功能开发混在一起会难以测试并引入新错误。
- 提高可读性和可维护性:面对难以理解的代码库时,如果能在保持行为不变的前提下将其改写得更清晰,就应大胆去做。测试会给你信心。
- 性能改进:虽然性能改进可能改变外部行为(如执行时间),但功能测试通常仍然适用。
- 预防未来错误:修改那些容易误用、可能导致错误的代码结构。
总结:重构通常是为了减少技术债务。技术债务是指那些在当前代码库演进背景下,我们不会那样写,但又没有时间修改的代码。它会增加未来任何变更的成本。因此,我们需要定期重构以降低未来变更的代价。
重要原则:
- 在变更时重构:不要重构那些稳定且你不需要触碰的代码。
- 基于实际收益重构:不要基于主观偏好(例如代码风格)进行重构,除非是为了统一项目规范。
代码审查与初步重构
让我们从一个简单的代码片段开始,这是一位学生提交的练习:
for (int i = 0; i < players.size(); ++i) {
if (!players[i] || !players[i].isAlive) {
continue;
}
// ... 处理存活玩家
}
这段代码检查玩家是否“非活跃”或“已死亡”,如果是则跳过。
存在的问题(代码坏味道):
- 重复:
players[i]被多次访问。 - 可读性差:条件中的否定逻辑
!players[i]令人困惑。 - 数据成员暴露:直接访问
isAlive数据成员,破坏了封装性。最好通过函数(如isAlive())来访问,以便未来可以添加验证或日志记录。 - 意图不清晰:
!players[i]的含义不明,可能表示玩家对象“无效”或“非活跃”。
重构步骤:
- 引入临时变量或使用基于范围的 for 循环来消除重复。
- 将否定逻辑和成员访问封装到具有清晰命名的函数中。
- 合并常见的条件检查。
重构后代码:
for (const auto& player : players) {
if (player.isNotActiveOrDead()) {
continue;
}
// ... 处理存活玩家
}
通过引入 isNotActiveOrDead() 成员函数,代码意图变得清晰,也封装了内部细节。这是一个“提取方法”重构的简单例子。
处理更复杂的循环
现在看一个更复杂的循环,它使用了索引,并且包含我们刚才重构过的逻辑:
for (size_t i = 0; i < players.size(); ++i) {
auto& player = players[i];
if (player.isNotActiveOrDead()) {
continue;
}
auto action = player.getAction();
// 根据 action 执行不同操作...
}
进一步重构:
- 避免裸循环:可以改用基于范围的 for 循环,并结合
std::views::enumerate(C++23)或手动管理索引来消除越界错误风险。 - 提取循环逻辑:如果“遍历所有存活玩家”这个模式在代码库中多次出现,就应该将其提取成一个独立的函数。
使用现代C++重构:
// C++20 风格:在循环内初始化索引
for (size_t i = 0; const auto& player : players) {
if (!player.isNotActiveOrDead()) {
auto action = player.getAction();
// 使用 i 和 player...
}
++i;
}
// 或者,使用函数封装
forEachLivePlayer([](size_t index, Player& player) {
auto action = player.getAction();
// 处理 action...
});
forEachLivePlayer 函数隐藏了遍历和过滤的复杂性,使主逻辑更清晰。这是“以函数对象取代函数”或“提取方法”的另一种形式。
消除条件判断
在循环内部,我们根据 action 这个枚举值执行不同操作,通常这会引出一个 switch 语句:
// 在循环内部
switch (action) {
case Action::Attack: // ... break;
case Action::Defend: // ... break;
// ... 更多 case
}
问题:每当新增一个动作类型,都需要修改这个 switch 语句,违反了开放-封闭原则。
重构(以多态取代条件表达式):
- 为“动作”定义一个抽象基类(或接口)。
- 为每种具体的动作(如攻击、防御)创建派生类。
- 让
player.getAction()返回一个指向基类对象的智能指针(或利用工厂模式根据枚举创建对象)。 - 直接调用动作对象的
execute()方法,利用多态分发行为。
重构后:
// 在循环内部
auto command = player.getActionCommand(); // 返回 unique_ptr<ActionCommand>
command->execute(player, gameState);
这样,新增动作类型只需添加新的派生类并在工厂中注册,无需修改现有的分发逻辑。
重构模式与代码坏味道
Martin Fowler 的《重构》一书提供了系统的重构方法。它包含一个“代码坏味道”目录和对应的“重构方法”目录。
为什么需要目录?
- 提供共享语言:像设计模式一样,便于团队沟通。
- 系统化问题检测:使发现问题的方法可重复。
- 提供可操作的解决方案:针对特定坏味道,有已知的解决模式。
- 辅助教育和代码审查:使用共同语言进行指点和讨论。
这是一个活的目录,随着编程实践的发展,会出现新的坏味道和缓解方法。
以下是部分常见的代码坏味道及其重构方法:
1. 过长函数
问题:函数包含太多逻辑,难以维护和测试。
重构方法:
- 提取函数:将一部分代码提取成新函数。
- 以查询取代临时变量:如果临时变量妨碍了提取函数,考虑直接调用查询函数。
- 以函数对象取代函数:如果函数非常复杂,将其变成一个类,将局部变量变为类的字段,然后将函数体分解为这个类的多个小方法。
2. 过长参数列
问题:参数过多难以理解和使用,容易出错。
重构方法:
- 引入参数对象:将相关参数封装成一个对象。
- 保持完整对象:如果调用者已有一个对象,其中包含函数所需的所有数据,则直接传递该对象。但需谨慎,这可能破坏封装,让函数知道过多调用者细节(参见“得墨忒耳定律”)。
- 依赖注入:传递一个接口对象,让函数通过它获取所需数据,而非传递数据本身。
3. 过大的类
问题:类承担了太多职责。
重构方法:
- 提炼类:将部分职责分离到新类中。
- 提炼子类/超类:如果职责可以划分,使用继承来分离。
4. 注释
问题:注释本身可能是坏味道,因为更好的代码本身应该能够表达意图。
重构方法:
- 提取函数:用函数名来解释一段代码块的行为。
- 引入断言:用代码来验证假设,而非用注释说明。
5. Switch语句/复杂条件表达式
问题:难以维护和扩展。
重构方法:
- 以多态取代条件表达式:如前所述,使用继承和多态。
- 以状态/策略取代类型码:将表示类型的代码(枚举/整数)替换为状态对象或策略对象。
- 引入Null对象:用代表“空”行为的对象来消除对
nullptr的检查。
特定于C++的重构技巧
这些是《重构》书中未强调,但对C++开发者特别有用的技巧:
- 将弱类型转换为强类型:不要仅用
int、double表示有单位的值(如米、秒)。定义具有语义的类型,防止误用。 - 从手动内存管理转向库管理:使用智能指针和容器,避免裸
new/delete。 - 从指针/引用转向值语义:在可能的情况下,直接使用对象值而非指针,可以简化所有权并减少空悬引用的风险。
- 优先使用标准算法而非裸循环:使用
<algorithm>中的函数(如std::for_each,std::transform)能使意图更声明式、更清晰。 - 使用RAII管理资源:利用构造函数获取资源,析构函数释放资源(如锁、文件句柄、内存),避免手动配对调用(如
lock/unlock)。
总结与最佳实践


在本教程中,我们一起学习了重构C++代码的核心概念和实用技巧。
关键要点:
- 重构是必要的技能:尤其在AI辅助编程时代,开发者必须负责理解和提升代码质量。
- 安全重构靠测试:没有充分的测试覆盖,重构就像在黑暗中摸索。
- 有明确目的才重构:重构是为了提高可维护性、可读性、可扩展性或预防错误,而不是随意更改。
- 小步前进,持续验证:进行小的、可测试的增量更改,并频繁运行测试以确保没有破坏任何功能。
- 善用工具和模式:了解常见的代码坏味道和重构模式,可以系统化地改进代码。可以将AI作为辅助工具来识别坏味道,但最终的重构决策和执行应由开发者负责。
- 沟通与协作:对于影响范围广的重构,需要与团队成员沟通,并考虑兼容性(例如,暂时保留旧接口并标记为废弃)。


记住,程序员的责任是“拥有”代码,理解其运作,并维护其质量。通过持续重构,我们可以防止代码库演变成一个难以维护的“大泥球”。
065:实用的端到端方法



在本教程中,我们将学习如何采用一种实用的端到端方法来构建安全的C++应用程序。我们将探讨如何在整个开发生命周期(设计、实现、代码审查、CI流水线和发布后)中,针对核心安全类别(边界、生命周期、初始化和类型安全)实施安全实践,并提升代码的整体正确性。
设计阶段
上一节我们介绍了本教程的概述,本节中我们来看看安全实践的第一个环节:设计阶段。安全始于设计阶段。为了理解我们的流程,让我们先看看架构。
我们采用了与Chromium相同的架构,即多进程模型。浏览器进程拥有最高权限,在Windows上以中等完整性级别运行。渲染进程则是不受信任的,因为它们权限最低,在Windows上以不受信任的完整性级别运行。我们使用Mojo进行进程间通信。默认情况下,我们假设渲染进程总是可能被攻破。如果渲染进程需要执行任何特权操作,我们会使用Mojo消息将其代理给浏览器进程。
我们还遵循Chromium的“二要素规则”。该规则指出,如果你的代码需要处理不可信的输入,并且代码是用不安全的语言(如C/C++)编写的,同时你的代码需要运行在无沙盒或高权限进程(如浏览器进程)中,那么这三个条件不应同时成立。这意味着,如果你想在浏览器进程中使用C++解析JSON,这是绝对不允许的。你可以在沙盒化的工具进程中做同样的事情,或者使用Rust等安全语言。
我们的安全审查流程如下:我们与安全团队一起,由安全负责人审查每个新功能的设计。
以下是我们在审查时关注的事项:
- 我们是否引入了任何新的不可信输入入口点?
- 如果是,我们是否需要新的进程来处理这些数据?
- 此功能是否需要任何新的进程间通信或Mojo通信?
- 此特定功能是否需要任何新的模糊测试覆盖?
边界安全
上一节我们探讨了设计阶段的安全考量,本节中我们来看看边界安全。本节将涵盖空间内存安全类别。
我们将重点关注两个方面:一是启用加固的Libc++,二是不安全缓冲区使用。
首先看看启用加固的Libc++。Libc++提供了四种加固模式:
- unchecked:无加固。
- fast:启用基本且低开销的安全检查,建议用于大多数生产构建。
- extensive:在fast模式基础上,增加一些中等开销的额外检查。
- debug:包含大量检查,但由于开销显著,不应在发布生产版本中使用。
那么,使用的编译器选项是什么?只需一个标志 -D_LIBCPP_HARDENING_MODE,它有四个值,分别对应一种模式。
有多种检查类别。fast模式技术上只覆盖其中两个类别:有效元素访问和有效输入范围。extensive模式覆盖更多,debug模式则覆盖所有类别。
在我们的项目中,我们使用extensive模式。
需要快速说明的是,Libc++加固处理的范围远不止边界安全,我们这里只讨论边界安全部分。
关于C++标准库加固的说明:C++26支持标准库加固。Herb Sutter在今年的CppCon上强调了这一点。目前,Libc++提供的加固比C++26提案中的更全面。
现在,当我们启用Libc++加固后,如果加固失败会发生什么?这是可以配置的。我们可以通过一个特定的宏来配置加固失败的行为。对于我们的代码,我们将其配置为内置陷阱。
什么是内置陷阱?它会导致程序异常停止执行。它会触发一个立即的CPU陷阱,在大多数平台上导致立即且无条件的崩溃,通常通过UD2(未定义指令)实现。这确保了程序立即停止,不给攻击者操纵程序状态的机会。在后面的幻灯片中,当我提到“陷阱”时,指的就是这种由内置陷阱引起的不可利用的崩溃。
接下来,让我们看看第一个类别“有效元素访问”的一些例子。标准规定:检查任何通过容器对象或迭代器访问容器元素的尝试是否有效,且未尝试越界或访问不存在的元素。
让我们看一些代码。vector是一个类,其下标运算符operator[]在无边界检查时被调用,或者front、back、pop_back在空向量上被调用,或者erase被end迭代器调用时,所有这些都会触发陷阱。
以下是一些代码示例:
std::vector<int> v; // 空向量
v.front(); // 陷阱
v.back(); // 陷阱
v.pop_back(); // 陷阱
v[5]; // 越界访问,陷阱
v.erase(v.end()); // 陷阱
如果没有启用加固,这些调用可能稍后才会崩溃。
与vector类似,string也加固了这些函数。在空字符串上调用front、back、pop_back会触发陷阱。越界调用operator[]会触发陷阱。使用end迭代器调用erase也会触发陷阱。
有趣的是,如果这段代码在没有加固的情况下构建和运行,它实际上不会崩溃,并产生特定的输出,从而导致未定义行为,因此我们需要加固。
再考虑一个类型:optional。如果在optional为空时调用其箭头运算符operator->或解引用运算符operator*,则会触发陷阱。注意,optional不是容器,但它仍属于此类别。同样,如果没有加固,此代码不会崩溃并打印输出。
与optional类似,expected类型也涵盖在此范围内。类似地,当expected没有存储值时调用箭头运算符或解引用运算符会触发陷阱。当expected没有错误对象时调用.error()也会触发陷阱。
其他涵盖的容器包括string_view、span、array、list和deque。显然,这不是一个详尽的列表。
有趣的是,它的作用不止于此,甚至超越了容器,例如算法。当在空范围上调用std::ranges::min、max、minmax时,所有这些调用都会触发陷阱。
我们结束对“有效元素访问”的探索,看看下一个类别“有效输入范围”。
文档说明:检查作为库函数输入给出的范围,其哨兵是否可以从起始迭代器到达。
以vector为例。如果你用first大于last调用erase(例如,用end和begin而不是begin和end调用erase),这将触发陷阱。如果没有加固,此代码不会崩溃。
string也是如此。如果用first大于last调用erase,也会触发陷阱。同样,如果没有加固,编译此代码不会崩溃。
在继续之前,我们覆盖Libc++加固的最后一个类别“非空指针”。这与边界安全无关。文档说明:检查被解引用的指针不是空指针。它提到,在大多数系统上,空指针解引用不会危及内存安全。然而,这是一种未定义行为,可能由于编译器优化导致奇怪的错误,我们稍后会在幻灯片中看到这样一个问题。
我快速提一下涵盖的一些内容。string_view有多个接受const char*的函数,所有这些都涵盖在内。如果在运行时用空指针调用它们,都会触发陷阱。与string_view类似,string也有两个函数(还有其他函数),如果在运行时用空指针调用,也会触发陷阱。
这引出了Libc++加固部分的结尾,并带来一个问题:是否需要更多的边界安全,或者这已经提供了我们所需的一切?
让我们看一个例子。这是一个打印函数,它接受一个int数组和长度,并尝试打印一些内容。代码编译并运行。但你可以立即注意到编程错误:使用了i <= length而不是i < length,这是越界访问和未定义行为。这段代码在运行时可能因地址清理器而崩溃。然而,大多数生产构建并未进行清理,这可能导致漏洞利用。
这就是不安全缓冲区使用发挥作用的地方。它试图将此类代码构造转换为编译时错误。它实际上只是一个编译时标志。
让我们看看。这是我们之前有的代码,它编译正常,但在运行时出错。当我们用不安全缓冲区使用标志编译它时,编译失败,并打印出错误信息。需要注意的一点是,实际失败的条件是i <= length,但该确切位置并未被指出。它只是指出可能在运行时导致问题的编码模式。
让我们尝试更好地理解这一点。这里有一些代码,它实际上做了正确的事情,但也给出了相同的错误。正如所说,这段代码没问题,但被指出的模式是相同的。
不安全缓冲区文档包含更多信息。它指出缓冲区操作永远不应使用原始指针执行,警告实际上是为使用下标运算符的错误索引、指针算术或调用边界不安全的C标准函数(如memcpy、memset等)而发出的。
它还给出了合理的建议:所有缓冲区都应封装在安全的容器和视图类型中。安全容器包括array、vector、string,视图包括span和string_view。
让我们以第一个为例,看看使用下标运算符错误索引的例子。这是我们之前看到的代码。如何修复它?这很简单,我们可以将其改为使用范围for循环。这完全解决了问题。但我们的建议是尝试将其改为使用std::array。这也解决了问题,但array更好,因为它是加固的。array中的许多函数也是加固的,所以在这种情况下最好使用array。
这是错误索引的例子。让我们看一个指针算术的例子,可能有很多这样的例子,这里只展示一个。这段代码将无法编译,并给出错误,指出此处进行了不安全的指针算术。同样,修复方法因错误类型而异,形式多样。这里的修复非常简单,只需转换为string_view即可。
继续,假设我们有一段代码,尝试打印一个int数组和长度,如何修复?正如提到的,这段代码无法编译。我们可以通过转换为span来修复。每当我们有指针和大小时,尝试看看是否能将其转换为std::span。这样做的原因是,对span的越界索引尝试在加固模式下将导致不可利用的崩溃。
接下来,看看“缓冲区需要封装到安全容器和视图类型中”这一指导原则。如果你有这样的代码:第一个属性是int数组,第二个是const char数组。你应该怎么做?第一个应尝试转换为std::array。第二个,如果可能,尝试转换为string_view,因为string_view实际上不分配任何内存。
如果你的代码像这样:一个接受数组和长度的函数,尝试转换为std::span。如果你的代码像这样:接受const char*和长度,则尝试转换为string_view。同样,这样能与不安全缓冲区使用正常工作的原因是,容器的越界索引尝试将导致不可利用的崩溃。
这引出了一个问题:如果我们无法修复不安全缓冲区问题怎么办?怎么可能无法修复呢?
这里有一些代码。这是一个来自C库的回调。显然,我们无法控制C库。我们必须处理这个回调。如果你看这里,这肯定会导致问题。如何修复?我们之前见过吗?不,我们之前没见过。那么如何修复?基本上,在这种情况下,我们可以将调用不安全代码的部分包装在#pragma clang unsafe_buffer_usage块中。一旦这样做,代码编译正常。
这也可以用于span构造函数。让我们看看如何操作。这里有一段代码,你可以看到那里有一个std::span,这正是我们应该做的。但问题是什么?我们从不安全的回调中获取数据,然后尝试将其转换为span,这立即导致编译失败,因为它指出span构造的两个参数是不安全的,因为它可能引入缓冲区大小和边界信息之间的不匹配。这是一个完全适用于将其包装在pragma块中的情况。一旦我们这样做,代码就编译了。
然而,这也引入了一个新问题。让我们看看这个。这段代码编译并正常工作,因为我们更新了my_span使其工作。然后我可以写这段代码,这显然是不安全的,因为我正在访问越界(arr+2)。这段代码也能编译,并给出垃圾输出。因此,最好标记所有对不安全回调的调用,以确保在调用它们时采取额外的安全措施。我们可以使用特定的标签来实现。如果你在函数上使用此标签,它将被标记为不安全。一旦你将函数标记为不安全,尝试调用它将会失败。
现在有两个选项:要么以其他方式修复它,要么如果你认为你的代码是正确的,那么可以将其放在pragma块中。这个额外的警告增加了在编码和代码审查阶段发现问题的机会。
接下来,让我们看看下一个类别:C风格函数。不安全缓冲区使用也会标记以下C风格函数(非详尽列表):memcpy、memset、strcpy等。检查器也很智能,它并不总是标记。如果它在编译时知道没有问题,比如这段代码,它就不会标记。这工作正常。在Chromium代码库中有更多示例。
现在,让我们看一个memcpy的例子以及如何修复它。这里我们尝试使用memcpy将一个字符串缓冲区复制到另一个。由于我们使用了不安全缓冲区使用,它立即给出错误,指出memcpy不安全。如何修复?你可以完全使用span和ranges::copy来修复。一旦你使用span和ranges::copy,问题就完全解决了。让我们看看代码输出。memcpy版本和ranges::copy版本的代码输出完全相同。
现在,我们稍微离题一下。因为每次我都想看看编译器能做什么,它能如何优化。我在说什么呢?看这个字符串“hello”。每当你看到“hello”这样的字符串,你通常会在代码中看到类似这样的内容,在.ascii部分。但在这里,没有那样的东西。相反,我们有一个看起来像长整数的神秘东西。那里到底发生了什么?如果你将十进制转换为十六进制,然后将输出转换为ASCII,你可以看到“hello”字符串实际上隐藏在那里。是的,这就是我们神奇的编译器为我们做的事情。
继续。让我们以memcpy为例。这是我们之前有的代码,它通过了检查。它正确吗?当然不正确,原因是没有检查span的大小。我们该怎么做?我们添加检查。我们将其移到一个新函数memcpy_span中,我们做的第一件事是添加一个断言,检查目标大小是否大于源大小。然后我们调用ranges::copy。然后主函数中的代码改为调用那个特定的memcpy_span函数。
断言可以是任何你想要的,但例如,它可以是为Clang和x86-64实现的,它会放置一个ud2指令。同样,编译器在这种情况下非常智能,你可以看到它静态地知道一切正常,所以它完全移除了ud2语句,两者输出完全相同。显然,在它无法看到的情况下,例如我们实际上使用这种方法复制一个vector,在这种情况下,这是产生的输出。别太担心代码,只需看到UD2实际上不太可能被执行,所以它被移到了函数的末尾,然后通常的代码实际上有一个主移动。
让我们再次看看这段错误的代码,看看我们在Edge和Chromium代码库中做了什么。在Edge和Chromium代码库中,我们有base::span,它是std::span的替代品,并且实际上是加固的。它有很多额外的函数,其中一些可以处理这个memcpy。我们做的是使用其中一个函数,我们创建一个base::span,执行first_span,然后执行copy_from。这就是我们实际上可以处理memcpy的函数。
现在,如果你想考虑memcpy_span。你在这里做的是我们只是在那里使用copy_from。注意,我移除了断言。没有第一个语句的断言,原因是base::span::copy_from将在运行时如果源和目标大小不匹配时触发陷阱。然后主代码变成这样,只需将std::span改为base::span,一切正常。
关于memcpy说得够多了,转到memset。这是我尝试使用memset试图将此数组的所有元素设置为1。让我们看看我是否成功了。我尝试打印一些东西,调用print_array,当然我没有成功。所以memset很难正确使用。有了不安全缓冲区使用,它实际上不担心你是否正确使用memset,它总是标记它是不安全的函数。你该怎么做?转换这个非常容易。基本上,我们可以只使用ranges::fill,一旦我们这样做,我们就得到了正确的输出,错误也消失了。
继续看一些统计数据。来自我们的代码库,Chromium是我们的上游代码库,他们在其代码库中将不安全缓冲区使用减少了90%以上。对于我们的代码,我们有更准确的数字。对于我们来说,实际上,对于非C库函数的不安全缓冲区使用,我们在三个月内将其降到了0。然后对于C库函数的使用,我们在两个月内修复了超过75%。
让我们总结一下本节,看看我们代码库的现状以及我们正在做什么。通过Libc++加固和不安全缓冲区实现,我们相信我们处于良好状态。它也是工具链的一部分,因此很难错过这类问题。此外,我们还有ASAN CI流水线来从测试中捕获更多问题。
关于ASAN的快速说明:ASAN是地址清理器,它是第一个可以检测越界访问、释放后使用、双重释放和无效释放的内存分配器。
这结束了我们关于边界安全的部分。让我们继续讨论生命周期安全。
生命周期安全
上一节我们探讨了边界安全,本节中我们来看看生命周期安全。这将涵盖许多指导原则和编码指南。
第一件事是,我想我们都知道这一点:谨慎使用原始指针。
让我们举个例子。假设有另一个类AnotherClass,然后我们有一个SomeClass,它持有该AnotherClass对象的原始指针。然后当你尝试这样做,调用该指针上的do_something时,如果没有仔细的生命周期管理,此调用可能会遇到释放后使用。你该怎么做?是的,如果你能转换为unique_ptr,所有权模型变得更加清晰,这可以完全修复。这里我们做的是,在构造函数中,我们将unique_ptr作为参数,并用类中的unique_ptr替换原始指针。这完全解决了问题。
然而,在某些情况下,由于各种原因可能无法做到这一点。因此,我们引入了Chromium的raw_ptr,也称为 MiraclePtr。它可以用来显著减少漏洞利用的机会。转换非常简单。无论你在哪里尝试使用T*,只需使用raw_ptr<T>,就是这样,调用代码不需要更改。这样做的效果是,现在释放后使用的漏洞利用机会在某些条件下显著降低。
那么,这些“某些条件”是什么?raw_ptr依赖于某些条件。首先,它依赖于Chromium中的分区分配。任何通过分区分配和释放的内存都会用特定模式进行毒化。因此,请注意,仅仅解引用悬垂指针的行为不会崩溃,但会增加后续使用可读内存时崩溃的机会,从而给我们调查和修复的机会。这也意味着,因为我们依赖于分区分配,它无法帮助处理栈或全局变量等。
我们在Chromium中的编码指南是:尽可能在类和字段中使用raw_ptr代替原始C++指针。
那么引用呢?类似地,有一个raw_ref可以用来处理引用。它也在分区中。看,非常简单,用raw_ref<AnotherClass>代替AnotherClass&。这样做的效果是,它使代码更不易被利用,但安全不等于正确。这并不能使其正确。在我们的代码库中,始终优先考虑明确的所有权模型和确定性方法。
话虽如此,让我们回到这段代码,假设你无法使用unique_ptr。我在说更一般的情况。考虑使用weak_ptr。我们只需更改我们的类,然后在构造函数中不采用原始指针,而是采用shared_ptr,并在那里存储一个weak_ptr。然后当我们想调用时,我们只需调用lock,每当该指针有效时我们就调用。这解决了问题。
现在,假设有一种情况,我们甚至无法使用shared_ptr,我们能做什么?一个选择是存储一个ID,稍后从另一个保证更长生命周期的对象中查询它。大多数程序都有这样的对象。让我们看一个例子。我创建一个类AnotherClassManager,它有一个函数create,将返回AnotherClass对象的指针,然后有另一个函数get_with_id,如果你传递一个ID给该函数,如果存在则返回该对象,否则返回空指针。有了这个,我们只需修改AnotherClass,它只接受一个ID并存储该ID。最后,主要的修改在SomeClass中,它存储一个ID而不是原始指针。那么它如何获取实际值呢?当它想要获取那个AnotherClass时,它从AnotherClassManager调用get_with_id,如果有一个非空指针,则通过它调用。是的,这种方法可能看起来有点做作,但它确实有效。我们实际上已经在代码库中使用了它。
现在,我们讨论了shared_ptr,我们也需要谨慎使用shared_ptr。例如,假设这段代码在这里,假设它被用作shared_ptr。在这段代码中,函数fn正在调用作为参数传递给它的fn2。我们总是可以变成这个输出。如你所见,my_class在fn函数体内被销毁。并且在那之后被调用,所以这是释放后使用。这类事情可以很容易地处理。我们可以通过使用shared_ptr来处理,如你所见,我们只是在那里增加强引用计数,一旦你这样做,这就变成了输出。因此,这是一个指导原则,我们可能需要在调用可能从被调用处删除该对象的函数之前,增加类的引用计数。
让我们看另一个非常常见的场景:成员函数的回调。让我们看一个例子。你有一个函数read_file,它接受一个名称作为第一个参数。大概我们会查找名称,然后有一个回调函数,稍后应该用数据回调。这里又是MyClass,它有一个新函数start_reading_file,调用read_file,并在第二个参数中传递Lambda。它在Lambda中捕获this参数,当Lambda被调用时,它将使用该参数调用回调。在这种构造中,总是可能遇到这种情况。从输出中可以看出,回调可能在对象被销毁后被调用。附录中有示例展示了整个场景。
我们如何修复它?我们可以再次通过使用shared_ptr或weak_ptr轻松修复。我们只需要在Lambda捕获列表中捕获weak_ptr,然后就可以修复它。因此,对于稍后要调用的类成员函数的回调注册要小心,并考虑使用weak_ptr或取消注册方法来处理释放后使用场景。
这引出了Chromium代码库规则。这变得有点难以看清。第一件事是我在谈论shared_ptr函数的替代品。我们有替代品。对于shared_ptr,我们有scoped_refptr和scoped_refptr本身。对于function,我们有OnceCallback和RepeatingCallback,顾名思义,OnceCallback只能调用一次,RepeatingCallback可以调用多次。
现在,让我们以看到的相同示例为例,看看如何为Chromium转换它以遵循Chromium编码指南。我们只使用OnceCallback代替function。然后调用代码需要更改,因为我们必须创建它。BindOnce是一种创建OnceCallback的方式。由于这实际上是绑定一个成员函数指针,我们需要在调用时也绑定一个对象,这就是第二个参数的原因。好处是这段代码无法编译。所以,我们默认就摆脱了那个问题。我们必须编译它,我们必须做额外的事情。如你所见,我们必须用base::Unretained标记它。开发人员需要显式使用base::Unretained才能编译。
现在,这个更改给我们带来了一些东西,它实际上带来了安全性。原因是base::Unretained导致BindOnce在内部将指针存储为原始指针。所以代码从释放后利用中变得更安全。但同样,安全不等于正确。在代码审查中,完全反对使用base::Unretained。所以我们应该做的是,我们不能实际获取ptr和weak_ptr,所以我们在Chromium中有替代方案。我们实际上有一个不同的weak_ptr基础设施来处理这种情况。
本质上,我们做的是引入一个新的成员变量,称为WeakPtrFactory。然后使用它来获取一个weak_ptr,该指针传递给第二个参数。现在,这个weak_ptr基础设施确保如果你的原始对象被销毁(在这种情况下是MyClass),该回调将永远不会被调用。这就是我们处理场景的方式。
还有另一种方法,也可以用base::ScopedClosureRunner解决。我不打算涵盖那个。但让我们看看另一个场景。让我尝试击败所有这些系统。BindOnce可以接受一个Lambda,然后我做的正是同样的事情,我将this作为Lambda的捕获变量捕获,然后调用callback,这可以击败所有这些。幸运的是,这段代码无法编译。
继续到我们可以使用的下一个策略,即使用像Clang的lifetimebound这样的属性。这是一个场景的例子。我们有一个get_or_default函数,它以字符串作为第一个参数,键作为第二个参数,如果字符串以键开头,则返回该子字符串。这里有一些调用它的代码,编译并运行正常。然后我创建一个新函数get_test_string并返回一个字符串,在这种情况下,它试图击败小字符串优化。可能无关紧要,但这就是你正在做的。然后我们调用这段代码。这是输出。显然,有些地方错了。哪里错了?get_test_string返回的字符串在第一个语句结束时就被销毁了,所以当我们在第二个语句中尝试访问它时,它引用了已销毁的内存。现在,如果你只是用clang::lifetimebound注解标记第一个参数类型,一旦你这样做,如果你尝试调用这段代码,它将得到这个错误。
让我们考虑另一个clang::lifetimebound派上用场的场景。这是一个非常常见的场景。我们有一个MyClass,它有一个字符串作为成员变量。现在,字符串是一个复制开销大的类型,所以我有这个str函数,它返回一个const string&。通常这工作正常,但这段代码呢?它似乎编译并运行正常。然而,GCC会抱怨。它编译正确,因为这段代码不正确。为什么不正确?str返回的字符串在这里被销毁了。所以如果我尝试在下一行访问它,它引用的是已销毁的内存,所以不正确。这里我们也可以使用那个clang::lifetimebound属性,一旦你这样做,这段代码将无法编译。
显然,也可以用其他方式解决,比如使用重载。例如,我们在这里做的是,我们实际上引入了一个重载。之前返回const string&的版本只对常量左值进行重载,另一个我们可以创建一个新函数,只对右值有效,这也有效。
另一个对我们有效的技巧是-Wdangling,它默认启用。这里有一段代码,显然是错误的。这无法编译。幸运的是,我们启用了-Wdangling。目前这只适用于某些风险类型,我们可以通过其他属性使其适用于其他类型,我们没有时间涵盖那些,它们是附录的一部分。
这结束了关于生命周期安全的特定部分,所以让我们看看现状,我们正在做什么。这是一个困难的话题,所以我们尝试做各种事情来处理它。首先,raw_ptr有助于安全。它实际上在代码审查阶段被强制执行。在CI流水线中也有检查,我们稍后会看到。然后我们有这个OnceCallback、Unretained和WeakPtr。它们在代码审查阶段被部分强制执行。clang::lifetimebound目前只在代码审查中标记。所以任何在代码审查中的东西都容易错过。编码策略,再次,代码和实现审查,-Wdangling默认启用。我们还有几个其他Clang检查被使用。然后ASAN、MSAN和流水线帮助我们通过测试找出更多此类问题。即使在发布后,我们稍后会讲到,我们实际上使用GWP-ASAN。
关于MSAN的快速说明:那是什么?那是内存清理器,它是一个未初始化内存检测器,可用于检测主要是未初始化内存的问题。我在这里涵盖它是因为它也涵盖释放后销毁。
初始化安全
上一节我们探讨了生命周期安全,本节中我们来看看初始化安全。本节主要考虑指导原则。
这也是我们使用的一个指导原则:在声明点初始化成员。我的意思是,成员变量在构造函数体中初始化,而不是在初始化列表中。相反,尝试这样做:每当你声明那个变量时,尝试看看是否可以在那里初始化它,一旦你这样做,就减少了在初始化时出错或遗漏的可能性。幸运的是,有一个Clang检查可以帮助我们发现这些问题。
我们使用的第二个正确初始化的指导原则是使用成员初始化列表。例如,这是一段代码。如你所见,这个尝试在构造函数体中从成员A获取成员B,然后在下一行初始化成员A。相反,如果你只是将代码转换为在初始化列表中初始化成员变量,那么这段代码立即会遇到编译错误。它说你重新排序了。所以开发人员查看并根据实际的初始化顺序正确重新排序,然后这段代码正常工作。
最后,我们还要求开发人员初始化变量。如果你有这样的东西,不要这样做,只需使用int x = 0。幸运的是,也有一个Clang检查可以帮助发现此类问题。使变量为const也可以帮助编译器检测此类问题。如果你有这样的东西,编译器会自动抱怨。Clang还有各种其他检查,用于检查各种场景下的未初始化变量,所以考虑使用那些,我们实际上在CI流水线中使用那些。
幸运的是,C++26带来了P2796,它移除了某些场景下未初始化变量的未定义行为。
这结束了我们这一部分,让我们看看我们在Edge中做什么。使用编码指南、代码审查、一些Clang检查,还有MSAN流水线通过测试找出更多问题。
类型安全
上一节我们探讨了初始化安全,本节中我们来看看如何减少类型混淆。我们的指导原则是避免类型双关。类型双关在C++中本质上是将一种类型对象的内存表示重新解释为另一种类型的做法。
这里有一个尝试将int类型双关为float的例子。这是一个联合,它有int和float成员。在这个函数中,我们尝试用一个整数初始化它,并尝试将其作为float返回,并将函数作为const float调用。这将因编译器错误而失败,说你不能这样做。这段代码在C中是正确的,但在C++中,它是未定义行为。
那么我们的建议是什么?指导原则是使用std::bit_cast,它在这种情况下工作正确。并且对于这类场景,使用std::bit_cast而不是联合技巧,也建议使用std::bit_cast而不是reinterpret_cast。
让我们看看reinterpret_cast。在这个特定例子中,在get_float中,我们有一个const T*,我们试图将其重新解释为float*。这在某些平台上可能失败并遇到段错误。为什么?因为像ARM这样的一些平台有非常严格的内存对齐要求。在Intel上,它可能遇到简单代码的问题。GCC有一个-Wcast-align-strict标志用于此类问题。
我们如何修复这个?一个选择是使用memcpy,问题就解决了,但使用memcpy我们可能会遇到不安全缓冲区问题,我们的指导原则是使用Chromium库中的某种包装器,我们有这些包装器可用。Clang也有这些检查,可以标记所有这些reinterpret_cast或static_cast问题。问题是,它会标记所有用户,包括有效的。我们还有用于此的清理器ASAN,它可能为此类未对齐访问问题创建运行时错误。
下一个指导原则是使用variant代替union。这是一段以不正确方式使用union的代码。我们有一个Wrapper类,它有一个联合,我们在这里创建一个Wrapper对象,它可能在运行时崩溃。为什么?这里的构造函数没有正确初始化任何成员变量。结果,析构函数也没有正确工作。如果你看set函数,它甚至根本不调用先前类型的析构函数。我们如何修复这个?我们使用带有string或vector<int>的variant。这段代码工作正确。
我们还有用于CI的控制流完整性,它可以帮助发现此类无效转换问题。这是一个例子。我们有一个基类Base,它有一个函数foo。然后我们有一个派生类Derived,它也重写了函数foo。在main中,我们尝试创建一个类型为Base的对象b。然后我们尝试将其转换为派生类Derived d。如果你启用了CFI,那么在运行时它会崩溃,说你试图从Base转换为Derived,并且这里的虚函数表是错误的。
我们这里的现状是什么?我们有编码指南来捕获此类问题。在代码审查期间,我们尝试捕获一些问题。此外,我们在CI流水线中启用了CFI。
线程安全
上一节我们探讨了类型安全,本节中我们来看看线程安全。那么我们如何避免死锁和竞争条件等线程安全问题呢?我们在代码库中使用序列。
序列本质上是Chromium在系统线程之上的抽象。我们有内部库来支持所有不同的序列。代码可以创建自己的序列并将任务发布到任何序列。你可以将任何任务发布到特定线程,如UI线程或IO线程。库负责所有序列的所有同步。
保证是发布到序列的任务将按照它们发布的相同顺序运行。即使同一序列上的每个任务可以在不同的物理线程上运行,库确保有同步,并且任务在同一序列中运行。
一个非常有用的工具是,你可以在一个序列上发布任务并获取结果,然后在另一个线程上使用该结果发布任务。
这是一个例子。我们有一个场景,我们想在后台序列上从磁盘读取一些项目。然后我们想在UI线程中执行一些任务,使用UI中的一些项目。用序列可以做到这一点。所以我们有这个PostTaskAndReplyWithResult。它要求在后台线程上发布此任务。并使用结果回调UI函数。它的工作方式是:我们有UI线程,它将在后台线程上发布任务。它执行任务。然后使用结果,它将在UI线程上回调UI中的use_some_items。这样,序列就得到了保证。
我们如何在Edge中确保这一点?我们有编码指南,并尝试在代码审查期间捕获其中一些问题。我们还在CI流水线中使用线程清理器,它本质上是一个数据竞争检测器。
定义安全
上一节我们探讨了线程安全,本节中我们来看看定义安全。这也将是一个小部分。
我们从ODR违规开始。ODR是单一定义规则,这出现在IFNDR(无需诊断的格式错误)中。
这里有一个简单的例子。在头文件中定义上下文变量可能导致ODR违规。有可能这样做。修复非常简单,只需使其内联。这确保不违反单一定义规则。
还要确保预处理器标志值一致,这是一个棘手的事情。让我们看一个例子。我们有这个MyClass,它有这个宏SPECIAL_SOURCE,当SPECIAL_SOURCE存在时,有一个special_function和一个作为成员变量的special_number。我们有这个myclass.cc,我在那里定义那个特定的宏。在实际情况下,它将来自某种构建定义文件,这里我只是将其展示为示例。在main.cc中,我没有那个宏。这是代码,我只是调用MyClass,创建MyClass对象,然后调用str。现在这段代码可能在运行时终止。这是基于我们在代码库中看到的真实示例,该示例被地址清理器捕获。
这里发生了什么,让我们看看。这是MyClass的代码。这是调用它的main。这是MyClass的构造函数。只有导致问题的部分。如你所知,main.cc只将MyClass视为一个字符串。MyClass也看到更多。但代码从main开始,然后考虑字符串是24字节。在main.cc中,string_变量放在那里,24字节在那里。然后MyClass,它在MyClass构造函数定义中。str实际上是这样放置的,如你所见,它已经尝试做一些越界的事情。现在这段代码运行,它调用move构造函数。这来自这里。所以这里发生了小字符串优化。所以你可以立即看到有一个越界写入。然后我们做这个哈希,它继续在那里。然后main.cc将此部分解释为字符串,因此崩溃。
一个更简单的ODR违规修复方法是,如果你有这样的东西,它是一个源或C文件,其中有一个类、一个全局变量和一个仅针对源的函数,在这种情况下,我们建议将其移动到未命名的命名空间中。因此,这成为指导原则:将仅源文件中的类、变量和函数添加到未命名的命名空间中以获得内部链接,从而移除ODR。
这结束了这一特定部分,让我们看看我们在Edge中做什么来提高定义安全。你使用编码指南、代码审查,当然还有ASAN和运行清理器流水线。

减少未定义行为
上一节我们探讨了定义安全,本节中我们来看看减少未定义行为。C++中有很多未定义行为。
让我们看看我们使用的一些策略。第一个指导原则是尝试对常量变量使用constexpr。这里有一些代码。这段代码遇到有符号整数溢出,因为我们试图将1加到INT_MAX,但这段代码编译并运行,并在运行时给出错误。值不正确。一旦我们将其移入constexpr,一旦我们这样做,编译器就会给出错误。即使是const,如果你只是使其为const,那也是一个错误。因此,这成为一个指导原则:考虑将可以在编译时计算的变量设为constexpr或const。这将有助于捕获未定义行为。
再举一个简单的例子:除以0、对0取模和移位操作(这是移位-1),所有这些都是未定义行为,这段代码编译正常。只需使它们为常量,一旦你这样做,没有编译,有一个编译错误。这些警告默认启用。

减少未定义行为的另一个策略是指导原则:考虑在适用时使用constexpr函数来检测未定义行为。我们之前看到类型双关被捕获时见过这个用法。有一篇由Shafik Yaghmour撰写的精彩文章,提到了许多这样的例子。
现在,未定义行为可能导致不需要的优化。比如这个非常有趣的场景,它来自20级博客。这是一些代码。我们有一个函数指针fp,它被设置为nullptr,然后有一个imp函数进行打印,有一个set函数将输入设置为fp,有一个call调用fp,main调用call。如果你看这段代码,set从未被调用,所以这段代码应该崩溃,对吗?但当用-O2构建时,它给出输出“hello”。这从未被调用,对吗?这是汇编代码。如你所见,set只是一个存根,主体被完全移除,main直接进行调用。它为什么这样做?因为它被允许这样做,因为调用空指针是未定义的,这允许它假设set必须在call之前被调用。你可以这样做。


当用这个特定检查-fno-delete-null-pointer-checks构建时,这会终止,因为代码是这样的
066:实现你自己的C++原子操作


在本教程中,我们将学习如何为一个不支持标准库 <atomic> 头文件的平台(例如 Arduino)实现一个自定义的原子操作模板。我们将从理解原子操作的核心概念开始,通过一个环形缓冲区队列的实例,分析为什么需要原子操作,并最终动手实现一个具备基本功能的 my_atomic 类模板。
概述:什么是原子操作?
在并发编程中,当多个线程同时访问和修改共享数据时,我们需要确保操作的原子性。一个原子操作主要包含两个关键属性:
- 非抢占性:操作一旦开始,就不会被其他线程中断,直到操作完成。
- 可同步性:一个线程对共享数据的修改结果,能够以可预测的方式被其他线程看到。
标准库的 std::atomic 提供了这些保证。但当它不可用时,我们就需要自己实现。
为什么需要原子操作?一个队列的例子
为了理解原子操作的必要性,我们来看一个单生产者、单消费者环形缓冲区队列的设计。这个队列使用一个固定大小的数组和两个索引:
head:指向队列头(下一个将被读取的元素)。tail:指向队列尾(下一个将被写入的位置)。
初始时,两者都指向位置0,表示队列为空。
队列的基本操作
以下是队列操作的核心逻辑:
try_push_back:尝试在队尾添加元素。它检查队列是否已满,如果未满,则在tail位置写入新元素,然后原子地将tail索引前进一位(必要时回绕到数组开头)。try_pop:尝试从队头取出元素。它检查队列是否为空,如果不空,则读取head位置的元素,然后原子地将head索引前进一位。
问题的关键在于,对 head 和 tail 索引的“读取-修改-写入”操作必须是原子的。否则,在多线程环境下会出现数据竞争和内存可见性问题。
非原子操作导致的问题
假设我们使用普通的整数作为索引,try_pop 中更新 head 的代码可能如下:
// 非原子版本 - 存在问题的代码
if (!empty()) {
v = buffer[head]; // 1. 读取元素
head = (head + 1) % size; // 2. 更新索引(非原子!)
return true;
}
这里存在两个主要问题:
-
非抢占性问题:如果
head的类型(例如uint16_t在8位平台上)需要多个CPU周期来写入,那么在线程A执行第2步(更新head)的过程中,可能会被线程B(生产者)抢占。线程B此时读取head,会得到一个不完整的、损坏的值,导致队列状态判断错误。 -
同步性问题:即使操作本身是瞬间完成的,由于编译器的指令重排或CPU缓存一致性机制,其他线程可能无法立即看到修改后的结果。例如,编译器或处理器可能将第2步(更新索引)重排到第1步(读取元素)之前执行。这样,生产者线程可能误以为消费者已经取走了元素,从而覆盖了尚未被读取的数据。
为了解决这些问题,我们必须确保对 head 和 tail 的更新是原子的和可同步的。
设计自定义原子模板 my_atomic
我们的目标是创建一个简化版的 my_atomic<T> 模板,至少实现 load() 和 store() 操作,并保证其原子性与同步性。
my_atomic 的基本接口设计如下:
template<typename T>
class my_atomic {
T value;
public:
my_atomic() = default;
explicit my_atomic(T desired) : value(desired) {}
my_atomic(const my_atomic&) = delete; // 不可拷贝
my_atomic& operator=(const my_atomic&) = delete;
T load() const; // 原子读取
void store(T desired); // 原子写入
};
我们需要实现 load 和 store 方法,使其满足原子操作的两个属性。
实现方法一:通过禁用中断实现非抢占性
在单核且无真正硬件并发的系统(如许多嵌入式系统)中,实现非抢占性的一种常见方法是禁用中断。这可以防止任务调度器在操作中途切换线程。
以下是一个利用RAII(资源获取即初始化)原则实现的“中断禁用器”类:
class InterruptDisabler {
uint8_t saved_status;
public:
InterruptDisabler() {
saved_status = SREG; // 保存当前状态寄存器
cli(); // 清除中断使能位(禁用中断)
}
~InterruptDisabler() {
SREG = saved_status; // 恢复状态寄存器(重新启用中断)
}
};
基于这个类,我们可以实现 my_atomic 的成员函数:
template<typename T>
T my_atomic<T>::load() const {
InterruptDisabler disabler; // 构造时禁用中断
T rv = value; // 安全地读取值
return rv; // 析构时恢复中断
}
template<typename T>
void my_atomic<T>::store(T desired) {
InterruptDisabler disabler; // 构造时禁用中断
value = desired; // 安全地写入值
}
这种方法确保了在读取或写入 value 时,当前线程不会被中断,从而解决了非抢占性问题。
实现方法二:利用内存屏障实现可同步性
仅禁用中断解决了本地线程的非抢占问题,但还不足以保证修改能同步到其他线程(或核心)。我们需要建立内存屏障,强制编译器与CPU完成必要的数据同步。
查看 cli() 和 sei() 宏在GCC中的可能实现,我们可以看到关键所在:
#define cli() __asm__ __volatile__ ("cli" ::: "memory")
#define sei() __asm__ __volatile__ ("sei" ::: "memory")
__asm__:允许内嵌汇编代码。__volatile__:告诉编译器此汇编代码有副作用,不能随意优化掉。"memory":这是一个Clobber,它是最关键的部分。它告诉编译器:“这段汇编代码会读写内存,因此你必须将所有缓存在寄存器中的内存值刷新到实际内存中,并且在执行完这段汇编后,重新从内存加载后续需要用的值。”
这个 "memory" clobber 就构成了一个编译器内存屏障,它解决了同步性问题,确保了在一个线程中对 my_atomic 对象的修改,能够被其他线程正确观察到。
更通用的实现:编译器内置函数与汇编指令
对于真正的多核系统(具有硬件并发性),仅禁用当前核心的中断是不够的。我们需要使用CPU提供的原子指令或编译器提供的内置原子函数。
-
使用汇编指令(例如x86):
// 原子递增操作示例 __asm__ __volatile__ ( "lock addl %1, %0" // lock前缀确保指令在多核上原子执行 : "+m" (x) // 输出操作数(+表示读写) : "ir" (1) // 输入操作数(立即数1) : "memory" // 内存屏障 ); -
使用编译器内置函数(例如GCC):
// 使用GCC内置函数实现load和store T my_atomic<T>::load() const { return __atomic_load_n(&value, __ATOMIC_SEQ_CST); } void my_atomic<T>::store(T desired) { __atomic_store_n(&value, desired, __ATOMIC_SEQ_CST); }这里的
__ATOMIC_SEQ_CST(顺序一致性)内存顺序参数,提供了最强的同步保证,相当于默认的std::memory_order_seq_cst。
总结
本节课我们一起学习了如何为一个缺乏标准原子库的平台实现自定义的原子操作模板 my_atomic。我们首先明确了原子操作必须具备非抢占性和可同步性两个核心属性。然后,通过一个环形缓冲区队列的案例,分析了非原子操作可能导致的数据竞争和内存可见性问题。
最后,我们探讨了三种实现 my_atomic 的途径:
- 对于单核无硬件并发系统:可以通过禁用中断来实现非抢占性,并结合汇编指令中的
"memory"内存屏障来实现同步性。 - 对于多核系统:需要使用CPU提供的带有
lock前缀的原子指令或专门的原子操作指令。 - 便捷方法:如果编译器支持,使用像
__atomic_load_n这样的编译器内置原子函数是最简单且可移植性较好的方式。



理解这些底层机制,不仅能帮助我们在受限环境中进行并发编程,也能加深我们对 std::atomic 工作原理的认识。
067:实现你自己的原子操作


在本教程中,我们将学习如何为一个不支持标准库 <atomic> 头文件的平台(例如 Arduino)实现一个自定义的原子操作模板。我们将从理解原子操作的核心概念开始,通过一个环形缓冲区队列的实例,分析为什么需要原子性,并最终探讨如何利用平台特定技术(如禁用中断、内联汇编或编译器内置函数)来实现非抢占和同步这两个关键属性。
并发与原子性:核心概念
上一节我们介绍了本教程的目标。本节中,我们来看看理解原子操作所需的基础术语。
- 并发:指一个程序看起来在同时做两件或更多事情。多线程是实现并发的常见方式。
- 硬件并发:指系统真正拥有同时执行多条指令的能力。一个系统可以是并发的,但不一定具备硬件并发能力(例如通过任务切换模拟)。
- 原子对象与操作:在多线程编程中,线程间需要通信。原子对象和操作是实现这种通信的一种方式,它们提供比互斥锁、信号量等更细粒度的控制。
原子操作通常需要具备两个关键属性:
- 非抢占性:操作在执行过程中不能被其他线程或上下文中断。
- 可同步性:操作的结果能以可预测的方式对其他执行线程可见。
在本教程的语境下,一个原子对象支持至少具备这两种属性的操作。
案例研究:单生产者单消费者环形缓冲区
上一节我们定义了原子性的核心。本节中我们来看看一个具体的应用场景,以理解为什么需要这些属性。
这是一个用于单生产者、单消费者场景的队列设计。它使用一个固定大小的数组作为底层存储,以循环方式使用元素,因此也称为环形缓冲区或循环缓冲区。
环形缓冲区类型包含:
- 一个元素数组。
- 两个索引:
head:指向队列前端(第一个)元素的索引。tail:指向下一个元素将被插入到队列后端的索引。
初始时(队列为空),head 和 tail 都指向位置 0。
push_back操作在tail位置写入新元素,然后向前移动tail索引。pop操作读取head位置的元素,然后向前移动head索引。- 当索引递增到超出数组边界时,会绕回 0。
为了使这个队列在多线程环境下正确工作,head 和 tail 索引必须是原子对象。
以下是环形缓冲区的一个简化类模板框架:
template <typename IndexT>
class ring_buffer {
public:
using value_type = unsigned long; // 示例元素类型
ring_buffer() : head_(0), tail_(0) {} // 初始化为空
bool empty() const;
bool try_pop(value_type& v);
bool try_push_back(value_type v);
private:
static constexpr size_t size = N; // 缓冲区容量(实际为 N-1)
value_type data_[size];
IndexT head_; // 需要是原子的
IndexT tail_; // 需要是原子的
};
注意:此设计中,数组始终保留一个未使用的元素,以区分“满”和“空”的状态(head == tail 表示空)。
非抢占性:为什么需要它?
上一节我们介绍了环形缓冲区的设计。本节中我们来看看如果 head 和 tail 不是原子的,会出什么问题。
首先看一个 try_pop 的初始错误实现:
bool try_pop(value_type& v) {
if (empty()) return false;
v = data_[head_]; // 读取元素
head_ = (head_ + 1) % size; // 移动 head 索引(非原子!)
return true;
}
问题在于 head_ 的递增操作可能不是原子的。如果 IndexT 类型(例如 uint16_t)的写入需要多个处理器周期,那么这个递增操作可能会在中间被抢占。
考虑场景:
- 消费者线程正在执行
head_的递增(例如,先写低字节,再写高字节)。 - 此时,生产者线程被调度运行,它调用
try_push_back,其中需要读取head_来判断队列是否已满。 - 生产者线程可能读取到一个半写状态的、无意义的
head_值,导致队列状态判断错误。
tail_ 索引也存在同样的问题。因此,对 head_ 和 tail 的读写必须是非抢占的,以确保其他线程看到的索引值始终是完整且一致的。
实现非抢占性:使用自定义原子模板
上一节我们看到了非抢占性的必要性。本节中我们来看看如何通过一个自定义的原子模板 my_atomic 来包装索引,以提供非抢占操作。
以下是 my_atomic 模板的一个最小化接口,模仿 std::atomic 的基本功能:
template <typename T>
class my_atomic {
public:
my_atomic() = default;
my_atomic(T value) : value_(value) {}
my_atomic(const my_atomic&) = delete; // 不可拷贝
my_atomic& operator=(const my_atomic&) = delete;
T load() const; // 原子读取
void store(T v); // 原子写入
private:
T value_;
};
std::atomic 可以通过 is_always_lock_free 成员来查询是否无锁(即使用硬件原子指令而非内部锁)。我们的 my_atomic 也需要实现类似的非抢占保证。
现在,我们用 my_atomic<IndexT> 来包装 head_ 和 tail_。但仅仅包装还不够,看下面的 try_push_back 代码:
bool try_push_back(value_type v) {
IndexT next_tail = (tail_.load() + 1) % size; // 原子读
if (next_tail == head_.load()) return false; // 队列满?
data_[tail_.load()] = v; // 写入元素(可能读到旧的tail?)
tail_.store(next_tail); // 原子写
return true;
}
这段代码仍有问题:它在计算 next_tail 和写入元素 data_[tail_.load()] 时,两次读取 tail_。在这两次读取之间,tail_ 可能已被其他操作改变(尽管在单生产者场景下不会)。更好的做法是只读一次并保存局部副本。
一个更健壮的 try_push_back 实现如下:
bool try_push_back(value_type v) {
IndexT current_tail = tail_.load();
IndexT next_tail = (current_tail + 1) % size;
if (next_tail == head_.load()) return false; // 满
data_[current_tail] = v; // 使用局部副本
tail_.store(next_tail); // 原子更新
return true;
}
这样,元素写入和 tail_ 更新是分离的。关键在于:tail_ 的更新(store)必须是一个原子的、非抢占的操作。只要这个更新是原子的,即使元素写入本身被抢占,在 tail_ 更新之前,消费者线程也看不到这个新元素,因此是安全的。try_pop 的逻辑与此对称。
可同步性:内存顺序与可见性
上一节我们确保了单个操作的原子性。本节中我们来看看另一个关键属性:可同步性。即使操作是非抢占的,一个线程的写入结果也可能不会立即被其他线程看到。
这是由于现代计算机系统的复杂性造成的,例如:
- 编译器优化重排:编译器可能为了效率而重新安排无关的内存操作顺序。
- CPU缓存一致性:不同CPU核心的缓存可能未及时同步,导致一个核心的写入对另一个核心暂不可见。
C++标准中,不同线程上的操作是无顺序的,除非它们通过同步操作进行同步。许多原子操作(如 load 和 store)在默认情况下就是同步操作。
原子同步操作有两种主要“风味”:
- 获取操作:通常是读操作(如
load),确保本线程中在它之后的所有读写操作,都能看到另一个线程释放操作之前的所有写入。 - 释放操作:通常是写操作(如
store),确保本线程中在它之前的所有读写操作,都对执行了对应获取操作的线程可见。
默认情况下,std::atomic 的操作既是获取又是释放(严格顺序,memory_order_seq_cst),这建立了最强的全局顺序保证。对于我们的环形缓冲区:
- 生产者
store(tail_)应该是一个释放操作,确保元素写入在tail_更新之前完成并对消费者可见。 - 消费者
load(head_)或load(tail_)应该是一个获取操作,确保在看到新的head_或tail_值后,能正确看到与之关联的元素值。
如果没有正确的同步,即使操作原子,也可能出现消费者读到新索引但没读到新元素数据的问题。
实现原子操作:平台特定技术
上一节我们讨论了同步的理论。本节中我们来看看如何在实际硬件上实现 my_atomic 的 load 和 store,使其具备非抢占和同步性。
实现方式取决于目标平台:
方法一:在无硬件并发的系统上禁用中断
对于像 Arduino Mega 2560 这样的单核微控制器,没有真正的并行执行。并发通过中断和任务切换模拟。此时,可以通过禁用全局中断来防止抢占。
一个利用RAII(资源获取即初始化)模式的中断禁用器类:
class InterruptDisabler {
public:
InterruptDisabler() {
saved_status_ = SREG; // 保存状态寄存器
cli(); // 清除中断使能位(禁用中断)
}
~InterruptDisabler() {
SREG = saved_status_; // 恢复状态寄存器(重新启用中断)
}
private:
uint8_t saved_status_;
};
然后 my_atomic 的实现可以很简单:
template <typename T>
T my_atomic<T>::load() const {
InterruptDisabler disabler;
T rv = value_;
return rv;
}
template <typename T>
void my_atomic<T>::store(T v) {
InterruptDisabler disabler;
value_ = v;
}
如何实现同步? 关键在于 cli() 和 sei() 等宏或函数的实现。它们通常使用内联汇编,并包含一个 “memory” 破坏描述符。这会告诉编译器:此汇编块会影响所有内存,从而在汇编指令前后创建内存屏障,强制编译器将寄存器中的写操作刷新到内存,并从内存重新加载值,从而实现了线程间的同步。
方法二:使用编译器内置原子函数
如果编译器支持(如GCC),可以使用内置函数,它们会为特定平台生成合适的原子指令。
T my_atomic<T>::load() const {
return __atomic_load_n(&value_, __ATOMIC_SEQ_CST);
}
void my_atomic<T>::store(T v) {
__atomic_store_n(&value_, v, __ATOMIC_SEQ_CST);
}
__ATOMIC_SEQ_CST 参数指定了严格顺序内存模型,保证了操作的获取-释放语义。
方法三:直接使用内联汇编
对于特定平台,可以直接编写汇编指令来实现原子操作。例如,在x86上原子递增:
__asm__ __volatile__(
"lock addl %1, %0" // lock 前缀确保原子性
: "+m" (x) // 输出操作数(读写)
: "ir" (1) // 输入操作数
: "memory" // 内存破坏描述符,提供同步
);
总结
本节课中我们一起学习了如何为一个受限平台实现自定义的原子操作模板 my_atomic。我们从理解并发、硬件并发以及原子操作的非抢占性和可同步性这两个核心属性开始。通过一个单生产者单消费者环形缓冲区的案例,我们分析了缺乏原子性会导致的数据竞争和状态不一致问题。
我们探讨了实现原子操作的几种平台特定方法:
- 在无硬件并发的系统上禁用中断,并结合内联汇编的
“memory”破坏描述符来创建内存屏障,实现同步。 - 使用编译器提供的原子内置函数,这是最便捷和可移植(在编译器支持范围内)的方式。
- 直接编写平台特定的内联汇编指令,适用于需要极致控制或缺乏内置函数支持的情况。



最终,我们了解到,实现原子操作的本质是:利用硬件或系统提供的机制(如原子指令、锁总线、禁用中断),确保一个内存操作在执行时不可分割,并通过内存屏障等机制保证其结果能跨线程正确同步。这为在缺乏标准库支持的环境下进行安全的多线程编程提供了基础。
068:C++的危险与缓解之道



在本教程中,我们将探讨C++语言固有的安全风险,并学习如何通过采用编码规范和工具来编写更安全、更可靠的代码。我们将重点分析C++的不安全性和不可预测性,并介绍MISRA C++等核心指南如何帮助开发者规避常见陷阱。
C++的不安全性
C++本质上是不安全的。C++之父Bjarne Stroustrup也承认这一点。C语言存在安全问题,例如可以给const变量赋值,这只会产生一个警告,而警告常常被开发者忽略。C++试图通过引入编译错误来改进,例如上述情况在C++中会产生编译错误。
然而,C++自身也引入了不安全机制,例如const_cast。以下代码不会产生编译错误,甚至没有警告:
const_cast<int*>(some_const_pointer);
这似乎是为了绕过C++自身刚刚建立的安全机制。结论是,C++存在固有的安全问题。
C++的危险类别
C++的危险主要分为两大类:安全性和不可预测性。
安全性问题
安全性问题包括多个方面,以下是主要类别:
- 生命周期安全:需要记住在构造函数中初始化对象,在析构函数中正确释放资源。需要确保析构函数是虚函数。悬空指针和访问已删除对象都属于此类问题。
- 边界安全:可以访问数组的索引
-1,或者访问向量size() + 2000的位置。 - 类型安全:类型转换和类型检查相关问题。
- 线程安全:竞态条件、死锁等问题。
- 运行时问题:无论编写多少单元测试或进行多少代码审查,运行时环境总是充满意外。
这些问题可以混合出现,例如在一个线程中访问对象,同时在另一个线程中删除它。
不可预测性问题
不可预测性不仅限于未定义行为,它包含更广泛的概念:
- 未定义行为:标准规定“任何事情都可能发生”。经典例子是除以零,结果可以是零、无穷大,或者在星期五格式化你的硬盘。
- 未指定行为:标准规定了必须发生的行为,但未规定顺序。例如,函数
f(a(), b(), c())中a(),b(),c()的调用顺序是不确定的,六种排列都有可能,甚至两次调用顺序可能不同。 - 实现定义行为:行为由编译器等实现定义,而非标准。例如,
long类型的大小可能是8字节、4字节或16字节,这取决于编译器、操作系统和CPU。 - 条件支持行为:行为是否支持取决于编译器。例如,
#pragma指令可能被一个编译器支持,而被另一个忽略。 - 区域特定行为:与文本和语言相关的行为。
- 错误行为:在C++23中引入,与未定义行为相关联。
- 无效化指针/迭代器:查阅标准后发现,这实际上也属于未定义行为。
C++是复杂、不可预测且不安全的。
迁移是否是解决方案?
一些安全机构建议迁移到其他语言。然而,这通常不切实际:
- 现有代码库庞大(如Linux、Windows),迁移需要数十年。
- 生态系统(工具、库)需要重建。
- 开发者技能转型需要巨大投资。
- 没有其他语言能完全替代C++的所有能力(实时性能、高效率、底层支持),同时保证安全和可预测性。
唯一的“替代品”可能是C语言或汇编语言,但这显然不是更好的选择。因此,我们需要的是缓解方案,而非彻底替换。
缓解策略:规则与工具
既然没有神奇的解决方案,我们就需要通过努力来缓解问题。我们建议采用“双管齐下”的策略:一套严格的编码规则,以及强制遵守这些规则的工具。
编码规则与指南
许多组织已经制定了优秀的C++编码标准:
- JSF C++编码标准:为F-35战斗机开发。
- C++核心指南
- MISRA C++:汽车行业标准,现已与其他行业标准(如AUTOSAR)合并。
- High Integrity C++ 等。
本教程将重点介绍MISRA C++,因为它面向高度规范的行业(如汽车、航空航天),非常严格,且广受欢迎。
MISRA C++ 指南简介
MISRA C++ 2023版包含179条指南,涵盖C++17。与长达约2000页的C++标准相比,这个数量是可控的。
让我们看一个具体的规则示例。
规则 9.6.1:不应使用 goto 语句。
这似乎是编程中的古老戒律。规则周围有许多精心设计的细节:
- 类别:规则分为“强制”、“要求”和“建议”。
- 强制:必须遵守。
- 要求:必须遵守,或正式记录任何偏差并说明理由,通常还需要上级批准。
- 建议:最佳实践,但由开发者自行决定。
- 可判定性:规则可以是“可判定的”或“不可判定的”。可判定规则意味着静态分析工具可以自动检查代码是否合规。MISRA C++中约90%的规则是可判定的。
“要求”类别非常巧妙。例如,如果你必须使用一个返回原始指针的C库函数,你有两个选择:
- 每次调用都记录偏差理由。
- 编写一个包装函数,将原始指针封装到智能指针中,然后只需为这一个包装函数记录一次偏差。
另一个例子是规则 9.4.2:switch语句的结构应适当。其“阐释”部分说明了“适当”的含义,其中一条是:每个switch语句都应有一个default标签。这可以防止代码因遗漏case而意外执行后续逻辑。
这似乎与规则 0.0.1:函数不应包含不可达语句矛盾。如果enum的所有值都已处理,default标签就逻辑上不可达。但MISRA对“不可达”有明确定义(例如return语句后的代码),逻辑上不可达的default标签不被视为违反规则0.0.1。这显示了指南制定的周密性。
静态分析工具
拥有250页左右的指南手册若无强制手段则形同虚设。幸运的是,由于大多数规则是可判定的,我们可以依靠静态分析工具(如Clang-Tidy, Coverity, SonarQube)来自动检查约90%的合规性问题。这使得代码审查只需关注剩下的10%,大大减轻了负担。
这些工具通常内置或可配置支持MISRA规则集。
与其他指南的对比:C++核心指南
C++核心指南由Bjarne Stroustrup等人创建,采取了不同的路线:
- MISRA C++:严格、保守、面向安全关键型应用、禁止不安全特性、强调合规与审计。
- C++核心指南:更轻量、灵活、提供可混合匹配的“ profiles”、不强制禁止而是“不鼓励”不安全使用、由开发者承担更多责任、持续演进(目前涵盖C++23)。
两者都主要依靠工具来强制执行。
核心理念:从限制到赋能
开发者可能觉得编码指南限制了创造力和自由。但我们应该换一个角度思考:这些指南通过消除语言的“黑暗角落”,促进了代码的可预测性。这使得代码更易于推理、维护、处理和验证,最终目标是交付高质量、安全、可靠的可工作软件,而不仅仅是更快地编写更多代码。
总结

本节课中我们一起学习了:
- C++是固有地不安全且不可预测的,存在内存安全、未定义行为等多种“地雷”。
- 彻底迁移到其他语言不现实,没有语言能完全替代C++在性能、效率和控制力方面的优势。
- 缓解问题的关键在于采用编码规范,如MISRA C++或C++核心指南。
- 必须结合静态分析工具来自动化执行大部分规则检查。
- 应把指南视为赋能工具,它们通过提升代码的可预测性和可维护性,最终帮助我们交付更安全可靠的软件。


通过采纳这些实践,你可以更好地避免在C++编程中“搬起石头砸自己的脚”或踩中那些可能“炸飞整条腿”的地雷。
069:发现所有权感知性能分析的威力 🧠💾



在本教程中,我们将学习一种基于对象所有权的C++内存分析方法。这种方法能帮助我们精确地定位程序中哪些对象真正占用了内存,以及它们是如何持有这些内存的,从而为内存优化提供清晰的数据支持。
为什么需要内存分析?🤔

系统内存是一种有限的资源。虽然有时可以使用交换空间,但其速度比RAM慢几个数量级。内存耗尽通常是不可恢复的错误。虽然可以设计数据结构在触发时释放资源,但这非常困难且不总是可靠。有时你就是没有更多内存了。

购买更多内存或使用更大内存的服务器是一种解决方案,但这通常很昂贵。对于分发给客户端的软件,你无法为每个客户端购买更多内存,因此必须能够优化程序本身。
要优化,首先需要测量。你需要知道优化前和优化后使用了多少内存,并且不能盲目进行。你需要能够识别程序中实际使用内存的部分。
现有内存测量工具简介 🛠️
目前有许多优秀的工具可用于测量内存使用情况。以下是三种常见工具的简要介绍。

1. time 工具

最简单的方法是使用像 time 这样的工具。以下是一个示例程序,我们使用 time 来测量其内存使用。
#include <map>
int main() {
std::map<int, int> m;
for (int i = 0; i < 1000000; ++i) {
m[i] = i;
}
}
在Linux上,通常可以使用 time -v 命令来获取峰值内存占用等信息。
优点:快速、简单,能获得一个数字——峰值内存占用。
缺点:无法了解内存的具体使用者,且只得到一个峰值数字。
2. Valgrind
Valgrind 是一个非常强大和有用的工具。如果你想找出内存泄漏发生的位置,Valgrind 是首选工具。Valgrind Massif 可以测量程序随时间变化的内存使用情况,并打印出漂亮的ASCII图表。

3. Heaptrack
Heaptrack 是一个更现代、更先进的工具。它在每次分配发生时记录堆栈跟踪,你可以用它来制作内存分配的火炬图。
下图来自其GitHub仓库,熟悉火焰图的人可能知道如何阅读它。简单解释一下:水平轴上的条长度表示消耗的内存量,条的高度基本上就是调用堆栈。你可以看到大量内存是在 QDataArray::allocate 中分配的。
然而,仅仅看到内存分配发生在哪里非常有用,但这并不能告诉你谁在持有这些内存,也不能解释为什么内存没有被释放或花了很长时间才被释放。
所有权感知内存分析的核心思想 🎯
我们编写的代码涉及数据结构、对象和容器,处理所有权语义。我们的分析器需要反映这一点。我们需要能够找出哪些对象在消耗内存,为什么内存没有被释放,以及哪些成员或子对象在使用这些内存。
对象可能非常复杂。仅仅能够说“这个类型使用了那么多内存”非常有用,但我们希望更进一步。
所有权感知内存分析器可以回答这些问题。它并非第一个此类分析器,但我们希望它非常有用、通用且强大。它可以分析用户定义的容器,处理模板特化、虚拟继承,在运行时解析实际分配的对象,处理C风格联合体以及通过placement new分配的子对象。
分析器工作原理:基于析构函数的归属判定 ⚙️
上一节我们介绍了所有权分析的必要性,本节中我们来看看分析器是如何实现这一目标的。核心在于一种基于析构函数的方法:我们将内存归属于实际释放该内存的对象。
任何在析构函数调用中释放的内存,都被定义为由正在被销毁的对象所拥有。如果在销毁时没有发生释放,我们就知道该对象最终并不拥有任何内存。

因为内存释放是唯一的(不能双重释放),所以即使内存被共享,这种方法也能正确分配所有者。内存总是归属于最后持有该内存的对象。这一点很重要,因为最后持有它的对象,也是内存未能更早被销毁的原因。

这种方法有几个优点:可以忽略所有非拥有类型,无需测量它们的大小;它是通用的,可以处理任何遵循RAII并释放其拥有内存的对象;它同时处理共享所有权和唯一所有权。
使用示例:从简单到复杂 📊
让我们通过几个例子来看看分析器的输出。
示例1:简单对象

这是一个非常简单的对象,包含一些字节向量和一个映射。

struct MyObject {
std::vector<uint8_t> a;
std::vector<uint8_t> b;
std::map<int, int> m;
};
int main() {
MyObject obj;
// ... 填充 obj ...
}

分析器输出显示有三个成员:a、b 和 m。我们可以看到每个成员使用了多少内存,发生了多少次分配,以及创建了多少个对象。我们还可以看到对象内部发现这些内存的偏移量。

示例2:Lambda表达式
与结构体不同,Lambda表达式没有命名字段,但分析器仍然可以记录类型信息。我们可以看到第一个字段拥有10字节,第二个字段拥有400字节(100个浮点数),第三个字段在一次分配中拥有8000字节(1000个双精度数)。


示例3:C风格数组
这个例子展示了一个类内部的C风格数组。分析器能够确定数组中每个元素的索引及其内存使用。
示例4:自定义容器(Toy实现)
这个例子展示了一个在对象内部内联分配缓冲区的向量(玩具实现)。分析器可以显示每个索引使用了多少内存。注意,索引3缺失了,因为那里没有分配内存。
示例5:标准库容器(非玩具示例)

这是一个规范示例:一个包含某些值的标准向量。分析器显示程序运行期间创建了两个包含 std::vector 的类型。其中一个没有分配子对象,直接分配了内存;另一个则拥有分配子对象。
技术实现细节:编译时插桩与运行时跟踪 🔧
上一节我们看了分析器的输出效果,本节我们来深入了解其技术实现。分析器的使用非常简单,例如在CMake中,你可以找到分析器的包,然后链接 mp_built_with_plugin 目标。链接它会添加编译标志,以便在使用Clang编译时注入插件。
插件在编译时修改抽象语法树,为析构函数添加注解。在运行时,通过 LD_PRELOAD 覆盖 malloc、free、new、delete 等符号,从而跟踪所有分配和释放操作。每次调用这些函数时,分析器会获取堆栈跟踪。如果是释放操作,还会展开堆栈,寻找由 save_state 留下的内存标记,从而识别出正在被销毁的对象。
运行时,堆栈跟踪只获取原始地址以提高速度。程序执行结束时,分析器对这些地址进行去重,并一次性解析为文件名和行号等信息。
分析真实项目:以CMake为例 🏗️


作为演示的最后一部分,我们将分析器应用在一个非平凡的代码库上:CMake。
CMake规模很大,包含935个翻译单元,超过一百万行代码,混合了C和C++,有多个依赖项。我们可以用插件构建整个项目。分析显示,核心的 CMake 状态对象总共使用了约340万字节,涉及数万次分配,包含许多拥有内存的字段。
对于一个完整的CMake自配置过程的分析,会生成一个包含150亿字节分配数据的统计对象。分析器可以处理如此大量的数据,并清晰地展示内存使用分布。
当前限制与未来方向 🚧

目前,你需要使用Clang编译器。如果有人为GCC编写了工作方式相同的插件,也可以使用,但目前我编写的插件只适用于Clang。
目前,该插件在注解非内联析构函数调用时存在问题。这与代码生成的时间有关。此外,如果库(如标准库)提供了析构函数的定义,链接器会直接使用那个定义,而不是被注解的版本,这可能导致注解丢失。
针对这些问题,修复工作正在进行中。我们正在修改抽象语法树,并更新内置对象的ABI标签,以确保不使用标准库提供的特定析构函数定义。


总结与问答环节回顾 📝
本节课中我们一起学习了一种基于对象所有权的C++内存分析方法。该工具提高了在类型和对象级别上对内存使用情况的可见性。按字段细分内存使用是前所未有的功能,非常有用。总的来说,它提供了识别低效性和优化代码内存使用所需的数据。
在问答环节,讨论涵盖了多种场景:
- 该工具主要解决的是内存使用低效(如数据结构臃肿)而非内存泄漏,但泄漏信息同样存在于数据中。
- 通过追踪所有分配和释放,分析器可以筛选出在峰值内存时刻已分配但尚未释放的内存,并将其归属到最终负责释放的对象。
- 对于使用自定义内存池或arena分配器的情况,目前需要注解相关分配函数才能获得相同可见性,这是未来的开发方向。
- 工具关注的是通过
malloc/new等接口分配的内存,不涉及共享库加载等操作系统层面的内存占用。 - 对于动态增长的容器(如
vector),目前主要关注其最终大小,但完整的分配事件历史已被记录,可用于更深入的分析。

这种方法为理解和优化C++程序的内存行为提供了强大的新视角。
070:自动微分基础与实现






在本教程中,我们将学习自动微分(Autodiff)的核心概念、工作原理及其在C++中的高效实现。我们将重点探讨反向模式自动微分,并分析在高吞吐量、内存密集型编程场景下的性能优化策略。

概述
自动微分用于计算程序中函数的梯度。它在机器学习、统计建模和科学计算中至关重要,例如用于训练大型语言模型或预测疫情传播趋势。梯度计算通常是优化算法中最耗时的部分,因此其实现效率至关重要。
什么是自动微分?🧠
自动微分通过将链式法则应用于程序的子表达式来计算梯度。其核心思想是将复杂函数分解为一系列基本运算(如加法、乘法、对数),然后组合这些基本运算的梯度。
例如,对于函数 f(x, y) = log(x * y),我们可以将其分解为:
g(x, y) = x * yh(g) = log(g)- 因此
f(x, y) = h(g(x, y))

根据链式法则,f 对 x 的梯度为:
∂f/∂x = (∂h/∂g) * (∂g/∂x) = (1/g) * y = 1/x
自动微分能够精确地(在浮点精度内)计算出梯度,而无需像有限差分法那样进行近似。
计算图与遍历 🔄
在C++中实现自动微分时,我们通常构建一个表达式图(Expression Graph)。图中的节点代表变量或运算,边代表数据流。
考虑表达式 z = log(x) * y + sin(x),其表达式图如下所示:
x y
|\ /
| \ /
| \ /
| *
| |
| |
| +
| / \
| / \
log sin
为了计算梯度,我们需要两次遍历这个图:
- 前向传播(Forward Pass):从输入节点开始,计算每个节点的值(例如,计算
log(x),sin(x),然后相乘、相加,最终得到z)。 - 反向传播(Reverse Pass):从输出节点(
z)开始,反向计算每个节点对其子节点的梯度(即伴随(adjoint))。
反向传播是反向模式自动微分的核心。每个节点都需要一个 chain 函数,用于根据其子节点的梯度来更新其父节点的梯度。
实现自动微分的两种主要方法 ⚙️
在C++中,主要有两种实现自动微分的方法:运算符重载(动态) 和源代码转换(静态)。选择哪种方法需要在灵活性和性能之间进行权衡。
方法一:运算符重载(动态方法)
这种方法在运行时构建和记录表达式图,具有很高的灵活性。
以下是其关键组件:
- 磁带(Tape):一个用于按顺序存储所有运算节点的容器(如
std::vector)。 - 区域分配器(Arena Allocator):一种高效的内存分配策略,它在连续的内存块上分配所有节点。在单次梯度计算完成后,通过重置指针而非释放内存来“清理”磁带,以便在下一次计算中重用内存,这极大地提升了性能。
- 变量类型(Var Type):一个封装类,包含值(value)和伴随(adjoint),并指向一个表示具体运算的节点对象。
一个简单的乘法运算节点可能如下所示:
struct MultiplyNode : VarNode {
Var* op1;
Var* op2;
MultiplyNode(Var* a, Var* b) : op1(a), op2(b) {}
void chain() override {
// 链式法则:∂z/∂op1 = adjoint * op2->value
op1->adjoint += this->adjoint * op2->value;
op2->adjoint += this->adjoint * op1->value;
}
};
Var operator*(const Var& a, const Var& b) {
// 1. 前向传播:计算值
double val = a.value * b.value;
// 2. 创建节点,记录运算
auto* node = tape.alloc<MultiplyNode>(a.impl, b.impl);
node->value = val;
// 3. 返回代表结果的新变量
return Var(node);
}
计算梯度时,我们首先将输出节点的伴随设为1,然后逆序遍历磁带,对每个节点调用其 chain 方法。
优点:非常灵活,支持运行时控制流(如 while 循环)。
缺点:存在指针追逐问题,缓存局部性差,通常比静态方法慢。
方法二:源代码转换(静态方法)

这种方法在编译时利用C++的类型系统和表达式模板来构建表达式图。表达式图的结构在编译期就已确定。


其核心是定义一个表达式模板类型:
template <typename Op, typename... Children>
struct Expr {
double value;
std::tuple<Children...> children;
// 编译时已知的反向传播逻辑
static void reverse(Expr& self, double child_adjoint) {
// 应用Op特定的反向传播规则
Op::propagate_adjoints(self.children, child_adjoint);
// 递归调用子表达式的reverse
std::apply([&](auto&... child) { (child.reverse(...), ...); }, self.children);
}
};
当我们写下 auto z = log(x) * y + sin(x); 时,z 的类型是一个复杂的、嵌套的表达式模板类型,它在编译期就编码了整个计算图。

优点:性能极高,编译器可以进行大量优化(如内联),消除了运行时开销。
缺点:灵活性受限,无法处理运行时才确定的计算图结构(例如,循环次数在运行时决定的 while 循环)。
矩阵运算的自动微分 📊

在科学计算中,我们经常需要对矩阵进行自动微分。这里有两种主要的存储策略:
-
结构体数组(Array of Structs, AoS):
std::vector<Var> matrix; // 每个元素是一个Var标量- 优点:实现简单,任何标量运算都可直接用于矩阵元素。
- 缺点:严重禁用SIMD优化,计算梯度时需要为每个矩阵元素进行指针追逐,缓存效率低。
-
数组结构体(Struct of Arrays, SoA):
struct MatrixVar { Eigen::MatrixXd values; // 值矩阵 Eigen::MatrixXd adjoints; // 伴随矩阵 void chain(double child_adjoint) { ... } // 整个矩阵的反向传播 };- 优点:
values和adjoints是连续存储的矩阵,可以利用Eigen等库的SIMD指令进行高效向量化运算。整个矩阵运算(如矩阵乘法)作为一个节点记录在磁带中,减少了节点数量和内存跳跃。 - 缺点:需要为每个矩阵运算(如乘法、Cholesky分解)手动实现其梯度(反向传播)逻辑。支持切片(slice)和赋值操作非常复杂,容易引入别名问题且可能导致意外的深度拷贝。
- 优点:
源代码转换方法对于矩阵运算尤其强大。我们可以定义一个表达式,它一次性描述整个矩阵计算流程(如 C = A * B; sum = C.sum()),然后在编译期生成高度优化的、可重用的前向和反向传播代码。通过预分配好值/伴随所需的内存,并绑定到表达式上,可以反复用新的输入数据执行该计算图,获得接近手工编码的性能。
性能考量与总结 🚀
- 性能对比:源代码转换(静态)方法通常比运算符重载(动态)方法快一个数量级。对于矩阵运算,采用SoA策略的动态方法优于AoS策略,但依然难以匹敌静态方法的性能。
- 选择建议:
- 如果需要支持动态控制流(如运行时循环),运算符重载是更合适的选择。
- 如果计算图在编译期可以确定,并且追求极致性能,源代码转换是最佳选择。
- 对于矩阵运算,优先考虑使用支持源代码转换或SoA动态策略的库。
- 关键优化点:
- 使用区域分配器:对于动态方法,这是减少内存分配开销、提升缓存局部性的最关键优化。
- 利用现代C++特性:如Lambda表达式、可变参数模板,可以大幅减少动态方法中为各种运算编写模板代码的工程量。
- 权衡灵活性:允许矩阵切片等操作会给实现带来显著复杂度,并可能影响性能,需要根据实际需求谨慎设计。
总结


本节课我们一起学习了自动微分的基础原理。我们了解了如何通过表达式图和反向传播计算梯度,并深入探讨了在C++中实现自动微分的两种主要方法:灵活的运行时运算符重载和高效的编译期源代码转换。我们还分析了在矩阵运算场景下不同的数据布局策略(AoS vs SoA)对性能的影响。理解这些核心概念和权衡,将帮助你为特定的应用场景选择或实现最合适的自动微分工具,从而在机器学习和科学计算任务中实现高性能的梯度计算。
071:将C++异常时间减少90%以上


概述
在本教程中,我们将跟随Khalil Estell在CppCon 2025上的演讲,学习如何将C++异常处理在ARM Cortex-M微控制器上的性能提升90%以上。我们将从基准测试设计开始,逐步分析GCC异常实现的瓶颈,并探索一系列优化技术,最终介绍一种名为Nearpoint的新型搜索算法。本教程旨在让初学者理解异常性能优化的核心思想和方法。
第0章:一切的起点
上一节我们概述了本教程的内容,本节中我们来看看这项研究是如何开始的。
在CppCon 2023的SG14和ISO委员会会议上,我提出了一个结论:提高展开性能、消除动态内存分配需求以及移除线程本地存储要求,将使异常成为嵌入式系统极具吸引力的选项。这个想法在我脑海中萦绕,最终促使我深入研究异常性能优化。
第1章:基准测试设计
上一节我们了解了研究的动机,本节中我们来看看如何进行性能测量和基准测试设计。
首先介绍测试设置和硬件。我使用了一款名为Hboard P3的设备,它集成了调试器和逻辑分析仪。该设备通过Micromod协议支持更换不同的微控制器,本次测试主要聚焦于一个低功耗、高约束的系统:仅64KB ROM和64MHz CPU速度。
以下是基准测试的关键设计点:
- 编译器与库:使用官方的GCC ARM 14.2工具链和Picolibc 1.8.10 C库。
- 测试目标:测量从错误检测到处理程序之间的堆栈传播时间。
- 代码结构:
expected(类似std::expected的结果类型)和异常的实现应互为同构,仅在错误返回/传播方式上不同。 - 优化等级:使用
-O3以获得最高性能。 - 避免内联:强制函数不内联,以观察最坏情况。
- 防止尾调用优化:通过操作
volatile字节数组来阻止编译器进行尾调用优化。 - 错误类型:测试4字节、16字节和65字节的简单错误对象(可平凡重定位)。
- 清理百分比:测试0%、25%、50%、100%的堆栈对象清理场景。
基准测试代码由Python脚本自动生成,并包含一个测试脚本,用于刷写设备、运行测试并通过逻辑分析仪收集时序数据。
第2章:初始结果与分析
上一节我们建立了基准测试,本节中我们来看看初始的测试结果。
结果使用对数刻度显示。在0%清理、4字节错误的情况下,GCC异常比返回整数慢113到141倍。性能表现不佳。
清理百分比的影响如下:
- 异常:清理越多,耗时越长。例如,50层深度时,从0%清理到100%清理,时间从2.3毫秒增加到3.3毫秒。
- Expected类型:趋势相同,但幅度小得多。
错误对象大小的影响如下:
- 异常:错误对象大小对传播时间几乎没有影响。
- Expected类型:错误对象越大,传播时间越长。特别是当错误大小超过缓存行(约65字节)时,耗时急剧上升。这是因为GCC会将其转换为
memcpy调用。
从初始基准测试中,我们学到:
- 对于小于64字节的错误,
expected类型非常快。 - 异常处理非常慢。
- 所有运行时参数的影响都是线性的,没有发现非确定性。
第3章:剖析与优化GCC异常实现
上一节我们看到了异常的性能瓶颈,本节中我们开始尝试优化GCC的实现。
我编写了一个Python脚本,通过GDB单步执行从throw到catch的每一条指令,并统计在GCC展开例程中各函数的耗时分布。基于此,我进行了三项主要优化。
优化1:改进异常表搜索
GCC使用二分查找在异常索引表中定位函数。标准的二分查找实现没有提前返回,总是执行log(N)步,且分支较多。
我将其替换为std::ranges::upper_bound。在包含约710个函数的程序中,平均周期数从379降至270,减少了约28%的运行时开销。
优化2:优化虚拟寄存器集弹出
此函数负责从堆栈中弹出值到寄存器。原始实现会遍历16个寄存器位,即使很多位是0(表示无需弹出)。
我利用C++20的std::countr_zero来直接定位需要弹出的寄存器位(即值为1的位),跳过了对0位的检查。这将操作数从46个减少到21个,使该函数在总运行时的占比从24%降至7.3%。
优化3:简化展开指令执行
_Unwind_Execute函数很长,包含许多针对不同ARM展开指令的处理代码。我注意到它频繁调用_Unwind_VRS_Get和_Unwind_VRS_Set等辅助函数。

我移除了这些函数中与当前架构无关的冗余检查(例如寄存器号大于15的检查),并将它们标记为always_inline。同时,也内联了_Unwind_GetByte函数。这些改动将该函数的运行时占比从24%降至17.1%。
综合效果:仅这三项优化,就将异常处理的运行时减少了33%到43%。
第4章:Nearpoint搜索算法
上一节我们优化了GCC的原有实现,本节中我们探索一种全新的、更快的搜索算法。
在与他人交流后,我萌生了一个想法:能否通过某种插值方法,根据程序计数器(PC)直接估算出对应的异常表条目?目标是获得比二分查找快一个数量级的搜索速度,同时保持异常带来的代码空间优势。
核心思想
- 合并重复项:将具有相同展开信息的函数条目合并(包括大量的
noexcept函数),减少表大小。 - 按大小排序:将函数块按大小排序后,其地址与条目索引的关系图会变得更平滑。
- 分块线性近似:将整个地址空间划分为2的幂次方大小的块。对于每个块,我们只存储两个值:块内起始条目索引(B)和块内条目数量(上升值)。运行值(地址范围)是固定的块大小。
- 快速计算:给定一个PC,先计算它属于哪个块(块索引),以及在该块内的偏移量。然后通过公式 估算条目索引 = B + (偏移量 * 条目数量) / 块大小 快速得到一个近似位置。由于误差很小(设计目标为小于8),只需在近似位置附近进行少量迭代即可找到精确条目。
优势
- 速度:相比二分查找,大幅减少了比较次数和缓存未命中(通过限制最大误差保证缓存局部性)。
- 空间:由于合并了重复条目,整体异常索引表的大小反而减少了588字节。
使用Nearpoint算法后,异常性能提升了约90%(对于11层以上的调用深度)。与返回整数错误码的expected类型相比,速度差距从上百倍缩小到约12-18倍。
第5章:自定义运行时与未来展望
上一节我们介绍了革命性的Nearpoint算法,本节中我们来看看如何构建一个更优的异常运行时以及对未来的思考。
我基于之前的分析,实现了一个自定义的C++异常运行时,包含以下优化:
- 用C++重写:利用现代编译器和语言特性。
- 单阶段异常:在展开堆栈的同时立即清理,而不是先搜索处理程序再清理。这与
expected的行为一致,且更快。 - TLS控制块:将每异常的状态存储改为每线程状态存储,节省内存。
- PMR分配:使用多态分配器资源来控制异常对象的分配。
- 缓存与预计算:缓存
throw本身的展开指令,并预处理展开指令表以便快速迭代。 - 即时展开(JIT Unwinding):使用GCC扩展的“标签作为值”特性,为256种可能的展开指令构建跳转表,消除循环和条件分支。
- 扁平化RTTI层次结构:在链接时分析所有类型信息,将继承层次结构扁平化为一个列表,使
dynamic_cast类型的捕获复杂度降为O(N)。
结果:该自定义运行时比GCC快83-85.4%,将异常与expected(整数错误)的差距缩小到17-21倍。对于复杂的非平凡可移动类型(如std::string),异常的性能优势更加明显。
未来的异常
- 工具支持:开发“异常洞察”工具,在构建时分析二进制文件,可视化所有
throw和catch的路径,帮助开发者系统化处理错误。 - 统一异常格式:进一步去重和优化异常数据表。
- 标准化接口:推动
set_exception_allocator/get_exception_allocator等函数进入标准,为独立环境下的异常支持铺路。 - 协程集成:研究如何改进异步上下文中的异常展开。
- 通过链接器插件分发:无需修改编译器或标准库,只需将优化后的运行时作为链接器插件使用,即可替换原有实现,并支持模块化配置(如选择单阶段/双阶段、不同跳转表实现等)。
总结

在本教程中,我们一起学习了如何将C++异常处理的性能提升90%以上。我们从基准测试入手,分析了GCC实现的瓶颈,并逐步应用了包括改进二分查找、优化寄存器弹出、简化展开逻辑在内的多项优化。接着,我们深入探讨了创新的Nearpoint搜索算法,它通过合并、分块和线性近似技术,极大加快了异常表的查找速度。最后,我们看到了一个集成了所有优化思路的自定义运行时,并展望了通过链接器插件、改进工具链等方式让高性能异常处理更易用的未来。核心在于相信优化是可能的,并利用数据驱动设计、编译时计算和现代C++特性来达成目标。
072:精通 C++ 友元关键字



在本教程中,我们将学习 C++ 中 friend 关键字的基础知识。我们将探讨其用途、最佳实践,并澄清一些常见的误解。通过本教程,你将理解如何恰当地使用友元来增强代码的封装性和可维护性。
1:友元的基本概念与访问控制

C++ 提供了丰富的访问说明符来控制类成员的可见性。


public成员:对所有人可访问。protected成员:对当前类及其派生类可访问。private成员:仅对当前类自身可访问。

class 默认提供私有成员访问和私有继承,而 struct 默认提供公有成员访问和公有继承。
有时,非成员函数(如流输出运算符 operator<< 或加法运算符 operator+)需要访问类的私有成员以实现功能。如果将这些运算符实现为成员函数,则左侧操作数无法进行隐式类型转换,限制了其灵活性。
示例:成员运算符的局限性
class MyInt {
int value;
public:
MyInt(int v) : value(v) {}
// 成员 operator+,仅右侧参数可隐式转换
MyInt operator+(const MyInt& rhs) const {
return MyInt(value + rhs.value);
}
};

MyInt a(42);
auto result1 = a + 1; // 正确:1 被隐式转换为 MyInt
auto result2 = 1 + a; // 错误:左侧的 `1` 无法调用成员函数 `operator+`




正确的做法是将这些二元运算符实现为非成员函数。但作为非成员函数,它们默认无法访问类的私有成员。这时就需要 friend 关键字。



friend 的作用:friend 声明授予一个函数或类访问另一个类的私有(private)和保护(protected)成员的权限。
2:友元的声明与语法

友元声明可以放置在类定义中的任何区域(public、protected 或 private),其访问性不受该区域影响。友元声明本身不会破坏封装性。
可以声明为友元的实体:
- 非成员函数
- 其他类的成员函数
- 整个类
- 函数模板或类模板


示例:声明友元函数
class BankAccount {
double balance;
public:
// 声明友元函数,允许其访问私有成员 `balance`
friend void transferFunds(BankAccount& from, BankAccount& to, double amount);
};





// 友元函数的定义
void transferFunds(BankAccount& from, BankAccount& to, double amount) {
from.balance -= amount; // 可以访问私有成员
to.balance += amount;
}



友元的性质:
- 非传递性:如果
X是Y的友元,Y是Z的友元,并不意味着Z是X的友元。 - 非相互性:如果
X声明Y为友元,并不意味着Y自动声明X为友元。 - 非继承性:基类的友元关系不会被派生类继承。
3:封装性与友元的关系

封装是将数据及与之相关的行为绑定在一个清晰接口后的艺术。它隐藏实现细节,维护类的不变量,并支持轻松重构。
一个常见的误解是添加成员函数会增强封装。实际上,如果一个函数既能实现为成员函数,也能实现为非成员函数,那么将其实现为非成员函数通常能提高类的封装性。因为非成员函数依赖于类的公有接口,当类的内部实现改变时,需要修改的函数更少。
友元函数与封装:友元函数在破坏封装性方面,与公有成员函数并无区别。两者都获得了访问类内部的权限。关键在于设计,而非关键字本身。
标准库中的 std::string 拥有大量成员函数,这使得其内部重构变得困难,这是一个反面例子。
4:应避免使用友元的场景
在了解了友元的基本用法后,我们来看看哪些情况下应该避免或谨慎使用友元。
1. 不要为单元测试声明友元
这是最常见的误用之一。为了测试私有成员而将测试类声明为友元,会带来严重问题:
- 增加了产品代码与测试代码的耦合。
- 私有成员的改动会迫使测试代码同步修改。
- 测试应该关注公有接口的行为,而非内部实现细节。
正确的做法是遵循单一职责原则,将大类分解为多个职责清晰的小类,并通过依赖注入、接口抽象等方式,使其能够通过公有接口进行独立测试。
2. 当嵌套类已具备访问权限时
如果某个类(如迭代器)被定义为另一个类的嵌套类,那么它天然具有访问外部类私有成员的权限,无需额外声明为友元。
3. 当需要更细粒度的访问控制时
声明一个类为友元,意味着授予它访问所有私有成员的权限。如果只想开放部分接口,可以考虑使用 Passkey 惯用法或 Attorney-Client 惯用法来提供更精细的访问控制。
5:隐藏友元:提升编译效率与错误信息
传统上,我们将友元函数在类内声明,在类外定义。这会导致在查找函数(特别是重载运算符时)时,编译器需要检查大量候选函数,产生冗长的编译错误信息并影响编译速度。
隐藏友元 是一种将友元函数的声明和定义同时放在类内部的写法。
示例:隐藏友元
class MyInt {
int value;
public:
MyInt(int v) : value(v) {}
// 隐藏友元:声明和定义一体,且仅在类内
friend MyInt operator+(const MyInt& lhs, const MyInt& rhs) {
return MyInt(lhs.value + rhs.value);
}
};
隐藏友元的优点:
- 改善编译错误信息:隐藏友元只能通过参数依赖查找(ADL)被发现。当调用不匹配时,编译器不会在错误信息中列出大量无关的全局重载,使得错误更清晰。
- 提升编译速度:减少了名称查找和重载解析时需要处理的候选函数数量。
- 代码更简洁:无需在类外再写一遍函数定义。
大多数自定义点(如 operator<<, operator+, swap, abs)都依赖 ADL 来查找函数,因此隐藏友元是它们的完美搭档。
建议:即使不需要访问私有成员,在重载运算符或实现具有通用名称的自定义点时,也应优先考虑使用隐藏友元,而非全局非成员函数,以获得更好的编译期体验。
6:总结与最佳实践
本节课我们一起学习了 C++ 中 friend 关键字的精髓。
总结要点:
friend用于授予特定函数或类访问另一个类私有/保护成员的权限。- 它会在两个独立的实体间引入强耦合,但如果这些实体本身逻辑紧密且因某些原因(如生命周期不同)无法合并,这种耦合是可接受的。
- 隐藏友元函数在破坏封装性方面并不比成员函数更甚,无需对其感到恐惧。
- 当外部函数(如流操作符、算术运算符)需要特殊访问权限,且不应或不能作为成员函数实现时,使用
friend。 - 积极使用隐藏友元来改善编译速度和错误信息的清晰度,即使不需要访问私有成员。
- 避免为单元测试声明友元。
- 当授予全部私有成员访问权限不合意时,避免使用友元,可以考虑
Passkey等替代方案。


核心思想:friend 是一个强大的工具,如同艺术,需要技巧、理解和谨慎应用。请明智地使用它,并充分理解其细微之处。
073:核心概念与实现



在本教程中,我们将学习如何构建一个健壮的、可用于进程间通信的C++消息队列。我们将探讨在跨进程环境中使用队列时遇到的独特挑战,例如地址空间隔离、同步、进程崩溃恢复以及C++标准对进程的有限定义。我们将重点关注如何设计一个分离生产者、消费者和队列本身职责的健壮系统。
进程间消息队列的挑战
上一节我们概述了课程目标,本节中我们来看看在跨进程环境中使用队列时面临的核心挑战。
C++标准几乎未定义进程相关的行为。因此,当我们尝试在进程间共享数据结构(如队列)时,会遇到许多语言未涵盖的问题。这些问题包括指针在不同进程地址空间中的不兼容性、同步原语的失效以及进程崩溃后的状态恢复。
指针与地址空间

在单个进程内的线程间,共享指针是直接的,因为它们指向相同的虚拟地址空间。然而,在进程间,每个进程拥有独立的地址空间。一个进程中的指针值对另一个进程毫无意义,即使它们通过共享内存映射到了相同的物理内存区域。

核心问题:队列数据结构内部通常包含指向其缓冲区的指针。当这个数据结构被放入共享内存时,不同进程看到的指针值是不同的,直接使用会导致未定义行为。
解决方案:我们需要一种机制,让生产者和消费者在访问队列时,能基于共享内存的基地址计算出正确的本地指针。这通常意味着队列元数据中存储的应该是相对于共享内存区域的偏移量,而非绝对指针。
分离关注点
在单进程多线程环境中,我们可能使用一个同时提供 push 和 pop 操作的队列API。但在跨进程场景下,这种设计是危险的。
我们需要强制实施分离关注点:
- 队列对象:负责自身的构造、元数据管理和缓冲区生命周期。它提供同步机制,确保只有一个实体能创建它。
- 生产者对象:只能向队列写入数据。在单生产者队列中,必须确保全局只有一个活跃的生产者实例。
- 消费者对象:只能从队列读取数据。在单消费者队列中,同样要确保唯一性;在多消费者队列中,可以有多个实例。
这种分离不能仅靠约定,必须通过代码逻辑来强制实施。例如,生产者构造函数应尝试获取一个“生产者锁”,如果锁已被占用(表明已有生产者存在),则构造失败。
进程崩溃与锁恢复
如果持有锁(例如标识自己是唯一生产者的锁)的进程崩溃,这个锁可能永远无法被释放,导致队列无法被后续进程使用。这是一个必须解决的“健壮性”问题。
解决方案思路:实现一种能检测持有者进程是否存活的锁。一种方法是使用一个扩展的“进程ID”,它不仅包含操作系统分配的PID(可能被复用),还包含进程的启动时间戳。锁持有者将此ID存入一个原子变量。当另一个进程尝试获取锁时,如果发现锁被占用,它可以检查该ID对应的进程是否仍然存活。如果进程已死亡,尝试者可以安全地“夺取”该锁。
实现健壮的进程间锁
上一节我们讨论了进程崩溃带来的锁恢复问题,本节中我们来看看一种可能的实现方案。
我们不能简单地使用 std::mutex,因为它在进程崩溃后的行为是未定义的,且其状态可能无法恢复。我们需要一个基于共享内存原子操作的自定义锁。
基于进程ID的健壮锁
以下是该锁的核心尝试获取(try_lock)逻辑的伪代码描述:
bool RobustLock::try_lock() {
ProcessId my_id = get_current_process_id(); // 获取包含PID和启动时间的ID
ProcessId expected = PROCESS_ID_NONE; // 预期锁是空闲的
// 尝试以原子方式将锁所有者设置为我自己
if (atomic_compare_exchange_strong(&lock_owner, &expected, my_id)) {
return true; // 成功获取锁
}
// 锁已被占用
if (expected == my_id) {
return true; // 我已经持有这个锁(可重入情况,根据需求处理)
}
// 检查当前锁持有者进程是否还活着
if (!is_process_alive(expected)) {
// 持有者已死,尝试夺取锁
// 需要再次使用CAS,因为可能有其他进程也在尝试夺取
ProcessId dead_id = expected;
if (atomic_compare_exchange_strong(&lock_owner, &dead_id, my_id)) {
return true; // 成功夺取锁
}
// 夺取失败,进入下一次重试或等待
}
return false; // 当前无法获取锁
}
关键点:
get_current_process_id()需要生成一个在系统运行期间几乎唯一标识进程的值(如 PID + 启动时间戳)。is_process_alive(id)需要通过系统调用(如检查/proc/[pid]/状态)来验证。- 所有操作都基于原子变量
lock_owner,它存储在共享内存中。
C++对象生命周期与共享内存
上一节我们探讨了同步问题,本节中我们来看一个更微妙但至关重要的语言层面问题:C++对象在共享内存中的生命周期。
当我们通过 mmap 将共享内存映射到进程地址空间时,我们获得了一块原始的字节区域。C++编译器如何知道这块内存中存在着一个具有构造函数、析构函数和虚表的复杂对象呢?
隐式生命周期类型
在C++20之前,在未构造的对象存储上直接进行访问是未定义行为。C++20引入了隐式生命周期类型的概念,允许编译器在某些情况下为对象“隐式”创建生命周期。
一个类型是隐式生命周期类型,如果它是:
- 标量类型(如
int,指针)。 - 数组类型。
- 具有
const/volatile限定的上述类型。 - 一个聚合类,或者
- 至少有一个平凡的(trivial)合格构造函数和一个平凡的、非删除的析构函数的类。
关键限制:如果一个类显式声明了复制/移动操作(即使是 = delete),或者其默认构造函数非平凡,它就可能不是隐式生命周期类型。
std::atomic 的问题
std::atomic 在C++20中有一个重大变化:其默认构造函数被要求进行值初始化(例如,将原子变量初始化为0)。这使得它的默认构造函数不再是平凡的。
根据上述规则,std::atomic 在C++20及之后不是隐式生命周期类型。这意味着,如果你将一个 std::atomic 变量放入共享内存,并在另一个进程中通过映射访问它,从C++标准的角度看,其生命周期并未开始,访问它是未定义行为。
解决方案:
- 使用
std::atomic_ref(C++20):在访问共享内存中的原子变量时,使用std::atomic_ref来包装它。std::atomic_ref的构造函数是平凡的,它本身是隐式生命周期类型。但你必须确保同一内存位置不会同时被std::atomic_ref和普通std::atomic访问。 - 使用自定义的平凡原子包装器:创建一个包含
std::atomic_ref或直接使用编译器内置原子操作的自定义类,并确保其所有构造函数和析构函数都是平凡的。 - 使用
std::start_lifetime_as(C++23):这个新函数可以显式地开始一个对象在给定存储上的生命周期。但它的参数类型必须是隐式生命周期类型,所以对std::atomic本身不直接适用。
结论:在共享内存中使用的任何消息类型,也必须是隐式生命周期类型,以确保跨进程访问的合法性。
“魔法缓冲区”技术
上一节我们讨论了语言层面的约束,本节中我们来看一个能极大简化环形缓冲区实现并可能提升性能的系统级技术:“魔法缓冲区”。
环形缓冲区的一个常见实现难点是处理回绕:当写指针到达缓冲区末尾时,需要检查剩余空间是否足够,如果不够,要么等待,要么将数据拆分成两段写入(一段在末尾,一段在开头)。
“魔法缓冲区”利用操作系统的虚拟内存机制,使得一个固定大小的缓冲区在逻辑上看起来是无限连续的。
实现原理
- 预留虚拟地址空间:首先,预留一块两倍于所需缓冲区大小的虚拟地址空间(例如,需要1MB缓冲区,则预留2MB虚拟地址)。此时并未分配物理内存。
- 第一次映射:将你的实际物理缓冲区(例如,一个1MB的共享内存文件)映射到这块预留空间的前半部分(0 到 1MB)。
- 第二次映射:将同一个物理缓冲区再次映射到预留空间的后半部分(1MB 到 2MB)。
效果:现在,在这2MB的虚拟地址范围内,访问偏移量 0 到 1MB-1 是缓冲区的第一遍。访问偏移量 1MB(即 0 + 1MB)时,由于第二次映射,实际上会访问到物理缓冲区的第一个字节。访问偏移量 1MB + 1 会访问物理缓冲区的第二个字节,依此类推。
对于应用程序来说,它获得了从起始地址开始、连续不断的 1MB 虚拟地址空间。写指针可以一直线性增加,当它超过第一个1MB的边界进入第二个1MB区域时,通过虚拟内存映射,它会自动回绕到物理缓冲区的开头,而无需任何条件判断或模运算。
代码概念:
// 伪代码描述映射过程
void* virtual_area = reserve_virtual_address(2 * buffer_size);
void* physical_buffer = get_shared_memory_buffer(buffer_size);
// 第一次映射
mmap(virtual_area, buffer_size, ..., physical_buffer);
// 第二次映射
mmap((char*)virtual_area + buffer_size, buffer_size, ..., physical_buffer);
// 现在,virtual_area 开始的一个大小为 buffer_size 的连续区域就是“魔法缓冲区”
char* magic_buffer = (char*)virtual_area;
// 可以像访问无限线性内存一样访问 magic_buffer[0] 到 magic_buffer[buffer_size-1],
// 而 magic_buffer[buffer_size] 实际上就是 magic_buffer[0]
优势:
- 简化代码:生产者/消费者逻辑无需处理回绕,指针可以一直递增。
- 潜在性能提升:消除了分支预测和模运算开销。
- 便于对齐访问:可以轻松满足大型数据结构的对齐要求,因为总能找到一段连续的对齐内存。
注意:需要测量以确保在特定平台上性能确实有提升,并且要注意虚拟地址空间的消耗。
多播队列简介
到目前为止,我们主要关注单生产者单消费者队列。另一种有用的模式是单生产者多消费者队列,也称为多播队列。
多播队列的特点
- 单生产者,多消费者:一个生产者向队列写入消息,多个消费者独立地从队列读取消息。
- 每个消息被所有消费者看到:理想情况下,每个活跃的消费者都应该能收到生产者发送的每一条消息。这与任务队列(一个任务只被一个工作者取出)不同。
- 生产者不阻塞:生产者通常以尽可能快的速度写入,不会因为消费者慢而等待。
- 消费者可能丢失消息:如果某个消费者处理速度跟不上生产者,它可能会“被套圈”,即未来的新数据覆盖了它还未读取的旧数据。消费者需要有能力检测到这种情况(例如,通过序列号断层)并执行恢复策略(如请求重传、跳过消息或优雅降级)。
实现考虑
多播队列的实现比SPSC队列更复杂,因为需要为每个消费者维护独立的读位置。一种常见的实现是,队列元数据中包含一个生产者写的全局序列号,而每个消费者在本地(或在其独立的共享内存区域)保存自己最后处理成功的序列号。消费者通过比较本地序列号和全局序列号来判断是否有新数据,并处理可能的序列号间隙。
总结
在本节课中,我们一起学习了构建健壮的C++进程间消息队列所需的核心知识:
- 挑战识别:理解了跨进程通信带来的独特问题,如地址空间隔离、需要分离生产者/消费者/队列的关注点,以及进程崩溃后的状态恢复。
- 健壮同步:探讨了如何实现一个能抵御进程崩溃的锁机制,通过结合PID和进程启动时间来唯一标识锁持有者。
- C++对象模型:认识了C++20中隐式生命周期类型的重要性,并了解到
std::atomic在共享内存中直接使用可能存在的问题及解决方案。 - 高级优化:学习了“魔法缓冲区”技术,它利用虚拟内存映射来简化环形缓冲区的实现并可能提升性能。
- 队列变体:简要了解了单生产者多消费者(多播)队列的概念和特点。



构建此类系统要求开发者同时深入理解操作系统机制(进程、内存映射、虚拟内存)和C++语言标准(对象生命周期、原子操作、内存模型)。通过精心设计分离关注点的API、实现健壮的同步原语并尊重语言的语义,我们可以创建出既高效又可靠的进程间通信组件。
074:C++23 std::stacktrace 的隐藏力量



概述
在本教程中,我们将学习如何利用 C++23 的 std::stacktrace 以及其他技术,在不修改现有代码的情况下,增强 C++ 异常处理的调试能力。我们将探讨如何捕获异常抛出时的调用栈信息,并将其传递到异常捕获点,从而解决“异常从何而来”这一常见调试难题。
1:问题引入与目标
上一节我们概述了本课程的目标。本节中,我们来看看开发者在使用异常时遇到的一个典型困境。
当程序抛出异常并进入 catch 块时,我们常常不知道这个异常具体是从调用栈的哪个位置抛出的。堆栈已经展开,原始的调用上下文信息丢失了,这使得调试变得困难。
我们的目标是实现一个机制,能够自动捕获异常抛出时的堆栈跟踪信息,并将其提供给 catch 块或程序终止处理器,而无需修改现有的 throw 和 catch 语句。
2:C++异常机制回顾
在深入解决方案之前,我们需要回顾一下 C++ 异常处理的基本机制,理解其工作流程中的关键节点。
C++ 使用 throw 语句抛出异常。程序必须位于 try 块内才能捕获异常,否则程序将终止。
当 throw 语句执行时,运行时环境会执行一系列操作。首先,它在堆上动态分配异常对象。然后,它调用一个内部函数(在 Itanium C++ ABI 中称为 __cxa_throw)来处理异常传播。
这个内部函数主要做两件事:
- 搜索与异常类型匹配的
catch块。 - 如果找到,则展开堆栈(调用各栈帧上对象的析构函数),并开始执行
catch块。
开始执行 catch 块时,会调用另一个内部函数 __cxa_begin_catch。
3:关键C++工具:std::source_location 和 std::stacktrace
为了实现我们的目标,我们需要利用 C++ 标准库提供的两个重要工具。
首先是 C++20 引入的 std::source_location。它用于在编译时捕获代码的源位置信息(文件名、函数名、行号)。它是一个常量表达式,由编译器生成,运行时开销极小。
#include <source_location>
#include <iostream>
void log(const std::source_location& loc = std::source_location::current()) {
std::cout << loc.file_name() << ":" << loc.line() << " in function " << loc.function_name() << std::endl;
}
其次是 C++23 引入的 std::stacktrace。它表示程序在某一时刻的调用栈。每个栈帧条目类似于 std::source_location,但包含的是运行时调用链的信息。我们可以控制其最大大小以避免过深的递归调用消耗过多内存。
#include <stacktrace>
#include <iostream>
void print_stacktrace() {
auto st = std::stacktrace::current();
for (const auto& entry : st) {
std::cout << std::to_string(entry) << std::endl; // 输出格式化的栈帧信息
}
}
4:核心思路:拦截异常处理内部函数
了解了异常处理流程和可用工具后,我们来看看解决方案的核心思路。
我们计划在异常处理流程的两个关键点注入代码:
- 在
__cxa_throw被调用时(即throw语句执行后),捕获当前的堆栈跟踪和其他上下文信息。 - 在
__cxa_begin_catch被调用时(即catch块执行前),获取并利用之前保存的上下文信息。
这样,我们就能在不修改业务代码的情况下,将异常抛出点的信息传递到异常处理点。
为了实现拦截,我们需要在动态链接的可执行文件中“钩住”(hook)这些函数。这可以通过 LD_PRELOAD 环境变量或确保我们的拦截库在链接时优先加载来实现。其原理是使用 dlopen 和 dlsym 找到这些函数的原始地址,然后用自己的函数包装它,在执行自定义逻辑(如捕获堆栈)后,再调用原始函数。
5:Linux/Itanium ABI 实现详解
本节我们具体看看在遵循 Itanium C++ ABI(GCC、Clang 使用)的 Linux 系统上如何实现。
我们需要处理一个细节:__cxa_throw 函数有两个常见签名。较旧的 GCC 版本使用 void* 参数,而较新的 Clang 和 GCC 使用更具体的类型指针。我们的拦截代码需要兼容两者。
以下是拦截 __cxa_throw 的简化示例:
extern “C” {
// 声明原始函数指针类型
using cxa_throw_type_new = void(void*, std::type_info*, void(*)(void*));
using cxa_throw_type_old = void(void*, void*, void(*)(void*));
// 获取原始函数指针
cxa_throw_type_new* orig_cxa_throw_new = (cxa_throw_type_new*)dlsym(RTLD_NEXT, “__cxa_throw”);
cxa_throw_type_old* orig_cxa_throw_old = (cxa_throw_type_old*)dlsym(RTLD_NEXT, “__cxa_throw”);
}
// 我们的拦截函数(以新签名为例)
void __cxa_throw(void* thrown_exception, std::type_info* tinfo, void(*dest)(void*)) {
// 1. 捕获堆栈跟踪和上下文信息
auto context = capture_throw_context(std::stacktrace::current(), tinfo);
// 2. 将信息存储在线程局部变量中,供后续 catch 块使用
get_thread_local_throw_info() = std::move(context);
// 3. 调用原始的 __cxa_throw,让异常处理正常进行
orig_cxa_throw_new(thrown_exception, tinfo, dest);
// 理论上 __cxa_throw 不会返回,此处用 __builtin_unreachable() 避免编译器警告
__builtin_unreachable();
}
捕获的上下文信息(如堆栈跟踪、时间戳、线程ID)可以存储在一个线程局部变量中。这样,当控制流进入 catch 块时,我们可以通过拦截 __cxa_begin_catch 来读取并打印这个信息。
此外,我们可以提供运行时控制,例如通过一个线程局部布尔变量来决定是否启用堆栈捕获,以便在性能敏感的循环中临时关闭此功能。
6:Windows SEH 实现差异
对于 Windows 平台,异常处理机制不同,它使用结构化异常处理。
在 Windows 上,我们可以通过向异常处理链(AddVectoredExceptionHandler)添加一个处理器来达到类似目的。这个处理器会在异常发生时被调用,我们可以在其中捕获堆栈跟踪。
#include <windows.h>
#include <eh.h>
LONG WINAPI MyVectoredExceptionHandler(PEXCEPTION_POINTERS ExceptionInfo) {
if (ExceptionInfo->ExceptionRecord->ExceptionCode == 0xE06D7363) { // C++ 异常代码
// 捕获堆栈跟踪
auto stack_trace = capture_stacktrace();
// 存储到线程局部变量
get_thread_local_throw_info() = std::move(stack_trace);
}
// 返回 EXCEPTION_CONTINUE_SEARCH 让其他处理器继续工作
return EXCEPTION_CONTINUE_SEARCH;
}
// 在程序初始化时注册处理器
__declspec(allocate(“.CRT$XLC”)) static auto handler = MyVectoredExceptionHandler;
需要注意的是,Windows 上拦截 catch 块开始的等效机制可能与 Linux 不同,需要进一步研究。
7:高级用法与未来展望
我们的基础机制可以实现一些高级调试功能。
例如,我们可以在 __cxa_throw 的拦截函数中调用一个用户注册的回调函数。这使得开发者可以实现条件调试逻辑,比如仅在特定条件下触发调试器断点。
我们也可以修改终止处理器(std::set_terminate),在程序因未捕获异常而终止前,打印出最后一个异常(或多个线程中的异常)的堆栈跟踪信息。但请注意,在信号处理程序(terminate 可能由此触发)中调用堆栈跟踪函数可能不是完全安全的,需谨慎处理。
展望未来,C++26 可能会引入 std::stacktrace_from_current_exception,允许从异常对象本身直接获取堆栈跟踪。这可能在性能上更优,因为它允许异常处理运行时在搜索 catch 块时共享已计算出的堆栈信息,避免重复遍历堆栈。
8:现有工具与总结
在 std::stacktrace 标准化之前,已有一些优秀的库提供了类似功能,例如 backward-cpp 和 cpptrace。它们使用不同的底层堆栈展开库,并且 backward-cpp 能够更好地处理优化后函数被内联的情况。

总结
本节课中,我们一起学习了如何增强 C++ 异常处理的调试能力。
我们从一个常见的调试痛点出发:在 catch 块中丢失了异常抛出的上下文。通过分析 C++ 异常处理 ABI,我们找到了两个关键的注入点:__cxa_throw 和 __cxa_begin_catch。

利用动态链接函数拦截技术、C++23 的 std::stacktrace 以及线程局部存储,我们构建了一个无需修改现有 throw/catch 代码的解决方案。该方案能够自动捕获并传递异常抛出点的完整调用栈信息,显著加速调试和错误诊断过程。

我们还简要探讨了 Windows 平台上基于 SEH 的不同实现方式,以及未来 C++ 标准可能带来的改进。最终,我们获得了一个强大且非侵入式的调试辅助工具。
075:为何99%的C++微基准测试是谎言——以及如何编写那重要的1%






在本教程中,我们将深入探讨C++性能测量的复杂性。我们将学习如何避免常见的微基准测试陷阱,理解硬件效应如何影响测量结果,并掌握编写可靠、可重现的微基准测试的方法。通过本课程,你将能够区分无效的测量与有价值的洞察,从而真正优化你的代码性能。
概述:性能测量的重要性
与提供计算机科学广泛概述的会议相比,CPPCon能够深入探讨语言的复杂性。如果人们对是否参加CPPCon有疑虑,我认为绝对值得参加。参加的最佳理由是能够亲眼目睹并体验C++新进展的发生过程。


大家好,我是Chris。今天我们将讨论性能,尤其是测量,因为在我看来这是最重要的部分。我们将用一个替代方案来解释为什么测量如此重要。


如果没有可靠的测量,后续的优化将非常困难。这在微基准测试领域尤其重要,因为我们必须处理大量噪声。这一切都非常困难,并且在过去几十年里变得更加困难。
每个人都可能熟悉摩尔定律。起初,晶体管总数每两年翻一番。随着晶体管变小,频率变快,CPU延迟因此获得了很大提升。这种情况在2005年左右发生了变化。我们可能达到了频率的极限,除非我们通过散热等方式进一步推动。因此,CPU现在更专注于吞吐量。我们有了更多的核心、更大的缓存和更多的数据并行性等。晶体管数量在增长,但其目的可能有所不同。
所有这些都很重要,因为纳秒现在比以往任何时候都更重要。我们不能仅仅等待CPU和频率在两年内变得更快,因为那将更多地与吞吐量相关,而不是延迟本身。例如,2005年的CPU(如奔腾4,约3GHz)与2025年的AMD Zen 5相比,速度会慢得多。
性能分析非常困难,非常复杂,没有银弹,没有一个库或一个神奇的公式能解决一切。因此,我们分阶段处理。我们有一个可以分析网络TCP等的系统,在设计数据结构和微架构时,最有趣的是硬件效应,这是我们今天将重点关注的。
你可能经常听到“始终测量”的说法,因为如果你无法测量,就无法改进。这有点像“垃圾进,垃圾出”。因此,你必须真正专注于这一点。这就是为什么在我看来,拥有对测量的信任和理解非常重要。你必须能够重现它们。
如果你在系统层面,首先,你可以使用eBPF来追踪C-states、I/O等。这是Linux中可追踪内容及其方法的很好图示。在应用层面,根据你是否需要硬件特定分析,你可以使用VTune、AMD uProf和perf等工具。我们将更多地关注perf,因为它更通用,允许我们用它做很多事情。顺便说一下,你也可以用GDB做一些分析,通过反向工程和反向调用栈进行反向追踪。你可以制作火焰图等。
但如果你真的想测量某些东西,热点分析并不是一个好主意,我们稍后会解释。你不想测量启动和配置部分。perf很棒,但你希望在初始步骤之后引入它。想法是,我们可以在启动后开始,这将是perf record。但如果你考虑如何将perf与你的C++代码集成,这是一种方法,那将是perf record,你将在启动时支付开销。
在这一层面上做这些很重要,因为你不想为了性能测量而重新编译应用程序,那会引入我们稍后讨论的偏差,你实际上不想那样做。X-Ray是这方面的一个很棒的工具,因为它可以以非常低的开销引入启用和禁用分析。它的工作方式是通过在函数的入口和出口添加特定字节数的“桩”(nop),然后进行代码修补,将“桩”改为跳转指令,然后我们就可以进行分析。你可以连接Linux Perf,并拥有一个启用此分析功能的二进制文件,你可以在任何时间点进行配置。这很重要,因为你必须始终在类似生产的环境中验证你的测量。
在另一个平台上,你可以做类似的事情。如果你熟悉Linux上的静态键(static keys),那是类似的想法,它在底层使用goto。注意,我们有一个constexpr bool变量,当我们将其设置为true时,实际上不会产生分支,它会改变底层的代码。你可以启动和停止分析器,将跳转指令修补到你的代码中。这样,你可以对关心的热点进行更准确的分析。在多线程环境中如何做到这一点非常有趣。有很多方法,但有一种非常快的方法需要特定的顺序。如果你感兴趣,可以在会后找我讨论。同样,之后放入跳转指令也很重要,这样你就不会在第一时间错过。
总的来说,如果你想查看这些工具,可以运行这个Docker文件并查看所有工具。但我想指出的是,我们在这里做的热点图像并不意味着加速。你可以整体加速,可以优化某个函数,但你可能不会获得应用程序的整体加速。这取决于你如何处理以及用它做什么。这也是我想表达的观点。同样,没有瓶颈并不意味着你最快。这真的取决于你关心什么。
最后,我想指出coz,这是一个用于多线程应用程序的出色分析工具。它的工作原理是消除人为的减速和加速部分,然后测量整体性能及其影响。如果你还没试过,你应该试试。
以上内容我讲得有点快,因为那是非常基础的东西,很容易找到。现在,我们将进入更有趣的部分。
我们将进入微基准测试领域,终于来到我们的微基准测试部分。为什么你想做微基准测试?因为在生产机器上运行迭代速度很慢,版本也很慢。所有这些都使得这个过程很困难,比如角落情况、数据分布覆盖,在生产环境中很难做到。因此,我们想用微基准测试来优化它。
想法是,我们有一段代码,我们只是测量它。我们如何测量以及从中得到什么非常重要。例如,让我们看一个fizzbuzz函数,一个简单的函数,我选择它是有原因的,我们稍后会看到。如果你想了解它的性能,我们可以使用基准测试库来测量某些东西。但很难说如果你得到40纳秒,那是否好,或者它实际上意味着什么,以及如果它比其他版本快,条件是否相同。所有这些都取决于背后的复杂性。
性能测量非常困难,基准测试中的噪声比率也必须考虑在内。
微基准测试的陷阱
让我们谈谈微基准测试的陷阱。我特意命名了其中几个,还有很多。但这是我认为值得关注的几个。
我们有噪声,这相对容易处理。我们有偏差,这是最难的,将是硬件效应。我们有“信仰”,即我们有了数字就相信它们。我们有“混沌”,即我们如何优化它。还有“错觉”,即我们有了微基准测试,一切都快了几倍或几千倍,然后我们应用到生产中,结果变慢了。我不知道是否有人以前见过这种情况。但为了记录,每个人很快都会看到。
所以,只是一个免责声明,因为现在我们将进入更多细节和更硬核的内容。希望你们中至少有些人会喜欢。
我们将专注于x86,因为微基准测试在架构上确实依赖于架构。但其中一些事情,大多数其他事情是通用的。我们还将专注于Linux,因为在性能方面,这是最容易处理的。
你会看到很多我们稍后会使用的快捷方式。我只是试着给你名字。我们可以用RII跟进,我猜听起来你会适合这里。所以你可以看到像CPU供应商有很多用于性能监控单元的快捷方式,这使我们能够更好地理解我们实际在处理什么。
我想指出,CPU有点像系统语言。你可以使用C++来使它们变快,但你能多容易地调整和验证它真的很重要。例如,英特尔在提供工具方面做得很好,特别是在理解性能方面。我特别要提到英特尔处理器追踪(Intel Processor Trace),我们将在这里广泛使用它,因为它对微基准测试非常棒。这在Apple M4上也可用,但从那个角度来看更难获取。
我们利用这个库。这并不那么重要,但只是为了更容易说明,因为我一直在研究它。我们将使用Linux Perf内核API,即perf_event_open。我们将使用IT,即英特尔库来获取英特尔处理器追踪。解码将使用LDM MA,所以所有DMA将在C++中使用。对于绘图,我们将使用plot和XL,这允许我们在普通终端以及TTY上绘图。之后你会移动到硬件机器上。
噪声
噪声从我们进入main函数甚至更早,当我们有全局变量时就开始了。其主要原因在于环境。这是Linux的映射。你可以看到这里有很多移动的部分,不止一个。这不是一件简单的事情。如果你能给它一个数字,请便。但我认为这很困难,这是一件非常复杂的事情。这是偏差的主题。但你可以做一些事情。你关心的噪声将取决于你的设置和应用程序,但一些我们想要避免的常见事情包括,例如,CPU隔离任务,以便将其中一个CPU固定到特定核心,内核不会切换它,这在微基准测试中很重要。我们可以禁用许多功能。你可以使用tuned系统,如果你被要求弄清楚你真正想禁用什么,但我鼓励你这样做。当我们有优先级和亲和性时,你必须为减少噪声而设置它们,除非你在生产环境中运行时有噪声,那可能很难优化。但我从在场的Igma那里得到的最酷的想法之一是使用UEFI。这是一种你可以完全避免噪声的方法。所以如果你真的关心纳秒或周期,你可以获取并完全避开内核,进入UEFI ring 0,所有特权都是你的。你也可以禁用CPU功能,我鼓励你在有生之年做这个实验,以欣赏2025年的CPU比2005年的好多少,如果你禁用缓存或分支预测,并运行一些代码,你肯定会看到并更欣赏它。但如果你关心它,这是一个非常棒的工具,可以深入到非常低的层次。
另一件我认为在开始微基准测试时非常重要的事情(我们稍后会进入更困难的内容)是拥有标准项目。我们想知道,例如,我们在哪里运行,如何运行,并检查我们是否优化了,不要分析调试构建,你不会从中获得太多性能。我想在这里指出的一件事是,我建议记录CPUID,这样我们就可以有这个系列、型号和步进信息,这使你有能力快速在线记录它。除非你在特定的硬件上运行,否则你会得到所有信息,你可以打印出来。
顺便说一下,既然我们在这里使用模块,因为它是C++20及以上。我发现模块非常有趣的一点是,你可以在模块中拥有编译时测试,你可以暴露或不暴露它们,比如你的静态断言和编译时检查。然后,当你通过模块编译时,它将被编译一次,你所有的编译时测试都已经在那里了。所以我认为当你暴露一个库时,这样做非常重要,因为否则,你将处理人们不运行测试的情况。这将进行验证。在这个特定情况下,这非常重要,因为它与你的CPU、硬件和编译器特定相关。
但在我们开始处理噪声和微基准测试之前,另一件要做的事情是进行一些运行时检查。让我们做自检。在左边,你会看到如果机器没有很好地调整,意味着我们没有进行某种噪声减少,我们将得到噪声验证。如果我们不关心那个噪声,因为它不是我们实验的一部分,那将比应有的更多地影响基准测试。你知道,我们的结论将是有噪声的。在右边,你会看到如果你更好地优化它,你最终会有更好的结果。但我们也可以做的是,既然我们有LLVM和其他东西,我们可以验证测量值与文档记录的内容。这将是一个星型曲线的例子,验证基本指令延迟(在这种情况下)与LLVM用于优化的调度模型提供的MCA值具有相同的值。所以你可以想象,不仅div真的很慢,而且你知道为什么。嗯,不是为什么,而是它确实慢。所以避免它,但你会看到它匹配,意味着我们的框架是为性能调整的,如果你想,你可以在欧洲表格和Agner Fog指令表中跟踪这些数字。
另外,我想指出,如果你在周期级别进行微基准测试,有一些工具可以利用,比如LLVM有一个用于延迟测量的工具,它们验证LLVM中拥有的调度模型是否与平台的现实匹配,等等。
所以,好吧,这只是噪声,噪声相对容易处理。让我们谈谈一些更困难的事情,实际上是偏差。
偏差
这里会有很多事情,但目标是拥有可重现的结果,然后我们可以将其应用到生产代码中,并获得实际可靠的结果,意味着它们与我们的边缘情况相关。如果你还没有读过这些论文,我只是在这里链接它们。它们真的很棒,尤其是第一个“生产我们的数据”。它展示了Linux中的一个环境变量如何改变堆栈的对齐方式,并且他们测量到应用程序有30%到300%的减速。所以,但那是当时的错误,现在好一点了,但问题仍然存在,我们必须面对它,因为你不希望在你以完整模式与精简模式运行时测量结果不同,这没有意义。
为什么所有这些都如此复杂?你可能见过Linux。嗯,你见过它。你来过这里,Linux地图。这里也有CPU类型的图表,实际上在底层甚至更复杂。这就是我们运行指令的地方,我们甚至不在CPU上运行指令,我们运行微操作。但所有这些都指出了底层发生的复杂性。所有这些在过去要容易得多。现在要困难得多。我们不能再忽视它了。
关于测量的一些话。它们不是独立的,也不是正态分布的。很难达到正态分布来进行统计。所以我们必须进行很多次运行,我建议你这样做,以便你有样本和迭代,如果你必须更接近测量值,以便你可以对其做一些统计,因为如果你取平均值,而你没有正态分布,那对你来说不会很有价值。我们想回答,这个东西是更快还是更慢某个百分比,并给出一定的置信度。有不同的时钟可以使用,我们将使用时间戳计数器,因为它是允许我们测量较低部分的东西,周期,但经常使用的是来自静态内核的高分辨率时钟,这是最糟糕的想法。因为首先,它试图被弃用。其次,它是一个系统时钟或稳定时钟。稳定时钟没问题,但系统时钟不行。你可能会得到,它就像一个日历时钟。所以它可能允许改变日期,因此你可能会在那里得到负数。所以不要这样做。
让我们看看我们通常没有的正态分布。大多数微基准测试会有偏态分布或双峰分布或任何分布,但很难达到正态分布。总的来说,如果你绘图,我总是建议你这样做。如果你没有正态分布,采取一些其他统计量,然后你可以总体上推理,如果你不能,就不要取平均值。最小值在这里不会给出太多价值。
我刚才指出了噪声,现在我在谈论偏差,这是最重要的,因为硬件效应,我们将在第二部分看看,尽管那只是具体情况。但如果你看到像这里的条形图,而你没有看到这些误差条,我们选择了可变性,这很难。这意味着它可能是一次测量或者是平均值之类的,然后很难推理它是更快还是更慢。因为,你知道,第一个有时可能更慢,有时可能更快。所以如果你真的关心,你必须展示所有这些,而不是仅仅假设它会更好或更差。
延迟与吞吐量
让我们谈谈延迟与吞吐量。大多数基准测试只会做吞吐量,我们稍后会讨论为什么。但有两种不同的测量和两种不同的指标。延迟是单个操作完成所需的时间,而吞吐量是在给定时间量(通常是一秒)内完成的操作总数。所以,当你实际测量时,区分这两者非常重要。因为当你做延迟测量时,你可以通过开始、调用函数、停止来完成。但你通常不会从中得到很高的分辨率。所以在某些情况下有价值,不是所有情况。如果你做一个循环,你实际上必须在运行之间引入数据依赖性以获得顺序性。因为,你知道,否则,有时我们还需要内存栅栏,如果你有写入以获得这种顺序性,我们必须在之后减去开销。但延迟测量非常简单。另一方面,吞吐量就像,如果你有一个循环,这里这个问号发生了什么,是CPU不等我们的事实。因为我们在循环上有一个闭合括号。我会等待。不,CPU会超过你。这有点像性能的关键。性能不是在当前时间获得的,它是在未来获得的。如果CPU在遥远的未来,你就快。否则,你可能不会。所以吞吐量,你知道,你有不同的策略来测量吞吐量,比如这里有顺序策略,当你只有一个循环时,但你也可以进入不同的方法。我还建议你阅读用于nano bench的低开销工具论文。可能不是你听说过的nano bench,而是欧洲稳定版使用的nano bench,他们展示了他们如何通过展开两倍然后只展开一次并以非常有趣的速率减去来测量延迟。如果你对此感兴趣的话。
那么,延迟本身作为一个指标也与吞吐量测量不同,对吧?所以延迟将是每次操作的时间,这是时间。吞吐量将是每秒操作数。你可以通过每秒千兆字节获得吞吐量。你可以获得逆吞吐量,这也是吞吐量的测量,但魔力在于逆吞吐量,在这种情况下与延迟相同,但来自不同的测量。这有区别。
我只是喜欢这张图片。所以我把它放上来展示延迟和吞吐量之间的区别。如果你更关注延迟,你知道,你通常会转向FPGA或ASIC之类的东西。对于吞吐量,通常会转向GPU。而介于两者之间的CPU,是我们今天将重点关注的,但我只是重新点亮了这张图片。
硬件效应
现在,我们将专注于微基准测试和一般代码的硬件效应,这会影响每个人。如果你不考虑这一点,它会影响你,你会错过。例如,未使用的函数可能会因为代码布局而极大地改变你的性能。它甚至没有被使用。它只是一个函数在那里。你把它编译进去,那会改变你的性能特征。它会改变代码布局。你可能在缓存分支上,其中一些。堆栈,它将如何对齐,会改变你的性能。内存访问,显然,分支预测,最重要的。数据布局,所有这些都会改变微基准测试的性能特征。所以如果你有一个微基准测试,而你只是采用了默认的无论什么,很难将其应用到你的生产代码中。这就像一个不同的实验室,不同的事情,效果并不好。
这也不意味着你应该删除那段代码,因为死代码可能让你更快,确实如此。
让我们谈谈像分支预测和缓存,这是最重要的,我们将稍微关注一下。
现代分支预测可以学习像1000、10000个分支之类的东西,我想这是在苹果上。对于直接记录,像静态分支预测器,如果你没有历史记录,向后的分支将被采用,向前的将不被采用。所以这是为了循环。但,你知道,以防有人想知道它是如何工作的。所以这个东西非常复杂。我真的建议你观看一些硬件讲座,了解它在底层是如何工作的。我之后有一些资源。
但这里重要的是,我们有我们的循环,我们会测量相同的参数。嗯,分支预测比我们聪明得多。我们将有10000个元素的历史记录,无论什么。PIP,是指令指针,以及全局的等等很多东西。所以我们必须改变这一点。我们必须有一种输入分布。
让我们看看,那将只是具体情况。所以如果我们弯曲,例如,这个fizzbuzz。我们暂停。编译它。我们有一个auto参数,它将被对齐,或者你可能熟悉像optimized这样的词,这是错误的词。但无论如何,那将是一种不同的行为,你应该传递三个或常量,完全不同。如果你试图处理分支预测,分支预测器。但通过给定像序列范围或不可预测的,这就像均匀分布或具有概率意义的选择。而且,记住如果你有堆,像malloc,所有这些都非常聪明,你必须污染堆,当你推入那些东西时,以避免不现实的场景。
如果你运行它,我们会得到很多数字。呃,这很难看。所以让我们移到图表。这就是为什么,你知道,你想绘图,因为绘图更容易推理。
所以你可以在这里说误差很小,意味着我们能够在没有噪声的情况下在一定程度上重现它。
条形图将向我们展示。你知道,越低越好。但这里有点重要的是,范围,即右边的第三个绿色条,是如果你有循环迭代,你只是将迭代的i放入你的基准测试中,这经常做。所以我们做循环,我们将迭代传递给循环。你知道,我们改变参数。我们改变数据。很好。如果我们的代码中有分支,那将被预测。很可能,我在这里放了一百万。
所以你可以看到配置文件中的差异。也有点有趣的是,均匀分布,这在现实世界中很难出现,但要慢得多,但更慢的是如果你有不可预测的最坏情况,通常,因为我们必须开发更多。所以,有些事情要。就像,如果有的话,我只是为微基准测试做这个,因为那会改变结果。非常多。
数据分布与可视化
当我们进行微基准测试时,我们想要关注的另一件事是显示像直方图这样的东西,这样我们可以看到数据分布,因为它会是偏斜的,那会给我们更好的理解,使用什么统计量。有不同的,像箱线图。我们可以显示我们的异常值。以及集中趋势和可变性的趋势。
但我想展示最重要的且不经常使用的经验累积分布函数。因为它显示了一切。没有移动,没有像那样的桶。所有点都对结果有贡献。所以你可以轻松比较所有测量值。你不得不做的所有样本,看看它如何影响性能,所以真的鼓励使用这些,它们显示累积数据比例。
顺便说一下,如果你。因为所有这些都是在终端上。所以那不是UI或任何东西。那是终端和终端模拟器。如果你感兴趣,XL,它被所有终端支持。像Windows、Mac只是一种你打印的格式。当控制台终端将显示一个像素和tellaada。所以这很酷。但通常当你,你知道,移动到你的生产机器和类似的东西时,你想使用像TTI这样的东西,你也可以这样做。那是相同的图表。只是看起来不同。而且,如果你熟悉Jupyter笔记本,那也是进行分析的好选择。
代码布局
让我们看看第二个。这是代码布局。
我指出代码布局将影响一切的性能。所以你编译,你改变任何东西,我之前说过,如果你改变分析并最终编译,那会改变你的分析结果。我的性能,对吧。所以要注意这一点。
所以,我们可以从应用层面做的事情,我们可以启用地址空间布局随机化。但那是每次运行固定的。这是一个安全功能。但我们可以利用堆将被随机化的事实。代码本身也将被随机化。并且有像Clang和Mold支持布局随机化,我们可以打乱函数顺序。看看那如何影响它。那将是随机的。所以你必须重新编译,重新编译,并多次进行。有最好,不是维护的动物,但一个很棒的工具稳定器,它使用LLVM在底层在运行时随机化这些东西,每秒钟一次。
所以那也很酷。然而,你知道,你必须尝试很多长时间运行的事情来获得正态分布的结果,以及它如何影响分支和其他事情,也很困难。
所以我们可以从基准测试的角度来做。我们有这个函数1。我们可以做,你,不同的基准测试。我们在上面放一些具体的东西。我们可以做不同的值分布。所以,例如,你可以对齐循环,因为如果你不对齐。基本块。嗯,编译器会为你选择对齐方式,这可能不是你想要的。它会在不同的上下文中选择,比如你的应用上下文。你知道,函数边界。这也取决于你的循环有多大,是否会影响堆栈大小、堆栈对齐、函数对齐。函数对齐是16字节。但设计,例如,其他编译器不是。而且你也必须在新的进程中运行,以稍微领先于自己。
所以你必须这样做。然后我们将进入缓存。那甚至更难做。
我不鼓励你像查看你的硬件是什么。所以lscpu就像在Linux上,你可以看到,你知道,L3在大多数情况下是共享的。所以以及你有多少个NUMA节点等等。但那允许你。然后,例如,如果你有L3在不同核心之间共享,你可以,你知道,从不同的核心破坏它。你知道,你使用虚假共享来基准测试。你应该读一下,顺便说一下,这里描述的论文,如果你还没有。
但我一直在实验并且对我来说效果很好的,我们稍后会讨论如何做。所以我一直在做类似这样的事情。运行。已经分布了,你知道,正如我们指出的,分支预测等等。收集执行的指令、地址、基于指令指针的指令。然后我们做我们的循环,我们实际上设置分支预测。并根据我们的分析结果刷新缓存。所以我们有结果。我们知道有一个查找表,那是地址。当我们有想要测量的分布时,我们想测量它是否是10%满,50%,70%,以及相同的分支预测,因为很难获取所有分支。从外部。所以你必须从底层开始。这很困难,而且真的很hacky。但它有效。如果你有L3,你可以从不同的核心来做。所以了解你的硬件。当你必须时。你不能在这里做循环。你有时可能会变得烦人,因为如果你做循环。你知道,缓存和分支预测学习得非常快,你可能会失去你可能的所有设置。你必须使用像快速的东西来想象周期。
所有这些,我不想吓唬你,还有很多,你可以更高或更低,你可以进入像执行端口,那也会影响,例如,将选择哪个ALU,比如如果你有围绕它的代码并且它已经被使用,在微基准测试中,那将是不同的结果。所以这里有很多事情必须考虑,以便实际上以一定的概率将结果映射到代码。这正在发生。这并不容易。没有银弹。需要很多工作。但当你做对时,会有很多满足感。
信仰与验证
“信仰”是我们现在要看的东西,意思是,你知道,你有了计时,我们做了偏差,我们做了噪声。现在我们有了计时。我们自我感觉良好,因为这个更快,那个更慢。我们去发布它。嗯,现在就像不理解为什么,它并不是真的那么有价值,因为你无论如何都会错过一些偏差。所以我们必须经历这些。很多这些工具,因为Matt在这里,所以你可以在那个Godbolt Compiler Explorer上查看。但我想指出,你知道,有时当你做基准测试时,你只分析这一个基准测试,对吧。那会影响结果吗?那会有偏差吗?有人会问。是的,它会。它可能会改变你的结果。所以那将涉及,例如,环境变量和堆栈对齐和大小。所以要注意这一点。所以我们可以做的是,我们必须做一些分析。为此,我们有能力进行分析。我们可以计数。在简单的C++中,我们想计数周期。所以这里将是。如果我们想要周期、指令和时间戳计数器,我们将使用CPU提供的指令来完成。所以R PMU和RTC是我们想要使用的东西。为了无周期地拥有更好的测量。
为了进入周期世界。我们也可以进行采样。我们必须有一些硬件支持。那将给我们每个指令指针的结果。然后我们可以更多地推理它,通过我稍后将展示的分析。
但你必须真正意识到,有时你必须进行很多测量,尤其是当你进行分析时,因为存在多路复用,因为只有8个硬件计数器可以在CPU中使用。追踪,我最喜欢的微基准测试。我们只是,你知道,我们只是记录被执行的指令。然后推理。
所以这也是你可以获得周期的东西。周期的准确性将取决于你的硬件,但你想尽可能接近它。
然后我们有了分析器,我们做开始停止在这里,像不是这个防止所有。是一样的。像,在我看来,命名的正确方式,不要优化,这很令人困惑,因为很多人在西班牙认为它是防止优化。现在,它是防止消除编译。所以我们记录。我们得到一些很酷的数字。很好。嗯,现在,我们必须更进一步。那是我们去MCA的地方,例如,当我们必须反汇编那些东西时。
那是我们可以利用LLVM提供的东西。所以在这里,我在一开始指出了调度模型,你可以在LLVM项目中找到它,并查看他们验证的所有这些数字,它们对你的设置是否正确。
这里我们必须引入,因为我们在简单的C++中,我们没有函数的大小。我们必须做一些魔术。我们可以做标签。我把它放出来。作为和转到。我们将再次利用它,因为它防止重新排序,我们可以获取代码的标签。它不发出指令。它有时禁止一些优化,但不发出指令。所以代码在底层是相同的。对于MCA,我们不必运行它。你可以只取指针。并获取区域。那可能很小,但这不是重点。重点是我们可以根据此进行大量分析。所以这里我们将专注于注释,即指令指针。所以我们可以做什么。呃,你知道,有时。有些人可能认为,更少的汇编指令更好。那并不意味着什么,汇编指令有不同的延迟。它们执行不同数量的微操作。它们有不同的吞吐量,所有这些我们都可以从你的机器的MCA中获得,如果你有一个好的调度模型,你可以验证。而且,也许不那么重要,但我喜欢指出,像ci bear risk。像,这不是关于指令数量,因为它们都有大量的指令。更多的是关于编码部分,而co arm将拥有你静态的一个,x86有。可变长度。两者都不意味着它更快或更慢。只是不同。
我们可以看到资源压力,所以,例如。编译器上有很多执行端口,你可以在这个图中看到。还有很多Ls。你想看看哪些部分被分配和使用在你的代码中,以理解为什么。
你可以做更多的事情。例如,你有像cant这样的代码,你可以做这个吞吐量模拟,你有这个代码的汇编,你循环多次。你会看到时间。我们真的想避免相等。你可以阅读MCA时间线。那就像每个周期,你的CPU是如何执行的。我想指出的一件事是这个汇编,你可以很容易地从Compiler Explorer中获得。和追踪,你目前不能,因为你必须用特定的值围绕你的代码运行。
所以这里将是开始结束区域。我们将有fi指令和一些时间线。在右边,我们有追踪,根据这个函数的输入参数,将有不同的结果和不同的指令被执行。这里非常酷的是,我们可以,你知道,抓取已执行的代码,通过管道传输到时间线,并查看在CPU上如何,你知道,基于MCA cadjoi模型搜索完整性。那给你很多更好的理解,为什么以及如何在低层次上发生事情。
然后我们将其应用到我们的基准测试中。对于这种情况下的延迟。如果我们做区域,我们会看到。像,我们运行了fizzbuzz,参数为15、5和不可预测,这意味着我们随机化输入。如果你获取汇编,那将是相同的。但如果你获取追踪,即已执行的指令,这里讲述的是不同的故事。所以15,因为如果你看fizzbuzz,那是第一部分,我们可以不关心其余部分,因为我们返回。所以这是不同的,对吧?也很酷的是,我们可以比较时间线。你应该看看那个。很长时间,你会从那样的图表中看到很多,我鼓励。你可以做很多其他事情。比如你可以看到加载来自哪里以及分支预测。所以。像15没有错过任何东西,像感觉15,和一样。这是基于频率的。而且,5没有错过任何东西,对吧。而不可预测的会错过。因为我们处理分支预测。你可以做流程图,流程图也可以看,但火焰图。你可以使用LBR来获取它们。然后你知道,关于火焰图的一件重要事情是它们没有时间,只是出现次数。所以要注意这一点。
混沌与优化
现在我们必须经历混沌。这是重要的部分,当你,你知道,我们做了计时,我们有了计时,当我们做了分析时,所以我们更有信心知道为什么。但你知道,我们如何,我们如何优化所有这些事情。像,你知道,随机游走会使我们陷入局部最小值和想法,以及2年的经验知道该做什么。现代CPU通常受限于,你知道,如果你提供所有指令,那会很好,但可能不可能。所以我们可以做的是,我们可以减少总体指令数。增加每周期指令数或每周期微操作数。而且。此外,我们可以应用自上而下的微架构分析,我们不必思考它,那可能意味着我们不必提出假设。但如果,我会改变那个,以及那如何,你知道,这是随机的。这不会经常引导你到正确的位置。我的,但你永远不会知道。而且这就像如果你试图优化每周期指令数,比如每周期微操作数,尤其是你知道何时停止,因为你永远不会超过调度模型中的调度端口,对于其他常规是6,如果你有每周期微操作数最大为6。不会比那更快。
让我们看看自上而下的微架构分析。所以我们必须有一个目标。所以这是来自英特尔,英特尔说。基于你是什么类型的应用程序,那是你瞄准的大致数字。你可以,显然,说。自己设计。但想法是,我们在这个图中覆盖整个CPU,并围绕它进行分析。所以首先,有像第一级,我们是否正在退休指令,或者我们有未命中,或者我们是前端或后端受限。我们这样做。然后我们做第二轮。所以,例如,我们有后端受限。我们看到它要么是核心受限,要么是内存受限,我们有,你知道,计数。然后我们更深入。假设我们是内存受限。并且是L2受限。所以我们知道要优化什么,以及什么会对优化产生影响。但所有这些都没有意义,如果你想给你结果,如果你有偏差或噪声。
所以你必须拥有所有这些,以便最终实际重现结果。
所以这就是我们可以做到的方式。不那么重要。但我们在这里可以看到,只是指出,如果你有这个序列或可预测性很好的东西,嗯,我们不会是。我的分支预测受限,这很糟糕,因为我们是,但那不会显示出来。所以你可以使用这种分析方法,它是结构化的,给你的工作带来理智。但你必须首先消除偏差和噪声。然后如果你有了那个,就很容易。像所有这些都是有文档记录的。如果你有退休,你可以做什么,应用什么,然后你可以学习一个不会种子。如果你有错误推测,我可以,你知道,观看feed讲座,并像无分支,或者如果你前端受限,你可以,你知道,去TNP,无论什么。你明白了。
验证与测试
所以,最后。我们完成了所有这些。所以我们有了分析,我们喜欢。我们减少了噪声。我们得到了计时。我们理解了为什么我们从混沌的角度正确地做了它。所以我们有了理解。然后。我们还必须应用措施,以确保它将进入生产。所以,例如。我们必须非常像,如果你运行得快,就没有意义。所以例如,如果你在这种情况下开始。你想指定验证所有那些。在你排序之后被排序,对吧,那在基准测试之外很难做到,因为你不再有这个目标了。所以我们可以在像一些基准测试QA、PA中做到。但问题是你不会影响基准测试。所以你会得到那个。
我最喜欢所有这些的是测试。如果你有所有这些工具,我们实际上可以应用,你知道,测试,这将给我们关于我们可能关心的事情的答案,所以我们可以反汇编。我们可以追踪,我们可以分析,我们将利用我们在这里展示的工具。不那么重要如何。你可以在会后问我。所以,例如。你可以测试一个fizzbuzz函数的反汇编,它有19条指令。以及这些指令是什么,如果你真的关心某些东西是相同的。你可以这样做。这不是理想的方式,压力测试事情,但有时,尤其是如果你有带vs的东西或类似的东西,你可能想验证事情的顺序,它实际上以你想要的方式发生。你可以追踪并打开它。这与这个汇编不同。所以如果你依赖获取fizz,参数为15,我们将得到7条指令。所以我们可以验证这一点,并验证一切是否合理。最终,只是测试它。我最喜欢的是,如果我们有一个函数。和另一个函数。我们验证了C++给了我们优化的方式,所以我们可以比较反汇编。或者我们可以为特定参数编译追踪。在这种情况下,这里有UB,那将被优化为基本上返回true。但当你能够,你,你可以,你可以想象你能做什么。那有很多潜力去理解和确保,在未来,它将是你假设的那样。如果不是,嗯,它会失败,知道这一点也很重要。
我最喜欢的是分析。所以正如我指出的,我们有这个时间线,所以我们可以,你知道,运行,那将来自那里。这里有很多方面。所以我们运行了fizzbuzz,参数为Red 15。我们记录了这个模拟运行的指令。将有7条指令。然后我们可以验证每个周期,在低层次上,事情是如何被调度的。我发现这非常有用和酷。然后,所有这些中最重要的部分是,所以我们做了所有这些工作。我们试图减少噪声偏差,我们的测量,从统计上来说。我们用它运行分析,这不会再次改变偏差。我们的基准测试点更慢,但应用程序更快。我们做什么。在这种情况下我们做什么,发布它。它更快,对吧。嗯,不,我们有,意味着我们做错了什么。我们,相关性非常重要。这就是为什么,你知道,这辆F1赛车对应于风洞,你知道,如果你没有风洞和赛道之间的相关性。嗯,你,你的优化之后没有意义。如果你在微基准测试上没有相关性,对那个特定原因来说没有那么多价值。如果你关心将它们应用到未来的优化。所以你做什么,你回去。验证为什么,可能是噪声。可能是偏差。有很多你可能看不到的偏差。当你试图关联它时,如果它关联。然后你应用它。
总结
因为时间不多了。总是测量,但你知道,仅仅测量,你知道,循环并除以迭代次数,可能不会让你被解雇。但没什么,但也许不会。
我们想避免微基准测试的陷阱。所以有噪声。所以我们想调整它。有偏差。必须有建模,如何或如何接近它。CPU如此复杂,操作系统也是如此,这很困难。这不是一个容易的任务。
在那之后我们必须有信仰,我们测量的东西有意义以及为什么。因为如果你测量某些东西,而它不。你不理解为什么。像,它没有价值。
理想情况下,我们避免这种混沌。我会优化那个。我会优化那个。让我们只是学习硬件,以结构化的方式接近它。并进行。然后验证,像其中最重要的部分。你完成了所有这些。我们最终验证。然后它关联,它改进了,你知道,生产中的性能。我们有了框架,我们可以处理,快速实验。快进它。所有这些都很棒。
进一步阅读
正如我指出的,有些人更关心另一个秒,当其他人,HFT通常是Kmar。在这里,我只是想列出进一步阅读,因为我只是触及了表面。还有很多。我只是指出了我认为最重要的最常见的几件事,但还有更多。所以我真的鼓励你阅读手册,一整天。但如果你写阅读C++标准。没有太大不同。那更好读。
我收集了这个网站上的所有资源。如果你想看看。

就这样,谢谢,如果。

课程总结


在本节课中,我们一起学习了C++性能测量的核心挑战与最佳实践。我们探讨了微基准测试中常见的噪声、偏差、硬件效应等陷阱,并学习了如何通过控制环境、理解延迟与吞吐量的区别、分析代码布局与缓存行为来编写可靠的基准测试。我们还介绍了使用perf、处理器追踪、MCA分析等工具进行深入性能剖析的方法。最重要的是,我们认识到测量本身必须可重现、可理解,并且最终要与生产环境的性能提升相关联。记住,没有单一的银弹,性能优化是一个需要严谨方法、持续验证和深入理解的系统工程。
076:C++26 契约的乐趣——神话、误解与防御性编程





在本节课中,我们将学习 C++26 中引入的契约功能。我们将探讨其动机、核心设计、最佳实践以及一些常见问题与开放性问题。契约旨在帮助开发者更清晰地表达代码意图,并在开发早期捕获程序错误,是防御性编程的重要工具。
概述
许多有趣的讨论发生在走廊里。当你离开一个会议后,你们刚刚共同经历了一次集体体验,因此会引发一场走廊对话,讨论会议材料。
欢迎来到这个我直到一周前才决定要做的演讲。实际上,我从未做过关于契约的演讲。我只是在几张幻灯片中提到过它。我算是后来者。这个房间里有很多人比我更了解契约。我会尽力而为,如有错误,责任在我。同时,我也想感谢所有为契约功能做出贡献的人。这项工作从 C++20 之前就开始了。如果你曾撰写过论文、写过一行实现代码、参与过邮件列表讨论,或参加过 EWG 或 SG21 的讨论,如果你以任何方式为契约功能做出过贡献,请现在起立。让我们为他们鼓掌。这只是众多辛勤工作者的一个子集。

当前的状态是,契约功能已进入 C++26 草案。委员会专家在二月份就此达成了共识。但仍存在一些问题。在 C++26 最终定稿之前,我们还有两次技术完善会议。我们将在十一月的下一次会议上再次讨论契约,包括是修复它还是推迟它。目前草案中的内容就是我将要描述的。但所有关切将继续得到充分听取。我只是想准确地反映当前状态。本次演讲总结了我的最佳理解,但我可能出错,而且经常出错。出错是好事,只要你乐于接受错误。因此,你也应将这里的所有答案视为临时的,取决于未来可能发生的变化。但让我描述一下我从与有关切的人以及为此功能辛勤工作的人交谈中所了解的情况。
我将首先让自己成为一个靶子。因为很多人对契约感兴趣,但有两个群体对此有非常强烈的意见。许多专家坚信 C++26 契约功能非常出色且已准备就绪,而另一些人则认为它们尚未准备好,不应现在发布。你们中有多少人认为我可以用两句话同时冒犯这两个群体?来吧,多一点信心。C++26 契约存在开放性问题,且尚未获得广泛的部署经验。这已经冒犯了一些人。我们很可能无法获得比 C++26 契约更出色的设计,除非寄希望于神奇的链接器改进。我现在又冒犯了另一些人。让我们开始吧。
我们将讨论动机。为什么几乎每个人都同意某种形式的契约是个好主意?然后我们将讨论当前的设计、我们正在开始学习和探索的最佳实践、一些设计原理以及一些常见问题。但让我们从安全性开始。是的,契约是关于安全性的,但它们不是关于类型和内存安全性的。幻灯片底部,类型和内存安全性是关于语言可以提供的保证,例如每个空指针解引用都会被捕获。如果我们有这样的规则,那就是语言保证某类内存安全错误不会发生。你不需要编写特殊代码。这只是你获得的保证,这包括静态分析工具的保证,如果你的代码未通过静态分析,则无法签入。因此,构建时规则提供保证。这是幻灯片的底部。

契约在幻灯片的顶部。它们是关于功能安全性的。它们不直接关乎“我是否发生了缓冲区溢出”。它们关乎“我的系统是否满足其要求”、“我的汽车是否在应该加速时加速,尤其是在应该停止时停止”、“我的医疗设备(如起搏器)是否正常工作”、“它是否保持节律”、“它是否及时响应实时消息”等我们想用断言来检查的事情。在那里,我们基本上有一个可以调节的旋钮,因为我们编写的断言越多,代码的覆盖率就越高,但这并不是一个保证。这是一个需要付出更多努力才能获得更多成果的机制,无论是你编写的断言还是库中的断言。因此,功能安全性与内存安全性不同。
重叠之处可能在于,如果未来我们继续保留 C++26 契约,并且我们将来制定语言保证(就像我们为强化标准库所做的那样),这些保证是用契约检查来表达的。因此,如果空指针解引用是一个契约检查,即 contract_assert(not null),那么这里就有重叠。但你获得内存安全性并不是因为它是契约,这是一个实现细节。这是因为语言功能恰好使用了契约功能。这是一种使用关系。所以,只是为了区分这两者。在本演讲的其余部分,我不会讨论安全性,这只是为了划清界限。
动机:为什么需要防御性编程?
假设你在进行代码审查。你看到这个函数。有什么可以改进的?你会提出什么评论?大声说出来。使用引用。好的,假设你打算使用指针。关于函数体我们还能说什么?检查空指针。谢谢 Oleg。是的,你会注意到这里有一个对指针的无条件解引用。因此,你可以立即洞察这段代码作者的想法。你知道,两种情况之一是真的:要么作者知道这一点,并且他们认为所有可能流向此函数的代码路径在结构上永远不可能是空指针,所以他们不检查;要么他们忘记了。我们都经历过这两种情况,对吧?阅读这段代码时,你怎么知道?你不知道。但如果你多写一行代码,现在你就知道了。你表达了你的意图。如果你看过我的任何一次演讲,你会厌倦我说我们希望人们直接表达他们的意图。现在,不仅将来阅读代码的人(可能是一年后的我们自己)能理解所做的假设,而且工具也能为我们检查它,即使是简单的 cassert。
我们很久以前就有这个建议了。当 Andre 和我合著《C++ 编码标准》一书时,我们进行了大量合作。我们各自编写了不同的条款,然后一起编辑。Andre 写了这一条。Andre,你在房间里吗?如果在,请挥挥手。哦,是的,他在那里。好的,Andre。谢谢你写了这个,因为在你告诉我之前,我就知道 cassert 了,我从 80 年代就知道了,对吧?我们学到的一件事是,我并没有意识到系统地使用它有多么重要,因为没有人教过我,我也从未在一个有这种文化的团队工作过。我们都会有盲点。
我强调了几点:断言必须始终为真,否则就是编程错误。所以它是关于程序错误的。永远不要在表达式中写入有副作用的代码。我们稍后会讨论这一点。但让我读一下底部的引用。我想让你看看这本书多么有用,因为我相当确定这段文字是 Andre 写的,因为它带有一种教授式的口吻。这不是坏事,只是风格不同。因为当你开始信奉某种理念并开始告诉别人“嘿,你应该多写断言”,无论是 C 断言、自制版本还是契约或其他东西时,你会遇到的一种阻力是,你会遇到有人说:“听着,我编程 X 年了,我在这里或那里用过断言。如果你告诉我应该在我非常熟悉的这个我最喜欢的函数里写断言,那是在浪费我的时间,因为我知道你让我写的这个断言永远不会失败。就像,那是一个愚蠢的断言,因为它不可能失败。” 好吧,你会听到这种说法。Andre 的回应是(我不会模仿罗马尼亚口音):“根据信息论,事件的信息量与其发生的可能性成反比。因此,某个断言触发的可能性越小,当它触发时带给你的信息就越多。”
你看到这个柔道技巧了吗?这个技巧在于,那个刚刚告诉你为什么这个断言是愚蠢的、因为它永远不会触发的人,恰恰给出了一个无可辩驳的理由,说明这正是他们绝对应该写的断言。因为如果它真的触发了,那将是他们写过的最有价值的断言。此外,那个聪明人甚至可能是对的,那个断言在今天可能永远不会触发。但如果代码成功了,它会被维护。我们知道当我们编写代码(包括在维护期间)时会发生什么:我们总会引入错误。这是永远正确的。所以,我从未……直到这一次,我实际上给 C++ 委员会发了一个 YouTube 视频。我确实给委员会发了一个 YouTube 短视频,因为我认为了解社区如何看待断言的价值很重要,特别是在这个视频中。有一天,我碰巧在浏览 YouTube 短视频,就像人们常做的那样。这个例子出现了,而且这甚至不是 C++,程序员正在编写 TypeScript,他们使用的是 Node.js 断言。请注意,这个视频到目前为止获得了近 400 万次观看。
我想让你自己看看这个视频。让我们来听听我们的“客户”怎么说。“在我的程序中,我……让我从头开始。在编程 20 年后,我刚刚改变了我的编程方式。这叫做负空间编程。让我们看看这个名为 Morfo 的函数。它期望 food.bar 在程序的这一点上是一个数字。我知道 food.bar 实际上是一个数字,尽管它的定义说 bar 可能不是数字。因此,我将断言这种行为存在。我实际上是在对我的程序中的不变量进行编程,以保证行为。当我的程序崩溃时,我就知道要么我对世界的看法是错误的,要么我有一个需要修复的错误。我无法告诉你这让我的生活以及我构建的程序质量提高了多少。在编程 20 年后,我刚刚改变了我的编程方式。” 这是一个满意的客户。这就是我们希望契约功能能够做到的。然而,我们可能会在这里犯一个错误。很容易会说:“哦,我不知道那是谁。也许是个自学成才的运动员程序员,现在成了 YouTube 网红。哦,我甚至不想看他写的代码。20 年了,拜托,我是计算机科学毕业生,你知道的。20 年了,他应该更懂。” 我刚刚告诉过你,我花了 20 年时间,因为没有人教过我。我就是他。直到职业生涯中期,我才知道要系统地使用断言。所以,让我们看看这位满意的客户,但也欢迎所有正在学习的人。这是一件好事。
C++26 契约设计概览
我们讨论了动机,为什么需要防御性编程。现在让我们看看 C++26 当前的设计。我询问了某个 AI,也许我会使用手持设备。给出一个示例,展示 C++ 函数应该以三种不同方式使用契约。我这样做的原因是我决定自己编写一个示例,但后来我想,也许我可以加速这个过程。所以我让 ChatGPT 来做这件事。这是 ChatGPT 给出的结果。有人发现错误了吗?它无法编译。这是我们取笑我们最喜欢的 AI 的日子。一次机会。两次机会,如果你看到了就喊出来。没有 some 的声明。不过,ChatGPT 尝试了。在右上角,你会看到我还做了第二个提示。那个提示是:“给我看看 ChatGPT 的 logo,加上有趣的小红魔鬼角。” 它做得相当不错。
所以我说,嗯,这是个好的开始,但让我修复这个函数。我们稍微降低一下难度。修复这个函数。这是在 C++26 契约中类似函数的样子。我们可以看到,我们可以将一些断言从函数体移到接口中。因此,前置条件现在可以提前声明。这样做的好处之一是它对调用者可见。它在声明中,不仅对人类调用者,对静态分析工具来说也更容易处理。本周晚些时候有一个关于这个的演讲。后置条件,如果后置条件错误,那就是这个函数的错误。现在也在其声明中说明。然后我们在函数体中有一个 contract_assert,它本质上是在检查一个前置条件(在这种情况下,因为它正在检查输入),但它是在处理输入时进行检查的,所以我们没有第二次检查的开销。这只是让你了解一下可以用契约完成的事情:前置条件、后置条件和契约断言。为什么不是拼写为 assert?历史原因。
契约有四种处理方式,称为语义:忽略、观察、强制和快速强制。这张幻灯片展示了它们的作用,你会注意到它们恰好处理了右边两列的每种组合。我们最初只有前三种,然后苹果指出我们需要第四种,所以我们添加了快速强制。一个好处是,对于前两种列的组合(我们不调用隔离处理程序且不终止),我们甚至不计算谓词。因此,如果我们处于忽略模式,成本为零。其中两种可能很熟悉,因为忽略和强制是我们习惯的,比如 cassert。最后,我们还有一种处理契约违规的方式。这是一个全局函数,你可以编写并替换它,就像你处理 new_handler 一样。这让你可以做到,如果发生契约违规,并且你处于会调用违规处理程序的模式(观察或强制),那么你可以获取相关信息,通常包括源代码位置、前置条件的实现质量指导(给出函数调用方的源代码行,这很有用,因为如果调用方未能满足前置条件,错误就在他们那里)、注释(可能是谓词的漂亮打印版本)以及其他类似信息。像所有全局替换函数一样,它在链接时提供,所以你为整个程序编写一次,不能在运行时更改它,否则会有安全问题。顺便说一下,你会注意到我为此使用的图标旨在具有普适性。它既是英国的也是美国的,因为英美分裂可以追溯到……不,不,那是另一个分裂。我们有过不同的紧急号码呼叫方式。999 是最古老的,1937 年在伦敦。我展示的是一个按键式电话,但这有点时代错位。因为如果你在火灾中看不见(黑暗或有烟雾),使用转盘电话,你可以很容易地感觉到哪个数字在指挡旁边,你可以在烟雾弥漫的房间里通过触摸找到它。是的,9 更长。为什么不直接用 111?因为在看不见的情况下更容易找到 9。明白了吗?所以这很重要。美国版本是蝙蝠信号。这就是我们今天在美国各地使用的方式。顺便说一下,我说,不要成为 Thomas Duffy,因为在 999 设立仅仅几天后,Thomas Duffy 就被报告并因入室盗窃被捕。所以它立即开始发挥作用。一旦你开始提供报告违规的方式,好的报告就会进来。
最佳实践初探
我们讨论了前置条件、后置条件、契约断言、处理契约违规以及四种语义。现在,我们很希望 Scott Myers 来写一本《Effective C++ 契约》。所以有好消息也有坏消息。坏消息是,嗯,他退休了。我试过,我真的试过邀请他来这次会议,但他就是不肯接受辩论。他仍然非常享受退休生活。好消息是,我们当然会学到更多。但到目前为止,我只知道少数几条。所以目前这实际上是一本非常短的书。以下是我目前所知道的。在我讲到第 0 条之前(因为我们从 0 开始),请明确区分程序错误和运行时错误。我以向量下标操作为例。我下标一个向量索引来访问索引元素。这是一个错误还是用户错误?嗯,这取决于谁对索引的值负责。如果索引是你在程序中代码计算出来的,并且它越界了,我很抱歉,你有一个错误。去修复你的错误,就像用户无能为力一样。你给了他们一个损坏的程序。所以去修复这个错误。契约断言、前置条件或后置条件是这项工作的合适工具。向调用者报告错误,例如从函数返回错误代码,或者抛出异常(通常如此,尽管我们稍后会讨论异常),对于正常的错误报告来说并不合适,因为根据定义,程序处于你编写程序时从未预料到的状态。它处于一个你未预期处理的状态。如果那个索引错了,很可能其他东西也已经错了。话虽如此,有时我们也可以做一些恢复。然而,在右侧,如果索引是从用户输入读取的(他们在控制台输入索引,或者你从数据文件或网络流中读取),并且它越界了,用户需要去修复他们的数据,修复他们的输入。这是一个运行时错误,我们想用 if 语句和错误处理逻辑来检查它。
所以底线是:断言不用于运行时输入验证。现在我们可以稍微概括一下。所以第 0 条(这些都是草案,我保留改进这些或认定我错了的权利,但这是我这一条的第一个草案):永远不要将契约或断言用于程序逻辑。一个例子是使用契约进行运行时输入验证,这不是它的用途。另一个是依赖副作用等正常程序行为来使用契约。这不是它的用途。我喜欢 Lisa Lipencott 的这句话。谢谢你,Timour,几个小时前提醒我这一点。这是她去年在 Cppcon 上的一次演讲中说的。所以我很感激这个提醒。Lisa 说:“关于断言,首先要了解的是,如果你写的东西不是冗余的,那么断言就是冗余的。不要使用断言。” 这是信息论的一个基本事实。我们在这里谈论的是程序错误,而不是运行时错误。没有冗余就没有错误检测,这就是 CRC 的用途,也是弹性数据库中分片和复制的用途。你想要错误检测,就意味着你想要冗余。这是固有的情况。所以第 1 条实际上是第 0 条的一个推论(或者推论,取决于你的说法)。不要在契约检查中产生副作用。这对于任何地方的任何契约都是正确的。可能是 cassert,可能是 C++26 契约,也可能是其他语言的其他机制。在 cassert 中,为什么不?因为谓词可能被计算,也可能不被计算,可能执行,也可能不执行。在 C++26 契约中,答案是因为它可能执行 0 次或多次,取决于情况。好消息是,即使你认为这有争议,我认为你会看这个幻灯片上的例子。很好,这实际上是一个编译时错误。之所以是编译时错误,是因为关于契约激烈辩论的一件事是,当你引用一个变量时,它被隐式地视为 const。具体来说,这是为了让你更难写出副作用。相信我,你仍然可以写出副作用。我们知道你们 C++ 程序员,我们知道如何做事,对吧?即使没有 const_cast。但许多问题可以被捕获,包括这个,这将是一个编译时错误,这比运行时错误要好得多。
第 2 条:避免拆分复合条件。对我来说,这实际上归结为一个更简单的事情,虽然并不总是适用,但在我见过的许多例子中都是如此。如果你想要短路求值,你必须键入双与符号 &&。这在语言中到处都是。我们有很多情况,人们说:“哦,我没有得到短路求值。” 但他们没有写 &&。我的意思是,事情就是这样。你需要内置的 && 或 || 来获得短路求值。所以,如果你有第一个契约条件 p != nullptr && *p,你保证永远不会解引用 p,因为短路求值。如果 p 是空指针,你永远不会到达谓词的第二部分。是的。如果你像坏例子中那样拆分它们,并且你正在运行观察语义(这意味着我们检查谓词,如果为假则调用违规处理程序,但然后我们继续,我们不终止,我们只是观察),那么如果 p 是空指针,你可能会在第二个谓词检查中得到未定义行为。但是,即使在观察模式下,第一个检查仍然会被检查。你的违规处理程序会被调用。所以当你的程序崩溃时,你会得到确凿的证据,说明你违反了有效契约第 2 条。它甚至可能说类似的话。如果没有,你会记得这次演讲中的这个例子。希望如此。它会告诉你你做错了什么。
第 3 条是:使用抛出异常的违规处理程序是否有意义?这合理吗?所以,第一个合理的情况不是契约的正常使用。你不是在检查程序的事情,而是你正在测试契约本身。你处于一个测试框架中,契约也是代码,所以你可能想测试它们。我通常不这样做,但许多注重良好卫生的团队会这样做。他们说:“我想确保我的断言在应该触发时触发,如果我犯了错误。” 所以他们测试它们。如果你通过故意违反断言来测试它们,然后终止你的程序,再启动一个新程序,去测试第二个断言,并进行负面测试,可能会有开销,而且只是重启,你基本上每次执行只能检查一个断言。如果你抛出异常,并假设你进行了清理,这样你就不会处于损坏状态(眨眼),那么你可以继续用每个异常测试更多这些,而无需多次加载可执行文件。所以这就是想法。这是为了优化测试契约本身。
但现在让我们回到契约的常规用途。确实存在一些情况,终止是不可接受的。我通常发现,虽然这不总是真的,但当我与处于这种情况下的人交谈时,无论是自主航天器还是自主陆地车辆的开发者,还是操作系统开发者,他们要么想要异常,要么想要终止。如果他们想要终止,他们希望是针对一个可以重启的子系统。如果你在一个完整的 C++ 程序中终止,整个程序就消失了。如果你抛出异常,那么你可以使用正常的栈展开进行一些清理。你处于损坏状态。让我们明确一点:那个子组件失败了。这不是轮胎气压不足或轮胎爆了,你现在试图回去,取下轮胎,换上一个新的,重启子组件。所以这就是在观众中看到一些人点头的原因,你们中的一些人这样做,但只是针对程序的一部分。这是一个完全可以接受的(请原谅《辛普森一家》的引用)抛出异常的违规处理程序的方式。但要小心,顺便说一下,正如我在底部提到的,这有先例。Josh Byne 是谁?你在某个地方,对吧?哦,不,他不在这里。Josh Byne 在这方面做了很多工作。他最近给我发了一句引用,我想原话是:“你随便一甩老鼠,就能碰到一打在契约违规时抛出异常的语言。我的意思是,它们都这样做。” 还有 Brna,谢谢你也在同一时间告诉我。Ada、Eiffel、Java,就像每个人都这样做。但正如我们经常记得的,C++ 不一定像其他语言。所有语言都是不同的,仅仅因为语言 A 做了某事,并不意味着语言 B 也要做。在这种情况下,它们非常接近。但对于 C++ 来说,有一个小问题。如果你抛出异常,以便不终止,有人发现缺陷或潜在的缺陷吗,Steve?你不能抛出另一个异常。是的,最好不要有另一个异常已经激活。你最好不要正在栈展开中。你最好不要在一个没有 try-catch 保护的 noexcept 函数中,因为它从未想过会发生异常。但有人在下面插入了一个契约。所以,一种你可以缓解这种情况的方法,特别是对于栈展开的情况。我在幻灯片上说,这完全是实验性的。我还没有尝试过。我在幻灯片上亲手写的,我甚至不知道代码是否编译,因为我没有尝试。我知道三斜杠不编译。如果你要安装一个抛出异常的违规处理程序,这似乎是个好主意,但请将此视为我们仍在学习的临时建议,至少测试一下 std::uncaught_exceptions(),确保你当前不在栈展开中。因为如果整个重点是你不想终止,那么你可能也不想在那里抛出异常,因为你可能处于栈展开的某个阶段。
第 4 条:理解构建模式如何组合。我现在要说的一些内容反映了我职业生涯的大部分时间都在 Windows 领域度过。这可能反映出要么我对 Windows 领域的理解不如我想象的那么好,要么我做的概括在 Linux 领域和其他更友好的环境中并不成立。所以请对此持保留态度。至少在有些环境中,众所周知,你不应该链接调试版和发布版构建。它们实际上可能链接到完全不同的运行时库,具有不同的名称、不同的静态库、不同的 DLL。NDEBUG 不是那样,但它通常与那些构建模式和标志一起设置。在微软编译器中,NDEBUG 通常与 _DEBUG 一起设置,后者绝对会影响 ABI,因为它进行迭代器调试并添加额外信息。所以我们已经告诉人们:“嘿,以相同的模式构建所有东西,不要将调试版构建的代码与发布版构建的代码链接。” 至少在某种程度上,看,有无数开关。其中许多是兼容的,对吧?但这里有两个我们通常知道的大类。那将不会给你带来 ODR 违规。但有时我们可以容忍良性的 ODR 违规。如果我们处于相同的构建模式,比如我们在发布模式下构建,并且我们的一些文件打开了 NDEBUG,而另一些没有,这通常仍然有效,因为尽管技术上存在 ODR 违规,但同一个函数可能被编译为带调试和不带调试。如果它一直到达链接器,并且链接器看到两个副本,它可能会抛硬币或等效地翻转一个比特,决定采用哪一个。所以你今天必须知道这些事情。我希望我没有说任何新东西。这就是我们生活的世界。因为我们处于一个多翻译单元的、ODR 违规并未被严格强制执行的、链接器会在可能的情况下帮助你的世界。这就是我们的世界。
契约提供了一个改进,同时仍留在这个世界中。一个设计问题是:我们是否应该要求更好的链接器?目前的答案是否定的。说“是”的陷阱在于,现在我们正在标准化一个依赖于尚不存在的工具的功能。依赖不存在的工具的危险在于,你不仅仅是在进行语言更改,而是说“工具必须在你能使用之前更新”。我可以举出我们这样做过的其他功能。但关键点是,这将至少延迟采用十年,等待这些工具可靠地可用。因为如果它们只在一个平台上可用是不够的,因为我们很多人编写可移植代码。在一个平台上可用,很好,这是一个很好的开始。当你在所有我需要的五个平台上都有它时,再告诉我。然后我才能考虑采用它。这就是为什么我们不依赖新链接器的原因。
当前的 GCC 和 Clang 契约实现(尚未合并到上游,但其中一个可能在未来几周内开始合并到上游,祈祷吧)确实允许链接以任何配置构建的翻译单元。标准并不要求这必须可能,标准允许平台施加限制。但当前正在进行的两个实现确实允许你混合匹配这些模式。我将向你展示这可能导致什么可以被视为陷阱。所以我稍后会讨论这个。嗯,实际上,现在就是稍后,这里有一个例子。这是一篇全新的论文,在最近几周才可用,我想是这个月。想象你有一个头文件,其中包含一个带有契约断言的 inline 函数。当我描述这个时,想想它与常规 assert 有多么相似或不同。f 做了一个契约断言,其参数是正数,大于零。现在我有两个使用它的翻译单元,让我指向它们。我们在左边有第一个翻译单元,它用快速强制构建,有一个名为 g 的函数,它调用 f 违反了契约。这应该失败,因为 0 不大于 0。所以这是一个失败。它应该立即终止,而不调用违规处理程序,因为它是用快速强制构建的。到目前为止一切顺利。我有第二个翻译单元,它也调用 f,并且符合契约,预计会通过。如果整个程序有两个 f 的副本,那么链接器只会选择一个。所以我们这里有一个问题。如果左边的调用被内联了,那么它将受快速强制管辖。如果它没有被内联,那么你就任由链接器摆布。我的理解是(我可能错了),你可以保证每个翻译单元的本地语义。你可以保证在左边你会得到那个快速强制,但你必须强制内联。这说起来容易做起来难,你必须强制内联,记住内联函数包括你写过的每个模板,因为它们都在头文件中。因为 C++。但如果你能安排确保契约断言在 CPP 文件的函数体中,它实际上到达了函数体并被编译,那么你就没问题了,那么你就知道快速强制会发生。好处是,你不仅得到了你想要的左边,而且 main 的作者也得到了他们想要的,他们得到了保证的忽略,零运行时成本。这实际上将链接而没有 ODR 违规,这比 assert 是一个巨大的改进。我应该说,在当前接近被提议合并或上游到 Clang 和 GCC 的实现中,它将链接。你的平台提供商可能会决定施加额外的限制,但编译器支持混合这些,甚至没有良性的 ODR 违规,这很不错。
那么,你怎么知道你的编译器是否支持这种混合?阅读文档。期望是你可能可以?但我们还没有合并到上游,所以我们不知道。但如果你想要保证事情在你的翻译单元中发生,你仍然必须了解模板中的内联。第 5 条:理解这个真正第一版契约的局限性。我们还不能在其中放入自定义错误消息。记得我们添加了 static_assert,然后在后来的标准中我们添加了可以逗号加引号错误消息的 static_assert,很有用,对吧?我们后来添加了它,我们以后也会在这里添加,同样的想法,但你在第一次迭代中得不到这个,这意味着你自动的本能反应,在断言中写 && "string explanation" 仍然有效,就像 assert 一样,你仍然会得到一个原因,但我们希望做得更好。我们还不支持在虚函数或函数指针上使用契约,我们现在也没有契约组或标签。这些都是正在积极研究的事情。我们今天有每个翻译单元的语义,但受到第 4 条的限制,并且只有一个全局违规处理程序。
所以这些是一些最佳实践。这些是我目前所知道的全部,但我们正在学习,但到目前为止这是一个相当短的列表。
设计原理与常见问题
现在,为什么设计的某些部分是这样的?委员会在未能将契约纳入 C++20 后,一直在努力制定一个最小可行产品,这一点已经被广泛讨论。该小组非常努力地决定:好吧,什么是最小且可行的?什么是我们可以在此基础上构建、不关闭任何大门、但本身就有用的东西?所以这张幻灯片是为了回顾主要功能,我将向你解释为什么我认为它们相当最小(虽然不是完全最小),因为这些是必要的。让我们从前置条件和后置条件开始。顺便说一下,前置条件是重要的,后置条件也不错,但我可以在 C++26 中没有后置条件也能接受。前置条件是重要的。为什么将断言从函数体中移出,并将其提升到声明中,让每个人(包括工具)都能看到,这是一个重大进步?是的,工具现在可以进入函数体。但如果你只是在声明中说明你的前置条件,而不是强迫它们去挖掘,强迫它们必须有一个函数体,然后做低级工作来弄清楚你的意图,你打算的前置条件是什么,这对工具来说要容易得多。哦,契约断言也比宏好。不用多说了。
语义,嗯,当然,我们至少需要第一种和第三种:忽略和强制。我的意思是,即使是 cassert 也给了我们这些,对吧?但观察和快速强制。在这里,我是从我自己的角度来说的,作为在最近几个月才开始深入研究的人。不仅仅是因为我听到了关切,想自己了解,第一次深入探讨,自己看看,而且因为我现在想在生产中使用这些。我有要求要看看,这能成为我在公司可以使用的东西吗?有一个非常重要的事情是 cassert 从未给过我的:我可以在生产中打开检查,因为我在生产中不终止。让我告诉你,至少对于我所有的代码来说,有些代码可能不是全部代码,所以 cassert 在生产中是不可能的,那是一个非首发。但观察模式让我可以在生产中启用检查。这对于测试新的谓词也很有用。我添加了新的契约,但还不完全确定,所以我想先看看它们是否会触发。观察模式对此很好,因为它会检查它们并调用违规处理程序,所以我可以看到在生产中触发了什么。然后快速强制允许在生产中强制执行,对大小和性能的影响绝对最小。你在哪里需要这个?特别是在安全强化应用程序中。所以再次强调,我不是说契约是我们的安全功能,但是当例如强化标准库提供保证,并将其作为契约断言的实现细节实现时,它绝对希望快速失败。在那些安全应用程序中,实际上,快速强制,如果我没记错的话,是苹果出于这个原因提出的。
违规处理程序。最后,我们甚至有一个。哦,我们太需要这个了。为什么?因为我们每个人都有我们的日志框架、错误报告框架,我们有仪表板,当坏事发生时,我们把它发送给我们的 CTO,然后他们给我们发邮件,我们承诺会做得更好。你知道,所有这些事情,我们都有框架。它只是一个钩子。给我一个钩子,我就可以调用我已有的所有现有丰富基础设施,我可以把它插入我的监控仪表板,我可以发送那个……我该说这个吗?发送那个寻呼机警报给凌晨三点的人,比如“现在去修复你的错误”,或者也许早餐后。所以对我来说,我从两个方向看到了契约的价值。再次强调,这只是从我如何看待它来说的,没有人告诉我这些。这只是我直觉到的以及我为我的用途看到的价值。
左移是巨大的,我们越早发现错误,修复成本就越低,对吧?总是这样,每早一步,修复成本就降低一个数量级。如果我能
在测试时发现错误,那么在测试时修复错误通常比在生产中、在我的 CTO 最喜欢的客户面前修复要容易得多。所以左移是显而易见的,显然是好的。但我也想在生产中运行它们,至少在观察模式下。为了发现那些通过了测试的东西,因为也许我没有测试每个代码路径,也许我没有测试每种数据可能性。我仍然想知道,作为一个安全网,这不是我想发现它们的地方。我不想在生产中发现它们。但总比根本没有发现要好。
最小可行产品不会关闭未来演进的大门,比如自定义消息、虚函数、函数指针、标签和组。让我举一个这些事情的例子。谢谢 Nina 向我指出这一点,Nina 是 GCC 契约实现的实现者之一,也是我们的委员会秘书,顺便说一下,你会在今晚的委员会炉边聊天小组中看到其中一些人。一个未来扩展的例子,我们可以在 C++26 之后添加,是“无异常强制”或“无异常观察”的想法。所以想法是,如果我们实际上接受抛出异常的违规处理程序,那么不想抛出异常但想终止的程序可以明确说明。用一个语义。这就是“无异常强制”和“无异常观察”的含义,这些在 GCC 中已经实现,但它们是扩展,不是 C++26 的一部分。我想在这里引用 Va Vota linein 的话,他是 UWG 演进小组的名誉成员,也是一个 GCC 黑客(这是轻描淡写),聪明人。我想给你读一下他关于这种处理潜在抛出违规处理程序的方式(通过让不想抛出的代码用“无异常强制”或“无异常观察”选择退出)的确切说法。“突破性的认识、顿悟和天才之举。Villa 完成了。他通常不会给予如此热烈的赞扬。好吧,这不符合文化习惯,就像他……他非常印象深刻。突破性的认识、顿悟和天才之举在于,与其试图通过让违规处理程序是 noexcept 或不是来迎合两个不同的群体,我们用同一个违规处理程序来满足两个群体。如果它抛出,不希望异常逃逸的组件作者得到了他们想要的。希望异常逃逸的组件作者得到了他们想要的。不同的决定可以在同一个二进制文件中混合,一个违规处理程序满足两个受众的需求。作者 A 用“无异常强制”编译,作者 B 用常规强制编译。同一个违规处理程序对两者都有效。没有链接器魔法。记得我说的关于新颖的魔法链接器的话吗?没有链接器魔法,没有技巧,只是违规处理程序调用的不同编译。在包装函数中完成,意思是在编译器内部的实现中,我们已经有的包装函数。这是前一张幻灯片的一个例子,说明这个设计是经过深思熟虑的,试图保持未来扩展的大门敞开。
常见问题与开放性问题
所以,我们讨论了设计原理。现在,让我们讨论一些常见问题,包括……到目前为止,在演讲中,如果我没有在一开始说那句话,此时,喜欢契约的人会想:“嘿,Herb 大部分站在我们这边”,而另一方可能更不高兴。我会等到我们谈到开放性问题部分时再讨论。
那么,让我们谈谈常见问题和开放性问题。C++26 契约有很多实现定义的行为吗?是的。大约有两页纸的描述。然而,有些事情我们通常确实让实现定义。关于其中一些有讨论,但基本上其中一些是编译器开关,我们从不告诉标准编译器应该支持什么开关,应该支持什么构建模式。这些是超出标准范围的事情。这完全没问题,因为这就是我们生活的世界。我们知道不同编译器的开关不同,我们知道要使用的开关组,它们通常提供适合其平台的方便默认值。所以你想给开关和构建模式之类的事情留有余地。
C++26 契约现在难以实现吗?我提到这个是因为在过去几年里,有时我听到有人说:“哦,提案中的某一行超级难或不可能实现。” 如果是这样,它们已经被移除了,因为我被告知,包括看过代码的非作者专家说,我们在 GCC 和 Clang 中有两个完整的实现,还有很多扩展。所以它是标准的超集,尚未合并到上游。当它们合并到上游时,希望从未来几周开始,我们将有更多的眼睛来验证,但到目前为止,不,不难实现。一个曾经出现这个问题的地方是,如果你有两个连续的谓词,过去有一个错误,其中一个可以别名另一个。我想我有个 f 在那上面。我们修复了它,我们放了一个可观察的检查点,这很像一个不透明的函数调用。我们已经为不透明函数调用这样做了。我们不知道它是否有副作用,所以我们基本上在那里使用了非常相似的想法。它可能要求编译器在一个新地方进行检查,这可能需要一些更新,但这不像是一个翻天覆地的新功能。
C++26 契约对静态分析没有帮助,这是真的吗?我对静态分析了解不少,但我不是静态分析专家,所以我会让你参考周四由 GitHub CodeQL 及其经验的人做的演讲,关于能够与契约一起工作的经验和期望。现在,我可以做一个区分。那么,C++26 契约对静态分析没有帮助,这是真的吗?认为“不”的论点是,嗯,它们有帮助。它们将谓词移到函数声明中,你可以看到它。这确实让静态分析器更容易工作。它们将表达式放在分析器已经做的工作更少就能看到的地方。这严格来说减少了它们已经做的分析工作。然而,静态分析器除了表达式之外还做其他事情。有些人希望契约也能让你表达非表达式类型的东西。这些不这样做。那么,C++26 契约能点亮静态分析器吗?是或否?对于表达式,是;对于非表达式,否。但这可能是未来的扩展。这是一个重要的区别,但请参见那个演讲,听听真正懂行的人怎么说。
是否有可移植的默认行为?如果我写一行代码,放在一本书里说“嘿,contract_assert”,我能合理地期望阅读这本书的人,无论他们在什么平台和编译器上,都能尝试并得到合理的结果,得到我可以告诉他们的结果吗?很可能,是的。C++ 标准说,建议是编译器默认使用强制语义。并且默认的违规处理程序打印源代码信息和失败的谓词之类的东西。事实上,GCC 就是这样做的。这是当前 GCC 消息的剪贴,我缩短了文件名以节省几行,因为它是一个很长的文件名,但其余部分完全是剪贴。这相当合理。那么,是否有可报告的默认行为?在实践中,是的,即使标准没有刻意要求,它当然强烈推荐,并且两个当前的实现都遵循这一点。
这个我已经在前一张幻灯片上讨论过了:契约是否有一个缺陷,如果我在受契约保护的代码中写入未定义行为,它可以通过时间旅行优化静默地消除另一个契约检查?我不打算解释这个例子,因为任何涉及时间旅行优化的东西都是深奥和奇怪的。特别是因为答案是不再是了。那是一个问题,我们已经修复了它。所以谢谢你。这就是我提到的可观察检查点语义。所以幸运的是,我们至少不需要教那个,这是一个改进。


如果你试图在虚函数或函数指针上写前置条件或后置条件,会发生什么?无法编译,尚不支持。我看到这被描述为一个陷阱。我不太确定为什么。在 C++11 中,我们有 lambda 的箭头自动返回类型(推导 lambda 的返回类型),但不适用于常规函数,如果你试图在常规函数上写它,无法编译。所以有一个功能适用于某些函数,但不适用于其他函数。然后在 C++14 中,这个功能很受欢迎,所以被添加到了其他函数上。我们也打算支持虚函数的前置和后置条件。在那里仍然有一些关于常量化的困扰。我说了。这个想法是,在谓词中,事物被隐式
077:对C++标准库不满意?加入Beman项目



概述
在本节课中,我们将学习一个名为Beman的项目。该项目旨在让C++标准库的提案和演进过程对普通开发者更加透明和易于参与。我们将了解Beman项目的目标、已取得的成果,以及你如何能够参与其中,共同塑造C++的未来。
从城市规划到语言演进
演讲者首先将语言演进比作城市规划。城市规划影响每个人,需要长远的眼光和务实的投资,因为糟糕的选择很难逆转。同样,C++语言的演进也影响着所有使用者,其核心语言特性和标准库的引入都至关重要。
上一节我们介绍了语言演进与城市规划的相似性,本节中我们来看看当前C++标准库提案的评估方式存在哪些挑战。
当前标准库提案评估的挑战
目前,评估新的C++标准库提案并不像评估一个普通的开源库那样简单直接。
以下是当前流程中存在的一些问题:
- 提案形式复杂:提案以长达20-30页的“论文”形式呈现,而非简洁的README和示例代码。
- 标准化过程不透明:讨论通常限于ISO委员会成员,普通开发者难以了解设计决策背后的原因或提出疑问。
- 缺乏统一的实现与反馈平台:没有一个像GitHub那样集中、易于访问的地方来获取提案的实现、进行测试和提供反馈。
因此,我们需要一种更易访问、更统一的方式来将标准库提案作为真正的“库”进行评估。
Beman项目简介
Beman项目正是为了解决上述问题而诞生的。它本质上是一个“可访问性”项目,旨在为对C++未来感兴趣的人和库实现者搭建桥梁。
Beman项目的核心使命是:支持高效设计并产出最高质量的C++标准库。具体而言,它致力于:
- 作为提案实现的集合:一个GitHub组织,托管各种标准库提案的生产就绪实现。
- 作为透明的反馈与讨论平台:通过GitHub Issues和Discourse论坛,让每个人都能参与设计讨论,理解语言特性。
- 作为欢迎的社区:聚集对语言演进充满热情的人,相互学习,形成良好的反馈循环。
对于广大C++委员会和社区,Beman让库提案像真正的库一样可访问、可测试。对于库实现者,它提供了一个现成的、最佳实践的基础设施(如CMake、CI/CD),让他们可以专注于实现本身。
Beman项目中的库示例
经过近一年的发展,Beman项目已经托管了多个库的实现。让我们来看几个例子。
std::optional<T&>
这是Beman中最成熟的项目之一。当前的std::optional不允许引用类型(T&),这是一个故意留下的设计空缺。Beman的std::optional实现填补了这一空白。
核心概念:
// Beman 实现的 optional 支持引用类型
std::optional<Cat&> find_cat();
// 替代了过去返回 std::optional<Cat*> 可能带来的歧义
这个实现已经进入C++26草案,并且通过暴露给更广泛的社区,帮助发现了原始实现中一个影响广泛的重大缺陷。
std::any_view
这个库旨在解决传递视图(views)时类型擦除的问题。当你有一个复杂的管道视图(如filter + transform)时,其类型复杂且无法用于跨接口边界。
核心概念:
// any_view 提供了一种类型擦除的视图容器
std::any_view<int> get_view();
// 可以持有任何满足特定概念(如输入范围)的视图,便于传递
参与这个库的实现和讨论,是深入了解C++类型擦除和概念模型的绝佳机会。


Beman项目的成熟度模型
Beman项目为库的实现定义了一个成熟度模型,直观地反映了其与标准化进程的关系:
- 灰龙:库处于开发初期,API可能不稳定,尚未准备好用于生产。
- 彩龙:库已被评估为“生产就绪”,拥有高质量的测试和实现,鼓励大家试用。
- 红龙:库的底层提案已被ISO标准采纳,其API稳定如标准库。此时,Beman的实现可作为在旧标准中“回溯移植”新特性的来源。
- 龙肉干:库的底层提案被拒绝,项目归档。
这个模型清晰地展示了从提案实验到标准落地的完整路径。
如何参与Beman项目
Beman项目投入了大量精力构建便捷的基础设施,以降低贡献门槛。
对于库用户/反馈者
你可以轻松地尝试任何Beman库:
- 访问 Beman GitHub 找到感兴趣的库。
- 使用 CodeSpace 在浏览器中一键打开开发环境,立即运行示例。
- 使用 Compiler Explorer 在线查看编译结果。
- 在库的GitHub仓库中提交Issue,分享你的使用体验、遇到的问题或新的用例想法。任何反馈都极具价值。
对于库作者/贡献者
如果你想为现有库贡献代码或启动一个新的Beman库,流程也非常简单:
- 使用项目模板:Beman提供了
be-mann/exemplar模板仓库,使用cookiecutter工具可以快速生成新库所需的所有基础设施。 - 完善的基础设施:生成的仓库已预置:
- CMake配置:遵循最佳实践,支持
cmake --preset一键构建和测试。 - 全面的CI/CD:通过GitHub Actions自动在Windows、Linux、macOS上使用GCC、Clang、MSVC等多个编译器版本进行测试。
- 代码质量工具:集成
pre-commit,自动格式化代码(C++、Markdown等)。结合reviewdog,在PR中直接提供修复建议。 - 开发容器支持:通过CodeSpace提供一致的云端开发环境。
- CMake配置:遵循最佳实践,支持
提出你的想法

即使你没有具体的实现,也可以在Beman的Discourse论坛上提出你希望看到的库特性。社区可以帮你了解是否有相关提案,甚至指导你如何撰写提案。
总结
本节课中我们一起学习了Beman项目。我们了解到,Beman通过将C++标准库提案作为生产就绪的开源库来实现,极大地提高了语言演进过程的透明度和参与度。它提供了一个从实验、反馈到最终标准化的完整平台。


无论你是想提前体验C++的未来特性,还是想为C++标准库的完善贡献一份力量,亦或是想学习大型C++库的基础设施建设,Beman项目都为你打开了大门。加入这个社区,让我们一起构建更好的C++。
078:C++开发者今天能应对ABI破坏吗?




在本教程中,我们将探讨C++应用二进制接口(ABI)的兼容性问题。我们将回顾历史上ABI破坏的案例,分析开发者当前仍面临的链接挑战,并了解现代工具如何帮助管理这些兼容性问题。无论你是初学者还是有经验的开发者,本教程都将帮助你理解ABI问题的本质及其应对策略。
什么是ABI?
上一节我们介绍了本教程的主题,本节中我们来看看ABI的基本概念。
应用二进制接口(ABI)定义了已编译的翻译单元如何与不同翻译单元中的实体进行通信。这些实体可能来自使用相同编译器版本编译的不同源文件,也可能是使用不同编译器或编译器版本构建的库中的代码。
ABI本身超出了C++标准的范围。标准主要涵盖语言和标准库,而ABI是平台和供应商特定的。不同的操作系统、CPU架构和编译器供应商都有不同的ABI。
然而,标准库中的某些更改可能会迫使编译器供应商在未来版本中破坏ABI,以符合标准。尽管如此,ABI破坏的具体实现方式以及开发者将如何体验它,仍然是平台和供应商特定的。
开发者如何体验ABI破坏?
上一节我们定义了ABI,本节中我们来看看开发者实际会遇到哪些问题。
开发者体验ABI破坏的方式有多种,从最理想到最不理想的情况如下:
以下是几种常见情况:
- 明显的链接器错误:这是最常见的信号。当尝试链接使用不同ABI构建的对象时,链接器会明确指出存在不兼容性。
- 隐晦的链接器错误:这是更常见的情况。ABI不兼容会导致难以诊断的链接错误。
- 运行时错误:在某些情况下,程序可能会遇到段错误(Segmentation Fault),这更难追溯到ABI不兼容性。
- 静默的错误行为:最糟糕的情况是程序能够运行并正常退出,但其行为不符合预期,例如计算结果错误。
历史上的ABI破坏案例
了解了ABI破坏的表现形式后,我们回顾一下C++发展史上几个重要的ABI破坏事件。
Visual Studio 2015之前的版本
在Visual Studio 2015之前,几乎每个VS新版本都会完全破坏ABI兼容性。例如,使用VS 2005构建的对象无法与使用VS 2010构建的程序链接并交互。虽然存在变通方法(例如避免对象跨越“CRT边界”),但这给工作流带来了很大干扰。
当时,开发者必须记住不同VS版本对应的内部版本号(如VS2012对应v110,但错误信息可能显示1700),并且需要为不同VS版本维护多套二进制文件(如vc11、vc12、vc14文件夹)。工具如CMake在查找包时也可能因版本混淆而导致链接错误。
GCC 5与libstdc++ C++11 ABI
GCC 5决定让std::string和std::list的实现符合C++11标准。与VS的“全面破坏”方式不同,GCC采用了新方法:新版本的libstdc++同时包含了新旧两种实现,并通过嵌套命名空间进行区分,因此符号修饰(name mangling)也不同。
这不需要源代码更改,并且库本身向后兼容新旧ABI。主要问题出现在混合使用新旧ABI构建的对象时,会导致链接错误。错误信息中通常包含cxx11或类似标记,提示开发者需要用同一种ABI重新构建所有对象。
需要注意的是,即使GCC版本相同,不同Linux发行版默认使用的ABI也可能不同(例如Ubuntu较早切换到新ABI,而Red Hat为了兼容性在多个版本中仍使用旧ABI)。因此,不能假定不同GCC版本构建的二进制文件一定链接兼容。
其他GCC与libstdc++的变更
其他例子包括:
std::filesystem:在GCC 7(实验性,需链接-lstdc++fs)到GCC 8(正式,仍需链接-lstdc++fs)再到GCC 9(实现并入主库,无需额外链接)的演进中,如果使用GCC 8构建的共享库(其中静态链接了libstdc++fs)与GCC 9的程序链接,可能会在运行时发生冲突导致段错误。GCC文档指出,C++17特性的ABI直到GCC 9才稳定。- libstdc++的完全破坏:上一次libstdc++完全破坏ABI是在2004年,新旧版本完全不兼容。
- macOS的过渡:大约在2013年,macOS从libstdc++过渡到libc++,总体上比较成功。
当前开发者仍面临的ABI挑战
回顾历史后,我们发现ABI兼容性问题并未消失。本节将探讨当前开发者日常开发中仍会遇到的链接问题。
由条件编译导致的ABI不兼容
库作者为了支持不同C++标准或编译器特性,常在头文件中使用条件编译宏。这可能导致同一个头文件在不同翻译单元中被解析成不兼容的ABI。
例如,一个库使用C++14构建,但其头文件根据__cplusplus宏决定使用哪种实现。如果消费者代码使用C++20编译并包含该头文件,即使源代码无需改动,也可能在链接时出错。
一种解决方法是避免在头文件中使用此类条件编译,而是在构建库时就将决策硬编码。例如,Abseil库提供了一个absl/base/options.h文件,允许用户硬编码决策以确保二进制兼容性。然而,如果上游库(如gRPC)单方面决定只使用新标准中的类型(如直接使用std::variant而不再考虑Abseil的实现),就会破坏这种封装,导致编译或链接错误。这类问题在Boost等库中也很常见。
编译器保证之外的现实情况
尽管编译器厂商努力保持ABI兼容,但仍存在一些例外:
inline静态成员变量(C++17前):在C++17之前,类内初始化的静态成员变量需要在类外有一个定义。如果使用C++11构建的库中包含了这样的变量,而主程序用C++17构建并链接,可能会因符号问题导致链接失败。这在某些编译器中被视为未解决的bug。- Windows运行时的多重版本:Windows开发者需要处理多个不兼容的运行时(如调试版/发布版、静态链接/动态链接)。如果混用,可能会得到清晰的链接器错误。但更隐蔽的是,如果动态加载(LoadLibrary)了使用不同运行时的DLL,程序可能不会崩溃,但行为异常。
- Visual Studio的“向后兼容”陷阱:自VS2015起,微软保持了ABI向后兼容,但要求链接器版本不低于所有待链接对象中最新的那个。如果不符合(例如CI环境中工具链版本过旧),会产生非常隐晦的链接错误。此外,偶尔也会有非预期的、文档中提及的微小ABI破坏在补丁版本中引入。
- Linux的兼容性“孤岛”:即使在同是GCC 13的情况下,在不同Linux发行版(如Ubuntu 22.04, RHEL 8, Alpine)上构建的共享库也可能因底层libstdc++版本、链接方式(glibc vs musl)等因素而互不兼容。开发者往往只关注文件名(如
libfoo.so),而忽略了这些底层差异。
现代工具如何帮助管理ABI
面对这些持续存在的挑战,现代构建和包管理工具提供了解决方案。本节我们将看看这些工具如何帮助开发者应对ABI复杂性。
理想情况下,工具应该能:
- 在构建开始前就预警ABI不兼容。
- 自动忽略与目标平台不兼容的预编译二进制包。
- 在缺少兼容二进制包时,能够自动从源码构建出一套兼容的版本。
- 支持构建特殊的二进制变体(如LTO优化、带消毒器)。
以下是一些工具的实际应对方式:
- Homebrew (macOS):在macOS切换libc++时,Homebrew会拒绝安装使用不兼容库构建的软件,这比遇到链接错误要好。
- Linux发行版包管理器:它们擅长创建“兼容性孤岛”,通过
发行版代号-架构(如noble-amd64)来精确描述二进制包的兼容环境。 - vcpkg:支持定义“三元组”(triplets),可以轻松复制并修改以建模新的ABI(例如
x64-windows-abi-new)。有趣的是,尽管过去十年ABI相对稳定,vcpkg默认仍倾向于从源码构建以确保最大兼容性。 - Conan:使用“配置文件”来描述目标平台,包括编译器、编译器版本、C++标准等。任何设置差异都会导致不同的二进制包。Conan 2.0引入了对C++标准版本的显式管理,并可以配置兼容性模式(例如,允许使用Clang 16构建的包在Clang 17下使用)。对于像Abseil这样明确跨C++标准不兼容的库,可以在Conan配方中声明,这样Conan只会为请求的特定C++标准版本提供二进制包,并在消费者尝试混合不兼容版本时直接报错,从而将问题从晦涩的链接器错误提前到清晰的依赖解析阶段。

总结与展望
本节课中我们一起学习了C++ ABI兼容性的核心问题、历史案例、当前挑战以及现代工具的应对策略。
我们认识到,尽管十年前发生了重大的ABI破坏,但由条件编译、编译器实现细节、平台差异等导致的链接兼容性问题从未消失,开发者至今仍受其困扰。同时,任何足够长的时间跨度内,总会有与C++无关的因素(如CPU架构变更)迫使你重新构建。
幸运的是,现代工具链(如Conan, vcpkg, CMake)和容器化技术(如Docker)的成熟,使得管理多版本、多平台依赖和从源码构建变得更加容易。许多开发者因此并未直接感受到ABI不兼容的冲击。软件供应商也能更好地为目标平台提供多套二进制文件。


最终,解决大多数ABI问题的方案仍然是从源码重新构建相关库,并确保使用一致的编译器、标志和标准版本。虽然并非所有人都有条件这么做(例如使用闭源库),但行业趋势和工具支持正朝着降低这一门槛的方向发展。作为开发者,理解ABI问题的本质,并善用现代工具来管理依赖和构建过程,是应对当前及未来ABI挑战的关键。
079:设计、优化与测试(以Libc++为例)📚



在本教程中,我们将通过分析C++标准库实现(以Libc++为例)中的具体案例,学习其背后的设计决策、性能优化技巧以及严格的测试方法。我们将重点关注内存布局优化、算法性能提升以及如何确保库实现的正确性。
1️⃣ 内存布局优化:std::expected 与 [[no_unique_address]]
上一节我们介绍了本教程的概述,本节中我们来看看如何通过内存布局优化来减少类型的大小。
std::expected 是C++23中新增的一个用于错误处理的工具类。一个关键的设计问题是:如何高效地实现它?
传统实现的内存浪费
一个简单的实现可能使用一个联合体(union)来存储值或错误,外加一个布尔成员来指示当前状态。对于一个包含 int、char、bool 的类 Foo,其大小通常为8字节(包含尾部填充)。如果 std::expected<Foo, std::error_code> 也采用这种简单实现,其大小可能达到12字节。
使用 [[no_unique_address]] 进行优化
Libc++的实现利用了 [[no_unique_address]] 属性。这个属性告诉编译器,被标记的成员可以与其他成员重叠存储。
代码示例:优化的 std::expected 布局
template <class T, class E>
class expected {
union {
T value_;
E error_;
};
[[no_unique_address]] bool has_value_;
};
通过这个属性,编译器可以将 has_value_ 这个布尔成员放入 Foo 的尾部填充空间中,从而将整个 std::expected 的大小从12字节减少到8字节。
优化带来的好处
- 更小的内存占用:对象本身更小。
- 更好的缓存局部性:更可能被放入CPU缓存行。
- 更高效的返回值传递:当类型大小足够小(例如8字节)时,编译器可能通过寄存器而不是栈来传递返回值,从而减少指令开销。
重要提醒:虽然 [[no_unique_address]] 非常有用,但在与手动管理生命周期的操作(如联合体、std::construct_at 或 placement new)结合使用时需要格外小心,否则可能意外覆盖重叠的内存区域。
2️⃣ 位复用与紧凑设计:std::stop_token 系列
上一节我们学习了如何利用尾部填充,本节中我们来看看如何通过复用数据位和填充空间来设计极其紧凑的并发工具。
std::stop_source, std::stop_token, std::stop_callback 是C++20引入的并发工具。它们需要一个共享状态来协调不同线程间的“停止请求”。
朴素实现的缺陷
一个朴素的共享状态可能包含:
- 一个
std::atomic<bool>作为停止标志(1字节)。 - 一个
std::atomic<int>用于计数存活的stop_source(4字节)。 - 一个
std::forward_list用于存储回调函数节点(需要额外分配内存)。 - 一个
std::mutex用于保护列表(在无异常抛出的要求下难以直接使用)。 - 一个
std::shared_ptr控制块用于生命周期管理(16字节)。
这种实现下,共享状态本身可能就达到72字节,且涉及多次内存分配。
Libc++的优化设计
Libc++采用了极其紧凑的设计:
- 复用原子整数的位:使用一个
std::atomic<std::uint32_t>。- 最低位用作“停止请求”标志。
- 第二位用作“回调列表锁”标志,利用C++20的
std::atomic等待/通知操作实现无锁同步。 - 剩余30位用于计数存活的
stop_source。
- 使用侵入式链表:
stop_callback对象本身作为链表节点,stop_state只保存一个指向链表根节点的指针,无需为节点额外分配内存。 - 消除填充孔洞:在
stop_state的布局中,巧妙地将引用计数放入原本是填充字节的空间。 - 使用侵入式共享指针:由于引用计数已内置于对象中,可以使用没有独立控制块的
intrusive_shared_ptr,进一步减少开销。
优化结果:共享状态从72字节降至16字节,stop_token 从16字节降至8字节。
核心思想:
- 寻找并复用类中未使用的位(如整数的特定位)和填充空间。
- 使用侵入式数据结构避免额外内存分配。
3️⃣ 算法优化:基于概念的泛型优化 🚀
上一节我们探讨了数据结构的紧凑设计,本节中我们来看看标准库算法如何通过基于概念(Concepts)的优化来获得巨大性能提升。
案例一:std::ranges::for_each 与 std::deque
对一个 std::deque<int> 中的每个元素进行“钳制”操作。使用传统的基于迭代器的范围for循环,编译器难以优化,因为它需要处理 deque 分段存储的复杂性。
Libc++引入了 分段迭代器(Segmented Iterator) 的概念。如果一个迭代器类型建模此概念,算法可以对其进行“解包”。
优化原理:
对于 std::ranges::for_each,如果输入范围是分段迭代器,算法会将其重写为一个嵌套循环:
- 外层循环遍历每个内存块(段)。
- 内层循环对每个连续的、平坦的内存块调用
std::ranges::for_each。
关键点:内层循环的迭代器是简单的原始指针,编译器能够轻松地对此循环进行向量化优化,从而获得3到12倍的性能提升。
案例二:std::ranges::copy 与 std::views::join
将一个嵌套的 vector<vector<int>> 扁平化并拷贝到另一个 vector 中。手动编写嵌套循环已经可以被编译器向量化。
然而,使用 std::ranges::copy 配合 std::views::join 适配器,性能仍能再提升50%到5倍。
优化原理:
join_view的迭代器自然建模了 分段迭代器概念。- 优化后的
std::ranges::copy会解包分段迭代器,对内层的连续范围(即每个内部的vector<int>)进行拷贝。 - Libc++的
std::ranges::copy针对“连续且可平凡拷贝”的范围有特殊优化:直接调用memmove。 - 因此,整个操作被优化为对每个子
vector进行一次memmove,效率极高。
算法优化的核心思想:
- 基于概念进行优化:不针对具体类型,而是针对满足特定概念(如
SegmentedIterator)的任何类型进行优化。 - 优化是通用且可组合的:一个优化(如分段迭代器解包)可以自动使另一个优化(如连续范围的
memmove)生效,无需额外工作。
4️⃣ 容器API与优化:善用标准库设施 🧩
上一节我们看到了算法层面的优化,本节中我们来看看如何通过选择正确的容器API来触发底层优化。
以C++23的 std::flat_map 为例。它底层用两个排序的 vector 分别存储键和值。
错误示例:手动循环插入
将 flat_map2 的所有元素插入 flat_map1。如果使用 for (auto& e : m2) m1.insert(e);,每次插入都是 O(n) 操作,总体是 O(n^2) 复杂度。
正确做法:使用 insert_range
标准库提供了 insert_range 成员函数,专门用于插入一个范围。其复杂度为 O(N log N)。如果已知输入范围已排序,还可以使用 insert_range(sorted_unique, ...) 重载,复杂度降至 O(N)。
库实现者的优化挑战
如何高效实现 insert_range?关键一步是将新范围追加到键和值向量的末尾。
朴素实现:遍历输入范围,每次分别将键和值 push_back 到两个向量中。这无法利用向量 insert 针对连续范围的 memcpy 优化。
Libc++的优化:引入了 乘积迭代器(Product Iterator) 概念。flat_map 的迭代器自然建模此概念,因为它聚合了键迭代器和值迭代器。
优化原理:
- 当检测到输入迭代器建模了
ProductIterator概念时,库代码会将其“解包”成底层的键迭代器范围和值迭代器范围。 - 然后,直接调用
vector::insert的范围重载,将整个键范围memcpy到键向量,整个值范围memcpy到值向量。 - 这个优化带来了2到10倍的性能提升。
给用户的启示:
- 使用最精确的API:例如,插入一个范围时,使用
insert_range而非手写循环。 - 利用库设施:例如,需要合并两个平行序列(键和值)时,使用
std::views::zip生成一个“对”的视图,然后使用insert_range。zip_view的迭代器也建模了ProductIterator概念,因此能自动获得相同的优化。
5️⃣ 标准库测试:追求极致的正确性 ✅
上一节我们讨论了如何利用API获得性能,本节中我们来看看标准库实现者如何确保每一行代码都符合标准要求,这是库正确性的基石。
测试标准库与测试应用程序代码截然不同。标准就是明确的规范,任何偏差都可能造成严重后果,且由于ABI稳定性要求,修复bug的成本可能极高。
测试标准中的“每一个词”
以 std::expected 的移动构造函数声明为例,我们需要测试:
constexpr:是否能在编译期求值。explicit(或其缺失):是否正确地允许或禁止隐式转换。noexcept规范:异常规范是否与标准规定一致。- 约束(Constraints):当条件不满足时,函数是否被正确地从重载集中移除。
- 规约(Mandates):当条件不满足时,是否保证产生编译错误(通常通过
static_assert)。 - 效果(Effects) 和 后置条件(Postconditions):运行时行为是否正确。
测试技术示例
-
测试
constexpr:constexpr bool test() { std::expected<int, int> e1{5}; auto e2 = std::move(e1); // 测试移动构造 return e2.has_value() && e2.value() == 5; } static_assert(test()); // 编译期测试 assert(test()); // 运行时测试同一份测试代码可用于编译期和运行时。
-
测试约束(Constraints):
struct NotMoveConstructible { NotMoveConstructible(const NotMoveConstructible&) = delete; }; // 测试:当T不可移动构造时,移动构造函数不应存在 static_assert(!std::is_move_constructible_v<std::expected<NotMoveConstructible, int>>); -
测试规约(Mandates)/
static_assert:
这需要测试代码编译失败。Libc++使用Clang的-verify编译器标志配合源代码中的预期错误注释。// 期望一个包含“某个错误信息”的编译错误 std::expected<int, int> e; auto x = e; // 错误:expected-error {{copy constructor is deleted}} -
测试断言(Assertions):
对于未定义行为的前置条件,库在调试模式下会用断言帮助诊断。测试需要验证断言是否在违反条件时正确触发。TEST_LIBCPP_ASSERT_FAILURE([]{ /* 触发断言的操作 */ }, “expected assertion message”);这个宏会启动子进程执行操作,捕获其断言失败输出并进行匹配。
测试的核心要点:
- 共享编译期与运行时的测试逻辑。
- 积极编写“负面测试”,确保不该编译的代码确实无法编译,不该通过的行为确实会失败。
- 利用
-verify等编译器工具测试静态断言。

总结 🎯
本节课中我们一起学习了C++标准库实现中的核心优化与测试理念:
- 内存优化:利用
[[no_unique_address]]和侵入式设计,减少对象大小和内存分配,提升缓存效率。 - 算法优化:通过引入和利用 概念(如
SegmentedIterator、ProductIterator)进行泛型优化,使性能提升自动应用于符合概念的任何类型,并且优化是可组合的。 - API设计:鼓励用户使用最精确、最高级的API(如
insert_range、范围适配器),这些API背后往往连接着库实现的最优路径。 - 测试哲学:标准库的测试需要覆盖标准的每一个细节,包括编译期行为、约束、规约和运行时行为,使用专门的工具确保错误处理机制完全符合标准规定。


这些思想不仅适用于标准库开发者也对编写高性能、可维护的通用C++代码具有重要的指导意义。
080:从 Boost 到 C++26




在本节课中,我们将要学习 std::optional 的发展历程,特别是它如何从 Boost 库演变到 C++26 标准,并最终支持引用类型。我们将探讨其设计挑战、核心语义以及未来的发展方向。
大家好,我是 Steve Downey,来自 Bloomberg。我是最终将 std::optional 支持引用类型的提案的主要作者。今天我将讨论我们取得了什么成果,为什么花了这么长时间,我们期望从中获得什么,以及还有哪些工作待完成。
标准化时间线 📅
std::optional 最早在 2005 年被提出,但当时只支持值类型。直到 2017 年,std::optional<T> 才被纳入 C++17 标准。对于左值引用的支持,则在 2025 年 6 月的索非亚会议上投票通过。
最初的提案希望 optional 具有引用语义,这与我们最终实现的结果基本一致。这个过程之所以漫长,是因为所有参与者都出于善意,但对实现方式有合理的不同见解。
Boost.Optional 库很早就提供了引用语义。一个为 C++20 准备的提案曾进展顺利但最终未被采纳。幸运的是,JeanHeyd Meneide 在受挫后进行了大量研究,梳理了历史上的各种尝试和争论,这直接促成了我的提案:我们应该直接实现它。当然,实际的细节远比我想象的要多。
核心问题:引用的语义 🤔
引用在 C++ 语言中大致扮演三种完全不同的角色,这导致了复杂性。
- 调用约定语义:通过引用传递参数,适用于像运算符这样的场景,可以避免拷贝。
- 局部别名:为表达式提供一个本地别名,不占用额外空间,编译器会跟踪这个别名。
- 作为结构体成员:在结构体中存放引用会非常复杂,会导致所有特殊成员函数(如默认构造函数、拷贝赋值运算符)失效,因为引用本身不可重新绑定。
因此,关于 optional 持有引用时应该如何表现,存在强烈的理论分歧。一种观点认为,它应该像一个包含引用成员的结构体,一旦绑定就无法改变。但事实证明,这种语义并不受欢迎,且会带来许多额外问题。
这个争论的核心是 “穿透”与“重新绑定”。赋值时,是应该直接操作 optional 当前引用的对象(穿透),还是让 optional 转而引用另一个对象(重新绑定)?对于真正的引用类型,你无法做到后者。
JeanHeyd 通过其库实现和研究得出了一个关键观察:如果赋值行为依赖于 optional 的当前状态(已 engaged 或未 engaged),那么代码将变得难以推理。你必须动态追踪程序路径才能知道会发生什么,这很容易引入错误。我们希望仅通过局部查看代码就能推理程序行为。
因此,我们最终的设计是:optional<T&> 内部只是一个指针。我们对这个指针施加了许多约束,但其本质就是一个指针。这也带来了一些其他问题,但这是权衡后的结果。
std::optional<T> 快速回顾 🔄
上一节我们讨论了引用的复杂性,本节我们来看看 std::optional<T> 的基本概念,以便与新的 optional<T&> 进行对比。
std::optional<T> 是一个值语义的拥有类型。它内部可能包含一个 T 类型的对象,也可能不包含任何值(即“未 engaged”状态)。你可以拷贝它,可以对其做任何底层类型 T 允许的操作。它只是额外提供了一个比特的信息来表示“可能没有值”。
这对于没有“带外值”来表示无效状态的场景非常有用。从代数类型角度看,可以将其视为 T + 1(即 T 的所有可能值加上一个“空”值)。它类似于 std::variant<T, std::monostate>,尽管实现方式不同。
关于 optional 的长期讨论,实际上也是为我们真正想在 variant、expected 等代数类型上实现的功能进行铺垫。现在我们在 optional 上达成了共识,就可以开始处理其他类型了。
optional 的一个核心用例是:从配置文件读取一个可能不存在的值。使用 optional 可以在代码中更好地建模这种情况,减少忘记处理“未 engaged”状态的可能。
在 C++26 中,我们可以使用一种简洁的语法来遍历 optional(它现在是一个范围):
for (auto& value : maybe_value) {
// 如果能进入循环体,则 value 一定存在
}
这对于简化需要检查 optional 状态的代码很有帮助。
另一个重要用例是作为默认参数:
void func(std::optional<int> param = std::nullopt) {
// ...
}
func(); // 使用默认值(未 engaged)
func(42); // 传递一个 int,会自动转换为 optional<int>
这增加了许多构造函数和转换,使得实现正确且无惊喜变得具有挑战性。
std::optional<T&> 概述 🎯
理解了 optional<T> 后,本节我们来看看新的 optional<T&> 是什么,以及我们通常希望它做什么。
std::optional<T&> 是一个非拥有类型。它内部并不包含一个 T 类型的值,而是引用着别处的某个对象。它具有指针式的引用和值语义:指针本身是值(可拷贝、可比较),但解引用后具有引用语义。这就是我们对 optional<T&> 的期望。
它有一个额外的值来表示空状态。我们实质上强制规定了一个核心优化:空状态就是内部的空指针,没有为额外的比特位分配空间。
以下是核心用例:
-
查找并可能修改:例如,在
map中查找一个键。// 传统方式,需要处理 iterator 和 pair auto it = my_map.find(key); if (it != my_map.end()) { it->second.modify(); } // 期望的方式(C++29 计划中) std::optional<Value&> maybe_val = my_map.find(key); if (maybe_val) { maybe_val->modify(); // 可以直接修改 }使用
optional能提示你应该检查是否存在,并且因为是引用,你可以直接修改其值。 -
作为可选参数:替代指针,传递更清晰的意图。
void process(Logger& logger, std::optional<Data&> maybe_data) { if (maybe_data) { logger.log(*maybe_data); } }传递
optional意味着接收方不会尝试删除它或对其做其他预期之外的操作,并且如果调用者不提供,可以跳过相关逻辑。
设计决策与挑战 ⚖️
在就基本语义达成一致后,我们仍然需要做出许多具体的设计选择。本节将探讨其中一些关键决策。
构造与赋值:从底层类型 T 或可转换为 T 的类型进行构造和赋值,不可避免地会带来意外和复杂的实现。对于 optional<T&>,我们希望尽可能默认安全。我们利用标准库中的新技术来检测转换是否会产生悬垂引用,并在可能时禁止这种构造。
赋值语义(穿透 vs 重新绑定):我们最终选择了重新绑定作为默认的安全语义,因为它能产生最少的意外结果。无论 optional 之前的状态如何,赋值操作总是会让它引用新的对象。这使得代码行为可预测。
与 optional<T> 的泛型性:optional<T&> 的特化与 optional<T> 完全不同。这在泛型系统中通常不被允许,因为希望泛型类型行为一致。但 C++ 中的引用本身就很特殊,不同于值语义。我们实际上是在要求“引用语义”,而 C++ 的引用语义与值语义本就不同。
make_optional 的行为:make_optional 总是返回 optional<T>,即使表达式是引用类型。我们不希望改变这一点造成意外。现在你可以直接写 optional<T&>,不再那么需要 make_optional。
值类别的影响:我们模仿指针。optional 本身的值类别(左值、右值)不影响你解引用它得到的内容(总是 T&)。否则,你可能会意外地从 optional<T&> 中移出内容。
常量性:我们使用浅层 const。const optional<T&> 并不意味着它引用的对象是 const 的。如果你需要一个引用常量对象的 optional,应该使用 optional<const T&>。
条件性 explicit:我们根据底层类型的构造是否 explicit 来决定 optional 的构造函数是否 explicit。这减少了现有代码的破坏和语法噪音,但增加了库设计的复杂性。
.value_or() 的返回值:为了安全性和时间限制,optional<T&> 的 .value_or() 目前总是返回一个 T 类型的值(而非引用)。未来有提案希望将其泛化,返回 T 和 U 的公共引用类型。
原位构造:支持,但会检查是否会产生悬垂引用,并避免从临时对象转换。
赋值运算符:我们最终意识到只需要一个赋值运算符(因为只是拷贝指针),这对标准化和编译器优化都有好处。
实现与悬垂引用 🛡️
上一节我们讨论了各种设计选择,本节我们来看看如何具体实现这些原则,特别是如何避免悬垂引用这一常见错误。
在具体实现设计时,我们遵循以下原则:
- 避免引用临时对象:我们检查所有转换的悬垂属性。如果转换会产生一个临时对象,而我们取了它的地址,这个临时对象在表达式结束时就会消失,我们将禁止这种构造。这排除了一些安全的用例,但阻止了更多危险的用例。
- 删除悬垂重载:我们直接删除会导致悬垂的重载,而不是使用
requires子句。这样错误会更快地暴露出来,不会落入构造函数重载集中的其他意外选择。 - 赋值即指针拷贝:
optional<T&>的赋值等价于指针拷贝,所有赋值都通过单一函数完成。这对编译器优化非常透明。
一个重要的相关项目是 Project Beman。它旨在为标准库组件提供精确的参考实现。在标准化过程中,拥有一个完全符合提案的实现至关重要,这可以帮助我们在早期发现设计问题,例如测试特定修改(如增加 const)的影响,而不是等到标准会议周期之后。
“窃取引用”的Bug与移动语义 🐛
在实现过程中,我们发现并修复了一个重要的边缘案例 Bug,这揭示了关于移动语义的一个普遍原则。
考虑以下代码:
Cat fin;
Cat& fin_ref = fin;
std::optional<Cat> maybe_cat = std::move(maybe_cat_ref); // Bug!
在 Boost.Optional 中,上述移动赋值会“窃取”猫(移动 fin 本身)并赋值给 maybe_cat。这非常令人意外。
问题的核心在于:optional 被指定为对 operator* 的结果进行移动。一旦我们有了 optional<T&>,从 operator* 获得的值类别就不可预测了。特别是,对于一个即将消亡的 optional(纯右值),你可以从中移动,但对于 optional<T&>,仅仅 optional 本身即将消亡,并不代表它引用的对象也可以被移动。
我们得出的一个普遍原则是:不要试图推理什么可以被移动。 你应该只对你明确有权移动的对象使用 std::move。更安全的做法是写 std::move(*rhs),而不是 *std::move(rhs),因为你不知道解引用一个被标记为“可移动”的右值是否是你想要的行为。
最好的移动是那些你不需要写的移动,比如返回值优化。
未来工作与总结 🚀
我们已经介绍了 optional<T&> 的设计与实现,本节将展望未来的计划,并对本节课内容进行总结。
关联容器的查找:optional<T&> 最常见的用例是在关联容器(如 map)中查找元素。我们计划为 C++29 在 map、unordered_map 和 flat_map 中添加返回 optional<value_type&> 的查找成员函数。这只是一个时间优先级问题,实现上并无困难。
其他待完成事项:
- 完善
optional与其他代数类型(如variant、expected)的协作。 - 解决
vector<bool>这类特化带来的历史问题(虽然不直接相关,但属于类似的“非泛型”特化问题)。 - 推进
variant支持引用类型的工作。现在我们在optional上达成了语义共识,这为variant铺平了道路。
与 std::reference_wrapper 的关系:reference_wrapper 内部也是一个指针,但其 API 设计初衷不同(用于 tuple 等),且没有我们为 optional<T&> 实现的悬垂检查等安全特性。虽然理论上可用作替代,但使用起来不够方便,且与 optional<T> 的互操作性可能存在问题。
本节课中,我们一起学习了 std::optional 支持引用类型的漫长演进之路。我们从其历史背景和核心设计挑战(穿透 vs 重新绑定)开始,明确了 optional<T&> 内部使用指针并采用重新绑定语义 以实现可预测性和安全性。我们探讨了其与 optional<T> 的区别、各种设计决策(如常量性、.value_or 行为),以及如何避免悬垂引用。最后,我们了解了未来的工作方向,包括为关联容器提供更好的查找接口。std::optional<T&> 的引入,是 C++ 在表达“可选引用”这一常见模式上迈出的重要一步。



081:基于概念的泛型编程

在本节课中,我们将学习 Bjarne Stroustrup 在 CppCon 2025 上介绍的基于概念的泛型编程思想。我们将探讨如何利用 C++ 概念来解决语言中的一些历史遗留问题,例如隐式窄化转换和范围检查,并理解概念如何使泛型代码更安全、更高效、更易于编写。
概述:什么是泛型编程?
泛型编程始于 C++ 的早期,并在 Alex Stepanov 的推动下得到了重大发展。其核心目标是:以最通用、最高效、最灵活的方式表达概念(即思想)。它遵循一些基本原则:不为抽象而抽象,而是为了解决实际问题;从具体、高效的算法中抽象;并且必须保持性能,因为性能在许多 C++ 应用场景中仍然至关重要。
设计准则与目标
上一节我们介绍了泛型编程的核心理念,本节中我们来看看其具体的设计准则。早在1994年,就为泛型编程(特别是C++模板)设定了三个设计标准:
- 必须足够通用:能够表达超出设计者想象的内容。
- 必须具有不妥协的效率:在各自领域(如系统编程、线性代数)与黄金标准语言(如C、Fortran)竞争。
- 必须静态检查接口:提供编译时类型安全。
此外,它还应易于使用,不需要顶尖的计算机或漫长的编译时间,并且必须是可教授的,不能要求开发者都是 MIT 的博士。
从问题出发:隐式窄化转换
要讨论一个特性,最好从一个具体问题开始,而不是空谈语言特性和理论。我们面临的一个经典问题是 C/C++ 中的隐式窄化转换,即在整数之间、浮点数与整数之间可以隐式地双向转换,这常常导致令人意外的错误。
以下是几个会让人“惊喜”的例子,那些不感到惊讶的人,可以把工作交给那些会感到惊讶的人。我们看到了这些 Bug,现在尝试消除它们。
定义算术类型概念
首先,我们只处理算术类型。为此,我们定义一个概念。概念是一个谓词,一个在编译时可求值、以类型为参数并返回布尔值的函数。
以下是一个示例概念 Number,用于判断一个类型是否是标准定义的整型或浮点型:
template<typename T>
concept Number = std::integral<T> || std::floating_point<T>;
std::integral 和 std::floating_point 是标准库中定义的概念,对应于 C 语言早期就存在的类型分类。
判断窄化转换
接下来,我们需要判断两种类型 T 和 U 之间,一个 U 类型的值赋值给 T 类型时是否会发生窄化(信息丢失)。这需要将语言标准中冗长、复杂的规则翻译成代码。
核心逻辑是:首先在编译时判断是否可能窄化(例如,目标类型太小、符号混合、浮点数转整数等)。只有在可能窄化的情况下,才在运行时检查具体的值。这符合泛型编程的效率准则:不做不必要的工作。
如果发生窄化,我们抛出一个异常。在真实代码中,经过检查后,窄化是极不可能发生的,这正是异常处理的适用场景。
创建安全的 Number 包装类型
然而,显式调用检查函数无法保证一致性。我们需要一种机制来保证转换总是安全的。因此,我们定义一个包装类型 Number<T>。
Number<T> 是一个 T 类型,但在创建时,其初始化器必须能无损地转换为 T。这样,我们就能写出看起来和普通代码一样,但具有安全保证的代码:
Number<unsigned int> u = 42; // 正确
Number<unsigned int> u2 = -1; // 编译时或运行时错误(抛出异常)
为安全类型定义运算
当然,数字类型需要算术运算。我们为 Number 类型定义运算符,例如加法:
template<Number T, Number U>
auto operator+(const Number<T>& a, const Number<U>& b) {
using CommonType = std::common_type_t<T, U>;
return Number<CommonType>(a.get() + b.get()); // get() 返回底层 T 值
}
我们使用标准库的 std::common_type_t 来确定运算结果的公共类型。每个运算符大约只需五行代码,并不复杂。
我们同样需要修复比较操作中的经典问题,例如 -1 < 2u 在 C++ 中为 false(因为 -1 会被转换为一个很大的无符号数)。我们为 Number 类型定义比较运算符,当涉及符号混合比较时,进行逻辑正确的值比较。
至此,我们拥有了一套规则,它本应在 1972 年就内置到 C 语言中。虽然历史原因导致我们至今仍受其困扰,但通过一个仅约 37 行代码的小型库,我们可以在自己的代码中消除这些问题。
解决另一个问题:范围检查
上一节我们解决了算术转换的安全问题,本节中我们来看看另一个常见问题:范围错误。一个典型的例子是使用 span(表示一段连续内存的指针和长度)时,由于符号转换导致的错误。
例如,一个 size 被误输入为负数,在转换为无符号数后变成一个巨大的正数,导致 span 无法进行有效的范围检查。
定义安全的 Span 类型
现在,我们可以利用处理好的内置类型(安全的 Number)来捕获所有这类错误。我们首先定义一个 Spanable 概念,它代表一个连续的、可获取大小和数据的范围(类似于标准库的 std::ranges::contiguous_range)。
然后,我们定义自己的 MySpan。它的构造函数只接受 Spanable 类型来初始化。其下标运算符 operator[] 接受一个 Number 类型的索引,并在访问前执行范围检查。这样,无论是负数还是超界的正数,都会被捕获。
利用类型推导简化代码
编写 MySpan<double> 和 MySpan<int> 很快让人感到繁琐。我们可以使用类型推导(CTAD,类模板参数推导)来简化:
template<Spanable R>
MySpan(R&& r) -> MySpan<std::ranges::range_value_t<R>>;
现在,我们可以写出非常简洁的代码:
std::vector<int> vec(100);
MySpan s1(vec); // 自动推导为 MySpan<int>
for (auto& x : s1) { ... } // 像使用现代C++范围一样方便
这体现了泛型编程的一个优点:无需重复编译器和你自己已经知道的信息。
扩展 Span 功能
一个基本的 Span 还不够,我们还需要获取子区间、从指针构造等功能。这些函数同样利用 Number 类型进行安全的边界检查,并通过类型推导来简化接口。例如,从一个指针和计数创建 Span 的代码会非常醒目,便于代码审查和静态分析工具发现潜在问题。
简化 Number 的构造
我们还可以进一步简化 Number 的书写。利用构造函数模板推导,我们可以让编译器自动推导 Number 的底层类型:
Number n1 = 42; // 推导为 Number<int>
Number n2 = 3.14; // 推导为 Number<double>
只有在需要显式指定类型(例如用整数初始化一个 Number<double>)时,才需要写出完整类型。
概念的经典应用:约束算法
人们可能会问,上述例子是真正的泛型编程吗?答案是肯定的。它使用泛型编程技术来解决语言中因历史遗留导致的基本问题。现在,让我们看看概念在经典泛型编程中的应用,例如排序算法。
传统模板的问题
标准库中的 std::sort 接受随机访问迭代器。但如果误用它来排序 std::list(它只提供双向迭代器),编译器会产生极其晦涩的错误信息,因为它只是在实例化模板时发现内部操作不合法。
使用概念约束接口
我们可以使用概念来明确表达接口要求。标准库现在已将迭代器分类等概念编码为代码。我们可以这样约束 sort:
template<std::random_access_iterator Iter, typename Pred>
requires std::sortable<Iter, Pred>
void sort(Iter first, Iter last, Pred comp) { ... }
现在,如果尝试排序 list,编译器会给出更清晰的错误信息,指出 Iter 不满足 random_access_iterator 概念。
引入范围简化用法
但上述写法对“差劲的打字员”和读者都不够友好。我们更希望直接写 sort(vec)。因此,我们引入基于范围的重载版本:
template<std::ranges::random_access_range R, typename Pred = std::ranges::less>
void sort(R&& r, Pred comp = {}) { ... }
这样,代码变得非常直观:
std::vector<double> vd;
std::list<std::string> ls;
sort(vd); // 使用随机访问版本
sort(vd, std::greater<>()); // 降序排序
sort(ls); // 使用我们为前向范围特化的版本(可能拷贝到vector排序再拷回)
概念重载的规则很简单:选择最受约束(要求最严格)的可行函数。因此,对 vector 会调用随机访问版本,对 list 会调用前向范围版本。
深入理解概念
上一节我们看到了概念的应用,本节中我们来更深入地理解概念本身。概念并非 C++ 独有或全新事物。从 C 语言的 int 和 float,到 STL 的迭代器、容器,再到数学中的群、环,概念早已存在。C++ 概念只是让我们能够将这些谓词写入代码。
概念的组合与构建
概念通常由其他概念构建而成。例如,我们之前定义的 ForwardSortableRange:
template<typename R>
concept ForwardSortableRange = std::ranges::forward_range<R> &&
std::sortable<std::ranges::iterator_t<R>>;
这就像编写一个函数,只是语法略有不同。我们也可以从基本语言特性构建概念,例如 EqualityComparable:
template<typename T, typename U = T>
concept EqualityComparable = requires(T a, U b) {
{ a == b } -> std::convertible_to<bool>;
{ a != b } -> std::convertible_to<bool>;
};
requires 表达式是一种底层机制,用于检查某个构造是否有效。如果你在代码中直接大量使用 requires requires,可能意味着你没有认真思考并定义一个具有有意义名称的概念。
使用模式(Use Patterns)
由 Gabriel Dos Reis 引入的“使用模式”至关重要。它允许你仅指定操作必须能进行,而不指定其实现方式(是成员函数、自由函数、是否 const 等)。这提供了接口的稳定性,即使用户重新实现了操作,只要语法有效,代码就依然工作。这对于处理混合模式运算和隐式转换尤其重要。
概念的灵活性
概念是编译时函数,非常灵活:
- 可接受多个类型参数:超过一半的概念需要多个参数来表达类型间的关系。
- 可接受值参数:例如,约束一个缓冲区大小至少为 1K 且是 2 的幂。
- 支持部分约束:初始版本的概念无需完美,可以在开发过程中逐步完善,这很重要。
- 不限于模板参数:可以在任何需要编译时布尔值的地方使用,例如
if constexpr或变量声明。
与面向对象编程的关系
泛型编程和经典面向对象编程(OOP)是互补的。
- OOP 基于类层次和虚函数,提供了运行时多态和稳定的接口,但需要早期设计且可能有效率开销。
- 泛型编程 基于编译时多态,更灵活、效率更高,可以表达更多内容。
两者可以结合使用。例如,一个使用概念的drawable接口,既能处理具有虚函数draw的类层次对象,也能处理任何定义了draw操作符的非继承类型。
高级主题与未来展望
静态反射
C++26 有望引入的静态反射,将允许我们编写更多依赖于编译器类型知识的代码,这是许多泛型编程的基础。例如,生成类的成员描述信息:
struct X { int a; double b; char c; };
auto layout = describe_members<X>(); // 返回一个数组成员描述符数组
这可以在运行时开销极小的情况下,生成以往需要复杂宏才能实现的功能,为序列化、对象映射等场景提供强大支持。
关于“单独编译模板”的思考
一个常见的想法是能否单独编译模板。结论是,至少在现阶段,我们不希望这样。许多工业级代码在泛型函数中加入了日志、调试、遥测等基础设施代码。如果模板被单独编译并严格检查,这些额外的调用会导致编译失败,破坏现有大量代码的接口稳定性。泛型编程必须务实。
总结
本节课中我们一起学习了基于概念的泛型编程。
- 泛型编程就是编程,是以最通用、最高效、最灵活的方式表达概念。
- 它建立在数学思想和 C++ 的统一类型表示、资源管理等基础之上。
- 概念是编译时函数,用于指定泛型函数对其参数的要求,但不指定实现方式。
- 使用概念可以进行重载决议,规则比普通函数重载更简单。
- 基于概念的泛型编程使我们能够在 C++ 默认类型系统之上构建自己的类型系统,例如我们创建的
Number和Span,它们安全地处理了窄化转换和范围检查,且仅在需要时才付出运行时代价。 - 它与面向对象编程互补,两者结合能发挥更大威力。
- 未来的工具(如静态反射)将使泛型编程更加强大。

通过编写和使用良好的、基于概念的库,我们可以使 C++ 代码更安全、更清晰、更高效。
082:C++ 需要一些汇编 🧩


在本节课中,我们将跟随 Matt Godbolt 的演讲,探讨汇编语言在 C++ 开发中的重要性。我们将了解汇编语言的基础知识,学习如何通过工具(如 Compiler Explorer)来阅读和理解编译器生成的汇编代码,并认识到 C++ 生态系统的构建不仅仅是代码本身,还包括社区、工具链和标准化过程。
汇编语言:从机器码到人类可读的文本
上一节我们介绍了本课程的主题,本节中我们来看看什么是汇编语言。
汇编语言是机器码的人类可读表示。机器码是 CPU 直接执行的二进制指令序列(例如 0F AF FF),而汇编语言则使用助记符(如 imul)和标签来代表这些指令和内存地址。两者之间存在一一映射关系,通过汇编器可以将汇编代码转换为机器码,反汇编器则执行相反的过程。
核心概念:
- 机器码:CPU 直接执行的二进制指令。
- 汇编代码:机器码的人类可读形式。
不同架构的汇编初览
了解了基本定义后,我们来看看不同 CPU 架构下的汇编代码有何不同。
我们以一个简单的平方函数为例:
int square(int x) {
return x * x;
}
以下是该函数在三种主流架构(使用 Linux System V ABI)下的汇编输出:
-
x86-64 (Intel):
square(int): imul edi, edi ; 计算 edi * edi,结果存回 edi mov eax, edi ; 将结果移动到 eax(返回值寄存器) ret ; 返回x86 指令通常是双操作数且目的操作数也是源操作数之一(类似
x *= x)。参数通过寄存器传递(edi),返回值必须放在eax寄存器中。 -
AArch64 (ARM):
square(int): mul w0, w0, w0 ; 计算 w0 * w0,结果存回 w0 ret ; 返回ARM 指令更规整(固定4字节长度),参数和返回值寄存器通常是同一个(
w0/x0)。 -
RISC-V:
square(int): mul a0, a0, a0 ; 计算 a0 * a0,结果存回 a0 ret ; 返回RISC-V 的指令集设计也非常规整,参数和返回值使用
a0寄存器。
核心概念:
- 寄存器:CPU 内部的高速存储单元,数量有限,用于存储计算中的临时数据、参数和返回值。
- 调用约定 (ABI):规定了函数调用时参数如何传递(哪些寄存器)、返回值放在哪里、哪些寄存器需要由调用者保存等规则。不同操作系统和架构的 ABI 可能不同。
如何阅读汇编:以 Compiler Explorer 为例
上一节我们看了简单的例子,本节中我们来看看如何借助工具分析更复杂的代码。
Compiler Explorer 是一个将 C++ 代码实时编译并显示对应汇编输出的在线工具。它能高亮显示源代码与汇编代码的对应关系,是学习汇编和编译器优化的绝佳平台。
以下是一个检查字符串是否为16位十六进制标识符的函数:
bool is_valid_id(std::string_view sv) {
if (sv.size() != 16) return false;
constexpr std::string_view valid_chars = "0123456789ABCDEF";
for (char c : sv) {
if (valid_chars.find(c) == std::string_view::npos) {
return false;
}
}
return true;
}
在 Compiler Explorer 中观察此代码:
- 优化级别的影响:使用
-O0(无优化)时,代码非常直接,可能包含对std::string_view::find(即memchr)的调用和显式循环。使用-O2或-O3时,编译器可能展开循环、内联函数调用,甚至使用位掩码等技巧进行向量化优化,代码会变得更高效但也更复杂。 - 识别模式:
- 循环:寻找一个标签(如
.L4)以及跳转到该标签的指令(如jne .L4),这通常构成循环体。 - 条件分支:
cmp(比较)指令后跟je(相等则跳转)或jne(不相等则跳转)等条件跳转指令。 - 函数调用:
call指令。
- 循环:寻找一个标签(如
- AI 辅助解释:Compiler Explorer 集成了 AI 功能,可以尝试解释汇编代码在做什么。但务必谨慎对待其输出,需要人工核实,就像演讲者分享的“盲目相信导航导致汽车卡在小路上”的故事一样,技术工具可能出错。
C++ 的“组装”:超越代码的生态系统
上一节我们关注于代码层面的汇编,本节我们将视野放宽,看看“组装”C++ 程序所需的更大生态系统。
“Assembly”一词也有“组装”的含义。构建一个 C++ 项目远不止写代码和编译,它涉及一系列组件和过程的协作。
以下是构建 C++ 项目所需的关键组件:
- 核心库:
- 标准模板库 (STL):由 Alexander Stepanov 创立,奠定了 C++ 泛型编程的基础。
- Boost:高质量的第三方库集合,许多 C++ 标准库功能源于此。
- Bloomberg BDE、Facebook Folly 等:大型公司内部开发的高质量库。
- 工具链:
- 编译器:GCC, Clang, MSVC 等。
- 构建系统:CMake, Bazel, Meson, Make 等,用于管理复杂的编译和链接过程。
- 包管理器:Conan, vcpkg, CPM 等,用于获取和管理第三方依赖。
- 编辑器/IDE:Visual Studio, CLion, VS Code, Vim/Emacs 等,提供编码环境。
- 基础设施工具:调试器 (GDB, LLDB)、静态分析器、代码格式化工具 (clang-format)、文档生成器 (Doxygen) 等。
- 人:最重要的因素:无论是开源项目还是公司内部项目,让代码易于获取、构建和理解,能鼓励更多人参与贡献和修复。友好的社区和文档至关重要。
C++ 的“立法机构”:标准化过程
上一节我们讨论了技术组件,本节我们来看看指导 C++ 发展的“立法”过程——标准化。
C++ 的标准由 ISO/IEC JTC1/SC22/WG21(通常简称为 WG21 或 C++ 标准委员会)制定。这是一个严谨但相对缓慢的过程。
一个特性进入 C++ 标准的大致流程如下:
- 提交提案(论文)到相关邮件列表。
- 在相应的研究组 (Study Group, SG) 或演进工作组 (Evolution Working Group, EWG/LWG) 中讨论和修改。
- 进入核心工作组 (Core Working Group, CWG) 或库工作组 (Library Working Group, LWG) 进行标准文案的精确制定。
- 最终在全体会议 (Plenary) 上投票表决。
这个过程保证了语言的稳定性和向后兼容性,但同时也意味着新特性的采纳需要时间和广泛的共识。我们应该感谢委员会成员及其赞助公司付出的巨大努力。

C++ 的“集会”:社区的力量
上一节我们了解了官方的标准化组织,本节我们来看看充满活力的 C++ 社区。
“Assembly”也指“集会”。C++ 社区通过线下和线上的各种聚会紧密连接。
以下是参与 C++ 社区的方式:
- 线下会议:像 CppCon 这样的大型国际会议,以及众多区域性会议,提供了学习前沿知识和进行“走廊交流”的机会。
- 本地线下聚会 (Meetup):全球有上百个 C++ 用户组,定期举办技术分享或社交活动,是结识本地同行、深入交流的好地方。
- 线上社区:Discord、Slack 频道、Reddit (r/cpp)、C++ Forum 等平台提供了随时交流的空间。Compiler Explorer 也有自己的 Discord 社区。
- 协作网站:如 cppreference.com(权威的 C++ 参考网站)和 Compiler Explorer 本身,都是社区驱动、共同维护的宝贵资源。
积极参与社区,无论是组织聚会、在会上演讲、开源项目,还是参与标准委员会,都能让你为 C++ 生态做出贡献,并从中获益。
总结与行动号召 🥁
本节课中,我们一起学习了“汇编”对于 C++ 的多重含义:
- 汇编语言:理解编译器生成的汇编代码,是进行底层调试、性能分析和深入理解 C++ 行为的关键技能。工具如 Compiler Explorer 极大地降低了学习门槛。
- 组装过程:构建现代 C++ 项目是一个复杂的系统工程,涉及编译器、库、构建工具、包管理器以及最重要的——开发者之间的协作。
- 标准制定:C++ 语言通过一个严谨的国际化标准流程演进,这保证了其稳定性和广泛适用性。
- 社区集会:强大而活跃的社区是 C++ 生命力的源泉。通过参与会议、线下聚会和线上讨论,我们可以互相学习,共同推动 C++ 向前发展。

C++ 的成功需要所有这些层面的“组装”。正如演讲者所言,这是“召集 C++ 编码者集合”的时刻。无论你是通过贡献代码、完善文档、组织活动还是参与标准化,每个人都可以为这个社区添砖加瓦,让 C++ 变得更好。
083:缓存机制与性能提升


概述
在本节课中,我们将学习C++性能优化中的一个核心概念:缓存机制。我们将探讨为什么理解硬件缓存对编写高效代码至关重要,以及如何在日常编程和代码审查中应用这些知识来提升程序性能。
上一节我们介绍了性能优化的基本理念,本节中我们来看看硬件缓存如何直接影响代码效率。
访谈内容整理与分析
以下是CppCon 2025访谈中,彭博社工程师Michelle分享的关于缓存与性能的见解。
Michelle是彭博社的软件工程师,负责开发C++订单交易录入修改系统。她也是C++ Guild的活跃成员和技术代表。
去年,她在CppCon上做了关于返回值优化(RVO)的演讲。今年,她的演讲主题将围绕缓存机制展开。
她认为,理解底层硬件对于真正理解自己的代码至关重要。她表示,缓存相关的知识应该被所有程序员了解,即使他们并非专门从事性能优化工作。
在编码和代码审查时,她始终将缓存效率放在心上。她提到,自己曾经因为不了解这些知识而感到惭愧,并希望更早地学习它们。
关于自定义数据结构,她指出,有时使用或创建自定义数据结构可以复用优化模式。许多第三方库和未来可能加入标准库的组件都体现了这种思想。
然而,是否进行高度定制化取决于具体用例、可用时间以及项目目标。例如,在凌晨紧急修复服务器问题时,可能不是优化缓存效率的最佳时机。
她强调了可观测性和测量的重要性。如果不进行测量,就无法确定问题是否真实存在。
核心概念与行动指南
基于访谈内容,我们提炼出以下核心要点和行动建议。
理解缓存的重要性
公式:程序性能 ≈ 算法效率 × 缓存利用率
不了解底层硬件的程序员,无法真正理解自己代码的性能表现。缓存是现代CPU架构的核心部分,其访问速度远高于主内存。未能有效利用缓存是导致程序性能低下的常见原因。
日常编码与审查中的缓存思维
在编写和审查代码时,应有意识地思考数据访问模式。以下是一些指导原则:
- 局部性原理:尽量让相关数据在内存中彼此靠近,以提高缓存命中率。
- 减少缓存失效:避免不必要的内存跳跃访问,这会导致缓存行被频繁换出。
- 数据结构选择:根据访问模式选择合适的数据结构。例如,对于顺序访问,数组通常比链表更高效。
自定义优化与实用主义的平衡
是否进行深度优化取决于具体情境:
- 使用现有优化库:优先考虑使用实现了缓存友好数据结构的第三方库或公司内部库。
- 评估投入产出比:在项目时间充裕、性能瓶颈明确且优化收益显著时,才考虑实现高度定制化的数据结构。
- 区分场景:在需要快速修复线上问题的紧急情况下,应以功能正确为首要目标,而非性能优化。
性能测量是关键
引用:“如果你不进行测量,你就不知道问题是否真的存在。”(源自访谈中对一句名言的转述)
优化必须建立在测量之上。在尝试优化缓存之前,需要使用性能剖析工具来定位真正的热点。盲目优化可能收效甚微,甚至引入新的问题。
总结


本节课中我们一起学习了C++性能优化中缓存机制的核心思想。我们了解到,理解CPU缓存的工作原理对于编写高效代码至关重要。在日常开发中,我们应该培养缓存友好的编程思维,在代码设计和评审时考虑数据访问模式。同时,我们需要在深度优化与项目实际需求之间取得平衡,并且始终牢记:任何优化都必须以可靠的性能测量数据为依据。掌握这些原则,将帮助你构建出更快、更高效的C++应用程序。
084:掌握AVX向量化,实现8倍性能提升
在本教程中,我们将学习如何利用现代CPU的AVX指令集进行向量化编程,以显著提升C++代码的性能。我们将从基础概念开始,逐步深入到高级优化技巧。
概述
现代CPU功能强大,但大多数软件仅使用了其一小部分能力。向量化是提升性能最有效的手段之一,但常被开发者忽视,原因在于其复杂性或对编译器自动优化的依赖。实际上,编译器很少能生成最优的SIMD代码。通过理解AVX内部函数,你可以在最关键代码中实现2倍、4倍甚至8倍的性能提升。这对于金融、游戏、数据中心和嵌入式系统等对性能要求极高的领域至关重要。
课程结构
本课程采用讲座与动手编码相结合的形式。每个主题都从清晰解释内部函数和技术开始,然后你可以在实验练习中立即应用。这种方式确保概念不流于抽象,你可以亲自编写和运行代码。
课程内容循序渐进,从AVX内部函数开始,然后介绍各种向量化模式和技术。
核心概念与技术
以下是本课程将涵盖的核心内容:
-
AVX内部函数基础
我们将从最基础的AVX内部函数学起,理解如何用它们操作向量数据。例如,加载数据到向量寄存器可以使用_mm256_load_ps函数。__m256 vec = _mm256_load_ps(float_ptr); -
向量化模式与技术
学习如何将常见的标量操作(如循环)转换为高效的向量化操作。核心思想是使用SIMD指令同时处理多个数据元素。 -
向量化障碍与解决方案
探讨阻碍编译器自动向量化的常见因素(如数据依赖、条件分支),并学习如何重构代码以消除这些障碍。 -
高级性能调优
为了达到显著的性能提升,我们需要深入高级主题:- 内存子系统调优:优化数据布局和访问模式以提升缓存效率。
- 打破依赖链:重组计算以减少指令间的顺序依赖,提高指令级并行度。
- 避免CPU端口拥塞:理解CPU执行单元,平衡指令混合以避免特定端口成为瓶颈。
目标受众与先决条件
本课程面向具备中级C或C++知识的开发者,旨在帮助你编写更快的软件。你不需要有SIMD编程经验,一切将从零开始。无论你身处金融、游戏、数据中心应用还是嵌入式系统领域,我们所涵盖的技术都能直接应用于现实世界中性能关键的软件。
讲师介绍
我是Ivica Bogosavljević,将主导本次AVX向量化研讨会。在过去的15年里,我专注于嵌入式系统和高性能C++编程,致力于从现代CPU中榨取每一分性能。
总结


在本节课中,我们一起学习了AVX向量化编程的概览、核心技术和课程结构。通过掌握这些知识,你将获得解决实际性能问题的能力和信心。希望你能充分享受学习过程,并在实践中运用这些强大的工具。期待在课程中见到你!
085:课程概述

在本节课中,我们将跟随CppCon讲师Assaf Tzur-El,一起揭开C++内存管理的“隐藏秘密”。我们将探讨从源代码到机器码的整个执行过程,理解内存布局、编译链接等底层机制,并学习如何利用这些知识编写更安全、更高效的C++代码。
C++底层机制:P85:课程介绍与讲师背景
欢迎回来。我是Kevin Carpenter,今天与Assaf Tzur-El一起交流。我们将讨论他今年教授的C++底层机制预会议课程,他还会进行一次演讲。我很兴奋,因为这是我第一次采访Assaf,非常感谢你今天抽出时间。最近怎么样?
很好,期待这次采访。Assaf拥有丰富的经验,粗略地说有30年。他的职业生涯始于15年的开发工作,从C++开始,也涉足C#、Java等类似C的语言。15年后,他意识到自己已经为超过10家公司工作过,并开始感到厌倦。这个认识花了他15年时间。
他开始自我探索,尝试各种事情,最终发现自己转了一圈又回到了原点,但现在他成为了一名自由职业者,额头上写着“顾问”二字。这意味着他不再换工作,而是换项目,这很好。在探索期间,一位朋友问他是否愿意教授编程课程,他答应了。他热爱教学,学生们也没有太多抱怨,从此他便一直从事教学工作。他的首批课程是C++,随后也包括C#、Java、设计模式和架构等内容。教学至今已有15年,总从业经验接近31年。
除了编程,Assaf还有一个爱好:自2008年起,他自愿担任FIRST机器人竞赛(FRC)的裁判。他通常会随身携带一些相关物品。
C++底层机制:P86:课程价值与核心内容
上一节我们介绍了讲师的背景,本节中我们来看看这门课程的核心价值与内容。理解C++的底层机制如何帮助开发者?
首先,有些知识是必须了解的。例如,栈并不存在,堆也是想象出来的,因为C++标准并未提及栈或堆。这听起来有些深奥。更有趣的是,你不能依赖在编译、运行或调试代码时学到的某些知识。另一方面,如果你了解一些底层知识,你可能会成为更好的调试者。
Assaf发布了一个关于未初始化变量的短视频预告。未初始化变量本应包含“垃圾值”,即分配这块内存之前就存在的数据。但如果你使用当今市场上的一些调试器观察,会发现这些所谓的随机位其实并不随机。这是有原因的。了解这个原因,你就能判断它是否随机。此外,虽然栈在标准中不存在,但在大多数系统中,栈是反向增长的。如果你有 int i 和 int j,i 的地址会在 j 之后。这在调试时是一个有用的知识,可以避免对此感到惊讶。
这门课程将大量使用Visual Studio进行教学,因为正如其名,它是可视化的。它可以展示许多内容,而不仅仅是讲述栈的原理,还能实际展示内存中的比特位。即使对不熟悉它的人来说也易于操作,因此鼓励学生在课堂上至少使用它。课程甚至会深入汇编语言。我们需要了解汇编,因为C++很美好,但实际运行的是操作码,我们需要意识到这一点。
这门课程可以看作是从源代码到机器在“金属”上如何运行的旅程,并使用工具来观察这一过程。我们将查看代码及其生成的汇编指令,观察内存布局和内存段(如栈和堆),并研究构建过程,包括预处理器、编译器和链接器。例如,我们将学习如何从编译器的视角看代码,因为开发者看到的是文件,而编译器只看到一个编译单元。因此,课程将涵盖运行时内容、编译时内容,甚至编辑时内容,这将是非常充实的一天。
C++安全编程:P87:安全实践与MISRA框架
上一节我们探讨了底层机制,本节我们将转向安全编程实践。C++本质上是一种“有风险”的语言吗?Assaf的演讲涉及MISRA框架,这通常让人联想到嵌入式领域。那么,MISRA框架是否适用于所有C++项目,还是主要停留在嵌入式空间?
这是一个很好的问题。Assaf有一整个讲座是关于我们程序员的。我们的报酬不是写更多代码,而是生产高质量、可工作的软件。如果你想写更多代码,尽管去敲键盘。但如果你的目标是编写高质量的软件,就意味着有时你必须放慢速度,以避免重复劳动、回头调试,或者避免客户因为代码崩溃等问题而对你大喊大叫。
因此,我们需要一些“防护栏”,因为语言本身显然没有提供足够的防护,而它提供的一些防护(例如不允许向常量赋值)又通过 const_cast 等方式被削弱了。所以我们需要外部的规则。像SOLID原则、设计模式、整洁代码等概念都很好,但我们需要更严格的规则。例如,就像在幼儿园时老师告诉我们永远不要使用 goto 语句一样。不用 goto 并不是什么沉重的负担,但它能让你的代码结构更清晰,减少错误和内存分配/释放等问题。
这只是一个遵循特定规则能使你成为更好程序员、写出更好代码的小例子。理解紧密耦合的问题也是如此。早期职业生涯中,可能会自然地写出紧密耦合的代码而不自知。学习测试驱动开发(TDD)后,仅仅因为需要分离代码并编写测试用例,就能帮助你看到紧密耦合的样子、它导致的问题以及后果。因此,拥有额外的“防护栏”来帮助我们更好地工作和思考是很有益的。
现代C++的安全性:P88:现代特性与遗留代码挑战
上一节我们讨论了安全框架,本节我们聚焦于现代C++语言本身的安全性。如果我们使用C++17及以后的标准进行编码,例如不使用原始指针,大量使用标准库,那么C++是否还像以前那样本质上不安全?
从C++11开始,实际上使C++安全了许多,或者说为编写安全代码提供了可能。因为你可以使用智能指针等特性,再也不需要使用 new 了。但这只是问题的一部分,还有许多其他问题。你仍然可能在 switch-case 中忘记 default 分支,这更像是C语言的习惯。因为C++,正如Scott Meyers曾经指出的,他过去常听说C++是一门“未来在前方”的语言,大部分C++代码尚未写出。但他说他已经很久没听到这种说法了,而且是在他退休之前说的。
C++背负着很多历史包袱,有大量的遗留代码。这些代码使用 new,可能使用 goto,等等。因此,我们需要能够处理旧代码,需要与使用旧代码思维的人共事。Assaf认识一些人仍在用C++98工作。所以,我们都需要更多的“防护栏”来帮助我们写出更好的代码。这并不可耻,不像一个成年人为了更安全而去骑儿童三轮车。我们是成年人,这没关系。但如果能为我们的C++代码加上一些防护栏,他认为这是一件好事。
课程总结与预告
本节课中,我们一起学习了C++内存管理的核心秘密。我们从了解底层机制(如内存布局、汇编)的重要性开始,探讨了如何利用这些知识进行更好的调试。接着,我们讨论了通过MISRA等框架和编码规范为C++代码添加“防护栏”的必要性,即使在使用现代C++特性时,处理遗留代码和避免常见陷阱依然至关重要。


Assaf Tzur-El的“C++底层机制”课程将于9月14日(周日)开课,而他的演讲“编写安全C++的实用方法”则在次日(周一)。对于希望深入理解C++运行机制和提升代码安全性的开发者来说,这是绝佳的学习机会。我们期待在CppCon上相遇,继续深入探讨内存管理等话题。
086:从Barry Revzin的演讲看反射的潜力

在本节课中,我们将通过整理Barry Revzin在CppCon 2025演讲中的核心观点,来了解C++反射(Reflection)这一即将到来的强大功能。我们将探讨反射如何改变我们编写代码的方式,并重点分析一个具体示例:如何利用反射轻松实现结构数组(Struct of Arrays)的数据布局转换。
概述:为何反射令人兴奋
C++26标准预计将引入反射功能,这被许多专家视为语言历史上最具变革性的转折点。反射允许程序在编译时检查和操作自身的结构,这将开启元编程和库设计的新纪元。
上一节我们介绍了反射的宏观意义,本节中我们来看看专家Barry Revzin分享的具体见解。
从Stack Overflow到标准委员会
Barry Revzin是Jump Trading的高级C++开发者,以其在Stack Overflow上的活跃解答而闻名。他参与C++标准委员会工作始于2016年,这被他视为逻辑上的下一步。通过不断解答问题,他深入理解了语言标准,并最终参与到修改和完善语言的工作中。
参与标准委员会工作是一项需要大量时间和精力的技能,就像其他技能一样,需要通过不断实践来提升。Barry早期的提案和现在的提案在结构和论证上已经有了显著进步。
反射的核心应用示例:结构数组(SoA)
在演讲中,Barry没有着重探讨结构数组的性能优势,而是将其作为一个展示反射能力的完美案例。结构数组是一种数据布局方式,与数组结构(AoS)相对,能更好地利用缓存,提升性能。
传统上,将代码从数组结构重构为结构数组是一项繁琐的工作,开发者需要手动重写大量代码才能验证其收益。然而,反射使得这一过程变得极其简单。
以下是反射带来的改变的核心对比:
- 传统方式(繁琐):手动重写数据结构,涉及多个向量和大量代码修改。
- 反射方式(简洁):可能只需一行代码更改,即可尝试新的数据布局。
Barry以Zig语言为例,说明只需将 ArrayList<T> 改为 MultiArrayList<T> 即可实现结构数组。C++反射的目标正是让这种便捷性成为现实。
通过实现这个具体示例,可以触及反射的多个方面,帮助开发者理解这套全新的“工具箱”能做什么。
反射的深远影响:超越序列化
很多人最初将反射与自动序列化/反序列化(如JSON)联系起来,这确实是其一个重要应用。但反射的潜力远不止于此,它可能只揭示了不到10%的可能性。
反射最令人兴奋之处在于,它允许我们以库的形式实现许多过去只能通过编译器扩展来完成的功能。这为语言特性的标准化开辟了一条新道路。
以下是反射将带来的几个关键变化:
- 库实现的编译器功能:例如,扩展可作为非类型模板参数的类类型范围。目前标准只支持所有成员均为公开的类型,而利用反射,可以在库层面实现对
std::string、std::vector等任意类型的支持。 - 降低新特性标准化门槛:新的语言特性很难在标准化前获得广泛的使用经验。而基于反射的库则更容易被开发者采纳和使用,丰富的使用经验反过来可以有力地推动该特性成为正式的语言标准。
- 激发社区创新:就像
std::meta::substitute这样最初动机不明的函数,后来被发现是反射库中最有用的工具之一。随着编译器实现反射,成千上万的开发者将探索其可能性,必将涌现出意想不到的创新应用。
总结
本节课中我们一起学习了C++反射的核心前景。我们了解到,反射不仅仅是关于自动序列化的便利工具,它更是一套强大的元编程基础设施,能够:
- 极大简化特定编程模式(如数据布局转换)的实现。
- 允许以库的形式实现过去需要编译器支持的功能。
- 改变语言新特性的孵化和标准化流程。



C++26的反射功能标志着我们刚刚开始探索一个充满可能性的新领域,未来的发展令人无比期待。
087:安全高效的嵌入式系统C++培训课程概述

在本节课中,我们将一起学习由拥有20多年经验的专家Andreas Fertig主讲的嵌入式系统C++开发课程的核心内容。课程将聚焦于如何在资源受限的嵌入式环境中,运用现代C++编写既安全又高效的代码。
C++ 嵌入式开发:P87-1:嵌入式环境下的C++挑战与标准
欢迎回到课程。我是Kevin Carpenter,在CPPCon活动现场,我身边的是Andreas Fertig。Andreas将在本次CPPCon上讲授一门关于嵌入式环境下安全高效C++的课程。这门课程是2025年的新增内容,旨在为嵌入式开发提供最新的实践指导。
Andreas是一位C++培训师、顾问和专家,拥有超过20年的经验。他也是C++标准委员会的成员,参与了语言核心的制定过程。他著有关于协程的书籍,并经常进行“回归基础”系列演讲,例如关于constexpr的主题,这些演讲旨在将复杂概念分解为易于初学者理解的部分。此外,他还开发了C++ Insights工具,这是一个非常出色的代码分析工具。
嵌入式标准与性能的平衡
在嵌入式开发中,我们常常面临各种行业标准,如MISRA、AUTOSAR和功能安全标准(如ISO 26262)。这些标准旨在引导开发者编写安全的C++代码,避免未定义行为和意外行为,为使用C++提供一条安全的路径。
然而,这些标准有时可能与追求极致性能或特定优化产生冲突。例如,为了遵循某条规则,我们可能无法写出针对特定场景的最优代码。但重要的是,这些标准通常不是绝对强制性的。以MISRA为例,它允许开发者在提供充分理由的情况下,豁免某些规则。在实际代码库中,经常可以看到开发者注明“此处不完全符合MISRA,但我们清楚自己在做什么”的情况。这是一种设计决策,旨在保证代码主体符合标准的同时,在特定行进行合理的变通。
这种灵活性在不同行业中有不同的体现。许多公司将遵循MISRA等标准视为一种良好的实践指南,但并非强制要求。而在医疗、汽车、航空等受严格监管的领域,情况则完全不同。在这些领域,遵循标准并生成大量文档是强制性的。标准提供了可静态检查的规则,辅以工具链支持,构成了合规开发流程的基础。如果决定不遵循某条规则,必须提供强有力的理由和详细的论证文档。
C++ 嵌入式开发:P87-2:嵌入式系统中的内存管理
上一节我们讨论了嵌入式开发中的标准与平衡,本节中我们来看看嵌入式系统的核心挑战之一:内存管理。在嵌入式环境中,内存资源通常非常有限,并且经常对堆内存的使用有严格限制。
智能指针在无堆环境下的应用
一个常见的问题是,在没有动态堆内存分配的系统中,如何使用std::unique_ptr?关键在于理解RAII(资源获取即初始化)原则。std::unique_ptr管理的并不一定是动态分配的内存,它管理的是任何需要独占所有权并自动释放的资源。
许多嵌入式系统从外部看似乎“作弊”了。它们确实进行动态分配,但分配来自一个固定大小的内存池。系统预先知道应用程序或任务需要多少内存块,通常会分配双倍以确保安全,然后仅从这个预分配的池中进行分配。通过为不同任务或不同网络数据包大小设置不同的内存池,可以更好地控制内存使用。虽然每次分配可能会过度分配(使用固定大小的块),但目标是永远不出现内存耗尽的情况,从而提高系统的可靠性和时序可预测性。在这种情况下,std::unique_ptr依然有用武之地,它可以确保本地资源不会泄漏,或者管理那些可能随时插拔的设备资源。
相比之下,std::shared_ptr在无堆环境中更为复杂,因为它需要一个控制块来管理引用计数。如果不修改标准库,很难让std::shared_ptr仅使用指定的内存池。
标准库容器的使用策略
对于std::vector或std::string这类可能在堆上分配内存的标准库容器,在嵌入式系统中的使用策略通常如下:
- 完全避免动态分配:在物理尺寸和规格极其有限的设备上,可能无法承担动态分配的开销,甚至无法承担管理分配器(如
libc)本身的开销。 - 启动阶段分配:许多系统允许在应用程序启动的初始化阶段进行动态分配。在这个阶段分配好整个运行时所需的所有内存(包括用于
std::vector的预留空间),之后程序便基于这些预分配的资源运行。然而,这种方法在实践中相对少见,因为std::vector在空间不足时仍可能抛出异常,而某些嵌入式领域无法容忍异常。
C++ 嵌入式开发:P87-3:异常处理与代码现代化
上一节我们探讨了内存管理的策略,本节中我们来看看异常处理和现代C++对嵌入式开发的影响。在嵌入式系统中,硬件约束和异常处理机制紧密相关,直接影响我们的编码方式。
嵌入式环境中的异常处理
在嵌入式系统中,异常的使用更具挑战性。C++异常的一个问题是,你无法确保在某个点能捕获或处理所有异常,新的异常可能会直接越过你的处理代码。
在许多嵌入式应用场景中,尤其是医疗设备(如病人监护仪),当发生错误时,无法像桌面程序一样弹出一个对话框让用户重试。通常的处理方式是直接重启设备,并期望不会再次遇到相同错误。在这种情况下,异常机制(向上抛出调用栈并由其他部分处理)并没有太大帮助,因为通常没有更好的处理者——错误发生了,除了重启别无他法。
因此,许多嵌入式错误条件更像是断言(assertions),它们本不该发生。如果发生了,可以将其转换为异常,但这通常不会改变最终结果。这解释了为什么异常在嵌入式领域接受度较低。
现代C++带来的改变
那么,现代C++是否改变了嵌入式开发的思维方式?它带来的主要优势体现在代码可维护性和简洁性上,这与非嵌入式环境是相似的。
现代C++标准(C++11及以后)让我们能用更少的代码实现相同的功能。代码越少,出现潜在缺陷的机会就越少,这对于需要长期维护的嵌入式代码库至关重要。在受监管的行业,经过充分测试的代码可能会存活20到30年,重写这些代码的风险很高,最好的策略往往是用更清晰、更安全的方式维护它们。
此外,编译时计算(constexpr)能力是一个巨大优势。能够将工作从运行时转移到编译时,意味着可以为产品腾出更多的运行时资源和内存空间,从而可能增加新功能或延长电池寿命。随着嵌入式设备(如智能手机)的能力越来越强,资源虽然仍受限制,但也在不断增长,现代C++的特性正好能帮助我们更高效地利用这些资源。
C++ 嵌入式开发:P87-4:课程总结与实践建议
在本节中,我们将对课程进行总结,并为希望进入或提升嵌入式C++技能的开发者提供建议。
实践驱动的学习方式
本课程强调动手实践。学员需要携带笔记本电脑,通过实际编写代码、解决问题来进行学习。这种“心智研磨”的过程是知识内化的关键。观看脑外科手术不会让你成为外科医生,同样,仅仅听课也无法掌握嵌入式开发的精髓。本课程提供了一个安全的环境,让学员可以在不损害实际嵌入式设备的情况下进行练习和实验。
课程目标受众
这门课程适合以下几类开发者:
- 希望从C++转向嵌入式的开发者:课程可以作为进入嵌入式领域的“训练营”或快速入门指南。
- 已有嵌入式经验的开发者:课程将帮助你提升技能,学习如何应用现代C++最佳实践来改进现有代码。
- 从C语言转向C++的嵌入式工程师:课程将讨论对你而言可能较新的概念和特性,帮助你平滑过渡。
无论你属于哪一类,都能从这门课程中收获有价值的知识。
核心要点回顾
本节课中我们一起学习了以下核心内容:
- 标准与实践的平衡:在嵌入式开发中,需要理解并灵活运用MISRA等行业标准,在安全性与性能之间找到平衡点,特别是在受监管的领域需注重文档和合规。
- 受限环境下的内存管理:可以通过预分配内存池的策略来模拟动态内存,并合理运用
std::unique_ptr等RAII工具管理资源,同时需谨慎使用可能引发堆分配的容器。 - 异常处理的局限性:在嵌入式系统中,异常并非主要的错误处理机制,许多错误条件更适用于断言或直接重启策略。
- 现代C++的价值:现代C++通过减少代码量、增强类型安全和提供编译时计算能力,显著提高了嵌入式代码的可维护性、安全性和运行效率。


如果你有兴趣深入学习,可以关注CPPCon等相关会议,并参与像Andreas Fertig主讲的这类专注于嵌入式系统安全高效C++开发的实践课程。
088:宣布CppCon 2025 🎉

在本节中,我们将了解CppCon 2025大会的官方宣布,并探讨为何参与此类社区活动对C++开发者至关重要。CppCon是全球顶级的C++开发者大会,汇聚了来自世界各地的专家、程序员和爱好者。
根据我的经验,CppCon是参与C++社区活动的最佳平台之一。我认为它是最出色的会议之一。我感觉它的影响力现在比以往任何时候都更加强大。
🏆 CppCon的独特性
上一节我们提到了CppCon的影响力,本节中我们来看看它具体独特在何处。
CppCon的独特之处在于,它是我参加过规模最大的C++会议。我相信它是全球规模最大的C++会议。它涵盖了极其广泛的经验水平和多元视角。在C++领域内,不同技能背景的人群都有充分的代表性。你可以带着自己在工作领域中遇到的问题,与专家们进行交流。
🤝 建立连接与学习
了解了大会的规模与多样性后,我们来看看参与其中能获得哪些具体收益。
尝试结识新朋友。能够与如此多的人交谈,并与业界同仁建立联系,这种感觉非常棒。从这个会议中获得的最宝贵的东西就是朋友。如果你以一名C++工程师的身份参加这个会议,你和会场里的每一位其他参会者都已经拥有了一种共同语言。这意味着你们可以进行更深层次的交流,因为你们已经有了共同点。
亲自来到这里真的打开了一个充满可能性的世界。我可能会遇到那些只在YouTube视频中见过,或只在互联网、邮件列表中讨论过问题的人。这个领域,即C++领域中最顶尖的人才都在这里。为什么不来到这里,与他们见面,向他们学习呢?我们知道,亲自去做这些事情远比虚拟参与要好得多。
我认为世界上只有少数几个地方和时间,能让如此多充满智慧的人聚集在一起。CppCon就是这样的地方之一,这使它独一无二。
总结

本节课中我们一起学习了CppCon 2025大会的宣布及其核心价值。我们了解到CppCon是全球最大的C++社区盛会,以其庞大的规模、多元的参与者以及深度交流的机会而著称。参与此类线下活动不仅能解决技术问题、向顶尖专家学习,更能与拥有共同语言的同行建立宝贵的连接,这是线上交流难以替代的体验。

浙公网安备 33010602011771号