C++八股文

  1. C++八股文
    1. 一、基础语法
      1. 1.1 在main执行之前和之后执行的代码可能是什么?
      2. 1.2 结构体内存对齐问题?
      3. 1.3 指针和引用的区别
      4. 1.4 在传递函数参数时,什么时候该使用指针,什么时候该使用引用呢?
      5. 1.5 堆和栈的区别
      6. 1.6 栈为什么比堆执行快?
      7. 1.7 区别以下指针类型?
      8. 1.8 new / delete 与 malloc / free的异同
      9. 1.9 被free回收的内存是立即返还给操作系统吗?
      10. 1.10 宏定义和typedef区别?
      11. 1.11 strlen和sizeof区别?
      12. 1.12 一个指针占多少字节?
      13. 1.13 常量指针和指针常量区别?
      14. 1.13.2 顶层const与底层const
      15. 1.14 C++和C语言的区别
      16. 1.15 C++中struct和class的区别
      17. 1.16 define宏定义和const的区别
      18. 1.17 数组名和指针(这里为指向数组首元素的指针)区别?
      19. 1.18 extern”C”的用法
      20. 1.19 野指针和悬空指针
      21. 1.20 C++中的重载、重写(覆盖)和隐藏的区别
      22. 1.21 浅拷贝和深拷贝的区别
      23. 1.22 内联函数和宏定义的区别
      24. 1.23 public,protected和private访问和继承权限/public/protected/private的区别?
      25. 1.24 如何用代码判断大小端存储?
      26. 1.25 volatile、mutable和explicit关键字的用法
      27. 1.26 C++的异常处理的方法
      28. 1.27 static的用法和作用?
      29. 1.28 成员初始化方式?构造函数的执行顺序 ?为什么用成员初始化列表会快一些?
      30. 1.29 有哪些情况必须用到成员列表初始化?作用是什么?
      31. 1.30 什么是内存泄露,如何检测与避免
      32. 1.31 对象复用的了解,零拷贝的了解
      33. 1.32 C++的四种强制转换reinterpret_cast/const_cast/static_cast /dynamic_cast
      34. 1.33 写C++代码时有一类错误是 coredump ,很常见,你遇到过吗?怎么调试这个错误?
      35. 1.34 说说移动构造函数
      36. 1.35 C++中将临时变量作为返回值时的处理过程
      37. 1.36 如何获得结构成员相对于结构开头的字节偏移量
      38. 1.37 怎样判断两个浮点数是否相等?
      39. 1.38 C++的标准库,STL及std的区别
      40. 1.39 C++中的指针参数传递和引用参数传递有什么区别?底层原理你知道吗?
      41. 1.40 类如何实现只能静态分配和只能动态分配
      42. 1.41 知道C++中的组合吗?它与继承相比有什么优缺点吗?
      43. 1.42 函数指针?
      44. 1.43 为什么要进行内存对齐
      45. 1.44 内存对齐规则
      46. 1.45 static变量
      47. 1.46 extern和static
      48. 1.47 如何在不使用额外空间的情况下,交换两个数?你有几种方法
      49. 1.48 strcpy 和 memcpy 的区别
      50. 1.49 程序在执行int main(int argc, char *argv[])时的内存结构
      51. 1.50 volatile关键字的作用?
      52. 1.51 如果有一个空类,它会默认添加哪些函数?
      53. 1.52 说一说strcpy、sprintf与memcpy这三个函数的不同之处
      54. 1.53 如何阻止一个类被实例化?有哪些方法
      55. 1.54 strcpy函数和strncpy函数的区别?哪个函数更安全?
      56. 1.55 写一个比较大小的模板函数
      57. 1.56 成员函数里memset(this,0,sizeof(*this))会发生什么
      58. 1.57 C++从代码到可执行程序经历了什么
      59. 1.58 友元函数和友元类
      60. 1.59 自旋锁
      61. 1.60 为什么C++没有垃圾回收机制?这点跟Java不太一样。
    2. 二、内存管理
      1. 2.1 简要说明C++的内存分区
      2. 2.2 什么是内存池,如何实现
      3. 2.3 几个this指针的易混问题
      4. 2.4 内存泄漏的后果?如何监测?解决方法?
        1. 2.4.1.使用 -g 选项编译程序有什么作用
      5. 2.5 在成员函数中调用delete this会出现什么问题?对象还可以使用吗?
      6. 2.6 如果在类的析构函数中调用delete this,会发生什么?
      7. 2.7 请说一下以下几种情况下,下面几个类的大小各是多少?
      8. 2.8 类对象的大小受哪些因素影响?
    3. 三、C++11新标准
      1. 3.1 C++ 11有哪些新特性?
      2. 3.2 智能指针的原理、常用的智能指针及实现
      3. 3.3 lambda函数
      4. 3.4 shared_ptr的循环引用问题
    4. 四、STL
      1. 4.1 什么是STL
        1. 1. 容器(Containers)
        2. 2. 迭代器(Iterators)
        3. 3. 算法(Algorithms)
        4. 仿函数
        5. 容器配接器
        6. 空间配置器
      2. 4.2 使用智能指针管理内存资源,RAII是怎么回事?
      3. 4.3 迭代器:++it、it++哪个好,为什么
      4. 4.4 右值
      5. 4.5 简单说一下traits技法
      6. 4.6 STL的两级空间配置器
      7. 4.7 vector与list的区别与应用?怎么找某vector或者list的倒数第二个元素
      8. 4.8 reserve与resize
      9. 4.9 STL迭代器如何实现
      10. 4.10 map插入方式有哪几种?
      11. 4.20 map中[]与find的区别?
      12. 4.21 STL中list与deque之间的区别
      13. 4.22 STL中的allocator、deallocator
      14. 4.23 常见容器性质总结?
      15. 4.24 说一下STL每种容器对应的迭代器
      16. 4.25 STL中迭代器失效的情况有哪些?
      17. 4.26 hashtable中解决冲突有哪些方法?
    5. 五、其余问题
      1. 5.1 C++多态
      2. 5.2 什么时候的析构函数必须写成虚函数
      3. 5.3 构造函数能否声明为虚函数或者纯虚函数,析构函数呢?
      4. 5.4 目标文件存储结构
      5. 5.5 基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间
      6. 5.6 模板函数和模板类的特例化
      7. 5.7 模板定义和实现可不可以不写在一个文件里面?为什么?
      8. 5.8 构造函数、析构函数、虚函数可否声明为内联函数
      9. 5.9 C++模板是什么,你知道底层怎么实现的?
      10. 5.10 构造函数和析构函数可以调用虚函数吗,为什么
      11. 5.11 如何解决菱形继承
      12. 5.12 将字符串“hello world”从开始到打印到屏幕上的全过程?
      13. 5.13 为什么拷贝构造函数必须传引用不能传值?
      14. 5.14 虚函数的调用关系
      15. 5.15 说一说你了解到的移动构造函数?
      16. 5.16 哪些函数不能是虚函数?把你知道的都说一说
      17. 5.17 什么是纯虚函数,与虚函数的区别
      18. 5.18 DLL劫持
      19. 5.19 我写的程序导致CPU使用率居高不下,可能是什么原因
      20. 5.20 C++类型安全吗
      21. 5.21 关于指针数组的delete与delete[]
      22. 5.22 C有没有函数重载的概念?

C++八股文

一、基础语法

1.1 在main执行之前和之后执行的代码可能是什么?

main函数执行之前,主要就是初始化系统相关资源:

  • 设置栈指针
  • 初始化静态static变量和global全局变量,即.data段的内容
  • 将未初始化部分的全局变量赋初值:数值型shortintlong等为0boolFALSE指针NULL等等,即.bss段的内容
  • 将main函数的参数argcargv等传递给main函数,然后才真正运行main函数
  • 执行__attribute__((constructor)),与golang的init()函数类似

main函数完成后:

  • 全局对象的析构函数
  • 可以用atexit注册一个函数,它会在main之后执行;
  • __attribute__((destructor))

1.2 结构体内存对齐问题?

  • 结构体内成员按照声明顺序存储,第一个成员地址和整个结构体地址相同。
  • 未特殊说明时,按结构体中size最大的成员对齐(若有double成员,按8字节对齐。)

c++11以后引入两个关键字alignasalignof。其中alignof可以计算出类型的对齐方式,alignas可以指定结构体的对齐方式。但是仍有需要注意的点:

  • alignas小于自然对齐的最小单位,则被忽略。
  • 如果想使用单字节对齐的方式,使用alignas是无效的。应该使用#pragma pack(push,1)或者使用__attribute__((packed))

用法如下:

// 无效的指定 因为2小于自然对齐的大小uint32_t(4)
struct alignas(2) Info2 {
  uint8_t a;
  uint32_t b;
  uint8_t c;
};

1.3 指针和引用的区别

  • 指针是一个变量,存储的是一个地址,引用跟原来的变量实质上是同一个东西,是原变量的别名
  • 指针可以有多级,引用只有一级
  • 指针可以为空,引用不能为NULL且在定义时必须初始化
  • 指针在初始化后可以改变指向,而引用在初始化之后不可再改变
  • sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小
  • 引用本质是一个指针,同样会占4/8字节内存,不过编译器对它进行了一些处理,使得程序认为它不单独占用内存空间;指针是具体变量,需要占用存储空间。
  • 引用一旦初始化之后就不可以再改变(变量可以被引用为多次,但引用只能作为一个变量引用);指针变量可以重新指向别的变量。
  • 不存在指向空值的引用,必须有具体实体;但是存在指向空值的指针。

1.4 在传递函数参数时,什么时候该使用指针,什么时候该使用引用呢?

  • 需要操作内存的时候传指针。
  • 对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小。
  • 类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式。

1.5 堆和栈的区别

  • 申请方式不同。

    栈由系统自动分配。
    堆是自己申请和释放的。

  • 申请大小限制不同。

    栈顶和栈底是之前预设好的,栈是向栈底扩展,大小固定,可以通过ulimit -a查看,由ulimit -s修改。
    堆向高地址扩展,是不连续的内存区域,大小可以灵活调整。
    栈空间默认是4M, 堆区一般是 1G - 4G

  • 申请效率不同。

    栈由系统分配,速度快,不会有碎片。
    堆由程序员分配,速度慢,且会有碎片。

user@master:~$ ulimit -a
core file size          (blocks, -c) 0              // 当程序崩溃时生成的核心转储文件的最大大小
data seg size           (kbytes, -d) unlimited      // 堆内存
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 63181
max locked memory       (kbytes, -l) 65536
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192           // 栈内存
cpu time               (seconds, -t) unlimited
max user processes              (-u) 63181
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

1.6 栈为什么比堆执行快?

  1. 栈内存的快速访问主要是因为它通常位于主存中的相对较小的内存区域,这使得 CPU 缓存(包括一级缓存)中的数据能够快速加载到处理器中。此外,栈内存的顺序访问模式也有助于提高缓存的命中率。

  2. 堆内存访问首先要从内存读指针地址,然后再用这个指针地址加偏移量去访问堆内存(堆内存中的数据通常是动态分配的,具体地址在运行时才确定),这就导致了多一个读指令。另外,堆内存很可能不处于RAM里(虚拟内存系统),尤其是第一次访问,如果再发生缺页中断会导致堆和栈出现巨大的速度差异。

    虚拟内存系统:操作系统可以将不经常使用的堆内存页面移到外存(如硬盘或SSD)上的交换区(swap space)以腾出物理内存(RAM)。这种机制使得堆内存中的数据在某些情况下可能不直接位于RAM中,而是在需要时从外存调入

  3. 堆内存需要做内存申请,这是极为耗时的操作,如果把内存申请的消耗计算在内,那栈内存的综合速度优势就又多了一项。

1.7 区别以下指针类型?

int *p[10]
int (*p)[10]
int *p(int)
int (*p)(int)
  • int *p[10]表示指针数组,强调数组概念,是一个数组变量,数组大小为10,数组内每个元素都是指向int类型的指针变量。

  • int (*p)[10]表示数组指针,强调是指针,只有一个变量,是指针类型,不过指向的是一个int类型的数组,这个数组大小是10。

  • int *p(int)是函数声明,函数名是p,参数是int类型的,返回值是int *类型的。

  • int (*p)(int)是函数指针,强调是指针,该指针指向的函数具有int类型参数,并且返回值是int类型的。

1.8 new / delete 与 malloc / free的异同

  • 相同点

    • 都可用于内存的动态申请和释放
  • 不同点

    • 前者是C++运算符,后者是C/C++语言标准库函数
    • new自动计算要分配的空间大小,malloc需要手工计算
    • new是类型安全的,malloc不是。例如:
    int *p = new float[2]; //编译错误
    *p = (int*)malloc(2 * sizeof(double));//编译无错误
    
    • new调用名为operator new的标准库函数分配足够空间并调用相关对象的构造函数,delete对指针所指对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存。后者均没有相关调用
    • 后者需要库文件支持,前者不用
    • new是封装了malloc,直接free不会报错,但是这只是释放内存,而不会析构对象

