RValue References, Move Semantics and Perfect Forwarding

Item 23: Understand std::move and std::forward

std::move仅仅是一个类型转换,将实参转换为右值:

1
2
3
4
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

因此,使用std::move不代表触发移动语义。例如,对const对象使用std::move得到的是常量右值引用,它无法被匹配到移动构造函数,只能被拷贝构造:

语义上,const对象也不应该被移动。因此不要将需要移动的对象声明为const

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Widget {
public:
Widget() = default;
Widget(Widget&& rhs) noexcept {
cout << "move ctor" << endl;
}
Widget(const Widget& rhs) {
cout << "copy ctor" << endl;
}
};

int main() {
const Widget w;

Widget w1(w); // copy ctor
Widget w2(std::move(w)); // 还是copy ctor,因为const Widget &&无法转换为Widet &&
}

std::forward<T>是一个有条件的右值转换。当T是原始类型或右值引用时,std::forward<T>是右值引用;当T是左值引用时,std::forward<T>是左值引用。std::forward<T>相当于static_cast<T&&>

Item 24: Distinguish universal references from rvalue references

如果函数模板的形参类型形如T&&T为模板参数,且需要推导得知),该形参是一个通用引用(universal reference)。

  • 如果T不是模板参数,则仍然是右值引用。

    1
    void foo(int &&i); // 右值引用
  • 如果形参类型不是严格的T&&,带有其他修饰,则仍然是右值引用:

    1
    2
    3
    4
    5
    template <typename T>
    void f(std::vector<T>&& param); // 右值引用

    template <typename T>
    void g(const T&& param); // 右值引用
  • 如果T是模板参数,但调用时并不需要推导,则仍然是右值引用。如std::vector::push_back

    这里模板参数value_type早在创建std::vector<value_type>对象时就已经被推导出来了。

    1
    void push_back(value_type&& __x)

使用auto&&声明的变量也是一个通用引用(本质上,使用auto还是发生了模板类型推导):

1
auto &&var = 1;

Item 25: Use std::move on rvalue references, std::forward on universal references

转发右值引用时,应该使用std::move无条件将其转换为右值;转发通用引用时,应该使用std::forward,仅在通用引用指向右值时将其转换为右值。

这个例子错误地在通用引用中使用了std::move,代价是即便Widget::setName接收了左值,它也会错误地被移动给Widget::name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {
public:
template<typename T>
void setName(T&& newName) {
name = std::move(newName);
}
private:
std::string name;
};

int main() {
Widget w;
std::string newName = "Adela";

w.setName(newName);

cout << newName << endl; // newName已经被移动走了!
}

对于按值返回的函数,如果返回值是右值/通用引用的形参,应该用std::move/std::forward修饰:

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

如果Matrix支持移动,operator+的返回值从右值引用构造,触发移动语义,减少一次形参到返回值的拷贝;如果Matrix不支持移动,返回值也能拷贝构造,不会有编译错误。

通用引用同理,使用std::forward包装返回值使我们可以在形参为右值时防止一次拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Fraction {
public:
Fraction() {}
Fraction(const Fraction&) { cout << "copy ctor" << endl; }
Fraction(Fraction&&) { cout << "move ctor" << endl; }
void reduce() {}
};

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

int main() {
Fraction f;
auto f2 = reduceAndCopy(f); // 触发拷贝构造函数
auto f3 = reduceAndCopy(Fraction{}); //触发移动构造函数
return 0;
}

然而,我们不应该错误地对按值返回的局部变量使用std::move

1
2
3
4
5
6
7
8
9
10
11
12
Fraction foo() {
Fraction f;

return f;

// 弄巧成拙
// return std::move(f)
}

int main() {
Fraction result = foo();
}

foo()的这种行为称为返回值优化(Return Value Optimization),标准要求(但非强制)编译器在这种情况下直接在返回值的内存处构建Fraction对象,从而省去严格字面执行指令带来的拷贝。

视返回的局部变量是否有名称,RVO又被分为named RVO(NRVO,本例)和unnamed RVO(URVO)。C++17后,标准规定URVO优化必须强制进行。

