C++ 的契约式设计宏和 Doxygen 链接






4.04/5 (34投票s)
宏,用于在函数头中编写“契约式设计”条件,并自动将其整合到您的 doxygen 文档中。
目录
所提供宏的目的
- 在 C++ 函数体之前用一个块编写前置/后置条件,以便清晰阅读。
- 自动将它们整合到您的 doxygen 文档中。
- 在编译时启用/禁用条件检查(如果您想在发布版本中移除代码)。
- 条件失败时中断,或始终在运行时忽略它。
- 提供处理条件失败的钩子。
引言
契约式设计
“契约式设计”是一种非常好的编程方法。(请阅读 Bertrand Meyer 的《面向对象软件构造》)。
对于从未听说过 DbC 的人,我将对该原则做一个非常简单快速的介绍。
其思想是,在函数与函数的调用之间,存在一个“契约”。
为确保函数能做它应该做的事情,契约中的每一个条件都必须满足。
这些条件可能涉及函数或调用者。
当条件失败时,两个缔约方之一未能尽职。因此,程序员必须选择更改函数或更改调用函数的方式。
例如,我选择编写一个线性插值函数。
/**@brief 浮点线性插值。
* @param _o lerp 结果。
* @param _t 参数值。
* @param _s 起始值。
* @param _e 结束值。
**/
void lerp( float& _o, float _t, float _s, float _e )
{ _o = _s + ((_e - _s) * _t); }
我们可以添加一些前置条件,例如:
- _t 必须在 [0..1] 范围内。
- _e 必须是一个浮点值。
- _z 必须是一个浮点值。
我们可以添加后置条件,例如:
- _o 必须是一个浮点数。
想象一下相同的函数,但用于处理向量或矩阵。我们应该添加条件来说明输入参数不应被函数修改。
在 C++ 中,可以使用 const 关键字在编译时(在此情况下)检查此条件。
但对于其他条件,我们应该使用 assert 函数。
void lerp( float& _o, const float _t, const float _s, const float _e )
{ assert((_t >= 0.f)&&(_t <= 1.f)); assert(!isnan(_s)); assert(!isnan(_e)); _o = _s + ((_e - _s) * _t); assert(!isnan(_o)); }
对于更专业的 lerp 函数,您可能需要添加更复杂的后置条件,例如确保矩阵的正交化……
个人小技巧:不要忽略“愚蠢”的条件,因为它们往往是最常出现的。大多数时候是因为代码在深夜被修改,通过复制粘贴代码块……,而设置的“愚蠢”条件变得非常重要。
Digital Mars C/C++ 编译器支持 DbC。
在不支持 DbC 的 C++ 编译器上使用 DbC。
通常,我这样编写契约条件:
int foo( void* p )
{ assert( p ); // precondition : p cannot be null. ... code ... if(...) { assert( post condition); // post condition documentation return ... } ... code ... if(...) { assert( post condition ); // doc // this condition didn't have to be check in the last return assert( an other post condition); // documentation return ... } ... code ... ... once again check all post conditions ... return ... ouf }
这样,“调试”代码或“检查”代码就会严重污染我的代码,主要是因为重复。而且我不想让这些东西被编译到最终的发布版本中。
因此,我真的很想能够以更好的方式编写函数……比如:
int foo( void* p )
{ #ifdef contract_check PRECONDITION( p, "p can't be 0" ); // the phrase is to display to the user // why the assertion fail. POSTCONDITION( postcondition, "description of my post condition" ); POSTCONDITION( checkpostcondition2(), "an other post condition"); #endif // contract_check ... code ... if(...) return ... ... code ... if(...) return ... ... code ... return ... }
而且,因为我使用的是一个超棒的代码文档生成器(Doxygen),我想在文档中看到我的断言(条件)的文档。当然,不用写两遍……您明白我的意思吗?(冗余意味着未来的问题)
所以,我写的一套小宏让您能够编写类似这样的内容:
int AClass::Test( int argA, int argB )
{ DBC_BEGIN DBC_PRE_BEGIN DBC_PRE( argA >= 0, precond 1 documentation and message ) DBC_PRE( argB != 0, precond 2 documentation and message ) DBC_PRE_END DBC_POST_BEGIN DBC_POST( argA + argB >= 0, post condition 1 documentation and message ) DBC_POST( ChechDataFct( argA, argB ), post cond 2 : cf. ChechDataFct() ) DBC_POST_END DBC_END ... code ... return ...; }
并且(如果您使用 Doxygen),文档将是最新的。
示例
看看这张取自 Doxygen 文档的 AClass::Test
函数的图片。
在编译 WIN32 时,当条件失败时,您会收到以下消息:
单击“确定”继续(使用调试器时会中断)。
单击“取消”忽略(表示如果条件再次失败,将不会弹出消息(或中断))。
代码
它适用于 VC6 和 VC7。
代码由 2 部分组成:
- 核心部分:宏定义……
- 回调部分:编写您想要处理断言的代码。
例如,您可以选择在条件失败时发送一个异常。
演示 zip 文件与源 zip 文件完全相同,外加一个压缩的 HTML 文档。
如果您有 Doxygen,可以从源 zip 文件生成此文档文件。
它是如何工作的?
因为我收到的 100% 的反馈(……2)都询问我关于我用来使该工具工作的技巧的解释,所以我将这一部分添加到文章中。
Doxygen 文档
Doxygen 文档使用 Doxygen 预处理器工作。
在源文件中,该行:
DBC_PRE( argA >= 0, precond 1 )
在 Doxygen 预处理器中使用:
DBC_PRE(a,b) =\pre b \code a \endcode
对于 Doxygen,代码变为:
\pre precond 1 \code argA >= 0 \endcode
这是一个有效的 Doxygen 解析器命令。
C++ 代码
注意:我假设您知道 prolog
和 epilog
函数是什么,以及编译器如何在调用函数过程中使用堆栈。
主要问题是……当然是后置条件。您很容易发现,在函数开始时调用前置条件很容易……因为您已经写在前面了 :)
但我用了一个小技巧,在函数返回后调用后置条件。
过程很简单:在函数退出时(即函数结束/返回/离开时),我们跳转到后置条件开始处,执行后置条件块,然后完成退出。
在继续解释这个技巧之前,您应该看到还有另一个问题:不要在函数结束之前调用 POSTCONDITION 块。因为它写在函数体之前,所以我们必须跳过它。
执行函数的算法变成:
- 执行前置条件块
- 跳转到函数体
返回时
- 转到后置条件块
- 执行后置条件块
- 返回到函数的退出部分。
因此,第一步是标记后置条件块。这很容易,因为它被两个宏包围:DBC_POST_BEGIN
和 DBC_POST_END
。
我们还需要知道函数体从哪里开始。答案是……在“契约式设计”块之后……被 DBC_BEGIN
和 DBC_END
包围。
伪宏变成类似:
define DBC_BEGIN // declare start of body label. define DBC_END __dbc_body : define DBC_PRE_BEGIN define DBC_PRE_END define DBC_POST_BEGIN if(not in epilog) __asm je __dbc_body define DBC_POST_END continue epilog (means jump after caller instruction in epilog ...)
使用 C++ 局部变量声明
我经常使用 C++ 局部变量声明,甚至局部静态变量声明来做很多事情。我计划在另一篇文章中写一些关于这方面的内容,因为这是 C++ 的一个强大功能,我从未读过任何关于利用它的文章。
这里,我只是利用了这样一个事实:当我们声明一个类的局部实例时,析构函数将在函数退出时被调用。因此,声明一个具有析构函数的类的实例为您提供了一个“退出陷阱”,即类的析构函数。
我还利用了构造函数在声明变量的地方被调用的事实……但这对于这个工具并不非常相关。我的意思是,我认为可以以不同的方式实现。
这是此工具使用的类的代码声明:
class structDBC { public : structDBC(); // constructor ~structDBC(); // destructor // store caller address of post condition block // (means instruction address which call destructor in epilog). long dbc_postblockRETaddr; // store start address of postcondition block. long dbc_postblockaddr; };
将我们的类添加到宏中。
define DBC_POST_BEGIN structDBC autoDBC; if(!autoDBC.dbc_postblockRETaddr) goto __dbc_body; define DBC_POST_END goto autoDBC.dbc_postblockRETaddr;
因此,后置条件块被包围在:
- 如果
dbc_postblockRETaddr
未设置(我们不是由退出部分调用的),则跳转到函数体开始处。否则(由退出部分调用),则执行后置条件块。 - 在后置条件块的末尾,我们跳转(goto)到退出部分的下一条指令(存储在
dbc_postblockaddr
中)。
structDBC 构造函数和析构函数实现。
注意
- 构造函数和析构函数实现在单独的文件中,以确保它们将使用 `call` 指令(而不是 `jmp`)被调用。根据编译器,可以添加一些特殊声明,如 `__noinline` 来确保这一点。我认为编译器通常会使用 `call` 来实现这些函数的调用,因为它们在一个单独的对象中实现。
__declspec(naked)
告诉 Visual C 编译器不要为此函数创建 *epilog* 和 *prolog*。
__declspec(naked) structDBC::structDBC()
{
// standard prolog (__asm ENTER)
__asm push ebp;
__asm mov ebp, esp;
// Put in eax the stack pointer where return address is store
__asm mov eax, ebp;
__asm add eax, 4; // because we have done a push on the first instruction.
// store address effective return adress in dbc_postblockaddr
// made in 2 lines because we can't write
// __asm mov dword ptr [ecx+4], [eax]
__asm mov eax, [eax]; // store effective address in eax.
__asm mov dword ptr [ecx+4], eax; // copy to dbc_postblockaddr
// standard epilog (__asm LEAVE )
__asm mov esp, ebp;
__asm pop ebp;
__asm mov dword ptr [ecx], 0 // reset dbc_postblockRETaddr
__asm ret;
}
__declspec(naked) structDBC::~structDBC()
{ // standard prolog __asm push ebp; __asm mov ebp, esp; // put return address in dbc_postblockRETaddr __asm pusha; __asm mov eax, ebp; __asm add eax, 4; __asm mov eax, [eax]; __asm mov [ecx], eax; // copy to dbc_postblockRETaddr __asm popa; // standard epilog __asm mov esp, ebp; __asm pop ebp; __asm add esp, 4; // skip next line. // goto dbc_postblockaddr (start of post condition block). __asm jmp dword ptr [ecx+4]; }
注意:如果您将此与源文件中的代码进行比较,您会注意到我没有使用类,而是使用 `struct`……这仅仅是因为我想确保不会实现虚表(或其他 C++ 编译器想要添加的东西)……根据汇编代码。在编写此解释段时,我注意到我从未真正使用过类名……这仅仅是因为……我在汇编中编码时不需要它。
最终宏
#define DBC_POST_BEGIN structDBC autoDBC; \ __asm cmp dword ptr [autoDBC], 0 \ __asm je __dbc_body #define DBC_POST_END __asm jmp dword ptr [autoDBC];
结论
这是我发表的第一篇文章,……很抱歉我的英语。
我希望它对其他人有用。
不幸的是,我没有对其进行太多测试,只在几个目标上进行了测试:代码依赖于编译器。
但是,想法在这里,每个人都可以改编和改进这些东西(请分享您的附加组件或改进 :) )
我尝试只使用 C/C++ 来做这些事情,例如使用 `longjump`。但我没有找到一种方法来得到我想要的结果。
如果有人可以用不使用汇编的方式实现,请发送您的代码给我 :)
访问
历史
- 2004 年 4 月:在 **Kandjar** 的评论后添加代码文档。
- 2004 年 10 月:在 **Peterchen** 的评论后添加 DbC 介绍。