ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C++码图代码实战:字符图案、类封装与编译链接避坑

C++码图代码实战:字符图案、类封装与编译链接避坑

简介:面向电子科技大学C++课程学习者的码图代码合集,聚焦课堂练习与上机题目的参考实现,适合正在学习面向对象、准备课程考核或希望补强基础语法的学生对照研读。压缩包共44个文件,全部为cpp源码,整体约18KB,体积轻便,按题目编号组织,覆盖变量与流程控制、函数与命名空间、类与对象、继承与多态、运算符重载、异常捕获、文件操作,以及链表、排序、矩阵、大整数等典型算法练习。资源已有3630人学习,说明其在课程同步练习中具有一定参考价值。读者可通过逐题阅读与调试,理解类设计、接口封装和多态调用方式,掌握常见异常处理与边界情况排查思路,并借助排序、查找、字符串统计等题目巩固算法实现能力,形成可复用的课程练习代码参考。

1. 从课程群里那份“码图代码”压缩包说起

上学期期末,课程群里有人丢了个压缩包,名字就叫码图代码,解压后三十多个.cpp躺在按周分好的文件夹里,从1.cpp一路排到test_final_final(2).cpp。这是某高校 C++ 课程上机题的参考实现集合,覆盖字符图案打印、数组统计、类封装、文件读写这几类典型题目。它不解决任何业务问题,作用很直接:让你在最短时间内把 C++ 从“看得懂”推到“编译得过、跑得出正确结果”。适合正在上 C++ 课、需要对照跑通作业的同学,也适合工作几年后想用一套小而全的样例把编译链接、调试流程重新过一遍的人。代码本身不难,难的是怎么从一个散装文件夹里把环境跑起来,再挑出能复用的骨架。

2. 项目定位与技术栈:这份 C++ 码图代码集里到底装了什么

2.1 目录结构就是题型索引,按周分而不是按知识点分

打开压缩包第一眼会有点懵,文件名没有统一前缀,但目录是按教学周切的,这个划分方式其实比按“循环、数组、类”来分更贴近实际使用场景——你是拿着本周作业来找对应实现的,不是来读教材章节。

cpp-dmap/ ├── week02_basic/ # 输入输出、运算符、分支 │ ├── 01_hello.cpp │ ├── 02_swap.cpp │ └── 03_grade.cpp ├── week03_loop/ # 循环与字符图案 │ ├── pattern_triangle.cpp │ ├── pattern_diamond.cpp │ └── pattern_hollow.cpp ├── week05_array/ # 一维/二维数组、排序查找 │ ├── score_stats.cpp │ ├── matrix_transpose.cpp │ └── binary_search.cpp ├── week06_class/ # 类与对象、封装 │ ├── student.h │ ├── student.cpp │ └── main.cpp ├── week08_file/ # 文件流与批量处理 │ ├── file_report.cpp │ └── data/scores.txt ├── utils/ │ └── io_helper.h ├── Makefile └── README.md

week02_basic里基本都是单文件、单main,cin >>读、cout <<写,没有别的依赖;week03_loop是码图题的集中区,三角形、菱形、空心图形都在这里;week05_array开始出现多函数协作;week06_class是唯一拆成头文件加实现文件的地方,也是整份代码里结构最像工程的一块;week08_file带了一个data/scores.txt,说明题目要求从文件读成绩、算出统计量再写回文件。

判断项目边界有个简单办法:看有没有第三方依赖。整份代码只#include标准库头文件,没有 Qt,没有 OpenCV,没有图形界面。这点很关键,“码图”这个词容易让人误以为是可视化绘图,实际是字符图案,跑出来是控制台里的一片星号和空格。下载之前想清楚这件事,能省掉一半“怎么没窗口”的困惑。

2.2 只依赖标准库,反而让兼容性成了主要问题

因为没有第三方库,理论上任何支持 C++ 的编译器都能编。但教学代码有个通病:作者在机房机器上写的时候是什么样,交上来就是什么样。这份代码里能看到两种风格混在一起——一部分文件老老实实#include <iostream>、#include <vector>,另一部分是#include <bits/stdc++.h>加using namespace std;。

