嵌入式开发工程师笔试面试指南-C++
C++基础
1 简述C++从代码到可执行二进制文件的过程⭐⭐⭐⭐⭐
C++和C语言类似,一个C++程序从源码到执行文件,有四个过程,预编译、编译、汇编、链接。
预编译:这个过程主要的处理操作如下:
(1) 将所有的#define删除,并且展开所有的宏定义
(2) 处理所有的条件预编译指令,如#if、#ifdef
(3) 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。
(4) 过滤所有的注释
(5) 添加行号和文件名标识。
编译:这个过程主要的处理操作如下:
(1) 词法分析:将源代码的字符序列分割成一系列的记号。
(2) 语法分析:对记号进行语法分析,产生语法树。
(3) 语义分析:判断表达式是否有意义。
(4) 代码优化:
(5) 目标代码生成:生成汇编代码。
(6) 目标代码优化:
汇编:这个过程主要是将汇编代码转变成机器可以执行的指令。
链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。
2 知道动态链接与静态链接吗?两者有什么区别⭐⭐⭐⭐
链接分为静态链接和动态链接。
1 静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。
2 而动态链接,是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。
区别
1 静态链接是将各个模块的obj和库链接成一个完整的可执行程序;而动态链接是程序在运行的时候寻找动态库的函数符号(重定位)
2 静态链接运行快、可独立运行;动态链接运行较慢(事实上,动态库被广泛使用,这个缺点可以忽略)、不可独立运行。
3 静态链接浪费空间,存在多个副本,同一个函数的多次调用会被多次链接进可执行程序,当库和模块修改时,main也需要重编译;动态链接节省空间,相同的函数只有一份,当库和模块修改时,main不需要重编译。
3 C++编译时和C有什么不同,在c++中怎么用c?⭐⭐⭐
C++与C在编译时的主要区别有以下几点:
- 由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。
- 语法和功能:C++相比C具有更多的语法和功能。C++引入了面向对象编程的概念,包括类、继承、多态等。此外,C++还提供了更多的库和工具,如标准模板库(STL)和异常处理机制等。
- 兼容性:C++是C的超集,这意味着C的源代码可以直接在C++中编译和运行。C++编译器会自动识别和处理C的语法,因此可以使用C代码编写的功能和库。
在C++中使用C代码有多种方式,其中常见的几种方式包括:
使用extern "C"
进行函数声明:在C++中,使用extern "C"
修饰C代码的函数声明,以告诉编译器使用C的名称重载规则。
extern "C" { // C函数声明 int add(int a, int b); }
在C++中包含C的头文件:在C++源文件中直接包含C的头文件,即#include "my_c_code.h"
,然后直接使用其中声明的C函数和数据结构。
#include "my_c_code.h" int main() { int result = add(3, 4); // 调用C函数 return 0; }
使用#ifdef __cplusplus
进行条件编译:在C的头文件中使用条件编译,根据__cplusplus
宏定义来区分C和C++环境,在C++环境下使用extern "C"
修饰C函数声明。
#ifdef __cplusplus extern "C" { #endif // C函数声明 int add(int a, int b); #ifdef __cplusplus } #endif
4 请你说说什么是宏?⭐⭐⭐⭐⭐
#define命令是一个宏命令,它用来将一个标识符定义为一个字符串,该标识符被称为宏名,被定义的字符串称为替换文本。
该命令有两种格式:一种是不带参数的宏定义,另一种是带参数的宏定义。
5 为什么要少使用宏?C++有什么解决方案?⭐⭐⭐⭐⭐
在C++中,推荐尽量避免过多使用宏的原因有以下几点:
- 可读性差:宏通常使用简单的文本替换机制,在代码中展开为复杂的表达式或语句,导致代码可读性降低。
- 潜在的副作用:宏的使用可能导致潜在的副作用,比如多次求值、修改变量等,这可能导致意外行为和错误。
- 缺乏类型检查:宏不进行类型检查,因此在使用宏时需要自行确保类型匹配,否则可能导致运行时错误。
为了解决这些问题,C++提供了一些替代方案来减少宏的使用:
- 使用const和constexpr:C++中的const和constexpr关键字可以用于定义常量,避免了宏定义常量的麻烦,并且提供了类型安全和编译期计算的优势。
- 使用内联函数:C++的内联函数可以取代宏,以提高代码的可读性和类型安全性。内联函数在编译时展开,避免了宏带来的副作用和类型不匹配的问题。
- 使用模板:模板是C++的强大特性之一,可以实现类型安全的泛型编程。通过模板,可以避免使用宏进行代码的泛化。
6 关键字volatile的作用⭐⭐⭐⭐⭐
1 并行设备的硬件寄存器。存储器的硬件寄存器通常加volatile,因为寄存器随时可以被外设硬件修改。当声明指向设备寄存器的指针时一定要用volatile,它会告诉编译器不要对存储在这个地址的数据进行假设。
2 一个中断服务程序中修改的供其他程序检测的变量。volatile提醒编译器,它后面定义的变量随时都有可能改变。因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序跟新的话,将出现不一致的现象。
3 多线程应用中被多个任务共享的变量。单地说就是防止编译器对代码进行优化。
7 关键字static的作用⭐⭐⭐⭐⭐
1 在函数体中,只会被初始化一次,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
2 在模块内(函数体外),一个被声明为静态变量可以在模块内所用函数访问,但不能被模块外其他函数访问。它是一个本地的全局变量(只能在当前文件使用)。
3 在模块内,一个被声明为静态的函数只可被这一模块内的其他函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用(只能被当前文件使用)。
其中对于所有的对象(不仅仅是静态对象),初始化都只有一次,而由于静态变量具有记忆功能,初始化后,一直没有被销毁,都会保存在内存区域中,所以不会再次初始化。存放在静态区的变量的生命周期一般比较长,他与整个程序同生死,同存亡,所以只需要初始化一次。
而auto变量,即自动变量,由于它存放在栈区,一旦函数调用结束,就会立即被销毁。
8 关键字const的作用⭐⭐⭐⭐⭐
1 定义变量(全局变量或者局部变量)为常量。
2 修饰函数的参数,表示在函数体内不能改变这个参数的值。
3 修饰函数的返回值。
a 如果给用const修饰返回值的类型为指针,那么函数返回值的内容是不能被修改的,而这个返回值只能赋给被const修饰的指针。
b 如果用const修饰普通的返回值,如返回int变量,由于这个返回值是一个临时变量,在函数调用结束后这个临时变量的生命周期也就结束了,因此这个返回值修饰为const是没有意义的。
4 节省空间,避免不必要的内存分配。
在啥情况下使用const关键字
1 修饰一般变量。一般常量是指简单类型的常量。
2 修饰常数值。
3 修饰常对象。常对象是指对象常量。
4 修饰指针。
const int*p; //常量指针,指向常量的指针。即p指向的内存可以变,p指向的数值内容不可变 int const*p; //同上 int*const p;//指针常量,本质是一个常量,而用指针修饰它。 即p指向的内存不可以变,但是p内存位置的数值可以变 const int* const p;//指向常量的常量指针。即p指向的内存和数值都不可变
5 修饰常引用。被const修饰的引用变量为常引用,一旦被初始化,就不能指向其他对象了。
6 修饰函数的常参数。const修饰符也可以修饰函数的传递参数
7 修饰函数的返回值。const修饰符也可以修饰函数的返回值,表明该返回值不可被改变。
8 在另一连接文件中引用const常量。
说说const和define的区别
const用于定义常量;而define用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:
const生效于编译的阶段;define生效于预处理阶段。
const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接的操作数,并不会存放在内存中。
const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。
9 什么是字节对齐?为什么要字节对齐?⭐⭐⭐⭐⭐
1 为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
2 为什么要字节对齐?
(1)需要字节对齐的根本原因在于CPU访问数据的效率问题。
(2)一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
(3)各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始
10 静态局部变量,全局变量,局部变量的特点,以及使用场景⭐⭐⭐⭐⭐
1 首先从作用域考虑:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域。
全局变量:全局作用域,可以通过extern作用于其他非定义的源文件。
静态全局变量 :全局作用域+文件作用域,所以无法在其他文件中使用。
局部变量:局部作用域,比如函数的参数,函数内的局部变量等等。
静态局部变量 :局部作用域,只被初始化一次,直到程序结束。
2 从所在空间考虑:除了局部变量在栈上外,其他都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。
3 生命周期:局部变量在栈上,出了作用域就回收内存;而全局变量、静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。
4 使用场景:从它们各自特点就可以看出各自的应用场景,不再赘述。
11 原子操作⭐⭐⭐⭐
原子操作(atomic operation)指的是由多步操作组成的一个操作。如果该操作不能原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。
原子操作类似互斥锁,但是原子操作比锁效率更高,这是因为原子操作更加接近底层,它的实现原理是基于总线加锁和缓存加锁的方式。
12 多线程编程修改全局变量需要注意什么⭐⭐⭐⭐⭐
多线程编程中,变量的值在内存中可能已经被修改,而编译器优化优先从寄存器里读值,读取的并不是最新值。
解决办法:
1 全局变量加关键字volatile
2 使用原子操作,效率比锁高
3 使用互斥锁
13 strlen和sizeof的区别⭐⭐⭐⭐⭐
1 sizeof 是运算符,strlen是函数。
2 sizeof运算符的结果类型是size_t,他在头文件的typedef为unsigned_int类型。
3 sizeof可以用类型作为参数,strlen只能用char* 做参数,而且需要以”\0“来结尾。
4 大部分编译程序的sizeof都是在编译的时候计算的;而strlen大小则是在运行期确定的。
5 当数组作为参数传给函数时,传递的是指针而不是数组,即传递的是数值的首地址。
strlen(“\0”)=? 和sizeof(“\0”)=?
strlen(“\0”)=0,
sizeof(“\0”)=2.
strlen用来计算字符串的长度(字符串是以"\0"作为结束符的),而sizeof是以字节的形式给出了操作数的存储大小,操作数的存储大小由操作数的类型决定的。
14 ++a和a++的区别?⭐⭐⭐⭐⭐
++a的运算过程是
a=a+1; return a;
而a++的运算过程是
int temp=a; a=a+1; return temp;
后置自增运算符需要把原来的值复制到一个临时的存储空间,等运算结束后才会返回这个临时变量的值。所以前置自增运算符效率比后置自增要高。
15 typedef和define有什么区别 ⭐⭐⭐⭐⭐
typedef和#define是C语言中用于定义别名的两种方式,它们的主要区别如下:
1 typedef是在编译时处理的,而define是在预处理时处理的。因此,typedef可以接受类型名称作为参数,而define不能;
2 typedef只能用于定义类型,而define可以定义常量、变量、函数等;
3 typedef定义的别名会导致新的类型定义,而define定义的别名只是文本替换,不能创建新的类型;
4 使用typedef定义的别名比使用#define定义的别名更加安全和易读,因为typedef定义的别名可以避免意想不到的替换,并且可以使用更加直观的语言来描述类型。
总之,typedef和#define都是定义别名的方式,但typedef定义类型别名更加安全、易读,而define定义常量、变量、函数等不同类型的别名。
16 const和define 的区别 ⭐⭐⭐⭐⭐
const用于定义常量;而define用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:
1 const生效于编译的阶段;define生效于预处理阶段。
2 const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接的操作数,并不会存放在内存中。
3 const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。
17 C++中 struct 和 class 的区别? ⭐⭐⭐⭐⭐
在C++中,struct和class都可以用来定义自定义数据类型。它们的基本功能是完全一样的,唯一的区别是默认的成员访问权限和继承方式不同。
struct 中声明的成员默认是公共的(public),而 class 中声明的成员默认是私有的(private)。这就是 struct 和 class 的主要区别。因此,在定义类的时候,如果只需要公共的成员,可以使用 struct;如果需要对成员进行更精细的控制,可以使用 class。
此外,struct 与 class 在继承方式上也有所不同。使用 struct 进行继承时,默认的继承方式是 public 继承,而使用 class 进行继承时默认的继承方式是 private 继承。因此,与继承相关的某些概念,如派生类中的成员访问控制和继承链中访问控制的继承,也不完全相同。
另外还有一些其他的区别,例如创建结构时不需要使用 new 关键字分配内存,而在创建类对象时需要使用 new。但在实际使用过程中,这些区别并不会对程序的功能产生重大影响,只是在一些细节上的区别。
总的来说,struct 和 class 的区别并不是很大,主要是在默认的成员访问权限和继承方式上有所不同。除此之外,大部分情况下建议根据需要使用恰当的关键字 struct 或 class 来定义类或模板类型。
18 C++结构体和C结构体的区别? ⭐⭐⭐⭐⭐
(1)C的结构体内不允许有函数存在,C++允许有内部成员函数,且允许该函数是虚函数。
(2)C的结构体对内部成员变量的访问权限只能是public,而C++允许public,protected,private三种。
(3)C语言的结构体是不可以继承的,C++的结构体是可以从其他的结构体或者类继承过来的。
(4)C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用。
19 结构与联合有何区别?⭐⭐⭐⭐
结构(struct)和联合(union)是 C 和 C++ 等编程语言中用于组织数据的两种不同方式,它们的区别主要如下:
内存分配:结构中的每个成员都有独立的内存空间,整个结构的大小是所有成员大小之和(考虑内存对齐)。联合的所有成员共享同一块内存空间,其大小取决于最大成员的大小。
数据存储:结构可以同时存储多个成员的值,各成员相互独立。联合在同一时刻只能存储一个成员的值,新成员赋值会覆盖旧成员的值。
用途:结构常用于将不同类型但相关的数据组合在一起,如表示一个学生的信息。联合常用于需要在不同时刻存储不同类型数据的场景,如实现通用数据类型的存储和解析。
20 全局变量和局部变量有什么区别?是怎么实现的?操作系统和编译器是怎么知道的?⭐⭐⭐
全局变量和局部变量的区别、实现方式以及操作系统和编译器的识别方式如下:
区别
作用域:全局变量作用域是整个程序,可在各个函数及代码块中访问;局部变量作用域仅在定义它的函数或代码块内,出了该范围就无法访问。
生命周期:全局变量从程序启动开始存在,到程序结束才销毁;局部变量在进入其所在函数或代码块时创建,离开时销毁。
存储位置:全局变量一般存于静态存储区,局部变量通常在栈区,若为动态分配的局部变量则在堆区。
初始化:全局变量若未初始化,会自动初始化为 0 或空值;局部变量若未初始化,其值是不确定的随机值。
实现方式
全局变量:编译器为其在静态存储区分配固定内存空间,在可执行文件中有专门存储段。程序运行时,操作系统根据可执行文件信息将其加载到对应内存区域,通过变量名访问内存地址。
局部变量:函数调用时,编译器在栈上为局部变量分配内存,其地址是相对于栈指针的偏移量,函数执行中通过栈指针和偏移量访问,函数返回时栈指针移动释放空间。
识别方式
编译器:通过语法分析,全局变量在函数外定义有文件作用域,局部变量在函数或代码块内有块作用域,编译器为它们生成不同符号表记录相关信息用于编译和链接。
操作系统:加载可执行文件时,依据文件格式信息,识别全局变量所在段和局部变量的栈区等内存区域,运行时管理进程内存空间,为变量提供正确访问环境。
21 mutable和volatile关键字功能对比?⭐⭐⭐
mutable关键字:
- mutable关键字用于修饰类的成员变量,在常量成员函数中允许被修改。
- 默认情况下,常量成员函数不允许修改类的成员变量,而使用mutable关键字可以解除这个限制,允许在常量成员函数中修改被mutable修饰的成员变量。
- mutable关键字适用于一些内部状态需要在常量成员函数中更新的情况,例如缓存结果或需要记录操作次数等。
volatile关键字:
- volatile关键字用于修饰变量,在多线程、硬件IO和中断处理等场景中,告诉编译器该变量的值是可能发生变化的,需要特殊对待。
- 为了告诉编译器不要对该变量进行优化,以防止对变量读取和写入的优化可能导致错误的行为。
- volatile适用于需要和外部环境进行交互的变量,比如硬件寄存器的状态、多线程操作的标记等。
虽然mutable和volatile关键字都涉及对变量的特殊处理,但用途和功能有所区别。mutable用于修改类的成员变量,以解除常量成员函数对变量修改的限制,而volatile用于标记可能发生变化的变量,告诉编译器需要特殊对待,以防止编译器优化导致的问题。
22 静态变量什么时候初始化?⭐⭐⭐⭐
在C语言中,全局变量和静态变量的初始化发生在编译期。这意味着它们的初始化在程序执行之前就已经完成,无论是否真正使用这些变量。
示例:
#include <stdio.h> int globalVar = 10; // 全局变量,在编译期初始化 void function() { static int staticVar = 20; // 静态变量,在编译期初始化 printf("Static variable: %d ", staticVar); } int main() { printf("Global variable: %d ", globalVar); function(); return 0; }
而在C++中,全局变量和静态变量的初始化行为有所不同。全局变量和静态变量在C++中的初始化推迟至它们"首次用到"时才进行,这是C++标准规定的行为。
示例:
#include <iostream> int globalVar = 10; // 全局变量,推迟初始化 static int staticVar = 20; // 静态变量,推迟初始化 void function() { static int staticVarFunction = 30; // 静态变量,推迟初始化 std::cout << "Static variable in function: " << staticVarFunction << std::endl; } int main() { std::cout << "Global variable: " << globalVar << std::endl; std::cout << "Static variable: " << staticVar << std::endl; function(); return 0; }
在这个例子中,全局变量 globalVar
和静态变量 staticVar
被推迟到它们首次用到时进行初始化。函数 function()
中的静态变量 staticVarFunction
也是在首次函数调用时进行初始化。
C++新特性
1 简述C++11有什么新特性?⭐⭐⭐
- 自动类型推导(Type Inference):引入了 auto 关键字,允许编译器根据初始化表达式的类型自动推导变量的类型。
- 统一的初始化语法(Uniform Initialization Syntax):引入了用花括号 {} 进行初始化的统一语法,可以用于初始化各种类型的对象,包括基本类型、数组、结构体、类等。
- 右值引用(Rvalue References):引入了 && 符号,用于声明右值引用。右值引用具有区分左值和右值的能力,提供了移动语义和完美转发的基础。
- 移动语义(Move Semantics):通过右值引用和移动构造函数(Move Constructor)实现,用于高效地转移资源拥有权,避免不必要的复制和内存分配。
- lambda 表达式(Lambda Expressions):引入了类似于匿名函数的语法,允许在代码中创建匿名函数对象,方便地编写更简洁的、具有局部作用域的函数。
- 并发支持(Concurrency Support):引入了多线程和原子操作的支持,包括线程库、原子类型、互斥锁、条件变量等,使得并发编程更加方便和安全。
- 新的智能指针(Smart Pointers):引入了 std::shared_ptr、std::unique_ptr、std::weak_ptr 等智能指针类模板,提供了更安全、更方便的内存管理机制。
- 静态断言(Static Assert):引入了 static_assert 关键字,允许在编译时对表达式进行静态断言,用于自定义的编译时检查和错误提示。
- 新的标准库组件:包括了正则表达式库、基于范围的循环(Range-based for loop)、哈希表(std::unordered_map、std::unordered_set)、随机数库、异步任务库(std::async)、类型特征工具(std::is_same、std::is_convertible 等)等。
2 auto关键字⭐⭐⭐
优点
- 代码简洁性:可避免书写冗长复杂的类型名称,使代码更简洁易读,尤其在处理标准库容器迭代器或模板类型时优势明显。
- 提高代码可维护性:当代码中的类型发生变化,使用
auto
无需手动修改所有相关变量的类型声明,减少出错可能性。 - 支持匿名类型:处理 lambda 表达式时,由于其类型是编译器生成的匿名类型,无法显式写出,使用
auto
可方便存储和使用。
缺点
- 降低代码可读性:过度使用
auto
会使代码可读性降低,在类型不明确时,阅读者需花费更多时间推断变量实际类型。 - 类型推导可能不符合预期:在某些复杂情况下,编译器推导的类型可能并非开发者期望的类型,涉及引用、常量性等问题时,需开发者深入理解类型推导规则。
- 调试难度增加:调试时,因变量类型由编译器推导,可能给调试带来困难,使用调试工具时难以直观查看变量具体类型。
使用场景
- 迭代器遍历:使用标准库容器迭代时,
auto
可避免写出冗长的迭代器类型,使代码更简洁。 - Lambda 表达式:lambda 表达式类型是匿名的,只能用
auto
存储。 - 模板编程:模板类型可能非常复杂,使用
auto
可简化代码、提高可维护性。 - 初始化复杂表达式:当变量初始化表达式复杂、类型难以确定时,可用
auto
让编译器自动推导类型。
3 Lambda表达式⭐⭐⭐
lambda 表达式的基本语法如下:
[capture](parameters) -> return_type { body }
其中:
capture
:用于从外部作用域捕获变量,可以是值捕获或引用捕获。parameters
:函数参数列表。return_type
:函数返回类型。可以省略,会根据返回表达式自动推导。body
:函数体,可以包含任意合法的代码。
优点
- 代码简洁性:可在需要函数对象的地方直接定义,无需额外定义命名的函数或函数对象类,让代码更紧凑。
- 提高代码的可读性和可维护性:当函数逻辑仅在特定上下文中使用时,将函数定义和使用放在一起,使代码意图更清晰。
- 捕获外部变量:能通过捕获列表捕获外部作用域的变量,在函数体中使用,为函数实现提供更多灵活性。
- 延迟执行:可将 lambda 表达式存储在变量中,在需要时调用,实现延迟执行功能。
缺点
- 降低代码的复用性:通常是匿名的且为特定上下文设计,难以在其他地方复用。若函数逻辑需多处使用,定义命名函数或函数对象更合适。
- 调试难度增加:调试时可能带来困难,尤其在嵌套较深或捕获大量外部变量时,调试器难以清晰显示其状态。
- 性能开销:尽管现代编译器有优化,但在某些情况下,lambda 表达式的创建和调用可能带来性能开销,特别是捕获大量数据时。
使用场景:
- 算法函数对象:作为 STL 的算法函数对象,lambda 表达式可以方便地用于操作容器中的元素。
- 回调函数:作为回调函数传递给其他函数,lambda 表达式可以提供一种简洁的实现方式。
- 并行编程:在并行编程的场景下,lambda 表达式可以用于定义线程函数或并行执行的任务。
作为算法函数对象:
std::vector<int> nums = {1, 2, 3, 4, 5}; std::for_each(nums.begin(), nums.end(), [](int num) { std::cout << num << " "; });
作为回调函数:
void doSomething(int a, int b, std::function<int(int, int)> callback) { int result = callback(a, b); std::cout << "Result: " << result << std::endl; } doSomething(5, 3, [](int x, int y) { return x + y; });
并行编程:
std::vector<int> nums = {1, 2, 3, 4, 5}; std::vector<int> squares(nums.size()); #pragma omp parallel for for (size_t i = 0; i < nums.size(); ++i) { squares[i] = nums[i] * nums[i]; }
4 理解左值和右值?⭐⭐⭐
左值(lvalue)和右值(rvalue)是 C++ 中非常基础且重要的概念,它们描述了表达式的属性,对理解 C++ 中的引用、移动语义等高级特性起着关键作用。以下为你详细介绍这两个概念:
基本定义
- 左值(lvalue):左值是一个表示对象的表达式,它可以出现在赋值语句的左边,具有一个确定的内存地址,可以被取地址。简单来说,左值是一个有名字、可以被引用的对象。
- 右值(rvalue):右值是一个临时的、即将被销毁的表达式,它只能出现在赋值语句的右边,不能被取地址。右值通常是字面量、临时对象或者函数返回的临时结果。
示例区分
左值示例
cpp
int num = 10; // num 是一个左值,它有自己的内存地址,可以被取地址 int& ref = num; // 可以使用左值引用绑定到左值 num
在上述代码中,num
是一个左值,因为它代表一个具体的对象,有自己的内存地址,并且可以被引用。
右值示例
cpp
int result = 3 + 5; // 3 + 5 是一个右值,它是一个临时的计算结果,没有自己的内存地址 // int& badRef = 3 + 5; // 错误:不能使用左值引用绑定到右值
在这个例子中,3 + 5
的结果是一个右值,它是一个临时的表达式结果,没有固定的内存地址,不能被取地址。
5 什么是右值引用?它的作用?⭐⭐⭐
右值引用(R-value reference)是 C++11 引入的一种新的引用类型,用于标识和操作右值。
右值引用使用 &&
符号进行声明,例如 int&&
表示一个右值引用类型的整数。右值引用具有以下几个重要的特性和作用:
- 标识右值:右值引用主要用于标识和操作右值(临时值、表达式结果、将被销毁的值等)。右值引用只能绑定到右值,不能绑定到左值。
- 移动语义:右值引用支持移动语义,通过对临时对象的资源所有权进行移动而不是复制,提高了操作的效率。例如,在对象的拷贝构造函数和拷贝赋值运算符中,可以通过移动构造函数和移动赋值运算符来实现对资源的转移。
- 完美转发:右值引用也用于实现完美转发,即在函数模板中保持参数的值类别。通过使用右值引用参数,可以将传递给函数的右值或左值转发到其他函数,保持传递参数的原始值类别。
右值引用的作用主要体现在以下几个方面:
- 避免不必要的拷贝:通过标识和操作右值,可以避免在操作临时对象时进行不必要的拷贝操作,提高程序的性能。
- 实现移动语义:通过右值引用和移动操作,可以在对象的资源拷贝过程中,将资源所有权从一个对象转移给另一个对象,避免了不必要的资源拷贝。
- 支持完美转发:通过右值引用,可以保持传递参数的值类别,实现参数的完美转发,避免了临时对象的额外拷贝操作。
以下是一个简单示例,展示了右值引用的使用:
void processValue(int&& value) { // 对右值进行操作 // ... } int main() { int x = 10; processValue(5); // 临时值 5 是一个右值 processValue(x); // x 是一个左值,无法绑定到右值引用 return 0; }
在这个示例中,processValue()
函数接受一个右值引用参数,可以绑定到临时值 5,但无法绑定到变量 x
。右值引用可以用于对右值进行特定的操作,提高代码的效率和灵活性。
6 说说移动语义的原理⭐⭐⭐⭐⭐
移动语义为了避免临时对象的拷贝,为类增加移动构造函数。移动构造函数与拷贝构造不同,它并不是重新分配一块新的空间同时将要拷贝的对象复制过来,而是"拿"了过来,将自己的指针指向别人的资源,然后将别人的指针修改为nullptr
7 迭代器和指针有什么区别?有了指针干嘛还要迭代器?⭐⭐⭐⭐⭐
迭代器不是指针,是类模板,表现的像指针。它只是模拟了指针的一些功能,通过重载了指针的一些操作符,如-->
、*
、++
、--
等。
迭代器封装了指针,是一个“可遍历STL容器内全部或部分元素”的对象,本质是封装了原生指针,是指针概念的一种提升,相当于智能指针。而迭代器的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。这就是迭代器产生的原因。
8 请你说说智能指针,智能指针为什么不用手动释放内存了?⭐⭐⭐⭐⭐
使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等。
正是因为指针存在这样的问题,C++便引入了智能指针来更好的管理堆内存。智能指针是利用了一种叫做RAII(资源获取即初始化)的技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针。
因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,自动释放资源。这样程序员就不用再担心内存泄露的问题了。
C++里面有四个指针:auto_ptr、unique_ptr、shared_ptr、weak_ptr,后面三个是C++11支持的,第一个被C++11弃用。
9 auto_ptr有什么样的问题⭐⭐⭐⭐⭐
- 看如下代码:
auto_ptr<string> p1 (new string ("I am jiang douya.")); auto_ptr<string> p2; p2 = p1; //auto_ptr不会报错.
auto指针存在的问题是,两个智能指针同时指向一块内存,就会两次释放同一块资源,存在潜在的内存崩溃问题!因此auto指针被C++11弃用。应该用unique指针替代auto指针。
10 unique_ptr指针实现原理⭐⭐⭐⭐⭐
unique指针规定一个智能指针独占一块内存资源。当两个智能指针同时指向一块内存,编译报错。
我们只需要将拷贝构造函数和赋值拷贝构造函数申明为private或delete。不允许拷贝构造函数和赋值操作符
11 shared_ptr实现原理⭐⭐⭐⭐⭐
std::shared_ptr 是 C++ 标准库 <memory> 头文件中提供的一种智能指针,用于管理动态分配的对象,其核心目标是实现对象的自动内存管理,避免内存泄漏。下面详细介绍 std::shared_ptr 的实现原理。
基本概念
std::shared_ptr 采用引用计数的方式来管理对象的生命周期。引用计数是一种记录有多少个 std::shared_ptr 实例共享同一个对象的机制。当引用计数变为 0 时,说明没有任何 std::shared_ptr 实例再引用该对象,此时就会自动释放该对象所占用的内存。
实现细节
1. 引用计数
std::shared_ptr 内部维护了一个引用计数,它通常存储在一个控制块(control block)中。控制块是一个额外的内存区域,除了引用计数外,还可能包含其他信息,如弱引用计数(用于 std::weak_ptr)和删除器(用于指定对象的释放方式)。
2. 构造和赋值操作
构造函数:当创建一个 std::shared_ptr 并让它指向一个新对象时,会同时创建一个控制块,并将引用计数初始化为 1。例如:
#include <memory> int main() { std::shared_ptr<int> ptr(new int(42)); // 创建一个 shared_ptr 并指向一个新的 int 对象,引用计数为 1 return 0; }
拷贝构造函数:当使用一个 std::shared_ptr 来初始化另一个 std::shared_ptr 时,它们会共享同一个控制块,并且引用计数会加 1。例如:
#include <memory> int main() { std::shared_ptr<int> ptr1(new int(42)); // 引用计数为 1 std::shared_ptr<int> ptr2 = ptr1; // 引用计数变为 2 return 0; }
赋值运算符:当一个 std::shared_ptr 被赋值给另一个 std::shared_ptr 时,原 std::shared_ptr 的引用计数会减 1(如果减到 0 则释放对象),新 std::shared_ptr 的引用计数会加 1。例如:
#include <memory> int main() { std::shared_ptr<int> ptr1(new int(42)); // 引用计数为 1 std::shared_ptr<int> ptr2(new int(100)); // 引用计数为 1 ptr2 = ptr1; // ptr2 原引用计数减为 0,释放原对象;ptr1 引用计数加 1 变为 2 return 0; }
3. 析构操作
当一个 std::shared_ptr 离开其作用域时,它的析构函数会被调用。析构函数会将引用计数减 1,如果引用计数变为 0,则会释放对象所占用的内存,并销毁控制块。例如:
#include <memory> void func() { std::shared_ptr<int> ptr(new int(42)); // 引用计数为 1 // ptr 离开作用域,引用计数减为 0,释放对象 } int main() { func(); return 0; }
4. 删除器
std::shared_ptr 允许用户指定一个删除器(deleter),用于自定义对象的释放方式。删除器是一个可调用对象,当引用计数变为 0 时,会调用该删除器来释放对象。例如:
#include <memory> #include <iostream> void customDeleter(int* ptr) { std::cout << "Custom deleting..." << std::endl; delete ptr; } int main() { std::shared_ptr<int> ptr(new int(42), customDeleter); return 0; }
总结
std::shared_ptr 通过引用计数和控制块来实现对象的自动内存管理。多个 std::shared_ptr 可以共享同一个对象,通过引用计数的增减来控制对象的生命周期。当引用计数变为 0 时,会自动释放对象所占用的内存。同时,std::shared_ptr 还支持自定义删除器,提供了更灵活的内存管理方式。
12 shared_ptr会不会出现内存泄露?怎么解决?⭐⭐⭐⭐⭐
会出现内存泄露问题。
共享指针的循环引用计数问题:当两个类中相互定义shared_ptr成员变量,同时对象相互赋值时,就会产生循环引用计数问题,最后引用计数无法清零,资源得不到释放。
可以使用weak_ptr,weak_ptr是弱引用,weak_ptr的构造和析构不会引起引用计数的增加或减少。我们可以将其中一个改为weak_ptr指针就可以了。比如我们将class B里shared_ptr换成weak_ptr。
13 weak_ptr 实现原理⭐⭐⭐⭐⭐
std::weak_ptr 是 C++ 标准库 <memory> 头文件中提供的一种智能指针,它主要用于解决 std::shared_ptr 存在的循环引用问题。下面详细介绍 std::weak_ptr 的实现原理。
基本概念
std::weak_ptr 是一种弱引用智能指针,它不会增加所指向对象的引用计数,因此不会影响对象的生命周期。它通常与 std::shared_ptr 配合使用,主要用于观察 std::shared_ptr 所管理的对象,并且可以在需要时临时获取一个 std::shared_ptr 来访问该对象。
实现细节
1. 控制块
和 std::shared_ptr 一样,std::weak_ptr 也依赖于控制块(control block)。控制块是一个额外的内存区域,除了存储 std::shared_ptr 所使用的引用计数(强引用计数)外,还会存储一个弱引用计数,用于记录有多少个 std::weak_ptr 指向同一个对象。
2. 构造和赋值操作
构造函数:std::weak_ptr 可以通过 std::shared_ptr 或者另一个 std::weak_ptr 来构造。在构造过程中,它会共享同一个控制块,但不会增加强引用计数,只会增加弱引用计数。例如:
#include <memory> int main() { std::shared_ptr<int> sharedPtr(new int(42)); // 强引用计数为 1,弱引用计数初始为 0 std::weak_ptr<int> weakPtr(sharedPtr); // 强引用计数不变仍为 1,弱引用计数变为 1 return 0; }
赋值运算符:当一个 std::weak_ptr 被赋值给另一个 std::weak_ptr 时,它们会共享同一个控制块,并且对应的弱引用计数会进行相应的调整。例如,将一个 std::weak_ptr 赋值给另一个已存在的 std::weak_ptr 时,原 std::weak_ptr 对应的弱引用计数减 1,新 std::weak_ptr 对应的弱引用计数加 1。
3. 弱引用计数的作用
弱引用计数主要用于跟踪有多少个 std::weak_ptr 指向同一个对象。当强引用计数变为 0 时,说明没有 std::shared_ptr 再引用该对象,此时会释放对象所占用的内存,但控制块不会立即销毁,而是等到弱引用计数也变为 0 时才会销毁。这是因为 std::weak_ptr 可能还需要通过控制块来判断对象是否已经被释放。
4. 检查对象是否有效
std::weak_ptr 提供了 expired() 方法来检查它所指向的对象是否已经被释放。该方法通过检查控制块中的强引用计数来判断对象的有效性。如果强引用计数为 0,说明对象已经被释放,expired() 方法返回 true;否则返回 false。例如:
#include <memory> #include <iostream> int main() { std::shared_ptr<int> sharedPtr(new int(42)); std::weak_ptr<int> weakPtr(sharedPtr); std::cout << "Is object expired? " << (weakPtr.expired() ? "Yes" : "No") << std::endl; sharedPtr.reset(); // 释放 sharedPtr,强引用计数变为 0 std::cout << "Is object expired? " << (weakPtr.expired() ? "Yes" : "No") << std::endl; return 0; }
5. 获取 std::shared_ptr
std::weak_ptr 提供了 lock() 方法,用于在对象仍然有效的情况下获取一个 std::shared_ptr 来访问该对象。在调用 lock() 方法时,会先检查控制块中的强引用计数,如果强引用计数大于 0,说明对象仍然有效,此时会创建一个新的 std::shared_ptr 并返回,同时强引用计数加 1;如果强引用计数为 0,说明对象已经被释放,lock() 方法会返回一个空的 std::shared_ptr。例如:
#include <memory> #include <iostream> int main() { std::shared_ptr<int> sharedPtr(new int(42)); std::weak_ptr<int> weakPtr(sharedPtr); if (auto newSharedPtr = weakPtr.lock()) { std::cout << "Object is valid: " << *newSharedPtr << std::endl; } sharedPtr.reset(); if (auto newSharedPtr = weakPtr.lock()) { std::cout << "Object is valid: " << *newSharedPtr << std::endl; } else { std::cout << "Object is expired." << std::endl; } return 0; }
总结
std::weak_ptr 通过弱引用计数和控制块来实现对对象的弱引用。它不会影响对象的生命周期,主要用于观察 std::shared_ptr 所管理的对象。通过 expired() 方法可以检查对象是否已经被释放,通过 lock() 方法可以在对象有效时临时获取一个 std::shared_ptr 来访问该对象。这种机制有效地解决了 std::shared_ptr 可能存在的循环引用问题。
14 四种cast转换⭐⭐⭐⭐
C++中有四种类型转换符可用于在不同类型之间进行类型转换。static_cast、dynamic_cast、const_cast和reinterpret_cast。
static_cast:
- 基本类型之间的转换,例如将int转换为double等。
- 向上或向下进行继承关系的指针或引用转换。
- 显式调用转换构造函数或转换操作符。
- 进行其他合法的转换,例如指针与整数类型之间的转换。
示例:
int num = 10; double convertedNum = static_cast<double>(num); class Base {}; class Derived : public Base {}; Base* basePtr = new Derived(); Derived* derivedPtr = static_cast<Derived*>(basePtr);
dynamic_cast:
- 向上转换:将派生类指针或引用转换为基类指针或引用。
- 安全向下转换:将基类指针或引用转换为派生类指针或引用,仅当基类指针或引用实际指向派生类对象时才有效。
- 运行时类型检查:dynamic_cast会在运行时检查转换的安全性,如果转换失败,返回空指针(对于指针转换)或
抛出std::bad_cast异常(对于引用转换)
示例:
class Base { virtual void foo() {} }; class Derived : public Base {}; Base* basePtr = new Derived(); Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); if (derivedPtr) { // 转换成功 }
const_cast:
- const_cast用于去除指针或引用的const属性。
- 可以修改被const修饰的对象。
- 仅能去除直接指针或引用的const属性。
- 使用const_cast需谨慎,因为修改被const修饰的对象会导致未定义行为。仅在确保安全性的前提下使用。
示例:
const int num = 10; int* nonConstPtr = const_cast<int*>(&num); *nonConstPtr = 20; // 合法:修改nonConstPtr的值
reinterpret_cast:
- reinterpret_cast是C++中用于执行低级别的类型转换的关键字(使用reinterpret_cast需要格外谨慎)。
- 它可以将一个指针或引用转换为不同类型的指针或引用,甚至是完全无关的类型。
- reinterpret_cast在类型转换时只进行位模式的重新解释,不执行任何类型检查或转换操作。
- 错误的使用reinterpret_cast可能导致程序行为不确定或非法。
- 因此,除非绝对必要,否则应避免使用reinterpret_cast,并且使用前需要确保类型转换的合法性。
示例代码:
int num = 10; double* doublePtr = reinterpret_cast<double*>(&num); // 不安全,可能导致未定义行为 int* intPtr = reinterpret_cast<int*>(doublePtr); // 转回原始类型
15 vector与list 介绍和异同⭐⭐⭐⭐
1. std::vector
介绍
std::vector
是 C++ 标准模板库(STL)中的一个动态数组容器,它封装了动态大小的数组。std::vector
能够在运行时根据需要自动调整其大小,并且支持随机访问元素。
特点
- 连续存储:
std::vector
中的元素在内存中是连续存储的,这使得它可以像普通数组一样通过下标快速访问元素。 - 动态大小:可以根据需要动态地增加或减少元素的数量。当元素数量超过当前容量时,
std::vector
会自动重新分配更大的内存空间,并将原有元素复制到新的内存位置。 - 支持随机访问:可以使用下标运算符
[]
或at()
方法在 \(O(1)\) 时间复杂度内访问任意位置的元素。
#include <iostream> #include <vector> int main() { std::vector<int> vec = {1, 2, 3, 4, 5}; // 随机访问元素 std::cout << "Element at index 2: " << vec[2] << std::endl; // 动态添加元素 vec.push_back(6); std::cout << "Element at the end: " << vec.back() << std::endl; return 0; }
2. std::list
介绍
std::list
是 C++ STL 中的一个双向链表容器,它由一系列节点组成,每个节点包含一个元素和指向前一个节点和后一个节点的指针。
特点
- 双向链表结构:元素在内存中不是连续存储的,而是通过指针连接成一个双向链表。这使得在链表的任意位置插入和删除元素都非常高效。
- 高效的插入和删除:在链表的任意位置插入或删除元素的时间复杂度为 \(O(1)\),因为只需要修改相邻节点的指针。
- 不支持随机访问:不能像
std::vector
那样通过下标直接访问元素,只能通过迭代器顺序访问。
#include <iostream> #include <list> int main() { std::list<int> myList = {1, 2, 3, 4, 5}; // 在链表头部插入元素 myList.push_front(0); std::cout << "First element: " << myList.front() << std::endl; // 遍历链表 for (auto it = myList.begin(); it != myList.end(); ++it) { std::cout << *it << " "; } std::cout << std::endl; return 0; }
3. std::vector
与 std::list
的异同
相同点
- 容器类型:二者都是序列式容器,用于存储一系列元素,并且元素按照插入的顺序排列。
- 支持泛型:都可以存储任意类型的元素,只要该类型满足容器的操作要求。
- 基本操作:都支持插入元素(如
push_back()
)、删除元素(如pop_back()
)、获取元素数量(size()
)等基本操作。
不同点
- 内存布局std::vector:元素在内存中连续存储,类似于数组。std::list:元素通过双向链表存储,每个元素位于独立的节点中,节点之间通过指针连接。
- 访问效率std::vector:支持随机访问,通过下标访问元素的时间复杂度为 \(O(1)\)。std::list:不支持随机访问,只能通过迭代器顺序访问元素,访问第 n 个元素的时间复杂度为 \(O(n)\)。
- 插入和删除效率std::vector:在尾部插入和删除元素的平均时间复杂度为 \(O(1)\),但在中间或头部插入和删除元素时,需要移动大量元素,时间复杂度为 \(O(n)\)。std::list:在任意位置插入和删除元素的时间复杂度均为 \(O(1)\),因为只需要修改相邻节点的指针。
- 迭代器稳定性std::vector:在插入或删除元素时,可能会导致迭代器失效,特别是在扩容时。std::list:迭代器比较稳定,在插入或删除元素时,除了被删除元素的迭代器会失效外,其他迭代器仍然有效。
- 内存使用std::vector:内存使用效率较高,没有额外的指针开销,但在扩容时可能会有一定的内存浪费。std::list:每个节点需要额外的指针来连接前后节点,内存使用效率相对较低,尤其是在存储较小元素时。
16 vector的底层实现⭐⭐⭐⭐
std::vector
是 C++ 标准模板库(STL)中非常常用的一个容器,它提供了动态数组的功能。下面将详细介绍 std::vector
的底层实现。
基本存储结构
std::vector
的底层使用连续的内存空间来存储元素,类似于普通的数组。它通过三个指针来管理内存:
start
:指向容器中第一个元素的内存地址。finish
:指向容器中最后一个元素的下一个位置的内存地址,也就是当前已使用内存的末尾。end_of_storage
:指向分配的内存空间的末尾,代表当前分配的总容量的边界。
这三个指针可以帮助 std::vector
高效地管理元素的存储和访问。start
到 finish
之间的元素是当前容器中实际存储的元素,finish
到 end_of_storage
之间的内存是已经分配但尚未使用的空间,用于后续添加元素时避免频繁的内存重新分配。
内存分配与扩容机制
初始分配
当创建一个 std::vector
对象时,会根据初始的需求分配一定大小的内存空间。如果没有指定初始大小,通常会分配一个较小的初始容量。例如:
#include <vector> int main() { std::vector<int> vec; // 创建一个空的 vector,初始可能分配了一定的容量 return 0; }
元素添加与扩容
当向 std::vector
中添加元素时,如果当前已使用的空间(即 finish
指针所指位置)达到了分配的总容量(end_of_storage
指针所指位置),就需要进行扩容操作。扩容的步骤通常如下:
- 分配新的内存空间:一般会分配一个比原来更大的连续内存空间,常见的扩容策略是将容量扩大为原来的 2 倍。
- 复制元素:将原内存空间中的所有元素依次复制到新的内存空间中。
- 释放原内存空间:释放原来分配的内存空间。
- 更新指针:更新
start
、finish
和end_of_storage
指针,使其指向新的内存空间。
以下是一个简单的示例展示添加元素时可能的扩容情况:
#include <iostream> #include <vector> int main() { std::vector<int> vec; for (int i = 0; i < 10; ++i) { vec.push_back(i); std::cout << "Size: " << vec.size() << ", Capacity: " << vec.capacity() << std::endl; } return 0; }
在这个示例中,随着元素的不断添加,std::vector
可能会进行多次扩容,每次扩容后容量会增大,你可以观察到 size()
和 capacity()
的输出变化。
元素访问与操作
随机访问
由于 std::vector
的元素是连续存储的,因此可以通过下标运算符 []
或 at()
方法在 \(O(1)\) 的时间复杂度内随机访问任意位置的元素。例如:
#include <iostream> #include <vector> int main() { std::vector<int> vec = {1, 2, 3, 4, 5}; std::cout << vec[2] << std::endl; // 通过下标访问第 3 个元素 std::cout << vec.at(3) << std::endl; // 通过 at() 方法访问第 4 个元素 return 0; }
插入与删除操作
- 尾部插入:使用
push_back()
方法在std::vector
的尾部插入元素时,如果当前容量足够,直接在finish
指针所指位置添加元素并更新finish
指针,时间复杂度为 \(O(1)\);如果需要扩容,则时间复杂度为 \(O(n)\),因为涉及到元素的复制。 - 中间或头部插入:在
std::vector
的中间或头部插入元素时,需要将插入位置之后的所有元素向后移动,时间复杂度为 \(O(n)\)。 - 尾部删除:使用
pop_back()
方法删除std::vector
尾部的元素时,直接更新finish
指针,时间复杂度为 \(O(1)\)。 - 中间或头部删除:在
std::vector
的中间或头部删除元素时,需要将删除位置之后的所有元素向前移动,时间复杂度为 \(O(n)\)。
总结
std::vector
底层通过连续的内存空间存储元素,利用三个指针管理内存,在添加元素时可能会进行扩容操作以满足需求。它支持高效的随机访问,但在中间或头部进行插入和删除操作时效率较低,适合需要频繁随机访问元素且插入删除操作主要在尾部进行的场景。
17 vector和deque的介绍和区别⭐⭐⭐⭐
1. std::vector
介绍
std::vector
是 C++ 标准模板库(STL)中的一个容器,它是一个动态数组,提供了对元素的随机访问能力。
特点
- 连续存储:
std::vector
中的元素在内存中是连续存储的,这使得它可以像普通数组一样通过下标快速访问元素,时间复杂度为 \(O(1)\)。 - 动态大小:
std::vector
可以根据需要动态调整大小。当元素数量超过当前容量时,它会自动重新分配更大的内存空间,并将原有元素复制到新的内存位置。 - 尾部操作高效:在
std::vector
的尾部插入和删除元素(如push_back()
和pop_back()
)通常是高效的,平均时间复杂度为 \(O(1)\)。但在扩容时会有额外的开销。 - 中间或头部操作低效:在
std::vector
的中间或头部插入和删除元素需要移动大量元素,时间复杂度为 \(O(n)\)。
#include <iostream> #include <vector> int main() { std::vector<int> vec = {1, 2, 3}; // 随机访问元素 std::cout << "Element at index 1: " << vec[1] << std::endl; // 尾部插入元素 vec.push_back(4); std::cout << "Last element: " << vec.back() << std::endl; return 0; }
2. std::deque
介绍
std::deque
(双端队列)也是 C++ STL 中的一个容器,它允许在容器的两端高效地插入和删除元素。
特点
- 分段连续存储:
std::deque
由多个固定大小的连续存储块组成,每个块内部的元素是连续存储的,但块与块之间不一定连续。它通过一个中控器(通常是一个指针数组)来管理这些存储块。 - 随机访问:
std::deque
支持随机访问元素,时间复杂度为 \(O(1)\),但由于需要通过中控器定位存储块,实际效率可能略低于std::vector
。 - 两端操作高效:在
std::deque
的头部和尾部插入和删除元素(如push_front()
、pop_front()
、push_back()
和pop_back()
)的时间复杂度都是 \(O(1)\)。 - 中间操作低效:在
std::deque
的中间插入和删除元素仍然需要移动元素,时间复杂度为 \(O(n)\)。
#include <iostream> #include <deque> int main() { std::deque<int> deq = {1, 2, 3}; // 随机访问元素 std::cout << "Element at index 1: " << deq[1] << std::endl; // 头部插入元素 deq.push_front(0); std::cout << "First element: " << deq.front() << std::endl; // 尾部插入元素 deq.push_back(4); std::cout << "Last element: " << deq.back() << std::endl; return 0; }
3. std::vector
和 std::deque
的区别
内存布局
std::vector
:元素在内存中是完全连续存储的,这使得它在缓存局部性方面表现较好,因为相邻元素在物理内存中也是相邻的,有利于提高缓存命中率。
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
#承诺提供免费技术答疑# 本专栏主要是介绍嵌入式开发岗位相关知识和学习攻略。“C/C++软件开发岗位”也可以参考。 该专栏覆盖了嵌入式求职过程中99%常见面试题,详细讲解了嵌入式软件开发岗位、学习攻略、项目经验分享、面试心得,从技术面,HR面,主管面,谈薪一站式服务。订阅即赠送简历模板、内推机会,需要的同学点击我头像私信即可!