NRVO的条件之一,就是返回值的类型与函数的返回类型(在忽略cv限定符后)一致,而return std::move(f)破坏了这一条件,因为现在返回值的类型是Fraction&&。此时NRVO无法进行,局部变量必须先被构造,然后移动到返回值所在的位置,产生了负优化。

上面提到NRVO不是强制的,但标准指出,只要NRVO的条件满足,哪怕编译器不能进行NRVO,它也必须把返回值看作右值。因此用std::move修饰返回值仍然是多余的。

Item 26: Avoid overloading on universal references

重载接收通用引用形参的函数,可能不会达到预期的结果,因为通用引用很可能被推导为一个精确匹配的类型,导致重载函数不被选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vector<std::string> vs;

template <typename T>
void foo(T&& t) {
cout << "foo(T&&)" << endl;
vs.push_back(std::forward<T>(t));
}

void foo(int i) {
cout << "foo(int)" << endl;
}

foo("Hello"); // foo(T&&)
foo(2); // foo(int)
foo(2ULL); // foo(T&&)

这个例子中,我们的本意是用foo(T&&)处理字符串类型,用foo(int)处理整型。

  • 如果用2作为实参调用foo(),在选择重载版本时,foo(T&&)推导出Tint,与foo(int)都是精确匹配,此时优先选择更特化的版本(即非模板函数),符合预期。
  • 但如果实参是2ULLfoo(T&&)可以推导出Tunsigned long long,是精确匹配,foo(int)则需要做一次隐式类型转换,因此优先级不如foo(T&&),这样的行为不符合我们的预期。

尤其,不要使用接收通用引用形参的构造函数,如果实参是non-const左值,或者是派生类对象的引用,它们可能会劫持对普通拷贝/移动构造函数的调用。下面的例子中,Person可以从std::string构造,,我们错误地用一个通用引用构造函数代替接收左/右值引用的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
public:
// Person(const std::string& name) : name(name) {}
// Person(std::string&& name) : name(std::move(name)) {}

template <typename T>
Person(T&& name) : name(std::forward<T>(name)) {}
private:
std::string name;
};

class Student: public Person {
public:
Student(const Student& other) : Person(other) {} // 错误,调用了Person(T&&)而不是拷贝构造函数
};

int main() {
Person p1("John");
Person p2(p1); // 错误:调用了Person(T&&)而不是拷贝构造函数
}

使用Person对象p1构造Person会匹配到通用引用的版本,因为这是精确匹配,而调用拷贝构造函数的形参多一个const

使用派生类Student的引用构造Person会匹配到通用引用的版本,因为这是精确匹配,而调用拷贝构造函数需要一个const Student&const Person&的类型转换。

Item 27: Familiarize yourself with alternatives to overloading on universal references

tag dispatch是一种重载通用引用的替代方案,foo()判断T是否为整型,设置一个tag值,并调用fooImpl()的不同重载版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
void fooImpl(T&& t, std::true_type) {
cout << "handle int" << endl;
}

template <typename T>
void fooImpl(T&& t, std::false_type) {
cout << "handle non-int" << endl;
}

template <typename T>
void foo(T&& t) {
fooImpl(std::forward<T>(t), std::is_integral<std::remove_reference_t<T>>());
}

其实直接写成这样也可以:

1
2
3
4
5
6
7
8
template <typename T>
void foo(T&& t) {
if (std::is_integral_v<std::remove_reference_t<T>>) {
cout << "handle int" << endl;
} else {
cout << "handle non-int" << endl;
}
}

这个例子中,我们为不同类型的实参提供了统一的foo(T&&)API。但对于Item26中通用引用构造函数的例子,由于编译器会自动生成拷贝/移动构造函数,有些实参会匹配到自动生成的版本上,无法让所有用户都使用Person(T&&),再在该函数内部做分发。

这种情况下,我们可以用std::enable_if让通用引用函数模板有条件地启用:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
template <typename T, typename = std::enable_if_t<
!std::is_base_of_v<Person, std::decay_t<T>>>>
Person(T&& n) : name(std::forward<T>(n)) {
static_assert(std::is_constructible_v<std::string, T>,
"Parameter n can't be used to construct a std::string");
}

private:
std::string name;
};

