【C++要笑着学】类的默认成员函数详解 | 构造函数 | 析构函数 | 构造拷贝函数(二)

简介: 朋友们好啊,今天终于更新了。我是柠檬叶子C,本章将继续讲解C++中的面向对象的知识点,本篇主要讲解默认成员函数中的构造函数、析构函数和拷贝构造函数。还是和以前一样,我们将由浅入深地去讲解,以 "初学者" 的角度去探索式地学习。会一步步地推进讲解,而不是直接把枯燥的知识点倒出来,应该会有不错的阅读体验。如果觉得不错,可以 "一键三连" 支持一下博主!你们的关注就是我更新的最大动力!Thanks ♪ (・ω・)ノ

Ⅲ. 析构函数


0x00 引入

通过前面构造函数的学习,我们知道了一个对象是怎么来的了,


❓ 那一个对象又是怎么没的呢?既然构造函数的本质是初始化,那清理的工作交给谁来干呢?


💡 交给专门擦屁股的 —— 析构函数!


以前我们玩数据结构的时候经常忘记调用 destroy 函数,但是现在我们有析构函数了!!!


4edc3d851528fb529169e651c66d83b9_c91e123a79db4f71bc36206cdb6d41ec.png

多么振奋人心啊!话不多说让我们开始讲解!!!


0x01 析构函数的概念

析构函数与构造函数的功能相反。


构造函数是特殊的成员函数,主要任务是初始化,而不是开空间;


析构函数也一样,主要任务是清理,而不是做对象销毁的工作。


(局部对象销毁工作是由编译器完成的)


📚 概念:对象在销毁时会自动调用析构函数,完成对象的一些资源清理工作。


0x02 析构函数的特性

构造函数是特殊的成员函数,主要特征如下:


① 析构函数名是在类名前面加上字符


② 析构函数既没有参数也没有返回值(因为没有参数,所以也不会构成重载问题)


③ 一个类的析构函数有且仅有一个(如果不写系统会默认生成一个析构函数)


④ 析构函数在对象生命周期结束后,会自动调用。


(和构造函数是对应的构造函数是在对象实例化的时候自动调用)


💬 为了演示自动调用,我们来让析构函数被调用时 "吱" 一声:


