在 MoonBit 中实现 Shunting Yard 算法

什么是 Shunting Yard 算法?
在编程语言或解释器的实现中,如何处理数学表达式一直是一个经典问题。我们希望能够像人一样理解“中缀表达式”(如 3 + 4 * 2),并正确考虑运算符优先级与括号。
1961 年,Edsger Dijkstra 提出了著名的 Shunting Yard 算法,它提供了一种机械化的方式来将中缀表达式转换为后缀表达式(RPN)或抽象语法树(AST)。算法的名字来源于铁路编组场:火车车厢通过在轨道之间来回调度实现排序,而在表达式处理中,我们通过两个栈来存储和调度操作数与操作符。想象一下你在脑子里计算 3 + 4 * 2 的过程:
- 你知道乘法优先级更高,所以要先算 4 * 2。
- 在此过程中,你会把前面的 3 和 + 临时“记在心里”。
- 等乘法结果出来,再把它和 3 相加。
Dijkstra 的洞见在于:这种人类计算时“临时记住某些东西再回来处理”的思维过程,其实可以用栈(stack)来模拟。就像铁路编组场会把火车车厢先临时停放在侧轨,再根据需要调度一样,算法通过把数字和运算符在不同的栈之间移动,来实现对运算顺序的控制。“Shunting Yard”(调度场算法)的名字正是来自这种铁路类比:
- 火车车厢通过在轨道间移动来完成排序;
- 数学表达式中的运算符和数字,也可以通过在栈之间移动来完成 正确的排序与计算。
Dijkstra 把我们人类零散、混乱的计算过程,抽象成了一个清晰、机械化的流程,让计算机也能按照同样的逻辑来处理算式。
Shunting Yard 算法的基本流程
Shunting Yard 算法通过维护两个栈来保证表达式按照正确的优先级和结合性进行解析:
-
初始化
建立两个空栈:
- 运算符栈(op_stack),用于临时存放尚未处理的运算符和括号;
- 值栈(val_stack),用于存放操作数以及已经构造好的部分子表达式。
-
逐一扫描输入 token
-
若 token 为数字或变量:直接压入 val_stack。
-
若 token 为运算符:
- 检查 op_stack 栈顶元素。
- 当且仅当栈顶运算符的优先级高于当前运算符,或优先级相等且为左结合时,将该栈顶运算符弹出,并结合 val_stack 中的两个操作数,合成一个新的子表达式,再压回 val_stack。
- 重复此过程,直到不满足条件为止,然后将当前运算符压入 op_stack。
-
若 token 为左括号:压入 op_stack,作为分界标记。
-
若 token 为右括号:不断从 op_stack 弹出运算符,并用 val_stack 顶部的操作数组合成子表达式,直到遇到左括号为止;左括号本身丢弃,不会进入 val_stack。
-
-
清空运算符栈
当所有 token 扫描完成后,若 op_stack 中仍有运算符,则依次弹出, 并与 val_stack 中的操作数组合成更大的表达式,直到运算符栈为空。
-
结束条件
最终,val_stack 中应只剩下一个元素,该元素即为完整的抽象语法树或后缀表达式。若栈内元素数量不为一,或存在未匹配的括号,则说明输入表达式存在错误。
演算示例
接下来我们以解析 (1 + 2) * (3 - 4) ^ 2 为例,来展示在读入 token 的过程中,两个栈是如何变化的,从而更好地理解 Shunting Yard 算法:
| 步骤 | 读入 token | 运算符栈(op_stack) | 值栈(val_stack) | 说明 |
|---|---|---|---|---|
| 1 | ( | [(] | [] | 左括号压入运算符栈 |
| 2 | 1 | [(] | [1] | 数字压入值栈 |
| 3 | + | [(, +] | [1] | 运算符压入运算符栈 |
| 4 | 2 | [(, +] | [1, 2] | 数字压入值栈 |
| 5 | ) | [] | [1 + 2] | 弹出直到左括号:1 与 2 结合为 1+2 |
| 6 | * | [*] | [1 + 2] | 运算符压入运算符栈 |
| 7 | ( | [*, (] | [1 + 2] | 左括号压入运算符栈 |
| 8 | 3 | [*, (] | [1 + 2, 3] | 数 字压入值栈 |
| 9 | - | [*, (, -] | [1 + 2, 3] | 运算符压入运算符栈 |
| 10 | 4 | [*, (, -] | [1 + 2, 3, 4] | 数字压入值栈 |
| 11 | ) | [*] | [1 + 2, 3 - 4] | 弹出直到左括号:3 与 4 结合为 3-4 |
| 12 | ^ | [*, ^] | [1 + 2, 3 - 4] | 幂运算符压入栈(右结合,不会触发弹出) |
| 13 | 2 | [*, ^] | [1 + 2, 3 - 4, 2] | 数字压入值栈 |
| 14 | 输入结束 | [] | [(1 + 2) * (3 - 4) ^ 2] | 清空运算符栈:先弹出 ^,结合 3-4 与 2;再弹出 *,结合 1+2 与结果 |
在这个例子中,有以下几点值得注意:
-
括号优先处理 在第一组括号
(1 + 2)中,运算符+被延迟存放在运算符栈中,直到遇到右括号才与 1 和 2 结合。第二组括号(3 - 4)的处理过程完全相同。 -
优先级的体现 当遇到
*时,它被压入运算符栈。但随后遇到幂运算符^时,由于^的优先级高于*,且是右结合,因此直接压栈,而不会触发*的弹出。 -
结合性的作用 幂运算符
^通常定义为右结合,这意味着表达式a ^ b ^ c会被解析为a ^ (b ^ c)。在本例中,(3-4) ^ 2保持这种结合方式,正确构建了子表达式。 -
最终结果 在输入结束后,运算符栈依次被清空,最终形成完整的表达式:
(1 + 2) * ((3 - 4) ^ 2)
在 MoonBit 中实现 Shunting Yard 算法
首先我们需要定义表达式和 token 的类型:
enum Expr {
(Int) -> Expr
Literal(Int
Int)
(String, Expr, Expr) -> Expr
BinExpr(String
String, enum Expr {
Literal(Int)
BinExpr(String, Expr, Expr)
} derive(Show)
Expr, enum Expr {
Literal(Int)
BinExpr(String, Expr, Expr)
} derive(Show)
Expr)
} derive(trait Show {
fn output(Self, &Logger) -> Unit = _
fn to_string(Self) -> String = _
}
Trait for types that can be converted to String
Show)
enum Token {
(Int) -> Token
Literal(Int
Int)
(String) -> Token
Op(String
String)
Token
LeftParen
Token
RightParen
} derive(trait Show {
fn output(Self, &Logger) -> Unit = _
fn to_string(Self) -> String = _
}
Trait for types that can be converted to String
Show)
我们可以利用 MoonBit 中的正则表达式匹配语法,快速的实现一个简单的 tokenizer:
pub fn fn tokenize(input : StringView) -> Array[Token] raise
tokenize(StringView
input : type StringView
StringView) -> type Array[T]
An Array is a collection of values that supports random access and can
grow in size.
Array[enum Token {
Literal(Int)
Op(String)
LeftParen
RightParen
} derive(Show)
Token] raise {
let Array[Unit]
tokens = []
for StringView
str = StringView
input {
lexmatch StringView
str {
"[0-9]+" as n, rest => {
tokens.push(Token::Literal(@strconv.parse_int(n)))
continue rest
}
Unit
"[\-+*/^]" as o, rest => {
tokens.push(Token::Op(o.to_string()))
continue StringView
rest
}
"\(", rest => {
tokens.push(Token::LeftParen)
continue Unit
rest
}
"\)", rest => {
tokens.push(Token::RightParen)
continue rest
}
"[ \n\r\t]+", rest => continue rest
"$", _ => break
_ => fail("Invalid input")
}
}
tokens
}
tokenize 函数的作用是把输入字符串分割成一系列 token:
- 匹配数字
[0-9]+,转换为 Token::Literal; - 匹配四则运算符和幂运算符
[-+*/^],转换为 Token::Op; - 匹配括号
(与),分别转换为 LeftParen 和 RightParen; - 匹配空格、换行等空白字符则直接跳过;
- 如果遇到不符合规则的字符,则报错。 通过 lexmatch 和正则表达式,整个分词过程既简洁又高效。
接下来我们定义一个全局的操作符表,用于存储操作符的优先级和结合性:
priv enum Associativity {
Associativity
Left
Associativity
Right
}
priv struct OpInfo {
Int
precedence : Int
Int
Associativity
associativity : enum Associativity {
Left
Right
}
Associativity
}
let Map[String, OpInfo]
op_table : struct Map[K, V] {
// private fields
}
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
test {
let map = { 3: "three", 8: "eight", 1: "one" }
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
}
Map[String
String, struct OpInfo {
precedence: Int
associativity: Associativity
}
OpInfo] = {
"+": { Int
precedence: 10, Associativity
associativity: Associativity
Left },
"-": { Int
precedence: 10, Associativity
associativity: Associativity
Left },
"*": { Int
precedence: 20, Associativity
associativity: Associativity
Left },
"/": { Int
precedence: 20, Associativity
associativity: Associativity
Left },
"^": { Int
precedence: 30, Associativity
associativity: Associativity
Right },
}
这里通过 op_table 定义了常见运算符的优先级与结合性:
+和-的优先级最低(10),是左结合;
-
*和/的优先级更高(20),同样是左结合; -
^(幂运算)的优先级最高(30),但它是右结合。
接下来我们定义一个辅助函数,用于判断在遇到一个新的运算符时,是否需要先处理(弹出)栈顶的运算符:
fn fn should_pop(top_op_info~ : OpInfo, incoming_op_info~ : OpInfo) -> Bool
should_pop(OpInfo
top_op_info~ : struct OpInfo {
precedence: Int
associativity: Associativity
}
OpInfo, OpInfo
incoming_op_info~ : struct OpInfo {
precedence: Int
associativity: Associativity
}
OpInfo) -> Bool
Bool {
OpInfo
top_op_info.Int
precedence fn Compare::op_gt(x : Int, y : Int) -> Bool
> OpInfo
incoming_op_info.Int
precedence (Bool, Bool) -> Bool
||
(
OpInfo
top_op_info.Int
precedence fn Eq::equal(self : Int, other : Int) -> Bool
Compares two integers for equality.
Parameters:
self : The first integer to compare.
other : The second integer to compare.
Returns true if both integers have the same value, false otherwise.
Example:
test {
inspect(42 == 42, content="true")
inspect(42 == -42, content="false")
}
== OpInfo
incoming_op_info.Int
precedence (Bool, Bool) -> Bool
&&
OpInfo
top_op_info.Associativity
associativity is Associativity
Left
)
}
should_pop 的逻辑是 Shunting Yard 算法的核心之一:
- 如果栈顶运算符的优先级高于新运算符,则应当先处理栈顶运算符;
- 如果两者优先级相等,并且栈顶运算符是左结合,同样应当先处理栈顶运算符;
- 否则,保留栈顶运算符,把新运算符直接压入栈。
接下来我们实现表达式的解析函数:
pub fn fn parse_expr(tokens : Array[Token]) -> Expr
parse_expr(Array[Token]
tokens : type Array[T]
An Array is a collection of values that supports random access and can
grow in size.
Array[enum Token {
Literal(Int)
Op(String)
LeftParen
RightParen
} derive(Show)
Token]) -> enum Expr {
Literal(Int)
BinExpr(String, Expr, Expr)
} derive(Show)
Expr {
let Array[String]
op_stack : type Array[T]
An Array is a collection of values that supports random access and can
grow in size.
Array[String
String] = []
let Array[Expr]
val_stack : type Array[T]
An Array is a collection of values that supports random access and can
grow in size.
Array[enum Expr {
Literal(Int)
BinExpr(String, Expr, Expr)
} derive(Show)
Expr] = []
fn (String) -> Unit
push_binary_expr(String
top_op) {
let Expr
right = Array[Expr]
val_stack.fn[T] Array::pop(self : Array[T]) -> T?
Removes the last element from an array and returns it, or None if it is empty.
Example
test {
let v = [1, 2, 3]
assert_eq(v.pop(), Some(3))
assert_eq(v, [1, 2])
}
pop().fn[X] Option::unwrap(self : X?) -> X
Extract the value in Some.
If the value is None, it throws a panic.
unwrap()
let Expr
left = Array[Expr]
val_stack.fn[T] Array::pop(self : Array[T]) -> T?
Removes the last element from an array and returns it, or None if it is empty.
Example
test {
let v = [1, 2, 3]
assert_eq(v.pop(), Some(3))
assert_eq(v, [1, 2])
}
pop().fn[X] Option::unwrap(self : X?) -> X
Extract the value in Some.
If the value is None, it throws a panic.
unwrap()
Array[Expr]
val_stack.fn[T] Array::push(self : Array[T], value : T) -> Unit
Adds an element to the end of the array.
If the array is at capacity, it will be reallocated.
Example
test {
let v = []
v.push(3)
}
push(Expr::(String, Expr, Expr) -> Expr
BinExpr(String
top_op, Expr
left, Expr
right))
}
for Token
token in Array[Token]
tokens {
match Token
token {
(Int) -> Token
Literal(Int
n) => Array[Expr]
val_stack.fn[T] Array::push(self : Array[T], value : T) -> Unit
Adds an element to the end of the array.
If the array is at capacity, it will be reallocated.
Example
test {
let v = []
v.push(3)
}
push(Expr::(Int) -> Expr
Literal(Int
n))
(String) -> Token
Op(String
incoming_op) => {
let OpInfo
incoming_op_info = let op_table : Map[String, OpInfo]
op_tablefn[K : Hash + Eq, V] Map::op_get(self : Map[K, V], key : K) -> V
Get value with at access semantics.
[incoming_op]
while true {
match Array[String]
op_stack.fn[A] Array::last(self : Array[A]) -> A?
Returns the last element of the array, or None if the array is empty.
Parameters:
array : The array to get the last element from.
Returns an optional value containing the last element of the array. The
result is None if the array is empty, or Some(x) where x is the last
element of the array.
Example:
test {
let arr = [1, 2, 3]
debug_inspect(arr.last(), content="Some(3)")
let empty : Array[Int] = []
debug_inspect(empty.last(), content="None")
}
last() {
String?
None => break
(String) -> String?
Some(String
top_op) =>
if String
top_op (x : String, y : String) -> Bool
!= "(" (Bool, Bool) -> Bool
&&
fn should_pop(top_op_info~ : OpInfo, incoming_op_info~ : OpInfo) -> Bool
should_pop(OpInfo
top_op_info=let op_table : Map[String, OpInfo]
op_tablefn[K : Hash + Eq, V] Map::op_get(self : Map[K, V], key : K) -> V
Get value with at access semantics.
[top_op], OpInfo
incoming_op_info~) {
Array[String]
op_stack.fn[T] Array::pop(self : Array[T]) -> T?
Removes the last element from an array and returns it, or None if it is empty.
Example
test {
let v = [1, 2, 3]
assert_eq(v.pop(), Some(3))
assert_eq(v, [1, 2])
}
pop() |> fn[T] ignore(t : T) -> Unit
Evaluates an expression and discards its result. This is useful when you want
to execute an expression for its side effects but don't care about its return
value, or when you want to explicitly indicate that a value is intentionally
unused.
Parameters:
value : The value to be ignored. Can be of any type.
Example:
test {
let x = 42
ignore(x) // Explicitly ignore the value
let mut sum = 0
ignore([1, 2, 3].iter().each(x => sum = sum + x)) // Ignore the Unit return value of each()
}
ignore
(String) -> Unit
push_binary_expr(String
top_op)
} else {
break
}
}
}
Array[String]
op_stack.fn[T] Array::push(self : Array[T], value : T) -> Unit
Adds an element to the end of the array.
If the array is at capacity, it will be reallocated.
Example
test {
let v = []
v.push(3)
}
push(String
incoming_op)
}
Token
LeftParen => Array[String]
op_stack.fn[T] Array::push(self : Array[T], value : T) -> Unit
Adds an element to the end of the array.
If the array is at capacity, it will be reallocated.
Example
test {
let v = []
v.push(3)
}
push("(")
Token
RightParen =>
while Array[String]
op_stack.fn[T] Array::pop(self : Array[T]) -> T?
Removes the last element from an array and returns it, or None if it is empty.
Example
test {
let v = [1, 2, 3]
assert_eq(v.pop(), Some(3))
assert_eq(v, [1, 2])
}
pop() is (String) -> String?
Some(String
top_op) {
if String
top_op (x : String, y : String) -> Bool
!= "(" {
(String) -> Unit
push_binary_expr(String
top_op)
} else {
break
}
}
}
}
while Array[String]
op_stack.fn[T] Array::pop(self : Array[T]) -> T?
Removes the last element from an array and returns it, or None if it is empty.
Example
test {
let v = [1, 2, 3]
assert_eq(v.pop(), Some(3))
assert_eq(v, [1, 2])
}
pop() is (String) -> String?
Some(String
top_op) {
(String) -> Unit
push_binary_expr(String
top_op)
}
Array[Expr]
val_stack.fn[T] Array::pop(self : Array[T]) -> T?
Removes the last element from an array and returns it, or None if it is empty.
Example
test {
let v = [1, 2, 3]
assert_eq(v.pop(), Some(3))
assert_eq(v, [1, 2])
}
pop().fn[X] Option::unwrap(self : X?) -> X
Extract the value in Some.
If the value is None, it throws a panic.
unwrap()
}
parse_expr 是整个 Shunting Yard 算法的核心实现:
-
数据结构准备
op_stack存储运算符和括号;val_stack存储操作数或部分构造好的子表达式;- 内部函数
push_binary_expr封装了一个小步骤:从值栈中弹出两个操作数,结合运算符,生成一个新的BinExpr节点,并压回值栈。
-
遍历 token
- 数字:直接压入
val_stack。 - 运算符:不断检查
op_stack栈顶的运算符,如果优先级更高或需要先计算,则弹出并构造子表达式;直到不满足条件时,把新运算符压入栈。 - 左括号:压入
op_stack,用于分隔子表达式。 - 右括号:持续弹出运算符,并结合值栈中的操作数形成子表达式,直到遇到匹配的左括号。
- 数字:直接压入