ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++分数计算器课设实战:类封装与运算符重载的完整实现

2026/10/4 14:14:53 拓冰建站 浏览量
C++分数计算器课设实战:类封装与运算符重载的完整实现 分数计算器是所有C课程设计里被选得最多、也最容易被低估的一道题目。表面上看不过是输入两个分数算个加减乘除但实际上它把C的类封装、运算符重载、算法设计、输入输出处理、内存安全这些核心知识点全串起来了。我当年做这个课设的时候前前后后改了三版第一版只会用全局函数硬算第二版学着用struct封装第三版才真正用class把分数类型做得像int一样自然。这篇文章就把我踩过的坑和最终沉淀下来的实现思路完整写出来从需求分析到代码实现再到调试排错每一步都讲清楚为什么这么做。1. 项目概述与设计思路1.1 分数计算器到底在考什么很多同学把分数计算器当成大数计算或者数学题来做这是最大的误区。课设题目的真正考察点是你能不能把分数这一个数学概念抽象成程序里的一种数据类型。一旦想通了这一点整个项目的架构就清晰了你需要做的不是写一堆计算函数而是定义一个自己的分数类让它支持加减乘除、比较、输入输出就像int和double那样直接使用。这个抽象过程涉及C的几大核心能力类的封装与构造、运算符重载、友元函数、流插入与流提取运算符重载这些都是C区别于C语言的关键特性也是老师评分时重点看的东西。如果你只是用两个整数变量存分子分母然后写几个普通函数去算那和C语言课设没什么区别C课设的分数一定会打折扣。1.2 需求拆解与功能清单拿到题目先别急着写代码把需求一条条列清楚。一个完整的分数计算器至少要满足以下功能分数的表示任意整数都可以表示为分数分母为1分子分母均为整数分母不能为零。结果约分计算结果必须是最简分数比如4/8要化简为1/2。带分数与假分数的处理输入可能是带分数如3 1/2也可能是假分数如7/2内部统一用假分数存储输出时可以切换格式。四则运算加、减、乘、除除法要考虑除数不能为零。比较运算判断两个分数是否相等、大于、小于。输入输出从键盘读入分数支持形如3/4、-5/2这样的格式输出时同样规范打印。除了这些基本功能我建议再加一个混运算模式让用户一次输入一个分数表达式比如1/2 3/4 - 1/8程序自动计算。这一步能把项目从及格拉到优秀因为你需要处理运算优先级和表达式的解析这在课设答辩时是最加分的亮点。1.3 技术选型为什么用C而不是别的C的运算符重载语法一直是初学时的痛点但在这个项目里它反而成了天然优势。如果你用Java或者Python分数类也能做但C的运算符重载能让你的代码逻辑和数学表达式几乎一一对应读起来非常直观。f1 f2就是加法不需要写成add(f1, f2)这种表达方式的爽感在写表达式计算器时尤其明显。我当时的做法是类内部数据成员只用两个长整型long long存分子分母所有计算都在运算符重载函数里完成main函数里只负责交互逻辑。这样设计的核心优势是单一职责分数类只关心如何表示和计算分数而不管用户的输入格式、菜单显示、错误提示后续无论是改成图形界面还是命令行动态输入都不用动核心代码。2. 数据结构设计与核心算法2.1 分数类设计用class还是structC里class和struct唯一的区别是默认访问权限class默认privatestruct默认public。对于分数类这种需要隐藏内部表示、保证数据完整性的类型我强烈建议用class而且把所有数据成员都设为private。这里有一个特别容易犯的设计错误把分子和分母设置成public成员然后到处直接修改。比如f.numerator 3; f.denominator 0;一不留神就把分母改成0了整个程序立刻崩溃。正确的做法是让构造函数和赋值运算统一负责数据的合法性检查外部代码没有任何机会绕过去。class Fraction { private: long long numerator; // 分子 long long denominator; // 分母始终为正数 void reduce(); // 私有成员函数用于约分 public: Fraction(long long num 0, long long den 1); Fraction(const std::string str); // 从字符串构造比如 3/4 // 运算符重载... };这个设计里有一个小但关键的决策denominator始终存储正数。负号统一放在分子上。这样处理之后比较运算、约分、输出都不需要额外判断符号逻辑大幅度简化。这个选择不变式的思维在工程里非常常见先想清楚我的数据在任何时刻都应该满足什么性质再围绕这个性质写代码很多bug自然就消失了。2.2 欧几里得算法求最大公约数约分是分数计算器的核心而约分依赖最大公约数。这里用的算法是辗转相除法也就是欧几里得算法它不调用任何库函数纯粹靠循环或递归实现非常适合作为面试或课设的算法考题。long long gcd(long long a, long long b) { a llabs(a); b llabs(b); while (b ! 0) { long long temp b; b a % b; a temp; } return a; }这个实现的要点有两个。第一先取绝对值因为负数参与取模虽然也能得到正确结果但容易出负的公约数后续处理起来很别扭。第二为什么要这样迭代原理是gcd(a, b) gcd(b, a % b)每次把较大的数缩小成余数数值规模指数级下降哪怕分子分母是十亿级别几十次循环就结束了性能完全不用考虑。实际做约分的时候注意一个细节分子为0时最大公约数会等于分母的绝对值约分后分子为0、分母为1这个结果是对的。但如果分子是0你直接调用gcd(0, den)返回值是den所以约分后分子0分母1没有问题。不过有些教材里的gcd实现遇到gcd(0,0)会死循环所以我在循环条件里加上了对b的判断gcd(0, 0)的情况永远不应该出现因为分母不可能为0。2.3 约分、通分与四则运算约分函数是私有成员函数在构造函数和每次运算完成后自动调用void Fraction::reduce() { if (denominator 0) { throw std::invalid_argument(分母不能为零); } if (denominator 0) { numerator -numerator; denominator -denominator; } long long g gcd(numerator, denominator); numerator / g; denominator / g; }这里denominator 0的处理不要忽略。因为构造函数可能接收denominator -4这样的参数符号统一这一步必须在约分之前完成否则gcd的结果可能受到符号干扰。四则运算的公式逻辑很简单但实现时要保证中间结果不溢出。比如加法Fraction operator(const Fraction a, const Fraction b) { return Fraction( a.numerator * b.denominator b.numerator * a.denominator, a.denominator * b.denominator ); }这个写法的风险在于a.numerator * b.denominator可能很大。两个int乘起来就变成long long没问题但两个long long相乘可能溢出。稳妥的做法是运算前先约分再通分能显著降低溢出风险。更进一步可以在加法和减法之前先对四个数做gcd预处理比如用以下技巧long long g1 gcd(a.denominator, b.denominator); // 先除以公约数再相乘减小中间值我在项目中实现了一个预约分乘法函数mul_reduce(x, y)会先对x和y求gcd并约分再把结果乘起来这样能最大程度保证long long范围内不溢出。这是很多同学课上不会讲到的实战细节。2.4 输入输出重载流插入和流提取运算符的重载是这个课设的另一大考点。注意这两个运算符不能作为成员函数重载因为左操作数是std::ostream或std::istream不是Fraction对象所以要用友元函数或普通函数实现。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) { std::string token; is token; size_t slash token.find(/); if (slash std::string::npos) { f Fraction(std::stoll(token)); } else { long long num std::stoll(token.substr(0, slash)); long long den std::stoll(token.substr(slash 1)); f Fraction(num, den); } return is; }输出时把分母为1的情况单独处理成整数体验会好很多。输入时用字符串解析而不是直接is num; is.ignore(); is den;因为用户可能不按格式输入字符串解析配合异常处理更容易给出友好的错误提示。这里stoll如果遇到非数字字符串会抛出异常你可以在外面try catch住提示输入格式错误应为 a/b 形式。3. 完整实现步骤与核心代码3.1 头文件设计与模块划分整个项目我分成三个文件Fraction.h放类的声明Fraction.cpp放成员函数和运算符重载的实现main.cpp放主流程。这种分文件写法是工程化的基本要求课设答辩时老师会问为什么要分开写你的理由可以是降低编译耦合度头文件提供接口声明源文件隐藏实现细节。Fraction.h的核心内容#ifndef FRACTION_H #define FRACTION_H #include iostream #include string #include stdexcept class Fraction { private: long long numerator; long long denominator; void reduce(); public: Fraction(long long num 0, long long den 1); Fraction(const std::string str); long long getNumerator() const { return numerator; } long long getDenominator() const { return denominator; } Fraction operator-() const; // 取负 Fraction operator(const Fraction rhs); Fraction operator-(const Fraction rhs); Fraction operator*(const Fraction rhs); Fraction operator/(const Fraction rhs); 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 std::ostream operator(std::ostream os, const Fraction f); friend std::istream operator(std::istream is, Fraction f); }; #endif我特意声明了、-这些复合赋值运算符它们可以让表达式计算的效率更高。每个复合运算符内部先调用对应的普通运算符结果再做赋值可以有效减少重复代码Fraction Fraction::operator(const Fraction rhs) { *this *this rhs; return *this; }注意这里返回的是引用目的是支持连写a b c这样的语法同时也避免不必要的拷贝。如果返回一个临时对象a.operator(b)返回副本语义上能用但会多一次拷贝构造性能不划算。3.2 构造函数与数据有效性校验构造函数里最容易踩的坑是用户传入分母为0时怎么处理。直接throw异常是C的正规做法但在课设里你还要考虑main函数是否catch了异常、是否给出了友好的提示不能简单让程序一崩了事。Fraction::Fraction(long long num, long long den) : numerator(num), denominator(den) { if (den 0) { throw std::invalid_argument(分母不能为零); } if (den 0) { numerator -num; denominator -den; } reduce(); }这里做了两层处理第一层检查分母非零第二层把负数分母的符号移到分子上。符号统一之后reduce()再调用gcd做约分整个过程一气呵成。这样设计的另一个好处是后续除法运算operator/只需要检查除数b的分子是否为0即可因为任何除数为0的分数本来就会被构造函数拒绝。从字符串构造的版本可以复用这个构造函数Fraction::Fraction(const std::string str) { size_t slash str.find(/); if (slash std::string::npos) { *this Fraction(std::stoll(str), 1); } else { long long num std::stoll(str.substr(0, slash)); long long den std::stoll(str.substr(slash 1)); *this Fraction(num, den); } }3.3 运算符重载的实现细节加减乘除四个二元运算符的声明方式是friend这有个好处它们不是成员函数所以左操作数不一定是Fraction类型。这样写之后你可以直接写2 f编译器会把字面量2通过构造函数隐式转换成Fraction非常自然。如果只写成成员函数2 f就编译不过因为int类型没有对应的成员函数。Fraction operator*(const Fraction a, const Fraction b) { long long g1 gcd(a.numerator, b.denominator); long long g2 gcd(b.numerator, a.denominator); return Fraction( (a.numerator / g1) * (b.numerator / g2), (a.denominator / g2) * (b.denominator / g1) ); }这段代码的价值就体现在预约分上。以1/2000000000 * 2000000000/1为例如果直接先乘再约分两个大数相乘会直接溢出但先对分子分母交叉做gcd处理中间结果的规模就小得多long long完全扛得住。这是分数运算在数值范围上的一个隐性杀手课设测试不会测特别大的数但如果你把这个细节写进文档里答辩时是一个很亮的加分点。减法实现为加上负数的形式即可比较简单。除法需要注意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); }这里乘法其实也可以做预约分但至少先检查b的分子是否为0。b.numerator 0和b Fraction(0)是等价的但直接检查成员更快而且避免构造临时对象。3.4 main函数与交互逻辑main函数的目标是让一个完全没有C基础的人也能顺畅操作。菜单要清晰输入要有提示出错要有重试机制。我的主流程是这样设计的int main() { std::cout C 分数计算器 std::endl; std::cout 支持输入两个分数进行四则运算也支持输入表达式如1/2 3/4 - 1/6 std::endl; int mode 0; while (true) { std::cout \n请选择模式1. 二元运算 2. 表达式计算 0. 退出 std::endl; std::cin mode; if (mode 0) break; try { if (mode 1) { calcBinary(); } else if (mode 2) { calcExpression(); } else { std::cout 无效选项 std::endl; } } catch (const std::exception e) { std::cout 出错 e.what() std::endl; } } return 0; }二元运算函数内部是这样的void calcBinary() { Fraction a, b; char op; std::cout 输入第一个分数(格式 a/b 或整数); std::cin a; std::cout 输入运算符 - * / ; std::cin op; std::cout 输入第二个分数; std::cin b; Fraction result; switch (op) { case : result a b; break; case -: result a - b; break; case *: result a * b; break; case /: result a / b; break; default: std::cout 不支持的运算符 std::endl; return; } std::cout a op b result std::endl; }表达式计算部分需要解析字符串这里建议用最简单的空格分隔法用户输入1/2 3/4 - 1/6程序按空格切分遇到运算符就处理当前累积符号。完整实现可以在后面的扩展部分展开这里先给一个能跑通的基础版本利用std::istringstream读取切分后的tokenvoid calcExpression() { std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 输入表达式各部分用空格分隔例如1/2 3/4 - 1/6 std::endl; std::string line; std::getline(std::cin, line); std::istringstream iss(line); Fraction result; char op ; std::string token; bool first true; while (iss token) { if (token || token -) { op token[0]; } else { Fraction f(token); if (first) { result f; first false; } else { if (op ) result f; else result - f; } } } std::cout result std::endl; }这个版本只处理加减乘除法需要增加优先级否则会出错后面我专门讲怎么升级。4. 实操中的常见问题与排查4.1 除零崩溃与异常处理运行中最常见的崩溃就是分母为0或者除以0。很多初版代码在构造函数里没有做检查用户输入5/0时程序直接在reduce()的gcd调用里死循环或者输出一个奇怪的垃圾值。我的建议是全项目统一使用异常机制Fraction构造函数遇到分母为0时抛std::invalid_argument除法运算符遇到除数为0时抛同样的异常main函数里捕获并打印友好信息。调试这个类问题的时候可以用gdb或者IDE的断点设置在reduce函数入口查看传给gcd的是不是(0,0)。gcd(0,0)会返回0然后numerator / 0就是浮点异常这个是课设里最阴间的报错之一。只要在reduce里先判断denominator是否为0整个问题就堵死了。4.2 整型溢出问题分数运算中溢出比你想的来得快得多。即使是100000/99999和99999/100000相加直接乘出来的分子也会接近10的10次方还在long long范围内但两个十亿级别的数相乘就直接爆了。这就是为什么我在乘法里使用预约分技巧。你可以在operator里也做一次预约分Fraction operator(const Fraction a, const Fraction b) { long long g gcd(a.denominator, b.denominator); long long da a.denominator / g; long long db b.denominator / g; long long num a.numerator * db b.numerator * da; long long den da * b.denominator; // 或 db * a.denominator return Fraction(num, den); }这个版本的中间值比暴力通分小很多。da和db因为是先除以公约数得到的所以参与乘法时不会像原来的分母乘积那样爆炸。如果你的测试数据包含极端大分数这一步能让程序在更大范围内不溢出。4.3 赋值与拷贝的隐性陷阱我第一版代码犯过这样的错写了一个成员函数void add(const Fraction other)函数内部修改了this的分子分母但忘记先统一符号导致负分数的加法结果完全错乱。更隐蔽的问题是把Fraction对象直接赋值给另一个对象时编译器默认生成的拷贝赋值运算符会逐成员复制这在这个类里没毛病但如果你后面增加了指针成员比如缓存某个字符串就一定要自己实现拷贝构造和拷贝赋值否则就出现同一块内存被两个对象同时管理的经典bug。给课设代码写单元测试时我建议专门测一类情况连续进行多次运算每次运算结果都作为下一次运算的输入。比如求1/2 1/3 1/4 1/5确保每次返回的Fraction对象都被正确管理不会出现悬垂引用或内存泄漏。用_DEBUG宏开启内存泄漏检测在Windows的MSVC环境下特别直观。4.4 编译环境配置问题课设最常见的编译环境无非是Visual Studio、Dev-C、VS Code MinGW。在VS Code里配置C环境时很多人会卡在tasks.json和launch.json上现象是能编译但一点运行的按钮就报launch: program does not exist。关键检查点有两个第一个是tasks.json的args里有没有-o指定输出文件名并且输出路径和launch.json里program字段一致第二个是编译器路径新版MinGW-w64的g路径一般在C:\msys64\mingw64\bin\g.exe不要在旧版MinGW的路径里找。另外如果你写的是分文件项目tasks.json里要把Fraction.cpp也写进编译命令否则链接阶段会报一堆未解决的符号错误。当Windows上弹出Microsoft Visual C 14.0 or greater is required这种错误时其实是你Python的某个库需要编译原生扩展跟你的C代码无关。这种报错通常发生在安装pycrypto、numpy之类的包时解决方案是安装VS Build Tools的C桌面开发组件或者干脆换一个预编译包。但这里要分清场景你在写课设时用的编译器刚才已经配置好了并不受这个报错影响两者不要混淆。5. 扩展与优化建议5.1 升级为完整的表达式计算器只在加法减法上做表达式解析其实不够震撼答辩时老师可能会问你的表达式能算乘除法吗这时候如果你说不行气势就弱了一截。其实要支持完整的四则运算优先级可以引入两个栈一个存操作数Fraction一个存运算符char按照标准的中缀表达式转后缀表达式思路处理优先级。我建议的方式比较简单先扫描整个token序列第一遍处理所有乘除第二遍处理加减。这在没有括号的场景下是完全够用的。std::vectorstd::string tokens; // 由输入拆分而来 // 第一遍处理 * 和 / std::vectorstd::string pass1; for (size_t i 0; i tokens.size(); i) { if (tokens[i] * || tokens[i] /) { Fraction left(pass1.back()); pass1.pop_back(); Fraction right(tokens[i 1]); pass1.push_back((tokens[i] *) ? (left * right).toString() : (left / right).toString()); i; } else { pass1.push_back(tokens[i]); } } // 第二遍处理 和 -如果你加入了括号就需要用递归下降或双栈法处理。双栈法的核心逻辑是数字直接压入数值栈运算符则先弹掉栈顶优先级不小于当前运算符的运算符再进行计算遇到左括号压栈右括号一直弹到左括号为止。这个算法本身也是数据结构课的经典内容和分数类配合起来整个课设的含金量瞬间上一个档次。5.2 支持小数与分数的互相转换很多实际使用场景需要把小数转成分数或者把分数转成小数。课设做扩展时可以加入一个小数转分数功能输入0.75输出3/4输入1.25输出5/4。原理并不复杂取小数点后的位数n则x等于整数部分 小数部分 * 10^n / 10^n然后约分。比如0.75就是75/100约分成3/4。注意float和double的二进制精度问题直接用stod读字符串然后算位数是不可靠的建议从字符串层面直接截取小数点后的部分纯整数运算避免误差。5.3 代码风格与注释规范课设代码至少要能让老师看得懂。我的习惯是每个成员函数用两行注释说明功能、参数、返回值复杂的算法比如gcd、reduce在函数上方画一个简单的文字流程示意不要在代码里堆砌无意义的行注释。变量命名上numerator和denominator不要缩写num和den可以接受但a、b这种只在局部小范围内使用的参数名没问题。答辩时老师经常问的另一个问题是如何保证程序的可测试性。你可以在项目里额外加一个测试函数批量跑几十组运算并自动比对结果把通过的用例数打印出来。这个做法可以充分展示你的工程意识而且写起来非常简单就是在一个vector里放预期结果的结构体循环调用断言。6. 一点个人体会这个课设做下来最大的收获不是我会写分数类了而是我真正理解了抽象的意义。分数这个数学对象被我用class封装成程序里的一个普通类型之后后续无论做表达式计算、图形界面还是科学计算器都是在跟这个干净的接口打交道而不需要再关心内部的约分细节。做课设不比比谁堆的功能多而是比谁能把一个小东西做得严丝合缝。我见过太多同学交上来的分数计算器代码能跑出结果但一输入负数或者带分数就炸一提起运算符重载就支支吾吾这就是没有真正吃透设计。写代码之前多花半小时画一画类的接口图比写完之后修两小时bug划算得多。最后再分享一个小技巧做完项目之后把你定义的所有运算符重载函数列出来逐一检查如果操作数是负数、零、极大值这三个边界条件能帮你堵掉至少一半逻辑漏洞这个习惯我到现在写生产代码还在用。