1.9 被free回收的内存是立即返还给操作系统吗?

  • 不是的,被free回收的内存会首先被内存管理系统的自由列表(free list)保存起来。常见的如ptmalloc使用双链表保存内存块,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。

1.10 宏定义和typedef区别?

  • 宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名。

  • 宏替换发生在编译阶段之前,属于文本插入替换;typedef是编译的一部分。

  • 宏不检查类型;typedef会检查数据类型。

  • 宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。

  • 注意对指针的操作,typedef char * p_char和#define p_char char *区别巨大。

1.11 strlen和sizeof区别?

  • sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。

  • sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串。

  • 因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。

1.12 一个指针占多少字节?

  • 在64位的编译环境下,指针的占用大小为8字节;

  • 而在32位环境下,指针占用大小为4字节。

  • 一个指针占内存的大小跟编译环境有关,而与机器的位数无关。

  • 还有疑问的,可以自行打开Visual Studio编译器自己实验一番。

1.13 常量指针和指针常量区别?

  • 常量指针是一个指针,读成常量的指针,指向一个只读变量,也就是后面所指明的int const 和 const int,都是一个常量,可以写作:
    • int const *p
    • const int *p

常量指针->指向常量的指针
常量指针->不可改变内容,但可以改变指向的地址。

  • 指针常量是一个常量,必须初始化,一旦初始化完成,它的值(也就是存放在指针中的地址)就不能在改变了,即不能中途改变指向,如:
    • int *const p

指针常量->常量
指针常量->不可改变指向的地址,但可以改变地址存放的数据。

1.13.2 顶层const与底层const

  • 顶层const:指针本身是一个常量;
  • 底层const:指针所指对象是一个常量;

1.14 C++和C语言的区别

  • 面向对象编程(OOP)
    C++ 是一种支持面向对象编程的语言,它引入了类和对象的概念,以及封装、继承和多态等面向对象的特性。
    C 语言虽然也可以编写面向对象的代码,但它没有内建的语言特性来支持面向对象编程,因此需要使用结构体和函数来模拟类和对象。
  • 标准库
    C++ 标准库(STL)提供了许多丰富的数据结构和算法,如向量、列表、映射、排序和搜索等。
    C 语言的标准库相对较小,只提供了一些基本的数据类型和函数,如整数、字符、字符串和文件处理等。
  • 内存管理
    C++ 支持动态内存分配和释放,引入了 new 和 delete 运算符用于动态分配和释放内存。
    C 语言也支持动态内存分配和释放,但是使用的是 malloc() 和 free() 函数。
  • 异常处理
    C++ 支持异常处理机制,可以使用 try、catch 和 throw 关键字来处理异常情况。
    C 语言没有内建的异常处理机制,通常通过返回值或错误码来处理异常情况。
  • 其他特性
    C++ 还引入了许多其他特性,如模板、命名空间、运算符重载和函数重载等,以提高代码的灵活性和可复用性。
    C 语言相对较简单,更加接近硬件和操作系统,因此更适用于系统级编程和嵌入式开发。

总的来说,C++ 是在 C 语言基础上发展而来的,它继承了 C 语言的一些特性,并引入了更多的高级特性,如面向对象编程和异常处理等,使得它更加适用于大型项目和复杂的软件开发。

1.15 C++中struct和class的区别

  • 相同点

    两者都拥有成员函数、公有和私有部分
    任何可以使用class完成的工作,同样可以使用struct完成

  • 不同点

    两者中如果不对成员不指定公私有,struct默认是公有的,class则默认是私有的

    class默认是private继承, 而struct默认是public继承

1.16 define宏定义和const的区别

编译阶段

  • define是在编译的预处理阶段起作用,而const是在编译、运行的时候起作用

安全性

  • define只做替换,不做类型检查和计算,也不求解,容易产生错误,一般最好加上一个大括号包含住全部的内容,要不然很容易出错
  • const常量有数据类型,编译器可以对其进行类型安全检查

内存占用

  • define只是将宏名称进行替换,在内存中会产生多分相同的备份。const在程序运行中只有一份备份,且可以执行常量折叠,能将复杂的的表达式计算出结果放入常量表

1.17 数组名和指针(这里为指向数组首元素的指针)区别?

  • 二者均可通过增减偏移量来访问数组中的元素。

  • 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。

  • 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了。

1.18 extern”C”的用法

  • 为了能够正确的在C++代码中调用C语言的代码:在程序中加上extern “C”后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++。

1.19 野指针和悬空指针

都是是指向无效内存区域(这里的无效指的是"不安全不可控")的指针,访问行为将会导致未定义行为。
  • 野指针
    野指针,指的是没有被初始化过的指针
  • 悬空指针
    悬空指针,指针最初指向的内存已经被释放了的一种指针。

使用智能指针可以有效解决问题。

1.20 C++中的重载、重写(覆盖)和隐藏的区别

  1. 重载(overload)
    重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是返回类型函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。举个例子:

  2. 重写(覆盖)(override)
    重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且:

    • 与基类的虚函数有相同的参数个数
    • 与基类的虚函数有相同的参数类型
    • 与基类的虚函数有相同的返回值类型
  3. 隐藏(hide)
    隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:

    • 两个函数参数相同,但是基类函数不是虚函数。和重写的区别在于基类函数是否是虚函数。
      //父类
      class A{
      public:
          void fun(int a){
          cout << "A中的fun函数" << endl;
        }
      };
      //子类
      class B : public A{
      public:
          //隐藏父类的fun函数
          void fun(int a){
          cout << "B中的fun函数" << endl;
        }
      };
      int main(){
          B b;
          b.fun(2); //调用的是B中的fun函数
          b.A::fun(2); //调用A中fun函数
          return 0;
      }
    
    • 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。
      //父类
      class A{
      public:
          virtual void fun(int a){
          cout << "A中的fun函数" << endl;
        }
      };
      //子类
      class B : public A{
      public:
          //隐藏父类的fun函数
        virtual void fun(char* a){
          cout << "A中的fun函数" << endl;
        }
      };
      int main(){
          B b;
          b.fun(2); //报错,调用的是B中的fun函数,参数类型不对
          b.A::fun(2); //调用A中fun函数
          return 0;
      }
    

1.21 浅拷贝和深拷贝的区别

  • 浅拷贝

    浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。

  • 深拷贝

    深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。

1.22 内联函数和宏定义的区别

  • 在使用时,宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。
  • 内联函数在编译时直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率,并且进行参数类型检查,具有返回值,可以实现重载。
  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
    内联函数有类型检测、语法判断等功能,而宏没有

    注意:使用inline关键字只是建议编译器内联函数,内联函数是否真的内联还需要编译器判断。

1.23 public,protected和private访问和继承权限/public/protected/private的区别?

  • 访问权限

    • public的变量和函数在类的内部外部都可以访问。

    • protected的变量和函数只能在类的内部和其派生类中访问。

    • private修饰的元素只能在类内访问。

  • 继承权限

    • public继承(公有继承)的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问

    • protected继承(保护继承)的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的

    • private继承(私有继承)的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承

1.24 如何用代码判断大小端存储?

  • 大端存储:字数据的高字节存储在低地址中 –> 高位在前

  • 小端存储:字数据的低字节存储在低地址中 –> 高位在后

    使用强制类型转换判断大小端存储

    #include <iostream>
    using namespace std;
    int main()
    {
        int a = 0x1234;
        //由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分
        char c = (char)(a);
        if (c == 0x12)
            cout << "big endian" << endl;
        else if(c == 0x34)
            cout << "little endian" << endl;
    }
    

1.25 volatile、mutable和explicit关键字的用法

  1. volatile
    volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量需要定义为volatile类型。

  2. mutable
    mutable的中文意思是“可变的,易变的”,跟constant(即C++中的const)是反义词。在C++中,mutable也是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后后面关键字位置

class person
{
    int m_A;
    mutable int m_B;//特殊变量 在常函数里值也可以被修改
public:
    void add() const//在函数里不可修改this指针指向的值 常量指针
    {
        m_A = 10;//错误  不可修改值,this已经被修饰为常量指针
        m_B = 20;//正确
    }
};
class person
{
public:
    int m_A;
    mutable int m_B;//特殊变量 在常函数里值也可以被修改
};
int main()
{
    const person p = person();//修饰常对象 不可修改类成员的值
    p.m_A = 10;//错误,被修饰了指针常量
    p.m_B = 200;//正确,特殊变量,修饰了mutable
}
  1. explicit
    explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显式的方式进行类型转换,注意以下几点:

    • explicit 关键字只能用于类内部的构造函数声明上
    • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换

1.26 C++的异常处理的方法

在程序执行过程中,由于程序员的疏忽或是系统资源紧张等因素都有可能导致异常,任何程序都无法保证绝对的稳定,常见的异常有:

  • 数组下标越界
  • 除法计算时除数为0
  • 动态分配空间时空间不足

如果不及时对这些异常进行处理,程序多数情况下都会崩溃。

C++中的异常处理机制主要使用try、throw和catch三个关键字,其在程序中的用法如下:

#include <iostream>
using namespace std;
int main()
{
    double m = 1, n = 0;
    try {
        cout << "before dividing." << endl;
        if (n == 0)
            throw - 1;  //抛出int型异常
        else if (m == 0)
            throw - 1.0;  //拋出 double 型异常
        else
            cout << m / n << endl;
        cout << "after dividing." << endl;
    }
    catch (double d) {
        cout << "catch (double)" << d << endl;
    }
    catch (...) {
        cout << "catch (...)" << endl;
    }
    cout << "finished" << endl;
    return 0;
}

代码中,对两个数进行除法计算,其中除数为0。可以看到以上三个关键字,程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块,如果发生异常,则使用throw进行异常抛出,再由catch进行捕获,throw可以抛出各种数据类型的信息,代码中使用的是数字,也可以自定义异常class。
catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(…)的方式捕获任何异常(不推荐)。
当然,如果catch了异常,当前函数如果不进行处理,或者已经处理了想通知上一层的调用者,可以在catch里面再throw异常

1.27 static的用法和作用?

  1. 先来介绍它的第一条也是最重要的一条:隐藏。(static函数,static变量均可)
    当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。

  2. static的第二个作用是保持变量内容的持久。(static变量中的记忆功能和全局生存期)存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围,说到底static还是用来隐藏的。

  3. static的第三个作用是默认初始化为0(static变量)
    其实全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是0x00,某些时候这一特点可以减少程序员的工作量。

  4. static类对象必须要在类外进行初始化,static修饰的变量先于对象存在,所以static修饰的变量要在类外初始化;

  5. 由于static修饰的类成员属于类,不属于对象,因此static类成员函数是没有this指针的,this指针是指向本对象的指针。正因为没有this指针,所以static类成员函数不能访问非static的类成员,只能访问static修饰的类成员;

  6. static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;静态成员函数没有this指针,虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual;虚函数的调用关系,this->vptr->ctable->virtual function

1.28 成员初始化方式?构造函数的执行顺序 ?为什么用成员初始化列表会快一些?

赋值初始化,通过在函数体内进行赋值初始化;列表初始化,在冒号后使用初始化列表进行初始化。
这两种方式的主要区别在于:

  • 对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。

  • 列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。

1.29 有哪些情况必须用到成员列表初始化?作用是什么?

  • 常量成员变量:

    如果类中包含const或者引用类型的成员变量,则必须使用成员列表初始化对它们进行初始化。
    因为常量成员变量和引用类型成员变量无法在构造函数内部进行赋值,只能通过成员列表初始化来初始化它们。

  • 继承的成员变量:

    如果派生类继承了基类的成员变量,且基类没有默认构造函数,则必须使用成员列表初始化来调用基类的构造函数对基类成员变量进行初始化。

  • 成员对象:

    如果类中包含其他类对象作为成员变量,则最好使用成员列表初始化对这些成员对象进行初始化。
    这样可以避免在构造函数体内部对成员对象进行默认初始化后再赋值,而直接在构造函数的初始化列表中完成初始化。

1.30 什么是内存泄露,如何检测与避免

  • 内存泄露

    一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、 new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了

  • 避免内存泄露的几种方式

    • 计数法:使用new或者malloc时,让该数+1,delete或free时,该数-1,程序执行完打印这个计数,如果不为0则表示存在内存泄露
    • 一定要将基类的析构函数声明为虚函数
    • 对象数组的释放一定要用delete []
    • 有new就有delete,有malloc就有free,保证它们一定成对出现
  • 检测工具

    • Linux下可以使用Valgrind工具
    • Windows下可以使用CRT库

