第五章 类、对象与面向对象基础
第五章 类、对象与面向对象基础
1. 封装、继承、多态和抽象分别解决什么问题?
问题分析
这道题考查能否用对象模型、构造析构与拷贝语义解释题目中的语义,而不是停留在定义记忆。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:封装把状态和维护状态的行为放在一起,通过接口保护不变式;
- 再讲机制:围绕“四个概念的职责”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯客户端一面
问题讲解
一、四个概念的职责
封装把状态和维护状态的行为放在一起,通过接口保护不变式;抽象隐藏不相关细节,只暴露使用者需要的能力;继承表达“派生类型是基类型”的可替换关系,并复用接口或部分实现;多态让同一接口根据实际对象产生不同行为。
组合表达“拥有/使用”关系,通常比为了复用几行代码而继承更稳健。C++ 多态既包括虚函数的运行时多态,也包括模板、重载等编译期多态。
二、常见错误
- 把封装等同于给成员加 getter/setter。
- 为代码复用使用 public 继承,却不满足可替换关系。
- 认为多态只指虚函数。
- 建立过深继承树,导致状态和生命周期难以推理。
回答自检
- 四个概念各自解决的问题。
- 运行时与编译期多态。
- is-a 与 has-a 的区别。
- 为什么组合通常更容易维护。
面试官可能追问的问题
- 组合和继承如何选? is-a 且需要替换时考虑继承;has-a/use-a 优先组合。
- 抽象类是否等于接口? 抽象类只要求有纯虚函数,也可含状态和实现;接口类通常只定义契约。
- 封装如何提高可靠性? 修改只能通过受控操作发生,类可持续维护不变式。
2. 多继承会带来哪些问题?
问题分析
这道题考查能否用对象模型、构造析构与拷贝语义解释题目中的语义,而不是停留在定义记忆。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:多继承允许一个派生类同时拥有多个直接基类子对象,可能出现同名成员二义性、指针调整、对象布局复杂化。
- 再讲机制:围绕“主要问题 → 边界与选择”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯游戏客户端二面
问题讲解
一、主要问题
多继承允许一个派生类同时拥有多个直接基类子对象,可能出现同名成员二义性、指针调整、对象布局复杂化。菱形继承中,若两条路径都普通继承同一基类,最底层对象包含两份该基类子对象;虚继承可以合并为一份共享虚基类。

这是子对象语义示意,不是精确ABI布局。
二、边界与选择
虚基类由最派生类负责初始化,布局通常需要额外的偏移信息,不能假设大小。多继承并非一律错误:多个无状态接口的继承较常见;复用带状态实现时优先组合。接口边界还要考虑 ABI 和跨模块析构。
三、常见错误
- 认为 virtual 继承与虚函数是一回事。
- 使用虚继承后仍由中间类决定虚基类最终初始化。
- 通过强制转换绕过二义性而不审视设计。
- 断言多继承对象固定有几个虚指针。
回答自检
- 成员二义性和菱形重复基类。
- 虚继承如何共享基类子对象。
- 最派生类的初始化职责。
- 多接口继承与带状态实现继承的区别。
面试官可能追问的问题
- 如何消除同名成员二义性? 使用作用域限定,或在派生类提供统一覆盖接口。
- 虚继承的虚基类有几份? 在一个最派生对象中共享一份。
- 什么时候可接受多继承? 组合多个职责单一、无状态的接口时较合适。
3. 构造函数和析构函数的执行顺序是什么?
问题分析
这道题考查是否建立了对象模型、构造析构与拷贝语义的清晰概念边界,并能把类别、职责和限制对应起来。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:面试中常说的构造函数包括默认构造、带参数构造、拷贝构造和移动构造。
- 再讲机制:围绕“常见构造函数种类 → 析构函数不能重载 → 完整顺序”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:美团 C++ 一面
问题讲解
补充:常见构造函数种类
面试中常说的构造函数包括默认构造、带参数构造、拷贝构造和移动构造。它们不是四个必须同时手写的函数,而是对象创建方式的分类。只要类直接管理资源,就要一起审视拷贝、移动和析构;如果成员已经是 string、vector 或智能指针,优先遵循 Rule of 0。
补充:析构函数不能重载
在本篇普通 C++17 类中,不能像普通函数那样按参数重载析构函数。C++20 的类模板可声明带不同约束的候选析构函数,具体特化选出一个析构函数;这不是按运行期实参选择清理方式。资源差异通常仍由成员类型、自定义删除器或显式关闭接口表达。参见析构函数规则。
一、完整顺序
构造最派生对象时,先构造虚基类,再按基类声明顺序构造直接基类,然后按成员声明顺序构造成员,最后执行构造函数体。析构顺序完全相反:先执行析构函数体,再逆序析构成员、直接基类,最后虚基类。
初始化列表只指定“如何初始化”,不能改变成员的实际顺序。因此有依赖的成员应按声明顺序组织,避免后声明成员被用于初始化先声明成员。
二、完整可执行示例
下面故意把初始化列表写成 second(), first(),输出仍证明成员按类中的声明顺序 first、second 构造,析构则严格反向进行。
#include <iostream>
struct Trace {
explicit Trace(const char* name) : name(name) {
std::cout << "construct " << name << '\n';
}
~Trace() { std::cout << "destroy " << name << '\n'; }
const char* name;
};
struct Base {
Base() { std::cout << "construct Base\n"; }
~Base() { std::cout << "destroy Base\n"; }
};
struct Derived : Base {
Derived() : second("second"), first("first") {
std::cout << "construct Derived body\n";
}
~Derived() { std::cout << "destroy Derived body\n"; }
Trace first;
Trace second;
};
int main() {
Derived value;
}

