C/C++面经

C/C++面经

Q1. 结构体大小

Q2. 什么是内存对齐?为什么要内存对齐?

结构体的sizeof涉及到字节对齐问题
为什么需要字节对齐?计算机组成原理教导我们这样有助于加快计算机的取数速度,否则就得多花指令周期了。

字节对齐的细节和编译器的实现相关,但一般而言,满足三个准则:

1) 结构体变量的首地址能够被其最宽基本类型成员的大小所整除。

2) 结构体的每个成员相对于结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要,编译器会在成员之间加上填充字节(internal adding)。

3) 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要,编译器会在最末一个成员后加上填充字节(trailing padding)。

关于pragma pack的使用
例如:

#pragma pack(1)

struct sample
{
char a;
double b;
};

#pragma pack()

注:若不用#pragma pack(1)和#pragma pack()括起来,则sample按编译器默认方式对齐(成员中size最大的那个)。即按8字节(double)对齐,则sizeof(sample)==16.成员char a占了8个字节(其中7个是空字节);若用#pragma pack(1),则sample按1字节方式对齐sizeof(sample)==9.(无空字节),比较节省空间啦,有些场和还可使结构体更易于控制。

应用实例
在网络协议编程中,经常会处理不同协议的数据报文。一种方法是通过指针偏移的方法来得到各种信息,但这样做不仅编程复杂,而且一旦协议有变化,程序修改起来也比较麻烦。在了解了编译器对结构空间的分配原则之后,我们完全可以利用这一特性定义自己的协议结构,通过访问结构的成员来获取各种信息。这样做,不仅简化了编程,而且即使协议发生变化,我们也只需修改协议结构的定义即可,其它程序无需修改,省时省力。下面以TCP协议首部为例,说明如何定义协议结构。其协议结构定义如下:

#pragma pack(1) // 按照1字节方式进行对齐
struct TCPHEADER
{
short SrcPort; // 16位源端口号
short DstPort; // 16位目的端口号
int SerialNo; // 32位序列号
int AckNo; // 32位确认号
unsigned char HaderLen : 4; // 4位首部长度
unsigned char Reserved1 : 4; // 保留6位中的4位
unsigned char Reserved2 : 2; // 保留6位中的2位
unsigned char URG : 1;
unsigned char ACK : 1;
unsigned char PSH : 1;
unsigned char RST : 1;
unsigned char SYN : 1;
unsigned char FIN : 1;
short WindowSize; // 16位窗口大小
short TcpChkSum; // 16位TCP检验和
short UrgentPointer; // 16位紧急指针
};
#pragma pack()

Q3. C语言中的堆和栈

C语言程序经过编译连接后形成编译、连接后形成的二进制映像文件由栈,堆,数据段(由三部分部分组成:只读数据段,已经初始化读写数据段,未初始化数据段即BBS)和代码段组成

栈区
由编译器自动分配释放,存放函数的参数值、局部变量的值等。其操作方式类似于数据结构中的栈。每当一个函数被调用,该函数返回地址和一些关于调用的信息,比如某些寄存器的内容,被存储到栈区。然后这个被调用的函数再为它的自动变量和临时变量在栈区上分配空间,这就是C实现函数递归调用的方法。每执行一次递归函数调用,一个新的栈框架就会被使用,这样这个新实例栈里的变量就不会和该函数的另一个实例栈里面的变量混淆。

堆区
用于动态内存分配。一般由程序员分配和释放,若程序员不释放,程序结束时有可能由OS回收。

区别
(1)申请方式和回收方式不同

栈由系统自动分配,堆需程序员自己申请,并指明大小,并由程序员进行释放。

栈是向低地址扩展的数据结构,是一块连续的内存区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,当申请的空间超过栈的剩余空间时,将提示溢出。因此,用户能从栈获得的空间较小。

堆是向高地址扩展的数据结构(它的生长方向与内存的生长方向相同),是不连续的内存区域。因为系统是用链表来存储空闲内存地址的,且链表的遍历方向是由低地址向高地址。由此可见,堆获得的空间较灵活,也较大。

(2)申请后系统的响应

栈:只要栈的空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。

堆:操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的free语句才能正确的释放本内存空间。另外,找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

对于堆来讲,频繁的malloc/free势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈就不会存在这个问题。

(3)存储内容
栈: 在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。 当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。

堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。

(4)存取效率
栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。

堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

Q4. static关键字

(1) 全局静态变量
在全局变量前加上关键字static,全局变量就定义成一个全局静态变量.

静态存储区,在整个程序运行期间一直存在。

初始化:未经初始化的全局静态变量会被自动初始化为0(自动对象的值是任意的,除非他被显式初始化);

作用域:全局静态变量在声明他的文件之外是不可见的,准确地说是从定义之处开始,到文件结尾。

(2)局部静态变量

在局部变量之前加上关键字static,局部变量就成为一个局部静态变量。

内存中的位置:静态存储区

初始化:未经初始化的局部静态变量会被自动初始化为0(自动对象的值是任意的,除非他被显式初始化);

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

(3)静态函数

在函数返回类型前加static,函数就定义为静态函数。函数的定义和声明在默认情况下都是extern的,静态函数只是在声明他的文件当中可见,不能被其他文件所用

函数的实现使用static修饰,那么这个函数只可在本cpp内使用,不会同其他cpp中的同名函数引起冲突;

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

