1. 程式人生 > 實用技巧 >【C++】《Effective C++》第二章

【C++】《Effective C++》第二章

第二章 構造/析構/賦值運算

條款05:瞭解C++默默編寫並呼叫哪些函式

預設函式

一般情況下,編譯器會為類預設合成以下函式:default建構函式copy建構函式non-virtual解構函式拷貝賦值(copy assignment)操作符

class Empty {};

// 等價於

class Empty {
public:
    Empty() { } // default建構函式
    Empty(const Empty& rhs) { } // copy建構函式
    ~Empty() { } // non-virtual解構函式,是否該為virtual呢?
    Empty& operator=(const Empty& rhs) { }  // copy assignment操作符
};

Empty e1;   // default建構函式
Empty e2(e1);   // copy建構函式
e2 = e1;    // copy assignment操作符
  • copy建構函式:預設版本會單純地將來源物件的每一個non-static成員變數拷貝到目標物件。
template<class T>
class NamedObject {
public:
    NamedObject(const char* name, const T& value);
    NamedObject(const std::string& name, const T& value);   // 自定義了建構函式,編譯器不會生成default建構函式,reference-to-const

private:
    std::string nameValue;  // non-reference
    T objectValue;  // non-const
};

NamedObject<int> no1("Smallest Prime Number", 2);
NamedObject<int> no2(no1);  // 呼叫預設copy建構函式,其中no2.nameValue的初始化是呼叫string的copy建構函式並以no1.nameValue為實參;no2.objectValue會以"拷貝no1.objectValue內的每一個bits"來完成初始化;
  • copy assignment操作符:行為同copy建構函式類似,但是以下情況編譯器不會預設合成copy assignment操作符
    • 含有引用成員:原因在於這種情況下,賦值的目的不明確。是修改引用還是修改引用的物件?如果是修改引用,顯然是C++不允許的。
    • 含有const成員:C++規定const成員不應該被修改。
    • 父類的copy assignment操作符被宣告為private:無法處理基類物件,因此無法合成。
template<class T>
class NamedObject {
public:
    NamedObject(std::string& name, const T& value); // reference-to-non-const

private:
    std::string& nameValue; // reference
    const T objectValue;    //const
};

std::string newA("A");
std::string newB("B");
NamedObject<int> a(newA, 2);
NamedObject<int> b(newB, 20);
a = b;  // error

請記住

  • 編譯器預設為class建立default建構函式copy建構函式non-virtual解構函式拷貝賦值(copy assignment)操作符

條款06:若不想使用編譯器自動生成的函式,就該明確拒絕

有的情況下,例如iostream類物件,物件是獨一無二的,所以應該拒絕物件拷貝動作。

一般情況下,不宣告相應函式就可以拒絕,但是我們知道,編譯器會為類預設合成一些函式,因此需要顯式拒絕。

通常有兩個方法可以實現這個需求:

  • 將預設合成函式宣告為private,並且不定義。
class HomeForSale {
public:
    // ...

private:
    // ...
    HomeForSale(const HomeForSale&);
    HomeForSale& operator=(const HomeForSale&);
};

顯然,上述的實現member函式和friend函式還是可以呼叫,這就容器發生連結錯誤。可以將預設合成函式放在一個基類中,它繼承這個基類,這樣的話,這些函式的"編譯器合成版"會嘗試呼叫其基類的對應兄弟,但是這些呼叫會被編譯器拒絕,因為是private

class UnCopyable {
protected:
    UnCopyable() {}
    ~UnCopyable() {}
private:
    UnCopyable(const UnCopyable&);
    UnCopyable& operator=(const UnCopyable&);
};

class HomeForSale: private UnCopyable {
    // ...
};
  • C++11中可使用delete,但是解構函式不能是刪除的成員。
class HomeForSale {
public:
    // ...
    HomeForSale() = default;
    HomeForSale(const HomeForSale&) = delete;
    HomeForSale& operator=(const HomeForSale&) = delete;
    ~HomeForSale() = default;
};

請記住

  • 當不可能拷貝、賦值或銷燬類的成員時,類的相應函式就應該被定義為刪除的。可以通過將預設合成函式宣告為private,並且不定義,或者使用C++11標準實現。

條款07:為多型基類宣告virtual解構函式

多型基類應該含有virtual函式

class TimeKeeper {
public:
    TimeKeeper();
    ~TimeKeeper();
    // ...
};

class AtomicClock: public TimeKeeper { }    // 原子鐘
class WaterClock: public TimeKeeper { } // 水鍾
class WristWatch: public TimeKeeper { }    // 腕錶

TimeKeeper* ptk = getTimeKeeper();  // 從TimeKeeper繼承體系獲得動態分配物件
// ...
delete ptk; // 釋放它,避免資源洩漏

