
C#基础训练营第二课聊的是运算器。我第一次带学生到这课的时候总有人以为就是背一张运算符表把加减乘除、大于小于记完就算过关。但真正上手写代码就会发现运算器这套东西几乎贯穿了后面所有的项目判断条件要比较循环变量要自增状态位要按位操作界面计算要处理类型转换。可以说变量和类型解决的是“数据怎么存”运算器解决的就是“数据怎么用”。这堂课适合刚学完C#基础语法、准备开始写第一个小工具的人也适合那些已经能写出简单程序、但经常在运算结果上翻车的自学者。我会把这堂课拆成几个核心能力来讲而不是单纯按运算符分类过一遍。每一块都会带实际代码、翻车案例和设计思路希望能让你学完之后遇到表达式不是靠猜而是能自己推理出结果。1. 为什么训练营第二课就安排运算器先搞懂“表达式求值”这个底层问题很多初学者会把“运算器”理解成“计算器”觉得无非就是两个数加一加。但C#里的运算器实际上是一个完整的表达式求值系统。你在代码里写的a b * c编译器和运行时到底按什么规则算出结果这一套机制才是本课真正要训练的东西。1.1 运算的本质是“把多个值变成一个新值”程序里到处都是运算。你写一个判断用户是否成年背后是age 18你统计购物车总价背后是price * count你做一个游戏角色移动背后是position speed * deltaTime。这些表达式的共同点是输入的是一批值输出的是一个新值。理解这一点你就知道为什么C#要把运算符设计得这么多。它不是故意为难人而是每一种运算符都对应一类常见的计算场景。算术运算符处理数值计算比较运算符处理大小关系逻辑运算符处理真伪判断位运算符处理二进制层面的控制。学会运算器本质上是学会“用表达式描述你脑子里的规则”。1.2 表达式求值遵循一套固定的“游戏规则”表达式求值有几个绕不开的概念优先级、结合性、操作数类型。这三者共同决定了2 3 * 4算出来是20还是14。优先级*和/高于和-所以先算乘除。结合性加减法是左结合a - b - c等价于(a - b) - c而不是a - (b - c)。操作数类型int除以int得到intdouble除以int得到double类型决定了运算结果的精度。这些规则不是C#独有的Java、C、Python都有类似机制。把这一套吃透以后学任何编程语言都会快很多。这也是训练营把这课放在第二课的原因——它建立的是通用计算思维。1.3 这堂课的实际产出一个能用的运算器程序为了不让练习停留在“看公式”的层面我习惯让学员从第一个小节就明确最终目标写一个控制台程序用户输入一个算术表达式比如3 5 * 2程序能正确输出13并且能处理括号、小数、负数。这个目标听起来有点远但等到第6节的时候你会发现其实每一个运算符的学习都是在为这个项目添砖加瓦。我经常跟学员说不要问“这个运算符有什么用”要问“我的表达式求值器里还缺哪个运算符”。带着这个目标去学每个运算符都不会白记。2. 算术运算符的四个高频翻车点整数除法、取余、溢出和自增自减算术运算符是所有语言里最“看着眼熟”的部分小学就学过加减乘除。但也正因为太熟反而最容易掉坑。C#里的算术跟数学里的算术并不完全一样尤其是整数和浮点的边界规则。2.1 整数除法为什么会丢精度看这段代码int a 10; int b 3; double result a / b; Console.WriteLine(result); // 输出 3而不是 3.333...很多第一次写C#的人会愣住我已经把结果赋值给double了为什么还是输出3原因在于a / b这一步的两个操作数都是intC#会先执行整数除法得到整数3然后再把这个整数隐式转换成double赋值给result。精度在除法那一刻就已经丢了后面接什么都救不回来。解决办法有两种。第一种是把其中一个操作数转成浮点类型double result (double)a / b;第二种是直接用浮点字面量参与运算double result a / 3.0;我一直建议团队里的初级开发养成一个习惯只要除法运算里可能出现小数就显式标注浮点类型不要依赖“我记得好像会转”这种模糊判断。2.2 取余运算的正负号规则取余运算符%在C#里的结果符号和被除数保持一致。也就是说-7 % 3的结果是-17 % -3的结果是1。这个规则跟数学里的“模运算”不完全一样很多从Python转过来的人会被坑。实际开发里取余最常见的用途是判断奇偶、循环分页、时间换算。比如判断一个数是不是偶数if (number % 2 0) { Console.WriteLine(偶数); }但是如果number可能是负数这个判断依然成立-4 % 2 0没问题。可如果你用取余做“环绕索引”比如数组下标越界时回到开头那就要额外处理负数偏移的情况int index (current offset) % array.Length; if (index 0) { index array.Length; }这不是运算符本身错了而是你要清楚它的边界规则。2.3 checked与unchecked溢出不是“报错”而是“绕回”C#的整数运算默认情况下不会因为溢出抛异常而是会悄悄地把二进制位截断导致结果变成一个看起来完全不相干的数。比如int max int.MaxValue; // 2147483647 int result max 1; Console.WriteLine(result); // 输出 -2147483648这就是所谓的“溢出绕回”。在默认的unchecked上下文中程序不认为这是错误它会按照补码规则给你一个错误的结果。如果你希望溢出时立刻被发现可以把运算包在checked块里try { int result checked(max 1); } catch (OverflowException ex) { Console.WriteLine(溢出啦结果不可靠); }我不建议所有代码都套checked因为会有性能开销。但涉及金额、计数、序列号这类“绝对不能算错”的场景一定要考虑加上溢出检查或者改用long来拓宽中间值的范围。2.4 i和i先算还是先用自增和自减看起来是语法糖但背后的求值时机经常让初学者翻车。int i 5; int a i; // a 5i 变 6 int b i; // i 先变 7b 7i是先取当前值作为表达式结果再把i加1i是先把i加1再取新值作为表达式结果。这个差异在单独一行语句里没有区别一旦混进复杂表达式或者方法参数里就非常容易出问题。我的建议是不要让自增自减出现在复杂表达式中间。要么单独一行i; int a i;要么用i 1这种更明确的写法。代码是写给人读的尽量减少“聪明但难懂”的写法。3. 赋值运算符和比较运算符最容易看花眼的一对如果说算术运算符是“算数”赋值和比较就是“做判断”。程序里的绝大多数逻辑分支都要靠比较运算符给出真或假而赋值运算符负责把新值写入变量。两者经常在if语句里出现也就经常有人把写成。3.1 是动作 是判断这个错误简直是新手村门口的经典怪if (x 10) { // 编译错误C# 不允许在 if 条件里直接赋值 }好消息是C#强制要求条件表达式必须是bool类型所以x 10这种赋值语句不能直接当作判断条件编译器会直接报错不像C/C那样会“能跑但行为诡异”。但这不代表你不会踩到近亲坑bool isReady true; if (isReady false) { Console.WriteLine(不会执行); }这段代码编译能过因为赋值表达式isReady false本身的值是false类型是bool所以能放在if里。问题是它同时把isReady改成了false而且分支不执行。如果你本来想写isReady false这就算是一个静默逻辑错误程序不报错但行为完全不对。我见过不少初级开发在这里排查半天。我的建议是如果你要判断一个bool变量是否为真直接写变量本身不要和 true比较如果要判断是否为假写!isReady。这样从写法上就天然避免了赋值和比较混淆的可能。3.2 复合赋值运算符的方便与陷阱、-、*、/这类复合赋值运算符是简洁的好帮手。它们看起来等价于x x 1但有一个细微差别复合赋值只对变量求值一次。考虑这种情况int[] arr new int[3]; int index 0; arr[index] 10;这行代码等价于先执行arr[0] 10然后才自增index。如果把复合赋值展开成普通写法你可能会写arr[index] arr[index] 10;这样index会被自增两次逻辑就错了。所以在“下标本身也在变化”的场景里复合赋值更安全。这个小知识点面试里也常被拿来挖坑值得记住。3.3 比较运算符的结果不只是bool比较运算符、、、、、!的结果是bool这个大家都知道。不过C#里比较运算还可以用来做“链式比较”吗答案是不支持。你写a b cC#会先算a b得到一个bool然后拿这个bool跟c比较这会直接编译报错。Python里能这么写C#不行。遇到需要判断a是否在区间内要写成if (a 10 a 20) { // do something }另外一个容易忽略的点是浮点比较。0.1 0.2 0.3在C#里通常是false因为二进制浮点数无法精确表示所有十进制小数。实际项目里比较浮点结果应该用误差范围double delta Math.Abs(0.1 0.2 - 0.3); if (delta 1e-10) { Console.WriteLine(相等); }这不是C#特有的问题是所有主流编程语言都存在的浮点精度问题。理解这一点你才不会在计算金额的时候踩坑。4. 逻辑运算符与位运算运算器里藏着另一层世界逻辑运算符处理的是bool值的组合位运算处理的是整数二进制位的组合。这两类运算在基础阶段容易被忽略但实际写业务代码时候非常有用尤其是权限系统、状态标记、图形图像处理这些场景。4.1 短路求值带来的性能与副作用差异C#里的和||都是短路运算符。左边为false时右边不会执行||左边为true时右边不会执行。这个特性既带来性能优化也会带来逻辑副作用。举个例子if (list ! null list.Count 0) { // 安全list 为 null 时不会访问 Count }这种写法就是利用了短路特性避免空引用异常。但如果右边是一个有副作用的方法调用比如if (isSuccess SaveToDatabase()) { // 当 isSuccess 为 false 时SaveToDatabase 根本不会执行 }你可能以为SaveToDatabase()总会执行但它实际没有。这种问题非常隐蔽尤其是代码后来被人改过条件顺序时更容易出现。我的建议是不要让带有副作用的方法调用出现在或||的右侧除非你有意利用短路特性做惰性执行。4.2 位运算在标志位组合中的实战位运算包括、|、^、~、、。其中最有实战价值的场景是“枚举标志位组合”。C#枚举可以用[Flags]特性标识然后通过按位或运算组合多个选项[Flags] public enum Permission { None 0, Read 1, Write 2, Delete 4 } Permission userPermission Permission.Read | Permission.Write; if ((userPermission Permission.Write) ! 0) { Console.WriteLine(可以写入); }这里的userPermission Permission.Write用来判断某个位是否被设置结果不为0就说明包含该权限。为什么用1、2、4这种取值因为它们在二进制里分别对应0001、0010、0100每个权限占一个独立的位互不干扰组合出来的值可以同时表示多个状态。这种做法的好处是一个整数就能存储多个开关状态数据库里存一个字段就行查询效率高也容易扩展。位运算看起来吓人实际掌握“按位与用来判断按位或用来组合异或用来切换”这几条基本就够用了。4.3 逻辑运算符的优先级坑逻辑运算符的优先级比比较运算符低。这意味着if (a b c d)会被解析成(a b) (c d)这符合大多数人的直觉。但一旦混入||和if (a b || c d e f)C#会按照优先于||的规则解析等价于a b || (c d e f)。如果你本意是(a b || c d) e f就必须加括号。这种问题跟数学里的乘除优先于加减是一个道理但人的直觉在长表达式里很容易失灵。我一直要求团队成员包含混合逻辑运算符的if条件必须使用括号显式声明优先级哪怕括号在语法上是多余的。因为代码不仅是写给编译器看的更是写给三个月后的自己看的。5. 类型转换在运算器中的位置没有转换很多运算根本没法做运算器不可能只处理同一种类型。整数加法、浮点乘法、字符串拼接这些操作都涉及类型之间的转换。C#的类型转换分为隐式转换、显式转换和辅助方法转换三种每一种都有明确的使用边界。5.1 隐式转换的安全边界隐式转换是指不需要写任何额外代码编译器自动完成的转换。C#的隐式转换规则以“不丢失信息”为前提。比如int i 100; long l i; // int - long可以 double d i; // int - double可以反过来就不行long l 100L; int i l; // 编译错误long 不能隐式转换为 int原因很简单long的取值范围内含有int无法表示的数编译器不帮你做可能丢数据的事。这个设计非常合理它把“可能出错”的代码挡在编译期。常见的隐式转换方向是int - long - float - double以及int - decimal。这里有个容易误解的点float和double虽然是浮点数精度有限但它们能表示的数值范围比int大得多所以int转float是允许的。可如果你把一个很大的整数转成float数值位数可能会丢。比如int big 16777217; float f big; Console.WriteLine(f); // 输出 1.677722E07实际是 16777216精度丢了这就是“范围够大但精度不够”的经典案例。所以隐式转换不代表绝对安全只代表“大概率不丢数量级”。5.2 显式转换的精度损失与四舍五入规则显式转换也叫强制转换写法是(目标类型)值double pi 3.99; int rounded (int)pi; Console.WriteLine(rounded); // 输出 3而不是 4这里要注意C#的强制类型转换是直接截断小数部分不是四舍五入。3.99转成int得到3。如果你想四舍五入要用Math.Round、Math.Ceiling或Math.Floor。另一个长期困扰初学者的问题是Convert类和强制转换有什么区别简单说(int)是做“底层数值重新解释”Convert.ToInt32会执行更大范围的转换逻辑还包含舍入操作。比如double pi 3.99; int a (int)pi; // 3 int b Convert.ToInt32(pi); // 4Convert 会四舍五入到偶数Convert.ToInt32默认使用“银行家舍入”也就是四舍五入时倾向于取偶数。3.5会变成42.5会变成2。这个细节在实际业务里经常会引发“为什么结果不是我预期”的疑问尤其是财务数据场景。5.3 Convert和Parse面向现实输入的转换控制台程序、配置文件、网络传输里拿到的数据大多数都是字符串。把字符串转成数值类型最常用的是int.Parse、double.Parse和Convert.ToInt32。string input 123; int number int.Parse(input);但如果输入不是合法数字Parse会抛异常。更稳健的方式是用TryParsestring input abc; if (int.TryParse(input, out int number)) { Console.WriteLine($解析成功{number}); } else { Console.WriteLine(输入不是合法整数); }TryParse不会抛异常解析失败时返回false同时将out参数设为0。这是我在项目里要求必须使用的模式。只要数据来自用户、文件或网络就默认它不可信一律用TryParse做防御式解析。Convert.ToInt32和int.Parse还有一个细微差别Convert.ToInt32(null)返回0而int.Parse(null)会抛ArgumentNullException。如果你明确知道“空值应该当成0”用Convert也行但我会觉得TryParse的意图更清晰。6. 实战用控制台写一个支持优先级的四则运算器说了这么多理论终于到动手环节。前面规划的最终目标是写一个控制台程序输入3 5 * 2输出13输入(2 3) * 4输出20。6.1 实现思路不靠“从前往后算”要靠语法分析很多初学者的第一反应是把字符一个个取出来遇到符号就按前一个数和后一个数运算。这个思路在处理2 3时没问题但遇到2 3 * 4就不知道怎么让乘法先算了。真正可扩展的方案是把输入当作一种“语言”用递归下降的方式做语法分析。我们定义两种“式”表达式Expression由若干项通过或-连接。项Term由若干因子通过*或/连接。因子Factor要么是数字要么是括号里的表达式。这个层级关系天然实现了优先级Term在Expression下层所以乘法先算。6.2 核心代码结构下面是一段精简但完整的实现可以在 .NET 6 及以上环境直接跑using System; using System.Collections.Generic; public class Calculator { private string _input; private int _pos; public double Evaluate(string expression) { _input expression.Replace( , ); _pos 0; double result ParseExpression(); if (_pos ! _input.Length) { throw new FormatException(表达式包含无法解析的字符); } return result; } private double ParseExpression() { double value ParseTerm(); while (_pos _input.Length (_input[_pos] || _input[_pos] -)) { char op _input[_pos]; _pos; double right ParseTerm(); if (op ) value right; else value - right; } return value; } private double ParseTerm() { double value ParseFactor(); while (_pos _input.Length (_input[_pos] * || _input[_pos] /)) { char op _input[_pos]; _pos; double right ParseFactor(); if (op *) value * right; else value / right; } return value; } private double ParseFactor() { if (_pos _input.Length) throw new FormatException(表达式意外结束); if (_input[_pos] () { _pos; double value ParseExpression(); if (_pos _input.Length || _input[_pos] ! )) throw new FormatException(缺少右括号); _pos; return value; } if (_input[_pos] -) { _pos; return -ParseFactor(); } string numberStr ; while (_pos _input.Length (char.IsDigit(_input[_pos]) || _input[_pos] .)) { numberStr _input[_pos]; _pos; } if (numberStr.Length 0) throw new FormatException(无法识别的字符); return double.Parse(numberStr); } } public class Program { public static void Main() { Calculator calc new Calculator(); while (true) { Console.Write(请输入表达式输入exit退出); string input Console.ReadLine(); if (input exit) break; try { double result calc.Evaluate(input); Console.WriteLine($结果{result}); } catch (Exception ex) { Console.WriteLine($计算出错{ex.Message}); } } } }这段代码只有不到80行但已经具备了以下能力支持 - * /支持括号改变优先级支持负数比如-3 5支持小数比如3.5 * 2能识别缺少右括号、非法字符等错误6.3 验证和调试过程我实际跑过几组测试用例结果如下输入输出说明23*414乘法先算(23)*420括号提升优先级10/42.5double参与结果是小数-352支持负号((23))*210连续括号也可以处理这里有一个实现细节值得一提ParseExpression和ParseTerm里的while循环设计成可以连续处理多个运算符所以123和10*2/5都能正确解析。ParseFactor里对-的处理放在括号判断之后这意味着-(23)也会被正确解析成-5。为什么不直接用DataTable.Compute或者调用内置编译器因为那相当于考试直接抄答案学不到递归下降背后的“优先级由语法层级决定”这个核心思想。训练营的目的不是让你最快写出一个计算器而是让你理解求值过程本身。7. 我在教运算器这一课时学生最容易踩的5个坑最后这部分是纯粹的经验总结。每次带训练营我都会把学员的报错和翻车现场记录下来。有些坑几乎每周都有人踩放在这里给大家提个醒。7.1 把 写成 程序不报错但逻辑全错前面提到过bool变量的赋值可以放在if条件里。比如bool isAdmin false; if (isAdmin true) { Console.WriteLine(你是管理员); }这段代码会输出“你是管理员”但原因不是isAdmin原本为真而是赋值表达式把isAdmin改成了true。排查的时候如果只看if分支很难发现罪魁祸首。我的习惯是条件表达式里永远只写比较运算或逻辑运算看到if (x 这种形式直接报错。7.2 整数除法得到0怎么排查“为什么5 / 2等于2我想要2.5啊。”这是出现频率最高的问题之一。如果你遇到这种情况先看两个操作数的类型只要都是整数结果必然是整数。把其中一个操作数改成浮点类型问题就解决了。不要只看“结果变量是什么类型”运算结果在运算发生时就已定型。7.3 忘记类型转换导致编译错误比如把double赋值给int变量编译器直接报错。有些初学者觉得“都是数字为什么不能转”其实C#正是在用编译错误保护你。遇到这种报错先想清楚你确定小数部分可以丢吗如果可以使用(int)如果需要四舍五入使用Convert.ToInt32如果输入是字符串先TryParse。7.4 短路逻辑导致的“右侧没执行”我见过最隐蔽的bug是if (isFirst || count 0) { // 期望 count 至少被加一次 }当isFirst为真时||右侧不执行count不会自增。如果后面对count的使用依赖这个自增就会出现“有时正常有时不正常”的诡异现象。排查这类问题要记住和||右侧的代码不是必然会执行。不要把关键逻辑只写在短路运算符右侧。7.5 优先级记错导致业务结果不符合预期比如int a 1; int b 2; bool result a b 2 a b;这个表达式里算术运算符优先级高于比较运算符比较运算符高于逻辑运算符所以先算a b再算3 2和a b最后做并集运算。如果你记不住优先级顺序最稳妥的做法是用括号把每一步写清楚bool result ((a b) 2) (a b);括号不会降低性能但能大幅降低读代码的人犯错的概率。运算器这一课本质上就是训练你准确描述计算规则的能力。我自己这么多年写C#面对再复杂的表达式第一反应永远是“先确认类型再确认优先级最后确认边界条件”。你可以把这个流程当作一套心法任何运算器相关的bug按这个顺序排查大部分都能在五分钟内定位。学完这课你可以试着把上面那个递归下降计算器加上取余%和幂运算^的支持那才算是真正把运算器吃透了。