C++ 异常的误用案例及改进方案

标题:Exceptionally Bad: The Story on the Misuse of Exceptions and How to Do Better

日期:2023/08/31

作者:Peter Muldoon

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

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


欢迎来到我的演讲,“异常糟糕”(Exceptionally Bad),讲述异常的故事、误用以及如何做得更好。好的,这是我放上去的幻灯片,因为我的公司希望我放一张彭博工程(Bloomberg Engineering)的幻灯片。是的,我在彭博工作。但我是谁?站在前面这位风度翩翩的年轻人是谁?嗯,我从1991年开始专业地用C++编程。所以我干这行很久了,可以追溯到C-front(早期的C++编译器)的旧时代。在我的职业生涯中,我做了很多系统分析和架构设计。在永久加入彭博之前,我做了21年的顾问,这意味着我见过很多不同的公司,很多不同的团队,见过哪些行得通,哪些行不通。我目前在彭博领导行情服务器(ticker plants)的Linux迁移工作。这些服务器负责将我们作为行情提供给彭博客户的所有数据。那么,当我做演讲时,我喜欢做什么?我的人生信条是做我称之为“聚焦实用软件工程”的演讲。它扎根于现实世界,因为那通常是我编程和工作的地方。我希望演讲能让你带走一些东西并使用,无论是改变你的思维方式,还是给你一些工程原则来运行你自己的项目。好的。那么,我有很多幻灯片。我想这是我第一次拥有如此多的幻灯片数量。关于异常有很多要说的。但在某些时候我会停下来问,谁有问题?给我一个幻灯片编号。因为要弄清楚“哦,是那张发生了什么的幻灯片”会非常困难。所以在右下角,对吧?你们有笔。我不知道你们有没有记事本。你们可以把问题写在酒杯托里,放酒杯的小东西上。好的。

在这次演讲中,我们将探讨什么?这将是一个轻松的演讲。关于异常没有争议。我们将讨论的主要是C++中看到的异常。我们可能会简要谈谈其他语言,但主要是C++。我们将回顾历史和最初的目标。异常应该给我们带来什么?它们应该交付什么?我们为什么要把它们引入语言?然后我们将看看catch throwthrow catch的机制,因为你需要知道它是如何工作的,才能理解发生了什么以及为什么应该使用或不使用异常。接着,我们将考察异常的使用和设计两方面。希望在我们完成这段旅程后,我们对于如何处理我们的用例(通过异常或其他机制)会有更好的思考。然后,我们将思考我称之为“真正异常”(truly exceptional)的剩余部分。好的。这应该能让你对在日常工作中使用的异常类的设计有更好的思考。

然而,这次演讲附带一个免责声明,对吧?它可能具有争议性。我有种不好的预感,我会同时惹恼支持异常和反对异常的群体。我认为这可能是个好结果。好的。那么,关于异常,我想从一件事开始。再次强调,我想调整我的思维,给自己一些指导方针:异常是什么?关于它们需要注意的事项。没有异常类属性或关键字。没有任何东西声明“这是一个异常类”。它可以是任何你想要的东西。没有关于什么是异常的正式定义。没有你可以查看的特征。我不是在说名字。我说的是它的工作机制如何表明“这是一个异常”。所以,这让我再次认为,用法是异常的主要特征,即它们是如何被使用的。那么,让我们继续探讨“异常的理想”(exceptional ideals)。异常本意是给我们带来什么?整个想法是什么?嗯,我们可以回到过去,看看那个人本人,Bjarne(Stroustrup),以及他在他的《设计与演化》(The Design and Evolution)书中所说的,异常应该给我们带来什么?

  1. 提供类型安全的任意数量信息传输,从抛出点到处理程序。这意味着我们不做回调那种抛出void指针到处传然后重新转换的事情。

  2. 我想要对不抛出异常的代码在时间或空间上没有额外开销。我不知道我们是否达到了这个目标。我们要么放弃这个,要么放弃那个,或者两者都放弃了。

  3. 保证每个引发的异常都被适当的处理程序捕获。我们没有这个保证。我猜这是因为它太复杂而被抛弃了。我们确实尝试在代码中使用抛出规范一段时间,但它们被废弃了。完全不可行。

  4. 然后我们需要一种对异常进行分组的方法。想法是会有大量各种细碎类型的小异常被抛出,我们希望以通用方式处理它们。所以我们要做的是有一种对它们进行分组的方法,那就是使用多态类型层次结构。

  5. 我们希望它在多线程程序中能正确工作。所以当你在一个线程中抛出它时,它只停留在这个线程内。

  6. 一种机制,这在语言的早期很重要,一种允许与其他语言协作的机制。换句话说,我可能在一段C++代码中抛出一个异常。这段代码是由一段C或Fortran代码调用的,而这段C或Fortran代码又是由一段包含异常处理程序的C++代码调用的。所以它必须能够穿越其他语言的代码。这是一个重要的设计决策,因为如我们所知,我们某种程度上把C++建模成像“带类的C”之类的东西。

它(指《设计与演化》书)说这里的大多数(目标)……其他七和八条没什么意义……但它说这里的大多数目标……(原文如此),都消失了。所以那是它们被引入语言时的情况。

那么,我们现在实际得到了什么?因为事情变了,对吧?其他人也参与进来了。Bjarne不再是唯一的叙述者了。所以我们说异常给了我们什么?给了我们一个易于阅读的“快乐路径”(happy path),代码中缺乏混乱。现在,如果代码中缺乏混乱,如果你没有到处使用try catch块,对吧?正是这些的缺失给了你一种缺乏混乱的感觉,对吧?不是我们使用的throwcatch这三个词之类的东西。

  • 将错误报告解耦。我们拥有了这个。我们不必在错误发生的地方处理错误。我们可以把它发送到其他地方。

  • 它们不能被被动忽略。嗯。我认为它们可以被被动忽略。如果你忽略它们,你只是要付出沉重的代价。换句话说,它会让你的运行时崩溃。如果你处在某个不介意崩溃的运行时环境中,你有一种非常高效的恢复方式,也许这对你来说无关紧要。好的。

  • 它是处理类的构造函数和运算符错误的更简单方式。所以在那之前,我们必须说一个类被构建了。它可能是一个“僵尸”。我们必须询问一个is_valid,你知道,某种双重功能来说,好的,现在你可以某种程度直接在构造函数中处理,因为你不能传递一对(pair)你想要的东西和一个可能返回的错误。所以这给了我们一个更简单的方式来处理这个问题。

  • 它是语言中处理错误的现代且推荐的方式。我向你展示证据,并且它得到了标准库的认可。对吧?所以它一定是好的。好的。

那么我是什么意思?让我们……我知道你们在这方面非常博学,但还是让我们了解一下这里发生了什么。我有一个类,它必须返回一个状态(status)函数。现在我有一个类。这就是我和我现在的年龄,就在这里。我在做的是,我说,你有效吗?如果无效,我就在这里退出。否则,我将对这个类应用一些东西,然后我会说,它还好吗?所以这里有很多混乱。当你试图找出真正的操作是什么,与捕获所有错误的部分相比时,这可能很难找到。

那么异常会给我们带来什么?嗯,它给了我们一个函数签名的改变,我们不需要传回状态。等等,我真的需要改变这个吗?不,我可以保持原样,但那将是非常糟糕的设计,人们查看代码时期望返回一个状态,而你实际上却在抛出异常。所以当你使用异常时,你真的需要控制抛出和捕获两方面。好的,这里我们有一个非常清晰的“快乐路径”。对吧?我只需要说MyClass me(...),如果我给它一个空名字、零年龄或负年龄,它会抛出一个异常。我可以执行apply,如果出了什么问题,它会被下面的代码捕获。所以这真的很棒。现在,这个路径只有在你看try块之间才是清晰的。在那个之外的地方有点难看。这段代码有什么问题?控制问题?没有。哦,请你们注意我在这里的main函数里。问题是什么?你回答不了。你在预演中见过这个了。好的。这可能会让我的类崩溃,因为我不能被动地忽略它……我可以忽略,但我需要像这样的东西,一个“异常吞噬器”。这告诉我外面有东西。我不知道它是什么。我不知道它的后果是什么,但我知道它在那里。这就是我所知道的全部。而这里真的是你不想做大部分错误处理的地方。对吧?这有点像一个兜底,我们需要把它们放在代码里。

所以,异常现在给我们带来了什么?这不是一个详尽的列表。只是我称之为“普通人”想知道的东西。但大多数应用程序,当然是我工作过的大多数应用程序,都需要这样的异常吞噬器之一,因为我们不能让生产环境仅仅因为一个杂散的异常或添加到代码中的新异常类型一路逃逸到main函数而崩溃。那么,有谁对此有评论吗?好的。下一节。

异常定义。异常是什么?出奇地难以精确定义。我将从几个角度来审视它。第一个角度是机制或基于行为的视角。异常是被抛出并被捕获的东西。这或多或少是我们有一个异常处理机制,而不是真正的C++中的“异常东西”。那么让我们来看看这个。

