Effective C++ 之《构造/析构/赋值运算》

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

  考虑如下一个类:

class Empty{};

  这个类其实等价于:

class Empty {
public:
	Empty();
	Empty(const Empty& other);
	~Empty();
	
	Empty& operator=(const Empty& other);
}

  也就是说,当我们编写一个空类时,编译器会在我们调用这些函数时,自动为我们创建出来。那这些函数对应我们平时做的哪些操作呢:

{
	Empty a1;		//调用Empty(),默认构造函数
	Empty a2(a1);	//调用Empty(const Empty& other),拷贝构造函数
	a2 = a1;  		//调用Empty& operator=(const Empty& other),赋值函数
}					//作用域结束,调用~Empty(),析构函数

  所以在写一个类的时候,如果没有自定义这几个函数的话,编译器会自动为我们创建,这些默认的函数也有自己默认的操作内容。

  1. 当类中有const或引用的成员变量时,编译器拒绝自动生成赋值函数
  2. 如果父类中的赋值函数被声明为private,那么编译器拒绝为子类生成一个赋值函数。

总结

编译器可以暗自为class创建默认构造函数、拷贝构造函数、赋值函数、以及析构函数。

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

  在项目开发中,我们会经常编写一些复杂的类、或者单例,或者某些类从业务逻辑上是不允许被拷贝和赋值的。那么这个时候,如果不去明确拒绝,那么正如条款05所描述,编译器会自动为我们生成这些函数。所以如果不想这样做的话,我们需要明确拒绝。

  那么如何明确拒绝呢,一般有两种方法:

  1. 类中声明拷贝构造函数和赋值函数为private,且不实现它们.
class HomeForSale {
public:
	...
private:
	HomeForSale(const HomeForSale&);
	HomeForSale& operator=(const HomeForSale&);
}

  如上在头文件中仅仅声明它即可,不用去实现。主要原因如下:

  1. 使用private修饰为了防止外部进行调用
  2. 不去实现,是为了防止在内部的成员函数或者friend函数内调用。
  1. 如果这种类过多,那么需要对每一个类做这样的操作,这样会比较麻烦。有一种方式是编写一个父类,让拥有这种属性的类继承父类,从而达到它们的实例不允许拷贝的效果。
class Uncopyable {
protected:
	Uncopyable();			//不可实例化
	~Uncopyable();		
private:
	Uncopyable(const Uncopyable&);
	Uncopyable& operator=(const Uncopyable&);	
}

  这样我们只需要HomeForSale 类继承Uncopyable即可:

class HomeForSale : public Uncopyable {
	...
}

  这种方式比较常用,boost中有noncopyable提供了该功能:

#ifndef BOOST_NONCOPYABLE_HPP_INCLUDED  
#define BOOST_NONCOPYABLE_HPP_INCLUDED  

namespace boost {  

//  Private copy constructor and copy assignment ensure classes derived from  
//  class noncopyable cannot be copied.  

//  Contributed by Dave Abrahams  

namespace noncopyable_  // protection from unintended ADL  
{  
  class noncopyable  
  {  
   protected:  
      noncopyable() {}  
      ~noncopyable() {}  
   private:  // emphasize the following members are private  
      noncopyable( const noncopyable& );  
      const noncopyable& operator=( const noncopyable& );  
  };  
}  

typedef noncopyable_::noncopyable noncopyable;  

} // namespace boost  

#endif  // BOOST_NONCOPYABLE_HPP_INCLUDED  

总结

为驳回编译器自动提供的功能,可以将相应的成员函数声明为private并且不予以实现。使用像Uncopyable这样的父类也是一种做法。

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

1. 带多态性质的父类,应该声明一个virtual析构函数

   看如下的一个例子:

class A {
	A();
	~A();
};

class B : public A {
	B();
	~B();
}

A *a = new B();
delete a;

  如上类B是类A的派生类,在实例化的时候使用A类型的指针指向B生成的对象,那么在析构的时候会出现什么情况呢。

  答案是类B的析构函数没有被调用。这是为什么呢?这是因为C++明确指出,当派生类对象经由一个父类指针删除,而该父类带着一个非虚析构函数,其结果是未定义的。也就是派生类对象析构函数未被调用。

  从另外一个角度思考也是合理的,程序在调用析构函数的时候,此时该指针是A类的,但实际指向B的实例。那么它首先会调用覆盖析构函数的派生类B的析构函数,但是A的析构函数未声明为虚函数,那么就不存在覆盖它的析构函数了,所以派生类B的析构函数未能执行。

  如果B的析构函数未被执行,那就意味这,对象未全部销毁。如果B类中申请了一些其他类的实例,那么显然的,这会出现内存泄漏的问题。

