ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#窗体计算器源码:事件驱动、表达式解析与状态机设计全解析

C#窗体计算器源码:事件驱动、表达式解析与状态机设计全解析

简介:一份面向C#初学者与Windows窗体开发者的计算器程序完整源码包,集中演示窗体界面设计、控件布局、按钮事件绑定,以及四则运算逻辑和除零错误处理的典型实现流程。压缩包共56个文件,核心为14个cs源码文件及配套Designer设计器代码、resx资源文件,编译后附带可直接运行的exe程序,另有jpg图片、ico图标与resources资源用于按钮样式和多个辅助界面展示,整体仅1.21MB,轻量易读。项目以标准型计算器主窗体为主体,并扩展了“关于”“按键说明”“日历”“我的电话”等辅助窗口,对应多组窗体类、图标及图片资源,可清晰看出多窗体应用的组织与切换方式。已有546人学习使用,适合对照源码逐行理解数字拼接、运算符暂存、运算触发时机、TextBox内容更新等关键点,也能从代码中学习异常捕获与输入校验的常用写法,直接用作课程设计、实训作业或个人练习的起步模板。

1. C# 窗体计算器源代码:为什么一个“玩具”藏着窗体程序的全部基本功

一份 C# 计算器源代码,配上一个 Windows 窗体程序的工程骨架,是入门桌面开发时最容易产生成就感的练手项目:界面能点、逻辑能跑、随手就能打开给别人演示。但真正把加减乘除、小数点、连按等号、取负号这些细节做顺,你会发现它根本不是“玩具”——布局、事件、状态、解析四件事全占了。我这篇就按一套可复现的源代码写法,把界面怎么搭、事件怎么收口、表达式怎么算、哪些边角最容易翻车一次性讲清楚。适合刚学完 C# 基础、想拿第一个窗体程序练手的人,也适合已经在写业务系统、需要快速内嵌一个计算器模块的开发者。

2. 动手前拆骨架:窗体项目的文件结构、入口与事件驱动模型

很多人拿到一份计算器源程序,第一反应是找 Form1.cs,然后对着 Designer.cs 里那一大堆new Button()发懵。其实一个 Windows 窗体项目真正要改的只有两个文件:Program.cs管进程入口,MainForm.cs管窗体和事件。MainForm.Designer.cs是设计器生成的控件初始化代码,新手经常在这三个文件之间迷路,改来改去发现界面没变化,多半是改错了文件。

2.1 从 Program.cs 看入口:新版模板和老模板差在哪

先看入口文件。用开发环境新建 Windows 窗体项目后,Program.cs长这样:

namespace CalculatorDemo { internal static class Program { [STAThread] static void Main() { ApplicationConfiguration.Initialize(); Application.Run(new MainForm()); } } }

ApplicationConfiguration.Initialize()是新版 .NET 模板自动生成的,负责把默认字体、高 DPI 支持、可视化样式一次性初始化好。如果你拿到的源代码是老模板,这里会是Application.EnableVisualStyles()、Application.SetCompatibleTextRenderingDefault(false)三件套,功能相同,都能跑,不用硬改成新写法。

有两点别乱动:[STAThread]这个特性不能删,剪贴板、拖拽、文件对话框这类 COM 操作依赖单线程单元模型,删掉后运行时可能报ThreadStateException。Application.Run(new MainForm())里的MainForm就是你的主窗体类名,改成别的窗体类会导致程序起来后直接闪退或黑窗口。拿到别人的源代码时,先看这两行,能快速判断项目用的哪套模板。

2.2 事件驱动模型:为什么不要在按钮 Click 里堆业务逻辑

窗体程序的运行机制是消息循环:用户在界面上点了什么,操作系统把消息丢进队列,Application.Run里的消息循环取出来,分发给对应控件的事件。所以你的代码不是从上往下执行的,而是“被事件牵着走”。计算器最容易写成一坨的写法,就是每个按钮一个 Click 方法:

private void btn1_Click(object? sender, EventArgs e) { textBox1.Text += "1"; } private void btnAdd_Click(object? sender, EventArgs e) { _firstNumber = decimal.Parse(textBox1.Text); _pendingOperator = "+"; textBox1.Clear(); }

这种写法对“2+3”这种一步运算够了,但状态全散落在_firstNumber、_pendingOperator、_isNewInput这些字段里。等你要加连按等号、连续运算、小数点防重复、取负号时,每个按钮的 Click 都要跟着改,状态流转会变成一团乱麻。

