C++期末读程序写结果:继承构造、浅拷贝与链表归并精讲

发布时间:2026/9/19 15:27:12
C++期末读程序写结果:继承构造、浅拷贝与链表归并精讲 简介这套C语言程序设计期末考试试题及答案整理面向高校计算机相关专业备战期末、需要系统复盘面向对象编程的学习者可用于考前自测与考点速查。压缩包内仅含1个PDF文件约58KB以试题与参考答案为主体轻量便携适合打印或分屏对照练习。内容分为填空与程序阅读两大板块填空部分覆盖成员函数原型与类外实现、引用与指针传参、抽象类只能作基类、函数重载的形参个数或类型差异、常对象仅能调用常成员函数、递归调用及拷贝构造函数形参要求等基础要点程序阅读部分涉及公有继承、派生类覆盖output()、作用域解析运算符、动态数组的new/delete与析构顺序、多态调用等并逐题给出运行结果对浅拷贝引发同一内存区二次释放等错误原因作出解析便于理解内存管理与异常检查思路。目前已有127人学习适合需要短周期查漏补缺、强化程序阅读题的读者。1. C 期末卷里最扎心的不是填空是读程序写结果不少人把 C 八股背得滚瓜烂熟拷贝构造函数形参必须是本类对象的引用、抽象类不能实例化、常对象只能调常成员函数。可真把一份「读程序写结果」的期末卷摆到面前要求不编译、纯靠肉眼写出输出正确率往往不到一半。差距就在这里背诵考的是术语读程序考的是对象内存布局、构造析构调用顺序和名字查找规则。这份 C 语言程序设计期末试卷的七道填空压的是语法边界两道读程序题压的是对象模型最后一道模板单链表归并题压的是模板类接口与指针操作。适合刚学完类与继承、准备上机考试或面试复习的人逐题拆一遍把「会背」变成「能推」。2. 继承链上的构造顺序与同名成员隐藏2.1 三层继承的构造调用链试卷里的 Base/Derived1/Derived2 三层结构先补全成能编译的现代写法原始卷子用的是#include iostream.h这种老标准头文件现在的 g/clang 上得改成iostream加using namespace std#include iostream #include cstring using namespace std; class Base { private: char msg[30]; protected: int n; public: Base(const char s[], int m 0) : n(m) { // 初始化列表里先给 n 赋值 strcpy(msg, s); // 字符数组必须保证不溢出 } void output() { cout n endl msg endl; } }; class Derived1 : public Base { private: int n; // 与 Base::n 同名构成隐藏 public: Derived1(int m 1) : Base(Base, m - 1) { n m; } void output() { cout n endl; Base::output(); } }; class Derived2 : public Derived1 { private: int n; public: Derived2(int m 2) : Derived1(m - 1) { n m; } void output() { cout n endl; Derived1::output(); } }; int main() { Base B(Base Class, 1); Derived2 D; // 走默认参数 m 2 B.output(); D.output(); return 0; }构造顺序从基类到派生类。Derived2 D;这一句会依次触发Base::Base(Base, m-1)其中 m 是 Derived1 传下来的值接着Derived1::Derived1(m-1)最后Derived2::Derived2()的默认参数 2 生效。参数是逐层递减的Derived2 的 m2传给 Derived1 的 m1Derived1 再把 0 传给 Base同时把字符串 Base 交给 Base。于是三个 n 的值分别是 0、1、2。运行结果是1 Base Class换行后接2 1 0 Base。2.2 同名成员隐藏三个 n 互不相干很多人第一反应是「n 被覆盖了」其实不是。C 里派生类声明同名成员不是覆盖而是隐藏三层各有一份独立的存储空间对象Base::nDerived1::nDerived2::nmsgB1不存在不存在Base ClassD012BaseDerived1::output()里写的cout n访问的是 Derived1 自己的 n不是 Base 的Derived2::output()里也是它自己的。想在派生类里访问被隐藏的基类成员只能显式加作用域Base::n。注意如果类里有char msg[30]这类字符数组用strcpy前一定确认源串长度小于 30否则是典型的栈溢出。想省心可以直接换成std::string赋值时不需要长度校验。2.3 非虚函数加作用域解析运算符走的是静态绑定B.output()输出1和Base Class这点没有争议。争议在D.output()为什么不会「只输出一个 2」。原因是 output 从头到尾没有virtual三个类的 output 之间只有隐藏关系调用哪一个完全由编译器根据静态类型决定。D的静态类型是 Derived2所以D.output()绑定到Derived2::output函数体里再显式写出Derived1::output()和Base::output()等于把所有层级都手工串了一遍。如果把Base::output()改成virtual这段代码的输出也不会变因为函数体里的两次调用都带了::限定限定调用不参与动态绑定。真正会变的场景是拿一个Base去绑定 Derived2 对象再调 output——那时才会走到虚表里指向的版本。试卷刻意不给 virtual考的就是能不能看穿「同名不等于多态」这件事。3. 拷贝构造函数、浅拷贝与双重释放3.1 编译器生成的拷贝构造函数只做成员逐一拷贝只要类里没自己写拷贝构造函数编译器就合成一个行为是对每个非静态成员依次拷贝。成员是int时都没事成员是裸指针时就要命了——拷的是指针值不是指针指向的那块内存。试卷里 Vector 和 CAT 两道题都是在考这件事而且两道题的报错描述一模一样浅拷贝 两次delete[]同一块内存。先看 Vector 的崩溃路径。3.2 Vector 例同一块 buffer 被释放两次class Vector { public: Vector(int s 100); int Elem(int ndx); void Display(void); void Set(void); ~Vector(void); protected: int size; int* buffer; }; Vector::Vector(int s) { buffer new int[size s]; } int Vector::Elem(int ndx) { if (ndx 0 || ndx size) { cout error in index endl; exit(1); // 注意exit 不会调用局部对象的析构 } return buffer[ndx]; }Vector a(10);让 a.buffer 指向一块 10 个 int 的堆内存。Vector b(a);调用编译器合成的拷贝构造函数b.size 和 b.buffer 被逐一拷贝于是 b.buffer 和 a.buffer 指向同一地址。a.Set()调用Elem(j) j 1因为 Elem 返回的是int写回的是 a.buffer 里的元素b 的 Display 读同一块内存所以输出 1 到 10。函数结束时先析构 b 再析构 a两次delete[] buffer落在同一地址上堆管理器直接报错。顺带提一句Elem里的exit(1)和delete[] buffer放在一起是有隐患的exit 会跳过栈上对象的析构如果此时还有别的对象持有堆资源一样泄漏。3.3 CAT 例指针被共享导致「改 a 也改 b」第二道题的现象更隐蔽程序不崩但语义错了as age:5 as age:6 bs age:6 // b 明明是从 a 拷的此刻观感上没什么问题 as age:7 bs age:7 // 改 a 影响到 b说明两人共用同一个 int原因是CAT::CAT(const CAT);这行声明本身就是错的正确写法是CAT(const CAT)——CAT会被解析成别的东西而且这行也没给函数体。编译器发现你只声明不定义拷贝时就用默认版本。a.SetAge(7)写的是*itsAge 7而 b.itsAge 和 a.itsAge 是同一个地址所以 b 读出来也是 7。最后两个对象析构时delete itsAge又一次重复释放。3.4 按三法则补上深拷贝只要类里出现了「析构函数里 delete 指针」这种资源管理动作就把拷贝构造函数和拷贝赋值运算符一起写出来这就是常说的三法则class Vector { public: Vector(int s 100); Vector(const Vector other); // 拷贝构造 Vector operator(const Vector other); // 拷贝赋值 ~Vector() { delete[] buffer; } protected: int size; int* buffer; }; Vector::Vector(const Vector other) : size(other.size) { buffer new int[size]; // 重新分配一块独立内存 for (int i 0; i size; i) buffer[i] other.buffer[i]; // 再做逐元素拷贝 } Vector Vector::operator(const Vector other) { if (this other) return *this; // 自赋值必须挡掉否则先 delete 自己 delete[] buffer; // 释放旧资源 size other.size; buffer new int[size]; for (int i 0; i size; i) buffer[i] other.buffer[i]; return *this; }拷贝构造里other是const引用所以other.buffer[i]只能读不能写size用初始化列表一次赋好避免默认构造后再赋值的开销。赋值运算符必须先做自赋值检查再delete[]旧内存顺序反过来就自己释放自己。返回*this的引用是为了支持a b c这种连续赋值。再往下走一步如果编译器支持 C11还应该补上移动构造和移动赋值或者干脆用std::vectorint替掉裸指针直接把三法则变成零法则——析构、拷贝、赋值全交给标准库。日常写业务代码我基本优先选后者。4. 模板单链表 MergeList从试卷填空到可编译的归并实现4.1 Node 与 LinkedList 的关键接口试卷第三题故意把类实现部分略去了只留接口和 MergeList。要跑起来得先把 Node 补完整template class T NodeT::Node(const T item, NodeT* ptrnext) : data(item), next(ptrnext) {} template class T void NodeT::InsertAfter(NodeT* p) { p-next next; // 新节点接上当前节点的后继 next p; // 当前节点再指向新节点 } template class T NodeT* NodeT::DeleteAfter(void) { NodeT* temp next; if (next ! nullptr) next next-next; return temp; }这几个函数是链表操作的地基InsertAfter的两行顺序不能倒先接后继再改自身指针倒过来就把后面整条链丢了。4.2 MergeList 的三个 while 循环拆解MergeList 干的是标准归并两个有序链表各自有序反复比当前节点把小的那个接到 lc 尾部然后从原链表删掉。三个循环各管一段循环条件作用第一个la、lb 都非空比大小谁小搬谁直到一条链走完第二个la 非空把 la 剩下的整段搬过去第三个lb 非空把 lb 剩下的整段搬过去template class T void MergeList(LinkedListT* la, LinkedListT* lb, LinkedListT* lc) { while (!la-ListEmpty() !lb-ListEmpty()) { if (la-Data() lb-Data()) { lc-InsertRear(la-Data()); // Data() 返回引用插入的是副本 la-DeleteAt(); // 删掉当前节点currPtr 自动后移 } else { lc-InsertRear(lb-Data()); lb-DeleteAt(); } } while (!la-ListEmpty()) { lc-InsertRear(la-Data()); la-DeleteAt(); } while (!lb-ListEmpty()) { lc-InsertRear(lb-Data()); lb-DeleteAt(); } }Data()返回的是当前节点 data 的引用InsertRear会按引用读出值再新建节点所以 la 里那份删掉之后 lc 里的数据不受影响。DeleteAt删的是 currPtr 指的节点删完 currPtr 会前移到下一个位置所以循环里的判断和取值始终打在「当前第一个未处理节点」上。题目注释特意强调「函数结束后内存总数不增加」意思就是 la、lb 的节点被逐个FreeNodelc 那边新建同样多的节点总量守恒。主函数里两次la.Reset()、lb.Reset()不能少。Reset 把 currPtr 拨回表头如果跳过这步直接Data()读到的可能是空指针或者上一次遍历留下的位置结果就是随机崩溃。4.3 补全被省略的类实现InsertRear 和 DeleteAt 是两个需要留心的实现template class T void LinkedListT::InsertRear(const T item) { NodeT* newNode new NodeT(item); if (rear nullptr) { // 空表时表头表尾都是它 front rear newNode; } else { rear-InsertAfter(newNode); // 挂到表尾之后 rear newNode; // 更新表尾指针 } size; } template class T void LinkedListT::DeleteAt(void) { if (currPtr nullptr) return; NodeT* p currPtr; if (prevPtr nullptr) { // 删的是表头 currPtr front front-next; } else { currPtr prevPtr-DeleteAfter(); // 前驱跳过当前节点 } if (p rear) rear prevPtr; // 删的正好是表尾表尾前移 --size; FreeNode(p); }DeleteAt 里最容易写错的是表尾更新。删的是表尾节点时rear 必须回退到 prevPtr否则下一次 InsertRear 会挂到一个已经被释放的节点上这种野指针往往要跑好几轮才暴露。4.4 归并的边界与内存释放自定义链表类在考试里图的是看清指针怎么走工程里更常见的是直接调用标准库#include algorithm #include vector std::vectorint a{1, 3, 5, 7, 9}, b{2, 4, 6, 8, 10}, c; std::merge(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(c));std::merge要求两个输入区间已有序输出区间容得下全部元素取等号时优先取前一区间的元素这一点和试卷里的写法保持一致。如果只是想把两段拼起来再排序c.insert(c.end(), b.begin(), b.end()); std::sort(c.begin(), c.end());也行代价是 O((nm)log(nm)) 对 O(nm)。手写归并的价值在于理解「不断取两个有序序列头部较小者」这个不变量冒泡排序、二分查找、链表反转这些常考题型都建立在类似的不变量思维上。5. 把卷子跑起来编译参数、内存检查与错题复现光看答案记住输出没什么用把每道题的代码亲手敲进编辑器、编译、跑出真实输出再看它和预期差在哪这才是这份卷子真正能榨出的价值。5.1 编译参数与地址检查器在 Linux/WSL 或者 vscode 配置好的 c/c 环境里用下面这行编译。-Wall -Wextra打开常见告警-fsanitizeaddress,undefined把重复释放、越界写、未定义行为全部标出来g -stdc17 -Wall -Wextra -g \ -fsanitizeaddress,undefined \ vector_shallow.cpp -o vector_shallow ./vector_shallowVector 那个例子用这套参数跑会在最后析构阶段报出attempting free on address which was not malloc()-ed或者double-free的位置直接指到delete[] buffer那行比看纸质答案直观得多。内存检查器还有个好处CAT 那道题不崩只是语义错-fsanitizeaddress也照样在退出时把重复释放抓出来。5.2 常见报错对照现象典型原因排查方向double free or corruption浅拷贝后两次 delete检查是否补了拷贝构造与赋值运算符输出和预测不一样非虚函数按静态类型绑定确认调用点类型确认有没有漏::free(): invalid pointerdelete 和 new[] 混用数组必须配delete[]Segfault 在链表遍历位置Reset 没调或 rear 没更新单步看 currPtr、rear 指向改一个对象另一个也变成员是指针且被共享判定是否需要深拷贝5.3 一个复现技巧把输出落到文件做逐字比对肉眼比对多行输出容易漏掉空行和换行差异把标准输出重定向到文件再 diff./derived_chain got.txt printf 1\nBase Class\n2\n1\n0\nBase\n expect.txt diff got.txt expect.txt echo 输出完全一致printf里每个\n对应程序里的一次endl写完直接比对差一个字节 diff 就会报出来。改代码之后再跑一遍形成「改一版、比一版」的节奏比对着答案反复读代码可靠得多。本文还有配套的精品资源点击获取