资讯动态

用Qt/C++实现支持短路求值的逻辑表达式计算器

发布时间:2026/10/13 6:48:41 来源:尧图企业网站定制
1. 为什么要做“可以算逻辑表达式”的计算器先说一下最开始的想法。Qt计算器教程网上一搜一大把基本都是做个四则运算数字按钮加减乘除再放个等号能算出12 34就算完工。这类项目做起来虽然快但交出去之后总觉得差点意思——它没有解决任何书本之外的问题也缺少一个让人“眼前一亮”的点。所以我当时给自己定的目标是做一个能用键盘和鼠标操作、支持括号、同时还能计算逻辑表达式的计算器。这里的“逻辑运算”不是指if (a b c d)这种编程语言里的判断而是实打实的表达式计算比如用户能直接输入1 2 * 3 4 (5 - 2 3) || !false计算器能算出true并且整个计算过程遵循逻辑运算的短路规则和优先级规则。这样做的好处是它从“普通四则运算工具”变成一个“表达式求值引擎”技术上能顺带把运算符优先级、表达式解析、状态机、界面事件分发这些点全部覆盖作为一个练习项目含金量立刻不一样。这篇文章就按我实际开发的顺序来写从哪里入手、界面怎么搭、解析引擎的核心怎么写、键盘交互怎么做、最后怎么验证正确性。代码以 Qt Widgets 和 C 为例逻辑本身不依赖特定 Qt 版本用 5.15 或 6.x 都行。2. 环境准备与项目结构2.1 用 Qt Widgets 还是 QML我选的是 Qt Widgets原因有两个一是这类桌面小工具用 Widgets 写起来直接信号槽一套组合拳就能解决按钮和键盘事件不需要引入 QML 的复杂绑定二是大部分读者对QGridLayout、QPushButton、QLabel这套东西更熟悉调试起来比 QML 直观。用 QML 也能实现但 Widgets 版本在“写课设”或“本地工具”场景下更稳妥编译出来的体积也小一些。2.2 创建项目时的关键配置创建 Qt Widgets Application 之后需要改两个地方。第一.pro文件里开启 C17因为后面解析表达式时会用到std::optional、结构化绑定这类特性QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c17 TARGET LogicCalc TEMPLATE app SOURCES main.cpp mainwindow.cpp exprparser.cpp HEADERS mainwindow.h exprparser.h第二为了后面键盘输入的流畅性需要给主窗口设置一个较强的焦点策略否则点击按钮之后按钮会把焦点抢走导致键盘按键事件失灵。这一步在main.cpp里初始化主窗口时顺手处理#include mainwindow.h #include QApplication int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }焦点问题的细节我在后面第 5 节专门讲这里只需要记住一个结论不要让按钮默认抢焦点。2.3 文件划分思路整个项目我拆成三个部分文件职责mainwindow.h/cpp界面搭建、按钮事件、键盘事件、显示刷新exprparser.h/cpp表达式解析与求值只暴露一个静态接口main.cpp程序入口解析引擎单独放一个文件非常重要。界面和计算逻辑一旦混在一起调试时你会分不清是布局问题还是计算问题。而且引擎单独成文件之后你可以在控制台程序里直接搞单元测试不用每次打开图形界面手工点按钮。我后面会专门讲自己是怎么做验证的这是整个开发过程中最值回票价的一个习惯。3. UI 布局不用 Qt Designer纯代码搭建的管理经验3.1 为什么不用 Designer正常开发中很多人习惯用 Qt Designer 拖控件。我这次特意不用 Designer全部用代码布局。原因很实际造这个计算器时我需要动态调整按钮之间的间距、字体大小、显示区域的高度尤其是当我想在“普通模式”和“展开逻辑运算符”两组按键之间切换时Designer 生成的 UI 文件并不适合动态增删组件。代码布局更适合做这种“运行时结构变化”的界面。另一个原因是把布局逻辑写进代码之后任何时候重新打开项目不用额外加载.ui文件也能在脑子里浮现出整个界面的结构对理解和维护更友好。3.2 布局结构三块区域整个窗口是一个垂直方向的大盒子从上到下依次是显示区一个QLabel右对齐字体用等宽字体用来显示当前输入表达式和计算结果按键区这里我分了两组一组是数字和四则运算键0-9、、-、*、/、(、)、.、、C另一组是逻辑运算键、、、!、、||、!、true、false状态提示区一个小QLabel用来显示当前是否输入未闭合括号、表达式是否合法等。按键区的控件用QGridLayout组织。数字键部分我用 4 列逻辑键部分我单独横向排了一行这样视觉上不拥挤也比把几十个按钮全塞进一个网格里容易管理。3.3 核心布局代码布局对象之间的关系如下// mainwindow.cpp 的关键片段 MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { QWidget *central new QWidget(this); setCentralWidget(central); QVBoxLayout *rootLayout new QVBoxLayout(central); rootLayout-setSpacing(8); // 显示区 m_display new QLabel(0, central); m_display-setAlignment(Qt::AlignRight | Qt::AlignVCenter); m_display-setMinimumHeight(60); m_display-setStyleSheet( QLabel { background:#f8f8f8; border:1px solid #ccc; border-radius:4px; padding:8px; font-family:monospace; font-size:24px; } ); // 状态提示区 m_stateLabel new QLabel(central); m_stateLabel-setStyleSheet(QLabel { color:#888; font-size:13px; }); rootLayout-addWidget(m_display); rootLayout-addWidget(m_stateLabel); // 数字和四则运算按键区 QGridLayout *btnLayout new QGridLayout(); btnLayout-setSpacing(4); const char *digitKeys[] { 7, 8, 9, /, 4, 5, 6, *, 1, 2, 3, -, 0, ., C, , (, ), , }; for (int i 0; i 5; i) { for (int j 0; j 4; j) { QString text digitKeys[i * 4 j]; if (text.isEmpty()) continue; QPushButton *btn new QPushButton(text, this); btn-setFocusPolicy(Qt::NoFocus); // 关键不让按钮抢焦点 btn-setMinimumHeight(48); connect(btn, QPushButton::clicked, this, [this, text]() { onButtonClicked(text); }); btnLayout-addWidget(btn, i, j); } } // 逻辑运算按键区 QHBoxLayout *logicLayout new QHBoxLayout(); const char *logicKeys[] { , , , !, , ||, !, true, false }; for (const char *key : logicKeys) { QPushButton *btn new QPushButton(QString::fromLatin1(key), this); btn-setFocusPolicy(Qt::NoFocus); btn-setMinimumHeight(40); btn-setStyleSheet(QPushButton { color:#2c3e50; }); connect(btn, QPushButton::clicked, this, [this, key]() { onButtonClicked(QString::fromLatin1(key)); }); logicLayout-addWidget(btn); } rootLayout-addLayout(btnLayout); rootLayout-addLayout(logicLayout); rootLayout-addStretch(1); resize(360, 420); }这代码里唯一一个容易被忽略但非常重要的点就是setFocusPolicy(Qt::NoFocus)。如果不加这一行你用鼠标点过数字键之后键盘的KeyPress事件就到不了主窗口了因为焦点停留在按钮上。3.4 对布局的补充说明纯代码布局的好处是灵活不好处是没法可视化预览所以我在写的过程中反复调整过数字键的排列顺序。最后确定的排列是第一行7 8 9 /第二行4 5 6 *第三行1 2 3 -第四行0 . C 第五行( ) 这个布局参考了市面上大多数计算器的习惯但第五行的括号键放在右下区域是为了让人用右手操作用鼠标时能顺手按到。逻辑键单独放一排视觉上也不会和数字键混在一起导致误触。true和false这两个键在布局上很有用因为逻辑表达式里经常要输入布尔字面量。如果没有这两个键用户得一个字母一个字母打体验很差。4. 表达式解析引擎逻辑运算的优先级与短路求值4.1 中缀表达式求值的整体思路计算器输入的是中缀表达式比如1 2 * 3直接从左往右算会得出错误结果9正确结果是7因为乘法优先级高。经典的解决办法有两种转后缀表达式逆波兰再求值或者直接用递归下降解析。我采用的是递归下降解析法。原因有两个一是支持短路求值方便第二是错误定位容易。递归下降把每个优先级级别写成一个函数遇到语法错误时可以明确知道是在解析哪个层级时出错的调试起来比操作符栈直观得多。简单解释一下递归下降的思路表达式由“一层套一层”的子表达式组成每一层都有明确的优先级。解析时从最低优先级逻辑或开始逐层向下直到最基本的“单元”数字、布尔字面量、括号内的子表达式。4.2 运算符优先级表这是整个引擎最核心的一层我定义了一套完整的优先级从最低到最高如下优先级低→高运算符说明1||逻辑或优先级最低2逻辑与3|按位或4^按位异或5按位与6!相等判断为了计算器使用体验我把和!放在这一级比常见编程语言中优先级略高但在这个项目里足够用了7关系比较8-加减法9*/%乘除取余10!~一元逻辑非、按位非11括号最高优先级这里的核心原则是一元运算符比二元运算符优先级高括号永远最强。如果你在后面扩展时加了幂运算^要注意^在 C 里是按位异或不是幂容易踩坑。4.3 递归下降求值的骨架我一个大概的引擎头文件定义如下// exprparser.h #pragma once #include QString #include optional class ExprParser { public: // 返回值optional 里存表达式的结果double // 空 optional 代表解析失败errMsg 会给出具体原因 static std::optionaldouble evaluate(const QString expr, QString *errMsg); };实现时的递归结构如下// 伪代码说明递归下降的逻辑 Parser类内部 parseExpression() // 处理 || 最低优先级 左边 parseAnd() 当遇到 || 右边 parseAnd() 结果 左边 || 右边 parseAnd() // 处理 优先级 左边 parseEquality() / parseBitOr() 当遇到 右边 parseEquality() / parseBitOr() 结果 左边 右边 类似往下推直到 parsePrimary() parsePrimary() 处理数字、true/false、!一元运算、(子表达式)这种结构在写的时候有一个非常重要的好处每个函数只需要关心自己这一层优先级的合并规则不需要知道下一层是怎么算出来的。就像流水线上每个工位只负责自己那一道工序系统整体反而最稳定。具体每一步计算的类型因为计算器要同时支持算术和逻辑我在内部统一用double保存数值。true当成1.0false当成0.0。逻辑运算的结果再转成布尔值非 0 即 true0 即 false。这样3 2算出来是1.0显示的时候我再根据上下文转成true或false。4.4 短路求值这是最容易忽视的坑逻辑运算有一个和普通算术运算截然不同的特点短路求值。表达式false (1 / 0 1)在 C/Java 这类语言里不会报除零错误因为左边已经是false右边根本不会被计算。但如果你的解析器实现得不小心右边1 / 0会先被计算出来触发除零错误结果和预期完全不同。短路求值要求解析时严格遵循“先计算左边根据结果决定是否计算右边”的执行顺序。递归下降天然能做到这一点我解析时先递归求左子树得到结果后再判断是否需要解析右子树double left parseBitOr(); while (match()) { if (left 0.0) { // 左边已经是 false右边不再计算 skipRightHandSide(); // 跳过右操作数但不求值 left 0.0; } else { double right parseBitOr(); left (left ! 0.0 right ! 0.0) ? 1.0 : 0.0; } }这里的skipRightHandSide()实现起来比较麻烦因为你必须“词法上跳过”而不是真的不读。如果直接不读整个解析器的位置指针就错乱了。我的做法是在跳过的过程中凡是遇到括号就递归跳过配对括号内部的所有内容直到当前运算符优先级对应的结束位置。这一段代码是整个项目里最容易写错的地方也是我调试时间最长的地方。为了方便理解你可以把短路求值看成“懒人计算”能少算就少算但不该少读的一个字符都不能少读。4.5 比较运算与类型转换因为数值统一用double比较运算实现起来很简洁。3 3.0成立1 true也成立true 在内部就是 1.0。对于这个计算器来说这样的处理逻辑是合理的。但这里有一个隐藏问题double的浮点数精度。用户如果输入0.1 0.2 0.3在double运算下结果是false因为0.1 0.2 0.30000000000000004。这在普通编程里是常识但计算器用户可不会这么想。我给出的处理方案是比较运算时做一个容差判断绝对值差小于1e-9就认为相等。这个经验我在第 6 节“隐藏边界问题”中会详细展开这里先记住结论。4.6 错误信息的采集表达式解析失败时不能只说“表达式错误”应该把具体原因和出错位置告诉用户。我在解析器中维护了一个lastError字符串和一个errorPos下标。当出现非法字符、括号不匹配、数值格式错误时会像下面这样记录非法字符 x 在第 5 个位置 括号不匹配缺少右括号 在 右侧缺少操作数界面上拿到错误信息后在状态提示区显示出来并震动一下显示区域改变一下背景色再恢复这样用户能立刻感知输入有问题。5. 交互细节从鼠标点到键盘流——完整操作体验的实现5.1 按键事件让整个窗口变成一个“输入面板”布局完成后还差一个核心体验用户不应该只能用鼠标点按钮更应该能直接用键盘输入整条表达式。比如1 2 * ( 3 4 ) true这样一气呵成地打出来比点按钮高效太多。为此我重写了主窗口的keyPressEventvoid MainWindow::keyPressEvent(QKeyEvent *event) { if (event-key() Qt::Key_Enter || event-key() Qt::Key_Return) { evaluateCurrentExpression(); // 统一走计算入口 return; } if (event-key() Qt::Key_Backspace) { removeLastChar(); // 退格删除最后一个有效字符 return; } if (event-key() Qt::Key_Escape) { clearAll(); // Esc 一键清空 return; } QString text event-text(); if (text.isEmpty()) return; if (isAcceptedInputChar(text.at(0))) { appendChar(text.at(0)); // 追加一个字符 } }isAcceptedInputChar里维护一个白名单允许的字符包括0-9、 - * / % ( ) . ! |外加空格。空格输入也接受因为在解析时我会丢弃所有空白符。5.2 键盘输入时容易踩的两个坑坑一event-text()在某些按键上返回空字符串。例如方向键、功能键都会被keyPressEvent捕获但text()是空的。如果你不判断text().isEmpty()就继续处理at(0)会越界崩溃。这个问题实测发生率极高因为很多用户会在输入过程中习惯性按一下方向键。坑二小键盘数字键和主键盘数字键的key()不同但text()是一样的。所以判断字符时只依赖text()不要依赖key()。我最初用Qt::Key_0到Qt::Key_9做过一次映射结果发现小键盘的键值根本不在这个范围里白白浪费了半小时调试。5.3 输入合法性检查把错误拦截在解析之前有了键盘输入用户就可能在任意位置输入垃圾字符。虽然解析器能做最终校验但好的交互应该把简单的错误拦截在输入阶段。我在追加字符时做以下检查运算符不能连续输入比如、**这种直接拒绝和||必须是完整的两个字符不能只输入一个或一个|就留在缓冲区里等待拼写正确做法是一次性整体追加!后跟true或false时要能正确组合成!true括号匹配情况实时在状态区提示“当前缺少 N 个右括号”小数点只能输入一次不能出现1.2.3。这些检查在appendChar里做因为计算核心的职责是“求值”不是“防错输入”。状态提示区我用一句话显示当前状态例如输入1 2 * 3 4 true缺 1 个右括号这样用户永远知道现在还差什么。实际体验下来这个状态提示比最终错误提示好用得多因为它是实时反馈的。5.4 显示区内容与缓冲区的分离这是一个比较隐蔽的设计问题。显示区是一个QLabel字符串内容来自编辑缓冲区QString m_inputBuffer。早期版本我直接让QLabel里的文本作为解析源结果各种问题不断显示“1/0”时标签会默认变成1/0还是1 / 0退格时该删谁后来我明确划分职责m_inputBuffer保存原始输入字符串是计算引擎的直接输入m_display只负责显示内容由m_inputBuffer派生出来过程中可以统一格式化。这样做之后逻辑变得非常清晰任何时候引擎拿到的是m_inputBuffer界面显示的是格式化后的m_display-text()。退格操作只需要在缓冲区上删末尾然后整体刷新显示。这种“数据和展示分离”的思路做任何带输入控件的项目都会用到这个计算器把它体现得很典型。5.5 格式化显示把12*3变成1 2 * 3用户在键盘上连续输入时不会刻意输入空格。如果显示区直接把12*3原样显示不仅看着费劲还容易看错。我写了一个formatExpression()函数在显示时自动在二元运算符和括号两侧加空格// 核心规则示例 12*3 - 1 2 * 3 (12)*true - (1 2) * true但这里要非常小心和||是两个字符的运算符不能在中间断开。格式化时我按最长匹配原则从左往右扫描先把、||、、!、、这些多字符运算符识别出来再处理单字符运算符。这个格式化函数虽然简单但让整个工具看起来专业了不少也是用户体验上容易被感受到的细节。6. 边界情况与验证如何保证计算器不出错6.1 除零与取余零的处理解析器运行中除数是 0 时不能崩溃也不能直接显示inf。我的做法是让evaluate返回空std::optional并在errMsg中给出“除数不能为 0”的明确提示。C 里double除以0.0可能产生inf而不是崩溃所以这里要主动判断if (std::abs(divisor) 1e-12) { setError(除数不能为 0); return std::nullopt; }取模运算%在浮点数上也不能直接用std::fmod对 0 取模同样需要先判断除数。6.2 浮点数比较的容差问题前面提到0.1 0.2 0.3是false这是浮点数运算的著名案例。为了让比较运算更符合直觉我在实现和!时用了容差bool doubleEquals(double a, double b) { return std::abs(a - b) 1e-9; }对于这个计算器的应用场景普通算术和逻辑比较这个容差足够了。但在实际开发中要注意如果未来加入幂运算或者更复杂的数学函数容差可能需要动态调整否则会出现误判。6.3 逻辑短路与除零同时出现时的验证这是我最看重的测试用例。例如false (1 / 0 1)正确结果是false并且不应该报除零错误。如果解析器没有短路求值这里就会算成false inf 1结果是false看上去好像也对但你再试false (1 / 0 1) || true如果短路失效结果变成false false || truetrue好像也对。但换成true || (1 / 0 1)正确结果是true不报错。如果短路失效内部算出inf 1为false表达式变成true || falsetrue结果依然对。真正的破绽在false || (1 / 0 0) true如果||右边被强行计算就会报除零错误而正确的短路逻辑下因为||左边是false才会去计算右边而右边包含除零错误被触发所以其实会对……我举这个例子想说的是短路逻辑正确性的验证不能只看最终结果对不对要确认什么时候该报错、什么时候不该报错。为此我设计了一批关键用例在开发过程中反复跑输入期望结果期望错误1 2 * 37无(1 2) * 39无2 * (3 4) % 54无1 / 0—除数不能为 00.1 0.2 0.3true无false (1 / 0 1)false无除零错true || (1 / 0 1)true无除零错!true || falsefalse无(1 2) (3 4)false无1 2 * 3 7false无!false truetrue无6.4 括号嵌套与错误匹配括号处理上最容易出现的问题是输入(((12)缺少右括号时解析器抛出“表达式不完整”但用户看不出缺在哪里。我采取的做法是解析器维护一个括号计数当表达式结束时计数不为零错误信息给出具体缺口缺少 2 个右括号用这个提示配合界面上的状态区用户基本能一眼看出问题。更进一步我还在显示区用不同颜色高亮未匹配的左括号比如把未闭合的左括号显示为橙红色。这个功能在 Qt 里就是二次扫描一遍字符串对匹配到的括号设置不同颜色代码量不大但对体验提升明显。6.5 自动化验证把测试用例写进代码手动点按钮验证效率太低而且会漏。我在开发过程中写了一个控制台验证程序把所有上面的测试用例直接跑一遍输出 PASS/FAIL。实现方式很简单新建一个普通控制台工程把exprparser.cpp包含进去然后在main里批量调用ExprParser::evaluate对比结果struct TestCase { QString expr; QString expectedValue; // 7 / true / false / ERROR:除数为0 }; QVectorTestCase tests { ... }; for (const auto tc : tests) { QString errMsg; auto result ExprParser::evaluate(tc.expr, errMsg); QString actual result.has_value() ? (isLogicalResult(*result) ? boolToString(*result) : numberToString(*result)) : QString(ERROR:%1).arg(errMsg); if (actual ! tc.expectedValue) { qWarning() FAIL: tc.expr expected tc.expectedValue got actual; } else { qDebug() PASS: tc.expr; } }这个方法代入感很强因为以后每改一次解析器逻辑你可以跑一遍全量用例不会因为新功能把老功能改坏。实际开发中我改短路求值逻辑的时候就是靠这套用例抓出了两个隐藏 bug。6.6 显示结果的类型判断计算出结果后界面要决定显示成数字7还是true。判断依据是如果表达式里包含逻辑运算符、||、!、、!、、、、、true、false并且最终求值结果不是因算术得到的数字则显示为布尔值。但这里有一个边界情况1 2结果是 0如果统一按数字显示会显示0。但用户期望看到false。我的判断逻辑是凡是表达式里出现了逻辑运算符或布尔字面量结果一律显示为 true/false否则显示数字。对于纯算术1 2 * 3即使结果恰好是 0也显示数字0不显示false。这样处理从直觉上最不容易让用户困惑。7. 如果继续扩展我会改动什么这个计算器做了一个基础版本之后其实还能加不少东西说几个我觉得最值得做的方向。第一加历史记录。用一个侧边栏或下拉列表存储最近的 20 条表达式和结果点击任意一条可以直接回填到输入框里。这属于典型的“看起来简单但非常提升日常使用频率”的功能。实现上只需要一个QListWidget加上一个双向绑定逻辑几乎不动解析引擎。第二支持变量和函数。在解析器里维护一个std::mapQString, double的符号表允许用户输入x 5之后后续表达式里直接使用x。再进一步可以内置sin、cos、log这些数学函数。这个方向会让解析器从“表达式求值”升级成“小型脚本语言”但工作量和复杂度会大很多建议在现有引擎通过测试后再扩展。第三支持进制切换。用户输入0xFF时按十六进制解析或者提供 DEC/HEX/BIN 切换按钮。进制切换的核心难点在于显示层因为同一数值在不同进制下的显示不同而底层double并不关心进制问题。我的建议是实现为表达式解析阶段允许识别0x、0b前缀计算仍统一按double只在结果显示时按当前进制转换。第四表达式的实时求值预览。当前版本只有按下等号才显示结果但其实可以在用户输入的每一瞬间都尝试计算当前编辑缓冲区的内容。如果表达式不完整比如多了一个号就静默忽略如果合法就在状态栏实时显示结果。这个功能我现在回头想其实很值得加因为它能把“计算器”变成“数学输入板”而且解析引擎已经是现成的了。最后按我个人的实际使用体验最值得优先做的还是历史记录。因为日常手动输入逻辑表达式时经常需要对比前后几个式子的结果没有历史记录就不得不用笔记下来体验是断层的。在做完这版 Qt 计算器之后我最大的体会是解析器本身的代码量并不大真正花时间的是各种边角情况的处理和把用户输入体验打磨得自然。短路求值、浮点比较、括号匹配提示这些问题每个单拿出来都不难但它们集中到一起时就能区分一个“能跑的 Demo”和“能日常自用的工具”。如果你也打算动手做建议顺序是先写纯控制台版解析器并跑通测试用例然后再去搭 Qt 界面。界面问题好排查难的是引擎把引擎先用测试按死了做界面时就会很轻松。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