(4)类的静态成员

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

(5) 类的静态函数

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

在静态成员函数的实现中不能直接引用类中说明的非静态成员,可以引用类中说明的静态成员(这点非常重要)。如果静态成员函数中要引用非静态成员时,可通过对象来引用。从中可看出,调用静态成员函数使用如下格式:<类名>::<静态成员函数名>(<参数表>);

Q5. 野指针是什么?

野指针”的成因主要有:

1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存

char *p; //此时p为野指针

2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针

char *p=new char[10];  //指向堆中分配的内存首地址,p存储在栈区
cin>> p;
delete []p; //p重新变为野指针

3)指针操作超越了变量的作用范围

char *p=new char[10]; //指向堆中分配的内存首地址
cin>> p;
cout<<*(p+10); //可能输出未知数据

Q6. 数组和链表分别用在什么场景

数组应用场景:
数据比较少;经常做的运算是按序号访问数据元素;数组更容易实现,任何高级语言都支持;构建的线性表较稳定。

链表应用场景:
对线性表的长度或者规模难以估计;频繁做插入删除操作;构建动态性比较强的线性表。

Q7. 指针函数和函数指针

指针函数
其本质是一个函数,而该函数的返回值是一个指针

指针函数多用于链表、树的结构,用于返回一个指向目标节点的指针

函数指针
其本质是一个指针变量,该指针指向这个函数。总结来说,函数指针就是指向函数的指针。

声明格式:类型说明符 (*函数名) (参数)如下:

int (*fun)(int x,int y);

函数指针是需要把一个函数的地址赋值给它,有两种写法:

fun = &Function;
fun = Function;

取地址运算符&不是必需的,因为一个函数标识符就表示了它的地址,如果是函数调用,还必须包含一个圆括号括起来的参数表。

调用函数指针的方式也有两种:

x = (*fun)();
x = fun();

两种方式均可,其中第二种看上去和普通的函数调用没啥区别,如果可以的话,建议使用第一种,因为可以清楚的指明这是通过指针的方式来调用函数。当然,也要看个人习惯,如果理解其定义,随便怎么用都行啦。

Q8. C++多态

多态是C++特性之一,多态性就是使用相同的接口实现不同的方法。多态性分为静态多态性和动态多态性。

在C++中,多态性的实现和联编(或称绑定)这一概念有关。一个源程序经过编译、链接,成为可执行文件的过程是把可执行代码联编在一起的过程。其中在编译时完成的联编称为静态联编(前期联编);而在运行时完成的联编称为动态联编(后期联编)。

在这里插入图片描述

静态多态性
静态多态性又可以称为编译时多态性,在C++中,静态多态性是通过函数重载和模板实现的。
例如函数重载机制,编译器会根据调用函数时实参的使用来确定调用的是哪个函数,如果有合适的函数可以调用就调,没有的话就会发出警告或者报错;而模板类和模板函数也是在编译时判断typename来调用不同的函数/类。

动态多态性
动态多态性又称为运行时多态性,它是在程序运行时根据基类的引用(指针)指向的对象来确定自己具体该调用哪一个类的虚函数。动态多态性是通过虚函数实现的。

动态多态的条件:

  • 基类中必须包含虚函数,并且派生类中一定要对基类中的虚函数进行重写。
  • 通过基类对象的指针或者引用调用虚函数。

虚函数

  • 虚函数在C++中指被virtual关键字修饰的函数。
  • 允许父类的指针调用子类的虚函数,子类当中的函数需要对父类的函数实现重写,注意重写的概念与重载不同,重写需要子类函数的函数名、参数、返回值均与父类函数相同,才可以实现重写(协变和析构函数除外)。
    (这里解释一些什么是协变:基类(或者派生类)的虚函数返回基类(派生类)的指针(引用))
  • 在子类中实现父类中的虚函数时,会告诉编译器不要静态链接到该函数,而是在程序中根据调用对象的类型来选择调用的函数,称为动态联编或后期绑定,也就是说一个类的虚函数的调用不是在编译时刻确定的,而是在运行时刻确定的。
  • 如果子类的函数有virtual修饰,但是父类没有,会造成函数隐藏。
  • 父类的析构函数应当是虚函数,这样的话在调用析构时,会先调用到对应子类的析构函数,再调用父类的析构函数。此时子类的析构函数和父类的析构函数由于函数名不同,看似不符合上面的重写规则,可以理解为在编译的时候对析构函数做了特殊的处理,可以达到依次析构的目的。

哪些函数不能定义为虚函数?
1)友元函数,它不是类的成员函数
2)全局函数
3)静态成员函数,它没有this指针
3)构造函数,拷贝构造函数,以及赋值运算符重载(可以但是一般不建议作为虚函数)

纯虚函数

  • 在很多情况下,一个父类生成对象是不合理的,例如:动物作为父类不应该生成对象,但是作为动物的子类猫狗等可以生成对象。为了解决这个问题便引入纯虚函数。
  • 编译器要求所有子类必须对纯虚函数有自己的实现方式,以实现多态性。
  • 包含纯虚函数的类称为抽象类,抽象类不能生成对象。纯虚函数在派生类中重新定义以后,派生类才能实例化出对象。
  • 定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
  • 纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。
  • 定义一个函数为虚函数不代表它没有实现,定义为纯虚函数才代表没有实现。

