现代C++特性

随着 ISO 在2011年发布 C++11 语言标准,以及2017年3月发布 C++17 ,现代C++(C++11/14/17等)增加了大量提高编程效率、代码质量的新语言特性和标准库。本章节描述了一些可以帮助团队更有效率的使用现代C++,规避语言陷阱的指导意见。

代码简洁性和安全性提升

建议10.1.1 合理使用auto

理由

  • auto可以避免编写冗长、重复的类型名,也可以保证定义变量时初始化。
  • auto类型推导规则复杂,需要仔细理解。
  • 如果能够使代码更清晰,继续使用明确的类型,且只在局部变量使用auto示例
  1. // 避免冗长的类型名
  2. std::map<string, int>::iterator iter = m.find(val);
  3. auto iter = m.find(val);
  4. // 避免重复类型名
  5. class Foo {...};
  6. Foo* p = new Foo;
  7. auto p = new Foo;
  8. // 保证初始化
  9. int x; // 编译正确,没有初始化
  10. auto x; // 编译失败,必须初始化

auto 的类型推导可能导致困惑:

  1. auto a = 3; // int
  2. const auto ca = a; // const int
  3. const auto& ra = a; // const int&
  4. auto aa = ca; // int, 忽略 const 和 reference
  5. auto ila1 = { 10 }; // std::initializer_list<int>
  6. auto ila2{ 10 }; // std::initializer_list<int>
  7. auto&& ura1 = x; // int&
  8. auto&& ura2 = ca; // const int&
  9. auto&& ura3 = 10; // int&&
  10. const int b[10];
  11. auto arr1 = b; // const int*
  12. auto& arr2 = b; // const int(&)[10]

如果没有注意 auto 类型推导时忽略引用,可能引入难以发现的性能问题:

  1. std::vector<std::string> v;
  2. auto s1 = v[0]; // auto 推导为 std::string,拷贝 v[0]

如果使用auto定义接口,如头文件中的常量,可能因为开发人员修改了值,而导致类型发生变化。

规则10.1.1 在重写虚函数时请使用override关键字

理由override关键字保证函数是虚函数,且重写了基类的虚函数。如果子类函数与基类函数原型不一致,则产生编译告警。

如果修改了基类虚函数原型,但忘记修改子类重写的虚函数,在编译期就可以发现。也可以避免有多个子类时,重写函数的修改遗漏。

示例

  1. class Base {
  2. public:
  3. virtual void Foo();
  4. void Bar();
  5. };
  6. class Derived : public Base {
  7. public:
  8. void Foo() const override; // 编译失败: derived::Foo 和 base::Foo 原型不一致,不是重写
  9. void Foo() override; // 正确: derived::Foo 重写 base::Foo
  10. void Bar() override; // 编译失败: base::Bar 不是虚函数
  11. };

总结

  • 基类首次定义虚函数,使用virtual关键字
  • 子类重写基类虚函数,使用override关键字
  • 非虚函数,virtualoverride都不使用

规则10.1.2 使用delete关键字删除函数

理由相比于将类成员函数声明为private但不实现,delete关键字更明确,且适用范围更广。

示例

  1. class Foo {
  2. private:
  3. // 只看头文件不知道拷贝构造是否被删除
  4. Foo(const Foo&);
  5. };
  6. class Foo {
  7. public:
  8. // 明确删除拷贝赋值函数
  9. Foo& operator=(const Foo&) = delete;
  10. };

delete关键字还支持删除非成员函数

  1. template<typename T>
  2. void Process(T value);
  3. template<>
  4. void Process<void>(void) = delete;

规则10.1.3 使用nullptr,而不是NULL或0

理由长期以来,C++没有一个代表空指针的关键字,这是一件很尴尬的事:

  1. #define NULL ((void *)0)
  2. char* str = NULL; // 错误: void* 不能自动转换为 char*
  3. void(C::*pmf)() = &C::Func;
  4. if (pmf == NULL) {} // 错误: void* 不能自动转换为指向成员函数的指针

如果把NULL被定义为00L。可以解决上面的问题。

