☰
表达式与运算符:从优先级到表达式树的完整指南
2026/10/10 10:17:30 网站建设 项目流程

表达式和运算符,几乎是每一门编程语言里最“最小”的基础设施。你可能觉得这玩意太简单了,加减乘除谁不会?但我在实际写代码、做Code Review甚至带新人的时候发现,真正能把表达式用对、用透的人其实不多。很多人栽在运算符优先级上,有人在赋值和等号之间反复横跳,还有人写出能跑但结果一塌糊涂的表达式还不自知。这篇内容我就把“表达式与运算符”从底层到上层完整捋一遍,包括它们的基本分类、优先级与结合性、计算机在底层到底怎么求值,以及表达式树这种进阶玩法,希望能帮你把基础补扎实,项目里少踩几个隐藏的坑。

1. 别把表达式当成“算数题”:先搞清楚它的真实身份

1.1 表达式:一个能算出“值”的语法片段

不少新手看到“表达式”三个字,第一反应是数学课上的算式。这个理解没有全错,但远远不够。在编程语言里,表达式的定义要宽泛得多:只要一段代码能求出一个“值”,它就是表达式。

3 + 5是表达式,因为能算出8;x * y是表达式;list.size()也是表达式,因为方法调用会返回一个值。甚至连a = 1这种赋值语句,在Java、C系语言里也整个是一个表达式,它的值是赋完之后的1。这个特点经常被忽视,但后面讲赋值运算符时它会变得特别关键。

与之对应的概念是“语句”。语句是执行动作但本身没有值的语法单元,比如if (...),while (...),return;。你没法写出int x = if (true) 5;这种代码,因为if不是表达式,没有值。区分这两个概念,是理解很多编译错误的第一步。有些语言(比如Scala、Ruby)几乎一切皆表达式,函数体的最后一行表达式就是返回值,风格就明显不一样。

表达式还可以嵌套。1 + f(2 * g(3))里面,3是表达式,g(3)是表达式,2 * g(3)又是表达式。计算机处理时,本质上是在构建一棵“表达式树”,从叶子到根一步步求值。这也解释了为什么“表达式求值”和“表达式树”经常出现在同一个话题里——它们本来就是同一件事的两副面孔。

1.2 运算符:表达式里的“动词”

如果操作数(变量、常量、字面量)是“食材”,那运算符就是“菜刀”、“锅”和“火候”。只有食材没有工具,做不了菜;只有工具没有食材,巧妇难为无米之炊。一个完整的表达式,必然是操作数和运算符的组合。

运算符可以从两个维度去理解。第一个维度是“目数”:只接收一个操作数的叫单目运算符,比如!flag、-x、i++;接收两个的叫双目运算符,比如a + b、x > y;接收三个的就是那个长相奇特的“三元运算符”condition ? a : b,也是Java和C系语言里唯一的三目运算符。第二个维度是书写位置:运算符写在操作数前面叫前缀(!x),写在中间叫中缀(a + b),写在后面叫后缀(i++)。

这里我想特别强调一个隐藏逻辑:表达式求值的核心,就是“找运算符,按规则把操作数算成一个结果”。人类习惯中缀写法,因为符合阅读直觉;但计算机更偏爱后缀表达式(也叫逆波兰表达式),因为不需要考虑括号和优先级,只需要一个栈就能机械地完成计算。这个反差是所有表达式求值算法的起点。我后面专门用一整节讲它。

2. 运算符家族谱系:一张表理清所有“行动派”

2.1 按功能分,运算符其实就那么几大家子

很多初学者被运算符数量吓到,其实按功能归类后很清晰。以Java为例,主流运算符大致分为以下家族:

家族运算符典型用途
算术运算符+ - * / %数值计算
自增自减++ --循环计数、下标移动
关系运算符== != > < >= <=比较大小、判断相等
逻辑运算符&& || !布尔组合、条件判断
位运算符& | ^ ~ << >> >>>位级处理、低层优化
赋值运算符= += -= *= /= %= <<= >>= &= |= ^=写入变量
三元运算符? :简单的if-else表达式化
类型相关instanceof、(T)强转判断类型、转换类型
成员/下标.()[]访问对象字段、数组元素、方法调用