在成员函数(必须为虚函数)的形参列表后面写上=0,则成员函数为纯虚函数。

抽象类

  • 带有纯虚函数的类称为抽象类,抽象类不能生成对象。
  • 抽象类的主要作用是提供接口,这个接口是其所有子类的公共根,用来规范子类的函数定义,函数的具体实现将在每个子类中分别进行。

派生类虚表:

  • 先将基类的虚表中的内容拷贝一份
  • 如果派生类对基类中的虚函数进行重写,使用派生类的虚函数替换相同偏移量位置的基类虚函数
  • 如果派生类中新增加自己的虚函数,按照其在派生类中的声明次序,放在上述虚函数之后

Q9. map底层实现

map成员
在这里插入图片描述

  • TreeMap是基于树(红黑树)的实现方式,即添加到一个有序列表,在 O ( l o g n ) O(logn) 的复杂度内通过key值找到value,优点是空间要求低,但在时间上不如HashMap。C++中Map的实现就是基于这种方式
  • HashMap是基于HashCode的实现方式,在查找上要比TreeMap速度快,添加时也没有任何顺序,但空间复杂度高。C++ unordered_Map就是基于该种方式。
  • HashTable与HashMap类似,只是HashMap是线程不安全的,HashTable是线程安全的,现在很少使用
  • ConcurrentHashMap也是线程安全的,但性能比HashTable好很多,HashTable是锁整个Map对象,而ConcurrentHashMap是锁Map的部分结构

HashMap详解
HashMap简称哈希表,下面介绍下主要思想和流程。

HashMap在添加值是需要给定两个参数,一个是key,一个是value。为了能很快的通过key值找到对应的value,因此有必要建立一个key值和内存指针的映射,举个简单的例子,如果说key值是int型,那么其实最简单的方式就是定义一个数组,以这个key值作为下标,value作为内存中的值。然而由于key值可能会很大,或者是string或着其他类型的值,因此就不能单纯的简单对应了,这时候就需要做一个转换。这个在Java和C#中是通过一个int HashCode()的函数实现的。具体的实现可能是通过地址、字符串或数字算出来的值,然后如果是自己定义的对象,则需要自己实现HashCode()和equal()。

注意,hashcode的实现需要满足以下要求:

  • 如果两个对象equals相等,那么这两个对象的HashCode一定也相同
  • 如果两个对象的HashCode相同,不代表两个对象就相同,只能说明这两个对象在散列存储结构中,存放于同一个位置

那么在计算出hashcode之后再怎么做呢,由于hashcode算出来的值可能很大,定义一个大小能包含所有hashcode的数组显然是不合理的。在实际的实现是这样的,事先定义一个大小为2的幂次方的数组(稍后解释为什么是2的幂次方)。为了能保证所有的hashcode都能对应到数组的下标,可以采用hashcode对数组大小(一般称为bucket)取余的方式。而具体的实现就是:

static int indexFor(int h, int length) {  
    return h & (length-1);
}

通过按位与运算巧妙的求得了余数,并且很大程度上减少了运算效率。但由于可能会有多个key值对应同一个index,为了避免冲突,其实每个数组元素里存储的是链表结构。当添加函数检测到index对应的元素已经有值了以后,它就会将key值和value作为子节点添加到该index所在元素的尾部节点。如果检测到key值相同,则更新value。

当链表的长度大于8后,会自动转为红黑树,方便查找。如果HashMap里的元素越来越多,那么冲突的概率会越来越大,因此有必要即时的对数组长度扩容。当HashMap中的元素个数超过数组大小(数组总大小length)loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12(这个值就是代码中的threshold值,也叫做临界值)的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。扩容的操作是这样的:

int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;

这个就表示,每次扩容都是在原有的基础上×2,这也就是为什么大小是2的幂次的原因。

红黑树
关于红黑树的细致内容可以参阅 红黑树

这里主要讲一下红黑树和AVL树的区别:

AVL树是严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过1)。不管我们是执行插入还是删除操作,只要不满足上面的条件,就要通过旋转来保持平衡,而旋转又非常耗时,由此我们可以知道AVL树适合用于插入与删除次数比较少,但查找多的情况。

由于维护这种高度平衡所付出的代价比从中获得的效率收益还大,故而实际的应用不多,更多的地方是用追求局部而不是非常严格整体平衡的红黑树。当然,如果应用场景中对插入删除不频繁,只是对查找要求较高,那么AVL还是较优于红黑树。

平衡二叉树追求绝对平衡,条件比较苛刻,实现起来比较麻烦,每次插入新节点之后需要旋转的次数不能预知。

红黑树通过对任何一条从根到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍,因此,红黑树是一种弱平衡二叉树(由于是弱平衡,可以看到,在相同的节点情况下,AVL树的高度低于红黑树),相对于要求严格的AVL树来说,它的旋转次数少,所以对于搜索,插入,删除操作较多的情况下,我们就用红黑树。

红黑树放弃了追求完全平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单。

Q11. epoll

epoll_wait epoll_create epoll_ctl
epoll原理详解及epoll反应堆模型
(缓冲区水位)
select 和 epoll的区别总结(重点关注LT和ET的区别)

Q12. 惊群问题

惊群问题

Q13. TCP三次握手、四次握手

TCP三次握手和四次挥手过程

Q14. TCP四种定时器

(1)重传定时器:

