右值引用、构造函数和完美转发

分类: cpp

背景

最近同学想到一个问题,有关C++的右值引用。我本以为自己读了两遍C++ Primer,总该得心应手,却发现越解释,越迷糊。

这篇文章不会介绍有关右值引用和移动构造函数的概念,而是直接通过描述同学的问题并深入探讨,来尝试解释一些右值引用、移动构造和完美转发的具体用途。

问题

STL里面的std::map<Key, T, Compare, Allocator>::insert,有两个insert方法的重载:

  1. 自C++11

    template <class P>
    std::pair<iterator, bool> insert(P&& value);
    
  2. 自C++17

    std::pair<iterator, bool> insert(value_type&& value);
    

这两个方法有什么不同用途?为什么在C++17时,又增加了第二个方法重载?

一些知识点

  1. std::move 只是一个到右值的类型转换,并不做任何其他实质性的事情。

  2. 右值引用的右值引用可以合成为右值引用,其他的引用组合都只能合称为左值引用:

    typedef int&  lref;
    typedef int&& rref;
    int n;
    lref&  r1 = n; // type of r1 is int&
    lref&& r2 = n; // type of r2 is int&
    rref&  r3 = n; // type of r3 is int&
    rref&& r4 = 1; // type of r4 is int&&
    
  3. 右值引用变量在表达式中是左值:

    int&& x = 1;
    f(x);            // calls f(int& x)
    f(std::move(x)); // calls f(int&& x)
    
  4. 转发引用是一种特殊的引用,可以保留函数参数的原始类别。
    比如,模板函数参数声明为没有const/volatile修饰符的右值引用类型:

    template<class T>
    int f(T&& x) {                    // x is a forwarding reference
        return g(std::forward<T>(x)); // and so can be forwarded
    }
    
    int main() {
        int i;
        f(i); // argument is lvalue, calls f<int&>(int&), std::forward<int&>(x) is lvalue
        f(0); // argument is rvalue, calls f<int>(int&&), std::forward<int>(x) is rvalue
    }
    

实验 1

想搞清楚那两个方法的用途,最好的办法就是用一下他们。

不过map::insert方法过于复杂,我们写一个简单的程序,有类似的方法:

这里可以简单浏览代码,后面会详细展开解释。

#include <iostream>
#include <string>
#include <list>

using namespace std;

struct A
{
    string str;

    A(const A& a) : str(a.str)
    {
        cout << "copy A" << endl;
    }

    A(A&& a) noexcept : str(move(a.str))
    {
        cout << "move A" << endl;
    }

    A(const string& str) : str(str)
    {
        cout << "init A (const string&)" << endl;
    }

    A(string&& str) : str(move(str))
    {
        cout << "init A (string&&)" << endl;
    }

    A(int n) : str(to_string(n))
    {
        cout << "init A (int)" << endl;
    }

    A& operator=(const A& rhs)
    {
        str = rhs.str;
        cout << "copy assign A" << endl;
        return *this;
    }

    A& operator=(A&& rhs) noexcept
    {
        str = move(rhs.str);
        cout << "move assign A" << endl;
        return *this;
    }
};

struct Container
{
    list<A> aList;

    void Add(A&& a)
    {
        cout << "Add(A&&)" << endl;

        aList.push_back(move(a));

        cout << aList.back().str << endl;
        cout << "---" << endl;
    }

    void Add(const A& a)
    {
        cout << "Add(const A&)" << endl;

        aList.push_back(a);

        cout << aList.back().str << endl;
        cout << "---" << endl;
    }

    template <class U, class = enable_if_t<is_constructible_v<A, U>>>
    void Add(U&& v)
    {
        cout << "Add(U&&)" << endl;

        aList.emplace_back(forward<U>(v));

        cout << aList.back().str << endl;
        cout << "---" << endl;
    }
};

