What & How & Why

这是本文档旧的修订版!


类的细节

第 12 章笔记


运算符重载

运算符重载是指在 C++ 中基于 operator 关键字引入对运算符的重载。经过重载的运算符可以被视作函数,典型的结构(以减法为例):

return Type + operator - + (parameter list)

运算符重载的特点

  • 不能重新发明运算,只能基于 C++ 的运算符进行重载
  • 不能改变对应运算符的优先级和结合性
  • 可以,但是不应该去改变运算符重载的意义(加法就是加法,不能用加法代表乘法)
  • 重载的运算符与 bulit-in 运算符的 operand 应该在数量上一致
  • 重载运算符中,其参数需要至少有一个是类类型(不能全是 built-in 类型)
  • 除了 operator(),其他重载的运算符都不能设置默认参数
成员 / 非成员函数

运算符重载可以以成员函数或非成员函数的方式来实现:

  • 以成员函数进行重载时,通常以 *this 作为第一个 operand
  • 注意 C++ 17 以后,== 并不遵循此例

// 非成员函数重载
Str operator+ (Str x, Str y)
{
    Str z;
    z.x = x.x + y.x;
    return z;
}
// 成员函数重载
Str operator+(Str rhs)
{
    Str z;
    //x imply this->x
    z.x = x + rhs.x;
    return z;
}

运算符重载的分类

  • 可重载,且必须重载为成员函数的运算符:=(), ,转型运算符
  • 可重载,且可以实现非成员函数的运算符:绝大部分
  • 不建议重载的运算符:&&||,。这类运算符往往需要维护特定的求值顺序,而
    • C++17 之前,重载运算符对执行顺序没有规定
    • C++17 之后,顺序规定,但重载上述运算符会破坏短路逻辑
  • 不可重载的运算符

对称性运算符

指可以左右算子互换位置的运算符(比如 +== 等)。对称性运算符需要被设计为非成员重载,是因为:

  • 对称性运算符通常牵涉到两个算子之间的运算。这个过程中会为了匹配左右算子类型进行隐式转换。如果声明为成员函数,算子的位置被固定,这将导致某些隐式转换无法正常进行,从而导致计算失败。比如:

class Str
{
    public:
    Str(int x) :val(x) {}
    Str operator+ (const Str& rhs) {...}
    private:
    int val;
};

int main(int argc, char const *argv[])
{
    Str myStr1(2);
    // 可以进行正常运算,4 对应 rhs, 可以通过 Str 的构造函数转化为 Str 类型
    Str myRetStr =myStr1 + 4;
    // 无法正常进行运算,4 不是 Str (this指向的) 类型
    Str myRetStr = 4 + myStr1;
    return 0;
}

  • 设计为非成员函数以后,由于不再受 this 的匹配影响,上述例子中的 4 则可以在函数匹配的过程中进行隐式转换。
对称性运算符的额外要求
  • 需要声明为 friend,否则无法访问私有数据

移位运算符

移位运算符必须重载为非成员函数。这是因为 C++ 中移位运算符有一个更重要的应用:输入输出流运算符。输入输出流运算符一般的格式为:

StreamObjReference << Object;
这种情况下,成员函数版本无法支持该功能。

移位运算符的实现
  • 返回对象为流的引用:为了支持连续的流运算
  • 参数为流的引用,以及需要进出流的对象
  • 具体的 const 根据输入输出决定

// 简单的输出流重载实现
// 需要声明为友元函数
std::ostream& operator << (std::ostream& ostr, const Str &output)
{
    ostr << output.val;
    return ostr;
}

赋值运算符

  • 必须以成员函数的形式重载
  • 返回为引用:因为赋值等号的左边是目标,右边是值
  • 典型例子:copy assignment / move assignment;除此之外参数也可以是其他形式

// 简单的实现
Str& Str::operator= (const Str& rhs)
{
    if(this != &rhs)
    {
        val = rhs.val;
    }
    return *this;
}

下标运算符

该运算符重载用于模拟下标操作。典型的应用是 std::vector。需要考虑的问题有:

  • 下标要支持读取
  • 下标运算的结果要支持写入

主要的问题在第二个子问题上:

  • 首先,要支持写入,那么需要对 ObjA[0] = ObjB 这种形式的运算进行处理。因此,下标运算的返回结果一定是左值,也就是对象的引用。
  • 其次,当下标运算的对象为 const 时,这种写入的操作应该被侦测并禁止。因此,我们需要一个专门的 const 实现版本来处理该情况。

// 简单的实现,基于 String 的下标运算重载
class StrVec {
public:
    std::string& operator[](std::size_t n)
        { return elements[n]; }
    // const 版本
    const std::string& operator[](std::size_t n) const
        { return elements[n]; }
private:
    std::string* elements;   // pointer to the first element in the array
};

自增自减运算符

  • 单目运算符,必须重载为成员函数
  • 分 Prefix 和 postfix 两种版本
前缀版本与后缀版本

以加法为例, 前缀版本:

  • 写法:不带参数 operator++()
  • 返回:引用,为了支持连续的使用
  • 过程:
    • 自增
    • 返回自增后的结果

后缀版本:

  • 写法:带一个没有任何用处的 int 类型参数:operator++(int)
  • 返回:自增对象被修改之前的副本
  • 过程:
    • 保存副本
    • 对原有的数据进行自增
    • 返回保存的副本

Str& Str::operator++()
{
    ++val;
    return *this;
}

Str Str::operator++(int)
{
    Str ret = *this;
    ++*this;
    return ret;
}

解引用 / 箭头运算符

两者都是模拟指针的行为。

解引用运算符
  • 返回:解引用后内容类型的引用(与下标运算符类似,目的是为了写操作)
  • 基于第一条的原因,因此也需要引入 const 版本

int& Str::operator*() 
{
    return *ptr;
}

箭头运算符

箭头运算符的重载是一个通过递归的方式访问最终数据成员的过程:

  • Base case:返回类型是类类型的指针bulit-in 指针)
  • Normal case:返回类型是普通类类型

当使用箭头运算符的重载时,只要返回值不是指针类型,则编译器会尝试在当前对象中继续寻找 operator →() 的定义,并执行该定义继续进行调用;直到某个调用返回指针时,该过程才会停止。这么做是因为箭头操作符右边的 operand(对象中数据成员的名称) 并不是严格意义上的对象;只有在其与对应的对象组合使用时,才能代表正式的对象(比如 obj.x)。因此整个该过程实际上是通过一个层层 “开盒” 的行为来找到最终数据成员的过程。

实现上,箭头运算符的重载有返回类型,但没有参数

// 返回指针(Base case)
Str* Str::operator->()
{
    return this;
}
// 返回对象(Normal case)
// 编译器会到 Str2 中继续寻找 -> 的定义
Str2 Str::operator->()
{
    return Str2;
}

函数调用运算符

函数调用运算符允许将对象变为可调用对象,即 obj() 这类型的函数。其最大的特点是接收参数数量不定:

struct Str
{
    Str(int* p):ptr(p){}
    int operator() (int x, int y, int z)
    {
        return *ptr + x + y + z;
    }
    
    int* ptr;
};

int main(int argc, char const *argv[])
{
    int x = 100;
    Str StrPtr(&x);
    // 打印 100 + 1 + 2 + 3 的结果
    std::cout << StrPtr(1,2,3);
    return 0;
}