1.31 对象复用的了解,零拷贝的了解

  • 对象复用:

    • 对象复用是指在程序执行过程中,重复使用已经创建的对象,而不是频繁地创建新的对象。通过对象复用,可以减少资源的消耗和提高性能。
    • 对象复用通常适用于那些需要频繁创建和销毁的对象,比如线程池中的线程对象、连接池中的数据库连接等。通过将这些对象创建一次,然后在需要时重复利用,可以避免反复创建对象的开销,提高系统的效率。
  • 零拷贝:

    • 零拷贝是一种优化技术,用于在数据传输过程中减少或消除不必要的数据拷贝操作。通过零拷贝技术,可以提高数据传输的效率和降低系统的负载。
    • 零拷贝通常应用于文件传输、网络通信等场景中。例如,在网络通信中,零拷贝技术可以避免将数据从用户空间拷贝到内核空间,再从内核空间拷贝到网络缓冲区的过程,而是直接在用户空间和网络缓冲区之间进行数据传输,从而提高了数据传输的效率。
    • 零拷贝的主要实现有mmap() (内存映射)

DMA(Direct Memory Access-直接内存访问)和零拷贝详细介绍 -> 【linux】图文并茂|彻底搞懂零拷贝(Zero-Copy)技术

1.32 C++的四种强制转换reinterpret_cast/const_cast/static_cast /dynamic_cast

  • reinterpret_cast:

    • reinterpret_cast 可以用于将一个指针或引用转换为另一种不同类型的指针或引用。它可以执行任意类型之间的转换,甚至是不兼容的类型。
    • reinterpret_cast 对转换的类型没有限制,它不进行类型检查,因此需要谨慎使用。
    • reinterpret_cast 主要用于进行底层的类型转换,如指针之间的转换或者将整数类型转换为指针类型等。
  • const_cast:

    • const_cast 主要用于去除表达式中的 const 或 volatile 修饰符,以便修改被修饰对象的值。
    • const_cast 只能用于去除 const 或 volatile 限定符,不能进行其他类型的转换。
    • const_cast 的使用场景通常是在需要对 const 对象进行修改时,或者在调用函数时需要去除函数参数的 const 修饰符。
  • static_cast:

    • static_cast 用于执行静态类型转换,可以在编译时进行类型检查,因此比较安全。
    • static_cast 可以执行基本类型之间的转换,如整数之间的转换、指针类型之间的转换,以及类之间的上行转换(派生类指针向基类指针的转换)和下行转换(基类指针向派生类指针的转换)。
    • static_cast 也可以用于显式调用构造函数和转换构造函数进行类型转换。
  • dynamic_cast:

    • dynamic_cast 用于执行动态类型转换,主要用于在运行时进行类型检查和转换,通常用于处理继承关系的类。
    • dynamic_cast 只能用于类类型之间的转换,并且其中至少一个类必须具有虚函数。它用于在类层次结构中安全地进行上行转换和下行转换,并且在转换失败时返回 nullptr(对于指针类型)或抛出 std::bad_cast 异常(对于引用类型)。

1.33 写C++代码时有一类错误是 coredump ,很常见,你遇到过吗?怎么调试这个错误?

coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。

如何使用gdb调试coredump:

  • 分析core dump:使用调试工具(如gdb)来分析core dump文件。可以通过以下命令来启动gdb并加载core dump文件:
    gdb [可执行文件名] [core文件名]
    
  • 查看崩溃位置:在gdb中可以使用backtrace命令(简写为bt)来查看程序崩溃时的调用栈,这可以帮助你找到崩溃位置。命令如下:
    (gdb) bt
    
  • 查看变量状态:通过在崩溃位置设置断点,并使用print命令来查看变量的值,可以帮助你理解程序崩溃的原因。例如:
    (gdb) break <line_number>
    (gdb) run
    (gdb) print <variable_name>
    

1.34 说说移动构造函数

移动构造函数是C++11引入的一个特性,它允许在对象的所有权转移时执行高效的资源移动,而不是传统的拷贝。移动构造函数通常用于实现在不再需要源对象的情况下,将其内容“移动”到新创建的对象中,从而避免不必要的内存分配和数据复制。

移动构造函数的语法如下:

class MyClass {
public:
    // 移动构造函数
    MyClass(MyClass&& other) noexcept {
        // 在此处执行资源的移动操作
        // 将other对象的资源转移到当前对象中
    }
};

在移动构造函数中,参数通常是一个右值引用(通过使用双引号&&),表示将要被移动的对象。关键字noexcept是一个可选的说明符,表示该函数不会抛出异常。这对于某些情况下的优化是有帮助的。

移动构造函数通常用于以下几种情况:

  1. 当返回临时对象时,避免不必要的拷贝。例如:
    MyClass createObject() {
        MyClass temp;
        // 初始化temp对象
        return temp; // 调用移动构造函数而不是拷贝构造函数
    }
    
  2. 当将一个对象插入容器时,可以使用移动构造函数将对象移入容器中,而不是复制:
    std::vector<MyClass> myVector;
    MyClass obj;
    myVector.push_back(std::move(obj)); // 使用 std::move 将对象移入容器中
    
  3. 当需要动态分配内存时,可以使用移动语义来避免额外的内存拷贝。

1.35 C++中将临时变量作为返回值时的处理过程

首先需要明白一件事情,临时变量,在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了

C语言里规定:16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保持在eax寄存器中,如果是64bit返回值,edx寄存器保存高32bit,eax寄存器保存低32bit

由此可见,函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系

如果我们需要返回值,一般使用赋值语句就可以了。

1.36 如何获得结构成员相对于结构开头的字节偏移量

使用<stddef.h>头文件中的,offsetof宏。

#include <cstddef>
#include <iostream>

struct MyStruct {
    int a;
    char b;
    double c;
};

int main() {
    std::cout << "Offset of 'a' in MyStruct: " << offsetof(MyStruct, a) << std::endl;
    std::cout << "Offset of 'b' in MyStruct: " << offsetof(MyStruct, b) << std::endl;
    std::cout << "Offset of 'c' in MyStruct: " << offsetof(MyStruct, c) << std::endl;

    return 0;
}

输出:

Offset of 'a' in MyStruct: 0
Offset of 'b' in MyStruct: 4
Offset of 'c' in MyStruct: 8

1.37 怎样判断两个浮点数是否相等?

对两个浮点数判断大小和是否相等不能直接用==来判断,会出错!明明相等的两个数比较反而是不相等!对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!浮点数与0的比较也应该注意。与浮点数的表示方式有关。

#include <cmath> // 包含数学库
#include <iostream>

// 判断两个浮点数是否相等的函数
bool areFloatsEqual(double a, double b, double tolerance) {
    return std::fabs(a - b) <= tolerance; // fabs 计算绝对值
}

int main() {
    double x = 0.1 + 0.2;
    double y = 0.3;

    double tolerance = 1e-9; // 设置公差值 0.000000001

    if (areFloatsEqual(x, y, tolerance)) {
        std::cout << "The numbers are considered equal." << std::endl;
    } else {
        std::cout << "The numbers are NOT equal." << std::endl;
    }

    return 0;
}

1.38 C++的标准库,STL及std的区别

  • std(Standard)
  • STL(Standard Template Library)
  • STL是标准模板库,是标准库的子集。主要是容器、算法、迭代器。标准库还包括stream,string等,STL大约占了标准库内容得80%
  • std是命名空间的名字,目的是为了避免命名空间污染。模板库(包括stl)的设计者,特意在库文件里面加上了命名空间。这样,我们使用者就可以在定义自己的函数时,定义自己的命名空间。然后在自己定义的命名空间作用域范围内,使用我们自己定义的、但可能和标准库里的函数重名的函数。这样就不会有函数冲突了,使用时注意命名空间的作用域就好了!
  • 模板库(包括stl,stream,string)中的所有名字的使用都得通过std::

1.39 C++中的指针参数传递和引用参数传递有什么区别?底层原理你知道吗?

在 C++ 中,指针参数传递和引用参数传递都可以用于实现函数之间的参数传递,它们之间有一些区别:

  1. 指针参数传递:
    • 指针参数传递是通过将参数声明为指针类型来实现的。在函数内部,可以通过解引用指针来访问参数所指向的对象。
    • 指针参数传递需要在函数调用时传递指针的地址,因此需要额外的内存空间存储指针地址。(本质上是值传递,它所传递的是一个地址值。)
    • 指针参数可以为空(即指向空指针),因此需要在函数内部进行空指针检查,以防止出现空指针异常。
  2. 引用参数传递:
    • 引用参数传递是通过将参数声明为引用类型来实现的。在函数内部,引用参数直接绑定到传递给函数的对象上,不需要解引用操作。
    • 引用参数传递不需要额外的内存空间存储地址,因为引用本身就是目标对象的别名。
    • 引用参数不能为空,因为引用必须引用一个有效的对象。

底层原理:

  • 指针参数传递的底层原理是将指针的值(即地址)传递给函数,函数内部通过解引用指针来访问所指向的对象。
  • 引用参数传递的底层原理是将引用绑定到传递给函数的对象上,因此在函数内部直接操作引用就相当于操作原始对象。

总的来说,引用参数传递更加简洁和安全,因为它不需要对空指针进行检查,并且在函数调用时不会产生额外的开销。但是在某些情况下,指针参数传递可能更加灵活,例如需要允许空指针传递的情况。

1.40 类如何实现只能静态分配和只能动态分配

  • 只能静态分配的类:
    如果希望类的对象只能在栈上分配,可以通过禁用类的动态内存分配来实现:
    • 删除newdelete运算符的重载
    • 将它们声明为私有成员,以阻止类的用户使用动态内存分配
      class StaticAllocatedClass {
      public:
          // 禁用 new 和 delete 运算符
          void* operator new(std::size_t) = delete;
          void operator delete(void*) = delete;
      };
      
  • 只能动态分配的类:
    • 无法通过在类中将构造函数设置为私有或者保护成员来实现,因为私有构造函数或受保护构造函数只限制直接实例化,但并不能阻止在类内部创建对象。如果在类内提供了一个静态方法来返回对象的实例,它仍然可以返回一个在栈上创建的对象的引用或指针,或者通过友元类等方式绕过限制。
    • 如果希望类的对象只能在堆上分配,可以在类中将析构函数设置为私有或者保护成员,以防止用户直接调用。强行创建会因为无法调用析构函数而导致编译错误。
      class HeapOnly {
      public:
          static HeapOnly* createInstance() {
              return new HeapOnly();  // 只能在堆上创建
          }
      
      private:
          HeapOnly() {}  // 私有构造函数
          ~HeapOnly() {}  // 私有析构函数
      };
      
      int main() {
          // HeapOnly ho;  // 无法编译,因为无法调用私有析构函数
          HeapOnly* ho = HeapOnly::createInstance();
          delete ho;  // 通过 delete 手动释放
      }
      

1.41 知道C++中的组合吗?它与继承相比有什么优缺点吗?

在面向对象编程中,组合(Composition)是一种将多个类组合在一起创建新的类的方式。在组合关系中,一个类包含另一个类的实例作为其成员变量,这种关系表达了“具有”的关系,而不是“是一个”的关系。

与继承相比,组合的优缺点如下:

  • 优点:

    • 灵活性: 组合关系更灵活,因为它不会限制子类必须继承特定的行为或属性,而是通过组合已有的类来实现新的功能。
    • 松耦合: 组合关系降低了类之间的耦合度,因为类之间的关系更加简单明确,不会引入不必要的依赖。
    • 封装性: 组合可以带来更好的封装性,因为组合的类可以选择性地暴露其内部成员,对外部隐藏实现细节。
    • 易于维护: 组合关系使得代码结构更清晰,易于理解和维护。
  • 缺点:

    • 代码重复: 在组合关系中,如果多个类都需要相同的功能或属性,可能会导致代码重复,增加了代码量和维护成本。
    • 初始化复杂: 当一个类包含多个其他类的实例作为成员变量时,初始化对象可能变得更加复杂。
    • 性能开销: 在运行时,由于需要额外的内存分配和对象构造,组合关系可能会引入一定的性能开销。

总的来说,组合关系提供了一种更加灵活和松耦合的方式来构建对象,可以避免继承带来的一些问题,但也需要注意代码重复和初始化复杂性等缺点。选择组合还是继承取决于具体的设计需求和问题领域,需要综合考虑各方面的因素来做出合适的选择。

1.42 函数指针?

