词法分析基础

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:奇铭

什么是词法分析

要弄清楚什么是词法分析,需要先搞清楚代码是如何执行的。高级编程语言的代码通常需要通过翻译才能被机器执行,而翻译的方式分为两种:

  • 解释: 即以源程序为输入,不产生目标程序,一边解释一边执行,比如 javascript
  • 编译: 即将源程序翻译为机器语言或者目标语言,再执行,比如C, C++

词法分析属于编译的一部分,也是编译的一个阶段。编译通常被分为两个部分:

  1. 编译前端:对源程序进行词法分析,语法分析,语义分析,最终生成中间表现形式,常见的中间表现形式是 AST(抽象语法树)
  2. 编译后端:将中间表现形式(AST)转化为目标程序,比如机器语言或者汇编语言。

在编译前端部分中:

  • 词法分析:将源程序分解成一系列的 Token
  • 语法分析: token 转换成一个由程序的各种结构(如循环,条件语句等)组成的抽象语法树(Abstract Syntax Tree, AST)。
  • 语义分析:确保抽象语法树符合语言规范,例如,确保使用变量前已经被声明,确保函数调用时参数类型和数量与定义时的匹配等。

编译器、解析器、解释器

编译器 的作用将一种语言(通常是高级语言)的源代码转换成另一种语言,其中包含词法分析、 语法分析、语义分析和代码生成等所有编译功能。常见的编译器有 GCCCLANG,前端领域最常见的编译器就是Babel

解析器 则是只负责对源程序进行词法分析和语法分析并将源程序转化为 AST 的部分,但其并不包含语义分析和代码生成功能。前端领域常见的解析器则是 Acorn,webpack 和 rollup 都适用它作为解析器。

解释器的任务是读取和执行源代码,而不需要(通常来说)先将源代码转换成机器代码。解释器通常会一边解析源代码一边执行它,或者先将源代码全部解析成AST或某种中间表示形式,然后再执行。Python 和 JavaScript 通常就是以解释执行的方式运行的。

另外,应用广泛的Antl4则是一个解析器生成器,它会根据语法文件生成解析器,生成产物中就包含用于词法分析的 Lexer 和 语法分析的 Parser。类似的解析器生成器还有 BisonJisonYaccPeg.js。其中 Antlr4JisonPeg.js 都可以在 javascript 环境中使用。

Token