关于抛出异常,它们是如何……让我们快速简要地看看异常是如何工作的。好的。这些内容大部分最初来自……很多年前我在这里听过Andres的一个演讲。Andreas Weiss也在场。那时我对异常不太满意。他给我做了一个小时的关于机制是什么的演讲。我听完后更不满意了。在过去的四五年里,我一直憋着劲儿需要做这样一个演讲。现在就是时候了。所以谢谢你,Andres。好的。

当我们抛出异常时,异常对象的内存以未指定的方式分配。这是标准里说的。但这意味着无论你抛出什么,即使是一个int,你也必须在堆上分配它。对吧?这可能很昂贵。你现在已经脱离了函数返回变量的常规高效路径。你还需要一个单独的栈展开机制。好的。你正在从栈中退出,你需要调用析构函数。对吧?所以你需要一种展开栈的方法。如今这有两种方式实现:

  1. 基于帧(frame-based)的方法:决定需要做什么时,它有一个需要析构的东西的链表。当它进入时添加,退出时移除。这种方法意味着即使你没有抛出异常,你也在计算上付出了代价。

  2. 基于表(table-based)的方法:在编译时,你编译出所有这个表,表中记录了指令指针在任何特定范围内需要销毁的对象。所以当你的指令指针在代码中移动时,你遇到了throw。你进入这个表,根据“我在哪个范围内”进行引用,然后遵循并执行这个机制。当你确实需要进行异常处理时,这有点昂贵。但如果你不这样做,你就不需要在速度上付出代价。你只需在代码膨胀上付出代价。你的代码中有大量的代码和表格。对吧?

我们来谈谈捕获异常。所以有一件事,我不知道,从来没有真正在任何地方说过。是的。关于两种不同的展开方式有问题。它是基于编译器以及它们如何编译的吗?是的,我认为微软过去使用基于帧的方式,现在我们或多或少都转向了基于表的方式,因为如果没有抛出异常,你就不需要付出代价,这是一个相当冒险的假设。但这不是企业家的东西。不,它是内置于编译器中的。

好的,所以异常处理程序实际上只能向后抛出到它已经经过的地方。你不能向侧面或向前或其他方向抛出。对吧?它必须是你已经经过的某个地方。我们需要对异常进行多态捕获。换句话说,如果我有一个基类被捕获,我需要说,嗯,派生类,这个处理程序真的能处理它吗?它使用RTTI来实现这一点,这有点昂贵。标准中没有指定,但据Andres说,它总是这样做的。而且异常层次结构通常是长链的,甚至是多重继承的,对吧?所以你可以想象,对于任何一个特定的处理程序,它必须做很多工作来遍历这些异常层次结构的树,并弄清楚这是否是某种匹配。所以这也相当昂贵。

然后我们再次看到标准中的说法:实现可以以未指定的方式释放对象的内存,对吧?如果你使用了内存,你快速分配和释放你从未使用的内存。但如果你试图释放你已经使用的内存,这有点昂贵。所以我没有注意到整个异常机制有什么特别的地方。所以昂贵。昂贵。是的,它到处都是“昂贵路”。对吧?现在这意味着,如果你想在这里或那里使用它,或者你是一个不在乎的应用程序,那可能是可以的。对吧?时间不是关键。但是是的,它很贵。

  • 如果在栈展开期间抛出异常,你的程序将以std::terminate终止。对吧?好的。现在,它不会……如果异常在你试图向处理程序移动的过程中从结构中泄漏出来,这会在哪里发生?或者如果你的异常类型本身抛出了异常?我相信所有这些都会导致你的问题……程序中途退出。对吧?

  • 但它也可以由匹配的处理程序调用。然后异常就不再在飞行中了。它会一直存活到catch处理程序的最后一个大括号结束。但它不再在飞行中,除非你重新抛出它。

  • 如果没有找到匹配的处理程序,则调用std::terminate。并且栈是否被展开是实现定义的。这是标准里说的。这意味着如果你期望回滚或放弃一些东西,或者你已经在数据库里设置了一些标志,std::terminate只会调用std::abort。我不知道它实际上是否执行了回滚。是的,Fred?你不能在栈展开期间抛出。你必须在它逃逸之前捕获它。是的。是的。但我说的是如果它逃逸了。是的。是的。所以不是如果它被抛出,而是如果它逃逸了。是的。“逃逸”作为一个术语。

让我们谈谈异常卫生。好的。你应该如何处理你的异常?你应该按值抛出(throw by value)它们。你不应该抛出栈上东西的引用,因为它会消失。你不应该抛出原始指针(raw pointers),因为,嗯,无论如何你大部分时候都不应该使用原始指针。所以你应该按值抛出。那个值会被复制到这个异常机制中。所以你的复制操作也不应该抛出异常。对吧?否则你将终止。你应该通过引用捕获(catch by reference)。这可以防止对象切片(object slicing)问题和额外的拷贝。好的。现在,有些人说const引用。这有点表示意图的信号。我不会修改它,但它是一个副本(copy)。所以随你怎么处理它。如果你想重新抛出(re-throw),就用throw;(没有参数)。如果你用传入的参数throw,你是在做一个新的拷贝。你可能会再次切片对象。好的。另外,catch处理程序是按照它们列出的顺序执行的。所以你要确保派生类放在它们的基类之前。否则你将永远无法到达派生类。

现在,我们处在一个函数中间,在栈的20层深处。我们按下了弹出按钮,throw。我们正向上穿过栈层,左右摧毁对象。你知道,就像混乱将随之而来。为了保护我们免受此害,我想是Dave Abrahams和另外几个人(不幸他今天不在这里)。对吧?他们提出了这个异常安全(exception safety)的想法,帮助我们在处理程序抛出时不至于陷入完全的混乱。当语言最初讨论引入异常时,有很多关于垃圾收集的讨论,这样内存会被自动清理。但它最终被抛弃了。那么异常安全是什么?它提供三种保证:

  1. 不抛出保证(no throw guarantee):我不会抛出异常。或者如果我抛出了,事情已经糟糕到应该杀死程序。好的。

  2. 基本保证(basic guarantee):听着,当我从这个抛出异常的函数块出来时,不会发生内存损坏。不会有内存泄漏,所有的类和不变量仍将保持。我不知道为什么这对这个是特殊的。我会说每个函数都应该遵循这种保证。对吧?

  3. 然后我们有强保证(strong guarantee),就像你在数据库中拥有的相同语义,你可以提交,如果不成功则可以回滚。所以如果抛出异常,数据没有改变,你拿回的是相同的东西。

所以这就是我们的异常安全。当我们在各处抛出异常时,它能让我们感到温暖和安心。对此有什么问题吗?

  • 抛出的值通常是进入堆的那个吗?抛出的值,我相信,会被复制到堆中。是的,复制到堆中,堆上分配的空间。也许。也许?也许?哦,抱歉。

  • 你通过函数指针捕获吗?那意味着你必须在那边创建一个临时对象。不。不?好吧。绝对不是。

好的。那么让我们看看缺点。与其用网格来振奋你们关于异常是什么,不如让我们看看异常的阴暗面。对吧?

  • 它们在时间和空间上是无界的,特别是嵌套异常和异常指针。所以异常现在存活得比处理程序的生命周期更长。

  • 不可见的执行路径。在一个进行大量异常抛出的地方,要找出控制流到哪里去并不容易。

  • 你需要异常吞噬器,至少在你的main函数里,对吧?

  • 然后它必须在C++中启用,因为它打破了“不为未使用的功能付费”的理念。在这里,即使你从不抛出异常,你也要为RTTI和异常付费,对吧?

  • 所以,这是Herb的饼图,显示超过一半的人在异常使用上受到限制。

那么,这就是对异常处理机制的简要介绍。有问题吗?很好,我还有好多内容要讲。好的,那么我们来谈谈基于哲学的观点。我们这个时代的伟大思想家们对异常有何见解?他们坐在山顶的塔楼上,向下传递石板。他们相信异常应该用于什么?对吧?我们谈论的是罕见的、不愉快的路径事件。我们谈论的是非平凡的或特殊的错误。那么让我们看看这个,对吧?我对我们这个时代的思想家们有几个问题。好的。

问题一:什么时候适合使用异常?对吧?当我去看(C++核心)指南(guidelines)时,它说:对于运行时错误(runtime errors),当一个函数无法履行其合约(contract)或无法完成其宣传要做的事情时。所以我们会抛出一个异常来表明我们无法完成我们说要做的事情。好的,如果你只遵循这一条原则,这个数据点会让你误入歧途,对吧?它适合用于构造函数和运算符失败(constructor operator failures)。我们被告知这是一种更简单的方式。好的,同样,如果你只使用这两个,你可能会在程序中到处抛出异常,对吧?但我想问,那未定义但可恢复的系统状态呢?比如我们有内存损坏、内存耗尽、你知道的,坏分配?这是异常的用途吗?不是。好的,人们一开始认为是的,但后来我们发现,如果我们有内存损坏,我甚至不知道是否能展开栈,对吧?我可能现在就应该离开道奇。好的。再次回到《设计与演化》,它说:试图提供允许单个程序从所有可能错误中恢复的设施,将导致错误处理本身的复杂性,从而产生错误。换句话说,你只是在加倍下注,却没有得到……(收益)。是的。

