4

掃描

大口咬下去。任何值得做的事情都值得過度地做。

羅伯特·A·海萊因,《愛有餘時》

任何編譯器或直譯器的第一步都是掃描。掃描器將原始碼以字元序列的形式接收,並將其分組為一系列我們稱之為符記的區塊。這些是構成語言文法的有意義的「單字」和「標點符號」。

掃描對我們來說也是一個很好的起點,因為程式碼並不難幾乎只是一個自以為是的 switch 陳述式。這將幫助我們在處理稍後一些更有趣的材料之前熱身。在本章結束時,我們將擁有一個功能齊全、快速的掃描器,它可以接收任何 Lox 原始碼字串,並產生我們將在下一章中饋送到剖析器的符記。

4.1直譯器框架

由於這是我們的第一個真正的章節,在我們實際掃描一些程式碼之前,我們需要勾勒出直譯器 jlox 的基本形狀。一切都從 Java 中的一個類別開始。

lox/Lox.java
建立新檔案
package com.craftinginterpreters.lox;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

public class Lox {
  public static void main(String[] args) throws IOException {
    if (args.length > 1) {
      System.out.println("Usage: jlox [script]");
      System.exit(64); 
    } else if (args.length == 1) {
      runFile(args[0]);
    } else {
      runPrompt();
    }
  }
}
lox/Lox.java,建立新檔案

將其放入文字檔案中,然後去設定你的 IDE 或 Makefile 或任何東西。當你準備好時,我就在這裡。好了嗎?OK!

Lox 是一種腳本語言,這意味著它可以直接從原始碼執行。我們的直譯器支援兩種執行程式碼的方式。如果你從命令列啟動 jlox 並給它一個檔案路徑,它會讀取該檔案並執行它。

lox/Lox.java
main() 之後新增
  private static void runFile(String path) throws IOException {
    byte[] bytes = Files.readAllBytes(Paths.get(path));
    run(new String(bytes, Charset.defaultCharset()));
  }
lox/Lox.java,在 main() 之後新增

如果你想與你的直譯器進行更密切的對話,你也可以互動式執行它。啟動不帶任何引數的 jlox,它會將你放入提示符,你可以在其中一次輸入和執行一行程式碼。

lox/Lox.java
runFile() 之後新增
  private static void runPrompt() throws IOException {
    InputStreamReader input = new InputStreamReader(System.in);
    BufferedReader reader = new BufferedReader(input);

    for (;;) { 
      System.out.print("> ");
      String line = reader.readLine();
      if (line == null) break;
      run(line);
    }
  }
lox/Lox.java,在 runFile() 之後新增

正如其名稱所暗示的那樣,readLine() 函式會從命令列讀取使用者的一行輸入並傳回結果。要關閉互動式命令列應用程式,你通常會輸入 Control-D。這樣做會向程式發出「檔案結尾」條件的訊號。當這種情況發生時,readLine() 會傳回 null,因此我們檢查它以結束迴圈。

提示符和檔案執行器都是這個核心函式的簡單包裝

lox/Lox.java
runPrompt() 之後新增
  private static void run(String source) {
    Scanner scanner = new Scanner(source);
    List<Token> tokens = scanner.scanTokens();

    // For now, just print the tokens.
    for (Token token : tokens) {
      System.out.println(token);
    }
  }
lox/Lox.java,在 runPrompt() 之後新增

它還不是超級有用,因為我們還沒有編寫直譯器,但慢慢來,你知道嗎?現在,它會列印出我們即將推出的掃描器將發出的符記,以便我們可以看到我們是否正在取得進展。

4.1.1錯誤處理

在我們設定事情時,另一個關鍵的基礎架構是錯誤處理。教科書有時會忽略這一點,因為它更多是一個實際問題,而不是一個正式的電腦科學問題。但是,如果你關心製作一種實際可用的語言,那麼優雅地處理錯誤至關重要。

我們的語言為處理錯誤提供的工具構成了其使用者介面的很大一部分。當使用者的程式碼正常運作時,他們根本不會考慮我們的語言他們的腦海裡都在想他們的程式。通常只有當事情出錯時,他們才會注意到我們的實作。

這種情況發生時,我們有責任向使用者提供他們需要的所有資訊,以了解出了什麼問題,並溫和地引導他們回到他們想要去的地方。要做到這一點,就意味著在整個直譯器的實作過程中思考錯誤處理,現在就開始。

