news 2026/7/15 2:35:47

C++控制台计算器项目实战:从架构设计到异常处理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++控制台计算器项目实战:从架构设计到异常处理

1. 项目概述:从“Hello World”到现代计算器

很多C++程序员,包括我自己,都是从那个经典的“Hello World”控制台程序开始的。这几乎是每个编程新手的必经之路。但说实话,一个只会打印“Hello World”的程序,除了让你确认开发环境没问题,几乎没什么实际价值。真正的编程乐趣和挑战,在于用代码去解决实际问题,构建一个能与人交互、有逻辑、有功能的应用。

“Windows计算器:现代C++实现的多功能计算工具”这个项目,就是一个绝佳的进阶练习。它远不止于一个简单的四则运算器。我们谈论的是一个运行在Windows控制台环境下的、具备基础数学运算能力、并能优雅处理用户输入和异常的程序。这听起来简单,但背后涉及了现代C++编程的核心思想:面向对象设计、模块化、错误处理以及用户交互逻辑。通过这个项目,你不仅能巩固C++语法,更能学会如何将一个复杂需求拆解成类、函数,并最终组装成一个可运行、可维护的应用程序。无论你是想夯实C++基础,还是为构建更复杂的桌面应用(比如基于Qt或WinUI的计算器)做准备,这个项目都是一个承上启下的关键实践。

2. 项目整体设计与架构思路

2.1 为什么选择控制台应用作为起点?

你可能会有疑问:为什么不直接用图形界面库(如Qt)来做一个带按钮的计算器?那样不是更酷吗?我的经验是,图形界面会引入大量与核心逻辑无关的复杂性,比如事件循环、UI布局、信号槽机制等。对于一个旨在深入理解C++核心编程和计算逻辑的项目来说,这些是噪音。

控制台应用的优势在于纯粹。它强迫你将所有精力集中在业务逻辑(如何解析表达式、如何进行计算)和程序结构(如何组织类、如何管理数据流)上。输入是简单的文本(如5+3),输出也是文本(Result is: 8),这使得调试和逻辑追踪变得异常清晰。当你用控制台完美实现了一个健壮的计算引擎后,为其套上一个图形界面,不过是“换件衣服”的事情,核心计算逻辑完全无需改动。这是一种非常扎实的“先内核,后外壳”的开发哲学。

2.2 核心架构:分离界面与逻辑

一个设计良好的程序,其不同部分应该各司其职。对于这个计算器项目,我们可以清晰地划分为两层:

  1. 表示层 (Presentation Layer):负责与用户交互。在控制台应用中,这就是main函数及其周边的输入输出处理。它负责提示用户、读取字符串、解析基本格式、显示结果,以及捕获最外层的输入错误(比如输入了非数字字符)。
  2. 业务逻辑层 (Business Logic Layer):负责核心计算。这就是我们的Calculator类。它不应该关心输入是从控制台、文件还是网络来的,也不应该关心结果如何被格式化显示。它的职责非常单一:接收两个操作数和一个运算符,返回计算结果。

这种分离(常被称为关注点分离)带来了巨大的好处:

  • 可测试性:你可以单独为Calculator类编写单元测试,无需启动整个控制台。
  • 可维护性:如果需要更换界面(比如改成图形界面),只需重写表示层,核心计算代码纹丝不动。
  • 可复用性:这个Calculator类可以轻松地被其他需要数学运算的模块调用。

基于这个思路,我们的项目结构自然就出来了:一个Calculator类(通常包含.h头文件和.cpp源文件)来封装所有计算逻辑,一个main.cpp来负责程序的启动、循环和用户交互。

2.3 开发环境与工具链选择

对于Windows平台的C++开发,Visual Studio是当之无愧的首选,尤其是对于初学者和需要快速上手的项目。它提供了无与伦比的集成体验:强大的IntelliSense代码补全、直观的图形化调试器、集成的Git版本控制,以及一键编译运行。社区版(Visual Studio Community)对个人和开源项目完全免费,功能齐全。

注意:安装时务必勾选“使用C++的桌面开发”工作负载。这会自动安装必要的编译器(MSVC)、标准库和Windows SDK。

