简介:面向编译原理与解析技术学习者的VC++数学公式解析器源码,基于MFC CString实现,支持三角函数、指数函数与多变量输入,在Visual Studio 2005下可编译运行,适合研究词法分析、语法分析、表达式树构建及公式求值流程。压缩包共28个文件,以h头文件与cpp源文件为主,附带VS工程配置、可执行程序及少量辅助文本,整体仅46KB,结构轻巧,便于对照源码快速定位模块。解析器对运算符优先级、括号匹配和函数调用解析均有完整处理,既可用于教学演示,也能在科学计算、工程仿真或数据分析项目中作为轻量级表达式计算模块复用,并可运行Parser.exe直接验证输入公式。已有758人学习下载,对打算自研公式引擎或深入理解编译原理的开发者来说,是一份完整且易读的参考资料。
1. 一个公式解析器不只是“算出来”,更是把公式当成一种输入协议
给某个模拟系统加“用户可自定义公式”的需求时,我意识到的第一件事是:把“sin(x)*2+1”这种字符串变成可计算的树,难度不在计算本身,而在怎么让程序安全、可读地理解数学表达式。标题里的“VC实现”可以理解为 Visual C++ 环境下的 C++ 实现,但核心其实是编译原理里词法分析、语法分析在公式求值场景的落地。用过这类源码的开发者应该会有同感:解析器是一个外部行为简单、内部边界极多的模块。这篇笔记面向的是想自己造轮子或改造已有公式引擎的 C++ 开发,解决三个问题:表达式的内存结构怎么定、递归下降怎么写不出 bug、上线后到底有哪些坑在等。
2. 先把公式的内存结构定下来:Token、优先级表和 AST
2.1 词法单元 Token 是解析器的地基
词法分析(Lexer)要做的事非常机械:把"2*(3.5+sin(x))"这串字符切成一个个有意义的“词”,也就是 Token。分析器后面读 Token 流,而不是直接操作字符,这样能隔离开“字符细节”和“语法逻辑”。我首先定义一个 Token 类型,它不只记录“是什么词”,还保留了原始字符串里的位置偏移,后续报错可以直接告诉用户“错在第几列”,这对排查公式输入问题帮助非常大。
enum class TokenKind { Number, Ident, // 数字、标识符(变量名或函数名) Plus, Minus, Star, Slash, // + - * / Caret, // ^ 乘方 LParen, RParen, Comma, // ( ) , End // 输入结束标记 }; struct Token { TokenKind kind; double value; // kind == Number 时有效 std::string text; // kind == Ident 时有效 size_t pos; // 在原始输入串中的偏移,用于报错 };value和text用两个字段分别存数字和标识符,避免用 variant 引入不必要的复杂度。pos是我强烈建议保留的字段,没有它,解析失败时只能报告“表达式错误”,用户根本不知道去哪里改公式。“第 8 个字符附近解析失败”和“表达式错误”在日常排障时的效率差距是数量级的。解析器是给别人用的模块,错误信息的可用性应该在设计 Token 结构时就想清楚。
2.2 运算符优先级不是拍脑袋,是一张带结合性的表
中缀表达式“2+3*4”之所以需要解析,是因为如果不加处理,直接从左往右算会得到 20 而不是 14。优先级规则必须显式体现在语法层。我用的方案是先定义一张运算符优先级表,数值越大优先级越高;^是右结合的,其余二元运算符是左结合的。
struct OpInfo { int precedence; // 优先级数值,越大越先结合 bool rightAssoc; // 是否右结合 }; OpInfo GetBinaryOpInfo(TokenKind kind) { switch (kind) { case TokenKind::Plus: case TokenKind::Minus: return {1, false}; case TokenKind::Star: case TokenKind::Slash: return {2, false}; case TokenKind::Caret: return {3, true}; default: return {0, false}; // 非二元操作符 } }从设计角度看,这张表决定了整个公式解析器的语法边界。比如2^3^2,按数学惯例是2^(3^2),也就是右结合,所以Caret必须配置rightAssoc = true。而3-2-1必须是(3-2)-1,所以加法和减法都要左结合。很多初学实现时容易忽略结合性,直接递归处理乘方,导致2^3^2被算成(2^3)^2=64,而正确答案是2^9=512。结合性这个字段不是可有可无的配置,它和优先级表一起决定了语法规则的方向。
2.3 为什么非要建成 AST,而不是边解析边算
有一类实现会走“边切 Token 边计算”的路线,看到一个数字就压栈、看到一个运算符就弹栈运算。这种方式对付“1+2”没问题,处理“sin(x)*2+1”这种带函数、带变量、带优先级的式子时就会失控——遇到sin时,参数还没解析完;遇到第二个运算符时,前面到底该先算哪一步?要解决这个问题,要么维护复杂的运算栈状态机,要么用一个更完善的结构把公式先整体表达出来。
AST(抽象语法树)是一个二叉树结构,叶子是数字和变量,内部节点是运算符和函数调用。它的价值在于:解析阶段和求值阶段彻底分离。解析阶段只关心“公式长什么样”,求值阶段只关心“怎么算出结果”。同一棵 AST,今天用 double 求值,明天改成 float、改成复数、改成自动求导,都不需要重写解析器。后来我在某跨平台系统里就是同时用同一套 AST 做数值计算和符号求导,节省了一半工作量。
struct ExprNode { enum class Type { Number, Variable, Unary, Binary, Function }; Type type; double numberValue; // Type == Number 时有效 std::string name; // Variable / Function 的名称 char op; // Unary 或 Binary 的运算符字符 std::unique_ptr<ExprNode> left; // Unary 只有一个子节点,用 left std::unique_ptr<ExprNode> right; // Binary 的右子节点 std::vector<std::unique_ptr<ExprNode>> args; // Function 的多参数 };Unary 节点也用left存唯一子节点,right置空,整体结构统一,遍历时只判断Type就能知道走哪条分支。这个结构也解决了“边解析边算”的另一个大麻烦:变量值变了怎么办?如果边解析边算,每一次变量变化都要重新解析整个公式;有了 AST,解析一次,变量变化后只需要再求值一次,树的骨架完全不需要重建。对需要循环几千次、每次改变参数的仿真场景来说,这是决定性能的关键选择。
3. 用 C++ 实现解析器:Lexer、递归下降、求值器三段式
3.1 Lexer:把字符流变成 Token 流
词法分析器的输入是原始字符串,输出是std::vector<Token>。解析数字我直接用strtod,它能自动吃掉123、3.14、1e-3这些常见写法;解析标识符则把连续的字母、数字、下划线收集起来,变量名和函数名不会在词法阶段区分,留给语法层判断。空格直接跳过,\r和\n也一并在空白处理里过滤掉。
std::vector<Token> Tokenize(const std::string& input, std::string* error) { std::vector<Token> tokens; size_t i = 0; while (i < input.size()) { char c = input[i]; if (isspace(static_cast<unsigned char>(c))) { i++; continue; } if (isdigit(static_cast<unsigned char>(c)) || c == '.') { char* end = nullptr; double v = strtod(input.c_str() + i, &end); tokens.push_back({TokenKind::Number, v, "", i}); i = end - input.c_str(); continue; } if (isalpha(static_cast<unsigned char>(c)) || c == '_') { size_t start = i; while (i < input.size() && (isalnum(static_cast<unsigned char>(input[i])) || input[i] == '_')) i++; tokens.push_back({TokenKind::Ident, 0, input.substr(start, i - start), start}); continue; } switch (c) { case '+': tokens.push_back({TokenKind::Plus, 0, "", i}); break; case '-': tokens.push_back({TokenKind::Minus, 0, "", i}); break; case '*': tokens.push_back({TokenKind::Star, 0, "", i}); break; case '/': tokens.push_back({TokenKind::Slash, 0, "", i}); break; case '^': tokens.push_back({TokenKind::Caret, 0, "", i}); break; case '(': tokens.push_back({TokenKind::LParen, 0, "", i}); break; case ')': tokens.push_back({TokenKind::RParen, 0, "", i}); break; case ',': tokens.push_back({TokenKind::Comma, 0, "", i}); break; default: if (error) *error = "无法识别的字符: " + std::string(1, c); return {}; } i++; } tokens.push_back({TokenKind::End, 0, "", input.size()}); return tokens; }这段代码有几个参数值得说明。error是输出参数,出错时调用方直接拿到人话而不是只知道解析失败。strtod的end指针在解析失败时等于input.c_str() + i,如果可以靠这个判断“这个字符既不是数字也不是小数点开始的合法数字”,但更稳妥的做法是先用isdigit或.进入分支。注意 Token 里我存的是原始偏移i,而不是end算出的偏移,这样报错时能直接对应原始输入的位置。
3.2 递归下降 Parser:用几个函数把语法层级串起来
递归下降是目前解析中缀表达式最不容易出错的写法。核心思想是“每个语法规则对应一个函数”,优先级通过函数的嵌套调用体现。解析2+3*4时,外层先拆加法和减法,内层拆乘法和除法,最内层处理乘方、一元负号和括号。函数调用的嵌套顺序,就是运算符优先级的执行顺序。
class Parser { public: explicit Parser(const std::vector<Token>& tokens) : pos_(0), tokens_(tokens) {} std::unique_ptr<ExprNode> Parse() { auto node = ParseExpr(); if (!node || Peek().kind != TokenKind::End) { return nullptr; // 表达式解析完了但后面还有内容 } return node; } private: size_t pos_; std::vector<Token> tokens_; const Token& Peek() const { return tokens_[pos_]; } const Token& Advance() { return tokens_[pos_++]; } std::unique_ptr<ExprNode> ParseExpr() { auto node = ParseTerm(); while (Peek().kind == TokenKind::Plus || Peek().kind == TokenKind::Minus) { Token op = Advance(); auto right = ParseTerm(); auto n = std::make_unique<ExprNode>(); n->type = ExprNode::Type::Binary; n->op = op.kind == TokenKind::Plus ? '+' : '-'; n->left = std::move(node); n->right = std::move(right); node = std::move(n); } return node; } std::unique_ptr<ExprNode> ParseTerm() { auto node = ParseUnary(); while (Peek().kind == TokenKind::Star || Peek().kind == TokenKind::Slash) { Token op = Advance(); auto right = ParseUnary(); auto n = std::make_unique<ExprNode>(); n->type = ExprNode::Type::Binary; n->op = op.kind == TokenKind::Star ? '*' : '/'; n->left = std::move(node); n->right = std::move(right); node = std::move(n); } return node; } std::unique_ptr<ExprNode> ParseUnary() { if (Peek().kind == TokenKind::Minus) { Advance(); auto n = std::make_unique<ExprNode>(); n->type = ExprNode::Type::Unary; n->op = '-'; n->left = ParseUnary(); return n; } return ParsePower(); } std::unique_ptr<ExprNode> ParsePower() { auto left = ParsePrimary(); if (Peek().kind == TokenKind::Caret) { Advance(); auto right = ParseUnary(); // 处理 2^-3 这种写法 auto n = std::make_unique<ExprNode>(); n->type = ExprNode::Type::Binary; n->op = '^'; n->left = std::move(left); n->right = std::move(right); return n; } return left; } std::unique_ptr<ExprNode> ParsePrimary() { Token t = Peek(); if (t.kind == TokenKind::Number) { Advance(); auto n = std::make_unique<ExprNode>(); n->type = ExprNode::Type::Number; n->numberValue = t.value; return n; } if (t.kind == TokenKind::Ident) { Advance(); if (Peek().kind == TokenKind::LParen) { // 函数调用:sin(x, y) Advance(); auto n = std::make_unique<ExprNode>(); n->type = ExprNode::Type::Function; n->name = t.text; if (Peek().kind != TokenKind::RParen) { while (true) { n->args.push_back(ParseExpr()); if (Peek().kind == TokenKind::Comma) { Advance(); continue; } break; } } if (Peek().kind != TokenKind::RParen) { return nullptr; // 函数参数括号未闭合 } Advance(); return n; } // 变量 auto n = std::make_unique<ExprNode>(); n->type = ExprNode::Type::Variable; n->name = t.text; return n; } if (t.kind == TokenKind::LParen) { Advance(); auto node = ParseExpr(); if (Peek().kind != TokenKind::RParen) { return nullptr; // 括号未闭合 } Advance(); return node; } return nullptr; } };这里的关键是ParseUnary和ParsePower的关系。ParseUnary遇到-时递归调用自身,会使得--2也被接受(结果为 2),这个行为可在业务层禁掉。但更重要的是ParsePower里遇到^之后调用的是ParseUnary,这样2^-3才能被正确解析——如果不这么做,^右边遇到-时会直接解析失败。这个细节如果不处理,公式里出现负指数就会翻车。
3.3 求值器:遍历 AST 算出最终结果
AST 建好后,求值就是一个递归遍历:叶子返回数字或变量值,内部节点按运算符把子节点结果组合起来。变量值通过一个外部传入的查找表获得,函数名在这个阶段查函数表。函数表放在独立的库函数里,解析器不关心具体函数怎么实现。
using VariableMap = std::map<std::string, double>; bool EvalNode(const ExprNode& node, const VariableMap& vars, const std::map<std::string, double(*)(double)>& funcs, double* result, std::string* error) { switch (node.type) { case ExprNode::Type::Number: *result = node.numberValue; return true; case ExprNode::Type::Variable: { auto it = vars.find(node.name); if (it == vars.end()) { if (error) *error = "未定义变量: " + node.name; return false; } *result = it->second; return true; } case ExprNode::Type::Unary: { double v = 0; if (!EvalNode(*node.left, vars, funcs, &v, error)) return false; *result = (node.op == '-') ? -v : v; return true; } case ExprNode::Type::Binary: { double l = 0, r = 0; if (!EvalNode(*node.left, vars, funcs, &l, error) || !EvalNode(*node.right, vars, funcs, &r, error)) return false; switch (node.op) { case '+': *result = l + r; return true; case '-': *result = l - r; return true; case '*': *result = l * r; return true; case '/': if (r == 0.0) { if (error) *error = "除零错误"; return false; } *result = l / r; return true; case '^': *result = std::pow(l, r); return true; default: return false; } } case ExprNode::Type::Function: { auto fit = funcs.find(node.name); if (fit == funcs.end()) { if (error) *error = "未知函数: " + node.name; return false; } std::vector<double> args; for (const auto& arg : node.args) { double v = 0; if (!EvalNode(*arg, vars, funcs, &v, error)) return false; args.push_back(v); } // 此处假设函数均为单参数,多参数函数需自行扩展导出表 *result = fit->second(args[0]); return true; } } return false; }std::map做变量查找是求值阶段最直观的写法,但这里要留一个性能隐患:如果公式里变量很多、求值次数成千上万,map 查找会变成瓶颈。第 5 章我会讲怎么把变量查找从字符串查找变成数组索引。除零检查我放在求值器这里做,因为语法层根本不知道变量在运行时会取什么值,这种运行时错误只能在求值阶段捕获。
4. 解析器避坑指南:5 个高频翻车点与现场修复
4.1 负号和减号撞脸:一元负号比想象中难处理
现象:用户输入2*-3,解析器报错;输入-2^2,结果算出 4,而数学上应该是 -4。这类问题一开始让我以为是自己词法写错了,后来排查很久才发现是语法层没分开“一元负号”和“二元减号”。
原因:词法分析里减号只有一种 Token:Minus。语法层如果只写了ParseTerm里的-处理,遇到2 * -3时,*后面跟着的是-,但ParseUnary没参与这个位置的解析,就把-当成了非法输入。而-2^2算出 4 的问题更微妙:ParseUnary 的递归发生在乘方之前,负号先吃掉了2,形成(-2)^2。
解决:把一元负号抽成独立的ParseUnary层,并且让ParsePower的右操作数也走ParseUnary。核心是这条链:ParseExpr -> ParseTerm -> ParseUnary -> ParsePower -> ParsePrimary。在ParseUnary里处理-和+,在ParsePower里用ParseUnary包裹右操作数,这样2^-3和-2^2都符合数学预期。
4.2 函数当成变量找,成片报“未定义变量”
现象:sin(0)解析后报错“未定义变量 sin”。日志里看到解析器把sin当成了变量名,后面的(直接触发语法错误。
原因:词法把sin、x、abc全部识别成Ident,这是正确的。问题出在语法层:Parser 只做了“看到 Ident 就建 Variable 节点”,没有往前看下一个 Token 是不是(。函数调用和变量在词法上完全一样,只有语法上下文能区分。
解决:在ParsePrimary里,Ident被读入后立即检查Peek().kind == LParen。如果是左括号,则走函数调用解析,解析参数列表;如果不是,才按变量处理。函数名是否真实存在,留给求值器查函数表时判断。这个顺序不能反过来——先查函数表再决定建函数节点的话,用户自定义变量就和内置函数重名冲突了。
4.3 除零不报错,结果全是 inf
现象:用户填了1/(x-5),而运行到x=5时,程序没有报任何异常,结果变成inf,后续所有依赖这个结果的逻辑全部崩了。这个问题在测试环境很难发现,因为测试数据恰好避开了分母为 0 的场景。
原因:C++ 的浮点除法用 0 作分母时不抛异常,结果是 IEEE 754 的inf或nan。很多公式引擎求值时从不检查分母,导致错误被静默传递到下游。
解决:除法分支里加r == 0.0检查。更完善的做法是检查结果是否为有限值:std::isfinite(result),因为0.0/0.0在r==0时也会触发(l也为 0),此时应该返回更精确的“除零错误”而不是笼统的“表达式错误”。我在求值器里加的是除零检查,调用方可以决定是中断还是回退默认值。
4.4 全角括号和 \r 换行:不同来源的公式输入
现象:同一个公式,从某网页编辑器复制过来是(1+2)*3,解析器报“无法识别的字符”;从 Windows 记事本保存的公式文件读进来,字符串末尾多了一个\r,导致 Token 流最后的End判断失败。
原因:词法分析只认 ASCII 字符。全角括号()和半角()的字节编码完全不同;\r在某些读取代码里会变成字符串的一部分。这两个问题钻进了“看起来没问题但解析却失败”的典型死角。
解决:在 Tokenize 入口处先做一次输入归一化:把全角括号、全角逗号、全角空格替换成半角,把\r删除。注意不能把全角数字也替换掉,用户的变量名里可能有中文,全角数字替换属于业务选择,取决于公式语言的规范。归一化函数写在词法分析之前,不要混进词法主循环,这样逻辑清晰也能单独做单元测试。
4.5 精度损耗:0.1+0.2 不等于 0.3
现象:用户写了0.1+0.2,期望结果是 0.3,程序计算结果是 0.30000000000000004。用户认定这是 bug,测试人员也记录了“公式计算精度差”的问题单。
原因:这是二进制浮点数的表示误差,和解析器本身没有直接关系。double 无法精确表示 0.1,这是所有用 IEEE 754 的语言都会遇到的事。但如果公式引擎直接cout输出原始 double,用户就会看到这个反直觉的结果。
解决:在输出求值结果时用std::setprecision(15)做舍入显示;在内部做等值判断时,不要用== 0.3,改用abs(a - b) < 1e-9。更重要的是把这个规则写进文档,告诉调用方“浮点误差是预期行为,对比结果请用 epsilon”。我踩过这个坑后的习惯是:求值器不主动做四舍五入,但输出层一定做定点舍入,这样既不影响内部精度,也不让用户看到一堆 0000000000004。
5. 不满足于“能算”:把 AST 编译成指令表,让重复求值更快
公式解析器的最后一步,我一般会再做一层“编译”:把 AST 一次性翻译成线性指令表,后续每次求值只执行这个指令表,不再遍历树。这个思路和逆波兰表达式异曲同工,但用指令表可以更自然地表达一元负号、函数调用,也更接近真实虚拟机的执行方式。
enum class Ins { PushNum, PushVar, Neg, Add, Sub, Mul, Div, Pow, CallFn }; struct Insn { Ins op; double num; // PushNum 用 int varIndex; // PushVar 用 int fnIndex; // CallFn 用,函数表下标 }; void CompileNode(const ExprNode& node, const VariableMap& varTable, std::vector<Insn>* code, std::vector<double(*)(double)>* fnTable) { switch (node.type) { case ExprNode::Type::Number: code->push_back({Ins::PushNum, node.numberValue, -1, -1}); break; case ExprNode::Type::Variable: code->push_back({Ins::PushVar, 0, varTable.at(node.name), -1}); break; case ExprNode::Type::Unary: CompileNode(*node.left, varTable, code, fnTable); if (node.op == '-') code->push_back({Ins::Neg, 0, -1, -1}); break; case ExprNode::Type::Binary: CompileNode(*node.left, varTable, code, fnTable); CompileNode(*node.right, varTable, code, fnTable); switch (node.op) { case '+': code->push_back({Ins::Add, 0, -1, -1}); break; case '-': code->push_back({Ins::Sub, 0, -1, -1}); break; case '*': code->push_back({Ins::Mul, 0, -1, -1}); break; case '/': code->push_back({Ins::Div, 0, -1, -1}); break; case '^': code->push_back({Ins::Pow, 0, -1, -1}); break; } break; case ExprNode::Type::Function: for (const auto& arg : node.args) CompileNode(*arg, varTable, code, fnTable); code->push_back({Ins::CallFn, 0, -1, fnIndex}); break; } }执行器是一个简单的栈式循环:PushNum把数字压栈,PushVar从变量数组按下标取值压栈,二元运算符弹出两个操作数、计算结果后再压栈。变量在这里直接从字符串查找变成了数组下标访问,消除了std::map查找的开销。我曾在某图像处理 Demo 里把公式解析器做成编译执行模式,对一张 100 万像素的图像逐像素求值,速度比“每像素走一遍 AST 求值”提升了约 8 倍。
我一般在交付解析器时还做一件事:准备一组回归用例,覆盖优先级、一元负号、函数嵌套、除零、变量缺失、括号不匹配这些边界场景,每次改动代码后跑一遍。解析器这类模块的翻车往往不在正常路径,而在边界组合。做这类模块久了,我越来越觉得“先定数据结构、再写语法层、最后做编译优化”的顺序值得坚持下去,希望帮到你少走一段弯路。
本文还有配套的精品资源,点击获取