上一篇我们认识了类,知道怎么定义成员变量和成员函数,也知道 this 指针在背后帮我们区分"是谁在调用我"。但你可能已经隐隐察觉到一个问题:我定义一个 Date 对象,它的 _year、_month、_day 到底是谁在初始化?我定义一个持有 malloc 申请的动态内存的 Stack,这块内存要不要自己手动释放?万一对象在传参、返回的过程中被"复制"了,复制后两块内存会不会打架?

这些问题的答案,都藏在 C++ 对象一生的"生死大戏"里。这一篇我们就把镜头对准它:一个人从呱呱坠地到寿终正寝是怎么发生的,对象被复制一份又意味着什么。

不过在这之前,我想先给你一颗定心丸——这一篇是 C++ 入门阶段"最长、最绕、最容易劝退"的一篇,但同时也是价值最高的一篇。学完它,你再看后面 string、vector 这些标准库容器的源码,会感觉像读老朋友的信一样亲切。因为标准库里那些大名鼎鼎的类,底层干的事无非就是构造、析构、拷贝构造、赋值重载这四件套的排列组合。所以哪怕这一篇文字很多,也请你咬牙看完——每一个字都值一个 bug。准备好了吗?我们开始。

类的默认成员函数

先记住一个叫默认成员函数的概念:所谓默认成员函数,就是你没有显式写,编译器会在背后偷偷帮你生成的那些成员函数。

一个类里如果我们一样东西都不写,编译器默认会生成这 6 个默认成员函数:

  • 默认构造函数
  • 默认析构函数
  • 默认拷贝构造函数
  • 默认赋值运算符重载
  • 默认普通取地址运算符重载
  • 默认 const 取地址运算符重载

前 4 个是重头戏,我们这一篇逐个搞定;最后两个取地址重载不重要,这一篇末尾带大家看一眼即可。

另外多说一句,到了 C++11 之后,编译器还会再补两个默认成员函数——移动构造和移动赋值,它们和"移动语义"有关,是更高阶的话题,你只需知道它们存在,更深处我们放到后面介绍 C++11 新特性时再展开。(顺带一提:标准库容器最重要的一条纪律"三法则 / 五法则"——只要你手写了析构、拷贝构造、赋值中的任何一个,几乎就必须把另外几个也补全——其源头正是这 8 个默认成员函数之间的联动关系。这条纪律我们现在先不去硬背,等明确了编译器"够不够用"的判断标准之后,它自然就水到渠成。)

学习默认成员函数,永远只有这两个问题:

  1. 我不写,编译器帮我写的那个,行为是怎么样的?够不够我用?
  2. 如果编译器生成的不够用,我该怎么自己写?

这一篇的所有内容,本质上都是在回答这两个问题。你可以把这两个问题像"咒语"一样贯穿始终:每遇到一个默认成员函数,就先问——编译器它自己会怎么干?我这回需不需要自己动手?

编译器到底会不会真的"生成"出来?

这是很多初学者卡住的第一个疑点:我代码里明明一个字都没写,它上哪生出去?答案是:编译器在编译你的 .cpp 时,如果发现某个默认成员函数"被需要",就会在幕后给你把对应的代码补出来。比如你写了一行 Date d1;,编译器发现 Date 需要构造,就自动生成一段无参构造的指令;你写了 Date d2 = d1;,它就自动生成一段拷贝构造的指令。

你可以用一个小手段"看"到它的存在:写一个类,然后主动去触发对某个默认成员函数的赋值,把生成的代码反过来调出来。比如:

class A
{
public:
    A()  { cout << "构造" << endl; }
    ~A() { cout << "析构" << endl; }
};
 
int main()
{
    A a;          // 触发默认构造
    A a2 = a;     // 触发默认拷贝构造
    a = a2;       // 触发默认赋值重载
    return 0;
}

这里我们没有定义拷贝构造和赋值重载,但它们被使用了,编译器就"现场补课"造了出来。所以"默认"二字,指的就是"编译器按一份设定好的规则自动给出实现",而不是"这个函数不存在"。记住这个设定,后面理解"浅拷贝是默认拷贝构造的行为"就不会懵了。

构造函数

构造函数是一个特殊的成员函数。这里要立刻澄清一个容易误会的点:名字叫"构造",但它的主要任务并不是开空间创建对象。你平时用的局部对象,空间在栈帧被创建的那一刻就已经开好了,跟构造函数没半点关系。构造函数真正的职责是——在对象刚实例化出来之后,把成员变量初始化为有意义的值。

底层到底是怎么回事?我们把镜头拉到底层看一眼:局部对象住在栈上。当你进入一个函数、执行到 Date d1; 这一行时,编译器会先在栈帧里给 d1 划出一块足够放 int*3 的空间——这一步在进入函数时就已经预留好了,跟构造函数无关。真正发生的是:空间划好之后,紧接着调用 Date::Date() 这段代码,往那三个格子 _year/_month/_day 里填上初始值。打个比方,构造函数就像一个开房入住流程——房间(栈上空间)早在你进门之前就已建好了,前台做的只是把住客信息(成员值)登记进去。

换句话说,构造函数就是在替代我们以前在 C 里写的 Init 之类的初始化函数。区别在于,Init 你得记得手动调用、还得记得传入地址;而构造函数是对象创建时系统自动调用的,你根本不需要管,忘不了也不会忘。

构造函数有这么几个硬性特点,逐条说:

  1. 函数名与类名完全相同,编译器靠这个认出"这是构造函数"。对编译器而言,这相当于一个"暗号":看到 Date::Date(...),就知道这不是普通成员函数,而是造对象的入口。
  2. 没有返回值。什么都不用写,连 void 都不用写。别纠结为什么,C++ 就是这么规定的。你甚至可以连着读:如果函数名和类名相同、又没有返回值,那它就是构造函数——这是一条硬性的识别规则。
  3. 对象实例化时自动调用。这正是它能替代 Init 的根本原因。你不用记着调,编译器帮你在"对象出生"的那一瞬间自动调。
  4. 可以重载。你可以同时提供多种参数形式的构造,比如 Date() 和 Date(int y, int m, int d),调用时靠实参去匹配。
  5. 如果你没显式定义任何构造函数,编译器会自动生成一个无参的默认构造函数;但只要你写了任意一个构造,编译器那个默认的就不再生成了。这就是"只要你点了一份带肉的菜,店家就不管送配菜了"的逻辑——你一旦接手了对象的初始化工作,剩下的事都归你管,编译器不再替你把关。
  6. 这里有个很容易绕晕的概念,必须讲透——什么叫默认构造函数?很多同学以为"默认构造函数"特指编译器生成的那个无参构造,其实不是。无参构造、全缺省构造、编译器自动生成的那个构造,只要"不给实参就能调用"的,统统都叫默认构造函数。 而且这三者同时只能存在一个,不能共存。为什么?因为无参构造和全缺省构造虽然构成函数重载(参数个数不同),但你写 Date d; 调用时编译器分不清你到底想调哪个,存在歧义,直接报错。所以它们之间不是"你死我活",而是"有你没我":同一种"能不传参就调用"的构造,只能留一个。
  7. 编译器默认生成的构造,对内置类型成员变量(比如 int、char、指针这类语言自带的原生类型)的初始化"没有要求"——也就是动不动初始化是随机的、看编译器心情的;但对自定义类型成员变量(我们这个类内部又包的其它类对象,比如 MyQueue 里的 Stack),要求调用这个成员变量的默认构造函数去初始化它。如果那个成员类型根本没有默认构造函数,就直接报错,这时就要靠初始化列表来解决——初始化列表我们这一篇末尾专门开一节铺垫。

第 7 条里那个"内置类型动不动初始化是随机的"特别值得再抠一下。它的准确含义是:标准并没有强制编译器给内置类型成员一个默认值。所以 Visual Studio 的 Release 下它可能就是一块没动过的栈上垃圾,而 Debug 下经常被填充成 0xCCCCCCCC(一种用来"提醒你会崩"的调试填充值)。无论哪种,都不可依赖。但 C++ 有一个很贴心的补偿:从 C++11 开始,你可以在声明成员时就给它一个缺省值,这叫"类内默认成员初始化器"。有了它,就算编译器生成的构造也会用这个值去初始化,彻底治好了"随机值"这个顽疾:

#include <iostream>
using namespace std;
 
class Date
{
public:
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year = 1900;   // C++11 起支持类内默认初始化
    int _month = 1;
    int _day = 1;
};
 
int main()
{
    Date d;    // 没有任何构造,编译器生成默认构造,会带上类内缺省值
    d.Print(); // 输出:1900-1-1
    return 0;
}

这一招在你需要"默认值 + 不必每次写构造"的场景特别好用。但注意:这是给"编译器生成的默认构造"兜底用的;一旦你写了构造函数,类内缺省值会作为初始化列表的"后备军"——下面讲初始化列表时你会再见到它。

先别被第 7 条吓到,我们先看一个构造函数的完整例子,把前 6 条落实:

#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;
    }
 
    void Print()
    {
        cout << _year << "/" << _month << "/" << _day << endl;
    }
 
private:
    int _year;
    int _month;
    int _day;
};
 
int main()
{
    Date d1;              // 调用无参构造函数,结果 _year=1,_month=1,_day=1
    Date d2(2025, 4, 18); // 调用带参构造函数,结果 _year=2025,_month=4,_day=18
    d1.Print();
    d2.Print();
 
    // 这里埋个坑:如果你写 Date d3(); 编译器会把这行当成
    // "声明一个名叫 d3、返回值是 Date、参数为空的函数",而不是定义对象!
    // 因此通过无参构造创建对象时,对象名后面千万不要跟括号。
    return 0;
}

你看,Date d1; 一旦执行,系统立刻自动调用无参构造把三个成员设成 1;Date d2(2025,4,18); 则自动调用带参构造。全程没有一个 Date d1; Init(&d1, ...) 式的繁琐调用。

在正式进入下一篇之前,有一条格式上的坑请务必记牢:用无参构造定义对象时,对象名后面不要写括号,否则 Date d3(); 会被当成函数声明,编译器甚至会给你抛一个 warning C4930: Date d3(void) 未调用原型函数 的警告,看起来像"声明了天花板上的函数",实际上根本没创建对象。这是初学者的保留节目。

这个现象有一个专门的名字,叫 most vexing parse(最令人苦恼的解析)——因为它在源码里同时成立"函数声明"和"对象定义"两种解释,而 C++ 语法规定优先把它当作函数声明。所以 Date d3(); 这行,d3 是一个"返回 Date、不带参数的函数"的名字,而不是对象。这也是为什么所有见多识广的 C++ 程序员,都会规规矩矩地写 Date d3;(不带括号)。另外这也解释了为什么 Stack st(4); 这样带实参的写法完全没问题——带实参时绝不可能是函数声明(函数声明里写实参没有意义,最多是带类型名 C++11 之前的历史糟粕)。

默认构造函数与自定义类型成员

前面第 7 条提过:编译器默认生成的构造,会给自定义类型成员调用它们的默认构造。这句话光看文字太抽象,用 Stack 组合成 MyQueue 的场景一下子就能看明白:

#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;
 
typedef int STDataType;
 
class Stack
{
public:
    // 全缺省构造函数:默认值 n=4,既满足"不给实参能调用",又能按需传参
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (_a == NULL)
        {
            perror("malloc 申请空间失败");
            return;
        }
        _capacity = n;
        _top = 0;
    }
 
    ~Stack()
    {
        cout << "~Stack()" << endl;
        free(_a);
        _a = NULL;
        _top = _capacity = 0;
    }
 
private:
    STDataType* _a;   // 指向动态申请的内存
    size_t _capacity; // 容量
    size_t _top;      // 元素个数
};
 
// 用两个 Stack 组合出一个 MyQueue,我这里故意什么都不写
class MyQueue
{
public:
    // 这一行注释上方是一片空白:我没有写任何构造、析构
private:
    Stack _pushst; // 自定义类型成员,用来入队
    Stack _popst;  // 自定义类型成员,用来出队
};
 
int main()
{
    Stack st;  // 调用 Stack 的全缺省构造,_a 指向申请好的 4 个 int 的空间
    MyQueue mq; // 关键在这里:编译器生成的 MyQueue 构造,会自动去调 _pushst 和 _popst 的构造
    return 0;
}

注意 main 里那行 MyQueue mq;:我压根没给 MyQueue 写构造,但编译器自动生成的构造,会主动去调用两个 Stack 成员的构造,把 _pushst 和 _popst 内部的 _a 都申请好内存。这就是"编译器默认构造会初始化自定义类型成员"的直观体现。什么都要自己手动初始化、手动 Init,在这里完全不需要了。

这一瞬间你能感受到组合的力量:MyQueue 一个函数都不用写,它的两个 Stack 成员自己把自己养得明明白白。往下读你会看到,这个"我不用写,成员自己负责"的组合思想,从构造一路贯穿到拷贝构造、赋值、析构——这是整个 OOP(面向对象编程)里最优雅的设计之一:一个对象要知道怎么管理自己的资源,作为"成员"被装进别的类时,别的类几乎可以甩手不管。

析构函数

对象出生有人管了,那"死亡"呢?这就是析构函数的职责。它与构造函数正好相反:构造函数是在对象诞生时初始化,析构函数是在对象销毁时清理资源。

这里又有一个常见误解要澄清:析构函数并不是真的去销毁对象本身。比如局部对象活在栈帧里,函数调用结束栈帧销毁,对象的内存自然就还给系统了,这事不归析构函数管。析构函数真正负责的是——清理对象内部申请出来的那些"资源",比如 malloc 出来的堆内存、打开的文件句柄、网络连接等等。没这些资源要清理的类,严格来说根本不需要析构函数。

用 Date 来体会对比最直观:Date 的三个成员就是三个 int,它们活栈上,函数结束整个栈帧回收,三个 int 自动消失,没有任何"别的资源"需要释放——所以 Date 不需要析构函数,用编译器默认的即可。而 Stack 的 _a 是一块 malloc 出来的堆内存,栈帧回收不会去动堆,如果没人 free,它就永远躺在那——所以 Stack 必须写析构,把这块堆内存还回去。

析构函数的特点,同样逐条过:

  1. 名字是类名前加一个 ~,比如 ~Stack。这个符号是个"死亡信号",一眼就看出是析构。
  2. 无参数、无返回值。连 void 也不用写,这一点和构造一样。
  3. 一个类只能有一个析构函数。参数都不能有,自然没法重载,所以只能有一个;没写,系统自动生成一个默认的。
  4. 对象生命周期结束时自动调用。跟构造一样,不用你手动 call,编译器在对象"死到临头"的那一刻自动调。
  5. 和构造类似:编译器默认生成的析构,对内置类型成员啥也不干;对自定义类型成员,会调用它的析构。所以 MyQueue 的默认析构会自动去 free 掉两个 Stack 的堆内存。
  6. 这里补一条关键特性:哪怕你自己显式写了析构函数,自定义类型成员的析构也照样会被自动调用。 换句话说,自定义类型成员的析构,无论什么情况都会自动执行,你不用担心"我写了析构是不是就抢了成员的活"。编译器设计成这样是刻意的:组合的时候,外层类的析构只管"自己申请的资源",成员的资源永远由成员自己善后,两者不冲突、不抢活。
  7. 那到底什么时候需要自己写析构?一句话原则:类里申请了资源(malloc/new/打开文件等),就必须自己写析构去释放;没申请资源的(比如 Date),直接用编译器默认的即可。 而像 MyQueue 这种内部成员都是 Stack 的,编译器默认析构会自动帮它调每个 Stack 的析构,也已经够用了。
  8. 还有个和构造相反的"生死时序"要记住:同一个局部作用域里定义了多个对象,后定义的对象先析构。 为什么?因为局部对象都在栈上,栈是"后进先出"的——你先定义的先进栈、活得久,后定义的后进栈、先出栈。这跟一摞盘子一个道理,先放的在下、后放的在上,拿走的时候必是先拿上面的。

关于第 8 条,还有一位"隐藏玩家"值得你认识,否则将来仍会栽跟头:成员变量的析构顺序,和它在类里声明的顺序正好相反。 构造按照"声明顺序"把成员一个个造出来,析构时则按"逆序"一个个拆掉(后声明的先析构、先声明的后析构)。这背后的理由跟第 8 条一样——成员构造时按声明顺推,就好比逐个压栈;析构自然要"后进先出"。这一点在你以后设计"成员之间互相引用"的类时极其重要:比如一个成员持有另一个成员的引用,若被持有的成员先析构了,另一个成员再用它就会踩到悬空。遇到这种类型,第一反应就该想到"声明顺序和析构逆序"这 12 个字。

