[八股] C/C++基础八股

点个小赞关注一波,持续更新……

[专栏]嵌入式软件校招笔记(点击跳转)

[知识点] 嵌入式软件开发知识点学习

[知识点] ARM指令集详解

[知识点] 通讯协议(very重要)

[项目] C++高并发Web服务器+个人改进项目详解

[八股] C/C++基础八股

[八股] C/C++进阶八股

[八股] 计算机网络八股

[八股] 操作系统八股

[八股] 嵌入式系统八股

[八股] Linux系统编程八股

[八股] Linux网络编程八股

秋招嵌入式企业面经

1 C/C++概念

1.1 C++和C语言的区别

  1. 编程范式:C语言是一种过程化的编程语言,而C++是一种面向对象的编程语言14。这意味着在C++中,你可以创建对象并利用它们的方法和属性来编写代码。而在C语言中,你需要依赖函数和数据结构来实现相同的功能。
  2. 函数重载:C++支持函数重载,这意味着你可以创建多个同名函数,只要它们的参数列表不同即可。然而,C语言不支持这个功能。
  3. 异常处理:C++提供了异常处理机制,允许你捕获和处理运行时错误3。相比之下,C语言没有这个功能。
  4. 标准库:C++有更丰富的标准库,包括容器、迭代器等功能3。而在C语言中,你需要自己实现这些功能。
  5. 编译速度和学习难度:与C++相比,C具备编译速度快、容易学习、显式描述程序细节、较少更新标准等优点。

1.2 C++和Java的区别

  1. 运行过程:Java源程序经过编译器编译成字节码文件,然后由JVM解释执行。而C++源程序经过编译、链接后生成可执行的二进制代码。因此,C++的执行速度比Java快。
  2. 跨平台性:Java可以跨平台,而C++不行。
  3. 指针:Java没有指针的概念,而C++有指针。
  4. 多重继承:Java不支持多重继承,但是可以实现多个接口来达到类似的目的。而C++支持多重继承。
  5. 操作符重载:Java不支持操作符重载,而C++支持操作符重载。
  6. 预处理功能:C++有预处理器,而Java没有预处理器。
  7. 缺省参数函数:C++支持缺省参数函数,而Java不支持。
  8. 字符串:在Java中字符串是用类对象(String和StringBuffer)来实现的,在整个系统中建立字符串和访问字符串元素的方法是一致的。而在C++中不支持字符串变量,在C++程序中使用“Null”终止符代表字符串的结束。
  9. goto语句:“可怕”的goto语句是C++的“遗物”,它是该语言技术上的合法部分。Java不提供goto语句。
  10. 类型转换:在C++中,有时会出现数据类型的隐含转换,这就涉及了自动强制类型转换问题。例如,在C++中可将一个浮点值赋予整型变量,并去掉其尾数。Java不支持C++中的自动强制类型转换,如果需要,必须由程序显式进行强制类型转换。

1.3 C++和Python的区别

  1. 运行过程:C++源程序经过编译、链接后生成可执行的二进制代码。而Python源程序可以直接通过解释器执行,不需要经过预处理和编译步骤。
  2. 类型检查:C++是静态类型检查的编程语言,而Python是动态类型检查的编程语言。
  3. 语法:C++具有更多的语法及其他编程规范,而Python更接近自然语言(常规英语)。
  4. 执行效率:C++代码的执行效率高,但编写和调试较为复杂。Python代码编写和调试更加方便快捷,但其执行效率较低。
  5. 应用领域:C++通常用于系统级开发、驱动编程、游戏开发等领域,可以实现低级别的内存管理、指针操作和底层交互。Python则更适用于脚本编程、网络爬虫、机器学习等领域,提供了丰富的第三方库和工具。

1.4 简述一下C++的特点

  1. 面向对象:C++完全支持面向对象的程序设计,包括封装、继承、多态等面向对象开发的三大特性。
  2. C++更加安全,增加了const常量引用、四类cast转换(static_cast、dynamic_cast、const_cast、reinterpret_cast)、智能指针、try_catch等等;
  3. C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL(Standard Template Library)。
  4. C++是不断在发展的语言。C++后续版本更是发展了不少新特性,如C++11中引入了nullptr、auto变量、Lambda匿名函数、右值引用、智能指针
  5. 跨平台:C++是一种跨平台语言,可以在多种操作系统上运行。

1.5 全局变量和局部变量的区别

全局变量和局部变量的主要区别在于它们的作用域和生命周期。

全局变量

  • 全局变量在程序的整个生命周期内都是有效的,可以在程序的任何地方被访问。
  • 全局变量在程序开始时被创建,在程序结束时被销毁。
  • 如果全局变量没有被初始化,它们会被自动初始化为零(对于数字类型)或者空(对于某些其他类型)。

局部变量

  • 局部变量只在定义它们的函数或代码块内部有效。
  • 局部变量在进入函数或代码块时被创建,在离开函数或代码块时被销毁。
  • 局部变量必须在使用前被明确地初始化。

注意,过度使用全局变量可能会导致代码难以理解和维护,因为全局变量可以在程序的任何地方被修改。相反,局部变量的使用范围受到限制,这有助于理解和跟踪它们的使用情况。

1.6 全局变量和static变量的区别

全局变量和static变量的主要区别在于它们的作用域和生命周期。

全局变量

  • 全局变量在整个程序中都是可见的,可以在程序的任何地方被访问。
  • 全局变量在程序开始时被创建,在程序结束时被销毁。
  • 如果全局变量没有被初始化,它们会被自动初始化为零(对于数字类型)或者空(对于某些其他类型)。

static变量

  • static变量的生命周期是整个程序执行期间,但其作用域仅限于定义它的函数或代码块。
  • static变量在程序开始时被创建,在程序结束时被销毁。
  • 如果static变量没有被初始化,它们会被自动初始化为零(对于数字类型)或者空(对于某些其他类型)。

总的来说,全局变量和static变量的主要区别在于它们的作用域。全局变量可以在整个程序中使用,而static变量只能在定义它的函数或代码块中使用。然而,两者都有相同的生命周期,即在程序执行期间一直存在。

1.7 define宏定义和const的区别

