std::expected+单子,能提高性能吗

标题:Can std::expected with Monadic Operations REALLY Boost Your C++ Code Performance

日期:2025/12/17

作者:Vitaly Fanaskov

链接:https://www.youtube.com/watch?v=cjw26MLaCCc

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


好了,麦克风工作正常吗?好的。我们可以开始了。如果想看清幻灯片或者看清我,大家可以坐近一点。谢谢大家今天来参加我的演讲。

让我们来聊聊 std::expected 的性能以及这种抽象的单子操作(monadic operations)。首先让我自我介绍一下。我叫 Vitaly,在 Remarkable 工作。Remarkable 是一家挪威公司,我们正在制作这种极其出色的电子笔记板(Notapods)。在屏幕上,你们可以看到我们几周前刚发布的最新型号。非常棒的产品,大家可以去看看。在这家公司,我在 App Foundation 团队担任首席软件工程师。我们团队为其他团队提供库和框架,但不直接参与面向用户的功能开发。

关于今天的内容,最开始我打算只谈 expected 以及 expected 的单子操作性能。但当我开始准备时,我意识到如果这太抽象,仅仅挂在文档上,并没有太大意义。这就是为什么我们不仅要研究 expected 的单子操作,还要将其与它最接近的竞争对手进行比较。请耐心听我说,我会告诉你原因。

首先我们会从总体的错误处理开始,因为如果你使用 expected、异常(exceptions)或类似的东西,我假设你需要错误处理或处理一般的意外情况。然后我们会非常简短地谈谈 std::expected 表达式,深入了解 expected 单子操作的内部机制,以了解什么会对性能产生影响,什么不应该产生影响以及类似的事情。

接着我给大家准备了一些基准测试。我把它们放在这里,虽然我不喜欢合成基准测试(synthetic benchmarks),但其中有一些有趣的东西,我也做了一些有趣的观察。所以我打算和大家分享这些。最后,我有几条非常实用的建议,关于你可以改变什么来可能使代码稍微快一点,或者至少减少分配。

让我们从一般的错误处理开始。我的第一个问题是,你们是否或多或少熟悉所有这些错误处理方法的案例,比如返回码?我就不读出来了,你们可以自己看。非常好。几年前我在另一个会议上做过这个演讲。我会分享幻灯片,你们不需要拍照。这只是为了方便大家。这是关于不同的错误处理方法,如果你在这个领域的知识有点生疏了,请看一看。总的来说,我想邀请大家参加我们在奥斯陆附近举办的挪威 NDC TechTown 会议,那是一个很棒的活动。

那么,是什么以及为什么?我要比较一下异常和 std::expected。因为这就好比这两种方法提供了类似的灵活性。你有一个问题,你需要知道发生了什么,而不仅仅是知道问题发生了。它们的使用案例非常相似。我会展示给你们看。有一些代码片段。所以我可以将这两者视为最接近的替代方案。

我知道这可能是一个愚蠢的问题,但大家熟悉异常吗?非常好。那么非常简短地用三张幻灯片来复习一下。什么是异常?就是程序执行过程中发生了计划外的事件。我们创建一个特定异常类型的对象。然后我们通过 throw 指令将这个对象移交给系统运行时。然后运行时会寻找特定的处理程序(handler)。如果在 catch 块中匹配到了处理程序,那就没问题。如果没有,那就不太妙了。那将是终止处理程序(termination handler),你的程序在那之后很可能会崩溃。

关于异常安全有四种情况。我不打算多谈这个。第一种是我们没有异常的情况,使用其他的处理方法。然后我们有强异常保证(strong exception guarantee)。这非常接近事务性的东西,你可以回滚所有内容。然后是基本异常保证(basic exception guarantee)。基本保证有点意思。想象一下,我不确定是什么,比如一个文档或类似的东西。你编辑了这个文档,然后发生了错误。你的程序还在运行,处于良好状态,但你知道你会丢失数据。这就是基本异常保证的内容。这大概是最常见的。还有无异常安全(no exception safety)。那很糟糕。任何事情都可能发生。所以我们不会讨论那种情况。

这是一个非常小的异常示例。一个包含三个元素的 vector。我们试图在这里访问第四个元素,但假设它是第三个元素。在这种情况下,at 会抛出一个异常。我猜我用 GCC 测试过,所以你可以看到类似这样的错误。我们如何抛出异常?它是如何工作的?例如,这个通过索引获取元素的方法或非常接近它的方法。我们检查是否在范围内。如果我们超出范围,我们抛出一个异常。否则,我们使用方括号运算符来获取元素。关于异常就说到这里。

