第十三章-拷贝控制

拷贝构造函数

  • 如果一个拷贝构造函数的第一个参数是自身类型的引用,且其他参数都有默认值,则此构造函数是拷贝构造函数
class Foo
{
public:
	Foo(); //默认构造函数
	Foo(const Foo&); //拷贝构造函数
	//...
};
//拷贝构造函数的第一个参数必须是引用
  • 也可以定义接受非const引用的拷贝构造函数,但大多数情况下都是一个const引用
  • 拷贝构造函数不应该是explict的
  • 如果没有给类定义拷贝构造函数,编译器会自动定义一个
  • 合成拷贝构造函数会从给定对象中依次将每个非static成员拷贝到正在创建的对象中,等价于下面这样:
class Sales_data
{
public:
	Sales_data(const Sales_data&);
private:
	string bookNo;
	int units_sold = 0;
	double revenue = 0.0;
};
Sales_data::Sales_data(const Sales_data &orig) :
	bookNo(orig.bookNo),
	units_sold(orig.units_sold),
	revenue(orig.revenue)
	{}
  • 理解直接初始化和拷贝初始化之间的差异
string dots(10, '.'); //直接初始化
string s = dots; //拷贝初始化
string nullBook = "9-999-99999-9"; //拷贝初始化
  • 将对象作为实参传递给一个非引用类型的实参时、从一个返回类型为非引用类型的函数返回一个对象时、用花括号列表初始化一个数组中的元素或者一个聚合类中的成员时都会发生拷贝初始化
  • 可以绕过拷贝构造函数,但是就算绕过没用到它,拷贝构造函数还是必须存在且可访问的(不能是private)
string nullBook = "9-999-99999-9"; //拷贝初始化
string nullBook("9-999-99999-9"); //略过了拷贝构造函数

拷贝赋值运算符

  • 拷贝构造函数是使用已有的对象创建一个新的对象,而拷贝赋值运算符是将一个对象的值赋给另一个已存的对象。区别就是有没有新的对象产生
Sales_data trans, accum;
trans = accum; //使用了拷贝赋值运算
  • 重载运算符的参数表示运算符的运算对象。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数
class Foo
{
public:
	Foo& operator=(const Foo&); //赋值运算符
	//...
};
//和内置类型的赋值保持一致,赋值运算符返回指向其左侧运算对象的引用
  • 如果没定义自己的拷贝赋值运算符,编译器会自动生成一个合成拷贝赋值运算符。
  • 如果类有const成员,则它不能使用合成的拷贝赋值运算符,因为运算符试图给所有成员赋值。
  • 拷贝赋值运算符会从右侧运算对象中的非static成员赋予左侧运算对象的对应成员,等价于下面这样:
Sales_data&
Sales_data::operator=(const Sales_data &rhs)
{
	bookNo = rhs.bookNo;
	units_sold = rhs.units_sold;
	revenue = rhs.revenue;
	return *this;
}

析构函数

  • 析构函数释放对象使用的资源并销毁对象的非static数据成员。它没有返回值,且不接受参数
  • 因为不接受参数,所以不能被重载。一个类只有唯一一个析构函数
  • 类数据成员在类中按出现顺序初始化。析构函数首先执行函数体,然后按成员初始化顺序的逆序销毁它们
  • 隐式销毁一个内置指针类型的成员并不会delete它指向的对象
  • 当指向一个对象的引用或指针离开作用域时,析构函数不会执行(离开作用域,指针自己被销毁,但是指向的内容没有释放。因为指针也是个内置数据类型,就和int float一样,离开作用域自然被销毁;假设离开作用域指向的内容会被释放,那如果出现多个指针指向同一个对象,离开作用域发生什么情况?)
  • 如果没有定义自己的析构函数,编译器会自动定义一个合成析构函数。对于某些类,合成析构函数用来阻止该类型的对象被销毁,如果不是这种情况,合成析构函数的函数体就为空
  • 析构函数本身并不直接销毁成员。执行完(空)析构函数体之后,成员生命周期结束而被自动销毁

三五法则

  • 如果一个类需要析构函数,则可以肯定它也需要一个拷贝构造函数和拷贝赋值运算符。如下代码所示:
class HasPtr
{
public:
	//构造函数
	HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
	HasPtr f(HasPtr hp)
	{
		HasPtr ret = hp;
		return ret;
	}
	//析构函数
	~HasPtr() { delete ps; }
private:
	string *ps;
	int i;
};
//因为使用了合成的拷贝构造函数和拷贝赋值运算符,所以会简单的拷贝指针
//这里调用f(HasPtr hp)函数,结束时ret和hp都会被销毁
//所以会调用两次析构函数
//而hp和ret包含相同的指针值
//所以被delete了两次
  • 如果一个类需要拷贝构造函数,则几乎可以肯定它也需要一个拷贝赋值运算符。

=default

  • 使用-default能显式地要求编译器生成合成版本
{
	Sales_data() = default
	Sales_data& operator=(const Sales_data &);
	Sales_data(const Sales_data&) = default;
	~Sales_data() = default;
}
Sales_data& Sales_data::operator=(const Sales_data &) = default;
  • 在类内使用=default时,合成的函数是隐式声明为内联的。如果不想声明成内联的,可以只对成员的类外定义使用=default,比如上面的拷贝赋值运算符
  • 只能对具有合成版本的成员函数使用=default

阻止拷贝

  • 有些类比如iostream必须阻止拷贝以避免多个对象写入或读取相同的IO缓冲
  • 可以将拷贝构造函数和拷贝赋值运算符定义为删除函数来阻止拷贝。在函数参数列表后面加上delete指出将它定义为删除的
NoCopy(const NoCopy) = delete;
NoCopy operator=(const Nocopy &) = delete;
  • 可以对任意函数指定=delete,但析构函数不能是删除的成员。对于一个删除了析构函数的类型就不能定义这种类型的遍历或释放该类型动态分配对象的指针
  • 如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的
  • 新标准出来之前是将拷贝构造函数和拷贝赋值运算符声明为private来阻止拷贝的
class PrivateCoPy
{
	PrivateCopy(const PrivateCopy&);
	PrivateCopy &operator(const PrivateCopy&);
public:
	PrivateCopy() = default;
	~PrivateCopy();
};
//由于拷贝构造函数和拷贝赋值运算符是private的,所以用户不能拷贝这个类型的对象
//但是友元函数和成员函数任然可以拷贝对象
//如果还想阻止这俩进行拷贝,可以将这些拷贝控制成员声明为private的且不定义它们

定义像值的类

  • 行为像值的类,意味着副本和原对象是独立的。改变副本不会对原对象有任何影响(比如string)
  • 行为像指针的类,意味着副本和原对象使用相同的底层数据。改变副本也会改变原对象(比如shared_ptr)
class HasPtr
{
public:
	//构造函数
	HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
	//拷贝构造函数
	HasPtr(const HasPtr &ph) : ps(new string(*ph.ps)), i(ph.i) {}
	//拷贝赋值运算符
	HasPtr& operator=(const HasPtr& rhs)
	{
		string *newp = new string(*rhs.ps); //拷贝底层string
		delete ps; //释放对象指向的string
		ps = newp;
		i = rhs.i;
		return *this;
	}
	//析构函数
	~HasPtr() { delete ps; } //因为ps指向一个动态分配的string
private:
	string *ps;
	int i;
};
  • 这里拷贝赋值运算符考虑到了自赋值的情况。如果写成这样就会发生错误:
HasPtr& operator=(const HasPtr& rhs)
{
	delete ps;
	//如果rhs和*this是同一个对象
	//我们就会从已释放的内存中拷贝数据
	ps = new string(*(rhs.ps));
	i = rhs.i;
	return *this;
}
  • 写赋值运算符的注意点
    1. 如果将一个对象赋予自身,赋值运算符必须能正常工作
    2. 大多数赋值运算符组合了析构函数和拷贝构造函数的工作

定义像指针的类

  • 定义行为像指针的类,我们要拷贝指针成员本身而不是它指向的string
  • 析构函数不能单方面释放关联的string。只有当最后一个指向string的HasPtr销毁时才能释放string
  • 让类表现的像指针的最好放法是使用shared_ptr管理类中的资源。如果我们想直接管理资源,就可以使用引用计数
    1. 除了初始化对象,构造函数还要创建引用计数
    2. 拷贝构造函数递增共享的计数器
    3. 析构函数递减计数器,递减为0则释放状态
    4. 拷贝赋值运算符递增右侧对象的计数器,递减左侧对象的计数器。递减为0则销毁
class HasPtr
{
public:
	//构造函数,动态分配一个新的计数器
	HasPtr(const string &s = string()) :
	ps(new string(s)), i(0), use(new size_t(1)) {}
	//拷贝构造函数,递增计数器
	HasPtr(const HasPtr &p) :
	ps(p.ps), i(ph.i), use(p.use) { ++*use; }
	//ps = p.ps,像指针一样,ps和p.ps指向同一片内存
	//拷贝赋值运算符
	HasPtr& operator=(const HasPtr& rhs);
	//析构函数
	~HasPtr(); //因为ps指向一个动态分配的string
private:
	string *ps;
	int i;
	size_t *use; //计数器
};
HasPtr::~HasPtr()
{
	if (--*use == 0)
	{
		delete ps; //释放string
		delete use; //释放计数器
	}
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	++*rhs; //递增右侧,拷贝构造函数的工作
	if (--*use == 0) //递减左侧,析构函数的工作
	{
		delete ps;
		delete use;
	}
	ps = rhs.ps;
	i = rhs.i;
	use = rhs.use;
	return *this;
}
//通过先递增rhs中的计数再递减左侧运算对象的计数的方法
//拷贝赋值运算符还是处理了自赋值的情况

交换

  • 如果一个类没定义自己的swap,将使用标准库定义的swap。在需要交换两个元素时会调用swap
  • 但是标准库的swap会比较浪费,比如下面很多动态内存分配都是不必要的。
//类值的HasPtr调用标准库的swap
HasPtr temp = v1;
v1 = v2;
v2 = temp;
//第一行temp新生成一片初始化为v1的动态内存,然后指向它
//第二行v1释放原来的动态内存,再新生成初始化为v2的动态内存然后指向它
//第三行v2释放原来的动态内存,新生成初始化为temp的动态内存然后指向它
  • swap直接交换指针会省时省力很多
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp
//现在v1.ps指向原来v2.ps指向的内存
//v2.ps指向原来v1.ps指向的内存
  • swap不是必要的,但是对于分配了资源的类来说swap可能会非常重要。我们可以定义自己的swap来重载默认的版本
class HasPtr
{
	friend void swap(HasPtr&, HasPtr&);
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
	using std::swap;
	swap(lhs.ps, rhs.ps);
	swap(lhs.i, rhs.i);
}
//这里最好不要使用std::swap(lhs.ps, rhs.ps)这样的写法
//因为这是在要求显式调用标准库版本的swap
//如果我有自己版本的swap,这样写就会无视掉自己版本的
//像上面代码中那样写才会真正调用自己的swap(前提是有自己版本的,如果没有还是默认标准库版本)
  • 一般定义了swap的类会用swap写赋值运算符。
HasPtr& HasPtr::operator=(HasPtr rhs)
//注意rhs是按值传递,所以会用到拷贝构造函数
{
	swap(*this, rhs);
	//交换内容后,rhs指向左侧对象原来指向的内存
	return *this;
	//结束后rhs作为局部变量被销毁
}
//这个方法巧妙的处理了自赋值的情况

右值引用

  • 右值引用就是对右值的引用(在第四章-表达式中讲过左值、右值)。平时的引用可以理解为对左值的引用。使用&&来获得右值引用
int i = 42;
int &r1 = i; //r"左值"引用i
int &&rr1 = i; //错误!不能将右值引用绑定到左值上
int &r2 = i * 42; //错误!i*42是右值
const int &r3 = i * 42; //正确!可以将const引用绑定到右值上
int &&rr2 = i * 42; //正确!rr2是对右值的引用
  • 虽然不能将右值引用绑定到左值上,但可以通过move函数获得绑定到左值上的右值引用
int &&rr3 = std::move(rr1); //正确
//调用了move后,除了对rr1(移后源)赋值或者销毁它之外,不能使用它

移动构造函数和移动赋值运算符

  • 移动构造函数不分配新内存,而是接管原来内存的控制权
  • 移动构造函数的第一个参数也是该类类型的引用,但是是右值引用
  • 移动构造函数要确保移后源对象被销毁是无害的。且移动完成后源对象不再指向移动后的资源
StrVec::StrVec(StrVec &&s) noexcept
	: elements(s.elements), first_free(s.first_free), cap(s.cap)
	{ s.elements = s.first_free = s.cap = nullptr; }
//移动后将s的成员置为nullptr
//表示s可以被析构,且不会影响移动后的新对象
  • 注意不抛出异常的移动构造函数和移动赋值函数后面都要加上noexcept
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
	//if用来检测自赋值
	if (this != &rhs)
	{
		free(); //释放左侧原来的元素
		//开始接管
		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;
		//将rhs置于可析构状态
		rhs.elements = rhs.first_free = rhs.cap = nullptr;
	}
	return *this;
}
  • 当一个类没有定义任何自己版本的控制成员,且类的每个非static数据成员都能移动时,编译器才会合成移动构造函数或移动赋值运算符
  • 如果我们为难编译器,比如将移动操作声明为default的,但是有不能移动的数据成员,这时候会将移动操作定义为删除函数
  • 如果一个类有拷贝构造函数但是没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。
class Foo
{
public:
	Foo() = default;
	Foo(const Foo&);
};
Foo x;
Foo z(std::move(x)); //使用了拷贝构造函数,因为没有移动构造函数
  • 可以结合swap和移动操作
class HasPtr
{
public:
	HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
	//赋值运算符既是移动赋值运算符又是拷贝赋值运算符
	HasPtr& operator=(HasPtr rhs) { swap(*this, rhs), return *this; }
}
//赋值运算符是非引用参数,如果传递左值就是拷贝构造函数,传递右值就是移动构造函数
  • 三五/法则更新:如果一个类定义了任何一个拷贝操作,它就该把所有五个操作都定义了
  • 由于一个移后源对象具有不确定的状态,对其调用std::move会比较危险。当调用move时必须绝对确认移后源对象没有其他用户

右值引用和成员函数

  • 假设StrVec有两个版本的push_back,一个是const左值引用,一个是右值引用。可以将能转换为类型string的任何对象传递给第一个版本。但是第二个版本只能给它传递非const的右值
void push_back(const string &s)
{
	chk_n_alloc();
	alloc.construct(first_free++, s);
}
void push_back(string &&s)
{
	chk_n_alloc();
	alloc.construct(first_free++, std::move(s));
}
StrVec vec;
string s = "abc";
vec.push_back(s); //调用第一个
vec.push_back("abc"); //调用第二个
  • 有时候能看到用右值调用函数,或者给右值赋值。
string s1 = "abc", s2 = "def";
auto n = (s1 + s2).find('a'); //右值调用函数
s1 + s2 = "wow!"; //给右值赋值
  • 新标准库允许这种行为,但是我们可以阻止这种用法——同时在函数声明和定义的参数列表后放置一个引用限定符&
class Foo
{
public:
	Foo &operator=(const Foo&) &;
	//...
};
Foo &Foo::operator=(const Foo &rhs) &
{
	//...
}
  • 引用限定符可以是&或者&&,表示左侧只能是左值或者只能是右值
  • 同时出现const和&时,引用限定符跟在const后面
  • 可以利用引用限定符重载函数
class Foo
{
public:
	Foo sorted() &&; //右值调用的sort
	Foo sorted() const &; //任何类型的Foo都能用
peivate:
	vector<int> data;
};
Foo Foo::sorted() &&
{
	sort(data.begin(), data.end());
	return *this;
}
Foo Foo::sorted() const &
{
	//函数是const的,不能修改this指向的值
	//所以只能拷贝一份副本,对副本排序,然后返回副本
	Foo ret(*this);
	sort(ret.data.begin(), ret.data.end());
	return ret;
}
retval().sorted(); //retval是右值,调用第一个
retFoo().sorted(); //retFoo是左值,调用第二个
  • 重载const函数可以一个有const一个没有。但是重载引用限定的不一样,如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符
class Foo
{
public:
	Foo sorted(Comp*); //正确
	Foo sorted(Comp*) const; //正确,两个版本都没有引用限定符
	Foo sorted() &&;
	Foo sorted() const; //错误,无参数版本的sorted必须都加上引用限定符
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值