《Imperfect C++中文版》——1.3 运行期契约:前置条件、后置条件和不变式

简介:

本节书摘来自异步社区出版社《Imperfect C++中文版》一书中的第1章,第1.3节,作者: 【美】Matthew Wilson,更多章节内容可以访问云栖社区“异步社区”公众号查看。

1.3 运行期契约:前置条件、后置条件和不变式

Imperfect C++中文版
“如果例程的所有前置条件(precondition)已经被调用者满足了,那么该例程必须确保当它完成时所有后置条件(postconditions)(以及任何不变式)皆为真。”——Hunt and Thomas, The Pragmatic Programmers [Hunt2000]。

如果我们无法执行编译期强制,那么还可以采用运行期强制。运行期强制的一个系统化的实现途径是指定函数契约。函数契约精确定义了在函数被调用之前调用者必须满足哪些条件(前置条件),以及在函数返回之时哪些条件(后置条件)是调用者可以期望的。契约的定义以及它们的强制实施是DbC(Design by Contract,契约式设计)[Meye1997]的基石。

前置条件是指函数履行其契约所必须满足的条件。满足前置条件是调用者的责任,而被调用者则假定它的前置条件已经被满足,并且仅当它的前置条件被满足时才负责提供正确的行为。这一点非常重要,在[Meye 1997]中被强调指出。倘若调用者没有满足前置条件,则被调用者做出任何事情都是完全合理的。事实上,通常这会引发一个断言(见1.4节),进而可能导致程序终止。这听起来似乎颇令人恐慌,刚接触DbC的程序员通常会对此感到很不舒服,直到你问起他们:如果一个函数的(前置)条件都不能被满足,那还能指望它有什么样的行为时,他们才哑口无言。事实上,契约越严格,违反它所导致的后果越严重,从而软件的质量就会越好。当转到DbC上时,要理解这一点是最为困难的。

后置条件在函数执行完毕时必须为真。确保后置条件被满足是被调用者的责任。当函数返回控制时调用者可以假定后置条件已经得到了满足。在现实中,有些时候有所保留(不要把赌注全部押在被调用者身上)还是必要的,例如,当调用应用服务器中的第三方插件时就是如此。然而,我认为前面所讲的原则仍然是对的。事实上,对违反契约的插件的合理反应之一是将它卸载掉,并给公司经理以及第三方插件厂商发一封电子邮件。既然我们对于违反契约的行为可以作出任何反应,那么有什么理由不这么做呢?

前置条件和后置条件可以被应用到类的成员函数,也可以被用到自由函数身上,这对于C++(更一般地说,面向对象编程)来说很有益处。事实上,还有另外一个与DbC相关的东西,它只能依附于类而存在,那就是类不变式(class invariant)。类不变式是指一个或一组条件式,它们对于一个处于良好定义状态的对象总是为真。根据定义,类的构造函数负责确保类的实例进入一个符合该类的不变式的状态中,而类的(public)成员函数则在它们完成之际确保类的实例仍然处在该状态中。仅当处于构造函数、析构函数或其他某个成员函数的执行过程中时,类不变式才不一定要为真。

在某些场合下,将不变式的作用范围定义为比“单个对象的状态”的范围更广可能更合适一些。原则上,不变式可以被应用到操作环境的整个状态上,然而,在实践中,这种情况是极其少见的,类不变式则很常见。因此,在本章以及本书剩余的篇幅中,如果提到不变式,均是指类不变式。

对部分或根本没有进行封装的类型提供不变式是可行的(见3.2节和4.4.1小节),这个不变式是由与该类型相关的API函数(以及该函数的前置条件)来强制实施的。事实上,当使用这种类型时,不变式是极好的主意,因为它们缺乏封装性的特质提高了滥用的风险。不过这种不变式相当容易被“绕过”,这也说明了为什么通常应该避免使用这种类型。事实上,[Stro2003]中某种程度上提到:如果存在一个不变式,则公有数据简直毫无意义。封装既是关于隐藏实现又是关于保护不变式的。至于“属性”(第35章),可能是为了结构上的一致性(见20.9节)而引入的,只不过为我们提供公有成员变量的表象而已,它仍然具有不变式。