失败图为独立教学情形:假设second抛异常且外层有匹配catch;上方代码本身不触发该失败。
三、常见错误
- 认为初始化列表的排列决定成员构造顺序。
- 在构造函数体内赋值,误称为成员初始化。
- 基类依赖派生类尚未构造的成员。
- 在部分构造失败时手动清理已经由成员 RAII 管理的资源。
回答自检
- 虚基类、基类、成员和函数体的顺序。
- 析构为何严格逆序。
- 声明顺序为何比初始化列表顺序重要。
- 部分构造失败时的自动清理。
面试官可能追问的问题
- 构造中途抛异常会调用本类析构吗? 不会,但已构造基类和成员会逆序析构。
- 虚基类由谁初始化? 最派生类。
- 为什么 const 成员必须在初始化列表初始化? 构造函数体开始时成员已完成初始化,不能再赋值给 const。
4. explicit 构造函数解决什么问题?
问题分析
这道题考查能否用对象模型、构造析构与拷贝语义解释题目中的语义,而不是停留在定义记忆。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:可用一个实参调用的构造函数可能参与隐式转换,使
process(10)自动创建某个业务对象。 - 再讲机制:围绕“转换构造的风险 → 边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:字节 C++ 后端一面
问题讲解
一、转换构造的风险
可用一个实参调用的构造函数可能参与隐式转换,使 process(10) 自动创建某个业务对象。若转换缺乏自然语义,会隐藏开销或把单位、状态混淆。explicit 禁止大多数复制初始化和隐式转换,但仍允许直接初始化。
二、边界
explicit 也可用于转换运算符;C++11 的 explicit operator bool 允许对象用于条件判断,又避免随意转为整数。C++20 支持条件 explicit。多参数构造只要其余参数有默认值,也可能成为转换构造。
三、常见错误
- 只给单参数构造加 explicit,忽略带默认参数的多参数构造。
- 把所有构造机械标为 explicit,连自然、无损转换也不分析。
- 认为 explicit 禁止所有构造方式。
- 通过隐式转换隐藏昂贵临时对象。
回答自检
- 转换构造如何参与重载。
- explicit 限制哪些初始化。
- 多参数默认实参的隐式转换风险。
explicit operator bool的用途。
面试官可能追问的问题
Type x = 10与Type x(10)有何不同? 前者复制初始化会受 explicit 限制,后者直接初始化可使用 explicit 构造。- 转换运算符能 explicit 吗? C++11 起可以。
- 标准库哪里常见 explicit? 智能指针到 bool 的转换具有条件上下文语义。
5. 局部静态变量、静态成员和文件作用域 static 有什么区别?
问题分析
这道题不只是让你罗列名词,而是考查能否从对象模型、构造析构与拷贝语义做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:局部
static具有静态存储期但块作用域,首次执行到声明时初始化; - 再讲机制:围绕“三种语义 → 边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:百度客户端一面
问题讲解
一、三种语义
局部 static 具有静态存储期但块作用域,首次执行到声明时初始化;静态数据成员不属于某个对象,所有实例共享,通常需类外定义,C++17 inline static 可在类内定义;文件作用域 static 给予名称内部链接,使其只在当前翻译单元可见。

