📄 模拟试卷一:C++核心与系统基础(90分钟)
一、单选题(每题2分,共20分)
1.在C++11中,关于auto关键字,错误的是:
A) auto x = 5;推导x为int
B) auto& y = x;推导y为int&
C) const auto z = x;推导z为const int
D) auto* p = &x;推导p为int*,与auto p = &x;不同
cpp
> 分析选项 A:
在 C++11 中,auto关键字用于自动类型推导。当执行auto x = 5;时,5是一个int类型的字面量,auto会根据初始化值的类型推导出x的类型为int。所以选项 A 正确。
分析选项 B:
当auto& y = x;时,x是int类型,auto&会推导出y为int&,即y是x的引用。这是因为&符号在auto类型推导中表示引用类型。所以选项 B 正确。
分析选项 C:
对于const auto z = x;,x是int类型,const auto会推导出z为const int,表示z是一个常量整数,其值不能被修改。所以选项 C 正确。
分析选项 D:
在auto* p = &x;中,&x是int类型变量x的地址,类型为int*,auto*会推导出p为int*。
而在auto p = &x;中,同样,&x的类型为int*,auto也会推导出p为int*。这两种写法在这种情况下推导的结果是相同的。所以选项 D 错误。
2.以下代码的输出是?
#include
using namespace std;
class A {
public:
A() { cout << "A"; }
~A() { cout << "~A"; }
};
class B : public A {
public:
B() { cout << "B"; }
~B() { cout << "~B"; }
};
int main() {
B b;
return 0;
}
cpp
> 代码分析:
这段 C++ 代码定义了两个类,A 是基类,B 是派生类,B 继承自 A。
在 main 函数中创建了一个 B 类的对象 b。
对象创建过程:
当创建 B 类的对象 b 时,会先调用基类 A 的构造函数,因为派生类对象的构造过程是先构造基类部分,再构造派生类自身部分。
基类 A 的构造函数 A() 输出 A。
然后调用派生类 B 的构造函数 B(),输出 B。
对象销毁过程:
当 main 函数结束时,对象 b 超出作用域,开始销毁。
销毁对象时,先调用派生类 B 的析构函数 ~B(),输出 ~B。
然后调用基类 A 的析构函数 ~A(),输出 ~A。
最终输出:
所以这段代码的输出是 AB~B~A。
3.关于智能指针,正确的是:
A) std::unique_ptr可以通过拷贝构造函数传递
B) std::shared_ptr的引用计数是线程安全的
C) std::weak_ptr会增加引用计数
D) 使用std::make_shared比直接new效率低
cpp
> 分析选项 A:
std::unique_ptr 不支持拷贝构造函数。它的设计目的是对资源进行独占式管理,即一个 std::unique_ptr 实例拥有对资源的唯一所有权。如果支持拷贝构造,就会违背独占式管理的原则。不过,std::unique_ptr 支持移动构造函数。所以选项 A 错误。
分析选项 B:
std::shared_ptr 的引用计数是线程安全的。多个线程可以同时对 std::shared_ptr 进行操作(如创建、拷贝、赋值、销毁等),其内部的引用计数机制能够正确处理多线程环境下的并发操作,确保引用计数的一致性。所以选项 B 正确。
分析选项 C:
std::weak_ptr 不会增加引用计数。std::weak_ptr 是为了配合 std::shared_ptr 解决循环引用问题而引入的。它指向由 std::shared_ptr 管理的对象,但不会影响对象的引用计数。所以选项 C 错误。
分析选项 D:
使用 std::make_shared 通常比直接使用 new 效率更高。std::make_shared 会一次性分配一个对象和一个控制块,而直接使用 new 然后用 std::shared_ptr 封装可能会导致两次内存分配(一次用于对象,一次用于控制块)。此外,std::make_shared 还可以减少内存碎片,提高内存管理效率。所以选项 D 错误。
4.以下STL容器的迭代器,随机访问效率最高的是:
A) std::list
B) std::vector
C) std::map
D) std::set
cpp
> 分析选项 A(std::list):
std::list 是双向链表结构。其迭代器只能顺序访问元素,在链表中要访问任意位置的元素,需要从链表头或尾开始逐个遍历节点,时间复杂度为
O(n)
,随机访问效率很低。例如,要访问链表中间位置的元素,需要从头开始移动迭代器,直到到达目标位置。
分析选项 B(std::vector):
std::vector 是动态数组结构。它的迭代器支持随机访问,因为元素在内存中是连续存储的。可以通过迭代器加上偏移量直接访问任意位置的元素,时间复杂度为
O(1)
。例如,vector<int> vec = {1, 2, 3, 4, 5};,如果有迭代器 it = vec.begin(),要访问第 3 个元素,可以通过 it + 2 直接定位到目标元素,这是非常高效的随机访问方式。
分析选项 C(std::map):
std::map 通常基于红黑树实现。红黑树是一种平衡二叉搜索树,其迭代器只能按照键的顺序遍历元素。要访问特定位置的元素,需要从根节点开始按照树的结构进行查找,时间复杂度为
O(logn)
,虽然性能不错,但相较于 std::vector 的
O(1)
随机访问效率还是低一些。
分析选项 D(std::set):
std::set 同样通常基于红黑树实现。它的迭代器也是按元素顺序遍历,随机访问特定位置元素的时间复杂度也是
O(logn)
,因为需要通过树的查找操作来定位元素,效率不如 std::vector。
综上,随机访问效率最高的是 std::vector,答案选 B。
5.关于多线程同步,错误的是:
A) std::mutex可以用std::lock_guard自动管理
B) std::condition_variable::wait会自动释放锁
C) std::atomic的++操作是线程安全的
D) 读写锁可以用std::shared_mutex实现
cpp
> 分析选项 A:
std::lock_guard 是 C++ 标准库中用于自动管理锁的类模板。它在构造时锁定关联的 std::mutex,在析构时自动解锁。这种机制保证了即使在代码块中发生异常,锁也能被正确释放,避免死锁。例如:
#include <iostream>
#include <mutex>
std::mutex mtx;
void print() {
std::lock_guard<std::mutex> lock(mtx);
std::cout << "This is a thread - safe print." << std::endl;
}// 这里lock离开作用域,自动调用析构函数解锁mtx
所以选项 A 正确。
分析选项 B:
std::condition_variable::wait 函数会自动释放它所关联的锁(通常是通过 std::unique_lock<std::mutex> 提供的锁),并阻塞当前线程,直到收到通知(notify_one 或 notify_all)。当收到通知后,wait 函数会重新获取锁,然后继续执行。例如:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void print_id(int id) {
std::unique_lock<std::mutex> lock(mtx);
while (!ready) cv.wait(lock);
std::cout << "thread " << id << '\n';
}
void go() {
std::unique_lock<std::mutex> lock(mtx);
ready = true;
cv.notify_all();
}
所以选项 B 正确。
分析选项 C:
std::atomic<int> 类型的变量提供了原子操作,其 ++ 操作是线程安全的。原子操作保证了在多线程环境下,对该变量的操作是不可分割的,不会出现数据竞争问题。例如,多个线程同时对 std::atomic<int> 变量进行 ++ 操作,不会导致结果错误。所以选项 C 正确。
分析选项 D:
在 C++17 中,读写锁可以用 std::shared_mutex 实现。std::shared_mutex 允许多个线程同时进行读操作(共享锁),但只允许一个线程进行写操作(独占锁),从而实现读写分离,提高并发性能。例如:
#include <iostream>
#include <thread>
#include <shared_mutex>
std::shared_mutex mtx;
void read() {
mtx.lock_shared();
std::cout << "Reading...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
mtx.unlock_shared();
}
void write() {
mtx.lock();
std::cout << "Writing...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
mtx.unlock();
}
所以选项 D 正确。
6.以下代码的输出是?
int main() {
int a = 5;
int& ref = a;
int* ptr = &a;
cout << sizeof(ref) << " " << sizeof(ptr);
return 0;
}
cpp
> 分析 sizeof(ref):
ref 是 int 类型变量 a 的引用。在 C++ 中,引用本质上是变量的别名,它和所引用的变量共享同一块内存空间。所以 sizeof(ref) 等同于 sizeof(a),因为 a 是 int 类型,在常见的 32 位和 64 位系统中,int 类型通常占用 4 个字节(在某些古老系统或特定编译器设置下可能不同,但常见情况如此),所以 sizeof(ref) 的值为 4。
分析 sizeof(ptr):
ptr 是一个指向 int 类型变量 a 的指针。指针用于存储内存地址,在 32 位系统中,指针大小通常为 4 个字节,因为 32 位系统的地址空间是
2 的32次方 ,需要 4 个字节来表示一个内存地址;在 64 位系统中,指针大小通常为 8 个字节,因为 64 位系统的地址空间是 2 的64次方
,需要 8 个字节来表示一个内存地址。所以 sizeof(ptr) 的值在 32 位系统中为 4,在 64 位系统中为 8。综合输出:
假设在 64 位系统环境下,这段代码的输出是 4 8。在 32 位系统环境下,输出则是 4 4。
7.关于虚函数,正确的是:
A) 构造函数中调用虚函数,会触发多态
B) 静态成员函数可以是虚函数
C) 虚函数表指针vptr在对象构造时初始化
D) 纯虚函数必须有函数体
cpp
分析选项 A:
在构造函数中调用虚函数不会触发多态。当在构造函数中调用虚函数时,对象的虚函数表指针(vptr)还没有完全初始化,此时调用虚函数,实际调用的是当前类的版本,而不是派生类中重写的版本。例如:
cpp
class Base {
public:
Base() {
virtualFunction();
}
virtual void virtualFunction() {
std::cout << "Base::virtualFunction" << std::endl;
}
};
class Derived : public Base {
public:
Derived() : Base() {}
void virtualFunction() override {
std::cout << "Derived::virtualFunction" << std::endl;
}
};
int main() {
Derived d;
return 0;
}
上述代码中,Derived 对象构造时,先调用 Base 的构造函数,在 Base 构造函数中调用 virtualFunction,输出的是 Base::virtualFunction,而不是 Derived::virtualFunction。所以选项 A 错误。
分析选项 B:
静态成员函数不能是虚函数。静态成员函数属于类,而不属于类的对象,它不依赖于对象的状态,没有 this 指针。虚函数的调用依赖于对象的虚函数表指针(vptr),而静态成员函数没有与之相关的 vptr 概念。所以选项 B 错误。
分析选项 C:
虚函数表指针(vptr)在对象构造时初始化。当创建一个对象时,编译器会为该对象分配内存,并在对象的内存布局中初始化 vptr,使其指向该类的虚函数表。不同类的虚函数表不同,派生类的虚函数表会根据基类虚函数表以及自身重写的虚函数进行构建。所以选项 C 正确。
分析选项 D:
纯虚函数不需要有函数体。纯虚函数是一种特殊的虚函数,它在基类中声明,没有函数实现,迫使派生类必须重写该函数。例如:
cpp
class AbstractBase {
public:
virtual void pureVirtualFunction() = 0;
};
这里 pureVirtualFunction 就是纯虚函数,没有函数体。所以选项 D 错误。
8.内存对齐题目:64位系统中sizeof(MyStruct)的值是?
#pragma pack(4)
struct MyStruct {
char c;
double d;
int i;
short s;
};
9.关于异常处理,错误的是:
A) throw可以抛出任意类型的对象
B) catch(...)可以捕获所有异常
C) 析构函数不应该抛出异常
D) noexcept表示函数可能抛出异常
cpp
分析选项 A:
在 C++ 中,throw 确实可以抛出任意类型的对象。这使得开发者能够根据实际需求定义和抛出特定类型的异常对象,以便在捕获异常时进行针对性处理。例如,可以抛出内置类型(如 int、double)、自定义结构体、类对象等。所以选项 A 正确。
分析选项 B:
catch(...) 被称为通用异常捕获块,它能够捕获任何类型的异常。当程序执行过程中抛出异常,而前面的 catch 块都无法匹配异常类型时,catch(...) 会捕获该异常。所以选项 B 正确。
分析选项 C:
析构函数通常不应该抛出异常。原因在于,如果析构函数抛出异常,并且该对象在栈上被销毁(例如函数结束时局部对象被销毁),可能会导致栈展开过程中出现未定义行为。此外,如果在 try - catch 块中捕获异常并处理时,又在析构函数中抛出新的异常,可能会导致程序终止。所以选项 C 正确。
分析选项 D:
noexcept 关键字表示函数不会抛出异常。当函数声明中带有 noexcept 说明符时,编译器会进行优化,假设该函数不会抛出异常,从而避免一些不必要的异常处理代码生成。如果在 noexcept 函数中抛出异常,std::terminate 函数将被调用,程序通常会异常终止。所以选项 D 错误。
10.TCP四次挥手过程中,客户端收到FIN后进入的状态是:
A) TIME_WAIT
B) CLOSE_WAIT
C) LAST_ACK
D) FIN_WAIT_2
cpp
TCP 四次挥手过程概述:
在 TCP 连接释放过程中,四次挥手涉及到客户端和服务器端状态的多次变化。
当服务器端向客户端发送 FIN 报文段,表示服务器端没有数据要发送了,请求关闭连接。
客户端收到 FIN 后的状态变化:
客户端收到 FIN 后,会进入 CLOSE_WAIT 状态。此时,客户端到服务器端的连接已经关闭,但服务器端到客户端的连接还未完全关闭,客户端还可以继续接收服务器端的数据(如果有残留数据)。同时,客户端需要等待应用层指示关闭连接。所以选项 B 正确。
其他状态解释:
TIME_WAIT:客户端在发送最后一个 ACK 给服务器端后,会进入 TIME_WAIT 状态。这个状态的存在是为了确保最后一个 ACK 能被服务器端收到,并且在这段时间内可以处理可能重传的 FIN 报文段。所以选项 A 错误。
LAST_ACK:这是服务器端在收到客户端的 ACK 后,进入的状态。服务器端等待最后一个 ACK 确认,如果在超时时间内未收到,会重发 FIN 报文段。所以选项 C 错误。
FIN_WAIT_2:客户端发送 FIN 给服务器端,等待服务器端的 ACK 时进入的状态。所以选项 D 错误。
综上,答案是 B,客户端收到 FIN 后进入的状态是 CLOSE_WAIT。
补充
cpp
TCP 连接过程:三次握手与四次挥手
三次握手(建立连接)
第一次握手(客户端 -> 服务器)
客户端动作:客户端想要与服务器建立连接,会向服务器发送一个带有 SYN(同步序列号,Synchronize Sequence Numbers)标志位的 TCP 报文段。
报文段内容:该报文段包含一个随机生成的初始序列号(Sequence Number,seq),假设为
x
。这个序列号用于标识该连接中传输的字节流的起始位置,后续数据的序列号将基于此递增。
目的:客户端借此告知服务器自己想要建立连接,并给出了起始序列号,等待服务器的响应。
第二次握手(服务器 -> 客户端)
服务器动作:服务器接收到客户端的 SYN 报文段后,会返回一个 SYN + ACK 报文段。
报文段内容:
确认号(Acknowledgment Number,ack):服务器将客户端的初始序列号
x
加 1,即
ack=x+1
,以此作为确认号发送回客户端。这表明服务器已成功收到客户端的 SYN 报文段,并期望客户端接下来发送序列号为
x+1
的数据。
服务器初始序列号:服务器也会随机生成一个初始序列号,设为
y
,并放入 SYN + ACK 报文段中发送给客户端。
目的:服务器通过此报文段,一方面确认了客户端的连接请求,另一方面向客户端提供了自己的初始序列号,为后续的数据传输做准备。
第三次握手(客户端 -> 服务器)
客户端动作:客户端收到服务器的 SYN + ACK 报文段后,会向服务器发送一个 ACK 报文段。
报文段内容:
确认号(ack):客户端将服务器的初始序列号
y
加 1,即
ack=y+1
,表明客户端已收到服务器的 SYN 报文段,并期望服务器接下来发送序列号为
y+1
的数据。
序列号(seq):此时序列号为
seq=x+1
,这是客户端在第一次握手中发送的初始序列号
x
加 1,与服务器在第二次握手中期望接收的数据序列号一致。
目的:客户端通过这个 ACK 报文段确认了服务器的连接响应,至此,TCP 连接成功建立,双方可以开始进行数据传输。
四次挥手(关闭连接)
第一次挥手(客户端 -> 服务器)
客户端动作:当客户端完成数据传输任务,或需要主动关闭连接时,会向服务器发送一个带有 FIN(结束标志,Finish)标志位的 TCP 报文段。
报文段内容:包含客户端当前的序列号
seq=u
(假设)。这个 FIN 报文段表示客户端已经没有数据要发送给服务器了,但仍然可以接收服务器发送的数据。
目的:客户端告知服务器自己准备关闭连接。
第二次挥手(服务器 -> 客户端)
服务器动作:服务器接收到客户端的 FIN 报文段后,会返回一个 ACK 报文段作为确认。
报文段内容:
确认号(ack):服务器将客户端的序列号
u
加 1,即
ack=u+1
,表示服务器已收到客户端的 FIN 报文段。
序列号(seq):服务器当前的序列号,设为
v
。
目的:服务器确认收到客户端的关闭请求,但此时服务器可能还有数据未发送完,所以不会立即关闭连接,而是先回复 ACK 告知客户端已收到关闭请求。
第三次挥手(服务器 -> 客户端)
服务器动作:当服务器完成所有数据的发送后,会向客户端发送一个 FIN 报文段。
报文段内容:包含服务器当前的序列号
seq=w
(假设),同时确认号仍然是
ack=u+1
,因为之前客户端发送的 FIN 报文段序列号为
u
。
目的:服务器告知客户端自己也准备好关闭连接了。
第四次挥手(客户端 -> 服务器)
客户端动作:客户端收到服务器的 FIN 报文段后,会发送一个 ACK 报文段给服务器。
报文段内容:
确认号(ack):客户端将服务器的序列号
w
加 1,即
ack=w+1
,表示已收到服务器的 FIN 报文段。
序列号(seq):客户端的序列号
seq=u+1
,因为在第一次挥手时客户端发送的 FIN 报文段序列号为
u
。
目的:客户端确认收到服务器的关闭请求,随后客户端等待一段时间(通常是 2MSL,Maximum Segment Lifetime,即报文段最大生存时间)后关闭连接。这段等待时间是为了确保最后一个 ACK 报文能被服务器成功接收,如果服务器未收到,可能会重发 FIN 报文段,客户端可以再次响应 ACK。服务器收到 ACK 后,也会关闭连接。
通过三次握手建立连接和四次挥手关闭连接,TCP 协议为网络通信提供了可靠的连接机制,确保数据的准确传输和连接的有序管理。
二、多选题(每题3分,少选得1分,错选不得分,共15分)
1.以下哪些是C++11引入的特性?
A) Lambda表达式
B) 右值引用
C) 智能指针
D) 范围for循环
cpp
分析选项 A:
Lambda 表达式:C++11 引入了 Lambda 表达式,它提供了一种简洁的定义匿名函数对象的方式。Lambda 表达式可以在需要函数对象的地方直接定义,无需像传统方式那样先定义一个类并重载 () 运算符。例如:
cpp
#include <iostream>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
int sum = 0;
std::for_each(numbers.begin(), numbers.end(), [&sum](int num) {
sum += num;
});
std::cout << "Sum: " << sum << std::endl;
return 0;
}
这里的 [&sum](int num) { sum += num; } 就是一个 Lambda 表达式,它捕获了外部变量 sum 并对 numbers 中的每个元素进行累加操作。所以选项 A 正确。
分析选项 B:
右值引用:C++11 引入右值引用(&&),主要用于解决移动语义和完美转发问题。右值引用使得我们可以区分左值和右值,从而避免不必要的对象拷贝,提高程序性能。例如:
cpp
#include <iostream>
#include <string>
class MyString {
public:
MyString() : data(nullptr), size(0) {}
MyString(const char* str) : size(strlen(str)), data(new char[size + 1]) {
strcpy(data, str);
}
MyString(MyString&& other) noexcept : data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// 其他成员函数...
~MyString() { delete[] data; }
private:
char* data;
size_t size;
};
MyString getString() {
return MyString("Hello, C++11");
}
int main() {
MyString s1 = getString();
return 0;
}
在上述代码中,MyString(MyString&& other) 是一个移动构造函数,利用右值引用避免了对象拷贝。所以选项 B 正确。
分析选项 C:
智能指针:虽然 C++98 标准库中已经有 auto_ptr,但 C++11 对智能指针进行了重大改进,引入了 std::unique_ptr、std::shared_ptr 和 std::weak_ptr。std::unique_ptr 提供独占式资源管理,std::shared_ptr 通过引用计数实现共享式资源管理,std::weak_ptr 用于解决 std::shared_ptr 可能产生的循环引用问题。例如:
cpp
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
std::shared_ptr<int> ptr2 = ptr1;
std::cout << "Reference count: " << ptr1.use_count() << std::endl;
return 0;
}
这里使用 std::shared_ptr 来管理动态分配的 int 型对象,并通过 use_count 查看引用计数。所以选项 C 正确。
分析选项 D:
范围 for 循环:C++11 引入了范围 for 循环,它提供了一种更简洁的遍历容器或数组的方式。例如:
cpp
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (int num : numbers) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
for (int num : numbers) 就是范围 for 循环,它会依次遍历 numbers 容器中的每个元素并赋值给 num。所以选项 D 正确。
2.会导致内存泄漏的情况包括:
A) new后忘记delete
B) std::shared_ptr循环引用
C) 异常抛出跳过delete
D) 使用std::make_unique
cpp
分析选项 A:
在 C++ 中,使用 new 操作符动态分配内存后,如果忘记使用 delete 操作符释放内存,那么这块内存将无法再被程序访问和释放,从而导致内存泄漏。例如:
cpp
void memoryLeakExample() {
int* ptr = new int;
// 这里忘记delete ptr
}
每次调用 memoryLeakExample 函数,都会分配一块 int 大小的内存,但永远不会释放,随着函数调用次数增加,内存泄漏会越来越严重。所以选项 A 会导致内存泄漏。
分析选项 B:
std::shared_ptr 通过引用计数来管理对象的生命周期。当引用计数为 0 时,对象会被自动释放。然而,当出现循环引用时,两个或多个 std::shared_ptr 对象相互引用,导致它们的引用计数永远不会降为 0,对象也就无法被释放,从而造成内存泄漏。例如:
cpp
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> b;
~A() { std::cout << "~A" << std::endl; }
};
class B {
public:
std::shared_ptr<A> a;
~B() { std::cout << "~B" << std::endl; }
};
void circularReferenceExample() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b = b;
b->a = a;
}
在 circularReferenceExample 函数中,a 和 b 相互引用,函数结束时,a 和 b 的引用计数都不会变为 0,A 和 B 对象占用的内存无法释放。所以选项 B 会导致内存泄漏。
分析选项 C:
如果在动态分配内存后,在执行 delete 语句之前抛出了异常,并且没有适当的异常处理机制来释放内存,就会导致内存泄漏。例如:
cpp
void exceptionLeakExample() {
int* ptr = new int;
if (someCondition()) {
throw std::exception();
}
delete ptr; // 如果抛出异常,这行代码不会执行,导致内存泄漏
}
所以选项 C 会导致内存泄漏。
分析选项 D:
std::make_unique 是 C++14 引入的函数,用于创建 std::unique_ptr 对象。std::unique_ptr 采用独占式资源管理,当 std::unique_ptr 对象离开其作用域时,会自动调用 delete 释放其所管理的资源,不会导致内存泄漏。例如:
cpp
void makeUniqueExample() {
auto ptr = std::make_unique<int>(42);
// ptr离开作用域时,会自动释放分配的内存
}
所以选项 D 不会导致内存泄漏。
综上,会导致内存泄漏的情况是选项 A、B、C。
3.关于STL容器特性,正确的有:
A) vector在尾部插入是O(1)摊销时间
B) list在任意位置插入是O(1)
C) map的查找是O(log n)
D) unordered_map的插入是O(1)最坏情况
cpp
分析选项 A:
vector尾部插入:vector 是动态数组,在尾部插入元素时,如果当前容量足够,直接在尾部添加元素,时间复杂度为
O(1)
。当当前容量不足时,vector 需要重新分配内存,将原有的元素复制到新的内存空间,然后再插入新元素,这个过程的时间复杂度为
O(n)
。然而,从摊销分析的角度来看,平均情况下每次在尾部插入元素的时间复杂度为
O(1)
。因为虽然偶尔会有重新分配内存的开销,但这种情况并不频繁,随着元素的不断插入,重新分配内存的开销被平均分摊到每次插入操作上。所以选项 A 正确。
分析选项 B:
list任意位置插入:list 是双向链表结构,对于双向链表,在任意位置插入一个新元素,只需要修改相邻节点的指针即可,不需要移动其他元素。因此,在 list 的任意位置插入元素的时间复杂度都是
O(1)
。例如,要在链表中间某个节点前插入新节点,只需调整几个指针的指向,与链表中元素的数量无关。所以选项 B 正确。
分析选项 C:
map的查找:map 通常基于红黑树实现(具体实现可能因编译器而异,但常见的是红黑树)。红黑树是一种自平衡的二叉搜索树,在红黑树上进行查找操作时,时间复杂度为
O(logn)
,其中
n
是树中节点的数量。这是因为每次比较可以排除大约一半的节点,类似于二分查找的过程,树的高度决定了查找的时间复杂度,而红黑树的高度为
O(logn)
。所以选项 C 正确。
分析选项 D:
unordered_map的插入:unordered_map 基于哈希表实现。在理想情况下,哈希函数能够将元素均匀地分布在哈希表中,插入操作的时间复杂度接近
O(1)
。然而,在最坏情况下,即哈希函数设计得不好,所有元素都映射到同一个哈希桶中,此时哈希表退化为链表,插入操作的时间复杂度会变为
O(n)
,其中
n
是哈希表中元素的数量。所以选项 D 错误。
综上,正确的选项是 A、B、C。
4.线程安全的单例模式实现方式包括:
A) 双检查锁
B) Meyer's Singleton(局部静态变量)
C) 饿汉式
D) 使用std::call_once
cpp
分析选项 A:双检查锁(Double - Checked Locking)
原理:双检查锁机制在获取单例实例时,先进行一次不加锁的检查,若实例尚未创建,再加锁进行第二次检查并创建实例。这样做的目的是避免每次获取实例都进行加锁操作,从而提高性能。
示例代码:
cpp
#include <iostream>
#include <mutex>
class Singleton {
public:
static Singleton* getInstance() {
if (instance == nullptr) {
std::lock_guard<std::mutex> lock(mutex_);
if (instance == nullptr) {
instance = new Singleton();
}
}
return instance;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton* instance;
static std::mutex mutex_;
};
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex_;
线程安全性:在 C++11 之前,由于指令重排序问题,双检查锁实现的单例模式在多线程环境下可能不是线程安全的。但在 C++11 中,对内存模型进行了改进,通过 std::atomic 类型和 memory_order 等机制保证了双检查锁的线程安全性。所以选项 A 是线程安全的单例模式实现方式。
分析选项 B:Meyer's Singleton(局部静态变量)
原理:在函数内部定义一个局部静态变量,当函数第一次被调用时,该静态变量会被初始化。由于 C++11 标准保证了局部静态变量的初始化是线程安全的,所以这种方式实现的单例模式是线程安全的。
示例代码:
cpp
#include <iostream>
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
线程安全性:C++11 规定,局部静态变量的初始化是线程安全的,在多线程环境下,多个线程同时调用 getInstance 函数时,只会有一个线程初始化 instance,其他线程等待初始化完成。所以选项 B 是线程安全的单例模式实现方式。
分析选项 C:饿汉式
原理:在程序启动时就创建单例实例,而不是在第一次使用时创建。由于实例在程序启动时就已创建,不存在多线程同时创建实例的问题,所以是线程安全的。
示例代码:
cpp
#include <iostream>
class Singleton {
public:
static Singleton& getInstance() {
return instance;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton instance;
};
Singleton Singleton::instance;
线程安全性:因为实例在程序启动时就已经创建完成,在多线程访问 getInstance 函数时,只是返回已创建的实例,不存在线程安全问题。所以选项 C 是线程安全的单例模式实现方式。
分析选项 D:使用 std::call_once
原理:std::call_once 函数可以确保其关联的函数只被调用一次,无论有多少线程同时调用 std::call_once。通过将单例实例的创建函数与 std::call_once 关联,可以保证单例实例只被创建一次,从而实现线程安全的单例模式。
示例代码:
cpp
#include <iostream>
#include <mutex>
class Singleton {
public:
static Singleton& getInstance() {
std::call_once(once_flag, []() {
instance = new Singleton();
});
return *instance;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton* instance;
static std::once_flag once_flag;
};
Singleton* Singleton::instance = nullptr;
std::once_flag Singleton::once_flag;
线程安全性:std::call_once 内部使用了互斥量和标志位来确保关联的函数只被调用一次,无论有多少线程并发调用 getInstance,都能保证单例实例的唯一性和线程安全性。所以选项 D 是线程安全的单例模式实现方式。
综上,选项 A、B、C、D 都是线程安全的单例模式实现方式
5.关于设计模式,正确的有:
A) 工厂模式用于创建对象
B) 观察者模式是一对多的依赖关系
C) 适配器模式转换接口
D) 装饰模式动态添加职责
cpp
分析选项 A:
工厂模式:工厂模式的核心目的就是用于创建对象。它将对象的创建和使用分离,通过一个工厂类来负责创建对象的具体逻辑。例如简单工厂模式,有一个工厂类,根据传入的参数决定创建哪种具体类型的对象;工厂方法模式则是将创建对象的方法延迟到子类中实现;抽象工厂模式可以创建一系列相关的对象。以简单工厂模式为例:
cpp
#include <iostream>
// 产品基类
class Product {
public:
virtual void show() = 0;
};
// 具体产品A
class ProductA : public Product {
public:
void show() override {
std::cout << "This is ProductA" << std::endl;
}
};
// 具体产品B
class ProductB : public Product {
public:
void show() override {
std::cout << "This is ProductB" << std::endl;
}
};
// 简单工厂类
class SimpleFactory {
public:
Product* createProduct(int type) {
if (type == 1) {
return new ProductA();
} else if (type == 2) {
return new ProductB();
}
return nullptr;
}
};
所以选项 A 正确。
分析选项 B:
观察者模式:观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。当主题对象的状态发生变化时,会自动通知所有依赖它的观察者对象,使它们能够做出相应的反应。例如,在一个新闻发布系统中,新闻发布者是主题,多个订阅者是观察者,当有新的新闻发布时,所有订阅者都会收到通知。所以选项 B 正确。
分析选项 C:
适配器模式:适配器模式主要用于将一个类的接口转换成客户希望的另一个接口。它使得原本由于接口不兼容而不能一起工作的类可以协同工作。比如,有一个旧的类,它的接口不符合新的需求,通过适配器模式可以创建一个新的类,将旧类的接口适配成新接口。例如,有一个 SquarePeg 类,它的接口是 insertSquare,现在需要一个能插入圆形孔的接口,就可以通过适配器将 SquarePeg 适配成能在圆形孔中使用的类。所以选项 C 正确。
分析选项 D:
装饰模式:装饰模式允许向一个现有的对象添加新的功能,同时又不改变其结构。它通过创建一个装饰类,将原始对象包装起来,并在装饰类中添加新的职责。这种添加是动态的,可以在运行时根据需要决定是否添加以及添加哪些功能。例如,给一个简单的图形对象(如圆形)添加不同的装饰,如边框、颜色填充等,这些装饰可以在运行时动态添加。所以选项 D 正确。
综上,选项 A、B、C、D 的描述都是正确的。
三、问答题(每题5分,共25分)
1.解释RAII原则,并举两个C++标准库中的例子说明。
cpp
1. 解释 RAII 原则
RAII 是 C++ 中一种管理资源的有效方式,其核心思想是将资源的获取和生命周期管理与对象的创建和销毁绑定。当一个对象被创建时,它获取所需的资源(如内存、文件句柄、锁等);当该对象被销毁(例如超出作用域或显式删除)时,它自动释放这些资源。这种机制利用了 C++ 对象的自动销毁特性,确保资源在不再需要时能够被正确释放,从而避免资源泄漏和悬空指针等问题。
2. C++ 标准库中的例子
例子一:智能指针(std::unique_ptr 和 std::shared_ptr)
智能指针是 RAII 原则的典型应用。以 std::unique_ptr 为例,当 std::unique_ptr 对象被创建时,它负责获取动态分配的内存资源:
cpp
#include <memory>
int main() {
std::unique_ptr<int> ptr(new int(42));
// 这里通过new分配了一个int型的内存资源,并由std::unique_ptr负责管理
// 当ptr离开作用域时,它会自动调用delete释放所指向的内存
return 0;
}
std::shared_ptr 同样遵循 RAII 原则,通过引用计数的方式管理资源。多个 std::shared_ptr 可以指向同一个资源,当最后一个指向该资源的 std::shared_ptr 对象被销毁时,资源会被释放:
cpp
#include <memory>
int main() {
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
std::shared_ptr<int> ptr2 = ptr1;
// ptr1和ptr2共享对同一个int型资源的引用
// 当ptr1和ptr2都离开作用域(引用计数降为0)时,资源会被释放
return 0;
}
例子二:std::lock_guard
std::lock_guard 用于管理互斥锁(std::mutex),也是 RAII 的体现。当 std::lock_guard 对象被创建时,它会自动锁定关联的互斥锁,获取锁资源:
cpp
#include <iostream>
#include <mutex>
std::mutex mtx;
void print() {
std::lock_guard<std::mutex> lock(mtx);
// 创建std::lock_guard对象时,自动锁定mtx
std::cout << "This is a thread - safe print." << std::endl;
// 当lock离开作用域时,自动解锁mtx,释放锁资源
}
在这个例子中,std::lock_guard 利用 RAII 机制,确保在其生命周期内互斥锁始终保持锁定状态,并且在对象销毁时自动解锁,避免了手动管理锁时可能出现的忘记解锁导致死锁的问题。
2.描述虚函数表的实现原理,包括单继承和多继承情况下的内存布局。
cpp
1. 虚函数表的实现原理
虚函数表(Virtual Table,简称 vtable)是 C++ 实现多态的关键机制。当一个类中声明了虚函数时,编译器会为该类生成一个虚函数表。
虚函数表的作用:虚函数表本质上是一个函数指针数组,其中每个元素都是指向该类虚函数的指针。当通过基类指针或引用调用虚函数时,程序会根据对象的实际类型(即运行时类型),通过虚函数表找到并调用正确的虚函数版本。
虚函数表指针(vptr):对于每个包含虚函数的类的对象,编译器会在对象的内存布局中添加一个虚函数表指针(vptr)。这个指针指向该对象所属类的虚函数表。在对象构造时,vptr 会被初始化,使其指向正确的虚函数表。
2. 单继承情况下的内存布局
假设存在一个基类 Base 和一个派生类 Derived,Base 中有虚函数。
cpp
class Base {
public:
virtual void virtualFunction() {
std::cout << "Base::virtualFunction" << std::endl;
}
};
class Derived : public Base {
public:
void virtualFunction() override {
std::cout << "Derived::virtualFunction" << std::endl;
}
};
Base 类的内存布局:Base 对象的内存布局首先是虚函数表指针(vptr),它指向 Base 类的虚函数表。接着是 Base 类的数据成员(如果有)。Base 类的虚函数表中包含指向 Base::virtualFunction 的指针。
Derived 类的内存布局:Derived 对象的内存布局同样以虚函数表指针(vptr)开始,这个 vptr 指向 Derived 类的虚函数表。然后是从 Base 类继承的数据成员(包括 Base 类的 vptr),接着是 Derived 类自身的数据成员(如果有)。Derived 类的虚函数表中,对于重写的虚函数(如 virtualFunction),指针会指向 Derived::virtualFunction;对于未重写的虚函数,指针会指向从 Base 类继承的虚函数版本。
3. 多继承情况下的内存布局
考虑一个有多个基类的派生类的情况,例如:
cpp
class Base1 {
public:
virtual void virtualFunction1() {
std::cout << "Base1::virtualFunction1" << std::endl;
}
};
class Base2 {
public:
virtual void virtualFunction2() {
std::cout << "Base2::virtualFunction2" << std::endl;
}
};
class Derived : public Base1, public Base2 {
public:
void virtualFunction1() override {
std::cout << "Derived::virtualFunction1" << std::endl;
}
void virtualFunction2() override {
std::cout << "Derived::virtualFunction2" << std::endl;
}
};
Base1 和 Base2 类的内存布局:与单继承类似,Base1 和 Base2 对象的内存布局分别以各自的虚函数表指针(vptr1 和 vptr2)开始,指向各自的虚函数表,接着是各自的数据成员。
Derived 类的内存布局:Derived 对象的内存布局相对复杂。它首先包含 Base1 类的部分,即 Base1 的 vptr1 指向 Derived 对应 Base1 虚函数表的版本,然后是 Base1 的数据成员;接着是 Base2 类的部分,Base2 的 vptr2 指向 Derived 对应 Base2 虚函数表的版本,然后是 Base2 的数据成员;最后是 Derived 类自身的数据成员。Derived 类有多个虚函数表,分别对应不同的基类,每个虚函数表中,对于重写的虚函数指向 Derived 类的实现版本,未重写的指向基类版本。
3.比较深拷贝和浅拷贝的区别,在什么情况下需要实现深拷贝?
cpp
1. 深拷贝和浅拷贝的区别
浅拷贝:浅拷贝是指在对象拷贝时,只复制对象中的基本数据类型成员和指针成员的值,但指针所指向的动态分配的内存空间并不进行复制。这意味着源对象和拷贝对象的指针成员指向同一块内存。当其中一个对象销毁时,释放了这块内存,另一个对象的指针就会变成悬空指针,导致程序出现未定义行为。例如:
cpp
class MyClass {
public:
int* data;
MyClass(int value) {
data = new int(value);
}
// 浅拷贝构造函数
MyClass(const MyClass& other) {
data = other.data;
}
~MyClass() {
delete data;
}
};
在上述代码中,MyClass 的浅拷贝构造函数只是简单地将 other.data 赋值给 data,两个对象的 data 指针指向同一块内存。
深拷贝:深拷贝不仅复制对象中的基本数据类型成员的值,还会为指针成员所指向的动态分配的内存空间分配新的内存,并将原内存中的数据复制到新的内存中。这样源对象和拷贝对象的指针成员指向不同的内存空间,它们的生命周期相互独立。例如:
cpp
class MyClass {
public:
int* data;
MyClass(int value) {
data = new int(value);
}
// 深拷贝构造函数
MyClass(const MyClass& other) {
data = new int(*other.data);
}
~MyClass() {
delete data;
}
};
这里的深拷贝构造函数为 data 重新分配了内存,并复制了 other.data 所指向的值,避免了浅拷贝带来的问题。
2. 需要实现深拷贝的情况
对象包含动态分配的资源:当类的对象中包含指针成员,且该指针指向动态分配的内存、文件句柄、网络连接等资源时,需要深拷贝。因为浅拷贝会导致多个对象共享这些资源,可能在资源释放时出现错误。比如一个表示字符串的类,内部使用 char* 来存储字符串内容,就需要深拷贝来确保每个对象都有自己独立的字符串副本。
防止数据共享冲突:如果希望拷贝后的对象与原对象在数据修改上相互独立,避免一个对象的修改影响到另一个对象,就需要深拷贝。例如,在图形编辑软件中,对于一个表示图形的对象,如果对其进行复制后,希望新的图形对象可以独立地进行编辑而不影响原始图形,就需要深拷贝。
对象的生命周期管理:当对象的生命周期与所管理的资源紧密相关,且需要多个独立的对象实例时,深拷贝是必要的。例如,一个数据库连接对象,每个拷贝都应该有自己独立的连接,而不是共享同一个连接,此时就需要深拷贝来创建独立的对象实例及其所管理的连接资源。
4.解释移动语义,std::move的作用是什么?为什么能提升性能?
cpp
1. 移动语义的解释
移动语义是 C++11 引入的重要特性,它主要解决了对象资源所有权转移的问题,避免了不必要的对象拷贝,从而提升程序性能。在传统的 C++ 中,对象的拷贝操作会复制对象的所有数据成员,包括动态分配的资源(如堆内存)。这在对象较大或资源分配开销较大时,性能消耗明显。移动语义则允许我们将一个对象的资源直接 "移动" 到另一个对象,而不是进行完整的拷贝。
例如,考虑一个管理动态数组的类 MyArray:
cpp
class MyArray {
private:
int* data;
size_t size;
public:
MyArray(size_t sz) : size(sz) {
data = new int[size];
// 初始化数组
}
// 传统拷贝构造函数
MyArray(const MyArray& other) : size(other.size) {
data = new int[size];
for (size_t i = 0; i < size; ++i) {
data[i] = other.data[i];
}
}
// 析构函数
~MyArray() {
delete[] data;
}
};
在上述代码中,拷贝构造函数会为新对象分配新的内存,并复制原对象数组中的所有元素。而移动语义可以优化这个过程。
2. std::move 的作用
std::move 是 C++11 标准库中的一个函数模板,它的作用是将一个左值转换为右值引用。右值引用是一种新的引用类型,专门用于支持移动语义。std::move 并不实际移动任何数据,它只是告诉编译器,我们可以将一个对象当作右值来处理,即可以进行资源的转移,而不是拷贝。
例如,我们为 MyArray 类添加移动构造函数:
cpp
class MyArray {
private:
int* data;
size_t size;
public:
MyArray(size_t sz) : size(sz) {
data = new int[size];
// 初始化数组
}
// 传统拷贝构造函数
MyArray(const MyArray& other) : size(other.size) {
data = new int[size];
for (size_t i = 0; i < size; ++i) {
data[i] = other.data[i];
}
}
// 移动构造函数
MyArray(MyArray&& other) noexcept : size(other.size), data(other.data) {
other.size = 0;
other.data = nullptr;
}
// 析构函数
~MyArray() {
delete[] data;
}
};
在使用时:
cpp
MyArray a(10);
MyArray b = std::move(a);
这里 std::move(a) 将左值 a 转换为右值引用,使得编译器可以调用移动构造函数,将 a 的资源(data 和 size)转移给 b,而不是进行拷贝。
3. 移动语义提升性能的原因
避免不必要的拷贝:在移动语义出现之前,对象传递和赋值操作通常会进行深拷贝,对于包含大量数据或动态分配资源的对象,这会导致显著的性能开销。移动语义通过直接转移资源所有权,避免了对这些资源的重复分配和复制。例如,在上述 MyArray 类中,移动构造函数只是简单地转移了指针和大小,而不是复制整个数组。
减少内存分配和释放次数:在传统的拷贝操作中,每次拷贝都可能需要分配新的内存,然后再释放旧的内存。移动语义减少了这种不必要的内存分配和释放操作,因为资源直接从一个对象转移到另一个对象,减少了内存碎片的产生,提高了内存管理效率。
5.描述生产者-消费者问题,给出至少两种线程同步的解决方案。
cpp
1. 生产者 - 消费者问题描述
生产者 - 消费者问题是一个经典的多线程同步问题。该问题场景中存在两类线程,即生产者线程和消费者线程。生产者线程负责生成数据并将其放入一个共享缓冲区中,而消费者线程则从共享缓冲区中取出数据进行处理。
主要存在以下挑战:
同步访问:生产者和消费者线程可能同时访问共享缓冲区,需要保证数据的一致性,避免数据竞争和不一致问题。
缓冲区管理:共享缓冲区通常有一定的容量限制。当缓冲区已满时,生产者线程需要等待;当缓冲区为空时,消费者线程需要等待。
2. 线程同步解决方案
方案一:使用互斥锁(std::mutex)和条件变量(std::condition_variable)
原理:互斥锁用于保护共享缓冲区,确保同一时间只有一个线程能访问它。条件变量则用于线程间的通信,当缓冲区状态满足特定条件(如缓冲区非空或未满)时,通知等待的线程。
示例代码(以 C++ 为例):
cpp
#include <iostream>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
std::queue<int> buffer;
std::mutex mtx;
std::condition_variable cv_producer, cv_consumer;
const int buffer_size = 5;
void producer(int id) {
for (int i = 0; i < 10; ++i) {
std::unique_lock<std::mutex> lock(mtx);
cv_producer.wait(lock, [] { return buffer.size() < buffer_size; });
buffer.push(id * 10 + i);
std::cout << "Producer " << id << " produced: " << id * 10 + i << std::endl;
lock.unlock();
cv_consumer.notify_one();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void consumer(int id) {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
cv_consumer.wait(lock, [] { return!buffer.empty(); });
int data = buffer.front();
buffer.pop();
std::cout << "Consumer " << id << " consumed: " << data << std::endl;
lock.unlock();
cv_producer.notify_one();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
解释:生产者线程在向缓冲区添加数据前,先获取锁并通过条件变量 cv_producer 等待缓冲区有空间。添加数据后,通知消费者线程。消费者线程在从缓冲区取出数据前,同样获取锁并通过条件变量 cv_consumer 等待缓冲区有数据。取出数据后,通知生产者线程。
方案二:使用信号量(std::counting_semaphore,C++20 引入,也可自行实现信号量机制)
原理:信号量是一个整型变量,通过控制信号量的值来管理对共享资源的访问。对于生产者 - 消费者问题,我们可以使用两个信号量:一个用于表示缓冲区中的空闲位置(初始值为缓冲区大小),另一个用于表示缓冲区中的数据项(初始值为 0)。
示例代码(假设使用 C++20 的std::counting_semaphore):
cpp
#include <iostream>
#include <queue>
#include <thread>
#include <semaphore>
#include <chrono>
std::queue<int> buffer;
std::counting_semaphore<5> empty(buffer_size);
std::counting_semaphore<5> full(0);
void producer(int id) {
for (int i = 0; i < 10; ++i) {
empty.acquire();
buffer.push(id * 10 + i);
std::cout << "Producer " << id << " produced: " << id * 10 + i << std::endl;
full.release();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void consumer(int id) {
while (true) {
full.acquire();
int data = buffer.front();
buffer.pop();
std::cout << "Consumer " << id << " consumed: " << data << std::endl;
empty.release();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
解释:生产者线程首先获取 empty 信号量(如果信号量值为 0 则等待),表示获取一个缓冲区的空闲位置,然后将数据放入缓冲区并释放 full 信号量,表示缓冲区有新数据。消费者线程首先获取 full 信号量,等待缓冲区有数据,然后取出数据并释放 empty 信号量,表示缓冲区有了空闲位置。
四、编程题(20分)
1.实现一个线程安全的环形缓冲区(Ring Buffer)类模板
要求:
模板参数T和size_t Capacity
支持push、pop、empty、full操作
线程安全,支持多生产者多消费者
使用现代C++(C++11及以上)
template<typename T, size_t Capacity>
class ThreadSafeRingBuffer {
public:
bool push(const T& item);
bool pop(T& item);
bool empty() const;
bool full() const;
size_t size() const;
private:
// 你的实现
};
cpp
设计思路阐述:
首先说明环形缓冲区的基本原理,它是一种固定大小的缓冲区,通过循环利用数组空间来实现高效的数据存储和读取,避免频繁的内存分配和释放。
强调线程安全的实现方式,使用互斥锁(std::mutex)来保护共享资源,防止多个线程同时访问缓冲区造成数据竞争。使用条件变量(std::condition_variable)来实现线程间的同步,让生产者和消费者在缓冲区满或空时等待。
成员变量定义:
cpp
private:
std::array<T, Capacity> buffer_;
size_t readIndex_ = 0;
size_t writeIndex_ = 0;
std::mutex mutex_;
std::condition_variable notFull_;
std::condition_variable notEmpty_;
size_t count_ = 0;
buffer_ 是一个固定大小的数组,用于存储数据。
readIndex_ 和 writeIndex_ 分别表示读取和写入的位置。
mutex_ 用于保护共享资源。
notFull_ 和 notEmpty_ 是条件变量,用于通知缓冲区非满和非空。
count_ 记录当前缓冲区中的元素个数。
push 函数实现:
cpp
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::push(const T& item) {
std::unique_lock<std::mutex> lock(mutex_);
notFull_.wait(lock, [this] { return count_ < Capacity; });
buffer_[writeIndex_] = item;
writeIndex_ = (writeIndex_ + 1) % Capacity;
++count_;
lock.unlock();
notEmpty_.notify_one();
return true;
}
首先获取锁,然后通过条件变量 notFull_ 等待缓冲区有空间(count_ < Capacity)。
将数据写入缓冲区,更新写入位置,并增加元素计数。
释放锁后,通过条件变量 notEmpty_ 通知消费者缓冲区有新数据。
pop 函数实现:
cpp
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::pop(T& item) {
std::unique_lock<std::mutex> lock(mutex_);
notEmpty_.wait(lock, [this] { return count_ > 0; });
item = buffer_[readIndex_];
readIndex_ = (readIndex_ + 1) % Capacity;
--count_;
lock.unlock();
notFull_.notify_one();
return true;
}
获取锁后,通过条件变量 notEmpty_ 等待缓冲区有数据(count_ > 0)。
从缓冲区读取数据,更新读取位置,并减少元素计数。
释放锁后,通过条件变量 notFull_ 通知生产者缓冲区有空闲空间。
empty 函数实现:
cpp
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::empty() const {
std::lock_guard<std::mutex> lock(mutex_);
return count_ == 0;
}
使用 std::lock_guard 自动管理锁,检查缓冲区元素计数是否为 0,以判断缓冲区是否为空。
full 函数实现:
cpp
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::full() const {
std::lock_guard<std::mutex> lock(mutex_);
return count_ == Capacity;
}
同样使用 std::lock_guard 管理锁,检查缓冲区元素计数是否等于缓冲区容量,以判断缓冲区是否已满。
size 函数实现:
cpp
template<typename T, size_t Capacity>
size_t ThreadSafeRingBuffer<T, Capacity>::size() const {
std::lock_guard<std::mutex> lock(mutex_);
return count_;
}
使用 std::lock_guard 管理锁,返回当前缓冲区中的元素个数。
cpp
#include <array>
#include <mutex>
#include <condition_variable>
#include <iostream>
template<typename T, size_t Capacity>
class ThreadSafeRingBuffer {
public:
bool push(const T& item);
bool pop(T& item);
bool empty() const;
bool full() const;
size_t size() const;
private:
std::array<T, Capacity> buffer_;
size_t readIndex_ = 0;
size_t writeIndex_ = 0;
std::mutex mutex_;
std::condition_variable notFull_;
std::condition_variable notEmpty_;
size_t count_ = 0;
};
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::push(const T& item) {
std::unique_lock<std::mutex> lock(mutex_);
notFull_.wait(lock, [this] { return count_ < Capacity; });
buffer_[writeIndex_] = item;
writeIndex_ = (writeIndex_ + 1) % Capacity;
++count_;
lock.unlock();
notEmpty_.notify_one();
return true;
}
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::pop(T& item) {
std::unique_lock<std::mutex> lock(mutex_);
notEmpty_.wait(lock, [this] { return count_ > 0; });
item = buffer_[readIndex_];
readIndex_ = (readIndex_ + 1) % Capacity;
--count_;
lock.unlock();
notFull_.notify_one();
return true;
}
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::empty() const {
std::lock_guard<std::mutex> lock(mutex_);
return count_ == 0;
}
template<typename T, size_t Capacity>
bool ThreadSafeRingBuffer<T, Capacity>::full() const {
std::lock_guard<std::mutex> lock(mutex_);
return count_ == Capacity;
}
template<typename T, size_t Capacity>
size_t ThreadSafeRingBuffer<T, Capacity>::size() const {
std::lock_guard<std::mutex> lock(mutex_);
return count_;
}
五、系统设计题(20分)
1.设计一个医疗器械数据采集系统的软件架构
场景:需要从多个传感器(温度、压力、心率)实时采集数据,存储到数据库,并提供实时监控界面。
要求:
画出软件模块图
说明线程/进程如何划分
如何处理传感器数据丢失或异常
如何保证系统实时性
考虑可扩展性(新增传感器类型)
cpp
1. 软件模块图绘制及说明
绘制一个分层的软件架构图,包括以下主要模块:
传感器接口层:
温度传感器接口模块:负责与温度传感器进行通信,读取温度数据。
压力传感器接口模块:负责与压力传感器通信,获取压力数据。
心率传感器接口模块:负责从心率传感器采集心率数据。
每个模块都需要适配不同传感器的通信协议,如 SPI、I2C、串口等。
数据处理层:
数据校验模块:对从传感器获取的数据进行有效性校验,例如检查数据是否在合理范围,数据格式是否正确等。
数据转换模块:将传感器采集到的原始数据转换为统一的标准格式,便于后续处理。
数据融合模块(可选,如果不同传感器数据有融合需求):对多种传感器数据进行融合处理,以提供更全面、准确的信息。
数据存储层:
数据库接口模块:负责与数据库进行交互,将处理后的数据存储到数据库中。可以选择关系型数据库(如 MySQL)或时序数据库(如 InfluxDB),以适应医疗器械数据的特点(时间序列性强)。
实时监控层:
实时数据显示模块:从数据处理层获取实时数据,并在监控界面上进行展示,例如使用图形化界面(如 Qt)实时绘制温度、压力、心率曲线。
报警模块:根据预设的阈值,对异常数据进行报警,如声音报警、界面提示等。
2. 线程 / 进程划分
每个传感器对应一个独立线程:这样可以确保每个传感器的数据采集互不干扰,同时实现并行采集,提高采集效率。例如,温度传感器采集线程负责定时从温度传感器读取数据,压力和心率传感器同理。
数据处理线程:负责从各个传感器采集线程接收数据,并进行校验、转换和融合等处理。这样可以将数据处理逻辑集中在一个线程,便于管理和维护。
数据库存储线程:负责将数据处理线程处理好的数据存储到数据库中。将存储操作放在独立线程,可以避免因数据库操作的延迟影响其他实时任务。
实时监控线程:负责从数据处理线程获取实时数据,并更新监控界面。通过独立线程,可以保证界面的流畅性,及时反映数据变化。
3. 处理传感器数据丢失或异常
数据校验与重传机制:在传感器接口层,每次采集到数据后进行校验。如果数据校验失败,认为数据可能丢失或异常,立即触发重传机制,重新从传感器读取数据。例如,对于 SPI 通信的传感器,可以重新发送读取指令。
设置默认值与标记:若多次重传仍失败,为了保证系统的连续性,可以为该传感器数据设置默认值(如温度传感器设为常温),并在数据中标记该数据为异常。这样在后续处理和显示中,可以对异常数据进行特殊处理。
报警与日志记录:一旦检测到数据丢失或异常,触发报警模块,通知操作人员。同时,将异常信息记录到日志文件中,包括异常发生时间、传感器类型、异常描述等,便于后续排查问题。
4. 保证系统实时性
硬件层面:选择高性能的处理器和通信接口,确保能够快速处理和传输数据。例如,使用多核处理器,提高数据处理能力;采用高速通信接口(如 USB 3.0、千兆以太网),加快数据传输速度。
软件层面:
优化线程调度:采用实时操作系统(RTOS)或在通用操作系统中设置线程优先级。将传感器采集线程和数据处理线程设置为高优先级,确保它们能够及时获取 CPU 资源,优先执行。
减少任务延迟:避免在关键路径上进行复杂的计算和长时间的 I/O 操作。例如,将数据存储操作优化为批量写入,减少数据库 I/O 次数;在数据处理中,采用高效的算法,降低计算复杂度。
数据预取与缓存:在数据处理层和实时监控层,采用数据预取和缓存机制。提前从数据库或传感器接口层获取可能需要的数据,并缓存起来,减少数据等待时间。
5. 考虑可扩展性(新增传感器类型)
模块化设计:整个软件架构采用模块化设计,每个传感器对应一个独立的接口模块。当需要新增传感器类型时,只需创建一个新的传感器接口模块,实现与该传感器的通信和数据采集逻辑,而不影响其他模块的功能。
抽象接口与统一数据格式:定义抽象的传感器接口类,规定所有传感器接口模块必须实现的方法,如数据采集、初始化等。同时,在数据处理层采用统一的数据格式,新传感器采集的数据经过转换模块后,也以统一格式进入后续处理流程。
配置文件管理:使用配置文件来管理传感器的相关信息,如传感器类型、通信参数、数据处理参数等。当新增传感器时,只需在配置文件中添加相应的配置项,系统启动时会自动加载新的传感器配置,实现系统的快速扩展。