鹦鹉虚拟机/Squaak 教程/作用域和子程序
第 1 集: 介绍
第 2 集: 深入编译器内部
第 3 集: Squaak 细节和第一步
第 4 集: PAST 节点和更多语句
第 5 集: 变量声明和作用域
第 6 集: 作用域和子程序
第 7 集: 运算符和优先级
第 8 集: 哈希表和数组
第 9 集: 总结和结论
在 第 5 集 中,我们学习了变量声明和作用域实现。我们当时涵盖了大量信息,但为了保持文章简短,没有讲完整的故事。在本集里,我们将介绍遗漏的部分,这也会导致子程序的实现。
在上一集里,我们把局部变量放入当前块的符号表中。正如我们之前所见,使用 do-block 语句,作用域可以嵌套。考虑这个例子
do
var x = 42
do
print(x)
end
end
在这个例子中,print 语句应该打印 42,即使 x 没有在引用它的作用域中声明。编译器是如何知道它仍然是一个局部变量的呢?这很简单:它应该在所有作用域中查找,从最内层的开始。只有当在任何作用域中找到该变量时,才应该将它的作用域设置为“词法”,以便生成正确的指令。
我想到的解决方案如下所示。请注意,我不能 100% 确定这是“最佳”解决方案,因为我对 PAST 编译器的个人理解有限。因此,虽然这个解决方案有效,但我可能会教你错误的“习惯”。请注意这一点。
method identifier($/) {
our @?BLOCK;
my $name := ~$<ident>;
my $scope := 'package'; # default value
# go through all scopes and check if the symbol
# is registered as a local. If so, set scope to
# local.
for @?BLOCK {
if $_.symbol($name) {
$scope := 'lexical';
}
}
make PAST::Var.new( :name($name),
:scope($scope),
:viviself('Undef'),
:node($/) );
}
你可能之前注意到了 viviself 属性。这个属性会导致额外的指令,这些指令会在变量不存在时初始化它。如你所知,全局变量在使用时会自动创建。我们之前提到过未初始化的变量的默认值为“Undef”:viviself 属性就是做这个的。对于局部变量,我们使用这种机制来设置(可选的)初始化值。当标识符是一个参数时,如果该参数所属的子程序被调用时没有收到值,那么该参数将被自动初始化。实际上,这意味着 Squaak 中的所有参数都是可选的!
我们之前已经提到过子程序,并介绍了 PAST::Block 节点类型。我们还简要提到了可以在 PAST::Block 节点上设置的 blocktype 属性,它指示该块是立即执行(例如,do-block 或 if 语句)还是表示声明(例如,子程序)。现在让我们看看子程序定义的语法规则
rule sub_definition {
'sub' <identifier> <parameters>
<statement>*
'end'
{*}
}
rule parameters {
'(' [<identifier> [',' <identifier>]* ]? ')'
{*}
}
这相当直接,这些规则的操作方法也很简单,你将会看到。然而,首先让我们看看子定义的规则。为什么子体被定义为 <statement>* 而不是 <block>?当然,子程序定义了一个新的作用域,这已经被 <block> 涵盖了。好吧,你说得对。但是,正如我们将会看到的那样,当创建一个新的 PAST::Block 节点时,我们已经太晚了!参数已经被解析,并且没有被放入块的符号表中。这是一个问题,因为参数很可能在子程序体中使用,而且由于它们没有被注册为局部变量(它们是),所以对参数的任何使用都不会被编译成获取任何参数的正确指令。
那么,我们如何以一种高效的方式解决这个问题呢?
解决方案很简单。参数只存在于子程序体中,由一个 PAST::Block 节点表示。为什么不在 parameters 规则的操作方法中创建 PAST::Block 节点呢?这样做,块就已经到位了,参数也会及时地被注册为局部符号。让我们看看操作方法。
method parameters($/) {
our $?BLOCK;
our @?BLOCK;
my $past := PAST::Block.new( :blocktype('declaration'),
:node($/) );
# now add all parameters to this block
for $<identifier> {
my $param := $( $_ );
$param.scope('parameter');
$past.push($param);
# register the parameter as a local symbol
$past.symbol($param.name(), :scope('lexical'));
}
# now put the block into place on the scope stack
$?BLOCK := $past;
@?BLOCK.unshift($past);
make $past;
}
method sub_definition($/) {
our $?BLOCK;
our @?BLOCK;
my $past := $( $<parameters> );
my $name := $( $<identifier> );
# set the sub's name
$past.name( $name.name() );
# add all statements to the sub's body
for $<statement> {
$past.push( $( $_ ) );
}
# and remove the block from the scope
# stack and restore the current block
@?BLOCK.shift();
$?BLOCK := @?BLOCK[0];
make $past;
}
首先,让我们看看 parameters 的解析操作。首先,创建一个新的 PAST::Block 节点。然后,我们遍历标识符列表(可以为空),每个标识符代表一个参数。在获取参数的结果对象(只是一个标识符)后,我们将它的作用域设置为“parameter”,并将它添加到块对象中。之后,我们以“lexical”的作用域将参数注册为块对象的符号。参数只是局部变量的一种特殊类型,子程序中参数和声明的局部变量之间没有区别,除了参数通常会用子程序调用时传递的值进行初始化。
处理完参数后,我们将当前块(由我们的包变量 $?BLOCK 引用)设置为我们刚刚创建的 PAST::Block 节点,并将它推送到作用域栈(由我们的包变量 @?BLOCK 引用)上。
在整个子程序定义解析完后,会调用操作方法 sub_definition。这将获取参数的结果对象,该对象是将代表子程序的 PAST::Block 节点。在获取子程序名称的结果对象后,我们将名称设置在块节点上,并将所有语句添加到块中。之后,我们将这个块节点从作用域栈(@?BLOCK)中弹出,并恢复当前块($?BLOCK)。
很简单,对吧?
定义了子程序后,你就会想要调用它。在 第 5 集 的练习中,我们已经提供了一些关于如何创建子程序调用的 PAST 节点的提示。在本节中,我们将提供完整的描述。首先,我们将介绍语法规则。
rule sub_call {
<primary> <arguments>
{*}
}
这不仅允许你通过名称调用子程序,你还可以将子程序存储在数组或哈希字段中,然后从那里调用它们。让我们来看看操作方法,它非常直接。
method sub_call($/) {
my $invocant := $( $<primary> );
my $past := $( $<arguments> );
$past.unshift($invocant);
make $past;
}
method arguments($/) {
my $past := PAST::Op.new( :pasttype('call'), :node($/) );
for $<expression> {
$past.push( $( $_ ) );
}
make $past;
}
sub_call 方法的结果对象应该是一个 PAST::Op 节点(类型为 'call'),它包含多个子节点:第一个是调用者对象,所有剩余的子节点都是对该子程序调用的参数。
为了将参数的结果对象“移动”到 sub_call 方法中,我们在方法参数中创建了 PAST::Op 节点,然后由 sub_call 获取。在 sub_call 中,调用者对象被设置为第一个子节点(使用 unshift)。这很容易,不是吗? :-)
在本集里,我们完成了 Squaak 中作用域的实现,并实现了子程序。我们的语言进展顺利!在下一集里,我们将探索如何实现运算符和运算符优先级表,以便高效地解析表达式。
与此同时,如果你遇到任何问题或疑问,请随时留言!
- 问题 1
现在你应该对 Squaak 中作用域的实现有了一个很好的了解。我们还没有实现 for 语句,因为它需要适当的作用域处理才能实现。实现这个。查看第三集,了解定义 for 语句语法的 BNF 规则。在实现它的时候,你会遇到和我们实现子例程和参数时一样的困难。使用相同的技巧来实现 for 语句。
- 解答
首先,让我们看看 for 语句的 BNF
for-statement ::= 'for' for-init ',' expression [step]
'do'
block
'end'
step ::= ',' expression
for-init ::= 'var' identifier '=' expression
将其转换为 Perl 6 规则非常容易
rule for_statement {
'for' <for_init> ',' <expression> <step>?
'do' <block> 'end'
{*}
}
rule step {
',' <expression>
{*}
}
rule for_init {
'var' <identifier> '=' <expression>
{*}
}
非常容易吧?让我们看一下语义。for 循环只是编写 while 循环的另一种方式,但在某些情况下更容易。这个
for var <ident> = <expr1>, <expr2>, <expr3> do
<block>
end
对应于
do
var <ident> = <expr1>
while <ident> <= <expr2> do
<block>
<ident> = <ident> + <expr3>
end
end
如果 <expr3> 缺失,则默认为值“1”。注意,步长表达式 (expr3) 应该是正数;循环条件包含 <= 运算符。当你指定一个负数步长表达式时,循环变量只会减少值,这永远不会使循环条件为假(除非它溢出,但那是另一个问题;这甚至可能在 Parrot 中引发异常;我并不知道)。允许负数步长表达式会引入更多复杂性,我认为这对于本教程语言来说不值得。
注意,循环变量 <ident> 对 for 循环是本地的;这在等效的 while 循环中通过周围的 do/end 对来表示:一个新的 do/end 对定义了一个新的(嵌套的)作用域;在 end 关键字之后,循环变量不再可见。
让我们实现 for 语句的动作方法。正如练习说明中提到的,我们遇到了与子例程参数相同的情况。在这种情况下,我们正在处理对 for 语句是本地的循环变量。让我们看看 for_init 的规则
method for_init($/) {
our $?BLOCK;
our @?BLOCK;
## create a new scope here, so that we can
## add the loop variable
## to this block here, which is convenient.
$?BLOCK := PAST::Block.new( :blocktype('immediate'),
:node($/) );
@?BLOCK.unshift($?BLOCK);
my $iter := $( $<identifier> );
## set a flag that this identifier is being declared
$iter.isdecl(1);
$iter.scope('lexical');
## the identifier is initialized with this expression
$iter.viviself( $( $<expression> ) );
## enter the loop variable into the symbol table.
$?BLOCK.symbol($iter.name(), :scope('lexical'));
make $iter;
}
所以,正如我们在参数动作方法中为子例程创建了一个新的 PAST::Block 一样,我们在定义循环变量的动作方法中为 for 语句创建了一个新的 PAST::Block。(猜猜我们为什么将 for-init 设置为一个子规则,而没有在 for 语句规则中放入“var <ident> = <expression>”)。此块是循环变量存在的场所。循环变量被声明,使用 viviself 属性进行初始化,并被输入到新块的符号表中。注意,在创建新的 PAST::Block 对象之后,我们将它放到堆栈作用域上。
现在,for 语句的动作方法很长,所以我只会嵌入我的注释,这使得阅读起来更容易。
method for_statement($/) {
our $?BLOCK;
our @?BLOCK;
首先,获取 for 语句初始化规则的结果对象;这是 PAST::Var 对象,表示循环变量的声明和初始化。
my $init := $( $<for_init> );
然后,为循环变量创建一个新节点。是的,另一个(除了当前包含在 PAST::Block 中的)。这个节点在循环体代码结束时(每次迭代)更新循环变量时使用。与另一个节点的不同之处在于,它没有 isdecl 标志,也没有 viviself 子句,这会导致额外的指令检查变量是否为空(我们知道它不是空的,因为我们初始化了循环变量)。
## cache the name of the loop variable
my $itername := $init.name();
my $iter := PAST::Var.new( :name($itername),
:scope('lexical'),
:node($/) );
现在,从作用域堆栈中检索 PAST::Block 节点,并将所有语句 PAST 节点推入它。
## the body of the loop consists of the statements written by the user and
## the increment instruction of the loop iterator.
my $body := @?BLOCK.shift();
$?BLOCK := @?BLOCK[0];
for $<statement> {
$body.push($($_));
}
如果存在一个步长,我们使用该值;否则,我们使用默认步长“1”。
负数步长将不起作用,但如果你感觉幸运,你可以继续尝试。这并不难,只是很多工作,我现在太懒了……嗯,我的意思是,我把这作为习题留给读者。
my $step;
if $<step> {
my $stepsize := $( $<step>[0] );
$step := PAST::Op.new( $iter, $stepsize, :pirop('add'), :node($/) );
}
else { ## default is increment by 1
$step := PAST::Op.new( $iter,
:pirop('inc'),
:node($/) );
}
循环变量的递增是循环体的一部分,所以将递增语句添加到 $body 中。
$body.push($step);
循环条件使用 <= 运算符,并将循环变量与指定的最大值进行比较。
## while loop iterator <= end-expression
my $cond := PAST::Op.new( $iter, $( $<expression> ),
:name('infix:<=') );
现在我们有了循环条件和循环体的 PAST,所以现在创建一个 PAST 来表示(while)循环。
my $loop := PAST::Op.new( $cond, $body,
:pasttype('while'),
:node($/) );
最后,循环变量的初始化应该在循环本身之前进行,所以创建一个 PAST::Stmts 节点来执行此操作
make PAST::Stmts.new( $init, $loop,
:node($/) );
}
哇,我们做到了!这是一个很好的例子,说明如何使用 PAST 实现非平凡的语句类型。