对于违反前置条件、后置条件或者不变式,你所采取的行动完全由你来决定。你可以把信息记录到日志文件中,也可以抛出异常,或者给你家人发一封SMS,告诉她今夜你将debug到很晚。不过,通常我们采取的行动是引发一个断言。

1.3.1 前置条件

在C++中,前置条件测试相当简单。在这本书中我们已经看到了好几个例子。它和使用断言一样简单:

template< . . . >
typename pod_vector<. . .>::reference pod_vector<. . .>::front()
{
  MESSAGE_ASSERT("Vector is empty!", 0 != size());
  assert(is_valid());
  return m_buffer.data()[0];
}

1.3.2 后置条件

这是C++容易产生磕磕碰碰的地方。这里的挑战是在函数的退出点捕获返回值和“输出”参数。1当然了,C++提供了特别有用的RAII(Resource Acquisition Is Initialization,资源获取即初始化)机制(见3.5节),该机制保证当执行流程退出某个作用域时栈上对象的析构函数都会得到调用。这就意味着我们可能借助这一点实现一个可行方案,至少该机制具备这个潜力。

我们的选择之一是声明监视器对象,它持有对输出参数和返回值的引用。

int f(char const *name, Value **ppVal, size_t *pLen)
{
  int                 retVal;
  retval_monitor    rvm(retVal, . . . policy . . . );
  outparam_monitor  opm1(ppVal, . . . policy . . . );
  outparam_monitor  opm2(pLen, . . . policy . . . );
  . . . // 函数体
  return retVal;
}

一些策略会被用来检查变量是否为NULL,或者是否位于一个特定的区间内,或者是一组数值中的一个,等等。尽管实现这些东西都有困难,这里仍然存在两个问题。第一,rvm的析构函数会对它所持有的指向函数返回值变量retVal的引用来施行约束。如果函数的其他任何部分返回了一个不同的值(或一个常量),那么rvm无可避免地会报告一次失败。为了能够正确工作,我们不得不强制让所有函数都通过单个变量来返回,这肯定不符合一些人的口味,在某些场合下也是不可能的。

然而,最主要的问题还在于各个后置条件监视器之间是没有关联的。大多数函数的后置条件是复合型的,个体输出参数和返回值仅当符合某种一致的关系时才有意义,例如:

assert(retVal > 0 || (NULL == *ppVal && 0 == *pLen));

我不打算建议你如何将这3个个体监视器对象以这样的方式结合起来,以便强制实施各种各样的后置条件状态,这类事情对于模板元编程爱好者可能是一个令人激动的挑战,不过对于其他人,它所带来的复杂性不值得我们付出代价。

Imperfection: C++对后置条件未提供合适的支持。
在我看来,惟一合理的(虽然看起来很平凡)解决方案是,通过一个转发函数将(待调用)函数和对它的(后置条件)检查分离开来,就像在程序清单1.5中展示的那样:

程序清单1.5

int f(char const *name, Value **ppVal, size_t *pLen)
{
  . . . // 进行f()的前置条件检查
  int retVal = f_unchecked(name, ppVal, pLen);
  . . . // 进行f()的后置条件检查
  return retVal;
}
int f_unchecked(char const *name, Value **ppVal, size_t *pLen)
{
  . . . // f的语义
}

在实际代码中,你可能希望在不需要执行DbC的地方省略掉所有的检查,为此我们需要使用预处理器:

程序清单1.6

int f(char const *name, Value **ppVal, size_t *pLen)
#ifdef ACMELIB_DBC
{
  . . . // 进行f()的前置条件检查
  int retVal = f_unchecked(name, ppVal, pLen);
  . . . // 进行f()的后置条件检查
  return retVal;
}
int f_unchecked(char const *name, Value **ppVal, size_t *pLen)
#endif /* ACMELIB_DBC */
{
  . . . // f的语义
}

这完全算不上优雅,不过它可以工作,并可以很容易地合并到代码生成器中。当处理被重写的(overridden)类成员函数时,问题可能要稍微复杂一点,因为你要面对是否实施父类的前置条件和后置条件的问题。这得条分缕析后才能决定,已经超出了我们的讨论范围。2

