【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)

简介: 本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。

前言

       之前我们在 类和对象(上)中了解了关于类的定义、对象的创建等一些基本知识:


https://developer.aliyun.com/article/1637204?spm=a2c6h.13262185.profile.8.204b2c70t7pAjo


今天,我们深入学习类和对象中默认成员函数相关的内容。


什么是默认成员函数

       所谓默认成员函数,就是在类当中我们没有显式实现,但是编译器自动生成的成员函数称之为默认成员函数。在c++11之前,默认成员函数一共有六个:



接下来我们会根据它们的特点,使用规则以及自实现方面逐一讲解。


一、构造函数

       构造函数的主要作用是:在对象被创建时,调用该函数对其成员变量进行初始化。就像我们在实现栈和队列时写的Init函数一样,会对它的成员先赋初值。


它的特点如下:


1. 构造函数的函数名与类名相同。

2. 构造函数无返回值。(连void都不写)

3. 构造函数可以重载。

4. 当对象被创建时,自动调用构造函数。


代码示例:

#include <iostream>
using namespace std;
 
class MyClass
{
public:
    //这里我们手动创建一个构造函数
    MyClass(int a = 0, int b = 0, int c = 0)//不传参时给个默认值为0
    {
        _a = a;
        _b = b;
        _c = c;
    }
    void Print()
    {
        cout << _a << endl;
        cout << _b << endl;
        cout << _c << endl;
    }
private:
    int _a;
    int _b;
    int _c;
};
 
int main()
{
    MyClass a;
 
    //打印一下数据
    a.Print();
    return 0;
}

运行结果:



可以看到,三个成员变量的值被初始化为0。这说明对象在创建时构造函数是自动调用的。接下来我们尝试给构造函数传参:

int main()
{
    //可以用类似函数调用的方式传参
    MyClass a(1, 2, 3);
 
    //也可以使用类似结构体初始化的方式传参
    MyClass b = { 4,5,6 };
 
    //打印一下数据
    a.Print();
    b.Print();
    return 0;
}

运行结果:



它还有以下三点特性:


5. 当我们在类中没有显示地定义构造函数时,编译器会自动生成一个无参的构造函数,用于创建对象时的初始化。一旦用户显示定义了构造函数之后,编译器则不会生成。

6. 显示定义的无参构造函数、全缺省构造函数,以及编译器自动生成的构造函数统称为默认构造函数。在一个类当中,这三种函数必须且只能存在一个。总的来说,不传参就可以调用的构造函数称之为默认构造函数。

7. 对于编译器自动生成的构造函数,当其对对象成员变量进行初始化时,如果成员是内置类型,则编译器通常不会为其赋初值;如果成员是由class或者struct创建的自定义类型(也就是类嵌套的情况),则会自动调用该自定义类型的默认构造函数。如果该成员没有默认构造函数,就会报错。这也就是默认构造函数必须存在的原因。


总结


       构造函数就是用于对创建的对象进行初始化的函数。我们在创建对象时,编译器会自动调用构造函数对成员变量进行初始化,这样我们就不需要单独定义或者使用Init函数对某个类进行初始化了


二、析构函数

       与构造函数相反,析构函数是在对象销毁时调用的,它的作用是在对象被销毁时完成对对象生成的资源的清理释放工作。就像我们在实现队列时使用的Destroy函数一样,完成对数据的销毁。


它的特点如下:


1. 析构函数的函数名是在类名之前加一个波浪号(~)。


2. 析构函数无返回值(void也不写),且不能加入参数。


3. 一个类当中只能有一个析构函数。


4. 当一个对象的生命周期结束之时,会自动调用析构函数。


5. 当我们没有在类中显示定义析构函数时,编译器会自动生成一个析构函数,供对象调用。


代码示例:

#include <iostream>
using namespace std;
 
class MyClass
{
public:
    //构造函数
    MyClass(int a = 0, int b = 0, int c = 0)
    {
        _a = a;
        _b = b;
        _c = c;
    }
 
