Rust 实现一个表达式 Parser(5) Lexer 实现

2,767 阅读8分钟

本文将结合前文封装的 DFA(其实只是封装了俩函数) 实现词法分析器 Lexer

源码: src/lexer/tokenizer.rs

Lexer 目标

开始之前先来明确我们的目标, 我们希望将一坨字符串分割为多个 Token, 大致如下

step_string_to_token.jpg

首先摆在我们面前的问题就是, Token 该长什么样, 首先明确 Token 需要附带哪些信息

  • 类型, 这里对应的是上一篇文章中的 SyntaxKind, 如 PLUS 类型和 MINUS 类型
  • 值, 针对数字 Token 我们需要保存其具体的值, 如 123

除此之外并不需要其余信息了, 那么我们就可以很想当然的使用一个简单的二元组来保存 Token, (类型, 值)

Lexer 实现

至此就可以直接开始怼逻辑了

Token 定义

经过上面的分析, 这里直接定义出两个类型别名即可, 值得一提的是, 这里为了之后的代码简洁, 额外定义了一个 TokenStream

pub type Token = (SyntaxKind, String);
pub type TokenStream = Vec<Token>;

Tokenizer 结构体定义

接着我们会需要一个结构体来保存当前已经得到的 Token, 直接定义如下, 并顺便实现三个简单的方法

pub struct Tokenizer {
    code: String,
    token_stream: TokenStream,
}
impl Tokenizer {
    pub fn new(code: String) -> Self {
        Tokenizer { code, token_stream: Vec::new() }
    }
    pub fn token_stream(&self) -> TokenStream {
        self.token_stream.to_owned()
    }
    fn push_token(&mut self, text: &str) {
        // 这里直接用模式匹配来区分是操作符 Token 还是 数字 Token
        let token = match SyntaxKind::from_operator(text) {
            // 操作符 Token
            Some(kind) => (kind, text.to_string()),
            // 数字 Token
            None => (NUM, (text.to_string())),
        };
        self.token_stream.push(token);
    }
}

核心逻辑

下面进入核心逻辑的实现, 此处要结合上一篇文章进行理解, 不考虑非法输入的话, 我们的 DFA 需要做的事情简单列举出来是这样的

  1. 根据当前输入进行状态迁移
  2. 判断转移前的状态是否是终止态
    • 终止态: 上次已经匹配一个完整的 Token, 输出
    • 非终止态: 当前并没有匹配到一个完整的 Token, 缓存
  3. 回到第一步, 直到解析完所有的输入序列

以上就是 DFA 的核心逻辑, 这里借助上一篇文章中封装好的两个函数, 可以很快速的搭出如下框架

impl Tokenizer {
    pub fn run(&mut self) {
        // 状态转移函数
        let transition = get_transition();
        // 判断传入状态是否是终止状态的函数
        let is_terminator = get_terminator_judgement();

        let mut idx = 0;             // 当前索引
        let mut state = START;       // 当前状态
        let mut prev_state = ERROR;  // 前一个状态

        // 缓存可能未匹配完的 Token
        let mut text_cache = String::new();
        // 将输入的字符串格式转化为 char 类型的迭代器并遍历
        while let Some(c) = self.code.chars().nth(idx) {
            // 状态迁移
            state = transition(c, state);

            // 从一个终止态转移到另一个终止态则意味着上一次已经匹配到了一个完整的 Token
            if is_terminator(prev_state) && state != prev_state {
                // 将 Token 输出到 self.token_stream
                self.push_token(&text_cache);
                // 清空缓存
                text_cache.clear();
            }

            // 将非空字符缓存
            if c != ' ' {
              text_cache.push(c);
            }

            // 更新一些数据
            idx += 1;
            prev_state = state;
        }
        // 因为是根据前一次状态进行判断, 因此在循环退出时会在缓存中残留最后一个 Token
        self.push_token(&text_cache);
    }
}

接着我们来看一下上一篇文章中写好的状态转移表

opws01-9
ERROR0000
START2134
OPERATOR2134
ZERO2100
NUM2144