以上的繼承和使用,存在一個問題,當derived class物件經由一個base class指標被刪除,而該base class帶著一個non-virtual解構函式,其結果未有定義。即實際執行時通常發生的是base case成分被銷燬,derived class成分沒被銷燬,這就是區域性銷燬,會造成資源洩漏。

解決方法就是給base class一個virtual解構函式:

class TimeKeeper {
public:
    TimeKeeper();
    virtual ~TimeKeeper();
    // ...
};

不為基類不應該含有virtual函式

class Point {
public:
    Point(int xC, int yC);
    ~Point();
private:
    int x, y;
};

分析上面的類,如果int佔用32bits,那麼Point物件總共佔64bits空間

如果實現virtual函式,物件必須攜帶某些資訊,它們主要用來在執行期決定哪一個virtual函式該被呼叫。這份資訊通常是由一個所謂的vptr(virtual table pointer)指標指出,vptr指向一個由函式指標構成的陣列,稱為vtbl(virtual table),每一個帶有virtual函式的class都有一個相應的vtbl,顯然這會增加記憶體開銷,可能會使得類無法被C函式使用(因為它沒有vptr),從而不再具有移植性。

所以,不能無端的將class的解構函式宣告為virtual,只有當class內含至少一個virtual函式,才為它宣告virtual解構函式

一個常識是,包括所有STL容器如vector、list等,它們都是不帶virtual解構函式的class。

abstract classes

我們知道,pure virtual函式會導致abstract classes(抽象類)不能被例項化,但是它顯然只使用於帶有多型性質的基類。

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

請記住

  • 帶多型性質的基類(polymorphic base classes)應該宣告一個virtual解構函式。如果class帶有任何virtual函式,它就應該擁有一個virtual解構函式
  • 如果Classes的設計目的不是作為base classes使用,或不是為了具備多型性(polymorphically),就不應該宣告virtual解構函式

條款08:別讓異常逃離解構函式

如果解構函式吐出異常,程式可能過早結束,比如某個函式呼叫發生異常,在回溯尋找catch過程中,每離開一個函式,這個函式內的區域性物件會被析構,如果此時解構函式又丟擲異常,前一個異常還沒處理完又來一個,編譯器這時候可能就罷工了,因此一般會引起程式過早結束,如果異常從解構函式中傳播出去,可能會導致不明確的行為。

解決這個問題,通常有兩種方法:

  • 在解構函式中catch異常,然後呼叫abort終止程式,通過abort搶先置"不明確行為"於死地。
DBConn::~DBConn() {
    try {
        db.close();
    } catch (err) {
        // 記錄資訊
        std::abort();
    }
}
  • 在解構函式中catch異常,然後吞下這個異常,單著通常不是一個很好的方法。
DBConn::~DBConn() {
    try {
        db.close();
    } catch (err) {
        // 記錄資訊
    }
}
  • 最好的辦法是重新設計介面,讓客戶能夠在析構前主動呼叫可能引起異常的函式,然後解構函式中使用一個bool變數,根據使用者是否主動呼叫來決定解構函式是否應該呼叫可能引起異常的函式,讓客戶擁有主動權。
void DBConn::close() {
    db.close();
    closed = true;
}

DBConn::~DBConn() {
    if(!closed) {
        try {
            db.close();
        } catch (err) {
            // 記錄資訊
        }
    }
}

請記住

  • 解構函式絕對不要吐出異常。如果一個被解構函式呼叫的函式可能丟擲異常,解構函式應該捕獲應該捕獲任何異常,然後吞下它們(不傳播)或者直接結束程式。
  • 如果客戶需要對某個操作函式執行期間丟擲的異常做出反應,那麼class應該提供一個普通函式(而非在解構函式中)執行該操作。

條款09:絕不在構造和析構過程中呼叫virtual函式

如果希望在繼承體系中根據型別在構建物件時表現出不同行為,可能會想到在基類的建構函式中呼叫一個虛擬函式。

假設有一個繼承體系,用來模擬股市交易如買進、賣出的訂單等,可能每當建立一個交易物件時,都需要新增一條日誌記錄。

class Transaction {
public:
    Transaction () {
        logTransaction();
    }
    virtual void logTransaction() const = 0;    // 純虛擬函式
};

class BuyTransaction: public Transaction {
public:
    virtual void logTransaction() const;    // 記錄此類交易
};

class SellTransaction: public Transaction {
public:
    virtual void logTransaction() const;    // 記錄此類交易
};

// 執行
BuyTransaction b;

