關於C++中建構函式的常見疑問

onlyblues發表於2021-03-01

基本概念

  我們已經知道在定義一個物件時,該物件會根據你傳入的引數來呼叫類中對應的建構函式。同時,在釋放這個物件時,會呼叫類中的解構函式。其中,建構函式有三種,分別是預設建構函式有參建構函式拷貝建構函式。在類中,如果我們沒有自行定義任何的建構函式,編譯器會為我們提供兩種建構函式(預設建構函式和拷貝建構函式)以及解構函式。其中預設建構函式和解構函式是空函式,而拷貝建構函式會為類中的每個成員變數進行淺拷貝。相關實現程式碼如下:(注意,下面的程式碼只是為了方便理解編譯器提供的函式是如何實現的。在實際中我們並不需要自行定義下面函式,因為我們前面有說,在宣告一個類後,編譯器會為我們自動提供這三個函式。)

 1 class Cls {
 2 private:
 3     int a_;
 4     int b_;
 5     
 6 public:
 7     Cls() {}                 // 編譯器提供的預設建構函式 
 8     Cls(const Cls &obj) {    // 編譯器提供的拷貝建構函式
 9         this -> a_ = obj.a_;
10         this -> b_ = obj.b_;
11     } 
12     ~Cls() {}                // 編譯器提供的解構函式 
13 };

   在類中,如果我們自己定義了相關的建構函式和解構函式,那麼我們自己定義的函式會代替編譯器為我們預設提供的相關函式。通常我們定義建構函式,是為了在定義一個類後,呼叫我們自行定義的建構函式來為類中的成員變數進行初始化。例如,我們可以自己進行相關定義:

 1 class Cls {
 2 private:
 3     int a_;
 4     int b_;
 5     
 6 public:
 7     Cls() {                             // 預設建構函式 
 8         a_ = 1;
 9         b_ = 2;
10     }
11     Cls(int a, int b): a_(a), b_(b) {}  // 編譯器不會為我們提供有參建構函式。自行定義有參建構函式,並用初始化列表進行初始化 
12 //  Cls(const Cls &obj) {               // 如果不需要進行深拷貝操作,一般不需要自行定義拷貝建構函式 
13 //      this -> a_ = obj.a_;
14 //      this -> b_ = obj.b_;
15 //  } 
16 //  ~Cls() {}                           // 解構函式,通常用來釋放我們在堆區申請的記憶體,一般情況下不需要自行定義 
17 };

利用上面的Cls類宣告,我們來定義一個物件:

1 int main() {
2     Cls obj1;       // 定義後,會呼叫預設建構函式,obj1物件中的a_初始化為1, b_初始化為2
3     Cls obj2(0, 1); // 定義後,會呼叫有參建構函式,obj2物件中的a_初始化為0, b_初始化為1 
4     Cls obj3(obj1); // 定義後,會呼叫拷貝建構函式,obj2中的成員變數的值會拷貝到obj3中來對obj3進行初始化,a_為1, b_為2
5     
6     return 0;
7 }

 

關於定義建構函式的注意事項  

  1. 當我們只在類中自定義了預設建構函式,如果需要對新建物件進行預設構造初始化,就會呼叫我們自己的預設建構函式,同時編譯器仍會提供拷貝建構函式
  2. 當我們只在類中自定義了有參建構函式編譯器就不會再提供預設建構函式,但仍會提供拷貝建構函式有參建構函式需要我們自己來定義,編譯器不會為我們提供。這裡有個問題是,如果我們在新建一個物件時不傳入任何的引數,那麼編譯器就會因為不能夠為物件呼叫預設建構函式而報錯。所以需要我們再去自定義一個預設建構函式。當然,這裡還有一個方法,那就是為我們自定義的有參建構函式中的全部形參提供預設值,這樣子就不需要再定義預設建構函式了,做法如下:
    1 Cls(int a = 1, int b = 2): a_(a), b_(b) {}

     

  3. 當我們只在類中定義了拷貝建構函式,那麼編譯器同樣不會提供預設建構函式,同時我們自定義的拷貝建構函式會取代編譯器原本為我們提供的拷貝建構函式。所以我們需要再自定義預設建構函式或有參建構函式。在一個類中,永遠存在拷貝建構函式。
  4. 無論我們是否自定義建構函式編譯器都會為我們提供解構函式(空實現的函式),只有我們自定義了解構函式,才能代替編譯器為我們提供的解構函式。也就是說,在一個類中,永遠存在解構函式。
  5. 下面會提到operator=函式,這也是編譯器為我們提供的一個函式,在為進行了物件初始化後呼叫,這個賦值函式會對屬於同一個類的物件進行淺拷貝,如obj1 = obj2。我們可以在類中進行'='號運算子過載,以呼叫我們定義的operator=函式。

 

