詞法分析基礎

袋鼠云数栈前端發表於2024-04-07

我們是袋鼠雲數棧 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. 分隔符/界符:比如 ;{(

以一個簡單的賦值語句舉例

const str = 'hello' + 1;

首先我們需要一張 token 型別對照表,例如:

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

那麼上述表示式的詞法分析結果就應該是:

[
  { 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 定義

用術語表達,這一步就是種別碼定義

enum TOKEN_TYPE {
  number = 'NUMBER',
  string = 'STRING',
  identifier = 'IDENTIFIER',
  punctuation = 'PUNCTUATION',
};

lexer 基本實現

在假設輸入的 token 都能被正確識別的情況下使用直接掃描的方式實現如下:

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 型別

enum TOKEN_TYPE {
  undefined = 'UNDEFINED',
};

然後在錯誤處返回 undefined token

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 無法從一個狀態轉換到另一個狀態,那麼就可以認定存在輸入中詞法錯誤,這可以讓我們很容易的進行錯誤檢測和恢復。

定義狀態

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",
}

定義狀態轉移過程

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,
    },
];

狀態機

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,
            }
        );
    }
}

詞法分析器

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

  • 大資料分散式任務排程系統——Taier
  • 輕量級的 Web IDE UI 框架——Molecule
  • 針對大資料領域的 SQL Parser 專案——dt-sql-parser
  • 袋鼠雲數棧前端團隊程式碼評審工程實踐文件——code-review-practices
  • 一個速度更快、配置更靈活、使用更簡單的模組打包器——ko
  • 一個針對 antd 的元件測試工具庫——ant-design-testing

相關文章