新手一般容易记住算术、比较和逻辑,却容易漏掉很多“看不见但很常用”的。比如+=这类复合赋值运算符,不只是x = x + 1的缩写,在Java里还藏着隐式强转的细节:int x = 5; x += 1.5;实际等价于x = (int)(x + 1.5);,不会编译报错,结果变成6。如果你拆开写成x = x + 1.5;反而会编译失败。这个差异很多人第一次踩到时都是一脸懵。

2.2 最容易翻车的那几对运算符

先说最经典的:=和==。单等号是赋值,双等号是相等判断。if (x = 1)在很多语言里不报错,而是先把1赋给x,再判断非零为真。这个bug历史悠久,甚至催生了Java不允许把布尔值赋给int的规则,还有C语言里故意写成if (NULL == ptr)来防止漏写等号的“yoda条件”风格。我自己的习惯是:凡是比较,一律把常量写在左边,这样万一少写一个等号,编译直接就报错,比人肉排查快得多。

然后是&&和&。前者是短路逻辑与,后者是位与,也支持boolean运算但不短路。很多人以为它们只是“效率差别”,实际上短路能力是保护代码安全的:if (list != null && list.size() > 0)在list == null时不会执行size(),安全;如果写成&,两边都会求值,直接空指针崩溃。这个坑我见过太多次,尤其在团队把代码从其他风格迁移过来的时候。

i++和++i也是重灾区。它们表达式的值不一样:i++对外暴露的是自增前的旧值,++i暴露的是自增后的新值。在循环里独立使用无差别,一旦嵌进复合表达式,比如arr[i++] = x和arr[++i] = x,行为完全不同。我的建议是:除了循环更新和数组下标这类经典场景,尽量不要把++塞进复杂表达式里,可读性和正确性都会好很多。

再一个容易被忽略的是取余运算符%在负数下的符号问题。不同语言处理方式不一样:Java和C++11以后的结果符号是被除数决定,比如-7 % 3结果是-1;Python则结果是2,符号跟除数一致。这不是“一个对另一个错”,而是语言规定不同。做跨语言移植或者写算法题时,这个差异足够让你怀疑人生。

3. 优先级与结合性:表达式的“交通规则”

3.1 为什么必须管优先级和结合性

优先级解决的是“先算谁”的问题。1 + 2 * 3之所以等于7而不是9,因为*优先级比+高,得先算乘法。这跟数学里的约定完全一致,人类没觉得这有什么特殊。但计算机不一样——它拿到表达式后必须有一套明确规则来消除歧义,否则同一段代码在不同编译器里可能输出不同结果。

结合性解决的是“优先级相同时,从左还是从右算”的问题。绝大多数双目运算符是“左结合”:10 - 3 - 2等同于(10 - 3) - 2,结果是5。但也有一些例外:赋值运算符、单目运算符、三元运算符是“右结合”。最典型的是连续赋值a = b = c,右结合意味着先算b = c,再把结果赋给a,实现链式赋值。如果它是左结合,(a = b) = c在大部分语言里是不合法的,因为(a = b)的结果不是左值。

结合性还有一种让人困惑的情况:单目运算符。-x++该怎么理解?在Java和C里,后置自增++的优先级比负号-高,所以等价于-(x++),先取x的旧值,然后自增,最后取负。这种写法我极度不建议在生产代码里出现,但要能看懂别人代码在干什么。

3.2 一张优先级速查表 + 实战解析

我把Java中常见的运算符优先级从高到低列出来,方便你当作速查表:

级别运算符说明
最高.()[]成员访问、方法调用、下标
++--(后置)后置自增自减
!~++--(前置)+-(正负号)一元运算
(type)强制类型转换
* / %乘除取余
+ -加减
<< >> >>>移位
< <= > >= instanceof关系判断
== !=相等判断
&位与
^位异或
|位或
&&逻辑与
||逻辑或
? :三元
最低= += -= ...赋值

具体看个例子:!flag && count > 2。逐层拆解:!优先级高于&&,所以先算!flag;>优先级高于&&,所以先算count > 2;最后执行&&。结果是(!flag) && (count > 2)。看起来挺合理。但如果写成flag & mask == 0,由于==优先级高于&,它实际被解析成flag & (mask == 0),这几乎肯定不是你想要的。这种“位运算优先级比关系运算低”的设定,是无数bug的温床。

