0. C++的三大特性

  • 封装是指将数据和操作这些数据的方法绑定在一起作为一个单元(通常是一个类),并通过访问修饰符(public、protected和private)来限制对这些成员的直接访问。封装的主要目的是保护对象的内容不被外部随意修改,从而提高安全性并减少错误的发生
  • 继承允许一个类(子类或派生类)继承另一个类(父类或基类)的属性和方法。这有助于代码重用,并能建立类之间的层次关系。
    • 单继承与多重继承:C++支持单继承(一个子类继承自一个基类)和多重继承(一个子类同时继承多个基类)
    • 继承方式:包括公有继承(public)、保护继承(protected)和私有继承(private),不同的继承方式会影响派生类对外部可见性的处理
  • 多态指的是能够在运行时决定调用哪个函数的能力,即同一个函数接口可能对应多种实现形式。在C++中,多态主要通过虚函数实现
    • 虚函数:在基类中声明为虚函数的成员函数可以在派生类中被重写,以提供特定的行为
    • 动态绑定:当使用基类指针或引用指向派生类对象时,如果调用了虚函数,则实际调用的是派生类中的版本,这种机制称为动态绑定或晚绑定

1. malloc、free和new、delete的区别

背景:malloc、free是c语言的库函数;new、delete是c++中的操作符

  • new会自动计算所需分配内存的大小;malloc需要手动计算(使用sizeof)

  • new返回的是对象类型的指针;malloc返回的是void*类型,需要进行强制类型转换

  • new分配失败会抛出异常;malloc分配失败返回的是NULL

  • new是在freestore上分配内存;malloc是在堆上分配

    • new 和 malloc本质上都在堆上分配内存,freestore 是 C++ 标准对 new/delete 管理内存区域的术语
    • 执行new时,会先在堆上申请到空间,再调用对象的构造函数;执行delete时,会先调用对象的析构函数(销毁对象,释放资源),再释放之前申请的那块堆内存
  • delete需要对象类型的指针(因为要调用对象的析构函数,所以需要是哪个对象);free只需要void*类型的指针

补:new和delete的一个过程:

  • new:1.调用operator new函数;2.分配一块内存;3.运行相应的构造函数来构造对象,传入初值;4返回指向该对象(地址)的指针
  • delete:1.调用对象的析构函数;2.调用调用operator delete函数;3.释放内存空间

2. malloc是怎么分配内存空间的

  • malloc 本质是调用了 系统的内存分配器(如 glibc 的 ptmallocjemalloctcmalloc)。

  • 内存分配器会维护一个“空闲链表”或“内存池”,管理所有堆上的空闲/已分配块。

  • 如果堆不够大了,分配器可能调用系统调用(如 sbrk()mmap())向操作系统申请更多的虚拟内存

注意:分配器为malloc()实际分配空间的大小会大于指定分配大小的字节数(因为需要额外存储元数据,比如块大小、是否空闲等)。

问题:malloc分配的是物理内存还是虚拟内存?

  • malloc分配的是虚拟内存,在现代操作系统中(如 Linux、Windows),每个进程都有独立的虚拟地址空间malloc 只是从这个地址空间中“划出”一块区域,不涉及物理内存。实际的物理内存是在真正访问这块内存时(如读写它)才由操作系统通过“页表映射”机制分配的(也叫做按需分配、缺页中断)。

问题:malloc 调用后是否立刻得到物理内存?

  • malloc 返回的指针确实指向一块虚拟内存,但这块内存对应的物理页可能并没有立即分配,只有你第一次访问(如写入)它时,操作系统才通过缺页中断分配物理页,物理内存才被分配出来。

如果分配的内存区域之前已经被分配并访问过(即已经触发了缺页中断,并且操作系统已经为其分配了物理页),那么在这种情况下,新的 malloc 调用返回的指针指向的内存地址如果正好对应于之前已经分配了物理页的位置,那么该指针指向的内存将可以直接使用而无需等待物理内存的分配。

问题:free(p) 怎么知道该释放多大的空间?

  • 当调用malloc(size)的时候,内存分配器会分配多于size字节的空间,在返回的地址p前面,会有一个隐式头部,记录这块内存的大小和状态(是否空闲),所以free(p)时,通过访问p前面的这段元数据来判断释放多少字节空间

注意:malloc 和 free 是一对,free 只能释放 malloc 分配出来的内存,并且不能随便改指针!

问题:free 释放内存后,内存还在吗?

  • 内存还存在(不会清除内容),只是标记为“空闲”了,如果继续使用这块内存,就会出现未定义的行为,可能报错、崩溃或写到其它正在用的内存

3. 虚函数是怎么实现的?它存放在哪里?什么时候生成的?

  • 虚函数是通过**虚函数表(vtable)虚函数指针(vptr)**实现的。当某个类中有虚函数时,编译器会为该类生成一张虚函数表,其中存储着该类中所有虚函数的地址(虚函数表是一个由指针构成的数组)。在对象的内存布局中(在构造对象的过程中),编译器会添加一个额外的指针,称为虚函数指针(虚表指针),这个指针指向该对象对应的虚函数表,当某个类对象调用虚函数时,程序会先通过对象中的 vptr 找到对应的虚函数表,再从中查找实际要调用的函数地址,从而实现多态。
  • 虚函数和普通函数一样存放在代码段,只是它的指针(地址)又存放在了虚表之中
  • 虚函数是编译阶段生成的

注意:1.虚函数表是一个全局的静态结构,存储在程序的数据段(.data),是在程序加载时就已经构造好的;2.虚函数表是属于类的,不是属于对象的,所有该类的对象共享同一张虚函数表

4. 虚拟地址空间分布结构

1
2
3
4
5
6
7
8
-----------高地址--------------
栈区(向下增长) : 函数的局部变量
空洞区 : 栈和堆之间的保护区(防止溢出)
堆区(向上增长) : 程序运行时动态分配的内存(如malloc/new)
bss段 : 未初始化的全局变量或静态变量
数据段(.data) : 初始化的全局变量或静态变量
代码段(.text) : 程序的机器指令,函数体(是只读的)
-----------低地址--------------

5. 智能指针的本质是什么,它们的实现原理是什么?

  • 智能指针本质上是一个封装了原始 C++ 指针的类模板,目的是为了安全、自动地管理动态内存资源。它可以在对象生命周期结束时,依靠析构函数自动释放资源,从而有效防止内存泄漏和资源泄露。
  • 常用的智能指针:
    • 独占智能指针:对资源独占所有权,即同一时间只能有一个unique_ptr指向资源对象。由于其独占性,unique_ptr不支持复制操作,但可以通过move进行转移
    • 共享智能指针:对资源共享所有权,允许多个共享智能指针同时共享同一个对象,并通过引用计数来管理对象的生命周期
    • 弱引用智能指针:是一种不控制所指向对象生命周期的智能指针,它不会增加共享智能指针的引用计数。主要作用是监视shared_ptr中管理资源的情况(辅助共享智能指针来使用的)

6. 匿名函数的本质是什么?它的优点是什么?

  • 匿名函数本质上是一个没有名字的函数对象,在其定义的过程中会创建出一个栈对象,内部通过重载()符号来实现函数的调用。

  • 优点:使用匿名函数,可以免去函数的声明和定义。这样匿名函数仅在调用函数的时候才会创建函数对象,而调用结束后立即释放,所以匿名函数比非匿名函数更节省空间。

1
2
3
4
5
6
7
8
auto f = [](int x, int y) { return x + y; };
//编译器大致生成了类似的代码(编译器自动生成了一个未命名的类,比如 __Lambda)
struct __Lambda {
int operator()(int x, int y) const {
return x + y;
}
};
__Lambda f; //f 是该类的一个 局部对象

7. 右值引用是什么,为什么要引入右值引用(作用)?

  • 左值:是指那些具有持久存储地址的表达式,并且可以使用地址运算符&来获取其地址。

  • 右值:是指那些没有明确内存地址的临时值或者是在表达式的求值过程中产生的临时结果。它们不能被取址

  • 右值引用是用来操作“临时对象”的,它可以更高效地转移资源,而不是复制,从而提升程序性能。

    • 支持移动语义:节省资源,提升程序性能(对资源进行浅拷贝,而非昂贵的深拷贝,所有权的转移,而不是复制资源)
    • 绑定临时对象:可以修改和复用临时变量
    • 支持完美转发:模板中保留参数的原始特性(因为当一个右值引用作为函数参数的形参时,在函数内部转发该参数给内部其他函数时,它就变成一个左值,并不是原来的类型了。如果需要按照参数原来的类型转发到另一个函数,就可以使用forward()方法)

8. 左值引用和指针的区别?

  • 指针是一个变量,保存的是一个地址;引用是对被引用的对象取一个别名,不能单独存在(与被引用对象共享内存地址)
  • 指针(除指针常量)可以被重新赋值,指向不同的变量;引用在初始化后不能更改,始终指向同一个变量
  • 指针可以为 nullptr,表示不指向任何变量;引用必须绑定到一个变量,不能为 nullptr

9. 指针是什么?

  • 指针变量是用来保存其他变量地址的一种变量。因为计算机把数据按地址顺序存放在内存中,我们就可以通过指针来找到、访问或操作那些数据。简单来说,指针就是“指向某个变量”的变量

10. weak_ptr占计数吗?在哪分配的空间?

  • 会增加弱引用计数,智能指针是一种用于自动管理动态内存资源的类模板,当创建一个共享智能指针 时,系统会在堆上分配一个控制块,用于记录强引用计数弱引用计数,分别追踪 共享智能指针弱引用智能指针 的数量。当 use_count 降为0 时,资源会被释放;当 weak_count 也为0时,控制块自身也会被销毁。

注意:智能指针的创建方式会影响资源和控制块的内存分布

  • 使用 std::make_shared<T>() 会在堆上一次性分配一块连续的内存,同时包含资源对象和控制块,内存布局更紧凑、效率更高
  • 使用 shared_ptr<T>(new T) 则会分别分配资源对象和控制块两块内存,两者分离,效率较低,并存在异常安全风险

11. malloc的内存分配的方式,有什么缺点?

使用malloc来分配内存的时候,首先会查看已有的内存池或空闲链表,寻找一个足够大的连续空闲块来满足请求。如果找到了合适的块,它可能会将这个块分割成两部分:一部分用于满足此次请求,另一部分则保持为空闲状态以供将来使用。但当请求的内存大于当前堆内存时,malloc需要向操作系统申请更多的内存,这通常是通过 sbrk 或 mmap 系统调用来完成的

  • 在使用malloc分配内存的时候会有两种方式向操作系统申请堆内存

    • 使用 brk/sbrk(小内存时使用):将堆顶指针向高地址移动,获取内存空间。优点是简单快速,分配连续内存,效率高;缺点就是内存不能轻易归还给操作系统(即使调用了 free()),释放的内存通常被保留在 malloc 的内存池中以备复用
    • 使用 mmap(大内存时使用):直接在虚拟内存空间中映射一块新的虚拟地址区域,通过free()释放内存时,会把内存归还给操作系统,内存得到真正释放。
  • 缺点:1.只是分配了内存,不会调用构造函数;2.返回的是void*,容易发生类型错误或忘记转换;3.容易造成内存碎片(不同大小的内存反复分配和释放后,可能导致堆中出现大量不可用的碎片内存)—>这是堆上空间能够满足请求内存的情况下产生

注:malloc并不是系统调用,而是C库中的函数,用于动态内存分配

  • 外部碎片:指系统中有足够的总空闲内存,但这些内存是分散的、不连续的,导致无法用来满足特定大小的内存分配请求。
  • 内部碎片:指的是已经被分配给某个进程或数据结构的内存块中未被使用的部分。换句话说,当一个内存块被分配给一个请求大小小于该块实际大小的任务时,剩余未使用的那部分内存就是内部碎片

12. 为什么不全部使用mmap来分配内存

  • 频繁系统调用:因为向操作系统申请内存的时候,是要通过系统调用的,执行系统调用要进入内核态,然后再回到用户态,状态的切换会耗费不少时间,所以申请内存的操作应该避免频繁的系统调用,如果都使用mmap来分配内存,等于每次都要执行系统调用(brk则没有那么频繁)。
  • 频繁缺页中断:因为mmap分配的内存每次释放的时候都会归还给操作系统,于是每次mmap分配的虚拟地址都是缺页状态,然后在第一次访问该虚拟地址的时候就会触发缺页中断,从而降低程序的性能。

注意:brk相比mmap会更少的触发缺页中断,因为 brk 是在虚拟地址空间中的堆区连续地扩展内存,操作系统有时会提前为堆区映射好物理页,所以第一次访问就不一定会触发缺页中断;而 mmap 每次分配都是新区域,通常采用按需分配策略,首次访问更容易导致缺页中断。

13. 为什么不全部都用brk

  • brk 申请的堆内存只能线性向高地址扩展,不能向低地址缩回,就导致释放的中间内存不能被系统真正回收,如果频繁的调用malloc和free,容易产生越来越多不可用的内存碎片。

14. 传入一个指针,它如何确定具体要清理多少空间呢?

  • malloc在申请内存的时候,内存分配器(如 glibc)通常会在用户请求的内存前面额外分配一段“元数据区域,里面保存了内存块的详细信息,free 就是靠这段信息知道要释放多大的空间。

注意:new/delete 底层机制类似,但它还会自动调用构造和析构函数。

15. 宏定义define和常量定义const的区别是什么?

  • 编译阶段:define是在预处理阶段进行文本替换;const是在编译阶段确定其值

  • 安全性:define定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全检查,容易出错;const定义的常量是有具体类型的,是要在编译时进行类型判断的,更安全

  • 调试:define定义的宏常量不能调试,因为在预处理阶段就已经进行替换了;const定义的常量是有地址和类型,可以进行调试的。

  • 内存占用:define通常不占内存,仅是文本替换;const是一个变量,会占用内存(但可能优化)

16. 程序运行的步骤是什么?

  • 预处理:在这个阶段主要做了三件事:展开头文件、宏替换、去掉注释行。这个阶段需要调用预处理器来完成,最终得到的还是源文件(.i文件)

  • 编译:逐行检查程序中出现的语法、词法错误和逻辑错误,并翻译成汇编指令,最终生成一个汇编文件(.s文件)

  • 汇编:将汇编文件里面的汇编指令翻译成二进制的机器码,这个过程没有错误检查,只是机械的翻译工作,最终生成一个二进制文件(.o文件)

  • 链接:将二进制文件链接库文件、数据段合并、地址回填,最终生成一个可执行的二进制文件