让我们谈谈 expected。另一个问题。这东西普及率低得多,所以我猜我对预期有了底。请先不要把手放下。你们中有谁在生产代码中使用过它吗?好的,很好。谢谢,谢谢。

这是标准库 expected 的一个非常基本的例子。没有自定义实现之类的。我们有一个 expected 盒子。我使用这种符号。所以你可以把这个 expected 想象成一个盒子,你在里面放东西,然后你可以以或多或少常见的方式处理它。我们把 42 放进去。然后我们打印这个值。当一个操作可能失败时,我们使用 expected,就像使用异常一样。我们需要知道为什么这个操作失败了。这不仅仅是一个 optionaloptional 只是携带对象的存在或缺失。但这里有一些数据。比如这里,我们有 widget_error。对吧?从这个错误中,我们可以了解 widget 发生了什么。

如果有错误,我们使用这个 unexpected 来区分什么是错误,什么是值。因为对于标准库 expected,你的值和错误可以拥有相同的类型。如果一切正常,我们就在这里返回一个 widget

如果我们需要使用 expected,我们可以使用常规方法,常规的 if。例如,我们可以检查是否有值。如果有值,就处理它。如果有错误,我们就做别的事情,以某种方式处理它。

还有一种更健壮的方法。几乎是一样的。我们获取 widget。然后有一些单子操作(monadic operations)来进行模拟。如果一切正常,我们就调用这个函数。它接受 widget 作为参数。然后我们可以对 widget 做些什么。例如,我们也可以调用 transform。这个函数不可能失败。我们只是改变类型。或者对对象做些什么。不一定要改变类型。or_else 只是为了从错误中恢复。或者在我们在错误状态时添加一些元数据或做些什么。

我们有四个单子操作。常规的 transform,可以改变值的类型,或者只是调整对象。transform_error 对错误做同样的事情。然后是 and_thenor_else。在 and_then 中,如果操作失败,你不仅可以改变类型,还可以返回 unexpected 对象。or_else 同理。我在这里放了一些稍微不同的东西。这代表你可以在这里改变值的类型,但不能改变错误的类型。因为这有丢失错误的风险。这与 or_else 正好相反。

我不打算谈论太多关于异常本身的内容。去年也是在这里,我做了一个关于现代 C++ 中单子操作的演讲。主要是关于实用性。我们如何使用它,有哪些注意事项等等。这个演讲已经在 YouTube 上发布了。所以你可以去那里观看。这就是我不花太多篇幅谈论单子操作本身以及如何使用它们的原因。

然而,std::expected 有一些相当重要的实现细节。这可能会影响使用此类型的性能。

首先,让我们看看。我们有的类型。有三种类型。一种是 unexpected 类型。你们刚才简短地看到了。那是一个用作包装器或标签的类型。用来区分我们拥有的是错误还是值。所以,不出所料,我们这里有 expected。技术上讲,我们的异常也可以抛出异常。这有点不一致。不管怎样。然后我们有 expected 本身。TET 代表值,E 代表错误。

std::expected 的内部,我们有如下的数据结构。这个,我相信是直接从标准中复制过来的。它的实现几乎就是这样的。如果我没记错的话,是在 GCC 标准库中。我们有一个联合体(union)。包含值和 unexunex 代表错误,unexpected。请注意,它不是 unexpected<E>。对吧?这就是为什么你的值和错误可以拥有相同的类型。

而现在最重要的是,我们有一个布尔标志 has_value。我们必须随身携带它。我们要在每个单子操作中检查它。所以,这已经可以告诉你,每个单子操作至少会有一个 if

这是一个简化版的 and_then。你可以看到。在这里我们至少有两个潜在的问题点。正如我所说,我们有一个 if。它总是检查值标志。第二点,如果没有值,如果有错误,我们将我们的 unexpected 包装进返回类型中。然后我们根据函数返回类型推导出,那应该是 expected<T>(基于某个 T)和相同的错误。然后我们把它沿着链条进一步发送。所以,这里你已经可以看到。我们至少需要做一个 if 检查。而且在这个 unexpected 值上可能有潜在的复制或移动操作。

如果你看看 transform 操作。这也是简化版。在实际实现中有更多的静态核心类型检查。但看起来非常接近。我们有这个 U 类型。那是函数的返回类型。然后我们有 expected。所以这应该包装在这个类型中。因为你可以重新映射类型,但你不能改变错误类型。如果有错误,你必须把它带到更远的地方。这和 and_then 几乎是一样的。所以你首先必须检查值。同样的问题。然后你必须返回你的值。同样包装在返回类型中。

