在C++中,对象默认都是值语义,比如在对象A里面定义了成员变量B和C,那么B和C会被直接放在A的内存空间里,这样做的优点是保证了内存访问的局部性,这在现代处理器架构上具有绝对的性能优势。但缺点是,在对象复制时,具有很大的开销。而在Java或者C#里面,默认存储的是引用或者说是指针。因为C++存储完整对象这个特性,而这会导致大的复制开销,所以C++需要移动语义这一优化,而在Java或者C#中则根本不需要这一概念。一句话总结就是,移动语义使得C++里返回大对象(比如容器)的函数和运算符的性能得到极大提高。

本文首先以一个简单的自定义字符串实现类的例子来说明C++中不经意间的隐形的对象复制的开销。进而引出右值引用和移动语义。更进一步,在上一篇自定义容器的基础上,来查看普通的容器复制操作所产生的拷贝开销。最后介绍使用移动拷贝和赋值来优化对象的拷贝,这期间会使用到移动std::move和转发std::forward。

类操作中的隐性赋值开销


首先实现一个简单的字符串类:CMyString。

#include <string.h>
#include <iostream>
using namespace std;
class CMyString
{
public:
    CMyString(const char *p = nullptr)
    {
        cout << "CMyString(const char* p)" << endl;
        if (p != nullptr)
        {
            _pstr = new char[strlen(p) + 1];
            strcpy(_pstr, p);
        }
        else
        {
            _pstr = new char[1];
            *_pstr = '\0';
        }
    }
    //拷贝构造函数
    CMyString(const CMyString &src)
    {
        cout << "CMyString(const CMyString &)" << endl;
        _pstr = new char[strlen(src._pstr) + 1];
        strcpy(_pstr, src._pstr);
    }

    //拷贝赋值运算符
    CMyString &operator=(const CMyString &src)
    {
        cout << "&operator=(const CMyString &)" << endl;
        if (this == &src)
        {
            return *this;
        }
        delete[] _pstr;
        _pstr = new char[strlen(src._pstr) + 1];
        strcpy(_pstr, src._pstr);
        return *this;
    }

    ~CMyString()
    {
        cout << "~CMyString()" << endl;
        delete[] _pstr;
        _pstr = nullptr;
    }
 
    int length() const
    {
        return strlen(_pstr);
    }

    // 可读写
    char &operator[](int index)
    {
        return _pstr[index];
    }

    //只读
    const char &operator[](int index) const
    {
        return _pstr[index];
    }

    const char *c_str() const
    {
        return _pstr;
    }
 
private:
    char *_pstr;
};

这个类在内部用一个字符串数组来实现,提供了构造函数、拷贝构造函数和拷贝赋值运算符重载。再提供两个函数,一个对加号进行重载的字符串拼接函数,一个输出打印:

CMyString operator+(const CMyString &lhs, const CMyString &rhs)
{
    CMyString tmp;
    tmp._pstr = new char[strlen(lhs._pstr) + strlen(rhs._pstr) + 1];
    strcpy(tmp._pstr, lhs._pstr);
    strcat(tmp._pstr, rhs._pstr);
    return tmp;
}

ostream &operator<<(ostream &os, const CMyString &src)
{
    os << src._pstr;
    return os;
}

要将上述两个全局函数标记为friend

private:
    char *_pstr;
    friend ostream &operator<<(ostream &os, const CMyString &src);
    friend CMyString operator+(const CMyString &lhs, const CMyString &rhs);

现在假设再提供一个获取字符串内容的函数:

CMyString GetString(CMyString &str)
{
    const char *pstr = str.c_str();
    CMyString tmpStr(pstr);
    return tmpStr;
}

现在,考虑以下代码的输出:

int main()
{
    CMyString str1("hello");
    CMyString str2;
    str2 = GetString(str1);
    std::cout << str2.c_str() << std::endl;
    return 1;
}

使用以下命令编译,以禁用返回值优化:

g++ main.cpp -o main.exe -fno-elide-constructors

运行main之后的输出结果如下:

CMyString(const char* p)     
CMyString(const char* p)     
CMyString(const char* p)     
CMyString(const CMyString &) 
~CMyString()
&operator=(const CMyString &)
~CMyString()
hello
~CMyString()
~CMyString()

