C++基础
C和C++的区别
- C++是⾯向对象的语言,而C是⾯向过程的语言;
- C++引入 new/delete 运算符,取代了C中的 malloc/free 库函数;
- C++引入引用的概念,而C中没有;
- C++引入类的概念,而C中没有;
- C++引入函数重载的特性,而C中没有
系统在执行main函数之前执行了什么
_start()函数,是C runtime库的函数_libc_start_main()函数,是libc中的函数main()_libc_start_main()函数- Linux内核
指向数组的指针
- 指向数组的指针声明
int (*ptr)[10],表示一个指向具有十个元素的数组的指针 - 不仅保存了数组的起始地址,还保存了数组的大小等信息
- 解引用方式:要先解引用这个指针,再解引用某个元素,也就是
(*ptr)[10]访问第十个元素
数组和指针的区别
- 取sizeof的时候数组取的是数组整个的大小,指针取的是指针自己的大小
- 数组名是数组的首地址,对数组取地址取的是指向数组的指针
- 传递数组作为参数的时候,可以指定大小如
int a[5],传递多维数组的时候,需要指定除了第一个维度以外的所有维度的大小如int a[][3] - 传递
int a[]和int* a在语义上没有区别 - 数组和指针都支持下标访问
static的作用
- 修饰局部变量时,使得该变量在静态存储区分配内存;只能在首次函数调用中进行首次初始化,之后的函数调用不再进行初始化;其生命周期与程序相同,但其作用域为局部作用域,并不能一直被访问
- 修饰全局变量时,使得该变量在静态存储区分配内存;在声明该变量的整个文件中都是可见的,而在文件外是不可见的
- 修饰函数时,在声明该函数的整个文件中都是可见的,而在文件外是不可见的,从而可以在多⼈协作时避免同名的函数冲突
- 修饰成员变量时,所有的对象都只维持一份拷贝,可以实现不同对象间的数据共享;不需要实例化对象即可访问;不能在类内部初始化,一般在类外部初始化,并且初始化时不加 static
- 修饰成员函数时,该函数不接受 this 指针,只能访问类的静态成员;不需要实例化对象即可访问
一个程序是如何被编译的
- 预处理(Pre-process)
- 可以使用cpp命令(C preprocessor)完成预处理,完成预编译中指定的文本替换
- 编译(Compile)
- 将程序翻译为汇编文件
- 是文本文件
- 汇编(assemble)
- 将文件转换为二进制命令,得到一个可重定位目标文件
.o - 是机器代码
- 将文件转换为二进制命令,得到一个可重定位目标文件
- 链接(link)
- 链接代码不同的可重定位目标文件得到完整的逻辑地址
- 链接的时候不仅仅需要用户编译的模块,还需要
cruntime等很多库的文件
链接的时候如何处理符号
强符号和弱符号
如果涉及到多个不同的源文件中包含同名的符号定义,会在链接中报错
但是假如在同一个源文件中具有多个同名符号的定义则会编译出错,比如在同一个源文件中引用了包含同名符号的多个头文件
C++编译器优化
常量折叠(Constant Folding):当编译器在编译期间遇到常量表达式时,它会计算出表达式的结果,而不是在运行时计算。例如,表达式3 * 4会被替换为12。
内联函数(Inline Functions):编译器可能会将小的函数替换为它们的函数体,以减少函数调用的开销。这被称为内联。
循环展开(Loop Unrolling):编译器可能会将循环的迭代次数减少,并在每次迭代中执行更多的操作。这可以减少循环控制的开销。
公共子表达式消除(Common Subexpression Elimination):编译器可以识别并消除在程序中多次计算的公共子表达式。
死代码消除(Dead Code Elimination):编译器会删除不会影响程序输出的代码,例如,未使用的变量和无法访问的代码。
尾调用优化(Tail Call Optimization):如果一个函数的最后一个操作是另一个函数的调用,编译器可能会将其优化为一个跳转指令,从而减少栈的使用。
数据流分析(Data Flow Analysis):编译器会通过分析程序的数据流,优化数据存储和检索,减少不必要的加载和存储操作。
#define和const有什么区别?
- 编译器处理方式不同: #define 宏是在预处理阶段展开,不能对宏定义进行调试,而 const 常量是在编译阶段使用
- 类型和安全检查不同: #define 宏没有类型,不做任何类型检查,仅仅是代码展开,可能产生边际效应等错误,而 const 常量有具体类型,在编译阶段会执行类型检查
- 存储方式不同: #define 宏仅仅是代码展开,在多个地方进行字符串替换,不会分配内存,存储于程序的代码段中,而 const 常量会分配内存,但只维持一份拷贝,存储于程序的数据段中
- 定义域不同: #define 宏不受定义域限制,而 const 常量只在定义域内有效
对于一个频繁使用的短小函数,应该使用什么来实现?有什么优缺点
- 应该使用 inline 内联函数,即编译器将 inline 内联函数内的代码替换到函数被调用的地方
- 优点:
- 在内联函数被调用的地方进行代码展开,省去函数调用的时间,从而提高程序运行效率
- 相比于宏函数,内联函数在代码展开时,编译器会进行语法安全检查或数据类型转换,使用更加安全
- 缺点
- 代码膨胀,产生更多的开销
- 如果内联函数内代码块的执行时间比调用时间长得多,那么效率的提升并没有那么大
- 如果修改内联函数,那么所有调用该函数的代码文件都需要重新编译
- 内联声明只是建议,是否内联由编译器决定,所以实际并不可控
RAII
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是一种管理资源的编程惯用法,特别适用于C++。该惯用法的核心思想是将资源的生命周期绑定到对象的生命周期,通过对象的构造函数获取资源,通过对象的析构函数释放资源。这种方式确保了资源在程序中得到可靠的管理,避免了资源泄漏。
异常安全:RAII 确保资源会被正确释放,即使在异常情况下也不会发生资源泄漏。
简化资源管理:通过将资源管理封装在对象中,减少了手动释放资源的代码,降低了出错的风险。
如何防止因为抛出异常而导致的内存泄漏
在C++中,为了防止程序因异常而出现内存泄漏,通常会使用RAII(Resource Acquisition Is Initialization)惯用法和智能指针。
C++标准库提供了一些RAII类,比如
std::vector,std::string,std::fstream等函数是怎么调用的
栈帧分配
- 当一个函数被调用时,程序会在栈上分配一块内存空间,用于存储该函数的局部变量、参数值、返回地址以及其他必要信息,这块内存空间就是栈帧。
- 栈帧的大小取决于函数所需的局部变量大小、参数个数和其他额外信息的大小。
参数传递
- 如果函数有参数,调用者将参数的值传递给被调用函数。这些参数值通常会被存储在栈帧中的特定位置,供被调用函数在执行时访问和使用。
局部变量分配
- 函数中声明的局部变量也会被分配到栈帧中的适当位置。它们的大小和数量由编译器在编译时确定,并在函数调用时为其分配空间。
返回地址存储
- 在函数调用时,调用者的程序计数器(PC)保存着下一条指令的地址,即函数调用语句的下一条指令地址。这个地址被称为返回地址,用于在函数执行完毕后返回到调用函数的正确位置。
- 返回地址会被压入栈帧中,以便在函数执行完毕后能够正确返回到调用函数的位置。
其他信息
- 除了参数值、局部变量和返回地址之外,栈帧还可能包含其他必要的信息,如保存寄存器状态、异常处理信息等,这些信息取决于具体的编程语言和编译器实现。
什么是智能指针?智能指针有什么作用?分为哪几种?各自有什么样的特点
- 智能指针是一个RAII类模型,用于动态分配内存,其设计思想是将基本类型指针封装为(模板)类对象指针,并在离开作用域时调用析构函数,使用 delete 删除指针所指向的内存空间。
- 智能指针的作用是,能够处理内存泄漏问题和空悬指针问题
- 分为 auto_ptr 、 unique_ptr 、 shared_ptr 和 weak_ptr 四种
- 对于 auto_ptr ,实现独占式拥有的概念,同一时间只能有一个智能指针可以指向该对象;但 auto_ptr在C++11中被摒弃,其主要问题在于
- 对象所有权的转移,比如在函数传参过程中,对象所有权不会返还,从而存在潜在的内存崩溃问题
- 不能指向数组,也不能作为STL容器的成员
- 对于 auto_ptr ,实现独占式拥有的概念,同一时间只能有一个智能指针可以指向该对象;但 auto_ptr在C++11中被摒弃,其主要问题在于
- 对于 unique_ptr ,实现独占式拥有的概念,同一时间只能有一个智能指针可以指向该对象,因为无法进行拷贝构造和拷贝赋值,但是可以进行移动构造和移动赋值
- 对于 shared_ptr ,实现共享式拥有的概念,即多个智能指针可以指向相同的对象,该对象及相关资源会在其所指对象不再使用之后,自动释放与对象相关的资源
- 对于 weak_ptr ,解决 shared_ptr 相互引用时,两个指针的引用计数永远不会下降为0,从而导致死锁问题。而 weak_ptr 是对对象的一种弱引用,可以绑定到 shared_ptr ,但不会增加对象的引用计数
shared_ptr的实现
- 构造函数中计数初始化为1
- 拷贝构造函数中计数值加1
- 赋值运算符中,左边的对象引用计数减1,右边的对象引用计数加1
- 析构函数中引用计数减1
- 在赋值运算符和析构函数中,如果减1后为0,则调用 delete 释放对象
右值引用有什么作用
- 右值引用的主要⽬的是为了实现转移语义和完美转发,消除两个对象交互时不必要的对象拷贝,也能够更加简洁明确地定义泛型函数
悬挂指针与野指针有什么区别
- 悬挂指针:当指针所指向的对象被释放,但是该指针没有任何改变,以⾄于其仍然指向已经被回收的内存地址,这种情况下该指针被称为悬挂指针
- 野指针:未初始化的指针被称为野指针
静态链接和动态链接有什么区别
- 静态链接是在编译链接时直接将需要的执行代码拷贝到调用处,优点在于程序在发布时不需要依赖库,可以独立执行,缺点在于程序的体积会相对较大,而且如果静态库更新之后,所有可执行文件需要重新链接
- 动态链接是在编译时不直接拷贝执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定代码时,在共享执行内存中寻找已经加载的动态库可执行代码,实现运行时链接
- 优点在于多个程序可以共享同一个动态库,节省资源
- 缺点在于由于运行时加载,可能影响程序的前期执行性能
变量的声明和定义有什么区别
- 变量的定义为变量分配地址和存储空间, 变量的声明不分配地址。一个变量可以在多个地方声明, 但是只在一个地方定义。加入extern 修饰的是变量的声明,说明此变量将在文件以外或在文件后⾯部分定义
- 很多时候一个变量,只是声明不分配内存空间,直到具体使用时才初始化,分配内存空间, 如外部变量
简述#ifdef、#else、#endif和#ifndef的作用
- 利用#ifdef、#endif将某程序功能模块包括进去,以向特定用户提供该功能。在不需要时用户可轻易将其屏蔽
- 在子程序前加上标记,以便于追踪和调试
- 应对硬件的限制。由于一些具体应用环境的硬件不一样,限于条件,本地缺乏这种设备,只能绕过硬件,直接写出预期结果
- 虽然不用条件编译命令而直接用if语句也能达到要求,但那样做⽬标程序长(因为所有语句都编译),运行时间长(因为在程序运行时间对if语句进行测试)。而采用条件编译,可以减少被编译的语句,从而减少⽬标程序的长度,减少运行时间
写出int 、bool、 float 、指针变量与 “零值”比较的if 语句
//int与零值比较
if ( n == 0 )
if ( n != 0 )
//bool与零值比较
if (flag) // 表示flag为真
if (!flag) // 表示flag为假
//float与零值比较
const float EPSINON = 0.00001;
if ((x >= - EPSINON) && (x <= EPSINON) //其中EPSINON是允许的误差(即精度)。
//指针变量与零值比较
if (p == NULL)
if (p != NULL)结构体可以直接赋值吗
- 声明时可以直接初始化,同一结构体的不同对象之间也可以直接赋值,但是当结构体中含有指针“成员”时一定要小心
- 当有多个指针指向同一段内存时,某个指针释放这段内存可能会导致其他指针的非法操作。因此在释放前一定要确保其他指针不再使用这段内存空间
sizeof 和strlen 的区别
- sizeof是一个操作符,strlen是库函数
- sizeof的参数可以是数据的类型,也可以是变量,而strlen只能以结尾为‘\0’的字符串作参数
- 编译器在编译时就计算出了sizeof的结果,而strlen函数必须在运行时才能计算出来。并且sizeof计算的是数据类型占内存的大小,而strlen计算的是字符串实际的长度
- 数组做sizeof的参数不退化,传递给strlen就退化为指针了
C 语言的关键字 static 和 C++ 的关键字 static 有什么区别
- 在 C 中 static 用来修饰局部静态变量和外部静态变量、函数。而 C++中除了上述功能外,还用来定义类的成员变量和函数。即静态成员和静态成员函数
- 编程时 static 的记忆性,和全局性的特点可以让在不同时期调用的函数进行通信,传递信息,而 C++的静态成员则可以在多个对象实例间进行通信,传递信息
volatile有什么作用
- 状态寄存器一类的并行设备硬件寄存器
- 一个中断服务子程序会访问到的非自动变量
- 多线程间被几个任务共享的变量
volatile修饰的指针
int * volatile p;指的是这个指针指向的地址可能随时发生改变volatile int * p;说的是指针指向的内存的内容可能会被改变volatile int * volatile p;此时指针指向的地址和指针指向的内容都可能发生改变一个参数可以既是const又是volatile吗
- 可以,用const和volatile同时修饰变量,表示这个变量在程序内部是只读的,不能改变的,只在程序外部条件变化下改变,并且编译器不会优化这个变量。每次使用这个变量时,都要小心地去内存读取这个变量的值,而不是去寄存器读取它的备份
- 在此一定要注意const的意思,const只是不允许程序中的代码改变某一变量,其在编译期发挥作用,它并没有实际地禁止某段内存的读写特性
全局变量和局部变量有什么区别?操作系统和编译器是怎么知道的
- 全局变量是整个程序都可访问的变量,谁都可以访问,生存期在整个程序从运行到结束(在程序结束时所占内存释放)
- 而局部变量存在于模块(子程序,函数)中,只有所在模块可以访问,其他模块不可直接访问,模块结束(函数调用完毕),局部变量消失,所占据的内存释放
- 操作系统和编译器,可能是通过内存分配的位置来知道的,全局变量分配在全局数据段并且在程序开始运行的时候被加载.局部变量则分配在堆栈⾥⾯
简述strcpy、sprintf 与memcpy 的区别
- 操作对象不同,strcpy 的两个操作对象均为字符串,sprintf 的操作源对象可以是多种数据类型, ⽬的操作对象是字符串,memcpy 的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型
- 执行效率不同,memcpy 最高,strcpy 次之,sprintf 的效率最低
- 实现功能不同,strcpy 主要实现字符串变量间的拷贝,sprintf 主要实现其他数据类型格式到字 符串的转化,memcpy 主要是内存块间的拷贝
请解析((void ()())0)()的含义
- void()是一个没有输入参数的函数类型
- void()()是将一个空对象转换为如上的函数类型
- (void ()()) 整个部分的意思是将一个空函数转换为一个没有参数并且返回 void 的函数
- 0 是一个整数常量
- (void ()())0 这部分的意思是将整数常量 0 强制转换为一个没有参数并且返回 void 的函数。实际上, 它被解释为一个函数指针, 其值为 NULL
- ((void ()())0)() 是对前面那个 NULL 函数指针的调用
C语言的指针和引用和c++的有什么区别
- 指针有自⼰的一块空间,而引用只是一个别名
- 使用sizeof看一个指针的大小是4,而引用则是被引用对象的大小
- 作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引 用的修改都会改变引用所指向的对象
- 可以有const指针,但是没有const引用
- 指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变
- 指针可以有多级指针(**p),而引用⽌于一级
- 指针和引用使用++运算符的意义不一样
- 如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄露
typedef 和define 有什么区别
- 用法不同:typedef 用来定义一种数据类型的别名,增强程序的可读性。define 主要用来定义 常量,以及书写复杂使用频繁的宏
- 执行时间不同:typedef 是编译过程的一部分,有类型检查的功能。define 是宏定义,是预编译的部分,其发生在编译之前,只是简单的进行字符串的替换,不进行类型的检查
- 作用域不同:typedef 有作用域限定。define 不受作用域约束,只要是在define 声明后的引用都是正确的
- 对指针的操作不同:typedef 和define 定义的指针时有很大的区别
- typedef 定义是语句,因为句尾要加上分号。而define 不是语句,千万不能在句尾加分号
指针常量与常量指针区别
- 指针常量是指定义了一个指针,这个指针的值只能在定义时初始化,其他地方不能改变。常量指针是指定义了一个指针,这个指针指向一个只读的对象,不能通过常量指针来改变这个对象的值,但是可以改变其指向的对象。指针常量强调的是指针的不可改变性,而常量指针强调的是指针对其所指对象的不可改变性
- 无论是指针常量还是常量指针,其最大的用途就是作为函数的形式参数,保证实参在被调用 函数中的不可改变特性
简述队列和栈的异同
- 队列和栈都是线性存储结构,但是两者的插入和删除数据的操作不同,队列是“先进先出”,栈是 “后进先出”。
设置地址为0x67a9 的整型变量的值为0xaa66
int *ptr;
ptr = (int *)0x67a9;
*ptr = 0xaa66; - 无论在什么平台地址长度和整型数据的长度是一样的
如何避免“野指针”
- 指针变量声明时没有被初始化。解决办法:指针声明时初始化,可以是具体的地址值,也可让它指向NULL。
- 指针p被free或者delete之后,没有置为NULL。解决办法:指针指向的内存空间被释放后指针应该指向NULL。
- 指针操作超越了变量的作用范围。解决办法:在变量的作用域结束前释放掉变量的地址空间并且让指针指向NULL
句柄和指针的区别和联系是什么
- 句柄和指针其实是两个截然不同的概念。Windows系统用句柄标记系统资源,隐藏系统的信息。你只要知道有这个东⻄,然后去调用就行了,它是个32it的uint。指针则标记某个物理内存地址,两者是不同的概念
说一说extern“C”
- extern “C”的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern “C”后,会指示编译器这部分代码按C语言(而不是C++)的方式进行编译。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名
- 这个功能⼗分有用处,因为在C++出现以前,很多代码都是C语言写的,而且很底层的库也是C语言写的,为了更好的支持原来的C代码和已经写好的C语言库,需要在C++中尽可能的支持C,而extern “C”就是其中的一个策略
- C++代码调用C语言代码
- 在C++的头文件中使用
- 在多个⼈协同开发时,可能有的⼈比较擅长C语言,而有的⼈擅⻓C++,这样的情况下也会有用到
C++⾥⾯的三个智能指针: shared_ptr, weak_ptr, unique_ptr
- unique_ptr实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露(例如“以new创建对象后因为发生异常而忘记调用delete”)特别有用
- unique_ptr还有更聪明的地方:当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁⽌这么做
- shared_ptr实现共享式拥有概念。多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字share就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。
可以通过成员函数use_count()来查看资源的所有者个数。除了可以通过new来构造,还可以通过传入auto_ptr,unique_ptr,weak_ptr来构造。当我们调用release()时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放 - weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared_ptr. weak_ptr只是提供了对管理对象的一个访问⼿段。weak_ptr 设计的⽬的是为配合shared_ptr 而引入的一种智能指针来协助 shared_ptr ⼯作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少
- 封装:将客观事物封装成抽象的类,而类可以把自⼰的数据和方法暴露给可信的类或者对象,对不可信的类或对象则进行信息隐藏
- 继承:可以使用现有类的所有功能,并且无需重新编写原来的类即可对功能进行拓展
- 多态:一个类实例的相同方法在不同情形下有不同的表现形式,使不同内部结构的对象可以共享相同的外部接口
C++中类成员的访问权限
- C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。在类的内部(定义类的代码内部),无论成员被声明为 public、
protected 还是 private,都是可以互相访问的,没有访问权限的限制。在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员多态的实现有哪几种
- 多态分为静态多态和动态多态。其中,静态多态是通过重载和模板技术实现的,在编译期间确定;动态多态是通过虚函数和继承关系实现的,执行动态绑定,在运行期间确定。
模板类/函数是如何实现的
- 在编译阶段实现的
- 模板实例化
- 当编译器遇到使用模板的代码时,会在编译阶段根据传递的具体类型参数对模板进行实例化
- 内联展开
- 在模板实例化的过程中,编译器可以对模板函数进行内联优化,减少调用开销
- 编译期优化
- 模板类一直到模板参数的类型被确定,才会生成具体的代码
- C++的编译模型是每个源文件单独编译(编译单元的概念),在某个源文件中使用模板时,编译器需要能够看到完整的模板定义结合具体使用的场景,才能生成具体类型的模板实例。
- 如果你只在头文件中声明模板类或函数,而把实现放在源文件中,那么当其他源文件在使用这个模板时,编译器无法看到模板的实现,就无法实例化该模板
什么是模板元编程
- 使用模板在编译期执行计算或逻辑操作,通过递归模板实例化和类型推导来完成各种复杂的逻辑。例如,可以通过递归模板计算编译期常量、创建元函数(在编译期执行的函数)以及操纵类型
- 通过递归调用模板,不断地实例化模板,直到递归终止条件满足。
- 允许程序员编写与类型无关的代码,然后在使用时传入具体的类型进行实例化
动态多态有什么作用?有哪些必要条件?
- 动态多态的作用
- 隐藏实现细节,使代码模块化,提高代码的可复用性
- 接口重用,使派生类的功能可以被基类的指针/引用所调用,即向后兼容,提高代码的可扩充性和可维护性
- 动态多态的必要条件
- 当编译器发现类中有虚函数时,会创建一张虚函数表,把虚函数的函数入口地址放到虚函数表中,并且在对象中增加一个指针
vptr,用于指向类的虚函数表。当派生类覆盖基类的虚函数时,会将虚函数表中对应的指针进行替换,从而调用派生类中覆盖后的虚函数,从而实现动态绑定纯虚函数有什么作用?如何实现
- 定义纯虚函数是为了实现一个接口,起到规范的作用,想要继承这个类就必须覆盖该函数
- 实现方式是在虚函数声明的结尾加上 = 0 即可
虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表是怎么维护的
- 虚函数表是针对类的,类的所有对象共享这个类的虚函数表,因为每个对象内部都保存一个指向该类虚函数表的指针 vptr ,每个对象的 vptr 的存放地址都不同,但都指向同一虚函数表
为什么基类的构造函数不能定义为虚函数
- 虚函数的调用依赖于虚函数表,而指向虚函数表的指针 vptr 需要在构造函数中进行初始化,所以无法调用定义为虚函数的构造函数
为什么基类的析构函数需要定义为虚函数
- 为了实现动态绑定,基类指针指向派生类对象,如果析构函数不是虚函数,那么在对象销毁时,就会调用基类的析构函数,只能销毁派生类对象中的部分数据,所以必须将析构函数定义为虚函数,从而在对象销毁时,调用派生类的析构函数,从而销毁派生类对象中的所有数据
构造函数和析构函数能抛出异常吗
- 从语法的角度来说,构造函数可以抛出异常,但从逻辑和⻛险控制的角度来说,尽量不要抛出异常,否则可能导致内存泄漏
- 析构函数不可以抛出异常,如果析构函数抛出异常,则异常点之后的程序,比如释放内存等操作,就不会被执行,从而造成内存泄露的问题;而且当异常发生时,C++通常会调用对象的析构函数来释放资源,如果此时析构函数也抛出异常,即前一个异常未处理又出现了新的异常,从而造成程序崩溃的问题
如何让一个类不能实例化
- 将类定义为抽象类(也就是存在纯虚函数)或者将构造函数声明为 private
影响类的大小的因素
- 类的非静态变量(静态变量不影响)
- 虚函数表指针
vptr - 内存填充
多继承存在什么问题?如何消除多继承中的二义性
- 增加程序的复杂度,使得程序的编写和维护比较困难,容易出错
- 在继承时,基类之间或基类与派生类之间发生成员同名时,将出现对成员访问的不确定性,即同名二义性;消除同名二义性的方法
- 利用作用域运算符 :: ,用于限定派生类使用的是哪个基类的成员
- 在派生类中定义同名成员,覆盖基类中的相关成员
- 当派生类从多个基类派生,而这些基类又从同一个基类派生,则在访问此共同基类的成员时,将产生另一种不确定性,即路径二义性
- 消除路径二义性的方法
- 覆盖是指派生类中重新定义的函数,其函数名、参数列表、返回类型与父类完全相同,只是函数体存在区别;覆盖只发生在类的成员函数中
- 重载是指两个函数具有相同的函数名,不同的参数列表,不关心返回值;当调用函数时,根据传递的参数列表来判断调用哪个函数;重载可以是类的成员函数,也可以是普通函数
简述类成员函数的重写、重载和隐藏的区别
- 重写和重载主要有以下几点不同
- 范围的区别:被重写的和重写的函数在两个类中,而重载和被重载的函数在同一个类中。
- 参数的区别:被重写函数和重写函数的参数列表一定相同,而被重载函数和重载函数的参数列表一 定不同。
- virtual 的区别:重写的基类中被重写的函数必须要有virtual 修饰,而重载函数和被重载函数可以被 virtual 修饰,也可以没有。
- 隐藏和重写、重载有以下几点不同
- 与重载的范围不同:和重写一样,隐藏函数和被隐藏函数不在同一个类中。
- 参数的区别:隐藏函数和被隐藏的函数的参数列表可以相同,也可不同,但是函数名肯定要相同。当参数不相同时,无论基类中的参数是否被virtual 修饰,基类的函数都是被隐藏,而不是被重写。
- 虽然重载和覆盖都是实现多态的基础,但是两者实现的技术完全不相同,达到的⽬的也是完 全不同的,覆盖是动态绑定的多态,而重载是静态绑定的多态
拷贝构造函数和赋值运算符重载之间有什么区别?
- 拷贝构造函数用于构造新的对象
- 正常使用等号赋值的时候调用的是赋值运算符而不是拷贝构造函数,只有在构造的时候使用等号会调用拷贝构造函数
Student s;
Student s1 = s; // 隐式调用拷贝构造函数
Student s2(s); // 显式调用拷贝构造函数 - 赋值运算符重载用于将源对象的内容拷贝到⽬标对象中,而且若源对象中包含未释放的内存需要先将其释放
Student s;
Student s1;
s1 = s; // 使用赋值运算符 - 一般情况下,类中包含指针变量时需要重载拷贝构造函数、赋值运算符和析构函数
对虚函数和多态的理解
- 多态的实现主要分为静态多态和动态多态,静态多态主要是重载,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定。举个例子:一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数
- 虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段(.text)中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率
C++中struct和class的区别
- 默认继承权限不同,class继承默认是private继承,而struct默认是public继承
- class还可用于定义模板参数,像typename,但是关键字struct不能同于定义模板参数
保留struct的意义
- 保证与C语言的向下兼容性,C++必须提供一个struct
- C++中的struct定义必须百分百地保证与C语言中的struct的向下兼容性,把C++中的最基本的对象单元规定为class而不是struct,就是为了避免各种兼容性要求的限制
- 对struct定义的扩展使C语言的代码能够更容易的被移植到C++中
说说强制类型转换运算符
static_cast- 用于非多态类型的转换
- 不执行运行时类型检查(转换安全性不如 dynamic_cast)
- 通常用于转换数值数据类型(如 float -> int)
- 可以在整个类层次结构中移动指针,子类转化为父类安全(向上转换),不能父类转化为子类
dynamic_cast- 用于多态类型的转换
- 执行行运行时类型检查
- 只适用于指针或引用
- 对不明确的指针的转换将失败(返回 nullptr),但不引发异常
- 可以在整个类层次结构中移动指针,包括向上转换、向下转换
const_cast- 用于删除 const、volatile 和 __unaligned 特性(如将 const int 类型转换为 int 类型 )
reinterpret_cast- 用于位的简单重新解释
- 滥用 reinterpret_cast 运算符可能很容易带来⻛险。除非所需转换本身是低级别的,否则应使用其他强制转换运算符之一
- 允许将任何指针转换为任何其他指针类型(如 char* 到 int* 或 One_class* 到 Unrelated_class* 之类的转换,但其本身并不安全)
- 也允许将任何整数类型转换为任何指针类型以及反向转换
- reinterpret_cast 运算符不能丢掉 const、volatile 或 __unaligned 特性
- reinterpret_cast 的一个实际用途是在哈希函数中,即,通过让两个不同的值几乎不以相同的索引结尾的⽅式将值映射到索引
左值和右值有什么区别
是否可以对表达式取地址。
- 可以获取地址的表达式就是左值,且持久性变量都是左值,临时变量(纯右值)和即将离开作用域的变量(将亡值)则是右值,不能取地址
- 一定程度是因为临时变量有时候是存储在寄存器里的,因此不能取内存地址
- 变量都是左值
- 因此可以对右值引用类型变量取地址
- 右值引用是左值
产生临时变量或字面常量的表达式都是右值
右值值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值
使用move语义可以减少一次拷贝,将其转化为移动
左值引用通常也不能绑定到右值,但常量左值引用是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化
拷贝和移动的区别
拷贝会产生新的内存,而移动不会
通过拷贝获得的对象状态改变,不会影响到源对象,而通过移动获得的对象状态改变,会影响到源对象,而且被移动的源对象失去所有资源的控制权
拷贝会增加内存申请和数据复制的开销,而移动不会
引用折叠规则:所有的右值引用叠加到右值引用上仍然是一个右值引用,其他引用折叠都为左值引用。当T&&为模板参数时,输入左值,它将变成左值引用,输入右值则变成具名的右值应用。
想实现移动语义,需要实现移动构造函数和移动赋值函数,会被move强制调用
返回右值引用变量时,需要使用
std::move()移动转发或者std::forward()完美转发在C++中,我们希望写一个模板函数,这个函数可以将参数完美地传递给另一个函数,而不丢失任何信息,包括左值、右值和常量性等特性。这就需要用到完美转发
std::forward是一个条件性地将对象转发为左值或右值的函数:- 如果传递给
std::forward的参数是左值,则返回左值引用。 - 如果传递给
std::forward的参数是右值,则返回右值引用。
- 如果传递给
万能引用(Universal References):如果函数模板的参数类型是
T&&,并且是在模板参数推导的上下文中,那么它是一个万能引用,可以绑定到任何类型的引用make_unique和直接new的区别
效率提升:make_shared在单次的动态内存分配中同时创建了控制块(包含引用计数等信息)和数据对象,这通常比shared_ptr的构造函数中的两次内存分配(一次分配控制块,一次分配数据对象)更高效。
空间优化:由于make_shared将控制块和数据对象存储在同一个内存块中,因此它通常比使用shared_ptr的构造函数更节省内存。
异常安全:使用make_shared可以避免某些类型的异常安全问题。如果你在构造shared_ptr对象时抛出异常(例如,因为无法分配足够的内存),make_shared能确保不会发生内存泄漏。
支持weak_ptr:只有通过make_shared或allocate_shared创建的shared_ptr对象,才能被转换为weak_ptr。如果你打算使用weak_ptr,那么你应该使用make_shared。
不适用于使用make系列函数的场景包括:自定义析构器、以及期望直接传递大括号初始化
RTTI是什么?其原理是什么
- 运行时类型识别,其功能由两个运算符实现:
- typeid 运算符,用于返回表达式的类型,可以通过基类的指针获取派生类的数据类型;
- dynamic_cast 运算符,具有类型检查的功能,用于将基类的指针或引用安全地转换成派生类的指针或引用
C++的空类有哪些成员函数
- 缺省构造函数
- 缺省拷贝构造函数
- 省析构函数
- 赋值运算符
- 取址运算符
- 取址运算符 const
- 但是如果声明了一自定义的拷贝构造函数、拷贝赋值函数、移动构造函数、析构函数中的一个或者多个,编译器都不会再生成默认版本。所以在C++11中,拷贝构造函数、拷贝赋值函数、移动构造函数和移动赋值函数必须同时提供,或者同时不提供,只声明其中一种的话,类都仅能实现一种语义
模板函数和模板类的特例化
- 编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化
- 模板函数特例化
- 必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参
template<typename T> //模板函数
int compare(const T &v1,const T &v2)
{
if(v1 > v2) return -1;
if(v2 > v1) return 1;
return 0;
}
//模板特例化,满⾜针对字符串特定的比较,要提供所有实参,这⾥只有一个T
template<>
int compare(const char* const &v1,const char* const &v2)
{
return strcmp(p1,p2);
}
- 必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参
- 类模板特例化
- 原理类似函数模板,不过在类中,我们可以对模板进行特例化,也可以对类进行部分特例化。对类进行特例化时,仍然用template<>表示是一个特例化版本
template<>
class hash<sales_data>
{
size_t operator()(sales_data& s);
//⾥⾯所有T都换成特例化类型版本sales_data
//按照最佳匹配原则,若T != sales_data,就用普通类模板,否则,就使用含有特定功能的特例化版本。
};
- 原理类似函数模板,不过在类中,我们可以对模板进行特例化,也可以对类进行部分特例化。对类进行特例化时,仍然用template<>表示是一个特例化版本
- 不必为所有模板参数提供实参,可以指定一部分而非所有模板参数,一个类模板的部分特例化本身仍是一个模板,使用它时还必须为其特例化版本中未指定的模板参数提供实参
template<typename T>
class Foo
{
void Bar();
void Barst(T a)();
};
template<>
void Foo<int>::Bar()
{
//进行int类型的特例化处理
cout << "我是int型特例化" << endl;
}
Foo<string> fs;
Foo<int> fi;//使用特例化
fs.Bar();//使用的是普通模板,即Foo<string>::Bar()
fi.Bar();//特例化版本,执行Foo<int>::Bar()
//Foo<string>::Bar()和Foo<int>::Bar()功能不同拷贝初始化、直接初始化和赋值
ClassTest ct1("ab");这条语句属于直接初始化,它不需要调用复制构造函数,直接调用构造函数ClassTest(constchar *pc),所以当复制构造函数变为私有时,它还是能直接执行的。ClassTest ct2 = "ab";这条语句为复制初始化,它首先调用构造函数ClassTest(const char* pc)函数创建一个临时对象,然后调用复制构造函数,把这个临时对象作为参数,构造对象ct2;所以当复制构造函数变为私有时,该语句不能编译通过。ClassTest ct3 = ct1;这条语句为复制初始化,因为 ct1 本来已经存在,所以不需要调用相关的构造函数,而直接调用复制构造函数,把它值复制给对象 ct3;所以当复制构造函数变为私有时,该语句不能编译通过。ClassTest ct4(ct1)这条语句为复制初始化,因为 ct1 本来已经存在,调用复制构造函数,生成对象ct3 的副本对象 ct4。所以当复制构造函数变为私有时,该语句不能编译通过。
如何对lambda表达式传递引用
- 在方括号中使用
&或&<变量名>符号传递引用,使用=或直接写变量名传递值
C++的STL
什么是STL
- C++ STL从⼴义来讲包括了三类:算法,容器和迭代器
- 算法包括排序,复制等常用算法,以及不同容器特定的算法
- 容器就是数据的存放形式,包括序列式容器和关联式容器,序列式容器就是list,vector等,关联式容器就是set,map等
- 迭代器就是在不暴露容器内部结构的情况下对容器的遍历
什么时候需要用hash_map,什么时候需要用map
- 总体来说,hash_map 查找速度会比 map 快,而且查找速度基本和数据数据量大小,属于常数级别;而 map 的查找速度是 log(n) 级别
- 并不一定常数就比 log(n) 小,hash 还有 hash 函数的耗时,明⽩了吧,如果你考虑效率,特别是在元素达到一定数量级时,考虑考虑 hash_map。但若你对内存使用特别严格,希望程序尽可能少消耗内存,那么一定要小心,hash_map 可能会让你陷入尴尬,特别是当你的 hash_map 对象特别多时,你就更无法控制了。而且 hash_map的构造速度较慢
hashtable的底层实现
- STL中的hashtable使用的是开链法解决hash冲突问题
- hashtable中的bucket所维护的list既不是list也不是slist,而是其自⼰定义的由hashtable_node数据结构组成的linked-list,而bucket聚合体本身使用vector进行存储。hashtable的迭代器只提供前进操作,不提供后退操作
- 在hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,…,429496729],在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(vector的⻓度),其中每个bucket所维护的linked-list⻓度也等于hashtable的容量。如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置
vector 的原理
- vector底层是一个动态数组,包含三个迭代器,start和finish之间是已经被使用的空间范围,end_of_storage是整块连续空间包括备用空间的尾部
- 当空间不够装下数据(vec.push_back(val))时,会自动申请另一⽚更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那⽚空间
- 当释放或者删除(vec.clear())⾥⾯的数据时,其存储空间不释放,仅仅是清空了⾥⾯的数据
- 因此,对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器会都失效了
vector的线程安全问题
- 读写竞争
- 一个线程在向 vector 添加元素,而另一个线程在读取元素。此时,可能会由于内存重新分配或者修改数据结构导致读取错误的结果。
- 如果两个线程同时修改同一个 vector,例如一个线程添加元素,另一个线程删除元素,可能会破坏 vector 的内部结构。
- 迭代器失效
std::vector会在某些操作时重新分配内存(例如,扩容操作)。这种操作会使得指向原始内存的迭代器、指针或引用失效。如果一个线程在添加元素时触发了内存重新分配,而另一个线程正使用迭代器遍历 vector,就可能导致严重的运行时错误
- 如果所有线程仅进行只读操作(不修改 vector 的内容),那么在大多数情况下,
std::vector是线程安全的
reserve和resize
- reserve是直接扩充到已经确定的大小,可以减少多次开辟、释放空间的问题(优化push_back)可以提高效率
- 还可以减少多次要拷贝数据的问题。reserve只是保证vector中的空间大小(capacity)最少达到参数所指定的大小n。
- reserve()只有一个参数
- vector 的 resize(n) ⽅法改变 vector 的大小,如果当前容量小于 n ,则调整容量为 n ,只把新增的元素填充为初始值;如果当前容量大于等于 n ,则什么都不做
size和capacity
- size表示当前vector中有多少个元素(finish - start),而capacity函数则表示它已经分配的内存中可以容纳多少元素(end_of_storage - start)
vector的元素类型可以是引用吗?
- vector的底层实现要求连续的对象排列,引用并非对象,没有实际地址,因此vector的元素类型不能是引用
vector迭代器失效的情况
- 当插入一个元素到vector中,由于引起了内存重新分配,所以指向原内存的迭代器全部失效。
当删除容器中一个元素后,该迭代器所指向的元素已经被删除,那么也造成迭代器失效。erase⽅法会返回下一个有效的迭代器,所以当我们要删除某个元素时,需要it=vec.erase(it);
正确释放vector的内存(clear(), swap(), shrink_to_fit())
- vec.clear():清空内容,但是不释放内存
- vector().swap(vec):清空内容,且释放内存,想得到一个全新的vector
- vec.shrink_to_fit():请求容器降低其capacity和size匹配
- vec.clear();vec.shrink_to_fit();:清空内容,且释放内存
list 底层原理
- list的底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间
- list不支持随机存取,适合需要大量的插入和删除
常见操作
list.push_back(elem) // 在尾部加入一个数据
list.pop_back() // 删除尾部数据
list.push_front(elem) // 在头部插入一个数据
list.pop_front() // 删除头部数据
list.size() // 返回容器中实际数据的个数
list.sort() // 排序,默认由小到大
list.unique() // 移除数值相同的连续元素
list.back() // 取尾部迭代器
list.erase(iterator) // 删除一个元素,参数是迭代器,返回的是删除迭代器的下一个位置deque原理
- deque是一个双向开口的连续线性空间(双端队列),在头尾两端进行元素的插入跟删除操作都有理想的时间复杂度
常见操作
deque.push_back(elem) // 在尾部加入一个数据。
deque.pop_back() // 删除尾部数据。
deque.push_front(elem) // 在头部插入一个数据。
deque.pop_front() // 删除头部数据。
deque.size() // 返回容器中实际数据的个数。
deque.at(idx) // 传回索引idx所指的数据,如果idx越界,抛出out_of_range。
map 、set、multiset、multimap 底层原理
- map 、set、multiset、multimap的底层实现都是红⿊树,epoll模型的底层数据结构也是红⿊树,linux系统中CFS进程调度算法,也用到红⿊树
- 红⿊树的特性
- set和multiset会根据特定的排序准则自动将元素排序,set中元素不允许重复,multiset可以重复
- map和multimap将key和value组成的pair作为元素,根据key的排序准则自动将元素排序(因为红⿊树也是二叉搜索树,所以map默认是按key排序的),map中元素的key不允许重复,multimap可以重复
- map和set的增删改查速度为都是logn
为何map和set的插入删除效率比其他序列容器高,而且每次insert之后,以前保存的iterator不会失效
- 因为存储的是结点,不需要内存拷贝和内存移动
- 因为插入操作只是结点指针换来换去,结点内存没有改变。而iterator就像指向结点的指针,内存没变,指向内存的指针也不会变
为何map和set不能像vector一样有个reserve函数来预分配数据
- 因为在map和set内部存储的已经不是元素本身了,而是包含元素的结点。也就是说map内部使用的Alloc并不是
map<Key, Data, Compare, Alloc>声明的时候从参数中传入的Alloc。unordered_map、unordered_set的底层原理
- unordered_map的底层是一个防冗余的哈希表(采用除留余数法)。哈希表最大的优点,就是把数据的存储和查找消耗的时间大大降低,时间复杂度为O(1);而代价仅仅是消耗比较多的内存。
- 不能够保证每个元素的key与函数值是一一对应的,因此极有可能出现对于不同的元素,却计算出了相同的函数值,这样就产生了“冲突”,换句话说,就是把不同的元素分在了相同的“类”之中。 一般可采用拉链法解决冲突
unordered_map 与map的区别?使用场景?
- 构造函数:unordered_map 需要hash函数,等于函数;map只需要比较函数(小于函数).
- 存储结构:unordered_map 采用hash表存储,map一般采用红⿊树(RB Tree) 实现。因此其memory数据结构是不一样的。
- 总体来说,unordered_map 查找速度会比map快,而且查找速度基本和数据数据量大小,属于常数级别;而map的查找速度是log(n)级别
- 并不一定常数就比log(n)小,hash还有hash函数的耗时,如果考虑效率,特别是在元素达到一定数量级时,考虑考虑unordered_map 。但若你对内存使用特别严格,希望程序尽可能少消耗内存,那么一定要小心,unordered_map 可能会让你陷入尴尬,特别是当你的unordered_map 对象特别多时,就更无法控制了,而且unordered_map 的构造速度较慢
迭代器的底层原理
- 迭代器是连接容器和算法的一种重要桥梁,通过迭代器可以在不了解容器内部原理的情况下遍历容器。它的底层实现包含两个重要的部分:萃取技术和模板偏特化
迭代器的种类
- 输入迭代器:是只读迭代器,在每个被遍历的位置上只能读取一次。例如上⾯find函数参数就是输入迭代器。
- 输出迭代器:是只写迭代器,在每个被遍历的位置上只能被写一次。
- 前向迭代器:兼具输入和输出迭代器的能⼒,但是它可以对同一个位置重·复进行读和写。但它不支持operator–,所以只能向前移动。
- 双向迭代器:很像前向迭代器,只是它向后移动和向前移动同样容易。
- 随机访问迭代器:有双向迭代器的所有功能。而且,它还提供了“迭代器算术”,即在一步内可以向前或向后跳跃任意位置, 包含指针的所有操作,可进行随机访问,随意移动指定的步数。支持前⾯四种Iterator的所有操作,并另外支持
it + n、it - n、it += n、it -= n、it1 - it2和it[n]等操作。迭代器失效的问题
- 插入
- 对于vector和string,如果容器内存被重新分配,iterators,pointers,references失效;如果没有重新分配,那么插入点之前的iterator有效,插入点之后的iterator失效
- 对于deque,如果插入点位于除front和back的其它位置,iterators,pointers,references失效;当我们插入元素到 front和back时,deque的迭代器失效,但reference和pointers有效;
- 对于list和forward_list,所有的iterator,pointer和reference有效。
- 删除
- 对于vector和string,删除点之前的iterators,pointers,references有效;off-the-end迭代器总是失效的;
- 对于deque,如果删除点位于除front和back的其它位置,iterators,pointers,references失效;当我们插⼊元素到front和back时,off-the-end失效,其他的iterators,pointers,references有效;
- 对于list和forward_list,所有的iterator,pointer和reference有效。
- 对于关联容器map来说,如果某一个元素已经被删除,那么其对应的迭代器就失效了,不应该再被使用,否则会导致程序无定义的行为。
Vector如何释放空间
- 由于vector的内存占用空间只增不减,比如你首先分配了10,000个字节,然后erase掉后⾯9,999个,留下一个有效元素,但是内存占用仍为10,000个
- 所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素。但是即使clear(),vector所占用的内存空间依然如故,无法保证内存的回收
如何在共享内存上使用STL标准库
- 当一个元素被插⼊到一个STL列表(list)中时,列表容器自动为其分配内存,保存数据。考虑到要将STL容器放到共享内存中,而容器却自⼰在堆上分配内存
- 一个最笨拙的办法是在堆上构造STL容器,然后把容器复制到共享内存,并且确保所有容器的内部分配的内存指向共享内存中的相应区域,这基本是个不可能完成的任务
- 假设进程A在共享内存中放⼊了数个容器,进程B如何找到这些容器呢
- map的下标运算符[]的作用是:将key作为下标去执行查找,并返回相应的值;如果不存在这个key,就将一个具有该key和value的默认值插⼊这个map
map中[]与find的区别
map[key]用于访问关联键的值。如果键不存在,它会自动插入一个新的键值对,其中键为给定的键,值为该类型的默认值(例如,对于 int 类型,值为 0)map.find(key)查找给定键的迭代器。如果键不存在,返回的迭代器等于map.end()。使用 find 函数的一个优点是,它不会自动插入新的键值对。
STL内存优化
- 第一级配置器
- 当分配的内存块大小大于一个特定阈值(通常为 128 字节)时,一级内存分配器直接调用底层系统分配函数(如 malloc() 或 operator new())。当释放内存时,也会直接调用底层系统释放函数(如 free() 或 operator delete())
- 第二级配置器
- 当分配的内存块大小小于或等于特定阈值时,二级内存分配器会使用内存池(Memory Pools)来优化内存分配和释放。内存池由一系列固定大小的内存块组成,这些内存块可以有效地减少内存碎片和分配/释放操作的开销。二级内存分配器会根据请求的内存大小从适当的内存池中分配内存。当释放内存时,内存块会返回到相应的内存池以备后续使用
- 这种二级内存分配策略有效地平衡了大内存分配和小内存分配的性能。对于大内存分配,直接使用系统分配函数可以减少内存池管理的开销;而对于小内存分配,使用内存池可以显著提高内存分配和释放的性能
频繁对vector调用push_back()对性能的影响和原因
- 内存重新分配,当元素数量超过当前容量时,它需要重新分配内存以容纳新元素。每次重新分配内存都需要分配一块更大的连续内存空间,并将现有元素复制到新的内存空间中
- 当进行内存重新分配时,vector 需要将现有元素复制到新的内存空间中
C++内存管理
程序被存储在内存中的分段
代码段(Text Segment):
- 这个段存储了程序的可执行机器指令。它是只读的,因为指令在程序运行过程中不应该被修改。
数据段(Data Segment):
- 这个段用于存储程序中已初始化的全局变量和静态变量。它在程序启动时就被加载到内存中。
- 如果是const的全局变量的话,会存放在
.rodata区 - 非常量的会放在
.data区
BSS段(BSS Segment):
- BSS是”Block Started by Symbol”的缩写。这个段用于存储程序中未初始化的全局变量和静态变量。它在程序启动时被加载到内存中,并被初始化为0或NULL。
堆(Heap):
- 堆是一个在程序运行时动态分配和释放内存的区域。它用于存储动态分配的变量,如通过malloc()或new分配的内存。
栈(Stack):
- 栈是一个在程序运行时自动分配和释放内存的区域。它用于存储函数调用时的返回地址、局部变量等。栈是按后进先出(LIFO)的顺序增长和收缩的。
new/delete和malloc/free之间有什么关系
内存分配和释放:
- malloc 和 free 是 C 语言中的函数,用于分配和释放动态内存。
- new 和 delete 是 C++ 中的运算符,用于分配和释放动态内存。
类型安全:
- malloc 返回一个 void* 类型的指针,需要显式地进行类型转换。
- new 返回一个指定类型的指针,不需要显式的类型转换,提供了更好的类型安全性。
构造函数和析构函数的调用:
- malloc 只分配内存,不会调用对象的构造函数。
- new 在分配内存的同时,会自动调用对象的构造函数进行初始化。
- free 只释放内存,不会调用对象的析构函数。
- delete 在释放内存之前,会自动调用对象的析构函数进行清理。
重载:
- malloc 和 free 是函数,不能被重载。
- new 和 delete 是运算符,可以被重载,以实现自定义的内存分配和释放行为。
异常处理:
- malloc 在分配内存失败时返回 NULL,需要手动检查返回值。
- new 在分配内存失败时会抛出 std::bad_alloc 异常,可以使用异常处理机制来捕获和处理。
内存泄漏:
- 使用 malloc 分配的内存需要使用 free 显式释放,否则会导致内存泄漏。
- 使用 new 分配的内存需要使用 delete 显式释放,否则也会导致内存泄漏。
delete与delete []有什么区别
适用场景:
- delete 用于释放单个对象的内存,即使用 new 分配的内存。
- delete[] 用于释放对象数组的内存,即使用 new[] 分配的内存。
析构函数调用:
- delete 释放内存时,会调用单个对象的析构函数。
- delete[] 释放内存时,会调用对象数组中每个对象的析构函数。
内存块太小导致malloc和new返回空指针,该怎么处理
检查返回值:
- 在使用 malloc 或 new 分配内存后,始终检查返回值是否为空指针。
- 如果返回值为空指针,说明内存分配失败,需要进行相应的错误处理。
处理内存分配失败:
- 当内存分配失败时,可以根据具体情况采取不同的处理方式:
- 打印错误信息,提示用户内存不足或分配失败。
- 尝试释放一些不必要的内存资源,然后重新尝试分配内存。
- 终止程序执行,并返回适当的错误码。
使用异常处理(仅适用于 new):
- 在使用 new 分配内存时,如果分配失败,会抛出 std::bad_alloc 异常。
- 可以使用 try-catch 块来捕获这个异常,并进行相应的错误处理。
内存泄漏的场景有哪些?如何判断内存泄漏?如何定位内存泄漏?
- 忘记释放动态分配的内存:
- 使用 malloc、calloc、realloc 或 new 分配内存后,没有使用相应的 free 或 delete 进行释放。
- 指针丢失:
- 在分配内存后,将指向该内存的指针重新赋值或者指针变量超出作用域,导致无法再访问和释放该内存。
- 未考虑所有执行路径:
- 在某些条件分支或异常情况下,没有正确释放内存,导致内存泄漏。
- 循环或递归中的内存泄漏:
- 在循环或递归过程中动态分配内存,但没有在每次迭代或递归结束时释放内存,导致内存泄漏。
- 共享资源的内存泄漏:
- 多个模块或线程共享内存资源,但在使用完毕后没有正确协调和释放内存,导致内存泄漏。
- 定位内存泄漏
- 使用 Valgrind 工具
- 使用 AddressSanitizer
- 使用 GDB 调试器
- 使用系统调用跟踪
strace
- 自定义一个宏函数hook覆盖
malloc和free,增加一个检错机制 bpftrace, 可以用bpftrace监控用户对malloc等函数的调用
内存的分配⽅式有几种
- 静态内存分配(Static Memory Allocation):
- 静态内存分配是在程序编译时分配内存。全局变量、静态变量和常量都使用静态内存分配。静态内存分配的生命周期与程序的运行期相同,因此在程序运行时始终存在。
- 栈内存分配(Stack Memory Allocation):
- 栈内存分配用于自动变量,即在函数内部定义的变量(不包括静态变量和常量)。栈内存分配在函数调用时进行,变量在函数返回时自动销毁。栈内存分配速度快,但空间有限。大量的递归调用或大型局部变量可能导致栈溢出。
- 堆内存分配(Heap Memory Allocation):
- 堆内存分配用于动态分配内存。在 C++ 中,可以使用 new(或 new[])操作符动态分配内存,使用 delete(或 delete[])操作符释放内存。堆内存分配的生命周期由程序员控制,因此需要确保正确管理内存以避免内存泄漏和悬垂指针。
- 内存池(Memory Pools):
- 内存池是一种预先分配的内存块集合,用于分配和回收固定大小的内存块。内存池可以提高内存分配和回收的速度,并减少内存碎片。内存池通常用于频繁分配和释放小块内存的场景。
- 内存映射文件(Memory-Mapped Files):
- 内存映射文件是一种将文件或设备的部分区域映射到内存中的技术。通过内存映射文件,可以使用内存操作直接读写文件,提高 I/O 性能。内存映射文件在 Linux 下使用 mmap 函数实现,在 Windows 下使用 CreateFileMapping 和 MapViewOfFile 函数实现。
- 线程本地存储(Thread-Local Storage,TLS):
| 类型 | 栈 | 堆 |
|---|---|---|
| 内存分配方式 | 由操作系统自动分配和释放内存。当函数被调用时,其局部变量会自动在栈上分配内存,函数执行完毕后,内存会自动被释放 | 由程序员手动分配和释放内存。使用 malloc、calloc、realloc、free(C 语言)或 new、delete(C++)等函数或运算符来管理堆内存 |
| 访问速率 | 栈的内存访问效率通常比堆更高。因为栈是连续的内存区域,访问局部变量时可以通过偏移量快速计算内存地址 | 堆的内存访问效率相对较低。因为堆是非连续的内存区域,访问堆上的对象需要通过指针间接访问,且可能存在内存碎片化的问题 |
| 空间大小 | 栈的内存空间通常比较小,且大小是固定的。具体大小取决于操作系统和编程语言的实现,一般在几兆字节范围内 | 堆的内存空间通常比栈大得多,且大小是动态变化的。堆的大小受限于计算机的物理内存和操作系统的虚拟内存设置 |
| 生命周期 | 栈上的内存在函数执行期间自动分配和释放。当函数执行完毕时,栈帧被弹出,局部变量的内存被自动释放 | 堆上的内存由程序员手动分配和释放。内存的生命周期由程序员控制,需要显式地使用 free 或 delete 释放不再使用的内存,否则会导致内存泄漏 |
| 内存碎片 | 栈上的内存分配和释放是自动进行的,不会产生内存碎片化问题 | 频繁的内存分配和释放可能导致内存碎片化,即出现大量的小内存块,导致内存利用率降低和内存分配效率下降 |
| 灵活性 | 栈的内存分配和释放是自动进行的,因此栈的使用相对简单,但灵活性较低 | 堆的内存分配和释放由程序员手动控制,因此堆的使用更加灵活,可以根据需要动态分配和释放内存 |
| 生长方向 | 栈的生长方向是从高地址向低地址生长的 | 堆的生长方向是从低地址向高地址生长的 |
高地址 ↑ |
静态内存分配和动态内存分配有什么区别
- 类似于堆和栈的区别,略过
如何构造一个类,使得只能在堆上或只能在栈上分配内存
- 只能在堆上分配内存的类:
- 要使一个类只能在堆上分配内存,可以将类的构造和析构函数设为私有的或者protected,并提供静态的公共成员函数来创建和删除类的实例
- 一个类必须有可以访问的构造和析构函数才能在栈上分配内存
- 只能在栈上分配内存的类
- 可以将
operator new和operator delete设置为私有
- 可以将
浅拷贝和深拷贝有什么区别
浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享一块内存;而深拷贝会创造一个相同的对象,新对象与原对象不共享内存,修改新对象不会影响原对象
在C++中,使用malloc申请的内存能否通过delete释放?使用new申请的内存能否用free
使用 malloc 申请的内存不能通过 delete 释放:
- malloc 是 C 语言的内存分配函数,它只分配内存,但不调用对象的构造函数。
- delete 是 C++ 的内存释放运算符,它不仅释放内存,还会调用对象的析构函数。
- 如果使用 delete 释放通过 malloc 分配的内存,会导致未定义行为,可能会引发程序崩溃或内存泄漏等问题。
使用 new 申请的内存不能用 free 释放:
- new 是 C++ 的内存分配运算符,它不仅分配内存,还会调用对象的构造函数进行初始化。
- free 是 C 语言的内存释放函数,它只释放内存,但不调用对象的析构函数。
- 如果使用 free 释放通过 new 分配的内存,会导致未定义行为,可能会引发程序崩溃或资源泄漏等问题。
位域和字节对齐
- 可以手动指定一个结构体中的某个变量占用的位数(bit)
// 定义一个包含两个位域的结构体
struct S {
unsigned int a : 1; // 定义一个长度为 1 的位域 a
unsigned int b : 3; // 定义一个长度为 3 的位域 b
}; - 字节对齐的方式
- 位域的变量可以挨个存储(即使不是一个整的字节)
- 但是中间被非位域的变量隔开之后就需要执行字节对齐
- sizeof的最终结果必然是结构内部最大成员的整数倍,不够补齐
- 结构内部各个成员的首地址必然是自身大小的整数倍
struct S1
{
int i : 8;
char j : 4;
int a : 4;
double b;
};
struct S2
{
int i : 8;
char j : 4;
double b;
int a : 4;
};
struct S3
{
int i;
char j;
double b;
int a;
};
int main()
{
printf("%ld\n", sizeof(struct S1));
printf("%ld\n", sizeof(struct S2));
printf("%ld\n", sizeof(struct S3));
return 0;
}
- 比如上述代码中,第一个结构体的大小是16,后两个都是24
- 因为一个double是8个字节,因此double变量的位置必须是8的整数倍,意味着double之前的变量要占据8个字节,因此总共是16
- 第二个结构体前两个位域对齐到8字节(同上),然后double消耗8个字节,最后一个变量虽然不需要8个字节但是整个结构体的大小必须是8的整数倍,也就扩展到24
- 第三个结构体的第一个int自身就是4个字节,char对齐为4个字节,double本身8个字节,最后一个int虽然只需要4个字节但是结构体大小必须是8的整数倍,因此还是24