lox/Lox.java
run() 之後新增
  static void error(int line, String message) {
    report(line, "", message);
  }

  private static void report(int line, String where,
                             String message) {
    System.err.println(
        "[line " + line + "] Error" + where + ": " + message);
    hadError = true;
  }
lox/Lox.java,在 run() 之後新增

這個 error() 函式及其 report() 輔助函式會告訴使用者在給定行發生了一些語法錯誤。這真的是能夠聲稱你甚至錯誤報告的最低限度。想像一下,如果你不小心在某個函式呼叫中留下一個懸空逗號,直譯器會列印出

Error: Unexpected "," somewhere in your code. Good luck finding it!

這不是很有幫助。我們至少需要將他們指向正確的行。更好的情況是,標示開始和結束的列,以便他們知道該行中的哪個位置。比更好的情況是,顯示使用者有問題的行,例如

Error: Unexpected "," in argument list.

    15 | function(first, second,);
                               ^-- Here.

我很樂意在這本書中實作類似的東西,但老實說,這是一堆骯髒的字串操作程式碼。對使用者非常有用,但在書中閱讀不是很有趣,而且技術上也不是很有趣。所以我們只會堅持使用行號。在你自己的直譯器中,請按照我說的去做,而不是我做的。

我們將此錯誤報告函式放在主 Lox 類別中的主要原因是該 hadError 欄位。它在這裡定義

public class Lox {
lox/Lox.java
在類別 Lox
  static boolean hadError = false;
lox/Lox.java,在類別 Lox

我們將使用它來確保我們不會嘗試執行有已知錯誤的程式碼。此外,它讓我們像一個好的命令列公民一樣以非零結束代碼結束。

    run(new String(bytes, Charset.defaultCharset()));
lox/Lox.java
runFile() 中
    // Indicate an error in the exit code.
    if (hadError) System.exit(65);
  }
lox/Lox.java,在 runFile() 中

我們需要在互動式迴圈中重設這個旗標。如果使用者犯了錯誤,不應該殺死他們的整個會話。

      run(line);
lox/Lox.java
runPrompt() 中
      hadError = false;
    }
lox/Lox.java,在 runPrompt() 中

我將錯誤報告放在這裡而不是將其塞進掃描器和其他可能發生錯誤的階段的另一個原因是為了提醒你,將產生錯誤的程式碼與報告錯誤的程式碼分開是一種良好的工程實務。

前端的各個階段都會偵測到錯誤,但了解如何將其呈現給使用者並不是他們的工作。在功能齊全的語言實作中,你可能會有多種顯示錯誤的方式:在 stderr 上、在 IDE 的錯誤視窗中、記錄到檔案等等。你不希望該程式碼遍布你的掃描器和剖析器。

理想情況下,我們應該有一個實際的抽象,某種類型的「ErrorReporter」介面,傳遞給掃描器和剖析器,以便我們可以切換不同的報告策略。對於我們這裡的簡單直譯器,我沒有這樣做,但我至少將錯誤報告的程式碼移到了不同的類別中。

在進行了一些基本的錯誤處理後,我們的應用程式 shell 已經準備就緒。一旦我們有一個帶有 scanTokens() 方法的掃描器類別,我們就可以開始執行它了。在我們開始之前,讓我們更精確地了解符記是什麼。

4.2詞素與符記

這是一行 Lox 程式碼

var language = "lox";

在這裡,var 是宣告變數的關鍵字。這個三個字元的序列「v-a-r」表示某件事。但是,如果我們從 language 的中間提取三個字母,例如「g-u-a」,它們本身沒有任何意義。

這就是詞法分析的內容。我們的工作是掃描字元列表,並將它們分組為仍然代表某事物的最小序列。每個字元區塊都稱為詞素。在該範例程式碼行中,詞素是

'var', 'language', '=', 'lox', ';'

詞素只是原始碼的原始子字串。但是,在將字元序列分組為詞素的過程中,我們也會偶然發現一些其他有用的資訊。當我們取得詞素並將其與其他資料捆綁在一起時,結果就是符記。它包含有用的東西,例如

4.2.1符記類型

關鍵字是語言文法形狀的一部分,因此剖析器通常有類似於「如果下一個符記是 while,則執行...」的程式碼。這意味著剖析器不僅想知道它是否具有某個識別字的詞素,而且還想知道它是否具有保留字,以及它是哪個關鍵字。

剖析器可以透過比較字串來從原始詞素中分類符記,但這很慢而且有點醜陋。相反,在我們辨識詞素的那一點,我們也會記住它代表哪種詞素。我們為每個關鍵字、運算子、標點符號和常值類型使用不同的類型。