bits/stdc++.h是 GCC 特有的聚合头,MSVC 和 Clang 的 libc++ 上都没有。如果读者在 Windows 上装了 MSVC 或者 clang-cl,第一条编译命令就会卡在这里。我的处理方式是保留原文件不动,但编译时用 g++(MinGW 也行),或者手动把它替换成实际用到的几个头文件:

// 把 bits/stdc++.h 换成显式包含,可移植性立刻上来 #include <iostream> #include <string> #include <vector> #include <algorithm> #include <numeric>

用using namespace std;在教学代码里很常见,放到稍大的工程里就是隐患,尤其几个源文件都写一遍、再和std::distance、std::count这类名字撞上。看这份代码时不用急着全改,先跑通,等第 6 章做二次开发的时候再统一收口。

2.3 标准版本选 C++11 就够,但要知道哪些特性机房不支持

代码里用到了auto、范围 for、nullptr、std::vector的初始化列表,这些都是 C++11 的东西。选 C++11 有两个理由:一是这份码图代码本身没用到更高版本的语言特性,二是很多教学机房还停留在 VS2010 那个时代,它对 C++11 只支持了一部分。把兼容性摊开看会比较清楚:

特性需要标准VS2010 支持情况建议
auto类型推导C++11部分支持单文件里可以放心用
范围 forfor (auto& x : v)C++11不支持机房编译失败首犯
nullptrC++11不支持改成NULL或0
移动语义std::moveC++11不支持类封装题里会出现
std::accumulate初值 0.0C++98 起支持注意别写成 0

表格里最右边那列是血泪经验:真正让作业跑不起来的往往不是语言版本,而是accumulate的初值写成了整数0,后面会细说。编译命令统一用-std=c++11,加上-Wall -Wextra把警告打开,能提前暴露一批“能编过但结果是错的”问题,这比事后调试省事得多。

3. 核心代码拆解:三条典型题型的完整实现链路

3.1 字符图案输出:数学法和坐标判定法是两套思路

码图题里最典型的是给一个奇数 n,打印边长 n 的字符菱形。新手容易写成上下两个独立循环,上半部分一套、下半部分复制一遍改改,代码长且容易错位。这份代码里给的写法是先把每行的星号数算出来,再统一输出:

// pattern_diamond.cpp // 输入一个正奇数 n,输出边长 n 的字符菱形 #include <iostream> #include <string> using namespace std; int main() { int n; if (!(cin >> n) || n <= 0 || n % 2 == 0) { cerr << "n must be a positive odd number" << endl; return 1; // 非法输入直接退出,不进入绘图逻辑 } int mid = n / 2; for (int i = 0; i < n; ++i) { // i 在上半部分(含中线)时星号递增,下半部分递减 int stars = (i <= mid) ? (2 * i + 1) : (2 * (n - 1 - i) + 1); int spaces = (n - stars) / 2; // 左右留白对称,靠左补齐 cout << string(spaces, ' ') << string(stars, '*') << '\n'; } return 0; }

i是行号,从 0 开始;mid是中线下标。stars的表达式用了一个三元判断,把“上行递增、下行递减”压成一行,避免复制粘贴带来的错位风险。spaces用(n - stars) / 2保证左右对称。string(cnt, ch)是标准库的构造方式,比手写内层循环干净。

参数上有两个点值得说清楚。第一,n必须是正奇数,偶数输入会让mid的取整把图形压歪,所以这里直接拦截并返回 1,让调用方能从退出码判断失败。第二,行末不能有多余空格,很多在线判题系统对行末空白是严格比对的,本地看着没问题、提交上去就是格式错误。string(stars, '*')天然不会产生尾部空格,这是它比“先打星号再打空格”更稳的地方。

同一类题还有另一种通用解法,叫坐标判定法:把图案看成一个 n×n 的网格,双重循环遍历每个坐标(r, c),写一个条件判断这个点该输出星号还是空格。空心菱形、沙漏、沙漏套菱形这类变体用坐标法更省事,因为只需要改判定条件,外层框架不用动:

// 坐标判定法:空心菱形,只画边界 for (int r = 0; r < n; ++r) { for (int c = 0; c < n; ++c) { int d = abs(r - mid) + abs(c - mid); // 曼哈顿距离 cout << (d == mid ? '*' : ' '); // 距离等于半径即在边界上 } cout << '\n'; }