或者在需要空指针的地方直接使用0。但这引入另一个问题,代码不清晰,特别是使用auto自动推导:

  1. auto result = Find(id);
  2. if (result == 0) { // Find() 返回的是 指针 还是 整数?
  3. // do something
  4. }

0字面上是int类型(0Llong),所以NULL0都不是指针类型。当重载指针和整数类型的函数时,传递NULL0都调用到整数类型重载的函数:

  1. void F(int);
  2. void F(int*);
  3. F(0); // 调用 F(int),而非 F(int*)
  4. F(NULL); // 调用 F(int),而非 F(int*)

另外,sizeof(NULL) == sizeof(void*)并不一定总是成立的,这也是一个潜在的风险。

总结: 直接使用00L,代码不清晰,且无法做到类型安全;使用NULL无法做到类型安全。这些都是潜在的风险。

nullptr的优势不仅仅是在字面上代表了空指针,使代码清晰,而且它不再是一个整数类型。

nullptrstd::nullptr_t类型,而std::nullptr_t可以隐式的转换为所有的原始指针类型,这使得nullptr可以表现成指向任意类型的空指针。

  1. void F(int);
  2. void F(int*);
  3. F(nullptr); // 调用 F(int*)
  4. auto result = Find(id);
  5. if (result == nullptr) { // Find() 返回的是 指针
  6. // do something
  7. }

建议10.1.2 使用using而非typedef

C++11之前,可以通过typedef定义类型的别名。没人愿意多次重复std::map<uint32_t, std::vector<int>>这样的代码。

  1. typedef std::map<uint32_t, std::vector<int>> SomeType;

类型的别名实际是对类型的封装。而通过封装,可以让代码更清晰,同时在很大程度上避免类型变化带来的散弹式修改。在C++11之后,提供using,实现声明别名(alias declarations):

  1. using SomeType = std::map<uint32_t, std::vector<int>>;

对比两者的格式:

  1. typedef Type Alias; // Type 在前,还是 Alias 在前
  2. using Alias = Type; // 符合'赋值'的用法,容易理解,不易出错

如果觉得这点还不足以切换到using,我们接着看看模板别名(alias template):

  1. // 定义模板的别名,一行代码
  2. template<class T>
  3. using MyAllocatorVector = std::vector<T, MyAllocator<T>>;
  4. MyAllocatorVector<int> data; // 使用 using 定义的别名
  5. template<class T>
  6. class MyClass {
  7. private:
  8. MyAllocatorVector<int> data_; // 模板类中使用 using 定义的别名
  9. };

typedef不支持带模板参数的别名,只能"曲线救国":

  1. // 通过模板包装 typedef,需要实现一个模板类
  2. template<class T>
  3. struct MyAllocatorVector {
  4. typedef std::vector<T, MyAllocator<T>> type;
  5. };
  6. MyAllocatorVector<int>::type data; // 使用 typedef 定义的别名,多写 ::type
  7. template<class T>
  8. class MyClass {
  9. private:
  10. typename MyAllocatorVector<int>::type data_; // 模板类中使用,除了 ::type,还需要加上 typename
  11. };

规则10.1.4 禁止使用std::move操作const对象

从字面上看,std::move的意思是要移动一个对象。而const对象是不允许修改的,自然也无法移动。因此用std::move操作const对象会给代码阅读者带来困惑。在实际功能上,std::move会把对象转换成右值引用类型;对于const对象,会将其转换成const的右值引用。由于极少有类型会定义以const右值引用为参数的移动构造函数和移动赋值操作符,因此代码实际功能往往退化成了对象拷贝而不是对象移动,带来了性能上的损失。

错误示例:

  1. std::string gString;
  2. std::vector<std::string> gStringList;
  3. void func() {
  4. const std::string myString = "String content";
  5. gString = std::move(myString); // bad:并没有移动myString,而是进行了复制
  6. const std::string anotherString = "Another string content";
  7. gStringList.push_back(std::move(anotherString)); // bad:并没有移动anotherString,而是进行了复制
  8. }

智能指针

建议10.2.1 优先使用智能指针而不是原始指针管理资源

理由避免资源泄露。