重传定时器:为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。当TCP发送报文段时,就创建这个特定报文段的重传定时器,可能发生两种情况:若在定时器超时之前收到对报文段的确认,则撤销定时器;若在收到对特定报文段的确认之前定时器超时,则重传该报文,并把定时器复位;

= 2 R T T 重传时间=2*RTT

RTT的值应该动态计算。常用的公式是: R T T = p r e v i o u s R T T i + 1 i c u r r e n t R T T RTT=previous RTT*i + (1-i)* current RTT

i 的值通常取90%,即新的RTT是以前的RTT值的90%加上当前RTT值的10%.

Karn算法:对重传报文,在计算新的RTT时,不考虑重传报文的RTT。因为无法推理出:发送端所收到的确认是对上一次报文段的确认还是对重传报文段的确认。干脆不计入。

(2)坚持定时器:

专门对付零窗口通知而设立的

先来考虑一下情景:发送端向接收端发送数据包知道接受窗口填满了,然后接受窗口告诉发送方接受窗口填满了停止发送数据。此时的状态称为“零窗口”状态,发送端和接收端窗口大小均为0。直到接受TCP发送确认并宣布一个非零的窗口大小。但这个确认会丢失。

我们知道TCP中,对确认是不需要发送确认的。若确认丢失了,接受TCP并不知道,而是会认为他已经完成了任务,并等待着发送TCP接着会发送更多的报文段。但发送TCP由于没有收到确认,就等待对方发送确认来通知窗口大小。双方的TCP都在永远的等待着对方。

要打开这种死锁,TCP为每一个链接使用一个持久计时器。当发送TCP收到窗口大小为0的确认时,就坚持启动计时器。当坚持计时器期限到时,发送TCP就发送一个特殊的报文段,叫做探测报文。这个报文段只有一个字节的数据。他有一个序号,但他的序号永远不需要确认;甚至在计算机对其他部分的数据的确认时该序号也被忽略。

探测报文段提醒接受TCP:确认已丢失,必须重传。

坚持计时器的值设置为重传时间的数值。但是,若仍没有收到从接收端来的响应,则需发送另一个探测报文段,并将坚持计时器的值加倍和复位。重复此过程,直到这个值增大到门限值(通常是60秒)为止。在这以后,发送端每个60秒就发送一个探测报文,直到窗口重新打开。

(3)保活计时器

保活计时器使用在某些实现中,用来防止在两个TCP之间的连接出现长时间的空闲。假定客户打开了到服务器的连接,传送了一些数据,然后就保持静默了。也许这个客户出故障了。在这种情况下,这个连接将永远的处理打开状态。

要解决这种问题,在大多数的实现中都是使服务器设置保活计时器。每当服务器收到客户的信息,就将计时器复位。通常设置为两小时。若服务器过了两小时还没有收到客户的信息,他就发送探测报文段。若发送了10个探测报文段(每一个75秒)还没有响应,就假定客户除了故障,因而就终止了该连接。这种连接的断开当然不会使用四次握手,而是直接硬性的中断和客户端的TCP连接。

(4)时间等待计时器

时间等待计时器是在四次握手的时候使用的。

四次握手的简单过程是这样的:假设客户端准备中断连接,首先向服务器端发送一个FIN的请求关闭包(FIN=final),然后由established过渡到FIN-WAIT1状态。服务器收到FIN包以后会发送一个ACK,然后自己有established进入CLOSE-WAIT. 此时通信进入半双工状态,即留给服务器一个机会将剩余数据传递给客户端,传递完后服务器发送一个FIN+ACK的包,表示我已经发送完数据可以断开连接了,就这便进入LAST_ACK阶段。客户端收到以后,发送一个ACK表示收到并同意请求,接着由FIN-WAIT2进入TIME-WAIT阶段。服务器收到ACK,结束连接。此时(即客户端发送完ACK包之后),客户端还要等待2MSL(MSL=maxinum segment lifetime最长报文生存时间,2MSL就是两倍的MSL)才能真正的关闭连接。

Q15. 快排优化、二路快排、三路快排

在这里插入图片描述

排序算法之——三路快排分析
快排优化:随机快排、双路快排、三路快排

Q16. 头文件的作用,里面放什么

头文件的作用

  • 通过头文件来调用库功能。提供保密和代码重用的手段。
  • 减少代码的重复书写,提高编写和修改程序的效率。

里面放了什么

