本文已参与「新人创作礼」活动,一起开启掘金创作之路。
拷贝构造函数
先实现一个String类,在这之中讲解拷贝构造和析构函数
#include<iostream>
using namespace std;
class String{
private:
char *data; //C风格字符串
int n;
public:
String(const char *s=0){
if(s == 0){
data = 0;
n = 0;
return;
}
const char *p = s;
while(*p){
p++;
}
n = p - s;
data = new char[n + 1];
for (int i = 0; i < n; i++){
data[i] = s[i];
}
data[n] = '\0';
}
int size(){
return n;
}
};
int main(){
String s1, s2("hello world");
return 0;
}
我们希望既可以带参数定义变量,也可以不带参数定义变量,所以对于构造函数可以使用默认构造函数(构造函数是不带参数,或者构造函数有默认值)
定义下标运算符,一个用于修改值,一个只用来查看
char operator[](int i) const{
if(i < 0 || i > n){
throw "越界";
}
return data[i];
}
char& operator[](int i){
if(i < 0 || i > n){
throw "越界";
}
return data[i];
}
定义输出类型运算符
ostream& operator<<(ostream &o, String &s){
for (int i = 0; i < s.size(); i++){
cout << s[i];
}
return o;
}
这里输出运算符第二个参数一定记得加引用,不然会定义新的变量,然后赋值过去,这样会额外调用拷贝构造函数。
int main(){
String s1, s2("hello world");
s2[1] = 'E';
cout << s2 << endl;
String s3 = s2;
cout << s3 << endl;
s3[3] = 'L';
cout << s2 << endl;
cout << s3 << endl;
return 0;
}
hEllo world
hEllo world
hElLo world
hElLo world
在上面这种情况下,s2和s3结果一样,修改了s3时,s2也被修改,这是我们不希望的。
之所以这样,是因为当s3 = s2;
时调用了拷贝构造函数,虽然我们没有定义这个构造函数,编译器会生成一个默认的硬拷贝构造函数,也就是:
String(const String &s){
data = s.data;
n = s.n;
}
重新定义拷贝构造:
String(const String &s){
data = new char[s.n + 1];
n = s.n;
for (int i = 0; i < n; i++){
data[i] = s.data[i];
}
data[n] = '\0';
// cout << "硬拷贝" << endl;
}
int main(){
String s1, s2("hello world");
s2[1] = 'E';
cout << s2 << endl;
String s3 = s2;
cout << s3 << endl;
s3[3] = 'L';
cout << s2 << endl;
cout << s3 << endl;
return 0;
}
hEllo world
hEllo world
hEllo world
hElLo world
这时修改了s3就不会再修改s2。
在 C++ 中,拷贝是指用已经存在的对象创建出一个新的对象。
严格来说,对象的创建包括两个阶段,首先要分配内存空间,然后再进行初始化:
- 分配内存很好理解,就是在堆区、栈区或者全局数据区留出足够多的字节。这个时候的内存还比较“原始”,没有被“教化”,它所包含的数据一般是零值或者随机值,没有实际的意义。
- 初始化就是首次对内存赋值,让它的数据有意义。注意是首次赋值,再次赋值不叫初始化。初始化的时候还可以为对象分配其他的资源(打开文件、连接网络、动态分配内存等),或者提前进行一些计算(根据价格和数量计算出总价、根据长度和宽度计算出矩形的面积等)等。说白了,初始化就是调用构造函数。
很明显,这里所说的拷贝是在初始化阶段进行的,也就是用其它对象的数据来初始化新对象的内存。
拷贝构造函数只有一个参数,它的类型是当前类的引用,而且一般都是 const 引用。
- 为什么必须是当前类的引用呢?
如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。
只有当参数是当前类的引用时,才不会导致再次调用拷贝构造函数,这不仅是逻辑上的要求,也是 C++ 语法的要求。
- 为什么是 const 引用呢?
拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。
另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。对于简单的类,默认拷贝构造函数一般是够用的,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认拷贝构造函数就不能拷贝这些资源,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据。
如果一个类的成员变量是指向一块资源,比如一块内存,或者打开一个文件、网络端口等,如果构造函数需要申请资源,那么就不能用默认的拷贝构造函数,不然会出现两个问题:
- 拷贝构造后两个对象会共享同一块资源,对一个修改,那么另一个也会修改。
- 销毁时出问题,当指向同一个内存,使用完时会释放掉,第一个释放完时,第二个也没了,当第二个再释放时就会出错。
析构函数
C++ 中的 new 和 delete 分别用来分配和释放内存,它们与C语言中 malloc()、free() 最大的一个不同之处在于:用 new 分配内存时会调用构造函数,用 delete 释放内存时会调用析构函数。构造函数和析构函数对于类来说是不可或缺的,所以在C++中我们非常鼓励使用 new 和 delete。
上面我们没有对申请的空间进行销毁,会造成内存泄漏,所以要及时销毁不用的申请的内存。
析构函数(Destructor)也是一种特殊的成员函数,没有返回值,不需要程序员显式调用(程序员也没法显式调用),而是在销毁对象时自动执行。构造函数的名字和类名相同,而析构函数的名字是在类名前面加一个~符号。
注意:析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数。
~String(){
cout << n << "析构函数" << endl;
if(data){
delete[] data;
}
}
析构函数执行时机
析构函数在对象被销毁时调用,而对象的销毁时机与它所在的内存区域有关。
在所有函数之外创建的对象是全局对象,它和全局变量类似,位于内存分区中的全局数据区,程序在结束执行时会调用这些对象的析构函数。
在函数内部创建的对象是局部对象,它和局部变量类似,位于栈区,函数执行结束时会调用这些对象的析构函数。
new 创建的对象位于堆区,通过 delete 删除时才会调用析构函数;如果没有 delete,析构函数就不会被执行。
完整程序:
#include<iostream>
using namespace std;
class String{
private:
char *data; //C风格字符串
int n;
public:
~String(){
cout << n << "析构函数" << endl;
if(data){
delete[] data;
}
}
String(const String &s){
data = new char[s.n + 1];
n = s.n;
for (int i = 0; i < n; i++){
data[i] = s.data[i];
}
data[n] = '\0';
// cout << "硬拷贝" << endl;
}
String(const char *s=0){
if(s == 0){
data = 0;
n = 0;
cout << "s==0构造函数" << endl;
return;
}
const char *p = s;
while(*p){
p++;
}
n = p - s;
data = new char[n + 1];
for (int i = 0; i < n; i++){
data[i] = s[i];
}
data[n] = '\0';
cout << "构造函数" << endl;
}
int size(){
return n;
}
char operator[](int i) const{
if(i < 0 || i > n){
throw "越界";
}
return data[i];
}
char& operator[](int i){
if(i < 0 || i > n){
throw "越界";
}
return data[i];
}
};
ostream& operator<<(ostream &o, String &s){
for (int i = 0; i < s.size(); i++){
cout << s[i];
}
return o;
}
int main(){
String s1, s2("hello world");
s2[1] = 'E';
cout << s2 << endl;
String s3 = s2;
cout << s3 << endl;
s3[3] = 'L';
cout << s2 << endl;
cout << s3 << endl;
return 0;
}
s==0构造函数
构造函数
hEllo world
hEllo world
hEllo world
hElLo world
11析构函数
11析构函数
0析构函数
从输出能看出,先构造的后销毁,构造与析构的顺序是相反的。
如果在重载输出运算符时没加引用ostream& operator<<(ostream &o, String s)
,那么在这里输出运算符结束也会调用析构函数,当调用的是默认构造函数时,也会把数据删除掉,在之后释放被打印的对象时也会报错。