实战:JSON解析器(状态机+递归下降)

JSON解析,听起来是不是有点唬人?其实说白了,就是把一段像 {"name":"张三","age":18} 这样的文本,变成程序能理解的数据结构。我当年第一次写解析器时,也觉得这东西高深莫测,后来发现核心就两个东西:状态机和递归下降。今天咱们就把这层窗户纸捅破。

为什么需要两种技术?

你想想看,JSON的结构其实分两层:

  • 词法层面:识别出花括号、冒号、引号、数字这些“单词”
  • 语法层面:把这些单词组合成对象、数组、键值对

状态机擅长做第一件事——它像流水线上的分拣员,一个个字符读进来,判断当前该进哪个状态。递归下降则擅长第二件事——它像俄罗斯套娃,一个函数调用另一个函数,层层嵌套地解析复杂结构。

核心思路:先用状态机做词法分析(Tokenizer),再用递归下降做语法分析(Parser)。两阶段分离,代码清晰到爆。

第一步:状态机实现词法分析

我个人习惯把词法分析器写成一个迭代器,每次返回一个Token。Token就是最小的语义单元,比如左花括号、字符串、数字等。

enum class TokenType {
    LBRACE, RBRACE,      // { }
    LBRACKET, RBRACKET,  // [ ]
    COLON, COMMA,        // : ,
    STRING, NUMBER,      // 字符串和数字
    TRUE, FALSE, NULL,   // 字面量
    END                  // 结束
};

struct Token {
    TokenType type;
    std::string value;
};

class JsonTokenizer {
public:
    explicit JsonTokenizer(const std::string& input) 
        : input_(input), pos_(0) {}

    Token nextToken() {
        skipWhitespace();
        if (pos_ >= input_.size()) return {TokenType::END, ""};

        char c = input_[pos_];
        switch (c) {
            case '{': pos_++; return {TokenType::LBRACE, "{"};
            case '}': pos_++; return {TokenType::RBRACE, "}"};
            case '[': pos_++; return {TokenType::LBRACKET, "["};
            case ']': pos_++; return {TokenType::RBRACKET, "]"};
            case ':': pos_++; return {TokenType::COLON, ":"};
            case ',': pos_++; return {TokenType::COMMA, ","};
            case '"': return readString();
            case 't': return readLiteral("true", TokenType::TRUE);
            case 'f': return readLiteral("false", TokenType::FALSE);
            case 'n': return readLiteral("null", TokenType::NULL);
            default:
                if (isdigit(c) || c == '-') return readNumber();
                throw std::runtime_error("Unexpected character");
        }
    }

private:
    const std::string& input_;
    size_t pos_;

    void skipWhitespace() {
        while (pos_ < input_.size() && isspace(input_[pos_])) pos_++;
    }

    Token readString() {
        pos_++; // 跳过开头的引号
        std::string value;
        while (pos_ < input_.size() && input_[pos_] != '"') {
            if (input_[pos_] == '\\') {
                pos_++;
                switch (input_[pos_]) {
                    case '"': value += '"'; break;
                    case '\\': value += '\\'; break;
                    case 'n': value += '\n'; break;
                    case 't': value += '\t'; break;
                    default: throw std::runtime_error("Invalid escape");
                }
            } else {
                value += input_[pos_];
            }
            pos_++;
        }
        if (pos_ >= input_.size()) throw std::runtime_error("Unterminated string");
        pos_++; // 跳过结尾的引号
        return {TokenType::STRING, value};
    }

    Token readNumber() {
        size_t start = pos_;
        if (input_[pos_] == '-') pos_++;
        while (pos_ < input_.size() && isdigit(input_[pos_])) pos_++;
        if (pos_ < input_.size() && input_[pos_] == '.') {
            pos_++;
            while (pos_ < input_.size() && isdigit(input_[pos_])) pos_++;
        }
        return {TokenType::NUMBER, input_.substr(start, pos_ - start)};
    }

    Token readLiteral(const std::string& expected, TokenType type) {
        if (input_.substr(pos_, expected.size()) != expected) {
            throw std::runtime_error("Invalid literal");
        }
        pos_ += expected.size();
        return {type, expected};
    }
};

避坑指南:我曾经在解析字符串时忘了处理转义字符,结果线上服务收到一个带反斜杠的JSON直接崩溃。记住,\"\\\n 这些都得老老实实处理。

第二步:递归下降实现语法分析

有了Token流,解析就变成了“看下一个Token是什么,然后决定怎么走”。递归下降的核心就是:每个语法规则对应一个函数,函数里可以调用其他函数。

class JsonParser {
public:
    explicit JsonParser(JsonTokenizer& tokenizer) 
        : tokenizer_(tokenizer), current_(tokenizer.nextToken()) {}

    JsonValue parse() {
        return parseValue();
    }

private:
    JsonTokenizer& tokenizer_;
    Token current_;

    void consume(TokenType expected) {
        if (current_.type != expected) {
            throw std::runtime_error("Unexpected token");
        }
        current_ = tokenizer_.nextToken();
    }

    JsonValue parseValue() {
        switch (current_.type) {
            case TokenType::LBRACE:  return parseObject();
            case TokenType::LBRACKET: return parseArray();
            case TokenType::STRING: {
                std::string val = current_.value;
                consume(TokenType::STRING);
                return JsonValue(val);
            }
            case TokenType::NUMBER: {
                double val = std::stod(current_.value);
                consume(TokenType::NUMBER);
                return JsonValue(val);
            }
            case TokenType::TRUE:
                consume(TokenType::TRUE);
                return JsonValue(true);
            case TokenType::FALSE:
                consume(TokenType::FALSE);
                return JsonValue(false);
            case TokenType::NULL:
                consume(TokenType::NULL);
                return JsonValue(nullptr);
            default:
                throw std::runtime_error("Invalid value");
        }
    }

