UCB-CS164-编程语言与编译器笔记-全-

UCB CS164 编程语言与编译器笔记(全)

1:什么是编译器?🚀

在本节课中,我们将要学习编译器的基本概念,并通过动手实践,构建一个极其简单的编译器。我们将从定义编译器开始,然后将其与解释器进行对比,最后实际编写一个能将数字转换为可执行机器代码的编译器。


编译器与解释器

首先,我们来探讨一个核心问题:什么是编译器?

简单来说,编译器是一个执行转换的工具。它将一种形式的程序(源程序)转换为另一种形式的程序(目标程序)。我们可以用以下类型签名来描述它:

编译器类型:source_program -> target_program

例如,编译器可以将高级语言(如C++)的源代码转换为低级的汇编语言或机器码。

上一节我们介绍了编译器的基本定义,本节中我们来看看它的“兄弟”——解释器。

解释器与编译器不同,它不产生新的程序,而是直接执行源程序并产生一个。其类型签名如下:

解释器类型:source_program -> value

例如,Python解释器读取你的Python脚本,并直接计算出结果(如一个数字或字符串)。

那么,编译器产生的目标程序如何最终变成我们想要的值呢?这需要一个最终的“解释器”来执行它。在大多数现代计算机系统中,这个最终的“解释器”就是你的处理器。处理器将机器码解释为具体的计算操作,从而产生最终结果。


为什么学习编译器?🤔

在学习如何构建编译器之前,了解其意义很重要。以下是学习编译器的一些主要原因:

  • 有趣且有挑战性:它像解谜一样,充满了智力上的挑战。
  • 理论与实践的结合:你将同时接触到计算机科学的理论(如形式语言、类型系统)和实际的系统构建技能。
  • 更好地理解日常工具:理解编译器的工作原理,能帮助你理解日常编程中遇到的晦涩错误信息从何而来。
  • 快速掌握新语言:实现编译器和解释器的技能,能极大地帮助你未来自学任何新的编程语言。

动手实践:构建第一个编译器 🔨

现在,让我们开始构建本学期的第一个编译器。为了让事情足够简单,我们将实现一个功能极其有限的“语言”:它只包含整数数字

这个编译器的任务是:将一个整数(如 164)转换为一小段x86-64汇编代码,这段代码能将该数字作为程序的返回值。

1. 理解运行时(Runtime)

在编写编译器之前,我们需要一个“运行时”环境来处理一些通用任务,比如打印输出。我们将使用一段简单的C程序作为运行时,它负责调用我们编译器生成的代码并打印结果。

以下是我们的C运行时核心部分(runtime.c):

#include <stdio.h>

// 声明一个外部函数 `entry`,它将由我们的编译器提供
extern int entry();

int main() {
    int result = entry(); // 调用编译器生成的代码
    printf("%d\n", result); // 打印结果
    return 0;
}

这段代码编译后,会等待与我们编译器生成的汇编代码链接。

2. 手写目标汇编代码

我们的编译器最终要生成汇编代码。对于输入数字 5000,我们需要生成类似下面的x86-64汇编代码(program.s):

global entry
entry:
    mov RAX, 5000  ; 将数字5000放入寄存器RAX(C运行时期望的返回值存放处)
    ret            ; 返回

将这段汇编代码与运行时链接并运行,程序就会输出 5000

3. 用OCaml实现编译器

显然,我们不能为每个数字都手写汇编。我们需要一个程序来自动完成这个转换。我们将使用OCaml语言来编写这个编译器。

以下是我们的第一个OCaml编译器(compile.ml)的核心:

(* 编译器函数:将源程序(字符串形式的数字)转换为目标程序(汇编字符串) *)
let compile (program : string) : string =
  let lines = [
    "global entry";
    "entry:";
    "    mov RAX, " ^ program;  (* 关键:将输入的数字字符串插入到汇编指令中 *)
    "    ret"
  ] in
  String.concat "\n" lines      (* 将字符串列表用换行符连接成一个字符串 *)

(* 辅助函数:将编译结果写入文件 *)
let compile_to_file (program : string) : unit =
  let out_channel = open_out "program.s" in
  output_string out_channel (compile program);
  close_out out_channel

(* 辅助函数:编译、汇编、链接并运行 *)
let compile_and_run (program : string) : unit =
  compile_to_file program;
  (* 以下命令调用外部工具进行汇编、链接和运行 *)
  let _ = Sys.command "nasm -f elf64 program.s -o program.o" in
  let _ = Sys.command "gcc -no-pie runtime.o program.o -o program" in
  let _ = Sys.command "./program" in
  ()

现在,调用 compile_and_run “164”,我们的编译器就会自动生成汇编代码,与运行时链接,并最终运行输出 164


活动:探索OCaml类型系统 🧪

为了更熟练地使用OCaml(我们本学期的主要实现语言),我们通过一个小活动来探索其类型系统。策略是:预测、运行、验证

以下是第一个代码片段:

let x = “164” in
match x with
| “164” -> print_string “I’m taking 164 now”
| “test” -> print_string “default test”
| _ -> print_string “default”

问题:如果在程序末尾分别加上 print_string xprint_endline xprint_int x,输出会是什么?

通过讨论和测试,我们验证了OCaml的静态类型检查特性:它会在编译时确保类型使用的一致性(例如,不能将字符串 x 传递给期望整数的 print_int 函数)。


课程总结 📚

本节课中我们一起学习了:

  1. 编译器的定义:它是一个将源程序转换为目标程序的工具。
  2. 解释器的定义:它是一个直接执行源程序并产生的工具。
  3. 两者的关系:编译器链的末端通常需要一个解释器(如处理器)来最终执行代码并产生结果。
  4. 动手实践:我们构建了一个最简单的编译器,它能将整数编译成可执行的汇编代码。
  5. OCaml初探:我们开始接触OCaml语言,并通过活动理解了其强大的静态类型系统,这能帮助我们在编写编译器时避免许多错误。

这只是编译器世界的入门。随着课程深入,我们将为我们的语言添加变量、函数、控制流等复杂特性,最终构建一个功能丰富的编译器。

2:反思活动与OCaml自定义类型 🧠

在本节课中,我们将首先反思学习编程语言和编译器的个人动机,然后深入探索OCaml编程语言,特别是其强大的模式匹配功能和自定义类型系统。我们将通过一系列实践活动来巩固这些概念。

课程概述与反思活动 🤔

上一节我们初步接触了OCaml。本节中,我们首先将花时间思考一个核心问题:我们为何在此学习编程语言与编译器?理解个人动机有助于将课程内容与长远目标联系起来。

编程语言和编译器不仅是计算机科学的基础,也是连接人类意图与机器执行的桥梁。掌握它们,意味着获得了设计和构建新工具的能力,从而让更多人能够利用计算解决实际问题。

现在,请花两分钟时间,静心思考以下问题,并简要记录你的想法:

  • 编程语言和编译器的知识如何与你未来两年、五年甚至更长远的目标相关联?
  • 你希望利用这些知识解决什么样的问题,或创造什么样的价值?

思考完毕后,请与你身边的同学进行简短交流。


回顾与深入:OCaml模式匹配 🔍

在上一节的活动中,我们学习了OCaml的match表达式和模式匹配。本节中,我们将通过一个具体的例子,来更深入地理解模式匹配的执行顺序和括号()在分组时的重要性。

请看以下代码片段,并预测A、B、C三处分别会输出什么:

let read_to_course prior current =
  match prior with
  | "CS61A" -> "ready"
  | _ -> match current with
         | "CS61B" -> "not sure"
         | _ -> "not sure about that either"

预测:

  • A. read_to_course "CS61A" "CS160" 输出:"ready"
  • B. read_to_course "CS70" "CS61B" 输出:"not sure"
  • C. read_to_course "CS70" "CS160" 输出:"not sure about that either"

然而,实际在OCaml中运行代码C时,却得到了"not sure"。这是为什么呢?

问题在于编译器的解析方式。对于编译器而言,代码的结构实际上是这样的:

let read_to_course prior current =
  match prior with
  | "CS61A" -> "ready"
  | _ -> match current with
         | "CS61B" -> "not sure"
  | _ -> "not sure about that either" (* 这个分支属于外层的match! *)

第二个通配符 _ 被错误地关联到了外层的match prior with,而不是内层的match current with。因此,当prior不是"CS61A"时,会直接匹配到第一个通配符_,其对应的分支是另一个match表达式,该表达式最终返回"not sure"

解决方案是使用括号()进行明确分组:

let read_to_course prior current =
  match prior with
  | "CS61A" -> "ready"
  | _ -> (match current with
         | "CS61B" -> "not sure"
         | _ -> "not sure about that either")

现在,整个内层的match表达式被括号包裹,作为一个整体返回。此时再运行,A、B、C的输出就符合我们最初的预测了。

核心要点:

  • OCaml不依赖缩进来决定代码结构,它依赖语法符号(如括号、分号)。
  • 当嵌套使用match时,使用括号()来明确表达式的分组范围是避免混淆的好习惯。
  • 编译器会按顺序尝试匹配match中的每个分支,并使用第一个匹配成功的分支。


探索OCaml自定义类型 🏗️

理解了基础的模式匹配后,本节我们来看看OCaml一个非常强大的特性:自定义类型。这允许我们根据问题领域创建专属的数据结构。

定义简单枚举类型

首先,我们定义一个表示布料的简单类型:

type fabric = Linen | Cotton | Wool
  • type 是定义新类型的关键字。
  • fabric 是我们新类型的名称(惯例用小写字母开头)。
  • LinenCottonWool 是这个类型的构造器。它们代表了fabric类型可能的值,类似于其他语言中的枚举值。

我们可以编写一个函数,根据布料类型计算每码价格:

let cost_per_yard f =
  match f with
  | Linen -> 15
  | Cotton -> 6
  | Wool -> 18

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs164-pl-cmpl/img/8988002a35f2508a39599753650b3add_26.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs164-pl-cmpl/img/8988002a35f2508a39599753650b3add_27.png)

let _ = cost_per_yard Linen (* 输出: 15 *)
let _ = cost_per_yard Cotton (* 输出: 6 *)
let _ = cost_per_yard Wool (* 输出: 18 *)

注意,函数cost_per_yard的参数f并没有显式标注类型(如f: fabric)。OCaml编译器非常智能,它通过观察match分支中匹配的构造器(LinenCottonWool),自动推断出f必须是fabric类型。

定义带有数据的构造器

构造器不仅可以标记类型,还可以携带额外的数据。例如,我们定义服装项目及其所需的布料码数:

type garment = Sample | MiniDress | MaxiDress | Suit of int * int

let yards_for g =
  match g with
  | Sample -> 1
  | MiniDress -> 4
  | MaxiDress -> 8
  | Suit (jacket_yards, pant_yards) -> jacket_yards + pant_yards

let _ = yards_for Sample (* 输出: 1 *)
let _ = yards_for (Suit (5, 4)) (* 输出: 9 *)
  • SampleMiniDressMaxiDress 是不带数据的构造器。
  • Suit of int * int 是一个带数据的构造器。它表示一个Suit值包含两个整数(夹克和裤子的码数)。*在这里用于组合类型,表示元组,而非乘法运算。

组合类型与完整示例

现在,我们将fabricgarment类型结合起来,计算制作一件服装的成本:

let total_cost (g: garment) (f: fabric) : int =
  (yards_for g) * (cost_per_yard f)

let summer_suit_cost = total_cost (Suit (5, 4)) Cotton (* 9码 * 6美元/码 = 54美元 *)
let winter_suit_cost = total_cost (Suit (6, 6)) Wool   (* 12码 * 18美元/码 = 216美元 *)

关于模式匹配完备性的重要提示:
当我们对一个自定义类型进行match时,OCaml会检查是否处理了该类型所有可能的构造器。如果处理了所有情况,编译器不会警告。例如,match f with 覆盖了LinenCottonWool,所以是完备的。
如果后续为fabric类型新增了构造器(如Silk),但未更新match表达式,编译器会发出“模式匹配不完整”的警告,提醒你可能遗漏了情况。这是一种强大的安全保障。


总结与核心收获 🎯

本节课中我们一起学习了以下内容:

  1. 学习动机反思:我们探讨了学习编程语言和编译器如何与个人长期目标及社会价值产生联系,认识到这门技术是赋能他人、创造新工具的关键。
  2. OCaml模式匹配的深入理解:我们通过一个陷阱示例,认识到括号()在明确表达式分组、控制match结构时的重要性。OCaml编译器按顺序匹配分支,且不依赖缩进。
  3. OCaml自定义类型:我们学会了使用 type 关键字定义新类型,并了解了两种构造器:
    • 简单构造器:如Linen,用于创建枚举值。
    • 带参数构造器:如Suit of int * int,用于创建包含数据的复合值。
  4. 类型推断与安全保障:OCaml能根据match中的构造器自动推断变量类型。同时,编译器会检查模式匹配的完备性,确保所有可能的情况都被处理,这大大增强了代码的健壮性。

通过定义贴合问题领域的类型,并利用模式匹配来解构它们,我们可以写出既安全又易于理解的OCaml代码。这是函数式编程范式的核心优势之一。

3:表达式与语句;不可变性基础 🧠

在本节课中,我们将学习编程语言中的两个核心概念:表达式与语句的区别,以及不可变性的基础知识。我们还将探讨为什么选择 OCaml 作为本课程的教学语言,并初步了解其函数式编程的特性。


为什么选择 OCaml?🤔

在开始核心内容之前,我们先了解一下选择 OCaml 作为课程语言的原因。这实际上与语言设计决策密切相关。当我们设计自己的语言时,会思考我们希望让哪些类型的程序易于编写,哪些类型难以编写,甚至完全禁止。OCaml 因其在编译器与解释器编写方面的优势而被广泛使用。

例如,Rust 语言的最初编译器就是用 OCaml 编写的。在编程语言和编译器领域,OCaml 常被视为编写编译器、解释器等工具的“领域特定语言”,因为它提供的抽象非常适合这类任务。此外,使用 OCaml 能让我们快速编写编译器和解释器,从而将精力集中在核心实现决策上,而非繁琐的样板代码。


回顾与热身:模式匹配实践 🔍

上一节我们介绍了自定义类型和模式匹配。现在,我们通过一个简单的活动来巩固理解。

以下是一个关于服装制作的程序片段,我们需要预测其输出:

type fabric = Linen | Cotton | Wool
type item = FabricSample of fabric | Dress of int * fabric | Suit of int * int * fabric

let cost_per_yard = function
  | Linen -> 10.0
  | Cotton -> 5.0
  | Wool -> 15.0

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs164-pl-cmpl/img/3f597cc09a12f90392d9f49f5cc5b682_7.png)

let yards_required = function
  | FabricSample _ -> 0.1
  | Dress (yards, _) -> float_of_int yards
  | Suit (jacket_yards, pants_yards, _) -> float_of_int (jacket_yards + pants_yards)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs164-pl-cmpl/img/3f597cc09a12f90392d9f49f5cc5b682_9.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs164-pl-cmpl/img/3f597cc09a12f90392d9f49f5cc5b682_10.png)

let fabric_budget item =
  match item with
  | Dress (_, Wool) -> print_endline "Why are we making a summer dress out of wool?"
  | Dress (_, _) -> print_endline "Nice winter dress sounds good"
  | Suit (_, _, Linen) -> print_endline "Linen suits aren't really in right now"
  | _ -> ()

let summer_dress = Dress (3, Cotton)
let winter_dress = Dress (3, Wool)
let linen_suit = Suit (2, 2, Linen)

let () =
  fabric_budget summer_dress; (* 第10行 *)
  fabric_budget winter_dress; (* 第11行 *)
  fabric_budget linen_suit    (* 第12行 *)

运行上述程序,第10、11、12行的输出分别是:

  • 第10行:"Nice winter dress sounds good"
  • 第11行:"Why are we making a summer dress out of wool?"
  • 第12行:"Linen suits aren't really in right now"

这个练习展示了模式匹配的强大之处:它不仅能检查值的结构,还能提取其中的数据并绑定到名称(如 yards),从而进行深入的条件判断和数据操作。


表达式 vs. 语句 📝

理解了基础模式后,我们进入本节课的核心概念:表达式与语句的区别。这对理解函数式编程至关重要。

表达式 是能求值为一个值的代码片段。例如 3 + 4"hello" 或一个函数调用 fabric_budget summer_dress

语句 则主要因其对世界产生的效果(副作用) 而重要,例如打印输出、写入文件或修改服务器状态。它可能不返回一个有用的值,或者返回一个表示“无值”的特殊值。

在 OCaml 中,有一个简洁的区分方式:

  • 如果一段代码的求值结果是 unit 类型(可以理解为“无返回值”或“空”),那么它就是一个语句
  • 如果求值结果是 unit 之外的任何其他类型,那么它就是一个表达式

OCaml 使用分号 ; 作为序列操作符,用于连接多个操作。它要求分号左侧的代码必须产生 unit 类型(即必须是一个语句),整个序列的值等于最右侧表达式的值。

(* 正确:左侧 print_endline 返回 unit,这是一个语句 *)
print_endline "Hello"; 5
(* 整个表达式的值是 5 *)

(* 错误:左侧 3 是一个表达式(返回 int),不是 unit 类型 *)
3; print_endline "World" (* 编译器会报错 *)

让我们在之前编写的编译器代码中看看实际应用:

let compile_and_run (program : string) : unit =
  let asm = compile program in
  let file = open_out "program.s" in
  output_string file asm;  (* 语句:写入文件,返回 unit *)
  close_out file;          (* 语句:关闭文件,返回 unit *)
  (* 调用外部命令运行程序,整个函数返回 unit *)
  let _ = Sys.command "gcc -o program program.s runtime.o && ./program" in
  ()

这里,output_stringclose_out 因其副作用(操作文件)而被使用,它们返回 unit,因此可以作为语句用分号连接。整个函数体是一系列由分号连接的语句,最终返回 unit

注意:用于列表的元素分隔符是分号 ;(如 [1; 2; 3]),而在交互式环境(UTop)中用于执行代码的是双分号 ;;。它们与作为序列操作符的分号含义不同,请不要混淆。


不可变性基础:Let 绑定 🔒

许多编程语言使用“变量”作为可以多次赋值的存储桶。OCaml 的默认核心语义是不可变的。我们使用 let ... = ... in ... 结构进行绑定,这更像是给一个值起一个别名或昵称,而不是将其放入一个可变的桶中。

let x = 2 in
let y = x + 2 in
print_int y (* 输出 4 *);
print_int x (* 输出 2 *)

在这个例子中,x 被绑定到值 2y 被绑定到表达式 x + 2 求值后的结果 4。这些绑定在它们的作用域(即 in 之后的部分)内有效。你不能“改变” x 的值,只能在其作用域内创建一个新的绑定。

如果尝试重新定义 x,实际上是在创建一个新的、可能在不同作用域内的绑定:

let x = 1 in
let print_x_first () = print_int x in (* 此函数捕获了 x = 1 *)
let x = 2 in                           (* 创建新的绑定,作用域从此处开始 *)
let print_x_second () = print_int x in (* 此函数捕获了 x = 2 *)
print_x_first (); (* 输出 1 *)
print_x_second () (* 输出 2 *)

OCaml 编译器会跟踪每个名称绑定在源代码中的位置,帮助我们理解程序。在顶层(不在任何函数或 in 内部)的 let 绑定,其作用域隐式地延伸到文件其余部分,这可以看作是 let ... = ... in (文件的其余部分) 的语法糖。

不可变性使得程序更容易推理,因为一旦一个名字被绑定,它的值在作用域内就不会改变。


OCaml 是静态类型语言 🏗️

OCaml 在编译时检查类型,而不是在运行时。这意味着许多错误可以在程序运行前就被发现。

let add_one (x : int) : int = x + 1

add_one 5     (* 正确 *)
add_one "hello" (* 编译错误:类型不匹配 *)

编译器会推断函数和表达式的类型。有时它会推断出多态类型,即适用于多种类型的通用类型。

let identity x = x
(* 类型为: 'a -> 'a (读作“alpha 到 alpha”)*)
(* 这意味着它可以接受任何类型的输入,并返回相同类型的输出 *)

identity 4        (* 正确,返回 int *)
identity "test"   (* 正确,返回 string *)

我们可以选择用类型标注来限制这种多态性:

let identity_int (x : int) : int = x (* 类型被限制为 int -> int *)
identity_int 4     (* 正确 *)
identity_int "test" (* 编译错误 *)

类型推断系统非常强大,我们将在课程后期深入探讨其原理。


列表操作快速浏览 📋

OCaml 提供了强大的列表处理高阶函数,这是函数式编程的常见模式。

以下是几个核心操作:

List.map:将一个函数应用到列表的每个元素上,生成一个新列表。

List.map (fun x -> x * 2) [1; 2; 3] (* 返回 [2; 4; 6] *)

List.filter:根据一个条件(谓词函数)过滤列表元素。

List.filter (fun x -> x < 3) [1; 2; 3; 4] (* 返回 [1; 2] *)

List.fold_left:用一个函数从左到右“折叠”或“累积”列表中的所有元素,最终得到一个单一值。

List.fold_left (fun acc x -> acc + x) 0 [1; 2; 3] (* 计算 0+1+2+3,返回 6 *)
(* 过程: (((0 + 1) + 2) + 3) *)

这些函数鼓励我们以声明式的方式思考数据转换,而不是使用循环和可变状态。


总结 🎯

本节课我们一起学习了以下核心内容:

  1. 表达式与语句:表达式求值为一个值,而语句因其副作用而重要。在 OCaml 中,可通过是否返回 unit 类型来区分。
  2. 不可变性:OCaml 默认使用不可变绑定。let 关键字用于将名字绑定到一个值,这个名字在其作用域内代表一个固定的值。
  3. 静态类型:OCaml 在编译时进行类型检查,支持强大的类型推断和多态类型。
  4. 函数式风格:通过列表操作函数(如 mapfilterfold)初步体验了以表达式和转换为中心的函数式编程风格。

理解表达式与不可变性是深入 OCaml 和函数式编程思维的关键第一步。在接下来的课程中,我们将利用这些概念来设计和实现我们自己的语言特性。

4:S表达式与一元运算

在本节课中,我们将学习编译器的核心组件,并深入探讨语法与语义的区别。我们将引入S表达式作为程序的结构化表示,并开始为我们的语言添加一元运算功能。

编译器概览

上一节我们介绍了解释器与编译器的区别。本节中,我们来看看编译器的具体组成部分。

一个典型的编译器流程包含以下主要阶段:

  • 前端:负责处理源代码的语法。
    • 词法分析器:将源代码字符串转换为一系列有意义的词元
    • 语法分析器:将词元序列转换为结构化的程序表示,即抽象语法树,并检查语法是否正确。
  • 后端:负责生成可执行代码。
    • 代码生成:将AST转换为目标平台的指令(如汇编代码)。这是编译器中最具挑战性的部分。
    • 优化:在代码生成前后,对程序表示或生成的指令进行转换,以提高运行效率或降低内存消耗。现代编译器通常包含多个优化阶段。
  • 汇编与链接:将汇编代码转换为机器码(0和1),并将多个模块链接成最终的可执行文件。这个过程基本上是机械的。

对于本课程,我们首先将重点放在代码生成上,之后会探讨词法分析、语法分析,最后再讨论优化。

语法与语义

考虑以下两个程序:

(if x y z)
if (x, y, z)

这两个程序使用了不同的语法(书写形式),但如果它们执行相同的操作,则具有相同的语义(行为含义)。编译器前端的工作就是处理不同的语法,将其转换为统一的AST表示,这样后端就可以专注于程序的语义。

引入S表达式

为了更轻松地操作程序结构,我们将使用S表达式作为我们语言的输入格式。S表达式由三种基本元素构成:

  • 数字字面量:例如 150
  • 符号:例如 ifadd1x
  • 列表:由括号包围的元素序列,可以嵌套,例如 (add1 50)(if x y z)

在OCaml中,我们可以定义一个递归类型来方便地表示S表达式:

type sx = Symbol of string | Number of int | List of sx list

有了这个类型,程序 (if x y z) 就可以表示为 List [Symbol "if"; Symbol "x"; Symbol "y"; Symbol "z"]

这种结构化的表示使我们能够轻松地编写函数来分析和处理程序。例如,我们可以编写一个函数来计算程序中所有数字的总和,或者检查程序中是否包含 if 表达式。

扩展编译器以处理S表达式

目前,我们的编译器只能处理单个数字作为输入程序。现在,我们要让它接受S表达式,并开始支持 add1sub1 这样的一元运算。

首先,我们修改 compile 函数,使其接受 sx 类型而非字符串,并添加模式匹配来处理不同的程序结构:

let compile (program : sx) : string =
  match program with
  | Number n -> ... (* 生成将数字n放入RAX的汇编代码 *)
  | _ -> raise (BadExpression program) (* 暂时无法处理其他情况 *)

我们使用一个辅助的解析函数将输入的字符串转换为 sx 类型。

设计一元运算的代码生成

对于程序 (add1 50),一个简单的想法是直接在编译时计算结果,生成将 51 放入RAX的代码。然而,这种方法缺乏通用性。一旦程序包含用户输入或更复杂的表达式,如 (add1 (add1 50)),我们就无法在编译时得知结果。

