[LLVM翻译]创建BOLT编译器:第三部分——使用OCamllex和Menhir编写一个词汇和解析器。

765 阅读17分钟

原文地址:mukulrathi.co.uk/create-your…

原文作者:mukulrathi.co.uk/

发布时间:2020年6月1日-9分钟阅读

系列。创建BOLT编译器

即将推出


我们不能直接对Bolt程序进行推理,因为它只是一个非结构化的字符流。词法和解析将它们预处理成一个结构化的表示,我们可以在编译器的后期阶段对其进行类型检查。

词法标记

单个字符的意义不大,所以首先我们需要将流分割成令牌(类似于句子中的 "词")。这些令牌赋予了意义:这组字符在我们的语言中是一个特定的关键词(如果是int类)还是一个标识符(banana)?

令牌还大大缩小了我们的问题空间:它们使表示标准化。我们不再需要担心空格问题(x == 0和x== 0都变成了IDENTIFIER(x) EQUAL EQUAL INT(0)),我们可以从源代码中过滤掉注释。

我们如何将我们的字符流分割成tokens呢?我们进行模式匹配。从表面上看,你可以认为这是一个大规模的案例分析。然而,由于案例分析是模糊的(多个标记可能对应同一组字符),我们必须考虑两个额外的规则。

  1. 优先顺序: 我们按照优先级对标记进行排序。例如,我们希望int被匹配为关键字,而不是变量名。
  2. 最长模式匹配:我们把 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.

任意解决不好! 这意味着它会随机选择一个,即使它不是我们想要的那个。为了解决这个问题,我们有两个选择

  1. 把语法改写得毫不含糊。
  2. 保留含糊不清的地方,但要告诉门希尔如何解决。

我最初为Bolt选择了方案1。对于小的语法来说,这样做是可能的,但是当你的语言变得更大时,这就变得更难了。主要的缺点是,你必须在Bolt表达式周围加上额外的括号来解除解析,这真的不是一个好的体验。

在写这篇文章的时候,我看了OCaml编译器的灵感(它也用了Menhir!)和Menhir文档。方案2提供了一个更好的解决方案,但首先我们需要了解Menhir的工作原理。

Menhir是一个shift-reduce解析器。它自下而上地构建我们的AST,根据下一个token决定是减少当前非终端和终端的堆栈(即它符合生产规则的右侧,所以将其减少到左侧)还是将另一个token移到堆栈上。

当它可以同时进行这两种操作,并且在任何一种情况下都能得到一个有效的AST时,就会发生shift-reduce冲突。Menhir让我们在<action>令牌中手动指定要采取的动作是否是。

  1. %left: reduce
  2. right%:移位
  3. %nonassoc: 引起一个语法错误(SyntaxError)

如果你不知道该选哪一个,你有一个秘密武器!

twitter.com/mukulrathi_…

运行 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早期版本的片段所演示的)。

twitter.com/mukulrathi_…

这在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(免费版)翻译