页面

分类

不变式、断言、契约和异常之我见

2016/8/29, by wingfire ; 分类: 代码相关, 程序设计; 2 comments

说起不变式和断言,程序员们是不陌生的。然而,对其内涵的理解,以及如何运用,实践中却颇多谬误。

不变式和断言常常是同义词,都是用来进行程序正确性分析的手段。在分析和讨论程序正确性时,断言和不变式含义没什么区别。但断言常常也指用于检验不变式的语言设施。

在分析程序正确性时,一旦不变式被打破,意味着程序存在逻辑错误,此时就没有必要在这个逻辑错误的基础上继续讨论了。但是在实际编程中,我们使用断言来检验不变式,一旦失败,总还会有个程序行为,默认的行为一般是程序终止。这里的“终止”,是指abort,或core dump,而非优雅地退出。

进入正题前,让我们先岔开一下,来看另外一个问题。

在某些语言中,如Pascal,Oracle数据库中,过程可以分为两类,一类叫function,另一类叫procedure。这对于来自于C社区的程序员来说,这种区分貌似是颇为多余的。从语言的实现层面看,这两者似乎也是一致的,没有区分的必要。当然,情况并非如此。Procedure,也被称为方法(Method,含义不同于OO所谓的方法),是指那些存在副作用的过程。而Function,也被称为查询(Query),指那些不改变系统状态的程序过程。对程序员来说,这两类过程对系统的影响是截然不同的。这种划分不是出于底层实现的需要,而是出于程序员认知的需要。

方法和查询在语法上的形式可以完全一样,但语义差别巨大。方法的返回值是一般是要被检查的,而查询的返回值则可以安全忽略。查询不改变系统,因此无论被连续调用多少次--虽然也可能消耗资源--所返回的结果总是相同的,查询因此被称为幂等的。幂等也意味着可以任意叠加组合而不影响系统正确性。方法因为有副作用,一般是不能被任意叠加调用的,即通常不是幂等的。查询的一次调用,也被称为对系统的一次观测。一次观测可以获取一个或多个系统状态。

在程序设计中,应该仔细地区分方法和查询。一个方法可能产生多个副作用,哪些副作用期待被观测到,这在设计时要明确。除了那些被设计所规定的可观测的副作用,一个方法不应该有其他副作用且能被系统内的某个查询所观测。这意味着一个方法的副作用并不能在设计时完全自由地增减,还要受制于系统的已有部分,简言之,设计本身是受限的,受系统的语义一致性所限制。例如,假设有某个方法向文件中写入一段文本,并且返回操作结束后这个文件的长度,或者在接口文档中声称文件长度增加量为文本的长度。如果这里说明文件长度的行为是必要的,那么系统就应该同时提供获取被写文件长度的查询。如果不能提供这种查询,那么关于文件长度的副作用描述就应该从方法中--函数原型和文档中--删除。注意,这和通过系统外的工具查询获得文件长度的变化并不矛盾。

在实践上,出于性能考虑,某个方法可以设计为返回副作用引起的一个或多个重要状态变化。即便如此,仍然需要为所有的副作用提供查询。以STL中map::insert为例,insert返回被插入元素位置的迭代器和一个是否成功插入的标记。实际上,我们可以在调用insert方法之前通过find查询待插入元素是否已经存在,从而预判insert的可能副作用,也可以在insert之后,通过find被插元素然后获得插入的位置。这两者在性能上都有所损失,因此insert的返回值是必要的。另一方面,不能因为insert返回了相当多的信息就不再提供相关的查询。

观测副作用并不都是可以随时进行的,有时需要一些准备工作。在map::insert的例子中,还有其他的副作用,例如,如果插入成功,map的size将增长1。要想观测这个效果,就必须在插入前记录map的size,插入后再次查询size,然后进行比较。关于方法和查询关系的描述往往容易让人误以为查询是附属于方法的,实事当然并非如此。确实存在某些查询只能服务于某个方法的情况,但是在map::insert的例子中,find和size查询显然都不是专为insert而设计的。对一个方法的所有副作用,都加以分析,看是不是所有的副作用都可以观测,这个步骤可以列入设计检查清单。实践中,我们总能检查出一些方法的副作用定义是不详细的,或者是不能观测的,这样的设计当然是需要修正的。

然而,性能是一个不能不考虑的方面。方法的副作用可能存在多种观测方式,我们有必要以最小算法复杂度提供必要的查询。有时候这会导致陷入两难,例如std::list::size()方法。为了实现常数时间复杂度的splice (来自两个list), 在C++98中,size允许是线性复杂度的方法,到C++11中,要求必须是常数时间。作为一般的原则,尽可能廉价地提供查询总是正确的,为此,甚至可以在一定程度上牺牲方法的性能。同样以splice为例,如果不考虑size()的效率,那么splice完全可以实现成总是常数时间的。当然,这种权衡只是一种经验规则,不能教条化。

