C++补充

文章目录

C/C++

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

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

  • 设置栈指针
  • 初始化静态static变量和global全局变量,即.data段的内容
  • 将未初始化部分的全局变量赋初值:数值型shortintlong等为0boolFALSE,指针为NULL等等,即.bss段的内容
  • 全局对象初始化,在main之前调用构造函数,这是可能会执行前的一些代码
  • 将main函数的参数argcargv等传递给main函数,然后才真正运行main函数
  • __attribute__((constructor))

main函数执行之后

  • 全局对象的析构函数会在main函数之后执行;
  • 可以用 atexit 注册一个函数,它会在main 之后执行;
  • __attribute__((destructor))
2、结构体内存对齐问题?
  • 结构体内成员按照声明顺序存储,第一个成员地址和整个结构体地址相同。

  • 未特殊说明时,按结构体中size最大的成员对齐(若有double成员,按8字节对齐。)

3、指针和引用的区别
  • 指针是一个变量,存储的是一个地址,引用跟原来的变量实质上是同一个东西,是原变量的别名
  • 指针可以有多级,引用只有一级
  • 指针可以为空,引用不能为NULL且在定义时必须初始化
  • 指针在初始化后可以改变指向,而引用在初始化之后不可再改变
  • sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小
  • 当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以。
  • 引用在声明时必须初始化为另一变量,一旦出现必须为typename refname &varname形式;指针声明和定义可以分开,可以先只声明指针变量而不初始化,等用到时再指向具体变量。
  • 引用一旦初始化之后就不可以再改变(变量可以被引用为多次,但引用只能作为一个变量引用);指针变量可以重新指向别的变量。
  • 不存在指向空值的引用,必须有具体实体;但是存在指向空值的指针。

参考代码

4、堆和栈的区别
  • 申请方式不同。

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

  • 申请大小限制不同。

    • 栈顶和栈底是之前预设好的,栈是向栈底扩展,大小固定,可以通过ulimit -a查看,由ulimit -s修改。

    • 堆向高地址扩展,是不连续的内存区域,大小可以灵活调整。

  • 申请效率不同。

    • 栈由系统分配,速度快,不会有碎片。

    • 堆由程序员分配,速度慢,且会有碎片。

    栈空间默认是4M, 堆区一般是 1G - 4G

管理方式堆中资源由程序员控制(容易产生memory leak)栈资源由编译器自动管理,无需手工控制
内存管理机制系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删 除空闲结点链表中的该结点,并将该结点空间分配给程序(大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外系统会将多余的部分重新放入空闲链表中)只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出。(这一块理解一下链表和队列的区别,不连续空间和连续空间的区别,应该就比较好理解这两种机制的区别了)
空间大小堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),堆大小受限于计算机系统中有效的虚拟内存(32bit 系统理论上是4G),所以堆的空间比较灵活,比较大栈是一块连续的内存区域,大小是操作系统预定好的,windows下栈大小是2M(也有是1M,在 编译时确定,VC中可设置)
碎片问题对于堆,频繁的new/delete会造成大量碎片,使程序效率降低对于栈,它是有点类似于数据结构上的一个先进后出的栈,进出一一对应,不会产生碎片。
生长方向堆向上,向高地址方向增长。栈向下,向低地址方向增长。
分配方式堆都是动态分配(没有静态分配的堆)栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由malloc函数分配,但栈的动态分配的资源由编译器进行释放,无需程序员实现。
分配效率堆由C/C++函数库提供,机制很复杂。所以堆的效率比栈低很多。栈是其系统提供的数据结构,计算机在底层对栈提供支持,分配专门 寄存器存放栈地址,栈操作有专门指令。
5、区别以下指针类型?
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类型的。

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

首先整理一下虚函数表的特征:

  • 虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成

  • 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段

  • 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中

因此最有可能存在全局数据区,测试结果显示:

虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),

由于虚表指针vptr跟虚函数密不可分,对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面。

一般分为五个区域:栈区、堆区、函数区(存放函数体等二进制代码)、全局静态区、常量区

C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

7、new / deletemalloc / free的异同

相同点

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

不同点

  • 前者是C++运算符,后者是C/C++语言标准库函数
  • new自动计算要分配的空间大小,malloc需要手工计算
  • new是类型安全的,malloc不是。例如:
int *p = new float[2]; //编译错误
int *p = (int*)malloc(2 * sizeof(double));//编译无错误
  • new
    • 调用名为**operator new**的标准库函数分配足够空间
    • 调用相关对象的构造函数,
  • delete
    • 对指针所指对象运行适当的析构函数;
    • 然后通过调用名为**operator delete**的标准库函数释放该对象所用内存。
  • 后者需要库文件支持,前者不用
  • new是封装了malloc,直接free不会报错,但是这只是释放内存,而不会析构对象
8、newdelete是如何实现的?
  • new的实现过程是:首先调用名为**operator new**的标准库函数,分配足够大的原始为类型化的内存,以保存指定类型的一个对象;接下来运行该类型的一个构造函数,用指定初始化构造对象;最后返回指向新分配并构造后的的对象的指针
  • delete的实现过程:对指针指向的对象运行适当的析构函数;然后通过调用名为**operator delete**的标准库函数释放该对象所用内存
9、mallocnew的区别?
  • mallocfree是标准库函数,支持覆盖;new和delete是运算符,并且支持重载。

  • malloc仅仅分配内存空间,free仅仅回收空间,不具备调用构造函数和析构函数功能,用malloc分配空间存储类的对象存在风险;new和delete除了分配回收功能外,还会调用构造函数和析构函数。

  • mallocfree返回的是void类型指针(必须进行类型转换),new和delete返回的是具体类型指针。

delete和delete[]区别?

  • delete只会调用一次析构函数。
  • delete[]会调用数组中每个元素的析构函数。
10、宏定义和函数有何区别?
  • 宏在编译时完成替换,之后被替换的文本参与编译,相当于直接插入了代码,运行时不存在函数调用,执行起来更快;函数调用在运行时需要跳转到具体调用函数。

  • 宏定义属于在结构中插入代码,没有返回值;函数调用具有返回值。

  • 宏定义参数没有类型,不进行类型检查;函数参数具有类型,需要检查类型。

  • 宏定义不要在最后加分号。

11、宏定义和typedef区别?
  • 宏主要用于定义常量及书写复杂的内容typedef主要用于定义类型别名

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

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

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

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

12、变量声明和定义区别?
  • 声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间;定义要在定义的地方为其分配存储空间。

  • 相同变量可以在多处声明(外部变量extern),但只能在一处定义。

13、哪几种情况必须用到初始化成员列表?
  • 初始化const修饰的类成员。

  • 初始化引用类型的类成员

    • 常量成员:因为常量只能初始化,不能赋值,所以必须放在初始化量表中

    • 引用类型:引用必须在定义的时候初始化,并且不能重新赋值,所欲也要写在初始化列表里面

    • #include <iostream>
      using namespace std;
      struct Demo{
      const int M_a;
          int &m_b;
          Demo(int a,int& b):M_a(a),m_b(b){}
          ~Demo(){}
      };
      int main(){
          int m = 5;
          int n = 6;
          Demo a(m,n);
          cout<<a.M_a<<endl;
          cout<<a.m_b<<endl;
      }
      
  • 没有默认构造函数的类:因为使用初始化列表不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化

  • 如果类存在继承关系,派生类必须在其初始化列表中调用基类的构造函数

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

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

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

  int main(int argc, char const *argv[]){
      
      const char* str = "name";

      sizeof(str); // 取的是指针str的长度,是8
      strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
      return 0;
  }
15、常量指针和指针常量区别?
  • 常量指针是一个指针,读成常量的指针,指向一个只读变量。如int const *pconst int *p

  • 指针常量是一个不能给改变指向的指针。指针是个常亮,不能中途改变指向,如int *const p

16、a和&a有什么区别?
//假设数组
int a[10];
int (*p)[10] = &a;
  • a是数组名,是数组首元素地址,+1表示地址值加上一个int类型的大小,如果a的值是0x00000001,加1操作后变为0x00000005。*(a + 1) = a[1]
  • &a是数组的指针,其类型为int (*)[10](就是前面提到的数组指针),其加1时,系统会认为是数组首地址加上整个数组的偏移(10个int型变量),值为数组a尾元素后一个元素的地址。
  • 若(int *)p ,此时输出 *p时,其值为a[0]的值,因为被转为int *类型,解引用时按照int类型大小来读取。
17、数组名和指针(这里为指向数组首元素的指针)区别?
  • 二者均可通过增减偏移量来访问数组中的元素。

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

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

18、野指针和悬空指针

都是是指向无效内存区域(这里的无效指的是"不安全不可控")的指针,访问行为将会导致未定义行为。

  • 野指针
    野指针,指的是没有被初始化过的指针

    int main(void) { 
        
        int* p;     // 未初始化
        std::cout<< *p << std::endl; // 未初始化就被使用
        
        return 0;
    }
    

    因此,为了防止出错,对于指针初始化时都是赋值为 nullptr,这样在使用时编译器就会直接报错,产生非法内存访问。

  • 悬空指针
    悬空指针,指针最初指向的内存已经被释放了的一种指针。

    int main(void) { 
      int * p = nullptr;
    
      int* p2 = new int;
      
      p = p2;
    
      delete p2;
    }
    

    此时 pp2就是悬空指针,指向的内存已经被释放。继续使用这两个指针,行为不可预料。需要设置为p=p2=nullptr。此时再使用,编译器会直接保错。

    避免野指针比较简单,但悬空指针比较麻烦。c++引入了智能指针,C++智能指针的本质就是避免悬空指针的产生。

产生原因及解决办法:

野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空。

悬空指针:指针free或delete之后没有及时置空 => 释放操作后立即置空。

19、迭代器失效的情况

以vector为例:

插入元素:

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

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

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

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

  5. dequevector的情况类似,

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

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

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

  1. 序列式容器(vertor,deque
  • 删除当前的iterator会使后i按所有元素的iterator都失效:因为vector,deque使用连续分配的内存,删除一个元素导致后面的元素都会向前移动一个位置、
  1. 关联式容器(map,set,multimap,multiset)
    • 删除当前的iterator,仅仅会使当前的iterator失效,只要在erase时,递增当前的iterator
20、C和C++的区别
  • C++中newdelete是对内存分配的运算符,取代了C中的mallocfree
  • 标准C++中的字符串类取代了标准C函数库头文件中的字符数组处理函数(C中没有字符串类型)。
  • C++中用来做控制态输入输出的iostream类库替代了标准C中的stdio函数库。
  • C++中的try/catch/throw异常处理机制取代了标准C中的setjmp()longjmp()函数。
  • C++可以重载,C语言不允许。
  • C++语言中,允许变量定义语句在程序中的任何地方,只要在是使用它之前就可以;而C语言中,必须要在函数开头部分。而且C++允许重复定义变量,C语言也是做不到这一点的
  • 在C++中,除了值和指针之外,新增了引用。引用型变量是其他变量的一个别名,我们可以认为他们只是名字不相同,其他都是相同的。
  • C++相对与C增加了一些关键字,如:boolusingdynamic_castnamespace等等
21.C++Java的区别

语言特性

  • Java语言给开发人员提供了更为简洁的语法;完全面向对象,由于JVM可以安装到任何的操作系统上,所以说它的可移植性强

  • Java语言中没有指针的概念,引入了真正的数组。不同于C++中利用指针实现的“伪数组”,Java引入了真正的数组,同时将容易造成麻烦的指针从语言中去掉,这将有利于防止在C++程序中常见的因为数组操作越界等指针操作而对系统数据进行非法读写带来的不安全问题

  • C++也可以在其他系统运行,但是需要不同的编码(这一点不如Java,只编写一次代码,到处运行),例如对一个数字,在windows下是大端存储,在Linux中则为小端存储。Java程序一般都是生成字节码,在JVM里面运行得到结果

  • Java用接口(Interface)技术取代C++程序中的多继承性。接口与多继承有同样的功能,但是省却了多继承在实现和维护上的复杂性

垃圾回收

  • C++用析构函数回收垃圾,写C和C++程序时一定要注意内存的申请和释放
  • Java语言不使用指针,内存的分配和回收都是自动进行的,程序员无须考虑内存碎片的问题

应用场景

  • Java在桌面程序上不如C++实用,C++可以直接编译成exe文件,指针是c++的优势,可以直接对内存的操作,但同时具有危险性 。(操作内存的确是一项非常危险的事情,一旦指针指向的位置发生错误,或者误删除了内存中某个地址单元存放的重要数据,后果是可想而知的)
  • Java在Web 应用上具有C++ 无可比拟的优势,具有丰富多样的框架
  • 对于底层程序的编程以及控制方面的编程,C++很灵活,因为有句柄的存在

《C++和java的区别和联系》:https://www.cnblogs.com/tanrong/p/8503202.html

22、C++中structclass的区别

相同点

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

不同点

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

  • class默认是private继承,而struct模式是public继承

引申:C++和C的struct区别

  • C语言中:struct是用户自定义数据类型(UDT);C++中struct是抽象数据类型(ADT),支持成员函数的定义,(C++中的struct能继承,能实现多态)

  • C中struct是没有权限的设置的,且struct中只能是一些变量的集合体,可以封装数据却不可以隐藏数据,而且成员不可以是函数

  • C++中,struct增加了访问权限,且可以和类一样有成员函数,成员默认访问说明符为public(为了与C兼容)

  • struct作为类的一种特例是用来自定义数据结构的。一个结构标记声明后,在C中必须在结构标记前加上struct,才能做结构类型名(除:typedef struct class{};);C++中结构体标记(结构体名)可以直接作为结构体类型名使用,此外结构体struct在C++中被当作类的一种特例

struct结构在C和C++中的区别》:https://blog.csdn.net/mm_hh/article/details/70456240

23、define宏定义和const的区别

编译阶段

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

安全性

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

内存占用

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

  • 宏替换发生在编译阶段之前,属于文本插入替换;const作用发生于编译过程中。

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

  • 宏定义的数据没有分配内存空间,只是插入替换掉;const定义的变量只是值不能改变,但要分配内存空间。

24、C++中conststatic的作用

static

  • 不考虑类的情况
    • 隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件所在的编译模块中使用
    • 默认初始化为0,包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区
    • 静态变量在函数内定义,始终存在,且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用
  • 考虑类的情况
    • static成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为static;可以被非static成员函数任意访问。
    • static成员函数:不具有this指针,无法访问类对象的非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问

const

  • 不考虑类的情况

    • const常量在定义时必须初始化,之后无法更改

    • const形参可以接收const和非const类型的实参,例如

      // i 可以是 int 型或者 const int 型
      void fun(const int& i){
      	//...
      }
      
  • 考虑类的情况

    • const成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化
    • const成员函数:const对象不可以调用非const成员函数;非const对象都可以调用;不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值
25、C++的顶层const和底层const

概念区分

  • 顶层const:指的是const修饰的变量本身是一个常量,无法修改,指的是指针,就是 * 号的右边
  • 底层const:指的是const修饰的变量所指向的对象是一个常量,指的是所指变量,就是 * 号的左边

举个例子

int a = 10;
int* const b1 = &a;        //顶层const,b1本身是一个常量
const int* b2 = &a;        //底层const,b2本身可变,所指的对象是常量
const int b3 = 20; 		   //顶层const,b3是常量不可变
const int* const b4 = &a;  //前一个const为底层,后一个为顶层,b4不可变
const int& b5 = a;		   //用于声明引用变量,都是底层const


区分作用

  • 执行对象拷贝时有限制,常量的底层const不能赋值给非常量的底层const
  • 使用命名的强制类型转换函数const_cast时,只能改变运算对象的底层const
const int a;
int const a;
const int *a;
int *const a;
  • int const aconst int a均表示定义常量类型a。

  • const int *a,其中a为指向int型变量的指针,const在 * 左侧,表示a指向不可变常量。(看成const (*a),对引用加const)

  • int *const a,依旧是指针类型,表示a为指向整型数据的常指针。(看成const(a),对指针const)

26、类的对象存储空间?
  • 非静态成员的数据类型大小之和。

  • 编译器加入的额外成员变量(如指向虚函数表的指针)。

  • 为了边缘对齐优化加入的padding

空类(无非静态数据成员)的对象的size为1, 当作为基类时, size为0.

27、final和override关键字

override

当在父类中使用了虚函数时候,你可能需要在某个子类中对这个虚函数进行重写,以下方法都可以:

class A
{
    virtual void foo();
}
class B : public A
{
    void foo(); //OK
    virtual void foo(); // OK
    void foo() override; //OK
}


如果不使用override,当你手一抖,将**foo()写成了f00()**会怎么样呢?结果是编译器并不会报错,因为它并不知道你的目的是重写虚函数,而是把它当成了新的函数。如果这个虚函数很重要的话,那就会对整个程序不利。所以,override的作用就出来了,它指定了子类的这个虚函数是重写的父类的,如果你名字不小心打错了的话,编译器是不会编译通过的:

class A
{
    virtual void foo();
};
class B : public A
{
    virtual void f00(); //OK,这个函数是B新增的,不是继承的
    virtual void f0o() override; //Error, 加了override之后,这个函数一定是继承自A的,A找不到就报错
};


final

当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。例子如下:

class Base
{
    virtual void foo();
};
 
class A : public Base
{
    void foo() final; // foo 被override并且是最后一个override,在其子类中不可以重写
};

class B final : A // 指明B是不可以被继承的
{
    void foo() override; // Error: 在A中已经被final了
};
 
class C : B // Error: B is final
{
};


《C++:override和final》:https://www.cnblogs.com/whlook/p/6501918.html

28、拷贝初始化和直接初始化
  • 当用于类类型对象时,初始化的拷贝形式和直接形式有所不同:
    • 直接初始化直接调用与实参匹配的构造函数,
    • 拷贝初始化总是调用拷贝构造函数。拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。
  • 举例如下
string str1("I am a string");//语句1 直接初始化
string str2(str1);//语句2 直接初始化,str1是已经存在的对象,直接调用构造函数对str2进行初始化
string str3 = "I am a string";//语句3 拷贝初始化,先为字符串”I am a string“创建临时对象,再把临时对象作为参数,使用拷贝构造函数构造str3
string str4 = str1;//语句4 拷贝初始化,这里相当于隐式调用拷贝构造函数,而不是调用赋值运算符函数
  • 为了提高效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,这样就完全等价于直接初始化了(语句1和语句3等价)。但是需要辨别两种情况。
    • 当拷贝构造函数为private时:语句3和语句4在编译时会报错
    • 使用explicit修饰构造函数时:如果构造函数存在隐式转换,编译时会报错
29、初始化和赋值的区别
  • 对于简单类型来说,初始化和赋值没什么区别
  • 对于类和复杂数据类型来说,这两者的区别就大了,举例如下:
class A{
public:
    int num1;
    int num2;
public:
    A(int a=0, int b=0):num1(a),num2(b){};
    A(const A& a){};
    //重载 = 号操作符函数
    A& operator=(const A& a){
        num1 = a.num1 + 1;
        num2 = a.num2 + 1;
        return *this;
    };
};
int main(){
    A a(1,1);
    A a1 = a; //拷贝初始化操作,调用拷贝构造函数
    A b;
    b = a;//赋值操作,对象a中,num1 = 1,num2 = 1;对象b中,num1 = 2,num2 = 2
    return 0;
}
30、extern"C"的用法

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

哪些情况下使用extern “C”:

(1)C++代码中调用C语言代码;

(2)在C++中的头文件中使用;

(3)在多个人协同开发时,可能有人擅长C语言,而有人擅长C++;

举个例子,C++中调用C代码:

#ifndef __MY_HANDLE_H__
#define __MY_HANDLE_H__

extern "C"{
    typedef unsigned int result_t;
    typedef void* my_handle_t;
    
    my_handle_t create_handle(const char* name);
    result_t operate_on_handle(my_handle_t handle);
    void close_handle(my_handle_t handle);
}


  • 参考的blog中有一篇google code上的文章,专门写extern "C"的,有兴趣的读者不妨去看看

《extern "C"的功能和用法研究》:https://blog.csdn.net/sss_369/article/details/84060561

综上,总结出使用方法**,在C语言的头文件中,对其外部函数只能指定为extern类型,C语言中不支持extern "C"声明,在.c文件中包含了extern "C"时会出现编译语法错误。**所以使用extern "C"全部都放在于cpp程序相关文件或其头文件中。

总结出如下形式:

(1)C++调用C函数:

//xx.h
extern int add(...)

//xx.c
int add(){
    
}

//xx.cpp
extern "C" {
    #include "xx.h"
}


(2)C调用C++函数

//xx.h
extern "C"{
    int add();
}
//xx.cpp
int add(){
    
}
//xx.c
extern int add();
31、模板函数和模板类的特例化

引入原因

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

定义

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

(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,typename Allocator>
class vector{
    /*......*/
};
//模板特例化
template<typename Allocator>
class vector<bool,Allocator>
{
/*......*/
};

特例化类中的部分成员

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

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()功能不同
32、C和C++的类型安全

什么是类型安全?

类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。类型安全的编程语言与类型安全的程序之间,没有必然联系。好的程序员可以使用类型不那么安全的语言写出类型相当安全的程序,相反的,差一点儿的程序员可能使用类型相当安全的语言写出类型不太安全的程序。绝对类型安全的编程语言暂时还没有。

(1)C的类型安全

C只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C中相当多的操作是不安全的。以下是两个十分常见的例子:

  • printf格式输出

上述代码中,使用%d控制整型数字的输出,没有问题,但是改成%f时,明显输出错误,再改成%s时,运行直接报segmentation fault错误

  • malloc函数的返回值

malloc是C中进行内存分配的函数,它的返回类型是void*即空类型指针,常常有这样的用法char* pStr=(char*)malloc(100*sizeof(char)),这里明显做了显式的类型转换。类型匹配尚且没有问题,但是一旦出现int* pInt=(int*)malloc(100*sizeof(char))就很可能带来一些问题,而这样的转换C并不会提示错误。

(2)C++的类型安全

如果C++使用得当,它将远比C更有类型安全性。相比于C语言,C++提供了一些新的机制保障类型安全:

  • 操作符new返回的指针类型严格与对象匹配,而不是void*

  • C中很多以void*为参数的函数可以改写为C++模板函数,而模板是支持类型检查的;

  • 引入const关键字代替#define constants,它是有类型、有作用域的,而#define constants只是简单的文本替换

  • 一些#define宏可被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全

  • C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。

    例1:使用void*进行类型转换

​ 例2:不同类型指针之间转换

#include<iostream>
using namespace std;
 
class Parent{};
class Child1 : public Parent
{
public:
	int i;
	Child1(int e):i(e){}
};
class Child2 : public Parent
{
public:
	double d;
	Child2(double e):d(e){}
};
int main()
{
	Child1 c1(5);
	Child2 c2(4.1);
	Parent* pp;
	Child1* pc1;
 	
	pp=&c1; 
	pc1=(Child1*)pp;  // 类型向下转换 强制转换,由于类型仍然为Child1*,不造成错误
	cout<<pc1->i<<endl; //输出:5
 
	pp=&c2;
	pc1=(Child1*)pp;  //强制转换,且类型发生变化,将造成错误
	cout<<pc1->i<<endl;// 输出:1717986918
	return 0;
}

上面两个例子之所以引起类型不安全的问题,是因为程序员使用不得当。第一个例子用到了空类型指针void*,第二个例子则是在两个类型指针之间进行强制转换。因此,想保证程序的类型安全性,应尽量避免使用空类型指针void*,尽量不对两种类型指针做强制转换。

33、为什么析构函数一般写成虚函数

由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。举个例子:

#include <iostream>
using namespace std;

class Parent{
public:
	Parent(){
		cout << "Parent construct function"  << endl;
	};
	~Parent(){
		cout << "Parent destructor function" <<endl;
	}
};

class Son : public Parent{
public:
	Son(){
		cout << "Son construct function"  << endl;
	};
	~Son(){
		cout << "Son destructor function" <<endl;
	}
};

int main()
{
	Parent* p = new Son();
	delete p;
	p = NULL;
	return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Parent destructor function

将基类的析构函数声明为虚函数:

#include <iostream>
using namespace std;

class Parent{
public:
	Parent(){
		cout << "Parent construct function"  << endl;
	};
	virtual ~Parent(){
		cout << "Parent destructor function" <<endl;
	}
};

class Son : public Parent{
public:
	Son(){
		cout << "Son construct function"  << endl;
	};
	~Son(){
		cout << "Son destructor function" <<endl;
	}
};

int main()
{
	Parent* p = new Son();
	delete p;
	p = NULL;
	return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Son destructor function
//Parent destructor function
34、构造函数能否声明为虚函数或者纯虚函数,析构函数呢?

析构函数:

  • 析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。
  • 只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。
  • 析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。

构造函数:

  • 构造函数不能定义为虚函数。在构造函数中可以调用虚函数,不过此时调用的是正在构造的类中的虚函数,而不是子类的虚函数,因为此时子类尚未构造好。
35、C++中的重载、重写(覆盖)和隐藏的区别

(1)重载(overload)

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

class A{
    ...
    virtual int fun();
    void fun(int);
    void fun(double, double);
    static int fun(char);
    ...
}


(2)重写(覆盖)(override)

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

  • 与基类的虚函数有相同的参数个数
  • 与基类的虚函数有相同的参数类型
  • 与基类的虚函数有相同的返回值类型

举个例子:

//父类
class A{
public:
    virtual int fun(int a){}
}
//子类
class B : public A{
public:
    //重写,一般加override可以确保是重写父类的函数
    virtual int fun(int a) 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;
}


36、C++的多态如何实现

C++的多态性,一言以蔽之就是:

在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据所指对象的实际类型来调用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数。

举个例子:

#include <iostream>
using namespace std;

class Base{
public:
	virtual void fun(){
		cout << " Base::func()" <<endl;
	}
};

class Son1 : public Base{
public:
	virtual void fun() override{
		cout << " Son1::func()" <<endl;
	}
};

class Son2 : public Base{

};

int main()
{
	Base* base = new Son1;
	base->fun();
	base = new Son2;
	base->fun();
	delete base;
	base = NULL;
	return 0;
}
// 运行结果
// Son1::func()
// Base::func()


例子中,Base为基类,其中的函数为虚函数。子类1继承并重写了基类的函数,子类2继承基类但没有重写基类的函数,从结果分析子类体现了多态性,那么为什么会出现多态性,其底层的原理是什么?这里需要引出虚表和虚基表指针的概念。

虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表

虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针

上图中展示了虚表和虚表指针在基类对象和派生类对象中的模型,下面阐述实现多态的过程:

**(1)**编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址

(2)编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数

**(3)**所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表

**(4)**当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面

这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。

《C++实现多态的原理》:https://blog.csdn.net/qq_37954088/article/details/79947898

37、C++有哪几种的构造函数

C++中的构造函数可以分为4类:

  • 默认构造函数
  • 初始化构造函数(有参数)
  • 拷贝构造函数
  • 移动构造函数(move和右值引用)
  • 委托构造函数
  • 转换构造函数

举个例子:

#include <iostream>
using namespace std;

class Student{
public:
    Student(){//默认构造函数,没有参数
        this->age = 20;
        this->num = 1000;
    };  
    Student(int a, int n):age(a), num(n){}; //初始化构造函数,有参数和参数列表
    Student(const Student& s){//拷贝构造函数,这里与编译器生成的一致
        this->age = s.age;
        this->num = s.num;
    }; 
    Student(int r){   //转换构造函数,形参是其他类型变量,且只有一个形参
        this->age = r;
		this->num = 1002;
    };
    ~Student(){}
public:
    int age;
    int num;
};

int main(){
    Student s1;
    Student s2(18,1001);
    int a = 10;
    Student s3(a);
    Student s4(s3);
    
    printf("s1 age:%d, num:%d\n", s1.age, s1.num);
    printf("s2 age:%d, num:%d\n", s2.age, s2.num);
    printf("s3 age:%d, num:%d\n", s3.age, s3.num);
    printf("s2 age:%d, num:%d\n", s4.age, s4.num);
    return 0;
}
//运行结果
//s1 age:20, num:1000
//s2 age:18, num:1001
//s3 age:10, num:1002
//s2 age:10, num:1002


  • 默认构造函数和初始化构造函数在定义类的对象,完成对象的初始化工作
  • 复制构造函数用于复制本类的对象
  • 转换构造函数用于将其他类型的变量,隐式转换为本类对象

《浅谈C++中的几种构造函数》:https://blog.csdn.net/zxc024000/article/details/51153743

38、浅拷贝和深拷贝的区别

浅拷贝

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

深拷贝

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

#include <iostream>  
#include <string.h>
using namespace std;
 
class Student
{
private:
	int num;
	char *name;
public:
	Student(){
        name = new char(20);
		cout << "Student" << endl;
    };
	~Student(){
        cout << "~Student " << &name << endl;
        delete name;
        name = NULL;
    };
	Student(const Student &s){//拷贝构造函数
        //浅拷贝,当对象的name和传入对象的name指向相同的地址
        name = s.name;
        //深拷贝
        //name = new char(20);
        //memcpy(name, s.name, strlen(s.name));
        cout << "copy Student" << endl;
    };
};
 
int main()
{
	{// 花括号让s1和s2变成局部对象,方便测试
		Student s1;
		Student s2(s1);// 复制对象
	}
	system("pause");
	return 0;
}
//浅拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffed0c3ec0
//~Student 0x7fffed0c3ed0
//*** Error in `/tmp/815453382/a.out': double free or corruption (fasttop): 0x0000000001c82c20 ***

//深拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffebca9fb0
//~Student 0x7fffebca9fc0


从执行结果可以看出,浅拷贝在对象的拷贝创建时存在风险,即被拷贝的对象析构释放资源之后,拷贝对象析构时会再次释放一个已经释放的资源,深拷贝的结果是两个对象之间没有任何关系,各自成员地址不同。

39、内联函数和宏定义的区别

内联(inline)函数和普通函数相比可以加快程序运行的速度,因为不需要中断调用,在编译的时候内联函数可以直接嵌入到目标代码中。

内联函数适用场景

  • 使用宏定义的地方都可以使用inline函数
  • 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率

为什么不能把所有的函数写成内联函数

内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:

  • 函数体内的代码比较长,将导致内存消耗代价
  • 函数体内有循环,函数执行时间要比函数调用开销大

主要区别

  • 内联函数在编译时展开,宏在预编译时展开

  • 内联函数直接嵌入到目标代码中,宏是简单的做文本替换

  • 内联函数有类型检测、语法判断等功能,而宏没有

  • 内联函数是函数,宏不是

  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义

  • 内联函数代码是被放到符号表中,使用时像宏一样展开,没有调用的开销,效率很高;

  • 在使用时,宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。

  • 内联函数本身是函数,强调函数特性,具有重载等功能。

  • 内联函数可以作为某个类的成员函数,这样可以使用类的保护成员和私有成员,进而提升效率。而当一个表达式涉及到类保护成员或私有成员时,宏就不能实现了。

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

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

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

#include <iostream>
using namespace std;
class A
{
public:
    inline A() {
		cout << "inline construct()" <<endl;
	}
    inline ~A() {
		cout << "inline destruct()" <<endl;
	}
    inline virtual void  virtualFun() {
		cout << "inline virtual function" <<endl;
	}
};
 
int main()
{
	A a;
	a.virtualFun();
    return 0;
}
//输出结果
//inline construct()
//inline virtual function
//inline destruct()

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

  • 类成员函数默认是inline型的,编译器也只是有选择性的inline,将构造函数和析构函数声明为内联函数是没有什么意义的。

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

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

(1)auto

C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应某种特定的类型说明符(例如 int)不同,

auto 让编译器通过初始值来进行类型推演。从而获得定义变量的类型,所以说 auto 定义的变量必须有初始值。

//普通;类型
int a = 1, b = 3;
auto c = a + b;// c为int型

//const类型
const int i = 5;
auto j = i; // 变量i是顶层const, 会被忽略, 所以j的类型是int
auto k = &i; // 变量i是一个常量, 对常量取地址是一种底层const, 所以b的类型是const int*
const auto l = i; //如果希望推断出的类型是顶层const的, 那么就需要在auto前面加上cosnt

//引用和指针类型
int x = 2;
int& y = x;
auto z = y; //z是int型不是int& 型
auto& p1 = y; //p1是int&型
auto p2 = &x; //p2是指针类型int*


(2)decltype

  1. 从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量。
  2. 函数的返回类型为某表达式的值类型。

说明符decltype它的作用是选择并返回操作数的数据类型。在此过程中,编译器只是分析表达式并得到它的类型,却不进行实际的计算表达式的值。

int func() {return 0};

//普通类型
decltype(func()) sum = 5; // sum的类型是函数func()的返回值的类型int, 但是这时不会实际调用函数func()
int a = 0;
decltype(a) b = 4; // a的类型是int, 所以b的类型也是int

//不论是顶层const还是底层const, decltype都会保留   
const int c = 3;
decltype(c) d = c; // d的类型和c是一样的, 都是顶层const
int e = 4;
const int* f = &e; // f是底层const
decltype(f) g = f; // g也是底层const

//引用与指针类型
//1. 如果表达式是引用类型, 那么decltype的类型也是引用
const int i = 3, &j = i;
decltype(j) k = 5; // k的类型是 const int&

//2. 如果表达式是引用类型, 但是想要得到这个引用所指向的类型, 需要修改表达式:
int i = 3, &r = i;
decltype(r + 0) t = 5; // 此时是int类型

//3. 对指针的解引用操作返回的是引用类型
int i = 3, j = 6, *p = &i;
decltype(*p) c = j; // c是int&类型, c和j绑定在一起

//4. 如果一个表达式的类型不是引用, 但是我们需要推断出引用, 那么可以加上一对括号, 就变成了引用类型了
int i = 3;
decltype((i)) j = i; // 此时j的类型是int&类型, j和i绑定在了一起

(3)decltype(auto)

decltype(auto)是C++14新增的类型指示符,可以用来声明变量以及指示函数返回类型

在使用时,会将“=”号左边的表达式替换掉auto,再根据decltype的语法规则来确定类型。举个例子:

int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e
42、public,protected和private访问和继承权限/public/protected/private的区别?
  • public的变量和函数在类的内部外部都可以访问。

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

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

(一)访问权限

  • 派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员,但是这些成员的访问属性在派生过程中也是可以调整的,

  • 三种派生方式的访问权限如下表所示:注意外部访问并不是真正的外部访问,而是在通过派生类的对象对基类成员的访问。

派生类对基类成员的访问形象有如下两种:

  • 内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问
  • 外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问

(二)继承权限

public继承

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

protected继承

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

private继承

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

43、如何用代码判断大小端存储

大端存储:字数据的高字节存储在低地址中

小端存储:字数据的低字节存储在低地址中

例如:32bit的数字0x12345678

所以在Socket编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输

小端模式中的存储方式为:

大端模式中的存储方式为:

了解了大小端存储的方式,如何在代码中进行判断呢?下面介绍两种判断方式:

方式一:使用强制类型转换-这种法子不错

#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;
}

方式二:巧用union联合体

#include <iostream>
using namespace std;
//union联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值
union endian
{
    int a;
    char ch;
};
int main()
{
    endian value;
    value.a = 0x1234;
    //a和ch共用4字节的内存空间
    if (value.ch == 0x12)
        cout << "big endian"<<endl;
    else if (value.ch == 0x34)
        cout << "little endian"<<endl;
}
44、volatile、mutable和explicit关键字的用法
volatile关键字
  1. volatile

volatile 关键字(类型修饰符),

  • 用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
  • 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据
  • volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量需要定义为volatile类型。
  1. volatile 指针
  • volatile 指针和 const 修饰词类似,const有常量指针和指针常量的说法,volatile 也有相应的概念
  • 修饰由指针指向的对象、数据是 const 或 volatile 的:
const char* cpch;
volatile char* vpch;
  • 指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:
char* const pchc;
char* volatile pchv;
  • 注意:
    • 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。
    • 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
    • C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员。
  1. 多线程下的volatile
  • 当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。
  • 如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
mutable关键字
  • 在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后后面关键字位置
explicit关键字
  • explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换,注意以下几点:
    • explicit 关键字只能用于类内部的构造函数声明上

    • explicit 关键字作用于单个参数的构造函数

    • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换

45、什么情况下会调用拷贝构造函数
  • 用类的一个实例化对象去初始化另一个对象的时候
  • 函数的参数是类的对象时(非引用传递)
  • 函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value优化)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数

另:第三种情况在Linux g++ 下则不会发生拷贝构造函数,不仅如此即使返回局部对象的引用,依然不会发生拷贝构造函数

46、C++中有几种类型的new

在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new

(1)plain new

言下之意就是普通的new,就是我们常用的new,在C++中定义如下:

void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();

因此plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过判断返回值是否为NULL是徒劳的,举个例子:

#include <iostream>
#include <string>
using namespace std;
int main()
{
	try
	{
		char *p = new char[10e11];
		delete p;
	}
	catch (const std::bad_alloc &ex)
	{
		cout << ex.what() << endl;
	}
	return 0;
}
//执行结果:bad allocation

(2)nothrow new

nothrow new在空间分配失败的情况下是不抛出异常,而是返回NULL,定义如下:

void * operator new(std::size_t,const std::nothrow_t&) throw();
void operator delete(void*) throw();

举个例子:

#include <iostream>
#include <string>
using namespace std;

int main()
{
	char *p = new(nothrow) char[10e11];
	if (p == NULL) 
	{
		cout << "alloc failed" << endl;
	}
	delete p;
	return 0;
}
//运行结果:alloc failed

(3)placement new

这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:

void* operator new(size_t,void*);
void operator delete(void*,void*);

使用placement new需要注意两点:

  • palcement new的主要用途就是反复使用一块较大的动态分配的内存构造不同类型的对象或者他们的数组

  • placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用delete,这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。

#include <iostream>
#include <string>
using namespace std;
class ADT{
	int i;
	int j;
public:
	ADT(){
		i = 10;
		j = 100;
		cout << "ADT construct i=" << i << "j="<<j <<endl;
	}
	~ADT(){
		cout << "ADT destruct" << endl;
	}
};
int main()
{
	char *p = new(nothrow) char[sizeof ADT + 1];
	if (p == NULL) {
		cout << "alloc failed" << endl;
	}
	ADT *q = new(p) ADT;  //placement new:不必担心失败,只要p所指对象的的空间足够ADT创建即可
	//delete q;//错误!不能在此处调用delete q;
	q->ADT::~ADT();//显示调用析构函数
	delete[] p;
	return 0;
}
//输出结果:
//ADT construct i=10j=100
//ADT destruct
47、C++中NULL和nullptr区别
  • 在C语言中,NULL被定义为(void)0,而在C++语言中,NULL则被定义为整数0*。
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif

在C++中指针必须有明确的类型定义。但是将NULL定义为0带来的另一个问题是函数重载情况下,无法与整数的0区分

#include <iostream>
using namespace std;
void fun(char* p) {
	cout << "char*" << endl;
}
void fun(int p) {
	cout << "int" << endl;
}
int main()
{
	fun(NULL);//传入NULL参数时,会把NULL当做整数0来看
	return 0;
}
//输出结果:int
  • nullptr可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误。

nullptr的一种实现方式如下:

const class nullptr_t{
public:
    template<class T>  inline operator T*() const{ return 0; }
    template<class C, class T> inline operator T C::*() const { return 0; }
private:
    void operator&() const;
} nullptr = {};
  • 通过模板类和运算符重载的方式:来对不同类型的指针进行实例化。
  • **由于nullptr是明确的指针类型,所以不会与整形变量相混淆。**但nullptr仍然存在一定问题,
    • 不同指针类型的函数重载的情况下,此时如果传入nullptr指针则仍然存在无法区分应实际调用哪个函数,这种情况下必须显示的指明参数类型。
#include <iostream>
using namespace std;
void fun(char* p)
{
	cout<< "char* p" <<endl;
}
void fun(int* p)
{
	cout<< "int* p" <<endl;
}
void fun(int p)
{
	cout<< "int p" <<endl;
}
int main()
{
    fun((char*)nullptr);//语句1
	fun(nullptr);//语句2
    fun(NULL);//语句3
    return 0;
}
//运行结果:
//语句1:char* p
//语句2:报错,有多个匹配
//3:int p
48、简要说明C++的内存分区

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

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

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

自由存储区:就是那些由malloc等分配的内存块,它和堆是十分相似的,不过它是用free来结束自己的生命的

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

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

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

49、C++的异常处理的方法

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

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

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

(1)try、throw和catch关键字

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

#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;
}
//运行结果
//before dividing.
//catch (...)
//finished
  • catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(…)的方式捕获任何异常(不推荐)。

(2)函数的异常声明列表

定义函数的时候知道函数可能发生的异常,可以在函数声明和定义时,指出所能抛出异常的列表,写法如下:

int fun() throw(int,double,A,B,C){...};

这种写法表明函数可能会抛出int,double型或者A、B、C三种类型的异常,如果throw中为空,表明不会抛出任何异常,如果没有throw则可能抛出任何异常

(3)C++标准异常类 exception

C++ 标准库中有一些类代表异常,这些类都是从 exception 类派生而来的,如下图所示

  • bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常,例如:
#include <iostream>
#include <typeinfo>
using namespace std;

class A{
public:
  virtual ~A();
};
 
using namespace std;
int main() {
	A* a = NULL;
	try {
  		cout << typeid(*a).name() << endl; // Error condition
  	}
	catch (bad_typeid){
  		cout << "Object is NULL" << endl;
  	}
    return 0;
}
//运行结果:bject is NULL
  • bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常
  • bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常
  • out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常
50、static的用法和作用?
  1. 隐藏。(static函数,static变量均可)
  • 当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。
  1. 保持变量内容的持久。(static变量中的记忆功能和全局生存期)存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围。

  2. 默认初始化为0(static变量)

  3. C++中的类成员声明static

    (1) 函数体内static变量的作用范围为该函数体,不同于auto变量,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;

    (2) 在模块内的static全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;

    (3) 在模块内的static函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;

    (4) 在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;

    (5) 在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量。

类内:

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

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

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

51、静态变量什么时候初始化

(1) 初始化只有一次,但是可以多次赋值,在主程序之前,编译器已经为其分配好了内存。

(2) 静态局部变量和全局变量一样,数据都存放在全局区域,

  • 在主程序之前,编译器已经为其分配好了内存,但在C和C++中静态局部变量的初始化节点又有点不太一样。在C中,初始化发生在代码执行之前,编译阶段分配好内存之后,就会进行初始化,在C语言中无法使用变量对静态局部变量进行初始化,在程序运行结束,变量所处的全局内存会被全部回收。

(3) 而在C++中,初始化时在执行相关代码时才会进行初始化,主要是由于C++引入对象后,要进行初始化必须执行相应构造函数和析构函数,在构造函数或析构函数中经常会需要进行某些程序中需要进行的特定操作,并非简单地分配内存。

  • 所以C++标准定为全局或静态对象是有首次用到时才会进行构造,并通过atexit()来管理。在程序结束,按照构造顺序反方向进行逐个析构。所以在C++中是可以使用变量对静态局部变量进行初始化的。
52、const关键字?
  • 阻止一个变量被改变,可以使用const关键字。在定义该const变量时,通常需要对它进行初始化,因为以后就没有机会再去改变它了;
  • 对指针来说,可以指定指针本身为const,也可以指定指针所指的数据为const,或二者同时指定为const;
  • 在一个函数声明中,const可以修饰形参,表明它是一个输入参数,在函数内部不能改变其值;
  • 对于类的成员函数,若指定其为const类型,则表明其是一个常函数,不能修改类的成员变量,类的常对象只能访问类的常成员函数;
  • 对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为“左值”。
  • const成员函数可以访问非const对象的非const数据成员、const数据成员,也可以访问const对象内的所有数据成员;
  • 非const成员函数可以访问非const对象的非const数据成员、const数据成员,但不可以访问const对象的任意数据成员;
  • 一个没有明确声明为const的成员函数被看作是将要修改对象中数据成员的函数,而且编译器不允许它为一个const对象所调用。因此const对象只能调用const成员函数。
  • const类型变量可以通过类型转换符const_cast将const类型转换为非const类型;
  • const类型变量必须定义的时候进行初始化,因此也导致如果类的成员变量有const类型的变量,那么该变量必须在类的初始化列表中进行初始化;
  • 对于函数值传递的情况,因为参数传递是通过复制实参创建一个临时变量传递进函数的,函数内只能改变临时变量,但无法改变实参。则这个时候无论加不加const对实参不会产生任何影响。但是在引用或指针传递函数调用中,因为传进去的是一个引用或指针,这样函数内部可以改变引用或指针所指向的变量,这时const 才是实实在在地保护了实参所指向的变量。因为在编译阶段编译器对调用函数的选择是根据实参进行的,所以,只有引用传递和指针传递可以用是否加const来重载。一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来。
54、形参与实参的区别?
  • 形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元。因此,形参只有在函数内部有效。 函数调用结束返回主调函数后则不能再使用该形参变量。
  • 实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值,会产生一个临时变量。
  • 实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
  • 函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。 因此在函数调用过程中,形参的值发生改变,而实参中的值不会变化。
  • 当形参和实参不是指针类型时,在该函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变。
55、值传递、指针传递、引用传递的区别和效率
  • 值传递:
    • 有一个形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象 或是大的结构体对象,将耗费一定的时间和空间。(传值)
  • 指针传递:
    • 同样有一个形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定为4字节的地址。(传值,传递的是地址值)
  • 引用传递:
    • 同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。(传地址)
  • 效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰。
56、什么是类的继承?
  • 类与类之间的关系

    • has-A包含关系,
      • 用以描述一个类由多个部件类构成,实现has-A关系用类的成员属性表示,即一个类的成员属性是另一个已经定义好的类;
    • use-A,一个类使用另一个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数的方式来实现;
    • is-A,继承关系,关系具有传递性;
  • 继承的相关概念

    • 继承:一个类继承了另一个类的属性和方法,这个新的类包含了上一个类的属性和方法,被称为子类或者派生类,被继承的类称为父类或者基类;
  • 继承的特点

    • ​ 子类拥有父类的所有属性和方法,
    • 子类可以拥有父类没有的属性和方法,
    • 子类对象可以当做父类对象使用;
  • 继承中的访问控制

    • public、
    • protected、
    • private
  • 继承中的构造和析构函数

  • 继承中的兼容性原则

57、什么是内存池,如何实现
  1. 内存池概念
  • 内存池(Memory Pool) 是一种内存分配方式。
  • new、malloc 等申请内存的缺点:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。
  • 内存池:真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块, 若内存块不够再继续申请新的内存
  • 优点:尽量避免了内存碎片,使得内存分配效率得到提升。
  1. allocate包装malloc,deallocate包装free

    操作解释
    allocator<T> a定义了一个名为aallocator对象,它可以为类型T的对象分配内存
    a.allocator(n)分配一段原始的、未构造的内存,这段内存能保存n个类型为T的对象,调用::operator new()
    a.deallocate(p,n)释放T*指针p地址开始的内存,这块内存保存了n个类型为T的对象,p必须是一个先前由allocate返回的指针,且n必须是p创建时所要求的大小,且在调用该函数之前必须销毁在这片内存上创建的对象。调用::operator delete()
    a.construct(p,args)p必须是一个类型为T* 的指针,指向一片原始内存,arg将被传递给类型为T的构造函数,用来在p指向的原始内存上构建对象。
    a.destory(p)p为T*类型的指针,用于对p指向的对象执行析构函数.
    #include <iostream>
    using namespace std;
    void main(){
    	std::allocator<int> alloc;
        std::allocator<int> ::value_type* p = alloc.allocate(5);
        alloc.construct(p,10);
        alloc.construct(p+1,20);
        alloc.construct(p+2,30);
        alloc.construct(p+3,40);
        alloc.construct(p+4,50);
        for (int i=0;i<5;++i)
        {
            cout<<p[i]<<endl;
        }
        alloc.destory(p);
        alloc.destory(p+1);
        alloc.destory(p+2);
        alloc.destory(p+3);
        alloc.destory(p+4);
    }
    
  2. 内存池处理流程

    • 首先,调用malloc()配置一定数量的区块(固定大小的内存块,通常为8的倍数),假设40个32bytes的区块,其中20个区块(一半)给程序实际使用,1个区块交出,另外19个处于维护状态。剩余20个(一半)留给内存池,此时一共有(20*32byte

    • 之后有内存需求,想申请(20*64bytes)的空间,这时内存池只有(20*32bytes),就先将(20*32bytes)个区块返回,1个区块交出,另外9个处于维护状态,此时内存池空空如也

    • 接下来如果客户端还有内存需求,就必须再调用malloc()配置空间,此时新申请的区块数量会增加一个随着配置次数越来越大的附加量,同样一半提供程序使用,另一半留给内存池。申请内存的时候用永远是先看内存池有无剩余,有的话就用上,然后挂在0-15号某一条链表上,要不然就重新申请。

    • 如果整个堆的空间都不够了,就会在原先已经分配区块中寻找能满足当前需求的区块数量,能满足就返回,不能满足就向客户端报bad_alloc异常

58、从汇编层去解释一下引用
int x = 1;
00401048  mov     dword ptr [ebp-4],1

int &b = x;
0040104F   lea     eax,[ebp-4]
00401052  mov     dword ptr [ebp-8],eax

x的地址为ebp-4,b的地址为ebp-8,因为栈内的变量内存是从高往低进行分配的,所以b的地址比x的低。

lea eax,[ebp-4] 这条语句将x的地址ebp-4放入eax寄存器

mov dword ptr [ebp-8],eax 这条语句将eax的值放入b的地址ebp-8

上面两条汇编的作用即:将x的地址存入变量b中,汇编层次来看,的确引用是通过指针来实现的。

59、C++实现反射
  • C++并不支持反射机制。
60、C++模板是什么,底层怎么实现的?
  • 编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。

  • 模板函数写到头文件的原因

    • 函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。
61、new和malloc的区别?
  • new/delete是C++关键字,需要编译器支持。malloc/free是库函数,需要头文件支持*
  • 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸。
  • new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。
    • 而malloc内存分配成功则是返回void *,需要通过强制类型转换将void*指针转换成我们需要的类型。
  • new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL
  • new会先调用operator new函数,申请足够的内存。调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存。malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
62、delete p、delete [] p、allocator都有什么作用?
  • 动态数组管理new一个数组时,[]中必须是一个整数,但是不一定是常量整数,普通数组必须是一个常量整数;
  • new动态数组返回的并不是数组类型,而是一个元素类型的指针;
  • delete[]时,数组中的元素按逆序的顺序进行销毁;
  • new在内存分配上面有一些局限性,new的机制是将内存分配和对象构造组合在一起,同样的,delete也是将对象析构和内存释放组合在一起的。allocator将这两部分分开进行,allocator申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。
63、new和delete的实现原理, delete是如何知道释放内存的大小的额**?**
  • new
    • new简单类型直接调用operator new分配内存;
    • 复杂结构,
      • 先调用operator new分配内存,
      • 然后在分配的内存上调用构造函数;
  • new[]
    • 简单类型,
      • new[]计算好大小后调用operator new;
    • 复杂数据结构,
      • new[]先调用operator new[]分配内存,然后在p的前四个字节写入数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小
        • ① new表达式调用一个名为operator new(operator new[])函数,分配一块足够大的、原始的、未命名的内存空间;
        • ② 编译器运行相应的构造函数以构造这些对象,并为其传入初始值;
        • ③ 对象被分配了空间并构造完成,返回一个指向该对象的指针。
  • delete
    • 简单数据类型
      • 默认只是调用free函数;
    • 复杂数据类型
      • 先调用析构函数,
      • 再调用operator delete;
  • delete[]
    • 针对简单类型,deletedelete[]等同。
    • 假设指针p指向new[]分配的内存。因为要4字节存储数组大小,
64、malloc申请的存储空间能用delete释放吗
  • 不能,malloc /free主要为了兼容C,new和delete 完全可以取代malloc /free的。

  • malloc /free的操作对象都是必须明确大小的,而且不能用在动态类上。

  • new 和delete会自动进行类型检查和大小,malloc/free不能执行构造函数与析构函数,所以动态对象它是不行的。

当然从理论上说使用malloc申请的内存是可以通过delete释放的。不过一般不这样写的。而且也不能保证每个C++的运行时都能正常。

65、malloc与free的实现原理?
  • 在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由**brkmmap、,munmap**这些系统调用实现的;

  • brk是将数据段(.data)的最高地址指针_edata高地址推。mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系;

  • malloc小于128k的内存,使用brk分配内存,将_edata往高地址推;

  • malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配;

  • brk分配的内存需要等到高地址内存释放以后才能释放,而mmap分配的内存可以单独释放。

  • 当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。在上一个步骤free的时候,发现最高地址空闲内存超过128K,于是内存紧缩。

  • ​ malloc是从堆里面申请内存,函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。

66、malloc、realloc、calloc的区别
  1. malloc函数
void* malloc(unsigned int num_size);
int *p = malloc(20*sizeof(int));申请20int类型的空间;
  1. calloc函数
void* calloc(size_t n,size_t size);
int *p = calloc(20, sizeof(int));

省去了人为空间计算;malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的;

  1. realloc函数
void realloc(void *p, size_t new_size);

给动态分配的空间分配额外的空间,用于扩充容量。

67、类成员初始化方式?构造函数的执行顺序 ?为什么用成员初始化列表会快一些?
  • 赋值初始化,通过在函数体内进行赋值初始化;

  • 列表初始化,在冒号后使用初始化列表进行初始化。

  • 主要区别在于:

    • 对于在函数体中初始化:是在所有的数据成员被分配内存空间后才进行的。
    • 列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。
    • 方法一是在构造函数当中做赋值的操作,而方法二是做纯粹的初始化操作。C++的赋值操作是会产生临时对象的。临时对象的出现会降低程序的效率。

(1) 一个派生类构造函数的执行顺序如下:

  • ① 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。
  • ② 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
  • ③ 类类型的成员对象的构造函数(按照初始化顺序)
  • ④ 派生类自己的构造函数。
68、成员列表初始化?
  • 必须使用成员初始化的四种情况

    • ① 当初始化一个引用成员时;
    • ② 当初始化一个常量成员时;
    • ③ 当调用一个基类的构造函数,而它拥有一组参数时;
    • ④ 当调用一个成员类的构造函数,而它拥有一组参数时;
  • 成员初始化列表做了什么

  • ① 编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作,并且在任何显示用户代码之前;

  • ② list中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的;

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

内存泄露

  • 堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。

避免内存泄露的几种方式

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

检测工具

  • Linux下可以使用Valgrind工具
  • Windows下可以使用CRT库
70、对象复用的了解,零拷贝的了解
  • 对象复用
    • 对象复用其本质是一种设计模式:Flyweight享元模式。
    • 通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。
  • 零拷贝
    • 零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。
    • 零拷贝技术可以减少数据拷贝和共享总线操作的次数。
    • vector的成员函数**emplace_back()**很好地体现了零拷贝技术
      • 它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:
        • 使用push_back()函数需要调用拷贝构造函数和转移构造函数,
        • 而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造,效率更高。:
#include <vector>
#include <string>
#include <iostream>
using namespace std;

struct Person
{
    string name;
    int age;
    //初始构造函数
    Person(string p_name, int p_age): name(std::move(p_name)), age(p_age)
    {
         cout << "I have been constructed" <<endl;
    }
     //拷贝构造函数
     Person(const Person& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been copy constructed" <<endl;
    }
     //转移构造函数
     Person(Person&& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been moved"<<endl;
    }
};

int main()
{
    vector<Person> e;
    cout << "emplace_back:" <<endl;
    e.emplace_back("Jane", 23); //不用构造类对象

    vector<Person> p;
    cout << "push_back:"<<endl;
    p.push_back(Person("Mike",36));
    return 0;
}
//输出结果:
//emplace_back:
//I have been constructed
//push_back:
//I have been constructed
//I am being moved.


71、解释一下什么是trivial destructor

trivial destructor一般是指用户没有自定义析构函数,而由系统生成的。

non-trivial destructor用户自定义了析构函数,这种析构函数如果申请了新的空间一定要显式的释放,否则会造成内存泄露

  • trivial destructor
    • 首先利用value_type()获取所指对象的型别,再利用__type_traits<T>判断该型别的析构函数是否trivial,若是__true_type,则什么也不做,若为__false_type,则去调用destory()函数
72、介绍面向对象的三大特性,并且举例说明

三大特性:继承、封装和多态

(1)继承

让某种类型对象获得另一个类型对象的属性和方法。

它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展

常见的继承有三种方式:

  1. 实现继承:指使用基类的属性和方法而无需额外编码的能力
  2. 接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力
  3. 可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力(C++里好像不怎么用)

例如,将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉、走路等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法

(2)封装

数据和代码捆绑在一起,避免外界干扰和不确定性访问。

封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏,例如:将公共的数据或方法使用public修饰,而不希望被访问的数据或方法采用private修饰。

(3)多态

同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为**(重载实现编译时多态,虚函数实现运行时多态)**。

73、C++中类的数据成员和成员函数内存分布情况
  • C++类是由结构体发展得来的,所以他们的成员变量(C语言的结构体只有成员变量)的内存分配机制是一样的。

  • 一个类对象的地址:类所包含的这一片内存空间的首地址。这个首地址也就对应具体某一个成员变量的地址。(在定义类对象的同时这些成员变量也就被定义了),举个例子:

#include <iostream>
using namespace std;

class Person
{
public:
    Person()
    {
        this->age = 23;
    }
    void printAge()
    {
        cout << this->age <<endl;
    }
    ~Person(){}
public:
    int age;
};

int main()
{
    Person p;
    cout << "对象地址:"<< &p <<endl;
    cout << "age地址:"<< &(p.age) <<endl;
    cout << "对象大小:"<< sizeof(p) <<endl;
    cout << "age大小:"<< sizeof(p.age) <<endl;
    return 0;
}
//输出结果
//对象地址:0x7fffec0f15a8
//age地址:0x7fffec0f15a8
//对象大小:4
//age大小:4
  • 成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。要是成员函数占用类的对象空间,
  • 静态成员函数与一般成员函数的唯一区别就是没有this指针,因此不能访问非静态数据成员,就像我前面提到的,所有函数都存放在代码区,静态函数也不例外。
74、成员初始化列表的概念,为什么用它会快一些?
  • 成员初始化列表的概念

    • 在类的构造函数中,不在函数体内对成员变量赋值,而是在构造函数的花括号前面使用冒号和初始化列表赋值
  • 效率

    • 对于类型,它少了一次调用构造函数的过程,
    • 而在函数体中赋值则会多一次调用。而对于内置数据类型则没有差别。举个例子:
#include <iostream>
using namespace std;
class A
{
public:
    A()
    {
        cout << "默认构造函数A()" << endl;
    }
    A(int a)
    {
        value = a;
        cout << "A(int "<<value<<")" << endl;
    }
    A(const A& a)
    {
        value = a.value;
        cout << "拷贝构造函数A(A& a):  "<<value << endl;
    }
    int value;
};
class B
{
public:
    B() : a(1)
    {
        b = A(2);
    }
    A a;
    A b;
};
int main()
{
    B b;
}
//输出结果:
//A(int 1)
//默认构造函数A()
//A(int 2)

在构造函数体内部初始化的对象b多了一次构造函数的调用过程,而对象a则没有。由于对象成员变量的初始化动作发生在进入构造函数之前,对于内置类型没什么影响,但如果有些成员是类,那么在进入构造函数之前,会先调用一次默认构造函数,进入构造函数后所做的事其实是一次赋值操作(对象已存在),所以如果是在构造函数体内进行赋值的话,等于是一次默认构造加一次赋值,而初始化列表只做一次赋值操作。

75、(超重要)构造函数为什么不能为虚函数?析构函数为什么要虚函数?⭐️⭐️⭐️⭐️⭐️
  • 存储空间角度,虚函数相应一个指向vtable虚函数表的指针。指向vtable的指针事实上是存储在对象的内存空间的。构造函数是虚的,就须要通过 vtable来调用,但是对象还没有实例化,内存空间还没有开辟,

  • 使用角度,虚函数主要用于在信息不全的情况下,能使重载的函数得到相应的调用。构造函数本身就是要初始化实例,使用虚函数没有实际意义。

    • 虚函数的作用在于通过父类的指针或者引用来调用它的时候可以变成调用子类的那个成员函数。
    • 构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
  • 构造函数不须要是虚函数,也不同意是虚函数

  • 实现vbtl在构造函数调用后才建立,因而构造函数不可能成为虚函数。

C++中基类采用**virtual虚析构函数为了防止内存泄漏。**

  • 为了实现动态绑定,基类指针指向派生类对象,如果析构函数不是虚函数,那么在对象销毁时,就会调用基类的析构函数,只能销毁派生类对象中的部分数据,所以必须将析构函数定义为虚函数,从而在对象销毁时,调用派生类的析构函数,从而销毁派生类对象中的所有数据。
76、析构函数的作用,如何起作用?
  • 构造函数起初始化值的作用,但实例化一个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数里面,这样就使其他的函数里面有值了。

    • 规则:只要你一实例化对象,系统自动回调用一个构造函数。就是你不写,编译器也自动调用一次。
  • 析构函数与构造函数的作用相反,用于撤销对象的一些特殊任务处理,可以是释放对象分配的内存空间;

  • 特点:析构函数与构造函数同名,但该函数前面加~。

  • 析构函数没有参数,也没有返回值,而且不能重载,在一个类中只能有一个析构函数。 当撤销对象时,编译器也会自动调用析构函数。

  • 每一个类必须有一个析构函数,用户可以自定义析构函数,也可以是编译器自动生成默认的析构函数。一般析构函数定义为类的公有成员。

77、构造函数和析构函数可以调用虚函数吗,为什么
  • 在C++中,提倡不在构造函数和析构函数中调用虚函数;

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

    • 联编:将模块或者函数合并在一起生成可执行代码的处理过程,同时,对每个模块或者函数调用分配内存地址,并且对外部访问也分配正确的内存地址
      • 按照联编所进行的阶段不同,可分为两种不同的联编方法:静态联编和动态联编
        • 静态联编是指在编译阶段就将函数实现和函数调用关联起来,也称为早绑定。
          • 在编译阶段即必须模块所有的函数或模块执行所需要检测的信息,对函数的选择是指向对象的指针(或引用)的类型
        • 动态联编是指在程序执行的时候才将函数实现和函数调用关联,也叫运行时绑定或者晚绑定
          • 动态联编对函数的选择时基于对象类型,不同的对象类型将做出部疼痛的编译结果。
          • C++中一般情况下联编也是静态联编,但一旦涉及到多态和虚拟函数就必须使用动态联编
  • 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;

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

78、构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥?

1) 构造函数顺序

① 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。

② 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。

③ 派生类构造函数。

2) 析构函数顺序

