TT Lab
开始
学习 学习路径 课程

编译器 — 从头到尾亲手打造一门小语言

作用域、名称解析与只看可知处的类型检查

在 TT Lab 中继续学习

目标

遍历语法分析器构造的树,把每个名字连到它的声明上(名称解析),并把运行之前就能知道的问题——不存在的名字、重复声明、读取自己的初始值、函数之外的 return、实参个数、能确定之处的类型——按位置顺序全部收集起来。

为什么重要

只有运行才会暴露的错误,在那一行执行之前一直隐藏着。反过来,如果检查器拒绝了完好的程序,人们就会把检查器关掉。所以语义分析的关键是决定到哪里为止可以确切知道,而本实验的评分对“漏报的错误”和“误报”同样严格。

规则

스코프     맨 아래가 전역. 블록마다, 함수마다 하나씩 쌓는다. 찾기는 안쪽부터.
           함수의 매개변수와 몸체의 맨 바깥 선언은 한 스코프다.
let        이름을 '정의 전' 으로 먼저 넣고 → 초기값 검사 → '정의됨'. 선언의 타입 = 초기값의 타입.
fn         이름을 (fn, 인자 수) 로 먼저 넣고 → 새 스코프에 매개변수(타입 모름) → 몸체.
타입       int · bool · fn · 모름(None). 양쪽을 다 알 때만 검사한다.
           + - * / % ^ 앞 - : int → int      < <= > >= : int → bool
           == != : 두 쪽이 같은 타입 → bool   ! && || : bool → bool
           대입: 이름의 타입과 값의 타입을 다 알면 같아야. 대입식의 타입 = 값의 타입. 호출의 결과 = 모름.
이름 해석  "쓴 곳 줄:칸" → "선언 줄:칸" (선언 위치는 let·fn 은 이름 토큰, 매개변수는 그 이름 토큰)
오류 글자  (줄, 칸, 메시지)를 모아 두었다가 (줄, 칸) 순으로 "줄:칸: 메시지"
  undefined variable 'x'                         쓴 곳
  'x' is already declared in this scope          두 번째 선언의 이름
  cannot read 'x' in its own initializer         쓴 곳
  return outside function                        return 키워드
  function 'f' takes 2 arguments, got 3          여는 괄호(이름으로 직접 부를 때만)
  cannot call int                                여는 괄호(부르는 쪽 타입이 int·bool 일 때)
  operator '+' expects int, got bool             연산자 (피연산자마다 따로)
  operator '==' compares int with bool           연산자
  cannot assign bool to 'x' (int)                =
  condition must be bool, got int                if·while 키워드

步骤

  1. 补全 /root/mini/checker.py 的 Decl(name, line, col, type_=None, arity=None, defined=True) 和 Scopes(push、pop、declare(已存在则返回 False)、lookup、innermost)。
  2. 补全 Checker 的 error、declare、use、statements、stmt 和 expr——let、块、print、表达式语句,以及名字和赋值。调用交给 call,运算符交给 typed_expr,if 和 while 交给 condition,fn 和 return 交给 function_stmt。
  3. 补全 function_stmt 和 call——为了能够递归,先放入名字;形参和函数体共用一个作用域;函数之外的 return;实参个数。
  4. 补全 ARITH、ORDER、want 和 typed_expr——未知的类型直接跳过。
  5. 补全 condition,以及模块级的 analyze(program)、check 和 resolve。analyze 返回 (오류 글자 목록, 이름 해석 표)(占位符依次为错误文字列表和名称解析表)。
  6. 评分器会把固定程序,以及棘手作用域(闭包看到的名字、每个分支各自的作用域、循环中的遮蔽)的名称解析表整体对照。
  7. 评分器会对照错误程序(/opt/fixtures/mini/errors/check-*.mini 和另外几个)的错误列表。
  8. 评分器会对照 120 个随机的正确程序(不能有误报)和 120 个逐处破坏的程序。

参考

作用域栈

stack 是字典的列表,最底层是全局。declare 只放入最上面的字典,如果已存在,就什么也不改,返回 False。lookup 用 reversed(stack) 从内层开始找。innermost 只看最上面的字典。

把名字连到声明

let 先 declare 一个 Decl(defined=False),求出初始值的类型之后,再改成 defined=True。use 在 innermost 是“定义前”时,报读取自己初始值的错误;lookup 返回 None 时,报名字不存在;找到了,就在 resolved 里记下“用到的位置 → 声明”。块用 push 和 pop 包起来。

函数、return 和调用

fn 先把 Decl(名字, 'fn', 实参个数) 放进当前作用域,然后 push,放入形参,并在那个作用域里直接检查函数体的各条语句(不要为函数体块再 push 一次)。记下函数深度,就能知道 return 是否在函数之外。

只看能确定之处的类型

want(节点, 运算符, 收到的类型, 期望的类型) 在收到的类型是 None 时什么也不说。操作数是逐个单独检查的,所以 1 + true 有一个错误,true + false 有两个。结果类型由运算符决定,与检查结果无关。

条件与错误整理

condition 在条件的类型已知却不是 bool 时,在关键字位置报错,并用 stmt 检查分支(块)——块会替你压入作用域。analyze 先把错误列表按(行,列)排序,再转成文字。

遮蔽与作用域泄漏

没有新代码。如果名称解析表对不上,消息会告诉你哪个用到的位置连到了错误的声明。如果块结束之后的名字连到了内层声明,就是漏了 pop;如果 if 各分支里的声明互相冲突,就是没有把分支当作块来检查。

错误程序逐字对上

没有新代码。消息文字、位置、顺序都必须一致。实参个数错误只在通过名字直接调用时才有(作为值收到的函数则未知),赋值错误在 = 的位置,条件错误在关键字的位置。

同时测量误报与漏报

没有新代码。如果在正确的随机程序上报错,说明在检查未知类型(形参、调用结果);如果在被破坏的程序上错误不够,说明找不到名字时就停了,或者把同一个表达式的第二个错误丢掉了。