news 2026/10/29 4:37:17

C#实现支持表达式解析的十六进制计算器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#实现支持表达式解析的十六进制计算器

打字机式敲完最后一行日志,我把手里那台开了 Windows 自带计算器的笔记本合上了。那天在核对一份设备通信日志,报文字段是一串03 1A FF E0,需要把后两字节当成无符号 16 位数拼出来,再和掩码做与运算。系统计算器的程序员模式其实能算,但每换一次位宽就得重新点一遍,算完一关窗口,刚才那串表达式就再也找不回来了。那天晚上我索性用 C# 写了个属于自己的十六进制计算器:能解析带0x前缀的表达式、能一次算完0xFF & (0x1F << 3)这种混合运算、能在 8/16/32/64 位视图之间一键切换。这篇就把整个设计与实现过程拆开讲,包括我踩过的几个坑。适合有一定 C# 基础、平时要和寄存器、报文、颜色值、内存地址打交道的读者,也适合想拿一个小项目练手表达式解析的人。

1. 从一次协议联调说起:为什么我会需要一个十六进制计算器

1.1 手算十六进制的四类高频崩溃场景

干这行久了会发现,十六进制运算出错的地方高度集中,翻来覆去就那么几类。第一类是报文字段拼接,高字节和低字节要合成一个 16 位值,0x1A和0xE0拼起来是0x1AE0而不是0x1AE0加错了位。第二类是寄存器位域操作,某个字段占 bit 4 到 bit 7,就得先构造掩码0x0F << 4,再做与和移位,中间任何一步搞错,读出来的配置值就是错的。第三类是内存地址偏移,基址0x1A00加上偏移0x2C,心算经常进位进错。第四类是颜色值拆解,0xFF3366CC拆成 A、R、G、B 四个分量,或者反过来拼装。

这四类场景的共同点是:参与运算的数都不大,但进制转换和位宽概念交织在一起,人脑在这种混合任务上极不可靠。我自己就有过把0x0F当成十进制 15 之外的某个值、结果把掩码写错导致一整批配置没生效的经历。从那以后我就认定,这类活儿必须交给工具,而且是一个理解十六进制语义的工具,不是那种只会做十进制加减法的工具。

提示:如果你也经常在日志里看到成串的十六进制字节,建议先把"字节序"和"位宽"两个概念固定下来再动手,工具只是加速,判断还得靠人。

1.2 系统计算器覆盖不到的三个缺口

系统自带的程序员模式确实是好东西,能切换 HEX/DEC/BIN,能做位运算。但我用下来的三个缺口始终绕不过去:

需求维度系统计算器我实际需要的能力
表达式留存算完即走,历史记录弱保留表达式原文,能回看、能修改后重算
位宽视图需要手动切换单选按钮一次运算同时看到 8/16/32/64 位四种呈现
有符号解释有,但和位宽绑定较紧同一个值在无符号与补码之间快速对照
脚本化/批量基本没有能把一组表达式丢进去批量出结果

最关键的是第一条。调协议的时候,同一个表达式我往往要改其中一两个常数反复试,系统计算器每改一次都要重新敲一遍全部内容,改到第五遍的时候人已经开始烦了。而"批量算一组"这个需求,在核对一张寄存器配置表时特别强烈——表里几十个字段的掩码都要算,一个个点太慢。

1.3 第一版的功能边界:先砍掉什么

动手前我先划了功能边界,这一步比写代码重要。小工具最容易死在"什么都想做"上,做着做着就变成了一个半成品 IDE。

功能优先级实现方式
四则运算 + 位运算P0自建表达式解析器
0x/0b/ 十进制混输P0词法分析阶段识别前缀
8/16/32/64 位十六进制视图P0掩码 + 补码渲染
有符号/无符号切换P1视图层开关,不影响内部值
表达式历史P1内存列表 + 可选落盘
超过 64 位的大数P1内部统一用 BigInteger
变量定义、宏、函数P2明确不做,避免复杂度失控

