ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++分数类设计实战:运算符重载与最大公约数约分

C++分数类设计实战:运算符重载与最大公约数约分

1. 为什么值得动手写一个分数运算类

很多人看到“分数的四则运算”第一反应是:这不就是小学数学吗,有什么好写的。但真正动手用C++实现过的人都知道,这里面藏着不少坑——整数溢出、分母为零、约分效率、负数取模、运算符重载的返回值设计,每一个都能让代码在边界测试时翻车。我带过几个刚入门的同学做这个练习,十个人里有八个第一版跑不过1/3 + 1/6之外的用例,问题基本都出在通分时的溢出和约分时的符号处理上。

这篇文章面向的是已经学过C++基础语法、想通过一个完整小项目把类设计、运算符重载、异常处理、最大公约数算法串起来的读者。哪怕你只是想把作业写扎实一点,或者面试前找个练手项目,这个题材都够用。我会从整体设计思路讲起,把每一步的参数选择、算法取舍、边界处理都摊开说,最后给出一份可以直接编译运行的完整实现,以及我在调试过程中踩过的那些坑。

核心关键词就三个:分数类设计、运算符重载、最大公约数约分。把这三个吃透,这个项目基本就稳了。

2. 整体设计与方案选型

2.1 用类封装还是用函数堆

最朴素的做法是写四个函数add、sub、mul、div,每个接收四个整数参数(两个分数的分子分母),返回四个整数。这样写能跑,但有几个致命问题:一是调用时参数顺序容易搞混,add(a,b,c,d)到底是a/b + c/d还是别的,看代码的人得对着函数定义猜;二是没法链式运算,a + b + c得写成嵌套调用,可读性极差;三是没法统一处理约分和符号,每个函数都得重复一遍约分逻辑。

所以正确的做法是设计一个Fraction类,用两个整型成员numerator(分子)和denominator(分母)表示一个分数,然后把加减乘除都做成运算符重载。这样f1 + f2写起来跟整数运算一模一样,代码自解释性直接拉满。这也是C++里“用类型系统表达意图”的典型思路——你定义了一个新类型,就应该让它用起来像内置类型一样自然。

2.2 分子分母用什么类型

用int还是long long?这是个必须提前想清楚的问题。假设你用int,两个分母相乘做通分,分母最大到 46340 左右就会溢出(因为 46340² ≈ 2.1×10⁹,接近 int 上限)。也就是说,只要用户输入1/50000 + 1/50000,通分时50000 × 50000 = 2.5×10⁹直接溢出成负数,结果就废了。

我的选择是long long。64位有符号整数上限约 9.2×10¹⁸,两个分母相乘只要各自不超过 3×10⁹ 就安全,实际使用中分母很少超过几万,余量非常充足。代价只是每个对象多占8字节,对这个小项目来说完全可以接受。如果你要做竞赛级的高精度分数运算,那得考虑大整数库,但那是另一个话题了。

2.3 约分策略:构造时约还是运算后约

有两种策略:一是每次运算完立即约分,二是构造对象时约分、运算时先不约。我选的是构造时约分 + 运算结果构造时自动约分。因为Fraction的构造函数里调用一次reduce(),所有通过构造函数产生的对象(包括运算返回的临时对象)都是最简形式,这样对象的不变式(invariant)就非常清晰:任何时候分子分母都互质,分母恒为正。

这个设计的好处是,你不需要在每个运算符函数里手动写约分代码,只要保证返回值是通过构造函数创建的就行。坏处是构造函数的开销稍微大一点(一次gcd计算),但gcd是欧几里得算法,对数级复杂度,可以忽略。

2.4 符号放在哪:分子还是分母

数学上分数-1/2和1/-2是等价的,但代码里必须统一。我的约定是:分母永远为正,符号由分子承载。这样比较两个分数时不用先统一符号,输出时也简单——直接输出分子、斜杠、分母即可,负数自然显示成-1/2。

实现上就是在构造函数里判断:如果分母小于0,分子分母同时取反。注意这里有个坑,如果分母是LLONG_MIN,取反会溢出,不过实际场景中分母不会到那个量级,可以不做特殊处理,但心里要有数。

