本节书摘来自异步社区出版社《C++面向对象高效编程(第2版)》一书中的第2章,第2.21节,作者: 【美】Kayshav Dattatri,更多章节内容可以访问云栖社区“异步社区”公众号查看。
2.21 确保抽象的可靠性——类不变式和断言
C++面向对象高效编程(第2版)
任何抽象都必须与客户履行它的契约(contract)。当客户使用类时,他希望类的对象像其发布描述的那样运行正常。另一方面,类的实现者必须千方百计地确保对象运行正常。但是,类只有在客户履行自己那部分契约后,才能正确行使它的职责。例如,类的成员函数可能要求传入的参数为非零指针(non-zero pointer)。只有满足此前提条件,成员函数才能保证它的行为。因此,客户必须履行一些义务。换言之,如果客户履行了她那部分契约,对象的实现者必须尊重客户那部分契约,并确保正当的行为。
在类和成员函数的文档中说明契约,这个主意不错。但是,把这些契约条件作为类的一部分实现代码,在执行类实现时检测契约条件,效果更好。这体现了断言(assertion)的巨大价值所在。
通常,断言是一个用于评估真假的表达式。如果表达式评估为假,则断言失败。例如,在TlaserDiscPlayer类中,Play成员函数会包含一个托盘关闭的断言。稍后即将介绍它的语法。
2.21.1 类不变式
进一步研究发现,在每个成员函数中(或甚至在一个成员函数的内部的多处)都包含一个断言可能并不方便。每个类都会在对象中包含一些恒为真的条件,无论对象调用任何成员函数,这些条件都必须为真。这样的条件称为类不变式(class invariant)。顾名思义,在对象中这些条件恒为真。如果我们以某种方式给类添加这些条件,并保证每个成员函数的代码都检查这些条件,将会非常方便。
2.21.2 前置条件和后置条件
除这些类不变式之外,成员函数可能会包含其他条件,在执行代码前必须保证这些条件为真。这些在操作开始被调用之前必须为真的条件,称为前置条件(precondition)。
C++:
在C和C++中,断言已经使用很长一段时间。所有的C和C++编译器都支持assert宏。该宏接受一个表达式,而且必须判断表达式的真假。倘若表达式判断为真,则继续执行;倘若表达式为假,则程序停止,并显示错误消息表明断言失败。消息中包含文件名,违规的语句源代码行号。这是最简单的(且唯一可用的)断言形式。Play成员函数的断言代码如下:
TLaserDiscPlayer::Play(unsigned atChapter)
{
// 这是断言代码
assert(this->_trayStatus == eClosed);
// 略去其余代码
}```
Play成员函数在被调用之前,要求影碟播放机托盘关闭,这个条件表示为断言。
一旦成员函数完成它的操作,将会执行某些条件必须为真的断言。换言之,如果成员函数成功地执行完毕,它将生成一个满足某些条件的结果,这样的条件被称为后置条件(postcondition)。
Eiffel:
在Eiffel中,前置条件和后置条件都是非常流行的概念,而且得到了很好的支持。Eiffel运行期系统检查这些条件,并确保它们为真,否则,停止正在运行的程序。在require子句中,前置条件置于操作的开始。操作的后置条件将在操作末尾的ensure子句中说明。按照这样的方式,每个操作不管在何处被调用,都可以自由地使用任何前提和后置条件。
在进入和退出每个操作(成员函数)时,都必须检查类不变式。为了让实现者便于使用它,我们最好能将所有的不变式都置于类内部的已知区域。类不变式就是在对象的生命期内,必须保证对象状态的语句。例如,Person抽象中可能包含某个不变式,用于保证人的出生日期必须为有效数据,且姓名正确。与此类似,不允许透支的BankAccount抽象中可能包含余额不能小于零的不变式。这些不变式功能强大,它们允许实现者清楚地规定类的某些特性。客户在使用类的对象时,可以认为这些条件为真。
C++和Smalltalk都没有内置支持类不变式,但是Eiffel内置支持类不变式。
Eiffel:
在Eiffel中,类不变式定义在类内部的invariant区,每个操作内部都会检查它们。特别是,在进入和退出每个操作时,会检查类不变式是否为真。这确保可以随时预测类对象的行为。实际上,操作的每个前置条件和后置条件都包含对类不变式的判断。
###2.21.3 使用断言实现不变式和条件
既然C++未直接支持类不变式,我们就必须设法构造一些策略来达到类似的效果。assert宏可以在这派上用场,以下就是一个简单的策略。
简单地定义一对宏:PRE_CONDITION和POST_CONDITION,它们使用assert宏。
define PRE_CONDITION(condition) assert(condition)
define POST_CONDITION(condition) assert(condition)`
如果需要,可以在这些宏中添加消息,当断言失败时打印消息。
#define PRE_CONDITION(message, condition) assert( (message, (condition)))
#define POST_CONDITION(message, condition) assert( (message, (condition)))```
当断言失败,希望打印失败消息时,这会有所帮助。例如,你可以编写以下代码:
`PRE_CONDITION(“Laser disc tray is not closed”, (_trayStatus==eClosed));`
虽然这样的前置条件和后置条件只是简单的断言,但是,它们让程序更可靠、更易读,而且更加易于理解。
然而,添加对类不变式的支持并不容易。记住,在每次进入和退出每个成员函数时,类不变式都必须为真。要保证这一点,需要在一个函数(名为InvariantChecker)中定义一组条件,且在每次进入和退出每个成员函数时,都调用该函数。这样的方法冗长且易出错,因为每个成员函数必须要调用这个函数。如果在类中添加新函数,这样的方法甚至更易出错。但是,却没有其他可行的办法,有总比没有好。如果没有其他办法,至少要在文档中清楚地说明,用户才能明确地知道实现者所保证的契约是什么。很多类的设计者提供非常清楚的类不变式文档,并遵循一些策略确保不变式被强制执行。
注意:
当使用任何一种策略时,都要根据需要添加可关闭或开启策略的支持(一对`#ifdef`)。
###2.21.4 高效使用断言
高效使用断言可实现更可靠的程序。当条件不成立时,断言至少保证程序不会继续执行。但是发生断言失败,就不可能再复原。要解决这个问题,需要用到C++支持的真正的异常管理工具(参见第10章)。但是,理解和使用异常并不容易,它需要适当的架构和高效的设计。断言简单且易于实现,众多程序员已经使用多年。
hand即使你并不打算精心设计程序,但至少要练习使用简单的断言。
不要认为一旦开始使用断言,就不能转用其他更好的处理方式。如果将来决定使用真正的异常管理,只需在代码中找到所有调用assert的地方,然后用适当的throw语句替换即可。