宏定义(#define) 和 const常量 在C++编程中都可以用来定义常量,但它们之间存在一些重要的区别:

宏定义(#define)

  • 宏定义是预处理器的指令,在编译前由预处理器进行文本替换。它不分配存储空间,因此不能对宏进行取地址操作。
  • 宏定义没有类型和作用域的概念,它只是简单的文本替换,容易产生错误,并且不易于调试。
  • 宏可以定义一些函数、表达式、甚至是代码片段,这是const常量做不到的。

const常量

  • const常量有明确的类型和作用域。在定义时必须初始化,一旦定义后就不能修改。
  • const常量在程序运行时占用存储空间,因此可以对const常量进行取地址操作。
  • const常量更安全,编译器会对其进行类型检查。使用不当时,编译器会立即报错。

总的来说,const常量比宏定义更具有类型安全性,更适合于面向对象的编程。而宏定义则更加灵活,但需要谨慎使用以避免错误。

1.7 new和malloc的区别

new和malloc都是C++中用于动态分配内存的方法,但它们之间存在一些重要的区别

  1. 函数与操作符:new是一个操作符,而malloc是一个函数
  2. 构造函数的调用:new会调用构造函数,而malloc不会。实际上,原始数据类型(如char、int、float等)也可以使用new进行初始化
  3. 返回类型安全性:new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * 需要通过强制类型转换将void*指针转换成我们需要的类型
  4. 内存分配失败时的返回值:new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL
  5. 内存分配位置:new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存

在C++的术语中,我们通常说new操作符在自由存储区(free store)上为对象动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。

自由存储区的位置取决于operator new的实现。自由存储区不仅可以为堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。

所以,当我们说"new在自由存储区上分配内存"时,我们实际上是在说"new在堆上分配内存",因为在大多数情况下,自由存储区就是堆。

  1. 是否需要指定内存大小:使用new操作符申请内存分配时无需指定内存块的大小,编译器会根据类型信息自行计算,而malloc则需要显式地指出所需内存的尺寸
  2. 对数组的处理:C++提供了new []与delete []来专门处理数组类型。使用new []分配的内存必须使用delete []进行释放。new对数组的支持体现在它会分别调用构造函数函数初始化每一个数组元素,释放对象时为每个对象调用析构函数。而malloc并不知道你在这块内存上要放的数组还是啥别的东西

总的来说,你应该根据你的需求来选择使用哪个函数。如果你需要分配一块可以容纳特定类型对象的内存,并希望自动调用构造函数和析构函数,那么应该使用new。如果你只是需要分配一定数量的字节,并且不需要自动调用构造函数和析构函数,那么可以使用malloc

1.7. new对数组处理用示例分析,malloc怎么进行数组处理

在C++中,new操作符可以用于动态分配数组。以下是一个示例

int* arr = new int[5]; // 分配一个包含5个整数的数组
for(int i = 0; i < 5; ++i) {
    arr[i] = i; // 初始化数组
}
delete[] arr; // 释放数组

在这个例子中,我们首先使用new操作符分配了一个包含5个整数的数组。然后,我们使用循环来初始化数组。最后,我们使用delete[]操作符来释放数组。

请注意,当你使用new[]操作符来分配数组时,你必须使用delete[]操作符来释放它。如果你只使用delete(没有方括号),那么只有第一个元素的内存会被释放,其它元素的内存则会发生泄漏。

在C语言中,你可以使用malloc函数来动态分配数组。以下是一个示例

int* arr = (int*)malloc(5 * sizeof(int)); // 分配一个包含5个整数的数组
for(int i = 0; i < 5; ++i) {
    arr[i] = i; // 初始化数组
}
free(arr); // 释放数组

在这个例子中,我们首先使用malloc函数分配了一个包含5个整数的数组。然后,我们使用循环来初始化数组。最后,我们使用free函数来释放数组。

请注意,当你使用malloc函数分配内存时,你必须使用free函数来释放它。如果你忘记了调用free函数,那么分配的内存将不会被回收,从而导致内存泄漏。

总的来说,无论是使用C++的new[] / delete[]还是C语言的malloc / free,都需要确保正确地释放已分配的内存以防止内存泄漏。

1.7 既然有了malloc/free,C++中为什么还需要new/delete呢?

malloc/free和new/delete都是用来申请内存和回收内存的。

对于非内部数据对象(eg:类对象),只用malloc/free无法满足动态对象的要求。这是因为对象在创建的同时需要自动执行构造函数,对象在消亡之前要自动执行析构函数,而由于malloc/free是库函数而不是运算符,不在编译器的控制权限内,也就不能自动执行构造函数和析构函数。因此,不能将执行构造函数和析构函数的任务强加给malloc/free。所以,在c++中需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理和释放内存工作的运算符delete。

new建立的是一个对象,malloc分配的是一块内存区域,用指针来访问,并且可以在区域里面移动指针;

1.8 常量指针和指针常量区别?指针函数和函数指针区别?(非常重要!!)

常量指针指针常量的区别主要在于常量性应用的位置:

  • 常量指针(Pointer to Constant):常量指针是指向常量的指针。这意味着,你不能通过这个指针来改变它所指向的值,但你可以改变指针本身。例如,const int *ptr;。
  • 指针常量(Constant Pointer):指针常量是一个常量指针。这意味着你不能改变这个指针的值,也就是说,一旦你把地址赋给了这个指针,你就不能再改变它了。但是,你可以改变这个指针所指向的值。例如,int *const ptr;。

函数指针指针函数的区别在于它们所表示的含义:

  • 函数指针(Pointer to Function):函数指针是一个指向函数的指针变量。函数指针可以用来调用函数和传递函数作为参数。例如,void (*ptr)(int); 是一个函数指针,它指向一个接受一个整数参数并且没有返回值的函数。
  • 指针函数(Function returning a pointer):指针函数是一个返回值为指针的函数。例如,int* func(); 是一个返回整数类型的指针的函数。

总结一下,常量指针和指针常量的区别在于是否可以改变所指向的值或者改变所存储的地址。而函数指针和指针函数的区别在于是否返回一个地址或者存储一个函数地址。

1.9 cout和printf有什么区别?

coutprintf都是在C++中用于输出的函数,但它们之间存在一些重要的区别:

cout

  • cout是C++中的一个输出流对象,它是使用流操作符<<来发送数据到标准输出(通常是屏幕)。
  • cout是类型安全的,这意味着你不需要指定要打印的数据的类型。例如,你可以直接写cout << variable;,而不需要知道variable的数据类型。
  • cout可以很容易地与其他C++对象一起使用,如字符串和类。

printf

  • printf是C语言中的一个函数,也可以在C++中使用。它使用格式化字符串和一系列参数来生成输出。
  • printf不是类型安全的,这意味着你必须为每个参数提供正确的格式说明符。例如,你需要写printf("%d", variable);来打印一个整数。
  • printf可能在处理复杂的C++对象(如类)时会有些困难。

总的来说,coutprintf都可以用于在C++中进行输出,但它们在使用方式和功能上有所不同。一般来说,如果你正在编写C++代码,那么使用cout可能会更方便一些。然而,在某些情况下(如需要精确控制输出格式),使用printf可能会更有用。

1.10 你理解的指针是什么东西?

在C语言中,指针是一种特殊的变量,它存储的是内存地址,而不是具体的值。这个内存地址是另一个变量(通常是相同数据类型的变量)的地址。通过指针,我们可以访问和操作存储在特定内存位置的数据

例如,假设我们有一个整数变量var,我们可以创建一个指针ptr来存储var的地址

int var = 10;
int* ptr = &var;

在这个例子中,ptr是一个指向var的指针。我们可以使用*ptr来访问或修改var的值

指针在C语言中是一个核心组件,它允许进行低级别的内存访问、动态内存分配以及许多其他功能

1.11 说说什么是野指针,怎么产生的,如何避免?

野指针是指向"垃圾"内存的指针,也就是说,它的值是不确定的。野指针通常由以下几种情况产生:

  1. 未初始化的指针:如果你声明了一个指针变量但没有给它赋值,那么它就是一个野指针。例如:int *ptr;。
  2. 已删除的指针:如果你使用delete或free删除了一个指针,但没有将它设置为NULL,那么它就成了一个野指针。例如:
  3. 超出作用域的指针:如果你返回了一个函数内部的局部变量的地址,那么这个地址在函数返回后就不再有效,因此返回的指针就是一个野指针。例如:

为了避免野指针,你可以遵循以下几个原则:

  • 初始化你的指针:当你声明一个新的指针时,总是给它一个初始值。这个值可以是一个已经分配的地址,或者是NULL。
  • 删除后置空:当你删除一个指针后,立即将它设置为NULL。这样,即使你再次使用这个指针,程序也不会崩溃。
  • 不要返回局部变量的地址:函数返回后,局部变量的内存会被回收,所以永远不要试图返回一个局部变量的地址。

1.12 说说使用指针需要注意什么?

使用指针时需要注意以下几点:

  1. 初始化指针:声明指针变量时,应该将其初始化为NULL或有效的内存地址。未初始化的指针可能会导致程序崩溃或者产生不可预知的行为。
  2. 避免野指针:野指针是指向未知内存区域的指针,可能会导致程序崩溃。当你释放了一个指针所指向的内存后,应该立即将该指针设置为NULL,以防止它成为野指针。
  3. 小心使用解引用操作符:在使用解引用操作符(*)访问指针所指向的值之前,一定要确保该指针已经被初始化并且指向了一个有效的内存区域。
  4. 避免内存泄漏:如果你使用new或malloc分配了内存,那么在不再需要这块内存时,一定要记得使用delete或free来释放它。
  5. 不要越界操作:当使用数组名作为指针时,一定要注意不要越过数组的边界,这也可能会导致程序崩溃。
  6. 慎重对待函数返回的指针:如果函数返回的是局部变量的地址,那么这个地址在函数返回后就不再有效,因此不能再使用这个地址。

1.13 栈溢出是什么?数组越界?野指针?

栈溢出:

栈区(stack)是后进先出的结构,向低地址进行扩展,是一块连续的内存区域,栈顶的地址和栈的最大容量是系统预先规定的,只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常来提示栈发生溢出。

导致栈溢出的原因有哪些:

  • 局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。
  • 递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。
  • 指针或数组越界。这种情况最常见,例如进行字符串拷贝,或处理用户输入等等。

解决这类问题的办法有两个

  • 增大栈空间
  • 改用动态分配,使用堆(heap)而不是栈(stack)
  • 直接查询生产环境服务器内存占用情况,通过命令定位到具体的那行代码

数组越界

访问数组时使用的索引超出了数组的大小。例如,如果一个数组有10个元素(位置0到9),你试图使用第11个位置,或者小于0的位置,你就越界了。这种情况下,程序的行为是未定义的,可能会导致程序崩溃或者其他未预期的行为。

野指针

野指针是指未被初始化的指针。因为它们指向一些任意的内存位置,可能会导致程序崩溃或者表现出未预期的行为。例如,如果你声明了一个指针但没有给它赋值,那么这个指针就是一个野指针。

野指针

  • 野指针是尚未初始化的指针,既不指向合法的内存空间,也没有使用NULL或nullptr初始化指针
  • 野指针可能会导致程序崩溃,因为它可能会访问到非法的内存地址

悬空指针

  • 悬空指针是指向被释放或不再有效的内存区域的指针
#include <iostream>
using namespace std;
 
int main()
{
    int *p = new int(5);
    cout<<"*p = "<<*p<<endl;
    free(p);  // p 在释放后成为悬空指针
    p = NULL; // 非悬空指针
    return 0;
}
  • 如果在释放内存后继续使用这个指针,就可能会导致未定义的行为,包括程序崩溃、数据损坏等问题

为了避免这些问题,我们应该遵循以下几个原则:

  • 在定义指针时,应该立即初始化它。
  • 在释放内存后,应该立即将对应的指针设置为NULL或nullptr。
  • 不要使用已经释放的内存。

1.14 如何解决内存碎片和内存泄漏

内存碎片和内存泄漏是两种常见的内存管理问题,解决这两种问题的方法如下:

内存碎片

  • 使用内存池:内存池是预先分配一大块连续的内存,然后按需将其分割成小块。这可以减少因频繁申请和释放小块内存而产生的内存碎片。
  • 使用垃圾收集器:一些语言(如Java和Python)提供了垃圾收集器,可以自动回收不再使用的内存,并进行内存整理,以减少内存碎片。
  • 定期重启应用程序:虽然这不是一个理想的解决方案,但对于一些难以避免产生内存碎片的长期运行的程序,定期重启可以释放所有内存,从而消除内存碎片。

内存泄漏

  • 及时释放不再使用的内存:在C++中,如果你使用new分配了内存,那么在不再需要这块内存时,一定要记得使用delete来释放它。
  • 使用智能指针:C++提供了智能指针(如unique_ptrshared_ptr),它们可以在不再需要时自动释放所指向的内存。
  • 使用内存分析工具:有许多工具(如Valgrind)可以帮助你检测程序中的内存泄漏。

1.15 指针和引用的区别

在C++中,引用和指针都可以用来间接访问变量,但它们之间存在一些重要的区别

  1. 定义和性质:指针是一个实体,存储的是一个地址,指向内存的一个存储单元;引用是原变量的一个别名,跟原来的变量实质上是同一个东西
int m;
int *p = &m; // p是指针
int &n = m; // n是引用
  1. 初始化:引用在定义时就必须被初始化,之后不能改变;指针可以在任何时候被初始化
int *p; // 合法
int &r; // 不合法
  1. NULL值:指针可以指向NULL,引用不可以为NULL
int *p = NULL; // 合法
int &r = NULL; // 不合法
  1. 多级:指针可以有多级,引用只能是一级
int **p; // 合法
int &&a; // 不合法
  1. sizeof运算结果sizeof对指针操作得到的是指针本身的大小;对引用操作得到的是所引用对象的大小
  2. 自增运算意义不同:对于指针,自增操作会使其指向下一个内存单元;对于引用,自增操作会改变其所引用对象的值

1.15 C++中新增了string,与C语言中的 char* 有什么区别吗?它是如何实现的?

C++中的string类和C语言中的char*在使用上有很大的区别。以下是一些主要的区别:

类型安全string是一个类,提供了许多方法(如appendreplacesubstr等)来操作字符串。这使得字符串操作更加安全和方便。而char*则需要使用字符串函数(如strcpystrcatstrlen等),这些函数在使用不当时可能会导致错误,如缓冲区溢出。

动态大小string对象可以动态地改变大小,你可以在运行时添加或删除字符,而不需要担心内存分配。而对于char*,你需要手动管理内存,并确保有足够的空间来存储所有的字符。

易用性:使用string可以更容易地进行一些操作,如字符串连接和比较。例如,你可以使用+运算符来连接两个字符串,或者使用==运算符来比较两个字符串是否相等。而对于char*,你需要使用特定的函数(如strcatstrcmp)来进行这些操作。

至于C++中的 string 类是如何实现的,它通常是作为一个动态数组实现的,其中包含一个指向字符数组的指针、一个表示字符串长度的整数以及一个表示分配的内存大小的整数。当字符串增长并超出当前分配的内存大小时,会分配一块更大的内存区域,并将现有字符串复制到新内存中,然后释放旧内存。这种实现方式使得 string 类能够有效地处理动态大小变化的字符串。

// 使用 char*
char str1[10] = "Hello";
char* str2 = "World";
char str3[10];
strcpy(str3, str1);  // 将 str1 复制到 str3
strcat(str3, str2);  // 将 str2 连接到 str3
printf("%s\n", str3);  // 输出 "HelloWorld"

// 使用 string
std::string s1 = "Hello";
std::string s2 = "World";
std::string s3 = s1 + s2;  // 将 s1 和 s2 连接起来
std::cout << s3 << std::endl;  // 输出 "HelloWorld"

2 C++面向对象

2.1 什么是面向对象

面向对象编程(Object Oriented Programming,OOP)是一种计算机编程架构。OOP的一条基本原则是计算机程序由单个能够起到子程序作用的单元或对象组合而成。OOP达到了软件工程的三个主要目标:重用性、灵活性和扩展性。

在面向对象编程中,我们会把要解决的问题分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个对象在整个解决问题的步骤中的属性和行为。

面向对象编程包括三个主要概念:类与实例、继承、封装

  • 类与实例:类是对一类事物的抽象,实例则是类的具体表现。例如,我们可以将“人”定义为一个类,而具体的“张三”或“李四”则是“人”这个类的实例。
  • 继承:继承是一种能够让某个类型的对象获得另一个类型的对象的属性和方法的机制。
  • 封装:封装是指将数据(属性)和操作数据的函数(方法)打包在一起,形成一个“类”。这样可以隐藏内部实现细节,只暴露必要的接口。

2.2 介绍面向对象的三大特性

面向对象编程的三大特性是:封装、继承和多态。

  1. 封装:封装是指将数据(属性)和操作数据的函数(方法)打包在一起,形成一个“类”。这样可以隐藏内部实现细节,只暴露必要的接口。封装可以增强安全性和简化编程,使用者只需要知道对象提供了哪些服务,而不需要知道这些服务是如何实现的。
  2. 继承:继承是一种能够让某个类型的对象获得另一个类型的对象的属性和方法的机制。通过继承,我们可以创建一个通用类(父类),然后定义更具体的类(子类)来继承父类的属性和方法。子类除了继承父类的特性外,还可以定义自己特有的特性。
  3. 多态:多态意味着可以通过基类指针调用任何派生类的函数,也就是说,指向不同对象的指针可能会在运行时调用不同的函数。多态分为编译时多态(重载)和运行时多态(虚函数)。多态允许我们使用一个接口表示不同的实现,从而使得程序具有更好的可扩展性。

这三大特性使得面向对象编程更加灵活和强大,有助于提高代码的可读性、可维护性和可复用性。

2.3 简述一下 C++ 中的多态

在C++中,多态是面向对象编程的一个重要特性,它允许我们使用一个接口表示不同的实现。多态分为两种形式:编译时多态和运行时多态。

编译时多态:也被称为静态多态或早绑定。它主要通过函数重载和运算符重载实现。编译器在编译阶段就能确定调用哪个函数。

运行时多态:也被称为动态多态或晚绑定。它主要通过虚函数和抽象类实现。具体调用哪个函数是在程序运行时才能确定。

以下是一个简单的运行时多态的例子:

在这个例子中,Base类有一个虚函数printDerived类重写了这个函数。我们创建了一个指向Derived对象的Base指针,并通过这个指针调用print函数。虽然这个指针的类型是Base*,但是调用的却是Derived类的print函数。这就是运行时多态的体现。

class Base {
public:
    virtual void print() {
        std::cout << "Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived();
    basePtr->print();  // 输出 "Derived"
    delete basePtr;
    return 0;
}

2.4 虚函数是什么,和纯虚函数的区别

在C++中,虚函数纯虚函数都是实现多态性的关键机制,但它们之间存在一些重要的区别

虚函数是一种特殊类型的函数,它在基类中被声明,并可以在任何派生类中被重写。虚函数允许我们通过基类指针来调用派生类的这个函数。这种机制被称为动态绑定或运行时多态。虚函数在基类中通常有一个默认的实现,但也可以没有。

例如:

class Base {
public:
    virtual void foo() {
        std::cout << "Base::foo() is called" << std::endl;
    }
};

class Derived : public Base {
public:
    void foo() override {
        std::cout << "Derived::foo() is called" << std::endl;
    }
};

在这个例子中,Base类有一个虚函数fooDerived类重写了这个函数。我们可以创建一个指向Derived对象的Base指针,并通过这个指针调用foo函数。虽然这个指针的类型是Base*,但是调用的却是Derived类的foo函数。

纯虚函数是在基类中声明的虚函数,它在基类中没有定义(即没有实现),但要求任何派生类都要定义自己的实现方法。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的声明方式是在函数原型后加=0

例如:

class AbstractBase {
public:
    virtual void foo() = 0;  // 纯虚函数
};

class ConcreteClass : public AbstractBase {
public:
    void foo() override {
        std::cout << "ConcreteClass::foo() is called" << std::endl;
    }
};

在这个例子中,AbstractBase类有一个纯虚函数foo,而ConcreteClass类提供了这个纯虚函数的实现

总结一下,虚函数和纯虚函数都是为了实现多态性而存在的。不同之处在于,虚函数在基类中通常有一个默认实现,而纯虚函数则必须在派生类中提供实现。

2.5 C++中哪些函数不能声明为虚函数

在C++中,以下类型的函数不能被声明为虚函数1234

  1. 普通函数(非类成员函数):只有类的成员函数才有可能被声明为虚函数。因为普通函数(非成员函数)只能被重载,不能被重写。声明为虚函数也没有什么意义,因此编译器会在编译时绑定函数。
  2. 构造函数:构造函数是用来初始化对象的。虚函数的主要作用是实现多态,多态是依托于类的,多态的使用必须是在类创建以后,而构造函数是用来创建构造函数的,所以不行。
  3. 内联函数:内联函数必须有实体,是在编译时展开。内联函数就是为了在代码中直接展开,减少函数调用花费的代价。虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。
  4. 静态成员函数:静态成员函数理论是可继承的。但是静态成员函数是编译时确定的,无法动态绑定,不支持多态,因此不能被重写,也就不能被声明为虚函数。
  5. 友元函数:友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
  6. 不会被继承的基类的析构函数:析构函数可以是虚函数,而且通常声明为虚函数。但是对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。

2.6. 虚函数表是针对类还是针对对象的。

虚函数表是针对类的,而不是对象。每一个类都有一张虚函数表,存储中这个类所有虚函数的入口地址。同一个类的两个对象共享类的虚函数表。当一个对象被创建时,它会在内存中分配一块空间用于存储对象数据和指向虚函数表的指针。这个指针始终指向该类的虚函数表,不会因为对象的不同而改变

如果派生类重写了基类的虚函数,那么派生类会在其自己的虚函数表中存储重写后的函数地址。如果派生类没有重写基类的虚函数,那么它会继承基类的虚函数地址,并将其复制到自己的虚函数表中

如果存在多重继承的情况,则派生类会生成多个虚函数表的指针,分别维护来自不同基类的虚函数表,其规则和单继承相同

总之,虚函数是针对类的,同一个类的两个对象共享同一张虚函数表,虚函数表的内容由类的层次结构和重载情况决定

2.6 C++中struct和class的区别

在C++中,structclass都可以用来定义类,但它们之间存在一些关键的区别

  1. 默认访问权限:在struct中,成员的默认访问权限是public,这意味着它们可以在结构体外部直接访问。而在class中,成员的默认访问权限是private。
  2. 默认继承访问权:当使用struct进行继承时,默认的继承访问权限是public。而当使用class进行继承时,默认的继承访问权限是private。
  3. 模板参数定义:在定义模板参数时,可以使用class关键字,但不能使用struct关键字。

除了这些区别,structclass在C++中的功能几乎是相同的。它们都可以有构造函数、析构函数、成员函数;都可以进行继承;都可以有静态成员

总的来说,你可以根据需要选择使用struct或者class。如果你需要定义一个主要用于存储数据的简单结构,并且希望所有成员默认都是公有的,那么使用 struct 可能更合适。如果你需要定义一个包含了数据和操作数据的方法的复杂对象,并且希望大部分成员默认都是私有的,那么使用 class 可能更合适

2.7 C语言中的struct和C++中的struct区别?

在C语言和C++中,struct都可以用来定义复合类型,但它们之间存在一些关键的区别:

  1. 成员访问权限:在C++中,struct可以有public、protected和private三种访问权限,而在C语言中,struct没有访问权限的概念,所有成员默认都是公有的123。
  2. 成员函数:在C++中,struct可以包含成员函数,包括构造函数和析构函数。而在C语言中,struct只能包含数据成员,不能包含函数123。
  3. 继承和多态:在C++中,struct可以被继承,并且可以包含虚函数,从而支持多态。而在C语言中,struct不支持继承和多态123。
  4. 默认访问级别:在C++中,如果不明确指定访问级别,则struct的成员默认为公有(public),而class的成员默认为私有(private)123。

总的来说,C++中的 struct 更像一个类,具有类似于类的功能。而 C 语言中的 struct 更像一个数据结构,主要用于封装一组相关的数据。

2.8 C++11创造一个空类,会默认生成哪些函数

默认构造函数

② 默认的拷贝构造函数

③ 默认的析构函数

④ 默认的重载赋值运算符函数

⑤ 默认的重载取地址运算符函数

⑥ 默认的重载取地址运算符const函数

⑦ 默认移动构造函数(C++11)

⑧ 默认重载移动赋值操作符函数(C++11)

只声明一个空类,编译器会自动创建上述的函数,当然这些函数只有在第一次被调用的时候,才会被编译器创建.如果我们不希望对象被显示的构造或者赋值,可以将对应的函数声明为private,或者写一个基类,开放部分默认函数,子类去继承就可以了.

defalut: 被标识的默认函数将使用类的默认行为,如: A() = default;delete:被标识的默认函数将禁用,如: A() = delete;override:被标识的函数需要强制重写基类虚函数final:被标识的函数将禁止重写基类虚函数

C++经典问题_11 C++类默认创建的成员函数

需要注意的是,这些默认生成的成员函数只有在被需要的时候才会产生。例如,如果我们不创建类对象,则不会创建类的构造函数、析构函数等

拷贝构造函数和移动构造函数都是C++中的特殊成员函数,用于对象的构造。它们的作用分别是

  • 拷贝构造函数:当使用一个已有对象来初始化一个新对象时,会调用拷贝构造函数。拷贝构造函数的参数是一个常量引用,表示将要被拷贝的对象。拷贝构造函数的作用是创建一个新的对象,并将原对象的值复制到新对象中。通常情况下,拷贝构造函数执行的是深拷贝操作,以确保新对象和原对象不共享同一块内存
  • 移动构造函数:在C++11标准中引入了移动语义,移动构造函数用于支持移动语义。当一个对象需要被移动而不是被拷贝时,会调用移动构造函数。移动构造函数的参数是一个右值引用,表示将要被移动的对象。移动构造函数的作用是将原对象的内部资源直接转移到新对象中,而不是像拷贝构造函数一样复制一份。这样可以避免不必要的内存分配和数据复制,提高程序的效率

总的来说,拷贝构造函数和移动构造函数都是用来创建新对象的,它们的区别在于创建新对象的方式不同。拷贝构造函数是将一个已经存在的对象复制到一个新的对象中,而移动构造函数则是将一个对象的资源移动到一个新的对象中

移动赋值运算符是C++11引入的一个特殊成员函数,用于支持移动语义。当一个对象需要被移动而不是被拷贝时,会调用移动赋值运算符。移动赋值运算符的参数是一个右值引用,表示将要被移动的对象

移动赋值运算符:

移动赋值运算符的作用是将原对象的内部资源直接转移到新对象中,而不是像拷贝赋值运算符一样复制一份。这样可以避免不必要的内存分配和数据复制,提高程序的效率。

以下是一个移动赋值运算符的示例:

MemoryBlock& operator= (MemoryBlock&& other)
{
    if (this != &other)
    {
        // Free the existing resource.
        delete[] _data;

        // Copy the data pointer and its length from the
        // source object.
        _data = other._data;
        _length = other._length;

        // Release the data pointer from the source object so that
        // the destructor does not free the memory multiple times.
        other._data = nullptr;
        other._length = 0;
    }
    return *this;
}

在这个例子中,MemoryBlock类的移动赋值运算符首先检查自身是否就是源对象(即检查自我赋值)。如果不是,它就释放当前对象的资源,然后从源对象那里获取资源,并最后将源对象置为空。这样,源对象就不再拥有任何资源,当它被销毁时,就不会影响到新对象。

浅拷贝和深拷贝的区别

浅拷贝和深拷贝是面向对象编程中常用的两个概念,它们主要的区别在于复制对象时是否复制对象所引用的其他对象。

浅拷贝只复制对象本身,不复制对象所引用的其他对象。如果被复制对象中包含了引用类型的成员变量,那么复制出来的新对象和原对象将会共享这些成员变量,也就是说,这些成员变量在新对象和原对象中都指向同一个内存地址。简单来说,浅拷贝只是单纯地将原对象的指针指向新对象,而不复制它所指向的实体。

深拷贝则会将复制对象所引用的其他对象也一并复制。深拷贝是一个整个独立的对象拷贝,深拷贝会拷贝所有的属性,并拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝速度较慢并且花销较大。简而言之,深拷贝把要复制的对象所引用的对象都复制了一遍。

总结一下,浅拷贝和深拷贝主要区别在于是否复制了原始数据类型以外的成员变量。

2.34. 移动拷贝构造原理,解决什么问题。

移动拷贝构造函数是C++11标准引入的一个新特性,它通过将资源“移动”而不是复制,可以避免浅拷贝和深拷贝可能出现的问题,并提高程序的性能。移动构造函数的主要目的是提高程序运行的效率,当类持有其它资源时,例如动态分配的内存、指向其他数据的指针等,拷贝构造函数中需要以深拷贝(而非浅拷贝)的方式复制对象的所有数据。深拷贝往往非常耗时,合理使用右值引用可以避免没有必要的深拷贝操作。

C++的移动构造函数是一种特殊的构造函数,它可以将一个对象的资源(如堆内存)“移动”到另一个对象,而不是创建一个完全新的副本。这可以提高效率,特别是在处理大型对象时

移动构造函数的工作原理是将声明的对象的指针指向临时对象的数据,并将临时对象的指针置空。这样,就避免了在内存中不必要地复制数据。与复制构造函数(它会复制现有对象的数据并将其分配给新对象)不同,移动构造函数只是使声明的对象的指针指向临时对象的数据,并将临时对象的指针置空

#include <iostream>
using namespace std;

class A {
public:
    int* x;
    // 构造函数
    A(int val) : x(new int(val)) { 
        cout << "Constructor" << endl; 
    }
    // 拷贝构造函数
    A(const A& a) : x(new int(*(a.x))) { 
        cout << "Copy Constructor" << endl; 
    }
    // 移动构造函数
    A(A&& a) : x(a.x) { 
        a.x = nullptr; 
        cout << "Move Constructor" << endl; 
    }
    ~A() { 
        delete x; 
        cout << "Destructor" << endl; 
    }
};

int main() {
    A a(1);  // 调用构造函数
    A b = move(a);  // 调用移动构造函数
    return 0;
}

在这个例子中,类A有一个指针成员x。当我们创建一个新对象b并使用move(a)初始化它时,移动构造函数会被调用。在移动构造函数中,我们直接将a.x赋值给b.x,然后将a.x设置为nullptr。这样,我们就避免了深拷贝操作。

例如,当一个对象通过值从函数返回时,通常会调用移动构造函数。然而,在许多情况下,复制和移动都可能被完全跳过

总的来说,移动构造函数可以提高代码效率,减少内存使用,并避免不必要的复制操作。这对于处理大型数据结构或需要高效率操作的程序来说非常有用。

3 C++11

3.1 你了解哪些C++11新特性

C++11引入了许多新的语言特性,以下是一些主要的新特性:

  1. auto:C++ 11引入了类型推断能力,使用auto关键字,编译器可以自行推断变量的类型
  2. noexcept:如果一个函数无法抛出异常,那么可以在C++ 11中将该函数声明为noexcept,这有助于解决未知类型的错误
  3. lambda:C++ 11可以创建匿名函数,也称为Lambda函数。Lambda表达式允许我们在本地定义函数。此外,它还允许在调用处定义函数,从而消除了许多复杂性和安全风险
  4. nullptr:C++ 11的新特性之一是允许程序员使用nullptr代替NULL或0来指定一个指向无值的指针。这与不定义任何值是不同的
  5. override标识符:随着项目变得越来越大,需要更多的文件来完成一个任务。为了避免混淆,C++ 11引入了override标识符,用于明确表示子类中的函数覆盖了基类中的同名函数
  6. 无序容器:C++ 11引入了无序容器,如unordered_map和unordered_set
  7. 移动语义和右值引用:通过引入移动构造函数和移动赋值操作符,C++ 11支持将对象的资源“移动”到另一个对象,而不是创建一个完全新的副本
  8. 变长模板:C++ 11支持变长模板,这使得模板可以接受可变数量的参数

3.2 auto跟decltype有什么区别?

autodecltype都是C++11中引入的类型推导关键字,它们的主要作用是让编译器自动推导变量的类型,从而减少代码的冗余,提高代码的可读性和可维护性。

auto的作用

  • auto可以根据初始化表达式自动推导出变量的类型,使得代码更加简洁,提高编程效率。
  • auto可以用于迭代器和模板编程,使得代码更加通用。

decltype的作用

  • decltype可以根据表达式推导出类型,而不仅仅是根据初始化表达式。
  • decltype可以保留表达式的所有类型信息(包括const、引用等),使得类型推导更加精确。

两者的区别

  • autodecltype在语法格式、初始化要求、对cv限定符(const和volatile)的处理、对引用类型的处理等方面都有所不同。
  • auto在书写格式上比decltype简单,但是它的推导规则复杂,有时候会改变表达式的原始类型;而decltype比较纯粹,它一般会坚持保留原始表达式的任何类型,让推导的结果更加原汁原味。

为什么有了auto还需要decltype

  • auto虽然使用方便,但是它不能完全替代decltype。因为在某些情况下,我们需要精确地保留表达式的所有类型信息(包括const、引用等),这时候就需要使用decltype
  • 另外,当我们需要定义一个变量,但是又不想立即初始化它,或者想让它的类型与某个表达式完全一致时,也需要使用decltype
#include <iostream>
using namespace std;

int main() {
    int n = 10;
    int & r1 = n;

    // auto推导
    auto r2 = r1;
    r2 = 20;
    cout << n << ", " << r1 << ", " << r2 << endl;

    // decltype推导
    decltype(r1) r3 = n;
    r3 = 99;
    cout << n << ", " << r1 << ", " << r3 << endl;

    return 0;
}

3.3 NULL和nullptr的区别

在C++11之前,我们通常使用NULL来表示空指针。然而,NULL在C++中实际上就是整数0。这是因为在C++中,void*类型是不允许隐式转换成其他类型的。这种设计可能会在函数重载的情况下引发歧义

为了解决这个问题,C++11引入了nullptr关键字,它是一个特殊类型的常量——std::nullptr_t。nullptr可以被转换为任意指针类型,用以表示空指针。但是,它不能被转换为整数类型,因此避免了与整数0的混淆。

下面是一个示例代码来说明这个问题:

void func(int i) {
    cout << "func(int) is called" << endl;
}

void func(char* ptr) {
    cout << "func(char*) is called" << endl;
}

int main() {
    func(0);      // 输出:func(int) is called
    func(NULL);   // 输出:func(int) is called
    func(nullptr); // 输出:func(char*) is called
    return 0;
}

在这个例子中,当我们使用0或者NULL作为参数调用函数func时,会调用func(int)。但是当我们使用nullptr作为参数时,会调用func(char*)

因此,在C++11及其后续版本中,建议使用nullptr来表示空指针

3.4 智能指针说一下

C++中有三种类型的智能指针

  1. std::auto_ptr是C++标准库中的一种智能指针,它可以管理通过new表达式获取的对象,并在auto_ptr本身被销毁时删除该对象。这种自动删除可以防止内存泄漏,使得内存管理更加容易然而,std::auto_ptr有一些已知的问题,例如它不支持数组,且在复制或赋值时会改变所有权。因此,它已经在C++11中被废弃,并在C++17中被移除。现在,我们通常推荐使用std::unique_ptr来替代std::auto_ptr。std::unique_ptr提供了类似的功能,但有更好的所有权语义,并且支持数组
  2. unique_ptr:这是一种独占所有权的智能指针,也就是说,同一时间只能有一个unique_ptr指向给定的对象。当unique_ptr离开作用域或被删除时,它所指向的对象也会被删除
  3. shared_ptr:这是一种共享所有权的智能指针,可以有多个shared_ptr指向同一个对象。shared_ptr使用引用计数来跟踪有多少个智能指针共享同一个对象。当最后一个shared_ptr不再需要其共享的对象时,该对象就会被删除
  4. weak_ptr:这是一种弱引用的智能指针,它可以观察另一个智能指针(如shared_ptr)所拥有的对象,但不会增加该对象的引用计数。因此,weak_ptr不会阻止其观察的对象被删除

这些智能指针都是C++标准库提供的,可以帮助程序员更容易地管理内存,防止内存泄漏。

4.5. shared_ptr底层实现,什么情况下计数器会加,什么时候会减。

智能指针是C++中的一种对象,它像常规指针一样,可以指向另一个对象,但当智能指针不再需要其指向的对象时,它会自动删除该对象。这种自动删除可以防止内存泄漏,使得内存管理更加容易

std::shared_ptr是一种智能指针,它允许多个智能指针指向同一个对象。std::shared_ptr使用引用计数来跟踪有多少个智能指针共享同一个对象

在典型的实现中,std::shared_ptr包含两个指针:一个原始指针,指向被管理的对象;另一个指针,指向控制块。控制块至少包含一个指向被管理对象的指针或对象本身、一个引用计数器和一个弱计数器

以下是std::shared_ptr引用计数器增加和减少的情况:

  • 增加:每次调用std::shared_ptr的复制构造函数或赋值操作符时,引用计数器都会增加。也就是说,每当有新的std::shared_ptr开始共享同一个对象时,引用计数器就会增加
  • 减少:每次调用std::shared_ptr的析构函数时,引用计数器都会减少。当引用计数器减少到0时(即没有任何std::shared_ptr再共享该对象),该对象就会被自动删除

这种引用计数机制确保了只有在最后一个std::shared_ptr不再需要其共享的对象时,该对象才会被删除。这可以防止提前删除仍然需要的对象,也可以防止内存泄漏

计数器是放在堆上的还是栈上的

std::shared_ptr的引用计数器是存储在堆上的。这是因为std::shared_ptr需要动态分配一个控制块来存储引用计数器和其他信息。这个控制块在堆上分配,因为它的生命周期不受任何特定std::shared_ptr实例的影响。换句话说,即使最后一个指向对象的std::shared_ptr已经被销毁,控制块也可能仍然存在(例如,如果还有std::weak_ptr指向该对象)。只有当最后一个指向对象的std::shared_ptr或std::weak_ptr被销毁时,控制块才会被删除

引用计数为0发生什么

当引用计数器为0时,std::shared_ptr会自动删除它所指向的对象,并释放该对象占用的内存。这是因为std::shared_ptr使用引用计数来跟踪有多少个智能指针共享同一个对象。只有当最后一个std::shared_ptr不再需要其共享的对象时(即引用计数器减到0),该对象才会被删除。这种自动删除可以防止内存泄漏,使得内存管理更加容易。此外,如果std::shared_ptr管理的对象是通过new[]分配的数组,那么在引用计数器为0时,会调用数组中每个元素的析构函数。这就是C++中引用计数为0时发生的情况。

引用计数是线程安全的吗?

std::shared_ptr的引用计数是线程安全的。这是因为std::shared_ptr使用原子操作来增加和减少引用计数。这意味着,即使在多线程环境中,也可以安全地对同一个对象使用多个std::shared_ptr。然而,虽然std::shared_ptr本身是线程安全的,但并不意味着通过多个线程访问同一个对象总是安全的。如果多个线程需要修改同一个对象,那么仍然需要使用互斥锁或其他同步机制来防止数据竞争。

shared_ptr循环引用问题

std::shared_ptr在C++中是一种智能指针,它允许多个智能指针共享同一个对象。然而,当两个或更多的std::shared_ptr相互引用时,就会产生循环引用问题

考虑以下情况,两个std::shared_ptr在两个不同的对象中互相指向对方

class Person {
    std::string m_name;
    std::shared_ptr<Person> m_partner; // 初始为空

public:
    Person(const std::string &name) : m_name(name) {
        std::cout << m_name << " created\n";
    }
    ~Person() {
        std::cout << m_name << " destroyed\n";
    }
};

int main() {
    auto lucy = std::make_shared<Person>("Lucy"); // 创建一个名为"Lucy"的Person
    auto ricky = std::make_shared<Person>("Ricky"); // 创建一个名为"Ricky"的Person

    lucy->m_partner = ricky; // 让"Lucy"指向"Ricky"
    ricky->m_partner = lucy; // 让"Ricky"指向"Lucy"

    return 0;
}

在这个例子中,我们动态分配了两个Person,“Lucy”和“Ricky”。然后我们让他们成为伙伴。这使得“Lucy”内部的std::shared_ptr指向“Ricky”,“Ricky”内部的std::shared_ptr指向“Lucy”。然而,这个程序并没有按照预期执行:没有进行任何内存释放

这是因为,在main()函数结束时,lucy和ricky这两个std::shared_ptr都会出作用域并被销毁。但由于它们互相引用,所以引用计数器永远不会减到0,因此它们所指向的对象永远不会被删除

解决这种循环引用问题的一种方法是使用std::weak_ptr。std::weak_ptr是一种智能指针,它可以观察另一个智能指针(如std::shared_ptr)所拥有的对象,但不会增加该对象的引用计数。因此,如果我们将上述例子中的某一个std::shared_ptr替换为std::weak_ptr,那么当另一个std::shared_ptr被销毁时,由于引用计数器减到0,所以它所指向的对象就会被正确地删除

如何解决循环引用问题

解决循环引用问题的一种方法是使用std::weak_ptrstd::weak_ptr是一种智能指针,它可以观察另一个智能指针(如std::shared_ptr)所拥有的对象,但不会增加该对象的引用计数。因此,如果我们将上述例子中的某一个std::shared_ptr替换为std::weak_ptr,那么当另一个std::shared_ptr被销毁时,由于引用计数器减到0,所以它所指向的对象就会被正确地删除。

以下是一个使用std::weak_ptr来解决循环引用问题的代码示例:

#include <memory>
#include <iostream>

class B; // 前向声明

class A {
public:
    std::shared_ptr<B> bPtr;
    ~A() { std::cout << "A deleted\n"; }
};

class B {
public:
    std::weak_ptr<A> aPtr; // 使用weak_ptr
    ~B() { std::cout << "B deleted\n"; }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        a->bPtr = b;
        b->aPtr = a;
    } // a和b都将在这里被正确地删除

    return 0;
}

在这个例子中,我们创建了两个类A和B,并让它们互相引用。我们使用std::shared_ptr来管理A的实例,并使用std::weak_ptr来管理B的实例。这样,当A的实例不再需要时(即离开作用域时),它就会被正确地删除,而不会因为B的实例仍然引用它而导致内存泄漏。

4.6 移动构造,右值引用

在C++11标准中,引入了右值引用和移动构造函数的概念,这两者都是为了提高代码的效率

右值引用:右值引用是一种新的C++语法,主要用于实现移动语义和完美转发。右值引用只能绑定到即将销毁的对象(通常是临时对象),而不能绑定到持久的对象。右值引用使用&&表示。例如:

int&& r = 10; // 右值引用

移动构造函数:移动构造函数是一种特殊的构造函数,它把对象的资源(如动态分配的内存)从一个对象移动到另一个对象,而不是创建资源的副本。这可以大大提高效率,因为在许多情况下,创建资源的副本是不必要的,并且可能非常耗时。例如:

class MyString {
public:
    // 移动构造函数
    MyString(MyString&& other) noexcept : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }

private:
    char* data_;
    size_t size_;
};

在这个例子中,移动构造函数接收一个右值引用参数other,并将other的资源移动到新创建的对象中。然后,它将other置于有效但未指定的状态,即data_指针被设置为nullptr,并且size_被设置为0

总的来说,右值引用和移动构造函数都是C++11为了提高代码效率而引入的重要特性

4.7 lamda表达式捕获列表捕获的方式有哪些?

在C++中,lambda表达式的捕获列表(capture list)可以通过以下方式捕获它们所在函数中的变量

  1. 按值捕获:这种方式会在lambda表达式创建时将指定的变量复制一份,并在函数体中使用这份副本。例如:
int a = 3;
auto func1 = [a] { std::cout << a << std::endl; };
func1();

在上述代码中,a被按值捕获,因此即使在lambda表达式创建后a的值发生改变,lambda表达式也只会使用创建时a的值

  1. 按引用捕获:这种方式允许lambda表达式直接访问引用所指向的变量。例如:
int a = 3;
auto func3 = [&a] { std::cout << a << std::endl; };
func3();

在上述代码中,a被按引用捕获,因此即使在lambda表达式创建后a的值发生改变,lambda表达式也会直接访问这个引用变量

  1. 隐式捕获:除了直接指定捕获方式之外,还可以使用以下几种方式来告诉lambda它可以捕获的变量
  2. this指针:在成员函数中,也可以直接捕获this指针。实际上,在成员函数中,[=]和[&]也会捕获this指针

4.8 move的作用

std::move是C++11引入的一个非常有用的函数,它的主要作用是将一个左值强制转化为右值引用。这个函数本身并不能移动任何数据,它的功能很简单,就是进行类型转换

在C++中,当我们谈论"移动"时,我们通常是指移动语义(Move Semantics),这是一种优化技术,可以减少不必要的拷贝操作,从而提高代码的效率移动语义允许我们直接转移对象的资源(如动态分配的内存),而不是复制它们

std::move函数常用于实现移动语义。当你有一个临时对象(右值),并且你想将其资源移动到另一个对象时,你可以使用std::move函数。例如:

std::string str1 = "Hello, world!";
std::string str2 = std::move(str1); // 将str1的资源移动到str2

在这个例子中,std::move(str1)str1转换为右值引用,然后str2的构造函数接受这个右值引用,并将str1的资源移动到str2。这样,我们就避免了在构造str2时复制str1的内容。

然而,请注意,在调用std::move(str1)后,str1就进入了一个"已移动状态"。在这个状态下,str1仍然是一个有效的对象,你仍然可以对它进行操作(如赋值新的内容),但是你不能假设它包含原来的值

总的来说,std::move函数是一种类型转换工具,它将左值转换为右值引用,从而使得我们可以在合适的情况下使用移动语义来提高代码效率

在C++中,几乎所有的数据结构都可以使用std::move。然而,对于一些简单的数据类型(如int、char等),使用std::move可能没有任何效果。这是因为这些类型没有动态分配的资源可以移动4。对于更复杂的数据类型(如用户定义的类或结构),使用std::move可以提高效率,因为它允许资源从一个对象移动到另一个对象,而不是创建资源的副本

总的来说,你应该根据你的需求来选择是否使用std::move。如果你需要将资源从一个对象移动到另一个对象,而不是复制这些资源,那么应该使用std::move

4 STL

4.1. 怎么在vector中删除一个元素后,然后继续往后遍历呢?

在C++的vector中删除元素后继续遍历,需要注意的是,当使用erase函数删除元素后,原有的迭代器可能会失效。因此,正确的做法是使用erase函数的返回值(即指向被删除元素的下一个元素的迭代器)来更新当前的迭代器

#include <vector>
#include <iostream>
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 3, 5};

    for(auto it = vec.begin(); it != vec.end();) {
        if(*it == 3) {
            it = vec.erase(it); // 使用erase的返回值更新迭代器
        } else {
            ++it;
        }
    }

    // 输出处理后的vector
    for(const auto &val : vec) {
        std::cout << val << " ";
    }

    return 0;
}