    //析构函数
    ~MyClass()
    {
        _a = 0;
        _b = 0;
        _c = 0;
    }
 
    void Print()
    {
        cout << _a << endl;
        cout << _b << endl;
        cout << _c << endl;
    }
private:
    int _a;
    int _b;
    int _c;
};
 
int main()
{
    MyClass a(1, 2, 3);
    return 0;
}

调试观察:



可以看到,程序中我们创建对象时,给三个成员变量分别赋初值1、2、3,而当程序运行结束时,这三个成员变量的值已经变为了0,这说明对象销毁时确实自动调用了析构函数


6. 与构造函数类似,对于编译器自己生成的析构函数,当其对象被销毁时,内置类型成员变量通常不被处理;对于自定义类型成员变量,则会调用其析构函数。

7. 对于一个局部域中的多个对象在进行销毁时,c++规定后创建的对象先析构。


那么我们什么时候该显示写析构函数呢?来看一段代码:

class A
{
public:
    //...
private:
    int _a;
    char _c;
};
 
class B
{
public:
    B(int n = 4)//初始化时在堆区申请内存空间
    {
        _p = (int*)malloc(n * sizeof(int));
        if (_p == nullptr)
        {
            perror("malloc");
            exit(1);
        }
        _n = n;
    }
private:
    int* _p;
    int _n;
};

对于类A,他所创建的对象并没有申请额外的内存空间,在销毁时不会造成内存泄漏,此时我们就不需要手动写析构函数;对于类B,由于它在创建时在堆区申请了空间,它在销毁时编译器自己生成的析构函数并不会将这部分空间销毁掉,需要我们手动释放,所以此时就需要我们显示地写析构函数。


总的来说,如果类中没有申请资源,一般不需要手动写析构函数;如果申请了资源,就需要写析构函数,否则会造成内存泄漏。

三、拷贝构造函数

       拷贝构造函数是构造函数的一个重载,它用于完成对象的拷贝。它的特点如下:


1. c++规定对象只要发生拷贝行为,就必须调用拷贝构造,包括对象传参或者做返回值,都需要产生一份临时拷贝。

2. 拷贝构造函数的第一个参数必须是类类型的引用,而不是对象的值。因为对象在传值传参的时候需要调用拷贝构造,如果拷贝构造的参数带有对象的临时拷贝,那就会再次调用拷贝构造,以至于发生无限递归。

3. 如果我们没有显示定义拷贝构造函数,编译器会自动生成一个拷贝构造。这个自动生成的拷贝构造在完成拷贝工作时,对内置类型会完成它的浅拷贝,对类类型则会调用该类的拷贝构造函数。


接下来我们尝试写一个拷贝构造函数并且使用它:

#include <iostream>
using namespace std;
 
class MyClass
{
public:
    //构造函数
    MyClass(int a = 0, int b = 0, int c = 0)
    {
        _a = a;
        _b = b;
        _c = c;
    }
 
    //拷贝构造函数
    MyClass(const MyClass& m)//确保源数据不被修改,在引用之前加上const
    {
        //逐一完成成员变量的复制
        _a = m._a;
        _b = m._b;
        _c = m._c;
    }
 
    //析构函数
    ~MyClass()
    {
        _a = 0;
        _b = 0;
        _c = 0;
    }
 
    void Print()
    {
        cout << _a << endl;
        cout << _b << endl;
        cout << _c << endl;
    }
private:
    int _a;
    int _b;
    int _c;
};
 
int main()
{
    MyClass a1(1, 2, 3);//创建对象a1并对其初始化
    MyClass a2(a1);//调用拷贝构造,将a1拷贝给a2
 
    //打印一下a2
    a2.Print();
    return 0;
}

运行结果:



可以看到,我们通过拷贝构造函数将a1拷贝给了a2。


       那么我们什么时候需要显示写拷贝构造函数供我们使用呢?之前我们提到,编译器自动生成的拷贝构造完成的是浅拷贝。这就意味着如果我们在类中有向堆区申请内存空间的方法,浅拷贝就无法达到预期效果



