Skip to content

其他

1、编译过程

1. 预处理

此阶段主要完成#符号后面的各项内容到源文件的替换。

  1. 删除所有的#define,展开所有的宏定义。
  2. 处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。

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

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

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

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

此阶段产生[.i]文件。

2. 编译

​ 经过预处理得到的输出文件中,将只有常量。如数字、字符串、变量的定义,以及C语言的关键字,如main,if,else,for,while,{,},+,-,*,\,等等。编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。

gcc编译的中间文件是[.s]文件,第一个阶段和第二个阶段由编译器完成。

3. 汇编

将汇编代码转变成机器可执行的指令(机器码)。汇编器的汇编过程相对于编译器来说更加简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表——翻译过来。

[.O]文件,是二进制文件,此阶段由汇编器完成。

4. 链接

此阶段完成文件中叼用的各种函数跟静态库和动态库的连接,并将它们一起打包合并形成目标文件,即可执行文件。

此阶段由链接器完成。

动态链接和静态链接

(1)静态链接

​ 在这种链接方式下,函数的代码将从其所在的静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。

(2)动态链接

​ 在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。

​ 对于可执行文件中的函数调用,可分别采用动态链接或静态链接的方法。使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。但并不是使用动态链接就一定比使用静态链接要优越。在某些情况下动态链接可能带来一些性能上损害。

2、C和C++编译

​ 由于CPP支持多态性,也就是具有相同函数名的函数可以完成不同的功能,CPP通常是通过参数区分具体调用的是哪一个函数。在编译的时候,CPP编译器会将参数类型和函数名连接在一起,于是在程序编译成为目标文件以后,CPP编译器可以直接根据目标文件中的符号名将多个目标文件连接成一个目标文件或者可执行文件。

​ 但是在C语言中,由于完全没有多态性的概念,C编译器在编译时除了会在函数名前面添加一个下划线之外,什么也不会做(至少很多编译器都是这样干的)。由于这种的原因,当采用CPP与C混合编程的时候,就可能会出问题。

3、C++在执行main()函数之前做了哪些操作?

  1. 设置栈指针
  2. 初始化静态和全局变量,即data段的内容
  3. 将未初始化部分的全局变量赋初值:数值型short,int,long等为0,bool为FALSE,指针为NULL,等等,即.bss段的内容
  4. 全局对象初始化,在main之前调用构造函数
  5. 将main函数的参数,argc,argv等传递给main函数,然后才真正运行main函数

4、用C实现多态

在 C 中没有类的概念,但有 struct,而且 C 中的 struct 是不允许有函数的,只允许存在变量,那是不是函数变量就允许存在了?!所以,函数指针可以给我们一些提示。

struct Animal
{
     void (*move)();
};

struct Rabbit
{
     void (*move)();
};

void Animal_move()
{
     printf("Animal move.\n");
}

void Rabbit_move()
{
     printf("Rabbit move.\n");
}

// struct Animal 和 struct Rabbit 在内容上完全一致,而且变量对齐上也完全一致,可以通过将 struct Rabbit 类型的指针强制转换为 struct Animal 类型的指针,即:
int main(void)
{
     Animal *panimal;

     Rabbit rabbit;

     rabbit.move=Rabbit_move;

     panimal = (Animal*)&rabbit;

     panimal->move();
}

问题:

结构体是根据变量在结构体的偏移量来读取或者修改变量的。如果struct Animal 中和 struct Rabbit 中的偏移量不同,panimal->move();

可以被形象的转化为:( * (panimal+sizeof(age)) ) ();

但发现 panimal 是指向 struct Rabbit 实体的,panimal+sizeof(age) 已经指向了非法地址。

因此需要模拟多态,必须保持函数指针变量对齐

5、可重入函数

在多任务系统下,中断可能在任务执行的任何时间发生,同时也可能在任务执行过程中发生系统调度而将执行转向另一个线程,如果一个函数的执行期间被中断后,到重新恢复到断点进行执行的过程中,函数所依赖的环境没有发生改变,那么这个函数就是可重入的,否则就不可重入。

不可重入的:

  • 使用了静态数据结构;
  • 调用了malloc或free;
  • 调用了标准I/O函数;

6、符号表的重定位

​ 在可重定位目标文件之中会存在一个用来放置变量和其入口地址的符号表,当编译过程中能够找到该符号的定义时就将该符号入口地址更新到符号表中否则就对该符号的地址不做任何决议一直保留到链接阶段处理。

  • 在编译阶段就能够在文件中找到他们的定义,所以能够进行明确的内存地址分配。
  • 在编译阶段虽然可以看到这些符号变量的声明,但却找不到他们的定义所以编译器陷入了一个决而未决的境地。