当然,你也可以选择其他组合,比如VS Code + MinGW-w64,这能让你更贴近原生的GCC编译体验,但在项目配置和调试便捷性上,初期会复杂一些。对于本项目,我强烈建议从Visual Studio开始,它能让你更专注于代码本身,而不是环境配置。

3. 核心模块解析与Calculator类实现

3.1 Calculator类的设计:头文件(Calculator.h)

头文件(.h)是类的“蓝图”或“接口说明书”。它告诉编译器和其他代码:Calculator类长什么样,能做什么,但不说具体怎么做。

// Calculator.h #pragma once class Calculator { public: double Calculate(double x, char oper, double y); };

我们来逐行拆解:

  • #pragma once:这是一个编译器指令,确保这个头文件在同一个编译单元(通常是一个.cpp文件)中只被包含一次。这是防止因重复包含头文件而导致“重复定义”编译错误的标准做法。比老式的#ifndef ... #define ... #endif宏守卫更简洁。
  • class Calculator:定义了一个名为Calculator的类。
  • public::这是一个访问说明符。它意味着后面的成员可以被类外部的代码访问。我们将接口函数设为公有,这样main函数才能调用它。
  • double Calculate(double x, char oper, double y);:这是类的公有成员函数声明。
    • double:函数的返回类型,表示计算结果是一个双精度浮点数。选择double而非int是为了支持带小数的运算和除法。
    • Calculate:函数名,清晰地表明了它的功能。
    • (double x, char oper, double y):参数列表。它定义了调用这个函数需要提供的信息:两个double类型的操作数xy,以及一个char类型的运算符oper。这种设计将数据(操作数)和操作(运算符)清晰地分离开。

为什么这样设计参数?这是一种非常直观、符合人类阅读习惯的“中缀表达式”函数接口(x oper y)。它避免了在函数内部再进行复杂的字符串解析,让函数职责单一,只做纯粹的数学运算。

3.2 Calculator类的实现:源文件(Calculator.cpp)

源文件(.cpp)是类的“工厂”,负责实现头文件中声明的蓝图。

// Calculator.cpp #include "Calculator.h" double Calculator::Calculate(double x, char oper, double y) { switch (oper) { case '+': return x + y; case '-': return x - y; case '*': return x * y; case '/': // 关键:除法前的检查 if (y == 0.0) { // 在实际项目中,这里应该抛出异常(如 std::invalid_argument) // 为了控制台演示的简洁性,我们返回一个特殊值,并在主函数中处理。 // 更佳实践是抛出异常,让调用者决定如何处理。 // throw std::invalid_argument("Division by zero!"); return 0.0; // 临时方案,主函数会检测并处理 } return x / y; default: // 处理未知运算符 // 同样,更好的做法是抛出异常。 // throw std::invalid_argument("Invalid operator!"); return 0.0; // 临时方案 } }

