0%

虽然lambda表达式只是C++11中的语法糖,但它对C++编程的影响是巨大的。没有lambda,STL中的”_if”算法(诸如std::find_ifstd::remove_ifstd::count_if等)通常局限于最平凡的谓语;但有了lambda,我们就可以方便地写出复杂的谓语来配合这些算法了。类似的例子也发生在需要比较函数的STL算法上,例如std::sortstd::nth_elementstd::lower_bound等。STL之外,我们可以通过lambda快速地为std::unique_ptrstd::shared_ptr写出自定义的销毁器,为线程API的条件变量写出条件谓语。标准库之外,lambda也允许我们快速完成一个回调函数、接口适配函数,以及只在一处调用的上下文相关函数。

澄清两个名词:

  • “lambda表达式”就是一个表达式,是下面代码中的加粗部分:

    std::find_if(container.begin(), container.end(), [](int val) { return 0 < val && val < 10; });

  • closure(闭包)是通过lambda创建的一个运行时对象。根据不同的捕获模式,closure持有被捕获数据的拷贝或引用。在上面的例子中,在运行时我们通过lambda表达式创建了一个closure并作为第三个参数传给了std::find_if

  • closure class(闭包类)是一个closure的实现类。编译器会为每个lambda表达式生成一个唯一的closure class,lambda表达式中的代码会成为这个类的成员函数的可执行代码。

lambda通常用于一次使用的场景。但closure通常是可复制的,因此一个lambda表达式可能会对应着多个closure,这些closure的类型是相同的:

1
2
3
4
5
6
int x;
...
auto c1 = [x](int y) { return x * y > 55; };
auto c2 = c1;
auto c3 = c2;
...

Item31: 避免用默认捕获模式

C++11中有两种默认捕获模式:引用模式和值模式。默认的引用模式会导致孤悬引用。默认的值模式会让你以为自己可以避免这个问题(实际上没有),以为你的closure是自包含的(不一定)。

引用模式下closure会包含它所在作用域的局部变量和参数的引用,但如果这个closure的生命期长过这些局部变量和参数,它包含的这些引用就成了孤悬引用。一个例子:

1
2
3
4
5
6
7
8
9
using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;

void addDivisorFilter() {
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back([&](int value) { return value % divisor == 0; }); // danger! ref to divisor will dangle!
}

如果显式捕获divisor的引用,问题仍然存在:

1
filters.emplace_back([&divisor](int value) { return value % divisor == 0; });

但相比默认捕获模式,现在我们更容易发现这里的问题。

有时候我们知道一个closure只在当前作用域范围内使用,不会传播出去,是不是用默认捕获模式就是安全的呢?例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename C>
void workWithContainer(const C& container) {
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);

using ContElemT = typename C::value_type;
using std::begin;
using std::end;

if (std::all_of(
begin(container), end(container),
[&](const ContElemT& value) { return value % divisor == 0; })) {
...
) else {
...
}
}

这段代码本身没什么问题,但你没办法保证不会有人把这段代码拷贝到其它地方,没注意这里有个默认的引用捕获,结果出现孤悬引用。

长期来看,显式列出引用捕获的变量更好。

题外话,C++14允许我们用auto来修饰lambda的参数,令代码更简洁:

1
2
3
if (std::all_of(
begin(container), end(container),
[&](const auto& value) { return value % divisor == 0; }))

应对上面问题的一种方法是用默认的值捕获模式:

1
filters.emplace_back([=](int value) { return value % divisor == 0; });

但这并不是解决孤悬引用的万能良药。如果你值模式捕获了一个指针,结果还是一样的。

有人会认为用智能指针就能避免这个问题。看这个例子:

1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
...
void addFilter() const;
private:
int divisor;
};

void Widget::addFilter() const {
filters.emplace_back([=](int value) { return value % divisor == 0; });
}

这段代码只能说大错特错。

捕获只会发生在lambda所在的作用域的非static的局部变量上(包括参数)。在Widget::addFilter中,divisor不是局部变量,它不能被捕获。如果把默认捕获去掉,直接用[],代码就编译不过去了。如果我们显式写[divisor],仍然编译不过去。

但上面这段代码为什么可以编译成功?因为它捕获了this。下面是它的等价代码:

1
2
3
4
5
6
7
8
void Widget::addFilter() const {
auto currentObjectPtr = this;
filters.emplace_back(
[currentObjectPtr](int value) {
return value % currentObjectPtr->divisor == 0;
}
);
}

现在我们回过头来看智能指针的情况:

1
2
3
4
5
6
7
8
using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;

void doSomeWork() {
auto pw = std::make_unique<Widget>();
pw->addFilter();
...
}

std::unique_ptr也改变不了我们捕获了一个孤悬的this指针的结局。

正确做法是什么?将成员变量拷贝一份为局部变量,再捕获进去:

1
2
3
4
5
6
7
8
void Widget::addFilter() const {
auto divisorCopy = divisor;
filters.emplace_back(
[divisorCopy] (int value) {
return value % divisorCopy == 0;
}
);
}

在此基础上,如果你真的想用默认的值捕获模式,也可以接受。但为什么要冒这个险呢?如果不用默认捕获,我们早就可以发现divisor是成员变量不可捕获了。

C++14中,更好的方式是用泛型lambda捕获(见Item32):

1
2
3
4
5
6
7
void Widget::addFilter() const {
filters.emplace_back(
[divisor = divisor] (int value) {
return value % divisor == 0;
}
);
}

默认值捕获模式的另一个缺点是它让我们以为closure是自包含的,但它却不能确保这点。因为closure不光依赖于局部变量,还会依赖静态存储区的对象。这些对象可以在lambda中使用,但无法被捕获:

1
2
3
4
5
6
7
void addDivisorFilter() {
static auto calc1 = computeSomeValue1();
static auto calc2 = computeSomeValue2();
static auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back([=](int value) { return value % divisor == 0; }); // captures nothing! refers to above static
++divisor;
}

粗心的读者会被[=]误导,以为所有变量都被捕获了。但实际上什么都没有被捕获。当调用++divisor时,addDivisorFilter创建的所有closure中的divisor都增加了。

这些问题通过显式捕获都可以提前发现,而用了默认捕获模式,却被藏了起来,等到上线时再boom。

Item32: 使用初始化捕获来将对象移动到closure中

有时候我们想把一个对象移动到closure中,比如一个只能移动的对象(std::unique_ptrstd::future),或是移动的代价远小于复制的对象(比如大多数的STL容器),这个时候默认的引用捕获和值捕获都无法做到。C++14提供了一种方式,叫“初始化捕获”,能满足这一需求。C++11无法直接实现,但后面会介绍一种间接实现的方式。

C++标准委员会没有选择添加一种默认的移动捕获模式,而是增加“初始化捕获”,是因为后者的使用方式非常灵活,移动捕获只是它能做到的一件事情,事实上它几乎能做到其它捕获模式能做的所有事情。

初始化捕获能让你指定:

  1. (closure class中)数据成员的名字。
  2. 这个数据成员的初始化表达式。

一个例子:

1
2
3
4
5
class Widget;
...
auto pw = std::make_unique<Widget>();
... // confiture *pw
auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); };

pw = std::move(pw)中,=左边的是数据成员的名字,它的作用域就是这个closure;右边是它的初始化式,它的作用域就是closure所在的作用域。

注意看有注释那行,如果在lambda前不需要修改*pw,就可以省掉这个变量,直接放到初始化捕获式中:

1
auto func = [pw = std::make_unique<Widget>()] {...};

如果你想在C++11中实现移动捕获,该怎么做?

一种做法是自己把closure class写出来:

1
2
3
4
5
6
7
8
9
10
11
class IsValAndArch {
public:
using DataType = std::unique_ptr<Widget>;
explicit IsValAndArch(DataType&& ptr): pw(std::move(ptr)) {}
bool operator() const {
return pw->isValidataed() && pw->isArchived();
}
private:
DataType pw;
};
auto func = IsValAndArch(std::make_unique<Widget>());

但这种方法需要写的代码太多了,有没有简单一点的呢?还真有,就是使用std::bind结合lambda,我们需要做的是:

  1. 将对象移动的结果放到std::bind创建的函数对象中。
  2. 令lambda接受上面这个“被捕获”的对象的引用。

初始化捕获版本:

1
2
3
std::vector<double> data;
...
auto func = [data = std::move(data)] {...};

std::bind+lambda版本:

1
2
3
4
5
6
std::vector<double> data;
...
auto func = std::bind(
[](const std::vector<double>& data) {...},
std::move(data)
);

函数对象中会保存每个参数的拷贝,我们使用了std::move,因此是移动生成了data的拷贝。之后lambda接受这个拷贝的const引用,就可以达到类似初始化捕获的效果了。

默认情况下closure class的operator()会被认为是const,因此我们在lambda中无法修改捕获的对象。这时我们可以给lambda添加上mutable标识符,令它可以修改捕获的对象:

1
2
3
4
auto func = std::bind(
[](std::vector<double>& data) mutable {...},
std::move(data)
);

上面的第二个例子同样可以用std::bind+lambda实现:

1
2
3
4
auto func = std::bind(
[](const std::unique_ptr<Widget>& pw) {...},
std::make_unique<Widget>()
);

Item33: 在auto&&类型的参数上使用decltype从而进行完美转发

C++14的一项引入注目的新功能就是泛型lambda,即lambda的参数可以用auto来修饰。它的实现很直接:closure class的operator()是个模板函数。给定下面的lambda:

1
auto f = [](auto x) {return normalize(x);};

对应的closure class的operator()为:

1
2
3
4
5
6
7
8
class SomeClosureClass {
public:
template <typename T>
auto operator()(T x) const {
return normalize(x);
}
...
};

上面的例子中,如果normalize处理左值参数和右值参数的方式上有区别,那么我们写的还不算对,应该用上完美转发。这么需要对代码做两处修改:

  1. x需要是一个普适引用。
  2. normalize的实参要使用std::forward

大致上代码需要改成这个样子:

1
auto f = [](auto&& x) { return normalize(std::forward<???>(x)); };

这里的问题就是std::forward的实例化类型是什么。通常的完美转发我们能有一个模板参数T,但在泛型lambda中我们只有auto。closure class的模板函数中有这个T,但我们没办法用上它。

Item28解释了左值参数传给普适引用后变成左值引用,而右值参数传给普适引用后变成右值引用。我们要的就是这个效果,而这就是decltype能给我们的(参见Item3)。

Item28中同样解释了当右值参数传给普适引用后,我们得到的T是无引用的,而delctype(x)是带右值引用的,这会影响std::forward吗?

std::forward的实现:

1
2
3
4
template <typename T>
T&& forward(remove_reference_T<T>& param) {
return static_cast<T&&>(param);
}

T替换为Widget,得到:

1
2
3
Widget&& forward(Widget& param) {
return static_cast<Widget&&>(param);
}

T替换为Widget&&,得到:

1
2
3
Widget&& && forward(Widget& param) {
return static_cast<Widget&& &&>(param);
}

应用引用折叠,得到:

1
2
3
Widget&& forward(Widget& param) {
return static_cast<Widget&&>(param);
}

TWidget的版本完全一样!这说明decltype就是我们想要的。

因此我们的完美转发版本的最终代码为:

1
2
3
auto f = [](auto&& x) {
return normalize(std::forward<decltype(x)(x)>);
};

C++14同样支持变长的泛型lambda:

1
2
3
auto f = [](auto&&... xs) {
return normalize(std::forward<decltype(xs)>(xs)...);
};

Item34: 优先使用lambda而不是std::bind

std::bind实际上早在TR1时已经进入C++标准库,那时候它还是std::tr1::bind。总之很多人已经用了它很多年了,要放弃它并不容易。但C++11中,lambda在绝大多数场景中都要比std::bind好,而在C++14k,这种优势还在变大。

倾向于用lambda的最主要原因是lambda更易读。首先是背景代码:

1
2
3
4
using Time = std::chrono::steady_clock::time_point;
enum class Sound {Beep, Siren, Whistle};
using Duration = std::chrono::steady_clock::duration;
void setAlarm(Time t, Sound s, Duration d);

lambda版本:

1
2
3
4
auto setSoundL = [](Sound s) {
using namespace std::chrono;
setAlarm(steady_clock::now() + hours(1), s, seconds(30));
};

看起来就像是个非常正常的函数,其中我们很清晰的看到了setAlarm是如何被调用的。C++14中我们可以用一些字面值让代码更易懂:

1
2
3
4
5
auto setSoundL = [](Sound s) {
using namespace std::chrono;
using namespace std::literals;
setAlarm(steady_clock::now() + 1h, s, 30s);
};

std::bind版本:

1
2
3
4
using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders; // needed for use of "_1"
auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);

一个不熟悉std::bind的用户可能很难发现setAlarm是在哪被调用的,_1看起来也很奇怪,更不好理解std::bind的第二个参数为什么是setAlarm的第一个参数。

上面说的都是可读性上的问题,但在正确性上std::bind版本也有些问题。在lambda版本中,我们知道steady_clock::now() + 1hsetAlarm的参数,它会在调用setAlarm时被求值。但在std::bind中,这个表达式是std::bind的参数,它是在我们生成bind对象时就被求值了,而此时我们还不知道什么时候才会调用setAlarm

Fix方案就是把std::bind中的表达式继续用std::bind拆开,直到这个表达式的每项操作都是用std::bind表示的:

1
2
3
4
5
6
7
8
9
auto setSoundB = std::bind(
setAlarm,
std::bind(
std::plus<>(),
std::bind(steady_clock::now),
1h),
_1,
30s
);

如你所见,有点丑。题外话,这里的std::plus<>是C++14新增的语法,即标准操作符模板的模板类型可以省略。在C++11中,不支持这种语法,必须写成std::plus<steady_clock::time_point>

如果setAlarm还有重载版本,新问题又产生了。假设另一个版本是:

1
void setAlarm(Time t, Sound s, Duration d, Volume v);

对于lambda版本来说,工作正常,因为重载决议会选出正确的版本。而对于std::bind版本来说,编译会失败,编译器不知道该用哪个版本,它得到的只有一个函数名字,而这个名字本身是二义的。为了让std::bind能使用正确的版本,我们需要显式转换:

1
2
3
4
5
6
7
8
9
10
11
using SetAlarm3ParamType = void (*)(Time, Sound, Duration);
auto setSoundB = std::bind(
static_cast<SetAlarm3ParamType>(setAlarm),
std::bind(
std::plus<>(),
std::bind(steady_clock::now),
1h
),
_1,
30s
);

接下来是性能问题。setSoundL的背后是一个closure class,lambda函数体就是它的operator()函数,因此我们调用setSoundL就是在调用一个对象的定义体的函数,而我们知道这种函数是可以内联的。而std::bind的内部保存了setAlarm的函数指针,后面会用这个函数指针来调用setAlarm,这种调用方式很难有机会内联。这就产生了一些性能差异。(不知道这里为什么没有提lambda和std::bind在保存捕获的变量时的方式不同对性能的影响,如果捕获的都是栈上结构,lambda可以不涉及内存分配,而std::bind一定会有内存分配)

下一个差异在于我们用lambda可以很轻松写出临时用的短函数,而用std::bind就很困难:

1
2
3
4
5
6
7
8
9
auto betweenL = [lowVal, highVal](const auto& val) {
return lowVal <= val && val <= highVal;
};

auto betweenB = std::bind(
std::logical_and<>(),
std::bind(std::less_equal<>(), lowVal, _1),
std::bind(std::less_equal<>(), _1, highVal)
);

C++11下两个版本要长一点:

1
2
3
4
5
6
7
8
9
auto betweenL = [lowVal, highVal](int val) {
return lowVal <= val && val <= highVal;
};

auto betweenB = std::bind(
std::logical_and<bool>(),
std::bind(std::less_equal<int>(), lowVal, _1),
std::bind(std::less_equal<int>(), _1, highVal)
);

怎么看起来都是lambda版本更清爽。

接下来的差异是,我们很难搞清楚std::bind中参数是如何传递的。

1
2
3
4
5
6
enum class CompLevel {Low, Normal, High};
Widget compress(const Widget& w, CompLevel lev);

Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);

上面这段代码中,为了把w传给compress,我们要把w保存到bind对象中,但它是怎么保存的?值还是引用?答案是值(可以用std::refstd::cref来传引用),但知道答案的唯一方式就是熟悉std::bind是如何工作的,而在lambda中,变量的捕获方式是明明白白写在那的。

