以下知识为网络组摘抄部分知识,由于摘抄太多太杂,所以没有引用,如需要引用请评论,我会加上你的文章的引用。
C/C++编译过程
预处理阶段:编译器对文件包含关系进行检查(头文件和宏),将其作相应替换,生成.i文件;
编译阶段:将预处理的生成文件转化为汇编文件.s;
汇编阶段:将汇编文件见转化为二进制机器码,对应后缀是.o(Linux), .obj(Windows);
链接阶段:将多个目标文件及所需要的库链接成可执行文件,.out(Linux), .exe(Windows);
1、预处理
预处理把一些#define的宏定义完成文本替换,然后将#include的文件里的内容复制到.cpp文件里,如果.h文件里还有.h文件,就头文件递归展开。在预处理这一步,代码注释直接被忽略,不会进入到后续的处理中,所以注释在程序中不会执行。
2、编译
编译只是把我们写的代码转为汇编代码,它的工作是检查词法和语法规则,所以,如果程序没有词法或则语法错误,那么不管逻辑是怎样错误的,都不会报错。
3、汇编
汇编过程将上一步的汇编代码转换成机器码,这一步产生的文件叫做目标文件(main.o),是二进制格式。
4、连接
C/C++代码经过汇编之后生成的目标文件(*.o)并不是最终的可执行二进制文件,而仍是一种中间文件(或称临时文件),目标文件仍然需要经过链接(Link)才能变成可执行文件。既然目标文件和可执行文件的格式是一样的(都是二进制格式),为什么还要再链接一次呢?
因为编译只是将我们自己写的代码变成了二进制形式,它还需要和系统组件(比如标准库、动态链接库等)结合起来,这些组件都是程序运行所必须的。
1 C++语法问题
1.1 malloc和free、new和delete的区别
看完这篇你还能不懂C语言/C++内存管理? - 知乎 (zhihu.com)
共同点,用于分配堆区内存,需要手动申请和手动释放。
C/C++内存分区:
栈区指的是存储一些临时变量的区域。临时变量包括了局部变量、返回值、参数、返回地址、函数调用信息等,当这些变量超出了当前作用域时将会自动弹出。该栈的最大存储是有大小的,该值固定,超过该大小将会造成栈溢出。{ int a }
堆区指的是一个比较大的内存空间,主要用于对动态内存的分配;在程序开发中一般是开发人员进行分配与释放,若在程序结束时都未释放,系统将会自动进行回收。
数据区指的是主要存放全局变量、常量和静态变量的区域,数据区又可以进行划分,分为全局区与静态区。全局变量与静态变量将会存放至该区域。{ static a }
代码区就比较好理解了,主要是存储可执行代码,该区域的属性是只读的。
C语言的内存管理机制_c语言可执行程序在调入内存之前的内存管理-CSDN博客
C++内存构成:
相同点:
malloc和new都是返回的是一个堆区地址,在使用过程中,会使用一个变量来保存该堆区地址。如果先定义指针变量p,则p保存在栈区,p的值为堆区地址。当大于128KB时,分布在mmap区域。
例如:
int *p;
p = (int *)malloc(sizeof(int));
free(p);
此时,p在栈区,申请的内存在堆区,所以在退出p的作用域之前需要释放堆区内存,否则在程序结束之前将永远也释放不了堆区的内存。
不同点:
2、对于new和delete
malloc和new语法的区别:
1、malloc使用手动计算大小,new可以直接用类型,不需要手动计算。
2、malloc返回的是void*,new返回的是具体的类型指针,不需要强制转化。
3、new分配失败会返回异常,malloc会返回null。
4、malloc不会初始化,不会调用构造函数,new会。这导致了malloc是在读取虚拟内存的对应物理内存时调用缺页中断,而new已经分配了物理内存,不会缺页中断。
free和delete的区别:
1、free在堆上开始释放内存时,对应的位置会保存当前块的元信息,包含了大小。所以free可以输入void*,而delete要申明释放的类型,即不能是void*型
2、new和delete都可以重载,按照需求重写:可以重写分配空间的位置进行重载
1、new/delete 除了分配内存和释放内存(与 malloc/free),还做其它的事情:
对于类:new 相对于 malloc 会额外的做一些初始化工作,delete 相对于 free 多做一些清理工作:会调用构造函数和析构函数。malloc和free不会,只会释放内存。
new 一般使用格式如下:
- 指针变量名 = new 类型标识符;
- 指针变量名 = new 类型标识符(初始值);
- 指针变量名 = new 类型标识符[内存单元个数];
2、delete 和 delete []
delete [] 会按照申请的数量调用析构函数,delete 只会调用一次析构函数。
当调用delete[] a时,会分别调用10次析构函数,从而释放每一个A[i]的指针指向的堆区内存。
1.1.2 malloc 是如何分配内存的?
实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过 brk() 系统调用从堆分配内存
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存;
方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。
方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。
malloc() 源码里默认定义了一个阈值:
- 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。例如malloc 1个字节时会分配更大的内存池。
free并不一定会归还给操作系统:
这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。
当然,当进程退出后,操作系统就会回收进程的所有资源。
- 通过 brk() 释放内存后,堆内存还是存在的,并不一定归还给操作系统。因为减少系统调用。
- 如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归还给操作系统。
随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
malloc() 分配的是虚拟内存,使用时会触发缺页中断。
如果只用brk() 会怎么样?
如果只用brk(),随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
free怎么知道free多少内存?
malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节吗?这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。
这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
1.2 C++的堆栈空间有多大,如何查询?
在C++中,堆栈空间的大小并不是固定的,而是取决于许多因素,包括操作系统、编译器、硬件配置以及程序的具体需求。
栈(Stack)空间主要用于存储局部变量和函数调用的信息。每个线程通常都有自己的栈空间,这个空间的大小在程序运行时由操作系统和/或线程库决定。在大多数情况下,你不能直接查询或设置栈空间的大小,除非你使用特定的操作系统或编译器特性。
堆(Heap)空间用于动态内存分配,例如通过new和delete操作符。堆空间的大小通常受限于可用物理内存和操作系统的内存管理策略。你也不能直接查询整个堆空间的大小,但你可以查询你的程序已经分配了多少堆内存。这通常需要使用特定的库函数或工具,例如使用Valgrind等内存分析工具。
1.3 指针和引用的区别
1、指针是一个实体,需要分配内存空间。引用只是变量的别名,不需要分配内存空间。
2、引用在定义的时候必须进行初始化,并且不能够改变。指针在定义的时候不一定要初始化,并且指向的空间可变。(注:不能有引用的值不能为NULL)
3、有多级指针,但是没有多级引用,只能有一级引用。
4、指针和引用的自增运算结果不一样。(指针是指向下一个空间,引用时引用的变量值加1)
5、sizeof 引用得到的是所指向的变量(对象)的大小,而sizeof 指针得到的是指针本身的大小。
6、引用访问一个变量是直接访问,而指针访问一个变量是间接访问。
7、引用底层是通过常指针实现的,const *;
作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引用的实质是传地址,传递的是变量的地址。
安全性:引用的对象不能改变,安全性好;但指针指向的对象是可以改变的,不能保证安全性。
方便性:引用实际上是封装好的指针解引用(即b->*b),可以直接使用;但指针需要手动解引用,使用更麻烦;
级数:引用只有一级,不能多次引用;但指针的级数没有限制。
初始化:引用必须被初始化为一个已有对象的引用,而指针可以初始化为NULL。
int x=3;
int &i =x;
int y=1;
i=y;
cout<<i<<x;
// 输出为11
// i=y 不会将i的引用变成y,而是将y的值传递给i的引用,即将1传递给x,所以最后x是1
1.4 各种关键字
1、static 用法
1、用于隐藏当前文件的全局变量,即:全局变量本来是整个程序运行时的全局变量,再加上static之后,该全局变量只在当前文件生效,其它文件可以再定义同名全局变量
2、全局变量与static变量初始值为0
3、类里面的static相当于类中的全局变量,可以用来统计一共定义了多少类实例。
2、volatile
直接读取内存的。
C++中的volatile和const对应。表示变量可以被编译器未知的因素所更改,比如操作系统,硬件或者其它线程。遇到volatile,编译器就不再进行优化,从而提供对特殊地址的稳定访问。
一般说来,volatile用在如下的几个地方:
- 中断服务程序中修改的供其它程序检测的变量需要加 volatile;
- 多任务环境下各任务间共享的标志应该加 volatile;
- 存储器映射的硬件寄存器通常也要加 volatile 说明,因为每次对它的读写都可能由不同意义;
有些变量是用 volatile 关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用 volatile 声明,该关键字的作用是防止优化编译器把变量从内存装入 CPU 寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile 的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,
3、extern
extern 用于指示变量或函数的定义在另一个源文件中,并在当前源文件中声明。 说明该符号具有外部链接(external linkage)属性。也就是告诉编译器: 这个符号在别处定义了,你先编译,到时候链接器会去别的地方找这个符号定义的地址。
4、显示类型转换
1、static_cast
隐式指针不能转换成其它的
常用于基本内置类型变量进行类型转换,例如不同数据变量和不同指针类型,类之间的类型转换,上行转化有效,下行转化无效。
2、const_cast
唯一一个可以去除const的强制类型转换
3、reinterpret_cast
重新解释,
4、dynamic_cast
运行时安全的转换,运行时开销,其它都是在编译时进行转换。真正用于上下行转换的关键字。
static_cast:用于各种隐式转换,比如void*转ptr*。
const_cast: 用来移除变量的const或volatile限定符。
dynamic_cast:安全的向下进行类型转换。只能用于含有虚函数的类,只能转指针或引用。
reinterpret_cast:允许将任何指针转换为任何其他指针类型,并不安全。
4、inline
将一小段函数代码在编译时进行展开,而不用频繁调用函数。但是也不一定会展开,要看代码的多少。inline相当于定义一个函数,会检查函数的所有特性,包括语法词法以及返回值,而宏定义不检查这些。
内联函数和宏定义都可以用来实现代码的快速执行,但它们有以下几点区别:
参数类型安全性: 内联函数比宏定义更加类型安全。内联函数会对参数进行类型检查,而宏定义不会。这意味着,使用内联函数可以避免一些潜在的类型错误。
编译器优化: 内联函数是在编译期间展开的,因此它可以进行更多的编译器优化。而宏定义则是在预处理器展开,不能进行编译器优化。因此,使用内联函数通常可以获得更好的性能。
调试: 内联函数比宏定义更容易进行调试。因为内联函数是实际函数的一份副本,可以通过调试器跟踪到内联函数的执行过程。而宏定义则无法通过调试器进行调试。
名称空间: 内联函数位于名称空间中,而宏定义不属于任何名称空间。这意味着,内联函数可以避免名称冲突问题,而宏定义可能会导致名称冲突。
大小和可读性: 内联函数比宏定义更易于阅读和维护。宏定义的代码通常比较冗长,而内联函数则可以使用常规的C++语法编写,更加简洁易懂。另外,内联函数可以利用C++的函数重载和模板等特性,提高代码的可读性和可维护性。
总结: 虽然内联函数和宏定义都可以用来实现代码的快速执行,但内联函数通常比宏定义更优秀,因为它更加类型安全、可读性更强、容易调试和进行编译器优化。
1.5 虚构函数和构造函数可以抛出异常吗?
从语法上来说,构造函数和析构函数都可以抛出异常。但从逻辑上和风险控制上,构造函数和析构函数中尽量不要抛出异常,万不得已,一定要注意防止内存泄露。在析构函数中抛出异常还要注意栈展开带来的程序崩溃。
在构造函数中抛出异常,当前对象的析构函数不会被调用,如果在构造函数中分配了内存,那么就会造成内存泄露,所以要格外注意。
在面对析构函数中抛出异常时,程序猿要注意以下几点:
(1)C++中析构函数的执行不应该抛出异常;
(2)假如析构函数中抛出了异常,那么你的系统将变得非常危险,也许很长时间什么错误也不会发生;但也许你的系统有时就会莫名奇妙地崩溃而退出了,而且什么迹象也没有,不利于系统的错误排查;
(3)当在某一个析构函数中会有一些可能(哪怕是一点点可能)发生异常时,那么就必须要把这种可能发生的异常完全封装在析构函数内部,决不能让它抛出函数之外。
那么如果无法保证在析构函数中不发生异常, 该怎么办?
其实还是有很好办法来解决的。那就是把异常完全封装在析构函数内部,决不让异常抛出析构函数之外。这是一种非常简单,也非常有效的方法。
1.6 内存泄漏如何检测?
C/C++ 内存泄露如何定位、检测以及避免 | 编程指北 (csguide.cn)
用现有的工具进行检测。此外,这些工具的内存检测方式无非也分为两种:
检测内存泄露的方法:
- 手动检查代码:仔细检查代码中的内存分配和释放,确保每次分配内存后都有相应的释放操作。比如 malloc和free、new和delete是否配对使用了。
- 使用调试器和工具:有一些工具可以帮助检测内存泄露。例如:
Valgrind(仅限于Linux和macOS):Valgrind是一个功能强大的内存管理分析工具,可以检测内存泄露、未初始化的内存访问、数组越界等问题。使用Valgrind分析程序时,只需在命令行中输入valgrind --leak-check=yes your_program即可。
Visual Studio中的CRT(C Runtime)调试功能:Visual Studio提供了一些用于检测内存泄露的C Runtime库调试功能。例如,_CrtDumpMemoryLeaks函数可以在程序结束时报告内存泄露。
AddressSanitizer:AddressSanitizer是一个用于检测内存错误的编译器插件,适用于GCC和Clang。要启用AddressSanitizer,只需在编译时添加-fsanitize=address选项。
1、维护一个内存操作链表,当有内存申请操作时,将其加入此链表中,当有释放操作时,从申请操作从链表中移除。如果到程序结束后此链表中还有内容,说明有内存泄露了;如果要释放的内存操作没有在链表中找到对应操作,则说明是释放了多次。使用此方法的有VC内置的调试工具,Visual Leak Detecter,mtrace, memwatch, debug_new。
2、模拟进程的地址空间。仿照操作系统对进程内存操作的处理,在用户态下维护一个地址空间映射,此方法要求对进程地址空间的处理有较深的理解。因为Windows的进程地址空间分布不是开源的,所以模拟起来很困难,因此只支持Linux。采用此方法的是valgrind。
2 多态与虚函数 virtual
2.1 什么是多态?
C++中的多态(Polymorphism)是面向对象编程的三大特性之一,另外两个是封装和继承。多态允许我们使用父类类型的指针或引用来调用子类的成员函数,从而实现运行时多态。C++中多态的实现主要依赖于虚函数(Virtual Function)和纯虚函数(Pure Virtual Function)。
2.1.1 虚函数 virtual
在基类中声明为虚函数的成员函数,可以在派生类中被重写(Override)。当使用基类指针或引用来调用虚函数时,会根据指针或引用实际指向的对象类型来调用相应的虚函数实现。
2.1.2 纯虚函数
纯虚函数是一种特殊的虚函数,它在基类中声明但没有定义,且需要在派生类中被重写。包含至少一个纯虚函数的类被称为抽象类(Abstract Class),抽象类不能被实例化。纯虚函数以 = 0 结尾。
2.1.3 多态的实现机制
在C++中,多态的实现通常依赖于虚函数表(Virtual Table,简称vtable)和虚函数指针(Virtual Pointer,简称vptr)。编译器会为包含虚函数的类创建一个虚函数表,表中存放着类中虚函数的地址。每个包含虚函数的类的对象都会包含一个指向其虚函数表的虚函数指针。当通过基类指针或引用来调用虚函数时,编译器会首先通过虚函数指针找到虚函数表,然后在表中查找并调用相应的虚函数。
2.1.4 构造函数可以是虚函数吗?析构函数可以是虚函数吗?
构造函数不能是虚函数。因为构造函数在对象创建时由编译器自动调用,而虚函数机制的运行依赖于虚函数表,这个表是在对象构造时由编译器初始化的。如果在构造函数中调用虚函数,实际上并不会发生动态绑定,而是会调用该类的虚函数实现,即使子类已经覆盖了该函数。因此,构造函数没有必要也不能是虚函数。
析构函数通常推荐将基类的析构函数设置为虚函数。这是因为当使用基类指针指向派生类对象并删除它时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致派生类部分没有被正确清理,造成资源泄漏。通过将基类的析构函数设置为虚函数,可以确保在删除对象时,先调用派生类的析构函数,再调用基类的析构函数,从而正确清理对象。
前置准则:
1、使用基类指针指向的子类实例不能读取子类的变量
2、初始化子类实例会从上到下调用构造函数,delete p 时会按照p的类型从当前往上调用构造函数
1、虚函数的实现原理
1、当编译时发现 virtual 申明时,创建虚函数表,并生成只读虚函数表,放入代码区
2、虚函数表是一个保存所有当前类的虚函数,包括从上面继承的和自己的。
2、虚函数表与虚函数表指针
1、每一个类只有一个虚函数表;虚函数表指针是指向类虚函数表的一个指针,在实例化时即被初始化不变。
C++ | 虚函数表及虚函数执行原理详解 - 知乎 (zhihu.com)
2、由于在调用子类的初始化时,会先调用基类的构造函数再调用子类的构造函数,所以初始化子类时,虚函数表的指针会先是基类再变成子类。
注意:
1、构造函数和析构函数不能被虚拟化
2.2 空类有哪些基本成员函数?
一个空类在C++中,编译器会为其自动生成一些基本的成员函数。这些函数通常被称为特殊成员函数,它们确保了对象的基本行为能够正确地进行。以下是编译器为一个空类自动生成的基本函数:
3+3+2
- 默认构造函数
:用于创建对象时初始化对象。如果没有显式定义任何构造函数,编译器会生成一个默认构造函数。
- 默认析构函数
:用于在对象生命周期结束时释放资源。如果没有显式定义析构函数,编译器会生成一个默认析构函数。
- 拷贝构造函数
:用于创建一个新对象作为现有对象的副本。如果没有显式定义拷贝构造函数,编译器会生成一个默认的拷贝构造函数。
- 赋值运算符(=,&,取址运算符 const)
:用于将一个对象的值赋给另一个已存在的对象。如果没有显式定义拷贝赋值运算符,编译器会生成一个默认的拷贝赋值运算符。
- 移动构造函数
:C++11引入,用于通过移动而非复制来初始化对象,这通常涉及资源的转移。如果没有显式定义移动构造函数,且类的成员中有可以移动的类型,编译器可能会生成一个移动构造函数。
- 移动赋值运算符
:C++11引入,用于通过移动而非复制来赋值对象。同样,如果没有显式定义移动赋值运算符,且类的成员中有可以移动的类型,编译器可能会生成一个移动赋值运算符。
2.3 继承如何实现?
C++中继承可以通过定义基类的子类来进行继承,在定义子类时申明要继承的基类属性,即可以继承基类属性。该基类可以被继承为 public、protected 或 private 几种类型。
派生类可以访问基类中所有的非私有成员。因此基类成员如果不想被派生类的成员函数访问,则应在基类中声明为 private。
我们可以根据访问权限总结出不同的访问类型,如下所示:
访问 | public | protected | private |
同一个类 | yes | yes | yes |
派生类 | yes | yes | no |
外部的类 | yes | no | no |
几乎不使用 protected 或 private 继承,通常使用 public 继承。当使用不同类型的继承时,遵循以下几个规则:
- 公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
- 保护继承(protected): 当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员。
- 私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员。
3 拷贝构造函数
3.1 什么时候触发拷贝构造函数?
1、赋值或者重载
2、函数传递参数
3、函数返回参数(如果没有编译器优化)
3.2 什么时候生成默认的拷贝构造函数?
编译器在检测到以下情况时,不得不,生成默认的拷贝构造函数:
1、类内有类,该类有默认的拷贝构造函数,则生成
2、类继承自一个基类,基类有默认的拷贝构造函数
3、类成员中有虚函数
4、基类有虚函数
3.3 浅拷贝和深拷贝,什么时候用浅拷贝就行,什么时候进行深拷贝?
在C++中,深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是对象复制时两种重要的拷贝策略,它们的主要区别在于如何处理对象中的动态分配内存或指针成员。
浅拷贝(Shallow Copy)
浅拷贝是默认的拷贝行为,当创建一个对象的副本时,它仅仅复制对象本身的成员值,如果对象中有指针成员,则只复制指针的值(即内存地址),而不复制指针所指向的内存区域的内容。这意味着原始对象和副本对象现在指向同一块内存区域。如果其中一个对象改变了这块内存区域的内容,另一个对象也会看到这些改变,因为它们都指向同一块内存。此外,如果原始对象或副本对象在其生命周期的某个时刻删除了这块内存,而另一个对象仍然试图访问它,那么将会导致未定义行为,通常表现为程序崩溃。
深拷贝(Deep Copy)
深拷贝在创建对象的副本时,不仅复制对象本身的成员值,还复制对象中的动态分配内存或指针成员所指向的内存区域的内容。这样,原始对象和副本对象将拥有各自独立的内存区域,对其中一个对象的修改不会影响另一个对象。在C++中,通常需要在类的拷贝构造函数或赋值运算符中手动实现深拷贝。
4 C++标准库 STL
4.1 vector
C++ vector(STL vector)底层实现机制(通俗易懂) (biancheng.net)
4.1.1 vector底层如何实现的?
其底层所采用的数据结构是一段连续的线性内存空间。
4.1.2 添加、查询和删除都是如何进行的?
读取是O(1) ,因为是连续的数组
添加是O(1) ,虽然会扩容,但是是摊还常数(amortized constant),扩容很少发生
删除尾部为O(1),删除头部为O(n),插入也是一样的。
4.1.3 怎么扩容的,为什么这么扩容?
满载时,如果再向其添加元素,那么 vector 就需要扩容。vector 容器扩容的过程需要经历以下 3 步:
- 完全弃用现有的内存空间,重新申请更大的内存空间;
- 将旧内存空间中的数据,按原有顺序复制到新的内存空间中;
- 最后将旧的内存空间释放。
这也就解释了,为什么 vector 容器在进行扩容后,与其相关的指针、引用以及迭代器可能会失效的原因。
具体的扩容策略(即扩容多少)可能因不同的标准库实现而异,但通常的做法是分配当前容量(capacity)的某个倍数(比如2倍)的内存。这样做的目的是减少未来扩容时所需的内存分配和拷贝次数,从而提高性能。
4.2 list
4.2.1 底层如何实现的?
list底层使用双向链表实现
4.2.2 添加、查询和删除都是如何进行的?
添加是O(1),加一个节点就行
查询是O(n),不支持[]操作,只能遍历链表
删除是O(1),头尾删除是O(1),中间删除需要先查询再删除,不用移动数组
4.2.3 怎么扩容的,为什么这么扩容?
申请一个节点进行扩容即可。
4.2.4 什么时候用vector,什么时候用list?
使用std::vector的情况:
- 需要随机访问元素
:如果你需要快速访问向量中的任意元素(通过下标),std::vector提供了O(1)复杂度的随机访问能力。
- 元素数量大致固定或预知
:如果你可以预知或者大致估计元素的数量,并且这个数量在程序的运行期间不会发生剧烈变化,因为std::vector在内存中是连续存储的,它可以利用缓存局部性来优化性能。
- 内存使用是关键因素
:std::vector通常比std::list使用更少的内存,因为它不需要存储指向前后节点的指针。
- 在末尾添加或删除元素
:虽然std::list在任何位置的插入和删除都是高效的,但std::vector在末尾添加或删除元素时通常非常高效(摊还常数时间)。如果你的应用程序主要进行这种操作,那么std::vector可能是一个好选择。
使用std::list的情况:
- 需要在任意位置插入或删除元素
:如果你需要在向量的中间或开头频繁地插入或删除元素,那么std::list是更好的选择,而不需要移动其他元素。
- 元素数量变化很大
:如果你的向量大小会在程序运行期间发生剧烈变化,并且这种变化很难预测,那么std::list可能更合适。因为std::list不需要像std::vector那样进行内存重新分配和元素拷贝来适应大小的变化。
- 双向迭代
:如果你需要同时向前和向后迭代元素,std::list提供了双向迭代器,可以方便地支持这种操作。
总的来说,选择std::vector还是std::list应该基于你的具体需求和数据操作的特点。如果你不确定该选择哪个,可以先尝试使用std::vector,因为它在很多情况下都能提供很好的性能。如果你发现std::vector的性能不满足需求,或者你的数据操作特点更适合链表结构,那么再考虑切换到std::list。
4.3 map和unordered_map实现原理的区别?
4.3.1 map:
map是一个基于红黑树(Red-Black Tree)实现的关联容器,它按照键(key)的值进行排序存储。红黑树是一种自平衡的二叉搜索树,它在插入、删除和查找操作中都能保持相对稳定的性能。由于红黑树的特性,map中的元素总是按键的升序排列。
由于map是基于树形结构实现的,因此其插入、删除和查找操作的平均时间复杂度都是O(log n),其中n是容器中元素的数量。然而,由于树形结构需要维护节点的平衡,因此在某些情况下,map可能会比unordered_map慢一些。
4.3.2 红黑树和二叉平衡树的区别?
- 平衡策略:
-
- 红黑树:红黑树放弃了追求完全平衡,而是追求大致平衡。它通过在每个节点上添加颜色属性(红色或黑色),并通过一系列规则使得红黑树在插入或删除节点时,最多只需要进行三次旋转就能达到平衡,从而保证了操作的高效性。
- 二叉平衡树(AVL树):二叉平衡树追求的是绝对平衡,它要求每个节点的左右子树的高度差不超过1。这种严格的平衡要求使得AVL树在每次插入或删除节点后,可能需要进行多次旋转以达到平衡。
- 实现难度:
-
- 由于红黑树的平衡策略相对宽松,其实现通常比AVL树更简单一些。AVL树需要严格保持每个节点的左右子树高度差不超过1,因此在插入或删除节点时,可能需要更复杂的旋转操作来保持平衡。
- 应用场景:
-
- 对于需要频繁进行插入和删除操作的场景,红黑树可能是一个更好的选择,因为它在保持平衡性方面更加高效。而AVL树由于其严格的平衡要求,可能在某些情况下导致更多的旋转操作,从而影响性能。
- 然而,如果应用场景对数据的排序有严格要求,或者需要快速访问树中的最小或最大元素(这在AVL树中可以通过直接访问根节点实现),那么AVL树可能更合适。
4.3.3 unordered_map:
unordered_map是一个基于哈希表(Hash Table)实现的关联容器,它允许通过键快速访问单个元素。哈希表通过使用哈希函数将键映射到存储桶(bucket)中,从而实现快速查找。在理想情况下,哈希表的查找、插入和删除操作的平均时间复杂度都是O(1),即常数时间复杂度。
总结来说,map和unordered_map的主要区别在于其内部实现原理:map基于红黑树实现,按键排序且提供稳定的性能;而unordered_map基于哈希表实现,提供快速的查找、插入和删除操作,但性能可能受到哈希函数和哈希冲突的影响。
4.3.4 hash冲突的几种解决方案?
- 开放寻址法(Open Addressing):
-
- 线性探测(Linear Probing):当发生冲突时,顺序地检查哈希表中的下一个位置,直到找到一个空位置为止。
- 二次探测(Quadratic Probing):发生冲突时,使用二次方的方式探测下一个位置,以减少聚集现象。
- 双重哈希(Double Hashing):除了主哈希函数外,还有一个辅助哈希函数。当主哈希函数发生冲突时,使用辅助哈希函数来确定探测的步长。
- 链地址法(Chaining):
-
- 这种方法中,哈希表的每个位置都对应一个链表。当发生冲突时,将具有相同哈希值的键都添加到同一个链表中。这种方法避免了探测的复杂性,但增加了链表的维护成本。
- 再哈希法(Rehashing):
-
- 当发生冲突时,使用另一个哈希函数对键进行再次哈希。这种方法可能需要多个哈希函数,并且设计合适的哈希函数组合可能比较复杂。
- 独立哈希表(Separate Chaining with Independent Hashing):
-
- 这种方法结合了链地址法和再哈希法的思想。每个哈希表位置对应一个独立的链表,并且每个链表使用不同的哈希函数。这样,即使两个键在主哈希函数中发生冲突,它们在各自的链表中也可能不会发生冲突。
- 动态扩容(Dynamic Resizing):
-
- 当哈希表的负载因子(已存储的元素数量与哈希表大小的比值)超过某个阈值时,对哈希表进行扩容,重新分配更大的内存空间,并重新计算所有元素的哈希值。这种方法可以减少哈希冲突的概率,但会带来额外的开销。
4.4 std::string底层实现,和c++的string有什么区别?
std::string 是 C++ 标准库中的一个类,用于表示和操作字符串。它提供了许多方便的功能,如字符串连接、子串搜索、大小调整等。而 C 语言的字符串通常是以字符数组(char 数组)或字符指针(char*)的形式表示的。
4.4 1 std::string 的底层实现
std::string 的底层实现通常是一个动态数组,用于存储字符。这个动态数组的大小可以根据需要增长或缩小,从而可以容纳任意长度的字符串。这个动态数组可能还包括一些额外的空间,用于优化字符串的修改操作(例如,预留一些空间以便在添加字符时不需要重新分配内存)。
此外,std::string 通常还包含一些成员变量,用于存储字符串的长度、容量等信息,以便快速地进行各种操作。
4.4.2 std::string 与 C 字符串的区别
- 内存管理
-
- std::string 自动管理其内存。当你向 std::string 添加字符或连接其他字符串时,它会自动调整其大小。而 C 字符串需要手动管理内存,例如使用 malloc、realloc 和 free。
- 安全性
-
- std::string 的操作是安全的,因为它会自动处理所有与内存相关的问题。而操作 C 字符串时,如果没有正确管理内存(例如,越界访问或忘记释放内存),可能会导致程序崩溃或安全漏洞。
- 功能
-
- std::string 提供了丰富的功能,如字符串连接、查找、替换、分割等。而操作 C 字符串时,通常需要使用一系列的函数和标准库来执行这些操作。
- 可移植性
-
- std::string 的行为在不同的编译器和平台上是一致的。而 C 字符串的某些方面(如字符编码)可能会受到平台和编译器的影响。
- 效率
-
- 在某些情况下,直接操作 C 字符串可能比使用 std::string 更高效,特别是当进行大量的字符操作或内存分配时。然而,对于大多数应用程序来说,std::string 的性能已经足够好,而且它提供了更高的安全性和易用性。
- 空字符终止
C 字符串以空字符(\0)终止,这是 C 语言字符串的一个特性。而 std::string 不需要这个空字符来标识字符串的结束,因为它内部存储了字符串的长度。
总的来说,std::string 提供了比 C 字符串更高级、更安全、更易于使用的字符串处理功能。在大多数情况下,建议使用 std::string 而不是 C 字符串。
5 C++11新特性
5.1 智能指针是什么,怎么用?
C++中的智能指针是为了解决原生指针可能导致的内存泄漏和野指针问题而设计的。它们可以自动管理内存的生命周期,确保在不再需要对象时自动释放其占用的内存。
是资源获取即初始化(Resource Acquisition Is Initialization,简称 RAII)思想的一种实现,它将在使用前获取(分配的堆内存、执行线程、打开的套接字、打开的文件、锁定的互斥量、磁盘空间、数据库连接等有限资源)的资源的生命周期与某个对象的生命周期绑定在一起。
- std::unique_ptr:
- 所有权:unique_ptr表示对对象的独占所有权。同一时间只能有一个unique_ptr指向某个对象。
- 移动语义:unique_ptr支持移动语义(std::move),但不支持复制。这意味着你可以将一个unique_ptr“移动”到另一个unique_ptr,但你不能复制它。
- 自定义删除器:你可以为unique_ptr提供一个自定义的删除器,以改变对象的删除方式。
- 用途:通常用于表示应该只有一个所有者的情况,比如在工厂函数中返回一个动态分配的对象。
- std::shared_ptr:
- 共享所有权:shared_ptr允许多个智能指针共享同一个对象的所有权。
- 引用计数:shared_ptr使用引用计数来跟踪有多少shared_ptr指向一个对象。当最后一个指向对象的shared_ptr被销毁或重置时,对象会被删除。
- 循环引用:使用shared_ptr时要小心循环引用的问题,这可能导致内存泄漏。为了避免这种情况,可以使用std::weak_ptr。
- 用途:适用于多个所有者需要共享同一个对象的情况。
- std::weak_ptr:
- 观察共享对象:weak_ptr是对一个shared_ptr所管理对象的弱引用,不会增加对象的引用计数。
- 打破循环引用:weak_ptr主要用于解决shared_ptr之间的循环引用问题,从而避免内存泄漏。
- 用途:当需要一个指向shared_ptr所管理对象的指针,但不希望参与对象的生命周期管理时,可以使用weak_ptr。
- 自定义删除器:
- 所有智能指针都可以接受一个自定义的删除器,这个删除器是一个可调用对象,用于定义如何删除所指向的对象。这对于管理特定类型的资源(如文件句柄、网络连接等)非常有用。
- 初始化:
- 智能指针可以使用new运算符初始化,并且当智能指针销毁时,它所指向的对象也会被自动删除。
5.2 lambda表达式是什么,怎么用?
Lambda表达式是一种匿名函数,可以没有具体的名称,但具有函数的功能,使得代码更加灵活简洁。在C++11及其之后的版本中,Lambda表达式提供了一种编写内联函数对象的简便方式,其可以捕获一定范围内的局部变量,并且可以使用这些变量在函数体内进行计算。
// 使用Lambda表达式作为std::sort的第三个参数,定义排序规则
std::sort(vec.begin(), vec.end(), [](int a, int b) { return a < b; });
5.3 lambda表达式和function的区别?
区别
1.定义方式:
- Lambda表达式是在代码中直接定义的,它们没有名字,并且只能在其定义的上下文中使用
- std::function是一个对象,可以存储任何可调用对象,并且可以在程序的任何位置使用。
2.类型:
- Lambda表达式的类型由编译器推导,通常不需要显式指定。
- std::function需要显式指定其可调用对象的签名。
3.用途:
- Lambda表达式通常用于需要临时函数对象的地方,例如在算法中或作为回调函数。
- std::function更通用,可以用于任何需要存储和调用可调用对象的地方,例如作为类的成员或作为函数的参数。
4.性能:
- Lambda表达式通常比std::function具有更好的性能,因为它们是内联的,并且没有间接调用
- std::function引入了类型擦除和间接调用,因此可能有一些性能开销。
5.灵活性:
- std::function比lambda表达式更灵活,因为它可以存储任何可调用对象,而不仅仅是lambda表达式。
5.4 多线程
1. std::thread与pthread对比
- std::thread是C++11接口,使用时需要包含头文件#include ,编译时需要支持c++11标准。thread中封装了pthread的方法,所以也需要链接pthread库
- pthread是C++98接口且只支持Linux,使用时需要包含头文件#include ,编译时需要链接pthread库
2. std::thread对比于pthread的优缺点:
优点:
- 简单,易用
- 跨平台,pthread只能用在POSIX系统上(其他系统有其独立的thread实现)
- 提供了更多高级功能,比如future
- 更加C++(跟匿名函数,std::bind,RAII等C++特性更好的集成)
缺点:
- 没有RWlock。有一个类似的shared_mutex,不过它属于C++14,你的编译器很有可能不支持。
- 操作线程和Mutex等的API较少。毕竟为了跨平台,只能选取各原生实现的子集。如果你需要设置某些属性,需要通过API调用返回原生平台上的对应对象,再对返回的对象进行操作。
5.4.1 std::thread 定义
// 创建另一个线程执行func函数,func的参数有args,args,,,,,
std::thread thread1 = std::thread(func,args,args,,,,,)
// 判断是否能使用join
if(thread1.joinable())
// join等待thread1执行结束,当前进程或者线程阻塞
thread1.join()
5.4.2 std::mutex 定义
// 定义一个锁名称
std::mutex mtx_lock;
// 对这个锁名称加锁,其他再想对这个锁名称加锁的都不允许加
mtx_lock.lock();
// 对这个锁名称解锁,其他锁可以对这个锁名称加锁
mtx_lock.unlock();
5.4.3 std::lock_guard and std::unique_lock
std::lock_guard用于定义一个自动锁变量,该变量传入一个锁变量,并对该锁变量加锁,当这个自动锁变量析构时会对锁变量解锁,即达到一行代码解决局部加锁与解锁问题。
std::unique_lock与std::lock_guard一样,但是多了一些方法,方法多但是消耗多,自己权衡用哪个。
{
std::lock_guard<std::mutex> lg(mtx_lock);
//mtx_lock.lock();
a=a+1;
//mtx_lock.unlock();
}
{
std::unique_lock<std::mutex> lg(mtx_lock);
//mtx_lock.lock();
a=a+1;
//mtx_lock.unlock();
}
5.4.4 std::condition_variable 条件变量
std::condition_variable用于在线程之间传递消息,常用于线程的等待与通知。
std::condition_variable cv1;
// 通知一个线程,可以不用等待了
cv1.notify_one();
// 让当前线程等待,并释放unique_lock锁的锁变量。
cv1.wait(unique_lock);
// 生成者与消费者模型
std::queue<int> q_queue;
std::condition_variable cv1;
std::mutex q_queue_mutex;
void Producer()
{
for(int i=0;i<10;i++)
{
q_queue_mutex.lock();
q_queue.push(i);
q_queue_mutex.unlock();
std::cout<<"Producer:"<<i<<std::endl;
cv1.notify_one();
}
}
void Conducter()
{
while (1)
{
// 先对q_queue_mutex加锁
std::unique_lock<std::mutex> lock(q_queue_mutex);
// 如果队列为空,则释放unique_lock并阻塞等待
if(q_queue.empty())
cv1.wait(lock);
int v = q_queue.front();
std::cout<<"conducter:"<<v<<std::endl;
q_queue.pop();
}
// 运行此函数下线程不会结束,所以主进程的join()是等不到的,所以程序最后会一直卡在这里
}
5.4.5 线程池
其它问题:
什么是浅拷贝和深拷贝?
浅拷贝就是增加了一个新指针指向原来的地址,那么改变原有对象也会改变新对象。
而深拷贝则是开辟了新的内存空间,并增加一个指向该空间的指针。
你介绍一下C++类访问权限。
C++有三个关键字public, protected, private.
public: 完全公开,任何类都可以访问。
protected,当前类和子类可以访问。
private,仅当前类可以访问。
STL由哪些组件组成?
STL由6个组件和13个头文件组成。这6个组件是:
容器:一些封装数据结构的模板类,比如vector,list等。
算法:它们被设计为一个个模板函数,大部分位于 ,小部分位于。
函数对象:如果一个类将()重载为成员函数,那么这个类称为函数对象类,类的对象称为仿函数。
迭代器:容器对数据的读写是通过迭代器完成的,它充当容器和算法之间的胶合剂。
适配器:将一个类的接口设计成用户指定形式。
内存分配器:为容器类模板提供内存分配和释放功能。
emplace_back()和push_back()哪个更好,为什么?
emplace_back()更好,因为它调用的是移动构造函数。而push_back()调用的是拷贝构造函数。移动构造函数不需要分配新的内存空间,所以更快一些。
讲一下中的sort原理
STL的sort采用了快速排序、插入排序和堆排序。根据数据量大小选择合适的算法:
- 当数据量较大,采用快速排序,分段递归;
- 一旦分段后的数据量小于一个阈值,改为插入排序。
- 为避免递归深度过深,达到一定递归深度采用堆排序。
函数参数压栈顺序?
从右到左。
C++11 的新特性
C++11新特性(全详解) - 知乎 (zhihu.com)
const char
指针也可以使用const,这里有个小技巧,从右向左读,即可知道const究竟修饰的是指针还是指针所指向的内容。
char *const ptr; // 指针本身是常量 const char* ptr; // 指针指向的变量为常量
const char *ptr;
char const *ptr;
char * const ptr;
const char * 、char const *、 char * const 三者的区别_char const-CSDN博客
变参模板