淺拷貝與深拷貝

  在前面我們有提到淺拷貝與深拷貝,這裡我們進行詳細說明。

  淺拷貝就是簡單的賦值操作,編譯器為我們提供的拷貝建構函式就是進行淺拷貝。這裡列出個淺拷貝的缺陷。比如我們宣告一個新的類,並且用這個類來創造兩個物件,並讓其中一個物件呼叫編譯器提供的拷貝建構函式,程式碼如下:

 1 #include <iostream>
 2 using namespace std;
 3 
 4 class Cls {
 5 public:
 6     int *p_;
 7     
 8     Cls(int num = 0) {
 9         p_ = new int(num);
10     }
11     ~Cls() {
12         if (p_ != NULL) {
13             delete p_;
14             p_ = NULL;
15         }
16     }
17 };
18 
19 void test() {
20     Cls obj1(10);                  // 呼叫有參建構函式 
21     Cls obj2(obj1);                // 呼叫拷貝建構函式,進行淺拷貝 
22     cout << *obj1.p_ << endl;
23     cout << *obj2.p_ << endl;
24 }
25 
26 int main() {
27     test();
28     
29     return 0;
30 }

執行結果為下圖:  

  可以看到,雖然正確輸出對應的值,但main函式返回的結果不為0,也就是程式執行奔潰了。這是什麼原因呢?先給出其中的原因:因為同一塊記憶體被釋放了兩次!下面我們來進行分析。

  由於obj2是呼叫編譯器提供的拷貝建構函式,通過淺拷貝進行初始化,在拷貝建構函式中進行的操作是obj2.p_ = obj1.p_;(注意,這裡的表述並不嚴格!),也就是說,obj2.p_存放的地址與obj1.p_存放的地址相同,他們都指向同一塊記憶體。其實,在全域性函式test呼叫結束前,一切都是正常執行的。而test函式呼叫結束後,由於函式內的變數要回收釋放,obj1和obj2都要呼叫解構函式。關鍵的地方來了!按照棧的規則,首先我們需要呼叫obj2的解構函式,在呼叫後,obj2.p_在堆區所指向的記憶體就釋放掉了,同時obj2.p_指向NULL。然後,再呼叫obj1的解構函式,因為obj1和obj2指向同一塊記憶體,但是obj2的解構函式剛剛已經把這塊記憶體給釋放了,而obj1又要釋放一次,所以就會造成同一塊記憶體空間被釋放兩次,從而程式奔潰!

  為了防止出現這些問題,我們必須對一個新的物件通過深拷貝來初始化。深拷貝就不是簡單地進行值的賦值操作,而是向堆區申請一塊新的記憶體空間,但這塊記憶體空間存放的值和你的傳入引數一樣,而這個物件中的成員變數存放的值(地址)與你傳入的引數中的成員變數存放的值(地址)不一樣,這樣就可以避免出現上述的問題。程式碼改進如下:

 1 #include <iostream>
 2 using namespace std;
 3 
 4 class Cls {
 5 public:
 6     int *p_;
 7     
 8     Cls(int num = 0) {
 9         p_ = new int(num);
10     }
11     Cls(const Cls &obj) {         // 自定義拷貝建構函式,進行深拷貝操作 
12         p_ = new int(*obj.p_);
13     }
14     ~Cls() {
15         if (p_ != NULL) {
16             delete p_;
17             p_ = NULL;
18         }
19     }
20 };
21 
22 void test() {
23     Cls obj1(10);
24     Cls obj2(obj1);               // 呼叫自定義拷貝建構函式,進行深拷貝 
25     cout << *obj1.p_ << endl;
26     cout << *obj2.p_ << endl;
27 }
28 
29 int main() {
30     test();
31     
32     return 0;
33 }

 下面來通過一個圖來進行進一步的理解。

 

 

  在建立的物件呼叫了建構函式進行初始化後,如果需要讓物件通過'='號拷貝另外一個物件的值時,同樣應該進行的是深拷貝操作。需要補充的是,在建立一個類時編譯器除了會自動提供上述所說的預設建構函式,拷貝建構函式和解構函式外,還會提供一個賦值運算子operator=函式,對屬性進行值拷貝,這個拷貝是淺拷貝。拷貝建構函式其實和operator=函式幾乎是一樣的,都是對成員變數之間進行淺拷貝。不同的地方在於,拷貝建構函式只會呼叫一次,那就是在你剛建立一個新的物件,並且傳入屬於同一個類的物件作為引數,來呼叫拷貝建構函式進行初始化。比如:Cls obj1(obj2);。而operator=可以呼叫多次,在物件進行了初始化後(或者呼叫了建構函式後)所有屬於同一個類的物件之間進行的’=’號賦值運算都是呼叫operator=函式,來拷貝另一個物件的值。比如:obj1 = obj2。注意,Cls obj1 = obj2;(obj2已經初始化)是呼叫拷貝建構函式(隱式轉換法)!前面已經提到,編譯器提供的operator=進行的是淺拷貝操作,我們需要的是可以進行深拷貝操作的operator=函式。為了實現深拷貝,我們需要在類中對'='號運算子進行過載,程式碼如下:

1 Cls& operator=(Cls &obj) {    // 對'='運算子進行過載,實現深拷貝 
2         if (this -> p_) {
3             delete p_;
4             p_ = NULL;
5         }
6         p_ = new int(*obj.p_);
7         return *this;
8     }

當我們使物件進行'='賦值時,就會呼叫上面我們定義的operator=函式。例如:

1 int main() {
2     Cls obj1(10), obj2(obj1), obj3(20);    // *obj1.p_ == 10, *obj2.p_ == 10, *obj3.p_ == 20
3     obj1 = obj2 = obj3;                    // *obj1.p_ == *obj2.p_ == *obj3.p_ == 20
4     cout << "*obj1.p_ = " << *obj1.p_ << endl;
5     cout << "*obj2.p_ = " << *obj2.p_ << endl;
6     cout << "*obj3.p_ = " << *obj3.p_ << endl;
7     return 0;
8 }

執行結果如下:

 

物件初始化的時機

  前一段時間我很難理解這個問題,分不清什麼時候呼叫建構函式和operator=函式,尤其是一個類中含有類成員的時候。

  下面我先用點類和圓類,定義一個含有類成員(Point)的一個類(Circle)。

 1 #include <iostream>
 2 using namespace std;
 3 
 4 class Point {
 5 private:
 6     double x_;
 7     double y_;
 8 
 9 public:
10     Point() {
11         cout << "Point::呼叫預設建構函式" << endl; 
12     }
13     Point(double x, double y): x_(x), y_(y) {
14         cout << "Point::呼叫有參建構函式" << endl;
15     }
16     void setX(double x) {
17         x_ = x;
18     }
19     double getX() {
20         return x_;
21     }
22     void setY(double y) {
23         y_ = y;
24     }
25     double getY() {
26         return y_;
27     }
28 };
29 
30 class Circle {
31 private:
32     double r_;
33     Point center_;
34     
35 public:
36     Circle(int r, int x, int y): r_(r), center_(x, y) {
37         cout << "Circle::呼叫有參建構函式" << endl; 
38     }
39     void setR(double r) {
40         r_ = r;
41     }
42     double getR() {
43         return r_;
44     }
45     void setCenter(Point center) {
46         center_ = center;
47     }
48     Point getCenter() {
49         return center_;
50     }
51 };
52 
53 int main()
54 {
55     Circle c(10, 10, 0);                // 半徑為10,圓心為(10,0) 
56     cout << "r = " << c.getR() << endl; 
57     cout << "center = (" << c.getCenter().getX() << ", " << c.getCenter().getY() << ")" << endl;
58     return 0;
59 }

 執行結果:

  可以看見是先呼叫Point中的有參建構函式,再呼叫Circle類中的有參建構函式。因為在Circle類的有參建構函式中,我們通過初始化列表進行賦值,所以在main函式中定義一個c物件並傳入引數後,並沒有先進入有參建構函式中,而是先在初始化列表中進行賦值,r_賦值為10,然後再構造Circle類中的Point類成員,呼叫Point類中的有參建構函式,最後才進入到Circle類中的有參建構函式中。我之前一直錯誤地認為在定義了一個物件後,先有Circle類,之後再有Point類成員center_,再對Point類的center成員進行賦值。很明顯,是先有類物件(center_)再有物件(c)

  讓我們來換一種賦值方式,在Circle類的有參建構函式中進行賦值,上述第36~38行程式碼改為如下:

