1. 从零开始:搭建你的第一个QT计算器项目
很多朋友刚开始学QT的时候,总觉得界面编程有点抽象,信号槽机制也让人摸不着头脑。我自己当年也是这么过来的,对着文档看了半天,还是不知道从哪儿下手。后来我发现,做一个计算器,其实是理解QT核心机制最快、最直观的路径。它麻雀虽小,五脏俱全:UI布局、控件交互、事件处理、数据处理,全都能练到。今天,我就带你从零开始,一步步构建一个不仅支持加减乘除,还能处理平方、开方等科学运算的桌面计算器。我会把我在实际开发中踩过的坑、总结的技巧都揉碎了讲给你听,保证你跟着做一遍,就能对QT的编程模式有个扎实的理解。
我们这次的目标,不是做一个只能按“1+1=2”的玩具。我们要实现一个能处理连续表达式、即时反馈的“引擎”。比如,你可以输入“3+5*2”,计算器能正确算出13,而不是16。这背后就涉及到表达式解析的逻辑,这也是我们这次“进阶”的重点。别担心,我们不用什么高深的编译原理,就用最直白、最容易理解的字符串处理方法来实现。整个过程,我会假设你刚装好QT Creator,对C++有基础了解,但没怎么接触过QT。我们从创建项目开始,手把手走一遍。
首先,打开你的QT Creator,点击“新建项目”。这里有个小细节,我建议你选择“Application” -> “QT Widgets Application”。为什么选Widgets而不是QML?因为对于计算器这种逻辑驱动、控件固定的桌面应用,Widgets框架更成熟,资料更多,调试起来也更直观。项目名称和路径你随意,我习惯叫“AdvancedCalculator”。在“Base class”选择时,直接选QWidget就行,这是最通用的窗口基类。后面的页面一路“下一步”,直到完成。这样,你的项目骨架就搭好了,你会看到自动生成了 main.cpp, widget.h, widget.cpp, widget.ui 这几个核心文件。.ui文件就是我们的“画布”,接下来所有界面设计都在这里进行。
2. 界面布局的艺术:栅格化与控件管理
界面是用户的第一印象,一个布局混乱的计算器,功能再强也用着别扭。QT提供了一套非常强大的布局管理器(Layout),能自动处理控件的大小和位置,适应窗口缩放。对于计算器这种规整的按钮矩阵,栅格布局(Grid Layout) 是绝配。我刚开始学的时候,总喜欢用绝对坐标去摆控件,结果窗口一拉就全乱了,后来改用布局管理器,才体会到什么叫“一劳永逸”。
进入 widget.ui 文件,你会看到设计界面。首先,我们从左侧的“Containers”里拖一个 Widget 到中间的窗体上。这个Widget将作为所有数字和运算符按钮的容器。为什么单独用一个容器?好处是隔离和复用。我们可以单独对这个容器应用栅格布局,让里面的按钮排列整齐,并且这个容器作为一个整体,再和上方的显示框进行垂直布局,结构非常清晰。然后,从“Buttons”里拖拽 Push Button 到这个Widget容器里,我们需要数字0-9、小数点、加减乘除、等于、清除、平方、平方根等按钮。大概需要20个左右。
接下来是关键步骤:选中Widget容器里的所有按钮(可以按住Ctrl键多选),右键点击,选择“布局” -> “栅格布局”。QT会自动把这些按钮均匀地排列成一个矩阵。但自动排列可能不符合计算器的经典布局(比如“0”键通常占两格)。这时,你需要手动调整。选中某个按钮,在右下角的属性编辑器里,找到 layoutRowSpan 和 layoutColumnSpan 属性。比如,让“0”按钮横跨两列,就把它的 layoutColumnSpan 设为2。同理,把“=”按钮的 layoutRowSpan 设为2,让它占两行的高度。调整的过程就像玩拼图,多试试就顺手了。
按钮的文本显示也很重要。双击按钮就可以修改其 text 属性,比如改成“1”、“+”、“sqrt”等。但更重要的是修改它的 objectName 属性(在属性编辑器里)。默认的名字是 pushButton, pushButton_2 这种,完全没法记。我强烈建议你改成有意义的名称,比如 btnNum1, btnPlus, btnSqrt。这样在后面写代码关联信号槽时,一眼就知道哪个按钮对应哪个功能,极大减少出错概率。最后,从“Display Widgets”里拖两个 Line Edit 到窗体上,放在Widget容器的上方。这两个一个用作输入表达式显示(我命名为 lineEditInput),一个用作结果输出显示(命名为 lineEditOutput)。它们不要放进那个Widget容器里。现在,选中整个窗体(或者选中所有控件,包括两个LineEdit和那个Widget容器),应用一个 垂直布局(Vertical Layout)。这样,无论窗口怎么拉,所有元素都会保持相对位置和比例,非常美观。
3. 信号与槽:让按钮“活”起来的魔法
界面摆好了,但点按钮还没反应。这就是QT的核心机制——信号与槽(Signals & Slots) 要登场了。你可以把它理解成一个事件广播和订阅系统。按钮被点击了(发出“clicked()”信号),我们写一个函数(槽函数)来响应这个事件。把它们连接(connect)起来,魔法就发生了。原始文章里用了老式的 SIGNAL() 和 SLOT() 宏来连接,这在QT5和QT6里虽然还能用,但我更推荐使用新式的语法,它能在编译时检查类型,更安全,写起来也更简洁。
我们先在头文件 widget.h 里声明槽函数。打开 widget.h,在 private slots: 区域(如果没有就自己加一行)添加函数声明。注意,命名可以随意,但为了清晰,我习惯用 on_控件对象名_clicked() 的格式,这样一看就知道是哪个按钮的响应函数。
private slots:
void on_btnNum1_clicked();
void on_btnNum2_clicked();
// ... 其他数字按钮
void on_btnPlus_clicked();
void on_btnMinus_clicked();
void on_btnMultiply_clicked();
void on_btnDivide_clicked();
void on_btnEquals_clicked();
void on_btnClear_clicked();
void on_btnSqrt_clicked();
void on_btnSquare_clicked();
// ... 其他功能按钮
然后,在 widget.cpp 的构造函数里,进行信号与槽的连接。新式语法是这样的:
Widget::Widget(QWidget *parent) : QWidget(parent), ui(new Ui::Widget)
{
ui->setupUi(this);
setWindowTitle("高级计算器");
// 连接数字按钮
connect(ui->btnNum1, &QPushButton::clicked, this, &Widget::on_btnNum1_clicked);
connect(ui->btnNum2, &QPushButton::clicked, this, &Widget::on_btnNum2_clicked);
// ... 连接其他按钮
// 连接运算符按钮
connect(ui->btnPlus, &QPushButton::clicked, this, &Widget::on_btnPlus_clicked);
// ... 以此类推
}
这种 &类名::信号 和 &类名::槽函数 的写法,是不是比老式的字符串宏清晰多了?而且如果你写错了函数名或类型,编译器会直接报错,而不是等到运行时才莫名其妙地没反应。这是我在调试时总结的血泪经验,一定要用新式连接。接下来,我们去实现这些槽函数。对于数字、小数点、运算符按钮,它们的逻辑很简单:把对应的字符追加到输入框(lineEditInput)的文本末尾。
void Widget::on_btnNum1_clicked()
{
ui->lineEditInput->insert("1"); // insert会在光标处插入
// 或者用 ui->lineEditInput->setText(ui->lineEditInput->text() + "1");
}
这里有个小技巧:insert() 是在当前光标位置插入文本,而 setText() 是替换全部文本。对于连续输入,insert() 更符合直觉,因为它能保持光标位置。你可以先简单实现,等所有功能都跑通后,再考虑优化光标逻辑。清除按钮(C或AC)的实现也很简单,就是清空输入输出框:ui->lineEditInput->clear(); ui->lineEditOutput->clear();。这样,一个能输入、能显示的架子就搭起来了。但光能输入不行,我们得让它能计算。
3.1 实现单步运算的陷阱
原始文章里的计算逻辑,是在等号按钮的槽函数里,用一堆 if 判断输入字符串里包含哪个运算符(如 indexOf("+")),然后进行分割计算。这个方法对于“A+B”这种单次运算是可行的。但它有一个致命缺陷:无法处理运算符优先级,也无法处理连续运算。比如输入“3+52”,它会先找到“+”,然后计算“3+5=8”,再乘以2吗?不会。因为它的逻辑是“找到第一个运算符就计算”,所以它会错误地计算“3+52”吗?实际上,由于它只处理第一个找到的运算符,并且只取运算符左右两边的字符串,对于“3+52”,它找到“+”,左边是“3”,右边是“52”,但 toDouble() 转换“5*2”会失败(返回0),导致结果错误。更别提连续输入“3+4-1”了。
所以,我们必须升级我们的计算核心,从“单步运算”进化到“表达式解析”。这听起来高大上,其实我们完全可以自己实现一个简化版。我们的目标是:能正确计算包含加减乘除、括号(可选)、平方、开方的表达式,并遵守先乘除后加减的优先级。
4. 表达式解析引擎:自己动手实现计算逻辑
表达式解析,说白了就是把用户输入的字符串(比如“3+5*2”),翻译成计算机能顺序执行的一系列运算。我们不做复杂的语法树,用一个相对取巧但足够有效的方法:双栈法。这个方法在《算法导论》里也有提到,特别适合带优先级的四则运算。我把它简化了一下,用计算器能理解的步骤来讲。
核心思想是准备两个栈:一个叫 操作数栈,专门存数字;一个叫 运算符栈,专门存加减乘除这些运算符号。我们从左到右扫描表达式字符串,遇到数字就压入操作数栈,遇到运算符就比较它和运算符栈栈顶元素的优先级。如果新运算符优先级更高,就压栈;如果更低或相等,就先从栈里弹出之前的运算符和操作数进行计算,把结果压回操作数栈,然后再处理新运算符。扫描完后,再把栈里剩下的运算依次弹出计算,最后操作数栈里剩下的那个数,就是结果。
为了支持平方(^2)和开方(sqrt)这种一元运算符(它们只对一个数进行操作),我们需要稍微调整一下逻辑。我建议在扫描前,先对表达式进行预处理,把这些特殊函数转换成标准运算符,或者单独处理。比如,把“4^2”转换成“4*4”,把“sqrt(9)”转换成对9进行开方运算。但为了保持代码清晰,我们可以在扫描过程中单独识别它们。
下面,我给出一个简化版本的核心解析函数 calculateExpression 的框架。它不处理括号,但能处理加减乘除和简单的平方、开方(我们约定平方用^2表示,开方用sqrt前缀表示,如sqrt9)。
double Widget::calculateExpression(const QString &expr)
{
QStack<double> numStack; // 操作数栈
QStack<QChar> opStack; // 运算符栈
QString number; // 用于临时拼接多位数
// 定义优先级
auto precedence = [](QChar op) -> int {
if (op == '+' || op == '-') return 1;
if (op == '*' || op == '/') return 2;
return 0;
};
// 执行一次计算
auto applyOperation = [&]() {
if (numStack.size() < 2 || opStack.isEmpty()) return;
double b = numStack.pop();
double a = numStack.pop();
QChar op = opStack.pop();
double result = 0;
switch (op.unicode()) {
case '+': result = a + b; break;
case '-': result = a - b; break;
case '*': result = a * b; break;
case '/': result = b != 0 ? a / b : 0; break; // 处理除零
default: break;
}
numStack.push(result);
};
for (int i = 0; i < expr.length(); ++i) {
QChar ch = expr[i];
// 如果是数字或小数点,拼接到当前数字字符串
if (ch.isDigit() || ch == '.') {
number.append(ch);
}
// 如果是运算符
else if (ch == '+' || ch == '-' || ch == '*' || ch == '/') {
if (!number.isEmpty()) {
numStack.push(number.toDouble());
number.clear();
}
// 处理负号作为一元运算符的情况(比如 -5)
if (ch == '-' && (i == 0 || expr[i-1] == '(' || isOperator(expr[i-1]))) {
// 这是一个负号,不是减号,我们简单处理,先压入0
numStack.push(0);
opStack.push('-'); // 实际上应该特殊标记,这里简化
} else {
// 比较优先级
while (!opStack.isEmpty() && precedence(opStack.top()) >= precedence(ch)) {
applyOperation();
}
opStack.push(ch);
}
}
// 这里可以添加对'^'和'sqrt'的识别和处理逻辑
}
// 处理最后一个数字
if (!number.isEmpty()) {
numStack.push(number.toDouble());
}
// 清空运算符栈
while (!opStack.isEmpty()) {
applyOperation();
}
return numStack.isEmpty() ? 0 : numStack.top();
}
这个函数是一个骨架,它展示了双栈法的流程。你需要完善它,比如添加对平方、开方的识别(可以在扫描到^或s时,向前或向后查看字符)。同时,这个函数没有处理表达式字符串中可能存在的空格,也没有做严格的错误检查(比如两个小数点、运算符不合法等)。在实际项目中,你需要添加这些健壮性处理。但作为入门,这个框架已经能帮你理解表达式解析的核心了。然后,在等号按钮的槽函数里,不再是一堆if-else,而是调用这个解析函数:
void Widget::on_btnEquals_clicked()
{
QString expr = ui->lineEditInput->text();
if (expr.isEmpty()) return;
double result = calculateExpression(expr);
ui->lineEditOutput->setText(QString::number(result));
}
4.1 处理平方与开方运算
平方和开方是一元运算符,它们只作用于一个数字。我们可以在表达式扫描过程中,当识别到“^2”时,就知道前面的数字需要平方。一个简单的办法是:在扫描完表达式字符串后,或者在进行双栈计算前,先对字符串进行预处理。例如,遍历字符串,找到“^2”,然后将其前面的数字提取出来,计算平方,再用结果替换掉“数字^2”这部分。开方处理类似,识别“sqrt”,然后提取后面的数字。
但更优雅的方式,是把它们也当作运算符,并入我们的双栈逻辑,只是它们的优先级最高,且是右结合的(先算平方再算乘除)。这需要扩展我们的运算符栈,使其能存储不同类型的操作(二元运算符如加减,一元运算符如平方)。对于初学者,我建议先用预处理的方法,虽然不够通用,但容易理解和实现。等双栈法玩熟了,再尝试扩展支持一元运算符。
QString preprocessExpression(QString expr) {
// 处理平方: 将 "数字^2" 替换为 "数字*数字"
QRegularExpression squareRegex("(\\d+(?:\\.\\d+)?)\\^2");
expr.replace(squareRegex, "(\\1*\\1)");
// 处理开方: 将 "sqrt数字" 替换为 "sqrt(数字)" 然后计算,这里简化处理
// 更严谨的做法是在解析时单独处理
// 我们可以先标记,比如替换为"$数字",然后在calculateExpression里识别'$'
QRegularExpression sqrtRegex("sqrt(\\d+(?:\\.\\d+)?)");
expr.replace(sqrtRegex, "$\1"); // 用$作为开方标记
return expr;
}
然后在 calculateExpression 函数里,当遇到$字符时,就知道下一个数字(或从栈顶弹出)需要做开方运算。这样,我们就把特殊函数转化成了解析引擎能理解的“标记”。
5. 功能完善与用户体验打磨
基础的计算逻辑实现后,我们的计算器已经能处理不少表达式了。但一个用起来舒服的计算器,还需要很多细节打磨。比如,连续计算功能。通常,按下等号得出结果后,再按运算符,应该以上次的结果作为第一个操作数开始新的计算。这需要我们在类里添加一个成员变量,比如 lastResult,来记录上一次的计算结果。当按下等号后,不仅显示结果,也把结果存入 lastResult。当用户紧接着按运算符时,判断输入框是否为空,如果为空,就把 lastResult 作为表达式开头显示在输入框。
错误处理也至关重要。用户可能会输入“1/0”、“3++5”这样的非法表达式。我们的解析函数需要能够检测这些情况,并给出友好的提示,而不是直接崩溃或者输出一个无意义的值(如inf)。我们可以在 calculateExpression 函数中,在除法操作前判断除数是否为零,在运算符栈操作时检查操作数数量是否匹配。一旦发现错误,就抛出一个异常(或者返回一个特殊值),然后在界面上显示“错误”或“非法输入”。
double Widget::calculateExpression(const QString &expr)
{
// ... 前面的代码 ...
auto applyOperation = [&]() {
if (numStack.size() < 2 || opStack.isEmpty()) return;
double b = numStack.pop();
double a = numStack.pop();
QChar op = opStack.pop();
double result = 0;
switch (op.unicode()) {
case '+': result = a + b; break;
case '-': result = a - b; break;
case '*': result = a * b; break;
case '/':
if (qFuzzyIsNull(b)) { // 判断b是否接近0
// 处理除零错误
throw std::runtime_error("Division by zero");
}
result = a / b;
break;
default: break;
}
numStack.push(result);
};
// ... 后面的代码 ...
try {
// 整个解析计算过程放在try块里
// ...
} catch (const std::exception &e) {
// 在界面上显示错误信息,例如:
ui->lineEditOutput->setText("错误: " + QString(e.what()));
return 0.0;
}
}
此外,键盘支持也是一个提升体验的点。我们可以重写窗口的 keyPressEvent 函数,监听键盘事件,将数字键、回车键(作为等号)、退格键(作为删除)等映射到对应的按钮点击事件上。这样用户就可以用键盘快速输入,效率倍增。
void Widget::keyPressEvent(QKeyEvent *event)
{
switch (event->key()) {
case Qt::Key_0: on_btnNum0_clicked(); break;
case Qt::Key_1: on_btnNum1_clicked(); break;
// ... 其他数字键
case Qt::Key_Plus: on_btnPlus_clicked(); break;
case Qt::Key_Minus: on_btnMinus_clicked(); break;
case Qt::Key_Asterisk: on_btnMultiply_clicked(); break;
case Qt::Key_Slash: on_btnDivide_clicked(); break;
case Qt::Key_Enter:
case Qt::Key_Return: on_btnEquals_clicked(); break;
case Qt::Key_Backspace: on_btnBackspace_clicked(); break; // 实现一个退格按钮
case Qt::Key_Escape: on_btnClear_clicked(); break;
default: QWidget::keyPressEvent(event); // 其他按键交给父类处理
}
}
最后,别忘了美化界面。在构造函数里,我们可以用样式表(QSS)给按钮和输入框加点颜色、圆角、悬停效果,让计算器看起来更现代。比如:
this->setStyleSheet(
"QPushButton {"
" font-size: 20px;"
" border: 2px solid #888;"
" border-radius: 10px;"
" background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #f6f7fa, stop:1 #dadbde);"
"}"
"QPushButton:hover {"
" background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #dadbde, stop:1 #f6f7fa);"
"}"
"QPushButton:pressed {"
" background-color: #ccc;"
"}"
"QLineEdit {"
" font-size: 24px;"
" border: 2px solid #aaa;"
" border-radius: 5px;"
" padding: 5px;"
"}"
);
这些细节的打磨,会让你的计算器从“能用”变成“好用”。我自己在项目收尾阶段,总会花不少时间在这些用户体验的优化上,因为这才是区分普通程序和优秀程序的地方。代码写到这里,你已经拥有了一个功能相当完整的QT计算器。它不仅界面美观、布局合理,而且内核具备表达式解析能力,能处理优先级和连续运算,还有基本的错误处理和键盘支持。你可以以此为起点,继续添加更多功能,比如历史记录、内存存储、三角函数、括号支持等。每添加一个新功能,都是对QT和C++编程的一次深入练习。最重要的是,通过这个完整的项目,你已经把QT的核心概念——窗口、控件、布局、信号槽、事件处理——都亲手实践了一遍,这个经验比看十遍教程都来得实在。

3万+

被折叠的 条评论
为什么被折叠?



