C++ 其他特性

常量与初始化

不变的值更易于理解、跟踪和分析,所以应该尽可能地使用常量代替变量,定义值的时候,应该把const作为默认的选项。

建议9.1.1 不允许使用宏来表示常量

说明:宏是简单的文本替换,在预处理阶段时完成,运行报错时直接报相应的值;跟踪调试时也是显示值,而不是宏名;宏没有类型检查,不安全;宏没有作用域。

  1. #define MAX_MSISDN_LEN 20 // 不好
  2. // C++请使用const常量
  3. const int kMaxMsisdnLen = 20; // 好
  4. // 对于C++11以上版本,可以使用constexpr
  5. constexpr int kMaxMsisdnLen = 20;

建议9.1.2 一组相关的整型常量应定义为枚举

说明:枚举比#defineconst int更安全。编译器会检查参数值是否位于枚举取值范围内,避免错误发生。

  1. // 好的例子:
  2. enum Week {
  3. kSunday,
  4. kMonday,
  5. kTuesday,
  6. kWednesday,
  7. kThursday,
  8. kFriday,
  9. kSaturday
  10. };
  11. enum Color {
  12. kRed,
  13. kBlack,
  14. kBlue
  15. };
  16. void ColorizeCalendar(Week today, Color color);
  17. ColorizeCalendar(kBlue, kSunday); // 编译报错,参数类型错误
  18. // 不好的例子:
  19. const int kSunday = 0;
  20. const int kMonday = 1;
  21. const int kRed = 0;
  22. const int kBlack = 1;
  23. bool ColorizeCalendar(int today, int color);
  24. ColorizeCalendar(kBlue, kSunday); // 不会报错

当枚举值需要对应到具体数值时,须在声明时显式赋值。否则不需要显式赋值,以避免重复赋值,降低维护(增加、删除成员)工作量。

  1. // 好的例子:S协议里定义的设备ID值,用于标识设备类型
  2. enum DeviceType {
  3. kUnknown = -1,
  4. kDsmp = 0,
  5. kIsmg = 1,
  6. kWapportal = 2
  7. };

建议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 常量应该保证单一职责

说明:一个常量只用来表示一个特定功能,即一个常量不能有多种用途。

  1. // 好的例子:协议A和协议B,手机号(MSISDN)的长度都是20。
  2. const unsigned int kAMaxMsisdnLen = 20;
  3. const unsigned int kBMaxMsisdnLen = 20;
  4. // 或者使用不同的名字空间:
  5. namespace namespace1 {
  6. const unsigned int kMaxMsisdnLen = 20;
  7. }
  8. namespace namespace2 {
  9. const unsigned int kMaxMsisdnLen = 20;
  10. }

建议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, floatdoubleenumerationvoid,指针等原始类型以及聚合类型,不能使用封装和面向对象特性(如用户定义的构造/赋值/析构函数、基类、虚函数等)。

由于非POD类型比如非聚合类型的class对象,可能存在虚函数,内存布局不确定,跟编译器有关,滥用内存拷贝可能会导致严重的问题。

即使对聚合类型的class,使用直接的内存拷贝和比较,破坏了信息隐蔽和数据保护的作用,也不提倡memcpy_smemset_s操作。

对于POD类型的详细说明请参见附录。

建议9.1.5 变量使用时才声明并初始化

说明:变量在使用前未赋初值,是常见的低级编程错误。使用前才声明变量并同时初始化,非常方便地避免了此类低级错误。

在函数开始位置声明所有变量,后面才使用变量,作用域覆盖整个函数实现,容易导致如下问题:

  • 程序难以理解和维护:变量的定义与使用分离。
  • 变量难以合理初始化:在函数开始时,经常没有足够的信息进行变量初始化,往往用某个默认的空值(比如零)来初始化,这通常是一种浪费,如果变量在被赋于有效值以前使用,还会导致错误。遵循变量作用域最小化原则与就近声明原则, 使得代码更容易阅读,方便了解变量的类型和初始值。特别是,应使用初始化的方式替代声明再赋值。
  1. // 不好的例子:声明与初始化分离
  2. string name; // 声明时未初始化:调用缺省构造函数
  3. name = "zhangsan"; // 再次调用赋值操作符函数;声明与定义在不同的地方,理解相对困难
  4. // 好的例子:声明与初始化一体,理解相对容易
  5. string name("zhangsan"); // 调用构造函数