17. 原子操作是什么?

  • 指的是不可分割的操作,在执行过程中不会被线程调度机制中断。这意味着,一旦这个操作开始执行,它就会一直执行到完成,而不会被其他线程或进程的执行所打断。原子操作可以确保在多线程或多进程环境下对共享资源的安全访问,避免了竞态条件的发生。

18. class与struct的区别

  • 默认继承权限不同:class默认继承的是private继承,struct默认是public继承。
  • c++中的struct与c语言中的不一样,其它功能完全与class相同,都可以有成员函数、构造/析构函数、继承、多态等
  • class 可以用于定义模板参数,struct 不能用于定义模板参数

19. 内存对齐是什么?为什么要进行内存对齐?内存对齐有什么好处?

  • 内存对齐是处理器为了提高处理性能而对存取数据的起始地址所提出的一种要求。

  • 有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定的地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时将进行对齐,这就具有平台的移植性。CPU每次寻址有时需要消耗时间的,并且CPU访问内存的时候并不是逐个字节访问,而是以字长为单位访问,所以数据结构应该尽可能地在自然边界上对齐,如果访问未对齐内存,处理器需要做多次内存访问,而对齐的内存访问可以减少访问次数,提升性能。

  • 优点分别是:增强程序的可移植性;提高程序的运行效率

20. 进程之间的通信方式有哪些?

进程之间通信的方式有很多,主要用于在不同进程之间交换数据传递消息同步行为。常见的 IPC 方式如下:

  • 管道:管道只能用于具有亲缘关系的进程间通信,管道本质上是内核中的一个缓存区,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端

  • 命名管道:支持任意进程间通信。是通过创建一个管道文件来完成的,需要写的进程以写的方式打开该文件,需要读的进程以读的方式打开该文件,最后通过返回的文件描述符来完成通信过程

  • 存储映射mmap:创建一个指定文件,通过mmap()将该文件映射到存储区域,把互相要通信的进程打开同一个映射区域,这样这些进程就可以通过这个映射区域进行通信了

  • 本地套接字:只能用于本机的两个进程通信。稳定性强,双向全双工,流程和网络套接字类似,但网络套接字绑定的是ip和端口,而本地套接字绑定的是一个文件,类似于管道

21. 线程之间的通信方式有哪些?

  • 互斥量:A线程加锁、访问资源,B线程必须等A释放后才能继续 → B线程“知道”A线程已经完成;
  • 条件变量:A线程等待某个条件,B线程满足条件后通知A → B线程“告诉”A线程可以继续了;
  • 信号量:控制线程的进入数量,也可以用作通知机制。
  • 消息队列:一个线程将消息放入队列中,另一个线程从队列中取出并处理这些消息。这通常需要结合互斥锁和条件变量来保证线程安全
  • promise 和 future:一种用于在线程之间传递数据的方式。promise用于在某一线程中设置值,而与promise相关联的future则用于在另一线程中获取这个值
  • async 和 future:async会返回一个future对象,并启动一个异步任务函数,后面可以通过这个future对象来获取异步任务函数的结果,

22. 介绍一下ARP协议

  • ARP是一种网络层协议,用于在已知ip地址情况下获取对应的mac地址。

  • 流程:假如数据路径为客户端、默认网关(路由器)B、默认网关(路由器)C、服务端

    • 客户端A首先检查其自身的IP地址和子网掩码,判断目标IP地址是否与其处于同一子网。如果在同一个子网中,就可以根据目标IP广播一个ARP请求报文,服务器收到该ARP报文后就会回应一个ARP响应报文,并包含mac地址;如果不在同一个子网中,客户端就知道它不能直接发送数据给服务端,而是需要通过默认网关来进行转发,于是客户端就会查找路由表,路由表会根据目标IP来提供下一跳(路由器B)的IP地址,客户端就可以通过下一跳的IP地址来广播一个ARP请求报文,然后路由器B接收到后回应一个响应ARP请求,并带上mac地址。
  • 注意:

    • 路由器不会盲目地向所有邻居发送ARP请求,而是根据目标IP地址查找路由表,确定下一跳IP后,再根据改IP广播一个ARP请求,从而获取对应的MAC地址用于转发。
    • 默认网关通常是一个路由器的接口地址,负责接收这些出站(去另一个广播域)的数据包,并根据路由表决定如何进一步转发这些数据包。

23. 父类的构造函数和析构函数是否能为虚函数?这样操作导致的结果?

  • 构造函数不能为虚函数,虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的虚函数指针指向,该指针是在构造过程中由编译器设置的,如果构造函数为虚函数,那就意味着构造前就要通过 vptr 找到构造函数,而此时 vptr 还未被正确初始化(可能指向其它的虚函数表),导致无法构造对象。

  • 析构函数可以且应该为虚函数:如果父类的析构函数不为虚函数,当使用父类指针指向子类,进行析构时,只会调用父类的析构函数,子类的析构函数不会被调用,容易造成内存泄漏。

24. 多线程为什么会发生死锁,死锁是什么?死锁产生的条件,如何解决死锁?

  • 死锁指的是多个线程因互相等待对方持有的资源,导致所有线程都无法继续执行下去的情况。

  • 死锁通常发生在多线程访问共享资源时,比如两个线程互相持有对方需要的锁(mutex),并且都不释放,导致相互等待,程序卡死。

  • 死锁产生的四个条件:

    • 互斥条件:资源不能共享,一次只能被一个线程持有
    • 占有且等待:线程在等待其他资源的同时不释放已持有的资源
    • 不可抢占:线程占有的资源在释放前不能被其它线程强行夺走
    • 循环等待:存在一个线程链,每个线程都在等待下一个线程所持有的资源
  • 解决死锁的方法就是破坏上述任意一种条件

    • 破坏“循环等待”:规定获取多个锁时的顺序,例如总是先锁 A 后锁 B
    • 破坏“不可抢占”:如果获取不到所需资源,就主动释放已有资源,稍后重试。

25. 描述一下面向过程和面向对象

  • 面向过程:是一种以“步骤”为核心的编程思想,它关注“做什么”“怎么做”,程序就是由一系列函数(步骤)组成的。相比面向对象,代码效率更高。

  • 面向对象:是一种以“对象”为核心的编程思想,它强调把现实世界中的事物抽象成一个个“对象”,通过对象之间的交互完成任务。相比面向过程,代码更易维护和复用

26. ++i是左值还是右值,++i和i++哪个效率更高?

  • ++i是左值,因为它返回的是i本身;而i++是右值,因为它返回的是临时值(i的值),

  • ++i效率更高,因为它是直接把i自增,然后返回引用;而i++要先保存旧值的副本,再自增,再返回新副本,多了一次拷贝构造(赋值操作)

27. 介绍一下vector、list的底层实现原理和优缺点

  • Vector底层是由一段连续的内存空间组成的,维护了三个指针,分别是头指针(指向第一个元素),尾指针(指向最后一个原始之后的位置)和可用空间尾指针(指向分配的内存末尾)。当 vector 容量不足时,会自动重新分配更大内存(通常是当前容量的 2 倍或 1.5 倍),然后将旧数据拷贝到新内存。

    • 优点:可使用下标随机访问,尾插尾删效率高
    • 缺点:扩容代价高,迭代器失效频繁
  • list底层是由双向链表实现的

    • 优点:无内存扩容问题;迭代器稳定性好;在任意位置的插入删除下效率高。
    • 不支持下标随机访问;内存开销大,每个节点额外需要两个指针

28. 变量在哪个阶段初始化、在哪个阶段分配内存?

  • 静态变量,全局变量,常量都在编译阶段完成初始化和内存分配

  • 局部变量在编译阶段完成初始化,但是在运行阶段完成内存分配

29. 空对象指针为什么能调用函数?

  • 在 C++ 中,即使一个对象指针是空指针,也可以调用类的成员函数,前提是该函数内部不使用任何成员变量或访问 this 指针。这是因为成员函数的代码并不保存在对象中,而是由类共享并存放在代码区;非静态成员函数被调用时,编译器会隐式地传入一个 this 指针。如果函数内部没有用到 this,那么即使这个 this 是空指针,也不会造成错误。

注意:对象只存数据,不存函数。在 C++ 中,一个类的对象只包含它的成员变量(也就是数据成员),而 成员函数并不存储在对象中。因为如果每个对象都复制一份函数代码,内存就太浪费了。

30. 智能指针线程安全吗?

  • 智能指针中的引用计数操作是线程安全的,但智能指针所指向的资源本身并不是线程安全的。也就是说,智能指针可以保证资源在多个线程中被安全地析构和释放,但并不保证多个线程同时访问或修改该资源时的安全性。换句话说,它仅保证资源的生命周期管理是线程安全的,而不保证资源本身的访问是线程安全的(多线程读写资源需要加锁、保护等操作)。

31. push_back()左值和右值的区别是什么?

  • push_back() 接收左值时会调用拷贝构造函数,将数据复制到容器中;接收右值时会调用移动构造函数,将数据“搬”进容器。使用右值或 std::move() 可以减少资源拷贝,提升性能,尤其在操作大型对象时非常重要。

32. move底层是怎么实现的?

  • move的功能是将一个左值引用强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义,从实现原理上讲基本等同一个强制类型转换。

  • 优点(这样做的一个好处):可以将一个左值变成右值(改变它身份),避免拷贝构造,而实现移动构造,这样就只是将对象的状态所有权从一个对象转移到另一个对象,只是转移,没有内存搬迁或者内存拷贝,继而节省空间,提高效率。

注意:如果一个类有移动构造函数,那么编译器在初始化新对象时,会根据右侧是左值还是右值,自动决定是调用拷贝构造还是移动构造函数。当右边是左值时,调用的是拷贝构造,当右边是右值时,调用的是移动构造

33. 完美转发的原理是什么?

  • 完美转发是指一个函数可以将自己的参数完美的转发给内部调用的其他函数,完美是指不仅能够准确的转发参数的值,还能保证被转发参数的左、右值属性不变,使用引用折叠的规则,将传递进来的左值以左值传递出来,将传递进来的右值以右值的方式传出。从而实现语义上的“完美转发”。

34. 空类中有什么函数?

  • 空类默认有:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数(c++11)

注意:c++11规定,如果显式地声明了拷贝构造函数拷贝赋值函数析构函数中的任何一个,编译器就不会再为你隐式生成移动构造函数和移动赋值函数。

35. explicit用在哪里?有什么作用?

  • 用于修饰只有一个参数的类构造函数(有一个例外就是,当除了第一个参数以外的其他参数都有默认值的时候此关键字依然有效),它的作用是表明该构造函数是显示的,而非隐式的(不能隐式转换),类构造函数默认情况下声明为implicit。explicit作用是防止类构造函数的隐式自动转换,增强类型安全

36. 成员变量初始化的顺序是什么?

  • 成员变量在使用初始化列表初始化时,与构造函数中初始化成员列表的顺序无关,只与类中定义成员变量的顺序有关。
  • 如果不使用初始化列表初始化,在构造函数内赋值时(这里不能说是初始化,因为真正的初始化其实在构造函数体执行之前就已经完成了,可能是默认值,构造函数体里面只是做了二次赋值操作),此时与成员变量在构造函数中的位置有关。
  • 类中const成员常量必须在构造函数初始化列表中初始化。
  • 类中static成员变量,只能在类外初始化,因为static 成员属于整个类共享,不属于具体对象,不能在构造函数或初始化列表中初始化。。

37. 指针占用的大小是多少?

  • 64位电脑上占8字节,32位的占4字节,我们平时所说的计算机多少位是指计算机CPU中通用寄存器一次性处理、传输、暂时保存的信息的最大长度。即CPU在单位时间内能一次处理的二进制的位数

38. 野指针和内存泄漏是什么?如何避免?

  • 野指针:指向一个已经释放的内存或申明了指针,但没有初始化,这时指针可能指向任意内存地址。

    • 避免方法:1.声明指针后,及时初始化,指向nullptr都可以;2.释放后及时将指针设为nullptr;3.使用智能指针
  • 内存泄漏:是指程序中以动态分配的堆内存由于某种原因程序未释放或无法释放,造成这部分内存无法再次使用(内存浪费),导致程序运行速度减慢甚至系统崩溃等严重后果

    • 避免方法:1.每new一块内存,都要有对应的delete;2.不手动管理内存时,尽量使用智能指针;3.避免中途覆盖指针变量,导致原有地址丢失

39. 多线程会发生什么问题?线程同步有哪些手段?

  • 会引发资源竞争的问题;频繁上锁又会导致程序运行效率低下,甚至会导致发生死锁。

  • 线程同步手段:使用atomic原子变量,使用互斥量也就是上锁,使用条件变量或信号量制约对共享资源的并发访问。

40. 什么是STL?

  • STL是C++标准库的重要组成部分,提供了大量常用的、高效的算法和数据结构。STL 的设计目标是为了提供通用的、高性能的组件,使得开发者能够专注于解决更高层次的问题,而不是重新发明基础工具。STL 主要由以下四个部分组成:
  • 容器:用于存储和管理数据
  • 算法,STL 提供了一套丰富的算法,用于操作容器中的数据。比如 sort(), find(), for_each(), count(), binary_search()
  • 迭代器:迭代器是一种类似于指针的对象,用于遍历容器中的元素
  • 函数对象:重载了 () 运算符的类对象,它像函数一样可以调用,用于自定义操作行为

41. 迭代器和指针的区别

  • 迭代器不是指针,是一个模板类,通过重载了指针的一些运算符来模拟了指针的一些功能,迭代器返回的是容器中元素的引用(T&&),而不是对应的值(T)。

  • 虽然迭代器通常用于 STL 容器,但本质上它是访问一段区间的工具,不具备像指针那样能指向任意对象(如函数)的能力。