3. 核心细节与实操要点

3.1 最大公约数:辗转相除还是更相减损

约分的核心是求最大公约数(GCD)。两种经典算法:辗转相除法(欧几里得算法)和更相减损术。辗转相除法的时间复杂度是 O(log min(a,b)),更相减损术最坏情况是 O(max(a,b)),比如求 gcd(1000000, 1) 要减一百万次。所以毫无疑问选辗转相除法。

但辗转相除法有个细节要注意:取模运算对负数不友好。C++里-7 % 3结果是-1而不是2,所以如果直接对负数取模,递归可能出问题。我的做法是在gcd函数开头先把两个参数取绝对值,保证全程正数运算。取绝对值也有溢出风险(abs(LLONG_MIN)未定义),但同样,实际输入不会到那个量级。

long long gcd(long long a, long long b) { a = (a < 0) ? -a : a; b = (b < 0) ? -b : b; while (b != 0) { long long t = a % b; a = b; b = t; } return a; }

用迭代而不是递归,避免栈溢出,虽然对数级深度递归也不会爆栈,但迭代更稳妥。

3.2 构造函数里的约分与符号归一

构造函数要做三件事:检查分母是否为0、归一化符号、约分。顺序很重要——先检查分母为0(否则后面除零崩溃),再归一化符号(保证gcd处理的是正数),最后约分。

Fraction(long long num, long long den) { if (den == 0) { throw std::invalid_argument("分母不能为零"); } if (den < 0) { num = -num; den = -den; } long long g = gcd(num, den); numerator = num / g; denominator = den / g; }

这里有个细节:如果num是0,gcd(0, den) 返回 den,约分后变成0/1,这是正确的。如果 num 和 den 都是负数,比如-2/-4,先归一化符号变成2/4,再约分成1/2,也对。

注意:分母为0时抛异常而不是返回错误码,是因为分数对象一旦构造出来就应该始终有效。用异常可以让调用方明确知道出了问题,而不是拿到一个“坏对象”继续运算。

3.3 运算符重载的返回值设计

四个运算符都返回Fraction值而不是引用,因为运算结果是新对象,没有现成的对象可以返回引用。有人会想返回const Fraction&来避免拷贝,但那是错的——返回局部对象的引用是悬垂引用,程序直接崩。

参数用const Fraction&传递,避免不必要的拷贝。对于这么小的对象(16字节),拷贝开销其实可以忽略,但养成传const引用的习惯没坏处。

Fraction operator+(const Fraction& other) const { long long num = numerator * other.denominator + other.numerator * denominator; long long den = denominator * other.denominator; return Fraction(num, den); }

注意这里通分后的分子可能很大,numerator * other.denominator最坏情况是 3×10⁹ × 3×10⁹,超过 long long 上限。但实际使用中分子分母不会那么大,如果你要防御性编程,可以在乘法前检查是否溢出,不过那会让代码复杂很多,这个项目里我选择不做。

3.4 除法为什么要单独处理

除法a/b ÷ c/d = a/b × d/c,看起来就是把第二个分数取倒数再相乘。但有个致命问题:如果c是0,取倒数后分母为0,构造函数会抛异常。这其实是正确的行为——除以零本来就该报错。但如果你在operator/里直接写return *this * Fraction(other.denominator, other.numerator);,当other.numerator为0时,Fraction(den, 0)会抛异常,异常信息是“分母不能为零”,用户可能一头雾水:我明明做的是除法,怎么跟我说分母为零?

更好的做法是在operator/里显式检查除数是否为零,抛出更明确的异常信息:

Fraction operator/(const Fraction& other) const { if (other.numerator == 0) { throw std::invalid_argument("除数不能为零"); } return *this * Fraction(other.denominator, other.numerator); }

这样报错信息更贴合用户的操作意图。

4. 完整实现与关键环节拆解

4.1 类的完整定义

把前面说的都串起来,Fraction类的头文件大概长这样:

#include <iostream> #include <stdexcept> #include <numeric> class Fraction { private: long long numerator; long long denominator; static long long gcd(long long a, long long b) { a = (a < 0) ? -a : a; b = (b < 0) ? -b : b; while (b != 0) { long long t = a % b; a = b; b = t; } return a; } void reduce() { long long g = gcd(numerator, denominator); numerator /= g; denominator /= g; } public: Fraction(long long num = 0, long long den = 1) { if (den == 0) { throw std::invalid_argument("分母不能为零"); } if (den < 0) { num = -num; den = -den; } numerator = num; denominator = den; reduce(); } long long getNumerator() const { return numerator; } long long getDenominator() const { return denominator; } Fraction operator+(const Fraction& other) const { long long num = numerator * other.denominator + other.numerator * denominator; long long den = denominator * other.denominator; return Fraction(num, den); } Fraction operator-(const Fraction& other) const { long long num = numerator * other.denominator - other.numerator * denominator; long long den = denominator * other.denominator; return Fraction(num, den); } Fraction operator*(const Fraction& other) const { return Fraction(numerator * other.numerator, denominator * other.denominator); } Fraction operator/(const Fraction& other) const { if (other.numerator == 0) { throw std::invalid_argument("除数不能为零"); } return Fraction(numerator * other.denominator, denominator * other.numerator); } bool operator==(const Fraction& other) const { return numerator == other.numerator && denominator == other.denominator; } bool operator<(const Fraction& other) const { return numerator * other.denominator < other.numerator * denominator; } friend std::ostream& operator<<(std::ostream& os, const Fraction& f) { if (f.denominator == 1) { os << f.numerator; } else { os << f.numerator << "/" << f.denominator; } return os; } friend std::istream& operator>>(std::istream& is, Fraction& f) { long long num, den; char slash; is >> num >> slash >> den; if (slash != '/') { is.setstate(std::ios::failbit); return is; } f = Fraction(num, den); return is; } };

4.2 输入输出重载的细节

输出重载里我加了一个判断:如果分母是1,只输出分子。这样Fraction(4, 2)约分后是2/1,输出就是2而不是2/1,更符合人的阅读习惯。这个细节很多实现会忽略,但用户体验差别很大。

输入重载要求用户输入分子/分母格式,比如3/4。如果用户输入3没有斜杠,is >> num >> slash >> den会在读slash时失败,slash保持原值(未初始化),所以我在读之前应该先初始化slash = 0,或者用is >> num然后is.get()读斜杠。更稳妥的写法是:

friend std::istream& operator>>(std::istream& is, Fraction& f) { long long num, den; char slash = 0; is >> num >> slash >> den; if (!is || slash != '/') { is.setstate(std::ios::failbit); return is; } f = Fraction(num, den); return is; }

这样即使输入格式错误,流状态也会被正确设置,调用方可以通过if (cin >> f)判断是否成功。

4.3 比较运算符的必要性

有人觉得分数运算只要加减乘除就够了,比较运算符是多余的。但实际用起来,==和<非常有用——比如你想验证1/2 + 1/3 == 5/6,没有==就得手动比较分子分母。而且<在排序场景下必不可少,比如把一组分数从小到大排列。

<的实现是交叉相乘比较:a/b < c/d等价于a*d < c*b(因为分母都为正)。这里同样有溢出风险,但和前面一样,实际场景可以接受。

4.4 主函数与测试用例

写一个简单的主函数来验证各种情况:

int main() { Fraction a(1, 2); Fraction b(1, 3); std::cout << a << " + " << b << " = " << a + b << std::endl; std::cout << a << " - " << b << " = " << a - b << std::endl; std::cout << a << " * " << b << " = " << a * b << std::endl; std::cout << a << " / " << b << " = " << a / b << std::endl; Fraction c(-4, 6); std::cout << "约分结果: " << c << std::endl; try { Fraction d(1, 0); } catch (const std::exception& e) { std::cout << "异常捕获: " << e.what() << std::endl; } try { Fraction e(1, 2); Fraction f(0, 1); std::cout << e / f << std::endl; } catch (const std::exception& e) { std::cout << "异常捕获: " << e.what() << std::endl; } return 0; }

