30、综合实战:设计一个有理数类,完整实现所有常用运算符重载,编写单元测试

终于到了运算符重载的收官之战。

前面我们聊了那么多语法细节、坑点、最佳实践,说实话,都是「散装」的知识。今天这一章,咱们要把它们全部串起来——亲手设计一个有理数类,把常用的运算符全部重载一遍,再写一套单元测试来验证它。

我个人习惯,学一个新特性,一定要亲手写一个完整的类。光看语法书是记不住的。你想想看,C++ 里那些标准库类型,比如 std::complexstd::chrono::duration,它们为什么用起来那么顺手?就是因为运算符重载做得好。

本章目标: 设计一个 Rational 类,支持加减乘除、比较、自增自减、输入输出、类型转换等全部常用运算符,并编写单元测试覆盖所有功能。

30.1 有理数类的设计思路

有理数,说白了就是分数。一个分子,一个分母,再加上约分、通分这些基本操作。

我在项目中遇到过好几次需要精确计算分数的场景——比如金融领域的费率计算、游戏中的概率系统。用浮点数?精度问题会让你抓狂。用有理数?一切都能精确表示。

设计一个有理数类,核心要考虑以下几点:

  • 数据成员: 分子 num_ 和分母 den_,分母永远为正。
  • 约分: 每次构造或运算后,都要调用 reduce() 化简到最简形式。
  • 符号处理: 负号统一放在分子上,分母保持正数。
  • 零值: 分子为 0 时,分母统一设为 1。

嗯,这里要注意:分母不能为 0。这是有理数的基本约束,我们必须在构造函数里检查。

30.2 核心代码实现

先看类的整体骨架。我习惯把声明和实现分开,但为了演示方便,这里直接给出完整定义。

#include <iostream>
#include <stdexcept>
#include <numeric>  // for std::gcd (C++17)

class Rational {
public:
    // 构造函数
    Rational() : num_(0), den_(1) {}
    Rational(int n) : num_(n), den_(1) {}
    Rational(int n, int d) : num_(n), den_(d) {
        if (d == 0) {
            throw std::invalid_argument("分母不能为0");
        }
        reduce();
    }

    // 拷贝和赋值 —— 编译器生成的默认版本就够用
    // 但为了演示,我们显式声明一下
    Rational(const Rational& other) = default;
    Rational& operator=(const Rational& other) = default;

    // 算术运算符
    Rational operator+(const Rational& rhs) const;
    Rational operator-(const Rational& rhs) const;
    Rational operator*(const Rational& rhs) const;
    Rational operator/(const Rational& rhs) const;

    // 复合赋值运算符
    Rational& operator+=(const Rational& rhs);
    Rational& operator-=(const Rational& rhs);
    Rational& operator*=(const Rational& rhs);
    Rational& operator/=(const Rational& rhs);

    // 自增自减
    Rational& operator++();    // 前置
    Rational  operator++(int); // 后置
    Rational& operator--();    // 前置
    Rational  operator--(int); // 后置

    // 比较运算符
    bool operator==(const Rational& rhs) const;
    bool operator!=(const Rational& rhs) const;
    bool operator<(const Rational& rhs) const;
    bool operator>(const Rational& rhs) const;
    bool operator<=(const Rational& rhs) const;
    bool operator>=(const Rational& rhs) const;

    // 类型转换
    explicit operator double() const;
    explicit operator float() const;

    // 输入输出
    friend std::ostream& operator<<(std::ostream& os, const Rational& r);
    friend std::istream& operator>>(std::istream& is, Rational& r);

    // 工具函数
    int numerator()   const { return num_; }
    int denominator() const { return den_; }

private:
    int num_;   // 分子
    int den_;   // 分母(始终为正)

    void reduce() {
        if (den_ < 0) {
            num_ = -num_;
            den_ = -den_;
        }
        if (num_ == 0) {
            den_ = 1;
            return;
        }
        int g = std::gcd(std::abs(num_), den_);
        num_ /= g;
        den_ /= g;
    }
};
小技巧: 使用 std::gcd 需要 C++17。如果你还在用 C++11/14,可以自己写一个欧几里得算法,也就几行代码。

30.3 运算符重载的实现细节

算术运算符的实现,说白了就是分数运算的公式。我直接贴代码,然后挑几个重点讲。

// 算术运算符
Rational Rational::operator+(const Rational& rhs) const {
    return Rational(num_ * rhs.den_ + rhs.num_ * den_, den_ * rhs.den_);
}

Rational Rational::operator-(const Rational& rhs) const {
    return Rational(num_ * rhs.den_ - rhs.num_ * den_, den_ * rhs.den_);
}

Rational Rational::operator*(const Rational& rhs) const {
    return Rational(num_ * rhs.num_, den_ * rhs.den_);
}

Rational Rational::operator/(const Rational& rhs) const {
    if (rhs.num_ == 0) {
        throw std::domain_error("除数不能为0");
    }
    return Rational(num_ * rhs.den_, den_ * rhs.num_);
}

// 复合赋值 —— 复用算术运算符
Rational& Rational::operator+=(const Rational& rhs) {
    *this = *this + rhs;
    return *this;
}

// 其他复合赋值类似,省略

// 前置自增
Rational& Rational::operator++() {
    num_ += den_;  // 加1相当于分子加分母
    return *this;
}

// 后置自增
Rational Rational::operator++(int) {
    Rational temp = *this;
    ++(*this);
    return temp;
}

// 比较运算符 —— 全部基于 operator< 和 operator== 实现
bool Rational::operator==(const Rational& rhs) const {
    return num_ == rhs.num_ && den_ == rhs.den_;
}

