ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++编程实例100篇:从语法学习到工程实践的进阶路径

C++编程实例100篇:从语法学习到工程实践的进阶路径

简介:这是一份以100个可运行C++程序为线索的编程实例集,覆盖基础语法、面向对象、函数、文件读写、图形编程与经典算法等主题,适合初学者跟练,也适合开发者查漏补缺。随包提供源码、工程配置与可执行文件,可对照验证运行结果,便于理解代码逻辑与编译流程。资源含557个文件,包括107个cpp源码、106个bmp位图、106个obj目标文件、100个exe程序,以及dsp/dsw等工程文件与少量头文件,压缩包约2.47MB。实例中还包括基于BGI的图形演示与配套素材,适合作为C++课程设计或自学上手的参考库。已有42000余人学习下载,是一套轻量而系统的编程练习资源。

1. C++编程实例100篇:打通「语法全会、动手全废」的那堵墙

学完C++语法,再打开编辑器却写不出第一行,这种症状几乎每个自学者都经历过。指针、引用、构造函数都能背,可真要落笔实现一个功能,却常常半天憋不出一段能编译的代码。C++编程实例100篇这类练习集,恰好是针对这个问题的解药:把庞大的C++知识拆成100个能独立完成的小题目,每个题目只聚焦一个知识点,做完一个就收获一次从编译到运行的正反馈。这套流程适合刚学完语法需要大量巩固练习的初学者,适合准备校招笔试的在校生,也适合初入公司需要写C++小工具的开发者。这篇笔记会从实例选题、工程组织、高频错误到代码质量,讲一套可以直接照做的完整练习路径。

2. 实例怎么选:100篇的难度分层、题目设计与排序

100篇不是随便凑数的,它的价值建立在合理的选题结构上。如果只是把网上零散的小题拼成100个,难度忽高忽低,知识点跳来跳去,练起来很容易中途放弃。我见过不少练习合集,数字是凑够了,但前20篇还在讲变量定义,第21篇突然跳到多线程,这种编排对新手是劝退而不是帮助。想让100篇真正可执行,难度分层、题目设计、排序逻辑三个环节要提前想清楚。

2.1 难度分层:40+40+20的三档配法

把100篇按难度切成三档,是让整套练习真正可执行的前提。第一档是入门,大约40篇,覆盖变量、运算符、分支、循环、函数、数组和字符串的基础操作。这一档的目标很直接:看到题目不用翻语法书,能独立写出能编译运行的代码。第二档是进阶,大约40篇,覆盖类与对象、封装继承多态、运算符重载、STL容器与算法、流与文件操作。这一档的目标是让学习者有能力拆解一个中等规模的需求,把“实现一个类”当成日常操作。第三档是工程,大约20篇,聚焦移动语义、智能指针、异常安全、多线程和常用设计模式。到了这一档,代码能不能跑已经不是重点,重点是能否说出每个设计选择背后的代价。

这个比例不是拍脑袋定的。入门档太少,学完后面对类和模板会心虚;入门档太多,又容易在简单的循环里消磨耐心。进阶档占最大比例,因为大多数实际工作场景是在写业务逻辑,而业务逻辑的核心就是类和标准库容器的组合。工程档放在最后20篇,像一个过渡带,让学习者在刷完语法后有足够空间消化那些真正影响代码质量的概念。如果时间紧张,我一般建议至少把前两档的一半做完,再往后深入,因为工程档里的例子对语法基础的要求很高,基础不牢去碰移动语义和智能指针,很容易变成照抄模板。

这里还有一个容易被忽略的细节:分档之后,每一档内部最好也保持难度递增。以入门档为例,前10篇只用顺序结构,中间10篇加入分支,最后20篇才系统地练循环和函数。这样每一篇都只引入一个不确定因素,出问题时定位范围小,学习者的挫败感也低。分档的意义就在于让练习者始终处于“有一点挑战但够得着”的状态,而不是一次性丢出一堆难易不分的题目。

2.2 题目设计:一句话需求加输入输出样例