std::enable_if_t<B, T = void>当且仅当仅当Btrue时返回类型T,否则不返回类型(这会导致模板推断失败,起到了有条件启用模板的效果)。

std::decay_t<T>移除类型T的引用和cv限定符。

std::is_base_of_v<Base, Derived>BaseDerived的基类类型是返回true,否则返回false

因此,以上的模板只在Person不是decay_t<T>的基类类型时才会启用,Item 26中的两个例子(传入Person&形参,传入const Student&形参)都不满足这一条件,不会匹配到通用引用函数模板,而会正常地匹配到拷贝构造函数。

最后,我们在Person(T&&)的实现中加入一个static_assert,保证在非预期地被调用时打印出供参考的错误信息。

Item 28: Understand reference collapsing

本节介绍了引用折叠,即编译器推导出引用的引用时,当且仅当两级引用均为右值引用,最终结果才是右值引用,否则是左值引用。

Item 29: Assume that move operations are not present, not cheap, and not used

移动语义并不是总有优势,因为有的对象不支持移动,有的对象虽然支持移动,但移动并不比拷贝快(例如std::array,拷贝和移动都是操作栈上对象)。

Item 30: Familiarize yourself with perfect forwarding failure cases

perfect forwarding在一些场景下不能正确转发参数:

1
2
3
4
template <typename... T>
void fwd(T&&... args) {
foo(std::forward<T>(args)...);
}
  • 花括号初始化。编译器不会将形如{1, 2, 3}的实参推断为std::initializer_list,而是不进行推导报出编译错误。

    1
    2
    3
    4
    5
    void foo(std::vector<int> v) {}

    fwd(std::vector<int>{1, 2, 3}); // 正确
    fwd(std::initializer_list<int>{1, 2, 3}); // 正确
    fwd({1, 2, 3}); // 错误
  • 0或者NULL作为空指针。它们会被推导成整型而不是指针类型,不能作为空指针被完美转发。应该使用nullptr

  • 转发常量折叠(constant folding)的对象。如果编译期常量所在的表达式只使用了它的值,而没有使用地址,编译器允许该对象只有声明没有定义,在编译期用常量替换掉对该对象的使用。

    1
    2
    3
    4
    5
    6
    7
    8
    class Widget {
    public:
    static const std::size_t max_size = 100;
    };

    int main() {
    cout << Widget::max_size << endl;
    }

    但是转发常量折叠对象时,由于使用了通用引用,需要该对象的地址(即所谓odr-used),因此不能没有定义,否则会在链接时报错。此时需要加上定义:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Widget {
    public:
    static const std::size_t max_size = 100;
    };
    const std::size_t Widget::max_size;

    int main() {
    fwd(Widget::max_size);
    }
  • 转发一个被重载的函数名称。编译器无法仅仅从一个函数名判断出是在指代哪个重载版本。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void foo(void (*)(int)) {}

    void bar(int) {}
    void bar(double) {}

    int main() {
    foo(bar); //正确,可以通过形参类型判断使用哪个bar
    fwd(bar); //错误,无法从模板形参出类Br); //错误,无法从模板形参判断出bar的类型
    fwd(static_cast<void (*)(int)>(bar)); //正确
    }
  • bitfield无法被取址,自然无法被绑定到引用,因此无法被完美转发:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void foo(std::size_t) {}

    struct Header {
    uint32_t a: 16;
    uint32_t b: 16;
    };

    int main() {
    Header h;
    foo(h.a); // 正确
    fwd(h.a); // 错误
    }

Lambda Expressions

Lambda表达式的类型是一个闭包类ClosureType,这个闭包类将会有一个重载的operator (),包含lambda表达式中的代码。通过capture list捕获的变量将会(以值或引用的形式)成为ClosureType的成员。

Item 31: Avoid default capture modes

默认捕获模式是指在捕获列表里写上=(按值捕获)或者&(按引用捕获)。

使用按引用捕获可能会导致空悬引用的问题:如果lambda创建的闭包生命周期超过了捕获的局部变量的生命周期,闭包中的引用会变成空悬引用。下面的例子中,lambda创建时引用捕获的divisor变量在使用时已经被销毁,导致未定义行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::vector<std::function<int(int)>> v;

