基于 Effective C++: 42 Specific Ways to Improve Your Use of C++11 and C++14 编写

推断类型

理解模板类型推断

在模板类型推导中,引用参数被视为非引用

对通用引用 && 参数,左值参数特别对待

在按值传递参数时,constvolatile 参数被视为非 const 和非 volatile

在模板类型推导中,数组或函数名参数退化为指针,除非被用于初始化引用

理解 auto 类型推断

auto 通常和模板类型推断一样,但是 auto 类型推断将大括号初始化 {} 视为 std::initializer_list,模板类型推断不这样

函数返回值中的 auto 或 lambda 参数是函数模板推断,不是 auto 类型推断

理解 decltype

decltype 在没有任何修改的情况下,几乎总是生成变量或表达式的正确类型

对于不同于名字的类型为 T 的左值表达式,decltype 总是返回 T&

decltype(auto) 类似于 auto,从初始化推断类型,但是使用了 decltype 规则推断类型

知道如何观察推断出的类型

  • IDE
  • 编译器错误信息
  • boost/type_indextype_id_with_cvr<decltype(param)>().pretty_name()

但某些工具的结果可能不准确

auto

倾向于 auto 而不是显式类型声明

  • auto 变量必须初始化
  • 可以用于防止一些未知类型的问题
  • 减轻重构过程
  • 打的字较少

auto 推断出不想要的类型时,使用显式类型初始化习语

不可见的代理类型可能导致 auto 推断出“错误”的类型,如 std::vector<bool> 返回的是一个代理

显式类型初始化习语强迫 auto 推断想要的类型:auto index = static_cast<int>(d * c.size())

移动到现代 C++

区分 (){} 创建对象

大括号初始化可以广泛应用于初始化中,可以防止狭窄的转换,还能够防止 C++ 中逆天的语法设计:

Widget w1; // 默认构造函数
Widget w2{}; // 同上
Widget w3(); // 函数声明!

但是大括号初始化几乎一定会匹配为 std::initializer_list 参数对应的构造函数,尽管明显有更好的匹配

std::vector<int> 初始化使用圆括号和花括号效果不同

在模板中选择是小括号还是大括号用于对象创建更具有挑战性

倾向于 nullptr 而不是 0NULL

因为后两者本质上都是一个整数 0,只有前者是一个特殊的指针 std::nullptr_t,可以隐式转化为任意指针类型

当然,为了兼容,还是应该避免重载整数和指针

同样,在模板推断中,后两者都会被推断为整数类型,从而造成麻烦

倾向于使用别名声明而不是 typedef

typedef 不支持模板化,但是别名声明可以,如

template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
MyAllocList<T> list; // 使用时不需要 typename

别名模板避免 ::type 后缀,同时在模板中,typedef 通常需要 typename 前缀

C++14 对所有 C++11 的类型特征转化提供了别名模板,例如

std::remove_const<T>::type
// 相当于
std::remove_const_t<T>

倾向于有作用域的 enum 而不是无作用域的 enum

C++98 风格的是无作用域的 enum,如 enum Color

有作用域的 enum 的枚举成员只在 enum 中可见,必须显示 cast 才能转换为其他类型

有作用域的和无作用域的 enum 都支持潜在类型规范,默认的有作用域的 enum 的潜在类型是 int,而无作用域的没有默认潜在类型

有作用域的总是可以前向声明,无作用域的只有在声明规范了一个潜在的类型时才可以前向声明(否则编译器无法得知要分配多少空间)

倾向于删除函数而不是私有未定义的

非成员函数也可以被删除,可以防止函数参数的隐式转换,如:

bool isLuck(int number);
bool isLuck(double) = delete; // 拒绝 double 和 float

特定的模板实例也可以被删除

声明覆盖的函数为 override

成员函数引用限定符可以不同地对待左值和右值对象

倾向于 const_iterator 而不是 iterator

在最大化泛型代码中,倾向于 begin 等的非成员函数版本,如

template <class C>
auto cbegin(const C& container) -> decltype(std::begin(container))
{
return std::begin(container);
}

如果某函数不会发出异常,则声明其为 noexcept

noexcept 是函数接口的一部分,意味着调用者依赖于它

noexcept 函数比非 noexcept 函数更优化

noexcept 特别有价值,如移动操作、swap、内存重新分配函数和析构函数

大部分函数的异常中立而不是 noexcept

尽可能使用 constexpr

constexpr 对象同时是 const 的且用编译时已知的值初始化

constexpr 函数可以在当编译时已知的参数时,产生编译时结果

constexpr 对象和函数可能在比非 constexpr 对象和函数更广泛的上下文有用

使 const 成员函数线程安全

除非确定不会用于并发上下文中

使用 std::atomic 变量可能比 mutex 效率更改,但是只能用于操作一个变量或内存地址

理解特殊成员函数生成

编译器会自动生成的特殊成员函数:默认构造函数,析构函数,复制操作和移动操作

