C++ 構造函數的理解


C++構造函數的理解

相對於C語言來說,C++有一個比較好的特性就是構造函數,即類通過一個或者幾個特殊的成員函數來控制其對象的初始化過程。構造函數的任務,就是初始化對象的數據成員,無論何時只要類的對象被創建,就會執行構造函數。

構造函數的語法

構造函數的名字必須和類名相同,與其他函數不一樣的是,構造函數沒有返回值,而且其必須是公有成員,因為私有成員不允許外部訪問,且函數不能聲明為const類型,構造函數的語法是這樣的:

class Test
{
    public:
        Test(){std::cout<<"Hello world!"<<std::endl;}
};
Test object; 
int main(){return 1;}

在main函數執行之前,object被定義時就會調用Test函數,輸出"Hello world!"。

這里只是示范了一個最簡單的構造函數的形式,其實構造函數是個比較復雜的部分,有非常多神奇的特性。

構造函數的種類

默認構造函數

當我們程序中並沒有顯式的定義構造函數時,系統會提供一個默認的構造函數,這種編譯器創建的構造函數又被稱為合成的默認構造函數,合成構造函數的初始化規則是這樣的:

  • 如果存在類內的初始值,用它來初始化成員。在C++11的新特性中,C++11支持為類內的數據成員提供一個初始值,創建對象時,類內初始值將用於初始化數據成員。如果在構造函數中又顯式地初始化了數據成員,則使用顯式初始化的值。
  • 否則,默認初始化該成員。默認初始化意味着和C語言一樣的初始化方式,當類對象為全局變量時,在系統加載時初始化為0,而作為局部變量時,由於數據在棧上分配,成員變量值不確定。

需要注意的是,只有當用戶沒有顯式地定義構造函數時,編譯器才會為其定義默認構造函數。

在某些情況下,默認構造函數是不合適的:

  • 如上所說,內部定義的類調用默認構造函數會導致成員函數的值是未定義的。

  • 如果類中包含其他類類型的數據成員或者繼承自其他類,且這個類沒有默認構造函數,那么編譯器將無法初始化該成員。上面提到了可以在類內給成員一個初始值,但是這只對於普通變量,並不支持類的構造。
    當我們除了自定義的其他構造函數,還需要一個默認構造函數時,我們可以這樣定義:

    Test() = default;
    這個構造函數不接受任何參數,等於默認構造函數。

初始化列表的構造方式

首先,我們先需要分清初始化和賦值的概念,初始化就是在新創建對象的時候給予初值,而賦值是在兩個已經存在的對象之間進行操作。在構造方式上,這兩種是不同的。

構造函數支持初始化列表,它負責為新創建的對象的一個或者幾個數據成員賦初值,初始化列表的語法是這樣的:

class Test
{
    public:
        Test(int a):x(a),y(2){}
        int x;
        int y;
};

初始化的列表的一個優勢是時間效率和空間效率比賦值要高,同時在const類型成員的構造時,普通的賦值構造函數是非法的。當我們創建一個const對象時,直到構造函數完成初始化過程,對象才能真正取得其常量屬性。

所以我們可以用這種方式為const成員變量寫值。

拷貝構造函數

拷貝構造函數的一般形式是這樣的:

class Test
{
    public:
        Test(const Test &ob){
            x = ob.x;
            y = ob.y;
        }
    private:
        int x;
        int y;
};

可以很清楚地看出來,構造過程就是將另一個同類對象的成員變量一一賦值,const修飾是因為限定傳入對象的只讀屬性。看到上面的示例,不知道有沒有朋友有所疑問:

為什么在構造函數中,用戶可以訪問到外部同類對象ob的私有變量,不是說私有變量只能通過類的公共函數(一般是get()方法)來訪問嗎,為什么這里可以直接使用ob.x,ob.y ??

如果你有這樣的問題,首先不得不承認你是個善於觀察且有一定基礎的學者,但是對封裝的概念並不是很清楚。

其實不僅僅構造函數可以訪問同類對象的私有變量,普通成員函數也可以訪問:

class Test
{
    public:
        Test(){};
        void func(const Test& ob){
            std::cout<<ob.x<<std::endl;
        }
        
    private:
        int x=2;
};