实例的题目描述质量,直接决定了练起来顺不顺畅。我见过不少实例集,题目写了一长串背景故事,读完还要自己提炼需求,这对新手很不友好。好题目应该满足一个标准:用一句话讲清楚要做什么,再配一组输入和输出样例。设计题目时可以用固定模板:行为描述、输入格式、输出格式、样例。行为描述尽量用动词开头,比如“实现一个函数,把字符串按空格拆分成单词数组”;输入格式说清楚数据类型和范围,比如“第一行一个整数n(1≤n≤1000)”;输出格式说明每一行是什么;样例至少两个,一个覆盖普通情况,一个覆盖边界情况,比如空串、最大值、负数。

实例编号行为描述输入样例输出样例
021判断一个整数是否为素数17YES
022输入一行单词,输出出现次数最多的单词hello world hellohello 2
023实现一个队列类,支持入队、出队和判空push 1 push 2 pop1

这种一板一眼的设计不是没有用,它其实在训练一种关键的工程能力:从需求描述到输入输出约束的转化。真到工作中,产品提需求很少给清边界,习惯了实例里这种明确约束,再看那些模糊需求时,至少知道该反问哪些问题。设计题目时另一个注意点是样例要能手工推导,不要让学习者为了验证一个输出还要再写一段计算脚本。有人觉得样例太详细会让人不动脑,我的看法相反:练习题的价值在实现过程,不在读题过程,把理解成本降下来,精力才能花在写代码上。

2.3 排序逻辑:让每篇练习踩在前一篇的肩膀上

实例的顺序必须按知识依赖来排,而不是按难度随机打散。我经常见到一些练习集把“实现一个链表”排在很靠前,理由是“直观”。但链表依赖指针、动态内存、析构函数,新手第一次接触只会照抄然后一脸茫然。合理的做法是每引入一个新知识点时,实例里同时用到的是前一篇已经出现过的内容。具体排序可以参照三条原则:先内置后自定义,前20篇只用内置类型和标准库,不出现自己写的类;先底层后封装,先写手动管理的数组和结构体,再引入类把这些封装起来;先单线程后并发,多线程实例一定要放到最后。

按这个思路,前30篇可以全是函数和数组的组合,第31篇引入结构体,第40篇才出现第一个自定义类。每条原则背后是同一个考量:让每一个新概念出现时,周围的代码都是学习者写法最熟悉的部分,这样注意力能全部集中在新知识点上。排序对自学者还有一个隐藏作用,它是一种天然的复习机制。当一篇实例同时用到类的构造、STL容器和文件操作时,学习者被迫把前面分散的知识串在一起,这种串接只靠重复做题是练不出来的。如果你拿到一套现成的100篇实例,发现顺序比较随意,我一般的做法是自己重排索引,把依赖关系理顺后再开始,而不是硬着头皮按原顺序刷。宁可先跳过几篇,也比在知识断档里反复怀疑自己强。

3. 把100个实例组织成工程:CMake与编译调试的最小闭环

实例练习最容易犯的一个错是只写一个几十行的 main.cpp,跑通就丢,下一篇从零开始。这样练50篇,代码组织能力几乎没有提升。实例数量一多,编译、运行、调试的效率会直接影响练习节奏。所以从第1篇开始就建立一套固定的工程结构,反而是投入产出比最高的准备。

3.1 标准代码骨架:从注释到验证样例

同样是实例,代码组织方式不同,练习效果差很多。我见过不少新手把每一篇都写成一个塞满全局变量的 main 函数,跑通就完事。更好的做法是给每一篇实例一个固定骨架:题目注释、需求分析、实现代码、验证样例。整个文件的结构保持一致,读代码的人一眼就能找到入口。下面是一篇完整实例的代码骨架,题目是“统计一行文本中每个单词的出现次数”,输入输出约束写在注释里。

