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

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

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

寫作不易,請有心人到我的公眾號上點點贊支援一下,增加一下熱度,也好讓更多的人能看到,公眾號裡有完整的文章列表可供閱讀。

有以下三種情況,一個類物件的初始化是以同一型別的另一個物件為初值。

第一種情況,定義一個類物件時以另一個物件作為初始值,如下:

class Foo {};
Foo a;
Foo b = a;

第二種情況,當呼叫一個函式時,這個函式的引數要求傳入一個類物件:

class Foo {};
void Bar(Foo obj) {}
Foo a;
Bar(a);

第三種情況,是函式里返回一個類的物件:

class Foo {};
Foo Bar() {
    Foo x;
	// ...
    return x;
}

這幾種情況都是用一個類物件做為另一個物件的初值,假如這個類中有定義了複製建構函式,那麼這時就會呼叫這個類的複製建構函式。但是如果類中沒有定義複製建構函式,那麼又會是怎樣?很多人可能會認為編譯器會生成一個複製建構函式來複製其中的內容,那麼事實是否如此呢?

C++標準裡描述到,如果一個類沒有定義複製建構函式,那麼編譯器就會隱式地宣告一個複製建構函式,它會判斷這個複製建構函式是nontrivial(有用的、不平凡的)還是trivial(無用的、平凡的),只有nontrivial的才會顯式地生成出來。那麼怎麼判斷是trivial還是nontrivial的呢?編譯器是根據這個類是否展現出有逐位/逐成員複製的語意,那什麼是有逐位/逐成員複製的語意?來看看下面的例子。

有逐位複製語意的情形

#include <string.h>
#include <stdio.h>

class String {
public:
    String(const char* s) {
        if (s) {
            len = strlen(s);
            str = new char[len + 1];
            memcpy(str, s, len);
            str[len] = '\0';
        }
    }
    void print() {
        printf("str=%s, len=%d\n", str, len);
    }
private:
    char* str;
    int len;
};

int main() {
    String a("hello");
    a.print();
    String b = a;
    b.print();
    
    return 0;
}

在上面程式碼中,是否需要為String類生成一個顯式的複製建構函式,以便在第25行程式碼構造物件b時呼叫它?我們可以來看看上面程式碼生成的彙編程式碼,節選main函式部分:

main:								# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 48
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 24]
    lea     rsi, [rip + .L.str]
    call    String::String(char const*) [base object constructor]
    lea     rdi, [rbp - 24]
    call    String::print()
    mov     rax, qword ptr [rbp - 24]
    mov     qword ptr [rbp - 40], rax
    mov     rax, qword ptr [rbp - 16]
    mov     qword ptr [rbp - 32], rax
    lea     rdi, [rbp - 40]
    call    String::print()
    xor     eax, eax
    add     rsp, 48
    pop     rbp
    ret

從彙編程式碼中看到,除了在第8行呼叫了帶一個引數的建構函式String(const char* s)之外,並沒有呼叫其他的複製建構函式,當然全部的彙編程式碼中也沒有見到生成的複製建構函式。說明這種簡單的情形只需要進行逐位複製類物件的內容即可,不需要生成一個複製建構函式來做這個事情。看看程式執行輸出結果:

str=hello, len=5
str=hello, len=5

這兩行輸出內容是上面程式碼第24行和第26行呼叫的輸出,說明這時物件a和物件b的內容是一模一樣的,也就是說物件b的內容完全複製了物件a的內容。簡單解釋一下上面的彙編程式碼,第4行是在main函式里開闢了48位元組的棧空間,用於存放區域性變數a和b,[rbp - 24]是物件a的起始地址,[rbp - 40]是物件b的起始地址。第11、12行就是將物件a的第一個成員先複製到rax暫存器,然後再複製給物件b的第一個成員。第13、14行就是將物件a的第2個成員(物件a的地址偏移8位元組)複製到rax,然後再複製給物件b的第2個成員(物件b的地址偏移8位元組)。

編譯器認為這種情形只需要逐成員的複製對應的內容即可,不需要生成一個複製建構函式來完成,而且生成一個複製建構函式然後呼叫它,效率要比直接複製內容更低下,這種在不會產生副作用的情況下,不生成複製建構函式是一種更高效的做法。

上面的結果從編譯器的角度來看是沒有問題的,而且是合理的,它認為只需要逐成員複製就足夠了。但是從程式的角度來看,它是有問題的。你能否看出問題出在哪裡?首先我們在建構函式中申請了記憶體,所以需要一個解構函式來在物件銷燬的時候來釋放申請的記憶體,我們加上解構函式:

~String() {
    printf("destructor\n");
    delete[] str;
}

加上解構函式之後再執行,發現程式崩潰了。原因在於記憶體被雙重釋放了,物件a中的str指標賦值給物件b的str,這時物件a和物件b的str成員都指向同一塊記憶體,在main函式結束後物件a和物件b先後銷燬而呼叫了解構函式,解構函式里釋放了這一塊記憶體,所以導致了重複釋放記憶體引起程式崩潰。這就是淺複製與深複製的問題,編譯器只會做它認為正確的事情,而邏輯上是否正確是程式設計師應該考慮的事情,所以從邏輯上來看是否需要明確寫出複製建構函式是程式設計師的責任,但是如果你認為沒有必要明確定義一個複製建構函式,比如說不需要申請和釋放記憶體,或者其它需要獲取和釋放資源的情況,只是簡單地對成員進行賦值的話,那就沒有必要寫出一個複製建構函式,編譯器會在背後為你做這些事情,效率還更高一些。

為了程式的正確性,我們顯式地為String類定義了一個複製建構函式,加上之後程式執行就正常了:

// 下面程式碼暫時忽略了物件中str原本已經申請過記憶體的情況。
String(const String& rhs) {
    printf("copy constructor\n");
    if (rhs.str && rhs.len != 0) {
        len = rhs.len;
        str = new char[len + 1];
        memcpy(str, rhs.str, len);
        str[len] = '\0';
    }
}

執行輸出如下,說明自定義的複製建構函式被呼叫了:

str=hello, len=5
copy constructor
str=hello, len=5
destructor
destructor

上面舉例了具有逐位複製語意的情形,那麼有哪些情形是不具有逐位複製語意的呢?那就是在編譯器需要插入程式碼去做一些事情的時候以及擴充套件了類的內容的時候,如以下的這些情況:

  1. 類中含有類型別的成員,並且它定義了複製建構函式;
  2. 繼承的父類中定義了複製建構函式;
  3. 類中定義了一個以上的虛擬函式或者從父類中繼承了虛擬函式;
  4. 繼承鏈上有一個父類是virtual base class。

下面我們按照這幾種情況來一一探究。

需要呼叫類型別成員或者父類的複製建構函式的情形

如果一個類裡面含有一個或以上的類型別的成員,並且這個成員的類定義中有一個複製建構函式;或者一個類繼承了父類,父類定義了複製建構函式,那麼如果這個類沒有定義複製建構函式的話,編譯器就會為它生成一個複製建構函式,用來呼叫類物件成員或者父類的複製建構函式,由於這兩種情況差不多,所以放在一起分析。

如在上面的程式碼中,新增一個Object類,類裡含有String型別的成員,見下面的程式碼:

// String類的定義同上

class Object {
public:
    Object(): s("default"), num(10) {}
    void print() {
        s.print();
        printf("num=%d\n", num);
    }
private:
    String s;
    int num;
};

// main函式改成如下
int main() {
    Object a;
    a.print();
    Object b = a;
    b.print();
    
    return 0;
}

執行結果如下:

str=default, len=7
num=10
copy constructor
str=default, len=7
num=10
destructor
destructor

從結果中可以看出最重要的兩點:

  1. String類的複製建構函式被呼叫了;
  2. 物件b的成員num被賦予正確的值。

我們來進一步,首先看一下生成的彙編程式碼:

main:																		# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 80
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 32]
    mov     qword ptr [rbp - 80], rdi		# 8-byte Spill
    call    Object::Object() [base object constructor]
    mov     rdi, qword ptr [rbp - 80]		# 8-byte Reload
    call    Object::print()
    jmp     .LBB0_1
.LBB0_1:
    lea     rdi, [rbp - 72]
    lea     rsi, [rbp - 32]
    call    Object::Object(Object const&) [base object constructor]
    jmp     .LBB0_2
.LBB0_2:
    lea     rdi, [rbp - 72]
    call    Object::print()
    jmp     .LBB0_3
.LBB0_3:
 # 以下程式碼省略

上面是節選main函式的部分彙編程式碼,執行解構函式部分省略掉。第10行對應的是main函式里的第18行a.print();,編譯器會把它轉換成print(&a),引數就是物件a的地址,也就是[rbp - 80],把它放到rdi暫存器中作為引數,從上面的程式碼中知道[rbp - 80]其實等於[rbp - 32],[rbp - 32]就是物件a的地址。第15行程式碼對應的就是Object b = a;這一行的程式碼,可見它呼叫了Object::Object(Object const&)這個複製建構函式,但C++的程式碼中我們並沒有顯式地定義這個函式,這個函式是由編譯器自動生成出來的。它有兩個引數,第一個引數是物件b的地址,即[rbp - 72],存放在rdi暫存器,第二個引數是物件a的地址,即[rbp - 32],存放在rsi暫存器。編譯器會把上面的呼叫轉換成Object::Object(&b, &a);。

