几乎每个class都会有一个或者多个构造函数,一个析构函数,一个copy assignment函数,因此有必要加深理解
1.条款05:了解C++默默编写并调用哪些函数
如果你没有生成一下函数,那么C++会在需要的时候(被调用) 帮你自动生成这些函数:
- default构造函数
- copy构造函数
- default析构函数
- copy assignment函数
因此当你写下
class Empty{
};
等同于:
class Empty{
Empty(){...}
~Empty(){...}
Empty(const Empty &){...}
Empty operator=(const Empty &){...}
};
这些函数做了什么?
default构造函数和析构函数调用base class和非static成员的构造函数,至于copy构造函数和copy assignment函数编译器创造的版本只是单纯将来源对象的非static成员拷贝到目标对象
基于这些函数的做法, 有些时候会造成编译错误。
- 继承类的default构造函数和析构函数会试图寻找调用基类的没有无参的构造函数和析构函数,而当基类没有定义这些函数,就造成了编译错误
- 编译器生成的copy构造函数只做简单的拷贝,那么当类成员有const成员,那copy构造上实际上在试图更改const的成员的值,编译器也会拒绝这种操作
下面是一个例子:
class Cat{
public:
Cat();
private:
const tail;
};
Cat white_cat , blue_cat;
white_cat = blue_cat;
这个类没有定义拷贝构造函数,于是系统会自动生成,white_cat = blue_cat;实际上做了下面的事情:
white_cat.tail = blue_cat.tail;
这是不被C++所接受的,编译器会拒绝该操作。
总结:
编译器会暗自韦class创建default函数copy函数、copy assignment操作符以及析构符。
2.条款06:若不想使用编译器自动生成的函数,就该明确拒绝
第五条提到编译器会为我们自动生成一些函数,但有时候我们并不想要他们。设想一个场景,写一个class去描述一个人;
class Person{
};
这个class什么都没写,但是C++已经自动生成了条款05提到的四个函数。因此我们可以做以下操作。
Person xiaoming ,xiaohong;
xiaoming = xiaohong;
这个操作语法上没问题,但违反实际:每个Person都是独一无二的,怎么能拿来拷贝呢?
于是我们应该设法禁止这个拷贝,有几种方案可供考虑:
- 将copy构造函数和copy assignment函数设为私有并且不去定义他们:
class Person{
private:
Person(const Person &);
Person & operator=(const Person &);
};
那么用户企图拷贝Person编译器会报错,而当member函数或者friend函数企图这么做,连接器会报错。如果想把连接期错误移动到编译期(这是好事,越早发现错误越好),可以定义一个base class。
2. 专门设计一个base 类阻止拷贝
class Uncopyable{
protected:
Uncopyable();
~Uncopyable();
private:
Uncopyable(const Uncopyable &);
Uncopyable& operator = (const Uncopyable &);
};
为阻止Person对象的拷贝,只需要继承这个Uncopyable:
class Person : private Uncopyable{
···
};
当Person进行copy或者assignment,会调用Uncopyable的copy和assignment,而由于私有继承,Person已经无法调用Uncopyable的copy和assignment,因此编译就出错。关于继承关系不清楚,看一看下图
![[Pasted image 20240508153922.png]]
总结:
如果不想C++帮我们生成函数,需要明确拒绝。具体方式有两种,一种是声明成私有函数并且不实现,但当member函数或者friend函数调用时,在连接期才能发现错误。想要在编译期就发现,可以用另一种方式:定义一个Uncopyable基类,其函数是private的。然后让你的类去私有继承Uncopyable,你的类需要调用Uncopyable的函数,而由于私有继承,Uncopyable的private函数已经不能调用,因此在编译期间就会报错,顺便一提,Uncopyable的copy和assignment是不用实现的。
3.条款07:为多态基类声明vitual析构函数
在C++内使用多态技术的时候,一种常见的做法是使用base class指针指向derived类的对象,就像下面一样:
class Animal{
animal();
~animal();
};
class Cat(): public Animal{
Cat();
~Cat();
};
class Dog(): public Animal{
Dog();
~Dog();
};
Animal *animal = new Cat(); //指向一个Cat对象
delete animal; //删除animal指针,资源回收
这样的写法看上去没问题,但实际上是未定义的行为:当derived class对象经由base class的指针被删除,而base class带有non-vitual的析构函数,其结果未定义。实际上通常的结果是derived class的部分没被删除,而base class的部分会被消除。这就造成一种局部销毁的现象,这是造成资源泄漏的绝佳途径。
解决办法很简单,就是给base class一个vitual 析构函数,这样derived对象就会完全被销毁。
此外,依据经验,任何class只要带有vitual函数都几乎确定需要定义一个virtual 析构函数,因为含有virtual析构函数,意味着可能在该类上使用多态技术,因此需要把一个virtual的析构函数。
但是也不能无端声明virtual 析构函数,virtual函数声明会提升class的内存占用。更进一步,如果该类传入C语言中,因为内存占用增大,将导致失败。
还有一种情况,即把某些不含virtual函数的类当成base class:
class SpecialString:public std::string{
};
//当你做下面的操作将出现问题,因为string没有virtual的析构函数
std::string *ps;
ps = new SpecialString("hello");
delete ps;
所有的STL,都会出现这种情况,因为设计之初,这些STL就没有被当作base class。
总结:
- 多态性质的base class应该声明一个virtual析构函数,如果class带有任何virtual,他就应该拥有一个virtual虚函数
- 如果classes的设计目的不是为了作为base class使用,就不该声明virtual析构函数
4.条款08:别让异常逃离析构函数
C++的析构函数允许吐出异常,但不鼓励这么做。看以下代码:
class Animal{
public:
~Animal(){}
}
void doSomeThing(){
std::vector<Animal> animals;
... //animals在此处销毁
}
animals包含多个Animal对象,如果第一个析构函数抛出异常,animals还需要继续析构剩下的Animal对象,此时如再有异常,C++会不是停止运行就是导致不明确行为,不明确行为是通向大海捞bug的康庄大道。
但如果你的代码必须(头铁)在析构函数里执行一个操作并且可能抛出异常,该怎么办。
假设你有一个class负责数据连接:
class DBConnection {
...
public:
static DBConnection create(); //该函数返回DBConnection函数
void close(); //关闭联机,失败则抛出异常
};
为了确保用户调用close(),一个合理的想法是创建一个用于管理DBConnection的类:
class DBConn{
public:
...
~DBConn(){
db.close();
}
private:
DBConnection db;
};
用户会这样调用代码:
DBConn dbc(DBConnection::create());
上面的代码会调用DBConn()进而调用db.close();,一旦出现异常,DBConn()会传播该异常,即运行的报错信息不会告诉你问题出在了~DBConn();
解决的办法如下:
- 通过abort直接停止运行程序:
DBConn::~DBConn(){
try {db.close();}
catch (){
//记录失败的原因
std:abort();
}
}
这样做实际上实在close出错之后立即关停程序,能够定位错误的位置,而不是让析构函数把错误传播出去,在一个毫无关系的地方报错。
2. 吞下异常
DBConn::~DBConn(){
try {db.close();}
catch (){
//记录失败的原因
}
}
该方法强行让程序继续运行通常是个坏主意,因为他压制了某些因运行失败而抛出的信息。但总比出现不确定行为好。
3. 重新设计DBConn,比如单独写一个close函数。如果close出问题,就不必进入析构函数才发现
class DBConn{
public:
...
void close(){
db.close();
closed = true;
}
~DBConn(){
if(!closed){
try {db.close();}
catch (){
//记录失败的原因
}
}
}
private:
DBConnection db;
bool closed;
};
总结:
不要再析构函数里进行有可能会造成异常的操作,这是因为析构函数会传播这个异常,使得运行的报错信息看起里和析构函数完全没有关系(未定义的行为),难以排查问题。如果确实需要在析构函数里进行操作,有以下办法:
- 使用std::abort,在发生错误时直接关停程序,便于定位错误
- 使用try catch转运这个程序,即忽略错误强行运行。这是比较差的做法,因为他会掠过某些种的错误信息,增大改进程序的困难,但也总比出现未定义的行为强。
- 把可能发生异常的操作交给客户,以便客户在其他地方使用,及时发现错误,而不是等到析构。
5.条款09:绝不在构造和析构过程中调用virtual函数
不能构造过程中调用virtual函数,因为这样的结果可能不是你想要的。
考虑下面的情况:
class Animal{
public:
virtual Eat();
Animal(){Eat();}
};
class Cat:Animal{
public:
virtual Eat();
};
然后我们这样调用代码
Cat cat;
Cat会调用Animal::Eat();
这是因为构造Cat的过程分为两个阶段:
- 构造基类Animal的部分
- 构造继承类Cat的部分
因此在第一阶段,就调用了Animal::Eat();,而你这时候想调用的其实是Cat::Eat();
如果想要在Cat创建的时候有适当版本的Eat被调用,可以把Eat设置为Animal的非virtual函数,然后要求Cat传入参数给Animal构造函数进行构造
class Animal{
public:
Eat(const std::string& food_name);
Animal(const std::string& food_name){Eat(food_name);}
};
class Cat:Animal{
public:
Cat():Animal("apple"){}
};
总结:
再构造函数调用virtual函数,有可能调用的结果并非你预期的virtual函数版本。如果一定要再构造函数期间调用不同版本的函数,可以让derived class传入具体参数,基类根据参数确定调用哪个版本的函数
6.条款10:令operator=返回一个reference to *this
该项条款并非强制,只是为了和其他类型的一致性。回想一下int类型,它支持以下操作:
int x, y, z;
x = y = z = 15;
为实现这样的连锁赋值,赋值操作符必须返回一个reference指向操作符的左侧实参。
还有这么做的理由是很多STL都这么干:vector,string...
总结:
令赋值操作符返回一个reference to *this。
7.条款11:在operator=中处理自我赋值
在代码中会有变量对自身赋值的情况:
int w ;
w = w;
当自定义对象开始对自身赋值的时候需要特别注意,以下是一个错误的实现方式
class Bitmap{
};
class Wiget{
private:
Bitmap *pb;
};
Widget &
Widge::operator(const Widget &rhs)
{
delete pb;
pb = new Bitmap(rhs.pb);
return *this;
}
当我们执行pb = new Bitmap(rhs.pb)时候,发现rhs.pb根本就消失了,于是产生错误。
有以下做法:
- 证同测试
class Bitmap{
};
class Wiget{
private:
Bitmap *pb;
};
Widget &
Widge::operator(const Widget &rhs)
{
if(this == &rhs) return *this;
delete pb;
pb = new Bitmap(rhs.pb);
return *this;
}
这种方法,能实现“自我赋值安全”,但是不具备异常安全,具体来说,Bitmap 申请内存失败,这个Widget对象会持有一个坏指针,将导致程序异常。
以下的内容可以保证“异常安全”
class Bitmap{
};
class Wiget{
private:
Bitmap *pb;
};
Widget &
Widge::operator(const Widget &rhs)
{
Bitmap* = pb;
pb = new Bitmap(rhs.pb);
delete pOrig;
return *this;
}
总结:
确保自赋值安全,方法包括对比地址,语句安排合理。
8.条款12:复制对象时勿忘其每一个成分
现在你有一个类:
class Animal{
public:
Animal(const Animal & animal):name(animal.name){
}
Animal& operator=(const Animal & animal){
name = animal.name;
return *this;
}
private:
std::string name;
};
这一切看起来很好,但是一旦在类内加入新的成员就糟糕了:
class Animal{
public:
Animal(const Animal & animal):name(animal.name){
}
Animal& operator=(const Animal & animal){
name = animal.name;
return *this;
}
private:
std::string name;
Tail tail;
};
Tail是我们自定义的类型。由于并未同时更新copy构造函数和copy assignment函数,因此这是一种局部的拷贝:只拷贝name而不拷贝tail。更离谱的是,编译器并不会提醒你。
所以当更新类成员的时候不要忘记更新copy构造函数和copy assignment函数。
当发生继承时,事态将更加严重:
class Cat : Animal{
public:
Cat(const Cat & cat) : color(cat.color){
}
Cat& operator=(const Cat & cat){
clor = cat.name;
return *this;
}
private:s
Color color;
};
乍一看似乎没问题,但实际上因参个危机:Cat来自Animal,包含了Animal的部分,但Cat的复制构造函数没有处理基类,因此编译器会用Animal的default构造函数(必须有否则无法编译通过),default构造函数会对Cat中的Animal基类部分进行缺省的初始化。
正确的做法是显示地给出at中的Animal基类部分的初始化方式:
class Cat : Animal{
public:
Cat(const Cat & cat) :
Animal(cat),
color(cat.color){
}
Cat& operator=(const Cat & cat){
clor = cat.name;
Animal::operator=(cat);
return *this;
}
private:s
Color color;
};
或者,你也可以把Animal的default构造函数写的足够完整。
最后一个话题,因为copy构造和copy assignment往往有相似的功能,因此很自然地会想到用其中一个调用另一个,以降低代码量。但这实际上不对:
copy assignment调用copy构造,这就象试图构造一个已经存在的对象,没有意义。
copy构造调用copy assignment调用,这就象是对尚未构造好的对性进行赋值,没有意义。
正确的做法应该是单独定义一个函数供copy构造和copy assignment调用。
总结:
- copying构造函数应该确保复制了其每一个成员和其基类部分,特别是当你更新了类成员,要重新检查这些类的构造是否符合要求。
- 不要尝试用一个copying函数构造另一个copying函数,应该将共同的机能放进第三个函数中,并由两个copying同时调用。