Qt篇: 常问面试题

1. Qt多线程如何实现,有哪些方式?

常用的Qt多线程实现方式有三种:QThread继承、moveToThread更改线程依附性和Qt高级API接口QtConcurrent。

  • QThread:这个方式是早起Qt提供的创建线程方案,主要是通过继承QThread类,重写run()函数来创建;

  • 使用Qt的信号和槽机制:早期的QThread子类必须重写run()函数,而现代Qt不在是纯虚函数了,类的本身就已经对run()函数进行了默认的实现,启动了事件循环,这就意味着可以利用信号槽的方式去创建新线程,只需创建一个业务类继承于QObject,将所有繁琐事务处理在该类实现,最后通过moveToThread更改线程依附性即可;

  • QtConcurrent:是一个高级别 API,提供了许多方便加载和管理线程的函数。通过使用 QtConcurrent,可以轻松地编写并行代码。它更易于使用和管理,但灵活性较低。适用于比较小且单一的事务处理。

2. 什么是Qt信号槽,信号槽是同步还是异步的?

思路:主要考察信号槽的第五个参数,需要理解信号槽的连接方式。

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

在Qt 5.15之前信号槽的连接方式有5种,Qt 5.15新增了第六种连接方式:Qt::SingleShotConnection。

  • Qt::DirectConnection(立即调用):直接在发送信号的线程中调用槽函数,等价于槽函数的实时调用,无论信号和槽函数是否在同一线程;

  • Qt::QueuedConnection(异步调用):信号发送至目标线程的事件队列中,由目标线程处理,当前线程继续向下执行;

  • Qt::AutoConnect(默认连接):平台会根据线程依附性而自行选择DirectConnection或者QueuedConnection的连接方式,如果发射信号线程 等于 目标线程,则连接方式默认为Qt::DirectConnection,如果发射信号线程 不等于 目标线程,则连接方式默认为Qt::QueuedConnection;

  • Qt::BlockingQueuedConnection(同步调用):信号发送至目标线程的事件队列,由目标线程处理,当前线程等待槽函数返回,然后在继续向下执行,需要注意的是当前线程和目标线程必须是不同,如果相同当前发射信号的线程永远也无法等到目标线程处理槽函数的返回;

  • Qt::UniqueConnection(单一连接):默认情况下,同一个信号可以多次连接同一个槽函数,即同一个槽函数多次被调用,但是UniqueConnection连接方式只允许存在一次连接,连接方式则跟Qt::AutoConnection一样,需要根据线程依附性决定;

  • Qt::SingleShotConnection(Qt 5.15 引入,一次性连接):当信号首次触发并调用槽函数后,连接会自动断开。之后的信号触发将不会再调用该槽函数。

3. 请说出你常用的解决线程同步问题。

多线程互斥问题是多线程在同一个时刻都需要访问(读/写)临界资源而产生的。

  • 互斥量:QMutex类是线程锁类,在需要读写资源是lock,读写完后再unlock。

  • 互斥锁:QMutexLocker,从声明处开始(在构造函数中加锁),出了作用域自动解锁(在析构函数中解锁)。

  • 信号量:QSemaphore对象中维护一个整型值(n),这个整型值就对标着多少条线程。acquire()会使得这个n值减1,release()会将该值加1,当这个n=0时,acquire()函数会阻塞当前线程。

4. 什么是死锁。死锁产生条件是什么,又该如何避免?

  1. 死锁概念:多线程之间相互等待临界资源而造成彼此无法继续执行。

  2. 死锁产生条件:

  • 系统中存在多个临界资源且临界资源不可抢占;
  • 每个线程需要多个临界资源才能继续执行。
  1. 死锁的避免
  • 对所有临界资源都分配唯一的序号(R1, R2, R3, ..., Rn);
  • 对应的线程锁也分配同样的序号(M1, M2, M3, ..., Mn);
  • 系统中的每个线程必须按照严格的升序的顺序去请求资源。

5. 什么是多态

  • 多态概念:根据实际的对象类型决定函数调用的具体目标,同样的调用语句在实际运行时有多种不同的表现形态体现,这就是多态,同个语句具有多种形态;

  • 内部剖析:父类和子类的对象,首地址都各自有一个虚函数表地址,这个地址会保存着虚函数的地址,在父类指针(引用)指向父类对象,就相当于指向父类的虚函数表,调用父类虚函数表中的函数;当父类指针(引用)指向子类对象,相当于指向子类的虚函数表,调用子类虚函数表中的函数。