表达式

规则9.2.1 含有变量自增或自减运算的表达式中禁止再次引用该变量

含有变量自增或自减运算的表达式中,如果再引用该变量,其结果在C++标准中未明确定义。各个编译器或者同一个编译器不同版本实现可能会不一致。为了更好的可移植性,不应该对标准未定义的运算次序做任何假设。

注意,运算次序的问题不能使用括号来解决,因为这不是优先级的问题。

示例:

  1. x = b[i] + i++; // Bad: b[i]运算跟 i++,先后顺序并不明确。

正确的写法是将自增或自减运算单独放一行:

  1. x = b[i] + i;
  2. i++; // Good: 单独一行

函数参数

  1. Func(i++, i); // Bad: 传递第2个参数时,不确定自增运算有没有发生

正确的写法

  1. i++; // Good: 单独一行
  2. x = Func(i, i);

规则9.2.2 switch语句要有default分支

大部分情况下,switch语句中要有default分支,保证在遗漏case标签处理时能够有一个缺省的处理行为。

特例:如果switch条件变量是枚举类型,并且 case 分支覆盖了所有取值,则加上default分支处理有些多余。现代编译器都具备检查是否在switch语句中遗漏了某些枚举值的case分支的能力,会有相应的warning提示。

  1. enum Color {
  2. kRed = 0,
  3. kBlue
  4. };
  5. // 因为switch条件变量是枚举值,这里可以不用加default处理分支
  6. switch (color) {
  7. case kRed:
  8. DoRedThing();
  9. break;
  10. case kBlue:
  11. DoBlueThing();
  12. ...
  13. break;
  14. }

建议9.2.1 表达式的比较,应当遵循左侧倾向于变化、右侧倾向于不变的原则

当变量与常量比较时,如果常量放左边,如 if (MAX == v) 不符合阅读习惯,而 if (MAX > v) 更是难于理解。应当按人的正常阅读、表达习惯,将常量放右边。写成如下方式:

  1. if (value == MAX) {
  2. }
  3. if (value < MAX) {
  4. }

也有特殊情况,如:if (MIN < value && value < MAX) 用来描述区间时,前半段是常量在左的。

不用担心将 '==' 误写成 '=',因为if (value = MAX) 会有编译告警,其他静态检查工具也会报错。让工具去解决笔误问题,代码要符合可读性第一。

建议9.2.2 使用括号明确操作符的优先级

使用括号明确操作符的优先级,防止因默认的优先级与设计思想不符而导致程序出错;同时使得代码更为清晰可读,然而过多的括号会分散代码使其降低了可读性。下面是如何使用括号的建议。

  • 二元及以上操作符, 如果涉及多种操作符,则应该使用括号
  1. x = a + b + c; /* 操作符相同,可以不加括号 */
  2. x = Foo(a + b, c); /* 逗号两边的表达式,不需要括号 */
  3. x = 1 << (2 + 3); /* 操作符不同,需要括号 */
  4. x = a + (b / 5); /* 操作符不同,需要括号 */
  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的转换,推荐使用大括号的初始方式。
  1. double d{ someFloat };
  2. 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用于移除对象的constvolatile性质。

使用const_cast转换后的指针或者引用来修改const对象,行为是未定义的。

  1. // 不好的例子
  2. const int i = 1024;
  3. int* p = const_cast<int*>(&i);
  4. *p = 2048; // 未定义行为
  1. // 不好的例子
  2. class Foo {
  3. public:
  4. Foo() : i(3) {}
  5. void Fun(int v) {
  6. i = v;
  7. }
  8. private:
  9. int i;
  10. };
  11. int main(void) {
  12. const Foo f;
  13. Foo* p = const_cast<Foo*>(&f);
  14. p->Fun(8); // 未定义行为
  15. }