因此,我们需要生成能在运行时执行计算的汇编代码。这意味着我们的编译函数需要能够递归地处理子表达式。

我们将引入新的汇编指令:

  • add:加法指令
  • sub:减法指令

同时,为了更清晰地构建汇编代码,我们使用一个辅助库来将汇编指令的抽象表示转换为字符串,并利用OCaml的管道操作符 |>部分应用来使代码更简洁、更易读。

重构代码生成器

为了支持递归处理表达式,我们重构代码生成器。我们创建一个核心函数 compile_expr,它专门为给定的S表达式生成指令列表。然后,一个包装函数 compile 负责添加全局入口标签等样板代码,并调用 compile_expr

核心函数的结构如下:

let rec compile_expr (e : sx) : directive list =
  match e with
  | Number n -> [Mov (Reg Rax, Imm n)] (* 将数字n移入RAX *)
  | List [Symbol "add1"; arg] ->
      compile_expr arg @ [Add (Reg Rax, Imm 1)] (* 先计算参数,然后加1 *)
  | List [Symbol "sub1"; arg] ->
      compile_expr arg @ [Sub (Reg Rax, Imm 1)] (* 先计算参数,然后减1 *)
  | _ -> raise (BadExpression e)

通过这种递归结构,我们可以处理任意嵌套的一元运算表达式。

总结

本节课中,我们一起学习了编译器的核心流程,区分了语法与语义。我们引入了S表达式作为程序的结构化表示,并开始为我们的语言添加 add1sub1 一元运算。通过重构代码生成器,我们实现了递归处理表达式的能力,为后续添加更复杂的语言特性打下了基础。下一节课,我们将继续扩展语言的功能。

5:编译器正确性;布尔值(解释器)🎓

在本节课中,我们将要学习如何完成一元运算的实现,探讨编译时与运行时的区别,比较编译器与解释器的异同,并学习如何验证编译器的正确性。最后,我们将开始为我们的语言添加布尔值类型。


完成一元运算 🛠️

上一节我们讨论了如何实现一元运算,但尚未完成。我们决定,一个令人满意的实现不应在编译时预先计算所有结果,因为程序可能需要处理运行时输入(例如用户输入或文件读取)。因此,我们需要生成能够在运行时执行计算的汇编代码。

以下是我们为目标程序 (add1 50) 生成的汇编代码示例:

global entry
entry:
    mov RAX, 50
    add RAX, 1
    ret

运行这段汇编时,处理器会依次执行指令:首先将值 50 移入寄存器 RAX,然后将其加 1,最后返回结果 51

为了在编译器中实现这一点,我们重构了代码,将公共的汇编样板代码提取到一个辅助函数中。核心的 compile_expr 函数现在专注于为特定表达式生成将结果放入 RAX 的汇编指令。

对于 add1 表达式,我们的实现思路是:首先递归编译其参数,确保参数求值结果在 RAX 中,然后生成一条 add RAX, 1 指令。

let rec compile_expr (e : sx) : directive list =
  match e with
  | Num n -> [Mov (Reg Rax, Imm n)]
  | Lst [Sym "add1"; arg] ->
      compile_expr arg @ [Add (Reg Rax, Imm 1)]
  | Lst [Sym "sub1"; arg] ->
      compile_expr arg @ [Sub (Reg Rax, Imm 1)]
  | _ -> raise (Stuck e)

sub1 的实现与之类似。这样,我们的编译器就能处理嵌套的一元运算表达式了。


编译时 vs. 运行时 ⏳

在深入之前,我们需要明确“编译时”和“运行时”这两个核心概念。

  • 编译时:指编译程序的过程发生的时间。此时,源代码被转换为另一种形式(如汇编代码)。
  • 运行时:指编译后的程序实际在处理器上执行的时间。
    我们也可以用 静态动态 来分别描述编译时和运行时发生的事。

让我们通过一个图表来梳理使用编译器时的完整流程:

  1. 编译过程(编译时):我们的OCaml编译器 compile.ml 读取用我们自定义语言编写的程序,并生成对应的汇编代码文件(如 program.s)。
  2. 生成机器码(编译时):汇编器将汇编代码 program.s 转换为机器码(0和1)。
  3. 程序执行(运行时):处理器执行生成的机器码,最终产生结果(如 42)。

在这个过程中,一旦生成了机器码,即使丢弃原始的编译器,程序依然可以独立运行。


编译器 vs. 解释器 🔄

接下来,我们比较编译器和解释器。它们的核心区别在于如何将源程序转化为最终结果。

编译器 的类型可以看作是:源程序 -> 目标程序。它将一种语言编写的程序转换为另一种语言(通常是更低级的语言,如汇编)的程序。
解释器 的类型则是:源程序 -> 值。它直接分析并执行源程序,即时计算出结果。

对于解释器,其实现本身(例如 interp.ml)也是一个OCaml程序。当我们要运行解释器时,OCaml编译器会将其编译成机器码。因此,解释器运行时,实际上是OCaml生成的机器码在处理器上执行,由这些机器码来“解释”执行我们的自定义语言程序。解释器不需要针对特定硬件(如x86)生成代码,这个任务由底层的OCaml编译器完成。

选择编译器还是解释器,通常涉及权衡:

  • 编译器 通常能提供更好的运行时性能和对底层细节的控制,但实现和正确性论证可能更复杂。
  • 解释器 通常更易于实现、理解和调试,是快速原型设计和参考实现的理想选择。

在本课程中,我们将同时实现编译器和解释器,以便于对比和验证。


验证编译器正确性 ✅

如何让我们确信编译器是正确的?仅仅运行几个测试用例(如 (add1 (sub1 43)))是远远不够的。编译器或解释器中的错误可能会影响所有用该语言编写的程序,后果严重。

我们探讨了几种策略:

  1. 测试:编写大量单元测试和集成测试,比较编译器输出与预期结果。
  2. 形式化验证:使用数学证明和专用工具(如Coq)来机器检验编译器的正确性。例如,CompCert是一个经过形式化验证的C编译器。
  3. 参考实现与差分测试:实现一个我们高度信任的、简单直观的参考解释器。然后,对大量输入程序,同时运行编译器和参考解释器,并比较它们的输出结果是否一致。这就是差分测试。

我们将采用 差分测试 作为主要方法。为此,我们需要先构建一个易于推理的参考解释器。


实现参考解释器 📝

参考解释器的结构应与编译器类似,但它的目标是直接计算表达式的值,而不是生成汇编。

我们首先定义一个新的类型 value 来表示语言中的值。目前只有整数:

type value = Number of int

解释器的核心函数 interp_expr 接收 sx 表达式并返回 value

let rec interp_expr (e : sx) : value =
  match e with
  | Num n -> Number n
  | Lst [Sym "add1"; arg] ->
      let evaluated_arg = interp_expr arg in
      (match evaluated_arg with
       | Number n -> Number (n + 1)
       | _ -> raise (Stuck e))
  | Lst [Sym "sub1"; arg] ->
      let evaluated_arg = interp_expr arg in
      (match evaluated_arg with
       | Number n -> Number (n - 1)
       | _ -> raise (Stuck e))
  | _ -> raise (Stuck e)

解释器递归地求值子表达式,并进行相应的运算。我们可以编写一个简单的差分测试函数:

let diff_test (examples : string list) : bool =
  List.for_all (fun ex ->
    let compiled_result = compile_and_run ex in
    let interpreted_result = interp_program ex in
    compiled_result = interpreted_result
  ) examples

如果对于所有测试用例,编译器和解释器的结果都一致,我们就增加了对编译器正确性的信心。


引入布尔值 🔘

目前我们的语言只处理整数。现在,我们将添加新的基本类型:布尔值truefalse)。

在许多语言中,其他值(如0)也可能在条件判断中被视为“假”。但在Scheme/Lisp风格中,只有字面量 false 是假,其他所有值(包括 true、数字等)在布尔上下文中都被视为真。我们将遵循这个设计。

这意味着我们需要能够判断一个值在运行时是整数还是布尔值。这引出了 静态类型动态类型 语言的区别:

  • 静态类型语言(如OCaml):类型在编译时已知。编译器可以检查类型错误,程序中通常不需要运行时类型查询。
  • 动态类型语言(如我们正在实现的语言):类型在运行时才能确定。因此,语言需要提供运行时类型检查操作,例如 number?

因此,我们将在语言中添加诸如 (number? e)(boolean? e) 的原语,它们需要在运行时通过生成的汇编代码来检查值的类型。


在解释器中扩展布尔值 🚧

为了支持布尔值,我们首先需要扩展 value 类型:

type value = Number of int | Boolean of bool

然后,我们需要修改解释器来处理布尔字面量和类型判断操作。例如:

  • true 应解释为 Boolean true
  • (number? v) 应先解释 v,然后检查结果是否是 Number _
  • 对于 add1 等运算,我们需要添加类型检查,确保操作数确实是数字。

由于时间关系,我们将在下一节课开始时代码实现这部分内容,并随后将其加入编译器。


总结 📚

本节课中我们一起学习了:

  1. 完成了对 add1sub1 等一元运算的编译器实现。
  2. 明确了 编译时(静态)与 运行时(动态)的核心区别。
  3. 对比了 编译器(转换程序)和 解释器(直接求值)的不同角色与实现思路。
  4. 探讨了通过 差分测试 对比参考解释器来验证编译器正确性的方法,并实现了简单的参考解释器框架。
  5. 引入了 布尔值 类型,并讨论了在 动态类型语言 中实现运行时类型检查的必要性。

下一节课,我们将首先完善解释器对布尔值的支持,然后着手在编译器中实现它们。

6:布尔值(编译器)🔧

在本节课中,我们将学习如何在编译器中添加对布尔值的支持。我们将探讨编译时与运行时的区别,并了解如何为新的数据类型设计运行时表示。

概述

上一节我们介绍了如何在解释器中添加布尔值。本节中,我们将看看如何在编译器中实现相同的功能。这涉及到改变数值在运行时的表示方式,以便为布尔值留出空间。

静态类型与动态类型

首先,回顾一下静态类型语言和动态类型语言的区别。静态类型意味着在编译时就能知道类型信息,例如 OCaml。动态类型意味着类型信息只能在运行时确定,例如我们正在实现的小型 Scheme 语言。

核心概念

  • 静态类型:类型检查在编译时进行。
  • 动态类型:类型检查在运行时进行。

解释器中的布尔值

在解释器中,我们引入了 value 类型,它可以表示整数或布尔值。

type value = Number of int | Boolean of bool

然后,我们更新了 interp 函数,使其返回 value 类型,并实现了 notzero?number? 等操作。

编译器中的挑战

现在,我们需要在编译器中支持布尔值。编译器的任务是生成汇编指令,这些指令在运行时执行,最终将结果值放入寄存器 RAX 中。

问题在于,RAX 是一个 64 位寄存器,之前只用于存放整数的二进制表示。为了同时表示整数和布尔值,我们需要设计一种编码方案,使得通过检查 RAX 中特定的位(标签),就能区分出它当前存放的是哪种类型的值。

设计运行时表示

我们决定采用以下方案:

  • 用最后两位为 00 的 64 位数来表示整数。这意味着所有整数的运行时表示都是 4 的倍数(左移两位的结果)。
  • 剩余的三种位模式(01, 10, 11)可以用来表示其他类型。我们选择其中一种来代表布尔值

具体来说,我们为布尔值选择一个 7 位的标签 0000011(实际是 0b0000011)。那么:

  • true 的运行时表示1 << 7 | bool_tag (即 1 左移 7 位后,在最后 7 位贴上布尔标签)
  • false 的运行时表示0 << 7 | bool_tag (即 0 左移 7 位后,在最后 7 位贴上布尔标签)

核心公式

  • 整数运行时值 = 原始整数值 << 2
  • 布尔值运行时值 = (10) << 7 | bool_tag

修改编译器

以下是需要在编译器中进行的核心修改:

首先,定义常量和标签。

let num_shift = 2
let num_mask = 0b11
let num_tag = 0b00

let bool_shift = 7
let bool_mask = 0b1111111
let bool_tag = 0b0000011

然后,修改代码生成部分。对于字面量整数,生成其运行时表示。

match sexp with
| Number n -> [ Mov (Reg Rax, Imm (n lsl num_shift)) ]
| Symbol "true" -> [ Mov (Reg Rax, Imm ((1 lsl bool_shift) lor bool_tag)) ]
| Symbol "false" -> [ Mov (Reg Rax, Imm ((0 lsl bool_shift) lor bool_tag)) ]
...

注意,这里的左移操作 lsl 是在 编译时 由 OCaml 执行的,计算结果是一个立即数,直接写入生成的汇编指令中。

修改运行时系统

编译器生成的汇编代码需要一个运行时系统来执行并打印结果。这个运行时系统(通常用 C 编写)需要能识别新的表示法。

以下是运行时系统中打印值的逻辑伪代码:

void print_value(int64_t val) {
    if ((val & num_mask) == num_tag) {
        // 是整数:右移回原始值
        printf("%ld", val >> num_shift);
    } else if ((val & bool_mask) == bool_tag) {
        // 是布尔值:检查高位是1还是0
        if ((val >> bool_shift) & 1) {
            printf("true");
        } else {
            printf("false");
        }
    } else {
        // 未知标签,说明编译器实现有误
        fprintf(stderr, "Unknown value tag\n");
        exit(1);
    }
}

总结

本节课中我们一起学习了:

  1. 区分编译时与运行时:编译器在编译时工作,生成代码;运行时是生成代码实际执行的阶段。
  2. 设计类型表示:为了在低级机器表示中支持多种类型,我们使用标签对值进行编码,通过检查特定的位来区分类型。
  3. 实现编译器支持:我们修改了编译器,使其能为布尔字面量生成正确的、带有标签的运行时表示。
  4. 更新运行时系统:我们确保了运行时系统能够识别并正确打印这种新的、带有标签的值表示。

通过本节,我们为简单的动态类型语言实现了多类型支持的基础框架,这是构建更复杂语言特性的重要一步。

7:标签活动与条件语句 🏷️

在本节课中,我们将学习如何为自定义类型(如“独角兽”)应用和检查标签,深入理解标签选择背后的约束,并开始探索条件语句的实现,包括标志位和跳转指令的使用。

标签活动:应用与检查 🧩

上一节我们讨论了布尔值和数字的标签机制。本节中,我们来看看如何为一个虚构的类型“独角兽”应用和检查标签。

应用“独角兽”标签

以下是应用标签的步骤:

  1. 将原始值左移,为标签位留出空间。对于“独角兽”标签,其 unicorn_shift 为 3,因此需要左移 3 位。
  2. 将移位后的值与“独角兽”标签(假设为二进制 101)进行按位或(OR)操作,将标签附加到值的末尾。

公式表示
tagged_value = (original_value << shift_amount) | tag

注意:左移可能导致高位比特溢出丢失,这对于数字类型尤其需要注意。

检查“独角兽”标签

以下是检查标签的步骤:

  1. 使用掩码(mask)将值中除标签位之外的所有比特置零。对于“独角兽”标签,掩码是 111(二进制)。
  2. 将掩码处理后的结果与“独角兽”标签进行比较。如果相等,则原始值具有“独角兽”类型。

公式表示
is_unicorn = (value & mask) == tag

标签选择约束 🤔

我们了解了如何操作标签。现在,让我们思考一下如何为语言中的不同类型选择具体的标签值。

假设我们决定数字(num)的标签是单个比特 0。同时,我们需要为字符串、函数、对(pair)和向量(vector)分配标签,且这些标签长度不能超过3比特。

核心约束:任何类型的标签都不能是另一个类型标签的后缀,否则在运行时将无法区分它们。例如,如果数字标签是 0,那么任何以 0 结尾的标签(如 10)都会与数字混淆。

经过分析,在数字标签为 0 的约束下,我们无法为布尔类型找到一个唯一的、长度不超过3比特且不与任何其他类型标签冲突的标签。这迫使我们重新考虑设计。

解决方案:将数字标签改为两个比特 00。这样,我们就可以为其他类型分配唯一的3比特标签(例如 001 给字符串,010 给函数等),并为布尔值分配一个独特的标签(如 000001)。

条件语句与跳转 🔀

我们掌握了标签系统。接下来,我们将开始实现编程语言中一个关键的控制流结构:条件语句(if)。

标志位(Flags)与新指令

为了实现条件判断,我们需要引入两个新的汇编指令:cmp(比较)和 setz(根据零标志位设置)。

  • cmp x, y:比较寄存器 xy 中的值。如果相等,则将零标志位(ZF)设置为1;否则设置为0。
  • setz reg*:如果 ZF 标志位为1,则将寄存器 reg 的最低字节设置为1;否则设置为0。

关键点:标志位(如 ZF)是处理器的一种特殊状态,由某些指令(如 cmp)设置,并能影响后续指令(如 setz)的行为。

实现 not 操作

让我们以 not 操作为例,看看如何使用这些指令。编译 not true 的流程如下:

  1. true 的运行时表示(如 ...00001)移入 RAX
  2. 使用 cmp RAX, false 的表示进行比较。
  3. 由于 true 不等于 false,ZF 被设为0。
  4. RAX 清零(mov RAX, 0),为写入结果做准备。
  5. 执行 setz RAX*。因为 ZF=0,所以 RAX 最低字节被设为0。
  6. RAX 左移7位,为布尔标签留出空间。
  7. 与布尔标签(如 00000101)进行按位或操作,得到 false 的最终表示。

实现 zero?number?

zero?number? 操作的实现思路类似:

  • zero?:将参数与数字 0 的表示进行比较,然后利用 ZF 标志位生成布尔结果。
  • number?:先将参数与数字掩码(...11)进行按位与操作,以提取其标签位;然后将结果与数字标签(00)比较,再利用 ZF 生成布尔结果。

我们创建了一个名为 zf_to_bool 的指令序列来封装将 ZF 标志位转换为带标签布尔值的通用步骤。

引入跳转(Jumps)

目前我们生成的代码都是“直线型”的。为了实现条件语句中“执行不同分支”的效果,我们需要跳转指令。

  • jz label:如果 ZF 标志位为1,则跳转到指定的 label 处继续执行。
  • jmp label:无条件跳转到指定的 label 处。

标签(label)是汇编代码中的位置标记。通过结合 cmpjzjmp,我们可以让程序根据运行时条件跳过某些代码段,从而实现 if 表达式的语义。

在解释器中实现 if

在将 if 加入编译器之前,我们可以先在解释器中实现它,这相对简单,因为可以直接利用 OCaml 自身的 if 表达式。

解释 if 表达式的逻辑是:

  1. 首先解释条件测试表达式。
  2. 如果测试结果严格等于布尔值 false,则解释 else 分支。
  3. 否则(包括测试结果为 true 或其他任何非 false 值),解释 then 分支。

总结 📚

本节课中我们一起学习了:

  1. 标签的实践:通过“独角兽”类型的例子,回顾了为值应用标签以及检查标签的具体步骤。
  2. 标签设计:理解了标签选择需要避免后缀冲突,并通过调整数字标签长度解决了布尔值标签的分配问题。
  3. 条件语句基础:引入了标志位(ZF)和 cmpsetz 指令,并利用它们实现了 notzero?number? 操作。
  4. 控制流:介绍了跳转指令 jzjmp,它们是实现 if 条件分支的关键。
  5. 解释器扩展:在解释器中初步实现了 if 表达式的逻辑。

下节课,我们将把这些知识结合起来,在编译器中实现完整的 if 条件表达式。

8:二元运算

在本节课中,我们将学习如何为二元运算(如加法、减法、比较等)实现编译。我们将从回顾条件表达式(if)的实现开始,然后深入探讨如何处理涉及多个子表达式的运算,并引入栈(Stack)的概念来管理中间值。


回顾:条件表达式(if)的实现

上一节我们介绍了如何使用汇编指令 JZJMP 来实现条件表达式。本节中,我们来看看如何生成唯一的标签以避免冲突。

为了实现 if 表达式,我们使用以下汇编结构:

  1. 计算测试(test)表达式,结果存入 RAX
  2. RAX 中的值与表示 false 的运行时值进行比较。
  3. 如果相等(即测试结果为假),使用 JZ 指令跳转到 else 分支的标签。
  4. 否则,顺序执行 then 分支的代码,执行完毕后用 JMP 指令跳转到 continue 标签,跳过 else 分支。
  5. 定义 else 标签及其对应的代码,最后是 continue 标签。

为了避免在程序中多次使用 if 时产生重复的 else 标签,我们需要生成唯一的标签名。这可以通过一个带有内部计数器的函数来实现。

以下是生成唯一标签的辅助函数示例:

let gensym =
  let counter = ref 0 in
  fun base_name ->
    let current = !counter in
    counter := current + 1;
    base_name ^ "__" ^ string_of_int current

这个函数使用了 OCaml 的引用(ref)来创建可变状态。每次调用 gensym "else" 都会返回一个像 "else__0""else__1" 这样递增的唯一字符串。


引入二元运算

现在,让我们转向本节课的核心:二元运算。我们的语言将支持加法(+)、减法(-)、相等比较(=)和小于比较(<)。

在解释器(Interpreter)中,我们需要递归地求值两个子表达式,然后根据运算符执行相应的操作。以下是处理加法的示例:

| Plus (e1, e2) ->
    match (interp e1, interp e2) with
    | (Num n1, Num n2) -> Num (n1 + n2)
    | _ -> raise BadExpression

对于 =<,我们返回布尔值;对于 +-,我们返回数字。


编译二元运算的挑战

在编译器(Compiler)中,我们面临一个新挑战:如何管理多个子表达式求值产生的中间结果?

简单的想法是依次编译 e1e2

compile_expr e1;
compile_expr e2

compile_expr 的设计总是将结果放入 RAX 寄存器。这意味着编译 e2 时会覆盖 e1 的结果。

一个初步的改进是使用另一个寄存器(如 R8)来暂存 e1 的结果:

compile_expr e1;
Mov (R8, RAX);
compile_expr e2;
Add (RAX, R8)

然而,对于嵌套的表达式(如 (1 + 2) + 3),这种方法仍然会失败,因为我们需要保存的中间值数量可能超过可用寄存器的数量。


解决方案:使用栈

为了解决这个问题,我们引入栈(Stack)来存储中间结果。栈是内存中的一个区域,我们可以按需分配空间来保存数据。

在我们的模型中:

  • 栈从高内存地址向低内存地址增长。
  • 寄存器 RSP 指向栈的当前“顶部”(实际上是可用空间的最低地址)。
  • 我们通过形如 [RSP - 8][RSP - 16] 的地址来访问栈上的位置(每次偏移 8 字节,对应一个 64 位值)。

关键思想是:在编译时跟踪下一个可用的栈位置(栈索引)。每当我们计算一个子表达式并需要保存其值时,就将其存储到当前栈索引指向的位置,然后将栈索引递减(例如,-8 -> -16),为下一个值腾出空间。

因此,compile_expr 函数需要增加一个参数 stack_index,用来指示当前可用的栈槽偏移量。

以下是编译加法时使用栈的概览:

let rec compile_expr expr stack_index =
  match expr with
  | Plus (e1, e2) ->
      (* 编译左子表达式 e1,结果在 RAX *)
      compile_expr e1 stack_index;
      (* 将 RAX 中的值保存到当前栈槽 *)
      Mov (MemOffset (Rsp, stack_index), RAX);
      (* 编译右子表达式 e2,使用下一个栈槽 (stack_index - 8) *)
      compile_expr e2 (stack_index - 8);
      (* 将之前保存的左值加载到 R8 *)
      Mov (R8, MemOffset (Rsp, stack_index));
      (* 执行加法: RAX = RAX (右值) + R8 (左值) *)
      Add (RAX, R8)
  | ... (* 其他情况 *)

对于减法,只需将最后的 Add 改为 Sub

对于比较运算(=<),流程类似,但最后使用 Cmp 指令比较 R8RAX 中的值,然后根据相应的标志位(ZF 或 LF)设置布尔值到 RAX


当前实现的局限性

需要注意的是,我们目前的编译器实现没有进行运行时类型检查。解释器在遇到 (true + 1) 这样的表达式时会抛出错误,但我们的编译器会生成汇编代码并产生无意义的运行时结果(可能是一个被解释为数字的布尔值标签)。确保类型安全是后续课程需要解决的问题。


总结

本节课中我们一起学习了:

  1. 完善了条件表达式:通过生成唯一标签来可靠地编译 if 表达式。
  2. 引入了二元运算:在解释器中实现了 +-=< 的求值逻辑。
  3. 解决了中间值存储问题:认识到仅使用寄存器不足以编译复杂的嵌套表达式。
  4. 引入了栈的概念:学会了如何使用内存中的栈区域,并通过在编译时跟踪 stack_index 来存储和检索中间结果,从而正确编译二元运算。

通过使用栈,我们为编译器处理更复杂、更深层嵌套的表达式奠定了基础。下一节课,我们将继续探索如何利用栈来实现更强大的语言特性。

9:命名表达式(Let)📚