我一般把事件处理当做一个“收口”:所有按钮挂同一个 Click 方法,用按钮的Tag区分语义,事件方法里只做分发,不写计算逻辑。这样新增一个按键,只需要在按键数组里加一行,Click 里的 switch 不用动。后面第三、四章会按这条思路把完整链路搭出来。

3. 搭界面与收事件:TableLayoutPanel 布局 + 统一 Click 分发

界面这层是新手最容易“看着简单、做起来难受”的地方。手工拖十几个 Button 到窗体上,位置全靠鼠标微调,换一台高分屏机器就错位。我这里用一套固定套路:TextBox显示区停靠顶部,TableLayoutPanel铺满剩余空间,按钮全部动态生成。

3.1 用 TableLayoutPanel 划分按键区:列宽设百分比,别设像素

先看构造函数的整体结构:

public MainForm() { InitializeComponent(); var display = new TextBox { Dock = DockStyle.Top, Height = 60, TextAlign = HorizontalAlignment.Right, ReadOnly = true, Font = new Font(FontFamily.GenericSansSerif, 16F, FontStyle.Bold), BackColor = Color.White }; Controls.Add(display); var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 4, RowCount = 6 }; for (int i = 0; i < 4; i++) panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); for (int i = 0; i < 6; i++) panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / 6)); Controls.Add(panel); BuildKeypad(panel); }

布局参数有几个关键选择。Dock = DockStyle.Fill的控件会占据剩余空间,前提是它被添加到 Controls 集合的顺序靠后——WinForms 的停靠布局是按 z-order 从后往前计算的,所以先 Add 显示区、再 Add 面板,面板才能拿到剩余高度。列宽用SizeType.Percent, 25,四列各占四分之一,窗口拉大拉小按钮跟着等比例缩放;如果写成SizeType.Absolute, 80,窗口一拉大右侧就空出一块,DPI 缩放时按钮还可能互相挤压。

接下来的BuildKeypad方法用一个字符串数组集中定义全部按键:

private void BuildKeypad(TableLayoutPanel panel) { string[] keys = { "C", "Backspace", "Negate", "Percent", "7", "8", "9", "/", "4", "5", "6", "*", "1", "2", "3", "-", "0", ".", "=", "+" }; for (int i = 0; i < keys.Length; i++) { var btn = new Button { Text = keys[i], Tag = keys[i], Dock = DockStyle.Fill, Margin = new Padding(3), Font = new Font(FontFamily.GenericSansSerif, 12F, FontStyle.Bold) }; btn.Click += KeyButton_Click; panel.Controls.Add(btn, i % 4, i / 4 + 1); } }

用Tag存按键语义是关键。i % 4算出列号,i / 4 + 1算出符号所在行,“+1”是因为第 0 行要留给显示区——当然如果显示区放在面板外面,行号也可以从 0 开始。这里特意把“C”“Backspace”“Negate”“Percent”放进数组而不是单独手工建按钮,是为了保证所有按键走同一条创建路径。之后你加一个“平方根”键,只需要改数组和 Click 里的 switch。

3.2 用 Tag 做事件收口:一个 Click 方法管所有按钮

所有按钮挂同一个事件处理器后,分发的代码非常短:

private void KeyButton_Click(object? sender, EventArgs e) { if (sender is not Button btn || btn.Tag is not string tag) return; switch (tag) { case "C": ClearAll(); break; case "Backspace": Backspace(); break; case "Negate": ToggleSign(); break; case "Percent": ApplyPercent(); break; case "=": EvaluateExpression(); break; case "+": case "-": case "*": case "/": AppendOperator(tag); break; default: AppendDigit(tag); break; } }

这里的调用方是 WinForms 的事件机制,sender一定是你点击的那个按钮。用is not Button做类型检查是防御性写法,防止有人把别的事件源也挂到这个方法上。Tag是object类型,拿回来要转成string。

这套收口比每个按钮写一个方法省了十几段重复代码。后面键盘输入也要复用同一套流程——你只需要在窗体的KeyDown事件里把Keys.D1转成字符串“1”,然后调用AppendDigit("1"),不用再走一遍 Button 的 Click。这是计算器源程序最值得抄的设计:事件入口可以有很多个,业务方法只有一个。

4. 算得对才算完:表达式解析器与连按等号的状态机