所以使用异常是一个大词。我的意思是,那是指抛出、捕获还是两者?抛出。抛出。抛出异常。因为抛出它的人发起了其他一切,对吧?你可以有捕获而什么都不发生,但必须有人抛出。即使你不想使用异常,如果有人抛出了,你就会被卷入其中。让我再深入探讨一下这个问题。异常是为哪些用例设计的?对吧?我想大概是这样的:程序遇到了一个无法正确处理的情况,因为它没有足够的上下文。但也许上面的某个人有。所以让我让他来处理它。这大概就是我所说的缓解策略。我们说,我只是想摆脱我正在做的工作。第二种是,我们遇到了一个真正严重的错误。继续前进是我能做的最糟糕的选择。我只想离开这里。一个和我谈过的人说,但有时我在一个地方,一切都出错了,我只想离开这里。对吧?按下弹出按钮。噗。你就走了。我坚持认为,如果你把异常用于其他事情,很可能有更好的方法。你可能没有正确使用它们。有人要因此攻击我吗?好的。很好。

问题二:异常仅用于非常罕见的事件。希望如此?是?否?也许?我认为它们如此昂贵,以至于它们必须在那里。好的。再次回到指南,对吧?将错误处理与普通代码分开,不管那是什么。因为这是个有趣的句子。“C++实现倾向于基于异常是罕见的这一假设进行优化”。这应该写成“基于它们非常昂贵这一假设进行悲观化处理”。好的。在微软的异常编程指南中,他们说:如果事件不经常发生,则使用异常处理。也就是说,事件是异常的。好的。这使它变得罕见吗?但这里谁能告诉我,在我的信息流经系统时,走异常路径的上限是多少?是10%,5%?一个。一个。真的有人在他们系统中测量这个吗?真的有人说,看,我想知道在……我的意思是,SREs可能对此有了解,但当我处理我的工作负载时,平均有多少代码是通过异常路径执行的?我认为没人真的这么做。哦。我的意思是,它接近零以至于无法测量。你这么想?肯定是的。然而我们说,永远不要假设,要测量。我可以向你保证,因为我拥有那个……我拥有那个最后机会异常处理程序,并且程序没有逃逸。是的。但那是最后的机会。你可能在整个代码库中处理它们,对吧?那是一个更长的讨论。是的。

  • 我曾在某个时候试图用它代替optional,结果慢了500倍。是的。哦,它很慢。

问题三:我如何处理频繁的次要错误?因为你说用于罕见事件。我如何处理频繁的次要错误?一个编写库的第三方如何知道我与那个错误的关系?如何知道它对我来说是频繁的还是严重的?这是一个隐藏的关系。对吧?如果异常被视为做事的方式,它们就像坐头等舱旅行,对吧?只有一些人可以这样做。其他人则回到这种劣等的机制中。我们被告知错误码和状态是劣等的。对吧?如果你对一个正常情况使用异常,比如你到处抛出它们,那么当一个真正的问题出现时,我怎么知道?很难发现。很难发现。

所以问题不断涌现。好的。

问题四:我有权选择何时使用异常吗?当然。是的。好的。这有点像,是的,当然。嗯哼。对吧?是的。如果你是那个抛出异常的人并且被允许这样做……我们之前有那张图说不能。我知道有很多地方不允许你这样做。如果从你的角度看是第三方库,那么不行。除非你捕获、吞掉或者重新打包成一个返回码,否则你无法选择,我相信这就是谷歌的做法。嗯,谷歌不允许异常。好的。这个庞大的软件帝国不允许异常。为什么?我的意思是,你可以阅读所有这些,但本质上他们说的是:我们编写的代码不是异常容忍的。即使我们编写了新的异常容忍代码,我们也无法保证异常不会逃逸到非容忍代码中并导致系统崩溃。我刚刚在这里之前听Sean Parent说过“铁甲”允许你这样做,但我想谷歌还没看到那个。对吧?但这是他们的立场。我们不使用异常。我会做一个总结。这来自Bjarne。我认为这很精彩,人们在思考异常时至少应该记住这个:它们主要用于错误处理。它们不是一种替代的返回值机制。好的。你不会找到什么东西然后说,当我找到时,让我把它抛出来,这样别人可以捕获它并获取那个值。好的。它很昂贵。你希望异常处理程序相对于函数定义来说是罕见的。这意味着try catch块的缺失是异常的力量所在,而不是到处都有它们。并且异常相对于函数调用来说发生得不频繁。你不经常使用它们,它们是罕见的。好的。

那么在我的思考之后,我得出了什么结论?我的意思是,那些伟大的思想家们可能会在演讲后来找我,但仅将异常用于功能上可恢复的错误。并且我倾向于尽可能让异常保持罕见,意思是它们用于不常见的严重错误,而不是你预期会经常发生的事情。这就是我对哲学的简要概述。是的,David。

  • 在上一张幻灯片上,什么是“功能上可恢复的错误”?一个不会……一个你的系统可以安全继续进行的错误。基本上是一个被预料到的错误。好的。它通常意味着你放弃那个工作单元,然后转向一个全新的单元。对吧?所以这是基于哲学的观点。现在我们将……哦,抱歉。更多问题。

  • 我实际上不同意那个说法。我举个例子,比如我们使用异常的一个例子。我们有一个解析器用于解析输入配置。规则是如果存在解析错误,程序停止,但我们会记录错误报告。日志记录和错误报告在顶层完成。这极大地简化了解析器核心的代码。我们抛出异常,里面打包了足够的信息说明到底哪里没解析成功。它一路穿过多层,在顶层被捕获。解析器……进行日志记录然后中止。我会以有利的方式覆盖那个用例。对吧?稍后。少数几次我会持赞同态度的时候之一。对吧?

所以这次演讲的主要部分是关于基于用法的观点。因为正如我在前言中所说,对我来说,异常是由用法定义的。你可以把任何东西放进异常处理程序并抛出它。没关系。那么我们如何在真实代码中使用这些东西?人们在真实代码中用异常做什么?对吧?换句话说,人们是否在误用或滥用异常?嗯,这次演讲的标题可能已经泄露了答案。所以让我们谈谈……我将通过一系列情境来说明。有些相当明显,但我想为了相对完整而涵盖它们。但它们都是我在现实世界中见过的事情。对吧?由于各种原因,我不能把完整代码放上来,但它们确实存在。对吧?

我将以“情境是什么”的形式来做这个。所以我们需要在离开函数或抛出异常之前,显式地归还或释放操作期间获取的资源。好的?所以我们必须做一些资源管理。异常可以在任何时候离开函数,可能导致泄漏。我要确保清理。所以我们要寻找的模式是:异常块捕获异常,释放资源,并且通常重新抛出异常。

所以它看起来是这样的。你会注意到错误处理并没有处理错误。这纯粹是资源管理,所以你让它承担双重职责。那么我们应该这样做吗?不。不。这里是一个真实的代码片段,以防你认为我精神错乱而编造了一些非常糟糕的东西。这是一个真实的代码片段。我不会说在哪里,但它在生产中运行。好的?有人看出这里的资源管理有什么问题吗?是的。对吧?所以我们有,嘿,try,我们要从一个文件中提取数据。如果你没有在使用你的笔记本电脑屏幕,能放下吗?我正在用,John。哦,好的。抱歉。这里没有问题。好的。所以我们看到这里,如果我们捕获到一个流异常,我们将关闭文件并重新抛出。我们捕获一个std::exception,希望所有异常都派生自它。我们将关闭。然后在最后,我们关闭它。在这个函数中有一个问题,有人之前没发现。对吧?有人看出来吗?问题是什么?return。捕获所有。没有捕获所有。不,如果你return,它不会抛出。这不是捕获所有。其他人呢?有人可能抛出了一个int之类的东西。是的。那也不是。是更基本的东西。在成功计数时你没有关闭文件。是的。在成功的情况下,我们只是在这里返回一个代码。对吧?我第一次看时没发现这个。我说,这太糟糕了。我说,我瞎了。所以当我开始做幻灯片时,我说,等等,这里的生产代码有个bug。那么修复的方法是什么?RAII。这很简单。你们都知道这个,对吧?所以我会写一个小的文件管理类,说,好的,RAII,它实际上应该是“构造时获取,析构时释放”。对吧?所以当我打开文件时获取它,然后文件管理器在销毁时会释放它。这意味着上面所有的废话都进入了这个小循环。好的。这里更容易发现错误。而且如果有人添加了……这些东西通常没那么小。它们可能很长,有额外的退出点,我们捕获它们。所以这是显而易见的东西。