丧心病狂的拷贝赋值以及析构。上面的每一行输出,对应的解释如下:

  1. CMyString(const char* p)            // 构造函数,变量str1的构造,参数为hello
  2. CMyString(const char* p)            // 构造函数,变量str2的构造,参数为nullptr
  3. CMyString(const char* p)            // 构造函数,局部临时变量tempStr的构造,这个对象分配在堆上
  4. CMyString(const CMyString &)   // 拷贝构造函数,临时变量tempStr在堆上,是函数的局部对象无法直接返回,只能调用拷贝构造函数在main函数栈帧上产生新的临时对象
  5. ~CMyString()                               // 析构函数,临时变量tempStr的析构
  6. &operator=(const CMyString &) //拷贝赋值运算符,从main函数的栈帧上的临时对象,给str2赋值
  7. ~CMyString()                               // 析构函数,main函数栈帧上的临时对象的析构
  8. hello                                            //字符串打印
  9. ~CMyString()                              // 析构函数,str2对象析构
  10. ~CMyString()                              // 析构函数,str1对象析构

如果字符串很大,则上述的拷贝和析构开销很大。

再举一个简单的例子,假设有如下类:

class Test
{

public:
    Test(int data = 10) : ma(data)
    {
        cout << "Test(int)" << endl;
    }
    Test(const Test &t) : ma(t.ma)
    {
        cout << "Test(const Test&)" << endl;
    }
    ~Test()
    {
        cout << "~Test()" << endl;
    }
    void operator=(const Test &t)
    {
        cout << "operator=" << endl;
        ma = t.ma;
    }
    int getData() const { return ma; }

private:
    int ma;
};

接着提供一个简单函数:

// 不能返回局部,或临时对象的指针或引用
Test GetObject(Test t)
{
    int val = t.getData();
    Test temp(val);
    return temp;
}

下面的代码,

int main()
{
    Test t1;
    Test t2;
    t2 = GetObject(t1);
    return 1;
}
g++ main.cpp -o main.exe -fno-elide-constructors

输出结果如下:

Test(int)  //构造函数,t1构造
Test(int)  //构造函数,t2构造
Test(const Test&)  //拷贝构造函数,实参到形参的拷贝,t1已经存在,形参t初始化,t1拷贝构造形参t。
Test(int)  //构造函数,temp
Test(const Test&)  //拷贝构造函数,temp不能直接给t2赋值。为了把返回值temp带出来,需要在main函数栈帧上使用temp拷贝构造临时对象。
~Test()   //析构函数,temp析构
operator=   // 拷贝赋值运算符, 栈帧上的临时对象拷贝赋值到t2
~Test()   //析构函数,形参t析构
~Test()   //析构函数,栈帧上的临时对象析构
~Test()   //析构函数,t2析构
~Test()   //析构函数,t1析构

如果开启返回值优化(ROV),则会少一个在栈帧上构建临时对象的操作。

Test(int)
Test(int)
Test(const Test&)
Test(int)
operator=
~Test()
~Test()
~Test()
~Test()

优化原则


从以上两个例子可以看到,存在大量比必要的临时对象拷贝和销毁的操作。有以下几个方面可以进行优化:

  1. 函数参数传递过程中,对象优先按照引用传递,不要按值传递。在上面的例子中,如果按照引用传递,则形参t的拷贝构造和形参t的析构,则不需要。
  2. 函数返回对象的时候,应优先返回一个临时对象,而不要返回一个定义过的对象。用临时对象拷贝构造一个新对象的时候,C++编译器会做返回值优化,就不会产生临时对象了,即不会首先构造临时对象,然后再用临时对象拷贝构造一个新对象。所以在函数return的时候,直接返回新对象,而不是先定义临时对象,然后再返回临时对象,这样,就会直接在main函数的栈帧上直接构造一个新的临时对象。
  3. 接收返回值是对象的函数调用的时候,优先按照初始化的方式接收,而不是按赋值的方式接收。

按照以上的原则,优化上面的例子,代码如下:

// 不能返回局部,或临时对象的指针或引用
Test GetObject(Test &t)
{
    int val = t.getData();
    // Test temp(val);
    // return temp;
    return Test(val);
}

int main()
{
    Test t1;
    Test t2 = GetObject(t1);
    return 1;
}

在编译器使用了返回值优化之后,输出结果如下:

Test(int)
Test(int)
~Test()
~Test()

带右值引用参数的拷贝构造和赋值函数


在C++ 11中增加了右值的概念,右值就是表示临时的对象,简单来说:

左值是有标识符,可以取地址的对象,常见的情况有:

  1. 变量、函数或数据成员的名字
  2. 返回左值应用的表达式,如++x,x=1,count<<""
  3. 字符串字面量,比如“hello world”

在函数调用时,左值可以绑定到左值应用的参数,如T&, 一个常量只能绑定到常左值应用如const T&。

右值是没有标识符、没有内存(比如常量,在寄存器中)、不可以取地址的表达式,一般也称之为“临时对象”,常见的情况有:

  1. 返回非应用类型的表达式,比如x++,x+1,make_share<int>(42)
  2. 除字符串字面量之外的字面量,比如43,true等

无法将左值引用绑定到右值,也无法将左值绑定到右值引用。可以把一个右值绑定到一个右值引用上。一个右值引用变量,本身是一个左值