1 Circle(int r, int x, int y) {
2         cout << "Circle::呼叫有參建構函式" << endl;
3         r_ = r;
4         center_.setX(10);
5         center_.setY(0); 
6     }

  你會發現,居然是呼叫Point中的預設建構函式,而不是Point中的有參建構函式。就像前面說的,如果建構函式有初始化列表,會優先在初始化列表進行賦值進行,再進入到函式體中。那為什麼cneter_會呼叫Point中的預設建構函式而不是其他的建構函式?

  首先我們應該知道,要先有這個類中的成員變數,才能構造出一個物件。(這裡用一個比喻的例子:先有零件才能夠有車,而不是先有車再有零件)。在點園的例子中,一個類(Circle)中有類成員(Point center_),所以應該先有類成員(Point center_)(當然這個例子中還要有r_),才能有這個類的物件(Circle c)。在進入Circle這個類的建構函式前,應該先有了center_,才能對center_進行操作,從而初始化c,所以應該先呼叫Point類的建構函式來初始化構造center_。又因為,如果建構函式有初始化列表,先進入初始化列表,再進入函式體。在初始化列表中,會根據你傳入的引數對類成員(center_)呼叫匹配的建構函式。比如在上面總行數為59的程式碼中,由於在第36行中給center_傳入的是兩個整形資料(對應Point類中的成員變數x_和y_),所以會呼叫它的有參建構函式;如果傳入的是一個Point類的變數,那麼就會呼叫它的拷貝建構函式;如果不給center_傳入任何引數,或是像上面修改的程式碼一樣沒有初始化列表,那麼就會呼叫預設建構函式,或者是全部引數都有預設值的有參建構函式。

  所以總的來說,如果一個類中(Circle)含有類成員(Point center_),那麼在進入這個類(Circle)的建構函式的函式體前,必須先宣告定義它的類成員(Point center_)。關於類成員(Point cent_)呼叫什麼建構函式,取決於你有沒有在類(Circle)的建構函式使用初始化列表,或者你在初始化列表中為類成員(Point)傳入了什麼引數。

   另外,在類(Circle)的建構函式中,如果'='賦值操作的的左值和右值屬於同一個類,那麼應該呼叫的是operator=函式,而不是拷貝建構函式。對上面的程式碼稍作修改(註釋的地方):

 1 #include <iostream>
 2 using namespace std;
 3 
 4 class Point {
 5 private:
 6     double x_;
 7     double y_;
 8 
 9 public:
10     Point() {
11         cout << "Point::呼叫預設建構函式" << endl; 
12     }
13     Point(double x, double y): x_(x), y_(y) {
14         cout << "Point::呼叫有參建構函式" << endl;
15     }
16     Point& operator=(const Point &p) {
17         cout << "Point::呼叫operator=函式" << endl;
18         this -> x_ = p.x_;
19         this -> y_ = p.y_;
20     }
21     void setX(double x) {
22         x_ = x;
23     }
24     double getX() {
25         return x_;
26     }
27     void setY(double y) {
28         y_ = y;
29     }
30     double getY() {
31         return y_;
32     }
33 };
34 
35 class Circle {
36 private:
37     double r_;
38     Point center_;
39     
40 public:
41     Circle(int r, Point center) {
42         cout << "Circle::呼叫有參建構函式" << endl;
43         r_ = r;
44         center_ = center;                  // 呼叫Point::operator= 
45     }
46     void setR(double r) {
47         r_ = r;
48     }
49     double getR() {
50         return r_;
51     }
52     void setCenter(Point center) {
53         center_ = center;
54     }
55     Point getCenter() {
56         return center_;
57     }
58 };
59 
60 int main()
61 {
62     Circle c(10, Point(10, 0));            // 當然,你也可以先定義一個Point的物件,Point center(10, 0),再把center作為引數傳入,Circle c(10, cneter)
63     cout << "r = " << c.getR() << endl; 
64     cout << "center = (" << c.getCenter().getX() << ", " << c.getCenter().getY() << ")" << endl;
65     return 0;
66 }

   解釋一下,執行結果的第1行發生在第62行中的Point(10, 0),創造出來的是一個匿名物件,創造出來後會作為引數傳入到c(Circle類)的有參建構函式的形參列表。然後執行結果的第2行發生在進入Circle類中的有參建構函式的函式體之前,來創造c這個物件中的成員變數center_。執行結果的第3行就是程式碼中第42行的輸出語句。第四行呼叫center_(Point類)中的operator=函式。只要初始化了一個物件後通過'='號運算子且屬於同一個類的左值和右值的拷貝操作都是在呼叫operator=函式

  下面將舉例一些常見的錯誤操作。

  • 我們把上面程式碼的第41~45行程式碼修改為如下:
1 Circle(int r, Point center) {
2         cout << "Circle::呼叫有參建構函式" << endl;
3         r_ = r;
4         center_(center); 
5     }

  編譯器會報錯,原因很簡單,因為第4行的操作只能夠發生在定義一個物件並對它初始化之時。在進入Circle(int r, Point center)這個函式體之前,類成員變數center_已經通過預設建構函式進行初始化了,在函式體中,再對center_進行初始化是錯誤的操作

  • 如果我們把Point中的預設建構函式註釋掉,如下:
 1 class Point {
 2 private:
 3     double x_;
 4     double y_;
 5 
 6 public:
 7 //  Point() {
 8 //      cout << "Point::呼叫預設建構函式" << endl; 
 9 //  }
10     Point(double x, double y): x_(x), y_(y) {
11         cout << "Point::呼叫有參建構函式" << endl;
12     }13 };

 同時在main函式中有如下定義:

1 int main()
2 {
3     Circle c(10, Point(10, 0));            
4     return 0;
5 }

  編譯器會報錯,因為在進入Circle(int r, Point center)這個函式體之前,並沒有為類成員變數center_提供任何的引數,所以應該呼叫預設建構函式。而我們在Point類中定義了有參建構函式,編譯器不會再提供預設建構函式,但我們已經把應該定義的預設構造給註釋掉了,所以center_無法呼叫預設建構函式,所以編譯不通過。

  類似的情況還有在有參建構函式的引數都提供了預設值,同時還定義了預設構造,這就會產生二義性,當你像這樣定義一個Point類物件時:Point p; 編譯器會報錯,因為我們並沒有傳入任何的引數,不知道應該呼叫預設建構函式還是有參建構函式。上述程式碼修改修下:

 1 class Point {
 2 private:
 3     double x_;
 4     double y_;
 5 
 6 public:
 7     // 有二義性 
 8     Point() {
 9         cout << "Point::呼叫預設建構函式" << endl; 
10     }
11     Point(double x = 0, double y = 0): x_(x), y_(y) {
12         cout << "Point::呼叫有參建構函式" << endl;
13     }
14 };

  建議的做法是,為有參建構函式的全部引數提供預設值,並且不定義預設建構函式

  • 另外還有的錯誤就是在類中定義了有參建構函式,並且沒有為全部引數提供預設值,同時也沒有定義預設建構函式,那麼在用不傳入引數的方式來定義一個類的時候,比如:Cls obj; 編譯器就會報錯,原因與上述相同,這裡不再重複。

 

參考資料

  黑馬程式設計師匠心之作|C++教程從0到1入門程式設計,學習程式設計不再難:https://www.bilibili.com/video/BV1et411b73Z

  

相關文章