int main()
{
    Container c;

    // 传入`A`
    {
        // pass in `A` rvalue
        //   构造自`string` rvalue
        //     构造自`const char *`
        c.Add(A("1"));

        // 传入`A` rvalue
        //   构造自`string` rvalue
        //     构造自`const char *`
        c.Add({ "2" });

        // 传入`A` rvalue
        //   构造自`string` rvalue
        //     构造自`const char *`
        c.Add({ {"3"} });

        // 传入`A` rvalue
        //   构造自`string` lvalue
        {
            string x("4");
            c.Add(A(x));
        }

        // 传入`A` lvalue
        {
            A a("5");
            c.Add(a);
        }
    }

    // 传入`string`
    {
        // 传入`string` rvalue
        //   构造自`const char *`
        c.Add(string("6"));

        // 传入`string` rvalue
        //   moved from`string` lvalue
        {
            string x("7");
            c.Add(move(x));
        }

        // 传入`string` lvalue
        {
            string x("8");
            c.Add(x);
        }
    }

    // 传入`int`
    {
        // 传入`int` rvalue
        c.Add(9);

        // 传入`int` lvalue
        {
            int n = 10;
            c.Add(n);
        }
    }

    // 传入`const char *`
    {
        // This is only applicable to Add(T&&).
        c.Add("11");
    }

    return 0;
}

程序说明

这段程序很简单,但是有点长,所以在这里一段段地解释一下。

Struct A

A拷贝构造移动构造拷贝赋值移动赋值,还有3个类型转换构造

A(const A& a);
A(A&& a) noexcept;
A(const string& str);
A(string&& str);
A(int n);

A& operator=(const A& rhs);
A& operator=(A&& rhs) noexcept;

Struct Container (对list简单的包装)

Container里面有一个list<A>成员变量,以及3个重载方法可以向这个列表增加A类型元素。

下面的代码是简化后的Container的实现(删减了输出等语句)。

list<A> aList;

void Add(A&& a)
{
    aList.push_back(move(a));
}

void Add(const A& a)
{
    aList.push_back(a);
}

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

Main

在主函数,我用通过不同构造函数构造的A调用了不同的Add方法。

后面我会一一分析解释他们。

输出

分析之前,我们先看一下程序输出的结果,以供参考和理解。

init A (string&&)
Add(A&&)
move A
1
---
init A (string&&)
Add(A&&)
move A
2
---
init A (string&&)
Add(A&&)
move A
3
---
init A (const string&)
Add(A&&)
move A
4
---
init A (string&&)
Add(U&&)
copy A
5
---
Add(U&&)
init A (string&&)
6
---
Add(U&&)
init A (string&&)
7
---
Add(U&&)
init A (const string&)
8
---
Add(U&&)
init A (int)
9
---
Add(U&&)
init A (int)
10
---
Add(U&&)
init A (string&&)
11
---

分析

Case 1

// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add(A("1"));
init A (string&&)
Add(A&&)
move A

因为"1"是一个string右值,A(...)也是一个右值,所以我们看到A是通过string&&构造的,然后Add(A&&)被调用。

void Add(A&& a)
{
    aList.push_back(move(a));
}

Add(A&&)里面,A右值要被移动(move)到aList里面。

Case 2

// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ "2" });
init A (string&&)
Add(A&&)
move A

实际上,除了这里用到了列表初始化,和Case 1没有什么不同。

这里的列表初始化会产生一个右值。所以他要匹配一个移动构造函数,同时接收const char *类型的参数。因为const char *可以隐式转换为string,所以最终A(string&&)被调用。

后面的事情和Case 1完全一样。

Case 3

// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ {"3"} });
init A (string&&)
Add(A&&)
move A

这个Case也完全与Case 1和2一样。

内部的列表初始化构造string右值,外部的列表初始化构造A右值。

Case 4

// 传入`A` rvalue 构造自`string` lvalue
string x("4");
c.Add(A(x));
init A (const string&)
Add(A&&)
move A

这个Case和前面三个Case的唯一区别在于这里传入A构造函数的是一个string左值。