移动操作只会在没有显式声明移动操作、复制操作和析构函数的情况下才会生成

复制构造函数只会在没有显式声明复制构造函数的情况下生成,如果声明了移动操作,则会删除。

复制赋值操作只会在缺少显式声明的复制赋值操作的情况下生成,如果声明了移动操作,则会删除。

有显式声明的析构函数的类复制操作的生成被弃用

成员函数模板不会影响特殊成员函数的生成

智能指针

std::unique_ptr 用于互斥所有权的资源管理

std::unique_ptr 小、块、只能移动

默认通过 delete 资源析构,但是可以指定自定义删除器。有状态的删除器和函数指针作为删除器都会增加 std::unique_ptr 对象的大小

std::unique_ptr 转化为 std::shared_ptr 很容易

std::shared_ptr 用于共享所有权的资源管理

std::shared_ptr 提供了接近垃圾收集的方便方法

相比于 std::unique_ptrstd::shared_ptr 两倍大,同时还有有控制块的开销,还需要引用计数操作

默认资源析构通过 delete,也可以自定义删除器。删除器的类型对 std::shared_ptr 没有影响

避免从原始指针类型创建 std::shared_ptr

st::weak_ptr 用于 shared_ptr 类似的可能悬垂的指针

可能的用法:

缓存:

std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) {
static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) {
objPtr = loadWidget(id);
cache[id] = objPtr;
}
return objPtr;
}

观察者列表

防止 std::shared_ptr 循环

倾向于 std::make_uniquestd::make_shared 而不是直接使用 new

相比直接使用 newmake 函数减少了代码重复,提高了安全性,同时对 std::make_sharedstd::allocate_shared 来说,生成的代码更小更快

make 函数不适用的情况是当想要自定义删除器或者传递大括号初始化值时

对于 std::shared_ptr,额外的 make 函数可能不推荐的情况是:

  • 自定义内存管理的类
  • 关心内存的系统,非常大的对象,std::weak_ptrstd::shared_ptr 活得长

使用 Pimpl 习语时,在实现文件中定义特殊成员函数

Pimpl 习语通过减少类客户和类实现的编译依赖来减少构建时间

对于 std::unique_ptr pImpl 指针,在函数头中声明成员函数,但是在实现文件中实现。即使默认函数实现是可接受的也要这么做

以上建议适用于 std::unique_ptr,但是不适用于 std::shared_ptr

右值引用,移动语义和完美转发

理解 std::movestd::forward

std::move 执行无条件转化为右值,实际上不会移动任何东西

std::forward 只有在参数被绑定到右值时才会将参数转化为右值

std::movestd::forward 在运行时都不会做任何事

区分通用引用和右值引用

如果寒素模板参数对推断的类型 T 有类型 T&&,或者一个对象使用 auto&& 被推断,则该参数或对象是通用引用

如果类型声明的形式不是 type&&,或者不发生参数推断,则 type&& 代表右值引用

通用引用只有在被右值初始化时才会关联到右值引用;如果以左值初始化,则关联到左值引用

对右值引用使用 std::move,对通用引用使用 std::forward

在最后一次使用时,对右值引用使用 std::move,对通用引用使用 std::forward

按值返回时也一样

不要对局部变量使用 std::movestd::forward,因为其已经执行了返回值优化

避免对通用引用重载

对通用引用的重载通常会导致通用引用比预期的更频繁调用

完美转发的构造函数特别有问题,因为其通常对非 const 左值的匹配比复制构造函数更好,且会劫持派生类对基类调用复制和移动构造函数

熟悉重载通用引用的替代

方法一:放弃重载,将函数命名不同

方法二:按 const T& 传递参数

方法三:按值传递参数

方法四:使用标签派遣

即通过一个标签来区分函数,如:

template<typename T>
void logAndAdd(T&& name) {
logAndAddImpl(
std::forward<T>(name),
std::is_integral<std::remove_reference_t<T>>()
);
}

template<typename T>
void logAndAddImpl(T&& name, std::false_type) { // 非整数参数
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

void logAndAddImpl(int idx, std::true_type) { // 传递的是一个整数
logAndAdd(nameFromIdx(idx));
}

方法五:限制接受通用引用的模板

通过 std::enable_if 控制了编译器使用通用引用重载的条件

class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n)
}

通用引用参数通常有效率的优势,但又有缺乏可用性的劣势

理解引用合并

引用合并出现在以下上下文中:

  • 模板实例化
  • auto 类型生成
  • typedef 和别名声明创建和使用
  • decltype

当编译器在引用合并的上下文中生成对引用的引用时,结果变成一个引用

  • 如果原始引用中有左值引用,则结果是左值引用
  • 否则,是右值引用

在类型推断区分从右值的左值和引用合并发生时,通用引用时右值引用

假设移动操作不存在,不便宜,不被使用

移动操作并不一定是高效的,因为其可能会不存在,不便宜,不被使用