這樣的寫法不會報錯且能夠正常運行,但是如果func()的函數是這樣的:

void func(const AnotherClass& ob){
            std::cout<<ob.x<<std::endl;
        }

那我們還能不能訪問ob的私有變量呢?答案肯定是不行的,這不用說。那我們回到上面的問題,為什么可以訪問同類對象的私有變量?

其實答案並不難理解,類的封裝性是針對類而不是針對類對象。

通俗地來說,我們定義類中成員訪問權限的初衷是為了保護私有成員不被外部其他對象訪問到,一般情況下私有成員被外部訪問的方式就是通過公共的函數接口(public),而在類的內部,任何成員函數都能訪問私有成員,這種保護是針對不同的類之間的,所以我們是在定義類的時候來指定訪問權限,而不是在定義對象的時候再指定訪問權限。

再者,相同類對象,對於所有的私有變量,彼此知根知底,也就沒有什么保護的必要。

既然是這樣,類內的構造函數以及其它函數都是類的成員函數,自然可以訪問所有數據。

賦值運算符重載

同時,類的構造可以用重載賦值運算符來實現,即"="。

class Test
{
    public:
        Test& operator=(const Test &ob){
            x = ob.x;
            y = ob.y;
            return this;
        }
    private:
        int x;
        int y;
};

在定義類的時候,我們可以這樣:

Test ob1;
Test ob2 = ob1;

默認拷貝構造函數的陷阱

當我們沒有指定拷貝構造函數或者沒有重載賦值運算符時,系統會生成默認的對應構造函數,分別為合成拷貝構造函數和合成拷貝賦值運算符。即使用戶沒有在類中定義相對應拷貝賦值操作,我們照樣可以使用它:

Test ob1;
Test ob2(ob1);
Test ob3 = ob2;

編譯器生成的默認拷貝賦值構造函數會將對應的成員一一賦值,是不是非常方便?

既然編譯器生成的默認拷貝賦值構造函數就能完成任務,為什么我們還要自己去定義構造函數呢?這是不是多此一舉?

非也!!!

如果類型成員全部都是普通變量是沒有問題的,但是如果涉及到指針,簡簡單單地復制指針也是沒有問題的,最要命的是如果指針指向的動態內存,這樣就會有兩個不同類的成員指向同一片動態內存,而析構函數在釋放內存時,必然造成double free,我們可以看下面的例子:

class Test
{
    public:
        Test(){p = new int(4);}
        ~Test(){delete p;}
        int *p;
};
Test ob1;
Test ob2 = ob1;
int main(){}

然后編譯運行:

g++ -std=c++11 test.cpp -o test
./test

這段程序不做任何事,僅僅是通過編譯器生成的合成拷貝賦值運算符,運行結果:

*** Error in `./a.out': double free or corruption (fasttop): 0x085dca10 *** Aborted (core dumped)

很明顯,和上面所提到的一樣,動態內存的double free導致程序終止。為了觀眾朋友們能更清晰地理解這個過程,我們再來對程序做一個step by step解析:

  • 構造類對象ob1,這是調用了構造函數,為ob1.p分配了內存空間。
  • 用合成拷貝賦值構造函數構造類對象ob2 = ob1,相當於執行了語句:ob2.p = ob1.p;
  • main()函數執行完畢,全局函數的運行周期結束,系統回收內存,先調用ob1的析構函數,將ob1.p指向的內存釋放。
  • 調用ob2的析構函數,將ob2.p指向的內存釋放,但是由於ob2.p的內存已經在上一步被釋放,所以造成了double free。

事實上,這種現象在C++中有兩個專用名詞來描述:"淺拷貝"和"深拷貝"。

所以,在使用編譯器默認的合成構造函數時,我們要非常小心這一類的陷阱,即使是目前沒有指針成員函數,也要自己寫拷貝賦值構造函數,這樣有利於代碼的擴展和維護。

但是,話說回來,如果我每次實現一個很簡單的需求,都要定義復制拷貝構造函數,一個一個成員去賦值,這樣也是很煩人的,在新標准下,C++提供了一種方法來"解決"這個問題。

阻止拷貝

用戶可以禁止使用拷貝函數,只要作這樣聲明:

Test(Test &ob) = delete;
Test &operator(Test &ob) = delete;
事實上,部分編譯器默認禁止合成的拷貝賦值構造函數。 

這樣,在使用者想使用默認的拷貝賦值構造函數時,編譯器將無情地報錯。


移動構造函數

在說到移動構造函數之前,我們得先介紹一下新標准下一種新的引用類型——右值引用。右值引用就是必須綁定到右值的引用,左值的引用用&,而右值的引用則用&&。右值引用有一個重要的性質,即只能綁定到一個將要銷毀的對象。

通俗地說,右值通常為臨時變量,字面值,未接受的返回值等等,它們沒有固定地址。
而左值通常是變量。總而言之,左值持久,右值短暫。  

下面是引用和右值引用的示例:

int x = 30;
int &r = x;  //正確,左值引用
int &&r = x; //錯誤,x為左值,&&r為右值引用
int &&r = 3; //正確,右值引用
const int &r = 3;  //正確,const左值可以對右值引用

由於右值引用只能綁定到臨時對象,我們可以知道它的特點:

  • 所引用的對象將要被銷毀
  • 該對象沒有其他用戶
    這兩個特性則意味着:使用右值引用的代碼可以自由地接管所引用的對象的資源。可想而知,右值引用的特點是"竊取"而不是"生成",在效率上自然就有所提高。

如果現在有一個左值,我們想將它作為右值來處理,應該怎么辦呢?答案是std::move()函數,語法是這樣的:

int x = 30;
int &&r = std::move(x); 

但是正如右值的特性而言,將左值轉換成右值的時候,你得確保這個左值將不再使用,建議使用std::move(),因為這樣的函數名總是容易出現命名沖突。

讓我們再回到移動構造函數,各位朋友們應該從前面的鋪墊已經猜到了這是個什么樣的實現,是的,它的特點就是接受一個右值作為參數來進行構造。實現是這樣的:

class Test
{
    public:
        Test(){p = new int(10);}
        ~Test(){delete p;}
        Test(Test &&ob) noexcept{
            p = ob.p;
            ob.p = nullptr;
        }
        int *p;
};

可能朋友們看了上面的實現會有兩個疑問:

  • 為什么函數要加上noexcept聲明?
  • 為什么要加上 ob.p=nullptr 這個操作?
    剛剛我們提到了拷貝賦值構造函數的淺拷貝問題(即指針部分僅僅是復制),很顯然,那樣是不行的。但是在移動構造函數中,我們依然是淺拷貝,為什么這樣又可以?

從上面的示例可以看出移動構造函數的參數是一個右值引用,我們上面有提到,移動構造函數的特點是"竊取"而不是生成。就相當於將目標對象的內容"偷過來",既然目標對象的內存本來就是存在的,所以不會因為失敗問題而拋出異常。當我們編寫一個不拋出異常的移動操作時,有必要通知標准庫,這樣它就不會為了可能的異常處理而做一些額外工作,這樣可以提升效率。

再者,我們將右值對象的內容偷過來,但是右值對象依然是存在的,它依舊會調用析構函數,如果我們不將右值的動態內存指針賦值為null,右值對象調用析構函數時將釋放掉這部分我們好不容易偷過來的內存。就像上面的例子所示,我們不得不將ob.p指針置為空。
口說無憑,我們來看下面的示例:

class Test
{
    public:
        Test(void){p=new int(50);
		}
        Test(Test &&ob) noexcept{
            p = ob.p;
			//ob.p = nullptr;     
        }
		~Test(){delete p;}
        int *p;
};
Test ob1;
int main()
{
    Test ob2 (std::move(ob1));
}

在示例中,我們將ob.p = nullptr;這條語句注釋,然后使用無參構造函數構造ob1,然后將ob1轉為右值來構造ob2.我們來看運行結果:

*** Error in `./a.out': double free or corruption (fasttop): 0x09f12a10 ***
Aborted (core dumped)

果然如我所料,出現了double free的錯誤,這是因為在移動構造函數中傳入的右值對象ob在使用完后調用了析構函數釋放了p,而對象ob2偷到的僅僅是一個指針的值,指針指向的內容已經被釋放了,所以在程序執行完成之后再調用析構函數時就會出現double free的錯誤。
為了再驗證一個問題,我們將上面的例子中加上ob.p = nullptr;,並將main()函數改成這樣:

class Test
{
    public:
        Test(void){p=new int(50);
		}
        Test(Test &&ob) noexcept{
            p = ob.p;
			ob.p = nullptr;
        }
		~Test(){delete p;}
        int *p;
};
Test ob1;
int main()
{
    Test ob2 (std::move(ob1));
    cout<<*ob1.p<<endl;
}

我們來看看已經被轉換成右值的ob1個什么情況,運行結果是這樣的:

Segmentation fault (core dumped)

好吧,其實這是顯而易見的,ob1.p已經在移動構造函數中被置為nullptr了。

為什么C++11要添加這個新的特性呢?從效率上出發,在程序運行的時候,由於中間過程會出現各種各樣的臨時變量,每創建一個臨時變量,就會多一次對資源的構造和析構的消耗,如果我們能將臨時變量的資源接管過來,就可以省下相應的構造和析構所帶來的消耗。

隱式轉換構造函數

C++中,當類有一個構造函數接收一個實參,它實際上定義了轉換為此類類型的隱式轉換機制,又是我們把這種構造函數稱為轉換構造函數。

官方解釋總是像數學公式一樣難以理解,通俗地說,當一個類A有其中一個構造函數接受一個實參(類型B)時,在使用時我們可以直接使用那個構造函數參數類型B來臨時構造一個類A的對象,好像我也沒解釋清楚?好吧,直接上代碼看:

class Test{
public:
    Test(string s,int para = 1){
        str = s;
    }
    void add(Test ob){
        str += ob.str;
    }
    string str;
};
Test ob1("downey");
int main()
{
    ob1.add(string("downey!"));
    cout<<ob1.str<<endl;
}

運行結果:

downeydowney!

如碼所示,Test類有一個構造函數,可以接收一個string類的實參(可以由一個實參構造並不代表只能有一個形參),而add()方法接受一個Test類類型參數,在調用add()方法時,我們直接傳入一個string類型,觸發隱式轉換功能,編譯器將自動以string作為實參構造一個Test的臨時類對象來傳入add()方法,程序結束之后將釋放臨時變量。
需要注意的是,隱式轉換只支持一次轉換,如果我們將main()函數改成這樣:

int main()
{
    ob1.add("downey!");   
    cout<<ob1.str<<endl;
}

編譯器需要將"downey"轉換成string類型,然后再進行一次轉換,這樣是不支持的。在編譯階段就會報錯:

error: no matching function for call to XXX

同時,如果我們在聲明add()函數時習慣性地使用了左值引用:

void add(Test &ob){      //使用引用,&
        str += ob.str;
    }

這樣又是什么結果呢?

答案是,編譯出錯。這又是為什么?如果你有仔細看上面的隱式轉換過程就可以知道,在使用隱式轉換時生成了一個臨時變量(類型同函數形參),而臨時變量是右值,是不能使用左值引用的。報錯信息如下:

error: no matching function for call to XXX  //左值引用不匹配,所以這里找不到匹配的方法。

阻止隱式轉換

使用explicit關鍵字修飾函數可以阻止構造函數的隱式轉換,而且explicit只支持直接初始化時使用,也就是在類內使用,同時,只對一個實參的構造函數有效。在STL中我們隨時可以看到explicit的影子。
下面是示例:

class Test{
public:
    explicit Test(string s,int para = 1){
        str = s;
    }
    void add(Test ob){
        str += ob.str;
    }
    string str;
};
Test ob1("downey");
int main()
{
    ob1.add(string("downey!"));    //報錯,no matching function for call to XXX,因為這里不支持隱式轉換
    cout<<ob1.str<<endl;
}

同時,如果用戶試圖在類外聲明時使用explicit關鍵字,將會報錯:

error: only declarations of constructors can be ‘explicit’  

結語

C++真是魔鬼!!!

好了,關於C++構造函數的討論就到此為止啦,如果朋友們對於這個有什么疑問或者發現有文章中有什么錯誤,歡迎留言

原創博客,轉載請注明出處!

祝各位早日實現項目叢中過,bug不沾身.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM