跳到主要内容

C++实践笔记

阅读量: 101
阅读人次: 102

记了很多次笔记,简单的不会去看,复杂的记了又看不上。这次就以面试为目的,记录那些自己遇到的不会的知识点。

我在创建一个 C++ 类 Test,在其构造函数执行资源分配。如果分配失败,我该如何通知构造函数外部?

软件设计开发

  1. C++程序设计的原则有哪些?

    • 封装(Encapsulation):通过类和对象将数据和函数封装在一起,隐藏实现细节,只暴露必要的接口。这有助于减少复杂性,提高代码的可维护性。
    • 继承(Inheritance):允许新建的类继承现有类的属性和行为,促进代码重用,提高程序的扩展性。
    • 多态(Polymorphism):通过接口和虚拟函数实现不同类对象的统一操作,提高程序的灵活性和可扩展性。
    • 单一职责原则(Single Responsibility Principle):每个类应该只有一个职责,即一个类只负责一件事情或功能模块,以提高类的可维护性和可理解性。
    • 开放封闭原则(Open/Closed Principle):软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。通过继承和多态,可以在不改变原有代码的情况下对系统进行扩展。
    • 接口隔离原则(Interface Segregation Principle):使用多个特定客户端的接口,而不是一个通用的接口,避免接口臃肿。
    • 依赖倒置原则(Dependency Inversion Principle):高层模块不依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。
    • 组合优于继承(Composition over Inheritance):优先使用组合来设计系统,以便更灵活地组织代码结构,避免继承层次过深带来的复杂性。
    • 资源管理(RAII,Resource Acquisition Is Initialization):通过构造函数和析构函数管理资源的分配与释放,确保资源的正确管理和回收,避免内存泄漏。
  2. 你在Linux平台开发中,常用哪些调试工具?分别适用于什么场景?

    • top/htop:用于初步分析系统性能瓶颈。
    • GDB:用于定位运行时异常和调试崩溃问题。还可以结合 core dump 使用。
    • strace和lsof:用于分析系统调用和文件描述符
    • valgrind:用于检查内存泄漏和性能分析;
  3. 使用过内存检测工具吗?

    有用过valgrind,但是用的不多,一个是在嵌入式开发环境下,很少有提供 valgrind 工具的。另外一个 valgrind 需要在应用进入/退出时进行统计。而实际项目中,有些项目在后期迭代中并没有那么注重代码质量,导致程序后台运行后,无法正常结束,不能很好的处理资源回收(一个优雅的应用无论是前台运行还是后台运行,都应该支持程序正常退出,退出前释放占用资源)。只能通过 kill 将进行杀死,那这就会导致 valgrind 统计出很多其实没有发生内存泄露的地方。另外有自定义实现内存池的地方发生内存泄露(申请分配了,但某种原因不归还),valgrind 也无法分析出。

    在项目中检测内存泄漏时,我们可以借鉴 Valgrind 的基本原理,即监控内存分配和释放函数(如 malloc()/free() 和 new/delete)的调用,以确保它们能够成对匹配。虽然 Valgrind 的实现远比这复杂,但这种方法为我们提供了一个基础思路。

    在我们的项目中,由于代码的特殊性,常规工具无法很好地应用,因此我们使用了 GCC 链接器的 --wrap 选项来替换标准的 malloc()/free() 实现,同时重载了 new/delete 操作符,以便统一通过 malloc()/free() 进行内存管理。在我们自定义的 malloc()/free() 实现中,我们记录了内存的起始地址和调用栈信息。

    对于无法正常终止的应用程序,我们采用定时将这些内存记录信息输出到日志文件中。然后,通过自定义的脚本或工具分析这些日志,帮助识别出哪些内存分配没有得到正确的释放。

    在客流统计抓拍终端项目中,我应用了这一方法,有效地发现并解决了内存泄漏问题,包括内存池的泄漏,从而显著减少了设备重启的次数。

  4. 在多线程开发中,你是如何处理线程安全问题的?

    为了保证线程安全,我通常会使用以下手段:

    • 使用互斥锁(std::mutex)来保护共享资源;
    • 使用读写锁(std::shared_mutex)优化读多写少的场景;
    • 避免死锁,通过分析线程依赖关系,按统一的顺序加锁;使用 std::lock_guard 等RAII机制。
    • 在某些情况下,使用无锁队列(如boost::lockfree)提升性能。

    在人脸识别门禁机项目中,为保证设备对多任务(如视频预览和用户识别,图片下发注册)的并发处理能力,采用线程池,并用条件变量优化了任务的调度效率。线程池之间的任务通过条件变量进行同步,通过std::future 或者自定义时间通知结果。

  5. 解释一下死锁是怎么形成的?

    • 互斥条件(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锁。

      这种环路会使得所有线程无法推进。

  6. 在Linux上多线程开发使用锁需要注意什么?

    避免在持有锁时执行耗时操作,否则会阻塞其他线程,影响并发性能

    需确保每次加锁后必须解锁,否则会导致死锁,在C++应该尽可能利用RAII机制对锁进行加解锁

  7. 请根据实际项目说下你熟悉的几种设计模式

    • 工厂模式:工厂模式在与硬件打交道的项目中,非常常见,比如我们开发一个实验台需要用到CAN卡,就需要适配各种不同价格和接口的CAN卡,比如PCIE,USB的。而我们只需抽象出公共接口例如read、write、就行,然后根据不同的参数得到不同的实现。同时他们也是进行单例化的,原则上每个硬件代表的对象同时只能有一个实例。
    • 观察者模式,在面板机从MPP侧获取到图像流之后,通过回调通知感兴趣的模块(人脸识别,OCR识别,视频编码)进行处理。
    • 适配器模式:apollo的TopicAdapter,然后这里还用到了观察模式,就是Adapter一但接收到了ros数据,就会将其转换为protobuf格式然后,回调各个对齐感兴趣的模块对齐进行处理。
    • 代理模式,用的不多,但是可以举一个例子。比如对数组进行排序,当交换拷贝元素时比较耗时,可以创建一个简单的代理类,其引用其比较大小的键值。再进行排序。
  8. 我有一个这样的终端,最初实现了通过MQTT控制终端的音量,屏幕亮度等。后来有客户提出希望通过http控制。那么如何使用设计模式,满足后续如果客户再提出希望使用WebSocket、TCP方式进行控制呢?

    这个问题非常适合使用策略模式和适配器模式来解决。我会设计一个灵活的架构,使系统能够轻松支持多种通信协议。

    解决方案架构

    核心设计思路

    1. 策略模式 (Strategy Pattern)
      • 定义抽象通信策略接口 ICommunicationStrategy,包含统一的虚函数接口、
      • 每种协议(MQTT、HTTP、WebSocket、TCP)都继承并实现这个接口
      • 终端控制器通过智能指针持有策略对象,可以动态切换
    2. 适配器模式 (Adapter Pattern)
      • 每个协议适配器负责将统一的控制命令转换为对应协议的特定格式
      • 处理不同协议的连接、认证、数据格式转换等细节
      • 隔离协议差异,使上层控制逻辑保持一致
    3. 工厂模式 (Factory Pattern)
      • 提供通信策略的创建方法,根据配置返回对应的适配器智能指针
      • 支持通过配置文件动态选择通信方式

    C++代码实现

    C++特性应用

    1. 智能指针: 使用 std::unique_ptr 管理策略对象生命周期
    2. 虚函数: 通过虚函数实现多态,支持运行时策略切换
    3. RAII: 在析构函数中自动释放资源
    4. 异常处理: 使用C++异常机制处理连接和通信错误
    5. STL容器: 使用 std::map 管理配置参数

    扩展性优势

    1. 开闭原则: 添加新协议无需修改现有代码,只需实现新的适配器类
    2. 内存安全: 使用智能指针避免内存泄漏
    3. 类型安全: 利用C++强类型系统在编译期发现错误
    4. 性能优化: 避免不必要的对象复制,使用移动语义
    5. 配置驱动: 协议选择和参数可通过外部配置文件管理

    这种设计使得C++系统能够灵活应对客户不断变化的通信需求,同时保持代码的高性能和类型安全。

  9. 简要说一下Qt信号与槽的原理

    信号与槽,实际就是观察者模式。当某个事件发生之后,比如,按钮检测到自己被点击了一下,它就会发出一个信号(signal)。这种发出是没有目的的,类似广播。如果有对象对这个信号感兴趣,它就会使用连接(connect)函数。当信号发出时,被连接的槽函数会自动被回调。这就类似观察者模式:当发生了感兴趣的事件,某一个操作就会被自动触发。(这里提一句,Qt 的信号槽使用了额外的处理来实现,并不是 GoF 经典的观察者模式的实现方式。)

    信号和槽是Qt特有的信息传输机制,是Qt设计程序的重要基础,它可以让互不干扰的对象建立一种联系。

    槽的本质是类的成员函数,其参数可以是任意类型的。和普通C++成员函数几乎没有区别,它可以是虚函数;也可以被重载;可以是公有的、保护的、私有的、也可以被其他C++成员函数调用。唯一区别的是:槽可以与信号连接在一起,每当和槽连接的信号被发射的时候,就会调用这个槽。

基础技能

  1. C++是不是类型安全的?

    不是。两个不同类型的指针之间可以强制转换(用 reinterpret_cast,为了兼容C) 。 C# 是类型安全的,C++是强类型语言,但不是类型安全语言。

  2. 讲一下C++中的多态是什么?在实际项目中是如何使用多态的?

    多态是面向对象编程的一个重要特性,允许通过【基类指针或引用】调用派生类的重写方法。在C++中,多态主要通过虚函数实现。

    在项目中,我曾在外设接入适配中使用多态。例如在面板机项目中,需要适配不同的身份证模块或二维码读头,可以定义一个基类接口,具体实现交由派生类完成,从而保证了代码的模块化和扩展性。

  3. 项目中为什么使用C++14/17,使用了哪些内容?

    使用C++14/17主要有这么几个好处,一个是提高代码可读性和安全性,另外一个是减少内存泄漏和资源管理问题,最后更好的类型推导和模板支持。

    • 项目中使用智能指针避免内存泄漏。
    • auto类型推导可以简化代码,提高可读性。
    • lambda函数可以简化函数对象的时间,方便一些回调小函数的实现。
    • std::threadstd::mutexstd::condition_variable实现多线程并发和同步,保护共享数据,还利用RAII机制防止死锁
    • std::chrono 统一 C++ 的时间处理
    • std::move 移动语义避免不必要的拷贝,提升代码可读性
    • std::function提供统一和类型安全的函数包装
    • constexpr用于编译器常量
    • std::optional用于三值语义
    • std::variant 用于安全的联合体使用,std::any 避免了指针void*范式的类型擦除
  4. 若有以下说明语句:

    char w; int x; float y,z; 

    则表达式w*x+z-y的结果为什么类型?

    答案:会发生隐式转换,float类型。

  5. 下列程序的输出结果:

    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。

  6. 已知某二叉树的先序遍历为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先序遍历:先访问根结点,然后再访问左右结点。中序遍历:先访问左结点,再访问根结点,最后访问右结点。二叉树的先、中、后序遍历都是递归操作。

  7. 进程间通信的几种方式及优缺点。

    • 管道:速度慢,容量有限,只有父子进程能通讯
    • FIFO:任何进程间都能通讯,但速度慢
    • 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
    • 信号量:不能传递复杂消息,只能用来同步
    • 共享内存区:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存。
  8. 在C++多线程编程中,当系统创建一个新线程时,会为每个线程分配以下核心资源:

    • 线程控制块(Thread Control Block, TCB)​:系统内核会为每个线程维护一个线程控制块,用于存储线程的元数据,例如:
    • 线程ID:唯一标识符,用于操作系统调度和管理。
    • 线程状态​(运行、就绪、阻塞等)和优先级。
    • 寄存器状态​(如程序计数器、栈指针等),用于保存和恢复线程执行的上下文。
    • 私有栈空间(Stack):每个线程拥有独立的栈空间,用于存储局部变量、函数调用链和临时数据。
    • 线程局部存储(Thread-Local Storage, TLS)​:某些情况下,系统或程序会为线程分配线程局部存储区,用于存储线程独有的全局或静态变量副本
  9. 实现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;
    }
  10. 实现 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;
    }
  11. 手写翻转单链表。

    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;
    }
  12. 合并两个有序列表。

    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;
    }
    }
  13. 如何定义一个数组指针?

int arr[5] = {1, 2, 3, 4, 5};
int (*p)[5] = &arr; // p是指向包含5个int元素的数组的指针
  1. 如何按位定义结构体?
struct 结构体名 {
数据类型 成员名 : 位数;
// 例如:unsigned int flag1 : 1;
};
  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;
}
  1. std::vectorresize()reserve() 的区别。

reserve()改变的是vectorcapacity,也就是容量,且只在扩大时,也就是reserve()的参数大于当前容易的capacity时才生效,小于时不作用。如果要减小vectorcapacity应该使用shrink_to_fit()capacity等于vectorsize

size()函数用于改变vector的size。当size大于当前capacity时,vector会进行扩容,然后再将现有元素复制到新的内存空间上去。小于capacity时,也只改变size大小,不改变capacity大小。