界面搭完了,真正拉开差距的是把“算”这件事做对。按钮堆状态变量的写法我见过太多翻车案例,这里直接说结论:把用户输入拼成一个表达式字符串,交给一个独立解析器去算。第四、五章的所有内容都围绕这个核心展开。

4.1 先避开黑匣子:为什么不推荐用 DataTable.Compute 算表达式

网上很多计算器源码会用DataTable.Compute("2+3*4", null)取结果,代码确实只有一行。但它的代价是:字符串拼错时异常信息很难懂,表达式里带一元负号(比如2*-3)时结果经常不符合预期,而且这套 API 在新版 .NET 里需要额外安装System.Data包。做计算器源码,解析器是值得自己写的东西。

我选择自己写一个迷你解析器,分成三步:词法分析(把字符串拆成记号)、中缀转后缀(处理优先级)、后缀求值。这套流程逻辑清晰,每步都能单独测试,后面要给计算器加括号、加百分号、加取负,改动范围都可控。

4.2 词法分析与逆波兰转换:三段代码搞定优先级

先看词法分析,把"12+3.5*4"拆成["12", "+", "3.5", "*", "4"]:

private static List<string> Tokenize(string expr) { var result = new List<string>(); var number = new StringBuilder(); foreach (char c in expr.Replace(" ", "")) { if (char.IsDigit(c) || c == '.') { number.Append(c); } else { if (number.Length > 0) { result.Add(number.ToString()); number.Clear(); } result.Add(c.ToString()); } } if (number.Length > 0) result.Add(number.ToString()); return result; }

这里有意识用了StringBuilder而不是string +=,输入变长时性能差距明显。注意到c == '.'也归入数字字符,这样3.14不会被拆成"3", ".", "14"。这个版本的词法不校验小数点多于一个,那个边界放到后面的显示层处理,解析器只负责“拿到合法 token 就正确求值”。

然后是中缀转后缀,也就是逆波兰转换,用经典的调度场算法:

private static List<string> ToRpn(List<string> tokens) { var output = new List<string>(); var ops = new Stack<string>(); int Precedence(string op) => op switch { "+" or "-" => 1, "*" or "/" => 2, _ => 0 }; foreach (var token in tokens) { if (decimal.TryParse(token, out _)) { output.Add(token); } else if (token == "(") { ops.Push(token); } else if (token == ")") { while (ops.Count > 0 && ops.Peek() != "(") output.Add(ops.Pop()); ops.Pop(); } else { while (ops.Count > 0 && Precedence(ops.Peek()) >= Precedence(token)) output.Add(ops.Pop()); ops.Push(token); } } while (ops.Count > 0) output.Add(ops.Pop()); return output; }

Precedence用局部函数定义,把运算符优先级集中在一个 switch 里,以后加一个%运算只需要加一行分支。注意比较条件用的是>=而不是>,这保证了同优先级运算符按从左到右的顺序出栈,8-3-2才会得到 3 而不是 7。

求值阶段用后缀表达式的标准算法:

private static decimal EvaluateRpn(List<string> rpn) { var stack = new Stack<decimal>(); foreach (var token in rpn) { if (decimal.TryParse(token, out var number)) { stack.Push(number); } else { var right = stack.Pop(); var left = stack.Pop(); stack.Push(token switch { "+" => left + right, "-" => left - right, "*" => left * right, "/" => right == 0 ? throw new DivideByZeroException() : left / right, _ => throw new InvalidOperationException($"未知运算符: {token}") }); } } return stack.Pop(); }

全部运算用decimal而不是double,这是计算器不出现0.30000000000000004类长尾巴的关键。decimal是十进制定点小数,能够精确表示 0.1、0.2 这类货币场景常用的数值。除以 0 直接抛DivideByZeroException,由窗体层统一捕获并显示“不能除以 0”,解析器本身不处理业务文案。

4.3 连按等号与取负号:最容易翻车的状态机设计

解析器只能算静态表达式,计算器真正的难点在“动态输入的状态管理”。同一套代码,为什么有的计算器连按等号能重复上次运算,有的却原地不动?差别就在窗体层维护的这几个字段:

private string _expression = ""; private decimal _lastResult; private string? _lastOperator; private decimal _lastOperand; private bool _justEvaluated;

先看等号方法,处理“第一次按等号求值”和“再按等号重复上次运算”两种场景:

private void EvaluateExpression() { if (string.IsNullOrWhiteSpace(_expression)) return; decimal result; try { if (_justEvaluated && _lastOperator != null) { result = ApplyOperator(_lastResult, _lastOperand, _lastOperator); } else { result = ParseExpression(_expression); _lastResult = result; _lastOperator = ExtractLastOperator(_expression); _lastOperand = ExtractLastOperand(_expression); } } catch (DivideByZeroException) { DisplayText = "不能除以 0"; ClearAll(); return; } _expression = result.ToString(); DisplayText = _expression; _justEvaluated = true; }

ApplyOperator就是根据运算符符号做加减乘除,直接复用 EvaluateRpn 里的计算分支。ExtractLastOperator和ExtractLastOperand从当前表达式里切出最后一个二元运算符和它右侧的操作数,比如2+3切出+和3。连按等号时用“上次结果 加/减/乘/除 上次操作数”再算一次,这样2+3按等号得到 5,再按得到 8,再按得到 11,符合日常计算器的行为。

输入流程同样要处理_justEvaluated:

private void AppendDigit(string digit) { if (_justEvaluated) { _expression = digit; _justEvaluated = false; } else { _expression += digit; } DisplayText = _expression; } private void AppendOperator(string op) { if (_justEvaluated) { _expression = _lastResult + op; _justEvaluated = false; } else { _expression += op; } DisplayText = _expression; }

这个状态机的核心用意是:算完一次之后,再按数字表示“开始新算式”,再按运算符表示“拿当前结果继续算”。如果不加_justEvaluated判断,会出现“2+3 按等号得到 5,再按数字 4 显示 54”这种奇怪的拼接。

最后补一个一元负号的处理。用户输入2*-3时,Tokenize 会把负号单独拆成一个"-"token,直接进 RPN 会按二元减法处理。在处理一元负号时,我通常会在 Tokenize 之后加一个合并预处理:

private static List<string> MergeUnaryMinus(List<string> tokens) { var merged = new List<string>(); for (int i = 0; i < tokens.Count; i++) { if (tokens[i] == "-" && (i == 0 || merged.Count == 0 || merged[^1] is "(" or "+" or "-" or "*" or "/")) { merged.Add("-" + tokens[i + 1]); i++; } else { merged.Add(tokens[i]); } } return merged; }

条件是“负号位于表达式开头,或者前一个 token 是左括号或二元运算符”,这时它是一元负号,直接把下一个数字 token 拼成"-3"这样的整体。反过来,如果负号前面是数字或右括号,它就是普通的减法。这段代码解决了计算器源码里最高频的一类 bug:2*-3算出 1 或者直接报错。

5. 常见问题与避坑:移植这段源代码前先查这 5 个点

这套代码我在本地跑通过,也给别人排查过二次开发出来的问题。下面五个坑是按出现频率排的,每一条都是实际踩过的血泪经验,查的时候按顺序看。

5.1 连按等号结果不对:算完一次后状态没重置

现象:输入2+3,按等号显示 5,再按等号显示还是 5,或者变成55。原因:_justEvaluated标志没有在按等号后置为 true,导致第二次等号走了“新计算”分支;或者_justEvaluated置 true 后,AppendOperator里没有用_lastResult续接。解决:确认状态机四个分支都做了处理——数字键、运算符键、等号键、清空键分别对应“开始新算式”“继续运算”“重复上次运算”“全部归零”。

5.2 double 精度尾巴:0.1+0.2 显示一长串小数

现象:输入0.1+0.2,结果显示0.30000000000000004。原因:double用二进制浮点数存储,无法精确表示 0.1。解决:所有数值类型统一用decimal,解析器、_lastResult、_lastOperand全部换成decimal,显示时用result.ToString()即可。decimal的运算速度比double慢一点,计算器这种低负载场景完全感觉不到差异。

5.3 除以 0 直接崩溃弹异常

现象:输入7/0按等号,窗体直接弹出未处理异常对话框。原因:EvaluateRpn里right == 0抛出的DivideByZeroException没有被捕获。解决:在EvaluateExpression里 try-catch 这个异常,显示区置为“不能除以 0”,然后调用ClearAll()清空所有状态。注意 catch 之后必须重置_expression,否则下一次按数字会把错误文案拼接进表达式。

5.4 高分屏下按钮错位、文字发虚