前 6 条和栈对象生命周期这些内容,用一个直观代码验证一遍最清楚:

#include <iostream>
using namespace std;
 
class A
{
public:
    A()  { cout << "A() 构造" << endl; }
    ~A() { cout << "~A() 析构" << endl; }
};
 
class B
{
public:
    B()  { cout << "B() 构造" << endl; }
    ~B() { cout << "~B() 析构" << endl; }
};
 
int main()
{
    A a;   // main 中先定义 a
    B b;   // 后定义 b
    cout << "main 函数即将结束" << endl;
    return 0;
    // 最终输出顺序:
    // A() 构造
    // B() 构造
    // main 函数即将结束
    // ~B() 析构   <- 后定义的先析构
    // ~A() 析构   <- 先定义的后析构
}

看到输出顺序了吗?构造是 A 先 B 后,析构却颠倒过来是 B 先 A 后。这就是"后进先出",也是为什么你能在析构里头先打印 ~Stack()。将来排查崩溃问题,如果发现某个资源被提前释放了,往"定义顺序"和"析构顺序"这两个方向想,往往一击即中。

再补充一个初学者提问率最高的场景:数组里的对象什么时候析构? 答案是一条同样简单的逆序——A arr[3]; 构造顺序是 arr[0]、arr[1]、arr[2],而结束时会先析构 arr[2]、再 arr[1]、最后 arr[0]。规则和"后进先出"一脉相承,掌握同一条规律即可推导,无需单独死记。

构造与析构跑到一起,能省掉多少事

现在我们手里有了自动调用的构造和析构,回过头对比一下以前用 C 实现的 Stack 你就会发现,C++ 这版省心得离谱。

以前用 C 写栈,你得严格记住"用时先 STInit,用完必须 STDestroy",哪怕中途 return 提前退出,也得小心翼翼在每个出口补一次 STDestroy,漏一个就是内存泄漏。而且这纯粹靠程序员自觉——忘了 Init 拿到的却是随机值,忘了 Destroy 白白泄漏内存,编译器一句都不提醒你。

而现在用 C++ 的 Stack 去解决"括号匹配"这类问题,你只需要:

#include <iostream>
using namespace std;
 
typedef int STDataType;
 
class Stack
{
public:
    // 构造:对象一出场内存就申请好了,不用手动 Init
    Stack(int n = 4)
    {
        _a = new STDataType[n];
        if (_a == nullptr)
        {
            cout << "new 失败" << endl;
            return;
        }
        _capacity = n;
        _top = 0;
    }
 
    void Push(STDataType x)
    {
        // 简化处理:满了就直接扩容一倍
        if (_top == _capacity)
        {
            int newCap = _capacity * 2;
            STDataType* tmp = new STDataType[newCap];
            for (size_t i = 0; i < _top; ++i)
            {
                tmp[i] = _a[i];   // 把旧数据搬过去
            }
            delete[] _a;          // 释放旧的
            _a = tmp;
            _capacity = newCap;
        }
        _a[_top++] = x;
    }
 
    bool Empty() const { return _top == 0; }
    STDataType Top() const { return _a[_top - 1]; }
    void Pop() { if (_top > 0) --_top; }
 
    // 析构:对象一死亡资源自动释放,不用手动 Destroy
    ~Stack()
    {
        delete[] _a;
        _a = nullptr;
        _top = _capacity = 0;
    }
 
private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};
 
// 检查括号是否匹配的经典问题
bool isValid(const char* s)
{
    Stack st;              // 自动构造,无需 STInit
    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(); // 都配对完还剩余左括号,说明左括号多了
}
 
int main()
{
    cout << isValid("({[]})") << endl; // 期望输出 1,表示匹配
    cout << isValid("([)]") << endl;   // 期望输出 0,表示不匹配
    return 0;
}

注意 isValid 里那几个 return false;——哪怕函数中途返回,Stack st 的析构也会自动触发,把 _a 释放干净。放在以前用 C 写,每个出口都得补一句 STDestroy,漏了内存就漏了。这就是构造函数和析构函数在一起的威力:你只管用,资源它自己管。 这也是为什么大家常说,学 C++ 面向对象,本质是在跟"对象的生命周期"打交道。

请你细品那句"中途 return 也自动析构"的含金量。在 C 版本里,isValid 有 return false 的三个出口、加上最后的 return true,一共四个出口,你要在三个 return false 之前逐个手动补 STDestroy(&st)——贪图省事漏掉一个,就是内存泄漏。而 C++ 版本一个都不用补,因为局部对象 st 的生命周期绑定在函数作用域上,无论从哪个出口离开函数,析构都会触发。这就是"RAII(资源获取即初始化)"思想的雏形:把资源的生命周期和对象的生命周期绑定,对象死,资源跟着清。 这个思想是 C++ 有别于 C、也区别于多数其它语言的精髓,你现在先在心里种下这颗种子,后面会反复遇到它开花。

拷贝构造

时间来到一个更烫手的点——拷贝构造(全称拷贝构造函数)。想象一下:我要用已经存在的对象 d1 去创建一个新的 d2,让 d2 复制 d1 的值。这种"对象复制"行为,C++ 规定必须通过一个叫拷贝构造函数的东西来完成。

先给定义:如果一个构造函数的第一个参数是自身类类型的引用,且其它任何参数都有默认值,那么这个构造函数就是拷贝构造函数。 说白了,拷贝构造是构造函数里很特殊的一个"重载版本",它的任务是"拿一个已有的同类对象来初始化一个新的对象"。

拷贝构造的特点,逐条拆开:

  1. 它是构造函数的一个重载——所以函数名也跟类名相同,只是参数形态特殊。
  2. 第一个参数必须是类类型对象的引用。如果你写成传值的拷贝构造(比如 Date(Date d)),编译器直接报错 error C2652。为什么?因为你传值给 d 的过程中,又得先做一次"对象复制"、又得调用拷贝构造,而这次调用又要传值、又要做一次复制……无限套娃,最终必然 递归 到栈溢出。编译器聪明地直接在语法层面拒绝了这种写法。拷贝构造也可以带更多参数,但第一个必须是引用,其余参数必须有缺省值。这里也顺带解释了一个看似矛盾的现象:Date(const Date& d) 里那个 const 不是可有可无的装饰,而是惯例——拷贝时我们绝不应该改动源对象,加上 const 既能让 const 对象也能被拷贝,又能在编译器层面保证你不会手滑改掉源对象。
  3. C++ 规定:自定义类型对象的一切拷贝行为都必须调用拷贝构造。 这句话是精髓。它意味着——传值传参要调拷贝构造,传值返回也要调拷贝构造。只要你把对象"传值"进函数或"传值"返回,背后就自动多了一次拷贝构造的调用。
  4. 你没写时编译器会自动生成一个拷贝构造。自动生成的拷贝构造,对内置类型成员做值拷贝(也叫浅拷贝,就是一个字节一个字节地复制),对自定义类型成员调用它自己的拷贝构造。
  5. 到这里就能推出一个实用结论:像 Date 这种成员全是内置类型、也没有指针指向外部资源的类,编译器自动生成的拷贝构造就完全够用,你不用自己写;像 Stack 这种虽然成员也全是内置类型,但 _a 这个指针指向了堆上资源,"一字节一字节复制"(浅拷贝)会把两个对象的指针指向同一块内存,这就不符合需求了,需要你自己写深拷贝(详见下一节);像 MyQueue 这种内部成员主要都是 Stack 的,编译器自动生成的拷贝构造会去调用每个 Stack 的拷贝构造,只要 Stack 的深拷贝写对了,MyQueue 也不用写。这里有个检查小技巧:如果一个类显式写了析构去释放资源,十有八九也得显式写拷贝构造;如果连析构都不需要写,那拷贝构造通常也不用写。
  6. 关于返回值再深挖一点:传值返回时,会先构造一个临时对象、再调一次拷贝构造把它拷给调用方接收;而传引用返回,返回的是返回对象的别名,不产生拷贝。但如果你返回的是函数内部的局部对象,函数一结束它就销毁了,这时的引用就成了"野引用"(类似野指针),那么用引用返回就有大问题。一句话原则:只有确保返回对象在函数结束后还活着(比如返回的是静态对象、成员变量、全局对象),才适合用引用返回。

