C++ 面经杂文
STL,C++11
move 相关
这里需要构造一个方便指明不同对象的 class A,下面的实现是不符合复制、移动构造函数的语义的,请不要模仿:
1 |
|
会发生值复制以及创建临时对象的地方
列个表方便对比查看:
class B 构造函数定义 | 运行代码 | 输出 |
---|---|---|
B(A a) : _a(a) {} | A aa; B b = B(aa); | Construct A; Copy A(0) to A(1); Copy A(1) to A(2); Destruct A(1); end; Destruct A(2); Destruct A(0); |
B(A a) : _a(a) {} | A aa; B b = B(std::move(aa)); | Construct A; Move A(0) to A(1); Copy A(1) to A(2); Destruct A(1); end; Destruct A(2); Destruct A(-1); |
B(A a) : _a(std::move(a)) {} | A aa; B b = B(std::move(aa)); | Construct A; Move A(0) to A(1); Move A(1) to A(2); Destruct A(-1); end; Destruct A(2); Destruct A(-1); |
B(A& a) : _a(std::move(a)) {} | A aa; B b = B(aa); | Construct A; Move A(0) to A(1); end; Destruct A(1); Destruct A(-1); |
注意 end; 插入的时机,end; 是在 main 函数结束之前,B 构造之后输出,这里是方便看清函数参数值复制时临时对象的产生与消失过程。
1 | B(A a) : _a(a) {} |
这段函数显然发生了两处值复制,一个是 B(aa) 处的 aa 会复制成 B(A a) 这个参数 a,然后 _a(a) 则是正常的复制拷贝函数调用。
这两处地方都可以使用 std::move 语义,但是显然 move 的使用并没有让临时对象少生成,因为 move 仅仅是改变资源(成员变量)在变量间的所有权,在 aa 外面套一层 move 壳仅仅会将 aa 从 A& 标记为 A&&。换言之,B(A a) 这个函数创建临时对象的过程仍然存在,只不过 move 会让本该调用复制构造函数的临时对象改成调用移动构造函数(反正无论怎样还是要构造的)。
返回值优化(Return Value Optimization, RVO)
返回值优化与临时对象是密切相关的,写了太多 Rust 以至于笔者下意识认为在栈上的对象是不可返回的,来梳理一下 C++ 返回值的过程,写一个测试函数如下:
1 | auto test_RVO() -> A { |
这里需要禁用编译器的 RVO
在没有 move 前(CPP98)函数会输出 Construct A; Copy A(10) to A(11); Destruct A(-1); end; Destruct A(11);
,有了 move(CPP11 之后)编译器就已经会把复制构造函数替换成移动构造函数了,输出 Construct A; Move A(10) to A(11); Destruct A(-1); end; Destruct A(11);
。
这说明了,test_RVO 里的 aa 在不做 RVO 前确实会消失,但 C++ 会先把这个 aa 赋值给函数外面的 a 再删除 aa。
反正 aa 最终都要删除,那么把 aa 变量所有权改成 a 而不是重新复制一份就是 move 语义带来的优化。而 RVO 则更加激进,为什么一定要让 aa 消失呢?不如直接将 a 的内存地址改成 aa 就好,这时会输出 Construct A; end; Destruct A(10);
。第二个优化会直接把第一个优化盖过去,所以可以看作时 move 优化的激进版。
因此要慎重考虑返回值的析构函数调用问题。 move 语义不影响临时对象的创建,但是 RVO 会直接消除临时对象。
完美转发
万能引用(Universal Reference)
智能指针相关
shared_ptr 是否线程安全
std::shared_ptr
的操作并非完全线程安全。具体来说:
- 单独的对象(实例)上的操作(例如解引用操作
*
和箭头操作符->
)是线程安全的。 - 对同一个
shared_ptr
实例进行写操作(例如修改引用计数)需要同步机制来保证线程安全。 - 不同的
shared_ptr
实例即使它们共享相同的控制块也是线程安全的,因为引用计数的增加和减少是原子操作。
在循环引用中,两个节点,如果一个用 shared_ptr 指向另一个,另一个用 weak_ptr 回指。根据什么来判断什么对象该使用 shared_ptr 以及 weak_ptr?
在处理循环引用时,正确使用std::shared_ptr
和std::weak_ptr
可以避免内存泄漏。选择哪个节点使用std::shared_ptr
,哪个节点使用std::weak_ptr
,并不是基于节点的释放顺序,而是基于对象的所有权关系和生命周期管理。
示例说明
假设有两个类ClassA
和ClassB
,其中ClassA
实例需要持有对ClassB
实例的长期引用,同时ClassB
实例也需要反向引用ClassA
实例。为了避免循环引用导致的内存泄漏:
-
如果
ClassA
的实例是ClassB
实例的主要拥有者(例如,ClassA
创建了ClassB
,并且其逻辑上应该控制ClassB
的生命周期),则在ClassA
中使用std::shared_ptr<ClassB>
,而在ClassB
中使用std::weak_ptr<ClassA>
来指向ClassA
。 -
反之,如果
ClassB
是主要拥有者,则在ClassB
中使用std::shared_ptr<ClassA>
,并在ClassA
中使用std::weak_ptr<ClassB>
。
通过这种方式,可以确保对象能够按照预期的方式被销毁,不会因为循环引用而导致内存泄漏。重要的是理解各个对象之间的关系以及它们的生命周期,而不是简单地依赖于释放顺序来做决定。
unique_ptr move 到 shared_ptr 会发生什么
当你将一个std::unique_ptr
通过移动语义传递给std::shared_ptr
时,所有权从unique_ptr
转移到shared_ptr
。这意味着:
unique_ptr
放弃对资源的所有权,并且之后不能再使用它来访问资源,因为它不再拥有该资源。shared_ptr
接管资源并开始管理它。现在可以有多个shared_ptr
共享同一资源,直到最后一个shared_ptr
被销毁或重置,这时才会释放资源。
unique_ptr 有什么特性,底层实现是怎样的,是怎么保证无法赋值构造的
shared_ptr 怎么实现的,引用计数是什么数据格式,引用计数的线程安全怎么保证的,底层怎么实现
STL 相关
STL 容器是分配在堆还是栈
各种容器对象本身通常是分配在栈上的,但是它管理的动态数组(即其元素)是存储在堆上的。
sizeof(vector)
返回的是 vector 对象大小还是容器大小
sizeof(vector)
返回的是该 vector
对象本身的大小,而不是它所管理的动态数组的大小。这个值通常包括几个指针或索引(如指向数据的指针、容量和大小等),因此它是一个相对较小且固定的数值。
vector
- resize:改变容器大小。如果新尺寸大于当前尺寸,则使用默认值或提供的值填充新增空间;如果小于当前尺寸,则删除多余的元素。
- reserve:请求容器分配足够的存储空间来容纳至少指定数量的元素,但不会改变容器的实际大小。这有助于减少内存重新分配的次数。
deque
deque 通常作为一组独立区块,第一区块朝某方向扩展,最后一个区块朝另一个方向扩展。
- resize:类似于
vector
的resize
,但是由于deque
的内部结构不同(块状连续存储),其性能特征也有所不同。
unordered_set
/ unordered_map
- rehash:调整容器的桶数,通常用于提高哈希表的性能。它强制容器重建其哈希表,可能导致所有元素的重定位。
- load_factor:获取或设置容器的负载因子,即每个桶的平均元素数。较高的负载因子可能导致更多的碰撞和降低查找性能。
插入一个元素后迭代器的有效性
容器名称 | 插入后的迭代器有效性 | 理由 |
---|---|---|
list /forward_list |
迭代器不会失效。 | list 是双向链表,forward_list 是单向链表,它们节点独立存在,插入新节点不会影响其他节点的位置或迭代器的有效性。 |
set /map |
迭代器不会失效。 | 因为它们通常基于平衡二叉树实现,插入新元素仅会改变树的结构,而不会使现有迭代器失效。 |
vector /string |
如果容器内存被重新分配,则所有迭代器都失效,否则插入位置之后的迭代器都会失效。 | 因为vector 的底层实现是连续存储空间,插入可能导致内存重新分配,从而使迭代器失效。 |
deque (首尾两端的变化存疑) |
增加任何元素都将使 deque 的迭代器失效,但两端插入时指针、引用仍有效。deque 是唯一一个在迭代器失效时不会使它的指针和引用失效的标准 STL 容器。 | deque 支持两端快速插入。 |
unordered_set /unordered_map |
在没有发生 rehash 的情况下迭代器不会失效;如果发生了 rehash,则所有迭代器都将失效。 | 当哈希表达到负载因子阈值时会发生 rehash,这会导致存储地址的变化,从而使得所有迭代器失效。 |
删除一个元素后迭代器的有效性
容器名称 | 删除后的迭代器有效性 | 理由 |
---|---|---|
list /forward_list |
只会使被删除元素迭代器失效。 | list 是双向链表,forward_list 是单向链表,它们节点独立存在,删除新节点不会影响其他节点的位置或迭代器的有效性。 |
set /map |
只会使被删除元素迭代器失效。 | 因为它们通常基于平衡二叉树实现,删除新元素仅会改变树的结构,而不会使现有迭代器失效。 |
vector /string |
被删除元素之后的迭代器都会失效。 | 因为vector 的底层实现是连续存储空间,删除不会导致内存重新分配。 |
deque (首尾两端的变化存疑) |
在 deque 的中间删除元素将使迭代器、指针、引用失效,而在头或尾删除元素时,只有指向该元素的迭代器失效。deque 是唯一一个在迭代器失效时可能不会使它的指针和引用失效的标准 STL 容器。 | deque 支持两端快速删除。 |
unordered_set /unordered_map |
只会使被删除元素迭代器失效。 |
如何高效删除 C++ vector 中所有下标为偶数的元素?
std::function
因为其他语言函数指针实现的太好了以至于想象不到 std::funtion 的作用,记录如下:
1 |
|
由此可见 std::function 就是为了统一函数接口提出来的,由于是跟 lambda 函数、std::bind 一起提出来的,基本上可以看作是配套措施。rust 和 go 完全没有这种历史包袱,打一开始就是 lambda 函数和正常函数一样传。
lambda 函数不捕获变量也可以作为函数指针传入,但捕获变量了就不行(为什么呢,笔者也不知道)。
而类的非静态成员函数都有个 this 指针作为第一个参数,即使用了 std::bind 绑定了 this 指针,std::bind 返回的可调用对象(可调用对象是 C++11 对上述几种函数统一的称呼)也不能直接转成函数指针。那么在 C++11 前该怎么传入类的非静态成员函数呢,使用适配器模式即可的:
代码实现
1 |
|
std::future 和 std::promise、std::packaged_task、std::async
需要注意的是 future 和 promise 是还是基于 thread 的,而非 coroutine。毕竟是 C++11 就引入的,而 C++20 才有 coroutine 呢。
先声明 promise 然后获取绑定的 future,把 promise 丢到另一个线程里去,promise.set_value 就可以通过 future.get 了。
在 C++ 中,std::async、std::packaged_task 和 std::promise 都是用来处理异步任务和结果管理的工具,packaged_task 和 async 可以视作 promise 的高级封装(因为可以注意到大家都要搭配 future 用而不是搭配 promise 用)。
下面是对这三者的对比分析:
std::promise
1 | std::promise<int> promise; |
std::packaged_task
1 | std::packaged_task<int()> task([]() { |
std::async
1 | // 可以自动选择使用新线程(std::launch::async)或延迟执行(std::launch::deferred) |
对比总结
特性/工具 | std::promise |
std::packaged_task |
std::async |
---|---|---|---|
启动异步任务 | 不直接启动任务,需配合其他机制 | 手动,需自行安排执行环境 | 自动,支持多种策略 |
资源管理 | 需要手动管理 | 需要手动管理 | 自动管理 |
使用便捷性 | 低(更灵活,但复杂) | 中 | 高 |
控制灵活性 | 最高 | 中等 | 较低 |
适用场景 | 复杂同步需求,多上下文设置结果 | 快速简单异步任务 | 需要更多控制的任务 |
模板函数
类模板全特化与偏特化
代码实现
1 |
|
函数模板特化
- 函数模板只有特化,没有偏特化(函数模板偏特化等价于新的一个模板函数,故没有偏特化的说法);
- 模板、模板的特化和模板的偏特化都存在的情况下,编译器在编译阶段进行匹配,优先特殊的;
- 模板函数不能是虚函数;因为每个包含虚函数的类具有一个 virtual table,包含该类的所有虚函数的地址,因此 vtable 的大小是确定的。模板只有被使用时才会被实例化,将其声明为虚函数会使 vtable 的大小不确定。所以,成员函数模板不能为虚函数。
面向对象
构造函数
只有构造函数可以重载,其他的默认函数(复制构造、移动构造和析构函数)都有唯一的函数签名,不可重载。
初始化列表
析构函数
复制构造函数
函数签名为
1 | T(T& t) |
移动构造函数
函数签名为
1 | T(T&& t) |
= 操作符重载
操作重载会把复制构造和移动构造的逻辑重复一遍,但是考虑为方便链式调用,函数签名为:
1 | // 以移动构造为例 |
这里的返回参数写法与构造操作没有任何关系,不返回 *this 也完全不影响单个的 a=b; 赋值式子,主要是方便链式调用。
a = b = c; 的调用顺序
视作 a = (b = c); ,也就是 b 调用 = 操作符复制了 c,然后返回了 b(*this 就是 b 本身),a 再调用 = 操作符复制 b。
用 C++ 实现一个日志系统,可以被调用,要求性能最好。先思考三分钟,说一下思路,伪代码实现。
设计一个高性能的日志系统,需要考虑几个关键点:
- 线程安全
- 最小化日志记录对主程序性能的影响
- 灵活的日志级别支持
- 易于扩展和维护。
以下是实现这样一个系统的思路:
思路
-
日志级别:定义不同的日志级别(如 DEBUG、INFO、WARNING、ERROR),让用户能够根据需要控制输出的详细程度。
-
异步日志记录:为了避免在记录日志时阻塞主线程,采用生产者 - 消费者模式,使用队列来缓存日志消息,并在一个单独的线程中处理这些消息。
-
高效写入:为了提高写入效率,可以将多个日志条目批量写入到文件中,减少磁盘 I/O 操作次数。
-
线程安全:确保多线程环境下的安全性,特别是当有多个线程同时尝试记录日志时。
-
格式化与过滤:提供灵活的日志格式化选项和过滤机制,允许用户自定义哪些级别的日志应当被记录下来。
伪代码实现
1 |
|
这个伪代码展示了如何创建一个简单的异步日志系统。实际应用中可能还需要增加更多的功能,比如配置文件的支持、多种输出目标(文件、控制台等)、更复杂的格式化选项等。此外,还可以通过调整队列大小、批量写入策略等方式进一步优化性能。
多线程打印奇偶数
为了实现多线程打印奇数和偶数,我们可以使用 C++ 中的线程同步机制来确保两个线程能够正确地交替执行,即一个线程打印奇数,另一个线程打印偶数。这里可以使用互斥锁(std::mutex
)和条件变量(std::condition_variable
)来控制两个线程的执行顺序。
以下是一个简单的例子展示如何使用 C++ 实现这一功能:
代码实现
1 |
|
这段代码创建了两个线程t1
和t2
,分别用于打印奇数和偶数。通过使用互斥锁和条件变量,我们保证了在任意时刻只有一个线程在执行,并且确保了奇数和偶数按照正确的顺序被打印出来。在这个例子中,我们假设打印的范围是 1 到 9,当然这个范围可以根据需要调整。
Golang 与 C++ 的区别
Go(通常称为 Golang)和 C++ 是两种不同的编程语言,它们在设计理念、使用场景以及实现方式上都有显著的区别:
-
内存管理:C++ 提供了对内存的细粒度控制,包括手动管理内存分配和释放(如通过
new
和delete
)。而 Go 采用垃圾回收机制自动管理内存,简化了程序员的工作负担。 -
并发模型:Go 内置支持轻量级线程(goroutines),并通过通道(channels)来简化并发编程。相比之下,C++ 需要借助标准库或第三方库来实现类似的并发功能,代码通常更加复杂。
-
编译速度与效率:Go 设计之初就考虑到了快速编译,其编译速度通常比 C++ 快很多。这使得开发迭代过程更快。
-
面向对象编程:虽然 Go 支持一些面向对象的概念,但没有传统意义上的类和继承层次。C++ 提供了完整的面向对象编程支持,包括类、继承、多态等特性。
Go 的“轻量”体现在什么地方?
-
Goroutines:Go 的 goroutines 比传统的线程更加轻量,占用更少的系统资源,允许轻松创建成千上万的并发任务。
-
快速编译:Go 编译速度快,有助于提高开发效率。
-
简单易用的标准库:Go 提供了一个强大且易于使用的标准库,许多常见的编程任务可以直接利用这些库来完成,无需引入额外的依赖。
Go 的劣势在哪?
尽管 Go 有许多优点,但它也存在一些限制和不足之处:
- 性能优化难度:对于某些需要极致性能的应用场景,Go 可能不如 C++ 灵活,尤其是在需要精细控制硬件资源时。