我们会发现目前写好的程序存在一个小 bug, 就是当连续匹配到多个 op 类型时, 会一直停留在 OPERATOR 状态, 这意味着不满足 state != prev_state 的条件, 就不会将 op 拆分开来存入, 因此仅需要多加一个简单的小条件即可

impl Tokenizer {
    pub fn run(&mut self) {
        // ...
        while let Some(c) = self.code.chars().nth(idx) {
            // 状态迁移
            state = transition(c, state);

            // 从一个终止态转移到另一个终止态则意味着上一次已经匹配到了一个完整的 Token
            // 或者当前匹配到一个操作符, 则直接存入
            if is_terminator(prev_state) && (state != prev_state || prev_state == OPERATOR) {
                // 将 Token 输出到 self.token_stream
                self.push_token(&text_cache);
                // 清空缓存
                text_cache.clear();
            }

            // ...

        }
        // ...
    }
}

目前来看就没有其他大问题了, 下面继续完善其他方面

错误处理

错误处理非常简单, 在 Rust 中使用 Result 可以很方便的进行错误处理, 此外在上一篇文章我们已经事先定义好了错误的状态 ERROR 以及迁移到该状态的途径, 因此这里直接简单的判断即可, 如下

impl Tokenizer {
    pub fn run(&mut self) -> Result<(), String> {
        if self.code.len() == 0 {
            return Err("an empty string was received".to_string());
        }

        // ...
        while let Some(c) = self.code.chars().nth(idx) {
            state = transition(c, state);

            // 迁移到 ERROR 状态, 直接报错, 并给出简单的报错信息
            if state == ERROR {
                return Err(format!(
                    "unexpected token at the {} of the input, current cache: {}",
                    idx, text_cache
                ));
            } else if is_terminator(prev_state) && (state != prev_state || prev_state == OPERATOR) {
                self.push_token(&text_cache);
                text_cache.clear();
            }

            // ...
        }
        self.push_token(&text_cache);

        // 解析成功, 返回一个空的 Ok
        Ok(())
    }
}

支持正负数

前文提到, 原本打算用 NFA 实现正负数的支持, 但是发现转化为 DFA 之后状态量增加了很多, 实现起来很麻烦, 因此就决定还是在词法分析的逻辑里进行处理

众所周知, 数字默认是正数, 因此添加 + 前缀并没有意义, 因此我们其实只需要考虑如何处理负数的问题就可以了

如何添加负号

按照现有逻辑, 我们会将所有的操作符直接输出到 token_stream, 数字会在完整匹配之后输出到 token_stream, 而空格会被直接跳过, 这意味着目前是这样的

表达式: "1 + -2"
解析完成后 token_steam: ["1", "+", "-", "2"]

那么我们其实可以在 push_token 的逻辑中简单处理一下, 在数字 Token 进入的时候, 尝试与顶部 Token 合并

何时添加负号

