第一章语言基础
1.1 简述C++语言的特点?
- C++在C语言基础上引入了面对对象的机制,同时也兼容C语言。
- C++有三大特性:封装,继承,多态。封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类);它们的目的都是为了——代码重用。而多态则是为了实现另一个目的——接口重用。
- C++语言编写出的程序结构清晰、易于扩充,程序可读性好。
- C++生成的代码质量高,运行效率高,仅比汇编语言慢10%~20%;
- C++更加安全,增加了const常量、引用、四类cast转换(static_cast、dynamic_cast、const_cast、reinterpret_cast)、智能指针、try—catch等等;
- C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL(Standard Template Library)。
- 同时,C++是不断在发展的语言。C++后续版本更是发展了不少新特性,如C++11中引入了nullptr、auto变量、Lambda匿名函数、右值引用、智能指针。
1.2 简述C语言的特点?
- C语言有出色的可移植性 ,能在多种不同体系结构的软/硬平台上运行。
- 简洁紧凑,使用灵活的语法机制 ,并能直接访问硬件。
- C语言具有很高的运行效率,直接操作底层寄存器。
1.3 为什么嵌入式用C语言比较多?
能够直接访问硬件的语言有汇编和C语言,汇编属于低级语言,难以完成一些复杂的功能,但是汇编比C语言访问硬件的效率更高。所以,一般将硬件初始化的工作交给汇编,比较复杂的操作交给C语言。
1.4 面向对象有哪些特征和原则?
包括四大基本特征和五大基本原则。
- 特征:抽象、继承、多态、封装
- 原则:单一职责原则、开放封闭原则、替换原则、依赖原则、接口分离原则
1.5 面向过程与面向对象的语言有哪些优缺点?
面向对象的语言:
- 优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;
- 缺点:没有面向对象易维护、易复用、易扩展
面向对象的语言:
- 优点:易维护、易复用、易扩展,
- 缺点:性能比面向过程低
1.6 程序运行的步骤是什么?
预编译:将头文件编译,进行宏替换,输出.i文件
编译:将其转化为汇编语言文件,主要做词法分析,语义分析以及检查错误,检查无误后将代码翻译成汇编语言,生成.s文件
汇编:汇编器将汇编语言文件翻译成机器语言,生成.o文件
链接:将目标文件和库链接到一起,生成可执行文件.exe
第二章 指针和引用
2.1 智能指针
参考:
2.1.1 什么是智能指针
智能指针本质是一个封装了一个原始C++指针的类模板,为了确保动态内存的安全性而产生的。实现原理是通过一个对象存储需要被自动释放的资源,然后依靠对象的析构函数来释放资源。
从比较简单的层面来看,智能指针是RAII(Resource Acquisition Is Initialization,资源获取即初始化)机制对普通指针进行的一层封装。这样使得智能指针的行为动作像一个指针,本质上却是一个对象,这样可以方便管理一个对象的生命周期。
在c++中,智能指针类通常定义在 头文件中,一共定义了4种:auto_ptr、unique_ptr、shared_ptr 和 weak_ptr。其中,auto_ptr 在C++11已被摒弃,在C++17中已经移除不可用。
2.1.2 原始指针的问题
- 忘记删除
- 删除后的情况没有考虑清楚,容易造成悬挂指针(dangling pointer)或者说野指针(wild pointer)。
- 程序有异常导致准备的删除操作无法执行。
2.1.3 unique_ptr
2.1.3.1 简介
unique_ptr是独享被管理对象指针所有权(owership)的智能指针。unique_ptr对象封装一个原始指针,并负责其生命周期。当该对象被销毁时,会在其析构函数中删除关联的原始指针。
2.1.3.2 创建
创建unique_ptr:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
void f1() {
unique_ptr<int> p(new int(5));
cout<<*p<<endl;
}
2.1.3.3 注意
unique_ptr没有复制构造函数,不支持普通的拷贝和赋值操作。因为unique_ptr独享被管理对象指针所有权,当p2, p3失去p的所有权时会释放对应资源,此时会执行两次delete p的操作。如下
void f1() {
unique_ptr<int> p(new int(5));
cout<<*p<<endl;
unique_ptr<int> p2(p);
unique_ptr<int> p3 = p;
}
对于p2,p3对应的行,IDE会提示报错
无法引用 函数 "std::__1::unique_ptr<_Tp, _Dp>::unique_ptr(const std::__1::unique_ptr<int, std::__1::default_delete<int>> &) [其中 _Tp=int, _Dp=std::__1::default_delete<int>]" (已隐式声明) -- 它是已删除的函数
unique_ptr不支持普通的拷贝和赋值操作,但可以将所有权进行转移,使用std::move方法即可。
void f1() {
unique_ptr<int> p(new int(5));
unique_ptr<int> p2 = std::move(p);
//error,此时p指针为空: cout<<*p<<endl;
cout<<*p2<<endl;
}
2.1.4 shared_ptr
2.1.4.1 简介
原理:是使用引用计数实现对同一块内存的多个引用。在最后一个引用被释放时,指向的内存才释放
2.1.4.2 创建
正确的shared_ptr写法:
void f2() {
shared_ptr<int> p = make_shared<int>(1);
shared_ptr<int> p2(p);
shared_ptr<int> p3 = p;
}
2.1.4.3 注意
下面是一个错误的写法,因为右边得到的是一个原始指针,前面我们讲过shared_ptr本质是一个对象,将一个指针赋值给一个对象是不行的。
std::shared_ptr<int> p4 = new int(1)
获取shared_ptr的原始指针:
void f2() {
shared_ptr<int> p = make_shared<int>(1);
int *p2 = p.get();
cout<<*p2<<endl;
}
不能将一个原始指针初始化多个shared_ptr
void f2() {
int *p0 = new int(1);
shared_ptr<int> p1(p0);
shared_ptr<int> p2(p0);
cout<<*p1<<endl;
}
上面代码就会报错。原因也很简单,因为p1,p2都要进行析构删除,这样会造成原始指针p0被删除两次,自然要报错。这点和上面的unique_ptr一样。
也就是说,可以用shared_ptr对象进行拷贝和复制,但是不能用原始指针进行多次创建。
2.1.4.4 循环引用问题和weak_ptr
shared_ptr最大的坑就是循环引用。引用网络上的一个例子:
struct Father
{
shared_ptr<Son> son_;
};
struct Son
{
shared_ptr<Father> father_;
};
int main()
{
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
}
该部分代码会有内存泄漏问题。原因是
- main 函数退出之前,Father 和 Son 对象的引用计数都是 2。
- son 指针销毁,这时 Son 对象的引用计数是 1。
- father 指针销毁,这时 Father 对象的引用计数是 1。
- 由于 Father 对象和 Son 对象的引用计数都是 1,这两个对象都不会被销毁,从而发生内存泄露。
解决办法:使用 weak_ptr。weak_ptr 并不拥有其指向的对象,也就是说,让 weak_ptr 指向 shared_ptr 所指向对象,对象的引用计数并不会增加。
使用 weak_ptr 就能解决前面提到的循环引用的问题,方法很简单,只要让 Son 或者 Father 包含的 shared_ptr 改成 weak_ptr 就可以了。
struct Father
{
shared_ptr<Son> son_;
};
struct Son
{
weak_ptr<Father> father_;
};
int main()
{
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
}
同样,分析一下 main 函数退出时发生了什么:
- main 函数退出前,Son 对象的引用计数是 2,而 Father 的引用计数是 1。
- son 指针销毁,Son 对象的引用计数变成 1。
- father 指针销毁,Father 对象的引用计数变成 0,导致 Father 对象析构,Father 对象的析构会导致它包含的 son_ 指针被销毁,这时 Son 对象的引用计数变成 0,所以 Son 对象也会被析构。
2.1.4 weak_ptr
参考:
weak_ptr是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是,将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。
不论是否有weak_ptr指向,一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放。
从这个角度看,weak_ptr更像是shared_ptr的一个助手而不是智能指针。
2.1.4.1 常见操作
初始化方式
- 通过shared_ptr直接初始化,也可以通过隐式转换来构造;
- 允许移动构造,也允许拷贝构造。
#include <iostream>
#include <memory>
class Frame {};
int main()
{
std::shared_ptr<Frame> f(new Frame());
std::weak_ptr<Frame> f1(f); // shared_ptr直接构造
std::weak_ptr<Frame> f2 = f; // 隐式转换
std::weak_ptr<Frame> f3(f1); // 拷贝构造函数
std::weak_ptr<Frame> f4 = f1; // 拷贝构造函数
std::weak_ptr<Frame> f5;
f5 = f; // 拷贝赋值函数
f5 = f2; // 拷贝赋值函数
std::cout << f.use_count() << std::endl; // 1
return 0;
}
常用操作函数
- w.user_count():返回weak_ptr的强引用计数;
- w.reset(…):重置weak_ptr。
2.1.4.2 缺点
然而,weak_ptr 并不是完美的,因为 weak_ptr 不持有对象,可能存在weak_ptr指向的对象被释放掉这种情况,所以不能通过 weak_ptr 去访问对象的成员,例如:
struct Square
{
int size = 0;
};
auto sp = make_shared<Square>();
weak_ptr<Square> wp(sp);
cout << wp->size << endl; // compile-time ERROR
C++中提供了lock函数来实现该功能。如果对象存在,lock()函数返回一个指向共享对象的shared_ptr(引用计数会增1),否则返回一个空shared_ptr。weak_ptr还提供了expired()函数来判断所指对象是否已经被销毁。
由于weak_ptr并没有重载operator ->和operator *操作符,因此不可直接通过weak_ptr使用对象,同时也没有提供get函数直接获取裸指针。典型的用法是调用其lock函数来获得shared_ptr示例,进而访问原始对象。
2.1.4.3 使用场景
共享对象的线程安全问题
例如:线程A和线程B访问一个共享的对象,如果线程A正在析构这个对象的时候,线程B又要调用该共享对象的成员方法,此时可能线程A已经把对象析构完了,线程B再去访问该对象,就会发生不可预期的错误。
#include <iostream>
#include <memory>
#include <thread>
class Test {
public:
Test(int id) : m_id(id) {}
void showID() {
std::cout << m_id << std::endl;
}
private:
int m_id;
};
void thread1(Test* t) {
std::this_thread::sleep_for(std::chrono::seconds(2));
t->showID(); // 打印结果:0
}
int main()
{
Test* t = new Test(2);
std::thread t1(thread1, t);
delete t;
t1.join();
return 0;
}
在例子中,由于thread1等待2s,此时,main线程早已经把t对象析构了。打印m_id,自然不能打印出2了。可以通过shared_ptr和weak_ptr来解决共享对象的线程安全问题。
#include <iostream>
#include <memory>
#include <thread>
class Test {
public:
Test(int id) : m_id(id) {}
void showID() {
std::cout << m_id << std::endl;
}
private:
int m_id;
};
void thread2(std::weak_ptr<Test> t) {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::shared_ptr<Test> sp = t.lock();
if(sp)
sp->showID(); // 打印结果:2
}
int main()
{
std::shared_ptr<Test> sp = std::make_shared<Test>(2);
std::thread t2(thread2, sp);
t2.join();
return 0;
}
如果想访问对象的方法,先通过t的lock方法进行提升操作,把weak_ptr提升为shared_ptr强智能指针。提升过程中,是通过检测它所观察的强智能指针保存的Test对象的引用计数,来判定Test对象是否存活。ps如果为nullptr,说明Test对象已经析构,不能再访问;如果ps!=nullptr,则可以正常访问Test对象的方法。
如果设置t2为分离线程t2.detach(),让main主线程结束,sp智能指针析构,进而把Test对象析构,此时showID方法已经不会被调用,因为在thread2方法中,t提升到sp时,lock方法判定Test对象已经析构,提升失败!
观察者模式
观察者模式就是,当观察者观察到某事件发生时,需要通知监听者进行事件处理的一种设计模式。
在多数实现中,观察者通常都在另一个独立的线程中,这就涉及到在多线程环境中,共享对象的线程安全问题(解决方法就是使用上文的智能指针)。这是因为在找到监听者并让它处理事件时,其实在多线程环境中,肯定不明确此时监听者对象是否还存活,或是已经在其它线程中被析构了,此时再去通知这样的监听者,肯定是有问题的。
也就是说,当观察者运行在独立的线程中时,在通知监听者处理该事件时,应该先判断监听者对象是否存活,如果监听者对象已经析构,那么不用通知,并且需要从map表中删除这样的监听者对象。其中的主要代码为:
// 存储监听者注册的感兴趣的事件
unordered_map<int, list<weak_ptr<Listener>>> listenerMap;
// 观察者观察到事件发生,转发到对该事件感兴趣的监听者
void dispatchMessage(int msgid) {
auto it = listenerMap.find(msgid);
if (it != listenerMap.end()) {
for (auto it1 = it->second.begin(); it1 != it->second.end(); ++it1) {
shared_ptr<Listener> ps = it1->lock(); // 智能指针的提升操作,用来判断监听者对象是否存活
if (ps != nullptr) { // 监听者对象如果存活,才通知处理事件
ps->handleMessage(msgid);
} else {
it1 = it->second.erase(it1); // 监听者对象已经析构,从map中删除这样的监听者对象
}
}
}
}
解决循环引用
上文已详细讲述了循环引用的错误原因和解决办法。
监视this智能指针
enable_shared_from_this中有一个弱指针weak_ptr,这个弱指针能够监视this。在调用shared_from_this这个函数时,这个函数内部实际上是调用weak_ptr的lock方法。lock()会让shared_ptr指针计数+1,同时返回这个shared_ptr。
2.1.4.4 weak_ptr真的不计数?是否有计数方式,在哪分配的空间。
计数,控制块中有强弱引用计数,如果是使用make_shared初始化的函数则它所在的控制块空间是在所引用的shared_ptr中同一块的空间,若是new则控制器所分配的内存与shared_ptr本身所在的空间不在同一块内存。
2.2 野指针,悬空指针,空指针
野指针是没有被初始化过的指针,指向的位置是不可知的(随机的、不正确的、没有明确限制的)。此时去解引用就是去访问了一个不确定的地址,所以结果是不可知的。
- 成因:指针变量未初始化、指针释放后之后未置空、指针操作超越变量作用域
- 解决:初始化时置 NULL、释放时置 NULL
- 悬空指针:指针最初指向的内存已经被释放了的一种指针。(例子:返回局部变量的地址)
- 空指针:指针的值为0,不指向任何有效数据
产生原因及解决办法: - 野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空。
- 悬空指针:指针free或delete之后没有及时置空 => 释放操作后立即置空。
2.3 野指针避免办法
使用时遵循以下步骤:
- 初始化置NULL
- 申请内存后判空
- 指针释放后置NULL
- 使用智能指针
2.4 指针数组和数组指针
指针数组:数组,元素是指针类型 int* arr[3]={&num1,&num2,&num3};
数组指针:指针,指向数组 int(* p)[3]=&arr;
不能把数组当参数直接传递,要用数组指针。
2.5 左值引用和右值引用
2.5.1 什么是左值?
左值(lvalue)是一种表达式,表示一个可以被取地址的对象。左值通常是指具有持久存储的对象,因此可以在表达式的左侧出现(即可以被赋值)。与左值相对的是右值(rvalue),后者通常是临时对象或常量,不能直接取地址。
2.5.2 左值引用和指针的区别?
- 是否初始化:指针可以不用初始化,引用必须初始化
- 性质不同:指针是一个变量,引用是对被引用的对象取一个别名
- 占用内存单元不同:指针有自己的空间地址,引用和被引用对象占同一个空间。
2.5.3 右值引用是什么,为什么要引入右值引用?
右值引用是为一个临时变量取别名,它只能绑定到一个临时变量或表达式(将亡值)上。实际开发中我们可能需要对右值进行修改(实现移动语义时就需要)而右值引用可以对右值进行修改。
引入目的:
- 为了支持移动语义,右值引用可以绑定到临时对象、表达式等右值上,这些右值在生命周期结束后就会被销毁,因此可以在右值引用中窃取其资源,从而避免昂贵的复制操作,实现高效的移动语义。
- 完美转发:右值引用可以绑定到任何类型的右值上,可以将其作为参数传递给函数,并在函数内部将其“转发”到其他函数中,从而实现完美转发。
- 拓展可变参数模板,实现更加灵活的模板编程。
2.5.4 push_back()左值和右值的区别是什么?
如果push_back()的参数是左值,则使用它拷贝构造新对象,如果是右值,则使用它移动构造新对象.
移动构造move底层是怎么实现的?
Move的功能是将一个左值引用强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义,从实现原理上讲基本等同一个强制类型转换。
2.6 动态内存函数
2.6.1 C语言动态内存函数
参考:
2.6.1.1 malloc
为解决静态内存开辟存在的问题,C语言提供了一个动态内存开辟的函数:
原型
void* malloc(size_t size);
malloc为memory allocation的简写,意为内存分配。这个函数的作用是向内存申请一块连续可用的空间,并返回指向这块空间的指针。
使用例子:
int* p = (int*)malloc(40);//申请40个字节的空间
特点:
- 如果开辟成功,则返回一个指向开辟好空间的指针;
- 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查;
- 返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定;
- 如果参数size为0,malloc的行为是标准是未定义的,取决于编译器。
- 对于malloc函数申请的内存空间,当程序退出时还给操作系统;当程序不退出时,malloc动态申请的内存不会主动释放。
2.6.1.2 free
C语言提供了另外一个函数free,专门用来做动态内存的释放和回收:
原型
void free(void* ptr);
使用例子:
int main()
{
int* p = (int*)malloc(40);
if (p == NULL)
{
perror("malloc");//如果开辟失败,告知原因
exit(-1);//开辟失败直接退出
}
free(p);
p = NULL;//避免p被释放后成为野指针
return 0;
}
注意
- free函数用来释放动态开辟的内存;
- 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的(也就是说free函数只能释放动态开辟的空间,不能释放静态开辟的空间);
- 如果参数 ptr 是NULL指针,则free函数什么事都不做。
对free传入一个指针时,它如何确定具体要清理多少空间呢?
我们在申请内存的时候,会多分配16字节的内存,里面保存了内存块的详细信息,free会对传入的内存地址向左偏移16字节,然后分析出当前内存块的大小,就知道要释放多大的内存空间了。
内存的布局可能如下所示:
+-------------------+-------------------+
| Size (4 bytes) | Allocated Memory |
| (32 bytes) | |
+-------------------+-------------------+
2.6.1.3 callloc
C语言还提供了一个函数叫calloc ,calloc为contiguous allocation的简写,意为动态内存分配并清零。
原型
void* calloc(size_t num, size_t size);
函数的功能是为 num个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为0;
与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0。
具体用法
与malloc大致相同:
int main()
{
int* p = (int*)calloc(10, sizeof(int));//10个大小为4字节的元素
if (p == NULL)
{
perror("calloc");//如果开辟失败,告知原因
exit(-1);//开辟失败直接退出
}
//打印已开辟好的元素
for (int i = 0; i < 10; i++)
{
printf("%d ", p[i]);
}
free(p);
p = NULL;//避免p被释放后成为野指针
return 0;
}
2.6.1.3 realloc
在我们使用malloc函数与calloc函数申请过空间之后,我们可能会遇到申请的空间过大了,比如我动态申请了1000个字节的空间,可发现我只需要10个字节的空间;又有可能遇到申请的空间过小了,比如我动态申请了100个字节的空间,可最后发现我却要10000字节的空间;
针对这两种现象,为了合理地使用内存,我们一定会对内存空间做灵活地调整,那 realloc 函数就可以做到对动态开辟内存大小的调整。
原型
void* realloc(void* ptr, size_t size);
解释:
- ptr 是要调整的内存地址;
- size 调整之后新大小;
- 返回值为调整之后的内存起始位置;
- 这个函数在调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。但扩充数据时新空间数据不会初始化为0。
对于第三点,有两种情况:
- 原有空间之后有足够大的空间。直接在原有空间基础上扩充,返回的指针和原指针相同。
- 原有空间之后的空间不足。这时会执行以下步骤:1.开辟新的空间;2.将旧的空间中的数据拷贝到新的空间;3.释放旧的空间;4.返回新空间的起始地址。
正确调用示例:
int main()
{
//先用malloc开辟40个字节的空间
int* p = (int*)malloc(40);
if (p == NULL)
{
perror("malloc");//如果开辟失败,告知原因
exit(-1);//开辟失败直接退出
}
//开辟成功后,将这块空间初始化为1~10
for (int i = 0; i < 10; i++)
{
p[i] = i + 1;
}
//再用realloc再增加40个字节的空间
int* ptr = (int*) realloc(p, 80);//未避免情况二的发生,不能用p接收新地址
if (ptr != NULL)
{
p = ptr;//如果开辟成功了,再把ptr拷贝给p
ptr = NULL;
}
else
{
perror("realloc");//如果开辟失败,告知原因
}
//打印数据
for (int i = 0; i < 20; i++)
{
printf("%d\n", p[i]);
}
//释放空间
free(p);
p = NULL;
return 0;
}
(小知识:如果p为空指针,那么realloc与malloc的功能相同;如果缩小空间,那么就一定是情况一了。)
2.6.1.4 mmap
原型:
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
参数说明:
- addr:映射的起始地址,通常设置为 NULL 让系统选择。
- length:映射的字节数。
- prot:映射区域的保护标志,常用值有:
- PROT_READ:可读。
- PROT_WRITE:可写。
- PROT_EXEC:可执行。
- flags:映射的类型,常用值有:
- MAP_SHARED:多个进程共享映射。
- MAP_PRIVATE:私有映射,写入后不影响原文件。
- fd:要映射的文件描述符,通过 open 函数打开文件获得。
- offset:文件内的偏移量,映射文件的起始位置。
返回值:成功时,返回映射区域的指针;失败时,返回 MAP_FAILED。
使用例子:
#include <iostream>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
int main() {
const size_t arraySize = 10; // 数组大小
// 使用 mmap 创建内存映射
int* array = static_cast<int*>(mmap(NULL, arraySize * sizeof(int), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
if (array == MAP_FAILED) {
perror("Error mmapping the memory");
return EXIT_FAILURE;
}
// 初始化数组
for (size_t i = 0; i < arraySize; ++i) {
array[i] = i * 10; // 将数组元素初始化为 0, 10, 20, ..., 90
}
// 输出数组内容
std::cout << "Array contents:" << std::endl;
for (size_t i = 0; i < arraySize; ++i) {
std::cout << "array[" << i << "] = " << array[i] << std::endl;
}
// 解除映射
if (munmap(array, arraySize * sizeof(int)) == -1) {
perror("Error unmapping the memory");
}
return EXIT_SUCCESS;
}
注意,为什么不全部使用mmap来分配内存?
动态分配不全部使用mmap因为向操作系统申请内存的时候,是要通过系统调用的,执行系统调用要进入内核态,然后再回到用户态,状态的切换会耗费不少时间,所以申请内存的操作应该避免频繁的系统调用,如果都使用mmap来分配内存,等于每次都要执行系统调用。
另外,因为mmap分配的内存每次释放的时候都会归还给操作系统,于是每次mmap分配的虚拟地址都是缺页状态,然后在第一次访问该虚拟地址的时候就会触发缺页中断。
2.6.1.5 munmap
munmap 是一个用于解除内存映射的系统调用,主要用于释放之前使用 mmap 创建的内存映射。它是 UNIX 和 Linux 系统中常用的函数,属于 POSIX 标准的一部分。
原型
int munmap(void* addr, size_t length);
参数说明:
- addr:指向要解除映射的内存区域的起始地址,通常是之前通过mmap 返回的指针。
- length:解除映射的字节数,应该与通过 mmap 时请求的大小相同。
返回值:
- 成功时,返回 0。
- 失败时,返回 -1,并设置 errno 以指示错误原因。
注意
- 确保解除映射:在使用 mmap 映射内存后,务必在不再需要这些内存时调用 munmap,以避免内存泄漏。
- 参数匹配:确保传递给 munmap 的 length 参数与 mmap 时请求的大小相同。如果指定的长度不正确,可能会导致未定义行为。
- 内存保护:解除映射后,指向已解除映射内存的指针将不再有效,访问这些内存区域可能导致段错误(segmentation fault)。
- 错误处理:调用 munmap 后,如果返回值是 -1,应检查 errno 以获取具体的错误信息,常见的错误可能包括传递了无效的地址或长度。
2.6.1.6 brk
brk 是一个用于控制进程数据段(即堆)的系统调用,主要用于动态内存分配。它的功能是用来调整进程的数据段的末尾(即“程序断点”),从而为进程分配或释放内存。brk 是在 UNIX 和 Linux 系统中使用的一种较低级的内存管理方式。
函数原型
int brk(void* end_data_segment);
参数说明:
- end_data_segment:指向新的数据段末尾的指针。通过调整这个指针,可以增加或减少可用的堆内存。
返回值:
- 成功时,返回 0。
- 失败时,返回 -1,并设置 errno 以指示错误。
使用场景
和其相关的 sbrk 函数通常用于直接管理堆内存。在 C 标准库中的 malloc、calloc 和 free 等动态内存分配函数的实现中,通常会使用这些系统调用来分配和释放内存。
#include <iostream>
#include <unistd.h>
int main() {
// 使用 sbrk 获取当前堆的结束位置
void* initial_brk = sbrk(0);
std::cout << "Initial break: " << initial_brk << std::endl;
// 使用 sbrk 增加堆的大小
void* new_memory = sbrk(20);
if (new_memory == (void*)-1) {
perror("sbrk failed");
return EXIT_FAILURE;
}
std::cout << "Memory allocated at: " << new_memory << std::endl;
// 使用 brk 设置新的堆结束位置
void* new_brk = static_cast<char*>(initial_brk) + 20;
if (brk(new_brk) != 0) {
perror("brk failed");
return EXIT_FAILURE;
}
std::cout << "New break set using brk: " << new_brk << std::endl;
// 释放内存
if (sbrk(-20) == (void*)-1) {
perror("sbrk to release memory failed");
return EXIT_FAILURE;
}
std::cout << "Memory released." << std::endl;
return EXIT_SUCCESS;
}
2.6.1.7 sbrk
原型
void* sbrk(intptr_t increment);
increment:要增加或减少的字节数。返回值是调整后的数据段末尾的地址。
为什么不全部都用brk和sbrk?
如果全部使用brk申请内存那么随着程序频繁的调用malloc和free,尤其是小块内存,堆内将产生越来越多的不可用的内存碎片。
2.6.2 C++动态内存函数
参考
2.6.2.1 new
new的运算符用法(关键字)
一共有四个步骤:
- 计算类型
- 申请一个空间(因为new的底层是malloc)
- 将所取得的空间初始化
- 将申请到的地址返回
int* p= new int(10);//创建一个整数并初始化为10
new的函数用法:
int* p = (int*)::operator new(sizeof(int)*10);
new当作函数用法使用的是时候,类似malloc,都是申请一个空间,区别在于,返回值不同;当空间不足时malloc会返回一个“nullptr”,operator new会返回一个throw_bad的异常。想要和malloc效果相同,可以加入nothrow参数。
int* p = (int*)::operator new(sizeof(int)*10, nothrow);
int* p = (int*)malloc(sizeof(int)*10);
定位new
定位 new(Placement new)是 C++ 中的一种特殊形式的 new 操作符,它允许在已有的内存区域(提前开辟的空间)中构造对象。这种方式常用于在自定义内存管理或特定内存区域中创建对象。
原型:
void* operator new(size_t size, void* ptr) noexcept;
用法:
int main()
{
int n = 10;
int* ipa = (int*)ma11oc(sizeof(int));
int* ipb = (int*)::operator new(sizeof(int) * n);
new(ipa) int(20);
new(ipb) int[]{ 1,2,3,4,5,6,7,8,9 };
free(ipa);
::operator delete(ipb);
return 0;
}
重要事项
- 内存管理:使用定位 new 时,程序员负责内存管理,确保在合适的时机调用析构函数和释放内存。
- 避免内存泄漏:如果不调用析构函数,可能会导致资源泄漏。
- 类型安全:定位 new 直接在已有内存上构造对象,因此必须确保传入的内存足够大且对齐方式正确。
- 性能:定位 new 可以避免多次内存分配和释放的开销,适合需要频繁创建和销毁对象的场景。
应用场景
5. 自定义内存池:在游戏开发或高性能计算中,可能会使用定位 new 来在预分配的内存池中创建和管理对象,以减少内存分配的开销。
6. 特定内存位置:在嵌入式系统或某些特定硬件平台中,可能需要在特定的内存地址上构造对象。
2.6.2.3 new创建对象
new创建对象特点:
- new创建对象需要指针接收,一处初始化,多处使用。
- new创建对象使用完需delete销毁。
- new创建对象直接使用堆空间,而局部不用new定义对象则使用栈空间。
- new对象指针用途广泛,比如作为函数返回值、函数参数等。
频繁调用场合并不适合new,就像new申请和释放内存一样。
new创建对象例子:
CTest* pTest = new CTest();
delete pTest;
CTest* pTest = NULL;
2.6.2.2 delete
delete运算符使用的一般格式为
delete [ ]指针变量
例如要撤销上面用new开辟的空间用:
delete p;
如果我们用“new char[10];”开辟的字符数组空间,把new返回的指针赋给了指针变量pt,则应该用以下形式的delete运算符撤销该空间:
char* pt = new char[10];
delete []pt;
pt = nullptr; // 避免悬挂指针
2.7
第三章 字符串
3.1 Sprintf strcpy memcpy
-
操作对象不同,strcpy 的两个操作对象均为字符串,sprintf 的操作源对象可以是多种数据类型,目的操作对象是字符串,memcpy 的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
-
执行效率不同,memcpy最高,strcpy次之,sprintf的效率最低。
-
实现功能不同,strcpy 主要实现字符串变量间的拷贝,sprintf 主要实现其他数据类型格式到字 符串的转化,memcpy 主要是内存块间的拷贝。
注意:strcpy、sprintf 与memcpy 都可以实现拷贝的功能,但是针对的对象不根据实际需求,来 选择合适的函数实现拷贝功能。
函数原型:
char* strcpy(char* destination, const char* source);
destination 是目标字符串的指针,表示要将源字符串复制到的位置。
source 是源字符串的指针,表示要被复制的字符串。
strcpy 函数的作用是将源字符串复制到目标字符串中,直到遇到源字符串的空字符(‘\0’)为止。需要注意的是,目标字符串必须具有足够的空间来容纳源字符串,以避免缓冲区溢出。
void* memcpy(void* destination, const void* source, size_t num);
destination 是指向目标内存区域的指针,表示要拷贝数据的目标位置。
source 是指向源内存区域的指针,表示要被复制的数据的起始位置。
num 表示要被复制的字节数。
memcpy 函数的作用是将源内存区域中的数据复制到目标内存区域。需要注意的是,memcpy 不会检查目标内存区域的大小,因此在使用时需要确保目标内存区域足够大以容纳源数据。
int sprintf(char* str, const char* format, ...);
str 是一个指向字符数组的指针,表示要写入数据的目标字符串。
format 是一个格式化字符串,包含了要写入的数据的格式信息。
… 表示可变数量的参数,根据 format 字符串中的格式说明符,将数据写入到 str 中。
sprintf 函数的作用是根据指定的格式将数据写入到字符串中,类似于 printf 函数将输出打印到控制台,而 sprintf 则将输出写入到字符串中。
格式化字符串是一种特殊的字符串,其中包含格式说明符(format specifiers),用于指定在输出时如何格式化数据。在 C++ 中,格式化字符串通常用于将数据按照指定的格式输出到控制台、文件或其他目标。格式说明符以百分号(%)开头,后面紧跟着一个字符,用于指定要输出的数据类型或格式。一些常用的格式说明符包括:
%d:用于输出整数。
%f:用于输出浮点数。
%s:用于输出字符串。
%c:用于输出字符。
%x:用于输出十六进制整数。
%p:用于输出指针地址。
除了格式说明符外,格式化字符串中可能包含其他文本内容,用于在输出中显示固定文本或分隔符。
3.2.字符串指针变量/字符数组/字符指针数组
3.2.1 字符串指针变量/字符数组
示例代码:
int main(){
// 字符指针数组
char arr[15]="helloworld";
// 字符串指针
const char* str="helloworld";
int n1= strlen(arr);
int n2= strlen(str);
// 字符串常量
int n3= strlen("helloworld");
cout << n1 << endl;
cout << n2 << endl;
cout << n3<< endl;
cout << sizeof(arr) << endl;
cout << sizeof(str) << endl;
sizeof("helloworld")<< endl;
}
输出结果:
10
10
10
15
4
11
strlen 函数通过遍历给定的字符串,从字符串的开始一直检查到遇到字符串末尾的空字符 ‘\0’ 为止。
sizeof用于获取数据类型或变量在内存中所占的字节数。
对于字符数组,返回数组的大小。
对于字符串指针,返回指针变量的大小,在32位机器中即4。
对于字符串常量,返回整个常量的大小,包含结尾空字符,因为这也是常量占据空间的一部分。
3.2.2 字符串指针数组
#include <iostream>
int main() {
const char* strArray[] = {"Apple", "Banana", "Orange"}; // 字符指针数组
cout<<sizeof(strArray);
cout<<sizeof(strArray[0]);
return 0;
}
12
4
字符指针数组是一个数组,每个元素都是指向字符的指针,可以用来表示多个字符串。
每个指针指向一个以 null 结尾的字符数组,类似于字符串数组。
对字符串指针数组使用 sizeof 运算符会返回整个数组所占内存空间的大小,而不是数组中每个指针所指向的字符串的长度或大小。
第四章 数组
4.1 区间迭代有哪些
4.1.1 范围基础 for 循环(Range-based for loop):
C++11 引入的范围基础 for 循环是一种方便的区间迭代方式,用于遍历容器、数组或其他支持迭代器的数据结构中的元素。示例代码如下:
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (int num : numbers) {
std::cout << num << " ";
}
4.1.2 STL 算法:
C++ 标准模板库(STL)提供了许多算法,如 std::for_each、std::accumulate、std::transform 等,可以对容器进行区间迭代和操作。这些算法可以用于对容器中的元素进行处理,而无需显式的迭代。示例代码如下:
std::vector<int> numbers = {1, 2, 3, 4, 5};
std::for_each(
numbers.begin(), numbers.end(), [](int num) {
std::cout << num << " ";
}
);
4.1.3 迭代器循环:
使用迭代器(iterator)进行循环遍历容器中的元素。可以使用 begin() 和 end() 方法获取容器的起始和结束迭代器,然后进行循环遍历。示例代码如下:
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << *it << " ";
}
4.1.4 范围迭代器(Range-based iterators):
一些库和工具提供了范围迭代器,可以简化区间迭代的操作,使代码更加简洁和易读。示例代码如下:
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (const auto& num : boost::irange(1, 6)) {
std::cout << num << " ";
}
4.2 迭代器与数组有哪些异同
4.2.1 相同点:
- 元素访问: 迭代器和数组都可以用于访问数据结构中的元素。
- 循环遍历: 迭代器和数组都可以用于循环遍历数据结构中的元素。
4.2.2 不同点:
4.2.2.1 内存分配:
数组: 数组在编译时就需要确定其大小,因此在创建数组时需要分配一定大小的连续内存空间。
迭代器: 迭代器是用于遍历容器或数据结构的抽象概念,不需要提前分配内存,可以动态地指向容器中的元素。
4.2.2.2 动态大小:
数组: 数组的大小在创建时确定,通常是固定的,不能动态改变大小。
迭代器: 迭代器可以在遍历容器时根据需要自由移动,可以灵活地处理不同大小的容器。
4.2.2.3 容器适用性:
数组: 适用于需要固定大小且元素类型相同的数据集合。
迭代器: 适用于需要动态大小或元素类型不同的数据结构,如容器(vector、list、map 等)。
4.2.2.4 指针语义:
数组: 数组名本质上是指向数组首元素的指针,因此数组的名称可以被解释为指针。
迭代器: 迭代器是一个对象,它具有指向容器中元素的功能,但不一定是指针。
4.2.2.5 操作灵活性:
数组: 数组的操作相对简单,但不能直接与标准库算法等配合使用。
迭代器: 迭代器提供了更多的操作和灵活性,可以与算法、STL 容器等高效地结合使用。
4.3 比较stack、queue、优先队列
4.3.1 栈(Stack)
遵循后进先出(Last In, First Out,LIFO)的原则。这意味着最后被放入栈的元素将会最先被取出。栈通常支持两种基本操作:压入(Push)和弹出(Pop)。
4.3.1.1 栈的基本特点:
-
后进先出(LIFO):最后压入栈的元素将会最先被弹出。
-
只能在栈顶进行操作:栈只允许在栈顶进行元素的插入和删除操作,不允许在中间或底部进行操作,这保证了操作的高效性。
4.3.1.2 栈的基本操作:
-
Push(压入):将元素压入栈顶。
-
Pop(弹出):从栈顶移除元素,并返回该元素的值。
-
Top(查看栈顶元素):查看栈顶元素的值,但不对栈做任何修改。
4.3.1.3 栈的应用场景:
-
函数调用:函数调用时,局部变量和返回地址被压入栈,函数返回时再从栈中弹出这些信息。
-
表达式求值:中缀表达式转换为后缀表达式后,通过栈来实现后缀表达式的求值。
-
内存管理:栈内存用于存储函数调用时的局部变量和返回地址等信息。
4.3.1.4 栈的实现方式:
-
数组实现:使用数组来实现栈,通过指针来标记栈顶位置。
-
链表实现:使用链表来实现栈,每个节点存储栈中的一个元素。
4.3.1.5 C++ 中的栈
在 C++ 中,标准模板库(STL)提供了 std::stack
类模板,用于实现栈数据结构,同时也可以使用 std::vector
或 std::list
来实现栈的功能。
下面是一个简单的示例代码,演示如何使用 std::stack
实现栈的基本操作:
#include <iostream>
#include <stack>
int main() {
std::stack<int> myStack;
myStack.push(1); // Push 1 into the stack
myStack.push(2); // Push 2 into the stack
std::cout << "Top element: " << myStack.top() << std::endl;
myStack.pop(); // Pop the top element
std::cout << "Top element after pop: " << myStack.top() << std::endl;
return 0;
}
4.3.2 队列(Queue)
是一种常见的数据结构,它遵循先进先出(First In, First Out,FIFO)的原则。这意味着最先被放入队列的元素将会最先被取出。队列通常支持两种基本操作:入队(Enqueue)和出队(Dequeue)。
4.3.2.1 队列的基本特点:
-
先进先出(FIFO): 最先进入队列的元素将会最先被弹出。
-
只能在队列尾和头部进行操作: 队列只允许在队尾进行元素的插入(入队)操作,在队头进行元素的删除(出队)操作。
4.3.2.2 队列的基本操作:
-
Enqueue(入队): 将元素插入队列的尾部。
-
Dequeue(出队): 从队列的头部移除元素,并返回该元素的值。
-
Front(查看队头元素): 查看队列头部元素的值,但不对队列做任何修改。
4.3.2.3 队列的应用场景:
-
任务调度: 队列常用于实现任务调度,先进入队列的任务会先被执行。
-
广度优先搜索(BFS): 在图的广度优先搜索算法中,通常会使用队列来存储待访问节点。
-
缓冲区管理: 队列常用于实现缓冲区,确保数据按照先后顺序处理。
4.3.2.4 队列的实现方式:
-
数组实现: 使用数组来实现队列,通过两个指针来标记队头和队尾。
-
链表实现: 使用链表来实现队列,每个节点存储队列中的一个元素,并维护指向下一个节点的指针。
4.3.2.5 C++ 中的队列
在 C++ 中,标准模板库(STL)提供了 std::queue
类模板,用于实现队列数据结构,同时也可以使用 std::deque
或 std::list
来实现队列的功能。
下面是一个简单的示例代码,演示如何使用 std::queue
实现队列的基本操作:
#include <iostream>
#include <queue>
int main() {
std::queue<int> myQueue;
myQueue.push(1); // Enqueue 1 into the queue
myQueue.push(2); // Enqueue 2 into the queue
std::cout << "Front element: " << myQueue.front() << std::endl;
myQueue.pop(); // Dequeue the front element
std::cout << "Front element after dequeue: " << myQueue.front() << std::endl;
return 0;
}
4.3.3 优先队列(Priority Queue)
4.3.3.1 优先队列的特点:
-
按优先级排序: 优先队列中的元素按照优先级顺序排列,而非按照插入顺序或其他顺序。
-
最高优先级元素先出队: 在优先队列中,具有最高优先级的元素将会最先被弹出(Dequeue)。
4.3.3.2 优先队列的基本操作:
-
Insert(插入): 将元素插入到优先队列中,根据元素的优先级进行排序。
-
Delete-Max/Delete-Min(删除最大/最小元素): 从优先队列中删除并返回具有最高/最低优先级的元素。
4.3.3.3 优先队列的实现方式:
-
基于堆(Heap-based): 最常见的实现方式是使用堆(Heap)数据结构,通常是最大堆或最小堆来实现优先队列。
-
基于有序数组或有序链表: 也可以使用有序数组或有序链表来实现优先队列,但插入和删除操作的时间复杂度可能会受影响。
4.3.3.4 优先队列的应用场景:
-
任务调度: 用于按照优先级顺序执行任务,如操作系统中的进程调度。
-
最短路径算法: 在 Dijkstra 算法等最短路径算法中,优先队列用于选择下一个要访问的节点。
4.3.3.5 C++ 中的优先队列:
在 C++ 中,标准模板库(STL)提供了 std::priority_queue
类模板,用于实现优先队列数据结构。默认情况下,std::priority_queue
是一个最大堆,但可以通过自定义比较函数来实现最小堆。
下面是一个简单的示例代码,演示如何使用 std::priority_queue
实现优先队列的基本操作:
#include <iostream>
#include <queue>
int main() {
std::priority_queue<int> myPriorityQueue;
myPriorityQueue.push(3); // Insert element 3
myPriorityQueue.push(1); // Insert element 1
myPriorityQueue.push(4); // Insert element 4
std::cout << "Top element: " << myPriorityQueue.top() << std::endl;
myPriorityQueue.pop(); // Remove the top element
std::cout << "Top element after pop: " << myPriorityQueue.top() << std::endl;
return 0;
}
4.4 数组名和指向数组首元素的指针的区别
- 二者均可通过增减偏移量来访问数组中的元素。
- 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。
- 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了。
第五章 树
5.1 介绍二叉搜索树
5.1.1 二叉搜索树的性质
- 若它的左子树不空,则左子树上所有结点的值均小于它根结点的值。
- 若它的右子树不空,则右子树上所有结点的值均大于它根结点的值。
- 它的左、右树又分为二叉排序树。
5.1.2 二叉搜索树的构建(插入)
- 只要左子树为空,就把小于父节点的数插入作为左子树
- 只要右子树为空,就把大于父节点的数插入作为右子树
- 如果不为空,就一直往下去搜索,直到找到合适的插入位置
void insert(int key)
{
//定义一个临时指针 用于移动
Node* temp = root;//方便移动 以及 跳出循环
Node* prev = NULL;//定位到待插入位置的前一个结点
while (temp != NULL)
{
prev = temp;
if (key < temp->data)
{
temp = temp->left;
}
else if(key > temp->data)
{
temp = temp->right;
}
else
{
return;
}
}
if (key < prev->data)
{
prev->left = (Node*)malloc(sizeof(Node));
prev->left->data = key;
prev->left->left = NULL;
prev->left->right = NULL;
}
else
{
prev->right = (Node*)malloc(sizeof(Node));
prev->right->data = key;
prev->right->left = NULL;
prev->right->right = NULL;
}
}
5.1.3 二叉排序树的查找
/*查找元素key*/
bool search(Node* root, int key)
{
while (root != NULL)
{
if (key == root->data)
return true;
else if (key < root->data)
root = root->left;
else
root = root->right;
}
return false;
}
5.1.4 二叉排序树的删除
- 被删除结点为叶子结点
直接从二叉排序中删除即可,不会影响到其他结点。
- 被删除结点D仅有一个孩子
- 如果只有左孩子,没有右孩子,那么只需要把要删除结点的左孩子连接到要删除结点的父亲结点,然后删除D结点;
- 如果只有右孩子,没有左孩子,那么只要将要删除结点D的右孩子连接到要删除结点D的父亲结点,然后删除D结点。
以下图为例,D=14没有右孩子,只有左孩子。(先把10指向14的右指针移动,去指向13,然后再删除14)。
再以D=10为例,它没有左孩子,只有右孩子。(先把8指向10的右指针移动,去指向14,然后再删除10)
- 被删除结点左右孩子都在
找到当前节点的左子树的“最右边”节点,或右子树的“最左边”。转化为删除新的元素,直到删除的节点是叶子节点。
int delete_node(Node* node, int key)
{
if (node == NULL)
{
return -1;
}
else
{
if (node->data == key)
{
//当我执行删除操作 需要先定位到删除结点的前一个结点(父节点)
Node* tempNode = prev_node(root, node, key);
Node* temp = NULL;
//如果右子树为空,只需要重新连接结点(包含叶子结点),直接删除
if (node->right == NULL)
{
temp = node;
node = node->left;
/*判断待删除结点是前一个结点的左边还是右边*/
if (tempNode->left->data == temp->data)
{
Node* free_node = temp;
tempNode->left = node;
free(free_node);
free_node = NULL;
}
else
{
Node* free_node = temp;
tempNode->right = node;
free(free_node);
free_node = NULL;
}
}
else if (node->left == NULL)
{
temp = node;
node = node->right;
if (tempNode->left->data == temp->data)
{
Node* free_node = temp;
tempNode->left = node;
free(free_node);
free_node = NULL;
}
else
{
Node* free_node = temp;/
tempNode->right = node;
free(free_node);
free_node = NULL;
}
}
else//左右子树都不为空
{
temp = node;
/*往左子树 找最大值*/
Node* left_max = node;//找最大值的临时指针
left_max = left_max->left;//先到左孩子结点
while (left_max->right != NULL)
{
temp = left_max;
left_max = left_max->right;
}
node->data = left_max->data;
if (temp != node)
{
temp->right = left_max->left;
free(left_max);
left_max = NULL;
}
else
{
temp->left = left_max->left;
free(left_max);
left_max = NULL;
}
}
}
else if(key < node->data)
{
delete_node(node->left, key);
}
else if (key > node->data)
{
delete_node(node->right, key);
}
}
}
5.1.5 完整代码
#include<stdio.h>
#include<stdlib.h>
typedef struct SortTree {
int data;//存放数据的数据域
struct SortTree* left;//指针域 左指针
struct SortTree* right;//指针域 右指针
}Node;
/*全局变量*/
Node* root;//根节点
void Init(int);//初始化操作
void insert(int);//插入操作
void show(Node*);
int delete_node(Node*, int);
Node* prev_node(Node*, Node*, int);
bool search(Node* root, int key);
int main()
{
Init(8);
insert(4);
insert(2);
insert(5);
insert(10);
insert(9);
insert(13);
show(root);
delete_node(root, 8);
delete_node(root, 13);
printf("\n");
show(root);
}
/*初始化根节点
int key : 根节点的值
*/
void Init(int key)
{
root = (Node*)malloc(sizeof(Node));
root->data = key;
root->left = NULL;
root->right = NULL;
}
void insert(int key)
{
//定义一个临时指针 用于移动
Node* temp = root;//方便移动 以及 跳出循环
Node* prev = NULL;//定位到待插入位置的前一个结点
while (temp != NULL)
{
prev = temp;
if (key < temp->data)
{
temp = temp->left;
}
else if(key > temp->data)
{
temp = temp->right;
}
else
{
return;
}
}
if (key < prev->data)
{
prev->left = (Node*)malloc(sizeof(Node));
prev->left->data = key;
prev->left->left = NULL;
prev->left->right = NULL;
}
else
{
prev->right = (Node*)malloc(sizeof(Node));
prev->right->data = key;
prev->right->left = NULL;
prev->right->right = NULL;
}
}
void show(Node* root)
{
if (root == NULL)
{
return;
}
show(root->left);
printf("%d ", root->data);
show(root->right);
}
/*查找元素key*/
bool search(Node* root, int key)
{
while (root != NULL)
{
if (key == root->data)
return true;
else if (key < root->data)
root = root->left;
else
root = root->right;
}
return false;
}
int delete_node(Node* node, int key)
{
if (node == NULL)
{
return -1;
}
else
{
if (node->data == key)
{
//当我执行删除操作 需要先定位到前一个结点
Node* tempNode = prev_node(root, node, key);
Node* temp = NULL;
/*
如果右子树为空 只需要重新连接结点
叶子的情况也包含进去了 直接删除
*/
if (node->right == NULL)
{
temp = node;
node = node->left;
/*为了判断 待删除结点是前一个结点的左边还是右边*/
if (tempNode->left->data == temp->data)
{
Node* free_node = temp;//释放用的指针
tempNode->left = node;
free(free_node);
free_node = NULL;
}
else
{
Node* free_node = temp;//释放用的指针
tempNode->right = node;
free(free_node);
free_node = NULL;
}
}
else if (node->left == NULL)
{
temp = node;
node = node->right;
if (tempNode->left->data == temp->data)
{
Node* free_node = temp;//释放用的指针
tempNode->left = node;
free(free_node);
free_node = NULL;
}
else
{
Node* free_node = temp;//释放用的指针
tempNode->right = node;
free(free_node);
free_node = NULL;
}
}
else//左右子树都不为空
{
temp = node;
/*往左子树 找最大值*/
Node* left_max = node;//找最大值的临时指针
left_max = left_max->left;//先到左孩子结点
while (left_max->right != NULL)
{
temp = left_max;
left_max = left_max->right;
}
node->data = left_max->data;
if (temp != node)
{
temp->right = left_max->left;
free(left_max);
left_max = NULL;
}
else
{
temp->left = left_max->left;
free(left_max);
left_max = NULL;
}
}
}
else if(key < node->data)
{
delete_node(node->left, key);
}
else if (key > node->data)
{
delete_node(node->right, key);
}
}
}
/*定位到待删除节点的前一个结点
Node* root 从根节点开始
Node* node 待删除的结点
int key 待删除数据
*/
Node* prev_node(Node* root, Node* node, int key)
{
if (root == NULL || node == root)
{
return node;
}
else
{
if (root->left != NULL && root->left->data == key)
{
return root;
}
else if(root->right != NULL && root->right->data == key)
{
return root;
}
else if (key < root->data)
{
return prev_node(root->left, node, key);
}
else
{
return prev_node(root->right, node, key);
}
}
}
5.2 介绍平衡二叉树AVL
参考:
5.2.1 AVL树的性质
一棵AVL树或者是空树,或者是具有以下性质的二叉搜索树:
-
它的左右子树都是AVL树
-
左右子树高度之差(简称平衡因子)的绝对值不超过1(-1/0/1)
5.2.2 AVL树节点的定义
template<class T>
struct AVLTreeNode
{
AVLTreeNode(const T& data)
: _pLeft(nullptr), _pRight(nullptr), _pParent(nullptr)
, _data(data), _bf(0)
{}
AVLTreeNode<T>* _pLeft; // 该节点的左孩子
AVLTreeNode<T>* _pRight; // 该节点的右孩子
AVLTreeNode<T>* _pParent; // 该节点的双亲
T _data;
int _bf; // 该节点的平衡因子
};
5.2.3 AVL树的插入
AVL树的插入过程可以分为两步:
-
按照二叉搜索树的方式插入新节点
-
调整节点的平衡因子
bool Insert(const T& data)
{
// 1. 先按照二叉搜索树的规则将节点插入到AVL树中
//
// 2. 新节点插入后,AVL树的平衡性可能会遭到破坏,此时就需要更新平衡因子,并检测是否
破坏了AVL树
// 的平衡性
/*
pCur插入后,pParent的平衡因子一定需要调整,在插入之前,pParent
的平衡因子分为三种情况:-1,0, 1, 分以下两种情况:
1. 如果pCur插入到pParent的左侧,只需给pParent的平衡因子-1即可
2. 如果pCur插入到pParent的右侧,只需给pParent的平衡因子+1即可
此时:pParent的平衡因子可能有三种情况:0,正负1, 正负2
1. 如果pParent的平衡因子为0,说明插入之前pParent的平衡因子为正负1,插入后被调整
成0,此时满足
AVL树的性质,插入成功
2. 如果pParent的平衡因子为正负1,说明插入前pParent的平衡因子一定为0,插入后被更
新成正负1,此
时以pParent为根的树的高度增加,需要继续向上更新
3. 如果pParent的平衡因子为正负2,则pParent的平衡因子违反平衡树的性质,需要对其进
行旋转处理
*/
while (pParent)
{
// 更新双亲的平衡因子
if (pCur == pParent->_pLeft)
pParent->_bf--;
else
pParent->_bf++;
// 更新后检测双亲的平衡因子
if (0 == pParent->_bf)
{
break;
}
else if (1 == pParent->_bf || -1 == pParent->_bf)
{
// 插入前双亲的平衡因子是0,插入后双亲的平衡因为为1 或者 -1 ,说明以双亲
为根的二叉树
// 的高度增加了一层,因此需要继续向上调整
pCur = pParent;
pParent = pCur->_pParent;
}
else
{
// 双亲的平衡因子为正负2,违反了AVL树的平衡性,需要对以pParent
// 为根的树进行旋转处理
if(2 == pParent->_bf)
{
// ...
}
else
{
// ...
}
}
}
return true;
}
5.2.4 AVL树的旋转
如果在一棵原本是平衡的AVL树中插入一个新节点,可能造成不平衡,此时必须调整树的结构,使之平衡化。根据节点插入位置的不同,AVL树的旋转分为四种:
5.2.4.1 新节点插入较高左子树的左侧—左左:右单旋
上图在插入前,AVL树是平衡的,新节点插入到30的左子树(注意:此处不是左孩子)中,30左子树增加了一层,导致以60为根的二叉树不平衡,要让60平衡,只能将60左子树的高度减少一层,右子树增加一层,即将左子树往上提,这样60转下来,因为60比30大,只能将其放在30的右子树,而如果30有右子树,右子树根的值一定大于30,小于60,只能将其放在60的左子树,旋转完成后,更新节点的平衡因子即可。在旋转过程中,有以下几种情况需要考虑:
- 30节点的右孩子可能存在,也可能不存在
- 60可能是根节点,也可能是子树
如果是根节点,旋转完成后,要更新根节点
如果是子树,可能是某个节点的左子树,也可能是右子树
void _RotateR(PNode pParent)
{
// pSubL: pParent的左孩子
// pSubLR: pParent左孩子的右孩子,注意:该
PNode pSubL = pParent->_pLeft;
PNode pSubLR = pSubL->_pRight;
// 旋转完成之后,30的右孩子作为双亲的左孩子
pParent->_pLeft = pSubLR;
// 如果30的左孩子的右孩子存在,更新亲双亲
if(pSubLR)
pSubLR->_pParent = pParent;
// 60 作为 30的右孩子
pSubL->_pRight = pParent;
// 因为60可能是棵子树,因此在更新其双亲前必须先保存60的双亲
PNode pPParent = pParent->_pParent;
// 更新60的双亲
pParent->_pParent = pSubL;
// 更新30的双亲
pSubL->_pParent = pPParent;
// 如果60是根节点,根新指向根节点的指针
if(NULL == pPParent)
{
_pRoot = pSubL;
pSubL->_pParent = NULL;
}
else
{
// 如果60是子树,可能是其双亲的左子树,也可能是右子树
if(pPParent->_pLeft == pParent)
pPParent->_pLeft = pSubL;
else
pPParent->_pRight = pSubL;
}
// 根据调整后的结构更新部分节点的平衡因子
pParent->_bf = pSubL->_bf = 0;
}
5.2.4.2 新节点插入较高右子树的右侧—右右:左单旋
情况与右单旋相似
5.2.4.3 新节点插入较高左子树的右侧—左右:先左单旋再右单旋
将双旋变成单旋后再旋转,即:先对30进行左单旋,然后再对90进行右单旋,旋转完成后再考虑平衡因子的更新
// 旋转之前,60的平衡因子可能是-1/0/1,旋转完成之后,根据情况对其他节点的平衡因子进行调整
void _RotateLR(PNode pParent)
{
PNode pSubL = pParent->_pLeft;
PNode pSubLR = pSubL->_pRight;
// 旋转之前,保存pSubLR的平衡因子,旋转完成之后,需要根据该平衡因子来调整其他节
点的平衡因子
int bf = pSubLR->_bf;
// 先对30进行左单旋
_RotateL(pParent->_pLeft);
// 再对90进行右单旋
_RotateR(pParent);
if(1 == bf)
pSubL->_bf = -1;
else if(-1 == bf)
pParent->_bf = 1;
}
5.2.4.4 新节点插入较高右子树的左侧—右左:先右单旋再左单旋
参考右左双旋。
5.2.4.5 旋转总结
假如以pParent为根的子树不平衡,即pParent的平衡因子为2或者-2,分以下情况考虑
-
pParent的平衡因子为2,说明pParent的右子树高,设pParent的右子树的根为pSubR
当pSubR的平衡因子为1时,执行左单旋
当pSubR的平衡因子为-1时,执行右左双旋 -
pParent的平衡因子为-2,说明pParent的左子树高,设pParent的左子树的根为pSubL
当pSubL的平衡因子为-1是,执行右单旋
当pSubL的平衡因子为1时,执行左右双旋
旋转完成后,原pParent为根的子树个高度降低,已经平衡,不需要再向上更新
5.3 介绍红黑树
参考:
5.3.1 红黑树的几个概念:
- parent:父节点
- sibling:兄弟节点
- uncle:叔父节点( parent 的兄弟节点)
- grand:祖父节点( parent 的父节点)
5.3.2 红黑树特性
首先,红黑树是一个二叉搜索树,它在每个节点增加了一个存储位记录节点的颜色,可以是RED,也可以是BLACK;通过任意一条从根到叶子简单路径上颜色的约束,红黑树保证最长路径不超过最短路径的二倍,因而近似平衡(最短路径就是全黑节点,最长路径就是一个红节点一个黑节点,当从根节点到叶子节点的路径上黑色节点相同时,最长路径刚好是最短路径的两倍)。
它同时满足以下特性:
- 节点是红色或黑色
- 根是黑色
- 叶子节点(外部节点,空节点)都是黑色,这里的叶子节点指的是最底层的空节点(外部节点),下图中的那些null节点才是叶子节点,null节点的父节点在红黑树里不将其看作叶子节点
- 从根节点到叶子节点的所有路径上不能有 2 个连续的红色节点。即红色节点的子节点都是黑色,红色节点的父节点都是黑色。
- 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
5.3.2 红黑树的效率
红黑树的查找,插入和删除操作,时间复杂度都是O(logN)。
查找操作时,它和普通的相对平衡的二叉搜索树的效率相同,都是通过相同的方式来查找的,没有用到红黑树特有的特性。
但如果插入的时候是有序数据,那么红黑树的查询效率就比二叉搜索树要高了,因为此时二叉搜索树不是平衡树,它的时间复杂度O(N)。
插入和删除操作时,由于红黑树的每次操作平均要旋转一次和变换颜色,所以它比普通的二叉搜索树效率要低一点,不过时间复杂度仍然是O(logN)。总之,红黑树的优点就是对有序数据的查询操作不会慢到O(logN)的时间复杂度。
5.3.3 红黑树和AVL树的比较
AVL树的时间复杂度虽然优于红黑树,但是对于现在的计算机,cpu太快,可以忽略性能差异
红黑树的插入删除比AVL树更便于控制操作
红黑树整体性能略优于AVL树(红黑树旋转情况少于AVL树)
5.3.4 红黑树的等价变换
上面这颗红黑树,我们来将所有的红色节点上移到和他们的父节点同一高度上,就会形成如下结构
这个结构很明显,就是一棵四阶B树(一个节点最多放三个数据),如果画成如下的样子大家应该就能看的更清晰了。
由上面的等价变换我们就可以得到如下结论:
红黑树 和 4阶B树(2-3-4树)具有等价性
黑色节点与它的红色子节点融合在一起,形成1个B树节点
红黑树的黑色节点个数 与 4阶B树的节点总个数相等
在所有的B树节点中,永远是黑色节点是父节点,红色节点是子节点。黑色节点在中间,红色节点在两边。
5.3.5 红黑树的操作
参考:
更详细版本(未参考)
5.3.5.1 旋转操作
旋转操作分为左旋和右旋,左旋是将某个节点旋转为其右孩子的左孩子,而右旋是节点旋转为其左孩子的右孩子。如图:
以右旋为例进行说明,右旋节点 M 的步骤如下:
将节点 M 的左孩子引用指向节点 E 的右孩子
将节点 E 的右孩子引用指向节点 M,完成旋转
5.3.5.1 插入操作
红黑树的插入可分为两步:
-
按照二叉搜索的树规则插入新节点
-
检测新节点插入后,红黑树的性质是否造到破坏(重点)
新节点的默认颜色是红色,因此:如果其双亲节点的颜色是黑色,没有违反红黑树任何性质,则不需要调整。
但当新插入节点的双亲节点颜色为红色时,就违反了性质三不能有连在一起的红色节点,所以我们需要介入调整的情况双亲节点(父节点)和插入的节点都为红。
此时需要对红黑树分情况来讨论,假设cur为当前节点,p为父节点(parent),g为祖父节点(granparent),u为叔叔节点(uncle):
情况一:cur为红,p为红,g为黑,u存在且为红
解析:在保证局部的每条路径的黑色节点数相同的前提,直接变色,由于修改了g节点颜色,有可能g节点的父节点为红,所以将g当作插入的红节点,向上循环调整直到g的父节点为黑色
解决方法:将p,u改为黑,g改为红,然后把g当成cur,继续向上调整。(变色)
情况二:cur为红,p为红,g为黑,u不存在/u存在且为黑
解析:从局部图发现插入前,p节点所在路径和u节点所在路径的黑色节点数不同,所以此时想直接通过变色就不行了,所以这里需要旋转 + 变色实现。
解决方法(单旋 + 变色):p为g的左孩子,cur为p的左孩子,则进行右单旋转;相反,p为g的右孩子,cur为p的右孩子,则进行左单旋转p、g变色–p变黑,g变红。
情况三: cur为红,p为红,g为黑,u不存在/u存在且为黑
解析:这种情况其实就是情况二的变异版,我们先通过旋转p,cur节点(看清楚了不是旋转g节点)变回情况二就可以借助情况二的方法解决了(啥?反骨又来了,觉得变情况二麻烦,想直接旋转 + 变色解决,你试试如果直接旋转的话,能不能把g、p、cur这三个节点的树形状捋直)
解决方法(双旋 + 变色):
p为g的左孩子,cur为p的右孩子,则针对p做左单旋转;相反,
p为g的右孩子,cur为p的左孩子,则针对p做右单旋转
则转换成了情况2
————————————————
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.youkuaiyun.com/qq_68140277/article/details/129686468
插入部分的代码:
bool Insert(const T& kv)
{
//根
if (_data == nullptr)
{
_data = new node(kv);
_data->_colour = BLACK;
return true;
}
//寻找合适的插入位置
node* parent = nullptr;
node* cur = _data;
while (cur)
{
if (cur->_data < kv)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv > kv)
{
parent = cur;
cur = cur->_left;
}
else//相等
return false;
}
//插入
cur = new node(kv);
cur->_parent = parent;
if (parent->_kv.frist < kv)
parent->_right = cur;
else
parent->_left = cur;
//如果插入的cur的父节点为黑则不用调整
//调整(父节点为红 -> 祖父结点为“黑”)
//-> 新插入节点(cur)、父节点(parent)、祖父节点(grandfather)都确定且都为红
//只有2个条件不确定:
//1:cur插入的是父节点的左右子树
//2:uncle(叔叔)节点的颜色/是否存在
while (parent && parent->_colour == RED)
{
node* grandfather = parent->_parent;
if (grandfather->_left == parent)
{
node* uncle = grandfather->_right;
//情况1:uncle为红色 -> 此时只有祖父节点为红色 + cur在parent2边插入都可以
if (uncle && uncle->_colour == RED)
{
//直接变色
grandfather->_colour = RED;
parent->_colour = BLACK;
uncle->_colour = BLACK;
}
//情况2、3:uncle为黑色/或不存在 + cur在parent左/右边插入
else
{
//情况2:左边插入 -> 右旋 + 变色
if (cur == parent->_left)
{
RoateR(grandfather);
parent->_colour = BLACK;
grandfather->_colour = RED;
}
//情况3:右边插入 -> 左旋 变为情况2 -> 右旋(双旋)+ 变色
else
{
RoateL(parent);
RoateR(grandfather);
cur->_colour = BLACK;
grandfather->_colour = RED;
}
break;
}
}
else//右
{
node* uncle = grandfather->_left;
if (uncle && uncle->_colour == RED)
{
//直接变色
grandfather->_colour = RED;
parent->_colour = BLACK;
uncle->_colour = BLACK;
}
else
{
if (cur == parent->_left)
{
RoateL(grandfather);
parent->_colour = BLACK;
grandfather->_colour = RED;
}
//情况3:右边插入 -> 左旋 变为情况2 -> 右旋(双旋)+ 变色
else
{
RoateR(parent);
RoateL(grandfather);
cur->_colour = BLACK;
grandfather->_colour = RED;
}
break;
}
}
}
_data->_colour = BLACK;
return true;
}
第六章 面向对象编程
6.1 final标识符的作用是什么?
放在类的后面表示该类无法被继承,也就是阻止了从类的继承,放在虚函数后面该虚函数无法被重写,表示阻止虚函数的重载。
6.2 虚函数是怎么实现的?它存放在哪里在内存的哪个区?什么时候生成的
在C++中,虚函数的实现原理基于两个关键概念:虚函数表和虚函数指针
虚函数表:每个包含虚函数的类都会生成一个虚函数表,其中存储着该类中所有虚函数的地址。虚函数表是一个由指针构成的数组,每个指针指向一个虚函数的实现代码。
虚函数指针:在对象的内存布局中,编译器会添加一个额外的指针,称为虚函数指针或虚表指针。这个指针指向该对象对应的虚函数表,从而让程序能够动态的调用虚函数。
当一个基类指针或引用调用虚函数时,编译器会使用虚表指针来查找该对象对应的虚函数表,并根据函数在虚函数表中的位置来调用正确的虚函数。
在编译阶段生成,虚函数和普通函数一样存放在代码段,只是它的指针又存放在了虚表之中。
6.3 匿名函数(lambda函数)
参考:
6.3.1 匿名函数的本质是什么?他的优点是什么?
匿名函数本质上是一个对象,在其定义的过程中会创建出一个栈对象,内部通过重载()符号实现函数调用的外表。
优点:使用匿名函数,可以免去函数的声明和定义。这样匿名函数仅在调用函数的时候才会创建函数对象,而调用结束后立即释放,所以匿名函数比非匿名函数更节省空间。
6.3.2 概述
匿名函数(英文名:lambda)就是没有名字的函数。最简单的匿名函数是{},它没有参数也没有返回值。匿名函数,也称lambda函数或lambda表达式;
在匿名函数中,[]里面用来捕获函数外部的变量,而()里面就是匿名函数的参数,{}里面就是函数的执行代码。
基础示例:
#include <iostream>
using namespace std;
int main()
{
auto func = [] () { cout << "Hello world"; };
func(); // now call the function
}
基本形式:
[capture](parameters)->return-type{body}
对应例子:
[](int x, int y) -> int { int z = x + y; return z; }
部分省略:
[](int x, int y) { return x + y; } // 隐式返回类型
[](int& x) { ++x; } // 没有return语句 -> lambda 函数的返回类型是'void'
[]() { ++global_x; } // 没有参数,仅访问某个全局变量
[]{ ++global_x; } // 与上一个相同,省略了()
6.3.3 Lambda函数中的变量截取
Lambda函数可以引用在它之外声明的变量. 这些变量的集合叫做一个闭包. 闭包被定义在Lambda表达式声明中的方括号[]内. 这个机制允许这些变量被按值或按引用捕获.下面这些例子就是:
[] //不截取任何变量,试图在Lambda内使用任何外部变量都是错误的(全局变量除外).
[&] //截取外部作用域中所有变量,并作为引用在函数体中使用
[=] //截取外部作用域中所有变量,并拷贝一份在函数体中使用
[=, &foo] //截取外部作用域中所有变量,并拷贝一份在函数体中使用,但是对foo变量使用引用
[bar] //截取bar变量并且拷贝一份在函数体重使用,同时不截取其他变量
[this] //截取当前类中的this指针。如果已经使用了&或者=就默认添加此选项。
-----------------------------
[x, &y] //x 按值捕获, y 按引用捕获.
[&, x] //x显式地按值捕获. 其它变量按引用捕获
[=, &z] //z按引用捕获. 其它变量按值捕获
6.3.4 Lambda函数和STL
对于vector的遍历,可以将原始方法改为lamda函数的形式,并且编译器有可能使用“循环展开”进行加速。
//vector<int> v;
//原始方法
for ( auto itr = v.begin(), end = v.end(); itr != end; itr++ )
{
cout << *itr;
}
//lamda
for_each( v.begin(), v.end(), [] (int val)
{
cout << val;
} );
6.4 父类的构造函数和析构函数是否能为虚函数?这样操作导致的结果?
构造函数不能为虚函数,虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的vptr指针指向,该指针存放在对象的内部空间之中,需要调用构造函数完成初始化,如果构造函数为虚函数,那么调用构造函数就需要去寻找vptr,但此时vptr还没有完成初始化,导致无法构造对象。
析构函数可以且经常为虚函数:当我们使用父类指针指向子类时,只会调用父类的析构函数,子类的析构函数不会被调用,容易造成内存泄漏。
第七章 RAII
7.1 简介
RAII(Resource Acquisition Is Initialization)是一种 C++ 编程中的重要设计理念,它指的是资源获取即初始化。RAII 的核心思想是通过对象的生命周期来管理资源的获取和释放,确保资源在对象创建时被分配,而在对象销毁时被释放,从而避免资源泄漏和提高代码的可靠性和安全性。
RAII 技术的基本原则是:将资源的获取和释放的代码封装在一个类的构造函数和析构函数中。在对象构造时,资源被获取并初始化;在对象析构时,资源被释放。这样,无论是正常退出还是异常退出,都可以确保资源被正确地释放,避免了资源泄漏和错误。
常见的应用 RAII 的场景包括:
-
智能指针:如 unique_ptr、shared_ptr 等,在对象销毁时自动释放所管理的动态内存资源。
-
文件操作:通过 RAII 可以在对象构造时打开文件,在对象析构时关闭文件,确保文件资源被正确释放。
-
互斥量:如 std::lock_guard,在对象的生命周期内管理互斥量的加锁和解锁,确保线程安全。
RAII 是 C++ 中一种重要的资源管理方式,它使得代码更加健壮、可维护,并且有助于避免资源泄漏和提高程序的安全性。
7.1 智能指针
见本文2.1节。
2.1 智能指针
7.2 互斥量
7.2.1 简介
在 C++11 中,为了方便实现自动加锁和解锁的操作,提供了 lock_guard 类模板。它是一个轻量级的 RAII(资源获取即初始化)类,用于在作用域结束时自动释放互斥锁。
std::lock_guard 用于管理互斥锁的加锁和解锁操作。它的主要作用是在构造函数中获取一个互斥锁,然后在析构函数中自动释放该锁,以确保在锁保护区域的结束时正确解锁。
std::lock_guard 的作用是获取互斥量的锁,并在作用域结束时自动释放锁。这样可以避免手动管理锁的复杂性和风险,同时也可以确保在使用共享资源时不会被其他线程打断。
简单来说就是使用 std::lock_guard 让开发者使用时不用关心 std::mutex 锁的释放。
7.1.2 使用例子
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx; // the mutex to protect the shared resource
void thread_function()
{
//访问临界区域资源
// 主动设置局部作用域
{
// 在构造函数中调用 lock()
std::lock_guard<std::mutex> lock(mtx);
std::cout << "Thread " << std::this_thread::get_id() << " is accessing the shared resource." << std::endl;
// access the shared resource here...
}
//离开作用域,lock_guard会调用其析构函数,会自动调用 unlock()
//other code
}
int main()
{
std::thread t1(thread_function);
std::thread t2(thread_function);
t1.join();
t2.join();
return 0;
}
第八章 关键词整合
8.1 define和const的区别是什么?
编译阶段:define是在编译预处理阶段进行简单的文本替换,const是在编译阶段确定其值
安全性:define定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全检查;const定义的常量是有类型的,是要进行类型判断的
内存占用:define定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段的内存;const定义常量占用静态存储区域的空间,程序运行过程中只有一份
调试:define定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const定义的常量是可以进行调试的。
8.2 class与struct的区别
默认继承权限不同:class默认继承的是private继承,struct默认是public继承。
Class还可用于定义模板参数,但是关键字struct不能同于定义模板参数,C++保留struct关键字,原因是保证与C语言的向下兼容性,为了保证百分百的与C语言中的struct向下兼容,,C++把最基本的对象单元规定为class而不是struct,就是为了避免各种兼容性的限制。
第九章 计算机系统相关
9.1 class与struct的区别
默认继承权限不同:class默认继承的是private继承,struct默认是public继承。
Class还可用于定义模板参数,但是关键字struct不能同于定义模板参数,C++保留struct关键字,原因是保证与C语言的向下兼容性,为了保证百分百的与C语言中的struct向下兼容,,C++把最基本的对象单元规定为class而不是struct,就是为了避免各种兼容性的限制。
9.2 内存对齐是什么?为什么要进行内存对齐?内存对齐有什么好处?
内存对齐是处理器为了提高处理性能而对存取数据的起始地址所提出的一种要求。
移植性:有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定的地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时将进行对齐,这就具有平台的移植性。
效率:CPU每次寻址有时需要消耗时间的,并且CPU访问内存的时候并不是逐个字节访问,而是以字长为单位访问,所以数据结构应该尽可能地在自然边界上对齐,如果访问未对齐内存,处理器需要做多次内存访问,而对齐的内存访问可以减少访问次数,提升性能。
优点:提高程序的运行效率,增强程序的可移植性。
9.3 进程之间的通信方式有哪些?
1. 管道(Pipes)
管道分为匿名管道和命名管道,管道本质上是一个内核中的一个缓存,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端。缺点:半双工通信,一个管道只能一个进程写,一个进程读。不适合进程间频繁的交换数据
匿名管道:用于具有亲缘关系的进程(如父子进程)之间的通信。数据在管道中是单向流动的,一个进程写入,一个进程读取。
命名管道:可以在无亲缘关系的进程之间进行通信,通过一个特定的名称来访问管道。
2. 消息队列(Message Queues)
消息队列允许进程以消息的形式进行通信。每条消息都会包含一个头部和数据部分,进程可以选择读取特定类型的消息。
支持优先级,可以设置消息的优先级,优先级高的消息会被优先处理。
3. 共享内存(Shared Memory)
共享内存是将一块内存区域映射到多个进程的地址空间中,多个进程可以直接读写这块内存。
速度快,但需要同步机制(如信号量)来避免并发访问带来的问题。
4. 信号量(Semaphores)
信号量是一种用于控制对共享资源访问的同步机制。它可以用于实现进程间的互斥和同步。
信号量可以是计数信号量(用于控制资源数量)或二进制信号量(通常用于互斥锁)。
5. 套接字(Sockets)
套接字是一种网络通信机制,可以在同一台计算机上的不同进程之间,或在不同计算机上的进程之间进行通信。
支持流式(TCP)和数据报(UDP)两种模式,可以进行双向通信。
6. 文件(Files)
进程可以通过读写文件来进行通信。这种方式相对简单,但速度较慢,且需要处理文件的访问权限和同步问题。
7. 信号(Signals)
信号是一种异步通知机制,进程可以通过发送信号给其他进程来通知某些事件的发生(如终止、暂停等)。
适合用于处理事件驱动的场景。
9.4 线程之间的通信方式有哪些?
信号量
条件变量
互斥量
9.5 介绍一下socket中的多路复用,及其他们的优缺点,epoll的水平和边缘触发模式
select、poll、epoll都是IO多路复用的一种机制,可以监视多个文件描述符,一旦某个文件描述符进入读或写就绪状态,就能够通知系统进行相应的读写操作。
Select优点:可移植性好,因为在某些Unix系统中并不支持poll和epoll.对于超时时间提供了更好的精度:微妙,而poll和epoll都是毫秒级
Select缺点:支持监听的文件描述符fd的数量有限制,最大数量默认是1024个
Select需要维护一个用来存放文件描述符的数据结构,每次调用select都需要把fd集合从用户区拷贝到内核区,而select系统调用后有需要把fd集合从内核区拷贝到用户区,这个系统开销在fd数量很多的时候会很大。
Poll优点(相对于select而言):没有最大文件描述符数量的限制,poll基于链表存储主要解决了这个最大文件描述符数量的限制(当然,他还是有限制的,上限为操作系统能支持的能开启的最大文件描述符数量),优化了编程接口,减少了函数调用参数,并且,每次调用select函数时,都必须重置该函数的三个fd_set类型的参数值,而poll不需要重置。
Poll缺点:poll和select一样同样都需要维护一个用来存放文件描述符的数据结构,当注册的文件描述符无限多时,会使得用户态和内核区之间传递该数据结构的复制开销很大。每次poll系统调用时,需要把文件描述符fd从用户态拷贝到内核区,然后poll系统调用返回前,又需要把文件描述符fd集合从内核区拷贝到用户区,这个内存拷贝的系统开销在fd数量很多的时候会很大。
Epoll优点:和poll一样没有最大文件描述符数量的限制,epoll虽然也需要维护用来存放文件描述符的数据结构(epoll_event),但是它只需要将该数据结构拷贝一次,不需要重复拷贝,并且它只在调用epoll_ctl系统调用时拷贝一次要监听的文件描述符数据结构到内核区,在调用epoll_wait的时候不需要再把所有的要监听的文件描述符重复拷贝进内核区,这就解决了select和poll种内存复制开销的问题。
Epoll缺点:目前只有Linux操作系统支持epoll,不支持跨平台使用,而Unix操作系统上是使用kqueue
Epoll水平触发(LT):对于读操作,只要缓冲区内容不为空,LT模式返回读就绪。
对于写操作,只要缓冲区还不满,LT模式会返回写就绪。
Epoll边缘触发(ET):对于读操作,当缓冲区由不可读变为可读的时候,有新数据到达时,进程修改了EPOLL_CTL_MOD修改EPOLLIN事件时
在ET模式下,缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。通常配合将文件描述符设置为非阻塞状态一起使用,这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。