多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++分数计算器课设实战:从需求分析到避坑指南

C++分数计算器课设实战:从需求分析到避坑指南 分数计算器这个题目在C课设清单里属于标准的“看着简单、动手才知道深浅”的项目。很多同学拿到题目第一反应是分数加减乘除不就是小学数学么有什么好写的结果写完才发现约分、通分、符号处理、输入解析、溢出保护随便一个环节都能让人折腾到凌晨。这篇文章就围绕这个课设把我自己从需求分析到编码实现、再到测试验证的完整过程拆开来讲重点放在那些代码之外的思考以及代码里头容易踩的坑。写完这篇文章你不仅能交出一份能跑的分数计算器还能在答辩的时候说清楚每一步的设计理由。1. 分数计算器没那么简单先想清楚需求边界课设题目里就四个字“分数计算器”但正因为太简单你反而得先花点时间把“这个程序到底要做什么”定义清楚。我见过太多人拿到题目就开始敲代码敲到一半发现键盘都能砸。原因很简单需求边界是模糊的实现的时候全靠猜。1.1 先列一份完整需求清单我从头梳理一遍一个拿得出手的分数计算器至少要覆盖这些东西支持分数的加、减、乘、除四则运算支持整数参与运算比如2 1/3支持带分数输入比如1 1/2表示二分之三结果必须化简到最简形式分母不能为零除数为零时要有明确的错误提示负数要显示规范-1/2而不是1/-2假分数显示成假分数还是带分数这个需求要提前定因为两种显示逻辑完全不同很多人忽略最后一条结果做到输出环节才回来改白白浪费时间。我建议第一版先把假分数原样输出比如7/3就输出7/3这样处理简单跟老师也好交代。如果学有余力再扩展成带分数输出属于锦上添花。1.2 交互方式的选择交互方式直接决定你后面解析模块的工作量边界。常见的方案有三种方案做法优点缺点命令行参数./fraction 1/2 3/4实现简单适合测试用户不友好菜单式交互先选运算符再输入两个分数流程清晰操作繁琐表达式输入直接输入1/2 3/4回车出结果体验最好解析最复杂我们做课设的目的不是搞科研是在有限时间内把核心功能做扎实。我最终选的是“表达式输入”的简化版允许用户一次输入一个完整的运算表达式例如1/2 3/4回车直接输出结果。这种交互方式对解析能力有一点要求但控制在合理范围内老师看了也觉得像那么回事。需求定完之后整个项目的脉络就出来了一个分数类来处理数值的表示和运算一个解析模块来处理输入一个主循环来驱动整个程序。下面按照这个顺序逐个击破。2. 数据表示与规范化约分、符号约定、溢出防线分数在数学上很简单但在计算机里表示起来有几个隐蔽的设计决策。这些决策直接决定了你后面多少代码要返工。2.1 为什么要用两个整数表示分数而不是 double一句话回答浮点数根本没法精确表示分数。1/3在 double 里是0.33333333333333331/3 1/6算出来是0.5看起来没问题但换成1/10 2/10double 给的是0.30000000000000004。分数的四则运算本质上是精确的用浮点数等于把精确问题强行变成近似问题课设的初衷就丢了。正确的做法是维护“分子 分母”两个整数class Fraction { private: long long num; // 分子 long long den; // 分母恒大于 0 public: // ... };这里我直接建议用long long不是int。原因后面会专门讲先记住这个结论。2.2 规范化normalize一切正确性的地基一个分数对象无论怎么被构造、怎么被运算内部必须始终满足两个不变量分母恒大于 0分子分母互质已经约到最简所有成员函数都建立在这两个不变量之上。只要这两个条件不被破坏你后面写运算符重载的时候会轻松非常多。规范化的过程分三步分母为 0 直接报错如果分母是负数分子分母同时取反把符号挪到分子上计算分子分母的绝对值的最大公约数 gcd然后同除对应的构造函数可以这样写Fraction::Fraction(long long n, long long d) { if (d 0) { throw std::runtime_error(分母不能为 0); } if (d 0) { n -n; d -d; } long long g gcd(abs(n), d); num n / g; den d / g; }注意我取gcd(abs(n), d)而不是gcd(n, d)因为n可能是负数。如果拿负数直接进辗转相除取模运算的结果符号因语言而异容易埋坑。2.3 最大公约数辗转相除法的递归与迭代求 gcd 是分数化简的核心这个算法必须闭着眼睛能写出来。辗转相除法的原理是gcd(a, b) gcd(b, a % b)当b为 0 时a就是最大公约数。递归版本long long gcd(long long a, long long b) { return b 0 ? a : gcd(b, a % b); }迭代版本long long gcd(long long a, long long b) { while (b ! 0) { long long t b; b a % b; a t; } return a; }两个版本时间复杂度一样都是 O(log(min(a, b)))迭代版本没有压栈开销我更推荐写成迭代。注意a % b里a和b都必须是正数——这就是上面abs(n)的原因所在。2.4 为什么是 long long一次乘法就看穿 int 的局限分数运算里面最危险的不是加减是乘法和通分。(a*d c*b) / (b*d)这一步里分母要做一次乘法中间结果可能远远超出两个操作数的范围。举个例子假设分数是1234567/89101112和3456789/10111213通分时交叉相乘中间量轻松超过 int 的 21 亿上限。int 一旦溢出结果是未定义行为你的程序可能在 debug 模式下正常、release 模式下崩溃这种问题拿到答辩现场就是灾难。long long能表示大约 9.2 × 10^18 的范围。课设有这个就够用了。如果你还想再稳妥一点可以在关键乘法前加溢出判断但我觉得课设阶段用long long已经是不错的平衡不必上大数库。3. 四则运算实现运算符重载与通分细节数据结构定好了接下来是分数类的灵魂——四则运算。这部分大部分代码不难但有些细节决定了你的结果是否规范。3.1 加减法先通分再运算还是直接交叉相乘先看公式加法a/b c/d (ad cb) / (b*d)减法a/b - c/d (ad - cb) / (b*d)乘法a/b * c/d (ac) / (bd)除法a/b ÷ c/d (ad) / (bc)很多人直接照抄公式用Fraction(a*d c*b, b*d)构造结果。构造函数会自动约分所以正确性没问题。但有个隐患b*d这个中间分母可能很大再加上交叉相乘溢出风险更高。一种更优雅的做法是先约分再通分。加法的标准流程是求出分母b和d的最小公倍数 lcm用 lcm 做通分后相加的分子构造Fraction(分子, lcm)自动约分最小公倍数可以通过 gcd 求出lcm(b, d) b / gcd(b, d) * d。注意先除后乘可以避免中间结果先撑爆。这里有个等价技巧b / gcd(b, d) * d和(b / gcd(b, d)) * d的含义不同。前一步可能先撑爆后一步先缩小再放大数值安全性更好。代码里一定要写成后者。减法同理把加法里的换成-即可。3.2 运算符重载用友元还是成员函数运算符重载有两种写法左边是成员函数右边是友元函数// 成员函数 Fraction operator(const Fraction rhs) const; // 友元函数 friend Fraction operator(const Fraction lhs, const Fraction rhs);这里选择有个讲究。加法满足交换律a b和b a应该一样这对成员函数没问题。但考虑2 frac这种用法——整数在左边——成员函数就无能为力了因为2不是 Fraction 对象。虽然 C 有隐式转换但那是单向的2 frac无法通过成员函数实现。所以我推荐用友元函数重载 - * /这样左右操作数对称整数会自动通过构造函数转换成 Fractionfriend Fraction operator(const Fraction lhs, const Fraction rhs) { long long lcm lhs.den / gcd(lhs.den, rhs.den) * rhs.den; long long n lhs.num * (lcm / lhs.den) rhs.num * (lcm / rhs.den); return Fraction(n, lcm); }lcm / lhs.den和lcm / rhs.den都是整数除法不会丢精度。整个表达式先算分母的 lcm再用它回推每个分数扩大的倍数逻辑非常清晰。3.3 除法要单独检查除数是否为 0a/b ÷ c/d (a*d) / (b*c)表面上看只要构造函数的den不为 0 就安全。但除法有个特殊情况除数为 0 时b*c这个新分母直接变成 0。你必须在除法重载内部先判断friend Fraction operator/(const Fraction lhs, const Fraction rhs) { if (rhs.num 0) { throw std::runtime_error(除数为 0); } return Fraction(lhs.num * rhs.den, lhs.den * rhs.num); }注意我检查的是rhs.num而不是rhs.den。一个分数为 0 当且仅当它的分子为 0跟分母没关系。0/5和0/100都是 0这个判断必须落在分子上。3.4 输出流重载与负号的规范显示输出也是一门学问。用operator重载可以统一控制格式friend std::ostream operator(std::ostream os, const Fraction f) { if (f.den 1) { os f.num; } else { os f.num / f.den; } return os; }因为规范化阶段已经保证了“负号在分子上、分母恒正”所以输出的时候直接输出num/den就一定是规范形式。1/-2这种非法形式在构造阶段就被纠正为-1/2了输出模块不需要做任何额外处理。这里还能顺便处理带分数显示如果 abs(num) den就先输出整数部分再输出余数部分。不过第一版建议先不做后面有时间再扩。4. 输入解析从字符串到分数的去伪存真输入解析是整个课设里最琐碎、也最容易出 bug 的地方。很多人的分数类写得很好一到输入环节就崩溃——用户手滑多打了个空格、输入了个带分数、或者输入了3/0程序直接没反应。输入解析的关键不是“能处理正常输入”而是“遇到异常输入不崩溃、最好还能给出提示”。4.1 处理三种输入形式用户可能输入的分数形式有三种整数2普通分数3/4带分数1 1/2表示一又二分之一如果输入的是整数要能转成Fraction(2, 1)。如果输入带分数要能拆出整数部分和真分数部分合并成Fraction(3, 2)。我的推荐做法是用std::istringstream 逐个读取 token。先读第一个 token如果是纯整数再看后面还有没有/分母有就是分数没有就是整数Fraction parseFraction(const std::string s) { std::istringstream iss(s); long long whole, num, den; char slash; if (iss whole !(iss slash)) { return Fraction(whole, 1); } if (iss num slash den) { return Fraction(whole, 1) Fraction(num, den); } throw std::runtime_error(无法解析的分数格式); }这段代码利用了一个小技巧iss whole成功后如果把下一个字符当作slash读取失败说明后面没有内容whole 就是整数。如果能继续读到num / den就是真分数或假分数。如果还能读到前后的空格但读不到数字就说明有非法字符混进去直接抛异常。带分数的场景在格式上比较特殊1 1/2本身包含空格。如果你用cin op1 op op2三个变量依次读输入op1会只读到1op2会读到1/2中间的操作符反而是第二段输入的开头。这会给解析带来意想不到的混乱。我的处理办法是先用 getline 读一整行再用字符串查找定位运算符位置把表达式拆成左右两个子串而不是依赖的分词机制。4.2 运算符定位与表达式拆分以1 1/2 2/3为例拆分子串的步骤如下std::string line; std::getline(std::cin, line); size_t plusPos line.find(); size_t minusPos line.find(-); size_t mulPos line.find(*); size_t divPos line.find(/);等等这里有一个很隐蔽的问题减法运算符-和负号、分数线/在同一个表达式里同时存在。-1/2 - 3/4里有两个-如果只找第一个-下标会指向负号而不是减号。所以不能简单用find找第一个出现的运算符得用find_first_of全体扫描再选择“中等偏后”的那个运算符位置——或者反过来从右往左找运算符。具体策略先找到所有、-、*、/的位置排除掉位于第一个字符的负号然后选位置最靠右的作为真正的运算符。这样的好处是-如果是符号一定在最前面不会干扰结果*和/的位置天然不会跟符号冲突。除法符号/也出现在分数里所以不能把find_first_of(-*/)直接当作运算符——当且仅当/是第一个运算符时它才是除号否则它是分数线。下面是我实际项目里用的简化决策表输入示例运算符左操作数右操作数1/2 3/4最右侧的1/23/4-1/2 - 3/4最右侧的--1/23/42 * 1/3最右侧的*21/31 1/2 / 3/4最右侧的/1 1/23/4这个“取最右侧运算符”的策略对减号和除号极其重要。拿下标之后用substr把左右子串截出来分别丢进parseFraction整个表达式的解析就通了。4.3 错误输入的兜底任何用户可能手滑输入的内容比如1/0 3/4、abc 1/2、1/2 程序都必须能给出反馈而不是崩溃。我的做法是在主循环外层包一个try-catch调用链上任何throw都能被统一捕获while (true) { std::cout ; std::string line; std::getline(std::cin, line); if (line exit) break; try { // 解析并计算 } catch (const std::exception e) { std::cout 输入错误: e.what() std::endl; } }这样分母为零、除数为零、解析失败、未知运算符这些异常情况全部走同一条兜底路径不用在每个函数里写一堆if-else判断代码清爽很多。5. 测试与验证让课设经得起老师现场检验代码写完了不等于能答辩。课设最常见的翻车方式就是程序平时跑得好好的老师一上手输入了几个“奇怪”的用例就崩了。所以测试环节绝对不能省。我整理了一套比较实用的测试思路你可以直接照着做。5.1 功能测试用例表先准备一张覆盖各种情况的用例表确保每个分支都测到测试类型输入期望输出真分数加法1/2 1/35/6约分后加法1/6 1/61/3整数与分数混合2 1/37/3带分数解析1 1/2 1/22负数运算-1/2 1/4-1/4减法结果为负1/4 - 3/4-1/2乘法2/3 * 3/41/2除法1/2 / 1/42分母为一4/2 13除数为零1/2 / 0报错提示非法字符abc 1/2报错提示这些用例基本能把正常路径和异常路径都覆盖到。建议建一个test_cases.txt写一个简单的脚本循环调用程序逐行比对输出。课设阶段不用上什么测试框架手写比对完全够用。5.2 随机化验证对拍大法比固定用例更让人放心的是随机对拍验证。具体做法用 C 自身的rand()生成 10000 组随机的a b c d构造两个分数分别用你的 Fraction 类运算再把结果转成 double 跟浮点数近似结果做对比。for (int i 0; i 10000; i) { long long a rand() % 100 1; long long b rand() % 100 1; long long c rand() % 100 1; long long d rand() % 100 1; Fraction f1(a, b), f2(c, d); Fraction r f1 f2; double expect (double)a / b (double)c / d; double actual (double)r.num / r.den; if (fabs(expect - actual) 1e-9) { std::cout 出错: a / b c / d std::endl; break; } }这个对拍法能在几分钟内帮你发现代码里那些“看似正常但实际约分错误”的隐蔽 bug。注意fabs(expect - actual) 1e-9这个阈值不能太小毕竟 double 本身有误差取1e-9足够区分真正的错误。5.3 内存与值语义检查分数类本身不涉及动态内存所以没有析构函数拷贝构造这种问题。但要注意运算符重载的返回值语义——所有运算都应该返回新对象不能修改操作数本身。写一个简单测试Fraction a(1, 2), b(1, 3); Fraction c a b; assert(a.num 1 a.den 2); assert(b.num 1 b.den 3);如果这条断言没过去说明你的运算代码无意中修改了入参典型的 bug 是对运算符重载省略了const限定符。这个测试虽然简单但经常能抓出那种“算完a b之后a自己变了”的诡异现象——说到底就是忘记把入参声明为const Fraction。6. 踩坑实录代码里那些隐蔽错误是怎么排查的各个坑我几乎都踩过一遍挑几个最有代表性的写出来你在写的时候注意避雷。6.1 坑一符号处理不彻底导致输出1/-2第一个版本里我的规范化逻辑只做了约分没做“分母取正”的处理。结果输入1/-2时虽然值是对的但输出是1/-2。单看值没有任何问题但答辩时老师一眼就能看出格式不专业。排查过程也很简单加一个d 0的判断把负号挪到分子上。修复后所有除法、减法产生的负数分母都能自动纠正。这个 bug 的特点是只有特定输入才触发所以测试用例里必须包含分母为负数的输入。6.2 坑二gcd 里用了负数结果越约越大第二个版本我偷懒直接在gcd(n, d)里传入原始分子没取绝对值。C 的%对负数的结果是平台相关的负数取模得到负余数辗转相除的收敛性质被破坏gcd 可能算出负值甚至算错。查这个 bug 花了整整一个下午gcd(-6, 8)按错误实现算出来是-2分子分母同除-2得到(3, -4)再规范化一次把符号挪上去变成(-3, 4)单次运算结果碰巧还对但连续运算时错误会累积放大。加一个abs()就彻底解决。这个教训总结成一句话gcd 的两个参数必须是非负整数不要让负数进算法。6.3 坑三乘法溢出在 int 下根本测不出来我用 int 写前两版的时候测试用例全是小数字程序跑得欢快结果一换成稍大的分数就随机出负数——典型的 overflow。更坑的是不同编译器对 int 溢出的处理不一样在本地 debug 下可能正常在老师的机器上就是怪结果。解决方式就是前面说的把所有数值字段改成long long。这不是“多占几个字节”的事是直接消除了一整类不确定问题。课设阶段long long足够不用考虑 int128 或者大数库。6.4 坑四cin 读取带分数时被空格骗了带分数1 1/2这个格式天然包含空格如果用cin a op b的方式读取程序会误以为1是完整操作数1/2是下一个 token。这个问题在cin分词模式下无解除非你用getline把整行读进来再手动切分。这里我踩坑之后的体会是凡是你想支持的输入格式有空格存在就别依赖做分词老老实实用getline 字符串处理。很多学生写计算器翻车九成都是在这个输入解析环节出的问题。6.5 坑五除号与分数线的歧义表达式1/2 / 3/4里有两个除号但实际上第一个/是分数线第二个/才是运算符。如果不认真处理程序会错误地从第一个/处切分导致解析混乱。我的“从右往左找运算符”策略就是专门针对这个问题的。只要确保找到的是最右侧的/除法表达式就不会被分数线干扰。如果你觉得这个逻辑太绕可以限制输入格式要求除号两侧必须有空格1/2 / 3/4这样按空格切分后/会独立成 token解析就简单得多。两种方案各有取舍按你的时间选择。7. 写在最后几个答辩一定会被问到的设计问题如果你把上面的内容都做了代码本身的完成度已经比较高了。最后分享几个我在课设答辩环节被老师和同学问到过的问题提前想好答案能让你现场从容不少。第一个问题为什么用分数类封装而不是直接用 double回答要点就是浮点数精度损失用整数对可以做到精确运算这也是本课设的意义所在。第二个问题为什么运算符重载都写在类外、用友元回答要点友元能保证左右操作数类型对称支持2 frac这样的整数混合运算此外友元函数天生就没有this指针不会意外修改操作数本身。第三个问题分母溢出怎么办这个问题可以坦诚说long long在课设规模下够用扩展方向是加一个溢出检测或者使用大数运算库。老师要的是你的思考过程不是要你上生产环境。第四个问题约分一次还是每次运算后都约分答案每次构造都约分。这样设计的好处是运算模块不用关心约分细节任何地方 new 一个 Fraction 出来都自动是最简形式这个“构造即约分”的不变量设计能让整个程序的正确性可推导性大幅提升。课设的意义不在于做一个多复杂的工具而在于通过一个完整的小项目把语言特性、算法设计和工程习惯串起来。分数计算器这个题目覆盖面够广又能把问题控制在两三百行代码以内做完之后你对 C 的类、运算符重载、流操作、异常处理这些知识点会有真正的体感。按照上面的流程走一遍你一定能在答辩时对每个设计决策都给出明确的理由而不是“别人这么写我就跟着写了”。
返回列表