C++从零实现分数类:精确四则运算与边界处理

发布时间:2026/10/12 13:02:20
C++从零实现分数类:精确四则运算与边界处理 1. 从整数除法的一个坑说起为什么需要自己实现分数类很多刚接触C的朋友都写过类似这样的代码计算1/2 1/3然后发现结果输出是0。原因很简单1/2在C里执行的是整数除法结果是0而不是0.5。就算换成double1.0/2.0 1.0/3.0得到的是0.833333精度丢失的问题依然存在而且你没法直接判断两个分数是否严格相等。这就是分数类存在的意义。它把分子和分母分开存储所有运算都在整数域内完成结果永远精确。你拿它算1/2 1/3输出就是5/6干净利落没有任何浮点误差。这篇文章要聊的就是怎么用C从零实现一个能跑四则运算的分数类。我会把设计思路、约分逻辑、运算符重载、边界处理这些环节全部拆开讲代码可以直接抄去用。适合已经学过C基础语法、想通过一个完整小项目来巩固类设计、运算符重载和异常处理的朋友。如果你正在准备课程设计或者想找一个练手项目这个题目非常合适——它足够小能在一两天内吃透又足够完整涵盖了面向对象编程的大部分核心知识点。我见过不少人写分数类写完发现负数运算结果不对、分母为零直接崩溃、连续运算时中间结果溢出。这些问题不是靠“多测试几组数据”就能解决的得从设计层面就想清楚。下面我按实际开发中会遇到的顺序一步步来拆。2. 分数类的骨架设计数据成员与构造逻辑2.1 分子分母怎么存符号放哪里分数类的数据成员看起来很简单两个整数就够了class Fraction { private: long long numerator; // 分子 long long denominator; // 分母 };但这里有几个决策点需要想明白。为什么用long long而不是int分数运算中分母会相乘比如1/100 1/99通分后分母变成9900还在int范围内。但如果连续做多次加法分母会指数级增长。用long long能把可用范围扩大一大截代价只是多占几个字节的内存。实测下来用int做五六次连续分数加法就可能溢出换成long long能撑到十几次。符号统一放在分子上。这是分数类设计的一个基本约定分母永远保持正数负号由分子携带。比如-1/2存成numerator -1, denominator 2而不是numerator 1, denominator -2。这样做的好处是约分和比较的逻辑会简单很多——你不需要在每次运算时都判断分母的符号。构造函数里就要把这个规则落实Fraction(long long num, long long den) { if (den 0) { throw std::invalid_argument(分母不能为零); } if (den 0) { num -num; den -den; } numerator num; denominator den; reduce(); // 构造时立即约分 }注意这里在构造函数里就调用了reduce()。这个选择很关键保持对象始终处于最简形式后续所有运算都不需要再担心结果是不是最简分数。代价是每次构造都有一次约分开销但换来的逻辑简洁性完全值得。2.2 约分函数的实现细节约分的核心是求最大公约数。C17 标准库里有std::gcd直接用就行#include numeric void reduce() { long long g std::gcd(std::abs(numerator), denominator); numerator / g; denominator / g; }如果你的编译器不支持C17手写一个欧几里得算法也很简单long long gcd(long long a, long long b) { while (b ! 0) { long long t b; b a % b; a t; } return a; }这里有个容易忽略的点对分子取绝对值再求gcd。因为分子可能是负数而std::gcd在C17中要求参数是非负整数实际上传入负数行为未定义。取绝对值保证结果正确约分后再由分子携带符号。还有一个边界情况分子为0时gcd(0, denominator)返回denominator约分后变成0/1。这正是我们想要的——零就是零不需要保留分母信息。2.3 默认构造与整数隐式转换有时候你会希望Fraction f 3;这种写法能工作也就是让整数自动变成分数。这需要提供一个单参数构造函数Fraction(long long num 0) : numerator(num), denominator(1) {}但要注意单参数构造函数如果不加explicit编译器会在你意想不到的地方做隐式转换。比如你写了一个接受Fraction参数的函数传个int进去它也能编译通过。大多数情况下这是好事但如果你不希望这种隐式转换发生就加上explicit关键字。我的建议是分数类保留隐式转换。因为数学上整数本来就是分母为1的分数让Fraction f 2;能工作符合直觉用起来也方便。3. 四则运算的运算符重载从通分到结果化简3.1 加法和减法通分是核心分数加法的公式很直接a/b c/d (a*d c*b) / (b*d)。但直接这样写会有两个问题中间结果可能溢出以及结果可能不是最简形式。先看基本实现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); // 构造函数会自动约分 }这里num和den都是long long乘法结果也在long long范围内。但如果两个分数的分母都接近long long的上限乘积就会溢出。实际使用中这种情况很少见但如果你要写一个健壮的库可以考虑用__int128GCC/Clang支持或者提前检查溢出。减法的实现和加法几乎一样只是中间的加号变成减号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); }一个值得注意的优化在通分之前先求两个分母的最小公倍数而不是直接相乘。这样可以减小中间结果的规模降低溢出风险。比如1/6 1/4直接相乘分母是24用最小公倍数则是12。实现上可以先求gcd(denominator, other.denominator)然后long long g std::gcd(denominator, other.denominator); long long lcm denominator / g * other.denominator; long long num numerator * (lcm / denominator) other.numerator * (lcm / other.denominator); return Fraction(num, lcm);这个版本比直接相乘多了一次gcd调用但中间结果更小。对于需要做大量连续运算的场景这个优化很有意义。3.2 乘法和除法约分时机的选择乘法最简单分子乘分子分母乘分母。Fraction operator*(const Fraction other) const { return Fraction(numerator * other.numerator, denominator * other.denominator); }但这里有个可以优化的地方在相乘之前先交叉约分。比如2/3 * 3/4如果直接相乘得到6/12再约分中间结果是6和12。如果先交叉约分——2和4有公因子23和3有公因子3——变成1/1 * 1/2 1/2中间结果小得多。Fraction operator*(const Fraction other) const { long long g1 std::gcd(std::abs(numerator), other.denominator); long long g2 std::gcd(std::abs(other.numerator), denominator); return Fraction((numerator / g1) * (other.numerator / g2), (denominator / g2) * (other.denominator / g1)); }这个写法看起来复杂但能显著降低溢出的概率。我在处理连续乘法时踩过坑用直接相乘的版本做七八次乘法后分子就溢出了换成先约分的版本同样的运算序列能顺利完成。除法就是乘以倒数Fraction operator/(const Fraction other) const { if (other.numerator 0) { throw std::invalid_argument(除数不能为零); } return *this * Fraction(other.denominator, other.numerator); }注意这里要检查除数是否为零。分数除以零在数学上没有定义程序里必须显式处理。我选择抛异常而不是返回一个特殊值因为调用方需要知道出错了而不是拿到一个看似正常的结果继续算下去。3.3 复合赋值运算符的复用技巧有了、-、*、/四个基本运算符复合赋值运算符、-、*、/可以直接复用Fraction operator(const Fraction other) { *this *this other; return *this; }这种写法虽然多了一次拷贝构造和赋值但代码量少不容易出错。如果追求性能可以单独实现复合赋值避免创建临时对象。但对于分数类这种轻量级对象编译器优化后差异微乎其微没必要为了那点性能牺牲代码可读性。4. 比较、输入输出与类型转换的完整配套4.1 比较运算符交叉相乘的陷阱判断两个分数是否相等最直接的方法是交叉相乘a/b c/d等价于a*d c*b。但这里有个陷阱——如果分母是负数交叉相乘的结果会反过来。因为我们已经在构造函数里保证了分母永远为正所以这个问题不存在。比较逻辑可以放心写bool operator(const Fraction other) const { return numerator * other.denominator other.numerator * denominator; } bool operator(const Fraction other) const { return numerator * other.denominator other.numerator * denominator; }其他比较运算符!、、、都可以基于和来实现C20 甚至可以用运算符一次性生成所有比较。但如果你的项目还在用 C17 或更早的标准手动写全六个比较运算符也不麻烦。一个容易忽略的细节交叉相乘时乘法可能溢出。如果两个分数的分母都很大numerator * other.denominator可能超出long long范围。对于一般教学项目这个问题可以忽略但如果要写生产级代码需要做溢出检查或者用更大范围的整数类型。4.2 输出格式假分数还是带分数分数的输出有两种常见格式假分数如7/3和带分数如2 1/3。大多数场景下假分数更通用因为它是计算机内部表示的直接映射。带分数更适合人类阅读但解析起来麻烦。我通常提供两个输出方式operator输出假分数另外提供一个toMixedString()方法输出带分数。std::ostream operator(std::ostream os, const Fraction f) { if (f.denominator 1) { os f.numerator; } else { os f.numerator / f.denominator; } return os; }注意这里对分母为1的情况做了特殊处理直接输出整数。这样Fraction(4, 2)会输出2而不是2/1更符合阅读习惯。输入运算符需要处理多种格式3/4、5、-2/3都应该能正确解析。实现时可以先读一个整数作为分子然后看下一个字符是不是/如果是就继续读分母。std::istream operator(std::istream is, Fraction f) { long long num, den 1; is num; if (is.peek() /) { is.ignore(); is den; } f Fraction(num, den); return is; }4.3 与浮点数的互转有时候你需要把分数转成double来做近似计算或者输出小数形式double toDouble() const { return static_castdouble(numerator) / denominator; }反过来把double转成分数需要连分数展开算法实现起来复杂得多而且浮点数本身就有精度限制转成分数后可能得到一个分子分母都很大的结果。对于教学项目通常不需要这个功能如果确实需要可以限制分母的最大值用近似算法来逼近。5. 那些只有实际写过才会遇到的边界问题5.1 分母为零构造时拦截还是运算时检查分母为零是分数类最核心的约束。我的做法是在构造函数里就抛异常这样任何试图创建非法分数的操作都会立即失败而不是等到运算时才暴露问题。Fraction(long long num, long long den) { if (den 0) { throw std::invalid_argument(分母不能为零); } // ... }但这里有个连锁反应如果构造函数抛异常那么所有可能产生零分母的运算都需要处理这个异常。比如除法运算中如果除数的分子为零倒数运算就会触发分母为零的异常。所以我在除法运算符里提前检查了other.numerator 0给出更明确的错误信息。5.2 连续运算中的溢出问题前面提到过分数连续运算时分母会快速增长。我做过一个测试从1/1开始每次加上1/nn从2递增做20次加法后分母会变成多大的数实际跑下来分母在第10次左右就超过了int的范围第15次超过了long long的范围。这意味着如果你要支持任意长度的连续运算long long也不够用。解决方案有几个一是用大整数库如 GMP但这就超出了“从零实现”的范围二是每次运算后检查是否溢出溢出时抛异常三是限制运算次数或者提供溢出检测接口。对于教学项目我建议至少加一个溢出检查long long safeMultiply(long long a, long long b) { if (a 0 || b 0) return 0; if (std::abs(a) std::numeric_limitslong long::max() / std::abs(b)) { throw std::overflow_error(乘法溢出); } return a * b; }然后在所有乘法操作前调用这个函数。虽然会增加一些开销但能避免程序在溢出后产生错误结果却不自知。5.3 负数约分的符号处理前面说过符号统一放在分子上但约分时如果分子是负数std::gcd的行为需要特别注意。C17 标准规定std::gcd的参数必须是非负整数传入负数行为未定义。所以约分时要先取绝对值void reduce() { if (numerator 0) { denominator 1; return; } long long g std::gcd(std::abs(numerator), denominator); numerator / g; denominator / g; }注意这里对分子为零的情况做了特殊处理直接把分母设为1。因为gcd(0, x) x如果不特殊处理0/5约分后会变成0/1结果是对的但多了一次不必要的计算。直接返回更高效。6. 完整代码与测试用例把上面的所有片段整合起来得到一个完整的分数类#include iostream #include numeric #include stdexcept #include cstdlib class Fraction { private: long long numerator; long long denominator; void reduce() { if (numerator 0) { denominator 1; return; } long long g std::gcd(std::abs(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 num() const { return numerator; } long long den() const { return denominator; } Fraction operator(const Fraction o) const { return Fraction(numerator * o.denominator o.numerator * denominator, denominator * o.denominator); } Fraction operator-(const Fraction o) const { return Fraction(numerator * o.denominator - o.numerator * denominator, denominator * o.denominator); } Fraction operator*(const Fraction o) const { long long g1 std::gcd(std::abs(numerator), o.denominator); long long g2 std::gcd(std::abs(o.numerator), denominator); return Fraction((numerator / g1) * (o.numerator / g2), (denominator / g2) * (o.denominator / g1)); } Fraction operator/(const Fraction o) const { if (o.numerator 0) { throw std::invalid_argument(除数不能为零); } return *this * Fraction(o.denominator, o.numerator); } Fraction operator(const Fraction o) { *this *this o; return *this; } Fraction operator-(const Fraction o) { *this *this - o; return *this; } Fraction operator*(const Fraction o) { *this *this * o; return *this; } Fraction operator/(const Fraction o) { *this *this / o; return *this; } bool operator(const Fraction o) const { return numerator * o.denominator o.numerator * denominator; } bool operator!(const Fraction o) const { return !(*this o); } bool operator(const Fraction o) const { return numerator * o.denominator o.numerator * denominator; } bool operator(const Fraction o) const { return o *this; } bool operator(const Fraction o) const { return !(o *this); } bool operator(const Fraction o) const { return !(*this o); } double toDouble() const { return static_castdouble(numerator) / denominator; } friend std::ostream operator(std::ostream os, const Fraction f); friend std::istream operator(std::istream is, Fraction f); }; std::ostream operator(std::ostream os, const Fraction f) { if (f.denominator 1) { os f.numerator; } else { os f.numerator / f.denominator; } return os; } std::istream operator(std::istream is, Fraction f) { long long num, den 1; is num; if (is.peek() /) { is.ignore(); is den; } f Fraction(num, den); return is; }测试用例覆盖各种情况int main() { Fraction a(1, 2), b(1, 3); std::cout a b std::endl; // 5/6 std::cout a - b std::endl; // 1/6 std::cout a * b std::endl; // 1/6 std::cout a / b std::endl; // 3/2 Fraction c(-1, 2), d(1, -3); std::cout c d std::endl; // -5/6 std::cout c * d std::endl; // 1/6 Fraction e(4, 2); std::cout e std::endl; // 2 Fraction f(0, 5); std::cout f std::endl; // 0 std::cout (a Fraction(2, 4)) std::endl; // 1 std::cout (a b) std::endl; // 0 std::cout (b a) std::endl; // 1 try { Fraction g(1, 0); } catch (const std::exception ex) { std::cout ex.what() std::endl; // 分母不能为零 } return 0; }7. 几个可以继续深挖的扩展方向这个分数类已经能正确处理四则运算和比较但如果你想继续打磨有几个方向值得尝试。第一个是支持任意精度整数。把long long换成自己实现的大整数类或者引入成熟的大整数库这样连续运算就不会溢出了。代价是实现复杂度大幅上升但如果你在做数学相关的项目这个投入是值得的。第二个是增加幂运算和开方。分数的整数次幂很容易实现——分子分母分别求幂即可。但分数次幂就涉及到开方结果可能不是有理数需要返回浮点数或者抛出异常。第三个是性能优化。目前的实现每次运算都会创建临时对象并约分。如果要做大量运算可以考虑惰性约分——只在需要输出或比较时才约分中间运算保持未约分状态。但这会让溢出风险增加需要权衡。第四个是异常安全。目前的代码在溢出时行为未定义。可以加入溢出检测在检测到溢出时抛出std::overflow_error让调用方有机会处理。我在实际使用这个分数类时最常遇到的场景是处理比例和概率计算。比如计算3/7和5/11的加权平均用分数类可以保证结果精确不会出现浮点数那种0.9999999的问题。如果你也有类似的需求这个类可以直接拿去用改改类型定义和异常处理策略就能适配大部分场景。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询