【C++】类和对象(中)—— 构造函数 + 析构函数 + 赋值拷贝 + 运算符重载

简介: 构造函数 + 析构函数 + 赋值拷贝 + 运算符重载

@TOC

1. 类的默认六个成员函数

如果一个类中什么成员都没有,称为空类。空类中什么都没有吗?并不是的。任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数。这就是C++比较复杂的初始化机制。

class Date{}

<img src=" title="">

它们是特殊的成员函数,特殊的点非常多,后面一一展开。

:candy: 小边有话要说:对于下面介绍的默认成员函数。我们写就按照规则写,要写什么要心中有数。如果我们不写,编译器会自己生成一份,那它们有什么特征,也是这里比较复杂的地方,也要做到心中有数,也好决定我自己写不写。

2. 构造函数

2.1 构造函数概念

构造函数特殊的成员函数。注意,构造函数的虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象

在写数据结构时,我就吃过这样的亏,最后出了稀奇古怪的错误,调试然后发现忘记调用初始化函数了。忘记销毁了我好像还没有直观的感受,但我也看过别人有忘记释放资源把服务器搞挂了的故事。

:strawberry: 那么构造函数就是,对象定义出来就自动调用保证对象一定是被初始化的了。

2.2 构造函数特征

:strawberry: 特性 ——

  1. 函数名和类名相同
  2. 无返回值
  3. 对象实例化时,编译器自动调用对应的构造函数
  4. 构造函数可以重载

:snowflake:来看日期类 ——

class Date
{
public:
    //1.无参构造函数
    Date()
    {
        _year = 0;
        _month = 1;
        _day = 1;
    }
    //2.带参构造函数 - 初始化成指定值
    Date(int year, int month, int day)
    {
        _year = year;
        _month = month;
        _day = day;
    }

private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    //对象实例化时,自动调用
    Date d1;//调用无参构造函数
    Date d2(2022, 1, 17);
    return 0;
}

上面这两个构造函数构成了函数重载,其实他们也可以合并成一个函数,实现同样功能 —— 那就是通过全缺省【推荐like this

    Date(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }

注:无参构造函数 Date();和全缺省函数Date(int year = 2002, int month = 2, int day = 19);构成函数重载,语法上可以同时存在,但是,若有 无参调用Date d1; ,则有二义性会报错。

:snowflake:5. 但是如果在类中我们没有写构造函数,则C++编译器会自动生成一个无参的默认构造函数,(一旦用户显式定义编译器将不再生成)。d1对象调用了编译器生成的默认构造函数,但是d对象的_year /_month/_day,依旧是随机值。那么这个默认生成的构造函数干了什么?

在C++中把类型分为了两类 ——

  • 内置类型(基本类型)—— C语言原生带类型int/char/double/指针/内置类型的数组
  • 自定义类型 —— struct/class定义的类型