void foo() {
int divisor = 2333;

auto lambda = [&](int x) {
cout << divisor << endl;
return x / divisor;
};
v.push_back(lambda);
}

int main() {
foo();
std::cout << v[0](2) << std::endl; // UB
}

作者建议总是显式捕获局部变量/形参等可能超出生命周期的变量,这样虽然仍会出现空悬引用,但更容易被编程者注意到并修复bug。

使用默认按值捕获可以避免在上述场景下出现问题,但如果捕获的局部变量是一个指针,还是可能出现空悬指针的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::vector<std::function<int(int)>> v;

void foo() {
int *divisor = new int(3);

auto lambda = [=](int x) {
cout << *divisor << endl;
return x / *divisor;
};
v.push_back(lambda);

delete divisor;
}

int main() {
foo();
std::cout << v[0](2) << std::endl;
}

这个例子有点刻意,但是在下面的场景中,我们不经意间就会遇到默认按指捕获时的空悬指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::vector<std::function<int(int)>> filters;

class Widget {
public:
Widget(int d) : divisor(d) {}
void addFilter() const;
private:
int divisor;
};

void Widget::addFilter() const {
auto lambda = [=](int value) {
return value / divisor;
};

filters.push_back(lambda);
}

Widget::addFilter()中,我们创建lambda,使用=进行默认值捕获,并在lambda体内使用了divisor变量,这段代码可以编译。

然而,默认捕获仅限于lambda创建时的作用域内的non-static变量。Widget::divisor是类的成员变量,因此根本不在默认捕获的范围之内。为什么代码可以编译呢?因为=捕获的其实是Widget::addFilter中的this指针的值,在类的成员函数作用域中,编译器认为divisor就是this->divisor。这样的问题是一旦lambda闭包在Widget对象的生命周期结束后被使用,就会带来空悬指针的问题:

1
2
3
4
5
6
7
8
int main() {
{
std::unique_ptr<Widget> w(new Widget(233));
w->addFilter();
}

filters[0](10); // UB
}

可以通过显式定义局部变量,再值捕获局部变量解决这个问题:

1
2
3
4
5
6
7
8
9
10
void Widget::addFilter() const {
auto divisorLocal = divisor;

auto lambda = [divisorLocal](int value) {
cout << divisorLocal << endl;
return value / divisorLocal;
};

filters.push_back(lambda);
}

C++14以后,还可以为捕获变量指定initializer,也可以解决这个问题:

1
2
3
4
5
6
7
8
void Widget::addFilter() const {
auto lambda = [divisor = divisor](int value) {
cout << divisor << endl;
return value / divisor;
};

filters.push_back(lambda);
}

注意:默认捕获也不能捕获静态对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Widget::addFilter() const {
static int divisor = 233;

// 编译错误:divisor不具有automatic storage duration
// auto lambda = [divisor](int value) {

auto lambda = [divisor = divisor](int value) { // 正确
cout << divisor << endl;
return value / divisor;
};

filters.push_back(lambda);

divisor = 666;
}

Item 32: Use init capture to move objects into closures

Item 31已经展示了C++14所支持的初始化捕获,它可以初始化一个捕获变量。除了Item31的用途以外,它还可以用于以移动的方式捕获变量:

1
2
3
4
5
6
7
8
9
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};

auto lambdaFunc = [v = std::move(v)]() {
for (auto i : v) {
std::cout << i << std::endl;
}
};
}

这是C++11的lambda表达式所不支持的,但可以通过一些写法达到类似的目的:

  • 手写一个函数对象,把需要移动捕获的对象作为右值引用传给该对象的某个成员,然后调用之。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class func {
    public:
    func(std::vector<int>&& v) : _v(std::move(v)) {}

    void operator() () {
    for (auto i : _v) {
    std::cout << i << std::endl;
    }
    }
    private:
    std::vector<int> _v;
    };

    func{std::move(v)}();
  • 使用std::bind。以下的例子通过std::movev移动到了bind对象中,调用bind对象的operator ()时,将bind对象中保存的std::vector<int>(这是一个移动构造出的左值)传给bind对象中保存的lambda对象。

    当lambda没有用mutable声明时,它生成的闭包类的operator ()会带有const修饰,并且不能修改捕获的对象。因此,下面例子中std::bind使用的lambda表达式的形参使用const修饰,这样就不可以修改形参v,行为与上面的lambdaFunc一致。

    1
    2
    3
    4
    5
    auto func = std::bind([](const std::vector<int>& v) {
    for (auto i : v) {
    std::cout << i << std::endl;
    }
    }, std::move(v));

