目录
2. 条款06:若不想使用编译器自动生成的函数,就该明确拒绝
6.条款10:令operator=返回一个reference to *this
1.条款05:了解C++默默编写并调用哪些函数
说白了,看到这个条款,我就马上想到了类和对象的六个默认成员函数:构造函数、析构函数、拷贝构造函数、赋值重载、普通对象和const对象取地址的重载。
对于这六大默认成员函数,详细的解析在类和对象这篇博客中,我将在这里简单的总结一下编译器是如何调用它们的。
对于构造函数和析构函数:对于内置类型,C++中选择不处理,也就是内置类型在构造函数中会是随机值,因此在C++11中,可以在声明的时候顺带定义一下。而对于类中的自定义类型,它们会自动调用的构造和析构函数,如果是别的类的自定义类型,则会到它们自己的类中去调用它们的构造和析构函数。在多态中,基类先构造,然后再是派生类构造。析构的时候,先是派生类先析构,然后是基类析构。
书中的补充①:需要注意的是编译器产生的析构函数并非虚函数。
书中补充②编译器拒绝为类生出operator=的情况:
第一种情况:类的成员变量中,存在引用的声明
第二种情况:存在const修饰的成员变量。
#include<iostream>
#include<string>
template<class T>
class NamedObject
{
public:
NamedObject(std::string& name = "", const T& value = 0)
:nameValue(name)
,objectValue(value)
{}
//...没有声明赋值重载,按道理来说会默认生成出来
private:
std::string& nameValue;//引用类型
const T objectValue = 0;//const 类型
};
int main()
{
std::string newDog("feifei");//我现在的狗叫肥肥,是柯基跟田园犬的结合
std::string oldDog("tiantian");//肥肥的妈妈叫天天,是一个聪明的柯基
NamedObject<int> p(newDog, 4);//肥肥四岁了
NamedObject<int> s(oldDog, 4);//天天的年龄永远停留在了四岁......
p = s;//error,报错显示operator是已删除的函数
return 0;
}
赋值不成功的理由很简单,引用的指向是不可以被改变的,赋值的话就说明要改变引用指向的对象。同样的,对const成员函数也是一样不能被改变!这种情况就必须自己定义一个赋值重载函数。
还有一种情况是在继承的情况下,基类将自己的赋值重载函数设为私有的,那么编译器就会拒绝给派生类默认生成赋值重载函数。理由是,派生类继承基类的时候,会继承基类的某些成分,编译器要处理这些成分,但是因为无法调用派生类无权调用的基类成员函数,因此也就没办法了。
2.条款06:若不想使用编译器自动生成的函数,就该明确拒绝
其实这里就是不想让用户能够调用这些成员函数,比如拷贝构造,赋值重载,一开始想到的办法就是不实现它们,但是上面我们说了,我们不写,编译器会自己生成的!
因此办法就是设置为私有的,并且不实现这个函数。这样,没有人能够在类外调用这个函数,也不能在类中调用了。
class A
{
public:
//...
A() {}
~A() {}
private:
//只声明,并且设为私有
A(const A&);
A& operator=(const A&);
};
或者直接删除,这是C++11新增的一种方法,使用delete关键字
class A
{
public:
//...
A() {}
~A() {}
private:
//删除了
A(const A&) = delete;
A& operator=(const A&) = delete;
};
还有一种方法就是继承的方法,只要把基类的拷贝构造函数和赋值重载私有或者删掉,派生类就不可能会默认生成拷贝构造和函数重载出来!理由是派生类实例化出来对象后,有一部分的成分是基类的,因此需要调用基类的对应的成员函数,如果进行了赋值或者拷贝的操作,就需要调用基类的对应的函数,而基类的这些函数被删掉了或者私有化,反正调用不了,此时派生类的拷贝构造和赋值重载也不能用了!
这样做的好处是,如果有人在类中调用了这些被私有化的函数,或者使用友元,那么会在连接期出现错误,而并非编译期的错误。如果是发生在连接期的错误,这种错误很难侦测出来!因此,这种做法就是将连接错误转移到了编译期,那么只要有人试图调用这些规定不能用的函数,就会在编译期报错!
class A_Father
{
public:
A_Father() {}
~A_Father(){}
private:
A_Father(const A_Father&);
A_Father& operator=(const A_Father&);
};
class A:public A_Father
{
public:
A() {}
~A() {}
};
int main()
{
A a1;
A a2;
a1 = a2;//err
A a3(a1);//err
return 0;
}
3.条款07:为多态基类声明virtual析构函数
在看到这个条款,我立马就会想到它的意思了:那就是在多态中,给基类的析构函数声明为virtual虚函数,这样就会保证资源不会被泄漏,因为当基类的指针或者引用指向了派生类的对象,在析构的时候,先会析构派生类的成分,基类的成分需要调用基类的析构函数。如果不需要构成多态,那么就不需要virtual析构函数。
4.条款08:别让异常逃离析构函数
如果在析构函数中进行了抛异常的操作,那么我们要在析构函数内将其捕获之,这样才能继续执行析构函数后面的代码,才能保证资源安全地释放完成,如果让这个异常走出析构函数了,那么就会让程序过早的结束或出现不明确的行为。
做法比较简单,就是使用try{} catch(...) {};捕获,在catch的主体内,可以选择使用abort()来结束程序,也能进行其它操作,比如记下析构的失败等待。
class A
{
A() {}
~A()
{
try
{
//....
}
catch (...)
{
//1.析构出现异常,直接结束程序
std::abort();
//2.析构出现异常,记下来
//...
}
}
};
但是这两种种做法有个缺点,那就是无法对因为某种原因而出现异常做出反应,因此解决办法就是将这个会抛异常的函数拿出来,不要放到析构函数中,然后使用“双保险”的方式,再在析构函数中判断是否已经将这个函数执行完毕(如果抛异常就是没执行完毕),如果没有执行完毕,再在析构函数中执行,让析构函数去执行它。如果析构函数也执行失败抛出异常,就会捕获异常,虽然此时就会回到上面的两种做法(退出程序或吞下异常)了。但是这种做法的好处是在不知道会不会抛异常的前提下,将调用这个函数的责任转移给了其它的函数,交给用户的手上。(至于这个函数为什么要在析构中执行,因为可能这个函数执行的功能是关闭连接或者关闭什么东西的,关闭了也就结束了,结束了也就要析构了嘛)。
class A
{
A() {}
void close() //交给用户去调用
{
//关闭连接
test.close();//假设有个东西要关闭连接
closed = true;
}
~A()
{
if (!closed)
{
try
{
test.close();//关闭连接
}
catch (...)
{
//1.析构出现异常,直接结束程序
std::abort();
//2.析构出现异常,记下来
//...
}
}
}
private:
bool closed;
};
5.条款09:绝不在构造和析构过程中调用virtual函数
本条款的重点:不要再构造函数和析构函数执行期间去调用virtual函数。理由是:在构造和析构期间,基类的构造和析构函数内的virtual函数不会下降到派生类阶层。
用一段代码展开聊聊这个条款吧~
//实现一个类的继承体现,基类为Dog,每创建一个对象,就多一条小狗狗
class Dog
{
public:
Dog();
virtual void count_Dog() const = 0;;
//...
};
Dog::Dog()
{
//...
count_Dog();
}
class Corgi :public Dog //柯基的类
{
public:
virtual void count_Dog() const;
};
int main()
{
Corgi co;
return 0;
}
分析代码:
代码中,用派生类创建了一个派生类的对象,在构造函数被调用的时候,会先去构造基类的成分,然后才会去构造派生类的从成分,这就意味着,会先去调用基类的构造函数。
基类的构造函数最后会去执行count_Dog函数,问题就出现在这里,上面说了,构造函数构造期间,基类的virtual函数不会下降到派生类中,也就是说即使我们创建的对象属于派生类的,但是在调用基类的构造函数期间,对象就会被看做成基类的对象!调用的是基类的count_Dog函数!
这种现象根本的原因在于:在派生类对象调用基类的构造函数期间,由于是基类先构造,那么在此期间,此时的对象被视为是基类的对象,并且派生类的成分并没有初始化,因此C++的做法是视它们不存在,这样才能保证安全。
同样的,对于析构函数也一样,由于是先析构派生类的成分,在派生类析构函数执行的时候,对象内的派生类的成员变量就是变成了未定义值,C++是它们不存在,而进入了基类的析构函数,就会变成基类的对象。
在上面这个例子中,基类的构造函数就直接调用了基类中的virtual函数,并且它是一个纯虚函数,此时连接器就找不到基类中count_Dog的实现代码了,编译器就会报错。
解决这个问题,就要确定我们的析构函数和构造函数都没有调用virtual函数,要保证这一点,我们可以将基类中的count_Dog函数变成非虚函数,另外让派生类在构造函数的时候给基类传递必要的信息给基类的构造函数。并且需要注意的使用static静态成员函数来传递,这样就可以在一开始的时候初始化了。
class Dog
{
public:
explicit Dog(const std::string& dog);
void count_Dog(const std::string& dog) const
{
std::cout << "小狗狗的数量+1,新来的狗狗叫:" << dog<< std::endl;
}
//...
};
Dog::Dog(const std::string& dog)
{
//...
std::cout << "调用基类构造函数" << std::endl;
count_Dog(dog);
}
class Corgi :public Dog //柯基的类
{
public:
Corgi(const std::string& dogs)
:Dog(createdogs(dogs))
{
std::cout << "调用派生类构造函数" << std::endl;
}
private:
static std::string createdogs(const std::string dog);
};
std::string Corgi::createdogs(const std::string dog)
{
return dog;
}
int main()
{
Corgi co1("天天");
Corgi co2("大肥");
return 0;
}
代码分析:一开始会进入到派生类的构造函数中的初始化列表中,通过调用了createdogs函数,创建了基类Dog的匿名对象,也就是调用了基类的构造函数,然后进入了count_Dog函数,最后再次去调用派生类的构造函数的主体!
我们调试来看一下:
第一步:在派生类的构造函数的初始化列表中。
第二步:进入了createdogs函数,因为此时对于这个函数来说,其参数已经有了
第三步:进入基类的构造函数
第四步:进入基类的count_Dog函数
最后一步:进入派生类构造函数
说到底,正是因为派生类的成员变量没有初始化,所以在基类的构造和析构期间调用的virtual函数不可以下降到派生类阶层。只要我们换一个思路,自底向上地传入信息,即先用static的特性,把派生类的一些必要的数据进行初始化,然后传递给基类就可以了。
总结:我们不要再构造和析构期间调用virtual函数了。
6.条款10:令operator=返回一个reference to *this
也就是让赋值重载函数的返回值是一个引用返回,这样是为了可以实现连锁赋值。
a = b = c = 10;
当然,你也可以根据需求来决定要不要这样做,但是一般大家都这样做,有必要还是这样做吧。
7.条款11:在operator=中处理“自我赋值”
自己给自己赋值,这种情况不能说没有,只能说少见,比如:
class A
{
public:
A() {}
~A(){}
};
int main()
{
//1.对象自我赋值
A a;
//...一堆代码
a = a;//不小心自我赋值
//2.下标相同时的自我赋值
int arr[5] = { 0 };
int i = 0, j = 0;
arr[i] = arr[j];
//3.指向是相同的指针自我赋值
int* px = arr;
int* py = arr;
*px = *py;
return 0;
}
这些其实都不是问题,只是很愚蠢罢了,还有以下这种很难找出的自我赋值:代码中的f和s很可能是同一个对象。
class Father_Class
{
public:
Father_Class(){}
~Father_Class(){}
};
class Son_Class :public Father_Class
{
public:
Son_Class() {}
~Son_Class() {}
};
void doSomeThing(const Father_Class& f, const Son_Class* s);
看起来,自我赋值好像没什么怕的,但是其实自我赋值是有隐患的,来看以下这段代码:
//建立一个class来保存一个指针指向一块动态分配的位图bitmap
class Bitmap
{
//...
};
class Widget
{
//..
Widget& operator=(const Widget& rhs)
{
delete pb; //释放当前的bitmap
pb = new Bitmap(*rhs.pb); //将rhs的pb赋值给当前的pb
return *this; //返回
}
private:
Bitmap* pb;
};
这里出现的问题是:当我们把当前的bitmap释放,然后再去赋值的时候,如果rhs中的pb和当前的pb是指向同一个对象的时候,在delete的时候,就相当于同时消耗了rhs的bitmap和当前对象的bitmap!此时this指向的是一个已经被删除的对象!
①简单的解决方法是检测一下rhs和当前的*this是否指向同一个对象:
Widget& operator=(const Widget& rhs)
{
if (&rhs != this)
{
delete pb; //释放当前的bitmap
pb = new Bitmap(*rhs.pb); //将rhs的pb赋值给当前的pb
}
return *this; //返回
}
但是还有问题,那就是抛异常的问题!如果new Bitmap的操作抛异常了,那就说明赋值失败,开辟空间失败,此时当前的对象已经被删除了,而赋值又失败了,此时当前的this指针会指向一块被删除的Bitmap。
②解决的方法是换一下顺序,并且保存当前的pb。
Widget& operator=(const Widget& rhs)
{
Bitmap* tmp = pb; //先保存原本的pb
pb = new Bitmap(*rhs.pb); //让pb指向*rhs.pb的一个副本
delete tmp; //删除原本的pb
return *this; //返回
}
此时,不仅不需要进行检测,还能处理自我赋值带来的问题。因为对原本的bitmap做了复件,然后才指向新的bitmap,然后删除原先的bitmap。
还有一种办法,这是在我之前的文章中提到过的,在赋值重载中使用所谓的“现代版本”进行赋值。
void swap(Widget& rhs)
{
Bitmap* tmp = pb;
pb = rhs.pb;//赋值
rhs.pb = pb;
}
//1.引用
Widget& operator=(const Widget& rhs)
{
Widget tmp(rhs);//对rhs做一份拷贝
swap(tmp);//交换
return *this; //返回
}
//2.传值传参,rhs的改变不会影响到本体的rhs
Widget& operator=(Widget rhs)
{
swap(rhs);
return *this; //返回
}
8.条款12:赋值对象时勿忘其每一个成分
这个条款的重点就是在继承体系中,要确保派生类的成分和基类的成分都必须得到赋值。做法就是在派生类中的拷贝构造函数和赋值重载中调用基类的拷贝构造和赋值函数。
class father_c
{
public:
father_c() {}
father_c(const father_c& f)
:_data(f._data)
,_str(f._str)
{
std::cout << "调用基类的拷贝构造" << std::endl;
}
father_c& operator=(const father_c& f)
{
std::cout << "调用基类的赋值重载" << std::endl;
_data = f._data;
_str = f._str;
return *this;
}
virtual ~father_c(){}
private:
int _data;
std::string _str;
};
class son_c:public father_c
{
public:
son_c() {}
son_c(const son_c& c)
:_tmp(c._tmp)
,_cash(c._cash)
, father_c(c) //调用基类的拷贝构造,使用切片的特性,给基类成分赋值
{
std::cout << "调用派生类的拷贝构造" << std::endl;
}
son_c& operator=(const son_c& c)
{
std::cout << "调用基类的拷贝构造" << std::endl;
_tmp = c._tmp;
_cash = c._cash;
father_c::operator=(c); // 调用基类的赋值重载,使用切片的特性,给基类成分赋值
}
private:
int _tmp;
std::string _cash;
};