深入理解C++构造函数与运算符重载
- 本文记录了我在学习《算法竞赛入门》第五章时对C++构造函数和运算符重载的深入理解,解决了几个困扰我的问题。
- 基于本人低水平的写作勿喷。
- Jaxon's-blog
- 查算法/知识点/代码分享工具/学习路线/大厂算法/八股
- 大学生食用大学本质
原文代码
#include<iostream>
using namespace std;
struct Point {
int x, y;
Point(int x=0, int y=0):x(x),y(y) {}
};
Point operator + (const Point& A, const Point& B) {
return Point(A.x+B.x, A.y+B.y);
}
ostream& operator << (ostream &out, const Point& p) {
out << "(" << p.x << "," << p.y << ")";
return out;
}
int main() {
Point a, b(1,2);
a.x = 3;
cout << a+b << "\n";
return 0;
}再来看看作者的解释:
上面的代码多数可以"望文知义"。
结构体Point中定义了一个函数,函数名也叫Point,但是没有返回值。
这样的函数称为构造函数(ctor)。
构造函数是在声明变量时调用的,例如,声明Pointa,b(1,2)时,
分别调用了Point( )和Point(1,2)。
注意这个构造函数的两个参数后面都有"=0"字样,其中0为默认值。
也就是说,如果没有指明这两个参数的值,就按0处理,
因此Point( )相当于Point(0,0)。
":x(x),y(y)"则是一个简单的写法,表示"把成员变量x初始化为参数x,
成员变量y初始化为参数y"。虽然之前也了解过构造函数和运算符重载的概念,但真正看到这样完整的应用时,我还是产生了几个疑问:
问题1:为什么构造函数名必须与结构体/类名相同?
对比示例:
// 构造函数 - 与结构体同名,自动调用
Point p(1,2); // 编译器知道调用Point(int, int)
// 如果是普通函数名,就无法实现自动构造
Point p = createPoint(1,2); // 需要显式调用,不够直观理解:
这是C++的语法设计,有几个重要原因:
- 明确标识:让编译器清楚地知道这是构造对象的特殊函数
- 自动调用:在对象创建时编译器能自动识别并调用构造函数
- 类型安全:确保创建的对象类型正确无误
问题2:两个&的作用与区别?
搜了一下资料,一个叫做参数引用,还有一个是返回引用。
- 参数引用,这个很简单,稍微看一眼。
原因两点: 对于大型结构体,值传递会产生完整的对象拷贝,而引用传递直接操作原对象,区别如下:
// 值传递 - 创建副本
void processPoint(Point p) { ... }
// 引用传递 - 直接操作原对象
void processPoint(Point& p) { ... }
// 常量引用传递 - 直接操作原对象,但保证不修改
void processPoint(const Point& p) { ... }使用const引用可以在避免拷贝的同时防止函数内部修改原对象,比如:
// 可以读取但不能修改参数
double calculateDistance(const Point& p1, const Point& p2) {
// p1.x = 10; // 错误!不能修改const引用
return sqrt((p1.x-p2.x)*(p1.x-p2.x) + (p1.y-p2.y)*(p1.y-p2.y));
}为什么&要放在ostream后面?
再来看一下代码
ostream& operator << (ostream &out, const Point& p) {
out << "(" << p.x << "," << p.y << ")";
return out;
}- 这是返回类型的引用
原因是实现链式调用。链式调用,顾名思义,就像cout << a << b << c一样,但是重载之后可以加入字符之类的东西,如下:
我们需要了解一下ostream是什么东西:
具体的可以看文章末尾补充内容
ostream(输出流)是C++中负责所有输出操作的类。我们常用的cout就是ostream的一个具体对象。
太抽象了我们来做个比方:
想象你在用一支笔写字:
ostream对象 = 你手中的笔
<<操作 = 用笔写字
返回引用 = 继续用同一支笔写下一个字
链式书写过程:
cout << a << b << c;这个书写过程:
第一个字:
cout << a用笔写下a的内容
笔不换,手不抬,准备写下一个字(返回同一支笔的引用)
第二个字:
(同一支笔) << b继续用同一支笔写下b的内容
笔迹连贯,书写流畅
第三个字:
(同一支笔) << c还是用这支笔写下c的内容
所有字都是用同一支笔连贯写出的
意思就是返回你传入的输出流,可能是out,也可能是别的
用这个例子实际调用过程:
cout << "坐标:" << Point(1,2) << "结束";编译器会把他解释为
// 编译器看到的:
((cout << "坐标:") << Point(1,2)) << "结束";现在分步执行:
步骤1:cout << "坐标:"
这是内置的字符串输出操作
返回: cout的引用(这样我们才能继续使用它)
步骤2:(步骤1的结果) << Point(1,2)
步骤1返回了cout的引用,所以实际上是:cout << Point(1,2)
调用我们重载的运算符函数
关键: 这里必须返回cout的引用,否则下一步无法执行
步骤3:(步骤2的结果) << "结束"
如果步骤2返回了cout的引用,那么就是:cout << "结束"
如果步骤2没有返回值,这里就没有操作对象
如果返回的是副本(不加引用)...
// 错误版本:返回副本
ostream operator << (ostream out, const Point& p) {
out << p.toString();
return out; // 返回新的笔!
}基本上会报错的,如果允许运行拷贝了,输出可能乱序丢失或者根本看不到。
这就好比:
- 写第一个字后,把笔放下
- 换一支新笔来写第二个字
- 再换一支笔写第三个字
- 每换一次笔,笔迹可能不同,书写不连贯
- 效率低下,体验很差
总结:
参数中的引用 (
const Point&):- 主要目的:避免不必要的对象拷贝
- 使用场景:大型对象、需要保持原对象不被修改
- 关键优势:提高性能,减少内存占用
返回类型中的引用 (
ostream&):- 主要目的:支持链式调用
- 使用场景:流操作、赋值操作等需要连续调用的场景
- 关键优势:代码更简洁,表达力更强
问题3:运算符重载后会影响其他类型的运算吗?是不是后面的加号只能给Point类用了?
验证代码:
int main() {
Point a(1,2), b(3,4);
int x = 5, y = 6;
double m = 1.5, n = 2.5;
cout << a + b << endl; // 使用重载的Point加法
cout << x + y << endl; // 使用内置的int加法(不受影响!)
cout << m + n << endl; // 使用内置的double加法(不受影响!)
return 0;
}核心原理:
运算符重载是基于操作数类型的,C++会根据操作数的具体类型选择对应的运算符实现,这是一个增加功能而非替换功能的过程。
问题4:初始化列表 :x(x),y(y) 到底在做什么?
// 初始化列表方式(推荐)
Point(int x=0, int y=0):x(x),y(y) {}
// 传统赋值方式
Point(int x=0, int y=0) {
this->x = x; // 这是赋值,不是初始化!
this->y = y;
}优势对比:
- 性能更好:初始化列表直接初始化成员,避免先默认构造再赋值
- 必须使用的情况:const成员和引用成员必须在初始化列表中初始化
- 代码更清晰:明确区分初始化与赋值
价值
简洁
传统写法:
// 创建和操作Point
Point a, b, c;
a.x = 1; a.y = 2;
b.x = 3; b.y = 4;
c.x = a.x + b.x;
c.y = a.y + b.y;
printf("(%d,%d)\n", c.x, c.y);现代C++写法:
Point a(1,2), b(3,4);
cout << a + b << endl;代码量减少60%以上,逻辑更加清晰。
可维护性增强
当需要修改Point的输出格式时:
- 传统方式:需要找到所有
printf或cout语句逐个修改 - 重载方式:只需修改输出运算符重载函数,一处修改,全局生效
补充:深入解析ostream对象与引用传递
用笔写字的完整比喻
让我们继续用笔写字的比喻,但这次更深入细节:
cout = 你的右手(主笔)
ostream &out = 你借给别人的笔
return out = 别人把笔还给你详细解析代码
ostream& operator << (ostream &out, const Point& p) {
out << "(" << p.x << "," << p.y << ")";
return out;
}1. 为什么没出现cout也能输出?
关键理解: out 就是传入的"笔",它可能是cout,也可能是其他输出流!
比喻说明:
// 当你写:
cout << pointObj;
// 相当于:
// 1. 你把右手(cout)借给了operator<<函数
// 2. operator<<函数用你的右手写字
// 3. 然后把右手还给你继续使用实际调用过程:
int main() {
Point p(1,2);
// 编译器看到这行代码:
cout << p;
// 实际上转换为:
operator<<(cout, p); // 把cout作为第一个参数传入!
}2. 传入的参数是什么?从哪里来?
参数来源:
- 第一个参数
ostream &out:来自<<左边的ostream对象 - 第二个参数
const Point& p:来自<<右边的Point对象
调用链分析:
cout << "结果:" << pointA << "和" << pointB;
// 执行过程:
1. cout << "结果:" → 返回cout的引用
2. (返回的cout) << pointA → 调用我们的重载函数,传入cout和pointA
3. (返回的cout) << "和" → 返回cout的引用
4. (返回的cout) << pointB → 再次调用我们的重载函数,传入cout和pointB3. ostream的对象到底是什么?
ostream对象类型:
cout- 标准输出(控制台)cerr- 标准错误输出clog- 标准日志输出ofstream- 文件输出流ostringstream- 字符串输出流
代码演示多种ostream对象:
#include<iostream>
#include<fstream>
#include<sstream>
using namespace std;
struct Point {
int x, y;
Point(int x=0, int y=0):x(x),y(y) {}
};
// 同一个函数,可以用于各种"笔"!
ostream& operator << (ostream &笔, const Point& 点) {
笔 << "(" << 点.x << "," << 点.y << ")";
return 笔;
}
int main() {
Point p(3,4);
// 用不同的"笔"写同一个Point
cout << p << endl; // 写到屏幕
ofstream 文件笔("output.txt");
文件笔 << p << endl; // 写到文件
ostringstream 字符串笔;
字符串笔 << p; // 写到字符串
string 结果 = 字符串笔.str();
cerr << "错误信息: " << p << endl; // 写到错误输出
return 0;
}4. 返回的引用到底是什么?
返回的就是传入的那支"笔"!
ostream& operator << (ostream &笔, const Point& 点) {
笔 << "(" << 点.x << "," << 点.y << ")";
return 笔; // 返回的就是传入的那支笔!
}内存层面的理解:
// 假设cout在内存中的地址是0x1000
cout << pointA;
// 调用过程:
operator<<(cout, pointA)
// 传入:out是cout的引用(相当于cout的别名,都指向0x1000)
// 返回:还是指向0x1000的引用5. 完整的调用过程模拟
让我们模拟一个完整的链式调用:
cout << "点坐标: " << Point(1,2) << " 结束";
// 逐步分解:
步骤1: cout << "点坐标: "
→ 返回cout的引用(假设叫temp1)
步骤2: temp1 << Point(1,2)
→ 调用operator<<(temp1, Point(1,2))
→ 在函数内:temp1 << "(" << 1 << "," << 2 << ")"
→ 返回temp1的引用(还是同一个cout)
步骤3: (返回的cout) << " 结束"
→ 继续输出6. 为什么这个设计如此巧妙?
灵活性: 同一支"笔法"可用于各种"笔"
// 教过一次怎么写Point后:
cout << point; // 写到屏幕
文件流 << point; // 写到文件
网络流 << point; // 写到网络
字符串流 << point; // 写到字符串一致性: 自定义类型用起来和内置类型一样
int num = 42;
Point pt(1,2);
// 用法完全一致!
cout << "数字: " << num << endl;
cout << "坐标: " << pt << endl;总结
out参数:就是调用时<<左边的ostream对象(可能是cout,也可能是其他输出流)- 为什么能输出:因为
out就是传入的输出流,在函数内部用out来输出 - 返回的引用:就是传入的那个ostream对象本身,确保链式调用时用同一支"笔"
- 设计优势:让自定义类型的输出与内置类型完全一致,支持各种输出流
这种设计体现了C++的"抽象"和"泛型"思想:一次定义,到处使用!