资讯详情

C++手搓分数运算库:从设计到实现的完整指南

📅 2026/10/12 13:42:25 | 华诺云谱 👁 阅读
C++手搓分数运算库:从设计到实现的完整指南
1. 为什么我要用C手搓一个分数运算库分数运算这件事乍一看像是编程入门课的练习题很多人第一反应是这有什么好写的通分一下不就行了。但真正动手写过的人都知道这里面藏着不少坑分母为零怎么处理、结果要不要约分、整数和分数怎么混合运算、连续运算时中间结果会不会溢出……每一个问题单拎出来都不难但要把它们全部处理干净写出一套能放心用的代码其实挺考验基本功的。我用C实现分数四则运算这个想法最早来源于一个实际需求。当时在做一个涉及比例分配的小工具需要频繁处理类似3/7加5/12再乘以9/14这样的计算。一开始图省事直接用浮点数结果发现精度问题让人头疼——0.1加0.2不等于0.3这种事在浮点数里是常态而比例分配对精度又比较敏感。后来改用分数表示所有中间结果都用分子分母的整数对来存储只在最终输出时才转成小数精度问题一下子就消失了。这篇文章适合谁看如果你正在学C想找一个难度适中但又能覆盖类设计、运算符重载、异常处理、最大公约数算法等多个知识点的练手项目那这个题材非常合适。如果你是有经验的开发者想找一个轻量级的分数运算实现直接拿来用或者想看看别人是怎么处理边界情况的这篇文章也能给你一些参考。我会从设计思路讲到具体实现再到实际踩过的坑尽量把每个决策背后的原因都说清楚。2. 整体设计思路与方案选型2.1 用类封装还是用结构体加函数实现分数运算最直接的想法是定义两个整数变量分别表示分子和分母然后写一堆函数来操作它们。这种做法在C语言里很常见但在C里就显得有些笨拙了。原因很简单分子和分母是逻辑上不可分割的整体把它们分开传递调用者很容易搞混顺序而且每次运算都要手动管理两个变量代码会变得很啰嗦。所以我选择用类来封装。一个Fraction类内部有两个私有成员numerator和denominator所有运算都通过成员函数或运算符重载来完成。这样做的好处是数据被保护起来了外部代码没法直接修改分子分母只能通过我提供的接口来操作保证了对象始终处于合法状态——比如分母永远不会是零分数永远是最简形式。用结构体行不行技术上当然可以但结构体默认是公开访问的你没法阻止别人把分母设成零。用类加上私有成员和构造函数校验就能在对象创建的那一刻就把非法状态挡在门外。这是C面向对象编程的一个基本优势在这个项目里体现得很明显。2.2 运算符重载的选择与取舍C允许我们为自定义类型重载运算符这让分数运算的代码写起来非常自然。比如两个分数相加直接写a b就行不用写成a.add(b)。我重载了、-、*、/四个算术运算符以及、!、、、、六个比较运算符还有和用于输入输出。这里有一个设计决策值得说一下算术运算符我是重载为成员函数还是友元函数对于二元运算符如果重载为成员函数左操作数必须是该类的对象这意味着2 fraction这种写法会编译失败因为2是int不是Fraction。为了让整数和分数能自由混合运算我把算术运算符重载为友元函数这样左右操作数都可以是Fraction或者能隐式转换成Fraction的类型。不过隐式转换也有代价。我提供了一个接受单个int参数的构造函数这样Fraction f 3;就能自动把整数3变成分数3/1。这个构造函数如果不加explicit关键字就会允许隐式转换好处是写起来方便坏处是可能产生意料之外的转换。我权衡之后决定保留隐式转换因为在这个场景下整数转分数是非常自然且频繁的操作禁止隐式转换反而会让代码变得难写。2.3 约分策略什么时候约分最合适分数运算绕不开约分。两个分数相乘分子乘分子、分母乘分母结果可能是一个巨大的分数比如2/3乘以3/4得到6/12需要约分成1/2。问题是什么时候约分一种策略是每次运算结束后立即约分保证任何时刻对象都是最简形式。另一种策略是运算过程中不约分只在最终输出时约分。我选择了前者原因有两个第一立即约分可以防止中间结果膨胀。比如连续做十次乘法如果不约分分子分母会迅速增长到超出整数范围第二立即约分让对象的相等性判断变得简单——两个分数相等当且仅当分子分母分别相等不需要先约分再比较。约分的关键是求最大公约数。我用的是欧几里得算法也叫辗转相除法。这个算法的原理是两个数的最大公约数等于其中较小的那个数和两数相除余数的最大公约数。用递归或迭代都很容易实现时间复杂度是O(log min(a,b))非常高效。2.4 异常处理分母为零与溢出问题分母为零是分数运算中最常见的错误。我在构造函数里做了检查如果分母为零就抛出异常。这里有一个细节如果分子也是零呢数学上0/0是未定义的所以也应该抛异常。但如果分子是零、分母非零那就是合法的零分数应该允许。溢出是另一个需要关注的问题。两个分数相乘如果分子分母都很大乘积可能超出int的范围。我用的是long long来存储分子分母范围比int大得多但理论上仍然可能溢出。对于一般的教学和轻量级应用场景long long足够用了。如果确实需要处理超大数那就得引入大整数库那是另一个层面的问题了。我在代码里没有做溢出检测因为检测本身有性能开销而且在这个项目的定位下溢出属于极端情况不值得为此增加复杂度。3. 核心实现细节与关键代码解析3.1 类的基本结构与构造函数先来看类的定义。我把它设计得非常紧凑两个私有成员变量一组公开的成员函数和友元函数。class Fraction { private: long long numerator; long long denominator; void reduce(); static long long gcd(long long a, long long b); public: Fraction(long long num 0, long long den 1); Fraction operator-() const; Fraction operator(const Fraction other); Fraction operator-(const Fraction other); Fraction operator*(const Fraction other); Fraction operator/(const Fraction other); double toDouble() const; long long getNumerator() const { return numerator; } long long getDenominator() const { return denominator; } friend Fraction operator(const Fraction a, const Fraction b); friend Fraction operator-(const Fraction a, const Fraction b); friend Fraction operator*(const Fraction a, const Fraction b); friend Fraction operator/(const Fraction a, const Fraction b); friend bool operator(const Fraction a, const Fraction b); friend bool operator!(const Fraction a, const Fraction b); friend bool operator(const Fraction a, const Fraction b); friend bool operator(const Fraction a, const Fraction b); friend bool operator(const Fraction a, const Fraction b); friend bool operator(const Fraction a, const Fraction b); friend std::ostream operator(std::ostream os, const Fraction f); friend std::istream operator(std::istream is, Fraction f); };构造函数接受两个参数默认值是0和1这样Fraction f;就得到一个0/1也就是零。构造函数内部先检查分母是否为零然后调用reduce()进行约分。这里有一个容易忽略的点约分之前要先处理符号。如果分母是负数应该把负号移到分子上保证分母始终为正。这样做的好处是分数的表示是唯一的比较和输出都更方便。Fraction::Fraction(long long num, long long den) { if (den 0) { throw std::invalid_argument(分母不能为零); } if (num 0) { numerator 0; denominator 1; return; } if (den 0) { num -num; den -den; } numerator num; denominator den; reduce(); }注意零分数的特殊处理不管分母是多少零分数统一表示为0/1。这样Fraction(0, 5)和Fraction(0, 3)就是相等的比较逻辑不用额外处理。3.2 最大公约数与约分实现欧几里得算法的迭代版本比递归版本更高效也避免了递归深度过大的问题。我用的是迭代写法long long Fraction::gcd(long long a, long long b) { a std::abs(a); b std::abs(b); while (b ! 0) { long long temp b; b a % b; a temp; } return a; } void Fraction::reduce() { long long g gcd(numerator, denominator); numerator / g; denominator / g; }这里有一个细节gcd函数内部取了绝对值。因为约分的时候分子分母可能都是负数但最大公约数应该是正数。取绝对值保证了这一点。另外如果分子是零gcd(0, denominator)会返回denominator约分后得到0/1正好符合我们的零分数表示。注意std::abs对于long long类型需要包含cstdlib或cmath头文件。在某些编译器上如果不包含正确的头文件可能会调用到int版本的abs导致大数被截断。这是一个很隐蔽的坑我当初调试了半天才发现。3.3 四则运算的实现与优化加法和减法的核心是通分。两个分数a/b和c/d相加结果是(a*d c*b) / (b*d)。但直接这样算分母会变成b*d可能很大。一个优化是先求b和d的最小公倍数用最小公倍数作为公分母这样中间结果更小溢出的风险也更低。Fraction operator(const Fraction a, const Fraction b) { long long lcm a.denominator / Fraction::gcd(a.denominator, b.denominator) * b.denominator; long long num a.numerator * (lcm / a.denominator) b.numerator * (lcm / b.denominator); return Fraction(num, lcm); }这里计算最小公倍数的方式是a/gcd(a,b)*b先除后乘避免a*b溢出。这个技巧在处理大数时很重要很多人习惯先乘后除结果中间结果就溢出了。减法的实现和加法几乎一样只是把加号换成减号。乘法和除法就简单多了乘法是分子乘分子、分母乘分母除法是乘以除数的倒数但要注意除数不能为零。Fraction operator*(const Fraction a, const Fraction b) { return Fraction(a.numerator * b.numerator, a.denominator * b.denominator); } Fraction operator/(const Fraction a, const Fraction b) { if (b.numerator 0) { throw std::invalid_argument(除数不能为零); } return Fraction(a.numerator * b.denominator, a.denominator * b.numerator); }乘法和除法在构造函数里会自动约分所以不需要额外处理。但这里有一个潜在问题a.numerator * b.numerator可能溢出。对于long long来说两个接近10^9的数相乘就会溢出。在实际使用中如果分数经过多次运算分子分母可能会变得很大。一个缓解办法是在乘法之前先做交叉约分比如a的分子和b的分母先约分a的分母和b的分子先约分然后再相乘。这样可以把中间结果控制得更小。Fraction operator*(const Fraction a, const Fraction b) { long long g1 Fraction::gcd(a.numerator, b.denominator); long long g2 Fraction::gcd(b.numerator, a.denominator); return Fraction((a.numerator / g1) * (b.numerator / g2), (a.denominator / g2) * (b.denominator / g1)); }这个优化版本在每次乘法时多做两次gcd但能显著降低溢出的风险。对于除法可以类似处理或者直接调用乘法a / b a * Fraction(b.denominator, b.numerator)。3.4 比较运算符与输入输出重载比较两个分数的大小最直接的方法是把它们通分后比较分子。但更高效的做法是交叉相乘a/b c/d 等价于 a*d c*b假设b和d都为正数我们的类保证了这一点。交叉相乘避免了计算公分母代码也更简洁。bool operator(const Fraction a, const Fraction b) { return a.numerator * b.denominator b.numerator * a.denominator; }同样要注意溢出问题。如果分子分母都很大交叉相乘可能溢出。一个更安全的做法是先比较整数部分再比较余数部分但那样代码会复杂很多。在这个项目的定位下我选择了简洁的交叉相乘并在文档中注明了大数场景需要额外处理。输出运算符重载需要考虑几种情况如果分母是1只输出分子否则输出分子/分母的形式。输入运算符需要解析分子/分母格式的字符串如果输入中没有斜杠就当作整数处理。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; }输入解析这里有一个细节is.peek()用来查看下一个字符是不是斜杠但不会消耗它。如果是斜杠用is.ignore()跳过然后读取分母。如果不是分母保持为1。这样既能处理3/4也能处理5。4. 实操过程与完整代码实现4.1 从零开始搭建项目结构我习惯把类的声明放在头文件里实现放在源文件里主函数单独一个文件。这样结构清晰也方便复用。项目结构如下FractionProject/ ├── Fraction.h // 类的声明 ├── Fraction.cpp // 类的实现 └── main.cpp // 测试代码头文件里需要包含必要的标准库头文件并加上防止重复包含的宏#ifndef FRACTION_H #define FRACTION_H #include iostream #include stdexcept #include cstdlib class Fraction { // ... 类的定义 }; #endif源文件里实现所有成员函数和友元函数。主函数里写测试用例覆盖各种情况正分数、负分数、零、整数、连续运算、异常捕获等。4.2 关键步骤约分与符号处理的顺序在构造函数里约分和符号处理的顺序很重要。我一开始写的时候先约分再处理符号结果发现Fraction(2, -4)约分后变成-1/2但分子是-1分母是2看起来没问题。但如果是Fraction(-2, -4)约分后是1/2也对。问题出在Fraction(2, -4)的约分过程中gcd(2, -4)返回2分子除以2得1分母除以2得-2得到1/-2然后符号处理把负号移到分子变成-1/2。结果是对的但中间状态1/-2是不合法的分母为负如果约分函数内部有断言检查就会失败。所以正确的顺序是先处理符号保证分母为正然后再约分。这样约分过程中分母始终为正不会出现中间非法状态。Fraction::Fraction(long long num, long long den) { if (den 0) throw std::invalid_argument(分母不能为零); if (num 0) { numerator 0; denominator 1; return; } if (den 0) { num -num; den -den; } numerator num; denominator den; reduce(); }4.3 完整代码与测试用例把上面的片段拼起来就是完整的实现。我在主函数里写了一系列测试覆盖了以下场景int main() { // 基本构造与输出 Fraction a(3, 4); Fraction b(5, 6); std::cout a a , b b std::endl; // 四则运算 std::cout a b a b std::endl; // 19/12 std::cout a - b a - b std::endl; // -1/12 std::cout a * b a * b std::endl; // 5/8 std::cout a / b a / b std::endl; // 9/10 // 整数与分数混合运算 Fraction c 2; std::cout c a c a std::endl; // 11/4 std::cout c * b c * b std::endl; // 5/3 // 比较运算 std::cout a b: (a b) std::endl; // 1 std::cout a b: (a b) std::endl; // 0 // 异常处理 try { Fraction d(1, 0); } catch (const std::invalid_argument e) { std::cout 捕获异常: e.what() std::endl; } try { Fraction e(1, 2); Fraction f(0, 1); Fraction g e / f; } catch (const std::invalid_argument e) { std::cout 捕获异常: e.what() std::endl; } // 连续运算 Fraction result (a b) * c - b / a; std::cout (a b) * c - b / a result std::endl; return 0; }运行这段代码输出符合预期。a b得到19/12a - b得到-1/12a * b得到5/8a / b得到9/10。整数和分数的混合运算也正常工作c a得到11/4c * b得到5/3。异常捕获也按预期触发。4.4 性能测试与优化效果对比为了验证交叉约分优化的效果我写了一个简单的性能测试连续做一万次乘法比较优化前后的耗时和中间结果的大小。// 优化前直接相乘 Fraction f(2, 3); for (int i 0; i 10000; i) { f f * Fraction(3, 4); } // 优化后交叉约分 Fraction g(2, 3); for (int i 0; i 10000; i) { g g * Fraction(3, 4); }实测下来优化前的版本在几千次乘法后分子分母就变得非常大接近long long的上限最终溢出得到错误结果。优化后的版本因为每次乘法都交叉约分分子分母始终保持在很小的范围内一万次乘法后结果仍然是精确的。这个对比很直观地说明了约分策略的重要性。提示如果你打算把这个分数类用在需要大量连续运算的场景交叉约分几乎是必须的。否则即使long long也撑不了多久。5. 常见问题与排查技巧实录5.1 分母为零的异常处理分母为零是最常见的错误。我的处理方式是在构造函数和除法运算符中抛出std::invalid_argument异常。为什么选择抛异常而不是返回错误码因为分数运算的调用链可能很深如果每一层都要检查错误码代码会变得非常臃肿。异常可以在一处抛出在合适的地方统一捕获代码更干净。但抛异常也有代价如果调用者没有捕获程序会直接终止。所以在实际使用中我建议在顶层调用处加一个try-catch块给用户一个友好的错误提示而不是让程序崩溃。5.2 整数溢出的预防与检测溢出是分数运算中最隐蔽的问题。两个看起来不大的分数相乘结果可能超出long long的范围。比如Fraction(1000000000, 1) * Fraction(1000000000, 1)分子相乘得到10^18刚好在long long的上限附近long long最大约9.2*10^18。如果再来一次乘法就溢出了。预防溢出的办法有几个一是使用更大的整数类型比如__int128GCC和Clang支持但MSVC不支持二是引入大整数库三是在每次运算前检查是否会溢出。我选择了交叉约分来降低溢出风险但没有做溢出检测。如果你需要处理极端情况可以在乘法前加一个检查if (a.numerator ! 0 b.numerator ! 0) { if (std::abs(a.numerator) LLONG_MAX / std::abs(b.numerator)) { throw std::overflow_error(乘法溢出); } }这个检查本身有性能开销所以是否启用要看具体场景。5.3 连续运算中的精度与性能平衡连续运算时每次运算都约分可以保证精度但会增加gcd调用的次数。gcd的时间复杂度是O(log n)对于一般的分数运算来说这个开销可以忽略不计。但如果是在性能敏感的场景比如实时计算可以考虑延迟约分只在分子分母超过某个阈值时才约分。这样可以在精度和性能之间取得平衡。我实测下来对于一万次以内的连续运算每次约分的版本和延迟约分的版本在耗时上差别不大但每次约分的版本结果更精确。所以我推荐默认每次约分除非你有特殊的性能需求。5.4 常见问题速查表问题现象可能原因排查方法解决方案程序崩溃提示分母不能为零构造分数时分母传了0检查构造函数调用处确保分母非零或捕获异常计算结果不正确分子分母很大连续运算未约分导致溢出打印中间结果的分子分母启用交叉约分或使用更大整数类型比较结果不符合预期分母为负导致符号混乱检查构造函数是否处理了负分母确保分母始终为正输入3/4解析失败输入流状态错误检查is.peek()和is.ignore()的使用确保输入格式正确处理异常整数和分数混合运算编译失败运算符重载为成员函数检查运算符定义改为友元函数或提供隐式转换5.5 几个我踩过的坑第一个坑是std::abs的类型问题。前面提到过如果不包含正确的头文件std::abs(long long)可能调用到int版本导致大数被截断。这个问题的隐蔽之处在于小数据测试时完全正常只有数据大了才会暴露。我的解决办法是显式使用std::llabs或者自己写一个模板版本的abs。第二个坑是输入流的状态管理。用is num读取分子后如果输入是3/4流里还剩/4。用is.peek()查看下一个字符是斜杠然后用is.ignore()跳过再读取分母。但如果输入是3 / 4带空格is.peek()会看到空格而不是斜杠解析就会出错。解决办法是在peek之前先跳过空白字符或者直接用is den读取分母让流自己处理空白。第三个坑是零分数的表示。我一开始没有特殊处理零分数Fraction(0, 5)约分后得到0/1Fraction(0, 3)也得到0/1看起来没问题。但Fraction(0, -5)呢符号处理后变成0/5约分后是0/1也对。但如果分子是零、分母也是零呢Fraction(0, 0)应该抛异常因为0/0未定义。我在构造函数里加了检查如果分母为零就抛异常不管分子是多少。这样就覆盖了0/0的情况。第四个坑是连续运算的表达式求值顺序。C没有规定二元运算符的操作数求值顺序所以a b * c中b*c先算但a和b*c的求值顺序是不确定的。对于分数运算来说这通常不是问题因为每个运算符重载都是纯函数不修改操作数。但如果你的实现中有副作用比如修改了某个全局状态就可能出问题。我的实现没有副作用所以不用担心。6. 扩展思路与实用建议6.1 支持更多运算幂运算与开方基本的四则运算实现之后可以很容易地扩展出幂运算。分数的整数次幂就是分子分母分别求幂负次幂则是取倒数后再求幂。实现起来很简单Fraction pow(const Fraction f, int exp) { if (exp 0) return Fraction(1, 1); if (exp 0) { return pow(Fraction(f.getDenominator(), f.getNumerator()), -exp); } Fraction result(1, 1); Fraction base f; while (exp 0) { if (exp 1) result * base; base * base; exp 1; } return result; }这里用了快速幂算法时间复杂度是O(log n)比循环相乘快得多。开方运算就复杂多了因为分数的平方根通常是无理数没法用分数精确表示。如果确实需要可以返回一个近似的小数或者用连分数展开来逼近。6.2 与浮点数的互转及精度控制toDouble()函数把分数转成浮点数实现很简单return static_castdouble(numerator) / denominator;。但要注意如果分子分母都很大转换过程中可能损失精度。对于一般的应用场景double有15到17位有效数字足够用了。如果需要更高精度可以用long double或者用字符串输出精确值。反过来把浮点数转成分数就复杂一些。一个常用的方法是连分数展开但实现起来比较繁琐。对于简单场景可以限定分母的最大值然后暴力搜索最接近的分数。比如要找一个分母不超过1000的分数来近似0.333333可以遍历所有可能的分母找到误差最小的那个。6.3 在实际项目中使用这个分数类的建议如果你打算把这个分数类用在实际项目中我有几个建议。第一把异常类型定义得更具体一些比如FractionDivideByZero和FractionOverflow而不是统一用std::invalid_argument这样调用者可以针对不同的错误做不同的处理。第二考虑添加一个isValid()方法用来检查分数是否处于合法状态虽然构造函数已经保证了这一点但在调试时有用。第三如果项目中有大量的分数运算可以考虑把分子分母的类型做成模板参数这样用户可以选择int、long long或者自定义的大整数类型。第四关于头文件的包含我建议把iostream的包含放到源文件里头文件里只用前向声明。这样可以减少编译依赖加快编译速度。具体做法是在头文件里写std::ostream operator(std::ostream os, const Fraction f);在源文件里包含iostream并实现这个函数。6.4 测试驱动开发在分数类中的应用写这个分数类的时候我强烈建议用测试驱动开发的方式。先写测试用例再写实现。测试用例要覆盖正常情况正分数、负分数、零、整数、边界情况分子为零、分母为一、最大最小值、异常情况分母为零、除法除数为零、溢出、连续运算多个运算符组合。每写一个功能就跑一遍测试确保没有破坏已有的功能。我用的是一个简单的断言宏#define ASSERT_EQ(a, b) \ do { \ if ((a) ! (b)) { \ std::cerr 断言失败: #a ! #b std::endl; \ std::cerr 实际值: (a) , 期望值: (b) std::endl; \ std::exit(1); \ } \ } while (0)这个宏在断言失败时打印出表达式、实际值和期望值方便定位问题。对于分数类来说因为重载了和!这个宏可以直接用在Fraction对象上。6.5 代码复用与模块化设计的思考最后聊一下代码复用。这个分数类可以独立成一个模块不依赖任何其他自定义代码只依赖标准库。这意味着你可以直接把它拷贝到任何C项目里使用。如果你有多个项目需要分数运算可以把它编译成静态库或动态库或者直接以源码形式共享。模块化设计的关键是接口清晰、职责单一。Fraction类只负责分数的表示和运算不负责输入输出的格式化那是和的事也不负责错误处理策略那是调用者的事。这种分工让代码更容易理解和维护。如果你需要扩展功能比如添加三角函数或对数运算可以写独立的函数接受Fraction参数返回Fraction结果而不是往类里塞更多成员函数。这样类的接口保持精简扩展功能也不会影响已有代码。我个人在实际操作中的体会是分数运算这个题材虽然看起来简单但要把边界情况都处理干净把性能优化做到位把接口设计得易用还是需要不少思考的。每次我觉得差不多了的时候再仔细检查一遍总能发现新的问题。这种对细节的打磨恰恰是编程能力提升的关键。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