42. 线程有哪些状态,线程锁有哪些?

  • 五种状态:

    • 创建:线程被创建,但尚未开始执行
    • 就绪:线程已经准备好运行,正在等待 CPU 时间片
    • 运行:获得了CPU时间片,线程正在执行
    • 阻塞:正在等待某个资源,例如锁、IO操作等(当线程因某些原因被阻塞时,操作系统会挂起该线程,将它从“就绪队列”中移除,不再分配 CPU 时间片,直到它重新变为“就绪”状态)
    • 终止:线程已经完成执行或被终止
  • 线程锁的种类:

    • 互斥锁:最基本的锁,一个线程获得锁,其他线程必须等待
    • 条件锁(条件变量):用于线程之间的等待-通知机制。类似“生产者-消费者模型”
    • 自旋锁(尝试锁):不阻塞线程,而是在循环中等待锁释放。适用于锁竞争不激烈,锁持有时间很短的情况,缺点是浪费CPU资源
    • 读写锁:多个线程可以同时读取资源,但写入时必须独占。适用于读线程多写线程少的情况。
    • 递归锁:同一线程可重复获得多次锁,不会死锁。适用于一个线程内多次调用相同的加锁函数

43. 介绍一下线程、进程和协程

  • 进程是操作系统资源分配的基本单位,一个程序启动时,操作系统会为其创建一个进程,每个进程拥有独立的内存空间、代码段、数据段、堆和栈

    • 因为每个进程都有独立的内存空间,所以进程间的通信需要通过特定的机制来实现,比如管道、共享内存mmap,本地套接字等
  • 线程是操作系统调度的基本单位,一个进程可以包含多个线程,多个线程共享该进程的资源。相比进程,创建和销毁线程的开销较小。

    • 由于资源共享,多个线程访问相同资源时可能需要同步机制(如锁)以避免数据竞争
  • 协程是一种“可以暂停执行”的函数,当遇到一个异步操作时(如 async_read_some),协程可以挂起自己,并释放当前的资源,待异步操作完成后,自动恢复执行。

    • 协程是用户级的轻量线程,因为协程是在用户空间完成调度的,不需要进入内核态,而线程的调度通常涉及用户态和内核态之间的切换

44. vector中的push_back()和emplace_back()的区别、以及使用场景

  • push_back和emplace_back的参数都是左值时,两者都会触发一次拷贝构造函数,因此这种情况下两者的没有太大差别;而当push_back和emplace_back的参数都是右值时,push_back会触发构造函数和移动构造函数,emplace_back只会在容器尾部触发构造函数,少了一次移动构造函数,因此在性能上通常优于 push_back
  • emplace_back适用于直接在容器中构造新元素的情况,如果要将现有的对象添加到容器中最好使用push_back。
  • push_back会更加安全;emplace_back会更加高效

注:如果push_back()传入的是已有的对象,那么调用的就只有拷贝构造函数,如果是push_back(Person(“lxx”,25)),那么调用的就是构造函数+移动构造函数(因为调用构造函数后,这是一个临时对象,即右值,就会进行调用移动构造函数)。emplace_back 的优势体现在:原地构造 + 避免临时对象的拷贝或移动

45. 如何实现线程安全,除了加锁还有没有其它的方式?

  • 原子操作(原子操作是不可分割的,使用原子操作可以确保在多线程环境中操作是安全的),
  • 条件变量(协调线程之间的协作,用来在线程之间传递信号,从而控制线程的执行流程)等方式

46. vector扩容,resize和reserve的区别

  • resize() 改变的是vector大小(即元素的数量),并可能间接影响容量(当需要更多空间时)
  • reserve() 改变的是vector容量(即预分配的内存大小),但不会影响当前的大小(元素数量)

47. vector扩容为了避免重复扩容做了哪些机制?

  • vector 的大小超过当前 capacity 时,它会自动扩容到原来容量的两倍左右,而不是只增加一点点空间。

  • 可手动调用 reserve() 提前分配空间

48. C++中空类的大小是多少?为什么?

  • 1字节,这样主要就是起占位的作用,保证每个对象都有一个唯一的地址。如果空类大小为0,那么p1和p2 的地址就可能完全一样,编译器无法区分两个对象,也就破坏了对象的基本属性(每个对象应有唯一地址)

问题:为什么空类大小为0,p1和p2的地址可能一样?

由于空类占 0 字节,编译器在内存中不需要为它分配任何空间,其对象也就没有空间,那么它们的地址该指向哪里,就可能会相同

49. weak_ptr是怎么实现的?

  • weak_ptr 的底层实现依赖于一个控制块,它是由共享智能指针创建的,该控制块中包含两个计数器:一个记录强引用(shared_ptr)的数量,另一个记录弱引用(weak_ptr)的数量。weak_ptr 不拥有资源,不会影响资源的生命周期,只用于观察资源是否存在,并在需要时临时获取 shared_ptr
    • 强引用计数(use_count):记录有多少个 shared_ptr 实例指向这个资源
    • 弱引用计数(weak_count):记录有多少个 weak_ptr 实例指向这个资源。

50. 一个函数f(int a,int b),其中a和b的地址关系是什么?

  • 函数参数如 f(int a, int b) 中的 ab 是值传递的,也就是说它们是实参的副本,会在函数栈帧中被依次压栈分配空间。

  • 因为ab 都是局部变量,位于函数的栈帧中,栈空间通常是从高地址向低地址增长的,则会按声明顺序依次入栈,即 &a > &b

51. 移动构造和拷贝构造的区别是什么?

  • 移动构造函数本质上是对资源指针的“浅拷贝”,通过资源所有权的转移,避免了昂贵的深拷贝操作。它常用于处理右值对象(如临时变量)或通过 std::move() 转换后的左值对象。在这种场景下,可以显著提高性能,减少不必要的堆内存分配和数据复制。
  • 相比之下,拷贝构造函数会分配新的内存空间,并将原对象的内容逐一复制过去,适用于需要保留原始对象副本的情况。

52. lambda表达式捕获列表捕获的方式有哪些?如果是引用捕获要注意什么?

  • Lambda 表达式的捕获列表是用来捕获外部作用域变量的,捕获的方式有按值捕获和按引用捕获。
  • 引用捕获要注意什么?
    • 生命周期问题:Lambda可能会延后执行(如在异步、线程、回调中),此时被引用的变量若已销毁(被引用的对象生命周期短),就会造成悬空引用,这是未定义行为,极易导致崩溃
    • 线程安全问题:若在多线程中使用引用捕获的变量,没有同步机制(如互斥锁),可能导致数据竞争

解决办法:应尽量避免对生命周期短的变量使用引用捕获,特别是在 lambda 延后执行的情况下。此时可以对关键变量采用**值捕获([=] 或 [a])**来避免悬挂引用

53. 哈希碰撞的处理方法

  • 在哈希表中,每个键(key)通过哈希函数映射到一个表的索引位置。但因为哈希函数输出是有限的,而输入可能无限,多个不同的键可能映射到同一个索引位置,这就叫做哈希碰撞

  • 处理方法:

    • 开放地址法:如果某个位置已经被占,就往下去寻找一个新的空闲的哈希地址。
    • 优点:空间利用率高
    • 缺点:容易产生聚集效应(指多个键值对在哈希表中聚集到相邻位置的现象),查找效率下降
    • 链地址法:每个数组槽位存放一个链表(或其他容器),将多个哈希地址相同的元素“挂”在同一个槽位下
      • 优点:碰撞处理简单,插入效率高
      • 缺点:空间开销大,需要额外内存;可能会导致链表太长,查找性能变差
    • 再哈希法:同时构造多个哈希函数,等发生哈希冲突时就使用其他哈希函数直到不发生冲突为止
      • 优点:不易发生聚集,
      • 缺点:增加了计算时间
    • 建立公共溢出区:将哈希表分为基本表和溢出表,将发生冲突的都存放在溢出表中

54. unordered_map的扩容过程

  • unordered_map 中的元素数量超过桶数量的负载因子(当前元素个数 ÷ 桶的总数量,默认是 0.75)时,容器会自动扩容
  • 1.创建更大的数组(空间),桶数量会扩大(通常是原来的2倍);2.重新哈希:把所有元素根据新的桶数重新计算哈希值并分配到新的桶中,以保持哈希查找的高性能。3.更新容器内部记录的相关信息(当前的桶数量和负载因子等)

55. vector如何判断应该扩容?

  • 由当前容器内元素数量的大小和容器容量的大小进行比较如果二者相等就会进行扩容,一般是1.5倍,部分的有两倍

56. this指针是什么?

  • this 指针是一个指向当前对象本身的指针。它只有在对象调用非静态成员函数时才存在。因为this 是每个对象调用非静态成员函数时,编译器隐式传入的一个指针参数,实际上就是当前对象的地址。

56. 类中static函数是否能声明为虚函数?

  • 不能,因为类中的static函数是所有类实例化对象所共有的,也就是说无论创建多少个类的实例对象,静态成员函数只有一个版本,而虚函数底层是通过虚函数表和虚函数指针来完成的,是为了实现多态,一方面静态成员函数没有this指针,也就不能调用虚函数指针,另一方面静态成员函数不具备多态性的基础,因此静态成员函数不能声明为虚函数
  • 虚函数机制依赖对象(实例)
    • 虚函数是面向对象的特性,它依赖于具体的对象来实现多态
    • static 成员函数不属于任何对象实例,它们没有 this 指针,无法访问对象的状态
  • 虚函数机制需要动态绑定
    • 虚函数的核心是根据对象的实际类型在运行时决定调用哪个函数
    • static 函数是编译时解析的,不涉及运行时类型判断

57. 哪些函数不能被声明为虚函数?

  • 构造函数,静态成员函数,非类成员函数
  • 内联函数:内联函数有实体,在编译时展开,没有this指针
  • 友元函数:因为虚函数必须是类的成员函数,而友元函数不是类的成员函数。友元函数只是提供一种机制,允许外部函数访问类的私有和保护成员

注:内联函数的处理主要发生在编译阶段。编译器会根据inline关键字的建议,尝试将函数的定义直接插入到每个调用点,从而减少函数调用的开销。然而,编译器可能会根据函数的大小、调用频率等因素决定是否真正内联某个函数。

58. 如何保证类的对象只能被开辟在堆上?

  • 1.将类的构造函数设置为私有,这样外部就不能在栈上创建对象了(MyClass obj);2.提供一个公共(public)的static函数来在new上创建对象并返回(public是因为允许外部调用该函数,static是因为在没有对象的情况下,只能调用static函数)

注意:如果只能在栈上分配呢?则是重载new操作符,使得new操作符的功能为空,这样就使得外部程序无法在堆上分配对象,只可以在栈上分配

59. 讲讲你理解的虚基类

  • 虚基类是C++中一种特殊的类,用于解决多继承所带来的“菱形继承”问题。如果一个派生类同时从两个基类派生,而这两个基类又共同继承自同一个虚基类,就会形成一个“菱形”继承结构,导致派生类中存在两份来自虚基类的实例,从而引发一系列的问题。因此为了解决这个问题,就可以将两个基类共同继承的类作为虚基类,派生类中就采用虚继承的方式来继承

  • 虚继承会使得派生类中只存在一份共同继承的虚基类的实例,从而避免了多个实例之间的冲突

  • 虚基类是可以被实例化的,只是被别的类以虚继承的方式使用。它是一个继承方式的描述

60. C++哪些运算符不能被重载?

  • 成员访问运算符(.)、作用域限定符(::)、成员指针访问运算符(.*)

  • 为什么不能重载:这些运算符涉及 C++ 的核心语言机制编译器的行为,如果允许重载,可能会破坏语言的基本规则或引起歧义。

61. 动态链接和静态链接的区别

  • 区别:最大区别就是在于链接的时机不同,静态链接是在形成可执行程序前(链接阶段),而动态链接的进行则是程序执行时(运行阶段)

  • 静态链接:在静态链接过程中,链接器会将程序所需的库代码直接复制到生成的可执行文件中。这意味着每个使用静态库编译的程序都会包含一份完整的库代码

    • 优点
      • 独立性:生成的可执行文件不依赖于外部库,因此可以在没有安装相应库的系统上运行。
      • 性能:由于所有需要的代码都已经包含在可执行文件中,所以在运行时不需要额外的加载时间
    • 缺点
      • 存储空间浪费:如果多个程序都使用了同一个静态库,那么每个程序都会包含一份该库的副本
      • 更新不便:如果静态库有更新,所有使用该库的应用都需要重新编译并发布新的版本
  • 动态链接:动态链接(或共享库)则是在编译时只记录对库函数的引用(符号引用),实际的库代码不会被复制到可执行文件中。当程序运行时,操作系统会加载这些共享库,并解决符号引用。

    • 优点

      • 节省磁盘空间:多个程序可以共享同一份动态库,减少了重复存储的情况
      • 易于维护:如果共享库更新了,只要库的接口不变,应用程序无需重新编译即可享受到新版本的好处
    • 缺点:

      • 依赖性:程序运行时需要相应的动态库存在,并且版本兼容。如果缺少必要的动态库或版本不匹配,程序可能无法运行。

      • 启动时间:程序启动时需要加载动态库,可能会增加一些启动时间。

62. 说一下内联函数及其优缺点

  • 内联函数:是一种建议编译器在调用该函数的地方直接展开函数代码(但保留类型检查和作用域规则,不像宏那样易出错),而不是进行常规的函数调用(压栈、跳转、返回等操作)。

  • 优点:节省了函数调用的开销,让程序运行更加快速。

  • 缺点:如果函数体过长,频繁使用内联函数会导致代码编译膨胀问题;不能内联递归调用,容易导致死循环的展开

63. auto是怎么实现自动识别类型的?模板是怎样实现转化成不同类型的?

  • auto 是编译器在声明变量时自动根据右边的表达式类型推导变量类型;而模板则是编译器根据传入实参,推导模板参数(T)类型,并在编译期生成对应的代码版本,两者都依赖于编译期的类型推导机制

64. map和set的区别和底层实现是什么?map取值的find、[]、at方法的区别

  • map和set都是通过红黑树实现的平衡二叉搜索树。插入、删除、查找的时间复杂度为 **O(log n)**。

  • 1.find()查找需要判断返回的结果才知道有没有查询成功;2.[]下标访问,如果原先不存在该key则插入,如果存在则返回对应的value值;3.at方法则会进行越界检查,如果存在则返回它的value值,如果不存在则抛出异常。