现在是 or_elseor_else 就像是镜像的 and_then。某种意义上。你只需要检查这个。如果有值。你就简单地返回这个值。你根本不调用错误处理程序。但你仍然需要检查标志。你需要检查这个值。并且你需要将你的返回值包装进这个类型中。这里的 std::U

对于 transform_error。这也是 transform 的镜像。很像。所以,我们首先检查。是否有值。如果有值。我们只是把它传递到单子链的更远处。如果有错误。我们只是调用处理程序。然后继续。

所以,这里我们有什么潜在的问题呢?很明显。我们总是要检查 has_value。在每一步都要检查。所以,我们在每个处理程序中都有 if 结构。为了合规性。这意味着我们不能总是进行适当的省略(elisions)或适当的返回值优化(RVO),或者不是我们,而是编译器。它阻止了这一点。

既然我们看过了内部结构,好吧,老实说我们还没看过异常的内部结构,我们也并不打算在这里看,但我们只是看了它是如何工作的。可以说是两个竞争对手。现在让我们比较一下实现。

我这里有一个非常简单的函数 square_root(平方根)。你们可能会看到,这两个函数几乎是一样的。在左边,如果值是负数,我们假设我们无法处理其他情况,如果值是负数,我们只是抛出参数无效。否则,我们就在这里调用标准的 square_root。顺便说一下,请注意这里许多 expected 函数被特意标记为 noexcept。这里的结果类型应该是某种 expected。我有 expected<double, std::string>,因为我用字符串作为错误。在实际代码中,你很可能会使用单独的错误类型,至少每个库或每个组件如此,这取决于你如何处理错误。然后我们只是简单地创建 std::unexpected,如果是负数;如果一切正常,如果我们可以做的话,就计算平方根。

我们如何计算,如何使用它?我的意思是我们可以用不同的方式使用它,但我只是把它放在这里。如果我们有不带 expected 的常规代码,我们可以这样使用。先 log,然后 square_root,然后 log。想象一下我们有所有其他像 log 这样的函数,以此类推,遵循相同的符号。因为要组合单子函数,就在这里,log,然后 and_then square_root,然后 and_then log,你需要它们所有都返回相同的,或者说不是相同的,而是可转换的 expected 类型。否则,你无法改变。

你还应该注意到这里至少有两个隐藏的 if。我们首先在 log 函数中创建对象,然后我们无条件地检查是否有值或者有错误,正如我们在实现中看到的那样。然后我们调用 square_root,然后我们再次检查是否有错误或者有值,然后我们调用 log。所以是两个隐藏的 if

如果出了问题,比如有错误,我们怎么处理所有这些?这些也差不多。在这里,我想补充一点,我们在这里没有使用单子操作或其他东西。想象一下,你生活在这个中间件空间里,你不能再使用单子操作,也不能再使用函数式代码了。例如,你进入了 UI 领域,对吧?你的 UI 框架不是声明式的,不能很好地处理 expected 对象。你需要将错误转换成某种东西。你需要打印它。你需要做类似这样的事情。C++ 最著名的 UI 框架之一可能是 Qt。如果你想将你的逻辑、后端实现与用 QML 编写的 UI 集成,你根本没有很好的抽象来说,好吧,我从这个操作返回一些可转换为 expected 的东西,然后请处理它。此外,UI 工程师,他们通常以完全不同的方式处理错误。这就是为什么在这个特定的例子中,我们只是像最终处理那样处理它。比如说我们做 print_line 或者我们做日志记录。在左边,我们只是捕获参数并打印一些东西。在这里我们不使用单子操作。我们只是检查是否有值,我们打印它。如果有错误,我们也打印它。所以我们保存 UI 应用程序,它会被打印在日志中,你或者工程师或者用户会看到它。

既然我们在谈论性能,我有一些参考数据。我就这样叫它吧。在这里,你真的不需要读它,即使你能读,但我会在下一张幻灯片上有更好的版本。所以这里,例如,你有 if 的正确分支,你只是比较。这是对数刻度。在这里你有第一个 if 的正确分支。在第二行,你有 if 的错误分支,这是一个预测失误。然后在最底部,你有 std::exception,抛出和捕获的成本。总共。它只比线程上下文切换(thread context switch)稍微慢一点。现在请试着计算一下。我不打算把那个搞得太复杂。在你的程序中,仅对于单个抛出和捕获的异常,你可以做多少次 if,甚至是预测失误的 if。再次强调,我们谈论的只是常规异常,没有优化,没有类似的东西。我们谈论的只是常规的单子操作和常规的 expected。结果表明,与仅一次抛出和捕获的异常相比,你可以做大量的单子操作。