// 题目:统计一行文本中每个单词的出现次数 // 输入:一行文本,单词之间用空格分隔,长度不超过 10000 // 输出:每行一个单词和出现次数,按单词字典序排列 #include <iostream> #include <map> #include <sstream> #include <string> int main() { std::string line; // 读取整行,避免空格导致只读一半 std::getline(std::cin, line); std::istringstream stream(line); std::string word; std::map<std::string, int> freq; // 按空白切分单词,逐一统计 while (stream >> word) { ++freq[word]; } // map 默认按键升序排列,直接输出即可 for (const auto& entry : freq) { std::cout << entry.first << ": " << entry.second << std::endl; } return 0; }

这个实例里有几个点值得停下来想一下。std::istringstream 把一行字符串变成输入流,operator>> 自动跳过空白,天然实现了按空格拆词,不需要手写切分逻辑。std::map<std::string, int> 的 operator[] 在键不存在时会自动插入一个值为0的条目,所以 ++freq[word] 一句就同时完成了插入和自增,不用先判断键是否存在。范围 for 遍历 map 时,entry.first 是键,entry.second 是值。这段代码虽然短,但把容器、IO流、结构化绑定三个知识点的组合用得很典型,比单独背语法更能建立“这些特性可以一起用”的意识。

3.2 CMake 统一管理:新增实例不用改构建脚本

100个实例如果用命令行一条一条编译,时间都浪费在敲命令和找历史命令上。从第1篇开始用 CMake 组织工程,是最值得投入的前期准备。推荐的目录结构如下,每个子目录放一个阶段的实例,文件名前缀跟实例编号保持一致。

cpp100/ ├── CMakeLists.txt ├── 01_basic/ │ ├── 001_hello.cpp │ ├── 002_fibonacci.cpp │ └── ... ├── 02_advanced/ │ ├── 041_stack_class.cpp │ └── ... └── 03_project/ ├── 081_smart_pointer.cpp └── ...

顶层 CMakeLists.txt 可以写成下面这样,它做的事情是:把每个子目录里的 .cpp 文件收集起来,逐个生成同名的可执行文件。

cmake_minimum_required(VERSION 3.16) project(cpp100 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 遍历阶段目录,自动收集所有源文件 foreach(dir IN ITEMS 01_basic 02_advanced 03_project) if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/${dir}) aux_source_directory(${CMAKE_CURRENT_SOURCE_DIR}/${dir} DIR_SRCS) foreach(src ${DIR_SRCS}) get_filename_component(exe_name ${src} NAME_WE) add_executable(${exe_name} ${src}) endforeach() endif() endforeach()

这段构建脚本里,aux_source_directory 会把目录下所有 .cpp 文件加入列表,get_filename_component 取每个文件去掉扩展名的名字作为可执行文件名。这样以后每新增一个实例,只需要把对应的 .cpp 文件放进合适目录,重新执行 cmake 和 make 就会自动生成目标,CMakeLists.txt 完全不用改。CMAKE_CXX_STANDARD 设置为 17,是为了让范围 for、auto、智能指针这些现代语法在实例里直接可用。有一点需要留意,如果某个目录里放了测试辅助文件,比如一个公共头文件,用 aux_source_directory 会把其中的 .cpp 也编成可执行文件,这时建议改成显式列出源文件,不要让收集逻辑把你的构建脚本变成黑匣子。

3.3 编译与调试最小工作流:g++ 与 AddressSanitizer

实例的编译建议从一条固定命令起步,尤其是从一开始就带上警告和内存检查参数。我习惯在项目根目录建一个 build 目录,构建和运行分开做。下面是针对单个实例文件的快速编译命令。

# 进入实例所在目录 cd cpp100/01_basic # 单文件快速编译,打开警告并开启内存检查 g++ -std=c++17 -Wall -Wextra -fsanitize=address,undefined -g 001_hello.cpp -o 001_hello # 运行 ./001_hello

这几个参数的作用值得记一下。-g 生成调试信息,崩溃时能定位到具体行号。-Wall 和 -Wextra 打开常见警告,比如未使用变量、无符号整数比较。-fsanitize=address,undefined 会在运行时检查越界访问、未定义行为和使用已释放内存,这些问题平时不一定触发崩溃,开了它基本能当场报出来。唯一的代价是程序运行变慢一些,但对实例练习来说完全无所谓。