65. fcntl的作用

  • fcntl 是 Linux 系统调用中的一个多功能函数,属于文件控制接口,主要用于对文件描述符进行各种控制操作

  • 功能:1.获取/设置文件描述符的标志(如非阻塞);2.复制文件描述符(类似于dup());

66. extern C关键字是什么,为什么会有这个关键字?

  • 作用:用C语言的方式来编译C++代码,

67. 迭代器失效及其解决方法

  • 迭代器失效:是指容器结构发生变化(如插入、删除、扩容等),导致原来保存的迭代器不再有效
  • 序列式容器迭代器失效:当当前元素的迭代器被删除后,后面所有元素的迭代器都会失效.他们都是一块连续存储的空间,所以当使用erase函数操作时,其后的每一个元素都会向前移动一个位置,此时可以使用erase函数操作可以返回下一个有效的迭代器。

注意:1.容器内部重新分配内存,导致原迭代器指向旧空间,也会造成迭代器失效;2.对于双向链表结构list,节点插入和删除不会影响其它节点,所以一般不会发生节点失效

68. 编译器是如何实现重载的?

  • c++底层实现函数重载是通过名称修饰这一机制。编译器会把函数名和参数类型编码在一起,形成一个“唯一的标识符”,并保存在一个符号表中,用于定位函数地址。

  • 1.当编译器遇到一个函数声明或定义时,通过名称修饰生成唯一的一个标识符注册到符号表中,并关联一个地址或占位符。

    • 如果这个函数是已经定义好的,编译器可以直接将这个函数地址记录在该函数标识符关联的地址上
    • 如果这个函数只是声明(没有函数体),那么编译器就在这个函数的标识符关联地址上创建一个未解析的占位符
  • 2.当遇到函数调用时,会通过类型推导找到正确的重载函数,然后找到它在符号表中的修饰名称,如果这个函数在符号表中绑定的是一个地址(说明之前已经定义好),就可以直接使用;如果这个函数在符号表中绑定的是一个占位符(说明之前没有定义好),编译器就会记录这个标识符(函数)为未解析,需要在链接时完成解析

  • 3.链接时,链接器会将多个文件的符号表合并,把这些“占位符”用真正的地址替换

补:在C语言中的符号表是以函数名为符号来绑定函数地址,就会存在歧义和冲突,因此C语言不支持函数重载;而C++符号表中的符号是以函数名+参数类型等信息,通过名称修饰生成唯一的内部符号来绑定函数地址的,所以不会产生查询歧义的问题,使得函数可以重载

注意:1.同一个作用域下(同一个类内),多个函数拥有相同的函数名,但参数类型或个数不同,这叫做函数重载;2.子类中重新定义父类的虚函数,并且函数名称、参数类型和个数、返回值都必须完全一致,这叫做函数重写;3.在继承关系中,子类实现了一个和父类名字名字一样的函数。这样子类的函数就把父类的同名函数隐藏了。隐藏只与函数名有关。

69. 使用条件变量的时候需要注意什么?

70. 类内普通成员函数可以调用类内静态变量吗,类内静态成员函数可以访问类内普通变量吗?

  • 类内普通成员函数可以调用类内静态变量,因为静态变量是整个类共享的,不依赖于对象,在第一次使用它时就会被初始化并分配内存,所以只要作用域允许,不管是通过对象还是普通成员函数,都可以访问静态变量。
  • 静态函数可以直接访问静态变量,静态函数不能直接访问非静态变量。如果要访问非静态成员变量,必须传入对象。

注意:静态成员函数不能直接访问非静态成员变量,是因为调用静态成员函数时,没有隐式传入 this 指针,而访问非静态成员变量需要通过对象(即 this 指针)来访问,因此在静态成员函数中就不知道是哪个对象,也就无法调用非静态成员变量。

71. 回调函数是什么,为什么要有回调函数?有什么优缺点?回调的本质是什么?

  • 回调函数:将一个函数作为参数传递给另一个函数,当条件满足或某个事件发生时,这个函数被“回调”执行

  • 回调函数的目的就是让程序更加灵活,把“调用者”与“实现逻辑”分离开,只关注“什么时候执行什么操作”即可

  • 优点:解耦合(调用方和实现方不直接依赖,逻辑更加清晰)、可重用(同一个接口,多个回调函数可以复用)

  • 缺点:调试困难(回调函数过多会导致代码难以维护)、可读性差(程序流程不再线性,阅读难度上升)

  • 回调的本质:将函数作为参数传递,使得程序能够在运行时动态地调用这些函数,从而实现更灵活和通用的设计。回调函数的核心在于动态性和灵活性

72. 什么是尾递归?

  • 尾递归:在一个函数中,最后一步调用自己本身,并且调用完成后,没有其他额外的操作需要继续执行
  • 普通递归:因为每次递归都有未完成的任务,所以必须开新的栈帧存着,保存每一层的一个状态,当递归层数太深,就会导致栈空间耗尽,程序崩溃。

为什么尾递归可以不新开栈帧:调用自己之后,当前函数就彻底结束了没有任何后续操作,所以当前栈帧就没有必要保留,可以直接用当前的栈帧给下一次调用复用,不用重新申请新的栈空间

补:栈帧就是函数调用时,编译器在栈上为这个函数专门分配的一块空间,用来保存这个函数运行所需要的一切信息

73. 为什么会有栈溢出,为什么栈会设置容量?

  • 1.栈空间通常用于存放临时变量,如果定义了大量的临时变量(局部变量),就可能超过设置的栈空间大小,就会出现栈溢出。2.如果函数嵌套太多,也会发生栈溢出,因为函数没有结束前,函数占用的变量也不被释放,占用了栈空间。

  • 栈如果不设置容量,函数一旦无限递归,就会影响其他程序甚至操作系统,会给内存管理带来困难。当然也不能设置太大,对于多线程程序来说,每个线程都必须分配一个栈,因此没办法让默认值太大。

74. 平衡二叉树的优缺点

  • 优点:可以避免二叉排序树可能出现最极端情况(退化为链表),其平均查找的时间复杂度为logN
  • 缺点:对AVL树做一些结构修改的操作时,性能非常低下。比如:插入时要维护其绝对平衡,旋转的次数比较多,更差的是在删除时,有可能一直要让旋转持续到根的位置

75. make_shared函数的优点、缺点?

  • make_shared是一个函数模板,用于创建 std::shared_ptr。它的作用是通过单次内存分配来创建一个对象,并且返回一个管理该对象的 shared_ptr

  • 优点:相较于new构造减少了内存分配的次数,降低了系统开销,提高了效率。(make_shared 会在一个单独的内存块中同时分配对象和控制块,而new方法会分别为对象和控制块分配内存)

  • 缺点:1.只能创建共享智能指针,不能创建其它类型的指针;2.当构造函数是保护或者私有的时候无法使用make_shared函数(make_shared本质上需要访问类的构造函数来创建对象);3.会导致weak_ptr保持控制块的生命周期,连带着保持了对象分配的内存,只有当最后一个weakptr被销毁时,内存才会被释放,对于内存要求高的场景来说,是一个需要注意的问题

76. 函数调用进行的操作

77. Qt 中常用的五大模块是哪些?

  • QtCore:提供了 Qt 的核心模块,负责非图形类支持,比如事件循环、定时器、文件操作、多线程、数据结构等

  • QtGui:负责图形界面支持,主要提供是像绘图(QPainter)、图像管理(QImage)和字体的能力

  • QtWidgets:提供窗口控件(比如按钮、表单、表格、树形结构等),主要用于构建传统桌面应用

  • QtNetwork:提供网络通信功能,支持TCP、UDP、HTTP等协议编程

  • QtSql:数据库模块,提供数据库访问API,支持连接和操作主流数据库(如MySQL、SQLite等)

78. 什么是信号和槽机制?如何使用信号和实现对象间通信?

  • 信号和槽是Qt框架中用于对象间通信的机制。信号是一种特殊类型的函数,用于发出通知(对象已经发生了某个事件)。而槽是接收信号的函数,当一个信号触发时,与之相连接的将被自动调用。这样可以实现对象间的解耦和灵活的事件处理流程。

  • 其他对象如果通过 connect 建立了某个信号与自己槽函数的连接关系,当该信号触发时,Qt 框架就会根据之前的连接记录,自动调用这些对象的对应槽函数,从而进行处理。

补:使用信号和机制可以在一个对象内部或之间实现异步编程,也可以帮助开发者解耦不同组件、模块的代码,提高系统的可维护性和扩展性。

79. 为什么握手是三次而挥手需要四次

  • 三次握手是为了确保双方都有发送和接收数据的能力,如果没有第三次握手,服务器就无法确认客户端是否具备收到自己回复的能力

    • 第一次是客户端发送一个SYN报文給服务器,请求建立连接。这个报文携会携带客户端的初始序列化(seq=x);
    • 第二次是服务器回应客户端,如果同意连接,会发送一个SYN+ACK的报文,ACK会携带客户端初始化序列+1,SYN会携带服务器的初始化序列(seq=y);
    • 第三次是客户端回复服务端,当收到服务器的同意连接后,会发送一个ACK报文,携带的是服务器的初始化序列+1。这一步完成后,双方都建立好了连接,可以开始传输数据了。
  • 四次挥手是为了双方都确认彼此数据发送完毕,由于TCP是全双工通信,双方都需要分别关闭各自的发送通道。一来一回两次,就是一个方向的关闭,因为双向独立,所以需要四次挥手

    • 第一次挥手是主动关闭连接请求端发送一个FIN报文,请求断开连接,表示没有应用数据发送了。(只能收,不能发应用数据)
    • 第二次挥手是被动关闭连接请求端收到断开连接请求后做出的回应,会发送一个ACK报文。(半关闭完成)
    • 第三次挥手是被动关闭连接请求端发送一个FIN报文,表示它也没有应用数据发送了
    • 第四次挥手是主动关闭连接请求端回复被动关闭连接请求端,发送一个ACK报文,进入TIME_WAIT状态,等待2MSL时间

注意:第一次挥手后主动端只能收,而不能发应用数据,但可以发TCP控制信息(比如ACK确认),因此第四次挥手能正常应答

问题:为什么TIME_WAIT状态要等待2MSL时长?

  • MSL:指的是一个 TCP 报文在网络中能存在的最长时间,等待2MSL主要就是为了被动关闭端能接收到最后的ACK,正常关闭连接。在等待期间,被动关闭端如果长时间没有收到ACK回应,还有时间可以重新发一个FIN,主动关闭端收到FIN后继续发送ACK进行回应

80.tcp和udp的原理、区别、应用场景。

  • 1.TCP是面向连接的,通信前必须先建立连接(三次握手),结束时要四次挥手,而UDP不需要;2.TCP是可靠传输,保证数据完整、有序、无丢失,而UDP是尽最大努力交付,不保证这些;总结就是TCP慢,可靠,UDP快,不可靠
  • TCP适合准确性要求高的应用:网页浏览、电子邮件、远程登录
  • UDP适合实时性要求高但允许偶尔丢包的应用:视频直播、语音通话、游戏

81. TCP的流量控制和拥塞控制

  • 流量控制的实现机制是滑动窗口,主要就是防止发送方发送太快,导致接收方来不及处理、缓存溢出。

    • 在三次握手完成,建立连接后,发送方发送数据给接收方,接收方会回复一个报文,这个TCP报文(确认ACK报文)的首部有一个窗口大小字段,接收方通过这个字段告诉发送方,它还能接收的字节数,发送方根据通知的这个大小来控制后续发送的数据量
    • TCP 首部里面确实有一个 Window Size 字段,它是动态变化的,实时反映接收方的缓冲区剩余大小
    • 发送方可以是客户端,也可以是服务器端,接收方同理也可以是客户端或服务器端
    • 窗口更新并不一定非得等收到数据之后,接收方可以单独发一个 ACK 报文更新窗口大小,即使没有数据(叫作“窗口更新”)
  • 拥塞控制是为了避免网络出现拥塞崩溃,即太多数据注入到网络中,导致路由器、链路处理不过来,产生丢包延迟上升,严重时整个网络瘫痪。TCP 拥塞控制分成四大阶段:

    • 慢开始:初始时,发送方的拥塞窗口(cwnd)很小(通常为一个最大报文段长度),每收到一个 ACK(确认应答),cwnd 加倍(指数增长),主要就是先一点点试探网络能力

    • 拥塞避免:当cwnd大于等于ssthresh(慢启动门限)时,就不能指数增加了,否则网络容易冲爆,这时cwnd呈线性增长

    • 快速重传:当发送方收到三个重复的ACKs时,即认为发生了数据包丢失,不等待超时就立即重传丢失的数据包。

      • 注意:如果接收方发现收到的数据包不是它期待的数据(乱序),就会不停地回复上一次正确接收的序号的 ACK
      • 超时重传:当发送方发送一个数据包后,会启动一个定时器。如果在这个定时器到期之前没有收到该数据包对应的确认(ACK),则认为这个数据包可能已经丢失,并触发重传操作
    • 快速恢复:当发送丢包后避免像慢启动那样剧烈地减少拥塞窗口(cwnd),而是采用一种更加温和的方式调整发送速率,调整cwnd和ssthresh,从而提高整体的发送效率

82. HTTP和HTTPS

  • HTTP是一种用于在Web浏览器和Web服务器之间传输超文本的应用层协议。它基于请求-响应模型工作,客户端发送一个请求到服务器,服务器处理该请求并返回一个响应
  • HTTPS是HTTP的安全版本,通过SSL或TLS加密来保护通信安全。这意味着从客户端到服务器的所有通信都是加密的。

注意:HTTP作为一种应用层协议,主要用于Web浏览器和Web服务器之间进行信息交换,例如请求网页、提交表单等。它依赖于下面各层提供的服务来保证数据能够正确无误地传输。在实际操作中,HTTP通常使用TCP作为其传输层协议,以确保数据传输的可靠性。

补:OSI模型(开放系统互联模型)是一个概念性的框架,用于理解不同网络协议如何交互并促进数据在网络上的传输