这里有一篇相当古老的论文,解释为什么异常会那么慢。我不打算在这里花太多时间。你可以直接去读。我知道有些实现在修复其中的许多问题,但我怀疑我在旁边列出的那些是否被修复了。所以基本上异常是在动态内存中分配的,稍微有点。然后你需要为此使用全局同步原语。这两件事可能是导致即使是异常的存在也慢得多的原因,甚至因为异常并不以这种成本抽象而闻名,对吧?如果没有抛出,就什么都没有,但我是说,还是有一些东西的,对吧?这稍微影响了我将在接下来的几张幻灯片中向你展示的合成基准测试。

现在是做一些基准测试的时候了。但这并不意味着我不给你们看基准测试。我只是想鼓励你们,如果你有任何好的用例,或者你有自己的问题,请做你自己的基准测试。并且用特定的构建类型来做。只要有你的构建类型,特定的发布版(release),或者带有调试信息的发布版模式,因为如果你只在调试(debug)模式下测量任何东西,我假设你不会把调试构建给最终客户。所以这没有太大意义,除非你真的知道你在做什么。显然要使用你声明用于生产的工具链。如果你有定制硬件,你也应该使用这个。

然后数据类型也很重要。我会向你展示一些案例以及数据类型如何影响性能。但基本上你看到了我幻灯片上的第二个红圈,那是你返回类型的地方。那是不容易移动或复制的地方。那可能会产生不良影响。然后你可以有不同数量的元素,不同数量的操作,这也影响性能。所以,是的,我只是想鼓励你们做自己的基准测试。特别是如果你在嵌入式或类似领域工作,例如,我们在做这种小型设备,当我们在桌面模拟器上测试时,那要快得多,但性能结果完全不同。因为硬件工作方式不同,有屏幕,内存更少,而且设备总体上要慢得多。

这是我做基准测试的一台旧家用机器的硬件配置。大概不需要通读它或背下来,我的意思是如果你是专家并且时间就是一切,那这对你有意义,如果这就是数据的话。

那么,如何比较以及比较什么?首先,我们应该选择相对轻量的操作。所谓的轻量操作,我是指在与分支或类似操作相比,该操作不占用大量时间。想象一下,例如,作为一个参考操作,就是我们测量的,我们决定采用 IO 操作。我们只是在磁盘上写一个符号并从磁盘读取一些东西。这意味着我们将测量这个操作的性能,而不是单子操作或类似的东西的性能。恐怕那样我们会得到绝对相似的结果,对于所有数据都是如此。

所以,是的,我不打算按顺序执行多个操作。那是很大的一步,但这绰绰有余。所以,10、100、10k 个操作,这些小的轻量操作,我们要首先在没有错误的情况下运行,即没有发生错误。我们只是连续运行 10、100、10k 个操作来看看结果。

然后,有三种最常见的用例。第一个用例是,我们在链的最开始发生了一个错误。如果是单子链,就在第一个操作处失败了。或者,有些东西在最中间失败了,然后有些东西在这个计算链的最末端失败了,或者我们在最末端抛出异常。

这里的区别在于,我们执行了一定数量的操作,正常执行,轻量出现,然后我们跳过了其他操作。所以,这要么总是调用 or_else 处理程序,但我们没有它。所以,这只是我们将错误传递到最后,我们将看看它如何影响事情。我们在最后检查 expected,或者我们捕获异常。我测试了结果代码,它们汇编成了这个,所以没有任何东西被优化掉。我们这里做得很好。

让我们看看操作代码。超级简单,超级琐碎。左边是带有异常的操作。右边是 expected。当然,在幻灯片的底部,代码不是我手动写的。我只是生成了很多代码,因为想象一下你有 10k 个操作,以此类推,所以这是生成的代码。在这里,我们只是检查什么是数据对象,顺便说一下。数据对象是一个超小的对象。这差不多就是两个 int。一个计数器和另一个对象,我们用它来看看是否有错误的步骤。所以我们可以模拟错误发生在最开始、中间和最后。小对象,在这里不应该真的计数,或者不应该造成巨大的干扰。

