C++ 構造和析構

啥是構造和析構,下面我們還是從C語言的角度來學習C++,簡述構造和析構語法的設計由來。

先看如下代碼:

struct Test
{
    int a;
    int b;
};

int main()
{
    Test test;
    test.a = 5;
    test.b = 7;
    printf("%d %d",test.a,test.b);
    return 0;
}

代碼應該很簡單,其本意其實是在創建對象的時候初始化a和b,所以我們最好再改進下,更符合我們的編程邏輯

struct Test
{
    int a;
    int b;
    void init(int a=0,int b=0)
    {
        this->a = a;
        this->b = b;
    }
};

int main()
{
    Test test;
    test.init(5,7); //調用表示初始化
    printf("%d %d",test.a,test.b);
    return 0;
}

上面的代碼配上了缺省參數(對這個不理解的可以上一篇博客),看着代碼邏輯清晰多了。

下面我們考慮一個問題,作爲我們(程序員)來說,會不會有可能忘記調用或者調用多次的情況呢?

這個情況是有的,畢竟封裝這個類的人和調用者可能不是同一個人哦。但是爲啥調用多次會有問題呢?我們可以考慮下類裏面有指針指向堆內存的情況

struct Test
{
    int a;
    int b;
    char *str;
    void init(int a=0,int b=0)
    {
        this->a = a;
        this->b = b;
        str = (char*)malloc(20);
    }
};

當多次調用後就會存在內存泄露的問題,因爲之前的str指向的堆內存可能沒釋放

所以 引出了構造,下面看一下構造的語法,使用構造替換init成員函數

struct Test
{
    int a;
    int b;
    char *str;
    // 1. 與類同名
    // 2. 沒有返回值
    Test(int a=3,int b=7)
    {
        this->a = a;
        this->b = b;
        str = (char*)malloc(20);
    }
};

int main()
{
    Test test; // 3.創建對象的時候執行
    printf("%d %d",test.a,test.b);
    return 0;
}

首先看第一點,同名這個是語法規定,第二點,無返回值,你使用返回值時,會報如下錯誤

void Test(int a=3,int b=7)
type(s) preceding 'Test' (constructor with return type, or illegal redefinition of current class-name?)

第三點的話我們來看一下彙編

22:       Test test; // 3.創建對象的時候執行
00401268 6A 07                push        7 ;缺省參數
0040126A 6A 03                push        3 ;缺省參數
0040126C 8D 4D F4             lea         ecx,[ebp-0Ch]
0040126F E8 91 FD FF FF       call        @ILT+0(Test::Test) (00401005) ;調用構造

可以看出來,調用初始化這個活只是編譯器幫我們幹了,所以上面說的忘記調用應該是不會有了,那麼重複調用呢,我們來試一下

int main()
{
    Test test; // 3.創建對象的時候執行
    //test.Test(1,2); err
    test.Test::Test(1,2); //ok 編譯器bug
    printf("%d %d",test.a,test.b);
    return 0;
}

平常情況來說,使用對象顯示調用構造是會報錯的,下面那個算是編譯器bug吧,這裏就不深究了

下面,我們把Test構造中的缺省參數去掉,編譯看看如何?

 'Test' : no appropriate default constructor available

編譯報錯,沒有合適的默認構造可用,那麼我們再添加一個空參數構造

    Test()
    {
        this->a = 5;
        this->b = 7;
        this->str = NULL;
    }
    Test(int a,int b)
    {
        this->a = a;
        this->b = b;
        this->str = NULL;
    }

OK,編譯通過,說明構造是可以重載的(對於重載可以看上一篇內容)

26:       Test test;
00401268 8D 4D F4             lea         ecx,[ebp-0Ch]
0040126B E8 A4 FD FF FF       call        @ILT+15(Test::Test) (00401014)

觀察彙編,發現其調用了無參的Test構造,那麼這裏你可能會有個疑問,既然是構造函數,我可以這樣寫麼

    Test test(); //err

爲什麼會編譯不過呢?這裏的話,其實細細看一下,這行代碼是不是比較像函數聲明呢,所以編譯器報錯了。

下面再來思考一個問題,構造函數是必須的麼,無構造函數的時候編譯器會幫我們默認生成麼

