zl程序教程

您现在的位置是:首页 >  后端

当前栏目

Effective C++ (二) 构造、析构和赋值运算

C++ 运算 赋值 构造 Effective 析构
2023-09-27 14:27:31 时间

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

类的拷贝行为是可以定义的,比如一个系列唯一的ID号,再如一个类共享的动态资源等。C++需要三个法则就可以完成拷贝行为的控制,他们分别是三个“特殊”的函数:

  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值函数

完全没有必要定义所有的三法则,因为编译器将会默默编写这些函数。对于析构函数,基本上什么都不做,这也就是说你需要手动调用delete删除哪些动态分配的内存;对于拷贝构造、拷贝赋值,编译器只是单纯地将类内non-static对象逐成员拷贝,特别的,指针成员拷贝则是拷贝其指向而不是所指(浅拷贝)。

在《类拷贝规则——三/五法则》已经解释过,下面的trick:

  • 如果你要定义析构函数,你肯定要定义对应的拷贝赋值和拷贝构造
  • 如果你定义了拷贝构造,那么你一定要定义赋值构造

在作者写这本书时,如果你需要屏蔽某个合成函数,你可以通过将其设置为private;除此,当你定义了自己的三法则中的一个时,编译器将不再继续为你提供合成剩余法则的服务,可有的时候你又确实需要合成服务,在此之前除了手工完成没有别的办法。

C++11支持了两个关键字,delete和default。前者解决了屏蔽任务,后者解决了再次服务问题。类的数据成员类型将会影响编译器合成三法则,如果一个类有数据成员不饿能默认构造、拷贝、复制或销毁,则对应的成员函数将会被定义为删除的;对于引用成员或无法默认构造的const成员的类,编译器将不会合成默认构造、默认拷贝构造、默认拷贝赋值。

PS:C++11其实是五法则,多了移动构造和移动赋值。

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

条款05已经做过说明,对于不支持delete编译器,至少有两种解决方案:

  • 只声明不实现放在private中
  • private继承

前者仍然存在风险,因为友元和成员函数仍然可以调用,从而产生错误,最好就是使用delete。

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

7.1 任何class只要带有virtual函数,几乎确定也需要一个virtual析构

#include <iostream>

enum class clockType
{
	Atomic,
	Water,
	Wrist
};

class TimeKeeper
{
public:
	
	TimeKeeper() {};
	~TimeKeeper() { std::cout << "~TimeKeeper()" << std::endl; }
	virtual void showTime() { std::cout << "Time show!" << std::endl; }



};

class AtomicClock :public TimeKeeper    //原子钟
{
public:
	~AtomicClock() { std::cout << "~AtomicClock()" << std::endl; }
	void showTime() { std::cout << "Atomic Time show!" << std::endl;  delete pint;}
	int * pint=new int;
};

class WaterClock :public TimeKeeper     //水钟
{
public:
	~WaterClock() { std::cout << "~WaterClock()" << std::endl; }
	void showTime() { std::cout << "Water Time show!" << std::endl; }
}; 

class WristClock :public TimeKeeper     //腕表
{
public:
	~WristClock() { std::cout << "~WristClock()" << std::endl; }
	void showTime() { std::cout << "Wrist Time show!" << std::endl; }


}; 

class TimeKeeperFactory
{
public:
	TimeKeeper* getTimeKeeper(clockType type)//返回一个基类指针
	{
		switch (type)
		{
			case clockType::Atomic:
			{
				return new AtomicClock;
			}
			case clockType::Water:
			{
				return new WaterClock;
			}
			case clockType::Wrist:
			{
				return new WristClock;
			}
			default:
				return nullptr;
		}
	}

};


void UniformFuncShowTime(TimeKeeper * ke)
{
	ke->showTime();
}