该文件包含了对程序中用到的所有函数的声明,即,只能在头文件中写形如:extern int a;和void f();的句子。但有三个规则是例外的:

  1. 头文件中可以写const对象的定义。因为全局的const对象默 认是没有extern的声明的,所以它只在当前文件中有效。把这样的对象写进头文件中,即使它被包含到其他多个.cpp文件中,这个对象也都只在包含它的 那个文件中有效,对其他文件来说是不可见的,所以便不会导致多重定义。同时,因为这些.cpp文件中的该对象都是从一个头文件中包含进去的,这样也就保证 了这些.cpp文件中的这个const对象的值是相同的,可谓一举两得。同理,static对象的定义也可以放进头文件。
  2. 头文件中可 以写内联函数(inline)的定义。因为inline函数是需要编译器在遇到它的地方根据它的定义把它内联展开的,而并非是普通函数那样可以先声明再链 接的(内联函数不会链接),所以编译器就需要在编译时看到内联函数的完整定义才行。如果内联函数像普通函数一样只能定义一次的话,这事儿就难办了。因为在 一个文件中还好,我可以把内联函数的定义写在最开始,这样可以保证后面使用的时候都可以见到定义;但是,如果我在其他的文件中还使用到了这个函数那怎么办 呢?这几乎没什么太好的解决办法,因此C++规定,内联函数可以在程序中定义多次,只要内联函数在一个.cpp文件中只出现一次,并且在所有的.cpp文 件中,这个内联函数的定义是一样的,就能通过编译。那么显然,把内联函数的定义放进一个头文件中是非常明智的做法。
  3. 头文件中可以写类 (class)的定义。因为在程序中创建一个类的对象时,编译器只有在这个类的定义完全可见的情况下,才能知道这个类的对象应该如何布局,所以,关于类的 定义的要求,跟内联函数是基本一样的。所以把类的定义放进头文件,在使用到这个类的.cpp文件中去包含这个头文件,是一个很好的做法。在这里,值得一提 的是,类的定义中包含着数据成员和函数成员。数据成员是要等到具体的对象被创建时才会被定义(分配空间),但函数成员却是需要在一开始就被定义的,这也就 是我们通常所说的类的实现。一般,我们的做法是,把类的定义放在头文件中,而把函数成员的实现代码放在一个.cpp文件中。这是可以的,也是很好的办法。 不过,还有另一种办法。那就是直接把函数成员的实现代码也写进类定义里面。在C++的类中,如果函数成员在类的定义体中被定义,那么编译器会视这个函数为 内联的。因此,把函数成员的定义写进类定义体,一起放进头文件中,是合法的。注意一下,如果把函数成员的定义写在类定义的头文件中,而没有写进类定义中, 这是不合法的,因为这个函数成员此时就不是内联的了。一旦头文件被两个或两个以上的.cpp文件包含,这个函数成员就被重定义了。

#include

#include 是一个来自C语言的宏命令,它在编译器进行编译之前,即在预编译的时候就会起作用。

#include的作用是把它后面所写的那个文件的内容,完完整整地、 一字不改地包含到当前的文件中来。简单的文本替换,别无其他。如,main.cpp文件中的第一句#include “math.h”,在编译之前就会被替换成math.h文件的内容。

头文件的使用

  • 如果头文件名包含在<>中,那么认为该头文件是标准头文件。
    编译器将会在预定义的位置集查找该头文件,这些预定义的位置可以通过设置查找路径环境变量或者通过命令行选项来修改。
  • 如果头文件名包含在" "中,那么认为它是非系统文件,非系统文件的查找通常开始于源文件所在的路径。

Q17. 一个程序在执行main函数前干了啥

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

Q18. new和malloc的区别

两者实现原理

new的实现原理:new的底层就是malloc,它会先调用malloc申请内存空间,然后再调用析构函数释放内存。

malloc的实现原理:malloc函数的实质体现在,它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表的功能。调用malloc函数时,它沿连接表寻找一个大到足以满足用户请求所需要的内存块。然后,将该内存块一分为二(一块的大小与用户请求的大小相等,另一块的大小就是剩下的字节)。接下来,将分配给用户的那块内存传给用户,并将剩下的那块(如果有的话)返回到连接表上。调用free函数时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要求的片段了。

为了减少内存碎片和系统调用的开销,malloc采用了内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。同时malloc采用链表结构来管理所有空闲块,即使用一个双向链表将空闲链表连接起来,每一个空闲块记录了一个连续、未分配的地址。

当内存进行分配时,malloc会通过链表遍历所有的空闲板块,选择满足要求的块进行分配。当进行内存合并时,malloc采用边界标记法,根据每个块前后块是否已经分配来决定是否进行块合并。

malloc在申请内存时,一般会通过brk或者mmap系统调用进行申请。其中当申请内存小于128K时,会使用系统函数brk在堆区中分配;而当申请内存大于128K时,会使用系统函数mmap在映射区分配。

二者区别

(1)new/delete是C++的操作符,需要编译器支持。 malloc/free是库函数,需要头文件的支持

(2)new可以调用对象的构造函数,对应的delete调用相应的析构函数,而malloc仅仅分配内存,free仅仅回收内存,并不执行构造和析构函数;

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

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

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

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

(3)使用new操作符申请内存分配时无需指定内存块的大小,编译器会根据其信息自行计算,且需要用户自己初始化。而malloc则需要显示地指出所需内存的大小,会自行初始化

(4)new操作符内存分配成功时,返回的类型是指针,类型严格与对象匹配,无需转换,所以new是符合类型的安全操作符。而malloc返回的是void*,需要通过强制类型转换,强void*转换成我们需要的类型。

(5)opeartor new /operator delete可以被重载。标准库是定义了operator new函数和operator delete函数的8个重载版本。而malloc/free并不允许重载。

(6)C++提供了new[]与delete[]来专门处理数组类型。new对数组的支持体现在它会分别调用构造函数初始化每一个数组元素,释放对象时为每个对象调用析构函数。注意delete[]要与new[]配套使用,不然会导致数组对象部分释放的现象,造成内存泄漏。至于malloc,它并不知道你在这块内存上要放的数组还是啥别的东西,反正它就给你一块原始的内存,再给你个内存的地址就完事。所以如果要动态分配一个数组的内存,还需要我们手动自定数组的大小

