😉本文主要总结了一些常见的C++面试题-面向对象相关知识点,欢迎大家前来学习指正,我也会不定期去更新相关内容。
面向对象
面向对象(OOP)的核心特性:封装、继承、多态
封装:将数据(成员变量)和操作数据的方法(成员函数)封装打包成一个类
- 数据隐藏:将类的数据(成员变量)私有化(private),禁止外部直接访问,只有通过公有(public)的方法间接访问操作。
- 接口暴露:提供公有方法(如get()、set()等)控制对私有成员的访问和修改
- 实现隔离:类内部的实现可以随意修改,只要接口保持不变,就不会对外影响代码
继承:允许一个类子类(派生类),基于父类(基类)构建,从而复用父类的方法和属性;子类可以扩展or修改父类的方法,同时保留父类的功能。
- 单继承:子类仅继承一个父类
- 多继承:子类可以继承多个父类
- 多层继承:子类继承的父类本身也是另一个类的子类,形成继承链
多态:
- 含义:同一个接口,多种实现
- 编译时多态(静态多态):函数重载、模板实现
- 运行时多态(动态多态):通过基类指针或引用,调用子类的虚函数实现
- 调用关系:this-> vptr->vtable->virtual function
重载、重写、隐藏
重载(overload):同一个作用域内,多个函数的 函数名相关,但是参数列表不同 <编译阶段:静态绑定>
重写(override):也成为覆盖,子类重写基类的虚函数 <运行阶段:动态绑定>
隐藏(Hiding):子类中定义了与父类同名
但参数列表不同
的函数,导致基类函数不可见 <编译阶段:静态绑定>
重载(overload) | 重写(override) | 隐藏(Hiding) | |
---|---|---|---|
作用域 | 同一个作用域 | 继承关系的 父子类之间 | 继承关系的 父子类之间 |
函数名 | 必须相同 | 必须相同 | 必须相同 |
参数列表 | 不同 | 必须相同 | 无要求 |
虚函数 | 无要求 | 父类必须为虚函数 | 无要求 |
返回值 | 无要求 | 必须相同 | 无要求 |
绑定时机 | 编译器,静态绑定 | 运行期,动态绑定 | 编译器,静态绑定 |
调用规则 | 根据实参类型匹配相应的函数 | 根据对象类型调用 | 子类函数隐藏父类同名函数 |
多态
静态多态(编译期间):函数重载,模板
- 效率高但是不灵活
动态多态(运行期间):虚函数
- 灵活性高,但是有性能开销
虚函数:
virtual修饰的成员函数
虚函数的调用流程:
- 编译期间:确认函数在虚函数表中的偏移
- 运行期间:
- 通过指针找到对象的虚函数表指针vptr
- 通过虚函数表指针vptr找到对象的虚函数表
- 根据偏移量找到实际的虚函数
- 正确的执行函数
虚函数的实现原理:
虚函数是C++实现运行时多态和核心机制,其底层通过虚函数表
和 虚函数表指针
实现:
虚函数表vtable
:编译器生成的静态的函数指针数组
,存放类的所有的虚函数地址;
- 同一个类的所有对象
共用一个
虚函数表`(静态数组) - 子类会继承父类的虚函数表,并根据自身是否重写虚函数进行更新(如果子类的虚函数重写了父类的虚函数,则对应在虚函数表中会把对应的虚函数替换成子类的函数的地址)
虚函数表指针vptr
:每一个包含虚函数的类的对象
,都拥有一个虚函数表指针
,指向该对象所属类的虚函数表
- 通常vptr,在每个类对象的前4/8字节(32位/84位系统)
- 每个vptr在构造时初始化,指向所属类的虚函数表
- 当继承和多态发生时,vptr会动态更新指向派生类的虚函数表
- 在多继承环境下,会存在多个虚函数表指针,分别指向对应的 不同基类的虚函数表
总结:
①每个包含虚函数的类需要维护一张虚函数表
②每个包含虚函数的类的对象要额外存储一个虚函数指针(vptr),通常为4/8字节
③虚函数的调用比普通函数多2次内存访问:通过虚函数指针vptr
找虚函数表vtable
,通过虚函数表vtable
找虚函数地址
构造/析构函数中的虚函数
<1>在构造函数
中调用虚函数,不会触发多态
,因此此时子类部分尚未构造。
构造的顺序是:父类构造->子类构造
类的对象构造时,先调用基类构造,此时对象的vptr指向基类的虚函数表,因此构造中的虚函数调用不会触发子类的重写版本。
<2>在析构函数
中调用虚函数,也不会触发多态
,因为子类已经析构。
- 析构时vptr逐级降级:析构中调用虚函数时,对象的虚函数指针vptr,指向当前正在执行析构函数的类的虚函数表,而非其子类的虚函数表。
- 子类析构阶段:执行子类的析构函数,子类的资源被释放,vptr仍然指向子类的虚函数表,此时调虚函数会调子类的实现(若有重写)
- 父类析构阶段:执行父类的析构函数,vptr会立即更新为指向父类的虚函数表,调用虚函数会触发父类的实现。
纯虚函数和抽象类
纯虚函数:纯虚函数在虚函数表中用特殊标记表示,通常为nullptr
抽象类:抽象类的虚函数表,包含纯虚函数的标记,其实例化会导致编译错误,因此纯虚函数无法实例化,必须继承后才能实例化。
菱形继承 & 虚继承
菱形继承:
class Person {
public:
int age;
void eat() { cout << "Person eats" << endl; }
};
class Student : public Person {
public:
int studentId;
};
class Employee : public Person {
public:
int employeeId;
};
class WorkingStudent : public Student, public Employee {
public:
int projectId;
};
// 内存布局
WorkingStudent对象
┌─────────────────────┐
│ Student::Person::age │ // 第一份age
├─────────────────────┤
│ studentId │
├─────────────────────┤
│ Employee::Person::age │ // 第二份age
├─────────────────────┤
│ employeeId │
├─────────────────────┤
│ projectId │
└─────────────────────┘
// 二义性问题
WorkingStudent ws;
ws.age = 25; // 错误:二义性,不知道是Student::Person::age还是Employee::Person::age
ws.eat(); // 错误:二义性,不知道是Student::Person::eat()还是Employee::Person::eat()
虚继承
虚继承,通过虚基类表(VBTable)和虚基类指针(VBptr)实现,更复杂,但是可以解决菱形继承的问题
虚基类表:VBTable
- 每个虚基类的子类维护一个VBTable,存储在该子类到公共基类的偏移量
- 运行时通过VBptr和VBTable,计算公共基类的实际地址
- 空间开销
- 每个虚继承的子类增加一个 VBPtr(通常 4/8 字节)
- 公共基类的成员被集中存储,可能导致内存布局不紧凑。
- 时间开销:
- 访问公共基类成员时,需要通过 VBPtr 和 VBTable 计算偏移量,增加一次内存访问
class Person {
public:
int age;
void eat() { cout << "Person eats" << endl; }
};
class Student : virtual public Person { // 虚继承
public:
int studentId;
};
class Employee : virtual public Person { // 虚继承
public:
int employeeId;
};
class WorkingStudent : public Student, public Employee {
public:
int projectId;
};
// 内存布局
WorkingStudent对象
┌─────────────────────┐
│ Student::VBPtr │ // 指向Student的虚基类表
├─────────────────────┤
│ studentId │
├─────────────────────┤
│ Employee::VBPtr │ // 指向Employee的虚基类表
├─────────────────────┤
│ employeeId │
├─────────────────────┤
│ projectId │
├─────────────────────┤
│ Person::age │ // 唯一一份age
└─────────────────────┘
// 解决二义性
WorkingStudent ws;
ws.age = 25; // 正确:唯一一份age
ws.eat(); // 正确:唯一一份eat()
虚指针初始化:
由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。
那么虚表指针在什么时候,或者说在什么地方初始化呢?
答案是在构造函数中进行虚表的创建和虚表指针的初始化。
构造函数的调用顺序,在构造子类对象时,要先调用父类的构造函数,之后再完成子类的构造。在调用父类的构造函数时,编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化虚表指针,将该虚表指针指向父类的虚表。当执行子类的构造函数时,虚表指针被重新赋值,指向自身的虚表。
C++多态性实现的原理
对于虚函数调用来说,每一个对象内部都有一个虚表指针,该虚表指针被初始化为指向本类的虚表。所以在程序中,不管你的对象类型如何转换,但该对象内部的虚表指针是固定的,所以才能实现动态的对象函数调用。
虚函数、构造函数、析构函数、的一些问题
构造函数 | 析构函数 | 虚函数 | |
---|---|---|---|
作用 | 初始化对象成员 | 释放对象资源 | 实现运行时多态 |
是否可继承 | 否 | 是(基类析构需为虚函数) | 是 |
是否可重载 | 是(参数列表不同) | 否(唯一,无参数) | 是(派生类重写) |
是否可 virtual | 否(构造时 VPTR 未初始化) | 是(基类必须为虚) | 是 |
是否可 inline | 是(隐式或显式) | 是(虚析构时动态绑定忽略) | 是(但动态绑定时忽略) |
是否可 static | 否 | 否 | 否 |
构造可以是virtual吗?析构函数呢?
- 构造函数:构造函数不能使用virtual修饰,因为构造函数执行时,vptr尚未初始化,无法实现虚函数的动态绑定。且无法通过基类指针调用派生类的构造(构造函数不能继承),virtual修饰构造存在语义矛盾。
- 析构函数:基类的析构函数,应该声明为虚函数
- 构造函数中调用虚函数:因为构造函数调用期间,此虚函数调用不会触发派生类的重写,因此是调用的当前类的构造
哪些函数不能是虚函数:
(1)构造函数
- 有虚函数的类都有一个虚函数表,每个类对象都有一个虚函数表指针,虚函数表指针在构造函数中初始化。
- 在构造函数执行时,vptr尚未初始化,无法实现虚函数的动态绑定。
- 无法通过基类指针或引用指向子类对象的形式调用子类的构造(构造函数不能继承),virtual修饰构造存在语义矛盾
(2)内联函数
- inline修饰的内联函数表示在编译期间展开;而虚函数意味着在运行期间进行类型确定。
(3)静态函数
- 静态函数不属于任何对象,属于类;静态成员函数没this指针,因为设置为虚函数没意义
(4)友元函数
- 友元函数不属于类的成员函数,不能被继承;对于没有继承性的函数没有虚函数的意义
(5)普通函数
- 普通成员函数不属于类的成员函数,不能被继承;对于没有继承性的函数没有虚函数的意义
构造函数、析构函数可以抛异常吗?
- C++只会析构已经完成的对象,如果在构造函数中抛出异常,控制权转出构造函数之外,因为构造并未完成,因此对象析构函数也不会被调用,因此会产生内存泄漏
- 析构函数不应该抛出异常:当对象被异常销毁(如栈展开)时,若析构函数抛出新异常,会导致两个异常同时存在,从而导致程序直接调用std::terminate终止
函数类型 | 是否允许抛出异常 | 异常处理要点 |
---|---|---|
构造函数 | 允许 | - 确保部分构造的对象正确释放资源(利用 RAII) - 异常应被上层调用者捕获处理 |
析构函数 | 不推荐 | - 必须保证无异常抛出(通过 noexcept 或内部处理) - 资源释放逻辑应简单可靠 |