先将他们存放在符号表中但却不去为他们进行内存关联一直等到链接阶段在进行处理。

​ 重定位发生于目标代码链接阶段,在链接阶段链接器就会查找符号表,当他发现了function2.cpp的符号表之中任然有没有决议的内存地址时,链接器就会查找所有的目标代码文件,一直到他找到了function1.cpp所生成的目标代码文件符号表时发现了这些没有决议的符号变量的真正内存地址,这是function2.cpp所生成的目标代码文件就会更新它的符号表,将这些尚未决议的符号变量的内存地址写进其符号表中。

8、Unicode、UTF-8

Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。

比如,汉字的 Unicode 是十六进制数4E25,转换成二进制数足足有15位(100111000100101),也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节,甚至更多。

这里就有两个严重的问题,第一个问题是,如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?

互联网的普及,强烈要求出现一种统一的编码方式。UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式。其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),不过在互联网上基本不用。重复一遍,这里的关系是,UTF-8 是 Unicode 的实现方式之一。

1)对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。

2)对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

9、大端、小端

unsigned int a=0x12345678;

大端Big-Endian:高字节在前 12 34 56 78

小端Little-Endian:低字节在前 78 56 34 12

#include <iostream>

using namespace std;
int main()
{
    int nNum = 0x12345678;
    char chData = *(char*)(&nNum);

    if (chData == 0x12)
    {
        cout << "big" << endl;
    }
    else
    {
        cout << "small" << endl;
    }

    return 0;
}

网络字节序

网络字节序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节序采用big endian排序方式。

主机字节序

不同的机器主机字节序不相同,与CPU设计有关,数据的顺序是由cpu决定的,而与操作系统无关。我们把某个给定系统所用的字节序称为主机字节序(host byte order)。比如x86系列CPU都是little-endian的字节序。

概念

1、面向过程VS面向对象

​ 面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。

​ 面向对象 ( Object Oriented ) 是将现实问题构建关系,然后抽象成 类 ( class ),给类定义属性和方法后,再将类实例化成 实例 ( instance ) ,通过访问实例的属性和调用方法来进行使用。“对象”是方法(函数)和数据的组合,类的一大特点,就是它自身是包含数据的,而且不同的数据可以造就不同的实例(对象)。

C++和C的区别:

C是面向过程的语言,而C++是面向对象的语言,因此C++语言中有类和对象以及继承多态这样的OOP语言必备的内容,此外C++支持模板,运算符重载,异常处理机制,以及一个非常强大的C++标准模板库STL,另外一个Boost库现在也归属C++标准库,提供了很多强大的功能。

C只能写面向过程的代码,而C++既可以写面向过程的代码,也可以实现面向对象的代码;既然C++是面向对象的OOP语言,因此它还有非常强大的设计模式,比如单例,工厂,观察者模式等等,这些在C语言当中都是不支持的。

C和C++一个典型的区别就在动态内存管理上了,C语言通过malloc和free来进行堆内存的分配和释放,而C++是通过new和delete来管理堆内存的

另外强制类型转换上也不一样,C的强制类型转换使用()小括号里面加类型进行类型强转的,而C++有四种自己的类型强转方式,分别是const_cast,static_cast,reinterpret_cast和dynamic_cast(一样,等面试官来深入问你,前提是这块你准备好了,如果没有理解他们的使用场景和区别,那么这一点你还是不要说了!)

C和C++的输入输出方式也不一样,printf/scanf,和C++的cout/cin的对别,前面一组是C的库函数,后面是ostream和istream类型的对象。

C++还支持带有默认值的函数,函数的重载,inline内联函数,这些C语言都不支持,当然还有const这个关键字,C和C++也是有区别的,但是这都是目前最常用的C89标准的C内容,在C99标准里面,C语言借鉴了C++的const和inline关键字,这两方面就和C++一样了。

由于C++多了一个类,因此和C语言的作用域比起来,就多了一个类作用域,此外,C++还支持namespace名字空间,可以让用户自己定义新的名字空间作用域出来,避免全局的名字冲突问题。

C++不仅支持指针,还支持更安全的引用,不过在汇编代码上,指针和引用的操作是一样的

由于C++是面向对象的语言,支持类对象,类和类之间的代理,组合,继承,多态等等面向对象的设计,有很多的设计模式可以直接使用,因此在设计大型软件的时候,通常都会采用面向对象语言,而不会采用面向过程语言,可以更好的进行模块化设计,做到软件设计的准则:高内聚,低耦合!

在C++中,struct关键字不仅可以用来定义结构体,它也可以用来定义类

1.封装

