跳转到内容

编译器构造/词法分析

来自维基教科书,开放书籍,为开放世界

词法分析

词法分析是将单个字符流(通常排列成行)分析成一系列词法标记(标记化,例如组成源代码的“单词”和标点符号)的过程,以馈送到解析器。大致相当于将用自然语言(如英语)编写的普通文本拆分成单词和标点符号序列。词法分析通常使用 lex、flex 和 jflex 等工具完成。

严格来说,标记化可能由解析器处理。在实践中,我们倾向于使用标记化是因为它使解析器更简单,并且将其与用于源代码的字符编码解耦。

例如,给定输入字符串

     integer aardvark := 2, b;

标记器可能会输出以下标记

keyword integer
word aardvark
assignment operator
integer 2
comma
word b
semi_colon

什么是词法单元

在计算中,词法单元是一个分类的文本块,通常由称为词素的不可分割的字符组成。词法分析器最初读取词素并根据其功能对其进行分类,从而赋予其意义。这种赋予意义的过程称为标记化。词法单元可以看起来像任何东西:英语、乱码符号、任何东西;它只需要是结构化文本的有用部分。

考虑以下表格

词素 词法单元
sum IDENT
= ASSIGN_OP
3 NUMBER
+ ADD_OP
2 NUMBER
; SEMICOLON

词法单元通常由正则表达式定义,词法分析器(如 lex)可以理解这些正则表达式。词法分析器读取词素流并将其分类为词法单元。这称为“标记化”。如果词法分析器发现无效词法单元,它将报告错误。

标记化之后是解析。从那里,解释后的数据可以加载到数据结构中,用于一般用途、解释或编译。

考虑描述计算的文本:"46 - number_of(cows);”。这里的词素可能是:“46”、“-”、“number_of”、“(”、“cows”、“)” 和“;”。词法分析器将词素 4 和 6 表示为“数字”,将 - 表示为字符,并将“number_of” 表示为单独的词法单元。即使在某些语言(如 C)中,词素“;”也具有一些特殊的意义。

空格词素有时会被语法分析器忽略。词法单元不需要有效,才能被识别为词法单元。“cows” 对语言来说可能是无意义的,“number_of” 可能是无意义的。但它们仍然是词法单元,在这个例子中。

有限状态自动机

我们首先研究被称为有限状态自动机,简称 FSA 的东西。FSA 通常用于进行词法分析。

FSA 由状态、起始状态、接受状态和转换表组成。自动机读取输入符号并相应地移动状态。如果 FSA 在读取完输入字符串直到其结束之后到达接受状态,则该字符串被称为被接受被识别。一组被识别的字符串被称为 FSA 识别的语言。

假设这种语言中的每个字符串都以“ab”开头,并以一个或多个“c”结尾。使用 state 类,可以这样编写

State
  s0 = new State (),
  s1 = new State (),
  s2 = new State (),
  s3 = new State ();
		
s0.setTransition ('a', s1);
s1.setTransition ('b', s2);
s2.setTransition ('c', s3);
s3.setTransition ('c', s3);

State r[] = s0.move (inputString);
if (Arrays.asList (r).contains (s3))
  System.out.println ("the input string is accepted.");
else
  System.out.println ("the input string is not accepted.");

假设输入字符串为“abccc”。那么自动机移动方式为:s0 -> s1 -> s2 -> s3 -> s3 -> s3。

简单的手写扫描

在本节中,我们将为用 Object Pascal 实现的简单语言创建一个简单的面向对象的扫描器/词法分析器。考虑以下 EBNF

program     ::= { instruction }
instruction ::= "print" expr | "var" IDENTIFIER ":=" expr
expr        ::= simple-expr [ ("<" | "<>" ) simple-expr ]
simple-expr ::= factor { ( "+" | "-" ) factor }
factor      ::= INTEGER | IDENTIFIER

在哪里

INTEGER    = [0-9]+
IDENTIFIER = [A-Za-z_][A-Za-z0-9_]*

从上面的 EBNF 中,我们即将识别的词法单元是:IDENTIFIER、INTEGER、关键字“print”、关键字“var”、:=、+、-、<、<>、EOF 和未知词法单元。所选词法单元旨在简明扼要,并能够识别所有类型的词法单元的词素:确切的单个字符(+、-、<、EOF 和未知)、确切的多个字符(print、var、:= 和 <>)、无限多个(IDENTIFIER、INTEGER 和未知)、重叠前缀(< 和 <>)和重叠整体(IDENTIFIER 和关键字)。此处标识符和关键字不区分大小写。请注意,某些词素被归类为多种类型的词素。