在本节课中,我们将要学习如何为我们的语言添加命名表达式(let 绑定)。我们将探讨如何在解释器和编译器中实现它,理解惰性求值与急切求值的区别,并学习如何使用符号表来管理变量名到其存储位置的映射。


概述

上一节我们介绍了栈的使用和二元操作的编译。本节中,我们来看看如何为表达式命名,即实现 let 绑定。这允许我们将一个值绑定到一个名称上,并在后续的表达式中使用这个名称。


解释器中的实现

首先,我们来看看如何在解释器中实现 let 表达式。我们需要一个环境(environment)来存储名称到值的映射。

环境与符号表

在解释器中,我们使用一个符号表(symbol table)来作为环境。它是一个从名称(字符串)映射到值的数据结构。

type env = (string, value) Symtab.t

以下是使用符号表的基本操作:

  • Symtab.add name value env:向环境 env 中添加一个从 namevalue 的新映射,并返回一个新的环境。
  • Symtab.find name env:在环境 env 中查找 name 对应的值。
  • Symtab.mem name env:检查 name 是否存在于环境 env 中。

惰性求值与急切求值

在实现 let 时,我们需要决定在环境中存储什么。有两种主要策略:

  1. 急切求值(Eager Evaluation):在绑定名称时,立即计算表达式的值,并将该值存储在环境中。
  2. 惰性求值(Lazy Evaluation):在绑定名称时,仅存储表达式本身。只有当名称被使用时,才计算其值。

我们的解释器将采用急切求值策略,即在 let 绑定时就计算表达式的值。

解释 let 表达式

现在,我们来实现 let 表达式的解释逻辑。核心思想是:

  1. 计算绑定表达式 e 的值。
  2. 将这个值添加到当前环境中,创建一个新的环境。
  3. 在这个新环境中解释 let 的主体部分 body

let rec interp_exp (exp : s_exp) (env : env) : value =
  match exp with
  | Let (name, e, body) ->
      let e_val = interp_exp e env in
      let new_env = Symtab.add name e_val env in
      interp_exp body new_env
  | ... (* 其他表达式类型 *)

这种实现利用了 OCaml 中符号表的不可变性。当我们添加一个新绑定时,会得到一个新的环境,而旧环境保持不变。这自然地实现了词法作用域(lexical scope),即变量只在定义它的 let 表达式的主体内部可见。


编译器中的实现

上一节我们在解释器中实现了 let。本节中,我们来看看如何在编译器中实现它。关键区别在于,编译器需要在编译时决定将变量的值存储在何处。

编译器的符号表

在编译器中,我们同样需要一个符号表。但是,编译器在编译时无法知道运行时的具体值(例如,未来用户输入的值)。因此,我们不能在符号表中存储值。

相反,我们在符号表中存储变量值在栈上的位置(偏移量)。

type compile_env = (string, int) Symtab.t

编译 let 表达式

编译 let 表达式的步骤如下:

  1. 使用当前的栈索引和符号表,编译绑定表达式 e,将其值存入 %rax
  2. 将这个值存储到当前栈索引指向的栈位置。
  3. 更新符号表,将变量名映射到我们刚刚使用的栈索引。
  4. 非常重要:将栈索引递减(例如 -8),以确保在编译 body 时不会覆盖刚才存储的值。
  5. 使用更新后的符号表和栈索引来编译 body

let rec compile_exp (exp : s_exp) (si : int) (tab : compile_env) : string =
  match exp with
  | Let (name, e, body) ->
      let e_code = compile_exp e si tab in
      let store_code = movq ~src:(reg Rax) ~dst:(stack_addr si) in
      let new_tab = Symtab.add name si tab in
      let new_si = si - 8 in
      let body_code = compile_exp body new_si new_tab in
      e_code ^ store_code ^ body_code
  | ... (* 其他表达式类型 *)

编译变量引用

当编译一个变量名(如 x)时,我们需要从符号表中查找它对应的栈偏移量,然后将该内存位置的值加载到 %rax 中。

| Name name ->
    let stack_offset = Symtab.find name tab in
    movq ~src:(stack_addr stack_offset) ~dst:(reg Rax)

编译时与运行时

需要注意的是,编译器的符号表仅在编译时存在。它的所有信息(变量名对应的栈偏移量)都会被“烘焙”到生成的汇编代码中。在程序运行时,不存在一个 OCaml 的符号表数据结构;变量访问直接通过硬编码的栈地址(如 -8(%rbp))来完成。

这意味着,如果一个变量名在编译时的符号表中找不到(例如,在定义它的 let 表达式外部使用它),编译器将无法生成有效的汇编代码,从而产生一个编译时错误。


总结

本节课中我们一起学习了命名表达式(let)的实现。

  • 在解释器中,我们引入了环境(符号表)来管理名称到值的映射,并采用急切求值策略。
  • 在编译器中,我们使用符号表来映射名称到栈偏移量,并在编译 let 表达式时,需要小心管理栈空间,避免覆盖已存储的值。
  • 我们理解了编译器的符号表仅存在于编译时,其信息被固化到生成的机器码中,这导致了未定义变量的错误会在编译时被捕获。

通过实现 let,我们的语言获得了定义和使用变量的能力,这是构建更复杂程序的基础。

10:Let表达式续讲;对(Pairs)

在本节课中,我们将继续学习Let表达式,并引入一个重要的新概念:对(Pairs)。我们将探讨如何在编译器中实现它们,以及如何利用堆(Heap)来存储更复杂、生命周期更长的数据结构。


概述

上一节我们介绍了Let表达式,它允许我们为表达式命名并在其作用域内重复使用。本节中,我们将首先通过几个例子巩固对Let表达式实现的理解,然后重点学习如何为我们的语言添加对(Pairs)这一新特性。对是构建更复杂数据结构(如列表)的基础。


Let表达式回顾与示例分析

在深入新内容之前,让我们回顾一下编译器中Let表达式的实现。核心在于我们引入了一个符号表(Symbol Table),它是一个从名称到整数的映射。

核心概念:符号表映射
这个整数代表该名称对应的值在运行时栈(Stack)上的偏移量(Offset)。例如,如果名称 x 映射到整数 -8,那么在生成的汇编代码中,我们通过 [rbp - 8] 这样的地址来访问 x 的值。

以下是编译Let表达式的关键步骤:

  1. 计算Let绑定表达式 e1 的值,结果存入 rax
  2. 将该值存入栈上下一个可用的槽位(slot),例如 [rbp - 8]
  3. 更新符号表,将名称 x 映射到这个栈偏移量(例如 -8)。
  4. 在这个新的符号表环境下,编译Let表达式的主体部分 e2
  5. 当在主体中遇到变量 x 时,编译器查询符号表,生成从对应栈偏移量(如 [rbp - 8])加载值到 rax 的代码。

重要讨论:编译时错误 vs. 运行时错误
考虑一个使用了未绑定名称的程序,例如 (let (x 1) y)。这个错误是在编译时被发现的,因为编译器在处理到 y 时,会在当前的符号表中查找,发现 y 不存在,从而立即引发一个错误。符号表本身是编译器在编译时使用的数据结构,并不存在于最终生成的机器码中。


对(Pairs)的引入

现在,我们为语言添加三个新的构造:

  • (pair e1 e2):构造一个对,包含 e1e2 两个值。
  • (left e):获取对 e 的左元素。
  • (right e):获取对 e 的右元素。

在其他语言(如Lisp)中,这些操作通常被称为 conscarcdr。对是构建链表的基础,例如 (pair 1 (pair 2 false)) 可以表示列表 [1, 2]


在解释器中实现对

在解释器中实现相对直接。我们扩展值的类型定义,增加一个 Pair 构造器。

代码:解释器中的值类型扩展

type value =
  | Num of int
  | Bool of bool
  | Pair of value * value

对应的求值规则也很直观:

  • (pair e1 e2):先求值 e1 得到 v1,再求值 e2 得到 v2,然后返回 Pair(v1, v2)
  • (left e):求值 e,如果结果是 Pair(v1, _),则返回 v1,否则报错。
  • (right e):求值 e,如果结果是 Pair(_, v2),则返回 v2,否则报错。

在编译器中实现对的挑战

在编译器中实现对面临一个核心挑战:表示问题。一个对包含两个值,每个值在机器中占用64位(8字节)。而我们的约定是,任何表达式的最终结果都必须存放在64位的 rax 寄存器中。

我们无法简单地将两个64位值塞进一个64位寄存器。因此,我们需要一种间接表示法。

为什么不能只用栈?
考虑以下程序:

(let (x 1) (pair (add1 x) (add1 x)))

(pair ...) 的结果需要作为整个Let表达式的返回值。如果将对的两个部分存储在栈上(例如 [rbp-8][rbp-16]),那么当Let表达式结束时,这些栈槽位可能被后续操作覆盖,导致返回的对“损坏”。栈更适合存储短期、生命周期明确(由作用域控制)的中间值。

解决方案:使用堆(Heap)
堆是一块我们可以动态分配的内存区域,适合存储生命周期较长或大小不确定的数据。我们将使用寄存器 rdi 作为“堆指针”,指向堆中下一个可用的地址。

对的运行时表示

  1. 在堆上连续分配16个字节(两个8字节槽位)。
  2. 将对左元素的值存入 [rdi + 0]
  3. 将对右元素的值存入 [rdi + 8]
  4. rdi 的值(即这个对在堆上的起始地址)存入 rax,作为整个 pair 表达式的“值”。
  5. 将堆指针 rdi 增加16,指向新的可用位置,防止后续分配覆盖当前对。

关键细节:标记位(Tagging)
我们之前用 rax 的最后两个比特来标记值的类型(如00表示整数)。现在,rax 中存储的是一个内存地址。幸运的是,由于我们按8字节对齐分配堆内存,任何堆地址的最后三位都是0。我们可以利用其中两位来标记这是一个“对”类型,例如使用标记 01

公式:对值的最终表示
因此,一个对在 rax 中的最终表示是:
rax = <堆内存地址> | <对标记位>
例如,如果对存储在地址 0x10,对标记是 0b01,那么 rax 中的值就是 0x11


编译器实现步骤

  1. 修改运行时系统:在程序启动时(通常在C启动代码中),分配一块内存作为堆,并将其起始地址通过 rdi 寄存器传递给我们的汇编入口点 entry
  2. 编译 (pair e1 e2)
    • 编译 e1,结果在 rax。保存到栈。
    • 编译 e2,结果在 rax
    • 从栈恢复 e1 的值到另一个寄存器(如 r8)。
    • 生成汇编,将 r8 的值存入 [rdi + 0]
    • 生成汇编,将 rax 的值存入 [rdi + 8]
    • rdi 的值(当前堆地址)复制到 rax
    • rax 应用“对”标记位(如 or rax, 1)。
    • 将堆指针 rdi 增加 16
  3. 编译 (left e)(right e)
    • 编译 e,结果在 rax
    • 清除 rax 中的类型标记位,得到原始堆地址。
    • 生成汇编,从 [rax + 0] 加载值到 rax(对于 left),或从 [rax + 8] 加载(对于 right)。
  4. 修改打印函数:扩展 print_value 函数,使其能够识别 rax 中的对标记,并递归地打印堆中对的左元素和右元素。

总结

本节课中我们一起学习了:

  1. Let表达式的深入理解:通过分析汇编代码,我们明确了符号表如何将变量名映射到栈偏移量,以及变量的作用域如何决定栈空间的复用。
  2. 引入对(Pairs):我们认识了这一构建复杂数据结构的基石。
  3. 堆内存管理:我们了解了为何对需要存储在堆而非栈上,并学习了使用堆指针(rdi)进行动态分配的基本模型。
  4. 对的运行时表示:我们掌握了将对表示为堆地址并添加类型标记的关键技术。

通过实现对,我们的语言表达能力得到了显著增强,为后续实现链表等数据结构打下了基础。在下一讲中,我们将看到这一切如何运作,并完善相关的运行时支持。

11:错误处理 🛠️

在本节课中,我们将学习如何处理程序中的错误。我们将回顾之前关于堆和配对的内容,并探讨如何在编译器和运行时中实现错误检查,以确保程序在遇到无效操作时能够优雅地失败,而不是产生不可预测的行为。


回顾:堆与配对

上一节我们介绍了如何在堆上存储配对。本节中,我们来看看如何实现配对的基本操作,并理解其内存布局。

我们决定将配对的左值存储在堆的第一个可用槽中,右值存储在下一个槽中。为了在寄存器 RAX 中表示这个配对,我们存储一个指向堆中配对起始地址的指针。由于所有堆地址都是8的倍数,其二进制表示的最后三位总是0,这为我们添加标签提供了便利。

以下是实现配对构造的核心代码逻辑:

; 计算左表达式 e1 的值,存入 RAX
...
; 将 RAX 的值临时保存到栈上
mov [rsp - 8], rax
; 计算右表达式 e2 的值,存入 RAX
...
; 将之前保存的左值恢复到 R8
mov r8, [rsp - 8]
; 将左值存入堆的第一个槽(地址在 RDI 中)
mov [rdi], r8
; 将右值存入堆的下一个槽
mov [rdi + 8], rax
; 将堆的起始地址存入 RAX 作为配对的表示
mov rax, rdi
; 为地址添加配对标签(例如,标签值为2)
or rax, 2
; 更新 RDI,指向堆的下一个可用位置
add rdi, 16

为了从配对中提取左值,我们需要移除标签并访问对应的内存地址:

; 假设配对地址(带标签)已在 RAX 中
; 移除配对标签
sub rax, 2
; 访问该地址,获取左值
mov rax, [rax]

提取右值的操作类似,但需要额外偏移8个字节:

; 移除配对标签并偏移8字节以获取右值
sub rax, 2
mov rax, [rax + 8]

运行时类型检查与错误处理

现在我们已经实现了配对,但我们的编译器目前对某些非法操作(例如对非数字值使用 add1)的处理方式与解释器不一致,甚至可能产生未定义行为。本节中,我们来看看如何通过运行时类型检查来捕获这些错误。

我们首先在C运行时中创建一个通用的错误报告函数:

void error() {
    printf("ERROR\n");
    exit(1);
}

然后,在汇编代码中声明这个外部函数,以便在需要时调用:

extern error

接下来,我们创建辅助函数来检查值是否为数字。关键在于,检查时不能破坏 RAX 中原始值的表示:

ensure_num:
    ; 将待检查的值复制到 R8,避免破坏 RAX
    mov r8, rax
    ; 使用数字掩码(例如,二进制...001)检查最后两位标签
    and r8, 1
    ; 与数字标签(例如,0)比较
    cmp r8, 0
    ; 如果不是数字,跳转到错误处理
    jne error
    ret

现在,我们可以在 add1 的实现中使用这个检查:

; 实现 add1
; 先确保 RAX 中的值是数字
call ensure_num
; 如果是数字,安全地执行加一操作
add rax, 8 ; 假设我们的数字表示是实际值的8倍

我们也可以用类似的方法确保操作对象是配对:

ensure_pair:
    mov r8, rax
    ; 使用堆掩码(例如,二进制...111)检查最后三位
    and r8, 7
    ; 与配对标签(例如,2)比较
    cmp r8, 2
    jne error
    ret

并在 left 操作中使用它:

; 实现 left
call ensure_pair
; 安全地提取左值
...


解释器与编译器的行为对齐

我们遇到的一个挑战是,解释器(使用结构相等性)和编译器(使用物理相等性)对于配对相等的判断结果不同。例如,(pair 1 2) == (pair 1 2) 在解释器中为真,在编译器中为假。

长期解决方案是在编译器中实现递归遍历的结构相等性检查,但这需要函数功能。在获得函数之前,我们采取一个临时方案:暂时将相等性操作 == 限制为仅用于比较数字。

另一个差异涉及错误触发时机。考虑程序 (if true 1 hello)。解释器采用惰性求值,因为条件为真,它永远不会评估 hello,因此不会出错。而编译器需要为整个程序生成汇编代码,因此会尝试处理 hello 并触发“错误表达式”异常。这引发了关于错误是在编译时还是运行时发生的思考。

为了测试包含错误情况的程序,我们更新了测试框架,使其能够捕获并比较解释器和编译器产生的错误输出。


总结

本节课中我们一起学习了:

  1. 回顾了配对在堆上的实现,包括构造以及 leftright 操作的提取。
  2. 引入了运行时类型检查,通过 ensure_numensure_pair 等辅助函数,在非法操作(如对布尔值使用 add1)发生时触发错误,而不是产生不可预测的结果。
  3. 讨论了解释器与编译器在语义上的差异,特别是相等性判断和错误触发时机的问题,并采取了初步措施使它们的行为在错误处理上更加一致。
  4. 增强了程序的健壮性,使得更多类型的编程错误能够被捕获并报告,而不是导致未定义行为。

通过实现这些错误检查机制,我们使编译器更加强大和可靠,为程序员提供了更好的反馈。

12:与环境交互(输入)📚

在本节课中,我们将要学习如何让我们的编程语言与外部环境进行交互,具体来说,是实现从用户那里读取输入的功能。我们将看到,这如何改变了我们对程序执行顺序的理解,并需要我们在编译器中引入新的机制来管理函数调用和状态。


回顾:内存管理 🧠

在深入新内容之前,让我们先回顾一下之前关于内存使用的讨论,特别是栈和堆的区别。

上一节我们介绍了栈和堆的基本概念,本节中我们来看看一些具体的决策点。

何时更新栈索引?

栈索引(stack_index)用于跟踪栈上可用的下一个位置。我们只在实际使用了一个栈槽来存储数据时,才会将其减小(例如减去8)。这确保了后续的编译步骤知道哪些位置已被占用。

为何某些数据必须放在堆上?

对于像“对”(pair)这样的复合数据结构,我们无法在编译时确定其确切大小(例如,一个对可能包含数字,也可能包含另一个对)。由于栈索引的更新和偏移量的计算都发生在编译时,如果不知道大小,我们就无法在汇编代码中正确预留栈空间。因此,未知大小的数据必须放在堆上。

寄存器 vs. 栈

我们更倾向于将数据存储在寄存器中,因为访问速度更快。然而,寄存器的数量(16个)是有限的。在实践中,编译器会进行寄存器分配,决定哪些值可以保留在寄存器中,哪些必须“溢出”(spill)到栈上。一个简单的经验法则是:如果后续有递归调用可能会覆盖寄存器中的值,那么最好先将该值存储到栈上。

堆中的数据何时被移除?

在我们当前的编译器实现中,数据永远不会从堆中被主动移除。堆只会增长,不会收缩。我们将在后续课程中讨论更智能的内存管理(如垃圾回收)。


引入输入/输出 🔄

到目前为止,我们的程序都是封闭的,不与环境交互。现在,我们将添加与环境交互的能力,首先是读取用户输入。

一个“高效”但无用的编译器

设想一个极端的编译器:它不编译源代码,而是直接运行解释器得到结果值,然后生成仅仅输出该固定值的汇编代码。虽然这种编译器生成的代码效率极高,但它有一个致命缺陷:它无法处理任何具有副作用(side effects)的操作

副作用是指除了计算返回值之外,还会改变程序状态或与环境交互的操作。例如:

  • 读取用户输入(每次运行可能不同)。
  • 输出到屏幕或文件。
  • 获取当前时间或随机数。
  • 修改全局状态。

一旦程序需要根据不同的输入产生不同的结果,或者需要产生副作用,这种“预计算”式的编译器就完全失效了。因此,我们必须回到能够动态生成代码的常规编译路径。

在解释器中实现 readnum

首先,我们在解释器(OCaml部分)中添加 readnum 的功能。这相对简单,我们可以利用OCaml的标准库。

let rec interp_exp (env : value env) (exp : expr) : value =
  match exp with
  | EReadNum ->
      let input_channel = stdin in
      let line = input_line input_channel in
      VNum (int_of_string line)
  (* ... 处理其他表达式 ... *)

这里,readnum 会从标准输入读取一行,并将其转换为整数。

副作用与求值顺序

添加副作用后,表达式的求值顺序变得至关重要。考虑程序 (readnum, readnum)。用户先输入1,再输入2,我们期望结果是 (1, 2)

然而,在OCaml中,函数参数的求值顺序是未定义的(实践中通常从右到左)。如果解释器先求值右边的 readnum,结果就会变成 (2, 1),这与我们的直觉和编译器的行为(通常从左到右)不一致。

因此,我们必须显式地固定求值顺序。我们需要修改解释器中处理pair等结构的代码,确保先求值左子表达式,再求值右子表达式。

let rec interp_exp (env : value env) (exp : expr) : value =
  match exp with
  | EPair (e1, e2) ->
      let v1 = interp_exp env e1 in (* 先求值左边 *)
      let v2 = interp_exp env e2 in (* 再求值右边 *)
      VPair (v1, v2)
  (* ... *)

在编译器中实现 readnum ⚙️

在编译器(汇编生成部分)实现 readnum 更为复杂。我们不能简单地跳转到一个C函数,因为我们需要妥善保存和恢复当前执行状态。

直接跳转的问题

如果我们像处理错误一样,直接使用 jmp 指令跳转到 readnum 函数:

  1. 无法返回:没有机制让 readnum 执行完后回到我们代码的下一行。
  2. 寄存器被破坏readnum 函数可能会覆盖我们正在使用的寄存器(如 RDI 堆指针)。
  3. 栈空间被破坏readnum 函数会使用当前的栈指针 RSP 以下的栈空间,可能覆盖我们保存的数据。

解决方案:使用 call 指令和栈帧

我们需要遵循标准的函数调用约定。关键指令是 callret

call 指令的作用

  1. 将当前栈指针 RSP 下移(例如减8),指向一个新的“栈帧”起始处。
  2. 下一条指令的地址(即 call 之后的地址)压入这个新栈帧的顶部。这个地址称为“返回地址”。
  3. 跳转到目标函数(readnum)开始执行。

ret 指令的作用(在 readnum 函数末尾):

  1. 从当前 RSP 所指的位置弹出“返回地址”。
  2. RSP 上移(加8),销毁当前栈帧。
  3. 跳转到弹出的返回地址,从而回到调用者 call 之后的指令继续执行。

这样,调用和返回就通过协作完成了。

保存与恢复状态

在调用 readnum 之前,我们必须手动保存那些我们期望在调用后保持不变、但可能被 readnum 破坏的寄存器值(如 RDI)。我们将它们保存到我们自己的栈帧中

readnum 返回后,我们再从栈帧中将这些值恢复到相应的寄存器中。

以下是实现 readnum 的编译器代码框架:

; 编译 readnum 表达式
mov [rsp + stack_offset], rdi  ; 保存当前堆指针 RDI 到栈上
sub rsp, 8                     ; 更新 RSP,为调用做准备(可能需要16字节对齐调整)
call readnum                   ; 调用C函数 readnum
add rsp, 8                     ; 恢复调用前的 RSP
mov rdi, [rsp + stack_offset]  ; 从栈上恢复堆指针 RDI
; 此时,用户输入的值已由 readnum 函数放在 RAX 中
shl rax, 1                     ; 将数值转换为我们运行时的标记表示
; RAX 现在包含了结果

关键点

  • 栈帧:每个函数调用都有自己的一块栈空间(栈帧),用于存放局部变量、保存的寄存器和返回地址。
  • 对齐:x86-64调用约定要求 RSPcall 指令执行前必须是16字节的倍数,有时需要额外调整。
  • 寄存器约定:我们只保证 RAX 用于返回值。其他寄存器(除了 RSPRBP等少数)可能被调用函数破坏,因此重要的状态需要调用者自己保存。


示例:指令指针与执行流程 🎬

理解程序如何一步步执行,需要认识一个隐藏的寄存器:指令指针 RIP。它总是指向下一条将要执行的指令地址。

让我们跟踪一段包含 call 的简单汇编代码的执行:

假设初始状态:

  • RIP 指向 mov [rsp-8], rdi (地址为1)。
  • RSP = 64 (指向栈顶)。
1: mov [rsp-8], rdi   ; 保存 RDI 到栈地址 56
2: sub rsp, 8         ; RSP = 56
3: call readnum       ; 1. RSP = 48
                       ; 2. 将“下条指令地址(4)”存入地址48
                       ; 3. RIP = readnum函数地址
4: add rsp, 8         ; (readnum返回后,从这里开始执行) RSP = 56
5: mov rdi, [rsp-8]   ; 从地址56恢复RDI
  1. 执行指令1、2,保存寄存器并调整 RSP
  2. 执行 call 指令3:RSP 变为48,将地址4压入栈地址48,然后跳转到 readnum
  3. readnum 函数执行(可能使用和修改栈地址48以下的空间),最后执行 ret
  4. ret 指令:从当前 RSP(48)弹出返回地址(4),RSP 加8变回56,然后跳转到地址4。
  5. 继续执行指令4、5,恢复 RSPRDI

通过 call/retRIP 的配合,控制流得以在函数间正确跳转和返回。


总结 📝

