函数契约实践¶
标题:Function Contracts in Practice using C++
日期:2023/08/10
作者:Rostislav Khlebnikov
链接:https://www.youtube.com/watch?v=5ttyA1-I8D8
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
大家好,欢迎来到“函数契约”。我叫 Rostislav Khlebnikov,或者 Slava。我是 Bloomberg BDE Solutions 团队的负责人,我们为 C++ 开发基础库。今天我将要谈论函数契约。它们为什么重要,它是什么,为什么它们重要,以及为什么你在开发函数时,可能会选择一个有前置条件的函数,或者换句话说,所谓的“窄契约”。然后,我将讨论如何设计一个好的契约?对于你的函数来说,一个好的契约有哪些方面?接着,一旦你实现并设计好了你的函数,我将简要介绍如何确保契约不被你的客户端违反,如何将你的契约传达给客户端,因为在编程中,沟通和技术能力同等重要。最后,当你在一个不仅仅是玩具程序上工作,而是希望将契约检查集成到你的组织和整个工作流程中时,一个大规模契约检查系统需要什么。
好的,在我开始谈论什么是函数契约之前,我们先看看现实世界中的契约是什么。契约是双方之间的一项协议,它产生执行或不执行特定义务的责任。这是一种定义。契约是双方之间产生义务的协议。契约是各方之间产生可由法律强制执行的双向义务的协议。所以,你可能注意到所有这些定义,尽管略有不同,但它们之间至少有一个共同点。那就是,至少有两方在进行沟通或互动,并且他们之间存在相互的义务。我刚刚抽出了一份我与承包商签订的合同,他负责修理我的房子。你不需要阅读这个,但同样,在左边是义务。一方是执行工作的合同方。他们说明了义务是什么,在右边是我的责任,以及我需要做什么来确保承包商完成工作,比如,我必须付款。
好的,那么这些契约与软件有什么关系呢?首先,有两方。一方是函数作者。另一方是调用该函数的客户端代码。现在,关于义务,函数的作者承诺执行特定的任务。但是,另一方面,客户端需要尊重对输入的任何约束(如果有的话)。它们是否可由法律强制执行?嗯,并不完全是。希望不是。但是,我们稍后会讨论强制执行的问题。哦,天哪,这个被切掉了。希望它以后不会成为一个大问题。
好的。在我继续之前,我想做一个小小的免责声明。首先,我所有的例子都将使用 C++。我是一个彻头彻尾的 C++ 开发者。我使用 C++ 已经超过 15 年了。但是,重要的是要注意,我要谈论的大部分想法,几乎适用于任何编程语言。这不是 C++ 特有的。而且,我将展示在幻灯片上的代码(希望不会切掉太多),其灵感基本上来自于 Bloomberg 数十年来使用契约的经验。这在我加入之前很久就开始了。C++20 的契约(带引号,因为它们从未实现),但那里做了很多出色的工作。以及当前在 ISO 流程中研究组 21(SG21)为 C++26 契约所做的工作。
好的。那么,函数契约的要素是什么?首先,前置条件(Preconditions)。基本上,是函数的调用者必须满足的约束,以便正确调用函数。这些约束可能针对输入参数(就是你传递的东西),或者程序状态(任何全局状态)。你可能对这些有一些要求。而且,如果它是一个成员函数,它也可能对对象状态有约束。如果这些约束不满足,我们说该函数的行为是未定义的。
然后,是核心行为(Essential behavior)。基本上是函数作者承诺在契约内实际调用函数时所做的事情。它包括后置条件(postconditions)、返回值、程序或对象状态的变化等内容。还有一些额外的东西,比如行为保证(behavioral guarantees),可能是算法复杂度(algorithmic complexity)、线程安全(thread safety)等等。
你可能会问,我们到底为什么需要前置条件?为什么不让每个函数处理所有可能的输入?嗯,有些函数确实是这样的。有些函数所谓的具有“天然宽契约”(naturally wide contracts)。而且你知道很多这样的函数。例如,经典的例子是 vector::push_back
。你总是可以尝试向 vector 中推入元素。并且该函数没有前置条件。你可以询问 vector 的大小。同样,调用这些函数不需要满足任何前置条件。
但是,其他函数就不那么容易了。所以,还是以 vector 为例。front()
,你想获取 vector 的第一个元素。如果 vector 是空的怎么办?索引操作也一样。你想获取 vector 的一个元素。嗯,vector 有一个大小。如果你索引超出范围怎么办?标准库中甚至存在更复杂的前置条件。比如,你有一个排序函数,对吧?你给它一个范围和一个比较器。这个比较器需要是特殊的,比如,它需要在该范围的元素上施加一个严格弱序。
所以,有多个理由需要前置条件。而不是简单地说,我们会尝试处理每一个可能的输入。因为为所有输入定义行为可能不仅效率低下,甚至可能字面意义上不可能。而且,更进一步,如果我们仔细地、正确地定义我们的前置条件,我们的程序将变得更加可靠——尽管这似乎违反直觉,但这是真的。它们将变得更易于维护,也更具可扩展性。
那么,让我们逐一更详细地看看这些方面。
首先是可行性。正如我提到的,试图为函数所有可能的输入定义行为可能确实是不可能的,无论你多么努力。例如,如果你有一个 string_view
的构造函数,你给它一个指针和大小,你知道,它的前置条件是这定义了一个有效的范围。但是,如果你决定,“嘿,我想为这种情况定义行为。我不想有任何未定义行为。” 你打算如何检查呢?类似地,回到排序的例子,比较器必须施加严格弱序。嗯,即使你试图为行为不良的比较器定义行为,你可能想尝试比较范围中的所有元素,逐一查看是否满足严格弱序的所有期望。好极了,你可以这样做。问题是,C++ 不要求这是一个纯函数。所以它可以在调用之间改变行为。而你没有办法真正检查这一点。就像那些大众汽车,柴油门事件,在测试台上它们表现完美。然后它们在路上就决定,全是污染、污染、污染。同样,比较器可能在你检查前置条件时表现良好,然后决定,“你知道吗,我不要严格弱序了。”
另一个例子,锁定互斥量。目前的前置条件是锁不能被当前线程拥有。在那里,你可以检查它。你可以尝试 try_lock
,但是如果 try_lock
失败了,你基本上又回到了相同的情况。
接下来是效率。同样,为所有输入定义行为可能是可能的,但代价非常高昂。例如,我们有一个合并两个范围的函数,其复杂度是线性的。它有一个前置条件,即两个范围都必须相对于 operator<
排序。检查是否排序本身也是线性的。但你基本上把你正在做的事情翻倍了。如果你说,“你知道,如果你给我两个未排序的范围,我会返回一个错误。” 所以你先检查,然后再做合并,你基本上把工作量翻倍了。可能更糟。lower_bound
或二分查找,无论你选择哪个,它要求范围必须相对于 operator<
和你正在查找的值是已分区的。而要检查,如果你想定义当它不成立时的行为,你必须检查范围是否已分区。这是一个线性操作,而我们非常希望该函数的复杂度是对数级的。更重要的是,你施加的性能惩罚将出现在对该函数的每一次调用上。即使调用者确切知道他们在做什么,他们在调用 lower_bound
之前刚刚对范围进行了排序,他们仍然必须支付性能惩罚的代价。
现在说说可靠性。如果你试图“处理”对你的函数来说毫无意义的输入,这实际上通常会导致掩盖缺陷。所以,有人用真正荒谬的输入调用你,而你继续工作,不通知调用者,然后这是一个错误,它只是不断传播、传播、再传播。让我们看一些假设的非常糟糕的例子。如果 pop_back
在 vector 为空时什么也不做,怎么样?也许吧。也许吧。但是,string_view
上的索引操作符呢?我们为什么不将索引钳制到大小范围内?这似乎更危险一些。再进一步呢?如果 optional
上的引用操作符,在 optional
为空时,返回给你一个静态默认构造值的引用?现在你要求该类型必须是默认可构造的。你会遇到所有静态对象带来的线程安全问题,这简直有点疯狂。
所以,如果你为一个函数定义了荒谬输入的行为,而调用者有一个错误,程序可能在某种情况下工作,它给人们一种错误的安全感,因为他们有一个错误,但他们看不到它,事情似乎还能运行。而这个错误可能在远离调用点的地方表现为错误或崩溃,而且很可能在最糟糕的时刻。就像,你知道的,你的客户在展示 Microsoft Windows 95时,就在向全世界展示的时候崩溃了。或者你因为一笔错误的交易损失数百万美元。
可维护性。同样,处理无意义的输入使实现复杂化。如果你有更多错误路径,不仅你作为函数的实现者需要做更多工作,你的客户端也可能需要处理更多的错误情况。所以他们的代码变得更复杂。让我们看一个简单的例子。假设我们有一个叫做 min_double
的函数。你给它一个范围,就是一对指针。这是我们想写的。我们基本上想写一个简单的循环,仅此而已。但显然这个东西会有一些荒谬的输入。所以我们决定,“你知道吗?我们不要前置条件。我们将尝试处理一切。” 然后抛出错误。好的,这是我们可以检查的一部分内容。注意我们无法真正检查所有东西,但我们尝试了。然后我们说,“你知道吗?也许我的数组里还有一个 NaN
。我不想要那个。所以如果我找到一个 NaN
,我就抛出一个域错误。” 而且,为了保险起见,最后,我还要检查一下我是否正确地写了这个东西。所以如果出了什么问题,我就抛出一个逻辑错误。你看,函数的规模是如何急剧增加的。在调用者这边,你可能还必须处理异常。所以它比我们之前的小函数可维护性差得多。
最后是可扩展性。如果我们定义了所有的行为,我们就不能轻易改变它。它基本上会破坏所有依赖该定义行为的客户端。但是,如果我们之前的行为是未定义的,那么没有人应该依赖它。当然,我必须引用海勒姆定律。但是任何没有违反我们契约、没有引发未定义行为的正确客户端,我们可以在不破坏任何现有客户端的情况下将其更改为某种定义的行为。例如,如果我们有自己的 vector 实现,并且我们有引用操作符。就像标准 vector 一样,如果索引超出范围,我们将此行为定义为未定义。但如果我们愿意,我们可以将其更改为直接中止。这是一种定义的行为。没有任何现有程序会真正崩溃。所以我们改变了行为。我们扩展了函数的行为,没有任何问题。
那么,什么是好的契约?嗯,首先,我们需要决定操作的顺序。我们有几件事需要做。我们需要实现函数。我们需要签名,我们需要写契约。这是正确的顺序吗?嗯,我想我从未见过有人在签名之前做实现的人。所以这显然很疯狂。那么这个顺序呢?首先,我们从签名开始,然后是实现,然后是契约。看起来更合理。看起来更合理,但如果你没有想清楚它应该做什么,你如何实际写出实现?所以这也不是正确的做事方式。那么这个呢?签名、契约、实现。同样,这好多了。但现实地讲,要理解你的函数需要接受什么参数,返回什么,你最好先想出一个契约。所以理想情况下,这应该是你的顺序。嗯,公平地说,契约和签名,你可以让它们共同演进。但现实地讲,你需要在做事之前思考你在做什么。
好的。那么一个好的契约有哪些方面?正如我们所知,在软件开发中,你希望事情简单、专注。所以对于一个函数契约,你想定义一个最小契约。也就是具有最多前置条件的契约,但对于你的功能来说又是完整的。所以它需要完成它需要完成的工作,但它不需要做任何额外的事情。因此,这样的函数将适用于各种各样的客户端。并且不会施加不必要的惩罚,比如,排序、lower_bound
,如果它有一个范围已排序或已分区的前置条件,你就不会对那些刚刚排好序范围的调用者施加惩罚。正如我们谈到的,有些契约天然是宽的。它们非常简单。回到 push_back
,回到 size
。另一个有趣的例子是递归互斥量。通常,操作系统会对递归锁的所有权层级数施加限制。那么如果超过了这个层级怎么办?你该做什么?这是一个前置条件吗?嗯,这… 这是一个更困难的问题,因为这是一个资源问题。这并不一定是对你函数的误用。而且调用者不一定能控制系统限制。所以在这种情况下,它可能非常类似于分配内存失败。而 push_back
在分配内存失败时做什么?它抛出 bad_alloc
。这非常… 这是一个异常,但它是你可以依赖的定义行为。所以,不要对你的客户端怀有敌意。在这种情况下,保持契约宽泛。标准库实际上就是这样做的。它抛出 system_error
。
所以,我们想要定义窄契约。但我们不希望它们太窄。例如,如果我们看 string_view::remove_prefix
函数。我们说,将这个视图的开头移动指定的 count
个字符。它应该处理零吗?比如,为什么有人… 你可能会想,为什么会有人需要移除计数为零的前缀?计数为零。但实际上,我们应该处理这种情况。同样,当我们移除整个 string_view
的前缀时也是如此。所以原因之一是,如果你处理这些情况,你的实现实际上会更简单。实现是一样的。并且没有理由限制你的客户端调用这个函数的参数。但是,如果他们用大于 size
的 count
调用,那通常是一个错误。例如,它可能来自某些算术运算。他们想… 他们做了一些计算,但减法的参数顺序颠倒了。由于 size_type
是无符号的,他们在 remove_prefix
中得到了一个巨大的值。这是个坏主意。所以,你想让那种行为保持未定义。
我们希望支持各种各样的客户端。当你定义一个具有窄契约的函数时,一种方法是提供额外的设施。例如,如果我们有一个 HTTPHeader
类,我们在 HTTP2 库中使用它,该库也允许 HTTP 通信。HTTP2 对头部字段施加了相当复杂的约束。那么,我们应该返回一个状态或指示… 来指示名称-值对的问题吗?嗯,不。我们不想把这个负担加给那些确切知道自己在调用什么的人。因为… 因为我们将不得不总是检查潜在的错误和我之前谈到的所有问题。但相反,我们可以做的是提供补充这个窄契约的宽契约设施。例如,我们可能希望提供一个函数来检查名称-值对是否是有效的头部字段。并且我们想提供一个并行的设施,比如 addFieldIfValid
,它会… 所以如你所见,它将返回一个错误码。本质上,它将做的是调用我们的检查函数。然后如果它是有效的,它将调用具有错误契约的 addField
。否则,它将返回一个错误。
好的。所以,我们定义了我们的函数契约。我们有一些前置条件。人们并非恶意,但人们会犯错。错误会发生。每个人时不时都会写出错误。那么,当契约被违反时我们该怎么办?嗯,根据我们的契约,行为是未定义的。我们可以做任何我们想做的事。我们可以在程序能力的范围内做任何事。嗯,我们应该报警吗?打个电话?嗯,要求赔偿损失。嗯,我的意思是,我们与我们的客户一起朝着更好的软件、共同的目标努力。所以我们实际上想帮助他们找到错误、理解问题并修复它。那么,我们有什么选择?我们能做什么来帮助他们?
同样,回到我们的 addField
方法。我们说,除非名称-值是有效的,否则行为是未定义的。好的。所以如果有人违反契约调用我们,提供了错误的名称和值,我们可以什么都不做。这不是很有帮助。在这种情况下,它甚至可能不会进入语言层面的未定义行为。但是,错误会在很久以后,当这个头部被发送到远程端,而远程端说,“是的,不,不,我不接受它。格式错误。”时才会出现。而找到问题,试图追溯到根源,将花费很长时间。所以,我们可能想检查前置条件是否被违反,但我们接下来该做什么?我们可以尝试修复不正确的字符。但是,同样,我们是在掩盖缺陷。我们的客户端将不知道他们做错了什么。我们应该抛出一个异常吗?可能有用。也许我们只想在日志中打印一条消息,然后像以前一样继续。也许我们想中止程序以立即提醒用户存在问题。或者两者兼而有之?也许打印并中止?嗯,在我们讨论哪种选择是最优之前,让我们提醒自己:检查是强制性的吗?比如,我们必须检查吗?我们必须总是检查吗?我们必须检查所有东西吗?嗯,我希望你记得我们无法检查所有东西,而且我们实际上不必总是检查,因为行为是未定义的。所以,我们可以使用,例如,我们的老朋友 assert
。
所以,这是我们的函数,我们说,好的,在开头我们断言名称-值对是有效的。那么,这给我们带来了什么?嗯,在检查构建中,当 NDEBUG
未定义时,一个违反契约的调用将导致检测。它基本上会在标准错误上打印一条消息。它会被及早检测到,并且通常非常接近问题的源头。所以,这就是 assert
会为你打印输出的示例。在非检查构建中,当 NDEBUG
被定义时,我们得到一个更高效的程序,因为检查将被完全省略。它不会被预处理掉,或者我们直接移除它。你可能会问,嗯,你谈到了未定义行为。所以契约违反会导致未定义行为。难道编译器不能做我们能用程序做的几乎任何事情吗?比如优化掉一些东西?嗯,并不完全是。原因是当契约被违反时,我们可能,在大多数时候,我们还没有达到语言层面的未定义行为。相反,我们拥有所谓的库未定义行为。因为,是的,我们违反了契约,但我们还没有进入硬 UB领域。它有时也被称为软 UB。例如,如果我们有像 strlen
这样的函数,非常直接。如果有人传给我们像空指针这样的东西,我们在进入函数时就遇到了库未定义行为。但直到我们尝试解引用那个指针之前,我们不会达到实际的语言未定义行为。
所以,契约检查。契约检查是冗余的。我想强调,它是旨在检测函数调用者误用的冗余代码。并且因为它是冗余的,从一个尽可能正确的程序中移除部分或全部检查,不应影响其核心行为。如果我们违背了“移除契约检查不应影响核心行为”这一理念,我们可能会遇到一些问题。我只想强调其中的几个。
想象我们有一个函数,我们说如果向整数集合插入值失败,则行为未定义。这是检查它的好方法吗?嗯,不是。这是一个好得多的选项。因为,再次记住,在非检查构建中,断言将被完全移除。所以我们将不会插入任何东西。因此,我们确实需要确保核心副作用不出现在谓词中。
这是另一个例子。它更狡猾一些。想象我们有一个包含 map
的类。我们有一个叫做 insertValue
的函数。我们说,如果索引处的值先前已被损坏,则行为未定义。这就像加密存储。你可以放些东西进去,后来损坏它,这样就没有人能再放进去。这是一个好的检查吗?这不是一个好的检查。那是因为 map
上的索引操作符如果元素不存在,将会插入一个元素。所以,如果我们在非检查构建中移除这一行,行为将会改变。
好的。但所有的副作用都是坏的吗?嗯,让我们看看这个例子。我们有我们的 HTTP 头部东西。我们有一个叫做 contains
的函数。它会记录日志。我们决定我们需要在那里记录一些东西。然后它基本上进行查找并检查元素是否存在。顺便问一下,这里有副作用吗?嗯,那是什么?是的,但就在这一行里。嗯,临时分配,可能吧。这对你来说可能重要,也可能不重要。
然后我们有 addField
。我们要求该字段尚未被插入。这是一个好的检查吗?嗯,好的。现在它被切掉了。有时它可能是可以的。这取决于你的应用程序。这取决于你的上下文。比如,如果你的程序唯一的目的就是打印日志。在你的程序中添加或删除一条日志语句可能会改变你的核心行为。但如果你是从事互联网通信业务,而日志仅用于记录本身。特定日志语句的存在或缺失可能并不重要。所以它可能是可以的。
同样重要的是,不要将契约检查用于控制流。这是一个有点人为的例子。我得说,但我想强调这一点。想象我们有一个函数,它应该返回一个值的一半,或者一个空的 optional
。如果你尝试索引超出范围,它返回空的 optional
。并且它有一个前置条件,无论你取一半的那个元素应该是偶数。我们很聪明。我们知道如果 vector 中有一个 at()
函数,当你尝试越界访问时会抛出异常。我们决定,“嘿,我们将使用那个函数,同时也检查前置条件。” 好的,然后我们返回值的一半,否则返回 null
。但这也是一个坏主意。再次,如果你构建一个非检查构建,你将失去那个检查,并且会遇到大麻烦。
另外,重要的是不要将契约检查与输入验证混淆。我所说的输入,是指来自不受信任来源的数据。比如可能是通过用户界面或命令行输入的用户输入,你刚打开并读取的文件数据,或者通过网络从远程接收的数据。如果你试图使用契约检查来断言你从网络上读取的数据是你所期望的,当你构建非检查构建时,你会很痛苦,因为那时你将不会对外部数据执行任何检查,这是个坏主意。这是一个深刻的话题。在下面,有一行写着,“你看我的 CppCon 2019 演讲”。我在那里更详细地讨论了输入验证。而且,你知道,这并不那么容易。
重要的是要理解契约检查不能替代单元测试。实际上,它们是相辅相成的。它们互相帮助。当你运行测试驱动或运行测试时,你代码中的契约检查如果检测到契约违反,断言将再次准确地通知你问题发生在哪里。此外,单元测试本质上是验证后置条件的机器。你给你的功能一些输入,然后检查是否得到了正确的输出和正确的行为。
另外,我不会谈论不变量,但我觉得有趣的是提一下,实际上你可以在析构函数中断言你的不变量。如果你有一个非常彻底的测试驱动,它把你的对象放入每个可能的状态然后销毁它,你就可以断言你的成员函数保持了不变量。
好的。那么,让我们总结一下。在创建函数时,从定义其契约开始。契约应该是最小但完整的。让行为保持未定义。不要害怕有前置条件。这是一件好事。拥有这些前置条件通常会使你的代码更快、更简单、更易于维护、更具可扩展性。然后,当你定义了契约后,尝试检查那些你可以检查的前置条件。你想帮助你的客户端及早且精确地发现问题。并且尽量不要误用契约检查。嗯,真的,不要。不要把核心副作用放在里面。不要将契约检查用作控制流机制。不要将其用于输入验证。并且记住,契约检查不是替代而是补充单元测试。
好的。现在我们有了契约,我们的客户如何知道该做什么呢?嗯,记录你的契约很重要。你可能会问,为什么不直接用自文档化代码?这很棒。嗯,它通常不起作用。四元数是一个非常有用的数学结构,用于表示 3D 空间中的方向。它们真的很棒。比如,如果你有一个需要沿样条线插值的相机,这字面上是最好的方法。如果你尝试用矩阵来做,那会痛苦得多。所以,你知道,你是一个刚了解到四元数及其优点的开发者。你需要使用它。你遇到了这个函数。你知道四元数定义了一个方向和一个角度。比如围绕该角度的旋转。所以你看到 x, y, z
。那可能是方向。w
,嗯。可能是角度,对吧?嗯,问题是,它不是。这就像,这来自很久以前有人在 Stack Overflow 上提的一个问题,我回答了。他们正好犯了这种错误。这些是四元数的坐标,你需要从轴角表示转换过来。
同样,记录你的契约。如果你遵循这个过程,你应该从契约开始。如果你已经思考过它,就把它写下来。你免费得到了文档。而“自文档化代码”的另一个问题是。你里面的任何行为都会变成核心行为。因为你没有另外说明。我的意思是,仍然有海勒姆定律,对吧?即使你在契约中承诺了某些东西,人们,你知道,只要有足够长的时间,就会依赖它。但至少你有一条路径,你知道,去面对他们说,“嘿,伙计们,我告诉过你们行为是未定义的。” 至少他们会感到难过,这已经是一种胜利了。同样,如果你有前置条件,如果你不记录它们并且你无法检查它们,那么客户端怎么知道呢?
好的。那么,一些基本的指导原则。我们需要记录契约的所有方面。我稍后会讨论这些方面是什么。具体的风格并不重要。风格由你决定。但一致性非常重要。它对你的库的客户端有极大的帮助。
好的。在我继续之前,我要再做一个免责声明。在接下来的例子中,我将使用我团队中使用的风格。我将在本节末尾与 Doxygen 风格进行直接比较。但是再次,记住,风格是你的选择,只要你记录了契约的每一个方面。
那么,契约的方面,我们在文档风格中如何布局它们?函数做什么,这是第一点。它返回什么,任何其他核心行为。所以,就像一些核心的东西,不一定与函数的主要目的相关。我们会给你一点函数描述。然后我们描述前置条件。然后我们可能会为读者添加一些额外的有用说明。
好的。那么,函数做什么。我们用一个祈使句描述函数执行的主要操作。这是… 就像,我没有真正… 好吧,没关系。这样做最好的结果之一是,如果你很难用一句话概括函数的主要操作,很可能… 很可能这个函数做了太多事情。你可能需要澄清目的,也许需要拆分它。所以,这是一个非常有用的设计工具。然后,在同一个句子里,你按名称指出所有参数,并解释它们是如何被函数使用或修改的。同样,这非常有用,因为如果你的句子流畅性被破坏了,你可以看到,“哦,可能参数名描述性不够强。” 然后你改变名称,你知道的,恢复句子的流畅性。大多数时候,当我个人不得不这样做时,它确实改善了参数的命名。
让我们看一个例子。我们有我们的 string::erase
,带两个迭代器。函数的主要目的:从字符串中擦除由指定的一对 first
和 last
迭代器在字符串内定义的子字符串。完成。
嗯,函数返回。同样,用一个祈使句描述函数的返回值。同样的原因,如果你很难用一句话表述它,你可能有一些设计问题。对于我们的 erase
,我们说:返回一个迭代器,提供对擦除前最后一个位置字符的可修改访问,如果不存在这样的字符,则返回 end
。我的意思是,显然,如果你的函数返回 void
,你不需要写“返回空”。这很明显。有时对于简单的函数,很容易合并。将返回部分与函数做什么合并是有意义的,因为函数所做的就是返回一个值。例如,starts_with
:如果此视图以给定的子视图开始则返回 true
,否则返回 false
。
其他核心行为。嗯,我们可能有一些附带效应和对功能至关重要的其他后果。例如,对于 erase
,我们的好朋友 erase
在这里,我们说:此方法使指向 first
或后续位置的现有迭代器失效。这是 erase
函数核心行为的一部分。它可以包括线程安全、复杂度保证、迭代器稳定性保证等内容,比如强异常安全保证等等。这里可以包含很多东西。
现在是前置条件。我们用短语“行为是未定义的,除非”来引入我们的前置条件,除非这会导致双重否定。因为没人喜欢双重否定。那么我们为什么使用“除非”形式呢?好的,没关系。让我们看看例子。在我们的 erase
例子中:行为是未定义的,除非 first
和 last
都在范围内,并且 first <= last
。
那么,“除非”形式的好处是什么?首先,它允许我们在文档和契约检查中使用相同的表达式。例如,我们简单的平方根函数说:行为是未定义的,除非值是非负的,然后我们在函数体中断言完全相同的拼写。但这还不是全部。用“除非”表述的多个前置条件是叠加的。所以你可以有多个,只需写多个断言,你不需要… 哦,这里,我写了 if
,然后如果 A || B
,那么我需要检查否定。所以我需要把 ||
改成 &&
。当你… 当你不需要让你的生活比必要的更困难时,你就不会有那种麻烦。这其实也不是麻烦。例如,如果我们有一个关于正方形的函数,它发送两个维度,我们说:行为是未定义的,除非宽度是非负的,并且高度是非负的,然后我们只需一个接一个地写两个断言。非常直接。
另外,保持前置条件的一致顺序也非常有用。我的意思是这样的。例如,如果我们有一个特定对象上的成员函数 func
,它接受三个参数。首先,我们描述那些不依赖于任何输入参数的前置条件。描述对象状态或全局状态的前置条件。然后我们定义前置条件,描述各个参数的前置条件,然后是它们之间的关系。仅仅保持这种一致的布局就对读者非常有帮助。
附加说明。附加说明可以是任何对函数客户端可能有用补充信息。它通常是你可以从现有契约中推导出的行为方面,但它们不一定显而易见。例如,如果我们有字符串上的 data()
函数,我们可以说:注意,任何对字符串析构器或任何操纵器的调用都会使返回的指针失效。如果你阅读了整个关于字符串的文档,你大概能弄清楚。但提醒阅读契约的人是有用的。同样,一个到 basic_string_view
的转换操作符。注意:此转换操作符可以被隐式调用,例如在参数传递期间。你可以查看签名,可以看到它不是显式的,你可以推断出来。但这强调了此转换操作符的用途以及它为什么实际上是隐式的。
那么,为什么我们选择这种风格?它实际上非常简洁。其严格的结构使我们能够在编写文档时发现问题。它是面向人的,而不是面向工具的。但是外面有各种各样的风格。可能最流行的是 Doxygen,但你也有 QDoc 等等。
那么让我们看看它们如何比较。我将从一个非常夸张的例子开始,但它仍然很有趣。这是某些人用 Doxygen 风格写的东西的一个例子。它是一个将两个值相加的函数。我们有一个简要描述,一个扩展描述,以及每个参数的描述。但有太多词没有真正说明任何东西。它只是污染了整个… 对于这样一个简单的函数,它花了九行,八行文档。用我们的风格,我们只说:返回指定的 x
和 y
的和。完成。在一个短句中,你就知道这个函数是做什么的。而且,如果我们遵循我们关于如何编写文档的指导原则,你可能会注意到你可能需要提到这个函数有前置条件。或者取决于你想如何实现它,你可能会说,嗯,核心行为是如果导致整数溢出它会抛出异常。
但是,人为的例子很棒。让我们看一个真实的例子。我从 JUCE 框架中拿了这个例子。JUCE 框架工程卓越,文档优秀,这体现在它有 5,000 颗星和 1,500 次分叉上。接下来的例子的目的不是要给 Doxygen 风格或 JUCE 抹黑。只是为了提供一个公平的比较。我随机选择了一个组件,这次是 ZipFile
。它有很多大块的代码,我认为它能为比较提供一个良好的基础。
好的。那么这是一个 ZipFile
的成员函数。叫做 addEntry
。它说:将流添加到将添加到存档的项目列表中。我们有一个参数 stream
,用于读取,有很多信息。并且说它不能为空。我们有 compressionLevel
。我们说它可以在 0 到 9 之间。我们有 storedPathName
,将存储在文件中的部分路径名。还有 fileModificationTime
,将存储在文件中的时间戳,是哨兵上的最后修改时间。
我尝试想出我们会如何记录它。看起来是这样的。你注意到它实际上更短。我说:将指定的流添加到将添加到存档的项目列表中,使用指定的压缩级别进行压缩,并使用指定的部分路径名和文件修改时间进行存储。注意我实际上重命名了一个参数。它原来叫做 storedPathName
和 fileModificationTime
。但就像两者都是被存储的。如果你看到两者都是被存储的,为什么其中一个叫做 storedPathName
而另一个不是像 just fileModificationTime
?所以我只是把它重命名为 partialPathName
,这实际上是对它更精确的描述,以及 fileModificationTime
。
然后我们说,有趣的是,在这个文档中,它说 compressionLevel
可以在 0 到 9 之间。这是什么意思?如果它超出这个范围会发生什么?我们钳制它吗?我们有未定义行为吗?我不知道。所以在这里我推测它很可能是未定义行为。但在这里我非常具体地说明:行为是未定义的,除非压缩级别在该范围内。或者如果流为空。然后我在“注意”部分重复了关于流细节的长描述。你看,我在那里用了那个。所以,如果你查看整个组件并分析,你大概可以理解那将是行为,但对于阅读这个特定函数的读者来说,指出这一点非常有用。
另外,在我写它的时候,我在想,partialPathName
可能有一些约束。但我不知道它们是什么。所以,再次,这种风格,至少对我个人来说,只是突出了问题,比如文档缺乏。
所以,请记录你的契约。具体风格真的不重要,但要选择一个。并遵循它。确保它包含契约的所有方面。并且保持一致。
好的。所以,assert
是不够的。这总是让我想起詹姆斯·邦德的《黑日危机》的调子。但是,大规模契约检查。嗯,希望我已经说服你,契约检查,即使用窄契约编程、描述契约、进行契约检查,是一个好主意。如果你试图仅仅使用常规的 assert
来大规模应用,你可能会遇到困难。因为 assert
的行为,嗯,是不可配置的。它会做它该做的事。所有检查都被同等对待,无论它们的复杂度如何。使用 assert
向旧代码添加检查非常、非常、非常困难。测试那些 assert
语句也非常困难。所以我要详细讨论每一个问题。
问题一:assert
行为不可配置。它只是打印到 stderr
并中止。而你,作为应用程序所有者,特别是大型应用程序所有者,可能想要完全不同的东西。也许你只是想记录到不同的目的地,不同的格式。也许你想抛出一个异常。也许在循环中自旋并让出,只是等待调试器被附加。也许其他事情。你可能会安装一个 SIGABRT
处理程序,但它的功能非常有限。它没有关于契约违反的信息。并且它无法真正区分来自 assert
和其他来源的信号。
好的。那么我们如何解决这个问题?嗯,我们可能需要实现我们自己的契约检查系统。而 CCS,那正是它的缩写。所以我们定义,好的,我们将有一个违反处理程序,我们将有一个设置违反处理程序的函数,每当检测到契约违反时就调用该处理程序,并向处理程序提供有关问题的信息。发生在哪里,违反了哪个条件。提供几个现成的处理程序是有意义的,这样使用你契约检查系统的人就不必自己编写了。默认情况下,直接中止是有意义的。这可能是最有帮助的,但并不总是适用。我们也可以提供“失败后睡眠”,就是在循环中睡眠,等待调试器。有一个 std::breakpoint
,我相信。它在 C++23 被接受了。那也可能是一个… 没有?还没有?好的,希望在 C++26,你可以把它作为你的契约违反处理程序。我们也可以“失败后抛出”,但我在讨论测试时会更多地谈到它。
违反处理程序是否应该被允许继续执行?在许多应用程序上下文中,这实际上是一个坏主意。因为契约被违反了,程序已经损坏。我们可能很快就会遇到语言未定义行为。如果你正在一个进行交易的金融系统上工作,如果你检测到问题,立即停止。否则,你可能损失数百万,而对方不会在乎你有一个错误。但在某些行业、某些情况下,我们需要继续执行。例如,在游戏中,未定义行为,一帧没有正确渲染,下一帧可能就好了。也许吧。那么为什么要停止你的游戏进程呢?我们将稍微多讨论一下这个。现在,让我们阻止继续执行。我们可以通过定义… 很简单地做到这一点。它仍然必须是一个宏,因为我们想将条件字符串化。至少我们可以使用源位置。如果条件不满足,我们调用处理程序,然后中止。顺便说一下,在… 在非检查构建中,我们不只是移除整个语句。我们把它放在一个未计算的上下文中,以确保它仍然被编译,这样我们可以防止一些代码腐烂。然后我们可以那样做。
好的。我想提一下,这个可定制的违反处理程序,有人可能会试图以完全不同的方式将其用于控制流。这里有一个简单的例子… 就像一个函数接受一个字符串,它应该是一堆数字,然后可能是一个点,再是一堆数字。所以就像一个值的整数部分和小数部分,例如在 JSON 中。有人可能会进来说,“你知道吗?我就要用这个有用的 RAII 守卫来设置我的违反处理程序为抛出。这正是我在契约中承诺的要抛出 std::domain_error
。然后我将愉快地继续,并断言所有那些指示何时抛出 std::domain_error
的语句。嗯,正如你可能想象的,在一个非检查构建中,这将严重崩溃。这是一个非常糟糕的主意。
好的。除了 assert
,提供额外的实用工具是有意义的。例如,invoke_handler
,它允许用户直接调用处理程序。有时它有助于避免多次计算谓词。例如,如果我们有一个函数接受一个枚举作为输入,并且该枚举有两个特定的枚举项,我们可以写 assert
。我们想确保没有给我们一些随机的整数,然后做一个 switch
。但是使用 invoke_handler
,我们可以避免双重检查,只需添加一个 default
分支并在那里调用处理程序。这比 assert(false)
更好,因为 assert(false)
有时会给你一个警告。而你不想无缘无故地收到警告。
第二个问题:有些检查太昂贵。应用程序所有者需要有能力在检查的性能影响与检查量之间取得平衡。库开发者需要一种方式将这种控制权交给应用程序所有者。你可能会采用的第一个方法,让我们给每个语句一个数字,显示检查相对于函数有用工作的相对工作量。例如,square_root
,不是一个非常简单的函数。所以这个检查非常简单。所以我们给它一个 3。3%,比如说。嗯,很酷。当我们对一个值取负时,我们的前置条件是我们不是 INT_MIN
。工作量非常相似。给它一个 100。我们的 lower_bound
怎么样?1000?100000?大小乘以 100?也许乘以 3?我不知道。在实践中,这太繁琐了。编写和控制都非常困难。
所以相反,最好有非常粗略的分类。首先是常规断言,表示检查所花费的计算量少于实际有用工作。其次是审计检查,基本上比有用工作花费更多。这是一个最小有用的集合。所以你可以有,比如你的企业可能决定,“哦,你知道吗?我们实际上需要一些超轻量级检查,我们真的希望几乎总是运行它们。” 但几乎。这很重要。也许我们希望为破坏我们算法复杂度的检查设置一个单独的级别。我们真的不想要那样,除非我们正在构建超级重度检查的构建。
也有一些技巧,你可以如何使用它们。例如,我们的好朋友 lower_bound
,再次,我将使用 is_sorted
而不是 is_partitioned
,因为这只是幻灯片。但如果我们有一个 lower_bound
函数,我们只是按常规做。那么我们如何验证它的前置条件?首先,我们可以为 is_sorted
添加一个审计级别的检查。所以在大多数构建中,这个检查不会被激活,也不会拖慢我们的性能。但我们希望在默认级别构建中也进行一些检查。所以我们可以做的实际上是在循环内部,检查我们正在查看的两个元素。这样我们只确保我们查看的元素是有序的,但我们不会改变复杂度。仍然是 O(log n)
的算法。
下一个问题是添加或修改检查很困难。我的意思是这样的。想象一下,你有一个交易系统,已经在数千台机器上运行了多年,被证明非常可靠,绝对出色并且运行良好。但我们想向该系统添加一个契约检查。契约检查,契约违反,我们想修复它们。但我们怎么做呢?如果我们只是添加一个 assert
,我们将遇到一个大问题,因为它会使所有机器宕机,而我们知道那东西是正常工作的。另外,我们可能添加了一个错误的检查。所以我们也不想因此让所有机器宕机。例如,这确实发生过。我们有自己的 optional
实现,带有解引用操作符。行为是未定义的,如果 optional
中没有值。但现有的代码实际上会解引用基础类型的 optional
,然后,你知道,什么也没做。所以这在技术上是契约违反,但事情正常运行。同样,如果你有一个审计级别的检查,你想把它变成默认级别的检查,情况非常相似。
那么我们如何解决这个问题?我们想要的是一个契约检查,允许在违反处理程序完成后继续执行,即使它返回了 nominal
。所以它看起来基本上是一个不同的宏,叫做 review
。它和 assert
完全一样,但你看,为了幻灯片可见性,abort()
被注释掉了。就是这样。
所以当你处理审查失败时,如果你把它放到你的工作系统中,即使是一个不正确的使用,那个代码位置也可能被命中数百万次。如果你每次都记录日志,你会让你的应用程序陷入停顿。如果它中止了可能更好,因为这样是勉强运行着,消耗你的能量,几乎不做任何有用的工作。为了缓解这个问题,违反处理程序需要区分发生在 assert
中的违反和发生在 review
中的违反。所以你可以做指数退避,并且更少见地记录日志。
所以我们做的是,我们为每个契约检查关联一个语义。它可能像这样简单。我们有一个忽略语义。这实际上永远不会被调用。违反处理程序不会被这个调用,但为了完整性它是有用的。强制执行语义,我们用于 assert
,基本上在处理程序返回后中止。以及观察语义。基本上,如果检查失败,违反处理程序被调用,但在违反处理程序返回后不会中止。我们需要修改我们的契约违反对象,并通过提供语义向违反处理程序提供信息。所以我们的宏稍微改变了一下。对于 assert
,我们提供 enforced
。对于 review
,我们提供 observe
。然后我们可以编写我们的指数退避处理程序。我不会讲得太详细。所以我们根据语义进行切换。如果是 enforced
,我们只记录日志,然后中止将从外部调用。对于 observe
,我们只计算违反次数,然后只有在它基本上是 2 的幂时,我们才记录日志,然后返回。就是这样。所以对于特定问题,我们将以指数级递减的频率记录日志。
实际上,在我继续之前,有趣的是,在这个星期四的研究组 21会议上,有人提出一个想法,可能要求违反处理程序本身在语义是 enforced
时中止,而不是契约检查系统本身强制执行中止。这可能对… 比如,如果你有一个第三方库,一个闭源的第三方库,而他们由于某种原因用 enforced
契约编译了。如果你有第三方库,如果你有第三方库,如果你有第三方库,你实际上可以将其改为 observe
,而不需要重新编译该库。但是,你知道,这是正在进行的工作。
那么,一个审查的生命周期。我们想向现有代码添加新的检查或更改现有检查的级别。我们从添加一个审查开始。我们观察并修复足够长时间内的任何违反。一旦我们修复了所有问题,没有观察到新的违反,我们就把那个审查改为 assert
,它就成为一个适当的 assert
,并且再也不会改变了。
好的,我将跳过那个。让我们看一个例子。那个 optional
解引用操作符的例子。我们有一个审计级别的 assert
。但我们想将该级别改为默认。这样它会被更频繁地检查。所以我们首先保留审计级别的 assert
。我们添加默认级别的审查。等待并修复问题。一旦希望所有问题都修复了,只需用常规的 assert
替换它。我们就完成了。
另一个有用的案例是,我们有一个一直在运行且一切正常的生产系统。但我们决定我们想在一部分服务器上运行额外的、更昂贵的检查。我们可以说我们只是将 assert
当作… 我们不希望那些服务器,如果它们在审计级别检测到契约违反,就死掉。因为我们知道一切正常。所以我们基本上将审计级别的 assert
视为审计级别的 review
,并将其投入生产,并监控日志以检测是否有任何违反。
另一个例子是缩小契约。这也是我们身上发生的真实事件。所以我们有一个并发缓存,有两个水位线,低水位和高水位。最初的契约是,如果低水位高于高水位,我们将使用低水位作为两者。而实际上,这意味着这个缓存不仅没有提高我们的性能,它只是闲置在那里,毫无理由地消耗资源。这通常是一个错误,人们并不希望这样。这是原始的实现。那么我们如何着手缩小这个契约呢?首先,添加一个审查。和之前一样,坐着等待。找到所有违反它的地方。修复它们。然后修复契约。现在行为是未定义的。修复实现。看,多么简洁小巧。并保留 assert
。将审查改为 assert
。
最后,契约检查,如果你使用 assert
,是很难测试的。但它们是代码。我们是人。我们会犯错。在断言中也会出错。所以我们必须对它们进行单元测试。所以我们需要做的是触发契约违反,并确保我们的契约检查在启用时确实捕获了问题。我们怎么做呢?如果我们有一个 assert
或者一个中止处理程序,它将需要死亡测试。但许多测试框架不支持它。它有显著的性能惩罚。很难区分。比如程序一旦中止,就很难区分是断言失败还是其他中止原因。所以它不是很有用。实际上,它也常常依赖于进程分叉。所以对于非分叉安全的代码来说,它有点不安全。
所以我们的方法是使用我们所谓的断言测试。我们设置一个抛出违反处理程序。我们违反契约地调用被测函数。我们捕获异常。异常来自错误的地方?契约检查中的错误。当应该没有异常时捕获了异常?契约检查中的错误。当应该有一个异常时没有捕获到异常?信不信由你,契约检查中的错误。
让我们看看例子。想象我们有一个叫做 bind
的函数,在 Socket
对象上。它接受一个以空字符结尾的字符串和一个端口号。我们有前置条件,你不应该给我们空值。地址必须是有效的,端口号应该在 0 到 65535 之间。为了做测试,我们使用另一个 RAII 对象,它基本上设置违反处理程序为抛出。创建 socket。我们有几个宏。所以基本上调用某些东西,捕获异常,检查异常是否完全如预期。它来自正确的地方。它在正确的级别。
所以我们做一些负面测试。用于测试契约违反检查的负面测试。我们为另一个断言做另一个测试。最后,对于空指针,在下面你看不到的地方,我们还有一个通过检查。所以基本上我们也想确保当我们给函数正确的参数时,它不会做出不正确的契约检查。所以我们有… 空指针。所以我们有… 空指针。所以我们有… 空指针。
你可能会问,noexcept
会不会妨碍这种测试?我可以抛出一个异常。嗯,是的,它会。这就是为什么我们公司遵循莱克尔斯规则。基本上,最基本的形式是,不要在窄契约函数上放 noexcept
。其动机在最初的论文中,在 C++11 之前的 2000 年。即将到来的,在 5 月 15 日,P2837 中有更多关于此的动机,该提案由我的同事 Joshua Berne 和 John Lakos 撰写。
重要的是要理解,目前,noexcept
本身就是一个完整的话题,但 noexcept
在现代代码中被过度使用了。有趣的是,我们有一本书叫《安全拥抱现代 C++》。我们做了很多基准测试来弄清楚 noexcept
是否真的有助于性能。仅仅是在函数上放 noexcept
。结果表明,没有。有时它确实使代码大小小一点。但现实地讲,性能收益只出现在当该 noexcept
被某个泛型算法中的 noexcept
操作符查询时。这原本是 noexcept
的首要目的,即在存在移动语义的情况下维护 vector 的强异常安全保证。这是主要收益所在。但仅仅在所有函数上撒 noexcept
实际上是一个坏主意。而且,它阻止你检查你的契约。测试你的契约检查。
同样,你可能会说,嗯,如果你有 noexcept
,我就使用死亡测试。我们已经讨论了为什么死亡测试可能有问题。你说,好吧,我会设置我的违反处理程序做一个长跳转。但如果你跳过了任何非平凡的东西,这是未定义行为。所以这些测试很可能是不可靠的。
另一个想法是只在非检查构建中使用 noexcept
。但在测试时,将其关闭。你仍然可以使用异常进行测试。嗯,我将留给你 Eric Fiselier的这句话。那是 2019 年的一个提交。他说:“经过更多思考并变得更明智后,noexcept_debug
是一个可怕的决定。它是传染性的。它没有覆盖所有需要的情况。它对用户是可观察的。最坏的情况是,改变了他们程序的行为。” 也有一篇关于此的论文。同样在 5 月 15 日发布。
好的。那么,总结一下。提供设置违反处理程序的方法。main
的所有者会感谢你,因为他们可以选择处理策略。允许继续执行时要小心。但记住,它可能对你的行业或特定情况是可以的。按复杂度区分断言。至少有一个 assert
和 assert_audit
。有趣的是,这正是 Boost.Contract 所拥有的。它让函数作者轻松分类,让 main
的所有者轻松控制权衡。提供向旧代码添加新检查的机制,即审查。在那下面,你需要测试你的契约检查。
好的。我们快结束了。所以,仔细地为你的函数定义契约。你会感谢你自己。你的客户会感谢你。这是一个非常、非常有用的想法。将它们定义为最小但完整的。要有前置条件。让行为对于对你的函数毫无意义的输入保持未定义。通过契约检查来防御调用者的误用。你是在保护自己,但你也在帮助他们发现错误。选择一个文档风格并始终如一地遵循它。确保它包含所有重要的方面。在部署到大规模时,拥有一个足够灵活的契约检查系统。可配置的违反处理程序非常有用。实际上,标准化似乎已经达成共识,链接时可替换的违反处理程序是我们前进的方向。但仍然,再次,工作正在进行中。拥有一个向程序引入新检查的并行设施,比如审查机制。拥有几个断言级别以便于… 嗯,我们讨论过这个。再次,你需要测试你的契约检查。并且记住,不要在有窄契约的函数上放 noexcept
。