「C/C++技术基础」类的构造、析构与赋值运算

C++ Primer 中描述类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程技术。类的接口包括用户能执行的操作;类的实现包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊成员函数来控制对象的初始化过程,构造函数(constructor)用来初始化类对象的数据成员,只要类的对象被创建就会执行构造函数。此外,类还需要控制拷贝、赋值和销毁对象的行为。

对象使用前必须先初始化

  • 对于 C part of C++ 中定义一个变量或 array 数组不保证被初始化,即我们在使用之前必须对其进行初始化
  • 对于 STL part of C++ 中定义一个如 vector 数组,其内容是被初始化的
  • 对于一个类对象的初始化,我们需要保证其构造函数将对象的每一个成员初始化
class PhoneNumber { };
class ABEntery {
private:
    string theName;
    string theAddress;
    list<PhoneNumber> thePhones;
    int numTimesConsulted;
public:
    ABEntery(const string& name, const string& address, 
             const list<PhoneNumber>& phones);
};

在构造函数内部对成员进行赋值不是初始化,这种做法成员的初始化是首先调用成员的默认构造函数对成员进行了初始化,然后再对其进行的赋值。但是 numTimesConsulted 属于内置类型,其初始化和我们上面说的时间点不同。
针对这种情况我们可以使用成员初值列表的形式来对成员初始化,考虑到一致性我们将 numTimesConsulted 也写在成员初值列表中。

ABEntery::ABEntery(const string& name, const string& address, const list<PhoneNumber>& phones)
    : theName(name),
      theAddress(address), 
      thePhones(phones),
      numTimesConsulted(0)
      { }

C++ 中有着固定的成员初始化次序:base class 的成员比 derived class 的成员更早初始化。class 的成员变量按照声明的次序被初始化,即使成员初值列表的顺序不同也是按声明次序初始化。

C++ 对定义在不同编译单元的 non-local static 对象的初始化没有明确定义,我们可以将每个 non-local static 对象搬到自己的函数中,函数返回一个 reference 指向的所含的对象。

class FileSystem { 
public:
    size_t numDisks() { return 0; } 
};
FileSystem& tfs()
{
    static FileSystem fs;       // 定义并初始化一个 local static 对象
    return fs;                  // 返回一个 reference 指向上述对象
}
class Directory { 
    int a;
public:
    Directory() { }
    Directory(int num);
};
Directory::Directory(int num)
{
    size_t disks = tfs().numDisks();    // 原本的 tfs 对象改为 tfs()
}
Directory& tempDir()
{
    static Directory td;
    return td;
}

了解 C++ 默认编写的函数

如果一个 empty class 编译后,编译器会为它声明一个默认构造函数、拷贝构造函数、拷贝赋值操作符和析构函数。

class Empty { };    // 只有上述的这些函数在被调用时,编译器才会创建出来

如果声明了一个构造函数,编译器就不会再创建构造函数。对于内含 reference、const 成员的类,必须自己定义一个拷贝构造函数和拷贝复制操作符。

template<typename T>
class NamedObject {
public:
    NamedObject(const char* name, const T& value);
    NamedObject(const string& name, const T& value);
private:
    string nameValue;
    T objectValue;
};

注意编译器默认创建的拷贝构造函数和拷贝赋值操作符都是浅拷贝

class StringBad {
private:
	int str_len;
	char* str;
};
StringBad sailor = sport;

上述代码是会出现浅拷贝的错误的。默认拷贝构造函数是按值进行复制的。这里复制的不是字符串,而是一个指向字符串的指针。也就是说,将 sailor 初始化为 sports 后,得到的是两个指向同一个字符串的指针。当析构函数被调用时,这将引发问题。析构函数释放 sailor 的 str 指针指向的内存,而之后程序释放 sports.str 指向的内存已经被 sailor 的析构函数释放,这将导致不确定的后果。解决方法是定义一个显示拷贝构造函数

StringBad::StringBad(const StringBad& rhs)
{
	str_len = rhs.str_len;
	str = new char[str_len];
	std::strcpy(str, rhs.str);
}

必须定义拷贝构造函数的原因在于:一些类成员是使用new初始化的、指向数据的指针,而不是数据本身

Note:

类中包含了使用new初始化的指针成员,应当定义一个拷贝构造函数,以复制指向的数据,而不是指针,这被称为深度复制(即深拷贝)。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅拷贝仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。

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