这里有一个知识点,就是调用delete[]时并未指定要删除的大小,那么delete是怎么知道要释放多少呢?这是因为,在new[]一个数组对象时,C++在分配数组空间时多分配了4个字节的大小,专门用来保存数组的大小,这4个字节的空间在数组的前面,而new[]返回的指针则指向第一个元素。在delete[]时就可以从数组之前取出这个保存的数,就知道需要调用析构函数的次数了。

(7)new内存分配失败时,会抛异常,即出现bac_alloc现象异常。 malloc分配内存失败时返回NULL

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

delete和free被调用后,内存不会立即回收,指针也不会指向空,delete或free仅仅是告诉操作系统,这一块内存被释放了,可以用作其他用途。但是由于没有重新对这块内存进行写操作,所以内存中的变量数值并没有发生变化,这时候就会出现野指针的情况。因此,释放完内存后,应该把指针指向NULL。

Q19. 如果new之后用free会怎样

new()函数实际过程中做了两步操作,第一步是分配内存空间,第二步是调用类的构造函数;delete()也同样是两步,第一步是调用类的析构函数,第二步才是释放内存;而malloc()和free()仅仅是分配内存与释放内存操作

那么如果通过new分配的内存,再用free去释放,就会少一步调用析构函数的过程。同时,在构造函数里面申请的内存因为没有调用析构函数,所以该内存并没有释放

Q20. delete该用方括号的时候不用怎么样

分两种情况:基本数据类型的分配和自定义数据类型的分配

基本数据类型

对于基本数据类型,假如有如下代码

int *a = new int[10];
...
delete a;    // 方式1
delete [ ] a;    //方式2

肯定会不少人认为方式1存在内存泄露,然而事实上是不会!针对简单的基本数据类型,方式1和方式2均可正常工作,因为:基本的数据类型对象没有析构函数,并且new 在分配内存时会记录分配的空间大小,则delete时能正确释放内存,无需调用析构函数释放其余指针。因此两种方式均可。

自定义数据类型

这里一般指类,假设通过new申请了一个对象数组,注意是对象数组,返回一个指针,对于此对象数组的内存释放,需要做两件事情:一是释放最初申请的那部分空间,二是调用析构函数完成清理工作。对于内存空间的清理,由于申请时记录了其大小,因此无论使用delete还是delete[ ]都能将这片空间完整释放,而问题就出在析构函数的调用上,当使用delete时,仅仅调用了对象数组中第一个对象的析构函数,而使用delete [ ]的话,将会逐个调用析构函数。
在这里插入图片描述

Q21. 访问数组下标为-1的位置会怎么样

数组下标为-1的地址对于数组来说是越界访问了,但是这个地址是有意义的

这个地址就是所申请的数组存储空间的首地址的向前偏移一个单位(也就是偏移一个当前数组类型所对应的字节数)所对应的地址。

这个地址由于没有跟着数组空间一起初始化,所以其中的数据是不一定的,
如果是正在被系统使用中的地址空间,那么可以被访问,其中的数据的意义取决于被系统所写入的数据,但是访问后,有可能会引起系统异常。

如果是没有被使用的地址,那么就是一个野地址,那么其中的数据是随机的,无意义的。

for example

判断数组尾部是否存在换行符,如存在则用结束符代替:

  char  resp[256];
  char *e = resp + strlen(resp);
  if (e > resp && e[-1] == '\n')
   e[-1] = '\0';                

检测数组越界

