
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是0gcd(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_castdouble(numerator) / denominator;注意要先转double再除否则整数除法会截断。我在实际写这个类的时候最大的体会是边界情况永远比正常情况多。正常路径1/2 1/3五分钟就能写对但分母为零、分子为零、负数、大数、输入格式错误这些边界花的时间是正常路径的好几倍。所以写完第一版之后一定要专门花时间构造边界用例去测而不是跑通一个正常用例就收工。这个习惯在写任何有数学运算的代码时都适用。