83. HTTPS用到的是对称加密还是非对称加密?分别体现在哪里?

  • HTTPS实际上结合使用了对称加密和非对称加密,以确保数据传输的安全性和效率

    • 非对称加密主要体现:在TLS的握手阶段,用来安全传输对称加密所需要的密钥
    • 对称加密主要体现:在真正传输数据阶段,用来加密大量数据通信内容。
  • 非对称加密:采用了一对密钥,一个是公开密钥(公钥),另一个是私有密钥(私钥)。公钥用于加密信息,而私钥则用于解密。

  • 对称加密:指的是加密和解密使用相同密钥的方式。这意味着发送方和接收方必须事先共享这个密钥,并且保证其保密性,以确保只有他们能够加密和解密信息。

  • 详细流程:

    • 初始时,服务器有一对密钥(公钥和私钥),客户端(浏览器)发起 HTTPS 请求
    • 服务器发送自己的数字证书,里面有公钥(Public Key)
    • 客户端验证证书的合法性(是否被受信的证书颁发机构 CA 签名过),合法的话客户端会生成一个“随机对称加密密钥”
    • 客户端使用服务器公钥加密这个随机对称密钥,发送给服务器
    • 服务器用自己的私钥解密,拿到这个随机对称密钥
    • 至此,双方拥有了同一个对称加密密钥,后续数据传输就可以用对称加密了(即通过相同密钥进行加密和解密)

84. HTTP/1.0 和 HTTP/1.1 的主要区别

  • 连接方式:

    • HTTP/1.0:默认短连接,也就是即每次请求和响应之后都会关闭TCP连接。这意味着每个HTTP请求都需要建立一个新的TCP连接,重新三次握手,非常耗时
    • HTTP/1.1:默认长连接,允许在一个TCP连接中发送多个请求和响应。显著减少了建立和断开TCP连接的开销,提高了效率。
  • 请求的改进:HTTP/1.0只支持GET、POST、HEAD方法,功能比较简单,HTTP/1.1新增了PUT、DELETE、OPTIONS、TRACE等方法,提供了更丰富的交互方式。

  • 错误状态码:在版本HTTP/1.0的基础上增加了更多的状态码

总结:HTTP/1.1 比 HTTP/1.0 更快(长连接)、更灵活(更多方法)、更高效(缓存优化、分块传输),而且支持虚拟主机。

85. get和post区别

GET和POST是HTTP协议中最常用的两种请求方法,它们的区别:

  • 作用:GET用于从服务器获取资源,如网页的加载、搜索查询等;而POST主要用于向服务器提交数据

  • 数据传输方式:GET是将参数附加在URL后面作为查询字符串发送给服务器,由于URL长度限制,GET请求的数据量有限;POST是通过HTTP请求体发送数据,不在URL中显示,因此它的大小限制主要取决于服务器设置而非URL长度

  • 安全性:GET因为参数是暴露在 URL 上的,所以不安全,而POST就相对更安全

  • 幂等性:GET被认为是幂等的,即多次执行同样的GET请求应该产生相同的效果,不会对服务器端资源造成影响;而POST不是幂等的,同样的POST请求多次执行可能会导致不同的结果

86. WebSocket 是什么?

  • WebSocket是通过HTTP协议来启动连接建立的过程,但一旦握手完成,后续的通信就完全按照WebSocket协议来进行。WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它让客户端和服务器之间可以建立一个持续的连接,双方都可以随时主动发送数据,不需要每次通信都重新建立连接
    • 客户端首先发起一个标准的HTTP请求到服务器,请求中包含特殊的HTTP头部信息,表明这是一个升级为WebSocket协议的请求
    • 如果服务器支持WebSocket协议,并且愿意接受此次升级请求,则会返回一个HTTP 101状态码。响应中同样包含特定的头部字段来确认升级
    • 一旦握手成功完成,TCP连接就不再遵循HTTP协议,而是转变为WebSocket协议。此时,双方可以通过这个连接进行双向、实时的数据交换,而无需像传统的HTTP那样每次交互都需要重新建立连接
  • 为什么需要 WebSocket:WebSocket 的出现是为了弥补传统 HTTP 协议在某些场景下的不足,特别是在需要实时通信和低延迟的应用中。HTTP 是一种请求-响应模型,客户端必须主动发起请求,服务器才能返回数据

87. 网络通信模型

两种常见的网络通信模型是五层模型和七层模型

  • 七层模型:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层

  • 五层模型:应用层、传输层、网络层、数据链路层、物理层

  • 应用层:负责为应用程序提供网络服务接口,包括HTTP、FTP、SMTP等协议

  • 传输层:管理端到端的通信会话,确保数据可靠地从一个节点传输到另一个节点。常见的协议有TCP和UDP

  • 网络层:负责路由选择和数据包转发,决定数据如何从源地址发送到目的地址。这一层还涉及到逻辑地址(如IP地址)的分配和管理

  • 数据链路层:

  • 物理层

应用层(应用程序生成数据)—>传输层(头部添加TCP或UDP信息)—>网络层(添加IP头部信息)—>数据链路层(添加MAC头部和尾部信息)—>物理层(讲数据帧转换为电信号或其它形式的信号,通过物理媒介传输到目标设备)

88. DNS服务器用的是什么协议

  • DNS服务器(域名系统服务器)是一种提供域名解析服务的服务器。它将域名转换为对应的IP地址,以便计算机能够在互联网上找到并访问目标服务器。

  • DNS服务器大部分使用的是UDP协议,少数情况会使用TCP协议。因为DNS的查询请求数据都比较小,而UDP可以满足,且速度快,开销低。以及DNS查询本身是简单的“请求-应答”模式,也就不需要持续的双向通信,所以在大多数情况下,UDP协议就可以满足,且更高效

  • DNS查询过程:1.当用户尝试访问一个网站时,其设备上的浏览器会首先检查本地缓存是否有该域名对应的IP地址。2.如果没有,则向配置的递归DNS服务器发送查询请求。这个服务器负责代表客户端完成整个解析过程,向其他权威的DNS服务器询问直到获得答案。3.一旦找到正确的IP地址,这个信息就会沿着查询路径返回给最初的客户端,同时也会被沿途的DNS服务器缓存起来以便未来快速响应类似请求。

补:DNS不仅是一种服务,也是一种协议。DNS协议主要工作在应用层

89. ping命令用的是什么协议?在哪一层。

  • ping命令主要是用来测试连通性、测延迟、诊断网络问题,用的是 ICMP 协议,ICMP是网络层协议(严格来说属于IP层的子协议)。
  • 过程:当在终端输入 ping www.example.com,系统会先把这个域名解析成IP地址(DNS查询);然后用ICMP协议构造一个回显请求 报文,发到目标IP;目标主机会回复一个回显应答报文回来;客户端根据收到的回复,显示结果(网络的一些情况)

补:1.ICMP是没有端口号的,因为端口号是传输层的概念,而ICMP在网络层;2.ping失败常见原因:目标主机关闭了ICMP响应、防火墙拦截了ICMP等;3.ping虽然用的是ICMP协议(在网络层),但整个处理过程仍然严格遵循五层模型,从应用层开始,从上到下逐层处理,只不过某些层的工作量很轻

90. 如果解析http请求的时候,用户一次性没传完数据,怎么办

  • 在解析 HTTP 请求时,发生这种情况是常见的,特别是在网络不稳定或客户端分块发送数据的情况下。为了解决这个问题,通常需要设计一个渐进式解析器,用于逐步解析流式数据。
  • 能够在接收到部分数据时就开始解析,并在后续数据到达时继续完成解析过程,而不需要一次性接收全部数据。通过维护内部状态和使用缓冲区保存未完成的数据片段,渐进式解析器可以逐步解析各个部分

91. 路由器

  • 路由器是网络层的一个设备,是一种具有多个输入/输出端口的专用计算机,其任务是连接不同的网络(异构网络)并完成路由转发。在多个逻辑网络(多个广播域)互联时必须使用路由器

    • 源主机和目标主机在同一个网络(同一个广播域)时,使用的是直接交付(不需要路由器转发,只是经过路由器)
    • 源主机和目标主机不在同一个网络时,需要路由器转发(间接交付)
    • 路由器隔离了广播域(一个广播域就可以称为网络)
  • 路由器的两个功能:路由选择和分组转发

    • 路由选择:路由选择是指路由器决定数据包从源地到目的地的最佳路径的过程。解决的是“这个数据包应该往哪个接口发?”
    • 分组转发:分组转发是指路由器根据已经建立好的路由表,将接收到的数据包从正确的接口发送出去的过程。解决的是:“这个数据包现在要从哪个口发出去?“

路由表:它包含了一系列规则,帮助确定从一个网络到另一个网络的数据包应该通过哪个接口发送出去以及下一跳应指向哪个网关。当一个数据包到达网络设备时,设备会检查其路由表,寻找与目标IP地址最匹配的路由条目,并依据该条目指定的接口和下一跳地址将数据包转发出去

92. 路由表为空怎么找到下一跳

  • 当路由表为空时,路由器或主机将无法确定如何转发数据包到非本地网络的目的地。如果路由表为空,路由器根本无法找到下一跳,数据包无法转发,一般会直接丢弃数据包,并根据情况返回一个 ICMP (目标不可达)错误给源主机。因此为了避免路由表完全为空,通常会配置一个默认路由,如果查不到详细路由,就把数据包发往默认下一跳。

93. 粘包拆包是什么,发生在哪一层

粘包和拆包都是网络通信中常见的两个现象

  • 粘包:指的是在传输层协议中,由于数据传输机制的原因,多个小的数据包可能会被合并成一个较大的数据包进行发送,或者相反地,单个大的数据包被分割成多个小的数据包发送。因此,在接收方可能无法区分发送方发出的不同消息边界,接收方就需要根据一些方法自行解析这些数据包并进行重组(udpz这种基于消息的协议一般不会出现这种问题)

    • 发生粘包的原因:1.为了提高网络效率,减少小包的数量,TCP协议栈可能会将多个小的数据片段合并成一个更大的数据包发送。2客户端的发送频率远高于服务器的接收频率,就会导致数据在服务器的tcp接收缓冲区滞留形成粘连
    • 粘包问题通常发生在传输层(如TCP)与应用层之间。虽然它是由于传输层的行为引起的,但是解决这个问题往往需要在应用层实现特定的规则来正确解析接收到的数据流
  • 拆包:是当发送的ip数据报太大,超过所支持的最大传输单元,而无法一次性传输时,该数据报会被分割成若干个小的数据片段,每个片段作为一个独立的IP数据报进行传输。目标主机上的IP层负责重新组装这些片段为原始的数据报。

    • 拆包则是网络层(IP层)的现象

94. TCP在什么情况下会出现大量time_wait

  • 在高并发短连接的情况下会出现大量的time_wait,即客户端和服务器迅速建立连接,但传输少量数据后就立即关闭,这种情况下大量的并发短连接会导致系统中有大量的TIME_WAIT连接

95. TCP 建立连接过程,SYN + ACK包能不能拆开来发

  • SYN+ACK是一个TCP报文段,它不是两个可以拆开的包,而是一个包中同时设置了 SYN 和 ACK 两个标志位。在规范 TCP 建连流程中,不允许将它们拆分为两个报文发送
  • 如果拆成两个包会发生什么:客户端会认为这是异常行为,可能丢弃或忽略后续的 ACK 包,客户端可能进入错误状态,导致连接无法正常建立。而且从效率(合并SYN和ACK为一个数据包减少了通信次数)和简化实现考虑,SYN+ACK没有必要拆分来发,也不能这样做

96. 黏包怎么解决?

  • 粘包问题是由于TCP是一个流式协议以及它数据传输过程中的一些机制引起的

    • TCP是流式协议就意味着,它只保证发送数据的准确性和顺序性,而没有消息边界(TCP并不关心你发送的数据是由多少个独立的消息组成的)
  • 处理粘包的方式主要采用应用层定义收发包格式的方式,这个过程俗称切包处理,常用的协议被称为TLV协议。

  • 这种方法是在每一个要发送的数据内容前面加入一段固定格式的头部信息,这段头部通常包括:要发数据的类型和长度。比如说客户端要发送一些业务数据给服务器,在客户端应用层这边会先在这个数据前面加入一个固定大小的头部信息(如5字节,1字节描述类型,剩下4个字节描述长度);在服务器的应用层先缓存接收到的数据,确保读到完整的头部(5字节)后再解析,了解接下来要收的数据类型,要收多少字节,然后根据记录的长度继续读取剩下的部分。这样就可以解决黏包问题了

97. 阻塞和非阻塞(网上两种解释)

第一种:

  • 阻塞:指的是当发起一个I/O请求后,程序会暂停当前线程的执行,直到该I/O操作完成并返回结果为止。在这段时间里,线程处于等待状态,无法执行其他任务
  • 非阻塞,指的是当发起一个I/O请求后,如果I/O操作不能立即完成(例如没有数据可读或缓冲区满了),相应的系统调用(如read()write())不会挂起当前线程或进程,而是立刻返回一个错误代码(通常是EAGAINEWOULDBLOCK)。这允许应用程序继续执行其他工作,而不是等待I/O操作完成。

第二种:

  • 一个典型的网络IO接口调用,分为两个阶段,分别是数据准备数据读写

    • 数据准备阶段指的是数据到达内核缓冲区或 socket 状态可读/可写这一过程。此阶段的阻塞与非阻塞,决定了应用在调用 API 时是等待内核准备数据,还是立即返回由应用自行轮询检查。
    • 数据读写阶段是指从内核缓冲区与用户空间之间的数据传输
  • 在数据准备和数据读写阶段,如果fd是阻塞的,应用程序都会阻塞在函数调用处(read),需要等待内核准备好数据,再将数据从内核缓冲区拷贝到用户空间(应用程序来完成)。如果fd是非阻塞的,在数据准备阶段,应用程序在函数调用处(read)不断返回,进行轮询检测内核的数据有无准备好;在数据读写阶段,应用程序还会阻塞在函数调用处进行数据读取,将数据从内核缓冲区读到用户空间。

补:五种IO模型:阻塞、非阻塞、IO复用、信号驱动(异步+同步)、异步