现在,给CMyString添加一个右值拷贝构造:

// 带左值引用参数的拷贝构造函数
CMyString(const CMyString &src)
{
    cout << "CMyString(const CMyString&)" << endl;
    _pstr = new char[strlen(src._pstr) + 1];
    strcpy(_pstr, src._pstr);
}
// 带右值引用参数的拷贝构造
CMyString(CMyString &&str)
{
    cout << "CMyString(CMyString&&)" << endl;
    _pstr = str._pstr;
    str._pstr = nullptr;
}

可以看到带右值引用参数的拷贝构造,右值引用参数引用的是临时量,在函数内部,只需要把指针赋值给当前对象,然后把右值的指针置为空即可。这里面没有内存开辟和元素拷贝。

相同地,添加带右值引用的赋值操作符重载:

// 带左值引用参数的赋值重载函数
CMyString &operator=(const CMyString &src)
{
    cout << "&operator=(const CMyString &)" << endl;
    if (this == &src)
    {
        return *this;
    }
    delete[] _pstr;
    _pstr = new char[strlen(src._pstr) + 1];
    strcpy(_pstr, src._pstr);
    return *this;
}

// 带右值引用参数的赋值重载函数
CMyString &operator=(CMyString &&src)
{
    cout << "&operator=(const CMyString &&)" << endl;
    if (this == &src)
    {
        return *this;
    }
    delete[] _pstr;
    _pstr = src._pstr;
    src._pstr = nullptr;
    return *this;
}

按照上述修改之后,可以看到输出结果为:

CMyString(const char* p)
CMyString(const char* p)
CMyString(const char* p)
CMyString(CMyString&&)
~CMyString()
&operator=(const CMyString &&)
~CMyString()
hello
~CMyString()
~CMyString()

可以看到,在函数返回时,需要将对象拷贝到main函数栈帧,这时调用的是带右值引用参数的拷贝构造函数。在将栈帧上的临时对象赋值给str2时,调用的带右值引用参数的赋值重载函数。

容器里的表现


当涉及到容器时,也会存在复制的问题,因此C++的标准容器也会对移动进行优化,比如对于上面的CMyString对象。下面的代码:

int main()
{
    CMyString str1("hello");
    vector<CMyString> vec;
    vec.reserve(10);
    cout << "----------------------" << endl;
    vec.push_back(str1);
    vec.push_back(CMyString("world"));
    cout << "----------------------" << endl;
    return 1;
}

输出结果为:

CMyString(const char* p)
----------------------
CMyString(const CMyString&)
CMyString(const char* p)
CMyString(CMyString&&)
~CMyString()
----------------------
~CMyString()
~CMyString()
~CMyString()

可见,当push_back时,如果对象是临时对象,则会直接调用带右值参数的移动构造函数,否则就是用带左值的拷贝构造函数。

std::move移动语义


C++中的容器空间分配器这篇文章中,实现了一个自定义的vector,在那个实现里,push_back是一个接受左值引用参数的方法:

void push_back(const T &val)
{
    if (full())
    {
        resize();
    }
    // *_last++ = val; //_last指针指向的内存构造一个值为value的对象
    _allocator.construct(_last, val);
    _last++;
}

现在,添加一个右值引用的方法:

// 接受右值引用参数
void push_back(T &&val)
{
    if (full())
    {
        resize();
    }
    // *_last++ = val; //_last指针指向的内存构造一个值为value的对象
    _allocator.construct(_last, val);
    _last++;
}

这里面在内部调用了_allocator的construct方法,所以construct也要添加一个右值引用类型的方法。

void construct(T *p, const T &val) // 负责对象构造
{
    new (p) T(val); // 定位New,调用T对象的拷贝构造
}

// 接受右值引用参数
void construct(T *p, T &&val) //
{
   new (p) T(val); 
}

现在问题来了,一个右值引用参数本身是一个左值,所以两个push_back内部调用的consruct方法都会匹配到带左值引用参数的那个方法。于是push_back带右值引用参数的方法需要修改为:

// 接受右值引用参数
void push_back(T &&val)
{
    if (full())
    {
        resize();
    }
    // *_last++ = val; //_last指针指向的内存构造一个值为value的对象
    _allocator.construct(_last, std::move(val));
    _last++;
}

std::move这里,就是将右值引用参数的这个左值形参,强行转换为右值。这个就是移动语义,将val强制转为右值引用类型。std::move的代码也是这样做的:

/**
 *  @brief  Convert a value to an rvalue.
 *  @param  __t  A thing of arbitrary type.
 *  @return The parameter cast to an rvalue-reference to allow moving it.
 */