如果一个系统的所有方法都满足其副作用可观测这一原则,这个系统的可测试性就完成了一半。在实践上来看,这将完成可测试性的一大半。

再回到断言。

区分方法和查询的可以帮助回答什么样的代码可以作为断言。一般而言,断言是一个表达式,而这个表达式必须是由查询组成的,不能有副作用。因为没有副作用,查询无论调用多少次,都不影响系统的逻辑正确性,因此,断言也有同样的性质,即必须也是幂等的。这就是为什么在实践中我们为什么可以在调试版本中保留断言,而在发布版中删除之,因为两者程序逻辑并未发生变化。我们也应该注意到,并没有什么逻辑上的原因要求将断言从发布版本中删除掉。去除断言是出于性能上的考虑。在相当多的实践中,都建议在发布版中保留断言,除非这么做会导致不可接受的性能损失(VC的STL为了支持调试,debug build的性能极差,真是吐槽无力)。

如何使用断言呢?这其实这包含三个部分:何时断言,何处断言,以及断言什么。很多讲不变式文章给出的例子是在一个循环开始前断言一个变量状态,在循环中或循环后再断言一个状态。这给初学者一种错觉,似乎断言只是为了帮助正确实现一个函数。断言的用途当然不仅于此,然而,这种感觉有一点是正确且重要的,即对实现者自身的怀疑和检验。断言并不直接证明程序是正确的,而是防备程序的某些错误的。这里所谓的“防备”也不是说程序员能够因此避免引入错误,而是寄希望于引入错误后能够发现。断言断的是程序员的“犯错”,是针对自身犯错可能的防范。

断言的另一个常见使用模式是用来描述函数执行前系统状态要满足的条件,和函数执行后系统状态将会满足的条件,即前条件和后条件。前条件也称前置条件、先验条件,后条件则也称为后置条件、后验条件。随着契约式程序设计思想的扩散,断言的这种使用方式就更加标准化了。在接口中描述的前后条件,是和接口的调用者之间的契约。前条件是调用者要保证达成的,后条件是函数的实现者承诺要达成的。在编程实践中,鼓励尽量用合法的语句来描述前后条件,但也有大量的情况是不可能用合法语句描述的,就需要辅之以文档,这种文档必须被认为是接口的一部分。对前后条件的检验,就成了对调用者可能出错的防范。

原则上来说,程序的实现者只需在认可的上下文中正确履行功能。以函数而言,其输出必须正确,这没什么疑问(呃,其实也未必没有争论)。但是其输入和开始执行前的系统状态也必须正确的要求,则往往被误解,认为即使前条件不满足,也应该尽力履行功能,美其名曰“防御式编程”。这是错误的。无论是在微观的程序编码层面、设计层面,甚至项目研发层面,都有个行之有效的原则,即快速失败原则(Fast Fail)。这个原则要求任何的错误都应该尽早地,尽可能响亮地暴露出来,而不是加以隐藏。工作流程也应该顺应此要求。失败并不可怕,可怕的是掩盖失败。所谓防御式编程是对可能出现的错误做详尽的检测,以利于从根源上加以消除,而不是鼓励就地对问题加以掩盖。有时候,分辨解决问题和掩盖问题并不是那么容易的事,尤其是在要说服合作者的时候。

如果认可了快速失败的设计哲学,对前条件的理解就可以做合理的极端化。在前条件不满足的情况下,一段程序可以任意行事,包括使程序崩溃--这是最大声的嚷嚷。这时候,断言防备的就不仅仅是函数实现者自身的失误,而是防备函数调用者的失误--尽管调用者和实现者可能是同一个人,我们还是有必要做出角色上的区分,这是两种不同的角色。可以借用生产者和消费者的术语,设计、实现接口的,是生产者,调用方是消费者。程序员在编程时总是不断在这两种身份中切换的,甚或兼而有之。作为消费者,必须明白调用一个函数前要满足哪些前提条件,这是你的职责所在。作为生产者,有权检验前提条件是否都成立,有任何一条不成立,按照契约式程序设计的观点,就有权“撕毁契约”。注意,要区分这里的权利和义务。义务是向别人承诺的,是你要保证做到的,无论这种义务是否能通过代码加以检查;而生产者所要求的前条件是权利,调用者无权要求生产者必须或不能对每一项权利加以检查。有些人是分不清其中的区别的。

