C++:(四)类和对象(中)—— 构造、析构与重载

目录

前言

一、类的默认成员函数

二、构造函数

三、析构函数

四、拷贝构造函数

五、赋值运算符重载

[5.1 运算符重载](#5.1 运算符重载)

[5.2 赋值运算符重载](#5.2 赋值运算符重载)

[5.3 日期类的实现](#5.3 日期类的实现)

六、取地址运算符重载

[6.1 const成员函数](#6.1 const成员函数)

[6.2 取地址运算符重载](#6.2 取地址运算符重载)

总结


前言

在面向对象的编程中,类和对象是构建复杂软件系统的核心机制。类作为对象的抽象模板,通过封装数据和行为定义了实体的属性和方法;而对象则是类的具体实例,承载运行时状态与操作。其中,构造函数与析构函数作为对象的生命周期管理工具,分别负责初始化资源与释放内存,确保程序的高效性与安全性。操作符重载进一步扩展了语言的表达力,允许开发者自定义类型的行为,使其与内置类型一样自然。深入理解这些机制,不仅能提升代码的健壮性与可维护性,还能为设计模式、框架开发等高级应用奠定基础。本文将从底层实现到实践案例,系统剖析构造、析构与重载的关键技术细节。下面就让我们正式开始吧!


一、类的默认成员函数

在 C++ 类与对象的体系中,默认成员函数 是一类特殊的成员函数:当用户未在类中显式定义这类函数时,编译器会根据特定规则自动生成,以此保障类的基本可用性。

在不同 C++ 标准下,默认成员函数的数量和功能有所差异,其中 C++98 标准下编译器会默认生成 6 个默认成员函数,但这 6 个函数的重要性并不相同,前 4 个是保证类功能正常运行的核心,后 2 个取地址重载函数实用性极低,仅需简单了解即可。这 4 个核心函数分别是构造函数、析构函数、拷贝构造函数和拷贝赋值运算符重载 ,构造函数负责对象初始化 ,为成员变量分配资源、设置初始值;析构函数负责对象销毁时的资源清理 ,比如释放动态分配的内存、关闭文件句柄等;拷贝构造函数用于用已存在的同类对象初始化新对象 ,默认实现是 "浅拷贝" ,即仅拷贝成员变量的数值 ;拷贝赋值运算符重载则用于两个已存在的同类对象之间的赋值操作 ,默认实现同样为 "浅拷贝"。而两个次要的取地址重载函数,分别是返回普通对象地址的Class* operator&() 和返回 const 对象地址的const Class* operator&() const,日常开发中极少用到。到了 C++11 标准,为解决 "浅拷贝" 在资源管理中的效率问题,又新增了两个与 "移动语义" 相关的默认成员函数 ------ 移动构造函数移动赋值运算符重载,移动构造函数能用 "即将销毁" 的对象(右值)初始化新对象,直接 "接管" 原对象的资源以避免内存拷贝,移动赋值运算符重载则是用 "即将销毁" 的对象(右值)为已存在的对象赋值,同样实现资源的 "转移" 而非 "拷贝",这两个函数的实现逻辑与使用场景会在后续博客中详细讲解。

默认成员函数既是类设计的基础,也是容易出现资源泄漏、浅拷贝等问题的关键,学习时需要围绕两个核心问题展开以形成完整的知识闭环。第一个核心问题是明确 "编译器默认生成的行为",要先掌握当我们不手动实现某类默认成员函数时,编译器自动生成的函数会执行什么操作,以及这些操作是否能满足当前类的需求。

比如对于仅包含intdouble等内置类型成员的 "简单类"(像存储学生姓名、年龄的Student类),编译器默认生成的构造函数、拷贝构造等函数能正常完成初始化、拷贝操作,这种情况下就无需手动实现;但对于包含动态内存(如char*指针指向堆区字符串)的 "复杂类"(如String类),编译器默认生成的拷贝构造函数是 "浅拷贝",只会拷贝指针地址而不是堆区的字符串内容,这会导致两个对象的指针指向同一块内存,析构时会触发 "double free"(即重复释放同一块内存)的错误,此时默认行为就无法满足需求。

第二个核心问题是掌握手动实现的方法,当编译器默认生成的函数无法满足需求时(比如上述String类的浅拷贝问题),就需要学会手动实现对应的默认成员函数,核心目标是解决 "默认行为的缺陷"。例如针对 "浅拷贝" 问题,手动实现拷贝构造函数和拷贝赋值运算符重载时,要采用 "深拷贝" 的方式,为新对象重新分配堆区内存,并将原对象的资源内容复制到新内存中,确保两个对象的资源相互独立;针对 "资源泄漏" 问题,手动实现析构函数时,要显式释放类中动态分配的资源(比如用**delete[]**释放char*指针指向的堆区内存),避免对象销毁后资源残留。

二、构造函数

构造函数是一类特殊的成员函数,尽管名称中带有 "构造" 二字,但其核心任务并非是开辟空间创建对象 ------ 就像我们常用的局部对象,其空间在栈帧创建时就已经分配完成,构造函数的真正作用是在对象实例化的过程中对其进行初始化操作。从功能本质上来说,构造函数其实是为了替代我们之前在 Stack、Date 等类中手动编写的 Init 函数,而它能够在对象创建时自动被调用的特性,恰好完美地实现了这种替代,省去了手动调用 Init 函数的步骤。

构造函数有如下的特点:

  1. 函数名与类名相同;

  2. 无返回值;(不需要给返回值,也不需要写void)

  3. 对象实例化时系统会自动调动对应的构造函数;

  4. 构造函数可以重载;

  5. 如果类中没有显式定义构造函数,那么C++编译器就会自动生成一个无参的默认构造函数,一旦用户显式定义,编译器将不再生成;

  6. 默认构造函数包含三种形式:无参构造函数、全缺省构造函数 ,以及当我们未显式定义构造函数时编译器自动生成的构造函数 。需要注意的是,这三种形式的默认构造函数不能同时存在于同一个类中,同一时间只能有其中一种形式存在。尽管无参构造函数和全缺省构造函数在函数重载的规则下可以共存,但在实际调用时会产生歧义,导致编译器无法确定应该调用哪一个,因此它们不能同时出现。很多人会误以为只有编译器默认生成的构造函数才是默认构造函数,这种理解并不准确,实际上只要是不需要传递实参就能被调用的构造函数,都属于默认构造函数,无参构造函数和全缺省构造函数同样符合这一特征。

  7. 当我们没有显式定义构造函数时,编译器会默认生成一个构造函数,这个默认生成的构造函数对于内置类型成员变量的初始化没有特殊的处理方式 ------ 它不做强制要求,也就是说这些成员变量是否会被初始化是不确定的,具体情况取决于编译器的实现。而对于自定义类型的成员变量,情况则不同,默认生成的构造函数会明确要求调用该成员变量自身的默认构造函数来完成初始化。如果这个自定义类型的成员变量没有可用的默认构造函数,那么编译过程就会报错。遇到这种情况需要初始化该成员变量时,就必须使用初始化列表来解决,关于初始化列表的具体内容,我们会在下个章节详细讲解。

需要注意的是,C++把类型分成了内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如:int/char/double/指针等,自定义类型就是我们使用class/struct等关键字自己定义的类型。

下面我为大家提供了一系列构造函数的示例代码:

cpp 复制代码
#include<iostream>
using namespace std;
class Date
{ 
public:
    // 1.⽆参构造函数
    Date()
    {
        _year = 1;
        _month = 1;
        _day = 1;
    } 

    // 2.带参构造函数
    Date(int year, int month, int day)
    {
        _year = year;
        _month = month;
        _day = day;
    } 

    // 3.全缺省构造函数
    /*Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }*/

    void Print()
    {
        cout << _year << "/" << _month << "/" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    // 如果留下三个构造中的第⼆个带参构造,第⼀个和第三个注释掉
    // 编译报错:error C2512: "Date": 没有合适的默认构造函数可⽤
    Date d1; // 调⽤默认构造函数
    Date d2(2025, 1, 1); // 调⽤带参的构造函数

    // 注意:如果通过⽆参构造函数创建对象时,对象后⾯不⽤跟括号,否则编译器⽆法
    // 区分这⾥是函数声明还是实例化对象
    // warning C4930: "Date d3(void)": 未调⽤原型函数(是否是有意⽤变量定义的?)
    Date d3();

    d1.Print();
    d2.Print();

    return 0;
}

下面我们再来尝试实现一下自定义的Stack的构造函数:

cpp 复制代码
#include<iostream>
using namespace std;

typedef int STDataType;
class Stack
{ 
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (nullptr == _a)
        {
            perror("malloc申请空间失败");
            return;
        } 

        _capacity = n;
        _top = 0;
    } 

    // ...
private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

// 两个Stack实现队列
class MyQueue
{ 
public:
    //编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造,完成了两个成员的初始化
private:
    Stack pushst;
    Stack popst;
};

int main()
{
    MyQueue mq;

    return 0;
}

三、析构函数

析构函数的功能与构造函数恰好相反,它并非负责销毁对象本身 ------ 比如局部对象存储在栈帧中,当函数执行结束时栈帧自然销毁,对象也就随之释放,这一过程无需手动干预。C++ 的规则是,对象在销毁时会自动调用析构函数,其核心作用是完成对象内部资源的清理与释放工作。从功能上看,析构函数类似于我们之前在 Stack 类中实现的 Destroy 函数;而像 Date 这类不涉及动态资源管理的类,由于没有需要手动释放的资源,严格来说并不需要显式定义析构函数。

析构函数具有如下的特点:

  1. 析构函数名是在类名前加上字符 '~'

  2. 无参数,无返回值;(这里和构造函数一样,不需要加上void)

  3. 一个类只能有一个析构函数。如果没有经过显式定义,系统就会自动生成默认的析构函数;

  4. 在对象的生命周期结束时,系统会自动调用析构函数;

  5. 和构造函数类似,我们不写的话,编译器自动生成的析构函数就会对内置类型成员不做处理,自定义类型成员就会调用他的析构函数;

  6. 还有需要注意的是,我们显式写析构函数,对于自定义类型成员而言也会调用他的析构,也就是说自定义类型成员不论什么情况都会自动调用析构函数

  7. 当类中没有申请需要手动管理 的资源时,析构函数可以不必显式定义,直接使用编译器生成的默认析构函数即可,比如Date类就是如此;如果默认生成的析构函数已经能够满足需求,同样不需要手动编写,像MyQueue类就是这种情况;但当类中存在资源申请时,就必须自己实现析构函数,否则会导致资源无法释放而造成泄漏,例如Stack类就属于这种情况;

  8. 对于一个局部域的多个对象,C++规定,后定义的先析构

在这儿我同样也提供一段析构函数的示例代码:

cpp 复制代码
#include<iostream>
using namespace std;

typedef int STDataType;
class Stack
{ 
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (nullptr == _a)
        {
            perror("malloc申请空间失败");
            return;
        } 

        _capacity = n;
        _top = 0;
    } 

    ~Stack()
    {
        cout << "~Stack()" << endl;
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }

private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

// 两个Stack实现队列
class MyQueue
{ 
public:
    //编译器默认⽣成MyQueue的析构函数调⽤了Stack的析构,释放的Stack内部的资源

    // 显⽰写析构,也会⾃动调⽤Stack的析构
    /*~MyQueue()
    {}*/
private:
    Stack pushst;
    Stack popst;
};

int main()
{
    Stack st;

    MyQueue mq;

    return 0;
}

对比一下用C++和C语言分别实现的Stack解决之前我们讨论过的括号匹配问题isValid,我们发现有了构造函数和析构函数确实方便了很多,不会再忘记调用Init和Destroy函数了,这样也方便了不少。如下所示:

cpp 复制代码
#include<iostream>
using namespace std;

// ⽤最新加了构造和析构的C++版本Stack实现
bool isValid(const char* s) {
    Stack st;

    while (*s)
    {
        if (*s == '[' || *s == '(' || *s == '{')
        {
            st.Push(*s);
        } 
        else
        {
            // 右括号⽐左括号多,数量匹配问题
            if (st.Empty())
            {
                return false;
            } 

            // 栈⾥⾯取左括号
            char top = st.Top();
            st.Pop();

            // 顺序不匹配
            if ((*s == ']' && top != '[')
                    || (*s == '}' && top != '{')
                    || (*s == ')' && top != '('))
            {
                return false;
            }
        } 

        ++s;
    } 

    // 栈为空,返回真,说明数量都匹配 左括号多,右括号少匹配问题
        return st.Empty();
} 

// ⽤之前C版本Stack实现
bool isValid(const char* s) {
        ST st;
        STInit(&st);
        while (*s)
        {
            // 左括号⼊栈
            if (*s == '(' || *s == '[' || *s == '{')
            {
                STPush(&st, *s);
            } 
            else // 右括号取栈顶左括号尝试匹配
            {
                if (STEmpty(&st))
                {
                    STDestroy(&st);
                    return false;
                } 

                char top = STTop(&st);
                STPop(&st);

                // 不匹配
                if ((top == '(' && *s != ')')
                        || (top == '{' && *s != '}')
                        || (top == '[' && *s != ']'))
                {
                    STDestroy(&st);
                    return false;
                }
            } 

            ++s;
        } 
        
        // 栈不为空,说明左括号⽐右括号多,数量不匹配
        bool ret = STEmpty(&st);
        STDestroy(&st);

        return ret;
} 

int main()
{
    cout << isValid("[()][]") << endl;
    cout << isValid("[(])[]") << endl;

    return 0;
}

四、拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用 ,且任何额外的参数都有其默认值 ,那么此构造函数也叫做拷贝构造函数,也就是说拷贝构造是一个特殊的构造函数

拷贝构造的特点:

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

  2. 拷贝构造函数的第一个参数必须是类类型对象的引用,若使用传值方式,编译器会直接报错,这是因为从语法逻辑上看,传值方式会引发无穷递归调用。拷贝构造函数也可以包含多个参数,但第一个参数必须是类类型对象的引用,而后面的参数则必须带有缺省值。

  3. C++ 有明确规定,当自定义类型的对象发生拷贝行为 时,必须调用拷贝构造函数来完成。基于这一规则,在自定义类型对象进行传值传参或传值返回的场景中,都会触发拷贝构造函数的调用,以此实现对象的拷贝过程。

  4. 如果没有显式定义拷贝构造函数,编译器会自动生成一个。这个自动生成的拷贝构造函数对于内置类型 的成员变量,会执行值拷贝(也称为浅拷贝) ,即按字节逐一复制 ;而对于自定义类型 的成员变量,则会调用该成员变量自身的拷贝构造函数来完成拷贝。

  5. 如下面的代码所示,对于像 Date 这样的类,其成员变量均为内置类型且不涉及任何资源指向 ,编译器自动生成的拷贝构造函数就能完成所需的拷贝操作,因此无需我们显式实现拷贝构造函数 。而像 Stack 这样的类,尽管其成员也都是内置类型,但其中的指针成员 (如_a)指向了实际资源 ,此时编译器自动生成的拷贝构造函数所完成的值拷贝(即浅拷贝)无法满足需求,这就需要我们自己实现深拷贝 ------ 对指针指向的资源也进行拷贝 。再比如 MyQueue 这类类型,其内部主要包含自定义类型的 Stack 成员,编译器自动生成的拷贝构造函数会调用 Stack 的拷贝构造函数,所以也不需要我们显式实现 MyQueue 的拷贝构造函数。这里有个实用的小技巧:如果一个类显式实现了析构函数来释放资源,那么它就需要显式编写拷贝构造函数,反之则不需要。

  6. 传值返回 时会产生一个临时对象 ,这个过程会调用拷贝构造函数;而传引用返回 时,返回的是返回对象的别名(引用) ,不会产生拷贝操作。不过需要注意,如果返回的对象是当前函数局部域中的局部对象,那么当函数结束时该对象就会被销毁,这时使用引用返回会出现问题,此时的引用就相当于一个野引用,和野指针类似。虽然传引用返回可以减少拷贝,但必须确保返回的对象在当前函数结束后仍然存在,才能使用引用返回。

Date类:

cpp 复制代码
#include<iostream>
using namespace std;

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

    // 编译报错:error C2652: "Date": ⾮法的复制
    //Date(Date d)
    Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    } 
    
    Date(Date* d)
    {
    _year = d->_year;
    _month = d->_month;
    _day = d->_day;
    } 
    
    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

void Func1(Date d)
{
    cout << &d << endl;
    d.Print();
} 

// Date Func2()
Date& Func2()
{
    Date tmp(2024, 7, 5);
    tmp.Print();

    return tmp;
} 

int main()
{
    Date d1(2024, 7, 5);
    // C++规定⾃定义类型对象进⾏拷⻉⾏为必须调⽤拷⻉构造,所以这⾥传值传参要调⽤拷⻉构造
    // 所以这⾥的d1传值传参给d要调⽤拷⻉构造完成拷⻉,传引⽤传参可以较少这⾥的拷⻉
    Func1(d1);

    cout << &d1 << endl;

    // 这⾥可以完成拷⻉,但是不是拷⻉构造,只是⼀个普通的构造
    Date d2(&d1);
    d1.Print();
    d2.Print();

    //这样写才是拷⻉构造,通过同类型的对象初始化构造,⽽不是指针
    Date d3(d1);
    d2.Print();

    // 也可以这样写,这⾥也是拷⻉构造
    Date d4 = d1;
    d2.Print();

    // Func2返回了⼀个局部对象tmp的引⽤作为返回值
    // Func2函数结束,tmp对象就销毁了,相当于了⼀个野引⽤
    Date ret = Func2();
    ret.Print();

    return 0;
}

Stack类:

cpp 复制代码
#include<iostream>
using namespace std;

typedef int STDataType;
class Stack
{ 
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (nullptr == _a)
        {
            perror("malloc申请空间失败");
            return;
        } 
        
        _capacity = n;
        _top = 0;
    } 

    Stack(const Stack& st)
    { 
        // 需要对_a指向资源创建同样⼤的资源再拷⻉值
        _a = (STDataType*)malloc(sizeof(STDataType) * st._capacity
        if (nullptr == _a)
        {
            perror("malloc申请空间失败!!!");
            return;
        } 
        
        memcpy(_a, st._a, sizeof(STDataType) * st._top);

        _top = st._top;
        _capacity = st._capacity;
    }

    void Push(STDataType x)
    {
        if (_top == _capacity)
        {
            int newcapacity = _capacity * 2;
            STDataType* tmp = (STDataType*)realloc(_a, newcapacity * sizeof(STDataType));
            if (tmp == NULL)
            {
                perror("realloc fail");
                return;
            } 

            _a = tmp;
            _capacity = newcapacity;
        } 

        _a[_top++] = x;
    } 

    ~Stack()
    {
        cout << "~Stack()" << endl;

        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }

private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

// 两个Stack实现队列
class MyQueue
{ 
public:
private:
    Stack pushst;
    Stack popst;
};

int main()
{
    Stack st1;
    st1.Push(1);
    st1.Push(2);
    // Stack不显⽰实现拷⻉构造,⽤⾃动⽣成的拷⻉构造完成浅拷⻉
    // 会导致st1和st2⾥⾯的_a指针指向同⼀块资源,析构时会析构两次,程序崩溃
    Stack st2 = st1;

    MyQueue mq1;
    // MyQueue⾃动⽣成的拷⻉构造,会⾃动调⽤Stack拷⻉构造完成pushst/popst
    // 的拷⻉,只要Stack拷⻉构造⾃⼰实现了深拷⻉,他就没问题
    MyQueue mq2 = mq1;

    return 0;
}

五、赋值运算符重载

5.1 运算符重载

当运算符作用于类类型的对象时,C++ 允许通过运算符重载来赋予其新的含义。按照 C++ 的规定,对类类型对象使用运算符时,必须转换为调用相应的运算符重载函数;如果不存在对应的运算符重载,程序将会出现编译错误。

cpp 复制代码
#include<iostream>
using namespace std;

// 编译报错:"operator +"必须⾄少有⼀个类类型的形参
int operator+(int x, int y)
{
    return x - y;
} 

class A
{ 
public:
    void func()
    {
        cout << "A::func()" << endl;
    }
};

typedef void(A::*PF)(); //成员函数指针类型

int main()
{
    // C++规定成员函数要加&才能取到函数指针
    PF pf = &A::func;

    A obj;//定义ob类对象temp

    // 对象调⽤成员函数指针时,使⽤.*运算符
    (obj.*pf)();

    return 0;
}

运算符重载是一种具有特殊名称的函数,其名称由**"operator"** 与后面要定义的运算符共同组成。和其他函数相同,运算符重载函数也拥有返回类型、参数列表以及函数体。

重载运算符函数的参数数量与该运算符所作用的运算对象数量相同:一元运算符有一个参数,二元运算符有两个参数。对于二元运算符而言,左侧运算对象对应第一个参数,右侧运算对象对应第二个参数。

如果一个重载运算符函数是成员函数,则其第一个运算对象就会默认传递给隐式的this指针,因此运算符重载作为成员函数的时候,参数是比运算对象少一个的。

cpp 复制代码
// 重载为全局的⾯临对象访问私有成员变量的问题
// 有⼏种⽅法可以解决:
// 1、成员放公有
// 2、Date提供getxxx函数
// 3、友元函数
// 4、重载为成员函数
bool operator==(const Date& d1, const Date& d2)
{
    return d1._year == d2._year
    && d1._month == d2._month
    && d1._day == d2._day;
} 

int main()
{
    Date d1(2024, 7, 5);
    Date d2(2024, 7, 6);

    // 运算符重载函数可以显⽰调⽤
    operator==(d1, d2);

    // 编译器会转换成 operator==(d1, d2);
    d1 == d2;

    return 0;
}

在运算符重载之后,其优先级和结合性与对应的内置类型运算符是保持一致的。

需要注意的是,我们不能通过连接语法中没有的符号来创建新的操作符,比如operator@。

.* :: sizeof ?: . 注意这五个运算符是不能重载的。这一点在选择题中经常考察。

重载运算符至少需要有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,比如:int operator+(int x, int y)

一个类需要重载哪些运算符,主要是要看哪些运算符在重载之后是有意义的,比如Date类重载operator-就是有意义的,但是重载operator+就没有意义。

在我们重载++运算符的时候,分为前置++和后置++,运算符重载函数名都是operator++,无法很好地区分。因此C++规定,在后置++重载时,需要增加一个int形参,跟前置++构成函数重载,便于区分。

重载<<和>>运算符的时候,需要将它们重载为全局函数,因为重载为全局函数的话,this指针就默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,在调用的时候就变成了对象<<cout,不符合我们的使用习惯以及可读性。重载为全局函数时只需要把ostream/istream放到第一个形参位置就可以了,第二个形参位置当类类型对象。

5.2 赋值运算符重载

赋值运算符重载是一个默认成员函数,用于完成两个已存在的对象直接的拷贝赋值 。这里需要注意和拷贝构造区分,拷贝构造用于一个对象拷贝初始化给另一个要创建的对象

赋值运算符重载有如下的特点:

  1. 赋值运算符重载属于运算符重载的一种,其规定必须重载为成员函数 。该重载的参数建议声明为 const 当前类类型引用,若采用传值传参则会产生拷贝操作
  2. 赋值运算符重载需要有返回值 ,且建议声明为当前类类型引用。引用返回可提升效率,而设置返回值的目的是为了支持连续赋值的场景。
  3. 当未显式实现赋值运算符重载时,编译器会自动生成一个默认版本。默认赋值运算符重载的行为与默认拷贝构造函数类似:对于内置类型 成员变量,会执行值拷贝 / 浅拷贝 (按字节逐一拷贝);对于自定义类型 成员变量,则会调用其自身的赋值重载函数
  4. 对于像 Date 这类成员变量均为内置类型且不涉及资源指向的类,编译器自动生成的赋值运算符重载即可完成所需的拷贝操作,因此无需显式实现。而像 Stack 这样的类,尽管其成员也都是内置类型,但由于存在指向资源的成员(如_a),编译器自动生成的赋值运算符重载所完成的值拷贝 / 浅拷贝无法满足需求,此时需要自行实现深拷贝(对指向的资源也进行拷贝)。对于 MyQueue 这类内部主要包含自定义类型成员(如 Stack)的类,编译器自动生成的赋值运算符重载会调用 Stack 的赋值运算符重载,因此也无需显式实现 MyQueue 的赋值运算符重载。这里有一个实用技巧:若一个类显式实现了析构函数并在其中释放资源,那么就需要显式编写赋值运算符重载;反之则无需。

在这我也提供了一段代码示例如下:

cpp 复制代码
class Date
{ 
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    } 

    Date(const Date& d)
    {
        cout << " Date(const Date& d)" << endl;

        _year = d._year;
        _month = d._month;
        _day = d._day;
    } 

    // 传引⽤返回减少拷⻉
    // d1 = d2;
    Date& operator=(const Date& d)
    {
        // 不要检查⾃⼰给⾃⼰赋值的情况
        if (this != &d)
        {
            _year = d._year;
            _month = d._month;
            _day = d._day;
        } 

        // d1 = d2表达式的返回对象应该为d1,也就是*this
        return *this;
    } 

    void Print()
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    Date d1(2024, 7, 5);
    Date d2(d1);

    Date d3(2024, 7, 6);
    d1 = d3;

    // 需要注意这⾥是拷⻉构造,不是赋值重载
    // 请牢牢记住赋值重载完成两个已经存在的对象直接的拷⻉赋值
    // ⽽拷⻉构造⽤于⼀个对象拷⻉初始化给另⼀个要创建的对象
    Date d4 = d1;

    return 0;
}

5.3 日期类的实现

  1. Date 日期类通过头文件(Date.h)和源文件(Date.cpp)分离实现,头文件包含类的声明,源文件实现类的成员函数与友元函数,同时包含<iostream>用于输入输出、<assert.h>用于参数校验,并使用std命名空间。
  2. 类的成员变量为私有访问权限,包含_year(年份)、_month(月份)、_day(日期)三个 int 类型变量,用于存储日期的核心信息。
  3. 类的友元函数声明包含ostream& operator<<(ostream& out, const Date& d)(流插入运算符重载)和istream& operator>>(istream& in, Date& d)(流提取运算符重载),使这两个函数能访问类的私有成员变量,用于日期的输出与输入。
  4. 类的构造函数声明为Date(int year = 1900, int month = 1, int day = 1),带有默认参数(默认日期为 1900 年 1 月 1 日),实现时会初始化_year_month_day,并调用CheckDate函数校验日期合法性,若日期非法则输出提示信息。
  5. 类的成员函数Print声明为void Print() const,实现时通过cout按 "年 - 月 - 日" 格式输出日期,且函数为 const 类型,确保调用时不修改对象状态。
  6. 类的成员函数GetMonthDay直接定义在类内(默认 inline,适合频繁调用),参数为int year(年份)和int month(月份),先通过assert断言确保月份在 1-12 之间,再通过静态数组monthDayArray存储非闰年各月天数,最后判断若为 2 月且年份是闰年则返回 29 天,否则返回数组中对应月份的天数,用于获取指定年月的天数。
  7. 类的成员函数CheckDate声明为bool CheckDate(),实现时判断_month是否在 1-12 之间、_day是否在 1 到当前年月的总天数之间,符合条件返回 true(日期合法),否则返回 false(日期非法)。
  8. 类实现了 6 个关系运算符重载,均为 const 成员函数(不修改对象状态):operator<(判断当前日期是否小于参数日期,先比年份、再比月份、最后比日期)、operator<=(判断当前日期是否小于等于参数日期,通过*this < d || *this == d实现)、operator>(判断当前日期是否大于参数日期,通过!(*this <= d)实现)、operator>=(判断当前日期是否大于等于参数日期,通过!(*this < d)实现)、operator==(判断当前日期与参数日期是否相等,需年份、月份、日期均相同)、operator!=(判断当前日期与参数日期是否不相等,通过!(*this == d)实现)。
  9. 类实现了 4 个与日期增减相关的运算符重载:operator+=(当前日期加上指定天数,若天数为负则调用operator-=处理,通过循环调整_day_month_year,确保日期合法,返回当前对象引用以支持连续操作)、operator+(当前日期加上指定天数,通过创建临时对象tmp并调用tmp += day实现,返回临时对象,不修改原对象)、operator-=(当前日期减去指定天数,若天数为负则调用operator+=处理,通过循环调整_day_month_year,确保日期合法,返回当前对象引用以支持连续操作)、operator-(当前日期减去指定天数,通过创建临时对象tmp并调用tmp -= day实现,返回临时对象,不修改原对象)。
  10. 类实现了 4 个自增自减运算符重载,区分前置与后置:前置operator++(当前日期加 1 天,调用*this += 1,返回当前对象引用,操作后返回更新后的对象)、后置operator++(参数为 int 类型以区分前置,先创建当前对象的临时副本tmp,再调用*this += 1更新原对象,返回临时副本,操作前返回原对象)、前置operator--(当前日期减 1 天,调用*this -= 1,返回当前对象引用,操作后返回更新后的对象)、后置operator--(参数为 int 类型以区分前置,先创建当前对象的临时副本tmp,再调用*this -= 1更新原对象,返回临时副本,操作前返回原对象)。
  11. 类的成员函数operator-(参数为const Date& d)实现两个日期相减的功能,先确定较大日期max和较小日期min,用flag标记结果正负(当前日期小于参数日期时flag为 - 1,否则为 1),再通过循环让min不断加 1 直到与max相等,统计循环次数n,最终返回n * flag,即两个日期相差的天数。
  12. 友元函数operator<<实现流插入功能,参数为ostream& out(输出流对象)和const Date& d(待输出日期对象),通过out按 "年⽉⽇" 格式输出日期,返回out以支持连续输出。
  13. 友元函数operator>>实现流提取功能,参数为istream& in(输入流对象)和Date& d(待输入日期对象),先提示用户依次输入年月日,再通过in读取输入值赋给d_year_month_day,最后调用CheckDate校验日期合法性,若非法则输出提示,返回in以支持连续输入。
  14. 头文件末尾需要声明ostream& operator<<(ostream& out, const Date& d)istream& operator>>(istream& in, Date& d)两个友元函数,与类内声明对应,确保源文件中函数实现的声明与类内友元声明一致。

代码如下所示:

cpp 复制代码
//Date.h
#pragma once
#include<iostream>
using namespace std;
#include<assert.h>
class Date
{
	// 友元函数声明
	friend ostream& operator<<(ostream& out, const Date& d);
	friend istream& operator>>(istream& in, Date& d);
public:
	Date(int year = 1900, int month = 1, int day = 1);
	void Print() const;
	// 直接定义类⾥⾯,他默认是inline
	// 频繁调⽤
	int GetMonthDay(int year, int month)
	{
		assert(month > 0 && month < 13);
		static int monthDayArray[13] = { -1, 31, 28, 31, 30, 31, 30,
		31, 31, 30, 31, 30, 31 };
		// 365天 5h +
		if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year
			% 400 == 0))
		{
			return 29;
		} e
			lse
		{
		return monthDayArray[month];
		}
	} 
	
	bool CheckDate();
	bool operator<(const Date& d) const;
	bool operator<=(const Date& d) const;
	bool operator>(const Date& d) const;
	bool operator>=(const Date& d) const;
	bool operator==(const Date& d) const;
	bool operator!=(const Date& d) const;
	// d1 += 天数
	Date& operator+=(int day);
	Date operator+(int day) const;
	// d1 -= 天数
	Date& operator-=(int day);
	Date operator-(int day) const;
	// d1 - d2
	int operator-(const Date& d) const;
	// ++d1 -> d1.operator++()
	Date& operator++();
	// d1++ -> d1.operator++(0)
	// 为了区分,构成重载,给后置++,强⾏增加了⼀个int形参
	// 这⾥不需要写形参名,因为接收值是多少不重要,也不需要⽤
	// 这个参数仅仅是为了跟前置++构成重载区分
	Date operator++(int);
	Date& operator--();
	Date operator--(int);
	// 流插⼊
	// 不建议,因为Date* this占据了⼀个参数位置,使⽤d<<cout不符合习惯
	//void operator<<(ostream& out);
private:
	int _year;
	int _month;
	int _day;
};

// 重载
ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);
// Date.cpp
#include"Date.h"
bool Date::CheckDate()
{
	if (_month < 1 || _month > 12
		|| _day < 1 || _day > GetMonthDay(_year, _month))
	{
		return false;
	} 
	else
	{
		return true;
	}
} 

Date::Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
		if (!CheckDate())
		{
			cout << "⽇期⾮法" << endl;
		}
	} 

void Date::Print() const
{
	cout << _year << "-" << _month << "-" << _day << endl;
}

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

// d1 <= d2
bool Date::operator<=(const Date & d) const
{
	return *this < d || *this == d;
} 

bool Date::operator>(const Date& d) const
{
	return !(*this <= d);
} 

bool Date::operator>=(const Date& d) const
{
	return !(*this < d);
} 

bool Date::operator==(const Date& d) const
{
	return _year == d._year
		&& _month == d._month
		&& _day == d._day;
}

bool Date::operator!=(const Date& d) const
{
	return !(*this == d);
} 

// d1 += 50
// d1 += -50
Date & Date::operator+=(int day)
{
	if (day < 0)
	{
		return *this -= -day;
	} 
	
	_day += day;
	
	while (_day > GetMonthDay(_year, _month))
	{
		_day -= GetMonthDay(_year, _month);
		++_month;
		if (_month == 13)
		{
			++_year;
			_month = 1;
		}
	} 
	return* this;
} 

Date Date::operator+(int day) const
{
	Date tmp = *this;
	tmp += day;
	return tmp;
} 

// d1 -= 100
Date & Date::operator-=(int day)
{
	if (day < 0)
	{
		return *this += -day;
	}
	_day -= day;
	while (_day <= 0)
	{
		--_month;
		if (_month == 0)
		{
			_month = 12;
			_year--;
		} 
		
		// 借上⼀个⽉的天数
			_day += GetMonthDay(_year, _month);
	} 
	return* this;
} 

Date Date::operator-(int day) const
{
	Date tmp = *this;
	tmp -= day;
	return tmp;
} 

//++d1
Date & Date::operator++()
{
	*this += 1;
	return *this;
} 

// d1++
Date Date::operator++(int)
{
	Date tmp(*this);
	*this += 1;
	return tmp;
} 

Date& Date::operator--()
{
	*this -= 1;
	return *this;
}

Date Date::operator--(int)
{
	Date tmp = *this;
	*this -= 1;
	return tmp;
} 

// d1 - d2
int Date::operator-(const Date & d) const
{
	Date max = *this;
	Date min = d;
	int flag = 1;
	if (*this < d)
	{
		max = d;
		min = *this;
		flag = -1;
	} 
	
	int n = 0;
	while (min != max)
	{
		++min;
		++n;
	} 
	return n* flag;
} 

ostream& operator<<(ostream& out, const Date& d)
{
	out << d._year << "年" << d._month << "⽉" << d._day << "⽇" << endl;
	return out;
} 

istream& operator>>(istream& in, Date& d)
{
	cout << "请依次输⼊年⽉⽇:>";
	in >> d._year >> d._month >> d._day;
	if (!d.CheckDate())
	{
		cout << "⽇期⾮法" << endl;
	}
	return in;
}

六、取地址运算符重载

6.1 const成员函数

用const修饰的成员函数称之为const成员函数,const修饰成员函数要放到成员函数参数列表的后面。

const实际修饰的是该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。const修饰Date类的Print成员函数,Print隐含的this指针由 Date* const this 变为 const Date* const this 。const成员函数的定义方法如下:

cpp 复制代码
#include<iostream>
using namespace std;
class Date
{
public :
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	} 
    
    // void Print(const Date* const this) const
	void Print() const
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	// 这⾥⾮const对象也可以调⽤const成员函数是⼀种权限的缩⼩
	Date d1(2024, 7, 5);
	d1.Print();
	const Date d2(2024, 8, 5);
	d2.Print();
	return 0;
}

