本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:C++计算器是一个经典的编程练习项目,涵盖控制流、数据类型、函数、类与对象等核心C++概念。本项目包含“My_Calc_Lib”功能库和“Cacl”测试文件,通过封装加减乘除、括号处理与运算优先级逻辑,实现完整的表达式计算功能。项目采用面向对象设计,利用类封装计算器操作,并结合栈结构解析复杂表达式。同时,通过单元测试验证各类运算的正确性,支持异常处理与输入验证,可扩展为命令行或GUI应用。本项目帮助开发者深入理解C++编程、模块化设计与软件测试流程。

C++ 实现计算器:从模块化设计到表达式解析的完整工程实践

在现代软件开发中,一个看似简单的“计算器”项目往往蕴含着丰富的技术内涵。它不仅是初学者理解编程逻辑的入门工具,更是资深开发者锤炼架构思维、掌握编译原理和提升代码健壮性的绝佳实验场。今天我们要深入剖析的,是一个名为 My_Calc_Lib 的 C++ 计算器核心库——它不仅仅能做加减乘除,更承载了面向对象设计、表达式解析、异常处理、测试驱动开发等一整套工业级工程实践。

你可能会问:“不就是个计算器吗?写个 main 函数调用几个运算符不行?”
但等等!当你面对用户输入 "3 + 5 * (2 - 8)" 时,如何确保优先级正确?当遇到 "5 / 0" "(((") 这类非法输入时,程序会不会崩溃?如果未来要支持 sqrt() log() 甚至变量代入,你的结构是否足够灵活?

这些问题的答案,就藏在这次从零构建 My_Calc_Lib 的全过程里。我们不只是实现功能,而是在打造一个 可复用、可扩展、高内聚、低耦合 的数学计算引擎。准备好一起拆解这个小而美的系统了吗?Let’s go!🚀


模块化设计:让代码像积木一样组装

想象一下,你要盖一栋楼,是直接用砖头堆起来,还是先规划地基、承重墙、水电管线?显然,后者才是专业做法。同样,在 C++ 项目中,良好的模块化设计决定了系统的可维护性和演化能力。

My_Calc_Lib 的起点就是一个清晰的分层架构:

src/
├── Calculator.h/cpp        # 核心计算接口
├── MathOperation.h/cpp     # 抽象运算基类
├── Tokenizer.h/cpp         # 表达式词法分析
├── ExpressionParser.h/cpp  # 语法解析与求值
└── ErrorHandler.h/cpp      # 统一错误处理

这种组织方式不是偶然,而是遵循了 单一职责原则(SRP) ——每个文件只负责一件事。比如 Tokenizer 只管把字符串切分成数字、运算符和括号; ExpressionParser 负责调度整个解析流程;而 Calculator 则专注于执行具体的算术操作。

头文件 vs 源文件:接口与实现的分离艺术 🎨

C++ 中 .h .cpp 文件的分工,堪称“契约精神”的典范:

  • 头文件(.h) :声明“我能做什么”——暴露给外界的类定义、函数原型、常量。
  • 源文件(.cpp) :实现“我是怎么做的”——隐藏内部细节,防止外部篡改。

来看 Calculator.h 的核心片段:

#ifndef MY_CALC_LIB_CALCULATOR_H
#define MY_CALC_LIB_CALCULATOR_H

class Calculator {
public:
    Calculator();
    double add(double a, double b);
    double subtract(double a, double b);
    double multiply(double a, double b);
    double divide(double a, double b);
    double getLastResult() const;

private:
    double lastResult;
};

#endif // MY_CALC_LIB_CALCULATOR_H

短短几行代码,已经体现了多个最佳实践:
- 使用 #ifndef 防止重复包含;
- 构造函数初始化状态;
- getLastResult() 声明为 const ,保证不会修改对象;
- 所有数据成员私有,杜绝外部直接访问。

再看对应的 Calculator.cpp 实现:

#include "Calculator.h"
#include <stdexcept>

Calculator::Calculator() : lastResult(0.0) {}

double Calculator::add(double a, double b) {
    lastResult = a + b;
    return lastResult;
}

// ... 其他方法省略 ...

double Calculator::divide(double a, double b) {
    if (b == 0.0) {
        throw std::runtime_error("Division by zero is not allowed.");
    }
    lastResult = a / b;
    return lastResult;
}

注意到没有? 除法操作加入了异常处理 !这正是工业级代码与玩具项目的分水岭。你不只是“完成了功能”,还在主动预防错误。

