白盒测试 [代码规范] [C++] 四

宏是C/C++编译环境提供给用户的,在编译开始前(编译预处理阶段)执行的唯一可编程逻辑。

何时使用宏

应当尽量减少宏的使用,在所有可能的地方都使用常量、模版和内联函数来代替宏。

 

边界效应

使用宏的时候应当注意边界效应,例如,以下代码将会得出错误的结果:

 

#define PLUS(x,y) x+y

cout << PLUS(1,1) * 2;

以上程序的执行结果将会是 "3",而不是 "4",因为 "PLUS(1,1) * 2" 表达式将会被展开为:"1 + 1 * 2"。

因此在定义宏的时候,只要允许,就应该为它的替换内容括上 "( )" 或 "{ }"。例如:

#define PLUS(x,y) (x+y)

#define SAFEDELETE(x) {delete x; x=0}

对复杂的宏实行缩进

有时为了实现诸如:对编译器和目标平台自适应;根据用户选项编译不同模块等机制,需要使用大量较为复杂的宏定义块。在宏比较复杂(代码块多于5行)的地方,为了便于阅读和理解,应当遵循与普通C++代码相同的原则进行缩进和排版。

为了区别于其他语句和便于阅读,宏语句的 "#" 前缀不要与语句本身一起缩进,例如:

//! Windows
#if defined(__WIN32__)
  if defined(__VC__) || defined(__BC__) || defined(__GNUC__) // ...
      define BAIY_EXPORT __declspec(dllexport)
      define BAIY_IMPORT __declspec(dllimport)
  else // 编译器不支持 __declspec()
      define BAIY_EXPORT
      define BAIY_IMPORT
  endif

//! OS/2
#elif defined(__OS2__)
  if defined (__WATCOMC__)
      define BAIY_EXPORT __declspec(dllexport)
      define BAIY_IMPORT
  elif !(defined(__VISAGECPP__) && (__IBMCPP__<400 || __IBMC__<400))
      define BAIY_EXPORT _Export
      define BAIY_IMPORT _Export
  endif

//! Macintosh
#elif defined(__MAC__)
  ifdef __MWERKS__
      define BAIY_EXPORT __declspec(export)
      define BAIY_IMPORT __declspec(import)
  endif

// Others
#else
  define BAIY_EXPORT
  define BAIY_IMPORT

#endif

 

名空间

名空间的使用

名空间可以避免名字冲突、分组不同的接口以及简化命名规则。应当尽可能地将所有接口都放入适当的名字空间中。

 

将实现和界面分离

提供给用户的界面和用于实现的细节应当分别放入不同的名空间中。

例如:如果将一个软件模块的所有接口都放在名空间 "MODULE" 中,那么这个模块的所有实现细节就可以放入名空间 "MODULE_IMP" 中 ,或者 "MODULE" 内的 "IMP" 中。

 

 

异常

异常使C++的错误处理更为结构化;错误传递和故障恢复更为安全简便;也使错误处理代码和其它代码间有效的分离开来。

何时使用异常

异常机制只用在发生错误的时候,仅在发生错误时才应当抛出异常。这样做有助于错误处理和程序动作两者间的分离,增强程序的结构化,还保证了程序的执行效率。

确定某一状况是否算作错误有时会很困难。比如:未搜索到某个字符串、等待一个信号量超时等等状态,在某些情况下可能并不算作一个错误,而在另一些情况下可能就是一个致命错误。

有鉴于此,仅当某状况必为一个错误时(比如:分配存储失败、创建信号量失败等),才应该抛出一个异常。而对另外一些模棱两可的情况,就应当使用返回值等其它手段报告。

此外,在发生错误的位置,已经能够获得足够的信息处理该错误的情况不属于异常,应当对其就地处理。只有无法获得足够的信息来处理发生的错误时,才应该抛出一个异常。

 

用异常代替goto等其它错误处理手段

曾经被广泛使用的传统错误处理手段有goto风格和do...while风格等,以下是一个goto风格的例子:

 

//! 使用goto进行错误处理的例子
bool
Function(void)
{
    int nCode, i;
    bool r = false;

    // ...

    if (!Operation1(nCode))
    {
        goto onerr;
    }

    try
    {
        Operation2(i);
    }
    catch (...)
    {
        r = true;
        goto onerr;
    }

    r = true;

onerr:
    // ... 清理代码
    return r;
}

由上例可见,goto风格的错误处理至少存在问题如下:

  • 错误处理代码和其它代码混杂在一起,使程序不够清晰易读 。
  • 函数内的变量必须在第一个 "goto" 语句之前声明,违反就近原则。
  • 多处跳转的使用破坏程序的结构化,影响程序的可读性,使程序容易出错 。
  • 对每个会抛出异常的操作都需要用额外的 try...catch 块检测和处理。
  • 稍微复杂一点的分类错误处理要使用多个标号和不同的goto跳转(如: "goto onOp1Err", "goto onOp2Err" ...)。这将使程序变得无法理解和错误百出。