现在,我确实在想,当我写幻灯片时,我不和ChatGPT聊天。我只是写我的幻灯片,对吧?然后我回去查阅文献。我查了《设计与演化》。这是其中提到的一件事,说你应该像这样使用资源管理。所以很酷,我几乎和ARNA想到了一块儿,关于不要做以前做过的事。遗憾的是你不得不那样做。是的。那么结论是什么?对于资源管理,优先使用RAII而非catch处理程序。换句话说,不要这样使用异常。那不应该发生。

现在我要谈谈“异常甩锅”。我这是什么意思?任何问题,无论是什么,都抛出一个异常。好的。异常允许抛出者把问题推卸给捕获者。当我在Java短暂工作过一段时间时,那感觉像是“千刀万剐之死”。好的。异常被抛出。所以用户通常会在整个代码中到处放置捕获块。因为如果你不这样做,会发生的是那个异常会传播到调用链的顶端,可能被main中的省略号捕获,并抹掉那个事务。而你可能想要那样做,也许它没那么严重。所以你需要在本地进行错误处理,捕获这些东西并忽略它们。好的。或者让它继续传播。所以当这种事情发生时,我们需要本地处理来缓解事务被抹除的情况。

我们要寻找的模式是:外部库、其他模型。其他模块化功能通过异常表达所有问题。那么为那种情况编写的代码看起来如何?我不能给你看代码。代码就是到处抛出异常。所以我得再问几个问题。在你的代码中到处都有try catch块,甚至是在多个单一函数调用上,这可以吗?好的。那是不行的。在你的代码库中到处都有“异常吞噬器”,这可以吗?对吧?因为你不知道发生了什么。你知道,只是基于信仰认为发生了什么事。也许,也许我可以继续。所以是的,正如我预料你们,我们得到了正确答案。你们同意我的观点。对吧?异常的好处之一是代码中到处都缺少try catch块。对吧?它很丑。你是在用try catch交换if语句。你走错路了,知道吗?我们已经讨论过它在效率方面不行,因为它的机制。现在也许你不在乎效率,但有些人在乎。而且再次强调,如果我为了天下所有事情都抛出异常,我怎么能发现不寻常的严重情况?好的。

现在,当异常被讨论并引入语言时,有一大群人说,这很棒。你知道,他们只是在讨论终止语义还是恢复语义。有一个人,Doug McIlroy,不同意。他说,嗯,他对将异常处理放入语言表示同意的著名例外是Doug McIlroy,他陈述道:异常处理的可用性将使系统可靠性降低,因为库编写者和程序会仅仅抛出异常,而不是试图处理问题,甚至理解它。并且只有时间能证明Doug的预言在多大程度上是正确的。有人认为这成真了吗?一个人?谁认为它是假的?四个人。所以其他人都不清楚。只是来这里做对的。好的。Doug McIlroy是谁?因为他可能只是某个走进Bjarne实验室一次,说了这话然后又走出去的家伙?不。Doug McIlroy是,我相信,Unix之父之一。他编写了许多……他发明了宏,我不知道这是否是你想要的褒奖。但他也做了许多我们拥有的Unix命令,对吧?但他说……不过这个人是谁?基本上,Bjarne说我做任何关键决定前都要咨询Doug,因为他有总是正确的诀窍。好的。即使有一两次Bjarne违背了他,但他通常是对的。所以他不是一个可以轻易被忽视的人。

所以这里的模式是什么?我说什么?优先仅将异常用于不寻常的罕见情况。我的意思是,它们是严重的。真正值得注意的事情。有问题吗?

  • 有些系统像Java,那里“异常甩锅”是一种习惯。它捕获、转换、包装链。是的。很多重新包装,直到你到达顶层记录它并中止。对于罕见异常是否有例外?嗯,有些语言像Python和OCaml等,异常处理非常容易和高效,你可以随意抛出异常。你知道,不必担心开销。尽管让它发生。好的。但那不是C++。好的。

  • 我见过用“甩锅”来在抽象层之间进行转换。所以我在地图中找不到一个键,在考虑JSON对象时,可能在语义上有不同的含义。我稍后在幻灯片中处理了那个确切的问题,因为我不喜欢你那样做。我稍后会进一步讨论那个问题。

所以,让我们……让我们继续。那么“异常日志记录”。情境是什么?抛出者缺少错误调查所需的上下文。好的。所以当异常被抛出时,可能缺少我们作为开发者找出问题所需的关键信息。那么我们做什么?我们在处理程序中记录错误并附带额外信息,然后重新抛出。大家都见过这个,对吧?或者我们重新打包异常,因为,你知道,这样做更酷。然后我们重新抛出那个。好的。一种是在栈深处报告,另一种是在处理程序现场报告。所以第二种可能比第一种稍微有点优势。我不喜欢这个,因为这常常导致一种思维模式,其中日志记录和内容定义了一个异常类型。我们在语言的其他地方永远不会犯这种错误。

我们要寻找的模式是:在处理程序中记录日志并重新打包异常,加入额外信息。然后我们重新抛出,让实际的处理程序对它做点什么。那么它看起来如何?好的。就在这里。对吧?这些函数中哪个在抛出?没人知道,对吧?这就是整个异常方法的一个缺点。我们尝试过做抛出说明符。那是一场灾难。好的。那么这里做了什么?所以如果transmitName是抛出异常的那个,我们不知道传入的键是什么。所以我们把这个放进日志文件,然后去搜索它。现在,有些人会说,我们是勒德分子吗?让我们用时髦的方式来做。好的。所以我将有一个异常,它把额外信息作为构造函数参数,从当前异常中窃取日志片段。然后当你调用.what()时,它会抛出这个额外信息加上其他异常的任何信息,对吧?所以你添加了一个带有异常类型的新概念。我说解决这个问题的方法是这个全新的概念:可变字符串。对吧?我说,看,我会捕获它。我会添加我的信息,然后重新抛出它。没有额外的分配。没有为了这个专门创建一个新类型。它的样子会像这样。对吧?捕获它。添加到what中。我把通常会在这里的呕吐物隐藏在函数调用中,这无论如何都是你应该做的。这样你就可以只看它然后说,啊,看,它在添加一些额外信息。有人?

  • 没有位置信息,如果你要对字符串做操作,你会为字符串分配新内存。可能。所以……所以……你说没有新的分配。但有分配。但另一种方式,保证了。所以,另一种方式保证了它。对吧?

  • 一些标准扩展,它们没有完成字符串。不,我不知道。我不是标准专家,但我没注意到你实际上可以……你说,标准异常上的what,这是我对标准库中异常的首要不满。所以,你有很多不满要列出来。是的。

  • .what()的意义在于它允许……啊……定制由what字符串产生的内容。让它成为一个可变字符串。你可能会踩踏其他可能被输入的东西。嗯,那只是编程错误。对吧?我无法为做坏事立法。对吧?你通常只会正常地附加到它。是的,Brett。

  • 反正你也无法对.what()做任何功能性的事情,为什么不直接记录日志?那就是它要做的事。没人真的想解析一个what字符串来弄清楚如何从错误中恢复,对吧?不,.what()字符串不是给系统用的。.what()字符串是给我们人类事后收拾残局用的。对吧?好的。

那么我对这种使用带有可变日志记录的异常类型进行日志记录的方式,结论是什么?有人倒吸一口气。John。那有点像boost异常做的事。哦,是吗?是的。它有点不同。想法是,它不是设计成通用异常,而是在你想要进行某种故障排除的情况下使用,因为你感觉在底层,一个底层函数,比如试图读取文件,无法读取文件。所以它想抛出信息,但它可能不知道文件名。是的。而那是你想知道的。所以这正是我会想要的东西。是的。所以在boost异常中,我认为他们实际上发明了一种语言,你可以在异常向上传播时向它添加信息。我不需要一门语言。我只需要这个。我只需要简单的异常类型,对吧?所以我是一个简单的人。对我来说没有复杂性。我知道这到处都能工作,但像C++20、23、30,我们有像追踪系统之类的东西来处理那种错误,对吧?嗯,让我打断你一下,因为我有很多要讲,有很多要抱怨。所以我需要时间。好的。

