C++快速入门7

44 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

拷贝构造函数

先实现一个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 引用。

  1. 为什么必须是当前类的引用呢?
    如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。
    只有当参数是当前类的引用时,才不会导致再次调用拷贝构造函数,这不仅是逻辑上的要求,也是 C++ 语法的要求。
  1. 为什么是 const 引用呢?
    拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。
    另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。

对于简单的类,默认拷贝构造函数一般是够用的,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认拷贝构造函数就不能拷贝这些资源,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据。

如果一个类的成员变量是指向一块资源,比如一块内存,或者打开一个文件、网络端口等,如果构造函数需要申请资源,那么就不能用默认的拷贝构造函数,不然会出现两个问题:

  1. 拷贝构造后两个对象会共享同一块资源,对一个修改,那么另一个也会修改。
  2. 销毁时出问题,当指向同一个内存,使用完时会释放掉,第一个释放完时,第二个也没了,当第二个再释放时就会出错。

析构函数

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),那么在这里输出运算符结束也会调用析构函数,当调用的是默认构造函数时,也会把数据删除掉,在之后释放被打印的对象时也会报错。