2 构造,析构,赋值运算符

5. 了解 C++ 默默编写并调用了哪些函数

5.1 C++ 为 class 默认生成的四个函数

表面上写下了如下的代码:

class Empty();

但实际上,C++ 会生成这个类的默认构造函数拷贝构造函数析构函数赋值运算符,如下:

class Empty {
public:
	Empty() { ... }									// 默认构造函数
	Empty(const Empty& rhs) { ... }					// 拷贝构造函数
	~Empty() { ... }								// 析构函数
	Empty& operator=(const Empty& rhs) { ... }		// 赋值运算符
};

如果某个类没有显式声明上述四种函数的某几种,那么 C++ 编译器就会创建对应的默认函数(如果这种函数被调用的话)。
而面对编写程序时各种各样的情况,可能是设计需求,也有可能是避免不可预期的错误,仅依靠 C++ 编译器创建的 4 个默认函数是远远不够的,通常需要程序员显式声明。

6. 若不想使用编译器自动生成的函数,就该明确拒绝

6.1 不允许拷贝构造的类

通常想要某个 class 没有某个功能,只要不声明这个函数就好了。可是拷贝构造函数赋值运算符不一样,就算你不声明,C++ 编译器也会偷偷地声明。
这时就可以用 private “关闭” 某个类的拷贝构造函数赋值运算符

class Uncopyable {
protected:
	Uncopyable() {}								// 允许派生类对象的构造和析构
	~Uncopyable() {}
private:
	Uncopyable(const Uncopyable&);				// 禁止拷贝和赋值运算符(可以省略掉参数名)
	Uncopyable& operator=(const Uncopyable&);
};

Uncopyable 当作基类,其派生类会有一些奇妙的性质,这种借助基类实现某些功能的设计方式很常见。

7. 为多态基类声明 virtual 析构函数

面试超级常见问题:“为什么基类的析构函数要声明为虚函数?

7.1 基类的 non-virtual 析构函数会引发内存泄漏
class Fruit {							// 基类
public:
	Fruit();
	~Fruit();
};
class Apple : public Fruit { ... };		// 派生类
class Banana : public Fruit { ... };	// 派生类
class Orange : public Fruit { ... };	// 派生类

Fruit* pFurit = new Apple();	// 基类指针指向派生类对象,为了实现多态,这种方式很常见

在上面代码中,如果析构函数 Fruit::~Fruit() 没有被声明为 virtual,那么派生类的析构函数可能没有被调用。
做法很简单,任何 class 只要带有 virtual 函数,那么应该也有一个 virtual 析构函数。
(书上这里写的和平时遇到的情况不一样,因为析构时的调用顺序是派生类的析构函数,然后是成员对象的析构函数,最后才是基类的析构函数;不过考虑到某些意外的错误使用,书上并没有说错。)
值得注意的是,理论上 C++ 的 STL 容器 vectorlistset 等等都是可以被继承的,如下。但是由于这些容器的析构函数未声明为 virtual 函数,因此会导致不明确行为,所以不要继承 STL 容器!(C++ 没有像 Java 那样的 final 关键词。)

class SpecialString: public std::string {		// 馊主意!写出这种代码的一定是个人才!
	...
};
7.2 不要无端地的声明 virtual 析构函数

根据 virtual 函数的实现原理(虚函数表指针,虚函数表等),一旦一个类内含有 virtual 函数,那么其对象的占用内存空间就会相应增加(存放虚函数表和虚函数表指针)。
所以,不要无缘无故地把所有 class 的析构函数都声明为 virtual 函数。

7.3 小结
  • 多态型的基类应该声明一个 virtual 析构函数。也就是说,如果一个 class 存在 virtual 函数,它就应该有一个 virtual 析构函数。
  • 不要无端地声明 virtual 函数,会带来没必要的内存开销,有些类 class 并不是作为基类的,不具有多态性。

8. 别让异常逃离析构函数

因为 C++ 开发比较少用到异常处理,这里不详细介绍原因,只说需要注意的两点:

  • 析构函数不要吐出异常,连续两次抛出异常未处理后,程序会结束执行或者导致不明确行为
  • 如果客户需要对某个操作函数运行期抛出的异常作出反应,那么 class 应该专门提供一个普通函数执行该操作,而不是在析构函数中

9. 绝不在构造和析构过程调用 virtual 函数

又是个被问烂了的面试题,“C++ 为什么不要在构造和析构函数中调用虚函数?

9.1 不要在构造函数调用虚函数

假设在 Class Base 的构造函数调用虚函数 void vFunc();,并且有 Class Derived : public Base 继承关系。

// 基类
class Base {
public:
	Base() { vFunc(); }				// 构造函数内调用虚函数
	virtual void vFunc() = 0;		// 虚函数,声明为纯虚函数也是常见的情况
	...
};
// 派生类
class Derived: public Base {
public:
	virtual void vFunc();			// 虚函数
	...
};
// 生成派生类对象
Derived dd;

那么在生成派生类对象时 Derived dd; ,一定是先调用基类的构造函数,然后调用派生类的构造函数。
问题来了,基类的构造函数调用了虚函数 void vFunc(); ,本应该调用的是派生类的版本 Derived::vFunc();,然而此时派生类的构造函数还未调用,派生类独有的成员变量也未初始化
这是十分危险的,C++ 绝不允许使用未初始化的成员变量!
因此,你只能使用基类版本的 Base::vFunc();,那么这也失去了虚函数的意义。并且如果 Base::vFunc(); 是纯虚函数,简直完蛋。

9.2 不要在析构函数调用虚函数