以strcpy(char dest, const char src)为例,dest必须不是空指针,消费者很容易保证这一点,但不应指望strcpy必然会断言dest不为空。又例如,dest所指空间必须足够大,这一点,strcpy是很难检测的,strcpy可以放弃检测,但这不意味着strcpy放弃了该权利。再比如,dest和src必须都不是野指针(显然),但是检测并不容易,但是这仍然是调用者的义务,和strcpy的权利。

尽管理论上并不要求对“权利”一一核查,但是在实践上,对于那些能够检测的前置条件,我们还是应该尽量在实现的时候通过断言一一加以检测。除非这种检测过于昂贵而难以承担。比如性能损失很大,不可靠,太复杂等等。这种对前条件的充分检测,才是真正的所谓防御式编程。这种检测只需要断言,而不是通过if...else...的分支就地“解决”问题。即便发现是设计错误,这个问题也必然要带回到设计层面去分析解决,“就地”是不可能的。

要想一个函数能成功执行,所需的前提条件是相当多的。有些前后条件是作为常识或约定出现的,比如,合法的指针参数,足够的栈空间等等,引用非空,默认是不必列出的。列出来,反而意味者有特殊情况。而一个函数到底应该有哪些前条件?这基本上是一个设计问题,虽然实现的可行性也是必须要考虑的。而设计,是需要在一个上下文中加以选择和取舍的。

对于如何使用断言的三个问题,从契约式程序设计的观点来看,对于何时、何处使用断言,是很好回答的了。前条件当然是在进入函数后,立即完成(实际可能有更复杂的情况,某些检查会稍微延后)。后条件的检查则是在被调函数返回后。这中观点不意味着排斥传统的在一个复杂函数的内部实现中使用断言,只是那种使用方式不再是一个重要的议题了--这其实也意味着后条件的检验是相对不太重要的。

然而,对于断言什么的问题,就没有机械的条款可循了。什么东西可以称为前后条件,除了受制于函数的功能,更多的是受制于设计者的选择和设计决策 -- 一个函数具有什么功能一样是设计者决定的。但是作为一般的原则是,前条件尽可能地“苛刻”(放宽对调用者的前条件总是容易的),把环境定得很细,使得实现代码所必需处理的意外降低到最少。一言以蔽之,奉行极简主义,追求高内聚。C标准库的strcpy是这方面的一个正面例子。

然而,也要防止过犹不及。

我曾经遇到这样一个例子:一个渲染引擎有个两个图片加载函数--姑且称为LoadA和LoadB,这两个函数都是用来加载某种图片的,但是这种图片内部有两种格式的(渲染引擎所需),LoadA和LoadB分别只能正确加载预期的那种格式。当某次渲染效果错误时,发现是用户程序员用错了Load函数和对应格式的图片,渲染引擎程序员一开始认为这是用户程序员的错误。于是问题来了:用户程序员有什么办法能区分这两种图片吗?答:两者图片内部结构是有不同的。再问:这种结构不同的知识,是应该渲染引擎掌握,还是用户程序必须掌握?答:这是渲染相关的知识,用户程序逻辑不应该掌握。

至此,问题应该怎么解决就很清楚了。

据这个例子的目的是想说明,尽管要奉行极简主义,但是不能要求消费方去掌握属于生产方的知识--不是看这种知识是简单还是复杂,而要看其归属于哪一方。这种以谁掌握相关知识来进行判断有时也会遇到困难。在上面的例子中,如果引擎提供了一个函数来检测图片文件的类型,从而消费方有充分的知识得以正确选择LoadA或LoadB呢?这时,在这个例子中还可以通过性能、简单性加以选择和设计。然而,具体到某个项目,恐怕就难以作出一般的指导了。

尽管断言什么很大程度上取决于设计者的取舍和决定,很难像另外两个问题一样作出机械的指导,但也还是有些指南可供参考,不至于茫然无措。

  • 区别方法和查询
  • 功能分解和正交
  • 定义基本操作集合(其中的函数都是不能通过别的函数来实现的),以区别于复合操作(原则上都是可以基于基本操作集合中的函数来实现的)
  • 最小化函数功能
  • 极简主义
  • 在恰当的粒度级别上进行抽象。

