- 现代C++特性
- 代码简洁性和安全性提升
- 建议10.1.1 合理使用auto
- 规则10.1.1 在重写虚函数时请使用override关键字
- 规则10.1.2 使用delete关键字删除函数
- 规则10.1.3 使用nullptr,而不是NULL或0
- 建议10.1.2 使用using而非typedef
- 规则10.1.4 禁止使用std::move操作const对象
- 智能指针
- 建议10.2.1 优先使用智能指针而不是原始指针管理资源
- 规则10.2.1 优先使用unique_ptr而不是shared_ptr
- 规则10.2.2 使用std::make_unique而不是new创建unique_ptr
- 规则10.2.3 使用std::make_shared而不是new创建shared_ptr
- Lambda
- 建议10.3.1 当函数不能工作时选择使用lambda(捕获局部变量,或编写局部函数)
- 规则10.3.1 非局部范围使用lambdas,避免使用按引用捕获
- 建议10.3.2 如果捕获this,则显式捕获所有变量
- 建议10.3.3 避免使用默认捕获模式
- 接口
- 建议10.4.1 不涉及所有权的场景,使用T*或T&作为参数,而不是智能指针
- 建议10.4.2 在接口层面明确指针不会为nullptr
- 代码简洁性和安全性提升
现代C++特性
随着 ISO 在2011年发布 C++11 语言标准,以及2017年3月发布 C++17 ,现代C++(C++11/14/17等)增加了大量提高编程效率、代码质量的新语言特性和标准库。本章节描述了一些可以帮助团队更有效率的使用现代C++,规避语言陷阱的指导意见。
代码简洁性和安全性提升
建议10.1.1 合理使用auto
理由
auto可以避免编写冗长、重复的类型名,也可以保证定义变量时初始化。auto类型推导规则复杂,需要仔细理解。- 如果能够使代码更清晰,继续使用明确的类型,且只在局部变量使用
auto。示例
// 避免冗长的类型名std::map<string, int>::iterator iter = m.find(val);auto iter = m.find(val);// 避免重复类型名class Foo {...};Foo* p = new Foo;auto p = new Foo;// 保证初始化int x; // 编译正确,没有初始化auto x; // 编译失败,必须初始化
auto 的类型推导可能导致困惑:
auto a = 3; // intconst auto ca = a; // const intconst auto& ra = a; // const int&auto aa = ca; // int, 忽略 const 和 referenceauto ila1 = { 10 }; // std::initializer_list<int>auto ila2{ 10 }; // std::initializer_list<int>auto&& ura1 = x; // int&auto&& ura2 = ca; // const int&auto&& ura3 = 10; // int&&const int b[10];auto arr1 = b; // const int*auto& arr2 = b; // const int(&)[10]
如果没有注意 auto 类型推导时忽略引用,可能引入难以发现的性能问题:
std::vector<std::string> v;auto s1 = v[0]; // auto 推导为 std::string,拷贝 v[0]
如果使用auto定义接口,如头文件中的常量,可能因为开发人员修改了值,而导致类型发生变化。
规则10.1.1 在重写虚函数时请使用override关键字
理由override关键字保证函数是虚函数,且重写了基类的虚函数。如果子类函数与基类函数原型不一致,则产生编译告警。
如果修改了基类虚函数原型,但忘记修改子类重写的虚函数,在编译期就可以发现。也可以避免有多个子类时,重写函数的修改遗漏。
示例
class Base {public:virtual void Foo();void Bar();};class Derived : public Base {public:void Foo() const override; // 编译失败: derived::Foo 和 base::Foo 原型不一致,不是重写void Foo() override; // 正确: derived::Foo 重写 base::Foovoid Bar() override; // 编译失败: base::Bar 不是虚函数};
总结
- 基类首次定义虚函数,使用
virtual关键字 - 子类重写基类虚函数,使用
override关键字 - 非虚函数,
virtual和override都不使用
规则10.1.2 使用delete关键字删除函数
理由相比于将类成员函数声明为private但不实现,delete关键字更明确,且适用范围更广。
示例
class Foo {private:// 只看头文件不知道拷贝构造是否被删除Foo(const Foo&);};class Foo {public:// 明确删除拷贝赋值函数Foo& operator=(const Foo&) = delete;};
delete关键字还支持删除非成员函数
template<typename T>void Process(T value);template<>void Process<void>(void) = delete;
规则10.1.3 使用nullptr,而不是NULL或0
理由长期以来,C++没有一个代表空指针的关键字,这是一件很尴尬的事:
#define NULL ((void *)0)char* str = NULL; // 错误: void* 不能自动转换为 char*void(C::*pmf)() = &C::Func;if (pmf == NULL) {} // 错误: void* 不能自动转换为指向成员函数的指针
如果把NULL被定义为0或0L。可以解决上面的问题。
或者在需要空指针的地方直接使用0。但这引入另一个问题,代码不清晰,特别是使用auto自动推导:
auto result = Find(id);if (result == 0) { // Find() 返回的是 指针 还是 整数?// do something}
0字面上是int类型(0L是long),所以NULL和0都不是指针类型。当重载指针和整数类型的函数时,传递NULL或0都调用到整数类型重载的函数:
void F(int);void F(int*);F(0); // 调用 F(int),而非 F(int*)F(NULL); // 调用 F(int),而非 F(int*)
另外,sizeof(NULL) == sizeof(void*)并不一定总是成立的,这也是一个潜在的风险。
总结: 直接使用0或0L,代码不清晰,且无法做到类型安全;使用NULL无法做到类型安全。这些都是潜在的风险。
nullptr的优势不仅仅是在字面上代表了空指针,使代码清晰,而且它不再是一个整数类型。
nullptr是std::nullptr_t类型,而std::nullptr_t可以隐式的转换为所有的原始指针类型,这使得nullptr可以表现成指向任意类型的空指针。
void F(int);void F(int*);F(nullptr); // 调用 F(int*)auto result = Find(id);if (result == nullptr) { // Find() 返回的是 指针// do something}
建议10.1.2 使用using而非typedef
在C++11之前,可以通过typedef定义类型的别名。没人愿意多次重复std::map<uint32_t, std::vector<int>>这样的代码。
typedef std::map<uint32_t, std::vector<int>> SomeType;
类型的别名实际是对类型的封装。而通过封装,可以让代码更清晰,同时在很大程度上避免类型变化带来的散弹式修改。在C++11之后,提供using,实现声明别名(alias declarations):
using SomeType = std::map<uint32_t, std::vector<int>>;
对比两者的格式:
typedef Type Alias; // Type 在前,还是 Alias 在前using Alias = Type; // 符合'赋值'的用法,容易理解,不易出错
如果觉得这点还不足以切换到using,我们接着看看模板别名(alias template):
// 定义模板的别名,一行代码template<class T>using MyAllocatorVector = std::vector<T, MyAllocator<T>>;MyAllocatorVector<int> data; // 使用 using 定义的别名template<class T>class MyClass {private:MyAllocatorVector<int> data_; // 模板类中使用 using 定义的别名};
而typedef不支持带模板参数的别名,只能"曲线救国":
// 通过模板包装 typedef,需要实现一个模板类template<class T>struct MyAllocatorVector {typedef std::vector<T, MyAllocator<T>> type;};MyAllocatorVector<int>::type data; // 使用 typedef 定义的别名,多写 ::typetemplate<class T>class MyClass {private:typename MyAllocatorVector<int>::type data_; // 模板类中使用,除了 ::type,还需要加上 typename};
规则10.1.4 禁止使用std::move操作const对象
从字面上看,std::move的意思是要移动一个对象。而const对象是不允许修改的,自然也无法移动。因此用std::move操作const对象会给代码阅读者带来困惑。在实际功能上,std::move会把对象转换成右值引用类型;对于const对象,会将其转换成const的右值引用。由于极少有类型会定义以const右值引用为参数的移动构造函数和移动赋值操作符,因此代码实际功能往往退化成了对象拷贝而不是对象移动,带来了性能上的损失。
错误示例:
std::string gString;std::vector<std::string> gStringList;void func() {const std::string myString = "String content";gString = std::move(myString); // bad:并没有移动myString,而是进行了复制const std::string anotherString = "Another string content";gStringList.push_back(std::move(anotherString)); // bad:并没有移动anotherString,而是进行了复制}
智能指针
建议10.2.1 优先使用智能指针而不是原始指针管理资源
理由避免资源泄露。
示例
void Use(int i) {auto p = new int {7}; // 不好: 通过 new 初始化局部指针auto q = std::make_unique<int>(9); // 好: 保证释放内存if (i > 0) {return; // 可能 return,导致内存泄露}delete p; // 太晚了}
例外在性能敏感、兼容性等场景可以使用原始指针。
规则10.2.1 优先使用unique_ptr而不是shared_ptr
理由
shared_ptr引用计数的原子操作存在可测量的开销,大量使用shared_ptr影响性能。- 共享所有权在某些情况(如循环依赖)可能导致对象永远得不到释放。
- 相比于谨慎设计所有权,共享所有权是一种诱人的替代方案,但它可能使系统变得混乱。
规则10.2.2 使用std::make_unique而不是new创建unique_ptr
理由
make_unique提供了更简洁的创建方式- 保证了复杂表达式的异常安全示例
// 不好:两次出现 MyClass,重复导致不一致风险std::unique_ptr<MyClass> ptr(new MyClass(0, 1));// 好:只出现一次 MyClass,不存在不一致的可能auto ptr = std::make_unique<MyClass>(0, 1);
重复出现类型可能导致非常严重的问题,且很难发现:
// 编译正确,但new和delete不配套std::unique_ptr<uint8_t> ptr(new uint8_t[10]);std::unique_ptr<uint8_t[]> ptr(new uint8_t);// 非异常安全: 编译器可能按如下顺序计算参数:// 1. 分配 Foo 的内存,// 2. 构造 Foo,// 3. 调用 Bar,// 4. 构造 unique_ptr<Foo>.// 如果 Bar 抛出异常, Foo 不会被销毁,产生内存泄露。F(unique_ptr<Foo>(new Foo()), Bar());// 异常安全: 调用函数不会被打断.F(make_unique<Foo>(), Bar());
例外std::make_unique不支持自定义deleter。在需要自定义deleter的场景,建议在自己的命名空间实现定制版本的make_unique。使用new创建自定义deleter的unique_ptr是最后的选择。
规则10.2.3 使用std::make_shared而不是new创建shared_ptr
理由使用std::make_shared除了类似std::make_unique一致性等原因外,还有性能的因素。std::shared_ptr管理两个实体:
- 控制块(存储引用计数,
deleter等) - 管理对象
std::make_shared创建std::shared_ptr,会一次性在堆上分配足够容纳控制块和管理对象的内存。而使用std::shared_ptr<MyClass>(new MyClass)创建std::shared_ptr,除了new MyClass会触发一次堆分配外,std::shard_ptr的构造函数还会触发第二次堆分配,产生额外的开销。
例外类似std::make_unique,std::make_shared不支持定制deleter
Lambda
建议10.3.1 当函数不能工作时选择使用lambda(捕获局部变量,或编写局部函数)
理由函数无法捕获局部变量或在局部范围内声明;如果需要这些东西,尽可能选择lambda,而不是手写的functor。另一方面,lambda和functor不会重载;如果需要重载,则使用函数。如果lambda和函数都可以的场景,则优先使用函数;尽可能使用最简单的工具。
示例
// 编写一个只接受 int 或 string 的函数// -- 重载是自然的选择void F(int);void F(const string&);// 需要捕获局部状态,或出现在语句或表达式范围// -- lambda 是自然的选择vector<Work> v = LotsOfWork();for (int taskNum = 0; taskNum < max; ++taskNum) {pool.Run([=, &v] {...});}pool.Join();
规则10.3.1 非局部范围使用lambdas,避免使用按引用捕获
理由非局部范围使用lambdas包括返回值,存储在堆上,或者传递给其它线程。局部的指针和引用不应该在它们的范围外存在。lambdas按引用捕获就是把局部对象的引用存储起来。如果这会导致超过局部变量生命周期的引用存在,则不应该按引用捕获。
示例
// 不好void Foo() {int local = 42;// 按引用捕获 local.// 当函数返回后,local 不再存在,// 因此 Process() 的行为未定义!threadPool.QueueWork([&]{ Process(local); });}// 好void Foo() {int local = 42;// 按值捕获 local。// 因为拷贝,Process() 调用过程中,local 总是有效的threadPool.QueueWork([=]{ Process(local); });}
建议10.3.2 如果捕获this,则显式捕获所有变量
理由在成员函数中的[=]看起来是按值捕获。但因为是隐式的按值获取了this指针,并能够操作所有成员变量,数据成员实际是按引用捕获的,一般情况下建议避免。如果的确需要这样做,明确写出对this的捕获。
示例
class MyClass {public:void Foo() {int i = 0;auto Lambda = [=]() { Use(i, data_); }; // 不好: 看起来像是拷贝/按值捕获,成员变量实际上是按引用捕获data_ = 42;Lambda(); // 调用 use(42);data_ = 43;Lambda(); // 调用 use(43);auto Lambda2 = [i, this]() { Use(i, data_); }; // 好,显式指定按值捕获,最明确,最少的混淆}private:int data_ = 0;};
建议10.3.3 避免使用默认捕获模式
理由lambda表达式提供了两种默认捕获模式:按引用(&)和按值(=)。默认按引用捕获会隐式的捕获所有局部变量的引用,容易导致访问悬空引用。相比之下,显式的写出需要捕获的变量可以更容易的检查对象生命周期,减小犯错可能。默认按值捕获会隐式的捕获this指针,且难以看出lambda函数所依赖的变量是哪些。如果存在静态变量,还会让阅读者误以为lambda拷贝了一份静态变量。因此,通常应当明确写出lambda需要捕获的变量,而不是使用默认捕获模式。
错误示例
auto func() {int addend = 5;static int baseValue = 3;return [=]() { // 实际上只复制了addend++baseValue; // 修改会影响静态变量的值return baseValue + addend;};}
正确示例
auto func() {int addend = 5;static int baseValue = 3;return [addend, baseValue = baseValue]() mutable { // 使用C++14的捕获初始化拷贝一份变量++baseValue; // 修改自己的拷贝,不会影响静态变量的值return baseValue + addend;};}
参考:《Effective Modern C++》:Item 31: Avoid default capture modes.
接口
建议10.4.1 不涉及所有权的场景,使用T*或T&作为参数,而不是智能指针
理由
- 只在需要明确所有权机制时,才通过智能指针转移或共享所有权.
- 通过智能指针传递,限制了函数调用者必须使用智能指针(如调用者希望传递
this)。 - 传递共享所有权的智能指针存在运行时的开销。示例
// 接受任何 int*void F(int*);// 只能接受希望转移所有权的 intvoid G(unique_ptr<int>);// 只能接受希望共享所有权的 intvoid G(shared_ptr<int>);// 不改变所有权,但需要特定所有权的调用者void H(const unique_ptr<int>&);// 接受任何 intvoid H(int&);// 不好void F(shared_ptr<Widget>& w) {// ...Use(*w); // 只使用 w -- 完全不涉及生命周期管理// ...};
建议10.4.2 在接口层面明确指针不会为nullptr
理由
- 避免解引用空指针的错误。
- 避免重复检查空指针,提高代码效率。建议使用
gsl::not_null,或参考实现自己的版本(如使用NullObject模式)。对于集合,在不违反已有的接口约定的情况下,建议返回空集合而避免返回空指针,当返回字符串时,建议返回空串""。
示例
int Length(const char* p); // 不清楚 length == nullptr 是否是有效的Length(nullptr); // 可以吗?int Length(not_null<const char*> p); // 更好:可以认为 p 不会是空指针int Length(const char* p); // 必须假设 p 可能是空指针
通过在代码中表明意图(指针不能为空),工具可以提供更好的诊断,如通过静态分析找到一些错误。也可以实现代码优化,如移除判空的测试和分支。
注意not_null由guideline support library(gsl)提供。