所以对于这种情况(类中有额外申请资源),我们就需要手动去写一个拷贝构造函数,实现深拷贝将申请的内存也复制一份出来


小技巧:是否需要显示写拷贝构造函数,就看类中是否有显示写析构函数。如果有写析构函数,那么通常需要写拷贝构造。


        当我们在某个函数当中将对象作为返回值时,由于这个返回值是一份临时拷贝,所以会自动调用拷贝构造函数,造成运行效率的下降。所以此时我们可以考虑返回该对象的引用,避免发生拷贝,提高运行效率。需要注意的是:一定要确保该对象在函数栈帧销毁后仍然存在,避免出现悬挂引用。


四、赋值重载

       在了解赋值重载之前,我们先学习一个概念:运算符重载


1. 运算符重载

       所谓运算符重载,指的就是当对象在使用一些运算符时,我们可以为该运算符设定新的含义。而这种含义的实现方式就是通过定义函数,该函数就叫做运算符重载。


       当对象在使用运算符时,如果没有对应的运算符重载,就会发生报错。


       它的定义方式如下:


(返回值类型) operator(运算符)(函数参数)

{

       (函数体)

}


这里的operator是一个关键字,与需要定义的运算符相连接,构成函数名


关于运算符重载,有以下要注意的几点:


      1. 运算符重载的参数个数与该运算符的操作数一样多。例如 + 号进行重载时,第一个参数表示左操作数,第二个参数表示右操作数。如果这个运算符重载是成员函数,一定要注意成员函数第一个位置已经有一个参数是this指针,所以我们要少写一个参数。

       2. 当我们使用一个运算符重载时,要注意该运算符本来的优先级和结合性是不变的。

       3. 不能以“莫须有”的方式去重载本来就没有的运算符,例如operator@。

       4. 这五个运算符不能重载: .*    : :    sizeof    ? :    .  

       5. 我们在定义运算符重载时,必须要有类类型的参数,否则就会与重载的本意相悖。

       6. 对于++和--运算符的重载,由于前置和后置无法区分,所以c++规定:对于后置++/--,需要在函数的参数中增加一个哑元(通常是int类型),这个参数不在函数体中使用,但是有了这个参数就表示重载的是后置++/--。


小知识


第 4 点中有一个运算符 “ .* ”,有很多人可能没有接触过这个运算符,我们来介绍一下它。


首先让我们创建一个类,这个类当中只有一个成员函数:

class A
{
public:
    void fun()
    {
        cout << "Hello World" << endl;
    }
};

接下来,我们将该函数的地址存储在一个函数指针当中:

int main()
{
    void (A::*pf)() = &A::fun;
}

可以看到,以上代码非常奇怪。实际上,对于类的成员函数,我们在声明它的类型时,要表明它所在的类域。其次,对于类的成员函数,想要得到它的地址,需要加上&符号,而普通函数是否加&都表示它的地址。


接下来,我们创建一个A类对象,并通过该指针调用函数fun:

int main()
{
    void (A::*pf)() = &A::fun;
    A a;
    (a.*pf)();
}

运行结果:



可以看到,运行成功了。这里我们在调用函数时,就使用到了“ .* ”运算符,它用于通过函数指针调用类的成员函数。


接下来,我们针对MyClass类,尝试实现运算符重载:+ 。

#include <iostream>
using namespace std;
 
class MyClass
{
public:
    //构造函数
    MyClass(int a = 0, int b = 0, int c = 0)
    {
        _a = a;
        _b = b;
        _c = c;
    }
 
    //拷贝构造函数
    MyClass(const MyClass& m)
    {
        _a = m._a;
        _b = m._b;
        _c = m._c;
    }
 
    //析构函数
    ~MyClass()
    {
        _a = 0;
        _b = 0;
        _c = 0;
    }
 
    //+号重载
    //我们定义一个含义:对象加上一个整数,该对象的所有成员变量都加上这个整数
    MyClass operator+(int a)
    {
        MyClass tmp(*this);//将该对象的内容拷贝给临时变量tmp
        tmp._a += a;
        tmp._b += a;
        tmp._c += a;
        return tmp;//返回tmp的临时拷贝,表示的值就是加后的值,并且原对象未发生改变
    }
 