词法单元类

TToken = class
protected
  FLine, FCol: LongWord;
public
  constructor Create(const ALine, ACol: LongWord);
  destructor Destroy; override;
end;

词法单元的基类只是一个包含其声明所在的行号和列号的对象。从这个基类,具有确切词素的词法单元(单个字符或多个字符)可以实现为直接子类。

TEOFToken = class(TToken)
end;

TPlusToken = class(TToken)
end;

TMinusToken = class(TToken)
end;

TAssignToken = class(TToken)
end;

TLessToken = class(TToken)
end;

TNotEqualToken = class(TToken)
end;

TPrintToken = class(TToken)
end;

TVarToken = class(TToken)
end;

TEOFToken = class(TToken)
end;

接下来,我们需要为具有可变词素的词法单元创建子类

TVariadicToken = class(TToken)
protected
  FLexeme: String;
public
  constructor Create(const ALine, ACol: LongWord; const ALexeme: String);
  destructor Destroy; override;
end;

与基词法单元类唯一的区别在于 lexeme 属性,因为它可能具有无限多种形式。从这里,我们创建子类,用于词素为无限多个的词法单元

TUnknownToken = class(TVariadicToken)
end;

TIdentifierToken = class(TVariadicToken)
end;

TIntegerToken = class(TVariadicToken)
end;

关于词法单元就这些了,让我们继续进行词法分析器。

词法分析器类

TLexer = class
private
  FLine: LongWord;
  FCol: LongWord;
  FStream: TStream;
  FCurrentToken: TToken;
  FLastChar: Char;
  function GetChar: Char;
public
  constructor Create(AStream: TStream);
  destructor Destroy; override;
  procedure NextToken;
  property CurrentToken: TToken read FCurrentToken;
end;

词法分析器由其在源代码中的位置(行和列)、表示源代码的流、当前(或最后识别/形成)的词法单元和最后读取的字符组成。为了封装源代码中的移动,从流中读取字符在 GetChar 方法中实现。尽管它的可能很简单,但正如我们很快将看到的那样,实现可能会很复杂。GetChar 被公共方法 NextToken 使用,NextToken 的作用是将词法分析器移动提前 1 个词法单元。让我们继续进行 GetChar 的实现