lox/TokenType.java
建立新檔案
package com.craftinginterpreters.lox;

enum TokenType {
  // Single-character tokens.
  LEFT_PAREN, RIGHT_PAREN, LEFT_BRACE, RIGHT_BRACE,
  COMMA, DOT, MINUS, PLUS, SEMICOLON, SLASH, STAR,

  // One or two character tokens.
  BANG, BANG_EQUAL,
  EQUAL, EQUAL_EQUAL,
  GREATER, GREATER_EQUAL,
  LESS, LESS_EQUAL,

  // Literals.
  IDENTIFIER, STRING, NUMBER,

  // Keywords.
  AND, CLASS, ELSE, FALSE, FUN, FOR, IF, NIL, OR,
  PRINT, RETURN, SUPER, THIS, TRUE, VAR, WHILE,

  EOF
}
lox/TokenType.java,建立新檔案

4.2.2常值

存在常值(數字和字串等)的詞素。由於掃描器必須走訪常值中的每個字元才能正確識別它,因此它還可以將值的文字表示形式轉換為稍後將由直譯器使用的實際執行時間物件。

4.2.3位置資訊

當我傳播關於錯誤處理的福音時,我們看到我們需要告訴使用者錯誤發生在哪裡。追蹤從這裡開始。在我們簡單的直譯器中,我們只記錄符記出現在哪一行,但更複雜的實作也包含列和長度。

我們將所有這些資料包裝在一個類別中。

lox/Token.java
建立新檔案
package com.craftinginterpreters.lox;

class Token {
  final TokenType type;
  final String lexeme;
  final Object literal;
  final int line; 

  Token(TokenType type, String lexeme, Object literal, int line) {
    this.type = type;
    this.lexeme = lexeme;
    this.literal = literal;
    this.line = line;
  }

  public String toString() {
    return type + " " + lexeme + " " + literal;
  }
}
lox/Token.java,建立新檔案

現在我們有一個具有足夠結構的物件,可用於直譯器的所有後續階段。

4 . 3正規語言與正規表示式

既然我們知道我們要產生什麼,那就讓我們來產生它吧。掃描器的核心是一個迴圈。從原始碼的第一個字元開始,掃描器會找出字元屬於哪個詞素,並消耗該字元以及屬於該詞素的任何後續字元。當它到達該詞素的末尾時,它會發出一個詞法單元。

然後它會迴圈回到並再次執行,從原始碼中緊接的下一個字元開始。它會不斷地執行此操作,消耗字元,並且偶爾「排出」詞法單元,直到到達輸入的末尾。

An alligator eating characters and, well, you don't want to know.

迴圈中我們查看一些字元以找出它「符合」哪種詞素的部分,可能聽起來很熟悉。如果您了解正規表示式,您可能會考慮為每種詞素定義一個正規表示式,並使用它們來比對字元。例如,Lox 對於識別符號(變數名稱等等)具有與 C 相同的規則。這個正規表示式符合一個

[a-zA-Z_][a-zA-Z_0-9]*

如果您想到了正規表示式,您的直覺很深刻。決定特定語言如何將字元分組為詞素的規則稱為其詞法文法。在 Lox 中,就像在大多數程式語言中一樣,該文法的規則非常簡單,足以將該語言歸類為正規語言。這與正規表示式中的「正規」相同。

如果您願意,您可以非常精確地使用正規表示式來識別 Lox 的所有不同詞素,並且有大量的有趣理論解釋為什麼會這樣以及這意味著什麼。諸如 LexFlex 之類的工具專門設計來讓您這樣做向它們拋擲一些正規表示式,它們會給您一個完整的掃描器回報

由於我們的目標是了解掃描器如何運作,我們不會委派這項任務。我們是關於手工製品的。

4 . 4掃描器類別

事不宜遲,讓我們自己建立一個掃描器。

lox/Scanner.java
建立新檔案
package com.craftinginterpreters.lox;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static com.craftinginterpreters.lox.TokenType.*; 

class Scanner {
  private final String source;
  private final List<Token> tokens = new ArrayList<>();

  Scanner(String source) {
    this.source = source;
  }
}
lox/Scanner.java,建立新檔案

我們將原始碼儲存為一個簡單的字串,並且我們有一個準備好填寫將要產生的詞法單元的列表。執行此操作的上述迴圈如下所示