其實前面這個問題拿一開始的例子就可以解決了,因爲我們一開始調用了inti方法進行初始化,並沒有寫構造方法,使用構造是可以缺省的,那編譯器會 幫我們生成麼,下面我們看一下彙編

13:       Test test;
14:       printf("%d %d",test.a,test.b);
00401268 8B 45 FC             mov         eax,dword ptr [ebp-4]
0040126B 50                   push        eax
0040126C 8B 4D F8             mov         ecx,dword ptr [ebp-8]
0040126F 51                   push        ecx
00401270 68 1C 10 43 00       push        offset string "%d %d" (0043101c)
00401275 E8 36 70 00 00       call        printf (004082b0)
0040127A 83 C4 0C             add         esp,0Ch
15:       return 0;
0040127D 33 C0                xor         eax,eax

可以看出來,編譯器不會幫我們生成,所以也沒調用。但是有時候,編譯器是會幫我們默認生成的哦,什麼情況下呢,下面來分析一種,看如下代碼

struct A
{
    int c;
    int d;
    A(int c=5,int d=4)
    {
        this->c = c;
        this->d = d;
    }
};

struct Test
{
    int a;
    A aObj;
    int b;
};

觀察反彙編

25:       Test test;
00401278 8D 4D F0             lea         ecx,[ebp-10h]
0040127B E8 94 FD FF FF       call        @ILT+15(Test::Test) (00401014)

此時,編譯器幫我們默認生成了一個構造,跟進去看一下生成的默認構造彙編代碼

Test::Test:
004012C0 55                   push        ebp
004012C1 8B EC                mov         ebp,esp
004012C3 83 EC 44             sub         esp,44h
004012C6 53                   push        ebx
004012C7 56                   push        esi
004012C8 57                   push        edi
004012C9 51                   push        ecx
004012CA 8D 7D BC             lea         edi,[ebp-44h]
004012CD B9 11 00 00 00       mov         ecx,11h
004012D2 B8 CC CC CC CC       mov         eax,0CCCCCCCCh
004012D7 F3 AB                rep stos    dword ptr [edi]
004012D9 59                   pop         ecx
004012DA 89 4D FC             mov         dword ptr [ebp-4],ecx
004012DD 6A 04                push        4
004012DF 6A 05                push        5
004012E1 8B 4D FC             mov         ecx,dword ptr [ebp-4]
004012E4 83 C1 04             add         ecx,4
004012E7 E8 2D FD FF FF       call        @ILT+20(A::A) (00401019)
004012EC 8B 45 FC             mov         eax,dword ptr [ebp-4]
004012EF 5F                   pop         edi
004012F0 5E                   pop         esi
004012F1 5B                   pop         ebx
004012F2 83 C4 44             add         esp,44h
004012F5 3B EC                cmp         ebp,esp
004012F7 E8 14 6F 00 00       call        __chkesp (00408210)
004012FC 8B E5                mov         esp,ebp
004012FE 5D                   pop         ebp
004012FF C3                   ret

會發現,在編譯器生成的構造中去調用了成員A的構造方法,此時雖然Test不需要構造,但是其成員aObj需要初始化,所以只能編譯器幫我們生成個默認構造再進行調用。

思考下下面這樣情況,假如A類也沒有構造呢,編譯器還會幫我們生成麼

這個的話也是不會的,因爲邏輯上來說,這個沒必要,所以編譯器也不會幹這事,浪費

那假如A類裏面有個空的構造方法呢,那麼編譯器是會幫我們生成默認的Test構造,用於調用A的構造(儘管它是空的)

所以只有被需要的時候,編譯器纔會幫我們生成默認構造哦。

下面,來糾正一個概念,其實上面的在構造方法裏面的並不是真正意義上的初始化,而是賦值,爲什麼這麼說呢,我們可以添加一個常量來試試

struct Test
{
    const int c;
    // err 'c' : must be initialized in constructor base/member initializer list
    Test()
    {
        c = 20; //err l-value specifies const object
    }
};

編譯報錯,根據報錯,編譯器已經給出了一個方案,那就是 初始化表

struct Test
{
    const int c;
    // ok
    Test() : c(10)
    {
        //c = 20; //err l-value specifies const object
    }
};