:strawberry: 我们啥也不写编译器会默认生成构造函数 ——

  • 对于内置类型的成员变量不做处理
  • 对于自定义类型的成员变量,会去调用它的默认构造函数(即不用传参就可以调)初始化

    注:如果没有构造函数,编译器就会报错。(比如我显式的写了一个带参的Date()

为此,写了一个自定义类型,来验证第二点 —— 对于自定义类型,会去调用它的默认构造函数

class A
{
public:
    A()
    {
        cout << "A()" << endl;
    }
private:
    int _a;
};

class Date
{
public:
    
private:
    int _year;
    int _month;
    int _day;

    A _aa;
};

int main()
{
    //对象实例化时,自动调用
    Date d1;
    return 0;
}

可以看到,确实是调了_aa的默认构造函数,打印了 ——

<img src=" title="">

再来解释一下 —— 所谓如果没有构造函数,编译器就会报错

如果我把上段代码中的class A 做一点修改,就报错了——

<img src=" title="">

在上面代码我们实例化d1时,在Date类中我们啥也没写,对于自定义类型变量_aa,会去调用它无参的默认构造函数。对于A这个类,我们没有写无参/全缺省的构造函数,然后还故意手欠写了一个带参的,那编译器也就没再生成。这就没有默认构造函数可调了,就报错咯。

:snowflake:6. 任何一个类的默认构造函数(不用参数就可以调用),有三个 —— 无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数。无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个(语法上他们可以同时存在,但是如果有对象定义去调用就会报错)。

3. 析构函数

3.1 析构函数概念

与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象的一些资源清理工作。

3.2 析构函数的特征

析构函数是特殊的成员函数。

:strawberry: 特征 ——

  1. 析构函数名是在类名前加上字符~
  2. 无参数无返回值。
  3. 一个类有且只有一个析构函数(无参数无法构成重载)。若未显式定义,系统会自动生成默认的析构函数。
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。

Date类为例,通过打印/调试都能看到在d1生命周期结束时,编译器自动调用了析构函数 ——

class Date
{
public:
    Date(int year = 2002, int month = 2, int day = 19)
    {
        _year = year;
        _month = month;
        _day = day;
    }
     
    ~Date()
    {
        cout << "~Date()" << endl;
    }
    
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    Date d1;
    return 0;
}

调试发现,这个析构函数好像什么都没有做。事实上,这个Date类也没有资源需要清理,不是所有的类都要析构函数。所以对于它不实现析构函数都是可以的。

那对于我们之前实现的栈这个类 ——

class Stack
{
public:
    //构造函数
    Stack(int capacity = 4)
    {
        _a = (int*)malloc(sizeof(int)* capacity);
        if (_a == nullptr)
        {
            cout << "malloc failed" << endl;
            exit(-1);
        }
        _top = 0;
        _capacity = capacity;
    }

    //析构函数
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _a;
    int _top;
    int _capacity;
};

int main()
{
    Stack st1;
    Stack st2(20);
    return 0;
}

这样就保证了,栈定义出来,就一定被初始化了;出作用域,在堆上申请的空间一定被回收了。就不会再忘记手动InitDestroy

注:析构顺序?st2先清理,st1后清理(调试可看)

  1. 如果我们不写,编译器自动生成的析构函数,会做一些什么呢?

:strawberry:与构造函数类似,它——

  • 对内置类型的成员变量不做处理
  • 对于自定义类型的成员变量会回去调它的析构函数

我们以用两个栈实现队列为例,(在这儿不谈题目思路,思路看我题解),主要看默认生成的作用 ——

class Stack
{
public:
    //构造函数
    Stack(int capacity = 4)
    {
        _a = (int*)malloc(sizeof(int)* capacity);
        if (_a == nullptr)
        {
            cout << "malloc failed" << endl;
            exit(-1);
        }
        _top = 0;
        _capacity = capacity;
    }

    //析构函数
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _a;
    int _top;
    int _capacity;
};

class MyQueue {
public:
    // 我们不需要写,构造函数和析构函数
    // 默认生成的很有用
    // 对于自定义类型,会自动调用它的默认构造函数和析构函数
    /*MyQueue() {} */
    
    void push(int x) {}
    int pop() {}
    int peek() {}
    bool empty() {}
    
private:
    Stack _pushST;
    Stack _popST;
};

int main()
{
    MyQueue mq;
    return 0;
}

而之前的C语言实现,哎,要手动调用 ——

MyQueue* myQueueCreate() {
    MyQueue* q = (MyQueue*)malloc(sizeof(MyQueue));
    StackInit(&q->pushST);
    StackInit(&q->popST);
    return q;
}

void myQueueFree(MyQueue* obj) {
    StackDestroy(&obj->pushST);
    StackDestroy(&obj->popST);
    free(obj);
}

4. 总结

上文描述了太多细节了,确实容易晕,在此汇总一下,就非常非常清晰了 ——

4.1 构造函数

<img src=" title="">

4.2 析构函数

<img src=" title="">

知识框架 ——

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K8T4lk5T-1642594487058)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220119181854832.png)]

5. 拷贝构造函数

5.1 拷贝构造函数概念

拷贝构造是用一个已存在的同类对象拷贝初始化一个即将创建的对象。这个概念要清晰,和本文6.2概念区分开。

5.2 拷贝构造函数特征

拷贝构造函数也是特殊的成员函数。

:strawberry:其特征如下:

  1. 拷贝构造函数是构造函数的一个重载形式。它的函数名就是类名,无返回值。
  2. 拷贝构造函数的参数只有一个必须使用引用传参,使用传值方式会引发无穷递归调用
