在C++面试中,智能指针、多态、虚函数及STL原理是贯穿各职级的核心考点,直接决定面试官对候选人基础功底与工程能力的评判。这四大知识点紧密关联,覆盖内存管理、面向对象核心及标准库底层逻辑,是区分“会用”与“精通”C++的关键。
智能指针是内存安全的核心工具,其底层实现、循环引用问题及unique_ptr、shared_ptr、weak_ptr的适用场景,是面试必问内容,直接体现候选人对内存泄漏的防控意识。多态与虚函数相辅相成,虚函数表机制、动态绑定原理及纯虚函数设计意义,是理解面向对象三大特性的核心,也是架构设计面试的高频切入点。
STL原理聚焦vector、list、map等容器的底层结构、迭代器实现及算法效率,反映候选人的性能优化认知。掌握这四大考点,既能应对基础问答,也能在项目场景题中展现严谨逻辑,为面试加分。下面我们逐一拆解各考点核心与高频提问方向。
一、智能指针实现原理
在C98里标准库提供一个std::auto_ptr的实现,以应对C++需要程序员自己管理内存资源广泛存在的问题,诸如野指针,内存泄漏,内存重复释放等令人困扰的问题。
对于智能指针基本的几个需求:
- 自动析构。这是最核心的特征,紧随其后的
unique_ptr, share_ptr 这些进阶版的指针封装类型无不立足于此
- 具有基本指针类型的行为特征,引用和解引用,运算符
*, ->的支持
// your auto ptr
auto_ptr p(type);
(*p).func();
p->func(); //支持 -> 操作符
对于第一个特征,基本的实现:
template <class T>
class MyAutoPtr
{
public:
MyAutoPtr(T *ptr): _ptr(ptr) {
}
~MyAutoPtr() {
delete _ptr;
}
T *_ptr;
};
Use:
MyAutoPtr<int> p(new int(5)); //使用动态申请的内存可以做到自动释放,因为p是一个栈变量,其析构函数会自动调用
std::cout << *p._ptr << std::endl;
// 但是我们要操作p的数据,不得不暴露原始的数据 _ptr,
// 以上的设计聊胜于无,我们一旦不小心 delete p._ptr就会发生灾难,析构函数会犯重复释放内存的错误。
使用动态申请的内存可以做到自动释放,因为p是一个栈变量,其析构函数会自动调用,但是我们要操作p的数据,不得不暴露原始的数据 _ptr。为了让智能指针的使用变得可行,我们必须隐藏 _ptr 成员变量,并且提供接口。同时利用运算符重载使得智能指针的使用变得和基本的指针行为看起来是一致的;因此需要重载 ->和 * 运算符。
第二版实现
template <class T>
class MyAutoPtr
{
public:
MyAutoPtr(T *ptr): _ptr(ptr) {
}
~MyAutoPtr() {
delete _ptr;
}
T operator *() {
return *get();
}
T * operator -> (){
return get();
}
T * get() {
return _ptr;
}
private:
T *_ptr;
};
除此以外,我们还需要考虑指针的复制,赋值这些行为的合理性。
为此我们为智能指针类再增加两个构造函数:
MyAutoPtr(MyAutoPtr &other) ;
MyAutoPtr& operator = (MyAutoPtr & right) ;
智能指针将会出现下面这些用法:
MyAuotPtr<Data> p(new Data(100, 200));
p = new Data(10,20); // (1)
MyAuotPtr<Data> q(p); // (2)
第 (1) 行中,p丢弃了原来指向的内存,指向一块新的内存,这时候,必须将原指针指向的内存清理。第(2)行根据 p复制一个q出来,两个指针指向同一片内存,如果不加处理,析构时必然会导致重复释放内存的问题。