lox/Scanner.java
Scanner() 之後新增
  List<Token> scanTokens() {
    while (!isAtEnd()) {
      // We are at the beginning of the next lexeme.
      start = current;
      scanToken();
    }

    tokens.add(new Token(EOF, "", null, line));
    return tokens;
  }
lox/Scanner.java,在 Scanner() 之後新增

掃描器會逐步掃描原始碼,直到耗盡字元為止,然後新增一個最終的「檔案結尾」詞法單元。這不是嚴格需要的,但它會使我們的剖析器更簡潔一些。

此迴圈依賴於幾個欄位來追蹤掃描器在原始碼中的位置。

  private final List<Token> tokens = new ArrayList<>();
lox/Scanner.java
在類別 Scanner
  private int start = 0;
  private int current = 0;
  private int line = 1;
  Scanner(String source) {
lox/Scanner.java,在類別 Scanner

startcurrent 欄位是索引到字串中的偏移量。start 欄位指向正在掃描的詞素中的第一個字元,而 current 指向目前正在考慮的字元。line 欄位追蹤 current 所在的原始碼行,以便我們可以產生知道其位置的詞法單元。

然後我們有一個小輔助函數,告訴我們是否已經消耗了所有字元。

lox/Scanner.java
scanTokens() 之後新增
  private boolean isAtEnd() {
    return current >= source.length();
  }
lox/Scanner.java,在 scanTokens() 之後新增

4 . 5識別詞素

在迴圈的每個回合中,我們都會掃描一個詞法單元。這是掃描器的真正核心。我們先從簡單的開始。想像一下,如果每個詞素都只有一個字元長。您只需要消耗下一個字元並為其選擇詞法單元類型即可。有幾個詞素在 Lox 中確實只有一個字元長,所以我們先從這些開始。

lox/Scanner.java
scanTokens() 之後新增
  private void scanToken() {
    char c = advance();
    switch (c) {
      case '(': addToken(LEFT_PAREN); break;
      case ')': addToken(RIGHT_PAREN); break;
      case '{': addToken(LEFT_BRACE); break;
      case '}': addToken(RIGHT_BRACE); break;
      case ',': addToken(COMMA); break;
      case '.': addToken(DOT); break;
      case '-': addToken(MINUS); break;
      case '+': addToken(PLUS); break;
      case ';': addToken(SEMICOLON); break;
      case '*': addToken(STAR); break; 
    }
  }
lox/Scanner.java,在 scanTokens() 之後新增

同樣,我們需要幾個輔助方法。

lox/Scanner.java
isAtEnd() 之後新增
  private char advance() {
    return source.charAt(current++);
  }

  private void addToken(TokenType type) {
    addToken(type, null);
  }

  private void addToken(TokenType type, Object literal) {
    String text = source.substring(start, current);
    tokens.add(new Token(type, text, literal, line));
  }
lox/Scanner.java,在 isAtEnd() 之後新增

advance() 方法會消耗原始檔中的下一個字元並傳回它。advance() 用於輸入,而 addToken() 用於輸出。它會抓取目前詞素的文字,並為其建立一個新的詞法單元。我們稍後將使用另一個多載來處理具有文字值的詞法單元。

4 . 5 . 1詞法錯誤

在我們深入了解之前,讓我們先花點時間思考一下詞法層級的錯誤。如果使用者向我們的直譯器拋出一個包含 Lox 不使用的一些字元(例如 @#^)的原始檔會發生什麼事?現在,這些字元會被靜默地捨棄。Lox 語言沒有使用它們,但這並不表示直譯器可以假裝它們不存在。相反地,我們會報告錯誤。

      case '*': addToken(STAR); break; 
lox/Scanner.java
scanToken() 中
      default:
        Lox.error(line, "Unexpected character.");
        break;
    }
lox/Scanner.java,在 scanToken() 中

請注意,錯誤字元仍然會被先前呼叫 advance()消耗。這很重要,這樣我們才不會陷入無限迴圈。

另請注意,我們 持續掃描。程式中稍後可能會有其他錯誤。如果我們一次偵測到盡可能多的錯誤,使用者可以獲得更好的體驗。否則,他們會看到一個微小的錯誤並修正它,然後下一個錯誤就會出現,依此類推。語法錯誤打地鼠一點都不有趣。

(別擔心。由於設定了 hadError,我們永遠不會嘗試執行任何程式碼,即使我們繼續掃描其餘程式碼。)

4 . 5 . 2運算子

我們已經讓單一字元詞素運作,但這並未涵蓋 Lox 的所有運算子。! 呢?它是一個單一字元,對吧?有時是,但如果緊接的下一個字元是等號,那麼我們應該改為建立 != 詞素。請注意,!=不是兩個獨立的運算子。您無法在 Lox 中撰寫 ! = 並使其像不相等運算子一樣運作。這就是為什麼我們需要將 != 掃描為單一詞素的原因。同樣地,<>= 後面都可以跟著 = 來建立其他相等和比較運算子。

對於所有這些,我們都需要查看第二個字元。

      case '*': addToken(STAR); break; 
lox/Scanner.java
scanToken() 中
      case '!':
        addToken(match('=') ? BANG_EQUAL : BANG);
        break;
      case '=':
        addToken(match('=') ? EQUAL_EQUAL : EQUAL);
        break;
      case '<':
        addToken(match('=') ? LESS_EQUAL : LESS);
        break;
      case '>':
        addToken(match('=') ? GREATER_EQUAL : GREATER);
        break;
      default:
lox/Scanner.java,在 scanToken() 中

這些情況會使用這個新方法

lox/Scanner.java
scanToken() 之後新增
  private boolean match(char expected) {
    if (isAtEnd()) return false;
    if (source.charAt(current) != expected) return false;

    current++;
    return true;
  }
lox/Scanner.java,在 scanToken() 之後新增

它就像是條件式 advance()。只有在它符合我們要尋找的字元時,我們才會消耗目前字元。

使用 match(),我們分兩個階段識別這些詞素。當我們到達例如 ! 時,我們會跳到其 switch 情況。這表示我們知道詞素 ! 開頭。然後,我們會查看下一個字元以判斷我們是否在 != 上,或僅僅是 !

4 . 6較長的詞素

我們仍然缺少一個運算子:用於除法的 /。這個字元需要一些特殊處理,因為註解也以斜線開頭。

        break;
lox/Scanner.java
scanToken() 中
      case '/':
        if (match('/')) {
          // A comment goes until the end of the line.
          while (peek() != '\n' && !isAtEnd()) advance();
        } else {
          addToken(SLASH);
        }
        break;
      default:
lox/Scanner.java,在 scanToken() 中

這與其他雙字元運算子類似,只是當我們找到第二個 / 時,我們不會立即結束詞法單元。相反地,我們會持續消耗字元,直到到達行的結尾為止。

這是我們處理較長詞素的總體策略。在我們偵測到一個詞素的開頭之後,我們會轉移到一些詞素特定的程式碼,這些程式碼會持續消耗字元,直到看到結尾為止。

我們有另一個輔助函數

lox/Scanner.java
match() 之後新增
  private char peek() {
    if (isAtEnd()) return '\0';
    return source.charAt(current);
  }
lox/Scanner.java,在 match() 之後新增

它有點像 advance(),但不消耗字元。這稱為 前瞻。由於它只查看目前未消耗的字元,因此我們有一個字元的前瞻。通常,這個數字越小,掃描器執行的速度越快。詞法文法的規則會決定我們需要多少前瞻。幸運的是,大多數廣泛使用的語言只會提前查看一兩個字元。

註解是詞素,但它們沒有意義,而且剖析器不想處理它們。因此,當我們到達註解的結尾時,我們不會呼叫 addToken()。當我們迴圈回到並開始下一個詞素時,start 會被重設,而註解的詞素會在煙霧中消失。

在我們進行的同時,現在是跳過其他無意義字元的好時機:換行符號和空白字元。

        break;
lox/Scanner.java
scanToken() 中
      case ' ':
      case '\r':
      case '\t':
        // Ignore whitespace.
        break;

      case '\n':
        line++;
        break;
      default:
        Lox.error(line, "Unexpected character.");
lox/Scanner.java,在 scanToken() 中

當遇到空白字元時,我們只是回到掃描迴圈的開頭。這會在空白字元之後開始一個新的詞素。對於換行符號,我們會執行相同的操作,但我們也會遞增行計數器。(這就是為什麼我們使用 peek() 來尋找註解結尾的換行符號,而不是 match() 的原因。我們希望該換行符號能將我們帶到這裡,以便我們可以更新 line。)

我們的掃描器變得越來越聰明。它可以處理相當自由形式的程式碼,例如

// this is a comment
(( )){} // grouping stuff
!*+-/=<> <= == // operators

4 . 6 . 1字串字面值

既然我們已經對較長的詞素感到滿意,我們就可以處理字面值了。我們先處理字串,因為它們總是從特定字元 " 開始。

        break;
lox/Scanner.java
scanToken() 中
      case '"': string(); break;
      default:
lox/Scanner.java,在 scanToken() 中

這會呼叫

lox/Scanner.java
scanToken() 之後新增
  private void string() {
    while (peek() != '"' && !isAtEnd()) {
      if (peek() == '\n') line++;
      advance();
    }

    if (isAtEnd()) {
      Lox.error(line, "Unterminated string.");
      return;
    }

    // The closing ".
    advance();

    // Trim the surrounding quotes.
    String value = source.substring(start + 1, current - 1);
    addToken(STRING, value);
  }
lox/Scanner.java,在 scanToken() 之後新增

就像註解一樣,我們會消耗字元,直到我們遇到結束字串的 "。我們也會優雅地處理在字串關閉之前耗盡輸入的情況,並報告該錯誤。

由於沒有特定原因,Lox 支援多行字串。這有好有壞,但禁止它們比允許它們稍微複雜一些,所以我讓它們保留。這表示當我們在字串內遇到換行符號時,也需要更新 line

最後,最後一個有趣的部分是,當我們建立詞法單元時,我們也會產生實際的字串,稍後會由直譯器使用。在這裡,該轉換只需要使用 substring() 來剝離周圍的引號。如果 Lox 支援跳脫序列(例如 \n),我們就會在這裡取消跳脫這些序列。

4 . 6 . 2數字字面值

在 Lox 中,所有數字在執行時都是浮點數,但同時支援整數和十進位數的字面值。數字字面值是一串數字,可選擇性地後面跟著一個 . 以及一或多個尾隨數字。

1234
12.34

我們不允許前導或尾隨小數點,因此這些都是無效的

.1234
1234.

我們可以輕鬆地支援前者,但我將其排除在外以保持簡單。如果我們想在數字上允許類似 123.sqrt() 的方法,後者會變得奇怪。

要識別數字語彙基元的開頭,我們會尋找任何數字。為每個十進位數字新增案例有點繁瑣,因此我們將其放入預設案例中。

      default:
lox/Scanner.java
scanToken() 中
取代 1 行
        if (isDigit(c)) {
          number();
        } else {
          Lox.error(line, "Unexpected character.");
        }
        break;
lox/Scanner.java, 在 scanToken() 中,取代 1 行

這依賴於這個小工具

lox/Scanner.java
peek() 之後新增
  private boolean isDigit(char c) {
    return c >= '0' && c <= '9';
  } 
lox/Scanner.java, 在 peek() 之後新增

一旦我們知道我們在數字中,我們會像處理字串一樣,分支到一個單獨的方法來消耗剩餘的字面值。

lox/Scanner.java
scanToken() 之後新增
  private void number() {
    while (isDigit(peek())) advance();

    // Look for a fractional part.
    if (peek() == '.' && isDigit(peekNext())) {
      // Consume the "."
      advance();

      while (isDigit(peek())) advance();
    }

    addToken(NUMBER,
        Double.parseDouble(source.substring(start, current)));
  }
lox/Scanner.java,在 scanToken() 之後新增

我們消耗找到的整數部分字面值中盡可能多的數字。然後,我們尋找一個小數部分,它是一個小數點 (.) 後面跟著至少一個數字。如果我們確實有小數部分,同樣地,我們會消耗我們能找到的盡可能多的數字。

查看小數點後面需要提前查看第二個字元,因為我們不希望在確定後面有數字之前消耗 .。因此我們新增

lox/Scanner.java
peek() 之後新增
  private char peekNext() {
    if (current + 1 >= source.length()) return '\0';
    return source.charAt(current + 1);
  } 
lox/Scanner.java, 在 peek() 之後新增

最後,我們將語彙基元轉換為其數值。我們的直譯器使用 Java 的 Double 型別來表示數字,因此我們產生該型別的值。我們使用 Java 自己的解析方法將語彙基元轉換為真正的 Java double。我們可以自己實作,但老實說,除非你正試圖為即將到來的程式設計面試而臨時抱佛腳,否則不值得你花時間。

剩餘的字面值是布林值和 nil,但我們將它們作為關鍵字處理,這將我們帶到 . . . 

4 . 7保留字和識別符號

我們的掃描器幾乎完成了。要實作的詞法語法中剩下的部分只有識別符號及其近親,保留字。你可能會認為我們可以像處理 <= 等多字元運算子一樣匹配 or 等關鍵字。

case 'o':
  if (match('r')) {
    addToken(OR);
  }
  break;

考慮一下,如果使用者將變數命名為 orchid 會發生什麼。掃描器會看到前兩個字母 or,並立即發出 or 關鍵字符號。這讓我們了解一個重要的原則,稱為 最大啃食。當兩個詞法語法規則都可以匹配掃描器正在查看的一段程式碼時,匹配最多字元的那個規則獲勝

該規則指出,如果我們可以將 orchid 匹配為識別符號,並將 or 匹配為關鍵字,則前者獲勝。這也是為什麼我們之前默默地假設 <= 應該被掃描為單個 <= 符號,而不是 < 後面跟著 =

最大啃食表示我們無法輕易偵測到保留字,直到我們到達可能是識別符號的結尾。畢竟,保留字一個識別符號,它只是語言為了自己使用而聲明的一個。這就是術語保留字的由來。

因此,我們首先假設任何以字母或底線開頭的語彙基元都是識別符號。

      default:
        if (isDigit(c)) {
          number();
lox/Scanner.java
scanToken() 中
        } else if (isAlpha(c)) {
          identifier();
        } else {
          Lox.error(line, "Unexpected character.");
        }
lox/Scanner.java,在 scanToken() 中

其餘的程式碼在這裡

lox/Scanner.java
scanToken() 之後新增
  private void identifier() {
    while (isAlphaNumeric(peek())) advance();

    addToken(IDENTIFIER);
  }
lox/Scanner.java,在 scanToken() 之後新增

我們根據這些輔助函式來定義它

lox/Scanner.java
peekNext() 之後新增
  private boolean isAlpha(char c) {
    return (c >= 'a' && c <= 'z') ||
           (c >= 'A' && c <= 'Z') ||
            c == '_';
  }

  private boolean isAlphaNumeric(char c) {
    return isAlpha(c) || isDigit(c);
  }
lox/Scanner.java, 在 peekNext() 之後新增

這使得識別符號可以正常運作。為了處理關鍵字,我們會查看識別符號的語彙基元是否為保留字之一。如果是,我們使用該關鍵字特定的符號型別。我們在地圖中定義保留字的集合。

lox/Scanner.java
在類別 Scanner
  private static final Map<String, TokenType> keywords;

  static {
    keywords = new HashMap<>();
    keywords.put("and",    AND);
    keywords.put("class",  CLASS);
    keywords.put("else",   ELSE);
    keywords.put("false",  FALSE);
    keywords.put("for",    FOR);
    keywords.put("fun",    FUN);
    keywords.put("if",     IF);
    keywords.put("nil",    NIL);
    keywords.put("or",     OR);
    keywords.put("print",  PRINT);
    keywords.put("return", RETURN);
    keywords.put("super",  SUPER);
    keywords.put("this",   THIS);
    keywords.put("true",   TRUE);
    keywords.put("var",    VAR);
    keywords.put("while",  WHILE);
  }
lox/Scanner.java,在類別 Scanner

然後,在我們掃描識別符號後,我們會檢查它是否與地圖中的任何內容匹配。

    while (isAlphaNumeric(peek())) advance();

lox/Scanner.java
identifier() 中
取代 1 行
    String text = source.substring(start, current);
    TokenType type = keywords.get(text);
    if (type == null) type = IDENTIFIER;
    addToken(type);
  }