​ 封装在语言的体现中有2点,第一点是类的封装,第二点是函数,其实二者语法体现不同,运用场景不同,但是在本质上都是对一堆面向过程的代码封装,我们只要封装一次或者说造一次轮子,后续就可以一直调用,如果说一门语言不具备封装,那么从抒写角度来看,就不具备面向对象的概念,因为你写出的代码,都是亲力亲为,一行一个步骤,很多代码段都是相同的,如果具备封装,那么轮子造一次,重复使用即可,从抒写角度来看,相对性并不是一行一个步骤,而是调用了一个轮子来实现的,所以具备面向对象的概念,当然这一切都是从代码抒写的角度提出的。从编程思想上即便写的是面向过程仍然是面向对象的思维方法论。

​ 函数封装很简单就不提及了,是最简单封装代码,实现轮子的方式,类的封装是一个有机体,封装代码具备联系,整体构成一个系统单元,对外只提供一个接口,和函数一样,和函数一样调用者不知道内部实现,而在类中还提供了对方法的私有保护的开方性,内部方法可以相互调用,可以说是大型的封装。

​ 不仅是个封装的概念,在代码层次上类更是聚焦于编程者的面向对象代码层次上的体现,一个对象,具备多个特征,映射于一个类具备多属性和方法,更适用于直接的实际项目开发;而用函数作为直接开发是远不及类的。

2.继承性

​ 这个很好解释,试着考虑一下我们在设计一门语言的同时,只具备封装,这样能快速使用轮子和面向对象体现,那么还有什么方式能够比较快速的使用轮子呢,就是继承,我直接把你的功能继承遗传过来,这样的语言会更加简约而不冗余,而且在实际开发场景中,确实我们可能还要在封装一个东西,但是需要用另外一个封装体中的内容,我们是去把代码直接复制过来,还是建立一个继承性,更为简单。

3.多态

​ 多态同一个行为具有多个不同表现形式或形态的能力。是指一个类实例(对象)的相同方法在不同情形有不同表现形式。多态机制使具有不同内部结构的对象可以共享相同的外部接口。这意味着,虽然针对不同对象的具体操作不同,但通过一个公共的类,它们(那些操作)可以通过相同的方式予以调用。

2、指针和引用的区别

  1. 指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。
  2. 引用不可以为空,当被创建的时候必须初始化;指针可以是空值,可以在任何时候被初始化。

  3. 引用没有顶层const即int & const,因为引用本身就不可变,所以在加顶层const也没有意义; 但是可以有底层const即 const int&,这表示引用所引用的对象本身是常量。

  4. 指针和引用的自增(++)和自减含义不同,指针是指针运算, 而引用是代表所指向的对象对象执行++或--

3、栈的效率为什么比堆高

堆是一个运行时数据区,类的(对象从中分配空间。由于要在运行时动态分配内存,存取速度较慢。 存在栈中的数据可以共享。

栈是编译时分配空间,而堆是动态分配(运行时分配空间),所以栈的速度快

cpu有专门的寄存器(esp,ebp)来操作栈,堆都是使用间接寻址的。栈快点

4、C++内存模型

image-20210921131231974

  1. 堆 heap

由malloc分配的内存块,其释放编译器不去管,由我们程序自己控制(一个malloc对应一个free)

  1. 自由存储区(free store)

由new分配的内存块,其释放编译器不去管,由我们程序自己控制(一个new对应一个delete)

  1. 栈 stack

是那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。存放在栈中的数据只在当前函数及下一层函数中有效,一旦函数返回了,这些数据也就自动释放了。

  1. 全局/静态存储区 (.bss段和.data段)

全局和静态变量被分配到同一块内存中。在C语言中,未初始化的放在.bss段中,初始化的放在.data段中;在C++里则不区分了

  1. 常量存储区 (.rodata段)

存放常量,不允许修改(通过非正当手段也可以修改)

  1. 代码区 (.text段)

存放代码(如函数),不允许修改(类似常量存储区),但可以执行(不同于常量存储区)

1、多态

​ C++多态分为静态多态和动态多态。静态多态是通过重载和模板技术实现,在编译的时候确定。动态多态通过虚函数和继承关系来实现,执行动态绑定,在运行的时候确定。

​ 基类指针在调用成员函数(虚函数)时,就会去查找该对象的虚函数表。虚函数表的地址在每个对象的首地址。查找该虚函数表中该函数的指针进行调用。

​ 每个对象中保存的只是一个虚函数表的指针,C++内部为每一个类维持一个虚函数表,该类的对象的都指向这同一个虚函数表。虚函数表直接从基类继承过来,如果覆盖了其中的某个虚函数,那么虚函数表的指针就会被替换,因此可以根据指针准确找到该调用哪个函数。

C++编译器是保证虚函数表的指针存在于对象实例中最前面的位置(是为了保证取到虚函数表的最高的性能),这样我们就能通过已经实例化的对象的地址得到这张虚函数表,再遍历其中的函数指针,并调用相应的函数。

静态多态通过重载和模板实现,在编译期间确定;动态多态通过虚函数和继承关系确定,在运行期间确定。

动态多态的作用:

隐藏实现细节,是代码模块化,提高可复用性;

接口重用,使派生类的功能可以被基类的指针/引用调用,

2、构造函数和析构函数中不能调用虚函数

​ 可以,虚函数底层实现原理(但是最好不要在构造和析构函数中调用) 可以,但是没有动态绑定的效果,父类构造函数中调用的仍然是父类版本的函数,子类中调用的仍然是子类版本的函数。 effictive c++第九条,绝不在构造和析构过程中调用virtual,因为构造函数中的base的虚函数不会下降到derived上。而是直接调用base类的虚函数。

  • a) 如果有继承,构造函数会先调用父类构造函数,而如果构造函数中有虚函数,此时子类还没有构造,所以此时的对象还是父类的,不会触发多态。更容易记的是基类构造期间,virtual函数不是virtual函数。
  • b) 析构函数也是一样,子类先进行析构,这时,如果有virtual函数的话,子类的内容已经被析构了,C++会视其父类,执行父类的virtual函数。
  • c) 总之,在构造和析构函数中,不要用虚函数。如果必须用,那么分离出一个Init函数和一个close函数,实现相关功能即可。

