C++ 基础语法实践:用数据流串起一个小程序
12 把基础语法串成一个小程序
前面的 11 章讲的是一块块独立的基础能力。变量负责保存数据,表达式负责计算结果,控制流负责决定执行路线,函数负责拆分逻辑,作用域负责管理名字的可见性,引用和指针提供了不同的访问路径,const 把修改权限写进了类型声明,auto 简化了局部的类型书写,枚举和命名空间则为值和名字划出了清晰的边界。
在这最后一章中,我们要做的事情就是把所有这些零散的积木拼装到一起,组成一个完整的小程序:成绩报告器。
程序的目标非常简单:
这个程序仍然只是一个命令行小程序,但它的内部结构会比之前任何一个最小示例都完整得多。通过它,你能够亲眼看到这些基础语法是如何各司其职,共同服务于一个真实业务任务的。
作为收束章,本章的重点并不在于引入新概念,而是将前面学过的所有知识放进同一条主线里运行。一个能够被顺畅阅读、安全修改和灵活扩展的小程序,依赖的正是每个语法点在各自岗位上承担起清晰的职责——变量保存状态,函数拆开步骤,枚举表达固定结果,const 引用声明只读访问,命名空间把工具函数收拢到统一的范围之下。
先看数据流
在动手写代码之前,更稳妥的做法是先把数据在程序中的流动路径想清楚。成绩报告器需要一组 int 类型的分数作为输入:
std::vector<int> scores;
从外部输入进来的分数首先要经过范围检查。合法的分数被收入 scores 容器,非法的分数则被跳过或者打印提示信息。拿到一份完整的合法分数列表之后,程序就可以着手计算平均值:
double average = Average(scores);
随后根据平均值判断出对应的成绩等级:
GradeLevel level = GetGradeLevel(average);
最后将所有结果组织成一份报告输出到屏幕上:
PrintReport(scores);
这条数据流决定了函数应当如何拆分。当每个函数只负责其中一个环节的小任务时,主流程就会变得非常清晰。
很多初学者的习惯是一上来就直接动手写 main,写着写着就会发现变量越堆越多、分支越嵌越深。相比之下,更稳健的做法是在写代码之前,先把"数据从哪里进入、经过哪些加工步骤、最终以什么形态离开"这条线索捋清楚。成绩报告器的数据流虽然很短,但它已经具备了真实程序的基本骨架:输入、校验、计算、转换、输出。
这条数据流同时也能帮助我们判断每个变量应该安放在哪里。score 只是在读取输入时需要的一个临时变量,它的作用域应该被限制在 main 函数的输入循环附近。scores 保存着经过筛选的合法成绩数据,会贯穿后续的计算和输出环节。average 是从 scores 中派生出来的中间结果,适合放在 PrintReport 函数的内部。当变量的作用范围跟着数据流的走向来安排时,程序里就不会到处散落着难以追踪的临时状态。
数据流还能帮助我们判断函数之间的边界应该划在哪里。输入校验是一个独立的判断动作,适合单独封装成 IsValidScore;求平均值是一个纯粹的计算过程,适合单独封装成 Average;等级映射是一次状态转换,适合单独封装成 GetGradeLevel;输出报告则是程序与外部世界交互的边界,适合交由 PrintReport 来负责。函数的拆分不应该按照代码行数来机械划分,而应该按照数据所经历的处理步骤来自然切割。
用枚举表达等级
成绩等级是一组固定的、可以穷举的值,非常适合用 enum class 来建模:
enum class GradeLevel {
kExcellent,
kGood,
kPass,
kFail,
};
相比直接使用字符串或整数来表示等级,这种做法要稳固得多。GradeLevel 这个类型名本身就在明确告诉阅读者:等级只能从这组预定义的值中选取一个,不存在其他可能性。后续用 switch 处理等级逻辑时,每个分支的含义也会更加清晰。
之所以在这里选择枚举而不是字符串,是因为等级在本质上属于一组固定的内部状态。字符串适合在最终展示给用户的环节中使用,而枚举则更适合在程序的内部逻辑中承载状态判断。把内部状态和外部展示这两个关注点分开处理,是一种非常基础但极其重要的设计意识。
将枚举值转换为展示字符串的逻辑可以封装成一个独立的函数:
std::string ToString(GradeLevel level) {
switch (level) {
case GradeLevel::kExcellent:
return "excellent";
case GradeLevel::kGood:
return "good";
case GradeLevel::kPass:
return "pass";
case GradeLevel::kFail:
return "fail";
}
return "unknown";
}
这个函数把"等级应该如何展示"这一关注点集中收拢到了一个可控的位置。将来如果想把 excellent 改成中文显示,只需要修改这一个函数即可。
这也体现了函数拆分的一个常见工程价值:把变化点收拢起来。等级的划分规则发生了变化,去改 GetGradeLevel;展示文字需要调整,去改 ToString;分数的合法范围需要重新定义,去改 IsValidScore。每个函数的职责越单一,后续需要改动时定位起来就越容易。
将内部状态和外部展示分离开来,是很多小程序走向工程级代码的第一步。程序内部始终使用 GradeLevel::kExcellent 来进行逻辑判断,好处是类型系统能够提供稳定的安全保障;到了输出环节再将其转换为 "excellent" 字符串,好处是展示格式可以独立调整而不影响核心逻辑。如果从头到尾都用字符串来表达等级,那么拼写错误、大小写差异、多语言切换等问题都会渗透进业务判断的代码中,导致逻辑和展示纠缠在一起。枚举的作用就是把参与判断的状态牢牢约束住,而字符串只需要在最终的输出环节各尽其责。
用函数拆开每个步骤
按照前面分析的数据流,成绩报告器可以被拆分为以下几个函数:
bool IsValidScore(int score);
double Average(const std::vector<int>& scores);
GradeLevel GetGradeLevel(double average);
std::string ToString(GradeLevel level);
void PrintReport(const std::vector<int>& scores);
每个函数的职责都非常单一,各自只负责数据流中的一个处理环节。
这份函数列表本身就可以当作数据流的文字版来阅读:IsValidScore 决定输入数据能否进入合法列表;Average 把一组数据压缩成一个平均值;GetGradeLevel 把平均分转换为一个枚举等级;ToString 把内部等级转换成展示文本;PrintReport 负责将最终结果组合输出。主函数只需要按照这个顺序依次调用它们,程序的整体结构自然就浮现出来了。
IsValidScore 负责判断分数是否处于合法范围内:
bool IsValidScore(int score) {
return score >= 0 && score <= 100;
}
Average 负责计算一组成绩的平均值。它的参数声明为 const std::vector<int>&,表示函数只需要以只读方式访问外部的成绩列表,同时避免了复制整个容器所带来的性能开销:
double Average(const std::vector<int>& scores) {
if (scores.empty()) {
return 0.0;
}
int total = 0;
for (const auto score : scores) {
total += score;
}
return static_cast<double>(total) / scores.size();
}
GetGradeLevel 负责将平均分映射到对应的成绩等级:
GradeLevel GetGradeLevel(double average) {
if (average >= 90.0) {
return GradeLevel::kExcellent;
}
if (average >= 80.0) {
return GradeLevel::kGood;
}
if (average >= 60.0) {
return GradeLevel::kPass;
}
return GradeLevel::kFail;
}
PrintReport 负责最终的报告输出。有了它的存在,主函数的逻辑会变得非常干净:
void PrintReport(const std::vector<int>& scores) {
if (scores.empty()) {
std::cout << "no valid scores\n";
return;
}
const double average = Average(scores);
const GradeLevel level = GetGradeLevel(average);
std::cout << "count = " << scores.size() << "\n";
std::cout << "average = " << average << "\n";
std::cout << "level = " << ToString(level) << "\n";
}
当函数拆分完成后,main 只需要表达最顶层的主线逻辑:读取输入、保存合法成绩、调用输出报告。
这就是一个小程序从"能跑通"进化到"结构清楚"的关键所在。把所有逻辑一股脑塞进 main 当然也能完成任务,但阅读者就不得不在一个庞大的函数体内同时追踪输入、校验、求平均、评级和输出等多个逻辑层次。拆成函数之后,main 就像一本书的目录,概括了程序运行的主要步骤,而具体的实现细节则各自归位到了对应的函数中。后续在调试时,也可以围绕某一个特定的函数进行独立检查,而不需要在整个程序里大海捞针。
这份函数列表还可以充当一份职责检查清单。IsValidScore 只返回布尔值,不负责任何输出;Average 只计算平均值,不负责判断等级;GetGradeLevel 只将平均分映射为枚举,不负责打印任何内容;PrintReport 负责组织和展示报告,但不负责读取输入。一旦某个函数开始同时承担两件不同的事情,它的命名通常就会变得含混不清,后续的修改也更容易产生意料之外的连锁反应。
函数拆分还有一个不那么显眼但很有价值的好处:每个函数都可以用前面某一章的知识来独立解读。阅读 IsValidScore 时,关注的主要是布尔表达式的逻辑;阅读 Average 时,关注的是 const 引用参数、循环遍历和类型转换;阅读 GetGradeLevel 时,关注的是控制流和枚举的配合;阅读 PrintReport 时,关注的是提前返回和函数之间的调用组合。基础语法从来不是一堆孤立的知识条目,它们在这些小函数中各自找到了自己的用武之地。
const 引用和 auto 放在哪里
在这一章的小程序中,const 引用最频繁的出场位置是函数参数:
double Average(const std::vector<int>& scores);
void PrintReport(const std::vector<int>& scores);
scores 是一个可能包含大量元素的容器,复制它毫无必要。函数只需要读取其中的数据,所以参数声明为 const std::vector<int>&。这个参数形态同时向外传达了两层信息:不产生数据复制,且承诺只读访问。
在这里你能看到第 7 章和第 8 章的知识是如何在实际工程中组合运用的。& 表明函数直接接触的是外部的原始列表,const 则宣告函数通过这条参数路径只会进行读取操作。调用者甚至不需要打开 Average 的函数体去逐行检查,仅凭函数签名就能确信它不会对传入的成绩列表做任何修改——这就是类型系统赋予接口的信息密度。
auto 在这个程序中最适合出现的位置是局部的循环遍历:
for (const auto score : scores) {
total += score;
}
这里容器中的元素类型是 int,按值复制的开销极低。写成 const auto score 则进一步表明循环体内部不会对这份临时副本进行修改。如果将来遍历的是一个字符串列表,写法就应该相应调整:
for (const auto& name : names) {
std::cout << name << "\n";
}
归根结底,判断的准则依然是前面章节中确立的那一套:
小对象遍历 → auto value
需要修改原元素 → auto& value
只读大对象 → const auto& value
auto 在这个程序中的角色也是非常具体的。遍历 std::vector<int> 时,元素类型一目了然,写 const auto score 可以减少不必要的类型重复;遍历字符串或复杂对象列表时,const auto& 既能表达只读语义又能避免复制开销。auto 服务的是局部代码的简洁性,但它不能替代你对"是否复制、是否引用、是否只读"这些本质问题的判断。
在这个程序中选择 const auto score 而非 const auto& score,理由很简单:元素是 int,复制一个整数的成本几乎可以忽略不计,循环体也没有任何修改它的意图。但如果将来 scores 的类型变成了 std::vector<std::string>,就必须重新审视这个决定,不能机械照搬。auto 的具体写法永远要和元素的大小、是否需要修改、是否只需只读访问这三个因素绑定在一起做判断。这正是第 10 章中着重强调的读写意图分析方法。
完整代码
下面是完整的、可编译运行的代码。
在阅读完整代码时,不建议从第一行开始一路硬啃到底。更有效的方式是先看命名空间里提供了哪些能力,再看 main 函数是如何调用这些能力的。也可以反过来:先读 main 的主流程把握全局,遇到不清楚的函数名时再跳回到对应的定义去了解细节。真实工程中阅读代码的方式往往也是如此——先抓住主干流程,再按需展开局部细节。
#include <iostream>
#include <string>
#include <vector>
namespace grade_report {
enum class GradeLevel {
kExcellent,
kGood,
kPass,
kFail,
};
bool IsValidScore(int score) {
return score >= 0 && score <= 100;
}
double Average(const std::vector<int>& scores) {
if (scores.empty()) {
return 0.0;
}
int total = 0;
for (const auto score : scores) {
total += score;
}
return static_cast<double>(total) / scores.size();
}
GradeLevel GetGradeLevel(double average) {
if (average >= 90.0) {
return GradeLevel::kExcellent;
}
if (average >= 80.0) {
return GradeLevel::kGood;
}
if (average >= 60.0) {
return GradeLevel::kPass;
}
return GradeLevel::kFail;
}
std::string ToString(GradeLevel level) {
switch (level) {
case GradeLevel::kExcellent:
return "excellent";
case GradeLevel::kGood:
return "good";
case GradeLevel::kPass:
return "pass";
case GradeLevel::kFail:
return "fail";
}
return "unknown";
}
void PrintScores(const std::vector<int>& scores) {
std::cout << "scores:";
for (const auto score : scores) {
std::cout << " " << score;
}
std::cout << "\n";
}
void PrintReport(const std::vector<int>& scores) {
if (scores.empty()) {
std::cout << "no valid scores\n";
return;
}
const double average = Average(scores);
const GradeLevel level = GetGradeLevel(average);
PrintScores(scores);
std::cout << "count = " << scores.size() << "\n";
std::cout << "average = " << average << "\n";
std::cout << "level = " << ToString(level) << "\n";
}
} // namespace grade_report
int main() {
std::vector<int> scores;
std::cout << "Enter scores, non-number to finish:\n";
int score = 0;
while (std::cin >> score) {
if (grade_report::IsValidScore(score)) {
scores.push_back(score);
} else {
std::cout << "ignored invalid score: " << score << "\n";
}
}
grade_report::PrintReport(scores);
return 0;
}
编译方式如下:
clang++ -std=c++20 -Wall grade_report.cc -o grade_report
运行后输入:
100 80 70 -1 200 x
程序会自动忽略 -1 和 200 这两个超出合法范围的输入,在遇到非数字字符 x 时结束输入循环,然后输出由合法成绩构成的完整报告。
把完整代码放在这里,目的是为前面分散在各个章节中的语法知识提供一个共同的落脚点。阅读这段程序时,可以先从 main 的主流程入手把握全貌,遇到某个函数的细节不太确定时再跳回定义去核实——这种阅读方式比从第一行开始逐行死磕要高效得多,也更接近真实工程中的代码阅读习惯。
这段代码在设计上还做了一个重要的取舍:它没有为了追求表面上的"完整性"而引入尚未讲解的概念。输入失败时,程序通过 while (std::cin >> score) 自然结束循环;无效分数只做简单的提示打印;成绩列表为空时由 PrintReport 提前返回处理。它没有涉及异常机制、文件读写、类的封装或者复杂的错误处理体系。第一阶段小程序的目标是把结构写清楚,而不是一口气覆盖所有的工程能力。
这份代码里串起了哪些基础语法
std::vector<int> scores; 使用一个容器来保存多份成绩数据。int score = 0; 则是输入环节中使用的临时变量。
while (std::cin >> score) 利用输入表达式的返回值来控制循环的持续与终止。只要程序能够成功读取到一个整数,循环就继续执行;当输入流遇到非数字字符导致读取失败时,循环自然结束。
if (grade_report::IsValidScore(score)) 根据函数返回的 bool 值来选择接下来的执行路线。合法分数被收入 scores 容器,非法分数则打印一条提示信息后跳过。
namespace grade_report 把成绩报告器相关的所有函数和枚举收纳在同一个命名范围之内。主函数通过 grade_report:: 前缀来访问它们。
enum class GradeLevel 将成绩等级表达为一组有限的固定值集合。它让 GetGradeLevel 函数的返回值比使用普通字符串要稳定和安全得多。
Average(const std::vector<int>& scores) 和 PrintReport(const std::vector<int>& scores) 都使用了只读引用参数来访问成绩列表。函数既不需要复制整个容器的数据,也无法通过这条参数路径去修改容器的内容。
for (const auto score : scores) 在循环中遍历每一个成绩元素。由于元素类型是 int,复制的开销微乎其微。如果将来元素换成了体积更大的对象类型,可以相应改写为 const auto& 来避免不必要的复制。
static_cast<double>(total) / scores.size() 通过显式的类型转换确保除法运算走的是浮点数计算路径。这里呼应的正是表达式章节中讨论过的整数除法截断问题。
这份程序没有引入类、异常、文件 IO、动态内存管理和智能指针中的任何一项。它仅仅使用基础语法就完成了一个结构清晰的小任务,这也正是第一阶段的核心目标:先把小程序写清楚。
在这段代码中,基础语法之间的分工协作关系也展现得非常充分。while 负责驱动输入循环,if 负责处理合法性分支,函数负责拆分各个处理步骤,const 引用负责声明只读传参的接口意图,枚举负责精确表达结果状态,命名空间负责为一组相关名字划定归属。每一个语法工具都只做了一件朴素的事情,但当它们协同起来,程序就具备了清晰的结构。
阅读这段程序时,可以按照以下这条数据流路线来进行完整的复盘:
理清这条路线,比逐行背诵语法规则要重要得多。只要你能够说出数据是从哪里进入程序的、中途经过了哪些函数的加工处理、最终又以什么形式被呈现出来,你就已经在用工程化的方式去阅读 C++ 代码了。
这个小程序还能怎么扩展呢
如果要沿着工程化的方向继续发展,可以在此基础上添加很多能力。比如从文件中读取成绩数据,把无效输入记录到错误日志中,用类来封装报告对象,用异常机制来处理输入错误,用单元测试来覆盖各种边界情况,用 std::optional 来表达可能不存在结果的语义。
这些都属于后续阶段的内容。当前这份程序已经完整地展示了基础 C++ 的核心骨架:数据进入程序,类型约束数据的合法性,表达式计算结果,控制流选择执行路线,函数拆分逻辑,作用域控制名字的可见范围,引用和 const 管理访问方式与修改权限,枚举表达固定状态,命名空间组织代码结构。
当基础程序能够写得清楚明白之后,下一步就该深入到对象本身的世界中去了。对象是怎样被创建和销毁的,复制一个对象时究竟会发生什么,移动语义为什么在现代 C++ 中如此重要,资源又是如何跟随对象的生命周期自动释放的——这些问题会把你带向后续的对象生命周期、资源管理和 STL 系列。
本章的收束点,是把"语法知识点"转化为"程序的组织结构":变量、表达式、控制流、函数、作用域、引用、const、指针、auto、枚举、命名空间,每一个都可以被单独拎出来讲解,但在真正编写程序时它们永远是协同出场的。在基础阶段,不需要急于追求写出多么庞大的项目,先把这种规模的小程序写清楚:数据流清楚,函数职责清楚,类型意图清楚,名字边界清楚。当这些基本功扎实之后,后面再去学习对象模型、容器库、资源管理和工程实践时,才会拥有一个稳定的底座。
阅读导航




