Python计算器中怎么安全解析数学表达式而不使用eval
详细讲解在Python计算器中如何安全解析并计算数学表达式,避免使用危险的eval函数,基于标准库ast实现白名单求值与防代码注入方案。
在编写Python计算器或处理用户输入的数学公式(例如 "3 + 5 * (10 - 2)")时,直接调用内置的 eval() 函数虽然看似最简便,但会导致极高的任意代码执行风险。安全的替代方案是使用Python标准库自带的 ast(抽象语法树)模块,仅将输入的文本解析为语法节点,再通过自底向上的白名单访问器只计算数字和预设的四则运算符;或者基于经典的数据结构算法(如调度场算法)自行将中缀表达式转换为逆波兰表达式后求值。本文将深入讲解基于 ast 模块实现安全计算器的完整步骤,并在一致条件下对比不同解析方案的关键差异,帮助你在零第三方依赖的前提下彻底杜绝代码注入漏洞。
一、为什么计算器程序必须拒绝使用 eval()
eval() 的设计初衷是动态执行任意有效的Python代码,并不仅仅是一个“数学表达式计算器”。当我们将终端、网页前端或客户端输入直接交给 eval() 时,程序失去对代码行为的任何控制力。
很多初学者会尝试通过清空全局字典来进行限制,例如写成 eval(expr, {"__builtins__": {}})。然而这种防御在Python的对象模型面前形同虚设。攻击者可以通过任意基础对象的类层级关系,反查到全局可用的系统函数。例如输入以下表达式:
().__class__.__bases__[0].__subclasses__()[137].__init__.__globals__['system']('whoami')
只要在子类列表中找到包含加载模块或系统调用的类(不同版本索引略有差异),就能脱壳获取 os.system 从而执行任意终端命令。因此,在任何面向不可信输入的计算器应用中,eval() 都属于不可修复的高危方案。
二、基于 AST 语法树的安全计算原理
Python 标准库自带的 ast 模块可以将代码字符串转化为抽象语法树(Abstract Syntax Tree)。当我们以 mode='eval' 解析表达式时,Python 仅进行词法与语法分析,输出一棵树状数据结构,而不会执行任何代码。
例如,表达式 3 + 5 * (10 - 2) 被解析后,根节点是一个 ast.Expression,其内部是一个二元运算节点 ast.BinOp:
- 根运算为加法(
ast.Add),左操作数是常数3。 - 右操作数是另一个
ast.BinOp,表示乘法(ast.Mult)。 - 乘法的左操作数是常数
5,右操作数是减法二元运算(10 - 2)。
计算的本质就变成了对这棵树进行自底向上的递归求值:先计算叶子节点的算术运算,将其结果返回给父节点。如果树中出现了不属于算术节点的类型(如函数调用 ast.Call、变量名 ast.Name 或属性访问 ast.Attribute),程序直接中断并拒绝执行。
三、基于 AST 的安全计算器实现步骤
实现该计算器无需安装任何第三方库,完全依赖标准库中的 ast 和 operator 模块。整个操作由以下四个步骤构成:
步骤 1:建立操作符白名单字典
首先将 AST 的运算符类型与 Python 标准库 operator 中的函数映射起来。这样可以避免手工编写 if op == '+' 这种散乱逻辑,且杜绝任何未列出的运算符:
import ast
import operator
# 允许的操作符白名单
ALLOWED_OPERATORS = {
ast.Add: operator.add, # +
ast.Sub: operator.sub, # -
ast.Mult: operator.mul, # *
ast.Div: operator.truediv, # /
ast.FloorDiv: operator.floordiv, # //
ast.Mod: operator.mod, # %
ast.Pow: operator.pow, # **
ast.USub: operator.neg, # 一元负号,如 -5
ast.UAdd: operator.pos, # 一元正号,如 +5
}
步骤 2:编写节点递归求值函数
编写递归函数 _evaluate_node(node)。当进入函数时,使用 isinstance 检查当前节点的具体类型。Python 3.8 及之后版本统一使用 ast.Constant 表示数字字面量(兼容更早版本可同时检查 ast.Num):
def _evaluate_node(node):
# 表达式根节点,解包内部主体
if isinstance(node, ast.Expression):
return _evaluate_node(node.body)
# 数值字面量节点
elif isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
raise TypeError(f"不支持的常量类型: {type(node.value).__name__}")
# 二元运算符节点: left op right
elif isinstance(node, ast.BinOp):
op_type = type(node.op)
if op_type not in ALLOWED_OPERATORS:
raise ValueError(f"禁止的操作符: {op_type.__name__}")
left_val = _evaluate_node(node.left)
right_val = _evaluate_node(node.right)
# 针对除数为零做显式保护
if op_type in (ast.Div, ast.FloorDiv, ast.Mod) and right_val == 0:
raise ZeroDivisionError("除数或模数不能为零")
# 针对指数运算进行资源防消耗限制
if op_type is ast.Pow:
if right_val > 100 or abs(left_val) > 10000:
raise ValueError("指数运算数值过大,已触发拒绝保护")
return ALLOWED_OPERATORS[op_type](left_val, right_val)
# 一元运算符节点: +num 或 -num
elif isinstance(node, ast.UnaryOp):
op_type = type(node.op)
if op_type not in ALLOWED_OPERATORS:
raise ValueError(f"禁止的一元操作符: {op_type.__name__}")
operand_val = _evaluate_node(node.operand)
return ALLOWED_OPERATORS[op_type](operand_val)
# 任何其他节点(函数调用、变量名、属性引用等)直接抛出异常
else:
raise ValueError(f"表达式包含非法语法结构: {type(node).__name__}")
步骤 3:封装完整的 safe_eval 对外入口
对外提供一个简洁的调用接口,统一做长度限制与异常收口:
def safe_eval(expr: str):
"""
安全计算纯数学算术表达式
:param expr: 用户输入的算术字符串,如 "12 + 4 * (3 - 1)"
:return: int 或 float 计算结果
"""
if not isinstance(expr, str):
raise TypeError("输入必须为字符串")
cleaned = expr.strip()
if not cleaned:
raise ValueError("表达式不能为空")
if len(cleaned) > 500:
raise ValueError("表达式长度超出限制 (最大 500 字符)")
try:
# 使用 mode='eval' 确保只接受单个表达式,禁止多行语句或控制流
tree = ast.parse(cleaned, mode='eval')
return _evaluate_node(tree)
except SyntaxError as e:
raise ValueError(f"语法错误: 无法解析输入的表达式 ({e.msg})") from e
步骤 4:验证功能与安全性
运行以下测试用例,验证正常运算与恶意输入的拦截表现:
if __name__ == "__main__":
# 1. 正常算术运算(支持括号、优先级与负数)
print("示例 1:", safe_eval("3 + 5 * (10 - 2)")) # 输出: 43
print("示例 2:", safe_eval("-4 * (2.5 + 1.5) / 2")) # 输出: -8.0
print("示例 3:", safe_eval("2 ** 8 + 10 % 3")) # 输出: 257
# 2. 恶意函数调用注入拦截
try:
safe_eval("__import__('os').system('ls')")
except ValueError as err:
print("拦截攻击 1 成功:", err) # 抛出: 禁止的操作符或语法结构
# 3. 恶意反射绕过拦截
try:
safe_eval("().__class__.__bases__[0]")
except ValueError as err:
print("拦截攻击 2 成功:", err) # 抛出: 表达式包含非法语法结构: Subscript
四、方案对比:AST 白名单求值 vs 调度场算法(Shunting-Yard)
在设计计算器时,除了使用 AST 树求值外,计算机科学中另一个极其经典的做法是调度场算法(Shunting-yard algorithm)。在相同的输入场景(给定中缀表达式字符串)下,两者的核心差异与取舍如下:
| 对比维度 | AST 白名单解析(Python 原生) | 调度场算法(Shunting-Yard) |
|---|---|---|
| 核心机制 | 利用 Python 编译器前端构建语法树,遍历执行 | 利用双栈结构(操作数栈与运算符栈)转为逆波兰表达式 |
| 外部依赖与平台 | 仅限 Python 环境(依赖标准库 ast) |
纯算法逻辑,跨语言通用(C/Java/JS/Rust 等完全一致) |
| 语法容错与优先级 | 由 Python 原生语法引擎保证,括号、乘方结合律天然正确 | 需自行编写词法分词器(Tokenizer),手工定义结合性与优先级 |
| 一元符号处理 | 自动将 -5 解析为 UnaryOp,清晰明确 |
需区分前导减号是“负号”还是“减法”,分词状态机较复杂 |
| 代码维护量 | 约 40 行代码即可完成完整安全的求值器 | 包含分词、中缀转后缀、后缀求值,通常需 120-200 行 |
| 推荐适用场景 | Python 后端服务、桌面应用、自动化运维计算器 | 教学演示、自研DSL解释器、移植到无Python运行时的嵌入式端 |
关键差异的影响分析:如果你的项目运行在 Python 环境中,优先选择 ast 方案。因为语法解析的边界情况极其繁杂(例如科学计数法 1e-5、多重括号嵌套、一元负号连写等),原生 ast.parse 已经由 Python 核心团队经过数十年千万级测试用例打磨,稳定性和合规性远高于手工编写的正则分词或栈调度代码。
五、生产环境计算器的防御边界与注意事项
虽然通过 AST 节点白名单杜绝了任意代码执行,但在生产环境中部署计算器时,仍需防范以下算力与资源层面的拒绝服务(DoS)隐患:
即使没有恶意代码注入,像
9 ** (9 ** 9) 或 99999999 ** 99999999 这样的纯数学表达式也会导致 Python 解释器内存耗尽或 CPU 占用 100%。因此必须像前文代码所示,对 ast.Pow 的底数与指数大小设置硬性上限,或者限制浮点数运算的时间片。
- 限制表达式长度与深度:在解析前必须对输入字符串的长度(例如限定不超过 500 个字符)做限制。过长或深层嵌套括号(如几千层
((((...)))))可能在语法解析阶段触发递归深度溢出错误(RecursionError)。 - 禁用未授权的内置常量:在白名单中仅允许
isinstance(node.value, (int, float))。如果不加类型限制,用户输入类似"True"或"None"也会生成ast.Constant,可能引发下游类型错误。 - 明确错误提示边界:对于捕获到的非法节点异常,向用户返回通用的“不支持的语法结构”或“输入格式有误”提示,避免直接抛出带有底层路径或完整堆栈信息的原生异常。
六、总结
在 Python 中开发数学计算器时,摆脱对 eval() 的依赖是保障程序安全的第一准则。通过标准库提供的 ast.parse(),我们可以以极轻量的方式将数学表达式解析为静态语法树,再结合操作符与节点类型的白名单策略,自底向上递归求值。这种做法既继承了 Python 语法引擎对优先级和括号的原生解析能力,又将执行权限牢牢锁死在有限的算术运算内,从源头上切断了代码注入的可能性。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