我个人的实操准则是:只要表达式里超过两个运算符,就老老实实加括号。括号不会影响性能,却能把意图直接钉死在代码里,也让读代码的人少死很多脑细胞。Review时我看到a == b || c == d && e == f这种代码,第一反应不是去背优先级表,而是要求作者加括号拆开写。

4. 表达式求值背后:栈、波兰表达式和中缀转后缀

4.1 计算机为什么不喜欢“中缀表达式”

人写9 + (3 - 1) * 3 + 10 / 2感觉很自然,因为有括号、优先级可以“一眼看穿”。但对计算机来说,直接从左往右扫这个字符串会遇到一个致命问题:看到9 + 3的时候,它没法确定这个+到底要不要立刻执行,因为后面可能出现优先级更高的*或者括号。解决方式听起来很简单——先全部扫描一遍,找到最高优先级的部分先算。但真正实现起来,要么递归下降,要么先转成一种没有歧义的线性表示。

后缀表达式(逆波兰表达式)就是那个“没有歧义的线性表示”。以9 3 1 - 3 * + 10 2 / +为例,计算规则极其机械:从左往右扫,遇到数字就压栈,遇到运算符就弹出栈顶两个数字运算,再把结果压回去。全程不需要知道任何优先级,不需要括号。这种规则非常适合计算机硬件和栈这种数据结构。前缀表达式(波兰表达式)也是一个道理,只是运算符写在前面,面试里偶尔会遇到。

所以“中缀表达式转后缀表达式+栈求值”就成了编译器课和数据结构课的经典必学内容,也是那个热词“编程题实训-实验2-基于栈的算术表达式求值算法”背后的完整故事。

4.2 手写一个基于栈的算术表达式求值器

前面讲了一堆理论,这里直接上一份可运行的Java实现,包含中缀转后缀和后缀求值两半。