函数指针是指向函数的指针变量,它存储了函数的地址,可以用来间接地调用函数。在 C 和 C++ 中,函数指针的语法如下:

return_type (*pointer_name)(parameter_types);

函数指针在 C 和 C++ 中具有多种用途,包括但不限于以下几个方面:

  • 回调函数: 函数指针可以作为参数传递给其他函数,从而实现回调函数的机制。通过回调函数,可以在运行时指定需要调用的函数,从而实现灵活的控制流程。这在事件处理、信号处理等场景中非常常见。

  • 动态选择函数: 函数指针可以根据不同的条件动态地选择调用不同的函数,从而实现更灵活的程序逻辑。这种技术常用于状态机、策略模式等场景。

  • 实现多态性: 在 C++ 中,函数指针可以用于实现简单的多态性,虽然它不如虚函数表那样灵活,但可以实现类似的功能。通过函数指针,可以在运行时选择不同的函数实现,从而实现对象的多态行为。

  • 动态加载库函数: 在动态链接库(DLL)和共享对象(SO)中,函数指针可以用于动态加载库函数,从而实现在运行时加载和调用特定库函数的功能。这在插件系统、动态扩展功能等场景中非常有用。

1.43 为什么要进行内存对齐

尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度.

现在考虑4字节存取粒度的处理器取int类型变量(32位系统),该处理器只能从地址为4的倍数的内存开始读取数据。

假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的连续四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器.这需要做很多工作.

1.44 内存对齐规则

  • 每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认#pragma pack(4),可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。

有效对其值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位。

了解了上面的概念后,我们现在可以来看看内存对齐需要遵循的规则:

  • 基本类型的对齐规则: 基本数据类型在内存中的存储位置通常要求是其自身大小的整数倍。例如,一个字节大小的字符通常需要对齐到地址为1的位置,一个四字节大小的整数通常需要对齐到地址为4的位置。

  • 结构体和类的对齐规则: 结构体和类的对齐规则是其成员中大小最大的成员大小的整数倍。这样做的目的是为了保证结构体或类的成员都能够按照其自身的对齐要求存储,从而保证结构体或类的实例的起始地址是合法的。

  • 指针类型的对齐规则: 指针类型的大小通常与机器的地址长度相等,因此指针类型的对齐规则通常与基本类型的对齐规则相同。

1.45 static变量

静态变量是在程序执行期间存在且只初始化一次的变量,它的生存周期与程序的运行周期相同。在C和C++中,静态变量可以分为两种类型:

  • 局部静态变量(Static Local Variable): 在函数内部声明的静态变量称为局部静态变量。这些变量在函数被调用时被创建,在程序的整个生命周期内保持存在,并且只被初始化一次。局部静态变量的作用域仅限于声明它们的函数内部。

    void func() {
        static int count = 0; // 局部静态变量
        count++;
        printf("Count: %d\n", count);
    }
    
  • 全局静态变量(Static Global Variable): 在函数外部声明的静态变量称为全局静态变量。这些变量在程序启动时被创建,在程序的整个生命周期内保持存在,并且只被初始化一次。全局静态变量的作用域为整个文件,对其他文件不可见(除非使用extern关键字进行声明)。

    static int globalVar = 5; // 全局静态变量
    
  • 静态变量的特点包括:

    • 在内存中分配固定的存储空间,存储在静态存储区域。
    • 生命周期与程序运行周期相同,程序结束时才被销毁。
    • 未初始化时,默认值为0。
    • 局部静态变量在函数内部可见,全局静态变量在整个文件内可见。
    • 静态变量的值在函数调用之间保持不变。
    • 静态变量在程序中的应用包括:存储全局状态、实现单例模式、记录函数调用次数等。由于静态变量的生存周期和作用域特性,它们通常用于需要持久存储数据的情况。

1.46 extern和static

externstatic都是用来限定变量或函数的作用域和链接属性的关键字,但它们的作用方式有所不同。

  • extern:

    • extern 用于声明变量或函数,表示该变量或函数是在其他源文件中定义的,当前源文件中只是进行了声明,实际定义在其他地方。

    • 当使用 extern 声明变量时,编译器不会为该变量分配存储空间,只是告诉编译器该变量是在其他地方定义的。

    • extern 声明通常用于在多个源文件中共享全局变量或函数的声明。

    例如:

    // File1.cpp
    int globalVar = 5; // 定义全局变量
    
    // File2.cpp
    extern int globalVar; // 声明全局变量
    
  • static:

    • static 用于声明静态变量或函数,限定其作用域为当前文件,在其他文件中无法访问。

    • 当使用 static 声明变量或函数时,它们的作用域仅限于当前源文件,对其他源文件不可见。

    • 对于全局变量,static 关键字也可以用于限定其链接属性,使其只能在当前文件中访问,称为文件作用域全局变量。

    例如:

    // File1.cpp
    static int localVar = 10; // 声明文件作用域的静态变量
    
    // File2.cpp
    // 在 File2.cpp 中无法访问 localVar
    

总结:

  • extern 用于声明外部变量或函数,使得在当前文件中可以引用其他文件中定义的全局变量或函数。

  • static 用于限定变量或函数的作用域为当前文件,使得它们只能在当前文件中可见,对其他文件不可见。

扩展:

如果你在头文件中声明了static变量,而后包含这个头文件,你将无法在不同的源文件之间访问该变量。每个源文件会有自己的独立实例,这种方式常用于实现某种形式的封装,以避免全局变量的意外干扰。

示例:

Copy code
// my_header.h
static int my_static_variable = 42;

// file1.c
#include "my_header.h"
void func1() {
    my_static_variable = 100;  // 这是file1.c中的my_static_variable
}

// file2.c
#include "my_header.h"
void func2() {
    my_static_variable = 200;  // 这是file2.c中的my_static_variable
}

在这个例子中,file1.c和file2.c中的my_static_variable是彼此独立的,修改其中一个不会影响另一个。这种行为符合static关键字的意图,即限制变量的作用域和链接范围。

1.47 如何在不使用额外空间的情况下,交换两个数?你有几种方法

  1. 使用加法和减法:
    a = a + b;
    b = a - b;
    a = a - b;
    
  2. 使用异或操作:
    a = a ^ b;
    b = a ^ b;
    a = a ^ b;
    
  3. 使用加法和位移:
    a = a + b;
    b = a - b;
    a = (a - b) >> 1;
    
  4. 使用乘法和除法:
    a = a * b;
    b = a / b;
    a = a / b;
    

这些方法都是在不使用额外空间的情况下,通过数学运算来交换两个数的值。其中,使用异或操作是最常见的方法,因为它既简单又高效。

1.48 strcpy 和 memcpy 的区别

  1. 复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
  2. 复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符”\0”才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。
  3. 用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy

1.49 程序在执行int main(int argc, char *argv[])时的内存结构

参数的含义是程序在命令行下运行的时候,需要输入argc 个参数,每个参数是以char 类型输入的,依次存在数组里面,数组是 argv[],所有的参数在指针

char *指向的内存中,数组的中元素的个数为argc个,第一个参数为程序的名称

1.50 volatile关键字的作用?

volatile关键字用于告诉编译器,被 volatile 修饰的变量可能会在程序执行过程中被意外修改,因此编译器不应该对这些变量进行优化。

具体来说,volatile 关键字的作用包括:

  • 防止编译器优化: 声明变量为 volatile 后,编译器会将对该变量的访问、赋值等操作视为有可能受到外部因素影响,因此不会对这些操作进行优化,确保编译后的代码与源代码中的操作顺序一致。

  • 指示变量可能被多线程或中断处理程序修改: 在多线程或中断处理程序的环境中,某些变量的值可能会被其他线程或中断处理程序修改,而这些修改对于程序的正确执行具有重要影响。通过使用 volatile 关键字,可以告诉编译器这些变量的值可能会在程序执行过程中被修改,因此需要每次访问都重新从内存中读取值,而不是使用缓存中的值。

volatile关键字通常在以下情况下使用:

  1. 硬件操作或内存映射: 当变量代表硬件寄存器或内存映射的状态时,可能会被外部设备或中断处理程序修改。在这种情况下,将变量声明为 volatile 可以确保编译器不会对其进行优化,以防止意外的行为。

  2. 多线程环境下共享变量: 在多线程程序中,共享变量可能会被多个线程同时访问和修改。如果这些变量没有使用同步机制进行保护,那么在读取和写入这些变量时可能会发生竞态条件。在这种情况下,将共享变量声明为 volatile 可以告诉编译器不要对其进行优化,以确保每次访问都是从内存中读取最新值。

  3. 信号处理程序中使用的全局变量: 在信号处理程序中,全局变量的值可能会在程序的正常执行流程之外被修改。为了确保信号处理程序能够正确地读取和修改这些变量,通常会将它们声明为 volatile。

1.51 如果有一个空类,它会默认添加哪些函数?

MyClass(); // 缺省构造函数
MyClass( const MyClass& ); // 拷贝构造函数
~MyClass(); // 析构函数
MyClass& operator=( const MyClass& ); // 赋值运算符

1.52 说一说strcpy、sprintf与memcpy这三个函数的不同之处

  1. 操作对象不同
  • strcpy的两个操作对象均为字符串

  • sprintf的操作源对象可以是多种数据类型,目的操作对象是字符串

  • memcpy的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。

  1. 执行效率不同
    memcpy最高,strcpy次之,sprintf的效率最低。

  2. 实现功能不同

  • strcpy主要实现字符串变量间的拷贝

  • sprintf主要实现其他数据类型格式到字符串的转化

  • memcpy主要是内存块间的拷贝

1.53 如何阻止一个类被实例化?有哪些方法

  1. 将构造函数声明为私有(private): 将类的构造函数声明为私有,这样外部代码就无法直接调用该构造函数实例化对象。但是需要注意的是,类的静态成员函数仍然可以访问私有构造函数,因此可以在类的静态成员函数中实现对象的创建,从而控制对象的实例化。
  2. 删除构造函数的定义: C++11 引入了删除函数的特性,可以通过将构造函数的定义删除来阻止对象的实例化。删除构造函数的定义后,任何尝试调用该构造函数的操作都会导致编译错误。
  3. 将构造函数声明为纯虚函数: 将构造函数声明为纯虚函数,这样派生类必须实现自己的构造函数,而基类则无法被实例化。

1.54 strcpy函数和strncpy函数的区别?哪个函数更安全?

  • strcpy
    • 函数原型:char *strcpy(char *dest, const char *src);
    • 功能:将源字符串(以空字符结尾)拷贝到目标字符串中,包括空字符。
    • 安全性:strcpy 不检查目标字符串的长度,如果源字符串比目标字符串长,可能会发生缓冲区溢出,导致未定义的行为。
  • strncpy
    • 函数原型:char *strncpy(char *dest, const char *src, size_t n);
    • 功能:将源字符串的前 n 个字符拷贝到目标字符串中,如果源字符串长度小于 n,则在目标字符串中用空字符填充剩余部分。
    • 安全性:相比于 strcpy,strncpy 更安全,因为它可以指定拷贝的最大长度,避免了缓冲区溢出的风险。但需要注意,如果源字符串的长度大于 n,则目标字符串不会以空字符结尾,因此可能需要手动添加空字符

因此,从安全性的角度来看,strncpy 更安全一些,但需要确保目标字符串足够大以容纳指定长度的内容。另外,使用 strncpy 时应格外小心,确保目标字符串始终以空字符结尾,以避免字符串操作中出现意外行为。

1.55 写一个比较大小的模板函数

template <typename T>
T max(T a, T b){
  return a > b ? a : b;
}

1.56 成员函数里memset(this,0,sizeof(*this))会发生什么

在成员函数中调用memset(this, 0, sizeof(*this))将会把当前对象所占内存的前sizeof(*this)字节全部设置为零。这样做会将对象的所有成员变量都设置为零值,但这种做法可能会导致一些问题,特别是对于含有虚函数或虚继承的类

具体来说,memset函数是用来将一段内存块设置为指定的值的,但它只是简单地按字节设置,对于非 POD(Plain Old Data)类型的对象,这种简单的内存设置可能会导致对象中的某些数据结构被破坏,从而导致程序出错。

对于含有虚函数的类,调用memset可能会破坏虚函数表(vtable)指针,导致虚函数调用出错。对于含有虚继承的类,调用memset会破坏虚基类指针(vptr),同样会导致程序出错。

因此,一般情况下不建议在成员函数中直接使用 memset 来清零对象的内存,而是应该使用更安全的方式来初始化对象的成员变量。