二、边界
C++11 保证静态局部变量的初始化只执行一次,但之后读写仍需同步。静态成员函数没有 this,只能直接访问静态成员。命名空间作用域更推荐匿名命名空间表达仅本翻译单元可见的实体。
三、常见错误
- 认为所有
static都表示“全局变量”。 - 把线程安全初始化误解为对象后续访问线程安全。
- 通过对象调用静态函数后认为函数持有该对象的
this。 - 在头文件定义非 inline 静态数据成员导致多重定义。
回答自检
- 三个上下文的不同含义。
- 静态存储期、作用域和链接的区别。
- 静态局部初始化的并发边界。
- 静态成员为何不依附对象。
面试官可能追问的问题
- 静态局部何时析构? 通常在程序正常结束阶段,按成功构造的逆序处理。
- 跨翻译单元全局初始化顺序可靠吗? 一般不应依赖,常用函数局部 static 规避。
inline static解决什么? 允许 C++17 在头文件中提供一个合并定义。
6. this 指针是什么?静态成员函数为什么没有 this?
问题分析
这道题考查能否从可观察现象追溯到对象模型、构造析构与拷贝语义中的具体机制,并说明规则被破坏后的后果。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:非静态成员函数需要知道操作哪个对象,调用
obj.f()时概念上会把对象作为隐式参数传入,函数内通过this访问当前对象。 - 再讲机制:围绕“隐式对象参数”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯游戏客户端一面
问题讲解
一、隐式对象参数
非静态成员函数需要知道操作哪个对象,调用 obj.f() 时概念上会把对象作为隐式参数传入,函数内通过 this 访问当前对象。this 是一个指针表达式,不能改为指向别处。const 成员函数中的 this 指向 const 对象,不能修改普通成员。
静态成员函数属于类作用域,但调用不依赖任何具体对象,所以没有 this,也不能直接访问非静态成员。它仍受类的访问控制,可访问 private 静态成员。

二、常见错误
- 返回
this后忽略调用者可能比对象活得更久。 - 在异步 Lambda 捕获
this,对象销毁后任务仍访问。 - 认为静态成员函数是绑定了类对象的普通成员函数。
- 在构造函数中发布
this,让其他线程看到未完成对象。
回答自检
this如何关联当前对象。- const 成员函数对 this 的限制。
- 静态成员函数没有 this 的原因。
- 发布或捕获 this 的生命周期风险。
面试官可能追问的问题
delete this合法吗? 只有极严格的动态分配和后续不访问条件下才可能合法,工程中应避免。- Lambda 捕获
this捕获了什么? 捕获指针,不会自动复制或延长整个对象寿命。 - 成员函数指针为何需要对象? 调用时要形成隐式对象参数。
7. 友元函数和友元类有什么作用?应该谨慎使用吗?
问题分析
这道题考查能否把对象模型、构造析构与拷贝语义转化为可执行的判断步骤,而不是只报出 API 名称或最终结论。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:类可声明某个函数、类或特定成员函数为 friend,使其访问 private/protected 成员。
- 再讲机制:围绕“友元是什么 → 是否破坏封装”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:小米 C++ 一面
问题讲解
一、友元是什么
类可声明某个函数、类或特定成员函数为 friend,使其访问 private/protected 成员。友元函数仍是非成员函数,常用于需要对称转换的二元运算符、紧密协作的工厂或测试辅助。友元关系不继承、不传递,也不自动对称。
二、是否破坏封装
友元放宽了访问边界,但由被访问类主动授权,因此不是无条件破坏封装。问题在于权限范围可能过大、耦合增强。能通过公开不变式接口完成时无需友元;若两个类型本就是一个紧密抽象的组成部分,少量友元可比暴露内部 setter 更安全。
三、常见错误
- 认为 friend 函数是成员函数。
- 认为 A 友元 B,则 B 自动友元 A。
- 为测试方便把大量内部细节授权给整个测试框架。
- 用友元修补职责划分不清的设计。
回答自检
- 友元授予的权限和不授予的关系。
- 常见合理用途。
- 友元与封装的真实关系。
- 权限范围和耦合的权衡。
面试官可能追问的问题
- 友元能是模板吗? 可以,可授权特定实例或模板。
- 为什么
operator<<常为友元? 左操作数是流,运算符通常是非成员,又可能需要读取对象内部表示。 - 友元受 public/private 声明位置影响吗? 友元声明本身放在哪个访问区都不改变授权效果。
8. 哪些场景会调用拷贝构造函数?参数为什么通常是引用?
问题分析
这道题考查能否从可观察现象追溯到对象模型、构造析构与拷贝语义中的具体机制,并说明规则被破坏后的后果。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:用同类型左值初始化新对象、按值传参、按值返回以及异常对象传递都可能涉及拷贝构造。
- 再讲机制:围绕“常见触发场景”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:美团后端一面
问题讲解
一、常见触发场景
用同类型左值初始化新对象、按值传参、按值返回以及异常对象传递都可能涉及拷贝构造。但 C++17 的强制拷贝省略和编译器允许的 RVO 会消除部分实际调用,因此“语义上可复制”不等于运行时一定执行构造函数。
拷贝构造通常写成 T(const T&)。T(T other) 这种构造声明不合法,编译期就会被拒绝,不是一个合法程序在运行时无限递归。按引用接收避免为了传参再创建同类型副本。参见拷贝构造参数规则。const 引用还能复制 const 对象并避免额外副本。
二、常见错误
- 把任何等号都称为拷贝赋值。
- 通过打印日志断言标准一定调用了几次拷贝。
- 拷贝构造参数按非 const 引用,导致不能复制 const 或临时对象。
- 在基类拷贝构造中忘记复制基类状态。
回答自检
- 初始化与赋值的差异。
- 按值参数为何可能触发拷贝。
- 拷贝参数为何是 const 引用。
- 拷贝省略如何影响实际调用次数。
面试官可能追问的问题
T b = a是什么? b 尚不存在,因此是复制初始化并调用拷贝构造。- 返回局部对象一定拷贝吗? 不一定,可能 RVO,不能用老旧次数规则判断。
- 拷贝构造能 explicit 吗? 可以,但会限制某些复制初始化,通常很少需要。
9. 浅拷贝和深拷贝有什么区别?
问题分析
这道题不只是让你罗列名词,而是考查能否从对象模型、构造析构与拷贝语义做出有依据的比较和选择。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:浅拷贝通常逐成员复制,若成员是裸指针,两个对象会保存同一地址。
- 再讲机制:围绕“区别不在“复制多少字节” → 现代 C++ 设计”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:百度 C++ 一面
问题讲解
一、区别不在“复制多少字节”
浅拷贝通常逐成员复制,若成员是裸指针,两个对象会保存同一地址。若该指针表示独占所有权,两个析构函数会重复释放;若它只是非拥有观察,浅拷贝可能完全正确。深拷贝为目标创建独立资源,使两个对象具有彼此独立的值语义。