lox/Scanner.java, 在 identifier() 中,取代 1 行

如果是,我們使用該關鍵字的符號型別。否則,它是一個常規的、使用者定義的識別符號。

有了這個,我們現在就有了一個適用於整個 Lox 詞法語法的完整掃描器。啟動 REPL 並輸入一些有效和無效的程式碼。它是否產生你預期的符號?嘗試想出一些有趣的邊緣情況,看看它是否能按應有的方式處理它們。

挑戰

  1. Python 和 Haskell 的詞法語法不是正規的。那是什麼意思,為什麼它們不是?

  2. 除了分隔符號之外區分 print fooprintfoo空格在大多數語言中沒有太多用途。然而,在幾個陰暗的角落,空格確實會影響程式碼在 CoffeeScript、Ruby 和 C 前置處理器中的解析方式。在每種語言中,空格在哪裡以及有什麼影響?

  3. 我們這裡的掃描器與大多數掃描器一樣,會捨棄註解和空白,因為解析器不需要這些。為什麼你可能想編寫一個捨棄這些的掃描器?它有什麼用?

  4. 為 Lox 的掃描器新增對 C 風格 /* ... */ 區塊註解的支援。請確保處理它們中的換行符號。考慮允許它們巢狀。新增對巢狀的支援比你預期的工作量更多嗎?為什麼?