如果有一个错误的步骤,我们在右边抛出一些东西。如果有一个错误的步骤,我们返回一些东西,或者否则我们增加计数器。那只是 ++ 操作,所以是最轻量的,然后我们返回一个对象。所以,这里生成的操作数量,比如我们做结果,我们做操作,然后我们移动到新的,移动到新的,移动到新的,等等。这将和我们在右边拥有的几乎一样。这里 and_then 的数量是生成的。非常相似。所以,我们要进行第一次操作,然后我们改变同一个操作的 and_then 的数量。

为什么我决定这样做,我必须解释一下,因为这非常接近真实的声明式设计。在这里,我把“声明式”(declarative)和“函数式”(functional)作为同义词使用。这不太正确,但对于这个特定的讨论,对于这个特定的例子,那是超级同义的。如果你有 n 个单子操作,想象一下在真实的程序中,以这种方式设计,你可以有成百上千个单子操作,组成不同的链。然后,如果有任何错误发生,你的异常,或者不是异常,expected 对象将会冒泡到处理程序,这个处理程序通常位于,我会说非常接近我们所说的系统错误处理程序的地方。这对于 try-catch 也是一样的。所以我们捕获一个错误,很可能我们不会做事务回滚。所以这个异常会在某个地方被调用,你知道,非常接近错误的公共处理程序。是的,这就是为什么我们有 n 个单子操作对应一个抛出和捕获指令。

这是第一张图表,当我们仅仅在没有错误的情况下运行。我要说的是两个轴都是对数刻度的,因为我们没有那么多的点。所以,在看图表时请记住这一点。这里是纳秒时间。我懒得转换它。Catch(测试框架)默认是用纳秒做的。然后这里有调用次数。所以,即使没有错误,你可以看到我们执行的操作越多,计算链就越慢。这很有道理。但 try-catch 在这里稍微慢一点。即使只是,你知道,请考虑我们在这里最多有比如 10k 次额外的 if 检查。所以,当我们执行不带它的操作,但程序中只有一个 try-catch 时,它会更慢。至少在我的基准测试中是这样。所以,你可能会有不同的结果。

当错误发生在最开始时。所以,我们几乎是线性地进行。但与此同时,你可以看到实际抛出异常并捕获它的效果是显著的。我们只是在计算链的最开始抛出它,并将其转发给所有这些处理程序。所以,你可以看到数据或多或少是正确的。我们这里最多有 5000,而在前一张图表上,我们有像 10k 纳秒。哦,对不起。那是不同的东西。所以,是的,这里我们有大约 50k,而这里我们有大约 5k,所以我们并没有真正在这两种情况下执行计算。我们只是,在第一个操作中,我们要么写入一些异常,要么我们只是返回 unexpected,然后所有其他近 10k 个操作根本没有执行。当异常在最开始抛出,或者我们的 unexpected 在最开始创建时,我们就得到了这种数据。

这是另一个有趣的案例。在这个案例中,我们在计算链的某个中间位置发生了一个错误。这意味着什么?例如,这里我们有 10 个操作,对吧?错误发生,异常抛出,或者 unexpected 对象创建,是在第 5 个操作。所以我们做了 5 个操作,然后对于其他的 5 个操作,例如对于 expected,我们只是去 else 替代分支,如果我们不做任何计算的话。显然,对于 expected,对象只是移交给系统运行时,最后到达处理程序。所以,从这张图表中,你可以看到,我们在计算链中的对象越多,线条就越接近。所以,例如,如果你有 10k 个操作,并且在中间某个地方,所有这些,也许是右侧的操作,你检查替代方案的地方,那会为整个计算管道创造一些额外的,嗯,额外的时间或额外的负担。

如果我们在最末端有它。所以你可以看到它们开始比以前更早地汇合,对吧,所以在最末端,我们的情况几乎就像根本没有异常,或者就像我们只有没有意外情况的计算一样。

关于这些图表有什么问题吗?超级合成,离现实很远,但仍然应该(有参考价值)。

(观众提问:你为什么选择在 Y 轴上变化这么大?比如从 0 到 500,然后在 1 到 5000 之间有一个巨大的跳跃,然后在 10000 到 50000 之间又有一个巨大的跳跃。这是否很难做出这种关联?)

