第三部分(设计者的工具)
1).很多语言并没有赋予类设计者控制,对象拷贝,赋值,移动和销毁时做什么的能力。
- 管理动态内存的类通常不能依赖于标准库合成的版本。
2).继承和动态绑定与数据抽象一起构成了面向对象编程基础。
第十三章(拷贝控制)
1).学习类如何控制该类型对象的拷贝,赋值,移动,销毁。
- 定义拷贝构造函数,移动构造函数,来定义当用同类型的另一个对象初始化本对象时做什么。
- 定义拷贝赋值运算符,移动赋值运算符,来定义将一个对象赋予同类型的另一个对象时做什么。
- 定义**析构函数,**来定义一个此类型对象销毁时做什么。
- 以上操作称为拷贝控制操作。
- 当一个类灭没有定义以上操作时,**编译器会自动为它们定义缺失的操作。**但是,对于一些类来说,这种默认的定义并不适用。
- 到底什么时候需要定义?
/1.拷贝、赋值、销毁
//1.拷贝构造函数(控制的是初始化)
1).拷贝构造函数。
- 形式。
{
class Foo {
public:
Foo ();//此为默认构造函数
Foo (const Foo &);//此为拷贝构造函数,
// 并且这个拷贝构造函数和 合成的拷贝构造函数hi是一样的。
//等价于
}
}
- 定义,**如果一个构造函数的第一个参数是自身类的引用,且任何额外参数都有默认值。**此构造函数就是拷贝构造函数。注意额外参数的默认值就是默认实参。
- 为什么拷贝构造函数的第一个参数是引用?任何拷贝都需要调用拷贝构造函数,如果不是引用,那么就需要调用拷贝构造函数,而此时的拷贝构造函数自己也需要拷贝,这是一个死循环。
- 我们可以定义一个接受非
const
引用的拷贝构造函数,但是此参数几乎总是一个const
的。 - 由于拷贝构造函数会经常被隐式地调用,所以他不应该是
explicit
的。 - 与默认构造函数不一样的是,即使我们定义了其他拷贝构造函数,编译器还是会为我们合成一个拷贝构造函数。
- 合成拷贝函数,对于某一些类来说是可以阻止我们进行类的对象拷贝的(13.1.6)。一般就是将对象中的每一个元素进行逐一地拷贝。编译器将给定的对象的每一个非
static
成员拷贝到正在创建的对象中去。 - 不同的类型,采取的方式不一样;
- 对于内置类型就是直接拷贝。(数组就是每一个元素进行拷贝)
- 对于类成员,就调用类成员的拷贝构造函数。(这是一个递归。)
{
Sales_data (const Sales_data &);
//以上的拷贝构造函数就等价于,
//与默认构造函数是一样的。
//对每一个成员进行拷贝。
Sales_data (const Sales_data &orig) :
bookNo(orig.bookNo),
unit_sold(orig.unit_sold),
revenue(orig.revenue)
{ }
}
2).什么是拷贝,什么是直接赋值。
- 拷贝初始化的发生经历两个过程。
- 类型转换,这一步需要有隐式转换。(可选)
- 调用类的拷贝构造函数。
{
string null_book = "123";//拷贝初始化,经历完整的两个过程
//等价于
string temp("123");//构造string,隐式转换
null_book = temp;//拷贝
//以下的例子也是一样的
//均调用了构造函数和拷贝构造函数。
string s = string(12,'1');
string s = string("123");
}
- 直接初始化。只有一个过程,即进行构造函数的调用。
{
//两个例子
string s(10,'1');
string s("123");
//这个例子比较特殊(里面就是一个对象)。
string s1(s);
//拷贝(书本有误。)。
}
3).什么时候发生拷贝?
- 将一个对象作为实参传递给非引用的实参时
- 函数返回类型不是引用时
- **使用花括号列表进行初始化数组的元素或者聚合类中的成员时。**如果数组的元素是类或者聚合类中有类成员,那么需要调用构造函数(需要类型转换时)和拷贝构造函数。
- 一些特殊的情况,某些类型还会对 它们所分配的对象(元素) 使用拷贝初始化。例如,初始化标准库容器或者调用
insert
,push
函数成员,容器对元素是拷贝初始化。 - 但是用
emplace
创建元素,是直接初始化。 - 区别是,要创建的元素/对象,是先创建,再进行初始化;还是直接创建。
4).在拷贝初始化时,编译器跳过拷贝/移动构造函数,直接构造对象。
- 拷贝初始化依靠拷贝/移动构造函数完成的。
- 虽然编译器可以这样略过,但是在这个执行点时,拷贝/移动构造函数必须是存在而且是可以访问的(
public
),
{
string s = "111";//拷贝初始化
//编译器进行改写
string s("111");//略过拷贝构造函数
}
5).是拷贝还是直接初始化。(总结)
- 是否调用了拷贝构造函数。(
string s = "1234";
) - 使用
=
还是()
。同类型对象都是拷贝。 - 是先创建了要创建的对象再进行拷贝,还是直接创建了对象。(
insert
,emplace
)
练习,
- 13.3,拷贝初始化,用
=
定义变量,就是拷贝初始化(有可能包含隐式转换。)。
{
//构造函数是不是explicit的区别
shared_ptr<int> p = new int(12);//错误,构造函数是只能显式转换的
shared_ptr<int> p(new int(12));//直接构造。
}
- 13.3,当类有
shared_ptr
成员时。类的对象进行拷贝时,利用拷贝构造函数,对成员逐一拷贝。那么shared_ptr
的引用计数会加一。上机证明,类使用()
或者=
,引用计数都增加了。 - 当
()
里面是一个同类型对象时,就是拷贝,调用拷贝初始化。这是特殊的。(其他的调用的是构造函数。) - 13.4,例如,
C *p = new C(c);
也是一次拷贝。申请一块空间进行拷贝。 - 可以这么说,对于需要
args
构造的,才是直接初始化。对于同类对象之间的,或者先类型转换为同类型的,都是拷贝初始化。 - 13.5,与默认拷贝构造函数不同的自定义拷贝构造函数。
{
class H {
public:
H(const H ©) {
i = copy.i;
p = new string(*(copy.p));//指向的是自己一个新的空间,只是内容一样。这一点与默认的是不一样的。
}
private:
int i;
string *p;
};
}
//2.拷贝赋值运算符(控制的是赋值)
1).默认的拷贝赋值运算符。
- 如果一个类没有定义拷贝赋值运算符,编译器会默认的合成。
- 用途,
- 用来阻止该类型对象的赋值运算。
- 或者将非
static
的成员逐一拷贝。(与拷贝构造函数的作用是一样的。)
{
//等价于合成的拷贝赋值运算符
Sales_data &
Sales_data::operator= (const Sales_data &s) {
bookNo = s.bookNo;//调用的是string::operator=
units = s.units;//调用的是内置的int赋值
revenue = s.revenue;//调用的是内置的double赋值
return *this;//返回的是左侧运算对象的引用。
}
}
2).重载赋值运算符。
- 重载运算符,本质就是一个函数,名称为
operator
加上它要重载的运算符号。 - 重载赋值运算符就是一个名字为
operator=
的函数。它也有返回类型,参数列表,函数体。 - 重载运算符号的参数表示,运算符的运算对象。
- **一些运算符,包括赋值运算符,必须定义为类的成员函数,因为需要返回左侧运算对象(我们只需要返回
*this即可。
这一点是为了与内置的=
保持一致。)和左侧的运算对象。**如果一个运算符是一个成员函数,其左侧的运算对象就绑定到隐式的this
。并且对于一个二元运算符号,例如=
。其右侧的运算对象就是显式传递参数(形参)。
3).拷贝赋值运算符
- 它接受一个与它类型一样的的参数作为右侧运算对象。
{
class F {
public:
F &operator=(const F&);
};
}
4).注意事项。
- 标准库要求保存在容器中的类型要具有赋值运算符,而且返回值是左侧运算对象的引用。
- 通常情况下,赋值运算符也应该返回一个指向其左侧运算对象的引用。
练习,
- 13.8,定义自己的拷贝赋值运算符。(如果是合成的
=
,则两个对象的指针指向的是同一个地方。)
{
H &
H::operator= (const H&) {
auto newsp = new string(*(H.sp));//构建的指针。
delete sp;//销毁旧得内存地址
sp = newsp;
i = H.i;
return *this;
}
}
//3.析构函数
1).什么时候进行析构?
- 一个类对象被销毁时,就会自动调用析构函数。(对于类,容器,)
- 类对象离开作用域被销毁时;特别地,当一个类对象被销毁时,其类成员也被销毁时。
- 容器(标准库容器或者内置数组),被销毁时;特别地元素为类类型时,元素也会调用析构函数。(则该过程一共两次调用析构函数。)
- 动态分配的对象,当被
delete
时,它所指向的元素会被销毁。如果对象为类类型则会调用析构函数。 - 对于临时的类对象,当创建它的表达式结束时。
- 注意,销毁和调用析构函数是不一样的。
因为,对于内置类型没有析构函数,直接销毁即可。
{
Sales_data *p = new Sales_data;//内置指针
auto p2 = make_shared<Sales_data>();
Sales_data item(*p);//拷贝
vector<Sales_data> vec;
vec.push_back(*p);
delete p;//Sales_data调用析构函数
}
//离开作用域,p2被销毁,引用减一为0,指向的类销毁执行析构,对于string成员,调用string析构函数
//vector被销毁,元素也被销毁,类类型元素调用自身析构函数,对string类型成员调用string析构函数
// 容器,数组,被销毁,它的每一个元素也被销毁。
2).智能指针
- 它是一个类,当它被销毁时,它会调用自己的析构函数;并且引用计数减一,如果此时引用计数为0,(这与智能指针的析构函数相关)那么它所指向的对象也会被销毁,调用该对象的析构函数。
- 而内置指针,如果不是
delete
,而是直接由于作用域被销毁,那么它所指对象不会销毁,不会调用析构函数。
3).自定义的析构函数。
- 用来释对象的资源,并销毁对象非
static
成员。 - 构造函数初始化非
static
数据成员。? - 形式,
- 析构函数不接受参数,不可以被重载。类只有一个析构函数。
- 构造函数有初始化部分和函数体,析构函数有析构部分和函数体,但是析构部分是隐式的。
- 析构函数的执行顺序,先执行函数体,然后再按初始化的逆序销毁成员。析构部分是隐式的。
{
class F {
public:
~F ();
};
}
4).合成析构函数。
- 合成析构函数可以用来阻止一些类的对象被销毁。
- 合成析构函数的等价形式。函数体为空,什么额外操作也没有。
{
class F {
public:
~F () {};
};
}
练习,
- 13.11,前面的类,数据成员是内置的指针。如果是按照合成的方式,它所指向的对象在类被销毁时,是不会被释放的。所以,我们需要在函数体中,进行操作。
{
~F(){delete sp;}
}
- 13.12,
class(class);
就是拷贝。
{
c b;//默认
c *a = new c(b);//拷贝
c *a = new c;//默认
}
//4.三/五法则
1).几个原则。
- 虽然编译器不要求我们定义自己的操作,也不要求我们一旦定义一些操作,其他操作也需要一起定义。但是在一些情况里,我们从逻辑上分析,可以知道
- 如果我们定义了拷贝赋值运算符,来到达某一些合成所不能完成的目的,那么我们必然需要自定义一个拷贝构造函数。反之也对。
- 如果定义了一个析构函数来完成合成的所不能完成的功能,那么我们必然必须定义靠别赋值运算符和拷贝构造函数。例子见练习,我们在析构函数体中,
delete
一个动态内存指针,而一个指针不能重复delete
,所以必然有赋值时,对每一个对象都是不一样的动态指针。而这是合成的拷贝构造函数以及拷贝赋值运算符所不能完成的。 - 总结,这些操作虽然可以单独地定义,但是从逻辑上,它们往往是一个整体,是打包定义的。
练习,
- 注意传参时候也会进行拷贝,我们定义的拷贝构造函数也会被调用。
{
void f(C c) {cout << c.num << endl;}
numbered a,b = a,c = a;
f(a),f(b),f(c);//一共进行了5此拷贝。假定num从零开始,由于构造函数时合成的,
// 只有拷贝构造函数是编号的,且从0开始,
// 输出结果是 3,4,5
}
//5.使用=default
1).几点注意
- 我们只能对构造函数以及拷贝控制成员使用
=default
。因为只有它们是有合成版本的成员函数。 - 使用
=default
显式地要求编译器生成合成的版本。 - 在类内使用时,合成的版本会隐式地声明为内联的。在类外则不是。
{
class Sales_data {
Sales_data() = default;//隐式内联的
Sales_data(const Sales_data &) = default;//同
Sales_data& operator=(const Sales_data &);//类外,不是内联的。
};
Sales_data& Sales_data::operator = (const Sales_data&) = default;
}
//6.阻止拷贝
1).阻止拷贝的两种方式。(有些类的有些操作是没有意义的,所以需要阻止。(例如,istream
))
- 将拷贝构造函数和拷贝赋值运算符号声明为
=delete
,(定义为删除函数,虽然声明了,但是不可以使用,同时还阻止了编译器进行合成。)
delete
必须出现在第一次出现时,因为这关乎整个操作,这不像default
是只影响单个成员,只为单个成员生成代码。
{
struct NoCopy {
NoCopy() = default;
NoCopy(const NoCopy &) = delete;//不允许拷贝初始化。
NoCopy& operator=(const NoCopy &) = delete;//不允许赋值拷贝
~NoCopy() = default;
};
}
- 将我们不希望的操作定义在不可以访问的地方(
private
),并且不定义它,防止定义了它,友元可以进行拷贝或者赋值。**一般只声明一个函数而不定义它是合法的。但是有一个例外。**这样做,如果友元试图拷贝,会编译报错,没有函数定义(链接时错误)。
{
class p {
p(const p &);
p& operator=(const p &);
}
}
2).我们可以对任何一个成员函数使用=delete;
- 用来引导函数匹配,防止重载。
- 阻止操作。
3).合成的函数也可能时delete
的。
- 当一个类的数据成员的拷贝构造,拷贝赋值,默认构造,析构函数是
delete
或者不可以访问的,那么该类对应得函数也是delete
。因为类的操作是需要对每一个成员进行。 - 如果一个类有成员的析构是
delete
或者不可访问的,那么类的默认构造,拷贝是delete
的,防止构建一堆不能析构的对象。 - 如果一个类中没有显式初始化的
const
成员或引用,那么合成默认函数是delete
的。 - 如果如果类中有一个成员是
const
的或者引用,那么拷贝构造和赋值运算符都是delete
的。因为const
不能拷贝;引用的拷贝和我们的意图相反,这样做会使得所有的引用都指向同一个对象,容易犯无效引用的错。
4).析构函数是delete
,我们可以设置它的动态指针。但是不能使用delete
。
{
struct s {
s() = default;
~s() = delete;
};
s *p = new s();//有默认构造函数,如果是而合成的,那么这句也是错误的
}
练习,
- 13.18,通过
static
数据成员来生成一个唯一的编号。
{
class s {
static int sn;
public:
s() {num = sn++;}
s(const string &) {name = s;num = sn++;}
private:
string s;
int num;
};
}
/2.拷贝控制和资源管理
1).拷贝形式。
- 值拷贝。相互独立
- 指针拷贝。共享
- 不允许拷贝。非指针非值。例如,
IO
类型和unique_ptr。
//1.行为像值的类
1).注意事项。
- 书写拷贝赋值运算符,等价于是拷贝构造和析构的和。
- 确保将一个对象赋予自身,赋值也可以正常进行。
{
s& s::operator=(const s &a) {
auto newp = new string(*(a.p));
delete p;
p = newp;
return *this;
//而如果是这样
delete p;//string释放
p = new string(*(a.p));//错误,p所指的和自身的一样的,
// 此时,数据已经丢失了,这指针是空悬指针
}
}
//2.行为像指针的类
1).
- 关键在于类似于
shared_ptr
的思想。使用我们自己定义的引用计数。 use
使用指针避免发生不统一的情况。
C a,b(a),c(a);//如果不是指针,那么b将会无法更新。
- 从这也可以看出
shared_ptr
的强大之处。 - 有接受
string
的构造函数,有接受对象的拷贝构造函数。 - 使用动态内存,建立共享
- 保证自身赋值不会出错。
{
class HasPtr {
public:
HasPtr(const string &s = string()) :
ps(new string(s)),i(0),use(new size_t(1)) {}
HasPtr(const HasPtr &) :
ps(p.ps),i(p.i),use(p.use) {++(*use);}//拷贝构造函数。是不创建指针的。直接赋值
// 这也说明了拷贝构造函数只用在初始化的事实。
HasPtr& operator=(const HasPtr &);
~HasPtr();
private:
string *ps;
int i;
size_t *use;//使用指针
};
HasPtr::~HasPtr() {
//引用计数变为0
// 释放相应的内存。
if (--(*use) == 0) {
delete ps;
delete use;
}
}
HasPtr& HasPtr::operator= (const HasPtr &d) {
++(*d.use); //为了防止一个对象复制给自身时发生错误。
if (--(*use) == 0) {
delete use;
delete ps;
}
use = d.use;
i = d.i;
ps = d.ps;
return *this;
}
}
- 拷贝赋值运算符号,基本是析构和拷贝构造的结合。
练习,
- 13.28,对于树结构,指针指向的还是指针,不可以仅仅
delete
根节点的指针。递归操作。
/3.交换操作
1).为什么要自定义swap
。
- 在使用一些元素排序算法时,需要使用
swap
,如果我们没有自己定义就会使用标准库里面的版本。而标准库里面的版本,就是三行式,需要建立一个temp
,有些时候我们不需要这样浪费,因为我们只需要交换指针就可以了。 - 所以避免不必要的内存分配。(注意这里是行为像值的类)
- 将
swap
声明为inline
,优化代码。 - 定义
swap
,但是是一种重要优化手段。
{
class HasPtr {
friend void swap(HasPtr &,HasPtr &);
HasPtr(const HasPtr &d) : ps(new string(*(d.ps))) {}
private:
string *ps;
};
//注意格式
inline
void swap(HasPtr &L,HasPtr &R) {
using std::swap;
swap(L.ps,R.ps);
swap(L.i,R.i);
}
}
2).到底是哪一个swap
。(类中的数据成员)
- 注意
swap
函数的中的swap
到底是std::
里面的还是类自己定义的,取决于
- 它是内置类型,则是
std::
- 它是类,并且定义了自己的
swap
- **我们不应该指定在
swap
前面指定std
;因为这样做和使用默认的swap
是一样的。**不带有std
,会优先匹配类中的版本,然后在是std
中的版本。(using
语句是否必不可少?) - 为什么前面有
using std::swap;
也可以正常地调用类中的版本?。
{
class C {
friend void swap(C &,C &);
HasPtr p;
};
// 定义C中的swap函数
void swap(C &L,C &R) {
using std::swap;
swap(L.p,R.p);//调用的是HasPtr自己定义版本。
// 以下是错误的。
std::swap(L.p,R.p);
// 虽然可以编译通过,正常运行,
// 但是不是我们想要的性能。
// 这就是默认的版本,会进行拷贝初始化。建立不必要的内存。
}
}
3).在赋值运算符中使用swap
,拷贝(实参到形参)并交换(形参和左运算对象)的技术。
- 保证自身赋值的正确性。
- 出现错误只能在拷贝形参时,中的
new string(*(d.ps));
初始化语句。即使出现异常也是在swap
之前,对原来的没有影响。 - 使用拷贝和交换的结合自动就是异常安全的,而且可以正确处理自赋值。
{
// 注意到这里是一个副本。而不是引用
HasPtr& operator=(HasPtr R) {
swap(*this,R);
return *this;
}
}
/4.控制拷贝示例
1).应用
- 分配资源的类需要拷贝控制。
- 簿记操作也需要拷贝控制。例如以下的邮件处理系统。
message
是消息。folders
是目录。 - 一个
message
可以在很多的folders
中,并且一个message
只有一个副本,实现的是共享。
{
class Message {
friend class Folder;
public:
// 也是一个默认构造函数
explicit Message (const string &str = "") :
contents(str) {}
Message(const Message &);
Message& operator=(const Message &);
~Message();
void save(Folder &);//添加文本
void remove(Folder &);//删除文本
private:
string contents; //消息文本
set<Folder *> folders; //包含message的folders
void add_to_folders(const Message &);
void remove_form_folders();
};
void Message::save(Folder &f) {
folders.insert(&f); //添加序列,文件的指针
f.addMsg(this); //添加到f中
}
void Message::remove(Folder &f) {
folders.erase(&f); //删除指定关键词(也就是文件指针)
f.remMsg(this); //this也是指针
}
}
2).拷贝控制成员。
- 需要将左侧运算对象添加到右侧运算兑现的每一个
folder
中,也就是说,需要遍历右侧的set
的每一个folder
指针。 - 由于拷贝构造函数和拷贝赋值运算符都需要这个操作,我们采用封装的思想,将实现放在
private
中。
{
//完成private实现部分
void Message::add_to_folder(const Message &m) {
for (auto f : m.folders)
f->addMsg(this);
}
// 完成拷贝构造函数。
Message::Message(const Message &m) :
contents(m.message),folders(m.folders) {
add_to_folder(m);
}
}
3).析构函数。
- 需要将该
message
中的set
的文件删除该message
。 - 同理由于拷贝运算符也需要这个操作,我们进行封装。
{
// 这是不封装的。
Message::~Message() {
for (auto f : this->folders) {
f.remMge(this);
}
}
//进行封装。
void Message::remove_from_folders () {
for (auto f : this->set) {
f.remMsg(this);
}
}
Message::~Message() {
remove_form_folders();
}
}
4).拷贝赋值运算符
- 同理需要解决自身赋值的问题。由于是没有删除
message
的set
所以这样操作是合理的。
{
Message& Message::operator=(const Message &m) {
remove_form_folders();
// 进行拷贝
this->folders = m.folders;
this->contents = m.contents;
add_to_folders(m);
return *this;
}
}
5).Message
结合swap
函数
- 标准库中定义了
string
和set
版本的swap
函数。 - 为什么不用三行式的交换?提升性能,避免不必要的添加到文件和从文件中删除。所以自定义这样的交换。
{
void swap(Message &L,Message &R) {
// using严格来说并不需要,但是这是一个好习惯。
using std::swap;
// 先将每一个消息从它们的folders中删除
for (auto f : L.folders) {
f->remMsg(&L);
}
for (auto f : R.folders) {
f->remMsg(&R);
// 交换指针和内容
}
swap(L.contents,R.contents);
swap(L.folders,R.folders);
// 添加回去。
for (auto f : L.folders) {
f->addMsg(&L);
}
for (auto f : R.folders) {
f->addMsg(&R);
}
}
}
练习,
- 13.33,对一个
Folder&
进行&
运算,得到的就是它本体的地址。 - 13.24,
Folder
的实现也需要将自己添加/删除到每一个message
;方便进行拷贝等。
/5.动态内存管理类
1).如果一个类在运行时需要分配大小可变的内存空间
- 使用标准库容器,例如
vector
来实现 - 但是一些类必须要自己进行内存的管理;需要我们自己定义拷贝成员函数。
2).类vector
内存分配思想构造自己的StrVec
类进行动态内存分配。
- 部分操作来自对
vector
的仿造。
{
class StrVec {
public:
StrVec() :
elements(nullptr),first_free(nullptr),cap(nullptr) {}
StrVec(cosnt StrVec&);
StrVec& operator=(const StrVec &);
~StrVec();
void push_back(const string &);
size_t size() const {
return first_free - elements;
}
size_t capacity() const {
return cap - elements;
}
string* begin() const {
return elements;
}
string* end() const {
return first_free;
}
};
private:
//分配内存空间的元素
Static allocator<string> alloc;
//查询是否需要重新分配内存。
void chk_n_alloc() {
if (size() == capacoty()) reallocate();
}
//工具函数。
pair<string *,string *> alloc_n_copy (const string *,const string *)
void free(); //销毁元素并释放内存
void reallocate(); //获取更多的空间并进行拷贝原有的元素
string *elements; //指向数组的首元素。
string *first_free; //第一个未定义的空间
string *cap; //空间的尾后迭代器
}
3).push_back
操作。
- 检查是否有空间。
- 没有则需要
reallocate
- 有则,使用
allocator.construct()
- 对于
construct
是进行构造。这里由于是同类型的对象,所以调用的是拷贝构造函数。
{
void StrVec::push_back(const string &s) {
ch_n_alloc(); //确保有空间
alloc.construct(first_free++,s);
};
}
4).alloc_n_copy
成员
- 拷贝或者赋值
StrVec
时,需要调用这个函数。 - 注意与
vector
类似,它具有类值得行为。所以是拷贝。分配新的内存空间。 - 返回的是一个指针对,指向的是拷贝的空间的首指针和尾后指针。
{
pair<string *,string *>
StrVec::alloc_n_copy(cosnt string *b,const string *e) {
auto data = alloc.allocate(e - b);
return {data,uninitialized_copy(b,e,data)};
// 返回的是一个新的,begin和end指针对
}
}
5).free
成员。
- 完成销毁和释放空间的工作。
- 注意销毁时,是从尾元素开始,逆序销毁所有的内存空间。
string
元素被销毁会调用自己的析构函数。
{
void StrVec::free (){
// 不能给deallocate一个空的指针。
if(elements) {
for (auto p = first_free;p != elements; )
alloc.destroy(--p);
alloc.deallocate(elements,cap-elements);
}
}
}
6).拷贝控制成员。
- 类值行为。
{
// 拷贝构造函数。
StrVec::StrVec(const StrVec &d) {
auto newData = alloc_n_copy(d.begin(),d.end());
// 注意返回的是一个pair
elements = newData.first;
// 此时first_free和cap在一个位置。
first_free = cap = newData.second;
}
// 析构函数
StrVec::~StrVec() {free();}
// 拷贝赋值运算符
StrVec& StrVec::operator=(const StrVec &d) {
auto newData = alloc_n_copy(d.begin(),d.end());
free();
element = newData.first;
first_free = cap = newData.second;
return *this;
}
}
7).编写reallocate
成员。
- 申请至少大一倍的空间
- 拷贝现有的元素
- 释放原来的空间
{
// 这样做的性能不高。因为需要将原来的数据进行拷贝转移,还要将原来的空间进行释放。
// 如果可以避免拷贝和释放string的额外开销,那将提升性能。
// 注意这里没有做size的判断,是错误的
void StrVec::reallocate() {
auto newSpace = alloc.allocate(end()*2 - begin());
auto newSpace = alloc_n_copy(begin(),end(),newSpace.first);
free();
element = newSpace;
cap = newSpace + end()*2 - begin();
first_free = newData.second;
}
}
8).移动构造函数和std::move
- 一些库类型,包括
string
,都定义了所谓的**移动构造函数。**具体细节未公开。 - 我们不知道具体的实现过程,但是我们可以把
string
看成是含有c
风格的指针成员的类,它管理着内存,当我们move
时,与前一个版本相比,相当于是进行了指针的移动,而指针所指内容是不移动的。原因是,使用了移动构造函数,string
管理的内存空间不会被拷贝,而是构造的每一个string
都会从原来的string
中接管内存的所有权。原来的string
不再管理原来的内存了。而且更加神奇的是,对于旧的string
,我们执行free
时,会正确执行string
的析构函数(即保持有效的,可以析构的状态);但是我们不知道,经过移动之后旧的string
里里面内容是什么。 move
是一种标准库函数,定义在头文件utility
中。
- 当我们使用,
reallocate
在新内存中构造string
时,必须调用move
表示我们希望使用的是string
的移动构造函数。如果漏掉了,就会执行string
的拷贝构造函数。 - 通常,对于
move
的使用不需要加上using
声明,但是必须是std::move;
而不是move
。
- 注意对原来空间的加倍处理。
{
void StrVec::reallocate() {
auto newCapacity = size() ? 2 * size() : 1; //当前大小大两倍的空间,注意0的时候。
auto newData = alloc.allocate(newCapacity);
auto dest = newData;
auto elem = elements;
for (size_t i = 0; i != size(); ++i)
alloc.construct(dest++,std::move(*elem++));
free();//移动完成,释放旧的内存
elements = newData;
first_free = dest;
cap = newData + newCapacity;
}
}
/6.对象移动
1).为什么移动?
- 有些时候,例如交换,我们拷贝的目标对象只是暂时存在的,使用之后就立即销毁了,拷贝是一种性能的消耗。对于一些大的对象更是如此。
IO
类以及unique_ptr
没有拷贝(有些不能共享的资源,IO
缓冲,指针。),怎么解决。
- 在之前容器中的元素要求可以拷贝;现在只要求,元素可以移动就可以。
share_ptr
,string
,标准库容器,支持拷贝也支持移动;但是IO
类,unique_ptr
,只支持移动,不支持拷贝。
//1.右值引用
1).哪些是左值,哪些是右值。
- 左值(持久的)
- 返回左值引用的函数,
- 前置的自增自减的返回值,
- 赋值运算的返回值,
- 下标运算的返回值,
- 解引用的返回值,
- 只有一个变量的变量表达式
- 右值(短暂存在,字面量或者在表达式求值时,创建的临时对象)
- 返回非引用类型的函数,
- 算术,关系,位运算
- 后置的自增,自减
- 字面量
{
StrVec v1,v1; //默认构造函数
v1 = v2;//拷贝赋值运算
StrVec getVec(istream &);
v2 = getVec(cin); //移动赋值运算
}
2).右值引用特性。
- 绑定的对象将要被销毁,
- 绑定的对象没有其他的用户
- 所以使用右值引用的代码可以任意使用它所包含的资源。
- 可以将他移动到另一个对象中去。
3).非const
左值引用,只能绑定在左值上;const
左值引用可以,右值引用只能绑定在右值上。(const
只是不能改变值,不要求时字面值类型等。)
- 右值引用的形式
int &&a;
{
int i = 42;
int &a = i;
int &&b = i;//错误
int &c = i*12;//错误
const int &d = 12;
int &&e = i * 13;
}
4).变量表达式,只有一个变量的表达式,返回的是一个左值。
- 即使它是一个右值引用类型。
- 因为它是变量,至少它在离开作用域前,它不会被销毁。
- 也就是,右值是短暂的,左值是持久的。
{
int &&i = 12;
int &&a = i; //错误。它是一个左值。
}
5).调用std::move
,实现左值到右值的转换。
- 这个
move
和上面介绍的move
是一样的。 - 但是,一旦对一个变量使用
move
,意味着,我们只能对这个变量进行销毁,或者赋予新值。而不能对他进行任何值的阶段,例如作为=
的右侧运算对象。
{
int &&a = std::move(i); //正确。
}
//2.移动构造函数和移动赋值运算符
1).为什么移动?需要注意什么。
- 标准库以及
string
类,很多都同时支持移动和拷贝操作。 - 尽管移动可以提升性能,但是在除了移动构造函数,移动赋值运算符之外的地方,性能的提升有限,而且使用它要小心。因为他会使得源的状态不确定。
2).移动构造函数和拷贝构造函数的差异。
- 参数的差异,一个是右值引用,一个是左值引用。相同的是,任何额外的参数都必须有默认实参。
- 并且,当我们只定义了拷贝构造函数,编译器会进行函数匹配,选择拷贝构造函数而不是移动构造函数。此时也不会合成移动的构造函数。
{
class f {
f() = default;
f(const f&);
};
f x;
f y(x);//拷贝构造
f k(std::move(y));//还是调用拷贝构造。 这就是函数的匹配。
// 因为f&&可以转换为const f&。
// 并且用拷贝构造函数来代替移动构造函数几乎肯定是安全的。因为拷贝构造函数一般都是满足移动构造函数的要求的,使得源对象处于有效的,安全的状态,而拷贝根本就没有改变源的状态。
}
- 拷贝构造并不会改变源的状态,而移动构造会将源改变得“面目全非”。但是有可能还指向原来的资源。
- 所以在最后,移动的还要保证源是有效的,是安全的(可以被赋新值的,可以正确调用析构函数的,也可能调用一些操作,但是我们不能对它的结果进行任何假设。虽然我们在
StrVec
中是将源进行归位,但是一些更加复杂的类,我们不可以预知。)。所以经常的对于指针就是置空,对于set
进行clear
,防止析构函数误伤重要内容(有可能是我们刚刚移动的资源。)。
3).移动构造的简单应用。
- 其实有偷换概念的嫌疑。因为这就是指针的交换。没有用到“移动”。
- 而且原来的对象经过交换后,不可用。
- 或者说这是指针的接管。而不是指针指向元素的接管。
{
// 例子,移动构造函数。
StrVec::StrVec(StrVec &&r) noexcept :
elements(r.elements),cap(r.cap),first_free(r.first_free) {
s.elements = s.first_free = s.cap = nullptr;
}
// 例子。移动赋值运算符号
StrVec &StrVec::operator=(StrVec &&r) noexcept {
if (this != &r ) {
// 检查是否是自身赋值
free(); //释放内存
elements = r.elements;
first_free = r.first_free;
cap = r.cap;
r.elements = r.cap = r.first_free = nullptr;
//将他置为可以析构的状态,有效,安全。
}
return *this;
}
}
4).**关于noexcept
,**它是一种信号,我们保证这个函数不会抛出异常。(自定义类和标准库的交互。)
- 如果我们不这样声明移动构造函数,那么移动构造函数不会被调用,因为如果调用,移动构造函数,执行到一半时,如果抛出异常,将会导致,源的内容丢失,目标的内容不完全。此时只会调用拷贝构造函数,拷贝不会对源进行修改,所以即使有异常,也不会导致内容丢失。
- 当我们声明为不会抛出异常时,才会调用移动构造函数。
- 类的保证自身不受损失的要求进行匹配函数。例如
vector
保证移动时,不会有异常导致自身内容丢失,才会调用移动构造函数。 - 实际上,如果只是移动是不会有异常的抛出的。
- 如果没有声明,标准库就会认为我们移动时可能抛出异常,并且为了处理这中可能做一些额外的工作。
- 对于不抛出异常的移动构造函数和移动赋值运算符,都必须标记为
noexcept
。 - 声明地方。必须在声明和定义中(如果定义在类外的话)都指定是
moexcept
。
{
class StrVec {
public:
StrVec(StrVec &&) noexcept;//移动构造函数
};
StrVec::StrVec(StrVec &&s) noexcept : /*成员初始化器*/ {
/*函数体*/
}
}
5).合成移动的操作和拷贝操作的差异
- 如果我们不声明自己的拷贝构造或者拷贝赋值,编译器总会为我们进行合成。要么逐一拷贝,要么是删除的。
- 但是,对于移动操作。**如果一个类定义了自己的拷贝构造或者拷贝赋值运算符或者析构函数,编译器就不会为它们合成移动操作。**因此一些类就没有移动操作。此时,会用拷贝操作代替移动操作。而且这样做也是安全的。因为拷贝对源没有任何的影响。
- 以下是定义了拷贝没有移动的情况。
{}
class f {
f() = default;
f(const f&);
};
f x;
f y(x);//拷贝构造
f k(std::move(y));//拷贝构造
}
- 编译器会为一个没有定义任何自己版本的控制成员的类,且每一个非
static
数据成员都可以移动的类,合成移动构造或移动赋值。
- 内置成员,编译器可以直接移动。
- 类成员,如果有相应的移动操作,也可以移动。
{
struct x {
int i; //内置类型
string s; //标准库定义
}; //编译器可以为x合成移动操作
struct HasX {
x mem; //有合成的
}; //编译器也可以为这个类合成移动操作。
x a,b = std::move(a);//默认构造,移动构造
HasX hx,hx2 = std::move(hx);//默认构造,移动构造
}
6).合成的移动构造或者移动赋值版本是删除的。
- 当我们显式地要求编译器生成
=default
的移动操作,但是编译器不能移动所有的成员时。此时编译器的移动操作就定义为删除的函数。 - 有类成员定义了拷贝构造函数,而未定义移动构造函数,(这是因为匹配关系)或者,有类成员没有定义自己的拷贝构造函数且编译器不能为它合成移动构造(例如定义了其他的拷贝控制成员)。此时编译器删除合成移动构造函数。移动赋值运算符号同。
{
// Y是一个类,定义了拷贝但是没有定义移动
struct hasY {
hasY() = default;
hasY(hasY &&) = default;//显式要求,但是编译器不能合成,因为有类成员定义了拷贝而没有定义移动,(函数匹配只能拷贝)
// 如果没有显式要求,那么将是不能合成的。
Y mem;
};
hasY hy,hy1 = std::move(hy);//错误。移动构造函数是删除的
}
- 有类成员的移动构造函数定义为删除或着不可访问的。赋值同
- 类成员的是
const
或是引用,类的移动赋值时删除的。 - 类的析构函数定义为地删除或者不可访问的,移动构造是删除的。
7).如果一个定义了移动操作,那么这个类合成的拷贝操作将会是删除的。
8).综合以上。**五个构造函数应该看成是一个整体。**由于移动构造函数,移动赋值运算,与拷贝的版本有着很多关系(可以重载,不能合成等),为了我们达到预期的目的(只是移动不拷贝,或者只拷贝不移动),这些函数常常一起定义。
- 只有移动构造函数和拷贝构造函数,似乎编译器不会合成默认的构造函数。
9).当我们一起定义这些控制操作时,然后就是一个函数匹配问题。
- 只定义移动构造,拷贝以及移动赋值,是不合法的。甚至定义一个默认对象的都不行;当一个对象给另一个对象赋值也是失败的,因为传参需要拷贝构造。
- 例如以下,我们定义了移动和拷贝的操作。
- 虽然当是一个右值,移动赋值和拷贝赋值都是可以的,但是拷贝赋值需要进行一次到
const
的转换,而strVec&&
是一个亲精确的匹配。
{
StrVec v1,v1; //默认构造函数
v1 = v2;//拷贝赋值运算
StrVec getVec(istream &);
v2 = getVec(cin); //移动赋值运算
}
- 当既有移动构造函数,也有拷贝构造函数时,编译器会进行精确匹配优先。
{
C i;//默认构造
C a(i);//调用拷贝构造
C b(std::move(i));//调用移动构造。
}
- 一个赋值操作重载了移动和拷贝。但是要注意实参给形参是一种拷贝初始化。如果实参是左值,只能调用拷贝构造函数,如果实参是右值,可以调用移动或者拷贝初始化(因为形参是
const
类型)。
- 定义了移动,就不会合成拷贝(编译器原因)
- 定义了拷贝,也不会合成移动(匹配原因)。
- 形参类型是不是引用(左值或者右值),不影响。可以是引用,但是没有必要,因为过了作用域就失效了。待验证?
{
class HasPtr {
public:
// 添加移动构造函数。
HasPtr(HasPtr &&p) noexcept :
ps(p.ps),i(p.i) {p.ps = 0;}
// 添加移动赋值运算符
// 既是拷贝赋值运算符号又是移动赋值运算符号。
HasPtr& operator=(HasPtr r) {
swap(*this,r);
return *this;
}
// 注意上述不是引用。
};
// 类的两个对象。
// 这里都是调用移动(拷贝)赋值运算符
// 此时这两个函数是一样的。
hp = hp2; //这个是报错的
hp = std::move(hp2); //可以运行。
// 但是在传递参数时,还是进行了拷贝。需要拷贝初始化
// 这里的拷贝会调用移动构造函数(实参是左值时),而如果实参是右值,由于定义了移动操作,那么它的合成拷贝操作就是删除的。
}
10).更加优化的Message
拷贝操作。(但是原来的对象是被销毁的。)(右值引用的应用。)
- 使用了
set,string
的移动构造函数,而不是拷贝构造函数。 - 注意由于
set
会抛出异常bad_alloc;
,我们并不声明是noexcept
的。
{
// 工具函数
// 使用指针,因为不想拷贝,从而提升性能。
void Message::move_Folders(Message *m) {
folders = std::move(m->folders); //使用移动操作,移动赋值
for (auto f : folders) {
f->remMsg(m); //移除旧的
f->addMsg(this); //添加新的
}
m->folders.clear(); //确保销毁是无害的。删除所有的元素。
}
// 移动构造函数。
// 这里将m置为右值,我们只能对他赋值或者销毁,对于它的内容,我们不可以进行期望。
// 既对Message进行移动,也对它的成员进行移动。
// m依然还是一个变量。
Message::Message(Message &&m) : contents(std::move(m.contents)) {
move_Folders(&m); //移动folders并更新指针。
}
// 移动赋值运算符
Message& Message::operator=(const Message &&m) {
// 由于是移动,必须检查是不是自己对自己赋值,否则将是混乱的。比其拷贝复杂
// 虽然一个是右值,但是等号依然成立
// if (*this != m) { //这样应该也可以。
if (this != m) {
remove_form_folders(); //将自身的指针移除
contents = std::move(m.contents);//移动
move_Folders(&m);
}
return *this;
}
}
11).移动迭代器
- 支持普通迭代器的所有操作。
- 不同之处在于移动迭代器解引用返回的是一个右值,而普通迭代器返回的是一个左值。
- 使用
initialized_copy
进行reallocate
操作。问题,它只能进行拷贝。而不能是移动。 - 引入移动迭代器,达到“假的拷贝”,“真的移动”的目的。使用标准库函数,
make_move_iterator
函数,将普通的迭代器转换为移动迭代器。
{
void StrVec::recallocate() {
auto newCapacity = size() ? size() * 2 : 1;
auto first = alloc.allocate(newCapacity);
// 看似是copy但是实际上是移动。
auto last = uninitialized_copy(make_move_iterator(begin()),
make_move_iterator(end()),
first);
free(); //销毁原来的空间,
//它会将元素都进行清空,保证销毁时不会误伤。
elements = first;
first_free = last;
cap = first + newCapacity;
}
}
uninitialized_copy
本质上也是对每一个元素调用construct
进行构造。并且,此算法是对**迭代器进行解引用,**所以实际上右值,所以construct
实际上就是用移动构造函数来进行构造元素。是对原来的for
循环的一个简化写法,而工作的实质是一样的。- 标准库并不保证移动迭代器使用于哪些算法,不适用于哪些算法。虽然可以传递给算法,但是由于它的特殊性,所以不一定适用。
练习,
- 13.51,虽然
unique_ptr
是不可以拷贝的,但是当一个将要被销毁的unique_ptr
是可以拷贝的。例如函数返回一个局部的unique_ptr
,此时的“拷贝”,其实是调用了移动构造函数进行了移动。 - 13.53,注意对原对象进行移动,是源对象的内容就变成不可知。
- 13.54,注意二义性的错误。例如我们既定义了移动赋值,又定义了拷贝交换
(HasPtr)
。
//3.右值引用和成员函数
1).形参为以下两种形式的成员函数。
const C &
,接受任意类型的版本。C &&
,接受非const
版本的右值。(精确匹配)- 没有
const C &&;
因为我们不会去接受一个const
的右值。是否是因为它无法改变,因为当我们使用右值时,意味着源对象的改变。 - 没有
C &;
因为当我们使用左值时,我们执行拷贝,并不会修改源对象。 construct
会根据传递给他的第二个及其以后的实参进行判断使用哪一个构造函数。由于是右值,所以使用的是移动构造函数。
{
class StrVec {
public:
void push_back(const string&);
void push_back(string &&);
};
void StrVec::push_back(const string &s) {
chk_n_alloc(); //确保有足够的容量
alloc.construct(first++,s);
}
void StrVec::push_back(string &&s) {
chK_n_alloc();
alloc.construct(first_free++,std::move(s)); //再一次进行移动。
}
}
- 使用什么构造函数。
{
StrVec vec;
string s = "this is some string";
vec.push_back(s); //左值,调用const string &
vec.push_back("const &&");//右值
}
2).对象调用函数,对象是左值还是右值并没有限定。
- 由于旧版本我们没有办法阻止这样的使用方式,新版本向后兼容。允许向右值赋值。
{
string s = "a value";
string t = " another value";
s + t = "woe"; //甚至可以对一个右值赋值,两个string的连接结果。
// 对两个string的连接调用函数。
auto p = (s + t).find('a');
}
3).引用限定符号。解决可以向右值赋值的问题。强制调用的对象是一个左值或者是一个右值。
- 两种形式,
&
,指定this
是一个左值;&&
,指定this
是一个右值。 - 位置,在
const
之前。 - 类似于,
const
声明限定符号,引用限定符号只能用于(非static
)成员函数。而且必须同时出现在函数的声明和定义中。 - 依据引用限定符号也可以进行重载。甚至可以综合引用限定符号和
const
限定符号。 - 使用例子。(实现返回排序结果,但是不改变源(左值时候)。)
{
class Foo {
public:
Foo sorted() &&;
Foo sorted() const &;
private:
vector<string> data;
};
// 对于右值,可以直接进行排序,应为传入的参数将要销毁。
Foo Foo::sorted() && {
sort(data.begin().data.end());
return *this;
}
// 对于左值,必须拷贝一个副本,避免对副本进行了修改。
Foo Foo::sorted() const & {
Foo ret(*this); //拷贝一个副本
sort(ret.data.begin().ret.data>end()); //对副本进行sort
return ret;
}
}
- 调用时。
{
Foo a();
Foo& b();
a.sorted();//使用的时&&版本
b.sorted();//使用的是const &版本。
}
- 注意,每一个成员函数的都带有引用限定符,或者都不带有引用限定符号。而对于
const
本来就可以依据是否有const
进行重载。这是不一样的地方。
{
class Foo {
public:
Foo sorted() &&;
Foo sorted() const;//错误没有带上引用限定符号。
};
}
练习
- 13.56,
return ret.sorted();
函数改为这样,那么会陷入死循环。因为一直需要,而左值调用一直是如此。 - 13.57,
return Foo(*this).sorted();
合法的,会调用右值的版本。因为Foo(*this);
会被当成是右值。
/7.小节
1).合成的版本。就是逐一地(非static
的。)构造,移动,拷贝,赋值,销毁等。
2).**copy and swap
。**在赋值运算时,先对右侧拷贝,再交换副本和左侧对象。避免自赋值的错误。