第 3 条"一切拷贝行为都必须调用拷贝构造"值得用一个真实例子烙进脑子里。请想象函数 void Func1(Date d) 里,传入形参 d 的那一刻,就是 d1 被"复制"成 d 的过程——它调的就是拷贝构造。这跟 C 语言是完全不同的心智模型:C 里传对象(比如传结构体)就是靠函数调用机制顺带的纯字节拷贝,你根本察觉不到;而 C++ 里,传参复制被上升到了"必然呼叫某个构造函数"的高度,你甚至可以在这个函数里拦截、改造每次复制。这就是为什么面向对象程序员常说"C++ 里的一切拷贝都是显式语义"。

先看一个 Date 自动生成拷贝构造就能满足的例子,顺便把传值传参也验证了:

#include <iostream>
using namespace std;
 
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
 
    // 拷贝构造:参数是 const 类类型引用
    // 若写成 Date(Date d) 会报错:传值会引发自身的无限递归拷贝,导致栈溢出
    Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
 
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
 
private:
    int _year;
    int _month;
    int _day;
};
 
void Func1(Date d) // 传值传参:形参 d 要调拷贝构造来初始化
{
    d.Print();
}
 
int main()
{
    Date d1(2024, 7, 5);
 
    Date d2(d1);    // 写法一:显式调用拷贝构造
    Date d3 = d1;   // 写法二:也是拷贝构造(注意!这行是"拷贝初始化",不是赋值)
 
    Func1(d1);      // 传值传参,隐式调用拷贝构造
 
    d1.Print();
    d2.Print();
    d3.Print();
    return 0;
}

请你特别留意 Date d3 = d1; 这一行——虽然看起来像"赋值",但它前面还有一个 Date 类型在创建对象,所以本质上是用 d1 复制初始化新对象 d3,走的是拷贝构造,而不是赋值。这一句是初学 C++ 最容易栽的跟头,下一节讲赋值时我们还会再对比一次。

这里我要多说一句你将来一定会遇到的问题:在讲拷贝构造的最佳实践时,编译器的两个优化会反复"抢戏",如果你没有心理准备,极易被绕晕。一是 RVO / copy elision(返回值优化 / 拷贝省略):从 C++11 起编译器被允许(C++17 更是强制保证大部分场景)在"按值返回局部对象"时直接省掉中间那次拷贝构造。二是真正的语义也从"拷贝"升级成了"移动"(后面讲移动语义时再展开)。所以你在很多现代编译器上测试"传值返回到底调了几次拷贝构造"会发现次数比理论上少——这是编译器在帮你偷懒优化,不代表"拷贝构造不参与"这种概念是错的。概念上,掌握"传值返回会出事、要调用拷贝"为先;优化只作为附录了解即可。

再看一个危险的例子——返回局部对象不要用引用返回:

#include <iostream>
using namespace std;
 
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;
    }
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
 
// 反例:返回局部对象的引用
Date& Func2()
{
    Date tmp(2024, 7, 5); // tmp 是这里的局部对象
    tmp.Print();
    return tmp;           // 危险!函数结束 tmp 就销毁了,返回的是"野引用"
}
 
int main()
{
    Date ret = Func2(); // 拿到一个已经死亡的对象的别名
    ret.Print();        // 行为未定义,可能读到垃圾数据
    return 0;
}

上文里的注释已经把道理说明白了:tmp 是 Func2 的局部对象,函数一返回它必然销毁,此时把它的引用传出去,ret 就握着一个"已死之人"的别名——叫它野引用。轻则打出一堆随机数,重则程序崩溃。引用返回减少拷贝是真的好,但你必须赌准返回对象活得够久。

这里再给一个"何时能用引用返回"的正例,让你形成正确的对照直觉:如果返回的是成员变量或全局静态对象,因为它们在函数结束后仍活着,用引用返回就安全且高效。比如一个 Stack 类里返回 _top 个元素的临时这种不行,但返回某个"长期住在对象内部"的东西就可以。我们下面那个完整日期类里的 operator++ 返回 *this,正是因为 *this 这个对象在调用者的作用域里仍然存活着,所以返回它的引用天经地义——同样道理,operator<< 返回 ostream& 引用返回 out,也是因为 out 是调用者传进来的、仍活着。这跟你看到的"局部 tmp 不能引用返回"形成了一组正反对照,值得反复品味。

深拷贝与浅拷贝

这是全篇最重要的一段,我们把浅拷贝和深拷贝彻底掰开揉碎。

先说浅拷贝。编译器自动生成的拷贝构造,对内置类型成员就是"一字节一字节复制",这叫浅拷贝。对 Date 这种没问题——复制完每个 int 各归各的。但对 Stack 就出大事了:

#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;
 
typedef int STDataType;
 
class Stack
{
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (_a == NULL)          // 记得加括号,NULL 是宏值 0
        {
            perror("malloc fail");
            return;
        }
        _capacity = n;
        _top = 0;
    }
 
    void Push(STDataType x)
    {
        if (_top == _capacity)   // 满了就扩到 2 倍
        {
            int newCap = _capacity * 2;
            STDataType* tmp = (STDataType*)realloc(_a, newCap * sizeof(STDataType));
            if (tmp == NULL)
            {
                perror("realloc fail");
                return;
            }
            _a = tmp;
            _capacity = newCap;
        }
        _a[_top++] = x;
    }
 
    ~Stack()
    {
        cout << "~Stack()" << endl;
        free(_a);      // 释放 _a 指向的内存
        _a = NULL;
        _top = _capacity = 0;
    }
 
private:
    STDataType* _a;   // 指向堆上的数组
    size_t _capacity;
    size_t _top;
};
 
// 注意:这个 Stack 我没有写拷贝构造,用的是编译器默认的浅拷贝
 
int main()
{
    Stack st1;
    st1.Push(1);
    st1.Push(2);
    Stack st2 = st1; // 浅拷贝!st2 的 _a 和 st1 的 _a 指向同一块堆内存
    return 0;        // 走到这里 st2 先析构释放一次,st1 再析构又释放一次,同一块内存 free 两次 => 崩溃!
}

这段代码的运行轨迹是这样的:Stack st2 = st1; 用编译器默认的浅拷贝,把 st1._a 这个指针的值原封不动复制给 st2._a。于是 st1._a 和 st2._a 现在指向同一块堆内存,但 st1 和 st2 是两个独立的对象!到 main 结束时,后定义的 st2 先析构,free 一次这块内存;紧接着先定义的 st1 析构,又 free 一次——同一块内存被释放两次,这叫 double free,程序直接崩溃,跑也跑不完。

浅拷贝的本质,我们要用一个类比钉进脑子里:浅拷贝复制的是"指向房子的地址纸条",而不是"房子本身"。 你抄了一份纸条给 st2,纸条上的地址还是同一个房子。等两个对象都想拆掉(析构)这座房子时,同一个房子被拆两次,自然矛盾重重。用术语说,就是"多个对象共享同一块资源引用,谁都以为自己独享"。

顺着这个例子,你可以轻易理解 Stack 为什么必须自己写深拷贝:深拷贝不仅复制指针本身,还要为新的对象单独申请一块同样大小的内存,再把内容 copy 过去,这样一来各对象就拥有各自独立的内存,互不干扰:

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
using namespace std;
 
typedef int STDataType;
 
class Stack
{
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (_a == NULL)
        {
            perror("malloc fail");
            return;
        }
        _capacity = n;
        _top = 0;
    }
 
    // 深拷贝:给 _a 单独申请一块资源,再把内容原样拷过来
    Stack(const Stack& st)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
        if (_a == NULL)
        {
            perror("malloc fail");
            return;
        }
        memcpy(_a, st._a, sizeof(STDataType) * st._top); // 只拷贝实际用到的 _top 个元素
        _top = st._top;
        _capacity = st._capacity;
    }
 
    void Push(STDataType x)
    {
        if (_top == _capacity)
        {
            int newCap = _capacity * 2;
            STDataType* tmp = (STDataType*)realloc(_a, newCap * sizeof(STDataType));
            if (tmp == NULL)
            {
                perror("realloc fail");
                return;
            }
            _a = tmp;
            _capacity = newCap;
        }
        _a[_top++] = x;
    }
 
    ~Stack()
    {
        cout << "~Stack()" << endl;
        free(_a);
        _a = NULL;
        _top = _capacity = 0;
    }
 
