《Effective C++》讀書筆記——第二章:Constructors, Destructors, and Assignment Operators

咳咳,上次寫讀書筆記居然已經是五個月前的事了,吐槽一下自己的懶_(:з)∠)_正好這兩個星期報名參加了公司一個關於C++的培訓,再次認識到自己對於基礎知識的欠缺,所以還是勤快一些多多學習吧,既是爲了自身的成長也是爲了不被淘汰。用社長的話來說,“自己的未來靠自己的雙手去開拓!”

好的那麼這一次我們來學習C++中對象的四大件:構造函數,析構函數,拷貝構造函數以及賦值語句,培訓的老師還講了兩個是移動拷貝構造函數和移動賦值語句,不過書裏暫時沒有涉及所以這裏就先不講了。這些函數可以說是對象不可或缺的組成部分,只要接觸對象就不可避免的需要跟它們打交道,所以保證它們被正確的編寫,或者說能正確的使用它們是非常重要的。


ITEM 5: KNOW WHAT FUNCTIONS C++ SILENTLY WRITES AND CALLS

這個item很清晰的說明了C++對象的這幾個函數的運作方式,如果你定義了一個空的類

class Empty{};

看起來它什麼都沒有,但是你仍然可以初始化它(也因此可以析構它)、用它構造其他對象和賦值給其他對象,這是因爲如果你沒有自己顯式地定義這些函數,當你寫了調用這些函數的代碼時,C++編譯器會幫你自動生成一個,可能是因爲幾乎所有的類都會用到這些函數所以比起報錯讓你自己去編寫一個默認的,C++很人性化的幫你處理了,這也是爲什麼有時候我們感覺不到除了構造函數以外的三個函數,畢竟大家通常都會自己編寫構造函數但不一定會寫其他的函數。所以下面的代碼會觸發編譯器自動生成相應的函數:

Empty a; // 構造函數、析構函數
Empty b(a); // 拷貝構造函數
b = a; // 賦值語句

對於自動生成的這些函數,默認構造函數和析構函數其實就做了些“幕後”的事,比如調用基類的構造/析構函數以及初始化非靜態成員變量等,而拷貝構造函數和賦值語句也就是簡單的把源對象的成員拷貝到目標對象中。

而反過來說,如果你自己定義過了這些函數,編譯器就不會再爲你自動生成這些函數了,你不必擔心自己定義的簽名和邏輯會因爲默認函數的存在而無法生效,但也意味着你必須按照自己定義的方式使用這些函數。

例如我們定義以下的類:

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

  ...

private:
  std::string nameValue;
  T objectValue;
};

它定義了兩個帶兩個參數的構造函數,那麼初始化這樣的對象時就需要相應的傳入兩個參數。它沒有定義拷貝構造函數和賦值語句,因此編譯器會自動幫你生成,看下面一段代碼:

NameObject<int> no1("Smallest prime number", 2);
NameObject<int> no2(no1);

默認拷貝構造函數會使用no1.nameValueno1.objectValue來初始化no2.nameValueno2.objectValuenameValuestd::string類型,所以會調用它的拷貝構造函數,objectValueint內置類型所以會用位拷貝的方式。默認賦值語句跟拷貝構造函數類似,但是它只有當生成的函數合法且有意義時才能生效,否則編譯器就會報錯,假設我們作了如下的改動:將nameValue定義爲引用,將objectValue定義爲常量

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

  ...

private:
  std::string& nameValue;
  const T objectValue;

再考慮如下的代碼

std::string newDog("Persephone");
std::string oldDog("Satch");

NamedObject<int> p(newDog, 2);               // when I originally wrote this, our
                                             // dog Persephone was about to
                                             // have her second birthday

NamedObject<int> s(oldDog, 36);              // the family dog Satch (from my
                                             // childhood) would be 36 if she
                                             // were still alive

p = s;                                       // what should happen to
                                             // the data members in p?