二、现代 C++ 设计
若需要值语义,优先把数据放入 string、vector 等成员,默认复制自然就是深层值复制;唯一资源使用 unique_ptr 并明确禁止复制或手写克隆;共享对象使用 shared_ptr,这是共享所有权语义,不应含糊称为“浅拷贝就够了”。
三、常见错误
- 认为含指针的类都必须深拷贝。
- 深拷贝时分配新资源失败,已经破坏目标旧状态。
- 用
memcpy复制含string、虚函数或非平凡成员的对象。 - 共享可变数据却没有同步或写时复制策略。
回答自检
- 拷贝策略由所有权语义决定。
- 裸拥有指针的默认复制为何危险。
- 值语义、唯一所有权和共享所有权的区别。
- RAII 成员如何让默认复制更可靠。
面试官可能追问的问题
shared_ptr复制是浅拷贝吗? 它复制共享所有权句柄,语义应明确称为共享,而非简单按深浅分类。- 如何复制多态对象? 常提供虚
clone()返回unique_ptr<Base>。 - 什么是 copy-on-write? 多个值先共享存储,修改时复制;并发和引用语义使实现复杂。
10. 什么是对象切片?如何避免?
问题分析
这道题考查能否把对象模型、构造析构与拷贝语义转化为可执行的判断步骤,而不是只报出 API 名称或最终结论。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:派生对象按值初始化基类对象时,只复制其基类子对象,派生类新增成员被切掉。
- 再讲机制:围绕“切片如何发生 → 避免方式”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯客户端一面
问题讲解
一、切片如何发生
派生对象按值初始化基类对象时,只复制其基类子对象,派生类新增成员被切掉。之后即便基类有虚函数,这个独立基类对象的动态类型也已经是基类,不会再调用派生覆盖。按值传入 void f(Base) 或存入 vector<Base> 都可能切片。