private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};
 
// 这个 Stack 的拷贝构造是深拷贝;只要它写对了,MyQueue 什么都不用写
class MyQueue
{
private:
    Stack _pushst;
    Stack _popst;
};
 
int main()
{
    Stack st1;
    st1.Push(1);
    st1.Push(2);
    Stack st2 = st1;  // 深拷贝:st2 的 _a 是全新的内存,内容为 {1,2}
    // 各析构各的,谁也不会碰谁的内存,安全释放
    return 0;
}

深浅拷贝的边界,可以用这一句话记牢:浅拷贝"复制的只是指针,而不是指针指向的东西";当成员有指向堆资源的指针时,浅拷贝必然造成"多个对象共享一块内存"的隐患(双释放、互相覆盖),必须用深拷贝给每个对象都配一块独立资源。 而像 MyQueue 这种纯靠组合(内部是 Stack 成员)的类,你甚至一个字都不用写——编译器自动生成的拷贝构造会逐个去调 Stack 的深拷贝,安全得很。

顺带一提:MyQueue mq2 = mq1; 这种"组合类的默认拷贝构造自动深拷贝成员"的能力,是"深拷贝只写一遍、到处都能用"的绝佳证明。这就像你教会了 Stack 怎么独立生活,那么组建的公司 MyQueue 就自动继承了这两种行为,不需要再为 MyQueue 单独开班授课。这也是为什么做正确类设计如此重要——正确实现一次,组合处自动受益。

初始化列表

前面讲构造函数第 7 条时留了个尾巴:如果自定义类型成员没有默认构造函数,编译器默认的构造就无能为力,得用初始化列表解决。 现在把这根线头接上,作为给下一章做的铺垫。(严格说,初始化列表是下一篇文章的主题,但这一篇先把它讲个八九不离十,你读下一篇时会非常顺。)

什么叫初始化列表?通俗说,就是构造函数名后面用冒号引出的一串初始化语句,它可以在这个对象还没进函数体之前,就先给某些成员指定初值。它的语法长这样:

class Date
{
public:
    Date(int y, int m, int d)
        : _year(y)   // 初始化列表:冒号后是"成员名(实参)"的形式
        , _month(m)
        , _day(d)
    { }
private:
    int _year;
    int _month;
    int _day;
};

那它到底解决了什么迫在眉睫的问题?举两个典型场景:

场景一:成员是没有默认构造函数的自定义类型。 这正好接上我们的痛点。假设 Date 里有一个 Time 成员,而 Time 只提供了带参构造、没有默认构造,那么 Date 的默认构造压根不知道拿什么去初始化 _time,编译直接报错。初始化列表就能在 _time 被构造的那一瞬间,显式地给它传入实参:

#include <iostream>
using namespace std;
 
class Time
{
public:
    // 只提供带参构造,没有提供"不给实参也能调"的默认构造
    Time(int h, int m, int s)
    {
        _hour = h;
        _minute = m;
        _second = s;
    }
    void Print() const
    {
        cout << _hour << ":" << _minute << ":" << _second << endl;
    }
private:
    int _hour;
    int _minute;
    int _second;
};
 
class Date
{
public:
    // 必须用初始化列表给 _time 传入实参,否则 _time 没有默认构造可用,编译报错
    Date(int year, int month, int day, int h, int m, int s)
        : _time(h, m, s)   // 初始化列表:显式调用 Time 的带参构造
    {
        _year = year;
        _month = month;
        _day = day;
    }
    void Print() const
    {
        _time.Print();
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
    Time _time;    // 自定义类型成员,没有默认构造
};
 
int main()
{
    Date d(2025, 4, 18, 9, 30, 0);
    d.Print();
    return 0;
}

场景二:const 成员变量和引用成员必须用初始化列表。 因为 const 成员一旦定义就不能再赋值,引用成员一旦定义就必须绑定到某个对象——这两个都必须在"对象内存建成的那一刻"就定下来,而不是进了函数体再改。函数体里 _constMember = x 这种写法对 const 成员是赋值,const 对象不能赋值,所以必须走初始化列表的初始化路径才行。

场景二是初始化列表里最常考、也最容易懵的一条,我多讲两句。一个成员一旦被声明为 const int _constData;,它的命运就注定了:只能被"初始化"一次,之后任何赋值动作都是非法。而函数体内的 _constData = 5; 属于"先有格子、再填值"的赋值——这违背了 const 的承诺,编译器自然直接拒绝。好在初始化列表是在"格子刚建好的那一瞬间"就把值填进去,属于初始化,完全合法。同理,引用成员 int& _ref; 必须从一开始就"绑定"到谁身上,不可能等晚点再绑,所以也只能走初始化列表。

除了上面两个"不用就编译不过"的硬性场景,初始化列表还有一个"用了就更快"的软性优点:对自定义类型成员,先初始化比"先默认构造再赋值"省掉一次构造。 打个比方,不写初始化列表时,一个自定义成员要先被默认构造一次(新建空房),进到函数体再被赋值覆盖一次(重新装修);写了初始化列表,则直接一步到位(建好就是目标装)。对大型对象,少一次构造是实打实的性能提升。

最后是初始化列表最容易阴人、几乎人人踩过的一条硬规矩:初始化列表的执行顺序,不看你写谁在前、谁在后,而看成员在类里的声明顺序。 也就是说,真正起作用的顺序是"先初始化先声明的成员",跟你冒号后写的顺序无关。这是 C++ 一个非常经典的反直觉设计,也是为什么有经验的 C++ 程序员强制自己"初始化列表的书写顺序严格对齐声明顺序"——否则容易出那种"看起来对、实际顺序错"的隐蔽 bug。关于初始化列表和函数体内直接赋值的细微差别(哪些成员必须初始化列表、初始化顺序跟随声明顺序等),我们在下一篇文章里再掰开讲透。

运算符重载入门

现在我们把目光投向一个更"酷"的能力——运算符重载。

什么叫运算符重载?C++ 允许你对一个已有的运算符(比如 +、-、==、<)给出针对自定义类型的新的含义。比如你希望两个 Date 对象能直接比较大小,就重载 operator<;希望两个 Date 能相减得到天数差,就重载 operator-。

先记住规则——自定义类型对象使用运算符时,编译器必须能把它转换成调用一个"运算符重载函数",如果没有对应的重载函数,就直接编译报错。这个重载函数有一张"特殊的名片":名字由关键字 operator 和运算符本身拼成,比如 operator==、operator+。

底层发生了什么?其实运算符重载在底层就是一个普通的函数调用。d1 == d2 会被编译器翻译成 d1.operator==(d2)(成员函数版),而 1 + 2 则直接走 CPU 指令。编译器正是靠一张"名字就是 operator+ 串起来"的函数签名表,把 d1 + d2 这种写法映射到你的重载函数上。所以从本质上讲,重载运算符 = 给运算符取名、当普通函数来设计,只是调用时语法更自然、可读性更强。

关于运算符重载,有几个硬规矩必须记住:

  • 重载运算符函数的参数个数,和该运算符作用的运算对象数量一样多。 +、== 这种二元运算符有两个参数;++、! 这种一元运算符有一个参数。
  • 如果是成员函数,那它的第一个运算对象会被自动交给隐式的 this 指针接收,所以参数个数会比运算对象少一个。比如成员函数 operator== 只有一个形参 d,实际上是在拿 this(左边的对象)和 d(右边的对象)比较。
  • 重载以后,运算符的优先级和结合性仍然和内置类型保持一致,不会因为重载而改变。这意味着 d1 + d2 * d3 里的乘法依然先算——你没法改变这种先后顺序。
  • 不能凭空创造语法里没有的符号,比如 operator@ 是非法的。
  • 这 5 个运算符不能重载:.*、::、sizeof、?:、.。(选择题高频考点,务必背下来。)其中 . 是成员访问,.* 是成员指针访问,:: 是作用域解析符,sizeof 是类型工具,?: 是三元条件——它们要么由编译器内部控制,要么涉及语言层面的基础机制,不允许程序员改写。
  • 重载运算符必须至少有 1 个类类型参数,不能靠重载去改变内置类型的行为。比如 int operator+(int x, int y) 会报错,因为你不能把两个 int 的相加含义改掉。
  • 一个类要不要重载某运算符,取决于它重载后有没有意义。比如 Date 重载 operator-(两个日期相减得到天数)很有意义,但重载 operator+(两个日期相加?)就没什么意义了。
  • 至于大家最爱考的 ++ 和 --:前置和后置的重载函数名都一样(operator++),没法直接区分。C++ 的规定是——后置 ++ 重载时,额外加一个 int 形参,靠这个"哑参数"跟前置构成重载。这个 int 形参你不写形参名也行,因为没人真的会传值给你,它纯粹为了区分。

看一个成员函数的 operator==,把 d1 == d2 背后发生了什么看清楚:

#include <iostream>
using namespace std;
 
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
 
    // 成员函数重载 operator==:左边对象用 this,右边对象用形参 d
    bool operator==(const Date& d) const
    {
        return _year == d._year
            && _month == d._month
            && _day == d._day;
    }
 
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
 
private:
    int _year;
    int _month;
    int _day;
};
 
int main()
{
    Date d1(2024, 7, 5);
    Date d2(2024, 7, 6);
    Date d3(2024, 7, 5);
 
    // 编译器会把 d1 == d2 转成 d1.operator==(d2)
    cout << (d1 == d2) << endl;   // 输出 0,年相同、月相同、但日 5!=6
    cout << (d1 == d3) << endl;   // 输出 1,三个字段都相等
 
    // 运算符重载函数也可以像普通函数一样显式调用
    cout << d1.operator==(d2) << endl; // 等价于 d1 == d2,输出 0
    return 0;
}

注意一个优秀实践:operator== 用了 const 成员函数、参数用了 const Ref——这保证了对 const 对象也能比较(回想上一篇关于 const 成员函数的权限论述)。能不能比较,本质上不该改变对象状态的两边,就都应该 const。

再演示前置 ++ 和后置 ++ 的重载写法以及 int 哑参数的作用:

#include <iostream>
using namespace std;
 
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
 
    // 前置 ++:先自增,再返回自增后的对象本身,因此返回引用
    Date& operator++()
    {
        cout << "前置++:" << _year << "-" << _month << "-" << _day << endl;
        ++_day;          // 简化实现:只让天数 +1,日期校正逻辑略去
        return *this;    // 返回 *this,即对象自增后的本体
    }
 
    // 后置 ++:先返回自增前的副本,再自增;函数名相同,靠多出的 int 形参区分
    Date operator++(int) // 注意:这里的 int 是"哑参数",只用来区分,不接收真实值
    {
        cout << "后置++:" << _year << "-" << _month << "-" << _day << endl;
        Date tmp = *this;  // 保存自增前的副本
        ++_day;            // 本体自增
        return tmp;        // 返回的是自增前的副本,符合"后置++先取旧值"的语义
    }
 
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
 
private:
    int _year;
    int _month;
    int _day;
};
 
int main()
{
    Date d1(2024, 7, 5);
    ++d1;   // 编译器转成 d1.operator++()
    d1.Print();
 
    Date d2 = d1++; // d1++ 转成 d1.operator++(0);d1 本身 +1,d2 拿到 +1 前的旧值
    d1.Print();
    d2.Print();
    return 0;
}

细心的你可能发现了一个规律:前置 ++ 返回 Date& 引用,后置 ++ 返回 Date 值。为什么?因为前置 ++ 的语义是"自增后的本人",返回本体再合适不过;而后置 ++ 的语义是"我也要自增,但你拿到的得是先自增前的旧值",所以得拷一份副本 tmp、自增本体、最后把副本传值返回。传值返回自然就不能返回引用,得返回一个 Date 值。这是最经典的一对,把它们的前因后果想通,运算符重载你基本就入门了。(顺带一个小优化点:因为后置 ++ 要构造临时对象再返回,标准库设计者普遍更推崇前置 ++。这不是玄学,而是实打实的"少一次拷贝"。后面你刷题或写容器迭代器时,这个习惯能省下真实的开销。)

为什么 << 和 >> 必须重载为全局函数?

这是课件里反复强调、面试也常被追问的一个魔性点:operator<<(流插入)和 operator>>(流提取)一般必须重载为全局函数,而不是成员函数。 答案跟 this 指针抢位置有关,请务必理解。

如果重载为成员函数,this 会默认抢占第一个形参位(也就是左侧运算对象的位置)。于是当你写 cout << d; 时,编译器会试图解释成 d.operator<<(cout)——也就是变成了**"对象 << cout",跟我们习惯的 cout << 对象 完全颠倒!这不仅绕口,而且可读性极差。所以正确做法是把它重载为全局函数**,把 ostream(或 istream)放在第一个形参位置、我们的类对象放第二个形参位置:

ostream& operator<<(ostream& out, const Date& d);   // 全局函数,第一个参数是流
istream& operator>>(istream& in, Date& d);          // 全局函数,第一个参数是流

这样一来 cout << d; 就直接命中 operator<<(cout, d)。但是,新问题来了:operator<< 是全局函数,它没有 this,也就默认无权访问 Date 的私有成员 _year/_month/_day。怎么解决?课件给了四条路:把成员设成 public、给每个成员写 get 函数、把流操作符声明成 Date 的友元(friend)、或者干脆重载成成员函数(但你刚知道那会牺牲可读性)。在真实工程里,"友元"是最常被采用的折中——它允许一个外部函数跨越 private 藩篱访问私有数据,代价是破坏了一点点的封装严格性,但换来的是自然且通用的 cout << d1 << d2 链式写法,非常值。这也是为什么标准库的日期/自定义类型输出,几乎清一色用"友元 + 全局 operator<<"的配方。

#include <iostream>
using namespace std;
 
class Date
{
    // 声明为友元:允许这个全局函数访问私有成员
    friend ostream& operator<<(ostream& out, const Date& d);
public:
    Date(int year = 1, int month = 1, int day = 1)
        : _year(year), _month(month), _day(day)
    { }
private:
    int _year;
    int _month;
    int _day;
};
 
// 全局重载 <<:第一个参数是流,第二个参数是对象
ostream& operator<<(ostream& out, const Date& d)
{
    out << d._year << "年" << d._month << "月" << d._day << "日";
    return out;   // 返回 out,支持 cout << d1 << d2 的连续输出
}
 
int main()
{
    Date d1(2024, 7, 5);
    Date d2(2025, 1, 1);
    cout << d1 << endl;   // 输出:2024年7月5日
    cout << d1 << " | " << d2 << endl;  // 连续链式输出:2024年7月5日 | 2025年1月1日
    return 0;
}

请留意上面的 operator<< 为什么必须返回 ostream&——因为只有返回 out 本身,cout << d1 << d2 才能像"链子"一样一个接一个地求值:(cout << d1) 算完得到 cout,再继续 << d2。少了这步返回值,链式就会断掉。这跟你下面的赋值重载返回 *this 支持连续赋值,是同一个设计哲学。

赋值运算符重载

赋值运算符重载是默认成员函数里的一个,专门用来完成"两个已经存在的对象之间的拷贝赋值"。这句话是它与拷贝构造的分水岭,一定要咬住:

  • 拷贝构造:用一个已有的对象,去初始化另一个"刚要创建"的新对象。
  • 赋值运算符重载:两个对象都已经存在了,把右边的值赋给左边的对象。

赋值运算符重载的特点,逐条过:

  1. 它本质上是一个运算符重载,C++ 规定必须重载为成员函数(因为赋值必然要修改左边对象的 *this)。
  2. 参数建议写成 const 当前类类型引用——否则传值传参会白白多做一次拷贝构造。
  3. 必须有返回值,且建议返回当前类类型的引用(返回 *this)。为什么要有返回值?为了支持连续赋值,比如 d1 = d2 = d3; 这样的链式写法,d2 = d3 的返回值还得继续赋给 d1,所以 d1 = d2 这个表达式的求值结果就是 d1 本身(也就是 *this)。
  4. 你没写时编译器自动生成一个默认的。它的行为跟默认拷贝构造类似:内置类型成员做值拷贝(浅拷贝),自定义类型成员调它自己的赋值重载。
  5. 所以"哪些类要自己写"的结论,跟拷贝构造完全一致:Date 这种全内置类型、无外部资源的,直接白嫖编译器默认的;Stack 这种成员指针指向资源的,必须自己写深拷贝式赋值;MyQueue 这种靠组合的,编译器默认赋值会自动调 Stack 的赋值重载,也不用写。那个检查小技巧同样适用:如果显式写了析构释放资源,那赋值重载一般也得自己写。

