深度解讀《深度探索C++物件模型》之預設建構函式

iShare_爱分享發表於2024-04-16

接下來我將持續更新“深度解讀《深度探索C++物件模型》”系列,敬請期待,歡迎關注!也可以關注公眾號:iShare愛分享,主動獲得推文。

提到預設建構函式,很多文章和書籍裡提到:“在需要的時候編譯器會自動生成一個預設建構函式”。那麼關鍵的問題來了,到底是什麼時候需要?是誰需要?比如下面的程式碼會生成預設建構函式嗎?

#include <cstdio>

class Object {
public:
    int val;
    char* str;
};

int main() {
    Object obj;
    if (obj.val == 0 || obj.str == nullptr) {
        printf("1\n");
    } else {
        printf("2\n");
    }

    return 0;
}

答案是不會,為了獲得確切的資訊,我們把它編譯成組合語言,編譯器是否有在背後給我們的程式碼增加程式碼或者擴充修改我們的程式碼,編譯成彙編程式碼後便一目瞭然。下面是上面程式碼對應的彙編程式碼:

main:                                   # @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     dword ptr [rbp - 4], 0
    cmp     dword ptr [rbp - 24], 0
    je      .LBB0_2
    cmp     qword ptr [rbp - 16], 0
    jne     .LBB0_3
.LBB0_2:
    lea     rdi, [rip + .L.str]
    mov     al, 0
    call    printf@PLT
    jmp     .LBB0_4
.LBB0_3:
    lea     rdi, [rip + .L.str.1]
    mov     al, 0
    call    printf@PLT
.LBB0_4:
    xor     eax, eax
    add     rsp, 32
    pop     rbp
    ret
.L.str:
		.asciz  "1\n"
.L.str.1:
		.asciz  "2\n"

從生成的彙編程式碼中並沒有看到預設建構函式的程式碼,說明編譯器這時不會為我們生成一個預設建構函式。

上面的C++例子中,程式的意圖是想要有一個預設建構函式來初始化兩個資料成員,這種情況是上面提到的“在有需要的時候”嗎?很顯然不是。這是程式的需要,是需要寫程式碼的程式設計師去做這個事情,是程式設計師的責任而不是編譯器的責任。只有編譯器需要的時候,編譯器才會生成一個預設建構函式,而且就算編譯器生成了預設建構函式,類中的那兩個資料成員也不會被初始化,除非定義的物件是全域性的或者靜態的。請記住,初始化物件中的成員的責任是程式設計師的,不是編譯器的。現在我們知道了在只有編譯器需要的時候才會生成預設建構函式,那麼是什麼時候才會生成呢?下面我們分幾種情況來一一探究。

類中含有預設建構函式的類型別成員

編譯器會生成預設建構函式的前提是:

  1. 沒有任何使用者自定義的建構函式;
  2. 類中至少含有一個成員是類型別的成員。

在上面的例子中我們增加一個類的定義,此類定義了一個預設建構函式,然後在Object類的定義裡面增加一個這個類的物件成員,增加程式碼如下:

class Base {
public:
    Base() {
        printf("Base class default constructor\n");
    }
    int a;
};

// 在Object類的定義里加上
Base b;

編譯後執行輸出:

Base class default constructor
2	// 這一行的輸出是隨機的,暫時先不管

從結果可以看到,Base類的預設建構函式被呼叫了,那麼肯定是有一個地方來呼叫它的,呼叫它的地方就在Object類的預設建構函式里面,我們來看下彙編程式碼,節選部分:

main:                                   # @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 32]
    call    Object::Object() [base object constructor]
    cmp     dword ptr [rbp - 32], 0
    je      .LBB0_2
    cmp     qword ptr [rbp - 24], 0
    jne     .LBB0_3

Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     qword ptr [rbp - 8], rdi
    mov     rdi, qword ptr [rbp - 8]
    add     rdi, 16
    call    Base::Base() [base object constructor]
    add     rsp, 16
    pop     rbp
    ret