p和s的nameValue都是std::string類型的引用,分別指向newDogoldDog,這時候把s的nameValue賦給p會怎樣呢——C++規定了引用一旦初始化後就不能再更改地址,所以不可能讓p的nameValue重新引用s的,那難道是對引用的值進行賦值嗎?這會導致賦值語句間接對不相干的對象造成了更改,讓newDog變成了“Satch”。所以C++也不知道這個默認的賦值語句應該怎麼寫:編譯器表示太南了
對於常量成員變量也是一樣的道理,C++不知道應該如何處理常量類型的賦值,所以以上兩種情況你必須自己定義賦值語句,否則編譯器會報錯。最後還有一點,如果基類的賦值語句被聲明爲private,而派生類中又不定義,編譯也是無法通過的,畢竟給派生類賦值時必須保證基類可以work,而這時候它又沒法調用基類的函數
臣妾調不到啊
總結:

編譯器會隱式地幫你生成構造函數、析構函數、拷貝構造函數以及賦值語句


ITEM 6: EXPLICITLY DISALLOW THE USE OF COMPILER-GENERATED FUNCTIONS YOU DO NOT WANT

這個item說的是如何不讓編譯器幫你自動生成這些函數,比如我有個對象是小熊,她應該是獨一無二的,我不希望這個對象有拷貝構造函數和賦值語句,應該怎麼做?本來對於一般的函數來說,只要你不聲明,那就不會有這個函數,調用的時候就會報錯,但現在的情況是如果你不聲明,調用的時候編譯器自己幫你生成了一個,所以還是能調用。總之,不管你是否聲明瞭拷貝的函數,這個對象都將支持拷貝的功能,怎樣才能讓它不支持拷貝呢?
如何讓別人不能擁有跟我一樣的對象?
作者告訴我們,編譯器自動生成的函數都是public類型,所以只要我們將拷貝構造函數和賦值語句聲明爲private不就行了嗎?這樣既防止了編譯器幫我們生成,又防止了別人調用它們。確實如此,但是還差了那麼一點,因爲你的成員函數和友元還是能調用這些函數,於是聰明的你一定能想到,只要我不去實現它們就可以了,這樣一來當有的地方試圖調用它們時,就會報link error。事實上,C++標準庫中很多地方也用到了這個技巧,來防止對象被拷貝。
天才的我
運用這個技巧,就可以寫出不能被拷貝的小熊了:

class LHE {
public:
  ...

private:
  ...
  LHE(const LHE&);            // declarations only
  LHE& operator=(const LHE&);
};

注意下面兩個函數的參數名都被省略了,畢竟你既不會實現它們,也沒人能調用到它們。最後還有一點可以改進的地方,我們可以將鏈接錯誤提前到編譯錯誤,早報晚報都是報,我們當然是希望錯了就早點告訴我們錯了,省的浪費時間。具體做法就是將函數聲明成private這件事放到一個基類去做,這個類的意義就是防止對象被拷貝,然後讓小熊去繼承它就可以了。

class Uncopyable {
protected:                                   // allow construction
  Uncopyable() {}                            // and destruction of
  ~Uncopyable() {}                           // derived objects...

private:
  Uncopyable(const Uncopyable&);             // ...but prevent copying
  Uncopyable& operator=(const Uncopyable&);
};

class LHE : private Uncopyable {            // class no longer
  ...                                       // declares copy ctor or
};                                          // copy assign. operator

正如上一節講的,這裏基類的拷貝構造和賦值都是私有的而派生類又沒有自己定義,所以別的地方想要調用的話編譯器會嘗試生成一個默認的並去調用基類的函數,然後發現不能調用於是就會報錯,而這正是我們希望看到的結果

總結:

想要防止編譯器自動生成某些函數,就將它們定義成私有的並且不要實現它們


ITEM 7: DECLARE DESTRUCTORS VIRTUAL IN POLYMORPHIC BASE CLASSES

