8. 重载自增自减运算符:前置与后置的语法区分

自增(++)和自减(--)运算符,在 C++ 里算是比较特殊的一对。为什么?因为它们有前置后置两种写法,而且语义完全不同。你想想看,++ii++,一个先加后用,一个先用后加。放到自定义类型里,编译器怎么知道你要重载的是哪一个?

嗯,这里就是关键了。C++ 设计者想了个巧妙的办法:通过一个额外的 int 参数来区分。前置版本没有参数,后置版本带一个 int 参数(这个参数在函数体内用不到,纯粹是为了区分)。

核心规则:
  • 前置 ++/--:T& operator++();T& operator--();
  • 后置 ++/--:T operator++(int);T operator--(int);

8.1 前置版本:返回引用,效率更高

前置自增的逻辑很简单:先修改对象本身,然后返回修改后的引用。我在项目中处理自定义迭代器时,前置版本用得最多,因为它没有临时对象的拷贝

class Counter {
private:
    int value_;
public:
    Counter(int v = 0) : value_(v) {}

    // 前置 ++
    Counter& operator++() {
        ++value_;
        return *this;  // 返回自身引用
    }

    // 前置 --
    Counter& operator--() {
        --value_;
        return *this;
    }

    int get() const { return value_; }
};

调用的时候,++c 直接修改 c 的值,然后返回 c 本身。没有拷贝,没有临时对象。说白了,这就是你想要的“原地操作”。

8.2 后置版本:返回副本,注意性能

后置版本就有点“绕”了。它需要先保存当前状态,再修改对象,最后返回保存的那个副本。我刚开始学的时候总觉得奇怪:为什么后置要返回一个副本而不是引用?

原因很简单:后置的语义是“先用后改”。你拿到的是修改前的值,而对象本身已经变了。所以必须拷贝一份旧值返回。

class Counter {
public:
    // 后置 ++
    Counter operator++(int) {
        Counter temp = *this;  // 保存当前状态
        ++value_;              // 修改自身
        return temp;           // 返回旧值
    }

    // 后置 --
    Counter operator--(int) {
        Counter temp = *this;
        --value_;
        return temp;
    }
};
我曾经踩过的坑:后置版本一定会产生临时对象。如果你在循环里用 for (auto it = v.begin(); it != v.end(); it++),每次迭代都会多一次拷贝。对于迭代器或复杂对象,性能损耗是实打实的。所以我个人习惯:能前置就别后置

8.3 前置 vs 后置:一张图看懂

前置 vs 后置自增执行流程 前置 ++obj 1. 直接修改对象内部值 2. 返回对象本身的引用 3. 无临时对象,效率高 返回类型:Counter& 后置 obj++ 1. 拷贝当前对象(临时对象) 2. 修改原对象的值 3. 返回临时对象(旧值) 返回类型:Counter(值拷贝) vs 总结:前置返回引用,后置返回副本。无特殊需求,优先用前置。

8.4 完整示例:一个可自增的计数器

把前置和后置放在一起对比,你就知道区别在哪了。

#include <iostream>

class Counter {
    int val_;
public:
    Counter(int v = 0) : val_(v) {}

    // 前置
    Counter& operator++() {
        ++val_;
        return *this;
    }

    // 后置
    Counter operator++(int) {
        Counter old = *this;
        ++val_;
        return old;
    }

    friend std::ostream& operator<<(std::ostream& os, const Counter& c) {
        os << c.val_;
        return os;
    }
};

int main() {
    Counter c(10);

    std::cout << "初始值: " << c << "\n";

    // 前置
    std::cout << "++c: " << ++c << "\n";      // 输出 11
    std::cout << "当前值: " << c << "\n";     // 输出 11

    // 后置
    std::cout << "c++: " << c++ << "\n";      // 输出 11(旧值)
    std::cout << "当前值: " << c << "\n";     // 输出 12

    return 0;
}
小技巧:如果你在实现一个迭代器类,建议把后置 ++ 直接复用前置 ++ 的实现,减少代码重复:
Counter operator++(int) { Counter old = *this; ++(*this); return old; }

8.5 自减运算符:完全对称

自减运算符的规则和自增一模一样。前置返回引用,后置返回副本。我就不重复贴代码了,你照着自增的写法把 ++ 换成 -- 就行。

注意:自减运算符在迭代器中要特别小心。如果你实现的是双向迭代器,自减必须保证能正确回退。我曾经在写一个链表迭代器时,后置自减忘了保存前驱节点,结果回退时直接崩了。嗯,调试了整整一个下午。

8.6 总结一下

特性 前置 ++/-- 后置 ++/--
语法 T& operator++() T operator++(int)
返回值 引用(T&) 值(T)
临时对象 有(拷贝一次)
语义 先修改,后使用 先使用,后修改
推荐场景 迭代器、循环控制 需要旧值的表达式

说白了,前置和后置的选择,本质上是在语义正确性能之间做权衡。大多数情况下,前置版本就够用了。只有当你确实需要“先取值再自增”的语义时,才去用后置。

我个人习惯:写自定义类型时,永远先实现前置版本。后置版本用前置版本去实现,省事又安全。


公众号:蓝海资料掘金营,微信deep3321