所以不同的A构造函数,即A(const string&)被调用。后面仍然一样。

Case 5

// 传入`A` lvalue
A a("5");
c.Add(a);
init A (string&&)
Add(U&&)
copy A

这个Case,一个A左值被传入Add,所以它匹配到Add(U&&),此时U = A&

你也许会问Add(A& &&)是啥?记得前面在一些知识点里讲过转发引用嘛?

那么,既然传入的是A左值,为什么不调用A(const A&)呢?

为什么要调用呢?如果调用A(const A&),需要额外的const转换。但模板方法是直接匹配的。

你可以试试在A a("5")前面加一个const,然后看看怎么样。

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

// when U = A&, the equivalent method is:
void Add(A& v)
{
    aList.emplace_back(v);
}

emplace会直接转发参数到构造函数,并在需要的内存位置中直接构造相应的对象。

因为vA&类型,emplace_back就调用了A的拷贝构造来使其就位。

Case 6

// 传入`string` rvalue 构造自`const char *`
c.Add(string("6"));
Add(U&&)
init A (string&&)

显然这里U = string

所以,在调用Add之前,A并不需要被构造出来。

Add内部,list<A>::emplace_back被调用,它会将参数直接转发到A的构造函数。

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

// when U = string, the equivalent method is:
void Add(string&& v)
{
    aList.emplace_back(move(v));
}

这里,U = stringv是一个string右值,A通过v来构造,所以A(string&&)构造函数被调用。

可以看到,A构造后,并没有额外的移动或拷贝。这还是因为emplace会直接转发参数,将A构造在所需的内存位置。既然它已经就位,就没有必要在移动或拷贝了。

本例和Case 5不同,因为Case 5传入的是左值。

Case 7

// 传入`string` rvalue moved from`string` lvalue
string x("7");
c.Add(move(x));
Add(U&&)
init A (string&&)

本例与Case 6完全一样,因为我们move了一个左值,即转换为了右值。

Case 8

// 传入`string` lvalue
string x("8");
c.Add(x);
Add(U&&)
init A (const string&)

这里U = string&,所以A在调用Add前并不需要被构造。

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

// when U = string&, the equivalent method is:
void Add(string& v)
{
    aList.emplace_back(v);
}

Add内部,A通过emplace_back由一个string左值构造。所以A(const string&)构造函数被调用。

A已就位,无需后续操作。

Case 9

// 传入`int` rvalue
c.Add(9);
Add(U&&)
init A (int)

这里U = int

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

// when U = int, the equivalent method is:
void Add(int&& v)
{
    aList.emplace_back(move(v));
}

A只有一个能接受int右值的构造函数,也就是A(int)

Case 10

// 传入`int` lvalue
int n = 10;
c.Add(n);
Add(U&&)
init A (int)

本例与Case 9唯一不同是传入了int左值,即U = int&

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

// when U = int&, the equivalent method is:
void Add(int& v)
{
    aList.emplace_back(v);
}

A的构造函数能接受int左值的还是只有A(int)

Case 11

// 传入`const char *`
c.Add("11");
Add(U&&)
init A (string&&)

这里U = const char &[3],或者也可以当作是const char *&

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

// when U = const char *&, the equivalent method is:
void Add(const char*& v)
{
    aList.emplace_back(v);
}

const char *& v在被emplace_back转发至构造函数时,被隐式转换为string,所以A(string&)被调用。

实验 2

上面的实验和分析解释了不同的构造函数和Add方法具有怎样的行为。但现在我们更想知道为什么我们需要他们。

所以,实验2中,我会移除一个Add方法,看看会发生什么。

移除Add(U&&)

本实验中,我将模板方法Add注释掉了。

void Add(A&& a)
{
    aList.push_back(move(a));
}

void Add(const A& a)
{
    aList.push_back(a);
}

/*
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}
*/

立刻会发现,程序编译不通过。错误来自:

c.Add("11");

因为现在它无法匹配到一个合适的Add重载方法,毕竟没有A可以直接从const char *构造。