二、避免方式
需要多态时用 Base&、Base* 或具有明确所有权的 unique_ptr<Base>/shared_ptr<Base>;多态容器存智能指针。需要值语义又要保留派生类型时,可使用虚 clone()、variant 或类型擦除。基类也可把复制操作设为 protected/delete,阻止公开按值复制。
三、常见错误
- 认为有虚函数就能阻止按值切片。
- 用
vector<Base>保存不同派生对象。 - 按值捕获基类对象后期待保留派生状态。
- 把引用切换误称为切片;引用绑定不会复制对象。
回答自检
- 切片发生的按值转换过程。
- 动态类型为何在切片后消失。
- 多态参数和容器应如何设计。
- clone、variant、类型擦除的替代思路。
面试官可能追问的问题
- 返回 Base 值会切片吗? 返回表达式若是 Derived 而返回类型是 Base,会构造独立 Base 值。
- 如何复制多态层次? 虚
clone()是常见方案。 - 为什么多态基类常删除复制? 可防止误用值语义并强调通过引用/指针使用。
11. C++ 中如何实现线程安全的单例?
问题分析
这道题考查能否把对象模型、构造析构与拷贝语义转化为可执行的判断步骤,而不是只报出 API 名称或最终结论。
建议按“结论 → 机制 → 边界”组织回答:
- 先给结论:单例不是“把构造函数写成 private”这么简单。
- 再讲机制:围绕“单例要保证什么? → 首选局部静态对象 → 其他方案与边界”说明规则如何生效,把关键对象、时机或状态变化串起来。
- 最后讲边界:结合对象模型、构造析构与拷贝语义说明适用条件、常见误用,以及如何通过代码、编译结果或运行现象验证判断。
- 来源:腾讯 C++ 后端二面(编辑化整理)
问题讲解
一、单例要保证什么?
单例不是“把构造函数写成 private”这么简单。一个可用的单例需要限制实例创建、提供访问入口,并明确初始化时机、析构时机和并发访问规则。若对象本身包含可变状态,唯一实例也不等于成员线程安全。
二、首选局部静态对象
C++11 起,函数内局部静态对象的初始化由语言保证只执行一次,即使多个线程同时第一次调用也不会重复构造。它使用 RAII 管理析构,避免手写双重检查锁的内存序问题。
#include <iostream>
#include <mutex>
#include <thread>
class Settings {
public:
static Settings& instance() {
static Settings value;
return value;
}
void set(int value) {
std::lock_guard<std::mutex> lock(mutex_);
number_ = value;
}
int get() const {
std::lock_guard<std::mutex> lock(mutex_);
return number_;
}
private:
Settings() = default;
Settings(const Settings&) = delete;
Settings& operator=(const Settings&) = delete;
mutable std::mutex mutex_;
int number_ = 0;
};
int main() {
std::thread first([] { Settings::instance().set(7); });
std::thread second([] { std::cout << Settings::instance().get() << '\n'; });
first.join();
second.join();
}
局部 static 保证首次初始化安全;示例另用同一 mutex 保护成员读写,二者是不同保证。读取先发生会输出 0,设置先发生会输出 7,不能保证固定顺序;这是一段没有数据竞争、但执行次序未固定的程序。

三、其他方案与边界
| 方案 | 优点 | 风险 |
|---|---|---|
| 局部 static | 简单,初始化线程安全 | 退出时析构顺序仍需留意 |
std::call_once | 可控制初始化逻辑和失败重试 | 代码更长,需要管理存储 |
| 双重检查锁 | 可延迟初始化 | 容易写错内存序和生命周期 |
| 全局对象 | 访问简单 | 初始化顺序和测试隔离较差 |
| 析构阶段若还有其他静态对象访问单例,可能形成静态析构顺序问题。需要跨模块长期存活时,应显式设计生命周期,而不是把“永不析构”当成默认答案。 |
四、常见错误
- 只把构造函数设为 private,却忘记删除复制和移动操作。
- 用裸指针和手写双重检查锁,忽略发布和析构。
- 把单例的唯一性误认为业务状态自动线程安全。
- 在构造函数中把未完成的
this发布给其他线程。
回答自检
- 单例的唯一性、初始化和状态安全是三个不同问题。
- 局部 static 为什么通常优于手写双重检查锁。
- 静态析构顺序和测试隔离的风险。
面试官可能追问的问题
- 局部 static 的初始化由谁保证? C++11 及以后由语言保证并发初始化只发生一次。
- 单例一定是好设计吗? 不一定,全局状态会增加测试耦合和依赖隐蔽性,依赖注入常更清楚。
- 如何让单例可测试? 把接口抽象成依赖并允许测试替身,避免业务代码到处直接取全局实例。