接下来,“异常检查”。情境是什么?我有某种结果,我想验证它是否有效。如果无效,让我抛出一个异常。好的。所以异常可以以一种简洁的方式短路栈。好的。它读起来很干净,即使执行起来可能有点昂贵。所以我要在这里寻找的模式是:异常抛出者避免返回函数。那么它会是什么样子?嗯,我这里有一个带注释“如有问题则抛出”的东西。对吧?有人对这段代码有问题吗?是的。你不能像对错误做出反应。当然你可以。没有catch块。正确。有人说没有catch块。这是使用异常的美妙之处。没有catch块。真的很敏锐。看,执行send,获取response,返回response。容易看懂。这个解析起来不那么容易,在你的认知负荷上。我在说,哦,看,我有一些……假设这是本地类型处理。我仍然有我的void problemFunction。现在发生的是它抛出异常。我捕获它。开始看起来一团糟。对吧?看起来像一个浅层返回。我们不是一路向上栈。对吧?所以这里是checkResponse,这里是错误。现在如果我只是说,看,这是我的checkResponse,它说,嘿,我将返回一个枚举。我不像……我并不是要求函数用返回值做双重职责。对吧?这是我们对异常的一个抱怨。它只是坐在那里什么也不做。对吧?所以我只是说如果有一个响应错误,就去把它打印出来。抱歉,我走得太快了。好的。这里这个东西你可以忽略。我只是做了一个糟糕的选择,使用了enum class。所以我必须静态类型转换并做这种花哨的操作来取出一个代码。但本质上你只是把它变成了一个简单的返回值。它更高效。对吧?我认为它更容易阅读。所以我说的是:如果栈返回是浅的,不要使用异常。优先使用返回值,因为你有空间放一个。有人?好的,很好。我们继续。

那么“异常尝试器”呢?有人在演讲前找到我谈过这个。对吧?我有一个潜在的临时问题,重试可能会解决它。所以这不是一个临时的异常。好的。所以异常可以被捕获并用作重试计数机制。它就在那里。有点乱,但我想很简洁。好的。所以我要寻找的模式是:异常在循环中,并且有条件地重新抛出。那是什么样子?像这样。人们怎么看这个?你得到了很多反对票。Lainey。

  • 如果抛出和捕获很昂贵,那么连续做多次将会非常昂贵。对吧?它打破了规则,你知道,我们在山顶上说过它很贵。不要经常使用它。这里我们准备重试计数次。但如果它是重试,它是一个某种程度上预期的路径,在这种情况下它不应该是异常的。对吧?它甚至不那么异常,也不是我们预期可能永远不会做的事情。所以这只是个糟糕的使用方式。我们不是用它来处理错误。我们是用它来尝试解决一个临时性问题。是的。

  • 我认为在捕获异常后尝试推理程序的状态是一种坏主意。嗯,这里我认为可能没问题。Andres。

  • 如果Connect接受一些你移动到代码中的参数会发生什么?嗯,我的意思是,如果你编码不好,你会得到……玩愚蠢的游戏。赢得愚蠢的奖品。是的,Alex。

  • 而人们实际上会写这种代码。对他们来说这样做更容易,因为与其使用几个if语句并尝试重试和做点什么,他们说,我们就抛出,然后在函数的末尾捕获它。容易写。很糟糕,但容易写。

所以让我展示当有更多事情发生时会发生什么。所以我们不仅需要连接,还需要通知某人那个结果。好的。现在我们必须开始在try块和catch块中复制功能。好的。它只会变得更混乱更狭窄。所以在这里我们应该做什么?这里再次出现你的条件重新抛出。它只是包含了更多已经在try块中的东西。所以我说的是使用一个简单的返回值。我认为你的API设计有问题,不完全是异常的问题。你只是选择了简单的方式。你需要重新思考你的API,并审视所有的……我们没有重复。任何需要做的事情,notify都在之后完成。重试循环看起来相当简单,而且高效,对吧?我们不抛出异常。那里可能还有sleep之类的东西。所以我的结论是什么?对于循环控制,优先使用返回状态。实际上,如果栈返回是浅的,它不应该在那里。

那么“异常难寻”呢?就是你在后面做的,对吧?你说,看,我在搜索某样东西,但找不到。好的。一个干净的方式来表明一个函数未能履行其合约,也就是我搜索某样东西但找不到它。这是“找不到就发脾气”,对吧?我找不到它。你来处理。对吧?如果你不处理,我就让你的整个代码库崩溃。所以这里要寻找的模式是什么?异常抛出者是一个搜索函数,它在找到时返回对象,在缺失时抛出异常。它看起来像这样。我将在这里寻找一个订单。这都是基于真实代码的。我不能给你确切的代码。太大了。但这里我们有,我们找不到它。抛出一个OrderNotFound异常给其他人。在下面这里,我们会捕获并说订单缺失。

我们在语言中有什么可以帮助的吗?optional。是的,对吧?optional。对吧?这里我们有optional返回,它表示我们是否有东西或没有东西。好的?我们可以返回std::nullopt。然后在这里我们可以看到是否有错误。再次,更高效。我认为读起来更清晰。是的。

  • 当我们读取一些其他代码并假设值应该在那里时怎么办?如果它不在那里,那么问题出在代码上。对吧?所以假设在你的特定情况下,没有订单,有人发送东西并说,我想让你修改这个订单。对吧?他们说,嗯,找不到它是一个事务取消事件。是的,你会抛出一个异常。但是别人在写这个findOrder函数时怎么能知道呢?对吧?再次,这涉及到那个隐藏的关系。异常只有在你知道从抛出点到捕获点再到它被使用的上下文的整个关系时才能最好地工作。而如果我们只是把它抛给你,很多时候这是缺失的。是的。所以抱歉,你的意思是……所以这将是……你的意思是如果用户然后说,好的,我想抛出,但optional返回了。你可以抓住它。我的意思是,我们有地方返回码是对的。重新包装成异常,反之亦然。是的。

  • 你为什么选择optional而不是expected?因为我想表示……嗯,让我看下一张幻灯片。哦,是的。是的。对吧。现在,因为C++23在这里,而我很久都不会用它,我玩过一些单子函数,我认为在Clang上还没有。只在GCC上有。但在这里,我可以说,嘿,你有一个.or_else,意思是如果你在optional中没有得到一个值,就执行这个lambda。所以它做了同样的事情。所以随着时间的推移,我们可以变得更时髦和更函数式,这显然是一件很棒的事。好的。所以,优先返回一个optional,它表示找到或没找到

那是给你的答案,Desi。我告诉过你等一会儿。我会讲到那个。你们,我知道你们是聪明的听众。耐心点。好的。让我们进入“异常数据传递”。现在我们进入有趣的部分了。好的。所以处理程序需要基于抛出点的数据执行特定操作,而这些数据在处理程序点不可用。所以异常可以,正如我们在原始设计标准中看到的,可以向上传输到栈顶的处理程序。它比传统的返回值更昂贵,但它更容易唤起,并且不需要在所有函数中传播这个值,这正是你所说的。对吧?

所以我要寻找的模式是什么?异常包含用于执行处理程序操作的数据,而不是仅仅用于日志记录和重置。所以那是什么样子?它看起来像这里的小怪物。好的。看,有人把一些数据塞进了异常,然后在下面的处理程序中,他们使用那个数据来执行一些操作。数据传递。人们怎么看这个?完全没有意见。我以为会有很多好斗的人攻击我。这太温和了。好的。那么我怎么看所有这些?嗯,我认为,再次,这里是每个人都问的expected。好的。所以它是一个浅层返回。看起来像一个浅层返回。我将对我的成功数据使用expected模板,也就是消息。所有这个坏客户端。然后我执行apply,数据通过正常的返回机制传回来。我可以说如果它有值,我们都高兴。引用它并发送。否则,使用消息。所以这里我们传输的不仅仅是它在那里或不在那里的概念。我们传回了一些失败数据。现在,有人对此有什么问题吗?好的。

现在,有一件事让我很恼火,在异常方面真正让我抓狂的是这种想法,你知道,每件小事都需要一个类型作为异常类型。对吧。所以这里我们有四个类型,其中我有一个BadClient异常。我将把那条消息数据放进它里面。我有一个BadOrder异常。我将把那条消息放进它里面。但因为理论上在我的脑海里,它们是由不同原因引起的,我命名了不同的类型,即使我在功能上对它们做相同的sendMessage操作,它们是相同的。或者我说,你知道,我会在那里放一个格式代码,因为在捕获点需要它。我得到了所有这些为了非常简单的事情而构建的额外类层次。

我们会在语言的任何其他地方做这个吗?所以为什么不这样做,对吧?异常类有一个模板参数。好的。它在这里。一点用户定义的数据。好的。当我创建它时,我只需把字符串和我想要的数据放进去。好的。实际上,那上面不应该有引用。然后我可以在抛出点……在捕获点访问数据。好的。不管它可能是什么。如果我需要在里面捕获一些数据,因为我没有确切抛出点的上下文,我会使用可变版本的东西,把数据放进去,然后我们抛出它。对吧。有人喜欢这个吗?不喜欢。不喜欢。为什么不喜欢?

  • 因为如果你不知道数据类型也不关心,你怎么捕获?我的异常,你用模板中指定的类型捕获。你……我不明白为什么。这真的不是问题。我的异常不一定是相同的。你必须确切地知道模板类型是什么。是的,是的。我会解决这个问题的。我认为我们做了太多的多态,但我们还没到那儿。对吧?所以,这是我之前有的东西。我有一个模板类。这就是发生的一切。好的。然后在下面这里,不需要改变任何代码。我只是不需要为每一小块数据写一堆类。好的。所以,David,这回答了你的问题吗?所以,我说什么?我的结论是什么?如果栈返回是浅的,优先使用std::expected,其中成功数据是成功类型,失败是失败数据。另外,使用一个简单的模板来传输不同的失败数据。不要为了大量异常类型而膨胀你的代码。我们在其他地方不会这样做。为什么我们要在这里这样做?是的。

  • 这里的困难在于,当你编写一个函数时,很难知道所有可能的用例是什么,以及它们是否会有浅层返回还是长的……是的,完全正确。我们会讲到那个。关系是隐藏的。我不断回到这一点。对吧?第三方编写者、库编写者如何知道?他不知道。他必须猜。

