Post

C++ 面经

C++ 面经

1. C/C++ 语言特性

1.1 const

常量修饰符,能够作用于:(1)变量;(2)指针;(3)引用,用于形参类型,即避免了拷贝,又避免了函数对值的修改;(4)成员函数,说明该成员函数内不能修改任何成员变量(常对象只能调用常函数)。

1
2
3
// const 修饰右侧紧邻的符号
const int* x;   // 修饰int,指向的内容不能改变
int* const x;   // 修饰 x 指针,指向地址不允许改变

#defineconst 的区别:

宏定义 #defineconst 常量
宏定义,相当于字符替换常量声明
预处理器处理编译器处理
无类型安全检查有类型安全检查
不分配内存要分配内存
存储在代码段存储在数据段
可通过 #undef 取消不可取消

1.2 this 指针

  • this 指针被隐含地声明为: ClassName *const this,即不能给 this 指针赋值;
  • 在 ClassName 类的 const 成员函数中,this 指针的类型为:const ClassName* const,这说明不能对 this 指针所指向的这种对象是不可修改的(即不能对这种对象的数据成员进行赋值操作);
  • this 并不是一个常规变量,而是个右值,所以不能取得 this 的地址(不能 &this)。

1.3 static

  • 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,main 运行前就分配内存。
  • 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。
  • 修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员。
  • 修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在 static 函数内不能访问非静态成员。

1.4 inline

inline 是一个用于函数优化的关键字,核心作用是建议编译器将函数调用直接替换为函数体代码,从而避免函数调用带来的开销

优点:

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

缺点:

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

PS - 虚函数可以是内敛函数吗? 可以,但是当虚函数表现出多态性时不能内联(多态在运行期,编译器无法知道调用哪个代码)。

1.5 volatile

  • volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素(操作系统、硬件、其它线程等)更改。所以使用 volatile 告诉编译器不应对这样的对象进行优化
  • volatile 关键字声明的变量,每次访问时都必须从内存中取出值(没有被 volatile 修饰的变量,可能由于编译器的优化,从 CPU 寄存器中取值)

✨1.6 new / malloc

newmalloc 都是用于动态分配内存的工具,从堆(heap)中分配一块指定大小的内存空间,并返回指向该内存的指针。但有一些差异,具体差异如下表所示。

特性mallocnew
语法形式malloc(字节数),返回 void*new 类型(初始化值),返回对应类型指针
类型检查无类型安全检查,需手动强转指针类型自动匹配类型,返回对应类型指针(类型安全)
初始化只分配内存,不初始化(内存内容不确定)可以直接初始化(如 new int(10) 初始化为 10)
对象构造仅分配内存,不调用构造函数分配内存后,自动调用对象的构造函数
内存不足处理返回 NULL 指针抛出 std::bad_alloc 异常
释放方式需用 free(指针) 释放需用 delete(单个对象)或 delete[](数组)释放
数组分配需手动计算总字节数(sizeof(类型)*n)直接支持数组(new 类型[n])

1.7 完整类型 / 非完整类型

  • 完整类型:当编译器知道一个类型的大小、布局(成员变量的排列方式)和所有成员定义时,该类型被称为 “完整类型”。
  • 非完整类型:编译器只知道类型的名称,但不知道其大小、布局或成员定义(例如,前向声明——仅声明类型名称,未定义其内容,数组类型但未指定大小等)。

2. 面向对象

2.1 explicit 关键字

explicit 用于修饰单参数构造函数(或只有第一个参数无默认值,其余参数有默认值的构造函数),作用是禁止编译器进行隐式类型转换和复制初始化,只能通过显式方式调用构造函数。

例如,对于 MyString 类的构造函数 MyString(const char* cstr) : str(cstr) {} 而言,MyString s1 = "hello"; 会进行隐式地转换,const char* → MyString,可能导致逻辑模糊或错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

class MyString {
private:
    std::string str;
public:
    explicit MyString(const char* cstr) : str(cstr) {}

    void print() const {
        std::cout << str << std::endl;
    }
};

void func(MyString s) {
    s.print();
}

int main() {
    // MyString s1 = "hello";  // 编译报错:禁止隐式转换
    // MyString s1 = {"hello"} // 编译报错:禁止复制初始化
    MyString s1("hello");

    // func("world");          // 编译报错:禁止隐式转换
    func(MyString("world"));
    return 0;
}

2.2 friend 友元关键字

friend 机制允许指定的类或函数访问另一个类的私有(private)和保护(protected)成员,打破了类的封装性,用于解决特定场景下的访问权限问题。

  • 友元关系不可传递
  • 友元关系是单向的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>

class Rectangle;  // 前置声明:告诉编译器有一个Rectangle类

class Square {
private:
    double side;
public:
    Square(double s) : side(s) {}

    friend class Rectangle;                         // 友元类
    friend double calculateArea(const Square& s);   // 友元函数
};

double calculateArea(const Square& s) {
    return s.side * s.side;   // 直接访问 Square 的私有成员 side
}

class Rectangle {
private:
    double length;
    double width;
public:
    Rectangle(double l, double w) : length(l), width(w) {}
    double compareArea(const Square& s) {
        double squareArea = s.side * s.side;  // 直接访问Square的私有成员side
        double rectArea = length * width;
        return rectArea - squareArea;
    }
};

int main() {
    Square s(4.0);
    Rectangle r(5.0, 3.0);

    std::cout << r.compareArea(s) << std::endl;
    return 0;
}

2.3 动态多态

动态多态本质是父类指针指向子类引用。

  • 虚函数:用 virtual 修饰成员函数,使其成为虚函数
  • 动态绑定:当使用基类的引用或指针调用一个虚函数时将发生动态绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <string>

class Animal {
protected:
    std::string name;
public:
    Animal(std::string n) : name(n) {}
    virtual void makeSound() const {
        std::cout << name << " makes an unknown sound" << std::endl;
    }
    virtual ~Animal() {}
};

class Dog : public Animal {
public:
    Dog(std::string n) : Animal(n) {}
    void makeSound() const override {
        std::cout << name << " barks: Woof! Woof!" << std::endl;
    }
};

class Cat : public Animal {
public:
    Cat(std::string n) : Animal(n) {}
    void makeSound() const override {
        std::cout << name << " meows: Meow! Meow!" << std::endl;
    }
};

void letAnimalSpeak(const Animal& animal) {
    animal.makeSound();
}

int main() {
    Dog dog("Buddy");
    Cat cat("Luna");

    letAnimalSpeak(dog);
    letAnimalSpeak(cat);

    Animal* animalPtr = &dog;
    animalPtr->makeSound();

    animalPtr = &cat;
    animalPtr->makeSound();

    return 0;
}

2.4 纯虚函数

纯虚函数作用在于定义函数接口,派生类根据自身特性提供具体实现。

1
2
3
4
5
6
class Abstract {
public:
    virtual void f() = 0; // 纯虚函数 → Abstract 是抽象基类
};

Abstract a; // 编译报错:无法实例化抽象基类

2.5 析构函数抛出异常的后果

当析构函数在栈展开(stack unwinding)过程中(即处理另一个异常的过程中)抛出新的未捕获异常时,编译器会立即调用 std::terminate() 函数

直接崩溃的原因:当一个异常被抛出后,程序会从异常抛出点向上回溯,销毁沿途所有已构造的局部对象(调用它们的析构函数),直到找到匹配的 catch 块。若在这个 “销毁对象” 的过程中,某个析构函数又抛出新异常,此时没有机制能够处理新异常,强制用 std::terminate() 终止程序。

3. C++ 特性

✨3.1 左值 / 右值 / 完美转发

引入左值 / 右值 / 完美转发概念,实质是便于编译器解决“拷贝语义”的问题。 左值拷贝 const T& arg 能解决大部分场景重复拷贝的问题,但如果出了作用域,就不能使用左值引用返回。例如,如果下面代码中,将执行 2 次拷贝(temp 返回时拷贝一次,赋值给 v 时再拷贝一次)。

1
2
3
4
5
6
7
8
std::vector<int> create_vector() {
    std::vector<int> temp(10000);
    return temp; // 传统C++:返回时拷贝temp到临时对象
}

int main() {
    std::vector<int> v = create_vector(); // 再将临时对象拷贝到v
}

右值目的是触发“移动”而非拷贝,从而节省资源。

1
2
3
4
5
6
7
8
9
10
vector(vector&& other) noexcept {
    this->data = other.data;    // 窃取指针,直接复用other的内存资源,移动而非拷贝
    this->size = other.size;
    this->capacity = other.capacity;
    
    // 将源对象置空,避免析构时重复释放内存
    other.data = nullptr;
    other.size = 0;
    other.capacity = 0;
}
  • 左值:可以取地址的表达式。通常表示一个占据内存中可识别位置的对象(有持久性)。
  • 右值:不能取地址的表达式。只能出现在赋值运算符的右侧(包括字面常量(如 10、”hello”)、临时对象、返回非引用类型的函数调用等)。
    • 左值引用可以指向右值,但需要const来修饰,不能修改这个值
    • 右值引用可以指向左值,需要 std::move(v),不能直接引用左值 int&& rrt = t; // 编译会报错
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 左值
int a = 3;
int* p = &a;

// 左值引用:给左值取别名,主要作用是避免对象拷贝。
int& ra = a;
int*& rp = p;

// 右值
double x = 1.3, y = 2.4;
x + y;
fmin(x, y);

// 右值引用:主要作用是把延长对象的生命周期,在具体的变量类型名称后加两个 &
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
// 右值引用变量实质为左值,取地址为变量的地址。

右值引用具体使用场景:

1
2
3
4
5
6
7
8
QList<Pin*> getModelPins() const
{
    QList<Pin*> pins;
    for (auto& pin : m_lstPins) {
        pins << pin.data();
    }
    return std::move(pins); // 会强制出发移动拷贝构造
}
  • 完美转发:用于解决模板转发中,右值引用当作左值处理的情况(本质也是左值)。即:只要是右值引用,由当前函数再传递给其它函数调用,要保持右值属性,必须实现完美转发。
  • 万能引用:模板中的 T&& 不表示右值引用,而是万能引用,模板类型必须通过推断才能确定。(但模板类中,实例化时类型已知,T&&实际是右值引用)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void Func(int& x) {    cout << "左值引用" << endl; }

void Func(const int& x) { cout << "const左值引用" << endl; }

void Func(int&& x) { cout << "右值引用" << endl; }

void Func(const int&& x) { cout << "const右值引用" << endl; }

template<typename T>
void f(T&& t)  // 万能引用
{
    Func(t);  // 根据参数t的类型去匹配合适的重载函数
}

int main()
{
    int a = 4;  // 左值
    f(a);
    
    const int b = 8;  // const左值
    f(b);
    
    f(10); // 10是右值
    
    const int c = 13;
    f(std::move(c));  // const左值被move后变成const右值
    
    return 0;
}

问题:我们预期的结果是:(1)左值引用;(2)const左值引用;(3)右值引用;(4)const右值引用。实际运行,(4)会是 const 左值引用,原因是右值引用变量其实是左值,右值引用失去了右值属性。 所以,C++11提出完美转发,在传递过程中能够保持住参数的左值或右值属性,如下述代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <utility>

// ...

template<typename T>
void PerfectForward(T&& t)
{
    Func(std::forward<T>(t));  // 根据参数t的类型去匹配合适的重载函数
}

// ...

Q3-1-1: 使用模板和命名空间实现 std::move

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

namespace my {
    // 移除引用的辅助模板
    template <typename T>
    struct remove_reference {
        using type = T;
    };

    template <typename T>
    struct remove_reference<T&> {
        using type = T;
    };

    template <typename T>
    struct remove_reference<T&&> {
        using type = T;
    };

    // 实现 move
    template <typename T>
    constexpr typename remove_reference<T>::type&& move(T&& arg) noexcept {
        // static_cast 到右值引用
        return static_cast<typename remove_reference<T>::type&&>(arg);
    }
}

✨3.2 智能指针

  • unique_ptr:独占所有权的智能指针,同一时间只能有一个 unique_ptr 指向对象
1
2
3
4
5
6
7
8
#include <memory>

std::unique_ptr<XClass> uptr(new XClass(1));
// 通过 move 转移控制权,uptr 会被释放,变成 nullptr
std::unique_ptr<XClass> uptr_2 = std::move(ptr1);

