鹦鹉虚拟机/Squaak 教程/变量声明和作用域
第 1 集: 介绍
第 2 集: 深入编译器内部
第 3 集: Squaak 细节和入门
第 4 集: PAST 节点和更多语句
第 5 集: 变量声明和作用域
第 6 集: 作用域和子程序
第 7 集: 运算符和优先级
第 8 集: 哈希表和数组
第 9 集: 总结和结论
第 4 集 讨论了某些语句类型的实现,例如 if 语句。在本集中,我们将讨论变量声明和作用域处理。这将是一个很长的故事,所以请花点时间阅读这一集。
Squaak 变量具有两种作用域之一:要么是全局变量,要么是局部变量。要创建全局变量,只需将某个表达式赋给一个标识符(该标识符尚未声明为局部变量)。另一方面,局部变量必须使用var
关键字声明。换句话说,在解析阶段的任何给定时间,我们都有一系列已知为局部变量的变量。当解析一个标识符时,会查找它,如果找到,则将其作用域设置为局部。如果没有找到,则假定其作用域为全局。当使用未初始化的变量时,其值将设置为一个名为Undef
的对象。下面给出了一些示例。
x = 42 # x was not declared, so it is global var k = 10 # k is local and initialized to 10 a + b # neither a nor b was declared; # both default to the value "Undef"
之前我们提到过需要存储已声明的局部变量。在编译器术语中,这种用于存储声明的数据结构称为符号表。每个单独的作用域都有一个单独的符号表。
Squaak 具有一个所谓的 do 块语句,它定义如下。
rule do_block { 'do' <block> 'end' {*} }
每个 do 块都定义了一个新的作用域;在“do
”和“end
”关键字之间声明的局部变量对该块是局部的。下面给出一个示例来说明这一点
do var x = 1 print(x) # prints 1 do var x = 2 print(x) # prints 2 end print(x) # prints 1 end
因此,每个 do/end 对都定义了一个新的作用域,在该作用域中,任何声明的变量都会隐藏外层作用域中具有相同名称的变量。这种行为在许多编程语言中很常见。PCT 内置了对符号表的支持;一个PAST::Block
对象有一个方法symbol
,可用于输入新符号并在表中查询现有符号。在 PCT 中,一个PAST::Block
对象代表一个作用域。存在两种blocktype
:immediate
和 declaration
。一个immediate
块可用于表示 do 块语句中的语句块,例如
do block end
当执行此语句时,会立即执行块。另一方面,一个declaration
块表示一个可以在以后调用的一组语句块。通常这些是子程序。因此,在此示例中
sub foo(x) print(x) end
为子程序foo
创建一个PAST::Block
对象。blocktype
被设置为declaration
,因为子程序是定义的,而不是执行的(立即执行)。现在你可以暂时忘记blocktype
,但现在我已经告诉你,当你看到它时,你就会认出它。我们将在后面的集中再讨论它。
因此,我们知道如何使用全局变量、声明局部变量以及有关PAST::Block
对象代表作用域的知识。我们如何让编译器生成正确的 PIR 指令?毕竟,在处理全局变量时,Parrot 必须与处理局部变量的方式不同。在创建PAST::Var
节点以表示变量时,我们必须知道该变量是局部变量还是全局变量。因此,在处理变量声明(局部变量的声明;全局变量未声明)时,我们需要将标识符注册为当前块的符号表中的局部变量。首先,我们将看一下变量声明的实现。
以下是对变量声明的语法规则。这是一种语句类型,因此我假设您知道如何扩展语句规则以允许变量声明。
rule variable_declaration { 'var' <identifier> ['=' <expression>]? {*} }
局部变量使用var
关键字声明,并且具有一个可选的初始化表达式。如果后者缺失,则变量的值默认为名为Undef
的未定义值。让我们看看解析操作的样子
method variable_declaration($/) { # get the PAST for the identifier my $past := $( $<identifier> ); # this is a local (it's being defined) $past.scope('lexical'); # set a declaration flag $past.isdecl(1); # check for the initialization expression if $<expression> { # use the viviself clause to add a # an initialization expression $past.viviself( $($<expression>[0]) ); } else { # no initialization, default to "Undef" $past.viviself('Undef'); } make $past; }
嗯,这并不难,不是吗?让我们分析一下我们刚刚做了什么。首先,我们检索了标识符的 PAST 节点,然后我们通过将它的作用域设置为lexical
(局部变量被称为词法作用域,因此为lexical
),并设置一个指示该节点表示声明(isdecl
)的标志,对其进行了装饰。因此,除了在其他语句(例如赋值)中表示变量之外,PAST::Var
节点还用作声明语句。
在本集中,我们之前提到过在声明局部变量时,需要将它们注册到当前作用域块中。因此,在执行变量声明的解析操作时,应该已经存在一个PAST::Block
节点,可以用来注册要声明的符号。正如我们在第 4 集中所学到的,PAST 节点以深度优先的方式创建;首先创建叶子,然后创建解析树中“更高”的节点。这意味着PAST::Block
节点是在将要成为块子节点的语句节点(variable_declaration
是其中之一)之后创建的。在下一节中,我们将看到如何解决这个问题。
为了确保在解析任何语句(及其解析操作 - 这些操作可能需要在块的符号表中输入符号)之前创建PAST::Block
节点,我们添加了一些额外的解析操作。让我们看看它们。
rule TOP { {*} #= open <statement>* [ $ || <.panic: syntax error> ] {*} #= close }
我们现在有两个用于 TOP
的解析操作,它们通过一个额外的键参数进行区分。第一个解析操作在任何输入被解析之前执行,这特别适合你可能需要的任何初始化操作。第二个操作(已经存在)在整个输入字符串被解析之后执行。现在我们可以创建一个 PAST::Block
节点,在任何语句被解析之前,这样当我们需要当前块时,它就在那里(在某个地方,稍后我们会看到确切的位置)。让我们看看 TOP
的解析操作。
method TOP($/, $key) { our $?BLOCK; our @?BLOCK; if $key eq 'open' { $?BLOCK := PAST::Block.new( :blocktype('declaration'), :node($/) ); @?BLOCK.unshift($?BLOCK); } else { # key is 'close' my $past := @?BLOCK.shift(); for $<statement> { $past.push( $( $_ ) ); } make $past; } }
让我们看看这里发生了什么。当解析操作第一次被调用时(当 $key
等于 "open"
时),一个新的 PAST::Block
节点被创建并分配给一个看起来很奇怪的变量(如果你不了解 Perl,像我一样。哦,等等,这是 Perl。没关系..)叫做 $?BLOCK
。这个变量被声明为 "our
",这意味着它是一个 **包变量**。这意味着该变量由同一个包(或类)中的所有方法共享,同样重要的是,该变量在解析操作完成后仍然存在。有关 "our" 的更多语义,请参阅 Perl 6 规范。变量 $?BLOCK
保存当前块。之后,这个块被推入另一个看起来很奇怪的变量 @?BLOCK
。这个变量有一个 "@" 符号,这意味着它是一个数组。unshift
方法将其参数放在列表的前面。从某种意义上说,你可以认为这个列表的前面是栈的顶部。稍后我们会看到为什么需要这个栈。
@?BLOCK
变量也用 "our
" 声明,这意味着它也是包级别的。但是,当我们在这个变量上调用一个方法时,它应该已经被创建了;否则你就会在未定义的("Undef")变量上调用方法。因此,这个变量应该在解析开始之前被创建。我们可以在编译器的主程序 squaak.pir
中做到这一点。在这样做之前,让我们快速看一下 TOP
解析操作的 "else
" 部分,它在整个输入字符串被解析后执行。PAST::Block
节点从 @?BLOCK
中检索,这是有意义的,因为它是在方法的第一部分创建的,并且被推入 @?BLOCK
。现在这个节点可以用作 TOP
的最终结果对象。所以,现在我们已经看到了如何使用作用域栈,让我们看看它的实现。
我们将作用域栈实现为 ResizablePMCArray 对象。这是一个内置的 PMC 类型。但是,这个内置的 PMC 没有任何方法;在 PIR 中,它只能用作内置 shift 和 unshift 指令的操作数。为了允许我们将其写为方法调用,我们创建了 ResizablePMCArray 的一个新的子类。下面的代码创建了新的类并定义了我们需要的的方法。
1 .namespace 2 .sub 'initlist' :anon :init :load 3 subclass $P0, 'ResizablePMCArray', 'List' 4 new $P1, 'List' 5 set_hll_global ['Squaak';'Grammar';'Actions'], '@?BLOCK', $P1 6 .end 7 .namespace ['List'] 8 .sub 'unshift' :method 9 .param pmc obj 10 unshift self, obj 11 .end 12 .sub 'shift' :method 13 shift $P0, self 14 .return ($P0) 15 .end
好吧,这就是你需要为 Squaak 编译器编写的一小部分 PIR 代码(还有一些用于内置子例程,稍后会详细介绍)。让我们更详细地讨论一下这段代码片段(如果你了解 PIR,你可以跳过这一节)。第 1 行将命名空间重置为 Parrot 的根命名空间,以便子 'initlist'
被存储在该命名空间中。第 2-6 行定义的子 'initlist'
有一些标志::anon
表示子没有按名称存储在命名空间中,这意味着它不能按名称查找。:init
标志表示子在主程序("main" 子)执行之前执行。:load
标志确保如果此文件被另一个文件通过 load_bytecode
指令编译和加载,则子被执行。如果你不理解,不用担心。现在你可以忘记它。无论如何,我们确信当我们需要它时,有一个 List 类,因为类创建是在运行实际编译器代码之前完成的。
第 3 行创建一个 ResizablePMCArray 的新子类,名为 "List"。这将产生一个新的类对象,它被保留在寄存器 $P0
中,但之后不再使用。第 4 行创建一个新的 List 对象,并将其存储在寄存器 $P1
中。第 5 行将这个 List 对象存储在 Actions 类的命名空间中,名为 "@?BLOCK
"(这个名字现在应该让你想起什么..)。多个键字符串之间的分号表示嵌套命名空间。因此,第 4 和第 5 行很重要,因为它们创建了 @?BLOCK
变量并将其存储在一个可以从 Actions 类中的操作方法访问的地方。
第 7-11 行定义了 unshift 方法,它是 "List" 命名空间中的一个方法。这意味着它可以作为一个 List 对象的方法被调用。由于子被标记为 :method
标志,子有一个隐式的第一个参数名为 "self",它指的是调用对象。unshift 方法在 self 上调用 Parrot 的 unshift 指令,将 obj 参数作为第二个操作数传递。因此,obj 被推入 self,即 List 对象本身。
最后,第 12-15 行定义了 "shift" 方法,它与 "unshift" 相反,删除第一个元素并将其返回给它的调用者。
现在,我们设置了必要的基础设施来存储当前作用域块,并且我们创建了一个充当作用域栈的数据结构,我们将在后面需要它。现在我们将回到 variable_declaration 的解析操作,因为我们还没有将声明的变量输入到当前块的符号表中。我们现在将看到如何做到这一点。首先,我们需要使当前块从 method variable_declaration 中访问。我们已经看到如何做到这一点,使用 "our" 关键字。我们在操作方法中的哪个位置将符号的名称输入到符号表中并不重要,但让我们在初始化部分之后,在最后执行。自然地,我们只会在符号不存在的情况下才输入它;相同的范围内的重复变量声明应该导致错误信息(使用匹配对象的 panic 方法)。要添加到 method variable_declaration 中的代码如下所示
method variable_declaration($/) { our $?BLOCK; # get the PAST node for identifier # set the scope and declaration flag # do the initialization stuff # cache the name into a local variable my $name := $past.name(); if $?BLOCK.symbol( $name ) { # symbol is already present $/.panic("Error: symbol " ~ $name ~ " was already defined.\n"); } else { $?BLOCK.symbol( $name, :scope('lexical') ); } make $past; }
有了这段代码,变量声明就被正确地处理了。但是,我们没有更新 identifier 的解析操作,它创建了 PAST::Var 节点并设置了它的作用域;目前所有标识符的作用域都设置为 'package'(这意味着它是一个全局变量)。由于我们在这一集中已经涵盖了很多内容,我们将把这留到下一集。在下一集中,我们还将介绍子例程,这是任何编程语言的另一个重要方面。希望以后还能见到你!
- 问题 1
在本集中,我们更改了 TOP 规则的操作方法;它现在被调用两次,一次在解析开始时,一次在解析结束时。block 规则定义了一个块,它是一系列语句,表示一个新的作用域。这条规则用在例如 if 语句(then 部分和 else 部分)、while 语句(循环体)和其他语句中。更新 block 的解析操作,使其被调用两次;一次是在解析语句之前,在此期间创建一个新的 PAST::Block 并将其存储到作用域栈中,一次是在解析语句之后,在此期间将这个 PAST 节点设置为结果对象。确保 $?BLOCK
始终指向当前块。为了正确地完成此练习,你应该理解 shift 和 unshift 方法的作用,以及为什么我们没有实现 push 和 pop 方法,它们在(作用域)栈的上下文中更合适。
- 解决方案
保持当前块最新:有时我们需要访问当前块的符号表。为了能够做到这一点,我们需要对 "当前块" 的引用。我们通过声明一个名为 "$?BLOCK
" 的包变量来做到这一点,用 "our" 声明(而不是用 "my")。这个变量将始终指向 "当前" 块。由于块可以嵌套,我们使用一个 "栈",在该栈上存储新创建的块。
每当创建一个新的块时,我们将它分配给 $?BLOCK
,并将它存储到栈中,以便下次创建一个新的块时,不会丢失 "旧的" 当前块。每当关闭一个作用域时,我们从栈中弹出当前块,并恢复之前的 "当前" 块。
为什么是 unshift/shift 而不是 push/pop?:当我们谈论栈时,谈论 "push" 和 "pop" 这样的栈操作似乎合乎逻辑。相反,我们使用 "unshift" 和 "shift" 操作。如果你不是 Perl 程序员(像我一样),这些名字可能没有意义。但是,它非常简单。与其将一个新对象推入栈的 "顶部",不如将对象推入这个栈。把它看成一辆旧式公交车,只有一个入口(在公交车的前面)。推入一个新人意味着在进入时占据第一个空座位,而推入一个新人意味着每个人都向后移动(移位)一个位置,以便新人可以坐在前排座位上。你可能会认为这不是很有效率(更多东西被移动了),但实际上并非如此(实际上:我认为(并且当然希望)shift 和 unshift 操作的实现比公交车隐喻更有效;我不知道它是如何实现的)。
那么为什么使用 unshift/shift 而不是 push/pop 呢?当恢复之前的“当前块”时,我们需要确切地知道它在哪里(什么位置)。能够始终引用“公交车上的第一个乘客”,而不是最后一个乘客,会很方便。我们知道如何引用第一个乘客(坐在第 0 号座位上(由 IT 人员设计));我们并不知道最后一个乘客的座位号:他/她可能坐在中间,也可能坐在后面。
我希望我在这里的意思很清楚... 否则,请查看代码,并尝试弄清楚发生了什么。
method block($/, $key) { our $?BLOCK; our @?BLOCK; if $key eq 'open' { $?BLOCK := PAST::Block.new( :blocktype('immediate'), :node($/) ); @?BLOCK.unshift($?BLOCK); } else { my $past := @?BLOCK.shift(); $?BLOCK := @?BLOCK[0]; for $<statement> { $past.push( $( $_ ) ); } make $past; } }