示例

  1. void Use(int i) {
  2. auto p = new int {7}; // 不好: 通过 new 初始化局部指针
  3. auto q = std::make_unique<int>(9); // 好: 保证释放内存
  4. if (i > 0) {
  5. return; // 可能 return,导致内存泄露
  6. }
  7. delete p; // 太晚了
  8. }

例外在性能敏感、兼容性等场景可以使用原始指针。

规则10.2.1 优先使用unique_ptr而不是shared_ptr

理由

  • shared_ptr引用计数的原子操作存在可测量的开销,大量使用shared_ptr影响性能。
  • 共享所有权在某些情况(如循环依赖)可能导致对象永远得不到释放。
  • 相比于谨慎设计所有权,共享所有权是一种诱人的替代方案,但它可能使系统变得混乱。

规则10.2.2 使用std::make_unique而不是new创建unique_ptr

理由

  • make_unique提供了更简洁的创建方式
  • 保证了复杂表达式的异常安全示例
  1. // 不好:两次出现 MyClass,重复导致不一致风险
  2. std::unique_ptr<MyClass> ptr(new MyClass(0, 1));
  3. // 好:只出现一次 MyClass,不存在不一致的可能
  4. auto ptr = std::make_unique<MyClass>(0, 1);

重复出现类型可能导致非常严重的问题,且很难发现:

  1. // 编译正确,但new和delete不配套
  2. std::unique_ptr<uint8_t> ptr(new uint8_t[10]);
  3. std::unique_ptr<uint8_t[]> ptr(new uint8_t);
  4. // 非异常安全: 编译器可能按如下顺序计算参数:
  5. // 1. 分配 Foo 的内存,
  6. // 2. 构造 Foo,
  7. // 3. 调用 Bar,
  8. // 4. 构造 unique_ptr<Foo>.
  9. // 如果 Bar 抛出异常, Foo 不会被销毁,产生内存泄露。
  10. F(unique_ptr<Foo>(new Foo()), Bar());
  11. // 异常安全: 调用函数不会被打断.
  12. F(make_unique<Foo>(), Bar());

例外std::make_unique不支持自定义deleter。在需要自定义deleter的场景,建议在自己的命名空间实现定制版本的make_unique。使用new创建自定义deleterunique_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_uniquestd::make_shared不支持定制deleter

Lambda

建议10.3.1 当函数不能工作时选择使用lambda(捕获局部变量,或编写局部函数)

理由函数无法捕获局部变量或在局部范围内声明;如果需要这些东西,尽可能选择lambda,而不是手写的functor。另一方面,lambdafunctor不会重载;如果需要重载,则使用函数。如果lambda和函数都可以的场景,则优先使用函数;尽可能使用最简单的工具。

示例

  1. // 编写一个只接受 int 或 string 的函数
  2. // -- 重载是自然的选择
  3. void F(int);
  4. void F(const string&);
  5. // 需要捕获局部状态,或出现在语句或表达式范围
  6. // -- lambda 是自然的选择
  7. vector<Work> v = LotsOfWork();
  8. for (int taskNum = 0; taskNum < max; ++taskNum) {
  9. pool.Run([=, &v] {...});
  10. }
  11. pool.Join();

规则10.3.1 非局部范围使用lambdas,避免使用按引用捕获

理由非局部范围使用lambdas包括返回值,存储在堆上,或者传递给其它线程。局部的指针和引用不应该在它们的范围外存在。lambdas按引用捕获就是把局部对象的引用存储起来。如果这会导致超过局部变量生命周期的引用存在,则不应该按引用捕获。

示例

  1. // 不好
  2. void Foo() {
  3. int local = 42;
  4. // 按引用捕获 local.
  5. // 当函数返回后,local 不再存在,
  6. // 因此 Process() 的行为未定义!
  7. threadPool.QueueWork([&]{ Process(local); });
  8. }
  9. // 好
  10. void Foo() {
  11. int local = 42;
  12. // 按值捕获 local。
  13. // 因为拷贝,Process() 调用过程中,local 总是有效的
  14. threadPool.QueueWork([=]{ Process(local); });
  15. }

