1. 程式人生 > 其它 >深度探索C++物件模型筆記--第二章

深度探索C++物件模型筆記--第二章

第二章.建構函式語意學

2.1 預設建構函式的構造操作

  1. explicit關鍵字能夠制止單參建構函式被當作型別轉換運算子.

  2. 編譯器的隱式操作只是為了滿足編譯器本身的需求, 而不是程式本身, 一個被編譯器隱式生成的預設建構函式, 多數情況下對於程式本身來說是無用的.

  3. 如果一個類沒有任何建構函式, 但類中的一個非內建型別成員變數有預設建構函式, 那麼這個類被隱式生成的預設建構函式則不是無用的.

  4. 只有建構函式真正被呼叫了, 編譯器才會為類隱式生成預設建構函式.

  5. 為了避免一個沒有預設構造的類被放在多個檔案中實現而導致生成多個預設建構函式, 構造,析構,拷貝構造,拷貝賦值 這些函式都會被生成為inline或者explict no-inline static 的.

  6. 編譯器的行為可以簡單記為 編譯器不會為內建型別(不包含建構函式) 生成/擴充 建構函式; 對於下方的程式碼, 編譯器會為Bar隱式生成一個預設建構函式, 該預設建構函式中會呼叫Foo::Foo()來對Bar::foo做初始化, 但並不會為str做初始化操作.

    classFoo
    {
    public:
    Foo();
    Foo(int);
    };

    classBar
    {
    public:
    Foofoo;
    char*str;
    };

    //code
    voidfoo_bar()
    {
    Barbar;
    }
  7. 如果一個類包含建構函式, 但建構函式中並未對非內建型別成員變數做初始化, 那麼編譯器會擴充已有的建構函式, 在其中呼叫相應成員變數的建構函式.

  8. 當編譯器對建構函式進行擴充時, 擴充的建構函式會被安插在顯式使用者程式碼之前, 安插建構函式的順序將以變數宣告的順序為基準.

  9. 編譯器會為不包含任何建構函式的派生類生成一個預設建構函式, 其中會按繼承先後順序呼叫基類的預設建構函式.

  10. 如果一個不包含預設建構函式的類, 其中宣告/繼承了虛擬函式 或 該類派生自一個繼承鏈,繼承鏈中有一個或多個虛基類, 則編譯器的預設建構函式生成行為如下

    • 編譯器會生成一個虛擬函式表vtbl, 其中存放類中的虛擬函式地址.

    • 編譯器會生成一個虛指標vptr, 該vptr存在於每一個類物件中, 指向相關的vtbl地址.

      // 示例1
      classCBase
      {
      public:
      virtualvoidflip()=0; // 純虛擬函式
      };

      classCDerivedA:publicCBase
      {
      public:
      virtualvoidflip() {printf("A\n");}
      };

      classCDerivedB:publicCBase
      {
      public:
      virtualvoidflip() {printf("B\n");}
      };

      voiddo_flip(constCBase&base) {base.flip(); }

      voidfoo()
      {
      CDerivedAa;
      CDerivedBb;

      do_flip(a);
      do_flip(b);
      }


      // 對於上述情況, do_flip中的虛擬函式呼叫操作會被重新改寫, 使用每一個base物件中的vptr以呼叫vtbl中的虛擬函式.
      /* 虛擬碼
      void do_flip(const CBase &base) { (*base.vptr[1])( &base ); }

      1.vptr[1]指向vtbl中的flip函式地址.
      2.&base為被呼叫的flip函式例項中的this指標.

      */
      // 示例2
      classCBase{public:intvar_base; };

      classCDerivedA:publicvirtualCBase{public:intvar_a; };

      classCDerivedB:publicvirtualCBase{public:intvar_b; };

      classCDerivedC:publicCDerivedA,publicCDerivedB{public:intvar_c; };

      voidfoo(CDerivedA*pa)
      {
      // 無法在編譯期決定出pa->CBase::var_base的地址
      // 經過一系列操作, 可能被編譯器轉換為 pa->_vbcBase->var_base = 1024;
      pa->var_base=1024;

      /* 此處我自己進行了一個驗證, 使用MSVC編譯器, base類變數在derived類變數更高的地址
      printf("pa's addr is %d\n", pa);
      printf("pa->var_a's addr is %d\n", &(pa->var_a));
      printf("pa->var_base's addr is %d\n\n", &(pa->var_base));

      不考慮編譯器安插其他內容的情況下, 可能的記憶體佈局
      +-----------+-----------+
      | CDerivedA | CDerivedC | 0x000
      +-----------+-----------+
      | var_a | var_a | 0x004
      +-----------+-----------+
      | var_base | var_b | 0x008
      +-----------+-----------+
      | | var_c | 0x00C
      +-----------+-----------+
      | | var_base | 0x010
      +-----------+-----------+

      */

      deletepa;
      pa=nullptr;
      }

      intmain(intargc,char**argv)
      {
      printf("In class A :\n");
      foo(newCDerivedA() );
      printf("In class C :\n");
      foo(newCDerivedC() );


      system("pause");
      return0;
      }

      /*
      編譯器無法固定住foo中"經由pa而存取的CBase::var_base"的實際偏移位置, 因為pa的真正型別可以改變.
      編譯器必須改變"執行存取操作"的那些程式碼, 使CBase::var_base可以延遲至執行期才決定下來.
      一種可能的方式是, 編譯器會在類物件中安插一個_vbcBase指標, 指向虛基類CBase, 所有經由引用/指標來存取虛基類的操作都由該指標完成.
      */
  11. 編譯器生成出來的預設建構函式中, 只有基類子物件,成員類物件會被初始化, 所有其他的非靜態成員變數(整數,整數指標,整數陣列等)都不會被初始化.