调试时最常用的是 gdb,几个命令就够。遇到程序崩溃或输出不对,先 bt 查看调用栈,再用 frame 跳到对应的层级,接着 print 打印变量。对于读入相关实例,一个很有效的技巧是重定向输入:把样例保存成文件,运行时用 < 符号喂给程序,这样每次运行都能复现同样的输入,不用反复手敲。

提示:练习阶段建议始终打开 -fsanitize=address,它能让多数“输出莫名其妙”的问题变成带文件名和行号的直接报错,这是排查内存问题最划算的一步。

4. 实例练习高频避坑:C++100篇里最常踩的5个问题

刷实例的过程里,真正消耗精力的往往不是语法,而是一批只看代码很难发现的运行期问题。这些问题不解决,会让学习者误以为“是我不适合学C++”,其实只是少了一点经验。下面这5个坑,在100篇的实题目里出现频率最高,每一条都按“现象、原因、解决”的顺序说清楚。

4.1 cin 与 getline 混用:缓冲区残留读出一行空串

现象:程序先执行 std::cin >> n; 读了一个整数,紧接着用 std::getline(std::cin, line); 读一行字符串,结果 line 的内容是空串。

原因:operator>> 在读完整数后,会把末尾的换行符留在输入缓冲区中。getline 读到这个换行符,认为“这一行已经结束了”,直接返回。这个换行符并不会导致程序出错,但是会让字符串读取结果完全偏离预期。

解决:在两次读取之间调用 std::cin.ignore();,它默认丢弃输入流中的下一个字符,正好把残留的换行吞掉。

int n; std::string line; std::cin >> n; std::cin.ignore(); // 丢弃缓冲区里残留的换行符 std::getline(std::cin, line);

之后读取 n 后面的整行就正常了。这个坑在练习输入输出类实例时几乎必踩,而且很难靠报错信息定位,因为程序完全正常运行,只是数据不对。更稳妥的习惯是:如果一行内容的格式不确定,就统一用 getline 读取,再用 std::istringstream 解析,从源头上避免混用。

4.2 数组越界不一定崩溃:内存损坏的隐蔽现场

现象:声明 int arr[10]; 然后执行 arr[10] = 5; 之后打印某个局部变量,发现它的值变成了5,而程序没有报告任何错误。

原因:C++ 不检查数组下标。arr[10] 访问的是数组之外紧邻的内存,很可能正是编译器安排的另一个局部变量的位置。写了 arr[10],等于悄悄改了那个变量的值。程序不崩溃,反而让问题更难被发现。

解决:优先使用 std::vector 或 std::array,需要越界安全时用 at(),它会抛异常而不是静默越界。如果非要用原生数组,搭配 -fsanitize=address 编译,越界会在第一次发生时被检测到并报出来。我在练习里发现,许多“莫名其妙得到负数”“循环次数不对”的问题,根源都在数组越界,开着这个选项一下就现形了。

4.3 循环里 erase:vector 迭代器失效

现象:遍历 std::vector 时调用 erase 删除元素,程序偶尔正常,偶尔崩溃,或者出现漏删元素。

原因:vector 删除元素后,所有指向被删除位置及其之后的迭代器都会失效。循环里的 ++it 是在已经失效的迭代器上执行,属于未定义行为,表现因编译器和数据规模而异。

解决:接收 erase 返回的迭代器,它指向被删除元素的下一个位置,循环体内不要再额外执行 ++。

std::vector<int> v = {1, 2, 3, 4, 5}; // 删除所有等于3的元素 for (auto it = v.begin(); it != v.end();) { if (*it == 3) { it = v.erase(it); // erase 返回下一个有效迭代器 } else { ++it; } }