源代码中的每一行都是由字符构成的,这些字符可能组成了数字,变量名,关键字等各种元素。词法分析器并不关心元素之间的关系(比如一个变量是否被赋值了,或者是否在使用它之前就被声明了),它只关心将字符逐个归类,生成 token。 一个 token 可以看作是编程语言中的最小有意义的单位,比如一个整数,一个字符串,一个标识符,一个关键字等。一个 token 最少由两部分组成--类型和值。比如 'hello'的类型为 string 值为 hello。 一门典型的语言中 token 类型大概有下面5大类

  1. 关键字: 比如 IFELSEFOR 等等
  2. 标识符:比如 js 中的变量名,函数名、sql 中的表名、字段名等
  3. 常数/字面量:比如数字字面量-100,字符串字面量-'hello'
  4. 运算符/操作符:比如 +*\>
  5. 分隔符/界符:比如 ;{(

以一个简单的赋值语句举例

javascript 复制代码
const str = 'hello' + 1;

首先我们需要一张 token 类型对照表,例如:

token tokenType
keyword 1
identifier 2
stringLiteral 3
NumberLiteral 4
operator 5
delimiter 6

那么上述表达式的词法分析结果就应该是:

javascript 复制代码
[
  { type: 1, value: "const" },
  { type: 2, value: "str" },
  { type: 5, value: "=" },
  { type: 3, value: "'hello'" },
  { type: 5, value: "+" },
  { type: 4, value: "1" },
  { type: 6, value: ";" },
]

关键字

关键字即为保留字,比如 ES6 中 breakcaseclass等都是保留字,它们不能作为标识符使用,详情请看 javascript 词法文法。在典型语言中,区分保留字和非保留字的重要依据就是是否能作为标识符,比如 undefind虽然印象中应该是一个关键字/保留字,但是其实不是,undefined 也是可以作为标识符的。

标识符

一般来说,在典型语言中,标识符用于标识某种实体,比如在 js 中表示符可以用来标识变量名、函数名,在 sql 中标识符可以用来标识表名、字段名。常见的大多数语言的标识符都由数字,英文字母和下划线组成。上文中提到的关键字虽然也符合这些规则,但是仍然不能作为标识符,因为这会导致难以进行语法分析,容易产生歧义。这也是为什么很多种语言都不支持标识符中包含中划线。 另外在 javascript 中像 TRUE / FALSE 此类字面量也不能作为标识符。

空格/换行和注释

对于空格/注释等不影响最终结果的代码片段/字符,一般可以忽略或者暂存,在大部分情况下,它们不需要进入最终的 token 序列中。

词法分析过程

词法分析的过程简单来说就是逐个扫描字符,并根据给定的模式/规则生成 Token 的过程,大概包含以下步骤:

  1. 输入源代码:首先,从文件、命令行或其他来源读取源代码;
  2. 字符扫描:接下来,词法分析器开始从输入流中扫描字符;
  3. 模式匹配:词法分析器试图将扫描到的字符与预定义的模式进行匹配。这些模式常常基于正则表达式定义,并且每个模式对应一种标记(token)类型,在遇到分隔符(如空格或符号)或者匹配了某一种模式时,词法分析器会停止对当前序列的扫描;
  4. 生成标记:一旦找到匹配的模式,词法分析器就生成一个与此模式对应的 Token;
  5. 重复上述过程: 词法分析器再次开始扫描字符,并且尝试找到下一个Token,这个过程会一直持续,直到源代码被完全转换为 Token 序列,或者出现错误;
  6. 错误处理: 如果在分析过程中出现错误,那么词法分析器应当能够在生成的标记序列中添加错误标记或者在编译过程中报告错误;

实现一个简单的词法分析器

根据上文中的词法分析过程,我们来实现一个简单的词法分析器,这个词法分析器能够生成数字,字符串,标识符和一些符号,在此之前我们首先需要定义 token

token 定义

用术语表达,这一步就是种别码定义

typescript 复制代码
enum TOKEN_TYPE {
  number = 'NUMBER',
  string = 'STRING',
  identifier = 'IDENTIFIER',
  punctuation = 'PUNCTUATION',
};

lexer 基本实现

在假设输入的 token 都能被正确识别的情况下使用直接扫描的方式实现如下:

javascript 复制代码
class Lexer {
    constructor(input: string) {
        /** 输入流 */
        this.input = input;
        /** 扫描位置 */
        this.pos = 0;
    }

    input: string;
    pos: number;

    /** 取出当前字符 */
    peek() {
        if (this.pos >= this.input.length) return '<EOF>';
        return this.input[this.pos];
    }

    /** 创建 token */
    createToken(value: unknown, type: TOKEN_TYPE) {
        return { value, type };
    }

    /** generate token */
    nextToken() {
        while (this.pos < this.input.length) {
            if (/\s/.test(this.peek())) {
                this.pos++;
                continue;
            }
            if (/\d/.test(this.peek())) {
                return this.createNumberToken();
            }
            if (this.peek() === '"') {
                return this.createStringToken();
            }
            if (/[a-zA-Z]/.test(this.peek())) {
                return this.createIdentifierToken();
            }
            if (/[{}(),:;+\-*/]/.test(this.peek())) {
                return this.createToken(this.input[this.pos++], TOKEN_TYPE.punctuation);
            }
        }
    }

    createNumberToken() {
        let numStr = '';
        while (/\d/.test(this.peek())) {
            numStr += this.input[this.pos++];
        }
        return this.createToken(numStr, TOKEN_TYPE.number);
    }

    createStringToken() {
        let str = '"';
        this.pos++
        while (this.peek() !== '"' && this.peek() !== '<EOF>') {
            str += this.input[this.pos++];
        }
        str+='"'
        this.pos++
        return this.createToken(str, TOKEN_TYPE.string);
    }

    createIdentifierToken() {
        let idStr = '';
        while (/[a-zA-Z]/.test(this.peek())) {
            idStr += this.input[this.pos++];
        }
        return this.createToken(idStr, TOKEN_TYPE.identifier);
    }
}

// test code
const lexer = new Lexer('let a "Hello, World";123');
const tokenList = [];
let token
while (token = lexer.nextToken()) {
    tokenList.push(token)
}
console.log(tokenList);

// outout
/*
[
  { value: 'let', type: 'IDENTIFIER' },
  { value: 'a', type: 'IDENTIFIER' },
  { value: '"Hello, World"', type: 'STRING' },
  { value: ';', type: 'PUNCTUATION' },
  { value: '123', type: 'NUMBER' }
]
*/

错误恢复

显然上述代码在遇到错误时就无法运行了,所以我们还需要一些错误恢复的机制,当前的 lexer 中的错误大概分为两种

  1. 未知字符
  2. 字符串未正常结束

首先在 tokenType 中新增一个 undefined 类型

typescript 复制代码
enum TOKEN_TYPE {
  undefined = 'UNDEFINED',
};

然后在错误处返回 undefined token

javascript 复制代码
class Lexer {
    // ...
	  nextToken() {
        while (this.pos < this.input.length) {
            // ....
            return this.errorRecovery();
        }
    }
  	// ...
    createStringToken() {
        let str = '"';
        this.pos++
        while (this.peek() !== '"' && this.peek() !== '<EOF>') {
            str += this.input[this.pos++];
        }
        if(this.peek() === '<EOF>'){
            console.warn('Unfinished strings', str)
            return this.createToken(str, TOKEN_TYPE.undefined)
        }
        str+=this.peek();
        this.pos++
        return this.createToken(str, TOKEN_TYPE.string);
    }
  	// ...
    errorRecovery() {
        console.warn('Unexpected character: ' + this.peek());
        const unknownChar = this.peek();
        this.pos++;
        return this.createToken(unknownChar, TOKEN_TYPE.undefined)
    }
}

// test code
const lexer = new Lexer('let a "Hello, World";123');
const tokenList = [];
let token
while (token = lexer.nextToken()) {
    tokenList.push(token)
}
console.log(tokenList);

// output
/*
Unexpected character: =
Unfinished strings "Hello, World
[
  { value: 'let', type: 'IDENTIFIER' },
  { value: 'a', type: 'IDENTIFIER' },
  { value: '=', type: 'UNDEFINED' },
  { value: '"Hello, World', type: 'UNDEFINED' }
]
*/

DFA

在词法分析领域,更常见或者说应用更广的词法分析技术是 DFA (确定有限自动机)。DFA 具有如下优点

  1. 确定性:即在任何状态和任意字符输入下,有且只有一种状态转换
  2. 高效: 只需要一次线性扫描就可以分析完成,不需要回溯
  3. 分析能力:DFA 能够最长匹配和优先匹配关键字,避免了多种符号解析的冲突
  4. 错误检测:如果 DFA 无法从一个状态转换到另一个状态,那么就可以认定存在输入中词法错误,这可以让我们很容易的进行错误检测和恢复。

定义状态

typescript 复制代码
enum STATE_TYPE {
  START = "start", // 初始状态
  NUMBER = "number",
  STRING_OPEN = "string_open",
  STRING_ESCAPE = "string_escape",
  STRING_CLOSE = "string_close",
  IDENTIFIER = "identifier",
  PUNCTUATION = 'punctuation',
  UNKNOWN = "unknown",
}

定义状态转移过程

typescript 复制代码
const TRANSITIONS: Transition[] = [
    // skip whitespace
    { state: STATE_TYPE.START, regex: /\s/, nextState: STATE_TYPE.START },

    /** ==== PUNCTUATION  */
    { 
        state: STATE_TYPE.START,
        regex: /[{}(),:;+\-*/]/,
        nextState: STATE_TYPE.PUNCTUATION,
        tokenType: TOKEN_TYPE.PUNCTUATION
    },
    {
        state: STATE_TYPE.PUNCTUATION,
        regex: /[\w\W]/,
        nextState: STATE_TYPE.START,
        tokenType: TOKEN_TYPE.PUNCTUATION
    },

    /** ==== identifier  */
    {
        state: STATE_TYPE.START,
        regex: /[a-z_A-Z]/,
        tokenType: TOKEN_TYPE.IDENTIFIER,
        nextState: STATE_TYPE.IDENTIFIER,
    },
    {
        state: STATE_TYPE.IDENTIFIER,
        regex: /[a-z_A-Z]/,
        tokenType: TOKEN_TYPE.IDENTIFIER,
        nextState: STATE_TYPE.IDENTIFIER,
    },
    {
        state: STATE_TYPE.IDENTIFIER,
        regex: /[^a-z_A-Z]/,
        tokenType: TOKEN_TYPE.IDENTIFIER,
        nextState: STATE_TYPE.START,
    },

    /** ===== number */
    {
        state: STATE_TYPE.START,
        regex: /[0-9]/,
        tokenType: TOKEN_TYPE.NUMBER,
        nextState: STATE_TYPE.NUMBER,
    },
    {
        state: STATE_TYPE.NUMBER,
        regex: /[0-9]/,
        tokenType: TOKEN_TYPE.NUMBER,
        nextState: STATE_TYPE.NUMBER,
    },
    {
        state: STATE_TYPE.NUMBER,
        regex: /[^0-9]/,
        tokenType: TOKEN_TYPE.NUMBER,
        nextState: STATE_TYPE.START,
    },

    /** ===== string */
    {
        state: STATE_TYPE.START,
        regex: /"/,
        tokenType: TOKEN_TYPE.UNDEFINED,
        nextState: STATE_TYPE.STRING_OPEN,
    },
    {
        state: STATE_TYPE.STRING_OPEN,
        regex: /[^"]/,
        tokenType: TOKEN_TYPE.UNDEFINED,
        nextState: STATE_TYPE.STRING_ESCAPE,
    },
    {
        state: STATE_TYPE.STRING_ESCAPE,
        regex: /[^"]/,
        tokenType: TOKEN_TYPE.UNDEFINED,
        nextState: STATE_TYPE.STRING_ESCAPE,
    },
    {
        state: STATE_TYPE.STRING_ESCAPE,
        regex: /"/,
        tokenType: TOKEN_TYPE.STRING,
        nextState: STATE_TYPE.STRING_CLOSE,
    },
    {
        state: STATE_TYPE.STRING_CLOSE,
        regex: /[\w\W]/,
        tokenType: TOKEN_TYPE.STRING,
        nextState: STATE_TYPE.START,
    },
];

状态机

typescript 复制代码
class StateMachine {
    constructor() {
        this.transitions = TRANSITIONS;
    }

    transitions: Transition[];

    addTransition(transition: Transition) {
        this.transitions.push(transition);
    }

    performTransition(currentState: STATE_TYPE, char: string) {
        const transition = TRANSITIONS.find(
            (t) => t.state === currentState && t.regex.test(char)
        );
        // 遇到未知字符串时,直接回到开始状态
        return (
            transition ?? {
                state: STATE_TYPE.UNKNOWN,
                regex: /./,
                tokenType: TOKEN_TYPE.UNDEFINED,
                nextState: STATE_TYPE.START,
            }
        );
    }
}

词法分析器

typescript 复制代码
class Lexer {
    constructor(input: string) {
        this.currentState = STATE_TYPE.START;
        this.input = input;
        this.pos = 0;
        this.stateMachine = new StateMachine();
    }
    stateMachine: StateMachine;
    currentState: STATE_TYPE;
    input: string;
    pos: number;

    peek() {
        if (this.pos >= this.input.length) return "<EOF>";
        return this.input[this.pos];
    }

    createToken(value: unknown, type: TOKEN_TYPE) {
        return { value, type };
    }

    nextToken() {
        let buffer = ""; // 缓冲区
        let tokenType: TOKEN_TYPE | undefined = undefined;
        while (this.pos < this.input.length) {
            const transition = this.stateMachine.performTransition(
                this.currentState,
                this.peek()
            );

            this.currentState = transition.nextState;
            tokenType = transition.tokenType;

            if(transition.nextState !== STATE_TYPE.START) {
                buffer += this.peek();
                this.pos++;
                continue;
            }

            if(!transition.tokenType) {
                buffer = '';
                this.pos++;
                continue; 
            }

            if(transition.state === STATE_TYPE.UNKNOWN) {
                buffer += this.peek();
                this.pos++;
            }

            return this.createToken(buffer, transition.tokenType)
        }

        if(tokenType && buffer) {
            return this.createToken(buffer, tokenType)
        }
    }
}

最后

欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

相关推荐
吉吉安2 分钟前
grid布局下的展开/收缩过渡效果【vue/已验证可正常运行】
前端·javascript·vue.js
梦凡尘7 分钟前
Vue3 对跳转 同一路由传入不同参数的页面分别进行缓存
前端·javascript·vue.js
攒了一袋星辰8 分钟前
Webpack安装以及快速入门
前端·webpack·node.js
吃饱很舒服23 分钟前
kotlin distinctBy 使用
android·java·开发语言·前端·kotlin
勤劳兔码农37 分钟前
从IE到Edge:微软浏览器的演变与未来展望
前端·microsoft·edge
web守墓人1 小时前
【前端】解决element-ui两层dialog嵌套,遮罩层消失的问题。
前端·ui
茶卡盐佑星_1 小时前
vue如何解决跨域?原理?
前端·javascript·vue.js
河北小田1 小时前
十四、模板引用
前端·vue.js·程序员
Clank的游戏栈1 小时前
Unity3D 场景树与组件化开发详解
前端
前端fighter1 小时前
表单代码示例
前端·javascript·vue.js