再来看看 do...while 风格的错误处理:

错:

//! 使用do...while进行错误处理的例子
bool
Function(void)
{
    int nCode, i;
    bool r = false;

    // ...

    do
    {
        if (!Operation1(nCode))
        {
            break;
        }

        do
        {
            try
            {
                Operation2(i);
            }
            catch (...)
            {
                r = true;
                break;
            }
        } while (Operation3())

        r = true;

    } while (false);

    // ... 清理代码
    return r;
}

与 goto 风格的错误处理相似 ,do...while 风格的错误处理有以下问题:

  • 错误处理代码和其它代码严重混杂,使程序非常难以理解 。
  • 需要进行分类错误处理时非常困难,通常需要事先设置一个标志变量,并在清理时使用 "switch case" 语句进行分检。
  • 对每个会抛出异常的操作都需要用额外的 try...catch 块检测和处理 。

此外,还有一种更糟糕的错误处理风格——直接在出错位置就地完成错误处理:

//! 直接进行错误处理的例子
bool
Function(void)
{
    int nCode, i;

    // ...

    if (!Operation1(nCode))
    {
        // ... 清理代码
        return false;
    }

    try
    {
        Operation2(i);
    }
    catch (...)
    {
        // ... 清理代码
        return true;
    }

    // ...

    // ... 清理代码
    return true;
}

这种错误处理方式所带来的隐患可以说是无穷无尽,这里不再列举。

与传统的错误处理方法不同,C++的异常机制很好地解决了以上问题。使用异常完成出错处理时,可以将大部分动作都包含在一个try块中,并以不同的catch块捕获和处理不同的错误:

//! 使用异常进行错误处理的例子
bool
Function(void)
{
    int nCode, i;
    bool r = false;

    try
    {
        if (!Operation1(nCode))
        {
            throw false;
        }

        Operation2(i);
    }
    catch (bool err)
    {
        // ...
        r = err;
    }
    catch (const excption& err)
    {
        // ... excption类错误处理
    }
    catch (...)
    {
        // ... 处理其它错误
    }

    // ... 清理代码
    return r;
}

以上代码示例中,错误处理和动作代码完全分离,错误分类清晰明了,好处不言而喻。

 

构造函数中的异常

在构造函数中抛出异常将中止对象的构造,这将产生一个没有被完整构造的对象。

对于C++来说,这种不完整的对象将被视为尚未完成创建动作而不被认可,也意味着其析构函数永远不会被调用。这个行为本身无可非议,就好像公安局不会为一个被流产的婴儿发户口然后再开个死亡证明书一样。但有时也会产生一些问题,例如:

class CSample
{
    // ...

    char* m_pc;
};

CSample::CSample()
{
    m_pc = new char[256];
    // ...
    throw -1;  // m_pc将永远不会被释放
}

CSample::~CSample()  // 析构函数不会被调用
{
    delete[] m_pc;
}

解决这个问题的方法是在抛出异常以前释放任何已被申请的资源。一种更好的方法是使用一个满足“资源申请即初始化(RAII)”准则的类型(如:句柄类、灵巧指针类等等)来代替一般的资源申请与释放方式,如:

templete <class T>
struct CAutoArray
{
    CAutoArray(T* p = NULL) : m_p(p) {};
    ~CAutoArray() {delete[] m_p;}
    T* operator=(IN T* rhs)
    {
        if (rhs == m_p)
            return m_p;
        delete[] m_p;
        m_p = rhs;
        return m_p;
    }
    // ...
   
    T* m_p;
};

class CSample
{
    // ...

    CAutoArray<char> m_hc;
};

CSample::CSample()
{
    m_hc = new char[256];
    // ...
    throw -1;  // 由于m_hc已经成功构造,m_hc.~CAutoPtr()将会
               // 被调用,所以申请的内存将被释放
}

注意:上述CAutoArray类仅用于示范,对于所有权语义的通用自动指针,应该使用C++标准库中的 "auto_ptr" 模板类。对于支持引用计数和自定义销毁策略的通用句柄类,可以使用白杨工具库中的 "CHandle" 模板类。

 

析构函数中的异常

析构函数中的异常可能在2种情况下被抛出:

  1. 对象被正常析构时
  2. 在一个异常被抛出后的退栈过程中——异常处理机制退出一个作用域,其中所有对象的析构函数都将被调用。