3、友元函数

类的友元函数是定义在类外部,但有权访问类的所有私有(private)成员和保护(protected)成员。

友元可以是一个函数,该函数被称为友元函数;友元也可以是一个类,该类被称为友元类,在这种情况下,整个类及其所有成员都是友元。

为什么会有友元函数?

结合着类的特性和类中一般成员函数,我们可以这样理解:类具有封装和信息隐藏的特性。只有类的成员函数才能访问类的私有成员,程序中的其他函数是无法访问私有成员的。非成员函数可以访问类中的公有成员,但是如果将数据成员都定义为公有的,这又破坏了隐藏的特性。另外,应该看到在某些情况下,特别是在对某些成员函数多次调用时,由于参数传递,类型检查和安全性检查等都需要时间开销,而影响程序的运行效率。

为了解决上述问题,提出一种使用友元的方案。友元是一种定义在类外部的普通函数,但它需要在类体内进行说明,为了与该类的成员函数加以区别,在说明时前面加以关键字friend。友元不是成员函数,但是它可以访问类中的私有成员。友元的作用在于提高程序的运行效率,但是,它破坏了类的封装性和隐藏性,使得非成员函数可以访问类的私有成员。

友元函数的特点是能够访问类中的私有成员的非成员函数。友元函数从语法上看,它与普通函数一样,即在定义上和调用上与普通函数一样。

4、public、protected和private

第一:private, public, protected 访问标号的访问范围。

private:只能由1.该类中的函数、2.其友元函数访问。不能被任何其他访问,该类的对象也不能访问。

protected:可以被1.该类中的函数、2.子类的函数、以及3.其友元函数访问。但不能被该类的对象访问。

public:可以被1.该类中的函数、2.子类的函数、3.其友元函数访问,也可以由4.该类的对象访问。

注:友元函数包括3种:设为友元的普通的非成员函数;设为友元的其他类的成员函数;设为友元类中的所有成员函数。

第二:类的继承后方法属性变化。

使用private继承,父类的protected和public属性在子类中变为private;

使用protected继承,父类的protected和public属性在子类中变为protected;

使用public继承,父类中的protected和public属性不发生改变;

5、虚函数表和虚函数指针存放在那个位置

  1. 虚函数表是全局共享的元素,即全局仅有一个
  2. 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表。即虚函数表不是函数,不是程序代码,不肯能存储在代码段
  3. 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中

6、限制类对象只能在堆或者栈上建立

1、只能建立在堆上

编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性,其实不光是析构函数,只要是非静态的函数,编译器都会进行检查。如果类的析构函数是私有的,则编译器不会在栈空间上为类对象分配内存。

class A
{
public:
    A(){}
    void destory(){delete this;}
private:
    ~A(){}
};

试着使用A a;来建立对象,编译报错,提示析构函数无法访问。这样就只能使用new操作符来建立对象,构造函数是公有的,可以直接调用。类中必须提供一个destory函数,来进行内存空间的释放。类对象使用完成后,必须调用destory函数。

2、只能建立在栈上

只有使用new运算符,对象才会建立在堆上,因此,只要禁用new运算符就可以实现类对象只能建立在栈上。将operator new()设为私有即可。

7、空类默认产生哪些函数

class Empty
{
    public:

      Empty(); // 缺省构造函数

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

      ~Empty(); // 析构函数

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

