《Effective C++:改善程序与设计的55个具体做法》阅读笔记 2——构造/析构/赋值运算
2 构造/析构/赋值运算
构造函数:产出新对象并确保它被初始化
析构函数:摆脱旧对象并确保它被适当清理
copy assignment操作符:赋予对象新值。
Item 5 了解C++默默编写并调用哪些函数
每个类都会有默认的构造函数、析构函数、拷贝构造函数、operator=
- 默认构造函数和默认析构函数作用:调用父类和非静态成员对象的构造函数和析构函数
- 默认拷贝构造函数和operator=作用:拷贝non-static成员变量拷贝到目标对象。
当成员变量是自定义的类时,默认的拷贝或赋值操作会调用成员变量的拷贝构造函数。
当成员变量是内置类型时,默认的拷贝或赋值操作会拷贝成员变量的每一个bit。
如果成员变量中有const、指针、引用,默认的拷贝构造函数和operator=就会出错,它们出错原因如下:
- const:常量不能更改
- 指针:两个对象中的指针类型的成员变量指向了同一个块内存
- 引用:引用在初始化以后,无法更改绑定对象
Item 6 若不想使用编译器自动生成的函数,就该明确拒绝
类设定为不可拷贝的方法:
- 将拷贝构造函数和operator=的声明放在private范围内,但是这样做的一个缺点就是member函数和friend函数还是可以调用private函数。
- 父类中将拷贝构造函数和operator=的声明放在private范围内,那么子类中的member函数和friend函数都不能调用private函数。这就是boost中noncopyable的实现方法。当然noncopyable还有其他实现的细节,这里不进行探究。
Item 7:为多态基类声明virtual析构函数
本节首先讲了如下内容:
C++中虚析构函数的作用及其原理分析:不加virtual关键字 ,父类的指针指向子类时,delete掉父类的指针,不调动子类的析构函数。一个类用作基类实现多态时,一般都要在析构函数上加上virtual。如果不是,那么一般不加virtual。
如果类中有一个成员函数加上virtual,则类中将会增加一个vptr ( virtual table pointer)指针,vptr指向一个由函数指针构成的数组,称为vtbl( virtual table)。当对象调用某一virtual 函数,实际被调用的函数取决于该对象的vptr所指的那个vtbl,即编译器在vtbl中寻找适当的函数指针。接下来我一步一步地使用代码说明一下这段话:
对象经过初始化以后,此对象在内存中只保存了成员变量,测试代码如下:
#include <unistd.h>
#include<iostream>
class Point {
public:
Point (int xCoord, int yCoord):
x(xCoord),
y(yCoord)
{}
private:
int x, y;
};
int main()
{
Point point = Point(97,98); // 97代表a,98代表b
std::cout<< "size:" << sizeof(point) << std::endl; // 计算Point对象的大小,8bytes
char* b = (char*)&point; // &为取地址
std::cout<< b[0] << std::endl; // 一个char代表一个字节
std::cout<< b[1] << std::endl;
std::cout<< b[2] << std::endl;
std::cout<< b[3] << std::endl;
std::cout<< b[4] << std::endl;
std::cout<< b[5] << std::endl;
std::cout<< b[6] << std::endl;
std::cout<< b[7] << std::endl;
return 0;
}
执行结果:
size:8
a
b
输出说明:
- size为8,说明对象在内存中只存储了成员变量。
- 我使用的是ubuntu系统,ubuntu系统采用的是小端模式(数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中),所以先输出a。
当类中有成员函数加了virtual时,测试代码如下:
#include <unistd.h>
#include<iostream>
class Point {
public:
Point (int xCoord, int yCoord):
x(xCoord),
y(yCoord)
{}
virtual void test(){}
virtual void test2(){}
virtual void test3(){}
private:
int x, y;
static int a;
};
int Point::a=0;
int main()
{
Point point = Point(97,98);
std::cout<<"size:" << sizeof(point) << std::endl; // 计算Point对象的大小,8bytes
int* b = (int*)&point;
std::cout<< b[0] << std::endl; // 一个int代表4个字节
std::cout<< b[1] << std::endl;
std::cout<< b[2] << std::endl;
std::cout<< b[3] << std::endl;
return 0;
}
执行结果:
size:16
-795415208
22054
97
98
输出的-795415208和22054就是代表指针vptr。【这里使用的测试机器是64位的计算机,所以vptr的大小为64bite。如果机器为32的话,vptr的大小将为32bite】
这里的static成员变量a并不存储在其他成员变量所在的位置,它存储在了全局(静态)存储区。不信你把成员变量a注释掉看看。
其他知识:
- 析构函数的调用顺序一般是先调用子类的析构函数,再调用父类的析构函数。
- 父类中定义了纯虚函数,则代表的是子类必须要实现此函数。
- C++——定义变量的底层实现理解:我们定义的局部变量是在栈空间区域分配存储空间的,而变量名、变量类型、变量地址都是存放在符号表中的变量表里面。
Item 8 别让异常逃离析构函数
C++并不禁止析构函数吐出异常,但它不鼓励你这样做。析构函数不能抛出异常的两点理由:
- 资源泄漏:如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
- 出现多个异常:在两个异常同时存在的情况下,程序若不是结束执行就是导致不明确行为,具体情况如下:
1)通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。
2)如果析构函数中会抛出异常到函数外,则一个对象数组被销毁时,数组中的多个对象的析构函数会被调用,每个对象的析构函数抛出一个异常,同时出现多个异常就会导致
如果你的析构函数必须执行一个动作,而该动作可能会在失败时抛出异常,该怎么办?
解决方法: 使用try catch进行异常处理
- 结束程序:使用try catch处理异常,并直接结束整个程序。
- 吞下异常:使用try catch处理异常,就和平常使用catch一样,在catch中打印出提示信息,只要不让异常从析构函数中跑出去就可以。异常在内部就被处理了,程序就正常运行了,当然就不会出现上面那些问题了。
如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class 应该提供一个普通成员函数(而非在析构函数中)执行该操作。比如关闭数据库连接可能发生异常,那么最好建立一个普通成员函数(而非在析构函数中)去关闭,并处理异常。
【在析构函数中处理可能发生的异常不行吗?答:可以啊,书中说了最好提供一个普通成员函数去处理可能抛出异常的操作(如关闭数据库),然后在析构函数中可以判断此操作是否完成(如判断数据库是否关闭成功),如果未完成,就由析构函数来执行此操作(由析构函数来对数据库进行关闭)】
【异常处理和直接使用if判断有什么区别?如除数为零时,直接在进行除法前使用if语句判断除数是否为零不就可以了?】
答:你能保证在使用if判断时,除数不为零,但你不能保证在进行除法时,除数变为了零。再如判断文件先存在,再读写文件,其实就是这个问题,按照程序的流程,可以保证在判断是否存在的时候,文件的存在性,但是不能保证在真正操作文件的时候文件的存在性(例如判断的时候文件还在,真正操作之前却被用户自己删掉了)。因为流程上无法对流程外的用户行为(用户删文件)作出保证,所以需要异常机制。所以我认为异常处理的作用有:
- 使用if不能保证对错误进行处理。
- 《C++ primer》中说的:将问题检测和问题处理相分离,try中处理业务,catch中处理异常情况。
所以在可能抛出异常的情况下,就要使用try-catch。
Item 9 绝不在构造和析构过程中调用virtual函数
virtual函数的作用:对象调用virtual函数时,优先调用的是子类重写的virtual函数。举例如下:
#include<iostream>
class A{
public:
void test(){
test1();
}
virtual void test1(){
std::cout<< "base test1" << std::endl;
}
};
class B:public A{
public:
virtual void test1(){
std::cout<< "derived test1" << std::endl;
}
};
int main(){
B b;
b.test();
return 0;
}
运行结果为:
derived test1
这代表了test()中调用的是子类的test1(),如果没有了virtual,test()就会调用父类的test1()。
但是构造和析构过程中调用virtual函数,情况就有所不一样。
本节中需要在构造函数中调用日志记录函数来记录当前对象的类型,但是不管日志记录函数是不是virtual函数,父类中的构造函数调用的都是父类中的日志记录函数,这样就只记录了父类是什么类型,并未记录子类是什么类型。造成这种情况的原因是:父类构造函数调用时,子类的成员变量还未初始化(子类的构造函数未调用),而子类的virtual函数中可能含有子类的成员变量,所以父类的构造函数肯定不能调用子类的virtual函数。
相同道理也适用于析构函数。一旦子类析构函数开始执行,对象内的子类成员变量便呈现未定义值,所以C++视它们仿佛不再存在。进入父类析构函数后,对象就成为一个父类对象,所以父类对象当然是调用父类的virtual函数。
所以本节就是告诉读者:virtual在析构和构造函数中,不能起到令对象调用子类中的重写函数。
解决方法:父类构造函数中不调用virtual函数,因为起不了什么作用。但我们可以让子类构造函数使用初始化列表向父类传递需要记录的信息。
Item 10 令operator=返回一个 reference to *this
operator=返回值指的是(b=c)返回的东西,连锁赋值a = b = c;被解析为 a = (b = c),a用于接收(b=c)的返回值。
【当operator=不返回引用时,实现连锁赋值会有什么问题吗?】
答:
-
没什么问题,只不过效率低一点。a = b = c;被解析为 a = (b = c),当operator=返回的不是引用类型时, 那么b = c时返回值必须通过copy构造函数来初始化临时对象termporary。 然后termporary通过操作符号operator=赋值给a,即a = termporary, operator=返回时同样会调用copy构造函数来初始化另外一个临时对象。故多调用两次copy构造函数而已。
当operator=返回的是引用类型时,b = c和a = termporary的返回值都没有通过copy构造函数来初始化临时对象。 -
另外, 如果想要支持另外一种“连锁赋值”,如 (a = b) = c; 那么就必须要operator=返回引用类型。即 a = b 返回的是 a的一个引用类型临时对象termporary,那么将c赋值给这个a的引用,即可正确地实现表达式的含义。 如果operator=返回值类型呢? 那么a = b 返回的是a的一个值类型临时对象termporary, 那么c赋值给这个临时对象termporary后,根本没有改变a的值,也就没有正确地实现表达式的含义。
【返回引用,那函数执行结束,函数里的局部变量不就被销毁了吗?】
答:这里返回的引用指向的是赋值操作的左变量。
参考:operator=为什么要返回一个reference to *this
Item 11 在operator=中处理“ 自我赋值”
防止w=w
时出错,错误情况如下:由于赋值操作的两个操作对象是同一个,那么赋值之前对指针类型的成员变量的清空操作就是对操作对象中的此成员变量被清空。具体代码见原文。
解决方法:在operator=函数的开头判断一下,赋值操作所操作的两个对象是否是相同的。
此解决方法,不能解决异常的安全性问题,如在使用new给指针指向区域分配内存时,可能会抛出异常(如内存不足)。异常安全性问题的解决方法:
- 在分配内存前,不delete指针所指内存。具体见原文。
- copy and swap。【使用copy and swap 感觉要保证拷贝构造函数的安全性】
文中还提到了保证了异常安全的同时往往自我赋值安全也会被自动保证。
本节说明了:任何函数如果操作一个以上的对象,就必须考虑多个对象是同一个对象的情况。
Item 12 复制对象时勿忘其每一个成分
复制对象时不要忘记每一个成员变量:
- (1) 复制所有local成员变量
- (2) 在初始化列表上,调用所有base classes内的适当的copying函数。
不要尝试以某个copying函数实现另-一个copying函数。应该将共同机能放进第三个函数中,并由两个coping函数共同调用:
- 不该令copy assignment操作符调用copy构造函数,因为拷贝构造函数是用于构造(初始化)对象,而copy assignment是赋值操作。如果copy assignment操作符调用copy构造函数,那就相当于在copy assignment中有创建了一个对象。【copy and swap中不就是copy assignment操作符调用copy构造函数?答:copy and swap中是对象初始化调用copy构造函数,这里是指类中的两个copy函数不要互相调用】
- 反方向一令copy构造函数调用copy assignment操作符——同样无意义。 构造函数用来初始化新对象,而assignment 操作符只施行于已初始化对象身上。对一个尚未构造好的对象赋值,就像在-一个尚未初始化的对象身上做“只对已初始化对象才有意义”的事一样。无聊嘛!别尝试。.
- 如果你发现你的copy构造函数和copy assignment操作符有相近的代码,消除重复代码的做法是,建立一个新的成员函数给两者调用。这样的函数往往是private而且常被命名为init。这个策略可以安全消除copy构造函数和copy assignment操作符之间的代码重复。