实现细节与考量:

  1. #include "Calculator.h":源文件必须包含自己的头文件,这样编译器才知道Calculator::Calculate这个函数是在实现哪个声明。
  2. Calculator::Calculate::是作用域解析运算符。它明确指出这个Calculate函数是属于Calculator类的,而不是一个全局函数。这是C++中实现类成员函数的语法。
  3. switch语句:这是处理多路分支的完美选择,比一连串的if-else if更清晰、效率也通常更高。它将运算符oper与各个case标签进行比较,执行对应的计算。
  4. 除法除零处理:这是计算器最关键的健壮性考量。直接执行x / 0会导致浮点数除零,在C++中对于double类型,这通常会产生一个特殊的“无穷大”值(inf)或“非数字”值(nan),程序不会崩溃,但输出对用户不友好。我们在这里添加了一个条件判断if (y == 0.0)。我采用了返回一个特殊值(如0.0)并让调用者处理的方案。但在严肃的项目中,最佳实践是抛出标准异常(如std::invalid_argument,这样错误信息可以更精确地传递到UI层进行展示。
  5. default分支:处理用户输入了非+,-,*,/的字符。这是防御性编程的体现,确保函数对任何非法输入都有确定的输出行为,避免不可预知的程序状态。

实操心得:在编写核心逻辑函数时,一定要思考它的“边界情况”。对于Calculate函数,边界情况就是除数为零和非法运算符。提前处理这些情况,而不是等它们引发更深层的问题,是写出稳定代码的关键。

4. 主程序(main)与用户交互循环

有了强大的计算引擎,我们需要一个友好的“驾驶员”来使用它。这就是main.cpp的工作。

4.1 初始设置与变量声明

// main.cpp #include <iostream> #include <limits> // 用于清除输入缓冲区 #include "Calculator.h" using namespace std; int main() { double x = 0.0; double y = 0.0; double result = 0.0; char oper = '+'; Calculator calc; // 创建Calculator类的一个实例(对象) cout << "Calculator Console Application" << endl << endl; cout << "Please enter the operation to perform. Format: a+b | a-b | a*b | a/b" << endl; cout << "Enter 'q' to quit." << endl << endl;
  • 变量初始化:将所有变量初始化为一个确定的值(如0.0, ‘+’)是一个好习惯。这可以避免使用未初始化的变量导致的随机值错误,这类错误在调试时非常隐蔽。
  • Calculator calc;:这行代码在栈上创建了一个Calculator类的对象(实例)calc。现在,我们可以通过calc.Calculate(...)来调用它的功能。
  • 用户提示:清晰的操作提示至关重要。我们不仅说明了格式,还提供了一个退出机制(输入’q’),这比强制用户关闭控制台窗口要友好得多。

4.2 核心输入循环与错误处理

这是程序的心脏,一个持续运行的循环,不断读取、处理、输出。

while (true) { cout << "> "; // 提示符,让用户知道程序在等待输入 // 尝试读取输入 if (!(cin >> x >> oper >> y)) { // 输入失败处理(例如用户输入了‘q’或非数字) cin.clear(); // 清除cin的错误状态 cin.ignore(numeric_limits<streamsize>::max(), '\n'); // 忽略当前行剩余的错误输入 char exitChar; cin >> exitChar; if (exitChar == 'q' || exitChar == 'Q') { cout << "Goodbye!" << endl; break; // 退出循环 } else { cout << "Invalid input. Please use format: number operator number (e.g., 5+3)." << endl; // 清空缓冲区,防止错误残留影响下一次读取 cin.ignore(numeric_limits<streamsize>::max(), '\n'); } continue; // 跳过本次循环剩余部分,重新开始 } // 输入成功,开始处理 // 1. 处理除零错误(在调用Calculate前检查) if (oper == '/' && y == 0.0) { cout << "Error: Division by zero is not allowed." << endl; continue; } // 2. 调用核心计算函数 result = calc.Calculate(x, oper, y); // 3. 处理Calculate返回的“错误”结果(针对default分支和内部除零处理) // 注意:这里是一种简化的错误传递方式。更好的方式是在Calculate中抛异常,在此处捕获。 if (oper != '+' && oper != '-' && oper != '*' && oper != '/') { cout << "Error: Invalid operator '" << oper << "'. Supported operators are +, -, *, /." << endl; // 注意:此时result可能是Calculate中default返回的0.0,我们不显示它。 continue; } // 4. 输出成功结果 cout << "Result of " << x << " " << oper << " " << y << " is: " << result << endl; } return 0; }

这段代码的精华在于其健壮性,我为你拆解几个关键点:

  1. 输入验证if (!(cin >> x >> oper >> y))这行代码是第一道防线cin在读取失败(例如用户输入了字母而不是数字)时会进入错误状态。!操作符检测到这个失败。一旦失败,我们:

    • cin.clear():清除错误状态,让cin恢复可读。
    • cin.ignore(...):忽略这一行剩下的所有错误字符,直到换行符。这是为了防止错误的输入残留在缓冲区,影响下一次读取。
    • 然后检查用户是否想退出(输入’q’),或者只是输错了,并给出明确的错误提示。
  2. 业务逻辑验证:在调用calc.Calculate之前,我们进行了除零检查。这是一种“前置检查”,比在函数内部处理并返回特殊值,再在主函数里判断这个特殊值要更清晰。逻辑更直接:发现除零 -> 报错 -> 跳过计算。

  3. 错误反馈:对于非法运算符,我们在调用Calculate后,通过检查oper来判断。这里有一个设计抉择:我们让Calculatedefault分支返回了0.0。在主函数中,我们检查如果oper不是合法运算符,则判定为输入错误,并给出包含错误运算符的详细提示。这里暴露了当前设计的一个小瑕疵:错误信息分散在两处(主函数和Calculate函数)。更优雅的做法是让Calculate函数在遇到非法运算符或除零时抛出异常,然后在maintry-catch块中统一捕获并打印错误信息。为了教程的渐进性,我们先采用当前方案。

  4. 清晰的输出:成功的输出格式化为Result of 5 + 3 is: 8,这比简单的8要友好得多,让用户一目了然。

避坑指南:处理cin输入流错误是控制台编程的常见痛点。务必记住“清除状态(clear)”和“清空缓冲区(ignore)”这两个步骤。忘记它们会导致程序陷入无限循环或产生不可预知的读取行为。numeric_limits<streamsize>::max()是一个获取缓冲区最大长度的标准方法,表示“忽略尽可能多的字符”。

5. 构建、调试与功能测试

5.1 在Visual Studio中构建与运行

  1. 创建项目:启动VS,选择“创建新项目” -> “控制台应用”模板,命名为CalculatorTutorial
  2. 添加文件:在“解决方案资源管理器”中,右键点击“源文件”->“添加”->“新建项”,分别创建Calculator.hCalculator.cpp,并将上面的代码粘贴进去。同样地,修改CalculatorTutorial.cpp(即主程序文件)为上面的main函数代码。
  3. 生成解决方案:按Ctrl+Shift+B或点击菜单“生成”->“生成解决方案”。如果代码无误,输出窗口会显示“生成成功”。
  4. 开始执行(不调试):按Ctrl+F5。这会运行程序而不附加调试器,运行完毕后控制台窗口会停留,并显示“按任意键关闭...”。这是测试程序完整流程的最佳方式。
  5. 开始调试:按F5或点击绿色三角按钮。这会启动调试器。如果设置了断点,程序会在断点处暂停。

5.2 调试技巧:设置条件断点

假设我们想观察除零错误的发生过程,而不是等程序输出错误信息。

  1. main.cpp中,找到result = calc.Calculate(x, oper, y);这一行。
  2. 在行号左侧的灰色区域单击,设置一个普通断点(红色圆点)。
  3. 右键点击这个红色断点,选择“条件”。
  4. 在条件框中输入(oper == '/' && y == 0)。这意味着只有当除数为零时,程序才会在此断点处暂停。
  5. F5调试运行。输入10/0。你会发现程序自动停在了断点行,此时你可以将鼠标悬停在变量x,y,oper上查看它们的值,或者使用“自动窗口”、“局部变量窗口”来监视所有变量。按F10(逐过程)可以单步执行,观察程序流。

调试窗口详解

  • 自动窗口:显示当前行及前后几行相关的变量。
  • 局部变量窗口:显示当前函数(这里是main)的所有局部变量。
  • 监视窗口:可以自定义添加任何你想监视的表达式。

5.3 功能测试用例

一个严谨的开发者必须测试自己的程序。你可以设计如下测试序列来验证计算器的健壮性:

测试输入预期输出测试目的
5+3Result of 5 + 3 is: 8基础加法
10.5 - 2.3Result of 10.5 - 2.3 is: 8.2小数减法
7 * -4Result of 7 * -4 is: -28负数乘法
10 / 2Result of 10 / 2 is: 5正常除法
10 / 0Error: Division by zero is not allowed.除零错误处理
5 % 2Error: Invalid operator ‘%’. Supported operators are +, -, *, /.非法运算符处理
abcInvalid input. Please use format...非数字输入处理
qGoodbye!(程序退出)退出功能

在控制台中逐一输入这些测试用例,观察程序行为是否与预期一致。这是最基本的黑盒测试

6. 从基础到进阶:项目优化与扩展思路

一个基础的控制台计算器已经完成,但编程的魅力在于不断迭代和优化。这里分享几个扩展方向,你可以选择感兴趣的来挑战。

6.1 扩展1:支持更复杂的表达式

目前的计算器一次只能处理一个a op b的运算。我们可以扩展它,使其能处理更长的表达式,如2 + 3 * 4

实现思路(中缀表达式求值):这涉及到运算符优先级(乘除优先于加减)和括号处理。一个经典的算法是“调度场算法”(Shunting-yard algorithm)或“双栈法”。

  1. 你需要将输入的字符串(如“2 + 3 * 4”)分解成数字和运算符的序列(词法分析)。
  2. 使用两个栈:一个存放操作数,一个存放运算符。
  3. 遍历序列,根据运算符优先级决定是直接入栈还是先计算栈顶的运算。
  4. 这个实现会显著增加代码复杂度,但能极大提升计算器的实用性。

6.2 扩展2:引入历史记录功能

让计算器能记住最近几次的计算。

实现思路:

  1. Calculator类中添加一个私有成员,比如std::vector<std::string> history;
  2. Calculate函数成功计算后,将本次计算的表达式和结果格式化成字符串(如“5 + 3 = 8”),并添加到history向量的末尾。
  3. 添加一个公有成员函数void PrintHistory() const,用于打印所有历史记录。
  4. main函数中增加一个命令(如输入history)来调用这个函数。

6.3 扩展3:改进错误处理——使用异常

如前所述,当前错误处理分散且不够优雅。让我们用C++异常来重构。

在Calculator.cpp中:

#include <stdexcept> // 包含标准异常类 // ... 其他代码 ... double Calculator::Calculate(double x, char oper, double y) { switch (oper) { case '+': return x + y; case '-': return x - y; case '*': return x * y; case '/': if (y == 0.0) { throw std::invalid_argument("Division by zero!"); } return x / y; default: throw std::invalid_argument(std::string("Invalid operator: ") + oper); } }

在main.cpp的循环中:

try { result = calc.Calculate(x, oper, y); cout << "Result of " << x << " " << oper << " " << y << " is: " << result << endl; } catch (const std::invalid_argument& e) { cout << "Calculation error: " << e.what() << endl; // e.what() 返回错误信息 }

优势:错误产生(Calculate)和错误处理(main)完全分离。Calculate函数只负责计算和报告错误,main函数只负责捕获错误并告知用户。代码职责更清晰,也更容易在将来扩展新的错误类型。

6.4 扩展4:引入单元测试

对于Calculator这样的纯逻辑类,单元测试是保证其长期稳定性的最佳实践。你可以使用像Google Test这样的测试框架。

  1. 创建一个新的测试项目,或在本项目中添加测试。
  2. 编写测试用例,覆盖所有运算符、边界值(如除零)、非法输入等。
  3. 例如:
    TEST(CalculatorTest, Addition) { Calculator calc; EXPECT_DOUBLE_EQ(calc.Calculate(5, '+', 3), 8.0); } TEST(CalculatorTest, DivisionByZero) { Calculator calc; EXPECT_THROW(calc.Calculate(5, '/', 0), std::invalid_argument); }
  4. 每次修改代码后运行测试,确保没有破坏原有功能。

7. 常见问题与实战排查记录

在实际编写和运行过程中,你几乎一定会遇到下面这些问题。这里是我踩过坑后的总结。

问题1:程序一闪而过,看不到输出。

  • 原因:如果你直接按F5(开始调试)并且程序没有设置断点或等待输入,它执行完main函数后就退出了。
  • 解决
    • 方法A(推荐):按Ctrl+F5(开始执行不调试)。VS会在程序结束后自动暂停,显示“按任意键关闭...”。
    • 方法B:在main函数return 0;前添加system(“pause”);。但这依赖于操作系统,且不是好习惯。
    • 方法C:确保你的程序有等待用户输入的循环,就像我们项目中的while (true)循环。

问题2:输入非数字后,程序陷入无限循环或输出乱码。

  • 原因cin在读取失败后进入错误状态,并且失败的数据还留在输入缓冲区中,导致后续的所有cin操作都立即失败。
  • 解决:这就是为什么我们在main函数中加入了cin.clear()cin.ignore(...)这一套“清理组合拳”。务必在每次检测到cin失败后使用它们。

问题3:头文件重复包含导致的编译错误。

  • 错误信息error C2011: ‘Calculator’: ‘class’ type redefinition
  • 原因Calculator.h被同一个.cpp文件直接或间接包含了多次。
  • 解决:确保头文件使用了#pragma once(现代方式)或传统的#ifndef ... #define ... #endif宏守卫。

问题4:链接器错误(LNK错误)。

  • 错误示例error LNK2005: “public: double __thiscall Calculator::Calculate(...)” already defined in Calculator.obj
  • 原因:通常是将函数实现(定义)写在了头文件(.h)中,并且该头文件被多个源文件包含,导致函数体被多次编译,链接时发现重复定义。
  • 解决严格遵守“声明在.h,定义在.cpp”的原则。只有模板函数/类、内联函数等特例可以放在头文件定义。

问题5:浮点数精度问题。

  • 现象:计算0.1 + 0.2可能不等于0.3,而是一个非常接近0.3的值,如0.30000000000000004
  • 原因:这是二进制浮点数表示法的固有局限,并非程序错误。double类型无法精确表示所有十进制小数。
  • 解决:对于需要精确十进制计算的场景(如金融),应使用十进制浮点数库(如std::decimal,如果编译器支持)或定点数。对于普通计算器,在显示结果时可以使用std::setprecision进行格式化输出,限制显示的小数位数。

这个项目虽然始于一个简单的想法,但深入下去,几乎触及了软件工程的核心:需求分析、架构设计、模块化、错误处理、测试和迭代。当你亲手完成它,并尝试了上述的扩展功能后,你会对“如何用代码构建一个可靠的应用”有更深刻的理解。这远比单纯学习语法更有价值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/15 2:34:55

MinerU文档解析:VLM+OCR双引擎驱动复杂PDF与表格处理

如果你正在构建RAG系统、训练大模型&#xff0c;或者需要处理大量PDF、扫描件、财务报表、学术论文等复杂文档&#xff0c;那么文档解析的质量直接决定了整个AI系统的上限。传统方案在处理复杂布局、跨页表格、数学公式时往往力不从心&#xff0c;而大语言模型直接"阅读&q…

作者头像 李华
网站建设 2026/7/15 2:34:24

JavaWeb期末通关指南——从Servlet到MVC的实战复习路线

1. Servlet生命周期&#xff1a;从出生到退休的完整旅程第一次接触Servlet时&#xff0c;我完全被它的生命周期搞晕了。直到把Tomcat比作餐厅&#xff0c;Servlet比作厨师&#xff0c;才恍然大悟——init()是厨师上岗培训&#xff0c;service()是日常做菜&#xff0c;destroy()…

作者头像 李华
网站建设 2026/7/15 2:33:33

工作汇报 PPT 怎么做?从 Word一键生成PPT的 5 款 AI 工具横评

又到了月度汇报、季度复盘的时候了。 是不是很多人的状态是这样的&#xff1a;手里握着一份几千字的Word工作总结&#xff0c;对着空白的PPT界面发呆&#xff1f;复制粘贴吧&#xff0c;太慢&#xff1b;重新写吧&#xff0c;没必要。这时候你就需要一个能读懂Word、还能帮你“…

作者头像 李华
网站建设 2026/7/15 2:33:18

揭开引擎的“账本“:金属度贴图背后的渲染计算公式

引子&#xff1a;从"知其然"到"知其所以然" 前面几篇文章&#xff0c;我们一路认识了金属度贴图——它是那位"非黑即白的鉴定师",它能拨动光的开关、让物体在两个世界间穿越。我们讲了它"是什么"、它"有什么影响"。 但如果你…

作者头像 李华
网站建设 2026/7/15 2:33:12

MoE专家混合模型:原理、Router实现与大模型应用解析

最近在准备面试的同学&#xff0c;可能已经注意到了 MoE&#xff08;Mixture of Experts&#xff09;这个词出现的频率越来越高。但如果你只是背下“MoE 是一种稀疏激活的专家混合模型”这样的定义&#xff0c;面试官追问一句“Router 具体是怎么决定哪些专家被激活的&#xff…

作者头像 李华
网站建设 2026/7/15 2:32:06

C#单例模式进阶:基于泛型与反射的通用基类设计与自动初始化

1. 项目概述&#xff1a;为什么我们需要一个“聪明”的单例基类&#xff1f;在C#开发中&#xff0c;单例模式&#xff08;Singleton Pattern&#xff09;几乎是每个开发者都会接触到的设计模式。它的核心目标很明确&#xff1a;确保一个类在整个应用程序生命周期中只有一个实例…

作者头像 李华