       Empty* operator&(); // 取址运算符

       const Empty* operator&() const; // 取址运算符 const

};

8、接口继承和实现继承

纯虚函数: 被声明为纯虚函数的类一定是作为基类来使用的,含有纯虚函数的类被称为抽象类,抽象类不能实例化对象。因此纯虚函数一般用来声明接口。其派生类必须实现这个函数。纯虚函数在基类中可以有函数实现,也可以没有。声明纯虚函数的原因是,在基类往往不合适进行实例化,比如一个shape类,中的draw方法。必须为纯虚函数,因为他不是任何一种形状。

虚函数:在基类中声明为vitual,并在一个或者多个派生类中被重新定义的函数。虚函数用于提供一类操作的统一命名。一般声明为虚函数是为了实现多态:使用基类指针指向一个派生类对象,通过这个指针调用基类和派生类的同名函数的时候,调用的是派生类中的方法(前提vitual函数肯定实现了)。

非虚函数:一般的成员函数,无virtual修饰。派生类继承函数接口及一份强制性实现。

有了这几个概念,现在解释下;接口继承与实现继承

接口继承: 派生类只继承函数接口,也就是声明。

实现继承:派生类同时继承函数的接口和实现。

总结:在设计类的时,遵循以下几点

  1. 纯虚函数:要求派生类必须实现的函数,在基类中实现没有什么具体的意义。
  2. 虚函数:继承类必须含有的接口,可以自己实现,也可以不实现,而采用基类的实现。
  3. 非虚函数:继承类必须函数有的接口,必须使用基类实现。

9、虚继承

image-20210923212707345

虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。虚拟继承基类的子类中,子类会增加一个指针。

关键字

1、#define和const、typedef和define

define和const

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

  • define只是简单的字符串替换,没有类型检查。而const有对应的数据类型,是要进行判断的,可以避免一些低级的错误。

  • define只是进行展开,有多少地方使用,就替换多少次,它定义的宏常量在内存中有若干个备份;const定义的只读变量在程序运行过程中只有一份备份。

  • const常量可以进行调试的,define是不能进行调试的,因为在预编译阶段就已经替换掉了。

typedef和define

  • typedef用来定义一种数据类型的别名,define用来定义常量以及书写复杂使用频繁的宏。

  • typedef是编译过程的一部分,有类型检测。define是宏定义,是预编译的部分,发生在编译之前,只是简单的字符串替换,不进行类型检查。

2、new和malloc的区别

1、申请的内存所在位置

​ new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从上动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。

​ 那么自由存储区是否能够是堆(问题等价于new是否能在堆上动态分配内存),这取决于operator new 的实现细节。自由存储区不仅可以是堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。

2、返回值

​ new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。3.内存分配失败时的返回值

​ new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。

3、是否需要指定内存大小

​ 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算,而malloc则需要显式地指出所需内存的尺寸。

4、是否调用构造函数/析构函数

使用new操作符来分配对象内存时会经历三个步骤:

  • 第一步:调用operator new 函数(对于数组是operator new[])分配一块足够大的,原始的,未命名的内存空间以便存储特定类型的对象。
  • 第二步:编译器运行相应的构造函数以构造对象,并为其传入初值。
  • 第三部:对象构造完成后,返回一个指向该对象的指针。

使用delete操作符来释放对象内存时会经历两个步骤:

  • 第一步:调用对象的析构函数。
  • 第二步:编译器调用operator delete(或operator delete[])函数释放内存空间。

5、是否可以被重载

opeartor new /operator delete可以被重载。

6、客户处理内存分配不足

在operator new抛出异常以反映一个未获得满足的需求之前,它会先调用一个用户指定的错误处理函数,这就是new-handler

3、cast

const_cast

1、常量指针被转化成非常量的指针,并且仍然指向原来的对象;

2、常量引用被转换成非常量的引用,并且仍然指向原来的对象;

static_cast

static_cast 作用和C语言风格强制转换的效果基本一样,没有运行时类型检查来保证转换的安全性。

dynamic_cast

dynamic_cast是运行时处理的,运行时要进行类型检查,不能用于内置基本数据类型间的强制转换,基类中一定要有虚函数。

reinterpret_cast

处理无关类型之间的转换,

reinterpret_cast用在任意指针(或引用)类型之间的转换;以及指针与足够大的整数类型之间的转换;从整数类型(包括枚举类型)到指针类型,无视大小。

只有将转换后的类型值转换回到其原始类型,这样才是正确使用reinterpret_cast方式。

4、this

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

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

当在类的非静态成员函数中访问类的非静态成员的时候,编译器会自动将对象本身的地址作为一个隐含参数传递给函数。

在类的非静态成员函数中返回类对象本身的时候,直接return *this;

5、const