① 调用派生类的析构函数;

② 调用成员类对象的析构函数;

③ 调用基类的析构函数。

79、虚析构函数的作用,父类的析构函数是否要设置为虚函数?
  1. C++中基类采用virtual虚析构函数是为了防止内存泄漏。

具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。

假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。

那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。

所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。

  1. 纯虚析构函数一定得定义,因为每一个派生类析构函数会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上一层基类的析构函数。

因此,缺乏任何一个基类析构函数的定义,就会导致链接失败,最好不要把虚析构函数定义为纯虚析构函数。

80、构造函数析构函数可否抛出异常
  • 语法角度上可以
  • 逻辑和风险控制上:不建议
  • 构造函数
      1. C++只会析构已经完成的对象,对象只有在其构造函数执行完毕才算是完全构造妥当。在构造函数中发生异常,控制权转出构造函数之外。 析构函数不会被调用。因此会造成内存泄漏。
      1. 用auto_ptr对象来取代指针类成员,便对构造函数做了强化,免除了抛出异常时发生资源泄漏的危机,不再需要在析构函数中手动释放资源;
  • 析构函数
      1. 如果控制权基于异常的因素离开析构函数,而此时正有另一个异常处于作用状态,C++会调用terminate函数让程序结束;
      1. 如果异常从析构函数抛出,而且没有在当地进行捕捉,那个析构函数便是执行不全的。如果析构函数执行不全,就是没有完成他应该执行的每一件事情。
82、类什么时候会析构?
  • 对象生命周期结束,被销毁时;
  • delete指向对象的指针时,或delete指向对象的基类类型指针,而其基类虚构函数是虚函数时;
  • 对象i是对象o的成员,o的析构函数被调用时,对象i的析构函数也被调用。
84、智能指针的原理、常用的智能指针及实现

原理

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

常用的智能指针

(1) shared_ptr

  • 实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。
    • 智能指针将一个计数器与类指向的对象相关联,引用计数器跟踪共有多少个类对象共享同一指针
    • 每次创建类的新对象时,初始化指针并将引用计数置为1
    • 当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数
    • 对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数
    • 调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)

(2) unique_ptr

  • unique_ptr:独享所有权语义,一个非空的unique_ptr总是拥有它所指向的资源。
    • 转移一个unique_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空;
      • unique_ptr不支持普通的拷贝和赋值操作
      • 不能用在STL标准容器中;局部变量的返回值除外(因为编译器知道要返回的对象将要被销毁);

(3) weak_ptr

  • weak_ptr:弱引用。
    • 引用计数有一个问题就是互相引用形成环(环形引用),这样两个指针指向的内存都无法释放。需要使用weak_ptr打破环形引用。weak_ptr是一个弱引用,它是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数。如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。所以weak_ptr不保证它指向的内存一定是有效的,在使用之前使用函数lock()检查weak_ptr是否为空指针。

(4) auto_ptr

  • 主要是为了解决“有异常抛出时发生内存泄漏”的问题 。因为发生异常而无法正常释放内存。
  • auto_ptr有拷贝语义,拷贝后源对象变得无效,这可能引发很严重的问题;
  • unique_ptr则无拷贝语义,但提供了移动语义,这样的错误不再可能发生,因为很明显必须使用std::move()进行转移。
    • auto_ptr不支持拷贝和赋值操作,
    • 不能用在STL标准容器中。STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,所以不能在STL中使用。

智能指针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;//指向引用计数的指针
};
85、构造函数的几种关键字
  • default
    • default关键字可以显式要求编译器生成合成构造函数,防止在调用时相关构造函数类型没有定义而报错
#include <iostream>
using namespace std;

class CString
{
public:
    CString() = default; //语句1
    //构造函数
    CString(const char* pstr) : _str(pstr){}
    void* operator new() = delete;//这样不允许使用new关键字
    //析构函数
    ~CString(){}
public:
     string _str;
};


int main()
{
   auto a = new CString(); //语句2
   cout << "Hello World" <<endl;
   return 0;
}
//运行结果
//Hello World

如果没有加语句1,语句2会报错,表示找不到参数为空的构造函数,将其设置为default可以解决这个问题

  • delete
    • delete关键字可以删除构造函数、赋值运算符函数等,这样在使用的时候会得到友善的提示
#include <iostream>
using namespace std;

