面试真题 | C++小红书-引擎架构
@[toc]
1. 自我介绍
2. 项目
3. c++ 多态,如何实现的,虚表、虚表指针存储位置
在C++中,多态性是一种重要的特性,它允许通过基类指针或引用来调用派生类中的函数。多态性主要分为两种:编译时多态(主要通过函数重载和模板实现)和运行时多态(主要通过虚函数实现)。关于您提到的“C++多态,如何实现的,虚表、虚表指针存储位置”,以下是详细解释和可能的面试官追问。
C++多态的实现机制
虚函数与虚表(Virtual Table, VTable)
- 虚函数:在基类中声明为
virtual
的函数即为虚函数。这告诉编译器该函数的调用应该在运行时决定,而不是在编译时决定。 - 虚表:对于包含虚函数的类,编译器会为这个类创建一个虚表(VTable)。虚表是一个包含函数指针的数组,每个指针对应一个虚函数的实现。
虚表指针(Virtual Table Pointer, VPtr)
- 虚表指针:对于每个包含虚函数的类的对象,编译器都会在其内部添加一个虚表指针(VPtr)。这个指针指向对象的虚表。通过这个指针,我们可以在运行时找到正确的虚函数实现。
虚表指针的存储位置
- 虚表指针的存储位置依赖于编译器的实现,但一般来说,对于单继承的类,虚表指针会被存储在对象的起始位置(或者紧跟在对象布局中的某些成员之后,如为了对齐考虑)。
- 对于多重继承的类,虚表指针的存储会变得更加复杂,因为每个基类可能都有自己的虚表。在这种情况下,每个基类都会在其部分对象内存中放置一个虚表指针,并且对象的实际布局会根据编译器和具体的继承层次而变化。
面试官的深度追问
-
虚析构函数的作用是什么?为什么基类需要虚析构函数?
- 回答:虚析构函数允许通过基类指针删除派生类对象时,能够调用到派生类的析构函数,确保资源的正确释放。如果不将基类的析构函数声明为虚的,当通过基类指针删除派生类对象时,只会调用基类的析构函数,从而导致派生类部分的资源未能被正确释放,引发内存泄漏。
-
纯虚函数和抽象类的概念是什么?
- 回答:纯虚函数是一种特殊的虚函数,它在基类中声明时不给出具体的实现(即使用
= 0
进行声明)。包含至少一个纯虚函数的类被称为抽象类。抽象类不能被实例化,只能作为基类使用,用于实现接口或规范一组派生类的共同行为。
- 回答:纯虚函数是一种特殊的虚函数,它在基类中声明时不给出具体的实现(即使用
-
钻石继承(Diamond Inheritance)问题是什么?如何解决?
- 回答:钻石继承问题是当多个派生类继承自同一个基类,而这些派生类又被另一个类继承时,可能会导致基类部分在派生类中被多次继承。这可能导致数据冗余和/或析构时资源被多次释放。一种解决方案是使用虚继承,虚继承会在所有通过虚路径继承该基类的派生类中共享基类的一个副本。
-
如何调试和观察虚表及其内容?
- 回答:直接观察和修改虚表的内容是不安全的,因为它们是由编译器管理的。然而,一些编译器(如GCC)提供了扩展或工具(如GDB的特定命令),可以用来查看虚表指针和虚表的内容。另外,理解类的布局和虚函数的调用机制对于理解和调试多态性是非常重要的。
4. explicit 关键字
explicit 关键字的回答
explicit
关键字在C++中用于修饰类的构造函数,其目的是防止构造函数在某些情况下被隐式调用,从而避免不期望的类型转换。当构造函数只接受一个参数(或除第一个参数外的其他参数都有默认值)时,它实际上定义了一个隐式转换,这意味着可以用单个参数的值来初始化该类的对象。然而,在某些情况下,这种隐式转换可能不是预期的,它可能会导致代码难以理解和维护,甚至引入bug。通过使用explicit
关键字,可以明确禁止这种隐式转换,要求必须通过显式的构造函数调用来创建对象。
示例:
class String {
public:
explicit String(const char* cstr) {
// 使用cstr初始化String对象
}
// 其他成员函数...
};
int main() {
String s = "Hello"; // 错误,因为String的构造函数被声明为explicit
String s2("Hello"); // 正确,显式调用构造函数
String s3 = String("Hello"); // 正确,通过临时对象显式调用
return 0;
}
面试官可能的追问
-
为什么
explicit
关键字对于单参数构造函数很重要?- 回答:单参数构造函数允许从参数类型到类类型的隐式转换,这可能在某些情况下导致不直观的行为或错误。例如,如果有一个
int
到Complex
(复数)的隐式转换构造函数,那么将int
类型传递给期望Complex
类型参数的函数时,编译器会尝试隐式转换,这可能不是预期的行为。使用explicit
可以避免这种隐式转换,使得代码更清晰、更安全。
- 回答:单参数构造函数允许从参数类型到类类型的隐式转换,这可能在某些情况下导致不直观的行为或错误。例如,如果有一个
-
explicit
关键字可以应用于哪些函数?- 回答:
explicit
关键字只能应用于类的构造函数。它不能用于其他成员函数、析构函数或转换运算符等。
- 回答:
-
在模板编程中,
explicit
关键字的使用有什么特别之处吗?- 回答:在模板编程中,
explicit
关键字的使用与普通类相同。然而,由于模板的泛型性质,有时候在模板类中定义的构造函数是否需要explicit
可能不那么直观。特别是在模板特化和实例化时,需要注意explicit
的应用,以确保类型安全和代码的意图得以保持。
- 回答:在模板编程中,
-
有没有场景是推荐使用隐式构造函数转换的?
- 回答:虽然
explicit
关键字是出于防止不期望的类型转换的目的而引入的,但在某些情况下,隐式构造函数转换是有用的。例如,当你希望你的类能够无缝地与标准库容器(如std::vector
)一起工作,并能够自动从其他类型(如int
)转换而来时,隐式构造函数转换可能是可取的。然而,这种决策需要谨慎,因为它可能会牺牲类型安全和代码清晰度。
- 回答:虽然
-
除了
explicit
,还有哪些C++特性可以帮助避免不期望的类型转换?- 回答:除了
explicit
关键字,C++还提供了其他几种机制来避免不期望的类型转换,包括删除构造函数(C++11及以后版本)、限制模板实例化(通过SFINAE、std::enable_if
等技术)、以及使用静态断言(static_assert
)来在编译时检查类型条件等。这些特性共同为C++程序员提供了强大的工具,以编写更安全、更易于维护的代码。
- 回答:除了
5. unique_ptr、shared_ptr、weak_ptr的原理,有没有线程安全问题,weak_ptr的解决了什么问题?可以用裸指针吗?会有什么问题
回答
unique_ptr
原理:unique_ptr
是 C++11 引入的一种智能指针,用于自动管理动态分配的内存。它采用独占所有权模型,即同一时间内只有一个 unique_ptr
可以指向给定的对象。当 unique_ptr
被销毁时(例如,离开作用域),它所指向的对象也会被自动删除。这通过 unique_ptr
的析构函数实现,其中调用了 delete
操作符。
线程安全:unique_ptr
本身在单个线程中是安全的,但如果你在多线程环境中共享 unique_ptr
的所有权(即尝试跨线程传递或复制 unique_ptr
),这将导致未定义行为,因为 unique_ptr
不支持复制(但支持移动)。因此,在多线程中安全使用 unique_ptr
需要确保不会同时有多个线程访问或修改同一个 unique_ptr
实例。
shared_ptr
原理:shared_ptr
是另一种智能指针,用于实现多个 shared_ptr
实例共享同一个对象的所有权。它通过内部的控制块(通常是一个包含计数器和指向对象的指针的结构)来管理对象的生命周期。每当一个新的 shared_ptr
被创建并指向对象时,控制块中的计数器就会递增;每当一个 shared_ptr
被销毁或重置时,计数器就会递减。当计数器减至零时,对象被删除。
线程安全:shared_ptr
的操作(如创建、销毁、赋值等)在多线程环境下通常不是原子操作,因此直接对 shared_ptr
的共享访问可能导致竞态条件。但是,C++11 之后的标准库实现通常提供了对 shared_ptr
的线程安全支持,主要是保证了对控制块中计数器的原子操作。然而,这并不意味着使用 shared_ptr
指向的对象本身是线程安全的;对共享对象的访问仍然需要适当的同步机制。
weak_ptr
解决的问题:weak_ptr
是为了解决 shared_ptr
之间的循环引用问题而设计的。循环引用发生时,两个或多个 shared_ptr
相互指向对方,导致它们的计数器永远不会降到零,从而内存无法被释放。weak_ptr
可以观察 shared_ptr
管理的对象,但它不拥有对象的所有权,也不会增加对象的共享计数。因此,它可以安全地用于打破循环引用,同时允许访问共享对象(通过 lock()
方法尝试获取 shared_ptr
)。
线程安全:与 shared_ptr
类似,weak_ptr
的操作(如 lock()
)在多线程环境下也是线程安全的,但同样需要确保对共享对象的访问是同步的。
可以用裸指针吗?
可以,但使用裸指针管理动态内存需要程序员手动管理内存的生命周期,这容易导致内存泄漏、重复释放等问题。在 C++ 中,推荐使用智能指针(如 unique_ptr
、shared_ptr
)来自动管理内存,以减少内存管理错误。
面试官追问
-
unique_ptr 和 shared_ptr 在性能上有什么区别?
- 回答可以涉及内存分配和释放的开销、控制块的存在与否等。
-
如何在多线程中安全地共享 shared_ptr 指向的对象?
- 可以讨论使用互斥锁(如
std::mutex
)、读写锁(如std::shared_mutex
)或其他同步机制来保护对共享对象的访问。
- 可以讨论使用互斥锁(如
-
weak_ptr 的
lock()
方法在什么情况下会返回空的 shared_ptr?- 当
weak_ptr
指向的shared_ptr
已经被销毁,即其管理的对象已经被删除时,lock()
会返回一个空的shared_ptr
。
- 当
-
有没有其他方式可以解决 shared_ptr 的循环引用问题?
- 可以提及使用弱引用(即
weak_ptr
)是标准推荐的解决方式,但也可以讨论其他非标准的方法,如重新设计类的结构以避免循环引用。
- 可以提及使用弱引用(即
-
在嵌入式系统中,为什么可能需要避免使用 shared_ptr?
- 可以讨论嵌入式系统对资源(如内存和处理器时间)的严格限制,以及
shared_ptr
带来的额外开销(如控制块和原子操作)可能不适合资源受限的环境。
- 可以讨论嵌入式系统对资源(如内存和处理器时间)的严格限制,以及
6. 介绍B树和B+树
介绍B树和B+树
B树(B-Tree)
B树是一种自平衡的多路搜索树,主要用于数据库和文件系统的索引结构。它的主要特点包括:
- 节点结构:B树的每个节点可以包含多个关键字(键值)和子节点指针。节点中的关键字按升序排列,且每个关键字对应一个子树的范围。
- 平衡性:B树的所有叶子节点都位于同一层级,确保树的平衡性。这意味着从根节点到每个叶子节点的路径长度都相同。
- 节点键值范围:每个节点的关键字数量在一个特定的范围内,通常为[t-1, 2t-1],其中t是B树的阶(或称为最小度数)。这确保了树的效率并限制了节点分裂的频率。
- 查找:查找操作从根节点开始,逐层向下比较关键字,直到找到目标键或到达叶子节点。
- 插入与删除:插入和删除操作可能导致节点的分裂或合并,以保持树的平衡。如果节点的关键字数量超过上限,节点会分裂成两个节点,并将中间的关键字提升到父节点。相反,如果节点的关键字数量低于下限,可能需要与兄弟节点合并或从父节点借关键字。
B+树(B+ Tree)
B+树是B树的一种变体,主要优化了对范围查询的支持,其特点包括:
- 数据存储:B+树的所有实际数据(或指向数据的指针)都存储在叶子节点中,内部节点仅存储关键字(或称为索引)和子节点指针,不包含实际数据记录。
- 叶子节点链表:B+树的叶子节点通过链表连接,形成一个有序链表,这支持了高效的范围查询。
- 查找:查找操作同样从根节点开始,逐层向下比较关键字,但最终在叶子节点中找到目标数据。
- 插入与删除:插入和删除操作主要涉及叶子节点,内部节点的结构在插入和删除过程中保持相对稳定。插入操作可能导致叶子节点的分裂,而删除操作可能导致叶子节点的合并或重新分配。
面试官追问环节
-
B树和B+树在数据结构上的主要区别是什么?
- 答:B树的每个节点都包含关键字和数据(或指向数据的指针),而B+树的所有数据都存储在叶子节点中,内部节点仅包含关键字和子节点指针。
-
为什么B+树更适合用作数据库索引结构?
- 答:B+树通过将所有数据存储在叶子节点并通过链表连接,提供了更高效的范围查询性能。此外,内部节点不存储数据,使得非叶子节点可以包含更多的关键字,进而降低了树的高度,提高了查询效率。
-
B树和B+树在插入和删除操作上有什么不同之处?
- 答:B树和B+树在插入和删除操作上的主要区别在于操作的节点类型。B树的插入和删除可能涉及非叶子节点和叶子节点的分裂与合并,而B+树的插入和删除通常只在叶子节点上进行,内部节点的结构相对更稳定。
-
如何计算B树和B+树的高度?
- 答:B树和B+树的高度通常取决于节点的关键字数量和树的阶(t)。由于每个节点可以存储多个关键字,B树和B+树的高度相对较低,一般可以通过对数公式来估算,如高度约为log_t(N),其中N是节点总数,t是树的阶。
-
在实际应用中,如何选择合适的B树或B+树阶数?
- 答:选择合适的B树或B+树阶数通常取决于存储系统的特性,如磁盘块大小、内存限制和查询性能要求。较大的阶数可以减少树的高度,但可能导致节点分裂和合并的频率增加。因此,需要根据实际应用场景来权衡这些因素,选择最优的阶数。
7. 介绍unordered_map、map,区别,应用场景
面试问题回答
介绍unordered_map和map
unordered_map:
unordered_map是C++标准模板库(STL)中的一个无序关联容器,用于存储键值对。它的实现基于哈希表(Hash Table),通过哈希函数将键映射到表中的位置,从而实现快速的查找、插入和删除操作。由于它是无序的,因此不保证元素按照任何特定的顺序存储或迭代。unordered_map提供平均常数时间复杂度的查找、插入和删除操作(O(1)),但在极端情况下(如哈希冲突严重时),这些操作的时间复杂度可能退化到O(n)。
map:
map同样是C++ STL中的一个关联容器,也用于存储键值对,但其内部实现基于红黑树(Red-Black Tree)。红黑树是一种自平衡的二叉搜索树,它保持了元素的排序,使得map中的元素总是按键的顺序进行排序。因此,map提供了稳定的对数时间复杂度的查找、插入和删除操作(O(log n)),其中n是元素数量。由于红黑树的复杂性和额外的存储开销(如节点颜色、父节点指针等),map在内存使用上通常比unordered_map要高。
区别:
有序性 | 无序 | 有序(按键排序) |
内部实现 | 哈希表 | 红黑树 |
查找、插入、删除平均时间复杂度 | O(1) | O(log n) |
最坏情况时间复杂度 | O(n)(哈希冲突严重时) | O(log n) |
内存使用 | 通常较低 | 较高(由于红黑树的额外开销) |
适用场景 | 快速查找,不关心元素顺序 | 需要元素有序,或按顺序迭代 |
应用场景:
- unordered_map:适用于需要快速查找键值对的场景,且不关心元素存储顺序的情况。例如,在缓存系统、计数器或实现字典功能时,unordered_map是一个很好的选择。
- map:适用于需要保持元素有序,或需要按照键的顺序进行迭代操作的场景。例如,在实现索引结构、排序后的数据集合或需要根据键的顺序进行遍历的算法时,map更为合适。
面试官追问
-
追问unordered_map:
- 哈希冲突是如何解决的?
- 哈希冲突通常通过开放寻址法(如线性探测、二次探测等)或链地址法(每个哈希表项指向一个链表)来解决。unordered_map通常使用链地址法,即当发生哈希冲突时,将元素存储在同一个哈希值对应的链表中。
- 哈希冲突是如何解决的?
-
追问map:
- 红黑树相比其他平衡二叉树(如AVL树)的优势是什么?
- 红黑树在保持平衡的同时,插入和删除操作的效率相对较高。与AVL树相比,红黑树在插入和删除时进行的旋转操作次数较少,因为AVL树要求每个节点的左右子树高度差不超过1,而红黑树则允许这种高度差达到2倍,从而减少了调整树的平衡所需的开销。
- 红黑树相比其他平衡二叉树(如AVL树)的优势是什么?
-
综合问题:
- 在哪些情况下你会优先考虑使用unordered_map而不是map?
- 当我对元素的顺序没有严格要求,但需要频繁进行查找、插入和删除操作时,我会优先考虑使用unordered_map。这是因为unordered_map在这些操作上提供了更好的性能,特别是在处理大量数据时。
- 在哪些情况下你会优先考虑使用unordered_map而不是map?
-
技术深度问题:
- unordered_map的负载因子(Load Factor)是什么?它如何影响性能?
- 负载因子是unordered_map中已存储的元素数与哈希表容量(即桶的数量)的比值。较低的负载因子可以减少哈希冲突的概率,但会增加哈希表的内存占用。较高的负载因子则可能增加哈希冲突,降低查找、插入和删除操作的效率。因此,在实际应用中,需要根据具体情况调整负载因子以达到性能和内存使用的平衡。
- unordered_map的负载因子(Load Factor)是什么?它如何影响性能?
8. c++ 11 以来有哪些新特性,标准库增加了什么新功能
C++11以来的新特性及标准库新增功能
C++11是C++标准的一个重要更新,它引入了许多新特性和对标准库的扩展,显著提升了C++的表达能力和编程体验。以下是一些主要的新特性和标准库新增功能:
C++11新特性
-
自动类型推断(auto):
- 使用
auto
关键字可以让编译器根据变量的初始化表达式自动推导其类型,简化了模板编程和复杂表达式的类型声明。
- 使用
-
Lambda表达式:
- 允许定义匿名函数对象,可以捕获外部变量,使代码更加简洁且易于理解,特别适合在需要函数对象的地方使用。
-
范围for循环(Range-based for loop):
- 提供了一种简洁的遍历容器和数组的方法,无需使用迭代器,增强了代码的可读性。
-
智能指针:
- 引入了
std::unique_ptr
、std::shared_ptr
和std::weak_ptr
等智能指针,用于自动管理动态分配的内存,避免了内存泄漏和悬空指针的问题。
- 引入了
-
右值引用和移动语义:
- 允许通过移动操作代替复制操作,提高了资源利用效率,特别适用于资源管理和性能敏感的应用。
-
静态断言(static_assert):
- 提供了一种在编译时检查条件是否为真的机制,有助于及早发现错误。
-
constexpr函数:
- 允许编译器在编译时计算函数的返回值,提高了代码的执行效率。
-
多线程支持:
- 引入了
std::thread
、std::mutex
等线程支持库,使得并发编程更加容易实现。
- 引入了
标准库新增功能
-
新的容器:
- 引入了
std::array
、std::unordered_map
等新的容器类型,提供了更加灵活和高效的数据结构。
- 引入了
-
正则表达式库:
- 提供了一套完整的正则表达式处理工具,简化了文本处理任务,如字符串搜索、替换和验证等。
-
原子操作和无锁编程:
- 提供了原子类型和原子操作,支持无锁编程模式,提高了多线程程序的性能和安全性。
-
统一初始化(Uniform Initialization):
- 引入了花括号
{}
进行列表初始化,使得对象的初始化更加一致和简洁。
- 引入了花括号
-
std::initializer_list:
- 提供了一种方便的方式来处理数量不定的同类型对象的初始化列表,常用于构造函数和函数参数。
面试官追问
-
Lambda表达式的捕获列表是如何工作的?
- Lambda表达式可以通过捕获列表捕获外部变量,捕获方式包括值捕获和引用捕获。值捕获会复制变量的值到Lambda表达式内部,而引用捕获则会在Lambda表达式内部保持对外部变量的引用。
-
智能指针
std::unique_ptr
和std::shared_ptr
的主要区别是什么?std::unique_ptr
是一种独占式智能指针,它保证同一时间内只有一个std::unique_ptr
指向某个对象(通过禁用拷贝构造函数和拷贝赋值运算符),适用于独占资源的场景。而std::shared_ptr
是一种共享式智能指针,多个std::shared_ptr
可以指向同一个对象,并通过引用计数来管理对象的生命周期,当最后一个std::shared_ptr
被销毁时,对象也会被自动删除。
-
如何在多线程环境下安全地访问共享数据?
- 在多线程环境下,可以使用
std::mutex
(互斥锁)来同步对共享数据的访问,确保同一时间只有一个线程可以访问数据。此外,还可以使用条件变量(std::condition_variable
)来在线程间进行事件通信,实现复杂的同步逻辑。
- 在多线程环境下,可以使用
-
右值引用和移动语义在实际编程中有哪些应用场景?
- 右值引用和移动语义特别适用于资源管理、性能敏感的应用以及需要频繁移动大型对象(如容器、字符串等)的场景。通过移动语义,可以避免不必要的复制操作,提高资源利用效率。例如,在STL容器的赋值、插入和交换操作中,都会使用到移动语义来优化性能。
9. 写一个右值引用的场景
在嵌入式C++开发中,右值引用(Rvalue Reference,表示为T&&
)主要用于支持移动语义(Move Semantics)和完美转发(Perfect Forwarding),这可以显著提高性能和资源利用率,尤其是在涉及大量资源密集型对象(如大型数据结构、动态分配的内存等)的传递和返回时。
右值引用场景示例
假设我们正在开发一个嵌入式系统,该系统需要处理大量的图像数据。每个图像对象都包含了一个指向动态分配内存区域的指针,用于存储图像的像素数据。在图像处理过程中,我们经常需要创建一个新的图像对象作为处理结果,并将原始图像的数据移动到新对象中,以避免不必要的内存复制。
#include <memory>
#include <iostream>
class Image {
public:
Image(size_t width, size_t height)
: width_(width), height_(height), data_(new unsigned char[width * height]) {}
// 移动构造函数
Image(Image&& other) noexcept
: width_(other.width_), height_(other.height_), data_(other.data_) {
other.data_ = nullptr; // 防止原始对象析构时释放内存
other.width_ = 0;
other.height_ = 0;
}
// 移动赋值运算符
Image& operator=(Image&& other) noexcept {
if (this != &other) {
delete[] data_;
width_ = other.width_;
height_ = other.height_;
data_ = other.data_;
other.data_ = nullptr;
other.width_ = 0;
other.height_ = 0;
}
return *this;
}
~Image() {
delete[] data_;
}
// 其他成员函数...
private:
size_t width_, height_;
unsigned char* data_;
};
// 使用场景:函数返回局部对象,触发移动语义
Image processImage(Image img) {
// 对img进行一些处理...
return img; // 这里返回的是img的右值引用,将调用移动构造函数
}
int main() {
Image original(1920, 1080); // 创建一个图像对象
Image processed = processImage(std::move(original)); // 使用std::move明确表示我们想要移动original
// 此时original对象不再拥有图像数据,其data_指针为nullptr
return 0;
}
面试官追问
-
为什么在这个场景中你选择了移动构造函数而不是拷贝构造函数?
- 回答:在这个场景中,我选择了移动构造函数是因为我们希望避免对大型图像数据的复制,从而减少内存分配和释放的开销,以及数据复制的时间成本。移动构造函数允许我们将原始对象的资源(如动态分配的内存)直接“窃取”到新对象中,而无需复制。
-
解释一下
std::move
的作用,以及为什么在这里使用它?- 回答:
std::move
是一个类型转换模板,它将对象的左值引用转换为右值引用。在这个场景中,original
是一个左值对象,正常情况下,如果我们将它传递给processImage
函数,那么将调用拷贝构造函数。然而,通过std::move(original)
,我们告诉编译器我们想要将original
视为一个右值,从而触发移动构造函数。这是因为我们知道在调用processImage
之后,original
对象不再需要保留原始数据。
- 回答:
-
如果
Image
类没有提供移动构造函数,会发生什么?- 回答:如果类没有提供移动构造函数,那么在上述场景中,当函数返回时,将调用拷贝构造函数来复制对象。这将导致图像数据的完整复制,从而增加了内存分配和复制的开销,降低了性
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
【C/C++面试必考必会】专栏,直击面试核心,精选C/C++及相关技术栈中面试官最爱的必考点!从基础语法到高级特性,从内存管理到多线程编程,再到算法与数据结构深度剖析,一网打尽。助你快速构建知识体系,轻松应对技术挑战。希望专栏能让你在面试中脱颖而出,成为技术岗的抢手人才。