以上的執行顯然是有問題的。在子類構造期間,virtual函式絕不會下降到派生類。派生類物件的基類構造期間,物件的型別是基類而不是派生類,除此之外,若使用執行期型別資訊(如dynamic_casttypeid),也會把物件視為基類型別,這樣做是合理的,因為根據建構函式執行的順序,此時子類部分尚未初始化,如果呼叫對的是子類的虛擬函式,通常會訪問子類的資料,這樣會引發安全問題。同樣的道理也使用與解構函式。

解決這個問題的辦法是,將logTransaction函式改為non-virtual,然後要求derived class建構函式傳遞必要資訊給Transaction建構函式

class Transaction {
public:
    explicit Transaction (const std::string&& logInfo) {
        logTransaction(logInfo);
    }
    void logTransaction(const std::string& logInfo) const;    // non-virtual函式
};

class BuyTransaction: public Transaction {
public:
    BuyTransaction(parameters): Transaction(createLogString(parameters)) { }    // 將log資訊傳遞給base class建構函式

private:
    static std::string createLogString(parameters);
};

請記住

  • 在構造和析構期間不要呼叫virtual函式,因為這類呼叫從不下降至derived class(比起當前執行建構函式和解構函式的那層)。

條款10:在operator=返回一個reference to *this

為了實現連鎖賦值,賦值操作符必須返回一個reference指向操作符的左側實參。

class Widget {
public:

    Widget& operator=(const Widget& rhs) {
        // ...
        return* this;
    }

    Widget& operator+=(const Widget& rhs) { // 同樣適用於+=,-=,*=等
        // ...
        return* this;
    }
};

請記住

  • 令賦值(assignment)操作符返回一個reference to *this

條款11:在operator=中處理"自我賦值"

"自我賦值"發生在物件被賦值給自己時。

如果嘗試自行管理資源(如果打算自己寫一個用於資源管理的類就得這麼做),可能會掉進“在停止使用資源之前意外釋放了它”的陷阱。

// 儲存一個指標指向一塊動態分配的點陣圖
class Bitmap { };

class Widget {
    // ...
private:
    Bitmap* pb; // 指標,指向一個從heap分配而得的物件
};
  • 實現operator=操作:這種實現在自賦值時就會發生問題。
Widget& Widget::operator=(const Widget& rhs) {
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
};
  • 解決方案1:進行“證同測試”,達到“自我賦值”的檢驗目的。這樣做雖然能處理自賦值,但不是異常安全的,如果new發生異常,物件pb將指向一塊被刪除的記憶體。
Widget& Widget::operator=(const Widget& rhs) {
    if(this == &rhs) return *this;
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
};
  • 解決方案2:調整語句順序,通過確保異常安全來獲得自賦值的回報。
Widget& Widget::operator=(const Widget& rhs) {
    Bitmap* pOriginal = pb;
    pb = new Bitmap(*rhs.pb);
    delete pOriginal;
    return *this;
};
  • 解決方案3:使用所謂的copy-and-swap技術,引數採用pass-by-reference
class Widget {
    void swap(Widget& rhs); // 交換*this和rhs的資料
};

Widget&::Widget::operator=(const Widget& rhs) {
    Widget temp(rhs);
    swap(temp);
    return *this;
}
  • 解決方案4:使用所謂的copy-and-swap技術,引數採用pass-by-value
Widget&::Widget::operator=(Widget rhs) {
    swap(rhs);
    return *this;
}

請記住

  • 確保類物件自我賦值時operator=有良好行為。其中技術包括比較"來源物件"和"目標物件"的地址、精心周到的語句順序、以及copy-and-swap
  • 確定任何函式如果操作一個以上的物件。而其中多個物件是同一個物件時,其行為仍然正確。

條款12:複製物件時勿忘其每一個成分

如果宣告自己的copying函式,意思是告訴編譯器不喜歡它預設給的,但是當你自己寫出的copying函式程式碼不安全時,它也不會告訴你。

  • copy建構函式
    • 非繼承中:當為類新增一個新成員時,copy建構函式也需要為新成員新增拷貝程式碼。否則會呼叫新成員的預設建構函式初始化新成員。
    • 繼承中:在派生類的copy建構函式中,不要忘記呼叫基類的copy建構函式拷貝基類部分。否則會呼叫基類的預設建構函式初始化基類部分。
  • copy assignment操作符
    • 非繼承中:當為類新增一個新成員時,copy assignment操作符中也需要為新成員新增賦值程式碼,否則新成員會保持不變。
    • 繼承中:在派生類的copy assignment操作符中,不要忘記呼叫基類的copy assignment操作符,否則基類部分會保持不變。

請記住

  • copying函式應該確保複製"物件內的所有成員變數"及"所有base class成分"。
  • 不要嘗試以某個copying函式實現另一個copying函式。應該將共同技能放進第三個函式中,並由兩個copying函式共同呼叫。