從上面main函式的彙編程式碼裡面第7行看到呼叫了Object::Object()函式,這個就是編譯器為我們程式碼生成的Object類的預設建構函式,看看這個建構函式的彙編程式碼,在第20行程式碼裡看到它呼叫了Base::Base(),也就是呼叫了Base的預設建構函式。

我們再仔細看一下Object類的預設建構函式的彙編程式碼,發現裡面根本沒有給兩個成員變數val和str初始化,這也確確實實地說明了,類中成員變數的初始化的責任是程式設計師的責任,不是編譯器的責任,如果需要初始化成員變數,需要在程式碼中明確地對它們進行初始化,編譯器不會在背後隱式地初始化成員變數。所以上面程式的輸出結果是一個隨機的結果,有可能是1也有可能是2,因為不知道var或者str的值到底是什麼。

那麼如果Base類裡面沒有定義了預設建構函式,那麼是否還會生成預設建構函式呢?可以把Base類裡的預設建構函式程式碼註釋掉,編譯試試,結果可以看到這時並不會生成預設建構函式,無論是Base類還是Object類都不會。因為這時候編譯器不需要,編譯器不需要生成程式碼去呼叫Base類的預設建構函式,這也驗證了是否生成預設建構函式是看編譯器的需要而非看程式的需要。

那如果在Object類裡已經定義了預設建構函式呢?如下面的程式碼:

Object() {
    printf("Object class default constructor\n");
    val = 1;
    str = nullptr;
}

上面的預設建構函式的程式碼裡顯示地初始化了兩個資料成員,但並沒有顯示初始化類成員物件b,我們看下執行輸出結果:

Base class default constructor
Object class default constructor
1		// 這時這行輸出結果是明確的

從結果來看,Base類的預設建構函式是被呼叫了的,我們並沒有顯示地呼叫它,那麼它是在哪裡被呼叫的呢?我們繼續看下彙編程式碼:

Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     qword ptr [rbp - 8], rdi
    mov     rdi, qword ptr [rbp - 8]
    mov     qword ptr [rbp - 16], rdi       # 8-byte Spill
    add     rdi, 16
    call    Base::Base() [base object constructor]
    lea     rdi, [rip + .L.str.2]
    mov     al, 0
    call    printf@PLT
    mov     rax, qword ptr [rbp - 16]       # 8-byte Reload
    mov     dword ptr [rax], 1
    mov     qword ptr [rax + 8], 0
    add     rsp, 16
    pop     rbp
    ret

上面只節選了Object類的預設建構函式的彙編程式碼,其它的程式碼不用關注。上面彙編程式碼的第9行就是呼叫Base類的預設建構函式,第13到15行是給val和str賦值,[rbp - 16]是物件的起始地址,把它放到rax暫存器中,然後給它賦值為1,按宣告順序這應該是val變數,rax+8表示物件首地址偏移8位元組,也即是str的地址,給它賦值為0,也就是空指標。這說明了在有使用者自定義預設函式的情況下,編譯器會插入一些程式碼去呼叫類型別成員的建構函式,幫助程式設計師去構造這個類物件成員,前提是這個類物件成員定義了預設建構函式,它需要被呼叫去初始化這個類物件,編譯器這時才會生成一些程式碼去自動呼叫它。如果類中定義多個類物件成員,那麼編譯器將會按照宣告的順序依次去呼叫它們的建構函式。

繼承自帶有預設建構函式的類

編譯器會自動生成預設建構函式的第二中情況是:

  1. 類中沒有定義任何建構函式,
  2. 但繼承自一個父類,這個父類定義了預設建構函式。

把上面的程式碼修改一下,Base類不再是Object的成員,而是改為Object類繼承了Base類,修改如下:

class Object: public Base {
	// 刪除掉Base b;,其它不變
}