先看 Date 的赋值重载,重点感受它和拷贝构造的差别,以及 *this 的返回值:

#include <iostream>
using namespace std;
 
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
 
    // 赋值运算符重载:两个已存在的对象之间赋值
    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 的连续赋值
    }
 
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
 
private:
    int _year;
    int _month;
    int _day;
};
 
int main()
{
    Date d1(2024, 7, 5);
    Date d2(2030, 1, 1);
    Date d3(2025, 4, 18);
 
    d2 = d3;    // 赋值:d2 和 d3 都已存在,把 d3 的值赋给 d2。编译器转成 d2.operator=(d3)
    d2.Print();
 
    // 请特别注意:下面这行 Date d4 = d1; 看起来像赋值,
    // 但 d4 是"正在创建的新对象",所以这行走的是拷贝构造,不是赋值重载!
    Date d4 = d1; // 拷贝构造
    d4.Print();
    return 0;
}

看清楚了:d2 = d3; 是往已经存在的对象 d2 里塞值,调赋值重载;而 Date d4 = d1; 是先声明一个还不存在的 d4,再拿 d1 初始化它,走的是拷贝构造。判断口诀就一句:对象名前面有没有类型(Date),决定这行是赋值还是拷贝构造。 有类型关键字 → 拷贝构造(新对象);没有类型关键字 → 赋值重载(已有对象)。把这条口诀死死记牢,你就能一眼看穿代码里每一行的真实去向。

那 Date 的赋值重载里那道 if (this != &d) 自赋值检查是干什么的?对 Date 这种简单类,d1 = d1 即使不检查也不会出错(最多是浪费一次把值赋给自己的动作)。但你别小看它——一旦到了 Stack 那种要先 free 旧资源的赋值,d1 = d1 如果没用检查,就会变成"先把自己唯一的资源 free 掉、再去拷贝一份已经不存在的内容",直接翻车。所以自赋值检查是赋值重载的"标准安检门",从 Date 这种简单类开始就养成写的习惯,将来绝不踩雷。

接着把 Stack 的深拷贝式赋值重载补全,它比 Date 多一个动作:先释放自己旧的资源,再申请新资源深拷贝:

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
using namespace std;
 
typedef int STDataType;
 
class Stack
{
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (_a == NULL)
        {
            perror("malloc fail");
            return;
        }
        _capacity = n;
        _top = 0;
    }
 
    Stack(const Stack& st)   // 拷贝构造:深拷贝
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
        if (_a == NULL)
        {
            perror("malloc fail");
            return;
        }
        memcpy(_a, st._a, sizeof(STDataType) * st._top);
        _top = st._top;
        _capacity = st._capacity;
    }
 
    // 赋值重载:也是深拷贝。特别要注意"先释放旧资源"
    Stack& operator=(const Stack& st)
    {
        if (this != &st)   // 自赋值检查:如果是 st = st,直接跳过,避免自释放后自拷贝
        {
            free(_a);      // 关键!先把左边对象自己的旧资源释放掉
            _a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
            if (_a == NULL)
            {
                perror("malloc fail");
                _top = _capacity = 0;
                return *this;
            }
            memcpy(_a, st._a, sizeof(STDataType) * st._top); // 深拷贝数据
            _top = st._top;
            _capacity = st._capacity;
        }
        return *this; // 支持连续赋值
    }
 
    void Push(STDataType x)
    {
        if (_top == _capacity)
        {
            int newCap = _capacity * 2;
            STDataType* tmp = (STDataType*)realloc(_a, newCap * sizeof(STDataType));
            if (tmp == NULL)
            {
                perror("realloc fail");
                return;
            }
            _a = tmp;
            _capacity = newCap;
        }
        _a[_top++] = x;
    }
 
    ~Stack()
    {
        free(_a);
        _a = NULL;
        _top = _capacity = 0;
    }
 
private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};
 
int main()
{
    Stack st1;
    st1.Push(1);
    st1.Push(2);
    Stack st2;
    st2.Push(3);
 
    st2 = st1; // 赋值:先释放 st2 旧的资源,再申请一块新的、内容为 {1,2} 的资源并拷进来
    st1.Push(100); // 此时 st1 变大,st2 不受任何影响,两者资源完全独立
    return 0;
}

这里的 st2 = st1; 若不用"先释放再深拷贝",就会踩一个经典的坑:st2 原来那块内存不释放就铺新数据,必然内存泄漏;更糟的是如果直接浅拷贝让指针共用一块内存,两个对象又是"双释放"崩溃。所以资源型类的赋值重载,三步缺一不可:自赋值检查 → 先释放 → 再深拷贝。

const 成员函数

前面所有示例里我都给 Print 加了 const,但一直没解释。现在是时候把它讲穿了——这是 const 成员函数。

const 成员函数,就是把 const 放在成员函数参数列表的后面(也就是 ) 和 { 之间)来修饰的函数。比如:

void Print() const
{
    // ...
}

这个 const 到底修饰谁?答案是——它修饰该成员函数隐含的 this 指针。在非 const 成员函数里,this 的类型是 Date* const(常量指针,指向的对象可改);一旦加了 const,this 就变成了 const Date* const(常量指针,且指向的对象也只能读不能改)。换句话说,在 const 成员函数内部,你碰不得任何成员变量来修改它——试图给 _year 赋值,编译器直接报错。

其实这套机制背后就是一条 C/C++ 通用的"常量指针 vs 指向常量的指针"规则:Date* const 表示"指针本身不能变(不能换指向),但 *this 指向的东西能改";加上 const 变成 const Date* const 后,"指针本身不能变"外加"指向的东西也不能改"。你可以类比 const char* 和 char* const 的区别来消化它。

为什么要这个?因为它能回答一个问题:const 修饰的对象(比如 const Date d;)能不能调用 Print?如果能,那这个函数就必须保证"只读不改"。请看:

#include <iostream>
using namespace std;
 
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
 
    // const 成员函数:内部 this 是 const Date* 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 对象必须通过 const 成员函数来读取,否则编译报错
    const Date d2(2024, 8, 5);
    d2.Print(); // 通过!因为 Print 是 const 成员函数
    return 0;
}

权限规则一句话就可以讲完:非 const 对象既能调非 const 成员函数也能调 const 成员函数(权限缩小),但 const 对象只能调 const 成员函数。 平时我们写类时,那些"只读不写"的成员函数(如 Print、比较运算),养成习惯都加上 const,这样 const 对象才用得起。

为什么要这么较真?因为 const 对象在工程里太常见了——传参传 const Date&、某些"只读查看"的容器迭代、以及大量标准库接口都强制要求你提供的对象是 const 的。如果你把一个本不该改动的成员函数漏加了 const,后果很直接:那个 const 对象一旦想调用它,编译器直接判"无权限"(报 error: passing 'const Date' as 'this' argument discards qualifiers 之类的错),整个调用点就编译不过。所以现在开始,判断一个成员函数该不该加 const,就是问自己"这个函数会不会修改对象的成员":只读就加,要改就不加。

最后提一个进阶但常被问的点:如果确实有一个成员,即使在 const 成员函数里也想被修改(比如一个用于调试的"调用计数" int _callCount),C++ 为此提供了 mutable 关键字——被 mutable 修饰的成员在 const 成员函数里也允许被修改。它的存在有点"钻 const 空子"的意味,真实开发中主要用于缓存、计数这类"逻辑上不该算作对象核心状态"的字段,初学者先了解有这么个东西即可,不必急着用。

取地址运算符重载

默认成员函数里最后两个——普通取地址运算符重载和 const 取地址运算符重载,它们的作用是让类"重定义 & 运算符返回什么"。绝大多数时候,编译器自动生成的那份已经够用,你直接取对象地址 &d 拿到的就是真实地址。为什么要重定义它?除非遇到很特殊的场景——比如你不想让别人取到当前对象的真实地址,就可以自己实现一份,故意返回一个假地址(比如 nullptr):