6.2 取地址运算符重载

取地址运算符重载包括普通取地址运算符重载和 const 取地址运算符重载。通常情况下,编译器自动生成的这两个函数就能满足需求,无需显式实现。只有在一些特殊场景下,比如不希望他人获取当前类对象的地址时,才需要自行实现,此时可返回一个任意地址。如下所示:

cpp 复制代码
class Date
{
public :
	Date * operator&()
	{
		return this;
		// return nullptr;
	} 
	
	const Date* operator&()const
	{
		return this;
		// return nullptr;
	}
private:
	int _year; // 年
	int _month; // ⽉
	int _day; // ⽇
}

总结

以上就是我们本期博客的全部内容了,我在这篇博客中为大家介绍了析构函数、构造函数、函数重载等类和对象中的重点知识,希望能对大家有所帮助。咱们下期再见!

相关推荐
MediaTea3 小时前
Python 编辑器:IDLE
开发语言·python·编辑器
Madison-No73 小时前
【C++】string类的常见接口的使用
开发语言·c++·算法
政沅同学3 小时前
C#系统日志
开发语言·c#
一只雄牧慕3 小时前
【C++】哈希表
开发语言·数据结构·c++·散列表
cici158744 小时前
在Ubuntu18.04安装兼容JDK 8的Eclipse集成开发环境
java·开发语言·eclipse
不枯石4 小时前
Matlab通过GUI实现点云的统计滤波(附最简版)
开发语言·图像处理·算法·计算机视觉·matlab
代码村新手4 小时前
C语言-操作符
开发语言·c++
老赵的博客4 小时前
c++ 之多态虚函数表
java·jvm·c++
liu****4 小时前
负载均衡式的在线OJ项目编写(四)
运维·c++·负载均衡·个人开发