简介C#科学型计算器是一款面向C#初学者、计算机专业学生及Windows桌面开发者的完整工程资源以Visual Studio解决方案形式呈现包含标准计算、科学计算、平均值计算和关于说明等多个功能窗体覆盖科学计算器从用户界面布局到核心算法实现的全部源码。功能上不仅支持加减乘除等基础运算还实现了自然对数、正弦、余弦、百分比运算以及二进制、八进制、十六进制相互转换等高级特性适合作为课程设计、毕业设计或WinForms/WPF入门练习的参考。压缩包内共有46个文件以cs源代码、resx与resources界面资源、sln解决方案、exe可执行程序、pdb调试符号及doc设计实施方案文档为主整体仅209KB目录结构清晰紧凑。通过源码可学习Windows窗体控件布局、EventHandler事件处理、Math库数学函数调用、Convert进制转换方法以及try-catch异常处理机制配套的设计实施方案文档还能帮助理清从需求分析、界面设计到编码实现的完整项目流程。包体小巧适合快速下载研读已有350人学习下载。1. 从“能算”到“算得准”C#科学型计算器到底难在哪我见过不少初学 C# 的开发者第一个练手项目就是计算器。一开始都很顺利等遇到 sin、log、幂运算和括号混在一起时结果就开始有误差界面也开始乱跳。C#科学型计算器的难点从来不是按钮排布而是底层的数据模型、运算优先级和浮点精度这三件事。写这篇文章时我按自己的实践顺序来先定选型再给两个可落地的实现路径最后把最容易翻车的五个场景单独列出来。无论你是交作业还是想在公司内部工具里集成一个计算面板这套方法都能直接照搬。2. 先定计算模型数字类型、界面框架与两种实现流派动手拖按钮之前最先要定的是“数字用什么类型表示、界面用什么框架、计算走哪条路”。这一章把决定后续所有代码的三个选型讲清楚否则写一半再回头改代价远比重写大。2.1 用 WinForms 还是 WPF按交付场景选常见做法是 WinForms 负责快速交付WPF 负责现代界面。两者都能做出完整的科学计算器区别全在维护成本和表现力上。维度WinFormsWPF上手成本低控件拖拽即用中需要理解 XAML、数据绑定、MVVM界面表现经典风格定制麻烦可定制性强适合做皮肤键盘事件直接挂在控件上需要命令绑定稍绕适合场景内部工具、快速落地独立产品、需要视觉质感我一般会建议如果只是把计算能力嵌进现有 Windows 工具里用 WinForms 能省一半时间如果是从零做一个面向终端用户的独立产品再看 WPF。本文的代码以 WinForms 为主但第 4 章的计算解析器是纯 C# 类库WPF 团队也能直接复用不用改一行。2.2 double 与 decimal 的分工不是越精确越好C# 里 decimal 看起来精度更高却完全不适合科学计算器。Math.Sin、Math.Log、Math.Pow 全部只认 doubledecimal 压根进不了这些函数。而 double 是二进制浮点0.1 0.2 会得出 0.30000000000000004这是 IEEE 754 的固有特性不是 bug。类型有效精度除以零行为数学函数支持推荐场景float约 7 位十进制返回 Infinity / NaN有精度有限尽量不用double约 15 - 17 位十进制返回 Infinity / NaN不抛异常Math 全套支持科学计算器首选decimal28 - 29 位有效数字直接抛 DivideByZeroException不支持三角函数等财务金额计算不适合函数键double 的“除以零不抛异常”是个大坑很多人习惯性地以为所有计算错误都会进 catch 块结果在计算器里永远等不到异常。正确做法是在输出层主动判断 double 是否进入 Infinity 或 NaN 状态具体代码在第 5 章。选型这块记住一句话科学计算器用 double财务计算器用 decimal两个方向不能混。2.3 状态机计算与表达式解析决定后续所有代码计算模型有两条路状态机模型和表达式解析器模型必须一开始就选好。状态机模型是原生计算器的交互方式用户按数字、按运算符、再按数字计算器内部用字段保存“上一个操作数”“当前运算符”和“是否新输入”。它贴近物理计算器做单个按钮的即时反馈非常自然但处理 2 3 * 4 这种优先级时要额外维护堆栈代码会逐渐变复杂。表达式解析器模型适合文本框输入用户在一行里输入 23*sin(30)回车后程序先做词法分析再按语法树计算。这个模型能天然处理括号、优先级和函数嵌套缺点是解析器本身要写不少代码。两者对浮点精度、异常处理的要求完全一样区别只在“谁来保存输入的序列”。本文第 3 章写状态机第 4 章写解析器你可以按自己的 UI 形式选一处照抄。如果既要按钮又要文本框我建议直接上解析器因为按钮可以只作为追加字符的入口最终统一走解析逻辑。3. 落地一手写按钮式科学计算器状态机版这一章给一个能直接跑通的最小实现怎么生成按钮界面、怎么管理数字输入、怎么做优先级调度、怎么写科学函数键。代码是 WinForms 风格但核心逻辑换成 WPF 也只是事件绑定的差异。3.1 初始化界面与动态生成按钮如果只靠设计器拖按钮拖几十个是重复劳动而且后续加一个键就要动一次布局。我一般用二维数组保存按钮文本按行列生成再把 Click 事件全部接到同一个处理器上用 Tag 区分身份。private void InitButtons(TableLayoutPanel panel) { string[,] keys new string[,] { { 7, 8, 9, /, sin }, { 4, 5, 6, *, cos }, { 1, 2, 3, -, tan }, { 0, ., , , sqrt } }; for (int r 0; r keys.GetLength(0); r) { for (int c 0; c keys.GetLength(1); c) { var btn new Button(); btn.Text keys[r, c]; btn.Tag keys[r, c]; // 用 Tag 区分身份 btn.Dock DockStyle.Fill; btn.Font new Font(Segoe UI, 12f); btn.Click Btn_Click; // 所有按钮共用一个事件 panel.Controls.Add(btn, c, r); } } }逻辑说明TableLayoutPanel 把窗口按行列网格切分按钮 Dock 设为 Fill 后会自动拉伸填满格子窗口缩放时按钮跟着缩放不会出现空白区域。参数说明Font 里 12f 是常规字号窗口特别小或按钮特别多时可以改成 10fkeys 数组的行列数必须和 TableLayoutPanel 的 RowCount、ColumnCount 对应多一个少一个都会让布局错位。3.2 数字输入与操作数管理状态机版有一个很关键的标志位 isNewEntry它区分“用户刚按完等号现在输入的是一个全新数字”和“用户正在连续输入同一个数字”。没有这个标志按完等号再按数字新数字会直接接在旧结果后面。private StringBuilder currentInput new StringBuilder(); private double lastValue; private string pendingOp ; private bool isNewEntry true; private void EnterDigit(string digit) { if (isNewEntry) { currentInput.Clear(); isNewEntry false; } if (digit . currentInput.ToString().Contains(.)) return; // 防止 1.2.3 currentInput.Append(digit); txtDisplay.Text currentInput.ToString(); }逻辑说明isNewEntry 置为 true 的时机有两个一是按完等号二是按完函数键。这样按下 sin 后再输数字不会把 sin 的结果尾巴接上去。参数说明小数点的去重放在 EnterDigit 内部是必须的。用户连按两次小数点第一次是合法输入第二次应该直接忽略而不是让 Input 字符串变成非法数字导致后续 double.TryParse 失败。3.3 运算符优先级与括号两栈调度如果只做“输入第一个数、按运算符、输入第二个数、按等号”就处理不了 2 3 * 4。科学计算器必须在内部维护操作数栈和运算符栈每当新运算符进来时把栈里优先级不低于它的旧运算符先算掉。private Stackdouble operandStack new Stackdouble(); private Stackstring opStack new Stackstring(); void PushOperator(string op) { while (opStack.Count 0 Priority(opStack.Peek()) Priority(op)) { double right operandStack.Pop(); double left operandStack.Pop(); string oldOp opStack.Pop(); operandStack.Push(Calc(left, oldOp, right)); } opStack.Push(op); } int Priority(string op) { switch (op) { case : case -: return 1; case *: case /: return 2; case ^: return 3; default: return 0; } }逻辑说明等号事件里要把栈清空弹出剩余所有运算符最后 operandStack 的栈顶就是结果。括号怎么处理按“(”时直接压进 opStack按“)”时循环弹栈直到弹出的运算符是“(”为止。参数说明Priority 把幂运算设为 3这样 2 3 ^ 2 会按 3^2 先算符合数学里的优先级约定。但要注意-2 ^ 2 按这个实现会被解析成 (-2) ^ 2而不是 -(2 ^ 2)。如果想严格按数学规则需要把一元负号当成独立运算符处理放到幂运算之后这一点第 4 章的递归下降解析器会给出更干净的解。3.4 数学函数按钮三角函数、对数、阶乘函数按钮大多是对当前显示值做一元变换处理逻辑和二元运算符不同。以 sin 为例private void Btn_Sin(object sender, EventArgs e) { if (!double.TryParse(txtDisplay.Text, out double x)) { ShowError(请输入合法的数字); return; } if (currentAngleMode AngleMode.Deg) // 注意角度/弧度 { x x * Math.PI / 180.0; } double result Math.Sin(x); txtDisplay.Text result.ToString(G15); currentInput.Clear(); currentInput.Append(result.ToString(G15)); isNewEntry true; }逻辑说明Math.Sin 接收的是弧度如果 UI 上显示“角度模式”必须在调用前完成换算。DEG/RAD 切换按钮只改 currentAngleMode 一个字段所有函数按钮都读同一个字段不要各自保存一份模式状态。参数说明“G15”是 ToString 的格式标识表示用最短的字符串保留 15 位有效数字。为什么不用默认 ToString因为 double 在某些值下默认会输出 16 到 17 位显示在窄文本框里会被截断而且后面跟着一长串 0 很难看。对数按钮同理Math.Log 默认底数是 e要做 Log10 得显式调用 Math.Log10(x)。4. 落地二接入表达式解析让用户一次输入整条算式状态机版本适合纯按钮操作。一旦用户有键盘希望直接输入 “23*sin(45)” 再按回车状态机就得按二十几次按钮才能等价表达。这一章给一个最小可用的递归下降解析器并把解析逻辑接到 UI 事件里。4.1 为什么解析式比状态机更贴近真实使用有键盘的场景下表达式解析是唯一不反人类的选择。用户的输入是一条完整的字符串程序要理解优先级、括号和函数。常见做法有两条路自己写递归下降解析器或者引入第三方表达式库。引库快但要审查授权和依赖体积自己写的优势是可控缺点是边界情况必须自己测试补齐。从我的经验看做一个计算器项目手写解析器的收益比引库高。它的代码量不大不过两三百行但能帮你把“优先级、右结合、一元负号”这些概念彻底理清。而且解析器是纯函数输入字符串、输出 double非常适合单测。4.2 最小可用的递归下降语法递归下降的核心思想是“每个语法层只认一种优先级”。加减层调乘除层乘除层调幂层幂层调一元负号层一元负号层调括号或数字。public class ExpressionParser { private string _text; private int _pos; public double Parse(string expr) { _text expr.Replace( , ); // 去空格统一处理 _pos 0; double result ParseAddSub(); if (_pos _text.Length) throw new FormatException($无法解析位置 {_pos} 的字符); return result; } private double ParseAddSub() { double left ParseMulDiv(); while (Peek() || Peek() -) { char op Next(); double right ParseMulDiv(); left op ? left right : left - right; } return left; } private double ParseMulDiv() { double left ParsePower(); while (Peek() * || Peek() /) { char op Next(); double right ParsePower(); left op * ? left * right : left / right; } return left; } private double ParsePower() { double left ParseUnary(); if (Peek() ^) { Next(); double right ParsePower(); // 右结合递归到右侧 left Math.Pow(left, right); } return left; } private double ParseUnary() { if (Peek() -) { Next(); return -ParseUnary(); } if (Peek() ) { Next(); return ParseUnary(); } return ParsePrimary(); } private double ParsePrimary() { if (Peek() () { Next(); double val ParseAddSub(); Expect()); return val; } return ParseNumber(); } private char Peek() { return _pos _text.Length ? _text[_pos] : \0; } private char Next() { return _text[_pos]; } // ParseNumber 与 Expect 见下文 }逻辑说明ParsePower 之所以用递归而不是 while 循环是为了实现幂运算的右结合性。2 ^ 3 ^ 2 应该解析成 2 ^ (3 ^ 2)结果是 512而不是 (2 ^ 3) ^ 2 的 64。这是计算器最容易错的地方左结合和右结合差一个递归位置。参数说明ParseUnary 放在幂运算和括号之间让 -2^2 解析为 -(2^2)。因为 ParseUnary 被 ParsePower 调用幂层先处理 2^2然后才轮到负号取反。而 (-2)^2 由括号层保证左侧的负号被括号包住在 ParsePrimary 里返回 -2再由幂层做平方结果就是 4。这两者的区别正是科学计算器“数学正确性”的核心。4.3 把解析器接到 UI 事件与错误提示上有了解析器等号按钮的逻辑会变得非常简洁。private void Btn_Equals(object sender, EventArgs e) { try { var parser new ExpressionParser(); double result parser.Parse(txtExpression.Text); txtResult.Text result.ToString(G15); } catch (Exception ex) { txtResult.Text 公式有误 ex.Message; } }逻辑说明异常文本直接写进结果文本框不用 MessageBox 弹窗。原因是用户看到错误后马上要改表达式弹窗每次要手动关掉而文本框里的错误信息能让用户一眼看出问题改完继续按等号。参数说明TryParse 的失败场景也被统一收进 catch。表达式里有非法字符时ParseNumber 内部用 double.TryParse 拆 token拆不动就抛 FormatException冒泡到等号事件统一显示。这样 UI 层不需要重复判断计算是否成功所有不可预期的输入都被拦在解析器内部。5. C#科学型计算器避坑指南五条现象级排查记录这一章全部来自真实调试场景。每个问题都按“现象、原因、解决”展开你可以把这一章当作排查手册遇到类似症状直接定位。5.1 除以零之后界面显示成 Infinity 而非错误提示现象用户输入 8 / 0结果显示 “Infinity”没有中文提示看起来像是程序 bug甚至被当成“计算器坏了”。原因double 除以零在 C# 里不抛异常返回 double.PositiveInfinity这是 IEEE 754 标准的规定。很多开发者期望所有数学错误都进 catch 块但在 double 场景下根本等不到那种异常。解决在输出层做统一过滤把所有结果显示都过一遍这个函数private string FormatResult(double value) { if (double.IsNaN(value)) return 结果无定义; if (double.IsInfinity(value)) return 所得值超出范围; return value.ToString(G15); }推演一遍流程sqrt(-1) 返回 NaN0/0 返回 NaN正无穷参与减法可能变成负无穷。三种情况都被 IsNaN 或 IsInfinity 拦截输出用户能看懂的中文提示而不是一串英文字符。这个函数是计算器最后一道防线宁可所有结果显示都走它也不要图省事直接用 ToString。5.2 三角函数结果偏得离谱弧度与度数状态没有固化现象用户笃定 sin 30 等于 0.5计算器输出 -0.988整个界面看起来全是错的。原因Math.Sin 的参数是弧度不是角度代码没做换算就调用了或者换算逻辑写了一半只有某个按钮做了转换。解决定义一个枚举 AngleMode { Deg, Rad }DEG/RAD 按钮只改这一个字段所有函数按钮统一走一个转换方法private double ToRadians(double x) { return angleMode AngleMode.Rad ? x : x * Math.PI / 180.0; }这条背后真正的问题是“状态源单一”。我见过一个版本等号计算用角度模式、sin 按钮用弧度模式同一台机器两个入口算同一道题结果不一致排查半小时才定位到模式被分成了两个字段保存。电子计算器按键和显示是同一套状态源代码里也要坚持这一点全局只有一个 angleMode 变量谁都不能另外存一份模式副本。5.3 文本框手输字母或空格计算直接罢工现象在表达式输入框里敲入 “1 abc”回车后程序弹异常或者更糟直接输出 0用户完全不知道哪里错了。原因解析器在字符串解析时遇到无法识别的字符抛出的异常没有被 UI 层捕获或者空格没处理词法分析在第一个空格处就乱套。解决第一解析器开头做 _text expr.Replace( , )把空格全部排除第二等号事件统一用 try-catch 包住解析调用第三如果输入框本来就只接收按钮输入就设置只读属性禁止键盘字符进入。从我的角度三者都做最稳既可解析非法字符也从入口拦截了非法输入。5.4 按“(”或“)”时程序崩溃括号匹配校验缺失现象用户输入 “((23)”按下等号直接报错崩溃调试时发现异常是索引越界。原因递归下降解析器在 Expect 右括号时已经读到了字符串末尾_pos 越过了 _text.Length而 Expect 没有做边界检查。解决把 Expect 写成安全版本或者在 Parse 最开头做括号配对校验。我习惯两个都做private void Expect(char expected) { if (_pos _text.Length || _text[_pos] ! expected) throw new FormatException($位置 {_pos} 附近缺少 {expected}); }这样至少不会崩溃。更彻底的方案是在 TextChanged 事件里实时统计左右括号数左括号大于右括号时只是提示未闭合右括号先于左括号出现时直接拒绝输入。实时校验能提前暴露问题用户体验远好于等到按等号才报错。5.5 高分屏下按钮错位布局缩放踩坑现象在 125% 或 150% 缩放的显示器上按钮文字被截断两列按钮挤到一起整个布局像被压缩过。原因WinForms 默认自动缩放模式没有适配 DPI如果你在设计器里给每个按钮写了固定像素宽度系统 DPI 变化时控件不会等比缩放。解决设置 AutoScaleMode.Dpi并用 TableLayoutPanel 的百分比列宽代替固定像素this.AutoScaleMode AutoScaleMode.Dpi; panel.ColumnStyles.Clear(); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 20f)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 20f));参数说明Percent 列宽意味着每列固定占容器宽度的 20%五列刚好占满 100%。窗口被拉大时每列同步变宽DPI 缩放时容器尺寸改变列宽也跟着等比变化不会出现按钮溢出的情况。我自己的项目几乎都用这个组合能省掉大量缩放的适配时间。6. 收尾把常被忽略的边界检查补上计算器才算真正完成状态机和解析器版本都做完后很多细节容易被忽略快捷键、记忆键、自动化冒烟测试。这三样恰恰是“能用”和“好用”的分水岭。6.1 用 Keyboard 事件补齐快捷键计算器面板上一百个功能键鼠标点着累。常见做法是在 Form 上启用 KeyPreview并在 KeyDown 里映射按键private void FormCalc_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode Keys.D0 e.KeyCode Keys.D9) { EnterDigit(((int)e.KeyCode - (int)Keys.D0).ToString()); e.Handled true; } else if (e.KeyCode Keys.Enter) { Btn_Equals(sender, EventArgs.Empty); e.Handled true; } }逻辑说明KeyPreview 属性让窗体在控件之前收到键盘事件避免焦点在按钮上时数字键被按钮吃掉。Enter 直接触发等号这是桌面计算器用户最本能的操作。6.2 把记忆键 MR/MC/MS 的语义写清楚很多实现里MR 和 MC 分不清。按规范语义MS 把当前显示值存入内存MR 读取内存MC 清空内存M 是把当前值与内存值相加后存回。状态就一个 double 字段加一个布尔标记但布尔标记必须参与显示否则用户不知道内存里有没有值。private double memValue; private bool hasMemory; void Btn_MS_Click(object sender, EventArgs e) { if (double.TryParse(txtDisplay.Text, out double val)) { memValue val; hasMemory true; lblMemory.Visible true; // 界面上亮起一个 M 标 } }逻辑说明hasMemory 和内存值的更新必须同步。有些版本只在 MS 里置位M 后忘了恢复导致内存键明明有值却显示为空。把 hasMemory 作为唯一状态源所有内存操作都重算它而不是在某一个按钮里手改。6.3 自动化冒烟测试脚本的断言清单计算器属于典型的“改一行坏一片”项目。我的习惯是维护一个断言清单每次改完计算逻辑跑一遍过了才敢继续。输入期望结果验证点0.1 0.20.3显示层格式化不暴露浮点尾数2 3 * 414优先级2 ^ 3 ^ 2512幂运算右结合-2 ^ 2-4一元负号优先级sin(30)角度模式0.5角度弧度转换8 / 0“所得值超出范围”Infinity 过滤((23)错误提示不崩溃括号校验1 / 0.0000000000000001“所得值超出范围”溢出过滤这套清单其实是我自己在一次版本迭代中踩出来的。当时我以为优先级和浮点都没问题结果把幂运算改成右结合后忘了负号层的位置-2^2 输出 4被用计算器核对结果的同事当场抓包。从那以后不管界面怎么改、按钮怎么排这个断言清单都会先跑一遍。边界用例永远写在代码里比记忆可靠。希望帮到你。本文还有配套的精品资源点击获取