1.57 C++从代码到可执行程序经历了什么

  1. 编写代码:首先,程序员编写C++源代码,这些代码通常包含在一个或多个源文件中。

  2. 预处理:在编译之前,源代码经过预处理器处理。预处理器执行诸如宏替换条件编译等操作,生成经过预处理的源代码文件。

  3. 编译:编译器将预处理后的源代码转换为汇编代码。这个阶段的任务是将高级语言代码转换为机器语言代码,生成相应的目标文件。

  4. 汇编:汇编器将汇编代码转换为机器可执行的二进制代码。它将每条汇编指令翻译成机器指令,并生成目标文件。

  5. 链接:链接器将生成的目标文件与所需的库文件链接在一起,创建一个完整的可执行程序。它解析程序中使用的符号引用,将它们与符号定义关联起来,并解决外部符号的引用。最终,链接器产生一个可执行文件,其中包含程序的所有指令和数据。

  6. 优化:在编译和链接过程中,还可以应用各种优化技术来提高程序的性能和效率。这些优化包括但不限于代码优化、内联函数、循环优化等。

  7. 生成可执行文件:经过链接和优化后,最终生成可执行文件,即可以在特定平台上运行的二进制文件。这个可执行文件包含了程序的所有代码和数据,可以直接在计算机上执行。

  8. 运行程序:最终,用户可以运行生成的可执行程序,执行程序中定义的操作和功能。

这些步骤通常由编译器和链接器自动完成,用户只需要编写和调试源代码即可。

1.58 友元函数和友元类

在C++中,友元函数和友元类是用来提供对类的私有成员的访问权限的机制,它们可以访问类的私有成员,即使这些成员在类的定义中被声明为私有的也可以。它们的区别在于:

  • 友元函数:友元函数是在类的外部声明的普通函数,可以访问类的所有成员。要声明一个函数为类的友元函数,需要在类的定义中使用 friend 关键字来声明。友元函数并不属于类的成员函数,它们可以通过对象或类名来调用。
    class MyClass {
        friend void friendFunction();
    private:
        int privateMember;
    };
    
    void friendFunction() {
        MyClass obj;
        obj.privateMember = 10; // 可以访问私有成员
    }
    
  • 友元类:友元类是指一个类可以访问另一个类的私有成员。同样地,在类的定义中使用 friend 关键字来声明一个类为友元类。友元类可以访问被声明为友元类的类的所有成员,包括私有成员和保护成员。
    class MyClass {
        friend class FriendClass;
    private:
        int privateMember;
    };
    
    class FriendClass {
    public:
        void accessPrivateMember(MyClass& obj) {
            obj.privateMember = 10; // 可以访问私有成员
        }
    };
    

友元函数和友元类的使用可以提供更灵活的访问控制,但同时也会破坏了类的封装性,因此应该谨慎使用。

1.59 自旋锁

如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

1.60 为什么C++没有垃圾回收机制?这点跟Java不太一样。

  • 实现一个垃圾回收器会带来额外的空间和时间开销。你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark。然后需要单独开辟一个线程在空闲的时候进行free操作。
  • 垃圾回收会使得C++不适合进行很多底层的操作。

二、内存管理

2.1 简要说明C++的内存分区

C++中的内存分区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区和代码区。

  • :在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限

  • :就是那些由 new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收

  • 自由存储区:如果说堆是操作系统维护的一块内存,那么自由存储区就是C++中通过new和delete动态分配和释放对象的抽象概念。需要注意的是,自由存储区和堆比较像,但不等价

  • 全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量和静态变量又分为初始化的和未初始化的,在C++里面没有这个区分了,它们共同占用同一块内存区,在该区定义的变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0

  • 常量存储区:这是一块比较特殊的存储区,这里面存放的是常量,不允许修改

  • 代码区:存放函数体的二进制代码

2.2 什么是内存池,如何实现

内存池(Memory Pool) 是一种内存分配方式。通常我们习惯直接使用new、malloc 等申请内存,这样做的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块, 若内存块不够再继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到提升。

// 内存池
#include "list"

class MemoryPool{
private:
    std::list<char*> m_blocks;
    unsigned int m_bloackNum;
public:
    MemoryPool(size_t size, unsigned int num){
        m_bloackNum = num;
        for(unsigned int i = 0; i < num; ++i){
            m_blocks.push_back(new char[size]);
        }
    }

    ~MemoryPool(){
        for(auto b : m_blocks){
            delete[] b;
            b = nullptr;
        }
    }

    char* allocate(){
        if(m_blocks.empty()){
            throw std::bad_alloc();
        }
        char* b = m_blocks.front();
        m_blocks.pop_front();
        return b;
    }

    void deallocate(void* block){
        m_blocks.push_back(static_cast<char*>(block));
    }
};

2.3 几个this指针的易混问题

  • this指针是什么时候创建的?
    this在成员函数的开始执行前构造,在成员的执行结束后清除。

  • this指针存放在何处?堆、栈、全局变量,还是其他?
    this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。在汇编级别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。不是存放在寄存器就是存放在内存中,它们并不是和高级语言变量对应的。

  • 每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?
    普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译期就能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。正是由于this指针的存在,用来指向不同的对象,从而确保不同对象之间调用相同的函数可以互不干扰。

2.4 内存泄漏的后果?如何监测?解决方法?

  • 后果
    只发生一次小的内存泄漏可能不被注意,但泄漏大量内存的程序将会出现各种症状:性能下降到内存逐渐用完,导致另一个程序失败;
  • 如何监测
    使用专门的内存泄漏检测工具,例如Valgrind、AddressSanitizer(ASan)、LeakSanitizer等,这些工具能够帮助检测程序中的内存泄漏问题,并给出详细的报告和堆栈信息。
    • 使用 -g 编译选项开启符号信息生成,然后使用Valgrind执行测试
    • 编译时使用-fsanitize=address选项来开启AddressSanitizer
      g++ -fsanitize=address -g your_code.cpp -o your_executable
      
      或使用-fsanitize=leak选项来开启LeakSanitizer

  • 解决方法
    智能指针。代码审查。

2.4.1.使用 -g 选项编译程序有什么作用

使用 -g 选项编译程序的作用是生成调试信息(debug information),这些调试信息包含了源代码和目标代码之间的映射关系,以及变量名、函数名等符号信息。具体来说,-g 选项会将调试信息嵌入到可执行文件中,以便在程序执行时能够进行调试和分析。

主要作用包括:

  1. 源代码和目标代码映射关系:调试信息可以帮助调试器将源代码的行号和目标代码的地址进行映射,从而在调试过程中能够准确地定位到源代码的位置。这样可以更方便地进行断点设置、单步调试等操作。

  2. 变量名、函数名等符号信息:调试信息中包含了变量名、函数名等符号信息,使得调试器能够识别和显示这些符号,从而更容易地理解程序的结构和逻辑。

  3. 错误信息定位:当程序发生错误时,调试信息可以帮助调试器准确地定位到错误的源代码位置,从而更容易地进行错误排查和修复。

  4. 内存检测工具支持:一些内存检测工具(如 Valgrind 的 Memcheck)需要在调试信息的基础上进行分析和检测,因此编译时需要使用 -g 选项生成调试信息。

2.5 在成员函数中调用delete this会出现什么问题?对象还可以使用吗?

在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

为什么是不可预期的问题?
delete this之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?这个问题牵涉到操作系统的内存管理策略。delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。

2.6 如果在类的析构函数中调用delete this,会发生什么?

可能会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

2.7 请说一下以下几种情况下,下面几个类的大小各是多少?

class A {};
int main(){
  cout<<sizeof(A)<<endl;// 输出 1;
  A a; 
  cout<<sizeof(a)<<endl;// 输出 1;
  return 0;
}
/*空类的大小是1, 在C++中空类会占一个字节,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。当该空白类作为基类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。这就是所谓的空白基类最优化。

空类的实例大小就是类的大小,所以sizeof(a)=1字节,如果a是指针,则sizeof(a)就是指针的大小,即4字节。
*/
class A { virtual void Fun(){} };
int main(){
  cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
  A a; 
  cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
  return 0;
}
// 因为有虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是4字节
class A { static int a; };
int main(){
  cout<<sizeof(A)<<endl;// 输出 1;
  A a; 
  cout<<sizeof(a)<<endl;// 输出 1;
  return 0;
}
// 静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小
class A { int a; };
int main(){
  cout<<sizeof(A)<<endl;// 输出 4;
  A a; 
  cout<<sizeof(a)<<endl;// 输出 4;
  return 0;
}

2.8 类对象的大小受哪些因素影响?

  • 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;

  • 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的;

  • 虚函数的话,会在类对象插入vptr指针,加上指针大小;

  • 当该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展。

三、C++11新标准

3.1 C++ 11有哪些新特性?

  • nullptr替代 NULL
  • 引入了 auto 和 decltype 这两个关键字实现了类型推导
  • 基于范围的 for 循环for(auto& i : res){}
  • 类和结构体的中初始化列表
  • Lambda 表达式(匿名函数)
  • std::forward_list(单向链表)
  • 右值引用和move语义

3.2 智能指针的原理、常用的智能指针及实现

  • 原理
    智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源

  • 常用的智能指针

    • unique_ptr
      它是一种独占所有权的智能指针,即它不能被复制或拷贝。当unique_ptr被销毁时,它所指向的对象会被自动释放。这使得unique_ptr非常适合管理动态分配的单个对象。

    • shared_ptr
      它是一种共享所有权的智能指针,可以被多个shared_ptr共享同一个对象。它使用引用计数来跟踪指向对象的指针数量,并在没有指针指向对象时自动释放对象。当最后一个shared_ptr销毁时,对象会被释放。

    • weak_ptr
      它是一种弱引用的智能指针,用于解决shared_ptr的循环引用问题。weak_ptr可以从shared_ptr创建,但不会增加引用计数。因此,它不会阻止对象的销毁。通常在需要访问shared_ptr所管理的对象,但又不需要拥有所有权时使用。

    • auto_ptr(C++11之前)
      它是早期版本的C++标准中提供的智能指针,类似于unique_ptr,但具有一些缺陷,并在C++11中被std::unique_ptr取代。auto_ptr没有明确定义的行为来处理拷贝和赋值操作,因此容易导致问题。

  • 智能指针shared_ptr代码实现

template<typename T>
class SharedPtr
{
public:
    SharedPtr(T* ptr = NULL):_ptr(ptr), _pcount(new int(1))
    {}

    SharedPtr(const SharedPtr& s):_ptr(s._ptr), _pcount(s._pcount){
        (*_pcount)++;
    }

    SharedPtr<T>& operator=(const SharedPtr& s){
        if (this != &s)
        {
            if (--(*(this->_pcount)) == 0)
            {
                delete this->_ptr;
                delete this->_pcount;
            }
            _ptr = s._ptr;
            _pcount = s._pcount;
            (*_pcount)++;
        }
        return *this;
    }
    T& operator*()
    {
        return *(this->_ptr);
    }
    T* operator->()
    {
        return this->_ptr;
    }
    ~SharedPtr()
    {
        --(*(this->_pcount));
        if (*(this->_pcount) == 0)
        {
            delete _ptr;
            _ptr = NULL;
            delete _pcount;
            _pcount = NULL;
        }
    }
private:
    T* _ptr;
    int* _pcount;//指向引用计数的指针
};

3.3 lambda函数

[capture-list] (parameter-list) -> return-type {
    // 函数体
}
  • capture-list:捕获列表,用于捕获外部变量。可以是值传递方式([var])或引用传递方式([&var])。还可以使用[=]表示以值传递方式捕获所有外部变量,或使用[&]表示以引用传递方式捕获所有外部变量。
  • parameter-list:参数列表,与普通函数的参数列表类似。
  • return-type:返回类型,可以省略,编译器会根据返回语句自动推断返回类型。
  • {}:函数体,与普通函数的函数体类似。
#include <iostream>

int main() {
    // Lambda表达式求两个数的和
    auto sum = [](int a, int b) -> int {
        return a + b;
    };

    // 调用lambda表达式
    int result = sum(3, 4);
    std::cout << "Sum: " << result << std::endl;

    return 0;
}

3.4 shared_ptr的循环引用问题

当两个对象相互引用并使用shared_ptr时,就会形成循环引用。例如,考虑一个简单的场景:

#include <memory>
#include <iostream>

class B; // 前置声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    A() { std::cout << "A constructor" << std::endl; }
    ~A() { std::cout << "A destructor" << std::endl; }
};

class B {
public:
    std::shared_ptr<A> a_ptr;
    B() { std::cout << "B constructor" << std::endl; }
    ~B() { std::cout << "B destructor" << std::endl; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    
    a->b_ptr = b;
    b->a_ptr = a;

    return 0;
}

在这个例子中,类 A 拥有一个指向类 Bshared_ptr,而类 B 拥有一个指向类 Ashared_ptr。这样就形成了循环引用。

为了避免循环引用,我们可以改用 weak_ptr 来解决这个问题:

#include <memory>
#include <iostream>

class B; // 前置声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    A() { std::cout << "A constructor" << std::endl; }
    ~A() { std::cout << "A destructor" << std::endl; }
};

