目录
1、封装、继承、多态是什么?
2、final标识符的作用是什么?
3、介绍一下虚函数
4、介绍一下智能指针
5、介绍一下左值、右值、左值引用、右值引用
6、指针和引用有什么区别?
7、define、const、inline 的区别是什么?
8、C++程序的内存划分
1. 代码区(Text Segment)
2. 全局/静态数据区(Data Segment)
3. 堆区(Heap)
4. 栈区(Stack)
5. 常量区(Read-Only Data)
6. 内存映射区(Memory Mapping Segment)
9、class与struct的区别
10、内存对齐是什么?
11、进程之间的通信方式有哪些?
12、介绍一下 I/O 多路复用
13、多线程为什么会发生死锁?
14、面向过程和面向对象
15、++i和i++哪个效率更高
16、介绍一下 vector、list、deque 的底层实现原理和优缺点
17、介绍一下 map 和 unordered_map
18、lamda表达式捕获列表捕获的方式有哪些?
19、空对象指针为什么能调用函数?
20、介绍一下 push_back 和 emplace_back
21、空类中有什么函数?
22、explicit 什么作用?
23、成员变量初始化的顺序是什么?
24、malloc和new的区别是什么?
25、迭代器和指针
26、线程有哪些状态
27、哈希碰撞的处理方法
28、动态链接和静态链接
29、C++ 强制类型转换
30、什么是 volatile 关键字
为什么需要 volatile?
31、什么是回调函数?
32、介绍一下栈溢出
33、介绍一下 C++ 协程
34、介绍一下锁
mutex 互斥量
read-write lock 读写锁
spinlock 自旋锁
35、介绍一下 Qt 的 D 指针和 Q 指针
36、介绍一下 Qt 的信号槽机制
37、一个函数,如何让它在main函数之前执行?
38、介绍一下 TCP 三次握手和四次挥手
39、介绍一下 HTTPS 的加密过程
40、介绍一下Qt中的内存管理机制
41、介绍一下 Qt 事件循环机制
42、介绍一下工厂方法模式
43、介绍一下单例模式
单例模式适合的场景主要有这些:
44、介绍一下观察者模式
45、介绍一下 MVD 框架
46、介绍上位机项目的通用模板
~面试官提问:为什么选择 MVD 架构?相比传统 MVC 有什么优势?
~面试官提问:QStyledItemDelegate 和 QItemDelegate 有什么区别?为什么要自己写 Delegate?
~面试官提问:在 QTableView 中如何实现单元格动态变化/代理类的变化
~面试官提问:为什么选择 MQTT 作为通信协议
~面试官提问:怎么处理 MQTT 断线重连?有做心跳机制吗?
~面试官提问:如果需要多表联合查询,还能用 QSqlTableModel 吗?怎么处理并发操作数据库时的冲突?
47、介绍中位机项目的通用模板
1、封装、继承、多态是什么?
封装:将具体实现过程和数据封装成一个类,只能通过接口进行访问,降低耦合性。
继承:子类继承父类的特征和行为,复用了基类的全体数据和成员函数,基类私有成员可被继承,但是无法被访问,其中构造函数、析构函数、友元函数、静态数据成员、静态成员函数都不能被继承。
多态:静态多态通过函数重载实现,可以实现同一个函数名根据传入参数不同而具有不同实现,编译期间就已经确定;动态多态:通过虚函数表实现,在派生类中重写基类函数,实现同一个函数名不同实现,运行时根据虚函数表确定调用哪个函数。
2、final标识符的作用是什么?
放在类的后面表示该类无法被继承,也就是阻止了从类的继承,放在虚函数后面该虚函数无法被重写。
3、介绍一下虚函数
虚函数是通过虚函数表实现的,含有虚函数的类在构造函数中会初始化虚函数表指针,它存储在类内存中,指向虚函数表,虚函数表存储指向每个虚函数的指针,多个类对象共用一张虚函数表。虚函数和普通函数都存储在代码段。
构造函数不能为虚函数,因为虚函数表指针需要在构造函数中初始化。析构函数最好为虚函数,这样当一个指向派生类的基类指针被释放时,可以先调用派生类析构函数,再调用基类析构函数,否则只会调用指向的类的析构函数。
类中static函数不能声明为虚函数,因为类中的 static 函数是所有类实例化对象所共有的,没有 this 指针,此时无法访问到虚函数表指针。
4、介绍一下智能指针
智能指针可以自动管理内存,通过调用对象的析构函数来实现。
share_ptr:共享同一个资源,每增加一个智能指针,引用计数加1,当引用计数为0时释源。
unique_ptr:对其持有的堆内存具有唯一拥有权,也就是说引用计数永远是 1,禁止复制语义。
weak_ptr:不占用引用计数,用于解决 share_ptr 循环引用问题。比如有两个类,它们各有一个指向对方的 share_ptr,然后相互初始化,此时就会造成引用循环。将其中的一个 share_ptr 换成 weak_ptr,就可以解决。
实际开发中,有时候需要在类中返回包裹当前对象(this)的一个 std::shared_ptr
对象给外部使用,C++ 新标准也为我们考虑到了这一点,有如此需求的类只要继承自 std::enable_shared_from_this
模板对象即可。用法如下:
#include <iostream>
#include <memory>
class A : public std::enable_shared_from_this<A>
{
public:A(){std::cout << "A constructor" << std::endl;}
~A(){std::cout << "A destructor" << std::endl;}
std::shared_ptr<A> getSelf(){return shared_from_this();}
};
int main()
{std::shared_ptr<A> sp1(new A());
std::shared_ptr<A> sp2 = sp1->getSelf();
std::cout << "use count: " << sp1.use_count() << std::endl;
return 0;
}
5、介绍一下左值、右值、左值引用、右值引用
左值:有地址的值,如下列代码中的变量a。
右值:临时值,如下列代码中的10。
int a = 10;
左值引用:就是普通的引用。
右值引用:一种引用类型,用&&
表示,它专门用于绑定到临时对象(右值)上,主要用于实现完美转发和移动语义。
完美转发就是在传递参数时维持参数的类型(左值、右值)不变
移动语义就是允许资源的所有权转移而非复制。
std::move 的功能是将一个左值引用强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义,从实现原理上讲基本等同一个强制类型转换
6、指针和引用有什么区别?
指针:指针是变量,内存中存储的是目标数据的地址。
引用:是变量的别名,不额外占用内存,必须初始化且只能初始化一次。
7、define、const、inline 的区别是什么?
define 是在预处理时进行简单的文本替换,不会进行类型安全检查,define 定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段的内存。
const 则是在编译时确定其值,会进行类型安全检查。
inline 是建议编译器在函数调用处将函数展开,适用于比较短、功能单一的函数,节省函数调用的开销。如果函数体过长,频繁使用内联函数会导致代码编译膨胀问题
在头文件中类内声明并定义的函数默认是 inline,类内声明类外初始化的必须加 inline,以解决多重定义错误(否则多个cpp文件包含了这个h文件,链接时会出现多重定义错误)。
在头文件中类内声明,cpp文件中定义的函数不需要加 inline,此时不会发生多重定义错误。
8、C++程序的内存划分
1. 代码区(Text Segment)
-
存储内容:程序的机器指令(可执行代码)
-
特点:
-
只读内存区域
-
在程序启动时加载
-
可能被多个进程共享(对于相同的可执行文件)
-
2. 全局/静态数据区(Data Segment)
分为两部分:
-
已初始化数据段(.data)
-
存储已初始化的全局变量和静态变量
-
包括静态局部变量
-
-
未初始化数据段(.bss)
-
存储未初始化的全局变量和静态变量
-
程序加载时由操作系统初始化为零
-
3. 堆区(Heap)
-
存储内容:动态分配的内存(通过
new
/malloc
分配) -
特点:
-
手动管理(需要
delete
/free
释放) -
内存分配方向从低地址向高地址增长
-
分配速度较慢(需要系统调用)
-
大小受系统虚拟内存限制
-
4. 栈区(Stack)
-
存储内容:
-
局部变量
-
函数参数
-
函数调用信息(返回地址、寄存器状态等)
-
-
特点:
-
自动管理(函数结束时自动释放)
-
内存分配方向从高地址向低地址增长
-
分配速度快(只需移动栈指针)
-
大小有限(通常几MB,可配置)
-
5. 常量区(Read-Only Data)
-
存储内容:
-
字符串字面量
-
编译期确定的常量表达式(如
constexpr
)
-
-
特点:
-
只读内存区域
-
尝试修改会导致段错误
-
6. 内存映射区(Memory Mapping Segment)
-
存储内容:
-
动态链接库
-
内存映射文件
-
共享内存区域
-
-
特点:
-
由操作系统管理
-
可用于进程间通信
-
9、class与struct的区别
class 默认是 private 继承,struct 默认是 public 继承。
10、内存对齐是什么?
CPU通常以固定大小的块(如4字节、8字节)从内存中读取数据。如果数据未对齐,可能需要多次内存访问才能读取完整数据,降低效率。
11、进程之间的通信方式有哪些?
管道:管道本质上是一个内核中的一个缓存,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端。缺点:半双工通信,一个管道只能一个进程写,一个进程读。不适合进程间频繁的交换数据
消息队列:可以边发边收,但是每个消息体都有最大长度限制,队列所包含的消息体的总数量也有上限并且在通信过程中存在用户态和内核态之间的数据拷贝问题
共享内存
套接字:例如QLocalSocket,可以创建一个类,继承于QLocalServer,接受QLocalServer::newConnection信号,在槽函数中获取客户端套接字,并调用其函数进行读取/写入。
12、介绍一下 I/O 多路复用
这三个都是I/O多路复用技术,用于同时监控多个文件描述符(fd)的状态变化(可读、可写、异常等),是高性能网络编程的核心机制。
select:每次调用select都需要把fd集合从用户区拷贝到内核区,而select系统调用后有需要把fd集合从内核区拷贝到用户区,这个系统开销在fd数量很多的时候会很大,并且存在最大fd数量限制,一般为1024
poll:和select基本相同,但采用了链表存储,没有最大fd数量限制
epoll:Linux特有的高效I/O多路复用机制,采用事件驱动方式,在调用epoll_ctl系统调用时拷贝一次要监听的文件描述符数据结构到内核区,在调用epoll_wait的时候不需要再把所有的要监听的文件描述符重复拷贝进内核区,支持水平触发和边缘触发
13、多线程为什么会发生死锁?
当各个线程想获取的资源,但是得不到满足,同时自身也不释放已有资源,就会产生死锁。
四个必要条件:互斥条件、请求和保持条件、不可剥夺条件、环路等待条件,破坏其一就可解除死锁。
14、面向过程和面向对象
面向过程:分析出解决问题的步骤,然后将这些步骤一步一步的实现,使用的时候一个一个调用就好。代码效率更高但是代码复用率低,不易维护。
面向对象:就是将问题分解为各个对象,每个对象描述某个事物在整个解决问题的步骤中的行为,相比面向过程,代码更易维护和复用。但是代码效率相对较低。
15、++i和i++哪个效率更高
++i 是左值,++i
直接修改 i
并返回 i
本身,因此它是可修改的具名对象
i++ 是右值,i++
返回的是递增前的临时副本,该副本是临时对象,过程中会触发拷贝构造
16、介绍一下 vector、list、deque 的底层实现原理和优缺点
vector 是由一块连续的内存空间组成,因此可使用下标随机访问,尾插尾删效率高,当容器大小超过容量时,会自动申请一块更大的内存(原有内存的1至2倍),并且把所有数据拷贝到新内存中。
list 是由双链表实现的,不支持下标随机访问,按需申请内存,不会造成内存空间浪费。在任意位置的插入删除下效率高。
deque
是双端队列,它的特点是可以在容器的两端进行高效的插入和删除操作。因此,deque
适用于需要频繁从容器两端进行操作的场景。支持随机访问(像 vector
一样,可以通过索引访问元素)。在两端进行插入和删除的时间复杂度是常数时间(O(1)
),但是在中间进行插入和删除时,复杂度是 O(n)
。
17、介绍一下 map 和 unordered_map
-
map
:底层使用 红黑树(Red-Black Tree) 实现。红黑树是一种自平衡二叉搜索树,元素是按照键的升序排列的。插入元素时,会自动根据键的大小进行排序。查找、插入和删除操作的时间复杂度是 O(log n)。红黑树的节点存储需要额外的指针来维护树的结构,因此内存开销相对较大。 -
unordered_map
:底层使用 哈希表(Hash Table) 实现。哈希表通过哈希函数将键映射到桶中,从而实现快速查找。元素的顺序是 不确定的,因为哈希表中的元素是根据哈希值存储的,而哈希值与元素的插入顺序没有关系。平均情况下,查找、插入和删除操作的时间复杂度是 O(1)。哈希表需要额外的内存来存储哈希桶和处理哈希冲突,但是内存开销小于 map。
18、lamda表达式捕获列表捕获的方式有哪些?
按值捕获:符号=,不能修改参数
引用捕获:符号&,可以修改传入的外部参数
默认的引用捕获可能会导致悬挂引用,引用捕获会导致闭包包含一个局部变量的引用或者形参的引用,如果一个由lambda创建的闭包的生命周期超过了局部变量或者形参的生命期,那么闭包的引用将会空悬。解决方法是对个别参数使用值捕获
19、空对象指针为什么能调用函数?
空对象指针可以调用成员函数,前提是成员函数中不涉及到成员变量。
20、介绍一下 push_back 和 emplace_back
push_back 接收左值时调用拷贝构造函数,接受右值时调用移动构造函数
emplace_back 则是在容器内部直接构造新对象,避免了复制操作
21、空类中有什么函数?
默认构造函数、默认拷贝构造函数、默认析构函数、默认赋值运算符
空类的大小为1B
22、explicit 什么作用?
只能用于修饰只有一个参数的类构造函数(有一个例外就是,当除了第一个参数以外的其他参数都有默认值的时候此关键字依然有效),它的作用是表明该构造函数是显示的,而非隐式的。作用是防止类构造函数的隐式自动转换,比如构造函数中接收 float 类型,结果传了 int 类型,此时不进行类型转换。
跟它对应的另一个关键字是implicit,意思是隐藏的,类构造函数默认情况下声明为implicit。
23、成员变量初始化的顺序是什么?
与构造函数中初始化成员列表的顺序无关,只与类中定义成员变量的顺序有关。
类中const成员常量必须在构造函数初始化列表中初始化。类中static成员变量,只能在类内定义、类外初始化。
24、malloc和new的区别是什么?
New/delete会调用构造析构函数,malloc/free不会,所以他们无法满足动态对象的要求。
25、迭代器和指针
迭代器返回的是对象引用,而指针是一个变量。
26、线程有哪些状态
创建,就绪,运行,阻塞,结束。
阻塞态/创建态 + 资源 = 就绪态
就绪态 + CPU调度 = 运行态
运行态 - 资源 = 阻塞态
阻塞态不可直接到达运行态,需要先转为就绪态。
27、哈希碰撞的处理方法
开放定址法:当遇到哈希冲突时,去寻找一个新的空闲的哈希地址。
再哈希法:同时构造多个哈希函数,等发生哈希冲突时就使用其他哈希函数知道不发生冲突为止,虽然不易发生聚集,但是增加了计算时间
链地址法:将所有的哈希地址相同的记录都链接在同一链表中
28、动态链接和静态链接
动态链接:不会将代码直接复制到自己程序中,只会留下调用接口,程序运行时根据代码中的路径将动态库加载到内存中,所有程序只会共享这一份动态库,因此动态库也被称为共享库。
静态链接:通俗来说就是把静态库打包到可执行程序中,如果有个多程序,每个程序链接静态库后,都会包含一份独立的代码,当程序运行起来时,所有这些重复的代码都需要占用独立的存储空间,显然很浪费计算机资源。
29、C++ 强制类型转换
static_cast
是一种最常见的强制类型转换,通常用于转换兼容类型之间的转换,例如基本数据类型之间的转换,类层次结构中的类型转换等。例如将 int 转换为 double。
dynamic_cast
主要用于多态类型的转换,特别是在类层次结构中,进行类对象的类型安全转换。它用于在类层次结构中进行向下转换(基类指针/引用转为派生类指针/引用),并且在运行时检查类型的安全性。
const_cast
用于移除或添加对象的 const
属性。它是唯一可以修改对象的常量性(const
和 volatile
)的类型转换。
reinterpret_cast
是最强大也是最危险的类型转换方式,它可以将指针类型转换为任意其他指针类型,甚至是整数与指针之间的转换。这种转换可能导致严重的错误和未定义行为,应该谨慎使用。
30、什么是 volatile
关键字
volatile
关键字主要用于防止编译器优化某些变量。它告诉编译器,变量的值可能会在程序的其他地方发生变化,即使在程序代码中没有显式地修改该变量。
为什么需要 volatile
?
-
硬件寄存器的映射:
在嵌入式系统中,通常会使用volatile
来声明与硬件寄存器相关的变量。例如,某些硬件寄存器的值可能会随着外部事件而发生变化(如传感器输入、外部设备状态等),因此编译器必须在每次访问时都从内存中读取最新的值,而不是从寄存器缓存中读取旧值。 -
多线程并发:
在多线程程序中,某个线程可能会在另一个线程正在运行时修改共享变量。如果没有使用volatile
,编译器可能会出于优化考虑,缓存该变量的值,导致读取到过时的数据。volatile
可以防止这种情况,确保每次访问共享变量时都从内存中读取最新的值。 -
信号处理:
在信号处理程序中,变量的值可能会在任何时刻发生变化,使用volatile
可以确保信号处理器和主程序中的访问行为一致。
31、什么是回调函数?
回调函数(Callback Function)是指将一个函数作为参数传递给另一个函数,并由后者在适当的时机调用该回调函数。回调函数在许多编程场景中都有应用,尤其是在事件驱动编程、异步编程以及接口设计中。
优点:
程序更具灵活性,因为调用者可以定义自己的行为(即回调函数)。例如,某个框架或库提供了一个通用的接口,而具体的行为由用户自定义回调函数来决定。这样,开发者无需修改框架或库的内部代码就能扩展其功能。例如 CANOpen 通信中的心跳、节点状态。
回调函数有助于解耦代码。被调用的函数不需要知道回调函数的具体实现,只需要知道如何调用回调即可。这样,可以实现不同模块间的松耦合,降低模块间的依赖。
缺点:
如果有多个嵌套的回调函数,尤其是在处理异步任务时,代码容易变得难以阅读和维护。回调嵌套深度过大时,代码层次混乱
调函数的执行顺序不确定,尤其是在异步编程中,可能会导致调试困难。
32、介绍一下栈溢出
栈是用于存储局部变量、函数调用信息、函数参数等数据的内存区域,如果在程序运行过程中,栈的空间被超出了其分配的最大空间,从而导致程序崩溃或者发生意外行为,就是栈溢出(Stack Overflow)。
溢出的原因和情况:
-
递归调用过深: 当程序中使用递归(一个函数直接或间接地调用自己)时,如果递归的深度过大,栈空间会被逐步消耗,最终导致栈溢出。
-
局部变量占用过多栈空间: 当一个函数声明了大量的局部变量(特别是大数组或大结构体)时,这些变量会占用栈上的大量空间。如果栈的空间不足以存储这些变量,也会导致栈溢出。
-
无限循环中的函数调用: 如果程序中有一个错误的循环结构,使得某个函数反复被调用,也可能导致栈空间的耗尽。与递归类似,只不过这种情况是在循环结构的控制下发生的。
33、介绍一下 C++ 协程
协程的本质就是处理自身挂起和恢复的用户态线程。
协程的切换比线程的切换速度更快,在IO密集型任务情境下更适合。IO密集型任务的特点是CPU消耗少,其大部分时间都是在等待IO操作完成。
相比于函数,协程最大的特点就是支持挂起/恢复。
- 有栈协程(Stackful Coroutine):每个协程都有自己的调用栈,类似于线程的调用栈。
- 无栈协程(Stackless Coroutine):协程没有自己的调用栈,挂起点的状态通过状态机或闭包等语法来实现。
34、介绍一下锁
mutex 互斥量
mutex 是睡眠等待类型的锁,当线程抢互斥锁失败的时候,线程会陷入休眠。优点就是节省CPU资源,缺点就是休眠唤醒会消耗一点时间。
read-write lock 读写锁
读写锁的特性:
- 加了写锁时,其他线程加读锁或者写锁都会阻塞。
- 加了读锁时,其他线程加写锁会阻塞,加读锁会成功。
适用于多读少写的场景。
spinlock 自旋锁
自旋锁不会引起线程休眠。当共享资源的状态不满足时,自旋锁会不停地循环检测状态,不休眠就不会引起上下文切换,但是会比较浪费CPU,俗称“忙等待”。
35、介绍一下 Qt 的 D 指针和 Q 指针
D 指针通常使用 QScopedPointer<MyClassPrivate> d_ptr,通常在类中声明一个指向私有类的指针,所有私有数据成员和实现细节都放在私有类中。
在一个类的构造函数初始化列表中初始化 D 指针,并通过其访问私有类的成员函数
MyClass::MyClass() : d_ptr(new MyClassPrivate) {}
MyClass::~MyClass() {}void MyClass::someFunction() {Q_D(MyClass); // 定义一个局部变量 d,指向 d_ptrd->someData = 10;
}
这样可以隐藏实现细节,私有的结构体可以随意改变,而不需要重新编译整个工程项目
q_ptr 是指向“外部类”的指针,用于私有类中访问其公共类(外部类)
36、介绍一下 Qt 的信号槽机制
元对象编译器 MOC 负责解析 signals、slot、emit 等标准 C++ 不存在的关键字,以及处理Q_OBJECT、Q_PROPERTY、Q_INVOKABLE 等相关的宏。
connect 的第五个参数:
-
Qt::AutoConnection
: 默认值,使用这个值则连接类型会在信号发送时决定。如果接收者和发送者在同一个线程,则自动使用 Qt::DirectConnection 类型。如果接收者和发送者不在一个线程,则自动使用 Qt::QueuedConnection 类型。 -
Qt::DirectConnection
:槽函数会在信号发送的时候直接被调用,槽函数运行于信号发送者所在线程。效果看上去就像是直接在信号发送位置调用了槽函数。这个在多线程环境下比较危险,可能会造成奔溃。 -
Qt::QueuedConnection
:槽函数在控制回到接收者所在线程的事件循环时被调用,槽函数运行于信号接收者所在线程。发送信号之后,槽函数不会立刻被调用,等到接收者的当前函数执行完,进入事件循环之后,槽函数才会被调用。多线程环境下一般用这个。 -
Qt::BlockingQueuedConnection
:槽函数的调用时机与 Qt::QueuedConnection 一致,不过发送完信号后发送者所在线程会阻塞,直到槽函数运行完。接收者和发送者绝对不能在一个线程,否则程序会死锁。在多线程间需要同步的场合可能需要这个。 -
Qt::UniqueConnection
:这个flag可以通过按位或(|)与以上四个结合在一起使用。当这个flag设置时,当某个信号和槽已经连接时,再进行重复的连接就会失败。也就是避免了重复连接
37、一个函数,如何让它在main函数之前执行?
1、main之前声明一个全局对象,然后实例化,这样就可以调用其构造函数。
2、用关键字 __attribute__
38、介绍一下 TCP 三次握手和四次挥手
三次握手
第一次:客户端发送给服务端,syn = 1 表示建立连接,同时也会发送 seq,消耗一个序号,不携带数据。
服务器确认了对方发送正常,自己接收正常。
第二次:服务端收到第一次握手后,发送给客户端,syn = 1 表示建立连接,ack = seq + 1,同时也会发送 seq 消耗一个序号,不携带数据。
客
户端确认了:自己发送、接收正常,对方发送、接收正常。
服务器确认了对方发送正常,自己接收正常。
第三次:客户端发送 ack = seq + 1,发送 seq 消耗一个序号,可以携带数据。
客户端确认了:自己发送、接收正常,对方发送、接收正常;
服务器确认了:自己发送、接收正常,对方发送、接收正常
四次挥手
第一次:客户端发送 FIN = 1 给服务端,表示客户端不再发送数据,但是可以接收数据。
第二次:服务端发送 ACK 给客户端,表示已接收。
第三次:服务端处理完当前数据后发送 FIN = 1 给客户端,表示不再发送数据,此时客户端进入 TIME_WAIT 状态(一段时间后自动进入关闭状态),服务端进入 LAST_ACK 状态。
第四次:客户端发送 ACK 给服务端,表示已接收,此时服务端完全关闭。
39、介绍一下 HTTPS 的加密过程
- 客户端(发送者)提交 HTTPS 请求
- 服务器(接收者)响应客户,并把信息通过证书公钥加密后发给客户端(此时产生了公钥和私钥,只把公钥给客户端)
- 客户端验证证书公钥的有效性
- 有效后,会生成一个会话密钥
- 用证书公钥加密这个会话密钥后,发给服务器
- 服务器收到证书公钥加密的会话密钥后,用证书密钥的私钥解密,获取会话密钥
- 客户端与服务器双方利用这个会话密钥加密要传输的数据进行通信
40、介绍一下Qt中的内存管理机制
Qt 中主要通过父子关系来进行内存管理。
-
当一个
QObject
派生对象被创建时,可以指定一个父对象(QObject* parent
)。 -
当父对象被销毁时,会自动销毁它的所有子对象(调用子对象的析构函数并释放内存)。
-
使用
Qt::AutoConnection
或者Qt::QueuedConnection
连接的信号与槽,不会延长对象生命周期。 -
如果对象被销毁,Qt内部机制会自动将与其相关的连接断开,避免“悬挂指针”。
41、介绍一下 Qt 事件循环机制
事件循环是 Qt 中处理各种事件(比如鼠标点击、键盘输入、定时器超时、网络消息等)的核心机制。它不断地从事件队列中提取事件,并将它们分发到对应的对象进行处理。
可以通过重写 event(QEvent *event) 函数来捕获事件,例如
bool event(QEvent *event) override {if (event->type() == QEvent::MouseButtonPress) {qDebug() << "鼠标点击事件被捕获!";return true; // 返回true表示事件已经处理}return QWidget::event(event); // 其他事件继续交给父类处理
}
通过 installEventFilter(QObject *filterObj)
,可以让一个对象去监视另一个对象的事件,即使它不是那个对象的父类也可以
#include <QWidget>
#include <QEvent>
#include <QDebug>class MyFilter : public QObject
{Q_OBJECT
protected:bool eventFilter(QObject *watched, QEvent *event) override {if (event->type() == QEvent::KeyPress) {qDebug() << "捕获到键盘按下事件!";return true; // 事件被拦截,不再继续传递}return QObject::eventFilter(watched, event);}
};MyFilter *filter = new MyFilter(this);
someWidget->installEventFilter(filter); // 在 MyFilter 类中监视 someWidget 的事件
42、介绍一下工厂方法模式
工厂方法模式是一种创建型设计模式,它的主要目的是将对象的创建过程延迟到子类中进行,这样可以让程序在不指定具体类的情况下创建对象。
传统方式里,如果你在代码里直接 new
一个对象,那么这个类就写死了,扩展起来很麻烦。
工厂方法模式把对象创建这件事交给子类或具体工厂去完成,父类只定义一个统一的接口,具体创建哪个对象,由子类决定。
43、介绍一下单例模式
单例模式是一种非常经典的创建型设计模式,它的目的是保证一个类在系统中只有一个实例,而且提供一个全局访问点来获取这个实例。
单例模式适合的场景主要有这些:
-
系统中只需要一个实例,且需要全局统一管理的场景
比如:-
配置管理器(全局配置)
-
日志系统(统一收集输出日志)
-
数据库连接池(控制连接数量)
-
线程池(统一管理线程资源)
-
-
需要控制资源的访问,避免出现竞争或者资源浪费
单例可以确保大家用的是同一个资源对象,不用每次都重新创建,节省系统资源。
44、介绍一下观察者模式
简单说就是:一个对象(被观察者)状态变化时,所有依赖它的对象(观察者)都会收到通知并自动更新。
主要有两个角色:
-
被观察者(Subject)
-
提供注册(attach)、取消注册(detach)观察者的方法
-
有状态变化时,通知(notify)所有观察者,调用观察者的更新接口
-
-
观察者(Observer)
-
定义更新接口(update),当被观察者变化时执行更新操作
-
45、介绍一下 MVD 框架
- 模型(Model) :模型负责管理数据,并提供数据的访问接口。Qt中有一些预定义的模型类,如 QAbstractTableModel、QSqlTableModel 等,也可以自定义模型类来满足特定的数据结构和需求。
- 视图(View) :视图是用于显示模型中的数据的部件,常见的视图类包括 QTableView、QTreeView、QListView 等。视图负责数据的可视化,并提供用户与数据进行交互的接口。
- 代理(Delegate) :代理用于控制数据的显示方式和编辑行为。通过自定义代理类,可以实现对数据的定制化显示和编辑效果。
46、介绍上位机项目的通用模板
使用 Qt C++ 作为开发工具,采用了 MVD 架构实现表格数据显示与逻辑分离,在界面展示方面,基于 QAbstractTableModel,使用 QTableView 进行表格数据的呈现,并自定义了 QStyledItemDelegate,实现了复杂单元格的渲染与编辑逻辑。例如,根据不同的数据类型动态切换编辑控件(如文本输入、下拉框、日期选择器等),从而提升了用户交互体验。
数据交互方面,通过 MQTT协议 与中位机建立实时通信,实现了设备状态监控、指令下发与数据同步。我对 MQTT 客户端进行了封装管理,支持断线重连、消息主题订阅与发布。
项目中有一些简单的数据,例如日志等不涉及复杂数据结构的表,我选用了 QSqlTableModel 进行单表映射。这种方式可以将数据库表直接映射为模型对象,极大地简化了数据库操作。具体来说,我通过调用 select()
、insertRow()
、setData()
、setSort()
、submitAll()
等 API,实现了数据的增删改查功能。
一些复杂的数据
比如,用户可以直接在 QTableView 中编辑单元格内容,系统在内部自动同步到模型中,我只需要统一调用 submitAll()
,就可以将所有改动一次性提交到数据库,大幅度简化了业务逻辑和代码量。
~面试官提问:为什么选择 MVD 架构?相比传统 MVC 有什么优势?
我选择 MVD 架构,主要是项目中涉及到了一些复杂表格数据,不同数据的显示方式有一定的要求,比如下拉框、输入框、勾选框。
相比传统 MVC(Controller 比较重,界面逻辑和业务逻辑容易耦合在一起),MVD更细粒度
-
更方便地自定义单元格外观(比如不同单元格使用不同的控件);
-
更容易实现数据与界面同步,比如 Model 数据变化,View 可以自动响应;
-
便于后期维护和扩展,比如新增一种新的渲染方式,只需要修改 Delegate,而不影响其他模块。
-
而且Qt 本身的控件体系(比如
QTableView
、QTreeView
)就是基于 MVD 架构设计的
~面试官提问:QStyledItemDelegate 和 QItemDelegate 有什么区别?为什么要自己写 Delegate?
QStyledItemDelegate 和 QItemDelegate 都是 Qt 中用于自定义 View(比如 QTableView
、QTreeView
)中单元格的渲染和编辑行为的。
QStyledItemDelegate 更现代、符合Qt样式系统,自定义Delegate 主要是为了让表格界面既美观又符合实际业务逻辑。
~面试官提问:在 QTableView 中如何实现单元格动态变化/代理类的变化
创建一个继承于 QStyledItemDelegate 类的自定义代理类,之后要在 QTableView 中使用 setItemDelegateForColumn 函数设置自定义代理,并重写 createEditor、setEditorData 改变自定义代理类的具体表现,并且通过重写 setModelData 函数将改变提交到 QAbstractTableModel 类或者其派生类中,实现视图与模型数据的统一。
~面试官提问:为什么选择 MQTT 作为通信协议
MQTT 协议本身非常轻量,相较于HTTP 和 WebSocket,MQTT的数据包头非常小,特别适合资源受限的设备或需要频繁通信的场景,比如我项目中的中位机通信。
而且 Qos = 2 时,传输机制为仅一次(最安全,保证不丢不重),HTTP 和 WebSocket 本身并没有内置这种传输质量控制,需要自己额外处理重发或确认。
MQTT 基于 Pub/Sub 模式,天然支持一对多、多对多的数据通信,可以支持多台客户端对设备信息的监控和数据处理。
~面试官提问:怎么处理 MQTT 断线重连?有做心跳机制吗?
在连接 MQTT Broker 前,我通过 QMqttClient::setKeepAlive(60)
设置了心跳间隔,比如60秒。
这样客户端会定时发送心跳包(PINGREQ),Broker响应 PINGRESP,双方可以及时检测连接是否活跃。
如果心跳失败,Broker可以及时断开连接,客户端也可以根据 disconnected()
信号触发自动重连机制。
~面试官提问:如果需要多表联合查询,还能用 QSqlTableModel 吗?怎么处理并发操作数据库时的冲突?
在需要多表联合查询的场景下,我使用了 QSqlDatabase 和 QSqlQuery 来执行自定义 SQL 查询。
-
通过
QSqlQuery
可以直接编写多表 JOIN 查询,灵活处理复杂数据结构。 -
同时,由于项目中存在多线程访问数据库的需求,我根据每个线程的线程ID,分别创建和管理独立的 QSqlDatabase 连接对象,保证了线程间数据库操作的安全性(因为Qt的数据库连接对象不是线程安全的)。
-
每个线程内部通过自己的 QSqlDatabase 执行
QSqlQuery
,完成查询,并将结果映射到界面上展示,确保了查询的正确性和系统的稳定性。
47、介绍中位机项目的通用模板
(4.28再更新)