在这个示例中,我们遍历vector并删除所有值为3的元素。注意,在删除元素后,我们使用erase函数的返回值来更新迭代器it。这样,即使删除了元素,我们也能正确地继续遍历vector

4.2 map的[]和at有什么区别?

std::map的[]操作符和at函数都可以用来访问元素,但它们的行为有一些重要的区别

  1. 行为差异
  2. 使用场景

以下是一些代码示例

#include <map>
#include <iostream>

int main() {
    std::map<int, std::string> m;

    // 使用[]操作符插入元素
    m[1] = "one";
    std::cout << m[1] << std::endl;  // 输出: one

    // 使用[]操作符访问不存在的元素
    std::cout << m[2] << std::endl;  // 输出: (空字符串)

    // 使用at函数访问不存在的元素
    try {
        std::cout << m.at(3) << std::endl;
    } catch(const std::out_of_range& e) {
        std::cout << "Caught exception: " << e.what() << std::endl;  // 输出: Caught exception: map::at
    }

    return 0;
}

4.3 unordered_map和map的区别

std::map和std::unordered_map是C++中的两种关联容器,它们都可以存储键值对,但在内部实现和性能上有一些重要的区别

  1. 内部实现:std::map内部使用平衡二叉树(红黑树)实现,因此它的元素会按照键的顺序进行排序。而std::unordered_map则使用哈希表实现,元素存储的顺序是任意的,不保证任何特定的顺序
  2. 查找时间:对于std::map,查找操作的时间复杂度为O(log n),其中n是元素的数量。对于std::unordered_map,在平均情况下,查找操作的时间复杂度为O(1),但在最坏情况下(例如发生大量哈希冲突时),时间复杂度可能会退化为O(n)
  3. 插入和删除时间:对于std::map,插入和删除操作的时间复杂度为O(log n),其中n是元素的数量。对于std::unordered_map,在平均情况下,插入和删除操作的时间复杂度为O(1),但在最坏情况下(例如发生大量哈希冲突时),时间复杂度可能会退化为O(n)
  4. 排序:如果你需要按照键的顺序遍历元素,那么应该使用std::map。如果你不需要保持任何特定的顺序,并且希望最大限度地提高查找、插入和删除操作的速度,那么应该使用std::unordered_map