另一个问题是当我们调用bind对象时,它的参数是如何传给底层函数的?即_1是传值还是传引用?答案是传引用,因为std::bind会使用完美转发。

以上几种差异说明了C++14下lambda在各方面几乎完爆std::bind。但在C++11中,std::bind有两项本领是lambda做不到的:

  • 移动捕获:参见Item32。

  • 多态函数对象:std::bind会完美转发它的参数,因此它可以接受任意类型的参数,因此它可以绑定一个模板函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class PolyWidget {
    public:
    template<typename T>
    void operator()(const T& param) const;
    ...
    };

    PolyWidget pw;
    auto boundPW = std::bind(pw, _1);

    boundPW(1930);
    boundPW(nullptr);
    boundPW("Rosebud");

    C++11的lambda无法做到这点。但C++14的可以:

    1
    auto boundPW = [pw](const auto& param) {pw(param);};

目录

问题

下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <string>

template <typename T>
class Base {
public:
virtual const std::string& Method() const = 0;
};

template <typename T>
class Base2: public Base<T> {
public:
void Func() const {
std::cout << "Func:" << Method() << std::endl;
}
};

class Derived: public Base2<double> {
public:
const std::string& Method() const {
static const std::string s("Derived");
return s;
}
};

int main() {
Derived d;
d.Func();
}

在gcc4.1.2下编译会有这样的错误:

1
2
3
test.cpp: In member function ‘void Base2<T>::Func() const’:
test.cpp:14: error: there are no arguments to ‘Method’ that depend on a template parameter, so a declaration of ‘Method’ must be available
test.cpp:14: error: (if you use ‘-fpermissive’, G++ will accept your code, but allowing the use of an undeclared name is deprecated)

原因

C++在对模板类和模板函数进行名字查找时,会分成两次进行:

  1. 对于与模板参数无关的名字,或称无依赖名字,编译器会在看到这个模板的定义时查找名字。
  2. 对于与模板参数有关的名字,或称有依赖名字,编译器会推迟检查,直到模板实例化时再查找名字。

在我们的例子中,Method与模板参数无关,因此是无依赖名字,编译器会在看到Base2定义时查找名字。因为Base是个模板类,在这次查找时还没有实例化,因此编译器不会去Base中查找Method,只会在Base2的定义体中及外围作用域查找Method

上面的例子中,如果Base不是模板类,而是普通类:

1
2
3
4
class Base {
public:
virtual const std::string& Method() const = 0;
};

你会发现编译就正常了。

错误解法1:指定基类类型调用

假如我们调用Method时指定基类类型,这样Method就变成有依赖名字了,是否可行?

1
2
3
4
5
6
7
template <typename T>
class Base2: public Base<T> {
public:
void Func() const {
std::cout << "Func:" << Base<T>::Method() << std::endl;
}
};

编译正常,运行一下:

1
2
3
/tmp/ccgToaEX.o: In function `Base2<double>::Func() const':
test.cpp:(.text._ZNK5Base2IdE4FuncEv[Base2<double>::Func() const]+0x12): undefined reference to `Base<double>::Method() const'
collect2: ld returned 1 exit status

为什么?因为我们指定了Method的类型,对Method的调用就是静态绑定,没有了动态绑定的效果,我们运行的Method就是基类版本,也就是没有定义的那个版本。

正确解法1:using基类中的名字

我们可以手动using基类中的名字,让名字查找时能看到Method,这样还表明MethodBase<T>中的成员,也就意味着Method依赖T,它就是一个有依赖名字,会推迟到实例化时再查找。这样我们仍然能让Method的调用是动态绑定:

1
2
3
4
5
6
7
8
template <typename T>
class Base2: public Base<T> {
public:
using Base<T>::Method;
void Func() const {
std::cout << "Func:" << Method() << std::endl;
}
};

编译正常,运行结果:

1
Func:Derived

完成!

正确解法2:使用this调用

另一种解法是显式使用this,这样也可以将Method变成有依赖名字:

1
2
3
4
5
6
7
template <typename T>
class Base2: public Base<T> {
public:
void Func() const {
std::cout << "Func:" << this->Method() << std::endl;
}
};

编译正常,运行结果:

1
Func:Derived

完成!

起因

上个月我做了一个实验,对比了C++中几种Callable实现方式的性能。今天我想继续这个实验,给Worker类加一个接受右值的构造函数。

下文中的编译环境都是Apple LLVM version 8.1.0 (clang-802.0.42)。

第一次尝试

我的第一版代码长这样(精简后):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

template <typename HandlerT>
void Test(HandlerT&& h) {
Worker<HandlerT> worker{std::forward<HandlerT>(h)};
}

void Func(int x) {}

int main(int argc, char** argv) {
Test(Func);
}

编译居然出错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜ > g++ -std=c++11 func_pointer_ctor.cpp
func_pointer_ctor.cpp:12:14: error: multiple overloads of 'Worker' instantiate to the same signature 'void (void (&&)(int))'
explicit Worker(HandlerT&& handler) {}
^
func_pointer_ctor.cpp:17:22: note: in instantiation of template class 'Worker<void (&)(int)>' requested here
Worker<HandlerT> worker{std::forward<HandlerT>(h)};
^
func_pointer_ctor.cpp:23:5: note: in instantiation of function template specialization 'Test<void (&)(int)>' requested here
Test(Func);
^
func_pointer_ctor.cpp:11:14: note: previous declaration is here
explicit Worker(const HandlerT& handler) {}
^
1 error generated.

但是把代码中的Test(Func)换成Test([](int) {})就没有问题。看出错信息是说当我传入一个函数时,它实例化出来的Worker的两个构造函数是一样的。

第二次尝试

那么我专门为Func特化一个版本好了:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

using FuncT = void (int);
template <>
struct Worker<FuncT> {
explicit Worker(FuncT func) {}
};
...

这里我专门为void (int)特化了一个Worker版本,只有一个构造函数,这回应该没问题了吧。

编译还是出错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜ > g++ -std=c++11 func_pointer_ctor.cpp
func_pointer_ctor.cpp:12:14: error: multiple overloads of 'Worker' instantiate to the same signature 'void (void (&&)(int))'
explicit Worker(HandlerT&& handler) {}
^
func_pointer_ctor.cpp:23:22: note: in instantiation of template class 'Worker<void (&)(int)>' requested here
Worker<HandlerT> worker{std::forward<HandlerT>(h)};
^
func_pointer_ctor.cpp:29:5: note: in instantiation of function template specialization 'Test<void (&)(int)>' requested here
Test(Func);
^
func_pointer_ctor.cpp:11:14: note: previous declaration is here
explicit Worker(const HandlerT& handler) {}
^
1 error generated.

看出错信息,编译器并没有实例化我们的特化版本,这说明我们特化的类型有问题。仔细看错误信息,其中有Worker<void (&)(int)>,与我们上面用的void (int)不同。

第三次尝试

这回我们把FuncT的类型改为void (&)(int)再试一下:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

using FuncT = void (&)(int);
template <>
struct Worker<FuncT> {
explicit Worker(FuncT func) {}
};
...

这回编译终于没有问题了。现在我想改一下调用Test时传入的参数:

1
2
3
4
5
...
int main(int argc, char** argv) {
auto f = Func;
Test(f);
}

编译居然又出错了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜ > g++ -std=c++11 func_pointer_ctor.cpp
func_pointer_ctor.cpp:12:14: error: multiple overloads of 'Worker' instantiate to the same signature 'void (void (*&&)(int))'
explicit Worker(HandlerT&& handler) {}
^
func_pointer_ctor.cpp:23:22: note: in instantiation of template class 'Worker<void (*&)(int)>' requested here
Worker<HandlerT> worker{std::forward<HandlerT>(h)};
^
func_pointer_ctor.cpp:30:5: note: in instantiation of function template specialization 'Test<void (*&)(int)>' requested here
Test(f);
^
func_pointer_ctor.cpp:11:14: note: previous declaration is here
explicit Worker(const HandlerT& handler) {}
^
1 error generated.

仔细看出错信息,里面的类型和之前的不一样了!现在是Worker<void (*&)(int)>了。

第四次尝试

这回我们再增加一种针对void (*&)(int)的特化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

using RefFuncT = void (&)(int);
template <>
struct Worker<RefFuncT> {
explicit Worker(RefFuncT func) {}
};

using PRefFuncT = void (*&)(int);
template <>
struct Worker<PRefFuncT> {
explicit Worker(PRefFuncT func) {}
};

template <typename HandlerT>
void Test(HandlerT&& h) {
Worker<HandlerT> worker{std::forward<HandlerT>(h)};
}

void Func(int x) {}

int main(int argc, char** argv) {
auto f = Func;
Test(f);
Test(Func);
}

编译成功,两种形式都支持。

函数的类型

Func的类型是void (int),但在模板的类型推断中,传入的Func会退化为它对应的函数指针或函数引用。

Effective Modern C++ Item1中讲到:

另一个会退化为指针的类型是函数。函数会退化为函数指针,且规则与数组相同。例如:

1
2
3
4
5
6
7
8
9
10
void someFunc(int, double);    // someFunc's type is void(int, double)

template <typename T>
void f1(T param);

template <typename T>
void f2(T& param);

f1(someFunc); // T and ParamType are both void (*)(int, double)
f2(someFunc); // T is void(int, double), ParamType is void (&)(int, double)

Test的形参类型是带引用的,因此我们在直接传入Func时得到的HandlerT就是void (&)(int)

auto f = Func中,根据auto的类型推断规则,Func会退化为函数指针,即f的类型是void (*)(int),传入Test后得到的类型是void (*&)(int)。为什么这里多了个引用?见下节。

普适引用、引用折叠与完美转发

为了避免下面的代码太蛋疼,本小节假设有using FuncT = void (int)

什么是引用折叠?参见Effective Modern C++ Item28

普适引用是个例外,C++有单独的规则来把类型推断中出现的引用的引用转换为单个引用,称为“引用折叠”。折叠规则为:

1
2
3
4
T& &   => T&
T& && => T&
T&& & => T&
T&& && => T&&

说来你可能不信,上面的例子里出现了引用折叠。还记得我们一直在处理的编译错误是什么吗?Worker的两个构造函数的原型相同。我们再看一下这两个构造函数:

1
2
3
4
5
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

你可能会觉得奇怪,它们明明是不同的引用类型,为什么最终会变成相同的样子?

当我们调用Test(Func)时,编译器首先要实例化TestFunc的类型在模板类型推断时退化为函数指针,即FuncT*,而在普适引用的类型推断中,有以下规则:

在普适引用的类型推断中,如果实参是左值,那么T就是左值引用;如果实参是右值,那么T没有引用,就是这个类型本身。

因此HandlerT就是FuncT*&。因此我们实例化了Worker<FuncT*&>。代入到上面的两个构造函数,我们得到:

1
2
3
4
struct Worker<FuncT*&> {
explicit Worker(const FuncT*& & handler);
explicit Worker(FuncT*& && handler);
};

再应用引用折叠,我们得到:

1
2
3
4
struct Worker<FuncT*&> {
explicit Worker(const FuncT*& handler);
explicit Worker(FuncT*& handler);
};

一个是const引用,一个是非const引用。我们用于构造Worker的参数h的类型是FuncT*&,不带const,因此编译器不知道该选择哪个函数。

如果实参是const引用

那么h的类型就是const FuncT*&,那么两个构造函数会变成:

1
2
3
4
struct Worker<const FuncT*&> {
explicit Worker(const FuncT*& handler);
explicit Worker(const FuncT*& handler);
};

完全一样了!

如果实参是右值引用

根据上面的普适引用类型推断规则,你可能会认为HandlerTFuncT,两个构造函数的参数类型一个是const FuncT&,一个是FuncT&&,就不会出错了。可是又错了(错误就不列出来了)。原因在于,一个右值引用变量本身是左值,因此HandlerT的类型是FuncT&& &,即FuncT&

注意看上面的规则,“如果实参是右值”而不是“如果实参是右值引用”。

如果实参是右值

这回我们终于得到了一个正确的程序,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

template <typename HandlerT>
void Test(HandlerT&& h) {
Worker<HandlerT> worker{std::forward<HandlerT>(h)};
}

using FuncT = void (int);

void Func(int x) {}

FuncT* Make() {
return Func;
}

int main(int argc, char** argv) {
Test(Make());
}

如果不使用完美转发

如果我们不使用完美转发,即在代码中去掉std::forward,直接使用Worker<HandlerT> worker{h},会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

template <typename HandlerT>
void Test(HandlerT&& h) {
Worker<HandlerT> worker{h};
}

using FuncT = void (int);

void Func(int x) {}

FuncT* Make() {
return Func;
}

int main(int argc, char** argv) {
Test(Make());
}
1
2
➜ > g++ -std=c++11 func_pointer_ctor.cpp
➜ >

编译成功了。但是不是与完美转发版本有相同的效果呢?我们运行两个版本,看一下结果:

1
2
3
4
➜ > ./perfect_forward
rvalue
➜ > ./right_ref
lvalue

有没有很惊讶?去掉了完美转发,我们调用的居然是const引用版本的构造函数。原因很简单,还是“右值引用本身是左值”。

所以结论是什么?

总结一下我们遇到的问题。

我们有一个模板类型Worker,它有两个构造函数,一个接受左值引用,一个接受右值引用:

1
2
3
4
5
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {}
explicit Worker(HandlerT&& handler) {}
};

然而,当模板参数为一个左值引用类型时,这两个构造函数的函数原型会产生冲突:

1
2
3
Handlert        => T&
const HandlerT& => const T& & => const T&
HandlerT&& => T& && => T&

通常我们不会这么实例化模板类,但如果这次实例化是发生在一个完美转发函数中,众所周知,完美转发是要应用在普适引用上的,而普适引用的特性就是,如果实参是左值,形参就会被推断成左值引用。这样我们就很不幸的用左值引用类型来实例化Worker,导致出现上面的问题。

看起来问题出在我们并不希望用一个带引用的类型来实例化Worker,那就把引用去掉。我们可以用std::remove_reference来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template <typename HandlerT>
struct Worker {
explicit Worker(const HandlerT& handler) {
cout << "lvalue" << endl;
}
explicit Worker(HandlerT&& handler) {
cout << "rvalue" << endl;
}
};

template <typename HandlerT>
void Test(HandlerT&& h) {
using T = typename std::remove_reference<HandlerT>::type;
Worker<T> worker{std::forward<T>(h)};
}

using FuncT = void (int);

void Func(int x) {}

FuncT* Make() {
return Func;
}

int main(int argc, char** argv) {
Test(Func);
Test(Make());
}

编译成功,运行一下:

1
2
3
4
➜ > g++ -std=c++11 func_pointer_ctor.cpp
➜ > ./a.out
lvalue
rvalue

非常完美!所以看起来我们得到以下几个结论:

  1. 在用普适引用参数的类型构造一个模板类时,用std::remove_reference去掉它的引用。
  2. 普适引用不是右值引用(参见区分普适引用与右值引用),如果要实现完美转发,记得用std::forward
  3. 谨慎重载普适引用,如果要重载,参考这里,以及,确认你真的调用了预期的重载版本。

Item27: 熟悉重载普适引用的替代方法

放弃重载

对于Item26的第一个例子logAndAdd,一种做法是放弃重载,直接用两个不同的名字,比如logAndAddNamelogAndAddNameIdx。当然这解不了Item26的第二个例子,即Person的构造函数:你总不能改构造函数的名字。

通过const T&传递

另一种做法是回到C++98,传递const T&,也意味着放弃了完美转发。这种方法在效率上是有损失的,但在完美转发和重载之间有矛盾时,损失一些效率来让设计变简单也许更有吸引力一些。

通过值传递

一种不损失效率,又不增加设计复杂度的方法是,直接传值,不传引用。Item41介绍了采用此建议的一种设计。这里我们只是简单看下Person类可以怎么实现:

1
2
3
4
5
6
7
8
9
10
11
class Person {
public:
explicit Person(std::string n)
: name(std::move(n)) {}

explicit Person(int idx)
: name(nameFromIdx(idx)) {}
...
private:
std::string name;
};

(没有效率损失的原因:如果实参是左值,那么实参到形参是一次复制,形参到name是一次移动,相比普适引用只多了一次移动;如果实参是右值,那么实参到形参是一次移动,形参到name还是一次移动,相比普适引用还是只多一次移动,可以认为没有效率损失。)