// C++ 14 引入,避免了手动 new 造成的安全隐患( new 完抛出异常,unique_ptr 不会接管指针生命周期 )
auto uptr3 = std::make_unique<XClass>(3);
  • shared_ptr: 多个智能指针指向相同对象,该对象和其相关资源会在 “最后一个 reference 被销毁” 时被释放。
  • weak_ptr:配合 shared_ptr 使用,不增加引用计数,用于解决循环引用问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::shared_ptr<XClass> sharedPtr = std::make_shared<XClass>(20);
// 通过 .use_count() 获取引用计数
std::cout << sharedPtr.use_count() << std::endl;

// weak_ptr 不增加引用计数,通过 lock() 获取 shared_ptr 来访问对象
// 如果 weak_ptr 指向的对象是否还存在,则返回一个新的 shared_ptr(引用计数 +1),超出作用域自动销毁(引用计数 -1)
// 不存在则返回 nullptr
std::weak_ptr<XClass> weakPtr = sharedPtr;
if (auto tempShared = weakPtr.lock()) {
    tempShared->doSomething();
    std::cout << tempShared.use_count() << std::endl;
} else {
    std::cout << "对象已被销毁" << std::endl;
}

// 释放 shared_ptr
sharedPtr.reset();

Q3-2-1: make_unique 的实现原理?

std::make_unique 是 C++14 引入的一个工厂函数,用来安全地创建 std::unique_ptr。本质上就是 调用 new 分配对象,然后把返回的裸指针交给 unique_ptr 接管,从而避免手写 unique_ptr(new T(...)) 时的潜在问题。

1
2
3
4
5
// 创建单个对象
template <class T, class... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

3.3 RAII

RAII(Resource Acquisition Is Initialization,资源获取即初始化),将资源的生命周期与对象的生命周期绑定。

  • 当创建对象时(初始化阶段),获取资源(如通过 new 分配内存、打开文件)。
  • 当对象超出作用域被销毁时(析构阶段),自动释放资源(如 delete 内存、关闭文件)。
  • 利用 C++ 自动调用析构函数的特性,确保资源必然被释放,避免泄漏。

典型应用:(1)智能指针(unique_ptr、shared_ptr)管理动态内存;(2)标准库中的容器(vector、string)自动管理内存;(3)锁对象(std::lock_guard)管理互斥量的加锁与解锁。

3.4 RTTI

RTTI(Run-Time Type Information,运行时类型信息),允许程序在运行时判断对象的实际类型(即使通过基类指针 / 引用访问)。RTTI 仅对多态类型(包含虚函数的类)有效。

主要功能:

  • dynamic_cast:用于多态类型的安全转换,在运行时检查类型兼容性。
  • typeid:获取对象的类型信息(返回 std::type_info 对象)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <typeinfo>

class Base {
public:
    virtual void func() {} // 基类必须有虚函数才能启用 RTTI
};

class Derived : public Base {
public:
    void func() override {}
};

int main() {
    Base* basePtr = new Derived(); // 多态场景:基类指针指向派生类对象
    
    // 1. 使用 dynamic_cast 安全转换
    Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
    
    // 2. 使用 typeid 获取类型信息
    std::cout << "basePtr 实际类型:" << typeid(*basePtr).name() << std::endl;
    std::cout << "Derived 类型:" << typeid(Derived).name() << std::endl;

    delete basePtr;
    return 0;
}

4. C++多线程与并发类

4.1 进程通信方式

  • 消息队列(Message Queue):存放在内核中并由消息队列标识符标识,通过系统调用函数来实现消息发送和接收之间的同步,无需考虑同步问题,但是信息的复制需要额外开销,不适用于信息量大或操作频繁的场景
  • 共享内存(Shared Memory):映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。
  • 套接字(Socket):可用于不同计算机间的进程通信
  • 管道(Pipeline)

4.2 线程通信方式