當數據成員是引用,常量,類對象(無帶參構造)時,我們必須使用初始化列表進行初始化。

下面,我們觀察如下的代碼來探究下初始化順序

struct A
{
    int c;
    int d;
    A(int c=5,int d=4)
    {
        this->c = c;
        this->d = d;
    }
};

struct Test
{
    int a;
    A b;
    A c;
    const int d;
    Test() : d(10),b(9),a(8)
    {
    }
};

int main()
{
    Test test;
    return 0;
}

下面我們來分析下Test構造對應的彙編代碼

22:       Test() : d(10),b(9),a(8)
23:       {
004012A0 55                   push        ebp
004012A1 8B EC                mov         ebp,esp
004012A3 83 EC 44             sub         esp,44h
004012A6 53                   push        ebx
004012A7 56                   push        esi
004012A8 57                   push        edi
004012A9 51                   push        ecx
004012AA 8D 7D BC             lea         edi,[ebp-44h]
004012AD B9 11 00 00 00       mov         ecx,11h
004012B2 B8 CC CC CC CC       mov         eax,0CCCCCCCCh
004012B7 F3 AB                rep stos    dword ptr [edi]
004012B9 59                   pop         ecx ;this指針
004012BA 89 4D FC             mov         dword ptr [ebp-4],ecx
004012BD 8B 45 FC             mov         eax,dword ptr [ebp-4]
004012C0 C7 00 08 00 00 00    mov         dword ptr [eax],8 ;先初始化 a
004012C6 6A 04                push        4
004012C8 6A 09                push        9
004012CA 8B 4D FC             mov         ecx,dword ptr [ebp-4]
004012CD 83 C1 04             add         ecx,4
004012D0 E8 44 FD FF FF       call        @ILT+20(A::A) (00401019) ;初始化b,調用A構造
004012D5 6A 04                push        4
004012D7 6A 05                push        5
004012D9 8B 4D FC             mov         ecx,dword ptr [ebp-4]
004012DC 83 C1 0C             add         ecx,0Ch
004012DF E8 35 FD FF FF       call        @ILT+20(A::A) (00401019) ;初始化c,調用A構造
004012E4 8B 4D FC             mov         ecx,dword ptr [ebp-4]
004012E7 C7 41 14 0A 00 00 00 mov         dword ptr [ecx+14h],0Ah ;初始化d
24:       }
004012EE 8B 45 FC             mov         eax,dword ptr [ebp-4]
004012F1 5F                   pop         edi
004012F2 5E                   pop         esi
004012F3 5B                   pop         ebx
004012F4 83 C4 44             add         esp,44h
004012F7 3B EC                cmp         ebp,esp
004012F9 E8 22 6F 00 00       call        __chkesp (00408220)
004012FE 8B E5                mov         esp,ebp
00401300 5D                   pop         ebp
00401301 C3                   ret

分析完彙編代碼,會發現其初始化的順序是和定義時的順序一樣的,爲何其初始化順序和定義時一樣呢,首先我們拿這個例子進行舉例,在構造Test時,因爲該類的成員b,c也是類對象,所以肯定需要先對其進行初始化(因爲構造函數中可能會用到b,c對象,所以肯定得先初始化),那麼繼續考慮,bc的對象是A類,要是A類裏面也有類對象呢,那麼是不是也得先進行構造,所以此時可能變成了一個遞歸問題,我們需要遞歸到最底層,然後倒序進行初始化。

en...說思路簡單,不過對於編譯器來說,這個很難實現,所以呢,可以這樣設計,每個類負責調用自己類的成員構造和析構(下面會講),可解決遞歸問題。詳細的講,就是編譯器在掃描代碼時,會把該成員的默認構造函數調用插入到構造中,所以初始化列表只是影響了傳遞的參數值,而並不影響順序(之前已插入好代碼)。

最後,再總結下之前的知識點

1、與類同名			
			
2、沒有返回值			
			
3、創建對象的時候執行,編譯器幫我們調用,主要用於初始化			
			
4、構造可以重載
			
5、編譯器不要求必須提供,但也可能不會幫你生成默認構造(分情況)

