hBifTs

山自高兮水自深!當塵霧消散,唯事實留傳.荣辱不惊, 看庭前花开花落; 去留随意, 望天上云展云舒.

导航

契约和异常处理[转载]

Posted on 2004-05-12 23:54  hbiftsaa  阅读(1390)  评论(1编辑  收藏  举报

契约和异常处理
 
--------------------------------------------------------------------------------
 
下文是CSDN上的一篇文章,谈异常处理和契约思想,无论如何对代码风格是大有裨益的
原文:
什么是契约——Eiffel的观点

假设你现在正在面试,主考不紧不慢地给出下一道题目:“请用C语言写一个类似strcpy的函数。要考虑可能发生的异常情况。” 你会怎么做呢?很明显,对方不是在考察你的编程能力,因为复制字符串实在太容易了。对方是在考察你的编程风格(习惯),或者说,要看看你编码的质量。

下面是多种可能的做法:

void
string_copy1(char* dest, const char* source)
{
assert(dest != NULL); /* 使用断言 */
assert(source != NULL);

while (*source != '\0') {
*dest = *source;
++dest;
++source;
}
}

void
string_copy2(char* dest, const char* source)
{
if (dest != NULL && source != NULL) { /* 对错误消极静默 */
while (*source != '\0') {
*dest = *source;
++dest;
++source;
}
}
}

int
string_copy3(char* dest, const char* source)
{
if (dest != NULL && source != NULL) {
while (*source != '\0') {
*dest = *source;
++dest;
++source;
}
return SUCCESS; /* 返回表示正确的值 */
}
else {
errno = E_INVALIDARG; /* 设定错误号 */
return FAILED; /* 返回表示错误的值 */
}
}

// C++
void
string_copy4(char* dest, const char* source)
{
if (dest == NULL || source == NULL)
throw Invalid_Argument_Error(); /* 抛出异常 */

while (*source != '\0') {
*dest = *source;
++dest;
++source;
}
}

如果你是主考,不知道面对这样四份答卷,你的评分如何?当然,你可以心里揣着一个“标准答案”,“顺我者昌,逆我者亡”。但是如果以认真的态度面对这四份答卷,我想很多人都会难以抉择。

因为这里涉及到了软件开发中的一个带有本质性的难题——错误处理。

历来错误处理一直是软件开发者所面临的最大困难之一。Bjarne Stroustrup在谈到其原因时说道,能够探察错误的一方不知道如何处理错误,知道如何处理错误的一方没有能力探察错误,而直接采用防御性代码来解决,会使得程序的正常结构被打乱,从而带来更多的错误。这种困境是非常难以应对的——费心耗力而未必有回报。因此,更多的人采用鸵鸟战术,对可能发生的错误视而不见,任其自然。

C++、Java和其他语言对错误处理问题的回答是异常机制。这种机制在正常的程序执行流之外开辟了专门的信道,专门用来在不同程序模块之间报告错误,解决上述错误探察与处理策略分散的矛盾。然而,有了异常处理机制后,开发者开始有一种倾向,就是使用异常来处理所有的错误。我曾经就这个问题在comp.lang.c++.moderated上展开讨论,结果是发现有相当多的人,包括Boost开发组里的很多专家,都认为异常是错误处理的通用解决方案。

对此我不能赞同。并且我认为滥用异常比不用异常的危害更大。

The Pragmatic Programmer是一本在国外程序员中间颇为流行的书,其中在讲到错误处理时,有一句箴言:

“只在真正异常的状况下使用异常。”

书中举了一个例子,如果你需要当前目录下的一个名叫“app.dat”的文件,而这个文件不存在,那么这不叫异常状况,这是你应该预料到的、并且显式处理的情况。而如果你要到Windows目录下寻找user.dat文件,却没找到,那才叫做异常状况——因为每一个正常运行的Windows系统都应该有这个文件。

我非常赞成书中的那句忠告,可是究竟什么是“真正异常”的状况?书中的这个例子显然只是一个颇具感性的、寓言似的故事,具有所有寓言的共同特点——读起来觉得豁然开朗,收获很大,实际上帮不了你什么忙。这种例子对于我们的实际开发,仍然提供不了真正的帮助。

究竟应该如何看待错误?怎样才能最好地错误处理?

说实话,在这两个问题上,我们所见到的大部分语言都没有给出很好的回答。C秉承一贯风格,把所有的东西推给开发者考虑;Ada发明了异常,但是又为异常所累(知道阿里亚纳5火箭的处女航为什么失败吗?);C++企图将Ada的异常机制融合进自己的体系中,结果异常成了C++中最难以处理的东西;Java和C#显然都没有耐心重新考虑错误处理这桩事,而只是简单的将C++的异常机制完善化了事。

与上述这些语言不同,Eiffel从一开始就把错误处理放在核心的位置上予以考虑,并以“契约”思想为核心,建立了整个的错误处理思想体系。在我了解的语言里,Eiffel是对这个问题思考最为深刻一个,因此,Eiffel历来享有“高质量系统开发语言”的声誉。(事实上,Bertrand Meyer很不喜欢别人称Eiffel为“编程语言”,他反复强调,Eiffel是一个Software Development Framework。不过本文只涉及语言特性,所以姑且称Eiffel语言。)

Eiffel把软件错误产生的本质归结与“契约”的破坏。Eiffel认为,一个软件能够正常运作,正确完成任务,是需要一系列条件的。这些条件包括客观运行环境良好,操作者操作正确,软件内部功能正确等等。因此,软件的正确运行,是环境、操作者与软件本身三方面合作的结果。相应的,系统的错误,也是由于三者中有一方没有正确履行自己的职责而导致的。细化到软件内部,每个软件都是由若干不同的模块组成的,软件的错误,是由于某些模块没有正确履行自己的职责。要彻底杜绝软件错误,只有分清各自模块的责任,并且建立机制,敦促各模块正确履行自己的责任,然后才有可能做到Bug-free。(鉴于系统中错综复杂的关系,以及开发者认识能力的局限,我认为真正无错误的系统是不可能的。但是当前一般软件系统中的质量问题远远比应有的严重。)

如何保证各方恪守职责呢?Eiffel引入了契约(Contract)这个概念。这里的契约与我们通常所说的商业契约很相似,有以下几个特点:

1. 契约关系的双方是平等的,对整个bussiness的顺利进行负有共同责任,没有哪一方可以只享有权利而不承担义务。
2. 契约关系经常是相互的,权利和义务之间往往是互相捆绑在一起的;
3. 执行契约的义务在我,而核查契约的权力在人;
4. 我的义务保障的是你的利益,而你的义务保障的是我的利益;

将契约关系引入到软件开发领域,尤其是面向对象领域之后,在观念上给我们带来了几大冲击:

1. 一般的观点,在软件体系中,程序库和组件库被类比为server,而使用程序库、组件库的程序被视为client。根据这种C/S关系,我们往往对库程序和组件的质量提出很严苛的要求,强迫它们承担本不应该由它们来承担的责任,而过分纵容client一方,甚至要求库程序去处理明显由于client错误造成的困境。客观上导致程序库和组件库的设计和编写异常困难,而且质量隐患反而更多;同时client一方代码大多松散随意,质量低劣。这种情形,就好像在一个权责不清的企业里,必然会养一批尸位素餐的混混,苦一批任劳任怨,不计得失的老黄牛。引入契约观念之后,这种C/S关系被打破,大家都是平等的,你需要我正确提供服务,那么你必须满足我提出的条件,否则我没有义务“排除万难”地保证完成任务。

2. 一般认为在模块中检查错误状况并且上报,是模块本身的义务。而在契约体制下,对于契约的检查并非义务,实际上是在履行权利。一个义务,一个权利,差别极大。例如上面的代码:
if (dest == NULL) { ... }
这就是义务,其要点在于,一旦条件不满足,我方(义务方)必须负责以合适手法处理这尴尬局面,或者返回错误值,或者抛出异常。而:
assert(dest != NULL);
这是检查契约,履行权利。如果条件不满足,那么错误在对方而不在我,我可以立刻“撕毁合同”,罢工了事,无需做任何多余动作。这无疑可以大大简化程序库和组件库的开发。

3. 契约所核查的,是“为保证正确性所必须满足的条件”,因此,当契约被破坏时,只表明一件事:软件系统中有bug。其意义是说,某些条件在到达我这里时,必须已经确保为“真”。谁来确保?应该是系统中的其他模块在先期确保。如果在我这里发现契约没有被遵守,那么表明系统中其他模块没有正确履行自己的义务。就拿上面提到的“打开文件”的例子来说,如果有一个模块需要一个FILE*,而在契约检查中发现该指针为NULL,则意味着有一个模块没有履行其义务,即“检查文件是否存在,确保文件以正确模式打开,并且保证指针的正确性”。因此,当契约检查失败时,我们首先要知道这意味着程序员错误,而且要做的不是纠正契约核查方,而是纠正契约提供方。换句话说,当你发现:
assert(dest != NULL);
报错时,你要做的不是去修改你的string_copy函数,而是要让任何代码在调用string_copy时确保dest指针不为空。


4. 我们以往对待“过程”或“函数”的理解是:完成某个计算任务的过程,这一看法只强调了其目标,没有强调其条件。在这种理解下,我们对于exception的理解非常模糊和宽泛:只要是无法完成这个计算过程,均可被视为异常,也不管是我自己的原因,还是其他人的原因(典型的权责不清)。正是因为这种模糊和宽泛,“究竟什么时候应该抛出异常”成为没有人能回答的问题。而引入契约之后,“过程”和“函数”被定义为:完成契约的过程。基于契约的相互性,如果这个契约的失败是因为其他模块未能履行契约,本过程只需报告,无需以任何其他方式做出反应。而真正的异常状况是“对方完全满足了契约,而我依然未能如约完成任务”的情形。这样以来,我们就给“异常”下了一个清晰、可行的定义。

5. 一般来说,在面向对象技术中,我们认为“接口”是唯一重要的东西,接口定义了组件,接口确定了系统,接口是面向对象中我们唯一需要关心的东西,接口不仅是必要的,而且是充分的。然而,契约观念提醒我们,仅仅有接口还不充分,仅仅通过接口还不足以传达足够的信息,为了正确使用接口,必须考虑契约。只有考虑契约,才可能实现面向对象的目标:可靠性、可扩展性和可复用性。反过来,“没有契约的复用根本就是瞎胡闹。(Bertrand Meyer语)”。

由上述观点可以看出,虽然Eiffel所倡导的Design By Contract在表象上不过是系统化的断言(assertion)机制,然而在背后,确实是完全的思想革新。正如Ivar Jacoboson访华时对《程序员》杂志所说:“我认为Bertrand Meyer的方向——Design by Contract——是正确的方向,我们都会沿着他的足迹前进。我相信,大型厂商(微软、IBM,当然还有Rational)都不会对Bertrand Meyer的成就坐视不理。所有这些厂商都会在这个方向上有所行动。”


 
--------------------------------------------------------------------------------
 回复: 契约和异常处理
 
契约思想有其局限性和理想化的成分,其中之一是测试的不充分性,由于测试案例不能穷尽,所以无法保证在提交给用户版本之前契约已被充分保证,所以在一些高可用性的应用里如何保证系统在发生异常(违反契约)的情况下能继续提供服务是程序员的任务。

但是有限度的使用契约思想,确实可以很大提高开发效率,现实中不少项目也是这样做的。


 
--------------------------------------------------------------------------------
同感
 
凡事极端了都不好
老实讲在读到这篇文章之前还不知道契约为何物,只是一些朴素的异常和错误处理,及在类中总定义一些CHECK(),CHECK***()之类的函数,而且定义统一的错误代码由这些函数返回,不仅程序的健壮性好了,而且调试时也容易BUG定位


 
--------------------------------------------------------------------------------
不懂Eiffel
 
不过assert应该起到的就是所谓检查契约的功能。而assert是c/c++标准库里的宏,使用断言也早就是对错误情况的标准对应之一了。
另一个问题是异常处理的代价问题,就这个问题而言,除了source==dest==0的问题外,还有指向不合法地址(内存没有分配,或者指向一个合法内存地址,不过是错误的,可能影响了别的程序段,也可能正常通过),dest没有足够的长度等等,这些只是最常见的问题可能,任何一个程序员都还能举出更多的例子。这些错误有的能先检查,还有很多只有运行时才知道。即便使用strncpy也不能保证dest真有足够的长度,从来就是调用者必须确保的。如果要确保高可用性,也只有try/catch了。
要说契约,其实任何函数说明里都包含了对参数的要求。追求可以防止任何意外错误的程序段可以很轻易就让95%以上的程序都是错误处理代码,程序质量/开发规模代价/性能/可维护性/清晰性从来就是一个trade-off。至少我从来不认为如果有人定义一个char *dest;然后直接strcpy(dest,"test")会算一个合法调用,虽然编译没有问题,也的确有相当的可能性会正常运行。这个算不算契约的一种呢。所以我个人不认为契约思想如此伟大。任何一个成功完成的项目只要有函数调用关系,都是自觉或不自觉应用了契约思想。
当然,如果要说异常处理的理想对应方法,那的确是一个很值得讨论的大问题。我也有很多困扰,throw不应该滥用是无疑的,但到什么程度才是分界则很头疼。望有识者教我


 
--------------------------------------------------------------------------------
回复: 强烈赞同“为契约设计”的思想
 
我们如果上升到业务建模阶段,人和自动化系统担任同样的业务角色,既然人可以用契约来约束,为什么自动化系统不可以?
本来软件中的很多概念就来自社会学中。如服务、代理、委托等。


 
--------------------------------------------------------------------------------
Apache Avalon是一套按契约设计的java类实例
 
它强调了关于组件生命周期中,一些回调接口的契约。有点中文文档:
http://www.freecoder.org/%7Eseal/avalon/introduction.html

对于契约我的观点是:合同是要定的,但定得不能太复杂。关键还在于合同双方或多方合作完成共同的目标。

在“代码大全Code Complete”一书中反映的一个观点是定下了契约,程序员的责任就只是按契约办事。如果契约规定不允许传进来null指针,那函数就没义务处理null指针。我认为这种做法是不对的。可以说是明确界定责任,也可以说是不负责任。

实际上契约签定的双方在定下契约之后,在执行过程中还是要相互检查和监督对方是否按契约办事的。To err, human。但好象从安然事件来看,不诚信也是很human的事了。

一方面我们要定下参数有效性/组件生存期的契约,另一方面还要有机制来检查契约有没有被违反。


 
--------------------------------------------------------------------------------
回复: 不懂Eiffel
 
其实我认为大家都被原文误导了,契约思想应该并不就是assert,原文只是举了一个assert的例子,这是一种代价最小的检查契约的方法,可以看作是一种鸵鸟算法,因为它假设程序发布时所有契约已被完全检查[1],和鸵鸟一样,鸵鸟在遇到危险的时候总是把头埋在沙子里装做危险不存在。

实际上契约思想的实质特征只有两点:(1)你必须忠实履行契约,否则我不服务(请注意,不是只服务10%或20%,而是100%不服务,我这句说明看似废话,但有多少程序员在编程的时候能够牢记这一点呢?)(2)如果你履行了契约,那么我必然忠实的提供服务(既不多,也不少)。而表现形式为服务开始前集中检查契约,服务结束后集中检查结果。契约在本质思想上与LSP是一致的,只不过用于不同的设计范畴。

原文作者似乎也不是完全理解契约的想法,所以才认为契约检查就是ASSERT,这是不对的(也许是我理解不对,请指正),契约检查可以很复杂,也可以很简单,简单如ASSERT, 这由设计者决定。

我认为它的局限性在于第一条:不履约则不服务。这是其降低错误处理成本的手段,简单而有效,但却导致即使不用assert而代替以复杂处理,假设[1]在很多情况中也不能被完全避免。尤其在高可用性领域,更是直接与容错运行的要求相抵触。

不过虽然我主要是做HA应用系统,但还是比较喜欢契约的思想,尤其是第二条:忠实服务。也一直思考这方面的问题,不过以前不知道它有个名字叫契约。呵呵。

关于THROW,我也曾经思考过,我觉得做的时候遵循一些原则会好些,
(1)尽量不要THROW到本函数体外部,如果必须,则一定要仔细检查。
(2)尽量捕获本函数体同层发生的所有异常。
(3)注意函数语义中的原子性条件,需要的时候记住做ROLLBACK。
(4)不要TRY太长的模块,尽量做分解。

一管之见,请指正。

--smilemac


 
--------------------------------------------------------------------------------
但究竟什么算是契约呢?对参数说明/要求等算不算契约呢
 
比如要File I/O必须先要有一个合法的文件句柄,这在任何函数说明里都会有相关资料,无非原来说的可能是被调用者使用传进来的句柄完成任务,现在说成是如果调用者不提供有效的句柄,被调用者就拒绝完成任务。就我个人看不出除了说法不同在实际上还有什么区别点。
说到底,assert不过是用来测试是否断言都被满足了,主要用在debug,危险就在楼下sealw说的测试可能(几乎必然)不完善,除了对开发员有参考作用就没有太多价值了。
至于您说的第一点,的确是有充足的理由,可是如果像sealw说的连是否null都拒绝测试了,似乎有些太过推卸责任了,但是去检查一堆在说明里已经明确的要求也实在代价太大。就以原文的strcpy例子言,至少包括了以下契约:source/dest是内存地址;内存地址是已经被分配空间,可以合法使用的;source是以null结束的字符串;dest的可用内存空间至少和source等长度+1;dest的内存空间不和别的变量重叠等等,这些契约有的可以检查,有的只能依靠调用者确保。实际的工作代码一行就够了,检查契约需要多少?这个还只是不和别的函数交互,没有执行顺序要求的最简单代码例子。系统再大一点,复杂度会增加多少?
您对第一条的局限性看法我很赞同,契约不能满足的原因可能来自于系统限制,可能是程序员错误调用,可能是用户提供了非法输入。也许是可尝试恢复修正的,也许是不能恢复的。即便不考虑那些需要高可用性的系统,从用户友好角度也不会有用户喜欢系统对任何一个错误都会立即崩溃的想法。
问题在于,在函数里,尤其是被多个功能里都调用的公用函数来说,即便捕捉到相同的错误,也未必对应方法一致,可能有时需要出错误通知;有时只要登记在log,然后切断目前连接/子功能;有时用缺省处理;有时就直接关闭程序;有时再次尝试。我觉得这里的难度其实不在于某个函数体如何处理,而是在整个系统内部如何保持一个完整统一的错误处理策略。我还是觉得throw是一个在成本约束下合理的解决方法,但需要总体周到的设计来支持。
至于开发时的资源平衡,则是另一个问题了,tacone兄认为要执法当然没错,但执法也需要法律成本,从文书准备到执行。这是即便在欧美,仲裁之类的简单处理方法依然能大行其道的原因,尤其是对标的不大的商业案子。理论上要求尽善尽美,实际上能否做到是另一回事,虽然这是我们良好的意愿,但是在现实中讨论在合理的代价下能够做到怎样的程度也许更有实际意义。
一点个人看法,望指正


 
--------------------------------------------------------------------------------
这些都是契约
 
不错,这些都是契约,您也可以说有计算机以来编程就一直是契约编程。但此契约非彼契约,一个概念存在于我们的思想行为潜意识中与把这个概念拿出单独表述研究我想不是一回事。就如同有人非要说OO在一百年前的大英百科全书或两千年前中国的道德经里已有完整的表述,这样的说法是没有意义的,抽象的表述提供思想的空间,而具象的表述提供行为的空间。契约编程的侧重点不在于定义什么是契约,而在于如何处理契约以及如何理解“违反契约”。

如何处理契约,契约编程认为重点在于“忠实”,不忠实则无义务,服务双方彼此都要忠实。而此观点是建立在对“违反契约”的理解上:正确的编程应该是契约被忠实履行,如果违反契约,则说明程序中一定存在bug,需要解决。(这是其理想化的方面)。在此理论基础上(我们先假设这种对“违反契约”的理解是对的),那么契约编程也是可以理解的,因为它实质上在很大程度是一种消除bug的手段。如果程序中的bug是必须被解决的,那么不外乎有如下方法:(1)测试.(2)程序内部检查+测试。(3)容错。可以看出,平均来讲,成本最低的要数第二种,契约编程即是此种,而且因为在不同位置检查效果成本也都不一样,而契约编程所作检查的位置在大多数情况下应该是最理想的。

现在再回过头看看前提条件:正确的编程应该是契约被忠实履行。这样的前提理论上没有错误,但现实中很难执行。但如果我们不强求程序发布时一定要解决所有bug,而是稳定性达到一定程度即可(这是现实的,因为95%的windows程序都是如此),那么契约编程作为一种形式化编程的方式就很有价值了。

至于您说的顺序执行或复杂系统,我不认为有什么区别。

管窥之见,请指正。

--smilemac


 
--------------------------------------------------------------------------------
以前没有听说过契约编程,所以不清楚。您是不是说契约编程其实就是代码级的详细文档化和据此文档具体编程?
 
前提条件:正确的编程应该是契约被忠实履行。我也认为很正确。当然,传统的说法是正确调用的前提下,完成一定的功能。
如果违反契约,现在叫做拒绝服务,以前叫做不保证能正常执行。
对于违反契约是说明程序中一定存在bug,需要解决,也不会有任何疑问。

我唯一能想到的区别就是,以前可能偏重注意在函数级别的错误处理,现在则是从系统总体出发考虑对应措施,对系统内部的关系投入了更多的精力。而且文档化的过程可能使一些以前会没有注意到的错误也暴露出来,从而提高了系统质量。

至于我说的复杂系统,只是一个代价上的考虑,就好像白盒测试的代价。假设一个函数有5个契约/系统有10个互相关联的函数,那么有多少个组合关系?如果100个函数呢。用严格的契约原则可以降低原有的判断合法性时的复杂度,但对结果也简化了原有的错误定位处理。至于容错性更是被降低了。
个人觉得契约编程其实就是将原来那些隐含条件用文档固定下来,当然是个比较好的开发习惯,至于有没有伟大到什么程度,好像还不能提到这样的程度。
前几天javalove还在说思维的惯性,无疑我也是其中一员,希望能有有识者扭转我的错误看法。
个人看法,望指正。


 
--------------------------------------------------------------------------------
契约编程不是文档+编程
 
我的看法也不一定正确,实际上我也是首次看到此名词,不过其内容我在以前为程序的质量和开发效率问题所困扰时(这个问题现在也在困扰我)曾经往这个方向思考过,也在实践中作了些探索,得到的经验与该方法很相似,所以看到这个编程哲学比较容易产生共鸣。我想说的是我的看法和理解可能也比较片面,所以想借讨论契约编程的机会写出我对契约的理解以求证于方家。


首先,我认为“正确的编程应该是契约被忠实履行”与“正确调用的前提下,完成一定的功能”并不完全等价。前者强调的是忠实,即我不会少作,但我也决不会多做。
另外,“拒绝服务”与“不保证能正常执行”也不等价,考虑如下代码:
void f1(char* p)
{
if (null == p)
return;

do somethong;
do something with p;
}

void f2(char* p)
{
do something;

[maybe]
if (null == p)
return;

do something with p;
}

如果f1给100分的话,f2只能给60分。前者是拒绝服务,后者是不保证能正常执行。

契约编程不是文档+编程,但可以看作一种形式化编程方法.但与文档没什么独特关系.其形式为
F()
{
precondition;

do the service job;

postcondition;
}

我想我们应该没有人说过它是一种伟大的哲学,但我认为它有独特的特点成为一种编程方式.其实有多少编程思想是独创的呢? 我以前在嘉宾聊天时问kent beck说我认为xp与evolution没什么区别,他说evolution只有一些princples,而他的xp有系统的方法,呵呵。

至于您说的函数之间有组合爆炸的问题,我认为和契约编程没有关系,契约编程讲的是只要调用者遵守函数的调用约束,则服务会被满足,如果契约与调用者的身份相关,那我认为需要改进的不是编程方法,而是设计。


for you reference,

--smilemac