基本概念
我们已经知道在定义一个对象时,该对象会根据你传入的参数来调用类中对应的构造函数。同时,在释放这个对象时,会调用类中的析构函数。其中,构造函数有三种,分别是默认构造函数,有参构造函数和拷贝构造函数。在类中,如果我们没有自行定义任何的构造函数,编译器会为我们提供两种构造函数(默认构造函数和拷贝构造函数)以及析构函数。其中默认构造函数和析构函数是空函数,而拷贝构造函数会为类中的每个成员变量进行浅拷贝。相关实现代码如下:(注意,下面的代码只是为了方便理解编译器提供的函数是如何实现的。在实际中我们并不需要自行定义下面函数,因为我们前面有说,在声明一个类后,编译器会为我们自动提供这三个函数。)
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 Cls(int a = 1, int b = 2): a_(a), b_(b) {}
- 当我们只在类中定义了拷贝构造函数,那么编译器同样不会提供默认构造函数,同时我们自定义的拷贝构造函数会取代编译器原本为我们提供的拷贝构造函数。所以我们需要再自定义默认构造函数或有参构造函数。在一个类中,永远存在拷贝构造函数。
- 无论我们是否自定义构造函数,编译器都会为我们提供析构函数(空实现的函数),只有我们自定义了析构函数,才能代替编译器为我们提供的析构函数。也就是说,在一个类中,永远存在析构函数。
- 下面会提到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