这一章在干嘛?
第 10 章你已经会造类了,但用起来还是别扭:想让两个 Time 对象相加,得写
coding.Sum(fixing)而不是顺手的coding + fixing。这一章就解决”好用”问题:运算符重载让你给 C++ 运算符赋予自定义类型的新含义;友元让非成员函数也能访问私有数据;重载 << 让 cout 直接打印对象;类型转换让对象和内置类型自由互转。本章延续第 10 章的 Stock→Time→Vector→Stonewt 递进式设计,是全书 OOP 三部曲的第二章。
11.1 运算符重载入门
运算符重载是 C++ 多态的一种。其实很多运算符早就”重载”过了:* 作用在两个数上是乘法,作用在地址上是解引用——编译器靠操作数的数目和类型决定用哪个定义。这一章要做的,就是让自定义类型也享受这种待遇。
重载靠一种特殊的函数——运算符函数,格式是 operatorop(参数列表),比如 operator+() 重载 +。op 必须是合法的 C++ 运算符,不能自创符号(没有 operator@())。
先从普通方法写起。Time 类表示”几小时几分钟”,Sum() 方法把两个时间相加:
// mytime0.h -- Time class before operator overloading
#ifndef MYTIME0_H_
#define MYTIME0_H_
class Time
{
private:
int hours;
int minutes;
public:
Time();
Time(int h, int m = 0);
void AddMin(int m);
void AddHr(int h);
void Reset(int h = 0, int m = 0);
Time Sum(const Time & t) const;
void Show() const;
};
#endifTime Time::Sum(const Time & t) const
{
Time sum;
sum.minutes = minutes + t.minutes;
sum.hours = hours + t.hours + sum.minutes / 60;
sum.minutes %= 60;
return sum;
}注意两个细节:参数用引用是为了效率(避免拷贝);返回值不能用引用——sum 是局部变量,函数一结束就销毁,返回引用等于返回一个不存在的东西。
常见坑:返回局部变量的引用
永远不要返回对局部变量或临时对象的引用。函数终止时它们就消失了,引用会指向不存在的数据。正确做法是按值返回——返回时会构造一个副本交给调用者。
把 Sum 改名成 operator+()(就是把运算符接到 operator 后面),就完成了重载:
Time operator+(const Time & t) const; // 声明,其余不变
Time Time::operator+(const Time & t) const
{
Time sum;
sum.minutes = minutes + t.minutes;
sum.hours = hours + t.hours + sum.minutes / 60;
sum.minutes %= 60;
return sum;
}于是两种写法都能用,效果相同:
total = coding + fixing; // 运算符表示法
total = coding.operator+(fixing); // 函数表示法运算符表示法中,左边的对象是调用者,右边的对象是参数。所以 t4 = t1 + t2 + t3; 会被翻译成 t4 = t1.operator+(t2.operator+(t3));——完全合法,加法从左到右结合,中间结果作为参数传下去。
编译器怎么选
c = a + b;(a、b 是 int)用内置加法;C = A + B;(A、B 是 Time)用你定义的 operator+()。操作数类型决定一切——这正是重载的精髓。
11.2 重载的限制
- 至少一个操作数是用户定义的类型——不能给两个 double 重载减法,防止”改写”内置类型的行为;
- 不能违反原运算符的语法规则——% 本来是双目的,不能重载成单目;也不能改变原运算符的优先级;
- 不能创造新运算符符号——没有
operator**()表示求幂; - 有些运算符不能重载:
sizeof、.、.*、::、?:、typeid以及四个强制类型转换运算符; - 有些运算符只能用成员函数重载:
=(赋值)、()(函数调用)、[](下标)、->(指针访问成员)——这个列表第 12 章会很重要。
其余绝大多数运算符(+ - * / % == < << ++ 等)既可以做成成员函数,也可以做成非成员函数。最后是品味问题:别把 * 重载成交换两个对象的成员——从写法上完全看不出它在干嘛,老老实实起个名字叫 Swap() 的方法更清晰。
减法和乘法照葫芦画瓢加进来(mytime2 版本):
Time operator-(const Time & t) const; // 声明
Time operator*(double n) const;
Time Time::operator-(const Time & t) const
{
Time diff;
int tot1, tot2;
tot1 = t.minutes + 60 * t.hours;
tot2 = minutes + 60 * hours;
diff.minutes = (tot2 - tot1) % 60;
diff.hours = (tot2 - tot1) / 60;
return diff;
}
Time Time::operator*(double mult) const
{
Time result;
long totalminutes = hours * mult * 60 + minutes * mult;
result.hours = totalminutes / 60;
result.minutes = totalminutes % 60;
return result;
}测试:total = weeding + waxing;、diff = weeding - waxing;、adjusted = total * 1.5; 输出分别是 7 hours, 22 minutes、1 hours, 48 minutes、11 hours, 3 minutes——语法和写内置类型一模一样。
11.3 友元
C++ 用私有数据保护类,但有时”只能从公共方法进”太死板。**友元(friend)**是另一扇门:友元函数拥有和成员函数相同的访问权。友元有三种:友元函数、友元类、友元成员函数(后两者在第 15 章)。
为什么需要友元?
看乘法。A = B * 2.75; 能翻译成成员函数调用 A = B.operator*(2.75);,因为左边 B 是对象。但 A = 2.75 * B; 呢?左边 2.75 不是 Time 对象,成员函数必须由对象调用,编译器没法处理。
出路是非成员函数:A = operator*(2.75, B);。可普通非成员函数访问不了私有成员……于是友元登场。
创建友元
两步:①在类声明中写原型并加 friend 关键字;②在类外写定义——不加 Time:: 限定,也不加 friend:
// 第一步:类声明中
friend Time operator*(double m, const Time & t);
// 第二步:定义(没有 Time::,没有 friend)
Time operator*(double m, const Time & t)
{
Time result;
long totalminutes = t.hours * m * 60 + t.minutes * m;
result.hours = totalminutes / 60;
result.minutes = totalminutes % 60;
return result;
}常见坑:friend 关键字的位置
friend 只出现在类声明内的原型上。定义处写 friend 是编译错误(除非定义直接写在类声明里成为内联函数)。
友元背叛 OOP 了吗?
看起来友元绕过了数据隐藏,其实是误解:友元是类接口的延伸。2.75 * Time 和 Time * 2.75 在概念上是同一件事,一个需要友元只是 C++ 语法的结果。而且只有类声明能决定谁是友元,访问控制权仍在类手里。
这个友函数其实还能写得更聪明——不碰私有成员,把工作转给成员函数:
Time operator*(double m, const Time & t) // 非友元版也行
{
return t * m; // 调用 t.operator*(m)
}这样写连 friend 都不需要,但把原型声明进类里仍有好处:它成为官方接口的一部分,将来若要直接访问私有成员,只改定义不改类声明。
通关标准:
能不看书写出:①operator+() 成员函数并解释左操作数是调用者;②”为什么 2.75 * B 必须用非成员函数”的完整推理;③friend 声明的两步写法;④”不要返回局部变量引用”的原因。这四点过关,本章最难啃的部分就拿下了。
11.4 重载输出运算符
一直在用的 Show() 可以退休了——能不能直接 cout << trip;?<< 本来是左移位运算符,ostream 类已经把它重载成输出工具(对 int、double 等各有一份定义)。我们没法改 iostream 文件,但可以在 Time 类里再教它认识 Time 对象。
第一版:能打印
cout << trip 中 cout 在左边,若用成员函数就得写成 trip << cout——反人类。所以必须用友元:
void operator<<(ostream & os, const Time & t)
{
os << t.hours << " hours, " << t.minutes << " minutes";
}注意:operator<<() 直接访问 Time 的私有成员,所以必须是 Time 的友元;但它只把 ostream 对象当整体用,不需要是 ostream 的友元——这很幸运,标准库谁也改不了。第一个参数用 ostream 引用而非 const 引用,因为输出会改变流的状态。
第二版:能拼接
cout << trip; 好了,但 cout << "Trip time: " << trip << "\n"; 还不行。为什么?cout << x << y 从左到右结合,等价于 (cout << x) << y——这要求 (cout << x) 的结果还得是 ostream 对象。iostream 里的 operator<<() 返回 ostream &(就是 cout 本身),所以能一路接下去。我们的版本返回 void,链就断了。
修复:返回 ostream 引用。
ostream & operator<<(ostream & os, const Time & t)
{
os << t.hours << " hours, " << t.minutes << " minutes";
return os;
}cout << trip 变成 operator<<(cout, trip),返回 cout;cout << "Trip time: " 先执行、返回 cout,接着处理 trip,再返回 cout,继续处理后面的字符串——完美串联。这个定义还能直接用于文件输出:ofstream fout; fout << trip; 会调用 operator<<(fout, trip)(继承机制让 ostream 引用也能绑定 ofstream 对象)。
重载 << 的通用模板
ostream & operator<<(ostream & os, const c_name & obj) { os << ... ; // 显示对象内容 return os; }若类的公有方法已经能取到要显示的数据,用方法访问即可,函数就不必(也不应该)是友元。
整合版 mytime3.h 把两个友元都收进类里:
// mytime3.h -- Time class with friends
#ifndef MYTIME3_H_
#define MYTIME3_H_
#include <iostream>
class Time
{
private:
int hours;
int minutes;
public:
Time();
Time(int h, int m = 0);
void AddMin(int m);
void AddHr(int h);
void Reset(int h = 0, int m = 0);
Time operator+(const Time & t) const;
Time operator-(const Time & t) const;
Time operator*(double n) const;
friend Time operator*(double m, const Time & t)
{ return t * m; } // inline definition
friend std::ostream & operator<<(std::ostream & os, const Time & t);
};
#endif测试程序与输出:
// usetime3.cpp -- compile usetime3.cpp and mytime3.cpp together
#include <iostream>
#include "mytime3.h"
int main()
{
using std::cout;
using std::endl;
Time aida(3, 35);
Time tosca(2, 48);
Time temp;
cout << "Aida and Tosca:\n";
cout << aida << "; " << tosca << endl;
temp = aida + tosca; // operator+
cout << "Aida + Tosca: " << temp << endl;
temp = aida * 1.17; // member operator*()
cout << "Aida * 1.17: " << temp << endl;
cout << "10.0 * Tosca: " << 10.0 * tosca << endl;
return 0;
}Aida and Tosca:
3 hours, 35 minutes; 2 hours, 48 minutes
Aida + Tosca: 6 hours, 23 minutes
Aida * 1.17: 4 hours, 11 minutes
10.0 * Tosca: 28 hours, 0 minutes最后一行 10.0 * tosca 走的是友元版——这就是 11.3 的价值。
11.5 成员函数还是非成员函数
很多运算符两种写法都行。比如加法可以是成员版 Time operator+(const Time & t) const;,也可以是友元版 friend Time operator+(const Time & t1, const Time & t2);。
记忆口诀
非成员版的参数个数 = 运算符的操作数个数;成员版少一个——有一个操作数是隐式传入的调用对象。
两种形式都匹配 T1 = T2 + T3(分别翻译成 T2.operator+(T3) 和 operator+(T2, T3)),但只能二选一,两个都定义是二义性错误,编译不过。选哪个?=、()、[]、-> 没得选只能成员;其余情况下两者通常没差别,但若类涉及类型转换,非成员(友元)版本往往更灵活——11.8 会展开。
11.6 实战:Vector 类
向量(vector)是工程中的概念:既有大小又有方向的量。推门用什么方向很关键,向花瓶推过去和推开它是两种命运。向量天然适合用类表示(一个数表示不了),也天然有加减乘的类比——是练运算符重载的好素材。
两种表示法
二维向量有两种等价描述:直角坐标(x 分量和 y 分量)或极坐标(长度 mag 和角度 ang)。有时这种方便,有时那种方便,干脆两种都存,改一个自动更新另一个——这正是类把”智能”封装进对象的体现。

▲ 图 11.1 用向量描述位移:长度是大小,箭头指向是方向

▲ 图 11.2 向量加法:首尾相接,从起点连到终点——和的长度可能小于分长度之和

▲ 图 11.3 向量的 x、y 分量:30 右 + 40 上等价于 50 @ 53.1°
vect.h 的骨架(完整版放在 VECTOR 命名空间里,省略部分私有方法定义):
// vect.h -- Vector class with <<, mode state
#ifndef VECTOR_H_
#define VECTOR_H_
#include <iostream>
namespace VECTOR
{
class Vector
{
public:
enum Mode {RECT, POL};
private:
double x; // horizontal value
double y; // vertical value
double mag; // length of vector
double ang; // direction of vector in degrees
Mode mode; // RECT or POL
// private methods for setting values
void set_mag(); void set_ang();
void set_x(); void set_y();
public:
Vector();
Vector(double n1, double n2, Mode form = RECT);
void reset(double n1, double n2, Mode form = RECT);
~Vector();
double xval() const {return x;} // 以下报告函数均为内联 const
double yval() const {return y;}
double magval() const {return mag;}
double angval() const {return ang;}
void polar_mode(); // set mode to POL
void rect_mode(); // set mode to RECT
// operator overloading
Vector operator+(const Vector & b) const;
Vector operator-(const Vector & b) const;
Vector operator-() const;
Vector operator*(double n) const;
// friends
friend Vector operator*(double n, const Vector & a);
friend std::ostream &
operator<<(std::ostream & os, const Vector & v);
};
}
#endif状态成员 mode
mode 是状态成员(state member):描述对象当前处于哪种表示模式。构造函数按它决定第三个参数是直角坐标还是极坐标:
Vector folly(3.0, 4.0); // RECT:x=3, y=4
Vector foolery(20.0, 30.0, VECTOR::Vector::POL); // POL:mag=20, ang=30operator<<() 也按 mode 决定显示格式:RECT 显示 (x,y),POL 显示 (m,a)。因为友元不在类作用域内,它得写 Vector::RECT 而不是裸的 RECT。
算术运算:让构造函数干活
向量加法在直角坐标下就是把分量分别相加。但对象里两种表示都存着,直接手写 sum.x = x + b.x; sum.y = y + b.y; 会漏掉极坐标的更新。最高明的写法是把构造函数当工具用:
Vector Vector::operator+(const Vector & b) const
{
return Vector(x + b.x, y + b.y); // 让构造函数创建并返回新对象
}构造函数内部会顺手把 mag、ang 都算好(调用私有的 set_mag()、set_ang()),新对象保证按类的标准规则诞生。乘法同理:return Vector(n * x, n * y);——极坐标语义上就是长度乘 n、方向不动。
重载”被重载的运算符”
C++ 里 - 本来就有双目(相减)和单目(取负)两种形态,向量两类都需要,于是 operator-() 有两份定义——签名不同,合法:
Vector operator-(const Vector & b) const; // 二目:相减
Vector Vector::operator-(const Vector & b) const
{
return Vector(x - b.x, y - b.y); // 注意别写成 b.x - x!
}
Vector operator-() const; // 单目:取负
Vector Vector::operator-() const
{
return Vector(-x, -y);
}diff = v1 - v2 翻译成 v1.operator-(v2):显式参数 v2 从隐式的 v1 中被减去。只有单目形式的运算符(如 /)不能重载出单目版本。
随机游走模拟
randwalk.cpp 用 Vector 类模拟”醉汉走路”:每步方向随机,问走多少步才能离灯柱 50 英尺?核心循环只有几行:
srand(time(0)); // 用当前时间做随机数种子
while (result.magval() < target)
{
direction = rand() % 360; // 随机方向
step.reset(dstep, direction, Vector::POL);
result = result + step; // 向量累加
steps++;
}
cout << result << endl; // 显示直角坐标
result.polar_mode();
cout << " or\n" << result << endl; // 再显示极坐标一次示例运行(目标 50、步长 2):253 步后位于 (x,y) = (46.1512, 20.4902),即 (m,a) = (50.495, 23.9402),每步平均只往外挪了 0.2 英尺——随机游走非常低效。概率论给出的平均步数是 N = (D/s)²:目标 50 英尺、步长 2 英尺,平均约 625 步(1000 次实验均值 636,但范围从 91 到 3951 不等)。结论:如果非得随机走路,请迈大步。
有个细节值得咀嚼:result = result + step; 执行后 result 会被重置成 RECT 模式——因为 operator+ 用默认构造函数创建新对象(默认 RECT),成员逐一赋值把 mode 也覆盖了。想保持原模式,就得自定义赋值运算符——第 12 章的正题。
自测:
Vector operator-() const;和Vector operator-(const Vector & b) const;为什么能同时存在?一个是单目取负、一个是双目相减,函数签名不同,构成合法重载。前提是 C++ 本来就为该运算符提供了两种形态;像 / 只有双目形态,就不能重载出单目版本。
自测:为什么 Vector::operator+ 里"手写 sum.x、sum.y"的做法不好?
Vector 对象同时存储直角和极坐标两套数据,手写分量相加会漏更新 mag 和 ang,留下脏数据。
return Vector(x + b.x, y + b.y);让构造函数统一初始化所有成员,保证新对象合法。
11.7 类的自动转换与强制类型转换
内置类型之间有自动转换规则(long count = 8; 把 int 转成 long)。类能不能也这样?答案是两种方向都可以。
构造函数:其他类型 → 类类型
Stonewt 类用英石(stone,1 stone = 14 磅)和磅两种方式表示体重:
// stonewt.h -- definition for the Stonewt class
#ifndef STONEWT_H_
#define STONEWT_H_
class Stonewt
{
private:
enum {Lbs_per_stn = 14}; // pounds per stone
int stone; // whole stones
double pds_left; // fractional pounds
double pounds; // entire weight in pounds
public:
Stonewt(double lbs); // constructor for double pounds
Stonewt(int stn, double lbs); // constructor for stone, lbs
Stonewt(); // default constructor
~Stonewt();
void show_lbs() const;
void show_stn() const;
};
#endif关键规则:只接收一个参数的构造函数,天然是把参数类型转换为类类型的蓝图。Stonewt(double lbs); 意味着 double 可以转成 Stonewt:
Stonewt myCat;
myCat = 19.6; // 隐式转换:Stonewt(19.6) 创建临时对象,再逐成员赋给 myCat这叫隐式转换,会发生在:初始化、赋值、把 double 传给形参为 Stonewt 的函数、返回值等场景。甚至 int 也能走两步转换:先 int→double,再 Stonewt(double)——前提是无歧义(如果同时有 Stonewt(long),编译器就拒绝选择)。
但隐式转换常常”帮你倒忙”,C++ 加了 explicit 关键字关闭它:
explicit Stonewt(double lbs); // 禁止隐式转换
Stonewt myCat;
myCat = 19.6; // 编译错误!
myCat = Stonewt(19.6); // OK,显式转换
myCat = (Stonewt) 19.6; // OK,旧式强制转换转换函数:类类型 → 其他类型
反方向怎么办?构造函数管不了,需要转换函数(conversion function)——用户自定义的强制类型转换:
operator typeName(); // 形式,例如:operator double();三条硬规则:必须是类方法;不声明返回类型(typeName 本身就说明了转换目标);没有参数(由调用它的对象提供数据)。加入 Stonewt:
// stonewt1.h 中新增
operator int() const;
operator double() const;
// stonewt1.cpp 中定义
Stonewt::operator int() const
{
return int (pounds + 0.5); // 四舍五入而非截断
}
Stonewt::operator double() const
{
return pounds;
}使用起来和内置类型转换无异:
Stonewt poppins(9, 2.8); // 9 stone, 2.8 pounds
double p_wt = poppins; // 隐式转换,p_wt = 128.8
cout << int (poppins); // 显式转换,129常见坑:转换函数太多导致二义性
同时定义 operator int() 和 operator double() 后,
long gone = poppins;直接编译失败——long 既能由 int 也能由 double 转来,编译器拒绝替你选。同样cout << poppins;也是二义性。另外隐式转换可能掩盖低级错误:想写ar[Temp]手滑写成ar[temp],本该报错,却因为 operator int() 把对象悄悄变成了数组下标。经验法则:转换函数也尽量加 explicit(C++11 支持),或改用名为 Stone_to_Int() 这样的普通函数,只在显式调用时才转换。
自测:
Stonewt(int stn, double lbs);是转换函数吗?不是——它有两个必填参数。但如果写成
Stonewt(int stn, double lbs = 0);给第二个参数默认值,它就变成单参构造,可以承担 int→Stonewt 的转换。
11.8 转换与友元的配合
给 Stonewt 加加法:成员版 Stonewt::operator+(const Stonewt & st) const 或友元版 operator+(const Stonewt & st1, const Stonewt & st2),二选一。有了 Stonewt(double) 构造函数后:
total = jennySt + bennySt;——两种版本都行;total = jennySt + kennyD;(kennyD 是 double)——两种版本也行:成员版把 kennyD 经构造函数转成 Stonewt 再相加;total = pennyD + jennySt;(double 在左)——只有友元版行!
原因:成员版要求左操作数是调用对象,翻译成 pennyD.operator+(jennySt) 毫无意义。C++ 只为成员函数的参数做转换,不为调用者做转换。友元版的两个操作数都是参数,都能享受构造函数转换的待遇。
若程序里 Stonewt 与 double 的加法用得非常频繁,另一个选择是为 double 显式再重载两个版本(成员的 operator+(double) 和友元的 operator+(double, Stonewt&)),免去每次转换的开销——程序更长、更快;偶尔用一用就靠隐式转换,程序更短、更省心。取舍看你。
通关标准:
能独立完成一个自定义类的”全套装备”:operator+ 成员版、operator* 友元版(处理 double 在左)、operator<< 友元版(返回 ostream & 支持拼接)、单参构造函数 + explicit 的取舍、operator double() 转换函数。写完能解释每一处 const 和引用的去留理由——达到这个水平,就可以挑战第 12 章的动态内存类设计了。