class B {
public:
    std::weak_ptr<A> a_weak_ptr;  // 使用 weak_ptr
    B() { std::cout << "B constructor" << std::endl; }
    ~B() { std::cout << "B destructor" << std::endl; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    
    a->b_ptr = b;
    b->a_weak_ptr = a;  // 使用 weak_ptr

    return 0;
}

通过将类 B 中指向类 A 的指针改为 weak_ptr,我们成功地避免了循环引用问题。

四、STL

部分面试题

4.1 什么是STL

STL通常指的是“标准模板库”(Standard Template Library)。这是C++编程语言的一部分,包含一组用于常见数据结构和算法的模板库。这些模板库是泛型的,允许程序员使用单个代码库来处理各种数据类型。

广义上讲,STL分为3类:Algorithm(算法)、Container(容器)和Iterator(迭代器),容器和算法通过迭代器可以进行无缝地连接。

详细的说,STL由6部分组成:容器(Container)、算法(Algorithm)、 迭代器(Iterator)、仿函数(Function object)、容器配接器(Adaptor)、空间配制器(Allocator)。

1. 容器(Containers)

容器是STL中用于存储和组织数据的基础结构。它们提供了一系列操作,如插入、删除、查找、迭代等。STL中的主要容器包括:

  • 序列容器(Sequence Containers):这些容器按顺序存储元素。

    • vector: 动态数组,提供随机访问,支持快速添加和删除末尾的元素。
    • list: 双向链表,适合在中间插入或删除元素。
    • deque: 双端队列,支持在头部和尾部快速添加或删除元素。
  • 关联容器(Associative Containers):这些容器基于键来存储和检索数据。

    • set: 集合,存储唯一元素,元素按特定顺序排列。
    • map: 映射,键-值对,按键排序。
    • multiset: 允许重复元素的集合。
    • multimap: 允许重复键的映射。
  • 无序关联容器(Unordered Associative Containers):这些容器使用哈希表进行数据存储。

    • unordered_set: 无序集合,元素顺序不固定。
    • unordered_map: 无序映射,键-值对,元素顺序不固定。
    • unordered_multiset: 无序多重集合。
    • unordered_multimap: 无序多重映射。

2. 迭代器(Iterators)

迭代器是用于遍历容器中元素的对象。不同的迭代器支持不同程度的访问能力。主要的迭代器类型包括:

  • 正向迭代器(Forward Iterator):可以从头到尾顺序遍历。

  • 双向迭代器(Bidirectional Iterator):支持向前和向后遍历。

  • 随机访问迭代器(Random Access Iterator):支持随机访问元素,如vector中的迭代器。

  • 输入迭代器(Input Iterator):只能向前读取数据。

  • 输出迭代器(Output Iterator):只能向前写入数据。

迭代器的设计与指针类似,因此可以使用类似于指针的语法进行迭代。

PS: 迭代器类型不是由程序员显式选择,而是由容器决定的 直接使用就好:auto it = xxx.begin();

3. 算法(Algorithms)

STL提供了大量的算法,用于操作容器中的数据。这些算法可以应用于不同的容器类型,因为它们是泛型的。常见的STL算法包括:

  • 排序算法:sort, stable_sort(稳定排序), partial_sort(部分排序), nth_element(将最好的n个元素放在widgets的前部,但并不关心它们的具体排列顺序)等。
  • 查找算法:find, binary_search, count, equal_range等。
  • 修改算法:copy, fill, generate, remove, replace等。
  • 排列算法:next_permutation, prev_permutation等。
  • 其他算法:accumulate, transform, rotate, reverse, shuffle等。

这些算法以泛型方式实现,可以与迭代器结合使用。

仿函数

行为类函数,可作为算法的某种策略,从实现角度看,仿函数是一种重载了operator()的class或class template。一般函数指针可视为狭义的仿函数。

  • 与普通函数:仿函数与普通函数不同,它们可以保存状态,而普通函数通常是无状态的。
  • 与lambda表达式:仿函数与lambda表达式相似,但lambda表达式通常更简洁,适用于简短的函数体,而仿函数适合更复杂的操作和持有状态。

和golang的闭包有相似之处。

容器配接器

一种用来修饰容器或者仿函数或迭代器接口的东西。比如queuestack,看着像容器,其实就是deque包了一层皮。

空间配置器

负责空间配置与管理。从实现角度看,配置器是一个实现了动态空间配置、空间管理、空间释放额class template。用户可以自定义分配策略。

4.2 使用智能指针管理内存资源,RAII是怎么回事?

RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。
因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。

智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。
毫不夸张的来讲,有了智能指针,代码中几乎不需要再出现delete了。

4.3 迭代器:++it、it++哪个好,为什么

前置返回一个引用,后置返回一个对象

  • ++i实现代码为:
int& operator++()
{
  *this += 1;
  return *this;
} 

前置不会产生临时对象,后置必须产生临时对象,临时对象会导致效率降低

  • i++实现代码为:
int operator++(int)                 
{
  int temp = *this;                   
  ++*this;                       
  return temp;                  
} 

对于大多数现代编译器来说,在合适的情况下,它们可能会对代码进行优化,包括将后置自增 it++ 转换为前置自增 ++it。但是,这种优化是否发生取决于编译器的实现以及优化级别。

4.4 右值

C++11引入了右值引用,用来支持移动语义和完美转发。

  1. 移动语义:传统的复制操作需要额外的时间和空间,而有了移动语义后,可以直接将资源(如内存)从一个对象转移到另一个对象,而不必创建并删除临时对象。这对于大对象或者拥有独占所有权资源的对象特别有用。例如,unique_ptr和std::vector等STL容器就利用了移动语义实现了高效的操作。
  2. 完美转发:在函数模板中,我们想把参数原封不动地传递给其他函数。由于传参可能存在值传递、左值引用、常量左值引用、右值引用等情况,为了保证参数的属性和类型不发生变化,我们需要使用std::forward实现完美转发。

右值引用主要用于两种场景:一是对象的移动(Move),二是万能引用(Forwarding Reference)。对于第一种情况,它是为了解决对象的复制效率问题;对于第二种情况,则是为了实现参数的完美传递,避免不必要的拷贝。

4.5 简单说一下traits技法

Traits 技法是一种在编程中使用的模式,用于实现泛型编程和类型参数化。它的基本思想是将类型的某些特性(或特征)提取出来,并将其定义为独立的类或结构体,然后通过模板或泛型编程技术将这些特性与具体的类型进行关联。

Traits 技法的主要目的是将类型的行为和属性与类型本身解耦,使得代码更具灵活性和可重用性。通过定义一组通用的接口或函数,可以将这些接口或函数应用于不同的类型,而无需对每种类型都进行单独的实现。

在 C++ 中,Traits 技法通常通过模板编程来实现。可以定义一组模板类或结构体,用于描述类型的特性,然后在模板函数或模板类中使用这些特性。通过特化或偏特化,可以针对不同类型提供不同的实现,从而实现更高级的泛型编程。

例如,在 C++ 中,STL 中的迭代器就是一种典型的 Traits 技法的应用。迭代器通过一组接口描述了迭代器的特性,然后通过模板函数和模板类来处理不同类型的迭代器,而无需知道具体的迭代器类型。这种设计使得算法可以与任何支持相应接口的迭代器一起使用,从而提高了代码的灵活性和可重用性。

4.6 STL的两级空间配置器

STL(Standard Template Library)的空间配置器是用于在堆上分配内存的组件,它们被用来支持STL容器(如vector、list、map等)的内存管理。STL中的空间配置器通常包括单级空间配置器和双级空间配置器。

双级空间配置器由两部分组成:

  1. 第一级空间配置器(第一级分配器):使用malloc和free等全局内存分配函数来分配内存。这一级的分配器适用于大块内存的分配,它通过调用全局的malloc和free函数来分配和释放内存,通常是通过模板参数指定的分配策略来实现。

  2. 第二级空间配置器(第二级分配器):由于第一级空间配置器在处理小块内存时效率较低,因此第二级空间配置器通常会对小块内存进行优化,它通常使用内存池等技术来管理和分配小块内存,以提高内存分配和释放的效率。

双级空间配置器的设计可以在大块内存和小块内存之间取得平衡,从而在不同大小的内存分配场景中提供更好的性能和效率。这种设计使得STL容器在不同的内存分配场景下都能够有效地工作,并且具有较好的性能表现。

4.7 vector与list的区别与应用?怎么找某vector或者list的倒数第二个元素

  • vector数据结构

    • vector和数组类似,拥有一段连续的内存空间,并且起始地址不变。因此能高效的进行随机存取,时间复杂度为o(1);但因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。

    • 当数组中内存空间不够时,会重新申请一块内存空间并进行内存拷贝。连续存储结构:vector是可以实现动态增长的对象数组,支持对数组高效率的访问和在数组尾端的删除和插入操作,在中间和头部删除和插入相对不易,需要挪动大量的数据。

    • 它与数组最大的区别就是vector不需程序员自己去考虑容量问题,库里面本身已经实现了容量的动态增长,而数组需要程序员手动写入扩容函数进形扩容。

    • 找某vector或者list的倒数第二个元素使用迭代器就行。

  • list数据结构

    • list是由双向链表实现的,因此内存空间是不连续的。只能通过指针访问数据,所以list的随机存取非常没有效率,时间复杂度为o(n);但由于链表的特点,能高效地进行插入和删除。非连续存储结构:list是一个双链表结构,支持对链表的双向遍历。每个节点包括三个信息:元素本身,指向前一个元素的节点(prev)和指向下一个元素的节点(next)。因此list可以高效率的对数据元素任意位置进行访问和插入删除等操作。由于涉及对额外指针的维护,所以开销比较大。
      区别:

vector的随机访问效率高,但在插入和删除时(不包括尾部)需要挪动数据,不易操作。
list的访问要遍历整个链表,它的随机访问效率低。但对数据的插入和删除操作等都比较方便,改变指针的指向即可。

从遍历上来说,list是单向的,vector是双向的。
vector中的迭代器在使用后就失效了,而list的迭代器在使用之后还可以继续使用。
int mySize = vec.size();vec.at(mySize -2);

list不提供随机访问,所以不能用下标直接访问到某个位置的元素,要访问list里的元素只能遍历,不过你要是只需要访问list的最后N个元素的话,可以用反向迭代器来遍历

4.8 reserve与resize

在 C++ 中,reserve() 和 resize() 是与标准库中的容器(例如 vector、list、deque 等)相关的两个重要函数,它们的作用如下:

  • reserve():
    这个函数用于预留容器的存储空间,但不改变容器的大小。预留的存储空间可以用来避免容器在添加新元素时频繁地重新分配内存,从而提高性能。reserve() 函数的参数是要预留的元素个数。如果当前容器的容量小于指定的元素个数,reserve() 函数会分配额外的内存空间以容纳指定数量的元素。

  • resize():
    这个函数用于更改容器中元素的数量。当调用 resize() 时,如果指定的大小比当前大小小,则容器中的元素数量会减少到指定大小;如果指定的大小比当前大小大,则容器会扩展以容纳额外的元素。如果容器扩展,新添加的元素将以容器元素的默认值进行初始化。

在调用 reserve() 函数之后,如果你直接访问预留的空间,将会导致未定义行为,因为这些空间并没有被初始化为有效的元素。

4.9 STL迭代器如何实现

  1. 迭代器是一种抽象的设计理念,通过迭代器可以在不了解容器内部原理的情况下遍历容器,除此之外,STL中迭代器一个最重要的作用就是作为容器与STL算法的粘合剂。

  2. 迭代器的作用就是提供一个遍历容器内部所有元素的接口,因此迭代器内部必须保存一个与容器相关联的指针,然后重载各种运算操作来遍历,其中最重要的是*运算符与->运算符,以及++、–等可能需要重载的运算符重载。这和C++中的智能指针很像,智能指针也是将一个指针封装,然后通过引用计数或是其他方法完成自动释放内存的功能。

  3. 最常用的迭代器的相应型别有五种:value type、difference type、pointer、reference、iterator catagoly;

4.10 map插入方式有哪几种?

  1. 用insert函数插入pair数据:
mapStudent.insert(pair<int, string>(1, "student_one")); 
  1. 用insert函数插入value_type数据:
mapStudent.insert(map<int, string>::value_type (1, "student_one"));
  1. 在insert函数中使用make_pair()函数:
mapStudent.insert(make_pair(1, "student_one")); 
  1. 用数组方式插入数据:
mapStudent[1] = "student_one"; 

4.20 map中[]与find的区别?