总的来说,选择使用哪种容器取决于你的具体需求。如果需要排序或者频繁进行查找操作,那么std::map可能更合适。如果不需要排序,并且主要进行插入和删除操作,那么std::unordered_map可能会提供更好的性能。

#include <iostream>
#include <map>
#include <unordered_map>

int main() {
    std::map<int, std::string> mapExample;
    mapExample[1] = "one";
    mapExample[2] = "two";
    mapExample[3] = "three";

    std::cout << "std::map output:\n";
    for (const auto& pair : mapExample) {
        std::cout << pair.first << ": " << pair.second << "\n";
    }

    std::unordered_map<int, std::string> unorderedMapExample;
    unorderedMapExample[1] = "one";
    unorderedMapExample[2] = "two";
    unorderedMapExample[3] = "three";

    std::cout << "\nstd::unordered_map output:\n";
    for (const auto& pair : unorderedMapExample) {
        std::cout << pair.first << ": " << pair.second << "\n";
    }

    return 0;
}

常用的容器并分析底层实现数据结构

以下是一些常用的STL容器及其底层实现:

  1. array:std::array是一个固定大小的容器,其底层实现为静态数组。它支持快速随机访问,但大小固定,不能动态扩展。
  2. vector:std::vector是一个动态数组。它支持快速随机访问,并可以在尾部进行高效的插入和删除操作。但在非尾部进行插入和删除操作时效率较低。
  3. deque:std::deque(双端队列)的底层实现为多个分段的动态数组。它支持在首尾两端进行高效的插入和删除操作,也支持随机访问。
  4. list:std::list的底层实现为双向链表。它支持在任意位置进行高效的插入和删除操作,但不支持快速随机访问
  5. forward_list:std::forward_list的底层实现为单向链表。它支持在任意位置进行高效的插入和删除操作,但不支持快速随机访问。
  6. set/multiset:std::set和std::multiset的底层实现为红黑树。它们支持快速查找,但不支持快速随机访问。
  7. map/multimap:std::map和std::multimap的底层实现为红黑树。它们支持根据键值进行快速查找,但不支持快速随机访问。
  8. unordered_set/unordered_multiset:这些无序容器的底层实现为哈希表。它们支持快速查找,但不支持快速随机访问。
  9. unordered_map/unordered_multimap:这些无序容器的底层实现为哈希表。它们支持根据键值进行快速查找,但不支持快速随机访问。
  10. stack:std::stack是一个容器适配器,通常使用std::deque或std::list作为其底层容器。
  11. queue:std::queue是一个容器适配器,通常使用std::deque或std::list作为其底层容器。
  12. priority_queue:std::priority_queue是一个容器适配器,通常使用std::vector作为其底层容器,并使用堆(heap)来管理底层容器以提供优先级队列功能。