是的。我会说我只是告诉我的脚本,请对两个轴都使用对数刻度,就是这样。所以我没有选择间隔是如何缩放的。因为那个,就像,你知道,如果你开始做间隔… 从这张图表中,你可以,就像,你知道,得出一些结论,至少。它是如何工作的,数字本身并不是超级重要。重要的是,就像,你知道,你可以比较,比如说参考,那是顶级的数字,所以有些操作被执行了或没有被执行。所以,这你可以直接视觉比较。再说一次,对于真实案例,你可能会有完全不同的图表。如果你想测量,你需要做的就是替换,就像,你知道,整个类,整个组件,整个库,从异常替换到 expected,然后进行性能分析。所以,仅仅替换单个函数,或者仅仅在一个函数的范围内开始使用 expected 是不够的。这取决于你的函数,但我希望你没有一个有 1000 个操作的函数,或者类似的东西。

非常好。我这里有一些一般的结论。我的意思是,从我的合成基准测试中,用这些非常轻量的操作,我可以得出结论,带有单子操作的代码比带有异常的代码稍微快一点。再次强调,你可能有完全不同的数据。

如果错误发生在计算的最开始,那么在我的例子中,单子操作明显更快,因为我们在一方有异常抛出和捕获的事件。另一方面,我们有绝对完美的分支预测,总是走替代分支。所以,我们根本不调用处理程序。

对于调用链,如果我们链上有大约少于 500 个操作,这里的链我是指不仅是一条链,比如假设你检索一个 widget,然后对 widget 做些什么,改变它的 ID,然后渲染它,例如,然后返回,或者处理错误代码。所以我假设,比如整个计算链,想象一下你不仅创建了一个 widget,你还把它放在布局上的某个视觉位置,然后你重新定位它,等等,如果我们把所有这些操作算作一个大的管道,那么我们可能会有更好的画面,因为这也是异常的工作方式。所以,我们计算在发起一个事件(例如 widget 创建事件)之前,直到发生错误之前的所有操作。

这是一个非常有效且非常有趣的问题。你们可能看到了第二个问题,第二个圈,第二个分支圈,是额外的复制或移动。我们如何处理额外的复制和移动操作?

我会向你们展示,我会从一个非常简单的例子开始。我有我的 noisy(嘈杂)对象。如果你不熟悉 noisy 对象,这种对象会为构造、复制、移动、析构以及所有这五件主要事情打印输出。我有 expected<noisy, string>。我有我的操作,什么都不做。它只是创建 noisy。我们将它赋值给某些东西。你可以从右边看到,我们创建了 noisy,然后我们把它移出去。所以,如果你从 C++11 开始看,这默认就是这样的。它带有移动语义。所以它被移动了。然后我们有 something 创建的析构函数,然后是被移动的 something 的析构函数。

我们实际上有一种方法可以稍微减少这种意图,但我现在不展示给你们看。那会在下一节。所以,还是这个带有 noisy 的函数。假设我们在某个地方创建了结果,然后我们将它存储为一个值。这是一个常规值,不是临时值,只是一个值。所以我们的 do_something 函数也做了一些非常重要的事情。它打印。所以,然后我们用 noisy 做些事情。看?然后我们返回它。然后它命中复制构造函数。然后我们再次从操作中返回它。它再次命中复制构造函数。然后它命中,比如说,两个析构函数。所以你看,我们至少复制了两次。而且我们销毁了它两次。

有什么建议吗?我们能做些什么来不复制?是的,那是一个非常好的东西。你可以,是的,我听到,比如移动。一个天真的方法,对吧?我们可以直接移动吗?我把我的错误值引用放在这里。然后我得到了编译错误。猜猜为什么?

正确。正确。所以,答案就是你在幻灯片上看到的。所以,你要么应该已经有了一个临时对象,要么你可以显式地移动它。否则,正确的 and_then 将不会被调用。它根本不会被编译。所以,现在,从你可以看到的,我们做些事情,然后我们移动。然后我们做些事情,然后我们移动。然后在几乎空的、移出的对象上调用了两个析构函数。这非常好。在大多数情况下,你会默认且免费地得到这种情况。为什么?因为你的结果对象是从某个函数返回的,对吧?所以它已经是临时的。这就是你可以像这样已经调用处理程序的原因。

但请注意,你在某处存储了对象。特别是从 const 对象开始。请不要那样做。只需移动它并像这样处理。所以,你可以用一种好的方式节省一些复制。