简单的列举分析一下可以发现, 实际上需要添加负号的情况只有以下两种, 以下也涵盖了要将正号去掉的情况

  1. 数字 Token 准备压栈, 当前栈顶和次栈顶都是操作符, 且栈顶是 + 或者 -

    • [ 1, +, 2, +, - ] <- 3
    • [ 1, +, 2, +, (, + ] <- 3
  2. 数字 Token 准备压栈, 当前栈长度为 1 且栈顶元素为 + 或者 -

    • [ - ] <- 1
    • [ + ] <- 1

具体逻辑

经过上面的分析, 可以很轻松的写出如下逻辑, 简单来说就是一路 if else, 只不过 Rust 里可以用模式匹配

impl Tokenizer {
    fn push_token(&mut self, text: &str) {
        let token = match SyntaxKind::from_operator(text) {
            Some(kind) => (kind, text.to_string()),
            // 数字 Token, 可能可以合并
            // 调用 self.try_merge 尝试一下
            None => (NUM, self.try_merge(text.to_string())),
        };
        self.token_stream.push(token);
    }
    fn try_merge(&mut self, mut text: String) -> String {
        let len = self.token_stream.len();
        // e.g
        //   [ 1, +, - ] <- 1
        //        |  |      |
        //        k1 k2   text
        if len >= 2 {
            let (k1, _) = self.token_stream[len - 2];
            let (k2, _) = self.token_stream[len - 1];
            match k1 {
                token!["+"] | token!["-"] | token!["*"] | token!["/"] | token!["("] => {
                    match k2 {
                        // "1 + - 1" => [ 1, +, -1 ]
                        token!["-"] => {
                            self.token_stream.pop();
                            text.insert_str(0, "-")
                        }
                        // "1 + + 1" => [ 1, +, 1]
                        token!["+"] => {
                            self.token_stream.pop();
                        }
                        _ => {}
                    }
                },
                _ => {}
            }
        }
        // e.g
        //   [ - ] <- 1
        //     |      |
        //     k1    text
        else if len == 1 {
            let (k1, _) = self.token_stream[len - 1];
            if k1 == token!["+"] || k1 == token!["-"] {
                self.token_stream.pop();
                match k1 {
                    // "- 1" => [ -1 ]
                    token!["-"] => text.insert_str(0, "-"),
                    // "+ 1" => [ 1 ]
                    _ => {}
                }
            }
        }
        text
    }
}

跑一下

写完了逻辑就简单跑一下, 如下测试都是能够顺利通过的, 而更多的用例在源码中, 就不列举了

// 封装了一个简单的驱动函数配合测试
fn lex(code: &str) -> Result<TokenStream, ()> {
    let mut tokenizer = Tokenizer::new(code.to_string());
    if tokenizer.run().is_ok() {
        Ok(tokenizer.token_stream())
    } else {
        Err(())
    }
}

#[test]
fn basic_test() {
    assert_eq!(vec![(NUM, "123".to_string())], lex("+123").unwrap());
    assert_eq!(vec![(NUM, "-123".to_string())], lex("-123").unwrap());
    assert_eq!(vec![(NUM, "123".to_string())], lex("123").unwrap());

    assert_eq!(
        vec![
            (NUM, "123".to_string()),
            (PLUS, "+".to_string()),
            (NUM, "-456".to_string())
        ],
        lex("123+-456").unwrap()
    );
    assert_eq!(
        vec![
            (NUM, "123".to_string()),
            (PLUS, "+".to_string()),
            (NUM, "-456".to_string())
        ],
        lex("+123+-456").unwrap()
    );
    assert_eq!(
        vec![
            (NUM, "123".to_string()),
            (PLUS, "+".to_string()),
            (NUM, "456".to_string())
        ],
        lex("+123+456").unwrap()
    );
}

模块导出

目前已经完成了 Lexer 模块的核心逻辑, 接下来可以来简单封装一个函数作为模块导出, 只是简单封装, 就不做赘述

源码 src/lexer/mod.rs

pub fn lex(code: &str) -> Result<TokenStream, String> {
    let mut tokenizer = Tokenizer::new(code.to_string());
    match tokenizer.run() {
        Ok(_) => Ok(tokenizer.token_stream()),
        Err(err) => Err(err),
    }
}

总结

至此我们完成了 Lexer 的所有部分, 最核心的部分在于应用状态转换表以及判断输出完整 Token 的时机, 这里笔者在实现时尽量写的简单清晰, 在源码中也书写了大量的注释, 具体的可以直接看源码中的注释

Q&A

Q: 核心逻辑为什么不用双指针?

A: 一开始写的是双指针版本, 在解决了 Rust 的一些 ownship 问题之后发现代码变得臃肿不易读, 不如直接整个简单的, 这里用双指针和我写的这个暴力算法时间复杂度在理论上是一样的量级, 还是可读性比较重要一点

完整代码

由于文章拆成了两篇, 且修改的比较混乱, 因此贴一个完整版, 以下代码去掉了注释, 其实还是更建议直接看仓库源码, 里面有注释

// src/lexer/dfa.rs
pub const ERROR: usize = 0;
pub const START: usize = 1;
pub const OPERATOR: usize = 2;
pub const ZERO: usize = 3;
pub const NUM: usize = 4;

pub fn get_terminator_judgement() -> impl Fn(usize) -> bool {
    const END_STATE: [usize; 3] = [OPERATOR, ZERO, NUM];
    |state: usize| END_STATE.contains(&state)
}

pub fn get_transition() -> impl Fn(char, usize) -> usize {
    const STATE_TABLE: [(usize, usize, usize, usize); 5] = [
        (0, 0, 0, 0), // ERROR
        (2, 1, 3, 4), // START
        (2, 1, 3, 4), // OPERATOR
        (2, 1, 0, 0), // ZERO
        (2, 1, 4, 4), // NUM
    ];
    let is_op = |c: char| match c {
        '-' | '+' | '*' | '/' | '(' | ')' => true,
        _ => false,
    };
    let is_whitespace = |c: char| match c {
        ' ' => true,
        _ => false,
    };
    let is_zero = |c: char| match c {
        '0' => true,
        _ => false,
    };
    let is_one_to_nine = |c: char| match c {
        '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => true,
        _ => false,
    };
    move |c: char, state: usize| {
        if is_op(c) {
            STATE_TABLE[state].0
        } else if is_whitespace(c) {
            STATE_TABLE[state].1
        } else if is_zero(c) {
            STATE_TABLE[state].2
        } else if is_one_to_nine(c) {
            STATE_TABLE[state].3
        } else {
            ERROR
        }
    }
}
// src/tokenizer.rs
pub type Token = (SyntaxKind, String);
pub type TokenStream = Vec<Token>;

pub struct Tokenizer {
    code: String,
    token_stream: TokenStream,
}

impl Tokenizer {
    pub fn new(code: String) -> Self {
        Tokenizer { code, token_stream: Vec::new() }
    }
    pub fn token_stream(&self) -> TokenStream {
        self.token_stream.to_owned()
    }
    pub fn run(&mut self) -> Result<(), String> {
        if self.code.len() == 0 {
            return Err("an empty string was received".to_string());
        }

        let transition = get_transition();
        let is_terminator = get_terminator_judgement();

        let mut idx = 0;
        let mut state = START;
        let mut prev_state = ERROR;

        let mut text_cache = String::new();
        while let Some(c) = self.code.chars().nth(idx) {
            state = transition(c, state);

            if state == ERROR {
                return Err(format!(
                    "unexpected token at the {} of the input, current cache: {}",
                    idx, text_cache
                ));
            } else if is_terminator(prev_state) && (state != prev_state || prev_state == OPERATOR) {
                self.push_token(&text_cache);
                text_cache.clear();
            }

            if c != ' ' {
                text_cache.push(c);
            }

            idx += 1;
            prev_state = state;
        }
        self.push_token(&text_cache);

        Ok(())
    }
    fn push_token(&mut self, text: &str) {
        let token = match SyntaxKind::from_operator(text) {
            Some(kind) => (kind, text.to_string()),
            None => (NUM, self.try_merge(text.to_string())),
        };
        self.token_stream.push(token);
    }
    fn try_merge(&mut self, mut text: String) -> String {
        let len = self.token_stream.len();
        if len >= 2 {
            let (k1, _) = self.token_stream[len - 2];
            let (k2, _) = self.token_stream[len - 1];
            match k1 {
                token!["+"] | token!["-"] | token!["*"] | token!["/"] | token!["("] => match k2 {
                    token!["-"] => {
                        self.token_stream.pop();
                        text.insert_str(0, "-")
                    }
                    token!["+"] => {
                        self.token_stream.pop();
                    }
                    _ => {}
                },
                _ => {}
            }
        } else if len == 1 {
            let (k1, _) = self.token_stream[len - 1];
            if k1 == token!["+"] || k1 == token!["-"] {
                self.token_stream.pop();
                match k1 {
                    token!["-"] => text.insert_str(0, "-"),
                    _ => {}
                }
            }
        }
        text
    }
}