唯一要注意的是,如果实参是0NULL,会匹配到int版本,原因见Item8。

使用标签分发(Tag dispatch)

普适引用的问题是,在重载决议中,它几乎总是完美匹配的。我们知道重载决议是在所有参数上发生的,那么如果我们人为的增加一个Tag参数,用Tag参数来匹配,就能避免普适引用带来的问题。

首先是原始版本:

1
2
3
4
5
6
7
std::multiset<std::string> names;
template <typename T>
void logAndAdd(T&& name) {
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

然后是一个接近正确的版本:

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

这里的问题在于,当实参是左值时,T会被推导为左值引用,即如果实参类型是int,那么T就是int&std::is_integral<T>()就会返回false。这里我们需要把T可能的引用性去掉:

1
2
3
4
5
6
7
template <typename T>
void logAndAdd(T&& name) {
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}

然后logAndAddImpl提供两个特化版本:

1
2
3
4
5
6
7
8
9
10
11
12
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));
}

std::string nameFromIdx(int idx);
template <typename T>
void logAndAddImpl(T&& name, std::true_type) {
logAndAdd(nameFromIdx(idx));
}

为什么用std::true_type/std::false_type而不用true/false?前者是编译期值,后者是运行时值。

注意这里我们都没有给logAndAddImpl的第二个参数起名字,说明它就是一个Tag。这种方法常用于模板元编程。

重要的是Tag dispatch如何把普适引用和重载结合起来了:通过一个新增的Tag参数,改变原本的重载决议顺序。

限制模板使用普适引用

Tag dispatch的主旨就是存在一个不重载的函数作为入口,它会加上一个Tag参数,再分发给实现函数。但这种方法也没办法解决Item26中Person的构造函数遇到的问题。编译器会自动为类生成复制和移动构造函数,因此你没办法完全控制入口。

注意这里:不是说有时候编译器生成的函数会绕过你的Tag dispatch,而是说它们没有保证经过Tag dispatch。这里你需要的是std::enable_if

std::enable_if可以让一个模板只在条件满足时存在。在Person的例子中,我们希望当传入的参数类型不为Person时完美转发构造函数才存在。例子(注意语法):

1
2
3
4
5
6
class Person {
public:
template <typename T,
typename = typename std::enable_if<condition>::type>
explicit Person(T&& n);
};

std::enable_if只影响模板函数的声明,不影响它的实现。这里我们不深究std::enable_if的细节,只要知道它应用了C++的”SFINAE”特性。

我们要的条件是T不是Person,可以用!std::is_same<Person, T>::value。但这还不够准确,因为由左值初始化而来的普适引用,它的类型会被推断为左值引用,即T&(参见Item28),而T&T是不同的类型。

事实上我们在比较时需要去掉:

  1. 引用:PersonPerson&Person&&都要被认为是Person
  2. constvolatileconst Personvolatile Personconst volatile Person都要被认为是Person

标准库中对应的工具是std::decay,它会把对象身上的引用和cv特性都去掉。它在处理数组和函数类型时会把它们转为指针类型(参见Item1)。

最终结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
public:
template <
typename T,
typename = typename std::enable_if<
!std::is_same<
Person,
typename std::decay<T>::type
>::value>
>::type
>
explicit Person(T&& n);
...
};

对于Person的构造函数,上面的版本已经能解决了:在传入的参数类型为Person时调用我们希望的复制和移动构造函数,而在其它时候调用完美转发函数。

Item26的最后一个例子是Person的派生类SpecialPerson

1
2
3
4
5
6
7
8
9
10
class SpecialPerson: public Persion {
public:
SpecialPerson(const SpecialPerson& rhs) // copy ctor: calls Person forwarding ctor!
: Person(rhs)
{...}

SpecialPerson(SpecialPerson&& rhs) // move ctor: calls Person forwarding ctor!
: Person(std::move(rhs))
{...}
};

看起来还没解决,原因是std::is_same<Person, SpecialPerson>::valuefalse。我们需要的是std::is_base_of。注意当T是自定义类型时,std::is_base_of<T, T>::value返回true,而如果T是内置类型,则返回false。所以我们需要做的就是把上面版本中的std::is_same替换为std::is_base_of

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
public:
template <
typename T,
typename = typename std::enable_if<
!std::is_base_of<
Person,
typename std::decay<T>::type
>::value>
>::type
>
explicit Person(T&& n);
...
};

C++14中代码可以省一点:

1
2
3
4
5
6
7
8
9
10
11
class Person {
public:
template <
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value>
>
>
explicit Person(T&& n);
...
};

还没有结束,最后一个问题:如何区分整数类型和非整数类型。直接看最终版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)
: name(std::forward<T>(n))
{...}

explicit Person(int idx)
: name(nameFromIdx(idx))
{...}
...
private:
std::string name;
};

取舍

本节的前3种方法舍弃了重载普适引用的念头,后2种方法则另辟蹊径在重载函数中使用普适引用。这里需要一个取舍。

使用普适引用,从而使用完美转发,效率上更好。但它的缺点是:

  1. 有些参数类型无法完美转发,参见Item30。
  2. 如果传入参数不正确,错误信息不好理解。

对于缺点2,我们举个例子。假设我们给Person的构造参数传入一个char16_t构成的字符串:

1
Person p(u"Konrad Zuse");

如果用前3种方法,编译器会报错说”no conversion from const char16_t[12] to int or std::string”。

如果用基于完美转发的方法,编译器在转发过程中不会报错,只有到了用转发的参数构造std::string时才会报错。这里的报错信息非常难理解。

有时候系统中的转发不止一次,参数可能跨越多层函数最终到达出错位置。这里我们可以用static_assert来提前发现这类错误:使用std::is_constructible来判断参数是否可以转发下去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)
: name(std::forward<T>(n))
{
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
...
}
...
};

Item28: 理解引用折叠

在介绍引用折叠之前,我们要先知道引用的引用在C++中是非法的:

1
2
3
int x;
...
auto& && rx = x; // error! can't declare reference to reference

以及,在普适引用的类型推断中,如果实参是左值,那么T就是左值引用;如果实参是右值,那么T没有引用,就是这个类型本身:

1
2
3
4
5
6
7
template <typename T>
void func(T&& param);

Widget widgetFactory(); // function returning rvalue
Widget w; // an lvalue
func(w); // T deduced to be Widget&
func(widgetFactory()); // T deduced to be Widget

func(w)中,T的类型是Widget&,那么func的原型就是:

1
void func(Widget& && param);

但引用的引用不是非法的吗?普适引用是个例外,C++有单独的规则来把类型推断中出现的引用的引用转换为单个引用,称为“引用折叠”。折叠规则为:

1
2
3
4
T& &   => T&
T& && => T&
T&& & => T&
T&& && => T&&

引用折叠就是std::forward依赖的关键特性。一个简化的std::forward实现:

1
2
3
4
template <typename T>
T&& forward(typename remove_reference<T>::type& param) {
return static_cast<T&&>(param);
}

假设func的实现中调用了std::forward

1
2
3
4
5
template <typename T>
void f(T&& fParam) {
...
someFunc(std::forward<T>(fParam));
}

f的实参是Widget的左值时,T会被推断为Widget&,实例化的std::forward版本就是std::forward<Widget&>,代入进上面std::forward的实现得到:

1
2
3
Widget& && forward(typename remove_reference<Widget&>::type param) {
return static_cast<Widget& &&>(param);
}

std::remove_reference<Widget&>::type代换为Widget,并应用引用折叠,得到:

1
2
3
Widget&& forward(Widget& param) {
return static_cast<Widget&>(param);
}

由此可见,如果普适引用的实参是个左值,将std::forward应用其上得到的还是个左值。

如果f的实参是右值,那么T就是Widget,对应的std::forward实现是:

1
2
3
Widget&& forward(Widget& param) {
return static_cast<Widget&&>(param);
}

这里没有引用的引用,因此也不涉及引用折叠。函数返回的右值引用会被认为是一个右值,因此最终我们得到了一个右值。

引用折叠会在四种场景中发生:

  1. 模板实例化,也是最常见的场景。
  2. auto的类型推断。
  3. typedef和别名声明(参见Item9)。
  4. decltype的类型推断。

回顾一下,普适引用并不是什么新东西,它就是满足以下两个条件的右值引用:

  1. 类型推断中能区分开左值和右值。
  2. 能发生引用折叠。

Item29: 假设移动操作不存在、不廉价、或没被使用

移动语义可能是C++11最重要的功能,“移动一个容器就像复制一个指针”,“返回临时对象现在很高效,非要避免这么做就是过早优化”。拿C++98的老代码和C++11的STL一起编译,你会发现程序变快了!

但本节是要让你冷静下来。

首先我们可以观察到很多类型还不支持移动。整个C++11的STL做了很多工作来利用移动语义,但可能一些三方库还没有完全按C++11的建议修订完。这些没有针对C++11优化过的代码,基本也不会有性能提升。C++11的编译器只会为没有声明复制操作、移动操作、析构函数的类生成移动函数,还有些类型禁止了移动函数。对于这些没有移动函数的类型,C++11对它们不会有什么帮助。

即使是支持移动的类型,移动带来的收益也没有你想象的大。C++11 STL的所有容器都支持移动,但不是每个容器的移动都很廉价。有些是因为没有廉价的移动手段,有些是需要元素类型支持廉价的移动,容器才能实现廉价的移动。

大部分STL容器,它的数据都是分配在堆上的,例如std::vector,因此它的移动就很廉价:直接移动一个指针。但std::array的数据是直接分配在栈上的,移动时要移动每个元素。假如元素类型的移动比复制更高效,那么std::array的移动也就比复制更高效。

另一个例子,std::string提供了O(1)的移动和O(n)的复制,看起来移动要比复制更快。但很多使用了SSO(small string optimization)的std::string实现的移动就不一定比复制高效了。

即使对支持高效移动的类型来说,有些看起来肯定会应用移动的地方最终调用的却是复制。Item14讲到STL的一些容器为了保证强异常安全性,只有在元素类型支持noexcept的移动时才会移动,否则会复制。

以下几种情况下C++11的移动对你无益:

  • 没有移动操作:会调用复制。
  • 移动不够高效:不比复制高效。
  • 移动不可用:需要noexcept的移动的场合。

以及:

  • 源对象是左值:除了少数情况(见Item25),只有右值可以作为移动的源。

Item30: 熟悉完美转发的失败案例

假设有一个非完美转发的函数f,和它对应的完美转发版本fwd

1
2
3
4
template <typename T>
void fwd(T&& param) {
f(std::forward<T>(param));
}

我们希望以下两行有相同的行为:

1
2
f(expression);
fwd(expression);

但在以下几种情况下,我们会遇到问题。

花括号初始化式

1
2
3
void f(const std::vector<int>& v);
f({1, 2, 3}); // fine, "{1, 2, 3}" implicitly converted to std::vector<int>
fwd({1, 2, 3}); // error! doesn't compile

原因在于,编译器知道f的形参类型,所以它知道可以把实参类型隐式转换为形参类型。但编译器不知道fwd的形参类型,因此需要通过实参进行类型推断。这里完美转发会在发生以下情况时失败:

  • 无法推断出fwd的某个参数类型。
  • 推断出错误类型。这里的“错误”可以是推断出的类型无法实例化fwd,也可以是fwd的行为与f不同。后者的一个可能原因是f是重载函数的名字,推断的类型不对会导致调用错误的重载版本。

fwd({1, 2, 3})这个例子中,问题在于它是一个“未推断上下文”,标准规定禁止推断作为函数参数的花括号初始化式,除非形参类型是std::initializer_list

解决方案很简单,这里我们应用了Item2中提到的一个auto特性:会优先推断接收的表达式为std::initializer_list

1
2
auto il = {1, 2, 3};
fwd(il);

0NULL作为空指针

例子见Item8,结论就是不要用0NULL作为空指针,用nullptr

只有声明的static constconstexpr的整数成员

通常来说我们不需要给类的声明为static constconstexpr的整数成员一个定义,因为编译器会把这些成员直接替换为对应的整数值:

1
2
3
4
5
6
7
8
class Widget {
public:
static constexpr std::size_t MinVals = 28;
...
}; // no def for MinVals
...
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);

如果没有任何地方取MinVals的地址,编译器就没有必要给它安排一块内存,就可以直接替换为整数字面值。否则我们就要给MinVals一个定义,不然程序会在链接阶段出错。

这里完美转发会有问题:

1
2
3
4
void f(std::size_t val);

f(Widget::MinVals); // fine, treated as 28
fwd(Widget::MinVals); // error! shouldn't link

问题在于fwd的参数类型是非const引用,这相当于取了MinVals的地址,因此我们需要给它一个定义:

1
constexpr std::size_t Widget::MinVals;  // in Widget's .cpp file

注意这里就不用给初始值了,否则编译器会报错的。

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

假设f的参数是一个函数:

1
void f(int (*pf)(int));

或者

1
void f(int pf(int));

以及我们有两个重载函数:

1
2
int processVal(int value);
int processVal(int value, int priority);

现在我们把processVal传给f

1
f(processVal);

令人惊讶的是,编译器知道该把processVal的哪个版本传给f。但fwd就不同了:

1
fwd(processVal); // error! which processVal?

因为fwd的参数没有类型,processVal这个名字本身也没能给出一个确定的类型。

模板函数也有这样的问题:

1
2
3
4
template <typename T>
T workOnVal(T param) {...}

fwd(workOnVal); // error! which workOnVal instantiation?

解决方案就是确定下来重载函数名字或模板函数名字对应的函数类型:

1
2
3
4
using ProcessFuncType = int (*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr);
fwd(static_cast<ProcessFuncType>(workOnVal));

位域

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
...
};

void f(std::size_t sz);

IPv4Header h;
...
f(h.totalLength); // fine
fwd(h.totalLength); // error!

问题在于fwd的参数是非const引用,而C++标准禁止创建位域的非const引用。实际上,位域的const引用就是引用一个临时的复制整数。解决方案很简单:把位域的值复制出来,再传入fwd

1
2
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);

目录

初学移动语义和完美转发时,它们看起来非常直接:

  • 移动语义:允许编译器用廉价的移动操作来替换昂贵的复制操作。与用复制构造函数和复制赋值函数来实现复制语义的方式类似,你也可以用移动构造函数和移动赋值函数来实现移动语义。移动语义也允许你设计出只能移动的类型,诸如std::unique_ptrstd::futurestd::thread
  • 完美转发:允许实现一个函数模板,接受任意数量的参数并转发给其它函数,且目标函数接收到的参数恰好与转发函数收到的参数数量相等。

右值引用就是把这两种看起来截然不同的功能联系起来的纽带,它是实现这两者的基础。

你对这两个功能了解的越多,你越会发现上面说的只是它们的冰山一角。移动语义、完美转发、右值引用比它们看起来要微妙得多。例如,std::move不移动任何东西,完美转发也不完美。移动操作不一定比复制要廉价;即使是,也可能没有你想象的廉价;能移动的场景也不一定真的调用了移动操作。type&&不一定代表右值引用。

无论你对这些功能钻研多深,都有你没发现的东西。幸运的是,它们的深度总是有限的。本章会带你探寻它们的基本原理,之后你再看这些功能就会觉得更合理了。

在本章的各节中,很重要的一点是牢记参数永远是一个左值,即使它的类型是右值引用,即:

1
void f(Widget&& w);

w是左值,即使它的类型是右值引用。

Item23: 理解std::movestd::forward

在学习std::movestd::forward时,一种很有用的方法是知道它们不做什么:std::move不移动任何东西,std::forward也不转发任何东西。它们在运行期不做任何事情,它们不产生一丁点可执行的代码。

std::movestd::forward仅仅是进行转换的函数。std::move无条件地将它的参数转换为右值,而std::forward只在某些条件满足时进行这种转换。

下面一个接近标准库实现的std::move实现的例子:

1
2
3
4
5
template <typename T>
typename remove_reference<T>::type&& move(T&& param) {
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}

如你所见,std::move接受一个对象的引用(具体来说,是普适引用,参见Item24)并返回这个对象的另一个引用。

“&&”表示std::move的返回类型是一个右值引用,但参见Item28,如果类型T恰好是左值引用,那么T&&也会变成左值引用,因此我们要在T上应用std::remove_reference来保证std::move一定返回右值引用。

