考虑如下一个类:
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(),析构函数
所以在写一个类的时候,如果没有自定义这几个函数的话,编译器会自动为我们创建,这些默认的函数也有自己默认的操作内容。
- 当类中有const或引用的成员变量时,编译器拒绝自动生成赋值函数
- 如果父类中的赋值函数被声明为private,那么编译器拒绝为子类生成一个赋值函数。
总结
编译器可以暗自为class创建默认构造函数、拷贝构造函数、赋值函数、以及析构函数。
在项目开发中,我们会经常编写一些复杂的类、或者单例,或者某些类从业务逻辑上是不允许被拷贝和赋值的。那么这个时候,如果不去明确拒绝,那么正如条款05所描述,编译器会自动为我们生成这些函数。所以如果不想这样做的话,我们需要明确拒绝。
那么如何明确拒绝呢,一般有两种方法:
class HomeForSale {
public:
...
private:
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&);
}
如上在头文件中仅仅声明它即可,不用去实现。主要原因如下:
- 使用private修饰为了防止外部进行调用
- 不去实现,是为了防止在内部的成员函数或者friend函数内调用。
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这样的父类也是一种做法。
看如下的一个例子:
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类中申请了一些其他类的实例,那么显然的,这会出现内存泄漏的问题。
如果一个类的设计目的不是用来作为基类的,那么我们最好不应该声明虚函数(包括虚析构函数)。
这是因为当我们声明虚函数的时候,申请出来的对象中含有一个vptr,它指向了一个虚函数表(vpbl),它存储了类中每一个虚函数的函数指针地址。所以在我们每申请一次这个对象就会多出一个占4字节(32位)的vptr。这无疑是增加了内存的开销。
在实际开发中,我们可能会写出这样的代码:
class MyString : public std::string {
}
这样的写法是危险的,因为std::string类并没有声明自己析构函数为virtual。如果发生1所描述的情况,那么就会出现问题。
如果你的基类的虚函数本身什么都不做,可以将其声明为纯虚函数:
class AWOV {
public:
virtual ~AWOV() = 0;
}
它的好处在于,我们知道先构造的后析构,后构造的先析构。那么编译器在先析构派生类的时候,如果发现派生类没有定义虚析构函数,那么链接器就会发出错误信息。这有助于我们提前知道自己所编写的代码的问题所在。
总结
- polymorphic(带多态性质的)base classes 应该声明一个virtual析构函数,如果class带有任何virtual函数,它就应该拥有一个virtual析构函数。
- Classes的设计目的如果不是作为base classes使用,或不是为了具备多态性(polymorphically),就不该声明virtual析构函数。
考虑如下代码:
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将异常吞掉,防止出现程序异常退出的情况。
这种双保险时解决异常逃离析构函数的方法。
总结
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么 class应该提供一个普通函数(而非在析构函数中)执行该操作。
且看如下实例:
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);
};
- 声明LogTransaction为非虚函数。
- 将变化的部分作为LogTransaction的形参传递给父类。
- 使用static函数让初始化的实参是已经定义的。
使用用static方法,同样是因为在传递参数的时候,该实参如果作为派生类的成员变量传递的话,此成员变量并未被初始化,同样会出问题。所以使用createLogString静态方法,确保传递的实参是已经被定义的。
当然以上只是举个例子,在实际开发中我们可能不会这样去写。
总结
在构造和析构期间不要调用virtual函数,因为这类调用从不下降至derived class(比起当前执行构造函数和析构函数的那层)。
该条款说明的是一种大家都遵循的协议,即我们在class中实现operator=、+=、-=、*=等操作符的时候,最好使它的返回类型是一个reference to *this。
class widget {
public:
widget& operator= (const widget& other) {
...
return *this;
}
}
总结
令赋值(assignment)操作符返回一个reference to *this。
该条款使用最经典的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对象的内容。
总结
- 确保当前自我赋值时operator=有良好行为,其中技术包括比较”来源对象“和”目标对象“的地址、精心周到的语句顺序、以及copy-and-swap。
- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
该条款表明的是,在实现拷贝构造函数和赋值函数时,我们需要考虑到类中的每一个成员是否被拷贝了,根据业务逻辑,我们需要使用的时浅拷贝还是深拷贝。
同时,当该类时子类的时候,需要考虑到父类的成员变量是否被拷贝到。
总结
Copying函数应该确保复制”对象内的所有成员变量“及”所有base class成分“。
不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数中,并由两个coping函数共同调用。