30、综合实战:设计一个有理数类,完整实现所有常用运算符重载,编写单元测试
终于到了运算符重载的收官之战。
前面我们聊了那么多语法细节、坑点、最佳实践,说实话,都是「散装」的知识。今天这一章,咱们要把它们全部串起来——亲手设计一个有理数类,把常用的运算符全部重载一遍,再写一套单元测试来验证它。
我个人习惯,学一个新特性,一定要亲手写一个完整的类。光看语法书是记不住的。你想想看,C++ 里那些标准库类型,比如 std::complex、std::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;
}
30.5 知识体系总览
下面这张图,我把有理数类涉及的所有知识点串在了一起。你可以把它当作一个检查清单——写任何自定义类型时,都可以对照着看看自己有没有遗漏。
30.6 一些经验之谈
写到这里,我想分享几个实战中总结出来的经验:
- 运算符重载不是越多越好。 只重载那些「有自然语义」的运算符。比如给有理数类重载
operator%取模?这就不太合理了。 - 尽量用友元函数实现二元运算符。 这样支持隐式类型转换。比如
Rational(1,2) + 3和3 + Rational(1,2)都能工作。 - 类型转换运算符要加 explicit。 避免隐式转换带来的意外。我见过有人写
if (r)结果把有理数转成了 double,然后跟 0 比较…… - 单元测试要自动化。 每次修改代码后跑一遍测试,能省去大量手动验证的时间。
#ifdef UNIT_TEST 控制编译。这样既能保证测试随时可用,又不会影响发布版本。
好了,有理数类的完整实现和测试就到这里。你可以在自己的项目里直接使用这个类,也可以把它当作模板,去设计其他自定义类型。记住,运算符重载的核心是「让自定义类型用起来像内置类型一样自然」。