大家好,我是阿Q。
不知不觉今天已经是节后的第五个工作日了,还以为周五了。。。
这个时候正是各个公司开奖的时间,大家有拿到自己心动的offer么~
节后的状态都还好吗?往年的“金九银十”,到了今年就是“铜九铁十”,不要太离谱。。不管怎么样,不要放弃就行了。
还有一件好消息,阿Q的知识星球正在的布置中,离与大家见面的时间越来越近,因为不想让大家失望(不过可能会失望哈哈),到现在还没有达到我自己的预期,所以还没有给大家开放,敬请期待~
来源:
https://www.nowcoder.com/feed/main/detail/25b3f953c6b34898b33a165c6dd5aab5
1、常见的qt特性?
信号与槽机制
Qt的信号和槽机制是用来在对象间通信的方法,当一个特定的事件发生的时候,signal会被emit发射出来,slot函数用来响应相应的signal。它使得对象间通信保持一种松耦合的关系。
Qt信号槽需要Q_OBJECT宏支持的,程序在编译之前moc预处理器会对有Q_OBJECT宏的类进行预处理,生成moc_xxxx.cpp来扩展当前类。内部由meta object来维护我们需要的信息和接口。
Qt的信号和槽机制是用来在对象间通信的方法,一个信号可以连接到多个槽和信号;多个信号可以连接到同一个槽。
如果一个信号连接到多个槽,当信号被发射后所有的槽函数按照连接建立的顺序都会被激活。
跨平台:Qt 允许开发者编写一次代码,然后在多个平台上运行,包括 Windows、macOS、Linux 和嵌入式系统等。这通过 Qt 的跨平台 API 和工具链来实现。
GUI 开发:Qt 提供了强大的图形用户界面(GUI)开发工具,包括 Qt Widgets 和 Qt Quick。Qt Widgets 是一种传统的 GUI 编程方式,而 Qt Quick 则基于 QML(Qt Meta-Object Language)实现,用于创建现代的移动和嵌入式应用。
事件驱动
Qt是事件驱动的,程序每个动作都是由某个事件所触发。QApplication::exec()会调用QEventLoop进入事件循环,此时程序会进入等待状态,等待处理各种事件。
从系统得到的消息,比如鼠标,键盘等。Qt事件循环的时候读取这些事件,转换为QEvent后依次派发到对应窗口进行处理。
由Qt或应用程序产生,不放入队列直接通过QApplication::notify进行派发和处理,是同步的。
说一说信号与槽的底层实现?
一种强大的事件处理和通信机制,它允许对象之间在松散耦合的情况下进行通信。其底层实现基于 Qt 的元对象系统(Meta-Object System)和 C++ 的一些特性。
元对象系统(Meta-Object System):Qt 在编译时通过元对象系统生成额外的代码,用于支持信号与槽机制。每个包含信号和槽的类都会生成一个元对象,其中包含了信号和槽的元数据信息。
MOC 编译器:Qt 的信号与槽机制依赖于 MOC(Meta-Object Compiler)编译器。在使用信号与槽的类中,使用
Q_OBJECT
宏声明的类会被 MOC 编译器处理。MOC 会生成额外的代码,包括元对象的信息和用于信号槽连接的函数。元数据信息:每个包含信号和槽的类都会生成元数据信息,这些信息包括信号和槽的名称、参数类型等。元数据信息存储在一个特殊的表中,以支持运行时的信号与槽连接。
信号与槽函数指针:Qt 使用函数指针来表示信号和槽。每个信号和槽都有一个唯一的函数指针,用于标识它们。这些函数指针与元数据信息一起存储在元对象中。
连接信号与槽:在运行时,通过调用
QObject::connect
函数来建立信号与槽之间的连接。connect
函数接受信号的发送者、信号的函数指针、槽的接收者和槽的函数指针作为参数。连接成功后,当信号被触发时,槽会被调用。运行时调用:当信号被触发时,Qt 运行时系统会查找与该信号连接的槽,并通过函数指针来调用槽函数。这种方式实现了对象之间的松散耦合,因为信号发送者和槽接收者之间不需要直接调用对方的函数。
线程安全:Qt 信号与槽机制在多线程环境中也是线程安全的。Qt 会确保槽函数在接收线程的上下文中执行,从而避免了多线程访问的竞态条件。
connect函数的第五个参数?
第五个参数是连接类型(Connection Type),它用于指定信号与槽之间的连接类型。
2、多态?动态多态如何实现的?虚表存储在哪里?虚表的数据结构是什么?
多态分为静态多态和动态多态两种,其中动态多态是指在运行时确定对象的具体类型,以调用相应的方法。
动态多态的实现:动态多态通过虚函数来实现。虚函数是在基类中声明的一种特殊函数,它用关键字
virtual
来标识。子类可以覆盖(重写)基类的虚函数,并提供自己的实现。当通过基类指针或引用调用虚函数时,实际执行的是对象的派生类版本,这个过程是在运行时动态确定的。虚函数表(Virtual Table):每个包含虚函数的类都有一个虚函数表,也称为 vtable。虚函数表是一个指针数组,其中包含了该类的虚函数的地址。每个对象都包含一个指向其类的虚函数表的指针,这个指针通常称为虚指针。
虚函数调用:当调用一个对象的虚函数时,C++ 运行时系统首先检查该对象的虚指针,找到对应的虚函数表。然后,根据函数在虚函数表中的位置,调用正确的虚函数实现。这使得 C++ 能够在运行时确定对象的实际类型,并调用正确的函数。
虚函数的数据结构:虚函数表是一个由函数指针组成的数组,每个函数指针指向一个虚函数的实现。通常,虚函数表的第一个元素是一个指向类型信息(typeinfo)的指针,用于支持运行时类型识别(RTTI)。接下来的元素是各个虚函数的地址。
虚函数的影响:虚函数的使用允许创建通用的基类,派生类可以根据需要重写虚函数,而客户端代码可以使用基类指针或引用调用这些虚函数,而不需要关心对象的具体类型。这提高了代码的灵活性和可维护性。
3、构造函数可以是虚函数吗?
构造函数不能声明为虚函数。虚函数是用于实现多态的一种机制,它允许在运行时确定对象的类型并调用正确的函数实现。然而,构造函数的调用是在对象被创建时自动发生的,而不是在运行时根据对象的类型来选择的。
为什么构造函数不能声明为虚函数?
派生类的构造函数会先调用基类的构造函数,然后才执行自己的构造函数。如果构造函数是虚函数,那么在调用基类构造函数时,虚函数机制还没有建立,因此无法实现多态。
构造函数用于初始化对象的状态,包括对象的虚函数表。如果构造函数是虚函数,那么在对象完全构造之前就需要访问虚函数表,这可能会导致不确定的行为和错误。
构造函数具有特殊的语义和行为,它们用于创建对象并初始化其状态。虚函数通常用于对象已经创建并且处于可用状态时的多态性,而不是对象的创建过程。
4、析构函数可以抛出异常吗?
在 C++ 中,析构函数可以抛出异常,但要小心使用,并确保在适当的情况下捕获异常,以避免程序终止。
析构函数的异常抛出:析构函数是对象生命周期结束时调用的函数,用于清理对象的资源和状态。它可以包含代码块,这些代码块在对象销毁时执行。这些代码块中的任何代码,包括异常抛出,都会在对象销毁时执行。
异常安全性(Exception Safety):由于析构函数可以抛出异常,因此必须考虑异常安全性。异常安全性是指程序在抛出异常时是否能够保持对象和资源的一致性状态。有三种级别的异常安全性:弱异常安全性、强异常安全性和不抛出异常安全性。
弱异常安全性(Basic Exception Safety):析构函数不抛出异常,或者如果抛出异常,则能够保证对象已销毁并释放了资源。这是最低级别的安全性。
强异常安全性(Strong Exception Safety):析构函数能够在抛出异常时保持对象不变,即不会改变对象的状态,也不会泄漏资源。
不抛出异常安全性(No-Throw Exception Safety):析构函数绝不抛出异常,这是最高级别的安全性。
注意事项:
在析构函数中抛出异常可能会导致资源泄漏,因为对象的销毁可能不会完成。
如果析构函数抛出异常,那么在对象销毁的过程中,如果栈展开(stack unwinding)过程中没有找到合适的异常处理程序,程序将终止,这可能导致资源未被释放。
为了确保析构函数不会抛出异常,可以使用异常规范(exception specification),如
noexcept
关键字,来声明析构函数不会抛出异常,这有助于提高异常安全性。
示例代码:
class MyClass {public:MyClass() { /* 构造函数可能抛出异常 */ }~MyClass() noexcept { /* 析构函数声明不会抛出异常 */ }};int main() {try {MyClass obj; // 构造函数可能抛出异常// ...} catch (...) {// 处理异常}// 对象在此离开作用域,析构函数调用不会抛出异常return 0;}
5、友元?
友元(friend)是一种特殊的访问权限控制机制,它允许一个类或函数访问另一个类的私有成员或保护成员。友元通常用于在不违反封装性原则的情况下,提供对类的更多访问权限。
友元函数:友元函数是一个非成员函数,但被允许访问类的私有成员和保护成员。在类的声明中,可以使用
friend
关键字来声明一个函数为友元函数。友元函数通常用于重载运算符,以便在不同类型之间执行操作。
class MyClass {private:int data;public:MyClass(int val) : data(val) {}friend void printData(const MyClass& obj); // 友元函数的声明};void printData(const MyClass& obj) {cout << "Data: " << obj.data << endl; // 可以访问私有成员 data}
友元类:除了友元函数,C++ 还支持友元类。友元类是一个类,可以访问另一个类的私有成员和保护成员。要声明一个友元类,可以在类的声明中使用
friend
关键字。
class FriendClass {public:void accessMyClass(const MyClass& obj) {cout << "Data: " << obj.data << endl; // 可以访问私有成员 data}};class MyClass {private:int data;friend class FriendClass; // 声明 FriendClass 为友元类public:MyClass(int val) : data(val) {}};
友元的使用场景:
在重载运算符时,可以将运算符函数声明为友元,以便访问私有成员。
在不同类之间建立特殊的关系,使它们可以互相访问私有成员,但要小心使用,以避免破坏封装性原则。
友元还可以用于实现一些特定的设计模式,如单例模式。
6、全局对象的生成过程?static成员变量的生命周期?
全局对象的生成过程:
静态初始化阶段:当程序启动时,会执行全局对象的构造函数(如果有的话)。这个阶段被称为静态初始化阶段。全局对象包括全局变量、静态变量(位于函数内部的静态变量也算)以及全局的对象实例。
动态初始化阶段:如果全局对象具有构造函数(即它们需要进行初始化),则在静态初始化之后,会执行构造函数的动态初始化。这个阶段是全局对象进行实际初始化的地方。
使用全局对象:全局对象在程序的整个生命周期内都是可用的,可以被其他部分的代码访问和使用。
程序结束阶段:当程序结束时,会执行全局对象的析构函数(如果有的话)。这个阶段被称为静态销毁阶段。全局对象的析构函数用于清理资源和执行必要的清理操作。
静态成员变量的生命周期:
静态成员变量是属于类的变量,而不是属于类的对象的。它们具有类范围的生命周期,因此与全局变量有一些相似之处。
静态成员变量的初始化:静态成员变量在程序启动时由编译器自动初始化为默认值,或者可以在类外部显式初始化。
静态成员变量的使用:静态成员变量可以通过类名或类的对象来访问,它们在程序的整个生命周期内都是可用的。
静态成员变量的销毁:静态成员变量的销毁发生在程序结束时,它们的析构函数不会被调用,因为它们没有与特定对象相关联。它们的内存会在程序结束时被释放。
首先,创建一个额外的指针,用于保存其中一个指针指向的地址。这个临时指针将用于在交换过程中保留一个指针的值。 将第一个指针指向的地址赋给第二个指针。这会导致第二个指针现在指向了第一个指针原来指向的内存空间。 将临时指针保存的地址赋给第一个指针。这样,第一个指针现在指向了原来由第二个指针指向的内存空间。
当你声明一个成员函数为 const 成员函数时,它表示该函数不会修改调用它的对象的内部状态。这个声明通过在函数声明和定义中的函数参数后面加上 const
关键字来实现,例如void someFunction() const;
。非 const 对象可以调用 const 成员函数,因为 const 成员函数承诺不会修改对象的状态,而非 const 对象本身就具有修改对象状态的权限。这样做是为了方便代码的复用和维护。非 const 对象调用 const 成员函数可以避免不必要的数据修改,有助于提高代码的安全性和可维护性。 当 const 对象访问 const 成员函数时,这种情况是最严格的。const 对象无法调用非 const 成员函数,因为这可能导致对象状态的修改,而 const 对象的设计目的是不允许修改。因此,const 对象只能调用 const 成员函数。
栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。 自由存储区,就是那些由malloc等分配的内存块,它和堆是十分相似的,不过它是用free来结束自己的生命的。 全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。 常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
使用内存检测工具:内存检测工具可以帮助检测内存泄漏和内存访问问题。常见的工具包括 Valgrind(针对 C/C++ 等语言)、AddressSanitizer(通常用于 C/C++)和其他内存检测工具。通过运行程序并使用这些工具来检测内存泄漏,可以找到泄漏的位置和内存块。 核查异常处理:查看程序中的异常处理代码,确保在发生异常时也能够释放之前分配的内存。在 C++ 中,使用 RAII(资源获取即初始化)技术,可以确保在对象的析构函数中释放资源,即使发生异常也会自动释放。确保在异常情况下,资源能够被正确释放。 分析堆栈跟踪:当程序发生异常时,获取堆栈跟踪信息,以查看异常发生的位置。堆栈跟踪可以帮助确定异常发生时哪些代码段还未运行到释放内存的部分。 代码审查:仔细审查代码,特别关注在分配内存后是否存在条件分支,如果满足某些条件,则需要释放内存。确保在函数退出前(正常或异常退出)释放分配的内存。 使用智能指针:如果可能的话,考虑使用 C++ 的智能指针,如 std::shared_ptr
和std::unique_ptr
。它们可以自动管理内存,减少手动释放内存的需求。测试和模拟:在测试过程中,特别关注边界条件和异常情况,以确保内存管理是正确的。模拟异常情况,看看程序如何响应,并确保内存得到正确释放。 修复问题:一旦确定了内存泄漏的位置,修复问题并进行测试,确保内存管理问题已解决。
auto可以用于函数的返回值类型和Lambda表达式的参数类型推导。
函数的返回值类型推导:你可以使用 auto
来推导函数的返回值类型,这在某些情况下可以简化代码,特别是当返回值类型比较复杂或依赖于函数内部逻辑时。例如:
auto add(int a, int b) -> decltype(a + b) {return a + b;}
Lambda表达式的参数类型推导:Lambda表达式也可以使用 auto
来推导参数的类型,这使得Lambda函数更加通用。例如:
auto lambda = [](auto a, auto b) {return a + b;};int result1 = lambda(5, 10); // 参数类型被推导为intdouble result2 = lambda(3.5, 2.7); // 参数类型被推导为double
使用std::swap交换
#include <iostream>#include <memory>int main() {int *rawPtr = new int(42);std::shared_ptr<int> smartPtr;// 使用 std::swap 进行交换std::swap(smartPtr, std::shared_ptr<int>(rawPtr));// 现在 smartPtr 拥有了 rawPtr 的资源,rawPtr 变为 nullptrstd::cout << *smartPtr << std::endl; // 输出 42return 0;}
直接使用智能指针的构造函数
#include <iostream>#include <memory>int main() {int *rawPtr = new int(42);// 直接使用智能指针的构造函数来接管 rawPtr 的资源std::shared_ptr<int> smartPtr(rawPtr);// rawPtr 变为 nullptr,不再拥有资源std::cout << *smartPtr << std::endl; // 输出 42return 0;}
内部数据结构: vector
:使用动态数组作为内部数据结构,支持随机访问,即可以通过索引快速访问任意元素。插入和删除元素可能需要移动后续元素,因此在插入和删除操作上性能相对较低。list
:使用双向链表作为内部数据结构,插入和删除元素的性能很高,因为只需要调整节点的指针,而不需要移动元素。但是不支持随机访问,只能通过迭代器逐个遍历元素。空间复杂度: vector
:由于使用动态数组,可能会分配比实际元素数量更多的内存,因此空间复杂度相对高一些。list
:每个元素都需要额外的链表节点,因此通常占用的内存比vector
更多。插入和删除操作: vector
:插入和删除元素的性能相对较差,特别是在中间位置,因为需要移动后续元素。list
:插入和删除元素的性能非常高,因为只需要调整节点的指针,不需要移动元素。随机访问: vector
:支持随机访问,可以通过索引快速访问元素。list
:不支持随机访问,只能通过迭代器逐个遍历元素。迭代器稳定性: vector
:在不发生插入和删除操作的情况下,迭代器的稳定性较好,可以一直有效。list
:在不发生删除当前元素的情况下,迭代器的稳定性较好。但如果在迭代过程中删除了当前元素,那么使用被删除元素的迭代器会导致未定义行为。适用场景: 使用 vector
当需要快速随机访问元素,并且不经常进行插入和删除操作时。使用 list
当需要频繁进行插入和删除操作,而随机访问操作不是主要需求时。
泛型编程:模板允许您编写通用代码,可以适用于不同的数据类型,而无需为每种类型编写单独的代码。这可以提高代码的复用性和可维护性。 类型安全:模板在编译时进行类型检查,可以捕获许多在运行时才会出现的类型错误,从而提高代码的稳定性和可靠性。 性能:使用模板可以生成高效的代码,因为编译器可以对模板进行优化,生成特定数据类型的高效代码。 标准库支持:C++标准库中大量使用了模板,使得开发人员可以轻松地使用各种数据结构和算法。 可扩展性:您可以创建自定义模板,以满足特定需求,从而提高了代码的灵活性和可扩展性。
编译时间:使用模板可能会导致较长的编译时间,特别是当模板被实例化为多个不同的数据类型时。这可能会增加开发周期。 错误消息:编译器生成的错误消息通常比非模板代码更难理解,特别是当涉及复杂的模板嵌套时。 代码可读性:包含大量模板的代码可能会变得难以阅读和理解,特别是当涉及复杂的模板元编程时。 冗余代码:为了支持不同的数据类型,模板通常会生成多个实例化的代码,可能导致代码膨胀,增加了可执行文件的大小。 复杂性:模板编程可以变得非常复杂,尤其是当涉及到高级模板技巧时,这可能会使代码难以维护。
std::move是一个用于将左值转换为右值引用的工具函数,通常用于移动语义的实现。它并不是一个操作符,而是一个函数模板。
std::move的主要目的是告诉编译器,你有意将一个左值转换为右值引用,以便进行移动语义的操作,例如移动构造函数或移动赋值运算符。它并不实际移动数据,而只是改变了数据的所有权。
++i是否是左值或右值的问题:
++i
是一个右值表达式,因为它是一个临时值,它没有名字,不能被取地址。i++
是一个左值表达式,因为它是一个具有名字的变量,可以被取地址。
++i)返回递增后的值作为右值,而后置递增运算符(
i++)返回递增前的值作为左值。
#include <iostream>#include <utility>int main() {int i = 42;// 使用 std::move 将左值 i 转换为右值引用int&& rvalue_ref = std::move(i);// 此时 i 已经是一个右值引用,不可再次使用// std::cout << i << std::endl; 产生编译错误// ++i 返回一个右值int a = ++i; // 可行// i++ 返回一个左值int b = i++; // 可行return 0;}
static_cast: 主要用途:用于进行基本类型之间的强制类型转换,以及在继承层次结构中的非多态类型之间进行转换。 编译时检查:进行较少的类型检查,主要依赖于程序员的判断。 安全性:通常用于安全的类型转换,但不适用于处理多态类型。 例子: const_cast: 主要用途:用于添加或去除 const
或volatile
限定符,主要用于修饰类型的转换。编译时检查:进行较少的类型检查,主要用于修饰类型的转换。 安全性:通常用于对非 const
对象进行const
转换,但滥用可能导致未定义行为。例子: reinterpret_cast: 主要用途:用于进行低级别的、不安全的类型转换,通常用于指针和整数类型之间的转换。 编译时检查:几乎没有类型检查,只是重新解释内存中的位模式。 安全性:非常不安全,滥用可能导致严重的问题。 例子: dynamic_cast: 主要用途:用于多态类型之间的转换,用于安全地在继承层次结构中检查和转换指针或引用。 编译时检查:进行类型检查,如果类型不兼容,返回 nullptr
(对于指针)或抛出异常(对于引用)。安全性:用于处理多态类型的类型转换,通常用于较安全的场合。 例子:
double d = 3.14;int i = static_cast<int>(d);
const int ci = 42;int* nonConstPtr = const_cast<int*>(&ci);
int i = 42;void* voidPtr = reinterpret_cast<void*>(&i);
class Base {virtual void foo() {}};class Derived : public Base {void foo() override {}};Base* basePtr = new Derived;Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
地址空间: 每个进程都有自己的虚拟地址空间,通常是4GB(32位系统)或更大(64位系统)。 进程中的每个程序都认为它拥有整个地址空间,但实际上只有部分地址空间映射到物理内存。 页面: 虚拟内存被划分成固定大小的块,称为页面或页(通常大小为4KB)。 物理内存也被划分成相同大小的块。 页面表: 操作系统维护一个页面表,用于跟踪虚拟内存页面与物理内存页面之间的映射关系。 页面表存储哪些虚拟页面当前被加载到了物理内存,以及它们的位置。 分页: 当进程访问虚拟内存中的某个页面时,操作系统会检查页面表以查看页面是否在物理内存中。 如果页面不在物理内存中(称为缺页),操作系统会将其从磁盘加载到物理内存,覆盖一个不需要的页面(置换算法决定)。 优点: 允许多个进程共享相同的物理内存,减少内存浪费。 允许虚拟内存大于物理内存,提高系统的可用性和性能。 简化了内存管理,允许更灵活地分配和释放内存。 缺点: 对虚拟内存的访问比物理内存慢,因为它需要磁盘访问。 增加了操作系统内核的复杂性,需要管理页面表、页面调度等。 需要额外的硬盘空间用于存储页面文件。
数据隐藏:封装类的一个关键目标是隐藏类的内部实现细节,只暴露必要的接口给外部。这可以通过将类的成员变量定义为私有(private)或受保护(protected),并提供公共的访问方法(getter和setter)来实现。 接口设计:设计好类的公共接口是封装的重要部分。接口应该清晰、简洁、易于使用,并反映类的主要功能。 成员变量的访问控制:成员变量可以根据需要使用 private
、protected
或public
访问控制修饰符。private
表示只有类内部可以访问,protected
表示类及其派生类可以访问,public
表示任何地方都可以访问。封装细节:除了将数据封装起来,还可以在类中封装一些操作和方法,以确保数据的一致性和有效性。例如,可以在类的方法中添加数据验证或处理逻辑。 内存管理:确保在封装类中正确管理内存是非常重要的。如果类使用动态分配的内存(如使用 new
运算符分配的内存),则需要在类的析构函数中释放这些内存,以防止内存泄漏。C++中,可以使用智能指针(如std::shared_ptr
、std::unique_ptr
)来管理动态分配的内存,以减少内存泄漏的风险。异常处理:考虑到类的使用者可能会传递不正确的参数或出现其他错误,应该在类的方法中进行适当的异常处理,以确保类能够健壮地处理异常情况。 性能:在封装类时,要考虑类的性能。某些操作可能会对性能产生影响,因此需要权衡封装和性能之间的权衡。