int main()
{
	TimeKeeperFactory factory;
	TimeKeeper * Basekeeper = factory.getTimeKeeper(clockType::Atomic);
	UniformFuncShowTime(Basekeeper);
	delete Basekeeper;                      //防止内存泄漏的尝试,事实上仍然存在内存泄漏的可能性。pint声明的int就没法通过派生类析构函数来析构。

}

上述delete只调用了基类的析构函数。和一般的虚函数一样,如果你不将其基类的方法声明为虚函数,那么他将不会进行动态绑定,调用想要的派生类方法。

解决方法也简单,只需要将析构函数声明为虚函数,delete的时候就能执行派生类对象、基类对象两个析构函数了。

7.2 如果class不含virtual,表示他并不想要作为基类

如果你并不打算作为基类,那么声明为虚函数是一个馊主意,没有必要且占空间。只要一个类含有虚函数,那么就就需要额外信息用于判断运行期调用的是哪个虚函数被调用,这份额外的信息通常由vptr(virtual table pointer)指出,这个指针指向一个函数指针构成的数组,即虚函数表(vtable)。比如:

class Point
{
public:
	Point(int xCoord,int yCoord);
	//virtual Point(int xCoord,int yCoord);
	~Point();
private:
	int x,y;
}

上述类占两个int空间,即32*2=64bit空间。假如析构函数声明为虚函数,那么需要额外存储一个指针,对于32bit计算机占用64+32=96bits,对于64bits系统,占用64+64=128bits空间!内存增加了将近50%~100%。

即使class不带任何virtual,也可能被non-virual 函数坑到,如:

SpecialString:public string{}

PS:如何计算类的大小?非静成员+(如果有)虚函数,则要加上固定的4字节(32位系统)的虚函数指针。空类可以实例化,有空间才有地址,有地址才能区分不同空实例。C++为了让空实例有唯一地址,设定空类的大小是1。

7.3纯虚函数

这个概念就不说了,复习一下纯虚函数作用吧!

条款08 别让异常逃离析构函数

C++不禁止析构函数吐出异常,但是不鼓励这么做。

class Widget{
public:
	...
	~Widget(){//这里可能抛出异常}
}
void doSomeThing()
{
	std::vector<Widget> v;
	...
}

Widget在其析构函数定义了一个可能抛出的异常。doSomeThing的std::vector离开作用域后逐元素析构其中的Widget,假设因为某种原因Widget析构都抛出异常,那么C++将会抛出高达十次的异常,有两个异常存在的情况下,程序要么就是结束执行,要么就是不明确行为。抛出异常行为,像是将程序挂起,如果不处理程序将不会继续执行。

出现异常要么处理,要么中止。假设一个class管理数据库连接

class DBConnection
{
public:
	...
	static DBConnection create();
	void close();
};
//一个使用这个数据库的类
class UseDBConnection
{
public:
	...
	~UseDBConnection()
	{
		db.close();
	}
private:
	DBConnection db;
}

为了确保数据库连接总是被关闭,在析构函数中调用了db的close方法。理想情况,close成功,一旦close抛出了异常,析构函数剩余内容将永远不会执行,解决这个问题有两种方法:

  • 抛出异常就中止这个程序。std::abort();
  • 抛出异常就吞下这个异常。记录异常情况
~UseDBConnection()
{
	try{
		db.close();
	}
	catch(...)
	{
		//法一:草率中止
		//std::abort();
		
		//法二:要么吞下这个记录,继续执行
		//logRecord();
	
	}
}

作者认为吞下异常比草率结束要好,因为前者仍可以继续执行,接着作者继续提到了一个更有效的方法:

  • 重新设计DBConnection接口,将close从析构剥离
class UseDBConnection
{
public:
	void close()
	{
		db.close();
		closed=true;
	}
	...
	~UseDBConnection()
	{
		if(!closed){//法三:给与用户关闭自由度,双保险
			try{
				db.close();
			}
			catch(...){
				//法一:草率中止
				//std::abort();
				
				//法二:要么吞下这个记录,继续执行
				//logRecord();
			
			}
		}
	}
private:
	DBConnection db;
	bool closed;
}

条款09 绝不在构造和析构中调用virtual函数

假如你不遵守这个条款,结果可能不符合预期。

//每个交易都需要被记录
//无论你是买入还是卖出
class Transaction
{
public:
	Transaction();
	virtual void logTransaction()const;

};

Transaction::Transaction()
{
	logTransaction();//每次交易都会构造一个Transaction,所以我尝试将其写在构造函数里;期望每个继承
					 //transaction的派生类都调用自己的logTransaction
}

void Transaction::logTransaction()const
{
	std::cout << "一些交易记录" << std::endl;
}

class BuyTransaction :public Transaction
{
public:
	virtual void logTransaction()const;
private:
	int buyMember=43;
};

class SellTransaction :public Transaction
{
public:
	virtual void logTransaction()const;
private:
	int sellMember=96;

};

void BuyTransaction::logTransaction()const
{
	std::cout << "一些购买记录" << std::endl;
	std::cout << "可能只有购买才有的数据 " << buyMember << std::endl;
	
}
void SellTransaction::logTransaction()const
{
	std::cout << "一些销售记录" << std::endl;
	std::cout << "可能只有销售才有的数据 " << sellMember << std::endl;
}


int main()
{
	BuyTransaction b;

}

在基类的构造函数上定义一个虚函数可能导致一些未定义的错误,因此有些编译器可能不允许这样的代码通过编译(有些可能允许)。我们都知道,在构造一个派生类对象时,首先执行的是基类构造,然后才是派生类本身的构造。因为在基类的构造函数定义了一个虚函数logTransaction,那么在构造阶段派生类是属于基类还是派生类显得尤为重要,它决定了最后调用哪一个版本的虚函数。C++认为在一个派生类处于基类构造函数作用阶段,其本质仍属于基类,应该调用基类的虚函数,这样做是妥当的,因为基类构造函数阶段派生类的数据成员还没初始化,执行一些派生类版本虚函数可能会使用一些派生类才有的数据成员,使用这些成员会导致未定义行为。

同理,派生类析构函数在执行基类析构时也会调用基类版本的虚函数。问题出在哪里?我们初衷是想让派生类只执行对应的派生类版本虚函数,而不想执行基类的虚函数(这个日志就会记录一些基类的日志),事实上,只要我在基类定义了虚函数,他就肯定会在派生类构造和析构中执行。

如果你不想调用基类的虚函数方法,请不要在基类的构造和析构中定义虚函数,就算是调用的函数中包含也不行。

其实我们为什么会有在基类定义虚函数的想法,可能是想让每个派生类都执行某个类似的操作,在派生类中特化这样的操作,基类帮助我们完成这样的特化操作的调用。那么我们就要实现这样的想法,如何解决?

作者给出的方法是:在派生类的构造函数中主动上传特化操作所需参数到基类构造函数,然后再通过基类构造函数执行这些操作。

class Transaction
{
	public:
	explicit Transaction(const std::string &logInfo);
	void logTransaction(const std::string &logInfo);
}
BuyTransaction:public Transaction
{
public:
	BuyTransaction(para):Transaction(createLogString(para))
private:
	static std::string createLogString(para);
}

这里有个小trick,将createLogString声明为static,是为了防止尚未完成派生类初始化的时候就使用数据成员。(static成员函数不能使用普通数据成员这一特点)

条款10 令operator= 返回一个reference to *this

重载赋值运算时最好返回同类型结果以支持连续运算,最好按照这个规则来。STL里边的容器基本都是这么做的。

Matrix &operator=(const Matrix rhs)
{
	...
	return *this;
}
int main()
{
	Matrix a(4,4),b(4,4),c(4,4);
	Matrix a=b=c;//ok,每次都返回对象本身 a=(b=c)
}

这个规则不仅适合=,还可以是一切赋值形式,如+= *=

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

一个类实例给自己赋值,叫做自我赋值。

class Widget{...}
Widget w;
...
w=w;

当然不是所有的都这么明显,比如说

vector<Widget> a={...};
a[i]=a[j];          //下标导致的自我赋值
*pa=pb;             //别名导致的自我赋值 pa pb

那会出现什么问题?当你自己管理一个拥有动态资源的类时,在拷贝赋值运算符operator=实现可能会意外释放两次资源。还是以书中的例子为例:

class Bitmap{...} //假设Bitmap中已经定义了一个接受自身类型的拷贝构造
class Widget{
	...
private:
	Bitmap *pb;
}

假如我们的拷贝赋值运算符operator =定义如下:

Widget & Widget::operator=(const Widget &rhs){
	delete pb;              //因为有动态资源,首先应该释放掉原有的资源
	pb=new Bitmap(*rhs.pb); //调用拷贝构造
	return *this;
}

正常来说,只要是不指向同一个都没有啥问题。整个流程就是:先删左侧资源,根据右侧调用拷贝构造,指针指向新资源。All is well!可是他没有处理好自我赋值这个潜在威胁:

Widget w;
Widget *pw1=&w;
Widget *pw2=&w;
*pw1=*pw2;//不这么明显的赋值语句

流程删除左侧资源的同时,将右侧资源错误删除了!也就是右侧调用拷贝构造没有其实利用的是一块已经释放掉内存的资源进行构造,显然这是一个很大的缺陷。

作者提供了三种方法:证同测试(Identity test)、异常安全性(exception safety)和拷贝交换技术(copy and swap)

证同测试是一个传统的做法

Widget & Widget::operator=(const Widget &rhs){
	if(this==&rhs) return *this;
	delete pb;              //因为有动态资源,首先应该释放掉原有的资源
	pb=new Bitmap(*rhs.pb); //调用拷贝构造
	return *this;
}

异常安全性就是合理借助程序设计完成的:

Widget & Widget::operator=(const Widget &rhs){
	Bitmap *pOrig=pb;	    //记住这个位置后边再删除	
	pb=new Bitmap(*rhs.pb); //调用拷贝构造
	delete pOrig;			//释放这个类本身的资源
	return *this;
}

异常安全这种方式额外调用了一个构造函数,效率上反而没有一个证同测试高。

无论是证同测试,还是异常安全都存在效率上的问题,使用拷贝交换技术是一种常见且够好的一种处理自我赋值的方法

Widget & Widget::operator=(const Widget &rhs){
	Widget temp(rhs);
	swap(temp);
	return *this;
}

条款12 复制对象时勿忘其每一部分

书上的例子讲的比较复杂,事实上就是提醒你如果你尝试定义一个copying函数(拷贝构造和赋值构造函数),不要忘记拷贝所有成员。

class A
{
public:
    A() {}
    A(const A& a ):i(a.i){ std::cout << "Copy!" << std::endl; }//特定不对j进行拷贝,编译器发出警告
private:
    int i = 0;
    int j = 0;
};

编译器认为:拷贝的行为完全由用户定义,就算是缺失也是用户故意为之。这不算错误,因此也不会发出警告。最重要的一点是你的基类自定义了一个拷贝构造函数,那么派生类也必须自己做拷贝构造工作,毕竟编译器认为这一部分已经由用户完成了,不需要我做一些默认调用基类拷贝构造的行为了。换句话说,若基类定义了拷贝构造,派生类由于没有编译器默认调用基类拷贝构造行为而没有对基类进行成员拷贝,这可能是一种设计缺陷,所以:

class D_A:public A
{
	public:
		D_A(const &D_A da):A(da),k(da.k){}
	private:
		int k=0;
}

最后作者提到不要尝试在拷贝构造函数中调用赋值构造函数进行简化,也不要在赋值构造函数中调用拷贝构造进行简化。因为,拷贝构造函数的语义是构造一个新的对象,而赋值构造则是更新现有对象。