6. 构造和析构函数能不能作为虚函数,且能不能发生多态?

  • 构造函数不能作为虚函数:虚函数表指针正确的初始化是发生在构造函数执行结束之后,所以构造函数不能作为虚函数的,假设你不小心用virtual修饰构造函数,编译器会直接报错;

  • 析构函数可以作为虚函数(建议virtual修饰父类析构函数), 因为如果不设置成虚函数,析构的过程中只会调用到基类的析构函数而不会调用到子类的析构函数,可能会产生内存泄漏。

  • 构造函数和析构函数不能发生多态行为:构造函数执行,虚函数表指针并未初始化;析构函数执行前,虚函数表指针已被销毁掉;当构造函数和析构函数调用虚函数时,只会体现当前类中的虚函数实现的内容。

7. 多态遇到默认参数会发生什么现象?

要想程序发生能正常发生多态行为,基类和派生类都不应该使用默认参数。

原因是虚函数的默认参数在编译期间就已经编译完成的,调用派生类具有多态行为,但是默认参数不具备多态行为。在开发中可能会存在以下几种情况:

  • 当基类和派生类虚函数的默认参数相同时,调用派生类可以发生多态行为,但参数不具备,且会调用基类虚函数的默认参数;
  • 当基类有默认参数,派生类没有默认参数,则此时是不会发生多态行为,始终调用基类的虚函数;
  • 当基类没有默认参数,派生类具有默认参数,编译器会直接报错;
  • 当基类和派生类都没有默认参数时,才能发生多态行为。

8. 什么是C++ STL?

C++ STL从广义来讲包括了三类:算法,容器和迭代器。

  • 算法包括排序,复制等常用算法,以及不同容器特定的算法。
  • 容器就是数据的存放形式,如list,vector,set,map等。
  • 迭代器就是在不暴露容器内部结构的情况下对容器的遍历。

9. 简要说明下list,vector的原理。

  • list的底层是一个双向链表,内存地址是不连续,每次插入或删除一个元素,就配置或释放一个元素空间。list适合需要大量的插入和删除。
  • vector是一个动态数组,当空间不足时会自动申请另一片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间,这就是vector内存增长机制。当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。因此,对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器会都失效了。

10. Vector如何释放空间?

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

最有效方法:可以用swap()来帮助你释放内存。swap会将一个空的 vector 与当前的 vector 进行交换,这会导致原始 vector 的内容被析构并释放其占用的内存

11. 什么是平衡二叉树(红黑树)?

红黑树是一种自平衡的二叉搜索树,它确保了树的高度始终为 O(log n),因此所有基本操作(如插入、删除、查找)的时间复杂度都为 O(log n)。红黑树的特性如下:

  • 每个节点要么是红色,要么是黑色。
  • 根节点是黑色的。
  • 叶节点是黑色的。
  • 红色节点的子节点必须是黑色的(即红色节点不能连续)。
  • 从任一节点到其每个叶节点的所有路径都包含相同数量的黑色节点。

12. 简要说明set,map,multiset、multimap原理。

  • map 、set、multiset、multimap的底层实现都是红黑树。
  • map和set的增删改查速度为都是logn,是比较高效的。
  • set和multiset会根据特定的排序准则自动将元素排序,set中元素不允许重复,multiset可以重复。
  • map和multimap将key和value组成的pair作为元素,根据key的排序准则自动将元素排序(因为红黑树也是二叉搜索树,所以map默认是按key排序的),map中元素的key不允许重复,multimap可以重复。

13. 简述Qt的TCP通信流程

TCP通信的基本过程,其实与TCP协议中的三次握手和四次挥手的连接管理原则紧密相关的。

  • 创建套接字服务器QTcpServer对象;
  • 通过QTcpServer对象设置监听,即QTcpServer::listen();
  • 基于QTcpServer::newConnection()函数检测是否有新的客户端连接;
  • 如果有新的客户端连接,调用QTcpSocket *QTcpServer::nextPendingConnection()得到通信的套接字对象;
  • 使用通信的套接字对象QTcpSocket和客户端通信。

14. const作用

  • 修饰变量,说明该变量不可以被改变;
  • 修饰指针,分为指向常量的指针和指针常量;
  • 常量引用,经常用于形参类型,即避免了拷贝,又避免了函数对值的修改;
  • 修饰成员函数,说明该成员函数内不能修改成员变量。

15. static作用

  • 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,在 main 函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它。
  • 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命令函数重名,可以将函数定位为 static。
  • 修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员。
  • 修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在 static 函数内不能访问非静态成员。

16. inline 内联函数与宏函数的区别

内联函数就好比把它内部的代码直接搬到调用它的地方。这样就不用走进入函数的那一套流程,能直接执行函数体。它有点像宏,但比宏好的地方是多了类型检查,是真正的函数。不过要注意,内联函数不能有循环、递归、switch 这类复杂的操作。

优点

  • 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
  • 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
  • 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
  • 内联函数在运行时可调试,而宏定义不可以。