template <typename _Tp>
_GLIBCXX_NODISCARD constexpr typename std::remove_reference<_Tp>::type &&
move(_Tp &&__t) noexcept
{
    return static_cast<typename std::remove_reference<_Tp>::type &&>(__t);
}

现在就可以匹配到带右值引用参数的construct方法:

// 接受右值引用参数
void construct(T *p, T &&val) //
{
   new (p) T(val); 
}

又出现了同一个问题,在这个右值引用参数里面,val本身是一个左值。所以也要修改为:

// 接受右值引用参数
void construct(T *p, T &&val) //
{
    new (p) T(std::move(val));
}

现在就能调用带右值引用参数的拷贝构造函数。

总结一下就是std::move是移动语义,如果传进去的是左值,则会强制转换为对应的右值。

std::forward完美转发


可以看到,由于带右值引用参数的变量本身是一个左值,所以在上面的实现中,要区分左值和右值需要使用std::move来将左值引用强制转为右值,每个push_back和constructor的逻辑都要实现两遍,区别仅仅是参数是左值和右值,非常繁琐。解决方法是采用模板方法来实现:

template <typename Ty>
void push_back(Ty &&val)
{
    if (full())
    {
        resize();
    }
    // *_last++ = val; //_last指针指向的内存构造一个值为value的对象
    _allocator.construct(_last, val);
    _last++;
}

现在,假设传进来的val是一个左值&,则&&+&,是一个左值;如果传进来的val是一个右值&&,则&&+&&,是一个右值。这就是引用折叠。

所以当调用push_back传进一个左值的CMyString&的时候,匹配的相当于是:

void push_back(CMyString& val)

如果传进去的是一个临时的右值CMyString&&,匹配的相当于是:

void push_back(CMyString&& val)

但还有一个存在的问题,那就是在一个右值引用参数本身是一个左值,所以在调用construct的时候,会永远匹配到带左值引用参数的那个方法,所以要使用std::forward类型完美转发:

template <typename Ty>
void push_back(Ty &&val)
{
    if (full())
    {
        resize();
    }
    // *_last++ = val; //_last指针指向的内存构造一个值为value的对象
    _allocator.construct(_last, std::forward<Ty>(val));
    _last++;
}

现在,当val传进来的参数是左值时,std::forward就是一个左值,当val传进来的是右值时,std::forward就是右值。它是通过模板的非完全特例化来实现的。

/**
 *  @brief  Forward an lvalue.
 *  @return The parameter cast to the specified type.
 *
 *  This function is used to implement "perfect forwarding".
 */
template <typename _Tp>
_GLIBCXX_NODISCARD constexpr _Tp &&
forward(typename std::remove_reference<_Tp>::type &__t) noexcept
{
    return static_cast<_Tp &&>(__t);
}

/**
 *  @brief  Forward an rvalue.
 *  @return The parameter cast to the specified type.
 *
 *  This function is used to implement "perfect forwarding".
 */
template <typename _Tp>
_GLIBCXX_NODISCARD constexpr _Tp &&
forward(typename std::remove_reference<_Tp>::type &&__t) noexcept
{
    static_assert(!std::is_lvalue_reference<_Tp>::value,
                  "std::forward must not be used to convert an rvalue to an lvalue");
    return static_cast<_Tp &&>(__t);
}

上面是C++库里面提供的两个forward重载函数,分别接收左值和右值引用类型,进行一个类型强转,static_cast<_Ty&&>(_Arg),例如如果实参类型是int& + && -> int&就保持了实参的左值引用类型,如果实参类型是int&& + && -> int&&就保持了实参的右值引用类型。

对于construct,也可以使用模板方法进行类似的改造。

template <typename Ty>
void construct(T *p, Ty &&val) // 负责对象构造
{
    new (p) T(std::forward<Ty>(val)); // 定位New,调用T对象的拷贝构造
}

现在,使用自定义的vector,运行下面的代码,也能实现与C++ 标准库里vector一样的结果:

int main()
{
    CMyString str1("hello");
    vector<CMyString> vec;
    cout << "----------------------" << endl;
    vec.push_back(str1);
    vec.push_back(CMyString("world"));
    cout << "----------------------" << endl;
    return 1;
}

输出结果为:

CMyString(const char* p)   
----------------------     
CMyString(const CMyString&)
CMyString(const char* p)   
CMyString(CMyString&&)     
~CMyString()
----------------------
~CMyString()
~CMyString()
~CMyString()

上面的代码中,如果将push_back(str1)改为push_back(std::move(str1)),则可以看到就能匹配到右值引用参数的拷贝构造,输出结果为:

CMyString(const char* p)
----------------------
CMyString(CMyString&&)
CMyString(const char* p)
CMyString(CMyString&&)
~CMyString()
----------------------
~CMyString()
~CMyString()
~CMyString()

 

参考