- C++ 其他特性
- 常量与初始化
- 建议9.1.1 不允许使用宏来表示常量
- 建议9.1.2 一组相关的整型常量应定义为枚举
- 建议9.1.3 不允许使用魔鬼数字
- 规则9.1.1 常量应该保证单一职责
- 建议9.1.4 禁止用memcpy_s、memset_s初始化非POD对象
- 建议9.1.5 变量使用时才声明并初始化
- 表达式
- 规则9.2.1 含有变量自增或自减运算的表达式中禁止再次引用该变量
- 规则9.2.2 switch语句要有default分支
- 建议9.2.1 表达式的比较,应当遵循左侧倾向于变化、右侧倾向于不变的原则
- 建议9.2.2 使用括号明确操作符的优先级
- 类型转换
- 规则9.3.1 如果确定要使用类型转换,请使用有C++提供的类型转换,而不是C风格的类型转换
- 建议9.3.1 避免使用dynamic_cast
- 建议9.3.2 避免使用reinterpret_cast
- 建议9.3.3 避免使用const_cast
- 资源分配和释放
- 规则9.4.1 单个对象释放使用delete,数组对象释放使用delete []
- 建议9.4.1 使用 RAII 特性来帮助追踪动态分配
- 标准库
- 规则9.5.1 不要保存std::string的c_str()返回的指针
- 建议9.5.1 使用std::string代替char*
- 规则9.5.2 禁止使用auto_ptr
- 建议9.5.2 使用新的标准头文件
- const的用法
- 规则9.6.1 对于指针和引用类型的形参,如果是不需要修改的,请使用const
- 规则9.6.2 对于不会修改成员变量的成员函数请使用const修饰
- 建议9.6.1 初始化后不会再修改的成员变量定义为const
- 异常
- 建议9.7.1 C++11中,如果函数不会抛出异常,声明为noexcept
- 模板
- 宏
- 常量与初始化
C++ 其他特性
常量与初始化
不变的值更易于理解、跟踪和分析,所以应该尽可能地使用常量代替变量,定义值的时候,应该把const作为默认的选项。
建议9.1.1 不允许使用宏来表示常量
说明:宏是简单的文本替换,在预处理阶段时完成,运行报错时直接报相应的值;跟踪调试时也是显示值,而不是宏名;宏没有类型检查,不安全;宏没有作用域。
#define MAX_MSISDN_LEN 20 // 不好// C++请使用const常量const int kMaxMsisdnLen = 20; // 好// 对于C++11以上版本,可以使用constexprconstexpr int kMaxMsisdnLen = 20;
建议9.1.2 一组相关的整型常量应定义为枚举
说明:枚举比#define或const int更安全。编译器会检查参数值是否位于枚举取值范围内,避免错误发生。
// 好的例子:enum Week {kSunday,kMonday,kTuesday,kWednesday,kThursday,kFriday,kSaturday};enum Color {kRed,kBlack,kBlue};void ColorizeCalendar(Week today, Color color);ColorizeCalendar(kBlue, kSunday); // 编译报错,参数类型错误// 不好的例子:const int kSunday = 0;const int kMonday = 1;const int kRed = 0;const int kBlack = 1;bool ColorizeCalendar(int today, int color);ColorizeCalendar(kBlue, kSunday); // 不会报错
当枚举值需要对应到具体数值时,须在声明时显式赋值。否则不需要显式赋值,以避免重复赋值,降低维护(增加、删除成员)工作量。
// 好的例子:S协议里定义的设备ID值,用于标识设备类型enum DeviceType {kUnknown = -1,kDsmp = 0,kIsmg = 1,kWapportal = 2};
建议9.1.3 不允许使用魔鬼数字
所谓魔鬼数字即看不懂、难以理解的数字。
魔鬼数字并非一个非黑即白的概念,看不懂也有程度,需要自行判断。例如数字 12,在不同的上下文中情况是不一样的:type = 12; 就看不懂,但 month = year * 12; 就能看懂。数字 0 有时候也是魔鬼数字,比如 status = 0; 并不能表达是什么状态。
解决途径:对于局部使用的数字,可以增加注释说明对于多处使用的数字,必须定义 const 常量,并通过符号命名自注释。
禁止出现下列情况:没有通过符号来解释数字含义,如const int kZero = 0符号命名限制了其取值,如 const int kXxTimerInterval = 300,直接使用kXxTimerInterval来表示该常量是定时器的时间间隔。
规则9.1.1 常量应该保证单一职责
说明:一个常量只用来表示一个特定功能,即一个常量不能有多种用途。
// 好的例子:协议A和协议B,手机号(MSISDN)的长度都是20。const unsigned int kAMaxMsisdnLen = 20;const unsigned int kBMaxMsisdnLen = 20;// 或者使用不同的名字空间:namespace namespace1 {const unsigned int kMaxMsisdnLen = 20;}namespace namespace2 {const unsigned int kMaxMsisdnLen = 20;}
建议9.1.4 禁止用memcpy_s、memset_s初始化非POD对象
说明:POD全称是Plain Old Data,是C++ 98标准(ISO/IEC 14882, first edition, 1998-09-01)中引入的一个概念,POD类型主要包括int, char, float,double,enumeration,void,指针等原始类型以及聚合类型,不能使用封装和面向对象特性(如用户定义的构造/赋值/析构函数、基类、虚函数等)。
由于非POD类型比如非聚合类型的class对象,可能存在虚函数,内存布局不确定,跟编译器有关,滥用内存拷贝可能会导致严重的问题。
即使对聚合类型的class,使用直接的内存拷贝和比较,破坏了信息隐蔽和数据保护的作用,也不提倡memcpy_s、memset_s操作。
对于POD类型的详细说明请参见附录。
建议9.1.5 变量使用时才声明并初始化
说明:变量在使用前未赋初值,是常见的低级编程错误。使用前才声明变量并同时初始化,非常方便地避免了此类低级错误。
在函数开始位置声明所有变量,后面才使用变量,作用域覆盖整个函数实现,容易导致如下问题:
- 程序难以理解和维护:变量的定义与使用分离。
- 变量难以合理初始化:在函数开始时,经常没有足够的信息进行变量初始化,往往用某个默认的空值(比如零)来初始化,这通常是一种浪费,如果变量在被赋于有效值以前使用,还会导致错误。遵循变量作用域最小化原则与就近声明原则, 使得代码更容易阅读,方便了解变量的类型和初始值。特别是,应使用初始化的方式替代声明再赋值。
// 不好的例子:声明与初始化分离string name; // 声明时未初始化:调用缺省构造函数name = "zhangsan"; // 再次调用赋值操作符函数;声明与定义在不同的地方,理解相对困难// 好的例子:声明与初始化一体,理解相对容易string name("zhangsan"); // 调用构造函数
表达式
规则9.2.1 含有变量自增或自减运算的表达式中禁止再次引用该变量
含有变量自增或自减运算的表达式中,如果再引用该变量,其结果在C++标准中未明确定义。各个编译器或者同一个编译器不同版本实现可能会不一致。为了更好的可移植性,不应该对标准未定义的运算次序做任何假设。
注意,运算次序的问题不能使用括号来解决,因为这不是优先级的问题。
示例:
x = b[i] + i++; // Bad: b[i]运算跟 i++,先后顺序并不明确。
正确的写法是将自增或自减运算单独放一行:
x = b[i] + i;i++; // Good: 单独一行
函数参数
Func(i++, i); // Bad: 传递第2个参数时,不确定自增运算有没有发生
正确的写法
i++; // Good: 单独一行x = Func(i, i);
规则9.2.2 switch语句要有default分支
大部分情况下,switch语句中要有default分支,保证在遗漏case标签处理时能够有一个缺省的处理行为。
特例:如果switch条件变量是枚举类型,并且 case 分支覆盖了所有取值,则加上default分支处理有些多余。现代编译器都具备检查是否在switch语句中遗漏了某些枚举值的case分支的能力,会有相应的warning提示。
enum Color {kRed = 0,kBlue};// 因为switch条件变量是枚举值,这里可以不用加default处理分支switch (color) {case kRed:DoRedThing();break;case kBlue:DoBlueThing();...break;}
建议9.2.1 表达式的比较,应当遵循左侧倾向于变化、右侧倾向于不变的原则
当变量与常量比较时,如果常量放左边,如 if (MAX == v) 不符合阅读习惯,而 if (MAX > v) 更是难于理解。应当按人的正常阅读、表达习惯,将常量放右边。写成如下方式:
if (value == MAX) {}if (value < MAX) {}
也有特殊情况,如:if (MIN < value && value < MAX) 用来描述区间时,前半段是常量在左的。
不用担心将 '==' 误写成 '=',因为if (value = MAX) 会有编译告警,其他静态检查工具也会报错。让工具去解决笔误问题,代码要符合可读性第一。
建议9.2.2 使用括号明确操作符的优先级
使用括号明确操作符的优先级,防止因默认的优先级与设计思想不符而导致程序出错;同时使得代码更为清晰可读,然而过多的括号会分散代码使其降低了可读性。下面是如何使用括号的建议。
- 二元及以上操作符, 如果涉及多种操作符,则应该使用括号
x = a + b + c; /* 操作符相同,可以不加括号 */x = Foo(a + b, c); /* 逗号两边的表达式,不需要括号 */x = 1 << (2 + 3); /* 操作符不同,需要括号 */x = a + (b / 5); /* 操作符不同,需要括号 */x = (a == b) ? a : (a – b); /* 操作符不同,需要括号 */
类型转换
避免使用类型分支来定制行为:类型分支来定制行为容易出错,是企图用C++编写C代码的明显标志。这是一种很不灵活的技术,要添加新类型时,如果忘记修改所有分支,编译器也不会告知。使用模板和虚函数,让类型自己而不是调用它们的代码来决定行为。
建议避免类型转换,我们在代码的类型设计上应该考虑到每种数据的数据类型是什么,而不是应该过度使用类型转换来解决问题。在设计某个基本类型的时候,请考虑:
- 是无符号还是有符号的
- 是适合float还是double
- 是使用int8,int16,int32还是int64,确定整形的长度但是我们无法禁止使用类型转换,因为C++语言是一门面向机器编程的语言,涉及到指针地址,并且我们会与各种第三方或者底层API交互,他们的类型设计不一定是合理的,在这个适配的过程中很容易出现类型转换。
例外:在调用某个函数的时候,如果我们不想处理函数结果,首先要考虑这个是否是你的最好的选择。如果确实不想处理函数的返回值,那么可以使用(void)转换来解决。
规则9.3.1 如果确定要使用类型转换,请使用有C++提供的类型转换,而不是C风格的类型转换
说明:
C++提供的类型转换操作比C风格更有针对性,更易读,也更加安全,C++提供的转换有:
- 类型转换:
dynamic_cast:主要用于继承体系下行转换,dynamic_cast具有类型检查的功能,请做好基类和派生类的设计,避免使用dynamic_cast来进行转换。static_cast:和C风格转换相似可做值的强制转换,或上行转换(把派生类的指针或引用转换成基类的指针或引用)。该转换经常用于消除多重继承带来的类型歧义,是相对安全的。如果是纯粹的算数转换,那么请使用后面的大括号转换方式。reinterpret_cast:用于转换不相关的类型。reinterpret_cast强制编译器将某个类型对象的内存重新解释成另一种类型,这是一种不安全的转换,建议尽可能少用reinterpret_cast。const_cast:用于移除对象的const属性,使对象变得可修改,这样会破坏数据的不变性,建议尽可能少用。- 算数转换: (C++11开始支持)对于那种算数转换,并且类型信息没有丢失的,比如float到double, int32到int64的转换,推荐使用大括号的初始方式。
double d{ someFloat };int64_t i{ someInt32 };
建议9.3.1 避免使用dynamic_cast
dynamic_cast依赖于C++的RTTI, 让程序员在运行时识别C++类对象的类型。dynamic_cast的出现一般说明我们的基类和派生类设计出现了问题,派生类破坏了基类的契约,不得不通过dynamic_cast转换到子类进行特殊处理,这个时候更希望来改善类的设计,而不是通过dynamic_cast来解决问题。
建议9.3.2 避免使用reinterpret_cast
说明:reinterpret_cast用于转换不相关类型。尝试用reinterpret_cast将一种类型强制转换另一种类型,这破坏了类型的安全性与可靠性,是一种不安全的转换。不同类型之间尽量避免转换。
建议9.3.3 避免使用const_cast
说明:const_cast用于移除对象的const和volatile性质。
使用const_cast转换后的指针或者引用来修改const对象,行为是未定义的。
// 不好的例子const int i = 1024;int* p = const_cast<int*>(&i);*p = 2048; // 未定义行为
// 不好的例子class Foo {public:Foo() : i(3) {}void Fun(int v) {i = v;}private:int i;};int main(void) {const Foo f;Foo* p = const_cast<Foo*>(&f);p->Fun(8); // 未定义行为}
资源分配和释放
规则9.4.1 单个对象释放使用delete,数组对象释放使用delete []
说明:单个对象删除使用delete, 数组对象删除使用delete [],原因:
- 调用new所包含的动作:从系统中申请一块内存,并调用此类型的构造函数。
- 调用new[n]所包含的动作:申请可容纳n个对象的内存,并且对每一个对象调用其构造函数。
- 调用delete所包含的动作:先调用相应的析构函数,再将内存归还系统。
- 调用delete[]所包含的动作:对每一个对象调用析构函数,再释放所有内存如果new和delete的格式不匹配,结果是未知的。对于非class类型, new和delete不会调用构造与析构函数。
错误写法:
const int KMaxArraySize = 100;int* numberArray = new int[KMaxArraySize];...delete numberArray;numberArray = NULL;
正确写法:
const int KMaxArraySize = 100;int* numberArray = new int[KMaxArraySize];...delete[] numberArray;numberArray = NULL;
建议9.4.1 使用 RAII 特性来帮助追踪动态分配
说明:RAII是“资源获取就是初始化”的缩语(Resource Acquisition Is Initialization),是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
RAII 的一般做法是这样的:在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。这种做法有两大好处:
- 我们不需要显式地释放资源。
- 对象所需的资源在其生命期内始终保持有效。这样,就不必检查资源有效性的问题,可以简化逻辑、提高效率。示例:使用RAII不需要显式地释放互斥资源。
class LockGuard {public:LockGuard(const LockType& lockType): lock(lockType) {lock.Aquire();}~LockGuard() {lock.Relase();}private:LockType lock;};bool Update() {LockGuard lockGuard(mutex);if (...) {return false;} else {// 操作数据}return true;}
标准库
STL标准模板库在不同模块使用程度不同,这里列出一些基本规则和建议。
规则9.5.1 不要保存std::string的c_str()返回的指针
说明:在C++标准中并未规定string::c_str()指针持久有效,因此特定STL实现完全可以在调用string::c_str()时返回一个临时存储区并很快释放。所以为了保证程序的可移植性,不要保存string::c_str()的结果,而是在每次需要时直接调用。
示例:
void Fun1() {std::string name = "demo";const char* text = name.c_str(); // 表达式结束以后,name的生命周期还在,指针有效// 如果中间调用了string的非const成员函数,导致string被修改,比如operator[], begin()等// 可能会导致text的内容不可用,或者不是原来的字符串name = "test";name[1] = '2';// 后续使用text指针,其字符串内容不再是"demo"}void Fun2() {std::string name = "demo";std::string test = "test";const char* text = (name + test).c_str(); // 表达式结束以后,+号产生的临时对象被销毁,指针无效// 后续使用text指针,其已不再指向合法内存空间}
例外:在少数对性能要求非常高的代码中,为了适配已有的只接受const char*类型入参的函数,可以临时保存string::c_str()返回的指针。但是必须严格保证string对象的生命周期长于所保存指针的生命周期,并且保证在所保存指针的生命周期内,string对象不会被修改。
建议9.5.1 使用std::string代替char*
说明:使用string代替char*有很多优势,比如:
- 不用考虑结尾的’\0’;
- 可以直接使用+, =, ==等运算符以及其它字符串操作函数;
- 不需要考虑内存分配操作,避免了显式的new/delete,以及由此导致的错误;需要注意的是某些stl实现中string是基于写时复制策略的,这会带来2个问题,一是某些版本的写时复制策略没有实现线程安全,在多线程环境下会引起程序崩溃;二是当与动态链接库相互传递基于写时复制策略的string时,由于引用计数在动态链接库被卸载时无法减少可能导致悬挂指针。因此,慎重选择一个可靠的stl实现对于保证程序稳定是很重要的。
例外:当调用系统或者其它第三方库的API时,针对已经定义好的接口,只能使用char*。但是在调用接口之前都可以使用string,在调用接口时使用string::c_str()获得字符指针。当在栈上分配字符数组当作缓冲区使用时,可以直接定义字符数组,不要使用string,也没有必要使用类似vector<char>等容器。
规则9.5.2 禁止使用auto_ptr
说明:在stl库中的std::auto_ptr具有一个隐式的所有权转移行为,如下代码:
auto_ptr<T> p1(new T);auto_ptr<T> p2 = p1;
当执行完第2行语句后,p1已经不再指向第1行中分配的对象,而是变为NULL。正因为如此,auto_ptr不能被置于各种标准容器中。转移所有权的行为通常不是期望的结果。对于必须转移所有权的场景,也不应该使用隐式转移的方式。这往往需要程序员对使用auto_ptr的代码保持额外的谨慎,否则出现对空指针的访问。使用auto_ptr常见的有两种场景,一是作为智能指针传递到产生auto_ptr的函数外部,二是使用auto_ptr作为RAII管理类,在超出auto_ptr的生命周期时自动释放资源。对于第1种场景,可以使用std::shared_ptr来代替。对于第2种场景,可以使用C++11标准中的std::unique_ptr来代替。其中std::unique_ptr是std::auto_ptr的代替品,支持显式的所有权转移。
例外:在C++11标准得到普遍使用之前,在一定需要对所有权进行转移的场景下,可以使用std::auto_ptr,但是建议对std::auto_ptr进行封装,并禁用封装类的拷贝构造函数和赋值运算符,以使该封装类无法用于标准容器。
建议9.5.2 使用新的标准头文件
说明:使用C++的标准头文件时,请使用<cstdlib>这样的,而不是<stdlib.h>这种的。
const的用法
在声明的变量或参数前加上关键字 const 用于指明变量值不可被篡改 (如 const int foo ). 为类中的函数加上 const 限定符表明该函数不会修改类成员变量的状态 (如 class Foo { int Bar(char c) const; };)。 const 变量, 数据成员, 函数和参数为编译时类型检测增加了一层保障, 便于尽早发现错误。因此, 我们强烈建议在任何可能的情况下使用 const。有时候,使用C++11的constexpr来定义真正的常量可能更好。
规则9.6.1 对于指针和引用类型的形参,如果是不需要修改的,请使用const
不变的值更易于理解/跟踪和分析,把const作为默认选项,在编译时会对其进行检查,使代码更牢固/更安全。
class Foo;void PrintFoo(const Foo& foo);
规则9.6.2 对于不会修改成员变量的成员函数请使用const修饰
尽可能将成员函数声明为 const。 访问函数应该总是 const。只要不修改数据成员的成员函数,都声明为const。
class Foo {public:// ...int PrintValue() const { // const修饰成员函数,不会修改成员变量std::cout << value << std::endl;}int GetValue() const { // const修饰成员函数,不会修改成员变量return value;}private:int value;};
建议9.6.1 初始化后不会再修改的成员变量定义为const
class Foo {public:Foo(int length) : dataLength(length) {}private:const int dataLength;};
异常
建议9.7.1 C++11中,如果函数不会抛出异常,声明为noexcept
理由
- 如果函数不会抛出异常,声明为
noexcept可以让编译器最大程度的优化函数,如减少执行路径,提高错误退出的效率。 vector等STL容器,为了保证接口的健壮性,如果保存元素的move运算符没有声明为noexcept,则在容器扩张搬移元素时不会使用move机制,而使用copy机制,带来性能损失的风险。如果一个函数不能抛出异常,或者一个程序并没有截获某个函数所抛出的异常并进行处理,那么这个函数可以用新的noexcept关键字对其进行修饰,表示这个函数不会抛出异常或者抛出的异常不会被截获并处理。例如:
extern "C" double sqrt(double) noexcept; // 永远不会抛出异常// 即使可能抛出异常,也可以使用 noexcept// 这里不准备处理内存耗尽的异常,简单地将函数声明为noexceptstd::vector<int> MyComputation(const std::vector<int>& v) noexcept {std::vector<int> res = v; // 可能会抛出异常// do somethingreturn res;}
示例
RetType Function(Type params) noexcept; // 最大的优化RetType Function(Type params); // 更少的优化// std::vector 的 move 操作需要声明 noexceptclass Foo1 {public:Foo1(Foo1&& other); // no noexcept};std::vector<Foo1> a1;a1.push_back(Foo1());a1.push_back(Foo1()); // 触发容器扩张,搬移已有元素时调用copy constructorclass Foo2 {public:Foo2(Foo2&& other) noexcept;};std::vector<Foo2> a2;a2.push_back(Foo2());a2.push_back(Foo2()); // 触发容器扩张,搬移已有元素时调用move constructor
注意默认构造函数、析构函数、swap函数,move操作符都不应该抛出异常。
模板
模板能够实现非常灵活简洁的类型安全的接口,实现类型不同但是行为相同的代码复用。
模板编程的缺点:
- 模板编程所使用的技巧对于使用c++不是很熟练的人是比较晦涩难懂的。在复杂的地方使用模板的代码让人更不容易读懂,并且debug 和维护起来都很麻烦。
- 模板编程经常会导致编译出错的信息非常不友好: 在代码出错的时候, 即使这个接口非常的简单, 模板内部复杂的实现细节也会在出错信息显示. 导致这个编译出错信息看起来非常难以理解。
- 模板如果使用不当,会导致运行时代码过度膨胀。
- 模板代码难以修改和重构。模板的代码会在很多上下文里面扩展开来, 所以很难确认重构对所有的这些展开的代码有用。所以, 建议模板编程最好只用在少量的基础组件,基础数据结构上面。并且使用模板编程的时候尽可能把复杂度最小化,尽量不要让模板对外暴露。最好只在实现里面使用模板, 然后给用户暴露的接口里面并不使用模板, 这样能提高你的接口的可读性。 并且你应该在这些使用模板的代码上写尽可能详细的注释。
宏
在C++语言中,我们强烈建议尽可能少使用复杂的宏
- 对于常量定义,请按照前面章节所述,使用const或者枚举;
- 对于宏函数,尽可能简单,并且遵循下面的原则,并且优先使用内联函数,模板函数等进行替换。
// 不推荐使用宏函数#define SQUARE(a, b) ((a) * (b))// 请使用模板函数,内联函数等来替换。template<typename T> T Square(T a, T b) { return a * b; }
如果需要使用宏,请参考C语言规范的相关章节。例外:一些通用且成熟的应用,如:对 new, delete 的封装处理,可以保留对宏的使用。