一般来说,如果我们不想使用某函数,只要不声明即可。但是对于 class 的拷贝构造函数和拷贝赋值操作符,如果不声明编译器就会自动声明。因此我们可以自己声明一个拷贝构造函数和拷贝赋值操作符,让它们的属性为 private,这样就可以阻止他人调用这个类的拷贝函数。但是这样做也有可能在成员函数或友元函数中被人调用,所以我们应该将这两个函数进行声明而不定义它

class HomeForSale {
private:
    HomeForSale (const HomeForSale& );              // 只有声明
    HomeForSale& operator=(const HomeForSale&);
};

此外我们还可以专门定义一个阻止 copy 函数的 base class
在需要调用 copy 函数时,编译器会尝试调用 base class 中的拷贝函数,但是此时的属性为 private,所以调用会被编译器拒绝。

class Uncopyable {
protected:
    Uncopyable()    { }
    ~Uncopyable()   { }
private:
    Uncopyable(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
};
class HomeFOrSale1 : private Uncopyable{
private:
    int value;
};

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

C++ 明确指出,当 derived class 对象由一个 base class 指针删除,而该 base class 带有一个 non-virtual 析构函数,其结果是未定义的,执行时通常是 derived 部分没有被销毁。因此,我们可以给 base class 一个 virtual 析构函数。

如果 class 不用做基类,其析构函数则不需要使用 virtual。一个经验是:在 class 中至少含有一个 virtual 函数,才声明 virtual 析构函数。

这种规则只适用于带多态性质的基类上(polymorphic base classes),例如 std::string 和 STL 容器都不被作为 base class 使用。

class TimeKeeper {
public:
    TimeKeeper();
    virtual ~TimeKeeper();
};
class WatchTime : public TimeKeeper { };
TimeKeeper* getTimeKeeper();        // 返回一个指向 TimeKeeper 派生类的动态分配对象
void getTime()
{
    TimeKeeper* ptk = getTimeKeeper();
    delete ptk;     // 调用先调用派生类析构函数,然后调用基类析构函数
}

pure virtual 函数可以使抽象类不能实体化,即不能为这种类创建对象。当我们希望一个 base class 成为抽象类,而此时没有任何 pure virtual 函数。因此我们可以使用 pure virtual 析构函数来时这个类称为抽象类。

class AWOV {
public:
    virtual ~AWOV() = 0;    // 声明 pure virtual 析构函数
};
AWOV::~AWOV()   { }         // 此时必须为析构函数提供定义

别让异常逃离析构函数

C++ 并不禁止析构函数吐出异常,但不鼓励这样做。因为对于使用容器或 array 存储的类可能会同时抛出两个异常,这种情况下程序会过早结束或导致不明确行为。

如果析构函数必须执行一个动作,而这个动作可能会抛出异常。例如使用一个 class 来负责数据库的连接:

class DBConnection {
public:
    static DBConnection create();   // 返回 DVConnection 对象
    void close();                   // 关闭连接;失败时则抛出异常
};

为了使客户不会忘记在 DBConnection 身上调用 close(),我们可以创建一个管理 DBConnection 资源的 class,并在析构函数中调用 close()。如果调用 close() 导致异常,两种方法可以避免这种问题。

  1. 如果 close() 抛出异常就结束程序,通常调用 abort() 完成

如果遇到一个“析构过程中发生的错误”无法继续执行,可以强迫关闭程序,防止异常从析构函数中泄漏出去,产生不明确行为。

class DBConn {
public:
    ~DBConn() {         // 确保数据库连接总是会被关闭
        try { db.close(); }
        catch () {
            std::abort();
        }
    }
private:
    DBConnection db;
};
  1. 吞下因调用 close() 而发生的异常

吞下异常不是一个明确的选择,但却比产生不明确行为要好

~DBConn() {
    try { db.close(); }
    catch () {
        std::abort();
    }
}

一个最佳的策略是重新设计 DBConn 接口,使其客户有机会对可能出现的问题作出反应,即客户可以手动调用 close 也可以不调用。

class DBConn {
public:
    void close() {
        db.close();
        colsed = true;
    }
    ~DBConn() {
        if (!closed) {
            try { db.close(); }
            catch () {
                // 可以结束程序,也可以吞掉异常
            }
        }
    }
private:
    DBConnection db;
    bool closed;
};

总结