1.3.3 类不变式

在C++中,实现类不变式几乎和实现前置条件一样简单。我个人的做法是为类定义一个名为is_valid()的方法,像这样:

template<. . . >
inline bool pod_vector<. . .>::is_valid() const
{
  if(m_buffer.size() < m_cItems)
  {
    return false;
  }
  . . . // 这里进行进一步的检查
  return true;
}

然后,该类的每个公有方法都把它放在断言里进行调用,在进入方法时断言一次,退出方法前再来一次。我喜欢在紧接着前置条件检查之后进行类不变式的检查(见1.3.1小节):

template< . . . >
inline void pod_vector<. . .>::clear()
{
  assert(is_valid());
  m_buffer.resize(0);
  m_cItems = 0;
  assert(is_valid());
}

作为一种替代策略,我们可以将断言放在不变式函数自身之中。然而,除非你手头拥有的是一个“久经考验”的断言(见1.4节),否则这会令你不得不选择提供关于“肇事”的条件或方法的断言信息(文件+行+消息)。我倾向于后者,因为违反不变式毕竟是非常少见的情况。不过,你可能会选择前者,如果是那样的话,你可能希望将断言放到is_valid()成员函数中。

事实上,对此存在一个合理的折中方案,我通常在具有良好的日志/跟踪界面的环境中使用这种策略(见21.2节),具体做法是在is_valid()成员函数中记录违反不变式的细节,并且让“肇事”成员函数3来触发该断言。

与输出参数和返回值检查不同,使用RAII(见3.5节)来使类不变式的检查自动化还是相当容易的(这种检查也作为方法退出前的后置条件验证的一部分),像这样:

template< . . . >
inline void pod_vector<. . .>::clear()
{
  check_invariant<class_type> check(this);
  m_buffer.resize(0);
  m_cItems = 0;
}

缺点是,强制会在check_invariant模板实例的构造函数和析构函数中被实施,这意味着使用预处理器来获悉  FILE 和 LINE 信息的简单的断言可能会给出误导信息。然而,要想实现一个可以正确显示断言失败位置的“宏+模板”的断言形式并不算是很大的挑战,甚至可以结合运用非标准的 FUNCTION 预处理符号(当然,对于那些支持它的编译器而言)。

1.3.4 检查?总是进行

在[Stro2003]中,Bjarne Stroustrup做了一个非常重要的观察:不变式只对那些具有方法的类才是必要的,而对于仅仅作为变量聚合体的简单结构而言是没有必要的(例如,我们将会在4.4.2小节看到的Patron类型就不需要不变式)。在我看来,这话还可以这么说:任何具有方法的类都应该具有类不变式。不过,在实践中对此有一个下限。如果你的类持有一个指向某些资源的指针,那么,它要么是NULL,要么不是NULL。除非你的类不变式方法可以使用非空指针所指向的有效的外部资源,否则你的类不变式将无事可干。在这种情况下,是否使用一个“存根(stub)”类不变式取决于你自己,或者你也可以干脆什么都不干。但如果你的类将来会不断升级,那么在里面放上一块有待以后扩充的“存根”方法可以令后续的精化工作变得容易一些。如果你使用了某种代码生成器的话,我建议你总是用它来生成类不变式,并生成对所生成的类不变式的调用。

类不变式较之散落在类实现周围的一堆断言而言,好处是非常明显的。类不变式使你的代码更容易阅读,并且在不同的类的实现之间具有一致的外观,以及具有更好的可维护性,这是因为对于每个类你都把类不变式定义在了某个单一的地方。

1.3.5 DbC还是不DbC

到目前为止,我所描绘的关于运行期契约的蓝图其实隐含了一个假定,那就是:在进行适当的测试后,人们会对他们的系统进行一次构建(build),4在这次构建中,DbC元素都被预处理器消去。5

事实上,关于“是否任何构建(build)都应该不实施DbC”这个问题[Same2003],仍然颇有争议。一个论据是(借用[Same2003]里的逻辑)DbC里的契约实施就好比电力系统中的保险丝,任何人都不应该在部署一个成熟的电力设备之前把它里面的所有保险丝都拔掉。

