C++ 面经
1. C/C++ 语言特性
1.1 const
常量修饰符,能够作用于:(1)变量;(2)指针;(3)引用,用于形参类型,即避免了拷贝,又避免了函数对值的修改;(4)成员函数,说明该成员函数内不能修改任何成员变量(常对象只能调用常函数)。
1
2
3
// const 修饰右侧紧邻的符号
const int* x; // 修饰int,指向的内容不能改变
int* const x; // 修饰 x 指针,指向地址不允许改变
#define 和 const 的区别:
| 宏定义 #define | const 常量 |
|---|---|
| 宏定义,相当于字符替换 | 常量声明 |
| 预处理器处理 | 编译器处理 |
| 无类型安全检查 | 有类型安全检查 |
| 不分配内存 | 要分配内存 |
| 存储在代码段 | 存储在数据段 |
| 可通过 #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 是一个用于函数优化的关键字,核心作用是建议编译器将函数调用直接替换为函数体代码,从而避免函数调用带来的开销。
优点:
- 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
- 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
- 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
缺点:
- 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
- inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
- 不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。
PS - 虚函数可以是内敛函数吗? 可以,但是当虚函数表现出多态性时不能内联(多态在运行期,编译器无法知道调用哪个代码)。
1.5 volatile
- volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素(操作系统、硬件、其它线程等)更改。所以使用 volatile 告诉编译器不应对这样的对象进行优化。
- volatile 关键字声明的变量,每次访问时都必须从内存中取出值(没有被 volatile 修饰的变量,可能由于编译器的优化,从 CPU 寄存器中取值)
✨1.6 new / malloc
new 和 malloc 都是用于动态分配内存的工具,从堆(heap)中分配一块指定大小的内存空间,并返回指向该内存的指针。但有一些差异,具体差异如下表所示。
| 特性 | malloc | new |
|---|---|---|
| 语法形式 | 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):用于控制资源访问或同步
- 无名线程信号量(同一进程内线程共享)
- 命名线程信号量(可跨进程共享)
信号机制(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;
}
}
};