🎬 胖咕噜的稞达鸭 :个人主页
🔥 个人专栏 : 《数据结构》《C++初阶高阶》
《Linux系统学习》
《算法日记》
⛺️技术的杠杆,撬动整个世界!
1. 右值引用和左值引用的区别
左值是我们平常使用的函数对象,表达式结束后依旧存在的持久对象;
右值是表达式结束后就会销毁的临时对象。
- 左值引用(T&) 主要用于绑定左值,本质是给变量取别名,常用于函数传参避免拷贝。
- 右值引用(T&&) 用于绑定右值,主要目的是实现移动语义(不复制资源,本质上是转移资源的所有权),提高性能。
在实际使用中,右值引用通常会配合 std::move 使用。
需要注意的是,std::move 本质上只是一个类型转换,它将左值转换为右值引用,使对象可以被移动,但并不会真正移动资源。
补充几点关键规则
- 右值引用不能绑定左值,但可以通过
std::move转换。 - 左值引用不能直接绑定右值,但
const T&可以。 - 右值引用变量本身是左值。
- 右值引用的核心价值是减少拷贝,提高性能。
1.1 手写 string 类,实现构造函数、移动构造和移动赋值
cpp
#include <iostream>
#include <cstring>
using namespace std;
class MyString
{
public:
// 1. 构造函数
MyString(const char* str = "")
{
_size = strlen(str);
_data = new char[_size + 1];
strcpy(_data, str);
cout << "构造函数" << endl;
}
// 2. 析构函数
~MyString()
{
delete[] _data;
_data = nullptr;
_size = 0;
cout << "析构函数" << endl;
}
// 3. 拷贝构造
MyString(const MyString& other)
{
_size = other._size;
_data = new char[_size + 1];
strcpy(_data, other._data);
cout << "拷贝构造" << endl;
}
// 4. 拷贝赋值
MyString& operator=(const MyString& other)
{
cout << "拷贝赋值" << endl;
if (this != &other)
{
delete[] _data;
_size = other._size;
_data = new char[_size + 1];
strcpy(_data, other._data);
}
return *this;
}
// 5. 移动构造
MyString(MyString&& other) noexcept
{
_data = other._data;
_size = other._size;
other._data = nullptr;
other._size = 0;
cout << "移动构造" << endl;
}
// 6. 移动赋值
MyString& operator=(MyString&& other) noexcept
{
cout << "移动赋值" << endl;
if (this != &other)
{
delete[] _data;
_data = other._data;
_size = other._size;
other._data = nullptr;
other._size = 0;
}
return *this;
}
void print() const
{
cout << (_data ? _data : "nullptr") << endl;
}
private:
char* _data;
size_t _size;
};
2. 智能指针有哪些?
C++11 中主要有三种智能指针:
unique_ptr:独占所有权,不允许拷贝
shared_ptr:共享所有权,使用引用计数管理资源
weak_ptr:弱引用,不增加引用计数,主要用于解决循环引用问题
unique_ptr 和 shared_ptr 区别
unique_ptr 是独占所有权,同一时间只能有一个指针指向资源,不能拷贝,只能移动。
shared_ptr 是共享所有权,通过引用计数来管理资源,多个指针可以指向同一对象。
它的引用计数是线程安全的(原子操作),但是对象本身不是线程安全的。
当引用计数为 0 时自动释放资源。
为什么要有智能指针?
在传统的 C++ 编程中,用 new 申请一块内存空间,最后要用 delete 释放这块内存空间。
当我们忘记 delete,或者 delete 没有正常执行的时候,就会造成内存泄漏。
更本质的问题在于,C++ 是手动管理内存的,如果程序在执行过程中发生异常、提前返回,或者逻辑分支复杂,就可能导致 delete 没有被执行。
为了解决这个问题,C++ 引入了 RAII 机制 和 智能指针,通过对象生命周期自动管理资源,从而避免内存泄漏。
3. 什么是 RAII 机制?
RAII 通过"==对象构造时获取资源,对象析构时释放资源"==的机制,将所有需要手动管理的资源(内存、文件、锁等)与对象生命周期绑定,从而从根源上避免资源泄漏,包括异常场景下的泄漏。
智能指针满足了 RAII 的设计思路,还方便资源的访问。智能指针代管资源,模拟指针的行为,访问和修改资源。
RAII只能管理内存吗?
RAII不仅用于内存管理,还广泛应用于各种资源的管理。
- 例如,文件句柄、数据库连接和锁等资源,都可以通过RAII进行管理。
- 文件句柄:可以通过自定义类或智能指针,在对象析构时自动关闭文件。
- 锁:使用
std::lock_guard或std::unique_lock来自动加锁和解锁,避免死锁和忘记释放锁的情况。 - 数据库连接:可以使用RAII来确保数据库连接在操作结束后自动关闭,避免连接泄漏。
4. 智能指针会引发什么问题?
4.1 循环引用问题
shared_ptr 采用引用计数的方式管理对象生命周期。正常情况下,当引用计数减为 0 时,对象会被自动释放。
但是如果两个对象互相持有对方的 shared_ptr,就会形成循环引用 。一旦形成循环引用,这两个对象的引用计数都无法降为 0,最终导致对象不能被释放,从而产生内存泄漏。
4.2 为什么会发生内存泄漏?
原因在于:
shared_ptr会让引用计数加1- 对象之间互相持有
shared_ptr - 即使外部的
shared_ptr已经销毁,对象内部仍然彼此引用 - 所以引用计数始终不为
0 - 析构函数无法被调用,资源也就无法释放
4.3 如何解决循环引用问题?
解决方法就是:
把其中一边的
shared_ptr改成weak_ptr
因为 weak_ptr 是一种弱引用:
- 它可以观察
shared_ptr管理的对象 - 但是不会增加引用计数
- 也不参与对象生命周期管理
这样就可以打破强引用环,避免内存泄漏。
4.4 weak_ptr 的特点
weak_ptr 不能直接通过 -> 或 * 来访问对象。
如果想访问资源,需要先调用 lock():
- 如果对象还存在,
lock()会返回一个有效的shared_ptr - 如果对象已经释放,
lock()返回空对象
所以在使用 weak_ptr 访问资源时,一般要先判断 lock() 的结果是否为空。
5. vector 和 list 的应用场景有什么不同?
5.1 二者的本质区别
vector的底层是动态数组list的底层是双向链表
因此它们在访问方式、插入删除效率以及内存布局上都不一样。
5.2 vector 的特点
vector 适合以下场景:
- 需要高效随机访问
- 经常在尾部插入元素
- 对缓存友好,性能较好
- 内存开销相对较小
因为 vector 底层是连续空间,所以可以通过下标快速访问元素,随机访问效率高。
5.3 list 的特点
list 更适合以下场景:
- 频繁在中间位置插入或删除元素
- 不要求随机访问
- 更关注插入删除的灵活性
由于 list 底层是链表,所以插入删除时不需要大规模搬移元素,但它不能像 vector 一样高效随机访问,同时内存开销也更大。
5.4 实际怎么选?
通常情况下:
大多数场景优先选择
vector
因为它的综合性能更好。
只有在频繁中间插入和删除 时,list 才更合适。
5.5 vector 的扩容机制
vector 的底层是动态数组,它有两个重要概念:
size:当前元素个数capacity:当前容量
当插入元素时:
- 如果
size < capacity,就可以直接插入,不会扩容 - 如果
size == capacity,说明空间已经满了,这时就会触发扩容
5.6 扩容触发条件
扩容的本质触发条件是:
_finish == _end_of_storage
也就是当前已经没有剩余空间可以继续存放新元素。
5.7 vector 扩容时会发生什么?
扩容一般不会原地进行,而是会执行以下步骤:
- 重新申请一块更大的连续内存空间
- 将旧空间中的元素拷贝或移动到新空间
- 销毁旧空间中的对象
- 释放旧空间
- 更新内部指针,指向新的内存区域
5.8 为什么不能原地扩容?
因为 vector 要求底层内存必须是连续空间 。
但操作系统很难保证原有空间后面正好还有一块足够大的连续内存,因此大多数情况下只能重新申请一块新的更大空间。
5.9 常见扩容策略
常见实现中,vector 会按几何方式扩容,例如:
- GCC 通常按 2 倍 扩容
- MSVC 通常按 1.5 倍 扩容
这样虽然单次扩容的时间复杂度是 O(n),但从均摊分析来看,push_back() 的均摊时间复杂度仍然是 O(1)。
5.10 扩容的影响
vector 一旦扩容,会导致:
- 原来的迭代器失效
- 原来的指针失效
- 原来的引用失效
因为底层地址已经发生了变化。
5.11 如何减少扩容带来的性能损失?
如果提前知道大概需要多少空间,可以使用:
reserve(n)
提前预留容量,减少扩容次数,从而提高性能。
6. map 和 unordered_map 的区别
map 和 unordered_map 都是 C++ 中常见的关联容器,都可以存储键值对,但它们的底层实现和使用场景并不相同。
6.1 底层实现不同
map:底层通常基于红黑树unordered_map:底层基于哈希表
这决定了它们在查找效率、元素顺序以及使用场景上的差异。
6.2 时间复杂度不同
map
- 插入:
O(logn) - 删除:
O(logn) - 查找:
O(logn)
unordered_map
- 插入:
O(1)(摊销) - 删除:
O(1)(摊销) - 查找:
O(1)(摊销)
所以从平均查找效率来看,unordered_map 往往更快。
6.3 元素顺序不同
map中的元素会按照 key 有序排列unordered_map中的元素是无序存储
如果你需要按键有序遍历,那么 map 更适合。
如果你不关心顺序,只关心查找速度,那么 unordered_map 更合适。
6.4 内存使用不同
map由于底层是红黑树,每个节点都需要维护树结构信息unordered_map由于底层是哈希表,除了元素本身,还需要桶数组等额外空间
文档里的表述是:
map:由于底层是红黑树,内存使用较少unordered_map:需要额外空间存储哈希表,但在处理大量数据时可能表现更好
6.5 场景选择
适合用 map 的场景
当你需要:
- 按 key 有序访问元素
- 顺序遍历键值对
- 范围查询
这类场景更适合使用 map
适合用 unordered_map 的场景
当你需要:
- 更高的查找效率
- 不关心元素顺序
- 高频键值查找
这类场景更适合使用 unordered_map
7. C++ 多态是怎么实现的?
7.1 什么是多态?
多态的核心含义可以理解为:
同样的接口,调用时表现出不同的行为
在 C++ 中,多态主要依赖虚函数 来实现。
当使用基类指针或者基类引用指向派生类对象时,通过这个统一接口调用函数,就可能表现出不同子类的不同实现。
7.2 多态实现的两个条件
多态成立需要满足两个条件:
- 被调用的函数必须是虚函数
- 必须通过基类的指针或者引用指向派生类对象
只有同时满足这两个条件,调用时才会发生动态绑定,从而执行子类重写后的函数。
7.3 为什么虚函数能实现多态?
如果父类中的成员函数没有加 virtual,那么通过基类指针调用函数时,会发生静态绑定,调用的是基类版本。
如果加了 virtual,并且子类对该函数进行了重写,那么通过基类指针调用时,会发生动态绑定,最终调用的是子类版本。
所以虚函数解决的是这样一个问题:
父类指针 / 父类引用,如何正确调用子类自己的实现
7.4 为什么基类的析构函数建议写成虚函数?
这是面试中特别高频的一个追问。
如果一个类会被当作基类使用,并且你可能会通过基类指针释放派生类对象,那么基类析构函数就应该写成虚函数。
原因是:
- 如果基类析构函数不是虚函数
- 通过基类指针
delete派生类对象时 - 只会调用基类析构函数
- 不会调用派生类析构函数
- 如果派生类中自己申请了资源,比如堆内存、文件句柄、锁等
- 这些资源就无法被正确释放,从而造成资源泄漏
而如果基类析构函数是虚函数,那么通过基类指针释放对象时,会先调用派生类析构,再调用基类析构,这样对象才能被完整销毁。
7.5 本质总结
基类析构函数建议写成虚函数,本质原因就是:
保证通过基类指针释放派生类对象时,析构过程完整执行,避免资源泄漏
代码示例
智能指针循环引用示例
cpp
#include <iostream>
#include <memory>
using namespace std;
struct ListNode
{
int _data;
shared_ptr<ListNode> _next;
shared_ptr<ListNode> _prev;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
// 循环引用 -- 内存泄漏
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
cout << "初始引用计数:" << endl;
cout << "n1.use_count() = " << n1.use_count() << endl; // 1
cout << "n2.use_count() = " << n2.use_count() << endl; // 1
n1->_next = n2;
n2->_prev = n1;
cout << "互相指向后:" << endl;
cout << "n1.use_count() = " << n1.use_count() << endl; // 2
cout << "n2.use_count() = " << n2.use_count() << endl; // 2
return 0;
}
使用weak_ptr解决循环引用的代码:
cpp
#include <iostream>
#include <memory>
using namespace std;
struct ListNode
{
int _data;
shared_ptr<ListNode> _next;
weak_ptr<ListNode> _prev; // 改成 weak_ptr
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
cout << "初始引用计数:" << endl;
cout << "n1.use_count() = " << n1.use_count() << endl; // 1
cout << "n2.use_count() = " << n2.use_count() << endl; // 1
n1->_next = n2;
n2->_prev = n1; // weak_ptr 绑定 shared_ptr,不增加 n1 的计数
cout << "建立关系后:" << endl;
cout << "n1.use_count() = " << n1.use_count() << endl; // 1
cout << "n2.use_count() = " << n2.use_count() << endl; // 2
return 0;
}
weak_ptr访问资源的方式:
cpp
#include <iostream>
#include <memory>
using namespace std;
struct ListNode
{
int _data;
shared_ptr<ListNode> _next;
weak_ptr<ListNode> _prev;
ListNode(int x) : _data(x) {}
};
int main()
{
auto n1 = make_shared<ListNode>(10);
auto n2 = make_shared<ListNode>(20);
n1->_next = n2;
n2->_prev = n1;
// weak_ptr 不能直接用 -> 或 *
// 要先 lock()
if (auto sp = n2->_prev.lock())
{
cout << "n2 的前驱节点数据: " << sp->_data << endl;
}
else
{
cout << "前驱节点已经释放" << endl;
}
return 0;
}