98. 为什么非阻塞几乎总是和IO复用一起使用

  • 单独使用非阻塞模型时,如果在循环中不断进行读取,它会一直忙轮询在函数调用处,这样太浪费CPU资源了

  • 单独使用IO复用(默认是阻塞的)不够完善,因为在处理的过程中,像read()、write()等函数都会阻塞当前线程,这样线程就不能及时处理其它socket上的IO事件了(无法及时响应其他事件)

  • 因此为了高效管理大量并发连接并避免 CPU 浪费,通常将非阻塞 I/O 与 I/O 复用结合使用,从而提高效率

99. 相比select和poll,为什么epoll更好

  • epoll 相较于 select 和 poll 在处理大量文件描述符(尤其是高并发场景)时表现得更为出色

  • 性能优势:

    • select和poll每次调用都需要重新扫描整个文件描述符集,随着文件描述符数量的增加,效率显著下降
    • epoll则不需要每次都遍历所有注册的文件描述符,它底层是使用一个事件通知机制,当某个描述符有事件触发时,内核会将其添加到就绪队列,最后只需要返回这个就绪队列中的描述符即可,大大提升了效率
  • epoll 在内部使用高效的结构(如红黑树或哈希表)来管理所有注册的文件描述符。这样做的好处是,添加、删除和查找文件描述符的操作都非常高效

  • 对于select和poll,每次调用都需要将整个文件描述符集合从用户态复制到内核态,并且在返回时还需要再次复制回用户态。对于频繁的I/O操作来说,这会增加系统开销。而epoll不需要这样(底层采用了事件驱动机制和高效的数据结构)

由于上述提到的优点,特别是在处理成千上万个并发连接时,epoll 显示出了明显的优势。它可以有效地管理大规模的文件描述符集合,而不会像 selectpoll 那样随着文件描述符数量的增长而导致性能急剧下降。

100. epoll实现原理,epoll使用的哪种模式

  • epoll 的实现基于事件驱动模型,首先是需要通过epoll_creat()先创建一个epoll实例;再通过epoll_ctl()来控制这个epoll实例上的文件描述符,即对epoll树上的文件描述符进行删除和修改,或添加新的描述符;最后就是通过epoll_wait()等待事件触发
  • epoll支持两种工作模式:水平触发(LT)和边沿触发(ET)
    • LT模式:在LT模式下,即使之前没有完全读取所有可用的数据,在后续的epoll_wait调用中,只要仍有数据可读,该文件描述符就会继续被标记为就绪。也就是说在第一次epoll_wait时,没有读完某个fd缓冲区中的数据,在第二次调用epoll_wait时,它还会被标记为就绪的fd来返回
    • ET模式:如果应用程序未能在第一次事件触发时读取完所有数据,在没有新的数据到达的情况下,后续的 epoll_wait 调用不会再次报告该文件描述符已就绪

补:

  • 边沿触发(ET):只在状态变化时(如从不可读变为可读)触发一次事件通知。
  • 非阻塞I/O:当尝试读取或写入操作时,如果当前不能立即完成,则立即返回而不是等待。

注意:select 和 poll 主要采用一种类似于水平触发的方式工作,它们没有实现像 epoll 那样的边沿触发模式

补:事件驱动指的是应用程序可以监听多个文件描述符的状态变化(如数据到达、连接请求等),并在状态变化时得到通知。这与传统的轮询方式不同,后者需要不断检查每个文件描述符的状态,效率较低。

101. 怎么理解IO多路复用机制的

  • 多路复用机制主要用于解决如何高效管理多个I/O操作的问题,特别是在处理大量并发连接时。其核心思想是通过一个线程或进程同时监控多个文件描述符(如网络套接字),以此确定哪些描述符事件触发,可以进行读写操作。

102. select和poll底层实现

  • select底层实现是通过 select() 向内核传递三个 fd 集合(readfds, writefds, exceptfds),本质上是三个大小固定的位图。内核会遍历所有传入的文件描述符,检查是否有就绪事件,如果没有事件,线程会被挂起,如果有事件发送,内核在返回前会修改传入的 fd 集合,清除未就绪的 fd。而poll和select类似,但底层实现是维护一个pollfd结构体组成的数组或链表
  • 但由于 poll 不受固定大小的限制,因此在某些情况下可能比 select 更加灵活和适用。然而,对于非常大量的文件描述符,这两种方法都不是最高效的解决方案,这时通常推荐使用 epoll 等更高级的I/O多路复用机制

103. select为什么只能支持1024个?poll和epoll是怎么解决这个问题的

  • 因为select底层是通过大小固定的位图来实现的,这些位图并且受限于系统定义的最大值(通过一个宏),所以就限制了select可以处理的文件描述符数量。而poll底层是使用一个结构体数组或链表来存储fd以及关心的事件,所以理论上可以根据需要支持任意数量的文件描述符。而epoll底层则是根据红黑树,通过事件驱动设计,不仅解决了文件描述符的数量限制,还显著提高了在高并发场景下的性能表现

104. epoll底层为什么用红黑树不用hash

  • 支持高效的插入/删除/查找:红黑树保证了 O(log n) 时间复杂度的插入、删除和查找操作;虽然哈希表在理想情况下可以达到 O(1) 的查找时间,但这是基于理想的哈希函数和低碰撞率的前提下的。在实际应用中,处理哈希冲突可能会影响性能,并且哈希表的扩容和重组操作也可能带来额外的开销
  • 稳定性和可预测性:红黑树提供了更加稳定和可预测的时间复杂度,这对于系统级别的编程尤其重要。尽管哈希表平均情况下表现优异,但在最坏情况下(例如大量哈希冲突),其性能可能会大幅下降。对于像 epoll 这样的底层机制,确保一致的性能表现是至关重要的。

105. Qt中的信号与槽机制,说明其原理及使用场景

  • 信号与槽是Qt开发当中最核心的机制。用于实现对象之间的通信。当某个事件发生时,对象会发出一个信号,而槽是对信号的响应函数。一个信号可以连接多个槽,多个信号也可以连接同一个槽。信号和槽的连接是通过QObject类里面的connect()函数完成的,当信号一触发,与它连接的槽就会被自动调用。
  • 使用场景:
    • 图形用户界面(GUI)开发:按钮点击事件处理、菜单项选择、定时器事件
    • 事件驱动的多模块通信:不同窗口或组件之间的状态同步、跨模块事件通知

106. 请讲述Qt的元对象系统,主要有哪些组成部分和作用

  • 主要由三个部分组成:QObject类、元对象编译器(MOC)、信号与槽机制

  • 作用:

    • 提供信号与槽机制,实现对象之间的安全通信、高效通信
    • 事件处理:更灵活地管理事件的分发和处理流程(自定义事件的处理,这通常涉及重写 event() 方法或特定事件处理函数)

107. Qt框架当中,有哪些布局管理器?使用场景?

  • 水平布局:常用于将多个部件水平排列(从左到右)
  • 垂直布局:常用于将多个部件垂直排列(从上到下)
  • 网格布局:常用于创建表单或矩阵形式的布局

108. 阐述一下Qt中的事件处理机制,如何自定义事件处理?

事件处理机制是Qt开发中核心特性之一,可以通过灵活的方式来管理和响应标准事件(如鼠标移动、点击、键盘输入等)。这些事件可以由用户交互(如鼠标点击、键盘输入)、系统通知(如定时器到期)产生,过程如下:

  • 事件:在 Qt 中,事件是由 QEvent 类及其子类表示的对象。每个事件对象都有一个类型标识符(type()),用于区分不同种类的事件,比如鼠标事件、键盘事件、定时器事件等。

  • 事件分发器:Qt 应用程序运行在一个事件循环中,这个循环不断地从系统的事件队列中取出事件,并将它们分发给相应的对象进行处理。事件分发通常通过 QCoreApplication::exec()QApplication::exec() 启动。

  • 事件接收者:事件被分发到具体的 QObject 子类对象上,这些对象负责处理事件。如果某个对象不处理特定类型的事件,则该事件会被转发给其父对象,直到找到一个能够处理该事件的对象或最终被丢弃。

  • Qt中的事件处理是基于事件循环和事件队列。事件首先会加入到事件队列当中,然后由事件循环不断的从事件队列中取出事件,并且根据事件的类型分发给相应的对象器处理。每一个通过QOject派生出来的类,它都可以重写事件处理函数来自定义事件的响应逻辑。同时支持事件过滤和自定义事件的发送,实现了灵活、高效的事件驱动编程模型。

  • 自定义事件处理:

    • 1.创建自定义事件类:继承QEvent并定义一个事件类型,在构造函数中添加需要传递的数据
    • 2.发送事件:使用 QApplication::sendEvent() 同步发送事件,或使用 QApplication::postEvent() 异步发送事件到指定的目标对象。
    • 3.处理事件:在目标对象中重写 event() 方法来捕获并处理自定义事件。在该方法中需要检查事件类型,如果是自定义事件类型,则进行相应的处理逻辑。

补:自定义事件处理:涉及创建新的事件类型,并且这些事件不是由系统自动产生的,而是由应用程序自身根据需要手动创建和发送的。

109. Qt的内存管理机制?与传统C++内存管理区别?

  • Qt内存管理机制:采用基于对象树的内存管理方式。在创建一个QObject派生类对象的时候,如果这个对象的父对象被指定了,那么该对象会被添加到父对象的子对象列表中,当父对象被销毁的时候,它会自动销毁其所有的子对象,确保这些子对象所占用的资源也被释放,这样就可以避免内存泄漏的问题。这种机制它能够使得内存管理更加的自动化和方便

  • 区别:

    • 手动内存管理和自动管理:传统C++的内存管理主要是通过手动new和delete来分配和释放;而Qt是通过父子关系和智能指针来处理的,很多情况下都可以自动管理
    • 对象生命周期管理的便捷性:传统C++中管理对象的生命周期较复杂,比如说一个复杂的系统,需要设计类的构造函数和析构函数,并确保对象在正确的时间被创建和销毁;Qt通过父子关系简化了对象生命周期的管理。只要正确设置了父子关系,对象的生命周期就会自动被管理。这种层次结构使得对象生命周期管理更加直观和易于理解,减少了人为错误的可能性

110. Qt如何实现多线程?阐述QThread的基本流程

  • 在Qt中,实现多线程主要有两种方式,一种是继承QThread类并重写其run()函数;另一种是使用Qt的线程池QThraedPool结合QRunnable类

  • QThread的基本流程:

    • 创建一个新类,即子线程类,让其继承QT中的线程类QThread
    • 在新类中重写父类的 run() 方法,在该函数内部编写子线程要处理的具体的业务流程
    • 在主线程或其它合适的位置创建子线程对象,new 一个就可以了
    • 调用该实例对象的start()方法,启动子线程
    • 当子线程执行完毕或不再需要时,可以调用quit()

111. 在qt中使用了多线程,有些事项是需要额外注意的:

  • 默认的线程在Qt中称之为窗口线程,也叫主线程,负责窗口事件处理或者窗口控件数据的更新
  • 子线程负责后台的业务逻辑处理,子线程中不能对窗口对象做任何操作,这些事情需要交给窗口线程处理
  • 主线程和子线程之间如果要进行数据的传递,需要使用Qt中的信号槽机制(即如果子线程需要对窗口的数据进行修改,只能先通过connect发送给主线程,由主线程对窗口进行修改,而子线程不能直接对窗口进行修改)。

112. Qt中图形绘制的基本原理及常用的绘图类

  • 原理:图形绘制是指通过在绘图设备上使用绘图工具进行图形的绘制。绘图设备可以是窗口部件(QWidget/QMainWindow等)、图片对象(QPixmap/QImage等)。绘制过程通常在部件的painEvent()函数中进行,当部件窗口需要重绘的时候,painEvent()函数会被自动调用。

  • 常用的绘图类:1.QPainter类(最基本也是最常用)用于绘制基本形状、文本等;2.QBrush类用于设置填充图案;3.QPen类用于设置线条样式

  • 手动重绘两种方法:

    • update() :它不会立即导致重绘,而是将一个绘制事件加入到事件队列中,

      • 优点:性能较好多次调用 update(),Qt只会生成一个 paintEvent(),这有助于减少不必要的绘制操作
      • 缺点:不是立即刷新,可能有视觉延迟。如果更新太频繁,可能导致部分更新被合并而丢失中间状态
    • repaint():立即调用 paintEvent(),强制立即重绘(不建议使用)。

      • 优点:立即刷新,画面更新无延迟,适合实时性要求高的场景

      • 性能差,多次调用 repaint() 会导致多次重绘,浪费资源

113. Qt的模型视图架构及优点(不懂)

114. Qt中如何进行文件读写操作

  • 在 Qt 中进行文件读写操作可以通过多种方式实现,主要就是使用 QFile类 、 QTextStream 类和 QDataStream类来完成的

    • 单独使用 QFile:可以进行基本的文件读写操作,但处理复杂的数据结构(如文本、数字、对象等)会比较繁琐
    • QFile结合QTextStream:非常适合处理文本数据,能够简化文本文件的读写操作
    • QFile结合QDataStream:适用于处理二进制数据
  • 使用:创建一个Qfile对象,主要是用它来打开和关闭文件,也可以直接基于Qfile对象调用读写函数来进行读写,但常用的还是将它结合QTextStream和QDataStream这两个类来使用,具体使用方法就是将Qfile对象作为参数传给QTextStream和QDataStream的对象,然后基于它们的对象进行读写

115. Qt开发中,如何实现应用程序的国际化支持,讲述其步骤

116.Proactor模式和Reactor模式

  • Proactor 模式和 Reactor 模式是两种不同的事件处理模型,主要用于处理并发网络操作
  • Proactor模式(前摄器模式)是一种异步 I/O 模型。它的主要思想是应用程序调用异步操作时,具体实现都是由操作系统或内核来完成,完成后内核或操作系统再通知应用程序来进行结果处理。Boost.Asio 中的异步操作就是采用这种模式工作的
    • 应用程序发起异步操作,操作系统开始执行这个异步 I/O
    • 当 I/O 完成,操作系统通知应用程序(如读/写完成事件)
    • 应用程序只处理完成事件的后续操作,而不需要主动读写
  • Reactor模式(反应堆模式)是一种同步 I/O 模型。应用程序注册监听事件,当事件就绪时,操作系统通知应用程序,再由程序负责处理这个事件(包括读写)
    • 应用程序注册事件(如读、写)到 Reactor(事件多路复用器,如 epollselect)
    • 当 I/O 就绪时,Reactor 被触发,并调用注册的回调函数(事件处理器)
    • 应用程序就进行读写操作