现在,我又做了一件事。我有另一个结果。另一个结果只是把东西反过来绑定。所以我现在把错误作为我的值。我有 noisy 对象作为错误。所以我以某种方式创建了一个 noisy 对象的错误,把它赋值给结果。然后我移动结果。然后看,我在这些处理程序这里有什么并不重要,按值、按引用、按其他什么。这些处理程序永远不会被调用,因为我们有一个错误,对吧?这是你的 if 的替代分支。所以,这些处理程序没有一个被调用。对于 noisy,我们有,比如看错误是如何携带的。移动,移动,移动。这只是显式移动,只是因为你的结果是一个临时对象。

这非常好。

这里的结论,非常中间的结论。对于值的复制-移动操作将在单子操作的每一步执行。极不可能被优化掉,因为怎么优化呢?你看到了实现,我的意思是也许有些编译器可以做到。我相信它。但很可能它们不会被优化。

接下来是一些非常实用的建议。让我们回到创建的案例。你们中有多少人熟悉 in_place 或类似的东西?啊哈!没那么多。我非常简短地解释一下。因为 in_place 所做的,就是创建一个对象,有点非常接近于,比如放置 new(placement new)。所以,你有一个对象,你不是先创建一个对象然后把它复制到结果里面。而是一起创建它们。结果和对象。这就是 emplace_back 的工作原理,例如。以及类似的构造。

所以,我的 noisy 对象是,我的意思是有默认构造函数。这就是为什么我这里没有传递任何参数。但是如果你的对象有任何参数,所以你可以把它作为参数包(variadic pack)传在逗号之后。对于,我不确定这对我来说有多直观。说实话,这听起来有点反直观。但是要创建值,使用 in_place。很好,在很多地方使用它。但是要创建一个错误,你要做 std::unexpect。所以,只是为了确保你可以创建你的结果,连同错误一起,连同值一起。所以,为此你需要使用 unexpect 来处理错误,或者用 in_place 来处理值。

所以,然后我们使用,考虑到这里那是构造函数和析构函数。所以,没有其他事情发生。所以,没有移动,没有移出对象的额外析构函数。我们可以使用那个。很可能,在很多情况下,你可能不会用这个。你只会返回一个对象。如果你真的需要优化,或者这对你很重要,那么你可以使用 in_place

而且,最重要的是,什么时候你可以这样做。因为,通常,你的结果就像已经是创建好的对象,已经是创建好的复杂对象。那已经来自某个函数,来自某个例程,来自某个计算。你并不真的需要创建它。它已经为你创建好了。这就是为什么,我的意思是这是一个很好的实用建议。但是,从我的经验来看,你真正能使用它的情况非常少。而且,我想,它真正能产生影响的案例数量甚至更少。不过,它还是在这里。

所以,然后你可以使用的是智能指针或引用,对吧?因为如果你有智能指针,比如说 unique_ptr,它只分配一次。它有非常好的语义,所以它总是会被为你移动。或者如果你有 shared_ptr,它会被复制,但你知道 shared_ptr 是怎么复制的,对吧?它是某种浅拷贝。以及保护控制块的原子变量的代价。差不多就是这样。

在这里,expected 本身和 optional,它们目前默认不支持引用。我想今年在 CPP Now 有一个关于引用的演讲,这个演讲的缩短版由 Steve 演讲,我猜,就在隔壁房间,现在正在进行。所以,你可以去 YouTube 上找这个演讲,看看关于 optional 引用的提案。那相当有趣。上次它引发了很多讨论。所以我们会看看进展如何。但是,截至目前,你们熟悉 reference_wrapper。你只需要调用,好像没多少人举手,解释一下。所以,你使用这个 std::refstd::cref 来包装你的对象。这东西几乎就是持有引用,并且有非常接近值的语义。这东西是简单的可复制、可移动等等。但在同时,你可以使用 get 方法,例如,或者直接传递给接受引用的函数。像往常一样使用它。

只是要注意你的对象应该有足够长的生命周期。所以,这个对象的生命周期应该和你计算管道的生命周期一样长。通常这对于单子来说是可以的,因为你使用这个点操作符,对吧?它某种程度上延长了生命周期。所以应该没问题。

我猜,是的,我还有一个建议给你们。为对象使用简单的数据结构。不是 noisy,我不打算谈论太多关于标准布局(standard layout),关于这种老式的 POD(Plain Old Data),等等。但是如果你的结构有,比如说,它只是结构体,没有静态字段,没有数组,没有虚继承,等等。我的意思是,那就像,真正的贴纸,它是平凡可复制的(trivially copyable),但它是一个非常简单的结构。所以,比如数据类(data class),如果你愿意的话。所以,那样的话,你很可能可以按值传递它。在很多情况下,它们会被很好地移动,你不需要太在意。