由于C++不支持异常的异常,上述第二种情况将导致一个致命错误,并使程序中止执行。例如:

class CSample
{
    ~CSample();
    // ...
};

CSample::~CSample()
{
    // ...
    throw -1;  // 在 "throw false" 的过程中再次抛出异常
}

void
Function(void)
{
    CSample iTest;
    throw false;  // 错误,iTest.~CSample()中也会抛出异常
}


如果必须要在析构函数中抛出异常,则应该在异常抛出前用 "std::uncaught_exception()" 事先判断当前是否存在已被抛出但尚未捕获的异常。例如:

// uncaught_exception() 函数在这个头文件中声明
#include <exception>

class CSample
{
    ~CSample();
    // ...
};

CSample::~CSample()
{
    // ...
    if (!std::uncaught_exception()) // 没有尚未捕获的异常
    {
        throw -1;  // 抛出异常
    }
}

void
Function(void)
{
    CSample iTest;
    throw false;  // 可以,iTest.~CSample()不会抛出异常
}

 

new  时的异常

在 C++ 标准(ISO/IEC 14882:2003)第 15.2 节中明确规定,在使用 new 或 new[] 操作创建对象时,如对象的构造函数抛出了异常,则该对象的所有成员和基类都将被正确析构,如果存在一个与使用的 operator new 严格匹配的 operator delete,则为这个对象所分配的内存也会被释放。例如:

 

class CSample
{
    CSample() { throw -1; }

    static void* operator new(IN size_t n)
        { return malloc(n); }

    static void operator delete(IN void* p)
        { free(p); }

    static void* operator new(IN size_t n, IN CMemMgr& X)
        { return X.Alloc(n); } // 缺少匹配的 operator delete
};


void
Function(void)
{
    CSample* p1 = new CSample; // 有匹配的 operator delete,为 p1 分配的内存会被释放
    CSample* p2 = new(iMyMemMgr) CSample; // 没有匹配的 operator delete,内存泄漏!为 p2 分配的内存永远不会被释放
}


// 编译器实际生成的代码像这样:
void
Function(void)
{
    CSample* p1 = CSample::operator new(sizeof(CSample));
    try { p1->CSample(); } catch(...) {CSample::opertaor delete(p1); throw; }

    CSample* p2 = CSample::operator new(sizeof(CSample), iMyMemMgr);
    p2->CSample();
}

这里顺便提一句,delete 操作只会匹配普通的 operator delete(即:全局或类中的 operator delete(void*) 和类中的 operator delete(void*, size_t)),如果像上例中的 p2 那样使用了一个高度自定义的 operator new,用户就需要自己完成析构和释放内存的动作,例如:

    // ...
    p2->~CSample();
    CSample::operator delete(p2, iMymemMgr);

 

delete 时的异常

C++ 标准中明确规定,如果在一个析构函数中中途返回(不管通过 return 还是 throw),该析构函数不会立即返回,而是会逐一调用所有成员和基类的析构函数后才会返回。但是标准中并没有说明如果这个异常是在 delete 时发生的(即:该对象是由 new 创建的),此对象本身所占用的堆存储是否会被释放(即:在 delete 时析构函数抛出异常会不会调用 operator delete 释放这个对象占用的内存)。

在实际情况中,被 delete 的对象析构函数抛出异常后,GCC、VC 等流行的 C++ 编译器都不会自动调用 operator delete 释放对象占用的内存。这种与 new 操作不一致的行为,其背后的理念是:在构造时抛出异常的对象尚未成功创建,系统应当收回事先为其分配的资源;而析构时抛出异常的对象并未成功销毁,系统不能自动回收它使用的内存(意即:系统仅自动回收确定完全无用的资源)。

例如:如果一个对象在构造时申请了系统资源(比如:打开了一个设备)并保留了与该资源对应的句柄,但在析构时归还该资源失败(例如:关闭设备失败),则自动调用 operator delete 会丢失这个尚未关闭的句柄,导致用户永远失去向系统归还资源或者执行进一步错误处理的机会。反之,如果这个对象在构造时就没能成功地申请到相应资源,则自动回收预分配给它的内存空间是安全的,不会产生任何资源泄漏。

应当注意到,如果一个对象在析构时抛出了异常,则这个对象很可能已经处于一个不完整的状态。此时访问该对象中的任何非静态成员都是不安全的。因此,应当在被抛出的异常中包含完成进一步处理的足够信息。这样捕获到这个异常的用户就可以安全地释放该对象占用的内存,并且仅使用异常对象完成后续处理。例如:

//! delete 时异常处理的例子
void
Function(void)
{
    CSample* p1 = new CSample;
    // ...

    try
    {
        delete p1;
    }
    catch (const sampleExp& err)
    {
        CSample::operator delete(p1); // 释放 p1 所占用的内存
        // 使用 err 对象完成后续的错误处理...
    }
}

 