#include <iostream>
using namespace std;
class Date {
public:
    Date(int year = 1, int month = 0, int day = 0) {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print() {
        printf("%d-%d-%d\n", _year, _month, _day);
    }
    ~Date() {
        // Date 类没有资源需要清理,所以Date不实现析构函都是可以的
        cout << "~Date() 吱~ " << endl;  // 测试一下,让他吱一声
    }
private:
    int _year;
    int _month;
    int _day;
};
int main(void)
{
    Date d1;
    Date d2(2022, 3, 9);
    return 0;
}


🚩 运行结果:

1a24f747f3db8cc7531dc31a1b067aa4_ece1a5c7e1634ced8175924c09185d28.png



额,之前举得日期类的例子没法很好地展示析构函数的 "魅力" ……


就像本段开头说情景,我们拿 Stack 来举个例子,这就很贴切了。


我们知道,栈是需要 destroy 清理开辟的内存空间的。


这里我们让析构函数来干这个活,简直美滋滋!


💬 析构函数的用法:


#include<iostream>
#include<stdlib.h>
using namespace std;
typedef int StackDataType;
class Stack {
public:
    /* 构造函数 - StackInit */
    Stack(int capacity = 4) {  // 这里只需要一个capacity就够了,默认给4(利用缺省参数)
        _array = (StackDataType*)malloc(sizeof(StackDateType) * capacity);
        if (_array == NULL) {
            cout << "Malloc Failed!" << endl;
            exit(-1);
        }
        _top = 0;
        _capacity = capacity;
    }
    /* 析构函数 - StackDestroy */
    ~Stack() {   // 这里就用的上析构函数了,我们需要清理开辟的内存空间(防止内存泄漏)
        free(_array);
        _array = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _array;
    size_t _top;
    size_t _capacity;
};
int main(void)
{
    Stack s1;
    Stack s2(20); // s2 栈 初始capacity给的是20(可以理解为"客制化")
    return 0;
}

🔑 解读:我们在设置栈的构造函数时,定义容量 capacity 时利用缺省参数默认给个4的容量,这样用的时候默认就是4,如果不想要4可以自己传。


如此一来,就可以保证了栈被定义出来就一定被初始化,用完后会自动销毁。以后就不会有忘记调用 destroy 而导致内存泄露的惨案了,这里的析构函数就可以充当销毁的作用。


❓ 问一个比较有意思的问题,这里是先析构 s1 还是先析构 s2?


既然都这样问了,应该是先析构 s2 了 ~


析构的顺序在局部的栈中是相反的,栈帧销毁清理资源时 s2 先清理,然后再清理 s1 。


(不信的话可以去监视一下 this 观察下成员变量)


0x03 析构函数的特性的测试

又到了测试环节,上号!


我们知道,如果没写析构函数编译器会自动生成一个。


那生成的析构函数会做什么事情呢?它会帮我们 destroy 嘛?


想屁吃?哪有这种好事。


如果我们不写默认生成的析构函数,结果和构造函数类似,


对于自定义类型的成员变量不作处理,对于自定义类型的成员变量会去调用它的析构函数。


#include<iostream>
#include<stdlib.h>
using namespace std;
typedef int StackDataType;
class Stack {
public:
    Stack(int capacity = 4) {
        _array = (StackDataType*)malloc(sizeof(int*) * capacity);
        if (_array == NULL) {
            cout << "Malloc Failed!" << endl;
            exit(-1);
        }
        _top = 0;
        _capacity = capacity;
    }
    // ~Stack() {
    //     free(_array);
    //     _array = nullptr;
    //     _top = _capacity = 0;
    // }
private:
    int* _array;
    size_t _top;
    size_t _capacity;
};
int main(void)
{
    Stack s1;
    Stack s2(20);
    return 0;
}


难道就不能帮我把这些事都干了吗?帮我都销毁掉不就好了?


不不不,举个最简单的例子,迭代器,析构的时候是不释放的,因为不需要他来管,


所以默认不对内置类型处理是正常的,万一误杀了怎么办,对吧。


有人可能又要说了,这么一来默认生成的析构函数不就没有用了吗?


有用!他对内置类型的成员类型不作处理,会在一些情况下非常的有用!


比如说:   两个栈实现一个队列(LeetCode232) ,用C++可以非常的爽。


💬 自定义类型的成员变量调用它的析构函数:


#include <iostream>
using namespace std;
class String {
public:
  String(const char* str = "jack") {
  _str = (char*)malloc(strlen(str) + 1);
  strcpy(_str, str);
  }
  ~String() {
  cout << "~String()" << endl;
  free(_str);
  }
private:
  char* _str;
};
class Person {
private:
  String _name;
  int _age;
};
int main()
{
  Person p;
  return 0;
}


🚩 运行结果如下:

dae67a4cbf25a8a1c7ac3a9c08e69218_fba4fe23a9c84c0f802f22f2739e22f5.png


Ⅳ.  拷贝构造函数


0x00 引入

21230186d88a02aacdb2008f18264190_458225ab34844a9884734d81de00bda2.png


我们在创建对象的时候,能不能创建一个与某一个对象一模一样的新对象呢?


Date d1(2022, 3, 9);    
d1.Print();
Date d2(d1);    // 照着d1的模子做一个d2
d2.Print();

当然可以,这时我们就可以用拷贝构造函数。

da2735ff45c2976413631f336724dc61_2e2cac346b3c4d57a643086f0bf10621.png

0x01 拷贝构造函数的概念

📚 拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用 const 修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。


0x02 拷贝构造函数的特性

它也是一个特殊的成员函数,所以他符合构造函数的一些特性:


① 拷贝构造函数是构造函数的一个重载形式。函数名和类名相同,没有返回值。


② 拷贝构造函数的参数只有一个,并且必须要使用引用传参!