C++14中std::move的实现可以更简单一些:

1
2
3
4
5
tmeplate <typename T>
decltype(auto) move(T&& param) {
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}

一定要牢记std::move做了什么,不做什么:它做了转换,但不做移动。在一个对象上应用std::move就告知了编译器这个对象可以被移动,这就是它叫这个名字的原因:易于标记出可能被移动的对象。

事实上,右值只是通常会被移动。假设你在写一个表示注解的类,它的构造函数接受包含注解的std::string参数,并将其复制给一个成员变量。根据Item41,你声明了一个传值的参数:

1
2
3
4
5
class Annotation {
public:
explicit Annotation(std::string text);
...
};

但构造函数里只需要读取text,根据尽可能用const的古老传统,你给text加上了const

1
2
3
4
5
class Annotation {
public:
explicit Annotation(const std::string text);
...
};

为了避免复制,你依从Item41的建议,在text上应用std::move,产生一个右值:

1
2
3
4
5
6
7
8
9
class Annotation {
public:
explicit Annotation(const std::string text)
: value(std::move(text))
{...}
...
private:
std::string value;
};

上面的代码编译、链接、运行都没问题,只是text没有移动赋值给value,它是复制过去的。text的类型是const string,因此std::move(text)产生的类型为const string&&,因此value的构造没办法应用移动操作,因为const还在。

std::string定义了复制构造函数和移动构造函数:

1
2
3
4
5
6
7
class string {
public:
...
string(const string& rhs);
string(string&& rhs);
...
};

显然const string&&没办法传给string(string&& rhs),但能传给string(const string& rhs)。因此value的构造应用了复制构造函数,即使参数是右值引用!

这里我们学到两点:

  1. 不要把希望移动的变量声明为const
  2. std::move不意味着移动任何东西,甚至不保证它转换的对象可移动。它只保证它的转换结果一定是右值。

std::forwardstd::move很类似,只是std::move是无条件的转换,而std::forward是有条件的转换。回忆std::forward的典型用法,是在接受普适引用参数的函数模板中将参数转发给其它函数:

1
2
3
4
5
6
7
8
9
void process(const Widget& lval);
void process(Widget&& rval);

template <typename T>
void logAndProcess(T&& param) {
auto now = std::chrono::system_clock::now();
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}

我们希望在param类型为左值引用时调用process(const Widget& lval),在param为右值引用时调用process(Widget&& rval)。但param是函数参数,它本身永远是左值。因此我们需要一种方法在条件满足时将其转换为右值——logAndProcess的实参为右值。这就是std::forward要做的,有条件的转换,即当且仅当它的参数是通过右值初始化时进行转换。

std::forward为什么能知道param是左值引用还是右值引用?参见Item28。

既然std::movestd::forward的差别只在于发生转换的条件,为什么不能去掉std::move都用std::forward?纯技术上没问题,而且它们两个都不是必不可少的,因为我们可以在任何地方手写转换。但希望你也能认同这么做很恶心。

std::move的吸引力来自它的便利性、减少了发生错误的可能性、以及更清晰的意图表达。比如我们想记录一个类型的对象被移动构造了多少次,可以用一个static的计数器:

1
2
3
4
5
6
7
class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s)) {
++moveCtorCalls;
}
};

如果用std::forward来实现,代码类似于:

1
2
3
4
5
6
7
class Widget {
public:
Widget(Widget&& rhs)
: s(std::forward<std::string>(rhs.s)) {
++moveCtorCalls;
}
};

首先std::move只需要一个参数,而std::forward还需要一个模板参数。其次注意我们传给std::forward的类型不能有引用,这是在编码一个被以右值方式传递的参数时的约定(参见Item28)。合起来,这意味着我们用std::move可以更少的敲击键盘,减少了在传递右值参数时类型错用为右值的风险,还消除了我们用错类型的问题(如转换为std::string&会导致s的构造使用复制构造函数)。

更重要的是,std::move是无条件的转换,而std::forward只在参数为右值引用时将其转换为右值。这里有两个不同的动作,一是移动,二是将对象传递给其它函数且保持其左值性或右值性。这两个显然不同的动作就要用两个显然不同的函数来触发。

Item24: 区分普适引用与右值引用

有句话叫“真理使你自由,但在适当的情况下,精心选择的谎言同样使你自由”。本节就是这样一个谎言,但在软件行业,我们不说“谎言”,而是说本节包含一个“抽象”。

看起来“T&&”就代表着一个右值引用,但没有这么简单:

1
2
3
4
5
6
7
void f(Widget&& param);          // rref
Widget&& var1 = Widget(); // rref
auto&& var2 = var1; // not rref
template <typename T> // rref
void f(std::vector<T>&& param);
template <typename T> // not rref
void f(T&& param);

“T&&”有两个含义,第一个就是右值引用,它的主要作用是标记一个可以移动的对象;第二个含义则既可能是右值引用也可能是左值引用,即看起来是“T&&”但实际上可能是“T&”。进一步地,“T&&”可能绑定在const或非const、volatile或非volatile对象上。理论上它可以绑定在任何对象上。我称其为“普适引用”。

普适引用发生在两个场景中,第一个是函数模板:

1
2
template <typename T>
void f(T&& param);

第二个是auto声明:

1
auto&& var2 = var1;

它们的共同点就是需要类型推断。如果不需要类型推断,例如Widget&&,这就不是普适引用,就只是一个右值引用。

普适引用的初始化式决定了它是右值引用还是左值引:如果初始化式是右值,普适引用就是右值引用;如果初始化式是左值,普适引用就是左值引用:

1
2
3
4
5
6
template <typename T>
void f(T&& param); // universal reference

Widget w;
f(w); // lvalue passed to f: Widget&
f(std::move(w)); // rvalue passed to f: Widget&&

光有类型推断还不足够,普适引用要求引用的声明格式必须是T&&,而不是std::vector<T>&&const T&&这样的声明。

如果你在模板中看到了一个函数参数为T&&,也不代表它是普适引用,因为这里可能根本不需要类型推断。例如:

1
2
3
4
5
6
template <class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x);
...
};

这里push_back的参数x不是普适引用,因为编译器会先实例化vector,之后你就发现push_back根本没有涉及到类型推断。例子:

1
2
3
4
5
class vector<Widget, allocator<Widget>> {
public:
void push_back(Widget&& x);
...
};

与之相反,emplace_back应用了类型推断:

1
2
3
4
5
6
7
template <class T, class Allocator = allocator<T>>
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);
...
};

args就是一个普适引用,因为它满足两个条件:

  1. 它的格式是T&&,当然这里是Args&&
  2. 它需要类型推断。

auto&&是普适引用也是相同的原因。它在C++11中出现得越来越多,在C++14中出现得更多,因为lambda表达式可以声明auto&&的参数了:

1
2
3
4
5
6
7
8
auto timeFuncInvocation =
[](auto&& func, auto&&... params) {
start timer;
std::forward<decltype(func)>(func)(
std::forward<decltype(params)>(params)...
);
stop timer and record elapsed time;
};

注意参数func,它是一个普适引用,因此可以绑定到任意可调用的对象上,无论左值还是右值;params是0或多个普适引用,可以绑定到任意数量的任意类型上。结果就是,利用auto普适引用,timeFuncInvocation可以完美地记录任何函数执行的时间。

但要记住,本节的基础,所谓的“普适引用”,只是一个谎言或“抽象”,它的底层机理被称为“引用折叠”,我们将在Item28中讲到。但真相不会减少这个抽象的价值,了解右值引用与普适引用的差别能让你更准确地阅读代码,也令你与同事交流时避免含糊不清,也能让你更好地理解Item25和Item26。

Item25: 将std::move用于右值引用,std::forward用于普适引用

右值引用就表示对应的对象可以被移动,对于那些可以被移动的对象,我们可以用std::move来让其它函数也能利用上它们的右值性:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
Widget(Widget&& rhs)
: name(std::move(rhs.name)), p(std::move(rhs.p))
{...}
...
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

而普适引用则既可能代表一个左值,又可能代表一个右值,只有在它代表右值时,我们才能将它cast成右值,这就是std::forward做的:

1
2
3
4
5
6
7
8
class Widget {
public:
template <typename T>
void SetName(T&& newName) {
name = std::forward<T>(newName);
}
...
};

简单来说,就是右值引用总是可以无条件转换为右值,因此用std::move,但普适引用不一定是右值,因此要用std::forward做有条件的右值转换。

参见Item23,在右值上可以应用std::forward,但这样的代码啰嗦,容易错,也不地道。更糟糕的是在普适引用上应用std::move,因为它会无意间修改左值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:
template <typename T>
void setName(T&& newName) {
name = std::move(newName); // compiles, but is bad, bad, bad!
}
...
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

std::string n{"name"};
Widget w;
w.setName(n); // moves n into w! n's value now unknown

看起来Widget::setName是个只读操作,但因为里面调用了std::move,无条件地把newName转换为了右值,导致n变成一个不可预期的值。

有人会说setName不应该声明一个普适引用参数,因为普适引用不能带const。我们可以声明两个重载函数来代替上面的版本:

1
2
3
4
5
6
7
8
9
class Widget {
public:
void setName(const std::string& newName) {
name = newName;
}
void setName(std::string&& newName) {
name = std::move(newName);
}
};

这样的代码能工作,但有缺点。

首先,需要维护的代码量变多。

其次,可能有运行时性能损失。考虑下面的调用:

1
w.setName("Adela Novak");

在普适引用版本的setName中,”Adela Novak”会被传到setName中,直接用于构造name,中间没有临时std::string产生。而在重载版本的setName中,”Adela Novak”会先用于构造一个临时的std::string,再传给右值版本的setName,再通过std::move赋值给name,然后临时std::string析构,整个过程多了一次std::string的构造和析构。

在不同的场景下这种性能差异可能有很大区别,但总的来说普适引用版本有机会比重载版本有更小的开销。

重载版本的最大问题,是代码的扩展性太差。setName只有一个参数,只需要两个重载版本,那如果有N个普适引用参数的函数呢?我们需要2N个重载版本,这显然不现实。更不用说变长参数了。

有些场景中,你可能会用到右值引用或普适引用的一个特性:它本身是个左值。这样我们在不想移动它时,直接使用这个引用本身,而在最终想要移动它们时,再用std::move(对于右值引用)或std::forward(对于普适引用)去移动它们。

1
2
3
4
5
6
template <typename T>
void setSignText(T&& text) {
sign.setText(text); // use text, but don't modify it
auto now = std::chrono::system_clock::now();
signHistory.add(now, std::forward<T>(text)); // conditionally cast text to rvalue
}

如果上面的text类型是右值引用,就可以用std::move。参见Item14,有些时候我们可能需要用std::move_if_noexcept来替代std::move

如果有一个按值返回的函数,其返回的对象是右值引用或普适引用,那么也可以用std::movestd::forward来获得更好的性能:

1
2
3
4
Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
lhs += rhs;
return std::move(lhs);
}

如果上面我们写的是return lhs;,编译器发现lhs是个左值,返回类型是Matrix,好,来个复制……

如果Matrix不支持移动,用std::move也不会有副作用。等到Matrix支持移动了,上面的代码马上就能享受到性能的提升。

std::forward也有类似的用法:

1
2
3
4
5
template <typename T>
Fraction reduceAndCopy(T&& frac) {
frac.reduce();
return std::forward<T>(frac);
}

对于按值返回的函数,如果返回的对象是个local对象,有些人可能会想到用std::move来避免复制:

1
2
3
4
5
6
7
8
9
10
11
12
13
// original version
Widget makeWidget() {
Widget w;
...
return w;
}

// some smart version
Widget makeWidget() {
Widget w;
...
return std::move(w);
}

但这么做是错的!

有个概念叫”RVO”,即“返回值优化”,即编译器会在返回一个local对象时,如果函数的返回类型就是值类型,那么编译器可以直接将这个local对象构造在接收函数返回值的对象上,省掉中间的复制过程。换句话说,在RVO的帮助下,直接返回这个local对象要比返回它的右值还要节省。

C++98中RVO只是一种优化,编译器可做可不做,我们不能有太高的预期。但C++11标准规定了这种场景下,编译器要么应用RVO优化,彻底省掉这次复制,要么返回这个local对象的右值。因此在C++11后,如果编译器没有进行RVO,上面的第一种写法和第二种写法是等效的。

既然直接返回local对象不会比手动调用std::move差,还有很大概率更好一些,我们还有什么理由去手动move呢?

Item26: 避免重载普适引用

整节都在讲重载普适引用带来的麻烦。麻烦的根源在于:根据C++的重载决议规则,普适引用版本总会被优先匹配。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
void logAndAdd(T&& name) {
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

std::string petName("Darla");
logAndAdd(petName);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog");

为了性能上的考虑,logAndAdd采用了普适引用作为参数类型,看起来还不错。然后我们添加一个重载版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::string nameFromIdx(int idx);
void logAndAdd(int idx) {
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}

std::string petName("Darla");
logAndAdd(petName);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog");

logAndAdd(22);

还是正常的。

1
2
3
short nameIdx;
...
logAndAdd(nameIdx); // error!

这次logAndAdd匹配到了普适引用版本,而不是int版本!

在这次重载决议中,short到普适引用是一次完美匹配,而shortint却是一次提升匹配,因此普适引用版本更优先。

通常来说普适引用版本在重载决议中的顺序都非常靠前,它们几乎能完美匹配所有类型(少数不能匹配的情况参见Item30)。这就是为什么重载普适引用大概率不好的原因。

在类的构造函数这里,情况变得更糟了:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
template <typename T>
explicit Person(T&& n)
: name(std::forward<T>(n)) {}

explicit Person(int idx)
: name(nameFromIdx(idx)) {}
...
private:
std::string name;
};

上面logAndAdd出现的问题在Person的构造函数中同样会出现。另外,根据Item17,某个类有模板构造函数不会阻止编译器为它生成复制和移动构造函数,即使这个模板构造函数可以实例化为与复制或移动构造函数相同的样子。因此Person中的构造函数实际上有4个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
public:
template <typename T>
explicit Person(T&& n)
: name(std::forward<T>(n)) {}

explicit Person(int idx)
: name(nameFromIdx(idx)) {}

Person(const Person& rhs);
Person(Person&& rhs);
...
private:
std::string name;
};

这其中的匹配规则对正常人来说都很反直觉。比如:

1
2
Person p("Nancy");
auto cloneOfP(p); // won't compile!

cloneOfP的构造中,我们直觉上会认为调用的是Person的复制构造函数,但实际上匹配到的却是普适引用版本。

编译器的理由如下:cloneOfP的构造参数是一个非const左值p,这会实例化出一个非const左值参数的版本:

1
2
3
4
5
6
7
8
9
10
class Person {
public:
explicit Person(Person& n)
: name(std::forward<Person&>(n)) {}

explicit Person(int idx);

Person(const Person& rhs);
...
};

p到复制构造函数的参数需要加一个const,而到Person&版本则是完美匹配。

假如我们将p改为const对象,即const Person p("Nancy"),那么情况又不一样了,这回模板参数变为const Person&

1
2
3
4
5
6
7
class Person {
public:
explicit Person(const Person& n);

Person(const Person& rhs);
...
};

我们得到了两个完全相同的完美匹配的重载版本,编译器无法决定用哪个,因此还是会报错。

在有继承的时候,情况更糟了:

1
2
3
4
5
6
7
8
9
10
class SpecialPerson: public Persion {
public:
SpecialPerson(const SpecialPerson& rhs) // copy ctor: calls Person forwarding ctor!
: Person(rhs)
{...}

SpecialPerson(SpecialPerson&& rhs) // move ctor: calls Person forwarding ctor!
: Person(rhs)
{...}
};

SpecialPerson的两个构造函数都调用了Person的普适引用版本构造函数。原因是rhs的类型是const SpecialPerson&SpecialPerson&&,到const Persion&Persion&&总是要进行一次转换的,而到普适引用版本则还是完美匹配。

如果你真的想处理一些普适引用参数的特殊情况,该怎么办?看下节,有很多方法。

目录

我们不爱裸指针的原因:

  1. 裸指针的声明没办法告诉我们它指向的是单个对象还是数组。
  2. 没办法知道用完这个裸指针后要不要销毁它指向的对象。
  3. 没办法知道怎么销毁这个裸指针,是用operator delete还是什么其它自定义的途径。
  4. 参照原因1,没办法知道该用delete还是delete[],如果用错了,结果未定义。
  5. 很难保证调用路径上恰好销毁这个指针一次,可能内存泄露,也可能double free。
  6. 通常没办法知道裸指针是否是空悬指针,即是否指向已销毁的对象。