class CString
{
public:
    void* operator new() = delete;//这样不允许使用new关键字
    //析构函数
    ~CString(){}
};

int main()
{
   auto a = new CString(); //语句1
   cout << "Hello World" <<endl;
   return 0;
}

在执行语句1时,会提示new方法已经被删除,如果将new设置为私有方法,则会报惨不忍睹的错误,因此使用delete关键字可以更加人性化的删除一些默认方法

0

将虚函数定义为纯虚函数(纯虚函数无需定义,= 0只能出现在类内部虚函数的声明语句处;当然,也可以为纯虚函数提供定义,不过函数体必须定义在类的外部)

86、C++的四种强制转换reinterpret_cast/const_cast/static_cast /dynamic_cast
  • reinterpret_cast

    • reinterpret_cast<type-id> (expression)
      • type-id 必须是一个指针、引用、算术类型、函数指针或者成员指针。它可以用于类型之间进行强制转换。
  • const_cast

    • const_cast<type_id> (expression)
    • 该运算符用来修改类型的const或volatile属性。除了const 或volatile修饰之外, type_id和expression的类型是一样的。用法如下:
      • 常量指针被转化成非常量的指针,并且仍然指向原来的对象
      • 常量引用被转换成非常量的引用,并且仍然指向原来的对象
      • const_cast一般用于修改底指针。如const char *p形式
  • static_cast

    • static_cast < type-id > (expression)
      • 该运算符把expression转换为type-id类型,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:

      • 用于类层次结构中基类(父类)和派生类(子类)之间指针或引用引用的转换

        • 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的

        • 进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的

          • 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。

          • 把空指针转换成目标类型的空指针

          • 把任何类型的表达式转换成void类型

  • 注意:static_cast不能转换掉expression的const、volatile、或者__unaligned属性。

  • dynamic_cast:有类型检查,基类向派生类转换比较安全,但是派生类向基类转换则不太安全

    • dynamic_cast <type-id> (expression)
    • 该运算符把expression转换成type-id类型的对象。type-id 必须是类的指针、类的引用或者void*
    • 如果 type-id 是类指针类型,那么expression也必须是一个指针,如果 type-id 是一个引用,那么 expression 也必须是一个引用
    • dynamic_cast运算符可以在执行期决定真正的类型,也就是说expression必须是多态类型。如果下行转换是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果 如果下行转换不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象)
    • dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换
    • 在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的
    • 在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全

举个例子:

#include <bits/stdc++.h>
using namespace std;

class Base
{
public:
	Base() :b(1) {}
	virtual void fun() {};
	int b;
};

class Son : public Base
{
public:
	Son() :d(2) {}
	int d;
};

int main()
{
	int n = 97;

	//reinterpret_cast
	int *p = &n;
	//以下两者效果相同
	char *c = reinterpret_cast<char*> (p); 
	char *c2 =  (char*)(p);
	cout << "reinterpret_cast输出:"<< *c2 << endl;
	//const_cast
	const int *p2 = &n;
	int *p3 = const_cast<int*>(p2);
	*p3 = 100;
	cout << "const_cast输出:" << *p3 << endl;
	
	Base* b1 = new Son;
	Base* b2 = new Base;

	//static_cast
	Son* s1 = static_cast<Son*>(b1); //同类型转换
	Son* s2 = static_cast<Son*>(b2); //下行转换,不安全
	cout << "static_cast输出:"<< endl;
	cout << s1->d << endl;
	cout << s2->d << endl; //下行转换,原先父对象没有d成员,输出垃圾值

	//dynamic_cast
	Son* s3 = dynamic_cast<Son*>(b1); //同类型转换
	Son* s4 = dynamic_cast<Son*>(b2); //下行转换,安全
	cout << "dynamic_cast输出:" << endl;
	cout << s3->d << endl;
	if(s4 == nullptr)
		cout << "s4指针为nullptr" << endl;
	else
		cout << s4->d << endl;
	
	
	return 0;
}
//输出结果
//reinterpret_cast输出:a
//const_cast输出:100
//static_cast输出:
//2
//-33686019
//dynamic_cast输出:
//2
//s4指针为nullptr

从输出结果可以看出,在进行下行转换时,dynamic_cast安全的,如果下行转换不安全的话其会返回空指针,这样在进行操作的时候可以预先判断。而使用static_cast下行转换存在不安全的情况也可以转换成功,但是直接使用转换后的对象进行操作容易造成错误。

87、C++函数调用的压栈过程
#include <iostream>
using namespace std;

int f(int n) 
{
	cout << n << endl;
	return n;
}

void func(int param1, int param2)
{
	int var1 = param1;
	int var2 = param2;
	printf("var1=%d,var2=%d", f(var1), f(var2));
}

int main(int argc, char* argv[])
{
	func(1, 2);
	return 0;
}
//输出结果
//2
//1
//var1=1,var2=2



  1. 当函数从入口函数main函数开始执行时,编译器会将我们
  • 操作系统的运行状态,

  • main函数的返回地址、

  • main的参数、

  • mian函数中的变量、进行依次压栈;

  1. 当main函数开始调用func()函数时,编译器

    • 此时会将main函数的运行状态进行压栈,

    • 再将func()函数的返回地址、

    • func()函数的参数从右到左、

    • func()定义变量依次压栈;

  2. 当func()调用f()的时候,编译器此时会将

    • func()函数的运行状态进行压栈,
    • 再将func()的返回地址f()函数的参数从右到左、f()定义变量依次压栈

将栈中的变量依次弹出,最后主函数返回。

88、说说移动构造函数
  • 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。

  • 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。

89、C++中将临时变量作为返回值时的处理过程
  • 临时变量:在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了

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

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

90、关于this指针你知道什么?全说出来
  • 什么是this指针

    • this指针是类的指针,指向对象的首地址

    • this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。

    • this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。

  • this指针的用处

    • 一个对象的this指针并不是对象本身的一部分,不会影响sizeof(对象)的结果。
    • this作用域是在类内部
    • 当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行
  • this指针的使用

    • 在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;
    • 当形参数与成员变量名相同时用于区分,如this->n = n (不能写成n = n)
  • 类的this指针有以下特点

    • *this只能在成员函数中使,全局函数、静态函数都不能使用this。实际上,成员函数默认第一个参数T * const this
    • this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。
91、几个this指针的易混问题

A. this指针是什么时候创建的?

  • this在成员函数的开始执行前构造,在成员的执行结束后清除。
  • 如果class或者struct里面没有方法的话,它们是没有构造函数的,只能当做C的struct使用。
    • 采用TYPE xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。
  • 采用new的方式创建对象的话,在堆里分配内存,new操作符通过eax(累加寄存器)返回分配的地址,然后设置给指针变量。之后去调用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx

B. this指针存放在何处?堆、栈、全局变量,还是其他?

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

C. this指针是如何传递类中的函数的?绑定?还是在函数参数的首参数就是this指针?那么,this指针又是如何找到“类实例后函数的”?

  • 大多数编译器通过ecx(寄数寄存器)寄存器传递this指针。
    • 一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。在call之前,编译器会把对应的对象地址放到eax中。
  • this是通过函数参数的首参来传递的。this指针在调用之前生成,
  • 至于“类实例后函数”,没有这个说法。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的

D. this指针是如何访问类中的变量的?

E.我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗?

  • **this指针只有在成员函数中才有定义。**因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。

F.每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?

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

    • 对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数
  • 拷贝构造函数

    • 对象不存在,但是使用别的已经存在的对象来进行初始化
  • 赋值运算符

    • 对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的
93、拷贝构造函数和赋值运算符重载的区别?
  • 拷贝构造函数是函数,赋值运算符是运算符重载。

  • 拷贝构造函数会生成新的类对象,赋值运算符不能。

  • 拷贝构造函数是直接构造一个新的类对象,所以在初始化对象前不需要检查源对象和新建对象是否相同;赋值运算符需要上述操作并提供两套不同的复制策略,另外赋值运算符中如果原来的对象有内存分配则需要先把内存释放掉。

  • 形参传递是调用拷贝构造函数(调用的被赋值对象的拷贝构造函数),但并不是所有出现"="的地方都是使用赋值运算符,如下:

    Student s;
    Student s1 = s;    // 调用拷贝构造函数
    Student s2;
    s2 = s;    // 赋值运算符操作
    

注:类中有指针变量时要重写析构函数、拷贝构造函数和赋值运算符

95、说说你了解的auto_ptr作用
  • auto_ptr的出现,主要是为了解决“有异常抛出时发生内存泄漏”的问题;抛出异常,将导致指针p所指向的空间得不到释放而导致内存泄漏;

  • auto_ptr构造时取得某个对象的控制权,在析构时释放该对象。我们实际上是创建一个auto_ptr<Type>类型的局部对象,该局部对象析构时,会将自身所拥有的指针空间释放,所以不会有内存泄漏;

  • auto_ptr的构造函数是explicit,阻止了一般指针隐式转换为 auto_ptr的构造,所以不能直接将一般类型的指针赋值给auto_ptr类型的对象,必须用auto_ptr的构造函数创建对象;

  • 由于auto_ptr对象析构时会删除它所拥有的指针,所以使用时避免多个auto_ptr对象管理同一个指针;

  • Auto_ptr内部实现,析构函数中删除对象用的是delete而不是delete[],所以auto_ptr不能管理数组;

  • auto_ptr支持所拥有的指针类型之间的隐式类型转换。

  • 可以通过*->运算符对auto_ptr所有用的指针进行提领操作;

  • ``T* get(),获得auto_ptr所拥有的指针;T* release()`,释放auto_ptr的所有权,并将所有用的指针返回。

97、什么是虚拟继承

由于C++支持多继承,除了public、protected和private三种继承方式外,还支持虚拟(virtual)继承,举个例子:

#include <iostream>
using namespace std;

class A{}
class B : virtual public A{};
class C : virtual public A{};
class D : public B, public C{};

int main()
{
    cout << "sizeof(A):" << sizeof A <<endl; // 1,空对象,只有一个占位
    cout << "sizeof(B):" << sizeof B <<endl; // 4,一个bptr指针,省去占位,不需要对齐
    cout << "sizeof(C):" << sizeof C <<endl; // 4,一个bptr指针,省去占位,不需要对齐
    cout << "sizeof(D):" << sizeof D <<endl; // 8,两个bptr,省去占位,不需要对齐
}

上述代码所体现的关系是,B和C虚拟继承A,D又公有继承B和C,这种方式是一种菱形继承或者钻石继承,可以用如下图来表示

  • **虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。**虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量,此类指针被称为bptr,如上图所示。如果既存在vptr又存在bptr,某些编译器会将其优化,合并为一个指针
98、如何获得结构成员相对于结构开头的字节偏移量

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

#include <iostream>
#include <stddef.h>
using namespace std;

struct  S
{
	int x;
	char y;
	int z;
	double a;
};
int main()
{
	cout << offsetof(S, x) << endl; // 0
	cout << offsetof(S, y) << endl; // 4
	cout << offsetof(S, z) << endl; // 8
	cout << offsetof(S, a) << endl; // 12
	return 0;
}

在VS2019 + win下 并不是这样的
    
    cout << offsetof(S, x) << endl; // 0
	cout << offsetof(S, y) << endl; // 4
	cout << offsetof(S, z) << endl; // 8
	cout << offsetof(S, a) << endl; // 16 这里是 16的位置,因为 double是8字节,需要找一个8的倍数对齐,
//当然了,如果加上  #pragma pack(4)指定 4字节对齐就可以了
#pragma pack(4)
struct  S
{
	int x;
	char y;
	int z;
	double a;
};
void test02()
{

	cout << offsetof(S, x) << endl; // 0
	cout << offsetof(S, y) << endl; // 4
	cout << offsetof(S, z) << endl; // 8
	cout << offsetof(S, a) << endl; // 12
    }

S结构体中各个数据成员的内存空间划分如下所示,需要注意内存对齐

99、静态类型和动态类型,静态绑定和动态绑定的介绍
  • 静态类型:对象在声明时采用的类型,在编译期既已确定;
  • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。

#include <iostream>
using namespace std;

class A
{
public:
	/*virtual*/ void func() { std::cout << "A::func()\n"; }
};
class B : public A
{
public:
	void func() { std::cout << "B::func()\n"; }
};
class C : public A
{
public:
	void func() { std::cout << "C::func()\n"; }
};
int main()
{
	C* pc = new C(); //pc的静态类型是它声明的类型C*,动态类型也是C*;
	B* pb = new B(); //pb的静态类型和动态类型也都是B*;
	A* pa = pc;      //pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C*;
	pa = pb;         //pa的动态类型可以更改,现在它的动态类型是B*,但其静态类型仍是声明时候的A*;
	C *pnull = NULL; //pnull的静态类型是它声明的类型C*,没有动态类型,因为它指向了NULL;
    
    pa->func();      //A::func() pa的静态类型永远都是A*,不管其指向的是哪个子类,都是直接调用A::func();
	pc->func();      //C::func() pc的动、静态类型都是C*,因此调用C::func();
	pnull->func();   //C::func() 不用奇怪为什么空指针也可以调用函数,因为这在编译期就确定了,和指针空不空没关系;
	return 0;
}

如果将A类中的virtual注释去掉,则运行结果是:

pa->func();      //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
pc->func();      //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
pnull->func();   //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;

本文代码里都是针对指针的情况来分析的,但是对于引用的情况同样适用。

至此总结一下静态绑定和动态绑定的区别:

  • 静态绑定发生在编译期,动态绑定发生在运行期;

  • 对象的动态类型可以更改,但是静态类型无法更改;

  • 要想实现动态,必须使用动态绑定;

  • 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

101、引用是否能实现动态绑定,为什么可以实现?
  • 可以。

  • 引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数。

#include <iostream>
using namespace std;

class Base 
{
public:
	virtual void  fun()
	{
		cout << "base :: fun()" << endl;
	}
};

class Son : public Base
{
public:
	virtual void  fun()
	{
		cout << "son :: fun()" << endl;
	}
	void func()
	{
		cout << "son :: not virtual function" <<endl;
	}
};

int main()
{
	Son s;
	Base& b = s; // 基类类型引用绑定已经存在的Son对象,引用必须初始化
	s.fun(); //son::fun()
	b.fun(); //son :: fun()
	return 0;
}


需要说明的是虚函数才具有动态绑定,上面代码中,Son类中还有一个非虚函数func(),这在b对象中是无法调用的,如果使用基类指针来指向子类也是一样的。

102、全局变量和局部变量有什么区别?
  • 生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;

  • 使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。

操作系统和编译器通过内存分配的位置可以区分两者,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。

103、指针加减计算要注意什么?
  • 指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,
  • 因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,如果再进行操作就会很危险

需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度,建议都转成10进制计算,计算结果除以类型长度取得结果

104、 怎样判断两个浮点数是否相等?
  • 对两个浮点数判断大小和是否相等不能直接用==来判断,会出错!
    • 对于两个浮点数比较只能通过相减并与预先设定的精度比较,要取绝对值!⭐️ ⭐️⭐️。浮点数与0的比较也应该注意。与浮点数的表示方式有关。
105、方法调用的原理(栈,汇编)
  • 系统用来传递过程参数存储返回信息保存寄存器用于以后恢复,以及本地存储。

  • 而为单个过程分配的那部分栈称为帧栈

    • 帧栈可以认为是程序栈的一段,它有两个端点,一个标识起始地址,一个标识着结束地址,两个指针结束地址指针esp,开始地址指针ebp;
  • 由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;

  • 每一个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。

  • 由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了。

过程实现

  • ① 备份原来的帧指针,调整当前的栈帧指针到栈指针位置;

  • ② 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存;

  • ③ 使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等。

  • ④ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了

  • ⑤ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。

  • ⑥ 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理。

  • ⑦ 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。

  • ⑧ 弹出返回地址,跳出当前过程,继续执行调用者的代码。

  1. 过程调用和返回指令

① call指令

② leave指令

③ ret指令

106、C++中的指针参数传递和引用参数传递有什么区别?底层原理你知道吗?
  • 1) 指针参数传递本质上是值传递,它所传递的是一个地址值。

    • 值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本
    • 值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值。
  • 2) 引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。

    • 被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。
  • 3) 引用传递和指针传递是不同的,

    • 虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。
    • 对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
  • 4) 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。

    • 指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。
    • 符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。
107、类如何实现只能静态分配和只能动态分配
  • 前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建

  • 建立类的对象有两种方式:

    • 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;
    • 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。
  1. 只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。
108、如果想将某个类用作基类,为什么该类必须定义而非声明?

派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。

109、什么情况会自动生成默认构造函数?
  • 带有默认构造函数的类成员对象

  • 如果一个类没有任何构造函数,但它含有一个成员对象,而后者有默认构造函数,那么编译器就为该类合成出一个默认构造函数。

  • 如果一个类A含有多个成员类对象的话,那么类A的每一个构造函数必须调用每一个成员对象的默认构造函数而且必须按照类对象在类A中的声明顺序进行;

  • 带有默认构造函数的基类

    • 如果一个没有任务构造函数的派生类派生自一个带有默认构造函数基类,那么该派生类会合成一个构造函数调用上一层基类的默认构造函数;
  • 带有一个虚函数的类

  • 带有一个虚基类的类

注: 合成的默认构造函数中,只有基类子对象和成员类对象会被初始化。所有其他的非静态数据成员都不会被初始化。

110、抽象基类为什么不能创建对象?
  • 抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。

    • 抽象类的定义:称带有纯虚函数的类为抽象类。
    • 抽象类的作用:
      • 将有关的操作作为结果接口,组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
  • 使用抽象类时注意:

    • 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。
      • 如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类
      • 如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。

抽象类是不能定义对象的。一个纯虚函数不需要(但是可以)被定义。

  • 纯虚函数定义:纯虚函数是一种特殊的虚函数,它的一般格式如下:
class <类名>
{
virtual <类型><函数名>(<参数表>)=0;
…
}; 
  • 在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。

    • 纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。
    • 凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。
  • 纯虚函数引入原因

    • 为了方便使用多态特性,需要在基类中定义虚拟函数。

    • 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔 雀等子类,但动物本身生成对象明显不合常理。

    • 为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;)。

  • 相似概念

    • 多态性:指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性。

      • a.编译时多态性:通过重载函数实现
      • b.运行时多态性:通过虚函数实现。
    • 虚函数:虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态重载。

    • 抽象类:包含纯虚函数的类称为抽象类。由于抽象类包含了没有定义的纯虚函数,所以不能定义抽象类的对象。

111、 继承机制中对象之间如何转换?指针和引用之间如何转换?
  1. 向上类型转换
    
  • 派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。
  1. 向下类型转换
    
  • 基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,
    • 因为一个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术。RTTI技术,dynamic_cast进行向下类型转换。
112、知道C++中的组合吗?它与继承相比有什么优缺点吗?
  • 继承:继承是Is a的关系,比如说Student继承Person,则说明Student is a Person。

    • 继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。
    • 继承的缺点有以下几点:
      • 父类的内部细节对子类是可见的。
      • 子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。
      • 如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。
  • 组合:设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。

    • 组合的优点:
      • 当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。
      • 当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
      • 当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。
    • 组合的缺点:
      • ①:容易产生过多的对象。
      • ②:为了能组合多个对象,必须仔细对接口进行定义。
113、函数指针?
  • 函数指针:函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。

  • 函数指针的声明方法

  • int (*pf)(const int&, const int&); (1)

  • 注意:*pf两边的括号是必须的,否则上面的定义就变成了:int *pf(const int&, const int&); 这声明了一个函数pf,其返回类型为int *, 带有两个const int&参数。

  • 为什么有函数指针

    • 函数与数据项相似,函数也有地址。我们希望在同一个函数中通过使用相同的形参在不同的时间使用产生不同的效果。
  • 一个函数名就是一个指针,它指向函数的代码。一个函数地址是该函数的进入点,也就是调用函数的地址。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变元传递给其他函数;

  • 两种方法赋值:指针名 = 函数名;,指针名 = &函数名

116、手写实现智能指针类
  • 智能指针是一个数据类型,一般用模板实现,模拟指针行为的同时还提供自动垃圾回收机制。它会自动记录SmartPointer<T*>对象的引用计数,一旦T类型对象的引用计数为0,就释放该对象。
    • 除了指针对象外,我们还需要一个引用计数的指针设定对象的值,并将引用计数计为1,需要一个构造函数。新增对象还需要一个构造函数,析构函数负责引用计数减少和释放内存。
    • 通过覆写赋值运算符,才能将一个旧的智能指针赋值给另一个指针,同时旧的引用计数减1,新的引用计数加1
    • 构造函数、拷贝构造函数、复制构造函数、析构函数、移走构造函数;
117、说一说你理解的内存对齐以及原因
  • 分配内存的顺序是按照声明的顺序。

  • 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。

  • 最后整个结构体的大小必须是里面变量类型最大值的整数倍

添加了#pragma pack(n)后规则就变成了下面这样:

  • 偏移量要是n和当前变量大小中较小值的整数倍

  • 整体大小要是n和最大变量大小中较小值的整数倍

  • n值必须为1,2,4,8…,为其他值时就按照默认的分配规则

118、 结构体变量比较是否相等
  • 重载了 “==” 操作符
struct foo {

  int a;

  int b;

  bool operator==(const foo& rhs) *//* *操作运算符重载*

  {

    return( a == rhs.a) && (b == rhs.b);

  }

};
  • 元素的话,一个个比;

  • 指针直接比较,如果保存的是同一个实例地址,则(p1==p2)为真;

119、 函数调用过程栈的变化,返回值和参数变量哪个先入栈?
  • 调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
  • 调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
  • 在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov ebp,esp);
  • 在被调函数中,从ebp的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈;
120、define、const、typedef、inline的使用方法?他们之间有什么区别?

一、const#define的区别:

  • const定义的常量是变量带类型,而#define定义的只是个常数不带类型
  • define只在预处理阶段起作用,简单的文本替换,而const编译、链接过程中起作用;
  • define只是简单的字符串替换没有类型检查。而const有数据类型的,是要进行判断的,可以避免一些低级错误;
  • define预处理后,占用代码段空间,const占用数据段空间;
  • const不能重定义,而define可以通过#undef取消某个符号的定义,进行重定义
  • define独特功能,比如可以用来防止文件重复引用。

二、 #define**和别名typedef的区别

  1. 执行时间不同,typedef在编译阶段有效,typedef有类型检查的功能;#define是宏定义,发生在预处理阶段,不进行类型检查;

  2. 功能差异,typedef用来定义类型的别名,定义与平台无关的数据类型,与struct的结合使用等。#define不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

  3. 作用域不同,#define没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。而typedef有自己的作用域

三、 defineinline的区别

  1. #define是关键字,inline是函数;

  2. 宏定义在预处理阶段进行文本替换,inline函数在编译阶段进行替换;

  3. inline函数有类型检查,相比宏定义比较安全;

121、你知道printf函数的实现原理是什么吗?
  • 在C/C++中,对函数参数的扫描是从后向前的。

  • C/C++的函数参数是通过压入堆栈的方式来给函数传参数的(堆栈是一种先进后出的数据结构),最先压入的参数最后出来,在计算机的内存中,数据有2块,一块是堆,一块是栈(函数参数及局部变量在这里),而是从内存的高地址向低地址生长的,控制生长的就是堆栈指针了,最先压入的参数是在最上面,就是说在所有参数的最后面,最后压入的参数在最下面,结构上看起来是第一个,所以最后压入的参数总是能够被函数找到,因为它就在堆栈指针的上方。

122、说一说你了解的关于lambda函数的全部知识
  • 利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;

  • 每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类重载了()运算符),我们称为闭包类型closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,其实一个右值。所以,我们上面的lambda表达式的结果就是一个闭包。

  • 闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块

  1. lambda表达式的语法定义如下:

[capture] (parameters) mutable ->return-type {statement};

4)lambda必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列表和函数体;

123、将字符串“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

124、模板类和模板函数的区别是什么?
  • 函数模板的实例化是由编译程序在处理函数调用时自动完成的,

  • 类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用而类模板只能显示调用。在使用时类模板必须加,而函数模板不必

125、为什么模板类一般都是放在一个h文件中
  • 模板定义很特殊。由template<…>处理的任何东西都意味着编译器在当时不为它分配存储空间,它一直处于等待状态直到被一个模板实例告知。

  • 在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。

  • 在分离式编译的环境下,编译器编译某一个.cpp文件时并不知道另一个.cpp文件的存在,也不会去查找(当遇到未决符号时它会寄希望于连接器)。

    • 当编译器只看到模板的声明时,它不能实例化该模板,只能创建一个具有外部连接的符号并期待连接器能够将符号的地址决议出来。当实现该模板的.cpp文件中没有用到模板的实例时,编译器懒得去实例化,所以,整个工程的.obj中就找不到一行模板实例的二进制代码,于是连接器也黔驴技穷了。
127、cout和printf有什么区别?

cout<<是一个函数,cout<<后可以跟不同的类型,是因为cout<<已存在针对各种类型数据的重载,所以会自动识别数据的类型。输出过程会首先将输出字符放入缓冲区,然后输出到屏幕。

  • cout是有缓冲输出:
 cout < < "abc " < <endl;
 cout < < "abc\n ";cout < <flush; 这两个才是一样的.
  • flush立即强迫缓冲输出

  • printf无缓冲输出。有输出时立即输出

128、你知道重载运算符吗?
  • 注意事项

    • 重载已有的运算符,而无权发明新的运算符;
    • 对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;
    • 不能改变运算符操作数个数;
  • 两种重载方式:

    • 成员运算符
    • 非成员运算符,

    下标运算符、箭头运算符必须是成员运算符;

  • 引入运算符重载,是为了实现类的多态性;

  • 当重载的运算符是成员函数时,

  • this绑定到左侧运算符对象。成员运算符函数的参数数量比运算符对象的数量少一个;

  • 至少含有一个类类型的参数;

  • 从参数的个数推断到底定义的是哪种运算符,当运算符既是一元运算符又是二元运算符(+,-,*,&)

  • 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;

  • 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;

129、当程序中有函数重载时,函数的匹配原则和顺序是什么?
  1. 名字查找 --> 2) 确定候选函数 —> 3) 寻找最佳匹配
130、定义和声明的区别
  • 变量的声明和定义

    • 从编译原理上来说,声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。
    • 而定义就是分配了内存。
  • 函数的声明和定义

    • 声明:一般在头文件里,对编译器说: 这里我有一个函数叫function() 让编译器知道这个函数的存在。
    • 定义:一般在源文件里,具体就是函数的实现过程写明函数体
132、 静态成员与普通成员的区别是什么?
  • 生命周期

  • 静态成员变量从类被加载开始到类被卸载,一直存在;

  • 普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束;

  • 共享方式

  • 静态成员变量是全类共享;普通成员变量是每个对象单独享用的;

  • 定义位置

    • 普通成员变量存储在栈或堆中,而静态成员变量存储在静态全局区;
  • 初始化位置

  • 普通成员变量在类中初始化;静态成员变量在类外初始化;

  • 默认实参

  • 可以使用静态成员变量作为默认实参,

133、说一下你理解的 ifdef endif代表着什么?
  • “条件编译”

    • 一般情况下,源程序中所有的行都参加编译。但是有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是。有时,希望当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。
  • 条件编译命令最常见的形式为:

#ifdef 标识符 
程序段1 
#else 
程序段2 
#endif
//当标识符已经被定义过(一般是用`#define`命令定义),则对程序段1进行编译,否则编译程序段2。 

