C++类和对象(中):默认函数 + 运算符重载 + 日期类实现完整笔记

这篇文章是我学习 C++ 类和对象的核心笔记。如果你觉得构造函数、拷贝构造、赋值重载这些概念很绕,说明你没有从"为什么需要它"这个角度去理解。我们从头捋一遍。


目录

一、什么是默认成员函数?

二、构造函数

[2.1 为什么需要构造函数?](#2.1 为什么需要构造函数?)

[2.2 构造函数的规则](#2.2 构造函数的规则)

[2.3 一个容易踩的坑](#2.3 一个容易踩的坑)

[2.4 什么叫"默认构造函数"?](#2.4 什么叫"默认构造函数"?)

[2.5 编译器自动生成的构造函数做了什么?](#2.5 编译器自动生成的构造函数做了什么?)

三、析构函数

[3.1 为什么需要析构函数?](#3.1 为什么需要析构函数?)

[3.2 析构函数的规则](#3.2 析构函数的规则)

[3.3 编译器自动生成的析构函数做了什么?](#3.3 编译器自动生成的析构函数做了什么?)

[3.4 析构的调用顺序](#3.4 析构的调用顺序)

四、拷贝构造函数

[4.1 为什么需要拷贝构造?](#4.1 为什么需要拷贝构造?)

[4.2 拷贝构造的语法](#4.2 拷贝构造的语法)

[4.3 为什么参数必须是引用?](#4.3 为什么参数必须是引用?)

[4.4 编译器生成的拷贝构造:浅拷贝](#4.4 编译器生成的拷贝构造:浅拷贝)

[4.5 解决方案:深拷贝](#4.5 解决方案:深拷贝)

[4.6 传值传参和传值返回都会调用拷贝构造](#4.6 传值传参和传值返回都会调用拷贝构造)

[4.7 一个判断是否需要写拷贝构造的小技巧](#4.7 一个判断是否需要写拷贝构造的小技巧)

五、赋值运算符重载

[5.1 运算符重载是什么?](#5.1 运算符重载是什么?)

[5.2 前置 ++ 和后置 ++ 怎么区分?](#5.2 前置 ++ 和后置 ++ 怎么区分?)

[5.3 << 和 >> 为什么要重载为全局函数?](#5.3 << 和 >> 为什么要重载为全局函数?)

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

[5.5 编译器默认生成的赋值运算符](#5.5 编译器默认生成的赋值运算符)

六、日期类完整实现

[6.1 头文件(接口声明)](#6.1 头文件(接口声明))

[6.2 实现文件(函数定义)](#6.2 实现文件(函数定义))

[6.3 日期类设计的几个思路](#6.3 日期类设计的几个思路)

[七、const 成员函数](#七、const 成员函数)

[7.1 为什么需要 const 成员函数?](#7.1 为什么需要 const 成员函数?)

[7.2 const 成员函数的使用规则](#7.2 const 成员函数的使用规则)

八、取地址运算符重载

九、面试高频考点汇总

Q1:不能重载的运算符有哪些?

Q2:构造函数能是虚函数吗?析构函数呢?

Q3:什么情况下必须自己写拷贝构造和赋值运算符?

[Q4:前置 ++ 和后置 ++ 哪个效率更高?](#Q4:前置 ++ 和后置 ++ 哪个效率更高?)

Q5:赋值运算符为什么要返回引用?为什么要检查自赋值?

[Q6:const 成员函数里能修改成员变量吗?](#Q6:const 成员函数里能修改成员变量吗?)

十、总结


一、什么是默认成员函数?

先问自己一个问题:你写了一个空类,里面什么都没写,这个类真的"空"吗?

复制代码
class Empty {};

不空。C++ 编译器会偷偷给你生成 6 个默认成员函数

  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 赋值运算符重载
  5. 取地址重载
  6. const 取地址重载

后两个基本用不上,最重要的是前四个。而且 C++11 以后还多了移动构造和移动赋值,这个后面再说。

学习这些默认函数,要从两个角度去想:

  • 编译器默认生成的行为是什么?能不能满足我的需求?
  • 如果不满足,我自己怎么写?

带着这两个问题,我们逐一来看。


二、构造函数

2.1 为什么需要构造函数?

在 C 语言里,你创建一个结构体之后,必须手动调用 Init 函数初始化,否则里面都是垃圾值。

复制代码
ST s;
STInit(&s);  // 忘了这行,后面就崩了

这件事C++觉得很烦,因为"忘记初始化"是一个极其常见的 bug。C++ 想解决这个问题,于是引入了构造函数:对象创建的那一刻,自动完成初始化,想忘都忘不了。

2.2 构造函数的规则

构造函数有几个语法规定,记住就行:

  • 函数名必须和类名相同
  • 没有返回值(连 void 都不写,C++ 就是这么规定的)
  • 对象实例化时系统自动调用
  • 可以重载(可以写多个)
cpp 复制代码
class Date
{
public:
    // 无参构造函数
    Date()
    {
        _year = 1;
        _month = 1;
        _day = 1;
    }

    // 带参构造函数
    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(2024, 7, 5);  // 调用带参构造
    return 0;
}

2.3 一个容易踩的坑

复制代码
Date d3();  // ⚠️ 这不是创建对象,这是函数声明!

编译器看到这行,认为你在声明一个叫 d3、无参数、返回值是 Date 的函数。用无参构造创建对象时,后面不能加括号。

2.4 什么叫"默认构造函数"?

很多人以为默认构造函数就是"编译器自动生成的那个",这是错的。

默认构造函数的定义是:不传实参就能调用的构造函数。 包含三种:

  1. 无参构造函数 Date() {}
  2. 全缺省构造函数 Date(int year=1, int month=1, int day=1) {}
  3. 编译器自动生成的构造函数

简单来说就是可以不传参数就可以调用(但是不是一定不传);

这三种有且只有一个能存在。无参和全缺省虽然构成函数重载,但调用时 Date d1; 编译器不知道该调用哪个,产生歧义,直接报错。

2.5 编译器自动生成的构造函数做了什么?

这里是很多人搞不清楚的地方。编译器生成的默认构造函数:

  • 内置类型 (int、double、指针等):不做任何处理,值是随机垃圾值(不是所有编译器都初始化内置类型)
  • 自定义类型 (class/struct 定义的类型):调用它的默认构造函数
cpp 复制代码
class MyQueue
{
public:
    // 什么都不写
    // 编译器自动生成的构造函数会去调用 Stack 的构造函数
    // pushst 和 popst 都会被正确初始化
private:
    Stack pushst;
    Stack popst;
};

int main()
{
    MyQueue mq;  // pushst 和 popst 都被自动初始化了
    return 0;
}

所以规律就是:如果类里面全是内置类型成员,编译器生成的构造函数大概率不够用,需要自己写。如果类里面包含自定义类型成员,编译器会帮你调用那个成员的构造函数。


三、析构函数

3.1 为什么需要析构函数?

和构造函数对应,析构函数解决的是另一个老问题:用完之后忘记释放资源

在 C 语言里你必须手动调 Destroy,而且每一个提前 return 的地方都得写,稍不注意就内存泄漏。C++祖师爷依旧看不惯:)

cpp 复制代码
bool isValid(const char* s)
{
    ST st;
    STInit(&st);

    // ...
    if (某个条件)
    {
        STDestroy(&st);  // 每个 return 前都要写
        return false;
    }

    // ...
    STDestroy(&st);  // 正常结束也要写
    return true;
}

C++ 的析构函数:对象生命周期结束时,自动调用,自动释放资源

3.2 析构函数的规则

  • 函数名是类名前加 ~
  • 无参数、无返回值
  • 一个类只能有一个析构函数(不能重载)
  • 对象生命周期结束时自动调用
cpp 复制代码
class Stack
{
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        _capacity = n;
        _top = 0;
    }

    ~Stack()
    {
        free(_a);       // 释放堆上的资源
        _a = nullptr;
        _top = _capacity = 0;
    }

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

3.3 编译器自动生成的析构函数做了什么?

和构造函数的规律一样:

  • 内置类型成员:不做处理
  • 自定义类型成员:调用它的析构函数
  • 对象销毁

    ├─ 调用析构函数
    │ │
    │ ├─ 调用成员对象析构
    │ └─ 内置类型 → 无操作

    └─ 内存回收

结论:

  • 类里面没有申请堆上资源(比如 Date 类),不需要写析构,编译器生成的够用
  • 类里面有自定义类型成员(比如 MyQueue 里有两个 Stack),编译器生成的析构会自动调用 Stack 的析构,也不需要自己写
  • 类里面有指针指向堆上资源(比如 Stack 里的 _a),必须自己写析构,否则内存泄漏

3.4 可是为什么指向资源必须自己写,编译器析构她的过程是怎样的?

指针本身不是资源,指针只是"地址变量"。

编译器不知道这个地址代表什么,所以 它不敢帮你释放资源

看一个最简单的例子
cpp 复制代码
class A
{
public:
int* p;

A()
{
p = new int(10);
}
};

对象A a;

内存结构是这样的:

复制代码

栈区

┌─────────┐

│ a │

│p 只是一个地值 ┼────────┐

└─────────┘ │

堆区 ▼

┌───────┐

│ 10 │

└───────┘

注意:p 只是一个地址

对象销毁时发生什么

a 销毁:

编译器默认析构函数

~A()

{

// 什么都不做

}

于是栈空间回收

变成:栈区(对象消失)

堆区

┌───────┐

│ 10 │ ← 还在

└───────┘

这就叫:

内存泄漏(memory leak)

因为:没有指针再指向这块堆内存

3.4 析构的调用顺序

同一个局部域内,后定义的对象先析构 ,类似的 LIFO 顺序。

cpp 复制代码
int main()
{
    Stack st1;  // 先构造
    Stack st2;  // 后构造
    return 0;
    // 先析构 st2,再析构 st1
}

四、拷贝构造函数

4.1 为什么需要拷贝构造?

有时候我们想用一个已有的对象去初始化另一个新对象:

复制代码
Date d1(2024, 7, 5);
Date d2 = d1;   // 希望 d2 是 d1 的一份拷贝
Date d3(d1);    // 同样是拷贝构造,两种写法等价

C++ 规定:自定义类型对象进行拷贝行为,必须调用拷贝构造函数。 这不只是上面这种显式拷贝,传值传参、传值返回都会触发拷贝构造。

4.2 拷贝构造的语法

拷贝构造是一种特殊的构造函数,第一个参数必须是自身类类型的引用

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

    // 拷贝构造函数
    Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

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

4.3 为什么参数必须是引用?

这是一个很有意思的推理过程,面试也常考。

假设拷贝构造写成值传递:

复制代码
Date(Date d)  // ❌ 编译直接报错

调用拷贝构造时,需要先把实参拷贝给形参 d。但是"把实参拷贝给形参"这个操作本身,就是一次拷贝行为,又要调用拷贝构造......然后又要拷贝实参给形参......无穷递归,直到栈溢出。

形象一些就是函数形成了自依赖,函数开始解决问题的前提是函数结果

所以参数必须是引用,引用不产生拷贝,直接绑定到原对象。

const是为了保证传入的对象不被修改。

4.4 编译器生成的拷贝构造:浅拷贝

如果你没有写拷贝构造,编译器会自动生成一个,行为是值拷贝(浅拷贝):把每个成员变量的值原样复制过去。

内置类型,和Date这种类,浅拷贝完全够用。他是按字节来拷贝;

但对于 Stack 这种类,浅拷贝会出大问题:

cpp 复制代码
Stack st1;
st1.Push(1);
st1.Push(2);

Stack st2 = st1;  // 浅拷贝

浅拷贝之后,st1._ast2._a 指向的是同一块堆内存

复制代码
st1._a ──────→ [ 1 | 2 | _ | _ ]
                        ↑
st2._a ──────────────── 同一块内存!

程序结束,st1 析构,free(_a),这块内存释放了。然后 st2 析构,又 free 同一个地址,

double free,程序崩溃

4.5 解决方案:深拷贝

cpp 复制代码
Stack(const Stack& st)
{
    // 重新申请一块同样大的内存
    _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;
}

深拷贝之后:

复制代码
st1._a ──────→ [ 1 | 2 | _ | _ ]   ← st1 独享这块内存
st2._a ──────→ [ 1 | 2 | _ | _ ]   ← st2 独享另一块内存

两个析构函数各自释放各自的内存,互不干扰。

4.6 传值传参和传值返回都会调用拷贝构造

cpp 复制代码
void Func1(Date d)   // 传值传参:d1 传进来时调用一次拷贝构造
{
    d.Print();
}

Date Func2()
{
    Date tmp(2024, 7, 5);
    return tmp;         // 传值返回:产生一个临时对象,调用一次拷贝构造
}

所以能用引用传参就用引用传参,能用引用返回就用引用返回,避免不必要的拷贝开销。

但引用返回有一个前提:返回的对象在函数结束后还活着 。如果返回的是局部变量的引用,函数结束局部变量销毁,引用就变成野引用了,相当于野指针:

cpp 复制代码
Date& Func2()
{
    Date tmp(2024, 7, 5);
    return tmp;   // ⚠️ 危险!tmp 函数结束就销毁了
                  // 返回的引用是野引用
}

4.7 一个判断是否需要写拷贝构造的小技巧

如果一个类显式写了析构函数并释放了资源,那它几乎一定也需要写拷贝构造。

反过来,如果析构函数用编译器默认生成的就够了,拷贝构造通常也不需要自己写。这两个经常成对出现。


五、赋值运算符重载

5.1 运算符重载是什么?

C++ 允许我们为类类型的对象重新定义运算符的含义。语法是用 operator 加上运算符名字作为函数名:

cpp 复制代码
bool operator==(const Date& d1, const Date& d2)
{
    return d1._year == d2._year
        && d1._month == d2._month
        && d1._day == d2._day;
}

然后**d1 == d2** 这个表达式,编译器会自动转换成 **operator==(d1, d2)**来调用。

有几个规则要记:

  • 不能创造新运算符,比如 operator@ 是非法的
  • 至少有一个参数是类类型,不能用重载改变内置类型的行为
  • . .* :: sizeof ?: 这五个运算符不能重载(面试选择题常考)

说简单的:

  • 为类类型定义这个运算符如何工作
  • 将运算符转换为函数调用,使用户自定义类型能够像内置类型一样参与运算。
cpp 复制代码
operator+(int a, int b)
{
return a - b;
}

两个都是 **内置类型,**编译器直接拒绝。当然也太反常识,没有现实意义;

如果重载为成员函数,this 指针占据第一个参数位置,所以参数比运算对象少一个:

C++规定运算符重载函数至少有一个操作数必须是用户自定义类型,从而防止程序员改变内置类型运算符的行为。

cpp 复制代码
class Date
{
public:
    // 成员函数版本:d1 == d2 → d1.operator==(d2)
    bool operator==(const Date& d)
    {
        return _year == d._year
            && _month == d._month
            && _day == d._day;
    }
};

5.2 前置 ++ 和后置 ++ 怎么区分?

两个都叫 operator++,怎么区分?C++ 规定:后置 ++ 多一个 int 形参,纯粹是为了和前置 ++ 构成重载,这个 int 的值没有实际意义:

cpp 复制代码
// 前置 ++:++d1 → d1.operator++()
Date& operator++()
{
    *this += 1;
    return *this;  // 返回加完之后的自己
}

// 后置 ++:d1++ → d1.operator++(0)
Date operator++(int)
{
    Date tmp(*this);  // 先保存加之前的状态
    *this += 1;
    return tmp;       // 返回加之前的状态
}

注意返回值的区别:

  • 前置 ++ 返回引用,效率更高(没有拷贝)
  • 后置 ++ 返回值(临时对象),因为要保存修改前的状态,不得不拷贝

所以优先用前置 ++,后置 ++ 有额外的拷贝开销

5.3 << 和 >> 为什么要重载为全局函数?

如果重载为成员函数:

cpp 复制代码
void operator<<(ostream& out)
{
    out << _year << "-" << _month << "-" << _day;
}

调用时变成 d1 << cout,因为 this 指针占了第一个参数,d1 就是左侧运算对象。这不符合使用习惯。

如果左边不是你的类,比如:cout << d

左边是 ostream,所以必须写 全局函数

所以要重载为全局函数,把 ostream 放第一个参数:

cpp 复制代码
ostream& operator<<(ostream& out, const Date& d)
{
    out << d._year << "年" << d._month << "月" << d._day << "日";
    return out;  // 返回 out 是为了支持链式调用 cout << d1 << d2
}
为什么返回 ostream&:因为要支持 连续输出

但这样全局函数访问不了 Date 的私有成员,解决方法是把这个函数声明为 Date友元函数

cpp 复制代码
class Date
{
    friend ostream& operator<<(ostream& out, const Date& d);  // 友元声明
    // ...
};

5.4 赋值运算符重载

赋值运算符重载是一个默认成员函数,必须重载为成员函数(不能是全局函数)。

注意区分赋值运算符重载和拷贝构造:

cpp 复制代码
Date d1(2024, 7, 5);
Date d2(d1);      // 拷贝构造:d2 是新创建的对象
Date d3 = d1;     // 拷贝构造!虽然用了 =,但 d3 是新创建的
                  // 不要被 = 迷惑

Date d4(2024, 8, 1);
d4 = d1;          // 赋值运算符重载:d4 已经存在,是两个已存在对象之间的赋值

记住:赋值运算符是两个已经存在的对象之间的拷贝赋值。拷贝构造是用已有对象初始化一个新对象。

赋值运算符重载的写法:

cpp 复制代码
Date& operator=(const Date& d)
{
    if (this != &d)  // 防止自己给自己赋值(d1 = d1 这种情况)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
    return *this;  // 返回 *this 是为了支持连续赋值 d1 = d2 = d3
}

返回引用而不是值,是为了减少一次拷贝,同时支持 d1 = d2 = d3 这样的链式赋值。

5.5 编译器默认生成的赋值运算符

和拷贝构造一样,默认生成的赋值运算符也是浅拷贝。所以:

  • Date 类:默认生成的够用,不需要自己写
  • Stack 类:需要自己写深拷贝版本
  • MyQueue 类:编译器自动调用 Stack 的赋值运算符,不需要自己写

同样的小技巧:如果显式写了析构函数并释放了资源,赋值运算符重载也要自己写。


六、日期类完整实现

理论说了这么多,来看一个完整的实战案例------日期类。它把上面所有的知识点都用上了。

6.1 头文件(接口声明)

cpp 复制代码
#pragma once
#include <iostream>
#include <assert.h>
using namespace std;

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 };

        // 闰年 2 月有 29 天
        if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
            return 29;
        else
            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;

    Date& operator+=(int day);
    Date  operator+(int day) const;
    Date& operator-=(int day);
    Date  operator-(int day) const;

    int operator-(const Date& d) const;  // 两个日期相差天数

    Date& operator++();    // 前置 ++
    Date  operator++(int); // 后置 ++
    Date& operator--();
    Date  operator--(int);

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

ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);

6.2 实现文件(函数定义)

cpp 复制代码
#include "Date.h"

// 检查日期是否合法
bool Date::CheckDate()
{
    if (_month < 1 || _month > 12
     || _day < 1   || _day > GetMonthDay(_year, _month))
        return false;
    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;
}

// 其他比较运算符复用 < 和 == 来实现,代码更简洁
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 += 天数(支持负数)
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;
}

// d1 + 天数(用 += 来实现,避免重复代码)
Date Date::operator+(int day) const
{
    Date tmp = *this;
    tmp += day;
    return tmp;
}

// d1 -= 天数
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;
}

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

// 后置 ++(先保存,再加,返回加之前的)
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;
}

// 两个日期相差多少天(计算两个日期之间的差值)
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.3 日期类设计的几个思路

思路一:用 += 实现 +,而不是反过来

很多人第一反应是先实现 +,再用 + 实现 +=。但这样反了:

cpp 复制代码
// 低效写法:用 + 实现 +=
Date& operator+=(int day)
{
    *this = *this + day;  // 这里产生了一个临时对象,多了一次拷贝
    return *this;
}

正确思路是:先实现 +=(直接修改自身,没有拷贝),再用 += 实现 ++ 需要产生新对象,拷贝不可避免,但不该在 += 里产生)。

思路二:比较运算符只实现 <==,其他都复用

cpp 复制代码
bool operator<=(const Date& d) const { return *this < d || *this == d; }
bool operator>(const Date& d) const  { return !(*this <= d); }
bool operator>=(const Date& d) const { return !(*this < d); }
bool operator!=(const Date& d) const { return !(*this == d); }

代码量减少一半,逻辑更清晰,修改也只需要改一处。

思路三:日期差值用"暴力加一"而不是数学计算

cpp 复制代码
int Date::operator-(const Date& d) const
{
    // 找出大的和小的,然后一天一天往前走,数步数
    while (min != max)
    {
        ++min;
        ++n;
    }
    return n * flag;
}

数学计算要考虑闰年、每月天数,逻辑复杂容易出错。"暴力加一"虽然性能差一点,但逻辑极其简单,而且利用了已经写好的 ++ 运算符,没有重复代码。这种思路在面试手撕代码的时候很实用。


七、const 成员函数

7.1 为什么需要 const 成员函数?

考虑这个场景:

复制代码
const Date d(2024, 7, 5);  // 常量对象,不允许修改
d.Print();  // ❓ 能调用吗?

Print 函数的 this 指针类型是 Date* const(指针本身不变,但可以通过指针修改对象)。而 dconst Date,它的地址类型是 const Date*

const Date* 传给 Date* const,权限放大了(本来只读,现在可写),编译器不允许,报错。

所以需要 const 成员函数,把 this 指针变成 const Date* const

cpp 复制代码
void Print() const  // const 放在参数列表后面
{
    cout << _year << "-" << _month << "-" << _day << endl;
    // 在这个函数里,不能修改任何成员变量
}

7.2 const 成员函数的使用规则

  • const 对象只能调用 const 成员函数
  • const 对象既能调用普通成员函数,也能调用 const 成员函数(权限缩小是允许的)
  • 不修改成员变量的函数,都应该加 const,这是一个好习惯;祖师爷怕有咱们写成 this=nullptr;
cpp 复制代码
const Date d1(2024, 7, 5);
d1.Print();      // ✅ const 对象调用 const 函数
// d1 += 100;   // ❌ const 对象不能调用非 const 函数

Date d2(2024, 7, 5);
d2.Print();      // ✅ 非 const 对象调用 const 函数(权限缩小,允许)
d2 += 100;       // ✅ 非 const 对象调用非 const 函数

八、取地址运算符重载

这个是六大默认成员函数里最不重要的,简单了解就行。

哈哈😄再说她可就要🤯爆炸ing(此时的"取地址运算符重载"默默的碎了/(ㄒoㄒ)/~~)

cpp 复制代码
class Date
{
public:
    Date* operator&()
    {
        return this;  // 返回对象地址
    }

    const Date* operator&() const
    {
        return this;
    }
};

编译器自动生成的版本就是上面这样,日常使用完全够了。

有一个特殊的使用场景:如果你不想让别人取到对象的地址,可以自己实现这个函数并返回 nullptr 或者乱七八糟的地址,把对方"骗"过去。

但实际开发里几乎用不到。可以说是合法但是不道德呃🤷‍♀️;


九、面试高频考点汇总

Q1:不能重载的运算符有哪些?

. .* :: sizeof ?: 这五个,背下来。

Q2:构造函数能是虚函数吗?析构函数呢?

构造函数不能是虚函数。析构函数可以是虚函数,而且在继承场景下,基类的析构函数通常都应该写成虚函数(这个后面学继承和多态时会深入讲)。

Q3:什么情况下必须自己写拷贝构造和赋值运算符?

类里面有指针成员指向堆上资源的时候。记住那个小技巧:显式写了析构 → 必须写拷贝构造 → 必须写赋值运算符重载。

Q4:前置 ++ 和后置 ++ 哪个效率更高?

前置 ++。后置 ++ 需要保存一份加之前的临时对象,多了一次构造和析构的开销。对于内置类型(int i++),编译器优化后差别不大;但对于自定义类型,优先用前置 ++。

Q5:赋值运算符为什么要返回引用?为什么要检查自赋值?

返回引用是为了:

① 减少一次拷贝的开销;② 支持 d1 = d2 = d3 这样的链式赋值。

检查自赋值是因为:如果 Stack 类里先 free(_a) 再申请新内存,自赋值时 _a 已经被释放,读取 d._top 等数据会访问野指针,程序崩溃。

Q6:const 成员函数里能修改成员变量吗?

不能,直接报错。但有一个特殊关键字 mutable,加了 mutable 的成员变量在 const 函数里也可以修改(用于统计函数调用次数等场景,实际开发中很少用)。


十、总结

把这篇文章的知识点用一张图梳理一下:

六大默认成员函数

├── 构造函数 → 初始化对象,替代 Init

│ └── 编译器默认:内置类型不处理,自定义类型调用其构造

├── 析构函数 → 释放资源,替代 Destroy

│ └── 编译器默认:内置类型不处理,自定义类型调用其析构

├── 拷贝构造 → 用已有对象初始化新对象

│ └── 编译器默认:浅拷贝(有指针成员时危险,需要深拷贝)

├── 赋值运算符 → 两个已存在对象之间赋值

│ └── 编译器默认:浅拷贝(同上)

├── 取地址重载 → 一般用编译器默认的就行

└── const 取地址重载 → 同上

当然还有日期类这个经典类的实现,下次我还会出专门一篇日期类,将我的易错点供大家参考

点赞收藏步迷了,大家一起进步,加油;

一句话记住核心规律:

如果类里有指针指向堆上的资源,析构、拷贝构造、赋值运算符这三个必须自己写。其他情况,交给编译器生成就好。


下一篇:初始化列表、static 成员变量、友元

相关推荐
Bat U2 小时前
JavaEE|多线程(一)
java·服务器·开发语言
逻辑驱动的ken2 小时前
Java高频面试考点场景题05
java·开发语言·深度学习·求职招聘·春招
SamDeepThinking2 小时前
秒杀系统需求PRD
java·后端·架构
小李子呢02112 小时前
前端八股Vue---插槽
前端·javascript·vue.js
一 乐2 小时前
咖啡商城|基于springboot + vue咖啡商城系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·咖啡商城系统
Royzst2 小时前
String方法
java·开发语言
学习使我健康2 小时前
Android 事件分发机制
android·java·前端
代码羊羊2 小时前
Rust基础类型与变量全解析
开发语言·后端·rust
小李子呢02112 小时前
前端八股Vue---自定义组件(控件)
前端·javascript·vue.js