原文地址:mukulrathi.co.uk/create-your…
原文作者:mukulrathi.co.uk/
发布时间:2020年6月1日-9分钟阅读
系列。创建BOLT编译器
- 第1部分:为什么要写自己的编程语言?为什么要写自己的编程语言?
- 第2部分:那么如何架构一个编译器项目?
- 第3部分:使用OCamllex和Menhir编写一个Lexer和解析器。
- 第4部分:类型理论和实现类型检查器的易懂介绍。
- 第5部分:关于活泼度和别名数据流分析的教程。
- 第6部分:Desugaring--将我们的高级语言简单化!
- 第7部分:OCaml和C++的Protobuf教程。
- 第8部分: 编程语言创建者的LLVM完全指南。
- 第9部分:实现并发和我们的运行库
- 第10部分:Bolt中的继承和方法重写。
- 第11部分:通用性--为Bolt添加多态性。
即将推出
我们不能直接对Bolt程序进行推理,因为它只是一个非结构化的字符流。词法和解析将它们预处理成一个结构化的表示,我们可以在编译器的后期阶段对其进行类型检查。
词法标记
单个字符的意义不大,所以首先我们需要将流分割成令牌(类似于句子中的 "词")。这些令牌赋予了意义:这组字符在我们的语言中是一个特定的关键词(如果是int类)还是一个标识符(banana)?
令牌还大大缩小了我们的问题空间:它们使表示标准化。我们不再需要担心空格问题(x == 0和x== 0都变成了IDENTIFIER(x) EQUAL EQUAL INT(0)),我们可以从源代码中过滤掉注释。
我们如何将我们的字符流分割成tokens呢?我们进行模式匹配。从表面上看,你可以认为这是一个大规模的案例分析。然而,由于案例分析是模糊的(多个标记可能对应同一组字符),我们必须考虑两个额外的规则。
- 优先顺序: 我们按照优先级对标记进行排序。例如,我们希望int被匹配为关键字,而不是变量名。
- 最长模式匹配:我们把 else 读作关键字,而不是把它拆成两个变量名 el 和 se。同样,intMax也是一个变量名:而不是读成int和Max。
在伪代码中。
// this is pseudocode for a simplified part of the lexer
let charsSeenSoFar = "i"
while(streamHasMoreCharacters){
let nextChar = readCharFromStream()
switch(nextChar){
// consider keyword cases (if, int) first
case "f":
output "IF" // we've found a token
charSeenSoFar= "" // start matching another token
case: "n":
charsSeenSoFar = "in" // longest match: we'll try
... // to pattern match "int" next iteration
default:
// not "if" or "int" keywords so must be an identifier
// of some sort (e.g variable or function name)
}
}
OCamllex
虽然你可以手工编写模式匹配的伪代码,但在实践中,它是相当精细的,尤其是当我们的语言变得更大时。相反,我们将使用词典生成器 OCamllex。OCamllex是一个OCaml库,我们可以在编译器中使用它,将它作为一个依赖项添加到我们的Dune构建文件中。
(ocamllex lexer)
lexer.mll文件(注意文件的扩展名为.mll)是lexer的规范。
OCaml头
首先,我们选择性地提供一个包含OCaml帮助代码的头(用大括号括起来)。我们定义了一个SyntaxError异常和一个函数nextline,它将指针移动到程序被读入的lexbuf缓冲区的下一行。
{
open Lexing
open Parser
exception SyntaxError of string
let next_line lexbuf =
let pos = lexbuf.lex_curr_p in
lexbuf.lex_curr_p <-
{ pos with pos_bol = lexbuf.lex_curr_pos;
pos_lnum = pos.pos_lnum + 1
}
}
辅助注册表
接下来,我们需要指定我们要使用的正则表达式来匹配标记。对于大多数标记来说,这只是一个简单的字符串,例如标记 "true"。然而,其他标记有更复杂的正则表达式,例如整数和标识符(如下)。OCamllex的regex语法和大多数regex库一样。
(* Define helper regexes *)
let digit = ['0'-'9']
let alpha = ['a'-'z' 'A'-'Z']
let int = '-'? digit+ (* regex for integers *)
let id = (alpha) (alpha|digit|'_')* (* regex for identifier *)
let whitespace = [' ' '\t']+
let newline = '\r' | '\n' | "\r\n"
词汇规则
接下来,我们需要为OCamllex指定扫描输入的规则。每条规则都是以模式匹配的格式来指定的,我们按照优先级的顺序来指定regexes(最高优先级优先)。
rule <rule_name> = parse
| <regex> { TOKEN_NAME } (* output a token *)
| <regex> { ... } (* or other execute other code *)
and <another_rule> = parse
| ...
规则是递归的:一旦它匹配了一个标记,它就会调用自己重新开始匹配下一个标记。多条规则是相互递归的,也就是说,我们可以在对方的定义中递归调用每条规则。如果你想在不同的情况下对字符流进行不同的处理,那么拥有多个规则是很有用的。
例如,我们希望我们的主规则读取标记。然而,我们希望以不同的方式处理注释,在到达注释的结尾之前不发出任何标记。另一种情况是Bolt中的字符串:我们希望将我们正在读取的字符作为字符串的一部分,而不是作为匹配标记。这些情况都可以在下面的Bolt代码中看到。
let x = 4 // here is a comment
/* This is a multi-line
comment
*/
printf("x's value is %d", x)
现在我们对我们的词典有了要求,我们可以在OCamllex规范文件中定义规则。关键点是。
- 我们有4条规则: read_token, read_single_line_comment, read_multi_line_comment 和 read_string.
- 我们需要显式处理eof(这意味着文件的结束),并包含一个全局性的case
_来匹配所有其他的regexes。 - 我们使用Lexing.lexeme lexbuf来获取由regex匹配的字符串。
- 对于read_string,我们创建了另一个缓冲区来存储字符:我们不使用Lexing.lexeme lexbuf,因为我们想明确地处理转义字符。Buffer.create 17分配一个可调整大小的缓冲区,初始大小为17字节。
- 我们使用 raise SyntaxError 来处理错误(意外输入字符)。
- 当读取令牌时,我们通过调用read_token lexbuf而不是发出一个令牌来跳过空白区。同样的,对于一个新行,我们调用我们的辅助函数 next_line 来跳过新行字符。
rule read_token =
parse
| "(" { LPAREN }
... (* keywords and other characters' regexes *)
| "printf" {PRINTF }
| whitespace { read_token lexbuf }
| "//" { single_line_comment lexbuf (* use our comment rule for rest of line *) }
| "/*" { multi_line_comment lexbuf }
| int { INT (int_of_string (Lexing.lexeme lexbuf))}
| id { ID (Lexing.lexeme lexbuf) }
| '"' { read_string (Buffer.create 17) lexbuf }
| newline { next_line lexbuf; read_token lexbuf }
| eof { EOF }
| _ {raise (SyntaxError ("Lexer - Illegal character: " ^ Lexing.lexeme lexbuf)) }
and read_single_line_comment = parse
| newline { next_line lexbuf; read_token lexbuf }
| eof { EOF }
| _ { read_single_line_comment lexbuf }
and read_multi_line_comment = parse
| "*/" { read_token lexbuf }
| newline { next_line lexbuf; read_multi_line_comment lexbuf }
| eof { raise (SyntaxError ("Lexer - Unexpected EOF - please terminate your comment.")) }
| _ { read_multi_line_comment lexbuf }
and read_string buf = parse
| '"' { STRING (Buffer.contents buf) }
| '\\' 'n' { Buffer.add_char buf '\n'; read_string buf lexbuf }
... (* Other regexes to handle escaping special characters *)
| [^ '"' '\\']+
{ Buffer.add_string buf (Lexing.lexeme lexbuf);
read_string buf lexbuf
}
| _ { raise (SyntaxError ("Illegal string character: " ^ Lexing.lexeme lexbuf)) }
| eof { raise (SyntaxError ("String is not terminated")) }
生成的OCamllex输出
OCamllex从lexer.mll规范中生成一个Lexer模块,你可以从这个模块中调用Lexer.read_token或任何其他规则,以及在头中定义的帮助函数。如果你好奇,在运行make build之后,你可以在_build文件夹中看到lexer.ml中生成的模块--这只是一个庞大的模式匹配语句。
let rec read_token lexbuf =
__ocaml_lex_read_token_rec lexbuf 0
and __ocaml_lex_read_token_rec lexbuf __ocaml_lex_state =
match Lexing.engine __ocaml_lex_tables __ocaml_lex_state lexbuf
with
| 0 ->
# 39 "src/frontend/parsing/lexer.mll"
( LPAREN )
# 2603 "src/frontend/parsing/lexer.ml"
| 1 ->
# 40 "src/frontend/parsing/lexer.mll"
( RPAREN )
# 2608 "src/frontend/parsing/lexer.ml"
| 2 ->
# 41 "src/frontend/parsing/lexer.mll"
( LBRACE )
# 2613 "src/frontend/parsing/lexer.ml"
| 3 ->
# 42 "src/frontend/parsing/lexer.mll"
( RBRACE )
# 2618 "src/frontend/parsing/lexer.ml"
| 4 ->
# 43 "src/frontend/parsing/lexer.mll"
( COMMA )
# 2623 "src/frontend/parsing/lexer.ml"
语法
我们使用结构来推理句子--即使我们没有想到这一点。单纯的单词并不能说明句子的很多问题--"使用 "既是名词又是动词--只有因为我们在 "我们使用结构 "中有一个主语-动词-宾语的模式,我们才能推断出 "使用 "是一个动词。对于编译器来说也是如此。单独的标记x, +, y并没有什么意义, 但我们可以从x+y中推断出x和y是加在一起的数字.
我们使用语法来指定Bolt中的程序结构,语法由一组关于如何构造Bolt表达式的规则(productions)组成。
例如,一个程序由一个类定义的列表组成,后面是一些函数定义,然后是主表达式。这看起来像这样(使用 "X_defns "复数来非正式地指代 "X_defn "的列表)。
program::= class_defns function_defns main_expr
这个顶层规则的右侧也有每个表达式的规则。可以用规则进一步扩展的表达式称为非终端,不能扩展的表达式即标记称为终端。我们来看一下class_defn规则和main_expr规则。
一个类定义由CLASS关键字令牌组成(令牌用大写),后面是ID令牌(identifier=类名),然后用大括号括住正文。本体由能力定义(这是Bolt特有的--用于防止数据竞赛)、字段定义和方法定义组成。这里CLASS、ID、LBRACE和RBRACE标记是终端,而capacity_defn、field_defns和method_defns表达式是非终端(它们有自己的扩展规则)。
class_defn ::= CLASS ID LBRACE capability_defn field_defns method_defns RBRACE
一个满足规则的类定义。
class Foo { // Foo is the identifier
capability linear Bar; // capability definition (has its own rule)
// field defns
var int f : Bar; // (field has Bolt-specific capability annotatation)
const bool g : Bar;
//method defns
int getF() : Bar { // method definition (again has its own rule)
this.f
}
}
而我们的主表达式有如下规则。
main_expr ::= TYPE_VOID MAIN LPAREN RPAREN block_expr
void main() {
...
}
表达式可以有多种形式。
expr ::=::=
| NEW ID LPAREN constructor_args RPAREN
| IF expr block_expr ELSE block_expr
| LET ID EQUAL expr
等等。
Bolt语法的全部细节见我论文的附录。
抽象句法树
如果我们从另一个角度来看这个语法,这实际上是在我们的程序结构上指定了一个层次结构。在顶层有我们的程序规则,然后我们递归扩展出规则右侧的每一个术语(比如用它的规则扩展出类定义,用它的规则扩展出主表达式等),直到最后得到令牌。
哪种数据结构表示层次结构?是树。所以语法也为我们的Bolt程序指定了一棵语法树,其中tokens是树叶。
我们可以在树上显示所有的标记(这将是一个具体的语法树),但实际上并不是所有的标记都有用。例如,在表达式let x : int = 1+2中,你关心的是ID标记(变量x)和表达式1+2被分配给变量。我们不需要在我们的树中表示=令牌,因为它并没有传达额外的意义(我们已经知道我们在声明一个变量)。通过删除这些不必要的标记,我们最终得到了一个抽象语法树(AST)。
解析的目标是从Bolt程序中构建一个抽象语法树。随后的编译器阶段再对这个解析后的AST进行分析和转换,形成其他的中间AST。
这里我们将AST的类型定义分成两个文件。
ast_types.mli包含了整个编译器中所有AST的通用类型。
(** Stores the line and position of the token *)
type loc = Lexing.position
type bin_op =
| BinOpPlus
| BinOpMinus
| BinOpNotEq
type modifier = MConst (** Immutable *) | MVar (** Mutable *)
(** An abstract type for identifiers *)
module type ID = sig
type t
val of_string : string -> t
val to_string : t -> string
val ( = ) : t -> t -> bool
end
module Var_name : ID
module Class_name : ID
(** Define types of expressions in Bolt programs*)
type type_expr =
| TEInt
| TEClass of Class_name.t
| TEVoid
| TEBool
...
parsed_ast.mli包含了类定义、函数defns和表达式的OCaml变体类型,本质上是语法规则的映射。
type expr =
| Integer of loc * int
...
| Constructor of loc * Class_name.t * type_expr option * constructor_arg list
(* optional type-parameter *)
| Let of loc * type_expr option * Var_name.t * expr
(* binds variable to expression (optional type annotation) *)
| Assign of loc * identifier * expr
| MethodApp of loc * Var_name.t * Method_name.t * expr list (* read as x.m(args) *)
| If of loc * expr * block_expr * block_expr (* If ___ then ___ else ___ *)
| BinOp of loc * bin_op * expr * expr
...
and block_expr = Block of loc * expr list
type class_defn =
| TClass of Class_name.t * capability list
* field_defn list
* method_defn list
...
注意使用Class_name.t、Var_name.t和Method_name.t,而不仅仅是一个字符串,因为这些类型给了我们更多的信息。
Menhir
和词法一样,我们可以用手写代码,但随着语言规模的扩大,它又会变得更加精细。我们将使用解析器生成器 Menhir。同样和词典一样,我们有一个 parser.mly(注意是 .mly 扩展名)规范文件。
我们需要将Menhir添加到Dune构建文件中。
(menhir
(modules parser))
与OCamllex不同的是,我们还需要更新我们的主沙丘项目构建文件来使用Menhir。
(using menhir 2.0)
OCamllex和Menhir有很多相似之处。我们从可选的OCaml头开始(这里,这只是在计算测试覆盖率时忽略自动生成的解析器文件,并打开Ast_types和Parsed_ast模块)。
%{
[@@@coverage exclude_file]
open Ast.Ast_types
open Parsed_ast
%}
然后我们指定我们在OCamllex中定义的标记(OCamllex和Menhir无缝地一起工作)。
%token <int> INT
%token <string> ID
%token LPAREN
%token RPAREN
...
指定生产签名
接下来,我们指定顶层语法制作的名称(这里是程序)。
%start program
前面我们提到OCaml变体类型和语法产物之间有一个映射,现在我们指定这个,这样Menhir就知道生成解析器时要输出什么类型。这是以一系列%type <ast_type> production_name来完成的。
%type <Parsed_ast.program> program
/* Class defn types */
%type <class_defn> class_defn
...
%type <Capability_name.t list> class_capability_annotations
注意,由于我们在头中打开了Ast_types和Parsed_ast模块,我们可以写Capability_name.t而不是Ast.Ast_types.Capability_name.t,写class_defn而不是Parsed_ast.class_defn。然而,我们需要为顶层制作(Parsed_ast.program而不仅仅是program)指定绝对类型,因为这个制作是在parser.mli接口中暴露的。
(* this file is autogenerated by Menhir *)
val program: (Lexing.lexbuf -> token) -> Lexing.lexbuf -> (Parsed_ast.program)
(* if we used program not Parsed_ast.program *)
val program: (Lexing.lexbuf -> token) -> Lexing.lexbuf -> (program)
(* now parser.mli doesn't know where to find program's type definition *)
语法规则
最后,我们可以指定我们的语法规则,在规则后面的{}中返回OCaml变体表达式。我们可以选择性地用分号将构成规则右侧的每个非终结符/终结符分开,以保证可读性。要在OCaml表达式中使用扩展的非终结符(例如class_defn)的结果,我们可以用一个变量class_defns来引用它(一般来说,形式是var_name=nonterminal表达式)。
program:
| class_defns=list(class_defn); function_defns=list(function_defn); main= main_expr; EOF {Prog(class_defns, function_defns, main)}
Menhir提供了很多小的帮助函数,让编写解析器变得更容易。我们已经看到了list(class_defn)--它返回的是类型为Parsed_ast.class_defn的list。还有其他的变体,例如 separated_list()和 nonempty_list()以及 separated_nonempty_list()。
params:
| LPAREN; params=separated_list(COMMA,param); RPAREN {params}
param_capability_annotations:
| LBRACE; capability_names=separated_nonempty_list(COMMA,capability_name); RBRACE {capability_names}
另一个有用的函数是option()。这里由于let_type_annot返回一个类型为Parsed_ast.type_expr的OCaml表达式,所以option(let_type_annot)返回一个类型为Parsed_ast.type_expr option的值--这对于用户可能会选择添加类型注释的情况很有用。例如:let x : int = 5 vs let x = 5。
最后,Menhir还可以让我们得到程序中生产的行号和位置(我们已经为此定义了类型loc),这对错误信息很有用! 你可以决定从哪里获取位置。我选择使用$startpos来获取生产开始时的位置。
expr:
| i=INT {Integer($startpos, i)}
| TRUE { Boolean($startpos, true)}
| FALSE { Boolean($startpos, false) }
...
| e1=expr op=bin_op e2=expr {BinOp($startpos, op, e1, e2)}
| LET; var_name=ID; type_annot=option(let_type_annot); EQUAL; bound_expr=expr {Let($startpos, type_annot, Var_name.of_string var_name, bound_expr)}
| id=identifier; COLONEQ; assigned_expr=expr {Assign($startpos, id, assigned_expr)}
解决模棱两可的解析
有时我们的语法可以产生多个抽象语法树,在这种情况下,我们说它是模糊的。典型的例子是,如果我们有以下语法。
expr ::= | INT | expr PLUS expr | expr MULT expr
如果我们尝试用Menhir来构建这个语法,那么我们会得到以下的错误信息(数字并不重要,Bolt有很多操作符,而不仅仅是+和-)。
menhir src/frontend/parsing/parser.{ml,mli}
Warning: 17 states have shift/reduce conflicts.
Warning: 187 shift/reduce conflicts were arbitrarily resolved.
任意解决不好! 这意味着它会随机选择一个,即使它不是我们想要的那个。为了解决这个问题,我们有两个选择
- 把语法改写得毫不含糊。
- 保留含糊不清的地方,但要告诉门希尔如何解决。
我最初为Bolt选择了方案1。对于小的语法来说,这样做是可能的,但是当你的语言变得更大时,这就变得更难了。主要的缺点是,你必须在Bolt表达式周围加上额外的括号来解除解析,这真的不是一个好的体验。
在写这篇文章的时候,我看了OCaml编译器的灵感(它也用了Menhir!)和Menhir文档。方案2提供了一个更好的解决方案,但首先我们需要了解Menhir的工作原理。
Menhir是一个shift-reduce解析器。它自下而上地构建我们的AST,根据下一个token决定是减少当前非终端和终端的堆栈(即它符合生产规则的右侧,所以将其减少到左侧)还是将另一个token移到堆栈上。
当它可以同时进行这两种操作,并且在任何一种情况下都能得到一个有效的AST时,就会发生shift-reduce冲突。Menhir让我们在<action>令牌中手动指定要采取的动作是否是。
%left: reduceright%:移位%nonassoc: 引起一个语法错误(SyntaxError)
如果你不知道该选哪一个,你有一个秘密武器!
运行 menhir src/frontend/parsing/parser.mly --explain 将会在 parser.conflicts 文件中生成一个解释,它将深入解释冲突所在!这只是故事的一部分--我们要指定乘法优先于加法。
这只是故事的一部分--我们要指定乘法优先于加法。我们可以通过指定动作的顺序来实现,从最低优先级到最高优先级。然后,当有多个规则时,Menhir将选择优先级最高的一个--在我们的例子中,它将在加法之前减少乘法,给我们正确的AST。
%right COLONEQ EQUAL
%left PLUS MINUS
%left MULT DIV REM
%nonassoc EXCLAMATION_MARK
所以,我们完成了,对不对?不完全是。如果我们有多个二进制运算符,我们会把这个语法写成。
expr ::= | INT | expr binop expr
binop ::= | PLUS | MINUS | MULT | DIV | REM | ...
在按照上述步骤进行操作后,你可能仍然会得到以下错误。
Warning: the precedence level assigned to PLUS is never useful.
或者你仍然有shift-reduce冲突,即使你已经解决了所有的冲突。出了什么问题?
看我说这个优先级是在有多个规则的情况下有用,而不是在有一个生产的情况下有用。这里我们只是有一个(expr binop expr)。我们要的是每一个运算符都有一条规则(expr PLUS expr)(expr MULT expr)等等。Menhir已经帮你解决了问题--只需添加一个%inline关键字。这就将下面bin_op的所有用法扩展为每个变量的一条规则。
%inline bin_op:
| PLUS { BinOpPlus }
| MINUS { BinOpMinus }
| MULT { BinOpMult }
...
瞧,我们完成了!没有更多的转变-减少冲突。
将词典和解析器放在一起。
好了,让我们来总结一下Bolt仓库的src/parsing文件夹,说说我们如何把这一切放在一起。我们需要的是以下内容。
(** Given a lex buffer to read a bolt program from, parse the
program and return the AST if successful *)
val parse_program : Lexing.lexbuf -> Parsed_ast.program Or_error.t
要做到这一点,我们需要使用OCamllex和Menhir从我们的lexer.mll和parser.mly文件中生成的Lexer和Parser模块。具体来说,我们关心的是这两个函数。
val Lexer.read_token: Lexing.lexbuf -> token
val Parser.program: (Lexing.lexbuf -> token) -> Lexing.lexbuf
-> (Parsed_ast.program)
这些函数可以抛出异常。我们很难追踪哪些函数会抛出异常,所以我们改用Result monad来捕获异常,而不是使用Result monad(不要害怕!),它有两个可能的选项--Ok something或Error e。然后你可以从函数签名Or_error.t看出它可能会返回一个错误。
(* Prints the line number and character number where the error occurred.*)
let print_error_position lexbuf =
let pos = lexbuf.lex_curr_p in
Fmt.str "Line:%d Position:%d" pos.pos_lnum (pos.pos_cnum - pos.pos_bol + 1)
let parse_program lexbuf =
try Ok (Parser.program Lexer.read_token lexbuf) with
(* catch exception and turn into Error *)
| SyntaxError msg ->
let error_msg = Fmt.str "%s: %s@." (print_error_position lexbuf) msg in
Error (Error.of_string error_msg)
| Parser.Error ->
let error_msg = Fmt.str "%s: syntax error@." (print_error_position lexbuf) in
Error (Error.of_string error_msg)
let pprint_parsed_ast ppf (prog : Parsed_ast.program) =
Pprint_past.pprint_program ppf prog
这个文件还公开了库中解析AST的pretty-print函数(pprint_past.ml)。这对于调试或期望测试是很有用的(正如这个Bolt早期版本的片段所演示的)。
这在Bolt的流水线中处于什么位置?
这是前端的第一阶段,可以在compile_program_ir函数中看到。
let compile_program_ir ?(should_pprint_past = false) ?(should_pprint_tast = false)
?(should_pprint_dast = false) ?(should_pprint_drast = false)
?(should_pprint_fir = false) ?(ignore_data_races = false) ?compile_out_file lexbuf =
let open Result in
parse_program lexbuf
>>= maybe_pprint_ast should_pprint_past pprint_parsed_ast
>>= ...
我还没告诉你 lexbuf 是怎么来的。你可以从输入通道中获取,就像main函数一样,它从命令行中读取Bolt文件。
In_channel.with_file filename ~f:(fun file_ic ->
let lexbuf =
Lexing.from_channel file_ic
(*Create a lex buffer from the file to read in tokens *) in
compile_program_ir lexbuf ...
或者,就像在测试(test/frontend/expect)中一样,你可以使用(Lexing.from_string input_str)从一个字符串中读取。
总结
关于lexer和解析器的讨论到此结束。如果你还没有,请fork the Bolt repo。
这篇文章中链接的所有代码都在 src/frontend/parsing 文件夹中,加上语法中的一些额外规则,以涵盖使用能力的数据竞赛自由度(正如我的论文中所讨论的那样),以及继承和属性。
听起来很刺激?接下来,我们将讨论类型检查,甚至实现一种局部类型推理的形式。这篇文章将在下周内发布,如果你喜欢这篇文章,我相信你会喜欢下一篇!我将解释如何读取类型检查,甚至实现一种局部类型推理。我将解释如何阅读类型规则(\Gamma \vdash e : \tauΓ⊢e:τ)以及如何实现核心语言的类型检查器。
通过www.DeepL.com/Translator(免费版)翻译