如果一次要删除多个满足条件的元素,更推荐用 std::remove_if 配合 erase,它先把不满足条件的元素移动到前面,再统一删除尾部,整个过程只涉及一次真正的搬移,代码也更短。这个坑在实现“学生信息管理”“联系人列表”这类增删改查实例时出现的频率很高,值得在练习时专门留意。

4.4 delete 之后继续用指针:悬空指针的未定义行为

现象:new 一个对象,delete 之后发现指针仍然指向原来的地址,于是又通过这个指针调用了一次方法,程序时好时坏。

原因:delete 只是释放了堆内存,并没有把指针变量本身的地址值清零。指针仍然指向那块已归还给系统的内存,继续使用就是未定义行为。如果系统把那块内存分配给了别的对象,你还会通过旧指针修改到其他对象的数据,几乎不可能排查。

解决:delete 后立即把指针置为 nullptr,置空后再误用会直接触发空指针访问,错误能立刻暴露。更根本的做法是优先使用 std::unique_ptr 和 std::shared_ptr,让智能指针在析构函数里自动释放资源,从根源上避免手动管理。

int* p = new int(42); delete p; p = nullptr; // 置空,防止误用 auto sp = std::make_unique<int>(42); // 不需要手动 delete,离开作用域自动释放

4.5 没写拷贝构造:浅拷贝与双重释放

现象:类里有一个指针成员,构造函数用 new 分配内存,没有实现拷贝构造函数。代码里把这个类的对象按值传给函数或按值返回,程序运行结束或返回时崩溃。

原因:默认的拷贝构造函数做的是逐字节复制。两个对象的指针成员指向同一块堆内存,当两个对象依次析构时,同一块内存被 delete 了两次,第二次 delete 触发崩溃。这个错误在返回值优化缺失的旧编译环境下尤其明显,但新手往往意识不到是拷贝构造的问题。

解决:遵循三/五法则,只要自定义了析构函数,就必须同时考虑拷贝构造和拷贝赋值。更稳妥的选择是用 std::string、std::vector 这类标准库容器替代裸指针成员,让编译器生成的默认拷贝行为本身就正确。

