面向对象
多态的底层实现
多态的实现分为静态多态和动态多态:
- 静态多态(编译时多态):通过函数重载(overload)、模板(templates)和运算符重载来实现,它的调用在编译时就已经确定。
- 动态多态(运行时多态):通过继承、函数重写(override)和虚函数,依赖虚函数表(vtable)和虚函数表指针(vptr)实现动态绑定。
那么虚函数的底层是如何实现的?
每个包含虚函数的类都有一个虚函数表,存储类的所有虚函数的指针(存的是虚函数真正的地址)。每个对象都有一个虚函数表指针,指向该对象所属的虚函数表。当调用虚函数时,程序会通过虚函数表指针访问虚函数表找到真正的函数地址,然后调用该函数。
虚析构函数的作用
虚析构函数是⼀个带有 virtual 关键字的析构函数,主要作用是确保在通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,从而释放对象所占用的资源。
即虚析构函数允许在运⾏时根据对象的实际类型调⽤正确的析构函数,从而实现多态性。
如果基类的析构函数不是虚的,当通过基类指针删除指向派生类对象的对象时,不会触发动态绑定,只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致派生类的资源未被正确释放,造成内存泄漏。
PS:内存泄漏指程序未能释放掉不再使用的内存的情况
那为什么没有虚构造函数?
构造函数在对象的创建阶段被调⽤,对象的类型在构造函数中已经确定。因此,构造函数调⽤不涉及多态性,即在对象的构造期间⽆法实现动态绑定。要创建⼀个对象,你需要知道对象的完整信息。 特别是,你需要知道你想要创建的确切类型。 因此,构造函数不应该被定义为虚函数。
RAII
RAII全称是“Resource Acquisition is Initialization”:资源获取即初始化。
- 在构造函数中申请分配资源,在析构函数中释放资源。因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。
- RAII的核心思想是将资源或者状态与对象的生命周期绑定,通过C++的语言机制,实现资源和状态的安全管理,智能指针是RAII最好的例子。
内存
new 和 malloc 的区别
- 类型安全性:
- new 是 C++ 的运算符,可以为对象分配内存并调⽤相应的构造函数
- malloc 是 C 语⾔库函数,只分配指定⼤⼩的内存块,不会调⽤构造函数
- 返回类型:
- new 返回的是具体类型的指针,不需要进⾏类型转换
- malloc 返回的是 void*,需要进⾏类型转换,因为它不知道所分配内存的⽤途
- 内存分配失败时的⾏为:
- new 在内存分配失败时会抛出 std::bad_alloc 异常
- malloc 在内存分配失败时返回 NULL
- 内存块大小:
- new 可以⽤于动态分配数组,并知道数组⼤⼩
- malloc 只是分配指定⼤⼩的内存块,不了解所分配内存块的具体⽤途
- 释放内存的方式:
- delete 会调⽤对象的析构函数,然后释放内存
- free 只是简单地释放内存块,不会调⽤对象的析构函数
对应上方,delete 和 free 有什么区别?
- 类型安全性:
- delete 会调⽤对象的析构函数,确保资源被正确释放
- free 不了解对象的构造和析构,只是简单地释放内存块
- 内存块释放后的行为:
- delete 释放的内存块的指针值会被设置为 nullptr,以避免野指针
- free 不会修改指针的值,可能导致野指针问题
- 数组的释放:
- delete 可以正确释放通过 new[] 分配的数组
- free 不了解数组的⼤⼩,不适⽤于释放通过 malloc 分配的数组
相较于 malloc,new 除了分配内存,还有其他额外的操作吗?
如果分配的是一个对象,new
不仅分配内存,还会调用对象的构造函数来初始化该对象
Q:那么 new 实际上是做了两件事:⼀个是分配内存,⼀个是调⽤实例的构造函数,那有了解过 new 可以只进⾏一个操作吗?⽐如只分配内存不调⽤构造函数,或者只调⽤构造函数不分配内存?
A:new 其实可以拆分成两个独立的操作:分配内存和调用构造函数
- 实现只分配内存:我们可以直接调用
operator new
,它只负责分配内存,而不调用构造函数,这样我们得到了一块原始内存,但对象未初始化:void* memory = operator new(sizeof(Test));
- 实现只调用构造函数:如果已经有一块合适的内存,我们可以使用
placement new
(定位 new),它会在指定的内存地址上调用构造函数:char buffer[sizeof(Test)]; Test* obj = new (buffer) Test();
STL
vector 容器的扩容机制
当 vector 容器的 capacity 和 size 相等时,vector 就会扩容,有两种扩容策略:
- 固定扩容(线性扩容):每次扩容的时候在原 capacity 的基础上加上固定的容量,⽐如初始 capacity 为100,扩容⼀次为 capacity + 20,再扩容仍然为 capacity + 20
- 优点:由于增长幅度固定,不会有加倍扩容带来的大块空闲空间,即固定扩容⽅式空间利用率比较高
- 缺点:扩容频率更高,每次扩容时都要执行数据拷贝,频繁进行内存分配,带来额外的性能损耗
- 加倍扩容(指数扩容):每次扩容的时候原 capacity 翻一倍,比如初始 capcity = 100,扩容⼀次变为 200,再扩容变为 400
- 优点:使得正常情况下添加元素需要扩容的次数⼤⼤减少(预留空间较多),时间复杂度更低
- 缺点:因为每次扩容空间都翻倍,可能导致很多空间没有得到利⽤,空间利⽤率不如固定扩容(但是在实际应⽤中,⼀般采⽤空间换时间的策略)
刚才提到了2倍扩容,那为什么是2倍,3倍可以吗?
虽然可以选择3倍或者1.5倍,但大多数 STL 实现都选择2倍,主要原因有以下几点:
- 避免频繁的内存重新分配
- 如果扩容太小,例如1.5倍:当
vector
频繁增长时,需要多次重新分配内存并拷贝元素,增加性能开销 - 如果扩容太大,例如3倍:可能会导致内存浪费,特别是在大规模数据应用场景下
- 所以,2倍扩容是一个时间复杂度和空间利用率的平衡点
- 如果扩容太小,例如1.5倍:当
- 符合现代 CPU 缓存优化
- 现代 CPU 采用 缓存行优化,通常2倍增长更符合 CPU 预取和内存对齐机制,能够减少缓存失效,提高访问效率
- 兼容
malloc
/new
的内存分配策略malloc
和operator new
在底层管理内存时,通常会采用2的幂次分配策略(例如8B → 16B → 32B → 64B ...
),2倍扩容更符合malloc
的工作方式,减少碎片化问题
vector 的初始容量是多少?什么时候会进行初次扩容?
初始容量取决于 STL 的具体实现,一般来说初始容量为0
只有当插入第一个元素时,才会进行首次扩容
为什么只有当插入第一个元素时,才会进行首次扩容?难道不是当 size == capacity 时就要扩容吗?初始vector大小和容量都为0,为什么不直接扩容?
因为 C++ STL 采用懒加载策略,只有在需要时才分配内存。因此一开始 vector 连内存都没有,当然就无法扩容了。只有当第一个元素插入时,vector 才被分配内存,这时进行扩容。
初次扩容后,容量是多少?
初次扩容后,容量一般是1,后续按2倍递增
双向链表list的front()和begin()区别
front()
直接返回第一个元素的引用,可以直接访问或修改。更加简洁,不需要解引用。适用于仅访问或修改第一个元素的情况,而不涉及遍历。
begin()
返回的是指向第一个元素的迭代器,需要使用 *
进行解引用才能访问值。迭代器可以用于遍历整个链表,适用于需要修改或遍历元素的情况。
如果需要修改第一个元素:myList.front() = 100;
等价于 *myList.begin() = 100;
新特性
lock_guard/unique_lock/shared_lock
lock_guard 和 unique_lock 都是 C++11标准库提供的互斥锁 RAII 封装工具,用于实现互斥访问。
创建这两个对象时,它将尝试获取提供给它的互斥锁的所有权,而当控制流离开对象的作⽤域时,
会执行析构并释放互斥量。
但它们有一些不同之处:
- lock_guard 是基于互斥锁 mutex 实现的,unique_lock 实现的是一个更通用的锁,可以用于不同类型的锁(如 mutex、recursive_mutex、timed_mutex 等)
- 因此,unique_lock 提供了更多的控制锁的行为,比如锁超时、不锁定、条件变量等
- unique_lock 比 lock_guard 更重,因为它有更多的功能,更多的开销
- lock_guard 是不可移动的,即不能拷贝、赋值、移动,只能通过构造函数初始化和析构函数销毁,unique_lock 是可移动的,可以拷贝、赋值、移动
- unique_lock 支持手动解锁,而 lock_guard 不支持手动解锁,只能等作用域结束由析构函数自动解锁
shared_lock 用于 shared_mutex,是C++14引入的新特性,允许多个线程共享读取,但只有一个线程可以独占写入。
特点:
- 适用于读多写少的场景
- 允许多个线程同时获取共享锁(
shared_lock
) - 但写锁(
unique_lock
)会阻塞所有的读锁,确保写入安全
其中,读写锁 shared_mutex 也叫共享-独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。只有一个线程可以占有写模式的读写锁,但是可以有多个线程占有读模式的读写锁。
智能指针
智能指针是为了解决动态分配内存导致内存泄露和多次释放同⼀内存所提出的,C++ 的智能指针主要有三类:unique_ptr
、shared_ptr
和 weak_ptr
- unique_ptr:
unique_ptr
是 独占所有权 的智能指针,表示指针所管理的对象只能由一个unique_ptr
拥有。- 封装了指向内存的原生指针,在对象离开作用域时自动调用析构函数释放资源。
- 不能被复制,即没有拷贝构造和拷贝赋值运算符,支持移动语义,可以通过
std::move
转移所有权。
- shared_ptr:
shared_ptr
是共享所有权的智能指针,多个shared_ptr
可以共同管理同一块资源。- 各个共享指针共享一个控制块,其中存储了:原生指针、引用计数、弱引用计数(有多少个
weak_ptr
观察这块资源)和自定义删除器(如果提供了的话)。 - 通过引用计数来管理对象,拷贝共享指针时,引用计数加1,被销毁时引用计数减一。
- 只有当所有
shared_ptr
都被销毁,对象才会被释放。
- 各个共享指针共享一个控制块,其中存储了:原生指针、引用计数、弱引用计数(有多少个
- weak_ptr:
weak_ptr
是弱引用指针,不影响shared_ptr
的引用计数。- 维护一个指向
shared_ptr
控制块的指针,不拥有资源,不增加引用计数,仅仅是观察shared_ptr
所管理的对象是否仍然存在。 - 避免
shared_ptr
循环引用问题(即两个shared_ptr
相互指向,导致引用计数无法降为0
,资源永远不会释放)。 - 需要
lock()
转换为shared_ptr
,才能访问资源。 - 如果
shared_ptr
释放了对象,weak_ptr
变为expired()
状态,不能访问已释放的对象。
- 维护一个指向
基础
静态局部变量\全局变量\局部变量
- 静态局部变量
- 作⽤域:限定在定义它的函数内
- ⽣命周期:与程序的⽣命周期相同,但只能在定义它的函数内部访问
- 关键字:使⽤ static 关键字修饰
- 初始化:仅在第⼀次调⽤函数时初始化,之后保持其值
- 适用场景:当希望在函数调⽤之间保留变量的值,并且不希望其他函数访问这个变量时,可以使⽤静态局部变量
- 全局变量
- 作⽤域:整个程序
- ⽣命周期:与程序的⽣命周期相同
- 关键字:定义在全局作⽤域,不使⽤特定关键字
- 适用场景:当多个函数需要共享相同的数据时,可以使⽤全局变量
- 局部变量
- 作⽤域:限定在定义它的块(⼤括号内)
- ⽣命周期:在块结束时销毁
- 关键字:定义在函数、语句块或类的成员函数中
- 适用场景:当变量只在某个特定作⽤域内有效,并且不需要其他作⽤域访问时,可以使⽤局部变量
. 和 ->的区别
如果是对象,使用 .
访问成员;如果是指针(包括迭代器),使用 ->
访问成员。
->
相当于先将指针解引用(*ptr
)获取到指向的对象,再使用 .
运算符访问对象的成员。
list<pair<int, int>> m_list; // 双向链表存k、v
unordered_map<int, list<pair<int, int>>::iterator> mp; // map存k到list迭代器的映射
那么对于 m_list.back().first
和 mp[key]->second
:
m_list.back().first
m_list
是一个list<pair<int, int>>
,即存储键值对的双向链表。m_list.back()
返回的是pair<int, int>
类型的对象,即std::pair<int, int>
。first
是std::pair<int, int>
的成员变量,直接用.
访问
mp[key]->second
mp
是unordered_map<int, list<pair<int, int>>::iterator>
,它存储的是键值对的映射,其中:key
是int
,value
是指向m_list
某个节点的迭代器。- 由于
mp[key]
是一个迭代器(指针类型),它指向的是pair<int, int>
,因此使用->
来访问pair
的成员 mp[key]->second
等价于(*mp[key]).second
ps:对象调用成员函数也是用 . 比如list.front(),因为前面是对象
指针和引用的区别
指针是一个变量,存储的是另一个变量的地址;引用是已有变量的别名,本质上是一个自动解引用的指针。
- 使⽤和操作:
- 指针:可以通过解引⽤操作符来访问指针指向的变量的值。
- 引⽤:引⽤在声明时被初始化,并在整个⽣命周期中⼀直引⽤同⼀个变量。不需要使⽤解引⽤操作符,因为引⽤本身就是变量的别名。
- 空值和空引用:
- 指针可以为空(nullptr),表示不指向任何有效的地址。
- 引⽤必须在声明时初始化,并且不能在后续改变绑定的对象。因此,没有空引⽤的概念。
- 可变性:
- 指针:可以改变指针的指向,使其指向不同的内存地址。
- 引⽤:⼀旦引⽤被初始化,它将⼀直引⽤同⼀个对象,不能改变绑定。
- ⽤途:
- 指针:通常⽤于动态内存分配、数组操作以及函数参数传递。
- 引⽤:通常⽤于函数参数传递、操作符重载以及创建别名。
常量指针和指针常量的区别
常量指针是指指针指向的对象是⼀个常量,即指针指向的对象的值不能被修改,但指针的值(地址)可以被修改。(记忆:常量指针,首先要得是常量,就是指向的值要是一个常量)
int x = 10;
int y = 20;
const int* ptr = &x; // 常量指针的const在*前面
*ptr = 30; // ❌ 错误:不能修改指向的对象的值(x的值)
ptr = &y; // ✅ 正确:可以修改指针的指向(指向y)
指针常量是指指针本身是⼀个常量,即指针本身的值(地址)不能被修改,但指针所指向的对象的值可以被修改(记忆:指针常量,即指针是常量)
int x = 10;
int y = 20;
int* const ptr = &x; // 指针常量的const在*后面
*ptr = 30; // ✅ 正确:可以修改x的值
ptr = &y; // ❌ 错误:不能修改指针的指向