前言
分数类(Fraction)是练习运算符重载的经典例子:它需要保持「分母为正、分子分母互质」的不变量,需要区分复合赋值(+=)与二元运算(+),还需要处理分母为零、除以零、整数溢出这些边界。写一个能算出正确结果的版本不难,写一个在异常和边界上都自洽的版本,才真正能体现运算符重载的设计规则。
常见误解有两个。第一,把operator+写成返回引用,或者返回局部对象的引用——前者语义错误,后者是悬垂引用。第二,认为重载了+就自动拥有+=,或者反过来。C++ 不会从一个推导出另一个,通常的做法是先实现+=,再用它实现+,这样逻辑只写一遍。
本文实现一个基于std::int64_t的分数类,支持+ - * /、复合赋值、比较和流输出,并说明归一化、溢出和除零的处理方式。示例以 C++17 为标准,使用<numeric>中的std::gcd。
一、不变量与归一化
一个分数表示为num / den,约定:
den != 0。den > 0;符号统一放在分子上。gcd(|num|, den) == 1,即已经约分。num == 0时,den == 1。
只要构造和每次运算后都恢复这三条,后续的比较、输出和相等判断都会简单很多。例如两个分数相等,当且仅当分子和分母分别相等,不需要做交叉相乘。
归一化函数先处理符号,再用std::gcd约分。std::gcd对负参数返回非负结果,对(0, n)返回n,因此0/5会被约成0/1。需要小心std::int64_t的最小值:对INT64_MIN取相反数会溢出,这是本实现没有完全防御的边界,实际使用时应限制输入范围或改用更宽的类型。
二、复合赋值与二元运算
四个复合运算符的实现思路:
| 运算 | 公式 | 约分时机 |
|---|
+= | a/b + c/d = (a*d + c*b) / (b*d) | 运算后 |
-= | a/b - c/d = (a*d - c*b) / (b*d) | 运算后 |
*= | a/b * c/d = (a*c) / (b*d) | 运算后 |
/= | a/b / c/d = (a*d) / (b*c) | 运算后,且检查c != 0 |
先乘后约分会让中间结果变大,增加溢出风险。可以在乘之前用gcd交叉约分,但代码会更复杂。本文选择先算后约,并在文档中明确「不保证大数不溢出」。
二元运算符按值接收左操作数,复用复合赋值:
// 适用于 C++17
friend Fraction operator+(Fraction lhs, const Fraction& rhs) {
lhs += rhs;
return lhs;
}这样写的好处是:左值实参走拷贝,右值实参走移动;函数体内只维护一份逻辑;返回值是值,不会悬垂。不要写成Fraction& operator+(...),那会返回对局部对象的引用。
三、完整实现
// 适用于 C++17
#include <cstdint>
#include <numeric>
#include <ostream>
#include <stdexcept>
class Fraction {
public:
Fraction() = default;
Fraction(std::int64_t numerator, std::int64_t denominator = 1)
: num_(numerator), den_(denominator) {
if (den_ == 0) {
throw std::invalid_argument("denominator must not be zero");
}
normalize();
}
std::int64_t numerator() const { return num_; }
std::int64_t denominator() const { return den_; }
Fraction& operator+=(const Fraction& rhs) {
num_ = num_ * rhs.den_ + rhs.num_ * den_;
den_ = den_ * rhs.den_;
normalize();
return *this;
}
Fraction& operator-=(const Fraction& rhs) {
num_ = num_ * rhs.den_ - rhs.num_ * den_;
den_ = den_ * rhs.den_;
normalize();
return *this;
}
Fraction& operator*=(const Fraction& rhs) {
num_ = num_ * rhs.num_;
den_ = den_ * rhs.den_;
normalize();
return *this;
}
Fraction& operator/=(const Fraction& rhs) {
if (rhs.num_ == 0) {
throw std::domain_error("division by zero fraction");
}
num_ = num_ * rhs.den_;
den_ = den_ * rhs.num_;
normalize();
return *this;
}
Fraction operator-() const {
return Fraction(-num_, den_);
}
friend Fraction operator+(Fraction lhs, const Fraction& rhs) {
lhs += rhs;
return lhs;
}
friend Fraction operator-(Fraction lhs, const Fraction& rhs) {
lhs -= rhs;
return lhs;
}
friend Fraction operator*(Fraction lhs, const Fraction& rhs) {
lhs *= rhs;
return lhs;
}
friend Fraction operator/(Fraction lhs, const Fraction& rhs) {
lhs /= rhs;
return lhs;
}
friend bool operator==(const Fraction& lhs, const Fraction& rhs) {
return lhs.num_ == rhs.num_ && lhs.den_ == rhs.den_;
}
friend bool operator!=(const Fraction& lhs, const Fraction& rhs) {
return !(lhs == rhs);
}
friend bool operator<(const Fraction& lhs, const Fraction& rhs) {
return lhs.num_ * rhs.den_ < rhs.num_ * lhs.den_;
}
friend std::ostream& operator<<(std::ostream& os, const Fraction& value) {
if (value.den_ == 1) {
return os << value.num_;
}
return os << value.num_ << '/' << value.den_;
}
private:
void normalize() {
if (den_ < 0) {
num_ = -num_;
den_ = -den_;
}
const std::int64_t divisor = std::gcd(num_, den_);
if (divisor > 1) {
num_ /= divisor;
den_ /= divisor;
}
}
std::int64_t num_ = 0;
std::int64_t den_ = 1;
};使用示例:
// 适用于 C++17
#include <iostream>
int main() {
const Fraction half(1, 2);
const Fraction third(1, 3);
const Fraction sum = half + third; // 5/6
const Fraction diff = half - third; // 1/6
const Fraction product = half * third; // 1/6
const Fraction quotient = half / third; // 3/2
std::cout << sum << ' ' << diff << ' '
<< product << ' ' << quotient << '\n';
std::cout << (sum == Fraction(5, 6)) << '\n';
return 0;
}按标准语义,sum为 5/6,diff为 1/6,product为 1/6,quotient为 3/2;相等比较为真。这里描述的是运算定义,不是某次运行的输出。
几个实现细节:
- 构造函数不是
explicit,因此half + 1可以把1隐式转换成Fraction(1, 1)。这很方便,但也意味着意外的隐式转换;若不希望如此,可把单参数构造标记为explicit,并另写接受整数的重载。 normalize只在divisor > 1时约分。gcd(0, 1)为 1;gcd(0, 5)为 5,会把 0/5 约成 0/1。gcd(0, 0)为 0,但分母为零的构造已被拒绝,不会走到这里。operator<用交叉相乘,要求分母为正,这一点由归一化保证。乘积仍可能溢出,比较极大分数时需要更宽的类型或浮点近似加精确校验。operator==直接比较成员,依赖「所有对象都已归一化」这一不变量。如果将来允许未归一化的内部状态,这个实现就会失效。
四、溢出与异常安全
std::int64_t的乘法溢出是未定义行为(undefined behavior, UB),不是「回绕成负数」这么简单。两个分母相乘、分子乘对方分母,都可能超出范围。防御方式有三种:
- 在运算前用
__int128(GCC/Clang 扩展,非标准)计算,再检查是否落在int64_t范围内。 - 用任意精度整数库(如 Boost.Multiprecision),以官方文档为准。
- 在接口层限制分子分母的范围,并在运算前做检查。
异常安全方面,四个复合赋值都是「先算到成员,再归一化」。如果归一化不抛异常、运算本身也不抛,那么没有中间状态问题;但一旦引入溢出检查并抛异常,就应先算到局部变量,成功后再写回成员,避免对象处于半更新状态。
常见坑点
- ❌ 允许分母为 0。
// 适用于 C++17
Fraction f(1, 0); // 后续除法、比较全部失去意义✅ 构造时检查并抛出异常。
// 适用于 C++17
if (den_ == 0) { throw std::invalid_argument("zero denominator"); }- ❌ 不把负号统一到分子。
// 适用于 C++17
Fraction f(1, -2); // 输出 1/-2,比较时符号混乱✅ 归一化时把分母变正。
// 适用于 C++17
if (den_ < 0) { num_ = -num_; den_ = -den_; }- ❌ 忘记约分。
// 适用于 C++17
Fraction f(2, 4); // 与 1/2 不相等(若按成员比较)✅ 每次构造和运算后都约分。
// 适用于 C++17
const std::int64_t g = std::gcd(num_, den_);
num_ /= g; den_ /= g;- ❌
operator+返回引用。
// 适用于 C++17
Fraction& operator+(const Fraction& rhs); // 返回局部对象或 *this,语义错误✅ 按值返回。
// 适用于 C++17
friend Fraction operator+(Fraction lhs, const Fraction& rhs);- ❌ 除法不检查零分子。
// 适用于 C++17
Fraction& operator/=(const Fraction& rhs) {
num_ = num_ * rhs.den_;
den_ = den_ * rhs.num_; // rhs 为 0 时分母变成 0
}✅ 先检查再运算。
// 适用于 C++17
if (rhs.num_ == 0) { throw std::domain_error("division by zero"); }- ❌ 比较用
<=作为排序谓词。
// 适用于 C++17
return lhs.num_ * rhs.den_ <= rhs.num_ * lhs.den_; // 相等时也返回 true✅ 严格弱序用<。
// 适用于 C++17
return lhs.num_ * rhs.den_ < rhs.num_ * lhs.den_;- ❌ 认为
a / b会自动调用operator/=。
// 适用于 C++17
// 只定义 operator/=,却期望 a / b 可用✅ 成对提供复合赋值与二元运算。
// 适用于 C++17
friend Fraction operator/(Fraction lhs, const Fraction& rhs) {
lhs /= rhs;
return lhs;
}- ❌ 忽略交叉相乘的溢出。
// 适用于 C++17
Fraction a(1, 1000000000), b(1, 999999999);
bool less = a < b; // 乘积可能超出 int64_t✅ 使用更宽类型或任意精度库,并在接口层限制范围。
// 适用于 C++17
// 以 Boost.Multiprecision 或 __int128 扩展为例,按平台文档使用总结
| 主题 | 做法 |
|---|
| 不变量 | 分母为正、互质、零表示为 0/1 |
| 构造 | 检查分母非零,立即归一化 |
| 复合赋值 | 先算后约,返回*this |
| 二元运算 | 按值接收左操作数,复用复合赋值 |
| 比较 | 归一化后可直接比较成员;<用交叉相乘 |
| 输出 | 分母为 1 时只输出整数部分 |
| 风险 | 乘法溢出、除零、INT64_MIN取反 |
分数类的运算符重载没有太多语法技巧,关键是先定义清楚「什么是一个合法的分数」,再让每个运算符都维护这个不变量。把归一化和边界检查放在构造与复合赋值里,二元运算符自然就短了。