其中#else部分也可以没有,即:

 \#ifdef 
 程序段1 
 \#denif
  • 在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量“重定义”错误。
  • 在头文件中使用``#define#ifndef#ifdef#endif`能避免头文件重定义。
134、隐式转换,如何消除隐式转换?
  • C++的基本类型中并非完全的对立,部分数据类型之间是可以进行隐式转换的。

    • 隐式转换:不需要用户干预,编译器私下进行的类型转换行为。
  • C++面向对象的多态特性:通过父类的类型实现对子类的封装。通过隐式转换,你可以直接将一个子类的对象使用父类的类型进行返回。

    • 在数值和布尔类型的转换,整数和浮点数的转换等。
  • 基本数据类型的转换以取值范围的作为转换基础(保证精度不丢失)。

    • 隐式转换发生在从小->大的转换中。比如从char转换为int。从int->long。
    • 自定义对象:子类对象可以隐式的转换为父类对象。

如何消除隐式转换

  • C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。

  • 如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制。可以通过将构造函数声明为explicit加以制止隐式类型转换,关键字explicit只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为explicit

136、多继承的优缺点,作为一个开发者怎么看待多继承
  • 什么是多继承
    • C++允许为一个派生类指定多个基类,这样的继承结构被称做多重继承。
  • 优点
    • 多重继承的优点很明显,就是对象可以调用多个基类中的接口;
  • 缺点
    • 如果派生类所继承的多个基类有相同的基类,而派生类对象需要调用这个祖先类的接口方法,就会容易出现二义性
  • 如何避免缺点
    • 加上全局符确定调用哪一份拷贝。
      • 比如pa.Author::eat()调用属于Author的拷贝。
    • 使用虚拟继承,使得多重继承类Programmer_Author只拥有Person类的一份拷贝。
137、迭代器:++it、it++哪个好,为什么
  1. 前置返回一个引用,后置返回一个对象
// ++i实现代码为:
int& operator++()
{
  *this += 1;
  return *this;
} 
  1. 前置不会产生临时对象,后置必须产生临时对象,临时对象会导致效率降低
//i++实现代码为:                 
int operator++(int)                 
{
int temp = *this;                   
   ++*this;                       
   return temp;                  
} 
138、C++如何处理多个异常的?
  • C++中的异常情况:

    • 语法错误(编译错误):比如变量未定义、括号不匹 配、关键字拼写错误等等编译器在编译时能发现的错误,这类错误可以及时被编译器发现,而且可以及时知道出错的位置及原因,方便改正。
    • 运行时错误:比如数组下标越界、系统内存不足等等。这类错误不易被程序员发现,它能通过编译且能进入运行,但运行时会出错,导致程序崩溃。为了有效处理程序运行时错误,C++中引入异常处理机制来解决此问题。
  • C++异常处理机制:

    • 异常处理基本思想:执行一个函数的过程中发现异常,可以不用在本函数内立即进行处理, 而是抛出该异常,让函数的调用者直接或间接处理这个问题。

    • C++异常处理机制由3个模块组成:

      • try(检查)、throw(抛出)、catch(捕获)
    • 抛出异常的语句格式为:throw 表达式;如果try块中程序段发现了异常则抛出异常。

try 
{ 
可能抛出异常的语句;(检查) 
} 
catch(类型名[形参名]//捕获特定类型的异常 
{ 
//处理1; 
} 
catch(类型名[形参名]//捕获特定类型的异常 
{ 
//处理2; 
} 
catch(…)//捕获所有类型的异常 
{ 
} 
140、在成员函数中调用delete this会出现什么问题?对象还可以使用吗?
  • 在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

  • 为什么是不可预期的问题?

    • delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。
  • 如果在类的析构函数中调用delete this,会发生什么?

    • 会导致堆栈溢出。
      • delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。
141、如何在不使用额外空间的情况下,交换两个数?你有几种方法
1)  算术
x = x + y;
y = x - y;
x = x - y; 
2)  异或
x = x^y;// 只能对int,char..
y = x^y;
x = x^y;
x ^= y ^= x;
142、你知道strcpy和memcpy的区别是什么吗?
  • 复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
  • 复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。
  • 用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy
143、程序在执行int main(int argc, char *argv[])时的内存结构,你了解吗?
  • 参数的含义是程序在命令行下运行的时候,需要输入argc 个参数,每个参数是以char 类型输入的,依次存在数组里面,数组是 argv[],所有的参数在指针char * 指向的内存中,数组的中元素的个数为 argc 个,第一个参数为程序的名称。
144、volatile关键字的作用?
  • volatile用在如下的几个地方:
    • 中断服务程序中修改的供其它程序检测的变量需要加volatile;
    • 多 任务环境下各任务间共享的标志应该加volatile;
    • 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;
145、如果有一个空类,它会默认添加哪些函数?
1)  Empty(); // 缺省构造函数//缺省构造是编译器自动生成的一个什么都不做的构造函数(唯一的作用就避免统错误)

2)  Empty( const Empty& ); // 拷贝构造函数//

3)  ~Empty(); // 析构函数//

4)  Empty& operator=( const Empty& ); // 赋值运算符//

146、C++中标准库是什么?
  • C++ 标准库可以分为两部分:

标准函数库: 这个库是由通用的、独立的、不属于任何类的函数组成的。函数库继承自 C 语言。

面向对象类库: 这个库是类及其相关函数的集合。

  • 输入/输出 I/O、字符串和字符处理、数学、时间、日期和本地化、动态分配、其他、宽字符函数

  • 标准的 C++ I/O 类、String 类、数值类、STL 容器类、STL 算法、STL 函数对象、STL 迭代器、STL 分配器、本地化库、异常处理类、杂项支持库

147、你知道const char* 与string之间的关系是什么吗?
  1. string 是c++标准库里面其中一个,封装了对字符串的操作,实际操作过程我们可以用const char*string类初始化

  2. 三者的转化关系如下所示:

a)  string转const char* 

string s = “abc”; 

const char* c_s = s.c_str(); 

b)  const char* 转string,直接赋值即可 

const char* c_s = “abc”; 
 string s(c_s); 

c)  string 转char* 
 string s = “abc”; 
 char* c; 
 const int len = s.length(); 
 c = new char[len+1]; 
 strcpy(c,s.c_str()); 

d)  char* 转string 
 char* c = “abc”; 
 string s(c); 

e)  const char*char* 
 const char* cpc = “abc”; 
 char* pc = new char[strlen(cpc)+1]; 
 strcpy(pc,cpc);

f)  char*const char*,直接赋值即可 
 char* pc = “abc”; 
 const char* cpc = pc;

148、为什么拷贝构造函数必须传引用不能传值?
  • 拷贝构造函数的作用就是用来复制对象的,在使用这个对象的实例来初始化这个对象的一个新的实例。

  • 参数传递过程到底发生了什么?

    • 将地址传递和值传递统一起来,归根结底还是传递的是"值"(地址也是值,只不过通过它可以找到另一个值)!
    • 值传递:
      • 对于内置数据类型的传递时,直接赋值拷贝给形参(注意形参是函数内局部变量);

      • 对于类类型的传递时,需要首先调用该类的拷贝构造函数来初始化形参(局部对象);

  • 引用传递:

    • 无论对内置类型还是类类型,传递引用或指针最终都是传递的地址值!而地址总是指针类型(属于简单类型), 显然参数传递时,按简单类型的赋值拷贝,而不会有拷贝构造函数的调用(对于类类型).

使用值传递会产生无限递归调用,内存溢出

149、你知道空类的大小是多少吗?
  • C++空类的大小不为0,不同编译器设置不一样,vs设置为1;

  • C++标准指出,不允许一个对象(当然包括类对象)的大小为0,不同的对象不能具有相同的地址;

  • 带有虚函数的C++类大小不为1,因为每一个对象会有一个vptr指向虚函数表,具体大小根据指针大小确定;

  • C++中要求对于类的每个实例都必须有独一无二的地址,那么编译器自动为空类分配一个字节大小,这样便保证了每个实例均有独一无二的内存地址。

150、你什么情况用指针当参数,什么时候用引用,为什么?
  • 使用引用参数的主要原因有两个:

    • 程序员能修改调用函数中的数据对象
    • 通过传递引用而不是整个数据对象,可以提高程序的运行速度
  • 一般的原则:

    • 对于使用引用的值而不做修改的函数:
    • 如果数据对象很小,如内置数据类型或者小型结构,则按照值传递;
    • 如果数据对象是数组,则使用指针(唯一的选择),并且指针声明为指向const的指针;
    • 如果数据对象是较大的结构,则使用const指针或者引用,已提高程序的效率。这样可以节省结构所需的时间和空间;
    • 如果数据对象是类对象,则使用const引用(传递类对象参数的标准方式是按照引用传递);
    • 对于修改函数中数据的函数:
      • 如果数据是内置数据类型,则使用指针
      • 如果数据对象是数组,则只能使用指针
      • 如果数据对象是结构,则使用引用或者指针
      • 如果数据是类对象,则使用引用
151、静态函数能定义为虚函数吗?常函数呢?说说你的理解
  • static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。

  • 静态与非静态成员函数之间有一个主要的区别,那就是静态成员函数没有this指针。

    • 虚函数依靠vptrvtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable.对于静态成员函数,它没有this指针,所以无法访问vptr。

这就是为何static函数不能为virtual,虚函数的调用关系:this -> vptr -> vtable ->virtual function

152、this指针调用成员变量时,堆栈会发生什么变化?
  • 当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针
  • This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。