檢視生成的彙編程式碼,可以看到編譯器生成了Object類的預設建構函式的程式碼,裡面呼叫了Base類的預設建構函式,程式碼這裡就不貼出來了,跟上面的程式碼大同小異。其它的情況跟上面小節的分析很相似,這裡也不再重複分析。

類中宣告或者繼承一個虛擬函式

  1. 如果一個類中定義了一個及以上的虛擬函式;
  2. 或者繼承鏈上有一個以上的父類有定義了虛擬函式,同時類中沒有任何自定義的建構函式。

那麼編譯器則會生成一個預設建構函式。《C++物件封裝後的記憶體佈局》一文中也提到,增加了虛擬函式後物件的大小會增加一個指標的大小,大小為8位元組或者4位元組(跟平臺有關)。這個指標指向一個虛擬函式表,一般位於物件的起始位置。其實這個指標的值就是編譯器設定的,在沒有使用者自定義建構函式的情況下,編譯器會自動生成預設建構函式,並在其中設定這個值。下面來看下例子,去掉繼承,在Object類中增加一個虛擬函式,其它不變,如下:

class Object {
public:
    virtual void virtual_func() {
        printf("This is a virtual function\n");
    }
    int val;
    char* str;
};

來看下生成的彙編程式碼,節選Object類的預設建構函式:

Object::Object() [base object constructor]:		# @Object::Object() [base object constructor]
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     rax, qword ptr [rbp - 8]
    lea     rcx, [rip + vtable for Object]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    pop     rbp
    ret

說明編譯器為我們生成了一個預設建構函式,我們來看看預設建構函式的程式碼裡面幹了什麼。第2、3行時儲存上個函式的堆疊資訊,保證不破壞上個函式的堆疊。第4行rdi暫存器儲存的是第一個引數,這個值是main函式呼叫這個預設建構函式時設定的,是物件的首地址,第5行是把它儲存到rax暫存器中。第6-8行是最主要的內容,它的作用就是設定虛擬函式表指標的值,[rip + vtable for Object]是虛表的起始地址,看看它的內容是什麼:

vtable for Object:
    .quad   0
    .quad   typeinfo for Object
    .quad   Object::virtual_func()

它是一個表格,每一項佔用8位元組大小,共有三項內容,第一項內容為0,暫時先不管它,第二項是RTTI資訊,這裡只是一個指標,指向具體的RTTI表格,這裡先不展開,第三項才是儲存的虛擬函式Object::virtual_func的地址。所以第7行程式碼中加了16位元組的偏移量,就是跳過前面兩項,取得第三項的地址,然後第8行裡把它賦值給[rax],這個地址就是物件的首地址,至此就完成了在物件的起始地址插入虛擬函式表指標的動作。

如果已經自定義了預設建構函式,那麼編譯器則會在自定義的函式里面插入設定虛擬函式表指標的這段程式碼,確保虛擬函式能夠被正確呼叫。如果Object類沒有定義虛擬函式,而是繼承了一個有虛擬函式的類,那麼它也繼承了這個虛擬函式,編譯器就會為它生成虛擬函式表,然後設定虛擬函式表指標,也就是會生成預設建構函式來做這個事情。

這裡順帶提一下一個編碼的誤區,如果不小心可能就會掉入坑裡,就是在這種情況下,如果你想要快速初始化兩個資料成員,或者是受C語言使用習慣影響,直接使用memset函式來把obj物件清0,如下面這樣:

Object obj;
memset((void*)&obj, 0, sizeof(obj));
Object* pobj = &obj;
pobj->virtual_func();

那麼如最後一行使用指標或者引用來呼叫虛擬函式的時候,程式執行到這裡就會崩潰,因為在預設建構函式里給物件設定的虛擬函式表指標被清空了,呼叫虛擬函式的時候是需要透過虛擬函式表指標去拿到虛擬函式的地址然後呼叫的,所以這裡解引用了一個空指標,引起了程式的崩潰。所以請記住不要隨便對一個類物件進行memset操作,除非這個類你確定只含有純資料成員才可以這樣做。

