现代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; // int
const auto ca = a; // const int
const auto& ra = a; // const int&
auto aa = ca; // int, 忽略 const 和 reference
auto 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::Foo
void 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 定义的别名,多写 ::type
template<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*);
// 只能接受希望转移所有权的 int
void G(unique_ptr<int>);
// 只能接受希望共享所有权的 int
void G(shared_ptr<int>);
// 不改变所有权,但需要特定所有权的调用者
void H(const unique_ptr<int>&);
// 接受任何 int
void 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)
提供。