CVTE校招C++面经,八股盛宴
1、虚函数的实现机制?虚函数表和虚表指针的内存空间布局,他们的分别存放在哪里?
虚函数(Virtual Function)是C++实现多态性(Polymorphism)的关键机制,它允许通过基类指针或引用调用派生类中重写的同名函数。这种机制的核心是虚函数表(Virtual Table, vtable)和虚表指针(Virtual Table Pointer, vptr)。
实现机制:
当一个类声明了虚函数(或者继承了带有虚函数的基类),编译器会为该类创建一个唯一的虚函数表。这个vtable本质上是一个函数指针数组,存储了类中所有虚函数的地址。如果派生类重写了基类的虚函数,那么在派生类的vtable中,相应的位置会被替换为派生类重写后的函数地址。如果派生类没有重写,则该位置将继续存储基类虚函数的地址。
当通过基类指针或引用调用一个虚函数时,实际的调用过程如下:
- 通过对象实例的vptr找到对应的vtable。
- 在vtable中查找被调用虚函数的地址(这通常是一个固定的偏移量)。
- 解引用该地址,执行相应的函数代码。
这个过程被称为动态绑定(Dynamic Binding)或运行时绑定(Late Binding),因为具体调用哪个函数是在程序运行时才确定的,而不是在编译期。
内存空间布局与存放位置:
为了清晰地说明内存布局,我们来看一个典型的例子:
class Base {
public:
virtual void func1() { /* ... */ }
virtual void func2() { /* ... */ }
private:
long long a;
};
class Derived : public Base {
public:
void func1() override { /* ... */ } // 重写
virtual void func3() { /* ... */ } // 新增虚函数
private:
long long b;
};| 内存区域 | 存放内容 | 详细说明 |
|---|---|---|
| .rodata 段 | 虚函数表 (vtable) | vtable是类级别的,一个类只有一张。它通常存放在程序的可执行文件的只读数据段(.rodata或.rdata),因为一旦在编译期确定,运行时就不应被修改。对于上面的例子,Base和Derived各自拥有一张vtable。Derived的vtable会包含func1的重写版本地址、继承的func2地址以及新增的func3地址。 |
| 对象内存 | 虚表指针 (vptr) | vptr是对象级别的,每个包含虚函数的类的实例都会有一个vptr。它作为对象的一个隐藏成员,通常位于对象内存布局的起始位置。vptr在对象构造时由构造函数初始化,指向其所属类的vtable。因此,vptr存放在对象所在的内存区域,如果对象在栈上,vptr就在栈上;如果对象在堆上,vptr就在堆上。 |
| .text 段 | 函数代码 | 所有成员函数(包括虚函数和非虚函数)的二进制代码本身存放在代码段(.text),这是一个只读的内存区域。vtable中存储的仅仅是这些函数的入口地址。 |
总结: 虚函数机制通过vptr和vtable实现了运行时的函数派发,是C++多态的核心。vtable是类相关的,存储在只读数据段;vptr是对象相关的,存储在对象本身的内存空间中。
2、class和struct有什么区别?
在C++中,class和struct的底层实现几乎完全相同,它们都可以包含数据成员、成员函数、构造函数、析构函数、继承、虚函数等。它们之间最主要的区别在于默认的成员访问权限和默认的继承方式。
| 特性 | class | struct |
|---|---|---|
| 默认成员访问权限 | private | public |
| 默认继承方式 | private | public |
使用惯例:
尽管功能上几乎等同,但在C++社区中,通常遵循以下使用惯例:
struct:用于表示主要由数据组成的聚合体,其成员默认是公开的,类似于C语言中的结构体。它强调的是数据的集合,行为(成员函数)较少或没有。例如,一个表示二维点的Point结构体。class:用于定义具有复杂行为和数据封装的对象。它强调的是封装和接口,通过public接口暴露功能,而将实现细节隐藏在private或protected部分。
遵循这个惯例可以使代码意图更清晰,提高可读性。
3、构造函数里面可以使用this指针吗?
可以。
在C++中,当一个对象的成员函数被调用时,编译器会隐式地传递一个指向该对象的指针,这个指针就是this指针。构造函数也是一个特殊的成员函数,因此在构造函数内部,this指针是可用的,并且它指向正在被创建的对象。
this指针在构造函数中的作用:
区分成员变量和参数:当构造函数的参数名与成员变量名相同时,可以使用
this->来明确指定访问的是成员变量。cppclass MyClass { private: int value; public: MyClass(int value) { this->value = value; // 使用this指针区分 } };返回对象自身的引用:在某些设计模式中(如链式调用),构造函数或成员函数可能需要返回对当前对象的引用。
cppclass MyClass { public: MyClass& doSomething() { // ... return *this; } };
注意事项:
虽然在构造函数中可以使用this指针,但有一个重要的限制:不要在构造函数中通过this指针调用虚函数。
在构造函数执行期间,对象的类型被认为是正在构造的这个类的类型,而不是任何派生类的类型。如果此时调用虚函数,将执行的是当前类版本的虚函数,而不是派生类中重写的版本,这会破坏多态性。更糟糕的是,如果虚函数依赖于派生类中尚未初始化的成员,可能会导致未定义行为。
4、智能指针有哪些分类?分别有什么作用?
C++11标准库引入了智能指针,旨在通过RAII(Resource Acquisition Is Initialization)机制自动管理动态分配的内存,从而有效防止内存泄漏。主要的智能指针有三种:std::unique_ptr、std::shared_ptr和std::weak_ptr。
| 智能指针 | 所有权模型 | 核心作用 | 主要特点 |
|---|---|---|---|
std::unique_ptr | 独占所有权 | 确保一个对象只有一个拥有者。当unique_ptr被销毁时(例如离开作用域),它所指向的对象也会被自动删除。 | - 轻量级,开销与原始指针相近。<br>- 不可复制,但可以通过std::move转移所有权。<br>- 是管理动态分配资源的默认首选。 |
std::shared_ptr | 共享所有权 | 允许多个shared_ptr共同拥有同一个对象。内部通过引用计数来跟踪拥有者的数量。当最后一个shared_ptr被销毁时,对象才会被删除。 | - 允许多个指针指向同一资源,适用于资源需要被多个部分共享的场景。<br>- 引用计数会带来一定的性能开销。<br>- 可能会导致循环引用问题。 |
std::weak_ptr | 非拥有(观察者) | 作为shared_ptr的助手,它提供对shared_ptr所管理对象的非拥有性(弱)引用。它不增加引用计数。 | - 用于打破shared_ptr之间的循环引用。<br>- 不能直接访问所指向的对象,必须通过lock()方法获取一个临时的shared_ptr来安全地访问。如果对象已被销毁,lock()会返回一个空的shared_ptr。 |
总结:
- 当你需要一个指针独占一个资源时,使用
std::unique_ptr。 - 当你需要多个指针共享一个资源的所有权时,使用
std::shared_ptr。 - 当你需要观察一个由
std::shared_ptr管理的对象,但又不想影响其生命周期时(特别是为了解决循环引用),使用std::weak_ptr。
5、enable_shared_from_this()和shared_from_this()的作用和原理是什么?以及在实际项目是否遇到使用这两个函数的实际场景?
std::enable_shared_from_this和std::shared_from_this是std::shared_ptr机制的一部分,用于解决一个特定的问题:在类成员函数内部,如何安全地获取一个指向当前对象(this)的std::shared_ptr?
作用和原理:
std::enable_shared_from_this<T>:这是一个模板类,需要被想要安全获取this的shared_ptr的类T公开继承。它在类T中注入了一个std::weak_ptr成员。当一个std::shared_ptr首次创建并管理类T的实例时,这个weak_ptr会被初始化,指向该实例。shared_from_this():这是std::enable_shared_from_this提供的一个成员函数。当在类T的成员函数中调用它时,它会使用内部的weak_ptr来lock(),从而创建一个新的、指向当前对象的std::shared_ptr。这个新的shared_ptr会与管理该对象的其他shared_ptr共享所有权和引用计数。
为什么不能直接 std::shared_ptr<T>(this)?
如果在成员函数中直接使用std::shared_ptr<T>(this),会创建一个全新的、独立的shared_ptr,它拥有自己独立的引用计数。这会导致**双重释放(Double Free)**的严重问题:当原始的shared_ptr和这个新建的shared_ptr都销毁时,它们会各自尝试删除同一个对象,导致程序崩溃。
enable_shared_from_this机制确保了所有从this创建的shared_ptr都连接到同一个所有权组,共享同一个引用计数,从而避免了这个问题。
实际项目场景:
是的,在实际项目中,尤其是在异步编程和回调机制中,这个功能非常有用。
一个典型的场景是异步操作:
假设有一个Connection类,它处理网络连接。当它发起一个异步读操作时,需要提供一个回调函数,在操作完成后被调用。这个回调函数通常是Connection类的一个成员函数。在发起异步操作后,Connection对象本身可能因为其他原因被销毁(例如,外部的shared_ptr被重置)。
为了确保在回调函数被调用时Connection对象仍然存活,我们可以在发起异步操作时,将一个shared_from_this()得到的shared_ptr绑定到回调函数中。这样,只要异步操作还在进行,这个shared_ptr就会保持Connection对象的生命周期,防止其被过早销毁。
#include <iostream>
#include <memory>
#include <functional>
// 模拟异步操作库
void async_read(std::function<void()> callback) {
// 模拟异步,在未来的某个时间点调用回调
std::thread([callback]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
callback();
}).detach();
}
class Connection : public std::enable_shared_from_this<Connection> {
public:
Connection() { std::cout << "Connection created" << std::endl; }
~Connection() { std::cout << "Connection destroyed" << std::endl; }
void start_read() {
// 使用shared_from_this()将this的生命周期绑定到回调中
auto self = shared_from_this();
async_read([self]() {
self->on_read_complete();
});
}
void on_read_complete() {
std::cout << "Read operation complete." << std::endl;
}
};
int main() {
std::cout << "Entering main" << std::endl;
{
auto conn = std::make_shared<Connection>();
conn->start_read();
} // conn在这里被销毁,但由于回调中持有了shared_ptr,对象会继续存活
std::cout << "Exiting main scope" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2)); // 等待异步操作完成
return 0;
}在这个例子中,即使main函数中的conn在作用域结束时被销毁,Connection对象也不会立即析构,因为它被回调函数中的self(一个shared_ptr)所持有,直到异步操作完成并调用回调函数后,引用计数才会归零,对象才被安全销毁。
6、lambda有什么作用,C++11-20各有什么什么改进等?捕获的类型有哪几种,有啥作用如果在lambda函数中捕获this指针,会不会有什么风险?
Lambda表达式的作用:
Lambda表达式(也称为匿名函数)是C++11引入的一个强大特性。它的主要作用是就地定义一个可调用对象,使得代码更简洁、更具表现力。它被广泛用于:
- STL算法:作为算法的谓词或操作函数,例如
std::sort,std::for_each,std::find_if。 - 回调函数:在异步编程或事件驱动模型中定义回调。
- 简化代码:避免为了一个只用一次的小功能而单独定义一个函数或函数对象。
C++11到C++20的改进:
- C++11:引入了基本的Lambda表达式,包括捕获列表(值捕获和引用捕获)、参数列表和函数体。
- C++14:
- 泛型Lambda(Generic Lambdas):允许在参数列表中使用
auto关键字,使得Lambda可以接受任意类型的参数,类似于模板。 - 捕获初始化(Init Capture):允许在捕获列表中创建新的变量并初始化,这对于移动(move)一个只能移动的对象到Lambda中非常有用(例如
std::unique_ptr)。语法:[var = std::move(ptr)](){ ... }。
- 泛型Lambda(Generic Lambdas):允许在参数列表中使用
- C++17:
constexprLambda:如果Lambda满足constexpr函数的要求,它就可以在编译期求值。- 捕获
*this:通过值捕获this所指向的对象,即将当前对象的一份拷贝存储在Lambda闭包中。这在异步调用中非常有用,可以保证在回调执行时对象是存活的(即使原始对象已销毁)。
- C++20:
- 模板Lambda:允许在Lambda表达式中使用模板参数列表,提供了更强的泛型编程能力。
- 无状态Lambda可默认构造和赋值:如果Lambda没有捕获任何变量,它就成为一个无状态的函数对象,可以被默认构造和赋值。
- 允许在非捕获Lambda中使用
[=]:简化了语法。
捕获的类型和作用:
捕获列表定义了Lambda如何从其所在的作用域中“捕获”变量。
| 捕获方式 | 语法 | 作用 |
|---|---|---|
| 值捕获 | [var] | 将变量var的一份拷贝存储在Lambda闭包中。Lambda内部对该变量的修改不会影响外部的原始变量。 |
| 引用捕获 | [&var] | 将变量var的引用存储在Lambda闭包中。Lambda内部对该变量的修改会直接影响外部的原始变量。 |
| 隐式值捕获 | [=] | 以值捕获的方式捕获所有在Lambda函数体中使用的外部变量。 |
| 隐式引用捕获 | [&] | 以引用捕获的方式捕获所有在Lambda函数体中使用的外部变量。 |
this指针捕获 | [this] | 捕获当前对象的this指针。允许在Lambda内部访问当前对象的成员变量和成员函数。 |
| 捕获初始化 (C++14) | [var = expr] | 在闭包中创建一个名为var的新变量,并用expr的结果初始化它。 |
*this值捕获 (C++17) | [*this] | 创建当前对象的一份拷贝,并存储在闭包中。 |
捕获this指针的风险:
如果在Lambda中捕获this指针(无论是显式[this]还是隐式[=]),确实存在风险,主要是**悬垂指针(Dangling Pointer)**的风险。
风险场景:
当一个Lambda以[this]方式捕获this指针,并被用于一个异步回调时,Lambda本身只持有了对象的原始指针,而不是一个能管理其生命周期的智能指针。如果在回调函数被调用之前,this所指向的对象已经被销毁,那么在回调函数内部通过this指针访问成员变量或调用成员函数将导致未定义行为(通常是程序崩溃)。
如何规避风险?
使用
shared_from_this(推荐):如前一题所述,如果类继承自std::enable_shared_from_this,可以在Lambda中捕获一个由shared_from_this()创建的std::shared_ptr。这能保证只要Lambda存活,对象就不会被销毁。cpp[self = shared_from_this()]() { self->doSomething(); }使用
*this值捕获(C++17及以上):如果对象的拷贝开销不大,并且你希望在回调中使用对象在捕获那一刻的状态,可以使用[*this]。这会将被调用对象的一份拷贝保存在lambda中,从而保证了生命周期的安全。cpp[*this]() { this->doSomething(); } // 这里的this指向lambda内部的拷贝手动管理生命周期:确保对象的生命周期一定长于任何可能调用该Lambda的异步操作。这种方式脆弱且容易出错,不推荐。
7、平时用C++哪个标准比较多?
在当前的专业开发环境中,C++11和C++14已经成为事实上的基础标准,绝大多数现代项目都在使用它们。近年来,随着编译器支持的完善和新特性带来的巨大便利,越来越多的项目开始迁移到或直接采用C++17。
- C++11/14:是目前使用最广泛的标准。它们引入了智能指针、右值引用、Lambda表达式、
auto类型推导、范围for循环等一系列革命性的特性,极大地提升了C++的现代化水平和开发效率。 - C++17:是当前的主流趋势。它带来了
std::optional、std::variant、std::any、结构化绑定、if constexpr、并行STL算法等非常实用的特性,进一步简化了代码并提升了性能。新项目通常会优先考虑C++17。 - C++20:是最新的主要标准,引入了Concepts、Ranges、Coroutines(协程)、Modules等重量级特性。虽然这些特性非常强大,但由于编译器支持和社区生态的成熟需要时间,目前主要在一些前沿项目、个人研究或特定库的开发中使用。预计在未来几年内会逐渐普及。
因此,作为一名资深C++工程师,我会说我主要使用C++17,同时对C++20的新特性保持学习和关注,并能在项目中根据实际情况和团队规范,熟练运用C++11/14/17的特性进行高效开发。
8、常用的STL容器有什么?平时用过哪些容器,详细介绍一下?
STL(Standard Template Library)容器是C++标准库的核心组成部分,提供了多种用于存储和组织数据的数据结构。它们可以分为几类:
- 序列容器(Sequence Containers):
std::vector,std::deque,std::list,std::array,std::forward_list - 关联容器(Associative Containers):
std::map,std::set,std::multimap,std::multiset - 无序关联容器(Unordered Associative Containers):
std::unordered_map,std::unordered_set,std::unordered_multimap,std::unordered_multiset - 容器适配器(Container Adapters):
std::stack,std::queue,std::priority_queue
在平时的项目中,我最常用的容器是std::vector、std::unordered_map和std::string(虽然string在技术上不是容器,但其行为非常相似)。下面详细介绍一下我经常使用的几个容器:
1. std::vector
- 底层数据结构:动态数组。它将元素存储在一段连续的内存空间中。
- 核心特性:
- 随机访问:支持通过索引(
[]或.at())在O(1)时间内访问任意元素。 - 动态扩容:当元素数量超过当前容量时,会自动分配一块更大的内存,并将原有元素移动或复制到新内存中。
- 尾部插入/删除高效:在尾部添加(
push_back)或删除(pop_back)元素通常是摊还O(1)时间复杂度。 - 中间插入/删除低效:在数组中间或开头插入/删除元素需要移动后续所有元素,时间复杂度为O(n)。
- 随机访问:支持通过索引(
- 使用场景:
- 需要频繁进行随机访问的场景。
- 元素数量不确定,但大部分操作是在尾部进行的场景。
- 作为函数参数或返回值,传递一组数据。
- 是大多数情况下的默认首选序列容器,因为其内存连续性对CPU缓存非常友好,性能通常最好。
2. std::unordered_map
- 底层数据结构:哈希表(Hash Table)。
- 核心特性:
- 键值对存储:存储
<Key, Value>形式的元素。 - 快速查找、插入和删除:在平均情况下,这些操作的时间复杂度都是O(1)。在最坏情况下(所有元素哈希冲突),会退化到O(n)。
- 无序:元素在容器中的存储顺序是不确定的,与插入顺序无关。
- 键唯一:一个
unordered_map中不能有重复的键。
- 键值对存储:存储
- 使用场景:
- 需要快速根据键查找值的场景,例如缓存、索引、字典等。
- 对元素的顺序没有要求。
- 当性能(特别是查找速度)是首要考虑因素时,通常优于
std::map。
3. std::map
- 底层数据结构:红黑树(Red-Black Tree),一种自平衡的二叉搜索树。
- 核心特性:
- 键值对存储:与
unordered_map类似。 - 有序:元素总是根据键自动排序。遍历
map时,会得到一个有序的序列。 - 查找、插入和删除稳定:所有这些操作的时间复杂度都是O(log n)。
- 键唯一:与
unordered_map相同。
- 键值对存储:与
- 使用场景:
- 需要存储键值对,并且需要对键进行排序的场景。
- 需要进行范围查找的场景(例如,查找所有键在某个范围内的元素),这是
unordered_map无法高效完成的。 - 当对性能有稳定要求,不希望出现哈希表最坏情况下的性能抖动时。
4. std::list
- 底层数据结构:双向链表。
- 核心特性:
- 快速插入和删除:在链表的任何位置插入或删除元素都是O(1)时间复杂度(前提是已经有了指向该位置的迭代器)。
- 不支持随机访问:访问第n个元素需要从头或尾开始遍历,时间复杂度为O(n)。
- 迭代器稳定性:插入或删除元素不会导致其他元素的迭代器失效(除非删除的就是迭代器指向的那个元素)。
- 使用场景:
- 需要频繁在容器中间进行插入和删除操作的场景。
- 当迭代器稳定性至关重要时。
- 在现代C++中,由于其缓存不友好性,
std::list的使用场景已经越来越少,很多时候std::vector即使在中间插入删除,由于高效的移动语义和缓存性能,综合表现也可能更好。
9、vector的扩容机制是怎么样的?
std::vector的扩容机制是其实现动态数组能力的核心。当向vector中添加元素(例如使用push_back)而其当前的容量(capacity())已满时,就会触发扩容。
扩容过程:
分配新内存:
vector会向内存管理器申请一块新的、更大的内存空间。新空间的大小通常是当前容量的某个倍数,这个倍数被称为增长因子(Growth Factor)。在不同的STL实现中,这个因子可能是2(例如在早期的GCC中)、1.5(例如在微软的MSVC中)或其他值。选择大于1的增长因子是为了保证push_back操作的摊还时间复杂度为O(1)。移动或复制元素:将
vector中原有的所有元素从旧的内存区域移动或复制到新的内存区域。C++11及以后,vector会优先使用移动构造函数(Move Constructor)来转移元素,这通常比复制高效得多,因为它只涉及指针的转移而不是深拷贝。如果元素类型没有提供noexcept的移动构造函数,或者根本没有移动构造函数,vector会退而求其次使用复制构造函数(Copy Constructor),以保证强异常安全(如果在复制过程中抛出异常,vector能保持原始状态)。释放旧内存:元素成功转移到新内存后,
vector会释放掉原来那块较小的内存空间。更新内部指针:
vector的内部指针会更新,指向新的内存区域,同时更新其capacity()和size()。
为什么是按比例增长?
如果每次扩容只增加一个固定的常数大小(例如10个元素),那么在连续push_back N个元素的过程中,扩容会越来越频繁。总的元素移动/复制次数将是O(N^2)级别的,导致单次push_back的摊还时间复杂度为O(N),性能很差。
而采用按比例(例如2倍)增长的策略,连续push_back N个元素,总的元素移动/复制次数大约是 1 + 2 + 4 + ... + N/2 = N-1,即O(N)级别。将这个成本分摊到N次push_back操作上,每次操作的摊还时间复杂度就是O(1)。
reserve()和shrink_to_fit()
reserve(n):程序员可以通过调用reserve(n)来主动请求vector至少具有n的容量。如果n大于当前容量,就会触发一次扩容。这在预知将要存储大量元素时非常有用,可以避免多次连续的自动扩容,从而提高性能。shrink_to_fit():在删除大量元素后,vector的容量并不会自动缩小。可以调用shrink_to_fit()来请求vector释放未使用的容量,将capacity()缩减到与size()相同。这是一个非强制性请求,具体实现可以忽略它。
10、里面提到扩容会涉及元素的移动和复制,那么具体是怎么判断移动还是复制的,如果是复制的话是深拷贝还是浅拷贝?
std::vector在扩容时,会尽力使用最高效且最安全的方式来转移元素。具体是移动(move)还是复制(copy),取决于元素类型T的特性。
判断移动还是复制的规则:
C++11标准引入了移动语义,std::vector的扩容逻辑利用了std::move_if_noexcept来做决策。其核心判断逻辑如下:
优先使用移动(Move),条件是:
- 元素类型
T有移动构造函数,并且 - 该移动构造函数被标记为**
noexcept**(即保证在移动过程中不会抛出异常)。
如果上述条件不满足,则退而求其次使用复制(Copy)。
为什么需要noexcept?
这个设计是为了提供强异常安全保证(Strong Exception Guarantee)。考虑一下扩容过程:
- 分配新内存。
- 将元素从旧内存转移到新内存。
- 释放旧内存。
如果在第2步转移元素的过程中(比如,在移动第k个元素时)抛出了一个异常:
如果使用的是复制构造函数:旧内存中的所有元素都还是完好无损的。
vector可以捕获这个异常,然后释放掉新分配的内存,并保持自身状态不变(仍然指向旧内存)。这样,vector仍然是一个有效的、包含原始数据的容器,满足强异常安全。如果使用的是一个可能抛出异常的移动构造函数:当移动第k个元素时抛出异常,此时前k-1个元素已经被“移动”到新内存(它们在旧内存中的状态可能已被破坏),而第k个及以后的元素还在旧内存中。
vector的状态变得一团糟,既不能恢复到原始状态,也不能完成扩容。数据被破坏了,这违反了强异常安全。
因此,为了保证在发生异常时能够回滚到原始状态,vector只在确信移动操作不会抛出任何异常(即移动构造函数是noexcept)时,才会采用移动语义。否则,它会选择更安全的复制语义。
复制是深拷贝还是浅拷贝?
当vector决定使用复制时,它调用的是元素类型T的复制构造函数。这个复制是深拷贝(Deep Copy)还是浅拷贝(Shallow Copy),完全取决于用户如何为类型T实现其复制构造函数。
如果
T是简单类型(如int,double, 指针),或者是一个只包含简单类型成员的类(且使用默认的复制构造函数),那么复制就是按位复制,对于指针来说就是浅拷贝(只复制指针地址,不复制指针指向的内容)。如果
T是一个正确实现了资源管理的类(例如,它内部管理着动态分配的内存,如std::string或一个自定义的类),那么它的复制构造函数必须实现深拷贝。也就是说,它需要为新对象分配自己的内存,并将原始对象所管理资源的内容复制过去。
STL容器(如std::vector)本身并不知道深拷贝和浅拷贝的概念。它只是忠实地调用你为你的类型所定义的复制构造函数。因此,编写一个正确的、遵循“Rule of Three/Five/Zero”的资源管理类是C++程序员的责任。如果一个类需要深拷贝而你没有提供,那么在vector扩容时就会发生浅拷贝,导致悬垂指针、双重释放等严重问题。
11、如果循环里面用迭代器去遍历map的话,什么情况下可能导致迭代器失效?
对于std::map(以及std::set, std::multimap, std::multiset这些基于红黑树的关联容器),其迭代器的稳定性相对较好,但仍然存在失效的可能。
导致std::map迭代器失效的唯一情况是:
- 当指向的那个元素被删除(erased)时,该元素的迭代器会立即失效。
不会导致std::map迭代器失效的操作:
- 插入(
insert)新元素:向map中插入任何数量的新元素,不会导致已有的任何迭代器失效。这是因为map的底层是节点式结构(红黑树),插入新元素只是在树中新增节点并调整指针,不会移动已有的节点。 - 删除(
erase)其他元素:从map中删除一个元素,只会导致指向被删除元素的那个迭代器失效。指向其他所有元素的迭代器都保持有效。
循环遍历中删除元素的正确姿势:
正是因为删除操作会使当前迭代器失效,所以在循环遍历map并根据条件删除元素时,不能使用常规的for循环递增方式,否则在erase之后再对迭代器进行++操作,就是对一个失效的迭代器进行操作,导致未定义行为。
正确的做法有两种:
方法一:使用erase的返回值(C++11及以后)
map::erase(iterator)在C++11后会返回一个指向被删除元素之后元素的有效迭代器。这是最现代、最简洁、最高效的做法。
for (auto it = my_map.begin(); it != my_map.end(); /* no increment here */) {
if (should_delete(*it)) {
it = my_map.erase(it); // erase返回下一个有效迭代器
} else {
++it; // 只有不删除时才递增
}
}方法二:在删除前手动递增迭代器
在调用erase之前,先将迭代器递增并保存起来,然后再用保存的旧迭代器去删除元素。
for (auto it = my_map.begin(); it != my_map.end(); /* no increment here */) {
auto current = it++; // 先递增it,保存旧的迭代器到current
if (should_delete(*current)) {
my_map.erase(current); // 删除旧的迭代器指向的元素
}
}这种方法在C++98/03中是标准做法,但在C++11后,方法一更受欢迎。
与std::vector的迭代器失效对比:
需要特别注意的是,std::map的迭代器失效规则比std::vector宽松得多。对于std::vector:
- 任何导致重新分配内存(扩容)的操作(如
push_back到一个满容量的vector)都会使所有迭代器、指针和引用失效。 - 在不重新分配内存的情况下,向
vector中间插入元素,会导致插入点及之后的所有迭代器失效。 - 从
vector中删除元素,会导致删除点及之后的所有迭代器失效。
因此,在处理不同容器时,必须清楚地了解它们各自的迭代器失效规则,这是编写健壮C++代码的关键。
12、进程和线程的区别?进程的通信方式有哪些?介绍一下
进程和线程的区别:
进程(Process)和线程(Thread)是操作系统中两个核心的概念,它们是并发执行的基本单位,但有着本质的区别。
| 特性 | 进程 (Process) | 线程 (Thread) |
|---|---|---|
| 定义 | 资源分配的基本单位。一个正在执行的程序的实例。 | CPU调度的基本单位。进程内的一条执行路径。 |
| 资源拥有 | 拥有独立的地址空间、内存、文件句柄等系统资源。 | 共享所属进程的地址空间和资源(如内存、文件),但有自己独立的栈、程序计数器和寄存器。 |
| 独立性 | 进程间相互独立,一个进程的崩溃通常不会影响其他进程。 | 同一进程内的线程共享资源,一个线程的崩溃可能会导致整个进程崩溃。 |
| 开销 | 创建、销毁和切换的开销较大,因为涉及独立的地址空间和资源。 | 创建、销毁和切换的开销较小,因为共享大部分资源。 |
| 通信 | 进程间通信(IPC)相对复杂,需要内核介入。 | 线程间通信更简单,可以直接读写共享内存(需要同步机制)。 |
一个形象的比喻:
- 进程就像一个工厂,有自己的厂房(地址空间)、设备(资源)和工人。
- 线程就像工厂里的工人,多个工人在同一个厂房里,共享设备,协同完成生产任务。每个工人有自己的工具箱(栈)和当前要做的工序(程序计数器)。
进程的通信方式(IPC - Inter-Process Communication):
由于进程拥有独立的地址空间,它们之间的通信必须通过操作系统提供的机制来完成。常见的IPC方式有:
管道(Pipes):
- 匿名管道:半双工的,只能在具有亲缘关系(如父子进程)的进程间使用。数据像水流一样单向流动。
- 命名管道(FIFO):可以在不相关的进程之间通信。它以文件系统中的一个特殊文件形式存在。
信号(Signals):
- 一种异步通信机制,用于通知接收进程某个事件已经发生。例如,
kill -9就是发送SIGKILL信号。信号承载的信息量很小。
- 一种异步通信机制,用于通知接收进程某个事件已经发生。例如,
消息队列(Message Queues):
- 一个由内核维护的消息链表。发送方可以向队列中添加消息,接收方可以从队列中获取消息。克服了信号信息量少、管道只能承载无格式字节流的缺点。消息队列是随内核持续的,不随进程的结束而消失。
共享内存(Shared Memory):
- 将同一块物理内存映射到不同进程的虚拟地址空间中,使得这些进程可以像访问普通内存一样直接访问这块共享区域。这是最快的IPC方式,因为它不涉及内核的数据拷贝。但需要用户自行实现同步机制(如互斥锁、信号量)来避免冲突。
信号量(Semaphores):
- 一个计数器,用于控制多个进程对共享资源的访问。它常被用作一种锁机制,实现进程间的同步和互斥。
套接字(Sockets):
- 最通用的IPC机制,不仅可以用于同一台主机上的进程间通信,还可以用于不同主机间的网络通信。提供了面向连接(TCP)和无连接(UDP)两种模式。
13、线程间的同步和互斥怎么解决?
当多个线程共享同一个进程的资源时,为了避免数据竞争(Data Race)和保证程序逻辑的正确性,必须对线程的执行进行协调。这就是线程的同步与互斥。
- 互斥(Mutual Exclusion):保证在任何时刻,只有一个线程可以访问某个共享资源(临界区)。
- 同步(Synchronization):不仅仅是互斥,还要求线程之间按照预定的顺序或规则来执行,以协同完成任务。
解决线程同步和互斥的主要机制有:
互斥量(Mutex):
- 最基本的互斥工具。一个互斥量只有两种状态:锁定(locked)和未锁定(unlocked)。当一个线程需要访问临界区时,它必须先获取(锁定)互斥量。如果互斥量已被其他线程锁定,则该线程会被阻塞,直到互斥量被释放。访问完毕后,线程必须释放(解锁)互斥量。
- C++标准库提供了
std::mutex、std::recursive_mutex(允许同一线程多次锁定)、std::timed_mutex等。 - 通常与
std::lock_guard或std::unique_lock等RAII风格的锁管理类配合使用,以确保锁在任何情况下(包括异常)都能被正确释放。
条件变量(Condition Variable):
- 用于实现线程间的等待和通知。它总是与一个互斥量配合使用。
- 一个线程可以锁定互斥量,然后检查某个条件。如果条件不满足,它可以在条件变量上
wait()。wait()操作会原子性地释放互斥量并阻塞当前线程。其他线程在改变了条件后,可以锁定同一个互斥量,然后通过notify_one()(唤醒一个等待的线程)或notify_all()(唤醒所有等待的线程)来通知等待的线程。 - 非常适合解决“生产者-消费者”这类需要等待特定条件才能继续执行的同步问题。
- C++标准库提供了
std::condition_variable。
信号量(Semaphore):
- 一个非负整数计数器,支持两个原子操作:
wait()(或P、down)和post()(或V、up)。 wait():如果信号量的值大于0,则将其减1;否则,线程阻塞。post():将信号量的值加1,并可能唤醒一个等待的线程。- 可以看作是互斥量的一般化。当信号量初始值为1时,它就等同于一个互斥量。当初始值大于1时,它允许多个线程同时访问一个资源池(最多N个)。
- C++20标准库引入了
std::counting_semaphore。
- 一个非负整数计数器,支持两个原子操作:
原子操作(Atomic Operations):
- 对于一些简单的、单一变量的操作(如计数、标志位设置),使用重量级的锁可能开销过大。原子操作提供了对内建类型(如
int,bool, 指针)的无锁、线程安全访问。 - 这些操作(如
fetch_add,exchange,compare_exchange_strong)由硬件指令保证其原子性,通常比锁更高效。 - C++标准库在
<atomic>头文件中提供了std::atomic模板类。
- 对于一些简单的、单一变量的操作(如计数、标志位设置),使用重量级的锁可能开销过大。原子操作提供了对内建类型(如
读写锁(Reader-Writer Lock):
- 一种更精细的锁,区分了读操作和写操作。允许多个线程同时进行读操作,但只允许一个线程进行写操作,且读和写不能同时进行。
- 适用于“读多写少”的场景,可以显著提高并发性能。
- C++17标准库引入了
std::shared_mutex(配合std::shared_lock用于读,std::unique_lock用于写)。
14、传输层有哪些协议?
传输层位于TCP/IP协议栈的第四层,负责在端到端的应用程序之间提供通信服务。它为上层应用协议(如HTTP, FTP, DNS)隐藏了底层网络(如IP层)的复杂性。最重要和最常见的两个传输层协议是TCP和UDP。
1. TCP (Transmission Control Protocol - 传输控制协议)
TCP是一个面向连接的、可靠的、基于字节流的传输层协议。
- 面向连接 (Connection-Oriented):在数据传输之前,通信双方必须先建立一个连接(通过三次握手)。数据传输结束后,需要断开连接(通过四次挥手)。
- 可靠的 (Reliable):TCP通过多种机制来保证数据的可靠传输:
- 序列号和确认应答 (ACK):发送的每个字节都被编号,接收方通过发送ACK来确认收到的数据。
- 超时重传 (Timeout Retransmission):如果发送方在一定时间内没有收到对某个数据包的确认,它会重新发送该数据包。
- 流量控制 (Flow Control):使用滑动窗口机制,接收方可以告诉发送方自己还有多少缓冲区空间,防止发送方发送过快导致数据丢失。
- 拥塞控制 (Congestion Control):当网络发生拥塞时,TCP会主动减慢发送速率,以缓解网络压力。
- 字节流 (Byte Stream):TCP不关心应用层数据的边界。它将应用层的数据视为一连串无结构的字节流进行传输,可能会发生粘包或拆包问题,需要应用层自行处理。
- 应用场景:适用于对可靠性要求高的应用,如文件传输(FTP)、网页浏览(HTTP/HTTPS)、电子邮件(SMTP)。
2. UDP (User Datagram Protocol - 用户数据报协议)
UDP是一个无连接的、不可靠的、面向数据报的传输层协议。
- 无连接 (Connectionless):发送数据前不需要建立连接,直接将数据报(Datagram)打包并发送出去。每个数据报都是独立的。
- 不可靠 (Unreliable):UDP不提供任何可靠性保证。它不保证数据报会到达目的地,不保证它们的到达顺序,也不保证数据报的完整性。它把这些工作都留给了应用层去处理。
- 面向数据报 (Datagram-Oriented):UDP保留了应用层数据的边界。发送方发送一个数据报,接收方就会收到一个完整的数据报。不会发生粘包或拆包。
- 开销小,速度快:由于没有连接管理、确认、重传等复杂机制,UDP的头部开销小(只有8个字节),处理速度快。
- 应用场景:适用于对实时性要求高、能容忍少量丢包的应用,如在线游戏、视频会议、DNS查询、直播流媒体。
其他传输层协议:
除了TCP和UDP,还有一些其他的传输层协议,虽然不那么常用,但在特定领域有其作用:
- SCTP (Stream Control Transmission Protocol - 流控制传输协议):结合了TCP和UDP的特点,提供了多流、多宿主、部分有序等高级功能,常用于电信信令网络(如SS7)。
- QUIC (Quick UDP Internet Connections):由Google开发,基于UDP,但实现了类似TCP的可靠性、拥塞控制和安全性(内置TLS)。旨在减少连接和传输延迟,是HTTP/3的底层协议。
15、在Linux系统下,有一个client和server,他们要建立连接通信需要调用哪些函数?
在Linux下使用TCP套接字(Socket)实现一个基本的Client-Server连接通信模型,涉及以下一系列系统调用函数。这些函数通常包含在<sys/socket.h>和<netinet/in.h>等头文件中。
服务器端 (Server) 的调用流程:
socket()- 创建套接字int sockfd = socket(AF_INET, SOCK_STREAM, 0);- 创建一个用于网络通信的端点。
AF_INET表示使用IPv4协议,SOCK_STREAM表示使用TCP协议。
bind()- 绑定地址和端口bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));- 将创建的套接字与一个具体的IP地址和端口号关联起来。服务器需要在一个众所周知的端口上监听,以便客户端能够找到它。
listen()- 开始监听listen(sockfd, backlog);- 将套接字设置为被动监听模式,准备接受客户端的连接请求。
backlog参数指定了内核为此套接字维护的已完成连接队列的最大长度。
accept()- 接受连接int new_sockfd = accept(sockfd, (struct sockaddr *)&cli_addr, &clilen);- 这是一个阻塞函数。它会等待客户端的连接请求。当一个连接请求到达时,
accept()会创建一个新的套接字(new_sockfd),这个新套接字专门用于与这个特定的客户端进行通信。原来的监听套接字sockfd则继续保持监听状态,可以接受其他客户端的连接。
read()/recv()和write()/send()- 数据交换read(new_sockfd, buffer, size);write(new_sockfd, buffer, size);- 服务器使用
accept()返回的新套接字new_sockfd与客户端进行读写数据。
close()- 关闭连接close(new_sockfd);- 当与一个客户端的通信结束后,服务器需要关闭这个客户端对应的套接字。
close(sockfd);- 当服务器不再需要监听时,关闭监听套接字。
客户端 (Client) 的调用流程:
socket()- 创建套接字int sockfd = socket(AF_INET, SOCK_STREAM, 0);- 与服务器一样,客户端也需要先创建一个套接字。
connect()- 发起连接connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));- 客户端使用
connect()函数向服务器的IP地址和端口号发起连接请求。这个函数会触发TCP的三次握手。如果连接成功,函数返回0;否则返回-1。
write()/send()和read()/recv()- 数据交换write(sockfd, buffer, size);read(sockfd, buffer, size);- 连接建立后,客户端使用它自己的套接字
sockfd与服务器进行数据交换。
close()- 关闭连接close(sockfd);- 通信结束后,客户端关闭套接字,这会触发TCP的四次挥手来终止连接。
16、new和malloc的区别是什么?
new和malloc都是用于动态内存分配的机制,但它们在C++中有着本质的区别和不同的使用场景。
| 特性 | new | malloc |
|---|---|---|
| 语言 | C++运算符 | C标准库函数 |
| 类型安全 | 是,返回具体类型指针,无需转换 | 否,返回void*,需强制类型转换 |
| 构造/析构 | 调用构造函数和析构函数 | 不调用构造函数和析构函数 |
| 失败处理 | 默认抛出std::bad_alloc异常,或返回nullptr(nothrow) | 返回NULL指针 |
| 可重载 | 是 | 否 |
| 分配对象 | 可分配单个对象或对象数组 | 只能分配原始字节块 |
| 配套释放 | delete / delete[] | free |
在C++编程中,通常推荐使用new和delete来管理动态内存,因为它更符合C++的面向对象特性,能够正确处理对象的生命周期。只有在与C语言代码交互、需要分配原始内存块或进行特殊内存管理时,才考虑使用malloc和free。
17、如果我只有一个2G的物理内存,new一个4G的对象,能实现吗?为什么?
在现代操作系统中,通常情况下是可以实现的,但这涉及到**虚拟内存(Virtual Memory)**的概念。
为什么可以实现?
- 虚拟内存机制:现代操作系统都实现了虚拟内存管理。每个进程都有自己独立的虚拟地址空间(例如,在64位系统上通常是256TB或更多),这个虚拟地址空间远大于实际的物理内存。当程序请求内存时,操作系统分配的是虚拟地址空间,而不是直接分配物理内存。
- 按需分配物理内存:操作系统并不会在
new操作时立即将所有4GB的物理内存分配给进程。它只是在进程的虚拟地址空间中预留了4GB的区域,并建立了虚拟地址到物理地址的映射关系。只有当程序实际访问这些虚拟地址时(例如,对这4GB内存进行读写操作),操作系统才会通过**页错误(Page Fault)机制,将所需的虚拟页面映射到物理内存中的某个页面,或者从磁盘上的交换空间(Swap Space)**中加载数据到物理内存。 - 交换空间(Swap Space):如果物理内存不足以容纳所有活跃的虚拟页面,操作系统会将不常用的物理页面内容暂时存储到硬盘上的交换空间中,从而腾出物理内存供当前活跃的程序使用。当程序再次访问被换出的页面时,操作系统会将其从交换空间重新加载到物理内存。
潜在的问题和限制:
尽管可以new一个大于物理内存的对象,但这并不意味着程序可以高效运行,或者说总是能成功:
- 性能下降:如果程序频繁访问这4GB内存,而物理内存只有2GB,会导致大量的页面置换(Paging/Swapping)操作,即数据在物理内存和硬盘交换空间之间频繁移动。硬盘的读写速度远低于内存,这将导致程序性能急剧下降,系统变得非常缓慢,这种现象被称为“颠簸”(Thrashing)。
- 交换空间限制:如果系统没有足够的交换空间,或者交换空间也耗尽了,那么即使有虚拟内存机制,操作系统也无法为新的内存请求提供支持,此时
new操作可能会失败,抛出std::bad_alloc异常。 - 系统资源耗尽:即使有足够的交换空间,过度的页面置换也会占用大量的CPU时间进行内存管理,影响整个系统的响应速度。
结论:
在有虚拟内存和足够交换空间的现代操作系统中,new一个4GB的对象在2GB物理内存的机器上是可能成功的。然而,这通常会导致严重的性能问题,甚至可能耗尽系统资源导致程序崩溃。因此,在实际开发中,仍然需要关注程序的内存使用量,尽量避免分配远超物理内存的巨大对象,或者优化内存访问模式以减少页面置换。
18、两个线程交替打印字符串,一个打印"1234",一个打印”abcd",要求打印输出为"1a2b3c4d"这种类型?
这是一个经典的线程同步问题,可以使用多种同步机制来实现,例如互斥量(Mutex)和条件变量(Condition Variable),或者信号量(Semaphore)。这里我们使用互斥量和条件变量来实现。
核心思路:
- 共享资源:两个线程需要共享一个打印的“轮次”或“状态”变量,以决定当前哪个线程可以打印。
- 互斥访问:使用互斥量保护共享状态变量和打印操作,确保同一时间只有一个线程能修改状态或进行打印。
- 条件等待/通知:使用条件变量让线程在不满足打印条件时等待,并在满足条件时被另一个线程唤醒。
实现步骤:
- 定义一个互斥量
std::mutex。 - 定义两个条件变量
std::condition_variable,一个用于线程A等待,一个用于线程B等待。 - 定义一个共享的
turn变量(例如int类型),表示当前轮到哪个线程打印(例如0代表线程A,1代表线程B)。 - 线程A在打印前,检查
turn是否为0。如果不是,则在条件变量上等待。如果是,则打印字符,然后将turn设置为1,并通知线程B的条件变量。 - 线程B在打印前,检查
turn是否为1。如果不是,则在条件变量上等待。如果是,则打印字符,然后将turn设置为0,并通知线程A的条件变量。 - 循环这个过程,直到所有字符打印完毕。
下面是C++11及更高版本中,使用std::mutex和std::condition_variable的实现示例:
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
// 共享资源
std::mutex mtx; // 互斥量,保护共享数据
std::condition_variable cv_char; // 条件变量,用于字符线程等待
std::condition_variable cv_num; // 条件变量,用于数字线程等待
int turn = 0; // 0 for num_thread, 1 for char_thread
void num_thread_func(const std::string& s)
{
for (char c : s)
{
std::unique_lock<std::mutex> lock(mtx);
// 等待,直到轮到数字线程打印
cv_num.wait(lock, []{ return turn == 0; });
std::cout << c;
turn = 1; // 切换到字符线程
cv_char.notify_one(); // 通知字符线程
}
}
void char_thread_func(const std::string& s)
{
for (char c : s)
{
std::unique_lock<std::mutex> lock(mtx);
// 等待,直到轮到字符线程打印
cv_char.wait(lock, []{ return turn == 1; });
std::cout << c;
turn = 0; // 切换到数字线程
cv_num.notify_one(); // 通知数字线程
}
}
int main()
{
std::string s1 = "1234";
std::string s2 = "abcd";
std::thread t1(num_thread_func, s1);
std::thread t2(char_thread_func, s2);
t1.join();
t2.join();
std::cout << std::endl; // 打印完后换行
return 0;
}代码解释:
std::mutex mtx;:创建一个互斥量,用于保护对turn变量和std::cout的访问,确保线程安全。std::condition_variable cv_char;和std::condition_variable cv_num;:创建两个条件变量。cv_num用于数字线程等待,cv_char用于字符线程等待。int turn = 0;:一个共享变量,表示当前轮到哪个线程执行。初始值为0,表示数字线程先开始。num_thread_func和char_thread_func:- 每个线程函数都遍历其对应的字符串。
std::unique_lock<std::mutex> lock(mtx);:在进入临界区前获取互斥量的锁。std::unique_lock是RAII(Resource Acquisition Is Initialization)风格的锁,它会在作用域结束时自动释放锁。cv_num.wait(lock, []{ return turn == 0; });(在num_thread_func中):数字线程会在这里等待。wait函数会原子性地释放锁lock,并将当前线程置于等待状态。当turn == 0的条件满足时,或者被notify_one唤醒时,线程会重新尝试获取锁,并在条件满足后继续执行。如果条件不满足,它会继续等待。std::cout << c;:打印当前字符。turn = 1;(在num_thread_func中):将turn设置为1,表示下一轮轮到字符线程。cv_char.notify_one();(在num_thread_func中):通知一个正在cv_char上等待的线程(即字符线程)可以尝试获取锁并检查条件了。char_thread_func的逻辑与num_thread_func类似,只是等待的条件和通知的对象相反。
main函数:- 创建两个
std::thread对象,分别运行num_thread_func和char_thread_func。 t1.join();和t2.join();:等待两个线程执行完毕,确保主线程在子线程结束前不会退出。
- 创建两个
这种机制确保了两个线程能够严格按照预期的顺序交替执行,从而实现“1a2b3c4d”的输出。
