在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()
丧心病狂的拷贝赋值以及析构。上面的每一行输出,对应的解释如下:
- CMyString(const char* p) // 构造函数,变量str1的构造,参数为hello
- CMyString(const char* p) // 构造函数,变量str2的构造,参数为nullptr
- CMyString(const char* p) // 构造函数,局部临时变量tempStr的构造,这个对象分配在堆上
- CMyString(const CMyString &) // 拷贝构造函数,临时变量tempStr在堆上,是函数的局部对象无法直接返回,只能调用拷贝构造函数在main函数栈帧上产生新的临时对象
- ~CMyString() // 析构函数,临时变量tempStr的析构
- &operator=(const CMyString &) //拷贝赋值运算符,从main函数的栈帧上的临时对象,给str2赋值
- ~CMyString() // 析构函数,main函数栈帧上的临时对象的析构
- hello //字符串打印
- ~CMyString() // 析构函数,str2对象析构
- ~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()
优化原则
从以上两个例子可以看到,存在大量比必要的临时对象拷贝和销毁的操作。有以下几个方面可以进行优化:
- 函数参数传递过程中,对象优先按照引用传递,不要按值传递。在上面的例子中,如果按照引用传递,则形参t的拷贝构造和形参t的析构,则不需要。
- 函数返回对象的时候,应优先返回一个临时对象,而不要返回一个定义过的对象。用临时对象拷贝构造一个新对象的时候,C++编译器会做返回值优化,就不会产生临时对象了,即不会首先构造临时对象,然后再用临时对象拷贝构造一个新对象。所以在函数return的时候,直接返回新对象,而不是先定义临时对象,然后再返回临时对象,这样,就会直接在main函数的栈帧上直接构造一个新的临时对象。
- 接收返回值是对象的函数调用的时候,优先按照初始化的方式接收,而不是按赋值的方式接收。
按照以上的原则,优化上面的例子,代码如下:
// 不能返回局部,或临时对象的指针或引用
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中增加了右值的概念,右值就是表示临时的对象,简单来说:
左值是有标识符,可以取地址的对象,常见的情况有:
- 变量、函数或数据成员的名字
- 返回左值应用的表达式,如++x,x=1,count<<""
- 字符串字面量,比如“hello world”
在函数调用时,左值可以绑定到左值应用的参数,如T&, 一个常量只能绑定到常左值应用如const T&。
右值是没有标识符、没有内存(比如常量,在寄存器中)、不可以取地址的表达式,一般也称之为“临时对象”,常见的情况有:
- 返回非应用类型的表达式,比如x++,x+1,make_share<int>(42)
- 除字符串字面量之外的字面量,比如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()
参考
- https://blog.csdn.net/Allen_sina/article/details/108854831
- https://blog.csdn.net/QIANGWEIYUAN/article/details/88653747?spm=1001.2014.3001.5502
- https://drewcampbell92.medium.com/understanding-move-semantics-and-perfect-forwarding-987cf4dc7e27
- https://drewcampbell92.medium.com/understanding-move-semantics-and-perfect-forwarding-part-2-6b8266b6cfa4