C++ OOP 核心知识点笔记
前言
本文整理自 OOP 课程的四次作业笔记,系统梳理了 C++ 面向对象编程的核心考点,包括:OOP 基础概念、命名空间、构造/析构函数、对象生命周期、继承体系、运算符重载以及多态机制。
一、OOP 基础与命名空间(HW1)
1.1 C++ 与面向对象
Q: 以下关于 C++ OOP 的说法哪个是错误的?
- A. 可以不使用类来编写代码
- B. 代码必须包含至少一个类 ✓
- C. 类必须有成员函数
- D. 代码中至少应声明一个对象
解析: C++ 是一门多范式(Multi-paradigm)语言。它不仅支持面向对象,还完全向下兼容 C 语言的面向过程编程。你可以写一个只有 main() 函数的 .cpp 文件,里面不定义任何类,代码依然能完美编译运行。
Q: 以下哪个特性支持开放递归(Open Recursion)?
- A. 使用 this 指针 ✓
- B. 使用指针
- C. 使用值传递
- D. 使用参数化构造函数
解析: “开放递归”指的是在类的内部,一个成员函数调用另一个成员函数。在 C++ 编译器底层,类内部的函数相互调用实际上都是通过隐藏的 this 指针(隐式执行 this->anotherFunction())来完成的。这也是多态中基类调用虚函数能正确路由到子类实现的关键机制。
Q: 关于类的成员函数,以下哪项是错误的?
- A. 所有成员函数必须被定义
- B. 成员函数可在类内部或外部定义
- C. 成员函数不需要在类定义内部声明 ✓
- D. 成员函数可以通过 friend 关键字成为另一个类的友元
解析: C++ 语法严格要求:所有成员函数必须在类的 {} 内部声明。你可以把函数体写在类的外面(使用 ClassName::),但必须先声明。否则编译器无法识别该函数属于这个类。
1.2 抽象与封装
抽象(Abstraction)原则的核心是:
- C. 尽可能使用抽象以避免重复 ✓
解析: 当代码中出现多处类似逻辑时,DRY(Don’t Repeat Yourself)原则建议将这些重复部分抽象成公共函数、类或基类,大幅提高代码复用性和可维护性。
封装(Encapsulation)与抽象的区别:
- A. 分别是绑定(Binding)和隐藏(Hiding) ✓
解析: 这是一道经典区分题:
- 封装侧重于绑定——把数据和方法捆绑成不可分割的整体
- 抽象侧重于隐藏——向外界隐藏内部复杂机制,只暴露必要的接口
在流和文件的概念中:
- A. 抽象被称为流(Stream),设备被称为文件(File) ✓
解析: “流”是纯粹的逻辑抽象概念(如 cin、cout),而”文件”或显示器等是具体的物理设备。我们通过操作”流”这个抽象层来统一读写物理设备。
1.3 命名空间
命名空间的作用:
- B. 将程序结构化为逻辑单元 ✓
解析: 命名空间类似于文件系统中的”文件夹”。在大型工程中,为防止不同模块产生命名冲突,使用命名空间将相关的类、对象、函数分组隔离。
匿名命名空间示例:
1 |
|
匿名命名空间中的变量自动具有内部链接属性(类似 static 全局变量),作用域被严格限制在当前文件中,可直接通过变量名访问。
作用域解析示例:
1 |
|
::i() 前的 :: 告诉编译器忽略局部作用域里的枚举和类,直接调用全局函数。函数内部的局部变量 int i 优先级最高,屏蔽了命名空间里的 i。
二、对象生命周期与类成员(HW2)
2.1 拷贝构造函数
拷贝构造函数的用途:
- C. 拷贝对象以便将其传递给函数 ✓
解析: 当对象以值传递方式作为形参传入函数,或从函数中按值返回时,编译器会自动调用拷贝构造函数生成该对象的副本。
拷贝构造函数何时被调用?
- C. 生成临时对象时 ✓
解析: 编译器在处理对象按值传参、按值返回或执行隐式类型转换时,会在栈区生成临时对象,此时必定触发拷贝构造函数。
拷贝构造函数可以设为 private 吗?
- A. 是的,始终可以 ✓
解析: 将拷贝构造函数和赋值运算符声明为 private 且不提供实现,是打造 Non-copyable Object(如互斥锁、单例对象)的经典模式。如今更推荐使用 = delete。
如果拷贝构造函数只接受非 const 参数:
- B. 只有非常量对象才能被接受 ✓
解析: 如果拷贝构造函数没有 const 修饰(即 ClassName(ClassName& obj)),编译器基于类型安全,绝不允许传递 const 对象。因此它只能接受非常量对象。
2.2 析构函数
析构函数的调用方式:
- C. 可以是隐式或显式的 ✓
解析: 析构函数通常在对象离开作用域时由系统隐式调用。但在使用 Placement new 等高级场景下,必须通过 obj.~ClassName() 显式调用以清理资源。
析构函数何时被调用?
- D. 对象生命周期结束之前的那一刻 ✓
解析: 析构函数在对象生命周期即将终结但内存还未被回收的”最后一刻”执行,负责释放对象持有的外部资源(堆内存、文件句柄等)。
全局对象的析构顺序:
- C. 逆序(Reverse) ✓
解析: C++ 中对象的构造和析构遵循栈的 LIFO 逻辑。无论在局部函数还是全局存储区,析构顺序总是与构造顺序完全相反。
2.3 静态成员
当没有对象存在时,要在类内部操作静态数据成员:
- C. 必须使用静态成员函数 ✓
解析: 静态数据成员独立于任何对象存在。普通成员函数需要隐含的 this 指针才能调用,而静态成员函数不需要对象即可执行。
static 关键字的使用规则:
- B. 只在类内部声明时使用,类外部定义时不使用 ✓
解析: 这是 C++ 的硬性规定。在类外部实现静态成员时,绝对不能再加 static 关键字,否则会导致链接错误。
2.4 this 指针
this 指针的本质:
- D. 不是对象本身的一部分 ✓
解析: this 是编译器在调用非静态成员函数时隐式传递的第一个函数参数。它不存储在对象的物理内存布局中。对对象执行 sizeof(),结果不包含 this 指针。
成员函数调用的底层真相:
- D.
function(&object, parameter)✓
解析: 在编译器眼中,object.function(arg) 会被重写为 function(&object, arg),即将调用者的地址作为第一个隐藏参数传入——这就是 this 指针。
this 指针的典型用途:
- B. 防范自我引用(Self-reference) ✓
解析: 在重载赋值运算符时,必须防范自我赋值:if (this != &other) { ... }。否则释放旧内存时会连带销毁新数据。
2.5 new 与 delete
new 失败时会怎样?
- C. 要么抛出异常,要么返回零 ✓
解析: 现代 C++ 中 new 失败默认抛出 std::bad_alloc。若使用 new (std::nothrow) Type,则返回 nullptr。
delete 的执行顺序:
- B. 先调用析构函数,再释放内存 ✓
解析: delete 分两步:第一步调用析构函数清理资源,第二步调用 operator delete 归还物理内存。
delete 一个不是由 new 分配的对象:
- C. 会产生不可预测的错误 ✓
解析: 对栈或全局区的对象使用 delete 属于典型的未定义行为(UB),会导致难以预测的后果。
删除对象数组的正确语法:
- A.
delete[] objectName;✓
解析: 使用 new Type[N] 申请的数组必须用 delete[] 释放。漏掉 [] 会导致只析构第一个元素,其余元素资源泄漏。
2.6 默认参数与构造函数
若构造函数应同时支持无参和有参创建对象:
- C. 使用全部带默认参数的构造函数 ✓
解析: 所有参数都有默认值的构造函数(如 ClassName(int a = 0, int b = 0))可同时充当无参和有参构造两种角色。
默认参数的铁律:
- C.
className(int x=0, char c);会产生编译错误 ✓
解析: 函数的默认参数必须从右向左连续填充。绝不能把默认参数放在无默认值参数的左边。
三、继承与访问控制(HW3)
3.1 访问权限与继承方式
B 类私有继承 A 类,B 的友元函数能否访问 A 的私有成员?
- C. 不能,因为友元函数只能访问友元类的私有成员 ✓
解析: 友元特权是不继承、不传递的。A 的 private 成员对 B 本身都不可见,B 的友元自然无法跨越两层去访问。
protected 继承导致的权限降级:
1 | class A { |
由于 protected 继承,A 中原有的 public 接口 disp() 在 B 中被降级为 protected,外部无法通过对象直接调用。
哪个访问修饰符能让基类的 public 成员在派生类中变得”安全”?
- D. private 和 protected ✓
解析: 两种继承方式都会将继承来的公有接口降级,从而切断外部对象直接访问的可能。
3.2 继承类型全景
| 继承模式 | 拓扑结构 | 核心描述 |
|---|---|---|
| Single level (单继承) | 1 对 1 | 一个派生类只拥有唯一一个直接基类(A → B) |
| Multilevel (多级继承) | 垂直链 | 派生类继续派生子类,代代相传(A → B → C) |
| Hierarchical (层次继承) | 1 对多 | 一个基类同时派生多个子类(A → B 且 A → C) |
| Hybrid (混合继承) | 网状 | 多种继承方式结合,导致菱形问题的罪魁祸首 |
多重继承(Multiple Inheritance):一个子类同时拥有多个直接基类。正是它引发了混合继承中的菱形危机。解决方案是使用
virtual关键字进行虚继承。
菱形问题(Diamond Problem)的根本原因:
- A. 同名方法造成二义性和冲突 ✓
解析: 子类通过不同路径继承了顶层基类的同名方法/变量,编译器面临两条等价的查找路径,无法决断,抛出二义性错误。
3.3 向上转型
基类指针可以用派生类地址初始化,是因为:
- A. 派生类到基类的指针隐式转换 ✓
解析: 派生类对象在内存中必定包含完整的基类子对象,因此 C++ 原生支持将派生类指针隐式转换为基类指针——这被称为向上转型(Upcasting),是实现多态的基石。
四、运算符重载与多态(HW4)
4.1 运算符重载基础
运算符重载为成员函数时:
- 左操作数充当隐含的
*this指针 - 右操作数作为显式函数参数传入
- 在函数体内部可直接访问左操作数的成员,右操作数必须通过参数名访问
4.2 运算符重载方式选择
| 运算符类别 | 示例 | 必须 Member | 必须 Friend | 两者皆可 |
|---|---|---|---|---|
| 类的核心行为 | =, [], (), -> |
✓ 是 | ✗ 否 | ✗ 否 |
| 标准 IO 流操作 | <<, >> |
✗ 否 | ✓ 是 | ✗ 否 |
| 修改对象自身状态 | +=, -=, ++, -- |
✗ 否 | ✗ 否 | ✓ (推荐 Member) |
| 对称计算与比较 | +, -, ==, < |
✗ 否 | ✗ 否 | ✓ (推荐 Friend) |
| 底层语法解析 | ., ::, ?:, sizeof |
— | — | 严禁重载 |
必须用成员函数的四个运算符记忆口诀: 等号、中括号、小括号、箭头——这四个是类的”私有财产”。
流操作符为何用友元: 为了保持 cout << obj 的直觉写法,左操作数必须是 ostream 对象,因此只能写成全局函数。
对称运算符推荐友元: 友元允许左右操作数都进行隐式类型转换(obj + 5 和 5 + obj 均可编译)。
4.3 多态
多态的定义: 不同对象接收到相同的消息时,产生不同的行为。
从运行角度分类:
| 类型 | 机制 | 决断时机 |
|---|---|---|
| 静态多态 | 函数重载、运算符重载、模板 | 编译期 |
| 动态多态 | 继承 + 虚函数 | 运行期 |
联编(Binding)的两种方式:
- 静态联编(早期联编):编译时确定函数调用与函数体的绑定
- 动态联编(晚期联编):运行时基于指针或引用所指向对象的实际类型,通过虚函数表(vtable)确定调用
4.4 拷贝构造 vs 赋值运算符
这是考试和面试中最经典的陷阱。核心判断标准只有一个:赋值号左边的对象是否已经存在?
| 拷贝构造 | 赋值运算符 | |
|---|---|---|
| 动作 | 初始化 | 赋值 |
| 时机 | 正在创建全新对象 | 对象早已存在 |
| 典型代码 | A d = a; |
c = a;(c 已声明) |
| 隐式触发 | 按值传参、按值返回 | — |
防坑口诀: 带类型名声明的
=(如A d = a;)是在”生孩子”,必定调用拷贝构造;没有类型名、单独使用的=(如c = a;)是在”换衣服”,必定调用赋值重载。
附:C++ OOP 知识点速查
| 概念 | 一句话总结 |
|---|---|
| 封装 | 绑定数据与方法为整体 |
| 抽象 | 隐藏内部细节,只暴露接口 |
| 继承 | 子类复用父类的属性和行为 |
| 多态 | 同一接口,不同实现 |
| 命名空间 | 组织代码为逻辑单元,避免命名冲突 |
| this 指针 | 编译器隐式传入的调用者地址,不占对象内存 |
| 静态成员 | 属于类本身,独立于任何实例 |
| 虚继承 | 解决菱形继承中重复基类子对象的问题 |
| 友元 | 不继承不传递的特权访问 |