Item 33: Use decltype on auto&& parameters to std::forward them

C++14支持用auto作为lambda的形参类型,这种lambda称为generic lambda

实现上,generic lambda生成的闭包类中的operator ()是一个函数模板:

1
2
3
4
5
6
7
8
auto f = [](auto x){};

// 相当于
class SomeCompilerGeneratedClassName {
public:
template <typename T>
void operator()(T x) {}
};

以上f的声明方式是值传递,如果lambda体中需要把x转发给另一个函数,会丢失x的左/右值引用信息:

1
2
3
4
5
6
7
8
9
10
11
void normalize(int& x) { cout << "lvalue" << endl; }
void normalize(int&& x) { cout << "rvalue" << endl; }

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

int main() {
int i = 233;

f(i); //输出:lvalue
f(233); //输出:lvalue
}

我们希望使用perfect forwarding。一般来说,在perfect forwarding的标准用法中,std::forward的类型会使用模板参数T,但在这个例子里,通用引用是以auto &&的形式出现的,并没有模板参数。此时我们使用decltype(x)作为std::forward实例化的类型:

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

这是做是正确的,因为:

  • 如果f的实参是左值,auto&&(即x的类型)在引用折叠后被推断为左值引用,std::forward<decltype(x)>(x)是标准用法,仍然为左值引用。
  • 如果f的实参是右值,auto&&(即x的类型)被推断为右值引用,std::forward使用右值引用实例化(标准用法是使用原始类型),由于引用折叠,std::forward<decltype(x)>(x)返回右值引用。

Item 34: Prefer lambdas to std::bind

本节列举了一些可以被lambda替代的,晦涩的std::bind用法,指出在C++14后可以无脑用lambda替代std::bind

在C++11中,lambda语义的不完善导致无法在以下两个场景中取代std::bind

  • 以移动语义捕获变量(Item 32中描述了),C++14允许lambda初始化捕获以解决这一问题。

  • 接收任意类型的多态函数对象:

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

    int main() {
    PolyWidget pw;
    auto boundPW = std::bind(pw, std::placeholders::_1);

    boundPW(1930);
    boundPW("hello");
    boundPW(5.5);
    }

    C++14中的generic lambda(在Item 33中描述)也可以达到这一目的。

The Concurrency API

Item 35: Prefer task-based programming to thread-based

本节介绍了一些方法论:有时可以将函数传给std::async进行任务级并发,把线程管理丢给标准库做,不一定要丢进一个std::thread。详见原文。

Item 36: Specify std::launch::async if asynchronicity is essential.

std::async使用的策略可以是以下两种状态的组合:

  • std::launch::async:函数必须在不同的线程中异步执行

  • std::launch::deferred:函数惰性求值,仅在结果被请求时执行,否则不会执行。

    “结果被请求”:指的是在std::async返回的std::future(或者它共享出去的std::shared_future)上调用get()或者wait()

创建std::async(f)默认的策略是std::launch::async | std::launch::deferred,因此此时不能保证f能够并发执行,不知道f在哪个线程上执行,甚至不知道f是否会执行(如果没用到f的结果就不会执行)。

因此,如果希望任务被异步执行,应该使用std::async(std::launch::async, f)

Item 37: Make std::threads unjoinable on all paths

std::thread对象在离开作用域被析构时必须处于unjoinable的状态,否则会导致程序异常终止。