C++11标准库中新增加了以下几种容器

  • std::array:这是一个固定大小的数组,它保存了一个以严格顺序排列的特定数量的元素。
  • std::forward_list:这是一个单向链表容器,提供了O(1)复杂度的元素插入,不支持快速随机访问。
  • 无序容器:包括std::unordered_map、std::unordered_multimap、std::unordered_set和std::unordered_multiset。这些容器的元素是不进行排序的,内部通过哈希表实现,插入和搜索元素的平均复杂度为O(1)。
  • std::tuple:元组可以存放不同类型的数据,比如std::pair只能保存两个元素,而元组可以保存多个。

STL使用过那些容器,说说各自查询时间复杂度

在C++的STL库中,常用的容器包括vector、deque、list、set、map、unordered_set、unordered_map等。这些容器的查询时间复杂度如下:

  • vector:采用一维数组实现,元素在内存连续存放。查看操作的时间复杂度为:O(1)。
  • deque:采用双向队列实现,元素在内存连续存放。查看操作的时间复杂度为:O(1)。
  • list:采用双向链表实现,元素存放在堆中。查看操作的时间复杂度为:O(N)。
  • set/map/multiset/multimap:这四种容器采用红黑树实现,红黑树是平衡二叉树的一种。查看操作的时间复杂度近似为: O(logN)。
  • unordered_set/unordered_map/unordered_multiset/unordered_multimap:这四种容器采用哈希表实现。查看操作的时间复杂度为:O(1),最坏情况O(N)。