#include <iostream>
using namespace std;
 
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
 
    // 普通取地址重载:让 &对象 返回这个对象的真实地址(this)
    Date* operator&()
    {
        return this;
        // return nullptr;   // 如果你不想让别人拿到真实地址,就手动返回其它值
    }
 
    // const 对象取地址重载:const 版本返回 const 指针
    const Date* operator&() const
    {
        return this;
        // return nullptr;
    }
 
private:
    int _year;   // 年
    int _month;  // 月
    int _day;    // 日
};
 
int main()
{
    Date d(2025, 4, 18);
    Date* p = &d;       // 普通对象取地址,走 Date* operator&()
    cout << p << endl;
 
    const Date cd(2025, 4, 18);
    const Date* cp = &cd; // const 对象取地址,走 const Date* operator&() const
    cout << cp << endl;
    return 0;
}

老实说,这两个函数在真实工程项目里几乎不会被你亲手重写,默认生成的够用一万年。这一节你只需要"知道它们存在、知道它们能干嘛"即可,不到万不得已不用动它们。(它俩加在一起,其实才凑齐了& 对普通对象和对 const 对象两条取地址通路,这也是为什么默认成员函数列表里会同时出现"普通取地址"和"const 取地址"两份。)

让它真正跑起来

讲到这,构造、析构、拷贝、赋值、运算符重载都齐了。把它们拼起来,就是一个哪怕放到真实项目里也基本合格的完整 Date 类骨架。现在我把课件里那个"麻雀虽小五脏俱全"的日期类,配齐真正的日期运算——闰年判定、月份天数、日期的加减进位/借位、两个日期求天数差——做成一个能真正落地的完整工程。这样你既能复习运算符重载,也能顺带把"日期的真实进位逻辑"跑通。

先看一个能独立编译运行的最小完整版,覆盖比较、+/-/+=/-=、前置/后置 ++/--、<</>>、天数差:

#include <iostream>
#include <cassert>
using namespace std;
 
class Date
{
    // 把 << 和 >> 声明为 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)
        : _year(year), _month(month), _day(day)
    {
        if (_month < 1 || _month > 12
            || _day < 1 || _day > GetMonthDay(_year, _month))
        {
            cout << "日期非法" << endl;
        }
    }
 
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
 
    // 判断某年某月有几天:处理闰年(四年一闰,百年不闰,四百年再闰)
    int GetMonthDay(int year, int month) const
    {
        assert(month >= 1 && month <= 12);
        static int monthDayArray[13] = { -1, 31, 28, 31, 30, 31, 30,
                                         31, 31, 30, 31, 30, 31 };
        if (month == 2
            && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
        {
            return 29;   // 闰年二月
        }
        return monthDayArray[month];
    }
 
    // 六个比较运算符:全部用 const 成员函数重载,只读不改
    bool operator==(const Date& d) const
    {
        return _year == d._year
            && _month == d._month
            && _day == d._day;
    }
 
    bool operator<(const Date& d) const
    {
        if (_year < d._year) return true;
        if (_year > d._year) return false;
        if (_month < d._month) return true;
        if (_month > d._month) return false;
        return _day < d._day;
    }
 
    // 复数个逻辑运算符可以基于一个基础运算符组合出来
    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); }
 
    // d1 += days:返回引用,因为要改自己;days 可为负
    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 + days:不改动 d1,返回一个新对象,因此是 const + 值返回
    Date operator+(int day) const
    {
        Date tmp = *this;   // 拷一份副本
        tmp += day;          // 调整副本
        return tmp;          // 返回副本,d1 本身不动
    }
 
    // d1 -= days:同理,支持负数
    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;
    }
 
    // d1 - days
    Date operator-(int day) const
    {
        Date tmp = *this;
        tmp -= day;
        return tmp;
    }
 
    // 前置 ++:返回引用
    Date& operator++()
    {
        *this += 1;
        return *this;
    }
 
    // 后置 ++:用 int 哑参数区分,返回自增前的副本(值返回)
    Date operator++(int)
    {
        Date tmp = *this;
        *this += 1;
        return tmp;
    }
 
    // 前置 --
    Date& operator--()
    {
        *this -= 1;
        return *this;
    }
 
    // 后置 --
    Date operator--(int)
    {
        Date tmp = *this;
        *this -= 1;
        return tmp;
    }
 
    // d1 - d2:两个日期相差多少天(结果可正可负)
    int 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 一天天自增,数到和 max 相等所需的天数
        {
            ++min;
            ++n;
        }
        return n * flag;
    }
 
private:
    int _year;   // 年
    int _month;  // 月
    int _day;    // 日
};
 
// 全局重载 <<:第一个参数是流,第二个是对象,靠 friend 访问私有成员
ostream& operator<<(ostream& out, const Date& d)
{
    out << d._year << "年" << d._month << "月" << d._day << "日";
    return out;   // 返回 out,支持 cout << d1 << d2 的连续输出
}
 
// 全局重载 >>:从流读入年月日
istream& operator>>(istream& in, Date& d)
{
    cout << "请依次输入年月日(空格分隔):>";
    in >> d._year >> d._month >> d._day;
    return in;   // 返回 in,支持 cin >> d1 >> d2
}
 
int main()
{
    // 构造 + 打印
    Date d1(2024, 7, 5);
    Date d2(2024, 7, 6);
    d1.Print();                            // 输出: 2024-7-5
 
    // 比较
    cout << (d1 < d2) << endl;             // 输出 1
    cout << (d1 == d2) << endl;            // 输出 0
 
    // += 和 +
    Date d10(2024, 7, 5);
    d10 += 30;          // 30 天后进位,变成 2024-08-04
    d10.Print();
    Date d11(2024, 7, 5);
    Date d12 = d11 + 60; // 60 天后又是一次跨月进位,变成 2024-09-03;d11 本身不变
    d11.Print();
    d12.Print();
 
    // -= 和 -
    Date d20(2024, 3, 1);
    d20 -= 1;           // 往前退 1 天,借位回到 2024-02-29(2024 是闰年,所以有 29 号)
    d20.Print();
 
    // 前置 / 后置 ++
    Date d30(2024, 7, 5);
    ++d30;              // 前置:变成 2024-07-06
    d30.Print();
    Date d31 = d30++;   // 后置:d31 拿到自增前(07-06),d30 变成 07-07
    d30.Print();
    d31.Print();
 
    // 两个日期求天数差
    Date d40(2024, 7, 5);
    Date d41(2024, 8, 9);
    cout << "天数差: " << (d41 - d40) << endl;   // 输出 35
 
    // 用重载的 << 打印(走 ostream& operator<<)
    cout << d11 << endl;   // 输出:2024年7月5日
    return 0;
}

看着从小小的一个类长成一台五脏俱全的"机器",你大概已经体会到了:构造与析构把对象的"来"和"去"管理得妥妥帖帖,拷贝与赋值把对象的"复制"语义钉得明明白白,运算符重载让对象能和内置类型一样参与运算——这才是面向对象真正有血有肉的样子。

这一篇走下来,我们从编译器的"默认成员函数"切入,先看构造函数如何替掉 Init、析构函数如何替掉 Destroy,再抓住栈对象"后进先出"的生命周期,引出传值时拷贝构造的自动调用,接着用 Stack 把"浅拷贝翻车、深拷贝救人"的前因后果理清,随后对比了拷贝构造与赋值重载的差别,还用友元 + 全局函数圆满了 <</>>,最后捋顺了 const 成员函数和取地址重载。

在下一次写类的第一行代码之前,记得先问自己三个问题:这个类需要自己写析构吗?需要深拷贝吗?哪些成员函数该加 const?把这三个问题想清楚,类就写对了一大半。而这三个问题的答案,你其实已经在心里有了——没资源就不写析构,有资源必须写析构(进而十有八九还要配深拷贝构造和重载赋值),只读函数都加 const。 把这张检查表变成肌肉记忆,C++ 面向对象的大门,你就已经稳稳地迈进来一大半了。剩下的运算符重载、初始化列表细节,下一章我们继续加餐,把这份肌肉记忆巩固成真正的能力。