但是在已知类型或支持移动语义的代码中,不需要这些假设

熟悉完美转发失败的情况

当模板类型推断失败或推断出了错误的类型时,完美转发失败

大括号初始化

fwd({1, 2, 3}); // 失败
auto il = {1, 2, 3};
fwd(il); // 成功

0NULL 指针:替换为 nullptr

只声明的整数 static const 数据成员

Widget.h
class Widget {
public:
static const std::size_t MinVals = 28; // 声明
};
fwd(Widget::MinVals); // 错误

需要在 .cpp 文件中定义:

Widget.cpp
const std::size_t Widget::MinVals;

重载的函数名称和模板名称

int processVal(int value);
int processVal(int value, int priority);
fwd(processVal); // 错误,不知道是哪个 processVal

using ProcessFuncType = int(*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr); // 成功

比特域

struct IPv4Header {
std::uint32_t version 16, totalLength: 16;
} h;
fwd(h.totalLength); // 错误

因为 fwd 的参数是一个引用,不可以将一个非 const 引用绑定到一个比特域(毕竟所谓的“引用”类似于指针,不可以指向某个位)

解决方法是复制一份:

auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // 转发复制

Lambda 表达式

  • Lambda 表达式是一个表达式
  • 闭包是由 Lambda 在运行时创建的一个对象
  • 闭包类是闭包从其实例化的类

避免默认捕捉模式

默认通过引用捕捉可能会导致悬垂引用

默认通过值捕捉容许悬垂指针(特别是 this),且会让人误解 Lambda 是自我包含的

使用初始化捕捉来将对象移动到闭包中

需要指明:

  • 数据成员的名称
  • 用于初始化的表达式
auto pw = std::make_unique<Widget>();
auto func = [pw = std::move(pw)] {
return pw->isValidated() && pw->isArchived();
};

auto&& 参数使用 decltypestd::forward 它们

auto f = [](auto&& param) {
return func(normalize(std::forward<decltype(param)>(param)));
};

倾向于 lambda 而不是 std::bind

lambda 更可读、更有表现力、可能比使用 std::bind 更高效

并发 API

倾向于基于 task 的编程而不是基于 thread 的

因为 std::thread API 无法直接获取异步运行函数的返回值,且如果抛出了异常,程序终止

基于线程的编程要求手动管理线程枯竭,认购超额,负载平衡,适配新平台

基于 task 的编程可以通过 std::async 的默认启动策略处理绝大部分问题

使用 thread 的一些特殊的情况:

  • 需要使用底层的线程实现 API
  • 需要为应用优化线程使用
  • 需要实现除 C++ 并发 API 的线程技术

如果异步是必须的,则指定 std::launch::async

std::async 的默认启动策略即允许异步也允许同步 task 执行

这种灵活性会导致访问 thread_local 的不确定性,造成 task 可能永远不会执行,影响程序对基于超时的 wait 调用的逻辑

使 std::thread 在所有路径上都不可合并

C++ 标准没有提供相关的 RAII,是因为:

  • 析构时 join 可能导致难以调试的效率异常
  • 析构时 detach 可能导致难以调试未定义行为

对于自己定义的 RAII 包装,在数据成员列表的最后声明 std::thread 对象,保证先包装,后执行

了解不同的线程处理析构函数的行为

future 的析构函数通常只析构 future 的数据成员

最终 future 引用一个非延迟的 task 的共享状态,直到 task 完成

给一次性事件通信考虑 void future

对于简单的事件通信,基于条件变量的设计需要多余的 mutex,对相关的检测和反应 task 的过程强加了限制,需要反应 task 验证事件已经发生了

使用 flag 的设计避免了这些问题,但是是基于 polling 的,而不是 blocking

condvar 和 flag 可以一起使用,但是造成的通信机制有些僵硬

使用 std::promise 和 future 可以避免这些问题,但是该方法使用堆内存来共享状态,且受限于一次性通信

std::atomic 用于并发,volatile 用于特殊的存储

std::atomic 用于在不使用 mutex 的情况下,多个线程访问的数据,是编写并发软件的工具

volatile 是用于读和写不应该被优化的内存,是处理特殊内存的工具

Tweaks

对于移动和总是要被复制的可复制参数,考虑按值传递

void addName(std::string newName) { names.push_back(std::move(newName)); }

几乎和按引用传递一样高效,更容易实现,且生成更少的对象代码。当然,代价就是一定会多一次 std::move()

通过构造复制参数可能比通过赋值复制明显更高效

按值传递会有切片问题,尤其是对基类参数类型不适合时

考虑 emplace 而不是插入

原则上,emplace 系列的函数应该有时比插入的对应部分更高效,应该永远不会更低效

实际上,只有满足以下条件时,才会更快:

  • 添加的值被构造到容器中,而不是被赋值
  • 传递的参数类型和容器持有的类型不同
  • 容器不会因重复而拒绝添加的值

放置函数可能执行会被插入函数拒绝的类型转换