天天看點

《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.nameValue

no1.objectValue

來初始化

no2.nameValue

no2.objectValue

nameValue

std::string

類型,是以會調用它的拷貝構造函數,

objectValue

int

内置類型是以會用位拷貝的方式。預設指派語句跟拷貝構造函數類似,但是它隻有當生成的函數合法且有意義時才能生效,否則編譯器就會報錯,假設我們作了如下的改動:将

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

類型的引用,分别指向

newDog

oldDog

,這時候把s的

nameValue

賦給p會怎樣呢——C++規定了引用一旦初始化後就不能再更改位址,是以不可能讓p的

nameValue

重新引用s的,那難道是對引用的值進行指派嗎?這會導緻指派語句間接對不相幹的對象造成了更改,讓

newDog

變成了“Satch”。是以C++也不知道這個預設的指派語句應該怎麼寫:

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

對于常量成員變量也是一樣的道理,C++不知道應該如何處理常量類型的指派,是以以上兩種情況你必須自己定義指派語句,否則編譯器會報錯。最後還有一點,如果基類的指派語句被聲明為

private

,而派生類中又不定義,編譯也是無法通過的,畢竟給派生類指派時必須保證基類可以work,而這時候它又沒法調用基類的函數

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

總結:

編譯器會隐式地幫你生成構造函數、析構函數、拷貝構造函數以及指派語句

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

這個item說的是如何不讓編譯器幫你自動生成這些函數,比如我有個對象是小熊,她應該是獨一無二的,我不希望這個對象有拷貝構造函數和指派語句,應該怎麼做?本來對于一般的函數來說,隻要你不聲明,那就不會有這個函數,調用的時候就會報錯,但現在的情況是如果你不聲明,調用的時候編譯器自己幫你生成了一個,是以還是能調用。總之,不管你是否聲明了拷貝的函數,這個對象都将支援拷貝的功能,怎樣才能讓它不支援拷貝呢?

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

作者告訴我們,編譯器自動生成的函數都是

public

類型,是以隻要我們将拷貝構造函數和指派語句聲明為

private

不就行了嗎?這樣既防止了編譯器幫我們生成,又防止了别人調用它們。确實如此,但是還差了那麼一點,因為你的成員函數和友元還是能調用這些函數,于是聰明的你一定能想到,隻要我不去實作它們就可以了,這樣一來當有的地方試圖調用它們時,就會報link error。事實上,C++标準庫中很多地方也用到了這個技巧,來防止對象被拷貝。

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

運用這個技巧,就可以寫出不能被拷貝的小熊了:

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)。如果析構函數被定義成虛函數的話,情況就會不一樣了

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

首先我們需要知道虛函數是如何運作的,虛函數的實作需要對象攜帶資訊來表明運作時應該調用哪一個虛函數,這是通過一個虛函數表指針(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++的行為就會無法定義,是以建議不要在析構函數中抛出異常。

那麼問題來了,如果我的析構函數确實需要進行某個可能抛出異常然後失敗的操作呢

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

例如我有一個資料庫連接配接類:

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

簡單來說,不要在構造函數中調用虛函數,因為它不會像你想的那樣運作,具體我自己也寫了個測試程式:

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

原因就是建立派生類時,會先調用基類的構造函數,這時候去調用虛函數的話,由于派生類的成員變量還沒有初始化,是以如果調用派生類的虛函數而這個函數中又用到了它的成員變量,就會造成無法預測的後果,是以這時候隻能調用基類的虛函數,當作派生類的成員變量不存在。不僅僅是虛函數,在這個時候這個對象的類型也隻能是基類,一個對象隻有當運作了派生類的構造函數後才可能變成派生類對象。

同樣的道理,當析構一個對象時,先調用了派生類的析構函數,是以我們認為派生類的資料成員已經被釋放了,是以這時候調用虛函數的話也隻可能調用基類的虛函數。

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

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. 不要嘗試用一個拷貝函數去實作另一個,把公共邏輯放到私有方法中