扫描
大口大口地吃。任何值得做的事情都值得过度做。
罗伯特·A·海因莱因,《足够的时间去爱》
任何编译器或解释器中的第一步都是扫描。扫描器将原始源代码作为一系列字符输入,并将其分组为我们称为标记的一系列块。这些是有意义的“单词”和“标点符号”,它们构成了语言的语法。
扫描对我们来说也是一个很好的起点,因为代码并不难—几乎就是一个带有宏伟幻想的switch
语句。它将帮助我们在处理后面一些更有趣的内容之前热身。在本节结束时,我们将拥有一个功能齐全、快速的扫描器,它可以接收任何 Lox 源代码字符串并生成标记,这些标记将在下一节中提供给解析器。
4 . 1解释器框架
由于这是我们第一个真正的章节,在我们开始实际扫描代码之前,我们需要勾勒出解释器 jlox 的基本形状。一切从 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(); } } }
将它粘贴到一个文本文件中,然后去设置你的 IDE、Makefile 或任何东西。当你准备好了,我会在这里。好了吗?好的!
Lox 是一种脚本语言,这意味着它直接从源代码执行。我们的解释器支持两种运行代码的方式。如果你从命令行启动 jlox 并给它一个文件路径,它会读取该文件并执行它。
在 main() 之后添加
private static void runFile(String path) throws IOException { byte[] bytes = Files.readAllBytes(Paths.get(path)); run(new String(bytes, Charset.defaultCharset())); }
如果你想与你的解释器进行更亲密的对话,你也可以交互地运行它。在不带任何参数的情况下启动 jlox,它会把你放到一个提示符中,你可以在其中一次输入并执行一行代码。
在 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); } }
readLine()
函数,顾名思义,从命令行上的用户那里读取一行输入并返回结果。要终止交互式命令行应用程序,你通常会键入 Control-D。这样做会向程序发出“文件结束”信号。当这种情况发生时,readLine()
返回 null
,因此我们检查这一点以退出循环。
提示符和文件运行程序都是围绕这个核心函数的薄包装
在 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); } }
它现在还不是特别有用,因为我们还没有编写解释器,但是一步一步来,你知道的。现在,它会打印出我们即将到来的扫描器将发出的标记,这样我们就可以看到我们是否取得了进展。
4 . 1 . 1错误处理
在我们进行设置的时候,另一个关键的基础设施是错误处理。教科书有时会忽略这一点,因为它更多的是一个实践问题,而不是一个正式的计算机科学问题。但是如果你关心制作一种真正可用的语言,那么优雅地处理错误至关重要。
我们的语言为处理错误提供的工具构成了其用户界面的很大一部分。当用户的代码正常工作时,他们根本不会考虑我们的语言—他们的思维空间完全集中在他们的程序上。通常只有在出现问题时,他们才会注意到我们的实现。
当这种情况发生时,由我们来提供用户理解错误发生的原因所需的所有信息,并引导他们顺利地回到他们想要去的地方。要做到这一点,就意味着在整个解释器实现过程中都要考虑错误处理,从现在开始。
在 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; }
这个 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 中
static boolean hadError = false;
我们将使用它来确保我们不会尝试执行已知有错误的代码。此外,它允许我们以非零退出代码退出,就像一个良好的命令行公民应该做的那样。
run(new String(bytes, Charset.defaultCharset()));
在 runFile() 中
// Indicate an error in the exit code. if (hadError) System.exit(65);
}
我们需要在交互式循环中重置这个标志。如果用户犯了错误,不应该终止他们的整个会话。
run(line);
在 runPrompt() 中
hadError = false;
}
我将错误报告代码提取到这里,而不是塞进扫描器和其他可能发生错误的阶段的另一个原因是提醒你,将生成错误的代码与报告错误的代码分开是一种良好的工程实践。
前端的各个阶段会检测错误,但真正了解如何将错误呈现给用户并不是它们的职责。在一个功能齐全的语言实现中,你可能会有多种错误显示方式:在 stderr 上、在 IDE 的错误窗口中、记录到文件中等等。你不会希望这些代码散布在你的扫描器和解析器中。
理想情况下,我们应该有一个真正的抽象,某种“ErrorReporter” 接口,传递给扫描器和解析器,这样我们就可以交换不同的报告策略。对于我们这里简单的解释器来说,我没有这样做,但我至少将错误报告代码移到了另一个类中。
有了基本的错误处理,我们的应用程序外壳就准备好了。一旦我们有了带有 scanTokens()
方法的 Scanner 类,我们就可以开始运行它了。在我们开始之前,让我们更精确地了解标记是什么。
4 . 2词素和标记
这是一行 Lox 代码
var language = "lox";
这里,var
是声明变量的关键字。这三个字符的序列“v-a-r”代表着某种含义。但是,如果我们从language
的中间部分取出三个字母,比如“g-u-a”,那么这些字母本身没有任何意义。
这就是词法分析的目的。我们的工作是扫描字符列表并将它们分组在一起,形成最小的序列,这些序列仍然代表着某种含义。这些字符块中的每一个都称为词素。在上面那行代码示例中,词素是
词素只是源代码的原始子字符串。然而,在将字符序列分组为词素的过程中,我们还会发现一些其他有用的信息。当我们将词素与这些其他数据捆绑在一起时,结果就是一个标记。它包含有用的信息,例如
4 . 2 . 1标记类型
关键字是语言语法的一部分,因此解析器经常有类似“如果下一个标记是 while
,那么做 . . . ”这样的代码。这意味着解析器不仅想知道它是否拥有某个标识符的词素,还想知道它是否拥有一个保留字,以及它具体是哪个关键字。
解析器可以根据原始词素对标记进行分类,方法是比较字符串,但这种方法很慢,而且有点难看。相反,在我们识别词素时,我们还会记住它代表哪种类型的词素。我们为每个关键字、运算符、标点符号和文字类型都有一个不同的类型。
创建新文件
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 }
4 . 2 . 2文字值
存在用于文字值的词素—数字、字符串等等。由于扫描器必须遍历文字中的每个字符才能正确识别它,因此它还可以将该值的文本表示形式转换为稍后将被解释器使用的活动的运行时对象。
4 . 2 . 3位置信息
当我在之前谈论错误处理时,我们发现我们需要告诉用户错误发生在哪里。跟踪这一点从这里开始。在我们简单的解释器中,我们只记录标记出现在哪一行,但更复杂的实现也会包括列和长度。
我们将所有这些数据包装在一个类中。
创建新文件
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; } }
现在我们拥有了一个结构足够完善的对象,可以用于解释器的所有后续阶段。
4 . 3正则语言和表达式
现在我们知道要做什么,就让我们开始做吧。扫描器的核心是一个循环。从源代码的第一个字符开始,扫描器会找出该字符属于哪个词素,并将其以及该词素中包含的任何后续字符都消耗掉。当它到达该词素的末尾时,它会发出一个标记。
然后它循环回来,并再次从源代码中的下一个字符开始进行操作。它一直这样做,不断地消耗字符,偶尔还会排出标记,直到到达输入的末尾。
循环中我们查看少量字符以确定它“匹配”哪种词素的部分可能听起来很熟悉。如果你了解正则表达式,你可能会考虑为每种词素定义一个正则表达式并使用它们来匹配字符。例如,Lox 在标识符(变量名等)方面与 C 具有相同的规则。此正则表达式匹配一个
[a-zA-Z_][a-zA-Z_0-9]*
如果你确实想到了正则表达式,你的直觉很深。确定特定语言如何将字符分组到词素中的规则称为其词法语法。在 Lox 中,就像在大多数编程语言中一样,该语法的规则足够简单,以至于该语言可以被归类为正则语言。这与正则表达式中的“正则”相同。
如果你愿意,你可以非常精确地使用正则表达式来识别 Lox 的所有不同词素,并且有一堆有趣的理论解释了为什么这样做以及这意味着什么。像Lex或Flex这样的工具专门为此而设计—向它们抛出一堆正则表达式,它们就会为你提供一个完整的扫描器回来.
由于我们的目标是了解扫描器是如何工作的,我们不会将这项任务委托出去。我们是关于手工制作的商品。
4 . 4扫描器类
事不宜迟,让我们自己做一个扫描器。
创建新文件
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; } }
我们将原始源代码存储为一个简单的字符串,并且我们准备了一个列表来填充我们要生成的标记。前面提到的执行此操作的循环如下所示
添加到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; }
扫描器通过源代码工作,添加标记,直到它用完字符。然后,它附加一个最终的“文件结束”标记。这并不是绝对必要的,但它使我们的解析器更加简洁。
此循环依赖于几个字段来跟踪扫描器在源代码中的位置。
private final List<Token> tokens = new ArrayList<>();
在类Scanner中
private int start = 0; private int current = 0; private int line = 1;
Scanner(String source) {
start
和current
字段是索引到字符串的偏移量。start
字段指向正在扫描的词素的第一个字符,current
指向当前正在考虑的字符。line
字段跟踪current
位于哪个源代码行,以便我们可以生成知道自己位置的标记。
然后我们有一个小助手函数,它告诉我们是否已经消耗了所有字符。
添加到scanTokens()之后
private boolean isAtEnd() { return current >= source.length(); }
4 . 5识别词素
在循环的每次迭代中,我们扫描一个标记。这是扫描器的真正核心。我们将从简单的开始。想象一下,如果每个词素的长度都只有一个字符。你只需要消耗下一个字符并为它选择一个标记类型。在 Lox 中,有几个词素的长度确实只有一个字符,所以让我们从这些开始。
添加到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; } }
同样,我们需要几个辅助方法。
添加到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)); }
advance()
方法消耗源文件中的下一个字符并将其返回。advance()
用于输入,addToken()
用于输出。它获取当前词素的文本并为其创建一个新标记。我们很快将使用另一个重载来处理具有文字值的标记。
4 . 5 . 1词法错误
在我们走得太远之前,让我们花点时间考虑一下词法级别的错误。如果用户向我们的解释器抛出一个包含 Lox 不使用的某些字符(如@#^
)的源文件会发生什么?现在,这些字符被默默地丢弃。它们没有被 Lox 语言使用,但这并不意味着解释器可以假装它们不存在。相反,我们报告一个错误。
case '*': addToken(STAR); break;
在scanToken()中
default: Lox.error(line, "Unexpected character."); break;
}
请注意,错误字符仍然被之前的advance()
调用所消耗。这很重要,这样我们才不会陷入无限循环。
还要注意,我们继续扫描。程序中以后可能还会出现其他错误。如果我们能够一次检测到尽可能多的错误,就会给我们的用户带来更好的体验。否则,他们会看到一个很小的错误并修复它,然后下一个错误就会出现,等等。语法错误打地鼠游戏一点也不好玩。
(别担心。由于hadError
被设置,我们永远不会尝试执行任何代码,即使我们继续扫描代码的其余部分。)
4 . 5 . 2运算符
我们已经让单个字符的词素工作起来,但这并不能涵盖 Lox 的所有运算符。!
呢?它是一个单字符,对吧?有时是的,但如果紧随其后的下一个字符是等号,那么我们应该创建一个!=
词素。请注意,!
和=
不是两个独立的运算符。你不能在 Lox 中写! =
并让它像一个不等式运算符一样工作。这就是为什么我们需要扫描!=
作为一个词素的原因。同样,<
、>
和=
都可以跟随着=
来创建其他等式和比较运算符。
对于所有这些,我们需要查看第二个字符。
case '*': addToken(STAR); break;
在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:
这些情况使用这种新方法
添加到scanToken()之后
private boolean match(char expected) { if (isAtEnd()) return false; if (source.charAt(current) != expected) return false; current++; return true; }
它就像一个条件advance()
。只有当我们正在寻找的字符是当前字符时,我们才会消耗它。
使用match()
,我们通过两个阶段识别这些词素。当我们遇到例如!
时,我们会跳转到它的 switch case。这意味着我们知道词素以!
开头。然后我们查看下一个字符,以确定我们是在一个!=
上还是仅仅是一个!
上。
4 . 6更长的词素
我们仍然缺少一个运算符:/
用于除法。该字符需要一些特殊的处理,因为注释也以斜杠开头。
break;
在scanToken()中
case '/': if (match('/')) { // A comment goes until the end of the line. while (peek() != '\n' && !isAtEnd()) advance(); } else { addToken(SLASH); } break;
default:
这与其他两个字符的运算符类似,只是当我们找到第二个/
时,我们不会立即结束标记。相反,我们会一直消耗字符,直到到达行的末尾。
这是我们处理更长词素的一般策略。在我们检测到其中一个的开头后,我们会转向一些特定于词素的代码,这些代码会不断地消耗字符,直到它看到结尾。
我们还有一个助手
添加到match()之后
private char peek() { if (isAtEnd()) return '\0'; return source.charAt(current); }
它有点像advance()
,但不会消耗字符。这被称为前瞻。由于它只查看当前未消耗的字符,所以我们有一个字符的前瞻。一般来说,这个数字越小,扫描器运行速度越快。词法语法的规则决定了我们需要多少前瞻。幸运的是,大多数广泛使用的语言只向前窥视一两个字符。
注释是词素,但它们没有意义,解析器也不想处理它们。因此,当我们到达注释的末尾时,我们不会调用addToken()
。当我们循环回到开始下一个词素时,start
会被重置,注释的词素会在一阵烟雾中消失。
趁着这个机会,现在是跳过那些其他无意义的字符的好时机:换行符和空格。
break;
在scanToken()中
case ' ': case '\r': case '\t': // Ignore whitespace. break; case '\n': line++; break;
default: Lox.error(line, "Unexpected character.");
当遇到空格时,我们只需返回到扫描循环的开头。这将在空格字符之后开始一个新的词素。对于换行符,我们执行相同的操作,但我们也会增加行计数器。(这就是为什么我们使用peek()
来查找结束注释的换行符而不是match()
的原因。我们希望换行符能将我们带到这儿,这样我们就可以更新line
。)
我们的扫描器越来越聪明了。它可以处理相当自由格式的代码,例如
// this is a comment (( )){} // grouping stuff !*+-/=<> <= == // operators
4 . 6 . 1字符串字面量
现在我们对更长的词素感到满意,我们已经准备好解决字面量问题。我们将首先处理字符串,因为它们总是以特定的字符"
开头。
break;
在scanToken()中
case '"': string(); break;
default:
它调用
添加到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 支持多行字符串。这样做有优点也有缺点,但禁止它们比允许它们更复杂,所以我保留了它们。这意味着我们还需要在遇到字符串内部的换行符时更新line
。
最后,最后一个有趣的点是,当我们创建令牌时,我们也会生成实际的字符串 *value*,该字符串将在以后由解释器使用。在这里,该转换只需要一个 `substring()` 来剥离周围的引号。如果 Lox 支持像 `\n` 这样的转义序列,我们将在此处对它们进行转义。
4 . 6 . 2数字字面量
Lox 中的所有数字在运行时都是浮点数,但支持整数和十进制字面量。数字字面量是一系列 数字,后面可选地跟着一个 `.` 和一个或多个尾随数字。
1234 12.34
我们不允许前导或尾随小数点,所以这些都是无效的
.1234 1234.
我们可以很容易地支持前者,但我为了简单起见把它去掉了。如果我们将来想允许像 `123.sqrt()` 这样的数字方法,后者就会变得很奇怪。
为了识别数字词素的开始,我们查找任何数字。为每个小数数字添加情况有点繁琐,所以我们将把它放在默认情况中。
default:
在scanToken()中
替换 1 行
if (isDigit(c)) { number(); } else { Lox.error(line, "Unexpected character."); }
break;
这依赖于这个小工具
在 peek() 后添加
private boolean isDigit(char c) { return c >= '0' && c <= '9'; }
一旦我们知道我们在一个数字中,我们就转向一个单独的方法来消耗字面量的其余部分,就像我们对字符串所做的那样。
添加到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))); }
我们消耗尽可能多的数字来获取字面量的整数部分。然后我们查找小数部分,它是一个小数点 (`.`) 后面跟着至少一个数字。如果我们确实有小数部分,同样,我们消耗尽可能多的数字。
越过小数点需要第二个字符的前瞻,因为我们不想消耗 `.`,直到我们确定它后面有一个数字。所以我们添加
在 peek() 后添加
private char peekNext() { if (current + 1 >= source.length()) return '\0'; return source.charAt(current + 1); }
最后,我们将词素转换为它的数值。我们的解释器使用 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();
在scanToken()中
} else if (isAlpha(c)) { identifier();
} else { Lox.error(line, "Unexpected character."); }
其余代码位于此处
添加到scanToken()之后
private void identifier() { while (isAlphaNumeric(peek())) advance(); addToken(IDENTIFIER); }
我们用这些助手来定义它
在 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); }
这样就可以让标识符工作了。为了处理关键字,我们查看标识符的词素是否为保留字之一。如果是,我们使用特定于该关键字的令牌类型。我们在一个映射中定义保留字集。
在类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); }
然后,在我们扫描完标识符后,我们检查它是否与映射中的任何内容匹配。
while (isAlphaNumeric(peek())) advance();
在 identifier() 中
替换 1 行
String text = source.substring(start, current); TokenType type = keywords.get(text); if (type == null) type = IDENTIFIER; addToken(type);
}
如果是,我们使用该关键字的令牌类型。否则,它是一个普通的用户定义的标识符。
有了这个,我们现在就有了 Lox 整个词法语法的完整扫描器。启动 REPL 并输入一些有效和无效的代码。它是否生成了你预期的令牌?尝试提出一些有趣的边缘情况,并查看它是否按预期处理它们。
挑战
-
Python 和 Haskell 的词法语法不是 *正则* 的。这意味着什么,为什么它们不是正则的?
-
除了分隔令牌 —区分 `print foo` 和 `printfoo` —之外,大多数语言中空格的使用并不多。然而,在几个黑暗的角落里,空格 *确实* 会影响 CoffeeScript、Ruby 和 C 预处理器中代码的解析方式。它在这些语言中的哪些地方产生什么影响?
-
我们的扫描器,就像大多数扫描器一样,丢弃了注释和空白,因为解析器不需要它们。为什么要编写一个 *不* 丢弃它们的扫描器?它对什么有用?
-
为 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` 问题。如果在 `return` 关键字 `end` 之前存在一个值,那么它 *必须* 用于 `return`。对于另外两种情况,它们允许显式的 `;` 并期望用户使用它。在实践中,这几乎不会发生,因为在括号表达式或一元否定表达式语句中没有意义。
-
Go 在扫描器中处理换行符。如果在已知可能结束语句的一小部分令牌类型之后出现换行符,则换行符将被视为分号。否则将被忽略。Go 团队提供了一个规范的代码格式化程序 gofmt,并且生态系统热衷于使用它,这确保了惯用的格式代码能够很好地与这个简单的规则配合使用。
-
Python 将所有换行符视为有效,除非在行尾使用显式反斜杠来将它延续到下一行。但是,在括号对 ( `()`、`[]` 或 `{}` ) 内部的任何位置的换行符都将被忽略。惯用的风格强烈地偏爱后者。
这个规则对 Python 很有效,因为它是一种高度面向语句的语言。特别是,Python 的语法确保语句永远不会出现在表达式中。C 也这样做,但许多其他具有“lambda”或函数字面量语法的语言却不能。
JavaScript 中的一个例子
console.log(function() { statement(); });
这里,`console.log()` *表达式* 包含一个函数字面量,该字面量反过来包含 *语句* `statement();`。
如果能够返回到 语句 中,在那里换行符应该变得有意义,而仍然嵌套在括号中,那么 Python 需要一组不同的规则来隐式地连接行。
-
JavaScript 的“自动分号插入”规则是真正奇怪的一个。当其他语言假设大多数换行符 *都是* 有意义的,并且只有少数应该在多行语句中被忽略时,JS 假设相反。它将你的所有换行符视为无意义的空白,*除非* 它遇到解析错误。如果确实如此,它会回溯并尝试将前面的换行符变成分号,以获得语法上有效的代码。
如果我详细介绍它是如何工作的,更不用说 JavaScript 的“解决方案”有多少种糟糕的方式,这份设计笔记就会变成一篇设计长篇大论。它一团糟。JavaScript 是我所知道的唯一一种语言,其中许多风格指南要求在每个语句后明确使用分号,即使该语言理论上允许你省略它们。
如果你正在设计一种新语言,你几乎肯定应该避免使用显式的语句终止符。程序员就像其他人类一样是时尚的产物,分号就像全大写关键字一样过时了。只需确保你选择一套适合你的语言特定语法和习语的规则。不要像 JavaScript 那样做。