接下來看看編譯器生成的複製建構函式的彙編程式碼:

Object::Object(Object const&) [base object constructor]:	# @Object::Object(Object const&) [base object constructor]
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rdi, qword ptr [rbp - 8]
    mov     qword ptr [rbp - 24], rdi       # 8-byte Spill
    mov     rsi, qword ptr [rbp - 16]
    call    String::String(String const&) [base object constructor]
    mov     rax, qword ptr [rbp - 24]       # 8-byte Reload
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 16]
    mov     dword ptr [rax + 16], ecx
    add     rsp, 32
    pop     rbp
    ret

第5、6行是把物件b的地址(rdi暫存器)存放到[rbp - 8]中,把物件a的地址(rsi暫存器)存放到[rbp - 16]中。第10行程式碼就是去呼叫String類的複製建構函式了。第11到14行程式碼是用物件a中的num成員的值給物件b的num成員賦值,[rbp - 16]是物件a的起始地址,存放到rcx暫存器中,然後再加16位元組的偏移量就是num成員的地址,加16位元組的偏移量是為了跳過前面的String型別的成員s,它的大小為16位元組。rax暫存器存放的是物件b的起始地址,[rax + 16]就是物件b中的num成員的地址。

從這裡可以得出一個結論:編譯器生成的複製建構函式除了會去呼叫類型別成員的複製建構函式之外,還會複製其它的資料成員,包括整形資料、指標和陣列等等,它和生成的預設建構函式不一樣,生成的預設建構函式不會去初始化這些資料成員。

如果類型別成員裡沒有定義複製建構函式,比如把String類中的複製建構函式註釋掉,這時編譯器就不會生成一個複製建構函式,因為不需要,這時它會實行逐成員複製的方式,若遇到成員是類型別的,則遞迴地執行逐成員複製的操作。

含有虛擬函式的情形

從前面的文章中我們知道,當一個類定義了一個或以上的虛擬函式時,或者繼承鏈上的父類中有定義了虛擬函式的話,那麼編譯器就會為他們生成虛擬函式表,並會擴充類物件的記憶體佈局,在類物件的起始位置插入虛擬函式表指標,以指向虛擬函式表。這個虛擬函式表指標很重要,如果沒有設定好正確的值,那麼將引起呼叫虛擬函式的混亂甚至引起程式的崩潰。編譯器往類物件插入虛擬函式表指標將導致這個類不再具有逐成員複製的語意,當程式中沒有顯式定義複製建構函式時,編譯器需要為它自動生成一個複製建構函式,以便在適當的時機設定好這個虛擬函式表指標的值。我們以下面的例子來分析一下:

#include <stdio.h>

class Base {
public:
    virtual void virtual_func() {
        printf("virtual function in Base class\n");
    }
private:
    int b;
};

class Object: public Base {
public:
    virtual void virtual_func() {
         printf("virtual function in Object class\n");
    }
private:
    int num;
};

void Foo(Base& obj) {
    obj.virtual_func();
}

int main() {
    Object a;
    Object a1 = a;
    Base b = a;
    Foo(a);
    Foo(b);
    
    return 0;
}

看下生成的彙編程式碼,節選main函式部分:

main:									# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 64
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 24]
    call    Object::Object() [base object constructor]
    lea     rdi, [rbp - 40]
    lea     rsi, [rbp - 24]
    call    Object::Object(Object const&) [base object constructor]
    lea     rdi, [rbp - 56]
    lea     rsi, [rbp - 24]
    call    Base::Base(Base const&) [base object constructor]
    lea     rdi, [rbp - 24]
    call    Foo(Base&)
    lea     rdi, [rbp - 56]
    call    Foo(Base&)
    xor     eax, eax
    add     rsp, 64
    pop     rbp
    ret

上面彙編程式碼中的第10行對應C++程式碼中的第27行,這裡呼叫的是Object類的複製建構函式,彙編程式碼中的第13行對應C++程式碼中的第28行,這裡呼叫的是Base類的複製建構函式,這說明了編譯器為Object類和Base類都生成了複製建構函式。繼續分析這兩個類的複製建構函式的彙編程式碼:

Object::Object(Object const&) [base object constructor]:	# @Object::Object(Object const&) [base object constructor]
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rdi, qword ptr [rbp - 8]
    mov     qword ptr [rbp - 24], rdi       # 8-byte Spill
    mov     rsi, qword ptr [rbp - 16]
    call    Base::Base(Base const&) [base object constructor]
    mov     rax, qword ptr [rbp - 24]       # 8-byte Reload
    lea     rcx, [rip + vtable for Object]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 12]
    mov     dword ptr [rax + 12], ecx
    add     rsp, 32
    pop     rbp
    ret
Base::Base(Base const&) [base object constructor]:	# @Base::Base(Base const&) [base object constructor]
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rax, qword ptr [rbp - 8]
    lea     rcx, [rip + vtable for Base]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 8]
    mov     dword ptr [rax + 8], ecx
    pop     rbp
    ret

在Object類的複製建構函式里,上面彙編程式碼的第10行,呼叫了Base類的複製建構函式,這裡的意思是先構造Base子類部分,在Base類的複製建構函式里,上面彙編程式碼的第27行到29行,在這裡設定了Base類的虛擬函式表指標,因為這裡構造的是Base子類的物件,所以這裡設定的是Base類的虛擬函式表指標。然後返回到Object類的複製建構函式,在上面彙編程式碼的第12行到第14行,這裡又重新設定回Object類的虛擬函式表指標,因為構造完Base子類之後繼續構造Object類,需要重設回Object類的虛擬函式表指標,Base類和Object類的虛擬函式表是不同的兩個表,所以需要為它們對應的物件設定對應的虛擬函式表指標。

其實同一型別的物件的賦值是可以採用逐成員複製的方式來完成的,比如像Object a1 = a;這行程式碼,因為它們的虛擬函式表是同一個,直接複製物件a的虛擬函式表指標給a1物件沒有任何問題。但是問題出在於使用派生類的物件給父類的物件賦值時,這裡會發生切割,把派生類物件中的父類子物件部分複製給父類物件,如果沒有編譯器擴充的部分(這裡是虛擬函式表指標),只是複製資料部分是沒有問題的,但是如果把派生類的虛擬函式表指標賦值給父類子物件,這將導致虛擬函式呼叫的混亂,本該呼叫父類的虛擬函式的,卻呼叫了派生類的虛擬函式。所以編譯器需要重設這個虛擬函式表指標的值,也就是說這裡不能採用逐成員複製的手法了,當程式中沒有顯式地定義複製建構函式時編譯器就會生成一個,或者在已有的複製建構函式中插入程式碼,來完成重設虛擬函式表指標這個工作。

再看下C++程式碼中的這三行程式碼:

Base b = a;
Foo(a);
Foo(b);

第一行的賦值語句,雖然是使用派生類Object的物件a作為初值,但是呼叫的卻是Base類的複製建構函式(見main函式的彙編程式碼第13行),因為b的型別是Base類。這就保證了只使用了物件a中的Base子物件部分的內容,以及確保設定的虛擬函式表指標是指向Base類的虛擬函式表,這樣在呼叫Foo函式時,分別使用物件a和b作為引數,儘管Foo函式的形參使用的是“Base&”,是使用基類的引用型別,但卻不會引起呼叫上的混亂。第二個呼叫使用b作為引數,它是Base類的物件,呼叫的是Base類的虛擬函式,這兩行的輸出結果是:

virtual function in Object class
virtual function in Base class

繼承鏈上有virtual base class的情形

當一個類的繼承鏈上有一個virtual base class時,virtual base class子物件的佈局會重排,記憶體佈局的分析可以參考另一篇文章《C++物件封裝後的記憶體佈局》。為使得能支援虛繼承的機制,編譯器執行時需要知道虛基類的成員位置,所以編譯器會在編譯時生成一個虛表,這個表格裡會記錄成員的相對位置,在構造物件時會插入一個指標指向這個表。這使得類失去了逐成員複製的語意,如果一個類物件的初始化是以另一個相同型別的物件為初值,那麼逐成員複製是沒有問題的,問題在於如果是以派生類的物件賦值給基類的物件,這時候會發生切割,編譯器需要計算好成員的相對位置,以避免訪問出現錯誤,所以編譯器需要生成複製建構函式來做這樣的事情。以下面的程式碼為例:

#include <stdio.h>

class Grand {
public:
    int g = 1;
};

class Base1: virtual public Grand {
    int b1 = 2;
};

class Base2: virtual public Grand {
    int b2 = 3;
};

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

int main() {
    Derived d;
    Base2* pb2 = &d;
    d.g = 11;
    pb2->g = 10;
    Base2 b2 = *pb2;
    
    return 0;
}

