在之前的章节中,我们曾经给出过一个这样的函数:
Number Number::greater(Number num) {
if (num.value() > value) {
return num;
} else {
return *this;
}
}而且我们当时也说过,这样的实现能够用运算符重载更为优雅地实现。那么到底什么是运算符重载呢?我们能够如何使用运算符重载呢?接下来我们来详细讲解运算符重载的概念和使用。
1. 运算符重载
1. 运算符重载能做什么
运算符重载允许我们像函数重载一样为应用于类的运算符定义新的行为。实际上在我们之前的学习中,已经有不少地方用到了运算符重载。比如我们在处理输入输出中要使用的 cout 和 cin,它们重载了 <<和>> 运算符用于流输出(或输入
已知计算机中的数据都是以 0 和 1 的二进制形式存在的,C++ 也不例外。以 char 类型举例,它包含 8 个 bit,每个 bit 是一位 0 或 1,逐位运算就是处理这些 bit。例如我们有一个 char 变量,其值为 01000010,对这个值左移一位后就会变成 10000100,右移一位就会变成 00100001。
运算符重载的函数名书写格式是 operator + 要重载的运算符。例如上面的 greater 函数,我们可以使用 > 运算符进行重载。为方便各位理解,我们在下面附上原先的类定义:
class Number {
int value; // 如果最开始没有指定范围限定,则直到指定限定为止的所有成员和方法都是private属性
public:
Number() =
default; // C++11标准开始,构造函数/析构函数如果等于default,则使用对象的默认行为,这里的默认行为是什么也不做
~Number() = default; // 同上
Number greater(Number num);
int value(){return value};
void setValue(int _value) {
value = _value
} // 我们能够直接在类内使用它自己的成员
}我们可以将类中的 greater 函数中的对比部分写为如下形式:
bool operator>(const Number &other) const {
return this->value > other.value;
}这样我们可以将 greater 函数写成下面的形式:
Number Number::greater(Number num) {
if (num > this) {
return num;
} else {
return *this;
}
}这样做的好处是可以令函数更可读、更好写,同时类的一些特性需要依靠运算符重载实现,将在后续继续介绍这部分的内容。
2. 运算符重载代表什么
虽然我们自己读起来,运算符重载后 num > this 看起来是直接对比两类的大小,但这样的写法在 C++ 编译器看来有另外的意义。
我们假设有下面的代码:
Number num1, num2;
num1.setValue(1);
num2.setValue(2);
if (num1 > num2) {
cout << num1.value();
}这样的代码在 num1 > num2 中,C++ 编译器与我们的理解有一些不同之处。首先,C++ 编译器会将上面的 num1 > num2 解读为:
if (num1.operator>(num2))这与我们之前的函数声明刚好对应,也就是说在使用重载后的运算符中,编译器会将其视为调用对应的运算符重载函数。此事在 ISO / IEC C++98 标准中亦有记载:
[!TIPS] C++ 标准 Operator functions are usually not called directly; instead they are invoked to evaluate the operators they implement (13.5.1 - 13.5.7). They can be explicitly called, however, using the operator-function-id as the name of the function in the function call syntax (5.2.2). Example:
cppcomplex z = a.operator+(b); // complex z = a+b; void *p = operator new(sizeof(int) * n);译文:运算符函数通常不会被直接调用;相反,它们被调用来计算所实现的运算符(见 13.5.1 至 13.5.7
然而,也可以使用运算符函数标识符作为函数名,通过函数调用语法(5.2.2)来显式调用它们。 ) 。
2. 友元
在之前的章节中,我们曾经提到 private 访问等级对友元不生效。那么什么是友元呢?为什么要提出友元的概念呢?
我们先来回答第二个问题。在 C++ 中,访问等级的限制有时会过于严格,以至于不适合特定的编程问题。例如我们有一个时间类与日期类,它们的数据都是 private 等级,而我们又有一个 display 类,它需要获取日期和时间类的数据并输出。但是这里的问题是:日期类与时间类的数据都是 private 等级,而 display 类又不在这两个类的继承链条中 —— 事实上也没有必要让这个类继承这两个类,因为这违反了高内聚低耦合原则。这时我们就可以使用友元来实现 display 类的功能。
我们可以对函数、类、成员函数声明友元。友元函数声明在类外部,可以赋予该函数与类成员函数相同的访问权限,即友元函数可以访问类中标为 private 等级的数据与成员。
1. 友元函数
要创建友元函数,首先我们要将这个函数的声明放在需要友元的类内部,例如在之前的 Number 类中,我们可以在里面加入一个 display 函数,它接收一个 Number 对象作为参数,输出这个 Number 对象的 value。区别在于,按 C++ 对友元的规定来说,它不属于这个类的成员函数,所以在书写定义时不需要添加类作用域,也不添加 friend 关键字。我们在 Number 类中用下面的函数声明:
friend void display(const Number &num);使用下面的函数定义:
void display(const Number &num) { cout << num.value; }这样 display 函数就能够突破 Number 类中的 private 限制,直接访问数据了。
2. 友元类
在声明友元类时,我们可以将想要访问其他类数据的类的友元声明直接放在要访问的其他类中,这样这个友元类中的所有方法就都可以访问目标类中的私有数据了。还是以上面的 Number 类举例,假设我们已经声明好了一个 Display 类并实现了其功能,我们只需要在 Number 类中这样写:
class Number {
// 数据与其他省略,只演示友元类写法
friend class Display;
}3. 友元成员函数
在声明友元成员函数时,我们应当在包含友元的类前插入目标类的声明(称为前向声明
class Number;
class Display {
void display(Number num);
} class Number {
friend void Display::display(Number num);
// 与上面的Number类相同
}之所以要插入前向声明,是因为在定义 Display 类时,Number 类尚未出现。这里我们要补充一点:在编译程序时,编译器是从上向下查看源代码的。编译器无法在编译前就对代码有整体了解,所以在编译到 Display 类时,编译器会默认 Number 类还没有出现。如果没有前向声明,那么 Display 类会因为编译时找不到 Number 类的位置而无法编译。那么我们能否将 Number 类的定义向上移动到 Display 定义前来解决问题呢?答案是也不行。因为 Number 类的友元成员函数依赖 Display 类,如果将 Number 类的定义放在顶部,同样会因为找不到 Display 类定义而无法编译。
2. 友元的用途
友元可以用在需要直接访问 private 或 protected 成员的场合,例如运算符重载与热点数据抓取。
1. 运算符重载
在运算符重载中,我们经常会遇到如下情况:某个运算符重载函数不止需要这个类的私有成员数据,还需要其他类的私有成员数据。或者这个运算符重载函数无法在两个类中的任意一个声明,只能在两个类外部声明与定义。这时如果我们想像操作私有成员一样操作两个类内的数据,友元就成了一个很好的选择。例如我们有一个 Date 类与 Time 类,而且我们想要只通过一次函数调用就同时输出日期和时间信息。怎么办呢?一种方法是在 Date 和 Time 类中分别声明友元函数,让 Date 和 Time 类互为对方的友元。但这样实现会显著增加两个类之间的不必要耦合,违反了我们的高内聚低耦合原则。怎么办呢?我们可以额外声明一个 Display 类作为中介类,让 Display 类同时成为 Date 和 Time 类的友元,这样我们就能够在不增加不必要耦合的情况下实现这个功能。
关于这种方法不会增加耦合的说明
这种方法不会增加类耦合的原因在于:Display 类是独立于两个类存在的,而且只负责打印信息的功能,没有在其中增加业务逻辑。删除这个类只需要在删除类的同时删除 Date 和 Time 的友元声明,而不需要其他任何操作,也不会影响其他功能的可用性。这种设计方法在设计模式中被称作中介者模式。
2. 热点数据抓取
在一些应用中,数据需要以极高的效率被移动(例如股票交易场合、游戏引擎运算等
call 8000现代 CPU 一般会采用流水线技术加快指令执行的速度,但是这并不意味着这些额外的跳转指令不占用时间。流水线技术仅仅是优化了指令在串行执行时的整体执行效率,并没有那种 deep♂dark♂魔法能令这些 call 指令凭空消失。更雪上加霜的是,一条 CALL 指令一般是由多个微指令组合而成,而不像 if 判断一样只用一条 CMP 和一条 JMP 就可以完成。所以对于这些热点数据来说,使用友元进行主动读取而非通过 Getter 进行被动传递就成为了一种解决方案。
Minecraft 中的高性能要求
在 Minecraft MOD 开发中,一般的服务器配置文件会配置游戏以每秒 20 Ticks 的速度运行,即每秒进行 20 次刷新运算。这意味着所有与游戏逻辑相关的运算必须在 1s / 20 Ticks = 50ms 内处理完毕。如果 MOD 过多或某个 MOD 的代码逻辑在帧刷新中涉及了过多无关操作,日志中就会出现如下警告信息: Can't keep up! Is the server overloaded? Running xxx ms or xxx ticks behind
3. 友元的缺陷
在上面的例子中,我们对友元的使用方法与优势进行了充分说明。可能有些读者会发现,友元不仅方便了类之间的运算符重载,还能加快热点数据获取,降低优化难度与成本,看起来已经十分完美了对吧?但是,古尔丹,代价是什么呢?我们接下来来看看友元的一些固有缺陷。
1. 破坏封装
众所周知,面向对象的三大基本特性就是封装、继承与多态。其中:
- 封装:把数据和操作数据的方法打包在一起,隐藏内部细节,只为外部保留必要的接口;
- 继承:允许新类直接使用现有类的属性和方法,提高代码可复用性,将在后续讲解继承的概念;
- 多态:同一个操作作用在不同对象上会有不同的表现,一般通过重写实现。
而友元的出现直接就把封装特性打了个稀烂。由于友元的根本作用就是读取类里的非 public 数据,封装在友元面前就完全失去了意义。这也是为什么大部分教程(例如《Effective C++》的第 23 条)都不推荐在非必要的时候直接使用友元,反而推荐使用一些设计模式代替,例如工厂模式与观察者模式(将在另外的系列教程中详细讲解设计模式
名词解释
工厂模式:通过统一接口创建对象而非暴露私有构造 观察者模式:通过事件通知机制替代直接访问私有状态
2. 不可继承
接下来这个事实更加残酷。在上面我们举的所有例子中使用的友元中,这些友元都只能对声明了友元的类本身生效,无法把这些特性应用到这些类的任何派生类中。你辛苦 3 天写的友元直接被继承一张白色死神全部沉默成白板是什么心情啊
3. 不可传递
友元的第三个缺陷在于:在友元链中(友元链是指类似 B 是 A 的友元,而 C 又是 B 的友元的情况此事在世界史课本上亦有记载:我的附庸的附庸,不是我的附庸