Racket的宏系统是基于Binding as Sets of Scopes算法的,在实现一些复杂的宏时,往往会需要对宏进行手动操纵。本文将对Racket中的各种scope进行分析,以期建立一个清晰的心理模型,能快速理解Macro Stepper的输出,对宏的各种scope的问题进行调试并修复。
(注:阅读本文前,应了解Binding as Sets of Scopes的1–3节内容)
Racket的 identifier 在不同的 phase 可以拥有不同的scope,解析到不同的 binding ,而syntax-shift-phase-level可以偏移各 phase 的scope。但实际上,大部分的scope都是作用于所有 phase 的,也就是说,“shift”对其不起作用。
phase 特定的scope有两种,一是在展开module或top-level的时候引入的,另一种是syntax-binding-set系列api引入的。这两种scope在expander里被称为"multi scope",syntax-shift-phase-level仅对这部分scope起作用。严格来说,“multi scope”并不是通常意义上的scope,它是一种lazy的结构,在各个 phase 体现为不同的“representation scope”——这个“representation scope”更接近于通常意义上的scope。
运行以下程序
#lang racket(definea(make-base-namespace))(hash-ref(syntax-debug-info(namespace-syntax-introduce(datum->syntax#f'a)a))'context)(hash-ref(syntax-debug-info(syntax-shift-phase-level(namespace-syntax-introduce(datum->syntax#f'a)a)-1))'context)
可以看到类似
'(#(0module)#(86798moduletop-level))'(#(0module)#(86799moduletop-level))
的字样。这里的#(86798 module top-level)和#(86799 module top-level)就是a命名空间里,top-level的“multi scope”,所对应的 phase level 0和1的“representation scope”了。
对于 phase 无关的各种scope,其自身属性并无区别,主要是按用途来归类。(实际上,也存在interned和uninterned的区别,但由于 interned scope 极少会被用到,这里按下不表)
按syntax-debug-info显示的名字来分,有以下几种scope:
local
Fully Expanded Program 的 binding form (let-values、#%plain-lambda等)所引入的scope,用于区分local的 binding 。另外,quote-syntax(不带#:local)完全展开时,会删去结果中的local scope。
macro
宏展开引入的scope,通过宏展开前后的两次反转,所有展开过程中新引入的syntax对象都会添加上该scope。
use-site
当一个宏的定义和使用在同一个 definition context 时,宏的参数会带上该scope。
module
展开module时引入的scope。
intdef
展开 internal definition context 时引入的scope。
lifted-require
顾名思义,syntax-local-lift-require的产物。
letrec-body
letrec-values/letrec-syntaxes+values添加到其“body”(“rhs”没有)的scope。虽然Binding as Sets of Scopes提到“body-scope”机制在Racket中没有采用,但事实上还是用到了:
#lang racket(define-syntax(fstx)(syntax-casestx()[(_id)(displayln(hash-ref(syntax-debug-infostx)'context))#'(void)]))(letrec-values()(fa))
(#(43768module)#(43775moduleanonymous-module)#(43836local)#(43837intdef)#(43838local)#(43839letrec-body)#(43840intdef)#(43841macro))
可以看到这里的#(43839 letrec-body)。
在 definition context 展开的时候,输入的syntax对象会带上 outside-edge scope 和 inside-edge scope ;并且,展开的结果也会带上 inside-edge scope 。
outside-edge scope 区分宏引入的 identifier , inside-edge scope 区分不同的 definition context 。
这两种scope并不是新种类的scope,而是从另一个维度对上述的各种scope进行分类。
对于各种 definition context :
top-level的情况:充当 outside-edge scope 的是一个所有top-level共享的scope,即上文出现的#(0 module);充当 inside-edge scope 的是一个特定的“multi scope”。namespace-syntax-introduce就是添加这对scope。
module的情况:充当 outside-edge scope 的是特定的module scope;充当 inside-edge scope 的是一个特定的“multi scope”。
internal definition context 的情况:充当 inside-edge scope 的是特定的intdef scope;充当 outside-edge scope 的是外面的 binding form 特定的local scope,以及letrec-body scope(如果存在)。
first class internal definition context 的情况和上面类似——除了没有 outside-edge scope ,这也导致了一些卫生问题。
一个来自于https://github.com/racket/racket/issues/3198的例子:
#lang racket(definex'good)(define-syntax-rule(m)(displaylnx))(definec%(classobject%(super-new)(definex'bad2)(m)))(newc%)
在Racket 7.8中,输出bad2。因为bad2和m中的x展开后都被打上了作为 inside-edge scope 的intdef scope,但由于没有 outside-edge scope ,情况变成了:
workaround是给它凑上一个 outside-edge scope ,例如使用一个local scope:
(definec%(let()(classobject%(super-new)(definex'bad2)(m))))
这样bad2 x多了个scope,不会绑定m x。
实际上从这个问题中可以看到 outside-edge scope 和 use-site scope 的相似性。
另一个需要注意的是,由于“multi scope”也被用作 inside-edge scope ,如果进行了shift,被移走的当前 phase 的“representation scope”在后面又会加回来——最终该 identifier 仍能触及当前 phase 的 binding ,并且在当前的 phase 带有两种“representation scope”。因此下面这个程序不会出现“unbound identifier”错误。
#lang racket(definex1)(define-syntax(mstx)(syntax-casestx()[(_id)(syntax-shift-phase-level#'id-1)]))(mx)
盘点完scope后,再来看看scope和binding的关系。
Binding as Sets of Scopes中提到
extends a global table that maps a ⟨symbol, scope set⟩ pair to a representation of a binding.
那么Racket中存在这么一张全局的表吗?按照常识,正经的实现里肯定不会出现一个这么容易内存泄漏的结构。这里就可能导致一些误区:
认为 identifier 捕获了自身所在的局部环境——实际上 identifier 仍然只是 symbol + scope set。
认为解析一个 identifier 的 binding 依赖当前环境——实际上identifier-binding这个函数并不需要一个namespace参数。
#lang racket(definex(datum->syntax#f'x))(definens(make-base-namespace))(defineadd-scope(make-syntax-delta-introducer(namespace-syntax-introducexns)x))(eval-syntax#`(define#,x1)ns)(identifier-binding(add-scopex)0#t)
这里identifier-binding看起来不可能知道ns的存在,但仍然能解析到ns中的binding,得到'(x.1)。
这些看起来暗示着这么一张全局的表的存在,但实际上Racket使用的是一个等效的结构:scope反过来索引了包含了该scope的binding。把一个 identifier 添加为 binding 的时候, binding 的信息也被记录到其scope set的一个scope里,这个scope set的超集总是能访问到该 binding 。因此在分析问题时,可以简单地假定这张表存在。
Sets of Scopes下的binding解析就是寻找scope set的greatest子集而已,找不到任何子集就“unbound identifier”,找到多个maximal子集就“identifier’s binding is ambiguous”。
但是,“multi scope”的的处理就比较特殊——如果解析失败,它会把最近加入的“multi scope”去掉再尝试,直到没有“multi scope”为止。
#lang racket(definea-ns(make-base-namespace))(defineb-ns(make-base-namespace))(definex(datum->syntax#f'x))(for([ns(in-list(lista-nsb-ns))][i(in-naturals)])(definename(datum->syntax#f(string->symbol(format"ns-~a"i))))(eval-syntax#`(define#,name#t)ns)(eval-syntax#`(define-syntax#,x(make-rename-transformer#'#,name))ns))(define(resolve.nss)(identifier-binding(for/fold([xx])([ns(in-listnss)])(namespace-syntax-introducexns))0#t))(resolvea-nsb-ns)(resolveb-nsa-ns)
输出'(ns-0.1)和'(ns-1.1)。这就是“multi scope”添加顺序对binding解析的影响。
syntax-debug-info结果的fallbacks项也是由此而来。
这个设计是为了方便syntax对象在多个命名空间里使用。
Racket的local-expand/capture-lifts可以用来分隔不同的语言,但若想要用于其他的用途,就不太适合了。因为它会无条件捕获其他的lift,与正常的使用相互干扰。
这里通过模仿local-expand/capture-lifts来展示如何维护正确的scope。
首先确定api:
(define(liftexpr)<...>)(define(local-expand/capturestx[intdef-ctx'()])<...>)
基本上和syntax-local-lift-expression和local-expand/capture-lifts类似,为了简化,这里限定只做expression context下的完全展开。
然后,lift需要添加binding,Racket中能动态添加binding的机制就是first class intdef-ctx。所以local-expand/capture需要告诉lift目标的first class intdef-ctx,并且记录expr,在展开结束后填到返回的syntax对象里。
可以用parameter来指示记录的位置:
(definecurrent-lift-context(make-parameter#f))
current-lift-context的类型是
(Parameter(PairofInternal-Definition-Context(Boxof(Listof(PairofIdentifierSyntax))))
填写lift和local-expand/capture的实现和测试代码:
#lang racket(modulearacket/base(require(for-templateracket/base)racket/match)(provide(all-defined-out))(definecurrent-lift-context(make-parameter#f))(define(liftexpr)(match-define(consctxb)(current-lift-context))(defineid(car(generate-temporaries(listexpr))))(syntax-local-bind-syntaxes(listid)#fctx)(set-box!b(cons(listidexpr)(unboxb)))id)(define(local-expand/capturestx[intdef-ctx'()])(definectx(syntax-local-make-definition-context))(defineb(box'()))(defineexpanded(parameterize([current-lift-context(consctxb)])(local-expandstx'expressionnull(consctxintdef-ctx))))(with-syntax([([idexpr]...)(reverse(unboxb))][expandedexpanded])#'(let()(defineidexpr)...(let()expanded)))))(require(for-syntax'asyntax/transformer))(define-syntax(liftmestx)(syntax-casestx()[(_abc)(begin(definea1(lift#'a))(defineb1(lift#'b))(definec1(lift#'c))#`(list#,a1#,b1#,c1))]))(define-syntaxcapme(make-expression-transformer(λ(stx)(syntax-casestx()[(_form)(local-expand/capture#'form)]))))(capme(apply+(liftme(add10)23)))
得到identifier used out of context: #<syntax temp1>
上面的”out of context“意味着temp1解析到了环境中没有的binding。
哪些binding从环境中消失了?是first class intdef-ctx,local-expand结束后,其binding即不存在于环境了,但由于”全局表“的存在,仍能被解析到。既然出现了这个错误,那就表明结果中temp1的定义比ctx里的少了scope,或者temp1的定义比使用多了scope。
ctx中的binding多了其 inside-edge scope ,即intdef scope,可以用internal-definition-context-introduce添加。
(defineid(internal-definition-context-introducectx(car(generate-temporaries(listexpr)))))
仍旧是identifier used out of context: #<syntax temp1>。
通过Macro Stepper可以发现:temp1的定义比使用多了macro scope,temp1的定义所用的 identifier 是lift直接传送给local-expand/capture的,所以这个scope是只能是capme展开结束后加入的。
既然在结果中,temp1的定义和使用都是宏引入的,为什么只有定义带上了macro scope呢?
因为local-expand。local-expand前后也会反转当前的macro scope和use-site scope,所以temp1的使用最终不带有capme的macro scope,而只有liftme的macro scope。
反转定义中的scope:
(with-syntax([([idexpr]...)(reverse(unboxb))][expandedexpanded])(with-syntax([(id...)(syntax-local-introduce#'(id...))])#'(let()(defineidexpr)...(let()expanded))))
得到temp1: identifier's binding is ambiguous。
在DrRacket中点开错误信息,看到类似如下的binding信息:
temp1: identifier's binding is ambiguous
context...:
#(67091 macro) #(67097 local) #(67098 intdef) #(67107 local)
#(67108 intdef) [common scopes]
matching binding...:
local
#(67091 macro) [common scopes]
matching binding...:
local
#(67097 local) #(67098 intdef) [common scopes]
common scopes...:
#(67088 intdef) #(67092 macro) in: temp1
不难看出第一个binding是ctx中的binding,第二个是结果中temp1的定义。也就是说ctx的binding也带有liftme的macro scope。
这是由于syntax-local-bind-syntaxes也会反转当前的macro scope,所以:
(syntax-local-bind-syntaxes(list(syntax-local-introduceid))#fctx)
通过上面的修改,这个程序输出6。
最终代码如下:
#lang racket/base(require(for-templateracket/base)racket/match)(provide(all-defined-out))(definecurrent-lift-context(make-parameter#f))(define(liftexpr)(match-define(consctxb)(current-lift-context))(defineid(internal-definition-context-introducectx(car(generate-temporaries(listexpr)))))(syntax-local-bind-syntaxes(list(syntax-local-introduceid))#fctx)(set-box!b(cons(listidexpr)(unboxb)))id)(define(local-expand/capturestx[intdef-ctx'()])(definectx(syntax-local-make-definition-context))(defineb(box'()))(defineexpanded(parameterize([current-lift-context(consctxb)])(local-expandstx'expressionnull(consctxintdef-ctx))))(with-syntax([([idexpr]...)(reverse(unboxb))][expandedexpanded])(with-syntax([(id...)(syntax-local-introduce#'(id...))])#'(let()(defineidexpr)...(let()expanded)))))