缺点

  • 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
  • inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
  • 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。

17. volatile

volatile关键字告诉编译器其修饰的变量是易变的,它会确保修饰的变量每次读操作都从内存里读取,每次写操作都将值写到内存里。volatile关键字就是给编译器做个提示,告诉编译器不要对修饰的变量做过度的优化,提示编译器该变量的值可能会以其它形式被改变。

18. extern "C"

  • 被 extern 限定的函数或变量是 extern 类型的;
  • 被 extern "C" 修饰的变量和函数是按照 C 语言方式编译和连接的
  • extern "C" 的作用是让 C++ 编译器将 extern "C" 声明的代码当作 C 语言代码处理,可以避免 C++ 因符号修饰导致代码不能和C语言库中的符号进行链接的问题。

19. C++ 中 struct 和 class

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

区别

  • 默认的继承访问权限不同。struct 是 public 的,class 是 private 的。
  • struct 作为数据结构的实现体,它默认的数据访问控制是 public 的,而 class 作为对象的实现体,它默认的成员变量访问控制是 private 的。

20. union联合体

联合(union)是一种节省空间的特殊的类,一个 union 可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当某个成员被赋值后其他成员变为未定义状态。联合有如下特点:

  • 默认访问控制符为 public;
  • 可以含有构造函数、析构函数;
  • 不能含有引用类型的成员;
  • 不能继承自其他类,不能作为基类;
  • 不能含有虚函数。
  • 匿名 union 在定义所在作用域可直接访问 union 成员。
  • 匿名 union 不能包含 protected 成员或 private 成员。
  • 全局匿名联合必须是静态(static)的。

21. 了解RAII 吗?介绍一下?

RAII: Resource Acquisition Is Initialization,资源获取即初始化,将资源的生命周期与一个对象的生命周期绑定,举例来说就是,把一些资源封装在类中,在构造函数中请求资源,在析构函数中释放资源且绝不抛出异常,而一个对象在生命周期结束时会自动调用析构函数,即资源的生命周期和一个对象的生命周期绑定。

22. 如何了解C++里各种强制类型转换

  • 强制类型转换:C语言常用的类型转换,其实就是对内存的不同解析方式。高效快捷,但没有在编译时进行类型安全检查,有可能在调用时会出错;

  • static_cast:带有安全类型检查,用于替代C语言风格的强制类型转换,此转换会在编译时进行类型安全检查,而强制转换时不会;

  • dynamic_cast:用于类层次间的上行转换和下行转换(基类必须有虚表)。除了在编译时进行类型安全检查外,dynamic_cast还会在运行时进行类型检查。从子类转换父类时,dynamic_cast和static_cast作用是一样的,而父类转换为子类的过程中,static_cast是非类型安全的,而dynamic_cast会在转换时做类型安全检查,若不能转换则会返回Null或者抛出异常;

  • const_cast:仅仅只是将const类型转换成非const类型,不做其他类型转换的操作;

  • reinterpret_cast:用于进行各种不同类型的指针之间,不同类型的引用之间,指针和能容纳指针的整数类型之间的转换,转换不进行类型安全检查,其实和普通强制类型转换没太大区别,使用reinterpret_cast的好处是可以将强制类型转换标准化,代码看得舒服些,而且能快速查找到强制类型转换,但也不能改变参数const属性。

23. 指针和引用有什么区别?什么情况下用指针,什么情况下用引用?

区别

  • 指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元,即指针是一个实体;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。
  • 有const指针,但是没有const引用。
  • 指针可以有多级,但是引用只能是一级(int** p;合法,而int&& a;不合法)。
  • 指针的值可以为空,但是引用的值不可以,并且引用在定义的时候必须初始化。
  • 指针的值在初始化后可以改变,即指向其它的存储单元,而引用在初始化后就不会再改变了,从一而终。
  • sizeof引用得到的是所指向的变量(对象)的大小,而sizeof指针得到的是指针本身的大小。
  • 指针和引用的自增++运算意义不一样。

相同点

  • 都可以对变量就行修改。
  • 都是地址的概念,指针指向一块内存,它的内容是所指内存的地址,引用是某块内存的别名。

何时使用

  • 当考虑到存在不指向任何对象的可能,这时候应该使用指针。
  • 当需要在能够在不同的时刻指向不同的对象,这个时候使用指针。如果总是指向一个对象并且一旦指向一个对象后就不会改变指向,那么应该使用引用。
  • 当重载某个操作符时,应该使用引用。

24. 了解auto 和decltype 吗?

  • auto:可以让编译器在编译时就推导出变量的类型,代码如下:
auto a = 10; // 10是int型,自动推导出a是int
int i = 10;
auto b = i; // b是int
auto d = 2.0; // d是double
  • decltype:用于推导表达式类型,代码如下:
int func() { return 0; };
decltype(func()) i; // i是int
 
int x = 0;
decltype(x) y; // y是int
decltype(x + y) z; // z是int

25. 谈一谈你对左值和右值的了解,了解左值引用和右值引用吗?

左值:在内存中有确定存储地址、有变量名、表达式结束依然存在的值。

左值引用:绑定到左值的引用,通过&来获得左值引用。

右值:在内存中没有确定存储位置、没有变量名,表达式结束就会销毁的值。

右值引用:绑定到右值的引用,通过&&来获得右值引用。

int a1 = 10; // 非常量左值
const int a2 = 10; // 常量左值
 
int& b1 = a1; // 非常量左值引用
const int& b2 = a2; // 常量左值引用
 
int&& c1 = 10; // 非常量右值引用
const int&& c2 = 10; // 常量右值引用,10是非常量右值  

26. 项目架构为什么选择MVP而不是MVC?

  • 更严格的解耦

    • MVP:View和Model之间完全解耦,所有的业务逻辑都由Presenter处理,View只负责展示和用户交互;
    • MVC:View和Model可能会直接通信,导致两者耦合较紧密,这在复杂的应用中可能增加维护难度。
  • 提高测试性

    • MVP:Presenter不依赖于View,可以更容易编写单元测试来验证业务逻辑;
    • MVC:由于View和Controller之间的交互紧密,测试业务逻辑可能需要实际的UI,这样的UI依赖使得测试更复杂。
  • 职责清晰

    • MVP:将UI操作和数据处理分离,UI仅显示数据,数据处理逻辑由Presenter承担。这种分离可以让前端开发人员专注于UI,后端开发人员专注于业务逻辑;
    • MVC:Controller的职责较为模糊,可能直接与View或Model交互,职责边界不如MVP清晰。
  • UI更新管理

    • MVP:Presenter完全控制View的更新逻辑,使得UI更新过程更加可控;
    • MVC:Controller可以直接更新View或通过Model间接影响View,更新逻辑易受耦合影响,特别是复杂项目中。

27. MVP架构的主要缺点

尽管MVP解耦、易测试,但也存在一些缺点,尤其是在大型项目中:

  • 代码量增加

    • MVP的职责分离导致Presenter中包含了大量逻辑代码和View接口定义,增加了代码量和复杂性;
    • 特别是当应用包含大量复杂的View时,每个View都需要一个对应的Presenter,可能会导致Presenter代码重复,代码量显著增加。
  • View和Presenter的高耦合

    • Presenter需要直接调用View的接口来更新UI,这种强依赖使得View和Presenter之间的关系紧密,导致Presenter在某些场景下难以重用;
    • 在复杂的UI界面中,Presenter可能会包含大量视图更新逻辑,导致代码复杂性上升。
  • 适用范围受限: 在一些简单的应用中,MVP可能显得过于繁琐,不如MVC简洁。

  • 维护成本

    • 对于大型项目,随着View和Presenter的数量增多,管理、维护Presenter会变得复杂,因为每个View都需要对应的Presenter,这使得整体项目的维护工作量加大;
    • 频繁的View接口修改需要同步更新Presenter,增加了维护的复杂性。

28. Qt信号槽机制和回调函数有什么区别?

  • 类型安全

    • 信号槽:Qt的信号槽机制是类型安全的,编译时会检测信号和槽的参数是否匹配,避免了参数不一致的问题;
    • 回调函数:回调函数通常不做严格的参数匹配检查,容易导致错误,比如传入了不兼容的参数类型。
  • 解耦性

    • 信号槽:信号和槽机制让发送者(signal)和接收者(slot)之间没有直接依赖,不需要知道对方的存在。对象之间是松散耦合的,这样可以更灵活地调整代码结构;
    • 回调函数:回调需要发送者持有接收者的指针或函数地址,因此增加了发送者和接收者的耦合度,修改某个模块时可能会影响其他模块。
  • 多重连接

    • 信号槽:一个信号可以连接多个槽,也可以将多个信号连接到同一个槽,实现一对多、多对一的关系;
    • 回调函数:通常只支持一对一的关系,一个回调函数只能被一个事件调用,不能直接实现多重连接。
  • 线程安全

    • 信号槽:Qt的信号槽机制在多线程环境中是线程安全的。信号槽之间可以跨线程通信,比如用Qt::QueuedConnection传递数据,槽会在接收对象的线程执行;
    • 回调函数:传统的回调函数在多线程中使用时需手动管理线程同步,稍有不慎可能会出现数据竞争等线程安全问题。

总得来说,信号槽机制相比传统回调更安全、灵活,适合复杂的GUI开发,而回调函数则更轻量,适合简单、直接的事件处理需求。