Python计算器中怎么安全解析数学表达式而不使用eval

巷口听风
巷口听风
发布时间:2026-10-28 | 11人浏览过
原创

详细讲解在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() 都属于不可修复的高危方案。

Python计算器中怎么安全解析数学表达式而不使用eval
图 1:eval() 与 AST 白名单求值在执行边界与安全性上的本质差异

二、基于 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),程序直接中断并拒绝执行。

Python计算器中怎么安全解析数学表达式而不使用eval
图 2:算术表达式转换为语法树后的节点结构及白名单过滤规则

三、基于 AST 的安全计算器实现步骤

实现该计算器无需安装任何第三方库,完全依赖标准库中的 ast 和 operator 模块。整个操作由以下四个步骤构成:

Python计算器中怎么安全解析数学表达式而不使用eval
图 3:基于 AST 的数学表达式安全求值处理流

步骤 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)隐患:

注意算力消耗(ReDoS 与大数膨胀):
即使没有恶意代码注入,像 9 ** (9 ** 9) 或 99999999 ** 99999999 这样的纯数学表达式也会导致 Python 解释器内存耗尽或 CPU 占用 100%。因此必须像前文代码所示,对 ast.Pow 的底数与指数大小设置硬性上限,或者限制浮点数运算的时间片。
  • 限制表达式长度与深度:在解析前必须对输入字符串的长度(例如限定不超过 500 个字符)做限制。过长或深层嵌套括号(如几千层 ((((...)))))可能在语法解析阶段触发递归深度溢出错误(RecursionError)。
  • 禁用未授权的内置常量:在白名单中仅允许 isinstance(node.value, (int, float))。如果不加类型限制,用户输入类似 "True" 或 "None" 也会生成 ast.Constant,可能引发下游类型错误。
  • 明确错误提示边界:对于捕获到的非法节点异常,向用户返回通用的“不支持的语法结构”或“输入格式有误”提示,避免直接抛出带有底层路径或完整堆栈信息的原生异常。
Python计算器中怎么安全解析数学表达式而不使用eval
图 4:Python 计算器表达式安全解析体系与防御总结

六、总结

在 Python 中开发数学计算器时,摆脱对 eval() 的依赖是保障程序安全的第一准则。通过标准库提供的 ast.parse(),我们可以以极轻量的方式将数学表达式解析为静态语法树,再结合操作符与节点类型的白名单策略,自底向上递归求值。这种做法既继承了 Python 语法引擎对优先级和括号的原生解析能力,又将执行权限牢牢锁死在有限的算术运算内,从源头上切断了代码注入的可能性。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表