资源分配和释放

规则9.4.1 单个对象释放使用delete,数组对象释放使用delete []

说明:单个对象删除使用delete, 数组对象删除使用delete [],原因:

  • 调用new所包含的动作:从系统中申请一块内存,并调用此类型的构造函数。
  • 调用new[n]所包含的动作:申请可容纳n个对象的内存,并且对每一个对象调用其构造函数。
  • 调用delete所包含的动作:先调用相应的析构函数,再将内存归还系统。
  • 调用delete[]所包含的动作:对每一个对象调用析构函数,再释放所有内存如果new和delete的格式不匹配,结果是未知的。对于非class类型, new和delete不会调用构造与析构函数。

错误写法:

  1. const int KMaxArraySize = 100;
  2. int* numberArray = new int[KMaxArraySize];
  3. ...
  4. delete numberArray;
  5. numberArray = NULL;

正确写法:

  1. const int KMaxArraySize = 100;
  2. int* numberArray = new int[KMaxArraySize];
  3. ...
  4. delete[] numberArray;
  5. numberArray = NULL;

建议9.4.1 使用 RAII 特性来帮助追踪动态分配

说明:RAII是“资源获取就是初始化”的缩语(Resource Acquisition Is Initialization),是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

RAII 的一般做法是这样的:在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。这种做法有两大好处:

  • 我们不需要显式地释放资源。
  • 对象所需的资源在其生命期内始终保持有效。这样,就不必检查资源有效性的问题,可以简化逻辑、提高效率。示例:使用RAII不需要显式地释放互斥资源。
  1. class LockGuard {
  2. public:
  3. LockGuard(const LockType& lockType): lock(lockType) {
  4. lock.Aquire();
  5. }
  6. ~LockGuard() {
  7. lock.Relase();
  8. }
  9. private:
  10. LockType lock;
  11. };
  12. bool Update() {
  13. LockGuard lockGuard(mutex);
  14. if (...) {
  15. return false;
  16. } else {
  17. // 操作数据
  18. }
  19. return true;
  20. }

标准库

STL标准模板库在不同模块使用程度不同,这里列出一些基本规则和建议。

规则9.5.1 不要保存std::string的c_str()返回的指针

说明:在C++标准中并未规定string::c_str()指针持久有效,因此特定STL实现完全可以在调用string::c_str()时返回一个临时存储区并很快释放。所以为了保证程序的可移植性,不要保存string::c_str()的结果,而是在每次需要时直接调用。

示例:

  1. void Fun1() {
  2. std::string name = "demo";
  3. const char* text = name.c_str(); // 表达式结束以后,name的生命周期还在,指针有效
  4. // 如果中间调用了string的非const成员函数,导致string被修改,比如operator[], begin()等
  5. // 可能会导致text的内容不可用,或者不是原来的字符串
  6. name = "test";
  7. name[1] = '2';
  8. // 后续使用text指针,其字符串内容不再是"demo"
  9. }
  10. void Fun2() {
  11. std::string name = "demo";
  12. std::string test = "test";
  13. const char* text = (name + test).c_str(); // 表达式结束以后,+号产生的临时对象被销毁,指针无效
  14. // 后续使用text指针,其已不再指向合法内存空间
  15. }

例外:在少数对性能要求非常高的代码中,为了适配已有的只接受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具有一个隐式的所有权转移行为,如下代码:

  1. auto_ptr<T> p1(new T);
  2. 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作为默认选项,在编译时会对其进行检查,使代码更牢固/更安全。

  1. class Foo;
  2. void PrintFoo(const Foo& foo);

规则9.6.2 对于不会修改成员变量的成员函数请使用const修饰

尽可能将成员函数声明为 const。 访问函数应该总是 const。只要不修改数据成员的成员函数,都声明为const。

  1. class Foo {
  2. public:
  3. // ...
  4. int PrintValue() const { // const修饰成员函数,不会修改成员变量
  5. std::cout << value << std::endl;
  6. }
  7. int GetValue() const { // const修饰成员函数,不会修改成员变量
  8. return value;
  9. }
  10. private:
  11. int value;
  12. };