2. 类的设计目的不作为父类使用,不该声明virtual函数

  如果一个类的设计目的不是用来作为基类的,那么我们最好不应该声明虚函数(包括虚析构函数)。

  这是因为当我们声明虚函数的时候,申请出来的对象中含有一个vptr,它指向了一个虚函数表(vpbl),它存储了类中每一个虚函数的函数指针地址。所以在我们每申请一次这个对象就会多出一个占4字节(32位)的vptr。这无疑是增加了内存的开销。

3. 不要继承一个没有声明虚析构函数的类

  在实际开发中,我们可能会写出这样的代码:

class MyString : public std::string {
}

  这样的写法是危险的,因为std::string类并没有声明自己析构函数为virtual。如果发生1所描述的情况,那么就会出现问题。

4. 纯虚函数

  如果你的基类的虚函数本身什么都不做,可以将其声明为纯虚函数:

class AWOV {
public:
	virtual ~AWOV() = 0;
}

  它的好处在于,我们知道先构造的后析构,后构造的先析构。那么编译器在先析构派生类的时候,如果发现派生类没有定义虚析构函数,那么链接器就会发出错误信息。这有助于我们提前知道自己所编写的代码的问题所在。

总结

  1. polymorphic(带多态性质的)base classes 应该声明一个virtual析构函数,如果class带有任何virtual函数,它就应该拥有一个virtual析构函数。
  2. Classes的设计目的如果不是作为base classes使用,或不是为了具备多态性(polymorphically),就不该声明virtual析构函数。

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

  考虑如下代码:

class Widget {
public:
~Widget() {    // 假定这个析构函数可能会吐出异常
};
void doSomething() {
	std::vector<Widget> v;
}

   这里如果在析构函数中对vector中的第一个元素析构时,抛出异常,那么就会导致程序结束执行或者出现不明确行为。
  对于这种情况,一般情况采用如下方式:

class DBConn {
public:
	void close() {
		db.close();
		closed = true;
	}
	~DBConn() {
		if(!closed) {
			try {
				db.close();
			}
			catch {...} {}
		}
	}
private:
	DBConnection db;
	bool		 closed;
};

  如上代码所示,它是一个在析构函数中关闭数据库链接的操作,为了防止db.close()函数在析构函数中发生异常,可以定义一个外部接口,供用户自己去关闭数据库连接,将异常的情况交给用户进行处理。

  但是如果用户忘记调用close,在析构函数中为了保险期间,需要try catch将异常吞掉,防止出现程序异常退出的情况。

  这种双保险时解决异常逃离析构函数的方法。
总结

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

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

  且看如下实例:

class Transaction {
public:
	Transaction();
	virtual void LogTransaction() const = 0;
	...
};

Transaction::Transaction() {
	...
	LogTransaction();
}

class BuyTransaction : public Transaction {
public:
	virtual void LogTransaction() const;
};

class SellTransaction : public Transaction {
public:
	virtual void LogTransaction() const;
};

  这是一个股票买进卖出的系统,不同的操作都会记录自己的日志信息。在Transaction父类的构造函数中调用了LogTransaction() 虚成员方法。这种情况会出现什么问题呢。

链接器会报错,无法找到LogTransaction定义的版本。

  我们知道当实例化BuyTransaction对象的时候,程序先调用父类Transaction的构造方法,此时BuyTransaction对象并没有被初始化,这个时候调用了LogTransaction虚方法并不是BuyTransaction的实现版本。就算是BuyTransaction的实现版本,那么BuyTransaction的构造方法没有执行,也就是类中的成员变量未初始化,这个时候BuyTransaction方法中如果使用了未初始化的成员变量,同样会使程序运行出现问题。

  同样的在析构函数中,如果在父类调用了虚函数,也会出现问题。因为父类的析构函数执行顺序在派生类之后。如果在父类的析构函数中调用了虚函数,此时,派生类中的成员变量已经变成未定义状态,这样同样会造成程序执行到不可知的方向。