数据。数据。让我们谈谈“异常控制流”。这是一个很好的话题。应该能让人们情绪激动。对吧?处理程序需要根据发生的失败执行不同的特定操作。对吧?这就是为什么我们有多个catch块。对吧?根据捕获的异常类型做不同的事情,这在理论上是受到反对的,我们说过不要将异常用于控制流。然而,这些东西的设计有多态捕获之类的东西以及额外的处理程序,对吧,几乎是在恳求人们这样使用它。所以当人们这样使用它时,我不知道你还能怎么太生气,因为你设计的东西就是为了这样工作。对吧?所以我要寻找的模式是什么?单个try上有许多异常catch块,并且每个块可能有特定的功能,而不是仅仅记录日志和重置,我认为这才是纯粹的方式。

所以那会是什么样子?这里。有人对这段代码有问题吗?是的。我见过很多这个。有人?这是好的,对吧?好的。好的。那么我要说的是让我改变它。让我说,看,我将把我的异常处理类作为所有可能抛出的异常的变体。好的。在下面这里,我可以说我的std::expected是我的成功数据和失败的变体。然后我有常规的返回机制。再次假设我们有一个浅层返回。result有一个值。快乐路径。快乐路径。如果它没有,我使用这个非常时髦的visit,我有一个通用的Lambda。对吧。所以无论传进来什么,我都对它调用sendError。这里。我会问,即使那只是一小段代码,如果你有三个不同的异常类型都导致相同的代码路径,你实际上并没有三个异常类型。是的。我会在一分钟内讲到那个。对吧?但我们确实看到了很多这样的情况,所以我必须处理它。

现在,人们可能会说,看,你很可爱,Peter。你把所有相同的东西放进了不同的块里。如果它们都不同呢?你知道,你的范式如何能处理这种转变?它现在变得狭窄了。我将发送警告、发送错误、处理回行。嗯,这个小类,我在之前的项目中写过它。我把它拿出来,我想是来自C++偏好或别的什么。我所做的只是添加了一个推导指南。所以它的作用是,你给它一堆Lambdas,它继承自它们。我们拉下operator()。好的。我给你的只是一个推导指南,它说:如果可以,移动数据,不要复制它。C++偏好中的那个是执行复制而不是移动。

所以当我使用这个时,我只是说,看,如果我脱离了快乐路径,我现在有一个包含所有这些处理各种异常类型的Lambdas的overload。我将调用访问模式。当我在生产环境中这样做时,可调试性变差了,人机工程学变差了,编译时间变差了。所以我只遇到过其中一个。抱歉?我只遇到过其中一个。猜猜是哪个?编译时间。是的。如果……如果……如果异常列表变得巨大,它就成了编译时问题,对吧?这就是为什么我要解决为什么我们不想要又长又深的字符串层次结构的问题,对吧?因为这是我们自己制造的问题。所以我的结论是什么?如果栈返回是浅的,优先使用std::expected,其错误类型是异常类型的变体?简单,直接。好的。

现在让我们进入真正最让我恼火的部分。好的。异常层次结构。所以情境是什么?我有很多不同的错误模式,我想使用异常类型来代表每一个。我有一个非常丰富的异常类型层次结构。所以异常可以是多种多样的,对吧?类型指示了问题的某种分类。我们通常期望真正的数据在消息中。换句话说,它的名字,我发现它并没有传递太多信息给它的使用者。所以我要寻找的模式是:一个带有许多执行相同操作的处理程序的try块,这是我们早先例子中有的,或者一个用于父类的单一catch处理程序。所以我的意思是什么?这里是多处理程序问题。有人看出这里的问题吗?冗余。冗余。冗余。冗余。是的,但暂时忘记那个,因为很多人这么做。好的。别的东西。有些异常在右边来自其他。是的。我有logic_error在顶部,underflow_errorrange_error派生自它。所以它永远不会被捕获。所以当你有一个列表时,我见过30个这样的列表,对吧?我必须正确地重新排列这个。确保有一个单一的错误来源,对吧?这里是多处理程序。我该如何修复这个?只需捕获std::exception。那就对了。看,只需捕获基类,对吧?当你对异常的使用功能相同时,为什么要为它做所有这些额外的异常类型?你的异常使用功能是相同的。现在,这解决了问题吗?没有。我必须回到我的代码库中看看是哪个小丑做了这个,对吧?好的。换句话说,这里是所有这些携带消息的类型,这是我们在日志文件中如果出现问题唯一会阅读的东西。对吧?但它们执行单一功能。记得在基类那里,我们在做什么?我们在捕获它并只是做e.what()。那么我们为什么要抛出所有这些呢?我们为什么不直接这样做?抛出基类。我们为什么要抛出功能?用法应该定义发生了什么。我们为什么不这样做?

  • 但你说过,如果异常在形状上是纯虚的,那么这个异常是纯虚的。它是纯虚的。它的what() = 0。嗯?它是。它的what() = 0。那不是,你可以像这样实例化它。是的。

  • 所以这里的问题是这里没有接受字符串的构造函数,对吧?我敢说,我在层次结构中遇到的最奇怪的决策之一,比如,我不知道,我错过了几个不满,但这绝对是另一个不满,对吧?为什么我们这里没有那个?所以我必须把这个东西回退到一个runtime_error,对吧?可悲的是,我必须把它回退到runtime_error

  • 但原作者的想法是通过不同的类型提供更多上下文吗?它提供了什么上下文?我的意思是,除以零和……是不同的。那又怎样?你处理它的方式是一样的,对吧?好的。嗯,但你知道人们会介入。是的。是的。是的。所以它回到了那些我不确定谁能处理不同类型东西的情况。你不是以不同的方式处理它们。如果你对它们中的每一个做不同的事情,比如区别处理它们,我会说绝对是不同的类型。但问题在于库做出了那个决定,但却是调用者决定……那个隐藏关系又来了,John,对吧?是的。是的。总是让我们陷入困境。

所以看,现在我们有一个单一的函数,因为那是我们在catch块中对异常使用的单一功能。现在,我有一些问题。是的。我有很多问题,对吧?如果catch处理程序是通过父类捕获的,为什么抛出者不抛出父类?假设他知道,对吧?也许如果他不知道,我能理解。但假设它是你的代码库,你在编写这两部分。是的。

  • 在结构层面和依赖管理层面上,与捕获之间存在高度耦合。但你不是。你只是调用它的.what()。那不是耦合。不,不。就像,为了构建,你必须添加依赖项,这样你才知道什么是phase类,什么是chop类。就像,除非你添加依赖项,否则你无法编译,因为你必须知道……你是说你必须知道层次结构?你必须有一个依赖列表。我可以告诉你,在我注意到使用标准异常的大多数地方,通常是runtime_error。大多数人在很多时候使用它。因为他们会查看日志然后继续下一件事。

  • 在设计上存在高度耦合,你更具体,而在基类中,你可以使用它们。我说那是一种错觉。嗯,从构建系统的角度看,不是。好的。好吧。我不是构建系统专家。

所以,我们需要谈谈这个。那么,为什么会有任何派生类呢,对吧?如果我们不用它们来做控制流,做不同的事情。并且如果catch处理程序对不同的异常执行相同的操作,为什么这些不用一个单一的异常类来表示?好的。你看,我试图在这里消除这些层次结构。是的。有时你可能确实想使用像虚方法来做不同的事情。是的。所以你在做不同的事情,那很好。当你做完全相同的事情时,才使用新类。好的。我认为,这是我希望你思考的一个关键方式。异常是如何被使用的? 不是你脑子里运行的理论信念导致了什么?对吧?现在,我还有更多问题。是的。我是个简单的人。很多问题。所以,std::exception没有字符串构造函数。这是问为什么的理想地方,因为我真的不知道。好的。现在,有人知道吗?还是你们只是猜测?因为我有几个猜测。它被设计成一个基类。它被设计成那样。它里面有一个字符串吗?如果你有一个字符串,你会调用runtime_error。但为什么?不,是因为你可能想做某些不分配内存的东西。不,异常里面有字符串。what()是什么?字符串在哪里?它是一个叫做what()的虚函数。是的。好的。我想,但我知道。如果我用任何类层次结构这样做,你会砍我的头。好的。字符串被内置到异常中。它在别处暴露。好的。我甚至不知道它在哪里,对吧?这对我来说太奇怪了。是的,Eduardo。

  • bad_alloc,派生自exception,对吧?你不想分配bad_alloc,因为你可能无法分配bad_alloc,对吧?你需要字符串在某个地方。使用那些标签,对吧?你不需要字符串。它是一个char*。它是一个更聪明的char*。好的。你可以在你的错误代码、你那古怪的说服中做同样的事情。那是同样的故事。你必须用无效来设置字符串。好的。嗯,谢谢。也许我会在前进中重新表述一些我对这个的尖刻。

