作用域、名称解析与只看可知处的类型检查
目标
遍历语法分析器构造的树,把每个名字连到它的声明上(名称解析),并把运行之前就能知道的问题——不存在的名字、重复声明、读取自己的初始值、函数之外的 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 키워드
步骤
- 补全
/root/mini/checker.py的Decl(name, line, col, type_=None, arity=None, defined=True)和Scopes(push、pop、declare(已存在则返回 False)、lookup、innermost)。 - 补全
Checker的error、declare、use、statements、stmt和expr——let、块、print、表达式语句,以及名字和赋值。调用交给call,运算符交给typed_expr,if 和 while 交给condition,fn 和 return 交给function_stmt。 - 补全
function_stmt和call——为了能够递归,先放入名字;形参和函数体共用一个作用域;函数之外的 return;实参个数。 - 补全
ARITH、ORDER、want和typed_expr——未知的类型直接跳过。 - 补全
condition,以及模块级的analyze(program)、check和resolve。analyze 返回(오류 글자 목록, 이름 해석 표)(占位符依次为错误文字列表和名称解析表)。 - 评分器会把固定程序,以及棘手作用域(闭包看到的名字、每个分支各自的作用域、循环中的遮蔽)的名称解析表整体对照。
- 评分器会对照错误程序(
/opt/fixtures/mini/errors/check-*.mini和另外几个)的错误列表。 - 评分器会对照 120 个随机的正确程序(不能有误报)和 120 个逐处破坏的程序。
参考
python3 /root/mini/mini.py check 파일.mini(占位符为文件名)会用你的检查器打印错误。- 词法分析器、语法分析器和节点在实验开始时已经铺好(也可以粘贴前面模块里你自己做的)。
- 常见错误:离开块时不丢弃作用域(泄漏),先检查初始值,导致
let x = x;指向外层的 x,把未知的类型当作错误(误报),按发现错误的顺序返回错误。 - 会话从 60 分钟开始,可以用+时间延长,结束后
/root/mini会消失。
作用域栈
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 各分支里的声明互相冲突,就是没有把分支当作块来检查。
错误程序逐字对上
没有新代码。消息文字、位置、顺序都必须一致。实参个数错误只在通过名字直接调用时才有(作为值收到的函数则未知),赋值错误在 = 的位置,条件错误在关键字的位置。
同时测量误报与漏报
没有新代码。如果在正确的随机程序上报错,说明在检查未知类型(形参、调用结果);如果在被破坏的程序上错误不够,说明找不到名字时就停了,或者把同一个表达式的第二个错误丢掉了。