import java.util.*; public class ExpressionCalculator { private static int precedence(char op) { switch (op) { case '+': case '-': return 1; case '*': case '/': case '%': return 2; default: return 0; } } private static boolean isLeftAssociative(char op) { // 目前只处理左结合运算符 return true; } // 1. 中缀表达式转后缀表达式 public static String infixToPostfix(String expr) { StringBuilder output = new StringBuilder(); Deque<Character> stack = new ArrayDeque<>(); for (int i = 0; i < expr.length(); i++) { char c = expr.charAt(i); if (Character.isWhitespace(c)) continue; if (Character.isDigit(c)) { while (i < expr.length() && (Character.isDigit(expr.charAt(i)) || expr.charAt(i) == '.')) { output.append(expr.charAt(i)); i++; } output.append(' '); i--; } else if (c == '(') { stack.push(c); } else if (c == ')') { while (!stack.isEmpty() && stack.peek() != '(') { output.append(stack.pop()).append(' '); } stack.pop(); // 弹出左括号 } else { while (!stack.isEmpty() && stack.peek() != '(' && (precedence(stack.peek()) > precedence(c) || (precedence(stack.peek()) == precedence(c) && isLeftAssociative(c)))) { output.append(stack.pop()).append(' '); } stack.push(c); } } while (!stack.isEmpty()) { output.append(stack.pop()).append(' '); } return output.toString().trim(); } // 2. 后缀表达式求值 public static double evaluatePostfix(String postfix) { Deque<Double> stack = new ArrayDeque<>(); String[] tokens = postfix.split(" "); for (String token : tokens) { if (token.isEmpty()) continue; if (token.matches("[+\\-*/%]")) { double b = stack.pop(); double a = stack.pop(); switch (token.charAt(0)) { case '+': stack.push(a + b); break; case '-': stack.push(a - b); break; case '*': stack.push(a * b); break; case '/': stack.push(a / b); break; case '%': stack.push(a % b); break; default: throw new IllegalArgumentException("未知运算符: " + token); } } else { stack.push(Double.parseDouble(token)); } } return stack.pop(); } public static void main(String[] args) { String expr = "9+(3-1)*3+10/2"; String postfix = infixToPostfix(expr); System.out.println("后缀: " + postfix); System.out.println("结果: " + evaluatePostfix(postfix)); } }

运行结果:

后缀: 9 3 1 - 3 * + 10 2 / + 结果: 20.0

这里有几个实现要点值得多说一句。第一个是“遇到运算符怎么压栈”:只要栈顶运算符优先级比当前运算符高,或者优先级相同且当前运算符是左结合,就把栈顶弹出并输出。这个条件保证了相同优先级的运算能按从左到右的顺序进行。第二个是括号的处理:左括号直接压栈,右括号则一直弹到左括号为止,注意左括号本身不输出。第三个是数字扫描部分,我允许了小数点,这样一个简易计算器就能支持小数输入了。

当然这个实现是简化版,没有处理负号、一元正号、变量、函数调用,也没有做除零检查。真实项目的计算器需要把这些都补上。但它足以说明核心原理:栈是表达式求值最好的朋友,没有之一。

5. 真实项目里最容易踩的坑:从编译错误到逻辑暗雷

5.1 常见错误速查表

我在日常开发和支持别人排错时,反复遇到下面这些跟表达式、运算符相关的报错或异常,整理成表:

错误信息/现象常见原因解决办法
C++:“表达式必须包含类类型”在非类对象(如int、指针)上用了.或::访问成员确认左边对象是类/结构体/联合体实例,而不是基本类型
C/C++:“表达式必须含有常量值”数组长度用了非编译期变量,如int n = 5; int arr[n];改用const常量、宏,或动态分配内存
Java:bad operand types for binary operator运算符两边类型不匹配,比如String + String以外的操作检查类型,必要时强转或用适合的方法
Java:空指针异常(NPE)&&错写成&,或链式调用中间某一步返回 null改用短路运算符,或者加空值判断
结果莫名多出1或少1混淆了i++和++i在表达式里的值独立使用自增,避免塞进复值表达式
整数除法结果不对两个int相除得到截断后的整数,如5 / 2得到2转成double再除,或使用5.0 / 2
浮点比较结果不对直接用==比较浮点结果,如0.1 + 0.2 == 0.3为 false设置精度差(epsilon),或转 BigDecimal 处理
短路没生效在条件里故意用位操作&替代&&逻辑判断一律用&&和 `

5.2 我自己的避坑习惯

第一个习惯是“能用局部变量绝不用超长表达式”。我见过一段代码,一行的条件里有嵌套三目、位运算、多个比较,长达两百多个字符,最后线上出问题排了一天。后来我们把那段逻辑拆成若干个带名字的布尔变量:boolean isStudent = ...; boolean isVip = ...; boolean canDiscount = ...;,问题立刻暴露出来。表达式的价值是让代码简洁,不是让代码变密码。

第二个习惯是“比较运算把常量放左边”。在Java里还好,在C/C++里if (x == 1)少写一个等号变成if (x = 1)是合法代码,能编过也能跑,结果却是灾难。写成if (1 == x)后,一旦漏等号,编译器会直接报“不能给常量赋值”。这毛病我偶尔也会犯,全靠这个习惯兜底。

第三个习惯是“字符串比较用equals,不要用==”。这个坑几乎所有Java新人都会踩一次。==比较的是引用地址,两个内容相同的String可能不相等;equals比较的是内容。老代码里如果频繁用==比较字符串,遇到一组从不同来源构造出来的字符串,结果就会离奇地时对时错。我后来在代码审查里看到字符串==,一律打回去改掉。热词里有个“java运算符”,我想很大一部分流量就是搜这个问题的。

第四个习惯是“整数除法要用浮点,先想清楚类型提升”。表达式total / count如果两个都是int,不管total和count怎么大,结果都是截断后的整数。改成(double) total / count就对了。类型提升规则其实不复杂:double和int运算,int会先转成double。关键是要有意识地在写表达式之前预判结果类型,而不是等输出不对再回头查。

5.3 排查表达式问题的方法论

如果你已经写错了表达式,怎么快速定位?我的排查顺序一般是这样的:先把复杂的表达式从代码里抽出来,放到一个极简的测试小类里,用固定的输入复现;然后在关键操作数之间打印中间值,或者用调试器在每一行表达式处打断点观察;最后把大表达式逐步还原成多个小表达式,分别验证每一段的结果。

举个例子,之前排查过一个性能计数器异常的场景,逻辑条件是flag1 && flag2 || flag3 && flag4,看起来没毛病,但实际需求是(flag1 && flag2) || (flag3 && flag4),表面上也一致。后来发现真正的问题是 flag 之间还有一层优先级更高的!,导致整个判断反转。这种问题靠人眼看很难发现,最有效的办法就是把条件整体提炼成一个带语义名字的变量,再用不同输入组合做单测。表达式越早拆解,真相越早浮出水面。

6. 表达式树:从“求值”到“求解”的进阶玩法

6.1 表达式树到底是什么

表达式树就是把表达式表示成一颗二叉树:叶子节点是操作数,内部节点是运算符。比如(3 + 5) * 2的根节点是*,左孩子是+,右孩子是2;+的左孩子是3,右孩子是5。

这和前面讲的“中缀转后缀”是紧密关联的:后缀求值过程里每次运算符弹出两个操作数并计算结果,其实就是从下往上构建表达式树里的一颗子树;反过来,对表达式树做一次后序遍历并输出,得到的就是后缀表达式。我在实际项目中接触过很多解析器,它们的共同思路都是:“文本 -> 中缀 -> 后缀/AST -> 求值或分析”。表达式树就是AST(抽象语法树)在数学表达式这个场景下的具体样子。

构建表达式树并不难。准备一个栈,扫描后缀表达式:遇到操作数,直接创建一个叶子节点压栈;遇到运算符,弹出栈顶节点作为右孩子,再弹出下一个节点作为左孩子,然后用这个运算符创建父节点,重新压栈。扫描结束后栈里唯一的节点就是整棵树的根。

class ExprNode { String value; ExprNode left, right; ExprNode(String value, ExprNode left, ExprNode right) { this.value = value; this.left = left; this.right = right; } } // 根据后缀表达式构建表达式树 public static ExprNode buildTree(String postfix) { Deque<ExprNode> stack = new ArrayDeque<>(); String[] tokens = postfix.split(" "); for (String token : tokens) { if (token.isEmpty()) continue; if (token.matches("[+\\-*/%]")) { ExprNode right = stack.pop(); ExprNode left = stack.pop(); stack.push(new ExprNode(token, left, right)); } else { stack.push(new ExprNode(token, null, null)); } } return stack.pop(); }

6.2 表达式树能拿来做什么

知道了表达式树,能做的事情远比“算个结果”多得多。第一类用途是“规则引擎和动态计算”。比如电商系统的折扣规则是用户可配置的,里面可以填类似(price >= 100 && vip) || coupon > 0的条件,系统收到这个字符串后把它解析成表达式树,再遍历树执行求值。这样做的好处是规则与代码解耦,产品经理改规则不需要发版。这也是市面上很多规则引擎底层的核心逻辑。

第二类用途是“代数变换和求导”。表达式的求导规则本质上是递归的:加法求导等于分别求导再加起来,乘法求导等于导数对加另一项的导数。这些规则天然适合在表达式树上做递归遍历。比如写一个简单的微分器,输入x * x的表达式树,输出对x的导树是x * 1 + 1 * x。再配合化简,就是一个迷你符号计算系统。听起来很高上大,但核心数据结构就是表达式树。

第三类用途跟“数学表达式识别”这个热词相关。很多拍照解题、手写公式识别的应用,流程是:OCR识别出数学字符串 -> 解析成表达式树 -> 对树做结构归一化 -> 匹配标准答案或者计算判分。直接用字符串比对“答案对不对”非常脆弱,一份答案写成1+2,另一份写成3,字符串不一致但数学上等价。有了表达式树,就能对树做规范化,再比较结构是否等价。这个思路在自动判题、辅助教育产品里非常实用。

表达式树的求值本身也特别优雅:递归地对左右子树求值,再根据节点的运算符合并结果。相比前面栈的后缀求值,表达式树更像“人类思维方式”,只是构建过程依赖栈。这两种视角合在一起,才算把表达式求值真正学通。

我自己后来的很多工具,包括一些内部用的公式编辑器、配置化规则解析,都是从这个最小原型长出来的。如果你正在写的东西需要动态计算、规则配置、公式识别,强烈建议先画一棵表达式树,所有复杂度都会清晰很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询