那么runtime_error做了什么?嗯,我进去看了看,它说:runtime_error类定义了作为异常抛出的对象类型,用于报告大概仅在程序运行时可检测到的错误。它没有给我们任何硬信息。就像,你知道的,大概可能发生。有点让你走开。那logic_error呢?那是一个逻辑错误。你不应该抛出逻辑错误。你应该断言它们。所以,是的,但我们可以抛出它们,John。我可以成为掌控邪恶的天才并抛出逻辑错误。好的。那么什么是逻辑错误?嗯,让我读出来,然后我会找你们。logic_error类定义了作为异常抛出的对象类型,用于报告大概在程序执行前可检测到的错误,例如违反前提条件。然而,我们只在程序运行时才遇到它。对吧?好的。那么我们为什么会有……我们能明智地发现这些东西吗?所以这两个类在操作功能上有什么区别?一个应该存在。是的。太晚了,John。精灵已经出了瓶子。我猜如果你有更好的概念会更好。所以这些类在除了名字之外的一切方面都是相同的。除了名字的文本替换之外,没有区别。是的。现在谁想来找我?Roy。

  • 你提到过,像如果出现内存损坏之类的事情,实际上没有尝试继续运行的用处。所以我认为逻辑错误有时也是如此,就像有一个迹象表明在那个点上没有继续运行的意义了。不。我可以说运行时错误也适用于同样的论点。不。是的。演讲后和我谈谈。

  • 所以std::logic_error是在人们认为如果在运行时检测到程序中的缺陷,你会抛出std::logic_error的时候引入语言的。但在此之后有了非常强烈的共识,认为如果你在运行时检测到缺陷,你应该有一个断言失败来终止你的程序。所以这就是它的来源,通常人们认为std::logic_error是为了维护旧代码而存在的,但它真的不应该存在,也没有人应该在新代码中使用它。好的。很好。所以摆脱它。我很乐意那样。我讨厌这两个东西放在一起。但我要说的一件事是,runtime_error,这真的是对这个类的好描述吗?std::runtime_error?它糟透了。它应该是runtime_logger之类的。它应该是exception_logger。像这样。那就是它所做的事。它不做其他任何事。好的。现在。在你使用异常的情况下,捕获一个runtime_error,你在那里使用它。但你会把捕获逻辑错误和关闭程序分开。这就是你区分它的原因。不,我永远不会关闭我的程序。我在彭博工作,如果我仅仅因为有人抛出一个逻辑错误就关闭我的程序,你知道的,我正打算在这个房间里投递我的简历。那是真的。那是真的。因为你在留下金钱。你的计划是什么?在非容错环境中,你不能让异常使你崩溃。是的。

  • 所以早些时候,我以为你主张应该将异常处理与日志记录分开。如果那是真的。我从未那样说。好的。我会说。我认为也许你应该将异常处理与日志记录分开。在那种情况下,异常处理真的针对程序,而不是将要查看日志的人类。如果你采取那种哲学,那会改变……但我认为那种哲学从根本上是有缺陷的,因为对于异常,我们主要使用它们来丢弃一个工作单元,记录错误,并把它放进日志给人类看。是的。这主要是因为程序员是白痴,对吧?我的意思是,因为……我是一个程序员。看,你说了我是。但问题是,对吧,当你……我假设你也是。我曾经是。所以,当你使用catch进行日志记录时,你得到一个抛出,然后你捕获它,记录它,打包它,重新抛出,捕获它,再次记录它。然后你得到这些巨大且难以理解的日志。是的。我的意思是,如果你不断捕获,我见过一个系统,其中有太多将工作分派给不同线程的情况,每次他们捕获一个异常,他们就记录它并抛出它。这让系统不堪重负。它的性能糟透了,对吧?所有的异常,我从中撕掉了大部分异常,对吧,让它工作。好的。我仍然必须继续前进,但我会再回答一个。因为是第一次,我想就日志记录反驳你,因为我同意runtime_error,因为它有一个字符串,它可能会用那个字符串做些什么,否则你能做什么?记录它。但你刚刚说异常通常只用于记录日志。我说在main函数中,在main函数中,是的。是的。但有一件事在你的例子中出现了很多,那就是这个问题,如果你的代码栈深度非常浅,是的,栈栈,那么你可以直接返回一个optionalunexpected。是的。让其他人去处理它。所以对我来说,异常是那个消息。我从未在我的异常中有消息,但我使用它们是为了那个异常的栈退出,说,对吧?文件错误发生在很深处,但处理必须在……。是的,是的,是的。我们谈论的是你的栈遍历时间,这正是我们要讨论的,对吧?所以再次,这些在各方面都是相同的。所以我决定让我们回到基础。因为再次,有很多争议,我要引用莎士比亚。好的。流行的……我不知道它在这里是否那么流行。它在欧洲是。任何其他名字的玫瑰闻起来都一样香。对吧?来自罗密欧与朱丽叶,伟大的家伙,莎士比亚。这个引用被用来声明事物的名称并不影响它们真正是什么。换句话说,我可以说那是一只企鹅,对吧?但玫瑰换了任何其他名字还是玫瑰。现在,那可能太艺术了。所以我们来看点更具体的东西。如果有人曾经在CppCon见过我,我的小东西写着:如果它走起来像鸭子,叫起来像鸭子,别告诉我它不是鸭子。好的。那么这是什么意思?这意味着某物是通过其习惯特征来识别的,而不是你随意给它起的任何名字。对吧?这里有一张小图,对吧?这可能是一只伪装的兔子。对吧?但事实并非如此。对吧?所以,想想你的异常是如何被使用的,而不是你想要的某种意图,某种书呆子式的意图。好的。非常教授心态。所以我说什么?我再说一遍,我不指望普遍接受,如果catch处理程序是基类,就抛出基类。并且你有那个控制。另外,如果多个catch处理程序中的操作相同,优先使用单一异常类型的catch处理程序。所以所有那些三个不同的异常只是做了一个sendError,比如为什么那里有三个?为什么不只是一个异常类型?所以这一切都应该开始压缩并摆脱这些庞大的异常层次结构。

这就是我对基于用法的观点的简要概述,正如我在开头所说,定义异常的是它们如何被使用,因为没有其他依据。那么让我们谈谈“异常展开或不展开”。好的?“异常展开”。我看到这个,我认为栈展开有三个类别。

  1. 异常终止进程。有些事情我不知道,也许我正在向上走,无法连接到数据库,所以我无法加载状态。对吧?那太糟糕了,我需要退出。所以现在我可以在栈的深处做,然后说exit,但那是不礼貌的终止别人的进程。对吧?所以你只需把它送上去,它被捕获,我们说终止,无法连接到数据库或其他如此糟糕的事情。

  2. 我们拥有的另一件事是栈展开中事务性致命的部分。换句话说,这将出现在你的主处理循环附近,我们会说,嘿,这个工作单元完蛋了。对吧?发回一个错误说我无法处理它。做任何事。在你的日志文件中做一些通知。然后拿一个新的工作单元,希望是更好的一天。好的。

  3. 最后一个类别是本地错误处理。好的。这可以用来擦除、缓解、从异常中恢复。我见过有人使用第三方库,如果存在重复项,它会抛出异常。他们说,如果那一直传播到顶层,它会抹掉异常或抹掉事务。但就像,这个东西我们有时有,有时没有。我们不在乎。也许我们是第一个,如果没有就创建。对吧?这可能是对异常的误用。而且再次,这回到了那个隐藏关系和返回深度。如果你孤立地编写抛出点。并且如果栈返回是浅的,我不断说你也许应该在没有异常的情况下处理。有更好的方法。

有人想评论这个吗?是的,又是你。你说什么?你是说当第三方库抛出时,你应该捕获它然后返回错误?不,我是说第三方没有足够的信息来知道我们是否应该抛出。所以让我们现在谈谈真正异常的东西,对吧?就像仰望神圣一样,对吧?关于异常。好的。它们应该用于什么?我说它应该用于错误追踪和日志记录。如果这是我们用这些异常做的主要事情之一,那么我们为什么不为此优化?错误是什么?我们有那个,对吧?发生了什么?代码中错误在哪里?我们可以使用源码位置来提供这些信息。在C++23中,我们可以说,当这个错误被抛出时,我们在栈的哪里?对吧?然后使用基本的栈跟踪。有一篇论文讨论了获取一个静态函数来为异常获取栈跟踪。它看起来异常复杂。所以我不是粉丝。好的。