⚠️ 小贴士:永远不要在头文件里写函数体(除非模板),否则会导致编译依赖爆炸,链接时间飙升!

文件类型 职责 示例内容
.h 头文件 接口声明、类定义、常量/枚举声明 函数原型、类成员声明、访问控制符
.cpp 源文件 具体实现、算法逻辑、错误处理 函数体、条件判断、异常抛出

面向对象三大支柱:封装、继承、多态的真实落地 💡

很多人学 OOP 的时候背得滚瓜烂熟:“封装隐藏数据,继承复用代码,多态统一接口。”但真到了写代码时却一脸懵:我该怎么用?

别急,咱们一步步来。

封装:保护你的“lastResult”

假设你没把 lastResult 设为 private ,会发生什么?

Calculator calc;
calc.lastResult = 999999;  // 直接篡改内部状态!
std::cout << calc.getLastResult();  // 输出 999999 —— 严重逻辑错乱!

这就是为什么必须封装。只有通过合法接口才能读写状态:

double result = calc.getLastResult();  // ✅ 安全读取
// calc.lastResult = ...;             // ❌ 编译报错,拦住了!

更进一步,我们可以引入“观察者模式”,让 GUI 或日志模块自动监听计算结果变化:

class Calculator {
public:
    using Observer = std::function<void(double)>;

    void addObserver(Observer obs) {
        observers.push_back(obs);
    }

private:
    double lastResult;
    std::vector<Observer> observers;

    void notifyObservers() {
        for (auto& obs : observers) {
            obs(lastResult);
        }
    }

public:
    double add(double a, double b) {
        lastResult = a + b;
        notifyObservers();  // 自动通知所有监听者
        return lastResult;
    }
};

这样一来,每次计算完成后,界面可以自动刷新,日志可以自动记录,完全解耦!

Observer机制相关元素说明表
成员 类型 作用
Observer std::function<void(double)> 回调函数签名,接受最新结果
observers std::vector<Observer> 存储所有注册的观察者
addObserver() 方法 添加新的监听者
notifyObservers() 方法 遍历并触发所有回调

是不是有点像 Vue 的 watch 或 React 的 useEffect ?其实底层思想是一致的: 状态变更 → 自动响应

继承与多态:打造计算器家族 🧬

现在我们有一个基础计算器,但如果想做一个科学计算器呢?难道要把 sin cos log 全部塞进原来的类里?

当然不行!那会违反 SRP,变成一个臃肿的“上帝类”。

正确的做法是使用 继承

class ScientificCalculator : public Calculator {
public:
    double sin(double x) { return std::sin(x); }
    double cos(double x) { return std::cos(x); }
    double log(double x) {
        if (x <= 0) throw std::domain_error("Log undefined for non-positive");
        return std::log(x);
    }
    double power(double base, double exp) { return std::pow(base, exp); }
};

这样, ScientificCalculator 自动继承了所有四则运算能力,只需专注新增功能。这是典型的“is-a”关系建模。

多态 的价值体现在统一调用上:

void performCalculation(Calculator& calc, double a, double b) {
    std::cout << "Add: " << calc.add(a, b) << "\n";
    std::cout << "Mul: " << calc.multiply(a, b) << "\n";
}

无论传进来的是 Calculator 还是 ScientificCalculator ,都能正常工作。接口一致性带来了极大的灵活性。

Mermaid 类图:继承关系可视化
classDiagram
    class Calculator {
        +double add(double, double)
        +double subtract(double, double)
        +...
        -double lastResult
    }

    class ScientificCalculator {
        +double sin(double)
        +double cos(double)
        +double log(double)
        +double power(double, double)
    }

    Calculator <|-- ScientificCalculator

清晰明了,子类扩展父类,职责分明。


表达式解析:从 "3+4*2" 到计算结果的魔法之旅 🔮

用户可不会一个个点击按钮让你调用 add(3, 4) 。他们希望输入一整串表达式,比如 "3 + 4 * 2" ,然后一键得出结果。

这就涉及两个关键步骤: 词法分析(Lexing) 语法分析(Parsing)

词法分析:把字符串切成“词”

第一步是将原始字符串分解成有意义的单元,称为 Token 。例如 "3 + 4 * 2" 应被切分为:

[NUMBER:3] [PLUS:+] [NUMBER:4] [MULTIPLY:*] [NUMBER:2]

我们用一个 Lexer 类来完成这项任务:

enum TokenType { NUMBER, PLUS, MINUS, MULTIPLY, DIVIDE, LPAREN, RPAREN, END };