顶层const代表指针的值是一个常量,int *const ptr,指针常量

底层const,不能改变指向的内容,int const *ptr,常量指针

不考虑类的情况

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

const形参可以接收const和非const的实参

考虑类的情况

const成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,必须有构造函数;不能在类中声明时初始化

const成员函数:const对象不能调用非const成员函数;非const对象都可以调用;不能改变非mutable数据的值

const成员函数

int Get() const;

  1. 常量成员函数不修改对象
  2. 常量成员函数在定义和声明中都应加const限定
  3. 非常量成员函数不能被常量成员函数调用,但构造函数和析构函数除外
  4. 常量(const对象)对象只能调用常量成员函数

6、const和constexpr

​ 对于修饰Object来说:const并未区分出编译期常量和运行期常量,constexpr限定在了编译期常量。

​ constexpr修饰的函数,简单的来说,如果其传入的参数可以在编译时期计算出来,那么这个函数就会产生编译时期的值。但是,传入的参数如果不能在编译时期计算出来,那么constexpr修饰的函数就和普通函数一样了。不过,我们不必因此而写两个版本,所以如果函数体适用于constexpr函数的条件,可以尽量加上constexpr。

7、指针数组和数组指针

指针数组:首先它是一个数组,数组的元素都是指针,数组占多少个字节由数组本身决定。它是“储存指针的数组”的简称。

数组指针:首先它是一个指针,它指向一个数组。在32 位系统下永远是占4 个字节,至于它指向的数组占多少字节,不知道。它是“指向数组的指针”的简称。

int *p1[10]; // 指针数组

int (*p2)[10]; // 数组指针

8、struct

struct更适合看成是一个数据结构的实现体,class更适合看成是一个对象的实现体。

struct和class的区别

  • 默认继承权限不同,class默认是private,struct默认是public
  • class还可以用于定义模板参数,像typename

C语言的结构体和C++的区别

  • C语言的结构体不能有函数成员,C++可以有。
  • C语言的结构体没有继承关系,C++有。

结构体对齐原则:

  • 原则1:数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行。
  • 原则2:结构(或联合)的整体对齐规则:在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。
  • 原则3:结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。

9、static

  • 修饰局部变量时,使得该变量在静态存储区分配内存;只能在首次函数调用中进行首次初始化,之后的函数调用不再进行初始化;其生命周期与程序相同,但其作用域为局部作用域,并不能一直被访问;
  • 修饰全局变量时,使得该变量在静态存储区分配内存;在声明该变量的整个文件中都是可见的,而在文件外是不可见的;
  • 修饰函数时,在声明该函数的整个文件中都是可见的,而在文件外是不可见的,从而可以在多人协作时避免同名的函数冲突;
  • 修饰成员变量时,所有的对象都只维持一份拷贝,可以实现不同对象间的数据共享;不需要实例化对象即可访问;不能在类内部初始化,一般在类外部初始化,并且初始化时不加static;
  • 修饰成员函数时,该函数不接受this指针,只能访问类的静态成员;不需要实例化对象即可访问。

内存

1、如何避免内存泄漏

内存泄漏情况

  1. 申请内存但是没有释放。

  2. 同一块空间释放两遍,导致崩溃。

```c++ void FunTest() { int pTest1 = (int)malloc(10sizeof(int)); int pTest2 = (int)malloc(10sizeof(int));

pTest1 = pTest2;
free(pTest1);
free(pTest2);

} ```

  1. 程序的误操作,将堆破坏。申请的空间不足以赋值,释放导致崩溃。

c++ void FunTest() { char *pTest1 = (char*)malloc(5); strcpy(pTest1,"hello world"); free(pTest1); }

  1. 当释放时传入的地址和分配时的地址不一样时,会导致崩溃。