砍掉的东西里,最舍不得的是"变量定义",本来想支持mask = 0x0F << 4然后后续引用。后来想清楚了,我的使用场景是"算一个数",不是"写一段程序",变量定义带来的状态管理成本远大于收益。这个取舍在后面的实现里被反复验证是对的——没有状态,求值函数就是纯函数,测试起来非常省心。

2. 数值基座:C# 里到底该用哪种类型承载十六进制数

2.1 五种候选类型的实战对照

C# 里能装整数的类型不少,但真要承载"用户随便敲进来的十六进制串",能用的其实没几个。我列了张表逐个过了一遍:

类型位宽支持位运算越界行为是否适合本项目
int32是解析时抛OverflowException不够,0xFFFFFFFF直接炸
uint32是同上,只是上限高一点不够,64 位场景直接放弃
long64是同上勉强,但负数与无符号切换别扭
decimal128否溢出抛异常直接排除,不支持位运算
BigInteger任意是(现代 .NET 已补齐)不溢出选它

decimal是最先被排除的,它精度高但不支持&、|、<<这些运算符,用它做十六进制运算等于自断手脚。int和uint的问题在于位宽写死,用户输入一个 64 位地址就崩了。long看起来够用,但它在处理"同一个 64 位值,既要当无符号看又要当补码看"的时候,代码里到处是unchecked和强制转换,可读性很差。

2.2 为什么把 BigInteger 定为唯一内部表示

最终我把内部表示统一成BigInteger,所有运算都在这个类型上完成,只在显示阶段才考虑位宽。这个决定的理由有三条。

第一,符合人的思维顺序。人在纸上算0xFF & (0x1F << 3)的时候,不会先想"这是 8 位还是 32 位",而是先把表达式算出来,最后再看结果落在什么位宽里。如果内部用定宽类型,运算过程中就要不断处理溢出和符号扩展,等于把复杂度提前了。

第二,规避了新手最容易栽的那个异常。int.Parse("FFFFFFFF", NumberStyles.HexNumber)会抛OverflowException,因为0xFFFFFFFF超过了int.MaxValue。这个坑我自己踩过,当时百思不得其解,明明字符串是合法的十六进制。换成BigInteger.Parse就没这个问题,它有多少位就装多少位。

第三,大数场景白送。某些场景下会遇到 128 位甚至更长的标识符,用BigInteger天然支持,不用改一行代码。

代价当然也有:BigInteger是引用类型(结构体包装),每次运算都会分配,性能比原生类型差一到两个数量级。但这个计算器的输入是人工敲的,一次求值撑死几百次运算,性能完全不是瓶颈。我在实测中给一个 200 字符的长表达式计时,单次求值在微秒级,键盘输入根本感知不到。

2.3 内部值与显示视图的彻底分离

既然内部不定宽,那"位宽"这个概念就只存在于显示层。我把它抽象成一个渲染函数,输入是内部值和位宽,输出是定长的十六进制字符串:

public static string ToHexView(BigInteger value, int bits) { if (bits % 4 != 0 || bits <= 0) throw new ArgumentOutOfRangeException(nameof(bits)); // 构造该位宽的全 1 掩码,注意必须用 BigInteger.One 左移 BigInteger mask = (BigInteger.One << bits) - 1; // 按补码语义取低位;BigInteger 内部就是补码表示,& 之后自然得到正确结果 BigInteger low = value & mask; int digits = bits / 4; return low.ToString("X").PadLeft(digits, '0'); }

这段代码里有两个细节值得说。一是掩码的构造必须写BigInteger.One << bits,如果图省事写成(1 << bits) - 1,当bits等于 64 时1 << 64在int语义下会回绕成1(因为 C# 的移位量会按位宽取模),掩码直接错成 0,结果全是零。这个坑非常隐蔽,因为它在 32 位及以下都正常,只有到 64 位才暴露。

二是value & mask这一句已经完成了补码转换。BigInteger的位运算遵循二进制补码语义,-4 & 0xFFFFFFFF会得到4294967292,也就是0xFFFFFFFC。不需要自己写"如果是负数就加上 2 的 n 次方"这种逻辑,写了反而容易出错。

2.4 解析入口的三个脏活

BigInteger.Parse虽然好用,但它不认识0x前缀,也不认识分隔符。所以真正的解析入口得自己写一层:

public static bool TryParseNumber(string text, out BigInteger value, out string error) { value = BigInteger.Zero; error = null; if (string.IsNullOrWhiteSpace(text)) { error = "空的数字"; return false; } string s = text.Trim().Replace("_", ""); // 允许 0xFF_FF 这种分隔写法 try { if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { value = BigInteger.Parse(s.Substring(2), NumberStyles.HexNumber); return true; } if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) { string digits = s.Substring(2); BigInteger acc = BigInteger.Zero; foreach (char c in digits) { if (c != '0' && c != '1') { error = "二进制里出现了非 0/1 字符"; return false; } acc = (acc << 1) + (c - '0'); } value = acc; return true; } value = BigInteger.Parse(s, NumberStyles.Integer); return true; } catch (FormatException) { error = "数字格式不合法"; return false; } }

这里我踩过一个细节:BigInteger.Parse("-FF", NumberStyles.HexNumber)是能成功的,返回-255,因为NumberStyles.HexNumber包含了AllowLeadingSign。这意味着如果你不小心把负号和十六进制混在一起,它不会报错,而是悄悄给你一个负数。我在词法分析阶段把负号单独当成一元运算符处理,就是为了避开这个模糊地带——负数只能由-运算符产生,不能由数字字面量自带。

注意:不要依赖NumberStyles.HexNumber对负号的处理,不同版本运行时的行为细节可能有差异,把符号交给解析器统一管理更稳。

3. 表达式引擎:把0xFF & (0x1F << 3)变成结果的那套流程

3.1 分两步走:先切词,再递归下降

朴素的做法是对字符串做正则匹配然后从左往右算,但那样处理不了括号,也处理不了优先级。正规做法是分成词法分析和语法分析两步。

词法分析负责把连续的字符流切成有意义的记号(Token),比如把0xFF切成一个数字记号,把<<切成一个移位记号。语法分析负责按优先级把这些记号组织成一棵隐式的树。分成两步的好处是错误定位非常准——词法阶段发现非法字符,能直接报出"第 7 个字符无法识别";语法阶段发现括号不匹配,能报出"第 12 个位置缺少右括号"。如果混在一起写,报错信息通常只能是"表达式有误",用户看了等于没看。

3.2 运算符优先级表:必须按 C 的语义来

优先级这件事不能凭感觉定,否则用户按 C 语言的习惯写表达式就会得到意外结果。我严格照搬了 C 家族的优先级:

优先级(从高到低)运算符结合性说明
1~-(一元)右按位取反、取负
2*/%左乘除取模
3+-左加减
4<<>>左移位
5&左按位与
6^左按位异或
7|左按位或

最容易搞错的是移位的层级。很多人凭直觉觉得移位应该和乘除同级(毕竟左移一位等于乘二),但在 C 里移位低于加减高于与。这个差异在0xFF & 0x0F << 4这种表达式上会体现得非常明显:按 C 语义先算0x0F << 4得0xF0,再与0xFF得0xF0;如果移位和乘法同级,结果也一样;但如果有人把移位写得比加法还高,1 + 1 << 2就会从(1+1)<<2 = 8变成1 + (1<<2) = 5。我第一版就把移位放错了层,和系统计算器对答案时差了 8 倍,查了半天。

3.3 词法分析器:四十行把字符串拆成记号

先定义记号类型。用一个枚举加一个只读结构体就够了,不需要继承体系:

public enum TokenKind { Number, Plus, Minus, Star, Slash, Percent, Amp, Pipe, Caret, Tilde, Shl, Shr, LParen, RParen, End } public readonly struct Token { public readonly TokenKind Kind; public readonly BigInteger Value; public readonly int Position; public Token(TokenKind kind, BigInteger value, int position) { Kind = kind; Value = value; Position = position; } }

有了记号定义,切词逻辑就是一次线性扫描。注意这里的Position记的是记号在原串里的起始下标,专门用于报错:

public 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; } // 十六进制 if (c == '0' && i + 1 < src.Length && (src[i + 1] == 'x' || src[i + 1] == 'X')) { int start = i; i += 2; int begin = i; while (i < src.Length && (Uri.IsHexDigit(src[i]) || src[i] == '_')) i++; if (i == begin) throw new CalcException(start, "0x 后面缺少十六进制数字"); string digits = src.Substring(begin, i - begin).Replace("_", ""); tokens.Add(new Token(TokenKind.Number, BigInteger.Parse(digits, NumberStyles.HexNumber), start)); continue; } // 二进制 if (c == '0' && i + 1 < src.Length && (src[i + 1] == 'b' || src[i + 1] == 'B')) { int start = i; i += 2; int begin = i; while (i < src.Length && (src[i] == '0' || src[i] == '1' || src[i] == '_')) i++; if (i == begin) throw new CalcException(start, "0b 后面缺少二进制数字"); string digits = src.Substring(begin, i - begin).Replace("_", ""); BigInteger acc = BigInteger.Zero; foreach (char d in digits) acc = (acc << 1) + (d - '0'); tokens.Add(new Token(TokenKind.Number, acc, start)); continue; } // 十进制 if (char.IsDigit(c)) { int start = i; while (i < src.Length && (char.IsDigit(src[i]) || src[i] == '_')) i++; string digits = src.Substring(start, i - start).Replace("_", ""); tokens.Add(new Token(TokenKind.Number, BigInteger.Parse(digits), start)); continue; } switch (c) { case '+': tokens.Add(new Token(TokenKind.Plus, 0, i)); i++; continue; case '-': tokens.Add(new Token(TokenKind.Minus, 0, i)); i++; continue; case '*': tokens.Add(new Token(TokenKind.Star, 0, i)); i++; continue; case '/': tokens.Add(new Token(TokenKind.Slash, 0, i)); i++; continue; case '%': tokens.Add(new Token(TokenKind.Percent, 0, i)); i++; continue; case '&': tokens.Add(new Token(TokenKind.Amp, 0, i)); i++; continue; case '|': tokens.Add(new Token(TokenKind.Pipe, 0, i)); i++; continue; case '^': tokens.Add(new Token(TokenKind.Caret, 0, i)); i++; continue; case '~': tokens.Add(new Token(TokenKind.Tilde, 0, i)); i++; continue; case '(': tokens.Add(new Token(TokenKind.LParen, 0, i)); i++; continue; case ')': tokens.Add(new Token(TokenKind.RParen, 0, i)); i++; continue; case '<': if (i + 1 < src.Length && src[i + 1] == '<') { tokens.Add(new Token(TokenKind.Shl, 0, i)); i += 2; continue; } throw new CalcException(i, "单个 '<' 不是合法运算符,左移请写 <<"); case '>': if (i + 1 < src.Length && src[i + 1] == '>') { tokens.Add(new Token(TokenKind.Shr, 0, i)); i += 2; continue; } throw new CalcException(i, "单个 '>' 不是合法运算符,右移请写 >>"); default: throw new CalcException(i, $"无法识别的字符 '{c}'"); } } tokens.Add(new Token(TokenKind.End, 0, src.Length)); return tokens; }

这段代码里Uri.IsHexDigit是个很好用的小工具方法,比自己写(c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')干净得多,而且大小写都覆盖。另外注意我在每个分支末尾都用了continue而不是break,因为break会把控制权交给switch之后的代码,而continue直接回到while顶部开始下一轮,逻辑更干脆。

3.4 递归下降求值:每层函数只做一件事

语法分析我用递归下降,理由是优先级表改起来直观、报错位置精确、代码读起来像伪代码。每一层函数对应一个优先级,函数体只关心自己这一层的运算符,遇到更高优先级的调用下一层:

public sealed class Parser { private readonly List<Token> _tokens; private int _pos; public Parser(List<Token> tokens) { _tokens = tokens; _pos = 0; } private Token Current => _tokens[_pos]; private bool Match(TokenKind kind) { if (Current.Kind != kind) return false; _pos++; return true; } public BigInteger Evaluate() { BigInteger value = ParseBitOr(); if (Current.Kind != TokenKind.End) throw new CalcException(Current.Position, "表达式末尾有多余内容"); return value; } private BigInteger ParseBitOr() { BigInteger left = ParseBitXor(); while (Match(TokenKind.Pipe)) left |= ParseBitXor(); return left; } private BigInteger ParseBitXor() { BigInteger left = ParseBitAnd(); while (Match(TokenKind.Caret)) left ^= ParseBitAnd(); return left; } private BigInteger ParseBitAnd() { BigInteger left = ParseShift(); while (Match(TokenKind.Amp)) left &= ParseShift(); return left; } private BigInteger ParseShift() { BigInteger left = ParseAdditive(); while (true) { if (Current.Kind == TokenKind.Shl) { int pos = Current.Position; _pos++; left = ShiftLeft(left, ParseAdditive(), pos); } else if (Current.Kind == TokenKind.Shr) { int pos = Current.Position; _pos++; left = ShiftRight(left, ParseAdditive(), pos); } else return left; } } private BigInteger ParseAdditive() { BigInteger left = ParseMultiplicative(); while (true) { if (Match(TokenKind.Plus)) left += ParseMultiplicative(); else if (Match(TokenKind.Minus)) left -= ParseMultiplicative(); else return left; } } private BigInteger ParseMultiplicative() { BigInteger left = ParseUnary(); while (true) { if (Match(TokenKind.Star)) left *= ParseUnary(); else if (Match(TokenKind.Slash)) left = DivideSafely(left, ParseUnary(), Current.Position); else if (Match(TokenKind.Percent)) left = ModSafely(left, ParseUnary(), Current.Position); else return left; } } private BigInteger ParseUnary() { if (Match(TokenKind.Minus)) return -ParseUnary(); if (Match(TokenKind.Tilde)) return ~ParseUnary(); return ParsePrimary(); } private BigInteger ParsePrimary() { if (Match(TokenKind.LParen)) { BigInteger inner = ParseBitOr(); if (!Match(TokenKind.RParen)) throw new CalcException(Current.Position, "缺少右括号"); return inner; } if (Current.Kind == TokenKind.Number) { BigInteger v = Current.Value; _pos++; return v; } throw new CalcException(Current.Position, "这里应该是一个数字或左括号"); } }

这种写法的好处是"优先级即代码结构"。哪天想把移位提到加法之前,只需要把ParseShift挪个位置,不用碰优先级表。缺点是对左结合的一元运算符处理起来要小心,比如--5会被解析成-(-5),这个行为是对的,但如果用户输入~~0xF0,也要能正确得到0xF0。

3.5 错误提示带上位置,比什么都有用

我一开始的报错信息就是一句"表达式语法错误",自己调试的时候都嫌烦,更别说用的时候。后来改成统一抛一个带位置的异常:

public sealed class CalcException : Exception { public int Position { get; } public CalcException(int position, string message) : base(message) { Position = position; } }

界面层捕获这个异常后,把Position对应的字符高亮出来,再在状态栏显示消息。这个改动看起来微不足道,实际使用体验提升巨大——用户敲了一个长表达式,中间某个括号漏了,高亮直接指到出问题的位置,不用自己数括号。

4. 补码、溢出与负数显示:最容易翻车的三件事

4.1 负数在十六进制里为什么是一串 F

这是新手最常问的问题:我输入-4,为什么显示出来是FFFFFFFC?答案是补码。计算机用补码表示负数,-4等价于"取 4 的二进制,全部取反,再加一"。在 32 位下就是0xFFFFFFFC。同一个-4在不同位宽下的表示完全不同:

数值8 位视图16 位视图32 位视图64 位视图
-1FFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
-4FCFFFCFFFFFFFCFFFFFFFFFFFFFFFC
-25600(截断)FF00FFFFFF00FFFFFFFFFFFFFF00
255FF00FF000000FF00000000000000FF

注意-256在 8 位视图下变成了00,因为它超出了 8 位有符号数的范围,低位全被截掉了。这个行为本身是正确的(截断就是截断),但如果用户不理解,就会觉得"计算器算错了"。所以我在界面上加了一行小字提示:当前显示的是低 N 位,值可能被截断。

4.2 位宽截断的正确写法

上一章给的ToHexView已经处理了截断,但还有个容易忽略的地方:掩码必须用 BigInteger 构造。我在第 2.3 节提过一次,这里再强调一遍,因为它值得单独成段。1 << 64在 C# 里会变成1(int 的移位量取低 5 位),1L << 64会变成1(long 取低 6 位)。这是语言规范规定的行为,不是 bug,但对写工具的人来说是个天坑,因为它在小位宽上完全正常。

还有一点要注意:显示时最好区分"截断后的无符号值"和"截断后的有符号解释"。前者直接& mask就行,后者还要判断最高位是否为 1:

public static BigInteger ToSigned(BigInteger value, int bits) { BigInteger mask = (BigInteger.One << bits) - 1; BigInteger low = value & mask; BigInteger signBit = BigInteger.One << (bits - 1); // 最高位为 1 表示负数,减去 2 的 bits 次方得到补码真值 return (low & signBit) != 0 ? low - (BigInteger.One << bits) : low; }

这个函数在核对有符号传感器读数时特别有用。曾经有一份温度数据,原始字节是0xF0,按无符号看是 240,按 8 位补码看是 -16,实际就是 -16 摄氏度。如果没有这个切换,很容易把负温度当成高温。

4.3 除法与取模的符号陷阱

C# 的整数除法采用"向零取整",和数学上的"向下取整"不是一回事。这个差异在负数上会暴露得很明显:

表达式C# 结果数学向下取整结果差异说明
7 / 233一致
-7 / 2-3-4向零 vs 向下
7 % 211一致
-7 % 2-11余数符号跟随被除数

如果你是从别的语言(比如某些脚本语言)转过来的,很可能会在这里翻车。我在求值器里没有去改这个行为,而是保持和 C# 一致,因为用户多半也是写 C# 或 C 的,保持语义一致比"更符合数学直觉"更重要。但我加了一条规则:除数为零时抛异常,不做静默处理。

private BigInteger DivideSafely(BigInteger left, BigInteger right, int pos) { if (right.IsZero) throw new CalcException(pos, "除数不能为零"); return left / right; } private BigInteger ModSafely(BigInteger left, BigInteger right, int pos) { if (right.IsZero) throw new CalcException(pos, "取模运算的除数不能为零"); return left % right; }

静默返回 0 是最糟糕的选择,用户拿着一个 0 去继续算,最后得到一堆莫名其妙的结果,排查成本极高。宁可当场炸掉。

4.4 移位:原生类型会偷偷取模移位量

这是我在对比测试时发现的一个诡异现象:同一个表达式1 << 32,用int算得到 1,用BigInteger算得到 4294967296。原因是 C# 规范规定,移位运算的移位量会按左操作数的位宽取模——int取低 5 位,所以 32 变成 0,1 << 0等于 1。

BigInteger没有位宽概念,所以移位量是多少就移多少位,结果是正确的。但这个不一致提醒我,在求值器里要主动检查移位量,避免用户写出1 << 100000这种把内存吃爆的表达式:

private static readonly int MaxShift = 4096; private BigInteger ShiftLeft(BigInteger value, BigInteger amount, int pos) { if (amount.Sign < 0) throw new CalcException(pos, "移位次数不能为负数"); if (amount > MaxShift) throw new CalcException(pos, $"移位次数超过上限 {MaxShift}"); return value << (int)amount; } private BigInteger ShiftRight(BigInteger value, BigInteger amount, int pos) { if (amount.Sign < 0) throw new CalcException(pos, "移位次数不能为负数"); if (amount > MaxShift) throw new CalcException(pos, $"移位次数超过上限 {MaxShift}"); return value >> (int)amount; }

BigInteger的>>对负数是算术右移,会保留符号位,也就是-8 >> 1 == -4。这和大多数语言的语义一致,符合预期。如果你需要逻辑右移(高位补零),得自己先转成无符号视图再移:

// 逻辑右移:先把值当成指定位宽的无符号数,再右移 public static BigInteger LogicalShiftRight(BigInteger value, int bits, BigInteger amount) { BigInteger mask = (BigInteger.One << bits) - 1; return (value & mask) >> (int)amount; }

这个区别在处理 CRC 校验、哈希算法的时候至关重要,算术右移会把高位全填成 1,结果彻底错掉。我在界面上加了一个"逻辑右移"的开关,默认关闭,需要的时候手动打开。

5. 三种壳子的取舍:控制台、WinForms 与 WPF 分别适合谁

5.1 控制台版:验证引擎的最短路径

引擎写完后,我第一件事是包了个控制台读循环,二十行代码:

static void Main() { Console.WriteLine("输入表达式,空行退出。"); while (true) { Console.Write("> "); string line = Console.ReadLine(); if (string.IsNullOrWhiteSpace(line)) break; try { var tokens = Tokenize(line); var parser = new Parser(tokens); BigInteger result = parser.Evaluate(); Console.WriteLine($" HEX32: {ToHexView(result, 32)}"); Console.WriteLine($" DEC : {result}"); Console.WriteLine($" BIN : {ToBinaryView(result, 32)}"); } catch (CalcException ex) { Console.WriteLine($" 错误(位置 {ex.Position}):{ex.Message}"); } } }

控制台版的价值不在于"能用",而在于调试反馈最快。改一行引擎代码,dotnet run一秒出结果,比在图形界面里点按钮快得多。我整个引擎的开发和调试都是在这个壳里完成的,图形界面是最后两天才加上去的。这个顺序建议所有人都照着走——先把核心逻辑在一个能快速迭代的环境里打磨透,再做界面。反过来的话,你会发现每改一次算法都要启动一次窗口程序,效率低得让人想放弃。

5.2 WinForms 版:半小时出可用界面

桌面小工具,WinForms 是最省事的。不需要学 XAML,拖控件、绑事件,半小时能出一个能用的东西。我用TableLayoutPanel生成按钮网格:

private readonly string[] KeyLabels = { "A", "B", "C", "D", "E", "F", "7", "8", "9", "&", "|", "~", "4", "5", "6", "<<", ">>", "^", "1", "2", "3", "+", "-", "%", "0", "0x", "(", ")", "*", "/", "CE", "DEL", "=", "" }; private void BuildKeypad() { var grid = new TableLayoutPanel { ColumnCount = 6, Dock = DockStyle.Fill }; for (int c = 0; c < 6; c++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / 6)); int rows = (KeyLabels.Length + 5) / 6; for (int r = 0; r < rows; r++) grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / rows)); for (int i = 0; i < KeyLabels.Length; i++) { if (string.IsNullOrEmpty(KeyLabels[i])) continue; var btn = new Button { Text = KeyLabels[i], Dock = DockStyle.Fill, Tag = KeyLabels[i] }; btn.Click += KeyButton_Click; grid.Controls.Add(btn, i % 6, i / 6); } Controls.Add(grid); }

按钮点击统一走一个入口,根据Tag决定是插入字符还是执行命令。这种"数据驱动"的写法比一个个button1_Click好维护得多,以后加按键只需要改数组。

private void KeyButton_Click(object sender, EventArgs e) { string key = (string)((Button)sender).Tag; switch (key) { case "=": EvaluateAndShow(); break; case "CE": _expression = ""; RefreshDisplay(); break; case "DEL": if (_expression.Length > 0) _expression = _expression.Substring(0, _expression.Length - 1); RefreshDisplay(); break; default: _expression += key; RefreshDisplay(); break; } }

5.3 WPF 版:实时求值体验最好

WPF 的优势在于数据绑定。把表达式绑到TextBox,结果绑到几个只读属性,每次输入变化自动重算,不需要手动刷新。ViewModel 大概长这样:

public class CalcViewModel : INotifyPropertyChanged { private string _expression = ""; public string Expression { get => _expression; set { _expression = value; OnPropertyChanged(); Recalculate(); } } public string Hex8 { get; private set; } = ""; public string Hex16 { get; private set; } = ""; public string Hex32 { get; private set; } = ""; public string Dec { get; private set; } = ""; public string Error { get; private set; } = ""; private void Recalculate() { try { var tokens = Tokenize(_expression); BigInteger value = new Parser(tokens).Evaluate(); Hex8 = ToHexView(value, 8); Hex16 = ToHexView(value, 16); Hex32 = ToHexView(value, 32);
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 4:36:21

指令微调全解析:从NLP四范式到LoRA高效微调实践

简介&#xff1a;面向自然语言处理与大模型方向的学习者&#xff0c;这份PPT系统梳理了NLP四范式演进与指令微调的核心脉络&#xff0c;适合希望快速建立大模型微调整体认知的初学者与进阶开发者。内容从传统机器学习、深度学习、预训练加微调一路讲到预训练加Prompt的第四范式…

作者头像 李华
网站建设 2026/10/29 4:35:04

电容式传感器原理与实战:从物理变量到工业级液位监测

1. 电容式传感器&#xff1a;不是“测电量”的工具&#xff0c;而是把“距离、压力、湿度、液位”全变成数字信号的隐形翻译官很多人第一次听到“电容式传感器”&#xff0c;脑子里立刻蹦出高中物理课上那个平行板电容器公式&#xff1a;C ε₀εᵣA/d。然后下意识觉得——这玩…

作者头像 李华
网站建设 2026/10/29 4:34:56

Logisim 16位自动运算器设计:数据通路与半周期时钟避坑指南

简介&#xff1a;这份资源是面向数字逻辑与计算机组成原理学习者的Logisim实验资料&#xff0c;围绕16位自动运算器的设计与实现展开&#xff0c;适合正在做课程实验、需要理解ALU与数据通路的学生参考。压缩包内共1个doc文件&#xff0c;约341KB&#xff0c;为华中科技大学课程…

作者头像 李华
网站建设 2026/10/29 4:33:30

从感知机到DeepSeek:AI大模型演进与实战解析

1. 从感知机到DeepSeek&#xff1a;AI大模型的演进图谱1957年Frank Rosenblatt提出的感知机&#xff08;Perceptron&#xff09;模型&#xff0c;用今天的眼光看简直简单得可爱——单个神经元结构&#xff0c;仅能处理线性可分问题。但正是这个"玩具"般的模型&#x…

作者头像 李华
网站建设 2026/10/29 4:33:30

Hyperframes风格制作指南:帧感编排与高密度电子音乐混音

这两年电子音乐圈子里冒出来的新词不少&#xff0c;"hyperframes"算是其中比较有辨识度的一个。我第一次听到这个词&#xff0c;是在一个制作人群里讨论新锐曲目的时候&#xff0c;有人用"hyperframes感"来形容某首歌的编排——意思是整首歌像一段被高帧率…

作者头像 李华
网站建设 2026/10/29 4:33:30

Enpass全平台密码同步配置指南:Windows到Android实战

密码管理这件事&#xff0c;我踩过的坑比大多数人吃过的盐还多。早些年用记事本存密码&#xff0c;后来换浏览器自带的管理工具&#xff0c;再后来发现跨设备同步简直是灾难——Windows上存的密码&#xff0c;到了Android手机上要么找不到&#xff0c;要么同步延迟半天。直到我…

作者头像 李华