本节课中我们一起学习了如何为我们的语言添加输入功能,这引出了一系列核心概念:

  1. 副作用:使得程序行为依赖于环境或历史,打破了纯函数的替代模型。输入/输出是最常见的副作用。
  2. 求值顺序:在存在副作用时,表达式的求值顺序必须明确定义,以保证解释器和编译器行为一致。
  3. 函数调用约定:为了实现与外部代码(如C函数)的交互,我们必须遵守系统约定的规则,包括如何使用栈和寄存器。
  4. 栈帧:是函数调用的活动记录,存储了返回地址、保存的寄存器和局部变量。callret 指令自动管理栈帧的创建和销毁。
  5. 状态保存与恢复:在调用可能破坏寄存器的函数前,调用者有责任保存重要状态到自己的栈帧中,并在返回后恢复。
  6. 指令指针 RIP:程序执行的“向导”,硬件自动更新它来顺序执行或跳转指令。

编译器的强大之处在于它能将高级语言结构翻译成这些底层的、精确的机器指令序列。同时,这也意味着编译器作者肩负着巨大责任,因为机器只是忠实地执行指令,不会检查我们的逻辑是否正确。

13:与环境交互(输出)📝

在本节课中,我们将学习如何让程序向用户输出信息。上一节我们介绍了如何从用户那里获取输入,本节中我们来看看如何将结果打印出来。我们将从回顾调用外部函数时栈指针的变化开始,然后实现 printnewline 功能,并引入 do 表达式来处理多个有副作用的表达式。

回顾:调用外部函数与栈指针

上一节我们介绍了如何调用 readnum 函数来获取用户输入。这是我们的编译器第一次生成会改变栈指针 RSP 值的汇编代码。

在此之前,我们之所以不需要改变 RSP,是因为我们完全控制着自己生成的汇编代码,清楚知道栈上发生了什么。然而,当我们调用像 readnum 这样的外部函数(由C编译器生成)时,情况就不同了。我们不知道这段外部代码会对栈做什么,因此必须调整 RSP,为它提供一个独立的栈帧空间,以保护我们自己的数据不被覆盖。

调用结束后,我们还需要将 RSP 恢复原状,以便后续代码能正确访问栈上的数据。

实现输出功能

现在,让我们来实现向用户输出的功能。我们将从解释器开始,因为它通常更直观。

在解释器中实现

首先,我们实现 newline 功能,它只是输出一个换行符。

let interp_newline env =
  output_string stdout "\n";
  Bool true

接下来实现 print 功能,它需要计算一个表达式的值,并将其转换为字符串输出。

let interp_print env e =
  let v = interp_exp env e in
  output_string stdout (string_of_value v);
  Bool true

注意,这两个函数在执行了打印的“副作用”后,都返回了布尔值 true 作为表达式的结果值。

引入 do 表达式

现在我们的语言有了副作用(输入/输出),我们可能需要按顺序执行多个操作。例如,先打印一个数字,然后换行。因此,我们需要引入一个序列表达式 do

do 表达式接收一个表达式列表,按顺序执行它们,并返回最后一个表达式的结果。

let interp_do env es =
  let values = List.rev_map (interp_exp env) (List.rev es) in
  List.hd values

这里使用 List.rev 是为了在映射后能方便地取到最后一个值作为结果。

在编译器中实现

现在,我们将同样的功能添加到编译器中。

实现 newline

在编译器中,调用运行时函数 print_newline 的汇编代码与调用 readnum 非常相似,但最后需要将布尔值 true 的运行时表示(即 1)放入 RAX 寄存器作为返回值。

; 假设 RAX 已保存,RDI(堆指针)已保存
sub RSP, 24          ; 为C函数调用对齐栈
call print_newline   ; 调用运行时函数
mov RAX, 1           ; 将 true 的运行时值放入 RAX
add RSP, 24          ; 恢复栈指针
; ... 恢复 RDI 等寄存器

实现 print

对于 print,我们需要先编译其参数表达式,将结果值放入 RAX。然后,根据C调用约定,第一个参数应通过 RDI 寄存器传递,所以我们需要将 RAX 的值移动到 RDI。当然,在这样做之前,必须先将原来的 RDI(堆指针)保存到栈上。

; 编译表达式 e,结果在 RAX 中
; 保存当前 RDI 到栈上
mov [RSP - 16], RDI
; 将参数移动到 RDI
mov RDI, RAX
; 调整栈并调用
sub RSP, 24
call print_value
mov RAX, 1           ; 返回值 true
add RSP, 24
; 恢复 RDI
mov RDI, [RSP - 16]

实现 do

在编译器中实现 do 序列反而比解释器更简单。我们只需要按顺序编译列表中的每个表达式,并将产生的指令列表连接起来即可。因为每个表达式编译后的代码都会将结果存入 RAX,执行下一个表达式时会覆盖它,所以序列执行完后,RAX 中自然就是最后一个表达式的结果。

let compile_do si es =
  List.concat_map (compile_exp si) es

更新运行时与测试

为了让这些新功能工作,我们需要在运行时库中声明相应的外部函数,并确保链接器能找到它们。同时,我们修改了测试框架,允许为需要输入的程序提供预设的输入字符串,并移除了程序结束后自动打印结果的默认行为。现在,程序员必须显式使用 print 才能看到输出。

有用的列表函数小练习

在课程最后,我们快速回顾了几个OCaml列表处理函数,它们对完成作业很有帮助:

  • List.mapi:它会在映射过程中同时提供元素索引。
  • List.map:经典的映射函数。
  • List.fold_left:从左到右的折叠操作,功能强大。

尝试用 fold_left 来实现 map 函数,是一个很好的练习,能帮助你深入理解这些核心概念。


本节课中我们一起学习了如何为语言添加输出功能。我们实现了 printnewline 操作,并引入了 do 表达式来组合多个带有副作用的操作。我们还看到了在编译器中与外部函数交互时需要遵守调用约定并妥善管理栈指针。现在,我们的语言已经能够与用户进行基本的输入输出交互了。

14:函数!🚀

在本节课中,我们将学习如何在我们的解释器中实现函数。我们将探讨函数定义、函数调用、递归,以及一个非常重要的概念:词法作用域与动态作用域的区别。通过本节课,你将能够运行包含函数的程序,并理解函数执行时环境的管理方式。


解释器的初步调整

首先,我们需要调整解释器以处理包含函数定义的程序。之前,我们的程序只包含一个表达式。现在,程序由一系列函数定义和一个最终的主体表达式组成。

为了实现这一点,我们引入了一个新的数据类型 definition,用于存储函数定义的信息。

type definition = {
  name : string;
  args : string list;
  body : s_expr;
}

这个类型包含函数名、参数列表(字符串形式)和函数体(一个S表达式)。我们还提供了一些辅助函数来解析程序,分离出定义列表和主体表达式。

接下来,我们修改解释器的入口点。不再将整个程序解析为单个表达式,而是解析出多个S表达式,然后分离出定义和主体。

let defns, body = parse_many program |> defns_and_body in
interp_expr defns body

我们还需要修改 interp_expr 函数,使其接收一个定义列表作为额外参数,以便在解释过程中可以查询可用的函数。


处理函数调用

当解释器遇到一个符号(可能代表函数调用)时,我们需要判断它是否是一个有效的函数调用。

以下是检查函数调用有效性的两个关键步骤:

  1. 检查函数是否存在:确认调用的函数名是否在提供的定义列表中。
  2. 检查参数数量:确认调用时提供的参数数量是否与函数定义期望的参数数量一致。

如果这些检查通过,我们就可以准备执行函数体。这涉及到计算实际参数的值,并为函数体创建一个新的执行环境。


关键决策:词法作用域 vs. 动态作用域

在准备执行函数体时,我们必须决定哪些变量名在函数体内是可见的。这引出了编程语言设计中一个核心概念:作用域。

考虑以下程序:

(let ((x 2))
  (define (f arg1) (+ arg1 x))
  (f 4))

函数 f 在其函数体内引用了变量 xx 的值应该是什么?这取决于语言采用的作用域规则。

  • 词法作用域(静态作用域):函数体内可见的变量由函数在源代码文本中的位置决定。在上面的例子中,函数 f 定义在 (let ((x 2)) ...) 的内部,因此在 f 的函数体内,x 应该绑定为 2。大多数现代编程语言(如Python、Java、OCaml)都使用词法作用域。
  • 动态作用域:函数体内可见的变量由函数被调用时的运行时环境决定。如果采用动态作用域,上面例子中 x 的值将取决于调用 f 时,当前环境中 x 绑定的值。这会使程序的行为难以预测和理解。

在我们的解释器实现中,我们选择了实现词法作用域。这意味着当进入一个函数体时,我们创建一个全新的环境,其中只包含该函数的形参与实参的绑定。我们将调用点的环境传递进去。

(* 为函数调用创建新环境:仅包含形参到实参的映射 *)
let new_env = List.combine defn.args arg_vals in
interp_expr defns new_env defn.body

这个决定使得函数的行为仅依赖于其定义处的代码结构,而不是其调用历史,从而提高了程序的可读性和可维护性。


递归与相互递归

由于我们将所有顶层函数定义都传递给了每个函数的执行环境,因此函数可以轻松地调用自身(递归)或调用其他已定义的函数(相互递归)。例如,判断奇偶性的函数可以这样定义:

(define (odd n)
  (if (= n 0)
      false
      (even (- n 1))))

(define (even n)
  (if (= n 0)
      true
      (odd (- n 1))))

因为 even 的函数体在执行时,其环境中的定义列表包含了 odd 的定义,所以它可以调用 odd,反之亦然。


向编译器迈进

在解释器中,实现函数调用相对直接:遇到调用时,找到函数体,然后解释执行它。然而,在编译器中,我们不能采用同样的“内联展开”策略。考虑一个递归函数(如斐波那契数列),如果每次调用都将其函数体的汇编代码复制一份,将会生成无限大的汇编程序,这是不可行的。

编译器需要一种不同的机制:函数调用指令(call)和返回指令(ret)。我们需要生成两部分代码:

  1. 调用点代码:负责计算参数值,将其放置到约定好的位置(例如栈上),然后使用 call 指令跳转到函数代码的入口标签。
  2. 函数定义代码:函数入口处有一个标签,函数体汇编代码负责从约定位置获取参数,执行计算,最后使用 ret 指令返回到调用点。

这要求调用方和被调用方对参数的传递位置(调用约定)有精确的共识。此外,为函数生成的标签名必须是确定性的,以便调用方能够引用,而不能使用每次生成唯一名称的 gensym 函数。


总结

本节课中我们一起学习了如何在解释器中实现函数。我们调整了解释器结构以处理函数定义和调用,实现了对递归和相互递归的支持,并深入理解了词法作用域动态作用域这一关键区别及其实现方式。我们还探讨了将函数功能移植到编译器时将面临的挑战,即需要利用 call/ret 机制和确定的标签来管理代码的跳转与返回,而不是简单地在调用点内联函数体。这为后续在编译器中实现函数奠定了基础。

15:函数(续)👨‍💻

在本节课中,我们将继续学习如何为我们的编程语言实现函数功能。我们将重点讨论在编译器实现中如何处理函数调用和函数定义,特别是如何管理栈帧和参数传递。


课程概述 📋

上一节课我们介绍了如何在解释器中实现函数,并讨论了词法作用域和动态作用域的区别。本节中,我们将深入编译器部分,学习如何生成汇编代码来处理函数调用,包括如何设置栈帧、传递参数以及如何确保调用方和被调用方之间的约定。


回顾:解释器中的函数实现 🔄

在解释器中,我们主要做了一项关键的簿记更改:现在程序可以输出多个S表达式,每个函数定义和程序主体各一个。此外,我们在解释函数调用时做了一个重要设计决策。

当我们遇到一个函数调用时,例如调用名为 F 的函数,我们会执行以下步骤:

  1. 在定义列表中查找名为 F 的定义。
  2. 比较调用点提供的参数数量与函数定义期望的参数数量,如果不匹配则报错。
  3. 如果匹配,则准备执行函数体。

执行函数体的关键在于创建新的符号表。我们评估调用点提供的每个参数(使用当前的运行环境),然后将这些值与函数定义中的参数名配对,形成一个新的符号表。这个新符号表只包含函数的参数

这个设计选择被称为 词法作用域。与之相对的是动态作用域,动态作用域会使用调用点的现有环境,并将其扩展以包含函数参数。词法作用域是当今大多数编程语言的标准选择,因为它使程序更易于理解和推理。

核心概念

  • 词法作用域:函数体执行时使用的环境,仅包含其自身参数的定义。
  • 动态作用域:函数体执行时使用的环境,是调用点环境的扩展。

编译器策略:调用方与被调用方 🏗️

在解释器中,每次遇到函数调用,我们都可以直接跳转到函数体并解释执行。但在编译器中,这种方法行不通,特别是对于递归函数。如果我们每次在调用点都直接插入函数体的汇编代码,遇到递归调用时会导致无限循环的代码生成。

因此,在编译器中,我们采用不同的策略:我们将代码生成分为两个部分。

  • 函数定义:生成一段带有标签的、可重用的汇编代码块。这段代码知道如何执行函数体的逻辑。
  • 函数调用点:生成调用指令(call),负责在跳转到函数定义之前,将参数值设置到栈上正确的位置。

这个策略的核心在于,整个程序中只有一个栈。函数定义在编译时,会假设其参数已经位于栈上某个特定的偏移位置。而函数调用点的责任,就是在执行 call 指令前,确保将这些参数值放到约定的位置。


编译函数定义 🏷️

现在,让我们看看如何编译一个函数定义。我们需要生成一个标签、函数体代码和一个返回指令。

以下是编译函数定义 compile_defn 的核心步骤:

  1. 为函数生成一个唯一的标签(例如 _label_函数名)。
  2. 编译函数体表达式 defn.body
  3. 在末尾添加 ret 指令。

编译函数体时,我们需要为其提供正确的上下文:

  • 定义列表:传入整个程序的函数定义列表,以便函数体可以调用其他函数(包括自身)。
  • 符号表:创建一个新的符号表,将每个形式参数名映射到栈上的一个固定偏移量。第一个参数在 RBP-8(跳过返回地址),第二个在 RBP-16,依此类推。
  • 栈索引:告诉函数体,它可以从哪个栈偏移量开始安全地使用栈空间(用于存储局部临时变量)。这个起始点应该是 -(参数数量 + 1) * 8,跳过返回地址和所有参数占用的空间。

代码示例 (伪代码表示逻辑):

let compile_defn defn =
  let label = "_label_" ^ defn.name in
  let param_symbol_table =
    List.mapi (fun i arg_name -> (arg_name, RBP_offset_for_param i)) defn.args
  in
  let body_stack_index = - ((List.length defn.args) + 1) * 8 in
  [Label label]
  @ compile_expr defn.body all_defns param_symbol_table body_stack_index
  @ [Ret]

编译函数调用点 📞

编译函数调用点更为复杂。我们需要生成代码来:

  1. 评估每个实参表达式。
  2. 将每个实参值移动到栈上被调用方期望的位置。
  3. 调整栈指针,为新的栈帧预留空间。
  4. 执行 call 指令。

关键点在于计算每个参数应该存放的栈地址。从调用方的视角看,在调整 RSP(第3步)之后,新的栈帧基址(我们称为 stack_base)是已知的。第一个参数应该放在 [stack_base - 8],第二个放在 [stack_base - 16],以此类推。

同时,在计算每个实参值时,我们必须小心不要覆盖已经计算好并存放好的其他参数。一个安全的方法是按照参数顺序(从左到右)进行评估和存放。在计算第 i 个参数时,我们可以使用从 stack_base - (i+1)*8 开始的栈空间,因为这不会覆盖之前已存放的参数。

代码示例 (关键逻辑部分):

let compile_call func_name args =
  (* 1. 评估每个参数,结果存入 RAX *)
  let arg_codes =
    List.mapi (fun i arg_expr ->
      let eval_code = compile_expr arg_expr current_symtab (stack_base - (i+1)*8) in
      eval_code
      @ [Mov (MemOffset (stack_base - (i+1)*8), Reg Rax)] (* 2. 存到约定位置 *)
    ) args
  in
  (* 3. 调整 RSP *)
  let adjust_rsp = [Add (Reg Rsp, Imm (- (total_arg_space)))] in
  (* 4. 调用函数 *)
  let call_instr = [Call (Label ("_label_" ^ func_name))] in
  (* 5. 恢复 RSP (调用后) *)
  let restore_rsp = [Add (Reg Rsp, Imm (total_arg_space))] in
  List.concat arg_codes @ adjust_rsp @ call_instr @ restore_rsp

栈帧变化示例 🧮

让我们通过一个简单的例子,手动跟踪函数调用过程中的栈和寄存器状态变化。考虑一个函数 test(x),它直接返回参数 x。在主程序中调用 test(4)

初始状态

  • RIP (指令指针): 指向 call test 的指令。
  • RSP (栈指针): 指向当前栈顶。
  • 栈上已准备好值 4 在正确位置。

执行 call test 指令

  1. RSP 减少 8 字节(压栈)。
  2. call 指令之后的那条指令的地址(返回地址)存入新的 [RSP]
  3. RIP 被设置为函数 test 第一条指令的地址(跳转)。

进入函数 test

  • 新的栈帧建立。此时 [RBP-8] 存放着传入的参数 4
  • 函数体执行 mov rax, [rbp-8],将参数值加载到 RAX(作为返回值)。

执行 ret 指令

  1. RIP 被设置为之前保存在栈上的返回地址。
  2. RSP 增加 8 字节(弹栈),回到调用前的状态。
  3. 程序继续从 call 之后的下一条指令执行。

这个过程清晰地展示了调用方如何通过栈传递控制权和数据,以及被调用方如何通过栈获取参数并返回结果。


总结 🎯

本节课中我们一起学习了:

  1. 回顾了词法作用域:这是现代语言函数实现的基础,函数环境仅包含其参数。
  2. 理解了编译器实现函数的策略:区分函数定义(生成可重用代码块)和函数调用点(负责设置参数并跳转)。
  3. 掌握了编译函数定义的方法:为函数体创建符号表,映射参数到栈偏移,并管理可用的栈空间。
  4. 分析了编译函数调用的过程:按顺序评估参数、存放到约定栈位置、调整栈指针、执行调用。
  5. 跟踪了栈帧的生命周期:通过示例了解了 callret 指令如何协作,实现函数调用和返回。

通过实现函数,我们的编译器现在能够处理更复杂、模块化的程序,并且性能远超解释器,正如我们在运行递归函数(如斐波那契数列)时所见证的那样。

16:尾调用优化 🚀

在本节课中,我们将要学习一种重要的编译器优化技术——尾调用优化。这项技术能让我们在递归函数中重用栈帧,从而避免栈溢出错误,使程序能够处理更深层次的递归。

概述 📋

我们首先会回顾一个递归求和函数的例子,它在大输入时会导致栈溢出。接着,我们会分析一个改进的、使用累加器的版本,并探讨为什么它理论上可以避免栈溢出。然后,我们将正式引入“尾位置”的概念,并学习如何在编译器中识别尾调用。最后,我们将动手修改编译器,实现尾调用优化,让改进后的递归函数能够成功运行。

栈溢出的问题 💥

上一节我们实现了函数调用,现在可以运行递归程序了。让我们看一个计算从1到n求和的递归函数:

(def (sum n)
  (if (= n 0)
      0
      (+ (sum (- n 1)) n)))

这个函数逻辑正确,但当输入值很大(例如一百万)时,无论是我们的编译器还是解释器版本,都会导致程序崩溃。

问题在于,每次递归调用都会在栈上创建一个新的栈帧。对于输入n,我们需要n个栈帧。栈空间是有限的,当递归深度过大时,就会发生栈溢出

尾递归形式 🔄

我们可以将上面的函数改写为“尾递归”形式,它使用一个额外的累加器参数:

(def (sum n total)
  (if (= n 0)
      total
      (sum (- n 1) (+ n total))))

这个版本在递归调用后没有其他工作(即不需要将结果再与n相加)。递归调用 (sum (- n 1) (+ n total)) 的结果就是整个函数的结果。

理论上,当一次函数调用完成后,如果它的栈帧数据不再需要,我们就可以复用这个栈帧来进行下一次调用,而不是创建新的。这样,无论递归多深,都只使用一个栈帧的空间。

理解尾位置 🎯

上一节我们看到了避免栈溢出的可能性,本节中我们来看看如何系统地识别可以进行优化的调用。关键在于判断一个表达式是否处于“尾位置”。

一个表达式处于尾位置,意味着完成这个表达式的求值后,当前函数体就再无其他计算需要执行,其求值结果就是整个函数的返回值。

让我们通过一些例子来直观理解:

以下是判断子表达式是否处于尾位置的几个例子:

  • 函数参数:例如在 (sum (- n 1) (+ n total)) 中,(- n 1) 是参数,它不处于尾位置。因为得到它的值后,还需要完成函数调用本身。
  • 函数体:在函数定义 (def (sum n total) ...) 中,函数体表达式处于尾位置。
  • if 表达式(if (= n 0) total ...) 这个 if 整体如果作为函数体,则处于尾位置。它的两个分支 total 和递归调用,如果被执行,也分别处于尾位置。
  • let 表达式:在 (let ((x 3)) x) 中,绑定值 3 不处于尾位置,但主体 x 处于尾位置。
  • do 表达式(do 1 2 3) 中,只有最后一个表达式 3 可能处于尾位置(如果do本身处于尾位置)。
  • 有副作用的表达式:例如 (print e),即使它返回 e 的值,求值后仍需执行打印操作,因此不处于尾位置。

在编译器中追踪尾位置 🛠️

为了在编译器中实现优化,我们给 compile_expr 函数增加了一个布尔参数 is_tail。它表示当前正在编译的表达式是否处于尾位置。

我们需要遍历编译器代码,为每个递归调用 compile_expr 的地方选择合适的 is_tail 值:truefalse 或传递父表达式的 is_tail 值。

以下是主要表达式类型中 is_tail 参数的设置规则:

  • 函数调用实参:始终为 false
  • do 表达式:除最后一个子表达式外,其他都为 false;最后一个子表达式继承 do 本身的 is_tail 值。
  • let 表达式:绑定值表达式为 false;主体表达式继承 let 本身的 is_tail 值。
  • if 表达式:条件测试表达式为 false;两个分支表达式继承 if 本身的 is_tail 值。
  • 二元操作数(如 +):两个操作数表达式均为 false
  • 函数定义体:应设为 true,因为函数体的结果就是函数的返回值。
  • 程序主表达式:应设为 true,允许复用初始栈帧。

实现尾调用优化 ⚡

现在我们已经能识别尾调用,接下来实现优化逻辑。核心思想是:当进行一个处于尾位置的函数调用时,复用当前栈帧,而不是创建新帧。

在编译器的函数调用处理部分,我们根据 is_tail 参数分两种情况处理:

1. 非尾调用(常规调用)
保持原有逻辑:移动栈指针(RSP),使用 call 指令,创建新栈帧。

2. 尾调用
进行以下关键修改:

  • 不移动栈指针:保持 RSP 不变,复用当前栈帧。
  • 使用 jump 代替 callcall 会将返回地址压栈,而 jump 直接跳转,不改变栈状态。
  • 妥善处理参数:在计算每个实参时,需要先将结果存到临时栈位置(避免覆盖当前帧中还需使用的值),然后再复制到当前栈帧的底部(即被调用函数期望找到参数的位置)。这通过额外的 mov 指令实现。

优化后的尾调用代码大致逻辑如下(伪代码表示):

; 假设正在编译 (sum (- n 1) (+ n total)) 且处于尾位置
; 计算实参1 (- n 1),结果存入 RAX
...
mov [RSP - 16], RAX ; 存到临时位置
mov R8, [RSP - 16] ; 加载到寄存器
mov [RSP - 8], R8  ; 复制到参数1位(相对于当前帧基址)
; 计算实参2 (+ n total)
...
mov [RSP - 24], RAX ; 存到临时位置
mov R8, [RSP - 24] ; 加载到寄存器
mov [RSP - 16], R8 ; 复制到参数2位
; 跳转到 sum 函数
jump sum_label

完成这些修改后,我们的编译器就能正确运行尾递归版本的 sum 函数,即使输入一百万也不会栈溢出。

总结 📝

本节课中我们一起学习了尾调用优化。我们从栈溢出问题出发,引入了尾递归形式。然后,我们定义了“尾位置”的概念,并学习了如何在编译器中通过 is_tail 参数来追踪它。最后,我们实现了尾调用优化的核心逻辑:当调用处于尾位置时,通过复用当前栈帧和用 jump 替代 call 来避免栈空间过度消耗。这项优化对于函数式语言的实践至关重要,它使得递归可以成为迭代的安全替代。

17:语法分析 🧩

在本节课中,我们将要学习语法分析。我们将回顾尾调用优化,并正式介绍语法分析的核心概念——语法和语法树。


课程回顾 📚

上一节我们深入探讨了尾调用优化。本节开始前,我们先简要回顾一下截至目前课程所涵盖的核心内容。

我们已讨论的主题包括:

  • 解释器与编译器:它们的类型、行为差异以及编译器的主要组件。
  • 语法与语义:程序书写形式(语法)与其实际含义(语义)的区别。
  • 汇编与内存管理:如何生成和阅读汇编代码,以及栈和堆的使用。
  • 控制流与作用域:跳转、函数调用、符号表、环境,以及词法作用域与动态作用域的设计选择。
  • 编程语言设计:可变性与不可变性、副作用、正确性推理、未定义行为。
  • 编译时与运行时:静态与动态的概念,特别是静态类型与动态类型检查。
  • 函数与求值策略:函数调用、惰性求值与急切求值。
  • 尾调用优化:重用栈帧以实现高效的递归和函数调用。
  • 语言特性语义:在 Scheme(C16S/Lang)和 OCaml 中各种语言特性的含义。