    JsonValue parseObject() {
        consume(TokenType::LBRACE);
        std::map<std::string, JsonValue> members;
        if (current_.type != TokenType::RBRACE) {
            do {
                if (current_.type != TokenType::STRING) {
                    throw std::runtime_error("Expected string key");
                }
                std::string key = current_.value;
                consume(TokenType::STRING);
                consume(TokenType::COLON);
                members[key] = parseValue();
            } while (current_.type == TokenType::COMMA && (consume(TokenType::COMMA), true));
        }
        consume(TokenType::RBRACE);
        return JsonValue(members);
    }

    JsonValue parseArray() {
        consume(TokenType::LBRACKET);
        std::vector<JsonValue> elements;
        if (current_.type != TokenType::RBRACKET) {
            do {
                elements.push_back(parseValue());
            } while (current_.type == TokenType::COMMA && (consume(TokenType::COMMA), true));
        }
        consume(TokenType::RBRACKET);
        return JsonValue(elements);
    }
};

注意:递归下降解析器对递归深度有限制。如果JSON嵌套超过1000层,可能会栈溢出。我在项目中遇到过这种情况——一个配置文件的数组嵌套了2000层,直接导致程序崩溃。解决方案是改用迭代式解析,或者增大栈空间。

整体架构图

下面这张图展示了整个解析器的数据流。我建议你把它打印出来贴在工位上,写代码时对照着看。

JSON解析器架构图 原始JSON字符串 状态机 → 词法分析器 (Tokenizer) 逐个字符读取,输出Token流 Token 流 递归下降 → 语法分析器 (Parser) 解析Token流,构建JsonValue树 JsonValue 内存中的树结构

JsonValue的设计

解析出来的结果需要一种统一的数据结构来承载。我推荐用 std::variant,C++17之后这东西太好用了。

class JsonValue {
public:
    using Object = std::map<std::string, JsonValue>;
    using Array = std::vector<JsonValue>;

    // 各种构造函数
    JsonValue() : data_(nullptr) {}
    JsonValue(std::nullptr_t) : data_(nullptr) {}
    JsonValue(bool b) : data_(b) {}
    JsonValue(double n) : data_(n) {}
    JsonValue(const std::string& s) : data_(s) {}
    JsonValue(const char* s) : data_(std::string(s)) {}
    JsonValue(const Object& obj) : data_(obj) {}
    JsonValue(const Array& arr) : data_(arr) {}

    // 类型判断
    bool isNull() const { return std::holds_alternative<std::nullptr_t>(data_); }
    bool isBool() const { return std::holds_alternative<bool>(data_); }
    bool isNumber() const { return std::holds_alternative<double>(data_); }
    bool isString() const { return std::holds_alternative<std::string>(data_); }
    bool isObject() const { return std::holds_alternative<Object>(data_); }
    bool isArray() const { return std::holds_alternative<Array>(data_); }

    // 取值方法(带安全检查)
    double asNumber() const {
        if (!isNumber()) throw std::runtime_error("Not a number");
        return std::get<double>(data_);
    }
    std::string asString() const {
        if (!isString()) throw std::runtime_error("Not a string");
        return std::get<std::string>(data_);
    }
    const Object& asObject() const {
        if (!isObject()) throw std::runtime_error("Not an object");
        return std::get<Object>(data_);
    }
    const Array& asArray() const {
        if (!isArray()) throw std::runtime_error("Not an array");
        return std::get<Array>(data_);
    }

private:
    std::variant<std::nullptr_t, bool, double, std::string, Object, Array> data_;
};

小技巧:用 std::variant 比用 union 安全得多。我以前用union写过一个JSON库,结果因为忘记调用析构函数导致内存泄漏,排查了两天才找到问题。variant自动管理生命周期,省心不少。

完整使用示例

int main() {
    std::string json = R"({
        "name": "张三",
        "age": 28,
        "skills": ["C++", "Python", "Rust"],
        "address": {
            "city": "北京",
            "zip": "100000"
        },
        "active": true,
        "salary": null
    })";

    try {
        JsonTokenizer tokenizer(json);
        JsonParser parser(tokenizer);
        JsonValue result = parser.parse();

        // 访问数据
        auto& obj = result.asObject();
        std::cout << "姓名: " << obj.at("name").asString() << std::endl;
        std::cout << "年龄: " << obj.at("age").asNumber() << std::endl;

        auto& skills = obj.at("skills").asArray();
        std::cout << "技能: ";
        for (const auto& skill : skills) {
            std::cout << skill.asString() << " ";
        }
        std::cout << std::endl;

    } catch (const std::exception& e) {
        std::cerr << "解析失败: " << e.what() << std::endl;
    }

    return 0;
}

性能对比

解析方式 代码量 可读性 性能 适用场景
状态机 + 递归下降 中等(约300行) 良好 通用JSON解析
纯状态机 大(约500行) 优秀 嵌入式/高性能场景
第三方库(nlohmann/json) 极小 极高 优秀 生产环境首选

我的建议:如果是学习目的,自己手写一遍状态机和递归下降,绝对能让你对编译原理的理解上一个台阶。如果是生产环境,直接用 nlohmann/json 或者 rapidjson,别重复造轮子。我自己在项目里就吃过这个亏——花了一周写的解析器,性能还不如人家成熟库的一半。

嗯,到这里JSON解析器的核心内容就讲完了。你可能会问:为什么不用正则表达式?正则处理简单JSON还行,一旦遇到嵌套结构就抓瞎了。状态机+递归下降的组合,才是处理结构化文本的正道。


公众号:蓝海资料掘金营,微信deep3321