0%

(面试)C++基础知识

1. C++知识面试版_基础

1.1 编译过程

如上所示,一个c源文件会先经过预处理,将头文件展开、宏替换和去注释;然后经过编译器生成汇编文件,再有汇编器生成二进制文件,最后再经过链接器将函数库中相应的代码组合到目标文件

  • 预处理:预处理器会扫描源代码文件,根据以“#”开头的预处理指令执行一些文本替换和文件包含操作。例如,预处理器会把所有的#include指令替换为对应的头文件内容,并将定义的宏展开。生成.i文件

  • 编译:编译器将经过预处理的源代码文件翻译成汇编代码,这是一种与具体机器体系结构相关的低级代码。生成.s文件

  • 汇编:汇编器将汇编代码转化为机器码,并生成一个目标文件(.obj 或 .o)。目标文件包含机器指令和一些元数据,如符号表和重定位信息。生成二进制.o文件

  • 链接:链接器将目标文件与系统库和其他目标文件链接在一起,生成可执行文件。链接器主要完成两个任务:解析符号引用和地址重定位。
    • 符号解析:每个符号对应一个函数、一个全局变量或一个静态变量,符号解析的目的就是将每个符号引用正好和一个符号定义关联起来。那么当然在不同目标文件中引用的同一全局变量或函数,链接器需要将其解析为同一个实体。
    • 地址重定位指的是在链接过程中,需要将不同目标文件中的函数和变量的地址进行调整,使得它们在最终的可执行文件中能够正确地链接到一起。生成可执行文件

1.2 静态链接和动态链接

1.2.1 静态链接:

链接器将目标文件和库文件的代码和数据全部拷贝到可执行文件中,形成一个独立的、包含所有必需代码和数据的可执行文件。在运行时,可执行文件不需要依赖外部库文件,所有需要的代码和数据都已经包含在可执行文件中

  • 优点:对运行环境的依赖性较小,具有较好的兼容性,方便分发和部署,不需要外部依赖
  • 缺点:生成的程序比较大,需要更多的系统资源,在装入内存时会消耗更多的时间;库函数有了更新,必须重新编译应用程序
1.2.2 动态链接

在动态链接中,库文件的代码和数据被保留在一个独立的文件中,被多个可执行文件共享。在链接时,链接器会将可执行文件中需要的库函数和数据的引用替换为动态链接库的符号表中对应的地址。在运行时,当程序调用一个需要动态链接库中的函数时,操作系统会将对应的库文件加载到内存中,并将调用转向库文件中的函数

  • 优点:在需要的时候才会调入对应的资源函数;简化程序的升级;可执行文件小,节省磁盘空间;同时多个可执行文件可以共享同一个库文件,减少了内存占用
  • 缺点:依赖动态库,不能独立运行;动态库依赖版本问题严重。如果库文件版本或路径发生变化,可能会导致程序无法正常运行

注:前面我们编写的应用程序大量用到了标准库函数,系统默认采用动态链接的方式进行编译程序,若想采用静态编译,加入-static参数。

1.2.3 运行是加载和链接共享库

1.3 C++函数调用过程

函数调用栈的基本知识

  • 每个线程都有一个自己的函数调用栈
  • 栈也是程序申请的一段内存,随着栈的使用而增长。而一般编译的时候也可以指定编译选项设置栈最大值。如果递归调用层数太深,会导致栈溢出。
  • 系统中程序执行的时候 栈都是从高地址往低地址增长的<函数参数压栈,一般从右向左压栈(比如__cdecl函数调用约定)
  • rip寄存器存储当前执行指令的内存位置,也称为程序计数器pc
  • rbp寄存器表明当前栈帧的栈底
  • rsp寄存器表明当前栈帧的栈顶

在C/C++中,函数调用的过程通常包括以下步骤:比如右调用者函数P,被调用函数Q

  • 第一步:保存调用者寄存器的值(保存现场):在调用函数Q之前,需要将调用者P当前的寄存器状态(除了被调用者保存寄存器如%rbx、%rbp、%r12~%r15和栈指针%rsp外,其他被划分位调用者保存寄存器的要在这里保存寄存器状态)的值保存在P的栈帧起来,以便函数调用完成后能够正确地恢复。

  • 第二步:传递参数和保存:然后函数的参数通过栈或寄存器传递给被调用函数。对于较少的参数(前6个),通常会使用寄存器传递,而对于较多的参数,则会使用栈传递,将剩余的参数从右向左压入P的栈帧(这也是为什么最后压如的是参数7)。将调用者P的返回地址压入栈中以保证能返回原来的地址继续执行,这个返回地址指向调用者函数在执行完被调用函数后应该返回的下一条指令的地址。

  • 第三步:跳转到被调用函数:在传递完参数后,通过设置程序计数器位Q的入口地址,跳转到被调用函数Q的入口地址开始执行被调用函数的代码。

  • 第四步:函数内部处理:被调用函数在执行时,保存需要保存的计数器值,然后将处理函数内部的逻辑,包括局部变量的分配和计算等操作,函数内部的变量通常会被分配在堆栈上。

  • 第五步:返回值:当Q执行完毕后,若有返回值,则会把返回值存放在寄存器%rax处,之后释放自己栈帧,弹出返回地址和压入参数,恢复现场的寄存器值状态,使得程序能够无错误的继续执行下一条指令。

调用函数具体过程

1
2
3
4
5
6
7
8
9
10
11
12
//以此为例
intFunAdd(intiPara1, intiPara2) {
intiAdd = 7;
intiResult = iPara1 + iPara2 + iAdd;
returniResult;
}
int main{
intiVal1 = 5; intiVal2 = 6;
intiRes = FunAdd(iVal1, iVal2);
printf( "iRes: %dn", iRes);
return 0;
}

call指令做了哪些事情 答:call指令只是函数调用的一部分指令,它做了一些函数调用的部分控制,并不是全部。call指令做两件事,一是将当前调用函数的下一条指令地址入栈,即保证被调用函数结束后返回能够继续正常执行。二是设置程序计数器PC(%rip)为被调用函数的入口地址,使得能正确跳转该函数执行。 怎么知道返回时栈顶指针恢复到哪呢? 编译器会计算当前函数需要多少空间,这样通过add指令后恢复

1.4 inline内联函数与普通函数的区别

  • 相当于把内联函数里面的内容写在调用内联函数的地方;
  • 相当于不用执行调用函数的步骤,直接执行函数体;
  • 相当于宏,却比宏多了类型检查,真正具有函数特性;
  • 编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数;
  • 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数
1.4.1 内联函数与宏定义的区别
  • 安全性:内联函数在编译期进行类型检查,因此比宏定义更安全。宏定义只是一个简单的文本替换,没有类型检查,可能会导致一些潜在的错误。

  • 可读性:内联函数在代码中的表现形式更像是一个普通函数,可以使用调试器和其它工具进行跟踪和分析,代码的可读性更高。而宏定义的代码片段可能比较难以理解,也难以进行调试和分析。

  • 函数特性:内联函数是C++中的一个特性,因此可以使用C++的函数特性,如函数重载、默认参数等。而宏定义只是一个简单的文本替换,不支持这些特性。

  • 对象代码生成:内联函数在编译期间将代码复制到调用点处,因此可以生成与普通函数相同的对象代码,而且比宏定义更灵活,可以根据参数类型生成不同的代码。而宏定义只是简单的文本替换,不能生成任何对象代码。

综上所述,内联函数比宏定义更加安全、可读、易于维护,同时支持函数特性和对象代码生成,因此在C++中推荐使用内联函数来替代宏定义。

1.5 结构体中的字节对齐

内存对齐的目的是为了提高CPU读写内存里数据的速度。现代的CPU读取内存并不是一个一个字节挨着读取,这样做的效率非常低。现代的CPU一般以4个字节(32bit数据总线)或者8个字节(64bit数据总线)为一组,一组一组地读写内存里的数据。为了使的计算机一次能够读完整,引入了内存对齐原则,支持计算机的快速寻址。

内存对齐原则:

  • 第一个成员在与结构体变量偏移量为0的地址处。
  • 其他成员变量都放在对齐数(成员的大小和默认对齐数的较小值)的整数倍的偏移地址处。
    • 对齐数=编译器默认的一个对齐数与该成员大小的较小值。(不同的编译器其默认对齐数不同,64位系统中VS默认的对齐数是8,在Linux中没有默认的对齐数)
    • 可以在程序开端声明#pragma pack(数字)来设置默认对齐值
  • 结构体总大小为最大对齐数(每个成员变量都有一个对齐数 )的整数倍。
  • 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。--->最大对齐数肯定不超过默认对齐数
1
2
3
4
5
6
7
8
9
10
struct test1{
char c_1; //char类型只有1字节-->补齐到8字节,即0存储着c_1,另外1、2、3、4、5、6、7作为补齐字节
long long a_1; //占8
};//16字节

struct test{
char c_2; //1字节-->4字节
int a_2; //4字节
struct test1 p; //由于64位默认是8字节对齐,因此8+16=24
};//24字节

测试:

1
2
3
4
cout<<sizeof(test1)<<" "<<sizeof(test)<<endl;
输出:
16
24

1.6 C++的内存模型

  • C++分区:堆和栈(动态数据段)、共享存储区、全局/静态存储区、常量存储区
  • c/c++内存模型生命周期:共享存储区、动态区、静态区

静态数据段:.bss、.data和代码段 .data段也称数据段,又细分为只读数据段和读写数据段 动态数据段:栈和堆(可用limit查看大小) txt:代码段,存放可执行的二进制机器指令

以下注意点:

(a)函数体中定义的变量通常是在栈上;
(b)用malloc, calloc, realloc、new等分配内存的变量和对象的是在堆上;
(c)在所有函数体外定义的是全局量,初始化的存储在.data段内,未初始化的则在.bss段;
(d)加了static修饰符后不管在哪里都存放在全局区(静态区);
(e)在所有函数体外定义的static变量表示在该文件中有效,不能extern到别的文件用;

1.6.1 各中类型的变量在内存中的位置
  • 全局/局部静态变量:而静态变量的存储位置在程序的全局数据段中,也称为BSS段(Block Started by Symbol)或者Data段。如果被初始化过,则存储在.data段,未初始化则存储在.bss段。BSS段是一段特殊的数据段,它存储所有被初始化为0或者未初始化的静态变量,这些为初始化的变量在程序启动时被自动初始化为0。
    • 静态变量只会初始化一次
    • 静态变量的生命周期是程序运行期间都存在,但其作用域范围要依据其是全局和局部才能区分。
  • 全局变量:同全局静态变量一样,存储在data或者bss段内。不同点就是全局变量默认外部可见(即注意不能重定义,外部使用使用加extern),而static不可见(见1.7)。
    1
    2
    3
    4
    //code_1.cpp
    int a=2; //全局变量
    //code_2.cpp
    extern int a; //使用code_1.cpp的a
  • 函数体中定义的局部变量存储在栈区,参数也是在栈中。
  • malloc, calloc, realloc、new等分配内存的变量和对象的是在堆上
  • 全局常量:即const修饰全局变量的则存储在只读数据段
  • 局部常量:其分配在栈区,所以可以通过地址来修改const局部变量。

1.7 static关键字的作用

  • 因为static声明的变量和函数存在程序整个生命周期,因此被static声明的变量和函数,它们的可见性被限制在当前源文件中,这可以提高程序的安全性和可维护性(你也不能加extern关键字)
  • static修饰的变量只能初始化一次
  • static修饰函数存储在代码段
1.7.1 全局静态变量(编译时初始化,存储在bss/data)

在全局变量前加上关键字static,全局变量就定义成一个全局静态变量。存储在静态存储区(未初始化bss或已初始化数据段data),在整个程序运行期间一直存在。

  • 初始化:只能初始化一次。未经初始化的静态变量会被自动初始化为0
  • 作用域:全局静态变量在声明他的文件之外是不可见的,准确地说是他的作用域从定义之处开始,到文件结尾。
1.7.2 局部静态变量

在局部变量之前加上关键字static,局部变量就成为一个局部静态变量。内存中的位置在静态存储区

  • 初始化:只能初始化一次。未经初始化的静态变量会被自动初始化为0。
  • 作用域:作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域结束,但并没有销毁,而是仍然驻留在内存当中,只不过我们不能再对它进行访问,直到该函数再次被调用,并且值不变;
1.7.3 静态函数

在函数返回类型前加static,函数就定义为静态函数。函数的定义和声明在默认情况下都是extern的,但静态函数默认声明仅在当前文件当中可见,不能被其他文件所用。函数的实现使用static修饰,那么这个函数只可在本cpp内使用,不会同其他cpp中的同名函数引起冲突,起到一个函数隐藏的作用;

  • warning:不要在头文件中声明static的全局函数,不要在cpp内声明非static的全局函数,如果你要在多个cpp中复用该函数,就把它的声明提到头文件里去,否则cpp内部声明需加上static修饰;
1.7.4 类的静态成员

在类中,静态成员可以实现多个对象之间的数据共享,并且使用静态数据成员还不会破坏隐藏的原则,即保证了安全性。因此,静态成员是类的所有对象中共享的成员,而不是某个对象的成员。对多个对象来说,静态数据成员只存储一处,供所有对象共用

1.7.5 类的静态函数

静态成员函数和静态数据成员一样,它们都属于类的静态成员,它们都不是对象成员。因此,对静态成员的引用不需要用对象名。

在静态成员函数中没有this指针,因此无法被const修饰。实现中不能直接引用类中声明明的非静态成员,但可以引用类中声明的静态成员(这点非常重要)。如果静态成员函数中要引用非静态成员时,要通过对象来引用。调用静态成员函数可通过object.staticfunc()也可以通过class::staticfunc()调用

