作者: Sam (甄峰)
sam_code@hotmail.com
0. 基础知识:
assert()用来检查不可能发生的行为, 以确保开发者在调试阶段尽早发现不可能发生的事件是否真的发生了。
若真的发生了,则表明这里逻辑有问题。
assert()最好的地方是,它只在debug模式下其效果,在Release模式下无效。保证了程序效率。
assert() 协助开发者发现程序bug.
当参数为false时,assert() 输出错误信息到标准错误. 并且调用abort(3)
终止程序。
错误信息包括调用者的文件名和函数名,程序行号。
如果宏 NDEBUG 被defined 在include 头文件
assert.h之前,assert()不做任何动作。
#define NDEBUG
#include <<span style="margin: 0px; padding: 0px; font-family: "Courier New" !important;">assert.h>
assert()使用总结:
1. 在函数开始阶段,检验传入参数的合法性。
例如:
MsgCpy(msg* dst, msg* src)
{
assert(dst != NULL);
assert(src != NULL);
.......
}
2. 每个assert()仅仅检验一个条件。否则出错了不知道是哪个条件出错。
3. 不能在assert()中对变量做出改变。因为assert()仅在Debug模式下起作用。若改变了变量,则Release和Debug模式下会有不同。
例如:
assert(i++ < 1024)
则在Debug下,i++被执行了。 Release模式下,i++没被执行。
4. assert()应自成一行,上下有空行。
5. assert()与条件过滤各自有自己的适用点。不能混淆。
2. assert()与条件过滤的思考:
Sam在之前的开发中,对一个问题一直有疑虑:应该在函数或模块内部检测环境和参数是否正常,还是在函数或模块的调用之前由调用者来检测呢?
后来有同事建议:为了稳妥起见,所有的检测和验证,都在函数或模块内部实现。即:不管用户怎样无理的使用此函数和模块,都应该能够正常工作。
但在实际工作中,这个方法有以下几个缺陷:
A. 运行环境和参数的检测耗费了大量的精力。
B. 这部分代码也占用了不少资源。
如果责任划分不明确,则我们或者需要设置太多的检查——每一方都要检查,或者需要太少的检查——每一方都期望对方进行检查。检查太多是很糟糕的,因为它会造成大量重复的代码,增加系统的复杂程度。所以需要明确哪一方应该进行检查。
后来看到契约式设计文档中的说法,感觉很贴切:
如果把程序库和组件库被类比为server,而使用程序库、组件库的程序被视为client。根据这种C/S关系,我们往往对库程序和组件的质量提出很严苛的要求,强迫它们承担本不应该由它们来承担的责任。这就造成程序库或者每个函数做了大量检验工作。以适应调用者。
这就造成Server提供者负责了太多任务。而client的开发者又随便使用,导致代码质量下降。
引入契约观念之后,这种C/S关系被打破,大家都是平等的,你需要我正确提供服务,那么你必须满足我提出的条件,否则我没有义务“排除万难”地保证完成任务。
模块中检查错误状况并且上报,是模块本身的义务。而在契约体制下,对于契约的检查并非义务,实际上是在履行权利。一个义务,一个权利,差别极大。例:
if (dest == NULL) { ... }
这就是义务,其要点在于,一旦条件不满足,我方(义务方)必须负责以合适手法处理这尴尬局面,或者返回错误值,或者抛出异常。而:
assert(dest !=
NULL);
检查契约,履行权利。如果条件不满足,那么错误在对方而不在我。
确保必须条件:
契约所核查的,是“为保证正确性所必须满足的条件”,因此,当契约被破坏时,只表明一件事:软件系统中有bug。其意义是说,某些条件在到达我这里时,必须已经确保为“真”。
assert(dest !=
NULL);
报错时,你要做的不是去修改你的string_copy函数,而是要让任何代码在调用string_copy时确保dest指针不为空
“函数”和“过程”的理解:
我们以往对待“过程”或“函数”的理解是:完成某个计算任务的过程,这一看法只强调了其目标,没有强调其条件
。引入契约之后,“过程”和“函数”被定义为:完成契约的过程。基于契约的相互性,如果这个契约的失败是因为其他模块未能履行契约
,本过程只需报告,无需以任何其他方式做出反应。而真正的异常状况是“对方完全满足了契约,而我依然未能如约完成任务”的情形。这样以来,我们就给“异常”下了一个清晰、可行的定义。
3. 契约式设计:
assert()是用来避免显而易见的错误的,而并非处理异常的。
所谓错误,是不应该出现的。 而异常,则是不可避免的。如上面第五条所说:
assert()处理错误。if() 条件过滤处理异常。
比如:
文件打开不成功,是(如文件不存在,权限不足)异常。 可以用条件变量处理。
而传递给函数的参数为文件描述符,这个文件描述符为NULL。则为错误。
具体什么时候使用assert(), 什么时候使用条件过滤。这就引出了一个概念: 契约式设计(契约式编程)。
契约式编程/契约式设计(Design
by Contract(DbC)/Programming by
Contract)是一种设计计算机软件的方法。这种方法描述了,软件设计者应该为软件组件定义正式的,准确的,可验证的接口规范。
它扩展了先验条件(Preconditions),后验条件(Postcondition),不变性(invariants)的一般定义。
DbC 的核心思想,用一个比喻就是:基于相互的“义务”与“权利”,一个软件系统的要素之间如何互相合作。这个比喻来自于商业活动,“客户”与“供应商”达成的“合约”而来。例如:
- 供应商必须提供某种产品(这是供应商的义务),并且有权期望客户付费(这是供应商的权利)。
- 客户必须支付费用(这是客户的义务),并且有权得到产品(这是客户的权利)。
- 双方必须满足应用于合约的某些义务,如法律和规定。
同样地,如果在面向对象程序设计中的一个类的例程(方法)提供某些功能,那么,它要:
- 期望所有调用它的客户模块都保证某个进入的条件:这就是例程(方法)的先验条件—客户的义务和供应商的权利(方法本身),这样,它就不用去处理不满足先验条件的情况。
- 保证退出时给出某个属性:这就是例程(方法)的后验条件——供应商的义务,也是客户的权利(调用方法的主要权利
- 进入时假设,退出时保证,维护某个属性:类的不变性。
契约(contract )就是这种义务和权利的形式化。可以用“三个问题”来概括 DbC,这也是软件设计者必须反复问的:
- 期望什么?
- 保证什么?
- 维护什么?
很多语言都使用这样的断言。然而,DbC 认为契约对于软件的正确性至关重要,它们应当是设计过程的一部分。实际上,DbC 提倡首先写断言。
契约的概念可以扩展到方法/过程。每个方法的契约通常包含如下信息
- 可接受和不可接受的输入的值或类型,以及它们的含义
- 返回的值或类型,以及它们的含义
- 错误和异常的值或类型,以及它们的含义
- 副作用
- 先验条件
- 后验条件
- 不变性
- (不太常见)性能保证,例如所需的时间和空间
3. 前置条件和后置条件。
所谓“前提条件”,是指在执行操作之前,期望具备的环境。对“求平方根”操作来说,前提条件可以定义为input
>= 0。根据该前提条件,对某个负数执行“求平方根”操作本身就是错误的,而且结果无可预料。
所谓“后继条件”,是指操作执行完之后的情况。举例来说,如果我们定义对某个数的“求平方根”操作,则该操作的后继条件为:input
= result * result,这里的result是输出结果,而input是输入的数值。在描述“做什么”(what we
do)而不涉及“怎样做”(how we do
it)——换言之,将接口和实现分离开来——时,后继条件是非常有用的方法。
例子:
自己实现memcpy()
void* memcpy(void* dst, void* src, size_t
count)
{
assert(dst !=NULL);
assert(src !=NULL);\
assert(count > 0);
assert((dst>src) && (src+count < dst));
assert((dst < src) && (dst+count < src));
}
所以,Sam准备未来尝试如此使用assert():
函数内部使用先验assert()来验证参数。
函数内部使用后验assert()验证结果。
但是否去掉if()。则视条件而定。
附录1:
Android NativeC代码中使用assert():
当在Application.mk中 使用APP_OPTIM := release
时。
ndk-build 会自动加上 -DNDEBUG.
此时,assert()不起作用。
当 APP_OPTIM := debug时。 不包含 -DNDEBUG. 所以,assert()就有效了。