补:同步和异步(关注谁来执行和等结果),同步是指调用方等待操作完成才能继续执行,操作完成的过程由调用者自己等待和处理;异步是指调用方不等待操作完成,而是立即返回,等完成后再被通知

阻塞和非阻塞(关注程序是否挂起),阻塞是指当执行 I/O 时,如果数据没准备好,程序就会挂起等待结果;非阻塞是指执行I/O操作时会立即返回,不管数据准备好没,程序可以继续执行其他操作。

117.如果一个程序中申明了多个命名空间,会有什么隐患

  • 可能会造成命名空间的污染,因为多个命名空间里面可能包含相同的内容(API),比如说智能指针等

118. boost.asio进行网络编程的流程

  • 对于服务端:

    • 创建io_context和网络端点endpoint,io_context是负责管理异步操作、事件循环的,而网络端点是定义本机ip和端口的
    • 创建接收器acceptor,它是基于刚刚创建的io_context和网络端点来初始化的接收器对象
    • 创建一个套接字socket,它是基于io_context初始化的,当与客户端连接成功后,该套接字就负责与新连接进行通信
    • 这些都创建好后,就通过接收器对象调用accept(socket)函数来进行监听连接
  • 对于客户端:

    • 创建io_context和网络端点endpoint,网络端点定义的是要连接服务端的IP和端口
    • 创建socket套接字,基于io_context来初始化的
    • 通过套接字对象调用connect(endpoint)函数来向服务端发起请求
  • 连接成功后,客户端和服务端都可以通过各自的 tcp::socket 对象来进行数据的发送和接收。