面試時也經常可能問到的一道題,這裏再鞏固一下。在需要應用多態的基類中需要將析構函數定義爲虛函數,因爲如果一個基類指針指向的是一個派生類對象,而這個基類的析構函數又不是虛函數,那麼在析構這個指針對應的對象時,結果是無法預測的。基類的析構函數很可能只釋放了基類的數據成員,對於派生類的部分沒有進行釋放,派生類的析構函數也不會被調用,形成一種“部分析構”的現象,造成內存的泄露。解決方法也很簡單,只需要將基類的析構函數定義爲虛函數即可,這樣在析構時派生類的部分也會被釋放。

如果一個類已經有一個虛函數,通常說明它需要運用到多態的特性,那麼它也應該有一個虛的析構函數。而如果一個類沒有虛函數,它一般就不適合作基類,也就不應該有虛的析構函數,作者舉了一個例子來說明這樣做的目的,考慮一個二維座標系的Point類:

class Point {                           // a 2D point
public:
  Point(int xCoord, int yCoord);
  ~Point();

private:
  int x, y;
};

如果一個int變量有32位,那麼一個Point對象可以存放在一個64bit的寄存器中,它也可以用64bit的量被傳遞到其他語言的函數中(C、FORTRAN)。如果析構函數被定義成虛函數的話,情況就會不一樣了
虛析構函數的Point會發生什麼?
首先我們需要知道虛函數是如何運作的,虛函數的實現需要對象攜帶信息來表明運行時應該調用哪一個虛函數,這是通過一個虛函數表指針(vptr,virtual table pointer)來實現的,它指向了一個函數指針的數組,這個數組稱爲虛函數表(virtual table)。每個有虛函數的類都有它自己相關聯的虛函數表,於是當對一個對象調用虛函數時,實際調用的函數就通過虛函數表指針找到虛函數表,再從表中找到合適的函數。

知道了這個之後再來考慮析構函數爲虛函數的Point對象,由於需要攜帶虛函數表指針,它所佔用的空間也會變大,在32位操作系統中會變成96bit,而在64位操作系統中回變成128bit,僅僅是將析構函數聲明爲虛函數就使得佔用空間增加了50%~100%。而且也不能以相同的方式傳遞到其他語言中了,因爲沒有虛函數表指針。

總的來說,將所有析構函數聲明成虛函數跟從來不定義成虛函數一樣都是錯誤的,而目前總結的最佳實踐就是:當且僅當一個類已經至少有一個虛函數時,纔將析構函數聲明成虛函數。這裏還有一些要注意的事情:不要繼承std::string或者容器類,因爲它們沒有虛析構函數。

有時候將析構函數聲明爲純虛函數是個不錯的選擇,這樣一來這個類就會變成抽象類,也就是無法被實例化的類。當你希望某個類是抽象類,你又沒有任何純虛函數時就可以將它的析構函數聲明爲純虛函數,因爲抽象類就是應當作爲基類使用的,而純虛函數又能保證它是抽象類,只不過你必須手動給它一個實現。

最後說一點,虛析構函數只適用於多態的場景,對於不涉及多態的基類,我們還是應當將析構函數聲明爲非虛函數,例如std::string,STL容器類,以及之前例子中的Uncopyable類,它們雖然是基類,但並不會有多態的特性,也就不需要虛的析構函數。

總結:

1. 有多態性的基類應當將析構函數聲明爲虛函數。如果一個類有虛函數它也應該有虛析構函數
2. 不被用作基類的類或者沒有多態性的基類不應該將析構函數聲明爲虛函數


ITEM 8: PREVENT EXCEPTIONS FROM LEAVING DESTRUCTORS

這個item主要是講不要在析構函數中拋出異常,雖然C++允許這麼做但是這麼做是不好的,例如:

class Widget {
public:
  ...
  ~Widget() { ... }            // assume this might emit an exception
};

void doSomething()
{
  std::vector<Widget> v;
  ...
}                                // v is automatically destroyed here