我们用异常做的另一件事是什么?栈展开,深栈返回,对吧?你的解析器,我忘了你在做什么,Sebastian,但无论是什么,对吧?用于深栈返回。我正在终止一个进程,或者我正在终止当前事务或工作单元。最后一件事,控制流。是的,它发生了。我们在现实世界中有需要基于异常做不同事情的程序,这就是控制流。封装控制数据。是的。那是我的观点。我是你的第一个要点。我们有更好的追踪异常的追踪系统。嗯,你最好把它们带到行情服务器。你需要把它们带到行情服务器。对吧?因为我没在那个里面见过它们。好的。并且在没有冗长、重复、重复的类层次结构的情况下完成所有这些。能做到吗?我们能有一个卓越的异常类吗?好的。嗯,我不知道谁说过“我是阿尔法。我是欧米茄”。对吧?万物将归向我。砰。它来了。我对完美的想法,嗯,它是幻灯片软件,所以不完全完美。但我认为它基本上能完成我不得不使用的每一个异常场景的工作。那么,我们有什么?让我们看看这个怪物并检查它。好的?首先,我们有一个地方放用户数据。所以我们不需要为了不同的数据类型写又长又深的链。构造函数怎么样?在我实际的godbolt中有几个,但这个只是移动东西进去。好的。你会看到std::source_location只是获取它被实例化时代码中的当前位置。栈跟踪也一样。所以一旦我有了那个,我能用这个东西做什么?嗯,我有可变的.what()?对吧?你想在传播过程中添加东西进去。这不是一个万能尺寸。我有一个源码位置。这发生在哪里?我有一个栈跟踪。这花了点功夫,两者都得到了。我认为在Clang中还没有。但在23版本中,我们有了这个,我们现在可以取出这些东西。我认为这很棒。我喜欢它。对吧?然后我们有用户数据,所以我们可以访问数据。我认为预测,预测。我有预见能力。预测。如果类似这样的东西开始在代码库中使用,当人们看到日志文件中的常规runtime_error时,他们会哭泣。对吧?就像一个.what()?我不知道它去了哪里。我要用grep搜索,翻看源码寻找它。不知道我是怎么到那里的。有多个路径进入。他们会说,伙计,我真希望有那个欧米茄异常类在附近。我不得不说Pete Muldoon真的搞定了那个东西。它除了内存不足外到处都适用。如果它不……内存不足,我们说过它不是异常的事情。内存不足,我不处理,因为异常不处理内存不足。是的。

  • 我会争辩说让它逐步更有用,你可以把位置放在栈里,放在一个非模板基类中。这样你就可以访问它了。我很高兴你提出这个,因为这意味着没有RTTI在继承链上到处跑。对吧?这只是你到达那里。没有其他人你可以去找。砰。你把RTTI追踪去掉了。但是的,如果你想,你可以取其中的一部分说,嗯,有些类型我需要放我自己的数据进去。是的。你们怎么做?等一下。让我找Andres,他几乎是我这次演讲的导师。你已经知道我要说什么了。这不是一个类。它是一个类模板。是的。我们能类型擦除数据吗,拜托,让它成为一个类?不。我不想类型擦除数据。那是数据隐藏的一种形式。奇怪的事情。这很简单。我是一个简单的人。这就是我做事情的方式。对吧?让我找这边的Brett。我要重申几件事。这很棒。如果我的依赖项已经依赖于小欧米茄,我不知道我们把它放在哪里了。依赖于什么?嗯,异常的部分意义在于你可以从一个地方弄清楚并到达另一个地方。对吧?两个地方都必须依赖这个类型。所以你必须,所以Robert和Petr都必须依赖小欧米茄。这是你必须一直做的一个约定。抛出者不能抛出捕获者不知道的东西。所以你有一个特殊的限定,那就是,异常只在……。我将在使用中展示它。也许,也许在使用中这会……这是理论上的东西还是我能展示它的用例?嗯,我该和谁谈?实际上,让我在使用中展示它,然后我们再讨论一点,因为我时间不多了。是的。所以,看,我有一些实用函数。我不喜欢,我认为栈跟踪通常打印出来的方式很糟糕。给你展示你不在乎的东西。所以这就是我把它们放在一起的方式。现在让我们看看这个东西的实际效果。准备好惊讶吧。是的。你还不能进来。我还没……为什么它是class而不是struct?它有什么要隐藏的吗?我不在乎。对吧?所以,让我们看看这个东西的运行。好的。看看一只猎豹。它看起来不太好。你看着它像在……跳跃,伙计,那真的很好。让我们看看这个东西的运行。所以,看,这里我正在从我的异常模板创建异常类。好的。我说,看,我在做几个枚举。类型无关紧要。我说,看,抛出一个BadOrderID异常,它是真的糟糕。那是我的电子邮件。好的。在下面这里,我说,看,捕获我的异常之一。所以你不必捕获,你可以捕获,你可以通过模板化获得那种细化。我可以说,嘿,看,它处理失败,代码是二,也就是,呃,真的糟糕。好的。它发生在这个函数的这个地方的代码中。是不是很好?大家都说,啊。我会让你更印象深刻。好的。我有这个数据桶。我需要用这个数据做点什么。我有一个数据桶。只是一个简单的,带有一个消息和一个ID。模板化在这个类上。一个尺寸可以到处使用。我就抛出这该死的东西。这里是一个坏错误。这里是一个桶,里面有一个客户端ID和一个消息。然后我捕获它并说,看,我处理这个失败。我伸手到桶里取出ID,打印.what(),一个栈跟踪。然后我用里面的小消息执行sendError,就像我们之前那样。这是你得到的结果。处理ID 222失败。这是它是如何完成的栈跟踪。好的。Sean,你还有问题吗?我从来没有问题。我只是在使劲抓你的耳朵。看起来像这样。嗨,David。好的。所以两个问题。所以第一个是你必须知道数据类型才能捕获异常。不,这是这里的类型。不,我说的是模板参数。如果我根本不在乎你的异常桶,我只关心是否有任何类型的欧米茄异常被抛出,我无法制作一个捕获块来捕获那个异常。你很容易修复这个。只需给它一个基类。是的,我知道。是的。好的。所以修复那个。第二个。在我以前工作过的一家公司。我不知道。有一次相当严重的宕机。是的。因为有一个类似的系统,他们在那里记录栈跟踪,这往往会变得相当大。而且它能相当快地填满你的硬盘。你碰巧有异常不断抛出。我知道你过去在哪里工作,我知道我们无论如何都会产生大量的日志文件。嗯,我可以告诉你。而且这可以是,你知道的,就像你想放一个修剪然后说,看,我只想要栈跟踪的X部分,那也可以。你甚至不必调用栈跟踪。嗯,让我告诉你,如果你想让它在一个真实的生产系统中工作。是的。你想确保你不是输出整个栈跟踪,而是想输出编码后的,然后能够使用可执行文件来获取漂亮的部分。或者也许你已经在一个追踪系统上工作过,你只需要做那个。我认为那个追踪系统已经出现了。所以,David,我不会那样做。除非我被设计所约束。换句话说,我可以让这个类更复杂以适应那些约束。如果它们不在我当前的环境中,我不会那样做。我不会为了一个极其无关紧要的角落案例而做一个有很多奇怪事情的类。是的。而这归结为价值判断,对吧?所以,是的,你可能是对的。

所以,看,我只剩几分钟了。总之,我们已经完成了这段漫长的旅程。它很娱乐。它很棒。但我们必须结束这场秀了,对吧?异常是由用法定义的。那是我的,那是我现在所依赖的东西,对吧?我曾经认为没有异常的位置,但当我开始深入研究时,我错了。它们有一个小位置。所以它们应该用于什么?错误追踪和日志记录。所以你帮助调查。栈展开,用于深栈遍历,因为你不想污染你整个返回值的东西来尝试传回一个值。数据传递和控制流在必要时。能够从抛出点一路拉取数据到捕获点,这样你才能正确处理问题。所以那就是它们应该被用于的地方。它们应该什么时候使用?嗯,我认为尽可能罕见。用于严重的、不频繁的、意外的错误。并且使用尽可能少的异常类型,由catch中使用的功能决定,而不是你脑子里运行的东西。最好使用欧米茄异常。我给自己打个广告。好的。就这样了,伙计们。关于异常,我无话可说了。感谢你们来到这里。你们真是好听众。现在,这里是我其他所有与现实世界相关的工程演讲,如果你想看看我其他一些疯狂的幽默。这里是许多godbolt示例,如果你想看看我做了什么。好的。现在,我们还有几分钟时间提问。所以,到现在你还没问我一个问题。所以,我们谈到运行时错误和逻辑错误的区别的那部分。你引用了规范说在程序运行前捕获不变式错误。那是什么?在程序运行前是什么?我称赞了环境,我们认为是那个角色结构的概念。是的。所以他们在那里指的是你应该在前期就发现它。所以错误不应该出现在你的代码中。至少这是当时的想法,这或多或少是不可能的。这是一个很好的补充。

好的。好的。下一个。我们时间有限。Jeff。