bool Rational::operator<(const Rational& rhs) const {
    return num_ * rhs.den_ < rhs.num_ * den_;
}

// 其他比较运算符用 == 和 < 组合实现
bool Rational::operator!=(const Rational& rhs) const { return !(*this == rhs); }
bool Rational::operator>(const Rational& rhs) const  { return rhs < *this; }
bool Rational::operator<=(const Rational& rhs) const { return !(rhs < *this); }
bool Rational::operator>=(const Rational& rhs) const { return !(*this < rhs); }

// 类型转换
Rational::operator double() const {
    return static_cast<double>(num_) / den_;
}

Rational::operator float() const {
    return static_cast<float>(num_) / den_;
}

// 输入输出
std::ostream& operator<<(std::ostream& os, const Rational& r) {
    os << r.num_ << '/' << r.den_;
    return os;
}

std::istream& operator>>(std::istream& is, Rational& r) {
    int n, d;
    char slash;
    is >> n >> slash >> d;
    if (slash != '/') {
        is.setstate(std::ios::failbit);
        return is;
    }
    r = Rational(n, d);
    return is;
}

这里有个细节我想强调一下:复合赋值运算符。很多人喜欢在复合赋值里直接写运算逻辑,但我建议复用算术运算符。为什么?因为一旦你修改了算术逻辑,复合赋值自动跟着变,不会出现不一致。

我曾经在一个项目里看到同事手写了 operator+=operator+ 两套逻辑,结果改了一个忘了改另一个,debug 了一下午……

30.4 单元测试怎么写

写单元测试,说白了就是「用代码验证代码」。我个人习惯用 assert 做简单测试,或者用 Google Test 这种框架。这里为了演示,我直接用 assert 加一个测试函数。

#include <cassert>
#include <sstream>

void test_rational() {
    // 1. 构造与约分
    Rational r1(2, 4);
    assert(r1.numerator() == 1);
    assert(r1.denominator() == 2);

    Rational r2(0, 5);
    assert(r2.numerator() == 0);
    assert(r2.denominator() == 1);

    // 2. 负数处理
    Rational r3(3, -6);
    assert(r3.numerator() == -1);
    assert(r3.denominator() == 2);

    // 3. 算术运算
    Rational a(1, 2), b(1, 3);
    assert(a + b == Rational(5, 6));
    assert(a - b == Rational(1, 6));
    assert(a * b == Rational(1, 6));
    assert(a / b == Rational(3, 2));

    // 4. 复合赋值
    Rational c(1, 2);
    c += Rational(1, 3);
    assert(c == Rational(5, 6));

    // 5. 自增自减
    Rational d(1, 2);
    assert(++d == Rational(3, 2));
    assert(d++ == Rational(3, 2));
    assert(d == Rational(5, 2));

    // 6. 比较
    assert(Rational(1, 2) < Rational(2, 3));
    assert(Rational(3, 4) > Rational(1, 2));
    assert(Rational(2, 4) == Rational(1, 2));

    // 7. 类型转换
    Rational e(1, 2);
    double val = static_cast<double>(e);
    assert(val == 0.5);

    // 8. 输入输出
    std::ostringstream oss;
    oss << Rational(3, 4);
    assert(oss.str() == "3/4");

    std::istringstream iss("5/8");
    Rational f;
    iss >> f;
    assert(f == Rational(5, 8));

    // 9. 异常
    try {
        Rational bad(1, 0);
        assert(false); // 不应该到达这里
    } catch (const std::invalid_argument&) {
        // 正常
    }

    try {
        Rational g(1, 2);
        g / Rational(0, 1);
        assert(false);
    } catch (const std::domain_error&) {
        // 正常
    }

    std::cout << "所有测试通过!" << std::endl;
}

int main() {
    test_rational();
    return 0;
}
注意: 单元测试一定要覆盖边界情况。比如分母为 0、分子为 0、负数、约分后为整数、除以 0 等等。这些 corner case 往往是 bug 的高发区。

30.5 知识体系总览

下面这张图,我把有理数类涉及的所有知识点串在了一起。你可以把它当作一个检查清单——写任何自定义类型时,都可以对照着看看自己有没有遗漏。

Rational 类 数据与构造 运算符重载 分子 / 分母 约分 reduce() 异常处理 算术: + - * / 复合赋值: += -= *= /= 自增自减: ++ -- 比较: == != < > <= >= 类型转换: double / float 输入输出: << >> 单元测试

30.6 一些经验之谈

写到这里,我想分享几个实战中总结出来的经验:

  • 运算符重载不是越多越好。 只重载那些「有自然语义」的运算符。比如给有理数类重载 operator% 取模?这就不太合理了。
  • 尽量用友元函数实现二元运算符。 这样支持隐式类型转换。比如 Rational(1,2) + 33 + Rational(1,2) 都能工作。
  • 类型转换运算符要加 explicit。 避免隐式转换带来的意外。我见过有人写 if (r) 结果把有理数转成了 double,然后跟 0 比较……
  • 单元测试要自动化。 每次修改代码后跑一遍测试,能省去大量手动验证的时间。
推荐做法: 把测试代码和实现代码放在一起,用 #ifdef UNIT_TEST 控制编译。这样既能保证测试随时可用,又不会影响发布版本。

好了,有理数类的完整实现和测试就到这里。你可以在自己的项目里直接使用这个类,也可以把它当作模板,去设计其他自定义类型。记住,运算符重载的核心是「让自定义类型用起来像内置类型一样自然」。


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