153、你知道静态绑定和动态绑定吗?讲讲?
  • 对象的静态类型:对象在声明时采用的类型。是在编译期确定的。

  • 对象的动态类型:目前所指对象的类型。是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。

  • 静态绑定:绑定的是对象的静态类型,某特性(比如函数依赖于对象的静态类型,发生在编译期。

  • 动态绑定:绑定的是对象的动态类型,某特性(比如函数依赖于对象的动态类型,发生在运行期。

154、如何设计一个类计算子类的个数?
  1. 为类设计一个static静态变量count作为计数器;

  2. 类定义结束后初始化count;

  3. 在构造函数中对count进行+1;

  4. 设计拷贝构造函数,在进行拷贝构造函数中进行count +1,操作;

  5. 设计复制构造函数,在进行复制函数中对count+1操作;

  6. 在析构函数中对count进行-1;

155、怎么快速定位错误出现的地方

1、如果是简单的错误,可以直接双击错误列表里的错误项或者生成输出的错误信息中带行号的地方就可以让编辑窗口定位到错误的位置上。

2、对于复杂的模板错误,最好使用生成输出窗口。

多数情况下出发错误的位置是最靠后的引用位置。如果这样确定不了错误,就需要先把自己写的代码里的引用位置找出来,然后逐个分析了。

156、虚函数的代价?
  • 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类;

  • 带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小;

  • 不能再是内敛的函数,因为内敛函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定到低是采用哪种函数,虚函数不能是内敛函数。

157、类对象的大小受哪些因素影响?
  • 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;

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

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

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

158、移动构造函数听说过吗?说说
  • 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制;

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

159、 什么时候合成构造函数?都说一说,你知道的都说一下
  • 如果一个类没有任何构造函数,但他含有一个成员对象,该成员对象含有默认构造函数,那么编译器就为该类合成一个默认构造函数,因为不合成一个默认构造函数,那么该成员对象的构造函数不能调用;

  • 没有任何构造函数的类派生自一个带有默认构造函数的基类,那么需要为该派生类合成一个构造函数,只有这样基类的构造函数才能被调用;

  • 带有虚函数的类,虚函数的引入需要进入虚表,指向虚表的指针,该指针是在构造函数中初始化的,所以没有构造函数的话该指针无法被初始化;

  • 带有一个虚基类的类

还有一点需要注意的是:

  • 并不是任何没有构造函数的类都会合成一个构造函数

  • 编译器合成出来的构造函数并不会显示设定类内的每一个成员变量

160、那什么时候需要合成拷贝构造函数呢?

有三种情况会以一个对象的内容作为另一个对象的初值:

  • 对一个对象做显示的初始化操作,X xx = x;

  • 当对象被当做参数交给某个函数时;

  • 当函数传回一个类对象时;

    调用拷贝构造函数

    • 如果一个类没有拷贝构造函数,但是含有一个类类型的成员变量,该类型含有拷贝构造函数,此时编译器会为该类合成一个拷贝构造函数;
    • 如果一个类没有拷贝构造函数,但是该类继承自含有拷贝构造函数的基类,此时编译器会为该类合成一个拷贝构造函数;
    • 如果一个类没有拷贝构造函数,但是该类声明或继承了虚函数,此时编译器会为该类合成一个拷贝构造函数;
    • 如果一个类没有拷贝构造函数,但是**该类含有虚基类,**此时编译器会为该类合成一个拷贝构造函数;
161、成员初始化列表会在什么时候用到?它的调用过程是什么?
  • 初始化一个引用成员变量时;引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面 。
  • 初始化一个const成员变量时;因为常量只能初始化不能赋值,所以必须放在初始化列表里面
  • 当调用一个基类的构造函数,而构造函数拥有一组参数时;
  • 当调用一个成员类的构造函数,而他拥有一组参数;
162、构造函数的执行顺序是什么?
  • 在派生类构造函数中,所有的虚基类及上一层基类的构造函数调用;

  • 对象的vptr被初始化;

  • 如果有成员初始化列表,将在构造函数体内扩展开来,这必须在vptr被设定之后才做;

  • 执行程序员所提供的代码;

163、一个类中的全部构造函数的扩展过程是什么?
  • 记录在成员初始化列表中的数据成员初始化操作会被放在构造函数的函数体内,并与成员的声明顺序为顺序;

  • 如果一个成员并没有出现在成员初始化列表中,但它有一个默认构造函数,那么默认构造函数必须被调用;

  • 如果class有虚表,那么它必须被设定初值;

  • 所有上一层的基类构造函数必须被调用;

  • 所有虚基类的构造函数必须被调用。

164、哪些函数不能是虚函数?把你知道的都说一说
  • 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;

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

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

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

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

165、说一说strcpy、sprintf与memcpy这三个函数的不同之处
  • 操作对象不同

    • ① strcpy的两个操作对象均为字符串
    • ② sprintf的操作源对象可以是多种数据类型,目的操作对象是字符串
    • ③ memcpy的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
  • 执行效率不同

    • memcpy最高,strcpy次之,sprintf的效率最低。
  • 实现功能不同

    • ① strcpy主要实现字符串变量间的拷贝
    • ② sprintf主要实现其他数据类型格式到字符串的转化
    • ③ memcpy主要是内存块间的拷贝。
166、将引用作为函数参数有哪些好处?
  • 传递引用给函数与传递指针的效果是一样的。

  • 被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。

  • 使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。

  • 使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用"*指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;

  • 另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。

167、你知道数组和指针的区别吗?
  • 数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:sizeof(数组名);数组大小:sizeof(数组名)/sizeof(数组元素数据类型);

  • 用运算符sizeof 可以计算出数组的容量(字节数)。sizeof(p),p为指针得到的是一个指针变量的字节数,而不是p 所指的内存容量

  • 表达式中的数组元素引用转换为指针加偏移量的引用。

  • 如果实参是一个数组,那用于接受的形参为对应的指针。也就是传递过去是数组的首地址而不是整个数组,能够提高效率;

  • 在使用下标的时候,两者的用法相同,都是原地址加上下标值,不过数组的原地址就是数组首元素的地址是固定的,指针的原地址就不是固定的。

168、如何阻止一个类被实例化?有哪些方法?
  1. 将类定义为抽象基类或者将构造函数声明为private;

  2. 不允许类外部创建类对象,只能在类内部创建对象

169、 如何禁止程序自动生成拷贝构造函数?
  • 为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动去重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们需要将他们设置成private,防止被调用。

  • 类的成员函数和friend函数还是可以调用private函数,如果这个private函数只声明不定义,则会产生一个连接错误;

针对上述两种情况,我们可以定一个base类,在**base类中将拷贝构造函数和拷贝赋值函数设置成private,**那么派生类中编译器将不会自动生成这两个函数,且由于base类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作。

170、你知道Denug和release的区别是什么吗?
  • 调试版本,包含调试信息,所以容量比Release大很多,并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。Debug模式下生成两个文件,除了.exe.dll文件外,还有一个.pdb文件,该文件记录了代码中断点等调试信息;

  • 发布版本,不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的PDB文件中生成)。Release模式下生成一个文件.exe或.dll文件。

实际上,Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。事实上,我们甚至可以修改这些选项,从而得到优化过的调试版本或是带跟踪语句的发布版本。

171、main函数的返回值有什么值得考究之处吗?

程序运行过程入口点main函数main()函数返回值类型必须是int,这样返回值才能传递给程序激活者(如操作系统)表示程序正常退出。

main(int args, char **argv) 参数的传递。参数的处理,一般会调用getopt()函数处理,但实践中,这仅仅是一部分,不会经常用到的技能点。

172、模板会写吗?写一个比较大小的模板函数
#include<iostream> 

using namespace std; 
template<typename type1,typename type2>//函数模板 

type1 Max(type1 a,type2 b) 
 { 
   return a > b ? a : b; 
} 
void main() 
{ 
  cout<<"Max = "<<Max(5.5,'a')<<endl; 
} 
173、智能指针出现循环引用怎么解决?

weak_ptr不会修改引用计数,即其存在与否并不影响对象的引用计数器。循环引用就是:两个对象互相使用一个shared_ptr成员变量指向对方。弱引用并不对对象的内存进行管理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。

174、strcpy函数和strncpy函数的区别?哪个函数更安全?
  • 函数原型
char* strcpy(char* strDest, const char* strSrc)
char* strncpy(char* strDest, const char* strSrc, int pos)
  • strcpy函数: 如果参数 dest 所指的内存空间不够大,可能会造成缓冲溢出(buffer Overflow)的错误情况,在编写程序时请特别留意,或者用strncpy()来取代。

  • strncpy函数:用来复制源字符串的前n个字符,src 和 dest 所指的内存区域不能重叠,且 dest 必须有足够的空间放置n个字符。

  • 如果目标长>指定长>源长,则将源长全部拷贝到目标长,自动加上’\0’
    如果指定长<源长,则将源长中按指定长度拷贝到目标字符串,不包括’\0’
    如果指定长>目标长,运行时错误 ;

175、static_cast比C语言中的转换强在哪里?
  • 更加安全;

  • 更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误;可清楚地辨别代码中每个显式的强制转;可读性更好,能体现程序员的意图

176、成员函数里memset(this,0,sizeof(*this))会发生什么
  • memset(this, 0, sizeof *this);`将整个对象的内存全部置为0。对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;
    • 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
    • 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。
177、你知道回调函数吗?它的作用?
  • 当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数;

  • 回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。

    • 为此,你需要做三件事:1 声明;2 定义;3 设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用;
  • 回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;

  • 因为可以把调用者与被调用者分开。调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数。

178、什么是一致性哈希?
  • 一致性哈希
    • 一致性哈希是一种哈希算法,就是在移除或者增加一个结点时,能够尽可能小的改变已存在key的映射关系。尽可能少的改变已有的映射关系,一般是沿着顺时针进行操作。
    • 一致性哈希将整个哈希值空间组织成一个虚拟的圆环,假设哈希函数的值空间为0~2^32-1,整个哈希空间环如下左图所示
    • 一致性hash的基本思想就是使用相同的hash算法将数据和结点都映射到图中的环形哈希空间中,上右图显示了4个数据object1-object4在环上的分布图

  • 结点和数据映射
    • 假如有一批服务器,可以根据IP或者主机名作为关键字进行哈希,根据结果映射到哈希环中,3台服务器分别是nodeA-nodeC
    • 现在有一批的数据object1-object4需要存在服务器上,则可以使用相同的哈希算法对数据进行哈希,其结果必然也在环上,可以沿着顺时针方向寻找,找到一个结点(服务器)则将数据存在这个结点上,这样数据和结点就产生了一对一的关联,如下图所示:

  • 移除结点
    • 如果一台服务器出现问题,如上图中的nodeB,则受影响的是其逆时针方向至下一个结点之间的数据,只需将这些数据映射到它顺时针方向的第一个结点上即可,下左图

  • 添加结点

    • 如果新增一台服务器nodeD,受影响的是其逆时针方向至下一个结点之间的数据,将这些数据映射到nodeD上即可,见上右图
  • 虚拟结点

    • 假设仅有2台服务器:nodeA和nodeC,nodeA映射了1条数据,nodeC映射了3条,这样数据分布是不平衡的。引入虚拟结点,假设结点复制个数为2,则nodeA变成:nodeA1和nodeA2,nodeC变成:nodeC1和nodeC2,映射情况变成如下:

这样数据分布就均衡多了,平衡性有了很大的提高

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

虚函数和纯虚函数区别

  • 虚函数是为了实现动态编联产生的,目的是通过基类类型的指针指向不同对象时,自动调用相应的、和基类同名的函数(使用同一种调用形式,既能调用派生类又能调用基类的同名函数)。
  • 虚函数需要在基类中加上virtual修饰符修饰,因为virtual会被隐式继承,所以子类中相同函数都是虚函数。当一个成员函数被声明为虚函数之后,其派生类中同名函数自动成为虚函数,在派生类中重新定义此函数时要求函数名、返回值类型、参数个数和类型全部与基类函数相同。
  • 纯虚函数只是相当于一个接口名,但含有纯虚函数的类不能够实例化。
    • 纯虚函数首先是虚函数,其次它没有函数体,取而代之的是用“=0”。既然是虚函数,它的函数指针会被存在虚函数表中,由于纯虚函数并没有具体的函数体,因此它在虚函数表中的值就为0,而具有函数体的虚函数则是函数的具体地址。

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

#include <iostream>
using namespace std;

class Base
{
public:
	virtual void fun1()
	{
		cout << "普通虚函数" << endl;
	}
	virtual void fun2() = 0;
	virtual ~Base() {}
};

class Son : public Base
{
public:
	virtual void fun2() 
	{
		cout << "子类实现的纯虚函数" << endl;
	}
};

int main()
{
	Base* b = new Son;
	b->fun1(); //普通虚函数
	b->fun2(); //子类实现的纯虚函数
	return 0;
}
180、C++从代码到可执行程序经历了什么?

(1)预编译:主要处理源代码文件中的以“#”开头的预编译指令。处理规则见下:

  1. 删除所有的#define,展开所有的宏定义。

  2. 处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。

  3. 处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他
    文件。

  4. 删除所有的注释,“//”和“/**/”。

  5. 保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重
    复引用。

  6. 添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是
    能够显示行号。

(2)编译:把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。

  1. 词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分
    割成一系列的记号。
  2. 语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的
    语法树是一种以表达式为节点的树。
  3. 语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进
    行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定
    的语义。
  4. 优化:源代码级别的一个优化过程。
  5. 目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言
    表示。
  6. 目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移
    来替代乘法运算、删除多余的指令等。

(3)汇编:将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过
来,汇编过程有汇编器as完成。经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Windows
下)、xxx.obj(Linux下)。

(4)链接

将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链
接:

  • 静态链接:函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
    • 缺点:
      • 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
      • 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
      • 运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
  • 动态链接:动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
    • 优点
      • 共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分,副本,而是这多个程序在执行时共享同一份副本;
      • 更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
      • 性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
181、为什么友元函数必须在类内部声明?
  • 因为编译器必须能够读取这个结构的声明以理解这个数据类型的大、行为等方面的所有规则。

有一条规则在任何关系中都很重要,那就是谁可以访问我的私有部分。

182、用C语言实现C++的继承
#include <iostream>

using namespace std;

//C++中的继承与多态
struct A

{

  virtual void fun()  //C++中的多态:通过虚函数实现

  {
    cout<<"A:fun()"<<endl;

  }

  int a;

};

struct B:public A     //C++中的继承:B类公有继承A类

{
  virtual void fun()  //C++中的多态:通过虚函数实现(子类的关键字virtual可加可不加)

  {

   cout<<"B:fun()"<<endl;

  }
  int b;

};

//C语言模拟C++的继承与多态

typedef void (*FUN)();   //定义一个函数指针来实现对成员函数的继承

struct _A    //父类

{

  FUN _fun;  //由于C语言中结构体不能包含函数,故只能用函数指针在外面实现
  int _a;

};

struct _B     //子类

{

  _A _a_;   //在子类中定义一个基类的对象即可实现对父类的继承

  int _b;

};

void _fA()    //父类的同名函数

{

  printf("_A:_fun()\n");

}

void _fB()    //子类的同名函数

{

  printf("_B:_fun()\n");

}

void Test()

{

  //测试C++中的继承与多态

  A a;  //定义一个父类对象a

  B b;  //定义一个子类对象b

 

  A* p1 = &a;  //定义一个父类指针指向父类的对象

  p1->fun();  //调用父类的同名函数

  p1 = &b;   //让父类指针指向子类的对象

  p1->fun();  //调用子类的同名函数

 

  //C语言模拟继承与多态的测试

  _A _a;  //定义一个父类对象_a

  _B _b;  //定义一个子类对象_b

  _a._fun = _fA;    //父类的对象调用父类的同名函数

  _b._a_._fun = _fB;  //子类的对象调用子类的同名函数



  _A* p2 = &_a;  //定义一个父类指针指向父类的对象

  p2->_fun();   //调用父类的同名函数

  p2 = (_A*)&_b; //让父类指针指向子类的对象,由于类型不匹配所以要进行强转

  p2->_fun();   //调用子类的同名函数

}

183、动态编译与静态编译
  • 静态编译,编译器在编译可执行文件时,把需要用到的对应动态链接库中的部分提取出来,连接到可执行文件中去,使可执行文件在运行时不需要依赖于动态链接库;

  • 动态编译的可执行文件需要附带一个动态链接库,在执行时,需要调用其对应动态链接库的命令。

    • 优点
      • 一方面是缩小了执行文件本身的体积,
      • 另一方面是加快了编译速度,节省了系统资源。
    • 缺点
      • 哪怕是很简单的程序,只用到了链接库的一两条命令,也需要附带一个相对庞大的链接库;二是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行。
184、hello.c 程序的编译过程

以下是一个 hello.c 程序:

#include <stdio.h>
int main()
{
    printf("hello, world\n");
    return 0;
}

在 Unix 系统上,由编译器把源文件转换为目标文件。

gcc -o hello hello.c

这个过程大致如下:

  • 预处理阶段:处理以 # 开头的预处理命令;
  • 编译阶段:翻译成汇编文件;
  • 汇编阶段:将汇编文件翻译成可重定位目标文件;
  • 链接阶段:将可重定位目标文件和 printf.o 等单独预编译好的目标文件进行合并,得到最终的可执行目标文件。
静态链接

静态链接器以一组可重定位目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:

  • 符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
  • 重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

目标文件
  • 可执行目标文件:可以直接在内存中执行;
  • 可重定位目标文件:可与其它可重定位目标文件在链接阶段合并,创建一个可执行目标文件;
  • 共享目标文件:这是一种特殊的可重定位目标文件,可以在运行时被动态加载进内存并链接;
动态链接

静态库有以下两个问题:

  • 当静态库更新时那么整个程序都要重新进行链接;
  • 对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。

共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点:

  • 在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;
  • 在内存中,一个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。

源代码-->预处理-->编译-->优化-->汇编-->链接–>可执行文件

185、介绍一下几种典型的锁

读写锁

  • 多个读者可以同时进行读
  • 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
  • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

互斥锁

  • 一次只能一个线程拥有互斥锁,其他线程只有等待

  • 互斥锁是在抢锁失败的情况下,主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒。而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换

条件变量

  • 互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。条件变量通过允许线程阻塞等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。
  • 当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

自旋锁

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

186、说一下C++左值引用和右值引用
  • 通过移动语义来避免无谓拷贝的问题,通过move语义来将临时生成的左值中的资源无代价的转移到另外一个对象中去,通过完美转发来解决不能按照参数实际类型来转发的问题。

    • 完美转发获得的一个好处是可以实现移动语义)。
  • 在C++11中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值将亡值。在C++11中可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)。。

  • C++11对C++98中的右值进行了扩充。在C++11中右值又分为纯右值(prvalue,Pure Rvalue)和将亡值(xvalue,eXpiring Value)。

    • 其中纯右值的概念等同于我们在C++98标准中右值的概念,指的是临时变量和不跟对象关联的字面量值
    • 将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&&的函数返回值std::move的返回值,或者转换为T&&的类型转换函数的返回值。将亡值可以理解为通过“盗取”其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。
  • 左值引用就是对一个左值进行引用的类型。右值引用就是对一个右值进行引用的类型,事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。左值引用通常也不能绑定到右值,但常量左值引用是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。不过常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。

  • 右值值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值。

左值和右值

  • 左值:表示的是可以获取地址的表达式,它能出现在赋值语句的左边,对该表达式进行赋值。但是修饰符const的出现使得可以声明如下的标识符,它可以取得地址,但是没办法对其进行赋值
const int& a = 10;
  • 右值:表示无法获取地址的对象,有常量值、函数返回值、lambda表达式等。无法获取地址,但不表示其不可改变,当定义了右值的右值引用时就可以更改右值。

左值引用和右值引用

  • 左值引用:传统的C++中引用被称为左值引用

  • 右值引用:C++11中增加了右值引用,右值引用关联到右值时,右值被存储到特定位置,右值引用指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置

这里主要说一下右值引用的特点:

  • 特点1:通过右值引用的声明,右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样长,只要该变量还活着,该右值临时量将会一直存活下去
  • 特点2:右值引用独立于左值和右值。意思是右值引用类型的变量可能是左值也可能是右值
  • 特点3:T&& t在发生自动类型推断的时候,它是左值还是右值取决于它的初始化。
#include <bits/stdc++.h>
using namespace std;

template<typename T>
void fun(T&& t)
{
	cout << t << endl;
}

int getInt()
{
	return 5;
}

int main() {
	
	int a = 10;
	int& b = a;  //b是左值引用
	int& c = 10;  //错误,c是左值不能使用右值初始化
	int&& d = 10;  //正确,右值引用用右值初始化
	int&& e = a;  //错误,e是右值引用不能使用左值初始化
	const int& f = a; //正确,左值常引用相当于是万能型,可以用左值或者右值初始化
	const int& g = 10;//正确,左值常引用相当于是万能型,可以用左值或者右值初始化
	const int&& h = 10; //正确,右值常引用
	const int& aa = h;//正确
	int& i = getInt();  //错误,i是左值引用不能使用临时变量(右值)初始化
	int&& j = getInt();  //正确,函数返回值是右值
	fun(10); //此时fun函数的参数t是右值
	fun(a); //此时fun函数的参数t是左值
	return 0;
}
187、STL中hashtable的实现?
  • STL中的hashtable使用的是开链法解决hash冲突问题,如下图所示。

  • hashtable中的bucket所维护的list,既不是list也不是slist,而是其自己定义的由hashtable_node数据结构组成的linked-list,而bucket聚合体本身使用vector进行存储。hashtable的迭代器只提供前进操作,不提供后退操作
  • hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,...,429496729],在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(vector的长度),其中每个bucket所维护的linked-list长度也等于hashtable的容量。如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置。
188、简单说一下STL中的traits技法
  • traits技法利用“内嵌型别“的编程技巧与编译器的template参数推导功能,增强C++未能提供的关于型别认证方面的能力。常用的有iterator_traitstype_traits

iterator_traits:被称为特性萃取机,能够让外界获取以下5中型别:

  • value_type:迭代器所指对象的型别
  • difference_type:两个迭代器之间的距离
  • pointer:迭代器所指向的型别
  • reference:迭代器所引用的型别
  • iterator_category:三两句说不清楚,建议看书

type_traits

  • 关注的是型别的特性,例如这个型别是否具备non-trivial defalt ctor(默认构造函数)、non-trivial copy ctor(拷贝构造函数)、non-trivial assignment operator(赋值运算符) 和non-trivial dtor(析构函数),如果答案是否定的,可以采取直接操作内存的方式提高效率,一般来说,type_traits支持以下5中类型的判断:
__type_traits<T>::has_trivial_default_constructor
__type_traits<T>::has_trivial_copy_constructor
__type_traits<T>::has_trivial_assignment_operator
__type_traits<T>::has_trivial_destructor
__type_traits<T>::is_POD_type
  • 由于编译器只针对class object形式的参数进行参数推到,因此上式的返回结果不应该是个bool值,实际上使用的是一种空的结构体:
struct __true_type{};
struct __false_type{};
  • 这两个结构体没有任何成员,不会带来其他的负担,又能满足需求,可谓一举两得

当然,如果我们自行定义了一个Shape类型,也可以针对这个Shape设计type_traits的特化版本

template<> struct __type_traits<Shape>{
	typedef __true_type has_trivial_default_constructor;
	typedef __false_type has_trivial_copy_constructor;
	typedef __false_type has_trivial_assignment_operator;
	typedef __false_type has_trivial_destructor;
	typedef __false_type is_POD_type;
};
189、STL的两级空间配置器
  • 为什么需要二级空间配置器

    • 我频繁的在堆开辟释放内存,则就会在堆上造成很多外部碎片,浪费了内存空间;
    • 每次都要进行调用malloc、free函数等操作,使空间就会增加一些附加信息,降低了空间利用率;
    • 随着外部碎片增多,内存分配器在找不到合适内存情况下需要合并空闲块,浪费了时间,大大降低了效率。于是就设置了二级空间配置器,
  • 当开辟内存<=128bytes时,即视为开辟小块内存,则调用二级空间配置器。

  • STL中一级空间配置器和二级空间配置器的选择上,

    • 一般默认选择的为二级空间配置器
    • 如果大于128字节再转去一级配置器器。
一级配置器

一级空间配置器中重要的函数就是allocatedeallocatereallocate 。 一级空间配置器是以malloc()free()realloc()等C函数执行实际的内存配置 。大致过程是:

  • 直接allocate分配内存,其实就是malloc来分配内存,成功则直接返回,失败就调用处理函数

  • 如果用户自定义了内存分配失败的处理函数就调用,没有的话就返回异常

  • 如果自定义了处理函数就进行处理,完事再继续分配试试

二级配置器

  • 维护16条链表,分别是0-15号链表,最小8字节,以8字节逐渐递增,最大128字节,你传入一个字节参数,表示你需要多大的内存,会自动帮你校对到第几号链表(如需要13bytes空间,我们会给它分配16bytes大小),在找到第n个链表后查看链表是否为空,如果不为空直接从对应的free_list中拔出,将已经拨出的指针向后移动一位。

  • 对应的free_list为空,先看其内存池是不是空时,如果内存池不为空:
    (1)先检验它剩余空间是否够20个节点大小(即所需内存大小(提升后) * 20),若足够则直接从内存池中拿出20个节点大小空间,将其中一个分配给用户使用,另外19个当作自由链表中的区块挂在相应的free_list下,这样下次再有相同大小的内存需求时,可直接拨出。
    (2)如果不够20个节点大小,则看它是否能满足1个节点大小,如果够的话则直接拿出一个分配给用户,然后从剩余的空间中分配尽可能多的节点挂在相应的free_list中。
    (3)如果连一个节点内存都不能满足的话,则将内存池中剩余的空间挂在相应的free_list中(找到相应的free_list),然后再给内存池申请内存,转到3。

  • 内存池为空,申请内存:此时二级空间配置器会使用malloc()heap上申请内存,(一次所申请的内存大小为2 * 所需节点内存大小(提升后)* 20 + 一段额外空间),申请40块,一半拿来用,一半放内存池中。

  • malloc没有成功
    在第三种情况下,如果malloc()失败了,说明heap上没有足够空间分配给我们了,这时,二级空间配置器会从比所需节点空间大的free_list中一一搜索,从比它所需节点空间大的free_list中拔除一个节点来使用。如果这也没找到,说明比其大的free_list中都没有自由区块了,那就要调用一级适配器了。

释放时调用deallocate()函数,若释放的n>128,则调用一级空间配置器,否则就直接将内存块挂上自由链表的合适位置。

  • STL二级空间配置器解决了外部碎片与提高了效率
  • 缺点:
    • 因为自由链表的管理问题,它会把我们需求的内存块自动提升为8的倍数,这时若你需要1个字节,它会给8个字节,即浪费了7个字节,所以它又引入了内部碎片的问题,若相似情况出现很多次,就会造成很多内部碎片;
    • 二级空间配置器是在堆上申请大块的狭义内存池,然后用自由链表管理,供现在使用,在程序执行过程中,它将申请的内存一块一块都挂在自由链表上,即不会还给操作系统,并且它的实现中所有成员全是静态的,所以它申请的所有内存只有在进程结束才会释放内存,还给操作系统,由此带来的问题有:
      • 1.即我不断的开辟小块内存,最后整个堆上的空间都被挂在自由链表上,若我想开辟大块内存就会失败;
      • 2.若自由链表上挂很多内存块没有被使用,当前进程又占着内存不释放,这时别的进程在堆上申请不到空间,也不可以使用当前进程的空闲内存,由此就会引发多种问题。
一级分配器

GC4.9之后就没有第一级了,只有第二级

二级分配器:

——default_alloc_template 剖析

有个自动调整的函数:你传入一个字节参数,表示你需要多大的内存,会自动帮你校对到第几号链表(0-15号链表,最小8字节 最大128字节)

allocate函数:如果要分配的内存大于128字节,就转用第一级分配器,否则也就是小于128字节。那么首先判断落在第几号链表,定位到了,先判断链表是不是空,如果是空就需要充值,(调节到8的倍数,默认一次申请20个区块,当然了也要判断20个是不是能够申请到,如果只申请到一个那就直接返回好了,不止一个的话,把第2到第n个挨个挂到当前链表上,第一个返回回去给容器用,n是不大于20的,当然了如果不在1-20之间,那就是内存碎片了,那就先把碎片挂到某一条链表上,然后再重新malloc了,malloc 2*20个块)去内存池去拿或者重新分配。

189、STL的迭代器

什么是迭代器

  • STL中每种容器在实现的时候设计了一个内嵌的iterator类,不同的容器有自己专属的迭代器,使用迭代器来访问容器中的数据。

    • 迭代器对指针的一些基本操作如*->++==!==进行了重载,使其具有了遍历复杂数据结构的能力,其遍历机制取决于所遍历的数据结构。

    • 所有迭代的使用和指针的使用非常相似。通过beginend函数获取容器的头部和尾部迭代器,end迭代器不包含在容器之内,当beginend返回的迭代器相同时表示容器为空。

    • #include <iostream>
      #include <vector>
      #include <list>
      #include <algorithm>
      using namespace std;
      
      int main(int argc, const char *argv[])
      {
          int arr[5] = { 1, 2, 3, 4, 5};
      
          vector<int> iVec(arr, arr + 5);//定义容器vector
          list<int> iList(arr, arr + 5);//定义容器list
      
          //在容器iVec的头部和尾部之间寻找整形数3
          vector<int>::iterator iter1 = find(iVec.begin(), iVec.end(), 3);
          if (iter1 == iVec.end())
              cout<<"3 not found"<<endl;
          else
              cout<<"3 found"<<endl;
      
          //在容器iList的头部和尾部之间寻找整形数4
          list<int>::iterator iter2 = find(iList.begin(), iList.end(), 4);
          if (iter2 == iList.end())
              cout<<"4 not found"<<endl;
          else
              cout<<"4 found"<<endl;
      
          return 0;
      }
      

迭代器的种类

  • 根据迭代器所支持的操作,可以把迭代器分为5类。

    • 输入迭代器:是只读迭代器,在每个被遍历的位置上只能读取一次。例如上面find函数参数就是输入迭代器。
    • 输出迭代器:是只写迭代器,在每个被遍历的位置上只能被写一次。
    • 前向迭代器:兼具输入和输出迭代器的能力,但是它可以对同一个位置重复进行读和写。但它不支持operator--,所以只能向前移动。
    • 双向迭代器:很像前向迭代器,向后移动和向前移动同样容易。
    • 随机访问迭代器:有双向迭代器的所有功能。而且,它还提供了“迭代器算术”,即在一步内可以向前或向后跳跃任意位置, 包含指针的所有操作,可进行随机访问,随意移动指定的步数。支持前面四种Iterator的所有操作,并另外支持it + nit - nit += nit -= nit1 - it2it[n]等操作。

    STL每种容器类型都定义了 const_iterator,只能读取容器的值,不能修改所指向容器范围内元素的值。vector、string、Deque随机存取迭代器List、Set、map、mutiset、multimap双向迭代器

迭代器失效

  • 容器类插入删除
    list所有迭代器失效有且仅有被删除的节点的迭代器失效
    vectorvector的容量不改变时,只失效之后的迭代器,否则全部失效被删除节点之后的迭代器全部失效
    set,map所有迭代器不失效有且仅有被删除的节点的迭代器才失效
  • 容器的插入inserterase操作可能导致迭代器失效,对于erase操作不要使用操作之前的迭代器,因为erase的那个迭代器一定失效了,正确的做法是返回删除操作时候的那个迭代器。

  • //insert插入
    list<int>::iterator it = L.begin();
    L.insert(L.begin(), 1000);
    printList(L);
    
    //删除
    it = L.begin();
    L.erase(++it);
    printList(L);
    
190、 vector与list的区别与应用?怎么找某vector或者list的倒数第二个元素
  • vector数据结构

    • vector和数组类似,拥有一段连续的内存空间,并且起始地址不变。因此能高效的进行随机存取,时间复杂度为o(1);
    • 但因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。
    • 另外,当数组中内存空间不够时,会重新申请一块内存空间并进行内存拷贝。连续存储结构:vector是可以实现动态增长的对象数组,支持对数组高效率的访问和在数组尾端的删除和插入操作,在中间和头部删除和插入相对不易,需要挪动大量的数据。它与数组最大的区别就是vector不需程序员自己去考虑容量问题,库里面本身已经实现了容量的动态增长,而数组需要程序员手动写入扩容函数进形扩容。
  • 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个元素的话,可以用反向迭代器来遍历:

191、STL 中vector删除其中的元素,迭代器如何变化?为什么是两倍扩容?释放空间?

STL 中vector删除其中的元素

  • 函数功能
    pop_back()删除 vector 容器中最后一个元素,该容器的大小(size)会减 1,但容量(capacity)不会发生改变。
    erase(pos)删除 vector 容器中 pos 迭代器指定位置处的元素,并返回指向被删除元素下一个位置元素的迭代器。该容器的大小(size)会减 1,但容量(capacity)不会发生改变。
    swap(beg)pop_back()先调用 swap() 函数交换要删除的目标元素和容器最后一个元素的位置,然后使用 pop_back() 删除该目标元素。
    erase(beg,end)删除 vector 容器中位于迭代器 [beg,end)指定区域内的所有元素,并返回指向被删除区域下一个位置元素的迭代器。该容器的大小(size)会减小,但容量(capacity)不会发生改变。
    remove()删除容器中所有和指定元素值相等的元素,并返回指向最后一个元素下一个位置的迭代器。值得一提的是,调用该函数不会改变容器的大小和容量。
    clear()删除 vector 容器中所有的元素,使其变成空的 vector 容器。该函数会改变 vector 的大小(变为 0),但不是改变其容量。
    • size()函数返回的是已用空间大小,
    • capacity()返回的是总空间大小,
    • capacity()-size()则是剩余的可用空间大小。
    • size()capacity()相等,说明vector目前的空间已被用完,如果再添加新元素,则会引起vector空间的动态增长。

由于动态增长会引起重新分配内存空间、拷贝原空间、释放原空间,这些过程会降低程序效率。因此,可以使用reserve(n):预先分配一块较大的指定大小的内存空间,这样当指定大小的内存空间未使用完时,是不会重新分配内存空间的,这样便提升了效率。只有当n>capacity()时,调用reserve(n)才会改变vector容量。

  • resize()成员函数只改变元素的数目,不改变vector的容量。

  • 空的vector对象,size()capacity()都为0

  • 当空间大小不足时,新分配的空间大小为原空间大小的2倍。

  • 使用reserve()预先分配一块内存后,在空间未满的情况下,不会引起重新分配,从而提升了效率。

  • reserve()分配的空间比原空间小时,是不会引起重新分配的。

  • reserve(size_type)只是扩大capacity值,这些内存空间可能还是**“野”**的,如果此时使用“[ ]”来访问,则可能会越界。而resize(size_type new_size)会真正使容器具有new_size个对象。

  • 不同的编译器,vector有不同的扩容大小。在vs下是1.5倍,在GCC下是2倍;

  • 空间和时间的权衡。简单来说, 空间分配的多,平摊时间复杂度低,但浪费空间也多。

  • 使用k=2增长因子的问题在于,每次扩展的新尺寸必然刚好大于之前分配的总和,也就是说,之前分配的内存空间不可能被使用。这样对内存不友好。最好把增长因子设为(1,2), 对比可以发现采用采用成倍方式扩容,可以保证常数的时间复杂度,而增加指定大小的容量只能达到O(n)的时间复杂度,因此,使用成倍的方式扩容。

如何释放空间:

  • vector的内存占用空间只增不减,所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素。但是即使clear(),vector所占用的内存空间依然如故,无法保证内存的回收。
  • 如果需要空间动态缩小,可以考虑使用deque。如果vector,可以用swap()来帮助你释放内存。
vector(Vec).swap(Vec);
//将Vec的内存空洞清除;
 vector().swap(Vec);
// 清空Vec的内存;
192、容器内部删除一个元素
  • 顺序容器

  • erase迭代器:不仅使所指向被删除的迭代器失效,而且使被删元素之后的所有迭代器失效(list除外),所以不能使用erase(it++)的方式,但是erase的返回值是下一个有效迭代器;It = c.erase(it)

  • 关联容器

    • erase迭代器只是被删除元素的迭代器失效,但是返回值是void,所以要采用erase(it++)的方式删除迭代器;
    • c.erase(it++)
193、STL迭代器如何实现
  • 迭代器是一种抽象的设计理念,通过迭代器可以在不了解容器内部原理的情况下遍历容器,除此之外,STL中迭代器一个最重要的作用就是作为容器与STL算法的粘合剂

  • 迭代器的作用就是提供一个遍历容器内部所有元素的接口,因此迭代器内部必须保存一个与容器相关联的指针,然后重载各种运算操作来遍历,其中最重要的是*运算符与->运算符,以及++--等可能需要重载的运算符重载。

  • 这和C++中的智能指针很像,智能指针也是将一个指针封装,然后通过引用计数或是其他方法完成自动释放内存的功能。

3、最常用的迭代器的相应型别有五种:value typedifference typepointerreferenceiterator catagoly;

194、map、set是怎么实现的,红黑树是怎么能够同时实现这两种容器? 为什么使用红黑树?
  • set包含了经过排序了的数据,这些数据的值(value)必须是唯一的

  • map(映射):经过排序了的二元组的集合,map中的每个元素都是由两个值组成,其中的key(键值,一个map中的键值必须是唯一的)是在排序或搜索时使用,它的值可以在容器中重新获取;而另一个值是该元素关联的数值。

  • 他们的底层都是以红黑树的结构实现,因此插入删除等操作都在O(logn)时间内完成,因此可以完成高效的插入删除;

  • 在这里我们定义了一个模版参数,如果它是key那么它就是set,如果它是map,那么它就是map;底层是红黑树,实现map的红黑树的节点数据类型是key+value,而实现set的节点数据类型是value

  • 因为map和set要求是自动排序的,红黑树能够实现这一功能,而且时间复杂度比较低。

195、如何在共享内存上使用stl标准库?

为何在共享内存上使用stl标准库?

  • 例如map, vector, list等等,放入共享内存中,IPC一旦有了这些强大的通用数据结构做辅助,无疑进程间通信的能力一下子强大了很多。我们没必要再为共享内存设计其他额外的数据结构,另外,STL的高度可扩展性将为IPC所驱使。STL容器被良好的封装,默认情况下有它们自己的内存管理方案。当一个元素被插入到一个STL列表(list)中时,列表容器自动为其分配内存,保存数据。考虑到要将STL容器放到共享内存中,而容器却自己在堆上分配内存。

如何在共享内存上使用stl标准库?

  • 一个最笨拙的办法是在堆上构造STL容器,然后把容器复制到共享内存,并且确保所有容器的内部分配的内存指向共享内存中的相应区域,这基本是个不可能完成的任务。
  • 假设进程A在共享内存中放入了数个容器,进程B如何找到这些容器呢?

一个方法就是进程A把容器放在共享内存中的确定地址上(fixed offsets),则进程B可以从该已知地址上获取容器。另外一个改进点的办法是,进程A先在共享内存某块确定地址上放置一个map容器,然后进程A再创建其他容器,然后给其取个名字和地址一并保存到这个map容器里。

进程B知道如何获取该保存了地址映射的map容器,然后同样再根据名字取得其他容器的地址。

196、map插入方式有几种?
//1)  用insert函数插入pair数据,

mapStudent.insert(pair<int, string>(1, "student_one")); 

//2)  用insert函数插入value_type数据

mapStudent.insert(map<int, string>::value_type (1, "student_one"));

//3)  在insert函数中使用make_pair()函数

mapStudent.insert(make_pair(1, "student_one")); 

//4)  用数组方式插入数据

mapStudent[1] = "student_one"; 


197、STL中unordered_map(hash_map)和map的区别,hash_map如何解决冲突以及扩容
  • unordered_mapmap类似,都是存储的key-value的值,可以通过key快速索引到value

  • 不同的是unordered_map不会根据key的大小进行排序,存储时是根据key的hash值判断元素是否相同,即unordered_map内部元素是无序的,而map中的元素是按照二叉搜索树存储,进行中序遍历会得到有序遍历。

  • 使用时mapkey需要定义operator<。而unordered_map需要定义hash_value函数并且重载operator==。但是很多系统内置的数据类型都自带这些,

  • 如果是自定义类型,那么就需要自己重载operator<或者hash_value()

  • 如果需要内部元素自动排序,使用map,不需要排序使用unordered_map

  • unordered_map的底层实现是hash_table;

  • hash_map底层使用的是hash_table,而hash_table使用的开链法进行冲突避免,所有hash_map采用开链法进行冲突解决。

    什么时候扩容:

  • 当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值—即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。

扩容(resize)

  • 重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。
198、vector越界访问下标,map越界访问下标?vector删除元素时会不会释放空间?
  • 通过下标访问vector中的元素时不会做边界检查,即便下标越界。下标与first迭代器相加的结果超过了finish迭代器的位置,程序也不会报错,而是返回这个地址中存储的值。如果想在访问vector中的元素时首先进行边界检查,可以使用vector中的at函数。通过使用at函数不但可以通过下标访问vector中的元素,而且在at函数内部会对下标进行边界检查。

  • map的下标运算符[]的作用是:将key作为下标去执行查找,并返回相应的值;如果不存在这个key,就将一个具有keyvalue的某值插入map

  • erase()函数,只能删除内容,不能改变容量大小; erase成员函数,它删除了itVect迭代器指向的元素,并且返回要被删除的itVect之后的迭代器,迭代器相当于一个智能指针;clear()函数,只能清空内容,不能改变容量大小;如果要想在删除内容的同时释放内存,那么你可以选择deque容器。

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

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

200、 STL中listduquevector之间的区别

listvector

  • list不再能够像vector一样以普通指针作为迭代器,因为其节点不保证在存储空间中连续存在;

  • list插入操作和结合才做都不会造成原有的list迭代器失效;

  • list不仅是一个双向链表,而且还是一个环状双向链表,所以它只需要一个指针;

  • list不像vector那样有可能在空间不足时做重新配置、数据移动的操作,所以插入前的所有迭代器在插入操作之后都仍然有效;

dequevector

  • deque是一种双向开口的连续线性空间,所谓双向开口,意思是可以在头尾两端分别做元素的插入和删除操作;可以在头尾两端分别做元素的插入和删除操作;

  • deque和vector最大的差异,一在于deque允许常数时间内对起头端进行元素的插入或移除操作,二在于deque没有所谓容量概念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接起来,deque没有所谓的空间保留功能。

201、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然后释放内存。

202、STL中hash_map扩容发生什么?
  • hash table表格内的元素称为桶(bucket),而由桶所链接的元素称为节点(node),其中存入桶元素的容器为stl本身很重要的一种序列式容器——vector容器。之所以选择vector为存放桶元素的基础容器,主要是因为vector容器本身具有动态扩容能力,无需人工干预。

  • 向前操作:首先尝试从目前所指的节点出发,前进一个位置(节点),由于节点被安置于list内,所以利用节点的next指针即可轻易完成前进操作,如果目前正巧是list的尾端,就跳至下一个bucket身上,那正是指向下一个list的头部节点。

203、常见容器性质总结?

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

  • [堆1] --> [堆2] -->[堆3] --> …

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

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

  • queue不用vector的原因应该是容量大小有限制,扩容耗时(stack和queue其实是适配器,而不叫容器,因为是对容器的再封装)

容器底层结构是否有序是否可重复
vector底层数据结构为数组支持快速随机访问,无序可重复
list双向链表支持快速增删,无序可重复
deque一个中央控制器和多个缓冲区支持首尾(中间不能)快速增删,也支持随机访问,无序可重复
stack一般用list或deque实现
queue一般用list或deque实现
priority_queue一般为vector为底层容器,堆heap为处理规则来管理底层容器
set红黑树有序不重复
multiset红黑树有序可重复
map红黑树有序不重复
multimap红黑树有序不重复
unordered_sethash表无序不重复
unordered_multisethash表无序可重复
unordered_maphash表无序可重复
unordered_multimaphash表无序可重复
204、vector的增加删除都是怎么做的?为什么是1.5或者是2倍?
  • 新增元素:vector通过一个连续的数组存放元素,如果集合已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,在插入新增的元素;

  • 对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了 ;

  • 初始时刻vector的capacity为0,塞入第一个元素后capacity增加为1;

  • 不同的编译器实现的扩容方式不一样,VS2015中以1.5倍扩容,GCC以2倍扩容。

  • 对比可以发现采用采用成倍方式扩容,可以保证常数的时间复杂度,而增加指定大小的容量只能达到O(n)的时间复杂度,因此,使用成倍的方式扩容。

    • 考虑可能产生的堆空间浪费,成倍增长倍数不能太大,使用较为广泛的扩容方式有两种,以2二倍的方式扩容,或者以1.5倍的方式扩容。
    • 以2倍的方式扩容,导致下一次申请的内存必然大于之前分配内存的总和,导致之前分配的内存不能再被使用,所以最好倍增长因子设置为(1,2)之间:
    • 向量容器vector的成员函数pop_back()可以删除最后一个元素.
    • 而函数erase()可以删除由一个iterator指出的元素,也可以删除一个指定范围的元素。
    • 还可以采用通用算法remove()来删除vector容器中的元素.
    • 不同的是:采用remove一般情况下不会改变容器的大小,而pop_back()与erase()等成员函数会改变容器的大小。
205、说一下STL每种容器对应的迭代器
容器迭代器
vector、deque随机访问迭代器
stack、queue、priority_queue
list、(multi)set/map双向迭代器
unordered_(multi)set/map、forward_list前向迭代器
206、STL中vector的实现
  • vector是一种序列式容器,其数据安排以及操作方式与array非常类似,两者的唯一差别就是对于空间运用的灵活性,众所周知,array占用的是静态空间,一旦配置了就不可以改变大小,如果遇到空间不足的情况还要自行创建更大的空间,并手动将数据拷贝到新的空间中,再把原来的空间释放。vector则使用灵活的动态空间配置,维护一块连续的线性空间,在空间不足时,可以自动扩展空间容纳新元素,做到按需供给。其在扩充空间的过程中仍然需要经历:重新配置空间,移动数据,释放原空间等操作。这里需要说明一下动态扩容的规则:以原大小的两倍配置另外一块较大的空间(或者旧长度+新增元素的个数),源码:
207、STL中slist的实现
  • list是双向链表,而slist(single linked list)是单向链表,它们的主要区别在于:
    • 前者的迭代器是双向的Bidirectional iterator
    • 后者的迭代器属于单向的Forward iterator。虽然slist的很多功能不如list灵活,但是其所耗用的空间更小,操作更快。

根据STL的习惯,插入操作会将新元素插入到指定位置之前,而非之后,然而slist是不能回头的,只能往后走,因此在slist的其他位置插入或者移除元素是十分不明智的,但是在slist开头却是可取的,slist特别提供了insert_after()erase_after供灵活应用。考虑到效率问题,slist只提供push_front()操作,元素插入到slist后,存储的次序和输入的次序是相反的

slist的单向迭代器如下图所示:

slist默认采用alloc空间配置器配置节点的空间,其数据结构主要代码如下

template <class T, class Allco = alloc>
class slist
{
	...
private:
    ...
    static list_node* create_node(const value_type& x){}//配置空间、构造元素
    static void destroy_node(list_node* node){}//析构函数、释放空间
private:
    list_node_base head; //头部
public:
    iterator begin(){}
    iterator end(){}
    size_type size(){}
    bool empty(){}
    void swap(slist& L){}//交换两个slist,只需要换head即可
    reference front(){} //取头部元素
    void push_front(const value& x){}//头部插入元素
    void pop_front(){}//从头部取走元素
    ...
}



举个例子:

#include <forward_list>
#include <algorithm>
#include <iostream>
using namespace std;

int main()
{
	forward_list<int> fl;
	fl.push_front(1);
	fl.push_front(3);
	fl.push_front(2);
	fl.push_front(6);
	fl.push_front(5);

	forward_list<int>::iterator ite1 = fl.begin();
	forward_list<int>::iterator ite2 = fl.end();
	for(;ite1 != ite2; ++ite1)
	{
		cout << *ite1 <<" "; // 5 6 2 3 1
	}
	cout << endl;

	ite1 = find(fl.begin(), fl.end(), 2); //寻找2的位置

	if (ite1 != ite2)
		fl.insert_after(ite1, 99);
	for (auto it : fl)
	{
		cout << it << " ";  //5 6 2 99 3 1
	}
	cout << endl;

	ite1 = find(fl.begin(), fl.end(), 6); //寻找6的位置
	if (ite1 != ite2)
		fl.erase_after(ite1);
	for (auto it : fl)
	{
		cout << it << " ";  //5 6 99 3 1
	}
	cout << endl;	
	return 0;
}



208、STL中list的实现

相比于vector的连续线型空间,list显得复杂许多,但是它的好处在于插入或删除都只作用于一个元素空间,因此list对空间的运用是十分精准的,对任何位置元素的插入和删除都是常数时间。list不能保证节点在存储空间中连续存储,也拥有迭代器,迭代器的“++”、“–”操作对于的是指针的操作,list提供的迭代器类型是双向迭代器:Bidirectional iterators。

list节点的结构见如下源码:

template <class T>
struct __list_node{
    typedef void* void_pointer;
    void_pointer prev;
    void_pointer next;
    T data;
}

从源码可看出list显然是一个双向链表。list与vector的另一个区别是,在插入和接合操作之后,都不会造成原迭代器失效,而vector可能因为空间重新配置导致迭代器失效。

此外list也是一个环形链表,因此只要一个指针便能完整表现整个链表。list中node节点指针始终指向尾端的一个空白节点,因此是一种“前闭后开”的区间结构

list的空间管理默认采用alloc作为空间配置器,为了方便的以节点大小为配置单位,还定义一个list_node_allocator函数可一次性配置多个节点空间

由于list的双向特性,其支持在头部(front)和尾部(back)两个方向进行push和pop操作,当然还支持erasesplice,`sort,merge,reverse,sort等操作,

209、STL中的deque的实现

vector是单向开口(尾部)的连续线性空间,deque则是一种双向开口的连续线性空间,虽然vector也可以在头尾进行元素操作,但是其头部操作的效率十分低下(主要是涉及到整体的移动)

deque和vector的最大差异一个是deque运行在常数时间内对头端进行元素操作,二是deque没有容量的概念,它是动态地以分段连续空间组合而成,可以随时增加一段新的空间并链接起来

deque虽然也提供随机访问的迭代器,但是其迭代器并不是普通的指针,其复杂程度比vector高很多,因此除非必要,否则一般使用vector而非deque。如果需要对deque排序,可以先将deque中的元素复制到vector中,利用sort对vector排序,再将结果复制回deque

deque由一段一段的定量连续空间组成,一旦需要增加新的空间,只要配置一段定量连续空间拼接在头部或尾部即可,因此deque的最大任务是如何维护这个整体的连续性

deque的数据结构如下:

class deque
{
    ...
protected:
    typedef pointer* map_pointer;//指向map指针的指针
    map_pointer map;//指向map
    size_type map_size;//map的大小
public:
    ...
    iterator begin();
    itertator end();
    ...
}


deque内部有一个指针指向map,map是一小块连续空间,其中的每个元素称为一个节点,node,每个node都是一个指针,指向另一段较大的连续空间,称为缓冲区,这里就是deque中实际存放数据的区域,默认大小512bytes。整体结构如上图所示。

deque的迭代器数据结构如下:

struct __deque_iterator
{
    ...
    T* cur;//迭代器所指缓冲区当前的元素
    T* first;//迭代器所指缓冲区第一个元素
    T* last;//迭代器所指缓冲区最后一个元素
    map_pointer node;//指向map中的node
    ...
}


从deque的迭代器数据结构可以看出,为了保持与容器联结,迭代器主要包含上述4个元素

deque迭代器的“++”、“–”操作是远比vector迭代器繁琐,其主要工作在于缓冲区边界,如何从当前缓冲区跳到另一个缓冲区,当然deque内部在插入元素时,如果map中node数量全部使用完,且node指向的缓冲区也没有多余的空间,这时会配置新的map(2倍于当前+2的数量)来容纳更多的node,也就是可以指向更多的缓冲区。在deque删除元素时,也提供了元素的析构和空闲缓冲区空间的释放等机制。

《STL源码剖析》 侯捷 P143-164

210、STL中stack和queue的实现

stack

stack(栈)是一种先进后出(First In Last Out)的数据结构,只有一个入口和出口,那就是栈顶,除了获取栈顶元素外,没有其他方法可以获取到内部的其他元素,其结构图如下:

stack这种单向开口的数据结构很容易由双向开口的deque和list形成,只需要根据stack的性质对应移除某些接口即可实现,stack的源码如下:

template <class T, class Sequence = deque<T> >
class stack
{
	...
protected:
    Sequence c;
public:
    bool empty(){return c.empty();}
    size_type size() const{return c.size();}
    reference top() const {return c.back();}
    const_reference top() const{return c.back();}
    void push(const value_type& x){c.push_back(x);}
    void pop(){c.pop_back();}
};


从stack的数据结构可以看出,其所有操作都是围绕Sequence完成,而Sequence默认是deque数据结构。stack这种“修改某种接口,形成另一种风貌”的行为,成为adapter(配接器)。常将其归类为container adapter而非container

stack除了默认使用deque作为其底层容器之外,也可以使用双向开口的list,只需要在初始化stack时,将list作为第二个参数即可。由于stack只能操作顶端的元素,因此其内部元素无法被访问,也不提供迭代器。

queue

queue(队列)是一种先进先出(First In First Out)的数据结构,只有一个入口和一个出口,分别位于最底端和最顶端,出口元素外,没有其他方法可以获取到内部的其他元素,其结构图如下:

类似的,queue这种“先进先出”的数据结构很容易由双向开口的deque和list形成,只需要根据queue的性质对应移除某些接口即可实现,queue的源码如下:

template <class T, class Sequence = deque<T> >
class queue
{
	...
protected:
    Sequence c;
public:
    bool empty(){return c.empty();}
    size_type size() const{return c.size();}
    reference front() const {return c.front();}
    const_reference front() const{return c.front();}
    void push(const value_type& x){c.push_back(x);}
    void pop(){c.pop_front();}
};


从queue的数据结构可以看出,其所有操作都也都是是围绕Sequence完成,Sequence默认也是deque数据结构。queue也是一类container adapter。

同样,queue也可以使用list作为底层容器,不具有遍历功能,没有迭代器。

211、STL中的heap的实现
  • heap(堆)并不是STL的容器组件,是priority queue(优先队列)的底层实现机制
  • 因为binary max heap(大根堆)总是最大值位于堆的根部,优先级最高。

binary heap本质是一种complete binary tree(完全二叉树),整棵binary tree除了最底层的叶节点之外,都是填满的,但是叶节点从左到右不会出现空隙,如下图所示就是一颗完全二叉树

  • 完全二叉树内没有任何节点漏洞,是非常紧凑的,这样的一个好处是可以使用array来存储所有的节点,因为当其中某个节点位于 i i i处,其左节点必定位于 2 i 2i 2i处,右节点位于 2 i + 1 2i+1 2i+1处,父节点位于 i / 2 i/2 i/2(向下取整)处。这种以array表示tree的方式称为隐式表述法。
  • 因此我们可以使用一个array和一组heap算法来实现max heap(每个节点的值大于等于其子节点的值)和min heap(每个节点的值小于等于其子节点的值)。由于array不能动态的改变空间大小,用vector代替array是一个不错的选择。

push_heap插入算法

  • 由于完全二叉树的性质,新插入的元素一定是位于树的最底层作为叶子节点,并填补由左至右的第一个空格。事实上,在刚执行插入操作时,新元素位于底层vector的end()处,之后是一个称为percolate up(上溯)的过程,举个例子如下图:

新元素50在插入堆中后,先放在vector的end()存着,之后执行上溯过程,调整其根结点的位置,以便满足max heap的性质,如果了解大根堆的话,这个原理跟大根堆的调整过程是一样的。

pop_heap算法

heap的pop操作实际弹出的是根节点吗,但在heap内部执行pop_heap时,只是将其移动到vector的最后位置,然后再为这个被挤走的元素找到一个合适的安放位置,使整颗树满足完全二叉树的条件。这个被挤掉的元素首先会与根结点的两个子节点比较,并与较大的子节点更换位置,如此一直往下,直到这个被挤掉的元素大于左右两个子节点,或者下放到叶节点为止,这个过程称为percolate down(下溯)。举个例子:

根节点68被pop之后,移到了vector的最底部,将24挤出,24被迫从根节点开始与其子节点进行比较,直到找到合适的位置安身,需要注意的是pop之后元素并没有被移走,如果要将其移走,可以使用pop_back()。

sort算法

  • 一言以蔽之,因为pop_heap可以将当前heap中的最大值置于底层容器vector的末尾,heap范围减1,那么不断的执行pop_heap直到树为空,即可得到一个递增序列。

make_heap算法

  • 将一段数据转化为heap,一个一个数据插入,调用上面说的两种percolate算法即可。

代码实测:

#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

int main()
{
	vector<int> v = { 0,1,2,3,4,5,6 };
	make_heap(v.begin(), v.end()); //以vector为底层容器
	for (auto i : v)
	{
		cout << i << " "; // 6 4 5 3 1 0 2
	}
	cout << endl;
	v.push_back(7);
	push_heap(v.begin(), v.end());
	for (auto i : v)
	{
		cout << i << " "; // 7 6 5 4 1 0 2 3
	}
	cout << endl;
	pop_heap(v.begin(), v.end());
	cout << v.back() << endl; // 7 
	v.pop_back();
	for (auto i : v)
	{
		cout << i << " "; // 6 4 5 3 1 0 2
	}
	cout << endl;
	sort_heap(v.begin(), v.end());
	for (auto i : v)
	{
		cout << i << " "; // 0 1 2 3 4 5 6
	}
	return 0;
}


212、STL中的priority_queue的实现
  • priority_queue:优先队列,是一个拥有权值观念的queue,它跟queue一样是顶部入口,底部出口,在插入元素时,元素并非按照插入次序排列,它会自动根据权值(通常是元素的实值)排列,权值最高,排在最前面,如下图所示。

  • 默认情况下,priority_queue使用一个max-heap完成,底层容器使用的是一般为vector为底层容器,堆heap为处理规则来管理底层容器实现 。priority_queue的这种实现机制导致其不被归为容器,而是一种容器配接器。关键的源码如下:
template <class T, class Squence = vector<T>, 
class Compare = less<typename Sequence::value_tyoe> >
class priority_queue{
	...
protected:
    Sequence c; // 底层容器
    Compare comp; // 元素大小比较标准
public:
    bool empty() const {return c.empty();}
    size_type size() const {return c.size();}
    const_reference top() const {return c.front()}
    void push(const value_type& x)
    {
        c.push_heap(x);
        push_heap(c.begin(), c.end(),comp);
    }
    void pop()
    {
        pop_heap(c.begin(), c.end(),comp);
        c.pop_back();
    }
};
  • priority_queue的所有元素,进出都有一定的规则,只有queue顶端的元素(权值最高者),才有机会被外界取用,它没有遍历功能,也不提供迭代器
#include <queue>
#include <iostream>
using namespace std;

int main()
{
	int ia[9] = {0,4,1,2,3,6,5,8,7 };
	priority_queue<int> pq(ia, ia + 9);
	cout << pq.size() <<endl;  // 9
	for(int i = 0; i < pq.size(); i++)
	{
		cout << pq.top() << " "; // 8 8 8 8 8 8 8 8 8
	}
	cout << endl;
	while (!pq.empty())
	{
		cout << pq.top() << ' ';// 8 7 6 5 4 3 2 1 0
		pq.pop();
	}
	return 0;
}
213、STL中set的实现?
  • set的特性是
    • 所有元素都会根据元素的值自动被排序(默认升序),
    • set元素的键值就是实值,实值就是键值,
    • set不允许有两个相同的键值
    • set不允许迭代器修改元素的值,其迭代器是一种constance iterators

标准的STL set以RB-tree(红黑树)作为底层机制,几乎所有的set操作行为都是转调用RB-tree的操作行为,这里补充一下红黑树的特性:

  • 每个节点不是红色就是黑色根结点为黑
  • 如果节点为红色,其子节点必为黑
  • 任一节点至(NULL)树尾端的任何路径,所含的黑节点数量必相同
#include <set>
#include <iostream>
using namespace std;


int main()
{
	int i;
	int ia[5] = { 1,2,3,4,5 };
	set<int> s(ia, ia + 5);
	cout << s.size() << endl; // 5
	cout << s.count(3) << endl; // 1
	cout << s.count(10) << endl; // 0

	s.insert(3); //再插入一个3
	cout << s.size() << endl; // 5
	cout << s.count(3) << endl; // 1

	s.erase(1);
	cout << s.size() << endl; // 4

	set<int>::iterator b = s.begin();
	set<int>::iterator e = s.end();
	for (; b != e; ++b)
		cout << *b << " "; // 2 3 4 5
	cout << endl;

	b = find(s.begin(), s.end(), 5);
	if (b != s.end())
		cout << "5 found" << endl; // 5 found

	b = s.find(2);
	if (b != s.end())
		cout << "2 found" << endl; // 2 found

	b = s.find(1);
	if (b == s.end())
		cout << "1 not found" << endl; // 1 not found
	return 0;
}



214、STL中map的实现
  • map的特性是
    • 所有元素会根据键值进行自动排序。map中所有的元素都是pair,拥有键值(key)和实值(value)两个部分,并且不允许元素有相同的key
    • 一旦map的key确定了,那么是无法修改的,但是可以修改这个key对应的value,
  • 因此map的迭代器既不是constant iterator,也不是mutable iterator

标准STL map的底层机制是RB-tree(红黑树),另一种以hash table为底层机制实现的称为hash_map。map的架构如下图所示

  • map的在构造时缺省采用递增排序key,也使用alloc配置器配置空间大小,需要注意的是在插入元素时,调用的是红黑树中的insert_unique()方法,而非insert_euqal()(multimap使用)
#include <map>
#include <iostream>
#include <string>
using namespace std;

int main()
{
	map<string, int> maps;
    //插入若干元素
	maps["jack"] = 1;
	maps["jane"] = 2;
	maps["july"] = 3;
	//以pair形式插入
	pair<string, int> p("david", 4);
	maps.insert(p);
	//迭代输出元素
	map<string, int>::iterator iter = maps.begin();
	for (; iter != maps.end(); ++iter)
	{
		cout << iter->first << " ";
		cout << iter->second << "--"; //david 4--jack 1--jane 2--july 3--
	}
	cout << endl;
	//使用subscipt操作取实值
	int num = maps["july"];
	cout << num << endl; // 3
	//查找某key
	iter = maps.find("jane");
	if(iter != maps.end())
		cout << iter->second << endl; // 2
    //修改实值
	iter->second = 100;
	int num2 = maps["jane"]; // 100
	cout << num2 << endl;
	
	return 0;
}
  • subscript(下标)操作:既可以作为左值运用(修改内容);也可以作为右值运用(获取实值)。
maps["abc"] = 1; //左值运用
int num = masp["abd"]; //右值运用
  • subscript操作符都会先根据键值找出实值,源码如下:
...
T& operator[](const key_type& k)
{
	return (*((insert(value_type(k, T()))).first)).second;
}
...
  • 首先根据键值和实值做出一个元素,这个元素的实值未知,因此产生一个与实值型别相同的临时对象替代:
value_type(k, T());

再将这个对象插入到map中,并返回一个pair:

pair<iterator,bool> insert(value_type(k, T()));

pair第一个元素是迭代器,指向当前插入的新元素,如果插入成功返回true,此时对应左值运用,根据键值插入实值。插入失败(重复插入)返回false,此时返回的是已经存在的元素,则可以取到它的实值

(insert(value_type(k, T()))).first; //迭代器
*((insert(value_type(k, T()))).first); //解引用
(*((insert(value_type(k, T()))).first)).second; //取出实值

由于这个实值是以引用方式传递,因此作为左值或者右值都可以

215、set和map的区别,multimap和multiset的区别
  • set只提供一种数据类型的接口,但是会将这一个元素分配到key和value上,而且它的compare_function用的是 identity()函数,这个函数是输入什么输出什么,这样就实现了set机制,set的key和value其实是一样的了。其实他保存的是两份元素,而不是只保存一份元素

  • map则提供两种数据类型的接口,分别放在key和value的位置上,他compare_function采用的是红黑树的comparefunction(),保存的确实是两份元素。

他们两个的insert都是采用红黑树的insert_unique() 独一无二的插入 。

  • multimap和map的唯一区别就是:
    • multimap调用的是红黑树的insert_equal(),可以重复插入,
    • map调用的则是独一无二的插入insert_unique(),multiset和set也一样,底层实现都是一样的,只是在插入的时候调用的方法不一样。

红黑树概念

面试时候现场写红黑树代码的概率几乎为0,但是红黑树一些基本概念还是需要掌握的。

1、它是二叉排序树(继承二叉排序树特显):

  • 若左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值。

  • 若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值。

  • 左、右子树也分别为二叉排序树。

2、它满足如下几点要求:

  • 树中所有节点非红即黑。

  • 根节点必为黑节点。

  • 红节点的子节点必为黑(黑节点子节点可为黑)。

  • 从根到NULL的任何路径上黑结点数相同。

3、查找时间一定可以控制在O(logn)。

216、STL中unordered_map和map的区别和应用场景
  • map支持键值的自动排序,底层机制是红黑树,红黑树的查询和维护时间复杂度均为 O ( l o g n ) O(logn) O(logn),但是空间占用比较大,因为每个节点要保持父节点、孩子节点及颜色的信息

  • unordered_map是C++ 11新添加的容器,底层机制是哈希表,通过hash函数计算元素位置,其查询时间复杂度为O(1),维护时间与bucket桶所维护的list长度有关,但是建立hash表耗时较大

从两者的底层机制和特点可以看出:

  • map适用于有序数据的应用场景,
  • unordered_map适用于高效查询的应用场景
217、hashtable中解决冲突有哪些方法?

记住前三个:

线性探测

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

开链

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

再散列

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

二次探测

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

公共溢出区

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

218、模板特化

模版与特化的概念

  • C++中模板分为函数模板和类模板
    • 函数模板:是一种抽象函数定义,它代表一类同构函数。
    • 类模板:是一种更高层次的抽象的类定义。

特化的概念

  • 所谓特化,为已有的模板参数进行一些使其特殊化的指定,使得以前不受任何约束的模板参数,或受到特定的修饰或完全被指定了下来。

模板特化的分类

  • 针对特化的对象不同,分为两类:函数模板的特化和类模板的特化

    • 函数模板的特化
      • 当函数模板需要对某些类型进行特化处理,称为函数模板的特化。
    • 类模板的特化
      • 当类模板内需要对某些类型进行特别处理时,使用类模板的特化。
  • 特化整体上分为全特化和偏特化

    • 全特化:模板中模板参数全被指定为确定的类型

    • 偏特化:模板中的模板参数没有被全部确定,需要编译器在编译时进行确定。

      • 全特化的标志就是产生出完全确定的东西,而不是还需要在编译期间去搜寻适合的特化实现,
    • 模板函数只能全特化,没有偏特化(以后可能有)。

    • 模板类是可以全特化偏特化的。

  • 特化标志

    • 全特化的标志:template <>然后是完全和模板类型没有一点关系的类实现或者函数定义
    • 偏特化的标志:template

函数模版的特化技巧
函数模板的特化:当函数模板需要对某些类型进行特别处理,称为函数模板的特化。

template <class T>
int compare(const T &left, const T&right)
{
    std::cout <<"in template<class T>..." <<std::endl;
    return (left - right);
}
template < >
int compare<const char*>(const char* left, const char* right)
{
    std::cout <<"in special template< >..." <<std::endl;
    return strcmp(left, right);

}

示例程序1–比较两个数据
#include <iostream>
#include <cstring>
///  模版特化

template <class T>
int compare(const T left, const T right)
{
    std::cout <<"in template<class T>..." <<std::endl;
    return (left - right);
}
//  这个是一个特化的函数模版
template < >
int compare<const char*>(const char* left, const char* right)
{
    std::cout <<"in special template< >..." <<std::endl;

    return strcmp(left, right);

}
//  特化的函数模版, 两个特化的模版本质相同, 因此编译器会报错
// error: redefinition of 'int compare(T, T) [with T = const char*]'|
//template < >
//int compare(const char* left, const char* right)
//{
//    std::cout <<"in special template< >..." <<std::endl;
//
//    return strcmp(left, right);
//}
//  这个其实本质是函数重载
int compare(char* left, char* right)
{
    std::cout <<"in overload function..." <<std::endl;
    return strcmp(left, right);
}

int main( )
{
    compare(1, 4);
    const char *left = "gatieme";
    const char *right = "jeancheng";
    compare(left, right);
    return 0;
}

函数模版的特化,当函数调用发现有特化后的匹配函数时,会优先调用特化的函数,而不再通过函数模版来进行实例化。

示例程序二-判断两个数据是否相等
#include <iostream>
#include <cstring>

using namespace std;
//函数模板
template<class T>
bool IsEqual(T t1,T t2){
    return t1==t2;
}

template<> //函数模板特化
bool IsEqual(char *t1,char *t2){
    return strcmp(t1,t2)==0;
}

int main(int argc, char* argv[])
{
    char str1[]="abc";
    char str2[]="abc";
    cout<<"函数模板和函数模板特化"<<endl;
    cout<<IsEqual(1,1)<<endl;
    cout<<IsEqual(str1,str2)<<endl;

    return 0;

}


类模版特化

  • 类模板的特化:与函数模板类似,当类模板内需要对某些类型进行特别处理时,使用类模板的特化。

    • 一是特化为绝对类型;

    • 二是特化为引用,指针类型;

    • 三是特化为另外一个类模板。

    • 特化为绝对类型:

      • 也就是说直接为某个特定类型做特化,这是我们最常见的一种特化方式, 如特化为float, double等

      • #include <iostream>
        #include <cstring>
        #include <cmath>
        // general version
        template<class T>
        class Compare
        {
        public:
            static bool IsEqual(const T& lh, const T& rh)
            {
                std::cout <<"in the general class..." <<std::endl;
                return lh == rh;
            }
        };
        
        
        
        // specialize for float
        template<>
        class Compare<float>
        {
        public:
            static bool IsEqual(const float& lh, const float& rh)
            {
                std::cout <<"in the float special class..." <<std::endl;
        
                return std::abs(lh - rh) < 10e-3;
            }
        };
        
        // specialize for double
        template<>
        class Compare<double>
        {
        public:
            static bool IsEqual(const double& lh, const double& rh)
            {
                std::cout <<"in the double special class..." <<std::endl;
        
                return std::abs(lh - rh) < 10e-6;
            }
        };
        
        
        int main(void)
        {
            Compare<int> comp1;
            std::cout <<comp1.IsEqual(3, 4) <<std::endl;
            std::cout <<comp1.IsEqual(3, 3) <<std::endl;
        
            Compare<float> comp2;
            std::cout <<comp2.IsEqual(3.14, 4.14) <<std::endl;
            std::cout <<comp2.IsEqual(3, 3) <<std::endl;
        
            Compare<double> comp3;
            std::cout <<comp3.IsEqual(3.14159, 4.14159) <<std::endl;
            std::cout <<comp3.IsEqual(3.14159, 3.14159) <<std::endl;
            return 0;
        }
        
        
      • 如果期望使用偏特化,

      • template<class T1, class T2>
        class A	
        {
        }
        
        template<class T1>
        class A<T1, int>
        {
        }
        
    • 特化为引用,指针类型

    template <class _Iterator>
    struct iterator_traits {
    typedef typename _Iterator::iterator_category iterator_category;
    typedef typename _Iterator::value_type        value_type;
    typedef typename _Iterator::difference_type   difference_type;
    typedef typename _Iterator::pointer           pointer;
    typedef typename _Iterator::reference         reference;
    };
    
    // specialize for _Tp*
    template <class _Tp>
    struct iterator_traits<_Tp*> {
      typedef random_access_iterator_tag iterator_category;
      typedef _Tp                         value_type;
      typedef ptrdiff_t                   difference_type;
      typedef _Tp*                        pointer;
      typedef _Tp&                        reference;
    };
    
    // specialize for const _Tp*
    template <class _Tp>
    struct iterator_traits<const _Tp*> {
      typedef random_access_iterator_tag iterator_category;
      typedef _Tp                         value_type;
      typedef ptrdiff_t                   difference_type;
      typedef const _Tp*                  pointer;
      typedef const _Tp&                  reference;
    }
    
    • 除了T*, 我们也可以将T特化为 const T*,T&, const T&等,以下还是以T*为例:

      • // specialize for T*
        template<class T>
        class Compare<T*>
        {
        public:
            static bool IsEqual(const T* lh, const T* rh)
            {
                return Compare<T>::IsEqual(*lh, *rh);
            }
        };
        
      • 这种特化其实是就不是一种绝对的特化, 它只是对类型做了某些限定,但仍然保留了其一定的模板性,这种特化给我们提供了极大的方便, 如这里, 我们就不需要对int*, float*, double*等等类型分别做特化了。

      • // specialize for vector<T>
        template<class T>
        class Compare<vector<T> >
        {
        public:
            static bool IsEqual(const vector<T>& lh, const vector<T>& rh)
            {
                if(lh.size() != rh.size()) return false;
                else
                {
                    for(int i = 0; i < lh.size(); ++i)
                    {
                        if(lh[i] != rh[i]) return false;
                    }
                }
                return true;
            }
        };
        

219、Traits 编程技法

为什么私用Traits技术

  • 算法中需要知道迭代器所指元素的类型

如何解决

  • 迭代器
    • 迭代器:一种智能指针,它也就拥有了一般指针的所有特点——能够对其进行*->操作。但是在遍历容器的时候,不可避免的要对遍历的容器内部有所了解,所以,每一种 STL 容器都提供有专属迭代器的缘故。

利用 function template 的参数推导机制。

如果 T 是某个指向特定对象的指针,那么在 func 中需要指针所指向对象的型别的时候,模板的参数推导机制可以完成任务,

template <class I>
inline
void func(I iter) {
    func_impl(iter, *iter); // 传入iter和iter所指的值,class自动推导
}

通过模板的推导机制,我们轻而易举的或得了指针所指向的对象的类型。

template <class I, class T>
void func_impl(I iter, T t) {
        T tmp; // 这里就是迭代器所指物的类别
        // ... 功能实现
}

int main() {
    int i;
    func(&i);
}

但是,函数的"template 参数推导机制"推导的只是参数,无法推导函数的返回值类型。万一需要推导函数的传回值,就无能为力了

声明内嵌型别

  • template <class T>
    struct MyIter {
        typedef T value_type; // 内嵌型别声明
        // ...
    };
    
    template <class I>
    typename I::value_type
    func(I ite) {
        return *ite;
    }
    
    // ...
    MyIter<int> ite(new int(8));
    cout << func(ite);
    
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值