if (size > 0) {
	if (str < end) *str = '\0';
	else end[-1] = '\0';

其中end是定义的内存空间的结束位置,如果访问越界了,就在结束前一个位置添加一个字符串结束符。

Q22. C++ set实现原理

C++ STL 的 set 和 map 容器底层都是由红黑树(RB tree)来实现的

Q23. 为何map和set的插入删除效率比用其他序列容器高

很简单,因为对于关联容器来说,不需要做内存拷贝和内存移动。说对了,确实如此。map和set容器内所有元素都是以节点的方式来存储,其节点结构和链表差不多,指向父节点和子节点。因此插入的时候只需要稍做变换,把节点的指针指向新的节点就可以了。删除的时候类似,稍做变换后把指向删除节点的指针指向其他节点就OK了。这里的一切操作就是指针换来换去,和内存移动没有关系。

Q24. 对map和set,为何每次insert之后,以前保存的iterator不会失效

看见了上面答案的解释,你应该已经可以很容易解释这个问题。

iterator这里就相当于指向节点的指针,内存没有变,指向内存的指针怎么会失效呢(当然被删除的那个元素本身已经失效了)。

相对于vector来说,每一次删除和插入,指针都有可能失效,调用push_back在尾部插入也是如此。因为为了保证内部数据的连续存放,iterator指向的那块内存在删除和插入过程中可能已经被其他内存覆盖或者内存已经被释放了。即使是push_back,容器内部空间可能不够,需要一块新的更大的内存,只有把以前的内存释放,申请新的更大的内存,复制已有的数据元素到新的内存,最后把需要插入的元素放到最后,那么以前的内存指针自然就不可用了。

Q25. 为何map和set不能像vector一样有个reserve函数来预分配数据

map和set内部存储的已经不是元素本身了,而是包含元素的节点

Q26. 当数据元素增多时(10000和20000个比较),map和set的插入和搜索速度变化如何?

在map和set中查找是使用二分查找,如果有10000个元素,最多比较的次数为 l o g 2 10000 log_210000 ,最多为14次;如果是20000个元素,最多不过15次。 当数据量增大一倍的时候,搜索次数只不过多了1次,多了1/14的搜索时间而已

Q27. vector和list的原理

Vector底层实现为:顺序表,相当于一个数组,但是与数组的区别为:内存空间的扩展

STL内部实现时,首先分配一个非常大的内存空间预备进行存储,即capacity()函数返回的大小,当超过此分配的空间时再整体重新放分配一块内存存储(通常会是两倍)

List底层实现为:带头节点的双向循环链表(优点:增删速度快,时间复杂度为O(1))

vector中的iterator在使用后就释放了,但是链表list不同,它的迭代器在使用后还可以继续用

使用场景

vector拥有一段连续的内存空间,因此支持随机访问,如果需要高效的随即访问,而不在乎插入和删除的效率,使用vector

list拥有一段不连续的内存空间,如果需要高效的插入和删除,而不关心随机访问,则应使用list

Q28. select和epoll

select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

select(轮询)

该模型轮询各socket,不管socket是否活跃,随着socket数的增加,性能逐渐下降。

调用时轮询一次所有描述字,超时时再轮询一次。如果没有描述字准备好,则返回0;中途错误返回-1;有描述字准备好,则将其对应位置为1,其他描述字置为0,返回准备好的描述字个数。

select 选择句柄的时候,是遍历所有句柄,也就是说句柄有事件响应时,select需要遍历所有句柄才能获取到哪些句柄有事件通知,因此效率是非常低。但是如果连接很少的情况下, select和epoll相比, 性能上差别不大。

epoll(触发)

epoll采用了中断注册回调的方式,socket IO就绪时发出中断,然后将socket加入就绪队列。由三个系统调用:epoll_create,epoll_ctl,epoll_wait。

能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率:它会复用文件描述符集合来传递结果,不需要每次等待事件之前都重新准备要被侦听的文件描述符集合;获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合。

对比

select 的几大缺点是:

(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

(3)select支持的文件描述符数量太小了,默认是1024

epoll是如何解决上面三个问题的呢?

epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果)。

对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于1024

select、poll、epoll之间的区别总结[整理] + 知乎大神解答

那是不是epoll一定比select高效啊

不是的,select适用于连接少,活动连接多的情况;而epoll适用于连接多,活动连接少的情况

Q28. C++内联函数

内联函数是什么

内联函数,它是一种编程语言结构,但与我们的普通函数不同,这种结构是给编译器用的,在编译过程发挥作用。

那怎么发挥作用呢?也就是编译器会如何使用内联函数呢?总体上讲有把它展开(类似带参宏展开)和把它当做普通函数看待(此时失去内联作用)两种。它类似带参宏定义展开,也就是在编译过程中他会以一种复制黏贴内联函数体内部代码的方式到调用处展开,所以会使得代码总量有些增加。然后,在调用处展开了之后,它就会像其他非函数调用的代码那样被执行,但此时由于不是函数调用,所以就没有了函数调用固有开销(如参数传递、保存上下文)。而后者应该是编译器的一些优化设计。

为什么需要内联函数

当函数体十分简短,我们调用它时函数调用的固有开销(参数传递、保存上下文)会大于函数体内部代码执行所占的开销。也就是说我们要调用一个函数,在真正执行函数体内部代码之前所做的准备工作所占时长都比我们真正执行函数体内部代码所占的时长还多了。那样的话调用这个函数就有些不值得啦,一次两次看不出,但频繁调用的话就会很浪费资源了。

那怎么办?直接用一句两句代码(非函数调用)实现就可以了。但我们又想要层次结构分明之类的,又或者这些与参数有关之类的,直接代码写死不好,那怎么办?那就我们主角——内联函数出场了。

内联函数怎么用

以下情况适合使用内联函数
(1)函数体很简短,里面没有循环、switch等复杂的结构控制语句。
(2)函数没有直接递归调用自身。

内联函数与(带参宏)宏定义的区别

(1)内联函数在编译过程展开,而宏定义在预编译过程展开。
(2)宏定义是简单的文本替换,而内联函数是直接被嵌入到目标代码中去的。
(3)使用宏定义时要小心处理宏参数,一般要用括号括起来,否则容易出现二义性。而内联函数没有这种二义性。
(4)宏展开是不作参数类型检查的,而内联函数是会作参数类型检查且还有返回值的类型检查。

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

(1)普通函数调用需要到函数入口地址去执行,而内联函数不用寻址直接在那儿执行就可以了。
(2)内联函数有一定的限制,参考上面的“怎么使用内联函数”。

Q29. 父类指针或引用指向子类对象,可以访问子类成员吗

父类指针既可以指向父类对象,也可以指向子类对象

当父类指针指向父类对象时,访问父类的成员

当父类指针指向子类对象时,那么只能访问子类中从父类继承下来的那部分成员,不能访问子类独有的成员,如果访问,编译阶段会报错

Q30. 如何判断一段程序是由C编译程序还是C++编译程序编译的?

#ifdef __cplusplus
	cout << "C++" << endl;
#else
	cout << "C" << endl;
#endif

如何判断一段程序是由C编译程序还是C++编译程序编译的?

Q31. 两个栈实现队列

剑指offer - 用两个栈实现队列

Q32. 最小时间复杂度匹配子串

单独只说一点: n e x t [ j ] next[j] 表示档模式中第 j 个字符与主串中相应字符“失配”时, 在模式中需要重新和主串中该字符进行比较的字符的位置

KMP 算法

Q33. 操作系统中的消费者/生产者模型

进程同步之生产者消费者模型

Q34. 互斥锁

互斥锁是一个二元变量,其状态为开锁(允许0)和上锁(禁止1),将某个共享资源与某个特定互斥锁在逻辑上绑定(要申请该资源必须先获取锁)。

(1)访问公共资源前,必须申请该互斥锁,若处于开锁状态,则申请到锁对象,并立即占有该锁,以防止其他线程访问该资源;如果该互斥锁处于锁定状态,则阻塞当前线程。

(2)只有锁定该互斥锁的进程才能释放该互斥锁,其他线程试图释放无效。

Q35. 哈希表原理及冲突解决

哈希表原理

Q36. 虚拟地址空间由哪些组成

虚拟地址空间由内核空间(kernel space)和用户模式空间(user mode space)两部分组成。

虚拟地址,虚拟地址空间, 交换分区

Q37. 能否用memcmp比较 struct中成员

不能,主要是考虑到是struct的字节对齐

不要用memcmp比较结构体

Q38. 如何理解虚函数

C++通过虚函数(virtual function)机制来支持动态联编(dynamic binding),并实现了多态机制。多态是面向对象程序设计语言的基本特征之一。在C++中,多态就是利用基类指针指向子类实例,然后通过基类指针调用子类(虚)函数从而实现“一个接口,多种形态”的效果。

可参考Q8. C++多态

Q39. 虚函数在C++底层如何实现的(虚函数表)

C++通过虚函数表和虚函数表指针来实现virtual function机制,具体而言:

  • 对于一个class,产生一堆指向virtual functions的指针,这些指针被统一放在一个表格中。这个表格被称为虚函数表,英文又称做virtual table(vtbl)。
  • 每一个对象中都添加一个指针,指向相关的virtual table。通常这个指针被称作虚函数表指针(vptr)。出于效率的考虑,该指针通常放在对象实例最前面的位置(第一个slot处)。每一个class所关联的type_info信息也由virtual table指出(通常放在表格的最前面)。

【C++拾遗】 C++虚函数实现原理

Q40. 一个指针为什么是四字节

首先,指针就是地址,地址就是指针。而地址是内存单元的编号。所以,一个指针占几字节,等于是一个地址内存单元编号有多长。

地址总线的宽度决定了CPU的寻址能力。对32位计算机,我们一般需要32个0或1的组合就可以找到内存中所有的地址,而32个0或1的组合,就是32个位,也就是4个字节的大小,因此,我们只需要4个字节就可以找到所有的数据。所以,指针变量在编译期转换为一个32位4字节的地址串(即存储单元编号), 存放在寄存器中。同理,在64位的计算机中,指针占8个字节。

Q41. 反转链表

两个指针
在这里插入图片描述
递归

在这里插入图片描述
LeetCode 206 - 反转链表

Q42. UDP、TCP

tcp 传输控制协议

  • 它是面向连接可靠的传输协议
  • 通信流程是先建立好连接,然后才能进行数据的传输,通信完成以后关闭连接

udp 用户数据报协议

  • 通信流程是创建好socket以后就直接可以发送数据了,不需要建立连接, 但是不能保证数据的准确性和有效性

tcp的特点

  • 面向连接: 发送数据之前需要建立好连接, 间接验证ip地址的有效性
  • 可靠的传输
  • 应答机制:收到数据底层会回复
  • 超时重传:如果数据包发送完成以后对方一直没有回复会隔一段时间再次发送,如果对方一直没有回复,表示掉 线了
  • 错误校验:如果收到的数据包顺序和发送时候的顺序不一定会自动排序,这样数据不好发送错乱, 如果有重复的数据包会把重复的数据包删除
  • 流量控制:如果发送数据的时候达到了网卡缓存区上限,会让其等待,等数据处理完成以后再发送数据,防止电脑卡死

tcp和udp的不同点

  • tcp面向连接, udp不面向连接
  • tcp能保证数据的可靠性,表示数据是有效无差错的,udp不能保证
  • udp适合做广播,比如:飞秋上线操作, tcp不适合
  • udp适合发送少量数据每个数据包最多是64kb, tcp适合发送大量数据
  • udp的使用场景:音视频传输,发送广播消息, tcp的使用场景文件的上传和下载,绝大多数情况下都是tcp

传输层——详解UDP和TCP的区别

Q43. TCP拥塞控制

拥塞控制主要是四个算法:1)慢启动,2)拥塞避免,3)拥塞发生,4)快速恢复

在这里插入图片描述

TCP 拥塞控制算法

Q44. Int型指针指向char数组"abcd" 取int*的值是多少

先说大小端的问题:高位字节放低地址,低位字节放高地址,是大端字节序

记句顺口溜:自大的人眼高手低–其中,自大代表大端序,眼高代表高地址,手低代表低字节

char占一字节,int占四字节,“abcd”的char数组刚好是四字节,将char数组转换成32位二进制再对应转换成int就行

Q45. 带参宏定义

札记——带参宏定义

发布了152 篇原创文章 · 获赞 22 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_38204302/article/details/105141322