这些概念相互关联,共同构成了我们理解和实现编程语言的基础。


尾调用优化详解 🔄

上一节我们介绍了尾调用优化的概念。本节中,我们通过一个具体示例来深入理解它是如何重用栈帧的。

考虑以下程序:(sub (add 16 12) y)。我们将对比编译器在启用和不启用尾调用优化时生成的汇编代码,观察栈帧的使用差异。

未启用尾调用优化的情况

以下是使用标准 call 指令时的栈帧变化:

  1. 函数 sub 调用函数 add
  2. call 指令会:
    • 将返回地址压入栈中(RSP 下移)。
    • 跳转到 add 的函数体。
  3. 这为 add 创建了一个新的栈帧,位于 sub 的栈帧之上。
  4. 函数返回时,需要依次清理这些栈帧。

关键点:每次函数调用都会分配新的栈空间,可能导致栈溢出,尤其是在深度递归时。

启用尾调用优化的情况

以下是使用 jump 指令(尾调用优化)时的栈帧变化:

  1. 在调用 add 之前,sub 已经将计算所需的参数(16 和 -12)准备好,并放置在当前栈帧中即将被重用的位置。
  2. 使用 jump 指令直接跳转到 add 的代码,而不是 call
  3. RSP 寄存器没有移动add 函数将直接使用 sub 函数当前的栈帧空间。
  4. sub 栈帧中旧的数据被 add 的新参数覆盖。

核心机制:当函数调用处于“尾位置”(即该调用的返回值直接作为外层函数的返回值)时,可以安全地重用调用者的栈帧,因为调用者之后不再需要执行任何代码。

公式表示尾位置条件
一个表达式 E 处于尾位置,当且仅当对其求值后,不需要再执行任何其他计算即可返回其值。

代码示例对比

; 非尾调用 (使用 call)
call add_label
ret

; 尾调用优化 (使用 jump)
; ... 准备参数 ...
jmp add_label ; 重用当前栈帧,不压入返回地址

总结:尾调用优化通过将尾位置的函数调用从 call 改为 jump,并重用当前栈帧,避免了栈空间的线性增长。这对于递归和函数式编程范式至关重要。


语法分析入门 🌳

之前我们一直假设能将程序代码转换为 S 表达式(嵌套列表)。现在,我们正式探讨这个过程,即语法分析

语法分析器负责将扁平的令牌序列转换为能反映程序层次结构的树形表示(如抽象语法树 AST)。

例如,字符串 "(+ 1 2)" 经过词法分析变成令牌列表:['(', '+', '1', '2', ')']。语法分析器则将其转换为嵌套结构:['+', 1, 2],这体现了 + 是操作符,12 是其参数。

语法:语言的蓝图

要为一种语言编写语法分析器,我们首先需要定义该语言的语法。语法是一套规则,精确描述了哪些字符串是合法的程序。

我们以 S 表达式的简化语法为例:

Sx ::= Num | Sym | Lparen List Rparen
List ::= Sx List | ε

术语解释

  • 非终结符:用尖括号 < > 括起,表示语法中的中间类别,如 <Sx>, <List>。它们不会直接出现在最终的程序字符串中。
  • 终结符:构成程序字符串的基本单元,如 Num (数字)、Sym (符号)、Lparen (左括号)、Rparen (右括号)、ε (空字符串)。
  • 产生式规则:定义如何从一个非终结符推导出符号序列。例如 <Sx> ::= Num 是一条规则。符号 ::= 表示“可推导为”,| 表示“或”。
  • 开始符号:语法推导的起点,通常是第一个出现的非终结符(这里是 <Sx>)。

语法树:推导的可视化

根据语法规则,我们可以为合法程序构建一棵语法树

例如,为程序 (+ 1 2) 构建语法树:

  1. 从开始符号 <Sx> 开始。
  2. 应用规则 <Sx> ::= Lparen List Rparen
  3. <List> 部分应用规则 <List> ::= Sx List
    • 第一个 <Sx> 推导为 Sym (+)。
    • 第二个 <List> 再次应用 <List> ::= Sx List
      • <Sx> 推导为 Num (1)。
      • <List> 应用规则 <List> ::= Sx List
        • <Sx> 推导为 Num (2)。
        • <List> 应用规则 <List> ::= ε (空字符串,表示结束)。
  4. 将所有终结符按顺序连接起来,就得到了原始字符串 (+ 1 2)

这棵树的叶子节点全是终结符,内部节点则是非终结符,清晰展示了字符串的语法结构。

语法分析的核心任务

语法分析的核心问题是:给定一个字符串,判断它是否属于该语言(即是否合法)?

判断方法:尝试使用语法的产生式规则,为该字符串构建一棵语法树。如果能成功构建出一棵完整的语法树,且树的叶子节点按顺序正好组成该字符串,那么这个字符串就是合法的程序。


总结 🎯

本节课中我们一起学习了两个关键部分。

首先,我们深入分析了尾调用优化,通过对比汇编代码,理解了它如何通过 jump 指令和参数准备来重用栈帧,从而避免递归深度过大时的栈溢出问题。我们明确了“尾位置”的定义以及该优化的适用场景。

其次,我们正式开启了语法分析的主题。我们介绍了语法的基本概念,包括终结符、非终结符和产生式规则,并使用 S 表达式的简化语法作为示例。我们了解了如何通过语法规则为合法程序构建语法树,并认识到语法分析的本质就是为输入字符串寻找这样一棵合法的语法树。

从下一讲开始,我们将更详细地探讨如何根据语法来实现一个语法分析器。

18:正则表达式

在本节课中,我们将学习正则表达式,并了解它们如何用于词法分析(tokenization)。我们将从回顾语法和解析树开始,然后深入探讨正则表达式的语法、核心概念,以及如何将它们转换为有限自动机以实现高效的字符串匹配。

回顾:语法与解析树

上一节我们介绍了语法和解析树的概念。语法用于定义编程语言中哪些字符串是合法的程序。我们使用非终结符和产生式规则来描述这些结构。

例如,一个简单的S表达式语法可能包含以下规则:

S -> num N | sym S | LPAREN list RPAREN
list -> ε | S list

解析树展示了如何从起始符号开始,应用一系列产生式规则,最终生成一个完全由终结符(即程序中的实际字符)组成的字符串。如果能为一个输入字符串构建出完整的解析树,那么该字符串就被语法所接受。

从语法到解析器

本节中,我们将看看如何根据语法来构建一个解析器。我们的目标是编写一个函数,将输入的词法单元(token)列表转换为S表达式的抽象语法树(AST)。

我们为语法中的每个非终结符编写一个辅助函数。每个函数负责处理与该非终结符相关的所有产生式规则。

以下是解析S表达式的辅助函数框架:

let rec parse_sx (toks : token list) : sx * token list =
  match toks with
  | Num n :: toks2 -> (Num n, toks2)
  | Sym s :: toks2 -> (Sym s, toks2)
  | LParen :: toks2 ->
      let (xs, toks3) = parse_list toks2 in
      (List xs, toks3)
  | _ -> raise ParseError

and parse_list (toks : token list) : sx list * token list =
  match toks with
  | RParen :: toks2 -> ([], toks2)
  | _ ->
      let (x2, toks2) = parse_sx toks in
      let (x3, toks3) = parse_list toks2 in
      (x2 :: x3, toks3)

这个解析器递归地处理输入,根据遇到的词法单元类型选择相应的产生式规则。如果遇到无法匹配任何规则的情况,则抛出解析错误。

词法分析与正则表达式

现在,我们来看看词法分析,即如何将原始字符串转换为词法单元列表。一个简单但低效的方法是按照空格分割字符串。然而,更健壮的方法是使用正则表达式来精确匹配每种词法单元的模式。

正则表达式提供了一种简洁的方式来表达字符串模式。它们可以高效地检查一个字符串是否匹配某种模式,这对于快速识别词法单元至关重要。

正则表达式的语法

正则表达式本身也有其语法。以下是其核心组成部分:

  • 字符字面量:例如 a,只匹配字符串 "a"
  • 连接:例如 ab,匹配字符串 "ab"
  • 选择(或):例如 a|b,匹配字符串 "a""b"
  • 克林星号(Kleene star):例如 a*,匹配零个或多个 "a",如 """a""aa" 等。
  • 分组:使用括号 () 来改变优先级,例如 (ab)* 匹配 """ab""abab" 等。

基于这些核心操作,可以定义一些有用的简写:

  • 加号(+)a+ 表示 aa*,匹配一个或多个 "a"
  • 问号(?)a? 表示 (a|ε),匹配零个或一个 "a"
  • 字符类[abc] 表示 a|b|c,匹配 "a""b""c"

例如,匹配整数的正则表达式可以是:-?[0-9]+。这表示一个可选的负号,后跟一个或多个数字。

正则表达式与有限自动机

为了高效地使用正则表达式进行匹配(例如在词法分析器中),我们将其转换为有限自动机。有限自动机是一种抽象机器,包含一组有限的状态、一个起始状态、一个或多个接受状态,以及基于输入字符在状态间转移的规则。

有限自动机分为两种:

  • 确定性有限自动机:对于每个状态和输入字符,恰好有一个转移。
  • 非确定性有限自动机:对于每个状态和输入字符,可能有零个、一个或多个转移,并且允许空字符(ε)转移。

正则表达式、NFA和DFA在表达能力上是等价的,都可以描述正则语言

从正则表达式构建NFA

我们可以机械地将任何正则表达式转换为一个NFA。以下是核心构造规则:

  1. 字符 a:创建两个状态,用标记为 a 的边连接。
  2. 连接 AB:将 A 的接受状态通过 ε 边连接到 B 的起始状态。
  3. 选择 A|B:创建一个新的起始状态,通过 ε 边分别连接到 AB 的起始状态;将 AB 的接受状态通过 ε 边连接到一个新的共同接受状态。
  4. 克林星号 A*:创建一个新的起始状态(也是接受状态),通过 ε 边连接到 A 的起始状态;将 A 的接受状态通过 ε 边连接回它自己的起始状态,并连接到最终的接受状态。

通过递归应用这些规则,可以为任何正则表达式构建出对应的NFA。虽然生成的NFA可能包含许多ε转移,看起来不够简洁,但这个过程是完全机械化的,易于自动化。

为何使用有限自动机

在词法分析中使用有限自动机(特别是DFA)的主要优势在于效率。DFA可以在线性时间内扫描输入字符串,每个字符只处理一次,无需回溯。这对于处理大量词法单元或长文件至关重要。

实际实现中,词法分析器通常:

  1. 为每种词法单元类型定义正则表达式。
  2. 将所有正则表达式合并为一个大的NFA(例如通过“或”操作)。
  3. 将NFA转换为DFA。
  4. 使用该DFA对输入进行线性扫描,识别出最长的匹配词法单元。

总结

本节课中我们一起学习了正则表达式及其在编译器词法分析阶段的应用。我们回顾了语法和解析树,并了解了如何根据语法编写递归下降解析器。接着,我们深入探讨了正则表达式的语法和核心操作,并理解了它们与有限自动机(NFA/DFA)的等价性。最后,我们看到了如何机械地将正则表达式转换为NFA,并理解了使用DFA进行词法分析可以实现高效、线性的字符串匹配,这是构建实用编译器的关键一步。

19:解析(续)🎯

在本节课中,我们将继续学习解析技术。我们将回顾正则表达式活动,深入探讨解析中的常见问题,并学习如何通过调整文法来构建高效的递归下降解析器。


回顾:之前的词法分析活动 🔍

上一节我们介绍了词法分析,并使用有限自动机来处理正则表达式。现在,我们来看看如何自己编写正则表达式。

以下是测试正则表达式的方法:

  • 在编辑器中启用正则表达式搜索模式。
  • 在搜索框中,在正则表达式前后各加一个空格,以便精确匹配我们提供的测试用例。

活动一:匹配以 ‘a’ 开头的单词

目标:编写一个正则表达式,匹配所有以字母 ‘a’ 开头的单词。

解决方案a[a-z]*

  • a 匹配开头的字母 ‘a’。
  • [a-z] 匹配任何小写字母。
  • * 表示前面的 [a-z] 可以出现零次或多次。

活动二:匹配至少包含两个 ‘a’ 的小写单词

目标:编写一个正则表达式,匹配所有包含至少两个字母 ‘a’ 的小写单词。

解决方案[a-z]*a[a-z]*a[a-z]*

  • [a-z]* 匹配任意数量(包括零个)的小写字母。
  • 模式 a[a-z]*a 确保字符串中至少有两个 ‘a’,并且它们之间可以有任意数量的其他字母。

活动三:匹配至少包含两个 ‘a’ 但 ‘a’ 不在开头或结尾的小写单词

目标:编写一个正则表达式,匹配包含至少两个 ‘a’,但单词不以 ‘a’ 开头或结尾的小写单词。

解决方案[b-z]+[a-z]*a[a-z]*a[a-z]*[b-z]+

  • [b-z]+ 确保单词以 ‘b’ 到 ‘z’ 之间的字母开头和结尾。
  • 中间部分 [a-z]*a[a-z]*a[a-z]* 确保字符串内部至少有两个 ‘a’。

活动四:匹配交替的 ‘a’ 和 ‘b’ 序列

目标:编写一个正则表达式,匹配像 “ab”, “aba”, “abab” 这样交替出现的 ‘a’ 和 ‘b’ 序列。

解决方案(ab)+(a)?

  • (ab)+ 表示 “ab” 这个单元至少出现一次。
  • (a)? 表示末尾可以有一个可选的 ‘a’,使得序列也能以 ‘a’ 结尾(例如 “aba”)。

活动五:匹配 aⁿbⁿ 形式的字符串(不可行)

目标:尝试编写一个正则表达式来匹配像 “aaabbb” 这样,含有 n 个 ‘a’ 后接 n 个 ‘b’ 的字符串。

结论这是不可能用正则表达式实现的

  • 正则表达式、确定性有限自动机(DFA)和非确定性有限自动机(NFA)的表达能力是等价的,它们所能描述的语言类别称为正则语言
  • aⁿbⁿ 这种需要“计数”和“配对”的结构(例如括号匹配)超出了正则语言的范围。
  • 直观理解:要为 aⁿbⁿ 构建有限自动机,我们需要为每一个可能的 n 值准备不同的状态路径。由于 n 可以是任意大的,这需要无限多个状态,但有限自动机的状态必须是有限的。

这个例子很好地说明了为什么我们需要将词法分析(标记化)和语法分析(解析)分开。词法分析器使用正则表达式来快速识别线性结构的标记,而解析器则使用更强大的工具(如上下文无关文法)来处理嵌套和递归的语法结构。


解析技术概览 🛠️

上一节我们介绍了如何为一个友好的文法编写解析器。本节中,我们来看看处理任意上下文无关文法时可能遇到的普遍问题。

我们期望解析器能在线性时间内运行,以处理大型程序。然而,并非所有文法都能直接生成线性时间的解析器。我们需要对文法进行一些调整。

解析器的分类

主要的解析器类型如下:

  1. 自顶向下解析:从文法的开始符号出发,逐步推导出整个句子。我们上次编写的解析器就属于此类。
  2. 自底向上解析:从输入的词法标记序列出发,逐步规约到文法的开始符号。

历史上,自底向上解析器常由解析器生成器自动生成。编译器作者只需提供上下文无关文法,工具就能自动生成解析器代码。虽然方便,但这种解析器产生的错误信息通常非常糟糕,难以理解和调试。因此,现代生产级的编译器(如GCC)更倾向于使用手写的自顶向下解析器

递归下降解析

我们重点学习递归下降解析,它是一种自顶向下的手写解析方法。

优点

  • 直观、灵活。
  • 易于生成清晰、友好的错误信息。
  • 解析器代码结构直接对应文法规则。

缺点

  • 有时需要对原始文法进行调整,才能适用于递归下降解析。
  • 主要需要处理三个问题:歧义性左公因子左递归

接下来,我们将逐一探讨这些问题及其解决方案。


问题一:歧义性 🤔

歧义性是指一个句子可以根据同一文法,构建出多个不同的语法树。

示例文法

Exp -> Exp ‘+’ Exp
Exp -> Exp ‘*’ Exp
Exp -> num

对于字符串 1 + 2 * 3,该文法可以生成两种语法树:

  • (1 + 2) * 3 (先加后乘)
  • 1 + (2 * 3) (先乘后加)

这会导致语义上的不确定性(例如,在解释器中得到不同的计算结果)。

解决方案:没有自动消除歧义的通用算法。我们必须根据语言设计者的意图(例如,乘法的优先级高于加法),重新设计文法来强制规定唯一的语法树结构。

修改后的无歧义文法

Exp  -> Term ‘+’ Exp | Term
Term -> Factor ‘*’ Term | Factor
Factor -> ‘(’ Exp ‘)’ | num

这个新文法通过引入不同的非终结符(Exp, Term, Factor)来体现优先级,强制乘法运算符在更深的语法层次中结合,从而消除了歧义。注意:新文法与原文法接受的语言(字符串集合)相同,但为每个句子规定了唯一的语法结构。


问题二:左公因子提取 🔧

当同一个非终结符的多个产生式以相同的符号序列开头时,解析器无法仅通过查看第一个输入标记来决定使用哪个产生式,可能导致回溯,影响效率。

示例文法

S -> B other C
S -> B other E
other -> C other | D

在解析输入 B C D E 时,解析器在遇到 C 后,需要猜测是使用 S -> B other C 还是 S -> B other E,可能造成错误尝试和回溯。

解决方案提取左公因子

  1. 识别产生式中的公共前缀(B other)。
  2. 将公共前缀提取为一个新的产生式。
  3. 为不同的后缀引入一个新的非终结符。

修改后的文法

S -> B other Last
Last -> C | E
other -> C other | D

现在,解析器看到 B 后可以确定使用 S -> B other Last,看到 CE 后再决定 Last 的展开方式,避免了回溯。


问题三:左递归消除 🔄

如果一个产生式的右侧开头就是其自身左侧的非终结符,即形如 A -> A ...,这会导致递归下降解析器陷入无限递归。

示例文法(左递归):

Exp -> Exp ‘+’ Term | Term

在解析函数 parse_Exp() 中,第一条规则会立即调用 parse_Exp(),导致无限循环。

解决方案:将左递归改写为右递归

修改后的文法(右递归):

Exp -> Term Exp’
Exp’ -> ‘+’ Term Exp’ | ε

这里 ε 表示空串。这个文法消除了立即左递归,使得递归下降解析能够正常工作。

注意:右递归文法可能会改变运算符的结合性。例如,对于减法串 1 - 2 - 3,左递归文法会生成左结合树 ((1-2)-3),而右递归文法会生成右结合树 (1-(2-3))。在后续阶段(如语义分析或中间代码生成)可能需要调整以恢复预期的结合性。


总结 📚

本节课我们一起学习了构建高效递归下降解析器时需要解决的三个关键文法问题:

  1. 歧义性:通过重新设计文法来强制规定唯一的语法树结构,以体现运算符优先级等语言规则。
  2. 左公因子:通过提取产生式开头的公共前缀,避免解析时的回溯,保证线性时间效率。
  3. 左递归:通过将左递归产生式改写为等价的右递归形式,防止递归下降解析器陷入无限递归。

掌握这些文法转换技巧,是编写健壮、高效解析器的重要基础。在接下来的课程中,我们将利用这些知识处理更复杂的语言结构。

19.5:语法分析 - 第三部分

在本节课中,我们将要学习语法分析中遇到的挑战,特别是左递归问题,并探讨解析器输出的不同表示形式,即抽象语法树。

语法分析中的挑战

上一节我们介绍了递归下降解析器的基本结构。本节中我们来看看在编写语法时可能遇到的一些具体问题,特别是左递归问题。

考虑一个简单的加法表达式 1 + 2 + 3。对于这个程序,使用加法运算符看起来没有问题。

但如果我们将加号换成减号,情况就变得不那么理想了。例如 1 - 2 - 3。突然间,解析结果可能就不符合我们的预期了。

鼓励大家在完成自己的解析作业时思考这个问题。让语法不那么递归可能感觉更自然,这通常也是我们对运算符的期望。

但有方法可以调整语法规则,以确保在像减法这样的情况下也能得到正确的行为。

解析器的输出

现在,让我们把注意力转向解析器的输出。在构建递归调用树的过程中,我们实际上是在构建解析树的表示形式。

程序执行中的每一个选择都对应着选择一条特定的产生式规则,并沿着解析树的特定分支向下走。

但如果我们仔细看看实际输出的是什么,它真的是一个解析树吗?

实际上,我们得到的是一个S表达式,而不是一个完整的解析树。它没有保留我们使用了哪些产生式规则的全部历史信息。相反,它包含了我们在编译或解释过程中需要识别和处理的核心元素。

例如,我们可能看到一个以 if 符号开头的结构,然后是需要作为条件使用的表达式。这就是我们得到的输出,而不是一个解析树。

到目前为止,我们看到的这种S表达式表示形式,虽然比原始字符串友好,但我们可以想象一种处理起来更加方便的形式。

抽象语法树

以下是一种对编译器编写者来说更友好的程序表示形式:

Let
├── x
└── 5
In
    └── x

对我来说,这比我们目前处理的形式友好得多。我们之前的形式是嵌套的列表结构,例如 [‘let’, [[‘x’, 5]], ‘x’],我们需要层层处理这些细节。

实际上,当我们尝试生成汇编代码或进行解释时,并不关心原始输入程序中那些特定的括号是如何组织的。我们可能更喜欢一种能直接表示我们需要执行的操作、反映语言核心结构的表示形式。

因此,我可能希望它是这样的:

Let("x", 5, Var("x"))

这似乎比我们之前使用的嵌套列表形式更友好。这就是我们所说的抽象语法树

让我们逐一分析这个术语:

  • :这很明显,我们通过将程序编码为树来保持其结构。
  • 语法:它仍然反映了程序的基本布局。
  • 抽象:在这个意义上,它仍然是具体的,因为我们还没有对程序形状做任何额外的处理来改变它。

语法与语言的分离

让我们思考一下,如果我们使用旧版本的 let 语法,应该如何恰当地表示相同的信息。花大约20秒与旁边的同学讨论:如果使用我们Scheme风格的语法,是否应该改变这种表示形式?

这是一个有趣的问题。为了进行编译或解释所需的所有信息,在这里都存在吗?答案是肯定的。

我想强调的是,这基本上可以替代我们目前使用的东西,因为我们目前使用的就是S表达式的抽象语法树。我们没有理由不能拥有专门为我们实际支持的语言定制的抽象语法树,这是一种更清晰的表示形式。

需要强调的是,即使我们改变了语法,只要拥有进行编译或解释所需的所有关键信息,就完全没有必要改变抽象语法树。这个树可以对应旧的语法,也可以对应新的语法。

事实上,这就是编译器组件的工作原理。在中间,我们有这个AST,它是在词法分析和语法分析之后产生的。

我想强调的是,我们可以有完全不同的源代码,完全不同的词法分析器,完全不同的解析器,但仍然生成相同的AST。这正是你们在作业中要做的。

以前我们一直使用这种类似Scheme的语法。在作业中,你们将使用这种更类似OCaml的语法,然后仍然将其插入完全相同的编译器,因为尽管语法改变了,但底层语言是相同的。

这就是我们将要真正面对的问题:语法与语言是分离的。程序的外观、为了使解析器接受而编写的特定字符串,这与语言中结构的意义是分开的。

这就是为什么我们可以做到这一点:让相同的AST被消费,并以完全相同的方式转化为指令,同时拥有两种看起来完全不同的独立语法。

所以,也许一种是 let x = 5 in x,另一种是 (let ((x 5)) x)。我们没有理由不让这两者都输入完全相同的后端代码生成器。

关于“语法与我们使用的语言结构的意义完全分离”这个想法,将是一个非常重要的概念。

构建自定义的抽象语法树

鉴于我们可能想为自己生成一些更友好的抽象语法树,我请大家花大约一分半钟与附近的人讨论,为这个程序绘制一个你们喜欢的抽象语法树。没有唯一正确的答案,但需要考虑:为了实际编译或解释这个程序,你需要哪些所有信息?

以下是我喜欢的一种表示形式:

Seq
├── Print(Num(3))
└── Print(Num(4))

我认为这提供了我们进行编译所需的所有信息。

如果使用S表达式形式的AST有什么问题吗?它已经带我们走了很远。我们现在可以拥有这种真正方便得多、为语言定制的AST。

我们将进行这种转变有几个原因。一是解释器和编译器将变得更加清晰。另一个原因是,在作业中编写自己的解析器时,你们将生成的就是这种AST。

你们将为ML风格语法编写解析器,但它仍然是为我们的语言服务的。构造的意义将完全相同,只有输入会改变。

代码库中的实现

