高级 #pl#semantics

操作语义(小步/大步)

操作语义(Operational Semantics)用"程序如何一步步执行"来定义语言的含义——小步语义一次一步,大步语义一次到最终结果

📖 什么叫”程序的含义”?

一个程序运行后会产生什么结果——这似乎不言自明。但精确地定义一个程序”是什么意思”,是编程语言理论的重要课题。

比如 x = x + 1——这句话”是什么意思”?不同语言可能有不同的理解:

  • 在 C 中:从内存读取 x,加 1,写回内存
  • 在 Haskell 中:x 没有被赋值(Haskell 没有赋值)——x 是一个定义
  • 在 SQL 中:= 是比较,不是赋值

操作语义(Operational Semantics) 就是用”程序如何一步步执行”来精确描述程序的含义的数学方法。

🍳 类比:菜谱 vs 做菜的过程

菜谱:西红柿炒蛋的”定义”——“西红柿两个,鸡蛋三个……”

操作语义:你实际做菜的过程——

  • 第 1 步:打三个鸡蛋到碗里
  • 第 2 步:搅拌
  • 第 3 步:切西红柿
  • ……

“程序的含义”不是看菜谱上的文字,而是看实际操作步骤会产生什么结果


👣 小步语义(Small-Step Semantics)——一步步来

小步语义描述程序”每一步”的变化——每次执行一个最原子的操作,然后进入下一个”状态”。

算术表达式的小步语义

(1 + 2) * (3 + 4)

第 1 步:先计算左边的 1+2
→ 3 * (3 + 4)

第 2 步:再计算右边的 3+4
→ 3 * 7

第 3 步:最后计算乘法
→ 21

用数学符号表示归约规则:

⟨n₁ + n₂, σ⟩ → ⟨n, σ⟩    如果 n = n₁ + n₂(两个数字相加)
⟨e₁ + e₂, σ⟩ → ⟨e₁' + e₂, σ⟩    如果 e₁ → e₁'(先归约左边)
⟨n₁ + e₂, σ⟩ → ⟨n₁ + e₂', σ⟩    如果 e₂ → e₂'(再归约右边)

这里 σ(sigma)表示状态——所有变量的当前值。

带变量的例子

# 考虑这段代码:
x = 1
y = x + 2

用状态 σ = {x → 1, y → ?} 表示:

执行过程:
⟨x + 2, {x → 1, y → ?}⟩     →   查找 x 在当前状态中的值
⟨1 + 2, {x → 1, y → ?}⟩     →   1+2 归约
⟨3, {x → 1, y → ?}⟩         →   结果 3,然后赋值给 y
{y → 3}                       →   更新状态

💡 小步语义的价值:你可以精确看到程序运行的”中间状态”——每一步发生了什么。这对于定义并发、异常处理等复杂特性特别有用。


🏃 大步语义(Big-Step Semantics)——直接到结果

大步语义不关心中间步骤——它直接把表达式”映射”到最终值:

⟨1+2, σ⟩ ⇓ 3
⟨3+4, σ⟩ ⇓ 7
──────────────────
⟨(1+2)*(3+4), σ⟩ ⇓ 21

横线上方是”前提”(子表达式的求值结果),横线下方是”结论”(整个表达式的求值结果)。

赋值语句的大步语义

⟨e, σ⟩ ⇓ v
──────────────────
⟨x = e, σ⟩ ⇓ σ[x → v]

意思是:如果表达式 e 在状态 σ 下求值得到 v,那么赋值语句 x = e 的结果是把状态更新为 σ[x → v](把 x 的值设为 v)。

if-else 的大步语义

⟨e, σ⟩ ⇓ true    ⟨s₁, σ⟩ ⇓ σ'
──────────────────────────────
⟨if e then s₁ else s₂, σ⟩ ⇓ σ'

⟨e, σ⟩ ⇓ false    ⟨s₂, σ⟩ ⇓ σ'
──────────────────────────────
⟨if e then s₁ else s₂, σ⟩ ⇓ σ'

📐 形式语法:大步语义的每条规则是一个”推理规则”——如果”前提”都成立,那么”结论”成立。读者不需要理解每个数学符号的细节,只需知道它在描述”输入什么 → 得到什么”的映射。


⚔️ 小步 vs 大步——对比

维度小步语义大步语义
粒度每次一步——细粒度直接到最终值——粗粒度
中间状态✅ 能描述❌ 跳过中间状态
非终止程序✅ 能处理(无限步骤)❌ 无法表示(永不终止)
并发语义✅ 自然支持(交错执行)❌ 难以定义
证明程序性质更复杂(步骤多)更简洁(步骤少)

什么时候用哪个?

小步语义 → 为了研究"程序怎么执行"、并发、非确定性
大步语义 → 为了证明"程序性质"、类型安全、编译器正确性

实际上很多语言的定义两者都用:
- 小步:定义操作的"顺序"
- 大步:定义表达式的"求值"

🧩 操作语义的实际应用

定义新语言

# 假设你要设计一种新的小语言
# 操作语义就是它的"精确说明书"

# Lilliput 语言的语义(简化版):

# 数值:⟨n, σ⟩ ⇓ n
# 加法:⟨e1, σ⟩ ⇓ v1, ⟨e2, σ⟩ ⇓ v2 → ⟨e1 + e2, σ⟩ ⇓ v1 + v2
# 变量:⟨x, σ⟩ ⇓ σ(x)          — 从状态中查找变量值
# 赋值:⟨e, σ⟩ ⇓ v → ⟨x = e, σ⟩ ⇓ σ[x → v]
# 序列:⟨s1, σ⟩ ⇓ σ', ⟨s2, σ'⟩ ⇓ σ'' → ⟨s1; s2, σ⟩ ⇓ σ''

证明类型安全

类型安全的证明通常使用操作语义:如果一个表达式”类型检查通过”,那么它的”操作语义执行不会卡住”(不会出现”类型错误”)。

类型安全定理:
如果 ⊢ e : τ(e 的类型为 τ)且 ⟨e, σ⟩ →* ⟨e', σ'⟩
那么要么 e' 是值,要么还有下一步 →*

通俗说:类型正确的程序不会"跑着跑着就炸了"

编译器正确性验证

编译器把高级语言翻译成机器码。怎么证明”翻译没有改变程序的含义”?

——用源语言和目标语言各自的操作语义,证明它们对同样的输入产生同样的结果。


📝 小结

概念一句话
操作语义用”程序怎么执行”来定义程序的含义
小步语义每次归约一步——描述执行过程的每个中间状态
大步语义从表达式直接到最终值——跳过中间步骤
状态(σ)变量的当前值——执行环境的数学表示
归约规则描述”表达式→新表达式”或”表达式⇓值”的规则
应用语言定义、类型安全证明、编译器验证

🎯 思考题:考虑两个程序——一个无限循环(while True: pass),一个执行完就终止。在小步语义和大步语义中,这两个程序的”含义”分别如何表示?

为什么先学这个? 操作语义是语言理论的”数学工具”。接下来看实际语言实现中的关键技术——垃圾回收机制