【八股cover#2】CPP语法 Q&A与知识点
CPP语法 Q&A与知识点
简历cover
1、熟练使用C的指针应用及内存管理
指针与引用的区别
指针是一个存储地址的变量,可以有多级,可以为空,并且在初始化后可以改变指向;
引用是原变量的别名,只有一级,不能为NULL,必须在定义时初始化,并且一旦初始化后就不能再改变。
指针在作为参数传递时不会影响实参,而引用会影响实参。
引用占用4字节内存,同样需要初始化为另一变量,不存在指向空值的引用,但存在指向空值的指针。
具体:
1.指针是一个变量,存储的是一个地址,引用跟原来的变量实质上是同一个东西,是原变量的别名;
2.指针可以有多级,引用只有一级;
3.指针可以为空,引用不能为NULL且在定义时必须初始化;
4.指针在初始化后可以改变指向,而引用在初始化之后不可再改变;
5.sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小;(即sizeof返回的是变量的大小)
6.当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以;
7.引用本质是一个指针,同样会占4字节内存,指针是具体变量,需要占用存储空间(具体情况还要具体分析);
8.引用在声明时必须初始化为另一变量,一旦出现必须为typename refname &varname形式,指针声明和定义可以分开,可以先只声明指针变量而不初始化,等用到时再指向具体变量。
9.引用一旦初始化之后就不可以再改变 (变量可以被引用为多次,但引用只能作为一个变量引用)。指针变量可以重新指向别的变量;
10.不存在指向空值的引用,必须有具体实体;但是存在指向空值的指针。
const
const的作用
被const修饰的值不能再改变,是只读变量,因此在定义时就要给它赋初值
具体体现如下:
常量指针(底层const)
定义了一个指针,但这个指针指向的是一个只读对象,因此无法通过常量指针来修改对象的值
常量指针强调的是指针所指对象的不可改变性
形式一般如下:
(1)const 数据类型 *指针变量 = 变量名
(2)数据类型 const *指针变量 = 变量名
int tmp = 10;
const int *a = &tmp;
int const *a = &tmp;
*a = 9;//错误,只读常量
tmp = 9;//正确
指针常量(顶层const)
定义了一个指针,该指针的值只能在定义时进行初始化,在其他地方不能改变。
指针常量强调的是指针本身的不可变性
形式如下:
数据类型* const 指针变量 = 变量名
int tmp = 10;
int tmp2 = 12;
int* const p = &tmp;
p = &tmp2;//错误,指针常量的指向不能再改变
*p = 9;//正确
记忆点
- 常量就是不可修改的值
数据类型*
的是指针常量
tips:
int const *r;//常量指针
先看最右边,变量名是r,r是个指针
再从右往左看,const int
所以r是一个指向const int的指针
ps:(*r)不能改变,但是r本身不是const int,所以可以改变
int *const r;//指针常量
先看最右边,变量名是r
然后从右往左,r是个const,并且还有*
所以r是个const指针
再往左看,该指针还指向int
ps:这意味着r的值本身不能改变,不能给r赋值,但是int没有const修饰,所以(*r)是可以改变的
new和malloc
定义
new
是 C++ 中的一个关键字,用于动态分配对象。它会自动计算需要分配的内存大小,并返回指向新分配的对象的指针。使用 new
分配内存时,还可以调用相应的构造函数以初始化对象。
而 malloc
则是标准 C 库函数之一,用于动态分配指定大小的内存块。malloc
函数接受一个参数,即需要分配的字节数,返回一个指向新分配内存的指针。使用 malloc
分配内存时,必须手动进行内存初始化。
两者异同(具体)
1、使用new
分配内存时,如果失败会返回异常bad_alloc,而malloc
分配内存失败会返回NULL
2、使用new
操作符申请内存分配时不用指定内存块的大小,而malloc
需要显式指出所需内存的尺寸
3、opeartor new/operator delete可以被重载,⽽malloc/free并不允许重载。
4、new/delete会调用构造函数和析构函数以完成对象的构造/析构,malloc不会管这些
5、malloc/free是C/C++的标准库函数,new/delete是C++的运算符
6、new操作符从自由存储区上为对象动态分配内存空间,malloc函数从堆上动态分配空间
malloc底层实现
在Linux下,如果开辟空间小于128K,使用brk()函数来增加堆的有效区域并申请内存空间;
如果大于128K则使用mmap()函数在堆和栈之间文件映射区域申请一块虚拟内存
new申请内存时操作系统做了什么事?
new
运算符调用 C++ 运行库中的 operator new
函数,该函数向操作系统请求一段足够大的连续内存块。然后操作系统会在虚拟地址空间中寻找可用的物理内存页,将这些页映射到进程的地址空间中,此时操作系统会记录下已分配的内存块信息(在释放时使用)。
最后的结果就是 operator new
函数会返回一个指向新分配内存空间的指针。
深拷贝与浅拷贝
浅拷贝只是拷贝了一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址
如果原来的指针所指向的资源释放了,那么再释放浅拷贝资源就会出现错误。
深拷贝不仅拷贝值,还开辟出一块新的空间来存放新的值,即使原先的对象被析构掉,并且释放内存,也不会影响到深拷贝得到的值。
在自己实现拷贝赋值时,如果有指针变量的话需要自行实现深拷贝
struct、union的异同
Struct和Union都是C语言中的复合数据类型,可以用来组织不同类型的数据。
它们的区别在于内存分配方式:Struct中的每个变量依次存储,而Union中的各个成员变量共用同一块内存空间,同一时间只有一个成员存储于该地址。此外,Struct内存大小遵循结构对齐原则,而Union内存大小为其最大成员的整数倍。Union主要用于节省内存和进行类型转换,而Struct则主要用于组织相关数据并容易扩展。
具体
相同点:
- 都可以用来将多个不同类型的数据组合成为一个新的数据类型。
- 都可以通过成员访问运算符"."来访问结构体或联合体中的成员变量。
- 结构体和联合体中的成员变量都可以是任意C语言中的数据类型,包括基本类型、指针类型、结构体类型、联合体类型等。
struct
中每个变量依次存储;(各个成员变量占用独立的内存空间并且互相不影响)union
中,每个变量都是从偏移地址零开始存储,同一时刻只有一个成员存储于该地址(所有成员变量共用同一块内存空间)struct
内存大小遵循结构对齐原则
- 数据成员对齐规则:每个数据成员存储的起始位置要从该成员大小的整数倍开始
- 数据成员包含结构体:结构体成员要从其内部最大元素对象的整数倍地址开始存储
- 结构体总大小:其内部最大基本成员的整数倍,不足则要补齐
union
内存大小为其最大成员的整数倍union
主要用于节省内存和在不同类型之间进行转换,而struct
则主要用于组织一组相关数据并且容易扩展。
union的赋值
union
是一种特殊的数据类型,其内存大小等于最大成员的大小,并且不同成员的地址是相同的。在进行赋值操作时,需要考虑当前系统的大小端模式。如果源系统和目标系统的大小端模式相同,则直接赋值即可;否则需要通过字节交换来保证正确性。
例如,在小端模式下,将一个4字节的整数赋值给一个2字节的short类型成员,需要将前两个字节复制给short类型成员;而在大端模式下,则需要将后两个字节复制给short类型成员。
在小端模式下,低地址存放的是最低有效字节,高地址存放的是最高有效字节;而在大端模式下则正好相反。
(总结一下:https://blog.csdn.net/weixin_57544072/article/details/121583724)
struct内存分配,为什么要对齐
struct内存分配需要进行对齐是为了提高程序的运行效率
结构体中的数据成员如果没有对齐,就会存在内存空洞,这样CPU在访问内存时就需要进行多次读取和写入操作,导致程序运行变慢。
此外,对齐还可以避免一些硬件异常问题(未对其访问可能会发生硬件异常)
栈溢出是发生在那些场景,伪递归和递归的区别,伪递归会发生栈溢出吗
当函数调用的层数过多或者函数内部申请的局部变量过多时有可能会导致栈空间不足,进而发生栈溢出错误。
栈溢出问题通常会出现在递归调用、大量的局部变量申请等情况中。
伪递归和递归的区别在于:递归是函数自己直接或者间接地调用自己,而伪递归则是通过手动维护一个栈结构来实现函数调用的过程,从而避免了因为递归过深导致的栈溢出问题。
由于伪递归手动维护了一个栈,所以不会发生栈溢出的情况。但是使用伪递归需要额外的空间来维护栈结构,因此也可能会有其他的空间限制问题。
def fake_recursive(n): stack = [n] while stack: i = stack.pop() if i <= 0: continue else: print(i) stack.append(i-1) stack.append(i-2) fake_recursive(10)
这个函数使用一个列表作为栈来维护递归调用过程中需要保存的信息。具体来说,它首先将输入参数n加入到栈中,然后进入while循环,只要栈不为空就一直循环。在每次循环中,它从栈顶弹出一个元素i,并且检查它是否小于等于0。如果是,则跳过当前循环;否则,打印i并且将i-1和i-2依次加入到栈中,以模拟递归调用。
memcpy()函数需要注意哪些问题
memcpy函数用于将源内存中连续的一定数量的字节数据复制到目标内存中。在使用该函数时需要注意以下几点:
- 数据长度count的单位是字节,1byte等于8bit。
- 如果数据类型为char,则数据长度等于元素的个数;否则需要考虑数据长度的值。
- 在计算要复制的数据长度时,可以使用n * sizeof(type_name)的写法来确保正确性。
gcc编译一个程序会经历哪些过程
当使用gcc编译一个程序时,它会经历以下过程:
- 预处理阶段(Preprocessing Stage):在此阶段中,gcc将预处理指令展开并将宏定义替换为其相应的值。同时,包含在源代码中的头文件也会被包含到该文件中。【展开预处理指令、将宏定义替换为对应值、加入头文件】
- 编译阶段(Compilation Stage):在此阶段中,gcc将翻译C语言源代码为汇编语言程序。这个阶段中,源代码被翻译成汇编代码,但是还没有链接到目标文件。【源代码转为汇编代码(未链接目标文件)】
- 汇编阶段(Assembly Stage):在此阶段中,gcc将汇编代码转换为可重定位二进制目标文件。这个阶段中,汇编代码被转换成机器码,并且生成一个包含符号表和其他元数据的目标文件。【汇编代码转换为二进制目标文件,汇编代码被转为机器码】
- 链接阶段(Linking Stage):在此阶段中,gcc将多个目标文件组合成单个可执行文件或库文件。这个阶段中,系统库和用户库的符号被解析,并将所有函数和变量链接起来,产生最终的可执行文件或库文件。【系统库和用户库的符号被解析,并将所有函数和变量链接起来,最终将多个目标文件组合成单个可执行文件或库文件】
编译动态库的命令
编译动态库的命令可以使用gcc或者clang编译器进行操作。其中,编译动态库的选项有两个,分别是-shared和-fPIC。-shared选项表示生成共享库,-fPIC选项表示编译为位置独立的代码。因此,编译动态库的命令可以如下所示:
gcc -shared -fPIC file1.c file2.c -o libexample.so
其中,file1.c和file2.c是动态库源代码文件的名称,-o选项表示输出文件名,libexample.so则是生成的动态库文件名。
C++动态库需要提供给第三方,头文件中需要注意什么
首先,头文件应该包含动态库中所有可用的函数和数据类型的声明。这些声明应该是符合标准的,并且要在文档中清晰地说明它们的使用方法和限制条件。
其次,头文件应该遵循适当的命名空间规则,以防止与其他代码发生名称冲突。这样可以确保第三方使用时不会出现意外的问题。
最后,头文件应该有清晰的注释,解释每个函数和数据类型的作用、参数和返回值的含义,以及任何使用限制或副作用。这样可以帮助第三方更好地理解和使用动态库。
2、C++的封装继承多态(三大特性)
三大特性定义
-
继承:继承是指一个类可以从另一个类中继承属性和方法。
继承可以让代码更加灵活和可复用,因为一个派生类可以使用基类中的成员变量和成员函数,而不必重复编写同样的代码。在C++中,通过关键字"public"、"protected"和"private"来控制继承的访问权限。
-
封装:封装是指将数据和行为组合到一个单元中,并对外部世界隐藏其具体实现细节,只保留一些公共接口供其他对象进行访问。
封装可以防止外部对象直接访问对象内部的数据,从而保证程序的安全性和稳定性。在C++中,可以使用访问修饰符"public"、"protected"和"private"来控制成员变量和成员函数的访问权限。
-
多态:多态是指同一种操作作用于不同的对象上,可以有不同的解释和执行方式。
多态可以增加代码的可扩展性和可维护性,因为它可以允许我们在运行时动态地选择要调用的特定方法。在C++中,可以通过虚函数和纯虚函数实现多态,其中虚函数是在基类中定义的、可以被派生类覆盖的函数,而纯虚函数是没有具体实现的虚函数,在派生类中必须覆盖它。
例子:
每种交通工具使用时的操作方式和性能都不同。例如,私家车需要用油、刹车和油门等部件来操控;摩托车也需要上述部件以及手动变速器等;而自行车则需要踩脚踏板来进行推进。
这个例子中,交通工具可以被看作是一个基类,而各种不同类型的交通工具则是派生类。每个交通工具都有其特定的行驶方法和性能,就像每个派生类都有其独特的行为和属性。当你在路上行驶时,无论你使用哪种交通工具,你都只需要关心如何进行加速、制动和转向等共同的操作,而不必关心底层实现细节。这就是多态性在生活中的应用。
在 C++ 中,多态性就是指通过基类的指针或引用调用派生类的函数时,根据实际对象类型决定调用哪个版本的函数。因此,在上述的交通工具例子中,如果我们定义一个名为
Vehicle
的基类并声明其中一个虚函数drive()
,然后在派生类Car
、Motorcycle
和Bicycle
中分别重写这个虚函数,并创建一个Vehicle *
指针指向不同类型的交通工具,就可以实现多态性。当你调用drive()
函数时,程序会根据实际对象类型决定调用哪个版本的函数,从而实现不同类型的交通工具有不同的行驶方法和性能。
C++的继承有没有什么缺点
1、父类和子类之间紧密耦合(子类的修改有可能会影响到父类)
2、继承过深导致可维护性下降
3、可能会导致多态性能下降。因为继承中如果有虚函数的话,那么运行时需要进行动态绑定,这会导致性能下降
继承是否破坏了封装?
先说结论:继承并不会直接破坏封装,因为可以通过访问权限来控制基类中的成员对派生类的可见性。但是如果派生类没有正确使用访问权限或者直接访问基类的私有成员,就可能会破坏基类的封装性。
- 如果使用了public继承,则基类中public成员在派生类中仍然是public的,protected成员在派生类中变为protected的,private成员在派生类中仍然是不可访问的。
- 如果使用了protected继承,则基类中public和protected成员在派生类中都变为protected的,private成员在派生类中仍然是不可访问的。
- 如果使用了private继承,则基类中所有的成员在派生类中都变为private的,即全部成员在派生类之外不可见。
虚函数
定义
虚函数是一种特殊的成员函数,它允许派生类重写该函数以实现多态性。
如果基类中的函数被声明为虚函数,则派生类可以覆盖该函数并提供自己的实现。在运行时,程序会根据实际对象类型来调用相应的函数,从而实现多态性。
虚函数的作用是在运行时决定调用哪个版本的函数,而不是在编译时决定。编译器会给每一个含有虚函数的类生成一个虚函数表,里面存储了该类中所有虚函数的地址,每个对象都有一个指向它所属类的 V-Table 指针,通过这个指针可以在运行时找到正确的虚函数地址。
正常函数与虚函数的区别
虚函数通过虚函数表进行动态绑定,使得在运行时能够根据对象类型动态选择调用哪个函数;
正常虚函数则在编译时就确定了调用哪个函数(静态绑定),因此不需要使用虚函数表进行动态查找。
无论是虚函数还是非虚函数,都是通过函数地址进行调用的
虚函数表指针的偏移量如何计算?如何真正找到我想访问的函数?(如何手动强行调用虚表中的函数?)
虚函数表指针记录了一个对象的虚函数地址,可以通过获取对象的指针来访问该对象的虚函数表指针。在虚函数表中,每个虚函数都对应着一个索引,根据要调用的虚函数的索引,在虚函数表中获取相应的函数指针,并将其转换为正确的函数类型。最后,我们可以通过这个函数指针调用虚函数。但是手动调用虚函数表中的函数是一种非常危险的操作,可能会引发内存泄漏、未定义行为等问题。
- 获取对象的指针。
- 访问该对象的虚函数表指针。
- 根据要调用的虚函数的索引,在虚函数表中获取相应的函数指针。
- 将函数指针转换为正确的函数类型。
- 通过函数指针调用虚函数。
手动调用虚函数表中的函数是一种非常危险的操作,容易引发许多问题,比如内存泄漏、未定义行为等
构造函数、析构函数可以是虚函数吗?
构造函数和析构函数可以是虚函数,但是不建议将其声明为虚函数。
原因如下:
-
将构造/析构函数声明为虚函数也无法实现多态性。因为其调用顺序是固定的,不能通过基类指针或引用来调用派生类的函数
-
构造/析构函数被应用于对象的创建和销毁过程中,此时对象的状态可能不稳定,使用虚函数可能会导致错误
析构为什么要声明为virtual
如果一个类带有虚函数(具有多态性质的类),则必须为其声明虚析构函数,确保调用 delete 时能够正确地删除对象。
如果没有声明虚析构函数,则在使用 delete 时可能会导致内存泄漏。(只调用基类的析构函数,而不调用派生类的析构函数)
3、STL常用容器
vector
底层实现
在堆中分配了一段连续的内存空间来存放元素(vector内部维护了一个指向存储数据的连续内存空间的指针)
扩容机制
如果当前的vector已经满了,在新增数据时就要分配一块更大的内存,将原来的数据复制到新内存中并释放原始内存块,然后再把新数据插入
对vector的任何操作,一旦引起空间重置,那么指向原有vector的所有迭代器都会失效
size() 和 capacity()
- 堆中分配内存,元素连续存放,内存空间只会增长不会减少
- vector有两个函数,一个是capacity(),在不分配新内存下最多可以保存的元素个数,另一个size(),返回当前已经存储数据的个数
- 对于vector来说,capacity是永远大于等于size的
- capacity和size相等时,vector就会扩容,capacity变大 (翻倍)
vector 扩容的方式有两种:倍增和加法。
倍增是指每次扩容都将 vector 容量乘以 2,而加法则是每次扩容增加一个固定的值,例如 10 或 20。
一般来说,可以考虑将容量选择为当前 vector 大小的两倍,这样可以减少扩容次数,同时又不会浪费太多内存。
resize()和reserve()
resize()改变当前容器内含有的元素的数量【对应size(),500ml瓶子里的水】
reserve()改变当前容器的最大容量【对应capacity(),500ml瓶子换成600ml】
list
std::list
是一个环状双向链表容器(其结点与list本身是分开设计的),它的每个元素(节点)都是单独分配内存的。其内部实现方式可以确保不同元素在内存中的位置不相邻,然后我们可以通过指针来访问对应数据。
list的迭代器
有以下特点:
1、因为list是双向链表,所以其迭代器也是双向的,可向前向后遍历链表
2、虽然list不支持随机访问,但是list的迭代器支持"++"和"--"等操作(但是不支持"+="和"-="),通过这些操作可以实现类似随机访问的效果。
vector和list的区别
它们的主要区别在于底层数据结构不同,因此它们在插入、删除和随机访问元素时的性能表现有所不同。
具体来说,vector底层使用的是动态数组,它可以在尾部高效地添加和删除元素,支持随机访问,但在中间或头部插入/删除元素时会比较耗费时间。这是因为需要将被插入/删除元素后面的所有元素向前/后移动,以便腾出空间,这个操作的时间复杂度是O(n)。
相比之下,list底层使用的是双向链表,虽然不支持随机访问,但在中间或头部插入/删除元素时非常高效,只需要更新相邻元素的指针即可,不需要像vector一样移动其他元素。这个操作的时间复杂度是O(1)。
如果需要频繁进行随机访问,而且元素数量不是很大,那么vector可能更适合;如果需要频繁进行元素插入/删除操作,并且元素数量较大,那么list可能更适合。
具体:
1.vector底层实现是数组; list是双向链表;
2.vector是顺序内存,支持随机访问,list不行;
3.vector在中间节点进行插入删除会导致内存拷贝,list不会;
4.vector一次性分配好内存,不够时才进行翻倍扩容;list每次插入新节点都会进行内存申请;
5.vector随机访问性能好,插入删除性能差;list随机访问性能差,插入删除性能好.
deque(双端数组)
deque(双端队列)提供了与vector相似的接口,但是更加适用于头尾操作。
deque可以在常数时间内对头部和尾部进行插入和删除操作,因此它非常适合需要频繁在头尾插入和删除元素的场景。
deque还提供了随机访问迭代器,使得可以快速访问其中的任何元素。与vector不同,deque的存储空间不必在一个连续的块中分配,这意味着deque可以更好地处理大型数据集,而不会因为连续内存分配失败而崩溃。
deque由一段一段的定量连续空间构成,这使得其迭代器别的复杂
list和deque的区别
List是一种有序的可变序列,可以使用索引访问元素。添加或删除元素时,列表中的所有元素都将向左或向右移动。这意味着添加或删除元素的时间复杂度为O(n),其中n是列表中的元素数量。
Deque(双端队列)也是一种有序的可变序列,但与List不同的是,在队列的两端都可以添加或删除元素。因此,Deque在执行从队列的开头或结尾进行添加或删除操作时,具有O(1)的时间复杂度。
总之,如果您需要在队列的两端频繁添加或删除元素,则应该使用Deque。如果只需要访问元素或仅需要在列表的末尾添加或删除元素,则使用List即可。
栈与队列(stack&queue)
Stack(栈)是一个后进先出(LIFO)的数据结构,而Queue(队列)是一个先进先出(FIFO)的数据结构
栈与队列底层架构为deque,是deque的配接器(Adapter)
堆
堆是一种动态分配内存的数据结构,它与栈不同,堆的内存空间可以在程序运行过程中随时申请和释放。堆中的每个元素都有一个键(key),该键决定了元素在堆中的位置。
堆是建立在完全二叉树这种结构上的,通常分为大根堆和小根堆两种(堆一般用于实现优先队列和动态数组等数据结构)
在堆中,为什么是以两倍的方式扩容而不是三倍四倍,或者其他方式呢?
这是因为它可以更高效地利用内存空间,减少内存分配和释放操作的次数,并且可以更快地达到目标大小从而减少扩容的次数。
这种方式也使得数组的大小始终都是2的幂次方,便于计算数组下标,提高代码效率。
相比之下,采用三倍或四倍方式扩容,则可能导致浪费更多的内存空间,增加内存分配和释放操作的数量,降低程序性能。
map&set
map和set都是C++ STL中的关联容器,其中map用于存储键-值对,而set则仅存储值
共同点:
都是C++的关联容器,只是通过它提供的接口对里面的元素进行访问,底层都是采用红黑树实现。
不同点:
set: 用来判断某一个元素是不是在一个组里面
map: 映射,相当于字典,把一个值映射成另一个值,可以创建字典
优点:
【map】有序性是map结构的最大优点,其查找某一个数的时间为O(logn);
【set】自动去重,支持按照元素大小排序
共同的缺点:
每次插入值的时候,都需要调整红黑树,对效率有一定影响
为什么插入数据(insert)后,之前保存的iterator不会失效?
map 和 set 存储的是节点,不需要内存拷贝和移动。而 vector 在插入数据时可能会重新开辟内存,其iterator指向内存的某个位置,而不是节点指针,Map和Set的iterator指向节点指针。
为何map和set的插入删除效率比其他序列容器高?
因为 map 和 set 底部使用红黑树实现,插入和删除的时间复杂度是 O(ogn),而向 vector 这样的序列容器插入和删除的时间复杂度是 O(N)
4、C++11常用特性(智能指针等)
智能指针
智能指针是一种资源管理类。常见的有:unique_ptr(独占式,一个unique_ptr对象只能搭配一个具体对象使用,不能被复制或共享)、shared_ptr(共享式,多个对象共享一个资源,采用引用计数机制来管理对象的生命周期)、weak_ptr(用来避免shared_ptr循环引用导致的内存泄漏)
定义unique_ptr时需要将其绑定到一个new返回的指针上;
unique_ptr不支持普通拷贝和赋值,因为其拥有指向的对象;
shared ptr的实现机制是在拷贝构造时使用同一份引用计数;
同一个shared ptr被多个线程"读"是安全的,被多个线程"写"是不安全的;
shared ptr的底层实现原理是什么?
shared_ptr的底层实现原理是利用计数器来实现多个智能指针共享同一个对象的内存资源。当一个shared_ptr对象被创建时,会在堆上分配两个内存块,一个用于存储所指向对象的地址,另一个用于存储引用计数。
每当有新的 shared_ptr 对象指向同一个对象时,其对应的引用计数加一;当一个 shared_ptr 对象销毁或者重新分配时,其对应的引用计数就会减一。只有当引用计数变为 0 时,表示当前对象没有任何 shared_ptr 对象指向它,此时才会真正地释放这块内存空间。
为什么auto ptr逐渐被弃用,(auto缺点)
首先,auto ptr 只能用于管理单个对象的所有权,对于数组等多个对象的情况就不太适用了。
其次,auto ptr 不能共享所有权,也就是说如果需要在多个地方访问同一个对象,就不能使用 auto ptr。
此外,auto ptr 在转移所有权时只能使用拷贝语义,不能使用移动语义,这会导致性能损失。
最后,auto ptr 的语义比较复杂,容易引起程序员的混淆和错误
因此,现在一般使用unique_ptr 和 shared_ptr来代替auto ptr
类型推导
auto
auto关键字可以用于自动推导变量的类型。使用auto定义的变量会根据其所赋的值的类型来推导出变量的类型
使用auto定义的变量必须在定义时进行初始化,否则编译器将无法推导出变量的类型
除此之外还有以下规则:
- auto无法推导出模板参数,不能定义数组,可以定义指针
- auto只能用于自动推导函数内部定义的变量,而不能用于类的成员变量(因为类成员变量的大小布局必须在编译期间确定,而auto作用于运行时期)
decltype
decltype是用来推导表达式类型的关键字。它可以在不执行代码的情况下,根据表达式返回值的类型推断出整个表达式的类型
好的,下面举一个简单的例子:
#include <iostream>
using namespace std;
int main() {
int x = 1, y = 2;
decltype(x + y) z; // 推导x + y的类型并定义z
z = x + y;
cout << "z的值为:" << z << endl;
return 0;
}
在这个例子中,我们使用“decltype(x+y)”来推导x+y表达式的类型,并将其赋给变量z。由于x和y都是int类型,所以x+y的类型也是int,于是z就被定义成了int类型。最后输出z的值为3。
需要注意的是,decltype并不会执行表达式,它只是推导类型。因此如果表达式中有函数调用等会产生副作用的操作,这些操作并不会被执行。此外,如果表达式中包含未定义的变量,编译器会报错。
左值、右值
在编程语言中,一个表达式的值可以被分为左值和右值。左值指可被取地址的表达式,通常是一个变量或者对象;而右值则指不能被取地址的表达式,通常是字面常量或者表达式计算后的结果,例如 1+2。
int a = 1; // a 是左值,可以被取地址
int b = a; // a 是右值,不能被取地址
简单来说,左值就是“可以放在等号左边”的值,右值就是“只能放在等号右边”的值。
左值可以出现在赋值操作符、引用操作符以及取地址操作符等需要使用内存地址的地方,而右值仅能用于运算和比较等操作中。
右值引用
是 C++11 中引入的一种引用类型,使用 && 符号来声明,它可以绑定到右值(即不能被修改的对象,如临时对象、字面量等)上。
与左值引用相对应,右值引用通常用于实现移动语义和完美转发。
移动语义
指的是将资源所有权从一个对象转移到另一个对象而不进行深拷贝,这可以提高程序的性能。
浅拷贝:
a和b的指针指向了同一块内存,就是浅拷贝,只是数据的简单赋值;
深拷贝:
深拷贝就是在拷贝对象时,如果被拷贝对象内部还有指针引用指向其它资源,自己需要重新开辟一块新内存存储资源
完美转发
则是指在函数调用中以原封不动的方式传递参数,使得被调用函数能够接收和处理原始参数。
左值引用
举个例子就知道了,C++中的引用就是是左值引用。
左值引用是指通过使用 & 符号声明的引用类型,它可以绑定到左值(即具有内存地址的对象),并且可以被修改。与之相对的是右值引用,它是使用 && 符号声明的引用类型,可以绑定到右值(即不能被修改的对象,如临时对象、字面量等)。
nullptr
C++11引入nullptr关键字来区分空指针和0。nullptr的类型为nullptr_t,能够转换为任何指针或成员指针的类型,也可以进行相等或不等的比较
可以简单理解为nullptr代表空指针,NULL直接等于0。在C11中建议用nullptr代替NULL
范围for循环
其实就是增强for,例如
#include <stdio.h>
int main(){
int arr[] = {10, 20, 30, 40, 50};
// 遍历整个数组
for(int x : arr) {
printf("%d ", x);
}
return 0;
}
列表初始化
引用LRU题解里面的LRUCache初始化部分
class LRUCache {
struct ListNode{//定义节点结构体
int key;
int val;
ListNode* next;
ListNode* pre;
};
ListNode* dummy;
int maxSize;//最大缓存数量
int nodeNums;//当前缓存中的节点数量
//定义哈希表,key是int,val是节点
unordered_map<int, ListNode*> hash;
public:
LRUCache(int capacity): maxSize(capacity), dummy(new ListNode){//不用参数列表也行
nodeNums = 0;
//dummy的 next 和 prev 指针都指向自身,这样当缓存为空时,dummy既是头节点也是尾节点
dummy->next = dummy;
dummy->pre = dummy;
}
...
};
这里在初始化LRUCache类时使用了列表初始化的方式
others
原子操作允许程序原子性地读取和修改共享数据,从而避免了竞态条件和数据竞争。
线程库提供了创建和管理线程的接口,使得多任务编程和并行计算变得简单。
互斥量和条件变量允许线程之间以安全的方式同步和通信。
future/promise机制则提供了一种方便的方法来异步处理结果。
补充问题
C++的 inline 关键词和宏有什么区别,有什么优势,有什么劣势
C++中的inline
关键字和宏都可以用于函数的定义。
宏的作用是在编译时将宏调用处替换为宏定义的内容,从而减少函数调用的开销,但是会增加代码长度,并且不能进行类型检查。相比之下,inline
关键字不仅可以避免函数调用的开销,还可以进行类型检查,因此更加安全可靠。但是,inline
关键字也可能导致代码膨胀,不适合对较大的函数进行标记。
总体来说,inline
关键字的优势在于效率高、安全可靠,缺点在于并非所有情况下都能起到优化作用。而宏的优点在于代码简洁、易于使用,缺点在于不够安全、不支持类型检查。
为什么C++有智能指针而C没有?
主要是因为C++支持面向对象编程,而C不支持。
在C++中,智能指针是一个类,它封装了指针,并提供了一些自动化的内存管理功能,如自动释放指针所指向的内存等。这样可以避免程序员手动管理内存带来的问题,例如内存泄漏和野指针等。
而在C语言中,并没有面向对象的概念和类的概念,因此也就无法实现智能指针这种自动化的内存管理机制。
普通模板函数定义在哪里,为什么?
普通模板函数的定义通常在头文件中。这是因为C++编译器在编译模板函数时需要知道其完整的实现定义,只有将其放在头文件中,才能保证每个使用该模板函数的源文件都能够访问到其完整的实现代码。
当一个源文件调用模板函数时,编译器会根据调用的参数类型来推断出模板参数的具体类型,并生成对应的代码。如果模板函数的定义不在头文件中,那么在链接阶段就无法找到其完整的实现,从而导致链接失败。
以下是一个普通模板函数的例子,定义在头文件中:
// example.h
#ifndef EXAMPLE_H
#define EXAMPLE_H
template <typename T>
void swap(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}
#endif
该函数是一个模板函数,可以交换任意类型的两个变量值。由于其实现是依赖于模板参数类型的,因此需要将其定义放在头文件中,以便于其他源文件使用。如果将其定义放在源文件中,那么编译器无法生成完整的代码,在链接阶段就会出现找不到实现定义的错误。
如果源文件a和b中都使用了模板函数,为什么不报重定义的错误
在C++中,如果多个源文件中都使用了相同的模板函数,编译器不会报重定义错误。这是因为模板函数并没有被直接实例化,在不同的源文件中编译器只会生成相应的代码模板,并不会生成真正的函数实现。在链接时,编译器会根据需要实例化的类型具体化生成不同的实例,然后将它们链接到一起。
因此,当链接程序处理多个目标文件时,由于模板函数并不是真正的函数实现,而只是一个代码模板,因此不会出现重复定义的问题。只有在具体化(即实例化)模板函数时,编译器才会生成真正的函数实现,从而避免了重复定义的问题。
malloc申请100B和申请100MB的区别[同花顺]
malloc申请100B和申请100MB的区别在于内存大小、申请方式、空间效率和时间效率等方面。
申请100B的内存大小为100字节,而申请100MB的内存大小为100兆字节。
当申请较小的内存时,malloc直接从堆上分配相应大小的内存空间;当申请较大的内存时,malloc会尝试从操作系统中获取相应大小的内存块,并将其划分为多个小块来分配。申请较小的内存时,由于需要频繁地进行内存分配和释放,因此时间效率比较低;而申请较大的内存时,由于内存分配和释放的次数相对较少,因此时间效率比较高。
vector中插入一万个数据如何提高效率,再把这一万个数据清掉后内存如何变化?[同花顺]
在向vector中插入一万个数据时,可以考虑提高插入效率的方法,如:
- 提前预留足够的空间:使用reserve函数预留足够的空间,在插入元素时避免频繁的内存重新分配,从而提高效率。
- 使用移动语义: 移动语义是C++11引入的新特性,通过std::move将对象转换为右值引用,可以避免拷贝构造函数和析构函数的调用,从而提高效率。
- 使用emplace_back: emplace_back可以直接在vector尾部构造元素,避免了拷贝和移动操作,因此可以提高效率。
清除vector中的一万个数据可以使用clear()函数,这个函数会将vector的大小设置为0,但是不会释放内存。如果需要释放vector占用的内存,可以使用swap()函数,将vector与一个空vector进行交换。这样可以让vector的大小变为0,同时释放掉原有的内存空间,达到释放内存的目的。