v被銷燬時,它會負責銷燬它所包含的所有Widget,假設其中一個拋出了異常,剩下的對象還是需要被釋放,否則就會造成內存泄露,如果這之中又有某個拋出了異常,就會造成同時有兩個異常存在的現象,C++的行爲就會無法定義,所以建議不要在析構函數中拋出異常。

那麼問題來了,如果我的析構函數確實需要進行某個可能拋出異常然後失敗的操作呢
拋出異常的析構函數
例如我有一個數據庫連接類:

class DBConnection {
public:
  ...

  static DBConnection create();        // function to return
                                       // DBConnection objects; params
                                       // omitted for simplicity

  void close();                        // close connection; throw an
};                                     // exception if closing fails

爲了保證客戶端不會忘記關閉連接,通常我們會需要一個資源管理類來管理它,比如DBConnection Manager,這個類負責在自己的析構函數中關閉數據庫的連接:

class DBConn {                          // class to manage DBConnection
public:                                 // objects
  ...
  ~DBConn()                            // make sure database connections
  {                                     // are always closed
   db.close();
   }
private:
  DBConnection db;
};

那麼客戶端的代碼就可以這麼寫:

{                                       // open a block

   DBConn dbc(DBConnection::create());  // create DBConnection object
                                        // and turn it over to a DBConn
                                        // object to manage

...                                    // use the DBConnection object
                                        // via the DBConn interface

}                                       // at end of block, the DBConn
                                        // object is destroyed, thus
                                        // automatically calling close on
                                        // the DBConnection object

這樣一來一出作用域dbc就會自動被銷燬,然後調用析構函數時去關閉數據庫的連接。如果關閉的操作能成功那麼萬事大吉,但是如果拋出了異常,那麼DBConn會往上拋,然後允許它離開這個析構函數。但如我們剛纔所說這樣做是會帶來麻煩的,解決的方法主要有兩種:

  1. 如果拋出異常直接終止程序
DBConn::~DBConn()
{
try { db.close(); }
catch (...) {
   make log entry that the call to close failed;
   std::abort();
}
}

這個做法在發生錯誤後程序無法正常運行時是合理的,它的好處就是防止造成無法預期的結果

  1. 忽略拋出的異常
DBConn::~DBConn()
{
try { db.close(); }
catch (...) {
      make log entry that the call to close failed;
}
}

一般來說這樣做是不太好的,因爲你隱瞞了某些操作失敗了這樣一個重要的信息,但是有時候相比於過早的終止程序或者未定義的行爲來說,這樣做更加可取。當然前提是程序必須保證在遇到異常並且忽略它之後仍然能正常運行。

這兩種做法都不是那麼盡如人意,因爲它們無法在引起異常的第一時間就及時響應。一個更好的解決方案就是修改DBConn的接口使得客戶端可以對此作出響應,比如提供一個close接口來讓客戶端有機會處理這個操作造成的異常,並且用一個變量來標識數據庫連接是否已經關閉,然後沒關閉的話就在析構函數中關閉它

class DBConn {
public:
  ...

  void close()                                     // new function for
  {                                                // client use
    db.close();
    closed = true;
  }