    void Print()
    {
        cout << _a << endl;
        cout << _b << endl;
        cout << _c << endl;
    }
private:
    int _a;
    int _b;
    int _c;
};

接着,我们来使用这个运算符重载:

int main()
{
    MyClass a;//创建对象a
 
    //将a与数字相加的值拷贝给其他对象
    MyClass b(a + 1);//可以直接使用运算符
    MyClass c(a.operator+(3));//也可以使用函数调用的方式
 
    b.Print();
    cout << endl;
    c.Print();
    return 0;
}

运行结果:



可以看到,运算符重载的编写成功了。注意:不管是用什么方式去使用运算符重载,本质都是函数调用


2. 赋值运算符重载

       了解了运算符重载的概念、特性、定义方法以及使用方法之后,我们切入正题--赋值重载。


       顾名思义,赋值重载就是对赋值运算符的重载函数,这个函数有点类似于拷贝构造,它的功能是完成已经存在的对象的拷贝赋值,这一点要和拷贝构造区分。


它的特点如下:


1. 赋值重载是运算符重载中的一种,必须重载为成员函数。一般情况下,它的参数和返回值都是当前类类型的引用,这样会减少拷贝提高效率。

2. 当我们没有显示写出赋值重载时,编译器会自动生成。自动生成的赋值重载会对内置类型成员变量完成浅拷贝,对于自定义类型成员变量,则会调用其赋值重载函数。

3. 与拷贝构造相同,如果我们的类中申请了资源,则需要自己显示写赋值重载来完成深拷贝;若没有申请资源,则可直接使用自动生成的赋值重载。


小技巧:是否需要显示写赋值重载函数,就看类中是否有显示写析构函数。如果有写析构函数,那么通常需要写赋值重载。


接下来我们针对MyClass类实现一个简单的赋值重载:

#include <iostream>
using namespace std;
 
class MyClass
{
public:
    //构造函数
    MyClass(int a = 0, int b = 0, int c = 0)
    {
        _a = a;
        _b = b;
        _c = c;
    }
 
    //拷贝构造函数
    MyClass(const MyClass& m)
    {
        _a = m._a;
        _b = m._b;
        _c = m._c;
    }
 
    //析构函数
    ~MyClass()
    {
        _a = 0;
        _b = 0;
        _c = 0;
    }
 
    //+号重载
    MyClass operator+(int a)
    {
        MyClass tmp(*this);
        tmp._a += a;
        tmp._b += a;
        tmp._c += a;
        return tmp;
    }
 
    //赋值重载
    MyClass& operator=(MyClass& src)
    {
        _a = src._a;
        _b = src._b;
        _c = src._c;
        return *this;//返回当前对象的引用可以完成连续赋值
    }
 
    void Print()
    {
        cout << _a << endl;
        cout << _b << endl;
        cout << _c << endl;
    }
private:
    int _a;
    int _b;
    int _c;
};
 
int main()
{
    MyClass a(1, 2, 3);
 
    MyClass b;
    MyClass c;
    c = b = a;
 
    b.Print();
    cout << endl;
    c.Print();
    return 0;
}

运行结果:



可以看到,我们成功将a的内容赋值给了b和c。


总结

       今天我们学习了四个类的默认成员函数以及它们的特点、使用方法:构造函数、析构函数、拷贝构造函数和赋值重载,它们能够确保资源的正确管理和对象状态的正确维护。之后博主会和大家分享其余的两个默认成员函数和其他知识。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤

相关文章
|
24天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
55 5
|
1月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
71 4
|
18天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
28 2
|
2月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
29 4
|
2月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
25 4
|
2月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
23 1
|
2月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
2月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)
|
2月前
|
存储 编译器 C语言
【C++类和对象(上)】—— 我与C++的不解之缘(三)
【C++类和对象(上)】—— 我与C++的不解之缘(三)
|
2月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
55 1