1.8 const关键字

const关键字是常量的意思,可以修饰变量、指针和引用,也可以修饰函数,在不同的修饰当中意思会不同,但总的意思是声明为常量,不可以更改:

  • const修饰变量:定义了该变量为常量,只允许初始化,不允许对其进行修改。
  • const修饰指针时,有两种情况:
    • 低层const:指如const int *b或者int const *b,表示可以修改b的值(即指针),但是我们不能修改其指针指向的内容。
    • 顶层const:例如int* const b,表示我们不能修改b的值,但是我们可以修改其指针指向的内容
  • const修饰引用时:表示我们不能通过这个引用别名去修改值
  • const修饰函数时,也有两种情况:
    • const修饰返回值:表示该函数返回的是一个const常量
    • const放在()后面:这种情况常用在类中,表明该成员函数不能改变成员变量的值的。(C++为了在特殊情况下也能改变值,可以将相应的成员变量声明为mutable)
  • 对于类中的常量,只允许通过初始化列表来进行初始化,不允许在函数中进行赋值初始化。
  • const成员函数:const对象(成员函数)不可以调用非const成员函数;非const对象都可以调用;不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值。

注意:运行非const赋值给const,但不允许const赋值给非const,因为如果运行const赋值给非const,那么就很容易规避const的常量不可变机制,导致未知错误

1.8.1 const与#define的区别
  • const修饰的是该变量为常量,只能被初始化,不能被修改;在编译区间会有类型检查。
  • #define只是对后面的表达式起一个文本替换;它发生在预处理阶段,不会做类型检查,当其所替换的式子很长时容易引发一些出人意料的错误。

1.9 delete和new(与c的malloc区别)

newmalloc都是动态分配内存的关键字,它们之间的区别是:

  • malloc是一个库函数,而new是c++的一个关键字
  • malloc按照给定的字节数去分配堆内存;而new是依照数据类类型大小去分配堆内存,因此对于new,如果我们要分配多个堆内存,就要实验数组形式去分配,如new int[20]
  • 因此,malloc返回的是一个原生为构造的堆内存,类型是void*;而new为运算符重载,会调用相应类型的构造函数构造相应的对象,返回的是对象指针。
  • malloc如果分配失败,则会返回一个NULL,没有其他的反应;而new分配失败则会抛出一个bad_alloc异常,指示此次内存分配失败
  • 为避免内存泄露,在使用完后必须去释放这些分配的内存。由malloc分配的内存必须使用free来释放;而new分配的,必须使用delete释放。
  • free只是单纯的释放该空间;delete则会先调用对象的析构函数再去释放内存。
1.9.1 new和delete是如何实现的?
  • new的实现过程是:首先调用名为::operator new全局重载运算符,分配足够大的原始为类型化的内存,以保存指定类型的一个对象;接下来运行该类型的一个构造函数,用指定初始化构造对象;最后返回指向新分配并构造后的的对象的指针
  • delete的实现过程:对指针指向的对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存
1.9.2 既然有了malloc/free,C++中为什么还需要new/delete呢?直接用malloc/free不好吗?
  • malloc/freenew/delete都是用来申请内存和回收内存的。
  • 在对非基本数据类型的对象使用的时候,对象创建的时候还需要执行构造函数,销毁的时候要执行析构函数。而malloc/free是库函数,是已经编译的代码,所以不能把构造函数和析构函数的功能强加给malloc/free,所以new/delete是必不可少的