异常的组织

异常类应该以继承的方式组织成一个层次结构,这将使以不同粒度分类处理错误成为可能。

通常,某个软件生产组织的所有异常都从一个公共的基类派生出来。而每个类的异常则从该类所属模块的公共异常基类中派生。例如:

 

异常捕获和重新抛出

  • 异常捕获器的书写顺序应当由特殊到一般(先子类后基类),最后才是处理所有异常的捕获器("catch(...)")。否则将使某些异常捕获器永远不会被执行。
     
  • 为避免捕获到的异常被截断,异常捕获器中的参数类型应当为常引用型或指针型。
     
  • 在某级异常捕获器中无法被彻底处理的错误可以被重新抛出。重新抛出采用一个不带运算对象的 "throw" 语句。重新抛出的对象就是刚刚被抛出的那个异常,而不是处理器捕获到的(有可能被截断的)异常。

例如:

try
{
    // ...
}
// 公钥加密错误
catch (const CPubKeyCipher::Exp& err) 
{
    if (可以恢复)
    {
        // 恢复错误
    }
    else
    {
        // 完成能做到的事情
        throw;  // 重新抛出
    }   
}
// 处理其它加密库错误
catch (const CryptoExp& err)
{
    // ...
}
// 处理其它本公司模块抛出的错误
catch (const CompanyExp& err)
{
    // ...
}
// 处理 dynamic_cast 错误
catch (const bad_cast& err)
{
    // ...
}
// 处理其它标准库错误
catch (const exception& err)
{
    // ...
}
// 处理所有其它错误
catch (...)
{
    // 完成清理和日志等基本处理...
    throw;  // 重新抛出
}

 

异常和效率

对于绝大部分现代编译器来说,在不抛出异常的情况下,异常处理的实现在运行时几乎不会有任何额外开销。相反,很多时候,异常机制比传统的通过返回值判断错误的开销还来得稍微小些。

相对于函数返回和调用的开销来讲,异常抛出和捕获的开销通常会大一些。不过错误处理代码通常不会频繁调用,再说传统的错误处理方式也不是没有代价的。所以错误处理时开销稍大一点基本上不是什么问题。这也是我们提倡仅将异常用于错误处理的原因之一。

更多关于效率的讨论,参见:C++异常机制的实现方式和开销分析 和 RTTI、虚函数和虚基类的开销分析和使用指导 等小节。

 

修改标记

在代码交叉审查,或使用带完整源代码的第三方库时,经常需要为某些目的修改源码。这时应当为被改动的部分添加修改标记。

何时使用修改标记

修改标记通常仅用于修改者不是被修改模块(或项目)的主要作者时,但也可以用于在调试、重构或添加新特性时进行临时标注。

在交叉审查中使用的修改标记,当原作者已经确认并将其合入主要版本之后,应当予以消除,以避免由于多次交叉审查累积的标记混乱。但是相应的修改应当记入文件头的修改记录中。

 

修改标记的格式

修改标记分为单行标记和段落标记两种,单行标记用于指示对零星的单行代码进行的修改,段落标记则用于指出对一组任意长度的代码作出的修改。它们的格式如下:

 

// 单行标记:
// code ...; // by <修改者> - <目的> [@ YYYY-MM-DD(可选的修改日期)]

// 段落标记:
// [[ by <修改者> - <目的> [@ YYYY-MM-DD(可选的修改日期)]
//    详细说明(可选,可多行)
// ... // 被修改的代码段落
// ]] [by <修改者>]

注意段落标记结尾的 "by <修改者>" 字段是可选的。

此外,在比较混乱或较长的代码段中,可以将段落开始("// [[")和段落结束("// ]]")标记扩展层次结构更为明显的:"// ---- [[" 和 "// ---- ]]"

例如:

    // [[ by BaiYang - limit @ 2005-03-29
    //    add pre compile and delay binding support to "limit [s,]n".
    void setStatementLimit(dbQuery const& q) {
        // ...
    }
    // ]]

// ...

// ---- [[ by Mark - multithread
void dbCompiler::compileLimitPart(dbQuery& query)
{
    // ...
    int4* lp1 = INVPTR; // by BaiYang - limit
    switch (scan())
    {
      case tkn_iconst:
    // ...
}
// ---- ]] by Mark

 

修改标记的语言

修改标记当中的说明性文字应当尽量选择与被修改项目一致的语言书写。例如在全英文的项目中应当尽量避免添加中文注释。

否则能完全看懂修改后项目的程序员将会被限制于同时掌握多种自然语言的人。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值