断言和保险丝之间的区别在于前者涉及运行期测试,而测试的代价明显不为零。尽管保险丝中的合金成分的电阻可能与它所在系统中的其他部分的电阻略有差别,然而这跟断言引入的代价相比仍然无法相提并论。我的看法是,这需要仔细分析才能求得一个良好的平衡。这就是为什么本节的例子代码中包含了ACMELIB_DB这个符号的缘故。我没有使用NDEBUG(或者_DEBUG),因为DbC的使用不应该直接和“调试版/发行版(debug/release)”的二进制概念耦合起来。究竟何时使用它,何时消除它,取决于你自己。6

1.3.6 运行期契约:尾声

尽管我们已经看到C++在后置条件方面是有缺陷的,然而进行前置条件和类不变式的测试仍然是合理的。在实践中,将这两者结合使用往往能发挥DbC大部分的威力。对返回值和输出参数的后置条件测试的能力缺失虽然令人遗憾,但也并非十分严重的事情。如果你必需这种能力的话,你可以求助于预处理器,就像在1.3.2小节中看到的那样。

如同约束一样, 对于不变式,我们可以通过使用一个间接层让日子好过一些。这个间接层对于约束来说是一个宏,而对于不变式来说则是一个成员函数。正因为如此,提供对新的编译器的支持或者修改某个类的内部实现也变得更为容易了,并且,我们还把该机制不爽的那一面全部隐藏到了类不变式方法中。

1译者注:即用于向外界返回东西的函数参数,例如指向待填充的缓冲区的指针。
2在这一点上,我承认我有点胆小自私,不过我有很好的借口。即便是在成熟运用DbC的语言中,对于继承体系中的层与层之间的关联契约的用处(事实上是机制)仍然是模棱两可的。此外,为C++加入DbC的提议直到本书的撰写时仍然不过是纳入考虑而已[Otto2004],因此,我认为在这里过多地在细节上饶舌没有什么好处。
3译者注:而非不变式函数。
4译者注:对程序进行编译和连接的过程。
5译者注:其实通常就是发行版(release)的构建,其中assert(exp)会展开为空。
6在ISE Eiffel 4.5中,你无法去掉前置条件,大概是因为前置条件可以在程序变成未定义状态之前进行反馈,从而对于程序捕获违反前置条件的异常并继续执行是有意义的。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

相关文章
|
10天前
|
人工智能 机器人 编译器
【C++】Windows端VS code中运行CMake工程(手把手教学)
【C++】Windows端VS code中运行CMake工程(手把手教学)
|
3月前
|
IDE 编译器 开发工具
Dev C++安装与运行
Dev C++安装与运行
|
17天前
|
Linux 编译器 程序员
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
59 0
|
4月前
|
JavaScript 前端开发 Serverless
函数计算只支持Node.js,我用C++写的程序怎么运行?
函数计算只支持Node.js,我用C++写的程序怎么运行?
89 1
|
5月前
|
存储 Cloud Native 编译器
C++编译期多态与运行期多态
C++编译期多态与运行期多态
|
5月前
|
存储 Cloud Native API
C++ QT监测可执行文件exe是否运行
C++ QT监测可执行文件exe是否运行
|
21天前
|
存储 安全 编译器
【C++ 多态 】深入理解C++的运行时类型信息(RTTI):dynamic_cast和typeid的应用与原理
【C++ 多态 】深入理解C++的运行时类型信息(RTTI):dynamic_cast和typeid的应用与原理
46 1
|
2月前
|
编译器 C++
C++ 新特性---->函数返回类型后置
C++ 新特性---->函数返回类型后置
|
2月前
|
监控 C++
【2021全国高校计算机能力挑战赛C++题目】17.信息整理 某机房上线了一套系统,和每台计算机都相连,以便监控各计算机相关外设的运行状态。
【2021全国高校计算机能力挑战赛C++题目】17.信息整理 某机房上线了一套系统,和每台计算机都相连,以便监控各计算机相关外设的运行状态。
|
2月前
|
C语言 C++
VScode中C++多文件编译运行问题(使用code runner配置)
VScode中C++多文件编译运行问题(使用code runner配置)