简介:面向吉林大学软件学院、计算机学院学生的2016年C++期末考试题详解文档,围绕类与对象、继承多态、运算符重载、静态成员、异常处理、抽象类与纯虚函数、命名空间及状态模式等核心考点展开,适合期末复习、查漏补缺与考前自测。资源包共1个docx文件,压缩后约20KB,以文字解析形式呈现选择题判断、改错题和代码设计题的参考答案与思路说明,不堆砌无关素材。文档对多态通过虚函数实现、静态成员不占对象空间、const指针转换权限、子类异常被父类捕获、虚表与虚指针区别等易错点作了拆解,并给出第六大题状态模式与工厂函数的完整代码及运行结果分析。目前已有1797人学习,可帮助读者对照题目定位知识盲区,理解命题意图与答题规范,提升C++面向对象编程的复习效率。
1. 从一道 sizeof 排名题切入:这份卷子考的是对象模型的边界
很多人复习 C++ 期末,把虚函数、多态、运算符重载背得很熟,翻开卷子第七大题却愣住了:A、B、C、D 四个类,问谁的对象最大。四个类里只有一个带虚函数,剩下的差异全在“静态成员算不算对象空间”“int* 和 int[1000] 差多少”“对齐补了几个字节”。这类题不考背诵,考的是对对象内存布局的边界判断。整份卷子的结构也印证了这一点:十道判断题、十道简答题、四道改错题、一道状态模式代码阅读、一道 sizeof 排名,分值重心不在“什么是多态”,而在“多态什么时候失效”“静态成员为什么不算大小”“父类指针为什么调不到子类函数”。下面按题型把这份 c++ 期末考试题详解拆成能直接背的判据,某高校软件学院、计算机学院的期末卷和 C++ 笔试都能套用。
2. 判断题:十道题九个“错”,每个“错”都有判据
2.1 为什么九道答案都是“错”
这一大题十道题,答案里只有第 8 题是“对”,其余九道全是“错”。这不是巧合,是出题人在集中考“绝对化表述”。凡是出现“都应”“都不需要”“只能”“只有一个”这种措辞,默认先怀疑它是错的,再去原文里找反例。比如“不是所有的成员函数都应该尽可能定义为虚函数”,破的就是“都应”两个字;“抽象类虽然不能被实例化,但通常来说需要构造函数和析构函数”,破的是“不需要”。
但光靠语感拿不到满分。判断题的给分标准通常是“判断 + 理由”,只写“错”不给理由,一半分就没了。所以每道题的答法应该是同一套结构:先写判断,紧跟一句“因为……”,理由是判据本身,不是复述题干。比如第 3 题答“错,因为静态成员函数不与具体对象绑定,不隐含 this 指针”,比答“错,this 指针只在非静态函数里”要稳,因为后者没说清静态这一侧。
还有一点容易忽略:判断题的错误往往只有一处,改动最小。答题时把错误点摆到最前面,别绕一大圈解释然后再下结论,阅卷人找的是关键词。
提示:判断题的时间成本最低,建议先做,但理由不要省。判据写对一句话,比写三行铺垫得分高。
2.2 抽象类、虚函数、this 指针:三道最容易答反的
第 1 题给了一个class A { private: A() {} };,问它是不是抽象类。答案是“不是”。抽象类的判据只有一条:类里至少有一个纯虚函数。构造函数写成私有、写在 private 区,只影响“能不能在类外实例化”,跟抽象没有任何关系。私有构造的类照样能定义对象,只是你得在类内部提供工厂方法或者友元来造。这道题的错误率高,是因为很多人把“不能实例化”和“抽象类”混成一件事,实际上“不能实例化”有两条完全不同的路径。
第 2 题考虚函数的使用边界。成员函数不是越虚越好,虚函数的代价是实打实的:对象里多一个虚指针、调用要走虚表、编译期没法内联。只有一种情况需要虚——你打算通过基类指针或引用调用派生类版本。其余时候写成普通函数更划算。答题时把“什么时候需要”说清楚,比只说“看情况”得分高。
第 3 题考 this 指针的归属。this 是指针常量没错,它的类型是“指向当前对象的常量指针”,本身不能被重新赋值。但静态成员函数不隐含 this,因为它不属于任何对象,没有对象就没有 this 可指。这段代码能说明两者的差别:
class A { public: static void sfunc() { // 这里没有 this:调用 A::sfunc() 时可能一个对象都没造出来 } void nfunc() { // 这里有 this:必须通过某个对象调用 // this->x 才能成立 } int x = 0; };static修饰的是“与类绑定、不与对象绑定”,this是“当前对象地址”,两个概念互斥。顺带记住一个衍生考点:静态成员函数不能声明为 const,因为 const 修饰的正是 this 指向的对象,没有 this 就没法 const。
2.3 运算符、抽象类构造、异常、虚表:四道靠细节的
第 4 题说“某些运算符只能定义为类内函数”,这句是对的,但题目表述的是“某些运算符不能”,答案判“错”,因为它把范围说绝对了。准确的记忆方式是列清单:=、[]、()、->这四个必须定义成成员函数,因为它们的左操作数必须是本类对象,编译器不允许把对象放到参数位置去调非成员版本。+、-、==这些既可以成员也可以非成员,非成员版本还更灵活,能做隐式转换。
| 运算符 | 必须写成成员函数 | 原因 |
|---|---|---|
= | 是 | 左操作数必须是本类对象,且要返回引用支持连等 |
[] | 是 | 同上,且要能出现在赋值号左边 |
() | 是 | 函数调用运算符,左操作数是对象本身 |
-> | 是 | 解引用链路要求左操作数是对象 |
+-== | 否 | 非成员版本能接受左操作数为其他类型 |
第 5 题考抽象类的构造与析构。抽象类不能被实例化,但它照样需要构造函数,因为派生类构造时要在初始化列表里调基类构造;照样需要析构函数,因为通过基类指针 delete 派生类对象时,析构要按层次往下走。这两个函数在抽象类里不是冗余,是链路的一部分。答题时把“派生类构造要先构造基类子对象”这句话写上,理由就完整了。
第 6 题考异常捕获的向上兼容:抛出的是子类对象,用父类类型去 catch 是可以的,前提是按引用捕获,避免切片。这道题判“错”,是因为题干说的是“不能”。答题时补一句“按引用捕获catch (Base& e)才能保留动态类型”,这是区分会不会的分水岭。
第 7 题考虚指针和虚表的数量关系。每个含虚函数的类,它的每个对象里都有一个虚指针,指向该类的虚表;对象可以有无数个,虚指针就有无数个;但虚表是类级别的,一个类一份。所以“每个对象都有一个虚表”这种说法是错的,错在把虚指针和虚表混为一个东西。这段话建议原样背下来,简答题和判断题都会用。
2.4 继承与组合、虚析构:两道考设计判断的
第 9 题只留下四个字:通常不需要。能对上号的考点是虚析构函数——不是每个类都要把析构写成 virtual。虚析构只在一种场景下必写:这个类会被当作基类,并且你会通过基类指针 delete 派生类对象。没有多态删除,虚析构就是白加一个虚指针。工程上更常见的做法是给基类一个 protected 的非虚析构,从根上禁止通过基类指针删除。
第 10 题考继承和组合的取舍。继承是“is-a”,耦合度高,基类一改,派生类全受影响;组合是“has-a”,只依赖接口,替换成本低。结论是:能组合就别继承。答题时给一句判断口径——如果两个类的关系能用“是一个”说通,并且确实需要多态替换,才用继承;只是“用到了”另一个类的能力,就用组合。
3. 简答题:多态、空类 sizeof、父类指针调用子类函数
3.1 多态的标准答法:三个要件缺一不可
这道题几乎是必考,但绝大多数人只答到第一句。完整的答法要分三层:第一层讲现象,允许把子类指针赋给父类指针,赋值之后父类指针按实际指向的对象调用对应版本;第二层讲实现,靠虚函数和虚表,调用发生在运行时;第三层讲前提,必须同时满足三个条件——有继承关系、基类里把函数声明为 virtual 且派生类完成覆盖、通过基类指针或引用调用。三个条件少一个,多态就不成立,这一点是阅卷人最想看到的。
举个卷子上出现过的例子:有一个车,有开车门函数;来一辆宝马,我开车门;来一辆本田,我开车门。同一个“开车门”动作,作用在不同对象上产生不同行为,这就是多态。
class Car { public: virtual void openDoor() = 0; // 纯虚:接口约定 virtual ~Car() {} // 基类析构必须是虚的 }; class BMW : public Car { public: void openDoor() override { /* 宝马的开门方式 */ } }; class Honda : public Car { public: void openDoor() override { /* 本田的开门方式 */ } }; void test(Car& c) { c.openDoor(); // 传 BMW 调 BMW 版本,传 Honda 调 Honda 版本 }override不是必须写的,但写上之后,如果基类里忘了加 virtual 或者签名不一致,编译器会直接报错,等于把“覆盖失败”这个坑提前到编译期。参数说明上,test用引用而不是值传递,是为了避免对象切片——按值传Car只会拷贝基类部分,虚表指针也一起被切掉,多态直接失效。
3.2 空类 sizeof 是 1:静态成员为什么不占对象空间
卷子给的是class A { public: static int x; };,问sizeof(A)。答案是 1,不是 4。两个点要分开讲。
第一,静态成员变量不占对象空间。它存在静态存储区,整个类共享一份,所有对象看到的是同一个 x。所以 sizeof 算的是“一个对象占多少字节”,静态成员不在这个账本里。第二,空类为什么是 1 而不是 0。C++ 要求每个对象有唯一地址,如果大小为 0,数组里两个元素就会重叠,取地址会出问题,所以编译器塞一个占位字节。
#include <iostream> class A { public: static int x; // 声明,不占对象空间 int y; // 占 4 字节 }; int A::x = 0; // 定义,放在静态存储区 int main() { std::cout << sizeof(A) << std::endl; // 输出 4,不是 8 A a1, a2; a1.x = 5; std::cout << a2.x << std::endl; // 输出 5,共享同一份 return 0; }这段代码里sizeof(A)是 4 而不是 1,因为多了个 int 成员;如果类里什么都没有,才是 1。另外要注意,静态成员能在对象实例化之前赋值,因为它的生命周期跟程序一致,不依赖任何对象。这句话本身就是判断题和简答题的通用素材。
3.3 10 + a 的两种写法:转换函数与非成员运算符
卷子问的是怎么让10 + a编译通过,给了两条路。
第一条是写转换函数,把 A 隐式转成 float:
class A { public: int x; A(int i) : x(i) {} // 转换函数:无参数、无返回类型声明,返回类型就是函数名 operator float() { return this->x * 1.0f; } }; int main() { A a(9); float m = 10 + a; // a 先转成 9.0f,再走内置加法 std::cout << m << std::endl; // 输出 19 return 0; }编译器在算10 + a时找不到int operator+(int, A),于是去找转换路径,发现 A 能转成 float,就先把 a 转掉再做加法。参数说明上,operator float()里的1.0f是为了让乘法在浮点域进行,写成x * 1.0会得到 double,虽然能隐式转回 float,但中间多一次转换。
第二条路是直接写一个非成员运算符:
class A { public: int x; A(int i) : x(i) {} A(const A& a) { this->x = a.getx(); } int getx() const { return this->x; } }; // 左操作数是 int,成员运算符做不到,只能写成非成员 float operator+(int i, A a) { return i * 1.0f + a.getx(); }成员运算符的左操作数必须是本类对象,这里左边是 int,所以只能写成非成员。参数按值传会触发拷贝构造,所以卷子上补了一个拷贝构造函数,把 x 搬过去。这类题的答题要点是:说清“为什么必须这样写”,而不是只贴代码。两种写法同时存在会有二义性风险,编译器不知道该走转换还是走重载,这一点写上去是加分项。
3.4 父类指针能不能调到子类函数:三种情形一次说清
卷子给了三种组合,问能不能通过父类指针调到子类版本。
第一种,两者都不是虚函数:不行。编译器按指针的静态类型绑定,Base*调到的永远是Base::f。第二种,父类非虚、子类虚:还是不行。子类里加 virtual 只影响子类自己的派生体系,改不了基类那个函数非虚的事实。第三种,两者都是虚函数:可以,走虚表,运行时按对象实际类型找。
class Base { public: void f() { std::cout << "Base::f" << std::endl; } // 非虚 }; class Derived : public Base { public: virtual void f() { std::cout << "Derived::f" << std::endl; } }; Base* p = new Derived; p->f(); // 输出 Base::f,静态绑定看指针类型,不看对象结论一句话:覆盖的前提是基类里就声明了 virtual。派生类单方面加 virtual 不构成覆盖,只构成隐藏。这道题的失分点在于很多人只答“可以/不可以”,不解释绑定时机。补一句“非虚函数在编译期按静态类型绑定”,理由就成立了。
3.5 静态与常成员、前++ 与后++:对比型简答怎么答
卷子上有一道题给了一组对比:funcA是静态成员函数,funcB是非静态成员函数;另一组是常成员函数和非常成员函数。这类题答成表格最省事,也最不容易漏点。
| 对比项 | 写法 | 关键差异 |
|---|---|---|
| 静态成员函数 | static void f(); | 无 this,不能访问非静态成员,不能声明为 const |
| 非静态成员函数 | void f(); | 隐含 this,可访问全部成员 |
| 常成员函数 | void f() const; | this 变成指向常量的指针,函数内不能改成员 |
| 非常成员函数 | void f(); | 可以修改成员 |
自增运算符那道题,卷子上记的签名是X operator++(const A&),这个写法在实际考试里要改。前++ 是A& operator++(),先自增,返回自身引用,支持++(++a);后++ 是A operator++(int),那个 int 是哑元参数,只为和前置版本区分,返回的是自增前的副本。
class Counter { public: int v; Counter(int n = 0) : v(n) {} Counter& operator++() { // 前++ ++v; return *this; // 返回引用,避免多一次拷贝 } Counter operator++(int) { // 后++,int 是占位 Counter old = *this; // 先存旧值 ++v; return old; // 返回旧值副本 } };答题时把“前置返回引用、后置返回值”这句话写出来,再补一句“哑元 int 不参与运算,只用于重载决议”,基本就是满分答法。
4. 常见失分点:改错题里四类编译期错误怎么改
4.1 改错题的给分逻辑:改一行,不改原意
改错题不是让你重写,是在保留原意的前提下把编译错误消掉。给分点通常落在“改对那一处 + 没有引入新错误”。所以动笔前先定位报错,再动最小的一处。把整段代码重写成另一套实现,哪怕能跑,也可能因为改变了原有结构被判错。
还有一条隐性规则:改错题的错误类型是分层的,语法层(参数表、返回类型)、类型层(const、隐式转换)、访问控制层(private/protected)、抽象类完整性(纯虚函数是否全部实现)。按这个顺序检查,不会漏。
4.2 四条真题:错在哪、为什么错、怎么答对
第一处:析构函数带了参数。
错在哪:代码里把构造函数才有的参数写到了析构函数上。
为什么错:析构函数无参数、不能重载,一个类只能有一个析构函数。它的调用时机由编译器在对象生命周期结束时决定,没有任何一方能给它传参。
怎么答对:把参数去掉,改回无参形式;如果原意是构造,把函数名改成类名。
class A { public: A(int i) { /* 构造:可以有参数 */ } ~A() { /* 析构:不能有参数,也不能重载 */ } };第二处:const int传给了int * const形参。
错在哪:实参&b的类型是“指向 const int 的指针”,形参类型是“常量指针,指向 int”,把前者传后者属于权限放大。
为什么错:const 写在*左边修饰被指对象,写在*右边修饰指针本身。int * const虽然指针不能改,但它指向的 int 是可以改的;而实参那边对象本身是 const,不许改。允许这种转换,就等于让人绕过 const 去改常量。
怎么答对:两条路,要么去掉实参 b 的 const,要么把形参改成const int*。后者更安全,改动也更小。
void f(const int* p) { /* 推荐:形参跟随实参的 const 属性 */ } // void f(int* const p) —— 这种写法只是指针不能改,指向的内容仍可改第三处:子类没有实现全部纯虚函数,外加基类成员私有。
错在哪:一是 B 没有覆盖 A 的纯虚函数 y,B 本身仍是抽象类,不能实例化;二是 A 的成员 a 是私有的,B 里写cin >> a编译器看不见。
为什么错:纯虚函数是“必须在具体类里全部实现”的约定,漏一个,这个类就还是抽象类。访问权限是编译期检查,private 成员对本类之外一律不可见,派生类也不例外。
怎么答对:y 改成空实现void y() {},把 A 变成具体类;a 挪到 protected 或 public,或者提供 setter。两种改法的区别值得写一句:挪 public 改动小,但破坏封装;加 setter 更规范,工程上更常用。
第四处:通过子类对象访问基类私有成员。
错在哪:cout << y.x直接读了 A 的私有成员 x。
为什么错:private 的作用域就是本类内部,派生类对象里虽然有这块内存,但名字不可见。
怎么答对:把 x 放 public,或者加一个int getX() const。考场上两种都算对,但如果题目强调“不改接口”,加 getter 更稳。
class A { private: int x = 0; public: int getX() const { return x; } // 只读访问,封装不破 };4.3 改错题的检查顺序:从参数表看到访问权限
拿到一道改错题,按这个顺序扫一遍,基本不会漏:
先看函数签名,构造函数和析构函数有没有参数、返回类型对不对、const 有没有写错位置;再看类型转换,const 的增减是不是单向合法的、有没有把指针常量和常量指针搞混;接着看访问权限,派生类用到的基类成员是不是 private;最后看抽象类是否完整,纯虚函数是不是全部实现。四步走完,再动手改。
注意:改完一定要在脑子里过一遍“这段代码现在能不能编译”。改错题最常见的失分不是改错,是改对了一处又引入另一处。
5. 代码阅读大题:状态模式那道题怎么跟着输入走一遍
5.1 先认清三个角色
这道大题的代码长,其实骨架只有三个角色。
State是抽象基类,只有一个纯虚函数Turn(Case* pCase),它是所有状态的共同接口。Case是上下文,手里握着一个State*,构造函数把它接过来,Run()里调pState->Turn(this),注意这句话同时完成了两件事:调用当前状态的 Turn,把自己的地址传回去,让状态能修改自己的指针。StateA到StateD是四个具体状态,都实现了 Turn,行为是“输出一行 + 把 pState 换成工厂造出来的新状态”。Factory读一个字符,按 1 到 4 返回不同的 State 对象。
这个结构就是状态模式的考场版:把“当前处于什么状态”放在上下文里,状态切换由具体状态自己发起。
5.2 主函数第一行:new Case(Factory()) 发生了什么
主函数第一行Case *p = new Case(Factory());要分两步读。
第一步算参数:调Factory(),从标准输入读一个字符,走到对应分支,new出一个 StateA 到 StateD 之一返回。这个对象是用 new 造的,没有对应的 delete,它的生命周期一直持续到程序结束。考场上常问“这里有没有内存泄漏”,答案是“有,但不影响程序结果”,因为进程退出时系统回收。答这个点的时候最好补一句“工程上应该用智能指针”,但不能因此说这段代码跑不起来。
第二步进构造:Case的构造函数把传进来的 State 指针赋给成员pState,构造结束,上下文和状态就连上了。
class State; // 前向声明,解决循环引用 class Case { public: State* pState; Case(State* p) { pState = p; } void Run() { pState->Turn(this); } // 把自身地址交给状态 };参数说明上,State*用裸指针而不是对象,是因为要多态:存对象会切片,虚表被切掉,Turn 就退化成基类版本。这也是答“为什么用指针”的标准理由。
5.3 九轮循环怎么列输出
进入 for 循环后,每一轮的流程固定:调Run(),进而调pState->Turn(this),走到当前状态的具体 Turn,输出一行文字,然后再次调Factory()读下一个字符,把pState换成新的对象。所以每一轮消耗一次输入、产出一行输出,循环 9 次就是 9 次输入、9 行输出。
State* Factory() { char c; std::cin >> c; switch (c) { case '1': return new StateA; case '2': return new StateB; case '3': return new StateC; case '4': return new StateD; default: return new StateA; // 原题没写 default,这是必须补的 } }原代码的 switch 没有 default 分支,输入不是 1 到 4 时函数走到末尾没有返回值,属于未定义行为。答输出题时把这一点点出来,是明显的加分项,因为它说明你读代码时考虑的是“所有路径”,不是“样例路径”。
假设输入序列是1 2 3 4 1 2 3 4 1,输出就是:
用餐 ing- 学习 ing- 运动 ing- 娱乐 ing- 用餐 ing- 学习 ing- 运动 ing- 娱乐 ing- 用餐 ing-第一行由第一次输入决定,之后每次输入既决定上一轮状态切换后的结果,也决定下一轮输出的内容。写答案的时候按“输入 → 状态 → 输出”列成三列,比直接抄九行输出清楚得多。
5.4 追问与加分点
这类题常见三个追问。
第一个:Turn 为什么要传 this。因为状态切换要修改上下文的pState,不拿到上下文地址就没法改,而状态对象本身不持有上下文。第二个:为什么pState用指针不用对象。多态要求按指针或引用调用,存对象会发生切片。第三个:如果新增一个状态要改哪里。改三处——加一个 StateE 类、Factory 里加一个分支、别的地方不动,这就是状态模式相对 switch 的价值。把这三点答出来,代码阅读题的分就稳了。
6. 内存对齐速判:sizeof 排名题的四步法
四个类的大小,A 是 16、B 是 4004、C 是 16、D 是 24,排名 B > D > A = C。这个结果用四步就能推出来,不用背。
第一步看虚函数。类里有虚函数,对象里就多一个虚指针,64 位下是 8 字节,而且它排在最前面。D 有虚函数,所以起手就是 8。第二步看静态成员。静态成员存在静态区,不计入 sizeof,A 和 C 只差一个静态变量,所以两者一样大。第三步看数组和指针的区别。int[1000]是整个数组塞进对象,4000 字节;int*只是一个地址,8 字节。B 是 4 + 4000,A 是 4 + 8,差距就在这。第四步按最大对齐数补齐。A 的成员是 int(4) + 空(4) + int*(8),中间补 4 字节让指针 8 字节对齐,合计 16;D 是虚指针(8) + int(4) + 空(4) + int*(8),合计 24。
| 类 | 成员构成 | 大小 | 关键判断 |
|---|---|---|---|
| A | int + 空 4 字节 + int* | 16 | 指针要 8 字节对齐 |
| B | int + int[1000] | 4004 | 数组整体进对象 |
| C | 同 A,另有一个静态成员 | 16 | 静态成员不占对象空间 |
| D | 虚指针 + int + 空 4 字节 + int* | 24 | 有虚函数就多 8 字节 |
记不住对齐规则的时候,用比较法兜底,卷子上给的就是这条路:A 和 C 只差静态变量,静态不占空间,所以相等;A 和 B 差在指针和数组,指针 8 字节、数组 4000 字节,所以 B 大得多;D 和 C 差在虚函数,多一个虚指针,所以 D 比 C 大。四步走完,排名自然出来。
押题方向也给三个:派生类的大小(基类有虚函数时,派生类复用同一个虚指针,不再加 8)、空类做成员时的占位字节、含标准库类型成员的对象大小(这个依赖具体实现,考场上一般不会让你算,遇到就说明“取决于实现”)。至于虚继承,属于偏难的方向,真考到就先画虚基类表指针,再按对齐补。
我当年第一次做这类题,凭感觉把 B 写成 8,理由是“数组就是个指针”——那次扣分扣得挺疼。从那以后我每次遇到 sizeof 题,都强制走一遍“虚函数 → 静态成员 → 数组还是指针 → 对齐补齐”这四步,再简单的类也不跳步。希望帮到你。
本文还有配套的精品资源,点击获取