代码风格的重要性怎么强调都不过分。一段稍长一点的无格式代码基本上就是不可读的。 先来看一下这方面的整体原则:
空行的使用 | 空行起着分隔程序段落的作用。空行得体(不过多也不过少)将使程序的布局更加清晰。空行不会浪费内存,虽然打印含有空行的程序是会多消耗一些纸张,但是值得。所以不要舍不得用空行。
- 在每个类声明之后、每个函数定义结束之后都要加2行空行。
- 在一个函数体内,逻揖上密切相关的语句之间不加空行,其它地方应加空行分隔。
| 语句与代码行 |
- 一行代码只做一件事情,如只定义一个变量,或只写一条语句。这样的代码容易阅读,并且方便于写注释。
- "if"、"for"、"while"、"do"、"try"、"catch" 等语句自占一行,执行语句不得紧跟其后。不论执行语句有多少都要加 "{ }" 。这样可以防止书写和修改代码时出现失误。
| 缩进和对齐 |
- 程序的分界符 "{" 和 "}" 应独占一行并且位于同一列,同时与引用它们的语句左对齐。
- "{ }" 之内的代码块在 "{" 右边一个制表符(4个半角空格符)处左对齐。如果出现嵌套的 "{ }",则使用缩进对齐。
- 如果一条语句会对其后的多条语句产生影响的话,应该只对该语句做半缩进(2个半角空格符),以突出该语句。
例如:
void Function(int x) { CSessionLock iLock(mxLock); for (初始化; 终止条件; 更新) { // ... } try { // ... } catch (const exception& err) { // ... } catch (...) { // ... } // ... } |
| 最大长度 | 代码行最大长度宜控制在70至80个字符以内。代码行不要过长,否则眼睛看不过来,也不便于打印(2009年更新:随着GUI开发环境和高分宽屏的普及,此规则可以视情况适当放宽)。 | 长行拆分 | 长表达式要在低优先级操作符处拆分成新行,操作符放在新行之首(以便突出操作符)。拆分出的新行要进行适当的缩进,使排版整齐,语句可读。 例如:
if ((very_longer_variable1 >= very_longer_variable2) && (very_longer_variable3 <= very_longer_variable4) && (very_longer_variable5 <= very_longer_variable6)) { DoSomething(); } |
| 空格的使用 |
- 关键字之后要留空格。象 "const"、"virtual"、"inline"、"case" 等关键字之后至少要留一个空格,否则无法辨析关键字。象 "if"、"for"、"while"、"catch" 等关键字之后应留一个空格再跟左括号 "(",以突出关键字。
- 函数名之后不要留空格,紧跟左括号 "(",以与关键字区别。
- "(" 向后紧跟。而 ")"、","、";" 向前紧跟,紧跟处不留空格。
- "," 之后要留空格,如 Function(x, y, z)。如果 ";" 不是一行的结束符号,其后要留空格,如 for (initialization; condition; update)。
- 赋值操作符、比较操作符、算术操作符、逻辑操作符、位域操作符,如"="、"+=" ">="、"<="、"+"、"*"、"%"、"&&"、"||"、"<<", "^" 等二元操作符的前后应当加空格。
- 一元操作符如 "!"、"~"、"++"、"--"、"&"(地址运算符)等前后不加空格。
- 象"[]"、"."、"->"这类操作符前后不加空格。
- 对于表达式比较长的 for、do、while、switch 语句和 if 语句,为了紧凑起见可以适当地去掉一些空格,如 for (i=0; i<10; i++) 和 if ((a<=b) && (c<=d)) 等。
例如:
void Func1(int x, int y, int z); // 良好的风格 void Func1 (int x,int y,int z); // 不良的风格 // =========================================================== if (year >= 2000) // 良好的风格 if(year>=2000) // 不良的风格 if ((a>=b) && (c<=d)) // 良好的风格 if(a>=b&&c<=d) // 不良的风格 // =========================================================== for (i=0; i<10; i++) // 良好的风格 for(i=0;i<10;i++) // 不良的风格 for (i = 0; I < 10; i ++) // 过多的空格 // =========================================================== x = a < b ? a : b; // 良好的风格 x=a<b?a:b; // 不好的风格 // =========================================================== int* x = &y; // 良好的风格 int * x = & y; // 不良的风格 // =========================================================== array[5] = 0; // 不要写成 array [ 5 ] = 0; a.Function(); // 不要写成 a . Function(); b->Function(); // 不要写成 b -> Function(); |
| 修饰符的位置 | 为便于理解,应当将修饰符 "*" 和 "&" 紧靠数据类型。 例如:
char* name; int* x; int y; // 为避免y被误解为指针,这里必须分行写。 int* Function(void* p); |
参见:变量、常量的风格与版式 -> 指针或引用类型的定义和声明
| 注释 |
- 注释的位置应与被描述的代码相邻,可以放在代码的上方或右方,不可放在下方。
- 边写代码边注释,修改代码同时修改相应的注释,以保证注释与代码的一致性。不再有用的注释要删除。
- 注释应当准确、易懂,防止注释有二义性。错误的注释不但无益反而有害。
- 当代码比较长,特别是有多重嵌套时,应当在一些段落的结束处加注释,便于阅读。
| 与常量的比较 | 在与宏、常量进行 "==", "!=", ">=", "<=" 等比较运算时,应当将常量写在运算符左边,而变量写在运算符右边。这样可以避免因为偶然写错把比较运算变成了赋值运算的问题。 例如:
if (NULL == p) // 如果把 "==" 错打成 "=",编译器就会报错 { // ... } |
| 为增强代码的可读性而定义的宏 | 以下预定义宏对程序的编译没有任何影响,只为了增加代码的可读性:
宏 | 说明 | NOTE | 需要注意的代码 | TODO | 尚未实现的接口、类、算法等 | UNDONE | 已取消的接口、类、算法等 | FOR_DBG | 标记为调试方便而临时增加的代码 | OK | 仅用于调试的标记 |
例如:
TODO class CMyClass; TODO void Function(void); FOR_DBG cout << "..."; |
|
类是C++中最重要也是使用频率最高的新特性之一。类的版式好坏将极大地影响代码品质。
注释头与类声明 | 与文件一样,每个类应当有一个注释头用来说明该类的各个方面。 类声明换行紧跟在注释头后面,"class" 关键字由行首开始书写,后跟类名称。界定符 "{" 和 "};" 应独占一行,并与 "class" 关键字左对齐。
/*! @class ******************************************************************************** <PRE> 类名称 : CXXX 功能 : <简要说明该类所完成的功能> 异常类 : <属于该类的异常类(如果有的话)> -------------------------------------------------------------------------------- 备注 : <使用该类时需要注意的问题(如果有的话)> 典型用法 : <如果该类的使用方法较复杂或特殊,给出典型的代码例子> -------------------------------------------------------------------------------- 作者 : <xxx>, [yyy], [zzz] ...(作者和逗号分割的修改者列表) </PRE> *******************************************************************************/ class CXXX { // ... }; |
对于功能明显的简单类(接口小于10个),也可以使用简单的单行注释头:
//! <简要说明该类所完成的功能> class CXXX { // ... }; |
| 继承 | 基类直接跟在类名称之后,不换行,访问说明符(public, private, 或 protected)不可省略。如:
class CXXX : public CAAA, private CBBB { // ... }; |
| 以行为为中心 | 没人喜欢上来就看到一大堆私有数据,大多数用户关心的是类的接口与 它提供的服务,而不是其实现细节。 所以应当将公有的定义和成员放在类声明的最前面,保护的放在中间,而私有的摆在最后。 | 访问说明符 | 访问说明符(public, private, 或 protected)应该独占一行,并与类声明中的‘class’关键字左对齐。 | 类成员的声明版式 | 对于比较复杂(成员多于20个)的类,其成员必须分类声明。 每类成员的声明由访问说明符(public, private, 或 protected)+ 全行注释开始。注释不满全行(80个半角字符)的,由 "/" 字符补齐,最后一个 "/" 字符与注释间要留一个半角空格符。 如果一类声明中有很多组功能不同的成员,还应该用分组注释将其分组。分组注释也要与 "class" 关键字对齐。 每个成员的声明都应该由 "class" 关键字开始向右缩进一个制表符(4个半角空格符),成员之间左对齐。 例如:
class CXXX { public: /// 类型定义 typedef vector<string> VSTR; public: / 构造、析构、初始化 CXXX(); ~CXXX(); public: /// 公用方法 // [[ 功能组1 void Function1(void) const; long Function2(IN int n); // ]] 功能组1 // [[ 功能组2 void Function3(void) const; bool Function4(OUT int& n); // ]] 功能组2 private: /// 属性 // ... private: / 禁用的方法 // 禁止复制 CXXX(IN const CXXX& rhs); CXXX& operator=(IN const CXXX& rhs); }; |
| 正确地使用const和mutable | 把不改变对象逻辑状态的成员都标记为 const 成员不仅有利于用户对成员的理解,更可以最大化对象使用方式的灵活性及合理性(比如通过 const 指针或 const 引用的形式传递一个对象)。 如果某个属性的改变并不影响该对象逻辑上的状态,而且这个属性需要在 const 方法中被改变,则该属性应该标记为 "mutable"。 例如:
class CString { public: //! 查找一个子串,find() 不会改变字符串的值 ,所以为 const 函数 int find(IN const CString& str) const; // ... private: // 最后一次错误值,改动这个值不会影响对象的逻辑状态, // 像 find() 这样的 const 函数也可能修改这个值 mutable int m_nLastError; // ... }; |
也就是说,应当尽量使所有逻辑上只读的操作成为 const 方法,然后使用 mutable 解决那些存在逻辑冲突的属性。
| 嵌套的类声明 | 在相应的逻辑关系确实存在时,类声明可以嵌套。嵌套类可以使用简单的单行注释头:
// ... class CXXX { //! 嵌套类说明 class CYYY { // ... }; }; |
| 初始化列表 | 应当尽可能通过构造函数的初始化列表来初始化成员和基类。初始化列表至少独占一行,并且与构造函数的定义保持一个制表符(4个半角空格)的缩进。 例如:
CXXX::CXXXX(IN int nA, IN bool bB) : m_nA(nA), m_bB(bB) { // ... }; |
初始化列表的书写顺序应当与对象的构造顺序一致,即:先按照声明顺序写基类初始化,再按照声明顺序写成员初始化。 如果一个成员 "a" 需要使用另一个成员 "b" 来初始化,则 "b" 必须在 "a" 之前声明,否则将会产生运行时错误(有些编译器会给出警告)。 例如:
// ... class CXXXX : public CAA, public CBB { // ... CYY m_iA; CZZ m_iB; // m_iA 必须在 m_iB 之前声明 }; CXXX::CXXXX(IN int nA, IN int nB, IN bool bC) : CAA(nA), CBB(nB), m_iA(bC), m_iB(m_iA) // 先基类,后成员, // 分别按照声明顺序书写 { // ... }; |
| 内联函数的实现体 | 定义在类声明之中的函数将自动成为内联函数。但为了使类的声明更为清晰明了,应尽量避免直接在声明中直接定义成员函数的编程风格。鼓励使用 "inline" 关键字将内联函数放在类声明的外部定义。 |
关于类声明的例子,请参见:类/结构的风格与版式例子
关于类声明的模板,请参见:类声明模板
|
函数是程序执行的最小单位,任何一个有效的C/C++程序都少不了函数。
函数原型 | 函数原型的格式为:
[存储类] 返回值类型 [名空间或类::]函数名(参数列表) [const说明符] [异常过滤器] |
例如:
static inline void Function1(void) int CSem::Function2(IN const char* pcName) const throw(Exp) |
其中:
| 函数声明 | 函数声明的格式为:
例如:
//! 执行某某操作 static void Function(void); |
函数声明和其它代码间要有空行分割。
声明类的成员函数时,为了紧凑,返回值类型和函数名之间不用换行,也可以适当减少声明间的空行。
| 函数定义 | 函数定义使用如下格式:
/*! @function ******************************************************************************** <PRE> 函数名 : <函数名> 功能 : <函数实现功能> 参数 : <参数类表及说明(如果有的话),格式为:> [IN|OUT] 参数1 : 参数说明 [IN|OUT] 参数2 : 参数说明 ... 返回值 : <函数返回值的意义(如果有的话)> 抛出异常 : <可能抛出的异常及其说明(如果有的话),格式为:> 类型1 : 说明 类型2 : 说明 ... -------------------------------------------------------------------------------- 复杂度 : <描述函数的复杂度/开销(可选)> 备注 : <其它注意事项(如果有的话)> 典型用法 : <如果该函数的使用方法较复杂或特殊,给出典型的代码例子> -------------------------------------------------------------------------------- 作者 : <xxx>, [yyy], [zzz] ...(作者和逗号分割的修改者列表) </PRE> *******************************************************************************/ 函数原型 { // ... } |
对于返回值、参数意义都很明确的简单函数(代码不超过20行),也可以使用单行函数头:
//! 函数实现功能 函数原型 { // ... } |
函数定义和其它代码之间至少分开2行空行。
| 参数描述宏 | 以下预定义宏对程序的编译没有任何影响,只为了增强对参数的理解:
宏 | 说明 | IN | 输入参数。 | OUT | 输出参数。 | DUMMY | 哑元参数-不使用参数的值,仅为帮助函数重载解析等目的而设置的参数。 | OPTIONAL | 可选参数-通常指可以为NULL的指针参数,带默认值的参数不需要这样标明。 | RESERVED | 保留参数-这个参数当前未被支持,留待以后扩展;或者该参数为内部使用,用户无需关心。 | OWNER | 获得参数的所有权,调用者不再负责销毁实参指定的对象;如果用来修饰返回值,则表示调用者获得返回值的所有权,并负责将其销毁。 | UNUSED | 标明这个参数在此版本中已不再使用。 | CHANGED | 参数类型或用途与较早版本相比发生了变化。 | ADDED | 新增的参数。 | NOTE | 需要注意的参数-参数意义发生变化或者与习惯用法不同。 | WRKBUF | 工作缓冲区-为避免频繁分配临时资源而传入的临时工作区。 | DEFERRED | 表示指定的参数会被延后使用,调用者在当前调用返回后仍然要保证该参数有效,直到事先在接口中约定的,某个未来的时间点。 | TRANSIENT | 表示参数指定的对象只能在调用返回前使用,调用返回后该对象可能失效。因此不能保留此对象供以后使用。 |
其中:
-
除了空参数 "void" 和哑元参数以外,每个参数左侧都必须有 "IN" 和/或 "OUT" 修饰。 -
既输入又输出的参数应记为:"IN OUT",而不是 "OUT IN"。 -
IN/OUT的左侧还可以根据需要加入一个或多个上表中列出的其它宏 。
参数描述宏的使用思想是:只要一个描述宏可以用在指定参数上(即:对这个参数来说,用这个描述宏修饰它是贴切的),那么就应当使用它。
也就是说,应该把能用的描述宏都用上,以期尽量具体地描述一个参数的作用和用法等信息。
| 参数列表 | 参数列表的格式为:
参数描述宏1 参数类型1 参数1, 参数描述宏2 参数类型2 参数2, ... |
例如:
IN const int nCode, OUT string& nName OWNER IN CDatabase* piDB, OPTIONAL IN OUT int* pnRecordCount = NULL IN OUT string& stRuleList, RESERVED IN int nOperate = 0 ... |
其中:
| 存储类 | "extern", "static", "inline" 等函数存储类说明应该在声明和定义中一致并且显式地使用。不允许隐式地使用一个类型声明,也不允许一个类型声明仅存在于函数的声明或定义中。 | 成员函数的存储类 | 由于C++语言的限制,类中成员函数的 "static", "virtual", "explicit" 等存储类说明不允许出现在函数定义中。 但是为了明确起见,这些存储类应以注释的形式在相应的成员定义中给出。 例如:
/*virtual*/ CThread::EXITCODE CSrvCtl::CWrkTrd::Entry(void) { // ... } /*static*/ inline void stringEx::regex_free(IN OUT void*& pRegEx) { // ... } |
特别地, 对于在类声明中直接实现的方法, 可以省略其 "inline" 关键字 。而在内联(.inl)文件中实现的 inline 方法则不能省略。这是因为 inline linkage 是在声明时起效的。
| 默认参数 | 类似地,参数的默认值只能出现在函数声明中,但是为了明确起见,这些默认值应以注释的形式在定义中给出。 例如:
bool stringEx::regex_find(OUT VREGEXRESULT& vResult, IN stringEx stRegEx, IN size_t nIndex /*= 0*/, IN size_t nStartPos /*= 0*/, IN bool bNoCase /*= false*/, IN bool bNewLine /*= true*/, IN bool bExtended /*= true*/, IN bool bNotBOL /*= false*/, IN bool bNotEOL /*= false*/, IN bool bUsePerlStyle /*= false*/) const { // ... } |
| 异常过滤器 | 对于任何可能抛出异常的函数,必须在其声明和定义中显式地指定异常过滤器,并在过滤器中列举该函数可能抛出的异常。 例如:
int Function(IN const char* pcName) throw(byExp, exception); |
如果一个函数本身及其直接调用的函数不会显式抛出异常(没有指定异常过滤器),那么该函数可以省略异过滤器。
特别地:如果一个函数内部显式地捕获了任何可能的异常(例如:使用了 "catch (...)" ),并且保证不抛出任何异常,那么应该在其声明和定义中显式地指定一个空异常过滤器:"throw()"。
例如:
int Function(IN const char* pcName) throw(); |
特别地:进程入口函数("main()")不应当使用异常过滤器。
除了空异常过滤器有时可帮助编译器完成一些优化以外,异常过滤器的主要作用反倒是为程序员提供函数出错时的行为描述。这些信息对于函数的使用者来说十分有用。因此,即使仅作为一种文档性质的措施,异常过滤器也应当被保留下来。
| 代码段注释 | 如果函数体中的代码较长,应该根据功能不同将其分段。代码段间以空行分离,并且每段代码都以代码段分割注释作为开始。 例如:
void CXXX::Function(IN void* pmodAddr) { if (NULL == pmodAddr) { return; } { CSessionLock iLock(mxLock); // ===================================================================== // = 判断指定模块是不是刚刚被装入,由于在NT系列平台中,“A”系列函数都是 // = 由“W”系列函数实现的。 所以可能会有一次LoadLibrary产生多次本函数调 // = 用的情况。为了增加效率,特设此静态变量判断上次调用是否与本次相同。 static PVOID pLastLoadedModule = NULL; if (pLastLoadedModule == pmodAddr) { return; // 相同,忽略这次调用 } pLastLoadedModule = pmodAddr; // ===================================================================== // = 检查这个模块是否在旁路模块表中 stringEx stModName; if (!BaiY_IMP::GetModuleNameByAddress(pmodAddr, stModName)) { return; } if (CHookProc::sm_sstByPassModTbl.find(stModName) != CHookProc::sm_sstByPassModTbl.end()) { return; } // ===================================================================== // = 在这个模块中HOOK所有存在于HOOK函数表中的函数 PROCTBL::iterator p = sm_iProcTbl.begin(); for (; p!=sm_iProcTbl.end(); ++p) { p->HookOneModule(pmodAddr); } } // SessionLock } |
明显地,如果需要反复用到一段代码的话,这段代码就应当 被抽取成一个函数。 当一个函数过长(超过100行)或代码的意图不明确时,为了便于阅读和理解,也应当将其中的一些代码段实现为单独的函数。 特别地,对由于如加密及性能优化等特殊原因无法提取为一个单独函数的代码段,应当使用特别代码段注释显式分割。当然,类似情况应当尽量使用内联函数或编译器提供的强制性内联函数代替。 例如:
void CXXX::Function(void) { // ... // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ // @@ 获取首网卡的 MAC 地址 typedef CTmpHandle<IP_ADAPTER_INFO, FreeDeletor<IP_ADAPTER_INFO> > THADAPTERINFO; byteEx btAddr; THADAPTERINFO thAdapterInfo; thAdapterInfo = (IP_ADAPTER_INFO*) malloc(sizeof(IP_ADAPTER_INFO)); ULONG ulOutBufLen = sizeof(IP_ADAPTER_INFO); // Make an initial call to GetAdaptersInfo to get // the necessary size into the ulOutBufLen variable if (ERROR_SUCCESS != ::GetAdaptersInfo(thAdapterInfo, &ulOutBufLen)) { thAdapterInfo = (IP_ADAPTER_INFO*) malloc(ulOutBufLen); } if (NO_ERROR != ::GetAdaptersInfo(thAdapterInfo, &ulOutBufLen)) { #ifdef DEBUG CLog::DebugMsg(byT("lic verifier"), byT("failed verifying license")); #endif CProcess::Exit(-97); } btAddr.assign(thAdapterInfo->Address, thAdapterInfo->AddressLength); // @@ 获取首网卡的 MAC 地址 // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ // ... } |
| 调用系统API | 所有系统API调用前都要加上全局名称解析符 "::"。 例如:
::MessageBoxA(NULL, gcErrorMsg, "!FATAL ERROR!", MB_ICONSTOP|MB_OK); if (0 == ::GetTempFileName(m_basedir.c_str(), byT("bai"), 0, stR.ref())) { // ... } |
| 让相同的代码只出现一次 | 为了使程序更容易调试、修改,尽量降低日后维护的复杂性,应该把需要在一个以上位置使用的代码段封装成函数。哪怕这段代码很短,为了以后维护方便着想,也应当将其封装为内联函数。 |
关于函数的例子,请参见:函数的风格与版式例子
关于函数的模板,请参见:函数模板
|
声明格式 | 变量、常量的声明格式如下:
其中:
-
以 "[ ]" 括住的为可选项目。 -
"存储类 " 的说明见下文
| 定义格式 | 变量、常量的定义格式如下:
其中:
-
以 "[ ]" 括住的为可选项目。 -
"存储类 " 的说明见下文
| 存储类 | 除 "auto" 类型以外,诸如 "extern", "static", "register", "volatile" 等存储类均不可省略,且必须在声明和定义中一致地使用(即:不允许仅在声明或定义中使用)。 特别地,由于 "auto" 关键字在 C++11 中已被用作类型推断,因此声明局部变量时,"auto" 存储类必须被省略。 | 成员变量的存储类 | 由于C++语言的限制,成员变量的 "static" 等存储类说明不允许出现在变量定义中。 但是为了明确起见,这些存储类应以注释的形式在定义中给出。 例如:
/*static*/ int CThread::sm_nPID = 0; |
| 指针或引用类型的定义和声明 | 在声明和定义多个指针或引用变量/常量时,每个变量至少占一行。例如:
int* pn1, * pn2 = NULL, * pn3; char* pc1; char* pc2; char* pc3; // 错误的写法: int* pn11, *pn12, *pn13; |
| 常指针和指针常量 | 声明/定义一个常指针(指向常量的指针)时,"const" 关键字一律放在类型说明的左侧。 声明/定义一个指针常量(指针本身不能改变)时,"const" 关键字一律放在变量左侧、类型右侧。 例如:
const char* pc1; // 常指针 char* const pc2; // 指针常量 const char* const pc3; // 常指针常量 // 错误的写法: char const* pc1; // 与 const char* pc1 含义相同,但不允许这样写 |
| 全局变量、常量的注释 | 全局变量、常量的注释独占一行,并用 "//!" 开头。 例如:
//! 当前进程的ID static int sg_nPID = 0; //! 分割符 static const char* pcDTR = "\\/"; |
| 类型转换 | 禁止使用C风格的 "(类型)" 类型转换,应当优先使用C++的 "xxx_cast" 风格的类型转换。C++风格的类型转换可以提供丰富的含义和功能,以及更好的类型检查机制,这对代码的阅读、修改、除错和移植有很大的帮助。其中:
static_cast | static_cast 用于编译器认可的,安全的静态转换,比如将 "char" 转为 "int" 等等。该操作通常在编译时完成,但有可能调用用户定义的类型转换操作或非 explicit 的单参(或至少从第二个参数开始带缺省值的)构造函数 。 | reinterpret_cast | reinterpret_cast 用于编译器不认可的,不安全的静态转换,比如将 "int*" 转为 "int" 等等。这种转换有可能产生可移植性方面的问题,该操作在编译时完成。(注意:reinterpret_cast 比 C 风格的类型转换还要野蛮,它不进行任何地址对齐和调整,也不调用任何用户定义的类型转换操作) | const_cast | const_cast 用于将一个常量转化为相应类型的变量,比如将 "const char*" 转换成 "char*" 等等。这种转换可能伴随潜在的错误。该操作在编译时完成 。 | dynamic_cast | dynamic_cast 是 C++ RTTI 机制的重要体现,用于在类层次结构中漫游。dynamic_cast 可以对指针和引用进行自由度很高的向上、向下和交叉转换。被正确使用的 dynamic_cast 操作将在运行时完成。反之,若编辑器关闭了 RTTI 支持,或被转换的类层次结构中没有抽象类存在,则此操作在编译时完成(有些编译器会给出警告)。 |
此外,对于定义了单参构造函数或类型转换操作的类来说,应当优先使用构造函数风格的类型转换,如:'string("test")' 等等。
通常来说,"xxx_cast" 格式的转换与构造函数风格的类型转换之间最大的区别在于:构造函数风格的转换经常会生成新的临时对象,可能伴随相当的时间和空间开销。而 "xxx_cast" 格式的转换只是告诉编译器,将指定内存中的数据当作另一种类型的数据看待,这些操作一般在编译时完成,不会对程序的运行产生额外开销。当然,"dynamic_cast" 和某些 "static_cast" 则例外。
参见:RTTI、虚函数和虚基类的开销分析和使用指导
|
|
枚举、联合的定义格式 | 枚举、联合的定义格式为:
//! 说明(可选) enum|union 名称 { 内容 // 注释(可选) }; |
例如:
//! 服务的状态 enum SRVSTATE { SRV_INVALID = 0, // 无效(尚未启动) SRV_STARTING = 1, SRV_STARTED, SRV_PAUSING, SRV_PAUSED, SRV_STOPPING, SRV_STOPPED }; //! 32位整数 union INT32 { unsigned char cByte[4]; unsigned short nShort[2]; unsigned long nFull; }; |
| typedef的定义格式 | typedef 的定义格式为:
//! 说明(可选) typedef 原类型 类型别名; |
例如:
//! 返回值类型 typedef int EXITCODE; //! 字符串数组类型 typedef vector<string> VSTR; |
|
|
宏是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 风格的错误处理有以下问题:
-
错误处理代码和其它代码严重混杂,使程序非常难以理解 。比如上例中的外层循环用于错误处理,而内层的 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种情况下被抛出:
- 对象被正常析构时。
- 在一个异常被抛出后的退栈过程中——异常处理机制退出一个作用域,其中所有对象的析构函数都将被调用。
由于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 |
| 修改标记的语言 | 修改标记当中的说明性文字应当尽量选择与被修改项目一致的语言书写。例如在全英文的项目中应当尽量避免添加中文注释。 否则能完全看懂修改后项目的程序员将会被限制于同时掌握多种自然语言的人。 |
|
|