• 现代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示例
    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)提供。