class Date
{
public:
    Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

    //构造函数
    Date(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    Date d1(2002, 3, 7);
    Date d2(d1);
    return 0;
}

注:这里const Date& 常引用,最明显的原因是防止误写,当然还有很多原因,后续学习。

:strawberry:下面来解释一下,为什么拷贝构造函数的必须使用引用传参,因为使用传值方式会引发无穷递归调用

如果我们传值传参 ——

<img src=" title="">

<img src=" title="">

这里可能比较有疑惑的是,传值传参为什么是拷贝构造 ——

传值传参,就是把实参的值拷贝赋给形参,用同类型的来初始化你,其实就是一个拷贝构造。下面这段代码,调试可以观察到,先进入了拷贝构造函数,再进入了f(Date d)函数 ——

<img src=" title="">

但是引用传参,d就是d1的一个别名。

  1. 如果没有显式定义,系统生成默认的拷贝构造函数

:strawberry:这块儿和之前的构造函数和析构函数有点差别 ——

  • 内置类型成员,会完成字节序拷贝(浅拷贝)
  • 自定义类型成员,会去调用它的拷贝构造

我们来验证一下:

可以看到,编译器生成的默认构造函数,对于内置类型成员,确实完成了字节序的拷贝。也就是说像日期类这样的我们完全可以不写。

<img src=" title="">

而对于栈呢 ?我们还啥也不写 ——

class Stack
{
public:
    //构造函数
    Stack(int capacity = 4)
    {
        _a = (int*)malloc(sizeof(int)* capacity);
        if (_a == nullptr)
        {
            cout << "malloc failed" << endl;
            exit(-1);
        }
        _top = 0;
        _capacity = capacity;
    }

    //析构函数
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _a;
    int _top;
    int _capacity;
};

int main()
{
    Stack st1(10);
    Stack st2(st1);
}

就崩了:shit: ——

<img src=" title="">

这是因为 ——

<img src=" title="">

像这种类,就不能用默认的了,要我们自己实现。

对于自定义类型变量,确实会调用它的拷贝构造函数,我们可以验证 ——

class A
{
public:
    A(const A& a)
    {
        cout << "A(const A&)" << endl;
    }

    A()
    {

    }
};

class Date
{
public:
    //构造函数
    Date(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    
private:
    int _year;
    int _month;
    int _day;

    A _a;
};

int main()
{
    Date d1(2002, 3, 7);
    Date d2(d1);
    return 0;
}

<img src=" title="">

6. 赋值运算符重载函数

在默认情况下,C++是不支持自定义类型对象使用运算符,因为系统也不知道运算规则。

比如,对于我们的日期类

     Date d1(2022, 1, 19);
    Date d2(2022, 1, 31);

我想比较任意两个日期大小d1 < d2,想计算还有多少天过春节d2 - d1,都是没办法直接用运算符计算的。

为此,我们引入了运算符重载

6.1 运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数。

它的函数原型如下 ——

<img src=" title="">

注:

  • 不能通过连接其他符号来创建新的操作符:比如operator@
  • 重载操作符必须有一个自定义类型操作数
  • 用于内置类型的操作符,其含义不能改变。例如:内置的整型+,不能改变其含义
  • .* (很很少见)、::sizeof ? : (三目)、. 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。想想它们的意义,不能重载好像都是有道理的。

于是我们就实现了这样一个比较日期类大小的逻辑 ——

注:传参可以传值,但是在C++中建议传引用,这样可以减少拷贝提高效率。且,如果我们无需改变操作数,就用常引用const Date& (防止我一不小心改了形参,把实参也改了;而且const引用对于接收对象通吃,是权限的缩小/不变)

<img src=" title="">

因为我的成员变量是private私有的,在类外不能访问,所以都标红了。那我姑且先把访问修饰限定去掉。

围绕此,我们要探讨两个问题 ——

:strawberry: 1. d1 > d2;是怎样调用这个函数的?

:strawberry: 2. 成员变量私有在类外访问不了如何解决?

6.1.2 Q&A1

:strawberry: d1 > d2;是怎样调用这个函数的?

F11调试可见,确实是调用了。事实上,编译器会把d1 > d2转化为operator>(d1, d2),看看有没有重载。

但一般也不会有人去写这第二行的形式,运算符重载的初衷就是为了增强可读性,不然你这跟函数调用有什么区别。

<img src=" title="">

6.1.3 Q&A2

:strawberry: 成员变量私有在类外访问不了如何解决?

我们为了让程序运行简单粗暴去掉了访问修饰限定符private,这实际上破坏了封装性。

我们也可以在类中写上诸如int GetYear() {} 这样的函数,这不破坏封装性,但还是有些麻烦,也不常用。

那我们把它挪到类里面去!然而问题又来了 ——

<img src=" title="">

这是因为,成员函数默认多了一个this指针。那我们来改造它一下 ——

class Date
{
public:
    Date(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }

    //d1.operator>(d2);
    bool operator>(const Date& d)
    {
        if (_year > d._year)
        {
            return true;
        }
        else if (_year == d._year && _month > d._month)
        {
            return true;
        }
        else if (_year == d._year && _month == d._month && _day > d._day)
        {
            return true;
        }
        else
        {
            return false;
        }
    }
private:
    int _year;
    int _month;
    int _day;
};

于是d1 > d2;我们这样调用它时,实际上会被编译器处理成d1.operator>(d2); ——

<img src=" title="">

我们推荐写这种调用形式 ——

  d1.operator>(d2);

6.2 赋值运算符重载

6.2.1 赋值重载的概念

有了上文的铺垫,再来介绍赋值运算符重载,就轻松很多。

在6.1中,我们介绍了拷贝构造函数 —— 它是用一个已经存在的对象,拷贝初始化一个即将创建的对象。

    Date d1(2022, 1, 31);
    Date d2(d1); //拷贝构造

下面我们要介绍赋值重载,注意与上边的区别 —— 它是两个已经存在的对象,之间进行拷贝赋值。

    Date d1(2002, 3, 7);
    Date d2(2002, 2, 19);
    d1 = d2; //赋值重载

小思考 :这是拷贝构造还是赋值重载?

    Date d1(2002, 3, 7);
    Date d2 = d1;

根据定义嘛~ 这是拷贝构造

6.3.2 赋值重载的实现细节

有了上文6.1的关于运算符重载函数的知识铺垫,我们很容易就能写出这样的赋值重载函数 ——

class Date
{
public:
    Date(int year = 0, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    
    // 赋值重载
    //d1.operator=(d2);
    void operator=(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
private:
    int _year;
    int _month;
    int _day;
};


int main()
{
    Date d1(2022, 1, 19);
    Date d2(2022, 1, 31);

    d1 = d2;
    d2.operator=(d1);
    return 0;
}

但这样写是不完美的,主要有以下两点:

:strawberry: 1. 它是有返回值的

回想学习操作符时,有这样的连续赋值 ——

    int i, j, k;
    i = j = k = 10;//连续赋值,从左向右

<img src=" title="">

在C++中,我们也逃不了日期类这样连续赋值,我们需要返回值——

    d1 = d2 = d3;

那我们可以传值返回 ——

    // d1 = d2;  =>  d1.operator=(d2);
    Date operator=(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
        
        //即返回d1 -- 返回了调用这个函数隐含的操作数
        return *this; 
    }

传值返回可以吗?可以。

众所周知,传值返回会生成一个临时拷贝的对象(调试/打印可以看到,上文那样的连续赋值会调用两次拷贝构造),传引用可以减少拷贝(调试可以看到对比,我自己都验了哈)。并且这里出了作用域,d1*this)还在,可以传引用返回

:strawberry: 2. 自己给自己赋值,直接判断一下跳过

对于这种特殊情况,我们可以优化,如果地址一样,就不赋值了 ——

    Date& operator=(const Date& d)
    {
        if (this != &d)
        {
            _year = d._year;
            _month = d._month;
            _day = d._day;
        }
        return *this;
    }

这样就完美啦!

:strawberry:如果我们没写,编译器默认生成的赋值重载,跟拷贝构造做的事儿完全类似 ——

