重载运算符与类型转换

这一章主要解决两个问题:

  1. 类对象如何像内置类型一样参与运算
  2. 类对象如何在不同类型之间转换

运算符重载:基本概念

重载运算符本质上是一个函数

它的名字形式是:

1
operator 运算符号

例如:

1
2
3
4
operator+
operator==
operator[]
operator()

和普通函数一样,重载运算符也有:

  • 返回类型
  • 参数列表
  • 函数体

基本规则

重载运算符时要记住:

只能重载已有运算符

可以重载:

1
+, -, *, ==, [], (), <<, >>

不能发明新运算符

至少有一个运算对象是类类型

例如:

1
int operator+(int, int);   // 错误

因为不能改变纯内置类型运算符的含义。

不能改变运算符的:

  • 运算对象个数
  • 优先级
  • 结合律

例如:

  • + 还是二元运算符
  • ++ 还是一元/后置形式
  • * 优先级不会变

不能重载的运算符

下面 4 个不能重载:

1
2
3
4
::
.*
.
?:

这是高频记忆点。

运算符调用本质

例如:

1
a + b;

+ 被重载,本质等价于调用函数。

非成员形式:

1
operator+(a, b);

成员形式:

1
a.operator+(b);

成员运算符与非成员运算符

运算符重载函数可以是:

  • 成员函数
  • 非成员函数(通常可配合 friend

成员函数时

左侧运算对象绑定到 this,因此参数少一个。

例如:

1
2
3
4
class X {
public:
X operator+(const X&) const;
};

调用:

1
a + b;

等价于:

1
a.operator+(b);

这里:

  • 左操作数 a 绑定到 this
  • 显式参数只有 b

非成员函数时

需要把左右运算对象都写成参数:

1
X operator+(const X&, const X&);

调用:

1
operator+(a, b);

什么时候作为成员,什么时候作为非成员

这是重载运算符非常重要的选择题。

必须是成员的运算符

以下 4 个必须是成员函数

  • =
  • []
  • ()
  • ->

即:

  • 赋值运算符
  • 下标运算符
  • 函数调用运算符
  • 成员访问箭头运算符

通常应作为成员的运算符

一般建议作为成员:

  • +=
  • -=
  • *=
  • /=
  • ++
  • --
  • *(解引用)
  • ->
  • []
  • ()

原因:

  • 这些运算符通常会修改对象状态
  • 或与对象内部表示密切相关

通常应作为非成员的运算符

通常建议作为非成员:

  • 算术运算符:+ - * /
  • 关系运算符:== != < > <= >=
  • 位运算符
  • 输入输出运算符:<< >>

原因:

这些运算符常常要求左右运算对象地位对称

如果写成成员函数,左侧必须是本类对象;而写成非成员函数,左右两边都可参与类型转换,更灵活。

重载运算符的一般原则

不要违背原有语义

重载后的运算符应尽量保持和内置类型一致的直觉。

例如:

  • + 应表示“求和/组合”,不应表示“比较大小”
  • == 应表示“相等性判断”
  • [] 应表示“下标访问”

尽量使相关运算符配套出现

例如:

  • == 通常也要有 !=
  • < 常也要考虑 <= > >=
  • + 常也要有 +=

优先实现复合赋值,再实现普通算术

常见写法:

1
2
3
4
5
6
7
8
9
class X {
public:
X& operator+=(const X&);
};

X operator+(X lhs, const X& rhs) {
lhs += rhs;
return lhs;
}

好处:

  • 避免代码重复
  • 保持逻辑一致

不建议重载的运算符

虽然有些运算符可以重载,但通常不建议

  • &&
  • ||
  • ,

因为重载后无法保留内置运算符的重要特性:

&&||

内置版本支持:

  • 短路求值

例如:

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
2
3
4
5
6
7
8
9
10
class Sales_data {
friend ostream& operator<<(ostream& os, const Sales_data& item);
public:
// ...
};

ostream& operator<<(ostream& os, const Sales_data& item) {
os << item.isbn << " " << item.units_sold;
return os;
}

输入运算符 >>

通常形式:

1
istream& operator>>(istream& is, T& obj);

参数特点

第一个参数:istream&

  • 输入会改变流状态
  • 流对象不能拷贝

第二个参数:T&

  • 因为要把读入的数据写入对象
  • 所以必须是非常量引用

返回值

一般返回读入流本身:

1
return is;

这样支持连续输入:

1
cin >> a >> b >> c;

输入运算符必须处理失败

与输出不同,输入可能失败,比如:

  • 格式错误
  • 到达文件尾
  • 类型不匹配

因此输入运算符通常要:

  1. 读入临时变量
  2. 若成功,再写入对象
  3. 若失败,使对象进入合理状态

示例思路

1
2
3
4
5
6
7
8
9
istream& operator>>(istream& is, Sales_data& item) {
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
if (is)
item.revenue = item.units_sold * price;
else
item = Sales_data(); // 读入失败,置为默认状态
return is;
}

相等与关系运算符

常见:

  • ==
  • !=
  • <
  • >
  • <=
  • >=

一般写成非成员

因为左右两侧通常应对称。

例如:

1
bool operator==(const T&, const T&);

设计原则

==

应比较对象的“值是否相等”

!=

通常基于 == 实现:

1
2
3
bool operator!=(const T& lhs, const T& rhs) {
return !(lhs == rhs);
}

< 等关系运算符

若定义了 <,通常要保证满足合理的排序语义,方便用于:

  • set
  • map
  • sort

算术运算符

典型如:

  • + - * / %

一般规则

复合赋值运算符作为成员

1
T& operator+=(const T&);

普通算术运算符作为非成员

1
2
3
4
T operator+(T lhs, const T& rhs) {
lhs += rhs;
return lhs;
}

这里按值传 lhs,便于在副本上修改。

下标运算符 []

必须是成员函数。

形式常见为两种版本:

1
2
3
4
5
class StrVec {
public:
string& operator[](size_t n);
const string& operator[](size_t n) const;
};

为什么通常写两个版本

这样:

  • 普通对象可修改元素
  • const 对象只能读

例如:

1
2
vec[0] = "hello";     // 非 const 版本
cout << cvec[0]; // const 版本

递增/递减运算符

包括:

  • 前置 ++x
  • 后置 x++

前置版本

形式:

1
2
T& operator++();
T& operator--();

特点:

  • 先修改对象
  • 再返回对象本身

后置版本

形式:

1
2
T operator++(int);
T operator--(int);

注意这个 int 参数只是为了与前置版本区分,调用时不传值。

特点:

  • 先保存旧值
  • 再修改对象
  • 返回修改前的副本

示例

1
2
3
4
5
class Counter {
public:
Counter& operator++(); // 前置
Counter operator++(int); // 后置
};

解引用与箭头运算符

operator*

常用于迭代器、智能指针类:

1
T& operator*() const;

operator->

必须是成员函数。

通常用于“像指针一样使用对象”。

例如:

1
ptr->mem

本质是:

1
(ptr.operator->())->mem

所以 operator-> 返回值通常是:

  • 指针
  • 或另一个重载了 operator-> 的对象

函数调用运算符 ()

函数调用运算符必须是成员函数。

如果一个类定义了 operator(),则该类对象称为:

函数对象(functor)

即:行为像函数的对象

基本形式

1
2
3
4
5
6
class PrintString {
public:
void operator()(const string& s) const {
cout << s << endl;
}
};

使用:

1
2
PrintString p;
p("hello");

特点

  • 一个类可以重载多个 operator()
  • 可像普通函数一样被调用
  • 常用于算法、自定义谓词、回调等

类型转换:基本概念

这一部分是你原笔记里还没展开的重点,这里补全。

类型转换分两类:

  1. 构造函数定义的转换
  2. 类型转换运算符定义的转换

转换构造函数

如果一个构造函数:

  • 只有一个实参
  • 或者其他参数都有默认值

那么它可能定义一种从其他类型到本类类型的隐式转换

例如:

1
2
3
4
class Sales_data {
public:
Sales_data(const string& s);
};

则可能允许:

1
Sales_data item = string("abc");

即从 string 自动转换为 Sales_data

隐式转换的风险

虽然方便,但也可能带来:

  • 二义性
  • 意外类型转换
  • 难理解的错误

所以通常要慎用。

explicit:禁止隐式转换

如果不希望构造函数用于隐式转换,可加 explicit

1
2
3
4
class Sales_data {
public:
explicit Sales_data(const string& s);
};

此时:

1
2
Sales_data item = "abc";   // 错误
Sales_data item("abc"); // 正确

适用位置

explicit 常用于:

  • 单参数构造函数
  • 可能被误用的转换构造函数

类型转换运算符

类还可以定义:

从类类型转换为其他类型

形式:

1
operator type() const;

例如:

1
2
3
4
class SmallInt {
public:
operator int() const { return val; }
};

这样对象就可以转成 int

1
2
SmallInt s;
int i = s;

特点

类型转换运算符:

  • 没有返回类型
  • 没有参数
  • 名字就是 operator 目标类型

例如:

1
2
3
operator bool() const;
operator int() const;
operator string() const;

也可以用 explicit

为了防止滥用,也可以定义为显式转换:

1
explicit operator bool() const;

这样不能随意发生隐式转换,但可用于条件判断或显式强转。

例如:

1
2
3
if (obj) { ... }        // 通常允许
bool b = obj; // 可能不允许
bool b = static_cast<bool>(obj); // 允许

为什么常定义 operator bool

很多类希望表达“是否有效”的状态,例如:

  • 输入流
  • 智能指针
  • 迭代器包装类
  • 文件类

所以常定义:

1
explicit operator bool() const;

这样对象可用于:

1
2
if (obj) { ... }
while (cin) { ... }

避免二义性转换

当类同时有:

  • 多个转换构造函数
  • 多个类型转换运算符

时,可能出现编译器不知道该选哪个转换路径的问题。

例如:

  • A -> int
  • A -> double
  • int -> B
  • double -> B

就可能导致歧义。

复习时记住一句:

用户定义的类型转换越多,越容易出问题

所以设计时应尽量:

  • 少定义隐式转换
  • 优先用 explicit
  • 保持语义清晰

重载运算符与类型转换的关系

很多运算符重载会涉及类型转换。

例如:

1
2
3
4
5
6
class Num {
public:
Num(int);
};

Num operator+(const Num&, const Num&);

则:

1
2
Num n(10);
n + 5; // 5 可通过构造函数转成 Num

如果 + 是非成员函数,则左右两边都更容易发生转换,这也是为什么对称运算符常写成非成员。

常见设计建议

<<>> 写成非成员

通常需要 friend 访问私有数据。

+ 基于 +=

1
2
T& operator+=(const T&);
T operator+(T lhs, const T& rhs);

==!= 配套

!= 常直接由 == 实现。

单参数构造函数慎用隐式转换

多数情况下考虑加 explicit

类型转换运算符更要慎用

尤其避免定义很多种数值类型转换:

1
2
3
operator int()
operator double()
operator long()

容易引起歧义。

易错点总结

不是所有运算符都能重载

不能重载:

1
::  .*  .  ?:

= [] () -> 必须是成员函数

输入输出运算符通常必须是非成员函数

重载 &&|| 后没有短路求值

所以一般不建议重载。

运算符重载不会改变优先级和结合律

至少有一个运算对象必须是类类型

不能改内置类型运算符行为。

operator type() 没有返回类型

例如:

1
operator int() const;

不是:

1
int operator int() const;   // 错

explicit 可用于转换构造函数和类型转换运算符

用于阻止隐式转换。

对称运算符通常写成非成员更合理

例如:

  • +
  • ==
  • <

下标运算符通常要写 const 和非 const 两个版本

小结

这一章的主线可以概括为:

  1. 运算符重载本质上是特殊名字的函数
  2. 有些运算符必须是成员,如:
  • = [] () ->
  1. 有对称性的运算符通常写成非成员
  2. <<>> 常用于类的输入输出支持
  3. operator() 使对象变成函数对象
  4. 单参数构造函数可定义“其他类型 -> 类类型”的转换
  5. 类型转换运算符可定义“类类型 -> 其他类型”的转换
  6. explicit 用于防止危险的隐式转换

运算符重载让类“像内置类型一样用”,类型转换让类“像别的类型一样转”,但两者都要谨慎设计,避免语义混乱和隐式转换歧义。