class Text { std::string data; // 用标准库类型管理内存,避免手工维护 public: explicit Text(const std::string& str) : data(str) {} };

这一条在练习“自己封装字符串”“实现自定义容器”时尤其要注意。封装的类一旦涉及指针或动态数组,第一步就是检查三个特殊成员函数是否齐全。等到程序崩溃再去查,费时费力,而且大概率查不出直接原因。

5. 从能跑到能看:给100个实例做一轮代码质量体检

实例跑通只是完成了50%,剩下的一半是让代码经得起追问。很多实例能运行,但经不起修改:换个输入规模就超时,加一个功能就要改三处,别人接手根本不敢动。给每篇实例做一轮质量体检,把基础习惯补齐,这套练习的价值会翻倍。

5.1 const 引用与 RAII:实例里最容易被忽略的好习惯

实例跑通之后,第一轮体检应该从 const 入手。函数参数能写成 const 引用的,就不要按值传递。按值传入 std::vector 会触发一次完整的拷贝,对这个拷贝的修改不会影响外部调用方,反而掩盖了拷贝构造带来的开销和潜在问题。改成 const 引用后,代码意图也更清楚:不复制,同时保证函数内不能修改原数据。

// 按值传入:触发拷贝,外部不可见,白白多一次复制 void print(std::vector<int> values); // 改为 const 引用:不复制,同时保证函数内不能修改原数据 void print(const std::vector<int>& values) { for (int v : values) { std::cout << v << ' '; } }

RAII 是 C++ 里值得单独列出来的资源管理思路,含义是让资源生命周期绑定到对象生命周期。文件句柄、互斥锁、堆内存都属于资源,放进一个管理类后,离开作用域时析构函数会自动释放,就不会再有“忘关文件”“忘释放内存”的问题。std::ifstream 和 std::unique_ptr 就是 RAII 的现成例子,实例里凡是打开文件、申请内存的地方,都应该优先用它们,而不是手动调 close 或 delete。很多学员在练习时觉得 RAII 是抽象概念,其实它就在每一次文件读取和动态内存分配里。

5.2 错误处理:返回码与异常的选择标准

实例里写文件读取、除法运算、内存分配时,都要面对一个选择:错误怎么报告。一条简单的经验是:错误发生在局部、调用方知道怎么处理时,用返回码;错误会沿调用链向上传递、中间层不关心细节时,用异常。实例练习建议把两种写法各用几篇,再形成自己的偏好。

// 返回码风格:适合简单实例,调用路径直白 bool open_config(const std::string& path, std::ifstream& ifs) { ifs.open(path); return ifs.is_open(); } // 异常风格:适合错误必须穿过多层调用的情况 double divide(double a, double b) { if (b == 0.0) { throw std::invalid_argument("divisor cannot be zero"); } return a / b; }

返回码的缺点是容易被忽略,调用方不检查返回值,错误就悄悄放过去了。异常的问题则相反,它强制调用方处理,但也不是所有场景都适合用异常,高频循环里抛异常会明显拖慢速度。实例练习里我一般的做法是:函数内部能处理就处理掉,处理不了就向上抛,让调用点决定怎么办。不要在一篇实例里混用两种风格,一致性比选哪种风格更重要。

5.3 最小测试:三个用例守住的正确性

实例要做对,靠肉眼盯输出很快就会漏掉边界情况。给每个实例配一组最小测试,是投入产出比最高的练习习惯。三个用例基本够用:一个正常输入,一个边界输入,一个非法输入。以加法函数为例,测试可以这样写。

#include <cassert> int add(int a, int b) { return a + b; } int main() { assert(add(2, 3) == 5); // 正常输入 assert(add(0, 0) == 0); // 边界:都为0 assert(add(-1, 1) == 0); // 边界:正负抵消 return 0; }

assert 在 Release 编译下会被去除定义,所以它只适合练习阶段,不适合作为线上错误检查。真正工程里会使用专门的测试框架,比如 GTest 或 Catch2。练习初期用 assert 的好处是没有额外依赖,一个头文件就能跑。写测试时要注意,测试用例要模仿真实输入的格式,尤其是涉及整行读取的实例,如果测试里用 std::cin,建议用重定向把样例喂给程序,这样测试结果才稳定,不会因为手动输入多敲了一个空格导致误判。

6. 100篇练完之后:把实例改造成一个小项目的具体技巧

练完100篇,最容易踏出的一步是把两个实例拼成一个组合体。以“统计单词频率”和“文件读取”为例,可以拼接成“统计整个文件的词频并按次数排序”。改造步骤其实只有三步:第一步把输入从 std::cin 换成 std::ifstream,文件名由命令行参数传入;第二步把统计结果从用 std::map 自动排序改成按次数排序,这里会用到 std::sort 配合 lambda;第三步把输出写到 result.txt,让程序从演示脚本变成一个能处理真实文件的工具。

这一步值得做的原因在于,它强迫你把输入来源、输出去向、处理逻辑三个部分解耦。真实项目里,这三个部分几乎总是独立演进的,输入从控制台改成网络,输出从文件改成数据库,但核心处理逻辑保持不变。在实例阶段体验一次这种改造,比背十遍“高内聚低耦合”都管用。改造成小项目时,你也会第一次遇到“改了一处导致另一处不工作”的问题,这正是从练习转向工程的关键节点。

我自己的习惯是每个阶段练完,挑一篇印象最深的实例重新改造一遍,看看能不能换一个输入来源,或者加一个约束条件。这个小习惯让我后来接手别人代码时,第一反应不是抱怨结构乱,而是先判断哪些地方能改、哪些地方动了会导致连锁反应。C++实例练习的意义不在于做完100篇,而在于做完之后,面对一个没有现成答案的问题时,敢动手拆、能拆得动。希望这套流程能帮到你,先把第一批实例跑起来,跑完10个再根据手感调整节奏,比把时间花在反复挑选资料上值得得多。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进