abs(r - mid) + abs(c - mid)是到中心的曼哈顿距离,等于半径mid的点正好构成菱形边界。这种写法在码图题里属于“一法通吃”,遇到新图案先想能不能用距离或不等式描述,比逐行硬凑快得多。两种方法各存一份在week03_loop里,方便对照。

3.2 数组与函数:把统计逻辑拆成三个纯函数

成绩统计题的要求通常是读一批分数,输出平均分、中位数、最高最低分。全塞进main也能跑,但一旦要改其中一个口径就得动整段。这份代码把它拆成三个函数,每个只做一件事:

// score_stats.cpp #include <iostream> #include <vector> #include <algorithm> #include <numeric> using namespace std; // 平均分:初值必须写 0.0,否则整数除法会把小数截掉 double average(const vector<int>& v) { if (v.empty()) return 0.0; return accumulate(v.begin(), v.end(), 0.0) / v.size(); } // 中位数:传值,排序不影响调用方手里的原数组 double median(vector<int> v) { if (v.empty()) return 0.0; sort(v.begin(), v.end()); size_t n = v.size(); return (n % 2 == 1) ? v[n / 2] : (v[n / 2 - 1] + v[n / 2]) / 2.0; } // 最高最低:返回 pair,避免用全局变量传结果 pair<int, int> min_max(const vector<int>& v) { auto mm = minmax_element(v.begin(), v.end()); return {*mm.first, *mm.second}; }

average的参数是const vector<int>&,不拷贝、不改动,这是读数据的标准写法。accumulate的第三个参数是累加初值,写成0的话整个表达式会按int计算,最后除以size()又是整数除法,平均分永远是个整数——这个坑在作业里出现频率极高。写成0.0之后类型提升到double,结果才对。

median特意用了传值,因为函数内部要排序,传引用会把调用方的原始顺序打乱。代价是一次拷贝,对这种几十个元素的数组完全可以接受。偶数个元素取中间两个的平均,除以2.0而不是2,道理同上。

min_max返回std::pair,调用方用auto [lo, hi] = min_max(v);解构,比“传两个引用参数出去”可读性好。minmax_element一次遍历同时拿到两个极值,比调两次min_element、max_element少扫一遍。

3.3 类封装:Student 的三文件拆分

week06_class是整份代码里唯一按“头文件 + 实现 + 驱动”拆开的地方,也是最适合拿来练工程习惯的一块。头文件里只放声明和极短的 inline 函数:

// student.h #ifndef STUDENT_H // 头文件守卫,防止重复包含 #define STUDENT_H #include <string> #include <vector> class Student { public: Student() = default; Student(std::string id, std::string name, std::vector<int> scores); double average() const; // const 成员函数,不修改对象 bool operator<(const Student& other) const; // 供 std::sort 使用 const std::string& id() const { return id_; } private: std::string id_; std::string name_; std::vector<int> scores_; }; #endif

实现文件里补全构造和成员函数:

// student.cpp #include "student.h" #include <numeric> #include <stdexcept> #include <utility> Student::Student(std::string id, std::string name, std::vector<int> scores) : id_(std::move(id)), name_(std::move(name)), scores_(std::move(scores)) { if (scores_.empty()) { throw std::invalid_argument("scores must not be empty"); } } double Student::average() const { return std::accumulate(scores_.begin(), scores_.end(), 0.0) / scores_.size(); } bool Student::operator<(const Student& other) const { if (average() != other.average()) { return average() > other.average(); // 按平均分降序 } return id_ < other.id_; // 同分按学号升序,保证排序稳定可预期 }

构造函数用移动语义接参,std::move把字符串和 vector 的所有权直接搬进来,少一次深拷贝。空分数直接抛std::invalid_argument,比让average()里出现除零更早暴露问题。operator<里先比平均分再比学号,是为了让同分学生的顺序确定——如果不加第二级比较,std::sort对相等元素的相对顺序不作保证,同一份数据跑两次可能输出不同,对拍的时候会误判成 bug。

驱动文件把对象装进 vector,读入、排序、输出一条龙:

// week06_class/main.cpp #include "student.h" #include <algorithm> #include <iostream> #include <vector> int main() { std::vector<Student> list; std::string id, name; int s1, s2, s3; while (std::cin >> id >> name >> s1 >> s2 >> s3) { list.emplace_back(id, name, std::vector<int>{s1, s2, s3}); } std::sort(list.begin(), list.end()); for (const auto& s : list) { std::cout << s.id() << ' ' << s.average() << '\n'; } return 0; }

emplace_back直接在容器里构造对象,省一次临时对象;while (cin >> ...)靠流状态判断结束,读到文件尾或非法输入自动退出,这是命令行小工具里最常见的读法。

3.4 文件读写:相对路径是最容易翻车的地方

week08_file的题目要求从data/scores.txt读成绩,统计后写到data/report.txt。代码本身不复杂:

// file_report.cpp #include <fstream> #include <iostream> #include <numeric> #include <vector> int main() { std::ifstream fin("data/scores.txt"); // 相对当前工作目录 if (!fin) { std::cerr << "cannot open data/scores.txt, check working directory\n"; return 1; } std::vector<int> scores; int x; while (fin >> x) scores.push_back(x); std::ofstream fout("data/report.txt"); if (!fout) { std::cerr << "cannot write data/report.txt\n"; return 1; } double avg = scores.empty() ? 0.0 : std::accumulate(scores.begin(), scores.end(), 0.0) / scores.size(); fout << "count=" << scores.size() << "\n"; fout << "average=" << avg << "\n"; return 0; }

两个流对象都做了打开检查,失败时打到cerr并返回非零。这里不写检查也能编过,但一旦路径不对就是静默读到空数据,最后输出count=0,看起来像逻辑错误,实际是文件没打开——这是最容易浪费半小时的一类问题。相对路径的基准是进程的当前工作目录,不是源文件所在目录。在终端里cd到项目根再运行没问题,从 IDE 里点运行按钮,工作目录常常被设成build/或者源文件目录,同一个可执行文件行为就不一样了。

4. 环境搭建与运行:从解压到第一行输出

4.1 单文件编译与警告选项

先把编译器确认下来,Windows 上装 MinGW-w64 或直接用 MSVC 的cl,macOS 和 Linux 上 g++ 或者 clang++ 都行:

g++ --version # 单文件编译:-std 指定标准,-Wall -Wextra 打开常用警告,-g 保留调试信息 g++ -std=c++11 -Wall -Wextra -g week03_loop/pattern_diamond.cpp -o build/diamond ./build/diamond

-std=c++11固定语言版本,避免不同机器默认标准不一致导致的“我这能编你那儿不行”。-Wall -Wextra会报出未使用变量、有符号无符号比较、函数缺返回值等问题,这些在教学代码里特别多,先扫一遍能省掉不少调试。-g带上调试符号,配合 gdb 或者 IDE 断点用。

跑起来之后输入一个5,应该看到九行菱形。如果输出全挤在一行,或者形状偏移,先回去看 3.1 里的spaces计算,八成是整除截断问题。

4.2 用 Makefile 批量管三十多个源文件

一个一个手敲编译命令不现实,写个 Makefile 更省事:

CXX := g++ CXXFLAGS := -std=c++11 -Wall -Wextra -g -I. SRCS := $(wildcard week*/*.cpp) BINS := $(patsubst %.cpp,build/%,$(SRCS)) all: $(BINS) # 通用规则:单个 .cpp 编成一个同名可执行文件 build/%: %.cpp @mkdir -p $(dir $@) $(CXX) $(CXXFLAGS) $< -o $@ clean: rm -rf build .PHONY: all clean

wildcard week*/*.cpp把各周目录下的源文件全收进来;patsubst把路径里的.cpp换成build/前缀,得到输出列表;$@是目标文件名,$<是第一个依赖,也就是对应的源文件;mkdir -p $(dir $@)保证输出目录存在。这套规则只适用于“一个 cpp 就是一个程序”的题目,week06_class那种多文件目标会被漏掉,得单独加一条规则:

build/student_app: week06_class/main.cpp week06_class/student.cpp @mkdir -p build $(CXX) $(CXXFLAGS) $^ -o $@

$^表示所有依赖文件,正好把两个 cpp 一起交给编译器。

4.3 多文件目标用 CMake 更省心

如果不想维护 Makefile 里的特例,直接上 CMake,代价是多一个配置文件,好处是跨平台不用改:

cmake_minimum_required(VERSION 3.10) project(cpp_dmap LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 单文件题目:逐个声明,生成到 build/ 下 add_executable(pattern_diamond week03_loop/pattern_diamond.cpp) add_executable(score_stats week05_array/score_stats.cpp) # 多文件题目:把 main 和实现文件一起列进来 add_executable(student_app week06_class/main.cpp week06_class/student.cpp) target_include_directories(student_app PRIVATE week06_class)

add_executable里必须把参与链接的源文件列全,只写main.cpp会在链接阶段报undefined reference to Student::...。target_include_directories把week06_class加进头文件搜索路径,#include "student.h"才能在别的目录里也找得到。这里刻意没用file(GLOB ...)自动收集源文件,因为新增文件之后不重新跑 cmake 就不会生效,排查起来很费劲,源文件就三十来个,手写更可控。

生成和构建:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j4

-S指定源码目录,-B指定构建目录,源码和产物分开,clean的时候直接删build就行。

4.4 跑起来之后怎么做一轮冒烟验证

编译通过不等于结果正确。三十多个程序全手工点一遍太慢,写个循环批量跑,每个喂空输入,看有没有崩溃或者卡死:

# 批量冒烟:每个程序喂一个空输入,2 秒超时,返回码非 0 也记录下来 for f in build/week*/*; do [ -x "$f" ] || continue printf '%-40s' "$f" if echo "" | timeout 2 "$f" >/dev/null 2>&1; then echo "OK" else echo "exit=$?" fi done

timeout是防止某些题在等输入时死循环,Linux 上自带,macOS 需要brew install coreutils之后把命令换成gtimeout。这个脚本只验证“不崩、不死循环”,正确性还得靠每题的样例输入。字符图案题重点看三处:n=1的边界、偶数输入是否被正确拒绝、行末有没有多余空格(cat -A能看出行尾的$前面有没有空格)。数组题用固定数据跑两遍,对比输出是否一致。

5. 避坑与排查:编译能过但结果不对的五个常见问题

5.1 图案整体偏移或行末多空格

现象:本地终端看菱形挺正,提交到判题系统就是格式错误,或者复制到文档里发现每行末尾多了一串空格。

原因:很多实现是“先打若干空格,再打星号,再打若干空格补齐到 n 列”,尾部空格肉眼看不见但会被逐字节比对。还有人用 Tab 对齐,制表符在不同终端宽度不一样。

解决:改成只输出前导空格加星号,行尾直接换行,像 3.1 里那样用string(stars, '*')收尾。本地用cat -A pattern.txt检查,行尾是$就对了,是空格加$就还得改。

5.2 平均分永远是整数

现象:输入80 85 90,平均分输出85,看起来没错,但输入80 85的时候输出82,应该是82.5。

原因:accumulate(v.begin(), v.end(), 0)的初值写成了int,整个累加过程按整数算,最后再除以v.size()又是整数除法,小数点直接被截掉。

解决:初值写0.0,或者显式写accumulate(..., 0.0)并确保返回类型是double。同类问题还有(a + b) / 2求中位数,也要写成/ 2.0。这个坑我见过太多次,现在写统计函数会先把返回类型和初值对齐再看别的。

5.3 越界访问导致段错误或“莫名其妙”的值

现象:程序在本地跑小数据正常,换一组大一点的数据直接崩,或者某个数组元素输出成随机数。

原因:vector::operator[]不做边界检查,下标越界是未定义行为,读到的可能是别的变量、也可能是垃圾值,崩不崩看运气。week05_array里的矩阵转置、二分查找都涉及下标计算,最容易在mid = (lo + hi) / 2这类地方出错。

解决:调试阶段把v[i]换成v.at(i),越界直接抛std::out_of_range,定位快得多。二分查找的mid用lo + (hi - lo) / 2避免lo + hi溢出。上生产前再换回[],或者保留at()也行,这点性能对教学代码没影响。

5.4 文件读不到,程序静默输出空结果

现象:file_report跑完输出count=0 average=0,看起来像统计逻辑写错了。

原因:data/scores.txt的相对路径是按进程工作目录解析的。终端里在项目根目录运行没问题,从 IDE 里点运行,工作目录可能是build/,也可能是week08_file/,文件自然找不到。更隐蔽的是ifstream打开失败不报错,只是后续读不到数据。

解决:打开后立刻if (!fin)检查并打印当前工作目录,Linux/macOS 用getcwd,Windows 用_getcwd。或者干脆在程序里用绝对路径,配合一个-f参数传入文件路径。调试文件类题目,先确认文件真的被打开了,再去看逻辑。

5.5 多文件编译报 undefined reference

现象:g++ week06_class/main.cpp -o app编译通过,链接阶段报undefined reference to 'Student::average() const'。

原因:只编译了main.cpp,student.cpp里的函数实现没有参与链接。C++ 的编译和链接是两步,头文件只提供声明,实现必须在某个翻译单元里被编进来。

解决:把实现文件一起交给编译器,g++ main.cpp student.cpp -o app,或者用 4.2 里的 Makefile 特例、4.3 里的 CMake 目标。另外注意头文件守卫别写漏,student.h被两个 cpp 包含时,没有#ifndef会出现重复定义。

6. 二次开发:把作业代码改成自己的算法练习骨架

这份代码跑通之后,最有价值的用法不是留着交作业,而是改成一个可以长期往里塞题的练习骨架。我一般做三件事。

第一件,抽一个统一的输入输出封装,放到utils/io_helper.h里。现在每个main都写一遍while (cin >> x),读文件的时候又要另写一套。封成一个read_ints(const std::string& path)和read_ints_from_stdin(),题目逻辑只关心拿到的vector<int>,输入源切换只改一行。

第二件,给每道题配一份样例数据和一个对拍脚本。目录改成problems/pattern_diamond/{sol.cpp, in1.txt, out1.txt}的结构,脚本负责编译、跑样例、逐字节比对:

#!/usr/bin/env bash # check.sh:编译当前题目并与期望输出逐字节比对 set -e g++ -std=c++11 -O2 -Wall sol.cpp -o /tmp/sol /tmp/sol < in1.txt > /tmp/got.txt diff -u out1.txt /tmp/got.txt && echo "PASS"

-O2在本地验证时打开,能提前暴露依赖未定义行为的代码;diff -u输出差异行,比cmp直观。有了这套,改代码之后跑一遍就知道有没有回归。

第三件,把week06_class那种类封装的题单独抽成静态库,main.cpp只做交互。用 CMake 加一句add_library(student STATIC student.cpp),再target_link_libraries(student_app PRIVATE student)。这样以后写单元测试可以直接链接这个库,不用把main也编进来。改造后的目标结构大致是这样:

目录作用改造要点
problems/一题一目录每题自带输入输出样例
libs/可复用的类与算法编成静态库,不依赖main
utils/输入输出、计时、随机数据头文件实现,避免链接麻烦
scripts/编译、对拍、批量冒烟一条命令跑完全部题目
build/全部产物加进.gitignore

改造过程中最容易忽略的是编译选项的统一。我习惯把-std=c++11 -Wall -Wextra -Wpedantic写进 CMake 的CMAKE_CXX_FLAGS,而不是每个add_executable里手写一遍,不然新增题目的时候总会漏掉一两个。-Wpedantic会揪出long long之外的非标准扩展,教学代码里不少写法其实依赖编译器扩展,早发现早改。

还有一个具体技巧值得单独说:给每道题加一个随机数据生成器,配合对拍跑两遍不同实现。比如码图题同时保留数学法和坐标判定法两份代码,随机生成 n 在 1 到 99 之间的奇数,比较两份输出是否一致。这种交叉验证比人工看图形靠谱得多,尤其是空心图形那种边界容易差一格的情况。

我以前改这类代码习惯直接动原文件,改崩了才想起来没备份,后来每次动手前都强制走一遍git init && git add -A && git commit,哪怕只是改个循环边界也提交一次。有后悔药吃的时候,排查心态完全不一样。希望帮到你。

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

RELATED READING

延伸阅读

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