现象:同一个源程序,在别人 1080P 屏幕上正常,换到 4K 笔记本上按钮挤压变形。原因:布局用了绝对像素列宽,或者没有处理 DPI 缩放。解决:列宽用SizeType.Percent,按钮和显示区字体用相对字号。窗体初始化时,新版模板的ApplicationConfiguration.Initialize()已经包含高 DPI 支持声明,如果你把它删了换成老模板三件套,就要显式调用Application.SetHighDpiMode(HighDpiMode.PerMonitorV2)。

5.5 显示区 0 和小数点输入混乱

现象:初始显示 0,按“5”变成05;先按小数点输入.5,程序在解析时报格式错误。原因:直接往文本框追加字符,没有维护输入状态。解决:显示区永远只渲染_expression,AppendDigit里对初始空串不做特殊处理,靠前端逻辑控制“能不能按小数点——如果当前正在输入的数字片段里已经包含.,直接忽略这次点击”。这个判断放在AppendDigit里,识别出“最后一个操作数已经含小数点”就不要追加。

private void AppendDigit(string digit) { if (_justEvaluated) { _expression = digit; _justEvaluated = false; } else if (digit == ".") { var lastNumber = GetLastNumber(_expression); if (lastNumber.Contains(".")) return; _expression += digit; } else { _expression += digit; } DisplayText = _expression; }

GetLastNumber的实现就是从表达式尾部往前找,跳过运算符,取出当前正在输入的数字串。这个函数大约 10 行,单独写成方法方便单测。

6. 收尾与进阶:把引擎独立测试,再给计算器加键盘和百分号

最后做两件事,一件是验证,一件是真正常用的进阶功能。

先说验证。我习惯把 Tokenize、ToRpn、EvaluateRpn 三个方法放在一个独立的ExpressionEngine静态类里,不依赖窗体。这样可以直接建一个临时控制台入口跑测试:

var cases = new (string expr, decimal expected)[] { ("1+2", 3), ("2*3+4", 10), ("8-3*2", 2), ("10/4", 2.5m), ("0.1+0.2", 0.3m), ("2*-3", -6m), ("(1+2)*3", 9) }; foreach (var (expr, expected) in cases) { var actual = ExpressionEngine.Parse(expr); Console.WriteLine($"{expr} => {actual}, 期望 {expected}, {(actual == expected ? "通过" : "失败")}"); }

这个验证表的重点是覆盖运算符优先级、小数精度、一元负号三个高风险点。(1+2)*3这个用例需要你的 Tokenize 支持括号——第四节的 ToRpn 已经带了括号处理,界面再加“(”“)”两个键就行。

再给计算器加键盘输入。WinForms 里让窗体获得焦点后重写OnKeyDown:

protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode >= Keys.D0 && e.KeyCode <= Keys.D9) { AppendDigit(((char)('0' + (e.KeyCode - Keys.D0))).ToString()); } else if (e.KeyCode == Keys.Add) { AppendOperator("+"); } else if (e.KeyCode == Keys.Enter || e.KeyCode == Keys.Oemplus) { EvaluateExpression(); } }

注意在这之前先对_justEvaluated做判断,否则键盘快速连按会被当成同一状态的重复触发。键盘映射的复用逻辑正是第三章“统一 Click 收口”的收益——AppendDigit不关心调用方是鼠标还是键盘。

百分号是很多计算器源码里语义最模糊的按钮。常见的做法有两种:标准型把百分号当“除以 100”,比如输入200按%得到2;商务型把百分号当“相对前一个操作数”,比如200+10%表示200*1.1。我一般做商务型,因为用到百分号的人基本都是算“原价加几个点”:

private void ApplyPercent() { if (_lastOperator is "+" or "-" && _lastOperand != 0) { var baseValue = decimal.Parse(_expression); _expression = (_lastOperand * baseValue / 100m).ToString(); } else { _expression = (decimal.Parse(_expression) / 100m).ToString(); } DisplayText = _expression; }

这段代码的边界很明确:如果表达式是200+后面正在输入10,%把10改成20(200 的 10%);如果表达式只是一个裸数字,%直接除以 100。两种语义都不需要改解析器,只改按下百分号时拼接进_expression的内容。

我最早写计算器也是按钮堆变量,后来被连按等号和一元负号反复折磨,改成表达式解析器才把账理顺。这个教训后来一直跟着我:任何带用户输入的窗体程序,先设计输入状态机,再写计算逻辑,顺序反了后面全是补丁。这次给的框架足够你跑通一版,后面加括号、加平方根、做内存键(M+、MR)都可以在这个结构上继续长。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进