十六进制计算器这东西,乍一看像是课程作业级别的练手项目,真拿它当日常工具用起来,坑比想象的多。我平时要翻寄存器手册、看内存 dump、拼协议字段、核对颜色值和哈希前缀,系统自带的计算器切到程序员模式之后,位宽、有无符号、表达式、历史记录这几件事总是差一口气,来回复制粘贴还经常把0x前缀带进去导致解析失败。所以后来干脆用 C# 自己写了一个,支持 8/16/32/64 位宽切换、有符号与无符号双视角、四则与位运算混合表达式、进制联动显示和操作历史。这篇就聊聊整个设计过程和实现细节,从类型选型、补码处理、表达式解析到界面事件处理,把踩过的坑和能直接抄的代码都摊开讲。适合已经会写 C# 基础语法、想做一个真正能用的小工具的朋友,也适合刚接触位运算、对补码和溢出还犯迷糊的同学。
1. 需求拆解与整体设计思路
动手写代码之前,我花了大半天时间把自己平时用计算器的场景列了一遍。这一步看起来很虚,但它直接决定了后面类型怎么选、界面怎么排、哪些功能可以砍。很多人做这类工具做到一半发现架构撑不住,问题往往不是出在编码阶段,而是需求没想清楚就开始画按钮了。
1.1 十六进制计算器真正要解决的是什么问题
先说清楚它和普通计算器的差别在哪。十进制计算器关心的是“数值大小”,十六进制计算器关心的是“位模式”。这两个视角在溢出的时候会打架:0xFFFFFFFF加 1,从数值角度看是 4294967296,从 32 位位模式角度看是0x00000000并且进位标志置位。我需要的恰恰是后者——写寄存器的时候,溢出回绕是常态,报错反而不符合预期。
具体到使用场景,我梳理出这么几类高频操作:
- 寄存器位域拼装:把
0b1010挪到第 4 位,和低 4 位掩码合并,这类操作全靠移位和按位或。 - 地址偏移计算:基址
0x40012000加上偏移0x3C,需要结果保持 8 位十六进制对齐,前面补零。 - 协议字段校验:把几个字节拼成一个 32 位整数,取高 16 位和低 16 位分别比对。
- 掩码与标志位判断:判断某一位是否置位,本质是
(value & mask) != 0。 - 颜色值与哈希片段:
#FF8800这种,拆分通道、调亮度、算对比色。
这些操作的共同点是:输入输出都是十六进制串,中间过程需要位运算,而且对位宽极其敏感。所以第一版设计目标就定下来了——内部统一按位模式存储,显示层负责解释。这个原则后面会反复用到,它是整个架构的地基。
1.2 为什么用 C#,以及界面技术怎么选
C# 做这类桌面小工具其实非常顺手。ulong是原生 64 位无符号整数,位运算直接映射到 CPU 指令,不用像脚本语言那样担心大整数对象的开销;字符串格式化和解析的 API 也齐全;再加上能编译成单文件可执行程序,扔到 U 盘里在任何一台机器上双击就能跑,这点对我很关键。
界面层的选择我对比过几种方案,列个表更清楚:
| 方案 | 启动速度 | 布局灵活度 | 上手成本 | 分发体积 | 适合场景 |
|---|---|---|---|---|---|
| WinForms | 快,几乎秒开 | 一般,靠绝对坐标 | 低 | 小 | 固定布局的工具类 |
| WPF | 中等 | 高,支持样式与动画 | 中 | 中 | 需要主题切换、复杂视觉 |
| 控制台 | 最快 | 无 | 最低 | 最小 | 脚本化、管道处理 |
| 浏览器前端 | 取决于宿主 | 高 | 中 | 需宿主 | 跨平台分享 |
我的结论是 WinForms。理由是这类工具的界面是固定网格——四行五列的按键区、一行显示区、一行历史区。用绝对坐标加锚点就能搞定,不需要数据绑定和样式系统,省下来的时间可以全部投到计算引擎上。如果你的目标是做一个能换肤、带动画的产品,那就该选 WPF,这是它擅长的领域;如果只是想批量处理一堆十六进制串,控制台版本反而最高效,可以直接从标准输入读、往标准输出写。
提示:不要一上来就纠结“哪个框架更先进”。工具类软件的第一评价标准是“打开快、不崩、结果准”,视觉表现排在很后面。选你最熟的那个,先把核心引擎跑通。
1.3 三种输入模式与状态机设计
用户敲按键的过程是有状态的,这点如果不在设计阶段想清楚,后面会写出一堆if-else嵌套。我把输入过程抽象成一个小的状态机,状态定义如下:
- InputState(输入中):正在录入一个操作数,此时按数字键追加,按运算符键触发“提交操作数”。
- OperatorPending(运算符挂起):刚按下运算符,等待第二个操作数。
- ResultState(结果态):刚按下等号,此时按数字键应该清空重新开始,按运算符键应该以当前结果为左操作数继续算。
- ErrorState(错误态):除零、括号不匹配、非法字符,任何数字键都清空错误并重新开始。
这个状态机的价值在于:它把“按了数字键该做什么”这种判断从十几个地方收敛到一处。我在第一版里没做状态机,结果在结果态按数字键时出现了“在结果后面追加数字”的诡异行为,0xFF算完之后按A,变成了0xFFA。改成状态机之后,这类问题一次性消灭。
状态转移我用一个二维表来描述,行是当前状态,列是按键类别,单元格是动作:
| 当前状态 \ 按键 | 数字键 | 运算符 | 等号 | 清除 |
|---|---|---|---|---|
| InputState | 追加字符 | 提交并转 OperatorPending | 求值转 ResultState | 转 InputState 并清零 |
| OperatorPending | 开始新操作数转 InputState | 替换运算符 | 用左操作数重复运算 | 转 InputState 并清零 |
| ResultState | 清空后追加转 InputState | 以结果为左值转 OperatorPending | 重复上次运算 | 清零转 InputState |
| ErrorState | 清空后追加转 InputState | 保持错误态 | 保持错误态 | 转 InputState |
2. 核心数据模型与底层算法
这一节是整个项目的技术核心。显示、按键、历史记录都是外围,真正决定这个工具能不能用的是数据类型怎么选、补码怎么处理、位宽怎么统一。我在这部分返工过两次,所以踩的坑比较具体。
2.1 内部数值类型:long、ulong 还是 BigInteger
一开始我用的是long,觉得有符号更通用。很快就出问题了:0xFFFFFFFFFFFFFFFF这个输入,用long.Parse会直接抛溢出异常,可它明明是一个合法的 64 位位模式,只是被解释成 -1 而已。用户输入的是位模式,不是数值,用有符号类型接收本身就是语义错位。
换成ulong之后主要问题解决了:64 位全 1 就是18446744073709551615,可以正常存储。但还有两个细节要处理。第一,8 位和 16 位模式下要主动截断,否则0xFF加0x01会得到0x100而不是回绕成0x00。第二,有符号视角的显示要单独算,不能直接强转,因为 32 位模式下的0xFFFFFFFF强转成long得到的是 4294967295,而不是 -1。
至于BigInteger,我在第二版试过,理由是它能支持任意位宽。结论是不划算。它的位运算性能和原生整数差一个数量级,而且一旦引入“无限位宽”,所有关于溢出的语义就都消失了——你没法在 32 位模式下演示回绕,而这恰恰是十六进制计算器最有价值的功能之一。所以我把它砍掉,只在“十进制大数换算”这个非核心功能里临时用一下。
最终方案定成这样:内部统一用ulong存储位模式,所有运算都在ulong上做,运算完成后立刻按当前位宽做一次掩码。位宽用一个枚举表示,掩码从位宽推导。这样 “位宽” 变成了一个贯穿始终的约束,而不是散落在各处的特判。
2.2 掩码生成与边界情况
掩码的生成公式看起来很简单:mask = (1UL << bits) - 1。位宽 8 时得到0xFF,位宽 16 时得到0xFFFF,都对。但位宽 64 的时候,1UL << 64在 C# 里不会报错,而是按位移量取模——移位量是 64,64 % 64 = 0,所以结果还是 1,减一之后得到 0,掩码全灭。这是个非常隐蔽的坑,症状是所有 64 位运算结果都变成 0。
正确的写法是单独判断:
public static ulong BuildMask(int bits) { if (bits <= 0 || bits > 64) throw new ArgumentOutOfRangeException(nameof(bits)); // 64 位要特判,因为 1UL << 64 会回绕成 1 return bits == 64 ? ulong.MaxValue : (1UL << bits) - 1; }有了掩码,所有运算统一走一个收口函数:
// 把任意结果收进当前位宽,模拟硬件回绕 public static ulong Wrap(ulong value, ulong mask) => value & mask;这个Wrap函数是整个引擎里调用最频繁的方法。加、减、乘、左移、按位取反,全部要过它一遍。取反尤其要注意,~value在 64 位下会把高位移全部翻转,8 位模式下必须用掩码截回来,否则显示出来是十六个 F 开头的怪物。
2.3 有符号视角与补码显示
同一个位模式,无符号解释和有符号解释是两种读法。32 位模式下0xFFFFFFFF,无符号是 4294967295,有符号是 -1。两者的转换靠符号扩展完成:
// 按位宽解释符号位,返回有符号值 public static long ToSigned(ulong value, int bits) { ulong mask = BuildMask(bits); value &= mask; ulong signBit = 1UL << (bits - 1); if ((value & signBit) == 0) return (long)value; // 正数直接返回 // 负数:补码取反加一,再取负 ulong magnitude = ((~value) & mask) + 1; return -(long)magnitude; }这段逻辑我解释一下为什么这么写。补码的负数,其绝对值等于“按位取反再加一”。比如 8 位下的0xFF,取反(限制在 8 位)得0x00,加一得 1,所以是 -1。0x80取反得0x7F,加一得0x80也就是 128,所以是 -128。这个算法对所有位宽通用,不需要为 8/16/32/64 各写一遍。
注意:做取反的时候一定要先
& mask再+1,顺序反了会在边界上出错。8 位下0x00取反是0xFF,加一是0x100,如果不截断就变成 256,而正确结果是 -256,超出 8 位表示范围,属于溢出输入,界面上应该给出提示而不是静默给个错值。
显示层我做成双行:上面一行显示无符号十六进制(带前导零,长度等于位宽除以 4),下面一行小字显示有符号十进制和无符号十进制。这样一眼能同时看到位模式和数值,比来回切换视图方便得多。前导零的补齐用PadLeft:
int hexDigits = bits / 4; // 8位→2, 16位→4, 32位→8, 64位→16 string hex = value.ToString("X").PadLeft(hexDigits, '0');2.4 位运算与算术运算的实现差异
算术运算和位运算在位宽处理上的策略不一样,这点值得单独说。加减乘可以直接在ulong上算完再Wrap,因为回绕是自然发生的,ulong的溢出本来就是取模 2^64,再按位宽截断正好模拟硬件行为。除法要注意除零检查,而且要区分“无符号除法”和“有符号除法”——0xFF / 0x02,无符号视角是 127,有符号视角(8 位)是 -1 除以 2,结果是 0(向零取整)。这两种结果天差地别,所以界面上必须明确当前是哪种视角,我把它做成了一个显式的切换开关,而不是隐式推断。
移位是最容易出问题的。规则有这么几条:
- 左移时,移出的高位被丢弃,低位补零,结果过一遍掩码。
- 逻辑右移(无符号)高位补零,任何位宽下都安全。
- 算术右移(有符号)高位补符号位,负数右移保持负号。
- 移位量超过位宽时,C# 的
<<和>>会对移位量取模(ulong是& 63),这往往不是用户想要的,需要主动校验并给出提示。
最后一条我吃过亏。用户输入“左移 32 位”,在 8 位模式下,1UL << 32实际算的是1UL << (32 & 63)也就是1UL << 32,结果是一个巨大的数,截断到 8 位变成 0,看起来“对了”,但中间的语义完全错了。我的处理是移位前检查shift >= bits,如果是就直接返回 0 并提示“移位量超出位宽”。
3. 表达式解析:从字符串到结果
支持表达式是我最想要的功能,因为实际工作中经常要一次算一长串,比如((0x10 | 0x04) << 8) + 0xFF。如果只能一步一步按,中间结果还得记下来,很容易错。
3.1 两种解析方案的取舍
常见方案有两个:调度场算法(Shunting-yard)和递归下降(Recursive Descent)。前者把中缀表达式转成后缀再求值,用两个栈实现,代码短;后者按文法一层层往下写函数,可读性好,报错定位精确。
我最后选的是调度场,理由是代码量小、结构紧凑,对计算器这种运算符集合固定的场景完全够用。递归下降更适合需要支持函数调用、变量、自定义运算符的复杂场景,对这里来说是过度设计。
运算符优先级我定成四级,从高到低:
| 优先级 | 运算符 | 结合性 | 说明 |
|---|---|---|---|
| 4 | ~ | 右 | 按位取反(一元) |
| 3 | <<>> | 左 | 移位 |
| 2 | & | 左 | 按位与 |
| 2 | ^ | 左 | 按位异或 |
| 2 | | | 左 | 按位或 |
| 1 | */% | 左 | 乘除取余 |
| 0 | +- | 左 | 加减 |
等等,这个表有个问题:按位运算的优先级我放得比四则还高,这跟 C 语言的传统是反的。C 里&的优先级低于==而高于^,整体排在加减之后。我这么排是出于实用考虑——十六进制场景下0x0F & 0x03 + 0x01这种写法几乎不会出现,用户更多是(a & b) << 8这种明确加括号的形式。但为了避免歧义,我在界面上做了个显式提示,并且默认在二元按位运算两侧自动补括号。这是个产品决策,不是技术决策,写的时候想清楚就行。
3.2 词法分析要处理的前缀问题
表达式里的数字有三种写法:带0x前缀的十六进制、带0b前缀的二进制、不带前缀的裸十六进制(因为整个计算器的语境就是十六进制)。第三种最容易出问题——10到底是十进制的十,还是十六进制的十六?
我的处理是:默认所有裸数字都是十六进制,界面上有个开关可以切到“裸数字按十进制解释”。这个默认值符合工具定位,但它会带来一个连带问题:0x1F里的F是数字,而表达式里的E可能是科学计数法。由于我压根不支持浮点和科学计数法,所以把A-F一律按数字处理,不产生歧义。
词法分析器我用一个游标加一个switch实现,核心就三步:跳过空白、识别数字串、识别运算符。运算符要按最长匹配,因为<<和<都存在,先匹配两字符再匹配单字符。
// 简化版词法扫描,只保留主干逻辑 private static List<Token> Tokenize(string src) { var tokens = new List<Token>(); int i = 0; while (i < src.Length) { char c = src[i]; if (char.IsWhiteSpace(c)) { i++; continue; } // 前缀形式:0x / 0b if (c == '0' && i + 1 < src.Length && (src[i + 1] == 'x' || src[i + 1] == 'X')) { int start = i + 2, j = start; while (j < src.Length && Uri.IsHexDigit(src[j])) j++; if (j == start) throw new FormatException("0x 后缺少十六进制数字"); tokens.Add(Token.Number(Convert.ToUInt64(src.Substring(start, j - start), 16))); i = j; continue; } // 裸数字:连续十六进制字符 if (Uri.IsHexDigit(c)) { int start = i, j = i; while (j < src.Length && Uri.IsHexDigit(src[j])) j++; tokens.Add(Token.Number(Convert.ToUInt64(src.Substring(start, j - start), 16))); i = j; continue; } // 双字符运算符优先 if (i + 1 < src.Length) { string two = src.Substring(i, 2); if (two is "<<" or ">>") { tokens.Add(Token.Op(two)); i += 2; continue; } } tokens.Add(Token.Op(c.ToString())); i++; } return tokens; }这里有个小细节:Convert.ToUInt64在数字超过 64 位时会抛异常,这个异常需要捕获后转成友好的错误提示,而不是让它冒泡到界面上显示一堆堆栈信息。
3.3 调度场求值与错误定位
调度场算法的骨架是两个栈:一个存操作数,一个存运算符。遇到数字压操作数栈;遇到运算符时,先把栈顶所有优先级更高或相等的运算符弹出求值,再把自己压进去;遇到左括号直接压栈;遇到右括号就一路弹到左括号为止。表达式扫完之后把剩下的运算符全部弹出求值,操作数栈顶就是结果。
一元运算符要单独处理,判断依据是“前一个 token 是运算符或左括号,或者当前位置是开头”。~就是典型的一元运算符,-在开头时也是一元负号,实现上是把它转成“0 减该值”或者直接对操作数取补码。
求值过程中最容易出错的三个点,我在代码里都加了显式检查:
- 操作数不足:弹出运算符时栈里少于两个值,说明表达式结构有问题,比如
5 +。 - 括号不匹配:扫完还有左括号没闭合,或者右括号找不到对应左括号。
- 除数为零:除法前判断,直接抛出带位置信息的异常,界面上高亮出错的那个字符位置。
错误信息我做得比较细,会带上出错字符的索引,界面上用Substring把出错位置标红。这看起来是小事,但调试长表达式的时候能省大量时间——你不需要自己数到第几个字符。
4. 界面实现与交互细节
引擎写完之后,界面部分其实占了整个项目一半的代码量,而且踩的坑更“脏”。这部分没有多少理论,全是实打实的经验。
4.1 按键布局与快捷键映射
布局我用的五行四列网格,上半部分是功能键,下半部分是数字和运算。按键用TableLayoutPanel排,每个按钮设Dock = Fill,这样窗口拉伸时按钮自动跟着变大,不需要手写Resize事件去算坐标。
快捷键映射这块要特别注意:&和|在键盘上需要按 Shift,^在某些键盘布局里位置也不一样。我的做法是给每个运算符都配一个“无 Shift”的替代键——比如按A触发按位与、按O触发按位或、按X触发异或。这样单手就能操作,效率高很多。映射表放在一个字典里,KeyDown事件里查表即可。
比分发更重要的是不要让按钮抢走焦点。WinForms 里按钮默认是TabStop = true,点完之后焦点在按钮上,此时按空格会触发按钮点击而不是输入空格,按方向键也没法在文本框里移动光标。处理办法是把所有按钮的TabStop设成false,并在点击后手动把焦点还给输入框:
private void OnDigitClick(object sender, EventArgs e) { var btn = (Button)sender; _inputBox.Focus(); // 焦点还给输入框 _inputBox.SelectionStart = _inputBox.Text.Length; InsertText(btn.Tag?.ToString() ?? btn.Text); }4.2 输入清洗与粘贴处理
用户从别处复制过来的十六进制串,格式五花八门:带0x前缀的、带空格的、带换行的、带下划线分隔的(0xFF_FF)、甚至带引号的。第一版我直接把这些字符串往解析器里塞,结果各种异常。
后来统一在入口处做一次清洗:去掉空白字符、去掉下划线、去掉成对的引号、把全角字符转半角、把大小写的x统一成小写。清洗完之后再判断是否带前缀,带前缀的去前缀,不带前缀的按当前进制假设处理。这个清洗函数只有二十来行,但它把粘贴相关的崩溃率降到了零。
提示:全角转半角这一步千万别省。中文输入法下不小心打出全角的
0和F,肉眼几乎看不出来,但解析器完全不认,报的错误还特别隐晦。我在这个问题上浪费了半小时才定位到。
4.3 历史记录与结果复用
历史记录我用了最简单的方式:一个List<string>存表达式文本,一个ListBox显示,只保留最近 100 条。点击某条记录时,把表达式回填到输入框而不是直接回填结果——因为很多时候我是想“改一下再算一次”,而不是“用这个结果”。
这里有个体验细节值得说:回填表达式时,光标要定位到末尾,并且要触发一次全选取消,否则用户接着打字会覆盖掉已有内容。另外历史记录要支持上下方向键浏览,这是命令行用户的本能反应,实现起来也就几行代码,但用起来顺手很多。
持久化我没用数据库,直接序列化成一个文本文件放在用户目录下,每行一条,读写都是一行代码的事。用 JSON 反而有点重了,因为数据结构就是字符串数组,没必要引入序列化框架。
4.4 高 DPI 与显示效果
高分屏上 WinForms 默认会把界面糊成一片,这个必须在程序入口处显式声明:
static void Main() { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); }同时MainForm.Designer.cs里的字体不要写死像素值,用相对单位或者干脆依赖系统字体。我最初把显示区的字号写成 24 像素,在 150% 缩放的屏幕上直接溢出了控件边界,数字被裁掉一半。改成按窗体宽度动态计算字号之后才稳定。
5. 常见问题与排查实录
这一节是我在自用过程中真实遇到过的问题,按“症状→原因→解决”的方式整理,可以当速查表用。
5.1 数值类问题的排查路径
| 症状 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 64 位结果恒为 0 | 掩码生成时1UL << 64回绕 | 打印掩码值看是否为 0 | 64 位单独返回ulong.MaxValue |
| 8 位相加变成 9 位 | 运算后未过掩码 | 检查结果是否大于0xFF | 所有运算出口统一Wrap |
| 取反后显示十六个 F | ~在 64 位下翻转高位 | 观察高 8 位 | 取反后立即& mask |
| 有符号显示数值不对 | 用强转代替符号扩展 | 检查(long)value | 按位宽计算符号位 |
| 移位结果莫名其妙 | 移位量被取模 | 打印实际移位量 | 位移前校验shift < bits |
| 大数粘贴报溢出 | Convert.ToUInt64超范围 | 看输入长度 | 捕获异常给友好提示 |
关于“8 位相加变成 9 位”这条,我再补充一点。这个 bug 的隐蔽之处在于它只在特定输入下出现——0x7F + 0x01会出错,0x10 + 0x01不会。如果你只测了几个小数字,很容易漏掉。我的建议是准备一组边界用例,每次改完引擎都跑一遍:最小值、最大值、最大值加一、零减一、符号位翻转、位移到位宽边界。
5.2 输入与界面类问题
输入法冲突:中文输入法激活时,按键事件会先被输入法捕获,数字键可能变成候选词选择。处理办法是在输入框的KeyPress事件里把e.Handled置位,并且给输入框设ImeMode = ImeMode.Off,从源头关掉输入法。
焦点丢失:点完按钮点别处,再按键盘没反应。原因是焦点跑到了不可输入的控件上。除了前面说的TabStop = false,我还加了一个窗体级的KeyPreview = true,让窗体先接一层按键再分发,这样即使焦点在别处,数字键也能正常输入。
粘贴内容含换行:从多行文本里复制一段十六进制,末尾带\r\n。ulong.Parse会报格式错误。清洗阶段统一Replace("\r", "").Replace("\n", "")就行。
启动时窗口位置跑到屏幕外:多显示器环境切换之后,上次保存的窗口位置可能已经失效。加载配置时要做一次边界校验,判断窗口矩形是否与任何屏幕区域相交,不相交就回到主屏居中。
长时间使用后历史记录卡顿:ListBox每次都是全量重绘,100 条以内还好,超过之后滚动会掉帧。改成虚拟模式或者干脆限制条数就行,我的选择是限制条数——工具类软件不需要无限历史。
5.3 一个容易被忽略的语义问题
有一个问题我纠结了很久,值得单独拿出来说:无符号和有符号的运算结果到底该怎么显示。
举个例子,8 位模式下0x80 + 0x80。无符号视角:128 + 128 = 256,回绕后结果0x00。有符号视角:-128 + (-128) = -256,回绕后同样是0x00。位模式一致,但如果你在结果旁边显示十进制注解,无符号显示 0,有符号也显示 0,看起来都没问题。
再看0xFF * 0x02。位模式结果是0xFE。无符号视角:255 × 2 = 510,回绕到 8 位是 254。有符号视角:-1 × 2 = -2,对应位模式0xFE,也就是 -2。两个视角给出的“数值解释”完全不同,但位模式一致。
我的结论是:运算永远在位模式层面进行,数值解释只放在显示层。这样不管用户从哪个视角看,位模式都是确定的、可复现的。如果反过来按数值语义去算,你就会陷入“这个运算该用哪种语义”的无休止判断里,而且结果会依赖上下文,很难调试。这条原则我在开头提过一次,这里用具体例子把它落实了。
6. 工程化收尾与扩展空间
功能跑通之后,我做了几件让这个工具从“能跑”变成“敢长期用”的事。
6.1 单元测试怎么覆盖边界
计算引擎是纯静态函数,输入输出都是值类型,非常适合写单元测试。我的测试用例分成四组:
- 转换组:各进制互转,重点测 0、最大值、带前导零的串。
- 符号组:各符号位的边界,
0x7F、0x80、0xFF,分别在 8/16/32/64 位下验证有符号解释。 - 运算组:加减乘除、四种位运算、两种移位,每类都测溢出和不溢出两种情况。
- 解析组:合法表达式、缺操作数、缺右括号、非法字符,每类都要验证异常类型和错误位置。
我建议至少写 60 个左右的用例,覆盖率会很好看,而且每次重构引擎时心里有底。跑一遍测试只要几百毫秒,比手工点界面快得多。
6.2 命令行模式的价值
图形界面适合交互式使用,但有些场景更适合命令行:批量转换一堆地址、在构建脚本里做校验、把结果管道给别的工具。我在同一个项目里加了个--cli开关,参数走标准输入输出,格式就是“表达式进、结果出”。
这样做的好处是复用同一套引擎,不用维护两份实现。坏处是要把界面的进度提示和命令行的输出分开,不能混在一起。我的处理是把所有提示信息都通过一个INotifier接口发出,图形界面用弹窗实现,命令行用标准错误流实现。
6.3 我个人在实现中的几点体会
第一个体会是关于“位宽”这个抽象。我一开始把位宽当成界面上的一组单选按钮,后来发现它应该是一个贯穿引擎的参数——所有的掩码、所有的符号扩展、所有的显示格式化,都要读取这一个值。把它从界面层提到引擎层之后,代码少了一大截,逻辑也清晰了。
第二个体会是关于错误处理。工具类软件最忌讳“静默给错值”。除零给个 0、溢出给个截断结果,看起来程序不崩,实际上比崩溃更危险,因为用户会拿这个错值去改寄存器。所以我的策略是:任何超出预期的输入都显式报错,宁可弹窗打断,也不默默给个可能错的答案。
第三个是性能上的小事。我原本以为计算量这么小的工具不需要考虑性能,直到我做了 64 位乘方运算,BigInteger加上每步掩码,一次运算要几十毫秒,连按几下就明显卡顿。改成快速幂加位宽截断之后,耗时降到了微秒级。这说明即使是小工具,选错了数据结构一样会难受。
提示:如果你的计算器要支持乘方,别用
Math.Pow,它走的是浮点,超过 2^53 之后精度就丢了。用循环快速幂在ulong上做,每一步都Wrap,结果才是准确的位模式。
这个工具后来我又陆续加了几样东西:位域可视化(把 32 位画成 32 个小格子,点击直接翻转某一位)、CRC 计算、大小端字节序互换。加起来代码量也就两千行左右,但每天都要用上几次。写这种工具最大的收获不是工具本身,而是在反复推敲补码和溢出语义的过程中,把这些以前靠背的知识点真正变成了肌肉记忆——现在看到0xFFFFFFF0,脑子里会条件反射地跳出“这是 -16 的 32 位补码”,而不是先愣一下再去算。