  • map的下标运算符[]的作用是:将关键码作为下标去执行查找,并返回对应的值;如果不存在这个关键码,就将一个具有该关键码和值类型的默认值的项插入这个map。

  • map的find函数:用关键码执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器。

4.21 STL中list与deque之间的区别

STL(标准模板库)中的list(双向链表)和deque(双端队列)是两种不同的容器,它们有以下区别:

  • 底层数据结构:

    • list:
      采用双向链表作为底层数据结构。每个节点包含数据以及指向前驱节点和后继节点的指针。
    • deque:
      采用分段数组(双端队列)作为底层数据结构。它由多个较小的数组块组成,每个数组块都包含一定数量的元素,并通过指针连接起来,形成一个逻辑上的双端队列。
  • 访问元素的效率:

    • list:
      由于采用链表结构,list在任意位置插入或删除元素的效率都很高,为O(1)。但是,随机访问元素的效率较低,为O(n),因为需要遍历链表找到指定位置的元素。
    • deque:
      由于采用分段数组结构,deque支持高效的随机访问,其时间复杂度为O(1)。同时,deque还支持在两端进行快速插入和删除操作,时间复杂度也为O(1)。
  • 内存分配方式:

    • list:
      由于采用链表结构,每次插入或删除元素时都需要动态分配内存,因此在频繁插入或删除大量元素时,可能会产生较多的内存分配和释放操作,导致性能下降。
    • deque:
      由于采用分段数组结构,内存是分块预先分配的,因此在插入或删除元素时不需要频繁进行内存分配和释放操作,性能相对较好。
  • 空间占用:

    • list:
      由于每个节点都需要额外的指针来指向前驱节点和后继节点,因此相比于deque,list的空间占用通常更大。
    • deque:
      由于采用分段数组结构,每个数组块的大小是固定的,因此deque的空间占用更为灵活,不会产生额外的指针开销。

根据以上区别,选择使用list还是deque取决于你的具体需求。如果需要频繁进行插入和删除操作,且不关心随机访问的效率,可以选择list;如果需要支持高效的随机访问,并且在两端进行快速插入和删除操作,可以选择deque。

4.22 STL中的allocator、deallocator

第一级配置器直接使用malloc()、free()和relloc(),第二级配置器视情况采用不同的策略:当配置区块超过128bytes时,视之为足够大,便调用第一级配置器;当配置器区块小于128bytes时,为了降低额外负担,使用复杂的内存池整理方式,而不再用一级配置器;

第二级配置器主动将任何小额区块的内存需求量上调至8的倍数,并维护16个free-list,各自管理大小为8~128bytes的小额区块;

空间配置函数allocate(),首先判断区块大小,大于128就直接调用第一级配置器,小于128时就检查对应的free-list。如果free-list之内有可用区块,就直接拿来用,如果没有可用区块,就将区块大小调整至8的倍数,然后调用refill(),为free-list重新分配空间;

空间释放函数deallocate(),该函数首先判断区块大小,大于128bytes时,直接调用一级配置器,小于128bytes就找到对应的free-list然后释放内存。

4.23 常见容器性质总结?

1.vector 底层数据结构为数组 ,支持快速随机访问

2.list 底层数据结构为双向链表,支持快速增删

3.deque 底层数据结构为一个中央控制器和多个缓冲区,详细见STL源码剖析P146,支持首尾(中间不能)快速增删,也支持随机访问

deque是一个双端队列(double-ended queue),也是在堆中保存内容的.它的保存形式如下:

[堆1] –> [堆2] –>[堆3] –> …

每个堆保存好几个元素,然后堆和堆之间有指针指向,看起来像是list和vector的结合品.

4.stack 底层一般用list或deque实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时

5.queue 底层一般用list或deque实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时(stack和queue其实是适配器,而不叫容器,因为是对容器的再封装)

6.priority_queue 的底层数据结构一般为vector为底层容器,堆heap为处理规则来管理底层容器实现

7.set 底层数据结构为红黑树,有序,不重复

8.multiset 底层数据结构为红黑树,有序,可重复

9.map 底层数据结构为红黑树,有序,不重复

10.multimap 底层数据结构为红黑树,有序,可重复

11.unordered_set 底层数据结构为hash表,无序,不重复

12.unordered_multiset 底层数据结构为hash表,无序,可重复

13.unordered_map 底层数据结构为hash表,无序,不重复

14.unordered_multimap 底层数据结构为hash表,无序,可重复

4.24 说一下STL每种容器对应的迭代器

| 容器 | 迭代器 |
| — | — |
| vector、deque | 随机访问迭代器 |
| stack、queue、priority_queue | 无 |
| list、(multi)set/map | 双向迭代器 |
| unordered_(multi)set/map、forward_list | 前向迭代器 |

4.25 STL中迭代器失效的情况有哪些?

以vector为例:

插入元素:

  1. 尾后插入:size < capacity时,首迭代器不失效尾迭代失效(未重新分配空间),size == capacity时,所有迭代器均失效(需要重新分配空间)。

  2. 中间插入:中间插入:size < capacity时,首迭代器不失效但插入元素之后所有迭代器失效,size == capacity时,所有迭代器均失效。

删除元素:

  1. 尾后删除:只有尾迭代失效。

  2. 中间删除:删除位置之后所有迭代失效。

deque 和 vector 的情况类似,

而list双向链表每一个节点内存不连续, 删除节点仅当前迭代器失效,erase返回下一个有效迭代器;

map/set等关联容器底层是红黑树删除节点不会影响其他节点的迭代器, 使用递增方法获取下一个迭代器 mmp.erase(iter++);

unordered_(hash) 迭代器意义不大, rehash之后, 迭代器应该也是全部失效.

4.26 hashtable中解决冲突有哪些方法?

记住前三个:

  • 线性探测
    使用hash函数计算出的位置如果已经有元素占用了,则向后依次寻找,找到表尾则回到表头,直到找到一个空位

  • 开链
    每个表格维护一个list,如果hash函数计算出的格子相同,则按顺序存在这个list中

  • 再散列
    发生冲突时使用另一种hash函数再计算一个地址,直到不冲突

  • 二次探测
    使用hash函数计算出的位置如果已经有元素占用了,按照$1^2$、$2^2$、$3^2$…的步长依次寻找,如果步长是随机数序列,则称之为伪随机探测

  • 公共溢出区
    一旦hash函数计算的结果相同,就放入公共溢出区

五、其余问题

5.1 C++多态

C++中,多态性主要通过两种方式实现:编译时多态(静态多态)和运行时多态(动态多态)。这两种多态的机制、特点和用途有所不同。

  • 编译时多态(静态多态):
    编译时多态是在程序编译阶段实现的多态性。主要通过函数重载、运算符重载和模板来实现。

函数重载: 同一个作用域内存在多个同名函数,但它们的参数类型或数量不同。根据调用时实际传递的参数类型和数量,编译器决定调用哪个函数。
运算符重载: 允许定义或重新定义大部分C++内置的运算符,使得它们可以根据操作数的类型执行不同的操作。
编译时多态的决策是在编译时做出的,因此它不支持在运行时根据对象的实际类型来选择相应的成员函数。

  • 运行时多态(动态多态): 运行时多态是在程序运行阶段实现的多态性。它主要通过虚函数和继承来实现。

虚函数: 通过在基类中声明虚函数,允许派生类中重写该函数。当通过基类的指针或引用调用虚函数时,实际执行的是与指针或引用所指对象的实际类型相对应的函数版本。
抽象类和纯虚函数: 抽象类至少包含一个纯虚函数。纯虚函数在基类中没有实现,派生类必须重写这个函数。抽象类不能被实例化。

5.2 什么时候的析构函数必须写成虚函数

一般情况下类的析构函数里面都是释放内存资源,而析构函数不被调用的话就会造成内存泄漏。这样做是为了当用一个基类的指针删除一个派生类的对象时,派生类的析构函数会被调用。

当然,并不是要把所有类的析构函数都写成虚函数。因为当类里面有虚函数的时候,编译器会给类添加一个虚函数表,里面来存放虚函数指针,这样就会增加类的存储空间。所以,只有当一个类被用来作为基类的时候,才把析构函数写成虚函数。

5.3 构造函数能否声明为虚函数或者纯虚函数,析构函数呢?

  • 析构函数:
    • 析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。
      只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。
    • 析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。
  • 构造函数:
    • 根据《effective C++》的条款09:绝不在构造和析构过程中调用虚函数可知,在构造函数中虽然可以调用虚函数,但是强烈建议不要这样做。因为基类的构造的过程中,虚函数不能算作是虚函数。若构造函数中调用虚函数,可能会导致不确定行为的发生.
    • 虚函数对应一个vtable(虚函数表),类中存储一个vptr指向这个vtable。如果构造函数是虚函数,就需要通过vtable调用,可是对象没有初始化就没有vptr,无法找到vtable,所以构造函数不能是虚函数。

纯虚函数:只要一个类中至少有一个纯虚函数,这个类就成为抽象类。抽象类不能直接实例化,它的主要作用是作为其他类的基类,提供一个接口或基本结构供子类继承和实现。

5.4 目标文件存储结构

功能
File Header 文件头,描述整个文件的文件属性(包括文件是否可执行、是静态链接或动态连接及入口地址、目标硬件、目标操作系统等)
.text section 代码段,执行语句编译成的机器代码
.data section 数据段,已初始化的全局变量和局部静态变量
.bss section BSS 段(Block Started by Symbol),未初始化的全局变量和局部静态变量(因为默认值为 0,所以只是在此预留位置,不占空间)
.rodata section 只读数据段,存放只读数据,一般是程序里面的只读变量(如 const 修饰的变量)和字符串常量
.comment section 注释信息段,存放编译器版本信息
.note.GNU-stack section 堆栈提示段

其他段略

5.5 基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间

  • C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区
  • 虚函数则位于代码段(.text),也就是C++内存模型中的代码区
  • 在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面

5.6 模板函数和模板类的特例化

  • 引入原因:
    编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化

  • 定义:
    对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上

(1)模板函数特例化

必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参,举例如下:

template<typename T> //模板函数
int compare(const T &v1,const T &v2)
{
    if(v1 > v2) return -1;
    if(v2 > v1) return 1;
    return 0;
}
//模板特例化,满足针对字符串特定的比较,要提供所有实参,这里只有一个T
template<> 
int compare(const char* const &v1,const char* const &v2)
{
    return strcmp(p1,p2);
}
  • 本质
    特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。例如,此处如果是compare(3,5),则调用普通的模板,若为compare(“hi”,”haha”)则调用特例化版本(因为这个cosnt char*相对于T,更匹配实参类型),注意二者函数体的语句不一样了,实现不同功能。

