
本教程将详细介绍如何在php中不使用`eval()`函数,安全有效地计算包含运算符优先级的数学表达式。核心方法是采用调度场算法将中缀表达式转换为逆波兰表示法(rpn),随后利用栈结构对rpn表达式进行求值,从而实现对复杂数学运算的精确处理。
在PHP开发中,直接使用eval()函数来执行用户提供的数学表达式存在严重的安全隐患,因为它允许执行任意的PHP代码。为了避免这种风险,同时又能灵活地处理带有运算符优先级的数学表达式,我们需要一种自定义的解析与计算方案。本教程将深入探讨如何通过将中缀表达式转换为逆波兰表示法(Reverse Polish Notation, RPN)并对其进行求值来实现这一目标。
数学表达式解析的核心原理
处理数学表达式通常涉及两个主要步骤:解析和求值。
1. 中缀表达式与逆波兰表示法 (RPN)
我们日常使用的数学表达式,如 2 + 3 * 4,被称为中缀表达式。它的特点是运算符位于操作数之间,并且需要考虑运算符优先级和括号。
逆波兰表示法(RPN),也称为后缀表达式,是一种没有括号的表达式形式,其中运算符位于其操作数之后。例如,中缀表达式 2 + 3 * 4 对应的 RPN 形式是 2 3 4 * +。RPN 的优点在于,它在求值时无需考虑运算符优先级,只需按照从左到右的顺序处理即可,这大大简化了计算逻辑。
立即学习“PHP免费学习笔记(深入)”;
2. 调度场算法 (Shunting-yard Algorithm)
调度场算法是 Dijkstra 提出的一种将中缀表达式转换为 RPN 的经典算法。它利用两个栈:一个用于存储运算符(运算符栈),另一个用于存储输出(输出栈或队列,最终形成 RPN 序列)。
算法的核心规则如下:
- 遇到数字,直接推入输出栈。
- 遇到运算符:
- 如果运算符栈为空,或栈顶是左括号,或当前运算符优先级高于栈顶运算符,则将当前运算符推入运算符栈。
- 否则(当前运算符优先级小于或等于栈顶运算符),将栈顶运算符弹出并推入输出栈,然后重复此比较,直到满足上述条件或运算符栈为空,最后将当前运算符推入运算符栈。
- 遇到左括号 (,推入运算符栈。
- 遇到右括号 ),不断将运算符栈中的运算符弹出并推入输出栈,直到遇到左括号。然后将左括号弹出(但不推入输出栈)。
- 表达式处理完毕后,将运算符栈中剩余的所有运算符依次弹出并推入输出栈。
实现步骤与代码解析
我们将通过一系列 PHP 函数来实现数学表达式的解析和计算。
1. 整体架构:calculate() 函数
calculate() 函数是整个流程的入口,它负责协调中缀表达式到 RPN 的转换以及 RPN 表达式的求值。
2. 中缀转 RPN:mathexp_to_rpn() 函数
这个函数实现了调度场算法,将中缀表达式字符串转换为 RPN 数组。
0, // 括号的优先级最低,用于控制弹出
'+' => 3,
'-' => 3,
'*' => 6,
'/' => 6,
'%' => 6
);
$i = 0;
$final_stack = array(); // 存储 RPN 结果的栈
$operator_stack = array(); // 存储运算符的栈
while ($i < strlen($mathexp)) {
$char = $mathexp{$i};
// 1. 处理数字
if (is_number($char)) {
$num = readnumber($mathexp, $i);
array_push($final_stack, (float)$num); // 将数字转换为浮点数并推入结果栈
$i += strlen($num); // 跳过已读取的数字长度
continue;
}
// 2. 处理运算符
if (is_operator($char)) {
// 当运算符栈不为空,且栈顶不是左括号,且当前运算符优先级小于等于栈顶运算符优先级时
while (!empty($operator_stack) && end($operator_stack) != '(' && $precedence[$char] <= $precedence[end($operator_stack)]) {
array_push($final_stack, array_pop($operator_stack)); // 弹出栈顶运算符到结果栈
}
array_push($operator_stack, $char); // 将当前运算符推入运算符栈
$i++;
continue;
}
// 3. 处理左括号
if ($char == '(') {
array_push($operator_stack, $char);
$i++;
continue;
}
// 4. 处理右括号
if ($char == ')') {
// 弹出运算符直到遇到左括号
while (!empty($operator_stack) && ($operator = array_pop($operator_stack)) != '(') {
array_push($final_stack, $operator);
}
$i++;
continue;
}
$i++; // 跳过空格或其他未知字符
}
// 表达式处理完毕,将运算符栈中剩余的所有运算符弹出到结果栈
while ($oper = array_pop($operator_stack)) {
array_push($final_stack, $oper);
}
return $final_stack;
}
/**
* 从字符串中读取一个完整的数字
* @param string $string 原始字符串
* @param int $i 当前读取位置的索引(引用传递)
* @return string 读取到的数字字符串
*/
function readnumber($string, &$i) {
$number = '';
$start_i = $i; // 记录开始位置
while ($i < strlen($string) && is_number($string{$i})) {
$number .= $string{$i};
$i++;
}
$i = $start_i; // 恢复 $i 到数字开始位置,因为外部循环会重新增加 $i
return $number;
}
/**
* 判断字符是否为运算符
* @param string $char 待判断字符
* @return bool
*/
function is_operator($char) {
static $operators = array('+', '-', '/', '*', '%');
return in_array($char, $operators);
}
/**
* 判断字符是否为数字或小数点
* @param string $char 待判断字符
* @return bool
*/
function is_number($char) {
return (($char == '.') || ($char >= '0' && $char <= '9'));
}
?>代码解析要点:
- $precedence 数组:定义了不同运算符的优先级。括号 ( 的优先级设为0,确保其在运算符栈中不会被其他运算符弹出,直到遇到 )。
- $final_stack (输出栈):存储最终的 RPN 序列。
- $operator_stack (运算符栈):临时存储运算符。
- readnumber() 函数:处理多位数字和浮点数,确保能正确读取整个数字。
- is_operator() 和 is_number():辅助函数,用于判断字符类型。
- 循环逻辑:遍历表达式字符串,根据字符类型(数字、运算符、括号)执行调度场算法的相应规则。
3. RPN 求值:calculate_rpn() 函数
这个函数接收 RPN 数组,并使用一个栈来计算表达式的结果。
代码解析要点:
- $stack (操作数栈):临时存储数字。
-
遍历 RPN 数组:
- 遇到数字:推入操作数栈。
- 遇到运算符:从操作数栈中弹出两个操作数(注意顺序,先弹出的为右操作数),执行相应运算,然后将结果推回操作数栈。
- 错误处理:增加了对除以零和取模操作数类型的基本检查。
示例代码
将以上所有函数组合在一个文件中,即可进行测试。
= '0' && $char <= '9'));
}
// 核心函数
function calculate($exp) {
return calculate_rpn(mathexp_to_rpn($exp));
}
function calculate_rpn($rpnexp) {
$stack = array();
foreach($rpnexp as $item) {
if (is_operator($item)) {
$j = array_pop($stack);
$i = array_pop($stack);
switch ($item) {
case '+': array_push($stack, $i + $j); break;
case '-': array_push($stack, $i - $j); break;
case '*': array_push($stack, $i * $j); break;
case '/':
if ($j == 0) throw new InvalidArgumentException("Division by zero.");
array_push($stack, $i / $j);
break;
case '%':
if (!is_int($i) || !is_int($j)) throw new InvalidArgumentException("Modulo operator requires integer operands.");
if ($j == 0) throw new InvalidArgumentException("Modulo by zero.");
array_push($stack, $i % $j);
break;
}
} else {
array_push($stack, $item);
}
}
return $stack[0];
}
function mathexp_to_rpn($mathexp) {
$precedence = array(
'(' => 0,
'-' => 3,
'+' => 3,
'*' => 6,
'/' => 6,
'%' => 6
);
$i = 0;
$final_stack = array();
$operator_stack = array();
while ($i < strlen($mathexp)) {
$char = $mathexp{$i};
if (is_number($char)) {
$num = readnumber($mathexp, $i);
array_push($final_stack, (float)$num);
$i += strlen($num); continue;
}
if (is_operator($char)) {
while (!empty($operator_stack) && end($operator_stack) != '(' && $precedence[$char] <= $precedence[end($operator_stack)]) {
array_push($final_stack, array_pop($operator_stack));
}
array_push($operator_stack, $char);
$i++; continue;
}
if ($char == '(') {
array_push($operator_stack, $char);
$i++; continue;
}
if ($char == ')') {
while (!empty($operator_stack) && ($operator = array_pop($operator_stack)) != '(') {
array_push($final_stack, $operator);
}
$i++; continue;
}
$i++; // 忽略其他字符,例如空格
}
while ($oper = array_pop($operator_stack)) {
array_push($final_stack, $oper);
}
return $final_stack;
}
// 使用示例
try {
$expression1 = "27+38+81+48*33*53+91*53+82*14+96";
echo "表达式: " . $expression1 . " = " . calculate($expression1) . "\n"; // 预期输出: 90165
$expression2 = "3 + 2 * (5 - 1)";
echo "表达式: " . $expression2 . " = " . calculate($expression2) . "\n"; // 预期输出: 11
$expression3 = "(10 + 20) / 5 - 3";
echo "表达式: " . $expression3 . " = " . calculate($expression3) . "\n"; // 预期输出: 3
$expression4 = "10 / 3";
echo "表达式: " . $expression4 . " = " . calculate($expression4) . "\n"; // 预期输出: 3.333...
$expression5 = "10 % 3";
echo "表达式: " . $expression5 . " = " . calculate($expression5) . "\n"; // 预期输出: 1
// 尝试除以零
// $expression_error = "5 / 0";
// echo "表达式: " . $expression_error . " = " . calculate($expression_error) . "\n";
} catch (InvalidArgumentException $e) {
echo "计算错误: " . $e->getMessage() . "\n";
}
?>注意事项与扩展
- 输入验证:当前实现假定输入是一个格式正确的数学表达式。在生产环境中,应在解析之前对输入字符串进行严格的验证,以防止格式错误的表达式导致程序崩溃或产生意外结果。例如,检查是否包含非法字符、括号是否匹配等。
- 浮点数精度:PHP 的浮点数计算可能存在精度问题,尤其是在涉及除法等操作时。如果需要高精度计算,可以考虑使用 PHP 的 BCMath 扩展。
-
功能限制:
- 本实现仅支持基本的加减乘除和取模运算,以及括号。
- 不支持负数(作为操作数,如 -5),但支持减法运算。
- 不支持一元运算符(如 +5, -5 的前缀形式,除非将其视为 0-5)。
- 不支持函数调用(如 sin(x))、变量、幂运算等。
- 不支持科学计数法。
- 扩展性:如果需要支持更多运算符(如幂运算 ^)、函数或一元运算符,需要修改 precedence 数组、is_operator 函数以及 calculate_rpn 函数中的运算逻辑,甚至可能需要调整 mathexp_to_rpn 的解析逻辑。
- 性能:对于非常长的表达式,这种基于字符遍历和栈操作的方法可能不是最高效的。但对于一般长度的数学表达式,其性能通常足够。
总结
通过调度场算法将中缀表达式转换为逆波兰表示法,并利用栈结构对 RPN 表达式进行求值,我们成功地在 PHP 中实现了一个不依赖 eval() 函数的数学表达式计算器。这种方法不仅避免了 eval() 带来的安全风险,还提供了一个清晰、可控且易于理解的表达式处理机制。虽然当前实现有一些限制,但其模块化的设计为未来功能的扩展和优化奠定了坚实的基础。