  ~DBConn()
   {
   if (!closed) {
   try {                                            // close the connection
     db.close();                                    // if the client didn't
   }
   catch (...) {                                    // if closing fails,
     make log entry that call to close failed;   // note that and
     ...                                             // terminate or swallow
   }
  }

private:
  DBConnection db;
  bool closed;
};

雖然把close的操作從析構函數中拿出來放到客戶端主動去調用(析構函數還是有一層備用的close)造成了不必要的負擔,但是如果一個操作可能失敗並且需要處理這個異常,那它就應當被放到一個非析構函數中去調用,因爲拋出異常的析構函數是危險的(重要的話說三次)。在這個例子中,讓客戶端主動調用close並不是加重了它的負擔,而是給了它一個處理異常的機會,如果客戶端足夠自信,它也可以不處理這個異常,就靠DBConn的析構函數來幫它關閉連接,也是可以的。當然如果那時候close失敗了,DBConn就會要麼終止程序要麼忽略異常,而客戶端也不能怪它,畢竟是你有這個機會去處理但是你沒有珍惜(狗頭)

總結:

1. 析構函數不應該拋出異常,如果析構函數中調用了可能拋出異常的函數,它應當捕獲這個異常然後決定是終止程序還是忽略它
2. 如果客戶端希望響應某個操作中可能拋出的異常,那麼類中應該提供一個非析構函數的方法來執行這個操作


ITEM 9: NEVER CALL VIRTUAL FUNCTIONS DURING CONSTRUCTION OR DESTRUCTION

簡單來說,不要在構造函數中調用虛函數,因爲它不會像你想的那樣運行,具體我自己也寫了個測試程序:
構造函數中調用虛函數
原因就是創建派生類時,會先調用基類的構造函數,這時候去調用虛函數的話,由於派生類的成員變量還沒有初始化,所以如果調用派生類的虛函數而這個函數中又用到了它的成員變量,就會造成無法預測的後果,所以這時候只能調用基類的虛函數,當作派生類的成員變量不存在。不僅僅是虛函數,在這個時候這個對象的類型也只能是基類,一個對象只有當運行了派生類的構造函數後纔可能變成派生類對象。

同樣的道理,當析構一個對象時,先調用了派生類的析構函數,因此我們認爲派生類的數據成員已經被釋放了,所以這時候調用虛函數的話也只可能調用基類的虛函數。

最後,既然不能通過虛函數來實現這樣的功能,應該怎麼做呢?作者給的例子是先保證調用函數不是虛函數,然後在派生類的構造函數的參數中傳入一些信息,具體可以參考下面的代碼:

class Transaction {
public:
  explicit Transaction(const std::string& logInfo);