智能指针就是来解这些问题的,它们用起来像裸指针,但能避免以上的很多陷阱。C++11中有4种智能指针:std::auto_ptrstd::unique_ptrstd::shared_ptrstd::weak_ptr。其中std::auto_ptr已经过时了,C++11中可以被std::unique_ptr取代了。

Item18: 需要显式所有权的资源管理时,用std::unique_ptr

首先要知道:默认情况下,std::unique_ptr与裸指针一样大,且对于绝大多数操作来说(包括解引用),它们编译后的指令都是完全一样的(参见为什么unique_ptr的Deleter是模板类型参数,而shared_ptr的Deleter不是),所有裸指针的空间和性能开销能满足要求的场景,std::unique_ptr一样能满足。

std::unique_ptr体现了显式所有权的语义:非空的std::unique_ptr总是拥有它指向的对象;移动一个std::unique_ptr会将源指针持有的所有权移交给目标指针;不允许复制std::unique_ptr;非空的std::unique_ptr总是销毁它持有的资源,默认是通过delete

一个例子是工厂函数。假设有一个基类和三个派生类,通过一个工厂函数来返回某个派生类的std::unique_ptr,这样调用方就不需要费心什么时候销毁返回的对象了:std::unique_ptr会负责这件事。

1
2
3
4
5
6
7
8
9
class Investment {...};
class Stock: public Investment {...};
class Bond: public Investment {...};
class RealEstate: public Investment {...};

template <typename... Ts>
std::unique_ptr<Investment> makeInvestment(Ts&&... params);

auto pInvestment = makeInvestment(args);

注意这里实际上有个所有权的转移:工厂函数通过std::unique_ptrInvestment对象的所有权转移给了调用者。

在构造std::unique_ptr时,我们还可以传入一个自定义的销毁器,它会在std::unique_ptr析构时被调用,来销毁对应的资源。比如我们可能不想只是delete obj,还想输出一条日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
auto delInvmt = [](Investment* pInvestment) {
makeLogEntry(pInvestment);
delete pInvestment;
};
template <typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> makeInvestment(Ts&&... params) {
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
if (...) {
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
...
return pInv;
}

从调用者的角度,你可以放心的处理std::unique_ptr,你可以相信在调用过程中资源只会销毁一次,且按恰当的方式销毁。理解以下几点能帮助你理解这种实现有多漂亮:

  • delInvmt是自定义的销毁器,在std::unique_ptr析构时,自定义的销毁器会来完成释放资源必需的操作。这里用lambda表达式来实现delInvmt,不仅更方便,性能还更好。
  • 自定义的销毁器的类型必须与std::unique_ptr的第二个模板参数相同,因此我们要用decltype(delInvmt)来声明std::unique_ptr
  • makeInvestment的基本策略是创建一个空的std::unique_ptr,再令它指向合适的类型,再返回。其中我们把delInvmt作为第二个构造参数传给std::unique_ptr,从而将销毁器与pInv关联起来。
  • 无法将裸指针隐式转换为std::unique_ptr,需要用reset来修改std::unique_ptr持有的裸指针。
  • 我们在创建具体的对象时,使用了std::forwardmakeInvestment的所有参数完美转发给对应的构造函数。
  • 注意delInvmt的参数是Investment*,而它的实际类型可能是派生类,因此需要基类Investment有一个虚的析构函数:
1
2
3
4
5
6
class Investment {
public:
...
virtual ~Investment();
...
};

C++14中我们可以做两件事来让makeInvestment更简单,封装更好:

  1. 返回类型可以为auto(参见Item3)。
  2. delInvmt的定义可以放到makeInvestment函数体中。

前文我们说过在不提供自定义的销毁器时,std::unique_ptr的大小与裸指针相同。但在有了自定义的销毁器后,这个假设不成立了。销毁器的大小取决于它内部保存了多少状态。对于无状态的函数对象(例如捕获列表为空的lambda表达式),销毁器实际不占用任何空间,这就意味着当你需要一个无状态的销毁器时,在lambda表达式和函数间做选择,lambda表达式更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
auto delInvmt1 = [](Investment* pInvestment) {
...
};

template <typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt1)> makeInvestment(Ts&&... args); // return type has size of Investment*

void delInvmt2(Investment* pInvestment) {
...
}

template <typename... Ts>
std::unique_ptr<Investment, void(*)(Investment*)> makeInvestment(Ts&&... args); // return type has size of Investment*
// plus at least size of function pointer

std::unique_ptr另一个广泛应用的场景是pImpl模式。

std::unique_ptr的两种形式分别是std::unique_ptr<T>std::unique_ptr<T[]>,其中前者没有定义operator[],后者在默认析构时会调用delete[],且没有定义operator*operator->。但在用到std::unique_ptr<T[]>的地方,你可能需要想一下是不是std::vectorstd::arraystd::string更合适。唯一一个用std::unique_ptr<T[]>更好的场合就是当你需要与C API交互时。

std::unique_ptr另一个吸引人的地方在于,它可以作为std::shared_ptr的构造参数,因此上面的工厂函数返回std::unique_ptr就再正确不过了:调用者可以根据自己对所有权的需求来决定用std::unique_ptr还是std::shared_ptr,反正都支持。

Item19: 需要共享所有权的资源管理,用std::shared_ptr

垃圾回收的好处:不用手动管理资源的生命期。坏处:资源回收的时间无法确定。

手动管理资源的好处:确定的资源回收时间,不只可以回收内存,还能回收任何其它资源。坏处:复杂,容易写出bug。

C++11中结合以上两者的方式是使用std::shared_ptr。使用std::shared_ptr管理的对象的所有权是共享的,没有哪个std::shared_ptr特别拥有这个对象,而是最后一个std::shared_ptr析构时,销毁这个对象。与垃圾回收类似,调用者不需要手动管理std::shared_ptr管理的对象;与析构函数类似,对象的析构时间是确定的。

std::shared_ptr内部有引用计数,被复制时,引用计数+1,有std::shared_ptr析构时,引用计数-1,当引用计数为0时,析构持有的对象。

引用计数的存在有以下性能影响:

  • std::shared_ptr的大小是裸指针的两倍:一个指针指向持有的对象,一个指针指向引用计数。
  • 引用计数使用的内存必须动态分配,原因是std::shared_ptr的引用计数是非侵入式的,必须要独立在对象外面。用std::make_shared能避免这次单独的内存分配。
  • 引用计数的加减必须是原子的,因此你必须假设读写引用计数是有成本的。

注意,不是所有std::shared_ptr的构造都会增加引用计数,移动构造就不会。因此移动构造一个std::shared_ptr要比复制一个更快。

std::unique_ptr类似,std::shared_ptr的默认销毁动作也是delete,且也可以接受自定义的销毁器。但与std::unique_ptr不同的是,std::shared_ptr的销毁器类型不必作为它的模板参数之一:

1
2
3
4
5
6
7
8
auto loggingDel = [](Widget* pw) {
makeLogEntry(pw);
delete pw;
};

std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);

std::shared_ptr<Widget> spw(new Widget, loggingDel);

因此std::shared_ptr要比std::unique_ptr使用更灵活,比如不同销毁器的std::shared_ptr可以放到同一个容器中,而std::unique_ptr则不可以。

另外,不同的销毁器不会改变std::shared_ptr的大小。std::shared_ptr内部需要为引用计数单独开辟一块内存,那么这块内存中再放一个销毁器也没什么额外开销。实际上这块内存被称为”控制块”,它里面包含以下元素:

  • 引用计数
  • 弱引用计数
  • 其它数据,包括:
    • 自定义销毁器
    • 内存分配器
    • 等等

控制块的创建规则为:

  • std::make_shared总会创建一个控制块。
  • 通过一个独享所有权的指针(如std::unique_ptrstd::auto_ptr)创建出的std::shared_ptr总会创建一个控制块。
  • 通过裸指针创建的std::shared_ptr会创建控制块。

一个推论就是:通过一个裸指针创建两个std::shared_ptr,会创建两个控制块,进而导致这个裸指针会被析构两次!

从中我们可以得到两个教训:

  1. 不要直接用裸指针构造std::shared_ptr,尽量用std::make_shared。当然在需要自定义的销毁器时不能用std::make_shared
  2. 非要用裸指针构造std::shared_ptr的话,尽量直接new,不要传入已有的裸指针变量。

有一种场景下,我们可能无意间创建了对应同一指针的两个控制块。

1
std::vector<std::shared_ptr<Widget>> processedWidgets;

processedWidgets表示所有处理过的Widget。进一步假设Widget有一个成员函数process

1
2
3
4
5
6
7
8
class Widget {
public:
...
void process() {
...
processedWidgets.emplace_back(this); // this is wrong!
}
};

如果被调用processWidget对象本身就被std::shared_ptr所管理,上面那行代码会导致它又创建了一个新的控制块。这种情况下我们应该令Widget继承自std::enable_shared_from_this,它允许创建一个指向自身控制块的std::shared_ptr

1
2
3
4
5
6
7
8
class Widget: public std::enable_shared_from_this<Widget> {
public:
...
void process() {
...
processedWidgets.emplace_back(shared_from_this());
}
};

这种基类是用派生类特化的模板的模式,称为“奇异递归模板模式”(The Curiously Recurring Template Pattern, CRTP)。

在调用shared_from_this,它会寻找指向自身的控制块。如果此时这个对象没有被任何一个std::shared_ptr持有,也就没有控制块,那么shared_from_this的行为是未定义的。因此往往继承自std::enable_shared_from_this的类都会把构造函数设为private,再提供一个静态方法来得到该类型的std::shared_ptr

1
2
3
4
5
6
7
8
9
class Widget: public std::enable_shared_from_this<Widget> {
public:
template <typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
...
void process();
private:
... // ctors
};

控制块会带来哪些开销呢?一个控制块通常只有几个word大,但其中会用到继承,甚至还有虚函数。这也意味着使用std::shared_ptr也会有调用虚函数的开销。

但通常来说std::shared_ptr的额外开销是很小的。对于std::make_shared创建的std::shared_ptr,它的控制块只有3个word大,且内存分配上无额外成本。解引用一个std::shared_ptr也不会比解引用一个裸指针开销大。操作引用计数会带来一两次原子操作的开销,但通常也不大。

但你用这些开销换来的是自动的资源管理。std::shared_ptr能满足大多数的共享所有权的资源管理需求。如果你还犹豫的话就想想共享所有权和额外开销,哪个对你而言更重要。

std::shared_ptr的一个缺点是它不支持数组,但在C++11已经提供了std::arraystd::vectorstd::string这些容器类的前提下,还要用std::shared_ptr去管理一个数组,本身就是不好设计的信号。

Item20: 在需要共享语义且可能空悬的地方用std::weak_ptr

有时候我们需要一种类似std::shared_ptr,但又不参与这个共享对象的所有权的智能指针。这样它就需要能知道共享对象是否已经销毁了。这就是std::weak_ptrstd::weak_ptr不是单独存在的,它不能解引用,也不能检测是否为空,它就是配合std::shared_ptr使用的。

通常std::weak_ptr都是通过std::shared_ptr构造的,但它不会影响std::shared_ptr的引用计数:

1
2
3
4
5
auto spw = std::make_shared<Widget>(); // ref count is 1
...
std::weap_ptr<Widget> wpw(spw); // ref count remains 1
...
spw = nullptr; // ref count toes to 0, wps now dangles

可以用expired()来检测std::weak_ptr指向的对象是否有效:

1
if (wpw.expired()) ...

另一个常用的操作是lock(),它能原子地检测对象是否有效,以及返回这个对象的std::shared_ptr

1
std::shared_ptr<Widget> spw = wpw.lock(); // if wpw's expired, spw is null

与之类似的操作是用std::weak_ptr构造一个std::shared_ptr

1
std::shared_ptr<Widget> spw(wpw); // 

区别在于,如果wpw已经失效了,这次构造会抛std::bad_weak_ptr的异常。

下面我们用一个例子来说明std::weak_ptr的必要性。

想象我们要实现一个cache,希望其中的元素在无人使用后被销毁。这里我们用std::unique_ptr并不合适,因为cache天然需要共享的语义。这样每个调用者都可以获得一个cache中元素的std::shared_ptr,它的生命期由调用者控制。cache内还需要保存一份元素的指针,且有能力检测它是不是失效了。这里我们需要的就是std::weak_ptr

1
2
3
4
5
6
7
8
9
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;
}

请不用在意上面的static,这只是个示意。

第二个例子是设计模式中的“观察者模式”。它的一种典型实现是每个主题对象持有一组观察者的指针,每当主题对象有状态变化时依次通知每个观察者。这里主题对象不需要控制观察者的生命期,但需要知道观察者的指针是否还有效。用std::weak_ptr就可以非常自然的实现出这样的特性。

第三个例子是,当A和C都持有B的std::shared_ptr时,如果B也需要持有A的某种指针,该持有什么?

  • 裸指针:如果A析构了,但C还在,B也就还在,此时B持有的A的裸指针就成了空悬指针,不好。
  • std::shared_ptr:这样A与B就形成了循环依赖,永远不可能析构了。
  • std::weak_ptr:唯一的好选择。

但要注意的是,用std::weak_ptr来解std::shared_ptr可能造成的循环依赖,这种特性本身并没有价值。设计良好的数据结构,比如树,父节点控制子节点的生命期,但子节点也需要持有父节点的指针,这里最好的方案是父节点用std::unique_ptr来持有子节点,而子节点直接持有父节点的裸指针。即,严格层次结构,明确生命期的场景,不需要使用std::weak_ptrstd::weak_ptr的价值在于:在生命期不明确的场景,可以知道对象是否还有效。

在效率方面,std::weak_ptr的大小与std::shared_ptr是相同的,它们使用相同的控制块,区别在于std::weak_ptr不会影响控制块中的引用计数,只会影响其中的弱引用计数。

Item21: 优先用std::make_uniquestd::make_shared而不是直接new

先做一下介绍,std::make_shared是在C++11中增加的,但std::make_unique却是在C++14中增加的。如果你想在C++11中就用上std::make_unique,自己写一个简单版的也不难:

1
2
3
4
template <typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params) {
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

这个版本不支持数组,不支持自定义的销毁器,但这些都不重要,它足够用了。但要记住的是,不要把它放到namespace std下面。

这两个make函数的功能就不解释了,和它们类似的还有一个std::allocate_shared

1
2
3
4
5
auto upw1(std::make_unique<Widget>());
std::unique_ptr<Widget> upw2(new Widget);

auto spw1(std::make_shared<Widget>());
std::shared_ptr<Widget> spw2(new Widget);

上面这个例子说明了用make函数的第一个好处:不需要重复写一遍类型。所有程序员都知道:不要重复代码。代码越少,bug越少。

第二个好处:异常安全性。想象我们有两个函数:

1
2
void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();

调用代码很可能长成这个样子:

1
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak!

上面这行代码有内存泄漏的风险,为什么?根据C++标准,在processWidget的参数求值过程中,我们只能确定下面几点:

  • new Widget一定会执行,即一定会有一个Widget对象在堆上被创建。
  • std::shared_ptr<Widget>的构造函数一定会执行。
  • computePriority一定会执行。

new Widget的结果是std::shared_ptr<Widget>构造函数的参数,因此前者一定早于后者执行。除此之外,编译器不保证其它操作的顺序,即有可能执行顺序为:

  1. new Widget
  2. 执行computePriority
  3. 构造std::shared_ptr<Widget>

如果第2步抛异常,第1步创建的对象还没有被std::shared_ptr<Widget>管理,就会发生内存泄漏。

如果这里我们用std::make_shared,就能保证new Widgetstd::shared_ptr<Widget>是一起完成的,中间不会有其它操作插进来,即不会有不受智能指针保护的裸指针出现:

1
processWidget(std::make_shared<Widget>(), computePriority()); // no potential resource leak

第三个好处:更高效。

1
std:shared_ptr<Widget> spw(new Widget);

这行代码中,我们以为只有一次内存分配,实际发生了两次,第二次是在分配std::shared_ptr控制块。如果用std::make_shared,它会把Widget对象和控制块合并为一次内存分配。

但是make函数也有一些缺点。

第一个缺点:无法传入自定义的销毁器。

第二个缺点:make函数初始化时使用了括号初始化,而不是花括号初始化,比如std::make_unique<std::vector<int>>(10, 20)创建了一个有着20个值为10的元素的vector,而不是创建了{10, 20}这么两个元素的vector(参见Item7)。

第三个缺点:对象和控制块分配在一块内存上,减少了内存分配的次数,但也导致对象和控制块占用的内存也要一次回收掉。即,如果还有std::weak_ptr存在,控制块就要在,对象占用的内存也没办法回收。如果对象比较大,且std::weak_ptr在对象析构后还可能长期存在,那么这种开销是不可忽视的。

如果我们因为前面这三个缺点而不能使用std::make_shared,那么我们要保证,智能指针的构造一定要单独一个语句。回到之前processWidget的例子中,假设我们有个自定义的销毁器void cusDel(Widget* ptr);,因此不能使用std::make_shared,那么我们要这么写来保证异常安全性:

1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority());