std::thread的以下状态是unjoinable

  • 默认初始化的std::thread(未关联到线程)
  • 已经被移动走的std::thread(线程现在关联到新的std::thread
  • 已经被join或者detachstd::thread
1
2
3
4
5
6
7
8
int main() {
std::thread t([]() {
std::cout << "Hello, World!" << std::endl;
});

// t.join();
return 0;
}

这是设计是因为std::thread被析构时如果关联的线程还在运行,无法给出一个妥善的处理方案,索性禁止这种行为:

  • 如果隐式join,析构函数就会阻塞,直到关联线程运行完。但std::thread离开作用域往往意味着用户已经不希望关联线程继续运行了,这种行为很奇怪。
  • 如果隐式detach,关联线程继续运行,如果使用了位于被析构的std::thread所在作用域(已被销毁)的对象,将出现非法的内存访问。

可以用一个RAII类保证std::thread析构前一定处于unjoinable状态,见书中细节。

Item 38: Be aware of varying thread handle destructor behavior

std::thread,以及未使用std::launch::deferredstd::async创建的std::future,都关联到了特定的OS线程,称为OS线程的handle。但是std::future的析构行为与std::thread不同:

  • 默认行为:销毁std::future对象,即:

    • 如果是由non-deferredstd::async创建的,相当于detach了关联的OS线程,(OS线程继续运行,但是与当前future无关)
    • 如果是由deferredstd::async创建的,相当于关联的函数永远不会执行了。
  • 特殊行为:阻塞析构,直到关联的函数执行完成。这种情况相当于隐式join了关联的OS线程,仅当满足下面条件时出现:

    • future对象由std::async创建。

    • 创建时使用的策略是std::launch::deferred

    • future对象其共享状态的最后一个引用者。(因为可能有std::shared_future存在)

      std::future关联到一个共享状态,该共享状态是堆上对象,存储了被调函数的执行结果。

Item 39: Consider void futures for one-shot event communication

std::promise<void>std::future<void可以起到类似条件变量的效果,检测线程在某事件发生时通知响应线程:

1
2
3
4
5
6
7
std::promise<void> p;

// 检测线程
p.set_value();

// 响应线程
p.get_future().wait();

用条件变量和互斥锁的实现则是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::condition_variable cv;
std::mutex m;
bool flag(false);

// 检测线程
{
std::lock_guard<std::mutex> g(m);
flag = true;
}
cv.notify_one();

// 响应线程
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [] { return flag; });
}

但是,基于promisefuture的方案只能进行一次通信,而且需要使用堆上内存共享状态。

Item 40: Use std::atomic for concurrency, volatile for special memory

std::atomic保证变量被原子读写,同时(在sequential consistency下)防止所有原子读写操作的重排序;volatile告知编译器不要优化掉对该变量的内存读写操作。它们是正交的,没有什么关系,可以同时使用。

Tweaks

Item 41: Consider pass by value for copyable parameters that are cheap to move and always copied

如果希望一个函数对左值实参和右值实参表现出不同的行为(如拷贝左值,移动右值),我们有几种可选的做法:

  • 定义两个重载版本,分别接收左值和右值实参:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Widget {
    public:
    void addName(const Internal& name) {
    cout << "lvalue" << endl;
    a = name;
    }
    void addName(Internal&& name) {
    cout << "rvalue" << endl;
    a = std::move(name);
    }
    private:
    Internal a;
    };

    代价:若实参是左值则需要一次拷贝,若实参是右值则需要一次移动。

    缺点:需要为类似的操作维护两份代码。

  • 定义一个形参为通用引用的函数模板:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Widget {
    public:
    ...
    template<typename T>
    void addName(T&& name) {
    cout << "universal reference" << endl;
    a = std::forward<T>(name);
    }
    ...
    }

    代价:若实参是左值则需要一次拷贝,若实参是右值则需要一次移动。

    缺点:

    • 有些实参类型无法被完美转发,见Item 30。
    • 如果向Widget::addName传入一个可以转换为Internal的类型,就会为该类型实例化一个新的模板函数,可能导致代码膨胀。

    通用引用可能可以提升性能,它避免了仅仅为了符合形参声明的类型而创建临时对象:假如类型U可以转换为Internal,同时Internal还有一个赋值运算符,可以直接接收类型U的对象,使用通用引用可以减少一次临时对象的构造和析构:

    • 将类型U对象的右值引用作为实参调用addName时(如addName("Hello")),T直接被推断为U而不是Internal,形参name直接被赋给Widget::a
    • 如果不使用通用引用,由于Widget::addName已经指定了形参类型是Internal的(左值/右值)引用,形参name将是一个从实参创建的Internal临时对象,然后被移动到Widget::a,最后被析构,带来了构造函数和析构函数的开销。

    一个这样的例子是Internal = std::stringU = const char *

  • 按值传递,实参先被拷贝(左值)/移动(右值)到形参,然后再移动到Widget::a。:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Widget {
    public:
    ...
    void addName(Internal name) {
    cout << "by value" << endl;
    a = std::move(name);
    }
    ...
    }

