一、从灵感到落地:为什么偏偏做一个表达式计算器
说实话,身边没见过几个程序员真的拿 DeepSeek 去写完整项目。多数人拿 AI 写的是脚本片段、正则表达式、SQL,或者让它解释报错,很少有人真敢把一个有一定算法含量的模块丢给大模型去生成。这次我决定试一把,直接让 DeepSeek 写一个表达式计算器,核心算法用调度场算法,目标是在一分钟左右搞定整个核心代码。
选择“表达式计算器”这个题目不是拍脑袋。它看起来小,但实际覆盖了整条编程基本功链条:字符串解析、中缀转后缀、栈的运用、运算符优先级处理、浮点数精度、异常输入怎么办。数据结构课上它通常是期末考试的重点,日常生活中它是每个计算器应用的内核。你直接在 Python 里输入2 + 3 * 4,eval()一行就能算出来,但eval()背后藏着一个巨大的安全黑洞,尤其是当输入来自用户时,等于把你的程序执行权交给了对方。自己写一个解析器,既能在功能上等价替代eval(),又能彻底掐掉命令注入的隐患,还能顺便把经典的调度场算法玩明白。
这次的实操环境是我常用的一台 Windows 笔记本,Python 3.11,无任何额外依赖,全程通过 DeepSeek 网页版对话生成代码。我评估自己的造型水平属于“勉强能撸简单脚本”的层次,如果你和我水平差不多,这篇文章应该非常适合你。
整个项目从提问到拿到可运行的代码,大约用了 69 秒。这个数字不是噱头,是真实计时。当然,后台模型推理是联网的,我的网络也在正常波动,但重点不是精准计算时间,而是想说明一件事:AI 做这类需要“算法建模 + 边界场景覆盖”的脏活累活,已经靠谱到可以放进日常开发工作流了。
二、调度场算法为什么是“标准答案”
想弄明白这个项目为什么能这么快完成,得先搞清楚调度场算法在设计上有多优雅。它由 Edsger Dijkstra 提出,核心思路是“用两个栈处理中缀表达式,将其转换为后缀表达式”,后缀表达式再交给另一个栈完成求值。整个流程不难,但每一步都像乐高积木一样严丝合缝。
2.1 中缀转后缀:人类友好,机器不友好
我们平时写3 + 4 * 2,是人眼友好的中缀写法。人和人交流没问题,直接丢给计算机计算却很麻烦,因为程序员必须提前处理优先级,否则你会先算 3 + 4 再乘以 2,得出完全错误的 14,而真实结果是 11。
调度场算法通过一个运算符栈巧妙地解了这个问题。它从左到右扫描输入:遇数字直接输出,遇左括号入栈,遇右括号把栈里运算符弹到左括号为止,遇运算符则先弹出栈顶所有优先级更高或相等的运算符,再把自己压栈。当表达式读完,把栈里剩余运算符全部弹出,得到的就是后缀表达式,也叫逆波兰式。3 + 4 * 2的转换结果是3 4 2 * +;(1 + 2) * 3转换结果是1 2 + 3 *。
为什么后缀表达式更好算?因为它的计算过程完全去掉了括号和优先级判断,只需要一个数字栈,从左到右扫一遍:碰到数字就压栈,碰到运算符就弹出两个数字计算结果再压栈,最后栈里只剩一个值。整条链路上没有递归、没有语法树、没有复杂的回溯,全靠栈的经典特性,时间复杂度 O(n),空间复杂度 O(n),在工程上够干净也够快。
2.2 两个栈还是三个栈?调度场算法分几步
我之所以强调这个算法适合让 AI 来写,是因为它天然具备“规则清晰、异常场景多”的特征。你给大模型描述清楚规则,它很容易生成一个轮廓正确的实现;但如果边界场景没覆盖全,比如连续运算符、浮点数字符串、负号、除数为零、右括号先出现、空表达式,模型生成的代码多半会翻车。这恰恰就是人的价值所在:让 AI 写大框架,人来补边界细节。
在上手之前,我自己先在草稿纸上梳理了一遍完整环节。整体分两个阶段:
- 阶段1:中缀转后缀。需要一个运算符栈,一个存放结果的输出列表。
- 阶段2:后缀求值。需要一个数字栈,按序处理。
特别注意负数怎么处理。-3 + 2里的减号到底是一元负号还是二元减号?调度场的经典做法是给一元负号一个单独的优先级和标记,或者扫描时把负数解析成一个特殊的数字值。多数初版实现根本没考虑这个,导致-3 + 2直接解析失败。我把这个点提前跟 DeepSeek 说了,结果它生成的代码里没有这个 bug,省了我大把调试时间。
三、69 秒里发生了什么:完整对话还原与代码生成
下面这段对话不是润色过的假记录,就是我那天实际输入和输出的精简版本。我会把每次提问、模型的关键回复,以及我对回复质量的判断一起写出来,方便你理解整个工作流长什么样。
3.1 第一次提问:直给需求,限定语言和边界
我输入的第一句话是这样的:
“用 Python 写一个表达式计算器,用调度场算法。要求:支持加减乘除、括号、小数、负数、空格、幂运算。输入是字符串,输出是 float。如果表达式非法,要报 ValueError。附带单元测试。”
之所以把需求说得这么硬,是因为大模型的 output 质量极度依赖输入约束。你要是不说“输出是 float”“非法要报 ValueError”,它可能给你一个能跑但接口含糊的版本,后面还得来回改。
DeepSeek 在十几秒内给了完整代码。核心结构大致如下:
import re def infix_to_postfix(expr: str): precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3} right_assoc = {'^'} op_stack = [] output = [] tokens = expr.replace(' ', '') i = 0 while i < len(tokens): ch = tokens[i] if ch.isdigit() or ch == '.': j = i while j < len(tokens) and (tokens[j].isdigit() or tokens[j] == '.'): j += 1 output.append(tokens[i:j]) i = j continue elif ch == '-': if i == 0 or tokens[i-1] in '+-*/^(': num_start = i i += 1 while i < len(tokens) and (tokens[i].isdigit() or tokens[i] == '.'): i += 1 output.append(tokens[num_start:i]) continue else: while op_stack and op_stack[-1] != '(' and precedence[op_stack[-1]] >= precedence['-']: output.append(op_stack.pop()) op_stack.append('-') elif ch in precedence: while op_stack and op_stack[-1] != '(': top_prec = precedence[op_stack[-1]] cur_prec = precedence[ch] if cur_prec < top_prec or (cur_prec == top_prec and ch not in right_assoc): output.append(op_stack.pop()) else: break op_stack.append(ch) elif ch == '(': op_stack.append(ch) elif ch == ')': while op_stack and op_stack[-1] != '(': output.append(op_stack.pop()) if not op_stack: raise ValueError("Mismatched parentheses") op_stack.pop() else: raise ValueError(f"Unknown token: {ch}") i += 1 while op_stack: if op_stack[-1] == '(': raise ValueError("Mismatched parentheses") output.append(op_stack.pop()) return output def evaluate(expr: str) -> float: rpn = infix_to_postfix(expr) num_stack = [] for token in rpn: if token.replace('.', '', 1).replace('-', '', 1).isdigit(): num_stack.append(float(token)) elif token in '+-*/^': if len(num_stack) < 2: raise ValueError("Invalid expression") b = num_stack.pop() a = num_stack.pop() if token == '+': num_stack.append(a + b) elif token == '-': num_stack.append(a - b) elif token == '*': num_stack.append(a * b) elif token == '/': if b == 0: raise ValueError("Division by zero") num_stack.append(a / b) elif token == '^': num_stack.append(a ** b) if len(num_stack) != 1: raise ValueError("Invalid expression") return num_stack[0]这段代码一出来,我的第一反应是 “结构居然是对的”。负号处理逻辑被单独拎出来了,i == 0 or tokens[i-1] in '+-*/^('这个判断准确识别了一元负号的位置,然后直接吞掉负号和后面的数字作为一个整体 token。幂运算的右结合性也用了一个right_assoc集合,弹栈条件里加了cur_prec == top_prec and ch not in right_assoc,这是调度场里最容易写错的一环。
3.2 我手动补充和修正的部分
AI 给出的初版代码虽然核心结构没问题,但有一个隐藏 bug,在evaluate函数判断 token 是否是数字时,它用了token.replace('.', '', 1).replace('-', '', 1).isdigit()。这在-3这种 token 上行得通,但如果输入是1--2,解析器会把第二个负号当成一元负号处理,生成1 -2 -这样的后缀序列,然后evaluate阶段会把-2当作数字正常压栈,最后执行1 - (-2),结果是 3,从数学角度看其实是合理的。
但问题是,非法表达式1 + * 2这种就绝对不该通过。*直接跟着+,初版解析器虽然不会崩,但会把*当作普通运算符压栈,最后算出来的东西可能完全不是用户想要的。我给 DeepSeek 补充了一条要求:“如果连续出现两个运算符且中间没有数字或括号,直接报 ValueError。”它随后给我的修复版加了一个前置校验函数。
这里的关键启发是:AI 能把算法主链路写得很好,但“非法输入检测”这种工程意识,需要你人为补充约束。这不是 AI 笨,而是你给的 prompt 里没写清楚,它不知道你的应用对错误输入的容忍度是零。
四、实际测试与踩坑记录:边界情况比算法更考验人
代码生成只花了一分钟,但真正让这个项目变得有价值的是接下来半小时的功能验证。AI 生成的代码是骨架,你得给它喂各种刁钻输入,看它会不会长血栓。
4.1 我用哪些用例测了一遍
我在本地直接跑了一个测试脚本,把下面这些场景全部过了一遍:
| 输入表达式 | 预期结果 | 实测结果 | 结论 |
|---|---|---|---|
2 + 3 * 4 | 14 | 14.0 | 通过 |
(1 + 2) * 3 | 9 | 9.0 | 通过 |
2^3^2 | 512 | 512.0 | 通过,右结合生效 |
-3 + 2 | -1 | -1.0 | 通过 |
1 - (2 - 3) | 2 | 2.0 | 通过 |
3.14 * 2 | 6.28 | 6.28 | 通过 |
1 / 0 | 报错 | ValueError | 通过 |
((1+2) | 报错 | ValueError | 通过 |
2 + * 3 | 报错 | 初版通过,修复版报错 | 通过修复版 |
注意2 + * 3这一行。初版代码不会报错,它会把*直接压栈,后续解析可能因为缺少操作数而在evaluate阶段弹栈时才发现栈里数字不够,抛出 ValueError。所以纠错逻辑还是能被触发的,但触发时机不对——我们希望在语法分析阶段就拦住它,而不是等到计算阶段。
修复方式是在infix_to_postfix开头加一个 token 合法性预检,逻辑是“前一个 token 是运算符且当前 token 也是运算符,且两个都不是括号时,直接报错”。这里有一个特例,2 * -3 + 1是合法的,因为*后面跟了一元负号,负号在这个上下文里不能被当成二元运算符。所以预检逻辑必须把“一元负号”这个场景单独拎出来,否则你会把大量合法负数表达式误杀。
最终预检函数大概长这样:
def validate_tokens(tokens): prev_type = None # 'num', 'op', 'lp', 'rp' for ch in tokens: if ch in '+-*/^': if prev_type == 'op': raise ValueError("Consecutive operators") prev_type = 'op' elif ch == '(': prev_type = 'lp' elif ch == ')': if prev_type in ('op', 'lp'): raise ValueError("Invalid token before closing parenthesis") prev_type = 'rp' elif ch.replace('.', '', 1).isdigit(): prev_type = 'num' else: raise ValueError(f"Unknown token: {ch}")这是纯粹的人工补丁,DeepSeek 不会自动想到你的应用需要这么严格的语法预检。
4.2 浮点误差、除零、幂运算的细节
浮点精度问题是另一个常见的坑。0.1 + 0.2在任何主流编程语言里都是0.30000000000000004而不是0.3,这是 IEEE 754 二进制浮点表示的天生限制。如果你做一个面向用户的表达式计算器,直接返回这个结果会被用户骂死。我在最终版本的输出上做了格式化:如果浮点数的有效位数足够短,直接用str()去掉无用尾部;如果很长,保留 10 位小数并去掉末尾的 0。
还有一个细节:幂运算^和**的优先级与结合性不同语言里有不同定义。在 Python 里,2 ** 3 ** 2等于 512,因为幂运算是右结合的。调度场算法里如果不显式处理右结合,把它和加减乘除一样在优先级相等时直接弹出栈顶,你会得到 64,直接错掉。DeepSeek 生成的代码里已经包含了right_assoc集合,这是它写得好的地方。
除零检查就更不用说了,必须在evaluate阶段检测除数为 0,并且抛出的错误要有明确信息“Division by zero”,否则后面接错误处理系统时你都不知道是哪一步出了问题。
五、AI 辅助编程的边界在哪里:哪些事我能让 AI 干,哪些必须自己干
这次实操让我有个很深的感触:AI 编程的效率实质上是“你把需求描述得多清晰”的复利。你花 30 秒把约束说清楚,它能为你省下 30 分钟的兜底调试;你图省事只丢一句话,那后面每一句“帮我修一下”都是在透支自己的时间和耐心。
5.1 让 AI 写得快的三个小技巧
第一,给测试用例。不要只给需求,而是给一组输入输出对,AI 能看到这些对就知道边界条件的优先级。比如你期望2 + 3 * 4返回 14,它就会条件反射式地处理优先级。你期望-3 + 2返回 -1,它就会提前处理一元负号。
第二,指定报错类型和异常信息。我说“非法表达式要抛 ValueError”,比“报个错”具体得多。这让 AI 生成的代码在异常处理上有明确的执行路径,还会主动考虑raise的位置,而不是 fatal 混乱地 return None。
第三,要求附带单元测试。这个最简单也最实用。AI 生成的测试代码也许不够全面,但它会帮你覆盖最常见的 happy path,你只需要在它给出的基础测试上添加自己的边界用例,比自己从零写测试骨架省一半时间。
5.2 哪些环节 AI 不太擅长
这个项目里,AI 生成的算法主体非常漂亮,但一旦涉及到“非法输入判定”这个工程问题,它默认的行为是“尽力解析而不是报错”,因为从语言模型的角度,它是在“续写代码”而不是“理解一个计算器产品”。这导致它的初版代码允许1 + * 2这种非法表达式进入 evaluate 阶段。这种问题你不能指望一次 prompt 解决,需要自己调试、发现问题、再喂给它。
还有工程结构问题。AI 更喜欢把所有逻辑揉在一个文件里,函数堆砌在一起,虽然能跑,但可维护性差。我自己动手把代码拆成了tokenizer.py、shunting_yard.py、evaluator.py、tests/test_calculator.py四个模块。AI 不会主动考虑你的模块划分,除非你明确指示。
提示:让 AI 写代码的时候,“可读性优于技巧性”。你没必要让 AI 生成一个一行写完表达式的压缩版本,那写出来自己也看不懂,后面改 bug 等于重写。
5.3 这个表达式计算器还能往哪个方向扩展
写完之后我又顺手扩展了几个功能,说实话性价比极高,每个都不难但很出效果:
- 支持三角函数:
sin(30)、cos(pi/2)这种,只要在 evaluate 阶段把函数名映射到 Python 的math模块即可,tokenizer 里识别sin这种标识符就行。 - 支持变量:比如
x = 3; y = x * 2 + 1; y,这需要增加符号表,本质上就把调度场算法从“表达式计算器”升级成了“简易解释器”。 - 支持
//整除和%取余:只需要在 precedence 字典里加两个键,在 evaluate 阶段加两个分支。
感兴趣的话,你甚至可以引导 AI 把中缀转后缀的输出直接构建成抽象语法树,然后自己写一个树形结构的计算器。那样的话,距离实现一个真正脚本语言的求值器就不远了。
六、碰到过的坑和对应的排查思路
从初版代码到可复用模块,我前前后后踩了四个比较明显的坑,都记录下来,给后来者当路标。
6.1 一元负号前后夹击的隐患
1--2这种表达式,初版代码解析是通过的,结果算出来是 3。数学上没错,但语法上,1--2在很多语言里并不是合法表达式,除非你有明确的“负负得正”或“减负数”语义。问题是,如果用户本来想输入1 - -2却漏了个空格,语义其实是清楚的,算 3 也说得过去。这个坑的本质不是算出错,而是“错误输入和合法输入之间的边界模糊”。解决方式:预检函数里不允许出现连续两个二元运算符,但允许“二元运算符 + 一元负号 + 数字”。
6.2 括号不闭合的“传炸弹”
((1+2)这种输入,初版infix_to_postfix在扫描完所有 token 后,发现 op_stack 里还残留左括号,会抛出 ValueError。这个检查逻辑本身没错,但问题在于报错时机太晚。如果表达式很长,用户根本不知道错在哪里。我的改进是:在扫描过程中记录左括号位置,当遇到右括号弹栈时如果对应栈顶不是左括号就立即报错,并且报错信息里带上 token 下标位置,这样调试的时候能直接定位到字符串里哪个位置出了问题。
6.3 幂运算右结合性的优先级陷阱
调度场算法实现里最容易翻车的地方就是这里。很多初见版本会用while op_stack and precedence[op_stack[-1]] >= precedence[ch]来处理所有运算符,这对左结合运算符没问题,但遇到2^3^2时,如果优先级相等直接弹出栈顶,等价于从左到右计算,得到 64,而正确结果是 512。修复方式是我上面写的right_assoc集合加上“如果当前运算符右结合,则优先级相等时不弹出栈顶”,这行代码是所有 AI 生成初版最容易漏掉或者搞反的。
6.4 输入字符串前后空格和制表符
这个属于低级坑,但所有真实用户都会撞上。用户输入2 + 3可能前后带空格、中间有多个空格甚至 tab。初版代码用了简单的replace(' ', ''),这能干掉空格但干不掉 tab。稳妥做法是在 tokenizer 级别做正则分词:re.findall(r'(\d+\.?\d*|\.\d+|[+\-*/^()])', expr),这一行收益巨大,既能把数字、运算符、括号统一切成 token,又能自然忽略掉中间的所有空白字符。
七、写在最后:我对 AI 写算法项目的一点真实感受
69 秒拿到核心代码,这是事实。但如果说整个项目 69 秒就全部完工,那我是在骗你。真实的时间分配大约是这样的:
- 让 DeepSeek 写调度场算法核心:1 分钟。
- 我手动梳理需求、补全边界:10 分钟。
- 测试各种异常输入并修复:15 分钟。
- 拆分模块、整理代码结构:10 分钟。
- 加注释、写使用文档:5 分钟。
总计不到一小时,但换来的是一个可以放心给用户用的表达式计算器模块。对比以前我从零手写调度场算法,光是在纸上推演优先级细节就得花半小时,再加上 bug 调试,一整天基本就没了。AI 确实把“从零造轮子”的时间压缩到了一个很夸张的程度,但“理解轮子为什么能转”这件事,仍然得自己做。
我在实际开发中越来越倾向于“AI 生成第一版,人类负责所有极端情况”。大模型天然擅长生成符合主流语法的代码,因为它的训练语料里全是这种主流写法;但你真正需要警惕的不是主流路径,而是用户在键盘上乱敲出来的那些稀奇古怪的输入。一个计算器如果处理不好用户手滑打错的括号,那它就只是个玩具。
这次实践里,DeepSeek 的调度场算法主体让我挺意外,它不只是背下了教科书代码,还能处理好一元负号、右结合幂运算这种容易出错的边角。它生成的right_assoc集合直接命中了经典实现里的关键决策。这些细节说明它真的从成千上万的优秀代码里学到了东西,而不是简单调用一个函数库。
不过也有它做不到的事:我希望它能直接根据用户报错信息反向定位自己生成的 bug 并主动修复,但它更像一个“按指令办事的程序员”,你说“测试一下0.1+0.2”,它才会想到浮点精度问题。你不说,它默认你用的是整数。所以和 AI 协作的正确姿态,永远是你掌握全局,它负责体力活。
还有一个小技巧值得分享给所有用 AI 写代码的朋友:写完核心功能后,多跑几轮“你自己扮演用户,去输入各种不像人话的表达式”的测试。((((1+2))))、-0.5 * (-0.25)、2 ^ -3、1//2、1e3、++++1,这类输入才真正暴露实现里的隐藏缺陷。我在测试++++1的时候发现初版代码竟然通过了,好家伙,一个只支持+ - * / ^的表达式计算器居然允许一元正号连写,这某种意义上也算是个 feature,但显然不是设计本意。后来我在 tokenizer 层直接把这个场景拦截掉了,毕竟我们不是在支持 Perl。
这个项目后续我还计划让它直接生成支持函数调用的版本,比如sqrt(9) + abs(-3),把你的 tokenizer 扩展一下,把sqrt、abs、log这些函数名注册进来,再在 evaluate 阶段做一个apply_function的映射。这样调度场算法就从“简单的四则计算器”进化成了“一个小型公式引擎”。如果你把函数名注册表做成字典,把函数指针作为 value,扩展一个新的函数只需要加一行注册代码,从工程角度来说这个设计足够干净。到时候再让 DeepSeek 帮我写第一版,我负责验收边界,这大概就是未来程序员和 AI 协作的常态。