設計註解:隱式分號

如今的程式設計師在語言方面有很多選擇,並且對語法變得挑剔。他們希望他們的語言看起來簡潔而現代。幾乎每種新語言都會刮掉的一種語法苔蘚(而一些像 BASIC 這樣的古老語言從來沒有)是 ; 作為顯式的語句終止符。

相反地,他們將換行符號視為在適當情況下的語句終止符。「適當情況」部分是具有挑戰性的部分。雖然大多數語句都位於自己的行上,但有時你需要將單個語句分散在幾行上。那些穿插的換行符號不應被視為終止符。

大多數應忽略換行符號的明顯情況很容易偵測到,但有一些討厭的情況

  • 下一行的傳回值

    if (condition) return
    "value"
    

    「value」是傳回的值,還是我們有一個沒有值的 return 語句,後面跟著一個包含字串字面值的運算式語句?

  • 下一行的括號運算式

    func
    (parenthesized)
    

    這是呼叫 func(parenthesized),還是兩個運算式語句,一個用於 func,一個用於括號運算式?

  • 下一行的 -

    first
    -second
    

    這是 first - second中綴減法還是兩個運算式語句,一個用於 first,一個用於否定 second

在所有這些情況下,將換行符號視為分隔符號或不視為分隔符號都會產生有效的程式碼,但可能不是使用者想要的程式碼。在不同的語言中,有令人不安的各種規則用於決定哪些換行符號是分隔符號。以下是一些規則

  • Lua 完全忽略換行符號,但會仔細控制其語法,以便在大多數情況下完全不需要語句之間的分隔符號。這是完全合法的

    a = 1 b = 2
    

    Lua 避免了 return 問題,方法是要求 return 語句是區塊中的最後一個語句。如果在關鍵字 end 之前,return 之後有值,則它必須用於 return。對於其他兩種情況,它們允許顯式的 ; 並期望使用者使用它。在實務上,這幾乎從未發生過,因為括號運算式或一元否定運算式語句沒有意義。

  • Go 在掃描器中處理換行符號。如果換行符號出現在已知可能結束語句的少數符號型別之一之後,則該換行符號被視為分號。否則,它會被忽略。Go 團隊提供了一個標準的程式碼格式化工具,gofmt,並且生態系統熱衷於使用它,這確保了慣用的風格化程式碼可以很好地與這個簡單的規則一起運作。

  • Python 將所有換行符號視為重要,除非在一行的結尾使用顯式的反斜線將其繼續到下一行。然而,括號對(()[]{})內部的任何位置的換行符號都會被忽略。慣用的風格強烈偏好後者。

    此規則對 Python 非常有效,因為它是一種高度面向語句的語言。特別是,Python 的語法確保語句永遠不會出現在運算式中。C 也執行相同的操作,但許多其他具有「lambda」或函式字面值語法的語言則沒有。

    JavaScript 中的一個範例

    console.log(function() {
      statement();
    });
    

    在這裡,console.log() 運算式包含一個函式字面值,而該函式字面值又包含語句 statement();

    如果可以在巢狀於括號中時回到換行符號應該變得有意義的語句中,Python 將需要一組不同的規則來隱式地連接行。

  • JavaScript 的「自動分號插入」規則是真正奇怪的一個。其他程式語言會假設大多數的換行符號意義,只有少數應該在多行陳述式中被忽略,而 JavaScript 則假設相反。它將你所有的換行符號視為無意義的空白,除非它遇到解析錯誤。如果遇到解析錯誤,它會回頭嘗試將先前的換行符號轉換成分號,以使語法上有效。

    如果我詳細說明它甚至是如何運作的,更別提 JavaScript 的「解決方案」在各方面都是個糟糕的主意,這個設計筆記就會變成一場設計上的長篇大論。這是一團糟。JavaScript 是我所知道的唯一一種,許多風格指南要求每個陳述式後都加上明確的分號,即使該語言理論上允許你省略它們。

如果你正在設計一種新的程式語言,你幾乎肯定應該避免使用明確的陳述式終止符。程式設計師和其他人一樣是時尚的追隨者,而分號就像大寫關鍵字一樣過時了。只要確保你為你的語言的特定語法和慣用語選擇一組有意義的規則即可。而且不要像 JavaScript 那樣做。