1.9.2 三种new

  • new operator指的就是new操作,是C++的一个关键字,使用它会经过两个步骤:一是调用::operator new操作符申请内存;二是使用类型的构造函数对内存地址进行构造。new operator操作符不能被重载
    1
    classA* p=new classA(5);
  • operator new操作符是单纯的申请内存,相当于C当中的malloc函数,operator new可以重载。::operator new::operator delete前面加上::表示全局,使用时就像malloc

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //operator new源码
    GLIBCXX_WEAK_DEFINITION void *
    operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
    {
    void *p;
    /* malloc (0) is unpredictable; avoid it. */
    if (sz == 0)
    sz = 1;
    while (__builtin_expect ((p = malloc (sz)) == 0, false))//底层仍然使用malloc
    {
    new_handler handler = std::get_new_handler ();
    if (! handler)
    _GLIBCXX_THROW_OR_ABORT(bad_alloc());
    handler ();
    }

    return p;
    }

  • placement new:`就是在用户指定的内存位置上(这个内存是已经预先分配好的)构建新的对象,因此这个构建过程不需要额外分配内存,只需要调用对象的构造函数在该内存位置上构造对象即可

1
2
3
4
5
template <class T1, class T2>
inline void _construct(T1 * p, const T2& value)
{
new(p) T1(value);
}

1.10 sizeof一个空类大小(即考察类的一些成员占不占类内存,占多少)

  • 空类不是0字节,而是1字节。这是因为空类也是可实例化的(实例化的过程就是内存中分配一块地址),为了区分每一个实例化的对象就必须有它们对应的独一无二的地址。因此编译器会给每一个空类隐式加上一个字节。
  • 类中若有虚函数则存在虚表指针,指针成员在X86-64会占8字节,在32位占4字节(最后一个)
  • 其他成员变量按照大小给定和遵循内存对齐
  • 存在字节对齐(内存对齐)问题:
    虚函数表4字节,4字节对齐!比如**
    --code7-->
1.10.1 对指针和引用sizeof,有什么区别
  • 指针就是数据的地址,对指针做sizeof操作,返回的是指针的大小,在64位系统上,指针大小位8字节,而在32位机器上则是4字节。
  • 引用只是数据对象的一个别名,因此对引用做sizeof就是数据对象的大小

引用的底层实现依然是指针,只不过在使用时没有表现出指针表现,而像是个普通变量 扩展:

1
2
3
double d=11.2;
int a=d(1
int &b=d(2
上面(1)操作合法,隐式的强制转化,向上转换。 上面(2)操作非法。如果合法,这里引用的变量其实是一块临时空间,而临时空间是右值是不能修改的,这种引用的方式本质上其实就是权限的放大,因此编译不能通过 说到隐式强制转化,必须要提到其转换机理;正如下图所示,在发生隐式类型转换的时候,需要将d的值存到一个int类型的临时变量里,然后将这个临时变量的值赋予给a

1.10.2 指针为啥是4字节,一定是4字节吗,对指针做seziof的结果与什么相关
  • 指针是地址,它字节数是一定的不改变的,由不同机器而定的。这样机器才能正确依照这样长度规则的知道正确的地址。
  • 不同的机器指针大小不一样,在64位系统中,指针的长度位8字节,而在32位中为4字节

1.11 c++中指针和引用的区别

共同点:

  • 引用的底层实现机制仍然是指针,因此能够像指针一样,修改变量值会影响到原值。

不同点:

  • 指针是地址,有自己的一块内存,里面的内容是指向所储存的数据的地址;因此对指针做sizeof操作返回的是指针大小4/8字节;对于指针,被const修饰时有两种完全不同的意思,一个是底层const,一个是顶层const
  • 引用只是一个别名,对于做sizeof操作返回的是相应数据的大小;被const修饰,只有一种意思那就是,不能通过该引用变量修改原值,表明这是个引用常量。
  • 指针可以被初始化为NULL,而引用必须被初始化且必须是一个已有对象 的引用
  • 指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变
  • 指针可以有多级指针**p,而引用只有一级;
  • 指针和引用使用++运算符的意义不一样

1.12 c++中的类型转换(四个)

C++除了继承C的风格的type Expression转换外,还提供了四种cast转换:static_cast, dynamic_cast, const_cast, reinterpret_cast

  • static_cast<type>(expression):static_cast一般满足各种类型的转换,但不能将底层const转换为非const。同时该运算符没有进行运行时类型检查来保证转换的安全性。
    • 用于类层次结构中基类和派生类之间指针或引用的转换。进行上行转换(把派生类的指针或引用转换成基类表示)是安全的;进行下行转换(把基类的指针或引用转换为派生类表示),由于没有动态类型检查,所以是不安全的。
    • 用于基本数据类型之间的转换,如把int转换成char。这种转换的安全也要开发人员来保证
    • 把空指针转换成目标类型的空指针
    • 把任何类型的表达式转换为void类型。
      1
      2
      const int a=10;
      int b=static_cast<int>(a);
  • dynamic_cast<type>:用于动态类型转换。该运算符主要用于继承中有虚函数类层次之间的上行和下行转换,或者类之间的交叉转换,只能转指针或引用,并且是同一种表达方式(引用对引用,指针对指针)。类层次间的上下行转换效果和static_cast一样,但是下行转换时,dynamic_cast具有类型检查功能,比static_cast更安全。。向下转化时进行类型检查,如果失败,对于指针返回NULL,对于引用抛异常bad_cast
    • 向下转换(从父类转换为子类指针或引用):dynamic_cast<son*>(father),若father是一个基类指针或引用,但指向的是其子类son指针或引用,此时将其转为son*是安全的;若father是基类指针,但指向的不是子类son指针或引用,而是其自己,则返回NULL。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class A{
      public:
      virtual void func(){}
      };
      class son:public A{
      public:
      virtual void func()override{}
      };
      A* a=new son();
      son* s=dynamic_cast<son*>(a); //会执行动态类型检查

    • 交叉转换(从一个父类转换为另一个父类的指针或引用):在多继承当中的虚继承可以使用。

向上转换:指的是子类向基类的转换 向下转换:指的是基类向子类的转换(有动态类型检查,安全)。它通过判断在执行到该语句的时候变量的运行时类型和要转换的类型是否相同来判断是否能够进行向下转换

  • const_cast:该类型转换为只用来修改类型的const或volatile属性,除了const或volatile修饰外,其他类型要一致。只能对是 引用 或者 指针 的变量添加或移除const
    1
    2
    3
    4
    5
    6
    7
    8
    const int a=11;
    const int* b=&a;
    int* c=const_cast<int*>(b);
    *c=20;
    输出:
    11 00000034CA9AF884
    20 00000034CA9AF884
    20 00000034CA9AF884
    • 为什么a\b\c指向同一个地址,c修改无法影响a呢?:C++primer指出一旦我们去掉了某个对象的const性质,编绎器就不在阻止我们对该对象进行写操作了。如果对象本身不是一个常量,使用强制类型转换获得写权限是合法的行为。然而如果对象是一个常量,再使用const_cast执行写操作就会产生未定义的后果。
    • 因此我们不要这样子做:定义一个常量,再去修改一个常量这是不正确的
  • reinterpret_cast主要有三种强制转换用途:
    • 改变指针或引用的类型、
    • 将指针或引用转换为一个足够长度的整形、
    • 将整型转换为指针或引用类型。

1.13 内存泄露问题,如何检测,解决

内存泄漏(Memory Leak)指的是程序在运行过程中分配的堆内存没有被释放,从而导致系统内存资源的浪费。内存泄漏问题可能会导致程序运行缓慢,甚至崩溃。以下是检测和解决内存泄漏问题的方法:

  • 使用内存检测工具:可以使用一些内存检测工具,如Valgrind、Dr. Memory等,这些工具可以帮助我们检测内存泄漏问题。这些工具会对程序进行内存跟踪,检测到内存泄漏时会给出相应的警告信息,以便开发人员及时解决问题。

  • 代码审查:对程序进行仔细的代码审查,查找可能存在内存泄漏的代码。可以在代码中添加跟踪内存分配和释放的日志信息,以便在程序运行时更容易定位问题。

  • 使用智能指针:C++11引入了智能指针,如std::shared_ptr和std::unique_ptr等,可以自动管理内存。智能指针可以帮助我们在不需要内存时自动释放它,避免出现内存泄漏问题。

  • 及时释放内存:在程序运行过程中,尽可能及时释放不需要的内存,可以通过手动释放、使用RAII(Resource Acquisition Is Initialization)等方式实现。

  • 代码重构:在进行代码重构时,可以考虑使用更高级别的语言特性和数据结构,如使用容器、智能指针等,以便更好地管理内存

1.14 野指针和悬空指针

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

  • 野指针:野指针指的是声明为指针但是进行初始化(未定义或未置空NULL\nullptr)的指针,其指向不确定。
    1
    2
    3
    4
    5
    int main(void) { 
    int* p; // 未初始化
    std::cout<< *p << std::endl; // 未初始化就被使用
    return 0;
    }
  • 悬空指针:指针最初指向的内存已经被释放了的一种指针,并未重新指向新内存或者置空。

    1
    2
    3
    4
    5
    6
    int main(void) { 
    int * p = nullptr;
    int* p2 = new int;
    p = p2;
    delete p2;
    }
    此时 p和p2就是悬空指针,指向的内存已经被释放。继续使用这两个指针,行为不可预料。因此需要设置为p=p2=nullptr。此时再使用,编译器会直接保错。 避免野指针比较简单,但悬空指针比较麻烦。c++引入了智能指针,CPP智能指针的本质就是避免悬空指针的产生。

  • 野指针:

    • 指针指向的内容已经无效了,而指针没有被置空,解引用一个非空的无效指针是一个未被定义的行为,也就是说不一定导致错误,野指针被定位到是哪里出现问题,在哪里指针就失效了,不好查找错误的原因。

    • 规避方法: 1.初始化指针的时候将其置为nullptr,之后对其操作。 2.释放指针的时候将其置为nullptr。

1.14 C++中的智能指针

C++引入智能指针能够有效的解决我们在动态申请内存空间,在结束时忘记释放而造成内存泄漏问题。智能指针是模板类。

在C++中存在三种智能指针类型:shared_ptr、unique_ptr、weak_ptr

  • shared_ptr:允许多个智能指针指向相同的对象,实现共享式拥有。它通过引用计数来表明当前对象被几个智能指针所共享,当计数为0时,对象被销毁。一般不允许管理动态数组,因为shared_ptr的析构函数默认时delete p;如果要分配要必须定义自己的删除器。

  • unique_ptr:任何时刻,都至多只能有一个unique_ptr智能指针指向一个对象,当unique_ptr指针被销毁时,其对象也被销毁。他没像shared_ptrmake_shared函数进行初始化,也不支持赋值和普通拷贝。需要将其绑定到一个new返回的指针,或者也可以通过releasereset函数将指针所有权从一个unique移交给另一个unique_ptr指针。

  • weak_ptrweak_ptr是一种不控制所指向对象生存期的智能指针,它指向一个有shared_ptr管理的对象。将一个weak_ptr绑定到一个shared_ptr不会改变share_ptr的引用计数。weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr

1.14.1 智能指针存在内存泄漏吗

存在!当两个对象相互使用shared_ptr成员变量指向对方时,就会造成循环引用,从而导致内存泄漏

为了解决循环引用导致的内存泄漏,引入了weak_ptr弱指针,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但不指向引用计数的共享内存,但是其可以检测到所管理的对象是否已经被释放,从而避免非法访问。

1.14.2 make_shared函数里面干了哪些事情?

std::make_shared是 C++11 引入的函数模板,用于创建 std::shared_ptr对象并初始化它所管理的对象。在调用 std::make_shared 时,它会完成以下几件事情:

  • 分配内存:std::make_shared会在一块连续的内存上分配所需的内存空间,用于存储对象的数据以及 std::shared_ptr 的控制块。
  • 构造对象:在分配的内存空间中调用对象的构造函数,初始化对象的数据。
  • 创建 std::shared_ptr:将分配的内存空间与一个控制块关联起来,并返回一个std::shared_ptr 对象,该对象包含了指向分配的内存空间的指针以及一个引用计数。

由于 std::make_shared 在一次内存分配中完成了对象的构造和控制块的分配,因此它通常比直接使用 new 来创建std::shared_ptr 更高效,因为减少了额外的内存分配和构造的开销。

1.14.3 什么时候不能用make_shared函数,只能用shared_ptr的构造函数?
  1. 需要控制对象的内存分配方式:std::make_shared 会在一次内存分配中分配对象和控制块的内存,但有时可能需要更精细的控制,比如指定自定义的内存分配器或者在构造对象时需要传递额外的参数。
  2. ** 需要延迟对象的构造**:std::make_shared 会立即构造对象,如果需要延迟对象的构造(比如在构造函数中可能会抛出异常),就不能使用 std::make_shared。

在这些情况下,可以使用 std::shared_ptr 的构造函数来手动分配内存,并在需要时显式地调用对象的构造函数。

1.14.4 实现一下有shared_ptr性质的类

引用计数:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还有创建一个引用计数,用来记录多少对象与正在创建的对象共享数据状态。
  • 拷贝构造函数不分配计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户共享
  • 析构函数递减计数器,指出gong共享状态的用户少了一个。若为0,则释放
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧。若左侧为0,则销毁释放。

  • 如何存放存放计数器?
    • 将计数器保存在动态内存中,当创建一个对象时,我们分配一个计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class HasPtr{
public:
HasPtr(const string& s=string())
:ps(new string(s)),i(0),use(new size_t(1)){} //构造函数,计数器为1
HasPtr(const HasPtr& p)
:ps(p.ps),i(p.i),use(p.use){*use++} //拷贝构造函数,共享ps,use
HasPtr& operator=(const HasPtr &); //赋值运算符
~HasPtr(); //析构函数
private:
string *ps;
int i;
size_t *use; //引用计数器
};

//析构函数定义:
HasPtr::~HasPtr(){
if(--*use==0){
delete ps; //计数为0时,释放
delete use;
}
}

//赋值运算符定义:
HasPtr& HasPtr::operator=(const HasPtr &p){
++*p.use; //递增右侧计数器
if(--*use==0){ //递减左侧计数器,并判断
delele ps;
delete use;
}
ps=p.ps; //共享该堆内数据
i=p.i;
use=p.use; //共享计数器
return *this; //返回本对象
}
1.14.5 shared_ptr和unique_ptr如何自定义删除函数?

在 C++11 及更新的标准中,可以使用自定义的删除器(deleter)来管理 std::shared_ptrstd::unique_ptr 指向的资源。自定义删除器是一个函数对象或者函数指针,用于在释放资源时执行特定的清理操作。

对于 std::shared_ptr,可以通过在创建 std::shared_ptr 时指定删除器来自定义删除函数。例子:

1
2
3
4
5
6
7
8
9
10
11
12
struct MyDeleter {
void operator()(int* p) const {
std::cout << "Deleting int pointer" << std::endl;
delete p;
}
};

int main() {
std::shared_ptr<int> sp(new int(42), MyDeleter());
// 使用 sp
return 0;
}

1.15 指针数组和数组指针

  • 指针数组:它是数组,不过内部存储的值是指针,即地址。
  • 数组指针:数组指针可以说成是”数组的指针”,首先这个变量是一个指针,其次,”数组”修饰这个指针,意思是说这个指针存放着一个数组的首地址,或者说这个指针指向一个数组的首地址
    1
    2
    int* ptr[10]; //指针数组
    int (*ptr)[10]; //数组指针

1.16 一个函数它前面的const和在()后面const的区别

  • const修饰函数时,也有两种情况:
    • const修饰返回值:表示该函数返回的是一个const常量
    • const放在()后面:这种情况常用在类中,表明该成员函数不能改变成员变量的值的。(C++为了在特殊情况下也能改变值,可以将相应的成员变量声明为mutable)(更多关于const成员函数的详解见面向对象部分)

1.17 C++的lambda表达式

lambda表达式是C++1 引入的一个“语法糖”,可以方便快捷地创建一个“函数对象”。多用于在函数体内直接嵌套生成一个子函数,可以方便函数体内后续的调用。一个lambda表达式具有返回类型、参数列表、捕获列表和函数体组成。其形式如下:

1
[capture list](argumrnt list)->retyrn type{function body;};
其中参数列表和返回类型可以忽略,但必须包含捕获列表和函数体;lambda表达式的提出,解决了:

  • 可在函数体内嵌套生成一个子函数,该lambda有函数的性质,可以多次调用使用。(可调用对象)
  • 解决一些STL算法当中对谓词的限制,STL当中较多的算法对谓词的参数有限制,如_if结尾的只能接收一元谓词,这时候使用lambda表达式通过捕获列表来活动多个参数。
    1
    auto w=find_if(vec.begin(),vec.end(),[sz](const string&a,){return a.size()>=sz;});

对于lambda表达式,最主要的是它的捕获列表,参数的捕获分为引用捕获&和值捕获=,这里主要谈论有隐式捕获时的规则:

  • [&]:隐式的引用捕获,即lambda中所使用来自函数的实体都采用引用捕获
  • [=]:隐式值捕获,即lambda中所使用来自函数的实体都采用值捕获
  • [&,identifier_list]:未出现在identifier_list的使用引用捕获,出现的则使用值捕获(注意identifier_list的显示捕获参数不能为引用捕获,只能为值捕获)
  • [=,identifier_list]:未出现在identifier_list的使用值捕获,出现的则使用引用捕获(注意identifier_list的显示捕获参数不能为值捕获,只能为引用捕获)
    1
    2
    3
    4
    5
    6
    7
    int a = 2;
    int b = 3;
    auto c = [&]() {a += 2; return a > b; };
    if (c())
    cout << "引用捕获,a=" <<a<< endl;
    //输出
    引用捕获,a=4

1.18 volatile

volatileconst一样是可用于修饰变量的关键字,它是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改。 BS 在 "The C++ Programming Language" 对 volatile 修饰词的说明是这样子的:

A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.

  • 它应对的场景是这样的:遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化。这是因为volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址即内存中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,就极有可能暂时使用寄存器中的值,此时这个变量由别的线程更新了的话,将出现不一致的现象,线程不安全。

  • 示例:int volatile vInt; 当要求使用volatile声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。

  • 有意思的示例:const volatile int cvInt;。首先const修饰cvInt说明它是一个常量,不会改变;而volatile告诉编译器这个变量极有可能改变,你不要做过于激进的优化,要在内存中进存取。我们第一眼可能感觉到这是矛盾的,但其实不然。
    • const表示在本程序段不能对cvInt进行修改,任何修改都是非法的,编译器应该要报错,防止这种错误。
    • volatile则是说这个变量完全有可能被另一个线程修改(比如说另一个线程使用汇编去修改),告诉编译器不要做太过激进的优化。
  • 不能保证线程安全volatile对于非原子操作来说,无法保证线程安全,而对于原子操作,可以保证线程安全的,因此对于原子操作可使用volatile来提升并发效率,不使用锁带来的巨大开销

1.19 auto关键字

auto关键字是C++11的一个新特性,auto一般与左值进行联合使用,它通过右值的表达式来推断左值类型,定义左值变量。即以往我们需要在运行前通过显示的声明左值类型,现在可将类型的声明推迟到运行中,并自动推断。

1.20 extern "C"

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

哪些情况下使用extern "C"

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
#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);
}
综上,总结出使用方法,在C语言的头文件中,对其外部函数只能指定为extern类型,C语言中不支持extern "C"声明,在.c文件中包含了extern "C"时会出现编译语法错误。所以使用extern "C"全部都放在于cpp程序相关文件或其头文件中。

  • C++调用C函数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    //xx.h
    extern int add(...)
    //xx.c
    int add(){
    }
    //xx.cpp
    extern "C" {
    #include "xx.h"
    }
  • C调用C++函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    //xx.h
    extern "C"{
    int add();
    }
    //xx.cpp
    int add(){
    }
    //xx.c
    extern int add();

1.21 变量声明和定义的区别

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

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

1.22 strlen和sizeof区别?

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

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

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

    1
    2
    3
    4
    5
    6
    int main(int argc, char const *argv[]){
    const char* str = "name";
    sizeof(str); // 取的是指针str的长度,是8
    strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
    return 0;
    }

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

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

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

main函数执行之后:

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

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

  • 大端:指在低位地址存储数据的高字节。大端也称网络字节序,在网络传输中总是采用大端法来传输的,因此对于小端主机接收到的网络数据,必须完成大小端的转换。
  • 小端:指在低位(地址)存储数据的底字节。

那么如何识别本机的是采用哪种方法存储数据呢,可使用一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void bigOrSmall(char * c)
{
printf("%d\n",*c);
}
int main(int argc,char* argv[])
{
int a=10;
char* c=(char*)&a;
for(int i=0;i<4;++i)
{
bigOrSmall(c);
c+=1;
}
}
输出
1
10  0  0  0
很明显可知本机是采用小端法

1.24 智能指针说下?

C++引入智能指针能够有效的解决我们在动态申请内存空间,在结束时忘记释放而造成内存泄漏问题。智能指针是模板类。

在C++中存在三种智能指针类型:shared_ptr、unique_ptr、weak_ptr

  • shared_ptr:允许多个智能指针指向相同的对象,实现共享式拥有。它通过引用计数来表明当前对象被几个智能指针所共享,当计数为0时,对象被销毁。一般不允许管理动态数组,因为shared_ptr的析构函数默认时delete p;如果要分配要必须定义自己的删除器。

  • unique_ptr:任何时刻,都至多只能有一个unique_ptr智能指针指向一个对象,当unique_ptr指针被销毁时,其对象也被销毁。他没像shared_ptrmake_shared函数进行初始化,也不支持赋值和普通拷贝。需要将其绑定到一个new返回的指针,或者也可以通过releasereset函数将指针所有权从一个unique移交给另一个unique_ptr指针。

  • weak_ptrweak_ptr是一种不控制所指向对象生存期的智能指针,它指向一个有shared_ptr管理的对象。将一个weak_ptr绑定到一个shared_ptr不会改变share_ptr的引用计数。weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr

1.25 引用存不存在空悬引用?

C++ 中,引用不存在空悬引用的问题,因为引用在定义时必须初始化,并且一旦引用与某个对象绑定后,它将一直引用该对象,无法重新绑定到其他对象。因此,正常情况下,C++ 中的引用不会出现空悬引用的情况

1.25.1 引用底层实现是怎么实现的?

C++ 中,引用的底层实现通常是通过指针来实现的,但是在语法和使用上有所不同。编译器在编译时会对引用进行优化,使得它在使用上更类似于直接访问对象而不是通过指针。

具体地说,引用在内部通常会被实现为对其所引用对象的一个指针。这个指针在引用初始化时就会被指向引用的对象,并且在整个引用的生命周期内不会改变指向其他对象。由于引用在语法上更类似于对象本身,因此可以在代码中使用引用来代替指针,使得代码更加简洁和易读

需要注意的是,由于引用在底层是通过指针实现的,因此在某些情况下,引用可能会受到指针的限制,比如无法指向空值(nullptr),也无法实现指针的一些特性,比如指针的算术运算

1.25.2既然引用底层是指针,那么引用中的指针会不会存在空悬的情况?

C++ 中,引用的底层实现通常是通过指针来实现的,但是引用在语义上和指针是有很大区别的。在语义上,引用代表了一个已经存在的对象的别名,它在定义时必须初始化,并且在其生命周期内一直指向同一个对象,无法改变指向其他对象。由于引用的这些已经人为规范好的特性,它不会出现空悬的情况。

空悬指针是指指针在指向的对象被销毁后仍然存在,指针的值未被清空,这时如果再去访问指针所指向的对象就会产生未定义行为。而引用在定义时必须初始化,并且在整个生命周期内都指向同一个对象,因此不存在空悬引用的情况。

1.25.3 空悬指针是在编译阶段出现的还是运行阶段出现的?

空悬指针通常是在运行阶段(即程序执行时)出现的。空悬指针是指指针在运行时指向了一个已经被释放或者超出了作用域的内存地址,导致在访问该地址时产生未定义行为。这种情况通常是由于程序员错误地使用了已经被释放或者超出了作用域的指针,或者在指针被释放后没有将其设置为 nullptr 等无效值导致的。

空悬指针的出现通常是由于程序中的逻辑错误或者使用了未初始化的指针等问题引起的,它可能导致程序崩溃、内存泄漏或者其他未定义行为。因此,在编写 C++ 程序时,需要特别注意对指针的管理,确保指针的有效性以避免空悬指针的出现。

1.26 你说到右值引用,那你说下左值右值的区别

C++的值存在左值和右值之分,我觉得比较好的一种定义是这样定义的:

  • 左值:左值是指针对于在内存单元,可以取地址。左值可以位于赋值语句的左侧,右值不能
  • 右值:右值更多是一种“值”的表达,不可取地址。

像返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式。右值要么是字面常量,要么是在常量表达式求解过程中创建的临时对象。

因此,在C++中,将左右值的概念做一个简单归纳:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。左值持久,右值短暂

一些特殊的左值:

1.字符串字面量,如:"Hello" 2.内置的前++与前--,如:++a 3.变量类型是右值引用的表达式,如:TestClassA&& ra = TestClassA(1000);,ra这里是左值,这个特别重要,后面涉及万能引用与完美转发 4.转型为左值引用的表达式,如:static_cast<double&>(fValue); 5.内置*解引用的表达式,如:*pkValue

1.27 好,那为什么C要有左值右值?C的开发者为什么当初要设定左值右值的是什么要解决什么问题

我觉得对于在C当中的左值和右值主要是对值的区分,左值是针对于内存单元,可以取地址。右值更多是一种“值”的表达,不可取地址。

而到了C++11以后,左值和右值有了更好的作用那就是引用,通过左值和右值,有了左值引用和右值引用,它们通过利用应用特性来避免构造,提供程序效率。同时右值引用充分利用右值(特别是临时对象)的构造来减少对象构造和析构操作以达到提高效率的目的,我们可以通过右值引用进行移动语义和完美转发。

1.28 左值引用和右值引用

引用本质是取别名,C++引入引用这一特性是为了传参的时候可以避免拷贝,其实引用的实现原理和指针类似(大家可以做个实验:同一个函数,分别用传指针和传引用实现,编译出来的汇编代码是完全一致的,在这里不多讲,不是重点)

c++有左值引用和右值引用:

  • 左值引用:指向左值的引用就称为左值引用,左值引用不能指向右值,但常量左值引用可以指向右值
  • 右值引用:指向右值的引用就称为右值引用,不能指向左值

注意:正常情况下左右引用只能处理对应的左右值,不能随意配对,否则编译失败。c++提供了能对右值进行左引用的方法,那就是const T&:常量左值引用const T&能接受右值,对右值进行这种形式左引用写法也不少,其生命周期被延续。

右值引用和左值引用本身都是左值,不是右值和左值,而是它们指向的是右值和左值。其中引入右引用概念的目的是为了充分利用右值(特别是临时对象)的构造来减少对象构造和析构操作以达到提高效率的目的。

1.29 你说unique_ptr有移动语义,那你说下move是什么吗?

移动语义的意思是利用移动拷贝构造函数,将对象的资源转移给其他对象管理,自己不在持有这些资源的管理权。通俗理解就是一些带资源的对象需要拷贝时,即想要有浅拷贝的效率,还想要深拷贝析构时安全的效果

unique_ptr是智能指针的一种,它规定任何时刻都至多只能有一个unique_ptr智能指针指向一个对象,因此unique_ptr不能够拷贝,但是能够移动。

那么为能够实现移动语义,c++提供了move函数,move函数将一个左值转换成右值,这样通过move转换后,一个右值引用就能够与左值进行交互

1.30 你说move是将左值转换成右值为什么要有move?move最根本解决什么问题

其实上面已经讲了,我们的对象都是左值,c++引入move函数的目的就是能够将对象左值转化程右值,那么这样移动拷贝构造函数就能正确的做到对象的资源转移操作,即通过移动拷贝构造函数既有了浅拷贝的效率,又有深拷贝析构时安全的效果。

1.31 完美转发

完美转发是在写一个接受任意实参的函数模板时会涉及到的话题。完美转发是指一个函数模板接受任意实参后在并内部转发到其它函数,那么目标函数会收到与转发函数完全相同的实参,转发函数实参是左值那目标函数实参也是左值,转发函数实参是右值那目标函数实参也是右值。

这里就引入了另一个模板函数:std::forward<>,他与move函数的区别如下:

  • std::forword<>:是一个模板函数,他支持左值先右值转换,也支持右值向左值转化
  • std::move():是一个函数,只支持左值向右值转化。

完美转发示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void func(int& a){
cout<<"lvalue"<<endl;
}

void func(int&& a){
cout<<"rvalue"<<endl;
}

template<typename T>
void test(T&& obj){
func(std::forward<T>(obj));
}

//输出
rvalue
lvalue

注意:上面的完美转发涉及到了C++的引用折叠

  • 即当T为int&时,int& &&折叠成int&
  • 同理T为int&&时,int&& &&折叠为int&&
  • 同理T为int时,就为int&&

1.32 C++中新增了string,它与C语言中的 char *有什么区别吗?它是如何实现的? string继承自basic_string,其实是对char*进行了封装,封装的string包含了char*数组,容量,长度等等属性。

string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2*n),然后将原字符串拷贝过去,并加上新增的内容。

1.33 指针加减计算要注意什么?

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

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

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

1.35 指针和引用作为函数参数传递时的区别

  • 指针作为函数参数传递时
    • 1、类似于值传递,传入函数的指针只是原指针的一个拷贝,所以此时是存在两个指针,同时指向一个内存空间(同时指向原对象)
    • 2、当在函数中不改变拷贝指针的指向时,修改指针的值,就相当于修改原指针指向的对象
    • 3、当在函数中改变拷贝指针的指向时,只是改变了拷贝指针的指向,不改变原指针的指向,所以不改变原指针指向的对象。
  • 引用作为函数参数传递时:
    • 实质上传递的是实参本身,即传递进来的不是实参的一个拷贝,因此对形参的修改其实是对实参的修改,所以在用引用进行参数传递时,不仅节约时间,而且可以节约空间

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

  • 前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建

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

  • ① 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;

  • ② 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;

只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有

1.37 函数指针?

  • 1)什么是函数指针?:通俗来讲就是执行函数的指针

函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分

一个具体函数的名字,如果后面不跟调用符号(即括号),则该名字就是该函数的指针(注意:大部分情况下,可以这么认为,但这种说法并不很严格)。

  • 2)函数指针的声明方法int (*pf)(const int&, const int&);

上面的pf就是一个函数指针,指向所有返回类型为int,并带有两个const int&参数的函数。

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

  • 3) 为什么有函数指针

函数与数据项相似,函数也有地址。我们希望在同一个函数中通过使用相同的形参在不同的时间使用产生不同的效果。最常见的就是回调函数。

  • 4) 一个函数名就是一个指针,它指向函数的代码。

一个函数地址是该函数的进入点,也就是调用函数的地址。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变元传递给其他函数;

1.38 为什么有了函数指针,还要引入function?

首先,函数指针和function仿函数都是用于处理函数的工具,但是,function具有更好的实用性:

  • 第一就是函数指针只能指向特定的函数签名,即它们只能指向具有相同参数和返回类型的函数。而function可以包装任何可调用对象,包括函数指针、函数对象、Lambda表达式等
  • 第二就是如果使用函数指针,必须确保它与实际函数的签名完全匹配。否则,可能会导致未定义的行为或编译错误。function: 提供了类型擦除的功能,使得可以在运行时处理不同的函数类型,同时提供了类型安全性。

1.39 bind函数

  • bind函数的作用是通过绑定一个其他func函数生成一个依赖于func的新的函数对象,复用func函数的实现,
  • 此外还可以通过设置值和占位符(std::placeholders::_1,std::placeholders::_2)来改变这个func的参数数量和顺序,bind可以绑定普通函数、全局函数,静态函数,成员函数、仿函数、lambada表达式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void func(int a,int b){
cout<<a-b<<endl;
}


int main(int argc,char** argv){
function<void(int,int)> f = func;
auto e = bind(f,placeholders::_1,3);
e(2); //-1
auto q = [&](int a,int b){cout<<a-b<<endl;};
auto r =bind(q,placeholders::_2,placeholders::_1);
r(10,5); //-5
return 0;
}

tips:占位符的使用只有一个限制条件,placeholders::_x的使用时已经使用了placeholders::_x-1时才能用(x>1)

1.40 C++和C语言的区别

  • C++是面向对象的编程语言,C是面向过程的编程语言
  • C++中除了C中有的变量、指针外,还增加了引用。
  • C++支持函数的重载,而C不支持
  • C++用new、delete代替了C中的malloc和free
  • C++支持泛型编程,也即是模板类和模板函数,同时引入了许多STL标准模板库,如vector、deque、map等
  • C++增加许多新的关键字和特性,如class、virtual、friend、bool
  • C++还引入了命名空间的机制,用于解决命名冲突的问题。C 语言中没有命名空间的概念。

1.41 explicit关键字

先验知识: 对于单个参数的构造函数如果没有explicit修饰是允许隐式调用和隐式类型转换,

1
2
3
4
Salse_data(const std::string& s):BookNo(s){};//这个构造函数。支持了转换构造
Sales_data item; //执行了默认构造函数
string null_book="9-999-999";
item.combine(null_book);//在这里combine函数的参数时Sales_data类型,因此这里用单参转换构造函数构造了一零时Sales_data对象,即null_book隐式转换为Sales_data类型
在这里combine函数的参数实际是Sales_data类型,因此这里调用单参转换构造函数Salse_data(const std::string& s):BookNo(s){};构造了一零时Sales_data对象,即null_book隐式转换为Sales_data类型,然后在执行该语句。 注意:形参必须声明为const

若explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显式的方式进行类型转换,注意以下几点:

  • explicit 关键字只能用于类内部的构造函数声明上
  • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换,因此只能直接初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A{
public:
// 显式构造函数
explicit MyClass(int value) {
this->value = value;
}
int value;
}

int main() {
// MyClass obj = 42; // 错误,不能进行隐式转换
MyClass obj = MyClass(42); // 正确,显式调用构造函数

return 0;
}

1.42 C++中新增了string,它与C语言中的 char 有什么区别吗?它是如何实现的? string继承自basic_string,其实是对char进行了封装,封装的string包含了char*数组,容量,长度等等属性。

string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2*n),然后将原字符串拷贝过去,并加上新增的内容。

1.43 什么是内存泄漏,可以用什么方法检测

内存泄漏一般是值我们从堆山上分配一段内存区域给程序使用,但是程序使用后未进行释放,使得该区域的内存无法被重新使用,此时就说是这片内存发生了内存泄漏了;

可以使用valgrind工具进行检测

1
valgrind --leak-check=yes --show-leak-kinds=all ./yourpragram >out.txt

1.43 为什么模板类都放在一个.h文件中

模板在 C++ 中是一种泛型编程的重要工具,它允许你编写通用代码,而不需要针对每种类型都编写不同的实现。由于模板的特殊性,编译器需要在使用模板时进行实例化,也就是生成特定类型的代码。因此,模板的定义通常需要在编译时可见,以便进行实例化。

  • 模板的特殊性:模板不同于普通的函数和类,它的实现往往需要放在头文件中。这是因为模板编译时进行实例化,而不是链接时。如果模板的定义在源文件中,编译器在链接时可能找不到实例化的定义,从而导致链接错误。
  • 可读性和维护性:将模板的声明和定义放在同一个头文件中可以提高代码的可读性和维护性。程序员只需要查看一个文件,就可以理解模板的接口和实现细节。

2. C++知识面试版_面向对象

2.1 C++中的面向对象是什么

  • 面向对象是什么:C++中的面向对象是指不再像C一样以面向结构和流程式编程,而是面向对象来编程。CPP中的对象是由于引入了类这一个概念而产生的,类指的是一类事物,在类的内部集成封装了许多了与该类有关各样的函数和成员变量。为了能够去调用这个类中的函数和变量,必须去示例化这个类,而实例化的结果就是产生了这个类的对象,然后我们可以通过这个对象去调用这个类所集成的函数和变量了,通过这个面向对象的编程方式提升了代码的可读性、可维护性和可重用性。

  • 由上面的类和面向对象得到了其熟知的三大特征,封装、继承和多态
    • 封装性:上面的类中集成了许多函数实现和成员变量,对于有一些数据和函数我们允许外部能够调用,但对于有一些数据和函数,我们为了保证数据的安全性,我们希望它们是不可被外部直接访问(除非提供访问接口),因此有了关键字:privateprotectedpublic,它们指示了相关数据函数的是否可访问。
    • 继承性:继承为了解决在拥有多个类的情况下,一些类和类呈现了父子般关系的场景,比如水果类草莓类,很明显草莓类水果类的一个子类,继承好处是:
      • 那么对于水果当中一些公共方法同样能够适用于草莓,这样通过继承能够实现代码复用,避免了代码的冗余;
      • 同样,草莓类能够基于水果类进行自己的扩展,能够添加新的函数,也能够重写已有的函数。
      • 继承是多态特性的前提之一。
    • 多态性:多态的实现主要分为静态多态动态多态静态多态主要是重载和模板,在编译的时候就已经确定;动态多态是用虚函数机制和继承实现的,在运行期间动态绑定。举个例子:一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。因此面向对象的多态特征是指类型在之前是不能确定,只有运行到它的时候才能确定实际的类型。
2.1.1 静态联编和动态联编(静态多态和动态多态)
  • 静态联编:编译器会根据函数调用的对象类型,在编译阶段就确定函数的调用地址,这就是静态联编(早绑定),实现方式主要有函数的重载和模板
  • 动态联编:在普通成员函数前面加virtual,该函数变为虚函数,是告诉编译器这个函数要晚绑定,在运行阶段才确定调用哪个函数(晚绑定),动态多态的实现方式主要是通过继承和虚函数实现的。

模板是一种静态多态的机制,它通过在编译时生成不同类型的代码来实现多态性。在使用模板时,编译器会根据模板参数的具体类型生成对应的代码,因此模板实现了静态多态。

而虚函数机制则是 C++ 中实现动态多态的方式,通过基类的指针或引用调用虚函数时,会根据对象的实际类型来确定调用的函数实现。这种动态的函数调用是在运行时确定的,因此实现了动态多态。

tips:

  • 1.子类转换成父类(向上转换):编译器认为指针的寻址范围缩小了,所以是安全的

  • 2.父类转换成子类(向下转换):编译器认为指针的寻址范围扩大了,不安全

2.2 struct和class的区别、struct和union的区别

相同点

  • 两者都拥有成员函数、公有和私有部分
  • 任何可以使用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++中被当作类的一种特例

2.2.1 struct和union的区别
  • 1.在存储多个成员信息时,编译器会自动给struct第个成员分配存储空间,struct 可以存储多个成员信息,而Union每个成员会用同一个存储空间,只能存储最后一个成员的信息。

  • 2.都是由多个不同的数据类型成员组成,但在任何同一时刻,Union只存放了一个被先选中的成员,而结构体的所有成员都存在。

  • 3.对于Union的不同成员赋值,将会对其他成员重写,原来成员的值就不存在了,而对于struct 的不同成员赋值 是互不影响的。

  • 4.sizeof一个结构体是其所有成员依据内存对齐后的结果;sizeof一个联合体是其最大成员的大小

2.3 C++中为什么要有this指针,哪些地方用到

this指针,它指的是对于类的内部成员函数,其参数列表的第一若不显式写出总是隐藏有一个this指针:

  • 成员函数this指针:成员函数通过一个名为this的隐式参数来访问调用它的那个对象this指针是一个指向当前对象的指针,或者说当前对象的地址。
  • this指针只能在一个类的非静态成员函数中使用(全局函数、静态函数不能使用)
  • C++的非静态成员函数的第一个默认并且被隐藏的参数是T *const register this。比如我们在Student这个类里声明这样一个函数:int SetName(const char *name);其实编译器处理的时候会变为 int SetName(Student *const register this, const char *name);

2.4 为什么要继承,继承会有什么问题,如何解决?

在面向对象的编程语言中,继承是极其重要的一个特性,这是因为通过继承:

  • 一是能够通过继承提高代码的复用性。在继承的基础上,子类能够复用父类的代码,并且能够在父类的代码基础上进行重写和功能扩展
  • 二是继承是实现多态的必要条件之一,没有继承,那么多态的就无法实现,通过继承我们才能实现基类指针指向子类对象,在运行阶段才确定类型。
2.4.1 菱形继承问题

继承当中存在菱形继承问题,所谓的菱形继承是指,比如我有基类A,子类BC都继承于A,并在A的基础上实现一些自己的功能扩展,现在又有一个子类D,子类D为了能够得到B和C当中独有的方法,它要进行多继承,即继承BC.如下图所示:

那么这种继承就带来一个问题,由于BC分别都继承于A,那么D就会重复继承于A,那么D会有来自A的两份拷贝。这不仅会占用不必要的存储空间,也会造成命名冲突。

  • 虚继承:继承方式前面加上 virtual关键字就是虚继承。虚继承用于解决多继承条件下的菱形继承问题(浪费存储空间、存在二义性)。 虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的A 就是一个虚基类

在C++中就存在虚继承关系的类:

  • 原理:虚继承底层实现原理与编译器相关,一般通过虚基类表指针vbptr和虚基类表实现,每个虚继承的子类都有一个虚基类表指针(占用一个指针的存储空间)和虚基类表(虚基类表在只读数据端内所以不占用类对象的存储空间,)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类表指针也会被继承,其指向虚基类表。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    /** 
    虚继承(虚基类)
    */

    #include <iostream>

    // 基类A
    class A
    {
    public:
    int dataA;
    };

    class B : virtual public A
    {
    public:
    int dataB;
    };

    class C : virtual public A
    {
    public:
    int dataC;
    };

    class D : public B, public C
    {
    public:
    int dataD;
    };

下面展示了无虚继承和有虚继承时的情况类D的内存消耗:(32位系统指针大小为4)24字节,注意有两个虚基类表指针。当A的数据多时,能够节省内存空间

虚函数表指针、虚函数表和虚基类表指针、虚基类表是两码事,不要搞混,但它们的实现原理是相似的。 因此,对拥有虚函数表指针和虚基类表指针的类其会多消耗两个指针空间的大小

2.5 什么是纯虚函数,作用

纯虚函数是指在类中用virtual修饰,并用=0做声明的函数,即形式像virtual coid func()=0;的函数,这种虚函数就是纯虚函数,此时函数所在的内存存储值为0x0,没有实际意义,因此不允许对该类实例化。

纯虚函数指示该函数并未定义,那么就指定该类不能被实例化,话句话说就是有纯虚函数的的类就是抽象基类,抽象基类不能实例化对象,必须通过继承重所有的纯虚函数后才能实例化对象。因此由纯虚函数构成的类只起到声明作用,告诉编程人员后续的继承工作要实现这些纯虚接口。因此纯虚函数更像是提供了一个模板规范,需要编程者自己实现逻辑功能

2.6 编译器什么情况下会合成构造函数,你知道的都说一说

  • 1.如果一个类没有任何构造函数,但是含有一个类类型的成员变量,该类型含有构造函数,么编译器就为该类合成一个默认构造函数,
  • 2.没有任何构造函数的类继承自一个带有默认构造函数的基类,那么需要为该派生类合成一个构造函数,只有这样基类的构造函数才能被调用;
  • 3.带有虚函数的类,虚函数的引入需要进入虚表,指向虚表的指针,该指针是在构造函数中初始化的,所以没有构造函数的话该指针无法被初始化;
  • 4.带有一个虚基类的类

2.7 那什么时候合成拷贝构造函数呢

  • 1.如果一个类没有拷贝构造函数,但是含有一个类类型的成员变量,该类型含有拷贝构造函数,那么也会合成默认的拷贝构造函数
  • 2.如果一个类没有拷贝构造函数,但是该类继承自含有拷贝构造函数的基类,此时编译器会为该类合成一个拷贝构造函数;
  • 3.如果一个类没有拷贝构造函数,但是该类声明或继承了虚函数,此时编译器会为该类合成一个拷贝构造函数;
  • 4.如果一个类没有拷贝构造函数,但是该类含有虚基类,此时编译器会为该类合成一个拷贝构造函数

2.7 C++中会合成的函数有哪些

在CPP类中存在一些合成的默认行为:

  • 构造函数:如果没有显示的定义任何构造函数,但是含有一个类类型的成员变量,该类型含有构造函数,那么就会合成默认的构造函数,该合成默认构造函数每有参数。
  • 拷贝构造函数:如果没有实现拷贝构造函数,但是含有一个类类型的成员变量,该类型含有拷贝构造函数,那么也会合成默认的拷贝构造函数
  • 拷贝赋值运算符:与拷贝构造函数一样,在没有定义自己的拷贝运算符时,也欸有删除,编译器会生成一个合成拷贝赋值运算符。
  • 析构函数:如果没有显示定义析构函数,编译器会为它定义一个合成析构函数。

在上面中是类中的一些默认合成的规则,但有时候也会存在合成拷贝赋值运算符会禁止该类型对象的赋值,这就要提到CPP中的三五发法则,三是指拷贝构造、拷贝赋值和析构,五是在三的基础上还有移动构造函数和移动赋值函数

一般来说,规范的编程中,在CPP的类中不是满足三就是满足五:

  • 当析构没有时,无法释放,那么其他的构造函数必定都是删除的,即禁止该对象进行拷贝、移动操作
  • 当有移动时,规范的类必是处于五法则,即5个构造函数都实现,能够拷贝、移动也能释放
  • 当有拷贝是,不是处于三就是处于五法则

2.7 深拷贝、浅拷贝和移动语义

  • 浅拷贝:浅拷贝是指在拷贝的过程中只是做简单的赋值操作,并未开辟新的内存空间。新变量和旧变量都指向的是一个内存区域,这样就会带来一个问题,那就是当多个纤拷贝构造函数,析构时会多次释放同一个分配的内存空间,这是不被编译器所运行的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Student{
    public:
    Student(const char* name,int age){
    pName=(char*)malloc(strlen(name)+1);
    strcpy(pName,name);
    this.age=age;
    }
    ~Student(){
    if(name!=NULL)
    {
    free(pName);
    pName=NULL;
    }
    }
    private:
    char* name;
    int age;
    };
    //执行
    Student s1("小明"22)
    Student s2(s1); //执行默认拷贝构造函数

  • 深拷贝:解决浅拷贝带来的问题就是使用深拷贝,在拷贝过程中申请自己的内存空间,把值存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //深拷贝
    Student(const Student &stu)
    {
    cout << "自己的拷贝构造函数" << endl;
    //1.申请空间
    pName = (char*)malloc(strlen(stu.pName) + 1);
    //2.拷贝数据
    strcpy(pName, stu.pName);
    age = stu.age;
    }

  • 另一种方法就是:移动语义,当一个对象作为拷贝后就需要释放时,就可以使用移动拷贝构造函数,使得既有浅拷贝的效率,又具有深拷贝的资源析构安全。

2.8 封装特征,封装整体的目的是

  • 封装:每个对象够包含它能进行操作所需的所有信息,这个特性称为封装,因此对象不必依赖其他对象来完成自己的操作。在CPP中涉及封装的三个关键字有pibilc\private\protected

  • 封装的目的:
    • (1)封装是对象的一种隐藏技术,其目的是将对象中的属性和方法组织起来。同时隐藏不想暴露的属性和方法及实现细节。
    • (2)用户或其它对象不能看到也无法修改其实现。只能通过接口去调用对象的方法,达到互相通信的目的。
    • (3)封装的目的在于将设计者与使用者分开。使用者不必知道实现的细节,只需要设计者提供的方法来访问对象。
  • 封装的好处 :
    • (1)良好的封装能减少耦合。
    • (2)类内部的实现可以自由地修改。
    • (3)类具有清晰的对外接口。
2.8.1 继承当中的pibilc\private\protected限定词

派生类需要使用类派生列表来指出它是从哪个基类继承而来的,每个基类前面可以有三种访问说明符public、private、protected中的一个。它们不会对基类造成影响,这是限定该继承的子类:

  • pubulic:子类的封装程度同基类一样
  • protected:如果基类当中存在public修饰的变量或函数,那么在子类当中会成为protectedprotected\private不会改变
  • private:如果基类当中存在public\proteced修饰的变量或函数,那么在子类当中会成为privateprivate不会改变

即继承的三个访问说明符是限制子类的变量和函数,它们的可访问程度是依次递减调整的。

2.9 析构函数调用时机

  • 对象生命周期结束被销毁时
  • delete指向对象的指针时,或者delete指向对象的基类类型的指针,而基类析构函数是虚函数
  • 对象A是对象B的成员,B的析构函数被调用时,对象A的析构函数也会被调用

什么时候调用析构函数:

  • 无论何时一个对象被销毁,就会自动调用其析构函数
  • 变量离开作用域时被销毁
  • 当一个对象被销毁时,其成员被销毁
  • 容器被销毁时,其元素被销毁
  • 对于动态分配对象,当指向它的指针应用delete运算符时被销毁
  • 对于临时对象,当创建它的完整表达式结束时被销毁

2.10 构造函数和析构函数再有子类情况下,子类的构造和析构顺序

  • 构造函数:先执行父类的构造,再执行子类的构造
  • 析构函数:先执行子类析构,再执行父类析构

2.10 c++的多态机制实现原理(虚函数和虚表)

谈到多态,多态的实现主要分为静态多态和动态多态静态多态主要是重载和模板,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定。

  • 多态:动态多态是面向对象的一大特性,CPP中多态指的是指针所指向的的类型在编译时候是无法确定的,只有在运行的时候才能确定,执行的是动态绑定的机制,即同一个操作作用于不同的对象,可以有不同的解释,会产生不同的效果,这就是多态。多态的应用场景,举个例子就是一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。

  • 实现原理:多态的实现原理依赖于继承虚函数。 在有虚函数的类中,类的最开始部分都会维护一个虚函数表指针vptr,这个指针指向一个虚函数表(该虚函数表存储在只读数据端中),表中放了虚函数的地址(虚函数指针),实际的虚函数在代码段.text中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率。
  • 作用:
    • 可以解决项目中的紧耦合问题,提供程序的可扩展性
    • 应用程序不必为每一个子类的功能调用编写代码,只需要对抽象的父类进行处理,这样能够更好的实现接口重用
  • 条件
    • 有继承
    • 重写父类的虚函数
    • 父类指针或引用指向子类对象

2.11 重载、重写和多态区别,实现机制。

  • 重载:是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同。也不能仅仅依靠返回值不同来区分的函数,说到底就是函数名一样,参数个数或类型不同就是重载,返回值没有要求,可相同也可不同。重载和函数成员是否是虚函数无关。举个例子:
    1
    2
    3
    4
    5
    6
    7
    8
    class A{
    ...
    virtual int fun();
    void fun(int);
    void fun(double, double);
    static int fun(char);
    ...
    }
  • 重写:重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且,在CPP中可用override进行标志该函数是重写函数
    • 与基类的虚函数有相同的参数个数
    • 与基类的虚函数有相同的参数类型
    • 与基类的虚函数有相同的返回值类型
  • 重载与重写的区别:
    • 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
    • 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
    • 重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体
  • 隐藏(hide):隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:
    • 两个函数参数相同,但是基类函数不是虚函数。和重写的区别在于基类函数是否是虚函数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      //父类
      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;
      }
      };

    • 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。举个例子:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      //父类
      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;
      }
      };

2.12 构造函数能不能是虚函数(什么函数为建议为虚函数,什么函数不能为虚)

我们知道,每个有虚函数的类都有属于自己的虚函数表vtbl,虚函数表在编译器构建好。当我们的派生类重写了虚函数,那么在虚表中就会替换掉父类的虚函数指针为子类的虚函数指针。当我们创建一个对象时,会在对象的内存模型中有自己的指向虚表的指针vtpr,对象通过虚表才知道调用的是哪一版本的虚函数

如果构造函数是虚函数,那么也会如上面的机制一样在虚表有一个指向自己构造函数版本的虚函数指针。现在有两个类AB,BA的派生类,在构造B的对象的时候发现继承于A的部分要先构造,它就要求调用A的构造函数,但是这些构造函数已经是虚函数了,而虚表指针必须是在构造一个对象的时候分配了内存才能得到,而你正在调用构造函数,这就陷入了一个矛盾的处境:你要用虚表指针vtpr去调用构造函数,而虚表指针vtpr只有当年调用构造申请了内存后才能得到,这形成了一个死结。

附加:vptr的初始化工作早于构造函数中的初始化列表

2.12.1 什么函数建议为虚函数,什么函数不能为虚
  • 析构函数我们都会在有继承的时候定义为虚函数,这是因为这样在动态晚绑定的时候才能准确的知道是析构哪个类的对象,调用哪个子类的析构函数去释放内存。
  • 构造函数不能为虚函数、普通非成员函数不能为虚函数、内联inline函数不建议声明为虚函数、静态成员函数不能为虚函数,友元函数不能为虚函数

2.13 为什么拷贝构造函数的参数要为引用

因为在函数调用中,非引用类型的的参数要进行拷贝初始化;函数返回一个非引用类型,调用方的返回结果也是一个拷贝,所以拷贝函数此时被用来初始化非引用类型的数据。如果拷贝构造函数的参数不是引用类型都是类型形参,为获得它的实参,那么它自身就会无限的调用自身的死循环。P(442)

2.14 inline能不能是虚函数、友元函数呢

这是个好问题,我们可以这样子去声明定义,但是编译器是否真的会内联展开是不一定的。首先,我们得明白内联函数的展开是在编译时展开的,这样无需生成函数调用指令,减少了函数调用的开销。但是:

  • 虚函数的实现是为了多态进行运行时的晚绑定,在运行时时候才能知道是哪个类对象,通过虚表指针调用哪个虚函数。这就造成的了矛盾。因此你可以这样声明定义,但编译器不一定执行内联展开操作。有一种情况会执行,那就是编译器可以确定对象的确切类型,因此可以静态地解析虚函数调用,此时可能会执行inline展开(编译器的virtual function devirtualization去虚拟化技术)。

  • 虚函数不能成为友元函数。因为友元函数(友元类)是不能被继承的(没有传递性)、是单向的,其访问权限完全由friend决定,对于没有继承这一特性的函数来说没有虚函数这一种说法。因此友元函数能够inline。

  • 普通函数(非成员函数)只能被overload,不能被override,声明为虚函数也没有什么意思,因此编译器会在编译时邦定函数,因此能够inline

2.15 构造函数、析构函数能和inine?

首先,将他们声明为inline没有错误,编译器并不一定执行内联操作(我们我们在类内定义的一般默认内联,但不一定真实执行)。

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

《Effective C++》中所阐述的是:

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

2.15 什么是静态联编和动态联编

  • 静态联编:编译器会根据函数调用的对象类型,在编译阶段就确定函数的调用地址,这就是静态联编(早绑定)
  • 动态联编:在普通成员函数前面加virtual,该函数变为虚函数,是告诉编译器这个函数要晚绑定,在运行阶段才确定调用哪个函数(晚绑定),

  • 1.子类转换成父类(向上转换):编译器认为指针的寻址范围缩小了,所以是安全的

  • 2.父类转换成子类(向下转换):编译器认为指针的寻址范围扩大了,不安全,需要使用dynamic_cast来进行向下转换

2.16 说一下初始化列表

  • 初始化列表:冒号和花括号之间的部分。其负责为新创建的对象的一个或几个数据成员进行初始化。列表是是类内置成员的名字,其()括号内就为初始值(注意不是赋值而是初始化)。
  • 注意:列表对成员变量的初始化是按其类内声明顺序初始化,而不是列表顺序
  • 对于类类型,使用初始化列表是直接初始化,只调用一次构造函数,因此具有更高的效率,而在函数体内是先调用默认构造构造该对象,然后再赋值。存在效率的差异,如果是类对象,那么效率更低
  • 构造函数的初始值列表解决了初始值必不可少的三种情况:
    • 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
    • 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      //情况1\2
      Class Test{
      public:
      Test(int ii); //构造函数
      private:
      int i;
      const int ci; //未被初始化
      int &ri; //未被初始化
      const int cr=10; //已类内初始化
      };
      Test:Test(int ii){
      i=ii;
      ci=ii; //错误,不能给const赋值
      ri=i; //错误,引用必须创建时被初始化
      }
    • 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化。进入构造函数后,test就会首先被默认初始化然后赋值,但是Test类没有默认的构造函数从而出现错误,所以初始化只能放在列表中。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      class Test
      { //无默认构造函数
      public:
      Test (int, int, int){
      cout <<"Test" << endl;
      }
      private:
      int x;
      int y;
      int z;
      };
      class Mytest
      {
      public:
      Mytest():test(1,2,3){ //初始化ok
      //test(Test(1,2,3));//error: no match for call to 'Test()'
      //test=Test(12,3,4);//error: no matching function for call to 'Test::Test()'
      //test(1,2,3);//error: no match for call to 'Test()'
      }
      private:
      Test test; //声明
      };

2.17 C++和C的区别

C++虽然是由C产生出来的,但是具有很多不同点:

  • 首先,C++是面向对象的编程语言,具有封装、继承和多态三大特性;而C是面向过程的结构化语言
  • C++中引入了许多关键字,比如用new/delete作为分配堆内存的的运算符,取代了C当中的mallocfree库函数
  • C++中使用iostream类库替代表中c中的stdio函数库
  • 在CPP中,允许有相同的函数名,不过它们的参数类型不能完全相同,这样这些函数就可以相互区别开来。而这在C语言中是不允许的。也就是CPP可以重载,C语言不允许
  • 在C++中,除了值和指针之外,新增了引用。引用型变量是其他变量的一个别名,我们可以认为他们只是名字不相同,其他都是相同的。
  • 在C++中为了防止内存泄露,引入了三大智能指针模板类;为了类型安全,出来继续使用C当风格的强制类型转换以外,还引入了四中强制类型转换const_cast、static_cast、dynamic_cast和reinterpret_cast
  • C++还增加了一些关键子如auto、using、namespace

2.18 friend友元类和友元函数

在类中,我们通过封装修饰符private/protected修饰相应的成员变量和函数,告诉编译器这些变量和函数不能被外部访问,但是,在某些情况,我们又希望这些变量和函数对特定的某个类或函数是可访问的,这时候就用到了友元函数和友元类。

  • 通过在类中声明friend class xxx告诉编译器,xxx类是该类的友类,可以访问任一类中的变量,它的形式就好像这个xxx类在该类中一样
  • 通过在类声明friend [returntype] funcName(args);,告诉编译器funcName是友元函数

因此对于友元其特性是:

  • 能访问私有成员
  • 破坏封装性
  • 友元关系不可传递
  • 友元关系的单向性
  • 友元声明的形式及数量不受限

2.19 模板类和类模板

  • 模板类:在编写中用template修饰,明白模板类他不是一个类,而是一个模板,只有实例化后的模板类才能是类。未实例化的模板类,编译器是无法知道其大小的,所以会略过编译。只有在实例化后,才会编译,在编译时会检查一些与模板无关的错误。同时,因为我们的编译器无法给为实例化的模板编译,如果把模板的声明放置.h,把实现发在.cpp文件,会发生链接错误,因为链接起找不到相应模板的实现,因为模板没有被实例化,当然也就找不到它的二进制文件。

  • 类模板:类模板就是实例化后的模板类,通过传入任意符合要求的类型,最后实例化成一个具体的类。

2.20 c++有哪些构造函数

C++中构造函数有:

  • 默认构造函数:如A(){},如果在类中没有定义任何构造函数,编译器会提供一个默认构造函数。默认构造函数没有参数,也没有执行任何初始化操作。
  • 带参数的构造函数:如A(int a):m_a(a{},带参数的构造函数接受参数,并用这些参数来初始化对象的成员变量。
  • 拷贝构造函数:A(const A& obj){...},拷贝构造函数用于创建一个对象,该对象是另一个同类型对象的拷贝。它通常用于传递对象给函数、从函数返回对象或者初始化新对象。如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义默认的拷贝构造函数
  • 委托构造函数C++11 引入了委托构造函数的概念,允许一个构造函数调用同一类中的另一个构造函数,以减少代码冗余。
  • 移动拷贝构造函数:如A(const A&& obj){...}:类似于拷贝构造函数,第一个参数是该类类型的右值引用,其他额外参数必须有默认值。移动拷贝构造函数不会创建新的内存,而是接管了obj的内存,因此对于源对象来说必须处于这样一个状态:销毁它是无害的

tips:拷贝赋值运算符和移动赋值运算符不是构造函数,而是赋值函数,因此是对已创建对象的赋值,而不是构造对象。

类中有哪些函数会默认生成

类默认生成的函数一定有以下几种:

  • 默认构造函数:如果没有提供任何构造函数,编译器会生成一个默认构造函数
  • 拷贝构造函数:如果你没有提供拷贝构造函数,编译器会生成一个默认的拷贝构造函数
  • 析构函数:如果你没有提供析构函数,编译器会生成一个默认的析构函数
  • 拷贝赋值运算符:如果你没有提供拷贝赋值运算符,编译器会生成一个默认的拷贝赋值运算符。它用于将一个对象的值赋给另一个已经存在的对象。

移动视情况发送:只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数移动赋值运算符

2.21 什么情况下会带调用拷贝构造函数

以下情况会调用拷贝构造函数

  • 当使用一个对象去初始化另一个对象时,会调用拷贝构造函数
    1
    2
    3
    4
    5
    6
    MyClass1 a;	//默认构造函数
    MyClass1 b(a); //拷贝构造
    MyClass1 c=a; //拷贝构造
    //注意
    Myclass1 d;
    d=c; //拷贝赋值(如果没有提供拷贝赋值的话,就好调用拷贝构造)
  • 当以值传递的方式将对象给函数参数时,拷贝构造函数会被调用。
  • 当函数返回一个对象时,拷贝构造函数会被调用

2.22 如果想将某个类用作基类,为什么该类必须定义而非声明?

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

所以必须定义而非声明。

2.23 const成员函数是什么,返回值有什么要求?

要理解const成员函数,归根到底就是理解const的修饰特性,它要求所修饰的变量为常量不能更改,就是这么简单。

所有对于const成员函数来说,我认为它会将隐藏的T*const this修饰为const T* const this,那么我们对底层const指针的了解下,this指针所包含的实际数据(成员变量)不能被修改,因此,也就知道const成员函数是对不能修改成员变量的,若该一个成员变量要能够在此修改,必须由mutable修改(底层我觉得与const_cast<>)差不多。

上面要细细品味“this指针所包含的实际数据(成员变量)不能被修改”这句话,如果没有深入理解,面试当中提到返回值值时就极有可能答错.

我们假设有一个类test,其内部包含一个非const指针变量,和一个变量

1
2
3
4
5
6
7
8
9
10
class test{
public
test(int* _a,int _b):a(_a),b(_b){}
int *func()const{return a;} //不报错
int& func2()const(){return b;} //报错,会出现const int&向int&转换错误
int* func3()const{return &b;} //报错,同上
private:
int *a;
int b;
};
对于一个对象而this指针而言,它的底层数据是谁?是指针a,和变量b,因此对于const T* const this来说,只有保证a的指向不改变,b的值不改变,就是符合const的修饰特性的,也就符合了const成员函数的特性**。(对于a指向的真实值改不改变并没有关系)因此:

  • func()不报错,因为const成员函数返回的是一个“指针变量”的副本,也就是拷贝指向同一地址的独立的副本。对返回指针的改变指针指向不会影响类内的指针指向,只是对其值的修改会影响而已,但值的影响并不与const冲突。
  • func2()会报错,因为变量的值b就是this的的底层数据,而该函数返回非const引用,如果正可以返回,那后续我们就可以通过对改返回值的修改来修改对象的变量b,这是与const特性相悖的,所有编译不通过。
  • func3()报错,也是一样,b的值才是我们this指针的底层数据,因此返回一个b的非const指针也会报错。

阅读了上面的步骤,大家明白对于const成员函数来说,不能修改的是对于const T* const this的thisz指针的底层可见状态而言的。

2.24 为什么引用能够指向子类对象

引用本质上是指针的别名,但是引用的语法使得它更加直观和方便。对于引用,其实际上是指针的一种语法糖,通过编译器进行内部处理。因此对于引用来说它在定义时必须绑定到一个具体的对象,但这个对象可以是派生类的对象。

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

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

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

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

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

  • 因此虚函数表回存储到在可执行文件的只读数据段中(rodata)
  • 虚表指针的是在对该类进行实例化是,在构造函数执行时会对虚表指针进行初始化,使每个实例对象都有一个虚表指针,存储在实例对象内存的堆区。

2.26 构造函数的执行顺序是怎么样的?

  • 1.在派生类构造函数中,所有的虚基类及上一层基类的构造函数调用;

  • 2.对象的vptr虚表指针被初始化;

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

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

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

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

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

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

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

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

2.28 为什么不要在构造函数和析构函数中调用虚函数?

构造派生类对象时,首先调用基类构造函数初始化对象的基类部分。在执行基类构造函数时,对象的派生类部分是未初始化的。实际上,此时的对象还不是一个派生类对象。

析构派生类对象时,首先撤销/析构他的派生类部分,然后按照与构造顺序的逆序撤销他的基类部分。

因此,在运行构造函数或者析构函数时,对象都是不完整的。为了适应这种不完整,编译器将对象的类型视为在调用构造/析构函数时发生了变换,即:视对象的类型为当前构造函数/析构函数所在的类的类类型。由此造成的结果是:在处于基类构造函数或者析构函数时,会将派生类对象当做基类类型对象对待

而这样一个结果,会对构造函数、析构函数调用期间调用的虚函数类型的动态绑定对象产生影响,最终的结果是:如果在构造函数或者析构函数中调用虚函数,运行的都将是为构造函数或者析构函数自身类类型定义的虚函数版本

因此不用在构造函数调用虚函数

2.29 构造函数析构函数可否抛出异常

在c++中,构造函数和析构函数都可以抛出异常,但是这会导致很多问题。

  • 如果构造函数抛出异常,对象的析构函数将不会执行,需要手动去释放已分配的资源,这很可能导致资源泄露的问题。
  • 如果析构函数抛出异常,和构造函数类似,而且更可能出现内存泄漏的问题,因为释放内存的操作通常在析构函数中进行。而且在C++异常机制中,当发生异常时,会调用对象的析构函数来释放资源。如果此时析构函数也抛出了异常,异常发生无限嵌套,就会导致程序崩溃,所以C++标准中,指明析构函数不能,也不应该抛出异常。
  • 如果无法确保析构函数是否抛出异常,最好的方法就是将异常封装在析构函数内部,比如可以使用try-catch代码块进行捕获和处理异常,总之,千万不要让异常游离在析构函数之外。

3. C++知识面试版_STL容器

3.1 STL的vector实现

vector是STL中最经常被使用到序列式容器,能够快速随机访问的(下标访问),因此其内存是连续的,当我们的内存不够时,容器必须分配新的内存空间来保存已有的和新的元素(即将旧内存的元素拷贝到新内存,添加新元素,释放旧内存),所以在进行插入和删除操作时,会造成内存块的拷贝从而导致原来的迭代器失效。

3.1.1 vector的扩容机制

当不得不获取新的内存空间时,vector和string的实现通常会分配比新的空间需求更大的内存空间。容器预留这些空间作为备用,可以来保存更多的新元素。这样,就不需要每次添加新元素都重新分配容器的内存空间了。在不同的编译器中,vector的扩容策略不相同,msvc编译器每次是以1.5倍且向下取整的策略进行扩容,gcc编译器SGI版本则是每次以2.0倍的策略进行扩容。

3.1.2 vector的释放空间
  • vector的内存占用空间只增不减,比如你首先分配了10,000个字节,然后erase掉后面9,999个,留下一个有效元素,但是内存占用仍为10,000个。所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素。但是即使clear(),vector所占用的内存空间依然如故,无法保证内存的回收。

    1
    2
    3
    //可以用swap()来帮助你释放多余内存或者清空全部内存。
    vector(Vec).swap(Vec); //将Vec中多余内存清除;
    vector().swap(Vec); //清空Vec的全部内存;

  • reserve(n):该函数预先分配一块较大的指定大小n的内存空间,这样当指定大小的内存空间未使用完时,是不会重新分配内存空间的,这样便提升了效率。即reserve不是当前容器含有多少元素,而是capacity。当当reserve()分配的空间比原空间小时,是不会引起重新分配的。

  • resize():只改变容器的元素数目,未改变容器capacity大小。

果需要空间动态缩小,可以考虑使用deque。

3.2 list的实现

STL当中的list是采用双向链表作为低层实现,相比于vector:

  • 其内存不要求连续,以因此不支持随机访问,所有迭代器使用的是双向迭代器。
  • 其插入和删除更快,只需要\(O(1)\)的时间复杂度,
  • 在插入和接合操作之后,都不会造成原迭代器失效,而vector可能因为空间重新配置导致迭代器失效。

3.3 deque的实现

deque则是一种双向开口的连续线性空间。所谓的双向开口,意思是可以在头尾两端分别做元素的插入和删除操作。

deque是由一段一段的定量的连续空间构成。一旦有必要在deque前端或者尾端增加新的空间,便配置一段连续定量的空间,串接在deque的头端或者尾端。deque最大的工作就是维护这些分段连续的内存空间的整体性的假象,并提供随机存取的接口,避开了重新配置空间,复制,释放的轮回,代价就是复杂的迭代器架构。

既然deque是分段连续内存空间,那么就必须有中央控制,维持整体连续的假象,数据结构的设计及迭代器的前进后退操作颇为繁琐。Deque代码的实现远比vector或list都多得多。 Deque采取一块所谓的map(注意,不是STL的map容器)作为主控,这里所谓的map是一小块连续的内存空间,其中每一个元素(此处成为一个结点)都是一个指针,指向另一段连续性内存空间,称作缓冲区。缓冲区才是deque的存储空间的主体。

3.3.1 deque的迭代器

deque虽然也提供随机访问的迭代器,但是其迭代器并不是普通的指针,其复杂程度比vector高很多,因此除非必要,否则一般使用vector而非deque。如果需要对deque排序,可以先将deque中的元素复制到vector中,利用sort对vector排序,再将结果复制回deque

1
2
3
4
5
6
7
8
9
struct __deque_iterator
{
...
T* cur;//迭代器所指缓冲区当前的元素
T* first;//迭代器所指缓冲区第一个元素
T* last;//迭代器所指缓冲区最后一个元素
map_pointer node;//指向map中的node
...
}

3.4 map的实现原理

STL中的map、set、muliti_map、muliti_set低层是采用红黑实现的。因此它们支持依据键值自动排序,而且查询和维护的时间复杂度都是\(O(logn)\),为每个节点要保持父节点、孩子节点及颜色的信息,因此占用的空间大。

unordered_map和unordered_set是C++11新添加的容器,其低层机制是通过哈希表实现的,通过哈希计算元素位置,使得查询和维护的时间复杂度早\(O(1)\),缺点就是键值不是有序的,同时会有哈希冲突等问题。

从两者的低层实现机制和特点来看:

  • map更适合于有序数据应用场景,unordered_map适用于高效查询、频繁创建和销毁、内存要求高的应用场景

3.5 哈希冲突的解决方法

  • 链地址法:每个表格维护一个list,如果hash函数计算出的格子相同,则按顺序存在这个list中
  • 再散列:发生冲突时使用另一种hash函数再计算一个地址,直到不冲突
  • 线性探测:使用hash函数计算出的位置如果已经有元素占用了,则向后依次寻找,找到表尾则回到表头,直到找到一个空位
  • 公共溢出区:建立一个公共溢出区,哈希冲突的值都存放再这里。

3.6 RAII是什么?

RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。

因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。,利用RAII机制可以避免内存泄漏

3.7 STL的两级空间配置器

我们知道动态开辟内存时,要在堆上申请,但若是我们需要频繁的在堆开辟释放大小不一的内存,则就会很容易在堆上造成很多外部碎片,浪费了内存空间;于是就STL就设置了二级空间配置器,当开辟内存<=128bytes时,即视为开辟小块内存,则调用二级空间配置器,若大于该数值,则调用一级空间配置器。SGI设计了双层配置器,第一级直接使用molloc()和free(),第二级则看情况采用不同策略分配:

  • 当配置区块超过128bytes时,视为足够大,直接调用第一级得配置器
  • 当配置区块小于128bytes时,视为小,便采用复杂的memory pool和自由链表管理配置

3.9 STL迭代器有哪些?

STL迭代器类型主要有三种随机访问迭代器、双向迭代器和单向迭代器。

  • 随机访问迭代器:不仅支持++、--,还支持+、-、[]运算符,可以任意访问容器以分配的内存,适用随机访问迭代器的容器有vector、string、array、deque
  • 双向迭代器:只支持++、--,适用双向迭代器的容器有list、set、map、mulitiset、mulitimap
  • 单向迭代器:只持++,适用该迭代器的有unordered_map、unordered_set、unordered_multimap

不支持迭代器的有:stack、queue、priority_queue

3.10 vector为什么会迭代器失效?

vector的迭代器失效的原因是vetor是一个连续内存存储的容器,vector插入操作会引发vector的扩容机制,即当前vector的容量不足与存储后续的数据时,vector就会重新申请一大小为当前大小2倍(SGI)或1.5倍(MSVC)区域存储数据,它会先将原始数据先拷贝到新的内存区域,然后执行插入操作,此时旧的迭代器所指向的区域已经无效了。同样删除导致后续的元素会被移动,也会有迭代器失效问题。

3.11vector和list的区别

  • vector是一个连续内存存储的容器,而list是一个双向链表,不要求内存连续,只要内部维护好上下节点承接关系即可
  • 因此vector支持下标随机访问时间复杂度为\(O(1)\),而list只能遍历
  • vector每次插入时可能引发扩容机制从而可能互会导致迭代器失效问题,但list不会
  • 对于插入删除操作,list的性能更高,只需要更该指针执行,时间复杂度为\(O(1)\),而vector的插入删除操作由于需要将后续数据前移或后移,因此总体的时间复杂度为\(O(n)\)

3.12 说一下map和set。

在 C++ 标准库中,std::map 是一个关联容器,它提供了一种将键值对关联起来的方式。std::map 的数据结构通常是基于红黑树(Red-Black Tree)实现的。

红黑树是一种自平衡的二叉查找树,它具有以下特点:

  • 每个节点要么是红色,要么是黑色。
  • 根节点是黑色的。
  • 每个叶子节点(NIL 节点,空节点)是黑色的。
  • 如果一个节点是红色的,则它的两个子节点都是黑色的。
  • 对于每个节点,从该节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点。

由于红黑树具有自平衡的特性,它可以保证在进行插入、删除等操作时,树的高度始终保持在 O(log n) 的水平,从而保证了 std::map 的操作效率。

std::map中,每个元素都是一个键值对,键和值可以是任意类型。std::map 中的元素按照键的顺序进行排序,因此可以通过键来快速查找和访问对应的值。在 std::map 中,键是唯一的,如果插入具有相同键的元素,新元素会覆盖旧元素。

3.13 红黑树了解吗,说一下它的机制

3.14 unordered_map和unordered_set呢

  • std::unordered_map:
    • 基于哈希表实现,无序性好,元素存储在哈希表中,根据键的哈希值快速查找。
    • 插入、删除、查找等操作的平均时间复杂度为$ O(1)$,但最坏情况下的时间复杂度可能为 \(O(n)\),其中 n 是元素的数量。
    • 适用于不需要元素有序存储,但需要快速进行插入、删除、查找等操作的场景。

当需要元素有序存储,并且需要频繁地进行插入、删除、查找等操作时,可以选择使用std::map。而当不需要元素有序存储,但需要快速进行插入、删除、查找等操作时,可以选择使用 std::unordered_map

对于大数据量的情况,一般情况下 std::unordered_map更适合,因为它的插入、删除、查找等操作的平均时间复杂度为 \(O(1)\),而 std::map 的这些操作的时间复杂度是$ O(log n)$,在大数据量的情况下,std::unordered_map 的性能优势会更加明显。但是需要注意,std::unordered_map在极端情况下(比如哈希冲突严重)的性能可能会下降,需要根据具体场景进行选择。

3.15 vector分配内存的方式

当我们使用 std::vector 时,它会动态地管理内存,以适应元素的增长。std::vector 内部使用动态数组来存储元素,这个动态数组的大小是动态调整的,因此我们可以在运行时向std::vector 中添加或删除元素,而不需要担心数组大小的问题。

std::vector 会维护两个关键的属性:

  • size:表示当前 std::vector 中的元素个数。
  • capacity:表示 std::vector 内部分配的存储空间的容量。

当我们向 std::vector 中添加新元素时,如果 size 达到了 capacity,即当前存储空间已经满了,std::vector 就会触发内部的重新分配内存的过程,来扩充存储空间。这个过程通常包括以下几个步骤:

  • 分配新的更大的内存空间:std::vector 会根据需要申请一块更大的内存空间,通常会将容量扩大为原来的2倍(MSVC)或1.5倍(SGI),以减少频繁的内存分配操作。
  • 将原有元素从旧的内存空间拷贝到新的内存空间:std::vector 会将原来的元素按顺序拷贝到新的内存空间中。
  • 释放旧的内存空间:一旦所有元素都已成功拷贝到新的内存空间,std::vector 会释放原来的内存空间

3.16 std::queue的数据结构

std::queue 是 C++ 标准库中的队列容器适配器,它是基于其他底层容器(比如 std::deque 或 std::list)实现的。std::queue 的特点是先进先出(FIFO)的数据结构,它只允许在队列的末尾(尾部)添加元素,并且只允许在队列的开头(头部)移除元素。

默认情况下,std::queue 使用 std::deque(双端队列)作为其默认底层容器。std::deque 是一种双端队列,它允许在两端高效地添加和删除元素,因此非常适合作为队列的底层数据结构。在 std::queue 中,元素的添加操作称为入队(enqueue),元素的移除操作称为出队(dequeue)。std::queue 提供了 push(入队)、pop(出队)、front(返回队头元素)、back(返回队尾元素)等操作,以及 empty(判断队列是否为空)、size(返回队列中元素的个数)等方法。

3.17 push_back()和emplace_back的区别

push_back和emplace_back都是向容器尾部添加一个元素操作,其区别是:

  • push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝(调用拷贝构造函数)或者移动(调用移动构造函数)到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素)。
  • 而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
  • 因此在效率上来看,emplace_back比push_back更好。
  • 若vector<pair<,>>这种情况push_back({x.y}) 要以pair的格式添加, 而emplace_back(x,y)不需要加{}

4. c++11新特性

4.1 enable_shared_from_this模板类

enable_shared_from_this是C++11引入的一个新特性,它的引入是为了使得shared_ptr指针能在异步调用中保活。

再了解这种保活机制之前,我们必须明白智能指针shared_ptr的初始化规则和工作原理:

  • 工作原理:shared_ptr通过引用计数来确保当前智能指针是否被其他对象使用,当赋值、拷贝都会使引入计数+1,而当这些对象销毁时,引入计数-1,当引入计数为0时,智能指针自动销毁。shared_ptr的引入是为了自动的释放堆内存,放置内存泄漏。
  • 初始化规则:一个普通指针(指new产生)只能初始化一个智能指针,不能初始化多个智能指针;智能指针之间的初始化没有限制。

为什么一个普通指针(指new产生)只能初始化一个智能指针,不能初始化多个智能指针呢,这是因为new产生的指针没有shared_ptr模板类的封装,因此当我们将new出来的指针用来初始化两个智能指针,它们的引用计数均为1,而不是2,这样就就会发生异常:另一个因计数为0释放了,另一个还以为没释放继续解析。

1
2
3
4
int* a= new int(5);
shared_ptr<int> p1(a);
shared_ptr<int> p2(a);
cout<<p1.use_count()<<" "<<p2.use_count()<<endl; //1 1

那么有了以上知识后,我们定义类一个类,定义了一个接口我们希望得到类对象的智能指针shared_ptr:

1
2
3
4
5
6
7
8
9
10
class stu:public enable_shared_from_this<stu>{
public:
stu(){}
shared_ptr<stu> getShared_ptr_unsafe(){
return shared_ptr<stu>(this);
}
shared_ptr<stu> getShared_ptr_safe(){
return shared_from_this();
}
};
上面getShared_ptr_unsafe()shared_ptr<stu>构造一个智能指针就会出现我们上面所讲的情况,即使多次调用getShard_ptr_unsafe返回的智能指针引入计数均为1,这样就会发生异常。为了解决这种问题,就引入了enable_shared_from_this模板类,使得shared_ptr再异步调用中保活

4.2 auto关键字

auto能使得编译器能够根据变量的初始化表达式自动推导出其类型,无需显式指定。

4.3 thread_local

thread_local是c++11添加的关键句,器指示该变量在每一个线程都有自己的副本,不会相互影响

4.4 智能指针

C++11还引入指能指针来解决内存泄漏等隐患问题。

4.5 bind函数

c++11引入bind函数,通过绑定生成一个依赖于func的新的函数对象,复用func函数的实现。

4.6占位符placeholder::_x placeholder::_x与bind联合使用,告诉编译器,这个参数我目前用它来占个坑,以后再来填。

4.7 function

C++ 11提供了std::function仿函数来代替函数指针,比函数指针使用性更强,也更适合C++风格。

4.8 lambada表达式

C++11还引入了lambad表达式,lambda表达式匿名函数,它可以简化代码。它通过提供一种简单的方式来声明一个匿名函数,并且可以方便地使用它来代替命名复杂的函数。

4.9 右值移动语义和完美转发

右值引用移动语义是C++11中的一个新特性,它为用户提供了右值引用的操作,并且可以方便地对数据进行移动。右值引用可以绑定在将要销毁的对象上,而不会在销毁对象上产生影响。另外,右值引用还可以根据需要对其进行分类,例如可以将亡值、纯右值等不同的类型进行分类。这样可以提高代码的性能和效率。

移动语义的引入和引用折叠产生了完美转发的定义。C++11新特性中的完美转发是指在函数模板中,完全依照模板的参数类型(左值or右值),将参数传递给函数模板中的另一个函数,目标函数能够收到与转发函数完全相同的实参。这样可以避免在函数调用过程中产生额外的开销,保持函数的原型和参数的原始类型,提高代码的简洁性和可读性。

5. C++并发

5.1 CAS机制是什么?

CAS全称是Compare-and-Swap操作,它是应对并发线程安全的一种技术。其旨在通过不使用锁来解决并发安全问题。

c++中的原子操作atomic就是依赖CAS机制来实现的,CAS指令通过三个操作数:内存地址V、旧预期值A、准备设置的新值B。

CAS指令执行更新一个变量的时候,只有当旧预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。这样的一个过程属于原子操作(依赖硬件),执行期间不会被其他线程打断。

我们可以用两个线程操作来说明CAS机制:加入两个线程,对变量i执行++i:

  1. 变量地址中存放的i值为20
  2. 此时线程一要将变量的值修改为21,旧值A=20与地址V中的值进行比较发现相等,说明没有其他线程对变量进行修改,可以直接将地址V中的值改为21
  3. 此时线程二要将变量的值修改为21,旧值A=20与地址V中的值进行比较发现不相等,说明有其他线程对变量进行了修改,所以不能地址V中的值改为21。
  4. 线程二进入再一次尝试,旧值=21,新值=22。旧值A=21与地址V中的值进行比较发现相等,说明没有其他线程对变量进行修改,可以直接将地址V中的值改为22。

5.2 无锁一定比加锁更好吗?

我们了解的无锁队列、栈其实都是基于原子操作来实现的,也就是CAS机制。无锁并发在一些情景下能够很好的提升系统的并发性能,但是也不是一定比加锁更好,就比如:

  • 竞争和性能:在高并发情况下 无锁队列可能导致大量的 CAS 操作,这会引入竞争,可能降低性能。这种情况下,无锁队列可能在一些情况下比有锁队列性能差。
  • 内存管理: 像无锁队列这些数据结构,需要频繁地进行内存分配和释放,这可能导致内存碎片问题。这也可能导致性能问题,因为内存分配和释放是比较昂贵的操作。

  • ABA问题:ABA 问题的核心是在一个线程尝试修改共享变量时,如果这个变量的值在操作之间曾经变成了期望的值,然后又变回原来的值,那么 CAS 操作将错误地认为共享变量没有被其他线程修改过。这可能导致程序在多线程环境下产生不正确的结果。
    • 解决ABA:为了解决 ABA 问题,通常需要在 CAS 操作中添加版本号或时间戳等额外信息,以确保在期望值相同时,也能检测到变量是否已经被其他线程修改过。

而且此外:

  • 复杂性: 无锁队列的实现通常比有锁队列复杂,容易引入 bug 和难以调试。并发编程本身已经很难,无锁编程更加复杂。
  • 难以维护: 无锁队列的代码通常比较复杂,难以维护。它需要开发人员具备高度的并发编程经验。

综上,再使用无锁技术来实现并发安全,应该根据具体的应用场景和性能需求来选择合适的并发数据结,并且通过测试去验证无锁是否比锁更好。无锁队列通常在需要高度并发和低延迟的场景下才会被选择使用,而在一般情况下,有锁队列可能更容易实现和维护。

5.3 什么情况适合用协程池,什么情况适合用线程池(IO密集协程,资源多线程)

  • 协程池适用场景:
    • I/O 密集型任务:当程序中有大量的 I/O 操作(如文件读写、网络请求等)时,使用协程池可以提高程序的并发性能。因为协程能够在单线程内部高效地切换执行,适合处理大量的 I/O 操作,能够充分利用 CPU 时间,减少等待时间。
    • 高并发的网络编程:在网络编程中,协程池可以很好地支持高并发的网络请求处理,能够有效地管理大量的网络连接和请求。
  • 线程池适用场景:
    • CPU 密集型任务:当程序中有大量的 CPU 计算密集型任务时,使用线程池可以提高程序的并发性能。因为线程池能够利用多核处理器的并行性,同时执行多个 CPU 密集型任务,提高 CPU 的利用率。
    • 长时间运行的任务:对于需要长时间运行的任务,使用线程池可以避免阻塞主线程,保持系统的响应性。

5.4 thread_local是什么?

thread_local是c++11引入的一个关键字,被thread_local修饰则指示该变量在每一个线程都有自己的副本,不会相互影响达到保证线程安全的目的。

什么时候用thread_local? - 当我们想每个线程需要拥有其自己的数据副本,但又不想使用复杂的同步机制(如互斥锁)来避免数据竞争的情况特别有用。

使用 thread_local 时应小心,因为它可能会增加内存消耗,因为每个线程都需要存储其自己的数据副本

6. C++模板与泛型编程

6.1 泛型编程的好处

面向对象编程(OOP)和泛型编程都能处理在编写程序时不知道类型的情况,不同之处在于:OOP能处理类型在程序运行之前都未知的情况;而泛型编程,在编译时就能知道类型,泛型编程的好处是通过编译期间确定的类型再去实例化一个函数或者类,通过写定一个代码就能处理类型不同时的场景,如之前介绍过的容器和泛型算法都是泛型编程的例子。

6.2 模板函数可以重载吗?

模板函数支持重载,可以被另一个模板函数或者普通函数,其重载规则跟普通函数相似,只要参数类型或参数个数不同即为重载。匹配规则:

  • 和往常一样,如果恰有一个函数提供比任何其他函数更好的匹配,则选择此函数,但是,如果有多个函数提供同样好的匹配,则:
  • 如果同样好的函数中只有一个是非模板函数,则选在此函数
  • 如果同样好的函数中没有非模板,而有多个函数模板,且其中一个模板比其他模板更特例化,则选择此模板,否则,此调用有歧义

6.3 模板的声明和实现为什么放在同一个头文件下?

类模板中的声明和实现必须放在同一头文件,因为不在同一文件下链接器(linker)会找不到实例化的函数模板的入口地址而报出“链接相关错误”。

这是因为模板类或者函数并不是一个类或者函数对象,它是提供给编译器生成真正实例函数或类的一张”图纸“,在编译的过程中,如果遇到了调用模板类或模板函数时编译器就会参考这个函数模板生成真正对应的代码。那么当我们不写在同一文件时,调用这个模板函数的地方因为编译器找不到其参考代码无法生成一个实例,那么编译器不会报错,只是做一个符号弱引用标志,由链接器去实现解析符号引用找到它的实现,但编译器完全没有做对应函数的编译工作,因此报无法解析外部符号的错误。

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

编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化,因此所谓的特例化是指对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上

模板函数的特例化

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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,更匹配实参类型),注意二者函数体的语句不一样了,实现不同功能。

模板类的部分特例化
  • 与函数模板不同,类模板的特例化不必为所有模板参数提供实参,可以只指定一部分而非所有模板参数,或是参数的一部分而非全部特性,即类模板支持偏特化。
  • 我们只能部分特例化类模板,而不能部分特例化函数模板,函数模板不支持偏特化
  • 我们可以只特例化特定成员函数而不是特例化整个类模板
  • 类模板即可以全特化,也可以偏特化

对类进行特例化时,仍然用template<>表示是一个全特例化版本

1
2
3
4
5
6
7
8
9
10
template<class T1, class T2>      // 普通类模板,有两个模板参数
class B {
void Bar() {/* ... */}
};

//指定一部分参数】(部分特例化)
template<class T2>    // 偏特化版本,指定其中一个参数,即指定了部分类型
class B<int , T2> {
void Bar() {/* t特例化版本 */}
};  // 当实例化时的第一个参数为int 则会优先调用这个版本

6.5 类模板支持虚函数吗?

类模板支持类模板虚函数,即类模板内可以有虚函数,但是类模板的模板成员函数不能时虚函数。

因为类模板的实例化类在编译期间确定,而虚函数表和虚函数表指针也是在有实例化类后才确定,这两者并不冲突。但但是,当我们在一个实例化类定义一个模板虚函数是不可行的,因为编译器在编译的时候就得确定虚函数表的大小,而模板函数只又在调用实例化后才会生成一个真正的函数,此时就无法确定到底有多少个虚函数了。

6.6 类模板的静态成员

  • 对于任意给定的模板参数X,所有的foo<x>类型对象都共享相同的静态成员。注意是相同的类型参数X情况下共享,不同类型各自拥有
  • 可以使用实例类访问foo<X>::getStatic(),也可以使用实例化的对象访问blob.getStatic()访问

6.7 模板类和模板函数的区别是什么?

  • 函数模板允许隐式调用和显式调用而类模板只能显示调用,在使用时类模板必须加,而函数模板不必。
  • 模板类支持全特化和偏特化,而模板函数只支持全特化操作。