2.2 拷貝建構函式的構造操作

  1. 以一個物件作為另一個類物件的初值的三種情況 :

    • 對一個物件做顯式初始化.

    • 當一個物件作為入參傳入某函式時.

    • 當函式返回一個類物件時.

      // 顯式初始化
      classCBase{ ... };
      Xobj;
      Xother_obj=obj;

      // 作為函式入參
      externvoidfoo(Xx);
      voidbar()
      {
      Xobj;
      foo(obj); //隱式初始化
      }

      // 函式返回類物件
      Xfoo_bar()
      {
      Xobj;
      returnobj;
      }
  2. 如果一個類沒有提供顯式的拷貝構造, 當對這個類的物件做拷貝初始化時, 內部會對類內每個成員施以預設的構造操作, 即將每一個內建的或派生的成員變數的值拷貝至當前物件的變數上( 淺拷貝/按位逐次拷貝 ), 這個操作是遞迴的.

  3. 預設構造和拷貝構造只有在類 不展現 淺拷貝 需求時才會被編譯器合成出來.

  4. 不展現 淺拷貝 需求的四種情況 ( 對於前兩種情況, 編譯器會將拷貝構造操作加入新合成的拷貝建構函式中 )

    • 類內包含一個類成員變數且這個變數的類中宣告有拷貝建構函式( 顯式宣告/編譯器合成 ).

    • 類繼承自一個基類, 基類中宣告有拷貝建構函式( 顯式宣告/編譯器合成 ).

    • 類聲明瞭一個或多個虛擬函式.

    • 類派生自一個繼承鏈, 其中有一個或多個虛基類.

  5. 為了支援多型, 對於編譯器而言, 每一個新生成的類物件中的vptr和vtbl都必須被正常設初值, 這就要求, 當vptr被編譯器擴張加入一個類中時, 該類就不再展現 淺拷貝 需求了.

    classCAnimal
    {
    public:
    CAnimal() :food("something") {}
    virtual~CAnimal() {}

    virtualstringsound() {return("HelloWorld"); }
    virtualstringeat() {returnfood; }

    private:
    stringfood;
    };

    classCDog:publicCAnimal
    {
    public:
    CDog() :dog_food("steak") {}
    virtual~CDog() {}

    stringsound() {return("WoofWoof"); } //雖然沒有宣告virtual,但其實是virtual的
    virtualstringeat() {returndog_food; }

    private:
    stringdog_food;
    };

    //dogglas會呼叫預設構造做初始化, dogglas的vptr被設定指向CDog的vtbl, 因此將dogglas拷貝給doggy是安全的.
    CDogdogglas;
    CDogdoggy=dogglas;
  6. 當一個基類物件以其派生類物件的內容做初始化操作時, vptr的設定同樣要保證安全

    /*
    * 由於派生類虛擬函式重寫後, 可能會呼叫派生類自身的私有成員變數, 如果將派生類物件中的vptr直接
    * 淺拷貝給基類物件中的vptr, 又因為不是使用指標或引用, 必將導致執行期間記憶體崩潰
    */
    CDogdoggy;
    CAnimalanimal=doggy; //此處發生了切割(sliced)
    CAnimal*dog=newCDog();

    /*
    * animal eat something
    * dog eat steak
    */
    printf("animal eat %s\n",animal.eat().c_str());
    printf("dog eat %s\n",dog->eat().c_str());

    /*
    * 這裡也是個很有趣的點, 我們delete了一個基類指標, 是否會造成記憶體洩漏?
    * 假定兩種情況: 1.派生類的構造存在額外的堆記憶體申請, 解構函式有正常釋放這個堆記憶體. 2.派生類的構造中沒有額外的堆記憶體申請
    * 在上述情況下, 如果基類解構函式為virtual, 且沒有過載new和delete運算子, 則不會造成記憶體洩露
    * 詳細原因可以檢視new和delete運算子定義, 返回值和入參均為void, 也就是說, 對於new和delete而言, 能否正常申請釋放記憶體是型別無關的.
    */
    deletedog;
  7. 當類派生自一個繼承鏈, 其中有一個或多個虛基類時, 編譯器則要保證初始化時_vbcBase(虛基類子成員物件)被正確設定, 該類則不展現淺拷貝需求.

