特殊运算符重载

最后有一些运算符的重载方式比较特殊,或者说需要更多文字说明的,我在本节列出。内容会比较琐碎,烦请谅解。

自增减运算符

我们已经熟悉了各种二元运算符的重载方式(包括成员重载或者非成员重载两种方式)。那么对于一元运算符来说,最常见的是重载自增运算符 ++ 和自减运算符 --。下面我们用自增运算符举例(自减运算符是类似的):

假设我们定义类 A,其保有一个 int data; 私有成员。然后我们需要实现其自增运算,效果是令 data 自增。那么很容易地可以想到,我们需要写一个 operator++ 的成员:

class A {
    int data;
public:
    void operator++() {
        data++;
    }
};

但是——

int main() {
    A a;
    a++; // 编译错误!
    ++a; // OK
}

如果你写下 a++ 会发生编译错误,写 ++a 却可以。换句话说,A::operator++() 只能重载前缀的自增减而不能重载后缀的自增减。

那么我们怎么实现后缀自增减的重载呢?C++ 规定,当一个类类型变量 a 出现在后缀自增减表达式时,会调用 a.operator++(0) 而非 a.operator++()。(如果是非成员重载的话,就调用 operator++(a, 0) 而非 operator++(a)。)注意到,后缀自增减表达式的重载调用会“多带一个参数”,所以为此我们需要这样做:

class A {
    int data;
public:
    // 下面这个函数重载的是前缀自增
    void operator++() {
        data++;
    }
    // 下面这个函数重载的是后缀自增
    void operator++(int) { // 这里的参数永远是 0,而且用不到;所以不用写参数名
        data++;
    }
};
int main() {
    A a;
    a++; // OK
    ++a; // OK
}

现在编译就能过了。但是还有一个问题,就是我们曾经强调过内置类型的自增减表达式的结果是不同的。前缀自增表达式严格等同于 += 1,它的结果就是自增减后的操作数本身;而后缀自增表达式的结果是自增减前操作数的值。注意我这里的用词,前者的结果是一个对象,而后者的结果是。如果更正式地说,前者的结果是左值,后者的结果是右值:

int main() {
    int a{42};
    &(++a);     // 合法,相当于 &a
    (++a) = 56; // 合法,相当于 a += 1 后 a = 56
//  &(a++);     // 不合法,右值不能取地址
//  (a++) = 56; // 不合法
}

所以我们定义的类运算符重载也最好按照这个规则来。比较典型的自增定义是这样的:

class A {
    int data;
public:
    // 下面这个函数重载的是前缀自增
    A& operator++() {
        data++;
        return *this; // 这里返回到自己的引用,所以结果为可取地址、赋值的左值
    }
    // 下面这个函数重载的是后缀自增
    A operator++(int) {
        A retVal{*this};
        data++;
        return retVal; // 注意返回值类型不是引用;这里返回的是之前值的副本
    }
};

你会发现两个运算符重载的返回值类型和返回值本身都是不太一样的。如果你希望重载某个类的前后缀自增减,那么请注意这里的细节。

函数调用运算符

神奇的是,函数调用运算符(就是 f(a, b, c) 里面的括号)也可以被重载。一般来说,任何非函数的类型都没有定义其函数调用运算符的重载,也就是 int a; a(42); 这样试图把一个普通变量当成函数来用是不可能的。 但我们一旦定义了这样的重载,那么就化不可能为可能了。

重载函数调用运算符的写法和重载下标运算符 [] 的写法是类似的,但区别是它可以接受任意多个“右”操作数:

class A {
    int data;
public:
    T operator()(/* 任意多个参数的列表 */) {
        return /* 返回值类型也是任意的 */;
    }
}

比如说,我们让它接受两个参数,然后返回这两个参数和 data 成员的乘积:

#include <iostream>
class A {
public:
    int data;
    int operator()(int a, int b) {
        return data * a * b;
    }
};
int main() {
    A a{2}; // data 成员为 2
    std::cout << a(3, 5) << std::endl; // 调用函数调用运算符重载
}