  有时候我们也会这样做:

class Transaction {
public:
	Transaction() {
		Init();
	}
	virtual void LogTransaction() const = 0;
	...
private:
	void Init() {
		LogTransaction();
	}
};

  这种情况和上面的一样会有问题,因为其本质上还是在构造函数中出现了对虚函数的调用。

  所以我们在coding的时候一定要注意构造函数中是否有对虚函数的调用,这样的做法使相当危险的。
  那么有什么办法解决这种问题呢。

class Transaction {
public:
	explicit Transaction(const std::string& logInfo);
	void LogTransaction(const std::string& logInfo) const;
	...
};

Transaction::Transaction(const std::string& logInfo) {
	...
	LogTransaction(logInfo);
}

class BuyTransaction : public Transaction {
public:
	BuyTransaction(parameters) 
		: Transaction(createLogString(parameters)){
	}
private:
	static std::string createLogString(parameters);
};
  1. 声明LogTransaction为非虚函数。
  2. 将变化的部分作为LogTransaction的形参传递给父类。
  3. 使用static函数让初始化的实参是已经定义的。

  使用用static方法,同样是因为在传递参数的时候,该实参如果作为派生类的成员变量传递的话,此成员变量并未被初始化,同样会出问题。所以使用createLogString静态方法,确保传递的实参是已经被定义的。

  当然以上只是举个例子,在实际开发中我们可能不会这样去写。

总结

在构造和析构期间不要调用virtual函数,因为这类调用从不下降至derived class(比起当前执行构造函数和析构函数的那层)。

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

  该条款说明的是一种大家都遵循的协议,即我们在class中实现operator=、+=、-=、*=等操作符的时候,最好使它的返回类型是一个reference to *this。

class widget {
public:
	widget& operator= (const widget& other) {
		...
		return *this;
	}
}

总结

令赋值(assignment)操作符返回一个reference to *this。

条款11:在operator= 中处理"自我赋值"

  该条款使用最经典的String拷贝面试题来解释会更好一些。这个在《剑指Offer》中也提到过的面试题。

  关于这个面试题,基本的解法如下:

EMyString& EMyString::operator = (const EMyString& str) {
	if (this == &str) {
		return *this;
	}

	delete[] m_pData;
	m_pData = nullptr;
	m_nLen = str.Len();
	m_pData = new char[m_nLen];
	memcpy(m_pData, str.m_pData, m_nLen);
	return *this;
}

  那么我们看到的:

	if (this == &str) {
		return *this;
	}

  就是条款中所说的,在operator =中处理自我赋值。因为如果不处理的话,很可能在后面delete[] m_pData,delete的是自己,这会出现很严重的问题。

  所以这个条款在我们的实际开发中要谨记。

  条款中也提到了另一个风险:

	delete[] m_pData;
	m_pData = nullptr;
	m_nLen = str.Len();
	m_pData = new char[m_nLen];

  m_pData = new char[m_nLen];这句可能出现的风险是,当内存不足时,可能申请不到这块内存。但是此时我们已经将this对象中的数据释放掉了,此时等于破坏掉了原始的this对象,这就会出现异常安全。所以有更好的实现方式如下:

const EMyString& EMyString::operator=(const EMyString& str) {
	if (this != &str) {
		EMyString strTmp(str);
		char* tempData = strTmp.m_pData;
		strTmp.m_pData = m_pData;				//作用域之后调用析构释放
		m_pData = tempData;						//tempData是在EMyString构造函数中申请的内存
	}

	return *this;
}

  这里使用交换的方式,先申请临时的strTmp实例,然后通过str拷贝构造出来的对象内容与this对象内容进行交换。当出了if作用域后,它会释放原本this对象中的m_pData。这样当EMyString拷贝构造函数中如果申请不到内存的话,也不会破坏原来this对象的内容。

总结

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

条款12:复制对象时勿忘其每一个成分

  该条款表明的是,在实现拷贝构造函数和赋值函数时,我们需要考虑到类中的每一个成员是否被拷贝了,根据业务逻辑,我们需要使用的时浅拷贝还是深拷贝。

  同时,当该类时子类的时候,需要考虑到父类的成员变量是否被拷贝到。

总结

Copying函数应该确保复制”对象内的所有成员变量“及”所有base class成分“。
不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数中,并由两个coping函数共同调用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章