13.6 对象移动
这一节本质上是对性能进行优化,(对设计资源管理的类的性能优化),就算不知道这一节的的内容,一样的可以编码,但是如果设计的类对性能要求较高,就需要这一节的内容了。
在使用vector装自定义的类型的类型时,如果容量不足会开辟空间,将原来的元素拷贝到新的空间的,原来的元素会被销毁,我们知道原来的元素拷贝到新空间之后,不会再使用,如果对象非常的大,那么拷贝是会消耗比较大的性能的,既然原来的对象拷贝之后就不再使用,那么有没有什么方法,能够让原本对象的内容直接拷贝到新的内存空间中呢,C++提供了移动操作,用来将原来对象“移动”到新的内存空间中。使用移动的方式可以避免拷贝。
目前看来,“移动”操作和swap()类型可以大幅度提升性能的前提是类中有标准库容器,或者有指针指向的动态分配的对象。
13.6.1 右值引用
因为要支持移动操作,C++定义了右值引用。
使用&&来获取右值引用,有了右值引用,我们就可以其绑定的资源“移动”到另一个对象中。
右值引用用来绑定字面值常量,返回右值的表达式以及要求转换类型的表达式 , (要求转换类型的意思应该是表达式中既有左值也有右值) ,
左值具有持久的状态,知道离开作用域才被销毁,而右值是字面值常量要么是表达式中的临时对象,所引用的对象即将被销毁且没有其他的用户。
所有的变量都是左值,因为右值引用的变量也是左值,而左值不能直接使用右值绑定。
需要使用std::move(obj)将左值转化为右值类型,但是这样意味着obj的资源将被右值引用的变量所接管,后续除了对obj进行赋值和销毁,我们不要再使用它。
练习
13.45
一句话:
左值引用可以绑定左值,const 左值引用和右值引用可以绑定右值。
左值引用可以绑定返回值类型为引用的函数、赋值、下标、解引用、前置递减递增运算符等返回左值的表达式的结果上。
const类型的左值引用和右值引用可以绑定在返回非引用类型的函数、算术、关系、位、后置递增递减等返回右值的表达式的结果上。
13.46
int &&r1 = f();
int &r2 = vi[0];
int & r3 = r1;
int &&r4 = vi[0] * f();
13.47
这里完善了之前的MyString类,添加了拷贝构造函数、拷贝内赋值运算符、free()等函数
.h文件
#pragma once
#include <memory>
class MyString {
friend void print(std::ostream& s, const MyString& str);
public:
MyString();
MyString(const char*);
MyString(const MyString&);
MyString& operator=(const MyString&);
~MyString();
size_t size()const;
private:
size_t get_char_arr_len(const char *);
void free();
static std::allocator<char> alloc;
char* begin;
char* end;
char* last;
};
.cpp文件
#include "pch.h"
#include "MyString.h"
#include <algorithm>
#include <iostream>
using std::cout;
using std::endl;
std::allocator<char> MyString::alloc;
MyString::MyString()
{
begin = alloc.allocate(1);
alloc.construct(begin, '\0');
end = begin;
last = end + 1;
}
MyString::MyString(const char * c)
{
size_t len = get_char_arr_len(c) + 1;
begin = alloc.allocate(len);
end = begin + len - 1;
last = end + 1;
size_t index = 0;
for (auto iter = begin; iter != end; ++iter)
{
alloc.construct(iter, c[index]);
++index;
}
*end = '\0';
}
MyString::MyString(const MyString & s)
{
cout << "调用了拷贝构造函数" << endl;
auto temp_begin = alloc.allocate(s.size() + 1);
auto temp_iter = temp_begin;
for (auto iter = s.begin; iter != s.end; ++iter)
{
alloc.construct(temp_iter++, *iter);
}
begin = temp_begin;
end = temp_iter;
*end = '\0';
last = end + 1;
}
MyString & MyString::operator=(const MyString &s)
{
cout << "调用了赋值运算符" << endl;
auto temp_begin = alloc.allocate(s.size()+1);
auto temp_iter = temp_begin;
for (auto iter=s.begin;iter!=s.end;++iter)
{
alloc.construct(temp_iter++, *iter);
}
free();
// TODO: 在此处插入 return 语句
begin = temp_begin;
end = temp_iter;
*end = '\0';
last = end + 1;
return *this;
}
MyString::~MyString()
{
free();
}
size_t MyString::size()const
{
return end - begin;
}
size_t MyString::get_char_arr_len(const char * c)
{
size_t len = 0;
while (*c != '\0')
{
++len;
++c;
}
return len;
}
void MyString:: free()
{
std::for_each(begin, end + 1, [](const char& item) {
alloc.destroy(&item);
});
alloc.deallocate(begin, last - begin);
}
void print(std::ostream& s, const MyString& str)
{
std::for_each(str.begin, str.end, [&s](const char& item) {
s << item;
});
}
13.48
每次重新分配内存空间,则会将原来的元素都拷贝一次。
vector<MyString> vec = {"233","333"};//两次拷贝
cout << "------------" << endl;
vec.push_back("233");//一次拷贝+两个拷贝函数
cout << "------------" << endl;
vec.push_back("233");//一次拷贝+三次拷贝
cout << "------------" << endl;
output
C风格构造函数
C风格构造函数
调用了拷贝构造函数
调用了拷贝构造函数
------------
C风格构造函数
调用了拷贝构造函数
调用了拷贝构造函数
调用了拷贝构造函数
------------
C风格构造函数
调用了拷贝构造函数
调用了拷贝构造函数
调用了拷贝构造函数
调用了拷贝构造函数
------------
13.6.2 移动构造函数,移动赋值运算符
有了右值引用,提升类的性能,就需要用到移动构造函数和移动赋值运算符。
移动构造函数形参和移动赋值运算符的基本要求都和拷贝构造拷贝赋值运算符一样。
只是他们的类型为&&。而且通常不加const,因为我们要修改右值引用的对象的数据成员。
class A
{
public:
A():value(nullptr){};
~A() {
delete value;
};
//拷贝构造函数
A(const A&) {};
//移动构造
A(A&& a) noexcept {
//将右值引用的资源转让给本对象
value = a.value;
//右值引用的对象的资源放置为安全的状态
a.value = nullptr;
};
//拷贝赋值
A& operator=(const A&) {};
//移动赋值
A& operator=(A&&a) noexcept {
if (this!=&a)
{
delete value;
value = a.value;
a.value = nullptr;
}
return *this;
};
private:
string* value;
};
如果在拷贝和赋值时传入的对象时右值类型,则会调用移动构造和移动赋值。在移动构造和移动赋值中,需要将右值引用的对象的资源赋值给本对象,然后将右值引用的对象的资源置为一个安全状态。
被移动后的对象叫做移后源对象,在被移动之后,我们必须确保移后源对象可以被正确的析构,且我们不再访问它的数据成员,因为不能保证它的数据成员此时还有效。
通常情况下我们会为移动构造和移动赋值加上noexcept表示,这两个函数绝对不会发生异常。noexcept表示我们自己承诺这个函数不会抛出异常,它加在参数列表和初始化列表的冒号:之间,且声明和定义都要有。只有加上了noexcept,在容器需要重新分配空间拷贝原来的元素时,容器才会执行移动构造,否则执行拷贝构造,如果没有使用容器来装自定义的类,那么noexcept不写也没事。
合成的移动操作
和拷贝构造、拷贝赋值一样,移动操作也有合成的版本,但是和拷贝构造、拷贝赋值的条件不一样。
只要我们没有定义拷贝构造、拷贝赋值、析构函数,编译器就会为我们的类合成一个。
只要拷贝构造、拷贝赋值、析构我们自定义了一个,那么就不会合成移动操作。 因为移动操作算是优化,就算没有移动操作,拷贝构造、拷贝赋值、析构程序一样的可以完成所有功能。
只有当我们没有定义任何拷贝控制成员和类的所有非static数据成员都可以移动时,移动操作才会被合成
另一方面,如果我们定义了移动构造,则编译器不会为我们合成拷贝构造,定义了移动赋值则编译器不会我们生成拷贝赋值。
关于什么时候变为已删除的函数。所谓的以删除就是编译器不会为我们合成这些函数。
如果我们同时定义了拷贝构造、赋值和移动构造、赋值,则如果传入的对象是右值类型的,则调用移动构造、赋值,否则调用拷贝构造、赋值。
如果没有定义移动构造、赋值传入的是右值,则拷贝构造、赋值会处理。
移动迭代器
之前的迭代器介绍时,除了流迭代器,插入迭代器、反向还有移动迭代器。
移动迭代器顾名思义就是为了用来迭代器的,移动迭代器改变了迭代器的解引用操作,其解引用返回的是一个右值引用。
我们使用make_move_iterator(iter)来得到一个迭代器的移动迭代器类型。
如果一个对象为右值引用类型。那么下面的代码将调用移动赋值运算符(如果有的话)
*iter = *make_move_iterator(iter1);
移动迭代器一般用在算法中,但是有些算法可能会改变传入的迭代器的值,所以我们需要确保使用移动迭代器时,需要确保被移动的对象不再被使用。
13.6.3 右值引用和成员函数
我们定义形参为右值引用版本的成员函数,它和其他同名成员函数构成重载。
void A::func(A&) {
}
void A::func(A&&) {
}
左值和右值引用成员函数
下面的代码是成立的,因为我们=被重载过了,这里为右值赋值其实是调用一个成员函数,而成员函数调用时不用管是否是左值还是右值。
string str1 = "123";
string str2 = "233";
(str1 + str2) = "123";
如果我们区分左值和右值成员函数的操作的话,那么可以通过在函数的形参列表后面加&或者&&来限定此成员函数由左值还是右值对象来调用。
//左值类型的对象调用这个函数
void A::func1(A) &
{
}
//右值类型的对象调用这个函数
void A::func2(A) &&
{
}
引用限定符参与函数的重载,需要注意引用限定符必须在const限定符的右侧
void A::func1(A) const &{
}
如果我们对两个或者两个以上的同名,同参数成员函数中使用引用限定符,那么要么全部使用引用限定符,那么一个都不使用。
练习
13.49
StrVec
StrVec::StrVec(StrVec &&s) noexcept
{
cout<<"调用移动构造函数"<<endl;
elements = s.elements;
first_free = s.first_free;
cap = s.cap;
//确保其可以被析构
s.elements = s.first_free = s.cap = nullptr;
}
StrVec & StrVec::operator=(StrVec &&s)noexcept
{
cout<<"-----------"<<endl;
cout<<"调用移动赋值运算符"<<endl;
if (this!=&s) {
free();
elements = s.elements;
first_free = s.first_free;
cap = s.cap;
//确保其可以被析构
s.elements = s.first_free = s.cap = nullptr;
}
// TODO: 在此处插入 return 语句
return *this;
}
MyString
StrVec::StrVec(StrVec &&s) noexcept
{
cout<<"调用移动构造函数"<<endl;
elements = s.elements;
first_free = s.first_free;
cap = s.cap;
//确保其可以被析构
s.elements = s.first_free = s.cap = nullptr;
}
StrVec & StrVec::operator=(StrVec &&s)noexcept
{
cout<<"-----------"<<endl;
cout<<"调用移动赋值运算符"<<endl;
if (this!=&s) {
free();
elements = s.elements;
first_free = s.first_free;
cap = s.cap;
//确保其可以被析构
s.elements = s.first_free = s.cap = nullptr;
}
// TODO: 在此处插入 return 语句
return *this;
}
Message
在这里我直接理所当然的将contents=m.contents,而没有考虑使用move,这是错误的,之前的两个类之所以没有写move是因为他们本来就是指针类型。而Message的数据成员都不是指针类型,而是类类型,所以想要移动他们的元素,就需要使用move来调用他们移动构造函数或者移动赋值运算符
Message::Message(Message &&m)
{
contents = std::move(m.contents);
folders = std::move(m.folders);
for (const auto &f:folders)
{
f->remMsg(&m);
f->addMsg(this);
}
m.folders.clear();
}
Message & Message::operator=(Message &&m)
{
if (this!=&m)
{
contents = std::move(m.contents);
remove_from_Folders();
folders = std::move(m.folders);
for (const auto &f : folders)
{
f->remMsg(&m);
f->addMsg(this);
}
m.folders.clear();
}
// TODO: 在此处插入 return 语句
return *this;
}
13.50
对于13.48中的输出可以看到,当容器的空间不足开辟新空间拷贝元素时调用的是移动构造函数而不是拷贝构造函数。
同时这里也 复习到了,隐式转换的时候是将传入的值先变为一个对象,然后再做拷贝或者移动
vector<MyString> vec = { "123","321" };//两次拷贝
//vector<MyString> vec = {MyString("123"),MyString("321")};//两次拷贝
cout << "------------" << endl;
vec.push_back("233");//
cout << "------------" << endl;
vec.push_back("233");//
cout << "------------" << endl;
--output-
C风格构造函数
C风格构造函数
调用了拷贝构造函数
调用了拷贝构造函数
------------
C风格构造函数
调用了移动构造函数
调用了移动构造函数
调用了移动构造函数
------------
C风格构造函数
调用了移动构造函数
调用了移动构造函数
调用了移动构造函数
调用了移动构造函数
------------
13.51
因为unique_ptr定义了移动构造函数和移动赋值运算符,所以调用clone()返回的时候,在调用处的匿名对象是通过移动构造函数来获得返回的unique_ptr的所有权的。调用结束后,只有匿名unique_ptr对象指向它维护的对象,而函数内部的unique_ptr已经被销毁了。
13.52
因为hp2是一个对象,所有对象都是左值。
所以hp=hp2.将调用拷贝赋值运算符。通过将hp2传入到HasPtr的拷贝运算符函数中,因为rhs是非引用类型,所以将调用拷贝初始化来初始化rhs。
所以rhs和hp2的内容将是独立的。
通过swap交换内部两个对象的数据,返回*this。完成hp的赋值,此时rhs的生命周期结束,系统销毁并回收内存。
hp=std::move(hp2),虽然hp2是左值,但是可以调用std::move()将一个右值引用绑定到hp2上,此时表达式右侧是右值引用,那么使用拷贝构造函数和移动构造函数都可以,但是拷贝构造函数首先要将右值引用转换为const类型的左值引用,但是移动赋值运算符精确匹配,所以将调用移动赋值运算符,来对hp赋值
赋值之后认为hp2原来的数据都有hp来操纵,所以后续的代码中不对hp2进行任何操作。
13.53
HasPtr& operator=(const HasPtr& p) {
cout << "拷贝赋值" << endl;
if (this!=&p) {
auto temp = new string(*p.ps);
delete ps;
ps = temp;
i = p.i;
}
return *this;
};
HasPtr& operator=( HasPtr p) {
cout << "拷贝赋值" << endl;
if (this!=&p) {
swap(*this, p);
}
return *this;
};
HasPtr& operator=( HasPtr&& p) {
cout << "移动赋值" << endl;
if (this!=&p)
{
delete ps;
ps = p.ps;
i = p.i;
p.ps = nullptr;
}
return *this;
}
对比没用swap的版本,可以看到拷贝赋值中需要动态分配内存。
而使用swap版本的拷贝赋值,则需要调用拷贝构造函数创建一个形参。
使用移动赋值既不需要调用拷贝构造也不需要动态分配内存。
13.54
如果使用这个版本的拷贝赋值运算符,则二者会发生冲突,报错为operator=不明确。
使用引用版本的拷贝赋值运算符不会发生冲突。
HasPtr& operator=( HasPtr p) {
cout << "拷贝赋值" << endl;
if (this!=&p) {
swap(*this, p);
/*auto temp = new string(*p.ps);
delete ps;
ps = temp;
i = p.i;*/
}
return *this;
};
13.6.3右值引用与成员函数
练习
13.55
void StrVec::push_back(string && s)
{
chk_n_alloc();
//这样做的前提是string类型专门对右值做了优化
//否则和普通的push_back没什么两样
alloc.construct(first_free++, std::move(s));
}
13.56
ret是左值,所以调用的是左值版本的sorted()函数,所以会形成死循环。
13.57
因为直接在return语句上创建对象,所以Foo(*this)实际上是一个右值,所以将调用右值版本的sorted();不会死循环。
13.58
class Foo {
public:
Foo sorted() && ;
Foo sorted() const&;
Foo(initializer_list<int> init_list) :data(init_list) {};
private:
vector<int>data;
};
Foo Foo::sorted() &&
{
cout<<"调用对象为右值"<<endl;
sort(data.begin(), data.end());
return *this;
}
Foo Foo::sorted() const &
{
cout<<"调用对象为左值"<<endl;
/*Foo ret(*this);
sort(ret.data.begin(), ret.data.end());
return ret;*/
//Foo ret(*this);
return Foo(*this).sorted();
}
Foo retVal() {
Foo foo({123,14,3,14,123,4,213,41,123,12321});
return foo;
}
Foo f11({123,4,3124,2134,2314,234112354,657,756543,345,765,3427,6758});
Foo& retFoo() {
return f11;
}
---
//retVal().sorted();
retFoo().sorted();