C++ 其他特性
常量与初始化
不变的值更易于理解、跟踪和分析,所以应该尽可能地使用常量代替变量,定义值的时候,应该把const作为默认的选项。
建议9.1.1 不允许使用宏来表示常量
说明:宏是简单的文本替换,在预处理阶段时完成,运行报错时直接报相应的值;跟踪调试时也是显示值,而不是宏名;宏没有类型检查,不安全;宏没有作用域。
#define MAX_MSISDN_LEN 20 // 不好
// C++请使用const常量
const int kMaxMsisdnLen = 20; // 好
// 对于C++11以上版本,可以使用constexpr
constexpr 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
// 这里不准备处理内存耗尽的异常,简单地将函数声明为noexcept
std::vector<int> MyComputation(const std::vector<int>& v) noexcept {
std::vector<int> res = v; // 可能会抛出异常
// do something
return res;
}
示例
RetType Function(Type params) noexcept; // 最大的优化
RetType Function(Type params); // 更少的优化
// std::vector 的 move 操作需要声明 noexcept
class Foo1 {
public:
Foo1(Foo1&& other); // no noexcept
};
std::vector<Foo1> a1;
a1.push_back(Foo1());
a1.push_back(Foo1()); // 触发容器扩张,搬移已有元素时调用copy constructor
class 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 的封装处理,可以保留对宏的使用。