     使用传值方式会引发无穷递归调用!


💬 拷贝构造函数的用法:


#include <iostream>
class Date {
public:
    Date(int year = 0, int month = 1, int day = 1) {
        _year = year;
        _month = month;
        _day = day;
    }
    /* Date d2(d1); */
    Date(Date& d) {         // 这里要用引用,否则就会无穷递归下去
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
    void Print() {
        printf("%d-%d-%d\n", _year, _month, _day);
    }
private:
    int _year;
    int _month;
    int _day;
};
int main(void)
{
    Date d1(2022, 3, 9);
    Date d2(d1);          // 拷贝复制
    // 看看拷贝成功没
    d1.Print();
    d2.Print();
    return 0;
}


🚩 运行结果如下:

c7b783077071d41ca2ecde80a8dd8e98_9d6ef494314d4e2c8db76bac63157f50.png

❓ 为什么必须使用引用传参呢?


调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。


调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。


调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。


……


一直在传参这里出不去了,所以这个递归是一个无穷无尽的。


💬 我们来验证一下:

6cfa5b5ab54dce08a05b37c4d2fa045d_b8ca5dcd31e74606b361dbdf0ad20a0d.png


error: invalid constructor; you probably meant 'Date (const Date&)'

77fc4fbf7a412ac956925722234d6bbe_dafe45d28d1740c49848add2cf3efe6f.png



这里不是加不加 const 的问题,而是没有用引用导致的问题。


不用引用,他就会在传参那无线套娃递归。至于为什么我们继续往下看。


💬 拷贝构造函数加 const :


如果函数内不需要改变,建议把 const 也给它加上


class Date {
public:
    Date(int year = 0, int month = 1, int day = 1) {
        _year = year;
        _month = month;
        _day = day;
    }
    /* Date d2(d1); */
    Date(const Date& d) {    // 如果内部不需要改变,建议加上const
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
    void Print() {
        printf("%d-%d-%d\n", _year, _month, _day);
    }
private:
    int _year;
    int _month;
    int _day;
};


第一个原因:怕出错,万一你一不小心写反了怎么办?


/* Date d2(d1); */
Date(Date& d) {
    d._year = _year;
    d._month = _month;
    d._day = _day;
}

这样会产生一个很诡异的问题,这一个可以被编译出来的 BUG ,结果会变为随机值。


所以,这里加一个 const 就安全多了,这些错误就会被检查出来了。


第二个原因:以后再讲,因为涉及一些临时对象的概念。


🔺 反正,不想深究的话就记住:如果函数体内不需要改变,建议把 const 加上就完事了。


0x03 关于默认生成的拷贝构造

这里比较特殊,我们单独领出来讲。


📚 默认生成拷贝构造:


① 内置类型的成员,会完成按字节序的拷贝(把每个字节依次拷贝过去)。


② 自定义类型成员,会再调用它的拷贝构造。


💬 拷贝构造我们不写生成的默认拷贝构造函数,对于内置类型和自定义类型都会拷贝处理。但是处理的细节是不一样的,这个跟构造和析构是不一样的!


#include<iostream>
using namespace std;
class Date {
    public:
        Date(int year = 0, int month = 1, int day = 1) {
            _year = year;
            _month = month;
            _day = day;
        }
        // Date(Date& d) {
        //     _year = d._year;
        //     _month = d._month;
        //     _day = d._day;
        // }
        void Print() {
            printf("%d-%d-%d\n", _year, _month, _day);
        } 
    private:
        int _year;
        int _month;
        int _day;
};
int main(void)
{
    Date d1(2002, 4, 8);
    // 拷贝复制
    Date d2(d1);
    // 没有写拷贝构造,但是也拷贝成功了
    d1.Print();
    d2.Print();
    return 0;
}


🚩 运行结果如下:

81a58c7c752ff5708a02b122fd1717bc_3bbb3b3273604b7ab4990e1843ee9fa8.png

🔑 他这和之前几个不同了,这个他还真给我解决了。


所以为什么要写拷贝构造?写他有什么意义?没有什么意义。


默认生成的一般就够用了!


当然,这并不意味着我们都不用写了,有些情况还是不可避免要写的


比如实现栈的时候,栈的结构问题,导致这里如果用默认的 拷贝构造,会翻车。


按字节把所有东西都拷过来会产生问题,如果 Stack st1 拷贝出另一个 Stack st2(st1)


会导致他们都指向那块开辟的内存空间,导致他们指向的空间被析构两次,导致程序崩溃


然而问题不止这些……


其实这里的字节序拷贝是浅拷贝,下面几章我会详细讲一下深浅拷贝,这里的深拷贝和浅拷贝先做一个大概的了解。


🔺 总结:对于常见的类,比如日期类,默认生成的拷贝构造能用。但是对于栈这样的类,默认生成的拷贝构造不能用。


Ⅴ.  总结


e81104d5f6be9b49d55989ffca66df16_2b2034afa08142ddbb2c07fb62943967.png


默认成员函数有六只,本篇只介绍了三只,剩下的我们后面讲。


类和对象部分知识很重要,所以我们来做一个简单的总结 ~


0x00 构造函数

初始化,在对象实例化时候自动调用,保证实例化对象一定被初始化。


构造函数是默认成员函数,我们不写编译器会自己生成一份,我们写了编译器就不会生成。


我们不写内置类型成员变量不处理。


对于内置类型成员变量不处理。


对于自定义类型的成员变量会调用它的默认构造函数。


// 我们需要自己实现构造函数
class Date {
    int _year;
    int _month;
    int _day;
};
// 我们不需要自己实现构造函数,默认生成的就可以
class MyQueue {
    Stack _pushST;
    Stack _popST;
};

0x01 析构函数

完成对象中自愿的清理。如果类对象需要资源清理,才需要自己实现析构函数。


析构函数在对象生命周期到了以后自动调用,如果你正确实现了析构函数,保证了类对象中的资源被清理。


什么时候生命周期到了?如果是局部变量,出了作用域。全局和静态变量,整个程序结束。


我们不写编译器会默认生成析构函数,我们实现了,编译器就不会实现了。


对于内置类型成员变量不处理。


对于自定义类型的成员变量会调用它的析构函数。


// 没有资源需要清理,不徐需要自己实现析构函数
class Date {
    int _year;
    int _month;
    int _day;
};
// 需要自己实现析构函数,清理资源。
class Stack {
    int* _a;
    int  _top;
    int  _capacity;
};

0x02 拷贝构造

使用同类型的对象去初始化实例对象。


参数必须是引用!不然会导致无穷递归。


如果我们不实现,编译器会默认生成一份默认的拷贝构造函数。


默认生成的拷贝构造:


① 内置类型完成按子继续的值拷贝。 —— 浅拷贝


② 自定义类型的成员变量,会去调用它的拷贝构造。


// 不需要自己实现,默认生成的拷贝构造,完成浅拷贝就能满足需求
class Date {
    int _year;
    int _month;
    int _day;
};
// 需要自己实现,因为默认生成的浅拷贝不能满足需求。
// 我们需要自己实现深拷贝的拷贝构造,深拷贝我们后面会用专门的章节去讲解。        
class Stack {
    int* _a;
    int  _top;
    int  _capacity;
};
#include <iostream>
using namespace std;
class Date {
public:
    Date(int year = 1, int month = 0, int day = 0) {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print() {
        printf("%d-%d-%d\n", _year, _month, _day);
    }
    ~Date() {
        cout << "&Date()" << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
int main(void)
{
    Date d1;
    d1.Print();
    Date d2(2002);
    d2.Print();
    Date d3(2022, 3);
    d3.Print();
    Date d4(2022, 3, 9);
    d4.Print();
    return 0;
}


🚩 运行结果如下:

755d8d28a907d90512d42823e4b30f16_d3768872beb641cd9e88ae021c0cd870.png

相关文章
|
6天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
29 4
|
27天前
|
程序员 C++ 容器
在 C++中,realloc 函数返回 NULL 时,需要手动释放原来的内存吗?
在 C++ 中,当 realloc 函数返回 NULL 时,表示内存重新分配失败,但原内存块仍然有效,因此需要手动释放原来的内存,以避免内存泄漏。
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
45 6
|
1月前
|
C++
C++ 多线程之线程管理函数
这篇文章介绍了C++中多线程编程的几个关键函数,包括获取线程ID的`get_id()`,延时函数`sleep_for()`,线程让步函数`yield()`,以及阻塞线程直到指定时间的`sleep_until()`。
23 0
C++ 多线程之线程管理函数
|
1月前
|
编译器 C语言 C++
详解C/C++动态内存函数(malloc、free、calloc、realloc)
详解C/C++动态内存函数(malloc、free、calloc、realloc)
154 1
|
1月前
|
编译器 C语言 C++
C++入门6——模板(泛型编程、函数模板、类模板)
C++入门6——模板(泛型编程、函数模板、类模板)
41 0
C++入门6——模板(泛型编程、函数模板、类模板)
|
1月前
|
C++
C++构造函数初始化类对象
C++构造函数初始化类对象
17 0
|
1月前
|
C++
C++入门4——类与对象3-2(构造函数的类型转换和友元详解)
C++入门4——类与对象3-2(构造函数的类型转换和友元详解)
21 0
|
7天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
25 4
|
30天前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
27 4