6、先按定義順序構造成員對象,再構造自己(初始化表的順序,不影響構造順序)

 

下面,可以總結析構了,還是一樣,從C開始說起,之前說了一種情況,類裏面有指針,指向堆內存的情況,這種情況很明顯,在對象不使用時,我們需釋放內存,那麼我們再添加個函數

struct Test
{
    char *str;
    Test()
    {
        str = NULL;
        str = (char*)malloc(20);
    }
    void release()
    {
        free(str);
    }
};

int main()
{
    Test test;
    test.release(); //結束釋放內存
    return 0;
}

那麼還是來老問題,忘記調用或者重複調用咋辦?

首先忘記調用這個不好解決,我們來看下重複調用的後果

    Test test;
    test.release(); //結束釋放內存
    test.release(); //重複調用

說明重複調用後程序就蹦了,爲啥呢,因爲第二次調用時,之前的堆內存已經被釋放了,但是我們是可以解決滴,改進代碼

    void release()
    {
        if(str)
        {
            free(str);
            str = NULL;
        } 
    }

這樣改進後,重複調用就不會再次釋放啦,但是忘記這個咋解決?所以,析構的語法就來了

struct Test
{
    char *str;
    Test()
    {
        str = NULL;
        str = (char*)malloc(20);
    }
    //1. 與類同名,前面加~
    //2. 無返回值
    ~Test()
    {
        printf("析構\r\n");
        if(str)
        {
            free(str);
            str = NULL;
        } 
    }
};

int main()
{
    Test test;
    test.~Test(); //顯式調用析構
    return 0;
}

看完上面的代碼,你會有疑惑,爲啥我們還是需要調用析構呢,不是怕忘記麼,這裏我只是故意顯式調用下析構,說明這裏析構和構造是不同的,析構可多次被調用(上面我們也驗證了),說到顯式,那隱式呢,我們先看看運行結果

可以發現,該程序調用了兩次析構,那麼隱式是在哪調用的呢,我們觀察下彙編代碼