需要对第6条做一点说明。软件离不开抽象,在什么层次上,针对什么进行抽象,不同性质的项目也会有不同。以具有一定规模的C++项目而言,可能的抽象层次有:函数、类、组件、模块。其中,模块常常可以对应为一个动态库。解释什么是模块有点困难,C++并没有对组件的直接支持。但是,在我看来,组件恰恰是最重要的抽象级别。识别一个组件,要看它是否提供了一组相对完整且封闭的功能。这种识别不是全然清晰的。以STL为例,vector算是一个组件吗?还是所有的容器是一个组件?还是说容器+算法+迭代器+内存管理算是一个组件?在我看来,这还得看在什么层次上进行设计和讨论。STL显然是作为一个整体被设计出来的,尽管容器看上去相当独立,没有迭代器、算法和内存管理的容器是不完整的。但是,这不妨碍我们在实现一个新容器时把它看作一个独立的组件来设计。因此,在我看来,STL是一个由多个子组件构成的单一大型组件。区别组件和其他抽象的关键在于,组件关注的中心是功能组。函数、类则有很多语言结构方面的东西要处理(至少在现代C++中这很突出),模块则要较多考虑依赖和物理抽象的问题,各有侧重。一般而言,我们要站在组件和组件间交互的角度来进行设计上的选择和取舍。

这些指南并不告诉你如何选择前条件,只是你选择的前条件应该尽力促使系统满足这些要求。这些指南的存在意味着前条件的设计不是个纯技术的问题,有相当多的经验和艺术的成分。

我们可以出于同样的原则选择后条件。再次强调的是,和前条件一样,哪些东西成为后条件也是个设计问题,而不是实现问题,至少主要不是实现问题。那有没有可能我调用你一个函数的时候,前条件都满足了,但是后条件就是无法满足呢?作为调用者,按照我们关于契约的理论,你无须担心,满足后条件是被调函数的义务,不是你的。你需要担心的是,如果你在实现一个函数的时候,发现某种情形下,后条件无法达成,怎么办?这时,如果你认为前条件的设计是正确合理的,那你唯一的选择就是抛出异常。由此也可以得出一个推论,如果某个被调用函数会抛出异常,可即便如此,你还是应该且能够达成后条件,那显然就应该捕获异常了。

至于异常安全的问题,那是另外一个主题,不在这里展开了。有兴趣的可以参考10年前的旧文《如何编写异常安全的C++代码》 https://icerote.net/blog/post/65

从断言和契约式程序设计出发,我们可以得到一个相当形式化的关于异常的使用方式。这和老旧的书籍中对于异常使用时机的建议是截然不同的。或许你会质疑这种形式化并没有从本质上消除老旧建议中问题分析的结论的不确定性,只是将那种分析转移到了接口设计步骤中去了。确实如此。可是这种转移不正是我们期望的吗?好的软件技术和方法就是要能够让程序员将精力尽量转移到问题分析和设计上去啊。

正经话说完了,下面开喷。

  • 断言就是用来帮助调试的

有道理,脑子是用来头疼的嘛。吃核桃仁的时候用门夹核桃了吧?

  • 断言允许忽略是个好主意,也是必要的

脑子是个好东西,但不是人人都有。死人三天后也能复活的嘛,你说呢?

  • 异常太难用了

当然,断言也一样难用。对没脑子的来说,难度其实和写个函数是一样的。

  • 没人能写异常安全的代码

也没有人能写没有bug的程序,所以也没有人会写程序。

评论

cber, at 2016/8/30 下午8:25

Just googled "What is the difference between function and procedure in oracle pl sql", and "what is the relationship between assertion and invariants". Get confirmation of my suspicious about your definitions for both terms. Just for picky. I agreed most of your opinions.:) Lazy to switch IME

wingfire, at 2016/10/10 上午11:00

我这里的定义确实不太常见,这是蓄意的,因为讨论问题的角度不同。这个定义不是我创造的,在许多程序语言和软件设计相关的书籍中也是这么定义的,但是我印象深刻的是一般叫method和query,少有叫function和procedure的。那些书平时大概也没什么人看,时隔多年,我一时也想不起具体出自哪些书了。 或许PL SQL和的本意也不是为了区别method和query,只是用于区别是否有返回值(很难相信这点,因为语法上允许像procedure一样调用function,并忽略返回值,就可以消除procedure了。C语言就是这么做的)?我没有查一下,有可能是我一厢情愿了。但是,用我提到的划分方法应用于PL sql,仍然是可行的。至于google出来的那些答案,我觉得只是就事论事而已。

关于assertion and invariants,我的理解是讨论代码正确性的时候,一般用invariants,就像教科书上常见的那样。因为这时候不必讨论invariants是怎么被检测的。而实际代码中,为了检测invariants,其检测行为和检测设施,一般用assertion这个词。但是前述的这两种分别,无论是在书本上,还是平时的编码和讨论中都不是严格的,混用是相当普遍的。因此我觉得严加区别并无必要,只在有必要的时候稍加确认,达成一致即可。

添加评论:

 
 the email would not displayed
 

您可以使用 Markdown 语法。

您必须启用浏览器的 JavaScript 功能才能发表评论。