建议10.3.2 如果捕获this,则显式捕获所有变量

理由在成员函数中的[=]看起来是按值捕获。但因为是隐式的按值获取了this指针,并能够操作所有成员变量,数据成员实际是按引用捕获的,一般情况下建议避免。如果的确需要这样做,明确写出对this的捕获。

示例

  1. class MyClass {
  2. public:
  3. void Foo() {
  4. int i = 0;
  5. auto Lambda = [=]() { Use(i, data_); }; // 不好: 看起来像是拷贝/按值捕获,成员变量实际上是按引用捕获
  6. data_ = 42;
  7. Lambda(); // 调用 use(42);
  8. data_ = 43;
  9. Lambda(); // 调用 use(43);
  10. auto Lambda2 = [i, this]() { Use(i, data_); }; // 好,显式指定按值捕获,最明确,最少的混淆
  11. }
  12. private:
  13. int data_ = 0;
  14. };

建议10.3.3 避免使用默认捕获模式

理由lambda表达式提供了两种默认捕获模式:按引用(&)和按值(=)。默认按引用捕获会隐式的捕获所有局部变量的引用,容易导致访问悬空引用。相比之下,显式的写出需要捕获的变量可以更容易的检查对象生命周期,减小犯错可能。默认按值捕获会隐式的捕获this指针,且难以看出lambda函数所依赖的变量是哪些。如果存在静态变量,还会让阅读者误以为lambda拷贝了一份静态变量。因此,通常应当明确写出lambda需要捕获的变量,而不是使用默认捕获模式。

错误示例

  1. auto func() {
  2. int addend = 5;
  3. static int baseValue = 3;
  4. return [=]() { // 实际上只复制了addend
  5. ++baseValue; // 修改会影响静态变量的值
  6. return baseValue + addend;
  7. };
  8. }

正确示例

  1. auto func() {
  2. int addend = 5;
  3. static int baseValue = 3;
  4. return [addend, baseValue = baseValue]() mutable { // 使用C++14的捕获初始化拷贝一份变量
  5. ++baseValue; // 修改自己的拷贝,不会影响静态变量的值
  6. return baseValue + addend;
  7. };
  8. }

参考:《Effective Modern C++》:Item 31: Avoid default capture modes.

接口

建议10.4.1 不涉及所有权的场景,使用T*或T&作为参数,而不是智能指针

理由

  • 只在需要明确所有权机制时,才通过智能指针转移或共享所有权.
  • 通过智能指针传递,限制了函数调用者必须使用智能指针(如调用者希望传递this)。
  • 传递共享所有权的智能指针存在运行时的开销。示例
  1. // 接受任何 int*
  2. void F(int*);
  3. // 只能接受希望转移所有权的 int
  4. void G(unique_ptr<int>);
  5. // 只能接受希望共享所有权的 int
  6. void G(shared_ptr<int>);
  7. // 不改变所有权,但需要特定所有权的调用者
  8. void H(const unique_ptr<int>&);
  9. // 接受任何 int
  10. void H(int&);
  11. // 不好
  12. void F(shared_ptr<Widget>& w) {
  13. // ...
  14. Use(*w); // 只使用 w -- 完全不涉及生命周期管理
  15. // ...
  16. };

建议10.4.2 在接口层面明确指针不会为nullptr

理由

  • 避免解引用空指针的错误。
  • 避免重复检查空指针,提高代码效率。建议使用gsl::not_null,或参考实现自己的版本(如使用NullObject模式)。对于集合,在不违反已有的接口约定的情况下,建议返回空集合而避免返回空指针,当返回字符串时,建议返回空串""

示例

  1. int Length(const char* p); // 不清楚 length == nullptr 是否是有效的
  2. Length(nullptr); // 可以吗?
  3. int Length(not_null<const char*> p); // 更好:可以认为 p 不会是空指针
  4. int Length(const char* p); // 必须假设 p 可能是空指针

通过在代码中表明意图(指针不能为空),工具可以提供更好的诊断,如通过静态分析找到一些错误。也可以实现代码优化,如移除判空的测试和分支。

注意not_nullguideline support library(gsl)提供。