  • 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕获该异常,然后吞下异常或者结束程序
  • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,class 应该提供一个普通函数执行该操作。

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

在构造函数和析构函数中调用 virtual 函数可能不会带来预期结果,对于 Java 和 C# 更应该注意这条规则。

假设一个 class 继承体系,用来塑膜股市交易如买进、卖出的订单等等。每当创建一个交易对象,在审计日志中也要创建一笔适当记录。下面是一个错误的代码实现

class Transaction {      // 所有交易的 base class
public:
    Transaction();
    virtual void logTransaction() const = 0;     // 不同类型的日志记录
};
Transaction::Transaction()
{
    logTransaction();    // 创建交易后记录日志
}

class BuyTransaction : public Transaction {        // 买进交易的 derived class
public:
    virtual void logTransaction() const;         // log 该类型的交易
};
class SellTransaction : public Transaction {      // 卖出交易的 derived class
public:
    virtual void logTransaction() const;         // log 该类型的交易
};

在执行 BuyTransaction b; 后,显然会调用一个 BuyTransaction 类的构造函数,但首先 Transaction 构造函数更早被调用,derived class 对象内的 base class 成分会在 derived class 自身成分构造之前被构造出来Transaction 构造函数的 virtual 函数 logTransaction 是 base class 内的版本,不是 derived class 的版本。base class 构造期间 virtual 函数绝不会下降到 derived class 层。通俗来讲,base class 构造函数中的 pure virtual 函数不属于 virtual 函数

如果在 base class 构造期间 virtual 函数下降到 derived class 层,derived class 成员函数几乎使用 local 成员变量,这样就造成了在 base class 中使用了 derived class 中的成员变量,而此时的成员变量并没有被初始化。

在 derived class 对象的 base class 构造期间,对象的类型是 base class 而不是 derived class。不只是 virtual 函数会被编译器解析至 base class,若使用运行期类型信息(如 dynamic_cast 和 typeid)也会把对象视为 base class。所以对象在 derived class 构造函数开始执行前不会成为一个derived class 对象。

为了确保在每次有 Transaction 继承体系上的对象被创建,就有适当版本的 logTransaction 函数被调用。一种做法是在 class Transaction 中将 logTransaction 函数改为 non-virtual,然后要求 derived class 构造函数传递必要信息给 Transaction 构造函数。

class Transaction {
public:
    explicit Transaction(const string& logInfo);
    void logTransaction(const string& logInfo) const;
};
Transation::Transaction(const string& logInfo)
{
    logTransaction(logInfo);    // 创建交易后记录日志,现在是 non-virtual 函数
}

class BuyTransaction : public Transaction {
public:
    BuyTransation(const string& logInfo) : Transaction(creatLogString(logInfo)) { }
private:
    static string creatLogString(const string& logInfo);
};

令 operator= 返回一个 reference to *this

为了实现“连锁赋值”,赋值操作符必须返回一个 reference 指向操作符的左侧实参。

class Widget {
public:
    Widget& operator+=(const Widget& rhs) {
    	// ...
        return *this;
    }
    Widget& operator=(int rhs) {
    	// ...
        return *this;
    }
};

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

如果使用一个 class 来管理资源,可能会在停止资源之前被释放。假设用一个 class 来保存一个指针指向一块动态分配的位图(bitmap)

class BitMap() { };
class Widget {
private:
    BitMap* pb;     // 指向一个从 heap 分配的对象
};

对于赋值操作函数 operator= 内的 *this(赋值的目的端)和 rhs 有可能是一个对象,这种情况下 delete 就不只是销毁当前对象的 bitmap,也可能会销毁 rhs 的 bitmap。

Widget& Widget::operator=(const Widget& rhs)
{
    if (this == rhs)    return *this;   // 证同测试(identity test),如果自我赋值就什么也不做
    delete pb;                          // 停止使用当前的 bitmap
    pb = new BitMap(*rhs.pb);           // 使用 rhs.bitmap 副本
    return *this;
}

上面的代码在“异常安全性”方面还有问题,如果 new BitMap 导致异常,Widget 最终会持有一个指针指向一块被删除的 BitMap。以下代码只要注意复制 pb 所指东西之前别删除 pb:

Widget& Widget::operator=(const Widget& rhs)
{
    BitMap* pOrig = pb;             // 记住原先的 pb
    pb = new BitMap(*rhs.pb);       // 令 pb 指向 rhs 的副本
    delete pOrig;
    return *this;
}

此外,还有一种解决方案是使用 copy and swap 技术:

class Widget {
	void swap(Widget& rhs);		// 交换 *this 和 rhs 的数据
};
Widget& Widget::operator=(const Widget& rhs)
{
	Widget tmp(rhs);			// 为 rhs 拷贝一份副本
	swap(tmp);					// 将 *this 和副本数据交换
	return *this;
}

总结:

  • 确保对象自我赋值是 operator= 由良好行为
  • 确定任何函数操作一个以上的对象,而多个对象是用一个对象时其行为仍然正确
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值