  void logTransaction(const std::string& logInfo) const;   // now a non-
                                                           // virtual func
  ...
};

Transaction::Transaction(const std::string& logInfo)
{
  ...
  logTransaction(logInfo);                                // now a non-
}                                                         // virtual call

class BuyTransaction: public Transaction {
public:
BuyTransaction( parameters )
: Transaction(createLogString(parameters ))             // pass log info
  { ... }                                                 // to base class
   ...                                                    // constructor

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

這樣一來創建派生類時打印的信息就會跟基類時不同,注意這裏createLogString被聲明爲了靜態方法,這樣就防止了訪問未初始化的派生類成員變量的危險

總結:

不要在構造函數或者析構函數中調用虛函數,因爲這次調用不會深入到它的派生類中


ITEM 10: HAVE ASSIGNMENT OPERATORS RETURN A REFERENCE TO *THIS

賦值語句一個有趣的地方在於你可以連着寫很多個賦值語句,因爲它是右結合的:

int x, y, z;

x = y = z = 15;                        // chain of assignments

等同於

x = (y = (z = 15));

這是因爲賦值語句返回了一個左值的引用,當我們自己編寫賦值語句時也應當遵循這個規則

class Widget {
public:
  ...

Widget& operator=(const Widget& rhs)   // return type is a reference to
{                                      // the current class
  ...
  return *this;                        // return the left-hand object
  }
  ...
};

不僅是賦值語句,對其他的自增自減運算符等也應當這麼做:

class Widget {
public:
  ...
  Widget& operator+=(const Widget& rhs   // the convention applies to
  {                                      // +=, -=, *=, etc.
   ...
   return *this;
  }
   Widget& operator=(int rhs)            // it applies even if the
   {                                     // operator's parameter type
      ...                                // is unconventional
      return *this;
   }
   ...
};

這只是一個約定俗成的規律,即使不這麼寫編譯器也不會報錯,但是幾乎所有的標準庫和內置類型都遵循了這一規律,所以照做肯定不會錯的

總結:

讓賦值語句返回一個對*this的引用


ITEM 11: HANDLE ASSIGNMENT TO SELF IN OPERATOR=

首先我們需要知道賦值給自己是個什麼情況:

class Widget { ... };

Widget w;
...

w = w;                                   // assignment to self

簡單來說就是賦值語句的左右兩邊是同一個對象,雖然這看起來很蠢但卻是合法的語句,所以在編寫賦值語句的時候你應當考慮到這個情況,而這也確實是一個容易疏忽的地方,畢竟我自己寫代碼有時候就是想當然的寫沒有考慮那麼多。除了上面這種很容易看出來是自賦值的情況以外,還有些不那麼容易發現的:

a[i] = a[j];                                      // potential assignment to self
*px = *py;                                        // potential assignment to self

那麼我們來看看不考慮自賦值的話可能會出現什麼問題,下面這一段代碼看起來是很合理的實現:

class Bitmap { ... };

class Widget {
  ...

Widget&
Widget::operator=(const Widget& rhs)              // unsafe impl. of operator=
{
  delete pb;                                      // stop using current bitmap
  pb = new Bitmap(*rhs.pb);                       // start using a copy of rhs's bitmap

  return *this;                                   // see Item 10
}

private:
  Bitmap *pb;                                     // ptr to a heap-allocated object
};

但是當發生自賦值的情況時,當釋放原來的pb時其實現在的pb也一起被釋放了,於是最後就會變成這個對象的pb指向了一塊被釋放的區域。傳統的解決方法就是在最前面加一行判斷來單獨處理自賦值的情況:

Widget& Widget::operator=(const Widget& rhs)
{
  if (this == &rhs) return *this;   // identity test: if a self-assignment,
                                    // do nothing
  delete pb;
  pb = new Bitmap(*rhs.pb);

  return *this;
}

這樣做可以解決對自賦值不安全的問題,但其實還存在對異常不安全的問題:假設在調用new Bitmap的時候發生了異常,創建失敗,就會導致pb指向了一片被釋放的區域。通常來說現在在寫代碼時越來越注重對異常的安全,因爲保證了對異常安全時,往往也能順便保證對自賦值安全。所以對以上的代碼,我們其實只需要更改一下語句的順序就能解決這個問題:

Widget& Widget::operator=(const Widget& rhs)
{
  Bitmap *pOrig = pb;               // remember original pb
  pb = new Bitmap(*rhs.pb);         // point pb to a copy of rhs's bitmap
  delete pOrig;                     // delete the original pb

  return *this;
}

也就是先用一個臨時的指針存放原來的pb,試圖將它拷貝給現在的對象,最後再釋放它,這樣即使中間發生了異常,我們的pb也沒有發生變化。而如果是自賦值的情況,就相當於拷貝了自己的值給自己,也是沒問題的。你可能會覺得這樣做是不是有點浪費,影響效率,你當然也可以在最前面加一個identity test,但是還是應該衡量一下,自賦值發生的概率,畢竟加的這行代碼也是會影響效率的,總之應該
綜合考慮。

還有一種做法是“拷貝替換”,也就是先交換左右兩邊的值,然後返回左邊:

class Widget {
  ...
  void swap(Widget& rhs);   // exchange *this's and rhs's data;
  ...                       // see Item 29 for details
};

Widget& Widget::operator=(const Widget& rhs)
{
  Widget temp(rhs);             // make a copy of rhs's data

  swap(temp);                   // swap *this's data with the copy's
  return *this;
}

根據C++的兩個特性(1)複製語句可以使用值傳遞的方法(2)採用值傳遞時會自動調用拷貝構造函數
我們可以更簡化一些:

Widget& Widget::operator=(Widget rhs)   // rhs is a copy of the object
{                                       // passed in — note pass by val

  swap(rhs);                            // swap *this's data with
                                        // the copy's

  return *this;
}

總結:

1. 保證賦值語句在自賦值情況下可以正常工作。技巧包括比較源和目標的地址、仔細安排語句的順序以及拷貝替換法
2. 保證對多個對象的操作在其中兩個或多個是相同對象時仍然可以正常工作


ITEM 12: COPY ALL PARTS OF AN OBJECT

當我們編寫我們自己的拷貝函數(拷貝構造函數和複製語句)時,我們需要注意拷貝所有的成員變量,這時候即使你忘了拷貝編譯器也是不會提醒你的,比如下面的例子:

void logCall(const std::string& funcName);          // make a log entry

class Customer {
public:
  ...
  Customer(const Customer& rhs);
  Customer& operator=(const Customer& rhs);
  ...

private:
  std::string name;
};

Customer::Customer(const Customer& rhs)
: name(rhs.name)                                 // copy rhs's data
{
  logCall("Customer copy constructor");
}

Customer& Customer::operator=(const Customer& rhs)
{
  logCall("Customer copy assignment operator");

  name = rhs.name;                               // copy rhs's data

  return *this;                                  // see Item 10
}

目前爲止一切順利,但是如果給Customer類加上一個成員變量呢:

class Date { ... };       // for dates in time

class Customer {
public:
  ...                     // as before

private:
  std::string name;
  Date lastTransaction;
};

如果不修改原有的複製語句,新加的Date類型成員就不會被拷貝,編譯器甚至不會告訴你你忘了拷貝(誰讓你不喜歡我幫你生成的函數,非要自己寫呢,哼😕)所以你必須自己記得在所有拷貝函數中給它加上。更隱蔽的情況發生在繼承時:

class PriorityCustomer: public Customer {                  // a derived class
public:
   ...
   PriorityCustomer(const PriorityCustomer& rhs);
   PriorityCustomer& operator=(const PriorityCustomer& rhs);
   ...

private:
   int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: priority(rhs.priority)
{
  logCall("PriorityCustomer copy constructor");
}

PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
  logCall("PriorityCustomer copy assignment operator");