为了避免这两种情况,复制构造函数和赋值重载函数需要小心实现。
MyAutoPtr(MyAutoPtr &other) {
T* tmp_p = _ptr;
_ptr = nullptr;
tmp_p = right.get(); // 交出控制权
return *this;
}
MyAutoPtr& operator = (MyAutoPtr & right) {
if (right->get() != _ptr)
delete _ptr;
_ptr = right.get();
}
在std标准库中的实现,对于上面两种情形都抽象了两个过程:release当前对象将内存的控制权交出,避免内存重复释放,reset则是重置指针的指向,对于原来的内存块则会删掉。这里的实现,其实就是C++11移动语义的雏形,隐含了一种资源转移的概念。
T* release();
void reset(T * _Ptr = 0);
智能指针的缺陷
了解了auto_ptr的实现,它的缺陷也是很明显了。
首先,智能指针被复制或用来作为副本创建另一个智能指针时,会存在潜在的风险。
std::auto_ptr<Data> p(new Data(100, 200));
std::auto_ptr<Data> q = p;
std::cout << p->a << std::endl; //错误,p不能再使用了, p._ptr 被置0了
std::cout << q->b << std::endl;
因为auto_ptr清理内存使用操作符 delete(也只能如此,不能推导模板类型),对于一些需要 delete [] 的指针是无能为力的。它不能用来装载数组指针。
auto_ptr不能用于标准库的容器元素,为什么?这是因为auto_ptr十分特殊的复制行为,标准库的容器元素复制一般是一个深拷贝,它们通常有自己的析构函数,auto_ptr发生复制的时候,会毁掉另一个。
由于以上的缺陷,C++11已经明确禁止使用 auto_ptr 来管理指针,取而代之的是unique_ptr, shared_ptr。
C++11在智能指针方面做了哪些改进?
auto_ptr的缺陷主要体现在赋值和拷贝构造资源转移带来的缺陷,C++11针对这两个问题,分别推出了两个进化版的智能指针 unique_ptr, shared_ptr 。从功能上来说几乎和auto_ptr没有太多区别,它们都能自动释放堆内存,可以像基础指针一样使用。区别在于:
unique_ptr 如其名,它禁用了拷贝和赋值函数,程序员需要明确这个 unique_ptr 指针自诞生就不可以和其它指针分享所指对象的内存。
unique_ptr(const _Myt&) = delete;
_Myt& operator=(const _Myt&) = delete;
unique_ptr实现中禁用了拷贝和赋值构造函数。
std::unique<int> q(new int(10);
std::unique<int> p = q; // 错误,无法复制
std::unique<int> p(q); //错误
但是 unique_ptr 仍然可以通过别的方式 转移它对内存资源的所有权,使用 std::move函数,这里和提供赋值拷贝构造函数返回一个左值有一些微妙的差别。
shared_ptr 允许多个指针共同拥有同一块内存。实现上,它和auto_ptr有些不一样。shared_ptr通过引用计数的技术实现,对象复制时,仅仅是将引用计数加1,发生内存拷贝时将引用减少,析构函数需要判断并维护引用计数,降到0时将它的内存清理掉。
二、智能指针,里面的计数器何时会改变
智能指针是一种用于管理动态分配的内存的指针,其中的计数器通常在拷贝构造函数和赋值运算符中会发生改变。在智能指针的实现原理中,通常会使用一个计数器来记录有多少个指针指向了同一个动态内存区域。当一个新的智能指针被创建时,计数器会加1;当一个智能指针被销毁时,计数器会减1。当计数器减为0时,表示没有指针指向该内存区域,此时会自动释放该内存,防止内存泄漏。
- 初始化:当一个新的智能指针对象被创建时,计数器会被初始化为1。
- 复制构造函数:当使用现有智能指针初始化另一个智能指针或进行复制构造函数操作时,计数器会增加。
- 赋值运算符重载:当将一个智能指针赋值给另一个已经存在的智能指针时,计数器会相应地递增或递减。
- 释放资源:当最后一个引用到达其生命周期的尽头时,即没有任何智能指针引用该资源时,计数器会减少。一旦计数器减少到0,智能指针将自动释放所管理的资源。
三、智能指针和管理的对象分别在哪个区
智能指针本身通常在栈区,托管的资源(被智能指针管理的对象)在堆区。
智能指针和管理的对象在不同的内存区域。智能指针本身通常是在栈上创建的,即它们属于自动存储区。这意味着当离开作用域时,智能指针会自动被销毁,并释放其占用的内存。
而由智能指针所管理的对象通常位于堆(heap)或自由存储区(free store)。智能指针通过使用new操作符来分配内存并初始化对象,并在适当的时机使用delete或delete[]操作符来释放对象和对应的内存空间。
在C++11标准中引入了智能指针类,它们分别是std::shared_ptr、std::unique_ptr和std::weak_ptr。其中,std::shared_ptr是一种共享所有权的智能指针,可以被多个指针共享,内部使用引用计数来管理资源的释放。std::unique_ptr是一种独占所有权的智能指针,不能被复制,只能通过移动来转移所有权,从而避免了资源的多重所有权问题。std::weak_ptr是一种弱引用的智能指针,用于解决循环引用导致的内存泄漏问题。
四、面向对象的特性:多态原理
多态是面向对象编程的一个重要特性,它允许使用基类的指针或引用来调用派生类对象的方法,实现了动态绑定。
多态的原理可以通过虚函数和动态绑定来解释。在基类中,可以将某个成员函数声明为虚函数,在派生类中进行覆盖(override)。当使用基类的指针或引用调用这个虚函数时,实际上会根据运行时对象的类型来确定应该调用哪个版本的函数。这种动态绑定发生在运行时阶段而不是编译时阶段,因此称为动态多态。这使得程序能够根据具体情况灵活地选择执行适当的函数实现。
五、介绍一下虚函数,虚函数怎么实现的
虚函数是在基类中声明的函数,用关键字virtual进行修饰。它允许派生类对该函数进行覆盖(重写)。虚函数的实现依赖于虚函数表(vtable)和虚指针(vptr)。
当一个类包含至少一个虚函数时,编译器会在对象中添加一个隐藏的指针,即虚指针。这个指针指向一个称为虚函数表的数据结构。
虚函数表是一个存储了各个虚函数地址的数组。每个对象都有自己的虚函数表。在基类中,这个表会按照定义顺序填充相应的虚函数地址。而在派生类中,如果某个方法被声明为虚函数并进行了覆盖,则该方法的地址会替代相应位置上原来基类的方法地址。
当通过基类指针或引用调用某个对象的虚函数时,程序会根据对象的实际类型去查找相应的地址,并调用正确版本的方法。下面是一个简单示例:
class Base {
public:
virtual void display() {
cout << "This is the base class." << endl;
}
};
class Derived : public Base {
public:
void display() override { // 覆盖基类中的display()方法
cout << "This is the derived class." << endl;
}
};
int main() {
Base* ptr = new Derived();
ptr->display(); // 调用派生类中的display()方法
delete ptr;
return 0;
}
在上述示例中,基类Base声明了虚函数display()并在派生类Derived中进行了覆盖。通过使用基类指针ptr,我们可以调用派生类中的虚函数display()。程序会动态绑定该方法,并输出"This is the derived class."。
这就是虚函数实现多态性的基本原理。它使得程序能够根据对象的实际类型来调用正确版本的函数,提供了灵活性和可扩展性。
六、多态和继承在什么情况下使用
对于继承来说,通常在需要创建一个新的类,并且这个类拥有某个已有类的属性和方法的基础上使用。通过继承,子类可以继承父类的属性和方法,同时还可以根据需要添加新的属性和方法。这样可以实现代码重用,提高代码的可维护性和扩展性。
继承的关键在于子类与父类之间的关系,子类可以继承父类的公共属性和方法,同时可以通过覆盖父类的方法来实现特定功能。而多态则是指相同的方法在不同的对象上有不同的表现形式。在实现多态的过程中,通常会使用继承和接口。多态可以提高代码的灵活性和可扩展性,使得程序更容易扩展和维护。通过多态,可以实现方法的重写和重载,不同的对象可以调用相同的方法但表现出不同的行为。
- 实现代码的重用:通过继承,可以将通用的属性和行为从基类派生到子类中,避免重复编写相同的代码。
- 处理不同类型对象的统一接口:通过定义基类和派生类之间的虚函数,并使用基类指针或引用来调用这些函数,实现了多态性。这使得我们可以以统一的方式处理不同类型的对象。
- 简化代码逻辑和提高可扩展性:通过使用继承和多态,可以将复杂的系统分解为更小、更可管理的模块,降低代码耦合度,并且便于在将来添加新功能或修改现有功能。
- 实现抽象概念和层次结构:通过使用继承,可以创建一个抽象基类,定义共享特征和行为,并在派生类中实现具体细节。这样可以建立起一个层次结构,在上层提供抽象概念,在下层提供具体实现。
七、除了多态和继承还有什么面向对象方法
另外一个面向对象方法是封装。封装是面向对象编程的三大特性之一,它指的是将数据和操作数据的方法捆绑在一起,形成一个整体(类),并对外部隐藏对象的内部细节,只提供有限的接口与外部交互。封装可以有效地保护对象的数据,防止外部直接访问和修改,从而提高代码的安全性和可维护性。
在面向对象编程中,封装通过访问修饰符(如private、protected、public)来实现。私有(private)成员变量和方法只能在类的内部访问,外部无法直接访问,只能通过类提供的公共(public)接口来操作。这样可以控制对象的状态和行为,避免不合理的操作。
封装还可以使对象的内部实现细节与外部接口分离,当对象的内部实现发生变化时,外部代码无需修改,只需要调整公共接口的实现即可。这有利于提高代码的灵活性和可维护性。
- 封装:封装是将数据和操作封装在一个对象中,隐藏内部实现细节,通过公共接口与外部进行交互。这样可以提高代码的安全性和可维护性。
- 抽象:通过定义抽象类或接口,将通用的属性和行为抽象出来,使得对象能够以更高层次的概念进行理解和设计。抽象可以帮助我们建立模型,并且支持代码重用和拓展。
- 组合:组合是指在一个类中使用其他类的对象作为其成员变量。通过组合关系,可以构建更复杂的对象结构,并且可以方便地使用已有类的功能。
- 接口:接口定义了一个协议或契约,规定了某个类应该具备哪些方法或属性。通过实现接口,不同的类可以提供相同的功能并满足相同的协议。
- 单一职责原则(SRP):每个类应该只负责一项职责或功能。这样可以提高代码的可读性、可维护性和可测试性。
- 开放-封闭原则(OCP):软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这样可以避免修改现有代码,而是通过扩展来添加新功能。
- 依赖倒置原则(DIP):高层模块不应该直接依赖于低层模块,而是依赖于抽象接口。这样可以实现松耦合,并且支持代码的可测试性和可维护性。
八、C++内存分布。什么样的数据在栈区,什么样的在堆区
在C++中,栈区主要存储函数的参数值、局部变量、函数的返回地址等数据,堆区主要用于动态内存分配。栈区和堆区是程序运行时分配内存的两种不同方式。
栈区(Stack)主要用于存储局部变量和函数调用的上下文信息。它以后进先出(LIFO)的方式进行管理,分配和释放内存非常高效。栈上的数据大小是在编译时确定的,并且随着函数执行完毕而自动释放。局部变量、函数参数、返回地址等都会被存储在栈区。
堆区(Heap)用于动态分配内存,它是通过new/delete或malloc/free等操作来管理的。堆上的数据大小可以在运行时决定,并且需要手动释放。通常情况下,通过new关键字创建的对象和使用malloc函数分配的内存块都位于堆区。
需要注意以下几点:
- 对象或数据类型本身不决定其存储位置,而是由其声明方式和使用方法决定。
- 全局变量、静态变量以及常量字符串通常被分配在静态数据区。
- 面向对象中的类对象通常不能直接存储在栈上,而是通过指针或引用,在堆上进行动态分配。
请记住,在使用堆内存时要小心管理,并确保及时释放以避免内存泄漏。
九、介绍一下const
在C++中,const 是一个关键字,用于定义常量。它可以应用于变量、函数参数、函数返回类型以及类的成员函数中。
(1)常量变量: const 关键字可以用来声明一个常量变量,即其值在初始化后不能再被修改。
const int num = 10;
(2)函数参数: 在函数的参数列表中,使用 const 可以指定某个参数为只读,在函数内部不能修改该参数的值。这样做有助于保护数据的一致性和安全性,并且允许传递临时对象或字面值给 const 引用。
void print(const string& str) {
// 不能修改 str 的值
cout << str << endl;
}
(3)函数返回类型: 在函数声明或定义时,可以将函数的返回类型标记为 const ,表示返回的是一个常量值,不允许对其进行修改。
const int getValue() {
return 42;
}
(4)类成员函数中的 const 成员函数:在类定义中,使用 const 关键字修饰成员函数,则表示该成员函数是一个只读成员函数,在其中不能修改类的非静态成员变量(除非使用 mutable 关键字),也不能调用其他非 const 成员函数(除了其他也被标记为 const 的函数)。
class MyClass {
public:
int getValue() const {
// 不能修改类的成员变量
return value;
}
private:
int value;
};
十、C++内存管理(RAII啥的)
C++内存管理是面试中经常涉及的一个重要话题,其中RAII(Resource Acquisition Is Initialization)是一个核心概念。在C++中,RAII是一种资源管理的编程范式,通过在对象的构造函数中获取资源,在析构函数中释放资源来管理资源的生命周期,从而避免资源泄漏的问题。这种方式可以确保资源在对象生命周期结束时被正确释放,而不需要手动管理资源的释放。
在实际应用中,RAII最常见的用途是管理动态分配内存。通过使用智能指针(如std::unique_ptr、std::shared_ptr)来管理动态分配的内存,可以避免内存泄漏和悬挂指针的问题。当一个对象被析构时,智能指针会自动释放其所管理的内存,从而确保内存的正确释放。
除了内存管理,RAII还可以用于管理文件句柄、数据库连接、线程资源等。通过将资源的管理交给对象的构造函数和析构函数来完成,可以确保资源在对象生命周期结束时被正确释放,提高代码的可靠性和安全性。
以下是使用RAII进行内存管理的一些常见方式:
- 智能指针:C++标准库提供了智能指针类
std::shared_ptr和std::unique_ptr,它们使用引用计数和独占所有权的机制来自动管理动态分配的对象的生命周期。当没有指针指向该对象时,它们会自动删除所持有的对象。
- 容器类:C++标准库提供了各种容器类,例如
std::vector、std::string等。这些容器类负责管理元素内存的分配和释放,并提供了安全易用的接口操作数据。
- 自定义类:使用RAII原则,可以在自定义类中实现资源管理。例如,在一个类中通过构造函数申请资源,在析构函数中释放资源。这样,在创建该类对象时就会自动获得相应资源,并且在对象销毁时会自动释放资源。
十一、C++从源程序到可执行程序的过程
首先,预处理阶段会处理源程序中的预处理指令,如#include、#define等,生成一个无宏定义、无注释的纯净C++源文件。接着,编译器会将预处理后的源文件翻译成汇编代码,然后汇编器将汇编代码转换成机器语言指令,生成目标文件。最后,链接器将所有目标文件和库函数链接在一起,生成最终的可执行程序。
C++从源程序到可执行程序的过程主要包括以下几个步骤:
- 编写源代码:首先,需要使用文本编辑器编写C++源代码。源代码是人类可读的文本文件,包含了程序的逻辑和算法。
- 预处理(Preprocessing):在编译之前,C++编译器会对源代码进行预处理。预处理阶段主要处理以
#开头的预处理指令,如宏定义、头文件引入等。预处理指令会被展开或替换为相应的内容。
- 编译(Compilation):经过预处理后,将进入编译阶段。在这个阶段,编译器将源代码翻译成机器可以执行的中间表示形式,称为目标代码(object code)。目标代码是二进制格式的文件。
- 链接(Linking):当有多个源文件组成一个程序时,编译后会得到多个目标代码文件。链接器(Linker)将这些目标代码文件及所需的库函数链接在一起,生成最终的可执行程序。链接过程解决了不同目标文件之间函数和变量引用的问题,并进行符号重定位。
- 优化(Optimization):在生成可执行程序之前,还可以进行优化操作。优化是针对目标代码进行各种优化策略,以提高程序的执行效率和性能。
- 生成可执行程序:最后一步是将链接好的目标代码转换成操作系统可以直接运行的可执行文件。这个文件包含了完整的二进制机器指令,可以在特定平台上运行。
十二、一个对象=另一个对象会发生什么(赋值构造函数)
当一个对象等于另一个对象时,会触发赋值构造函数。赋值构造函数是一个特殊的成员函数,用于将一个对象的值赋给另一个对象。
赋值构造函数在C++中是一个重要的概念,尤其是在涉及到类对象的复制和赋值操作时。在定义类时,如果没有显式地定义赋值构造函数,编译器会默认生成一个浅拷贝的赋值构造函数。这可能会导致对象之间共享同一块内存空间,当其中一个对象被修改时,另一个对象的值也会受到影响。因此,为了确保对象之间的独立性,建议在定义类时显式地定义赋值构造函数,并在其中实现深拷贝操作。
在C++中,赋值构造函数通常的定义形式如下所示:
class MyClass {
public:
MyClass(const MyClass& other) {
// Perform deep copy here
}
};
通过在赋值构造函数中实现深拷贝操作,可以确保对象之间的独立性,避免因为浅拷贝而产生的问题。在面试中,了解赋值构造函数的作用和实现原理是非常重要的,可以展示出对C++语言特性的理解和应用能力。
十三、如果new了之后出了问题直接return。会导致内存泄漏。怎么办(智能指针,raii)
正确的做法是使用智能指针来管理内存,通过RAII(资源获取即初始化)的机制来确保资源的及时释放,避免内存泄漏。
智能指针是C++中的一种数据类型,可以模拟原始指针的行为,但具有自动释放内存的功能。在面对可能出现异常导致提前返回的情况下,使用智能指针能够确保在任何情况下都能正确释放内存,从而避免内存泄漏。
内存泄漏是指程序中动态分配的内存在不再使用时没有被释放,导致系统内存耗尽的问题。使用智能指针可以有效避免内存泄漏,其中最常用的智能指针包括std::shared_ptr和std::unique_ptr。std::shared_ptr允许多个指针共享同一块内存,会维护一个引用计数,当引用计数为0时自动释放内存;std::unique_ptr则独占所指向的对象,保证了独占所有权的语义。在编程中,应该尽量使用智能指针来管理动态内存,以提高程序的健壮性和可维护性。
十四、c++11的智能指针有哪些。weak_ptr的使用场景。什么情况下会产生循环引用
智能指针是C++11引入的一种智能管理内存的指针,它可以自动释放所指向的对象,避免内存泄漏和野指针的产生。C++11中主要有四种智能指针:unique_ptr、shared_ptr、weak_ptr和auto_ptr(C++17已废弃)。
std::unique_ptr:用于独占所有权的指针,不能共享。它提供了自动释放资源的功能,适用于单个所有者的情况。
std::shared_ptr:允许多个指针共享同一个对象所有权的指针。使用引用计数来跟踪对象的生命周期,当最后一个指向该对象的 shared_ptr 被销毁时,会自动释放对象。
std::weak_ptr:是一种弱引用指针,不会增加对象引用计数。主要用于解决循环引用问题,可以在某些情况下获取被 shared_ptr 管理的对象,但不拥有它。可以通过调用 lock() 方法返回一个有效的 shared_ptr 对象进行操作。
关于循环引用产生的情况,在两个或多个对象之间相互持有对方的强引用时可能会导致循环引用。例如,A对象持有B对象的 shared_ptr,并且B对象也持有A对象的 shared_ptr。这样就形成了循环依赖关系,在没有合适处理的情况下会导致内存泄漏。此时可以使用 weak_ptr 来打破其中一个方向上的引用,避免循环引用问题的发生。
十五、多进程fork后不同进程会共享哪些资源
多进程fork后不同进程会共享代码段、数据段、堆、文件描述符等资源。
在Unix-like系统中,当调用fork()函数创建子进程时,子进程会复制父进程的地址空间,包括代码段、数据段、堆等。因此,子进程会和父进程共享这些资源。
在多进程编程中,了解子进程和父进程之间共享的资源是非常重要的。这不仅涉及到内存管理的机制,还涉及到进程间通信和资源竞争的问题。同时,深入了解fork()函数的实现原理和操作系统内核是非常有帮助的。
- 文件描述符:子进程会复制父进程打开的文件描述符,包括标准输入、输出和错误输出。
- 内存映射:如果父进程使用了内存映射(如
mmap()函数),子进程也会继承这些内存映射。
- 信号处理器:子进程会继承父进程对信号的处理方式(包括信号处理函数和信号屏蔽集)。
- 用户ID和组ID:子进程会保留与父进程相同的用户ID和组ID。
- 资源限制:子进程会继承父进程设置的各种资源限制,如CPU时间限制、文件大小限制等。
十六、多线程里线程的同步方式有哪些
线程的同步是为了解决多个线程访问共享资源时可能引发的数据不一致或者竞态条件的问题。同步的方式可以通过加锁来实现,常见的同步方式包括使用synchronized关键字、ReentrantLock类、Semaphore信号量等。这些同步方式可以保证在同一时刻只有一个线程访问共享资源,从而避免数据不一致性的问题。
- 互斥锁(Mutex):使用互斥锁可以保证在同一时间只有一个线程访问共享资源,其他线程需要等待释放锁后才能访问。这样可以避免多个线程同时修改共享资源导致数据不一致的问题。
- 条件变量(Condition Variable):条件变量用于在线程之间进行等待和通知机制。通过条件变量,一个线程可以等待某个条件满足,而另一个线程在满足条件时发送信号通知等待的线程继续执行。
- 信号量(Semaphore):信号量是一个计数器,用来控制对共享资源的访问。它可以实现多个线程对同一资源的有序访问。
- 屏障(Barrier):屏障用于将多个线程分为若干阶段进行并发执行,并在每个阶段结束后使所有线程都停止,直到所有线程都完成当前阶段后再继续执行下一个阶段。
- 原子操作(Atomic Operations):原子操作是指不可中断的操作,在多线程环境下能够确保操作的完整性。例如原子整型变量或原子指令集可用于保证对共享数据的原子性访问。
十七、size_of是在编译期还是在运行期确定
在C++中,sizeof是一个运算符,用来计算数据类型或变量的大小。这个运算符在编译期就会根据数据类型或变量的定义进行计算,而不是在运行时动态确定的。
十八、函数重载的机制。重载是在编译期还是在运行期确定
函数重载是静态多态的一种体现,属于编译多态。在编译阶段,编译器会根据函数名和参数列表的不同来确定函数的调用,从而实现函数重载。函数重载可以提高代码的可读性和复用性,但需要注意避免函数重载导致的二义性问题。
在C++中,函数重载可以发生在相同的类中,也可以发生在不同的类中。在编译器中,函数重载会根据参数列表的类型、个数、顺序来进行匹配,选择最合适的重载函数。
在运行时,已经决定了要调用哪个具体的重载函数,在实际执行时直接调用相应的函数代码。因此,重载的解析发生在编译阶段,而不是运行阶段。这意味着即使在运行时修改了传递给重载函数的参数,也不会改变所调用的具体函数。
十九、指针常量和常量指针
指针常量(Pointer to Constant)是指一个指针变量被声明为常量,即指针所指向的内存地址不能改变,但是可以通过该指针修改所指向内存地址上的值。
int x = 5;
int y = 10;
const int* ptr = &x; // 指针ptr是一个指向常量的指针,不允许通过ptr修改所指向的值
ptr = &y; // 合法,可以改变ptr的指向
// 下面的赋值操作是非法的
*ptr = 15; // 错误!无法修改ptr所指向的值
常量指针(Constant Pointer)是指一个指针变量被声明为指向常量对象,即不能通过该指针来修改所指向对象的值,但可以更改所指向对象的地址。
int x = 5;
int y = 10;
int* const ptr = &x; // 常量指针ptr,不允许改变ptr的值,即不能改变它所保存的地址
// 下面的赋值操作是非法的
ptr = &y; // 错误!无法改变ptr保存的地址
// 下面的赋值操作是合法的
*ptr = 15; // 可以通过ptr修改所指向对象x的值
总结:指针常量是指通过指针不能改变所指向的地址,但可以改变指针指向的内容;常量指针是指通过指针可以改变所指向的地址,但不能改变指针指向的内容。
二十、vector的原理,怎么扩容
Vector是C++标准库中的一个动态数组容器,vector内部维护了三个指针,分别指向头部、尾部和目前可容纳的末尾位置。当vector需要扩容时,会分配更大的内存空间,将原有元素复制到新空间中,并释放原来的空间。通常情况下,vector的扩容是以原容量的两倍进行扩展的。
当我们向Vector中添加元素时,如果当前容量不足以容纳新元素,Vector会进行扩容操作。Vector的扩容过程通常分为以下几个步骤:
- 当需要扩容时,
Vector会分配一块更大的内存空间来存储元素。通常情况下,新分配的内存大小是当前容量的两倍或者某个固定增长因子(如1.5倍)。
Vector将原有的元素逐个复制到新的内存空间中。
- 释放原有内存空间,并更新指针指向新分配的内存区域。
- 将新元素添加到
Vector末尾。
这种动态扩容机制保证了Vector可以根据需要灵活地调整大小,并且能够高效地处理大量数据。
需要注意的是,由于扩容过程中需要重新分配和拷贝元素,所以频繁进行大规模插入操作可能会导致性能损耗。在实际使用中,如果已知预期插入元素数量较多,可以通过reserve()函数提前分配足够的内存空间来避免多次扩容操作。
二十一、const引用和指针的区别
const引用在声明时需要同时初始化,而且在使用时不能修改所引用变量的值,相当于给变量加上只读属性。指针可以通过解引用来修改所指向变量的值,但需要注意空指针和野指针的问题。
const引用和指针都是用于处理常量数据的方式,但它们有一些区别:
- 语法:
const引用使用&符号表示,而指针使用*符号表示。
- 初始化要求:
const引用必须在定义时初始化,并且只能绑定到与其类型相同或可转换为其类型的对象。指针可以先声明再初始化,并且可以指向任意类型的对象。
- 空值(null):指针可以为空值(
nullptr),即不指向任何对象。而const引用必须始终引用一个有效的对象。
- 可变性:通过
const引用无法修改所引用的对象,这种约束是由编译器强制执行的。而通过指针,可以通过解引用操作符(*)来修改所指向的对象。
- 对象拷贝:对于大型对象来说,通过
const引用传递参数会更高效,因为不需要进行拷贝操作。而指针作为函数参数时需要进行拷贝。
二十二、Cpp新特性知道哪些
Cpp新特性中包含了四种类型转换:static_cast、dynamic_cast、const_cast和reinterpret_cast。
类型转换在C++中是一个常见且重要的操作,不同的类型转换方式会涉及到不同的底层原理和实现细节。static_cast主要是通过编译器在编译时进行类型检查和转换,是一种较为安全的类型转换方式;dynamic_cast通过运行时的类型信息进行转换,可以保证安全性,但会增加运行时开销;const_cast通过移除const属性来进行转换,可以修改const对象的值,但慎用以避免潜在的错误;reinterpret_cast是一种较为底层的类型转换方式,通常用于一些特殊情况下的转换,需要谨慎使用以避免潜在的风险。
二十三、类型转换
类型转换是将一个数据类型的值转换为另一个数据类型的过程。在C++中,有多种类型转换方式:
- 隐式类型转换(Implicit Conversion):编译器会自动进行的类型转换。例如,将一个整数赋值给一个浮点数变量,或者将一个较小范围的整数赋值给较大范围的整数变量。
- 显式类型转换(Explicit Conversion):需要使用特定的语法来指明要进行类型转换。主要有以下几种形式:
- C风格的强制类型转换:使用括号将目标类型写在被转换表达式之前。例如,
(int) 3.14 将浮点数 3.14 转换为整数。
static_cast:用于基本数据类型之间的显式转换。例如,int a = static_cast<int>(3.14) 将浮点数 3.14 转换为整数。
dynamic_cast:用于类层次间的向下转型和运行时检查。用于具有虚函数的类及其派生类之间的指针或引用。
const_cast:用于去除常量属性。例如,去除 const 修饰符后修改常量值。
reinterpret_cast:进行底层位模式重新解释的转型。
二十四、RAII基于什么实现的(生命周期、作用域、构造析构)
RAII(资源获取即初始化)是一种C++编程技术,基于对象的生命周期来管理资源,通过对象的构造函数获取资源,通过对象的析构函数释放资源。RAII的实现依赖于C++的对象生命周期管理机制,主要是通过对象的作用域和构造析构函数来确保资源的正确获取和释放。
具体实现上,可以通过将资源管理类作为对象成员变量来实现RAII。在该类的构造函数中进行资源的获取操作,并在析构函数中进行相应的资源释放操作。当对象离开作用域时,会自动调用析构函数,从而保证了资源的正确释放。
使用RAII可以有效避免忘记手动释放资源或处理异常导致资源泄漏等问题,提高了代码的健壮性和可维护性。常见的应用包括使用智能指针、文件句柄、数据库连接等需要手动管理生命周期的资源。
二十五、手撕:Unique_ptr,控制权转移(移动语义)
当涉及到动态分配的资源管理时,std::unique_ptr 是 C++11 引入的一个智能指针类型。它提供了独占所有权的语义,可以方便地管理使用 new 分配的对象或数组。
通过使用 std::unique_ptr,可以避免手动释放内存并减少内存泄漏的风险。它还支持移动语义,即控制权转移。
下面是一个简单的示例,展示了如何使用 std::unique_ptr 和移动语义:
#include<memory>
#include<iostream>
class MyClass {
public:
MyClass() {
std::cout << "MyClass Constructor" << std::endl;
}
~MyClass() {
std::cout << "MyClass Destructor" << std::endl;
}
void DoSomething(){
std::cout << "Doing something..." << std::endl;
}
};
int main(){
// 创建一个 unique_ptr,并使用 new 分配内存
std::unique_ptr<MyClass> ptr1(new MyClass);
// 移动构造函数将所有权从 ptr1 转移到 ptr2
std::unique_ptr<MyClass> ptr2(std::move(ptr1));
// 检查 ptr1 是否为空指针
if (ptr1 == nullptr) {
std::cout << "ptr1 is nullptr" << std::endl;
}
// 调用成员函数
ptr2->DoSomething();
return 0;
}
在上述示例中,我们创建了一个 std::unique_ptr 对象 ptr1,它拥有一个动态分配的 MyClass 对象。然后,通过使用 std::move 将所有权从 ptr1 转移到了另一个 std::unique_ptr 对象 ptr2。此时,原始的 ptr1 变为 nullptr,表示不再拥有资源的所有权。最后,我们可以通过调用成员函数来操作被指向的对象。
这样做的好处是,在程序中只有一个 std::unique_ptr 持有对资源的所有权,确保了资源在退出作用域时会被正确释放,避免了内存泄漏问题。移动语义则提供了高效的资源转移方式,减少了资源拷贝的开销。
二十六、手撕:类继承,堆栈上分别代码实现多态
类继承是面向对象编程中的重要概念,它可以让一个类(子类)继承另一个类(父类)的属性和方法,从而实现代码的重用和扩展。堆栈和堆是内存中的两种不同区域,堆栈上分别代码实现多态是指在内存中,堆和栈分别存储不同类型的数据。多态是面向对象编程中的一个重要特性,它允许不同类的对象对同一消息做出响应,实现不同对象对同一方法的不同行为。
#include<iostream>
// 基类 Shape
class Shape {
public:
virtual void Draw(){
std::cout << "Drawing a shape" << std::endl;
}
};
// 派生类 Circle
class Circle : public Shape {
public:
void Draw() override{
std::cout << "Drawing a circle" << std::endl;
}
};
// 派生类 Rectangle
class Rectangle : public Shape {
public:
void Draw() override{
std::cout << "Drawing a rectangle" << std::endl;
}
};
int main(){
// 在堆上创建对象,并通过基类指针调用虚函数实现多态
Shape* shape1 = new Circle();
shape1->Draw();
Shape* shape2 = new Rectangle();
shape2->Draw();
// 释放内存
delete shape1;
delete shape2;
// 在栈上直接创建对象,并通过基类引用调用虚函数实现多态
Circle circle;
Shape& shape3 = circle;
shape3.Draw();
Rectangle rectangle;
Shape& shape4 = rectangle;
shape4.Draw();
return 0;
}
在上述示例中,我们定义了一个基类 Shape,并派生出两个子类 Circle 和 Rectangle。基类中有一个虚函数 Draw(),并在派生类中进行重写。
首先,在堆上创建了两个对象,并使用基类指针分别指向这两个对象。通过使用虚函数,调用的是指向对象实际类型的成员函数,即实现了多态。
接下来,在栈上直接创建了两个对象,并使用基类引用分别引用这两个对象。同样地,通过虚函数调用实现了多态。在输出结果中,可以看到不同对象类型对应的 Draw() 函数被正确地调用,展示了多态性质。
二十七、unique_ptr和shared_ptr区别
unique_ptr和shared_ptr是C++智能指针的两种类型,它们之间的主要区别在于所有权管理方式和线程安全性。unique_ptr是独占所有权的智能指针,只能有一个unique_ptr指向同一个对象,当unique_ptr析构时会自动释放内存;而shared_ptr是共享所有权的智能指针,多个shared_ptr可以指向同一个对象,内部使用引用计数来管理对象的生命周期。
- 所有权管理:
unique_ptr 以独占方式拥有所指向的对象,只能由一个 unique_ptr 指针来管理该对象的生命周期。而 shared_ptr 允许多个 shared_ptr 指针共享同一个对象,并使用引用计数来跟踪对象的引用数量。
- 内存回收:当
unique_ptr 被销毁或重置时,它所拥有的对象会被自动释放和删除。而在最后一个 shared_ptr 销毁或重置时,才会触发对象的释放和删除。
- 引用计数开销:由于需要维护引用计数,因此
shared_ptr 的实现通常比 unique_ptr 更加复杂,并且可能涉及线程安全操作。这使得 shared_ptr 相对于 unique_ptr 存在一些额外的性能开销。
- 循环引用问题:由于使用引用计数,在某些情况下可能出现循环引用(例如 A 对象持有 B 对象的
shared_ptr,而 B 对象也持有 A 对象的 shared_ptr)。这种循环引用会导致内存泄漏,因为无法正常释放对象。为了解决这个问题,shared_ptr 提供了 weak_ptr 类型,可以打破循环引用。
二十八、右值引用
右值引用是C++11引入的一种新的引用类型,通过 && 符号表示。它主要用于实现移动语义和完美转发。
右值引用可以绑定到临时对象、将要销毁的对象或者使用 std::move() 转换得到的对象上。与传统的左值引用不同,右值引用可以接管资源,并允许对其进行修改或移动。
使用右值引用主要有两个重要应用场景:
1. 移动语义:当需要将一个资源所有权从一个对象转移到另一个对象时,可以使用右值引用实现高效的资源转移操作,避免了昂贵的深拷贝或者内存分配。
std::string str = "Hello";
std::string&& rvalueRef = std::move(str); // 使用 move 转换为右值引用
2. 完美转发:在模板编程中,通常希望保持参数类型不变地将参数传递给其他函数。右值引用允许以原样传递实参,无需进行多余的复制或移动操作。
template<typename T>
void forward(T&& arg) {
otherFunction(std::forward<T>(arg)); // 使用 std::forward 进行完美转发
}
通过右值引用,我们可以更高效地管理资源并实现泛型编程中的完美转发。它在现代C++中广泛应用于移动语义、智能指针等领域。
二十九、右值引用函数参数可不可以传右值
右值引用函数参数可以接受右值。右值引用是C++11引入的新特性,通过将参数声明为右值引用,可以避免不必要的内存拷贝,提高性能。
右值引用是为了解决传统的左值引用在传递临时对象时会进行拷贝构造的问题。在函数参数列表中,如果使用&&表示右值引用,则可以接受右值参数。例如:
void foo(int&& x) {
// do something with x
}
int main() {
foo(5); // 5是一个右值,可以传递给foo函数
return 0;
}
在上述代码中,foo函数的参数x是一个右值引用,可以接受右值参数,因此可以将5作为参数传递给foo函数。这样可以避免不必要的拷贝构造,提高程序性能。
三十、参考c/c++堆栈实现自己的堆栈。要求:不能用stl容器。
首先,我们可以通过数组来实现一个简单的堆栈结构。堆栈的基本操作包括压栈(push)和出栈(pop)操作,我们可以使用一个数组和一个指针来实现这两个操作。
我们可以定义一个固定大小的数组作为堆栈的存储空间,同时使用一个指针来指向当前栈顶元素的位置。在压栈操作时,将元素放入数组指针指向的位置,并将指针向上移动;在出栈操作时,返回指针指向的元素,并将指针向下移动。
在这个问题中,涉及到了堆栈的基本数据结构和操作。堆栈是一种后进先出(LIFO)的数据结构,它只允许在一端进行插入和删除操作。通过使用数组和指针来实现堆栈,我们可以更好地理解堆栈的底层原理和操作方式。在实际的面试中,面试官可能会通过这个问题来考察我们对数据结构的理解和编程能力。
以下是一个简单的伪代码实现:
// 定义一个固定大小的数组作为堆栈
const int maxSize = 100;
int stack[maxSize];
int top = -1;
// 压栈操作
void push(int element) {
if (top == maxSize - 1) {
printf("Stack overflow");
} else {
top = top + 1;
stack[top] = element;
}
}
// 出栈操作
int pop() {
if (top == -1) {
printf("Stack underflow");
return -1; // 或者处理错误
} else {
int element = stack[top];
top = top - 1;
return element;
}
}
通过以上实现,我们可以清晰地了解如何使用数组和指针来实现一个简单的堆栈结构。在面试中,我们可以根据这个思路来回答类似的题目,展示我们对数据结构和算法的理解和应用能力。
三十一、stl容器了解吗?底层如何实现:vector数组,map红黑树,红黑树的实现
是的,我了解STL容器。STL(Standard Template Library)提供了多种容器,包括vector、map等。这些容器在底层使用不同的数据结构来实现。
vector:vector是一个动态数组,底层通过连续的内存空间来存储元素。当元素数量超过当前容量时,会自动进行扩容操作,并将旧元素复制到新的内存空间中。由于使用连续的内存空间,支持快速随机访问和常数时间插入/删除末尾元素。
map:map是基于红黑树实现的有序键值对容器。红黑树是一种平衡二叉搜索树,具有较好的插入、删除和查找性能。每个节点包含一个键和一个值,并按照键的顺序进行排序。通过比较键值大小,可以在O(log n)时间复杂度下执行查找、插入和删除操作。
红黑树是一种自平衡二叉搜索树,它满足以下性质:
- 每个节点要么是红色,要么是黑色。
- 根节点必须为黑色。
- 所有叶子节点(NIL或空节点)都被认为是黑色。
- 如果一个节点为红色,则其两个子节点都必须为黑色。
- 从任意节点出发,到达其每个叶子节点的路径上,黑色节点数量相同。
红黑树通过保持上述性质来实现自平衡。在插入或删除操作后,通过旋转和重新着色等操作来调整树结构,以保持平衡性质。
红黑树作为map的底层数据结构,在STL中提供了高效的有序键值对存储和查询能力。
三十二、完美转发介绍一下 去掉std::forward会怎样?
完美转发(perfect forwarding)是在C++中用于保留传入函数的参数类型和值类别的技术。它通常与模板和引用折叠一起使用,以实现通用的参数传递。
std::forward 是一个模板函数,用于将参数原封不动地转发给其他函数。它根据输入参数的值类别(左值还是右值)和类型来确定最终调用哪个重载函数。通过 std::forward,可以将左值作为左值、右值作为右值进行传递。
如果去掉 std::forward 或者不使用完美转发时,可能会导致以下问题:
- 类型信息丢失:去掉
std::forward 的话,在函数内部无法准确得知传入的参数是左值还是右值,并可能丧失对类型信息的推导能力。
- 不正确的拷贝或移动:如果传入的参数是一个左值,但没有使用
std::forward 正确地转发它,则在传递给下一个函数时,会进行额外的拷贝构造操作而非移动构造操作,增加了开销。
- 缺少完美转发能力:没有使用
std::forward 的话,在编写泛型代码时无法保持传入参数的精确类型和属性。这意味着当我们试图将参数传递给其他重载函数或模板实例时,无法正确匹配所需的版本。
因此,std::forward 在实现通用代码时非常有用,它帮助保留传入参数的类型和值类别,并正确地将其转发给其他函数。使用 std::forward 可以避免不必要的拷贝或移动,并确保完美转发能力。
三十三、介绍一下unique_lock和lock_guard区别?
unique_lock和lock_guard都是C++中用于管理互斥量的RAII(资源获取即初始化)类。它们的主要区别如下:
- 所有权:
unique_lock允许在其生命周期内多次锁定和解锁互斥量,而lock_guard只允许一次性地锁定和解锁互斥量。因此,unique_lock提供了更大的灵活性,在需要多次锁定和解锁的情况下更为方便。
- 条件变量支持:
unique_lock可以与条件变量一起使用,可以通过wait()、notify_one()和notify_all()等方法进行线程之间的同步与通信。而lock_guard没有直接支持条件变量的功能。
- 构造时机:
unique_lock对象可以在构造时不立即锁定互斥量,并且可以选择在合适的时机手动调用lock()方法来显式地进行加锁;而lock_guard对象在构造时会立即加锁,析构时自动解锁。
- 作用域限制:由于
unique_lock提供了更大的灵活性,它可以在作用域内声明并使用。这意味着你可以根据需要将其局部化到代码块或函数中。而lock_guard一般被设计为函数内部局部变量,仅在函数范围内有效。
三十四、C代码中引用C++代码有时候会报错为什么?
在C代码中引用C++代码会报错的原因可能是由于C和C++的语法和编译规则不同所致。C++是C的超集,但是C++引入了一些新的特性和语法规则,这些规则在C中是不支持的。比如在C++中有类、模板、命名空间等特性,在C中是没有的,所以在C代码中引用C++代码时可能会出现无法识别的语法。另外,C++编译器和C编译器也可能存在差异,导致在混合编译时出现错误。
当在C代码中引用C++代码时,可能会出现以下几个常见的问题导致报错:
- 语法不兼容:C++相对于C具有更严格的语法要求。如果将C++代码直接复制到C文件中,可能会使用到一些C++特有的语法或关键字,例如命名空间、类、模板等,在C编译器中无法识别或解析这些语法,从而导致报错。
- C和C++函数签名差异:在C和C++之间,函数的调用约定(calling convention)以及参数传递方式可能存在差异。如果在C代码中引用了一个由C++实现的函数,并且函数的签名与C的调用约定不一致,那么编译器就无法正确处理这种情况。
- C和C++运行时环境不匹配:由于编译器和链接器等工具链的差异性,在将C代码与使用了特定库或依赖项的C++代码进行链接时可能会发生冲突。例如,如果使用了不同版本的标准库或运行时库,可能会导致符号重定义或其他链接错误。
为了解决这些问题,可以考虑以下几个方法:
- 将引用的部分功能封装为纯粹的C接口:通过定义纯粹的过程接口(plain C interface),将C++代码中的功能封装为C可调用的函数,并使用
extern "C"声明来告知编译器以C语言方式进行处理。
- 使用条件编译:通过在C代码中使用条件编译指令,可以根据不同的编译环境选择性地包含或排除相关的头文件和源代码。这样可以避免出现不兼容或冲突的情况。
- 将C++代码作为库文件提供给C程序使用:将C++代码编译为静态库或动态库,并在C程序中链接该库。这种方式可以有效隔离两者之间的差异,并确保运行时环境一致性。
三十五、静态多态有什么?虚函数原理 虚表是什么时候建立的 为什么要把析构函数设置成虚函数?
静态多态是指在编译时根据函数的参数类型或者重载函数的选择来实现多态。C++中的函数重载和模板就是一种静态多态的实现方式。
虚函数是通过虚表(vtable)来实现的。每个包含虚函数的类都有一个隐藏的指针指向虚表,虚表中存储了该类所有虚函数的地址。当通过基类指针或引用调用虚函数时,会根据对象的动态类型去查找对应虚表中正确的函数地址进行调用。
虚表是在编译阶段由编译器生成并与类相关联的,通常在编译期间就建立好了。
将析构函数设置为虚函数是为了确保在删除派生类对象时能够正确地调用派生类和基类的析构函数。如果析构函数不被声明为虚函数,当使用基类指针指向派生类对象,并通过该指针释放内存时,只会调用基类析构函数而不会调用派生类特有的析构逻辑,可能导致资源泄漏或未定义行为。通过将析构函数声明为虚函数,可以让程序根据对象的动态类型来正确调用相应的析构函数,从而确保正确释放资源和执行清理操作。
虚函数原理是实现面向对象中的多态的重要机制,通过虚函数可以实现动态绑定。在C++中,每个包含虚函数的类都有一个虚函数表(vtable),其中存储了指向各个虚函数的指针。虚表是在编译阶段就建立好的,每个类都有一个虚表,其中存放着指向各个虚函数的指针。当一个类对象被创建时,会分配内存空间并且在对象的内存布局中会包含一个指向虚表的指针,也就是所谓的虚函数指针(vptr)。
三十六、map为啥用红黑树不用avl树?(几乎所有面试都问了map和unordered_map区别)
map使用红黑树而不使用AVL树的原因主要是出于性能和实际应用的考虑。
在C++的标准库中,std::map使用红黑树实现而不是AVL树的原因有以下几点:
- 平衡性调整次数较少:红黑树对于插入和删除操作的平衡性调整次数要比AVL树少。这意味着红黑树在执行插入和删除操作时,需要做的旋转和颜色调整等操作更少,从而提高了性能。
- 更好的插入和删除性能:由于红黑树进行了一定程度上的权衡,它在插入和删除节点时具有更好的性能表现。AVL树保持了严格的平衡,需要更频繁地进行旋转操作来保持平衡性,相比之下会有更多的开销。
- 内存使用更为紧凑:由于红黑树仅需维护一个额外位表示节点颜色信息,相较于AVL树需要维护每个节点高度信息,在内存使用方面更加紧凑。
- 应用场景考虑:红黑树适用于大部分情况下的查找、插入、删除等常见操作,并且兼顾了效率与平衡性。而AVL树在某些特定应用场景下可能会被选择,例如对于那些读取操作较多、写入操作相对较少的场景。
三十七、inline失效场景
inline 失效场景通常指的是CSS中inline元素无法生效的情况,比如给inline元素设置宽度和高度无效等。
inline元素是指在一行内显示的元素,比如span、a、em等,它们通常不能设置宽度和高度,因为它们的宽度和高度由内容决定。当我们给inline元素设置宽度和高度时,通常会失效,这是因为CSS规定了某些属性对inline元素无效。解决这个问题的方法可以是将inline元素设置为block元素,或者使用display: inline-block来实现类似于inline元素的显示效果。
inline函数的失效场景包括以下几种情况:
- 函数体过于庞大:如果一个函数的实现代码非常庞大,那么将其定义为
inline函数可能会导致代码膨胀,增加可执行文件的大小,并且可能对性能产生负面影响。因此,在函数体较大时,编译器有权选择不进行内联。
- 递归函数:由于递归函数在每次调用时都需要生成新的栈帧,而内联则是直接展开代码插入到调用处,这与递归的本质相矛盾。因此,递归函数通常不会被内联。
- 虚函数:虚函数是通过虚表(vtable)和虚指针(vptr)来实现多态特性的,这使得其无法被静态地内联展开。即使在类中声明了
inline关键字,也不会真正内联虚函数。
- 在循环中调用的函数:如果一个被循环频繁调用的函数被定义为
inline,则可能导致代码膨胀和缓存未命中等问题。在这种情况下,编译器可能会选择不进行内联以保持较好的性能。
三十八、C++ 中struct 和class区别
在C++中,struct和class在语法上是可以互换使用的,唯一的区别在于默认的访问权限。在struct中,默认的成员访问权限是public,而在class中,默认的成员访问权限是private。
在C++中,struct和class是用于定义自定义数据类型的关键字,它们有一些区别:
三十九、如何防止一个头文件 include 多次
头文件被多次包含可能会导致重复定义,造成编译错误。使用头文件保护宏可以有效避免这种情况发生。头文件保护宏的机制是在预处理阶段检查是否已经定义了特定的宏,如果已经定义则跳过后续内容,否则继续包含头文件内容。这种机制可以确保头文件只被包含一次,避免了重复定义的问题。
以下是一个简单的示例:
#ifndef EXAMPLE_H
#define EXAMPLE_H
// 头文件内容
#endif
四十、lambda 表达式的理解,它可以捕获哪些类型
lambda表达式是一种匿名函数,它可以捕获其所在作用域中的局部变量和全局变量。这是C++11引入的一种函数对象创建方式,它可以在需要函数对象的地方使用,例如作为参数传递给算法函数、用于定义函数对象变量等。
Lambda表达式的基本语法如下:
[capture list](parameters) -> return_type {
// 函数体
}
其中:
capture list 是可选项,用于捕获外部变量。
parameters 是参数列表。
return_type 是返回类型。
{} 包含了函数体。
Lambda表达式可以捕获以下几种类型的外部变量:
- 值捕获(value capture):通过在捕获列表中以值方式进行捕获。例如:
[x],表示以值方式捕获变量x。
- 引用捕获(reference capture):通过在捕获列表中以引用方式进行捕获。例如:
[&y],表示以引用方式捕获变量y。
- 隐式捕获(implicit capture):省略了具体的变量名,在编译器根据上下文推断要进行哪种类型的捕获。例如:
[=] 表示按值方式隐式捕获所有外部变量; [&] 表示按引用方式隐式捕获所有外部变量。
在lambda表达式内部可以使用被捕获的外部变量,并且根据捕获方式的不同,有不同的语义。需要注意的是,被值捕获的变量在lambda表达式内部是不可修改的(除非使用mutable修饰符);而被引用捕获的变量可以修改。
四十一、友元friend介绍
友元(friend)是C++中的一个关键字,用于在类的声明中声明其它类或函数为该类的友元,使得友元类或函数可以访问该类的私有成员。
友元的引入是为了在某些特殊情况下需要访问类的私有成员,但又不希望将这些成员设置为public或提供访问函数。友元函数可以访问类的所有成员,包括private和protected成员,但并不是类的成员函数。友元类能够访问声明该类为友元的类的所有成员。
友元不是面向对象编程的理念,因为它破坏了封装性,应该谨慎使用。友元的声明通常在类的声明中进行,但也可以在类的定义中进行,此时需要在类的声明前使用关键字friend。
class A {
private:
int num;
public:
A(int n) : num(n) {}
friend void showNum(A obj);
};
void showNum(A obj){
cout << "The private number is: " << obj.num << endl;
}
四十二、move函数
在C++中,移动语义是为了解决传统的拷贝构造函数和赋值运算符在传递临时对象时效率低下的问题。通过使用move函数模板类,程序员可以明确地表达自己的意图,将资源的所有权从一个对象转移到另一个对象,避免不必要的数据拷贝,提高程序的性能。在实现move函数模板类时,需要考虑对象的生命周期管理、资源的移动和释放等问题,以确保程序的正确性和高效性。
std::move() 是 C++ 标准库中的一个函数,它位于 <utility> 头文件中。它用于将对象的所有权从一个对象转移给另一个对象。
当我们需要将某个对象转移(而不是复制)到另一个对象时,可以使用 std::move() 函数来实现高效的资源管理。通过使用 std::move(),编译器将不再试图拷贝该对象,而是将其内部状态的所有权转移到新的目标对象上。
使用 std::move() 的一般步骤如下:
- 引入
<utility> 头文件:#include <utility>
- 使用
std::move() 函数进行转移:target = std::move(source);
以下是一些关键点和注意事项:
std::move() 并不实际移动任何数据,它只是将传递给它的参数标记为可被移动(右值)。这意味着我们应该确保在调用 std::move() 之前,源对象不再被使用。
- 被标记为可移动的右值引用可以通过 Rvalue 引用来接收,并且可以使用该引用访问原始源对象的内容。
- 在转移完成后,源对象处于有效但未定义状态,通常会被设置为默认构造状态或
nullptr 等合理值。因此,在转移后,请不要假设源对象保持原有值。
- 对于支持移动语义的类,可以通过实现自定义的移动构造函数和移动赋值操作符来控制转移过程。
四十三、模版类的作用
模板类是C++中一种通用的编程工具,它允许我们编写可以适用于多种数据类型的通用类或函数。通过使用模板类,我们可以在不重复编写代码的情况下,实现对不同数据类型的操作和处理。
以下是模板类的几个主要作用:
- 代码重用:使用模板类可以避免为每种数据类型都编写相同功能的类。通过定义一个通用的模板类,在需要时只需提供具体的数据类型参数即可生成针对该特定类型的代码。
- 泛型编程:模板类使得泛型编程成为可能。泛型编程是一种以通用方式来描述算法和数据结构,并且能够适应多种不同数据类型和结构的方法。通过将模板参数化,我们可以以一种更抽象、灵活和可复用的方式实现算法和数据结构。
- 类型安全性:使用模板类可以在编译期间进行类型检查,从而提供更强大的静态类型检查机制。这有助于捕获并防止在运行时出现与类型相关的错误。
- 简化代码维护:当需要对某个通用功能进行修改或更新时,只需修改模板定义即可,而无需逐个修改所有特定类型实例化出来的对象。
四十四、模板和泛型的区别
模板和泛型是两种不同的概念。在C++中,模版是一种通用的代码框架,可以用来生成特定类型或特定函数的代码,而泛型是一种通用编程范式,允许在编程时使用未指定具体类型的变量或函数。
区别如下:
- 范围:模板是C++语言特有的特性,而泛型则是一种更广义的概念,存在于多种编程语言中。
- 意义:模板主要解决了C++中代码复用和类型安全问题,通过在编译时生成具体化版本;而泛型着眼于设计通用算法和数据结构,并使其能适应多种不同类型。
- 实现方式:模板依赖于C++编译器对模板进行解析、实例化和生成代码;而泛型则可以通过其他机制实现,例如Java中的类型参数化和C#中的泛型。
四十五、内存管理:C++的new和malloc的区别
C++的 new 和 malloc 都是用于动态分配内存的方式,但在使用上有一些区别:
- 类型安全:
new 是C++运算符,能够根据所需类型进行自动类型推断,并返回所需类型的指针。这样可以确保分配的内存与所需类型相匹配,避免了手动计算字节数或进行显式类型转换。而 malloc 是C语言库函数,它只返回一个 void* 指针,并且需要手动计算所需内存大小。
- 构造函数调用:使用
new 分配的内存会自动调用对象的构造函数进行初始化;而 malloc 分配的内存不会自动调用构造函数,需要手动对对象进行构造。
- 内存大小:使用
new 不需要显式地指定要分配的内存大小。例如,通过 new int[5] 可以分配足够空间来容纳 5 个整数。而使用 malloc 需要明确指定要分配的字节数。
- 异常处理:如果
new 失败(如没有足够的可用内存),它将引发异常 std::bad_alloc(除非使用了无异常版本),可以通过捕获该异常来处理错误情况。而 malloc 在分配失败时返回空指针(NULL),需要手动检查并处理错误情况。
四十六、new可以重载吗,可以改写new函数吗
new操作符无法被重载,因为它是一个关键字,而不是一个函数。new操作符在内存分配时会调用operator new函数,然后调用构造函数来初始化对象。因此,无法改写new函数。
四十七、C++中的map和unordered_map的区别和使用场景
C++中的map和unordered_map的区别在于底层数据结构不同,map是基于红黑树实现的有序映射,而unordered_map是基于哈希表实现的无序映射。map适合有序性要求高的场景,而unordered_map适合查找速度要求高的场景。
它们之间有以下区别和不同的使用场景:
- 存储方式:
map 是基于红黑树实现的有序容器,而 unordered_map 则是基于哈希表实现的无序容器。
- 查找速度:由于哈希表具有 O(1) 的平均查找复杂度,相比之下,红黑树具有 O(log n) 的查找复杂度。因此,在大多数情况下,
unordered_map 的查找速度更快。
- 顺序性:
map 中的元素按照键值进行排序,并保持着固定的顺序;而 unordered_map 中元素的顺序是根据哈希函数计算得到的结果,并没有特定的顺序。
- 内存占用:由于哈希表需要额外存储桶来处理碰撞冲突,所以通常情况下
unordered_map 占用的内存会更多一些,而 map 只需要额外存储节点即可。
在选择使用哪种容器时可以考虑以下几点:
- 如果需要按照键值排序并且能够保持稳定顺序,则选择
map。
- 如果对元素插入和查询速度要求较高,并且不关心顺序,则选择
unordered_map。
- 如果对内存使用量非常敏感,并且可以接受较低的查询性能,可以选择
map。
四十八、他们是线程安全的吗
线程安全是一个相对的概念,取决于具体的实现。一般来说,如果一个数据结构或者方法在多线程环境下被并发访问时,不会导致数据错误或者不一致,那么就可以说它是线程安全的。
要确保多线程环境下的安全访问,可以考虑以下方法:
- 使用互斥锁(Mutex):在每个涉及到共享容器的操作之前,使用互斥锁进行加锁和解锁,以保证同一时间只有一个线程可以访问容器。关于多线程同步的更多知识,可以参考相关专题。
- 使用读写锁(Read-Write Lock):对于大部分情况下是读操作的场景,可以采用读写锁来实现读取共享容器时的并发性能优化。
- 使用线程安全版本:某些编译器或库提供了线程安全版本的容器类,例如GCC中的
std::concurrent_hash_map。使用这些容器类可以避免手动添加同步机制。
- 使用并发数据结构:除了传统的
map 和 unordered_map,还可以使用专门设计用于并发环境的数据结构,如 std::concurrent_unordered_map。
四十九、c++标准库里优先队列是怎么实现的?
堆是一种特殊的树形数据结构,可以分为大顶堆和小顶堆。在C++中,优先队列是基于大顶堆实现的,默认情况下,队首元素是优先级最高的元素。当插入元素或者弹出元素时,优先队列会自动调整堆,保持堆的性质。
在C++标准库中,优先队列(priority_queue)是通过堆(heap)来实现的。堆是一种特殊的二叉树结构,具有以下性质:
- 堆是一个完全二叉树:除了最底层外,其他层都被元素填满,并且最底层从左到右连续填充。
- 对于大顶堆(默认情况下),父节点的值始终大于等于子节点的值。
- 对于小顶堆,父节点的值始终小于等于子节点的值。
在优先队列中,默认使用大顶堆来实现。这意味着具有较高优先级的元素将排在队列前面。当插入一个新元素时,它会被放置在合适的位置以满足堆的性质。
优先队列提供了以下主要操作:
- 插入(push):将元素添加到队列中,并保持堆的性质。
- 弹出(pop):移除队首元素,即具有最高优先级的元素。
- 访问顶部(top):获取队首元素的引用,但不删除它。
这些操作都能够在对数时间复杂度内完成,因为基于堆结构的插入和弹出操作只需花费O(logN) 的时间。而访问顶部操作只需O(1) 时间。
五十、gcc编译的过程
GCC(GNU Compiler Collection)是一款流行的开源编译器,用于将源代码转换为可执行程序。
下面是GCC编译过程的简要步骤:
- 预处理(Preprocessing):预处理阶段通过处理预处理指令,如
#include和宏定义等,对源文件进行展开和替换。结果生成一个扩展名为.i的中间文件。
- 编译(Compilation):编译阶段将预处理后的代码翻译成汇编语言代码。这个阶段主要包括词法分析、语法分析、语义分析和优化等步骤。结果生成一个扩展名为
.s的汇编代码文件。
- 汇编(Assembly):汇编阶段将汇编语言代码转化为机器码指令,并生成一个扩展名为
.o的目标文件。
- 链接(Linking):链接阶段将目标文件与必要的库文件进行合并,以及解决符号引用和地址重定位等问题。最终生成可执行程序或者共享库文件。
在链接阶段,还可以包含以下几个子过程:
- 符号解析(Symbol resolution):确定所有符号引用的实际地址。
- 地址重定位(Address relocation):根据实际地址修改目标文件中的地址值。
五十一、C++ Coroutine
C++ Coroutine 是 C++20 新增的一种协程特性,用来简化异步编程,提高代码的可读性和可维护性。C++ Coroutine 通过 co_await、co_yield、co_return 等关键字来实现协程的操作,可以在异步任务中暂停和恢复执行,避免了回调地狱和复杂的状态管理。
C++ Coroutine 的实现原理是基于协程的状态机,编译器会对协程进行转换,生成状态机代码。协程的底层机制包括协程栈、协程控制块等数据结构。
以下是一个简单的示例:
#include<coroutine>
#include<iostream>
struct generator {
struct promise_type {
int current_value;
auto initial_suspend(){ return std::suspend_always{}; }
auto final_suspend(){ return std::suspend_always{}; }
generator get_return_object(){ return generator{this}; }
void return_void(){}
auto yield_value(int value){
current_value = value;
return std::suspend_always{};
}
};
bool move_next(){
if (coro.done())
return false;
coro.resume();
return !coro.done();
}
int current_value(){ return coro.promise().current_value; }
generator(generator::promise_type* p) : coro(std::coroutine_handle<promise_type>::from_promise(*p)) {}
~generator() {
if (coro)
coro.destroy();
}
private:
std::coroutine_handle<promise_type> coro;
};
generator simple_coroutine(){
co_yield 1;
co_yield 2;
co_yield 3;
}
int main(){
generator gen = simple_coroutine();
while (gen.move_next()) {
std::cout << gen.current_value() << std::endl;
}
return 0;
}
以上示例展示了一个简单的使用 C++ Coroutine 实现生成器的例子,通过 co_yield 关键字来暂停和恢复执行。深入了解 C++ Coroutine 的底层实现原理和语法特性,有助于在面试中更好地回答相关问题。
五十二、extern C有什么作用
在C++中,由于函数重载、命名空间等特性,导致C++编译器会对函数名进行修饰,而C语言没有这个特性,因此在C++代码中调用C语言函数时需要使用extern "C"来进行声明。这是C++与C语言互操作的重要技术之一,也是面试中常见的问题。
使用 extern "C" 的示例:
#ifdef __cplusplus
extern "C" {
#endif
// 在此处声明你要调用或访问的C语言函数或变量
#ifdef __cplusplus
}
#endif
通过将相关代码放置在 extern "C" 块内,可以确保它们被以C链接规则处理。这使得C++代码能够与使用纯C语言编写的模块正确地进行链接和交互。
五十三、c++ memoryorder/elf文件格式/中断对于操作系统的作用
在C++中,memory_order是用于指定内存访问顺序的枚举类型,用于控制原子操作的内存顺序。ELF(Executable and Linkable Format)文件格式是一种常见的可执行文件和目标文件的格式,用于在Linux系统中执行程序。中断是计算机系统中的一种机制,用于处理外部事件或异常情况,对操作系统的作用是及时响应外部事件并进行相应处理。
C++中的memory_order:memory_order是用于多线程编程中对原子操作的内存顺序进行指定的枚举类型。它可以用来确保多个线程之间的内存访问顺序,以避免数据竞争和不一致性问题。在使用C++的原子操作(如std::atomic)进行并发编程时,我们可以使用不同的memory_order选项来指定读写操作之间的顺序和同步行为。
ELF文件格式:ELF(Executable and Linkable Format)是一种常见的可执行文件和目标文件格式,被广泛用于Unix、Linux等操作系统中。它定义了可执行文件和共享库(动态链接库)的结构、元数据和程序入口点等信息。ELF文件包含了代码段、数据段、符号表、重定位表以及其他与可执行程序或共享库相关的信息。
中断对于操作系统的作用:中断是计算机体系结构中一种重要机制,它能够打破CPU执行指令流程,立即响应硬件设备或异常事件。对于操作系统来说,中断具有以下作用:
- 实现设备驱动程序:通过中断处理程序来响应外部设备发出的信号,并进行相应的处理逻辑,从而实现设备驱动程序。
- 提供任务调度机制:当一个任务正在执行时,如果发生了外部事件(如IO操作完成、定时器到期等),中断能够迅速暂停当前任务,切换到相应的中断处理程序,从而实现多任务调度。
- 硬件异常处理:当出现硬件故障或错误时(如除零错误、页面错误等),中断机制可以捕获这些异常并进行相应的处理,例如发送错误消息或执行修复操作。
- 系统服务调用:操作系统提供一些系统级服务(如文件读写、进程管理等),应用程序可以通过软中断指令(例如
int 0x80)触发相应的中断,从而请求操作系统提供服务。
五十四、C++的符号表
符号表是编译器在编译过程中用来维护变量、函数、类等标识符的数据结构。在C++中,符号表是编译器的重要组成部分,用于记录源代码中定义的所有标识符及其相关信息,以便后续的语义分析和代码生成。
C++的符号表是一种用于记录程序中函数、变量和其他标识符的名称和信息的数据结构。在编译C++程序时,编译器会生成符号表,它包含了程序中所有的全局变量、函数名以及它们对应的存储地址或偏移量等信息。
符号表通常包括以下内容:
- 符号名称:标识符(如函数名、变量名)的名称。
- 符号类型:标识符所属的类型,例如函数、全局变量、局部变量等。
- 存储位置:记录标识符在内存中的地址或偏移量。
- 访问权限:指示标识符是否是公开可见或私有的。
- 作用域信息:表示标识符所在的作用域范围。
- 相关联信息:与该标识符相关联的其他信息,如参数列表、返回类型等。
通过符号表,编译器可以在链接阶段将不同源文件中定义和引用相同标识符的地方进行匹配,并生成正确可执行代码。此外,在调试过程中,调试器也可以利用符号表来定位和查看程序中各个标识符对应的具体信息。
需要注意的是,对于C++模板类和成员函数等特殊情况,由于其实例化过程可能发生在链接阶段之后,符号表中可能不会直接包含所有实例化的具体信息,而是包含一些模板相关的符号信息,以供链接器在需要时进行实例化。
五十五、C++的单元测试
C++的单元测试是一种软件测试方法,用于验证和确保程序中各个独立模块(即单元)的功能是否正常运行。单元可以是函数、类、方法或其他可独立测试的代码片段。
以下是一些常用的C++单元测试框架:
- Google Test:Google Test是一个流行的开源C++单元测试框架,提供了丰富的断言和测试宏来编写测试用例,并具有良好的跨平台支持。
- Catch2:Catch2是另一个轻量级的开源C++单元测试框架,它采用了现代C++特性,支持自动发现和执行测试用例。
- Boost.Test:Boost.Test是Boost库中集成的一个C++单元测试框架,它提供了丰富的断言和扩展功能,适合于使用Boost库的项目。
这些框架都提供了丰富的功能和断言方式来编写测试用例,并且具有灵活性和可扩展性,使得开发人员能够轻松地编写和运行各种类型的单元测试。通过编写全面的单元测试,可以增强代码质量、减少bug和维护成本,并促进代码的可测试性和可重用性。
以上就是对C++面试中智能指针、多态虚函数及STL原理等核心考点的全面解析。掌握这些知识点,不仅能帮助你顺利通过技术面试,更能加深对C++语言精髓的理解。如果你想深入探讨某个话题或获取更多学习资源,欢迎在云栈社区与广大开发者交流。