但这么写还不够高效,这里我们明确知道spw就是给processWidget用的,那么可以使用std::move,将其转为右值,来避免对引用计数的修改:

1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(std::move(spw), computePriority());

Item22: 在用到Pimpl惯用法时,在实现文件中定义特殊成员函数

我们经常用名为Pimpl的方法来实现接口与实现分离,进而大大降低程序构建的时间。Pimpl是指把类A中的所有数据成员都移到一个impl类中,A中只留下一个impl类型的指针。一个例子:

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget is some user-defined type
};

Widget的数据成员的类型为std::stringstd::vector<double>Gadget,这样就至少要include三个头文件,这也意味着每个需要include了这个包含Widget定义的头文件的地方,都被动引入了三个头文件。如果有一天我们修改了Widget的实现,比如增加或删除了一个成员,即使它们都是private的,即使接口完全没有变化,所有include它的用户文件都要重新编译。我们不想污染用户文件,也不想用户文件因为我们的实现修改而重新编译,我们就可以用Pimpl:

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget();
~Widget();
...
private:
struct Impl;
Impl* pImpl;
};

注意这里出现的Impl类型只是声明,没有定义,称为“不完整类型”,这样的类型只支持很少的操作,其中包括了我们需要的:声明一个不完整类型的指针。

对应的实现文件内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(new Impl)
{}

Widget::~Widget()
{
delete pImpl;
}

有了智能指针后,我们觉得直接newdelete不好,需要用std::unique_ptr

1
2
3
4
5
6
7
8
class Widget {
public:
Widget();
...
private:
struct Impl
std::unique_ptr<Impl> pImpl;
};

因为不需要手动的delete,我们没有自己实现Widget的析构函数。

看起来都很美好,编译也没问题,但在用户要用时,出事了:

1
Widget w; // error!

编译时出错(参见delete不完整类型的指针):

究其原因,是因为我们没有给Widget实现自定义的析构函数,因此编译器为Widget准备了一个。这个析构函数会被放到Widget的定义体内,默认是内联的,因此会有一份实现在用户文件中。~Widget中只做一件事:析构pImpl,即析构一个std::unique_ptr<Impl>。注意,我们隐藏了Impl的实现,在析构std::unique_ptr<Impl>时编译器发现Impl还是个不完整类型,此时对它调用delete是危险的,因此编译器用static_cast禁止了这种行为。

解决方案很简单:自己实现Widget的析构函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// widget.h
class Widget {
public:
Widget();
~Widget();
...
private:
struct Impl
std::unique_ptr<Impl> pImpl;
};
// widget.cpp
...
Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget()
{}

参考Item17,更好的方法是将析构函数定义为= default

1
Widget::~Widget() = default;

根据Item17,自定义的析构函数会阻止编译器生成移动构造函数和移动赋值函数,因此如果你想要Widget有移动的能力,就要自己实现:

1
2
3
4
5
6
7
8
class Widget {
public:
Widget();
~Widget();
Widget(Widget&& rhs) = default; // right idea, wrong code!
Widget& operator=(Widget&& rhs) = default;
...
};

注意不要在这些特殊成员函数的声明后面加= default,这样会重复上面析构函数的问题:会被内联,因此在用户代码中有一份实现,遇到不完整类型,game over。我们要做的就是在.cpp中将它们的实现定义为= default

接下来就是复制构造函数和复制赋值函数了。我们用std::unique_ptr是为了更好的实现Pimpl方法,这也导致了Widget无法自动生成复制函数(std::unique_ptr不支持),但这并不意味着Widget就不能支持复制了,我们还可以自己定义两个复制函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// widget.h
class Widget {
public:
...
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
...
};
// widget.cpp
Widget::Widget(const Widget& rhs)
: pImpl(nullptr) {
if (rhs.pImpl) {
pImpl = std::make_unique<Impl>(*rhs.pImpl);
}
}
Widget& Widget::operator=(const Widget& rhs) {
if (!rhs.pImpl) {
pImpl.reset();
} else if (!pImpl) {
pImpl = std::make_unique<Impl>(*rhs.pImpl);
} else {
*pImpl = *rhs.pImpl;
}
}

有意思的是,如果你把pImpl的类型改为std::shared_ptr<Impl>,你会发现上面所有这些注意事项,都不见了。你不需要手动实现析构函数、移动函数、构造函数,程序编译仍然是好的。

这种差异来自于std::unique_ptrstd::shared_ptr对自定义销毁器的支持方式不同(参见为什么unique_ptr的Deleter是模板类型参数,而shared_ptr的Deleter不是)。std::unique_ptr的目标是从体积到性能上尽可能与裸指针相同,因此它将销毁器类型作为模板参数的一部分,这样实现起来更高效,代价是各种特殊函数在编译时就要知道元素的完整类型。而std::shared_ptr没有这种性能上的要求,因此它的销毁器不是模板参数的一部分,性能会有一点点影响,但好处是不需要在编译特殊函数时知道元素的完整类型。

std::shared_ptr在构造时就把销毁器保存在了控制块中,之后即使传递到了不知道元素完整类型的地方,它仍然能调用正确的销毁器来销毁元素指针。而std::unique_ptr是依靠模板参数提供的类型信息来进行销毁,因此必须要知道元素的完整类型。

目录

前言

C++中想实现一个callable的对象,通常有四种方式:

  1. std::function:最common的方式,一般会配合std::bind使用。
  2. function pointer:最C的方式,但没办法实现有状态的callable object。
  3. function object:就是重载了operator()的类,C++98的STL中经常用。
  4. lambda expression:不会污染namespace,一般来说编译器内部会实现为一个匿名的function object。

从原理上性能最好的应该是3和4,其次是2,最差的是std::function戳这里)。下面我们用一小段代码来测试它们的性能。

阅读全文 »

Item15: 尽可能使用constexpr

C++11中最难理解的关键字可能就是constexpr了:修饰对象时,它就是加强版的const;但修饰函数时,它有着非常不同的含义。

概念上constexpr表示一个值不仅是const,而且是在编译期确定的。但修饰函数时,这个函数不一定返回const,也不一定能在编译期确定它的返回值。

编译期确定的值可以放到只读内存中,因此很适合于嵌入式系统。而编译期确定的整数类型的值还可以用于各种C++中要求“整数常量表达式”的地方,比如模板参数、数组长度、枚举值、对齐规格等等:

1
2
3
4
5
6
int sz;                            // non-constexpr variable
...
constexpr auto arraySize1 = sz; // error! sz's value not known at compilation
std::array<int, sz> data1; // error! same problem
constexpr auto arraySize2 = 10; // fine, 10 is a complie-time constat
std::array<int, arraySize2> data2; // fine, arraySize2 is constexpr

注意:这些地方没办法用const

constexpr函数会在所有参数都是编译期常量时产生一个编译期常量,否则就会在运行期产生值,即与普通函数一样。因此,如果所有参数都是编译期常量,那么constexpr函数也可以用于那些要求常量表达式的地方,否则,这个函数还可以用于普通的函数调用场景。

1
2
3
4
5
6
7
constexpr int pow(int base, int exp) noexcept { ... }
constexpr auto numConds = 5;
std::array<int, pow(3, numConds)> results; // call pow at compile-time

auto base = readFromDB("base");
auto exp = readFromDB("exponent");
auto baseToExp = pow(base, exp); // call pow at runtime

C++11中对constexpr的限制非常严,函数体中只能有一个可执行的语句:return。当然我们还可以用三元比较符?:和递归。

1
2
3
constexpr int pow(int base, int exp) noexcept {
return (exp == 0)? 1: base * pow(base, exp - 1);
}

C++14中放宽了限制,允许:

1
2
3
4
5
constexpr int pow(int base, int exp) noexcept {
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;
return result;
}

constexpr函数要求所有参数和返回值都是“字面值类型”。C++11中所有内置类型(除了void)都满足这个条件,但自定义类型也可以满足,因为构造函数和成员函数也可以是constexpr的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {
public:
constexpr Point(double xVal = 0, double yVal = 0) noexcept
: x(xVal), y(yVal)
{}

constexpr double xValue() const noexcept { return x; }
constexpr double yValue() const noexcept { return y; }
private:
double x, y;
};

constexpr Point p1(9.4, 27,7);
constexpr Point p2(28.8, 5.3);

PointxValueyValue两个成员函数也是constexpr,即如果被调用的对象是constexpr对象,它们的返回值就是constexpr值。这样我们可以写出下面的constexpr函数:

1
2
3
4
5
6
constexpr Point midpoint(const Point& p1, const Point& p2) noexcept {
return {(p1.xValue() + p2.xValue()) / 2,
(p1.yValue() + p2.yValue()) / 2};
}

constexpr auto mid = midpoint(p1, p2);

C++11中对声明为constexpr的成员函数有两个限制:

  1. 必须是const函数,因此不能修改对象本身。
  2. 必须返回一个字面值类型,因此不能返回void

因此我们没办法为Point声明下面两个constexpr成员函数:

1
2
3
4
5
6
7
8
9
10
11
class Point {
public:
...
constexpr void setX(double newX) noexcept {
x = newX;
}
constexpr void setY(double newY) noexcept {
y = newY;
}
...
};

C++14中去掉了这两个限制,上面两个函数就可以用了,还可以这么用:

1
2
3
4
5
6
7
8
9
10
11
12
constexpr Point reflection(const Point& p) noexcept {
Point result;
result.setX(-p.xValue());
result.setY(-p.yValue());
return result;
}

constexpr Point p1(9.4, 27.7);
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);

constexpr auto reflectedMid = reflection(mid);

本节的建议是尽可能地用constexpr,因为constexpr对象和函数的适用范围远比非constexpr的对象和函数要广。但要注意:

  1. constexpr是函数签名的一部分,如果把constexpr从函数签名中去掉,可能会破坏用户代码。
  2. constexpr函数可以把原本运行期的运算移到编译期进行,这会加快程序的运行速度,但也会影响编译时间。

Item16: 保证const函数成员线程安全

假设我们有一个多项式类,它有一个计算根的成员函数:

1
2
3
4
5
6
class Polynomial {
public:
using RootsType = std::vector<double>;
...
RootsType roots() const;
};