​ 代价:若实参是左值则需要一次拷贝+一次移动,若实参是右值则需要两次移动。

使用按值传递,我们可以不维护两个函数重载版本,同时避免通用引用带来的问题。但作为代价,不管实参是左值还是右值类型,我们都需要多做一次移动。作者指出,只有满足下列条件时,我们才应该做出这样的trade off并使用按值传递:

  1. 函数处理的不能是不可拷贝而只能移动的类型,对于这样的类型,我们不需要接收左值引用的重载版本,不存在维护两份代码的痛点,使用按值传递的优势不存在,反而白白多做了一次移动。

  2. 函数处理的类型的移动开销必须可以接受,否则多做的一次移动开销会很巨大。

  3. 形参在函数中必须总是被移动,假如形参只在某些分支中需要移动,那对于不需要移动的执行路径,相较于引用形参的版本,我们白白多做了一次拷贝/移动。

    1
    2
    3
    4
    void addName(Internal name) { // 此时不推荐按值传递
    if (name.length() > 10)
    a = std::move(name);
    }
  4. 具体情况具体分析,书中举了一个改密码的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Password {
    public:
    explicit Password(std::string pwd)
    : text(std::move(pwd)) {}
    void changeTo(std::string newPwd)
    { text = std::move(newPwd); }

    private:
    std::string text;
    };

    这里changeTo是按值传递,新密码被存储在为newPwd分配的内存中,再被移动到text中。text的旧密码所用的内存被释放。

    然而如果采用引用传递,且新密码比旧密码更短,移动赋值可能可以直接把新密码放到旧密码所在的内存中,省去一次内存分配和释放。这种情况下,传值的代价就远远大于传引用,不应该按值传递。

  5. 函数处理的类型不能是某个继承体系中的基类,否则如果按值传递一个派生类对象进来,它的派生部分就被截断了。使用引用传递会触发动态绑定,不存在这个问题。

Item 42: Consider emplacement instead of insertion

一般来说,使用emplacement函数(如emplace_back)可以直接传入需要放入的对象的构造函数参数,并直接在目标位置上构造对象,因此比起接收对象的insert函数(如push_back)可以省去临时对象的构造和析构,往往可以获得性能提升。

如果容器不允许重复值,加入元素时往往需要先构造出来,再与已有元素比较是否重复,这种情况下emplacement没有性能优势。

使用emplacement需要关注异常安全的问题。在下面的例子中,使用push_back()时如果内存分配失败,临时构造的shared_ptr会被正常析构,不会有内存泄漏;但使用emplace_back()时如果内存分配失败,由于shared_ptr对象还没有构造出来,使用new分配出的内存泄漏了。本质上,这么使用违背了RAII原则,没有立刻将new出来的指针交给资源管理对象。

1
2
3
4
std::vector<std::shared_ptr<int>> v;

v.push_back(std::shared_ptr<int>{new int(1), [](int* p) { delete p; }});
v.emplace_back(new int(1), [](int* p) { delete p; });

emplacement使用直接初始化insert使用拷贝初始化,因此emplacement会使用explicit标记的构造函数而insert不会。

之所以拷贝初始化不能使用explicit的函数,是因为语义上拷贝初始化先(隐式地,因此不能使用带explicit的构造函数)构造了临时对象,再拷贝给声明的对象。尽管编译器可能会优化这个过程,让它和直接初始化一致,但仍然需要满足字面执行所需的条件。

1
2
3
4
5
6
7
8
9
// std::regex接收const char*参数的构造函数是explicit的

std::regex r1("a"); // 正确,直接初始化
std::regex r2 = "a"; // 错误,拷贝初始化

std::vector<std::regex> v;

v.push_back("a"); // 错误
v.emplace_back("a"); // 正确