c++ void FunTest() { int *pTest1 = (int*)malloc(10*sizeof(int)); assert(pTest1 != NULL); pTest1[0] = 0; pTest1++; //地址向后移动了一位 free(pTest1); }

怎么避免内存泄漏?

  1. 不要手动管理内存,可以尝试在适用的情况下使用智能指针。

  2. 使用string而不是char*。string类在内部处理所有内存管理,而且它速度快且优化得很好。

  3. 在C++中避免内存泄漏的最好方法是尽可能少地在程序级别上进行new和delete调用--最好是没有。任何需要动态内存的东西都应该隐藏在一个RAII对象中,当它超出范围时释放内存。RAII在构造函数中分配内存并在析构函数中释放内存,这样当变量离开当前范围时,内存就可以被释放。

  4. 使用了内存分配的函数,要记得使用其想用的函数释放掉内存。可以始终在new和delete之间编写代码,通过new关键字分配内存,通过delete关键字取消分配内存。

内存泄漏的检测工具

Valgrind——memcheck

2、野指针、栈溢出是什么

野指针

  1. 指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。
char *p; //此时p为野指针
  1. 指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针.
char *p=new char[10];  //指向堆中分配的内存首地址,p存储在栈区
cin>> p;
delete []p; //p重新变为野指针
  1. 指针操作超越了变量的作用范围。
char *p=new char[10]; //指向堆中分配的内存首地址
cin>> p;
cout<<*(p+10); //可能输出未知数据

栈溢出

缓冲区溢出(栈溢出)

​ 程序为了临时存取数据的需要,一般会分配一些内存空间称为缓冲区。如果向缓冲区中写入缓冲区无法容纳的数据,机会造成缓冲区以外的存储单元被改写,称为缓冲区溢出。而栈溢出是缓冲区溢出的一种,原理也是相同的。分为上溢出和下溢出。其中,上溢出是指栈满而又向其增加新的数据,导致数据溢出;下溢出是指空栈而又进行删除操作等,导致空间溢出。

局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。

递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。

指针或数组越界。这种情况最常见,例如进行字符串拷贝,或处理用户输入等等。

解决:

  • 减少栈空间的需求,不要定义占用内存较多的auto变量,应该将此类变量修改成指针,从堆空间分配内存
  • 函数参数中不要传递大型结构/联合/对象,应该使用引用或指针作为函数参数
  • 减少函数调用层次,慎用递归函数,例如A->B->C->A环式调用。

3、让你实现malloc和free的内存分配和释放,你怎么设计,考虑内存碎片问题?

会有内部碎片与外部碎片的问题,内部碎片难以消除(因为字对齐之类的问题),而外部碎片是可以消除的(如果不消除的话,外部的内存块越来越小,虽然数量多了,但是利用率会急剧下降!)

内存池原理

内存池的思想是,在真正使用内存之前,预先申请分配一定数量、大小预设的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存,当内存释放后就回归到内存块留作后续的复用,使得内存使用效率得到提升,一般也不会产生不可控制的内存碎片。

内存池设计

算法原理:

  1. 预申请一个内存区chunk,将内存中按照对象大小划分成多个内存块block
  2. 维持一个空闲内存块链表,通过指针相连,标记头指针为第一个空闲块
  3. 每次新申请一个对象的空间,则将该内存块从空闲链表中去除,更新空闲链表头指针
  4. 每次释放一个对象的空间,则重新将该内存块加到空闲链表头
  5. 如果一个内存区占满了,则新开辟一个内存区,维持一个内存区的链表,同指针相连,头指针指向最新的内存区,新的内存块从该区内重新划分和申请

4、外碎片如何处理?

  • 分配内存和释放的内存尽量在同一个函数中(避免内存泄漏)
  • 尽量一次性申请较大的内存2的指数次幂大小的内存空间,而不要反复申请小内存(少进行内存的分割)
  • 做内存池,也就是自己一次申请一块足够大的空间,然后自己来管理,用于大量频繁地new/delete操作。

5、为什么要重载operator new和operator delete

由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

当你必须要使用new和delete的时候,你不得不控制C++中的内存分配。你需要用重载的全局new和delete代替系统的内存分配符。需要给类重载new和delete。

一个防止内存破碎的方法是从不同固定大小的内存池中分配不同类型的对象。对单个类重载new和delete,你就可以灵活的控制内存分配。

C++11

1、C++中的锁有哪些,你用过哪些

互斥锁(Mutex)

​ 在某一时刻,只有一个线程可以获取互斥锁,在释放互斥锁之前其他线程都不能获取该互斥锁。如果其他线程想要获取这个互斥锁,那么这个线程只能以阻塞方式进行等待。

头文件:< mutex > 类型: std::mutex

用法:在C++中,通过构造std::mutex的实例创建互斥元,调用成员函数lock()来锁定它,调用unlock()来解锁,不过一般不推荐这种做法,标准C++库提供了std::lock_guard类模板,实现了互斥元的RAII惯用语法。

//用互斥元保护列表
#include <list>
#include <mutex>

std::list<int> some_list;
std::mutex some_mutex;

void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(some_mutex);
    some_list.push_back(new_value);
}

条件变量

​ 条件锁就是所谓的条件变量,某一个线程因为某个条件为满足时可以使用条件变量使改程序处于阻塞状态。一旦条件满足以“信号量”的方式唤醒一个因为该条件而被阻塞的线程。最为常见就是在线程池中,起初没有任务时任务队列为空,此时线程池中的线程因为“任务队列为空”这个条件处于阻塞状态。一旦有任务进来,就会以信号量的方式唤醒一个线程来处理这个任务。

头文件:< condition_variable >

