Effective C++ 3nd笔记——构造/析构/赋值运算

Effective C++ 3nd笔记——构造/析构/赋值运算

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

如果某个基类将拷贝赋值操作符声明为private,编译器将拒绝为其派生类生成赋值操作符。

请记住:

  • 编译器可以暗自为类创建默认构造函数、拷贝构造函数、拷贝赋值操作符以及析构函数

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

请记住:

  • 为驳回编译器自动提供的功能,可将相应的成员函数声明为private并且不予实现

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

欲实现出virtual函数,对象必须携带某些信息,主要用来在运行期间决定哪一个virtual函数该被调用。这份信息通常是由一个虚表指针指出。虚表指针指向一个由函数指针构成的数组,称为虚表;每一个带有虚函数的类都有一个相对应的虚表。当对象调用某一个虚函数,实际被调用的函数取决于该对象的虚指针所指的那个虚表——编译器在其中寻找适当的函数指针

虚函数的实现细节不重要。重要的是如果指向的类内含虚函数,其对象的体积会增加:在32bit计算机体系结构中将多占用32bits空间,在64bits计算机中将多占用64bits,因为其内含一个虚指针;声明虚函数之后,类的对象也不再和其他语言内的相同声明有着一样的结构,因此也就不再可能把它传递至其他语言所写的函数,除非你明确补偿虚指针——那属于实现细节,也因此不再具有移植性
因此,无端的将所有类的析构函数声明为虚函数,是不可取的;许多人的心得是:只有当类内至少含有一个虚函数时才为它声明虚析构

请记住:

  • polymorphic(带多态性质的)base classes应该声明一个虚析构函数。如果类带有任何虚函数,它就应该有一个虚析构函数
  • 类的设计目的如果不是作为基类使用,或不是为了具备多态性,就不该声明虚析构函数

别让异常逃离析构函数(别让析构函数的异常扩散出去)

C++并不禁止析构函数吐出异常,但它并不鼓励你这样做。这是有理由的。考虑以下代码:

class Widget{
public:
	~Widget(){ ... }  //假设这里会抛出一个异常
};
void doSomething(){
	std::vector<Widget> v;
	...  // v在这里被自动销毁
}

当 v 被自动销毁,它有责任销毁其内含有的所有Widget。当析构第一个Widget时,有一个异常被抛出。其他的Widget还是应该被销毁(不然会发生内存泄漏),因此 v 应该调用它们各个析构函数。但在调用期间,第二个Widget析构函数又抛出异常。现在有两个同时作用的异常,这对C++而言太多了。在两个异常同时存在的情况下,程序若不是结束执行就是会导致不明确的行为,在本例中它会导致不明确的行为。

即使没有使用容器,程序也有可能过早结束或出现不明确行为

有两个办法可以避免这一问题:

  • 当异常发生时,可以通过调用 abort 强迫结束程序,也就是说调用 abort 可以抢先制止不明确行为
    try{ 析构函数抛出异常 }
    catch( ... ) {
    	制作运转记录,记下对析构函数的调用失败;
    	std::abort();
    }
    
  • 吞下因调用析构函数而发生的异常
    try{ 析构函数抛出异常 }
    catch( ... ) {
    	制作运转记录,记下对析构函数的调用失败;
    }
    
    一般来说,将异常吞下是个坏主意,因为它压制了“某些动作失败”的重要信息!然而有时候吞下异常也比负担“草率结束程序”或“不明确行为”带来的风险好。为了让这称为一个可行的方案,程序必须能够继续可靠的执行,即使在遭遇并忽略一个错误之后

请记住:

  • 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序
  • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作

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

你不该在构造函数和析构函数期间调用 virtual 函数,因为这样的调用不会带来你预想的结果,就算有你也会不高兴。考虑以下代码:

class Transaction {
public:
	Transaction(){ logTransaction(); }
	virtual void logTransaction() { std::cout << "Transaction\n"; }
};

class BuyTransaction :public Transaction {
public: 
	virtual void logTransaction() { std::cout << "BuyTransaction\n"; }
};

int main() {
	BuyTransaction b;
	return 0;
}

在这里插入图片描述
这表明构造函数执行的是基类版本的虚函数,而不是派生类版本的虚函数

这是因为基类构造期间,虚函数绝不会下降到派生类阶层。取而代之的是,对象的作为就像隶属基类型一样。由于基类构造函数的执行更早于派生类的构造函数,当基类构造函数执行时派生类的成员变量尚未初始化。如果此期间调用的虚函数下降至派生类阶层,就会产生不明确行为。因为此时派生类特有的成员还未初始化

其实还有比上述理由更根本的原因:在派生类对象的基类构造期间,对象的类型是基类而不是派生类。不只虚函数会被编译器解析至基类,若使用运行期类型信息,也会把对象视为基类类型

相同的道理也适用于析构函数。一旦派生类析构函数开始执行,对象内的派生类成员变量便呈现未定义值,所以C++视它们仿佛不再存在。进入基类析构函数后对象就成为一个基类对象,而C++的任何部分包括虚函数、dynamic_cast 等等也就那么看待它

再考虑一种情况,我们对 Transaction 类做如下修改

class Transaction{
public:
	Transaction(){ init(); }
	virtual void logTransaction() const = 0
private:
	void init(){ logTransaction(); }
};

