1. Shared_ptr 的底层原理
std::shared_ptr
是 C++ 标准库中的一个智能指针,用于自动管理对象的生命周期。它通过引用计数来跟踪有多少个 shared_ptr
指向同一个对象。当最后一个 shared_ptr
被销毁或重置时,对象也会被删除。
内部结构
std::shared_ptr
的底层实现通常包括以下几个关键组件:
- 控制块(Control Block):
- 控制块通常是一个独立的对象,它负责管理对象的引用计数和弱引用计数。
- 引用计数(
use_count
)记录了多少个shared_ptr
指向同一个对象。 - 弱引用计数(
weak_count
)记录了多少个std::weak_ptr
持有对该对象的引用。
- 存储机制:
std::shared_ptr
包含一个指向控制块的指针。- 控制块包含一个指向实际对象的指针。
- 控制块还包含一个指向删除器函数的指针(可选)。
在典型的实现中,shared_ptr 只保有两个指针:
- get()所返回的指针;(基础对象的内存地址)
- 指向控制块的指针。(控制块对象的内存地址)
控制块是一个动态分配的对象,其中包含:
- 指向被管理对象的指针或被管理对象本身;(基础对象的内存地址)
- 删除器;(Deleter,类型擦除)
- 分配器;(Allocator,类型擦除)
- 占用被管理对象的shared_ptr的数量(strong refs强引用的引用计数);
- 涉及被管理对象的weak_ptr的数量(weak refs弱引用的引用计数) 。
创建和销毁
创建 std::shared_ptr
当你创建一个新的 std::shared_ptr
时,通常会做以下几件事:
- 创建控制块:
- 分配内存来存储控制块。
- 初始化引用计数和弱引用计数(通常是0)。
- 设置指针:
- 设置
std::shared_ptr
内部的指针,使其指向控制块。 - 设置控制块内的指针,使其指向实际对象。
- 设置
- 增加引用计数:
- 创建新的
std::shared_ptr
时,引用计数加1。
- 创建新的
销毁 std::shared_ptr
当 std::shared_ptr
被销毁或重置时,会发生以下几件事:
- 减少引用计数:
- 销毁或重置
std::shared_ptr
时,引用计数减1。
- 销毁或重置
- 检查引用计数:
- 如果引用计数变为0,表示没有
std::shared_ptr
指向该对象。 - 此时,执行删除操作:
- 调用控制块中存储的删除器(如果有)。
- 释放实际对象占用的内存。
- 最后,释放控制块占用的内存。
- 如果引用计数变为0,表示没有
下面是一个简单的示例代码,展示了 std::shared_ptr
的基本用法:
1 |
|
内存管理
std::shared_ptr
通过引用计数来管理内存,使得多个 std::shared_ptr
可以共享对同一个对象的拥有权。当最后一个 std::shared_ptr
被销毁时,对象也会被自动删除,从而避免了内存泄漏。
性能考虑
虽然 std::shared_ptr
提供了方便的内存管理,但由于涉及额外的控制块和引用计数的操作,它可能会带来一定的性能开销。特别是当引用计数频繁变化时,这些操作可能会变得较为昂贵。
总的来说,std::shared_ptr
是一个强大的工具,用于自动管理对象的生命周期,但使用时需要注意其内部机制以避免潜在的问题。
2. Shared_ptr 的循环引用
共享指针(std::shared_ptr
)的一个常见问题是循环引用(circular reference),这会导致内存泄漏。当两个或多个 std::shared_ptr
彼此持有对方时,它们的引用计数永远不会降为零,从而导致它们所指向的对象永远不会被删除。
循环引用示例
假设我们有两个类 A
和 B
,它们互相持有对方的 std::shared_ptr
:
1 |
|
在这个例子中,A
持有一个指向 B
的 std::shared_ptr
,而 B
也持有一个指向 A
的 std::shared_ptr
。因此,这两个对象的引用计数始终大于零,导致它们永远不会被删除。
解决循环引用的方法
解决循环引用问题通常有几种方法:
- 使用
std::weak_ptr
:std::weak_ptr
不增加引用计数,可以用来打破循环引用。- 当
std::weak_ptr
的lock()
方法返回nullptr
时,表明没有std::shared_ptr
持有该对象。
- 重构代码逻辑:
- 重新设计类之间的依赖关系,避免相互持有。
使用 std::weak_ptr
打破循环引用
我们可以修改上面的例子,使用 std::weak_ptr
来持有对方的指针:
1 |
|
在这个修改后的例子中,A
和 B
都持有对方的 std::weak_ptr
。当需要获取对方的 std::shared_ptr
时,可以通过 lock()
方法从 std::weak_ptr
转换为 std::shared_ptr
。如果没有任何 std::shared_ptr
持有该对象,lock()
方法将返回 nullptr
。
注意事项
使用 std::weak_ptr
时需要注意:
- 在使用
lock()
获取std::shared_ptr
时,要确保对象仍然存在。 - 如果
lock()
返回nullptr
,说明对象已经不存在,此时不应该继续使用该对象。
通过使用 std::weak_ptr
,我们可以有效地避免由循环引用导致的内存泄漏问题。
3. 移动语义
移动语义能够转移资源的所有权,而非拷贝资源,能够更高效地管理资源。移动语义接受一个 右值
引用作为参数,通常调用移动构造函数或者移动赋值函数。而通过 move
能够把一个 左值
转换为 右值
,触发移动语义,通过 forward
能够保持原有的引用类型。
右值
可以种发移动语义,那什么是移动语义?我们可以理解为在对象转换的时候,通过右值可以触发到类的移动构造函数或者移动赋值函数。
因为触发了 移动构造函数
或者 移动赋值函数
,我们就默认,原对象后面已经不会再使用了(包括内部的某些内存),这样我们就可以在新对象中直接使用原对象的那部分内存,减少了数据的拷贝操作,昂贵的拷贝转为了廉价的移动,提升了程序的性能。
左值和右值
一个变量可以分为左值和右值:
- 是否能放在等号左边
- 是否能够取地址
move
std::move
函数的作用是将一个左值转换为一个可以像右值一样被处理的对象。它通常用于明确表示希望将一个左值当作右值来对待,从而可以利用移动语义(move semantics),允许编译器使用移动构造函数或移动赋值运算符。std::move
的源码如下:
1 | /** |
使用std::move
是为了能够触发移动语义,调用移动构造函数,从而避免资源的拷贝:
1 |
|
forward
std::forward
用于转发模板参数,它在模板函数中用于保持模板参数的引用类型不变。源码如下:
1 | /** |
std::forward
主要有两个作用:
- 保持引用类型:
std::forward
可以保持模板参数的引用类型不变,无论是左值引用还是右值引用。 - 完美转发:在模板函数中,
std::forward
可以实现“完美转发”,即保持原始参数的引用类型,从而允许模板函数透明地处理不同类型和引用类型的参数。
1 |
|
区别总结
- 目的不同:
std::move
的主要目的是将左值转换为右值引用,以便使用移动语义。std::forward
的主要目的是保持模板参数的引用类型,实现完美转发。
- 用法不同:
std::move
通常用于显式地将左值转换为右值引用。std::forward
通常用于模板函数中,保持模板参数的引用类型。
- 模板参数:
std::move
可以直接应用于具体的表达式。std::forward
需要模板参数来确定原始类型的引用类型。
通用引用
当 T
是一个 模板参数
时,T&&
表示一个“通用引用”(universal reference)。这种引用类型可以根据传入的实际类型的不同,既可以绑定到左值(lvalue),也可以绑定到右值(rvalue)。T&&
允许我们在模板函数中灵活地处理不同的输入类型:
- 左值引用:当
T&&
绑定到一个左值时,它实际上退化成T&
(左值引用)。 - 右值引用:当
T&&
绑定到一个右值时,它保留其作为右值引用的本质。
注意通用引用和右值引用的区别:
- 右值引用:
std::string&&
表示一个绑定到右值的引用。右值通常指的是临时对象或表达式的结果,这些对象通常没有名字,因此不能绑定到左值引用(lvalue reference)。
- 万能引用(Universal Reference):
- 在 C++ 中,当一个引用类型前面加上
&&
时,它被称为“万能引用”。万能引用实际上是模板推导中的一个概念,用于在模板函数中实现完美转发。 - 在非模板上下文中,
std::string&&
仍然是一个右值引用,只能绑定到右值。
- 在 C++ 中,当一个引用类型前面加上
让我们通过几个示例来展示 std::string&&
的行为:
绑定到临时对象
1 | void foo(std::string&& str) { |
在这个例子中,foo("Hello, World!")
调用时,str
绑定到了一个临时字符串对象 "Hello, World!"
。
绑定到命名的右值
1 | void foo(std::string&& str) { |
在这个例子中,foo(rvalue)
调用时,str
绑定到了通过 std::move
转换得到的右值引用 rvalue
。
绑定到左值(错误)
1 | void foo(std::string&& str) { |
在这个例子中,尝试将命名的左值 temp
作为参数传递给 foo
函数会导致编译错误,因为 std::string&&
只能绑定到右值。
总结
- 右值引用:
std::string&&
表示一个绑定到右值的引用,主要用于支持移动语义和绑定临时对象。 - 万能引用:在模板上下文中,
T&&
通常表示万能引用,可以绑定到左值或右值,并通过模板推导实现完美转发。
如果你想要一个函数既能接受左值也能接受右值,可以使用模板和万能引用:
1 | template<typename T> |
在这个例子中,bar
函数通过模板和万能引用 T&&
实现了既可以接受左值也可以接受右值的能力。
4. RAII 技术
RAII(Resource Acquisition Is Initialization)是一种编程模式,它通过在对象的生命周期内自动管理资源来确保资源的安全释放。RAII 的核心思想是将资源的获取与对象的初始化绑定在一起,同时把资源的释放与对象的析构关联起来。简单来说,就是在构造函数中获取资源(比如动态分配内存、打开文件、获取数据库连接等),当对象生命周期结束,在析构函数中自动释放这些资源,从而确保资源的正确管理,避免资源泄漏等问题。在多线程编程中,RAII 通常用于管理互斥锁(mutexes)等同步原语,以确保在适当的时机锁定和解锁资源。
RAII Lock 的概念
RAII Lock 是一种基于 RAII 模式的锁定机制,它通过构造和析构来自动管理锁的状态。具体来说:
- 构造时锁定:在 RAII Lock 对象构造时自动获取锁。
- 析构时解锁:在 RAII Lock 对象析构时自动释放锁。
示例:使用 std::lock_guard
std::lock_guard
是 C++ 标准库中的一个类模板,用于自动锁定和解锁互斥锁。它是一个典型的 RAII Lock 的实现。
1 |
|
在这个例子中,std::lock_guard
在构造时自动锁定互斥锁 mtx
,并在析构时自动释放锁。这样可以确保在函数执行期间互斥锁始终处于锁定状态,从而防止数据竞争。
示例:使用 std::unique_lock
std::unique_lock
是另一个常用的 RAII Lock 类模板,它提供了比 std::lock_guard
更多的灵活性,例如可以显式地锁定和解锁互斥锁,并支持超时等特性。
1 |
|
在这个例子中,std::unique_lock
提供了更多的灵活性,可以在适当的时候显式地锁定和解锁互斥锁。
RAII Lock 的优点
- 自动管理锁:RAII Lock 在构造和析构时自动管理锁的状态,减少了手动锁定和解锁的出错机会。
- 异常安全:即使在函数抛出异常的情况下,RAII Lock 也能保证锁被正确释放。
- 易于理解和维护:使用 RAII Lock 的代码更加清晰和简洁,易于理解和维护。
RAII Lock 的注意事项
尽管 RAII Lock 提供了许多便利,但在使用时也需要注意以下几点:
- 锁的粒度:尽量减小锁的范围,只在必要的时候锁定资源,以减少锁的竞争。
- 死锁问题:确保不会因为锁定多个互斥锁而导致死锁。可以使用
std::lock
或std::lock_scope
来同时锁定多个互斥锁,并保证锁定顺序的一致性。 - 线程安全性:确保在多线程环境下正确使用互斥锁,避免数据竞争和竞态条件。
通过使用 RAII Lock(如 std::lock_guard
和 std::unique_lock
),可以有效地管理多线程环境中的互斥锁,提高代码的健壮性和可维护性。
5. 互斥锁如何访问共享资源
互斥锁(std::mutex
)是用于确保多线程环境中共享资源安全访问的一种重要同步机制。互斥锁的基本思想是在访问共享资源之前先获取锁,确保在同一时刻只有一个线程可以访问共享资源,从而避免数据竞争和竞态条件。
互斥锁的工作原理
互斥锁通过以下步骤实现共享资源的安全访问:
- 锁定互斥锁:
- 在访问共享资源之前,线程必须首先获取互斥锁。
- 如果互斥锁已经被另一个线程持有,则当前线程将被阻塞,直到锁被释放为止。
- 访问共享资源:
- 一旦线程获得了互斥锁,就可以安全地访问共享资源。
- 在这个过程中,其他试图获取同一互斥锁的线程将会被阻塞,直到当前线程释放锁。
- 解锁互斥锁:
- 当线程完成了对共享资源的访问后,必须释放互斥锁。
- 释放锁后,其他被阻塞的线程可以继续尝试获取锁。
示例代码
下面是一个使用 std::mutex
和 std::unique_lock
保护共享资源的示例代码:
1 |
|
6. std::list 的访问
在 C++ 中,std::list
提供了双向链表的功能,它使用双向迭代器来访问元素。与随机访问迭代器不同,双向迭代器不支持索引访问,也不支持迭代器之间的算术运算,但支持前后移动迭代器。
如何在 std::list
中指向下一个元素
要在 std::list
中指向下一个元素,你可以使用 ++
操作符来递增迭代器。同样,你可以使用 --
操作符来递减迭代器。
示例代码
下面是一个示例代码,展示了如何使用迭代器来访问 std::list
中的元素,并指向下一个元素:
1 |
|
解释
- 创建容器:
- 创建了一个包含 10 个元素的
std::list<int>
容器lst
。
- 创建了一个包含 10 个元素的
- 使用迭代器访问元素:
- 使用
lst.begin()
获取容器的开始迭代器it
。 - 使用
lst.end()
获取容器的结束迭代器end
。
- 使用
- 输出前五个元素:
- 使用迭代器和解引用操作符
*it
输出前五个元素。 - 使用
++it
操作符将迭代器指向下一个元素。
- 使用迭代器和解引用操作符
- 指向最后一个元素:
- 如果迭代器未到达末尾,输出最后一个元素。
- 重新设置迭代器到开始位置:
- 将迭代器
it
重新设置为容器的开始位置。
- 将迭代器
- 输出当前元素并打印下一个元素:
- 使用循环遍历列表,并输出当前元素。
- 使用
++it
操作符将迭代器指向下一个元素。 - 如果迭代器未到达末尾,输出下一个元素,并再次将迭代器指向下一个元素。
总结
在 std::list
中,你可以使用双向迭代器来前后移动迭代器。要指向下一个元素,可以使用 ++
操作符递增迭代器。同样,要指向上一个元素,可以使用 --
操作符递减迭代器。这种方式适用于任何双向链表结构,不仅限于 std::list
。
这种方法相比随机访问迭代器(如 std::vector
中使用的迭代器)稍微复杂一些,因为不支持索引访问和迭代器之间的算术运算,但在处理大量数据时,双向链表提供了更好的插入和删除性能。
7. 迭代器的类别
迭代器类别及其支持的操作
- 输入迭代器(Input Iterator):
- 支持解引用 (
*
) 和递增 (++
) 操作。 - 不支持索引访问和算术运算。
- 支持解引用 (
- 输出迭代器(Output Iterator):
- 支持赋值 (
*
) 和递增 (++
) 操作。 - 不支持索引访问和算术运算。
- 支持赋值 (
- 前向迭代器(Forward Iterator):
- 支持解引用 (
*
) 和递增 (++
) 操作。 - 不支持索引访问和算术运算。
- 支持解引用 (
- 双向迭代器(Bidirectional Iterator):
- 支持解引用 (
*
)、递增 (++
)、递减 (--
) 操作。 - 不支持索引访问和算术运算。
- 支持解引用 (
- 随机访问迭代器(Random Access Iterator):
- 支持解引用 (
*
)、递增 (++
)、递减 (--
) 操作。 - 支持索引访问(如
container[index]
)。 - 支持迭代器间的算术运算(如
iter + n
和iter - n
)。
- 支持解引用 (
8. lock_guard 和 unique_lock 的区别
两者都是 RAII 形式的 锁管理类
,用于管理互斥锁(mutex)。不过它们有一些关键区别:
lock_guard 是一个简单且轻量级的锁管理类。在构造时锁定给定的互斥体,并在销毁时自动解锁。它不可以显式解锁,也不支持锁的转移。
unique_lock 提供了更多的灵活性。它允许显式的锁定和解锁操作,还支持锁的所有权转移。unique_lock 可以在构造时选择不锁定互斥体,并在稍后需要时手动锁定。
9. 字节序的判断
1 |
|
10. 同步/异步和阻塞/非阻塞
阻塞和非阻塞
从简单的开始,我们以经典的读取文件的模型举例。(对操作系统而言,所有的输入输出设备都被抽象成文件。)
在发起读取文件的请求时,应用层会调用系统内核的I/O接口。
如果应用层调用的是阻塞型I/O,那么在调用之后,应用层即刻被挂起,一直出于等待数据返回的状态,直到系统内核从磁盘读取完数据并返回给应用层,应用层才用获得的数据进行接下来的其他操作。
如果应用层调用的是非阻塞I/O,那么调用后,系统内核会立即返回(虽然还没有文件内容的数据),应用层并不会被挂起,它可以做其他任意它想做的操作。(至于文件内容数据如何返回给应用层,这已经超出了阻塞和非阻塞的辨别范畴。)
这便是(脱离同步和异步来说之后)阻塞和非阻塞的区别。总结来说,是否是阻塞还是非阻塞,关注的是接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的,可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。
![img](/2024/10/05/%E8%AE%A1%E7%AE%97%E6%9C%BA/Cpp%E5%85%AB%E8%82%A1%E7%AC%94%E8%AE%B0/v2-6507ab3517814b1b84fbff9a3eb31842_1440w.webp)
同步和异步
阻塞和非阻塞解决了应用层等待数据返回时的状态问题,那系统内核获取到的数据到底如何返回给应用层呢?这里不同类型的操作便体现的是同步和异步的区别。
对于同步型的调用,应用层需要自己去向系统内核问询,如果数据还未读取完毕,那此时读取文件的任务还未完成,应用层根据其阻塞和非阻塞的划分,或挂起或去做其他事情(所以同步和异步并不决定其等待数据返回时的状态);如果数据已经读取完毕,那此时系统内核将数据返回给应用层,应用层即可以用取得的数据做其他相关的事情。
而对于异步型的调用,应用层无需主动向系统内核问询,在系统内核读取完文件数据之后,会主动通知应用层数据已经读取完毕,此时应用层即可以接收系统内核返回过来的数据,再做其他事情。
这便是(脱离阻塞和非阻塞来说之后)同步和异步的区别。也就是说,是否是同步还是异步,关注的是任务完成时消息通知的方式。由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式是异步调用。
![img](/2024/10/05/%E8%AE%A1%E7%AE%97%E6%9C%BA/Cpp%E5%85%AB%E8%82%A1%E7%AC%94%E8%AE%B0/v2-6507ab3517814b1b84fbff9a3eb31842_1440w.webp)
示例
老张爱喝茶,废话不说,煮开水。 出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。 1 老张把水壶放到火上,立等水开。(同步阻塞) 老张觉得自己有点傻 2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。 3 老张把响水壶放到火上,立等水开。(异步阻塞) 老张觉得这样傻等意义不大 4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞) 老张觉得自己聪明了。
所谓同步异步,只是对于水壶而言。 普通水壶,同步;响水壶,异步。 虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。 同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。
所谓阻塞非阻塞,仅仅对于老张而言。 立等的老张,阻塞;看电视的老张,非阻塞。 情况1和情况3中老张就是阻塞的,媳妇喊他都不知道。虽然3中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。
阻塞时程序的行为
- I/O 操作:
- 当程序试图读写磁盘文件、网络连接等 I/O 资源时,如果 I/O 操作尚未完成,程序会进入阻塞状态。
- 在阻塞期间,当前线程将不会执行任何代码,CPU 时间片将被分配给其他就绪状态的线程或进程。
- 同步原语:
- 当程序试图获取互斥锁(Mutex)、信号量(Semaphore)、条件变量(Condition Variable)等同步对象时,如果这些对象当前不可用,程序会进入阻塞状态。
- 在阻塞期间,当前线程将不会执行任何代码,CPU 时间片将被分配给其他就绪状态的线程或进程。
- 等待特定条件:
- 当程序等待某个条件满足(例如,等待另一个线程的通知)时,程序会进入阻塞状态。
- 在阻塞期间,当前线程将不会执行任何代码,CPU 时间片将被分配给其他就绪状态的线程或进程。
11. 常用容器及操作
向量 (std::vector)
- 增加:
push_back()
,insert()
- 删除:
erase()
,clear()
- 查找:
find()
(需要算法),at()
,operator[]
- 修改:
at()
,operator[]
,front()
,back()
,assign()
列表 (std::list)
- 增加:
push_front()
,push_back()
,insert()
- 删除:
remove()
,erase()
,pop_front()
,pop_back()
,clear()
- 查找:
find()
- 修改:
splice()
,reverse()
,sort()
,unique()
队列 (std::queue, std::priority_queue)
- 增加:
push()
- 删除:
pop()
- 查找:通常不适用,因为队列是先进先出或优先级排序的
- 修改:不直接支持修改元素
双端队列 (std::deque)
- 增加:
push_front()
,push_back()
,insert()
- 删除:
pop_front()
,pop_back()
,erase()
,clear()
- 查找:
find()
(需要算法),at()
,operator[]
- 修改:
at()
,operator[]
,front()
,back()
映射 (std::map, std::unordered_map)
- 增加:
insert()
,emplace()
- 删除:
erase()
,clear()
- 查找:
find()
,count()
- 修改:
emplace()
,operator[]
集合 (std::set, std::unordered_set)
- 增加:
insert()
,emplace()
- 删除:
erase()
,clear()
- 查找:
find()
,count()
- 修改:不直接支持修改元素
12. Defer的作用
C++中可以通过定义一个类来实现defer效果,这个类在析构的时候调用析构函数,而把要实现的功能放在析构函数中就能够近似defer的效果,当这个对象在作用域结束后就会终结自己的生命周期,从而调用析构函数。
13. C++中list和vector的区别
底层数据结构
vector
:是一个动态数组,在内存中以连续的块存储元素,类似于普通数组,但可以根据需要自动扩展或收缩大小。list
:是一个双向链表,每个节点包含元素值以及指向前一个和后一个节点的指针,元素在内存中不一定是连续的。
访问元素方式
vector
:支持随机访问,可以使用索引直接访问元素,时间复杂度为 O (1)。这是因为其元素在内存中连续存储,通过指针运算就能快速定位到指定元素。list
:不支持随机访问,要访问其中的元素,需要遍历链表,从链表的头节点或其他已知位置开始,逐个节点访问,时间复杂度为 O (n)。
插入和删除操作
vector
:在末尾插入和删除元素通常效率较高,时间复杂度为 O (1)。但在中间或开头插入和删除元素时,可能需要移动大量其他元素,时间复杂度为 O (n)。list
:在任何位置插入和删除元素都比较高效,只需修改指向前后元素的指针,时间复杂度为 O (1)。
内存分配与空间占用
vector
:通常会预分配一定的内存空间,当元素数量增加超过当前容量时,可能需要重新分配更大的连续内存块,并将原有元素复制到新的内存空间中,可能会导致一定的内存浪费。list
:每个元素单独分配内存,不会预分配额外空间,除了存储元素本身外,还需要额外的空间来存储指针,因此单个元素占用的内存空间相对较大,但不会出现像vector
那样因扩容导致的大量未使用空间。
迭代器特性
vector
:支持随机访问迭代器,可以像指针一样进行算术运算,如++
、--
、+
、-
等,能够快速定位到任意位置的元素。但在插入或删除元素时,可能会导致迭代器失效。list
:支持双向迭代器,只能逐个元素地向前或向后移动,不支持随机访问。在插入或删除元素时,迭代器的稳定性较高,只有指向被插入或删除元素的迭代器会受影响,其他迭代器仍然有效。
排序操作
vector
:可以使用<algorithm>
头文件中的sort()
函数进行快速排序,默认使用快速排序算法,时间复杂度为 O (n log n)。list
:本身没有内置的排序函数,需要手动实现排序算法或使用自定义的比较函数进行排序。
适用场景
vector
:适合需要频繁随机访问元素,且元素数量变化不大,或者对插入和删除操作主要集中在末尾的情况,如数值计算、数组替代、数据缓冲区等。list
:适合需要频繁在任意位置插入和删除元素,而对随机访问性能要求不高的场景,如实现队列、栈、复杂的数据结构调整等。
14. CRTP技术
CRTP 基于 C++ 的模板特性,是一种静态多态的实现方式。它的核心在于让派生类将自身作为模板参数传递给基类,从而在基类中可以使用派生类的类型信息,实现一些依赖于具体派生类特性的操作,同时又不需要虚函数表等动态多态机制带来的额外运行时开销,达到在编译期进行多态绑定的效果。
优势
性能优势:
相比于传统的基于虚函数的动态多态(运行时多态),CRTP 是在编译期就确定了具体要调用的函数版本,避免了虚函数调用带来的额外开销,如虚函数表查找等操作,因此在性能敏感的场景中可以提升程序的运行效率,特别是对于频繁调用的函数,这种性能提升会更加明显。
代码复用与灵活性:
基类可以定义通用的算法框架或者逻辑流程,然后由派生类去填充具体的实现细节,这样在多个派生类具有相似逻辑结构但具体实现不同的场景下,能很好地复用基类代码。而且派生类可以根据自身需求灵活地定制实现,不需要受限于基类事先定义好的虚函数接口形式等,只要遵循基类调用的约定即可。
编译时错误检查:
因为 CRTP 的多态绑定是在编译期完成的,所以如果派生类没有正确实现基类所期望调用的函数等情况,编译器能够及时发现并报错,有助于更早地排查代码中的问题,而不像动态多态可能要到运行时才会暴露出错误(比如派生类忘记重写虚函数等情况)。
15. 构造函数的初始化列表要初始化些什么?
在C++中,初始化列表用于在构造函数中初始化类成员。并非所有的成员变量都需要通过初始化列表来初始化,但某些情况下使用初始化列表是必要的或更合适的。以下是需要考虑通过初始化列表初始化的变量类型:
- 常量成员(const members): 如果一个类包含
const
修饰的成员变量,那么这些变量必须在初始化列表中进行初始化,因为它们在创建后不能被修改。 - 引用成员(reference members): 类中的引用成员也必须在初始化列表中初始化,因为引用一旦绑定到一个对象就不能再改变,并且必须立即绑定到某个有效的对象。
- 没有默认构造函数的类类型的成员: 如果一个成员是一个类类型的对象,并且该类没有提供无参数的构造函数(即默认构造函数),那么你需要在初始化列表中调用其构造函数来初始化这个成员。
- 基类和虚基类: 当派生类从基类继承时,如果基类有带参数的构造函数,则需要在派生类的初始化列表中明确地调用它。对于虚基类来说,即使它是间接基类,派生类也需要负责调用它的构造函数。
- 性能考量: 对于那些可以通过复制赋值操作符或等号运算符正确初始化的成员变量,你可以在构造函数体内初始化它们。但是,对于一些复杂的对象(如动态分配内存的对象),直接在构造函数体中初始化可能会导致不必要的拷贝,而使用初始化列表可以避免这种情况,从而提高效率。
- 成员变量的顺序: 初始化列表中的成员变量会按照它们在类定义中的声明顺序被初始化,而不是按照你在初始化列表中列出的顺序。因此,通常最好按照声明顺序书写初始化列表,以避免混淆。
16. 单例类的构造
在这个例子中,Singleton
类的构造函数是私有的,因此不能从 main
函数中直接创建它的实例。相反,我们提供了一个静态成员函数 getInstance
来返回一个已经创建好的实例。对于 Base
和 Derived
类,Base
的构造函数是保护的,所以只能由 Derived
类内部调用。
1 | class Singleton { |
17. 头文件和源文件中的include有什么区别?
在C++编程中,头文件(.h 或 .hpp)和源文件(.cpp 或 .cc)中的包含指令(#include
)都用于引入其他文件的内容,但它们的使用场景和目的有所不同。理解这两者之间的区别对于编写清晰、高效的代码至关重要。
头文件中的 #include
- 声明与定义分离:头文件主要用于声明函数、类、变量等,而这些实体的具体实现则放在源文件中。通过在头文件中使用
#include
,你可以确保所有需要这些声明的源文件都能访问到它们,而无需重复书写相同的声明。 - 防止重复定义:通常会使用预处理指令如
#ifndef
/#define
或#pragma once
来避免同一个头文件被多次包含,这可以防止编译时出现重复定义错误。 - 跨文件依赖管理:当多个源文件需要共享某些声明或类型定义时,将这些内容放在头文件中并通过
#include
引入是最佳实践。这有助于维护一个清晰的依赖关系图,并使得代码更易于管理和扩展。
源文件中的 #include
- 实现细节:源文件主要负责提供函数、方法等的实现。当你在一个源文件中
#include
一个头文件时,你是在告诉编译器:“我在这个源文件中实现了这个头文件中声明的东西。” 这样做可以让编译器知道如何正确地链接这些实现与它们的声明。 - 减少编译时间:尽量只在源文件中
#include
必要的头文件,而不是在头文件中。因为如果一个头文件被多个源文件包含,那么每次编译这些源文件时都会重新解析该头文件的内容,从而增加编译时间。此外,过度的头文件包含还可能导致不必要的依赖传播,使项目更加复杂。 - 模块化开发:每个源文件应该尽量独立,只包含它实际需要的头文件。这样不仅提高了代码的可读性和可维护性,而且也有助于并行编译,加快整体构建过程。
原则
在C++中,选择在一个源文件(.cpp 或 .cc)中包含另一个类的头文件,而不是在头文件中包含,主要是为了遵循一些最佳实践和原则,以确保代码的清晰性、减少编译依赖以及优化编译时间。以下是几个关键原因:
- 减少编译依赖
当你在一个头文件中包含其他头文件时,所有包含该头文件的源文件都会间接地包含那些被包含的头文件。这会增加不必要的依赖关系,使得修改一个头文件可能需要重新编译大量的源文件,即使这些源文件实际上并不直接使用那个头文件的内容。
通过只在源文件中包含所需的头文件,你可以限制这种依赖链,从而减少由于某个头文件变化而引起的重新编译范围。这不仅提高了开发效率,还减少了构建时间。
- 避免重复定义和命名冲突
如果多个头文件相互包含,并且这些头文件又被多个源文件包含,可能会导致重复定义问题或命名空间冲突。虽然预处理器指令如 #ifndef
/#define
或 #pragma once
可以防止多重包含带来的直接错误,但复杂的包含关系仍然可能导致难以追踪的问题。
- 提高编译速度
每个 #include
指令都会让编译器处理额外的文件内容,这会增加编译时间和内存占用。特别是当一个头文件被多个源文件包含时,相同的代码会在每次编译中被解析多次。通过将 #include
放在源文件中,可以减少编译器处理的总代码量,进而加快编译速度。
- 前向声明的使用
很多时候,你只需要知道某个类的存在,而不需要完整的类定义。在这种情况下,可以使用前向声明(forward declaration),而不是包含整个头文件。例如:
1 | class SomeClass; // 前向声明 |
前向声明可以减少对其他头文件的依赖,同时保持代码的正确性。如果你只需要指针或引用类型的参数或返回值,通常前向声明就足够了。
- 接口与实现分离
头文件应该尽量保持精简,只包含必要的声明,这样可以更清晰地展示类的接口。而具体的实现细节则放在源文件中。这样做有助于维护模块化设计,使代码更容易理解和维护。
18. namespace 和 using 的区别
为什么有的别名是 namespace,有的别名是 using?
namespace http = beast::http;
创建了一个名为http
的命名空间别名,指向beast::http
。using tcp = boost::asio::ip::tcp;
为boost::asio::ip::tcp
类型创建了一个别名tcp
。
19. VS 中的动态库
vs 中引入动态库需要两个文件,一个是 dll 文件,同时还需要一个 lib 文件,这个 lib 文件不是静态库,只是动态库的引导文件,存储着动态库的函数和相应的符号,相当于动态库的简介。因此配置动态库的时候需要设置3个步骤:
- 配置包含目录
- 配置库目录
- 配置附加依赖项