讲到这里,我想,谢谢大家。我们很准时。我试着留出 15 分钟提问。还剩 12 分钟。所以,如果你有什么要问的,请到麦克风前,尽管问。或者,如果你没有,我们可以稍后聊。是的,请,这边的问题。

Q: 对。谢谢你的演讲。你能回到第四张幻灯片吗,关于你的基准测试结果?

A: 哪一张幻灯片?

Q: 第四张关于你的基准测试结果的幻灯片。我想大概是 47 页或什么地方。

A: 好的。我会滚过去。这一张。

Q: 对。你可能已经提到过了,我可能只是错过了,但我原本以为 try-catch 在走好路径(正常路径)时真的很好,没有开销。但看这个,似乎它表现得更差。你知道为什么会这样吗?

A: 我不知道为什么会这样。我试着在… 这是在哪里?在这张幻灯片上回答。所以,但基本上,你需要,首先,为你自己检查,比如,为你的标准库实现,为你的系统,为一切。所以,我没有真正好的答案为什么。它不是慢得那么明显,但在我的基准测试中稍微慢一点。

Q: 在 10,000 次标记,10,000 次函数调用时,这看起来很显著?

A: 它可以是显著的,但这就好像所有都在单子操作的好路径上。所以,那总是右分支。所以分支预测器工作得非常好。现代机器在这方面的优化绝对惊人。但是再次强调,我根本不会相信合成基准测试。完全不,但你可以用它作为一些参考数据,但即便如此,这就是结果。

Q: 好的。谢谢。

A: 不客气。

Q: 嘿,谢谢你的演讲。我想我可能误解了其中的一部分,我想对此展开讨论。所以,当你比较 try-catch 和单子操作时,你说了一些关于重复单子操作很多次的事情?

A: 我可以回到… 我们有什么幻灯片?不,不是这里。好的,但这并不重要。这个操作,所以… 我们在这里能做的是我们生成代码,比如当然,这是工具生成的,不是手写的。所以,例如这里,如果你有 10k 个操作,就会有 10k 个 and_then,也就是组合的 and_then。对于常规操作也是一样的。所以,10k 个常规操作对 10k 个单子操作。

Q: 对。那么顺着这个思路,我想知道,你是否尝试过嵌套 try-catch 并看看性能是否有变化,不是在整个操作中只有一个 try-catch,而是像一个 try-catch 包裹一个 try-catch 再包裹一个 try-catch

A: 答案是否定的,我没有。因为,我的意思是,我试图去,嗯,获得最简单可能情况的基准测试,因为有很多有趣的案例可以探索。你说的那个,然后另一个是比如传递更大的对象。我有大概 64 位的对象,取决于架构。但是如果我传递一个 10 个元素的元组(tuple),例如,这会如何影响这两种情况?因为比如 10 个元素,好吧,可能太多了,但 3 个元素的元组,这是当你做单子组合时非常常见的情况。所以,很多用例,很多有趣的事情可以去了解,但我试图做最简单和最通用的。

Q: 对。所以,当我们看这张幻灯片时,我们看的是一个 try-catch 对比 10,000 个单子操作。

A: 是的,完全就是这样。所以,我们只有 n 个单子操作,因为这是最接近真实情况的案例,然后我们只有一个 try-catch。所以,是的。

Q: 酷。好的。

Q: 如果你有非常不同的性能,这取决于,正如你刚才提出的,使用不同的对象在 throw-catch 与单子操作中是否有更好的性能,你会选择总是走最优化的路线,在你的代码库中有不同的 try-catchexpected,还是你会尽管有些情况下会损失一些性能而选择其中一种?

A: 这是一个有趣的问题,因为比如较大的对象可能会有潜在影响,但它也不总是会有影响,因为在我的具体例子中,它不会有影响,因为,对吧,我们有一个操作,我们从它返回对象,然后我们移动结果,那就是我们在 and_thenand_then 结尾做的事情。然而,我所说的大对象是指你可以有一个完全不同的程序。所以,当你重构你的程序,比如说从异常到 expectedoptional 时,你可以有完全不同的计算流,因为你在重构你的代码,特别是如果你对此没有太多经验,你可能只是开始比如额外复制这个对象很多次,当你并不真的需要时,然后你就会对性能产生完全负面的影响。所以那就是我基本上想说的,大对象可能会影响事情。

还有其他问题吗?是的,如果没有,谢谢大家。祝大家晚上愉快。