求根计算开销很大,我们可能想加个cache,不要每次都算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Polynomial {
public:
using RootsType = std::vector<double>;
...
RootsType roots() const {
if (!rootsAreValid) {
...
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{false};
mutable RootsType rootVals{};
};

假设有两个线程对同一个对象调用roots,因为这是一个const成员函数,通常意味着它是只读的,因此不需要有任何互斥手段。但实际上这两个线程都会去试图修改rootsAreValidrootVals,导致未定义结果。

问题就在于roots声明为const,但又没有保证线程安全性。我们可以用C++11增加的mutex来实现线程安全:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Polynomial {
public:
using RootsType = std::vector<double>;
...
RootsType roots() const {
std::lock_guard<std::mutex> g(m);
if (!rootsAreValid) {
...
rootsAreValid = true;
}
return rootVals;
}
private:
mutable std::mutex m;
mutable bool rootsAreValid{false};
mutable RootsType rootVals{};
};

值得注意的是,std::mutex是个不可移动或复制的类型,这也导致Polynomial也成为不可移动或复制的类型。

有些场景下std::mutex可能太重了,比如我们只需要一个计数器,那么用std::atomic就可以了:

1
2
3
4
5
6
7
8
9
10
11
class Point {
public:
...
double distanceFromOrigin() const noexcept {
++callCount;
return std::hypot(x, y);
}
private:
mutable std::atomic<unsigned> callCount{0};
double x, y;
};

std::atomic也是不可移动或复制的类型,也会导致Point不可移动或复制。

通常std::atomic开销比std::mutex低,你也许因此在各种场景下用std::atomic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Widget {
public:
...
int magicValue() const {
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; // marked as A
cacheValid = true; // marked as B
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{false};
mutable std::atomic<int> cachedValue;
};

考虑下面的场景,你会发现这里用std::atomic是错的:

  • 线程1调用Widget::magicValue,看到cacheVaildfalse,就开始做两个很重的计算,再把它们的和赋给cachedValue
  • 与此同时,线程2也调用Widget::magicValue,也看到cacheValidfalse,开始重复的计算。

为了解这个问题,你交换了A行和B行的顺序,但还是错的:线程1在做完B行,没做A行时,线程2看到cachedValuetrue,就把还没有更新的cachedValue返回出去了!

实际上这说明:单个变量的原子修改可以用std::atomic,但多个变量的原子修改还是要用std::mutex的。

回到正题上。对于根本不考虑并发调用的类型,它的成员函数的线程安全性并不重要,因此我们不需要为它的const成员函数添加开销昂贵的同步机制。但这样的类型实际上是越来越少的,对于可能被并发调用的const成员函数,使用者往往会忽略外部的同步机制,这也是为什么我们要自己保证const成员函数的线程安全性。

Item17: 理解特殊成员函数的产生机制

按C++的官方说法,“特殊成员函数”指编译器自己会生成的成员函数。C++98中有4个这样的函数:默认构造函数、析构函数、复制构造函数、赋值函数。当然,这些函数只会在需要时才生成。这些默认生成的成员函数都是publicinline的。除了派生类的析构函数外(基类的析构函数为虚函数),其它情况下这些成员函数都是非虚的。

C++11中又增加了两个特殊成员函数:移动构造函数和移动赋值函数:

1
2
3
4
5
6
class Widget {
public:
...
Widget(Widget&& rhs); // 移动构造函数
Widget& operator=(Widget&& rhs); // 移动赋值函数
};

这两个函数的生成规则和行为与对应的复制版本非常类似:只在需要时生成,行为是逐个移动非静态成员变量,也会调用基类对应的移动函数。注意,我们实际上是发出了“移动”的请求,不代表这些成员变量真的执行了移动的操作。对于那些没有定义移动函数的类型(比如C++98中的类型),“移动”请求实际上是通过复制函数完成的。逐个移动的过程的核心是对每个非静态成员变量调用std::move,并在重载决议时决定是调用移动函数还是复制函数。

如果你声明了某个移动函数,编译器就不再生成另一个移动函数。这与复制函数的生成规则不太一样:两个复制函数是独立的,声明一个不会影响另一个的默认生成。这条规则的背后原因是,如果你声明了某个移动函数,就表明这个类型的移动操作不再是“逐一移动成员变量”的语义,即你不需要编译器默认生成的移动函数的语义,因此编译器也不会为你生成另一个移动函数。

进一步地,如果你声明了某个复制函数,编译器也不再生成这两个移动函数了。这条规则的背后原因与上一条类似:自定义的复制函数表示你不想要“逐一”复制的语义,那么很大概率上“逐一”移动你也不想要,那么编译器就不会为你生成移动函数。

反过来的规则也成立:如果你声明了移动函数,那么编译器就不会生成复制函数。

C++98中有所谓的“三法则”:如果你声明了复制构造函数、复制赋值函数或析构函数中的一个,你也应该定义另外两个。该原则的原因是如果你声明了其中任意一个函数,就表明你要自己管理资源,而这三个函数都会参与到资源管理中,因此如果声明就要全声明掉。STL中的每个容器类都声明了这三个函数。

三法则的一个推论就是,自定义了析构函数往往意味着逐一的复制语义并不适用于这个类,因此自定义析构函数也应该阻止编译器生成复制函数。但在C++98标准产生过程中,三法则还没有被广泛认可,因此C++98中自定义析构函数并不会影响编译器生成复制函数。C++11中为了兼容老代码,并没有改变这一条规则,但要注意的是自定义析构函数会阻止编译器生成移动函数。因此移动函数的产生规则为,编译器只在以下三条都成立时才生成默认的移动构造函数和移动赋值函数:

  • 没有声明复制函数。
  • 没有声明移动函数。
  • 没有声明析构函数。

C++11中也将声明了复制函数或析构函数的类中自动生成的复制函数标记为“已过时”,未来某个版本中会禁止这种行为。

如果希望声明一个默认生成的特殊函数,在C++11中你可以标记其为“=default”,显式要求编译器生成一个这样的函数:

1
2
3
4
5
6
7
class Widget {
public:
...
~Widget();
Widget(const Widget&) = default;
Widget& operator=(const Widget&) = default;
};

这种方法广泛应用于纯虚基类中。纯虚基类往往会声明一个虚的空析构函数,但这会阻止编译器为其生成复制和移动函数,此时就可以用“=default”来要求编译器生成这样的函数:

1
2
3
4
5
6
7
8
class Base {
public:
virtual ~Base() = default;
Base(Base&&) = default;
Base& operator=(Base&&) = default;
Base(const Base&) = default;
Base& operator=(const Base&) = default;
};

事实上,即使编译器默认生成的复制和移动函数已经足够了,你仍然可以在类中显式声明这些函数为“=default”,明确表达你的意图,且能避免无意间对编译器隐式生成的行为产生影响。一个例子:

1
2
3
4
5
6
7
class StringTable {
public:
StringTable() {}
... // functions for insertion, erasure, lookup, etc.. but no copy/move/dtor funcs
private:
std::map<int, std::string> values;
};

这样的类型,编译器隐式生成的复制函数、移动函数、析构函数已经足够用了。但如果有一天,你决定在这个类的构造和析构时打一条LOG:

1
2
3
4
5
6
7
8
9
10
11
class StringTable {
public:
StringTable() {
makeLogEntry("Creating StringTable object");
}
~StringTable() {
makeLogEntry("Destroying StringTable object");
}
private:
std::map<int, std::string> values;
};

看起来很合理,但因此编译器不再为StringTable生成移动函数,而生成的复制函数不受影响。这样一来,原始版本中可以调用移动构造函数或移动赋值函数的地方,现在都改为调用复制构造函数和复制赋值函数。程序没有报错,但性能却在无人注意时下降了。而如果我们一开始就显式声明这些函数为“=default”,既利用上了编译器生成的函数,又不会在无意间改变程序的行为。

C++11规定了以下特殊成员函数:

  • 默认构造函数,与C++98相同。
  • 析构函数,基本与C++98相同,但默认为noexcept
  • 复制构造函数,与C++98的运行时行为相同。声明了移动函数会阻止生成复制构造函数;声明了析构函数会导致生成的复制构造函数被标记为“deprecated”。
  • 复制赋值函数,与C++98的运行时行为相同,其它特性同复制构造函数。
  • 移动构造函数和移动赋值函数,执行逐一移动成员的操作,只有在未声明析构函数、复制函数、移动函数时才会自动生成。

这里没有说声明一个模板成员函数会阻止编译器生成这些特殊函数,即如果Widget里声明了模板成员函数:

1
2
3
4
5
6
7
8
class Widget {
...
template <typename T>
Widget(const T& rhs);

template <typename T>
Widget& operator=(const T& rhs);
};

并不会阻止编译器继续生成特殊成员函数,即使这两个模板函数在TWidget时函数签名与自动生成的复制函数完全相同。Item26会解释为什么会有这条规则存在。

目录

Item11: 优先用deleted函数取代private的未定义函数

有时C++会为你自动生成一些函数,但你想要阻止其他人调用这些函数。

C++98中,为了避免编译器为我们生成拷贝构造函数和赋值函数,最佳实践是:将它们声明为private函数,且不定义。

比如basic_ios类不希望自己被拷贝构造和赋值,在C++98中的做法是:

1
2
3
4
5
6
7
8
template <class charT, class traits = char_traits<charT> >
class basic_ios: public ios_base {
public:
...
private:
basic_ios(const basic_ios&); // not defined
basic_ios& operator=(const basic_ios&); // not defined
};

这样对于没有权限访问它们的用户,编译期会报访问权限的错误,而对于basic_ios的友元这样有权限访问private函数的用户,链接期会报”undefined reference”。

C++11中我们可以将这样的函数声明为= delete

1
2
3
4
5
6
7
8
template <class charT, class traits = char_traits<charT>>
class basic_ios: public ios_base {
public:
...
basic_ios(const basic_ios&) = delete;
basic_ios& operator=(const basic_ios&) = delete;
...
};

注意到C++11中我们将这两个函数声明为public,这样友元也会在编译期报错,错误会更友好一些。

deleted函数的一个重要优势在于,它不只能用于成员函数(未定义的private函数只能是成员函数)!

例如我们有这么个函数:

1
bool isLucky(int number);

C++的隐式转换导致非整数的基本类型也能调用这个函数:

1
2
3
4
5
if (isLucky('a)) ...

if (isLucky(true)) ...

if (isLucky(3.5)) ...

C++11中我们可以将这些我们不想要的函数定义为deleted:

1
2
3
4
5
bool isLucky(int number);

bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;

这些deleted函数仍然会参与到重载决议中,再报错。

deleted函数的另一类用途是禁止模板的某个特化版本。例如:

1
2
template <typename T>
void processPointer(T* ptr);

我们不想将其用于void*char*,就将它们声明为deleted:

1
2
3
4
5
template <>
void processPointer<void>(void*) = delete;

template <>
void processPointer<char>(char*) = delete;

如果要做彻底的话,我们还要将const void*const volatile void*wchar_t这样的类型一一禁止掉。

C++98中,我们没办法通过声明为private来禁止其他人调用模板成员函数的某个特化版本,因为模板成员函数的所有版本的访问权限都是一样的:

1
2
3
4
5
6
7
8
9
class Widget {
public:
...
template <typename T>
void processPointer(T* ptr) {...}
private:
template <>
void processPointer<void>(void*); // error!
};

而C++11中我们可以在类外面将这个特化版本标记为deleted:

1
2
3
4
5
6
7
8
9
class Widget {
public:
...
template <typename T>
void processPointer(T* ptr) {...}
};

template <>
void Widget::processPointer<void>(void*) = delete; // still public

C++98的未定义private函数的方法其实就是要达到C++11中deleted函数的效果,因此在C++11中使用deleted函数总是更好的。

Item12: 将重写函数声明为override

C++的面向对象的基础就是类的继承和虚函数的重写(override),允许把对基类的接口调用转到子类的重写函数上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual void doWork();
...
};

class Derived: public Base {
public:
virtual void doWork();
...
};

std::unique_ptr<Base> upb = std::make_unique<Derived>();
upb->doWork(); // call Derived::doWork

重写需要满足几个条件:

  • 基类中的函数必须是虚函数。
  • 基类与子类中的函数必须同名(析构函数除外)。
  • 基类函数与子类函数的参数类型必须相同。
  • 基类函数与子类函数的const性必须相同。
  • 子类函数的返回类型和异常规格必须与基类函数的兼容。

以上是C++98中对重写的要求,C++11又加了一条:

  • 函数的引用限制必须相同(被调用的对象是左值还是右值)。
1
2
3
4
5
6
class Widget {
public:
...
void doWork() &; // applies only when *this is an lvalue
void doWork() &&; // applies only when *this is an rvalue
};

如果违反了这些条件,我们就会遇到一个名字差不多,但实际没有关系的概念:重载(overload)。重载函数就是名字相同,但不符合上面其它条件的函数。而且,子类中的重载函数会屏蔽基类中同名的版本,即在重载决议阶段我们只能看到子类中的各个重载版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};

class Derived: public Base {
public:
virtual void mf1();
virtual void mf2(unsigned int x);
virtual void mf3() &&;
void mf4() const;
};

我们很容易就制造了一个全是重载没有重写的场景:

  • mf1的const性不同。
  • mf2的参数类型不同。
  • mf3的引用性不同。
  • mf4在基类中不同虚函数。

编译器不一定会提醒我们弄出了重载函数,但我们需要保证这点。C++11增加了override修饰符,可以声明一个函数是重写函数,否则就会报错。

1
2
3
4
5
6
7
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};

以上4个函数在编译时都会报错。

在这个基础上,把所有的重写函数都加上override,还能帮助我们去衡量修改一个基类接口的代价:所有标记了override的子类重写函数都会报错。如果没有加上override,我们就只能人肉去修改,再指望单元测试足够全面了。

C++11引入了两个局部关键字:overridefinal(阻止子类重写此函数),它们只在函数声明的修饰符区域才是关键字,其它地方不是。你不必担心下面的代码出错:

1
2
3
4
5
6
class Warning {
public:
...
void override();
...
};

下面说一下函数的引用限制。我们在某些场景下需要知道对象是左值还是右值。

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
using DataType = std::vector<double>;
...
DataType& data() { return values; }
...
private:
DataType values;
};

Widget w;
auto vals1 = w.data();

这里Widget::data返回了一个左值,因此vals1的初始化调用了vector的拷贝构造函数。

假设我们有个函数Widget makeWidget(),它返回一个临时的Widget对象,在这个临时对象上调用data就不太值了:

1
auto vals2 = makeWidget().data();

如果我们能在调用data时知道*this是右值的话,就可以返回一个右值:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
using DataType = std::vector<double>;
...
DataType& data() & { return values; }
DataType&& data() && { return std::move(values); }
...
private:
DataType values;
};

这样vals2的初始化就只需要调用vector的移动构造函数。

Item13: 优先用const_iterator代替iterator

const_iterator指向const对象,我们当然希望尽可能用它,但C++98对它的支持很不全面,首先难以创建,其次可使用的场景很受限。

例如,下面这段代码:

1
2
3
4
std::vector<int> values;
...
std::vector<int>::iterator it = std::find(values.begin(), values.end(), 1983);
values.insert(it, 1988);

这里有3个iterator:itvalues.begin()values.end(),最好是能把它们替换为const_iterator。但:

  1. 非const的容器对象的begin()end()只能返回iterator,不能返回const_iterator。
  2. vector::insert的第一个参数只接受iterator,不接受const_iterator。

如果非要在C++98中把上面的iterator替换为const_iterator,我们需要使用多次static_cast

1
2
3
4
5
6
7
8
9
10
typedef std::vector<int>::iterator IterT;
typedef std::vector<int>::const_iterator ConstIterT;
std::vector<int> values;
...
ConstIterT ci = std::find(
static_cast<ConstIterT>(values.begin()),
static_cast<ConstIterT>(values.end()),
1983
);
values.insert(static_cast<IterT>(ci), 1988);

实际上面这段代码可能都编译不了,因为const_iterator到iterator并没有一个可移植的转换方式,static_cast不行,reinterpret_cast也不行。总之C++98中用const_iterator就是会有一堆的麻烦,结果就是大家都不用const_iterator了。

C++11做了几个改变,令const_iterator重新回到人们的视野中:

  1. STL中的几个容器类提供了cbegincend成员函数,返回非const对象的const_iterator。
  2. 提供std::cbeginstd::cend函数,返回参数的const_iterator,甚至支持数组。
  3. STL的几个容器类增加了多个接受const_iterator参数的成员函数重载版本,比如insert

上面的第2条不太准确,实际上C++11只增加了beginend这两个非成员函数,C++14则一口气增加了cbegincendrbeginrendcrbegincrend这六个非成员函数。

前面的代码在C++11中是这样的:

1
2
3
4
std::vector<int> values;
...
auto it = std::find(values.cbegin(), values.cend(), 1983);
values.insert(it, 1988);

如果我们想在C++11中就用到非成员版的cbegin,大可以自己写一个:

1
2
3
4
template <typename C>
auto cbegin(const C& container) -> decltype(std::begin(container)) {
return std::begin(container);
}

这里为什么返回的是std::begin(container)?为什么不返回container.cbegin()

  1. 注意container的类型是const C&,通常来说const对象的begincbegin都会返回const_iterator。而且还会有一些类只定义了begin,没有定义cbegin,这样调用begin可以适用于更多的类型。

  2. 调用std::begin的话,对于定义了begin成员函数的类,与调用成员版本的begin是相同效果的;对于数组类型,它没有成员版本的begin,但有std::begin的一个特化版本,因此调用std::begin能适用于更多的情况。

回到正题上,C++11后,你就可以尽量多地用const_iterator了。

Item14: 如果函数永远不会抛出异常,则声明其为noexcept

C++98中的异常规格是一个很难用的特性:你要总结出这个函数可能抛哪些异常,还包括它下层函数可能抛的异常,把这些异常类型写到异常规格中,一旦改了实现(或下层函数改了实现),你还要修改异常规格,由此导致函数签名发生变化,可能破坏一大堆用户代码。这其中编译器通常帮不上忙。总之大多数人都认同C++98的异常规格是一个设计失误,不值得花那么大的代价来使用它。

但人们发现,标记一个函数可能抛哪些异常通常没什么意义,还惹来一大堆麻烦,但标记一个函数会不会抛异常却很有意义。

因此C++11中我们可以标记一个不会抛异常的函数为noexcept

1
2
int f(int x) throw();   // C++98 style
int f(int x) noexcept; // C++11 style

编译器不会在编译期检查这个限制,但在运行期,一个标记为noexcept的函数如果抛了异常,程序会直接终止。这里与违背异常规格的现象有些像,但有不同:违背异常规格时,程序会展开调用栈,再终止;而违背noexcept时,程序可能会展开调用栈,再终止。

这个“可能”非常重要,它允许编译器不去生成处理栈展开的代码(可能对目标代码的大小有明显的影响),不去按构造的相反顺序析构对象,甚至不去析构对象。

而且,在某些场景下,我们可以利用有没有noexcept来做不同的操作,从而优化代码。一个例子:

1
2
3
4
5
std::vector<Widget> vw;
...
Widget w;
...
vw.push_back(w);

如果vw空间已经满了,再调用push_back就需要重新分配一块更大的空间。现在的问题就是如何把vw中旧的元素转移到新的空间上。为了保证转移过程中的异常安全性,C++98中我们只能一个一个拷贝过去,全部成功后再把旧的对象依次销毁掉。但C++11中,我们可以用到noexcept的信息:如果Widget有移动构造函数,且标记为noexcept,我们就可以放心地用移动构造函数去构造新元素,而不用担心抛异常导致vw被破坏。

vectorpush_back就是这么做的,一些其它C++98保证了强异常安全性的函数也有着类似的行为。但它们是如何知道函数的noexcept信息的呢?vector::push_back中调用了std::move_if_noexcept,而std::move_if_noexcept调用了std::is_nothrow_move_constructible,后者的值是由编译器给的。

一些泛型函数可以根据它们的参数来推断是否有noexcept的保证。以swap为例:

1
2
3
4
5
6
7
8
9
10
template <typename T, size_t N>
void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(*a, *b)));

template <typename T1, typename T2>
struct pair {
...
void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&
noexcept(swap(second, p.second)));
...
};

noexcept实际上有三种用法:

  1. 作为函数规格的单独的noexcept,即不抛异常的保证。
  2. 作为函数规格区域的noexcept(bool-expression),如果bool-exp为true,则与单独的noexcept相同,否则与没有这个noexcept相同。
  3. 表达式noexcept(func-call-exp),如果func-call-exp为noexcept则返回true,否则返回false

上面的swap的第一个例子,说的是如果swap<T>noexcept的保证,则swap<T, N>也有,否则也没有。第二个例子是只有swap<T1>swap<T2>都有noexceptpair::swap才有noexcept的保证。

看起来很美好,但是不是所有函数都要加上noexcept呢?

  1. 优化很重要,但正确性更重要。只有真的不应该抛异常的函数才应该加上noexcept
  2. noexcept是函数签名的一部分,所以如果一个接口当前不抛异常,但长远来看不确定会不会抛异常,那么也不建议加noexcept
  3. 加了noexcept不代表这个函数不能抛异常,而是“如果这里抛了异常,程序就应该直接挂掉”,只有这样的函数,才应该加noexcept

注意第3点,C++98中我们认为内存释放函数(operator deleteoperator delete[])和析构函数抛异常是种不好的写法,而C++11中干脆默认它们都是noexcept的。如果你不想要这个特性,需要显式声明它们为noexcept(false)。但为什么要这么做呢?标准库中没有这种写法,且一个析构函数可能抛异常的类型与标准库同时使用的行为也是未定义的。