  • 对于内置类型成员,会完成字节序值拷贝 —— 浅拷贝。
  • 对于自定义类型成员,会调用它自己的operator=函数

在此不做赘述,看下边的小总结。

7. 小总结

7.1 拷贝构造函数

<img src=" title="">

7.2 赋值运算符重载

<img src=" title="">
本文完@边通书

相关文章
|
2天前
|
存储 编译器 C++
【C++】类和对象④(再谈构造函数:初始化列表,隐式类型转换,缺省值
C++中的隐式类型转换在变量赋值和函数调用中常见,如`double`转`int`。取引用时,须用`const`以防修改临时变量,如`const int& b = a;`。类可以有隐式单参构造,使`A aa2 = 1;`合法,但`explicit`关键字可阻止这种转换。C++11起,成员变量可设默认值,如`int _b1 = 1;`。博客探讨构造函数、初始化列表及编译器优化,关注更多C++特性。
|
2天前
|
编译器 C++
【C++】类和对象④(类的默认成员函数:取地址及const取地址重载 )
本文探讨了C++中类的成员函数,特别是取地址及const取地址操作符重载,通常无需重载,但展示了如何自定义以适应特定需求。接着讨论了构造函数的重要性,尤其是使用初始化列表来高效地初始化类的成员,包括对象成员、引用和const成员。初始化列表确保在对象创建时正确赋值,并遵循特定的执行顺序。
|
2天前
|
C++
【C++】日期类Date(详解)②
- `-=`通过复用`+=`实现,`Date operator-(int day)`则通过创建副本并调用`-=`。 - 前置`++`和后置`++`同样使用重载,类似地,前置`--`和后置`--`也复用了`+=`和`-=1`。 - 比较运算符重载如`&gt;`, `==`, `&lt;`, `&lt;=`, `!=`,通常只需实现两个,其他可通过复合逻辑得出。 - `Date`减`Date`返回天数,通过迭代较小日期直到与较大日期相等,记录步数和符号。 ``` 这是236个字符的摘要,符合240字符以内的要求,涵盖了日期类中运算符重载的主要实现。
|
5天前
|
存储 编译器 C语言
【C++航海王:追寻罗杰的编程之路】类与对象你学会了吗?(上)
【C++航海王:追寻罗杰的编程之路】类与对象你学会了吗?(上)
10 2
|
5天前
|
C++
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
7 0
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
|
5天前
|
存储 编译器 C++
【C++ 初阶路】--- 类和对象(下)
【C++ 初阶路】--- 类和对象(下)
7 1
|
5天前
|
存储 编译器 C语言
【C++初阶路】--- 类和对象(中)
【C++初阶路】--- 类和对象(中)
10 1
|
2天前
|
C语言 C++
【C++】日期类Date(详解)③
该文介绍了C++中直接相减法计算两个日期之间差值的方法,包括确定max和min、按年计算天数、日期矫正及计算差值。同时,文章讲解了const成员函数,用于不修改类成员的函数,并给出了`GetMonthDay`和`CheckDate`的const版本。此外,讨论了流插入和流提取的重载,需在类外部定义以符合内置类型输入输出习惯,并介绍了友元机制,允许非成员函数访问类的私有成员。全文旨在深化对运算符重载、const成员和流操作的理解。
|
2天前
|
定位技术 C语言 C++
C++】日期类Date(详解)①
这篇教程讲解了如何使用C++实现一个日期类`Date`,涵盖操作符重载、拷贝构造、赋值运算符及友元函数。类包含年、月、日私有成员,提供合法性检查、获取某月天数、日期加减运算、比较运算符等功能。示例代码包括`GetMonthDay`、`CheckDate`、构造函数、拷贝构造函数、赋值运算符和相关运算符重载的实现。
|
2天前
|
编译器 C++
【C++】类和对象③(类的默认成员函数:赋值运算符重载)
在C++中,运算符重载允许为用户定义的类型扩展运算符功能,但不能创建新运算符如`operator@`。重载的运算符必须至少有一个类类型参数,且不能改变内置类型运算符的含义。`.*::sizeof?`不可重载。赋值运算符`=`通常作为成员函数重载,确保封装性,如`Date`类的`operator==`。赋值运算符应返回引用并检查自我赋值。当未显式重载时,编译器提供默认实现,但这可能不足以处理资源管理。拷贝构造和赋值运算符在对象复制中有不同用途,需根据类需求定制实现。正确实现它们对避免数据错误和内存问题至关重要。接下来将探讨更多操作符重载和默认成员函数。