上一篇我们认识了类,知道怎么定义成员变量和成员函数,也知道 this 指针在背后帮我们区分"是谁在调用我"。但你可能已经隐隐察觉到一个问题:我定义一个 Date 对象,它的 _year、_month、_day 到底是谁在初始化?我定义一个持有 malloc 申请的动态内存的 Stack,这块内存要不要自己手动释放?万一对象在传参、返回的过程中被"复制"了,复制后两块内存会不会打架?
这些问题的答案,都藏在 C++ 对象一生的"生死大戏"里。这一篇我们就把镜头对准它:一个人从呱呱坠地到寿终正寝是怎么发生的,对象被复制一份又意味着什么。
不过在这之前,我想先给你一颗定心丸——这一篇是 C++ 入门阶段"最长、最绕、最容易劝退"的一篇,但同时也是价值最高的一篇。学完它,你再看后面 string、vector 这些标准库容器的源码,会感觉像读老朋友的信一样亲切。因为标准库里那些大名鼎鼎的类,底层干的事无非就是构造、析构、拷贝构造、赋值重载这四件套的排列组合。所以哪怕这一篇文字很多,也请你咬牙看完——每一个字都值一个 bug。准备好了吗?我们开始。
类的默认成员函数
先记住一个叫默认成员函数的概念:所谓默认成员函数,就是你没有显式写,编译器会在背后偷偷帮你生成的那些成员函数。
一个类里如果我们一样东西都不写,编译器默认会生成这 6 个默认成员函数:
- 默认构造函数
- 默认析构函数
- 默认拷贝构造函数
- 默认赋值运算符重载
- 默认普通取地址运算符重载
- 默认
const取地址运算符重载
前 4 个是重头戏,我们这一篇逐个搞定;最后两个取地址重载不重要,这一篇末尾带大家看一眼即可。
另外多说一句,到了 C++11 之后,编译器还会再补两个默认成员函数——移动构造和移动赋值,它们和"移动语义"有关,是更高阶的话题,你只需知道它们存在,更深处我们放到后面介绍 C++11 新特性时再展开。(顺带一提:标准库容器最重要的一条纪律"三法则 / 五法则"——只要你手写了析构、拷贝构造、赋值中的任何一个,几乎就必须把另外几个也补全——其源头正是这 8 个默认成员函数之间的联动关系。这条纪律我们现在先不去硬背,等明确了编译器"够不够用"的判断标准之后,它自然就水到渠成。)
学习默认成员函数,永远只有这两个问题:
- 我不写,编译器帮我写的那个,行为是怎么样的?够不够我用?
- 如果编译器生成的不够用,我该怎么自己写?
这一篇的所有内容,本质上都是在回答这两个问题。你可以把这两个问题像"咒语"一样贯穿始终:每遇到一个默认成员函数,就先问——编译器它自己会怎么干?我这回需不需要自己动手?
编译器到底会不会真的"生成"出来?
这是很多初学者卡住的第一个疑点:我代码里明明一个字都没写,它上哪生出去?答案是:编译器在编译你的 .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 你得记得手动调用、还得记得传入地址;而构造函数是对象创建时系统自动调用的,你根本不需要管,忘不了也不会忘。
构造函数有这么几个硬性特点,逐条说:
- 函数名与类名完全相同,编译器靠这个认出"这是构造函数"。对编译器而言,这相当于一个"暗号":看到
Date::Date(...),就知道这不是普通成员函数,而是造对象的入口。 - 没有返回值。什么都不用写,连
void都不用写。别纠结为什么,C++ 就是这么规定的。你甚至可以连着读:如果函数名和类名相同、又没有返回值,那它就是构造函数——这是一条硬性的识别规则。 - 对象实例化时自动调用。这正是它能替代
Init的根本原因。你不用记着调,编译器帮你在"对象出生"的那一瞬间自动调。 - 可以重载。你可以同时提供多种参数形式的构造,比如
Date()和Date(int y, int m, int d),调用时靠实参去匹配。 - 如果你没显式定义任何构造函数,编译器会自动生成一个无参的默认构造函数;但只要你写了任意一个构造,编译器那个默认的就不再生成了。这就是"只要你点了一份带肉的菜,店家就不管送配菜了"的逻辑——你一旦接手了对象的初始化工作,剩下的事都归你管,编译器不再替你把关。
- 这里有个很容易绕晕的概念,必须讲透——什么叫默认构造函数?很多同学以为"默认构造函数"特指编译器生成的那个无参构造,其实不是。无参构造、全缺省构造、编译器自动生成的那个构造,只要"不给实参就能调用"的,统统都叫默认构造函数。 而且这三者同时只能存在一个,不能共存。为什么?因为无参构造和全缺省构造虽然构成函数重载(参数个数不同),但你写
Date d;调用时编译器分不清你到底想调哪个,存在歧义,直接报错。所以它们之间不是"你死我活",而是"有你没我":同一种"能不传参就调用"的构造,只能留一个。 - 编译器默认生成的构造,对内置类型成员变量(比如
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 必须写析构,把这块堆内存还回去。
析构函数的特点,同样逐条过:
- 名字是类名前加一个
~,比如~Stack。这个符号是个"死亡信号",一眼就看出是析构。 - 无参数、无返回值。连
void也不用写,这一点和构造一样。 - 一个类只能有一个析构函数。参数都不能有,自然没法重载,所以只能有一个;没写,系统自动生成一个默认的。
- 对象生命周期结束时自动调用。跟构造一样,不用你手动 call,编译器在对象"死到临头"的那一刻自动调。
- 和构造类似:编译器默认生成的析构,对内置类型成员啥也不干;对自定义类型成员,会调用它的析构。所以
MyQueue的默认析构会自动去free掉两个Stack的堆内存。 - 这里补一条关键特性:哪怕你自己显式写了析构函数,自定义类型成员的析构也照样会被自动调用。 换句话说,自定义类型成员的析构,无论什么情况都会自动执行,你不用担心"我写了析构是不是就抢了成员的活"。编译器设计成这样是刻意的:组合的时候,外层类的析构只管"自己申请的资源",成员的资源永远由成员自己善后,两者不冲突、不抢活。
- 那到底什么时候需要自己写析构?一句话原则:类里申请了资源(
malloc/new/打开文件等),就必须自己写析构去释放;没申请资源的(比如Date),直接用编译器默认的即可。 而像MyQueue这种内部成员都是Stack的,编译器默认析构会自动帮它调每个Stack的析构,也已经够用了。 - 还有个和构造相反的"生死时序"要记住:同一个局部作用域里定义了多个对象,后定义的对象先析构。 为什么?因为局部对象都在栈上,栈是"后进先出"的——你先定义的先进栈、活得久,后定义的后进栈、先出栈。这跟一摞盘子一个道理,先放的在下、后放的在上,拿走的时候必是先拿上面的。
关于第 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++ 规定必须通过一个叫拷贝构造函数的东西来完成。
先给定义:如果一个构造函数的第一个参数是自身类类型的引用,且其它任何参数都有默认值,那么这个构造函数就是拷贝构造函数。 说白了,拷贝构造是构造函数里很特殊的一个"重载版本",它的任务是"拿一个已有的同类对象来初始化一个新的对象"。
拷贝构造的特点,逐条拆开:
- 它是构造函数的一个重载——所以函数名也跟类名相同,只是参数形态特殊。
- 第一个参数必须是类类型对象的引用。如果你写成传值的拷贝构造(比如
Date(Date d)),编译器直接报错error C2652。为什么?因为你传值给d的过程中,又得先做一次"对象复制"、又得调用拷贝构造,而这次调用又要传值、又要做一次复制……无限套娃,最终必然递归到栈溢出。编译器聪明地直接在语法层面拒绝了这种写法。拷贝构造也可以带更多参数,但第一个必须是引用,其余参数必须有缺省值。这里也顺带解释了一个看似矛盾的现象:Date(const Date& d)里那个const不是可有可无的装饰,而是惯例——拷贝时我们绝不应该改动源对象,加上const既能让const对象也能被拷贝,又能在编译器层面保证你不会手滑改掉源对象。 - C++ 规定:自定义类型对象的一切拷贝行为都必须调用拷贝构造。 这句话是精髓。它意味着——传值传参要调拷贝构造,传值返回也要调拷贝构造。只要你把对象"传值"进函数或"传值"返回,背后就自动多了一次拷贝构造的调用。
- 你没写时编译器会自动生成一个拷贝构造。自动生成的拷贝构造,对内置类型成员做值拷贝(也叫浅拷贝,就是一个字节一个字节地复制),对自定义类型成员调用它自己的拷贝构造。
- 到这里就能推出一个实用结论:像
Date这种成员全是内置类型、也没有指针指向外部资源的类,编译器自动生成的拷贝构造就完全够用,你不用自己写;像Stack这种虽然成员也全是内置类型,但_a这个指针指向了堆上资源,"一字节一字节复制"(浅拷贝)会把两个对象的指针指向同一块内存,这就不符合需求了,需要你自己写深拷贝(详见下一节);像MyQueue这种内部成员主要都是Stack的,编译器自动生成的拷贝构造会去调用每个Stack的拷贝构造,只要Stack的深拷贝写对了,MyQueue也不用写。这里有个检查小技巧:如果一个类显式写了析构去释放资源,十有八九也得显式写拷贝构造;如果连析构都不需要写,那拷贝构造通常也不用写。 - 关于返回值再深挖一点:传值返回时,会先构造一个临时对象、再调一次拷贝构造把它拷给调用方接收;而传引用返回,返回的是返回对象的别名,不产生拷贝。但如果你返回的是函数内部的局部对象,函数一结束它就销毁了,这时的引用就成了"野引用"(类似野指针),那么用引用返回就有大问题。一句话原则:只有确保返回对象在函数结束后还活着(比如返回的是静态对象、成员变量、全局对象),才适合用引用返回。
第 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 支持连续赋值,是同一个设计哲学。
赋值运算符重载
赋值运算符重载是默认成员函数里的一个,专门用来完成"两个已经存在的对象之间的拷贝赋值"。这句话是它与拷贝构造的分水岭,一定要咬住:
- 拷贝构造:用一个已有的对象,去初始化另一个"刚要创建"的新对象。
- 赋值运算符重载:两个对象都已经存在了,把右边的值赋给左边的对象。
赋值运算符重载的特点,逐条过:
- 它本质上是一个运算符重载,C++ 规定必须重载为成员函数(因为赋值必然要修改左边对象的
*this)。 - 参数建议写成
const当前类类型引用——否则传值传参会白白多做一次拷贝构造。 - 必须有返回值,且建议返回当前类类型的引用(返回
*this)。为什么要有返回值?为了支持连续赋值,比如d1 = d2 = d3;这样的链式写法,d2 = d3的返回值还得继续赋给d1,所以d1 = d2这个表达式的求值结果就是d1本身(也就是*this)。 - 你没写时编译器自动生成一个默认的。它的行为跟默认拷贝构造类似:内置类型成员做值拷贝(浅拷贝),自定义类型成员调它自己的赋值重载。
- 所以"哪些类要自己写"的结论,跟拷贝构造完全一致:
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++ 面向对象的大门,你就已经稳稳地迈进来一大半了。剩下的运算符重载、初始化列表细节,下一章我们继续加餐,把这份肌肉记忆巩固成真正的能力。
还没有评论 — 第一条由你来留。