119. Protobuf和JSON

  • Protobuf和 JSON都是用于数据序列化和反序列化的工具

  • 在网络编程中,尤其是使用像TCP这样的流协议时,直接发送结构体可能会出现问题,这时就需要将结构体序列化为某种格式的数据(如文本或二进制),以便于在网络上传输。Protobuf 和 JSON 是两种非常流行的解决方案,它提供了高效且跨平台的数据序列化机制,非常适合用于网络通信中的数据交换

  • protobuf流程:

    • 定义消息格式:通过 .proto 文件定义消息格式。
    • syntax = "proto3";
      message Book
      {
         string name = 1;
         int32 pages = 2;
         float price = 3;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      + 编译 `.proto` 文件:使用 `protoc` 编译器将 `.proto` 文件编译为目标语言的代码(如 C++, Java, Python 等)。
      + 序列化/反序列化:使用生成的代码进行数据的序列化和反序列化操作。
      + ```c++
      int main()
      {
      Book book;
      book.set_name("CPP programing");
      book.set_pages(100);
      book.set_price(200);
      std::string bookstr;
      book.SerializeToString(&bookstr); //序列化
      std::cout << "serialize str is " << bookstr << std::endl;
      Book book2;
      book2.ParseFromString(bookstr); //反序列化
      std::cout << "book2 name is " << book2.name() << " price is "
      << book2.price() << " pages is " << book2.pages() << std::endl;
      getchar();
      }
  • JSON流程:

    • 构造一个JSON::value节点

    • 通过这个节点编写要发送的数据(类似于key-value的形式),然后通过toStyledString将该节点序列化为文本格式的字符串

    • 发送序列化的字符串给对端,发送之前为了防止粘包先要发送序列化后的数据长度,还要将本地字节序转化为网络字节序

    • int main()
      {
          Json::Value root;
          root["id"] = 1001;
          root["data"] = "hello world";
          std::string request = root.toStyledString();           //序列化
          std::cout << "request is " << request << std::endl;
          Json::Value root2;
          Json::Reader reader;
          reader.parse(request, root2);            //反序列化
          std::cout << "msg id is " << root2["id"] << " msg is " << root2["data"] << std::endl;
      }
      
      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
      35
      36
      37
      38
      39
      40
      41
      42
      43

      + Protobuf 序列化后的数据是以二进制形式存在的。这种格式非常紧凑,因此相比于文本格式(如 JSON),它占用更少的带宽,并且在序列化和反序列化过程中通常更快。由于它是二进制的,所以对人类来说不易读,但在机器处理上效率很高。protobuf方式会经常用来处理服务器与服务器之间的通信(服务之间的调用)

      + 优点:体积小、传输快;
      + 缺点:不可读性

      + JSON 序列化后的数据是一种文本格式,易于人阅读和编写,同时也易于机器解析和生成。JSON 数据以文本字符串的形式存在,这使得它非常适合用于调试和快速开发。json序列化方式经常用来做客户端和服务器之间的通信
      + 优点:可读性好、灵活性高(无需预先定义数据结构,适合快速迭代开发)
      + 缺点:效率低

      ### 120. boost.asio里面除了使用线程来完成并发操作,还有其它方法吗

      + 还可以使用协程来完成并发操作。协程是一种“可以暂停执行”的函数,当遇到一个异步操作时(如 `async_read_some`),协程可以挂起自己,并释放当前的资源,待异步操作完成后,自动恢复执行
      + 优点:协程调度比线程调度更轻量化,因为协程是运行在用户空间的,而线程可能涉及用户空间和内核空间切换

      + co_spawn:启动一个协程任务,绑定到某个执行器上运行。如:co_spawn(io_context, listener(), detached)
      + co_await:挂起协程,等待异步操作完成
      + use_awaitable:告诉 Asio 异步操作应该以协程方式处理

      ### 121. 两者并发设计模式actor和CSP

      + actor模式是通过消息传递的方式来处理并发问题的,它的好处就是可以消除共享状态,而不用考虑锁机制。比如说多个线程想要访问同一个共享资源(读和写),就可以设计一个专门用于管理对该共享资源的逻辑类,就将所有线程对该共享资源的访问请求封装成任务并发送给这个逻辑类中的队列,逻辑类就负责依次从队列中取出任务并安全的处理这些请求,处理完一个请求就返回结果给对应的线程
      + CSP和Actor类似,只不过CSP将消息投递给channel,不关注谁从channel中取数据和发送的一方是谁。而Actor在发送消息前是知道接收方是谁,接受方收到消息后也知道发送方是谁,更像是邮件的通信模式。而csp是完全解耦合的。

      总结:但它们有一个共同的特性:**要通过共享内存来通信,而是通过通信来共享内存**

      ### 122. Qt如何建立与数据库的连接

      + 安装ODBC驱动程序,并配置好了数据源

      + 创建数据库表,在Qt项目文件(`.pro`)中添加了`QT += sql`,以便链接Qt的SQL库

      + 在Qt中加入相关头文件,使用`QSqlDatabase::addDatabase()`方法并指定"QODBC"作为驱动名称来创建一个新的数据库连接

      + 然后配置连接参数,如数据库名称、主机名、端口、用户名和密码等。

      + ```c++
      // 创建数据库连接
      QSqlDatabase db = QSqlDatabase::addDatabase("QMYSQL");
      db.setHostName("localhost"); // 数据库主机地址
      db.setDatabaseName("your_database_name"); // 数据库名称
      db.setUserName("your_username"); // 数据库用户名
      db.setPassword("your_password"); // 数据库密码
  • 一旦连接成功打开,你就可以使用QSqlQuery对象来执行SQL语句,对数据库进行查询或修改。

123. qt如何执行SQL语句,有哪些不同执行方法

  • 对于不带参数的简单SQL语句,可以直接通过QSqlQuery对象调用exec()方法来执行。

  • 也可以使用绑定值执行带有参数的查询,通过QSqlQuery对象的prepare()函数来完成,最后调用exec()方法来执行

124. Qt中执行数据库操作出现错误,如何获取和处理错误

  • 每次通过QSqlQuery对象调用exec()执行sql语句后,检查其返回状态,如果有异常,就可以通过执行query.lastError().text();来获取详细的错误信息。

125. Qt支持哪些常见的数据库驱动

  • Qsqlite、Qmysql、Qodbc、Qpsql
  • 使用上述任何一个数据库驱动,需要确保相应的Qt模块已经安装,并在项目的.pro文件中添加适当的配置

126. 什么是数据库连接池?在Qt中是否有内置的数据库连接池功能

  • 数据库连接池是一种用于管理数据库连接的技术,它通过预先创建一定数量的数据库连接并将其保存在一个池中,以便在应用程序需要执行数据库操作时能够快速获取一个可用的连接,用完后又放回池中。这可以显著减少每次请求数据库连接所需的时间,从而提高应用性能和响应速度
  • 没有,需要自己实现

127. 在qt中,show()和exec()函数的区别

  • 函数功能都用于显示窗口,但本质不同,show()函数是一种非模态显示方式(QWidget、QMainWindow、QDialog等);exec()函数是一种模态事件循环显示方式(阻塞当前代码执行)
  • 返回值区别:show()函数没有返回值;exec()函数有返回值
  • 事件处理区别:使用show()显示窗口之后,可以接收和处理事件,同时程序的其它部分也可以运行;执行exec()后,则会启动一个局部的事件循环,专门用于处理本模态对话框事件,在没有关闭之前,其它窗口的事件处理会受到限制

128. C++中有栈溢出的情况怎么解决

  • 检查递归调用是否有明确的退出条件、通过尾递归优化来优化程序、减少局部变量的使用,需要动态分配内存的情况,尽量使用智能指针或容器

129. 数据库中的一对一,一对多,多对多的关系能具体讲一下吗

  • 一对一关系:指的是两个实体之间存在一种相互唯一对应的关系,如每个人只能有一个护照,每个护照也只属于一个人
  • 一对多关系:表示一个实体可以关联到多个其他实体,但反过来,那些实体各自仅能关联到一个前者,如一个作者可以写多本书,但每本书只能由一个作者撰写
  • 多对多关系意味着任何一方都可以关联到另一方的多个实例,如一个学生可以选择多门课程,一门课程也可以被多名学生选择

130. 模板和模板特化

  • 模板是指在定义函数或类的时候,不需要指定具体的数据类型,在使用时再由编译器根据上下文自动推断

  • 模板特化在C++中主要用于为特定的数据类型提供专门的实现,从而优化性能、处理特殊情况或解决通用模板无法解决的问题。如打印std::vector<int>类型的值,默认实现可能不会给出你想要的结果(比如,它可能只是打印出一些内存地址而不是向量的内容)。这时可以通过模板特化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    template <typename T>
    void print(const T& value) {
    std::cout << value << std::endl;
    }

    //模板特化实现
    template <>
    void print<std::vector<int>>(const std::vector<int>& vec) {
    std::cout << "{ ";
    for(auto& elem : vec) {
    std::cout << elem << " ";
    }
    std::cout << "}" << std::endl;
    }

131. 假如你的代码在多线程环境下出现崩溃的现象,怎么去解决

  • 可能是竞态条件、死锁、非法内存访问、资源泄漏等问题引起。
  • 可以在容易出错的代码处打印日志,通过调试工具看有无执行到该处,特别是要检查锁、条件变量的使用是否正确;确保每次调用 newmalloc 都有相应的 deletefree 来释放内存

132. 解释一下这两个关键字Static,volatile

  • static 主要影响变量或函数的作用域和生命周期

    • 静态局部变量:当应用于函数内部的变量时,它使得该变量在整个程序运行期间都存在(而不是每次函数调用时创建并销毁),但其作用域仅限于定义它的函数内
    • 静态全局变量/函数:主要是限制它们的作用域在声明所在的源文件中
    • 静态成员变量/函数:static成员属于类本身而非任何对象实例,所有对象共享同一份副本
  • volatile 主要用于告诉编译器不要对该变量进行优化,它可能在程序之外被修改(如硬件中断、另一个线程等)。这确保了每次访问volatile变量时都会从内存中读取最新的值,而不是使用缓存或寄存器中的旧值。

133. 分治和贪心这两种算法思想

  • 分治算法的基本思想是将一个问题分解成若干个规模更小的子问题,递归地解决这些子问题,然后合并这些子问题的解以获得原问题的解(分解-解决-合并)。如归并排序和快速排序

  • 贪心算法的基本思想是在每个决策点上做出在当前看来最优的选择,希望通过一系列这样的局部最优解能够得到全局最优解。这种方法并不总是能得到全局最优解,但对于某些特定问题却是有效的。如最小生成树和背包问题

总结:分治算法侧重于将问题分解为独立的子问题,并通过解决子问题间接解决问题,适用于那些可以通过分解简化的问题;贪心算法则强调每一步都做出最佳选择,试图通过一系列局部最优解达到全局最优解,但其有效性依赖于具体问题结构。

134. Mysql 和 Redis

135. 在C++中创建一个类对象在C++的内存分布是什么样的

  • 在C++中创建一个类对象时,其内存分布主要取决于类的成员变量(包括静态成员和非静态成员)、继承层次结构以及是否有虚函数等因素
    • 非静态成员变量:直接存储在类对象的内存空间中。它们按照声明顺序排列,但编译器可能会为了对齐目的插入填充字节以满足特定硬件平台上的对齐要求
    • 静态成员变量:静态成员变量不属于任何具体对象实例,而是属于整个类本身。因此,它们不占用类对象实例的内存空间,而是单独存储在全局数据区或静态数据区中
    • 当一个类从另一个类派生时,基类的成员变量会被包含在派生类的对象中
    • 如果一个类定义了虚函数,那么该类的对象通常会包含一个隐藏的指向虚函数表的指针(虚函数指针)。这个指针指向一个包含该类及其基类虚函数地址的表(虚函数表)

136. C++中使用new创建一个类对象过程是怎么样的

  • 使用 new 创建类对象的过程主要包括三个主要阶段:内存分配、构造函数调用以及返回指向新对象的指针
    • 当你使用 new 来创建一个对象时,首先会在自由存储区(通常称为堆)上为该对象分配足够的内存空间。所需的空间大小取决于类定义中的成员变量以及是否包含虚函数等因素
    • 一旦分配了足够的内存,接下来会调用相应的构造函数来初始化这块内存
    • new 表达式最后返回一个指向新创建对象的指针

137. 斗地主项目

  • 这个项目模拟了斗地主的整个过程,从发牌、叫地主、出牌到游戏结算,其中叫地主和出牌阶段,回创建子线程来模拟两个机器人玩家的行为,结合信号与槽机制实现各玩家之间的交互

桌面布局:首先是每次启动游戏主窗口时,都会随机加载一张图片作为游戏背景,在游戏主窗口正下方会嵌入stackedwidget组件窗口,它会根据不同的游戏状态来显示对应的一页按钮组,比如说刚开始时,就只有一个开始按钮,在叫地主时,就有4个按钮,出牌时按钮又不同;其次就是右上角的得分面板,它是记录各玩家的一个得分情况,这两个窗口都是通过主窗口的一个按钮提升嵌入进来的

玩家的头像、每个玩家的出牌区域等信息都是记录在一个结构体里面,每个玩家都有这样一个结构体,特别是在出牌阶段的时候,会经常用到这个结构体里面的信息

动画效果:它是在出牌过程中,打出了特殊牌型触发的,比如说飞机、炸弹、连对等。这是每个玩家出牌后都会记录它的牌型,会将该牌型发给动画效果类,动画效果就根据不同的牌型,显示不同的效果,静态效果就是一张图片,动态效果就是使用定时器模拟

洗牌:是在一个游戏控制类中有一个存放卡牌类的容器,每次游戏开始前,都会先清空这个容器,然后用双层循环遍历所有花色和卡牌点数,创建卡牌对象了插入到这个容器中

发牌:发牌是用定时器模拟的过程,每次触发移动一点距离,当牌从窗口中心移动到当前玩家的卡牌区域时,就会为该玩家随机生成一张牌,然后放入该玩家对象的手牌容器中(当然要从总牌里面移除这张卡牌),就切换下一个玩家为当前玩家,然后继续这样操作,直到总牌里面只剩下3张的时候,就停止发牌了,进入叫地主状态

叫地主:进入叫地主状态时,如果当前玩家是用户玩家,用户玩家是通过窗口的按钮来完成是否抢地主;如果是机器人玩家,则是创建一个线程,线程执行的操作就是根据该机器人玩家的手牌来计算得分,根据分数来确定下注几分发出信号,该信号携带下注玩家和分数,游戏控制类接收该信号处理,当某个玩家直接下注3分时,它就获得地主

出牌:如果是用户玩家,通过鼠标选择对应卡牌窗口,点击出牌按钮就会触发信号,由主窗口执行槽函数,这个槽函数里面会先检查当前玩家是不是用户玩家,如果不是,就退出函数,如果是,就会结合playhand类根据用户打出的牌,分析这个牌型,看是不是合规的牌,而且如果不是只有出牌的情况,还要看是否能压住上一家的牌,如果都满足,就可以打出这些牌,这里就要从玩家手牌中移除这些牌,还要通过发送信号给主窗口,窗口显示对应动画效果;如果是机器人玩家,会创建一个出牌的线程来完成这些操作,它会根据该机器人玩家的手牌调用Strategy类,这个类里面的函数封装的是一些出牌策略,根据手中的牌和上一个玩家打出的牌来选择出要打出的牌

胜负判断:每个玩家出牌后,都要判断该玩家还剩下多少手牌,如果还有,就切换下一个玩家为当前玩家,如果没有了就发出用户状态变化的信号,游戏主窗口接收了就处理各玩家的得分、显示本局结束面板

138. 多反应堆的高并发项目

这个项目使用了主从反应堆模型来实现一个高并发的HTTP服务器,主线程维护一个主反应堆,线程池中的每个子线程也都会维护一个子反应堆,当主反应堆中监听到客户端的连接后,会取出一个子线程,由子线程里面的反应堆模型来处理读写事件,其中每个反应堆里面都可以任意指定特定的多路IO复用模型,如select、epoll和poll。服务器底层实现了TCPServer、TcpConnection、反应堆和Channel等重要模块

  • TcpServer结构体对象:主反应堆模型mainLoop、线程池threadPool、负责监听的描述符lfd和监听端口port

  • 反应堆模型结构体对象EventLoop:是否工作变量isQuit、分发模型指针dispatcher、对应分发模型的数据块指针、任务队列ChannelElement、Channel结构体数组ChannelMap、线程id、互斥锁(包含任务队列)、本地通信fd

  • 线程池结构体ThreadPool:主线程反应堆模型mainLoop、是否启动变量isStart、线程池子线程数量threadNum、工作线程数组指针workerThread、

  • 工作线程结构体WorkerThread:线程id、互斥锁、条件变量、子反应堆模型evLoop

解决粘包问题:把接收到的客户端发来的信息全部存到一个readbuf里面,这是一个结构体,提供读数据位置和写数据位置,因为http协议发来的信息每行是以/r/n结尾的,所以可以通过这个结尾符号进行截取,得到请求行的内容,然后一行一行进行解析,把请求方式、请求资源、版本号以及下面请求头的内容都存在一个请求结构体里面,自定义的,就是用来存放解析的内容。然后通过该结构体来处理要相应的状态行、状态头信息都存在另一个相应结构体里面。最后就是通过相应结构体来组织信息存到writebuf里面,发送给客户端

  • 主反应堆模型负责监听连接,也就是说主反应堆里面的多路io模型监听的描述符只有监听描述符的读事件,当读事件触发后,会调用之前写好的回调函数,这个回调函数里面就负责取出线程池里面子线程的子反应堆模型,并将通信描述符添加到子反应堆模型的多路io模型中

  • 子反应堆模型就负责监与客户端通信,客户端发来数据,就会触发读回调函数,该回调函数里面就负责解析http请求,处理并响应

  • 事件分发的实现:申明一个dispatcher结构体,里面的成员都是函数指针,比如说开始检测函数指针、添加/删除/修改channel在检测集合里面的事件函数指针等;然后分别通过多路io的三种模型来初始化该结构体里面的函数,这样在反应堆里面就可以设置分发指针dispatcher的指向来使用对应的io模型了

139. 条件变量和信号量

  • 条件变量和信号量都是是用于线程同步的两种机制,它们都涉及到了唤醒(通知)和阻塞(等待)的机制
  • 条件变量工作原理:
    • 条件变量需要配合互斥锁使用。
    • 当一个线程检查到某个条件不满足时,它会调用 wait() 方法释放持有的互斥锁并进入等待状态。
    • 另一个线程在修改了相关条件后,可以通过调用 notify_one()notify_all() 来唤醒一个或所有等待的线程。
    • 被唤醒的线程重新获得互斥锁,并再次检查条件是否满足
  • 信号量工作原理:
    • 信号量有一个内部计数器,表示当前可用资源的数量。
    • 当一个线程想要访问资源时,它会执行 P 操作(即 wait()),如果计数器大于0,则减少计数器并继续执行;否则该线程会被阻塞直到有其他线程释放资源(执行 V 操作)。
    • 当一个线程完成对资源的使用后,它会执行 V 操作(即 signal())s,增加计数器并可能唤醒一个正在等待的线程

140. 委托构造函数和继承构造函数

  • 委托构造函数允许使用同一个类中的一个构造函数调用其它的构造函数
  • 继承构造函数可以让派生类直接使用基类的构造函数,而无需自己再写构造函数

141. 补充问题

  • 为什么选择多线程而没有选用多进程?

    • 是因为线程之间共享内存资源,通信和资源复用效率更高,非常适合基于 epoll 的事件驱动架构。而进程之间是完全隔离的,通信成本大、调度开销高。在高并发场景下,线程模型更轻量、更易管理
  • final关键字来限制某个类不能被继承,或者某个虚函数不能被重写

  • **尖括号 < >:主要用于标准库或已安装库的头文件,编译器直接从标准路径中查找。双引号 ""**:主要用于项目内部的头文件,首先在当前文件所在目录查找,然后才转向标准路径。

  • c++中怎么去引用c代码:使用 extern "C" 声明,表示使用c语言的方式来编译c++程序,因为C和C++在名称修饰(name mangling)、类型检查等方面存在差异。如果不正确处理这些差异,可能会导致链接错误或运行时错误

  • 当一个进程调用 fork() 函数创建子进程时,操作系统会为子进程创建一个新的地址空间,并将父进程的内存内容(包括全局变量、data段、text段、栈和堆等)复制到子进程的地址空间中

142. IO多路复用模型的作用是什么,如果要实现高并发的多线程或多进程,是否一定需要用到IO多路复用模型

  • IO多路复用是一种允许单个进程或线程下监视多个文件描述符的技术,一旦某个描述符就绪(通常是读就绪或写就绪),就能进行相应的IO系统调用

  • 对于实现高并发的多线程或多进程服务,并不一定非要使用IO多路复用模型,但它是解决高并发问题的一种非常有效的方式,特别是在需要处理大量并发连接的情况下。

    • 如果不使用IO多路复用,就得通过增加更多的线程或进程来处理并发请求,在处理成千上万甚至更多并发连接时效率低下,因为每个线程/进程都需要占用一定的内存和CPU时间,这可能导致系统资源耗尽
    • 如果使用IO多路复用,单个线程或进程就可以同时监听多个文件描述符的状态变化,只有当某个描述符准备好执行IO操作时才去处理它。这种方式极大地提高了资源利用率,适合于大规模并发场景

143. 编译时和运行时

  • 编译时:是源代码被转化为机器码的过程,期间会进行语法分析、类型检查等。主要包括以下几个过程:预处理、编译、汇编、链接
  • 运行时:是程序被执行的过程,在这个阶段,计算机按照编译后生成的机器码指令序列执行相应的操作。涉及如下几个方面:
  • 内存管理:包括堆和栈的分配与回收
  • 异常处理:捕获并处理程序运行过程中出现的异常情况
  • 动态绑定:在面向对象编程中,方法调用可能要到运行时才能确定具体调用哪个版本的方法(比如虚函数调用)
  • 输入输出:与用户或其他系统交互,读取输入数据或输出结果

144. g++链接动态库怎么链接

  • 需要使用-L来指定动态库的路径

  • 需要使用-l来指定动态库的库名

  • 通过-rpath设置运行时库搜索路径:默认情况下,操作系统会在标准位置(如 /usr/lib, /usr/local/lib)查找共享库。如果你的共享库不在这些标准位置之一,你需要告诉操作系统去哪里找它。这可以通过设置环境变量 LD_LIBRARY_PATH 或者在链接时使用 -rpath 选项来实现。

145. 使用的是UDP来连接,但尽可能的达到TCP的效果需要怎么去修改

这种情况是希望在使用 UDP 的前提下,尽可能实现 TCP 的功能和效果,比如:可靠传输、顺序交付、流量控制、拥塞控制等

  • 可靠传输

    • 给每个发送的数据包分配一个序列号,发送方维护一个未确认包的队列,接收方收到数据包后发送 ACK,发送方超时未收到 ACK 则重发
  • 有序交付

    • 每个数据包带上序列号,接收端先缓存乱序到达的包,按照顺序将数据提交给应用层
  • 流量控制

    • 接收方告知发送方当前可接收的数据大小,发送方根据接收窗口大小控制发送速度

146. 数据结构

  • 数组与链表有什么区别

    • 数组静态分配内存,链表动态分配内存
    • 数组在内存中连续,链表不连续
    • 数组利用下标定位,时间复杂度为 O (1),链表定位元素时间复杂度 O (n)
    • 数组插入或删除元素的时间复杂度 O (n),链表的时间复杂度 O (1)
  • 线性表的存储结构

  • 顺序存储(内存连续)、链式存储(内存不连续)

  • 头指针和头结点的区别

    • 头指针:是指向链表第一个节点的指针
    • 头结点:是一种特殊的节点,它位于链表的第一个实际数据节点之前。头结点不存储有效数据,主要用于简化某些操作。
  • 栈和队列的区别:栈和队列都是操作受限的线性表

    • 栈:只能在栈尾入栈、出栈,是先进后出
    • 队列:队尾进,队首出,是先进先出
  • 度为2的树与二叉树有什么区别

    • 度为2的树指这棵树中最大的节点度为2,也就是说至少有一个节点的度是2;而二叉树可以为空
    • 度为2的树其子节点没有顺序之分;二叉树需要明确指出左子节点和右子节点
  • 唯一确定一棵二叉树:中序 + 先序/后序/层序

  • 二叉排序树:若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值;若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值;它的左、右子树也分别为二叉排序树

  • 最小生成树有几种方法

    • Prim(普里姆)算法:在图中取任意顶点 v 作为起始顶点,并加入集合 V;之后遍历与 V 中顶点相邻的边,选择权值最小且顶点未加入集合 V 的边,把其加入集合 V,直到集合 V 包含所有顶点结束。(根据节点来选择,适用于节点少的图)
    • Kruskal(克鲁斯卡尔)算法:在含有 n 个顶点的图中始终选择权值最小且不会产生回路的边,一直进行此步骤直到选择 n-1 条边为止。(根据边来选择,适用于边少的图)
  • 图的存储方式有哪些?每一种方式优缺点

    • 邻接矩阵:使用一个二维数组A来表示图,其中A[i][j]表示顶点i到顶点j之间是否有边以及边的权重(如果有的话)。对于无权图,A[i][j]=1表示存在从ij的边,A[i][j]=0表示不存在;对于有权图,A[i][j]直接存储边的权重,若无边则通常设为无穷大或特定值如-1
      • 对于稠密图(边数接近最大可能边数),空间利用率高;判断两点间是否存在边的操作时间复杂度为O(1)。
    • 邻接表:每个顶点都有一个链表或者列表,用于存储所有与该顶点相连的其他顶点。对于有权图,每个元素不仅包含目标顶点的信息,还包含边的权重
      • 节省空间,特别是对于稀疏图而言;插入和删除操作较为高效
    • 十字链表、邻接多重表
  • 树的存储结构:双亲表示法、孩子表示法、孩子兄弟表示法

  • 图的遍历与树的遍历有什么区别:图的遍历可能会出现循环遍历的情况,要设置标记数组。而树的遍历则不会出现这种情况。其次,图可能存在不连通的情况,而树不存在,所以图的遍历要对所有的顶点都循环一遍

  • 什么是稳定的算法:对于拥有相同键值的元素,它们的相对顺序保持不变。

    • 稳定的排序算法:冒泡排序、插入排序、归并排序、基数排序
    • 不稳定的排序算法:选择排序、快速排序、希尔排序、堆排序