类型:std::condition_variable(只和std::mutex一起工作) 和 std::condition_variable_any(符合类似互斥元的最低标准的任何东西一起工作)。

2、右值引用

在C++之中的变量只有左值与右值两种:其中凡是可以取地址的变量就是左值,而没有名字的临时变量,字面量就是右值

正常情况下只能操作 C++ 中的左值,无法对右值添加引用。

int num = 10;
int &b = num; //正确
int &c = 10; //错误

右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

int num = 10;
//int && a = num;  //右值引用不能初始化为左值
int && a = 10;

3、移动语义

​ 在面向对象中,有的类是可以调用拷贝构造函数来拷贝,但有的类的对象或者说类的资源是独有的,比如IO、 unique_ptr等,他们不可以复制,但是可以把资源交出所有权给新的对象,称为可以移动的。

​ std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:static_cast(lvalue);

移动语义通过移动构造函数移动赋值操作符实现,其与拷贝构造函数类似,区别如下:

  • 参数的符号必须为右值引用符号,即为&&。
  • 参数不可以是常量,因为函数内需要修改参数的值
  • 参数的成员转移后需要修改(如改为nullptr),避免临时对象的析构函数将资源释放掉。

移动构造函数:

​ 当用户使用左值(非右值)初始化类对象时,会调用拷贝构造函数。用右值初始化类对象时,会调用移动构造函数,这样可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。

4、完美转发

当我们用一个变量接收右值引用时,根据C++ 标准的定义,这个变量变成了一个左值。那么他永远不会调用接下来函数的右值版本,这可能在一些情况下造成拷贝。

为了解决这个问题 C++ 11引入了完美转发,支持右值判断的推导,若原来是一个右值,那么他转出来就是一个右值,否则为一个左值。

template <typename T>
T&& forward(typename std::remove_reference<T>::type& param)
{
    return static_cast<T&&>(param);
}

template <typename T>
T&& forward(typename std::remove_reference<T>::type&& param)
{
    return static_cast<T&&>(param);
}

第一个是左值引用模板函数,第二个是右值引用模板函数。

紧接着std::forward模板函数对传入的参数进行强制类型转换,转换的目标类型符合引用折叠规则,因此左值参数最终转换后仍为左值,右值参数最终转成右值。

5、智能指针

​ 智能指针主要用于管理在堆上分配的内存,它将普通的指针封装为一个栈对象。当栈对象的生存周期结束后,会在析构函数中释放掉申请的内存,从而防止内存泄漏。

​ C++ 11中最常用的智能指针类型为shared_ptr,它采用引用计数的方法,记录当前内存资源被多少个智能指针引用。该引用计数的内存在堆上分配。当新增一个时引用计数加1,当过期时引用计数减一。只有引用计数为0时,智能指针才会自动释放引用的内存资源。

​ 对shared_ptr进行初始化时不能将一个普通指针直接赋值给智能指针,因为一个是指针,一个是类。可以通过make_shared函数或者通过构造函数传入普通指针。并可以通过get函数获得普通指针。

shared_ptr里引用计数器是怎么实现共享的?

引用计数法的内部实现:

  1. 这个引用计数器保存在某个内部类型中,而这个内部类型对象在shared_ptr第一次构造时以指针的形式保存在shared_ptr中

  2. shared_ptr重载了赋值运算符,在赋值和拷贝另一个shared_ptr时,这个指针被另一个shared_ptr共享

  3. 在引用计数归0时,这个内部类型指针与shared_ptr管理的资源一起释放

  4. 此外,为了保证线程安全,引用计数器的加1和减1都是原子操作,它保证了shared_ptr由多个线程共享时不会爆掉

6、lambda

编写内嵌的匿名函数,替换独立函数或者函数对象

可以通过传值或者引用的方式捕捉其封装作用域内的变量,方括号就是用来定义捕捉模式以及变量。

STL

1、迭代器

通过迭代器可以在不了解容器内部原理的情况下遍历容器,作为容器和算法的连接器。

input output \ / forward | bidirectional | random access

Input Iterator:只能单步向前迭代元素,不允许修改由该类迭代器引用的元素。

Output Iterator:该类迭代器和Input Iterator极其相似,也只能单步向前迭代元素,不同的是该类迭代器对元素只有写的权力。

Forward Iterator:该类迭代器可以在一个正确的区间中进行读写操作,它拥有Input Iterator的所有特性,和Output Iterator的部分特性,以及单步向前迭代元素的能力。

Bidirectional Iterator:该类迭代器是在Forward Iterator的基础上提供了单步向后迭代元素的能力。

Random Access Iterator:该类迭代器能完成上面所有迭代器的工作,它自己独有的特性就是可以像指针那样进行算术计算,而不是仅仅只有单步向前或向后迭代。