你会发现,这里 a 作为一个对象也可以像函数那样被调用。它还可以有若干个重载,只要“右”参数列表类型或长度不同就可以。特别地,函数调用运算符不得以非成员的形式定义重载;类似地,下标运算符 [] 也不得以非成员的形式定义重载。

像这种重载了函数调用运算符的对象有时被称为函数对象(Function Object)。

指针成员运算符(选读)

有时我们会突发奇想想要去重载 -> 这个玩意儿。这种情况一般发生在我们想要重载一元 * (解地址运算符)的时候,发现 *-> 的行为会不一致。比如:

#include <iostream>
struct A {
    int a, b, c;
};
class PtrToA {
    A* p;
public:
    PtrToA(A& a) : p{&a} { }
    A& operator*() {
        return *p;
    }
};
int main() {
    A a{1, 2, 3};
    PtrToA p(a);
    std::cout << (*p).a << std::endl; // OK
//  std::cout << p->a << std::endl;  // 错误
}

上面的例子中,我用 PtrToA 作为指向 A 的指针类型的包装类,然后定义了它的解地址运算符 *。但是只重载一元 * 并不能让 -> 的行为也跟着变化,所以我们需要重载 ->

在此之前,请注意 -> 其实是一个“一元后缀运算符”。它只有一个左侧操作数,而右侧那个东西并不是操作数(只是一个“分量标识”)。所以,它应该这样定义:

class PtrToA {
    A* p;
public:
    // [...]
    A* operator->() { // 因为是一元运算符,所以没有右操作数
        return p;     // 返回值类型必须是一个定义了 -> 的类型
    }
};

C++ 这样规定重载 -> 的语义:对于表达式 a->b,如果 a 是指针则执行 (*a).b,否则执行 (a.operator->())->b。所以,重载 -> 就相当于把 -> 的任务“转嫁”出去,这里转嫁给了 A* 类型的 p。因此 operator-> 的返回值类型必须是一个定义了 -> 的类型。-> 也不允许定义非成员形式的重载。

其它运算符

到此为止,我们讲解并部分实践了这些运算符的重载:[] 二元 + += = 类型转换 == >> << 前缀 ++ 后缀 ++ 前缀 -- 后缀 -- () 一元 * ->

有一些运算符的重载并没有讲,但它们和我们讲过的内容是十分相似的,不再多提:

  • 普通算术运算符:二元 - 二元 * / % 一元 - 一元 +
  • 位运算符: ^ 二元 & | ~
  • 布尔运算符:!
  • 比较运算符:< > <= >= !=
  • 复合赋值运算符:-= *= /= %= ^= &= |= <<= >>=

new new[] delete delete[] 这四个运算符也可以被重载,但他们有着特殊的语法;这里篇幅所限无法展开。

<=> ->* co_await 这三个运算符也可以被重载,但它们的定义目前我们并不了解,所以这里略过不提。

还有一个非运算符的东西叫做“自定义字面量”也可以像重载运算符一样地被定义。

除此之外,以下运算符也可以被重载,但它们并不建议被重载:

  • 取地址运算符 一元 &。一般不会有什么地方要重载这个,而且这样写完之后就没法用一般方法得到指向它的指针了。
  • 布尔运算符 || && 。一般用到这两个运算符都通过重载 operator bool 来实现。而且重载他们的坏处是:会丢失短路求值特性。
  • 逗号运算符 ,。除非你在使用什么“黑魔法”,否则重载它只会带来使用上的麻烦和困惑。

这些运算符不能重载:

  • 作用域解析运算符 ::
  • 成员运算符 .
  • 成员指针运算符 .*
  • 条件运算符 ?:
  • sizeof sizeof... alignof throw

当然,你也不能定义新的运算符,或者更改已有运算符的优先级、结合方向或求值顺序。

最近更新:
代码未运行