跳轉到內容

鸚鵡虛擬機器/Squaak 教程/變數宣告和作用域

來自華夏公益教科書,開放的書籍,為開放的世界

第 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物件代表一個作用域。存在兩種blocktypeimmediatedeclaration。一個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;
        }
    } 
華夏公益教科書