C++实践笔记
记了很多次笔记,简单的不会去看,复杂的记了又看不上。这次就以面试为目的,记录那些自己遇到的不会的知识点。
我在创建一个 C++ 类 Test,在其构造函数执行资源分配。如果分配失败,我该如何通知构造函数外部?
软件设计开发
-
C++程序设计的原则有哪些?
- 封装(Encapsulation):通过类和对象将数据和函数封装在一起,隐藏实现细节,只暴露必要的接口。这有助于减少复杂性,提高代码的可维护性。
- 继承(Inheritance):允许新建的类继承现有类的属性和行为,促进代码重用,提高程序的扩展性。
- 多态(Polymorphism):通过接口和虚拟函数实现不同类对象的统一操作,提高程序的灵活性和可扩展性。
- 单一职责原则(Single Responsibility Principle):每个类应该只有一个职责,即一个类只负责一件事情或功能模块,以提高类的可维护性和可理解性。
- 开放封闭原则(Open/Closed Principle):软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。通过继承和多态,可以在不改变原有代码的情况下对系统进行扩展。
- 接口隔离原则(Interface Segregation Principle):使用多个特定客户端的接口,而不是一个通用的接口,避免接口臃肿。
- 依赖倒置原则(Dependency Inversion Principle):高层模块不依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。
- 组合优于继承(Composition over Inheritance):优先使用组合来设计系统,以便更灵活地组织代码结构,避免继承层次过深带来的复杂性。
- 资源管理(RAII,Resource Acquisition Is Initialization):通过构造函数和析构函数管理资源的分配与释放,确保资源的正确管理和回收,避免内存泄漏。
-
你在Linux平台开发中,常用哪些调试工具?分别适用于什么场景?
- top/htop:用于初步分析系统性能瓶颈。
- GDB:用于定位运行时异常和调试崩溃问题。还可以结合 core dump 使用。
- strace和lsof:用于分析系统调用和文件描述符
- valgrind:用于检查内存泄漏和性能分析;
-
使用过内存检测工具吗?
有用过valgrind,但是用的不多,一个是在嵌入式开发环境下,很少有提供 valgrind 工具的。另外一个 valgrind 需要在应用进入/退出时进行统计。而实际项目中,有些项目在后期迭代中并没有那么注重代码质量,导致程序后台运行后,无法正常结束,不能很好的处理资源回收(一个优雅的应用无论是前台运行还是后台运行,都应该支持程序正常退出,退出前释放占用资源)。只能通过 kill 将进行杀死,那这就会导致 valgrind 统计出很多其实没有发生内存泄露的地方。另外有自定义实现内存池的地方发生内存泄露(申请分配了,但某种原因不归还),valgrind 也无法分析出。
在项目中检测内存泄漏时,我们可以借鉴 Valgrind 的基本原理,即监控内存分配和释放函数(如 malloc()/free() 和 new/delete)的调用,以确保它们能够成对匹配。虽然 Valgrind 的实现远比这复杂,但这种方法为我们提供了一个基础思路。
在我们的项目中,由于代码的特殊性,常规工具无法很好地应用,因此我们使用了 GCC 链接器的 --wrap 选项来替换标准的 malloc()/free() 实现,同时重载了 new/delete 操作符,以便统一通过 malloc()/free() 进行内存管理。在我们自定义的 malloc()/free() 实现中,我们记录了内存的起始地址和调用栈信息。
对于无法正常终止的应用程序,我们采用定时将这些内存记录信息输出到日志文件中。然后,通过自定义的脚本或工具分析这些日志,帮助识别出哪些内存分配没有得到正确的释放。
在客流统计抓拍终端项目中,我应用了这一方法,有效地发现并解决了内存泄漏问题,包括内存池的泄漏,从而显著减少了设备重启的次数。
-
在多线程开发中,你是如何处理线程安全问题的?
为了保证线程安全,我通常会使用以下手段:
- 使用互斥锁(std::mutex)来保护共享资源;
- 使用读写锁(std::shared_mutex)优化读多写少的场景;
- 避免死锁,通过分析线程依赖关系,按统一的顺序加锁;使用 std::lock_guard 等RAII机制。
- 在某些情况下,使用无锁队列(如boost::lockfree)提升性能。
在人脸识别门禁机项目中,为保证设备对多任务(如视频预览和用户识别,图片下发注册)的并发处理能力,采用线程池,并用条件变量优化了任务的调度效率。线程池之间的任务通过条件变量进行同步,通过std::future 或者自定义时间通知结果。
-
解释一下死锁是怎么形成的?
-
互斥条件(Mutual Exclusion):当共享资源(如互斥锁、文件句柄等)被某一线程独占时,其他线程必须等待该资源释放。例如,std::mutex的锁定操作会阻止其他线程同时访问被保护资源。若多个线程反复争夺同一组互斥资源,就可能触发死锁。
-
持有并等待(Hold and Wait):线程在已持有至少一个资源的情况下,继续请求新的资源,且这些新资源被其他线程持有。例如,线程A锁定mutex1后尝试锁定mutex2,而线程B在锁定mutex2后尝试锁定mutex1,双方形成僵持
-
不可抢占(No Preemption):已分配的资源无法被强制剥夺,只能由持有者主动释放。例如,C++中的std::mutex不具备自动超时或抢占机制,若线程因逻辑错误未释放锁,其他线程将无限期等待。
-
循环等待(Circular Wait):多个线程形成资源请求的环形依赖链。例如:
- 线程1持有A锁,等待B锁;
- 线程2持有B锁,等待C锁;
- 线程3持有C锁,等待A锁。
这种环路会使得所有线程无法推进。
-
-
在Linux上多线程开发使用锁需要注意什么?
避免在持有锁时执行耗时操作,否则会阻塞其他线程,影响并发性能
需确保每次加锁后必须解锁,否则会导致死锁,在C++应该尽可能利用RAII机制对锁进行加解锁
-
请根据实际项目说下你熟悉的几种设计模式
- 工厂模式:工厂模式在与硬件打交道的项目中,非常常见,比如我们开发一个实验台需要用到CAN卡,就需要适配各种不同价格和接口的CAN卡,比如PCIE,USB的。而我们只需抽象出公共接口例如read、write、就行,然后根据不同的参数得到不同的实现。同时他们也是进行单例化的,原则上每个硬件代表的对象同时只能有一个实例。
- 观察者模式,在面板机从MPP侧获取到图像流之后,通过回调通知感兴趣的模块(人脸识别,OCR识别,视频编码)进行处理。
- 适配器模式:apollo的TopicAdapter,然后这里还用到了观察模式,就是Adapter一但接收到了ros数据,就会将其转换为protobuf格式然后,回调各个对齐感兴趣的模块对齐进行处理。
- 代理模式,用的不多,但是可以举一个例子。比如对数组进行排序,当交换拷贝元素时比较耗时,可以创建一个简单的代理类,其引用其比较大小的键值。再进行排序。
-
我有一个这样的终端,最初实现了通过MQTT控制终端的音量,屏幕亮度等。后来有客户提出希望通过http控制。那么如何使用设计模式,满足后续如果客户再提出希望使用WebSocket、TCP方式进行控制呢?
这个问题非常适合使用策略模式和适配器模式来解决。我会设计一个灵活的架构,使系统能够轻松支持多种通信协议。
解决方案架构
核心设计思路
- 策略模式 (Strategy Pattern)
- 定义抽象通信策略接口
ICommunicationStrategy,包含统一的虚函数接口、 - 每种协议(MQTT、HTTP、WebSocket、TCP)都继承并实现这个接口
- 终端控制器通过智能指针持有策略对象,可以动态切换
- 定义抽象通信策略接口
- 适配器模式 (Adapter Pattern)
- 每个协议适配器负责将统一的控制命令转换为对应协议的特定格式
- 处理不同协议的连接、认证、数据格式转换等细节
- 隔离协议差异,使上层控制逻辑保持一致
- 工厂模式 (Factory Pattern)
- 提供通信策略的创建方法,根据配置返回对应的适配器智能指针
- 支持通过配置文件动态选择通信方式
C++代码实现
C++特性应用
- 智能指针: 使用
std::unique_ptr管理策略对象生命周期 - 虚函数: 通过虚函数实现多态,支持运行时策略切换
- RAII: 在析构函数中自动释放资源
- 异常处理: 使用C++异常机制处理连接和通信错误
- STL容器: 使用
std::map管理配置参数
扩展性优势
- 开闭原则: 添加新协议无需修改现有代码,只需实现新的适配器类
- 内存安全: 使用智能指针避免内存泄漏
- 类型安全: 利用C++强类型系统在编译期发现错误
- 性能优化: 避免不必要的对象复制,使用移动语义
- 配置驱动: 协议选择和参数可通过外部配置文件管理
这种设计使得C++系统能够灵活应对客户不断变化的通信需求,同时保持代码的高性能和类型安全。
- 策略模式 (Strategy Pattern)
-
简要说一下Qt信号与槽的原理
信号与槽,实际就是观察者模式。当某个事件发生之后,比如,按钮检测到自己被点击了一下,它就会发出一个信号(signal)。这种发出是没有目的的,类似广播。如果有对象对这个信号感兴趣,它就会使用连接(connect)函数。当信号发出时,被连接的槽函数会自动被回调。这就类似观察者模式:当发生了感兴趣的事件,某一个操作就会被自动触发。(这里提一句,Qt 的信号槽使用了额外的处理来实现,并不是 GoF 经典的观察者模式的实现方式。)
信号和槽是Qt特有的信息传输机制,是Qt设计程序的重要基础,它可以让互不干扰的对象建立一种联系。
槽的本质是类的成员函数,其参数可以是任意类型的。和普通C++成员函数几乎没有区别,它可以是虚函数;也可以被重载;可以是公有的、保护的、私有的、也可以被其他C++成员函数调用。唯一区别的是:槽可以与信号连接在一起,每当和槽连接的信号被发射的时候,就会调用这个槽。
基础技能
-
C++是不是类型安全的?
不是。两个不同类型的指针之间可以强制转换(用 reinterpret_cast,为了兼容C) 。 C# 是类型安全的,C++是强类型语言,但不是类型安全语言。
-
讲一下C++中的多态是什么?在实际项目中是如何使用多态的?
多态是面向对象编程的一个重要特性,允许通过【基类指针或引用】调用派生类的重写方法。在C++中,多态主要通过虚函数实现。
在项目中,我曾在外设接入适配中使用多态。例如在面板机项目中,需要适配不同的身份证模块或二维码读头,可以定义一个基类接口,具体实现交由派生类完成,从而保证了代码的模块化和扩展性。
-
项目中为什么使用C++14/17,使用了哪些内容?
使用C++14/17主要有这么几个好处,一个是提高代码可读性和安全性,另外一个是减少内存泄漏和资源管理问题,最后更好的类型推导和模板支持。
- 项目中使用智能指针避免内存泄漏。
- auto类型推导可以简化代码,提高可读性。
- lambda函数可以简化函数对象的时间,方便一些回调小函数的实现。
std::thread、std::mutex、std::condition_variable实现多线程并发和同步,保护共享数据,还利用RAII机制防止死锁std::chrono统一 C++ 的时间处理std::move移动语义避免不必要的拷贝,提升代码可读性std::function提供统一和类型安全的函数包装constexpr用于编译器常量std::optional用于三值语义std::variant用于安全的联合体使用,std::any避免了指针void*范式的类型擦除
-
若有以下说明语句:
char w; int x; float y,z;则表达式w*x+z-y的结果为什么类型?
答案:会发生隐式转换,float类型。
-
下列程序的输出结果:
void main() {
int a=0, b=0, c=0;
if(++a > 0 || ++b > 0) ++c;
printf("a=%d, b=%d, c=%d\n", a, b, c);
}答案:输出 a=1, b=0, c=1。
-
已知某二叉树的先序遍历为F B A C D E G H,中序遍历为A B D C E F G H,该二叉树的后序遍历为 。A) A D E C B H G F B) A B D E C G H F C) G H A D E C B F D) H G A D E C B F答案:A先序遍历:先访问根结点,然后再访问左右结点。中序遍历:先访问左结点,再访问根结点,最后访问右结点。二叉树的先、中、后序遍历都是递归操作。
-
进程间通信的几种方式及优缺点。
- 管道:速度慢,容量有限,只有父子进程能通讯
- FIFO:任何进程间都能通讯,但速度慢
- 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
- 信号量:不能传递复杂消息,只能用来同步
- 共享内存区:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存。
-
在C++多线程编程中,当系统创建一个新线程时,会为每个线程分配以下核心资源:
- 线程控制块(Thread Control Block, TCB):系统内核会为每个线程维护一个线程控制块,用于存储线程的元数据,例如:
- 线程ID:唯一标识符,用于操作系统调度和管理。
- 线程状态(运行、就绪、阻塞等)和优先级。
- 寄存器状态(如程序计数器、栈指针等),用于保存和恢复线程执行的上下文。
- 私有栈空间(Stack):每个线程拥有独立的栈空间,用于存储局部变量、函数调用链和临时数据。
- 线程局部存储(Thread-Local Storage, TLS):某些情况下,系统或程序会为线程分配线程局部存储区,用于存储线程独有的全局或静态变量副本
-
实现String类。
class String {
public:
String(const char *str = nullptr); // 普通构造函数
String(const String &other); // 拷贝构造函数
~String(); // 析构函数
String & operate =(const String &other); // 赋值函数
private:
char *m_data = nullptr; // 用于保存字符串
};
String::String(const char *str) {
if(str==nullptr) {
m_data = new char[1];
*m_data = '\0';
} else {
int length = strlen(str);
m_data = new char[length+1];
strcpy(m_data, str);
}
}
String::String(const String &other) {
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
}
String & String::operate =(const String &other) {
if(this == &other)
return *this;
if(m_data!=nullptr) delete [] m_data;
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
return *this;
}
String::~String() {
if(m_data!=nullptr) delete[] m_data;
} -
实现
memcpy()函数。考察内存可能重叠的处理,其实就是memmove()函数。void* memcpy (void *dest, const void *src, size_t len) {
char *d = dest;
const char *s = src;
if (d < s) {
while (len--)
*d++ = *s++;
} else {
char *lasts = s + (len-1);
char *lastd = d + (len-1);
while (len--)
*lastd-- = *lasts--;
}
return dest;
} -
手写翻转单链表。
struct ListNode {
int value;
ListNode *next;
};
ListNode* reverseList(ListNode *head) {
if(head==nullptr || header->next==nullptr) return head;
ListNode* ret = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return ret;
} -
合并两个有序列表。
struct ListNode {
int value;
ListNode *next;
};
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
if (l1 == nullptr) {
return l2;
} else if (l2 == nullptr) {
return l1;
} else if (l1->value < l2->value) {
l1->next = mergeTwoLists(l1->next, l2);
return l1;
} else {
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
} -
如何定义一个数组指针?
int arr[5] = {1, 2, 3, 4, 5};
int (*p)[5] = &arr; // p是指向包含5个int元素的数组的指针
- 如何按位定义结构体?
struct 结构体名 {
数据类型 成员名 : 位数;
// 例如:unsigned int flag1 : 1;
};
- 编写一个判断大小端存储方式的函数。
#include <stdio.h>
int is_little_endian() {
union {
int num;
char byte;
} test = {.num = 1}; // 初始化联合体成员
return test.byte == 1; // 共享内存首字节[1,8](@ref)
}
int main() {
printf("%s\n", is_little_endian() ? "小端模式" : "大端模式");
return 0;
}
std::vector中resize()和reserve()的区别。
reserve()改变的是vector的capacity,也就是容量,且只在扩大时,也就是reserve()的参数大于当前容易的capacity时才生效,小于时不作用。如果要减小vector的capacity应该使用shrink_to_fit()让capacity等于vector的size。
size()函数用于改变vector的size。当size大于当前capacity时,vector会进行扩容,然后再将现有元素复制到新的内存空间上去。小于capacity时,也只改变size大小,不改变capacity大小。