struct Token {
    TokenType type;
    double value; // 仅用于 NUMBER
};

class Lexer {
private:
    std::string expr;
    size_t pos;

public:
    Lexer(const std::string& input) : expr(input), pos(0) {}

    Token getNextToken() {
        while (pos < expr.length() && std::isspace(expr[pos])) pos++;

        if (pos >= expr.length()) return {END, 0};

        char ch = expr[pos];

        if (std::isdigit(ch) || ch == '.') {
            std::string numStr;
            while (pos < expr.length() && (std::isdigit(expr[pos]) || expr[pos] == '.'))
                numStr += expr[pos++];
            return {NUMBER, std::stod(numStr)};
        }

        pos++;
        switch (ch) {
            case '+': return {PLUS, 0};
            case '-': return {MINUS, 0};
            case '*': return {MULTIPLY, 0};
            case '/': return {DIVIDE, 0};
            case '(': return {LPAREN, 0};
            case ')': return {RPAREN, 0};
            default: throw std::invalid_argument("Invalid character: " + std::string(1, ch));
        }
    }
};

这套扫描逻辑虽然简单,但非常有效。你可以把它想象成一个“字符机器人”,一步一步往前走,看到数字就收集起来,看到符号就分类标记。

不过有个坑要注意:负数怎么办?比如 -5 + 3 。这里的 - 是一元负号,不是二元减法。我们需要结合上下文判断:

当前字符 前一项类型 解释为 示例
- NONE 一元负号 -5
- LPAREN 一元负号 ( -3 + 2 )
- NUMBER 二元减法 5 - 3

这个逻辑可以在后续的语法分析阶段处理。

Mermaid 状态图:词法分析流程
stateDiagram-v2
    [*] --> Start
    Start --> SkipSpaces : 忽略空白
    SkipSpaces --> Number : 数字或.
    SkipSpaces --> Operator : +,-,*,/
    SkipSpaces --> Parentheses : ( 或 )
    Number --> FormNumber : 收集字符
    FormNumber --> ConvertToDouble : stod()
    Operator --> EmitToken : 返回对应类型
    Parentheses --> EmitToken
    EmitToken --> SkipSpaces
    FormNumber --> SkipSpaces
    SkipSpaces --> End : 到达末尾
    End --> [*]

是不是像极了你小时候做阅读理解的过程?逐字读,分段落,抓关键词 😂

中缀转后缀:Shunting Yard 算法登场 🚦

接下来是最精彩的部分:如何处理运算优先级?

人类习惯写中缀表达式(如 3 + 4 * 2 ),但计算机更适合处理 后缀表达式(逆波兰表示法) ,例如:

中缀:3 + 4 * 2
后缀:3 4 2 * +

一旦转成后缀,就可以用一个栈轻松求值:遇到数字压栈,遇到运算符弹出两个数计算后再压回去。

转换算法叫 Shunting Yard Algorithm ,由 Dijkstra 提出,核心思想是用操作符栈管理优先级:

std::vector<Token> infixToPostfix(const std::vector<Token>& tokens) {
    std::stack<TokenType> opStack;
    std::vector<Token> output;

    for (const auto& token : tokens) {
        if (token.type == NUMBER) {
            output.push_back(token);
        } else if (token.type == LPAREN) {
            opStack.push(token.type);
        } else if (token.type == RPAREN) {
            while (!opStack.empty() && opStack.top() != LPAREN) {
                output.push_back({opStack.top(), 0});
                opStack.pop();
            }
            opStack.pop(); // 移除 '('
        } else {
            int prec = precedence(token.type);
            while (!opStack.empty() && opStack.top() != LPAREN &&
                   precedence(opStack.top()) >= prec) {
                output.push_back({opStack.top(), 0});
                opStack.pop();
            }
            opStack.push(token.type);
        }
    }

    while (!opStack.empty()) {
        output.push_back({opStack.top(), 0});
        opStack.pop();
    }

    return output;
}

int precedence(TokenType op) {
    switch (op) {
        case PLUS:
        case MINUS: return 1;
        case MULTIPLY:
        case DIVIDE: return 2;
        default: return 0;
    }
}

这套算法时间复杂度 O(n),空间 O(n),高效稳定。

后缀求值:栈的终极应用 💥

最后一步,用栈完成实际计算:

double evaluatePostfix(const std::vector<Token>& postfix) {
    std::stack<double> stack;

    for (const auto& token : postfix) {
        if (token.type == NUMBER) {
            stack.push(token.value);
        } else {
            if (stack.size() < 2) throw std::runtime_error("Invalid expression");

            double b = stack.top(); stack.pop();
            double a = stack.top(); stack.pop();
            double result;

            switch (token.type) {
                case PLUS:     result = a + b; break;
                case MINUS:    result = a - b; break;
                case MULTIPLY: result = a * b; break;
                case DIVIDE:   result = divide(a, b); break;
                default: throw std::logic_error("Unknown operator");
            }
            stack.push(result);
        }
    }

    if (stack.size() != 1) throw std::runtime_error("Malformed expression");
    return stack.top();
}

注意操作顺序:先出栈的是右操作数( b ),后出栈的是左操作数( a )。 a - b b - a 可不一样!

数据结构使用总结表
阶段 主要数据结构 功能
词法分析 字符串 + 循环索引 提取token
中缀→后缀 栈(操作符) + 队列(输出) 重排运算顺序
后缀求值 栈(操作数) 计算中间结果

健壮性保障:括号匹配、输入验证与异常处理 🔒

你以为解析完就能高枕无忧了?Too young too simple!

现实中的用户输入千奇百怪:
- "(()" —— 括号不匹配
- "5 ++ 3" —— 连续运算符
- "abc" —— 完全看不懂
- "1e1000" —— 浮点溢出

这些都必须妥善处理。

括号匹配检测:栈的经典应用

最简单的办法是用栈检查括号是否成对:

bool validateParentheses(const std::string& expr) {
    std::stack<char> parenthesesStack;

    for (char ch : expr) {
        if (ch == '(') {
            parenthesesStack.push(ch);
        } else if (ch == ')') {
            if (parenthesesStack.empty()) {
                throw std::invalid_argument("Unmatched closing parenthesis at position: " + 
                                          std::to_string(&ch - expr.data()));
            }
            parenthesesStack.pop();
        }
    }

    if (!parenthesesStack.empty()) {
        throw std::invalid_argument("Unmatched opening parenthesis(s) remaining.");
    }

    return true;
}
Mermaid 流程图:括号匹配过程
graph TD
    A[开始] --> B{读取下一个字符}
    B -->|是 '('| C[入栈]
    B -->|是 ')'| D{栈是否为空?}
    D -->|是| E[抛出异常: 多余右括号]
    D -->|否| F[出栈]
    B -->|其他字符| G[跳过]
    C --> H[继续]
    F --> H
    G --> H
    H --> I{是否还有字符?}
    I -->|是| B
    I -->|否| J{栈是否为空?}
    J -->|否| K[抛出异常: 多余左括号]
    J -->|是| L[括号匹配成功]

输入合法性验证:状态机的力量

除了括号,我们还要防各种“花式作死”。可以用状态机来做预判:

enum class ParseState {
    START,
    IN_NUMBER,
    AFTER_OPERATOR,
    AFTER_DOT,
    ERROR
};

bool isValidExpression(const std::string& input) {
    ParseState state = ParseState::START;
    bool hasOperand = false;

    for (size_t i = 0; i < input.length(); ++i) {
        char ch = input[i];

        if (isspace(ch)) continue;

        if (isdigit(ch)) {
            state = ParseState::IN_NUMBER;
            hasOperand = true;
        } else if (ch == '.') {
            if (state != ParseState::IN_NUMBER && state != ParseState::START) {
                return false;
            }
            state = ParseState::AFTER_DOT;
        } else if (ch == '+' || ch == '-' || ch == '*' || ch == '/') {
            if (state == ParseState::START || state == ParseState::AFTER_OPERATOR) {
                if (ch != '+' && ch != '-') return false;
            }
            state = ParseState::AFTER_OPERATOR;
        } else if (ch == '(' || ch == ')') {
            continue;
        } else {
            return false;
        }
    }

    return hasOperand && state != ParseState::AFTER_OPERATOR;
}

这种方法比正则更灵活,能捕捉上下文相关的语义错误。

异常处理:优雅地应对失败 🛑

C++ 的 try/catch 是结构化错误处理的利器:

double safeDivide(double a, double b) {
    if (std::abs(b) < 1e-12) {
        throw std::domain_error("Division by zero detected");
    }
    return a / b;
}

// 调用处
try {
    result = safeDivide(5.0, 0.0);
} catch (const std::domain_error& e) {
    std::cerr << "Error: " << e.what() << std::endl;
}

还可以结合 <cfenv> 检测浮点异常:

#include <cfenv>
#pragma STDC FENV_ACCESS ON