  priority = rhs.priority;

  return *this;
}

看起來這個派生類的拷貝構造函數已經拷貝了所有的成員變量,但其實它的基類的兩個成員變量都忘記了拷貝,這兩個變量將通過它們的默認構造函數進行初始化(如果沒有,編譯器就會報錯)。在賦值語句中也是一樣的,基類的兩個成員變量不會發生變化。因此當你自己編寫派生類的拷貝函數時,一定不要忘記拷貝基類的部分,當然基類的很多成員變量可能是私有的,所以你應該主動去調用基類的拷貝函數

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
:    Customer(rhs),                   // invoke base class copy ctor
  priority(rhs.priority)
{
  logCall("PriorityCustomer copy constructor");
}

PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
  logCall("PriorityCustomer copy assignment operator");

  Customer::operator=(rhs);           // assign base class parts
  priority = rhs.priority;

  return *this;
}

所以總之就是記住兩點:(1)不要漏拷貝任何成員變量(2)記得調用基類的拷貝函數

最後還有一點,在實際工作中,我們通常都會追求代碼的複用,減少重複代碼,而這兩個拷貝函數其實做的事情又差不多,所以你可能會想在某個函數中調用另外一個,這是很不好的一個做法。

(1)在賦值語句中調用拷貝函數❌這意味着你嘗試在一個已經存在的對象上新建一個對象,不合理
(2)在拷貝函數中調用賦值語句❌這意味着你嘗試把一個值賦給還未存在的對象,不合理,too

總之,不要嘗試在一個拷貝函數中調用另一個,如果你希望減少重複,你可以將它們公共的部分提取成一個私有的成員函數,然後讓兩個拷貝函數都調用它即可。這點其實在工作中我也有用過,比如在寫槽函數的時候,我們通常希望用on作爲函數名的前綴,但有時候在別的地方我們可能也會需要這個槽函數的邏輯,直接調用這個槽函數也不是不行,但是看着就不合理,畢竟這時候其實沒有信號觸發它,我們就可以把這段邏輯放到一個私有的函數中,然後在槽函數中調用這個函數即可,既複用了代碼又保證了代碼的可讀性。

總結:

1. 拷貝函數應該保證能拷貝所有的成員,包括它的基類
2. 不要嘗試用一個拷貝函數去實現另一個,把公共邏輯放到私有方法中

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