重载
核心机制
操作符重载的本质是静态多态。它不是改变了操作符的优先级或结合性,而是让编译器将表达式映射为函数调用。
底层映射原理
当我们在代码中写表达式时,编译器会进行如下转换:
-
成员函数形式:
c = a + b; $\rightarrow$ c = a.operator+(b);
- 原理:左操作数
a通过this指针隐式传递,右操作数b作为参数传递。
- 原理:左操作数
-
全局(友元)函数形式:
c = a + b; $\rightarrow$ c = operator+(a, b);
- 原理:两个操作数都作为显式参数传递。
两种实现路径
对于允许重载的操作符,我们只有两条路可选 :
- 类成员函数:
- 格式:
Obj.operator op (Args) - 适用:赋值
=、下标[]、调用()等。
- 格式:
- 全局(友元)函数:
- 格式:
operator op (Obj, Args) - 适用:需要满足交换律的二元运算(如
10 + a),或左操作数不可修改(如cout << a)。
- 格式:
操作符重载时参数至少包含一个用户自定义类型(new delete 除外)
成员函数 全局函数
这是一个常见的设计难点,不仅是语法区别,更关乎对称性。
- 场景:假设有一个复数类
Complex。 - 问题:
Complex a(1, 2);a + 10;// 正常,编译器看作a.operator+(10)10 + a;// 报错! 整数10是内置类型,没有成员函数operator+。
- 解决方案:必须使用全局友元函数来实现
operator+(int, Complex)。
代码样例:
class Complex {
int real, imag;
public:
Complex(int r, int i) : real(r), imag(i) {}
// 方案1:成员函数(只能处理 c + int)
Complex operator+(const Complex& rhs) {
return Complex(real + rhs.real, imag + rhs.imag);
}
// 方案2:友元全局函数(处理 int + c,赋予整数“主动权”)
friend Complex operator+(int lhs, const Complex& rhs);
};
// 全局函数实现
Complex operator+(int lhs, const Complex& rhs) {
return Complex(lhs + rhs.real, rhs.imag);
}
禁止重载的操作符
以下四个操作符涉及 C++ 最底层的对象访问和流程控制,编译器严禁程序员修改其行为,否则会导致语言的基本语法崩塌 。
.(成员访问):obj.member。如果重载了它,编译器就无法确定你是要访问成员还是调用函数。.\*(成员指针访问):这是一个非常底层的操作符,用于通过“类成员指针”来调用函数。::(域解析):Class::static_member。这是编译期确定的符号,运行时无法重载。?:(三元条件运算符):这是 C++ 唯一的三元操作符,且涉及控制流跳转(短路特性),重载会破坏其逻辑。
深度解析:冷门的 .* 操作符
PPT 右侧的代码 (class A ...) 并不是在演示如何重载,而是在演示 .\* 到底原本是干什么用的。这是一个很少见但在底层框架(如 MFC、Qt)中常用的语法。
代码还原与解读:
class A {
public:
void f() { ... }
};
// 定义一个“指针”,它不仅仅指向内存地址,还限定了必须是 A 类的成员函数
void (A::*p_f)();
// 让指针指向 A 类的函数 f
p_f = &A::f;
A a;
// 核心用法:通过对象 a,调用指针 p_f 指向的函数
(a.*p_f)();
- 含义:
.*的作用是“解引用成员指针并绑定到具体对象上”。 - 结论:这个操作太底层且依赖对象模型,因此禁止重载。
可以重载但是不要重载的操作符
逻辑与 (&&) 和 逻辑或 (||)
- 原生行为:具有短路求值 (Short-circuit Evaluation) 特性。
- 例如
if (p != nullptr && p->value > 0),如果p为空,后面根本不会执行,程序安全。
- 例如
- 重载后行为:变成了普通的函数调用。
operator&&(expression1, expression2)- C++ 规定函数调用前必须先计算所有参数。这意味着即使
p为空,p->value也会被执行,导致程序崩溃。
- 结论:永远不要重载
&&和||。
重载的“基本法”(三不准原则)
虽然我们可以自定义操作符的逻辑(加法变减法),但不能改变编译器解析代码的方式。必须严格遵守以下“宪法”:
- 不准改变元数 (Arity) :
- 单目运算符(如
++)重载后必须还是单目。 - 双目运算符(如
+)重载后必须还是双目。你不能创造一个operator+(a, b, c)。
- 单目运算符(如
- 不准改变优先级 (Precedence) :
- 无论怎么重载,
*(乘除)永远比+(加减)先计算。编译器在生成语法树时就已经定好了。
- 无论怎么重载,
- 不准改变结合性 (Associativity) :
- 赋值符
=永远是从右向左算 (a=b=c),加法+永远是从左向右算。这也不能改。
- 赋值符
返回方式
原则:不要在 operator+ / operator* 中返回引用或指针
当我们需要返回一个运算结果(如两数之和)时,必须返回对象本身 (Return by Value)。
常见错误分析:
- 在堆上
new:- 写法:
Rational* p = new ...; return *p; - 后果:内存泄漏。调用者无法获取隐藏的指针来释放内存。
- 写法:
- 使用
static静态变量:- 写法:
static Rational res; ... return res; - 后果:逻辑错误。所有运算共享同一个静态对象,连续运算(
a*b == c*d)时,后计算的值会覆盖前面的值,导致比较永远为真。
- 写法:
正确做法: return Rational(...);
利用构造函数生成临时对象返回。现代编译器通过 RVO (返回值优化) 技术可以消除拷贝开销。
正确写法:返回临时对象 (Return by Value)
return Rational(n*r.n, d*r.d);
错误写法 A:想省拷贝,结果搞出内存泄漏
Rational *result = new Rational(n*r.n, d*r.d);
return *result;
- 谁负责
delete?- 当你写
w = x * y * z;时,x*y产生了一个new出来的对象,... * z又产生了一个。这些中间产生的对象不仅没名字,还拿不到指针,根本没法释放。 - 结论:Memory Leak (内存泄漏)。这行代码会导致程序越跑内存占用越高,直到崩溃。
- 当你写
错误写法 B:想省内存,结果逻辑错乱
static Rational result;
result.n = ...;
return result;
if ((a * b) == (c * d)) { ... }
- 计算
a * b,结果存入static result(比如结果是 10)。 - 计算
c * d,结果覆盖入static result(比如结果是 20)。 - 比较操作符
==拿到两边的东西。左边是result(现在是 20),右边也是result(还是 20)。 - 结果永远相等! 哪怕
a*b不等于c*d,这个判断也会通过。
单目操作符
前置 ++ vs 后置 ++
为了区分 ++a 和 a++,C++ 规定后置版本多一个 int 哑参数。
-
前置
++a:- 逻辑:先自增,再返回引用。
- 效率:高(不产生临时对象)。
-
后置
a++:- 逻辑:先保存旧值(临时对象),自增,返回旧值。
- 效率:低(多了一次对象的构造和析构)。
- 代码实现:
// 前置 ++ Counter& operator++() { value++; return *this; } // 后置 ++ (int 是标记) Counter operator++(int) { Counter temp = *this; // 记录旧值(性能损耗点) value++; // 自增 return temp; // 返回旧值 } -
结论:在迭代器或复杂对象操作中,优先习惯使用前置
++it。
operator=
这是 C++中最容易导致程序崩溃和内存泄漏的地方。默认的赋值操作符只做浅拷贝(逐字节复制),一旦类中管理了指针资源,浅拷贝会导致两个对象指向同一块内存,析构时发生 Double Free。
编写健壮的 operator= 必须遵循以下四个步骤,缺一不可:
- 证同测试 (Identity Test):检查是否是自己赋值给自己 (
a = a)。- 底层原因:如果不检查,第2步会把自己的数据删掉。等到第3步想拷贝源数据时,发现源数据(就是自己)已经是垃圾值了。
- 释放旧资源:防止内存泄漏。
- 分配新资源并拷贝:实现深拷贝。
- 返回
*this:为了支持链式赋值a = b = c。
标准实现代码:
class String {
char* data;
public:
String& operator=(const String& rhs) {
// 1. 证同测试 (Self-assignment check)
if (this == &rhs) {
return *this;
}
// 2. 释放旧资源 (防止 Memory Leak)
delete[] data;
// 3. 深拷贝 (Deep Copy)
if (rhs.data) {
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
} else {
data = nullptr;
}
// 4. 返回引用 (支持 a = b = c)
return *this;
}
};
operator[]
C++ 的 operator[] 有一个硬伤:它只接受一个参数。那么 matrix[3][4] 这种多维数组语法是如何实现的?
核心矛盾与代理模式
matrix[3]必须返回一个对象,这个对象代表“第3行”。- 然后对这个“行对象”再次调用
[4],才能拿到具体的元素。 - 这就是 Proxy Pattern(代理模式)。
代码实现逻辑
class Matrix {
int* data;
int cols;
public:
// 内部代理类:代表“一行数据”
class RowProxy {
int* rowPtr;
public:
RowProxy(int* ptr) : rowPtr(ptr) {}
// 第二次调用 []:在“行”中取“列”
int& operator[](int c) {
return rowPtr[c];
}
};
// 第一次调用 []:返回一个“行代理”对象
RowProxy operator[](int r) {
return RowProxy(data + r * cols);
}
};
// 调用链解析:
// m[1][2]
// 1. m.operator[](1) -> 返回 RowProxy 对象 (指向第1行起始)
// 2. RowProxy.operator[](2) -> 返回 int&
operator()
C++ 通过重载 () 让对象可以像函数一样被调用,这被称为仿函数 (Functor)。这是 C++ 闭包和 Lambda 表达式的基石。
- 普通函数:只有代码,没有状态(除非用全局变量,但不安全)。
- 仿函数:是类对象,可以拥有成员变量(携带状态)。
函数对象
| 特性 | 函数指针 (Function Pointer) | 函数对象 (Function Object) |
|---|---|---|
| 状态保存 | 无法保存状态。 函数只是代码段的地址,没有数据成员,除非用全局变量(不安全)。 | 可以携带状态。 作为类对象,它可以拥有成员变量(如 factor),通过构造函数初始化,根据不同的状态表现出不同的行为。 |
| 执行效率 | 较低。 编译器难以进行内联优化(Inline),因为指针指向哪里通常是运行时才确定的,必须进行间接调用。 | 极高。 编译器在编译期就知道要调用哪个类的 operator(),可以直接将代码内联展开,消除了函数调用的开销。 |
class Multiply {
int factor; // 【状态】成员变量,用来保存倍数
public:
// 构造函数:初始化状态
Multiply(int f) : factor(f) {}
// 【核心】重载 (),让对象像函数一样被调用
int operator()(int x) const {
return x * factor;
}
};
// 1. 创建两个不同的函数对象,携带不同的状态
Multiply times2(2); // 这个对象负责 "乘以 2"
Multiply times5(5); // 这个对象负责 "乘以 5"
// 2. 像调用函数一样调用对象
cout << times2(10); // 输出 20 (10 * 2)
cout << times5(10); // 输出 50 (10 * 5)
Lambda 表达式:函数对象的“语法糖”
Lambda 表达式在 C++11 引入,它让我们可以就地定义匿名函数对象,而不需要像上面那样专门写一个 class。
- 映射关系:
- 数学表达:$\lambda x. x + 1$ (定义一个把 $x$ 映射为 $x+1$ 的函数)。
- C++ 表达:
[](int x) { return x + 1; }。 - 调用:$(\lambda x. x + 1)(3)$ $\rightarrow$
[](int x){ return x + 1; }(3),结果为5
| 捕获语法 | 含义 | 底层行为 |
|---|---|---|
[] |
不捕获任何变量。 | 生成的类没有成员变量。 |
[&] |
引用捕获所有外部变量。 | 生成的类成员变量全是引用(如 int&)。效率高,但需警惕悬空引用。 |
[=] |
值拷贝捕获所有外部变量。 | 生成的类成员变量全是副本。安全,但拷贝有开销。 |
[=, &foo](foo是任意自定义对象名) |
混合模式:默认全值拷贝,唯独 foo 用引用。 |
针对大对象 foo 单独优化,避免拷贝。 |
[bar](bar是任意自定义对象名) |
显式模式:只值拷贝 bar,不捕获其他任何变量。 |
推荐写法,明确依赖关系。 |
Lambda 表达式示例:通用字符串过滤器
这个例子的目标是:编写一个通用的过滤函数 str_filter,它可以根据调用者传入的“任意规则”,筛选出符合条件的字符串。
1.定义通用接口 (std::function)
首先,利用 std::function 定义了一个可以接受“任意判断逻辑”的函数接口:
// matched 是一个可调用对象,接受 string&,返回 bool
vector<string> str_filter(vector<string> &vec, function<bool(string &)> matched) {
vector<string> result;
for (string tmp : vec) {
// 【多态调用】这里调用传入的逻辑,不管是函数指针还是 Lambda
if (matched(tmp)) {
result.push_back(tmp);
}
}
return result;
}
- 亮点:
str_filter不需要知道具体的过滤规则(是查长度?还是查内容?),它把规则判断权交给了参数matched。
2.Lambda 捕获外部变量
在 main 函数中,我们需要筛选出包含 ".com" 的网址。
int main(){
vector<string> vec = {"www.baidu.com", "www.kernel.org", "www.google.com"};
string pattern = ".com"; // 【外部变量】这是要在 Lambda 内部使用的
// 调用过滤器,现场定义筛选规则
vector<string> filterd = str_filter(vec,
[&](string &str) { // 【核心点】捕获列表 [&]
// 如果不捕获,这里根本访问不到 pattern 变量!
if (str.find(pattern) != string::npos)
return true;
return false;
}
);
}
在这个 Lambda 表达式中:
- 代码逻辑:
str.find(pattern)。 - 问题:
pattern定义在 Lambda 函数体之外(在main函数栈上)。普通的函数无法直接访问外部的局部变量。 - 解决方案:使用捕获列表
[&]。[&]的含义是:以引用方式捕获作用域内的所有变量。- 这意味着 Lambda 内部的
pattern实际上就是main函数里那个pattern的引用。 - 注:如果没有
[&](即写成[]),编译器会报错,提示pattern未定义。
std::function可存储
- 普通函数 (Ordinary Function)
- 函数指针 (Function Pointer)
- 类成员函数 (Member Function)
- 函数对象 / 仿函数 (Functor)
- Lambda 表达式 (Lambda Expression)
同一个变量,可以动态改变,存储完全不同类型的逻辑。
#include <functional>
#include <iostream>
using namespace std;
// 1. 普通函数
void hello() { cout << "Hello (Function)" << endl; }
// 2. 仿函数
struct HelloFunctor {
void operator()() { cout << "Hello (Functor)" << endl; }
};
int main() {
// 定义一个通用的函数包装器:不接受参数,返回 void
std::function<void()> func;
// A. 存普通函数
func = hello;
func(); // 输出: Hello (Function)
// B. 存 Lambda 表达式
func = []() { cout << "Hello (Lambda)" << endl; };
func(); // 输出: Hello (Lambda)
// C. 存仿函数对象
HelloFunctor f;
func = f;
func(); // 输出: Hello (Functor)
return 0;
}
std::function 的底层原理:类型擦除
- 问题:如何统一存储不同类型的可调用对象?
- 方案:利用运行时多态 (Runtime Polymorphism)。
- 内部持有一个抽象基类指针 (
_Base*)。 - 具体的函数对象被包装在一个模板子类 (
_Model<F>) 中。 _Model<F>继承自_Base,从而“擦除”了具体类型F的差异,对外只暴露_Base接口。
- 内部持有一个抽象基类指针 (
- 代价:每次调用
std::function都会产生一次虚函数调用(Indirect Call),这就是为什么它比直接调用函数指针稍微慢一点点的原因。

operator <基本数据类型>基本数据类型>
类型转换运算符
- 目的:允许自定义的类(Class)像基本数据类型(如
int,double,bool)一样被使用。 - 语法:
operator 类型名() { ... }- 注意:这种函数没有返回类型(因为类型名本身就暗示了返回类型),并且通常是
public的。
- 注意:这种函数没有返回类型(因为类型名本身就暗示了返回类型),并且通常是
class Rational {
public:
// 构造函数:初始化分子 n 和分母 d
Rational(int n1, int n2) { n = n1; d = n2; }
// 【关键代码】类型转换操作符重载
// 这行代码定义了如何将 Rational 对象转换成 double 类型
operator double() { return (double)n/d; }
private:
int n, d; // 分子和分母
};
- 含义:当你把一个
Rational对象放到需要double的地方时,编译器会自动调用operator double(),计算n/d并返回结果。
Rational r(1, 2); // 创建一个对象 r,代表分数 1/2
double x = r; // 【隐式转换】r 自动变成了 double 类型 (0.5) 赋值给 x
x = x + r; // 【混合运算】这里 r 再次自动变成 0.5,与 x 相加
- 如果没有这个重载,写
double x = r;编译器会报错,因为它不知道怎么把“对象”变成“数字”。
“减少混合计算中需要定义的操作符重载函数的数量”
- 如果不写类型转换:如果你想让分数和整数相加、分数和浮点数相加,你可能需要分别重载
operator+(Rational, int)、operator+(Rational, double)等一大堆函数。 - 写了类型转换:编译器会自动把
Rational变成double,然后利用系统自带的加减法进行计算。这样你就不用写那么多数学运算重载函数了。
ostream f("abc.txt");
if (f) ....
这是一个常见的实际应用场景(文件流)。
f是一个文件对象。if(f)之所以能成立,是因为ostream类重载了类似operator bool()(或void*) 的转换操作符。它允许文件对象直接像布尔值一样被判断(用来检查文件是否成功打开)。
这张 PPT 紧接上一页,深入讲解了类型转换操作符中的一个重要关键字:explicit(显式)。
它的核心目的是:防止编译器在用户毫不知情的情况下,自动乱做类型转换,从而避免歧义和错误。
class A {
public:
operator int() { return 1; } // 【隐式转换】允许自动转成 int
explicit operator double() { return 2.0; } // 【显式转换】必须强制转换才能转成 double
};
operator int(): 这是一个普通的转换函数。当代码需要一个int时,编译器会自动把对象A变成1。explicit operator double(): 这是一个受限制的转换函数。关键字explicit告诉编译器:“除非程序员明确写出来要转成 double,否则绝对不要自动转”。
A a;
cout << a + 0; // 该转成 int 还是 double?
答案是:转成 int。
- 原因:
- 在计算
a + 0时,编译器需要把a转换成一个能和0(int) 进行加法运算的数字。 - 虽然类里定义了
double的转换,但因为它被标记为explicit,所以编译器在进行这种“自动(隐式)转换”查找时,直接忽略了它。 - 编译器只看到了
operator int()是可用的。 - 因此,
a变成了1,结果是1 + 0 = 1。
- 在计算
cout << static_cast<double>(a) + 0;
- 如果你非要用那个
return 2.0的逻辑,你就必须使用强制类型转换(如static_cast)。 - 这相当于你在对编译器说:“我知道我在做什么,请强制把它转成 double。”
- 这时候
operator double()就会被调用,a变成2.0,结果输出就是2。
指针访问操作符 -> 的重载。
让一个普通的对象,表现得像一个指针一样。
普通的重载(比如 +)是算出结果就结束了。但 -> 操作符有一种特殊的“钻头”属性:它会一直向下钻,直到找到一个真正的原生指针为止。
- 你写代码:
a->f() - 编译器自动把它翻译成:
( a.operator->() )->f()
这意味着:
当你对对象 a 使用 -> 时,编译器会调用 a 内部定义的 operator->() 函数。这个函数必须返回一个指针(或者另一个重载了 -> 的对象),然后编译器会继续用 -> 去操作那个返回回来的指针。
代码实例详解
第一步:定义一个普通的类 CPen (笔)
class CPen {
int m_color;
// ... 其他属性
public:
void setColor(int c) { m_color = c; } // 设置颜色的函数
// ...
};
这是一个很普通的类,里面有 setColor 方法。
第二步:定义一个包装类 CPanel (面板)
class CPanel {
CPen m_pen; // 【关键】它内部私藏了一支笔
// ...
public:
// 【核心重载函数】
CPen* operator->() {
return &m_pen; // 返回内部那支笔的地址(指针)
}
};
这里是魔法发生的地方。CPanel 重载了 ->,它的逻辑是:“如果你对我用箭头,我就把我的内部成员 m_pen 的地址给你”。
实际调用
CPanel c; // c 是一个对象,不是指针!
c->setColor(16); // 竟然可以像指针一样用箭头调用函数?
为什么能这么写?编译器背后的动作是:
- 看到
c->,编译器去查CPanel类,发现重载了operator->。 - 执行
c.operator->(),这个函数返回了&m_pen(即内部那支笔的指针CPen*)。 - 现在代码变成了:
(CPen*)->setColor(16)。 - 这既然是一个真正的指针,就可以正常调用
setColor了。
最终效果:
表面上你在“戳”面板(CPanel),实际上是面板把这个操作转发给了它内部的笔(CPen)。
应用场景
Smart Pointer (智能指针)。
这是 C++ 高级编程极其重要的概念。想象一下,如果 operator->() 里面不仅仅是返回指针,还顺便做了一些其他事情:
- 比如检查空指针:防止程序崩溃。
- 比如引用计数:记录有多少人在用这个指针,没人用时自动删除(防止内存泄漏)。
重载 -> 的目的是为了“代理”或“转发”。
当你写 c->func() 时,你实际上是在操作 c 内部持有的另一个对象的指针。这让对象 c 用起来像个指针,但比普通指针更强大(因为你可以在转发过程中夹带私货,做额外的管理工作)。
new delete 重载
通过重载new和delete来实现程序自身管理内存,即申请一块较大的内存,针对该内存自己管理存储分配
重载的new和delete为静态成员,且遵循类的控制访问,可继承
重载 new
- 函数签名:
void *operator new(size_t size, ...)- 返回类型
void*:因为new只负责给出一块“生肉”(原始内存地址),它不管上面存什么。 - 第一个参数
size_t size:这是必须的。当你写new MyClass()时,编译器会自动算出MyClass需要多少字节,并传给这个参数。
- 返回类型
- 关键点:
- 这些函数默认是静态成员 (static)(即使你不写 static),因为在创建对象之前,对象还不存在,必须通过类来调用。
- 一旦你写了这个函数,以后创建这个类的对象时,就会跑你的代码,而不是系统默认的代码。
三种 operator new 的重载版本,主要区别在于参数列表的不同,而这些参数决定了它们各自独特的用途。
- 标准版本 (Standard New)
代码: void* operator new(std::size_t size)
- 参数
size_t size代表什么?- 这是编译器自动计算出的、你的对象所占用的字节数。
- 比如你写
new int,编译器会传4进去;如果你写new MyClass,编译器会算出MyClass的大小传进去。
- 用途:
- 这是最普通的用法。
- 它负责去堆(Heap)里找一块大小为
size的空地,返回地址。 - 如果找不到(内存满了),它会抛出异常 (
std::bad_alloc),导致程序崩溃或跳转到 catch 块。
- 定位 New (Placement New) —— 最特殊的一个
代码: void* operator new(std::size_t size, void* ptr)
-
参数
void* ptr代表什么?- 这是你手动提供的一个内存地址。
- 它的意思是:“不要去堆里申请新内存了,就用我给你的这一块现成的地 (
ptr)。”
-
第一个参数
size还有用吗?- 虽然函数声明里必须有它,但在
Placement New的逻辑里,它通常没用。因为地皮已经有了,不需要再申请大小了。
- 虽然函数声明里必须有它,但在
-
怎么调用?
- 你必须显式地把地址传进去:
char buffer[100]; // 我自己准备了一块地(在栈上或预先分配的池里) MyClass* p = new (buffer) MyClass(); // 把对象建在 buffer 这块地上
- 不抛异常的 New (Nothrow New)
代码: void* operator new(std::size_t size, const std::nothrow_t&)
-
参数
const std::nothrow_t&代表什么?- 这是一个占位符(Tag),或者叫“暗号”。
- 这个参数本身没有实际数据意义,它的存在只是为了触发函数重载。
- 当你传了这个参数,编译器就会知道:“哦,你想要的是那个不报错的版本。”
-
用途:
- 主要用于不能容忍程序因为内存不足而崩溃(抛出异常)的场合(比如嵌入式或老旧系统)。
- 如果内存分配失败,它不抛异常,而是静悄悄地返回一个空指针 (
nullptr)。
-
怎么调用?
// 显式传入 std::nothrow 这个“暗号” MyClass* p = new (std::nothrow) MyClass(); if (p == nullptr) { // 只能在这里手动检查是否成功 // 处理内存不足的情况 }
重载 delete
如果你重载了 new,你就必须重载 delete
-
函数签名:
void operator delete(void *p, size_t size) -
参数:
void *p:要销毁的对象的地址。size_t size(可选):对象的大小。
delete的重载只能有一个 new可以有多个