  • 注意
    模板及其特例化版本应该声明在同一个头文件中,且所有同名模板的声明应该放在前面,后面放特例化版本。

(2)类模板特例化

原理类似函数模板,不过在类中,我们可以对模板进行特例化,也可以对类进行部分特例化。对类进行特例化时,仍然用template<>表示是一个特例化版本,例如:

template<>
class hash<sales_data>
{
    size_t operator()(sales_data& s);
    //里面所有T都换成特例化类型版本sales_data
    //按照最佳匹配原则,若T != sales_data,就用普通类模板,否则,就使用含有特定功能的特例化版本。
};

类模板的部分特例化

不必为所有模板参数提供实参,可以指定一部分而非所有模板参数,一个类模板的部分特例化本身仍是一个模板,使用它时还必须为其特例化版本中未指定的模板参数提供实参(特例化时类名一定要和原来的模板相同,只是参数类型不同,按最佳匹配原则,哪个最匹配,就用相应的模板)

特例化类中的部分成员

可以特例化类中的部分成员函数而不是整个类,举个例子:

template<typename T>
class Foo
{
    void Bar();
    void Barst(T a)();
};

template<>
void Foo<int>::Bar()
{
    //进行int类型的特例化处理
    cout << "我是int型特例化" << endl;
}

Foo<string> fs;
Foo<int> fi;//使用特例化
fs.Bar();//使用的是普通模板,即Foo<string>::Bar()
fi.Bar();//特例化版本,执行Foo<int>::Bar()
//Foo<string>::Bar()和Foo<int>::Bar()功能不同

5.7 模板定义和实现可不可以不写在一个文件里面?为什么?

模板定义很特殊。由template<…>处理的任何东西都意味着**编译器在当时不为它分配存储空间(没有具体的函数时不会对模板实例化)**,它一直处于等待状态直到被一个模板实例告知。在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。

定义一个类一般都是在头文件中进行类声明,在cpp文件中实现,但使用模板时应注意目前的C++编译器还无法分离编译,最好将实现代码和声明代码均放在头文件中。如:

// test.h
template <class T>
class CTest
{  
public:        
  T& GetValue();        
protected:
  T m_Value;
};
 
// test.cpp
template <class T>
T& CTest<T>::GetValue()
{    
   return m_Value;  
}

在这儿test.cpp中的内容应放在test.h中,否则在生成最终可执行程序时就会出现错误(在链接时会出错)。因为在编译时模板并不能生成真正的二进制代码,而是在编译调用模板类或函数的CPP文件时才会去找对应的模板声明和实现,在这种情况下编译器是不知道实现模板类或函数的CPP文件的存在,所以它只能找到模板类或函数的声明而找不到实现,而只好创建一个符号寄希望于链接程序找地址。但模板类或函数的实现并不能被编译成二进制代码,结果链接程序找不到地址只好报错了。

5.8 构造函数、析构函数、虚函数可否声明为内联函数

首先,将这些函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联。

register关键字:这个关键字请求编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率

构造函数和析构函数声明为内联函数是没有意义的

《Effective C++》中所阐述的是:将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。其次,class中的函数默认是inline型的,编译器也只是有选择性的inline,将构造函数和析构函数声明为内联函数是没有什么意义的。

将虚函数声明为inline,要分情况讨论

有的人认为虚函数被声明为inline,但是编译器并没有对其内联,他们给出的理由是inline是编译期决定的,而虚函数是运行期决定的,即在不知道将要调用哪个函数的情况下,如何将函数内联呢?

上述观点看似正确,其实不然,如果虚函数在编译器就能够决定将要调用哪个函数时,就能够内联,那么什么情况下编译器可以确定要调用哪个函数呢,答案是当用对象调用虚函数(此时不具有多态性)时,就内联展开

综上,当是指向派生类的指针(多态性)调用声明为inline的虚函数时,不会内联展开;当是对象本身调用虚函数时,会内联展开,当然前提依然是函数并不复杂的情况下。

5.9 C++模板是什么,你知道底层怎么实现的?

  • 编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。

  • 这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。

5.10 构造函数和析构函数可以调用虚函数吗,为什么

在C++中,提倡不在构造函数和析构函数中调用虚函数;

构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;

因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;

析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。

5.11 如何解决菱形继承

使用虚继承:

虚拟继承是多重继承中特有的概念。虚拟基类是为解决多重继承而出现的。 如:类D继承自类B1、B2,而类B1、B2都继 承自类A,因此在类D中两次出现类A中的变量和函数。为了节省内存空间,可以将B1、B2对A的继承定义为虚拟继承,而A就成了虚拟基类,虚拟继承在一般的应用中很少用到,所以也往往被忽视,这也主要是因为在C++中,多重继承是不推荐的,也并不常用,而一旦离开了多重继承,虚拟继承就完全失去了存在的必要因为这样只会降低效率和占用更多的空间。

虚继承的特点是,在任何派生类中的virtual基类总用同一个(共享)对象表示,

#include <iostream>

class Base {
public:
    int value;
    Base() : value(0) {}
};

class Derived1 : virtual public Base {};
class Derived2 : virtual public Base {};
class Final : public Derived1, public Derived2 {};

int main() {
    Final f;
    f.value = 42;  // 只有一个Base实例,不再需要指定作用域

    std::cout << "f.value = " << f.value << std::endl;  // 输出:42
}

5.12 将字符串“hello world”从开始到打印到屏幕上的全过程?

  1. 用户告诉操作系统执行HelloWorld程序(通过键盘输入等)

  2. 操作系统:找到helloworld程序的相关信息,检查其类型是否是可执行文件;并通过程序首部信息,确定代码和数据在可执行文件中的位置并计算出对应的磁盘块地址。

  3. 操作系统:创建一个新进程,将HelloWorld可执行文件映射到该进程结构,表示由该进程执行helloworld程序。

  4. 操作系统:为helloworld程序设置cpu上下文环境,并跳到程序开始处。

  5. 执行helloworld程序的第一条指令,发生缺页异常

  6. 操作系统:分配一页物理内存,并将代码从磁盘读入内存,然后继续执行helloworld程序

  7. helloword程序执行puts函数(系统调用),在显示器上写一字符串

  8. 操作系统:找到要将字符串送往的显示设备,通常设备是由一个进程控制的,所以,操作系统将要写的字符串送给该进程

  9. 操作系统:控制设备的进程告诉设备的窗口系统,它要显示该字符串,窗口系统确定这是一个合法的操作,然后将字符串转换成像素,将像素写入设备的存储映像区

  10. 视频硬件将像素转换成显示器可接收和一组控制数据信号

  11. 显示器解释信号,激发液晶屏

  12. OK,我们在屏幕上看到了HelloWorld

5.13 为什么拷贝构造函数必须传引用不能传值?

拷贝构造函数用来初始化一个非引用类类型对象,如果用传值的方式进行传参数,那么构造实参需要调用拷贝构造函数,而拷贝构造函数需要传递实参,所以会一直递归。

5.14 虚函数的调用关系

this -> vptr -> vtable ->virtual function

5.15 说一说你了解到的移动构造函数?

  1. 有时候我们会遇到这样一种情况,我们用对象a初始化对象b后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;

  2. 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制;

  3. C++引入了移动构造函数,专门处理这种,用a初始化b后,就将a析构的情况;

  4. 与拷贝类似,移动也使用一个对象的值设置另一个对象的值。但是,又与拷贝不同的是,移动实现的是对象值真实的转移(源对象到目的对象):源对象将丢失其内容,其内容将被目的对象占有。移动操作的发生的时候,是当移动值的对象是未命名的对象的时候。这里未命名的对象就是那些临时变量,甚至都不会有名称。典型的未命名对象就是函数的返回值或者类型转换的对象。使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用,因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:当使用一个临时变量对象进行构造初始化的时候,调用移动构造函数。类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作;

Example6 (Example6&& x) : ptr(x.ptr) 
  {
    x.ptr = nullptr;
  }

  // move assignment
  Example6& operator= (Example6&& x) 
  {
   delete ptr; 
   ptr = x.ptr;
   x.ptr=nullptr;
    return *this;
}

5.16 哪些函数不能是虚函数?把你知道的都说一说

  1. 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;

  2. 内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;

  3. 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。

  4. 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。

  5. 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。

5.17 什么是纯虚函数,与虚函数的区别

  • 虚函数和纯虚函数区别?
    虚函数是为了实现动态编联产生的,目的是通过基类类型的指针指向不同对象时,自动调用相应的、和基类同名的函数(使用同一种调用形式,既能调用派生类又能调用基类的同名函数)。虚函数需要在基类中加上virtual修饰符修饰,因为virtual会被隐式继承,所以子类中相同函数都是虚函数。当一个成员函数被声明为虚函数之后,其派生类中同名函数自动成为虚函数,在派生类中重新定义此函数时要求函数名、返回值类型、参数个数和类型全部与基类函数相同。

  • 纯虚函数只是相当于一个接口名,但含有纯虚函数的类不能够实例化。

  • 纯虚函数首先是虚函数,其次它没有函数体,取而代之的是用“=0”。

  • 既然是虚函数,它的函数指针会被存在虚函数表中,由于纯虚函数并没有具体的函数体,因此它在虚函数表中的值就为0,而具有函数体的虚函数则是函数的具体地址。

  • 一个类中如果有纯虚函数的话,称其为抽象类。抽象类不能用于实例化对象,否则会报错。抽象类一般用于定义一些公有的方法。子类继承抽象类也必须实现其中的纯虚函数才能实例化对象。

5.18 DLL劫持

DLL 劫持(DLL hijacking)是一种安全漏洞,利用该漏洞,攻击者可以将恶意 DLL 文件伪装成系统或应用程序所需的 DLL 文件,并放置在系统搜索路径中,以便在程序运行时被加载并执行恶意代码。

攻击者通常利用以下几种方法进行 DLL 劫持:

  1. 将恶意 DLL 放置在可被系统搜索到的目录中:攻击者可以将恶意 DLL 文件放置在系统搜索路径中的一个或多个目录中,例如程序所在目录、当前工作目录、系统目录(如 System32)或者应用程序目录等。当程序启动时,系统会按照一定的搜索顺序查找并加载所需的 DLL 文件,如果发现了恶意 DLL,就会加载并执行其中的恶意代码。
  2. 利用缺陷的搜索顺序:有些程序在加载 DLL 文件时存在搜索顺序上的缺陷,例如优先从当前工作目录加载 DLL 文件而不是系统目录。攻击者可以利用这种缺陷,将恶意 DLL 文件放置在当前工作目录下,并等待程序启动时被加载执行。
  3. 利用自定义环境变量:攻击者可以利用自定义的环境变量来修改程序的搜索路径,从而加载恶意 DLL 文件。例如,攻击者可以通过修改 PATH 环境变量,将恶意 DLL 文件所在的目录添加到系统搜索路径中。

为了防止 DLL 劫持攻击,可以采取以下几种措施:

  1. 使用绝对路径加载 DLL 文件:在程序中明确指定 DLL 文件的绝对路径,而不是依赖系统搜索路径。这样可以确保程序只加载所需的 DLL 文件,并避免加载恶意 DLL 文件。
  2. 加强权限控制:限制用户对系统目录和应用程序目录的写入权限,防止恶意 DLL 文件被放置在系统搜索路径中。
    更新程序:及时更新程序,修复可能存在的 DLL 劫持漏洞。一些程序已经针对 DLL 劫持进行了修复,并提供了安全更新。
  3. 使用数字签名:对 DLL 文件进行数字签名,确保文件的完整性和来源可信。程序在加载 DLL 文件时可以验证其数字签名,以确保文件未被篡改。
    启用安全策略:在操作系统和应用程序中启用相应的安全策略,限制恶意代码的执行。例如,使用应用程序白名单、启用应用程序沙盒等。

通过采取以上措施,可以有效地防止 DLL 劫持攻击,并保护系统和应用程序的安全。

5.19 我写的程序导致CPU使用率居高不下,可能是什么原因

  • 死循环或无限循环: 程序中可能存在没有终止条件的循环。

  • 频繁的I/O操作: 过多的磁盘或网络I/O操作可能导致CPU资源被大量占用。

  • 资源泄漏: 程序可能存在内存泄漏或未正确释放的资源。(在使用垃圾回收机制的语言(如Java、C#)中,如果内存泄漏导致堆内存接近耗尽,垃圾回收器会频繁运行以尝试释放未使用的内存,这会导致CPU使用率升高。)

  • 低效算法: 算法的时间复杂度过高,导致计算量过大。

  • 多线程问题: 线程之间的竞争和锁争用可能导致CPU资源被大量占用。

  • 不必要的计算: 执行了很多不必要的计算或重复计算。

5.20 C++类型安全吗

  • 类型安全的定义
    类型安全性指的是语言保证不会在运行时发生未定义的行为或类型错误。类型错误包括非法的类型转换、访问不正确类型的数据等。

  • C++类型转换
    C++提供了多种类型转换方法,包括静态转换(static_cast)、动态转换(dynamic_cast)、常量转换(const_cast)和重新解释转换(reinterpret_cast)。这些转换提供了对类型系统的控制,但也可能导致类型安全性问题,特别是reinterpret_cast。

所以C++不是类型安全的

5.21 关于指针数组的delete与delete[]

如果我有一个数组指针 应使用delete 还是delete[]

  • 对于基本数据类型,两者效果相同;

    int* intArray = new int[10];
    // delete intArray;
    delete[] intArray;
    
  • 对于内部无指针变量的自定义类型,两者效果相同;对于内部有指针变量的自定义类型,delete会导致内存溢出,需要使用delete[]。

    myclass* intArray = new myclass[10];
    delete[] intArray;
    

new先分配内存,再调用构造函数。delete先调用析构函数,再释放内存。delete只会调用数组第一个对象的析构函数,而delete[]会调用数组中所有对象的析构函数。虽然有时候没有影响,但是最好还是遵循规范,防止错误的发生,对于数组都使用delete[]。

5.22 C有没有函数重载的概念?

C语言不能函数重载
由于编译器不同,编译后函数名变化只是在原来的函数名前加了一个下划线,所以当同名的函数参数不同时,编译器无法解析到他们的不同,因为它们编译后的名称都相同,所以C语言不能函数重载。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1430797759@qq.com

文章标题:C++八股文

字数:33.3k

本文作者:花落阁

发布时间:2023-01-23, 17:48:51

最后更新:2024-05-23, 14:41:20

原始链接:https://hualog.dns.navy/2023/01/23/C++%E5%85%AB%E8%82%A1%E6%96%87/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。