第25行的程式碼是將派生類Derived類的物件賦值給Base2父類物件,這將會發生切割,將Derived類中的Base2子物件部分複製過去,看下對應的彙編程式碼:

# 節選部分main函式彙編
mov     rsi, qword ptr [rbp - 56]
lea     rdi, [rbp - 72]
call    Base2::Base2(Base2 const&) [complete object constructor]

[rbp - 56]存放的是C++程式碼裡的pb2的值,也就是物件d的地址,存放在rsi暫存器中,[rbp - 72]是物件b2的地址,存放到rdi暫存器中,然後將rsi和rdi暫存器作為引數傳遞給Base2的複製建構函式,然後呼叫它。繼續看下Base2的複製建構函式的彙編程式碼:

Base2::Base2(Base2 const&) [complete object constructor]:	# @Base2::Base2(Base2 const&) [complete object constructor]
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     qword ptr [rbp - 16], rsi
    mov     rax, qword ptr [rbp - 8]
    mov     rcx, qword ptr [rbp - 16]
    mov     rdx, qword ptr [rcx]
    mov     rdx, qword ptr [rdx - 24]
    mov     ecx, dword ptr [rcx + rdx]
    mov     dword ptr [rax + 12], ecx
    lea     rcx, [rip + vtable for Base2]
    add     rcx, 24
    mov     qword ptr [rax], rcx
    mov     rcx, qword ptr [rbp - 16]
    mov     ecx, dword ptr [rcx + 8]
    mov     dword ptr [rax + 8], ecx
    pop     rbp
    ret

首先將兩個引數(分別存放在rdi和rsi暫存器)複製到棧空間[rbp - 8]和[rbp - 16]中,第8到11行程式碼就是將物件d中的Grand子物件的成員複製到b2物件中,物件的前8個位元組在構造物件的時候已經設定好了虛表的指標,這裡將指標指向的內容存放到rdx暫存器中,第9行取得虛基類成員的偏移地址然後存放在rdx暫存器,第10行將物件的首地址加上偏移地址,取得虛基類的成員然後複製到ecx暫存器,在第11行程式碼裡複製給[rax + 12],即b2物件的起始地址加上12位元組的偏移量(8位元組的虛表指標加上成員變數b2佔4位元組),即完成對Grand類中的成員變數g的複製。

所以對於有虛基類的情況,將一個派生類的物件賦值給基類物件時,不能採取逐成員複製的手法,需要藉助虛表來計算出虛基類的成員的相對位置,以獲得正確的成員地址,需要生成複製建構函式來完成。

抑制合成複製建構函式的情況

C++11標準之後新增了delete關鍵字,它可以指定不允許編譯器生成哪些函式,比如我們不允許複製一個類物件,那麼可以將此類的複製建構函式宣告為=delete的。例如標準庫中的iostream類,它不允許複製,防止兩個物件同時指向同一塊快取。如果一個類的定義中有一個類型別成員,而此成員的複製建構函式宣告為=delete的,或者類的父類中宣告瞭複製建構函式為=delete的,那麼這個類的複製建構函式也會被編譯器宣告為delete的,這個類的物件將不允許被複製,如以下的程式碼:

class Base {
public:
    Base() = default;
    Base(const Base& rhs) = delete;
};

class Object {
    Base b;
};

int main() {
    Object d;
    Object d1 = d;	// 此行編譯錯誤
    
    return 0;
}

上面程式碼的第13行會引起編譯錯誤,原因就是Object類沒有複製建構函式,不允許賦值的操作,同樣地,複製賦值運算子也將被宣告為delete的。

總結

  • 複製賦值運算子的情況和複製建構函式的情況類似,可以採用上述的方法來分析。
  • 當不需要涉及到資源的分配和釋放時,不需要顯示地定義複製建構函式,編譯器會為我們做好逐成員複製的工作,效率比去呼叫一個複製建構函式要更高效一些。
  • 當你需要為程式定義一個解構函式時,那麼肯定也需要定義複製建構函式和複製賦值運算子,因為當你需要在解構函式中去釋放資源的時候,說明在複製物件的時候需要為新物件申請新的資源,以避免兩個物件同時指向同一塊資源。
  • 當你需要為程式定義複製建構函式時,那麼也同時需要定義複製賦值運算子,反之亦然,但是卻並不一定需要定義解構函式,比如在構造物件時為此物件生成一個UUID,這時在析構物件時並不需要釋放資源。

此篇文章同步釋出於我的微信公眾號:深度解讀《深度探索C++物件模型》之複製建構函式

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

相關文章