你打开《C++ Primer Plus(第六版)》第四章的编程练习题时,大概率会有一种“哎呀这章内容我都会”的错觉。数组、字符串、结构体、共用体、枚举、指针,每一个知识点单独拎出来都挺简单,可真要动手写完整程序,却发现不是这里警告就是那里乱码,甚至程序没结束窗口就闪退了。第四章的题全是这一类“看着基础、做着扎心”的综合练习,专门考验你对复合类型的基本功是否扎实。我最近刚把这一章十道题完整刷了一遍,把每道题的思路、代码、易错点都整理出来了,这篇文章就给正在啃这本书的读者做个参考。不管你是刚看完第四章准备做题,还是想把之前的代码翻出来对一下思路,都能从里面找到点有用的东西。
说明一下:我手头这个版次的第四章练习题的题号顺序是这样的。不同印刷批次可能有细节差异,但考点基本一致,你重点看解题思路即可,不用纠结题号是否完全对上。
1. 项目概述与整体设计思路
1.1 第四章练习题到底在考什么
第四章“复合类型”是这本书第一次系统性地让你接触“由基础类型组合出来的类型”。它不像第三章那样只跟 int、double、char 这种单值打交道,而是让你把一个变量当成一个“容器”,这个容器里可以同时放着不同类型的数据。数组、字符串、结构体、共用体、枚举、指针和 new,这些概念全部在第四章一次性铺开,编程练习题的作用就是把它们串起来,逼着你用它们写出真正能跑的程序。
如果你只看书上的讲解和书末的复习题,可能觉得“我都懂了”。但到了编程练习题就会发现,真让你从零写一个包含输入、处理、输出的完整程序,很多问题就冒出来了:字符串数组到底该开多大?cin >> str和cin.getline(str, n)有什么区别?结构体指针用->还是.?用new分配完内存后要不要delete?这些细节,只有动手敲代码才会真正刺痛你。
1.2 做题前需要先打通的知识点
第四章的题需要你同时掌握以下这些前置技能,缺一个都可能卡壳:
- 数组的声明方式:
int arr[10]、double scores[3],知道下标从 0 开始,知道数组名本质上是一个常量指针。 - C 风格字符串:以空字符
\0结尾的char数组,用strlen()求长度,用strcpy_s()/strcat_s()拼接复制。教材里大量使用了这个老派但重要的方式。 string类:第六章以前你完全可以依赖它,它有size()、+拼接等能力,第四章已经引入了它,所以练习里混用 C 风格字符串和string都是正常的。- 结构体:声明结构体类型、定义结构体变量、成员访问用
.,结构体指针用->,结构体数组的遍历。 - 共用体和枚举:至少要理解它们的内存布局和使用场景,练习题里会有。
- 动态内存:
new和delete的配对使用,以及怎样通过指针访问动态分配的数组或结构体。
如果以上概念里有任何一个感觉模糊,建议先把教材相应小节翻一遍再做题,不然很容易在写程序时反复报错,浪费时间在查语法上,而不是练思路。
1.3 我对这套练习题的整体设计感受
把十道题全部做完以后,我最大的感受是:出题人并不是想把题目出难,而是想让你把每一种复合类型都“用一遍”。十道题里,几乎每一道都换了一个侧重点,侧重点之间层层递进:开始是“声明结构体、输入输出”,然后是“字符串的不同处理方式”,中途用两到三道题反复练结构体数组和结构体指针,最后一道题还要求你用array对象替代普通数组。这种安排非常适合自学者——它不指望你一上来就写出多么优雅的代码,只要求你把每个语法点都亲手写对一次。
所以这篇文章的定位也不是“标准答案大全”,而是“解题思路+避坑指南”。我把每道题的思路、参考代码、常见错误都拆开讲清楚,你写的时候不用照抄,关键是理解每一步在干什么,然后自己重新写一遍。
2. 核心细节解析与易错点
2.1 数组长度和下标:基本但最容易翻车
第四章的练习里多次出现“用数组存储一组数据”的场景。比如填写一个结构体数组来保存好几条记录,或者用char数组存姓名。这里的第一个坑就是:数组长度到底开多少才够。
很多新手习惯用char name[10]这种“拍脑袋”的长度,结果输入一个稍微长一点的姓名就把内存踩了。在实际编程中,应该给字符串预留“最长输入 + 1”的空间,那个+1是给\0准备的。教材里的一个典型写法是这样:
const int NameSize = 30; char first_name[NameSize]; char last_name[NameSize];这里的 30 只是一个示例,你完全可以根据题目要求把缓冲区设置得更大。关键是记住:C 风格字符串一定要以\0结尾,如果输入正好填满了数组,而末尾没有结束符,strlen()和cout <<都会出问题,甚至可能读到数组外面去。这个问题在字符串长度不定的场景下尤其隐蔽。
结构体数组的下标也容易出错。当你定义了CandyBar snacks[3],遍历时下标就是 0、1、2,如果用<= 3就会越界。编译器未必会报错,但运行结果可能是错误的,或者会悄悄破坏别的变量。排查起来特别费劲,因为程序可能只是输出不对劲,而不是崩溃。
2.2 string 和 C 风格字符串:混着用要注意接口差异
第四章练习题有一个特点:既允许你用string,又要求你练 C 风格字符串。有的题目书上建议用string,有的题则天然就是 C 风格字符串的活儿。两道题放在一起,就能看出你对两种字符串的熟悉程度。
string的好处是简单:
#include <string> #include <iostream> std::string dessert; std::cin >> dessert; std::cout << dessert.size() << std::endl;它的+可以直接拼接,size()可以直接取长度,没有缓冲区溢出的担忧。而 C 风格字符串则需要:
#include <cstring> char full_name[60]; strcpy_s(full_name, last_name); strcat_s(full_name, ", "); strcat_s(full_name, first_name);注意这里strcpy_s和strcat_s的第二个参数是目标缓冲区大小,不是源字符串大小。如果你把目标大小写错,运行时会触发安全警告,甚至直接中断。很多初学者在这里困惑:“为什么我在 Visual Studio 里用strcpy会报错?”因为新版编译器推荐使用带_s的安全版本。如果是在命令行用 g++ 编译,直接用strcpy/strcat是没问题的,但推荐你自己主动用安全版本,形成好习惯。
2.3 cin、getline 和 cin.get:输入方式混用会产生回车残留
这是第四章练习题里最经典、也最折磨人的一个坑。cin >> age;在读入数字后,会把换行符留在输入缓冲区里;紧接着如果又用cin.getline(name, 30);,它读到缓冲区里的换行符就立刻结束了,给你留下一个空字符串。
典型场景:练习里先让你输入年龄,再让你输入姓名。如果你写成:
int age; char name[30]; std::cin >> age; std::cin.getline(name, 30);程序会让你输入年龄,然后getline直接跳过,不会等待输入姓名。解决方案有几种:
- 用
cin.get()把那个换行符吃掉:
std::cin >> age; std::cin.get(); // 吃掉换行符 std::cin.getline(name, 30);先用
cin读完整行再转换数字,但这在第四章阶段有点超纲。调换输入顺序:先
getline字符串,再cin >> age。
这个坑在做练习题时几乎必然遇到,值得多花点时间搞明白。你可以在草稿纸上画一下输入缓冲区的内容变化,理解了原因以后,这类问题就再也不怕了。
2.4 结构体、结构体数组和动态结构体指针
第四章练习题大概有一半都围绕结构体展开。结构体本身不难,难的是它和其他知识点的组合。比如:
- 结构体变量:
struct Student s; s.name = "xxx"; - 结构体数组:
struct Student stu[3];然后循环stu[i].name - 结构体指针:
Student *p = &s; p->name = "xxx";
结构体指针是很多人的第一个“卡点”。因为通常在结构体变量上访问成员用.,而指针用->,如果你混着写,编译会直接报错。教材里还有一个经典操作:用new动态分配一个结构体而不是结构体数组:
Pizza *pg = new Pizza; std::cin.getline(pg->name, 30); std::cin >> pg->diameter; delete pg;这样做的好处是内存大小在运行时才确定,也就是动态内存。练习题里有一道题专门练这个,如果你亲手写过一遍,对“栈上分配”和“堆上分配”的感觉会完全不同。
动态结构体还要注意一点:当你new了以后,用完一定要delete。虽然程序退出后操作系统会回收内存,但养成配对new/delete的习惯,对未来写大型程序非常重要。我记得我早期刷题时经常忘了delete,程序一样能跑,就给忘了;后来养成了“写完分配立即写释放”的习惯,才没有在以后的项目里吃亏。
2.5 字符串排序:strcmp 返回值的理解
有一道题要求把多个单词按字典顺序排序。看着不难,但你一旦去写,就会遇到strcmp的问题。C 风格字符串不能直接用<或>比较,因为那样比较的是指针地址,而不是字符串内容。正确做法是用strcmp:
if (strcmp(str1, str2) < 0) { // str1 在字典顺序上排在 str2 前面 }strcmp的返回值是 int:负数表示第一个字符串比第二个小,0 表示相等,正数表示第一个字符串比第二个大。
如果你要实现选择排序,比较完以后还要交换字符串数组中的两个元素。这时候你要交换的是整个字符数组的内容,不能简单写temp = words[i],因为数组名是常量指针,不能直接重新赋值。专业的做法是定义临时字符数组,用strcpy_s把内容拷进去,再把另一个数组拷过来。这个细节我在做练习题时第一次体会到了“数组不能直接赋值”的别扭感。后来用string写就方便多了,直接temp = words[i];就行,但 C 风格字符串的实现依然值得练习。
3. 实操过程:逐题拆解与参考代码
3.1 练习 1:结构体存储个人基本信息
题目要求创建一个结构体,包含姓名和年龄,然后让用户输入并输出。这是结构体的入门题,也是很多人的第一道“完整程序”题。
我当时的代码结构大致如下:
#include <iostream> #include <cstring> struct Student { char name[30]; int age; }; int main() { Student s; std::cout << "请输入姓名:"; std::cin.getline(s.name, 30); std::cout << "请输入年龄:"; std::cin >> s.age; std::cout << "姓名:" << s.name << ",年龄:" << s.age << std::endl; return 0; }这里需要注意的点已经说过了:名字用getline是因为姓名里可能包含空格,如果只输一个单词,用cin >> s.name也行,但“空格”是很多名字里合理存在的字符,所以用getline更稳妥。输入顺序上我把名字放前面、年龄放后面,就是为了避免cin >> age的换行符残留问题。
在 Visual Studio 里运行这个程序,中文输出可能需要添加:
system("chcp 936 > nul");或者在项目属性里把字符集改成“使用多字节字符集”。这些属于环境配置问题,我在后面“常见问题与排查”里再展开。
3.2 练习 2:string 类型的简单应用
这个练习通常要求你用string对象存姓名或其他信息,体会它和 C 风格字符串的区别。参考代码:
#include <iostream> #include <string> int main() { std::string name; std::string dessert; std::cout << "请输入你的姓名:"; std::getline(std::cin, name); std::cout << "请输入你最喜欢的小吃:"; std::getline(std::cin, dessert); std::cout << "你的姓名:" << name << ",你最喜欢的小吃是:" << dessert << std::endl; return 0; }注意这里用的是std::getline(std::cin, name),它是string版本的getline,跟cin.getline不同。前者只接受string对象,第二个参数是目标变量,不需要指定最大长度。
这道题主要是让你感受string的便利:不用管长度、不用顾虑\0,想拼接、想取长度都更自然。书里之所以把string和 C 风格字符串同时教,是要让你知道两条路都走得通,实际开发中大多数时候用string更安全高效。
做题时我犯过一个低级错误:把std::getline(std::cin, name)写成了std::cin.getline(name),然后编译器报错说类型不匹配。这个错误本质上是把两个不同版本的getline混在一起了。搞清楚两个版本各自作用于什么类型,就不会再犯。
3.3 练习 3:C 风格字符串的拼接与复制
这道题一般会让你从键盘输入姓和名,然后将它们拼接成一个格式化字符串输出。它强制要求用 C 风格字符串操作,不能直接用string。
参考思路:
#include <iostream> #include <cstring> const int Size = 30; int main() { char first_name[Size]; char last_name[Size]; char full_name[2 * Size + 2]; std::cout << "输入你的名:"; std::cin.getline(first_name, Size); std::cout << "输入你的姓:"; std::cin.getline(last_name, Size); strcpy_s(full_name, last_name); strcat_s(full_name, ", "); strcat_s(full_name, first_name); std::cout << "格式化后的字符串:" << full_name << std::endl; return 0; }这里的full_name长度为什么是2 * Size + 2?Size是每个名字的最大长度,两个名字加一起是2 * Size,中间还有一个逗号和一个空格,一共两个字符,最后还要一个位置给\0,所以追加+2是比较稳妥的做法。你可能会想:名字通常不会真的顶格输入,随便开大一点行不行?行,但我们要养成算长度的习惯,因为缓冲区溢出是最常见也是最危险的内存错误之一。
另一个容易忽略的点是strcpy_s的第二个参数。不同编译器对strcpy_s的原型处理略有不同,Visual Studio 里你写strcpy_s(full_name, last_name)可能会报“参数过少”,需要写成strcpy_s(full_name, sizeof(full_name), last_name)。这种差异不同环境真的不一样,我在学校机器上用的老编译器就不用写sizeof,回家在自己电脑上就报错,一度很困惑。遇到这种情况,最直接的解决办法就是:报错说参数过少就补上目标缓冲区大小,报错说找不到strcpy_s就临时改用strcpy,但心里要清楚strcpy不安全。
3.4 练习 4:结构体数组遍历
这题往往要求定义结构体数组,存放多条记录,然后通过循环依次打印。它比单纯结构体变量多了一个“数组”的概念。
参考代码结构:
#include <iostream> struct CandyBar { char brand[30]; double weight; int calories; }; int main() { CandyBar snacks[3] = { {"Mocha Munch", 2.3, 350}, {"Caramel Delight", 1.9, 280}, {"Hazelnut Crunch", 2.7, 410} }; for (int i = 0; i < 3; ++i) { std::cout << "品牌:" << snacks[i].brand << std::endl; std::cout << "重量:" << snacks[i].weight << std::endl; std::cout << "卡路里:" << snacks[i].calories << std::endl; std::cout << "-------------------" << std::endl; } return 0; }结构体初始化时可以用花括号按成员顺序给值,这是 C++11 风格的列表初始化,C++98/03 只支持拷贝初始化。如果你用的编译器比较老,可能不接受这种写法,这时就改成逐成员赋值:
CandyBar snack; strcpy_s(snack.brand, "Mocha Munch"); snack.weight = 2.3; snack.calories = 350;数组初始化时还可以只初始化部分元素,其余元素自动执行默认初始化(比如数值类型为 0)。这一点在调试时可能造成困惑:你以为某个元素没被设置,但它的值其实有一个默认状态。
这道题本身不难,但它为后面更复杂的结构体数组和动态内存题目打底。如果这道题你写得很顺手,后面的结构体指针题目会更容易理解。
3.5 练习 5:共用体或枚举的小练习
这类题会围绕union或enum中的一个小知识点展开。共用体的特点是:所有成员共享同一块内存,同一时刻只能保存一个成员的值。如果你先存了一个 int,再存一个 double,那 int 的内容就会被覆盖。所以它适合用在“多种类型但一次只用一种”的场景,比如一个“值”可能是数字也可能是字符串。
一个典型的共用体声明:
union Value { int int_val; double double_val; char str_val[20]; };枚举的典型声明:
enum Color { red, orange, yellow, green, blue }; Color my_color = red;做题时我先会用枚举来表示某种状态,比如披萨的大小等级或者糖果的颜色分类,然后用共用体存储附加信息。这种题主要考你对“类型的内存布局”的理解。写的时候我记得最深刻的一点是:共用体变量的内存大小等于最大成员的大小,而不是所有成员之和。如果你在程序里打印sizeof(Value),会发现它比我们直观想象的要小。这就是所谓“空间换时间还是时间换空间”的老话题,共用体是典型省内存的方案。
3.6 练习 6:用循环输入结构体数组
这道题会稍微进阶一点:不是直接在初始化列表里写死数据,而是让用户逐一输入多条记录。此时你要注意的就不只是结构体访问,还有输入方法的选择。
核心代码框架:
#include <iostream> struct CandyBar { char brand[30]; double weight; int calories; }; int main() { CandyBar snacks[3]; for (int i = 0; i < 3; ++i) { std::cout << "输入第 " << i + 1 << " 个品牌:"; std::cin.getline(snacks[i].brand, 30); std::cout << "输入重量:"; std::cin >> snacks[i].weight; std::cout << "输入卡路里:"; std::cin >> snacks[i].calories; std::cin.get(); // 吃掉换行符,准备下一次 getline } for (int i = 0; i < 3; ++i) { std::cout << "品牌:" << snacks[i].brand << ",重量:" << snacks[i].weight << ",卡路里:" << snacks[i].calories << std::endl; } return 0; }这里有一个极其重要的细节:循环里每次输入完数字后,都要调用std::cin.get()把缓冲区里残存的换行符吃掉。如果你漏了这一步,下一次循环中的getline会直接读到残留换行符并返回空字符串,品牌就变成空了。我在第一次写这个循环时,连续三次输入输出,前两次品牌正常第三次就变成空白,排查了半天才发现是换行符问题。
另一个经验是:把输入和输出分开处理,先统一输入,再统一输出,这样逻辑更清晰。如果你一边输入一边输出,代码也能跑,但容易把循环逻辑搅在一起,出现问题更难定位。
3.7 练习 7:动态分配结构体
这道题一般要求用new动态创建一个结构体,然后输入数据并输出。它是第四章里第一次让你正式接触“堆内存”,跟前面 “栈上定义结构体变量” 的体验有明显区别。
参考代码:
#include <iostream> #include <cstring> struct Pizza { char company[30]; double diameter; double weight; }; int main() { Pizza *pg = new Pizza; std::cout << "输入披萨公司名称:"; std::cin.getline(pg->company, 30); std::cout << "输入披萨直径:"; std::cin >> pg->diameter; std::cout << "输入披萨重量:"; std::cin >> pg->weight; std::cout << "公司:" << pg->company << std::endl; std::cout << "直径:" << pg->diameter << std::endl; std::cout << "重量:" << pg->weight << std::endl; delete pg; return 0; }注意这里访问成员用的是pg->company而不是pg.company,因为pg是一个指针。如果你是第一次写这种代码,很可能会混着用.和->,编译器报错后才知道问题出在哪。实际上,别管是.还是->,它们的本质区别是:左边是对象还是指针。对象用点,指针用箭头,就这么记。
动态分配结构体的好处是,内存空间是在程序运行时从堆中申请的,而不是在编译时就固定。堆空间比栈空间大得多,所以对于大结构体,动态分配是常见做法。但用完以后delete不要忘记,否则就是内存泄漏。可以给自己立一个规矩:写完new的那一行,紧接着先把delete写出来,再写中间的代码。这样再怎么改都不会忘。
如果你把delete pg忘写了,程序照样能正常输出,所以新手往往觉察不到。但如果在一个长期运行的程序里反复new而从不delete,内存会越占越多,最后系统变慢甚至崩溃。这是一个在练习题阶段看起来“无所谓”,实际危害很大的坏习惯。
3.8 练习 8:字符串字典序排序
这题对新手来说是最容易懵的一道。题目一般要求输入多个单词,然后按字典顺序输出。它的难点在于:C 风格字符串比较大小用strcmp,而不是</>;交换字符串内容要复制而不是赋值。
一个用选择排序实现的完整方案:
#include <iostream> #include <cstring> const int WordSize = 30; const int WordCount = 5; int main() { char words[WordCount][WordSize]; std::cout << "输入 " << WordCount << " 个单词:" << std::endl; for (int i = 0; i < WordCount; ++i) { std::cin.getline(words[i], WordSize); } // 选择排序,按字典序升序 for (int i = 0; i < WordCount - 1; ++i) { int min_index = i; for (int j = i + 1; j < WordCount; ++j) { if (strcmp(words[j], words[min_index]) < 0) { min_index = j; } } if (min_index != i) { char temp[WordSize]; strcpy_s(temp, WordSize, words[i]); strcpy_s(words[i], WordSize, words[min_index]); strcpy_s(words[min_index], WordSize, temp); } } std::cout << "排序后的结果:" << std::endl; for (int i = 0; i < WordCount; ++i) { std::cout << words[i] << std::endl; } return 0; }排序的逻辑本身跟整型数组排序没有区别,关键是字符串比较和交换的写法。strcmp(words[j], words[min_index]) < 0表示当前单词比候选最小单词在字典序上更靠前。交换时你不能写char* temp = words[i]然后把 words 里的指针换掉,因为words[i]本身是数组名,它的地址不可变。必须先声明char temp[WordSize],用一个一个字符拷贝的方式(也就是strcpy_s)来完成交换。
如果你改用std::string存储,排序会容易很多:直接temp = words[i]就能交换。但这个练习的初衷是练 C 风格字符串,所以建议还是老老实实写一遍char数组版本。
如果有同学想挑战自己,可以对比一下两种写法的代码量:string版本大概会少十行左右,而且不容易出错。这也是为什么实际项目里几乎都用string,但学习时仍需要懂 C 风格字符串的原理。
3.9 练习 9:动态结构体数组或其他动态内存题目
这道题跟第 7 题类似,但通常要求动态分配一个结构体数组,并把输入的数据存进去。它比“用一个new分配单个结构体”多了一个维度:new []与delete []的配对。
代码骨架:
#include <iostream> struct Car { char producer[30]; int year; }; int main() { int count; std::cout << "输入汽车数量:"; std::cin >> count; std::cin.get(); Car *cars = new Car[count]; for (int i = 0; i < count; ++i) { std::cout << "输入第 " << i + 1 << " 辆车的生产厂商:"; std::cin.getline(cars[i].producer, 30); std::cout << "输入第 " << i + 1 << " 辆车的生产年份:"; std::cin >> cars[i].year; std::cin.get(); } for (int i = 0; i < count; ++i) { std::cout << cars[i].producer << " : " << cars[i].year << std::endl; } delete[] cars; return 0; }注意这里我用的是Car *cars = new Car[count],它是一个指针,指向一个堆上的数组。访问元素时我用cars[i].producer,这是因为cars[i]本身是一个Car对象,用点访问成员;如果我用(cars + i)->producer也一样,效果等同。这里特别容易混淆的点是:指针指向数组首元素,首元素是对象,对象用点访问成员。等你以后学到“指针数组”和“数组指针”的时候,这种混淆会更加明显。
delete[]语法上多了一对方括号,少了这对方括号,行为未定义,不同编译器处理方式不一样,有的会崩,有的不崩。我的习惯是:new[]对应delete[],new对应delete,永远配对,这个习惯能帮你避免非常多的隐性故障。
3.10 练习 10:用 array 对象存储数据
这道题通常让你用std::array或者std::vector替代普通数组,解决“数组定长”的痛点。如果你还没学到标准模板库,没关系,第四章已经可以简单使用array了。
参考代码:
#include <iostream> #include <array> int main() { std::array<double, 3> scores; std::cout << "输入三次成绩:\n"; for (int i = 0; i < 3; ++i) { std::cin >> scores[i]; } double total = 0.0; for (int i = 0; i < 3; ++i) { total += scores[i]; } double average = total / scores.size(); std::cout << "总分:" << total << std::endl; std::cout << "平均分:" << average << std::endl; return 0; }std::array的好处是不需要手动管理内存,也不会衰减成指针,scores.size()能直接拿到元素个数。它和 C 风格数组最大的区别是:C 风格数组名在传入函数时会退化成首元素指针,导致你丢失“这个数组有多长”的信息,而std::array把大小封装成了类型的一部分。
这道题在难度上其实不算高,它是整章练习的“轻松结尾”,让你体会一下现代 C++ 的便利性。但如果你之前一直习惯用传统数组,第一次用array可能会有一点不适应,比如它不能直接初始化成{}以外的东西,遍历时更推荐用范围 for 循环。
4. 常见问题与排查技巧实录
4.1 编译错误速查表
刷第四章练习题时,我收集到的高频编译错误和解决办法整理成了下面这个表,你可以贴在桌边随时看:
| 错误现象 | 常见原因 | 解决办法 |
|---|---|---|
在 Visual Studio 中strcpy报错 C4996 | 新版编译器强制推荐安全函数 | 改用strcpy_s,或者明确缓冲区大小 |
不能将参数 1 从“char*”转换为“const char *” | 字符串字面量和变量类型不匹配 | 检查是否声明为char数组或const修饰 |
未定义标识符“strcpy_s” | 没有包含<cstring>或<string.h> | 添加#include <cstring> |
数组下标的类型错误 | 用了非整型变量作为下标 | 把下标换成int或size_t |
不允许使用不完整的类型 | 结构体声明还没写完就使用 | 检查结构体定义是否以分号结尾 |
member access into incomplete type | 忘记包含结构体定义的头文件 | 将结构体定义放在使用之前 |
表格最后一行的“incomplete type”其实在第四章很少出现,更多是等你以后拆分多文件时才会遇到。不过提前知道一下没坏处:如果你在main函数里使用结构体,但结构体定义在另一个文件里且只做了前置声明,编译器不知道怎么给这个对象分配内存,就会报这个错。
4.2 运行期问题集锦
运行期问题比编译错误更烦人,因为它们不总是报错,而是输出看起来“好像不太对”。
第一个是最常见的“超长字符串被截断”。当你用cin.getline(name, 30);时,如果用户输入了超过 29 个字符,输入流会进入 fail 状态,后续的cin操作都会失效。表现是程序明明有多个输入,却直接跳到了末尾。解决办法是让getline的第二个参数大一些,或者在输入后调用std::cin.clear()清理状态。
第二个问题是“窗口一闪而过”。这是小白最容易卡住的:程序运行完以后,控制台窗口马上关闭,你根本来不及看输出结果。解决办法是在main的return 0;之前加一行:
std::cin.get();如果前面用了cin >>,则可能需要写两个std::cin.get(),因为第一个会吃掉残留换行符。或者直接用system("pause"),但这不是跨平台的做法,Linux 下没有pause命令。我在练习题阶段喜欢用两个cin.get(),虽然看起来笨,但跨平台没问题。
第三个问题是“中文输出变成乱码”。这通常是因为源码文件编码和终端编码不一致。Visual Studio 默认源码是 UTF-8 或 GB2312,而终端控制台通常用 GBK(简体中文 Windows)。文件开头添加system("chcp 936 > nul");会把终端代码页切换成 GBK,但这也只是权宜之计。更好的办法是:写代码时统一用 UTF-8 编码,终端也改成 UTF-8。不过对新手来说,先用system("chcp 936 > nul")把中文输出搞定,别让环境问题干扰练习本身,也是可以的。
第四个问题是“符号重定义”。假设你同时在main.cpp里定义了结构体,又在一个头文件里定义了同名结构体,链接时就会报重复定义。第四章练习一般只有一个文件,出现概率不高,但如果你把多个练习粘贴到同一个文件里,且不同练习里定义了一模一样的结构体,就会遇到这个问题。解决办法是:每个练习建一个独立项目,或者在同一个文件里给结构体起不同的名字。
4.3 调试技巧:怎么快速定位“不明所以”的错误
第四章的程序都比较短,Debug 起来不算难。但我发现很多初学者在程序出错时,第一反应是“是不是哪一行语法错了”,而不是打印中间变量的值。调试技巧比语法知识更值钱,因为语法错误编译器会直接告诉你位置,而逻辑错误需要自己去观察。
我常用的几个方法:
在关键位置加
std::cout << "到这里了" << std::endl;,用来确认程序流程是否到达某个分支。第四章练习题里最常见的流程问题就是循环次数多一或少一,加一行输出就能立刻看出来。打印中间量的长度和内容。比如拼接字符串后,先打印
strlen(full_name)和full_name,看看是不是预期结果。如果长度不对,说明问题出在缓冲区大小或strcpy/strcat的使用上。检查输入流状态。如果
getline没有按预期等待输入,在调用前加一行std::cout << std::cin.fail() << std::endl;,如果输出 1,说明输入流因为上一次的错误输入进入了 fail 状态,需要clear()和“吃掉坏输入”。利用 IDE 的断点调试。如果你用的是 Visual Studio,直接在可疑行加断点,按 F5 运行,然后按 F11 逐步执行,查看变量的实时值。这个方法对理解指针尤其有效,你能直观地看到“指针的值”和“指针指向对象的值”的区别。
我有一个印象非常深的调试案例:练习里要求输入披萨信息,我先输入重量再输入品牌名,结果品牌名总是空字符串。我打印了输入流状态,发现上一轮的cin >> pg->weight已经把换行符留在缓冲区,然后getline(pg->company, 30)读到换行符就结束了,根本不等待用户输入。解决办法是在cin >> pg->weight后加一个std::cin.get();。这种问题如果不调试,靠肉眼很难发现。
4.4 不同编译环境下的兼容性差异
第四章练习题里,最容易爆出环境差异的就是strcpy_s和strcat_s。Visual Studio 中它们被当作标准函数直接使用;但 g++ 在某些版本里可能没有这两个函数,必须用strcpy和strcat。如果你的代码在 电脑上写了strcpy_s,拿到另一台用 g++ 的机器上编译,可能报“没有匹配的函数”。
为了减少这种困扰,建议你在学习阶段统一用主流 IDE 的默认配置。如果你用的是 Visual Studio,strcpy_s是安全的;如果你在 Linux 上用 g++,直接用strcpy。但心里要清楚strcpy可能出现越界问题,写的时候自己留意缓冲区大小。
如果确实遇到平台差异,可以用一个条件编译来解决:
#ifdef _MSC_VER strcpy_s(temp, WordSize, words[i]); #else strcpy(temp, words[i]); #endif这种做法在练习题阶段有点“杀鸡用牛刀”,但知道有这回事,以后写跨平台代码时就会自然想到。
5. 一点实操心得,希望能帮你少走弯路
把第四章十道题全部写完后,我最想分享的不是哪道题的答案,而是刷题顺序和学习节奏。如果你时间有限,建议按这样的优先级去刷:先做第 6 题(结构体数组输入输出)和第 7 题(动态结构体),这两题覆盖了结构体、指针、输入缓冲区多个难点,刷透它们等于拿下了第四章的七成考点。然后做第 8 题(字符串排序),它是最容易让人放弃的一道,但也是最能巩固 C 风格字符串基本功的题。第 2 题和第 10 题相对轻松,可以在前面几道题刷累的时候穿插着做,当作调剂。
另外,遇到“代码跑了但结果不对”的事情,别急着怀疑编译器有问题。百分之九十九的情况是你自己的代码有问题,而且多半出在缓冲区换行符、数组下标、字符串结束符这三个地方。我刷完这一章后养成的一个习惯是:每次写完一个输入相关的程序,都会先画一下输入缓冲区里到底剩了什么,再决定下一个cin或getline是否需要多吃一个换行符。这个习惯让我在后面写文件输入输出、网络编程时也少踩了很多坑。
第四章说到底就是在教你“怎么用一块连续记忆体存放复杂数据”,数组、结构体、指针都是这个主题的不同侧面。等你把这些练习题都写顺了,再往后看函数、指针运算、动态内存时,会明显觉得轻松很多。我自己在刷完这一章后,对 C++ 程序的“内存视角”算是真正建立起来了,那种感觉就像是之前写代码只看得见表面语法,现在能隐约看到数据在内存里是怎么排布的了。希望你也能刷出这种感觉。