function TLexer.GetChar: Char;
begin
  try
    FLastChar := Chr(FStream.ReadByte);
    Inc(FCol);
    // Handles 3 types of line endings
    if FLastChar in [#13, #10] then begin
      FCol := 0;
      Inc(FLine);
      // CR met, probably CRLF
      if FLastChar = #13 then begin
        FLastChar := Chr(FStream.ReadByte);
        // Not CRLF, but CR only, move backward 1 position
        if FLastChar <> #10 then
          FStream.Seek(-1,soFromCurrent);
      end;
      // Always returns as LF for consistency
      FLastChar := #10;
    end;
  except // Exception? Returns EOF
    FLastChar := #26;
  end;
  GetChar := FLastChar;
end;

如前所述,GetChar 的工作并不像它的名字那么简单。首先,它必须从输入流中读取一个字符并增加词法分析器的列位置。然后它必须检查此字符是否为可能的换行符之一(我们的词法分析器能够处理 CR-、LF- 和 CRLF- 样式的换行符)。接下来是我们的词法分析器的核心,NextToken

procedure TLexer.NextToken;
var
  StartLine,StartCol: LongWord;

  function GetNumber: TIntegerToken;
  var
    NumLex: String;
  begin
    NumLex := '';
    repeat
      NumLex := NumLex + FLastChar;
      FLastChar := GetChar;
    until not (FLastChar in ['0' .. '9']);
    Result := TIntegerToken.Create(StartLine, StartCol, NumLex);
  end;
  
  function GetIdentifierOrKeyword: TVariadicToken;
  var
    IdLex: String;
  begin
    IdLex := '';
    repeat
      IdLex := IdLex + FLastChar;
      // This is how we handle case-insensitiveness
      FLastChar := LowerCase(GetChar);
    until not (FLastChar in ['a' .. 'z','0' .. '9','_']);
    // Need to check for keywords
    case IdLex of
      'print' : Result := TPrintToken.Create(StartLine,StartCol);
      'var'   : Result := TVarToken.Create(StartLine,StartCol);
      otherwise Result := TIdentifier.Create(StartLine,StartCol,IdLex);
    end;
  end;

begin
  // Eat whitespaces
  while FLastChar in [#32,#9,#13,#10] do
    FLastChar := GetChar;
  // Save first token position, since GetChar would change FLine and FCol
  StartLine := FLine;
  StartCol := FCol;
  if FLastChar = #26 then
    FCurrentToken := TEOFToken.Create(StartLine, StartCol)
  else
    case LowerCase(FLastChar) of
      // Exact single character
      '+': begin
        FCurrentToken := TPlusToken.Create(StartLine, StartCol);
        FLastChar := GetChar;
      end;
      '-': begin
        FCurrentToken := TMinusToken.Create(StartLine, StartCol);
        FLastChar := GetChar;
      end;
      // Exact multiple character
      ':': begin
        FLastChar := GetChar;
        if FLastChar = '=' then
          FCurrentToken := TAssignToken.Create(StartLine, StartCol)
        // ':' alone has no meaning in this language, therefore it's invalid
        else
          FCurrentToken := TUnknownToken.Create(StartLine, StartCol);
      end;
      // Overlapping prefix
      '<': begin
        FLastChar := GetChar;
        // '<>'
        if FLastChar = '>' then
          FCurrentToken := TNotEqualToken.Create(StartLine, StartCol)
        // '<'
        else
          FCurrentToken := TLessToken.Create(StartLine, StartCol);
      end;
      // Infinitely many is handled in its own function to cut down line length here
      '0' .. '9': begin
        FCurrentToken := GetNumber;
      end;
      'a' .. 'z','_': begin
        FCurrentToken := GetIdentifierOrKeyword;
      end;
      else begin
        FCurrentToken := TUnknownToken.Create(StartLine, StartCol, FLastChar);
        FLastChar := GetChar;
      end;
    end;
end;

如您所见,核心是一个(可能很大的)case 语句。其他部分相当自文档化并且有很好的注释。最后但并非最不重要的一点是构造函数

constructor TLexer.Create(AStream: TStream; AErrorList: TErrorList);
begin
  FStream := AStream;
  FLine := 1;
  FCol := 0;
  FLastChar := GetChar;
  NextToken;
end;

它设置初始的行号和列号(猜猜为什么它是行的 1 但列的 0 :)), 以及设置第一个可用的词法单元,以便在调用构造函数后 CurrentToken 可用,无需在之后显式调用 NextToken。

测试程序

uses
  Classes,SysUtils,lexer,tokens;
var
  Stream: TStream;
  lex: TLexer;
begin
  Stream := THandleStream.Create(StdInputHandle);
  lex := TLexer.Create(Stream,nil);
  while not(lex.CurrentToken is TEOFToken) do begin
    WriteLn(lex.CurrentToken.ToString);
    lex.NextToken;
  end;
  lex.Free;
  Stream.Free;
end.

作为练习,您可以尝试将词法分析器扩展为浮点数、字符串、以 10 以外的基数表示的数字、科学记数法、注释等。

表驱动的书写扫描

编译通道常量

词法分析工具

通过工具扫描 - lex/flex

Clipboard

待办事项
我的 Wikipedia:en:Flex 词法分析器 文章。


通过工具扫描 - JavaCC

JavaCC 是标准的 Java 编译器编译器。与本章介绍的其他工具不同,JavaCC 是一个解析器和扫描器(词法分析器)生成器合二为一。JavaCC 只接受一个输入文件(称为语法文件),然后使用该文件创建词法分析的类以及解析器。

在 JavaCC 的术语中,扫描器/词法分析器被称为词法单元管理器。事实上,包含词法单元管理器的生成的类被称为ParserNameTokenManager。当然,按照通常的 Java 文件命名要求,该类存储在名为ParserNameTokenManager.java 的文件中。ParserName 部分取自输入文件。此外,JavaCC 创建第二个类,称为ParserNameConstants。正如其名称所暗示的那样,第二个类包含常量的定义,尤其是词法单元常量。JavaCC 还生成一个样板类,称为Token。这个类始终相同,包含用于表示词法单元的类。还会获得一个名为ParseError 的类。这是一个异常,如果出现问题则会抛出该异常。

可以指示 JavaCC 不要生成ParserNameTokenManger,而是提供您自己的手写词法单元管理器。通常,对于本章中介绍的所有工具来说,手写扫描器/词法分析器/词法单元管理器效率要高得多。因此,如果您发现生成的编译器太大,请仔细查看生成的扫描器/词法分析器/词法单元管理器。如果您需要解析二进制数据并将其馈送到解析层,则自行编写词法单元管理器也很有用。

然而,由于本节是关于使用 JavaCC 生成词法单元管理器,而不是手写词法单元管理器,因此此处不再详细讨论。

在 JavaCC 语法文件中定义词法单元

JavaCC 语法文件通常以与解析器相关但与扫描器无关的代码开头。对于简单的语法文件,它看起来类似于

options { LOOKAHEAD=1; }
PARSER_BEGIN(ParserName) public class ParserName { // code to enter the parser goes here } PARSER_END(ParserName)

这通常紧随其后的是标记的定义。这些定义是我们本章感兴趣的信息。当涉及到标记的定义时,JavaCC 理解四种不同的类型,由四个不同的关键字表示。

TOKEN
指定标记管理器应能够识别的标记的正则表达式。
SPECIAL_TOKEN
SPECIAL_TOKEN 与 TOKEN 类似,只是解析器会忽略它们。这对于指定注释很有用,注释应该被理解,但对解析器没有意义。
SKIP
标记管理器应该完全忽略的标记(输入数据)。这通常用于忽略空白。SKIP 标记仍然会中断其他标记。例如,如果跳过空白,并且定义了标记“else”,并且输入是“el se”,则不会匹配标记。
MORE
这用于一种高级技术,其中标记是逐步构建的。MORE 标记被放入缓冲区,直到下一个 TOKEN 或 SPECIAL_TOKEN 匹配。然后返回所有数据,缓冲区中累积的标记以及最后一个 TOKEN 或 SPECIAL_TOKEN。
一个使用 *MORE* 标记的例子是构造,其中希望匹配某个起始字符串、任意数据和某个结束字符串。许多编程语言中的注释或字符串文字都符合这种形式。例如,要匹配由 *"* 分隔的字符串文字,不会将第一个找到的 *"* 作为标记返回。相反,会累积更多标记,直到找到字符串文字的结束 *"*。然后返回完整的文字。有关此用于扫描注释的示例,请参见 注释示例

上面提到的每个关键字都可以根据需要使用多次。这使得可以对标记进行分组,例如,将操作符放在一个列表中,将关键字放在另一个列表中。所有相同类型的部分都被合并,就像只有一个部分被指定一样。

每个标记规范都包含标记的符号名称和一个正则表达式。如果正则表达式匹配,标记管理器将返回该符号。

简单示例

让我们看一个例子

//
// Skip whitespace in input
// if the input matches space, carriage return, new line or a tab,
// it is just ignored
//
SKIP: { " " | "\r" | "\n" | "\t" }
// Define the tokens representing our operators // TOKEN: { < PLUS: "+" > | < MINUS: "-" > | < MUL: "*" > | < DIV: "/" > }
// // Define the tokens representing our keywords // TOKEN: { < IF: "if" > | < THEN: "then" > | < ELSE: "else" > }

以上所有标记定义都使用简单的正则表达式,其中只匹配常量。建议研究 JavaCC 文档 以了解所有可能的正则表达式。

当以上文件通过 JavaCC 运行时,会生成一个标记管理器,它能够理解上面声明的标记。

注释示例

在编程语言中消除(忽略)注释是词法分析器的常见任务。当然,当使用 JavaCC 时,这项任务通常由标记管理器完成,方法是指定特殊标记。

基本上,JavaCC 已经演化出一种关于如何忽略注释的标准习惯用法。它将 *SPECIAL_TOKEN* 和 *MORE* 类型的标记与 *词法状态* 结合起来。假设我们有一种编程语言,其注释以 *--* 开头,并以另一个 *--* 或行尾结束(这是 ASN.1 和其他几种语言的注释模式)。那么,为其构建扫描器的办法是

//
// Start of comment. Don't return as token, instead
// shift to special lexical state.
//
SPECIAL_TOKEN: {  <"--"> : WithinComment }
// // While inside the comment lexicals state, look for end // of comment (either another '--' or EOL). Don't return // the (accumulated data). Instead switch back to normal // lexical state // <WithinComment> SPECIAL_TOKEN: { <("--" | "\n" )> : DEFAULT }
// // While inside the comment state, accumulate all contents // <WithinComment> MORE: { <~[]> }


Clipboard

待办事项
文档词法状态;标记管理器声明;有关正则表达式的详细信息;内部 (#) 正则表达式。

华夏公益教科书