注意,容器的时间复杂度取决于其底层实现方式。

2.46. vector和数组区别

在C++中,vector和数组都是用来存储数据的容器,但它们有一些重要的区别

  1. 内存分配:数组在声明时就需要确定大小,并且在编译时会分配固定的连续内存空间12。而vector是动态数组,它可以在运行时动态调整大小
  2. 灵活性:数组的长度在声明时就已经确定,不能更改12。而vector可以根据需要动态调整大小,可以在末尾增加元素(使用push_back方法)
  3. 访问方式:数组和vector都可以使用下标操作进行处理,也都可以用迭代器进行操作12
  4. 内存管理:对于vector,当其生命周期结束后,它会自动释放所占用的内存4。而对于数组,如果是动态分配的,需要手动释放内存
  5. 性能:如果数组的长度确定的话,效率上vector差一些1。因为vector需要管理动态内存,所以相比于数组会有额外的管理开销

总的来说,选择使用数组还是vector主要取决于你的具体需求。如果你需要灵活性和易用性,那么vector可能是更好的选择。如果你追求效率,并且能提前知道数据的大小,那么使用数组可能更合适

#嵌入式##嵌入式软件开发##校招##C++##八股#
嵌入式软件校招笔记 文章被收录于专栏

记录本人校招过程中遇到的问题及笔记整理!后续会持续更新

全部评论
码住
1 回复 分享
发布于 2023-10-27 23:01 山东
码住
1 回复 分享
发布于 2023-10-28 11:09 安徽
有通讯协议八股吗
1 回复 分享
发布于 2023-10-28 11:49 安徽
😸
点赞 回复 分享
发布于 2023-10-27 19:44 安徽
码住
点赞 回复 分享
发布于 2023-10-28 11:31 安徽
通讯协议的八股后面会更新哦 感谢关注
点赞 回复 分享
发布于 2023-10-28 11:51 安徽
码住
点赞 回复 分享
发布于 2024-03-16 12:43 上海
跪谢膜拜
点赞 回复 分享
发布于 2024-03-24 18:49 浙江
这有啥办法可以打印下来做笔记吗?😂
点赞 回复 分享
发布于 2024-05-26 20:40 广东
c++中与java相比说的是没有跨平台性,后面说c++特点时又有呢,应该是有的
点赞 回复 分享
发布于 2024-07-17 21:22 四川
mark住
点赞 回复 分享
发布于 2024-08-26 15:33 广东

相关推荐

评论
36
160
分享

创作者周榜

更多
牛客网
牛客企业服务