于是干脆暂时删掉这个Case 11,其他的Case都还能编译通过。

我们来看一下变化!

Case 5 变化

change-1-case-5

// 传入`A` lvalue
A a("5");
c.Add(a);

因为现在没有模板方法Add,所以Add(const A&)被调用。没有什么性能上的差异。

Case 6 变化

change-1-case-6

// 传入`string` rvalue 构造自`const char *`
c.Add(string("6"));

现在没有Add方法直接接受string,所以,Add(A&&)被调用。在这之前,string右值必须被转换构造为A右值。

可以看到构造和Add的调用顺序发生了变化,也证实了前面的说法。

最后,A需要被移动到aList中,也带来了额外的运行代价。

也许你会问,为什么不在这里用emplace_back来避免移动这一运行代价。

其实emplace确实可以在这里使用,但代价是去除不掉的。因为emplace也只是转发传入的A右值,仍然会调用A的移动构造函数。这和前一实验中的emplace不同,之前是传入的string右值,直接通过它构造A,构造被推迟到了emplace这里;而这里我们在emplace之前已经有一个A,是不就位的,移动是不可避免的。

Case 7-10 变化

change-1-case-7-10

在这几例中,A的构造过程也提前了,正如Case 6一样。不过,这几例会通过不同的构造函数构造A的右值。

后面A右值传入Add(A&&)后的过程,与Case 6完全一样。

实验 2 总结

可以看到,没有模板方法Add,我们就没有办法利用好emplace方法,也就避免不了一些本可避免的额外移动或拷贝的代价。

那么,Add(A&&)又有什么用呢?它是否能被模板方法Add替代?

让我们继续实验!

实验 3

移除Add(A&&)

这里,我注释掉了Add(A&&)方法。

/*
void Add(A&& a)
{
    aList.push_back(move(a));
}
*/

void Add(const A& a)
{
    aList.push_back(a);
}

template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
    aList.emplace_back(forward<U>(v));
}

让我们再来看看变化!

Case 1,4 变化

change-2-case-1

// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add(A("1"));

change-2-case-4

// 传入`A` rvalue 构造自`string` lvalue
string x("4");
c.Add(A(x));

因为没有Add(A&&)匹配,他们会转而匹配模板方法,U = A。没有什么其他运行代价的变化。

Case 2-3 Changes

change-2-case-2-3

// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ "2" });
// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ {"3"} });

因为花括号初始化(列表初始化)也是调用构造函数,需要知道具体的构造类型。模板方法是无法提供一个具体类型的,所以Add(const A&)就被调用了。

当然,后果就是之前的移动变成了现在的拷贝,增加了运行代价。

实验 3 总结

Add(A&&)对于花括号(列表)初始化很有用,因为编译器需要匹配类型才能正确调用构造函数,但模板方法无法提供具体的类型。

你可以试试把Add(A&&)Add(const A&)都删掉,那么Case 2和3就无法编译通过了。

回到问题

  1. 自C++11

    template <class P>
    std::pair<iterator, bool> insert(P&& value);
    
  2. 自C++17

    std::pair<iterator, bool> insert(value_type&& value);
    

首先,我们要知道map<Key, T>value_typepair<const Key, T>。而pair拥有非常多的构造函数。

所以,为了能够利用emplace的好处,以及尽可能多地使用移动构造而非拷贝构造,同时提供上述两种方法是必要的。

gcc 7.1 和 gcc 9.1 比较

请放大网页或右键图片从新标签页打开查看。

请忽略编译器参数上的–std=c++14,看起来对有没有map::insert(value_type&&)这个库函数没啥用。

map-gcc-7 map-gcc-9

新问题

然而我现在很困惑Add(const A&)

在实验1中,他根本没被调用。

实际上,想调用它,除非传入的正好是const A左值。但单独去处理const左值的意义是什么呢?

尤其是在map中,也提供了:

std::pair<iterator, bool> insert(const value_type& value);

这是有必要的吗?

翻译