一些库作者会把函数分成“高可用性”和“低可用性”两种,“高可用性”指对参数没有要求,调用方可以任意传参数而不用担心出错,这样的函数当然可以声明为noexcept。剩下的函数就是低可用性的,它可能对参数有检查,或者在参数不符合要求时行为未定义。这样的函数就要好好想一想要不要声明为noexcept了:如果声明为noexcept,抛异常就会导致程序终止,那么怎么测试它对参数的要求?

最后需要注意的是一个声明为noexcept的函数,如果内部调用了未声明为noexcept的函数,编译器不会抛错,连警告都没有,原因是:

  1. 作者想表达的是“正常不会抛异常,如果这里抛了异常,程序就应该直接挂掉”,编译器需要尊重这种选择。
  2. 可能调用的函数是C函数,或是C++98中的确实不会抛异常的函数,这些函数显然没办法声明为noexcept

目录

Item7: 创建对象时区分开()和{}

通常来说,C++11中我们能用(){}=来初始化一个变量:

1
2
3
4
int x(0);
int y = 0;
int z{0};
int z = {0}; // available in many cases

但这几种初始化方式之间还有着区别。

=与另外两个不太一样,它代表着拷贝构造函数或赋值函数。

{}是C++11引入的新的初始化方式,它被设计为能用在各种地方,表达各种形式的值,也可以称为“统一初始化式”。

它能表达一组值,来初始化STL容器:

1
std::vector<int> v{1, 3, 5};

它能用来给类的非static成员设定默认值(而()就不行):

1
2
3
4
5
6
7
class Widget {
...
private:
int x{0}; // fine
int y = 0; // also fine
int z(0); // error!
};

它和()都能用于初始化一个uncopyable的对象(而=就不行):

1
2
3
std::atomic<int> ai1{0};   // fine
std::atomic<int> ai2(0); // also fine
std::atomic<int> ai3 = 0; // error!

{}有一个性质:它会阻止基本类型向下转换:

1
2
3
4
5
double x, y, z;
...
int sum1{x + y + z}; // error! double -> int is prohibited
int sum2(x + y + z); // ok
int sum3 = x + y + z; // ditto

另一个性质是:它不会被认为是声明。

C++中规定“所有看起来像声明的语句都会被视为声明”,这导致()在一些场景下会被视为函数声明,而{}则不会:

1
2
3
Widget w1(10);      // call Widget ctor with 10
Widget w2(); // NOTICE: w2 is a function declare!
Widget w3{}; // call Widget ctor with no args

但是{}也不是什么都好,在类有std::initializer_list参数的构造函数时,{}会有麻烦:{}总会被认为是std::initializer_list,即使解析出错。

我们先看一个没有std::initializer_list构造函数的类Widget

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
...
};
Widget w1(10, true); // call first ctor
Widget w2{10, ture}; // also call first ctor
Widget w3(10, 5.0); // call second ctor
Widget w4{10, 5.0}; // also call second ctor

一切都很正常,直到我们给Widget添加了一个新构造函数:

1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il); // added
...
};
Widget w1(10, true); // call first ctor
Widget w2{10, ture}; // NOTICE: now call third ctor(10 and true convert to long double)
Widget w3(10, 5.0); // call second ctor
Widget w4{10, 5.0}; // NOTICE: now call third ctor(10 and 5.0 convert to long double)

甚至通常拷贝和移动构造函数该被调用的地方,都会被劫持到std::initializer_list构造函数上:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
operator float() const; // added
...
};
Widget w5(w4); // call copy ctor
Widget w6{w4}; // call third ctor! w4 -> float -> long double
Widget w7(std::move(w4)); // call move ctor
Widget w8{std::move(w4)}; // call third ctor! w4 -> float -> long double

甚至在{}中的内容没办法完全匹配std::initializer_list时:

1
2
3
4
5
6
7
8
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<bool> il);
...
};
Widget w{10, 5.0}; // error! requires narrowing conversions

只有当{}中的所有元素都没办法转换为std::initializer_list需要的类型时,编译器才会去选择其它构造函数。比如上面的bool改为std::string,编译器找不到{10, 5.0}中有能转换为std::string的元素,就会去匹配我们希望的前两个构造函数了。

一个有趣的地方:如果{}中没有元素,那么被调用的是默认构造函数,而不是一个空的std::initializer_list

如果你真的想传入一个空的std::initializer_list,那么这样:

1
2
Widget w4({});
Widget w5{{}};

std::vector<int>就有上面说的问题:

1
2
3
4
5
6
std::vector<int> v1(10, 20);   // use non-std::initializer_list ctor:
// create 10-element std::vector, all
// elements are 20
std::vector<int> v2{10, 20}; // use std::initializer_list ctor:
// create 2-element std::vector whose
// values are 10 and 20

从这个问题中,我们能学到什么?

首先,向一个已有的类添加std::initialzier_list构造函数要非常谨慎,这可能会导致用户的调用被劫持。

其次,作为用户的我们,要小心选择用()还是{}{}的好处上面已经说了。()的好处是:与C++98风格的连续性,还能避免陷入std::initializer_list的问题。

当你写模板代码时,如果需要创建一个对象,你会用哪种语法?

1
2
T localObject(std::forward<Ts>(params)...);
T localObject{std::forward<Ts>(params)...};

如果Tstd::vector<int>,参数是{10, 20},哪种是对的?只有用户才知道。

std::make_uniquestd::make_shared遇到了这个问题,它们选择了(),并在文档中说明了这个选择。

有一种方法允许用户来指定用()还是{}Intuitive interface

(我觉得只在{}明确有好处的地方用{},其它地方还是用()比较好)

Item8: 优先选用nullptr来替代0和NULL

首先是结论:

  1. NULL是0,0是int,不是指针;
  2. nullptr不是指针,但可以安全地用在需要用指针的场合;
  3. 因此在需要空指针的地方,用nullptr
  4. nullptr还能提高代码的可读性。

NULL和0不是指针会带来什么问题?函数重载。当一个函数同时有int参数和指针参数的两个重载版本时,传入NULL或0(下面只说NULL吧,反正它们两个是完全一样的),你猜编译器会匹配哪个版本?

1
2
3
4
5
6
void f(int);
void f(bool);
void f(void*);

f(0); // calls f(int), not f(void*)
f(NULL); // might not complie. typically calls f(int). Never calls f(void*)

f(NULL)能否编译过取决于NULL被定义成了什么:如果定义为0,就能编译过,调用f(int);如果定义为0L,那么NULL的类型就是long,编译器会发现f没有直接匹配long的重载版本,但有两个相同优先级的转换后匹配f(int)f(void*),于是就报错。

C++98中我们只能尽量避免同时存在整数参数和指针参数的重载版本,但C++11中我们可以用nullptr

nullptr不是指针,它的类型是std::nullptr_t,但C++11保证它能转换为任意的裸指针类型。

上面的例子中,f(nullptr)就会乖乖地调用f(void*)

nullptr还能让代码更清晰,尤其是和auto配合使用时。假设你看到这么一段代码:

1
2
3
4
auto result = findRecord(/* args */);
if (result == 0) {
...
}

如果你不看findRecord的签名,就没办法区分result是整数还是指针。但如果用nullptr就很明显的表达了result是指针:

1
2
3
4
auto result = findRecord(/* args */);
if (result == nullptr) {
...
}

在有模板出现的地方,nullptr更加有优势。看下面的例子,我们有三个函数,都需要持有相应的锁时才能调用:

1
2
3
int    f1(std::shared_ptr<Widget> spw);
double f2(std::unique_ptr<Widget> upw);
bool f3(Widget* pw);

刚开始代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::mutex f1m, f2m, f3m; // mutexes for f1, f2, and f3.
using MuxGuard = std::lock_guard<std::mutex>;
...
{
MuxGuard g(f1m);
auto result = f1(0); // pass 0 as null ptr to f1
}
...
{
MuxGuard g(f2m);
auto result = f2(NULL); // pass NULL as null ptr to f2
}
...
{
MuxGuard g(f3m);
auto result = f3(nullptr); // pass nullptr as null ptr to f3
}

上面这段代码能工作,但是丑了点:三段代码的模式都是一样的,应该封装起来。鉴于f1、f2、f3的参数和返回类型都不一样,我们需要用模板来实现:

1
2
3
4
5
6
template <typename FuncT, typename MuxT, typename PtrT>
auto lockAndCall(FuncT func, MuxT& mutex, PtrT ptr) -> decltype(func(ptr)) {
using MuxGuard = std::lock_guard<MuxT>;
MuxGuard g(mutex);
return func(ptr);
}

C++14中我们还可以用decltype(auto)作为返回值类型,但不影响结论。

现在我们来试一下新代码:

1
2
3
auto r1 = lockAndCall(f1, f1m, 0);         // error! PtrT is deduced to int
auto r2 = lockAndCall(f2, f2m, NULL); // error! PtrT is deduced to int
auto r3 = lockAndCall(f3, f3m, nullptr); // OK, PtrT is nullptr_t

前两个调用是错的!模板只能看到int,而shared_ptrunique_ptr没有int参数的构造函数。第三个调用则没有任何问题,PtrT被推断为std::nullptr_t,在调用f3时隐式转换为Widget*

我们再回头看一下本节的结论,在下个需要传入空指针的地方,你还会用0NULL吗?

Item9: 优先选用using来替代typedef

大家在用STL时,经常会搞出来std::unique_ptr<std::unordered_map<std::string, std::string>>这么长的类型出来。为了避免写好几次这么长的类型,我们会用typedef将它定义为一个新类型:

1
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;

而在C++11中,我们多了一种选择:可以用using做类似的事:

1
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;

那么为什么在有typedef的情况下C++还要再增加一个using呢?

首先,using在表达类型时更清晰:

1
2
typedef void (*FP)(int, const std::string&);
using FP = void (*)(int, const std::string&);

typedef来自于C,它的设计初衷是想用声明变量的方式来声明一个类型,但这导致名字和类型搅合在了一起,严重的影响了可读性。而using则将名字和类型分开了,我们能更容易的看出我们声明的类型到底是什么。

其次,using能模板化,称为”别名模板”,而typedef不能:

1
2
3
4
5
template <typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; // OK

template <typename T>
typedef std::list<T, MyAlloc<T>> MyAllockList; // error!

C++98中我们可以通过struct来绕过这个问题:

1
2
3
4
5
6
template <typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};

MyAllocList<Widget>::type lw; // client code

但在模板里我们不能直接使用这个类型,要在前面加上typename

1
2
3
4
5
template <typename T>
class Widget {
private:
typename MyAllocList<T>::type list;
};

原因是编译器在第一次处理模板时,不知道T是什么,也没办法知道MyAllocList<T>::type是什么,它只能假设typeMyAllocList<T>的一个成员。只有前面加上typename编译器才能放心的将type按类型处理。

using则明确表明它就是声明了一个类型,因此不需要加typename

做过一些模板元编程(TMP)的人应该都有体会,在处理类型时,我们经常要用到下面的trait类:

1
2
3
std::remove_const<T>::type          // const T -> T
std::remove_reference<T>::type // T& and T&& -> T
std::add_lvalue_reference<T>::type // T -> T&

但每次使用都要在前面加上typename,就是因为这些trait类都是通过typedef定义出来的。

C++14中这些类都有了一个using版本,直接是一个类型,使用时不需要加typename

1
2
3
std::remove_const<T>::type         -> std::remove_const_t<T>
std::remove_reference<T>::type -> std::remove_reference_t<T>
std::add_lvalue_reference<T>::type -> std::add_lvalue_reference_t<T>

如果你还在用C++11,但很羡慕这些新类型,你也可以自己动手实现:

1
2
template <typename T>
using remove_const_t = typename remove_const<T>::type;

Item10: 优先选用有界枚举来替代无界枚举

C++的通用规则是name会从属于它所在的scope,只在这个scope内可见。但C++98的enum没有遵循这个规则,enum内定义的name,不只在这个enum内可见,而是在enum所在的整个scope内可见!

1
2
enum Color {black, white, red};
auto white = false; // error! white already declared in this scope

这种enum我们称为“无界枚举”,源自C。C++11增加了一种有界的枚举,来解决name泄漏到整个scope的问题。

1
2
3
4
5
enum class Color {black, white, red};
auto white = false; // fine, no other "white" in this scope
Color c = white; // error!
Color c = Color::white; // fine
auto c = Color::white; // also find, c is 'Color'

enum class定义的枚举又被称为“枚举类”。而且它还有一个特性:无法隐式转换为整数类型(也因此无法隐式转换为浮点类型):

1
2
3
4
5
6
7
8
9
enum Color {black, white, red};
std::vector<std::size_t> primeFactors(std::size_t x);

Color c = red;
...
if (c < 14.5) { // compare Color to double!
auto factors = primeFactors(c); // compute prime factors of a Color!
...
}

上面的转换显然不是我们预期的,而用enum class就可以避免这种转换:

1
2
3
4
5
6
7
8
9
enum class Color {black, white, red};
std::vector<std::size_t> primeFactors(std::size_t x);

Color c = Color::red;
...
if (c < 14.5) { // error! can't compare Color and double!
auto factors = primeFactors(c); // error! can't pass Color to primeFactors
...
}

如果实在需要做这种转换,我们可以用static_cast来做,比如static_cast<std::size_t>(c)

enum class还有一个特性:可以前向声明。C++98中的无界enum则不能前向声明。C++11中的无界enum可以在指定底层类型后前向声明。

1
2
enum Color;          // error!
enum class Color; // fine

为什么C++98只支持enum的定义,而不支持声明?因为C++中每个enum类都对应着一个底层整数类型,但C++98中我们没办法指定这个类型,也没办法确定这个类型。编译器会在看到enum的定义时确定它底层用什么整数类型来存储。而前向声明的一个基本要求是:知道对应类型的大小。如果我们没办法确定enum是用什么类型存储的,我们也就没办法知道enum的大小,也就没办法前向声明。

1
2
3
4
5
6
7
enum Status {
good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF
};

编译器在看到上面的Status定义时,发现它的所有的值范围都在[-1, 200]之间,最合适的类型就是char。如果我们增加一项audited = 500,值范围就变成了[-1, 500],最合适的类型变成了short

但C++98这种过于死板的规定也导致了:每当我们向Status中增加一项,所有引用了它的.cpp文件都需要被重新编译一次。

C++11允许我们指定enum的底层类型,尤其是enum class在不指定时默认使用int。这就保证了我们能安全的前向声明enum和enum class。

1
2
3
4
enum class Status;                 // underlying type is int
enum class Status: std::uint32_t; // underlying type is std::uint32_t
enum Status; // error!
enum Status: std::uint32_t; // underlying type is std::uint32_t

注意:如果要指定底层类型,需要在enum和enum class的声明和定义处都指定相同的底层类型。

那么enum class有什么地方不如enum呢?还真有。

C++11中我们使用std::tuple时需要用到整数常量作为下标:

1
auto val = std::get<1>(uInfo);

如果用enum来代替硬编码的下标,对可读性有好处:

1
2
enum UserInfoFields {uiName, uiEmail, uiReputation};
auto val = std::get<uiEmail>(uInfo);

但换成enum class上面的代码就不行了:enum class没办法隐式转换为整数类型。但我们可以用static_cast

1
2
enum class UserInfoFields {uiName, uiEmail, uiReputation};
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);

这么写太长了,我们可能需要一个辅助函数。注意:std::get是模板,它的参数需要是编译期常量,因此这个辅助函数必须是一个constexpr函数。我们来通过std::underlying_type实现一个将enum class的值转换为它的底层类型值的constexpr函数:

1
2
3
4
template <typename E>
constexpr typename std::underlying_type<E>::type toUType(E e) noexcept {
return static_cast<typename std::underlying_type<E>::type>(e);
}

C++14中我们可以用std::underlying_type_t

1
2
3
4
template <typename E>
constexpr std::underlying_type_t<E>::type toUType(E e) noexcept {
return static_cast<std::underlying_type_t<E>>(e);
}

这样我们在使用std::tuple时就可以:

1
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

还是有点长,但已经好多了,尤其是考虑到enum class相比于enum的其它好处:不污染namespace;不会莫名的做隐式转换。

目录