65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.04/5 (34投票s)

2004 年 3 月 3 日

CPOL

8分钟阅读

viewsIcon

114743

downloadIcon

908

宏,用于在函数头中编写“契约式设计”条件,并自动将其整合到您的 doxygen 文档中。

目录

所提供宏的目的

  1. 在 C++ 函数体之前用一个块编写前置/后置条件,以便清晰阅读。
  2. 自动将它们整合到您的 doxygen 文档中。
  3. 在编译时启用/禁用条件检查(如果您想在发布版本中移除代码)。
  4. 条件失败时中断,或始终在运行时忽略它。
  5. 提供处理条件失败的钩子。

引言

契约式设计

“契约式设计”是一种非常好的编程方法。(请阅读 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 函数的图片。

Documentation screenshot

在编译 WIN32 时,当条件失败时,您会收到以下消息:

Failure screenshot

单击“确定”继续(使用调试器时会中断)。

单击“取消”忽略(表示如果条件再次失败,将不会弹出消息(或中断))。

代码

它适用于 VC6 和 VC7。

代码由 2 部分组成:

  1. 核心部分:宏定义……
  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++ 代码

注意:我假设您知道 prologepilog 函数是什么,以及编译器如何在调用函数过程中使用堆栈。

主要问题是……当然是后置条件。您很容易发现,在函数开始时调用前置条件很容易……因为您已经写在前面了 :)

但我用了一个小技巧,在函数返回后调用后置条件。

过程很简单:在函数退出时(即函数结束/返回/离开时),我们跳转到后置条件开始处,执行后置条件块,然后完成退出。

在继续解释这个技巧之前,您应该看到还有另一个问题:不要在函数结束之前调用 POSTCONDITION 块。因为它写在函数体之前,所以我们必须跳过它。

执行函数的算法变成:

  • 执行前置条件块
  • 跳转到函数体

返回时

  • 转到后置条件块
  • 执行后置条件块
  • 返回到函数的退出部分。

因此,第一步是标记后置条件块。这很容易,因为它被两个宏包围:DBC_POST_BEGINDBC_POST_END

我们还需要知道函数体从哪里开始。答案是……在“契约式设计”块之后……被 DBC_BEGINDBC_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 介绍。
© . All rights reserved.