预期输出:

1/2 + 1/3 = 5/6 1/2 - 1/3 = 1/6 1/2 * 1/3 = 1/6 1/2 / 1/3 = 3/2 约分结果: -2/3 异常捕获: 分母不能为零 异常捕获: 除数不能为零

5. 常见问题与排查技巧实录

5.1 结果符号不对

最常见的症状是1/2 - 3/4算出来是1/4而不是-1/4。原因通常是构造函数里没有处理分母为负的情况,或者gcd函数对负数取模出了问题。排查方法:先单独测试Fraction(-1, 4)的输出,如果输出1/-4或者-1/-4,说明符号归一化没做对。检查构造函数里if (den < 0)那个分支,确保分子分母同时取反。

5.2 约分不彻底

比如2/4输出还是2/4。这通常是gcd函数写错了,或者构造函数里忘了调用reduce()。排查方法:单独测试gcd(2, 4)是否返回2。如果gcd返回1,检查辗转相除的循环条件是不是写成了while (b > 0)而不是while (b != 0),前者在b为负数时会直接跳过循环。

5.3 除以零没有报错

如果1/2 / 0/1没有抛异常而是输出了某个奇怪的结果,说明operator/里没有检查除数是否为零。注意检查的是other.numerator == 0而不是other.denominator == 0,因为除数0/1的分子是0、分母是1,分母不为零但整个分数值为零。

5.4 大数运算溢出

输入1/100000 + 1/100000结果变成负数,这是通分时100000 * 100000 = 10¹⁰超过了int上限。解决办法就是把成员类型改成long long。如果你已经用了long long还溢出,那说明输入的分母超过了 3×10⁹,这种场景建议直接上大整数库,或者限制输入范围。

5.5 常见问题速查表

症状可能原因排查方法解决方案
结果符号错误分母为负未归一化测试Fraction(-1,4)输出构造函数中分母为负时分子分母同时取反
约分不彻底gcd实现错误或未调用单独测试gcd(2,4)检查辗转相除循环条件,确保构造函数调用reduce
除以零未报错operator/未检查测试1/2 / 0/1在operator/中检查other.numerator是否为0
大数溢出成员类型为int测试1/50000 + 1/50000改用long long
输入格式错误崩溃未检查流状态输入3不带斜杠读入后检查slash是否为'/',设置failbit
输出2/1而非2输出重载未处理分母为1测试Fraction(4,2)输出时判断分母是否为1

实操心得:调试分数类的时候,不要一上来就测复杂的四则混合运算。先测单个分数的构造和输出,确保Fraction(4, 6)输出2/3、Fraction(-4, 6)输出-2/3、Fraction(4, -6)也输出-2/3。这三个用例过了,再测加减法,最后测乘除和异常。由简到繁,定位问题会快很多。

6. 进一步扩展的方向

这个基础版本跑通之后,有几个方向可以继续打磨。一是加上+=、-=、*=、/=这些复合赋值运算符,让f1 += f2这种写法也能用,实现上就是*this = *this + other; return *this;。二是加上<=、>、>=、!=这些比较运算符,其实有了==和<之后,其他的都可以基于这两个推导出来。三是把分数和整数之间的运算也支持上,比如f + 1,可以通过隐式转换构造函数Fraction(long long num = 0, long long den = 1)来实现,因为整数可以隐式转成Fraction。

还有一个比较实用的扩展是加上toDouble()方法,返回double类型的近似值,方便和浮点数比较或者输出。实现就是return static_cast<double>(numerator) / denominator;,注意要先转double再除,否则整数除法会截断。

我在实际写这个类的时候,最大的体会是:边界情况永远比正常情况多。正常路径1/2 + 1/3五分钟就能写对,但分母为零、分子为零、负数、大数、输入格式错误这些边界,花的时间是正常路径的好几倍。所以写完第一版之后,一定要专门花时间构造边界用例去测,而不是跑通一个正常用例就收工。这个习惯在写任何有数学运算的代码时都适用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进