C++ Primer第十三章①
C++ Primer
我们已经学过类的一些基本的东西,接下来很长一段时间,应该是到这学期末,我们都在学类的一些知识,因为内容实在太多了,我争取在寒假之前把类的这些东西更新掉。
拷贝控制
我们已经学过,每个类都定义了一个新类型和在此类型对象上可执行的操作,比如我们可以定义构造函数,用来控制在创建此类型对象时做什么。
在这一章中,我们要介绍一些函数来控制类的行为:
这一章介绍的类的行为有:拷贝、赋值、移动、销毁
手段(特殊的成员函数)有:拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符、析构函数
以上这些都被称为拷贝控制。
特殊的成员函数 | 控制类的行为 |
---|---|
拷贝和移动构造函数 | 用同类型的另一个对象初始化本对象时做什么(class a(b)) |
拷贝和移动赋值运算符 | 将一个对象赋予同类型的另一个对象时做什么(class a = b) |
析构函数 | 此类型对象销毁时做什么 |
当然了,这些也可以不用我们亲自动手,当我们定义的类没有定义这些特殊的成员函数时,编译器会为我们生成它们,但是编译器生成的那些函数的行为不一定是我们想要的,这点和构造函数一样,所以啊,我们要认识到什么时候需要自己去定义这些操作,这往往也是实现拷贝控制最难的地方。
拷贝、赋值与销毁
我们先来介绍最基本的操作-拷贝构造函数、拷贝赋值运算符和析构函数(移动操作是C++11引入的,放在后面介绍)
拷贝构造函数
拷贝构造函数:
- 构造函数
- 第一个参数是自身类类型的引用
- 任何额外参数都有默认值
class Foo { public: Foo(); // 构造函数 Foo(const Foo&); //拷贝构造函数,参数最好是const且不要是explicit的,方便大家用 };
合成拷贝构造函数
不管我们有没有定义拷贝构造函数,编译器都会好心地帮我们合成一个拷贝构造函数。
一般来说:合成的拷贝拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定的对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:
- 类类型成员:调用拷贝构造函数来拷贝
- 内置类型成员:直接拷贝
- 数组:逐个元素来拷贝一个数组 我们来用老朋友Sales_data作为例子看看:
class Sales_data { public: Sales_data(const Sales_data)&; private: string bookNo; int units_sold = 0; double revenue = 0; }; Sales_data::Sales_data(const Sales_data &orig) : bookNo(orig.bookNo), units_sold(orig.units_sold), revenue(orig.revenue) {}
拷贝初始化和直接初始化的区别
string dots(10, 's'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
- 直接初始化:普通的函数匹配
- 拷贝初始化:拷贝构造函数(或之后要介绍的移动构造函数)
拷贝初始化除了在定义变量时用=会发生外,还有哪些情况呢(其实跟值传递类似):
- 实参传递给非引用形参
- 返回类型为非引用
- 用花括号列表初始化数组或聚合类(自己回顾聚合类定义) 其实之前我们在用一些容器的函数时就涉及到这方面的知识了,只不过那会还不方便提,我们在调用insert或push时,进行的是拷贝初始化;用emplace时用直接初始化
为什么拷贝构造函数的参数必须是引用类型
这里的逻辑有点绕,看好了: 假如我们的拷贝构造函数参数是值传递的,我们为了调用它,就要拷贝这个类的对象,怎么拷贝这个对象呢,通过调用拷贝构造函数,这就尴尬了,回到前面去了。。。
拷贝初始化函数是explicit带来的影响
我们以vector<int>为例,vector接受单一大小参数的构造函数是explicit的:
vector<int> v1(10); //正确:直接初始化,10个0
vector<int> v2 = 10; //错误:无法隐式转换
//函数参数
void f(vector<int>); //正确
f(10); //错误
f(vector<int>(10)); //正确
所以我们在拷贝构造函数中还是尽量不用explicit吧,允许隐式转换,爱怎么调用就怎么调用。
编译器可以绕过拷贝构造函数
string a = "aa"; //这是调用拷贝构造函数
string b("aa"); //这样就略过了拷贝构造函数,直接初始化了
其实我觉得就是构造函数替代了拷贝构造函数。
拷贝赋值运算符
拷贝构造函数是在定义时用=,拷贝赋值是在赋值时:
int a = 0; //拷贝构造函数
int b;
b = a; //拷贝赋值运算符
如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。(编译器很实诚啊)
在介绍之前,我们先要了解一个概念:
重载运算符
重载运算符就是函数,只不过函数名是operator关键字后面接表示要定义的运算符的符号组成,所以啊,赋值运算符就是operator=
class Foo
{
public:
Foo& operator=(const Foo&); //赋值运算符,通常返回左侧对象的引用,为了可以连着调用成员函数吧
};
合成拷贝赋值运算符
我们来直接举例吧:
Sales_data& Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo;
units_sold = rhs.units_sold;
revenue = rhs.revenue;
return *this; //返回此对象的引用
}
值得注意的是:无论是拷贝构造函数还是拷贝赋值运算符,它们大多数是拷贝的作用,但有些情况也会用来禁止该对象的拷贝或赋值,很神奇吧,后面会有介绍。其实也不算神奇,反正操作都是自己定义,爱怎么来怎么来罢了。
析构函数
析构函数执行与构造函数相反的操作:
- 构造函数初始化对象的非static数据成员
- 析构函数释放对象使用的资源,并销毁对象的非static成员
析构函数是类的一个成员函数,名字是由波浪线加类名构成,没有返回值,没有参数:
class Foo
{
public:
~Foo(); //析构函数,因为它不接受参数,所以不能被重载,一个类只有一个析构函数
};
析构函数完成什么工作
- 构造函数中:成员的初始化是在函数体执行之前完成的,顺序是类中出现的顺序初始化
- 析构函数:首先执行函数体,然后销毁成员,按初始化顺序来逆序销毁
隐式销毁一个内置指针类型的成员不会delete它所指向的对象(普通指针没有析构函数呀),但智能指针是类类型,是有析构函数的,所以智能指针在析构阶段会被自动销毁。
什么时候会调用析构函数
无论何时一个对象被销毁,就会调用其析构函数:
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器被销毁时,其元素被销毁
- 动态分配的对象,当指向它的指针被delete时,被销毁
- 对于临时对象,当创建它的完整表达式结束时被销毁。 对于这些我们中国的祖先就很能理解,就是过河拆桥,飞鸟尽良弓藏,狡兔死走狗烹嘛。
因为析构函数自动运行,我们的程序可以按需分配资源,不用去担心什么时候释放它们,我们的机制可以为我们保证完成这个析构的任务:
{
Sales_data *p = new Sales_data; //p是普通指针
auto p2 = make_shared<Sales_data>(); //p2是shared_ptr
Sales_data item(*p); //拷贝构造函数将*p拷贝到item中
vector<Sales_data> vec;
vec.push_back(*p2); //拷贝p2指向的对象
delete p; //对p指向的对象执行析构函数
}
//退出局部作用域:item、p2和vec调用析构函数;p2被销毁后,其对象引用计数为0,
//对象被释放;销毁了vec,就销毁了它的元素
上面的代码比较复杂,但是仔细看会发现,我们只要管自己new出来的p就好了,其他的析构都不用自己操心。你要是不用new的话,你什么也不用管。
合成析构函数
老样子,未定义的话,编译器会生成:
class Sales_data
{
public:
~Sales_data() {} //成员会被自动销毁,所以不需要做任何事情。。。
}
bookNo是string,会调用string的析构函数,释放bookNo所用的内存。
析构函数其实名不符实: 析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
讲人话就是,销毁人家自己会进行,你要是想另外加点什么操作,就在析构函数体里面加。