C++面向对象
1.面向对象的三大特性:
封装,继承,多态
2.重载、**重写 和 **隐藏
对比项 | 重载(Overloading) | 重写(Overriding) | 隐藏(Hiding) |
---|---|---|---|
作用域 | 同一个类 | 继承体系(子类 vs. 父类) | 继承体系(子类 vs. 父类) |
函数名 | 相同 | 相同 | 相同 |
参数列表 | 必须不同 | 必须相同 | 可相同或不同 |
返回类型 | 可以不同,但不影响重载 | 不能改变(C++11 允许协变返回类型) | 可以不同 |
访问方式 | 直接调用 | 通过基类指针或引用调用 | 直接调用 |
虚函数 | 无关 | 必须是 virtual |
无关 |
影响 | 编译期选择不同方法 | 运行时调用子类方法(动态绑定) | 隐藏父类同名方法,必须使用 Base::func() 访问 |
- 重载(Overloading) 发生在同一个类,参数不同,返回值可以不同,属于编译期多态。
- 重写(Overriding) 发生在继承体系,子类重写基类的虚函数,必须函数签名(用于在重载时标识不同的函数)一致,属于运行时多态。
- 隐藏(Hiding) 发生在继承体系,但不是虚函数重写,而是子类定义了同名函数,隐藏了父类版本,调用父类方法需要
Base::func()
。
在实践中,虚函数重写是 OOP 里最重要的特性,它允许多态行为,而隐藏可能导致代码难以维护,因此建议避免让子类的方法隐藏父类的方法,除非有特殊需求。
3.多态及其实现办法
- 多态分为运行时多态(通过虚函数实现)和编译时多态(通过函数重载和模板实现)。
- 运行时多态执行的核心步骤 (运行时多态,虚函数必须为
virtual
,否则可能会引起内存泄漏)- 对象构造时初始化虚函数指针(vptr,存储在对象的实例内存中)
- 当对象被构造时,编译器会隐式地在对象内存中插入一个指向**虚函数表(vtable)**的指针(
vptr
)。 vptr
的值由对象的实际类型决定。例如:Base
类型的对象,vptr
指向Base
的虚函数表。Derived
类型的对象,vptr
指向Derived
的虚函数表。
- 当对象被构造时,编译器会隐式地在对象内存中插入一个指向**虚函数表(vtable)**的指针(
- 虚函数表(vtable)的结构 (存储在只读数据段中,并且全局唯一)
- 每个有虚函数的类都有一个对应的虚函数表(静态存储区)。
- 虚函数表是一个函数指针数组,每个条目指向该类的虚函数实现。
- 若派生类覆盖了基类的虚函数,则虚函数表中对应的条目会被替换为派生类的函数地址。
- 通过基类指针或引用调用虚函数
- 当通过基类指针或引用调用虚函数时,编译器会生成代码:
- 从对象的
vptr
找到对应的虚函数表。 - 根据虚函数在表中的索引(由编译器确定)找到函数地址。
- 调用该函数地址指向的实现。
- 从对象的
- 当通过基类指针或引用调用虚函数时,编译器会生成代码:
- 对象构造时初始化虚函数指针(vptr,存储在对象的实例内存中)
4.虚函数和纯虚函数
在虚函数中,基类需要提供默认实现,并且基类是可以实例化的。但是在纯虚函数中,基类不提供实现(但是可以定义,但是使用必须显示的使用),同时基类不允许被实例化,并且派生类必须覆盖,要给出纯虚函数的实现,否则仍然是抽象类。(只要类中有一个纯虚函数,那么这个类就不能被初始化)
纯虚函数更多的应用在接口之中,来实现接口和实现的分离,充分体现面向对象的特性。此外,纯虚函数也和虚函数一样,要指定虚构函数,并且为`virtual`
为什么纯虚类的构造函数必须是`virtual`的:因为多态对象的析构是从派生类开始到基类结束的,只有这样才能保证内存被正确的释放。在析构时,需要动态调用来保证资源的全部释放。
5.多继承
在 CPP
中,与 Java
不同的一点是 CPP
允许多继承。
1 | class Derived : public Base1, public Base2 { |
但是在多继承中,常见的问题是二义性问题,和棱形问题
菱形问题:
- 当一个派生类从两个或多个基类继承,而这些基类又共同继承自同一个基类时,会导致二义性 和数据冗余 。用虚继承可以解决这个问题
二义性问题:
- 如果多个基类有同名成员,直接访问会引发编译错误。用显式指定基类的作用域可以解决这个问题。
6.深拷贝和浅拷贝
- 深拷贝:会重新复制内容并且分配内存,并且每个对象独立管理其对应的内存(类似于
unique_ptr
) - 浅拷贝:只会复制指针的地址,并且多个对象共享同一块内存。但是可能会导致double free,悬垂指针的问题。
- 此外资源管理类 必须遵循”Rule of Three “: 如果定义了析构函数、拷贝构造函数或赋值运算符中的任意一个,通常需定义全部三个
7.Base* ptr = new Derived()的一些理解
首先是Base* ptr,这表明ptr是静态类型Base*,并且是保存在栈空间上的
然后ptr指向的是堆空间上的Derive()对象。这表明动态类型是Derived()
这里,由于Derived是继承的Base,因此在Derived的堆空间中,结构是这样的(如果Base有虚函数),同时Base中的属性也会被继承下来(无论是否private)只不过在Derived中无法访问。
1
2
3
4
5
6
7
8堆上的 Derived 对象:
+------------------+
| vptr(指向Derived) |
+------------------+
| base_data |
+------------------+
| derived_data |
+------------------+注意,这里的Base* ptr只能访问Base类中的属性和方法。当访问Base中的虚函数时,编译器会因为访问虚函数,因此触发动态调用,使用虚函数表指针去虚函数表中寻找
8.单继承和多继承的虚函数表
单继承
- 只有 1 张
vtable
,对象中只有 1 个vptr
。 - 调用虚函数时,
vptr
访问vtable
,跳转到函数地址。
多继承
- 每个
Base
类有独立的vtable
,Derived
有多个vptr
。 - 访问
Base1
相关函数时,走vptr1 → vtable_for_Base1
。 - 访问
Base2
相关函数时,走vptr2 → vtable_for_Base2
。
9.如何禁止构造函数的使用
方法 | 适用场景 | 是否允许子类实例化 |
---|---|---|
= delete |
彻底禁止构造 | ❌ |
private 构造函数 |
外部不能创建对象,但 friend 可创建 |
❌ |
protected 构造函数 |
允许子类创建对象,但基类不可实例化 | ✅ |
static 方法 |
通过 static 方法创建对象(单例模式) |
❌ |
抽象类(纯虚函数) | 作为基类强制继承 | ✅ |
10.什么是默认构造函数
默认构造函数是没有参数或所有参数都有默认值的构造函数。如果程序员没有显式定义构造函数,编译器会自动提供一个默认构造函数。
- 没有参数 或者 所有参数都有默认值。
- 用于创建对象时自动调用,初始化对象的成员变量。
- 如果没有显式定义,编译器会自动生成一个(但不会初始化成员变量)。
- 如果定义了其它带参数的构造函数,编译器不会再提供默认构造函数(C++11 之后)。
11.如何提高构造函数的效率
使用成员初始化列表构造效率最高的原因:避免了默认构造和赋值的双重开销,而是直接调用构造函数
1 | class Example { |
优势从上面就显而易见
优化方法 | 适用场景 | 优化点 |
---|---|---|
使用成员初始化列表 => A() : x(10), y(3.14) {} |
成员变量的初始化 | 避免默认构造 + 赋值 |
避免不必要的构造 => void foo(A a) { } // ❌ void foo(const A& a) { } // ✅ |
传参、返回值 | 传引用、RVO 优化 |
使用 explicit |
防止隐式转换 | 避免额外构造 |
避免 new 动态分配 |
资源管理 | 使用栈分配或智能指针 |
优化容器初始化 | std::vector 等容器 |
直接初始化,避免扩容 |
使用 = default |
编译器优化 | 让编译器自动优化 |
使用 std::move |
资源移动优化 | 避免不必要的拷贝 |
12.类对象初始化顺序
首先按照继承的顺序进行基类的初始化 –> 然后按照成员变量的申明顺序初始化,和构造函数初始化列表中的顺序无关 –> 然后进行构造函数体的执行
构造函数初始化列表指的是:
1 | Derived() : m2("m2"), m1("m1") { // 初始化列表顺序:m2 → m1(实际顺序仍按声明) |
13.友元函数的作用和使用场景
- 运算符重载:当需要重载
<<
或者>>
的时候,由于左操作数是ostream
或istream
- 跨类访问私有成员
- 工具函数需要访问私有数据
需要注意的是:
友元函数的注意事项
- 破坏封装性
友元函数会暴露类的内部实现,需谨慎使用,避免过度依赖。 - 友元关系不可传递
若类A
是类B
的友元,类B
是类C
的友元,类A
不会自动成为 类C
的友元。 - 友元声明的位置
友元函数的声明必须出现在类的内部(通常在public
或private
区域,但权限不影响友元的访问能力)。 - 友元函数不是成员函数
- 友元函数不属于类的成员,没有
this
指针。 - 调用时直接通过函数名,而非对象(如
exchange(a, b)
)。
- 友元函数不属于类的成员,没有
- 友元类
可以将整个类声明为友元:
1 | class A { |
14.静态绑定和动态绑定
绑定时机 | 编译时 | 运行时 |
---|---|---|
函数类型 | 非虚函数、重载函数、模板函数 | 虚函数(virtual functions) |
性能 | 无额外开销 | 需查虚函数表,有轻微开销 |
灵活性 | 固定,无法动态改变行为 | 灵活,支持多态 |
15.cpp模板编程
C++ 模板编程是泛型编程的核心技术,允许你编写与类型无关的通用代码。模板编程也是一种多态
- 函数模板:用于创建通用函数,自动推导参数类型
- 类模板:用于创建通用类(如容器)
- 模板特化:为特定类型提供定制化实现
- 模板元编程:在编译期执行计算,例如计算阶乘
1 | template <int N> |
16.如何避免不必要的拷贝来提高效率
- 使用引用或者指针来传递值 -> 避免不必要的大型对象拷贝
- 针对资源管理类使用
std::move
来移动所属权 - 尽可能的避免使用临时对象
17.实例化一个对象需要哪几个阶段
阶段 | 描述 |
---|---|
1. 分配内存 | 在栈上或堆上分配存储空间 |
2. 调用构造函数 | 初始化对象、调用基类构造函数 |
3. 执行初始化 | 初始化成员变量,拷贝/移动构造等 |
4. 使用对象 | 调用方法、访问成员变量 |
5. 调用析构函数 | 释放资源,执行清理操作 |
对象的实例化并不仅仅是构造函数的调用,而是一个完整的生命周期管理过程
18.如何让类中的函数无法访问类的成员变量
方法 | 是否能访问成员变量 | 适用场景 |
---|---|---|
static 成员函数 |
❌ 无法访问 | 工具类、无状态函数 |
内部 private 类 |
❌ 无法访问 | 数据封装,防止误操作 |
PIMPL 设计模式 |
❌ 无法访问 | API 设计,隐藏实现细节 |
friend 友元类 |
✅ 友元可访问 | 需要控制访问权限 |
如果目标是让类的普通成员函数无法访问数据成员,最佳方案是使用 static
方法 或 隐藏数据(PIMPL
、内部类)。
19.怎么限制类的对象只能创建在栈上或者堆上
限制在堆上:
- 将构造函数以及析构函数设为私有,同时提供一个静态的工厂函数用来返回在堆上创建的对象。(注意,这样做必须要手动释放堆空间,调用delete()直接delete this即可)
限制在栈上:
- 删除
new
和delete
关键字,来禁止在堆上创建
1
2void* operator new(size_t) = delete; // 禁止 new
void operator delete(void*) = delete; // 禁止 delete- 删除
20.类的默认私有继承和公有继承
在继承中,父类的所有属性和操作都会被继承到子类中,但是由于继承的默认是私有继承,导致无法直接访问父类的方法。(在下面的代码中,如果是 class Test : Derived
那么Test的实例t无法直接调用 t.Derived::foo()
)。而在类中的 private
,protected
,public
修饰的操作或者属性,仅仅用于类的不同访问者的访问权限。
1 |
|