C++
1.1 多态(⭐⭐⭐)
同一个函数在不同的上下文中的多种形态。C++氛围编译时多态(静态)和运行时多态(动态)。
- 多态性定义: 多态性是面向对象编程(OOP)中的一个特性,允许不同类的对象通过相同的接口调用各自不同的实现方法。多态性主要分为两类:
- 编译时多态(静态多态):通过函数重载和运算符重载实现。
- 运行时多态(动态多态):通过虚函数实现。
虚函数: 虚函数是通过在基类中使用 virtual 关键字声明的函数,允许在派生类中重写。通过基类指针或引用调用虚函数时,会根据实际对象类型调用相应的重写版本。
纯虚函数: 纯虚函数是没有实现的虚函数,在基类中使用 = 0 声明。包含纯虚函数的类称为抽象类,不能实例化。
虚表(V-Table): 当一个类包含虚函数时,编译器会为该类生成一个虚函数表(虚表),其中存储了指向各个虚函数的指针。对象包含一个指向虚表的指针(虚表指针)。
常见面试题
- 解释多态性及其实现方式:
- 问题:什么是多态性?C++ 中如何实现多态性?
- 答案:多态性是允许不同对象通过相同接口调用不同实现的特性。C++ 中通过函数重载和运算符重载实现编译时多态,通过虚函数和继承实现运行时多态。
- 虚函数和纯虚函数的区别:
- 问题:虚函数和纯虚函数有什么区别?
- 答案:虚函数在基类中有默认实现,派生类可以重写;纯虚函数在基类中没有实现,必须在派生类中实现,否则派生类也是抽象类,不能实例化。
- 多态性的应用场景:
- 问题:在实际项目中,多态性有哪些应用场景?
- 答案:多态性常用于实现灵活的接口设计,例如通过基类指针或引用操作派生类对象,避免在代码中使用大量的条件语句(如 if-else 或 switch)。
- 虚表(V-Table)机制:
- 问题:解释虚表机制及其工作原理。
- 答案:虚表是编译器为类生成的表,其中存储了指向虚函数的指针。对象包含一个指向虚表的指针,调用虚函数时通过虚表指针找到实际的函数地址,从而实现动态绑定。
- 运行时多态性示例:
- 问题:编写一个简单的 C++ 程序,展示运行时多态性的用法。
- 答案:
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() {
cout << "Base class" << endl;
}
};
class Derived : public Base {
public:
void show() override {
cout << "Derived class" << endl;
}
};
int main() {
Base* b = new Derived();
b->show(); // 输出:Derived class
delete b;
return 0;
}
- 析构函数中的多态性:
C++中,构造函数不可以是虚函数,而析构函数可以且常常是虚函数。
问题 1:构造函数不可以是虚函数
答案 1:因为构造函数的用途是初始化对象的状态,虚函数是为了实现多态,在对象构造时,虚函数表还未完全建立,且构造函数的调用顺序从基类到派生类,这样虚函数机制无法正确地工作。
问题 2:为什么基类析构函数通常定义为虚函数?
答案 2:如果基类析构函数不是虚函数,通过基类指针删除派生类对象时不会调用派生类的析构函数,导致资源泄漏。将基类析构函数定义为虚函数,可以确保正确调用派生类析构函数。
- 虚函数表指针 size:32 位下 4 个字节
- 虚函数表存储在的区域:常量区(因为编译好了,虚函数表也已经确定了不能改变);
- 虚函数指针存储在的区域:和创建的对象在堆上创建还是在栈上创建所区分的,跟着对象走;
- 在构造函数和析构函数中使用虚函数:
- 构造函数:例如在基类的构造函数中使用虚函数,由于对象尚未完全构造,
vptr指向基类的虚函数表,因此调用的是基类的版本; - 析构函数: 在派生类析构函数中调用虚函数时,调用的是派生类的版本。但在进入基类析构函数后,调用的是基类的版本。这是因为在进入
Base的析构函数时,vptr已经指向了Base的虚函数表。
1.2 内存模型,继承
可以参考这篇博客,很好的博客,使我虚函数指针旋转。
内存模型指的是 C++中类对象的内存分布,一般有如下特点:
- 有虚函数,虚函数指针时钟放在内存空间的头部;
- 虚函数外,内存空间按类的继承顺序(父->子)和字段生命顺序布局;
- 多继承,每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到
第一个虚函数表的后面; - 如果有钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚表)、子类、公共基类(最上方的父类,包含虚表),并且各个父类不再拷贝公共基类中的数据成员。
- 菱形继承
1.3 内存管理(内存分配、内存对齐)(⭐⭐⭐)
1. C++的内存区域:
- 堆,使用 malloc、free 动态分配和释放空间,能分配较大的内存;
- 栈,为函数的局部变量分配内存,能分配较小的内存;
- 全局/静态存储区,用于存储全局变量和静态变量;
- 常量存储区,专门用来存放常量;
- 自由存储区:通过 new 和 delete 分配和释放空间的内存,具体实现可能是堆或者内存池。
2. 堆和栈的内存区别
- 堆中的内存需要手动申请和手动释放,栈中内存是由 OS 自动申请和自动释放;
- 堆能分配的内存较大(4G(32 位机器)),栈能分配的内存较小(1M);
- 在堆中分配和释放内存会产生内存碎片,栈不会产生内存碎片;
- 堆的分配效率低,栈的分配效率高;
- 堆地址从低向上,栈由高向下。
3. malloc/free 和 new/delete 的区别
- new 分配内存空间无需指定分配内存大小,malloc 需要;
- new 返回类型指针,类型安全,malloc 返回 void*,再强制转换成所需要的类型;
- new 是从自由存储区获得内存,malloc 从堆中获取内存;
- 对于类对象,new 会调用构造函数和析构函数,malloc 不会(核心)。
4. 什么是内存对齐(字节对齐),为什么要做内存对齐,如何对齐?
- 内存对其的原因:CPU 存取数据为提高效率,计算机从内存中取数据是按照一个固定长度的。如 32 位机器,CPU 每次都是取 32bit 即 4 字节数据。不对齐会出现
1.4 类型转换(⭐⭐)
1. C 风格类型转化
在变量前直接加数据类型
2. C++风格类型转化关键字
static_cast:隐式/静态类型转换,基本数据类型、指针类型、具有继承关系的类(处理不了无关关系的类型转换),编译期会做类型安全检查dynamic_cast:动态类型转换,基类指针或引用安全地->派生类指针或引用,配合多态使用,转换失败返回NULL,这是根据RTTI(Runtime Type Information)来保证安全,父类必须有虚函数,运行期会做类型安全检查const_cast: 增加或移除变量的常量性,只能用于指针或引用,并且只能改变对象的底层const(顶层const,本身是 const;底层const,指向对象 const)reinterpret_cast: 一个指针类型转换为另一个不同类型的指针,常用于底层编程
1.5 智能指针
为什么出现? 智能指针主要解决一个内存泄露的问题,它可以自动地释放内存空间。因为它本身是一个类,当函数结束的时候会调用析构函数,并由析构函数释放内存空间。
分为共享指针(shared_ptr)、独占指针(unique_ptr)、弱指针(weak_ptr)
shared_ptr: 多个共享指针可以指向相同的对象,采用引用计数的机制,当最后一个引用销毁时,释放内存空间; 具体实现:unique_ptr: 保证同一时间段内只有一个智能指针能指向该对象(可通过 move 操作来传递 unique_ptr);weak_ptr: 解决 shared_ptr 互相引用引起的死锁问题,特点是不会增加对象的引用计数,
1.6 各种关键字
const、指针常量和常量指针(顶层 const 和底层 const)、static 作用和初始化时间(只有在被生命的作用域被看到)、extern(到文件外查找该变量)、explicit(构建函数不能隐式转换)、constexpr(验证在编译器是不是常熟)、volatile(取内存而不是取寄存器的备份)、mutable(const 函数可以修改类中的非静态成员)、auto&deltype(自动推导数据类型,auto 不能用于函数传参和推导数组类,deltype 可以)
1.7 左值右值,构造函数
左值右值
lvalue: located value, 通常是有地址的rvalue: 即将销毁的临时对象 在下面的例子中,i是lvalue,10是rvalue
int i = 10;
左值引用,右值引用
- 左值引用是对左值的引用,通过左值引用可以访问和修改所引用的对象
- 右值引用特点是演唱右值的生命周期,主要用于实现移动语义,避免不必要的深拷贝,从而提高程序性能。例如,在实现自定义的移动构造函数和移动赋值运算符时,使用右值引用。
// 左值引用只能引用左值
void func(string& name) {};
// 左值引用只能引用左值
void func(string&& name) {};
// 可以兼容右值和左值-常量左值引用
ClassName(const ClassName& other);
拷贝构造函数和移动构造函数
C++11 之前,对象的拷贝控制由三个函数决定:拷贝构造函数(Copy Constructor)、拷贝赋值运算符(Copy Assignment operator)和析构函数(Destructor)。
有几个概念:
- 深拷贝:拷贝构造
- 前拷贝:对象左值引用/数据复制
- 移动构造:临时对象深拷贝
C++11 之后,新增加了两个函数:移动构造函数(Move Constructor)和移动赋值运算符(Move Assignment operator)。
- 构造函数:创建或初始化对象时调用
- 赋值函数:更新一个对象的值时调用
1.8 内联函数和宏
inline & define
- 两者的区别
1.9 杂项
C++11新:auto、nullptr、智能指针、右值引用C++11不足:没有垃圾回收机制、没有反射机制- 指针和引用的区别:有没有内存空间、是否可以更改指向、初始化、多级层面
- 重写、重载、隐藏
- delete 和 delete []
C++11关键字:noexceptC++11完美转发:引入的类型推导和转发机制,作用是在函数模板中保持参数类型和值的类别(左值右值)不变的传递给另一个函数C++11Lambda 函数:即匿名函数,也叫闭包。
STL、数据结构、算法
2.1 STL 各种容器的底层实现
vector,底层是一块具有连续内存的数组,vector 的核心在于其长度自动可变。vector 的数据结构主要由三个迭代器(指针)来完成:指向首元素的 start,指向尾元素的 finish 和指向内存末端的 end_of_storage。vector 的扩容机制是:当目前可用的空间不足时,分配目前空间的两倍或者目前空间加上所需的新空间大小(取较大值),容量的扩张必须经过“重新配置、元素移动、释放原空间”等过程。因此频繁地push_back会影响性能list,底层是一个循环双向链表,链表结点和链表分开独立定义的,结点包含 pre、next 指针和 data 数据。deque,双向队列,由分段连续空间构成,每段连续空间是一个缓冲区,由一个中控器来控制。它必须维护一个 map 指针(中控器指针),还要维护 start 和 finish 两个迭代器,指向第一个缓冲区,和最后一个缓冲区。deque 可以在前端或后端进行扩容,这些指针和迭代器用来控制分段缓冲区之间的跳转。stack和queue,栈和队列。它们都是由由 deque 作为底层容器实现的,他们是一种容器配接器,修改了 deque 的接口,具有自己独特的性质(此二者也可以用 list 作为底层实现);stack 是 deque 封住了头端的开口,先进后出,queue 是 deque 封住了尾端的开口,先进先出。priority_queue,优先队列。是由以 vector 作为底层容器,以 heap 作为处理规则,heap 的本质是一个完全二叉树。set和map。底层都是由红黑树(左根右、根叶黑、不红红、黑路同)实现的。红黑树是一种二叉搜索树,但是它多了一个颜色的属性。红黑树的性质如下:1)每个结点非红即黑;2)根节点是黑的;3)如果一个结点是红色的,那么它的子节点就是黑色的;4)任一结点到树尾端(NULL)的路径上含有的黑色结点个数必须相同。通过以上定义的限制,红黑树确保没有一条路径会比其他路径多出两倍以上;因此,红黑树是一种弱平衡二叉树,相对于严格要求平衡的平衡二叉树来说,它的旋转次数少,所以对于插入、删除操作较多的情况下,通常使用红黑树。
2.2 常见的问题
- push_back 和 emplace_back 的区别:push_back 会创建一个副本在进行 push,而 emplace 是直接将其值 push
- STL 排序用到的算法:快排、插入排序、堆排序
- 获取序列的前 k 的最大数或最小数:基于
快排和基于堆排序 - 什么是
友元函数,友元函数可以作为虚函数吗:友元函数是 C++中允许非成员函数或非成员类访问另一个类的私有和保护成员的一种机制。它通过在类的声明中使用关键字friend来实现。友元函数并不是该类的成员函数,但它有权访问该类的私有和保护成员;友元函数不能作为虚函数。虚函数是类的成员函数,通过在基类中使用关键字virtual声明,并允许在派生类中重写。由于友元函数不是类的成员函数,它们不能被声明为虚函数,也无法参与多态机制。 内联函数可以是虚函数吗:不能,虚函数是动态绑定的,即运行期调用,而内联函数是编译期展开的模板函数可以是虚函数吗:不能,虚函数的选择在运行时进行的,模板函数实例化在编译期- 模板函数是怎么实现的,为什么能实现多态:通过模板机制,编译器会在编译期为每个不同的模板参数类型生成一个对应的函数,其能够实现多态,是因为它们多个函数能处理任何的数据类型,只要数据类型支持模板函数中使用的所有操作
完美转发:std::forward
template<typename T>
void print(T & t){
std::cout << "Lvalue ref" << std::endl;
}
template<typename T>
void print(T && t){
std::cout << "Rvalue ref" << std::endl;
}
template<typename T>
void testForward(T && v){
print(v);//v此时已经是个左值了,永远调用左值版本的print
print(std::forward<T>(v)); //本文的重点
print(std::move(v)); //永远调用右值版本的print
std::cout << "======================" << std::endl;
}
int main(int argc, char * argv[])
{
int x = 1;
testForward(x); //实参为左值
testForward(std::move(x)); //实参为右值
}
出现完美转发的原因本质问题在于,左值右值在函数调用时,都转化成了左值,使得函数转调用时无法判断左值和右值。