建议9.6.1 初始化后不会再修改的成员变量定义为const

  1. class Foo {
  2. public:
  3. Foo(int length) : dataLength(length) {}
  4. private:
  5. const int dataLength;
  6. };

异常

建议9.7.1 C++11中,如果函数不会抛出异常,声明为noexcept

理由

  • 如果函数不会抛出异常,声明为noexcept可以让编译器最大程度的优化函数,如减少执行路径,提高错误退出的效率。
  • vector等STL容器,为了保证接口的健壮性,如果保存元素的move运算符没有声明为noexcept,则在容器扩张搬移元素时不会使用move机制,而使用copy机制,带来性能损失的风险。如果一个函数不能抛出异常,或者一个程序并没有截获某个函数所抛出的异常并进行处理,那么这个函数可以用新的noexcept关键字对其进行修饰,表示这个函数不会抛出异常或者抛出的异常不会被截获并处理。例如:
  1. extern "C" double sqrt(double) noexcept; // 永远不会抛出异常
  2. // 即使可能抛出异常,也可以使用 noexcept
  3. // 这里不准备处理内存耗尽的异常,简单地将函数声明为noexcept
  4. std::vector<int> MyComputation(const std::vector<int>& v) noexcept {
  5. std::vector<int> res = v; // 可能会抛出异常
  6. // do something
  7. return res;
  8. }

示例

  1. RetType Function(Type params) noexcept; // 最大的优化
  2. RetType Function(Type params); // 更少的优化
  3. // std::vector 的 move 操作需要声明 noexcept
  4. class Foo1 {
  5. public:
  6. Foo1(Foo1&& other); // no noexcept
  7. };
  8. std::vector<Foo1> a1;
  9. a1.push_back(Foo1());
  10. a1.push_back(Foo1()); // 触发容器扩张,搬移已有元素时调用copy constructor
  11. class Foo2 {
  12. public:
  13. Foo2(Foo2&& other) noexcept;
  14. };
  15. std::vector<Foo2> a2;
  16. a2.push_back(Foo2());
  17. a2.push_back(Foo2()); // 触发容器扩张,搬移已有元素时调用move constructor

注意默认构造函数、析构函数、swap函数,move操作符都不应该抛出异常。

模板

模板能够实现非常灵活简洁的类型安全的接口,实现类型不同但是行为相同的代码复用。

模板编程的缺点:

  • 模板编程所使用的技巧对于使用c++不是很熟练的人是比较晦涩难懂的。在复杂的地方使用模板的代码让人更不容易读懂,并且debug 和维护起来都很麻烦。
  • 模板编程经常会导致编译出错的信息非常不友好: 在代码出错的时候, 即使这个接口非常的简单, 模板内部复杂的实现细节也会在出错信息显示. 导致这个编译出错信息看起来非常难以理解。
  • 模板如果使用不当,会导致运行时代码过度膨胀。
  • 模板代码难以修改和重构。模板的代码会在很多上下文里面扩展开来, 所以很难确认重构对所有的这些展开的代码有用。所以, 建议模板编程最好只用在少量的基础组件,基础数据结构上面。并且使用模板编程的时候尽可能把复杂度最小化,尽量不要让模板对外暴露。最好只在实现里面使用模板, 然后给用户暴露的接口里面并不使用模板, 这样能提高你的接口的可读性。 并且你应该在这些使用模板的代码上写尽可能详细的注释。

在C++语言中,我们强烈建议尽可能少使用复杂的宏

  • 对于常量定义,请按照前面章节所述,使用const或者枚举;
  • 对于宏函数,尽可能简单,并且遵循下面的原则,并且优先使用内联函数,模板函数等进行替换。
  1. // 不推荐使用宏函数
  2. #define SQUARE(a, b) ((a) * (b))
  3. // 请使用模板函数,内联函数等来替换。
  4. template<typename T> T Square(T a, T b) { return a * b; }

如果需要使用宏,请参考C语言规范的相关章节。例外:一些通用且成熟的应用,如:对 new, delete 的封装处理,可以保留对宏的使用。