• 21天学会C++:Day10----类的默认成员函数


    · CSDN的uu们,大家好。这里是C++入门的第十讲。
    · 座右铭:前路坎坷,披荆斩棘,扶摇直上。
    · 博客主页: @姬如祎
    · 收录专栏:C++专题

    目录

    1. 构造函数

    1.1 引入

    1.2 默认构造函数

    2. 析构函数

    2.1 析构函数的定义

    2.2 什么时候写析构函数

    3. 拷贝构造函数

    3.1 如何书写拷贝构造

    3.2 何时需要自己书写拷贝构造函数

    3.3 拷贝构造的使用场景

    3.3.1 对象传参时

    3.3.2 对象返回时

     4. 赋值运算符重载

    5. 取地址重载


     上一讲我们提到过空类,即定义的类里面没有书写任何成员函数和成员变量。我们计算这个类的大小时显示的是一个字节。但是除了这一个字节就真的没有其他东西了吗?

    并不是这样的哦!任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。

    默认成员函数用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数

    让我们来看看这些默认成员函数都有哪些吧:

    构造函数:完成对象的初始化。

    析构函数:完成对象资源的清理。

    拷贝构造:用一个对象初始化另一个对象。

    赋值运算符重载:将一个对象的成员赋值给另一个对象的成员。

    取地址重载(不重要):这个不需要我们写,这里面包括普通对象的取地址重载和const对象的取地址重载两个。

    小盆友,你是不是有很多的问号,不着急,容我一一道来。

    1. 构造函数

    1.1 引入

    我们在学完栈的时候做过这么一道题:

    20. 有效的括号 - 力扣(LeetCode)

    C语言数据结构初阶(8)----栈与队列OJ题_姬如祎的博客-CSDN博客 

    因为C语言没有常见数据结构的库,因此当时我们自己手搓了一个栈,其中有这么两个函数:StackInit 和 StackDestroy 分别用来完成栈的初始化和销毁工作。 

    1. //栈的初始化
    2. void StackInit(ST* st)
    3. {
    4. ST_DATA_TYPE* newlist = (ST_DATA_TYPE*)malloc(sizeof(ST_DATA_TYPE) * INIT_STACK_SIZE);
    5. if (newlist != NULL)
    6. {
    7. st->data = newlist;
    8. st->capacity = INIT_STACK_SIZE;
    9. st->size = 0;
    10. }
    11. }
    12. //栈的销毁
    13. void StackDestory(ST* st)
    14. {
    15. free(st->data);
    16. st->data = NULL;
    17. }

    当我们创建一个栈,销毁一个栈的时候都要分别手动调用函数。是不是相当的麻烦,万一你没有调用呢,这个栈就废了。

    因此C++引入类之后,对象的初始化工作就不需要我们手动调用初始化函数了。在创建对象的时候编译器会自动调用他的构造函数进行对象的初始化工作,并且构造函数在一个对象的整个生命周期中只会被调用一次。

    我们来看看构造函数的特性:

    1:构造函数的函数名与类同名。

    2:无返回值。

    3:对象实例化的时候编译器自动调用对应的构造函数。

    4:构造函数支持重载。

     构造函数虽然叫做构造函数,但是他的任务不是开空间创建对象,而是用来初始化对象的。

    下面我们尝试来写一个构造函数:我们定义了一个名为Stack的类,在构造函数中为他的成员变量赋了初始值。

    1. class Stack
    2. {
    3. public:
    4. //构造函数与类同名,没有返回值
    5. Stack()
    6. {
    7. _a = (int*)malloc(sizeof(int) * 100);
    8. _size = 0;
    9. _capacity = 100;
    10. }
    11. private:
    12. int* _a;
    13. int _size;
    14. int _capacity;
    15. };

    那么我们怎样调用这个构造函数呢?前面说了创建对象的时候会自动调用相应的构造函数哒。我们在main函数里面直接创建一个对象,通过调试来确认编译器是否自动调用了构造函数:

    1. int main()
    2. {
    3. Stack st;
    4. return 0;
    5. }

     

    我们看到创建一个对象之后编译器确实自动调用了构造函数。但是前面不是说我们不写构造函数编译器会自动帮我们补上的嘛,那么编译器默认提供的构造函数会初始化成员变量吗?应该初始化成什么呢? 

    我们注释掉自己写的构造函数,只用编译器提供的构造函数来初始化对象,我们发现编译器并没有对成员变量做任何处理,即编译器提供的构造函数没有初始化成员变量。

    但是我们来看看下面的代码:我们定义了一个新的类 Test, 类Test中除了三个内置类型的成员变量,还多了一个自定义类型的成员变量,新的类Test依旧没有写构造函数。当我们实例化一个Test的对象会发生什么呢?

    1. class Stack
    2. {
    3. public:
    4. //构造函数与类同名,没有返回值
    5. Stack()
    6. {
    7. _a = (int*)malloc(sizeof(int) * 100);
    8. _size = 0;
    9. _capacity = 100;
    10. }
    11. private:
    12. int* _a;
    13. int _size;
    14. int _capacity;
    15. };
    16. class Test
    17. {
    18. public:
    19. private:
    20. Stack st;
    21. int _a;
    22. int _b;
    23. int _c;
    24. };
    25. int main()
    26. {
    27. Test test;
    28. return 0;
    29. }

    我们发现了一些奇怪的事,当一个类中的成员变量有自定义类型时。编译器提供的默认构造函数居然会把内置类型初始化为0。这其实是编译器的个性化行为,C++标准并未规定编译器提供的默认构造函数要对内置类型做初始化的工作。换一个编译器可能就不会初始化了(VS2013都没有初始化)。C++规定:程序员不写构造函数,提供的默认构造函数对内置类型不做处理,对自定义类型会调用他自己的构造函数。通过下图中的监视窗口可以看到,调用了Stack的构造函数。

    为了解决编译器提供的默认构造函数不初始化内置类型, C++11支持在成员变量声明的时候给缺省值。当我们没有写构造函数初始化成员变量或者写了构造函数但是没有初始化成员变量,编译器会使用缺省值来初始化成员变量。

    还有一点要注意的是,构造函数的访问权限一定要是公有的。因为对象的实例化都要调用构造函数,如果构造函数是私有的,我们就无法实例化对象了。 

    1.2 默认构造函数

     来看下面的代码:我们定义了一个Date类,自己写了一个构造函数将成员变量初始化为0。

    1. class Date
    2. {
    3. public:
    4. Date()
    5. {
    6. _year = 0;
    7. _month = 0;
    8. _day = 0;
    9. }
    10. private:
    11. int _year;
    12. int _month;
    13. int _day;
    14. };
    15. int main()
    16. {
    17. Date d1;
    18. return 0;
    19. }

    但是我们的需求远不止如此,我们希望根据自己的需求来初始化Date对象。这应该怎么做呢?这就要用到函数重载了。前面我们也提到过构造函数是支持重载的,因此我们可以再写一个构造函数,来实现自定义初始化Date对象。

    1. class Date
    2. {
    3. public:
    4. Date()
    5. {
    6. _year = 0;
    7. _month = 0;
    8. _day = 0;
    9. }
    10. Date(int year, int month, int day)
    11. {
    12. _year = year;
    13. _month = month;
    14. _day = day;
    15. }
    16. private:
    17. int _year;
    18. int _month;
    19. int _day;
    20. };

    问题来了,应该怎么在实例化对象的时候调用Date(int, int ,int)这个函数呢?没错就是你想的那样,在实例化对象的时候直接传入参数就可以了。

    1. int main()
    2. {
    3. Date d1(2004, 01, 01);
    4. return 0;
    5. }

    那我就有一个疑问了,有参数的构造函数要传参数调用,没有参数的构造函数可不可以这样调用呢?

    Date d2();

     答案是不可以哦!你没有发现他跟函数的声明很像:一个名为d2,返回类型为Date的函数不就是这个样子的嘛。因此无参构造函数的调用不需要加哪一个括号,别问,问就是规定。

    当我们把无参的构造函数删去,会发生什么呢?

     我们发现 Date d1; 无法实例化出来对象了!但是Date d2(2004, 01, 01); 依旧可以实例化出来对象。这是为什么呢?原因是在我们显示地写了构造函数之后,编译器将不再提供默认的构造函数。因此当我们用 Date d1; 这样的方式实例化对象时,尝试去调用无参的构造函数,发现没有自然就报错了。

    我们来看报错:不存在默认构造函数!什么是默认构造函数呢?默认构造函数是不传递参数就可以直接调用的构造函数。像我们不写构造函数编译器提供的那个构造函数就是默认构造函数

    1. Date(int year = 0, int month = 0, int day = 0)
    2. {
    3. _year = year;
    4. _month = month;
    5. _day = day;
    6. }

    那你觉得上面的构造函数可以是默认构造函数吗?先不着急回答,我们就提供这一个构造函数,看程序是否还会报错:

    1. class Date
    2. {
    3. public:
    4. Date(int year = 0, int month = 0, int day = 0)
    5. {
    6. _year = year;
    7. _month = month;
    8. _day = day;
    9. }
    10. private:
    11. int _year;
    12. int _month;
    13. int _day;
    14. };
    15. int main()
    16. {
    17. Date d1;
    18. return 0;
    19. }

    我们发现这个代码运行良好,说明Date已经有默认构造函数了。所以说,你现在知道答案了撒!

    记住默认构造函数的定义哦!默认构造函数是不传递参数就可以直接调用的构造函数。

    2. 析构函数

    2.1 析构函数的定义

    在构造函数的引入部分我们提到了C语言实现的栈有这么一个函数:StackDestroy,用于栈使用完后的资源清理和释放。每次使用完栈就要手动调用,可能你也嫌烦是吧,反正我是挺烦的。于是C++的析构函数他来了。析构函数就是专门负责类对象销毁之前的资源清理和释放的。

    我们来看看析构函数的特性:

    1:析构函数名是在类名前加上字符 ~。

    2:无参数无返回值类型。

    3:一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载

    4:对象生命周期结束时,C++编译系统系统自动调用析构函数。

    可以看到我们并没有显示的调用析构函数,析构函数还是会自己调用的。

     

    当我们的类中并没有写析构函数的时候,编译器提供的析构函数会做什么呢?答案跟构造函数很相似呢!编译器提供的析构函数对内置类型不做处理,对自定义类型会调用他自己的析构函数。这里就不再演示了。

    2.2 什么时候写析构函数

     一个类中的成员变量一般都是在栈中的,在函数栈帧销毁的时候,这个对象也会随之一起被销毁。但是如果我们的成员变量维护了堆区的空间,此时如果我们不写析构函数,编译器提供的析构函数是无法做到将堆区的空间释放掉的,就会造成内存泄漏。因为编译器提供的析构函数是不会对内置类型做任何处理的。因此当我们的成员变量维护了堆区的空间时,就需要我们自己写析构函数,手动释放内存。

    像下面的代码一样:

    1. class Stack
    2. {
    3. public:
    4. Stack()
    5. {
    6. _a = (int*)malloc(sizeof(int) * 100);
    7. _size = 0;
    8. _capacity = 100;
    9. }
    10. ~Stack()
    11. {
    12. //手动释放堆区的内存
    13. free(_a);
    14. _a = nullptr;
    15. }
    16. private:
    17. int* _a;
    18. int _size;
    19. int _capacity;
    20. };

    3. 拷贝构造函数

    3.1 如何书写拷贝构造

    顾名思义,拷贝构造函数也是构造函数。拷贝构造是用来干嘛的呢?

    C++标准规定,当用一个已有的对象去初始化另一个对象时必须调用拷贝构造函数。而不是将一个对象里面的成员变量直接拷贝给另一个对象的成员变量。

    我们来看看拷贝构造函数的特性:

    1. 拷贝构造函数是构造函数的一个重载形式。

    2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发 无穷递归调用。

    3. 若未显式定义,编译器会生成默认的拷贝构造函数。

     第一点,第三点都很好理解,我们来看第二点:类类型对象的引用就是说参数必须是这个类对象的引用,这是为什么呢?我们先不写引用,看看拷贝构造函数的调用逻辑:下面的这段代码是无法编译通过的。

    1. class Date
    2. {
    3. public:
    4. Date(int year = 0, int month = 0, int day = 0)
    5. {
    6. _year = year;
    7. _month = month;
    8. _day = day;
    9. }
    10. //这里的形参是一个类的对象,并不是类的对象的引用
    11. Date(Date d)
    12. {
    13. _year = d._year;
    14. _month = d._month;
    15. _day = d._day;
    16. }
    17. private:
    18. int _year;
    19. int _month;
    20. int _day;
    21. };
    22. int main()
    23. {
    24. Date d1(2004, 01, 01);
    25. Date d2(d1); //拷贝构造,用已经存在的d1对象来初始化正要实例化的d2对象
    26. return 0;
    27. }

    我们知道,函数的调用形参是实参的拷贝。当我们把d1对象传给形参d时,必然会发生对象的拷贝,根据对象的拷贝规定,必须调用拷贝构造函数。调用拷贝构造函数,就要继续传参,传参又要拷贝,拷贝就要调用拷贝构造函数······显然就无穷递归下去了。因此拷贝构造函数的形参必须是类对象的引用。引用即是别名,传参的过程就不会发生拷贝了。

    如对形参是实参的拷贝有疑问请先阅读:函数栈帧的创建和销毁详解_弹出该方法的栈帧去哪里了_姬如祎的博客-CSDN博客

    因此我们的拷贝构造函数的参数要写类对象的引用,因为拷贝构造不会改变原对象中的成员变量,因此最好加上const。

    1. //标准的拷贝构造函数
    2. Date(const Date& d)
    3. {
    4. _year = d._year;
    5. _month = d._month;
    6. _day = d._day;
    7. }

    3.2 何时需要自己书写拷贝构造函数

    你可能会想,我们不写拷贝构造函数编译器会自己提供的啊!那我们还写什么拷贝构造函数,这不是多此一举吗? 可事实真的是这样的吗?来看下面的代码:我们没有书写拷贝构造函数,使用的是编译器提供的拷贝构造。在main函数中,我们用已经创建好的st1对象初始化一个新的对象st2。

    1. class Stack
    2. {
    3. public:
    4. Stack()
    5. {
    6. _a = (int*)malloc(sizeof(int) * 100);
    7. _size = 0;
    8. _capacity = 100;
    9. }
    10. ~Stack()
    11. {
    12. //手动释放堆区的内存
    13. free(_a);
    14. _a = nullptr;
    15. }
    16. private:
    17. int* _a;
    18. int _size;
    19. int _capacity;
    20. };
    21. int main()
    22. {
    23. Stack st1;
    24. Stack st2(st1);
    25. return 0;
    26. }

    运行代码之后发现程序崩溃了!这究竟是为什么呢?那就得先搞清楚编译器提供的默认构造函数干了什么!

    编译器提供的默认拷贝构造对内置类型只会进行值拷贝(浅拷贝),而对自定义类型会调用自定义类型的拷贝构造函数。我们通过画图来看问题出现在哪里!

    st1对象的成员变量 _a 维护了堆区的一块空间,当我们调用编译器提供的拷贝构造,进行值拷贝,那么st2对象的成员变量 _a 就会指向 st1._a 指向的空间。当 main 函数结束,这两个对象即将销毁的时候,都会调用各自的析构函数。st2调用析构函数之后,堆区的空间已经被释放了。此时st1再来调用析构函数,释放_a指向的空间必然会引起程序崩溃。

    因此我们得出结论:当我们的成员变量右维护堆区的空间时,我们需要自己写拷贝构造函数,实现深拷贝(重新开相同大小的空间,将数据拷贝过去)。

    3.3 拷贝构造的使用场景

    3.3.1 对象传参时

    在将对象作为实参传递给函数的时候,形参一般都喜欢加上引用,因为对象可能很大,传值需要调用拷贝构造函数,效率太低了。

    1. class A
    2. {
    3. private:
    4. int _a[100];
    5. };
    6. void func(A a)
    7. {
    8. }
    9. int main()
    10. {
    11. A a1;
    12. func(a1);
    13. return 0;
    14. }

    3.3.2 对象返回时

     我们知道返回值的传递也是通过拷贝返回的(不理解请阅读函数栈帧的相关知识),也需要调用拷贝构造,效率也比较低下。这个时候我们千万不能在返回值那里随便加引用,因为不能返回局部变量的引用嘛。

    1. class A
    2. {
    3. private:
    4. int _a[100];
    5. };
    6. A func()
    7. {
    8. A a;
    9. return a;
    10. }
    11. int main()
    12. {
    13. func();
    14. return 0;
    15. }

     4. 赋值运算符重载

    这个是属于运算符重载的问题,我们就不在这里讲解了。

    5. 取地址重载

    这个不重要不讲解。

  • 相关阅读:
    想要精通分布式微服务架构?你得先学会设计、原理与实战
    UE4逆向篇-2_各类数据的查找方式
    012 Spring Boot + Vue 电影购票系统
    c++区间dp
    Spring MVC中的拦截器
    Android OOM问题笔记
    Project Costs
    Linux 学习笔记:input 子系统
    7. 微服务之Docker自动化部署
    数字驱动,营销赋能丨工商职院电子商务专业学生,前往餐饮美食电商新业态基地试岗交流
  • 原文地址:https://blog.csdn.net/m0_73096566/article/details/132972808