这段代码把 logTransaction 定义成了纯虚函数,但他比较潜藏并且暗中为害,因为它通常不会引发任何编译器和连接器的报错。

此时由于 logTransaction 是 Transaction 内一个纯虚函数,当纯虚函数被调用时,大多执行系统会终止程序。然而如果 logTransaction 是个正常的虚函数并在 Transaction 内有一份实现代码,该版本就会被调用,而程序也会向前执行。

唯一能够避免此问题的做法就是:确定你的构造函数和析构函数都没有调用虚函数,而它们调用的所有函数也都服从同一约束

但我们如何确保每次一有 Transaction 继承体系上的对象被创建,就会有适当版本的 logTransaction 被调用?

  • 一种做法是:在基类中将logTransaction 改为非虚函数,然后要求派生类构造函数传递必要信息给 Transaction 构造函数

请记住:

  • 在构造和析构期间不要调用虚函数,因为这类调用从不下降至派生类

令 operator= 返回一个reference to *this

关于赋值,有趣的是你可以把它们写成连锁形式:

int x, y, z;
x = y = z = 15;  // 赋值连锁形式,赋值采用右结合律

为了实现“连锁赋值”,赋值操作符必须返回一个引用指向操作符的左侧实参。这是你为 classes 实现赋值操作符时应该遵守的协议:

class Widget{
public:
	Widget &operator=(const Widget& rhs){  // 返回一个引用类型指向当前对象
		return *this;
	}
};

这个协议不仅适用于以上的标准赋值形式,也适用于所有赋值相关运算,例如“+=”。当然,这只是个协议,并无强制性要求。如果你不遵守它,代码一样可以通过编译。不过所有的内置类型和标准库程序提供的类型都遵守这个协议。

在 operator= 中处理“自我赋值”

class Widget{ ... };
Widget w; w = w;  // 赋值给自己

// 潜在的自我赋值
a[i] = a[j];  // 如果i和j有相同的值,那么这也是自我赋值
*px = *py;  // 如果两个指针恰巧指向同一块内存空间,那么这也是自我赋值

实际上两个对象只要来自同一个继承体系,它们甚至不需声明为相同类型就可能造成“别名”,因为一个基类的引用或指针可以指向一个派生类对象

如果你尝试自行管理资源,可能会掉进 “在停止使用资源之前意外释放了它” 的陷阱。假设你简历一个 class 用来保存一个指针指向一块动态分配的位图(bitmap)

class Bitmap{ ... };
class Widget{
private:
	Bitmap *pb;
};

Widget& Widget::operator=(const Widget& rhs){
	delete pb;  // 停止使用当前的bitmap
	pb = new Bitmap(*rhs.pb);  // 使用 rhs 的 bitmap 的复件
	return *this;

这里的自我赋值问题是,operator=函数内的 *this 和 rhs 有可能是同一个对象。如果是这样,那么 delete 就不只是销毁当前对象的 bitmap,它也销毁 rhs 的 bitmap。在函数末尾,Widget——它原本不该被自我赋值动作改变的——发现自己持有一个指针指向一个已被删除的对象!

想要阻止这种错误,传统的做法是在 operator= 的函数体里加入一段判断语句,判断此时的赋值动作是否为“自我赋值”,即“证同测试”:

Widget& Widget::operator=(const Widget& rhs){
	if(this == rhs) return *this;  // 证同测试
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

这样做行得通。前一版operator=不仅不具备“自我赋值安全性”,也不具备“异常安全性”,这个新版本仍然存在异常方面的麻烦。更明确的说,如果“new Bitmap”导致异常,Widget最终会持有一个指针指向一块被删除的Bitmap。这样的指针有害。

但让operator=具备“异常安全性”往往自动获得“自我复制安全性”

又比如:

Widget& Widget::operator=(const Widget& rhs){
	Bitmap* pOrig = pb;
	pb = new Bitmap(*rhs.pb);
	delete pOrig;
	return *this;
};

现在,如果“new Bitmap”抛出异常,pb保持原状,即使没有证同测试,这段代码还是能够处理自我赋值,因为我们对原Bitmap做了一份复件、删除原Bitmap,然后指向新制造的那个复件。

如果你很关心效率,可以把证同测试再次放回函数起始处,然而这样做会使得代码变大一些并导入一个新的控制流分支,而两者都会降低执行速度。Prefetching、caching和pipeling等指令的效率都会因此降低

请记住:

  • 确保当对象自我复制operator=有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy and swap技术
  • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确

复制对象时勿忘其每一个成分

总结起来就是:

  • 在编写拷贝构造函数和拷贝赋值操作符的时候不要忘记为每个成员变量赋值
  • 在派生类中的拷贝构造函数或拷贝赋值操作符要记得调用基类的拷贝构造函数和拷贝赋值操作符以为基类成员变量赋值
  • 当类修改的时候,要记得修改拷贝构造函数和拷贝赋值操作符
  • 不要尝试以某一个cpoying函数实现另一个cpoying函数;例如:
    • 用拷贝赋值操作符调用拷贝构造函数是不合理的,因为这试图构造一个已经构造好的对象
    • 用拷贝构造函数调用拷贝赋值操作符也是不合理的,因为这试图对一还未初始化的对象进行赋值操作
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JallinRichel

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值