现在,让我们看看在代码库中是如何实现的。因为我们已经在程序中设置了一种获取S表达式AST的方法,所以我们要做的是将S表达式AST转换为此类AST。

在作业7中,你们将直接把内容解析成这种类型的AST,而不需要先转换成S表达式再转换的中间步骤。

但我们现在要做的是从S表达式转换到这个AST的有趣过程。让我们看看这是如何实际工作的。

看看 ast.ml 文件。这是我们重要的新类型。当我们用OCaml编写程序时,几乎总是从编写一些重要的新类型开始,这就是我们现在要处理的新类型,它将代替S表达式。

这在很多情况下看起来有些熟悉。例如,Num 看起来相当熟悉。True 就是 true,False 就是 false。但在某些情况下,它看起来比以前的版本简单一些。

这看起来很像我们刚才在白板上画的树。之前有人问,我们能处理任意长度的列表吗?是的,在这里,我们之前已经看到了如何构造列表,这里有一个例子,Do 节点可以在其列表中包含任意数量的表达式。

这就是我们进行改变的方式,现在它将看起来很像我们刚刚在白板上画的图。这将使我们的编译器和解释器变得更好。

现在,我们不再做诸如“列表,然后是特定的字符串调用”之类的事情,而是直接说:当我们看到一个看起来像调用节点的节点时,它将有 funcargs。我们甚至保留了所有相同的名称。我们在这里做完全相同的事情,只是我们有一个专门用于表示调用的定制节点。

在我们的解释器中,我们可以看到,是的,我们正在处理 Call 节点。这实际上给我们带来了几样东西。

我想特别强调一件事。看看我们解释器的底部。你可能记得以前,在我们的调用表达式末尾,有一个看起来像 _ -> 的兜底分支。现在,如果我们尝试编译这个,我们会说,等等,我们永远不会到达这里。通过切换到这种表示形式,我们已经说服OCaml,我们的匹配是详尽的。所有可能出现在这种语言中的东西,我们都处理了。

我知道在进行这种转换时会有一些奇怪的感觉,因为我们已经习惯了以前的结构方式。现在,当我们添加新内容时,除了更改编译器或解释器之外,我们可能还必须确保我们的AST实际上能表示我们希望它表示的新功能。

如果我们加入一些以前不支持的新语言特性,我们必须在这里添加,说明它应该如何表示在我们的AST中,以及我们如何将那种S表达式转换成我们想要的漂亮表示形式。

你们可以看到我们在哪里实际做这个转换,这非常枯燥,但它只是做了我们以前做的所有相同的匹配,然后产生我们想要的漂亮表示形式。这就是你们在作业中将要生成的同类表示形式。

总结

本节课中我们一起学习了语法分析中的左递归问题及其对结合性的影响,并深入探讨了解析器的输出。我们比较了S表达式和自定义抽象语法树作为程序中间表示形式的优劣,理解了语法与语言语义分离的重要性。最后,我们查看了代码库中如何实现从S表达式AST到更友好、定制的AST的转换,为后续的编译或解释步骤提供了更清晰的数据结构。

20:一等函数 - 第一部分 🚀

在本节课中,我们将要学习如何实现“一等函数”。这意味着函数将像其他值(如数字、布尔值)一样,可以被传递、作为参数使用、从函数返回,并存储在数据结构中。我们将分阶段实现这一功能,今天首先关注将函数作为值来使用。

概述

上一节我们介绍了如何将S表达式转换为更接近我们语言内部表示(AST)的格式。本节中,我们来看看如何修改我们的解释器和编译器,以支持将函数作为一等公民来使用。

修改抽象语法树(AST)

为了支持一等函数,我们需要修改AST的表示。目前,函数调用(Call)的第一个参数是一个字符串(函数名)。但现在,这个位置可以是一个任意的表达式,其求值结果应该是一个函数值。

以下是需要修改的AST定义:

(* 旧的AST定义 *)
type expr =
  | ...
  | Call of string * expr list  (* 函数名,参数列表 *)
  | ...

(* 新的AST定义 *)
type expr =
  | ...
  | Call of expr * expr list    (* 函数表达式,参数列表 *)
  | ...
  | Function of string          (* 新增:函数值构造器 *)

这个改变意味着,在函数调用中,我们不再直接查找函数名,而是先对第一个表达式求值,得到一个函数值。

更新解释器

现在,我们需要更新解释器来处理新的Function值,并修改函数调用的逻辑。

添加函数值

首先,我们在解释器的值类型中添加一个新的构造器来表示函数值。

type value =
  | Num of int
  | Bool of bool
  | Pair of (value * value)
  | Function of string  (* 函数名 *)

修改函数调用逻辑

函数调用的逻辑需要改变。我们不再假设第一个参数是字符串,而是先对它进行求值,然后检查结果是否为函数值。

以下是修改后的函数调用处理逻辑:

let rec interp_expr (defns : defn list) (env : value Symtab.t) (e : expr) : value =
  match e with
  | ...
  | Call (f, args) ->
      (* 1. 对函数表达式 f 求值 *)
      let fv = interp_expr defns env f in
      (* 2. 检查 fv 是否为函数值 *)
      (match fv with
       | Function name ->
           (* 3. 查找函数定义 *)
           let def = List.find (fun d -> d.name = name) defns in
           (* 4. 对参数求值 *)
           let arg_vals = List.map (interp_expr defns env) args in
           (* 5. 创建新环境并求值函数体 *)
           let new_env = List.fold_left2 (fun env param arg -> Symtab.add env param arg)
                                         (Symtab.empty) def.params arg_vals
           in
           interp_expr defns new_env def.body
       | _ -> raise (BadExpression "试图调用非函数值"))
  | ...

处理变量引用

当解释器遇到一个变量名时,它首先在环境中查找。如果找不到,现在需要检查它是否是一个顶层函数名。

  | Var x ->
      (match Symtab.find_opt env x with
       | Some v -> v
       | None ->
           (* 检查是否是顶层函数名 *)
           if List.exists (fun d -> d.name = x) defns then
             Function x  (* 返回函数值 *)
           else
             raise (UnboundVariable x))

更新编译器

编译器也需要进行类似的修改。我们需要一种在运行时表示函数值的方法,并修改函数调用的代码生成。

运行时表示函数值

我们决定使用函数代码的起始地址(标签地址)来表示函数值,并使用一个特殊的标签位来标记。

let function_tag = 0b111  (* 例如,使用最后三位作为函数标签 *)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucb-cs164-pl-cmpl/img/2248c37dab8bef217426d4f5fc0135eb_30.png)

let ensure_fn (rax_reg : reg) : directive list =
  [ Mov (R11, rax_reg)
  ; And (R11, Imm heap_mask)  (* 检查标签位 *)
  ; Cmp (R11, Imm function_tag)
  ; Jne (Label "error_not_function") ]

修改函数调用的代码生成

编译器中的函数调用生成也需要改变。我们不再直接跳转到一个已知的标签,而是需要先计算函数表达式的值(它现在在RAX中),确保它是一个函数,然后进行调用。

以下是修改后的非尾调用代码生成逻辑:

let rec compile_expr (defns : defn list) (tab : int Symtab.t) (stack_index : int) (is_tail : bool) (e : expr) : directive list =
  match e with
  | ...
  | Call (f, args) when not is_tail ->
      (* 1. 编译参数,将它们压入栈中 *)
      let compiled_args = ... in
      (* 2. 编译函数表达式 f,结果在 RAX 中 *)
      let compiled_f = compile_expr defns tab (stack_index - 8 * List.length args) false f in
      compiled_args @
      compiled_f @
      [ Ensure_fn Rax ] @          (* 确保RAX中是函数值 *)
      [ Sub (Rax, Imm function_tag) ] @ (* 去掉标签位,得到真实地址 *)
      [ ComputedCall Rax ]         (* 调用RAX中的地址 *)
  | ...

尾调用的情况也需进行类似修改,使用ComputedJump

测试新功能

经过以上修改,我们的语言现在支持将函数作为值传递。例如,以下程序现在可以正常工作:

(define (f g) (g 2))
(define (m2 x) (+ x x))
(print (f m2))  ; 输出 4

我们也可以给函数起“别名”:

(let ((new-name m2))
  (print (new-name 5)))  ; 输出 10

引入语法糖:匿名函数(Lambda)

我们目前已经实现了将已命名的函数作为一等值。但许多现代语言也支持匿名函数(通常称为lambda表达式)。例如,我们想这样写:

(map (lambda (x) (+ x 1)) (list 0 1 2 3))

而不是:

(define (inc x) (+ x 1))
(map inc (list 0 1 2 3))

实现策略:脱糖(Desugaring)

我们不需要修改核心的解释器或编译器来支持lambda。相反,我们可以将其视为一种语法糖。我们可以在将源代码转换为核心AST之前,进行一个额外的“脱糖”转换。

我们的新编译管道如下:

  1. 字符串 -> 词法分析/语法分析 -> 扩展AST (包含 Lambda 节点)
  2. 扩展AST -> 脱糖转换 -> 核心AST (将 Lambda 转换为顶层函数定义)
  3. 核心AST -> 编译/解释

定义扩展AST

我们创建一个新的AST类型expr_lambda,它包含所有核心表达式,外加一个Lambda构造器。

type expr_lambda =
  | Num of int
  | ...
  | Call of expr_lambda * expr_lambda list
  | Lambda of string list * expr_lambda  (* 参数列表,函数体 *)

脱糖转换

转换函数expr_lambda_to_expr负责将expr_lambda转换为只包含核心特性的expr。对于Lambda节点,转换步骤如下:

  1. 生成一个唯一的函数名(例如,使用gensym("lambda"))。
  2. 将这个新函数(包含参数和转换后的函数体)添加到顶层定义列表(defns)中。
  3. Lambda出现的位置,替换为一个对该新生成函数名的引用(即Function name值)。

这样,在后续的编译或解释阶段,这个匿名函数就被当作一个普通的顶层函数来处理了。

总结

本节课中我们一起学习了:

  1. 一等函数的含义:函数可以作为值被传递、存储和使用。
  2. 如何通过修改AST、解释器和编译器来支持将命名函数作为值
  3. 如何通过脱糖技术来支持匿名函数(lambda),而无需修改核心的解释和编译逻辑。我们引入了一个包含Lambda节点的扩展AST,并通过转换将其提升为顶层函数定义。

通过分阶段实现,我们成功地为我们的语言添加了强大的一等函数支持,这是构建现代编程语言的关键一步。下一节,我们将探讨更复杂的概念——闭包。

21:一等函数(第二部分)🎯

在本节课中,我们将继续学习一等函数的实现。我们将探讨如何处理词法作用域下的匿名函数,特别是当函数体需要访问其定义环境之外的变量时,编译器应如何应对。我们将引入“闭包”这一核心概念,并学习如何计算“自由变量”,最终在编译器中实现闭包的运行时表示。


概述

上一节我们介绍了如何将函数作为值来处理,并通过将匿名函数提升到顶层并赋予新名称的方式来实现。然而,这种方法在处理词法作用域时遇到了问题。本节中,我们将看看当匿名函数需要访问其定义环境中的变量(即“自由变量”)时,应如何实现。核心解决方案是使用闭包,它将函数代码与其定义时的环境绑定在一起。


为什么简单的提升方法会失效?

考虑以下程序:

(let ((y 3))
  ((lambda (x) (+ x y)) 2))

按照我们之前的方法,我们会将 (lambda (x) (+ x y)) 这个匿名函数提升到顶层,并生成一个新的顶层函数定义。但这样做会带来一个问题:提升后的函数将无法访问变量 y,因为 y 只在 let 表达式的作用域内有效。

因此,我们不能简单地将函数体移出它被定义的环境。我们需要一种方法,在函数被创建时“记住”当时可用的变量。


解决方案:闭包

闭包是一个数据结构,它包含两部分:

  1. 函数代码的引用(例如,函数体的起始地址)。
  2. 定义时的环境(即,函数体内用到的、但并非其参数的变量及其值的映射)。

在解释器中,我们可以直接修改值的表示来包含环境。例如,函数值可以表示为一个包含函数名(用于查找定义)和当前符号表的对(pair)。

公式表示
函数值 = (函数标识符, 定义时环境)

然而,在编译器中,环境(符号表)是编译时的概念,在运行时并不存在。我们需要一种在运行时表示这些“额外变量”的方法。


关键概念:自由变量与约束变量

为了高效地构建闭包,我们需要知道一个函数体到底需要哪些外部变量。这引出了两个重要概念:

  • 约束变量:在表达式内部被定义的变量(例如,函数参数、let 绑定的变量)。
  • 自由变量:在表达式内部被使用,但未在该表达式内部定义的变量。

示例分析
在表达式 (lambda (x) (+ x y)) 中:

  • x约束变量(它是该 lambda 的参数)。
  • y自由变量(它在 lambda 内部被使用,但未在其中定义)。

闭包只需要捕获自由变量,因为约束变量会在函数调用时通过参数传递或内部绑定获得新值。


计算自由变量

我们需要一个函数 fv(free variables),它接收一个表达式和一个已绑定变量列表,返回该表达式中的自由变量集合。

以下是该函数处理几种核心表达式类型的逻辑:

以下是 fv 函数处理几种核心表达式类型的逻辑:

  • 变量引用:当遇到一个变量名 s 时,检查它是否在当前的“已绑定”列表中。如果不在,则它是一个自由变量。

    | Var s when not (List.mem s bound) -> [s]
    
  • Let 表达式(let ((v e)) body)

    1. 首先计算表达式 e 中的自由变量(使用当前的 bound 列表)。
    2. 然后计算 body 中的自由变量,但此时需要将变量 v 加入到 bound 列表中,因为它在 body 内部是已定义的。
    | Let (v, e, body) ->
        let free_in_e = fv bound e in
        let free_in_body = fv (v :: bound) body in
        union free_in_e free_in_body
    
  • Lambda 表达式(lambda (args...) body)
    计算 body 中的自由变量,但需要将函数参数 args... 加入到 bound 列表中。

    | Lambda (args, body) ->
        fv (args @ bound) body
    

通过递归地遍历抽象语法树(AST),我们可以为任何代码片段(特别是 lambda 表达式)计算出其自由变量列表。这个列表是有限且固定的,在编译时即可确定。


闭包的运行时表示

知道了自由变量集合后,我们如何在运行时表示一个闭包呢?我们将在堆上分配一块内存。

闭包的内存布局

+-------------------+
|   函数代码地址     |  // 指向函数体的第一条指令
+-------------------+
|  自由变量1的值     |  // 第一个自由变量的值
+-------------------+
|  自由变量2的值     |  // 第二个自由变量的值
+-------------------+
|        ...        |
+-------------------+

示例
对于程序 ((lambda (x) (+ x y)) 2),假设 y 在当前环境中的值是 8。

  1. fv 计算得出 lambda 的自由变量是 [y]
  2. 创建的闭包在堆上包含:
    • 一个指向 (lambda (x) (+ x y)) 代码的指针。
    • 变量 y 的值(即 8)。
  3. 当调用这个闭包时,除了传递常规参数 2(给 x),我们还需要一种方式让函数体能够访问到闭包中存储的 y 的值(8)。

调用闭包:传递“额外参数”

为了在函数体内访问自由变量,我们需要修改函数调用约定。我们将闭包本身的地址作为一个“额外的隐式参数”传递给函数。

修改后的调用栈布局(概念上):

+-------------------+
|    返回地址        |
+-------------------+
|  闭包指针          | <-- 额外的“隐式参数”,指向堆上的闭包数据
+-------------------+
|  实际参数1 (x=2)   | <-- 常规参数
+-------------------+

在函数体的代码中,我们可以通过这个“闭包指针”来加载自由变量(例如 y 的值)到寄存器中使用。


总结

本节课我们一起学习了如何实现支持词法作用域的一等函数。

  1. 问题识别:简单的“提升”方法无法处理函数对定义环境外自由变量的访问。
  2. 核心方案:引入闭包,在创建函数时捕获其所需的自由变量。
  3. 关键分析:通过区分自由变量约束变量,我们可以精确知道需要捕获哪些变量。
  4. 编译时计算:实现 fv 函数,在编译时静态分析出任意表达式的自由变量集合。
  5. 运行时表示:在堆上分配闭包数据结构,包含函数指针和自由变量的值。
  6. 调用约定:修改调用约定,将闭包指针作为隐式参数传递,使函数体能访问捕获的环境。

通过这套机制,我们使得匿名函数能够正确地遵守词法作用域规则,即使在被当作值传递和调用时,也能访问其定义时所在环境的变量。这为一等函数提供了完整且强大的支持。

22:一等函数(第三部分)🎯

在本节课中,我们将要学习闭包的实现细节。我们将一起探讨编译器如何构建和使用闭包,以支持具有词法作用域的一等函数。

闭包实现方案回顾

上一节我们介绍了闭包的概念和基本实现思路。本节中,我们来看看编译器如何具体实现这一方案。

我们决定,当为匿名lambda设置栈时,它应该看到类似这样的布局:返回地址在末尾,第一个参数在常规位置,但新增了一个指向堆上闭包的指针。

闭包包含两部分信息:

  1. 函数体第一条指令的地址(通常用标签表示)。
  2. 该代码片段中所有自由变量的值。

自由变量是指在代码片段中被使用但未在该片段内定义的变量。例如,在代码 (lambda (y) (+ x y)) 中,x 是自由变量,而 y 是绑定变量。

构建闭包

以下是编译器构建闭包的关键步骤。

处理顶层函数定义

首先,我们处理顶层函数定义。由于顶层函数没有自由变量,其闭包构建相对简单。

; 获取函数标签对应的地址
lea rax, [rel <function_label>]
; 将地址存入堆中特定位置
mov [rdi], rax
; 将堆指针(即闭包指针)存入rax
mov rax, rdi
; 应用函数标签(例如,标签值6)
or rax, 6
; 移动堆指针,保护已分配的闭包空间
add rdi, 8

这个过程主要是在堆上分配空间,存储函数入口地址,并返回一个带标签的闭包指针。

处理匿名Lambda表达式

对于包含自由变量的匿名Lambda,闭包构建更为复杂。核心步骤如下:

  1. 识别自由变量:编译器分析Lambda表达式体,找出所有自由变量,生成一个变量名列表。
  2. 获取自由变量值:在创建闭包的时刻,利用当前的符号表,从栈中获取每个自由变量对应的运行时值。
  3. 组装闭包:在堆上分配空间,依次存入函数入口地址和所有自由变量的值。

以下是关键代码逻辑的示意:

(* 假设 fvs 是自由变量名列表 *)
let heap_offset = ref 8 in (* 跳过存储函数指针的第一个字 *)
List.iter (fun fv_name ->
  let stack_addr = get_stack_address fv_name symbol_table in
  (* 生成汇编:从 stack_addr 加载值到 rax,然后存入堆的 [rdi + !heap_offset] *)
  emit_load_from_stack stack_addr;
  emit_store_to_heap !heap_offset;
  heap_offset := !heap_offset + 8
) fvs

为什么可以从栈中获取自由变量的值? 因为在编译时,创建闭包的位置的符号表记录了当前作用域内所有变量的栈偏移量。因此,我们可以通过符号表查询到自由变量 x 的栈地址,并在运行时从该地址加载其值。

为什么需要将值存入堆(Heap)而非仅存储栈指针? 考虑一个函数返回了一个闭包。当该函数返回后,其栈帧会被销毁。如果闭包中只存储了指向原栈帧的指针,那么这个指针将变成悬垂指针,指向无效或已被重用的内存。将值拷贝到堆上可以确保闭包的生命周期独立于其创建时的栈帧。

调用闭包

构建闭包后,我们需要在调用点正确地使用它。调用闭包与调用普通函数指针的主要区别在于两点:

  1. 传递闭包指针:在设置调用栈时,除了常规参数,还需要将闭包指针作为一个“额外”的参数压入栈中特定位置,以便被调函数访问。
  2. 跳转到正确地址:从寄存器中取出的闭包指针指向堆上的闭包结构体。我们需要从该结构体的第一个字中取出真正的函数入口地址,然后跳转到该地址执行。

以下是非尾调用情况下的改动示意:

; ... 压入常规参数 ...
; 将闭包指针(假设在 rax 中)作为“额外参数”压栈
mov [rsp - <offset_for_closure>], rax
; 从闭包中加载函数入口地址:rax 目前是闭包指针,先解引用获取第一个字(函数地址)
mov rax, [rax - 6] ; 先去掉标签(假设标签为6)
; 现在 rax 中是真正的函数地址,进行调用
call rax

编译函数定义(处理闭包参数)

当控制权转移到Lambda函数体后,函数需要能够访问到传递给它的闭包中的自由变量。因此,我们需要修改函数定义的编译过程。

主要改动如下:

  1. 扩展符号表:函数定义的符号表不仅包含其形式参数,还需要包含所有自由变量。自由变量列表可以通过再次分析函数体获得(顺序与创建闭包时一致)。
  2. 从闭包中加载自由变量:在函数体执行前,生成汇编指令,从传入的闭包指针中解引用,将各个自由变量的值加载到扩展后符号表所指定的栈位置。
(* 在编译函数定义起始处 *)
let fvs = get_free_variables function_body in
let extended_symbol_table = add_arguments_and_free_vars_to_symbol_table formal_args fvs in
(* 生成汇编:从闭包中加载每个自由变量到栈上对应位置 *)
List.iteri (fun idx fv_name ->
  let stack_slot_for_fv = lookup_stack_offset fv_name extended_symbol_table in
  (* 生成汇编:从闭包指针(通常在某个固定寄存器或栈位)的 [8 * (idx+1)] 偏移处读取值,存入 stack_slot_for_fv *)
  emit_load_from_closure idx stack_slot_for_fv
) fvs

这样,在函数体内,自由变量就可以像普通局部变量或参数一样,通过其在栈上的固定偏移量进行访问了。

示例与练习

通过一个具体示例,我们可以追踪闭包从创建、传递到使用的完整生命周期。例如,对于程序:

(let ((y 8))
  (let ((f (lambda (x) (+ x y))))
    (f 3))) ; 应返回 11

编译器会:

  1. (lambda (x) (+ x y)) 创建闭包,包含函数地址和自由变量 y 的值(8)。
  2. 调用 f 时,将闭包指针和实际参数 3 一同设置到栈上。
  3. 进入lambda体后,从传入的闭包中取出 y 的值(8)并放入栈帧,然后执行 (+ 3 8)

边界情况与思考

实现闭包后,我们的语言能力得到了极大增强,但也需要考虑一些边界情况:

  • 顶层函数的自由变量:之前的编译器允许顶层函数引用未定义的变量(自由变量),这可能导致未定义行为。现在需要添加检查,禁止顶层函数存在自由变量。
  • 内存管理:闭包在堆上分配,目前我们尚未实现垃圾回收。在更复杂的语言中,需要考虑闭包内存的回收问题。

本节课中我们一起学习了闭包在编译器中的完整实现路径,包括闭包的构建、传递以及在函数调用时的解包和使用。这使得我们支持了具有真正词法作用域的一等函数,是编程语言实现中的一个重要里程碑。

23:优化 🚀

在本节课中,我们将要学习编译器中的一个重要环节:优化。我们将探讨什么是优化,为什么需要优化,以及如何在不改变程序含义的前提下,让程序运行得更快、更高效。我们将重点介绍三种常见的优化技术:常量折叠、函数内联和公共子表达式消除。


什么是优化? 🤔

优化是指对程序进行转换,使其在某些方面变得“更好”。这里的“更好”通常指运行速度更快、内存占用更少或程序体积更小。然而,优化并不保证程序达到某种理论上的“最优”状态,它只是朝着“更好”的方向改进。

优化的目标

以下是优化可能追求的目标:

  • 运行速度更快:减少程序的执行时间。
  • 内存占用更低:减少程序运行时的内存消耗。
  • 程序体积更小:减少编译后二进制文件的大小。
  • 避免冗余计算:消除程序中重复执行的计算。

需要注意的是,优化通常以提高代码可读性或修复程序错误为目标。优化必须保持程序的含义不变


优化在编译器中的位置 🗺️

在编译流程中,优化可以发生在多个阶段。最常见的优化发生在抽象语法树层面,即对AST进行一系列转换,生成一个语义等价但更高效的AST。

另一种优化发生在指令层面,称为窥孔优化。它通过检查一小段指令序列,并用更高效的指令序列替换它来工作。

本节课我们将主要关注AST层面的优化。


常量折叠 🔢

上一节我们介绍了优化的基本概念和位置。本节中我们来看看第一种优化技术:常量折叠。

常量折叠是指在编译时计算表达式中所有操作数都是常量的运算,并用计算结果替换原表达式。这可以避免在程序运行时进行这些计算。

核心思想

当遇到像 1 + 5 这样的表达式时,编译器可以在编译时直接计算出结果 6,并将 6 这个常量嵌入到生成的代码中,而不是生成执行加法操作的指令。

实现示例

以下是一个简化的常量折叠函数实现,它递归地遍历AST:

let rec fold (e : expr) : expr =
  match e with
  | Prim1 (Add1, e1) ->
      let e1' = fold e1 in
      (match e1' with
      | Num n -> Num (n + 1)  (* 折叠 *)
      | _ -> Prim1 (Add1, e1')) (* 无法折叠,但子表达式可能已被优化 *)
  | Prim1 (Sub1, e1) ->
      let e1' = fold e1 in
      (match e1' with
      | Num n -> Num (n - 1)
      | _ -> Prim1 (Sub1, e1'))
  (* 处理其他表达式类型... *)
  | _ -> e (* 默认情况,返回原表达式 *)

何时应用常量折叠?

以下是关于常量折叠的两个关键问题:

  1. 何时可以安全地应用?
    当表达式中所有操作数的值都能在编译时静态确定时。如果表达式依赖于用户输入、文件读取或随机数等运行时信息,则不能应用。

  2. 何时应该应用?
    几乎总是应该应用。常量折叠成本低廉(只需遍历一次AST),并且总能通过消除运行时计算来提升性能。因此,编译器会频繁地进行常量折叠。


函数内联 📞

上一节我们学习了常量折叠,它是一种简单而强大的优化。本节中我们来看看另一种优化:函数内联。

函数内联是指将函数调用处替换为该函数的函数体。这样可以消除函数调用的开销(如参数传递、栈帧管理等),但可能会增加代码体积。

核心思想

例如,对于一个小函数 let f(x) = x + 2 和调用 f(5),内联优化会将其直接替换为 5 + 2,进而可能被常量折叠为 7

何时(安全地)应用函数内联?

以下是安全应用函数内联的条件:

  • 函数非递归:内联递归函数可能导致无限循环。
  • 函数是“常量”的:给定相同输入,总是产生相同输出,且不依赖运行时信息(如用户输入)。
  • 调用的函数是静态已知的:编译器必须能在编译时确定具体调用哪个函数。如果函数指针在运行时才能确定,则无法内联。
  • 函数没有自由变量:如果函数体引用了其定义作用域外的变量(形成闭包),内联后这些变量在新上下文中可能不可用。

何时(有效地)应用函数内联?

即使可以安全内联,也需考虑是否值得:

  • 函数体较短:内联短函数对代码体积影响小。
  • 调用点较少:函数只在少数几个地方被调用,内联不会导致代码过度膨胀。
    应用内联需要在性能提升(减少调用开销)和代码膨胀之间做出权衡,这取决于具体场景(如嵌入式设备注重空间,高性能计算注重速度)。

公共子表达式消除 🔄

函数内联帮助我们减少了函数调用开销。本节中我们来看最后一种优化:公共子表达式消除。

公共子表达式消除是指识别并提取程序中重复计算的相同表达式,将其结果存储在一个临时变量中,后续使用该变量代替重新计算。

核心思想

考虑以下代码片段:

let x = read_num()
print((x + 2) * 3)
print((x + 2) * 5)
print((x + 2) * 7)

表达式 x + 2 被计算了三次。公共子表达式消除可以将其优化为:

let x = read_num()
let y = x + 2  // 计算一次
print(y * 3)
print(y * 5)
print(y * 7)

何时应用?

  1. 何时可以安全地应用?
    当重复的表达式是纯的,即多次计算总是产生相同的结果,并且没有副作用。如果表达式包含像 read_num() 这样的操作,则不能消除。

  1. 何时应该应用?
    当表达式计算成本较高,且在程序中多次出现时。编译器需要判断提取公共表达式、引入新变量所带来的收益是否大于开销。

总结 📚

本节课中我们一起学习了编译器优化的基础知识。

  • 我们首先明确了优化的目标是使程序在速度、内存或体积上“更好”,同时必须保持程序含义不变。
  • 接着,我们探讨了优化在编译流程中的位置,主要关注AST层面的转换。
  • 然后,我们深入学习了三种具体的优化技术:
    • 常量折叠:在编译时计算常量表达式。
    • 函数内联:用函数体替换函数调用,以减少调用开销。
    • 公共子表达式消除:提取并重用重复计算的表达式结果。
  • 对于每种优化,我们都分析了其核心思想、安全应用的条件以及有效应用的场景。

优化是一个广阔而活跃的领域,存在许多挑战,例如阶段顺序问题(以何种顺序应用不同的优化规则效果最好)。现代编译器通常基于大量基准测试来指导优化决策。希望本节课能为你打开编译器优化世界的大门。

24:优化第二部分 🚀

在本节课中,我们将继续学习编译器优化技术。我们将介绍第三种优化方法,并探讨除了在抽象语法树上进行优化之外,我们还能在何种中间表示上进行优化。我们将学习一种虽然计算成本高但非常有价值的优化技术,这需要我们在不同于抽象语法树的中间表示上工作。

回顾已学优化

上一节我们介绍了常量折叠和内联优化。本节中,我们来看看第三种优化方法。

公共子表达式消除

首先,我们通过一个例子来理解这种优化。

(print (+ x 2))
(print (+ x 2))
(print (+ x 2))

以下是我们可以对该程序进行的一种可能使其更高效的修改。

(let (y (+ x 2))
  (print y)
  (print y)
  (print y))

我们注意到,表达式 (+ x 2) 出现了多次,这意味着我们重复进行了相同的计算。这似乎没有必要。因此,我们引入一个新的 let 绑定 y,将其值设为 (+ x 2),然后在原来使用 (+ x 2) 的地方替换为 y。这样,我们只计算一次,而不是三次,从而节省了计算量。

这种优化称为公共子表达式消除。它所做的正如其名:识别程序中重复出现的相同子表达式,并消除冗余计算。

接下来,我们看另一个例子,并判断是否可以对其应用公共子表达式消除。

(print (sum-to (read-num)))
(print (sum-to (read-num)))
(print (sum-to (read-num)))

在这个例子中,(sum-to (read-num)) 是一个可能涉及大量计算的表达式。由于 read-num 只执行一次,并且对于给定的输入,sum-to 函数总是产生相同的输出(它是一个“常量函数”),我们可以安全地进行优化。优化后,我们只计算一次 sum-to 的结果并重复使用,从而获得显著的性能提升。

然而,并非所有情况都适合应用此优化。考虑以下程序:

(print (read-plus x))
(print (read-plus x))
(print (read-plus x))

这里,read-plus 函数可能涉及从用户读取输入等副作用。如果我们进行公共子表达式消除,将三次调用合并为一次,程序的行为就会改变(从获取三个输入变为获取一个输入)。因此,在这种情况下,我们不能应用此优化,因为它会改变程序的语义。

优化应用的条件与时机

基于以上例子,我们来总结公共子表达式消除的应用条件。

何时可以安全应用?

  • 当表达式是无副作用的。
  • 当表达式在程序中多次出现

何时应该应用?

  • 当表达式需要大量计算时。
  • 当表达式出现次数很多时。

不同的编译器对于“大量计算”和“出现次数很多”有不同的定义和启发式方法,通常基于基准测试和成本模型来决定。

优化顺序的影响

现在,我们思考一个关于优化顺序的问题。假设我们对初始程序 P0 应用不同的优化顺序:

  1. 路径 A: P0 -> 常量折叠 -> P1 -> 公共子表达式消除 -> P2
  2. 路径 B: P0 -> 公共子表达式消除 -> P3 -> 常量折叠 -> P4

优化顺序会影响程序的正确性吗?
不会。因为我们设计的优化都保证了不改变程序的语义,所以无论顺序如何,最终程序 P2P4 在功能上是等价的。

优化顺序会影响程序的性能吗?
会。应用一种优化可能会揭示出应用另一种优化的新机会。例如,先进行常量折叠可能暴露出新的公共子表达式,反之亦然。因此,P2P4 的性能可能不同。这就是所谓的“阶段排序问题”,即如何安排优化过程的顺序以获得最佳性能。

引入中间表示

到目前为止,我们的优化都是在抽象语法树上进行的。抽象语法树与用户编写的源代码在结构上非常接近。然而,编译器内部经常使用一种更接近机器指令的低级表示,称为中间表示

使用中间表示主要有两个原因:

  1. 便于优化:某些优化在更接近机器指令的表示上实施更为自然和高效。
  2. 支持多语言和多目标:它允许编译器前端(处理不同输入语言)和后端(生成不同目标硬件代码)像乐高积木一样组合。例如,如果有 M 种输入语言和 N 种目标架构,直接为每一对编写编译器需要 M×N 个。而使用公共的中间表示,只需要 M 个前端和 N 个后端,大大减少了工作量。LLVM 就是这样一个著名的中间表示。

基本块与控制流图

我们将学习一种基于 LLVM 思想的中间表示,它对于理解接下来的优化至关重要。这种表示的核心概念是基本块控制流图

一个基本块具有以下特征:

  • 以一个标签开始。
  • 包含一系列特定风格的语句
  • 以一个跳转指令结束。

其中的语句遵循静态单赋值形式,即每个临时变量只被赋值一次。语句本身非常简单,通常是常量赋值、函数调用或基本运算,这些运算通常可以编译为大约一条机器指令。对内存的访问通过显式的 loadstore 指令完成。

当程序包含条件判断、循环等控制流结构时,单个基本块就不够了。我们需要用控制流图来表示程序。控制流图是一个有向图,其中节点是基本块,边表示可能的执行跳转路径(通过 jump 指令连接)。

例如,一个 if 语句会生成一个条件跳转,指向两个不同的基本块(then 分支和 else 分支),这两个分支最后通常会汇聚到一个共同的基本块。对于汇聚点,如果两个分支为同一个变量赋予了不同的值,我们会使用一个特殊的 φ 函数来表示该变量的值取决于从哪个前驱块到来。

控制流图能够清晰地表示所有控制流模式(如顺序、分支、循环),除了函数调用(函数调用通常通过单独的机制处理,每个函数有自己的控制流图)。

在中间表示上的优化示例

有了控制流图,我们可以实施一些在 AST 上难以进行或效率低下的优化。例如:

死代码消除:通过分析控制流图,从入口块开始遍历所有可达的基本块。那些无法从入口块到达的基本块就是永远不会执行的“死代码”,可以安全地从图中移除。

然而,我们引入这种中间表示的主要目的是为了接下来要学习的一种关键优化。

寄存器分配简介

回顾我们的代码生成过程,我们直接在 codegen 中决定使用哪个寄存器或栈位置来存储值。访问寄存器速度极快,而访问内存(栈或堆)则慢得多。目前我们的策略导致了很多不必要的内存访问。

寄存器分配就是解决这个问题的优化。在我们将程序编译到上述中间表示时,我们仿佛拥有无限多的临时变量(寄存器)。但实际的硬件寄存器数量是有限的。寄存器分配的任务就是决定如何将中间表示中这些大量的临时变量映射到有限的物理寄存器上,或者在寄存器不足时,将一些变量“溢出”到内存中。目标是尽可能让更多的值留在快速的寄存器中,从而提升程序性能。

本节课中我们一起学习了第三种优化——公共子表达式消除,探讨了优化顺序的影响,并引入了编译器的中间表示概念,特别是基本块和控制流图。我们还了解到,使用中间表示可以方便实施如死代码消除等优化,并为实现强大的寄存器分配优化奠定了基础。下一讲我们将深入探讨寄存器分配的具体算法。

25:深奥编程语言 🧠

在本节课中,我们将要学习一类特殊的编程语言——深奥编程语言。我们将探讨它们的设计理念、背后的价值观,以及它们如何挑战我们对编程的传统认知。通过了解这些语言,我们可以更深入地思考编程语言设计的多样性和可能性。

课程概述

到目前为止,我们一直在努力从零开始构建自己的语言。课程的一个重要目标是展示我们日常使用的编程语言中的许多功能和决策并非凭空而来。事实上,这些语言特性是由像我们一样的人做出的决策,并且在当时往往并非显而易见的选择。随着时间的推移,不同的语言根据先前语言的成功与失败经验,拥有了不同的功能和决策。在设计我们自己的语言时,我们也必须为自己做出许多这样的选择。

例如,在本学期初,我们讨论了使用词法作用域与动态作用域,这是我们围绕语言中变量工作方式必须做出的明确选择。在我们的案例中,我们决定为我们的语言使用词法作用域。当我们思考为何做出这个决定时,你可以想到几个使用词法作用域而非动态作用域的原因。也许你会说,词法作用域更容易通过查看程序文本来可视化;或者,无论函数在何处被调用,其行为都相同,因此不依赖于任何特定调用点的动态环境。总的来说,我们可以说词法作用域使代码更容易快速理解和维护。因此,基于这些原因,我们选择了词法作用域。

这里的一个广泛教训是,我们关于这些语言所做的决策,是由我们带入这个过程的价值和优先级所决定的。我们认为用这种语言做什么工作很重要?我们想支持什么样的人?这些都是影响我们今天使用的所有语言设计者的同类问题。

今天,我们将讨论一些更奇特、更具探索性的编程语言。我想让大家领略一下编程语言设计领域还有哪些其他可能性。

反思与价值观

为了构建我们的讨论框架,我们将从一个回顾开始。我们将进行学期初的头脑风暴活动。

我希望大家花几分钟时间思考一下到目前为止的课程体验。我希望大家思考一下,是什么价值观和目标将你带入这门课程,同时也希望大家回顾性地思考:这门课程对你的价值观和目标有何帮助或可能没有帮助?作为课程的结果,你的目标和价值观发生了怎样的变化?最初是什么吸引你来到这门课程,现在又是什么让你留在这里?

我们将先花几分钟时间各自安静地思考这个问题。然后,我们将过渡到小组分享时间。

深奥编程语言之旅

现在,我们将开始探索一些有趣且古怪的语言,这些语言比我们目前接触到的更具探索性。

我们将从讨论能够创建自己的语言以及这些更特定领域的语言开始。为了说明这一点,我在屏幕上放了一张图片。这是一张色彩丰富、看起来抽象的图片。今天我将尝试说服大家,这是一种编程语言中的代码。具体来说,这张图片是 David Morgan-Mar 于 1991 年创建的一种名为 Piet 的语言中的代码。如果你在 Piet 解释器中运行这个程序,它将输出“Hello, world”。所以,我将尝试说服大家这是一个“Hello, world”程序。

总的来说,今天我们将介绍一些看起来像这样的语言,这些被称为深奥编程语言。我这样做部分是因为它很有趣,其中一些语言通常有点傻气。

但我认为它们对我们也可能具有建设性意义。许多这类项目本质上是探索性的。它们的设计目的并非高效、最优,甚至不一定有用,而是为了探索人们围绕语言和编程提出的问题。例如,一个为美观或美学质量而设计的语言会是什么样子?我们如何以新颖或令人惊讶的方式使用现有技术?设计一个无法使用、困难或令人沮丧的语言意味着什么?我认为,看到除了“我们能否为某个特定目标领域、以某种特定的‘简单’和‘快速’定义,制作一种简单快速的语言”之外,我们还能用我们的语言提出哪些其他问题,这是很有用的。

我认为这些深奥语言也清楚地表明,这些语言是构建出来的事物,是由真实的人创造的,他们不断地就他们认为重要的人和事做出决策。大家现在已经花了大学个学期的时间深入思考如何从零开始构建和设计一门语言。你们亲眼目睹了我们在当今语言中认为理所当然的所有决策。这些都是普通人做出的普通决策。因此,我希望我们能够广泛地思考人们为什么编码、为什么创造语言,以及伴随这种工作的价值观和政治。

总的来说,我希望我们思考计算机科学工作如何伴随价值观和政治,以及你希望自己的计算制品体现何种价值观。

接下来,我们将开始介绍一些这样的语言。之后,我们会有一个短暂的休息,然后在剩余的课堂时间里进行另一个头脑风暴活动。

在我们浏览这些语言的过程中,我们将反复回到这个核心问题:这些语言是围绕什么价值观设计的?我们将尝试站在这些语言创造者的角度,为他们进行我们之前在幻灯片上为自己所做的同样的头脑风暴。

语言案例研究

APL:数学表达的语言

我们将从第一种语言开始,这种语言的核心问题是:如果我们想用编程来表达数学思想会怎样?

这里介绍一下背景。这位是肯·艾弗森。在 60 年代,他试图提出一种更标准化的数学符号。他试图表达这些算法,比如在白板上书写、教授数学课、算法课等。他意识到现有的数学符号对此并不理想,通常可能不够标准,难以表达这类算法思想。于是他思考,他正在寻找一种标准化的符号,具有某种标准语法和语义。他意识到,哦,我正在寻找的正是一种编程语言。我需要创造一种编程语言来表达这类算法,特别是这类数学问题。

因此,在 1966 年,他写了一篇名为《一种编程语言》的论文,简称 APL。这是我们将要介绍的第一种语言。

我在屏幕上放了一段 APL 代码示例。它看起来相当正常,我们将两个变量 A 和 B 相加。APL 的一个有趣之处在于,肯·艾弗森设计这种语言的方式是,它对列表的操作与对单个项目的操作相同。假设 A 是一个列表,比如数字 1 到 4,B 是另一个列表,5 到 8。如果 A 和 B 都是列表,这里的表达式将起作用。发生的情况是,如果 A 和 B 这样定义,它将执行逐元素加法。A 的每个元素将与 B 的每个元素配对,然后将这些项目逐元素相加,得到另一个相加后的列表。

这是这种语言的第一个有趣之处。第二个有趣之处是,APL 常因此而出名:许多操作不是用自然语言表达,而是用这些有趣而古怪的符号。我在这里展示了一个计算列表平均值的函数。你可能会说,大卫,那不是说“平均”。我不知道发生了什么。我们来分解一下这里发生了什么。那些外层的花括号表示“请定义一个函数”。如果你想到我们在 164 中使用的 define 关键字,这些花括号做着同样的事情,它们将 ω 关键字绑定到这个函数的参数。求值从右向左进行。所以,如果我们从最右边的表达式开始,那个有三条线穿过一条线的符号,它表示获取列表中的项目数。当我们将其应用于 ω 时,我们得到参数列表中的元素数量。除号表示除以,然后左边括号内的表达式是取 ω 中所有元素的总和。加号表示加法,带有一条水平线的斜杠符号表示归约。所以我们说的是,对这个列表进行加法归约。换句话说,将列表中的所有元素相加。

这是 APL 的另一个定义性特征:注重简洁,用符号表示这些更复杂的操作,因为它本意是数学符号。它最初从未打算被输入。因此,当肯·艾弗森设计这种语言时,他围绕的是:哪些符号易于手写,并能非常简洁地表达复杂的操作。

我们再看几个程序示例。我无法解释这里发生了什么,老实说,但只是让大家感受一下 APL 编程的样子。这是一个用 APL 编写的数独求解器,大概只有五行代码,却能完成所有数独求解。

大家现在对实现编译器非常熟悉了。这是一个完整的 APL 编译器。不包括运行时。所以这不完整。它编译成一种字节码,由运行时解释。如果你想了解更多信息,这个编译器叫做 Co-dfns。我们可以在课后核实,因为我觉得我刚才说的可能不准确,我不想传播错误信息,但你可以查一下,应该会有更多信息。这只是让你了解一下用 APL 代码可以简洁到什么程度。

我认为研究 APL 的一个有趣之处在于,看看它如何影响了后来的编程语言发展。我在这里放了另一个片段。这是找出 1 到 100 之间的所有质数,同样很简洁。我在旁边放了一段 NumPy 代码片段。NumPy 是 Python 的一个科学计算库,用于处理大量数据。如果你眯起眼睛看,用正确的方式重命名函数,调整一下角度,你可以看到 NumPy 代码和 APL 代码之间有某种家族相似性。事实上,NumPy 操作的许多定义性特征之一是它们应用于整个数组。你不是在做显式的 for 循环,而是编写这些将逐元素应用于列表的函数。所以你可以看到 APL 这种计算风格对 NumPy 的普遍影响。

事实上,我们可以更明确地看看灵感从何而来。例如,NumPy 有一个叫做 ravel 的函数,它可以将一堆嵌套列表展平成一个扁平列表。老实说,我不会选择 ravel 作为这个函数的名字,有点奇怪的选择。他们为什么这么做?结果是因为他们参考了 APL。他们借鉴的原始操作是 APL 的 ravel 操作,它在 APL 的原始实现中做同样的事情。我想通过这个例子强调的是,这些语言有历史,它们通常非常具有历史偶然性。人们根据他们熟悉的东西、从当时常见的其他语言中获得的灵感,来做出语言决策、命名事物和设计决策。

Brainfuck:追求极简实现

关于这些程序,你可能注意到的一件事是,它们似乎很难编写和调试。这是对 APL 的一个常见批评:这是一种“写一次”的编程。你写完这个程序,它就变成了一堆难以理解的符号。因此我们可以开始问一些问题。如果我们不在乎让编程变得容易呢?如果我们放弃这种期望呢?当我们思考编程语言时,这是一种隐含或常见的期望,但情况不一定如此,这是设计者做出的选择。所以我们可以说,如果我们关心其他事情呢?例如,如果我们想制作尽可能简单的语言来实现呢?这是我们要讨论的第二种语言。它由 Urban Müller 于 1993 年创建。为了讲座目的,我称之为 BF。它代表的东西你可以在自己的时间里查找,我不会进一步阐述。

正如我提到的,这种语言被设计得尽可能容易实现。这是这种语言的完整编译器。它只有 240 字节,并且直接编译成汇编语言。是的,这是这种语言的完整编译器,包括一切:运行时等等。它能如此简单的原因是它只有八种操作。你可以想象,基本上这种语言模拟了一种非常简单的图灵机。你有一条单元格带,有一些操作可以在当前单元格递增和递减,移动你指向的单元格,然后方括号表示这种循环操作:如果当前单元格是 0,就跳转到匹配的括号。所以你可以把它看作你可能熟悉的条件跳转。这是一种非常简单的类汇编情况,非常简单的语言。

我们再看一些示例程序。这是将两个值相加的程序。你可能看着这个在想,发生了什么?我们来分解一下。括号表示一个循环。只要当前单元格不为零,我们就从当前单元格递减,然后给相邻单元格加一。如果你在循环中这样做,最终结果就是将两个数字相加,并将结果存储在第二个单元格中。

我认为 BF 的一个有趣之处在于,即使这种语言极其简单、非常基础,习惯用法仍然会出现。因此,即使在这种语言中,实践社区仍然会涌现出来,常见的短语、习惯用法、模式或设计模式仍然会围绕这些语言出现。我在这里放了几个例子,最下面的一个只是清除单元格的代码,即将单元格设置为 0。中间的是将当前单元格的值复制到接下来的两个单元格,然后将当前单元格清零。因此,BF 实践者会积累这些不同短语的储备。

这是一个用 BF 编写的 BF 解释器。我们在这里变得非常元。但这是用 BF 编写的 BF 解释器的完整实现。你可以在 BF 程序上运行它,它会给出你期望的结果。正如你所见,创造语言的人喜欢为其他语言制作解释器。

回到这种语言被创造的原始原因,我认为这种语言的存在很酷,人们能够在其中制作复杂的程序,这很酷。但同样,拥有一种极其容易实现的语言,也因其他原因而变得有趣。一是它是学习编译器优化的有用场所。这种语言非常简单,有许多低垂的果实可用于实现不同的代码优化策略。如果你搜索 BF 编译器,你会看到 GitHub 上有很多人在研究不同的优化和编译 BF 代码的方法。我提到这一点是因为查看这些示例可能对作业 8 有帮助,在作业 8 中你需要实现编译器优化。

另一件事是,人们喜欢用各种不同的语言实现 BF。它是一种如此简单的语言,以至于人们会乐于尝试用非常有趣和奇怪的方式编写 BF 解释器或编译器。举个例子,你们中有多少人熟悉 TypeScript?好的,这里有一部分人熟悉。正如你们所知,TypeScript 是 JavaScript,但你可以添加类型注释。你们可能不知道的一件事是,你可以使用 TypeScript 类型系统实现一个 BF 解释器。这个特定的实现有一个 import type BF 的东西。我们将程序作为类型参数传入。如果你进入 VS Code 并高亮显示这个类型,你可以看到它会在编译时运行这个 BF 程序。因此,你实际上可以使用 TypeScript 类型系统实现一个完整的 BF 解释器,这很有趣。

问题是 TypeScript 的类型系统是否是图灵完备的?我认为答案是肯定的。我们可以做这样有趣的事情。

Malbolge:故意制造困难

我们可以更进一步。事实上,我们可以问自己:如果我们故意让编程变得困难会怎样?如果我们尝试创造一种我们不想让人们为其编写程序的语言会怎样?这就是一种名为 Malbolge 的语言的目标。我从维基百科上摘录了这个摘要,显然它是以地狱第八圈命名的,这让你感受到语言设计者 Olmstead 想要营造的氛围。我们会稍微快速地过一下。

这种语言有 8 条指令,在某些方面类似于 BF。这又是一种非常基础的类汇编语言。使这种语言有趣的地方,我将强调两个不同的特性。一是没有移动操作符。通常,即使我们看 BF,我们也可以用加减操作符直接设置内存。显然,在汇编中,我们一直在使用移动指令。在这里,我们无法访问这些。设置内存的唯一方法是通过一个叫做“疯狂操作符”的操作符。有一个表格,列出了这个疯狂操作符的具体操作。所有值都以三进制存储。所以是的,数字在这种语言中都是三进制的。所以你必须使用那个表格来弄清楚如果你想设置值,如何操作。

这种语言的第二个有趣之处是,当程序运行时,它被加载到内存中。然后每当一条指令被执行后,它就会被加密。所以你不能运行同一个程序两次,因为当你运行一条指令时,该指令会在内存中改变,所以如果你“重置”指令指针到之前的位置,它将执行一条不同的指令。这使得在这种语言中编程非常困难,因为你真的不知道会发生什么。

我再给大家看一些示例程序。这是“Hello, world”。它不仅看起来有点荒谬,而且是在该语言发明两年后才被发现的。基本上,有人写了一个 Lisp 程序来搜索这种编程语言中可能的不同程序,偶然发现了这个打印出“Hello, world”的语言。这是另一个示例程序。这是 cat 命令的克隆。如果你熟悉 Unix 的 cat 命令,它只是打印出文件的内容。这是为你准备的 cat。这是用这种语言编写的 Lisp 解释器的前 1000 字节。我真的无法解释发生了什么。不仅如此,实现这个 Lisp 解释器的完整文件有 300 MB。有点大。所以,是的,这种语言被故意设计得尽可能难以理解和编写。

Piet:艺术与编程的交汇

这很有趣。我们浏览了一些可能并非围绕我们通常倾向于的“简单”或“可用性”定义而设计的语言。但现在我想转向一个略有不同的问题。

在这里,我将向大家介绍一位名叫皮特·蒙德里安的抽象艺术家的抽象艺术。他是 20 世纪的一位抽象画家,我在屏幕上放了一些他的艺术作品的例子。他被认为是几何抽象艺术领域的先驱。在 90 年代左右,有人看了这些画作,他们想,哇,这些是非常有趣和美丽的艺术作品。我想知道我们是否能让我们的编程语言看起来像这样,或者我们编程语言中的代码看起来像这样。这正是大卫·摩根-马尔在 1991 年用 Piet 语言所做的。这是我在讲座一开始提到的语言。

我们在这里有几个这种语言的示例程序。最左边是一个测试数字是否为质数的程序;中间是一个打印单词“Piet”的程序;最右边是有人为他们的伴侣制作的情人节卡片,当你执行该程序时,它会打印出一张小情人节卡片。

我将解释一下发生了什么,因为你可能看着这个,可能会想,嘿,那是一张图片,发生了什么?基本上,我们可以将一张图片分解成一系列像素。我们可以将这些颜色值解释为信息。一旦我们可以做到这一点,我们就可以说,好吧,如果我们看到这种颜色,然后看到那种颜色,我们可以将其解释为一条指令。然后你就拥有了一种编程语言。

这里我制作了这个非常简单的示例程序。在这种语言中,色调的变化被解释为不同的指令。我们有一个颜色图表。这些是允许使用的 18 种主要颜色,除了黑色和白色。还有一个预定义的列表,说明如果你看到色调变化或亮度变化,应该运行什么指令。基本上,当开始执行时,你可以想象自己从一个像素开始,指向右边。解释器将沿着该方向移动到下一个颜色,并根据从前一个颜色到下一个颜色的色调或亮度变化来评估/运行一条指令。如果我们遇到其中一个黑色方块,我们会将自己旋转 90 度,即改变我们面对的方向。然后,如果我们连续遇到四个空白单元格,执行将结束。所以这里的这个片段只是给程序状态中的当前单元格加一。

所以,如果你从这个起点开始,你可以做很多令人惊讶的事情。我在这里放了一系列来自不同人的艺术/代码示例。我想指出一点。顶部第二张图片,字符转 ASCII 码程序。那最初并不是一个 Piet 程序。那是某人创作的一件艺术品,然后另一个人过来,说,嘿,这看起来像一个 Piet 程序。是不是很有趣?所以他们拍了一张照片,然后使用主要颜色调色板将其转录成一个 Piet 程序。结果它是一个有效的 Piet 程序。它的功能是,如果你给它一个 ASCII 字符,它会打印出该字符的 ASCII 码。我认为这使其成为第一个偶然的 Piet 程序。

我认为这组程序有趣之处在于,它使创造性实践与编程之间的契合变得非常明显。我们经常使用“代码风格”这个短语。在这里,它是代码风格含义的一个非常明显的体现。所有这些片段看起来都非常不同。同样有趣的是,尽管编程环境非常受限,但人们能够将其推向非常不同和富有创造性的方向。

语言设计中的价值观与政治

正如我在本讲座开始时提到的,以及大家所看到的,无论是在大家构建 164 Lang 的工作中,还是在这些案例研究中,当人们设计语言时,他们都在明确地决定他们想为谁设计这些语言,以及他们希望这些语言完成什么样的工作。如果你想想 OCaml 或 Python,也许我们想帮助软件工程师编写更正确的代码,也许我们想帮助科学家更快地编写代码,这些都是我们在设计语言时告诉自己的激励性故事。而在本讲座中大家看到的语言中,这些目标更具探索性。也许我们想创造一种实现起来有趣的语言,或者产生美观的程序。这些语言的设计并非与历史或更广泛的社会分离,而是深深植根于其创造的背景中。我们看到未来的语言功能和词汇如何受到过去语言工作的影响。对于 Piet,我们可以看到语言设计如何受到当代艺术和文化的影响。

我认为,我刚才所说的一切的另一种表述是:语言设计工作是政治性的。我指的不是选举政治体系意义上的政治,而是指这些语言及其设计者试图以某种方式影响世界的变化,而他们试图影响变化的方式受到过去和当前社会政治形势的影响。

因此,当人们进行语言设计时,他们对自己想要生活的世界以及他们希望用这种语言创造的可能的世
界有一种感觉。这种感觉受到他们现在生活的世界以及这个世界如何形成的影响。因此,我想介绍几个例子,其中它们所体现的政治非常清晰,可能与我们目前看到的语言有所不同。我将讨论的这两种语言的一个共同点是,它们都试图为历史上被边缘化的社区进行语言设计。在我们今天生活的社会中,有许多人面临不同且重叠的边缘化:有色人种、酷儿、跨性别者、残疾人、穷人。这些都是由于社会中存在的结构而被忽视或积极歧视的人和社区。因此,对于从事语言设计的人来说,他们常常认为这不关他们的事,认为我们的工作是纯粹技术性的,没有任何政治性,或者语言设计者对社会的结构没有任何能动性。但正如我们在本讲座中所讨论的,事实并非如此。这些政治并不总是可见的,但它们始终存在。如果你为从事大型科技工作的大型科技软件工程师设计语言,那本身就是一种政治声明。因此,我认为接下来的两种语言很好地打破了这种误解,向我们展示了语言设计者带着非常明确的政治立场意味着什么,一种优先考虑我们社会边缘人群并邀请他们进入计算空间的政治立场。

Cree#:为原住民社区设计的语言

那么,这意味着什么呢?我想讨论的第一种语言叫做 Cree#。屏幕上大段文字来自一篇博客文章,我会解释。Cree# 是由一位名叫 John Corbett 的人创建的,他目前是加拿大西蒙弗雷泽大学的教员,他也是梅蒂人,这意味着他有混合的原住民血统。具体来说,John 是克里族的一员,克里族是加拿大所称的“第一民族”之一,即在欧洲殖民者到来之前居住在现今加拿大地区的原住民。他构建这种语言的目标是为克里族人构建一种编程语言,并思考围绕克里族语言、文化和生活方式构建一种语言意味着什么。其中一部分是语言本身。回到这个问题:为什么所有语言的关键字都是英语?这很大程度上源于许多早期语言来自美国的军事和经济机构。他们说英语,最终使用了英语关键字,现在我们的所有编程语言中都有英语。如果你想了解更多,我强烈建议你查阅 John Corbett。网上有一篇非常有用的博客文章,是他与一位名叫 Daniel Temkin 的艺术家之间的访谈,讨论了这种语言。但在这里,我只是在屏幕上展示一点这种语言。是的,这项工作的一部分涉及将编程语言中的关键字口语视为这项工作的一部分,John 将这些英语关键字替换为克里语关键字。这项工作的一部分还意味着思考如何将克里文化逻辑深度融入语言及其提供的结构中。我举一个例子:在这种语言中,每个程序都必须以一个叫做“烟熏”的东西开始。本质上,“烟熏”是一种在使用前重置事物的净化仪式,这种仪式在克里文化中很常见。因此,在这种语言中,在每个程序运行之前,你必须发出一个“烟熏”命令,为程序的其余部分运行做好准备。通过这种方式,它以一种文化方式呈现了计算机正在做的事情:你重置它以准备执行程序的其余部分。同样,如果你有兴趣了解更多,我只是浅尝辄止,但我真的建议你查阅关于这方面的更多信息。因为访谈充满了非常有趣的细节和见解。

Wordplay:包容性与可访问性

我要讨论的最后一种语言叫做 Wordplay。Wordplay 最初由华盛顿大学的 Amy J. Ko 教授于 2023 年发表,她从事编程语言研究和人机交互研究。2023 年,她构建了这种名为 Wordplay 的语言,部分作为一种艺术项目,部分作为一种向年轻的中学生教授编程的方式。该语言旨在帮助人们创建交互式故事、动画、表演等。它综合了我们过去讨论的五种语言中的许多内容。它也具有探索性,提出艺术问题,有点古怪,但也在几个方面非常明确地植根于政治。

她也写了一篇关于 Wordplay 开发的博客文章,并非常明确地阐述了她的语言目标。与 John Corbett 类似,也与我们之前的讨论类似,她思考如何在语言设计中去中心化英语国家和文化,并且她也在非常批判性地思考残疾问题,以及我们如何使编程语言对具有不同残疾的人更易访问。我们在 164 的讨论中接触过这一点,我们进行了屏幕阅读器练习,为 164 Lang 实现了屏幕阅读器支持。因此,广泛思考使语言可访问意味着什么。

在思考 Wordplay 时,我们可以看到这些政治如何在这种语言设计的每个微观层面体现出来。例如,这种语言中的所有核心语法都是纯符号的。所以没有 deflet 关键字,你使用上面看到的那种中缀冒号操作符来定义变量。与 Cree# 一样,我们正在远离使用英语单词如 definelet,以允许不同文化和背景的人参与语言。我们也可以眯起眼睛看到一些与 APL 的共鸣,思考使用符号作为描述操作的方式。在这种语言设计中做出的另一个选择是,所有 Unicode 都被视为有效的语言结构。在一些其他语言中,包括 164 Lang,标识符仅限于 ASCII 字符。显然,对于许多语言来说,这行不通。因此,明确允许使用所有 Unicode 作为标识符。我在这里展示了一些示例代码。我是越南人,所以我选择用越南语命名两个变量。然后第一个变量,我命名为中性人表情符号。一切正常。你可以有表情符号、越南语单词、任何可以用 Unicode 输入的语言单词作为变量名。

另一件事是,这种语言明确围绕教学和可调试性设计。在设计语言时,非常明确地选择不优先考虑速度或效率等,而是为程序员提供最大的灵活性,以便能够从他们的代码中学习并调试他们的代码。现在视频中显示的是单步执行代码,并看到每个子表达式的结果出现。这种语言具有称为“时间旅行调试”的功能,这意味着你可以直接进入程序执行过程中的任何一点,查看该点的程序状态。所以你可以直接跳到程序末尾,就像直接运行程序一样,你可以倒带回到开头,跳到中间,沿着时间线任意跳转,查看程序状态。

最后,考虑到可访问性,这种语言围绕可访问性设计了几种方式。思考不同的程序输入方式,不仅仅是能够打字,还能够像 Scratch 风格那样拖放,或者如果你在手机或 iPad 上,能够使用触摸编程。它设计支持屏幕阅读器。同样,回顾我们的 164 作业和添加屏幕阅读器支持,那里发生了类似的处理。此外,更日常的事情,比如遵循网络标准可访问性指南,有颜色对比度,链接清晰可见等。

总结与展望

这就是对这些不同语言的快速浏览。我希望大家能够感受到语言领域还有哪些可能性。我们将进行一个非常短的五分钟休息,让大家有时间站起来伸展一下、呼吸、做任何需要做的事情、喝水等等,然后我们将回到头脑风暴活动。

在本节课中,我们一起学习了深奥编程语言的世界。我们从 APL 的数学简洁性,到 Brainfuck 的极简实现,再到 Malbolge 的故意困难,以及 Piet 的艺术表达。最后,我们探讨了 Cree# 和 Wordplay 如何将社会政治价值观融入语言设计,关注边缘化社区和可访问性。这些语言挑战了我们对编程的传统认知,展示了语言设计的多样性和可能性。它们提醒我们,编程语言不仅是工具,也是文化和价值观的载体。希望这节课能激发大家思考自己希望创造什么样的计算世界,以及如何通过语言设计来实现它。

26:寄存器分配;垃圾回收 🧠🗑️

在本节课中,我们将要学习编译器后端中的两个核心主题:寄存器分配和垃圾回收。我们将从一种中间表示(IR)出发,学习如何通过活跃性分析和图着色算法,将程序中的“临时变量”高效地分配到有限的物理寄存器中。随后,我们将探讨自动内存管理的基本原理,了解垃圾回收器如何识别并回收程序中不再使用的堆内存。


寄存器分配 🎯

上一节我们介绍了LLVM风格的中间表示(IR),它使用无限的“临时变量”(T0, T1, ...)来抽象寄存器的使用。本节中我们来看看如何将这些临时变量映射到有限的物理寄存器上,这个过程称为寄存器分配。

活跃性分析

为了进行寄存器分配,我们首先需要进行活跃性分析。这是一种向后分析,我们从程序的末尾开始,向前回溯,确定在程序的每个点上,哪些变量的值在将来还会被使用(即“活跃”)。

以下是进行活跃性分析的步骤:

  1. 从程序最后一行开始,假设返回值(例如 T6)是活跃的。
  2. 向前移动一行。对于当前行,移除在本行被定义的变量(因为它的旧值不再需要),并添加为计算本行结果所需的所有变量。
  3. 重复步骤2,直到程序开始。

通过这个过程,我们得到了一系列集合,每个集合代表了程序执行到某一点时,必须同时存在于存储(寄存器或栈)中的所有变量。

构建冲突图

活跃性分析的结果直接用于构建冲突图。图中的每个节点代表一个临时变量。如果两个临时变量在程序的同一时刻都是活跃的(即出现在同一个活跃变量集合中),那么它们之间就存在一条边,表示它们不能被分配到同一个寄存器。

以下是构建冲突图的规则:

  • 节点:程序中的所有临时变量(T0, T1, T2, ...)。
  • 边:对于程序中的每一个活跃变量集合,为该集合中每一对不同的变量添加一条边。

图着色与寄存器分配

冲突图构建完成后,寄存器分配问题就转化为了图着色问题。我们需要用不同的“颜色”(代表不同的物理寄存器)为图中的每个节点着色,并确保任何有边相连的两个节点颜色不同。

以下是进行图着色的简化过程:

  1. 为可用的物理寄存器分配颜色(例如,RAX=蓝色,R8=黄色,R9=绿色)。
  2. 按某种顺序(如节点顺序)尝试为每个节点着色。
  3. 为一个节点选择颜色时,不能使用其任何邻居已使用的颜色。
  4. 如果所有颜色都被邻居占用,则可能需要将该变量“溢出”到栈上,或者使用启发式算法重新尝试。

通过成功的图着色,我们就完成了寄存器分配:每个临时变量都被分配了一个具体的物理寄存器(颜色)或栈位置。


垃圾回收 🗑️

在程序运行时,动态分配在堆上的内存(例如通过 pair 创建的数据)可能不再被使用。手动管理这些内存(如C语言中的 free)容易出错。垃圾回收是一种自动内存管理技术,由运行时系统负责回收不再使用的堆内存。

基本概念:标记-清除算法

最经典的垃圾回收算法是标记-清除算法。其核心思想是:回收那些从“根”对象出发不可达的内存块。

算法分为两个阶段:

  1. 标记阶段:从(如当前活跃的寄存器、栈帧中的指针)开始,遍历所有可达的对象,并将它们标记为“活跃”。
  2. 清除阶段:线性扫描整个堆。将所有未被标记的对象内存回收,加入空闲链表,以供后续分配使用。

这种方法安全但保守:它可能不会回收所有垃圾(例如,形成环的不可达对象如果被错误地标记为可达),但绝不会错误地回收仍在使用的内存。

识别指针与根

垃圾回收器必须能够区分堆内存中的指针和非指针数据(如整数、布尔值)。在像OCaml这样的语言中,这是通过标记位实现的(例如,最低有效位为0表示指针)。对于像C这样的无类型语言,可以使用保守式垃圾回收(如Boehm GC),它将任何看起来像堆地址的值都视为指针,这虽然可能造成轻微的内存泄漏,但是安全的。

其他垃圾回收策略

除了标记-清除,还有其他策略来应对不同场景:

  • 停止-复制:将堆分为两个“半空间”。分配只在一个半空间进行。当空间耗尽时,将活跃对象复制到另一个半空间,使其连续排列,然后交换角色。这避免了内存碎片,但需要双倍内存。
  • 分代式回收:基于“大多数对象生命周期很短”的观察,将堆分为年轻代和老年代。频繁对年轻代进行小规模回收,偶尔才对老年代进行大规模回收。
  • 引用计数:每个对象维护一个引用计数器。当引用降为0时立即回收。这种方法无法处理循环引用,且维护计数器开销大,实践中较少使用。

本节课中我们一起学习了编译器后端的两项关键技术。我们了解了如何通过活跃性分析和图着色,将使用无限临时变量的中间表示映射到有限的物理寄存器上,从而生成高效的机器代码。接着,我们探讨了自动内存管理的必要性,并学习了垃圾回收的基本原理,包括标记-清除算法及其变种,理解了运行时系统如何自动识别和回收不再使用的堆内存,以保障程序的内存安全与效率。

27:类型检查 🧠

在本节课中,我们将学习类型检查的核心概念,了解静态类型与动态类型的区别,并学习如何使用逻辑推理规则来形式化地描述和验证程序中的类型。我们还将探讨类型系统的设计权衡,以及一个关于编译器“学习”的著名思想实验。


概述 📋

类型系统是编程语言设计中的核心部分,它定义了哪些操作在哪些值上是有效的。本节课我们将探讨类型检查的两种主要方式:静态类型检查(在编译时进行)和动态类型检查(在运行时进行)。我们将学习如何使用形式化的推理规则来构建类型检查系统,并通过具体例子理解其工作原理。


静态与动态类型 ⏳

上一节我们回顾了课程的核心概念。本节中,我们来看看类型检查发生的时间点。

程序分析可以发生在不同的阶段:

  • 静态:发生在编译时。
  • 动态:发生在运行时。

类型检查也不例外。我们可以选择在编译时检查类型(静态类型),也可以在运行时检查类型(动态类型)。


什么是类型?🔍

在深入类型检查之前,我们需要明确“类型”的含义。虽然不同语言的定义略有不同,但核心思想是一致的。

一个类型由两部分组成:

  1. 一组值。
  2. 在这组值上允许进行的操作。

类型系统的作用就是为语言中的各种类型(如整数、布尔值、函数)明确写下这些规则。这就像我们之前用正则表达式定义词法单元,用上下文无关文法定义程序结构一样,现在我们将用逻辑推理规则来形式化类型规则。


为什么需要类型系统?🛡️

在汇编层面,所有数据都只是比特位,没有类型的概念。那么为什么要在高级语言中引入类型呢?

原因在于,大多数有意义的操作只对特定类别的值有效。例如,数字的加法与字符串的连接是截然不同的操作。类型系统就像一个保护层,在生成可能出错的汇编代码之前,就阻止程序员进行无意义的操作(例如将一个数字与一个函数指针相加)。它帮助程序员更早地发现错误。


类型检查与类型推断 🧩

类型检查是验证程序是否符合类型系统规则的过程。
类型推断是在程序员没有显式标注所有类型的情况下,由编译器自动推导出表达式类型的过程。

两者都使用同一套类型规则 machinery。


推理规则介绍 📜

我们使用逻辑推理规则来形式化类型规则。这种规则的基本形式是:如果某些前提(假设)成立,那么结论就成立。

以下是如何阅读这些规则:

  • Γ (Gamma)表示类型环境,它是一个从标识符到类型的映射,记录了当前作用域中自由变量的类型。
  • (Turnstile)读作“在给定环境下可以证明”。
  • : 读作“具有类型”。
  • 横线上方是前提条件,下方是结论。

例如,整数加法的规则可以写作:

Γ ⊢ e1 : int    Γ ⊢ e2 : int
---------------------------- (规则 T-Add)
    Γ ⊢ e1 + e2 : int

用中文解读是:在类型环境Γ下,如果能证明表达式e1具有int类型,并且能证明e2具有int类型,那么就可以证明表达式e1 + e2也具有int类型。


核心类型规则示例 📝

以下是本节课涉及的一些核心类型规则:

基础值规则:

--------------- (规则 T-Int)    --------------- (规则 T-Bool-False)
Γ ⊢ i : int                    Γ ⊢ false : bool

变量规则:

x : T ∈ Γ
---------- (规则 T-Var)
Γ ⊢ x : T

这条规则表示:如果变量x映射到类型T的记录存在于类型环境Γ中,那么在当前环境下,表达式x就具有类型T

函数应用规则:

Γ ⊢ e1 : T1 -> T2    Γ ⊢ e2 : T1
--------------------------------- (规则 T-App)
        Γ ⊢ e1 e2 : T2

这条规则表示:如果e1是一个从T1类型到T2类型的函数,并且e2是一个T1类型的参数,那么函数应用e1 e2的结果就是T2类型。

Let绑定规则:

Γ ⊢ e0 : T0    Γ, x:T0 ⊢ e1 : T1
--------------------------------- (规则 T-Let)
     Γ ⊢ let x = e0 in e1 : T1

这是较复杂的规则。它表示:要证明一个let表达式具有类型T1,需要证明两点:

  1. 绑定表达式e0具有类型T0
  2. 扩展了x:T0的新环境(Γ, x:T0)下,能证明函数体e1具有类型T1

类型推导树 🌳

将类型规则应用于具体程序时,会形成一棵类型推导树

  • 树的根节点在底部,是整个表达式的类型结论。
  • 树的叶节点是那些没有前提条件的规则(如T-Int, T-Bool)。
  • 信息流动有两个方向:
    1. 类型环境Γ从上向下传播(从根到叶),随着进入新的作用域而不断扩展。
    2. 类型结论从下向上推导(从叶到根),基于子表达式的类型推导出父表达式的类型。

活动与示例 💻

我们通过一个具体例子来实践。考虑表达式:not (5 < 7)
其抽象语法树(AST)和类型推导过程如下:

  1. 根据T-Int规则,57都是int类型。
  2. 根据自定义的“小于”规则(类似T-Add),5 < 7bool类型。
  3. 根据T-Not规则(Γ ⊢ e : bool 可得 Γ ⊢ not e : bool),not (5 < 7)也是bool类型。

推导树清晰地展示了从叶子到根的类型信息聚合过程。


编译器的“信任链”与“学习”攻击 🕵️♂️

课程最后,我们探讨了一个由Ken Thompson提出的著名思想实验——“反思信任”。它揭示了编译器工具链中一个深刻的信任问题。

核心悖论:

  1. 第一个编译器必须用汇编语言(或机器码)编写。
  2. 之后,我们可以用高级语言(如C)编写一个新的编译器A,并用第一个编译器来编译它,从而得到一个可执行的编译器A。
  3. 假设我们在编译器A的源代码中插入一个“后门”(例如,在编译登录程序时插入一个万能密码)。
  4. 别人发现了后门,并提供了修复后的编译器B的源代码。
  5. 关键步骤:如果我们用带后门的编译器A去编译这个干净的编译器B源代码,那么生成的编译器B的可执行文件仍然会包含那个后门!因为后门代码在编译过程中被“注入”了。
  6. 更隐蔽的是,我们可以修改后门,使其在编译“编译器”源代码时,自动将后门代码插入输出。这样,即使我们之后用看起来完全干净的编译器源代码进行编译,只要它被一个受过“污染”的编译器编译过,后门就会永远存在于该编译器的后代中。

这个实验表明,你无法通过审查编译器源代码来完全信任其生成的可执行文件,因为编译器的可执行文件可能来自一个你无法追溯的、被污染的“祖先”。它强调了软件供应链安全的基础性和脆弱性。


总结 🎓

本节课中我们一起学习了:

  1. 静态类型检查与动态类型检查的根本区别及其设计权衡。
  2. 如何使用逻辑推理规则类型环境(Γ) 来形式化地定义类型系统。
  3. 如何阅读和构建类型推导树,理解类型信息在AST中的双向流动。
  4. 通过具体例子实践了类型推导的过程。
  5. 了解了编译器领域一个经典的“信任链”安全问题——Thompson攻击,它提醒我们,即使审查了所有源代码,底层的工具链本身也可能是一个隐蔽的漏洞来源。

类型系统是编程语言设计的基石,它影响着语言的表达能力、安全性和开发体验。理解其原理对于成为更好的程序员或语言设计者至关重要。

posted @ 2026-03-29 09:26  布客飞龙II  阅读(18)  评论(0)    收藏  举报