類的繼承鏈上有一個virtual base class

如果類的繼承鏈上有一個虛基類,同時類中沒有定義任何建構函式,那麼編譯器就會自動生成一個預設建構函式,它的作用同上面分析虛擬函式時是一樣的,就是在預設建構函式里設定虛表指標。如下面的例子:

class Grand {
public:
    int a;
};

class Base1: virtual public Grand {
public:
    int b;
};

class Base2: virtual public Grand {
public:
    int c;
};

class Derived: public Base1, public Base2 {
public:
    int d;
};

int main() {
    Derived obj;
    obj.a = 1;
    Grand* p = &obj;
    p->a = 10;
    
    return 0;
}

想要訪問爺爺類Grand中的成員a,如果是透過靜態型別的方式訪問,如上面程式碼中的第23行,那麼編譯時是可以確定a相對於物件起始地址的偏移量的,直接透過偏移量就可以訪問到,這在編譯時就可以確定下來的。如果是透過動態型別來訪問,也就是說是透過父類的指標或者引用型別來訪問,因為在編譯時不知道在執行時它指向什麼型別,它既可以指向爺爺類或者父類,也可以指向孫子類,所以在編譯時並不能確定它的具體型別,也就不能確定它的偏移量,所以這種情況只能透過虛表來訪問,編譯器在編譯時會生成一個虛表,其實是和虛擬函式共用同一張表,也有的編譯器是分開的,不同的編譯器有不同的實現方法。透過在表中記錄不同的型別有不同的偏移量,那麼在執行時可以透過訪問表得到具體的偏移量,從而得到成員a的地址。所以需要在物件構造時設定虛表的指標,具體的彙編程式碼跟上面虛擬函式的類似。

類內初始化

在C++11標準中,新增了在定義類時直接對成員變數進行初始化的機制,稱為類內初始化。如下面的程式碼:

class Object {
public:
    int val = 1;
    char* str = nullptr;
};

int main() {
    Object obj;
    
    return 0;
}

編譯成對應的彙編程式碼:

main:                                   # @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 24]
    call    Object::Object() [base object constructor]
    xor     eax, eax
    add     rsp, 32
    pop     rbp
    ret
Object::Object() [base object constructor]:		# @Object::Object() [base object constructor]
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     rax, qword ptr [rbp - 8]
    mov     dword ptr [rax], 1
    mov     qword ptr [rax + 8], 0
    pop     rbp
    ret

類內初始化,就是告訴編譯器需要對這些成員變數在構造時進行初始化,那麼編譯器就需要生成一個預設建構函式來做這個事情,從上面的彙編程式碼可以看到,在Object::Object()函式里,第17、18行程式碼即是對兩個成員分別賦值。

總結

上面的五種情況,編譯器必須要為沒有定義建構函式的類生成一個預設建構函式,或者在程式設計師定義的預設建構函式中擴充內容。這個被生成出來的預設建構函式只是為了滿足編譯器的需要而非程式設計師的需要,它需要去呼叫類物件成員或者父類的預設建構函式,或者設定虛表指標,所以在這個生成的預設建構函式里,它預設不會去初始化類中的資料成員,初始化它們是程式設計師的責任。

除了這幾種情況之外的,如果我們沒有定義任何建構函式,編譯器也沒有生成預設建構函式,但是它們卻可以構造出來。C++語言的語義保證了在這種情況下,它們有一個隱式的、平凡的(或者無用的)預設建構函式來幫助構造物件,但是它們並不會也不需要被顯示的生成出來。

此篇文章同步釋出於我的微信公眾號:編譯器背後的行為之預設建構函式

如果您感興趣這方面的內容,請在微信上搜尋公眾號iShare愛分享或者微訊號iTechShare並關注,以便在內容更新時直接向您推送。

相關文章