2.3 程式轉化語義學

  1. 顯式初始化 在程式轉化時有兩個必要的階段: 重寫每一個變數定義, 剝離初始化操作; 呼叫類的拷貝構造.

    classCBase
    {
    public:
    CBase();
    CBase(CBase&base);
    virtual~CBase();

    private:
    ...
    };

    CBaseg_base;

    //原始函式
    voidfoo_bar()
    {
    CBaselocal_base1(g_base);
    CBaselocal_base2=g_base;
    CBaselocal_base3=CBase(g_base);
    }

    //編譯器轉化後 虛擬碼
    voidfoo_bar_tran()
    {
    //重寫變數定義,剝離初始化操作
    CBaselocal_base1;
    CBaselocal_base2;
    CBaselocal_base3;

    //呼叫拷貝構造
    local_base1.CBase::CBase(g_base);
    local_base2.CBase::CBase(g_base);
    local_base3.CBase::CBase(g_base);
    }
  2. 引數初始化 把一個類物件當做引數傳給一個函式/作為函式返回值, 相當於以引數值為物件對形參/返回值做初始化操作

    voidfoo(CBasebase) { ... }

    //相當於 CBase base = g_base
    foo(g_base);
  3. 返回值初始化 分兩個階段: 首先加上一個額外引數, 型別是類物件的一個引用, 該引數用來放置拷貝構造的返回值; 其次在 return 指令前安插一個拷貝構造呼叫操作, 將返回值物件賦給新增的額外引數.

    //原始函式
    CBasebar()
    {
    CBasebase;
    //...
    returnbase;
    }

    //轉換後 虛擬碼
    voidbar_tran(CBase&__result)
    {
    CBasebase;
    base.CBase::CBase();
    __result.CBase::CBase(base);
    return;
    }

    /*
    * 上述的這種轉換方式被稱為NRV(Named Return Value)優化
    * 該轉換方式被視為C++編譯器的一個義不容辭的優化標準
    * 當然,該優化方式要求類記憶體在拷貝建構函式(無論是顯式編寫的還是編譯器合成的)
    */

2.4 列表初始化

  1. 必須使用列表初始化的幾種情況

    • 初始化一個引用成員變數

    • 初始化一個const成員變數

    • 呼叫基類的構造且該建構函式擁有一組引數

    • 呼叫一個類成員變數的構造且該構造擁有一組引數時

    classCWord
    {
    String_name;
    int _cnt;

    public:
    CWord()
    {
    _name=0;
    _cnt=0;
    }
    };

    //上方構造可能會被編譯器轉化成如下形式
    //虛擬碼
    CWord::CWord(this)
    {
    _name.String::String();

    Stringtemp=String(0);

    _name.String::operator=(temp);

    //臨時物件被銷燬了,如果String的 ‘=’ 運算子內部是一個指標的淺拷貝,那麼這裡就會有問題.
    temp.String::~String();
    }

    //如果CWord類的構造使用了列表初始化
    CWord::CWord
    :_name(0)
    {
    _cnt=0;
    }
    //構造可能會被編譯器轉化成如下形式
    //虛擬碼
    CWord::CWord(this)
    {
    //拷貝構造,沒有臨時物件
    _name.String::String(0);
    _cnt=0;
    }
  2. 當我們使用列表初始化時, 編譯器會一一操作初始化列表中的成員, 以適當的順序在建構函式中的編寫者顯式程式碼之前安插初始化操作, 而這個安插的順序並不是按照我們列表初始化中編寫的順序來排列的, 而是這些變數在類中的宣告順序決定的.

    //形如下列程式碼,由於誤以為初始化順序會按照列表初始化編寫的順序操作而產生了錯誤
    classCWord
    {
    inti;
    intj;
    public:
    //由於宣告順序導致i(j)會比j(val)更早被執行
    CWord(intval)
    :j(val),i(j)
    {}
    }

    //虛擬碼
    CWord::CWord(this,intval)
    {
    i=j;
    j=val;
    }