C++_重载运算与类型转换
重载运算符与类型转换
这一章主要解决两个问题:
- 类对象如何像内置类型一样参与运算
- 类对象如何在不同类型之间转换
运算符重载:基本概念
重载运算符本质上是一个函数。
它的名字形式是:
1 | operator 运算符号 |
例如:
1 | operator+ |
和普通函数一样,重载运算符也有:
- 返回类型
- 参数列表
- 函数体
基本规则
重载运算符时要记住:
只能重载已有运算符
可以重载:
1 | +, -, *, ==, [], (), <<, >> |
但不能发明新运算符。
至少有一个运算对象是类类型
例如:
1 | int operator+(int, int); // 错误 |
因为不能改变纯内置类型运算符的含义。
不能改变运算符的:
- 运算对象个数
- 优先级
- 结合律
例如:
+还是二元运算符++还是一元/后置形式*优先级不会变
不能重载的运算符
下面 4 个不能重载:
1 | :: |
这是高频记忆点。
运算符调用本质
例如:
1 | a + b; |
若 + 被重载,本质等价于调用函数。
非成员形式:
1 | operator+(a, b); |
成员形式:
1 | a.operator+(b); |
成员运算符与非成员运算符
运算符重载函数可以是:
- 成员函数
- 非成员函数(通常可配合
friend)
成员函数时
左侧运算对象绑定到 this,因此参数少一个。
例如:
1 | class X { |
调用:
1 | a + b; |
等价于:
1 | a.operator+(b); |
这里:
- 左操作数
a绑定到this - 显式参数只有
b
非成员函数时
需要把左右运算对象都写成参数:
1 | X operator+(const X&, const X&); |
调用:
1 | operator+(a, b); |
什么时候作为成员,什么时候作为非成员
这是重载运算符非常重要的选择题。
必须是成员的运算符
以下 4 个必须是成员函数:
=[]()->
即:
- 赋值运算符
- 下标运算符
- 函数调用运算符
- 成员访问箭头运算符
通常应作为成员的运算符
一般建议作为成员:
+=-=*=/=++--*(解引用)->[]()
原因:
- 这些运算符通常会修改对象状态
- 或与对象内部表示密切相关
通常应作为非成员的运算符
通常建议作为非成员:
- 算术运算符:
+ - * / - 关系运算符:
== != < > <= >= - 位运算符
- 输入输出运算符:
<< >>
原因:
这些运算符常常要求左右运算对象地位对称
如果写成成员函数,左侧必须是本类对象;而写成非成员函数,左右两边都可参与类型转换,更灵活。
重载运算符的一般原则
不要违背原有语义
重载后的运算符应尽量保持和内置类型一致的直觉。
例如:
+应表示“求和/组合”,不应表示“比较大小”==应表示“相等性判断”[]应表示“下标访问”
尽量使相关运算符配套出现
例如:
- 有
==通常也要有!= - 有
<常也要考虑<= > >= - 有
+常也要有+=
优先实现复合赋值,再实现普通算术
常见写法:
1 | class X { |
好处:
- 避免代码重复
- 保持逻辑一致
不建议重载的运算符
虽然有些运算符可以重载,但通常不建议:
&&||,
因为重载后无法保留内置运算符的重要特性:
&& 和 ||
内置版本支持:
- 短路求值
例如:
1 | a && b |
若 a 为假,b 不会计算。
但重载后本质是函数调用,两个实参都会先求值,所以失去短路特性。
,
逗号运算符的求值顺序也难保留原语义。
所以一般不建议重载这些运算符。
输入输出运算符
输入输出运算符是最常考、也最常写的一类重载。
输出运算符 <<
通常形式:
1 | ostream& operator<<(ostream& os, const T& obj); |
为什么必须是非成员
因为左操作数是 ostream:
1 | cout << obj; |
若写成成员函数,就必须是 ostream 的成员,但我们不能给标准库类随意加成员。
所以:
<<对用户自定义类型通常必须写成非成员函数
参数形式为什么这样写
第一个参数:ostream&
- 输出流不能拷贝
- 输出会改变流状态
- 所以必须是非常量引用
第二个参数:const T&
- 避免拷贝
- 输出通常不应修改对象
返回值为什么是 ostream&
为了支持连续输出:
1 | cout << a << b << c; |
示例
1 | class Sales_data { |
输入运算符 >>
通常形式:
1 | istream& operator>>(istream& is, T& obj); |
参数特点
第一个参数:istream&
- 输入会改变流状态
- 流对象不能拷贝
第二个参数:T&
- 因为要把读入的数据写入对象
- 所以必须是非常量引用
返回值
一般返回读入流本身:
1 | return is; |
这样支持连续输入:
1 | cin >> a >> b >> c; |
输入运算符必须处理失败
与输出不同,输入可能失败,比如:
- 格式错误
- 到达文件尾
- 类型不匹配
因此输入运算符通常要:
- 读入临时变量
- 若成功,再写入对象
- 若失败,使对象进入合理状态
示例思路
1 | istream& operator>>(istream& is, Sales_data& item) { |
相等与关系运算符
常见:
==!=<><=>=
一般写成非成员
因为左右两侧通常应对称。
例如:
1 | bool operator==(const T&, const T&); |
设计原则
==
应比较对象的“值是否相等”
!=
通常基于 == 实现:
1 | bool operator!=(const T& lhs, const T& rhs) { |
< 等关系运算符
若定义了 <,通常要保证满足合理的排序语义,方便用于:
setmapsort
算术运算符
典型如:
+ - * / %
一般规则
复合赋值运算符作为成员
1 | T& operator+=(const T&); |
普通算术运算符作为非成员
1 | T operator+(T lhs, const T& rhs) { |
这里按值传 lhs,便于在副本上修改。
下标运算符 []
必须是成员函数。
形式常见为两种版本:
1 | class StrVec { |
为什么通常写两个版本
这样:
- 普通对象可修改元素
const对象只能读
例如:
1 | vec[0] = "hello"; // 非 const 版本 |
递增/递减运算符
包括:
- 前置
++x - 后置
x++
前置版本
形式:
1 | T& operator++(); |
特点:
- 先修改对象
- 再返回对象本身
后置版本
形式:
1 | T operator++(int); |
注意这个 int 参数只是为了与前置版本区分,调用时不传值。
特点:
- 先保存旧值
- 再修改对象
- 返回修改前的副本
示例
1 | class Counter { |
解引用与箭头运算符
operator*
常用于迭代器、智能指针类:
1 | T& operator*() const; |
operator->
必须是成员函数。
通常用于“像指针一样使用对象”。
例如:
1 | ptr->mem |
本质是:
1 | (ptr.operator->())->mem |
所以 operator-> 返回值通常是:
- 指针
- 或另一个重载了
operator->的对象
函数调用运算符 ()
函数调用运算符必须是成员函数。
如果一个类定义了 operator(),则该类对象称为:
函数对象(functor)
即:行为像函数的对象
基本形式
1 | class PrintString { |
使用:
1 | PrintString p; |
特点
- 一个类可以重载多个
operator() - 可像普通函数一样被调用
- 常用于算法、自定义谓词、回调等
类型转换:基本概念
这一部分是你原笔记里还没展开的重点,这里补全。
类型转换分两类:
- 构造函数定义的转换
- 类型转换运算符定义的转换
转换构造函数
如果一个构造函数:
- 只有一个实参
- 或者其他参数都有默认值
那么它可能定义一种从其他类型到本类类型的隐式转换
例如:
1 | class Sales_data { |
则可能允许:
1 | Sales_data item = string("abc"); |
即从 string 自动转换为 Sales_data。
隐式转换的风险
虽然方便,但也可能带来:
- 二义性
- 意外类型转换
- 难理解的错误
所以通常要慎用。
explicit:禁止隐式转换
如果不希望构造函数用于隐式转换,可加 explicit:
1 | class Sales_data { |
此时:
1 | Sales_data item = "abc"; // 错误 |
适用位置
explicit 常用于:
- 单参数构造函数
- 可能被误用的转换构造函数
类型转换运算符
类还可以定义:
从类类型转换为其他类型
形式:
1 | operator type() const; |
例如:
1 | class SmallInt { |
这样对象就可以转成 int:
1 | SmallInt s; |
特点
类型转换运算符:
- 没有返回类型
- 没有参数
- 名字就是
operator 目标类型
例如:
1 | operator bool() const; |
也可以用 explicit
为了防止滥用,也可以定义为显式转换:
1 | explicit operator bool() const; |
这样不能随意发生隐式转换,但可用于条件判断或显式强转。
例如:
1 | if (obj) { ... } // 通常允许 |
为什么常定义 operator bool
很多类希望表达“是否有效”的状态,例如:
- 输入流
- 智能指针
- 迭代器包装类
- 文件类
所以常定义:
1 | explicit operator bool() const; |
这样对象可用于:
1 | if (obj) { ... } |
避免二义性转换
当类同时有:
- 多个转换构造函数
- 多个类型转换运算符
时,可能出现编译器不知道该选哪个转换路径的问题。
例如:
A -> intA -> doubleint -> Bdouble -> B
就可能导致歧义。
复习时记住一句:
用户定义的类型转换越多,越容易出问题
所以设计时应尽量:
- 少定义隐式转换
- 优先用
explicit - 保持语义清晰
重载运算符与类型转换的关系
很多运算符重载会涉及类型转换。
例如:
1 | class Num { |
则:
1 | Num n(10); |
如果 + 是非成员函数,则左右两边都更容易发生转换,这也是为什么对称运算符常写成非成员。
常见设计建议
<< 和 >> 写成非成员
通常需要 friend 访问私有数据。
+ 基于 +=
1 | T& operator+=(const T&); |
== 和 != 配套
!= 常直接由 == 实现。
单参数构造函数慎用隐式转换
多数情况下考虑加 explicit。
类型转换运算符更要慎用
尤其避免定义很多种数值类型转换:
1 | operator int() |
容易引起歧义。
易错点总结
不是所有运算符都能重载
不能重载:
1 | :: .* . ?: |
= [] () -> 必须是成员函数
输入输出运算符通常必须是非成员函数
重载 && 和 || 后没有短路求值
所以一般不建议重载。
运算符重载不会改变优先级和结合律
至少有一个运算对象必须是类类型
不能改内置类型运算符行为。
operator type() 没有返回类型
例如:
1 | operator int() const; |
不是:
1 | int operator int() const; // 错 |
explicit 可用于转换构造函数和类型转换运算符
用于阻止隐式转换。
对称运算符通常写成非成员更合理
例如:
+==<
下标运算符通常要写 const 和非 const 两个版本
小结
这一章的主线可以概括为:
- 运算符重载本质上是特殊名字的函数
- 有些运算符必须是成员,如:
= [] () ->
- 有对称性的运算符通常写成非成员
<<和>>常用于类的输入输出支持operator()使对象变成函数对象- 单参数构造函数可定义“其他类型 -> 类类型”的转换
- 类型转换运算符可定义“类类型 -> 其他类型”的转换
explicit用于防止危险的隐式转换
注
运算符重载让类“像内置类型一样用”,类型转换让类“像别的类型一样转”,但两者都要谨慎设计,避免语义混乱和隐式转换歧义。