锁机制:包括互斥锁(std::mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(std::condition_variable)

  • 互斥锁
  • 读写锁:允许多个线程同时读共享数据,而对写操作是互斥的。
  • 自旋锁:当资源被占用,使用互斥锁的情景,申请者会进入睡眠状态;而自旋锁则循环检测保持者是否已经释放锁。
  • 条件变量:以原子的方式阻塞进程,直到某个特定条件为真为止。条件变量通常与互斥锁一起使用,用于线程间的等待和通知。

信号量机制(Semaphore):用于控制资源访问或同步

  1. 无名线程信号量(同一进程内线程共享)
  2. 命名线程信号量(可跨进程共享)

信号机制(Signal):一种异步通知机制,用于线程间传递 “事件发生” 的消息(如中断、错误通知),类似进程间的 SIGINT 等信号,但作用于线程。

屏障(barrier):屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。

4.3 如何避免 C++ 多线程编程中的死锁问题

死锁是指两个或多个线程在争夺资源时,造成的一种相互等待的僵局。每个线程都持有一些资源,同时等待其他线程持有的资源释放,而没有任何线程能够继续执行,导致程序完全停止响应。

避免死锁的常用策略包括:

  • 保持锁的顺序一致:确保所有线程获取多个互斥锁的顺序相同
  • 尽量避免嵌套锁:尽量减少在持有一个锁的同时请求另一个锁的情况
  • 使用锁超时:尝试锁定操作时使用超时方式(例如std::try_lock_for或std::try_lock_until),如果不能在给定时间内获取锁,则放弃操作
  • 使用死锁检测算法:在程序中检测和处理死锁
  • 层次化锁:为锁分配层次,低层次的锁可以获取高层次的锁,但不反其道。

4.4 如何使用 Mutex 互斥锁

互斥锁通常与std::lock_guard或std::unique_lock结合使用,这些类提供了RAII(资源获取即初始化)模式的封装,确保互斥锁会在离开作用域时自动解锁,从而避免死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

int sharedCounter = 0;
std::mutex mtx; // 互斥锁

// 对共享数据的访问操作
void incrementCounter() {
    std::lock_guard<std::mutex> lock(mtx); // 使用互斥锁保护代码块
    ++sharedCounter;
    std::cout << "Counter: " << sharedCounter << ", by thread: " << std::this_thread::get_id() << std::endl;
}

int main() {
    std::vector<std::thread> threads;
    for (size_t i = 0; i < 10; ++i) {
        threads.push_back(std::thread(incrementCounter));
    }

    // 等待所有线程完成
    for (auto& t : threads) {
        t.join();
    }

    return 0;
}

5. 设计模式

5.1 单例模式

单例模式是一种确保某个类只有一个实例,并提供全局访问点的设计模式。基础单例模式的实现如下所示,在单线程环境下工作正常,但在多线程环境中可能会创建多个实例,因为多个线程可能同时通过if (instance == nullptr)的检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Singleton {
private:
    Singleton() {} // 私有构造函数,防止外部实例化

    // 防止复制
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    
    static Singleton* instance;  // 私有静态实例指针

public:
    // 公有静态方法,提供全局访问点
    static Singleton* getInstance() {
        if (instance == nullptr) {
            instance = new Singleton();
        }
        return instance;
    }
    
    static void destroyInstance() {
        if (instance != nullptr) {
            delete instance;
            instance = nullptr;
        }
    }
};

线程安全的单例模式,通过上锁+双重检查实现线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class ThreadSafeSingleton {
private:
    ThreadSafeSingleton() {}
    
    // 禁止复制
    ThreadSafeSingleton(const ThreadSafeSingleton&) = delete;
    ThreadSafeSingleton& operator=(const ThreadSafeSingleton&) = delete;
    
    // 静态实例和互斥锁
    static ThreadSafeSingleton* instance;
    static std::mutex mtx;

public:
    // 线程安全的获取实例方法
    static ThreadSafeSingleton* getInstance() {
        // 双重检查锁定(Double-Checked Locking)
        if (instance == nullptr) {
            std::lock_guard<std::mutex> lock(mtx);  // 加锁
            if (instance == nullptr) {
                instance = new ThreadSafeSingleton();
            }
        }
        return instance;
    }

    static void destroyInstance() {
        std::lock_guard<std::mutex> lock(mtx);
        if (instance != nullptr) {
            delete instance;
            instance = nullptr;
        }
    }
};
This post is licensed under CC BY 4.0 by the author.

Trending Tags