道理和构造函数相同,先调用基类的析构函数,然后调用派生类的析构函数。
派生类完成析构后,再进入基类的析构函数,而此时原本是派生类的对象就会变成相当于基类的对象。

9.3 小结

构造函数析构函数 内不要调用 虚函数 ,因为这种调用不会下降到派生类,只会停留在当前基类

10. 令赋值运算符返回 *this 的引用

赋值运算可以写成连锁形式

int x, y, z;
x = y = z = 3;

为了让重载的赋值运算符函数 operator= 也能支持“连锁赋值”,函数应该返回 *this 的引用。

class Widget {
public:
	...
	Widget& operator=(const Widget& rhs)		// 返回类型是引用,该引用指向操作符左侧的对象
	{
		...
		return* this;							// 返回操作符左侧的对象
	}
}

不仅是 operator=operator+=operator-=operator*= 等等重载运算符都可以遵循此协议。
这份协议被所有内置类型和标准程序库提供的类型遵守( string, vector, complex, tr1::shared_ptr)!

11. 在赋值运算符中处理“自我赋值”

由于别名基类指针指向派生类对象,在赋值运算符的左右两边可能是同一个对象。

11.1 法一(证同测试)
class Bitmap { ... };
class Widget {
	...
private:
	Bitmap* pb;
};
Widget& Widget::operator=(const Widget& rhs)
{
	if (this == &rhs) return *this;			// 证同测试,如果是自我赋值就不做任何事
	delete pb;								// 不具备异常安全性,如果 new Bitmap 异常
	pb = new Bitmap(*rhs.pb)				// pb 就会指向一块被删除的区域
	return *this;
}
11.2 法二 (建立副本)
Widget& Widget::operator=(const Widget& rhs)
{
	// if (this == &rhs) return *this;		// 证同测试会让代码体积变大,并倒入新的控制流分支
											// 估计“自我赋值”发生的可能性再决定是否加入证同测试
											
	Bitmap* pOrig = pb;						// 建立副本,在 new 之前建立原始 pb 的副本
	pb = new Bitmap(*rhs.pb);				// 可以确保异常安全和自我赋值安全
	delete pOrig;
	return *this;	
}
11.3 法三(copy and swap)
Widget& Widget::operator=(const Widget& rhs)
{
	Widget temp(rhs);						// 手动创建被传对象的副本
	swap(temp);								// 用自定义的 swap() 函数实现赋值
	return *this;
}
11.4 法四(传值方式)
Widget& Widget::operator=(Widget rhs)		// 此处不是引用,rhs是被传对象的副本
											// 牺牲代码清晰性巧妙地解决了问题,有时候会更高效!
{
	swap(rhs);								// swap() 函数实现数据交换
	return *this;
}
11.5 小结
  • 确保重载赋值运算符函数在自我赋值时仍有正确行为,可以用到的技术有:“证同测试”、“手动创建副本后再 new”、“copy-and-swap”。
  • 确保任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,行为仍然正确。

12. 复制对象时勿忘其每个成分(Copy all parts of an object.)

不妨把拷贝构造函数重载赋值运算符统称为“拷贝函数”。
假设在某个 class 内部添加了新的成员变量,那么也必须同时在拷贝函数添加这些成员变量的拷贝操作,不能遗漏。

12.1 派生类的拷贝函数
// 基类
class Tree {
public:
	Tree(const Tree& rhs) : name(rhs.name) {}	// 拷贝构造函数
	Tree& operator=(const Tree& rhs) {			// 重载赋值运算符
		name = rhs.name;
		return *this;		// 前面提到的协议记得遵守
	}
private:
	std::string name;		// 派生类无法直接访问
};
// 派生类
class AppleTree {
public:
	AppleTree(const AppleTree& rhs) : apppleCount(rhs.appleCount) {}	// 拷贝构造函数
	AppleTree& operator=(const AppleTree& rhs) {						// 重载赋值运算符
		appleCount = rhs.appleCount;									// 基类的拷贝函数并没有复制基类的成分!
		return *this;
	}
private:
	int appleCount;
};

上面的例子中,派生类无法访问基类private 成分,并且拷贝函数也没有参数从基类传递给派生类,于是派生类无法复制基类的成分。
因此 C++ 会擅自使用基类 Tree默认拷贝构造函数来初始化派生类 对象 AppleTree基类成分。
本例中,默认拷贝构造函数会对 Tree::name 执行缺省的初始化动作。
所以必须“手动为派生类编写拷贝函数”!保证基类的成分也被完整拷贝。

// 手动为派生类编写拷贝函数
AppleTree::AppleTree(const AppleTree& rhs)
	: Tree(rhs),										// 调用基类的拷贝构造函数,这在Qt编程里很常见
	appleCount(rhs.appleCount)
{
}
AppleTree& AppleTree::operator=(const AppleTree& rhs)
{
	Tree::operator=(rhs);								// 调用基类的重载赋值运算符函数
	appleCount = rhs.appleCount;
	return *this;
}
12.2 拷贝函数的互相调用
  1. 重载赋值运算符函数调用拷贝构造函数
    不合理!调用拷贝构造函数创建一个已经存在的对象?没有意义。
  2. 拷贝构造函数调用重载赋值运算符函数
    拷贝构造函数:初始化对象
    赋值运算符:用于已经初始化的对象
    对还未初始化的对象赋值,没有意义!
12.3 小结
  • 拷贝函数应该保证复制「对象内的所有成员变量」以及「基类的所有成分
  • 不要让重载赋值运算符拷贝构造函数互相调用,如果为了避免代码重复,请新建 init() 函数
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值