38:       Test test;
0040129D 8D 4D F0             lea         ecx,[ebp-10h]
004012A0 E8 79 FD FF FF       call        @ILT+25(Test::Test) (0040101e)
004012A5 C7 45 FC 00 00 00 00 mov         dword ptr [ebp-4],0
39:       test.~Test(); //顯式調用析構
004012AC 6A 00                push        0
004012AE 8D 4D F0             lea         ecx,[ebp-10h]
004012B1 E8 63 FD FF FF       call        @ILT+20(Test::`scalar deleting destructor') (00401019)
40:       return 0;
004012B6 C7 45 EC 00 00 00 00 mov         dword ptr [ebp-14h],0
004012BD C7 45 FC FF FF FF FF mov         dword ptr [ebp-4],0FFFFFFFFh
004012C4 8D 4D F0             lea         ecx,[ebp-10h]
004012C7 E8 39 FD FF FF       call        @ILT+0(Test::~Test) (00401005) ;隱式調用
004012CC 8B 45 EC             mov         eax,dword ptr [ebp-14h]
41:   }

根據其彙編代碼,可以發現在返回之前編譯器幫我們插入了幾行調用析構的代碼,所以,析構的話,是會在對象出作用域之前,編譯器幫我們插入析構代碼。

下面對比構造,我們再來分析下這幾個問題,析構可以重載麼,必須寫析構麼,編譯器會幫我們提供默認麼

這裏的話,先說第一個問題,重載問題,顯然是不能的,首先沒必要,因爲析構一般只用於做釋放工作,而且當重載後,編譯器也不知道插入啥代碼。

第二個問題,必須寫析構麼?看如下代碼

struct Test
{
   int a;
};

int main()
{
    {
        Test test;
        test.~Test();
    }
    
    return 0;
}

上面的代碼中我還顯式的調用了下析構,觀察其反彙編

22:       {
23:           Test test;
24:           test.~Test();
25:       }
26:
27:       return 0;
00401268 33 C0                xor         eax,eax
28:   }
0040126A 5F                   pop         edi
0040126B 5E                   pop         esi
0040126C 5B                   pop         ebx
0040126D 8B E5                mov         esp,ebp
0040126F 5D                   pop         ebp
00401270 C3                   ret

分析上面的彙編代碼,完全忽略了構造和析構的代碼,因爲其根本沒有產生對應的彙編指令。說明這裏不寫析構也是可以的,而且這裏編譯器也不會幫我們默認生成。

那麼啥時候會幫我們生成呢,下面還是來分析下某一種情況,只有被需要的時候纔會被生成

struct A
{
    ~A()
    {
    }
};

struct Test
{
   A a;
};

int main()
{
    {
        Test test;
    }
    
    return 0;
}

分析其彙編代碼

19:       {
20:           Test test;
21:       }
00401278 8D 4D FC             lea         ecx,[test]
0040127B E8 85 FD FF FF       call        @ILT+0(Test::~Test) (00401005) ;編譯器生成



Test::~Test:
004012A0 55                   push        ebp
004012A1 8B EC                mov         ebp,esp
004012A3 83 EC 44             sub         esp,44h
004012A6 53                   push        ebx
004012A7 56                   push        esi
004012A8 57                   push        edi
004012A9 51                   push        ecx
004012AA 8D 7D BC             lea         edi,[ebp-44h]
004012AD B9 11 00 00 00       mov         ecx,11h
004012B2 B8 CC CC CC CC       mov         eax,0CCCCCCCCh
004012B7 F3 AB                rep stos    dword ptr [edi]
004012B9 59                   pop         ecx
004012BA 89 4D FC             mov         dword ptr [ebp-4],ecx
004012BD 8B 4D FC             mov         ecx,dword ptr [ebp-4]
004012C0 E8 4F FD FF FF       call        @ILT+15(A::~A) (00401014) ;調用A的析構
004012C5 5F                   pop         edi
004012C6 5E                   pop         esi
004012C7 5B                   pop         ebx
004012C8 83 C4 44             add         esp,44h
004012CB 3B EC                cmp         ebp,esp
004012CD E8 FE 6E 00 00       call        __chkesp (004081d0)
004012D2 8B E5                mov         esp,ebp
004012D4 5D                   pop         ebp
004012D5 C3                   ret

此時,因爲需要析構成員,所以編譯器就必須幫我們默認生成一個。那麼考慮下一個問題成員的和自己那個先析構呢,這裏話分析一下也容易想明白的,因爲在自己的析構函數可能還會用到成員,所以這裏肯定是自己先析構後,成員按定義順序相反析構。

下面再看來下面這個問題,函數中的兩個對象誰會先被析構呢

struct A
{
    ~A()
    {
    }
};

struct Test
{
   ~Test()
   {
   }
};

int main()
{
    {
        Test test;
        A a;
    }
    
    return 0;
}

這個問題的話觀察反彙編很容易得到驗證

21:       {
22:           Test test;
0040128D C7 45 FC 00 00 00 00 mov         dword ptr [ebp-4],0
23:           A a;
24:       }
00401294 8D 4D EC             lea         ecx,[a]
00401297 E8 78 FD FF FF       call        @ILT+15(A::~A) (00401014) ;A 先析構
0040129C C7 45 FC FF FF FF FF mov         dword ptr [ebp-4],0FFFFFFFFh
004012A3 8D 4D F0             lea         ecx,[test]
004012A6 E8 5A FD FF FF       call        @ILT+0(Test::~Test) (00401005) ;Test後析構

這裏的話因爲a的作用範圍比較小,所以a就先析構了。其實在構造和析構中說了一堆的順序,記住一個即可,另一個反着來就OK.就拿這個來說,因爲test先被構造,所以它肯定後析構,兩者順序會相反。

最後,在總結下析構:

1. 與類同名,並且前面加~

2. 沒有返回值和參數

3. 對象出作用域時執行,編譯器會幫我們插入代碼,主要用於清理工作

4. 不能重載

5. 編譯器不要求提供,但也可能不會幫你生成

6. 先析構自己,再按定義順序反向析構成員對象

下面,再來看最後個東西,那就是拷貝構造,首先其可以理解爲構造中的一種重載,作用是啥呢

觀察如下代碼:

struct Test
{
   int a;
   Test(int a)
   {
        this->a = a;
   }
};

int main()
{
    Test t1(10);
    Test t2 = t1;
    printf("%d %d\r\n",t1.a,t2.a);
    return 0;
}

通過其反彙編觀察其賦值方式

16:       Test t1(10);
00401268 6A 0A                push        0Ah
0040126A 8D 4D FC             lea         ecx,[ebp-4]
0040126D E8 93 FD FF FF       call        @ILT+0(Test::Test) (00401005) ;構造t1
17:       Test t2 = t1;
00401272 8B 45 FC             mov         eax,dword ptr [ebp-4] ;拷貝 eax = t1.a
00401275 89 45 F8             mov         dword ptr [ebp-8],eax ;拷貝 t2.a = eax

其拷貝方式相當於值拷貝,也就是淺拷貝,至於淺拷貝哪些地方有不足呢?

可以看下面的代碼

struct Test
{
   char *str;
   Test()
   {
        str = NULL;
        str = (char*)malloc(10);
   }
   ~Test()
   {
        if(str)
        {
            free(str);
            str = NULL;
        }
   }
};

int main()
{
    Test t1;
    Test t2 = t1;
    return 0;
}

結果是運行錯誤

根據彙編代碼我們來分析一下

25:       Test t1;
0040128D 8D 4D F0             lea         ecx,[ebp-10h]
00401290 E8 84 FD FF FF       call        @ILT+20(Test::Test) (00401019)
00401295 C7 45 FC 00 00 00 00 mov         dword ptr [ebp-4],0
26:       Test t2 = t1;
0040129C 8B 45 F0             mov         eax,dword ptr [ebp-10h]
0040129F 89 45 EC             mov         dword ptr [ebp-14h],eax

說明賦值發生了指針值拷貝,也就是說賦值完畢後 t1 和 t2中的str都指向了同一塊內存空間,會先析構t2,t2析構完對應的堆空間已經釋放了,而當析構t1時,就會出現重複釋放的問題。

我們可以在析構處下斷點,第一次正常析構,第二次進入析構的時候我們來觀察下內存

此時拷貝構造就派上用場了,看如下代碼

struct Test
{
   char *str;
   Test(const char *s)
   {
        str = NULL;
        int len = strlen(s) + 1;
        str = (char*)malloc(len);
        strcpy(str,s);
   }
    // 拷貝構造
   Test(const Test& test)
   {
        str = NULL;
        int len = strlen(test.str) + 1;
        str = (char*)malloc(len);
        strcpy(str,test.str);
   }
   ~Test()
   {
        if(str)
        {
            free(str);
            str = NULL;
        }
   }
};

int main()
{
    Test t1("hello");
    Test t2 = t1;
    return 0;
}

觀察其反彙編

35:       Test t2 = t1;
004012B1 8D 45 F0             lea         eax,[ebp-10h]
004012B4 50                   push        eax
004012B5 8D 4D EC             lea         ecx,[ebp-14h]
004012B8 E8 48 FD FF FF       call        @ILT+0(Test::Test) (00401005) ;調用拷貝構造賦值


@ILT+0(??0Test@@QAE@ABU0@@Z):
00401005 E9 A6 03 00 00       jmp         Test::Test (004013b0)


15:      Test(const Test& test)
004013B0 55                   push        ebp
004013B1 8B EC                mov         ebp,esp
004013B3 83 EC 48             sub         esp,48h
;............

會發現此時由之前的值拷貝變成了調用拷貝構造,分析拷貝構造裏面的代碼,其實相當於把對象成員指針指向的數據也做了一份拷貝,這樣也就保證了析構的時候不會重複析構。這種拷貝也叫做深拷貝

下面思考一個問題,爲什麼拷貝構造需要傳引用呢,可以去掉引用麼

   // err 'Test' : illegal copy constructor: first parameter must not be a 'Test'
   Test(const Test test)

編譯器報錯了,爲啥呢,回想下之前的結構體當做參數,不過此時有了拷貝構造就會調用拷貝構造進行傳參,最後造成無限遞歸,模擬下調用

    Test t2 = t1;
    //=> t2.Test(t1) => t2.Test(const Test test)
    // test = t1 時又會調用拷貝構造
    //=> t2.Test(test.Test(t1))
    //此時無限遞歸

Over,構造和析構的一些知識點就總結到這裏了。

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