feclearexcept(FE_ALL_EXCEPT);
double res = hugeVal * hugeVal;
if (fetestexcept(FE_OVERFLOW)) {
    throw std::overflow_error("Floating point overflow");
}

测试驱动开发:用自动化测试守护质量 🧪

写完代码不等于结束,还得证明它是对的。

单元测试设计:覆盖边界情况

一个好的测试集应该涵盖:
- 正常情况( 2 + 3
- 边界情况( .5 + .3
- 错误情况( 5 / 0
- 复杂表达式( (3 + 4 * (5 - 6)) / 7

我们可以用表格形式组织测试用例:

测试编号 表达式输入 预期输出 是否抛出异常 异常类型
TC001 “2 + 3” 5.0
TC002 “10 / 0” 抛出异常 Division by zero
TC003 “3 + 5 * (2 - 8)” -27.0
TC004 ”“ 抛出异常 Empty expression
TC005 ”((()))” 0.0 无(合法空括号)
TC006 “2.5 * 4” 10.0

使用 Google Test 实现自动化

推荐使用成熟的测试框架,如 Google Test

TEST_F(CalculatorTest, SimpleAddition) {
    EXPECT_DOUBLE_EQ(calc.calculate("2 + 3"), 5.0);
}

TEST_F(CalculatorTest, DivisionByZeroThrows) {
    EXPECT_THROW({
        try {
            calc.calculate("5 / 0");
        } catch (const std::runtime_error& e) {
            EXPECT_STREQ(e.what(), "Division by zero!");
            throw;
        }
    }, std::runtime_error);
}

或者轻量级的 Catch2

TEST_CASE("Basic arithmetic", "[calculator]") {
    Calculator calc;
    REQUIRE(calc.calculate("2 + 3") == Approx(5.0));
}

TEST_CASE("Handle division by zero", "[exception]") {
    Calculator calc;
    REQUIRE_THROWS_WITH(calc.calculate("1 / 0"), "Division by zero!");
}

CI/CD 流程中集成测试也很简单:

graph LR
A[代码提交] --> B{触发CI}
B --> C[编译项目]
C --> D[运行GTest/Catch2]
D --> E{测试通过?}
E -->|Yes| F[部署到测试环境]
E -->|No| G[发送失败通知]
G --> H[开发者修复]
H --> A

未来扩展:CLI、GUI 与跨平台集成 🚀

随着核心稳定,下一步可以考虑交互方式升级。

CLI 交互模式

支持两种模式:

# 即时计算
./mycalc "2 + 3 * (4 - 1)"

# REPL 模式
./mycalc --interactive
> Enter expression: 2^8
Result: 256
> quit

GUI 集成:Qt 是首选方案

框架 学习曲线 跨平台 UI 描述方式 推荐场景
Qt 中等 QML / Widgets 复杂业务逻辑应用
wxWidgets 较陡 原生控件封装 类原生外观需求
Dear ImGui 代码绘制 工具型/调试界面

最终建议采用分层架构:

graph TD
A[GUI Layer: Qt/wxWidgets] --> B[Interface Adapter]
B --> C[Core Logic: My_Calc_Lib]
C --> D[Parsing Engine]
D --> E[Arithmetic Kernel]

保持核心独立,便于移植至 WebAssembly、Python 绑定(PyBind11)、REST API 等。


结语:小项目,大智慧 🌟

回过头看,这个“简单”的计算器项目,竟然涵盖了这么多关键技术:模块化、OOP、栈应用、词法语法分析、异常处理、TDD、GUI 集成……

这正是它的魅力所在: 麻雀虽小,五脏俱全 。每一个环节的选择,都在塑造系统的基因。

所以,下次有人问你“你会写计算器吗”,你可以自信地说:

“我会写的不是一个计算器,而是一个具备工业级架构思维的数学计算引擎。” 💪

而这,正是通往优秀工程师之路的第一步。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:C++计算器是一个经典的编程练习项目,涵盖控制流、数据类型、函数、类与对象等核心C++概念。本项目包含“My_Calc_Lib”功能库和“Cacl”测试文件,通过封装加减乘除、括号处理与运算优先级逻辑,实现完整的表达式计算功能。项目采用面向对象设计,利用类封装计算器操作,并结合栈结构解析复杂表达式。同时,通过单元测试验证各类运算的正确性,支持异常处理与输入验证,可扩展为命令行或GUI应用。本项目帮助开发者深入理解C++编程、模块化设计与软件测试流程。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