一个小例子
最近在《剑指Offer》上看到了一道题(程序如下),要求我们分析编译运行的结果,并提供3个选项: A. 编译错误; B. 编译成功,运行时程序崩溃;C. 编译运行正常,输出10。
1 #include <iostream> 2 using namespace std; 3 4 class A 5 { 6 private: 7 int value; 8 9 public: 10 A(int n) { value = n; } 11 A(A other) { value = other.value; } 12 void Print() { cout << value << endl; } 13 }; 14 15 int main() 16 { 17 A a = 10; 18 A b = a; 19 b.Print(); 20 return 0; 21 }
这个程序是通不过编译的,GCC和VS均通不过。根据《剑指Offer》上的解释,上述程序中的拷贝构造函数A(A other)传入的参数是A的一个实例,所以由于是传值参数,我们把形参复制到实参会调用拷贝构造函数。因此如果允许拷贝构造函数传值,就会在拷贝构造函数内调用复制构造函数,就会形成无休止的递归调用从而导致栈溢出。为了说明这个解释,我们先看下稍微修改过的程序:
1 #include <iostream> 2 using namespace std; 3 4 class A 5 { 6 private: 7 int value; 8 9 public: 10 A(int n) 11 { 12 value = n; 13 cout << "constructor with argument" << endl; 14 } 15 16 A(const A& other) 17 { 18 value = other.value; 19 cout << "copy constructor" << endl; 20 } 21 22 A& operator=(const A& other) 23 { 24 cout << "assignment operator" << endl; 25 value = other.value; 26 return *this; 27 } 28 29 void func(A other) 30 { 31 } 32 }; 33 34 int main() 35 { 36 A a = 10; // constructor with argument 37 A b = 5; // constructor with argument 38 b = a; // assignment operator 39 A c = a; // copy constructor 40 b.func(a); // copy constructor 41 42 return 0; 43 }
程序的运行结果如下:
对于上述程序的第39行,构造c,实质上是c.A(a)。假如拷贝构造函数参数不是引用类型的话,那么将使得c.A(a)变成a传值给c.A(A other),即A other = a,而other没有被初始化,所以other = a将继续调用拷贝构造函数(这也是为什么调用func函数会调用拷贝构造函数)。接下来是构造other,也就是other.A(a),即A other = a,又会触发拷贝构造函数,这样永远地递归下去。
关于上述程序还有一些值得说明的:
1)当某对象没被初始化,这时运用赋值运算符调用的是拷贝构造函数;否则调用的是赋值运算符重载函数;
2)上述程序的“A a = 10”实际存在隐式类型转换(从int到A),这是编译器默认帮我们处理的。这条语句相当于:
A tmp(10); A a(tmp); // or a = tmp
如果我们不想编译器默认帮我们做这种转换,我们可以在构造函数前加上关键字explicit:
explicit A(int n) {...}
这样,类似这样的赋值“A a = 10”将通不过编译。
另外,当我们定义如下的函数:如果构造函数前没有加explicit,则我们这样子调用test(10)是可以的;但如果加了explicit就会提示无法从‘int’转换为‘A’。
void test(A other) { }
关于explicit可参考:
What does the explicit keyword in C++ mean?
C++ explicit关键字 详解(用于构造函数)
3)关于public、protected、private几个关键字的重新理解。如果我们直接在main函数用a.value是不行的(因为权限是private),但在拷贝构造函数和重载赋值运算符函数中确是可以的,而且,当我们不将传入参数设定为const的话,我们在函数中还可以修改传入参数的值,这不是自相矛盾了吗?具体解释可以参考博文C++ 类访问控制public/private/protected探讨的说法:
“
类是将数据成员和进行于其上的一系列操作(成员函数)封装在一起。注意:成员函数可以操作数据成员(可以称类中的数据成员为泛数据成员)!
对象是类的实例化,怎样理解实例化?其实每一个实例对象都只是对其中的数据成员初始化,内存映像中每个对象仅仅保留属于自己的那份数据成员副本。而成员函数对于整个类而言却是共享的,即一个类只保留一份成员函数。
那么每个对象怎样和这些可以认为是“分离”的成员函数发生联系,即成员函数如何操作对象的数据成员?记住this指针,无论对象通过(.)操作或者(->)操作调用成员函数。编译时刻,编译器都会将这种调用转换成我们常见的全局函数的形式,并且多出一个参数(一般这个参数放在第一个,有点像python中类中函数声明中的self参数),然后将this指针传入这个参数。于是就完成了对象与成员函数的绑定(或联系)。
实例化后就得到同一个类的多个不同的对象,既然成员函数共享的,那么成员函数就可以操作对象的数据成员。
问题是现在有多个对象,成员函数需要知道操作的是哪个对象的数据成员?比如有对象obj1和obj2,都属于A类,A类有public成员函数foo()。如果obj1调用该函数,编译时会给foo函数传入this指针,obj1.foo中操作obj1自身的成员就不用任何修饰,直接访问,因为其中的数据成员自动根据this指针找到。
如果obj1调用该函数,同样可以访问同类的其他对象的数据成员!那么你需要做的是让foo函数知道是同类对象中哪个对象的数据成员,一个解决办法是传入同类其他对象的指针或引用,那么就可以操作同类其他对象的数据成员。
foo(A& obj)
这样定义,然后调用:
obj1.foo(obj2)
就可以在obj1访问obj2的数据成员,而无论这些数据成员是private还是protected
"
处理静态成员变量
关于静态成员变量详细的可参考之前博文C/C++中关键字static的用法及作用。静态成员变量主要是为了同个类的不同实例之间数据的共享,所以其处理方法也跟其他成员变量稍微不一样。
先看下程序:
1 class Rect 2 { 3 public: 4 Rect() // 构造函数,计数器加1 5 { 6 count++; 7 } 8 ~Rect() // 析构函数,计数器减1 9 { 10 count--; 11 } 12 static int getCount() // 返回计数器的值 13 { 14 return count; 15 } 16 private: 17 int width; 18 int height; 19 static int count; // 一静态成员做为计数器 20 }; 21 22 int Rect::count = 0; // 初始化计数器 23 24 int main() 25 { 26 Rect rect1; 27 cout << "The count of Rect: " << Rect::getCount() << endl; 28 29 Rect rect2(rect1); // 使用rect1复制rect2,此时应该有两个对象 30 cout << "The count of Rect: " << Rect::getCount() << endl; 31 32 return 0; 33 }
程序的输出结果都是count = 1,这明显跟我们的期望值(应该是2)不一样。具体原因在于我们没有定制拷贝构造函数,而是由编译器帮我们自动生成一个默认拷贝函数:
1 Rect::Rect(const Rect& orig) 2 { 3 width = orig.width; 4 height = orig.height; 5 }
显然这里并没有处理静态成员变量count,所以我们需要定制拷贝构造函数:
1 Rect(const Rect& orig) // 拷贝构造函数 2 { 3 width = orig.width; 4 height = orig.height; 5 count++; // 计数器加1 6 }
深拷贝与浅拷贝
深拷贝主要解决的问题是指针成员变量浅拷贝的问题。这方面的博文很多,可以参考博文C++拷贝构造函数详解。这篇博文有提到了几点值得注意的:
1. 防止默认拷贝(也能够禁止复制)
有一个小技巧可以防止按值传递——声明一个私有拷贝构造函数。甚至不必去定义这个拷贝构造函数,这样因为拷贝构造函数是私有的,如果用户试图按值传递或函数返回该类对象,将得到一个编译错误,从而可以避免按值传递或返回对象。如下程序:
1 #include <iostream> 2 using namespace std; 3 4 class CExample 5 { 6 private: 7 int value; 8 9 public: 10 //构造函数 11 CExample(int val) 12 { 13 value = val; 14 cout << "creat: " << value << endl; 15 } 16 17 private: 18 //拷贝构造,只是声明 19 CExample(const CExample& C); 20 21 public: 22 ~CExample() 23 { 24 cout << "delete: " << value << endl; 25 } 26 27 void Show() 28 { 29 cout << value << endl; 30 } 31 }; 32 33 //全局函数 34 void g_Fun(CExample C) 35 { 36 cout << "test" << endl; 37 } 38 39 int main() 40 { 41 CExample test(1); 42 // g_Fun(test); // 按值传递将出错 43 44 return 0; 45 }
而根据《C++ Primer》第四版13.1.3节,要禁止类的复制, 类必须显示声明其复制构造函数为private。
2. 小问题1:以下哪个函数是拷贝构造函数,为什么?
1 X::X(const X&); 2 X::X(X); 3 X::X(X&, int a=1); 4 X::X(X&, int a=1, int b=2);
解答:对于一个类X,如果一个构造函数的第一个参数是下列之一:
a)X&
b)const X&
c) volatile X&
d)const volatile X&
且没有其他参数或其他参数都有默认值,那么这个函数是拷贝构造函数.
1 X::X(const X&); //是拷贝构造函数 2 X::X(X&, int=1); //是拷贝构造函数 3 X::X(X&, int a=1, int b=2); //当然也是拷贝构造函数
3. 小问题2:一个类中可以有多个拷贝构造函数吗?
解答:类中可以存在超过一个拷贝构造函数。
1 class X { 2 public: 3 X(const X&); // const 的拷贝构造 4 X(X&); // 非const的拷贝构造 5 };
关于运算符重载可参考之前博文C++运算符重载。
在本文的第2个程序中我们就已经对赋值运算符进行了重载:
1 A& operator=(const A& other) 2 { 3 cout << "assignment operator" << endl; 4 value = other.value; 5 6 return *this; 7 }
为了便于说明,我们用一个新的例子(我们极容易写出这样的代码):
1 class CMyString 2 { 3 public: 4 CMyString(char *ptr = nullptr); 5 CMyString(const CMyString &str); 6 ~CMyString(); 7 CMyString& operator=(const CMyString& str); 8 9 private: 10 char *pData; 11 }; 12 13 CMyString& CMyString::operator=(const CMyString& str) 14 { 15 pData = str.pData; 16 return *this; 17 }
这个赋值运算符重载函数存在的问题如下:
1)浅拷贝;
2)没有(检查)释放实例自身已有的内存。如果我们忘记在分配新内存之前释放自身已有的空间,程序将出现内存泄漏;
3)没有判断传入的参数和当前的实例(*this)是不是同一个实例。如果是同一个,则不进行复制操作,直接返回。如果事先不判断就进行赋值,那么在释放实例自身的内存的时候就会导致严重问题:当*this和传入的参数是同一个实例时,那么一旦释放了自身的内存,传入的参数的内存也同时被释放了,因此在也找不到需要赋值的内容了。
修改之后的赋值运算符重载函数如下:
1 CMyString& CMyString::operator=(const CMyString& str) 2 { 3 if (this == &str) 4 return *this; 5 6 delete []pData; 7 pData = nullptr; 8 9 pData = new char[strlen(str.pData) + 1]; 10 strcpy(pData, str.pData); 11 12 return *this; 13 }
上述代码现在的问题在于4)异常安全性,即new可能会抛出异常,而我们却没有处理!所以我们可以将程序继续修改:
1 CMyString& CMyString::operator=(const CMyString& str) 2 { 3 if (this == &str) 4 return *this; 5 6 char *tmp = new(nothrow) char[strlen(str.pData) + 1]; 7 if (tmp == nullptr) 8 return *this; 9 10 strcpy(tmp, str.pData); 11 12 delete []pData; 13 pData = tmp; 14 tmp = nullptr; 15 16 return *this; 17 }
除了前边提到的4个点,赋值运算符重载还有两点需要注意:
5)是否把返回值的类型声明为该类型的引用,并在函数结束前返回实例自身的引用(即*this)。只有返回一个引用,才可以允许连续赋值。否则如果函数的返回值是void,应用该赋值将不能做连续赋值。假设有3个CMyString对象:str1、str2和str3,在程序中语句str1=str2=str3将不能通过编译。
6)是否把传入的参数的类型声明为常量引用。如果传入的参数不是引用而是实例,那么从形参到实参会调用一次拷贝构造函数。把参数声明为引用可以避免这样的无谓消耗,从而提高代码效率。同时,我们在赋值运算符函数内不会修改传入的实例的状态,因此应该为传入的引用参数加上const关键字。
参考资料《剑指Offer》
C++拷贝构造函数详解
C++ 类访问控制public/private/protected探讨