🎉作者简介:👓 博主在读机器人研究生,目前研一。对计算机后端感兴趣,喜欢 c + + , g o , p y t h o n , 目前熟悉 c + + , g o 语言,数据库,网络编程,了解分布式等相关内容 \textcolor{orange}{博主在读机器人研究生,目前研一。对计算机后端感兴趣,喜欢c++,go,python,目前熟悉c++,go语言,数据库,网络编程,了解分布式等相关内容} 博主在读机器人研究生,目前研一。对计算机后端感兴趣,喜欢c++,go,python,目前熟悉c++,go语言,数据库,网络编程,了解分布式等相关内容
📃 个人主页: \textcolor{gray}{个人主页:} 个人主页: 小呆鸟_coding
🔎 支持 : \textcolor{gray}{支持:} 支持: 如果觉得博主的文章还不错或者您用得到的话,可以免费的关注一下博主,如果三连收藏支持就更好啦 \textcolor{green}{如果觉得博主的文章还不错或者您用得到的话,可以免费的关注一下博主,如果三连收藏支持就更好啦} 如果觉得博主的文章还不错或者您用得到的话,可以免费的关注一下博主,如果三连收藏支持就更好啦👍 就是给予我最大的支持! \textcolor{green}{就是给予我最大的支持!} 就是给予我最大的支持!🎁
💛本文摘要💛
本专栏主要是对c++ primer这本圣经的总结,以及每章的相关笔记。目前正在复习这本书。同时希望能够帮助大家一起,学完这本书。 本文主要讲解第13章 拷贝控制 文章目录
c++ primer 第五版 系列文章:可面试可复习
第2章 变量和基本类型
第3章 字符串、向量和数组
第4章 表达式
第5章 语句
第6章 函数
第8章 IO库
第9章 顺序容器
第10章 泛型算法
第11章 关联容器
第12章 动态内存
第13章 拷贝控制
第 14章 重载运算符
第15章 面向对象程序设计
第 16章 模板与泛型编程
🌟拷贝控制
- 当定义一个类时,显式地或者隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么,拷贝控制成员控制类的对象在拷贝、赋值、移动、销毁时做什么
- 5种拷贝控制函数
- 拷贝构造函数:
定义了当用同类型的对象初始化另一个对象时做什么
- 拷贝赋值运算符:
定义了当将同类型的一个对象赋予另一个对象时做什么
- 移动构造函数:同拷贝构造函数
- 移动赋值运算符:同拷贝赋值运算符
- 析构函数:定义对象销毁时做什么
- 拷贝构造函数:
拷贝控制成员是类的必要部分,如果没有显式定义,编译器会自动为其隐式地定义。
难点:认识到什么时候需要定义这些操作
💗13.1 拷贝、赋值与销毁
💤13.1.1 拷贝构造函数
- 如果一个构造函数的
第一个参数是自身类类型的引用,且其他参数都有默认值
,则此构造函数为拷贝构造函数。
- 拷贝构造函数的第一个参数
必须是引用,且一般是 const 引用
。(如果不使用引用会导致无限循环,因为传递实参本身就是拷贝) 拷贝构造函数通常不是 explicit 的。
class B{
public:
B(){cout << "默认构造"<< endl;};
B(const B & b) {cout << "拷贝构造"<< endl;};
};
B f(B a) {return a;};
int main()
{
B b; //默认构造
B a(b); //显式调用拷贝
B c = b; //赋值运算是拷贝
f(b); //传递参数与return 都是拷贝,所以调用2次拷贝
return 0;
}
合成拷贝构造函数
如果没有为一个类定义拷贝构造函数,则编译器会定义一个合成拷贝构造函数。
对于某些类,合成拷贝构造函数用来禁止该类型对象的拷贝(通过 =delete)
一般合成拷贝构造函数会逐个拷贝类的每个成员。
- 与合成默认构造函数不同,即使定义了其他的构造函数,编译器也会合成一个拷贝构造函数
直接初始化和拷贝初始化区别
string dots(10,'.'); //直接初始化
string s1(dots); // 直接初始化,选择匹配的构造函数来初始化 s
string s2 = dots; // 拷贝初始化,使用拷贝构造函数或移动构造函数来完成。
理解
拷贝构造函数一般要求编译器将右侧运算对象拷贝到正在创建的对象中。拷贝初始化发生于那些没有显式调用构造函数却生成了类的对象的场合,比如使用 = 初始化一个对象。
- 显式调用构造函数的场合都是直接初始化
拷贝初始化发生情况
以 = 定义变量
将一个对象作为实参传递给非引用形参
从一个返回类型为非引用类型的函数返回一个对象
用花括号列表初始化一个数组中的元素或一个聚合类中的成员
某些类类型会为它们所分配的对象进行拷贝初始化。
比如标准库容器初始化或调用 insert 和 push 成员时,会对其元素进行拷贝初始化(emplace 则是直接初始化)。
💤13.1.2 拷贝赋值运算符
- 如果类未定义自己的拷贝赋值运算符,编译器会自动合成一个。
- 拷贝赋值与拷贝构造区别是拷贝赋值是左右都存在,只不过用右边的值赋值给左边的,而拷贝构造是左边的值不存在,右边的值存在,将右边的进行构造得到左边
重载赋值运算符
重载运算符本质上也是函数
。重载赋值运算符必须定义为成员函数
。如果一个运算符是成员函数,其左侧运算对象自动绑定到隐式的 this 参数。
- 对于二元运算符,例如赋值运算符,其右侧运算对象作为显示参数传递
拷贝赋值运算符接受一个与其所在类同类型的参数
class Foo{
public:
Foo& operator=(const Foo &); // 重载的赋值运算符通常返回一个指向其左侧运算对象的引用
}
重载的赋值运算符通常返回一个指向其左侧运算对象(也就是自身)的引用,赋值操作会在函数体内完成。
理解: while(a=2) 的含义:a=2 返回了 a 的引用,值为 2,条件为真.
合成拷贝赋值运算符
- 如果没有定义拷贝赋值运算符,编译器会生成一个合成拷贝赋值运算符。
对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值(通过=delete)
- 合成拷贝运算符会将右侧运算对象的每个非 static 成员赋予左侧对象的相应成员,这一工作通过成员类型自己的拷贝赋值运算符来完成。
💤1.3.1.3 析构函数
- 只要一个对象被销毁,就会执行其析构函数。
- 没有返回值,也不接受参数。
- 因为析构不接受参数,所以不能被重载,一个类只能有一个析构函数。不同于构造函数。
构造函数和析构函数的区别:
- 构造函数包括一个初始化部分和一个函数体。先执行初始化部分再执行函数体。初始化顺序按它们在类中出现的顺序进行初始化。
析构函数包括一个函数体和一个隐式的析构部分。先执行函数体,然后执行析构部分销毁成员。成员按初始化的顺序逆序销毁。
- 销毁成员时发生什么依赖成员自己的类型。
如果是类类型的成员,需要执行成员自己的析构函数。
注意析构函数体自身并不直接销毁成员,成员是在析构函数体之后的隐含的析构阶段中被销毁的。
销毁指针
- 内置指针:
隐式地销毁一个内置指针类型的成员不会 delete 它所指向的对象。
- 智能指针:
智能指针是类类型,具有析构函数。智能指针的析构函数会递减对象的引用计数,如果计数变为 0,则销毁对象并释放内存。
调用时机:
- 变量在离开其作用域时。
- 当一个对象被销毁时,其成员被销毁。
- 容器被销毁时,其元素被销毁。
- 动态分配的对象,当对指向它的指针应用delete运算符时。
- 对于临时对象,当创建它的完整表达式结束时。
合成析构函数
- 当未定义析构函数时,编译器会定义一个合成析构函数。
- 类似拷贝和赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁,如果不是这种情况合成析构函数的函数体为空
析构函数体自身并不直接销毁成员,成员是在析构函数体之后的隐含的析构阶段中被销毁的。
对于非静态的成员函数回收是自动的,所以函数体中是空的,不需要写任何东西,需要写的是在执行过程中创建的动态的内存
一个例子
如何定义一个类,这个类可以为每个对象生成一个唯一的序号?
方法:使用一个 static 成员,然后在构造函数中对它递增并基于递增后的 static 成员构造序号。
注意:要在所有的构造函数及拷贝赋值运算符中都对它进行递增(下面的例子中仅列出了默认构造函数)。
class numbered {
public:
numbered() { mysn = unique++; }
int mysn;
static int unique;
};
int numbered::unique = 10;
💤13.1.4 三/五法则
- 有三个操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符、析构函数。
- 此外拷贝控制成员还有移动构造函数、移动赋值运算符。
- 这些操作通常应该被看做一个整体,
如果定义了其中一个操作,一般也需要定义其他操作。
确定类是否需要定义自己的拷贝控制函数,有俩条原则
判断它是否需要一个析构函数。如果它需要自定义一个析构函数,几乎可以肯定它也需要一个拷贝构造函数和一个拷贝复制运算符。
如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然。但是需要拷贝构造函数不意味着一定需要析构函数。
需要析构函数的类也需要拷贝和赋值操作,反之亦然
- 注意的三个地方
需要拷贝操作的类也需要赋值操作,反之亦然,无论需要拷贝构造还是需要拷贝赋值的运算符不一定需要析构函数
当需要定义析构函数,一般意味着在类内有指向动态内存的指针成员。因为合成析构函数只会销毁指针成员而不会 delete,所以需要定义析构函数。
这种情况下,如果使用合成的拷贝和赋值操作,它们会直接复制该指针,这就导致可能有多个指针指向相同的一块动态内存,当有一个类对象执行了析构函数,该内存就会被释放,其他指针就变成了悬空指针。所以需要定义拷贝和复制操作。
💤13.1.5 使用=default
- 可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。
- 合成的函数将隐式地声明为内联的。如果default定义在结构体外面,则为非内联
只能对默认构造函数或拷贝构造成员这些具有合成版本的函数使用 =default。
class Student{
public:
Student(const Student&) = default; // 不用加函数体。在参数列表后加一个 =default 即可
Student& operator(const Student &); //内联
~Student() = default;
};
Student& Student::operator=(const Student&) = default //非内联
💤13.1.6 阻止拷贝
- 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
- 删除的函数的性质:
虽然声明了它们,但是不能以任何方式使用它们。
- 如果一个类有数据成员不能默认构造、拷贝、复制或者销毁,则对应的成员函数将被定义为删除的。
定义删除的函数:=delete。
有一些例外需要阻止类进行拷贝或赋值。如 iostream 类、unique_ptr 等。
阻止拷贝的方式是将其定义为删除的函数
析构函数不能是删除的成员。
default与delete区别
- 对于任何成员可以使用delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用default)
- delete必须出现在函数第一次声明的时候
析构函数不能定义为删除的成员
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针,如果析构函数被删除,就无法销毁此类型的对象了。
合成拷贝控制成员可能是删除的
private拷贝控制
- 新标准之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为 private 来组织拷贝。但是友元和成员函数依然可以拷贝对象,
因此将拷贝控制成员声明为private但是不定义他们只声明
class PrivateCopy {
//无访问说明符,接下来的成员默认Wieprivate的
PrivateCopy(const PrivateCopy&); //拷贝控制成员是 private 的,因此普通用户代码无法访问
PrivateCopy &operator=(const PrivateCopy&);
public:
PrivateCopy() = default; //使用合成默认构造函数
~PrivateCopy(); //用户可以定义此类型对象,但无法拷贝他们
}
将拷贝控制成员定义为 private 可以阻止普通用户拷贝对象,但是无法阻止友元和成员函数拷贝对象,为此还要注意:只能声明不能定义这些拷贝控制成员。
- 声明但不定义一个函数是合法的,试图访问一个未定义的成员将导致一个链接时错误。
理解:在此情况下,普通用户调用拷贝控制成员将引发编译时错误,友元和成员函数调用拷贝控制成员将引发链接时错误。
💗13.2 拷贝控制和资源管理
通常管理类外资源的类都需要定义拷贝控制成员,因为它们需要定义析构函数来释放对象所分配的资源,一个类一旦需要析构函数,那么它几乎肯定需要一个拷贝构造函数和一个拷贝赋值函数
- 通过定义不同的拷贝操作可以实现两种效果:
行为像值
:对象有自己的状态,副本和原对象是完全独立的。如 strnig 看起来像值行为像指针
:共享状态,拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。如shared_ptr类提供类似指针的行为
还有一些其他的类,如 IO 类型和 unique_ptr 不允许拷贝和赋值,所以它们的行为既不像值也不像指针。
💤13.2.1 行为像值的类
行为像值的类中,对于类管理的资源,每个对于都应该有一份自己的拷贝。
- 为了实现类值行为,HasPtr需要
定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
定义一个析构函数来释放string
定义一个拷贝赋值运算符来释放对象当前的string(也就是销毁左侧运算对象),并从右侧对象拷贝string
注意:这些操作要以正确的顺序执行,即使将一个对象赋予它自身,也保证正确。
对于拷贝构造函数来说,传值,而不是传指针
HasPtr(const std::string& s = std::string()) : ps(new std::string(s)), i(0) {} // 构造函数
'使类的行为像值一样,传递解引用的值'
HasPtr(const HasPtr& p) : ps(new std::string(*p.ps)), i(p.i) {} // 拷贝构造函数
对于拷贝赋值运算符来说,一般将右侧运算对象拷贝到一个局部临时对象中,以保证将对象赋予自身也能正确工作
理解
:拷贝赋值运算符,本来左侧对象就是存在的指向一个内存地址,首先需要断开,然后使得它指向新的内存地址(顺序很重要)
'正确顺序'
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放旧内存
ps = newp; //从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this //返回本对象
}
'错误顺序'
'如果是a = a这种情况,那么内存先被释放了,就无法拷贝了'
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
delete ps; //释放旧内存
auto newp = new string(*rhs.ps); //拷贝底层string
ps = newp; //从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this //返回本对象
}
综上
-
- 首先需要对等号右边的进行拷贝,此时有一个指向了
-
- 其次把this指针指向的删除
-
- 最后将this指针指向新拷贝的内存
- 最后将this指针指向新拷贝的内存
💤13.2.2 定义行为像指针的类
- 对于行为类似指针的类,需要定义拷贝构造函数和拷贝赋值运算符来拷贝指针成员本身而不是它指向的值。
还需要析构函数来释放分配的内存,但是注意不能简单地直接释放关联的内存,应确保最后一个指向该内存的指针也销毁掉后才释放内存。
- 令一个类行为像指针的最好方法是使用 shared_ptr 来管理类内的资源。
引用计数
但是有时候我们想直接管理资源,不使用shared_ptr
,于是使用引用计数
引用计数工作方式
除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当创建一个对象时,计数器初始化为 1。
- 拷贝构造函数不创建新的引用计数,
而是拷贝对象的计数器并递增它。
- 析构函数递减计数器,如果计数器变为 0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0 就销毁状态。
难点:在哪里存放引用计数。计数器不能直接作为HasPtr对象的成员
计数器应该保存在动态内存中,当拷贝或赋值对象时,拷贝指向计数器的指针。这样使得副本和原对象都会指向相同的计数器
例子
class HasPtr {
public:
HasPtr(const std::string& s = new std::string()): ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
HasPtr(const HasPtr& p): ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr& rhs) {
++*rhs.use; // 递增右侧运算对象的引用计数
if(--*use == 0) { // 递减本对象的引用计数
delete ps; // 如果没有其他用户,释放本对象的资源。
delete use;
}
ps = rhs.ps; // 将数据从 rhs 拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
~HasPtr() { if(--*use == 0) { delete ps; delete use; } }
private:
std::string* ps;
int i;
std::size_t* use; // 引用计数器
}
HasPtr::~HasPtr()
{
if(--*use == 0) //如果引用计数变为0
{
delete ps; //释放string内存
delete use; //释放计数器内存
}
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; //递增右侧运算对象的引用计数
if (--*use ==0) //然后递减本对象的引用计数
{
delete ps;
delete use;
}
ps =rhs.ps;
i = rhs.i;
use = rhs.use;
return *this
}
💗13.3 交换操作
除了定义拷贝控制成员,管理资源的类通常还定义一个 swap 函数。
- 那些重排元素顺序的算法在重排元素时,
需要使用 swap 来交换元素的位置。因此被重排的元素类型是类时,为类定义自己的 swap 函数非常重要。
- 如果一个类定义了自己的 swap,算法将使用自定义版本,否则算法将使用标准库定义的 swap。
错误写法
HasPtr temp = v1; //创建v1的值的一个临时副本
v1 = v2; //将v2的值赋予v1
v2 = temp; //将保存的v1的值赋予v2
将原来的v1中的string拷贝俩次:第一次HasPtr的拷贝构造函数将v1拷贝给temp,第二次是赋值运算符将temp赋予v2,在把v2赋予v1,还拷贝了原来v2的sting,多次拷贝,实属不必
正确写法(交换指针)
与拷贝控制成员不同,swap 不是必要的,但是对于分配了资源的类,定义 swap 有时是一种很重要的优化手段。
'类似交换指针'
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
class HasPtr {
friend void swap(HasPtr& HasPtr);
// 其他成员定义与 13.2.1 中定义的 HasPtr 一样。
}
inline void swap(HasPtr& lhs, HasPtr& rhs)
{
using std::swap; // 使用此声明而非直接通过 std::swap 调用。因为这样的话,如果某个类成员定义了自己版本的 swap,对其进行 swap 时会优先使用自定义版本。
swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
swap(lhs.i, rhs, i);
}
swap 函数应该调用 swap,而不是 std::swap
- swap 函数用来交换两个类的值,因此实现 swap 函数时要交换类的所有数据成员的值,这也通过 swap 来实现。
而类的成员可能是另一个类类型,这时为了保证调用的是该类自定义的 swap 版本,应该使用 swap,而不是 std::swap。
- 直接调用
std::swap
是正确的,但是性能低。所以应该先调用自己写的swap
,然后如果自己没有写,在调用标准库中的swap
理解:使用 using std::swap 的目的是保证当某个成员没有自定义的 swap 版本时,能够执行标准库版本。
在赋值运算符中使用swap
定义了 swap 的类通常用 swap 来定义赋值运算符,注意这时参数要使用值传递而非引用传递。
- 使用了值传递和 swap 的赋值运算符自动就是异常安全的,并且能够正确处理自赋值。
💗13.4 拷贝控制示例
- 需要自定义拷贝控制成员的类通常是分配了资源的类,但这不是唯一原因。一些类也需要拷贝控制成员来帮助进行薄记工作或其他操作。
理解:所谓薄记工作的应用场景是有两个或两个以上的类,且当创建、复制或销毁其中某个类的对象时,需要更新另一个类的对象的值。
例子
- Message 和 Folder俩个类,分别表示消息和目录
- 一个消息可能出现在多个目录中,一个目录可能包含多个消息。但是任何消息都只有一个副本。
Folder需要删除和保存Message,使用函数addMsg和reMsg(save 和 remove)
每个Folder包含很多Message,每个Message里面维护一个Folders(是一个Folder列表)
Message类
- 对于拷贝构造函数需要
add_to_folders
- 对于拷贝赋值运算符既需要
add_to_folders
也需要remove_from_folders
(对于拷贝赋值左边和右边不一样) - 对于析构函数需要
remove_from_floders
void swap(Message &lhs, Message &rhs);
class Message {
friend class Folder;
friend void swap(Message &lhs, Message &rhs); // 要将 swap 定义为友元
public:
//folder被隐式的初始化为空集合
explicit Message(const std::string &str = "") : contents_(str) {}
//拷贝控制成员,用来管理指向本Message的指针
Message(const Message &msg) : contents_(msg.contents_), folders_(msg.folders_) //拷贝构造函数
{
add_to_Folders(msg);
}
Message &operator=(const Message &rhs) //拷贝赋值运算符
{
//通过先删除指针在插入它们来处理自赋值的情况(a = a与上面将的拷贝一样,先加在在删除就没了),对于a = b,a本身存在,先把a维护的folders拿出来,把a的记录清楚掉,然后更新成b的folders,最后把a的folders加到b维护的folders中
remove_from_Folders(); //更新已有的folder
contents_ = rhs.contents_; // 从rhs拷贝消息内容
folders_ = rhs.folders_; //从rhs拷贝folder指针
add_to_Folders(*this); //将本Message添加到那些folder中
return *this;
}
~Message() //析构函数
{
remove_from_Folders();
}
//从给定Folder集合中添加/删除本Message
void save(Folder &folder)
{ //将message放到某一个folder中,它维护的folders需要增加一条记录,同样相应的folder也需要加入一条记录
folders_.insert(&folder); //将给定folder添加到我们的floder列表中
folder.messages_.insert(this); //将本message添加到f的message集合中
}
//需要移除某个消息,则需要移动俩个地方,1.从folders集合中移除。2.从folder当中移除
void remove(Folder &folder)
{
folders_.erase(&folder); //将给定folder从我们的folder列表中删除
folder.messages_.erase(this); //将本message从f的message集合中删除
}
private:
std::string contents_; //实际消息文本
std::set<Folder *> folders_; //包含本Message的Folder(message包含在多个folder中)
//Folders是一个folder列表,当添加一个message,则会在多个目录下添加相应消息
//在拷贝构造函数、拷贝赋值运算符和析构函数所使用的工具函数
void add_to_Folders(const Message &msg)
{
for (auto folder : msg.folders_)
folder->addMsg(this);
}
//从folders中的每个folder中删除message
void remove_from_Folders()
{
//需要删除message时,需要将folders列表的中的所有folder拿出来,一个一个的清理folder中所包含的message
for (auto folder : folders_)
folder->remMsg(this);
}
};
-
add_to_folders
是在message中添加folders
- 遍历folders列表得到多个folder,在每个folder中都添加message
-
remove_from_folders
是在Message 中删除folders
- 遍历folders列表得到多个folder,将每个folder中都删除message
-
save
是在folder中加入message,需要添加俩个地方1. folder中添加message
2. folders列表中添加folder
-
remove
是在folder中移除message,需要添加俩个地方1. folder中移除Message
2. folders列表中移除folder
对于拷贝赋值,只能先删在加,不能先加在删。而且删除时floders并没有删除,只是删除消息
Message类的swap函数
- 不能直接交换首先需要先把之前的删除,在进行交换。例如一个高富帅一个白富美,俩人身份交换后,能去的地方也发生了变化。
Folder类的定义
- 只有类的定义,函数的定义略。
class Folder {
friend void swap(Folder &, Folder &);
friend class Message;
public:
Folder() = default;
Folder(const Folder &);
Folder& operator=(const Folder &);
~Folder();
private:
std::set<Message*> msgs;
void add_to_Message(const Folder&);
void remove_from_Message();
void addMsg(Message *m) { msgs.insert(m); }
void remMsg(Message *m) { msgs.erase(m); }
};
void swap(Folder &, Folder &);
💗13.5 动态内存管理类
- 某些类需要在运行时分配可变大小的内存空间,这种类一般可以使用标准库容器来保存它们的数据,
但某些类需要自己进行内存分配,这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
- 本节实现了一个需要自己分配内存以进行动态内存管理的类:StrVec。这个类是标准库 vector 的简化版本,且只用于 string。
理解:区分动态内存管理类与分配资源的类。动态内存管理类主要特点是其所占用内存大小是动态变化的,而分配资源的类其特点是使用了堆内存。
StrVec 类的设计
StrVec的内存管理模仿vector
- 预先分配足够的内存
- vector的每一个添加元素的成员函数会检查是否有空间容纳更多元素,如果有就在可用位置构造一个对象,如果没有,vector就会重新分配空间
- 重新分配空间:获得新空间,将已有元素一到新空间,释放旧空间,并添加新元素
String 类的设计和实现
- String 类是一个标准库 string 类的简化版本,它有一个默认构造函数和一个接受 C 风格字符串指针的构造函数。并使用 allocator 来分配内存。
String.h
#include <memory>
//类vector类内存分配策略的简化实现
class String {
public:
//allocator成员进行默认初始化
String() : String("") {}
String(const char *);
String(const String &);
String &operator=(const String &);
~String();
const char *c_str() const { return elements; }
size_t size() const { return end - elements; }
size_t length() const { return end - elements - 1; }
private:
std::pair<char *, char *> alloc_n_copy(const char *, const char *);
void range_initializer(const char *, const char *);
void free();
private:
char *elements; // elements 指向字符串的首部
char *end; // end 指向字符串的尾后
std::allocator<char> alloc; // 定义了一个分配器成员
};
String.cpp
#include "String.h"
#include <algorithm>
#include <iostream>
std::pair<char*, char*>
String::alloc_n_copy(const char *b, const char *e) {
auto str = alloc.allocate(e - b);
return{ str, std::uninitialized_copy(b, e, str) };
}
void String::range_initializer(const char *first, const char *last) {
auto newstr = alloc_n_copy(first, last);
elements = newstr.first;
end = newstr.second;
}
String::String(const char *s) {
char *sl = const_cast<char*>(s);
while (*sl)
++sl;
range_initializer(s, ++sl);
}
String::String(const String& rhs) {
range_initializer(rhs.elements, rhs.end);
std::cout << "copy constructor" << std::endl;
}
void String::free() {
if (elements) {
std::for_each(elements, end, [this](char &c){ alloc.destroy(&c); });
alloc.deallocate(elements, end - elements);
}
}
String::~String() {
free();
}
String& String::operator = (const String &rhs) {
auto newstr = alloc_n_copy(rhs.elements, rhs.end);
free();
elements = newstr.first;
end = newstr.second;
std::cout << "copy-assignment" << std::endl;
return *this;
}
💗13.6 对象移动
- c++11新特征
移动对象
- 使用对象移动的三个常见原因:
IO 类或 unique_ptr 这样的类不能拷贝但可以移动。
- 对象拷贝后立刻被销毁了,而且对于那些对象本身要求分配空间大的,拷贝开销太大
- 移动而非拷贝对象对大幅度提升性能。
新标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝
💤13.6.1 右值引用
右值引用性质:
- 只能绑定到一个将要销毁的对象,且该对象没有其他用户。因此使用右值引用的代码可以自由接管所引用的对象的资源。
- 右值引用:必须绑定到右值的引用,通过 && 操作符来获得右值引用。
- 右值引用是为了支持移动操作而引入的。
区分左值/右值/左值引用/右值引用
左值
:左值和右值是表达式的属性,左值表达式表示的是一个对象的身份,左值持久状态右值
:右值表达式表示的是一个对象的值,右值要么是字面值常量,要么是表达式求值过程中创建的临时对象左值引用
:就是前面普通引用(只是起了一个别名,而且不能将其绑定到要求准换的表达式、字面值常量或是返回右值表达式右值引用
:右值引用和左值引用完全相反的绑定特性,可以将右值引用绑定到这类表达式上,但不能将一个右值引用绑定到一个左值上。
注意:const的左值也可以绑定到右值上
int i = 42; // i 是一个左值
int&& r = i; // 错误,不能将右值引用绑定到左值上
int &r2 = i * 2; // 错误,i * 2 是一个右值,不能将左值引用绑定到一个右值上。
const int& r3 = i * 2; // 正确,可以将一个 const 引用绑定到一个右值上
int&& r4 = i * 2; // 正确
函数返回的左/右值
- 返回左值的运算符:复制、下标、解引用、前置递增/递减运算符等都返回左值引用。
- 返回右值的运算符:算术、关系、位、后置递增/递减运算符等都返回右值。
返回非引用类型的函数返回的也是右值。
变量是左值
- 变量也可以看作是一个表达式,
一个变量表达式是一个左值。
- 即使一个变量的类型是一个右值引用,
但该变量本身也是一个左值
。因此不能将一个右值引用直接绑定到一个右值引用变量上。
int&& rr1 = 42; // 正确
int&& rr2 = rr1; // 错误,表达式 rr1 是左值。
标准库move函数
- 不能将一个右值引用直接绑定到一个左值上,
但我们可以显示地将一个左值准换为对应的右值引用类型
,但是可以通过标准库move函数实现
#include <utility>
int &&rr3 = srd::move(rr1); //正确
解释
- move调用告诉编译器:希望像右值一样处理一个左值
调用move后,可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值
- 使用 move 函数的代码应该是·
std::move
而非直接用 move,这可以避免名字冲突。
实例
int f();
vector<int> vi(100);
int&& r1 = f();
int& r2 = vi[0];
int& r3 = r1;
int&& r4 = vi[0] * f();
💤13.6.2 移动构造函数和移动赋值运算符
- 移动构造函数的第一个参数是该类类型的一个引用。
与拷贝构造不同的是,这个引用参数在引用构造函数中是一个右值引用。与拷贝构造函数一样,额外的参数都必须是默认实参
- 移动构造函数要确保移后源对象是可以直接销毁的。特别是:一旦完成资源的移动,源对象必须不再指向被移动的资源。
只是移动,并不发生拷贝,所以不需要分配任何新内存
noexcept
- 如果是拷贝构造,俩个容器,将原容器的值一个一个的拷贝到新容器,拷贝的过程中,原容器一直存在的。如果拷贝发生了异常,那么新容器会销毁,恢复到以前的状态
- 移动是一个一个移动下来,移动的过程中原有的容器里面已经发生了变化,没有办法回退,对于标准库它不接受,那我就用拷贝不用移动了,所以避免这种情况设置noexcept告诉编译器大胆用,不会出现异常
- 你使用移动构造函数,确保你对其他人不会产生影响
移动赋值运算符
合成移动操作
- 移动操作不会隐式的定义为删除的函数。但是可以要求显示地要求编译器生成default的。(
具体看书
) - 移动构造函数定义为删除的条件:
- 类成员定义了自己的拷贝构造函数且未定义移动构造函数
假定Y是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数
struct hasY{
hasY() = default;
hasY(hasY&&) = default;
Y mem; //hasY将有一个删除的移动构造函数
};
hasy hy,hy2 = stdf::move(hy); //错误:移动构造函数是删除的
定义了一个移动构造函数或者移动赋值运算符的类必须定义自己的拷贝操作。否则,这些成员默认地被定义为删除的
。因为如果一个类定义了一个移动构造函数或者一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会定义为删除的
移动右值,拷贝左值
- 第一个赋值:v2是一个左值,不能隐式的将一个右值引用绑定到一个左值,因此这个赋值语句使用拷贝赋值运算符
- 第二个赋值:v2是getVec调用结果。因此此表达式是一个右值。,俩个都可以行,但是但是调用拷贝赋值运算符需要进行一次到const的转换,而StrVec&&则是精确匹配。所以第二个使用移动赋值运算符
但如果没有移动构造函数,右值也会被拷贝
拷贝并交换赋值运算符和移动操作
Message类的移动操作
步骤
Message类可以使用string和set的移动操作来拷贝contents和folders成员的额外开销
除了移动folders成员外,还必须更新每一个指向原message的folder我们必须删除指向旧message的指针,并添加一个指向新message指针。
💤13.6.3 右值引用和成员函数
- 除了构造和赋值函数外,一个成员可以同时提供拷贝和移动版本。
- 这种允许移动成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——
一个版本接受指向const的左值引用,第二个版本接受一个指向非const的右值引用
class StrVex{
public:
void push_back(const std::string&); //拷贝
void push_bac(std::string &&); //移动
//其他成员定义,如前
};
void StrVec::push_back(const string&s)
{
chk_n_alloc(); //确保有空间容纳新元素
//在first_free指向的元素中构造s的一个副本
alloc.construct(forst_free, s);
}
void StrVec::push_back(string&&s)
{
chk_n_alloc(); //如果需要的话为StrVec宠幸分配内存
alloc.aonstruct(first_free, std::move(s));
}
StrVec vec; //空Strvec
string s = "some string";
vec.push_back(s); //拷贝,因为s是左值
vec.push_back("done") //移动,done是右值
右值和左值引用成员函数
- 通常在一个对象上调用成员函数,不管该对象是一个左值还是一个右值。
string s1 = "hello", s2 = "world";
auto n = (s1 + s2).find('a');
- 对于俩个右值连接的结果,可以进行赋值(这有点惊讶,因为违背了右值初衷,不可以对右值进行赋值)
s1 + s2 = "world";
- 新标准库允许对右值赋值,但是可以加引用限定符,来不允许右值进行赋值