std::find() 是坏的¶
标题:Warning: std::find() is Broken!
日期:2022/05/29
作者:Sean Parent
链接:https://www.youtube.com/watch?v=2FAi2mNYjFA
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:标题党,聊的事情太深奥了没看完。
欢迎大家。我是 Sean Parent。我是 Adobe 的高级首席科学家。直到最近,我在那里的头衔还写着在 Photoshop 团队,但最近改成了软件技术实验室(Software Technology Lab),这个实验室是 Dave Abrahams 负责的,他可能就在这里,就在这儿。所以 Dave 和我正在重启 Adobe 的软件技术实验室。我在幻灯片上放了这个标题,“警告:std::find
是坏的”。这是我“点击诱饵”式的标题。我只是想让你们大家坐下来听完 90 分钟的形式化方法轻松介绍。所以谢谢大家能来。要理解 std::find
是怎么坏的,我们必须先理解一点我们是如何推理软件的。那么,几年以前,好吧,超过 25 年以前,我在 Apple 工作。当我刚被 Apple 录用时,我被分配去修复一个 bug。然后我去修复了那个 bug。接着我带着我的修复方案去找我的导师进行代码审查。那时,导师是一个叫 Darren Adler 的人。你们中的一些人可能认识他。
Darren 对我说,那是个很棒的修复。确实是个很棒的修复。但你还没做完。我希望你回去想想,我们能做些什么来防止其他人犯同样的错误。那个问题让我重新思考了我对编写软件的看法,对吧?你如何开发健壮的、有弹性的、不会失败的系统?于是我花了很多时间思考和致力于此。几年后,我把这个故事原原本本地告诉了 Alex Stepanov。这是他的原话。他说,嗯,理解软件为什么失败很重要。但真正的挑战是理解软件为什么能工作。这句话里蕴含着非常深刻的道理。乍一看,它们似乎是等价的。但我希望在这次演讲的过程中,我能说服你们它们并不等价。那里有更深层次的信息。在过去的近 20 年里,这一直驱动着我思考如何编写软件。
那么我们如何思考软件为什么能工作?这可以追溯到 1967 年的这篇论文。这是 Bob Floyd 写的一篇论文。有一张 Bob 的照片,标题是“为程序赋予意义(Assigning Meaning to Programs)”。Bob 在这篇论文中所做的是开始构建一种我们可以用来推理代码及其正确与否的形式化方法。他设想,我们可以拥有自动验证系统,这些系统可以检查我们的代码并验证代码以确保其正确性。其工作原理是,他为操作分配逻辑命题,作为前件(antecedents)和后件(consequences)的验证条件。所以基本上,就是这个操作工作所需的条件,以及在这个操作执行后会发生什么。而这确实是着眼于机器层面的东西,对吧?计算机上的指令做了什么?这就是他所研究的内容。嗯,Tony Hoare 在 1969 年基于这项工作进行了扩展。在这篇论文中,“计算机程序设计的公理基础(an axiomatic basis for computer programming)”。Tony Hoare 所做的是,他采用了 Bob Floyd 所做的工作,并围绕它建立了一套演算(calculus),对吧?一种关于我们如何在你的机器上推理代码的形式化方法。其结果就是所谓的 Hoare 逻辑(Hoare logic),也称为 Floyd-Hoare 逻辑以代表 Bob 的贡献。它将计算描述为一个 Hoare 三元组(Hoare triple),对吧?看起来像这样。P, Q, R。P 是一个先决条件(precondition)。好的。Q 是一个操作,R 是一个后置条件(postcondition)。所以我们有两个谓词(predicate)P 和 R,然后中间有一些操作。
像这样的语句是组合起来的。在论文中,他描述了如何处理赋值(assignment)、后件(consequence)、组合(composition)和迭代(iteration)的规则。我不会在这里通读整篇论文。但这里有一个关键部分是组合。你如何构建系统?现在,他是从内向外构建系统。好的。所以他正在研究执行机器中给定指令所需的条件,以及该指令的后置条件,并将这些组合在一起来寻找函数的规则。那么,就像 Bob 的工作一样,只要你能保证一个操作的后置条件保证了后续操作的先决条件,依此类推,你就有了某种概念,认为你的程序是正确的。现在,这和我们通常所说的匹配规范(matching specification)意义上的正确性模型有点不同,对吧?如果代码符合规范,那么它就是正确的。这更偏向于它是否能在没有失败的情况下执行?
现在,在我们继续之前,我想介绍几个数学符号,对吧?不想有任何混淆。所以,全称量词是倒过来的 A(∀)。存在量词是反过来的 E(∃)。这些被称为全称量词和存在量词。属于(in),作为一个元素,属于一个集合(∈)。是(is),看起来有点像小写的 e(∊)。使得(such that)是它的反方向(∍)。否定或非(negation or not)是那个小钩子(¬)。蕴含(implies)是右箭头(→)。当且仅当(if and only if),也简写为 I-F-F,是双端粗箭头(⇔),不要与 C++ 中的太空船操作符(<=>)混淆。我们还有逻辑与(logical and)(∧)和逻辑或(logical or)(∨)。
所以,现在你明白我为什么要介绍这些了。这些是整数加法的属性,对吧?我们将整数集称为 Z。我们在这里建立这些属性。我不会全部讲完。但我只读一下,比如说,倒数第二个。这样你就能理解这是如何工作的,对吧?我们这里说的是,必须存在一个零,使得对于所有 a 和零属于 Z 的元素,a 加零等于 a。对吧?这就是加法单位元。所以,要让某物是整数,它必须有一个零。我们可以把它们相加,我们可以把那个零加到集合内的任何值上。然后我们得到我们放进去的相同值。
现在,Z 不等于计算机中的整数。不等于计算机中的 int 类型。对吧?具体来说,当有符号整数上溢或下溢时,行为是未定义的。我们不能那样做。所以在 Hoare 逻辑中,这可以用一个额外的公理来表达。像这样。不存在一个 X,它是 int 的一个元素,使得 X 大于我们的最大 int。由此,我们可以推导出一个 Hoare 三元组,看起来像 A + B 小于或等于我们的最大整数。然后我们这里有一个赋值,int N = A + B。后置条件是 N 小于或等于最大整数。现在,要使这个 A + B 成立,它必须在 int 的定义域之外。这些是在 Z 域中的数学操作。对吧?所以我们使用数学来形式化定义和验证我们的操作。
你可以看到这只是处理上溢。我没有处理下溢。我也没有处理所有其他算术操作以及它们如何连接。所以 Tony Hoare 在他的论文中引用了这句话:“即使对整数算术的表征也远未完成”。他不是说它特别困难。他只是说没有人坐下来完成这项工作。在他们发表这篇论文之前,他们懒得去做这些大量的工作,认识到这是一件复杂的事情。尽管如此,这是一个极其强大的工具。这就是静态分析器所做的。所以这是 Clang 静态分析器的一个截图,针对一个小函数,该函数将返回,你知道的,给定指向某个序列的指针,它将返回下一个元素,而不是下一个位置。它将返回该序列中的下一个元素。那么这里发生的是步骤九,在中间一点。我们得到了一个未定义或垃圾值,因为我们刚刚走到了数组的末尾,对吧?而 Clang 静态分析器能够检测到这一点,并通过所有函数进行查看,因为它正在利用每个操作的先决条件和后置条件,使用这些函数的组合规则构建一个模型,从而构建一套规则,以便能够推理代码并告诉你,嘿,这里是你违反该操作先决条件的完整路径。
现在,我想讨论的下一个基础性工作叫做“契约式设计”。这篇论文是我能找到的最早的论文副本,来自 1992 年。还有一篇更早的论文来自 1986 年。标题就是“契约式设计”。这是 Bertrand Meyer 的工作。今天早些时候,我发了封邮件问他是否有原始论文的副本,但他还没有回复。所以如果有人碰巧有副本,我很乐意拥有它。那么什么是契约式设计?
嗯,契约式设计采纳了这个概念并稍微反转了一下。现在我们从外部一个函数开始,我们定义该函数的一组先决条件和后置条件。Bertrand 的工作是在一种叫做 Eiffel 的语言中进行的。我不知道你们中有多少人听说过 Eiffel。
30 年前,它引起了相当大的轰动。但这是 Eiffel 类中一个名为 SetMinutes 的方法,它接受一个整数 M,这是接口的一部分,而不是实现的一部分。它有一个 require 子句,名为“分钟的有效参数(valid argument for minute)”,如果你想做错误报告,这非常有用。它要求 M 在 0 到 59 的范围内。然后它确保一个后置条件。所以后置条件,标题是“分钟已设置(minute set)”,是指这个类中名为 minute 的成员将在这次调用后等于 M。一个非常简单的先决条件和后置条件。
现在 Bertrand 还引入了类不变式(class invariants)到这个概念中。我们以前有不变量(invariants)。我们有循环不变量(loop invariants)。还有其他重要的不变量类型,但这里特别处理的是类不变式。所以一个类不变式是一个适用于类所有公共成员的后置条件。所以从类外部看,对于该类的任何值,这些后置条件始终成立。所以这里,不变式是分钟是有效的,意味着分钟在 0 到 59 之间。
因此,通过扩展,这些作为所有成员的后置条件,它们也成为任何接受这些对象的操作的保证。任何其他操作,无论它接受这个时间类引用的是什么,我们都会知道分钟在 0 到 59 之间。
现在契约在实现上有一些限制。断言(assertions)必须能在代码中表达。运行时检查的断言的复杂性是有限的。一个在常量操作上的线性时间断言。这可能会把一个原本是 O(n) 阶的操作变成 O(n*m) 或 O(n²) 阶的东西,并且如果你真的检查它,可能会导致你的程序无法终止。没有某种逻辑验证系统叠加在上面,你无法用全称量词或存在量词进行验证。
然而,这里的关键贡献不在于实现,而在于这个想法。从根本上说,这通过反转过程简化了形式化方法。我们不再着眼于每个操作并从那里向外构建并试图确定发生了什么。相反,我们通过描述函数开始,结果证明证明函数内部的操作能够满足(如果那些先决条件被满足)函数后置条件的过程要容易得多。
所以,我们可以过一遍。这些想法,好吧,并不局限于实现约束。对吧?我们可以在文档中写下完整的语义。我们可以写下,这必须对所有情况成立,或者必须存在某种情况。现在我们可以手动推理我们的函数,但我们可以把它放进我们的规范中。所以它给了我们一种描述我们的函数实际在做什么的方式。对吧?对吧?对吧?对吧?对吧?关键点是,这使形式化方法成为在座每个人日常编写代码时都可以做的事情。对吧?你可以坐下来,然后说,我的函数要求是什么?你可以在文档中写下来,即使没有语言支持。好吧?我的函数的后置条件是什么?然后当你在使用它们时,你可以尝试在脑中确保,对吧?这个操作的后置条件是否满足那个操作的先决条件?如果是,那么你就可以保证你的函数做了它该做的事。
但这里有一个问题。这是我们故事中的反派。这是 Hiram Wright(希拉姆·怀特)。Hiram 本人没什么错。我喜欢 Hiram。但在这里他仍然是我们的反派。他提出了这个观察:对于一个有足够多用户的 API,你在契约中承诺什么并不重要。他这里具体谈论的契约就是 Bertrand Meyer 的契约式设计理念中的契约。你在那个契约中声明了什么并不重要。你的系统的所有可观察行为都会被某个人所依赖。对吧?
所以,之前的引述被称为 Hiram 定律(Hiram’s Law),是 Titus Winters 创造了“Hiram 定律”这个术语。那么让我们看看这在实践中是如何运作的。我们有一些 vector,一个值的序列。我们将尝试编写一段代码,这段代码应该做的是移除 vector 中的第一个奇数。对吧?我们想,好吧,我们要使用 remove if,然后我们要写一个谓词。
走到这里。我们要写一个谓词,它将保留我们的奇数计数,好吧,记录我们见过多少个奇数。如果这个数是奇数,
我离得太远了。如果这个数是奇数,
并且它是第一个,那么我们将返回 true,这将移除它。
现在 remove if,如你所知,它实际上不会缩短我们的 vector,它只是告诉我们未被移除的最后一个元素的位置,所以我们将从那里擦除到末尾。然后我们将显示我们的 vector,编写这段代码的工程师运行了代码,
它打印了这个。有人看出哪里错了吗?
3 不见了,对吧?它移除了 1 和 3。所以无论哪个工程师写了这个,都在挠头,看着他们的代码。他们说,我不认为我把计数搞错了。我的代码中一切看起来都合法。
但它移除了前两个奇数。所以,他们拿了这段代码,发给一个同事,同事拿了这段代码,用他们的编译器编译,在他们的系统上运行,然后回应说,我不知道你在说什么。我得到了正确答案。好吧。那么这里发生了什么?
嗯,这里是 remove if 的一个可能实现。现在我不记得了,我应该把它写下来,但两个主要的开源标准 C++ 库之一是这样做的。另一个则不是。所以你得到的结果取决于你使用的是 libstdc++ 还是 libc++。选一个,你会得到其中一种行为。所以这里发生了什么?
对吧?这里发生的是,我们的谓词通过值传递给 find if,以找到我们要移除的第一个东西。当它找到时,它会增加那个谓词副本内部的计数器。但接着我们会丢弃那个谓词副本。然后我们会使用原始的谓词副本继续迭代,这里的计数器会重置为零。我们也会移除第二个。好吧,所以最终会移除前两个元素。所以研究这段代码的工程师查看了实现,意识到发生了什么,然后说,啊,我知道怎么修复了。我可以这样做。这次我要做的是把我的计数(add_count)从 lambda 中提升出来,然后我会通过引用来引用它。这样在实现中,当它复制我的 lambda 时,它不会同时复制我的计数,我有一个单独的计数,计数会随着遍历递增,我将移除第一个奇数。他们执行了代码,它工作了,他们很高兴,他们把它发给朋友,朋友得到了相同的答案,他们发给更多人,用更多标准库版本尝试,他们尝试的每个标准库版本上,它都能工作。它正确吗?
在座的各位?你们认为它正确吗?不。那是不对吗?不。不。它不正确。为什么不正确?嗯,标准中有这样一行关于谓词的描述。非常复杂的一行,谈论 U 的泛左值(glvalues),但基本上它说的是,我的谓词必须在我为值 U 调用它时,或者在我为迭代器解引用得到的值调用它时,如果这些东西指代的是同一个对象,它必须返回相同的值。把我的计数器放在里面违反了这一点。一个更强的形式是声明谓词必须是正则函数(regular function),这意味着如果我们给它相等的值,它应该返回相同的东西,这与纯函数(pure function)的概念非常相关,只不过纯函数不能有任何副作用。我们可以有副作用,只是它们对这个函数来说不能是可见的副作用。好吧。但是 Hiram 定律。
对吧?我刚才展示的那段代码可能部署在某个地方。我为什么这么想?因为整个对话是在 Twitter 上出现的。对吧?这意味着如果有人在 Twitter 上问这个问题,就有人在代码中这样做了。
现在让我们稍微离题一下,花点时间谈谈安全性和正确性,因为在这个领域有很多混淆。Dave 和我花了很多时间讨论安全性和正确性。我在之前的演讲中给安全性下过定义,与我这里要给出的定义不同,但与我这里要给出的定义并不矛盾。我们一直在努力为安全性提出一个清晰、明确的定义,既要符合文献中对安全性的使用,又要与 C++ 契合,并且某种程度上与我自己的编码哲学契合。所以这是我们的定义:一个操作是安全的,如果它不能导致未定义行为。句号。这就是操作安全的含义。
好吧?当我说“导致”时,这包括直接和间接的。好吧?即使操作的前置条件被违反了也是如此。所以这就是拥有一个安全操作的含义。那么,一个不安全操作在如果前置条件被违反时,可能导致未定义行为。好吧?无论是直接的还是在后续操作中,无论这些操作是安全的还是不安全的。对吧?所以一旦我执行了一个不安全的操作,我最终可能会得到某个值,我们称之为中毒的值,然后我可以把它交给另一个原本安全的操作,但它可能以某种方式违反该操作的先决条件,从而导致未定义行为。
现在,违反前置条件的代码是不正确的。好吧?那么让我们谈谈软件的要求。好吧?这有点像是我们对软件正确性的定义。不是完整的定义,因为你还必须满足后置条件,但如果你确实违反了前置条件,你就是不正确的。所以安全性是关于存在 bug 时可能造成的后果。它是关于当事情偏离轨道时情况有多糟糕。那么让我们谈谈对代码正确性的要求。一个正确实现的操作保证:如果前置条件被满足,该操作要么成功并且结果符合契约中的后置条件,要么该操作报告失败。
现在,这是另一个混淆点。对吧?有时候你无法满足这个保证。对吧?对吧?对吧?只要你能返回某种错误类型来表明你无法满足该操作的后置条件,那么违反后置条件并不是一个 bug。对吧?任何被该操作修改的对象必须处于,用 Bertrand 的术语来说,一个已知或可确定的状态。所以这意味着如果我在修改操作的中途,该操作无法满足其后置条件。所以这意味着如果我发出错误信号,我就得到了一个在某种程度上损坏的对象,也许,我需要至少能够知道,因为规范说如果我得到这个错误消息,我的对象就处于这个状态,或者我需要能够检查事物并确定它处于什么状态。好吧?所以我需要某种方式来基本保持我的应用程序继续运行,如果我得到一个错误,就弄清楚发生了什么。好吧?所以这是一个比有效更弱的要求。对吧?有效意味着满足该类的不变式。所以这是一个比有效更弱的条件。
那么现在,如果一个前置条件没有被满足,如果该操作是安全的,那么结果是未指定的。好吧?未指定行为不同于未定义行为。这两者在 C++ 中都是某种术语艺术。未指定行为可能包括失败,这可能是返回错误、抛出异常、设置 errno 等类似操作。它可能包括捕获或终止应用程序。它可能包括将被操作修改的对象留在未指定的、可能无效的状态。好吧?
现在,在我离开这张幻灯片之前,我能后退一下吗?哎呀。我走得太远了。幻灯片名字相同的麻烦。未被满足。在我想要点离这张幻灯片之前,Dave 说过,哦,我又点错了。Dave 在午餐时有一个很好的观察,就是我们真的应该有一个强安全性的概念。强安全性的想法是,你会得到保证:如果你违反了前置条件,那么你会终止或被捕获。对吧?意思是它就在那里停止了,不会对你的系统造成任何额外的损害。对吧?所以,我认为这是个有趣的想法。所以,这是我们正在探索的东西,我只是想在这里提一下。所以,如果前置条件没有被满足,但如果操作是不安全的,那么行为是未定义的。句号。不仅是该操作的行为,而且此时你的应用程序的行为也是未定义的。好吧?如果该操作碰巧返回了,任何后续操作也是未定义的。对吧?未定义行为可能包括写入任意内存、执行任意函数、损坏硬件、发射导弹、撞毁汽车、杀死你的家庭宠物狗,任何事情都可能发生。编译器可以自由地假设未定义行为不会发生。而且它们确实这样做了。
那么让我们看看。这是一个有点人为的例子,但它源自我在审查实际代码时看到的东西。某人在他们的实际代码中有一个类,里面有一堆成员函数,在每个代码里面,他们都在检查,看这个指针是否为空?所以,在每个成员函数的顶部,他们都有一个检查。这个指针为空吗?如果是,就抛出一个异常,代码就是这样写的。我说,嗯,这很有趣。我说,你理解这实际上什么也没做吗。他们说,哦,不,它可能发生,因为有人可能解引用一个指向我的对象的空指针,然后调用我的对象,那样的话 this 就会是空的。要调用你对象的成员函数,你必须已经解引用了那个指针。这与传递对某物的引用的概念非常相似。好吧?所以如果我们这里有一个引用,我们可以取它的地址看看是不是空。好吧?那么在这里,我们将声明一个指针 p,它是一个空指针。然后我们将调用我们的函数 *p。好吧?那么这会打印“空引用”还是“有效”?有效。有效。它打印“有效”。为什么它打印“有效”?是的。顶部的检查,编译器知道如果你解引用一个空指针,那就是未定义行为。好吧?所以这里下面,嘭,这样调用它。就在那里,发生了未定义行为。因为编译器能够假设你不能解引用空指针,编译器可以自由地假设引用永远不能为空。因为得到引用的唯一方式就是解引用一个空指针。因此,整个测试是无效的。整个测试被剥离了。剩下的唯一东西是 std::cout << "valid"
。整个东西都被剥离了。好吧?现在,取决于你在哪个编译器上,在 Clang 上你会收到警告,说这不可能发生。这段代码正在被剥离。GCC 只是默默地剥离它。它从你的应用程序中消失了。好吧?
所以存在一种倾向,一种愿望说,嗯,也许答案是我们想要更弱的前置条件。好吧?我们想做的是,如果我们有很强的前置条件,那么我们会遇到 Hiram 定律,这有点痛苦。所以,也许我们想要做的是削弱我们的前置条件,也许尝试让我们的操作没有任何前置条件。我们会把它们全部削弱。我们会指定所有的行为。当违反前置条件时,一个给定的实现可能会做一些可指定且安全的事情。对吧?所以很诱人地在那些情况下削弱前置条件,以说明实现实际做了什么。你知道,因为 Hiram,对吧?我们试图阻止 Hiram 进入我们的代码。但我们应该这样做吗?这是件好事吗?嗯,用 Kate Gregory 的话说,这很复杂。
所以让我们看一些相对简单的东西。有符号与无符号整数类型。在 C++ 中,有符号整数类型比无符号类型有更多的前置条件。对有符号类型进行上溢或下溢是未定义行为。但无符号类型被定义为模 2 的位数次方。所以它们会回绕空间。无符号类型仍然有前置条件。C++ 中几乎所有的类型都有某种前置条件。其中一个前置条件是,在它被初始化之前,你不能读取它。所以仅仅是这里的简单赋值就是未定义行为。
很好。那么让我们在一个算法中使用有符号整数类型。这是 Bresenham 的直线绘制算法。在座的有多少人熟悉 Bresenham 直线绘制算法?哦,好。相当多的人。所以它的工作原理是,为了解释它,我们正在画一条线。我们看的是大约第一个八分之一象限。通过反射,我们可以得到围绕一个圆的所有其他象限。我们正在光栅显示器上画一条线。所以我们将从这个函数得到什么,我们传入这个 fout 函数。它将用 x 和 y 坐标调用 fout,我们可以绘制这些坐标,它会画出一条小光栅线。好的。作为输入,它接受两个 delta,也就是距离。所以 delta x,我们的线在 x 坐标上有多长,和 delta y,有多高。这就给出了我们线的斜率。好的。现在,我们有一些相当复杂的断言来检查,以确保我们不会发生溢出,因为我们不想遇到未定义行为。最复杂的部分在下面这里,对吧?我们在这里确保我们将把 dy 加到 dx 上,我们要确保这不会超过 int 的最大值。所以我们必须稍微思考一下如何表达这个。现在,我可以运行这段代码,我会得到一条看起来像那样的线。这是一段好代码。
让我们深入看看,感受一下它是如何工作的。
好的。这是算法中关键的部分就在这里。好的。我们在做什么,我们有一个 a,它是我们的累加器,a 代表的就是它。我们正在把 dy 加到 a 中。每次当 a 超过 dx 时,我们就从中减去 dx。好的。所以你可以把 dx 看作极限,它有点像回绕。每次它回绕时,我们就把 y 坐标增加一。好的。而且,你可以想到,它回绕的量就是额外的比特,对吧?所以我们有点像是在累积我们在这条线上停留多久。直到我们累积得足够多,我们的 y 坐标需要向上增加。在这一点上,我们只能增加一个完整的单位。还有一些剩余的量。它留在我们的累加器中。我们继续前进。我们的线会随着我们前进而构建起来。
所以这里的这个小东西就是模加法,对吧?我们正在做的是将 a 加上 dy,然后只取模 dx 的结果。对吧?所以这是模 dx 的加法。啊哈。无符号整数,整数算术是模 2 的位数次方。这也是模无符号值的最大值加一。好的。我们只是在做模算术。那么,如果我们不是做模 dx,而是做模最大值加一会怎样?我们能那样做吗?当然可以。我们所要做的就是将我们线的斜率一直投影到,你知道的,到我们无符号值的最大值加一。对吧?那么我们怎么做呢?嗯,我们必须那样做。嗯,我们有一个斜率 dy/dx。而我们需要的是一种 dy’ / (最大值加一)’。对吧?所以我们可以在这里解出 dy’。这相当简单。
所以,现在我们就可以用无符号整数来编码这个了。对吧?那么这里发生了什么?嗯,首先,我们的断言简化了,因为我们不必处理溢出,因为那是我们的意图。我们在这里就是要回绕。我们确实仍然有一个断言。我们不能处理完全对角线的线。好吧?也就是当 dx 等于 dy 的情况。为什么?因为我们无法将其与零情况区分开。好吧?计算我们的 dy’ 有点棘手。因为我们需要一个比最大无符号整数大一的值。所以,方法是,我们通过把它加到 1.0 上,把一切都转换成双精度。好吧?然后我们做乘以 dy 除以 dx,我们将把它截断回 dy。我们想要截断而不是四舍五入的原因,是因为我们实际上不想超过 dy。我们,我们永远不想向上取整。
但是看看我们循环体这里发生了什么。好吧?a += dy。那是我们的累加器。但它自然回绕。我们可以判断它何时回绕,因为 a 会小于 dy。好吧?所以如果 a 小于 dy,那么它就回绕了,我们就把 y 坐标增加一。现在,即使我们在那里有一个比较操作,也没有测试。右边的注释,这些是这段代码至少在 x86 上会生成的指令。好吧?所以我们有一个加法指令和一个带进位加法。带进位加法是什么?嗯,如果你做加法并且回绕了,你需要进位1。这正是我们在这里做的。我们正在做无符号值的加法。它在回绕,我们正在加那个 1。所以之前有一个关于无分支编程的演讲。这是一种进行无分支编程的方法。所以这里的结果是,这段代码按原样大约比带 if 条件语句的那个快 15%。但也因为它没有分支,这意味着你可以非常容易地对其进行向量化。所以,你可以在机器的 SIMD 单元上执行相同的算法时获得 8 倍或 16 倍的加速。所以这是一个相当显著的加速。
如果我们运行它,你会看到它工作了。我们得到了一条线。现在,我们不会得到与原始 Bresenham 算法完全相同的线,因为当我们做投影并进行截断时会有一些误差。好吧?但它们将非常接近,我们实际上可以,我这里不做,但有一个几何证明表明它们总是会在正确的位置开始和结束。变化的是中间的小阶梯。好吧?所以有符号与无符号整数。所以用模算术更容易检测溢出。对吧?模算术属性可以被利用。如果我们知道我们的数字会回绕,我们可以做很酷的事情,比如快速绘制 Bresenham 线。所有这些都非常好。但通常,数学是在建模 Z的一个子集,或者自然数的一个子集。对吧?有符号数学为分析器提供了更多机会来检测可能的溢出。大多数时候,如果你让一个有符号或无符号数字溢出,那都不是有意的。那是你代码中的一个错误。一个错误。对吧?
如果我们把之前代码中的断言去掉,把它放入 UBSAN 中,然后喂给它一些确实会导致溢出的东西,它会捕获它并停止我们的程序。所以理想情况下,我们想要的是能够指定我们想要的行为。对吧?有我们想做的模算术,它会回绕。有捕获算术,我们希望它只是停止我们的程序,也就是强安全保证。有饱和算术,这在做图形处理时非常有用,它只是固定在最大值,不再升高。然后还有我们现在拥有的…嗯,还有未指定行为,它可能是其中任何一种。我们现在拥有的是未定义行为,也就是发射导弹并杀死你的狗。对吧?我有点质疑我们是否需要未定义行为。我知道有些情况下编译器能够利用未定义行为进行优化以实际获得收益,但也许只有未指定行为就足够了。对吧?好的。现在,这些可以被封装在单独的类型中,这就是 Rust 为这种算术所做的,或者封装在单独的操作中,这就是 Swift 所做的。
现在,强前置条件的好处是它们将为我的实现提供灵活性。如果我有强前置条件,那给我在函数内部…怎么说呢?在边界上利用某些东西提供了更多机会。对吧?这样说不太好。我会想出别的说法。有了强前置条件,我们可以赋予… 有了强前置条件,我们可以赋予一个操作意义和意图。对吧?对吧?我这么说是什么意思?当我有一个强前置条件时,我可以说,如果你满足这个强前置条件,那么这个东西就会做一些有意义的事情并满足强后置条件。我们不想陷入的,而且有一种倾向会这样做,就是去说,嗯,你知道,如果我们将前置条件削弱,并且如果那些较弱的前置条件被满足,它会做一些无意义的事情,但我们写下了那个无意义的东西是什么。所有这些都简化了我们推理代码的能力。强前置条件使得推理我们的代码更容易。如果我们做的是 Tony Hoare 所做的事,也就是只看所有的单独操作,并将它们组合成调用这个函数所需的最低要求,并且不引发未定义行为,好吧?不让机器崩溃。事实证明,这真的很难做到。并且那些描述变得非常复杂和庞大。
不过这里也有缺点。对吧?正如我们在有符号与无符号整数中看到的,对吧,我们能够利用无符号整数有较弱前置条件这个事实,好吧?并且实际上用它做了些有用的事情。但我们也看到这允许不同实现之间的行为存在差异,好吧?这就是我们在 remove if 操作中超出前置条件的情况,对吧?我们在看 remove if,我们超出了前置条件。一个实现做一件事。另一个实现做另一件事。更强的前置条件为 Hiram 定律打开了一个漏洞。
所以这就是我们所看到的。尽管如此,我仍然站在无符号阵营。无符号队。无符号队。现在,我们必须稍微回溯一下。我们有点超前了。因为这个故事还有更多内容。
你们中的一些人可能听说过泛型编程。泛型编程这个术语是由这两个人创造的,Dave Musser 和 Alex Stepanov。这是 1989 年发表的论文,其中创造了“泛型编程”这个术语。而从一开始,泛型编程的一个关键部分就是被称为要求和保证的东西。这些在 1992 年由 James Dennert 和 Alex Stepanov 发表的一篇论文中得到了更正式的阐述。这是 James 的照片。这就是概念这个术语被创造出来的地方。所以早在 92 年,概念这个术语就被创造了。我们终于在 C++20 中有了一些语言支持的概念的影子。所以花了一点时间。这是他们首次引入概念概念的方式。它说,“我们将一个数据类型及其上的一组操作所满足的一组公理称为一个概念”。有点像泛型编程的基础。
所以概念。概念本身是一组命名的要求。命名的部分很重要。它们包括一组指定操作语义的公理。所以当我们之前看相等性时,我们说相等性必须是自反的、对称的、传递的。这些是等价关系操作的语义。它们还封装了操作的前置条件和后置条件。这些是契约要求。最后,这在某种程度上是一个独特的立场,就是概念实际上捕获了复杂度要求。它们告诉你,为了满足后置条件并保证操作的某种复杂度,需要满足什么复杂度要求。现在,C++20 概念将一组有文档记录的语义、契约和复杂度要求与一个名称和一组语法要求关联起来。好吧?所以 C++20 概念,它们没有任何方式来描述语义、契约或复杂度要求实际是什么,除了通过你的文档和规范。它们只捕获语法要求。好吧?表面上看这似乎相当弱,但这是一个非常强大的想法。对吧?我们说的是,我们将某个东西的所有语义要求与一个名称关联,与一个操作的名称关联。这就是我们说话时所做的。这就是我们彼此交流的方式。好吧?这是语言的理念。对吧?
所以如果我们有一个像标准中“可相等比较”的例子,它要求 operator== 被定义并且结果可转换为 bool。这是语法要求。好吧?它要求 operator== 是一个等价关系。这是语义要求。但这些词并没有出现在语言中。它们出现在标准文档中。operator== 的参数必须在操作的域内。这是契约要求。而 operator== 的执行时间与对象的面积成正比。这将是复杂度要求。现在,我撒了点小谎。最后两项,最后两个要点实际上并没有放入标准中。它们实际上并不是“可相等比较”所必需的。但它们应该是。所以它们缺失了。所以概念将语义和复杂度与语法关联起来。它们允许我们定义一个组件,该组件将与满足这些要求的任何类型一起工作。好吧?这允许我们为一个无限的操作集赋予意义。我们可以有一个像 find 这样的操作,我们可以在 vector 中查找整数。我们可以在某个序列中查找雇员或某些其他东西。对吧?这允许我们混合搭配并将事物组合在一起,仍然构建一个我们可以推理的系统。这是一个极其强大的想法。所以我们说,传递给这些组件之一的参数需要满足一个概念。作为回报,一个数据类型,一个特定的实例,一个 int,一个 vector
所以让我们稍微谈谈这个。要求适用于参数化类型或操作的参数。保证适用于对象实例或对象。所以保证是断言这样一个实例满足一个要求,或者说建模了一个概念。所以如果我们看一个像 distance 的函数,我们说 distance(f, l),对吧?它要求什么?嗯,它要求 F 和 L 满足输入迭代器的概念。输入迭代器要求存在前置自增 ++i、后置自增 i++、解引用 *i 和 i++。这有一个前置条件,即我不能等于 L,对吧?我们要递增或解引用的迭代器不能等于范围内的最后一个元素。F 和 L 还必须满足平凡迭代器的所有要求。也就是说,F 和 L 必须满足它是可赋值的、可相等比较的和可默认析构的。可相等比较有一个前置条件,即参数在 operator== 的定义域内。好吧?我不能比较任意两个迭代器。它们必须在同一个序列内。这是我们输入迭代器的前置条件。好吧?最后,我们有一个前置条件,[f, l) 是一个有效范围。我们在这里遗漏了一堆东西,因为我没有深入探究可赋值意味着什么,或者可默认构造意味着什么。对吧?所以命名一组要求是一个显著的简化。它是巨大的,对吧?“标准输入迭代器”这个概念封装了这整套庞大的语法和语义要求。只有语义要求是由编译器强制执行的,但分析器和消毒器可以,也希望能做更多验证一些语义要求的工作。所以标准中的概念是对库组件参数的要求。再次重申,标准类型通常被描述为保证它们满足某些概念。好吧?我之所以想再重复一遍,是因为我看到过非常多的混淆,甚至在标准委员会成员之间。在座的有多少人是标准委员会的成员?好吧?几个。在座的有多少人认为标准中要求表中的要求,就像是给实现者关于他们如何必须实现 vector 的要求?vector,好吧?那其实不是它们的意思。它们是关于容器或算法的要求,说明你可以把什么类型塞进去,而我们恰好将标准库中一些特定类型的保证记录为满足这些要求,对吧?所以它与微软是否这样做无关。这不是标准中的意思。
这些命名要求是从一组相关的组件中提炼出来的,对吧?当你试图弄清楚要求是什么时,你是在看一组算法、容器、类型等等,或者不管是什么,一组东西和一组通用模型,对吧?所以随机访问迭代器的想法就像是,嗯,我们有像指针这样的东西,你知道的,语言指针和类似指针的东西,它们的行为是这样的,而在另一端,嗯,我们有输入流,它们将是输入迭代器的一个好模型,好吧?我们有链表容器,我们会用仅向前或双向的迭代器遍历它们,取决于它是单链表还是双链表。所以你想去看看,然后说,好吧,我有这个算法,算法要求这些东西。我有一组我认为可以放进去的模型。通过这种方式,它们为我们创建了一种简单的方式来匹配,对吧,我们的对象和我们的组件,将两者组合在一起,对吧?它们避免了,如果我想排序,我必须谈论我是在排序一个列表,还是排序一个向量,或者我究竟在排序什么,对吧?它们避免了这种 n 乘 m 的复杂性,使其变成 n 加 m。
对于特定组件,要求可能比实现实际要求的要求更强。如果你回头看,我举了 distance 的要求的例子。distance 的一个要求是它接受输入迭代器,而输入迭代器要求是可解引用的,但 distance 永远不会解引用迭代器,好吧?这并非坏事,对吧?这允许我们构建一个思维框架,以便我们可以将这些事物组合在一起并推理代码。
目的不是指定实现,而是指定代码的含义。这就是概念的目的。现在我们有足够的材料,我们终于可以看看 std::find
了,对吧?所以 std::find
是做什么的?它接受一对迭代器,first 和 last,以及一个值 value,对吧?它将做什么?它将找到范围内,从 first 到 last 的范围内的第一个元素,该元素等于 value,它将返回那个东西的位置,如果那个东西不存在,它将返回 last。这就是 find 所做的。
好吧?那么,这里的 value 等于序列中的某个元素是什么意思?嗯,我们需要定义我们所说的相等性是什么意思。两个对象相等当且仅当它们代表相同的实体,这是一个非常哲学和抽象的东西,但这就是相等性的含义。它们具有相同的值。它们意味着相同的东西。由此,我们推导出相等性是一种等价关系,对吧?意味着它是自反的、对称的和传递的。
相等性还需要与你类型的其他操作保持一致。为什么?因为我们根据相等性来定义那些操作,对吧?所以复制某物意味着什么?它意味着复制之后,两个东西是相等的。好吧?拥有一个全序关系,成为可小于比较的意味着什么?其定义的一部分意味着我们得到了排中律,对吧?这种能够用相等性来定义事物的整体概念是非常基础的。它被称为等式推理。这就是我们理解宇宙的方式。我们可以用别的东西来描述一件事物。
所以这是旧的 SGI STL 文档中关于 Find 的描述。旧的 SGI STL 文档中关于 Find 的描述。它被称为 SGI STL 关于 Find 的模型文档。所以这是新的 SGI STL 文档中关于 Find 的描述。它被称为 SGI STL 关于 Find 的模型文档。你会看到它接受输入迭代器和一个可相等比较的值,并要求在“可相等比较”类型的对象和输入迭代器的值类型的对象之间定义了相等性。定义了相等性。它没有说定义了 operator==。它说的是定义了相等性。好的,它还有一个前置条件,即 [first, last) 是一个有效范围,意味着如果我们从 first 开始递增,最终会遇到 last。然后它告诉了我们复杂度保证是什么,即在此序列中最多进行 last - first 次相等性比较。
所以如果我们在文档中查看“可相等比较”的定义,我们也会在这里找到相等性的定义。所以它意味着两个东西是相等的,如果表达式——现在我们引入操作符——x == y 是一个有效的表达式,前提是 x 和 y 在 operator== 的定义域内。我们有一组不变式,看起来和我们在数学中的完全一样,即自反性、对称性、传递性,我们还加了一条,说如果它们是同一个对象,意思是它们有相同的地址,那么这也意味着它们是相等的。
现在让我们看看 C++20 规范中 std::find
的定义。对吧?我们首先注意到的是它接受一对输入迭代器,和之前一样,但现在对值 value 的要求只是它是某个类 T。这里没有提到“可相等比较”。
然后我们有了这种复杂的说法来表示表达式 *i == value,嗯,我们会说,返回值是范围 [first, last) 内的第一个迭代器 i,其位置满足这个表达式 *i == value 为 true。否则返回 last。
所以它将一路调用我们的相等操作符并返回最后那个。
现在 C++20 标准确实包含了“可相等比较”的概念。它实际上包含了两个。我只包含了旧版本,称为 C++17 “可相等比较”,因为这里会应用那个。新的那个在范围算法中使用,但两个定义在本质上是一样的。所以我们要求表达式 a == b 可转换为 bool,并且它是一个等价关系。然后我们遍历我们的定义:对于所有 a,它是自反的、对称的、对称的和传递的。
但我们实际上没有在 find 的定义中使用它,对吧?那被遗漏了。
所以让我们开始看到这里的问题。这是 NAN。NAN 通常由零除以零生成,在浮点数中表示“非数字”。好的。NAN == NAN 是 false,非自反。最近有个很棒的笑话,意思是 NAN 代表“不是一个 NAN”。因为它们不是一个 NAN。
好吧。所以 NAN 不满足“可相等比较”或 C++17 “可相等比较”的要求,无论是旧的还是新的。
所以如果我取这个 double 类型的数组,它包含一个 NAN,
然后我在其中调用 find,这是一个 std20 的 find,寻找那个 NAN,然后我输出“找到”或“未找到”,它会打印什么?
有人知道吗?
未找到?是的。它打印“未找到”,对吧?因为 NAN 不等于 NAN,所以它找不到 NAN。好的。
嗯,如果我稍微改变一下这里,让我寻找 3。
找到还是未找到?
好的。那会打印我们找到了 3。好的。看起来相当合理。嗯,我们找到 3 了吗?我是说,在数学上,如果我在里面找 3,我把 3 和 NAN 比较,
那是不确定的。我不知道。也许是,也许不是。也许这是正确答案。也许不是。
对吧?
你会想,嗯,这里的修复应该超级简单。让我们进入标准中的 std::find
,我们只需将要求改为 std::find
应该要求 CPP17 “可相等比较”。但如果我们真那样做了,那么这段代码将是未定义行为。这里没有 NAN。
好吧。但这段代码在旧的 SGI STL 中是完全定义的。那么这里发生了什么?
有一个关键部分缺失了。
这句话。来自旧的 SGI STL。包含了这句话。“x 和 y 在 operator== 的定义域内”。我一直在大量使用这个术语,但这个术语并没有出现在“可相等比较”的定义中。这个术语,操作的域,在整个标准中只在一个地方出现,就是针对输入迭代器的 operator== 的域。所以当我们看这里的要求时,
在 C++20 中是“对于所有(for all)”。对于所有什么?对吧?这通常被理解为对于该类型的所有值(for all values of the type)。这就是标准委员会解释这里的“对于所有”的方式。好吧?他们说,嗯,现在即使没有 NAN,我们也不能在序列中找到一个值,因为 double 不满足“可相等比较(equality comparable)”,因为有 NAN。
对吧?那么我们如何定义术语“操作的域”?实际上有非常相似的措辞来定义输入迭代器的 operator== 的域。那是从 Alex 关于 STL 的旧工作中借鉴来的。但是“操作的域”这个术语在旧的 STL 中一直被使用。而现在它在标准中几乎无处可见。正如我所说,只在输入迭代器中出现,并且只针对输入迭代器的 operator==。所以这是操作域的定义。我要读一下,因为它很重要。“操作的域”这个术语在通常的数学意义上使用,表示操作被要求定义在其上的值集合。这个集合可以随时间改变。每个组件可能对操作的域施加额外的要求。所以这是操作域的定义。好的。在这里停顿一下。“随时间改变”。随时间改变。我们这么说是什么意思?嗯,最容易想到的是,如果我们看输入迭代器,对吧?我只能在两个输入迭代器在同一个范围内时比较它们是否相等。但同一个范围会随时间改变。我可以缩短一个范围,一个迭代器可能掉出末尾。那么它就不再可以用于相等比较了。对吧?所以我实际上可以使用相等性的时机可以随着程序的执行而改变。对吧?并且每个组件可以对操作何时需要以及什么条件下需要施加额外的要求。一般来说,这些要求可以从组件对该操作的使用中推断出来,正如我们继续下去会看到的,并且通常被约束为通过操作参数可访问的那些值。所以当我们调用 find 时,我们传递了迭代器对 first 和 last。该序列中的所有元素,好吧,都是通过我们操作的参数可访问的。所以操作的域不是参数的类型。对吧?我们常常想,哦,一个函数接受一个类型 T 的东西。那就是操作的域。当我们在数学中这样做并说,我们有一个定义在自然数上的函数时,那与类型略有不同,Z 表示一组值。某个类型 T 也表示一组值,但我们可以在其中谈论不同的值集,而不必给它一个单独的符号。所以类型 T 要满足某个要求 P 的要求是什么?对吧?如果我们想说对于所有 A,某个属性 P 成立,某个要求 P 成立,T 意味着什么?T 必须保证什么?T 只需要保证存在某个属于 T 的元素 A,使得 P 成立。这才是实际的要求。对吧?对 double 的要求不是所有的 double 都是可相等比较的。而是至少有一个是。好吧?这就是满足“可相等比较”的要求。double 和 float 满足旧的“可相等比较”。它们应该满足新的“可相等比较”。只要被比较的集合中没有 NAN。对吧?是值 NAN 位于外部。所以对于 find 来说,序列中没有 NAN 是“可相等比较”的一个前置条件。所以如果我实际上把 NAN 作为我要搜索的值,或者作为序列中的一个值,并且我调用 std::find
,那应该是违反该操作前置条件的行为。因为 find 在那种情况下不会找到。所以 std::find
将返回谓词为 true 的第一个元素。很诱人去查看一个实现然后说,嗯,我可以用 find_if 来实现 find。find_if 只接受一个谓词。好吧?它返回 true。所以这看起来像是 find 的一个有效实现。我可以从内向外推导出它的要求。但我不应该这样看问题。我应该从外向内看。find 找到东西意味着什么?好吧?它在这里,正是这个相等性施加了额外的要求。对吧?额外的要求来自于 operator== 的使用,因为我们试图交流,因为词语有意义。对吧?否则,find 的含义和相等性的含义就被削弱了。我们推理代码的能力就被削弱了。对吧?即使在这里,我也搞错了。如果你看到,之前我有 value == e,它需要是 e == value。为什么?因为在标准中甚至没有要求 operator== 是自反的,或者说不是自反的,是对称的。没有关于对称性的要求。好吧?所以 std::find
不要求 T 中存在任何“可相等比较”的值。std::find
不保证即使 value 存在于序列中,它也能找到 value。std::find
的含义被简化为“按实现工作”。幸运的是,很容易证明当且仅当operator== 建模了“可相等比较”,并且序列中的所有值以及要查找的值都在 operator== 的定义域内,那么 std::find
实际上会找到。好吧?现在,幸运的是,这很简单,但当你处理一段代码时,你不想对每个操作都这样做。如果你试图推理一个函数,你不想不得不去看,然后说这些东西是否匹配?这就是为什么我们有概念。我们有概念,这样我就知道,嘿,这个东西保证它是“可相等比较”的。这个东西要求是“可相等比较”的。我可以把它们插在一起。它会找到。好吧?这已经被剥夺了。所以这让我们回到了最初的引述。我希望我已经说服你们相信这句话的深刻性。对吧?理解软件为什么失败,这很重要。对吧?当我们谈论安全性时,我们谈论的是当我们的软件失败时会造成多大的损害。对吧?对吧?试图弄清楚我们如何防止软件偏离轨道非常重要,但理解软件为什么能工作要重要得多。对吧?以及我们如何构建能工作的软件。我们想要的是一条清晰而坚硬的界限,介于不正确代码和正确代码之间。某种我们可以看见的东西。某种可见的东西。当我们削弱我们的要求时会发生什么,是我们打开了 Hiram 的漏洞。这是所有碰巧能工作的代码掉进去的地方。
Titus Winters 有这句引述:“我与非常优秀的程序员一起工作,我看到大量的‘碰巧能工作(happens to work)’的代码,而真正正确的(actually correct)代码却很少。” 他当时谈论的正是所有掉进 Hiram 漏洞的代码。
那么你能从中带走什么?嗯,当你编写一段代码时,我理解。你经历了很多试错。你在谷歌搜索。你在 Stack Overflow 上搜索。你试图弄清楚,我只需要让这个东西工作,做点事情。你终于收集了足够的信息。它编译了。它运行了。哇哦!非常令人兴奋。你还没做完。好吧?现在你必须花时间去理解你刚刚做了什么。你写了一段代码。你调用了一些函数。你调用了一些操作符。它做了些事情。好吧?所以花时间坐下来,实际查阅那些东西是什么。学会阅读规范。C++ 规范。Cpp reference 相当好。不是完美的。稍微更可读。无论你看什么语言。无论你用什么库。去阅读文档。查清楚任何你不清楚的东西。对吧?如果你在阅读规范,不要只是扫一眼说,哦,这个东西要求那个东西是“可相等比较”的。好的。你知道“可相等比较”是什么意思吗?去读它。向专家提问。对吧?如果你在耍聪明,如果你试图利用数据,在边界上做点事情,好吧,这需要额外的勤勉。对吧?它需要在你自己的代码中进行额外的规范和额外的文档。我不是说永远不要那样做,但如果你做了,就要花时间。我们看到了在加速 Bresenham 算法时这能有多强大。好吧?我们在利用。我们也看到了在 remove if 中这可能有多危险。如果我们不理解规范,现在我们正在违反前置条件,并且我们进入了未定义行为的领域。即使它在我们尝试过的每个人的机器上都能工作,在每个标准库的实现上都能工作,这并不意味着它明天还能工作。它被标准明确排除在有效之外。所以要思考你代码的语义含义。对吧?你的代码意味着什么?确保你的使用反映了隐含的语义。对吧?因为你知道读者并不总是会去查阅所有的细节。这就是为什么我们能够依靠约定很重要。对吧?我发现 std::find
实际上不要求“可相等比较”很令人惊讶。如果我读一段代码,我会期望 std::find
,嗯,实际上是在查找。如果一个工程师过来利用了我往序列里塞了个 NAN 时 std::find
会做某事的事实,好吧,我在代码审查中不会预料到那个。好吧?所以那是一个狡猾的用法。根据标准是有效的。永远不要那样做。所以,确保你的名称反映了它们所代表事物的语义。所以仔细思考你给操作命名什么。正如我之前所说,我们使用名称作为语言。它们传达信息。标准给了我们相当广泛的词汇和一组名称。所以如果你在查找东西,就叫它 find。对吧?如果你在搜索东西,就叫它 search。算法在标准中有细微的区别。不要创造一个新的词汇。这里有一个很好的例子,几年前有件事。有一个工程师走进我的办公室,他说,我在写一个类,我需要能够复制它,所以我应该把复制成员函数叫什么?我说,嗯,那应该是你的拷贝构造函数。他说,哦,不,我已经在用我的拷贝构造函数做别的事了。
好吧,这就像我说,嗯,当我们在我的房子里时,我要把苹果叫做香蕉,我要把香蕉叫做 fizzles。好吧,我仍然可以指代苹果和香蕉,因为我造了一个词,我的集合仍然是完整的,但你会非常困惑,对吧?你会超级困惑。当我要一个 fizzle 时,你完全不知道我是什么意思。而如果我要一个 apple,当我递给你一个 banana 时,你会非常惊讶。是的。
所以用规范、概念和约束来指定你的前置条件。
将语义与你的概念关联起来。它们不仅仅是一组语法要求,即使语言能为我们提供的设施只有这些。断言你所能断言的,好吧?我们无法断言一切,但断言你能断言的,以及编译器和工具无法断言的。好吧?
而这里的责任,如果你是一个库作者,会成倍增加,对吧?希望这几乎是每个人。我总是告诉人们你应该把所有的代码都当作库来写,因为它会被重用,所以最好从那里开始。但委员会成员也是如此,对吧?我偶然发现这个是因为我对 std::move
后置条件有长期的不满,即被移动对象的必需后置条件。John Lakos 让我在他的书里写几页关于这个。所以如果你拿到他刚出版的新书,你会看到我写的那些页。但当我真正通读时,导致这个发现的是“操作域”这个术语从标准中被剥离了。我们甚至没有办法在标准内部谈论析构和赋值的操作域。所以如果你不是,你会在下一本书中看到我的页。下次见。好吧?好吧?那就是缺失的东西。
所以我给你们的最后一点想法,对吧?失败的代码和正确的代码之间的鸿沟是巨大的。这就是 Hiram 的漏洞。不幸的是,它太大了。里面躺着所有碰巧能工作的代码。所有不正确但运行并做了点事情的代码。对吧?所以当你编写代码时,努力去编写正确的代码。你可能不会总是完全正确,但我保证如果你这样做,你会写出更好的代码。所以这是我的结束语。
最后一点评论。正如我之前提到的,我在 Adobe 工作。Adobe 正在招聘。我们有很多空缺职位。这是指向我们招聘网站的链接。底部那个是一个 bitly 链接,指向我们的招聘人员为招聘 C++ 人员专门建立的网站。所以你们可能想去看看。谢谢大家。我来回答问题。
Daisy?嘿,John。谢谢你的演讲。和往常一样精彩。所以你在里面提到类型不一定能表达我们想用操作域表达的很多东西。显然有些语言有更强的类型概念,可以表达这些,甚至包括可形式化证明的语言。但人们不使用那些语言,因为,你知道,大量经验表明如此,对吧?对。你认为这种差距是否实际上源于人们无法持续遵循你在这次演讲中建议的那些理念?比如,一个能够做到你要求的事情的人,他们是否能够舒适地用那些语言之一编写代码?或者你认为这里有更深层次的原因?不,我认为这里肯定有更深层的东西,那就是当我们谈论编程语言中的一个类型时,实际上我们谈论的是机器中某个值集的表示。对吧。对吧。对吧。这让我们很难在拥有一个表示时开始谈论,你知道的,子集和部分子集之类的东西。然后还要有某种方式在上面实现强制执行。所以,是的,我认为如果你遵循这个,你会稍微更适应,我不知道,TLA+ 或,你知道的,这些围绕形式化方法和表达事物设计的语言之一。
你知道,一个形式化验证系统,但不是很多。对吧?你最终还是试图让计算机做点什么,并生活在其局限性中。坦率地说,在其他语言中,甚至很难表达到硬件的映射是什么。而这是我们工作的重要组成部分。是的。
Sean。所以这让我思考。我在考虑安全性和一般情况,比如每当我们调用 API 时,你基本上是从一组契约开始。然后那个 API 可能调用其他 API,这些 API 可能会缩小那组契约,理想情况下。
所以我想我有两个层次的问题,第一,我们如何将内部调用的 API 的契约向上冒泡多远?我们什么时候做?什么时候不做?基于控制流,我可能很少调用一个 API,它缩小了契约,以至于你知道,将该契约应用于顶层函数时,它几乎永远不为真。是的。是的。我认为,你知道,基本上我前面说的是你想从外部开始向内构建,意思是从定义我希望这个操作做什么开始?以及我希望后置条件是什么,对吧?在实现中,你可能会发现一些关于这些关系的你事先没有意识到的事情。但那是你想开始的地方,而不是暴露实现。你知道,泛型编程的一个关键理念是你可以随时跟进,就像一旦我有了 find,我总是可以为分段迭代器实现一个更快的 find,因为理论上我应该能够进行精化。我们实际上无法用 C++ 正确地做精化,但是,你知道,那应该是目标。所以从外部开始。我们为迭代器层次结构之类的东西内置了一些精化。谢谢。
是的。我们有一个在线问题。当然。“我们能否为像 float 和 double 这样的类型拥有部分相等的概念?” 我们能有一个部分相等的概念吗?不。不。NAN 是在那个定义域之外的。所以事情很简单,类型本身是“可相等比较”的,但这排除了值 NAN。所以,你知道的,如果你想说我就是要创建一个没有 NAN 的类型,那非常困难。那样你真的无法在其中做除法。
所以。作为那个问题的后续。是的。“为什么不直接移除浮点数的等于和不等于操作符,因为它们没有意义?” 我不同意这一点。我认为,你知道的,像 JavaScript 的几乎所有东西都不会工作,因为它们几乎都是 double。在很多概念中,等于有巨大的意义。但是是的,你知道的,用正确的精度等做浮点数是件棘手的事。人们确实会误用它,但我肯定不会移除它们。是的。
“所以我基本上有两个问题。一个是,你能否实际使用像 Clang 消毒器这样的工具来定义未定义行为,如果你传入指向包含 double 的东西的迭代器?第二个是…第二个是…我想我忘了我的第二个问题。” 好的。让我回答你的。然后我想回到在线那个问题,因为我刚刚对它有了个想法。是的,希望如此,我的意思是,如果我们有更强的概念对 std::find
施加了实际相等性的要求,那么是的,你可以让 Clang tidy 或未定义行为消毒器(UNSAN)或类似的东西。你可以教它那些规则,让它挑出你的 NAN 并告诉你。所以,我的意思是,这当然是可行的。你知道的,这些工具能做什么是有限制的,但在这个案例中,你可以。我确实想回到为什么在浮点类型上拥有等于操作符很重要。部分原因是我们在相等性方面定义了复制。所以如果我复制了某物,那个副本必须和原始物相等。所以如果你从浮点数中剥离了相等性,那实际上很难验证你得到了某物的正确副本。
“我刚想起了我最后一个问题。另外,按你所说,使用迭代器指针或迭代器值的 std::find
是未定义的,那是否也意味着 ranges
的 std::find
也是未定义行为,如果你传入一个 NAN 在 ranges 中?或者某个在 ranges 中包含 NAN 的东西?” 我还没有,你知道的,深入研究所有那些操作的所有要求。要明确的是,我并没有说当前实现的 std::find
在有 NAN 时是未定义行为,因为它只要求相等性,要求 operator== 在类型 T 上有定义。它实际上并不要求它是“可相等比较”的。我知道在标准中有一个 std::equality_comparable
概念,它被用在一些算法中。根据它用在哪些算法中,你可能会陷入那种情况。
“你在鼓励一种软件方法学,让人们非常仔细地查看你调用的函数的语义,并确保它与你编写的函数相匹配。通读所有文档,非常深入地思考它。我认为每个人都会同意,作为一个抱负,这很棒。你会如何回应商业环境中这种编程方法学没有竞争力的反对意见?” 你知道的,我认为交付有缺陷的软件会很快毁掉一家公司,你知道的,高缺陷率。所以你要么不做那个,要么试图在测试中发现一些问题,这又回到了老论点:你在前期花的时间越多,你在后期试图验证的时间就越少。一般来说,这被证明是节省成本的。关于这类事情有大量的研究。
它确实有点违背“快速行动,打破常规(move fast and break stuff)”的心态。
但我认为总的来说,即使不是公司授权或指令,仅仅是你个人,如果你花时间这样做,你会发现你能够比不花时间那样更快地写出更好的代码。好的,Sean,这将是最后一个问题。好的,最后一个问题。“我关于你最后一张幻灯片的问题,关于我们能做什么来编写正确的代码?既然你提出了它是什么以及你如何推导编写正确代码的问题,你可能想提出一些理论框架,一些数学推理来说明为什么那些是应该产生正确代码的方式。所以,有时很常见会把它看作一段代码,看作一个有输入、输出和一些副作用的沙盒。然后你对所有这些事情施加约束和条件,并试图推导输出和副作用,并确保在这个特定代码的执行过程中不会发生坏事。这也是为什么函数式语言、计算机函数式语言在确保你编写的代码正确方面做得如此好的原因,对吧?你写的表达式的类型在编译时就确定了。嗯,是的,那里有点不同,那种正确性是 Tony Hoare 和 Bob Floyd 意义上的正确性,意思是它不会引发未定义行为。它不会杀死你的狗。但它不一定做了你想要的,因为你从未在外部陈述你想要什么,对吧?所以我认为,这就是 Bertrand Meyer 在契约式设计中的卓越之处,就是你实际上必须陈述你打算让这个东西做什么。” 是的,这就是函数式语言和过程式语言的区别,在过程式语言中,你必须通过告诉计算机你想做什么来编码。我认为函数式语言没有被商业上广泛使用的原因之一,是因为总是有一些副作用,那就是性能。所以你的代码在机器上执行的时间是副作用的一部分。你需要考虑那个,特别是如果你有时间限制,这些限制是你如何约束自己。它应该执行。对吧。所以你真正要说的是,这不是正确性问题甚至方法问题。而是大多数函数式语言保证安全性。它们实现的方式是,如果你所有的基本原语都是安全的,那么你从那里构建的一切都将是安全的。所以如果你从一个安全的基础开始,那么你将拥有一个安全的系统。问题在于保证一个安全基础是有代价的。所以这会影响你的性能。对吧。对吧。
而安全性不等于正确性。对吧。安全性是关于当你的代码偏离轨道时会造成多大的损害。所以你的函数式程序仍然会偏离轨道。只是损害被限制住了。我通常的说法是,如果你的语言是图灵完备的,那么在你的语言内部,我可以实现一个 C 机器,并且我可以写出完全相同的错误。确实如此。唯一的区别是,我能造成的所有损害都被隔离在我构建的 C 机器的边界内,这真的没什么意义。对吧?所以那,我试图说的是,所有这些都是与性能的权衡。我发现自己在处理这个问题时,最常见的是在智能设备上,但比如说你需要特别验证,尤其是试图做那些更高效的事情。并且在并发领域超出了过早优化的范围,试图编写一些算法,特别是那些大多是碰巧能工作的,最有问题的碰巧能工作的事情是与并发问题相关的那些。是的。谢谢。我确实同意。我的意思是,并发系统中的碰巧能工作会变成有时碰巧能工作。那真的很糟糕。是的。好的。好的。我想,我想我们结束了。谢谢。