C++ 语言基础篇

1、说⼀下你理解的 C++ 中的四种智能指针

⾯试官你好,⾸先,说⼀下为什么要使⽤智能指针:智能指针其作⽤是管理⼀个指针,避免程序员申请的空间在函数结束时忘记释放,造成内存泄漏这种情况的发⽣。

然后使⽤智能指针可以很⼤程度上的避免这个问题,因为智能指针就是⼀个类,当超出了类的作⽤域是,类会⾃动调⽤析构函数,析构函数会⾃动释放资源。所以智能指针的作⽤原理就是在函数结束时⾃动释放内存空间,不需要⼿动释放内存空间。

常用接口

T* get();
T& operator*();
T* operator->();
T& operator=(const T& val);
T* release();
void reset (T* ptr = nullptr) 

T 是模板参数, 也就是传⼊的类型; get()⽤来获取 auto_ptr 封装在内部的指针, 也就是获取原⽣指针; operator()重载 , operator->()重载了->, operator=()重载了=; realease()将 auto_ptr封装在内部的指针置为nullptr, 但并不会破坏指针所指向的内容, 函数返回的是内部指针置空之前的值;直接释放封装的内部指针所指向的内存, 如果指定了 ptr 的值, 则将内部指针初始化为该值(否则将其设置为nullptr;

下面分别说⼀下哪四种:

1、 auto_ptr(C++98 的⽅案, C11 已抛弃)采⽤所有权模式。

auto_ptrstd::string p1 (new string ("hello")); auto_ptrstd::string p2; p2 = p1; //auto_ptr 不会报错 此时不会报错,p2剥夺了 p1 的所有权,但是当程序运⾏时访问 p1 将会报错。所以 auto_ptr的缺点是:存在潜在的内存崩溃问题!

2、 unique_ptr (替换 auto_ptr)

unique_ptr 实现独占式拥有或严格拥有概念,保证同⼀时间内只有⼀个智能指针可以指向该象。它对于避免资源泄露特别有⽤。

采⽤所有权模式,还是上⾯那个例⼦ unique_ptr p3 (new string (auto));//#4 unique_ptr p4;//#5 p4 = p3;//此时会报错 编译器认为 p4=p3 ⾮法,避免了 p3 不再指向有效数据的问题。 因此, unique_ptr ⽐ auto_ptr 更安全。

3、 shared_ptr(共享型,强引⽤)

shared_ptr 实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在 “最后⼀个引⽤被销毁”时候释放。从名字share就可以看出了资源可以被多个指针共享,它使⽤计数机制来表明资源被⼏个指针共享。

可以通过成员函数 use_count() 来查看资源的所有者个数,除了可以通过 new 来构造,还可以通过传⼊auto_ptr, unique_ptr,weak_ptr来构造。当我们调⽤release()时,当前指针会释放资源所有权,计数减⼀。当计数等于 0 时,资源会被释放。

shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性 (auto_ptr 是独占的),在使⽤引⽤计数的机制上提供了可以共享所有权的智能指针。

4、 weak_ptr (弱引⽤)

weak_ptr 是⼀种不控制对象⽣命周期的智能指针,它指向⼀个 shared_ptr 管理的对象。进⾏ 该对象的内存管理的是那个强引⽤的 shared_ptr。

weak_ptr 只是提供了对管理对象的⼀个访问⼿段。weak_ptr设计的⽬的是为配合shared_ptr ⽽引⼊的⼀种智能指针来协助 shared_ptr ⼯作,它只可以从⼀个shared_ptr 或另⼀个 weak_ptr 对象构造, ,它的构造和析构不会引起引⽤记数的增加或减少。

weak_ptr 是⽤来解决 shared_ptr 相互引⽤时的死锁问题,如果说两个shared_ptr 相互引⽤,那么这两个指针的引⽤计数永远不可能下降为0,也就是资源永远不会释放。它是对对象的⼀种弱引⽤,不会增加对象的引⽤计数,和 shared_ptr 之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调⽤ lock 函数来获得shared_ptr。 当两个智能指针都是 shared_ptr 类型的时候,析构时两个资源引⽤计数会减⼀,但是两者引⽤计数还是为1,导致跳出函数时资源没有被释放(的析构函数没有被调⽤),解决办法:把 其中⼀个改为weak_ptr就可以。

2、 C++ 中内存分配情况

栈:由编译器管理分配和回收,存放局部变量和函数参数。 堆:由程序员管理,需要⼿动 new malloc delete free 进⾏分配和回收,空间较⼤,但可能会 出现内存泄漏和空闲碎⽚的情况。 全局/静态存储区:分为初始化和未初始化两个相邻区域,存储初始化和未初始化的全局变量和静态变量。 常量存储区:存储常量,一般不允许修改。

代码区:存放程序的⼆进制代码。

3、 C++ 中的指针参数传递和引用参数传递

指针参数传递本质上是值传递,它所传递的是⼀个地址值。值传递过程中,被调函数的形式参 数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从⽽形成了实参的⼀个副本(替身)。值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会 变)。

引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时 存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

引⽤传递和指针传递是不同的,虽然他们都是在被调函数栈空间上一个局部变量,但是任何对于参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引⽤。

从编译的⻆度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引⽤对象的地址值(与实参名字不同,地址相同)。符号表⽣成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),引用对象则不能修改。

4、 C++ 中 const 和 static 关键字(定义,⽤途)

static 作用:控制变量的存储放方式和可见性。

作用一:修饰局部变量:一般情况下,对于局部变量在程序中是存放在栈区的,并且局部的生命周期在包含语句块执⾏结束时便结束了。但是如果用static 关键字修饰的话,该变量便会存放在静态数据区,其⽣命周期会⼀直延续到整个程序执⾏结束。但是要注意的是,虽然⽤static 对局部变量修饰之后,其生命周期以及存储空间发了变化,但其作用域并没有改变,作⽤域还是限制在其语句块。

作用二:修饰全部变量:对于一个全局变量,它既可以在本文件中被访问到,也可以在同一个⼯程中其它源⽂件被访问(添加 extern进⾏声明即可)。⽤ static 对全局变量修饰改变了其作⽤域范围,由原来的整个⼯程可⻅变成了本⽂件可⻅。

作⽤三:修饰函数:⽤ static 修饰函数,情况和修饰全局变量类似,也是改变了函数的作用域。

作⽤四:修饰类:如果 C++ 中对类中的某个函数⽤ static 修饰,则表示该函数属于⼀个类⽽ 不是属于此类的任何特定对象;如果对类中的某个变量进行static 修饰,则表示该变量以及所有的对象所有,存储空间中只存在⼀个副本,可以通过类和对象去调⽤。

(补充:静态非常量数据成员,其只能在类外定义和初始化,在类内仅是声明而已。)

作⽤五:类成员/类函数声明 static

函数体内 static 变量范围为该函数体,不同于 auto 变量,该变量的内存只被分配⼀次,因此其值在下次调⽤时仍维持上次的值; 在模块内的 static 全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;

在模块内的 static 函数只可被这⼀模块内的其它函数调⽤,这个函数的使⽤范围被限制在声明它的模块内; 在类中的 static 成员变量属于整个类所拥有,对类的所有对象只有一份拷贝; 在类中的 static 成员函数属于整个类所拥有,这个函数不接收this 指针,因⽽只能访问类 的 static 成员变量。 static 类对象必须要在类外进⾏初始化, static 修饰的变量先于对象存在,所以 static 修饰的变量要在类外初始化; 由于 static 修饰的类成员属于类,不属于对象,因此 static 类成员函数是没有 this 指针, this 指针是指向本对象的指针,正因为没有this 指针,所以 static 类成员函数不能访问⾮ static 的类成员,只能访问 static修饰的类成员; static 成员函数不能被 virtual 修饰, static 成员不属于任何对象或实例,所以加上 virtual没有任何实际意义;静态成员函数没有 this 指针,虚函数的实现是为每⼀个对象分配⼀个 vptr 指针,⽽ vptr 是通过 this 指针调⽤的,所以不能为virtual;虚函数的调⽤关系,this->vptr->ctable->virtual function。

const 关键字:含义及实现机制

const 修饰基本类型数据类型:基本数据类型,修饰符 const 可以⽤在类型说明符前,也可以用在类型说明符后,其结果是一样的。在使用这些常量的时候,只要不改变这些常量的值即 可。

const 修饰指针变量和引用变量:如果 const位于⼩星星的左侧,则 const 就是⽤来修饰指针所指向的变量,即指针指向为常量;如果 const 位于⼩星星的右侧,则 const 就是修饰指针 本身,即指针本身是常量。

const 应⽤到函数中:作为参数的 const 修饰符:调用函数的时候用相应的变量初始化 const 变量,则在函数体中,按照 const 所修饰的部分进行常量化,保护了原对象的属性。[注意]:参数 const 通常⽤于参数为指针或引⽤的情况; 作为函数返回值的 const 修饰符:声明了返回值后,const 按照"修饰原则"进⾏修饰,起到相应的保护作⽤。

const 在类中的⽤法: const 成员变量,只在某个对象生命周期内是常量。对于整个类而言是可以改变的。因为类可以创建多个对象,不同的对象其 const 数据成员值可以不同。所以不能在类的声明中初始化 const 数据成员,因为类的对象在没有创建时候,编译器不知道 const 数据成员的值是什么 const 数据成员的初始化只能在类的构造函数的初始化列表中进⾏。const 成员函数: const 成员函数的主要⽬的是防⽌成员函数修改对象的内容。要注意 const 关键字和 static 关键字对于成员函数来说是不能同时使⽤的,因为 static 关键字修饰静态成员 函数不含有 this 指针,即不能实例化 const 成员函数⼜必须具体到某⼀个函数。

const 修饰类对象,定义常量对象:常量对象只能调用常量函数,别的成员函数都不能调用。

补充: const 成员函数中如果实在想修改某个变量,可以使用mutable进行修饰。成员变量中如果想建立在整个类中都恒定的常量,应该用类中的枚举常量来实现或者 static const。

C ++ 中的 const类成员函数(用法和意义)

常ᰁ对象可以调⽤类中的 const 成员函数,但不能调⽤⾮ const 成员函数; (原因:对象调 ⽤成员函数时,在形参列表的最前⾯加⼀个形参 this,但这是隐式的。this 指针是默认指向调 ⽤函数的当前对象的,所以,很⾃然,this 是⼀个常量指针 test * const,因为不可以修改 this 指针代表的地址。但当成员函数的参数列表(即小括号)后加了 const 关键字(void print() const;),此成员函数为常ᰁ成员函数,此时它的隐式this形参为 const test * const, 即不可以通过 this 指针来改变指向对象的值。

非常量对象可以调⽤类中的 const 成员函数,也可以调⽤⾮ const 成员函数。

5、 C 和 C++ 区别(函数/类/struct/class)

⾸先, C 和 C++ 在基本语句上没有过⼤的区别。

C++ 有新增的语法和关键字,语法的区别有头文件的不同和命名空间的不同, C++ 允许我们⾃⼰定义⾃⼰的空间, C 中不可以。关键字⽅⾯⽐如 C++ 与 C 动态管理内存的⽅式不同, C++ 中在 malloc 和 free 的基础上增加了 new 和 delete,⽽且 C++ 中在指针的基础上增加 了引⽤的概念,关键字例如 C++中还增加了 auto, explicit 体现显示和隐式转换上的概念要 求,还有 dynamic_cast 增加类型安全⽅⾯的内容。

函数方面 C++ 中重载和虚函数的概念: C++ 支持函数重载而C 不⽀持,是因为 C++ 函数的名字修饰与 C 不同, C++ 函数名字的修饰会将参数加在后⾯,例如, int func(int,double)经过名字修饰之后会变成_func_int_double,⽽ C 中则会变成_func,所以 C++ 中会⽀持不同参数调⽤不同函数。

C++ 还有虚函数概念,⽤以实现多态。

类⽅⾯, C 的 struct 和 C++ 的类也有很⼤不同: C++ 中的 struct 不仅可以有成员变量还可以有成员函数,⽽且对于 struct 增加了权限访问的概念, struct 的默认成员访问权限和默认继承权限都是 public, C++ 中除了 struct 还有 class 表示类, struct 和 class 还有⼀点不同在于 class 的默认成员访问权限和默认继承权限都是 private。

C++ 中增加了模板还重用代码,提供了更加强大的 STL 标准库。

最后补充⼀点就是 C是一种结构化的语言,重点在于算法和数据结构。 C 程序的设计⾸先考虑的是如何通过⼀个代码,⼀个过程对输⼊进⾏运算处理输出。⽽ C++ ⾸先考虑的是如何构造⼀个对象模型,让这个模型能够契合与之对应的问题领域,这样就能通过获取对象的状态信息得到输出。

C 的 struct 更适合看成是⼀个数据结构的实现体,⽽ C++ 的 class 更适合看成是⼀个对象的实现体。

6、 C++ 和 Java 区别(语言特性,垃圾回收,应⽤场景等)

指针: Java 语⾔让程序员没法找到指针来直接访问内存,没有指针的概念,并有内存的⾃动 管理功能,从⽽有效的防⽌了 C++ 语⾔中的指针操作失误的影响。但并⾮ Java 中没有指 针, Java 虚拟机内部中还是⽤了指针,保证了 Java 程序的安全。

多重继承: C++ 支持多重继承但 Java 不⽀持,但⽀持⼀个类继承多个接口,实现 C++ 中多重继承的功能,又避免了 C++ 的多重继承带来的不便。

数据类型和类: Java 是完全面向对象的语言,所有的函数和变量必须是类的一部分。除了基本数据类型之外,其余的都作为类对象,对象将数据和⽅法结合起来,把它们封装在类中,这样每个对象都可以实现⾃⼰的特点和⾏为。 Java 中取消了 C++ 中的 struct 和 union 。

⾃动内存管理: Java 程序中所有对象都是⽤ new 操作符建⽴在内存堆栈上, Java自动进⾏无用内存回收操作,不需要程序员进⾏⼿动删除。⽽ C++ 中必须由程序员释放内存资源,增加了程序设计者的负担。 Java 中当⼀个对象不再被⽤到时,无用内存回收器将给他们加上标 签。 Java 里无用内存回收程序是以线程⽅式在后台运⾏的,利⽤空闲时间⼯作来删除。

Java 不支持操作符重载。操作符重载被认为是 C++ 的突出特性。

Java 不⽀持预处理功能。 C++ 在编译过程中都有⼀个预编译阶段, Java 没有预处理器,但它 提供了 import 与 C++ 预处理器具有类似功能。

类型转换: C++ 中有数据类型隐含转换的机制, Java 中需要限时强制类型转换。

字符串: C++中字符串是以 Null 终⽌符代表字符串的结束,⽽ Java 的字符串 是⽤类对象 (string 和 stringBuffer)来实现的。 Java 中不提供 goto 语句,虽然指定 goto 作为关键字,但不⽀持它的使⽤,使程序简洁易 读。

Java 的异常机制⽤于捕获例外事件,增强系统容错能⼒。

7、说⼀下 C++ 里是怎么定义常量的?常量存放在内存的哪个位置?

对于局部常量,存放在栈区;

对于全局常量,编译一般不分配内存,放在符号表中以提高访问效率;

字面值常量如字符串,放在常量区。

8、 C++ 中重载和重写,重定义的区别

重载

翻译⾃ overload,是指同⼀可访问区内被声明的⼏个具有不同参数列表的同名函数,依赖于 C++函数名字的修饰会将参数加在后⾯,可以是参数类型,个数,顺序的不同。根据参数列表决定调⽤哪个函数,重载不关⼼函数的返回类型

重写 翻译⾃ override,派⽣类中重新定义⽗类中除了函数体外完全相同的虚函数,注意被重写的函数不能是 static 的,⼀定要是虚函数,且其他⼀定要完全相同。要注意,重写和被重写的函数 是在不同的类当中的,重写函数的访问修饰符是可以不同的,尽管 virtual 中是 private 的,派⽣类中重写可以改为 public。

重定义(隐藏)

派生类重新定义父类中相同名字的非 virtual 函数,参数列表和返回类型都可以不同,即⽗类中除了定义成virtual 且完全相同的同名函数才不会被派生类中的同名函数所隐藏(重定义)。

9、介绍 C++ 所有的构造函数

类的对象被创建时,编译系统为对象分配内存空间,并⾃动调⽤构造函数,由构造函数完成成员的初始化⼯作。

即构造函数的作⽤:初始化对象的数据成员。

⽆参数构造函数: 即默认构造函数,如果没有明确写出⽆参数构造函数,编译器会⾃动⽣成默认的⽆参数构造函数,函数为空,什么也不做,如果不想使⽤⾃动⽣成的⽆参构造函数,必需要自己显示写出⼀个⽆参构造函数。

⼀般构造函数: 也称为重载构造函数,一般构造函数可以有各种参数形式,一个类可以有多个一般构造函数,前提是参数的个数或者类型不同,创建对象时根据传入参数不同调⽤不同的构造函数。

拷⻉构造函数: 拷⻉构造函数的函数参数为对象本身的引⽤,⽤于根据⼀个已存在的对象复制出⼀个新的该类的对象,⼀般在函数中会将已存在的对象的数据成员的值⼀⼀复制到新创建的对象中。如果没有显示的写拷⻉构造函数,则系统会默认创建⼀个拷⻉构造函数,但当类中有指针成员时,最好不要使⽤编译器提供的默认的拷⻉构造函数,最好⾃⼰定义并且在函数中执⾏深拷⻉。

类型转换构造函数: 根据⼀个指定类型的对象创建⼀个本类的对象,也可以算是⼀般构造函数 的⼀种,这⾥提出来,是想说有的时候不允许默认转换的话,要记得将其声明为 explict 的, 来阻⽌⼀些隐式转换的发⽣。

赋值运算符的重载:注意,这个类似拷⻉构造函数,将=右边的本类对象的值复制给=左边的 对象,它不属于构造函数,=左右两边的对象必需已经被创建。如果没有显示的写赋值运算符的重载,系统也会生成默认的赋值运算符,做一些基本的拷贝工作。

这⾥区分

A a1, A a2; a1 = a2;//调⽤赋值运算符

A a3 = a1;//调⽤拷⻉构造函数,因为进⾏的是初始化⼯作,a3 并未存在

10、 C++ 的四种强制转换

C++ 的四种强制转换包括: static_cast, dynamic_cast, const_cast, reinterpret_cast

static_cast:明确指出类型转换,⼀般建议将隐式转换都替换成显示转换,因为没有动态类型检查,上⾏转换(派⽣类->基类)安全,下⾏转换(基类->派⽣类) 不安全,所以主要执⾏⾮多态的转换操作; dynamic_cast:专⻔⽤于派⽣类之间的转换, type-id 必须是类指针,类引⽤或void*,对于下⾏转换是安全的,当类型不⼀致时,转换过来的是空指针,⽽static_cast,当类型不 ⼀致时,转换过来的事错误意义的指针,可能造成⾮法访问等问题。 const_cast:专⻔⽤于 const 属性的转换,去除 const 性质,或增加 const 性质, 是四个转换符中唯⼀⼀个可以操作常量的转换符。 reinterpret_cast:不到万不得已,不要使⽤这个转换符,⾼危操作。使⽤特点: 从底层对数据进⾏重新解释,依赖具体的平台,可移植性差; 可以将整形转 换为指针,也可以把指针转换为数组;可以在指针和引⽤之间进⾏肆⽆忌惮的转换。

11、指针和引⽤的区别

指针和引⽤都是⼀种内存地址的概念,区别呢,指针是⼀个实体,引⽤只是⼀个别名。 在程序编译的时候,将指针和引⽤添加到符号表中。

指针它指向⼀块内存,指针的内容是所指向的内存的地址,在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷⻉和赋 值,有 const 和⾮ const 区别,甚⾄可以为空, sizeof 指针得到的是指针类型的⼤⼩。

⽽对于引⽤来说,它只是⼀块内存的别名,在添加到符号表的时候,是将"引用变量名-引⽤对象的地址"添加到符号表中,符号表⼀经完成不能改变,所以引⽤必须⽽且只能在定义时被绑定到⼀块内存上,后续不能更改,也不能为空,也没有 const 和⾮ const 区别。 sizeof 引⽤得到代表对象的⼤⼩。⽽ sizeof 指针得到的是指针本身的⼤⼩。另外在参数传递中,指针需要被解引⽤后才可以对对象进⾏操作,⽽直接对引⽤进⾏的修改会直接作⽤到引⽤ 对象上。

作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引⽤的实质是传地址, 传递的是变⃞的地址。

12、 野(wild)指针与悬空(dangling)指针有什么区别?如何避免?

野指针(wild pointer):就是没有被初始化过的指针。⽤ gcc -Wall 编译, 会出现 used uninitialized 警告。

悬空指针:是指针最初指向的内存已经被释放了的⼀种指针。

无论野指针还是悬空指针,都是指向⽆效内存区域(这⾥的⽆效指的是"不安全不可控")的指针。 访问"不安全可控"(invalid)的内存区域将导致"Undefined Behavior" 。

如何避免使用野指针? 在平时的编码中,养成在定义指针后且在使⽤之前完成初始化的习惯或者使⽤智能指针。

13、说⼀下 const 修饰指针如何区分?

下⾯都是合法的声明,但是含义⼤不同:

const int * p1; //指向整形常量的指针,它指向的值不能修改

int * const p2; //指向整形的常量指针 ,它不能再指向别的变量,但指向(变量)的值可以修改。

const int *const p3;//指向整形常量的常量指针 。它既不能再指向别的常量,指向的值也不能修改。

理解这些声明的技巧在于,查看关键字const右边来确定什么被声明为常量 ,如果该关键字的右边是类型,则值是常量;如果关键字的右边是指针变量,则指针本身是常量。

14、简单说⼀下函数指针

从定义和⽤途两⽅⾯来说⼀下⾃⼰的理解:

首先是定义:函数指针是指向函数的指针变量。函数指针本身首先是一个指针变量,该指针变量指向一个具体的函数。这正如指针变量可指向整型变量、字符型、数组一样,这里是指向函数。

在编译时,每⼀个函数都有⼀个⼊⼝地址,该⼊⼝地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样, 在这些概念上是⼤体⼀致的。

其次是⽤途:调⽤函数和做函数的参数,⽐如回调函数。

示例:

char * fun(char * p) {…} // 函数fun 

char * (*pf)(char * p); // 函数指针

pf pf = fun; // 函数指针pf指向函数fun 

pf(p); // 通过函数指针pf调⽤函数**fun**

15、堆和栈区别

由编译器进⾏管理,在需要时由编译器⾃动分配空间,在不需要时候⾃动回收空间,⼀般保存 的是局部变量和函数参数等。

连续的内存空间,在函数调⽤的时候,⾸先⼊栈的主函数的下⼀条可执⾏指令的地址,然后是 函数的各个参数。

⼤多数编译器中,参数是从右向左⼊栈(原因在于采⽤这种顺序,是为了让程序员在使⽤C/C++的“函数参数⻓度可变”这个特性时更⽅便。如果是从左向右压栈,第⼀个参数(即描述可变参数表各变量类型的那个参数)将被放在栈底,由于可变参的函数第一步就需要解析可变参数表的各参数类型,即第⼀步就需要得到上述参数,因此,将它放在栈底是很不⽅便的。) 本次函数调⃞结束时,局部变⃞先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程 序由该点继续运⾏,不会产⽣碎⽚。

栈是⾼地址向低地址扩展,栈低⾼地址,空间较⼩。

由程序员管理,需要⼿动 new malloc delete free 进⾏分配和回收,如果不进⾏回收的话,会造成内存泄漏的问题。

不连续的空间,实际上系统中有⼀个空闲链表,当有程序申请的时候,系统遍历空闲链表找到 第⼀个⼤于等于申请⼤⼩的空间分配给程序,⼀般在分配程序的时候,也会空间头部写⼊内存 ⼤⼩,⽅便 delete 回收空间⼤⼩。当然如果有剩余的,也会将剩余的插⼊到空闲链表中,这也是产⽣内存碎⽚的原因。

堆是低地址向⾼地址扩展,空间交⼤,较为灵活。

16、函数传递参数的⼏种⽅式

值传递: 形参是实参的拷⻉,函数内部对形参的操作并不会影响到外部的实参。

指针传递: 也是值传递的⼀种⽅式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进⾏操作。

引⽤传递: 实际上就是把引⽤对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作 可以直接映射到外部的实参上⾯。

17、 new / delete, malloc / free 区别

都可以⽤来在堆上分配和回收空间。 new /delete 是操作符, malloc/free 是库函数。

执⾏ new 实际上执⾏两个过程: 1.分配未初始化的内存空间(malloc); 2.使⽤对象的构造 函数对空间进⾏初始化;返回空间的⾸地址。如果在第⼀步分配空间中出现问题,则抛出 std::bad_alloc 异常,或被某个设定的异常处理函数捕获处理;如果在第⼆步构造对象时出现 异常,则⾃动调⽤ delete 释放内存。

执⾏ delete 实际上也有两个过程: 1. 使⽤析构函数对对象进⾏析构; 2.回收内存空间 (free)。

以上也可以看出 new 和 malloc 的区别, new 得到的是经过初始化的空间,⽽ malloc 得到的 是未初始化的空间。所以 new 是 new ⼀个类型,⽽ malloc 则是malloc ⼀个字节⻓度的空间。 delete 和 free 同理, delete 不仅释放空间还析构对象, delete ⼀个类型, free ⼀个字节⻓度的空间。

为什么有了 malloc/free 还需要 new/delete? 因为对于⾮内部数据类型⽽⾔,光⽤ malloc /free ⽆法满⾜动态对象的要求。对象在创建的同时需要⾃动执⾏构造函数,对象在消亡以前 要⾃动执⾏析构函数。由于 mallo/free 是库函数⽽不是运算符,不在编译器控制权限之内, 不能够把执⾏的构造函数和析构函数的任务强加于 malloc/free,所以有了 new/delete 操作符。

18、 volatile 和 extern 关键字

volatile 三个特性

易变性:在汇编层⾯反映出来,就是两条语句,下⼀条语句不会直接使⽤上⼀条语句对应的 volatile 变量的寄存器内容,而是重新从内存中读取。

不可优化性: volatile 告诉编译器,不要对我这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,⼀定会被执⾏。

顺序性:能够保证 volatile 变量之间的顺序性,编译器不会进行乱序优化。

extern

在 C 语⾔中,修饰符 extern 用在变量或者函数的声明前,来说明 “此变量/函数是在别处定义的,要在此处引⽤”。

注意 extern 声明的位置对其作⽤域也有关系,如果是在 main 函数中进⾏声明的,则只能在 main 函数中调用,在其它函数中不能调用。其实要调用其他文件中的函数和变量,只需把该⽂件⽤ #include 包含进来即可,为啥要⽤ extern?因为⽤ extern 会加速程序的编译过程,这样能节省时间。

在 C++ 中 extern 还有另外⼀种作⽤,⽤于指示 C 或者 C++函数的调⽤规范。⽐如在 C++ 中调⽤ C 库函数,就需要在 C++ 程序中⽤ extern “C” 声明要引⽤的函数。这是给链接器 ⽤的,告诉链接器在链接的时候⽤C 函数规范来链接。主要原因是 C++ 和 C 程序编译完成 后在⽬标代码中命名规则不同,⽤此来解决名字匹配的问题。

19、 define和 const 区别(编译阶段、安全性、内存占⽤等)

对于 define来说, 宏定义实际上是在预编译阶段进⾏处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进⾏字符串的展开,遇到多少次就展开多少次,⽽且这个简单的展开过 程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是展开,因此运⾏ 时系统并不为宏定义分配内存,但是从汇编 的⻆度来讲,define 却以⽴即数的⽅式保留了多份数据的拷⻉。

对于 const 来说, const 是在编译期间进⾏处理的, const 有类型,也有类型检查,程序运⾏ 时系统会为 const 常量分配内存且从汇编角度讲, const 常量在出现的地方保留的是真正数据的内存地址,只保留了⼀份数据的拷⻉,省去了不必要的内存空间。⽽且,有时编译器 不会为普通的 const 常量分配内存,是直接将 const 常量添加到符号表中,省去了读取和写⼊内存的操作,效率更⾼。

20、计算下⾯⼏个类的大小

class A{}; sizeof(A) = 1; //空类在实例化时得到⼀个独⼀⽆⼆的地址,所以为 1. 

class A{virtual Fun(){} }; sizeof(A) = 4(32bit)/8(64bit) //当 C++ 类中有虚 函数的时候,会有⼀个指向虚函数表的指针(vptr) 

class A{static int a; }; sizeof(A) = 1; 

class A{int a; }; sizeof(A) = 4; 

class A{static int a; int b; }; sizeof(A) = 4

21、⾯向对象的三⼤特性,并举例说明

C++ ⾯向对象的三⼤特征是:封装、继承、多态。

所谓封装

就是把客观事物封装成抽象的类,并且类可以把⾃⼰的数据和⽅法只让信任的类或者对象操作,对不可信的进⾏信息隐藏。⼀个类就是⼀个封装了数据以及操作这些数据的代码的逻辑实 体。在⼀个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种⽅式, 对象对内部数据提供了不同级别的保护,以防⽌程序中⽆关的部分意外的改变或错误的使⽤了 对象的私有部分。

所谓继承

是指可以让某个类型的对象获得另⼀个类型的对象的属性的⽅法。它⽀持按级分类的概念。继承是指这样一种能力:它可以使用现有类的所有功能,并在无需要重新编写原来的类的情况下对这些功能进⾏扩展。通过继承创建的新类称为“⼦类”或者“派⽣类”,被继承的类称为“基类” 、 “⽗类”或“超类”。继承的过程,就是从⼀般到特殊的过程。要实现继承,可以通过“继承”和“组合”来实现。

继承概念的实现⽅式有两类:

实现继承:实现继承是指直接使⽤基类的属性和⽅法而无需额外编码的能⼒。

接⼝继承:接⼝继承是指仅使⽤属性和⽅法的名称、但是⼦类必需提供实现的能⼒。

所谓多态

就是向不同的对象发送同⼀个消息,不同对象在接收时会产⽣不同的⾏为(即⽅法)。即⼀个接⼝,可以实现多种⽅法。

多态与⾮多态的实质区别就是函数地址是早绑定还是晚绑定的。如果函数的调⽤,在编译器编 译期间就可以确定函数的调⽤地址,并产⽣代码,则是静态的,即地址早绑定。⽽如果函数调 ⽤的地址不能在编译器期间确定,需要在运⾏时才确定,这就属于晚绑定。

22、多态的实现

多态其实⼀般就是指继承加虚函数实现的多态,对于重载来说,实际上基于的原理是,编译器为函数⽣成符号表时的不同规则,重载只是⼀种语⾔特性,与多态⽆关,与⾯向对象也⽆关, 但这⼜是 C++中增加的新规则,所以也算属于 C++,所以如果⾮要说重载算是多态的⼀种, 那就可以说:多态可以分为静态多态和动态多态。

静态多态其实就是重载,因为静态多态是指在编译时期就决定了调用哪个函数,根据参数列表来决定;

动态多态是指通过子类重写父类的虚函数来实现的,因为是在运行期间决定调用的函数,所以称为动态多态,⼀般情况下我们不区分这两个时所说的多态就是指动态多态。

动态多态的实现与虚函数表,虚函数指针相关。

扩展: 子类是否要重写类的虚函数?子类继承父类时, 父类的纯虚函数必须重写,否则子类也是⼀个虚类不可实例化。定义纯虚函数是为了实现⼀个接口,起到⼀个规范的作⽤,规范继承这个类的程序员必须实现这个函数。

23、虚函数相关(虚函数表,虚函数指针),虚函数的实现原理

⾸先我们来说⼀下, C++中多态的表象,在基类的函数前加上virtual 关键字,在派生类中重写该函数,运⾏时将会根据对象的实际类型来调⽤相应的函数。如果对象类型是派⽣类,就调⽤派⽣类的函数,如果是基类,就调⽤基类的函数。

实际上,当⼀个类中包含虚函数时,编译器会为该类⽣成⼀个虚函数表,保存该类中虚函数的地址,同样,派⽣类继承基类,派⽣类中⾃然⼀定有虚函数,所以编译器也会为派⽣类⽣成自己的虚函数表。当我们定义⼀个派⽣类对象时,编译器检测该类型有虚函数,所以为这个派⽣ 类对象⽣成⼀个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数 中完成的。

后续如果有⼀个基类类型的指针,指向派⽣类,那么当调⽤虚函数时,就会根据所指真正对象 的虚函数表指针去寻找虚函数的地址,也就可以调⽤派⽣类的虚函数表中的虚函数以此实现多 态。 补充:如果基类中没有定义成 virtual,那么进⾏ Base B; Derived D; Base *p = D; p- >function(); 这种情况下调⽤的则是 Base 中的 function()。因为基类和派⽣类中都没有虚函数的定义,那么编译器就会认为不⽤留给动态多态的机会,就事先进⾏函数地址的绑定(早绑定),详述过程就是,定义了⼀个派⽣类对象,⾸先要构造基类的空间,然后构造派⽣类的自身内容,形成⼀个派⽣类对象,那么在进⾏类型转换时,直接截取基类的部分的内存,编译器 认为类型就是基类,那么(函数符号表[不同于虚函数表的另⼀个表]中)绑定的函数地址也就是基类中函数的地址,所以执⾏的是基类的函数。

24、编译器处理虚函数表应该如何处理

对于派⽣类来说,编译器建⽴虚函数表的过程其实⼀共是三个步骤:

拷贝基类的虚函数表,如果是多继承,就拷⻉每个有虚函数基类的虚函数表

当然还有⼀个基类的虚函数表和派⽣类⾃身的虚函数表共⽤了⼀个虚函数表,也称为某个基类为派⽣类的主基类

查看派生类中是否写基类中的虚函数, 如果有,就替换成已经重写的虚函数地址;查看派⽣类是否有⾃身的虚函数,如果有,就追加⾃身的虚函数到⾃身的虚函数表中。

Derived *pd = new D(); B *pb = pd; C *pc = pd; 其中 pb, pd, pc 的指针位置是不同的,要注意的是派⽣类的⾃身的内容要追加在主基类的内存块后。

25、析构函数⼀般写成虚函数的原因

直观的讲:是为了降低内存泄漏的可能性。举例来说就是,⼀个基类的指针指向⼀个派⽣类的 对象,在使⽤完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那 么编译器根据指针类型就会认为当前对象的类型是基类,调⽤基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执⾏基类的析构,派⽣类的自身内容将⽆法被析构,造成内存泄漏。

如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执⾏派⽣类的析构函 数,再执⾏基类的析构函数,成功释放内存。

26、构造函数为什么⼀般不定义为虚函数

虚函数调⽤只需要知道“部分的”信息,即只需要知道函数接⼝,⽽不需要知道对象的具体类型。但是,我们要创建⼀个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该被定义成虚函数; ⽽且从⽬前编译器实现虚函数进⾏多态的⽅式来看,虚函数的调⽤是通过实例化之后对象的虚函数表指针来找到虚函数的地址进⾏调⽤的,如果说构造函数是虚的,那么虚函数表 指针则是不存在的,⽆法找到对应的虚函数表来调⽤虚函数,那么这个调⽤实际上也是违 反了先实例化后调⽤的准则。

27、构造函数或析构函数中调⽤虚函数会怎样

实际上是不应该在构造函数或析构函数中调⽤虚函数的,因为这样的调⽤其实并不会带来所想要的效果。 举例来说就是,有⼀个动物的基类,基类中定义了⼀个动物本身⾏为的虚函数 action_type(),在基类的构造函数中调⽤了这个虚函数。

派生类中重写了这个虚函数,我们期望着根据对象的真实类型不同,而调用各自实现的虚函数,但实际上当我们创建⼀个派⽣类对象时,⾸先会创建派⽣类的基类部分,执⾏基类的构造函数,此时,派⽣类的⾃身部分还没有被初始化,对于这种还没有初始化的东⻄,C++选择当 它们还不存在作为⼀种安全的⽅法。

也就是说构造派⽣类的基类部分是,编译器会认为这就是⼀个基类类型的对象,然后调⽤基类 类型中的虚函数实现,并没有按照我们想要的⽅式进⾏。即对象在派⽣类构造函数执⾏前并不 会成为⼀个派⽣类对象。

在析构函数中也是同理,派⽣类执⾏了析构函数后,派⽣类的⾃身成员呈现未定义的状态,那么在执行基类的析构函数中是不可能调⽤到派⽣类重写的⽅法的。所以说,我们不应该在构在函数或析构函数中调⽤虚函数,就算调⽤⼀般也不会达到我们想要的结果。

28、析构函数的作用,如何起作用?

构造函数只是起初始化值的作⽤,但实例化⼀个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数里面,这样就使其他的函数⾥⾯有值了。规则,只要你⼀实例化对象,系统⾃动回调⽤⼀个构造函数,就是你不写,编译器也⾃动调⽤⼀次。

析构函数与构造函数的作用相反,⽤于撤销对象的⼀些特殊任务处理,可以是释放对象分配的 内存空间;特点:析构函数与构造函数同名,但该函数前⾯加~。

析构函数没有参数,也没有返回值,而且不能重载,在⼀个类中只能有⼀个析构函数。当撤销对象时,编译器也会⾃动调⽤析构函数。每⼀个类必须有⼀个析构函数,⽤户可以⾃定义析构函数,也可以是编译器⾃动⽣成默认的析构函数。⼀般析构函数定义为类的公有成员。

29、构造函数的执行顺序?析构函数的执行顺序?

构造函数顺序基类构造函数。如果有多个基类,则构造函数的调⽤顺序是某类在类派⽣表中出现的顺

序,⽽不是它们在成员初始化表中的顺序。 成员类对象构造函数。如果有多个成员类对象则构造函数的调⽤顺序是对象在类中被声明 的顺序,⽽不是它们出现在成员初始化表中的顺序。

派⽣类构造函数。

析构函数顺序

调⽤派⽣类的析构函数; 调⽤成员类对象的析构函数; 调⽤基类的析构函数。

30、纯虚函数 (应用于接⼝继承和实现继承)

实际上,纯虚函数的出现就是为了让继承可以出现多种情况:

有时我们希望派⽣类只继承成员函数的接⼝

有时我们⼜希望派⽣类既继承成员函数的接⼝,⼜继承成员函数的实现,⽽且可以在派⽣类中可以重写成员函数以实现多态

有的时候我们又希望派生类在继承成员函数接口和实现的情况下,不能重写缺省的实现。

其实,声明⼀个纯虚函数的⽬的就是为了让派⽣类只继承函数的接⼝,⽽且派⽣类中必需提供 ⼀个这个纯虚函数的实现,否则含有纯虚函数的类将是抽象类,不能进⾏实例化。

对于纯虚函数来说,我们其实是可以给它提供实现代码的,但是由于抽象类不能实例化,调⽤ 这个实现的唯⼀⽅式是在派⽣类对象中指出其 class 名称来调⽤。

31、静态绑定和动态绑定的介绍

说起静态绑定和动态绑定,我们⾸先要知道静态类型和动态类型,静态类型就是它在程序中被 声明时所采⽤的类型,在编译期间确定。动态类型则是指“⽬前所指对象的实际类型”,在运⾏ 期间确定。

静态绑定,⼜名早绑定,绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发 ⽣在编译期间。

动态绑定,⼜名晚绑定,绑定的是动态类型,所对应的函数或属性依赖于动态类型,发⽣在运 ⾏期间。

⽐如说, virtual 函数是动态绑定的,⾮虚函数是静态绑定的,缺省参数值也是静态绑定的。这呢,就需要注意,我们不应该新定义继承⽽来的缺省参数,因为即

使我们重定义了,也不会起到效果。因为⼀个基类的指针指向⼀个派⽣类对象,在派⽣类的对象中针对虚函数的参数缺省值进行了重定义, 但是缺省参数值是静态绑定的,静态绑定绑定的是静态类型相关的内容,所以会出现⼀种派⽣类的虚函数实现⽅式结合了基类的缺省参数值的调⽤效果,这个与所 期望的效果不同。

32、深拷⻉和浅拷⻉的区别(举例说明深拷⻉的安全性)

当出现类的等号赋值时,会调⽤拷⻉函数,在未定义显示拷⻉构造函数的情况下,系统会调⽤默认的拷⻉函数-即浅拷⻉,它能够完成成员的⼀⼀复制。当数据成员中没有指针时,浅拷⻉是可⾏的。

但当数据成员中有指针时,如果采⽤简单的浅拷⻉,则两类中的两个指针指向同⼀个地址,当对象快要结束时,会调用两次析构函数,导致指野指针的问题。

所以,这时必需采⽤深拷⻉。深拷⻉与浅拷⻉之间的区别就在于深拷⻉会在堆内存中另外申请空间来存储数据,从而也就解决野指针的问题。简⽽⾔之,当数据成员中有指针时,必需要 ⽤深拷⻉更加安全。

33、什么情况下会调⽤拷⻉构造函数(三种情况)

类的对象需要拷⻉时,拷⻉构造函数将会被调⽤,以下的情况都会调⽤拷⻉构造函数:

⼀个对象以值传递的⽅式传⼊函数体,需要拷⻉构造函数创建⼀个临时对象压⼊到栈空间中。 ⼀个对象以值传递的⽅式从函数返回,需要执⾏拷⻉构造函数创建⼀个临时对象作为返回 值。

⼀个对象需要通过另外⼀个对象进⾏初始化。

34、为什么拷⻉构造函数必需时引⽤传递,不能是值传递?

为了防⽌递归调⽤。当⼀个对象需要以值⽅式进⾏传递时,编译器会⽣成代码调⽤它的拷⻉构造函数⽣成⼀个副本,如果类 A 的拷⻉构造函数的参数不是引⽤传递,⽽是采⽤值传递,那 么就⼜需要为了创建传递给拷⻉构造函数的参数的临时对象,⽽⼜⼀次调⽤类A 的拷⻉构造 函数,这就是⼀个⽆限递归。

35、结构体内存对齐方式和为什么要进⾏内存对⻬?

⾸先我们来说⼀下结构体中内存对⻬的规则:

对于结构体中的各个成员,第⼀个成员位于偏移为 0 的位置,以后的每个数据成员的偏移量必须是 min(#pragma pack() 制定的数,数据成员本身⻓度) 的倍数。 在所有的数据成员完成各⾃对⻬之后,结构体或联合体本身也要进⾏对⻬,整体⻓度是min(#pragma pack()制定的数,⻓度最⻓的数据成员的⻓度) 的倍数。

那么内存对⻬的作⽤是什么呢?

经过内存对⻬之后, CPU 的内存访问速度⼤⼤提升。因为 CPU 把内存当成是⼀块⼀块的,块的⼤⼩可以是 2, 4, 8, 16 个字节,因此 CPU 在读取内存的时候是⼀块⼀块进⾏ 读取的,块的⼤⼩称为内存读取粒度。⽐如说 CPU 要读取⼀个 4 个字节的数据到寄存器 中(假设内存读取粒度是 4),如果数据是从 0 字节开始的,那么直接将 0-3 四个字节完 全读取到寄存器中进⾏处理即可。 如果数据是从 1 字节开始的,就⾸先要将前 4 个字节读取到寄存器,并再次读取 4-7 个字节数据进⼊寄存器,接着把 0 字节, 5, 6, 7 字节的数据剔除,最后合并 1, 2, 3, 4 字节的数据进⼊寄存器,所以说,当内存没有对⻬时,寄存器进⾏了很多额外的操作,⼤⼤降低了 CPU 的性能。

另外,还有⼀个就是,有的 CPU 遇到未进⾏内存对⻬的处理直接拒绝处理,不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类 型的数据,否则抛出硬件异常。所以内存对⻬还有利于平台移植。

36、内存泄漏的定义,如何检测与避免?

定义:内存泄漏简单的说就是申请了⼀块内存空间,使⽤完毕后没有释放掉。 它的⼀般表现 ⽅式是程序运⾏时间越⻓,占⽤内存越多,最终⽤尽全部内存,整个系统崩溃。由程序申请的 ⼀块内存,且没有任何⼀个指针指向它,那么这块内存就泄漏了。

如何检测内存泄漏⾸先可以通过观察猜测是否可能发⽣内存泄漏, Linux 中使⽤ swap 命令观察还有多少可⽤的交换空间,在⼀两分钟内键⼊该命令三到四次,看看可⽤的交换区是否在减少。

还可以使⽤ 其他⼀些/usr/bin/stat ⼯具如 netstat、 vmstat 等。如发现波段有内存被分配且从不释放,⼀个可能的解释就是有个进程出现了内存泄漏。 当然也有⽤于内存调试,内存泄漏检测以及性能分析的软件开发⼯具valgrind 这样的⼯具 来进⾏内存泄漏的检测。

37、说⼀下平衡⼆叉树、⾼度平衡⼆叉树(AVL)

⼆叉树:任何节点最多只允许有两个⼦节点,称为左⼦节点和右⼦节点,以递归的⽅式定义⼆叉树为,⼀个⼆叉树如果不为空,便是由⼀个根节点和左右两个⼦树构成, 左右⼦树都可能为空。

⼆叉搜索树:⼆叉搜索树可以提供对数时间的元素插⼊和访问。节点的放置规则是:任何节点的键值⼀定⼤于其左⼦树的每⼀个节点的键值,并⼩于其右⼦树中的每⼀个节点的键值。因此 ⼀直向左⾛可以取得最⼩值,⼀直向右⾛可以得到最⼤值。插⼊:从根节点开始,遇键值较⼤ 则向左,遇键值较⼩则向右,直到尾端,即插⼊点。删除:如果删除点只有⼀个⼦节点,则直 接将其⼦节点连⾄⽗节点。如果删除点有两个⼦节点,以右⼦树中的最⼩值代替要删除的位 置。

平衡⼆叉树:其实对于树的平衡与否没有⼀个绝对的标准, “平衡”的⼤致意 思是:没有任何⼀个节点过深,不同的平衡条件会造就出不同的效率表现。以及不同的实现复杂度。有数种特 殊结构例如 AVL-tree, RB-tree, AA-tree,均可以实现平衡⼆叉树。

AVL-tree :⾼度平衡的平衡⼆叉树(严格的平衡⼆叉树) AVL-tree 是要求任何节点的左右⼦ 树⾼度相差最多为 1 的平衡⼆叉树。当插⼊新的节点破坏平衡性的时候,从下往上找到第⼀ 个不平衡点,需要进⾏单旋转,或者双旋转进⾏调整。

38、说⼀下红⿊树(RB-tree)

红⿊树的定义:

性质1:每个节点要么是⿊⾊,要么是红⾊。 性质2:根节点是⿊⾊。

性质3:每个叶⼦节点(NIL)是⿊⾊。

性质4:每个红⾊结点的两个⼦结点⼀定都是⿊⾊。

性质5:任意⼀结点到每个叶⼦结点的路径都包含数量相同的⿊结点。

39、说⼀下 define、 const、 typedef、 inline 使⽤⽅法?

1、const 与 #define 的区别

const 定义的常量是变量带类型,⽽ #define 定义的只是个常数不带类型;

define 只在预处理阶段起作⽤,简单的⽂本替换,⽽ const 在编译、链接过程中起作⽤;

define 只是简单的字符串替换没有类型检查。⽽const是有数据类型的,是要进⾏判断的,可 以避免⼀些低级错误;

define 预处理后,占⽤代码段空间,const 占⽤数据段空间;

const 不能重定义,⽽ define 可以通过 #undef 取消某个符号的定义,进⾏重定义;

define 独特功能,⽐如可以⽤来防⽌⽂件重复引⽤。 2、 #define 和别名 typedef 的区别

执⾏时间不同, typedef 在编译阶段有效, typedef 有类型检查的功能; #define 是宏定义,发

⽣在预处理阶段,不进⾏类型检查;

功能差异, typedef ⽤来定义类型的别名,定义与平台⽆关的数据类型,与 struct 的结合使⽤ 等。

#define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

作⽤域不同, #define 没有作⽤域的限制,只要是之前预定义过的宏,在以后的程序中都可以 使⽤。

⽽ typedef 有⾃⼰的作⽤域。 3、 define 与 inline 的区别

#define是关键字, inline是函数;

宏定义在预处理阶段进⾏⽂本替换, inline 函数在编译阶段进⾏替换; inline 函数有类型检查,相⽐宏定义⽐较安全;

扩展:

40、预处理,编译,汇编,链接程序的区别

⼀段⾼级语⾔代码经过四个阶段的处理形成可执⾏的⽬标⼆进制代码。

预处理器→编译器→汇编器→链接器:最难理解的是编译与汇编的区别。

这⾥采⽤《深⼊理解计算机系统》的说法。

预处理阶段: 写好的⾼级语⾔的程序⽂本⽐如 hello.c,预处理器根据 #开头的命令,修改原始 的程序,如#include<stdio.h> 将把系统中的头⽂件插⼊到程序⽂本中,通常是以 .i 结尾的⽂件。

编译阶段: 编译器将 hello.i ⽂件翻译成⽂本⽂件 hello.s,这个是汇编语⾔程序。⾼级语⾔是源程序。所以注意概念之间的区别。汇编语⾔程序是⼲嘛的?每条语句都以标准的⽂本格式确 切描述⼀条低级机器语⾔指令。 不同的⾼级语⾔翻译的汇编语⾔相同。

汇编阶段: 汇编器将 hello.s 翻译成机器语⃞指令。把这些指令打包成可⃞定位⃞标程序,即 .o⽂件。 hello.o是⼀个⼆进制⽂件,它的字节码是机器语⾔指令,不再是字符。前⾯两个阶段都还有字符。

链接阶段:⽐如 hello 程序调⽤ printf 程序,它是每个 C 编译器都会提供的标准库 C 的函数。这个函数存在于⼀个名叫 printf.o 的单独编译好的⽬标⽂件中,这个⽂件将以某种⽅式合并到 hello.o 中。链接器就负责这种合并。得到的是可执⾏⽬标⽂件。

41、说⼀下 fork, wait, exec 函数

⽗进程产⽣⼦进程使⽤ fork 拷⻉出来⼀个⽗进程的副本,此时只拷⻉了⽗进程的⻚表,两个进程都读同⼀块内存。

当有进程写的时候使⽤写实拷⻉机制分配内存,exec 函数可以加载⼀个 elf ⽂件去替换⽗进程,从此⽗进程和⼦进程就可以运⾏不同的程序了。

fork 从⽗进程返回⼦进程的 pid,从⼦进程返回 0,调⽤了 wait 的⽗进程将会发⽣阻塞,直到 有⼦进程状态改变,执⾏成功返回 0,错误返回 -1。

exec 执⾏成功则⼦进程从新的程序开始运⾏,⽆返回值,执⾏失败返回 -1。

42、动态编译与静态编译

静态编译,编译器在编译可执⾏⽂件时,把需要⽤到的对应动态链接库中的部分提取出来,连 接到可执⾏⽂件中去,使可执⾏⽂件在运⾏时不需要依赖于动态链接库;

动态编译,可执⾏⽂件需要附带⼀个动态链接库,在执⾏时,需要调⽤其对应动态链接库的命令。所以其优点⼀⽅⾯是缩⼩了执⾏⽂件本身的体积,另⼀⽅⾯是加快了编译速度,节省了系统资源。缺点是哪怕是很简单的程序,只⽤到了链接库的⼀两条命令,也需要附带⼀个相对庞 ⼤的链接库;⼆是如果其他计算机上没有安装对应的运⾏库,则⽤动态编译的可执⾏⽂件就不 能运⾏。

43、动态链接和静态链接区别

静态连接库就是把 (lib) ⽂件中⽤到的函数代码直接链接进⽬标程序,程序运⾏的时候不再需要其它的库⽂件;动态链接就是把调⽤的函数所在⽂件模块(DLL)和调⽤函数在⽂件中的位 置等信息链接进⽬标程序,程序运⾏的时候再从 DLL 中寻找相应函数代码,因此需要相应DLL ⽂件的⽀持。

静态链接库与动态链接库都是共享代码的⽅式,如果采⽤静态链接库,则⽆论你愿不愿意, lib中的指令都全部被直接包含在最终⽣成的 EXE ⽂件中了。但是若使⽤ DLL,该 DLL不必被包 含在最终 EXE ⽂件中, EXE ⽂件执⾏时可以“动态”地引⽤和卸载这个与 EXE 独⽴的 DLL⽂件。

静态链接库和动态链接库的另外⼀个区别在于静态链接库中不能再包含其他的动态链接库或者 静态库,⽽在动态链接库中还可以再包含其他的动态或静态链接库。

动态库就是在需要调⽤其中的函数时,根据函数映射表找到该函数然后调⼊堆栈执⾏。如果在 当前⼯程中有多处对dll⽂件中同⼀个函数的调⽤,那么执⾏时,这个函数只会留下⼀份拷⻉。 但如果有多处对 lib ⽂件中同⼀个函数的调⽤,那么执⾏时该函数将在当前程序的执⾏空间⾥ 留下多份拷⻉,⽽且是⼀处调⽤就产⽣⼀份拷⻉。

44、动态联编与静态联编

在 C++ 中,联编是指⼀个计算机程序的不同部分彼此关联的过程。按照联编所进⾏的阶段不 同,可以分为静态联编和动态联编;

静态联编是指联编⼯作在编译阶段完成的,这种联编过程是在程序运⾏之前完成的,⼜称为早期编。要实现静态联编,在编译阶段就必须确定程序中的操作调⽤(如函数调⽤)与执⾏该 操作代码间的关系,确定这种关系称为束定,在编译时的束定称为静态束定。静态联编对函数 的选择是基于指向对象的指针或者引⽤的类型。其优点是效率⾼,但灵活性差。

动态联编是指联编在程序运⾏时动态地进⾏,根据当时的情况来确定调⽤哪个同名函数,实际 上是在运⾏时虚函数的实现。这种联编⼜称为晚期联编,或动态束定。动态联编对成员函数的 选择是基于对象的类型,针对不同的对象类型将做出不同的编译结果。

C++中⼀般情况下的联编是静态联编,但是当涉及到多态性和虚函数时应该使⽤动态联编。动 态联编的优点是灵活性强,但效率低。动态联编规定,只能通过指向基类的指针或基类对象的 引⽤来调⽤虚函数,其格式为:指向基类的指针变量名->虚函数名(实参表)或基类对象的引⽤名.虚函数名(实参表

实现动态联编三个条件:

必须把动态联编的⾏为定义为类的虚函数;

类之间应满⾜⼦类型关系,通常表现为⼀个类从另⼀个类公有派⽣⽽来;

必须先使⽤基类指针指向⼦类型的对象,然后直接或间接使⽤基类指针调⽤虚函数;

四、类和数据抽象

1、什么是类的继承?

类与类之间的关系

has-A 包含关系,⽤以描述⼀个类由多个部件类构成,实现 has-A 关系⽤类的成员属性表 示,即⼀个类的成员属性是另⼀个已经定义好的类;

use-A,⼀个类使⽤另⼀个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数 的⽅式来实现;

is-A,继承关系,关系具有传递性;

继承的相关概念

所谓的继承就是⼀个类继承了另⼀个类的属性和⽅法,这个新的类包含了上⼀个类的属性和⽅ 法,被称为⼦类或者派⽣类,被继承的类称为⽗类或者基类;

继承的特点

⼦类拥有⽗类的所有属性和⽅法,⼦类可以拥有⽗类没有的属性和⽅法,⼦类对象可以当做⽗ 类对象使⽤;

继承中的访问控制

public、 protected、 private

继承中的构造和析构函数

继承中的兼容性原则

2、什么是组合?

⼀个类⾥⾯的数据成员是另⼀个类的对象,即内嵌其他类的对象作为⾃⼰的成员;创建组合类 的象:⾸先创建各个内嵌对象,难点在于构造函数的设计。创建对象时既要对基本类型的成 员进⾏初始化,⼜要对内嵌对象进⾏初始化。

创建组合类对象,构造函数的执⾏顺序:先调⽤内嵌对象的构造函数,然后按照内嵌对象成员 在组合类中的定义顺序,与组合类构造函数的初始化列表顺序⽆关。然后执⾏组合类构造函数 的函数体,析构函数调⽤顺序相反。

3、构造函数析构函数可否抛出异常

C++ 只会析构已经完成的对象,对象只有在其构造函数执⾏完毕才算是完全构造妥当。在构造函数中发⽣异常,控制权转出构造函数之外。因此,在对象 b 的构造函数中发⽣异常,对 象b的析构函数不会被调⽤。因此会造成内存泄漏。

⽤ auto_ptr 对象来取代指针类成员,便对构造函数做了强化,免除了抛出异常时发⽣资源泄 漏的危机,不再需要在析构函数中⼿动释放资源;

如果控制权基于异常的因素离开析构函数,⽽此时正有另⼀个异常处于作⽤状态, C++ 会调 ⽤ terminate 函数让程序结束;

如果异常从析构函数抛出,⽽且没有在当地进⾏捕捉,那个析构函数便是执⾏不全的。如果析构函数执⾏不全,就是没有完成他应该执⾏的每⼀件事情。

4、类如何实现只能静态分配和只能动态分配

前者是把 new、 delete 运算符重载为 private 属性。

后者是把构造、析构函数设为 protected 属性,再⽤⼦类来动态创建

建⽴类的对象有两种⽅式:

静态建⽴,静态建⽴⼀个类对象,就是由编译器为对象在栈空间中分配内存; 动态建⽴, A *p = new A(); 动态建⽴⼀个类对象,就是使⽤ new 运算符为对象在堆空间中分配内存。这个过程分为两步,第⼀步执⾏ operator new() 函数,在堆中搜索⼀块内存 并进⾏分配;第⼆步调⽤类构造函数构造对象; 只有使⽤ new 运算符,对象才会被建⽴在堆上,因此只要限制 new 运算符就可以实现类对象只能建⽴在栈上。可以将 new 运算符设为私有。

5、何时需要成员初始化列表?过程是什么?

当初始化⼀个引⽤成员变量时;

初始化⼀个 const 成员变量时;

当调⽤⼀个基类的构造函数,⽽构造函数拥有⼀组参数时;

当调⽤⼀个成员类的构造函数,⽽他拥有⼀组参数;

编译器会⼀⼀操作初始化列表,以适当顺序在构造函数之内安插初始化操作,并且在任何显示 ⽤户代码前。 list中的项⽬顺序是由类中的成员声明顺序决定的,不是初始化列表中的排列顺序决定的。

6、程序员定义的析构函数被扩展的过程?

析构函数函数体被执⾏;

如果 class 拥有成员类对象,⽽后者拥有析构函数,那么它们会以其声明顺序的相反顺序被调 ⽤;

如果对象有⼀个 vptr,现在被重新定义

如果有任何直接的上⼀层⾮虚基类拥有析构函数,则它们会以声明顺序被调⽤;

如果任何虚基类拥有析构函数

7、构造函数的执行算法?

在派⽣类构造函数中,所有的虚基类及上⼀层基类的构造函数调⽤;

对象的 vptr 被初始化;

如果有成员初始化列表,将在构造函数体内扩展开来,这必须在vptr 被设定之后才做;

执⾏程序员所提供的代码;

8、构造函数的扩展过程?

记录在成员初始化列表中的数据成员初始化操作会被放在构造函数的函数体内,并与成员的声明顺序为顺序;

如果⼀个成员并没有出现在成员初始化列表中,但它有⼀个默认构造函数,那么默认构造函数 必须被调⽤; 如果 class 有虚表,那么它必须被设定初值;

所有上⼀层的基类构造函数必须被调⽤;

所有虚基类的构造函数必须被调⽤。

9、哪些函数不能是虚函数

构造函数,构造函数初始化对象,派⽣类必须知道基类函数⼲了什么,才能进⾏构造;当有虚 函数时,每⼀个类有⼀个虚表,每⼀个对象有⼀个虚表指针,虚表指针在构造函数中初始化;

内联函数,内联函数表示在编译阶段进⾏函数体的替换操作,⽽虚函数意味着在运⾏期间进⾏ 类型确定,所以内联函数不能是虚函数; 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。

友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数 的说法。

普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数;

五、 STL 容器和算法

1、 C++ 的 STL 介绍(内存管理, allocator,函数,实现机理,多线程实现等)

STL ⼀共提供六⼤组件,包括容器,算法,迭代器,仿函数,配器和配置器,彼此可以组合套⽤。容器通过配置器取得数据存储空间,算法通过迭代器存取容器内容,仿函数可以协助算 法完成不同的策略变化,配接器可以应⽤于容器、仿函数和迭代器。 容器: 各种数据结构,如 vector, list, deque, set, map,⽤来存放数据, 从实现的⻆度来讲是⼀种类模板。

算法: 各种常⽤的算法,如 sort (插⼊,快排,堆排序), search (⼆分查找), 从实现的⻆度来讲是⼀种⽅法模板。

迭代器: 从实现的⻆度来看,迭代器是⼀种将 operator*,operator->,operator++, operator--等指针相关操作赋予重载的类模板,所有的 STL 容器都有⾃⼰的迭代器。

仿函数: 从实现角度看,仿函数是一种重载了 operator()的类或者类模板。 可以帮助算法实现不同的策略。

配接器:⼀种⽤来修饰容器或者仿函数或迭代器接⼝的东⻄。

配置器: 负责空间配置与管理,从实现的⻆度讲,配置器是⼀个实现了动态空间配置、空间管理,空间释放的类模板。 扩展: 内存管理 allocator

SGI 设计了双层级配置器,第⼀级配置器直接使⽤ malloc()和 free()完成内存的分配和回收。第二级配置器则根据需求量的大小选择不同的策略执行。

对于第⼆级配置器,如果需求块⼤⼩⼤于 128bytes,则直接转⽽调⽤第⼀级配置器,使⽤ malloc()分配内存。如果需求块大小128bytes,第⼆级配置器中维护了 16 个⾃由链表, 负责 16 种⼩型区块的次配置能⼒。

即当有⼩于 128bytes 的需求块要求时,⾸先查看所需需求块⼤⼩所对应的链表中是否有空闲空间,如果有则直接返回,如果没有,则向内存池中申请所需需求块⼤⼩的内存空间,如果申请成功,则将其加⼊到⾃由链表中。如果内存池中没有空间,则使⽤ malloc() 从堆中进⾏申请,且申请到的⼤⼩是需求量的⼆倍(或⼆倍+n 附加量),⼀倍放在⾃由空间中,⼀倍(或⼀倍+n)放⼊内存池中。

如果 malloc()也失败,则会遍历⾃由空间链表,四处寻找“尚有未⽤区块,且区块够⼤”的freelist,找到⼀块就挖出⼀块交出。如果还是没有,仍交由 malloc()处理,因为 malloc() 有 out-of-memory 处理机制或许有机会释放其他的内存拿来⽤,如果可以就成功,如果不⾏就报 bad_alloc 异常。

STL 中序列式容器的实现:

vector

是动态空间,随着元素的加⼊,它的内部机制会⾃⾏扩充空间以容纳新元素。 vector 维护的 是⼀个连续的线性空间,⽽且普通指针就可以满⾜要求作为vector 的迭代器

(RandomAccessIterator)。

vector 的数据结构中其实就是三个迭代器构成的,⼀个指向⽬前使⽤空间头的 iterator,⼀个 指向⽬前使⽤空间尾的iterator,⼀个指向⽬前可⽤空间尾的 iterator。。当有新的元素插⼊时, 如果⽬前容量够⽤则直接插⼊,如果容量不够,则容量扩充⾄两倍,如果两倍容量不⾜, 就扩张⾄⾜够⼤的容量。

扩充的过程并不是直接在原有空间后⾯追加容量,⽽是从新申请⼀块连续空间,将原有的数据 拷⻉到新空间中,再释放原有空间,完成⼀次扩充。需要注意的是,每次扩充是⃞新开辟的空 间,所以扩充后,原有的迭代器将会失效。 list

与 vector 相⽐, list 的好处就是每次插⼊或删除⼀个元素,就配置或释放⼀个空间,⽽且原有的迭代器也不会失效。 STL list 是⼀个双向链表,普通指针已经不能满⾜ list 迭代器的需求,因为 list 的存储空间是不连续的。 list 的迭代器必需具备前移和后退功能,所以 list 提供的是BidirectionalIterator。 list 的数据结构中只要⼀个指向 node 节点的指针就可以了。

deque

vector 是单向开⼝的连续线性空间,deque 则是⼀种双向开⼝的连续线性空间。所谓双向开口,就是说 deque ⽀持从头尾两端进行元素的插⼊和删除操作。相⽐于 vector 的扩充空间的方式, deque 实际上更加贴切的实现了动态空间的概念。 deque 没有容量的概念,因为它是动态地以分段连续空间组合⽽成,随时可以增加⼀段新的空间并连接起来。

由于要维护这种整体连续的假象,并提供随机存取的接口(即也提供 RandomAccessIterator),避开了“⃞新配置,复制,释放”的轮回,代价是复杂的迭代器结 构。也就是说除⾮必要,我们应该尽可能 的使⽤ vector,⽽不是 deque。

那么我们回过来具体说 deque 是如何做到维护整体连续的假象的, deque 采⽤⼀块所谓的map 作为主控,这⾥的 map 实际上就是⼀块⼤⼩连续的空间,其中每⼀个元素,我们称之为 节点 node,都指向了另⼀段连续线性空间称为缓冲区,缓冲区才是 deque 的真正存储空间主体。

SGI STL 是允许我们指定缓冲区的⼤⼩的,默认 0 表示使⽤ 512bytes 缓冲区。当 map 满载 时,我们选⽤⼀块更⼤的空间来作为 map,重新调整配置。 deque 另外⼀个关键的就是它的 iterator 的设计, deque 的 iterator 中有四个部分, cur 指向缓冲区现⾏元素, first 指向缓冲区 的头, last 指向缓冲区的尾(有时会包含备⽤空间), node 指向管控中⼼。所以总结来说,deque的数据结构中包含了,指向第⼀个节点的iterator start, 和指向最后⼀个节点的 iterator finish,⼀块连续空间作为主控 map,也需要记住 map 的⼤⼩,以备判断何时配置更⼤的map。

stack

是⼀种先进后出的数据结构,只有⼀个出⼝, stack 允许从最顶端新增元素,移除最顶端元素,取得最顶端元素。deque 是双向开⼝的数据结构,所以使⽤ deque 作为底部结构并封闭其头端开⼝,就形成了⼀个 stack。

queue

是⼀种先进先出的数据结构,有两个出⼝,允许从最底端加⼊元素,取得最顶端元素,从最底 端新增元素,从最顶端移除元素。 deque 是双向开⼝的数据结构,若以 deque 为底部结构并封闭其底端的出口,和头端的⼊⼝,就形成了⼀个 queue。(其实 list 也可以实现 deque)

heap

堆并不属于 STL 容器组件,它是个幕后英雄,扮演 priority_queue 的助⼿, priority_queue 允 许⽤户以任何次序将任何元素推⼊容器内,但取出时⼀定是从优先权最⾼(数值最⾼)的元素 开始取。⼤根堆(binary max heap)正具有这样的性质,适合作priority_queue 的底层机制。

⼤根堆,是⼀个满⾜每个节点的键值都⼤于或等于其⼦节点键值的⼆叉树(具体实现是⼀个vector,⼀块连续空间,通过维护某种顺序来实现这个⼆叉树),新加⼊元素时,新加⼊的元 素要放在最下⼀层为叶节点,即具体实现是填补在由左⾄右的第⼀个空格(即把新元素插⼊在 底层 vector 的 end()),然后执⾏⼀个所谓上溯的程序:将新节点拿来与⽗节点⽐较,如果其键值⽐⽗节点⼤,就⽗⼦对换位置,如此⼀直上溯,直到不需要对换或直到根节点为⽌。当取出一个元素时,最大值在根节点,取走根节点,要割舍最下层最右边的右节点,并将其值重新安插⾄最⼤堆,最末节点放⼊根节点后,进⾏⼀个下溯程序:将空间节点和其较⼤的节点对调,并持续下⽅,直到叶节点为⽌。

priority_queue

底层是⼀个 vector,使⽤ heap 形成的算法,插⼊,获取 heap 中元素的算法,维护这个 vector,以达到允许⽤户以任何次序将任何元素插⼊容器内,但取出时⼀定是从优先权最⾼(数值最⾼)的元素开始取的⽬的。

slist: STL list 是⼀个双向链表, slist 是⼀个单向链表。

2、 vector 使⽤的注意点及其原因,频繁对 vector 调⽤ push_back()性能影响

使⽤注意点:

注意插⼊和删除元素后迭代器失效的问题;

清空 vector 数据时,如果保存的数据项是指针类型,需要逐项 delete,否则会造成内存泄漏。

频繁调⽤ push_back()影响:

向 vector 的尾部添加元素,很有可能引起整个对象 存储空间的重新分配,新分配更⼤的内存,再将原数据拷⻉到新空间中,再释放原有内存,这个过程是耗时耗⼒的,频繁对 vector 调⽤ push_back()会导致性能的下降。

在 C++11 之后, vector 容器中添加了新的⽅法: emplace_back() ,和 push_back() ⼀样的是都是在容器末尾添加⼀个新的元素进去,不同的是 emplace_back() 在效率上相⽐ 较于 push_back() 有了⼀定的提升。

emplace_back() 函数在原理上⽐ push_back() 有了⼀定的改进,包括在内存优化⽅⾯和 运⾏效率⽅⾯。内存优化主要体现在使⽤了就地构造(直接在容器内构造对象,不⽤拷⻉⼀个 复制品再使⽤) +强制类型转换的⽅法来实现,在运⾏效率⽅⾯,由于省去了拷⻉构造过程, 因此也有⼀定的提升。

3、 map 和 set 有什么区别,分别⼜是怎么实现的?

map 和 set 都是 C++ 的关联容器,其底层实现都是红⿊树(RB-Tree)。

由于 map 和 set 所开放的各种操作接⼝,RB-tree 也都提供了,所以⼏乎所有的 map 和 set 的操作⾏为,都只是转调 RB-tree 的操作⾏为。

map 和 set 区别在于:

(1) map 中的元素是 key-value (关键字—值)对:关键字起到索引的作⽤,值则表示与索 引相关联的数据; Set与之相对就是关键字的简单集合, set 中每个元素只包含⼀个关键字。

(2) set 的迭代器是 const 的,不允许修改元素的值; map允许修改value,但不允许修改 key。其原因是因为map和set是根据关键字排序来保证其有序性的,如果允许修改key的话,那首先需要删除该键,然后调节平衡,再插入修改后的键值,调节平衡,如此一来,严重破坏了map和set的结构,导致iterator失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const,不允许修改迭代器的值;⽽map的迭代器则不 允许修改key值,允许修改value值。

(3) map⽀持下标操作, set不⽀持下标操作。 map可以⽤key做下标, map的下标运算符[ ] 将关键码作为下标去执⾏查找,如果关键码不存在,则插⼊⼀个具有该关键码和 mapped_type类型默认值的元素⾄map中,因此下标运算符[ ]在map应⽤中需要慎⽤, const_map不能⽤,只希望确定某⼀个关键值是否存在⽽不希望插⼊元素时也不应该使⽤, mapped_type类型没有默认值也不应该使⽤。如果find能解决需要,尽可能⽤find。

4、请你来说⼀说 STL 迭代器删除元素

这个主要考察的是迭代器失效的问题。

对于序列容器 vector, deque来说,使⽤ erase(itertor) 后,后边的每个元素的迭代器都会失 效,但是后边每个元素都会往前移动⼀个位置,但是 erase 会返回下⼀个有效的迭代器;

对于关联容器 map set 来说,使⽤了 erase(iterator) 后,当前元素的迭代器失效,但是其结构 是红⿊树,删除当前元素的,不会影响到下⼀个元素的迭代器,所以在调⽤ erase 之前,记录 下⼀个元素的迭代器即可。 对于 list 来说,它使⽤了不连续分配的内存,并且它的 erase ⽅法也会返回下⼀个有效的 iterator,因此上⾯两种正确的⽅法都可以使⽤。

5、请你来说⼀下 STL 中迭代器的作⽤,有指针为何还要迭代器

迭代器

Iterator (迭代器)模式⼜称 Cursor (游标)模式,⽤于提供⼀种⽅法顺序访问⼀个聚合对象 中各个元素, ⽽⼜不需暴露该对象的内部表示。或者这样说可能更容易理解: Iterator模式是运⽤于聚合对象的⼀种模式,通过运⽤该模式,使得我们可以在不知道对象内部表示的情况下, 按照⼀定顺序(由iterator提供的⽅法)访问聚合对象中的各个元素。

由于Iterator模式的以上特性:与聚合对象耦合,在⼀定程度上限制了它的⼴泛运⽤,⼀般仅 ⽤于底层聚合⽀持类,如STL的list、 vector、 stack 等容器类及ostream_iterator等扩展 iterator。

迭代器和指针的区别

迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的⼀些功能,通过重载了指针的⼀些操作符,->、*、++、--等。迭代器封装了指针,是⼀个“可遍历STL( Standard T emplate Library)容器内全部或部分元素”的对象, 本质是封装了原⽣指针,是指针概念的⼀种提升(lift),提供了⽐指针更⾼级的⾏为,相当于⼀种智能指针,他可以根据不同类型的数据结构来实现不同的++,--等操作。

迭代器返回的是对象引⽤⽽不是对象的值,所以cout只能输出迭代器使⽤*取值后的值⽽不能直接输出其⾃身。

迭代器产⽣原因

Iterator类的访问⽅式就是把不同集合类的访问逻辑抽象出来,使得不⽤暴露集合内部的结构 ⽽达到循环遍历集合的效果。

6、回答⼀下 STL ⾥ resize和 reserve 的区别

resize():改变当前容器内含有元素的数ᰁ(size()),eg: vectorv; v.resize(len);v的size变为len,如 果原来v的size⼩于len,那么容器新增(len-size)个元素,元素的值为默认为0.当 v.push_back(3);之后,则是3是放在了v的末尾,即下标为len,此时容器是size为len+1; reserve():改变当前容器的最⼤容量(capacity),它不会⽣成元素,只是确定这个容器允许放⼊多少对象,如果reserve(len)的值⼤于当前的capacity(),那么会重新分配⼀块能存len个对象 的空间,然后把之前v.size()个对象通过 copy construtor 复制过来,销毁之前的内存;

六、情景设计题

1、 HelloWorld 程序开始到打印到屏幕上的全过程?

⽤户告诉操作系统执⾏ HelloWorld 程序(通过键盘输⼊等); 操作系统:找到 HelloWorld 程序的相关信息,检查其类型是否是可执⾏⽂件;并通过程序⾸部信息,确定代码和数据在可执⾏⽂件中的位置并计算出对应的磁盘块地址; 操作系统:创建⼀个新进程,将 HelloWorld 可执⾏⽂件映射到该进程结构,表示由该进程执⾏ HelloWorld程序; 操作系统:为 HelloWorld 程序设置 cpu 上下⽂环境,并跳到程序开始处;执⾏ HelloWorld 程序的第⼀条指令,发⽣缺⻚异常; 操作系统:分配⼀⻚物理内存,并将代码从磁盘读⼊内存,然后继续执⾏ HelloWorld 程序; HelloWorld 程序执⾏ puts 函数(系统调⽤),在显示器上写⼀字符串;

操作系统:找到要将字符串送往的显示设备,通常设备是由⼀个进程控制的,所以,操作系统将要写的字符串送给该进程; 操作系统:控制设备的进程告诉设备的窗⼝系统,它要显示该字符串,窗⼝系统确定这是⼀个合法的操作,然后将字符串转换成像素,将像素写⼊设备的存储映像区;

视频硬件将像素转换成显示器可接收和⼀组控制数据信号; 显示器解释信号,激发液晶屏; OK,我们在屏幕上看到了 HelloWorld;

2、⼿写实现智能指针类

template<typename T> 

class SharedPtr {

 private:

 size_t* m_count_; _

_T* m_ptr_; 

public: 

//构造函数 

SharedPtr(): m_ptr_(nullptr),m_count_(new size_t) {} 

SharedPtr(T* ptr): m_ptr_(ptr),m_count_(new size_t) { m_count_ = 1;}

 //析构函数 

~SharedPtr() { 

-- (*m_count_);*

 if (*m_count_ == 0) { 

delete m_ptr_; _

_delete m_count_;

 m_ptr_ = nullptr; 

m_count_ = nullptr; 

}

 } 

//拷⻉构造函数

 SharedPtr(const SharedPtr& ptr) {

 m_count_ = ptr.m_count_; _

_m_ptr_ = ptr.m_ptr_;_

 ++(*m_count_); _*

*_} _*

//拷⻉赋值运算 

void operator=(const SharedPtr& ptr) { SharedPtr(std::move(ptr)); } 

//移动构造函数 

SharedPtr(SharedPtr&& ptr) : m_ptr_(ptr.m_ptr_), 

m_count_(ptr.m_count_) { ++(*m_count_); }

//移动赋值运算
void  operator=(SharedPtr&&  ptr)  {  SharedPtr(std::move(ptr));  }
//解引⽤
T&  operator*()  {  return  *m_ptr_;  }
//箭头运算
T*  operator->()  {  return  m_ptr_;  }
//⃞载bool操作符
operator  bool()  {return  m_ptr_  ==  nullptr;}
T*  get()  {  return  m_ptr_;}
size_t  use_count()  {  return  *m_count_;}
bool  unique()  {  return  *m_count_  ==  1;  }
void  swap(SharedPtr&  ptr)  {  std::swap(*this ,  ptr);  }
};

3、⼿写字符串函数 strcat, strcpy, strncpy, memset, memcpy实现

//把  src  所指向的字符串复制到  dest,注意:  dest定义的空间应该⽐src⼤。
char*  strcpy(char  *dest ,const  char  *src)  {
char  *ret  =  dest;
assert(dest!=NULL);//优化点1:检查输⼊参数
assert(src !=NULL);
while(*src !='\0')
*(dest++)=*(src++);
*dest= '\0';//优化点2:⼿动地将最后的'\0'补上
return  ret;
}
//考虑内存重叠的字符串拷贝函数优化的点
char*  strcpy(char  *dest ,char  *src)  {
char  *ret  =  dest;
assert(dest!=NULL);
assert(src !=NULL);
memmove(dest ,src ,strlen(src)+1);
return  ret;
}
//把  src  所指向的字符串追加到  dest  所指向的字符串的结尾。
char*  strcat(char  *dest ,const  char  *src)  {


//1.  将⽬的字符串的起始位置先保存,最后要返回它的头指针
//2.  先找到dest的结束位置 ,再把src拷⻉到dest中,记得在最后要加上'\0'
char  *ret  =  dest;
assert(dest!=NULL);
assert(src !=NULL);
while(*dest!='\0')
dest++ ;
while(*src !='\0')
*(dest++)=*(src++);
*dest= '\0';
return  ret;
}
//把  str1  所指向的字符串和  str2  所指向的字符串进⾏⽐较。
//该函数返回值如下:
//如果返回值  <  0,则表示   str1  ⼩于  str2。
//如果返回值  >  0,则表示   str1  ⼤于  str2。
//如果返回值  =  0,则表示   str1  等于  str2。
int  strcmp(const  char  *s1 ,const  char  *s2)  {
assert(s1 !=NULL);
assert(s2 !=NULL);
while(*s1 !='\0'  &&  *s2 !='\0')  {
if(*s1>*s2)
return  1;
else  if(*s1<*s2)
return  -1;
else  {
s1++ ,s2++ ;
}
}
//当有⼀个字符串已经⾛到结尾
if(*s1>*s2)
return  1;
else  if(*s1<*s2)
return  -1;
else
return  0;

}
//在字符串  str1  中查找第⼀次出现字符串  str2  的位置,不包含终⽌符   '\0'。
char*  strstr(char  *str1 ,char  *str2)  {
char*  s  =  str1;
assert(str1 !='\0');
assert(str2 !='\0');
if(*str2== '\0')
return  NULL;//若str2为空,则直接返回空
while(*s!='\0')  {//若不为空,则进⾏查询
char*  s1  =  s;
char*  s2  =  str2;
while(*s1 !='\0'&&*s2 !='\0'  &&  *s1==*s2)
s1++ ,s2++ ;
if(*s2== '\0')
return  str2;//若s2先结束
if(*s2 !='\0'  &&  *s1== '\0')
return  NULL;//若s1先结束⽽s2还没结束,则返回空
s++ ;
}

return  NULL;
}
//模拟实现memcpy函数从存储区 str2  复制n  个字符到存储区  dst。
void*  memcpy(void*dest ,  void*  src ,size_t  num)  {

void*  ret  =  dest  ;
size_t  i  =  0  ;
assert(dest   !=  NULL  )  ;
assert(src   !=  NULL)  ;
for(i  =  0;  i<num;  i++)  {
//因为void*  不能直接解引⽤,所以需要强转成char*再解引⽤
//此处的void*实现了泛型编程
*(char*)  dest  =  *(char*)  src  ;
dest  =  (char*)dest  +  1  ;
src  =  (char*)  src  +  1  ;
}
return  ret  ;
}

4、 C++ 模板是什么,底层怎么实现的?

编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产⽣不同的函数;编译器会对函数模板进⾏两次编译:在声明的地⽅对模板代码本身进⾏编译,在 调⽤的地⽅对参数替换后的代码进⾏编译。

这是因为函数模板要被实例化后才能成为真正的函数,在使⽤函数模板的源⽂件中包含函数模 板的头⽂件,如果该头⽂件中只有声明,没有定义,那编译器⽆法实例化该模板,最终导致链 接错误。

5、请你来写个函数在 main 函数执⾏前先运⾏

__attribute((destructor)),标记函数应当在程序结束之前(main结束之后,或者调⽤了 exit后)执⾏;

__attribute((constructor))void before() { 

printf("before main 1\n"); 

}
//第⼆种:全局 static 变量的初始化在程序初始阶段,先于 main 函数的执⾏ 

int test1(){ 

cout << "before main 2" << endl; 

return 1; 

} 

static int i = test1(); 

// 第三种:知乎⼤⽜ Milo Yip 的回答利⽤ lambda 表达式 

int a = []() {

 cout << "before main 3" << endl;

 return 0; }(); 

int main(int argc, char** argv) { 

cout << "main function" <<endl;

return 0;

}

输出:

before main 1

 before main 2

before main 3

 main function

6、请你来说⼀下 fork 函数

成功调⽤ fork() 会创建⼀个新的进程,它⼏乎与调⽤ fork() 的进程⼀模⼀样,这两个进程都会 继续运⾏。在⼦进程中,成功的fork( ) 调⽤会返回0。在⽗进程中 fork() 返回⼦进程的 pid。

如果出现错误, fork() 返回⼀个负值。

最常⻅的 fork() ⽤法是创建⼀个新的进程,然后使⽤ exec() 载⼊⼆进制映像,替换当前进程的 映像。这种情况下,派⽣(fork)了新的进程,⽽这个⼦进程会执⾏⼀个新的⼆进制可执⾏⽂件的映像。这种“派⽣加执⾏”的⽅式是很常⻅的。

在早期的 Unix 系统中,创建进程⽐较原始。当调⽤ fork 时,内核会把所有的内部数据结构复制⼀份,复制进程的⻚表项,然后把⽗进程的地址空间中的内容逐⻚的复制到⼦进程的地址空 间中。但从内核⻆度来说,逐⻚的复制⽅式是⼗分耗时的。现代的 Unix 系统采取了更多的优 化,例如 Linux,采⽤了写时复制的⽅法,⽽不是对⽗进程空间进程整体复制。

7、说⼀下 ++i和 i++ 的区别

++i (前置加加)先⾃增 1再返回, i++ (后置加加)先返回 i 再自增1。

前置加加不会产⽣临时对象,后置加加必须产⽣临时对象,临时对象会导致效率降低

++i 实现:

int& int::operator++ (){ 

*this +=1;

 return *this;

 }

i++ 实现:

const int int::operator(int){

 int oldValue = *this;*

++(*this);

 return oldValue;

 }

8、简单说⼀下 printf实现原理?

在C/C++中,对函数参数的扫描是从后向前的。 C/C++的函数参数是通过压⼊堆栈的⽅式来给函数传参数的(堆栈是⼀种先进后出的数据结构)。

最先压⼊的参数最后出来,在计算机的内存中,数据有 2 块,⼀块是堆,⼀块是栈(函数参数及局部变量在这⾥),⽽栈是从内存的⾼地址向低地址⽣⻓的,控制⽣⻓的就是堆栈指针 了,最先压⼊的参数是在最上⾯,就是说在所有参数的最后⾯,最后压⼊的参数在最下⾯,结构上看起来是第⼀个,所以最后压⼊的参数总是能够被函数找到。

因为它就在堆栈指针的上⽅。printf的第⼀个被找到的参数就是那个字符指针,就是被双引号括起来的那⼀部分,函数通过判断字符串⾥控制参数的个数来判断参数个数及数据类型,通过 这些就可算出数据需要的堆栈指针的偏移量了。

9、讲讲⼤端⼩端,如何检测

⼤端模式:是指数据的⾼字节保存在内存的低地址中,⽽数据的低字节保存在内存的⾼地址端。 ⼩端模式,是指数据的⾼字节保存在内存的⾼地址中,低位字节保存在在内存的低地址端。

直接读取存放在内存中的⼗六进制数值,取低位进⾏值判断

int a = 0x12345678;

int *c = &a;

c[0] == 0x12 ⼤端模式 

c[0] == 0x78 ⼩段模式

⽤共同体来进⾏判断

union 共同体所有数据成员是共享⼀段内存的,后写⼊的成员数据将覆盖之前的成员数据,成员数据都有相同的⾸地址。 Union 的⼤⼩为最⼤数据成员的⼤⼩。

union 的成员数据共⽤内存,并且⾸地址都是低地址⾸字节。 Int i= 1时:⼤端存储1放在最⾼位,⼩端存储1放在最低位。当读取char ch时,是最低地址⾸字节,⼤⼩端会显示不同的值。

\#include<stdio.h>

  int main() {

 union { 

int a; //4 bytes

 char b; //1 byte

 } data; 1//占4 bytes,⼗六进制可表示为 0x 00 00 00 01  

//b因为是char型只占1Byte,a因为是int型占4Byte 

//所以,在联合体data所占内存中,b所占内存等于a所占内存的低地址部分

 if(1 == data.b) { 

//⾛到这⾥意味着说明a的低字节,被取给到了b 

//即a的低字节存在了联合体所占内存的(起始)低地址,符合⼩端模式特

printf("Little_Endian\n"); 

} else {

 printf("Big_Endian\n");

 } 

return 0;

 }

10、分别写出 bool, int, float,指针类型的变量a 与“零”的⽐较语句。

bool:if ( !a ) or if(a)

int: if ( a == 0) 

float: const EXPRESSION EXP = 0.000001 if ( a <= EXP && a >= -EXP) 

pointer : if ( a != NULL) or if(a == NULL)

⽆论是 float 还是 double 类型的变量,都有精度限制。所以⼀定要避免将浮点变量⽤“==”或“!=”与数字⽐较,应该设法转化成“>=”或“<=”形式。。

11、回调函数的作⽤

当发⽣某种事件时,系统或其他函数将会⾃动调⽤你定义的⼀段函数;

回调函数就相当于⼀个中断处理函数,由系统在符合你设定的条件时⾃动调⽤。为此,你需要 做三件事: 1,声明; 2,定义; 3,设置触发条件,就是在你的函数中把你的回调函数名称转 化为地址作为⼀个参数,以便于系统调⽤;

回调函数就是⼀个通过函数指针调⽤的函数。如果你把函数的指针(地址)作为参数传递给另 ⼀个函数,当这个指针被⽤为调⽤它所指向的函数时,我们就说这是回调函数;

因为可以把调⽤者与被调⽤者分开。调⽤者不关⼼谁是被调⽤者,所有它需知道的,只是存在 ⼀个具有某种特定原型、某些限制条件(如返回值为int)的被调⽤函数。

七、 C++11 新特性

C++11 的特性主要包括下⾯⼏个⽅⾯:

提⾼运⾏效率的语⾔特性:右值引⽤、泛化常量表达式

原有语法的使⽤性增强:初始化列表、统⼀的初始化语法、类型推导、范围 for 循环、 Lambda 表达式、final 和 override、构造函数委托

语⾔能⼒的提升:空指针 nullptr、default 和 delete、⻓整数、静态 assert

C++ 标准库的更新:智能指针、正则表达式、哈希表

1、空指针 nullptr

nullptr 出现的⽬的是为了替代 NULL。

在某种意义上来说,传统 C++ 会把 NULL、 0 视为同⼀种东⻄,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0。 C++ 不允许直接将 void * 隐式转换到其他类型,但如果 NULL 被定义为 ((void*)0),那么当编译 char *ch = NULL; 时, NULL 只好被定义为 0。⽽这依然会产⽣问题,将导致了 C++ 重载特性会发⽣混乱,考虑:

oid func(int);

void func(char *); 

对于这两个函数来说,如果 NULL ⼜被定义为了 0 那么 func(NULL) 这个语句将 会去调⽤ func(int),从⽽导致代码违反直观。

为了解决这个问题, C++11 引⼊了 nullptr 关键字,专⻔⽤来区分空指针、 0。 nullptr 的类型 为nullptr_t,能够隐式 的转换为任何指针或成员指针的类型,也能和他们进⾏相等或者不等的⽐较。

当需要使⽤ NULL 时候,养成直接使⽤ nullptr 的习惯。

2、 Lambda 表达式

Lambda 表达式实际上就是提供了⼀个类似匿名函数的特性,⽽匿名函数则是在需要⼀个函 数,但是⼜不想费⼒去命名⼀个函数的情况下去使⽤的。 利⽤ lambda 表达式可以编写内嵌的匿名函数,⽤以替换独⽴函数或者函数对象,并且使代 码更可读。

从本质上来讲, lambda 表达式只是⼀种语法糖,因为所有其能完成的⼯作都可以⽤其它稍微复杂的代码来实现,但是它简便的语法却给 C++ 带来了深远的影响。 从⼴义上说, lamdba 表达式产⽣的是函数对象。在类中,可以重载函数调用运算符(),此时类的对象可以将具有类似函数的⾏为,我们称这些对象为函数对象(Function Object)或者仿函数(Functor).相⽐ lambda表达式,函数对象有⾃⼰独特的优势。

lambda 表达式⼀般都是从⽅括号[]开始,然后结束于花括号{},花括号⾥⾯就像定义函数那 样,包含了 lamdba 表达式体,⼀个最简单的例⼦如下:

// 定义简单的lambda表达式

auto basicLambda = [] { cout << "Hello, world!" << endl; };

basicLambda(); // 输出:Hello, world!

上⾯是最简单的 lambda 表达式,没有参数。如果需要参数,那么就要像函数那样,放在圆括 号⾥⾯,如果有返回值,返回类型要放在->后⾯,即拖尾返回类型,当然你也可以忽略返回类型, lambda会帮你⾃动推断出返回类型:

// 指明返回类型,托尾返回类型 

auto add = [](int a, int b) -> int { return a + b; }; 

// ⾃动推断返回类型

 auto multiply = [](int a, int b) { return a * b; };

 int sum = add(2, 5); // 输出:7

 int product = multiply(2, 5); // 输出:10

最前边的 [] 是 lambda 表达式的⼀个很重要的功能,就是闭包。 先说明⼀下 lambda 表达式的⼤致原理:每当你定义⼀个 lambda 表达式后,编译器会⾃动 ⽣成⼀个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。 那么在运⾏时,这个 lambda 表达式就会返回⼀个匿名的闭包实例,其实⼀个右值。所以,我 们上⾯的 lambda 表达式的结果就是⼀个个闭包实例。 闭包的⼀个强⼤之处是其可以通过传值或者引⽤的⽅式捕捉其封装作⽤域内的变量,前⾯的方括号就是⽤来定义捕捉模式以及变量,我们⼜将其称为 lambda 捕捉块。例⼦如下:

int main() {

 int x = 10;  

auto add_x = [x](int a) { return a + x; }; // 复制捕捉x,lambda 表达式⽆法修改此变量

auto multiply_x = [&x](int a) { return a * x; }; // 引⽤捕捉x,lambda 表达式可以修改此变量 

 cout << add_x(10) << " " << multiply_x(10) << endl; // 输出:20 100 

return 0; }

捕获的⽅式可以是引⽤也可以是复制,但是具体说来会有以下⼏种情况来捕获其所在作⽤域中的变量。 []:默认不捕获任何变量; [=]:默认以值捕获所有变量; [&]:默认以引⽤捕获所有变量; [x]:仅以值捕获x,其它变量不捕获; [&x]:仅以引⽤捕获x,其它变量不捕获; [=, &x]:默认以值捕获所有变量,但是x是例外,通过引⽤捕获; [&, x]:默认以引⽤捕获所有变量,但是x是例外,通过值捕获; [this]:通过引⽤捕获当前对象(其实是复制指针); [*this]:通过传值⽅式捕获当前对象;

⽽ lambda 表达式⼀个更重要的应⽤是其可以⽤于函数的参数,通过这种⽅式可以实现回调函数。其实,最常⽤的是在STL算法中,⽐如你要统计⼀个数组中满⾜特定条件的元素数ᰁ, 通过 lambda 表达式给出条件,传递给 count_if 函数:

int val = 3; 
vector v {1, 8, 5, 3, 6, 10};

int count = std::count_if(v.beigin(), v.end(), [val](int x) { return x > val; });
// v中⼤于3的元素数ᰁ

最后给出 lambda 表达式的完整语法: [ capture-list ] ( params ) mutable(optional) constexpr(optional)(c++17) exception attribute -> ret { body }

// 可选的简化语法 [ capture-list ] ( params ) -> ret { body } [ capture-list ] ( params ) { body } [ capture-list ] { body }

capture-list:捕捉列表,这个不⽤多说,前⾯已经讲过,它不能省略; params:参数列表,可以省略(但是后⾯必须紧跟函数体); mutable:可选,将 lambda 表达式标记为 mutable 后,函数体就可以修改传值⽅式捕 获的变量; constexpr:可选, C++17 ,可以指定 lambda 表达式是⼀个常量函数; exception:可选,指定 lambda 表达式可以抛出的异常; attribute:可选,指定 lambda 表达式的特性; ret:可选,返回值类型; body:函数执⾏体。

3、右值引用

C++03 及之前的标准中,右值是不允许被改变的,实践中也通常使⽤ const T& 的⽅式传递 右值。然⽽这是效率低下的做法,例如

Person get(){
 Person p;
 return p;
}
Person p = get();

上述获取右值并初始化 p 的过程包含了 Person 的3个构造过程和2个析构过程。 这是 C++ 受诟病的⼀点,但C++11 的右值引⽤特性允许我们对右值进⾏修改。借此可以实现 move语 义 ,即从右值中直接拿数据过来初始化或修改左值, 而且不需要重新构造左值后再析构右值。⼀个 move 构造函数是这样声明的:

class Person{
public:
 Person(Person&& rhs){...}
 ...
};

4、泛化的常量表达式

还记得刚开始学习 C++ 给你的苦恼吗?你看:

int N = 5;
int arr[N];

编译器会报错: error: variable length array declaration not allowed at file scope int arr[N]; ,但 N 就是5,不过编译器不知道这⼀点,于是我们需要声明为 const int N = 5 才可以。但C++11的泛化常数给出了解决⽅案:

constexpr int N = 5; // N 变成了⼀个只读的值
int arr[N]; // OK

constexpr 告诉编译器这是⼀个编译期常ᰁ,甚⾄可以把⼀个函数声明为编译器常量表达式。

constexpr int getFive(){ return 5; }
int arr[getFive() + 1];

5、初始化列表

接下来⼏个特性属于原有语⾔特性的使⽤性增强。这意味着这些操作原来也是可以实现的, 不过现在语法上更加简洁。⽐如⾸先要介绍的初始化列表。 ⽽ C++11 提供了 initializer_list 来接受变⻓的对象初始化列表:

class A{
public:
 A(std::initializer_list<int> list);
};
A a = {1, 2, 3};

注意初始化列表特性只是现有语法增强,并不是提供了动态的可变参数。该列表只能静态地构 造。

6、统⼀的初始化语法

不同的数据类型具有不同的初始化语法。如何初始化字符串?如何初始化数组?如何初始化多 维数组?如何初始化对象? C++11给出了统⼀的初始化语法:均可使⽤“{}-初始化变量列表”:

X x1 = X{1,2};
X x2 = {1,2}; // 此处的'='可有可⽆
X x3{1,2};
X* p = new X{1,2};
struct D : X {
 D(int x, int y) :X{x,y} { /* … */ };
};
struct S {
 int a[3];
 // 对于旧有问题的解决⽅案
 S(int x, int y, int z) :a{x,y,z} { /* … */ };
};

7、类型推导

C++ 提供了 auto 和 decltype 来静态推导类型,在我们知道类型没有问题但⼜不想完整地写 出类型的时候, 便可以使⽤静态类型推导。

for(vector<int>::const_iterator it = v.begin(); it != v.end(); ++it);
// 可以改写为
for(auto it = v.begin(); it != v.end(); ++it);

虽然写起来和动态语⾔(如JavaScript的 var )很像,但C++仍然是强类型的,会执 ⾏静态类型检查的语⾔。 这只是语法上的简化,并未改变C++的静态类型检查。

decltype ⽤于获取⼀个表达式的类型,⽽不对表达式进⾏求值(类似于sizeof)。 decltyp(e) 规则如下:

若 e 为⼀个⽆括号的变量、函数参数、类成员,则返回类型为该变量/参数/类成员在源程 序中的声明类型;

否则的话,根据表达式的值分类(value categories),设 T 为 e 的类型: 若 e 是⼀个左值(lvalue,即“可寻址值”),返回 T& ; 若 e 是⼀个临终值(xvalue),则返回值为 T&& ; 若 e 是⼀个纯右值(prvalue),则返回值为 T 。

const std::vector<int> v(1);
const int&& foo(); // 返回临终值:⽣命周期已结束但内存还未拿⾛
auto a = v[0]; // a 为 int
decltype(v[0]) b = 0; // b 为 const int&
 // 即 vector<int>::operator[](size_type) const 的
返回值类型
auto c = 0; // c, d 均为 int
auto d = c; 
decltype(c) e; // e 为 int,即 c 的类型
decltype((c)) f = e; // f 为 int&,因为 c 是左值
decltype(0) g; // g 为 int,因为 0 是右值

8、基于范围的for循环

Boost 中定义了很多"范围",很多标准库函数都使⽤了范围⻛格的实现。这⼀概念被C++11提 了出来:

int arr[5];
std::vector<int> v;
for(int x: arr);
for(const int& x: arr);
for(int x: v);
for(auto &x: v);

9、构造函数委托

在 C# 和 Java 中,⼀个构造函数可以调⽤另⼀个来实现代码复⽤,但 C++⼀直不允许这样 做。

现在可以了,这使得构造函数可以在同⼀个类中⼀个构造函数调⽤另⼀个构造函数,从⽽达到简化代码的⽬的:

class myBase {
 int number; string name;
 myBase( int i, string& s ) : number(i), name(s){}
public:
 myBase( ) : myBase( 0, "invalid" ){}
 myBase( int i ) : myBase( i, "guest" ){}
 myBase( string& s ) : myBase( 1, s ){ PostInit(); }
};

10、 final 和 override

C++ 借由虚函数实现运⾏时多态,但 C++ 的虚函数⼜很多脆弱的地⽅:

⽆法禁⽌⼦类重写它。可能到某⼀层级时,我们不希望⼦类继续来重写当前虚函数了。

容易不⼩⼼隐藏⽗类的虚函数。⽐如在重写时,不⼩⼼声明了⼀个签名不⼀致但有同样名称的新函数。

C++11 提供了 final 来禁⽌虚函数被重/禁⽌类被继承, override 来显示地重写虚函 数。 这样编译器给我们不⼩⼼的⾏为提供更多有⽤的错误和警告。

struct Base1 final { }; 
struct Derived1 : Base1 {}; // 编译错:Base1不允许被继承
struct Base2 {
 virtual void f1() final;
 virtual void f2();
};
struct Derived2 : Base2 {
 virtual void f1(); // 编译错:f1不允许᯿写
 virtual void f2(int) override; // 编译错:⽗类中没有 void f2(int)
};

11、 default 和 delete

我们知道编译器会为类⾃动⽣成⼀些⽅法,⽐如构造和析构函数(完整的列表⻅ Effective C++: Item 5)。

现在我们可以显式地指定和禁⽌这些⾃动⾏为了。

struct classA {
 classA() = defauult; // 声明⼀个⾃动⽣成的函数
 classA(T value);
 void *operator new(size_t) = delete; // 禁⽌⽣成new运算符
};

在上述 classA 中定义了 classA(T value) 构造函数,因此编译器不会默认⽣成⼀个⽆参数 的构造函数了, 如果我们需要可以⼿动声明,或者直接 = default 。

12、静态 assertion

C++ 提供了两种⽅式来 assert :⼀种是 assert 宏,另⼀种是预处理指令 #error。 前者在 运⾏期起作⽤,⽽后者是预处理期起作⽤。它们对模板都不好使,因为模板是编译期的概念。 static_assert 关键字的使⽤⽅式如下:

template< class T >
struct Check {
 static_assert( sizeof(int) <= sizeof(T), "T is not big enough!" ) ;
} ;

13、智能指针

接下来介绍 C++11 对于 C++ 标准库的变更。 C++11 把 TR1 并⼊了进来,废弃了 C++98 中 的 auto_ptr, 同时将 shared_ptr 和 uniq_ptr 并⼊ std 命名空间。 智能指针在 [Effective C++: Item 13] 中已经有不少讨论了。这⾥给⼀个例⼦:

int main(){
 std::shared_ptr<double> p_first(new double);
 {
 std::shared_ptr<double> p_copy = p_first;
 *p_copy = 21.2;
 } // p_copy 被销毁,⾥⾯的 double 还有⼀个引⽤因此仍然保持
 return 0; // p_first 及其⾥⾯的 double 销毁
}

14、正则表达式

这个任何⼀⻔现代的编程语⾔都会提供的特性终于进标准:

const char *reg_esp = "[ ,.\\t\\n;:]";
std::regex rgx(reg_esp) ;
std::cmatch match ; 
const char *target = "Polytechnic University of Turin " ;
if( regex_search( target, match, rgx ) ) {
 const size_t n = match.size();
 for( size_t a = 0 ; a < n ; a++ ) {
 string str( match[a].first, match[a].second ) ;
 cout << str << "\n" ;
 }
}

15、增强的元组

在 C++ 中本已有⼀个 pair 模板可以定义⼆元组, C++11 更进⼀步地提供了边⻓参数的 tuple 模板:

typedef std::tuple< int , double, string > tuple_1 t1;
typedef std::tuple< char, short , const char * > tuple_2 t2 ('X', 2,
"Hola!");
t1 = t2 ; // 隐式类型转换

16、哈希表

C++ 的 map , multimap , set , multiset 使⽤红⿊树实现, 插⼊和查询都是 O(lgn) 的复杂 度,

但 C++11 为这四种模板类提供了(底层哈希实现)以达到 O(1) 的复杂度:

散列表类型
有⽆关系值
接受相同键值

std::unordered_set

std::unordered_multiset

std::unordered_map

std::unordered_multimap

⼋、数据结构和算法

1、⼗⼤排序算法及其时间和空间复杂度

(1)冒泡排序

算法描述: ⽐较相邻的元素。如果第⼀个⽐第⼆个⼤,就交换它们两个; 对每⼀对相邻元素作同样的⼯作,从开始第⼀对到结尾的最后⼀对,这样在最后的元素应 该会是最⼤的数; 针对所有的元素重复以上的步骤,除了最后⼀个; 重复步骤 1~3,直到排序完成。 ⽤⼀个例⼦,带你看下冒泡排序的整个过。我们要对⼀组数据 4,5,6,3,2,1,从⼩到 到⼤进⾏排序。第⼀次冒泡操作的详细过程就是这样

                                                        ![](https://files.mdnice.com/user/11419/12cc6518-b049-4b02-b125-9826e1df857c.png)****

可以看出,经过⼀次冒泡操作之后,6 这个元素已经存储在正确的位置上。要想完成所有数据 的排序,我们只要进⾏ 6 次这样的冒泡操作就⾏了。

                                                                          ![  ](https://files.mdnice.com/user/11419/41d67fa8-3574-43ea-8622-6ce617477726.png)

下⾯代码中 std::swap 函数的源代码如下,可以看到有三个赋值操作:

template<class T>
void swap(T &a, T &b) {
 T temp = a;
 a = b;
 b = temp;
}
void BubbleSort(std::vector<int> &nums, int n) {
 if (n <= 1) return;
 bool is_swap;
 for (int i = 1; i < n; ++i) {
 is_swap = false;
 //设定⼀个标记,若为false,则表示此次循环没有进⾏交换,也就是待排序列已经有
序,排序已经完成。
 for (int j = 1; j < n - i + 1; ++j) {
 if (nums[j] < nums[j-1]) {
 std::swap(nums[j], nums[j-1]);
 is_swap = true;//表示有数据交换
 }
 }
 if (!is_swap) break;//没有数据交集,提前退出
}
}
int main() {
 int a[] = {34,66,2,5,95,4,46,27};
 BubbleSort(a,sizeof(a)/sizeof(int)); //cout => 2 4 5 27 34 46 66 95
 return 0;
}

(2)插⼊排序

算法描述:分为已排序和未排序 初始已排序区间只有⼀个元素 就是数组第⼀个 遍历未排序的 每⼀个元素在已排序区间⾥找到合适的位置插⼊并保证数据⼀直有序。

                                                                                          ![](https://files.mdnice.com/user/11419/360abfb4-51a3-4660-9c1c-747b4df5b377.png)
void InsertSort(std::vector<int> &nums,int n) {
 if (n <= 1) return;
 for(int i = 0; i < n; ++i) {
 for (int j = i; j > 0 && nums[j] < nums [j-1]; --j) {
 std::swap(nums[j],nums[j-1]);
 }
 }
}
int main() {
 std::vector<int> nums = {4,6,5,3,2,1};
 InsertSort(nums,6);//cout => 1,2,3,4,5,6
 return 0;
}

(3)选择排序

算法描述:分已排序区间和未排序区间。每次会从未排序区间中找到最⼩的元素,将其放到已排序区间的末尾。

                                                                                  ![](https://files.mdnice.com/user/11419/fdb17bef-c6ff-4385-a75e-e46ff55c2c4d.png)
void SelectSort(std::vector<int> &nums, int n) {
 if (n <= 1) return;
 int mid;
 for (int i = 0; i < n - 1; ++i) {
 mid = i;
 for (int j = i + 1; j < n; ++j) {
 if (nums[j] < nums[mid]) {
 mid = j;
 }
 }
 std::swap(nums[mid],nums[i]);
 }
}

【时间 ,空间复杂度/是否稳定?】

⾸先,选择排序空间复杂度为 O(1),是⼀种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为O(n)。你可以⾃⼰来分析看看。

那选择排序是稳定的排序算法吗?答案是否定的,选择排序是⼀种不稳定的排序算法。从图中,你可以看出来,选择排序每次都要找剩余未排序元素中的最⼩值,并和前⾯的元素交换位 置,这样破坏了稳定性。 【思考】冒泡排序和插⼊排序的时间复杂度都是 O(n),都是原地排序算法,为什么插⼊排序 要⽐冒泡排序更受欢迎呢?

【思路】冒泡排序不管怎么优化,元素交换的次数是⼀个固定值,是原始数据的逆序度。插⼊ 排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。但是,从代码实现 上来看,冒泡排序的数据交换要⽐插⼊排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,⽽插⼊排序只需要 1 个。把执⾏⼀个赋值语句的时间粗略地计为单位时间,处理相同规模的数,插⼊排序⽐冒泡排序减少三倍的单位时间!

(4)快排

算法描述:先找到⼀个枢纽;在原来的元素⾥根据这个枢纽划分 ⽐这个枢纽⼩的元素排前 ⾯;⽐这个枢纽⼤的元素排后⾯;两部分数据依次递归排序下去直到最终有序。

void QuickSort(std::vector<int> &nums,int l,int r) {
 if (l + 1 >= r) return;
 int first = l, last = r - 1 ,key = nums[first];
 while (first < last) {
 while (first < last && nums[last] >= key) last--;//右指针 从右向左
扫描 将⼩于piv的放到左边
 nums[first] = nums[last];
 while (first < last && nums[first] <= key) first++;//左指针 从左向
右扫描 将⼤于piv的放到右边
 nums[last] = nums[first];
 }
 nums[first] = key;//更新piv
 quick_sort(nums, l, first);//递归排序 //以L为中间值,分左右两部分递归调⽤
 quick_sort(nums, first + 1, r);
}
int main() {
 int a[] = {0,34,66,2,5,95,4,46,27};
 QuickSort(a, 0, sizeof(a)/sizeof(int));
 for(int i=0; i<=8; ++i) {
 std::cout<<a[i]<<" "; // print => 0 2 4 5 27 34 46 66 95
 }
 std::cout<<endl;
 return 0;
}

(5)归并排序

算法描述:归并排序是⼀个稳定的排序算法,归并排序的时间复杂度任何情况下都是O(nlogn),归并排序不是原地排序算法⽤两个游标 i 和j,分别指向 A[p…q] 和A[q+1…r] 的第⼀个元素。⽐较这两个元素 A[i] 和 A[j],如果 A[i]<=A[j],我们就把 A[i] 放⼊到临时数组 tmp,并且 i 后移⼀位,否则将 A[j] 放⼊ 到数组 tmp, j 后移⼀位。

void mergeCount(int a[],int L,int mid,int R) {
 int *tmp = new int[L+mid+R];
 int i=L;
 int j=mid+1;
 int k=0;
 while( i<=mid && j<=R ) {
 if(a[i] < a[j])
 tmp[k++] = a[i++];
 else
 tmp[k++] = a[j++];
 }
 ///判断哪个⼦数组中有剩余的数据
 while( i<=mid )
 tmp[k++] = a[i++];
 while( j<=R)
 tmp[k++] = a[j++];
 /// 将 tmp 中的数组拷⻉回 A[p...r]
 for(int p=0; p<k; ++p)
 a[L+p] = tmp[p];
 delete tmp;
}
void mergeSort(int a[],int L,int R) {
 ///递归终⽌条件 分治递归
 /// 将 A[L...m] 和 A[m+1...R] 合并为 A[L...R]
 if( L>=R ) { return; }
 int mid = L + (R - L)/2;
 mergeSort(a,L,mid);
 mergeSort(a,mid+1,R);
 mergeCount(a,L,mid,R);
}
int main() {
 int a[] = {0,34,66,2,5,95,4,46,27};
 mergeSort(a, 0, sizeof(a)/sizeof(int));
 for(int i=0; i<=8; ++i) {
 std::cout<<a[i]<<" "; // print => 0 2 4 5 27 34 46 66 95
 }
 std::cout<<endl;
 return 0;
}

(6)堆排序

算法描述:利⽤堆这种数据结构所设计的⼀种排序算法。堆积是⼀个近似完全⼆叉树的结构, 并同时满⾜堆积的性质:即⼦结点的键值或索引总是⼩于(或者⼤于)它的⽗节点。堆排序可 以⽤到上⼀次的排序结果,所以不像其他⼀般的排序⽅法⼀样,每次都要进⾏ n-1 次的⽐较, 复杂度为O(nlogn) 。

算法步骤:

1、利⽤给定数组创建⼀个堆 H[0..n-1] (我们这⾥使⽤最⼩堆),输出堆顶元素

2、以最后⼀个元素代替堆顶,调整成堆,输出堆顶元素

3、把堆的尺⼨缩⼩ 1

4、重复步骤 2,直到堆的尺⼨为 1

建堆:将数组原地建成⼀个堆,不借助额外的空间,采⽤从上往下的堆化(对于完全⼆叉树来 说,下标是 n/2+1 到 n 的节点都是叶⼦节点,不需要堆化)。

排序: ”删除堆顶元素“:当堆顶元素移除之后,把下标为 n 的元素放到堆顶,然后在通过堆化 的⽅法,将剩下的 n - 1 个元重新构建成堆,堆化完成之后,在取堆顶的元素,放到下标为 n-1 的位置,一直重复这个过程,直到最后堆中只剩下标 1 的⼀个元素。

/*
优点:O(nlogn),原地排序,最⼤的特点:每个节点的值⼤于等于(或⼩于等于)其⼦树节点
(7)桶排序
算法描述:将数组分到有限数ᰁ的桶⾥。每个桶再个别排序(有可能再使⽤别的排序算法或是
以递归⽅式继续使⽤桶排序进⾏排序)。
缺点:相⽐于快排,堆排序数据访问的⽅式没有快排友好;数据的交换次数要多于快排。
*/
void HeapSort(int a[], int n) {
 for(int i=n/2; i>=1; --i) {
 Heapify(a, n, i);
 }
 int k = n;
 while( k > 1) {
 swap(a[1],a[k]);
 --k;
 Heapify(a,k,1);
 }
}
void Heapify(int a[], int n, int i) {
 while (1) {
 int maxPos = i;
 if (i*2 <= n && a[i] < a[i*2]) { maxPos = i*2; }
 if (i*2+1 <= n && a[maxPos] < a[i*2+1]) { maxPos = i*2+1; }
 if (maxPos == i) break;
 std::swap(a[i], a[maxPos]);
 i = maxPos;
 }
}
void

(8)计数排序

扩展:如果在⾯试中有⾯试官要求你写⼀个 O(n) 时间复杂度的排序算法,可不要傻乎乎的说 这不可能!虽然前⾯基于⽐较的排序的下限是O(nlogn)。但是确实也有线性时间复杂度的排 序,只不过有前提条件,就是待排序的数要满⾜⼀定的范围的整数,⽽且计数排序需要⽐较多 的辅助空间。

算法描述:其基本思想是,⽤待排序的数作为计数数组的下标,统计每个数字的个数。然后依 次输出即可得到有序序列。 假设有 8 个考⽣,分数在 0 到 5 分之间。这 8 个考⽣的成绩我们放在⼀个数组 A[8]中,它们分别是: 2, 5, 3, 0, 2, 3, 0, 3 。 考⽣的成绩从 0 到 5 分,我们使⽤⼤⼩为 6 的数组 C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考⽣,⽽是对应的考⽣个数。像我刚刚举的那个例⼦,我们只需要遍历⼀ 遍考⽣分数,就可以得到 C[6]的值。

这是我们的数组,从图中可以看出,分数为 3 分的考⽣有 3 个,⼩于 3 分的考⽣有 4 个,所 以,成绩为 3 分的考⽣在排序之后的有序数组 R[8]中,会保存下标 4, 5, 6 的位置。

那我们如何快速计算出,每个分数的考⽣在有序数组中对应的存储位置呢?

我们对 C[6] 数组顺序求和, C[6]存储的数据就变成了下⾯这样⼦。 C[k]⾥存储⼩于等于分数 k的考⽣个数。

我们从后到前依次扫描数组 A。⽐如,当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的 值 7,也就是说,到⽬前为⽌,包括⾃⼰在内,分数⼩于等于 3 的考⽣有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放⼊到数组 R 中后,⼩ 于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3]要减 1,变成 6。

以此类推,当我们扫描到第 2 个分数为 3 的考⽣的时候,就会把它放⼊数组 R 中的第 6 个元 素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按 照分数从⼩到⼤有序排列的了。 注意:计数排序只能⽤在数据范围不⼤的场景中,如果数据范围 k ⽐要排序的数据 n ⼤很多,就不适合⽤计数排序了。⽽且,计数排序只能给⾮负整数排序,如果要排序的数据是其他 类型的,要将其在不改变相对⼤⼩的情况下,转化为⾮负整数。

void countSort(int *a,int n){
 int maxV=a[0];
 for(int i=1; i<n; ++i){
 maxV=max(maxV,a[i]);
 }
 int c[maxn];
 memset(c,0,sizeof(c));
 for(int i=0; i<n; ++i){
        c[a[i]]++;
 }
 for(int i=1; i<=maxV; ++i){
 c[i]+=c[i-1];
 }
 int r[maxn];
 memset(r,0,sizeof(r));
 for(int i=n-1; i>=0; --i){
 int index = c[a[i]]-1;
 r[index]=a[i];
 c[a[i]]--;
 }
 for(int i=0; i<n; ++i){
 a[i]=r[i];
 }
}

(9)基数排序

算法描述:基数排序对要排序的数据是有要求的,需要可以分割出独⽴的“位”来⽐较,⽽且位之间有递进的关系,如果 a 数据的⾼位⽐ b 数据⼤,那剩下的低位就不⽤⽐较了。除此之外,每⼀位的数据范围不能太⼤,要可以⽤线性排序算法来排序,否则,基数排序的时间复杂 度就⽆法做到 O(n) 了。

基数排序相当于通过循环进⾏了多次桶排序。

int getDigit(int x,int d){
 int t[]={1,1,10,100};
 return (x/t[d])%10;
}
void RadixSort(int *a,int begin,int end,int d){
 const int radix = 10;
 int c[maxn];
 int bucket[maxn];
 for(int k=1; k<=d; ++k){
memset(c,0,sizeof(c));
 for(int i=begin; i<=end; ++i){
 c[getDigit(a[i],k)]++;//计算i号桶⾥要放多少数
 }
 for(int i=1; i<radix; ++i) c[i]+=c[i-1];
 // 把数据依次装⼊桶(注意:装⼊时的分配技巧)
 for(int i=end; i>=begin; --i){
 int j=getDigit(a[i],k);//求出关键码的第k位的数值, 例如:576的第3
位是5
 bucket[c[j]-1]=a[i];//放⼊对应的桶中(count[j]-1)表示第k位数值为j
的桶底索引
 --c[j];//当前第k位数值为j的桶底边界索引减⼀
 }
 for(int i=begin,j=0;i<=end;++i,++j){// 从各个桶中收集数据
 a[i]=bucket[j];
 }
 }
}

(10)希尔排序

算法描述:通过将⽐较的全部元素分为⼏个区域来提升插⼊排序的性能。这样可以让⼀个元素 可以⼀次性地朝最终位置前进⼀⼤步。然后算法再取越来越⼩的步⻓进⾏排序,算法的最后⼀ 步就是普通的插⼊排序,但是到了这步,需排序的数据⼏乎是已排好的了。

// 希尔排序
void shellSort(vector<int>& nums) {
 for (int gap = nums.size() / 2; gap > 0; gap /= 2) {
 for (int i = gap; i < nums.size(); ++i) {
 for (int j = i; j - gap >= 0 && nums[j - gap] > nums[j]; j -= gap)
{
 swap(nums[j - gap], nums[j]);
 }
 }
 }
}

2、⼆叉树前中后遍历⼿撕代码(递归和⾮递归)

LeetCode 144. ⼆叉树的前序遍历

难度简单609

给你⼆叉树的根节点

示例 1:

image-20211017154213492
例子1:
输⼊:root = [1,null,2,3]
输出:[1,2,3]
例子2:
输⼊:root = []
输出:[]
例子3:
输⼊:root = [1]
输出:[1]

【思路】 由于“中左右”的访问顺序正好符合根结点寻找⼦节点的顺序,因此每次循环时弹栈,输出此弹 栈结点并将其右结点和左结点按照叙述顺序依次⼊栈。⾄于为什么要右结点先⼊栈,是因为栈 后进先出的特性。右结点先⼊栈,就会后输出右结点。

初始化: ⼀开始让root结点先⼊栈,满⾜循环条件

步骤: 弹栈栈顶元素,同时输出此结点 当前结点的右结点⼊栈 当前结点的左结点⼊栈重复上述过程 结束条件: 每次弹栈根结点后⼊栈⼦结点,栈为空时则说明遍历结束。

//递归版
/* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x),
left(left), right(right) {}
* };
*/
class Solution {
public:
 vector<int> ans;
 vector<int> preorderTraversal(TreeNode* root) {
 // 为空则直接返回
 if(root == NULL)
 return ans;
 ans.push_back(root->val);
 preorderTraversal(root->left);
 preorderTraversal(root->right);
 return ans;
 }
}
//⾮递归
class Solution {
public:
 std::vector<int>preorderTraversal(TreeNode* root) {
 std::vector<int>res;
 if (!root) return res;
 stack<TreeNode*> st;
 TreeNode* node = root;
 while (!st.empty() || node) {
 while(node) {
 st.push(node);
 res.push_back(node->val);
 node = node->left;
 }
node = st.top();
 st.pop();
 node = node->right;
 }
 return res;
 }
};

LeetCode 94. ⼆叉树的中序遍历

难度简单1035

给定⼀个⼆叉树的根节点 root ,返回它的 中序 遍历。

示例 1:

输⼊:root = [1,null,2,3]
输出:[1,3,2]

示例 2:

输⼊:root = []
输出:[]

示例 3:

输⼊:root = [1]
输出:[1]

示例 4:

输⼊:root = [1,2]
输出:[2,1]

示例 5:

输⼊:root = [1,null,2]
输出:[1,2]

【思路】

中序遍历思路相较于前序遍历有很⼤的改变。前序遍历遇到根结点直接输出即可,但中序遍历 “左中右”需先找到此根结点的左结点,因此事实上第⼀个被输出的结点会是整个⼆叉树的最左侧结点。

依据这⼀特性,我们每遇到⼀个结点,⾸先寻找其最左侧的⼦结点,同时⽤栈记录寻找经过的 路径结点,这些是输出最左侧结点之后的返回路径。

之后每次向上层⽗结点返回,弹栈输出上层⽗结点的同时判断此结点是否含有右⼦结点,如果 存在则此右结点⼊栈并到达新的⼀轮循环,对此右结点也进⾏上述操作。 初始化: curr定义为将要⼊栈的结点,初始化为root top定义为栈顶的弹栈结点 步骤:

寻找当前结点的最左侧结点直到curr为空(此时栈顶结点即为最左侧结点)弹栈栈顶结点top并输出 判断top是否具有右结点,如果存在则令curr指向右结点,并在下⼀轮循环入栈

重复上述过程 结束条件:这⾥可以看到结束条件有两个:栈为空, curr为空。这是因为中序遍历优中后右的特性,会有⼀个时刻栈为空但右结点并未被遍历,因此只有在curr也为空证明右结点 不存在的情况下,才能结束遍历。 【代码】

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), 
left(left), right(right) {}
 * };
 */
 
//递归法
// class Solution {
// public:
//     std::vector<int> ret;
//     vector<int> inorderTraversal(TreeNode* root) {
//         postOrder(root);
//         return ret;
//     }
//     void postOrder(TreeNode* root) {
//         if (root == nullptr) return;
//         inorderTraversal(root->left);
//         ret.push_back(root->val);
//         inorderTraversal(root->right);
//     }
// };
 
 
//迭代法
class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> ans;
        stack<TreeNode*> stk;
        TreeNode* curr = root;
        while(!stk.empty() || curr != NULL) {
            // 找到节点的最左侧节点,同时记录路径⼊栈
            while(curr != NULL) {
                stk.push(curr);
                curr = curr->left;
            }
            // top 定义是此刻的弹栈元素
            TreeNode* top = stk.top();
            ans.push_back(top->val);
              stk.pop();
            // 处理过最左侧结点后,判断其是否存在右⼦树
            if(top->right != NULL)
                curr = top->right;
        }
        return ans;
    }
};

LeetCode 145. ⼆叉树的后序遍历

难度简单630

给定⼀个⼆叉树,返回它的 后序 遍历。

示例:

输⼊: [1,null,2,3]  
   1
    \
     2
    /
   3 
 
输出: [3,2,1]

进阶: 递归算法很简单,你可以通过迭代算法完成吗?

【思路】

1、前序遍历的过程是中左右。 2、将其转化成中右左。也就是压栈的过程中优先压⼊左⼦树,再压⼊右⼦树。 3、在弹栈的同时将此弹栈结点压⼊另⼀个栈,完成逆序。 4、对新栈中的元素直接顺序弹栈并输出。

【代码】

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), 
left(left), right(right) {}
 * };
 */
/*
//递归版
class Solution {
public:
    vector<int> ans;
    vector<int> preorderTraversal(TreeNode* root) {
        // 为空则直接返回
        if(root == NULL)
            return ans;
        preorderTraversal(root->left);
        preorderTraversal(root->right);
        ans.push_back(root->val);
        return ans;
    }
};
*/
//⾮递归版
class Solution {
public:
    std::vector<int> postorderTraversal(TreeNode* root) {
        std::vector<int> res;
        if (!root) return res;
        std::stack<TreeNode*> st1;
        std::stack<TreeNode*> st2;
        st1.push(root);
        // 栈⼀顺序存储
        while (!st1.empty()){
            TreeNode* node = st1.top();
            st1.pop();
            st2.push(node);
            if (node->left) st1.push(node->left);
            if (node->right) st1.push(node->right);
        }
        // 栈⼆直接输出
        while (!st2.empty()) {
            res.push_back(st2.top()->val);
            st2.pop();
        }
        return res;
    }
};

九、计算机⽹络

1、 OSI 七层协议模型

OSI 模型(Open System Interconnection Model)是⼀个由 ISO 提出得到概念模 型,试图提供⼀个使各种不同的的计算机和⽹络在世界范围内实现互联的标准框架。

虽然OSI参考模型在实际中的应⽤意义并不是很⼤,但是它对于理解⽹络协议内部的 运作很有帮助,为我们学习⽹络协议提供了⼀个很好的参考。它将计算机⽹络体系结 构划分为7层,每层都为上⼀层提供了良好的接⼝。以下将具体介绍各层结构及功 能。

2、分层结构

OSI 参考模型采⽤分层结构,如图所示。 附上⼀张经典图。

主要分为以下七层(从下⾄上):物理层、数据链路层、⽹络层、传输层、会话层、 表示层、应⽤层。

3、各层功能

物理层

简单的说,物理层(Physical Layer)确保原始的数据可在各种物理媒体上传输。在这⼀ 层上⾯规定了激活,维持,关闭通信端点之间的机械性,电⽓特性,功能特性,为上层协 议提供了⼀个传输数据的物理媒体,这⼀层传输的是 bit 流。 数据链路层

数据链路层(Data Link Layer)在不可靠的物理介质上提供可靠的传输。该层的作⽤包 括:物理地址寻址、数据的成帧、流⃞控制、数据的检错、⃞发等。这⃞层中将 bit 流封 装成 frame 帧。 ⽹络层

⽹络层(Network Layer)负责对⼦⽹间的数据包进⾏路由选择。此外,⽹络层还可以实 现拥塞控制、⽹际互连等功能。在这⼀层,数据的单位称为数据包(packet)。 传输层

传输层是第⼀个端到端,即主机到主机的层次。传输层负责将上层数据分段并提供端到端 的、可靠的或不可靠的传输。此外,传输层还要处理端到端的差错控制和流⃞控制问题。 在这⼀层,数据的单位称为数据段(segment)。 会话层

这⼀层管理主机之间的会话进程,即负责建⽴、管理、终⽌进程之间的会话。会话层还利 ⽤在数据中插⼊校验点来实现数据的同步,访问验证和会话管理在内的建⽴和维护应⽤之 间通信的机制。如服务器验证⽤户登录便是由会话层完成的。使通信会话在通信失效时从 校验点继续恢复通信。 ⽐如说建⽴会话,如 session 认证、断点续传。 表示层

这⼀层主要解决⽤户信息的语法表示问题。它将欲交换的数据从适合于某⼀⽤户的抽象语 法,转换为适合于OSI系统内部使⽤的传送语法。即提供格式化的表示和转换数据服务。 数据的压缩和解压缩, 加密和解密等⼯作都由表示层负责。⽐如说图像、视频编码解, 数据加密。 应⽤层

这⼀层为操作系统或⽹络应⽤程序提供访问⽹络服务的接⼝。

4、各层传输协议、传输单元、主要功能性设备⽐较

名称
传输协议
传输单元
主要功能设备/接⼝

物理层

IEEE 802.1A、IEEE 802.2

bit-flow ⽐特流

光纤,双绞线,中继器,集线器,⽹线接⼝

数据链路层

ARP、MAC、 FDDI、Ethernet、Arpanet、PPP、PDN

frame 帧

⽹桥、⼆层交换机

⽹络层

IP、ICMP、ARP、RARP

数据包(packet)

路由器、三层交换机

传输层

TCP、UDP

Segment/Datagram

四层交换机

会话层

SMTP、DNS

报⽂

QoS

表示层

Telnet、SNMP

报⽂

-

应⽤层

FTP、TFTP、T elnet、HTTP、DNS

报⽂

-

5、描述TCP头部?

序号(32bit):传输⽅向上字节流的字节编号。初始时序号会被设置⼀个随机的初始值 (ISN),之后每次发送数据时,序号值 = ISN + 数据在整个字节流中的偏移。假设A ->B且ISN = 1024,第⼀段数据512字节已经到B,则第⼆段数据发送时序号为1024 + 512。 ⽤于解决⽹络包乱序问题。 确认号(32bit):接收⽅对发送⽅TCP报⽂段的响应,其值是收到的序号值 + 1。 ⾸部⻓(4bit):标识⾸部有多少个4字节 * ⾸部⻓,最⼤为15,即60字节。 标志位(6bit): URG:标志紧急指针是否有效。 ACK:标志确认号是否有效(确认报⽂段)。⽤于解决丢包问题。 PSH:提示接收端⽴即从缓冲读⾛数据。

RST:表示要求重新建⽴连接(复位报⽂段)。 SYN:表示请求建⽴⼀个连接(连接报⽂段)。 FIN:表示关闭连接(断开报⽂段)。 窗⼝(16bit):接收窗⼝。⽤于告知对⽅(发送⽅)本⽅的缓冲还能接收多少字节数据。⽤于解决流控。 校验和(16bit):接收端⽤CRC检验整个报⽂段有⽆损坏。

6、 TCP三次握⼿和挥⼿

1.三次握⼿过程? 第⼀次:客户端发含SYN位, SEQ_NUM = S的包到服务器。(客 -> SYN_SEND)

第⼆次:服务器发含ACK, SYN位且ACK_NUM = S + 1, SEQ_NUM = P的包到客户机。(服 -> SYN_RECV) 第三次:客户机发送含ACK位, ACK_NUM = P + 1的包到服务器。(客 ->ESTABLISH,服 -> ESTABLISH)

2.四次挥⼿过程? 第⼀次:客户机发含FIN位, SEQ = Q的包到服务器。(客 -> FIN_WAIT_1) 第⼆次:服务器发送含ACK且ACK_NUM = Q + 1的包到服务器。(服 ->CLOSE_WAIT,客 -> FIN_WAIT_2) 此处有等待 第三次:服务器发送含FIN且SEQ_NUM = R的包到客户机。(服 -> LAST_ACK,客-> TIME_WAIT) 此处有等待 第四次:客户机发送最后⼀个含有ACK位且ACK_NUM = R + 1的包到客户机。(服 -CLOSED)

3.为什么握⼿是三次,挥⼿是四次? 对于握⼿:握⼿只需要确认双⽅通信时的初始化序号,保证通信不会乱序。(第三次握⼿必要性:假设服务端的确认丢失,连接并未断开,客户机超时重发连接请求,这样服务器会对同⼀个客户机保持多个连接,造成资源浪费。) 对于挥⼿: TCP是双⼯的,所以发送⽅和接收⽅都需要FIN和ACK。只不过有⼀⽅是被动的,所以看上去就成了4次挥⼿。

4.TCP连接状态? CLOSED:初始状态。 LISTEN:服务器处于监听状态。

SYN_SEND:客户端socket执⾏CONNECT连接,发送SYN包,进⼊此状态。 SYN_RECV:服务端收到SYN包并发送服务端SYN包,进⼊此状态。 ESTABLISH:表示连接建⽴。客户端发送了最后⼀个ACK包后进⼊此状态,服务端接收到ACK包后进⼊此状态。 FIN_WAIT_1:终⽌连接的⼀⽅(通常是客户机)发送了FIN报⽂后进⼊。等待对⽅ FIN。 CLOSE_WAIT:(假设服务器)接收到客户机FIN包之后等待关闭的阶段。在接收到 对⽅的FIN包之后,⾃然是需要⽴即回复ACK包的,表示已经知道断开请求。但是本 ⽅是否⽴即断开连接(发送FIN包)取决于是否还有数据需要发送给客户端,若有, 则在发送FIN包之前均为此状态。 FIN_WAIT_2:此时是半连接状态,即有⼀⽅要求关闭连接,等待另⼀⽅关闭。客户端接收到服务器的ACK包,但并没有⽴即接收到服务端的FIN包,进⼊FIN_WAIT_2状 态。 LAST_ACK:服务端发动最后的FIN包,等待最后的客户端ACK响应,进⼊此状态。 TIME_WAIT:客户端收到服务端的FIN包,并⽴即发出ACK包做最后的确认,在此之后的2MSL时间称为TIME_WAIT状态。

5.解释FIN_WAIT_2, CLOSE_WAIT状态和TIME_WAIT状态? FIN_WAIT_2:半关闭状态。 发送断开请求⼀⽅还有接收数据能⼒,但已经没有发送数据能⼒。 CLOSE_WAIT状态: 被动关闭连接⼀⽅接收到FIN包会⽴即回应ACK包表示已接收到断开请求。 被动关闭连接⼀⽅如果还有剩余数据要发送就会进⼊CLOSED_WAIT状态。 TIME_WAIT状态:⼜叫2MSL等待状态。 如果客户端直接进⼊CLOSED状态,如果服务端没有接收到最后⼀次ACK包会在如果服务端没有接收到最后⼀次ACK包会在超时之后重新再发FIN包,此时因为客户端已经CLOSED,所以服务端就不会收到ACK⽽是收到RST。所以TIME_WAIT状态⽬的是防⽌最后⼀次握⼿数据没有到达对⽅⽽触发重传FIN准备的。在2MSL时间内,同⼀个socket不能再被使⽤,否则有可能会和旧连接数据混淆(如果新连接和旧连接的socket相同的话)

6.解释RTO,RTT和超时重传? 超时重传:发送端发送报⽂后若⻓时间未收到确认的报⽂则需要重发该报⽂。可能有以下⼏种情况:

发送的数据没能到达接收端,所以对⽅没有响应。

接收端接收到数据,但是ACK报⽂在返回过程中丢失。

接收端拒绝或丢弃数据。

RTO:从上⼀次发送数据,因为⻓期没有收到ACK响应,到下⼀次重发之间的时间。 就是重传间隔。

通常每次重传RTO是前⼀次重传间隔的两倍,计量单位通常是RTT。例:1RTT,2RTT,4RTT,8RTT ......重传次数到达上限之后停⽌重传。

RTT:数据从发送到接收到对⽅响应之间的时间间隔,即数据报在⽹络中⼀个往返⽤时。⼤⼩不稳定。 ⽬的是接收⽅通过TCP头窗⼝字段告知发送⽅本⽅可接收的最⼤数据量,⽤以解决发送速率过快导致接收⽅不能接收的问题。所以流量控制是点对点控制。

TCP是双⼯协议,双⽅可以同时通信,所以发送⽅接收⽅各⾃维护⼀个发送窗和接收窗。 发送窗:⽤来限制发送⽅可以发送的数据⼤⼩,其中发送窗⼝的⼤⼩由接收端返回的TCP报⽂段中窗⼝字段来控制,接收⽅通过此字段告知发送⽅⾃⼰的缓冲 (受系统、硬件等限制)⼤⼩。

接收窗:⽤来标记可以接收的数据⼤⼩。

TCP是流数据,发送出去的数据流可以被分为以下四部分:已发送且被确认部分 | 已发送未被确认部分 | 未发送但可发送部分 | 不可发送部分,其中发送窗 = 已发送未确认部分 + 未发但可发送部分。接收到的数据流可分为:已接收 | 未接收但准备接收 | 未接收不准备接收。接收窗 = 未接收但准备接收部分。

发送窗内数据只有当接收到接收端某段发送数据的ACK响应时才移动发送窗,左边缘 紧贴刚被确认的数据。接收窗也只有接收到数据且最左侧连续时才移动接收窗⼝。 拥塞控制原理?

拥塞控制⽬的是防⽌数据被过多注⽹络中导致⽹络资源(路由器、交换机等)过载。 因为拥塞控制涉及⽹络链路全局,所以属于全局控制。控制拥塞使⽤拥塞窗⼝。

TCP拥塞控制算法:

慢开始 & 拥塞避免:先试探⽹络拥塞程度再逐渐增⼤拥塞窗⼝。每次收到确认后拥塞窗⼝翻倍,直到达到阀值ssthresh,这部分是慢开始过程。达到阀值后每次 以⼀个MSS为单位增⻓拥塞窗⼝⼤⼩,当发⽣拥塞(超时未收到确认),将阀值减为原先⼀半,继续执⾏线性增加,这个过程为拥塞避免。

快速重传 & 快速恢复:略。

最终拥塞窗⼝会收敛于稳定值。

7、如何区分流量控制和拥塞控制

流量控制属于通信双⽅协商;拥塞控制涉及通信链路全局。

流量控制需要通信双⽅各维护⼀个发送窗、⼀个接收窗,对任意⼀⽅,接收窗⼤⼩由⾃身决定,发送窗⼤⼩由接收⽅响应的TCP报⽂段中窗⼝值确定;拥塞控制的拥塞窗⼝⼤⼩变化由试探性发送⼀定数据量数据探查⽹络状况后⽽⾃适应调整。

实际最终发送窗⼝ = min{流控发送窗⼝,拥塞窗⼝}。

8、 TCP如何提供可靠数据传输的?

建⽴连接(标志位):通信前确认通信实体存在。 序号机制(序号、确认号):确保了数据是按序、完整到达。 数据校验(校验和):CRC校验全部数据。 超时重传(定时器):保证因链路故障未能到达数据能够被多次重发。 窗⼝机制(窗⼝):提供流量控制,避免过量发送。 拥塞控制:同上。

9、 TCP soctet交互流程?

服务器:

创建socket -> int socket(int domain, int type, int protocol);

domain:协议域,决定了socket的地址类型,IPv4为AF_INET。

type:指定socket类型,SOCK_STREAM为TCP连接。

protocol:指定协议。IPPROTO_TCP表示TCP协议,为0时⾃动选择type默认协议。

绑定socket和端⼝号 -> int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

sockfd:socket返回的套接字描述符,类似于⽂件描述符fd。

addr:有个sockaddr类型数据的指针,指向的是被绑定结构变量。

// IPv4的sockaddr地址结构
    struct sockaddr_in {
        sa_family_t sin_family;    // 协议类型,AF_INET
        in_port_t sin_port;    // 端⼝号
        struct in_addr sin_addr;    // IP地址
    };
    struct in_addr {
        uint32_t s_addr;
    }

addrlen:地址⻓度。

监听端⼝号 -> int listen(int sockfd, int backlog);

sockfd:要监听的sock描述字。

backlog:socket可以排队的最⼤连接数。

接收⽤户请求 -> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

sockfd:服务器socket描述字。

addr:指向地址结构指针。

addrlen:协议地址⻓度。

注:⼀旦accept某个客户机请求成功将返回⼀个全新的描述符⽤于标识具体客户的TCP连接。 从socket中读取字符 -> ssize_t read(int fd, void *buf, size_t count);

fd:连接描述字。 buf:缓冲区buf。 **count:缓冲区⻓度。

注:⼤于0表示读取的字节数,返回0表示⽂件读取结束,⼩于0表示发⽣错误。

关闭socket -> int close(int fd);

fd:accept返回的连接描述字,每个连接有⼀个,⽣命周期为连接周期。 注:sockfd是监听描述字,⼀个服务器只有⼀个,⽤于监听是否有连接;fd是连接描述字,⽤于每个连接的操作。

sockfd客户端的sock描述字。 addr:服务器的地址。 addrlen:socket地址⻓度。 向socket写⼊信息 -> ssize_t write(int fd, const void *buf, size_t count); fd、buf、count:同read中意义。 ⼤于0表示写了部分或全部数据,⼩于0表示出错。 关闭oscket -> int close(int fd); fd:同服务器端fd。

⼗、操作系统

1、操作系统特点

并发性、共享性、虚拟性、不确定性。

2、什么是进程

  1. 进程是指在系统中正在运⾏的⼀个应⽤程序,程序⼀旦运⾏就是进程;

  2. 进程可以认为是程序执⾏的⼀个实例,进程是系统进⾏资源分配的最⼩单位,且每个进程拥有独⽴的地址空间;

  3. ⼀个进程⽆法直接访问另⼀个进程的变量和数据结构,如果希望⼀个进程去访问另⼀个进程的资源,需要使⽤进程间的通信,⽐如:管道、消息队列等

  4. 线程是进程的⼀个实体,是进程的⼀条执⾏路径;⽐进程更⼩的独⽴运⾏的基本单位,线程也被称为轻量级进程,⼀个程序⾄少有⼀个进程,⼀个进程⾄少有⼀个线程;

3、进程

进程是程序的⼀次执⾏,该程序可以与其他程序并发执⾏;进程有运⾏、阻塞、就绪三个基本状态;

进程调度算法:先来先服务调度算法、短作业优先调度算法、⾮抢占式优先级调度算法、抢占 式优先级调度算法、⾼响应⽐优先调度算法、时间⽚轮转法调度算法;

4、进程与线程的区别

  1. 同⼀进程的线程共享本进程的地址空间,⽽进程之间则是独⽴的地址空间;

  2. 同⼀进程内的线程共享本进程的资源,但是进程之间的资源是独⽴的;

  3. ⼀个进程崩溃后,在保护模式下不会对其他进程产⽣影响,但是⼀个线程崩溃整个进程崩 溃,所以多进程⽐多线程健壮;

  4. 进程切换,消耗的资源⼤。所以涉及到频繁的切换,使⽤线程要好于进程;

  5. 两者均可并发执⾏;

  6. 每个独⽴的进程有⼀个程序的⼊⼝、程序出⼝。但是线程不能独⽴执⾏,必须依存在应⽤程序中,由应⽤程序提供多个线程执⾏控制。

5、进程状态转换图

  1. 新状态:进程已经创建

  2. 就绪态:进程做好了准备,准备执⾏,等待分配处理机

  3. 执⾏态:该进程正在执⾏;

  4. 阻塞态:等待某事件发⽣才能执⾏,如等待I/O完成;

  5. 终⽌状态

6、进程的创建过程?需要哪些函数?需要哪些数据结构?

  1. fork函数创造的⼦进程是⽗进程的完整副本,复制了⽗亲进程的资源,包括内存的内容 task_struct内容;

  2. vfork创建的⼦进程与⽗进程共享数据段,⽽且由vfork创建的⼦进程将先于⽗进程运⾏;

  3. linux上创建线程⼀般使⽤的是pthread库,实际上linux也给我们提供了创建线程的系统调 ⽤,就是clone;

7、进程创建⼦进程,fork详解

  1. 函数原型

pid_t fork(void); //void代表没有任何形式参数
  1. 除了0号进程(系统创建的)之外,linux系统中都是由其他进程创建的。创建新进程的进

程,即调⽤fork函数的进程为⽗进程,新建的进程为⼦进程。
  1. fork函数不需要任何参数,对于返回值有三种情况:

对于⽗进程,fork函数返回新建⼦进程的pid; 对于⼦进程,fork函数返回 0; 如果出错, fork 函数返回 -1。

```
int pid=fork();
if(pid < 0){
//失败,⼀般是该⽤户的进程数达到限制或者内存被⽤光了  
........   
}
else if(pid == 0){
//⼦进程执⾏的代码
......
}
else{
//⽗进程执⾏的代码
.........
}
```

8、⼦进程和⽗进程怎么通信?

  1. 在 Linux 系统中实现⽗⼦进程的通信可以采⽤ pipe() 和 fork() 函数进⾏实现;

  2. 对于⽗⼦进程,在程序运⾏时⾸先进⼊的是⽗进程,其次是⼦进程,在此我个⼈认为,在 创建⽗⼦进程的时候程序是先运⾏创建的程序,其次在复制⽗进程创建⼦进程。 fork() 函数主 要是以⽗进程为蓝本复制⼀个进程,其 ID 号和⽗进程的 ID 号不同。对于结果 fork出来的⼦进 程的⽗进程 ID 号是执⾏ fork() 函数的进程的 ID 号。

  3. 管道:是指⽤于连接⼀个读进程和⼀个写进程,以实现它们之间通信的共享⽂件,⼜称 pipe ⽂件。

  4. 写进程在管道的尾端写⼊数据,读进程在管道的⾸端读出数据。

9、进程和作业的区别?

  1. 进程是程序的⼀次动态执⾏,属于动态概念;

  2. ⼀个进程可以执⾏⼀个或⼏个程序,同⼀个程序可由⼏个进程执⾏;

  3. 程序可以作为⼀种软件资源⻓期保留,⽽进程是程序的⼀次执⾏;

  4. 进程具有并发性,能与其他进程并发执⾏;

  5. 进程是⼀个独⽴的运⾏单位;

10、死锁是什么?必要条件?如何解决?

所谓死锁,是指多个进程循环等待它⽅占有的资源⽽⽆限期地僵持下去的局⾯。很显然,如果 没有外⼒的作⽤,那麽死锁涉及到的各个进程都将永远处于封锁状态。当两个或两个以上的进 程同时对多个互斥资源提出使⽤要求时,有可能导致死锁。 〈1〉 互斥条件。即某个资源在⼀段时间内只能由⼀个进程占有,不能同时被两个或两个以 的进程占有。这种独占资源如CD-ROM驱动器,打印机等等,必须在占有该资源的进程主动释放它之后,其它进程才能占有该资源。这是由资源本身的属性所决定的。如独⽊桥就是⼀种 独占资源,两⽅的⼈不能同时过桥。

〈2〉 不可抢占条件。进程所获得的资源在未使⽤完毕之前,资源申请者不能强⾏地从资源占 有者⼿中夺取资源,⽽只能由该资源的占有者进程⾃⾏释放。如过独⽊桥的⼈不能强迫对⽅后退,也不能⾮法地将对⽅推下桥,必须是桥上的⼈⾃⼰过桥后空出桥⾯(即主动释放占有资 源),对⽅的⼈才能过桥。

〈3〉 占有且申请条件。进程⾄少已经占有⼀个资源,但⼜申请新的资源;由于该资源已被另 外进程占有,此时该进程阻塞;但是,它在等待新资源之时,仍继续占⽤已占有的资源。还以 过独⽊桥为例,甲⼄两⼈在桥上相遇。甲⾛过⼀段桥⾯(即占有了⼀些资源),还需要⾛其余 的桥⾯(申请新的资源),但那部分桥⾯被⼄占有(⼄⾛过⼀段桥⾯)。甲过不去,前进不能,⼜不后退;⼄也处于同样的状况。 〈4〉 循环等待条件。存在⼀个进程等待序列{P1, P2, ..., Pn},其中P1等待P2所占有的某 ⼀资源, P2等待P3所占有的某⼀源, ......,⽽Pn等待P1所占有的的某⼀资源,形成⼀个进程循环等待环。就像前⾯的过独⽊桥问题,甲等待⼄占有的桥⾯,⽽⼄⼜等待甲占有的桥⾯,从 彼此循环等待。

死锁的预防是保证系统不进⼊死锁状态的⼀种策略。它的基本思想是要求进程申请资源时遵循 某种协议,从⽽打破产⽣死锁的四个必要条件中的⼀个或⼏个,保证系统不会进⼊死锁状态。

<1>打破互斥条件。即允许进程同时访问某些资源。但是,有的资源是不允许被同时访问的, 像打印机等等,这是由资源本身的属性所决定的。所以,这种办法并⽆实⽤价值。

<2>打破不可抢占条件。即允许进程强⾏从占有者那⾥夺取某些资源。就是说,当⼀个进程已 占有了某些资源,它⼜申请新的资源,但不能⽴即被满⾜时,它必须释放所占有的全部资源,以后再重新申请。它所释放的资源可以分配给其它进程。这就相当于该进程占有的资源被隐蔽 地强占了。这种预防死锁的⽅法实现起来困难,会降低系统性能。

<3>打破占有且申请条件。可以实⾏资源预先分配策略。即进程在运⾏前⼀次性地向系统申请 它所需要的全部资源。如果某个进程所需的全部资源得不到满⾜,则不分配任何资源,此进程 暂不运⾏。只有当系统能够满⾜当前进程的全部资源需求时,才⼀次性地将所申请的资源全部 分配给该进程。由于运⾏的进程已占有了它所需的全部资源,所以不会发⽣占有资源⼜申请资 源的现象,因此不会发⽣死锁。

<4>打破循环等待条件,实⾏资源有序分配策略。采⽤这种策略,即把资源事先分类编号,按 号分配,使进程在申请,占⽤资源时不会形成环路。所有进程对资源的请求必须严格按资源序 号递增的顺序提出。进程占⽤了⼩号资源,才能申请⼤号资源,就不会产⽣环路,从⽽预防了 死锁

死锁避免:银⾏家算法

11、鸵⻦策略

假设的前提是,这样的问题出现的概率很低。⽐如,在操作系统中,为应对死锁问题,可以采⽤这样的⼀种办法。当系统发⽣[死锁](时不会对⽤户造成多⼤影响,或系统很少发⽣[死锁]的场合采⽤允许死锁发⽣的鸵⻦算法,这样⼀来可能开销⽐不允许发⽣死锁及检测和解除死锁的 ⼩。如果[死锁]很⻓时间才发⽣⼀次,⽽系统每周都会因硬件故障、 [编译器]错误或操作系统错 误⽽崩溃⼀次,那么⼤多数⼯程师不会以性能损失或者易⽤性损失的代价来设计较为复杂的死锁解决策略,来消除死锁。鸵⻦策略的实质:出现死锁的概率很⼩,并且出现之后处理死锁会 花费很⼤的代价,还不如不做处理, OS中这种置之不理的策略称之为鸵⻦策略(也叫鸵⻦算法)。

12、银⾏家算法

在避免[死锁]的⽅法中,所施加的限制条件较弱,有可能获得令⼈满意的系统性能。在该⽅法中把系统的状态分为安全状态和不安全状态,只要能使系统始终都处于安全状态,便可以避免发⽣[死锁]。

银⾏家算法的基本思想是分配资源之前,判断系统是否是安全的;若是,才分配。它是最具有 代表性的避免[死锁]的算法。

设进程cusneed提出请求REQUEST [i],则银⾏家算法按如下规则进⾏判断。

(1)如果REQUEST [cusneed] [i]<= NEED[cusneed][i],则转(2);否则,出错。

(2)如果REQUEST [cusneed] [i]<= AVAILABLE[i],则转(3);否则,等待。

(3)系统试探分配资源,修改相关数据:

AVAILABLE[i]-=REQUEST[cusneed][i]; ALLOCATION[cusneed][i]+=REQUEST[cusneed][i];

NEED[cusneed][i]-=REQUEST[cusneed][i];

(4)系统执⾏安全性检查,如安全,则分配成⽴;否则试探险性分配作废,系统恢复原状,进 程等待。

13、进程间通信⽅式有⼏种,他们之间的区别是什么?

  1. 管道

管道,通常指⽆名管道。

① 半双⼯的,具有固定的读端和写端;

② 只能⽤于具有亲属关系的进程之间的通信;

③ 可以看成是⼀种特殊的⽂件,对于它的读写也可以使⽤普通的read、write函数。但是它不是普通的⽂件,并不属于其他任何⽂件系统,只能⽤于内存中。

④ Int pipe(int fd[2]);当⼀个管道建⽴时,会创建两个⽂件⽂件描述符,要关闭管道只需将这两个⽂件描述符关闭即可。

  1. FiFO (有名管道)

① FIFO可以再⽆关的进程之间交换数据,与⽆名管道不同;

② FIFO有路径名与之相关联,它以⼀种特殊设备⽂件形式存在于⽂件系统中;

③ Int mkfifo(const char* pathname,mode_t mode);

  1. 消息队列

① 消息队列,是消息的连接表,存放在内核中。⼀个消息队列由⼀个标识符来标识;

② 消息队列是⾯向记录的,其中的消息具有特定的格式以及特定的优先级;

③ 消息队列独⽴于发送与接收进程。进程终⽌时,消息队列及其内容并不会被删除;

④ 消息队列可以实现消息的随机查询

  1. 信号量

① 信号量是⼀个计数器,信号量⽤于实现进程间的互斥与同步,⽽不是⽤于存储进程间通信数据;

② 信号量⽤于进程间同步,若要在进程间传递数据需要结合共享内存;

③ 信号量基于操作系统的PV操作,程序对信号量的操作都是原⼦操作

  1. 共享内存

① 共享内存,指两个或多个进程共享⼀个给定的存储区;

② 共享内存是最快的⼀种进程通信⽅式,因为进程是直接对内存进⾏存取;

③ 因为多个进程可以同时操作,所以需要进⾏同步;

④ 信号量+共享内存通常结合在⼀起使⽤。

14、线程同步的⽅式?怎么⽤?

  1. 线程同步是指多线程通过特定的设置来控制线程之间的执⾏顺序,也可以说在线程之间通过同步建⽴起执⾏顺序的关系;

  2. 主要四种⽅式,临界区、互斥对象、信号量、事件对象;其中临界区和互斥对象主要⽤于互斥控制,信号量和事件对象主要⽤于同步控制;

  3. 临界区:通过对多线程的串⾏化来访问公共资源或⼀段代码,速度快、适合控制数据访问。在任意⼀个时刻只允许⼀个线程对共享资源进⾏访问,如果有多个线程试图访问公共资源,那么在有⼀个线程进⼊后,其他试图访问公共资源的线程将被挂起,并⼀直等到进⼊临界区的线程离开,临界区在被释放后,其他线程才可以抢占。

  4. 互斥对象:互斥对象和临界区很像,采⽤互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有⼀个,所以能保证公共资源不会同时被多个线程同时访问。当前拥有互斥对象的线程处理完任务后必须将线程交出,以便其他线程访问该资源。

  5. 信号量:它允许多个线程在同⼀时刻访问同⼀资源,但是需要限制在同⼀时刻访问此资源的最⼤线程数⽬。在⽤CreateSemaphore()创建信号量时即要同时指出允许的最⼤资源计数和当前可⽤资源计数。⼀般是将当前可⽤资源计数设置为最 ⼤资源计数,每增加⼀个线程对共享资源的访问,当前可⽤资源计数就会减1 ,只要当前可⽤资源计数是⼤于0 的,就可以发出信号量信号。但是当前可⽤计数减⼩ 到0 时则说明当前占⽤资源的线程数已经达到了所允许的最⼤数⽬,不能在允许其他线程的进⼊,此时的信号量信号将⽆法出。线程在处理完共享资源后,应在离 开的同时通过ReleaseSemaphore ()函数将当前可⽤资源计数加1 。在任何时候当前可⽤资源计数决不可能⼤于最⼤资源计数。

  6. 事件对象:通过通知操作的⽅式来保持线程的同步,还可以⽅便实现对多个线程的优先级 ⽐较的操作。

15、⻚和段的区别?

  1. ⻚是信息的物理单位,分⻚是由于系统管理的需要。段是信息的逻辑单位,分段是为了满⾜⽤户的要求。

  2. ⻚的⼤⼩固定且由系统决定,段的⻓度不固定,决定于⽤户所编写的程序,通常由编译程序在对源程序紧进⾏编译时,根据信息的性质来划分。

  3. 分⻚的作业的地址空间是⼀维的,程序员只需要利⽤⼀个记忆符,即可表示⼀个地址。分段的作业地址空间则是⼆维的,程序员在标识⼀个地址时,既需要给出段名,⼜需要给出段的地址值

16、孤⼉进程和僵⼫进程的区别?怎么避免这两类进程?守护进程?

1、 ⼀般情况下,⼦进程是由⽗进程创建,⽽⼦进程和⽗进程的退出是⽆顺序的,两者之间都不知道谁先退出。正常情况下⽗进程先结束会调⽤ wait 或者 waitpid 函数等待⼦进程完成再退出,⽽⼀旦⽗进程不等待直接退出,则剩下的⼦进程会被init(pid=1)进程接收,成会孤⼉进程。(进程树中除了init都会有⽗进程)。 2、 如果⼦进程先退出了,⽗进程还未结束并且没有调⽤ wait 或者 waitpid 函数获取⼦进程的状态信息,则⼦进程残留的状态信息( task_struct 结构和少量资源信息)会变成僵⼫进程。⼦进程退出时向⽗进程发送SIGCHILD信号,⽗进程处理SIGCHILD信号。在信号处理函数中调⽤wait进⾏处理僵⼫进程。原理是将⼦进程成为孤⼉进程,从⽽其的⽗进程变为init进程,通过init进程可以处理僵⼫进 程。 3、 守护进程( daemon) 是指在后台运⾏,没有控制终端与之相连的进程。它独⽴于控制终端,通常周期性地执⾏某种任务 。守护进程脱离于终端是为了避免进程在执⾏过程中的信息在任何终端上显示并且进程也不会被任何终端所产⽣的终端信息所打断。

17、守护进程是什么?怎么实现?

  1. 守护进程(Daemon)是运⾏在后台的⼀种特殊进程。它独⽴于控制终端并且周期性地执⾏某种任务或等待处理某些发⽣的事件。守护进程是⼀种很有⽤的进程。

  2. 守护进程特点

  3. 守护进程最重要的特性是后台运⾏。

  4. 守护进程必须与其运⾏前的环境隔离开来。这些环境包括未关闭的⽂件描述符,控制终端,会话和进程组,⼯作⽬录以及⽂件创建掩模等。这些环境通常是守护进程从执⾏它的⽗进程(特别是shell)中继承下来的。

  5. 守护进程的启动⽅式有其特殊之处。它可以在Linux系统启动时从启动脚本/etc/rc.d中启动,可以由作业规划进程crond启动,还可以由⽤户终端(shell)执⾏。

  6. 实现

  7. 在⽗进程中执⾏fork并exit推出;

  8. 在⼦进程中调⽤setsid函数创建新的会话;

  9. 在⼦进程中调⽤chdir函数,让根⽬录 ”/” 成为⼦进程的⼯作⽬录;

  10. 在⼦进程中调⽤umask函数,设置进程的umask为0;

  11. 在⼦进程中关闭任何不需要的⽂件描述符

18、线程和进程的区别?线程共享的资源是什么?

  1. ⼀个程序⾄少有⼀个进程,⼀个进程⾄少有⼀个线程

  2. 线程的划分尺度⼩于进程,使得多线程程序的并发性⾼

  3. 进程在执⾏过程中拥有独⽴的内存单元,⽽多个线程共享内存,从⽽极⼤地提⾼了程序的运⾏效率

  4. 每个独⽴的线程有⼀个程序运⾏的⼊⼝、顺序执⾏序列和程序的出⼝。但是线程不能够独 ⽴执⾏,必须依存在应⽤程序中,由应⽤程序提供多个线程执⾏控制

  5. 多线程的意义在于⼀个应⽤程序中,有多个执⾏部分可以同时执⾏。但操作系统并没有将 多个线程看做多个独⽴的应⽤,来实现进程的调度和管理以及资源分配

  6. ⼀个进程中的所有线程共享该进程的地址空间,但它们有各⾃独⽴的(/私有的)栈(stack), Windows 线程的缺省堆栈⼤⼩为1M。堆(heap)的分配与栈有所不同,⼀般是⼀个进 程有⼀个C运⾏时堆,这个堆为本进程中所有线程共享, windows 进程还有所谓进程默认堆, ⽤户也可以创建⾃⼰的堆。

线程私有:线程栈,寄存器,程序寄存器 共享:堆,地址空间,全局变量,静态变量 进程私有:地址空间,堆,全局变量,栈,寄存器 共享:代码段,公共数据,进程⽬录,进程ID

19、线程⽐进程具有哪些优势?

  1. 线程在程序中是独⽴的,并发的执⾏流,但是,进程中的线程之间的隔离程度要⼩;

  2. 线程⽐进程更具有更⾼的性能,这是由于同⼀个进程中的线程都有共性:多个线程将共享 同⼀个进程虚拟空间;

  3. 当操作系统创建⼀个进程时,必须为进程分配独⽴的内存空间,并分配⼤量相关资源

20、什么时候⽤多进程?什么时候⽤多线程?

  1. 需要频繁创建销毁的优先⽤线程;

  2. 需要进⾏⼤量计算的优先使⽤线程;

  3. 强相关的处理⽤线程,弱相关的处理⽤进程;

  4. 可能要扩展到多机分布的⽤进程,多核分布的⽤线程;

21、协程是什么?

  1. 是⼀种⽐线程更加轻量级的存在。正如⼀个进程可以拥有多个线程⼀样,⼀个线程可以拥有多个协程;协程不是被操作系统内核管理,⽽完全是由程序所控制。

  2. 协程的开销远远⼩于线程;

  3. 协程拥有⾃⼰寄存器上下⽂和栈。协程调度切换时,将寄存器上下⽂和栈保存到其他地⽅,在切换回来的时候,恢复先前保存的寄存器上下⽂和栈。

  4. 每个协程表示⼀个执⾏单元,有⾃⼰的本地数据,与其他协程共享全局数据和其他资源。

  5. 跨平台、跨体系架构、⽆需线程上下⽂切换的开销、⽅便切换控制流,简化编程模型;

  6. 协程⼜称为微线程,协程的完成主要靠yeild关键字,协程执⾏过程中,在⼦程序内部可中断,然后转⽽执⾏别的⼦程序,在适当的时候再返回来接着执⾏;

  7. 协程极⾼的执⾏效率,和多线程相⽐,线程数量越多,协程的性能优势就越明显;

  8. 不需要多线程的锁机制

22、递归锁?

  1. 线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制是引⼊互斥锁。互斥锁 为资源引⼊⼀个状态:锁定/⾮锁定。某个线程要更改共享数据时,先将其锁定,此时资源的 状态为“锁定”,其他线程不能更改;直到该线程释放资源,将资源的状态成“⾮锁定”,其他 的线程才能再次锁定该资源。互斥锁保证了每次只有⼀个线程进⾏写⼊操作,从⽽保证了多线 程情况下数据的正确性。

  2. 读写锁从⼴义的逻辑上讲,也可以认为是⼀种共享版的互斥锁。如果对⼀个临界区⼤部分是读操作⽽只有少量的写操作,读写锁在⼀定程度上能够降低线程互斥产⽣的代价。

  3. Mutex可以分为递归锁(recursive mutex)和⾮递归锁(non-recursive mutex)。可递归锁也可称为可重⼊锁(reentrant mutex),⾮递归锁⼜叫不可重⼊锁(non-reentrant mutex)。⼆者唯⼀的区别是,同⼀个线程可以多次获取同⼀个递归锁,不会产⽣死锁。⽽如果⼀个线程多次获取同⼀个⾮递归锁,则会产⽣死锁。

23、⽤户态到内核态的转化原理?

  1. 系统调⽤

这是⽤户态进程主动要求切换到内核态的⼀种⽅式,⽤户态进程通过系统调⽤申请使⽤操作系 统提供的服务程序完成⼯作,⽐如前例中fork()实际上就是执⾏了⼀个创建新进程的系统调⽤。⽽系统调⽤的机制其核⼼还是使⽤了操作系统为⽤户特别开放的⼀个中断来实现,例如 Linux 的 int 80h 中断。

  1. 异常

当 CPU 在执⾏运⾏在⽤户态下的程序时,发⽣了某些事先不可知的异常,这时会触发由当前 运⾏进程切换到处理此异常的内核相关程序中,也就转到了内核态,⽐如缺⻚异常。

  1. 外围设备的中断

当外围设备完成⽤户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执⾏下⼀条即将要执⾏的指令转⽽去执⾏与中断信号对应的处理程序,如果先前执⾏的指令是⽤户 态下的程序,那么这个转换的过程⾃然也就发⽣了由⽤户态到内核态的切换。⽐如硬盘读写操 作完成,系统会切换到硬盘读写的中断处理程序中执⾏后续操作等。

24、中断的实现与作⽤,中断的实现过程?

① 关中断,进⼊不可再次响应中断的状态,由硬件实现。

② 保存断点,为了在[中断处理]结束后能正确返回到中断点。由硬件实现。

③ 将[中断服务程序]⼊⼝地址送PC,转向[中断服务程序]。可由硬件实现,也可由软件实现。

④ 保护现场、置屏蔽字、开中断,即保护CPU中某些寄存器的内容、设置[中断处理]次序、允 许更⾼级的中断请求得到响应,实现中断嵌套由软件实现。

⑤ 设备服务,实际上有效的中断处理⼯作是在此程序段中实现的。由软件程序实现

⑥ 退出中断。在退出时,⼜应进⼊不可中断状态,即关中断、恢复屏蔽字、恢复现场、开中 断、中断返回。由软件实现。

25、系统中断是什么,⽤户态和内核态的区别

  1. 内核态与⽤户态是操作系统的两种运⾏级别, 当程序运⾏在3级特权级上时,就可以称之为运⾏在⽤户态,因为这是最低特权级,是普通的⽤户进程运⾏的特权级,⼤部分⽤户直接⾯对的 程序都是运⾏在⽤户态;反之,当程序运⾏在0级特权级上时,就可以称之为运⾏在内核态。 运⾏在⽤户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执⾏⼀个 程序时,⼤部分时间是运⾏在⽤户态下的,在其需要操作系统帮助完成某些它没有权⼒和能⼒ 完成的⼯作时就会切换到内核态。

  2. 这两种状态的主要差别是: 处于⽤户态执⾏时,进程所能访问的内存空间和对象受到限 制,其所处于占有的处理机是可被抢占的; ⽽处于核⼼态执⾏中的进程,则能访问所有的内 存空间和对象,且所占有的处理机是不允许被抢占的。

26、 CPU中断

  1. CPU中断是什么

① 计算机处于执⾏期间;

② 系统内发⽣了⾮寻常或⾮预期的急需处理事件;

③ CPU暂时中断当前正在执⾏的程序⽽转去执⾏相应的事件处理程序; ④ 处理完毕后返回原来被中断处继续执⾏;

  1. CPU中断的作⽤

① 可以使CPU和外设同时⼯作,使系统可以及时地响应外部事件;

② 可以允许多个外设同时⼯作,⼤⼤提⾼了CPU的利⽤率;

③ 可以使CPU及时处理各种软硬件故障。

27、执⾏⼀个系统调⽤时, OS 发⽣的过程,越详细越好

1.执⾏⽤户程序(如:fork)

2. 根据glibc中的函数实现,取得系统调⽤号并执⾏int $0x80产⽣中断。 3. 进⾏地址空间的转换和堆栈的切换,执⾏SAVE_ALL。(进⾏内核模式) 4. 进⾏中断处理,根据系统调⽤表调⽤内核函数。 5. 执⾏内核函数。 6. 执⾏ RESTORE_ALL 并返回⽤户模式

28、函数调⽤和系统调⽤的区别?

  1. 系统调⽤

① 操作系统提供给⽤户程序调⽤的⼀组特殊的接⼝。⽤户程序可以通过这组特殊接⼝来获得 操作系统内核提供的服务;

② 系统调⽤可以⽤来控制硬件;设置系统状态或读取内核数据;进程管理,系统调⽤接⼝⽤来保证系统中进程能以多任务在虚拟环境下运⾏;

③ Linux中实现系统调⽤利⽤了0x86体系结构中的软件中断;

  1. 函数调⽤

① 函数调⽤运⾏在⽤户空间;

② 它主要是通过压栈操作来进⾏函数调⽤;

  1. 区别

29、虚拟内存?使⽤虚拟内存的优点?什么是虚拟地址空间?

  1. 虚拟内存,虚拟内存是⼀种内存管理技术,它会使程序⾃⼰认为⾃⼰拥有⼀块很⼤且连续的内存,然⽽,这个程序在内存中不是连续的,并且有些还会在磁盘上,在需要时进⾏数据交换;

  2. 优点:可以弥补物理内存⼤⼩的不⾜;⼀定程度的提⾼反应速度;减少对物理内存的读取 从⽽保护内存延⻓内存使⽤寿命;

  3. 缺点:占⽤⼀定的物理硬盘空间;加⼤了对硬盘的读写;设置不得当会影响整机稳定性与速度。

  4. 虚拟地址空间是对于⼀个单⼀进程的概念,这个进程看到的将是地址从0000开始的整个内 存空间。虚拟存储器是⼀个抽象概念,它为每⼀个进程提供了⼀个假象,好像每⼀个进程都在 独占的使⽤主存。每个进程看到的存储器都是⼀致的,称为虚拟地址空间。从最低的地址看起:程序代码和数据,堆,共享库,栈,内核虚拟存储器。⼤多数计算机的字⻓都是32位, 这就限制了虚拟地址空间为4GB。

30、线程安全?如何实现?

  1. 如果你的代码所在的进程中有多个线程在同时运⾏,⽽这些线程可能会同时运⾏这段代码。如果每次运⾏结果和[单线程]运⾏的结果是⼀样的,⽽且其他的变量的值也和预期的是⼀样的,就是线程安全的。

  2. 线程安全问题都是由[全局变量及[静态变量]引起的。

  3. 若每个线程中对全局变量、静态变量只有读操作,⽽⽆写操作,⼀般来说,这个全局变量是线程安全的;若有多个线程同时执⾏写操作,⼀般都需要考虑[线程同步],否则的话就可能影响线程安全。

  4. 对于线程不安全的对象我们可以通过如下⽅法来实现线程安全:

① 加锁 利⽤Synchronized或者ReenTrantLock来对不安全对象进⾏加锁,来实现线程执⾏的串⾏化,从⽽保证多线程同时操作对象的安全性,⼀个是语法层⾯的互斥锁,⼀个是API层⾯的互斥锁. ② ⾮阻塞同步来实现线程安全。原理就是:通俗点讲,就是先进性操作,如果没有其他线程争⽤共享数据,那操作就成功了;如果共享数据有争⽤,产⽣冲突,那就再采取其他措施(最常⻅的措施就是不断地重试,知道成功为⽌)。这种⽅法需要硬件的⽀持,因为我们需要操作和冲突检测这两个步骤具备原⼦性。通常这种指令包括CAS SC,FAI TAS等。 ③ 线程本地化,⼀种⽆同步的⽅案,就是利⽤Threadlocal来为每⼀个线程创造⼀个共享变量的副本来(副本之间是⽆关的)避免⼏个线程同时操作⼀个对象时发⽣线程安全问题。

31、常⻅的IO模型,五种?异步IO应⽤场景?有什么缺点?

  1. 同步

就是在发出⼀个功能调⽤时,在没有得到结果之前,该调⽤就不返回。 也就是必须⼀件⼀件 事做,等前⼀件做完了才能做下⼀件事。就是我调⽤⼀个功能,该功能没有结束前,我死等结 果。

  1. 异步

当⼀个异步过程调⽤发出后,调⽤者不能⽴刻得到结果。实际处理这个调⽤的部件在完成后, 通过状态、通知和回调来通知调⽤者。就是我调⽤⼀个功能,不需要知道该功能结果,该功能 有结果后通知我(回调通知)

  1. 阻塞

阻塞调⽤是指调⽤结果返回之前,当前线程会被挂起(线程进⼊⾮可执⾏状态,在这个状态 下, cpu不会给线程分配时间⽚,即线程暂停运⾏)。函数只有在得到结果之后才会返回。对 于同步调⽤来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回⽽已。 就 是调⽤我(函数),我(函数)没有接收完数据或者没有得到结果之前,我不会返回。

  1. ⾮阻塞

指在不能⽴刻得到结果之前,该函数不会阻塞当前线程,⽽会⽴刻返回。就是调⽤我(函 数),我(函数)⽴即返回,通过select通知调⽤者。

  1. 阻塞I/O

应⽤程序调⽤⼀个IO函数,导致应⽤程序阻塞,等待数据准备好。 如果数据没有准备好,⼀ 直等待….数据准备好了,从内核拷⻉到⽤户空间,IO函数返回成功指示。

  1. ⾮阻塞I/O

我们把⼀个SOCKET接⼝设置为⾮阻塞就是告诉内核,当所请求的I/O操作⽆法完成时,不要 将进程睡眠,⽽是返回⼀个错误。这样我们的I/O操作函数将不断的测试数据是否已经准备 好,如果没有准备好,继续测试,直到数据准备好为⃞。在这个不断测试的过程中,会⃞⃞的 占⽤CPU的时间。

  1. I/O复⽤

I/O复⽤模型会⽤到select、 poll、 epoll函数,这⼏个函数也会使进程阻塞,但是和阻塞I/O所 不同的的,这三个函数可以同时阻塞多个I/O操作。⽽且可以同时对多个读操作,多个写操作 的I/O函数进⾏检测,直到有数据可读或可写时,才真正调⽤I/O操作函数。

  1. 信号驱动I/O

⾸先我们允许套接⼝进⾏信号驱动I/O,并安装⼀个信号处理函数,进程继续运⾏并不阻塞。当 数据准备好时,进程会收到⼀个SIGIO信号,可以在信号处理函数中调⽤I/O操作函数处理数 据。

  1. 异步I/O

当⼀个异步过程调⽤发出后,调⽤者不能⽴刻得到结果。实际处理这个调⽤的部件在完成后, 通过状态、通知和回调来通知调⽤者的输⼊输出操作。

32、 IO复⽤的原理?零拷⻉?三个函数? epoll 的 LT 和 ET 模式的理解。

  1. IO复⽤是Linux中的IO模型之⼀,IO复⽤就是进程预先告诉内核需要监视的IO条件,使得内核⼀旦发现进程指定的⼀个或多个IO条件就绪,就通过进程进程处理,从⽽不会在单个IO上阻塞了。Linux中,提供了select、poll、epoll三种接⼝函数来实现IO复⽤。

  2. Select

select的缺点:

① 单个进程能够监视的⽂件描述符的数量存在最⼤限制,通常是1024。由于select采⽤轮询的⽅式扫描⽂件描述符,⽂件描述符数量越多,性能越差; ② 内核/⽤户空间内存拷⻉问题,select需要⼤量句柄数据结构,产⽣巨⼤开销; ③ Select返回的是含有整个句柄的数组,应⽤程序需要遍历整个数组才能发现哪些句柄发⽣事件; ④ Select的触发⽅式是⽔平触发,应⽤程序如果没有完成对⼀个已经就绪的⽂件描述符进⾏IO操作,那么每次select调⽤还会将这些⽂件描述符通知进程。

  1. Poll 与select相⽐,poll使⽤链表保存⽂件描述符,⼀你才没有了监视⽂件数量的限制,但其他三个缺点依然存在

  2. Epoll 上⾯所说的select缺点在epoll上不复存在,epoll使⽤⼀个⽂件描述符管理多个描述符,将⽤户关系的⽂件描述符的事件存放到内核的⼀个事件表中,这样在⽤户空间和内核空间的copy只需⼀次。Epoll是事件触发的,不是轮询查询的。没有最⼤的并发连接限制,内存拷⻉,利⽤mmap()⽂件映射内存加速与内核空间的消息传递。 区别总结:

  3. ⽀持⼀个进程所能打开的最⼤连接数 ① Select最⼤1024个连接,最⼤连接数有FD_SETSIZE宏定义,其⼤⼩是32位整数表示,可以改变宏定义进⾏修改,可以重新编译内核,性能可能会影响; ② Poll没有最⼤连接限制,原因是它是基于链表来存储的; ③ 连接数限数有上限,但是很⼤;

  4. FD剧增后带来的IO效率问题 ① 因为每次进⾏线性遍历,所以随着FD的增加会造成遍历速度下降,效率降低; ② Poll同上; ③ 因为epool内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调⽤callback,所以在活跃socket较少的情况下,使⽤epoll没有前⾯两者的现象下降的性能问题。

  5. 消息传递⽅式 ① Select内核需要将消息传递到⽤户空间,都需要内核拷⻉; ② Poll同上; ③ Epoll通过内核和⽤户空间共享来实现的。 epoll 的 LT 和 ET 模式的理解: epoll对⽂件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger),LT是默认模式。 区别: LT模式:当epoll_wait检测到描述符事件发⽣并将此事件通知应⽤程序,应⽤程序可以不⽴即处理该事件。下次调⽤epoll_wait时,会再次响应应⽤程序并通知此事件。 ET模式:当epoll_wait检测到描述符事件发⽣并将此事件通知应⽤程序,应⽤程序必须⽴即处理该事件。如果不处理,下次调⽤epoll_wait时,不会再次响应应⽤程序并通知此事件。 在 select/poll中,进程只有在调⽤⼀定⽅法后,内核才对所有监视的⽂件描述符进⾏扫描,⽽epoll事先通过epoll_ctl()来注册⼀个⽂件描述符,⼀旦某个⽂件描述符就绪时,内核会采⽤类似callback的回调机制,迅速激活这个⽂件描述符,当进程调⽤epoll_wait时便得到通知(此处去掉了遍历⽂件描述符,⽽是通过监听回调的机制,这也是epoll的魅⼒所在)。Epoll 的优点主要体现如下⼏个⽅⾯:

  6. 监视的描述符不受限制,它所⽀持的FD上限是最⼤可以打开⽂件的数⽬,这个数字⼀般远⼤于2048,举个栗⼦,具体数⽬可以在cat/proc/sys/fs/file-max 查看,⼀般来说,这个数⽬和内存关系很⼤。

  7. Select最⼤的缺点是进程打开的fd数⽬是有限制的,这对于连接数⽬较⼤的服务器来说根本不能满⾜,虽然也可以选择多进程的解决⽅案(Apache就是如此);不过虽然linux上⾯创建进程的代价较⼩,但仍旧不可忽视,加上进程间数据同步远⽐不上线程间同步⾼效,所以并不是⼀种完美的解决⽅案。

  8. IO的效率不会随着监视fd的数量的增⻓⽽下降,epoll不同于select和poll的轮询⽅式,⽽是通过每个fd定义的回调函数来实现,只有就绪的fd才会执⾏回调函数。

  9. 如果没有⼤量的idle -connection或者dead-connection,epoll的效率并不会⽐select/poll⾼很多,但是当遇到⼤量的idle- connection,就会发现epoll的效率⼤⼤⾼于select/poll。

⼗⼀、数据库

1、⼀⼆三范式

  1. 第⼀范式,数据库表中的字段都是单⼀属性的,不可再分;每⼀个属性都是原⼦项,不可分割;如果实体中的某个属性有多个值时,必须拆分为不同的属性 通俗解释。 1NF是关系模式应具备的最起码的条件,如果数据库设计不能满⾜第⼀范式,就不称为关系型数据库。也就 是说,只要是关系型数据库,就⼀定满⾜第⼀范式。

  2. 第⼆范式,数据库表中不存在⾮关键字段对任⼀候选关键字段的部分函数依赖,即符合第 ⼆范式;如果⼀个表中某⼀个字段A的值是由另外⼀个字段或⼀组字段B的值来确定的,就称 为A函数依赖于B;当某张表中的⾮主键信息不是由整个主键函数来决定时,即存在依赖于该 表中不是主键的部分或者依赖于主键⼀部分的部分时,通常会违反2NF。

  3. 第三范式,在第⼆范式的基础上,数据表中如果不存在⾮关键字段对任⼀候选关键字段的 传递函数依赖则符合3NF;第三范式规则查找以消除没有直接依赖于第⼀范式和第⼆范式形成 的表的主键的属性。我们为没有与表的主键关联的所有信息建⽴了⼀张新表。每张新表保存了 来⾃源表的信息和它们所依赖的主键;如果某⼀属性依赖于其他⾮主键属性,⽽其他⾮主键属 性⼜依赖于主键,那么这个属性就是间接依赖于主键,这被称作传递依赖于主属性。 通俗理解:⼀张表最多只存2层同类型信息 *。

2、数据库的索引类型,数据库索引的作⽤

  1. 数据库索引好⽐是⼀本书前⾯的⽬录,能加快数据库的查询速度。索引是对数据库表中⼀个或多个列(例如,employee 表的姓⽒ (lname) 列)的值进⾏排序的结构。如果想按特定职员的姓来查找他或她,则与在表中搜索所有的⾏相⽐,索引有助于更快地获取信息。

  2. 优点

⼤⼤加快数据的检索速度; 创建唯⼀性索引,保证数据库表中每⼀⾏数据的唯⼀性;加速表和表之间的连接; 在使⽤分组和排序⼦句进⾏数据检索时,可以显著减少查询中分组和排序的时间。

  1. 缺点

索引需要占⽤数据表以外的物理存储空间;创建索引和维护索引要花费⼀定的时间;当对表进⾏更新操作时,索引需要被重建,这样降低了数据的维护速度。

  1. 类型

唯⼀索引——UNIQUE,例如:create unique index stusno on student(sno);表明此索引的每⼀个索引值只对应唯⼀的数据记录,对于单列惟⼀性索引,这保证单列不包含重复的值。对于多列惟⼀性索引,保证多个值的组合不重复。主键索引——primary key,数据库表经常有⼀列或列组合,其值唯⼀标识表中的每⼀⾏。该列称为表的主键。 在数据库关系图中为表定义主键将⾃动创建主键索引,主键索引是唯⼀索引的特定类型。该索引要求主键中的每个值都唯⼀。当在查询中使⽤主键索引时,它还允许对数据的快速访问。

聚集索引(也叫聚簇索引)——cluster,在聚集索引中,表中⾏的物理顺序与键值的逻辑(索 引)顺序相同。⼀个表只能包含⼀个聚集索引,如果某索引不是聚集索引,则表中⾏的物理顺序与键值的逻辑顺序不匹配。与⾮聚集索引相⽐,聚集索引通常提供更快的数据访问速度。

  1. 实现⽅式

B+树、散列索引、位图索引

3、聚集索引和⾮聚集索引的区别

  1. 聚集索引表示表中存储的数据按照索引的顺序存储,检索效率⽐⾮聚集索引⾼,但对数据 更新影响较⼤。⾮聚集索引表示数据存储在⼀个地⽅,索引存储在另⼀个地⽅,索引带有指针 指向数据的存储位置,⾮聚集索引检索效率⽐聚集索引低,但对数据更新影响较⼩。

  2. 聚集索引⼀个表只能有⼀个,⽽⾮聚集索引⼀个表可以存在多个。聚集索引存储记录是物 理上连续存在,⽽⾮聚集索引是逻辑上的连续,物理存储并不连续。

4、唯⼀性索引和主键索引的区别

对于主健索引, oracle/sql server/mysql 等都会⾃动建⽴唯⼀索引;

主键不⼀定只包含⼀个字段,所以如果你在主键的其中⼀个字段建唯⼀索引还是必要的; 主健可作外健,唯⼀索引不可; 主健不可为空,唯⼀索引可以; 主健也可是多个字段的组合; 主键与唯⼀索引不同的是 主键索引有 not null 属性; 主键索引每个表只能有⼀个。

5、数据库引擎, innodb和myisam的特点与区别

  1. Innodb引擎提供了对数据库ACID事务的⽀持,并且实现了SQL标准的四种隔离级别,关于数据库事务与其隔离级别的内容请⻅数据库事务与其隔离级别这篇⽂章。该引擎还提供了⾏级锁和外键约束,它的设计⽬标是处理⼤容量数据库系统,它本身其实就是基于MySQL后台的完整数据库系统,MySQL运⾏时Innodb会在内存中建⽴缓冲池,⽤于缓冲数据和索引。但是该引擎不⽀持FULLTEXT类型的索引,⽽且它没有保存表的⾏数,当SELECT COUNT(*) FROM TABLE时需要扫描全表。当需要使⽤数据库事务时,该引擎当然是⾸选。由于锁的粒度更⼩,写操作不会锁定全表,所以在并发较⾼时,使⽤Innodb引擎会提升效率。但是使⽤⾏级锁也不是绝对的,如果在执⾏⼀个SQL语句时MySQL不能确定要扫描的范围,InnoDB表同样会锁全表。

  2. MyIASM是MySQL默认的引擎,但是它没有提供对数据库事务的⽀持,也不⽀持⾏级锁和外键,因此当INSERT(插⼊)或UPDATE(更新)数据时即写操作需要锁定整个表,效率便会低⼀些。不过和Innodb不同,MyIASM中存储了表的⾏数,于是SELECT COUNT(*) FROM TABLE时只需要直接读取已经保存好的值⽽不需要进⾏全表扫描。如果表的读操作远远多于写操作且不需要数据库事务的⽀持,那么MyIASM也是很好的选择。

  3. ⼤尺⼨的数据集趋向于选择InnoDB引擎,因为它⽀持事务处理和故障恢复。数据库的⼤⼩决定了故障恢复的时间⻓短,InnoDB可以利⽤事务⽇志进⾏数据恢复,这会⽐较快。主键查询在InnoDB引擎下也会相当快,不过需要注意的是如果主键太⻓也会导致性能问题,关于这个问题我会在下⽂中讲到。⼤批的INSERT语句(在每个INSERT语句中写⼊多⾏,批量插⼊)在MyISAM下会快⼀些,但是UPDATE语句在InnoDB下则会更快⼀些,尤其是在并发量⼤的时候

6、关系型和⾮关系型数据库的区别

7、数据库的隔离级别

  1. 隔离级别⾼的数据库的可靠性⾼,但并发量低,⽽隔离级别低的数据库可靠性低,但并发量⾼,系统开销⼩。

  2. READ UNCIMMITTED(未提交读),事务中的修改,即使没有提交,其他事务也可以看得到,⽐如说上⾯的两步这种现象就叫做脏读,这种隔离级别会引起很多问题,如⽆必要,不要随便使⽤;这就是事务还没提交,⽽别的事务可以看到他其中修改的数据的后果,也就是脏读;

  3. READ COMMITTED(提交读),⼤多数数据库系统的默认隔离级别是READ COMMITTED,这种隔离级别就是⼀个事务的开始,只能看到已经完成的事务的结果,正在执⾏的,是⽆法被其他事务看到的。这种级别会出现读取旧数据的现象

  4. REPEATABLE READ(可重复读),REPEATABLE READ解决了脏读的问题,该级别保证了每⾏的记录的结果是⼀致的,也就是上⾯说的读了旧数据的问题,但是却⽆法解决另⼀个问题,幻⾏,顾名思义就是突然蹦出来的⾏数据。指的就是某个事务在读取某个范围的数据,但是另⼀个事务⼜向这个范围的数据去插⼊数据,导致多次读取的时候,数据的⾏数不⼀致。虽然读取同⼀条数据可以保证⼀致性,但是却不能保证没有插⼊新的数据。

  5. SERIALIZABLE(可串⾏化),SERIALIZABLE是最⾼的隔离级别,它通过强制事务串⾏执⾏(注意是串⾏),避免了前⾯的幻读情况,由于他⼤量加上锁,导致⼤量的请求超时,因此性能会⽐较底下,再特别需要数据⼀致性且并发量不需要那么⼤的时候才可能考虑这个隔离级别。

8、数据库连接池的作⽤

  1. 在内部对象池中,维护⼀定数量的数据库连接,并对外暴露数据库连接的获取和返回⽅法,如外部使⽤者可通过getConnection⽅法获取数据库连接,使⽤完毕后再通过releaseConnection⽅法将连接返回,注意此时的连接并没有关闭,⽽是由连接池管理器回收,并为下⼀次使⽤做好准备。

  2. 资源重⽤,由于数据库连接得到重⽤,避免了频繁创建、释放连接引起的⼤量性能开销。在减少系统消耗的基础上,增进了系统环境的平稳性(减少内存碎⽚以级数据库临时进程、线程的数量)

  3. 更快的系统响应速度,数据库连接池在初始化过程中,往往已经创建了若⼲数据库连接置于池内备⽤。此时连接池的初始化操作均已完成。对于业务请求处理⽽⾔,直接利⽤现有可⽤连接,避免了数据库连接初始化和释放过程的时间开销,从⽽缩减了系统整体响应时间。

  4. 新的资源分配⼿段,对于多应⽤共享同⼀数据库的系统⽽⾔,可在应⽤层通过数据库连接的配置,实现数据库连接技术。

  5. 统⼀的连接管理,避免数据库连接泄露,较较为完备的数据库连接池实现中,可根据预先的连接占⽤超时设定,强制收回被占⽤的连接,从⽽避免了常规数据库连接操作中可能出现的资源泄露。

9、数据的锁的种类,加锁的⽅式

  1. 锁是⽹络数据库中的⼀个⾮常重要的概念,当多个⽤户同时对数据库并发操作时,会带来数据不⼀致的问题,所以,锁主要⽤于多⽤户环境下保证数据库完整性和⼀致性。

  2. 数据库锁出现的⽬的:处理并发问题;

  3. 并发控制的主要采⽤的技术⼿段:乐观锁、悲观锁和时间戳。

  4. 从数据库系统⻆度分为三种:排他锁、共享锁、更新锁。从程序员⻆度分为两种:⼀种是悲观锁,⼀种乐观锁。

10、数据库union join的区别

  1. join 是两张表做交连后⾥⾯条件相同的部分记录产⽣⼀个记录集,union是产⽣的两个记录集(字段要⼀样的)并在⼀起,成为⼀个新的记录集 。

  2. union在数据库运算中会过滤掉重复数据,并且合并之后的是根据⾏合并的,即:如果a表和b表中的数据各有五⾏,且有两⾏是重复数据,合并之后为8⾏。运⽤场景:适合于需要进⾏统计的运算

  3. union all是进⾏全部合并运算的,即:如果a表和b表中的数据各有五⾏,且有两⾏是重复数据,合并之后为10⾏。

  4. join是进⾏表关联运算的,两个表要有⼀定的关系。即:如果a表和b表中的数据各有五⾏,且有两⾏是重复数据,根据某⼀列值进⾏笛卡尔运算和条件过滤,假如a表有2列,b表有2列,join之后是4列。

11、⾯试前必知的 MySQL 常⽤命令

启动与退出 指定 IP 地址和端⼝号登录 MySQL 数据库 命令格式为: mysql -h ip -u root -p -P 3306 例如: mysql -h 127.0.0.1 -u root -p -P 3306 退出 MySQL 使⽤ quit 或 exit 退出 MySQL 查看数据库 SHOW DATABASES ; 创建数据库 CREATE DATABASE IF NOT EXISTS dbname ; 选择数据库 USE 数据库名 ; 查看数据库中的数据表 SHOW TABLES ; 删除数据库 DROP DATABASE IF EXISTS dbname; 创建⼀个简单的数据库表 字段 类型(⻓度) 属性 索引 CREATE TABLE IF NOT EXISTS 表名( id INT UNSTGND AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL )ENGINE = InnoDB DEFAULT CHARSET=utf8; 添加数据 INSERT INTO table_name ( field1, field2,...fieldN )VALUES ( value1, value2,...valueN ) ; 查询数据 SELECT * FROM table ; 修改数据 UPDATE table SET 字段1 = '值1', 字段1='值2' WHERE 条件 ; 删除数据 DELETE FROM table WHERE 条件 ; 创建新普通⽤户 GRANT 权限 ON 库名.表名 TO '⽤户名'@'主机名' IDENTIFIED BY '密码' 查询所有⽤户 SELECT user,host FROM mysql.user; 删除普通⽤户 DROP USER '⽤户名'@'主机名'; 修改 root ⽤户密码 SET PASSWORD = PASSWORD('新密码'); root ⽤户修改普通⽤户密码 SET PASSWORD FOR '⽤户名'@'主机名'=PASSWORD('新密码'); 授权 GRANT 权限 ON 库名.表名 TO '⽤户名'@'主机名' IDENTIFIED BY '密码'; GRANT SELECT,INSERT,UPDATE,DELETE ON cendxia.user TO '⽤户名'@'主机名' IDENTIFIED BY '密码'; 查看权限 SHOW GRANTS FOR '⽤户名'@'主机名'; 收回权限 REVOKE 权限 ON 库名.表名 FROM '⽤户名'@'主机名'; 备份 mysqldump -u root -p 数据库名 > 要保存的位置 还原数据 mysql -u yser -p dbname < filename.sql; 建表引擎 MyISAM -- 读取速度快,不⽀持事务 InnoDB -- 读取速度稍慢 ⽀持事务 事务回滚 ⼀些常⽤属性 UNSTGND ⽆符号属性 AUTO_INCREMENT ⾃增属性(⼀般⽤在id字段上) ZEROFILL 零填充 字符串类型 CHAR 定⻓的字符串类型 (0-255)个字符 VARCHAR 变⻓的字符串类型,5.0以前(0-255)个字符,5.0版本以后(0-65535)个字符 查看表结构 DESC 表名; (缩写版) DESCRIBE 表名 ; 查看建表语句 SHOW CREATE TABLE 表名; 修改表名 ALTER TABLE 原表名 RENAME TO 新表名; 修改字段的数据类型 ALTER TABLE 表名 MODIFY 字段名 数据类型 属性 索引; ALTER TABLE testalter_tbl MODIFY c CHAR(10); 修改字段名 ALTER TABLE 表名 CHANGE 原字段名 新字段名 数据类型 属性 索引; 增加字段 ALTER TABLE 表名 ADD 字段名 数据类型 属性 索引; -- [FIRST|AFIER 字段名] -- (FIRST 在最前⾯添加字段。AFIER 字段名 在某字段后⾯添加) 删除字段 ALTER TABLE 表名 DROP 字段名; 修改字段的排列位置 ALTER TABLE 表名 MODIFY 字段名 数据类型 属性 索引 AFIER 字段名; 修改表引擎 ALTER TABLE 表名 ENGINE=引擎名; --MyISAM 或 InnoDB ⾼级⽤法 explain sql; explain 命令我们可以学习到该条 SQL 是如何执⾏的,随后解析 explain 的结果可以帮助我们 使⽤更好的索引,最终来优化它! 通过 explain 命令我们可以知道以下信息: 表的读取顺序,数据读取操作的类型,哪些索引可以使⽤,哪些索引实际使⽤了,表之间的引 ⽤,每张表有多少⾏被优化器查询等信息。

格式化输出 sql \G 在命令最后⾯加上 \G 即可。 查看帮助 在 MySQL 提示符中输⼊ help;或者 \h 获取使⽤帮助。

⼗⼆、设计模式(设计和代码实现)

设计模式有 6 ⼤设计原则:

单⼀职责原则:就⼀个类⽽⾔,应该仅有⼀个引起它变化的原因。 开放封闭原则:软件实体可以扩展,但是不可修改。即⾯对需求,对程序的改动可以通过增加代码来完成,但是不能改动现有的代码。 ⾥⽒代换原则:⼀个软件实体如果使⽤的是⼀个基类,那么⼀定适⽤于其派⽣类。即在软件中,把基类替换成派⽣类,程序的⾏为没有变化。 依赖倒转原则:抽象不应该依赖细节,细节应该依赖抽象。即针对接⼝编程,不要对实现编程。 迪⽶特原则:如果两个类不直接通信,那么这两个类就不应当发⽣直接的相互作⽤。如果⼀个类需要调⽤另⼀个类的某个⽅法的话,可以通过第三个类转发这个调⽤。 接⼝隔离原则:每个接⼝中不存在派⽣类⽤不到却必须实现的⽅法,如果不然,就要将接⼝拆分,使⽤多个隔离的接⼝。

创造型模式: 单例模式、⼯⼚模式、建造者模式、原型模式 结构型模式: 适配器模式、桥接模式、外观模式、组合模式、装饰模式、享元模式、代理模式 ⾏为型模式: 责任链模式、命令模式、解释器模式、迭代器模式、中介者模式、备忘录模式、 观察者模式、状态模式、策略模式、模板⽅法模式、访问者模式。

介绍常⻅的⼏种设计模式:

单例模式: 保证⼀个类仅有⼀个实例,并提供⼀个访问它的全局访问点。 ⼯⼚模式: 包括简单⼯⼚模式、抽象⼯⼚模式、⼯⼚⽅法模式 简单⼯⼚模式: 主要⽤于创建对象。⽤⼀个⼯⼚来根据输⼊的条件产⽣不同的类,然后根据不 同类的虚函数得到不同的结果。 抽象⼯⼚模式: 定义了⼀个创建⼀系列相关或相互依赖的接⼝,⽽⽆需指定他们的具体类。 观察者模式: 定义了⼀种⼀对多的关系,让多个观察对象同时监听⼀个主题对象,主题对象发

⽣变化时,会通知所有的观察者,使他们能够更新⾃⼰。 装饰模式: 动态地给⼀个对象添加⼀些额外的职责,就增加功能来说,装饰模式⽐⽣成派⽣类 更为灵活。

1、说说⾯对对象中的设计原则

SRP (Single Responsibility Principle): 单⼀职责原则,就是说⼀个类只提供⼀种功能和仅 有⼀个引起它变化的因素。

OCP (Open Close Principle): 开放封闭原则,就是对⼀个类来说,对它的内部修改是封闭 的,对它的扩展是开放的。

DIP (Dependence Inversion Principle): 依赖倒置原则,就是程序依赖于抽象,⽽不依赖于实现,它的主要⽬的是为了降低耦合性,它⼀般通过反射和配置⽂件来实现的。

LSP (Liskov Substitution Principle): ⾥⽒替换原则,就是基类出现的地⽅,通过它的⼦类 也完全可以实现这个功能

ISP(Interface Segregation Principle):接⼝隔离原则,建⽴单⼀接⼝,不要建⽴庞⼤臃肿的接⼝,尽量细化接⼝,接⼝中的⽅法尽量少。也就是说,我们要为各个类建⽴专⽤的接⼝,⽽不要试图去建⽴⼀个很庞⼤的接⼝供所有依赖它的类去调⽤。

CRP (Composite Reuse Principle): 合成复⽤原则,多⽤组合设计类,少⽤继承。

2、单⼀职责原则和接⼝隔离原则的区别

单⼀职责原则注重的是职责;⽽接⼝隔离原则注重对接⼝依赖的隔离。 单⼀职责原则主要是约束类,其次才是接⼝和⽅法,它针对的是程序中的实现和细节; ⽽接⼝隔离原则主要约束接⼝,主要针对抽象,针对程序整体框架的构建。

3、单例模式

有两种懒汉和饿汉:

饿汉:饿了就饥不择⻝了,所以在单例类定义的时候就进⾏实例化。

懒汉:顾名思义,不到万不得已就不会去实例化类,也就是在第⼀次⽤到的类实例的时候才会去实例化。

饿汉模式(线程安全):

在最开始的时候静态对象就已经创建完成,设计⽅法是类中包含⼀个静态成员指针,该指针指 向该类的⼀个对象,提供⼀个公有的静态成员⽅法,返回该对象指针,为了使得对象唯⼀,构造函数设为私有。

#include <iostream>
#include <algorithm>
using namespace std;
 
class SingleInstance {
public:
    static SingleInstance* GetInstance() {
        static SingleInstance ins;
        return &ins;
    }
    ~SingleInstance(){};
private:
    //涉及到创建对象的函数都设置为private
    SingleInstance() {  std::cout<<"SingleInstance() 饿汉"<<std::endl;   
}
    SingleInstance(const SingleInstance& other) {};
    SingleInstance& operator=(const SingleInstance& other) {return 
*this;}
};
 
int main(){
    //因为不能创建对象所以通过静态成员函数的⽅法返回静态成员变量
    SingleInstance* ins = SingleInstance::GetInstance();
    return 0;
}
//输出 SingleInstance() 饿汉

懒汉模式(线程安全需要加锁):

尽可能的晚的创建这个对象的实例,即在单例类第⼀次被引⽤的时候就将⾃⼰初始化, C++ 很多地⽅都有类型的思想,⽐如写时拷⻉,晚绑定等。

#include <pthread.h>
#include <iostream>
#include <algorithm>
using namespace std;
 
class SingleInstance {
public:
    static SingleInstance* GetInstance() {
        if (ins == nullptr) {
            pthread_mutex_lock(&mutex);
            if (ins == nullptr) {
                ins = new SingleInstance();
            }
            pthread_mutex_unlock(&mutex);
        }
        return ins;
    }
    ~SingleInstance(){};
    //互斥锁
    static pthread_mutex_t mutex;
private:
    //涉及到创建对象的函数都设置为private
    SingleInstance() {  std::cout<<"SingleInstance() 懒汉"<<std::endl;   
}
    SingleInstance(const SingleInstance& other) {};
    SingleInstance& operator=(const SingleInstance& other) {  return 
*this; }
    //静态成员
    static SingleInstance* ins;
};
 //懒汉式 静态变量需要定义
SingleInstance* SingleInstance::ins = nullptr;
pthread_mutex_t SingleInstance::mutex;
 
int main(){
    //因为不能创建对象所以通过静态成员函数的⽅法返回静态成员变量
    SingleInstance* ins = SingleInstance::GetInstance();
    delete ins;
    return 0;
}
//输出 SingleInstance() 懒汉

单例模式的适⽤场景 (1)系统只需要⼀个实例对象,或者考虑到资源消耗的太⼤⽽只允许创建⼀个对象。

(2)客户调⽤类的单个实例只允许使⽤⼀个公共访问点,除了该访问点之外不允许通过其它 ⽅式访问该实例(就是共有的静态⽅法)。

4、⼯⼚模式

简单⼯⼚模式:

就是建⽴⼀个⼯⼚类,对实现了同⼀接⼝的⼀些类进⾏实例的创建。简单⼯⼚模式的实质是由 ⼀个⼯⼚类根据传⼊的参数,动态决定应该创建哪⼀个产品类(这些产品类继承⾃⼀个⽗类或 接⼝)的实例。

#include <iostream>
#include <pthread.h>
using namespace std;
 
//产品类(抽象类,不能实例化)
class Product{
public:
    Product(){};
    virtual void show()=0;  //纯虚函数
};
 
class productA : public Product{
public:
    productA(){};
    void show(){ std::cout << "product A create!" << std::endl; };
    ~productA(){};
};
 
class productB : public Product{
public:
    productB(){};
    void show(){ std::cout << "product B create!" << std::endl; };
    ~productB(){};
};
 
class simpleFactory{ // ⼯⼚类
public:
    simpleFactory(){};
    Product* product(const string str){
        if (str == "productA")
            return new productA();
        if (str == "productB")
           return new productB();
        return NULL;
    };
};
 
int main(){
    simpleFactory obj; // 创建⼯⼚
    Product* pro; // 创建产品
    pro = obj.product("productA");
    pro->show(); // product A create!
    delete pro;
 
    pro = obj.product("productB");
    pro->show(); // product B create!
    delete pro;
    return 0;
}

⼯⼚模式⽬的就是代码解耦,如果我们不采⽤⼯⼚模式,如果要创建产品 A、 B,通常做法采 ⽤⽤ switch...case语句,那么想⼀想后期添加更多的产品进来,我们不是要添加更多的switch...case 吗?这样就很麻烦,⽽且也不符合设计模式中的开放封闭原则。

为了进⼀步解耦,在简单⼯⼚的基础上发展出了抽象⼯⼚模式,即连⼯⼚都抽象出来,实现了 进⼀步代码解耦。

代码如下:

#include <iostream>
#include <pthread.h>
using namespace std;
 
//产品类(抽象类,不能实例化)
class Product{
public:
    Product(){}
    virtual void show()=0;  //纯虚函数
};
 
class Factory{//抽象类
public:
    virtual Product* CreateProduct()=0;//纯虚函数
};
//产品A
class ProductA:public Product{
public:
    ProductA(){}
    void show(){ std::cout<<"product A create!"<<std::endl; };
};
 
//产品B
class ProductB:public Product{
public:
    ProductB(){}
    void show(){ std::cout<<"product B create!"<<std::endl; };
};
 
//⼯⼚类A,只⽣产A产品
class FactorA: public Factory{
public:
    Product* CreateProduct(){
        Product* product_ = nullptr;
        product_ = new ProductA();
        return product_;
    }
};
//⼯⼚类B,只⽣产B产品
class FactorB: public Factory{
public:
    Product* CreateProduct(){
        Product* product_ = nullptr;
        product_ = new ProductB();
        return product_;
    }
};
 
int main(){
    Product* product_ = nullptr;
    auto MyFactoryA = new FactorA();
    product_ = MyFactoryA->CreateProduct();// 调⽤产品A的⼯⼚来⽣产A产品
    product_->show();
    delete product_;
 
    auto MyFactoryB=new FactorB();
    product_ = MyFactoryB->CreateProduct();// 调⽤产品B的⼯⼚来⽣产B产品
    product_->show();
    delete product_;
 
    return 0;
}
//输出 
//product A create! product B create!

5、观察者模式

观察者模式: 定义⼀种⼀(被观察类)对多(观察类)的关系,让多个观察对象同时监听⼀个 被观察对象,被观察对象状态发⽣变化时,会通知所有的观察对象,使他们能够更新⾃⼰的状 态。

观察者模式中存在两种⻆⾊:

观察者: 内部包含被观察者对象,当被观察者对象的状态发⽣变化时,更新⾃⼰的状态。(接 收通知更新状态)

被观察者: 内部包含了所有观察者对象,当状态发⽣变化时通知所有的观察者更新⾃⼰的状 态。(发送通知) 应⽤场景:

当⼀个对象的改变需要同时改变其他对象,且不知道具体有多少对象有待改变时,应该考虑使 ⽤观察者模式;

⼀个抽象模型有两个⽅⾯,其中⼀⽅⾯依赖于另⼀⽅⾯,这时可以⽤观察者模式将这两者封装 在独⽴的对象中使它们各⾃独⽴地改变和复⽤。 实现⽅式:

#include <iostream>
#include <string>
#include <list>
using namespace std;
 
class Subject;
//观察者 基类 (内部实例化了被观察者的对象sub)
class Observer {
protected:
    string name;
    Subject *sub;
 
public:
    Observer(string name, Subject *sub) {
        this->name = name;
        this->sub = sub;
    }
    virtual void update() = 0;
};
 
class StockObserver : public Observer {
public:
    StockObserver(string name, Subject *sub) : Observer(name, sub){}
    void update();
};
 
class NBAObserver : public Observer {
public:
    NBAObserver(string name, Subject *sub) : Observer(name, sub){}
    void update();
};
//被观察者 基类 (内部存放了所有的观察者对象,以便状态发⽣变化时,给观察者发通知)
class Subject {
protected:
    std::list<Observer *> observers;
public:
    string action; //被观察者对象的状态
    virtual void attach(Observer *) = 0;
    virtual void detach(Observer *) = 0;
    virtual void notify() = 0;
};
 
class Secretary : public Subject {
    void attach(Observer *observer) {
        observers.push_back(observer);
    }
    void detach(Observer *observer) {
        list<Observer *>::iterator iter = observers.begin();
        while (iter != observers.end()) {
            if ((*iter) == observer) {
                observers.erase(iter);
                return;
            }
            ++iter;
        }
    }
    void notify() {
        list<Observer *>::iterator iter = observers.begin();
        while (iter != observers.end()) {
            (*iter)->update();
            ++iter;
        }
    }
};
 
void StockObserver::update() {
    cout << name << " 收到消息:" << sub->action << endl;
    if (sub->action == "⽼板来了!") {
        cout << "我⻢上关闭股票,装做很认真⼯作的样⼦!" << endl;
    }
}
 
void NBAObserver::update() {
    cout << name << " 收到消息:" << sub->action << endl;
    if (sub->action == "⽼板来了!") {
        cout << "我⻢上关闭 NBA,装做很认真⼯作的样⼦!" << endl;
    }
}
 
int main()
{
Subject *BOSS = new Secretary();
    Observer *xa = new NBAObserver("xa", BOSS);
    Observer *xb = new NBAObserver("xb", BOSS);
    Observer *xc = new StockObserver("xc", BOSS);
 
    BOSS->attach(xz);
    BOSS->attach(xb);
    BOSS->attach(xc);
 
    BOSS->action = "去吃饭了!";
    BOSS->notify();
    cout << endl;
    BOSS->action = "⽼板来了!";
    BOSS->notify();
    return 0;
}
//输出 
//product A create! product B create!

6、装饰器模式

装饰器模式(Decorator Pattern)允许向⼀个现有的对象添加新的功能,同时⼜不改变其结构。 这种类型的设计模式属于结构型模式,它是作为现有的类的⼀个包装。 代码没有改变 Car 类的内部结构,还为其增加了新的功能,这就是装饰器模式的作⽤。

#include <iostream>
#include <list>
#include <memory>
using namespace std;
 
//抽象构件类 Transform (变形⾦刚)
class Transform{
public:
    virtual void move() = 0;
}; 
 
//具体构件类Car
class Car : public Transform{
public:
    Car(){
        std::cout << "变形⾦刚是⼀辆⻋!" << endl;
    }
    void move(){
        std::cout << "在陆地上移动。" << endl;
    }
}; 
 
//抽象装饰类
class Changer : public Transform{
public:
    Changer(shared_ptr<Transform> transform){
        this->transform = transform;
    }
    void move(){
        transform->move();
    }
private:
    shared_ptr<Transform> transform; 
}; 
 
//具体装饰类Robot
class Robot : public Changer{
public:
    Robot(shared_ptr<Transform> transform) : Changer(transform){
        std::cout << "变成机器⼈!" << std::endl;
    }
 
    void say(){
        std::cout << "说话!" << std::endl;
    }
}; 
 
//具体装饰类AirPlane
class Airplane : public Changer{
public:
    Airplane(shared_ptr<Transform> transform) : Changer(transform){
        std::cout << "变成⻜机!" << std::endl;
    }
 
    void say(){
        std::cout << "在天空⻜翔!" << std::endl;
    }    
}; 
 
int main(void){
    shared_ptr<Transform> camaro = make_shared<Car>();
    camaro->move();
    std::cout << "--------------" << endl;
    shared_ptr<Robot> bumblebee = make_shared<Robot>(camaro);
    bumblebee->move();
    bumblebee->say();
    return 0;
} 
/*
输出
变形⾦刚是⼀辆⻋!
在陆地上移动。
--------------
变成机器⼈!
在陆地上移动。
说话!
--------------
变成⻜机!
在陆地上移动。
在天空⻜翔!
*/

⼗三、千字⻓⽂ 30 图解陪你⼿撕 STL

13.1 前⾔

你清楚下⾯这⼏个问题吗?

调⽤ new 和 delete 时编译器底层到底做了哪些⼯作? STL 器底层空间配置原理是怎样的? STL 空间配置器到底要考虑什么? 什么是内存的配置和释放?

这篇,我们就来回答这些问题。

13.2 STL 六⼤组件

在深⼊配置器之前,我们有必要了解下 STL 的背景知识:

标准模板库(英⽂: Standard Template Library,缩写: STL),是⼀个 C++ 软件库。

STL 的价值在于两个⽅⾯,就底层⽽⾔, STL 带给我们⼀套极具实⽤价值的零部件以及⼀ 整合的组织;除此之外, STL 还带给我们⼀个⾼层次的、以泛型思维 (Generic Paradigm) 为 基础的、系统化的“软件组件分类学”。

STL 提供六⼤组件,了解这些为接下来的阅读打下基础。

容器(containers):各种数据结构,如 vector, list, deque, set, map ⽤来存放数据。从实现的⻆度来看, STL 容器是⼀种 class template。

算法(algorithms):各种常⽤的算法如 sort, search, copy, erase…从实现⻆度来看,STL 算法是⼀种 function template。

迭代器(iterators):扮演容器与算法之间的胶合剂,是所谓的“泛型指针”。从实现⻆度来看,迭代器是⼀种将 operator *, operator ->, operator++, operator– 等指针相关操作予以重载的class template。

仿函数(functors):⾏为类似函数,可以作为算法的某种策略。从实现⻆度来看,仿函数是⼀种重载了 operator() 的 class 或class template。

适配器(adapters):⼀种⽤来修饰容器或仿函数或迭代器接⼝的东⻄。例如 STL 提供的 queue 和 stack,虽然看似容器,其实只能算是⼀种容器适配器,因为它们的底部完全借助 deque,所有操作都由底层的 deque 供应。

配置器(allocator):负责空间配置与管理,从实现⻆度来看,配置器是⼀个实现了动态 空间配置、空间管理、空间释放的 class template。

13.3 何为空间配置器

3.1 为何需要先了解空间配置器?

从使⽤ STL 层⾯⽽⾔,空间配置器并不需要介绍 ,因为容器底层都给你包装好了,但若是从 STL 实现⻆度出发,空间配置器是⾸要理解的。

作为 STL 设计背后的⽀撑,空间配置器总是在默默地付出着。为什么你可以使⽤算法来⾼效 地处理数据,为什么你可以对容器进⾏各种操作,为什么你⽤迭代器可以遍历空间,这⼀切的⼀切,都有“空间配置器”的功劳。

3.2 SGI STL 专属空间配置器

SGI STL 的空间配置器与众不同,且与 STL 标准规范不同。 其名为 alloc,⽽⾮ allocator。

虽然 SGI 也配置了 allocatalor,但是它⾃⼰并不使⽤,也不建议我们使⽤,原因是效率⽐较 感⼈,因为它只是在基层进⾏配置/释放空间⽽已,⽽且不接受任何参数。

SGI STL 的每⼀个容器都已经指定缺省的空间配置器是 alloc。

                                               ![](https://files.mdnice.com/user/11419/d188eb5c-0578-4479-8df7-7a9df09cf75b.png)

在 C++ ⾥,当我们调⽤ new 和 delete 进⾏对象的创建和销毁的时候,也同时会有内存配置 操作和释放操作:

这其中的 new 和 delete 都包含两阶段操作:

对于 new 来说,编译器会先调⽤ ::operator new 分配内存;然后调⽤ Obj::Obj() 构造对象内容。

对于 delete 来说,编译器会先调⽤ Obj::~Obj() 析构对象;然后调⽤ ::operator delete 释 放空间。

为了精密分⼯, STL allocator 决定将这两个阶段操作区分开来。

对象构造由 ::construct() 负责;对象释放由 ::destroy() 负责。 内存配置由 alloc::allocate() 负责;内存释放由 alloc::deallocate() 负责;

STL配置器定义在 中,下图直观的描述了这⼀框架结构

13.4 构造和析构源码

我们知道,程序内存的申请和释放离不开基本的构造和析构基本⼯具: construct() 和 destroy() 。

在 STL ⾥⾯, construct() 函数接受⼀个指针 P 和⼀个初始值 value,该函数的⽤途就是将初值设定到指针所指的空间上。

destroy() 函数有两个版本,第⼀个版本接受⼀个指针,准备将该指针所指之物析构掉。直接调⽤析构函数即可。

第⼆个版本接受 first 和 last 两个迭代器,将[first,last)范围内的所有对象析构掉。

其中 destroy() 只截取了部分源码,全部实现还考虑到特化版本,⽐如判断元素的数值类型 (value type) 是否有 trivial destructor 等限于篇幅,完整代码请参阅《STL 源码剖析》。

13.5 内存的配置与释放

前⾯所讲都是对象的构造和析构,接下来要讲的是对象构造和析构背后的故事 — (内存的分配 与释放),这块是才真正的硬核,不要搞混了哦。 5.1 真· alloc 设计奥义

对象构造和析构之后的内存管理诸项事宜,由 <stl_alloc.h> ⼀律负责。 SGI 对此的设计原则 如下:

向 system heap 要求空间 考虑多线程 (multi-threads) 状态 考虑内存不⾜时的应变措施 考虑过多“⼩型区块”可能造成的内存碎⽚ (fragment) 问题

考虑到⼩型区块可能造成的内存破碎问题, SGI 为此设计了双层级配置器。当配置区块超过 128bytes 时,称为⾜够⼤,使⽤第⼀级配置器,直接使⽤ malloc() 和 free()。

当配置区块不⼤于 128bytes 时,为了降低额外负担,直接使⽤第⼆级配置器,采⽤复杂的 memory pool 处理⽅式。

⽆论使⽤第⼀级配接器(malloc_alloc_template)或是第⼆级配接器(default_alloc_template), alloc 都为其包装了接⼝,使其能够符合 STL 标准。

其中, __malloc_alloc_template 就是第⼀级配置器; __default_alloc_template 就是第⼆级配置器。这么⼀⼤堆源码看懵了吧,别着急,请看下图

其中 SGI STL 将配置器多了⼀层包装使得 Alloc 具备标准接⼝。

13.6 alloc ⼀级配置器源码解读

这⾥截取部分(精华)解读

(1)第⼀级配置器以 malloc(), free(), realloc() 等 C 函数执⃞实际的内存配置、释放和⃞配置 操作,并实现类似 C++ new-handler 的机制(因为它并⾮使⽤ ::operator new 来配置内存, 所以不能直接使⽤C++ new-handler 机制)。

(2) SGI 第⼀级配置器的 allocate() 和 reallocate() 都是在调⽤malloc() 和 realloc() 不成功后,改调⽤ oom_malloc() oom_realloc()。

(3)oom_malloc() 和 oom_realloc() 都有内循环,不断调⽤“内存不⾜处理例程”,期望某次调⽤后,获得⾜够的内存⽽圆满完成任务,哪怕有⼀丝希望也要全⼒以赴申请啊,如果⽤户并没有指定“内存不⾜处理程序”,这个时候便⽆⼒乏天,真的是没内存了,STL 便抛出异常。或调⽤exit(1) 终⽌程序。

13.7 alloc ⼆级配置器源码解读

照例,还是截取部分(精华)源码解读。看累了嘛,远眺歇会,回来继续看,接下来的这部 分,将会更加的让我们为⼤师的智慧折服!

第⼆级配置器多了⼀些机制,专⻔针对内存碎⽚。内存碎⽚化带来的不仅仅是回收时的困难, 配置也是⼀个负担,额外负担永远⽆法避免,毕竟系统要划出这么多的资源来管理另外的资源,但是区块越⼩,额外负担率就越⾼。

7.1 SGI 第⼆级配置器到底解决了多少问题呢?

简单来说 SGI第⼆级配置器的做法是: sub-allocation (层次架构):

前⾯也说过了, SGI STL 的第⼀级配置器是直接使⽤ malloc(), free(), realloc() 并配合类似 C++ new-handler 机制实现的。第⼆级配置器的⼯作机制要根据区块的⼤⼩是否⼤于128bytes 来采取不同的策略:

继续跟上节奏,上⾯提到了 memory pool ,相信程序员朋友们很熟悉这个名词了,没错,这 就是⼆级配置器的精髓所在,如何理解?请看下图:

有了内存池,是不是就可以了,当然没有这么简单。上图中还提到了⾃由链表,这个⼜是何⽅ 神圣?

我们来⼀探究竟!

7.2 ⾃由链表⾃由在哪?⼜该如何维护呢?

我们知道,⼀⽅⾯,⾃由链表中有些区块已经分配给了客端使⽤,所以free_list 不需要再指向 它们;另⼀⽅⾯,为了维护free-list,每个节点还需要额外的指针指向下⼀个节点。

那么问题来了,如果每个节点有两个指针?这不就造成了额外负担吗?本来我们设计 STL 容 器就是⽤来保存对象的,这倒好,对象还没保存之前,已经占据了额外的内存空间了。那么, 有⽅法解决吗?当然有!再来感受⼀次⼤师的智慧! (1)在这之前我们先来了解另⼀个概念——union(联合体/共⽤体),对 union 已经熟悉的读者可以跳过这⼀部分的内容;如果忘记了也没关系,趁此来回顾⼀下: (a)共⽤体是⼀种特殊的类,也是⼀种构造类型的数据结构。 (b)共⽤体表示⼏个变量共⽤⼀个内存位置,在不同的时间保存不同的数据类型和不同⻓度的变量。

(c)所有的共⽤体成员共⽤⼀个空间,并且同⼀时间只能储存其中⼀个成员变量的值。例如 如下

⼀个union 只配置⼀个⾜够⼤的空间以来容纳最⼤⻓度的数据成员,以上例⽽⾔,最⼤⻓度是 double 类型,

所以 ChannelManager 的空间⼤⼩就是 double 数据类型的⼤⼩。在 C++ ⾥, union 的成员默 认属性⻚为 public。 union 主要⽤来压缩空间,如果⼀些数据不可能在同⼀时间同时被⽤到, 则可以使⽤ union。

(2)了解了 union 之后,我们可以借助 union 的帮助,先来观察⼀下free-list 节点的结构

                                                                   ![](https://files.mdnice.com/user/11419/1a6d5546-c6e9-429f-9c65-e778a78d5b2a.png)

来深⼊了解 free_list 的实现技巧,请看下图。

                                                                                    ![](https://files.mdnice.com/user/11419/ce4d5faf-fc4d-4bb5-91ea-c1ec120bdf61.png)

在 unionobj 中,定义了两个字段,再结合上图来分析:

从第⼀个字段看, obj 可以看做⼀个指针,指向链表中的下⼀个节点;

从第⼆个字段看, obj 可以也看做⼀个指针,不过此时是指向实际的内存区。

⼀物⼆⽤的好处就是不会为了维护链表所必须的指针⽽造成内存的另⼀种浪费,或许这就是所 谓的⾃由奥义所在!⼤师的智慧跃然纸上。

7.3 第⼆级配置器的部分实现内容

到这⾥,我们已经基本了解了第⼆级配置器中free_list 的⼯作原理了。附上第⼆级配置器的部 分实现内容源码:

                                                                    ![](https://files.mdnice.com/user/11419/0b0e9d75-c3d7-4a70-a4a3-e9c05268af3f.png)

13.8 空间配置器函数allocate源码解读

我们知道第⼆级配置器拥有配置器的标准接⼝函数 allocate()。此函数⾸先判断区块的⼤⼩, 如果⼤于 128bytes –> 调⽤第⼀级配置器;⼩于128bytes–> 就检查对应的 free_list (如果没 有可⽤区块,就将区块上调⾄ 8 倍数的边界,然后调⽤ refill(), 为 free list ⃞新填充空间。

8.1 空间申请

调⽤标准接⼝函数 allocate():

NOTE:每次都是从对应的 free_list 的头部取出可⽤的内存块。然后对free_list 进⾏调整,使上⼀步拨出的内存的下⼀个节点变为头结点。

8.2 空间释放

同样,作为第⼆级配置器拥有配置器标准接⼝函数 deallocate()。该函数⾸先判断区块⼤⼩, ⼤于 128bytes 就调⽤第⼀级配置器。⼩于 128 bytes 就找出对应的 free_list,将区块回收。

NOTE:通过调整 free_list 链表将区块放⼊ free_list 的头部。

区块回收纳⼊ free_list 的操作,如图所示:

8.3 重新填充 free_lists (1)当发现 free_list 中没有可⽤区块时,就会调⽤ refill() 为free_list 重新填充空间; (2)新的空间将取⾃内存池(经由 chunk_alloc() 完成); (3)缺省取得20个新节点(区块),但万⼀内存池空间不⾜,获得的节点数可能⼩于 20

8.4 内存池(memory pool)

唔 …在前⾯提到了 memory pool,现在终于轮到这个⼤ boss 上场。

⾸先,我们要知道从内存池中取空间给 free_list 使⽤,是 chunk_alloc() 在⼯作,它是怎么⼯作的呢?

我们先来分析 chunk_alloc() 的⼯作机制:

chunk_alloc() 函数以 end_free – start_free 来判断内存池的“水量” (哈哈,很形象的⽐喻)。

如果第⼀级配置器的 malloc() 也失败了,就发出 bad_alloc 异常。

说了这么多来看⼀下 STL 的源码吧。

NOTE:上述就是 STL 源码当中实际内存池的操作原理,我们可以看到其实以共⽤体串联起来 共享内存形成了 free_list 的实质组成。

13.9 本⽂⼩结

STL 源码本身博⼤精深,还有很多精妙的设计等着⼤家去探索。

⼩贺本⼈才疏学浅,在这⾥也只是在⾃⼰掌握的程度下写出⾃⼰的理解,不⾜之处希望对⼤家 多多指出,互相讨论学习。肝了⼀个礼拜的⽂章,⽂中所有的图都是⾃⼰⼀个个亲⼿画的,不画不知道,画完之后真⼼感觉不容易啊。

⼗四、万字⻓⽂⼿撕 STL 迭代器源码

14.1 前⾔

上⼀篇,我们剖析了 STL 空间配置器,这⼀篇⽂章,我们来学习下 STL 迭代器以及背后的 traits 编程技法。

在 STL 编程中,容器和算法是独⽴设计的,容器⾥⾯存的是数据,⽽算法则是提供了对数据的操作,在算法操作数据的过程中,要⽤到迭代器,迭代器可以看做是容器和算法中间的桥梁。

14.2 迭代器设计模式

为何说迭代器的时候,还谈到了设计模式?这个迭代器和设计模式⼜有什么关系呢?

其实,在《设计模式:可复⽤⾯向对象软件的基础》(GOF)这本经典书中,谈到了 23 种设 计模式,其中就有 iterator 迭代模式,且篇幅颇⼤。 碰巧,笔者在研究 STL 源码的时候,同样的发现有 iterator 迭代器,⽽且还占据了⼀章的篇幅。

在设计模式中,关于 iterator 的描述如下:⼀种能够顺序访问容器中每个元素的⽅法,使⽤该 ⽅法不能暴露容器内部的表达⽅式。⽽类型萃取技术就是为了要解决和 iterator 有关的问题的。

有了上⾯这个基础,我们就知道了迭代器本身也是⼀种设计模式,其设计思想值得我们仔细体会。那么 C++ STL 实现 iterator 和 GOF 介绍的迭代器实现⽅法什么区别呢? 那⾸先我们需要了解 C++ 中的两个编程范式的概念, OOP (⾯向对象编程)和 GP (泛型编程)。

在 C++ 语⾔⾥⾯,我们可⽤以下⽅式来简单区分⼀下 OOP 和 GP:

OOP: 将 methods 和 datas 关联到⼀起 (通俗点就是⽅法和成员变量放到⼀个类中实现),通过继承的⽅式,利⽤虚函数表( virtual )来实现运⾏时类型的判定,也叫"动态多态",由于运⾏过程中需根据类型去检索虚函数表,因此效率相对较低。

GP :泛型编程,也被称为"静态多态",多种数据类型在同⼀种算法或者结构上皆可操作,其 效率与针对某特定数据类型⽽设计的算法或者结构相同,具体数据类型在编译期确定,编译 器承担更多,代码执⾏效率⾼。在 STL 中利⽤ GP 将 methods 和 datas 实现了分⽽治之。

⽽ C++ STL 库的整个实现采⽤的就是 GP (Generic Programming),⽽不是 OOP (Object Oriented Programming)。⽽ GOF 设计模式采⽤的就是继承关系实现的,因此,相对来 讲,C++ STL 的实现效率会相对较⾼,⽽且也更有利于维护。 在 STL 编程结构⾥⾯,迭代器其实也是⼀种模板 class ,迭代器在 STL 中得到了⼴泛的应 ⽤,通过迭代器,容器和算法可以有机的绑定在⼀起,只要对算法给予不同的迭代器,⽐如vector::iterator、 list::iterator, std::find() 就能对不同的容器进⾏查找,⽽⽆需 针对某个容器来设计多个版本。 这样看来,迭代器似乎依附在容器之下,

那么,有没有独⽴⽽适⽤于所有容器的泛化的迭代器 呢?这个问题先留着,在后⾯我们会看到,在 STL 编程结构⾥⾯,它是如何把迭代器运⽤的 炉⽕纯⻘。

14.3 智能指针

STL 是泛型编程思想的产物,是以泛型编程为指导⽽产⽣的。具体来说,STL 中的迭代器将范型算法 (find, count, find_if) 等应⽤于某个容器中,给算法提供⼀个访问容器元素的⼯具, iterator 就扮演着这个重要的⻆⾊。

稍微看过 STL 迭代器源码的,就明⽩迭代器其实也是⼀种智能指针,因此,它也就拥有了⼀般指针的所有特点—— 能够对其进⾏ * 和 -> 操作。

template<typename T>
class ListIterator {//mylist迭代器
public:
    ListIterator(T *p = 0) : m_ptr(p){} //构造函数
    T& operator*() const { return *m_ptr;}  //取值,即dereference
    T* operator->() const { return m_ptr;} //成员访问,即member access
    //...
};

但是在遍历容器的时候,不可避免的要对遍历的容器内部有所了解,所以,⼲脆把迭代器的开发⼯作交给容器的设计者,如此以来,所有实现细节反⽽得以封装起来不被使⽤者看到,这也正是为什么每⼀种 STL 容器都提供有专属迭代器的缘故。 ⽐如笔者⾃⼰实现的 list 迭代器在这⾥使⽤的好处主要有: (1) 不⽤担⼼内存泄漏(类似智能指针,析构函数释放内存); (2) 对于 list ,取下⼀个元素不是通过⾃增⽽是通过 next 指针来取,使⽤智能指针可以对⾃增进⾏重载,从⽽提供统⼀接⼝。

14.4 template 参数推导

参数推导能帮我们解决什么问题呢?

在算法中,你可能会定义⼀个简单的中间变量或者设定算法的返回变量类型,这时候,你可能会遇到这样的问题,假如你需要知道迭代器所指元素的类型是什么,进⽽获取这个迭代器操作的算法的返回类型,但是问题是 C++ 没有 typeof 这类判断类型的函数,也⽆法直接获取,那该如何是好?

注意是类型,不是迭代器的值,虽然 C++ 提供了⼀个 typeid() 操作符,这个操作符只能获得型别的名称,但不能⽤来声明变量。要想获得迭代器型别,这个时候⼜该如何是好呢?

function template 的参数推导机制是⼀个不错的⽅法。

例如:

如果 I 是某个指向特定对象的指针,那么在func 中需要指针所指向对象的型别的时候,怎 么办呢?这个还⽐较容易,模板的参数推导机制可以完成任务,

template <class I>
inline void func(I iter) {
    func_imp(iter, *iter); // 传⼊ iter 和 iter 所指的值,class ⾃动推导
}

通过模板的推导机制,就能轻⽽易举的获得指针所指向的对象的类型。

template <class I, class T>
void func_imp(I iter, T t) {
        T tmp; // 这⾥就是迭代器所指物的类别
        // ... 功能实现
}
int main() {
    int i;
    func(&i);//这⾥传⼊的是⼀个迭代器(原⽣指针也是⼀种迭代器)
}

上⾯的做法呢,通过多层的迭代,很巧妙地导出了 T ,但是却很有局限性,⽐如,我希望 func() 返回迭代器的 value type 类型返回值, 函数的 " template 参数推导机制" 推导的只是参数,⽆法推导函数的返回值类型。万⼀需要推导函数的返回值,好像就不⾏了,那 么⼜该如何是好? 这就引出了下⾯的内嵌型别。

14.5 声明内嵌型别

上述所说的 迭代器所指对象的型别,称之为迭代器的 value type 。

尽管在 func_impl 中我们可以把 T 作为函数的返回值,但是问题是⽤户需要调⽤的是 func 。

如果在参数推导机制上加上内嵌型别 (typedef) 呢?为指定的对象类型定义⼀个别名,然 后直接获取,这样来看⼀下实现:

template<typename T>
class MyIter {
public:
    typedef T value_type; //内嵌类型声明
    MyIter(T *p = 0) : m_ptr(p) {}
    T& operator*() const { return *m_ptr;}
private:
    T *m_ptr;
};
 
//以迭代器所指对象的类型作为返回类型
//注意typename是必须的,它告诉编译器这是⼀个类型
template<typename MyIter>
typename MyIter::value_type Func(MyIter iter) {
    return *iter;
}
 
int main(int argc, const  char *argv[]) {
    MyIter<int> iter(new int(666));
    std::cout<<Func(iter)<<std::endl;  //print=> 666
}

上⾯的解决⽅案看着可⾏,但其实呢,实际上还是有问题,这⾥有⼀个隐晦的陷阱:实际上并不是所有的迭代器都是 class type ,原⽣指针也是⼀种迭代器,由于原⽣指针不是 class type ,所以没法为它定义内嵌型别。

                                                                                          ![](https://files.mdnice.com/user/11419/7f534bdc-90b8-4e41-b6c0-0bb67c0fe407.png)

因为 func 如果是⼀个泛型算法,那么它也绝对要接受⼀个原⽣指针作为迭代器,下⾯的代码编译没法通过:

int *p = new int(5);
cout<<Func(p)<<endl; // error

14.6 Partial specialization (模板偏特化)

所谓偏特化是指如果⼀个 class template 拥有⼀个以上的 template 参数,我们可以针对其中某个(或多个,但不是全部) template 参数进⾏特化,⽐如下⾯这个例⼦

template <typename T>
class C {...}; //此泛化版本的 T 可以是任何类型
template <typename T>
class C<T*> {...}; //特化版本,仅仅适⽤于 T 为“原⽣指针”的情况,是泛化版本的限制

所谓特化, 就是特殊情况特殊处理,第⼀个类为泛化版本, T 可以是任意类型,第⼆个类为 特化版本,是第⼀个类的特殊情况,只针对原⽣指针。

14.6.1、原⽣指针怎么办? ——特性 “萃取”traits

还记得前⾯说过的参数推导机制+内嵌型别机制获取型别有什么问题吗?问题就在于原⽣指针 虽然是迭代器但不是 class ,⽆法定义内嵌型别,⽽偏特化似乎可以解决这个问题。

有了上⾯的认识,我们再看看 STL 是如何应⽤的。 STL 定义了下⾯的类模板,它专⻔⽤来 “萃取”迭代器的特性,⽽ value type 正是迭代器的特性之⼀: traits 在 bits/stl_iterator_base_types.h 这个⽂件中:

template<class _Tp>
struct iterator_traits<_Tp*> {
    typedef ptrdiff_t difference_type;
    typedef typename _Tp::value_type value_type;
    typedef typename _Tp::pointer pointer;
    typedef typename _Tp::reference reference;
    typedef typename _Tp::iterator_category iterator_category;
};
template<typename Iterator>
struct iterator_traits {  //类型萃取机
  typedef typename Iterator::value_type value_type; //value_type 就是 
Iterator 的类型型别
}

加⼊萃取机前后的变化:

template<typename Iterator> //萃取前
typename Iterator::value_type  func(Iterator iter) {
    return *iter;
}
 
//通过 iterator_traits 作⽤后的版本
template<typename Iterator>  //萃取后
typename iterator_traits<Iterator>::value_type  func(Iterator iter) { 
    return *iter;
}

看到这⾥也许你会问了,这个萃取前和萃取后的 typename : iterator_traits::value_type 跟 Iterator::value_type 看起来⼀样啊,为什么还要增加 iterator_traits 这⼀层封装,岂不是多此⼀举? 回想萃取之前的版本有什么缺陷:不⽀持原⽣指针。⽽通过萃取机的封装,我们可以通过类模板的特化来⽀持原⽣指针的版本!如此⼀来,⽆论是智能指针,还是原⽣指针,iterator_traits::value_type 都能起作⽤,这就解决了前⾯的问题。

//iterator_traits的偏特化版本,针对迭代器是原⽣指针的情况
template<typename T>
struct iterator_traits<T*> {
    typedef T value_type;
};
image-20211015172311784

14.6.2、 const 偏特化

通过偏特化添加⼀层中间转换的 traits 模板 class,能实现对原⽣指针和迭代器的⽀持,有的读者可能会继续追问:对于指向常数对象的指针⼜该怎么处理呢?⽐如下⾯的例⼦:

iterator_traits<const int*>::value_type  // 获得的 value_type 是 const 
int,⽽不是 int

const 变量只能初始化,⽽不能赋值(这两个概念必须区分清楚)。这将带来下⾯的问题:

template<typename Iterator>
typename iterator_traits<Iterator>::value_type  func(Iterator iter) { 
    typename iterator_traits<Iterator>::value_type tmp; 
    tmp = *iter; // 编译 error
}
 
int val = 666 ;
const int *p = &val;
func(p); // 这时函数⾥对 tmp 的赋值都将是不允许的

那该如何是好呢?答案还是偏特化,来看实现:

template<typename T>
struct iterator_traits<const T*> { //特化const指针
    typedef T value_type; //得到T⽽不是const T
}

14.7 traits编程技法总结

通过上⾯⼏节的介绍,我们知道,所谓的 traits 编程技法⽆⾮ 就是增加⼀层中间的模板 class ,以解决获取迭代器的型别中的原⽣指针问题。利⽤⼀个中间层 iterator_traits 固定了 func 的形式,使得重复的代码⼤量减少,唯⼀要做的就是稍稍特化⼀下 iterator_tartis 使其⽀持 pointer 和 const pointer 。

                                                                      ![](https://files.mdnice.com/user/11419/d1146e0a-190c-41d3-b13a-b161f292f5d7.png)
#include <iostream>
 
template <class T>
struct MyIter {
    typedef T value_type; // 内嵌型别声明
    T* ptr;
    MyIter(T* p = 0) : ptr(p) {}
    T& operator*() const { return *ptr; }
};
// class type
template <class T>
struct my_iterator_traits {
    typedef typename T::value_type value_type;
};
// 偏特化 1
template <class T>
struct my_iterator_traits<T*> {
    typedef T value_type;
};
// 偏特化 2
template <class T>
struct my_iterator_traits<const T*> {
    typedef T value_type;
};
 
// ⾸先询问 iterator_traits<I>::value_type,如果传递的 I 为指针,则进⼊特化版
本,iterator_traits 直接回答;如果传递进来的 I 为 class type,就去询问 
T::value_type.
template <class I>
typename my_iterator_traits<I>::value_type Func(I ite) {
    std::cout << "normal version" << std::endl;
    return *ite;
}
int main(int argc, const  char *argv[]) {
    MyIter<int> ite(new int(6));
    std::cout << Func(ite)<<std::endl;//print=> 6
    int *p = new int(7);
    std::cout<<Func(p)<<std::endl;//print=> 7
    const int k = 8;
    std::cout<<Func(&k)<<std::endl;//print=> 8
}

上述的过程是⾸先询问 iterator_traits::value_type ,如果传递的 I 为指针,则进⼊特化 版本, iterator_traits 直接回答 T ;如果传递进来的 I 为 class type ,就去询问 T::value_type 。 通俗的解释可以参照下图:

                                                              ![](https://files.mdnice.com/user/11419/e87b2fee-1283-4a75-9072-1eaa6915d098.png)

总结:核⼼知识点在于 模板参数推导机制+内嵌类型定义机制, 为了能处理原⽣指针这种特殊的迭代器,引⼊了偏特化机制。 traits 就像⼀台 “特性萃取机”,把迭代器放进去,就能榨取出迭代器的特性。

这种偏特化是针对可调⽤函数 func 的偏特化,想象⼀种极端情况,假如 func 有⼏百万⾏代码,那么如果不这样做的话,就会造成⾮常⼤的代码污染。同时增加了代码冗余。

                                                                ![](https://files.mdnice.com/user/11419/8dfd1c59-9e6a-48b1-98e2-837c2a601490.png)

14.8 迭代器的型别和种类

14.8.1 迭代器的型别 我们再来看看迭代器的型别,常⻅迭代器相应型别有 5 种:

value_type :迭代器所指对象的类型,原⽣指针也是⼀种迭代器,对于原⽣指针 int*,int 即为指针所指对象的类型,也就是所谓的 value_type 。

difference_type : ⽤来表示两个迭代器之间的距离,对于原⽣指针,STL 以 C++ 内建的 ptrdiff_t 作为原⽣指针的 difference_type。

reference_type : 是指迭代器所指对象的类型的引⽤,reference_type ⼀般⽤在迭代器的 * 运算符重载上,如果 value_type 是 T,那么对应的 reference_type 就是 T&;如果value_type 是 const T,那么对应的reference_type 就是 const T&。

pointer_type : 就是相应的指针类型,对于指针来说,最常⽤的功能就是 operator* 和 operator-> 两个运算符。

iterator_category : 的作⽤是标识迭代器的移动特性和可以对迭代器执⾏的操作,从 iterator_category 上,可将迭代器分为 Input Iterator、Output Iterator、Forward Iterator、Bidirectional Iterator、Random Access Iterator 五类,这样分可以尽可能地提 ⾼效率。

template<typename Category,
         typename T,
         typename Distance = ptrdiff_t,
         typename Pointer = T*,
         typename Reference = T&>
struct iterator //迭代器的定义
{
    typedef Category iterator_category;
    typedef T value_type;
    typedef Distance difference_type;
    typedef Pointer pointer;
    typedef Reference reference;
};

iterator class 不包含任何成员变量,只有类型的定义,因此不会增加额外的负担。由于后⾯三个类型都有默认值,在继承它的时候,只需要提供前两个参数就可以了。这个类主要是⽤来继承的,在实现具体的迭代器时,可以继承上⾯的类,这样⼦就不会漏掉上⾯的 5 个型别了。

对应的迭代器萃取机设计如下:

tempalte<typename I>
struct iterator_traits {//特性萃取机,萃取迭代器特性
    typedef typename I::iterator_category iterator_category;
    typedef typename I::value_type value_type;
    typedef typeanme I:difference_type difference_type;
    typedef typename I::pointer pointer;
    typedef typename I::reference reference;
};
 
//需要对型别为指针和 const 指针设计特化版本看

14.8.2、迭代器的分类

最后,我们来看看,迭代器型别 iterator_category 对应的迭代器类别,这个类别会限制 迭代器的操作和移动特性。除了原⽣指针以外,迭代器被分为五类:

Input Iterator : 此迭代器不允许修改所指的对象,是只读的。⽀持 ==、!=、++、、-> 等操作。

Output Iterator :允许算法在这种迭代器所形成的区间上进⾏只写操作。⽀持 ++、* 等操作。

Forward Iterator :允许算法在这种迭代器所形成的区间上进⾏读写操作,但只能单向移动,每次只能移动⼀步。⽀持 Input Iterator 和 Output Iterator 的所有操作。

Bidirectional Iterator :允许算法在这种迭代器所形成的区间上进⾏读写操作,可双向移动,每次只能移动⼀步。⽀持 Forward Iterator 的所有操作,并另外⽀持 – 操作。

Random Access Iterator :包含指针的所有操作,可进⾏随机访问,随意移动指定的步 数。⽀持前⾯四种 Iterator 的所有操作,并另外⽀持 [n] 操作符等操作。

                                                                  !![](https://files.mdnice.com/user/11419/cd833595-e67f-43ae-9886-ce3c685db072.png)

为什么我们要对迭代器进⾏分类呢?迭代器在具体的容器⾥是到底如何运⽤的呢?这个问题就放到下⼀节在讲。 最最后,我们再来回顾⼀下六⼤组件的关系:

container(容器) 通过 allocator(配置器) 取得数据储存空间

algorithm(算法)通过 iterator(迭代器)存取 container(容器) 内容

functor(仿函数) 可以协助 algorithm(算法) 完成不同的策略变化

adapter(配接器) 可以修饰或套接 functor(仿函数)。

⼗五、 2 万字 20 图带你⼿撕 STL 序列

15.1 前⾔

源码之前,了⽆秘密。

上⼀篇,我们剖析了 STL 迭代器源码与 traits 编程技法, 这⼀篇我们来学习下容器。在 STL 编程中,容器是我们经常会⽤到的⼀种数据结构,容器分为序列式容器和关联式容器。

两者的本质区别在于:序列式容器是通过元素在容器中的位置顺序存储和访问元素,⽽关联容器则是通过键 (key) 存储和读取元素。

本篇着重剖析序列式容器相关背后的知识点。

15.2 容器分类

15.3 vector

写 C++ 的⼩伙伴们,应该对vector 都⾮常熟悉了, vector 基本能够⽀持任何类型的对象,同 时它也是⼀个可以动态增⻓的数组,使⽤起来⾮常的⽅便。

但如果我问你,知道它是如何做到动态扩容的吗?哎,是不是⼀时半会答不上来了,哈哈,没 事,我们⼀起来看看。 vector 基本数据结构

基本上, STL ⾥⾯所有的容器的源码都包含⾄少三个部分

迭代器,遍历容器的元素,控制容器空间的边界和元素的移动;

构造函数,满⾜容器的多种初始化;

属性的获取,⽐如 begin(),end()等;

vector 也不例外,其实看了源码之后就发现,vector 相反是所有容器⾥⾯最简单的⼀种。

template <class T, class Alloc = alloc>
class vector {
public:
    // 定义 vector ⾃身的嵌套型别
    typedef T value_type;
    typedef value_type* pointer;
    typedef const value_type* const_pointer;
    // 定义迭代器, 这⾥就只是⼀个普通的指针
    typedef value_type* iterator;
    typedef const value_type* const_iterator;
    typedef value_type& reference;
    typedef const value_type& const_reference;
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;
    ...
    protected:
    typedef simple_alloc<value_type, Alloc> data_allocator; // 设置其空间
配置器
    iterator start;       // 当前使⽤空间的头
    iterator finish;      // 当前使⽤空间的尾
    iterator end_of_storage;  // 当前可⽤空间的尾
    ...
}; 

因为 vector 需要表示⽤户操作的当前数据的起始地址,结束地址,还需要其真正的最⼤地址。所以总共需要 3 个迭代器分别指向:数据的头(start),数据的尾(finish),数组的尾(end_of_storage)。

构造函数 vector 有多个构造函数, 为了满⾜多种初始化。

我们看到,这⾥⾯,初始化满⾜要么都初始化成功, 要么⼀个都不初始化并释放掉抛出异常,异常机制这块拿捏的死死的呀。

因为 vector 是⼀种 class template, 所以呢,我们并不需要⼿动的释放内存, ⽣命周期结束后就⾃动调⽤析构从⽽释放调⽤空间,当然我们也可以直接调⽤析构函数释放内存。

void deallocate() {
  if (start) 
        data_allocator::deallocate(start, end_of_storage - start);
}
// 调⽤析构函数并释放内存
~vector()  { 
  destroy(start, finish);
  deallocate();
}

属性获取

下⾯的部分就涉及到了位置参数的获取, ⽐如返回 vector 的开始和结尾,返回最后⼀个元素,返回当前元素个数,元素容量,是否为空等。 这⾥需要注意的是因为 end() 返回的是 finish,⽽ finish 是指向最后⼀个元素的后⼀个位置的指针,所以使⽤ end() 的时候要注意。

public:
  // 获取数据的开始以及结束位置的指针. 记住这⾥返回的是迭代器, 也就是 vector 迭代器
就是该类型的指针.
    iterator begin() { return start; }
    iterator end() { return finish; }
    reference front() { return *begin(); } // 获取值
    reference back() { return *(end() - 1); } 
    const_iterator begin() const { return start; }// 获取右值
    const_iterator end() const { return finish; }
    
    const_reference front() const { return *begin(); }
    const_reference back() const { return *(end() - 1); }
    
    size_type size() const { return size_type(end() - begin()); }  // 数
组元素的个数
    size_type max_size() const { return size_type(-1) / sizeof(T); }  // 
最⼤能存储的元素个数
    size_type capacity() const { return size_type(end_of_storage - 
    begin()); } // 数组的实际⼤⼩
    bool empty() const { return begin() == end(); } 
    //判断 vector 是否为空, 并不是⽐较元素为 0,是直接⽐较头尾指针。

push 和 pop 操作

vector 的 push 和 pop 操作都只是对尾进⾏操作, 这⾥说的尾部是指数据的尾部。

当调⽤ push_back 插⼊新元素的时候,⾸先会检查是否有备⽤空间,如果有就直接在备⽤空 间上构造元素,并调整迭代器 finish。

当如果没有备⽤空间,就扩充空间(重新配置-移动数据-释放原空间),这⾥则是调⽤了 insert_aux 函数。

                                              ![](https://files.mdnice.com/user/11419/e007dc12-3740-48ed-b77b-78ff153dc948.png) 

在上⾯这张图⾥,可以看到,push_back 这个函数⾥⾯⼜判断了⼀次 finish != end_of_storage 这是因为啥呢?原来这是因为 insert_aux 函数可能还被其他函数调⽤哦。

在下⾯的 else 分⽀⾥⾯,我们看到了 vector 的动态扩容机制:如果原空间⼤⼩为 0 则分配 1 个元素,如果⼤于 0 则分配原空间两倍的新空间,然后把数据拷⻉过去。

pop 元素

public:
  //将尾端元素拿掉 并调整⼤⼩
  void pop_back() {
      --finish;//将尾端标记往前移动⼀个位置 放弃尾端元素
      destroy(finish);
  }

erase 删除元素 erase 函数清除指定位置的元素, 其重载函数⽤于清除⼀个范围内的所有元素。实际实现就是将删除元素后⾯所有元素往前移动,对于 vector 来说删除元素的操作开销还是很⼤的,所以说 vector 它不适合频繁的删除操作,毕竟它是⼀个数组。

//清楚[first, last)中的所有元素
  iterator erase(iterator first, iterator last) {
      iterator i = copy(last, finish, first);
      destroy(i, finish);
      finish = finish - (last - first);
      return first;
  }
  //清除指定位置的元素
  iterator erase(iterator position) {
      if (position + 1 != end()) 
          copy(position + 1, finish, position);//copy 全局函数
      }      
      --finish;
      destroy(finish);
      return position;
  }
  void clear() {
      erase(begin(), end());
  }

我们结合图解来看⼀下:

清楚范围内的元素,第⼀步要将 finish 迭代器后⾯的元素拷⻉回去,然后返回拷⻉完成的尾部迭代器,最后在删除之前的。 删除指定位置的元素就是实际就是将指定位置后⾯的所有元素向前移动, 最后析构掉最后⼀个元素。 insert 插⼊元素 vector 的插⼊元素具体来说呢,⼜分三种情况: 1、如果备⽤空间⾜够且插⼊点的现有元素多于新增元素;

2、如果备⽤空间⾜够且插⼊点的现有元素⼩于新增元素;

3、如果备⽤空间不够;

我们⼀个⼀个来分析。

插⼊点之后的现有元素个数 > 新增元素个数

插⼊点之后的现有元素个数 <= 新增元素个数

如果备⽤空间不⾜

这⾥呢,要注意⼀个坑,就是所谓的迭代器失效问题。 通过图解我们就明⽩了,所谓的迭代器失效问题是由于元素空间重新配置导致之前的迭代器访问的元素不在了,总结来说有两种: 由于插⼊元素,使得容器元素整体迁移导致存放原容器元素的空间不再有效,从⽽使得指向原空间的迭代器失效; 由于删除元素,使得某些元素次序发⽣变化导致原本指向某元素的迭代器不再指向期望指向的元素。

前⾯提到的⼀些全局函数,这⾥总结⼀下: copy(a,b,c):将(a,b)之间的元素拷⻉到(c,c-(b-a))位置 uninitialized_copy(first, last, result): 具体作⽤是将 [first,last)内的元素拷⻉到 result 从前 往后拷⻉ copy_backward(first, last, result): 将 [first,last)内的元素拷⻉到 result 从后往前拷⻉

vector 总结

到这⾥呢,vector 分析的就差不多了,最后提醒需要注意的是:vector 的成员函数都不做边 界检查 (at⽅法会抛异常),使⽤者要⾃⼰确保迭代器和索引值的合法性。 我们来总结⼀下 vector 的优缺点。

优点

在内存中分配⼀块连续的内存空间进⾏存,可以像数组⼀样操作,动态扩容。 随机访问⽅便,⽀持下标访问和vector.at()操作。 节省空间。

缺点

由于其顺序存储的特性,vector 插⼊删除操作的时间复杂度是 O(n)。只能在末端进⾏pop和push。

当动态⻓度超过默认分配⼤⼩后,要整体重新分配、拷⻉和释放空间。vector的缺点也很明显, 在频率较⾼的插⼊和删除时效率就太低了。

15.4 list

好了,下⾯我们来看⼀下 list,list 是⼀种双向链表。

list 的设计更加复杂⼀点,好处是每次插⼊或删除⼀个元素,就配置或释放⼀个元素,list 对于空间的运⽤有绝对的精准,⼀点也不浪费。⽽且对于任何位置的元素插⼊或删除,list 永远是常数空间。

注意:list 源码⾥其实分了两个部分,⼀个部分是 list 结构,另⼀部分是 list 节点的结构。

那这⾥不妨思考⼀下,为什么 list 节点分为了两个部分,⽽不是在⼀个结构体⾥⾯呢? 也就是说为什么指针变量和数据变量分开定义呢?

如果看了后⾯的源码就晓得了,这⾥是为了给迭代器做铺垫,因为迭代器遍历的时候不需要数据成员的,只需要前后指针就可以遍历该 list。

list 数据结构-节点

__list_node ⽤来实现节点,数据结构中就储存前后指针和属性。

template <class T> struct __list_node {
    // 前后指针
    typedef void* void_pointer;
    void_pointer next;
    void_pointer prev;
    // 属性
    T data;
};

来瞅⼀瞅,list 的节点⻓啥样,因为 list 是⼀种双向链表,所以基本结构就是下⾯这个样⼦:

基本类型

template<class T, class Ref, class Ptr> struct __list_iterator {
    typedef __list_iterator<T, T&, T*>     iterator;  // 迭代器
    typedef __list_iterator<T, const T&, const T*> const_iterator;
    typedef __list_iterator<T, Ref, Ptr>    self;   
  
    // 迭代器是bidirectional_iterator_tag类型
    typedef bidirectional_iterator_tag iterator_category;
    typedef T value_type;
    typedef Ptr pointer;
    typedef Ref reference;
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;
    ... 
};

构造函数

template<class T, class Ref, class Ptr> struct __list_iterator {
    ...
    // 定义节点指针
    typedef __list_node<T>* link_type;
    link_type node;
  // 构造函数
    __list_iterator(link_type x) : node(x) {}
    __list_iterator() {}
    __list_iterator(const iterator& x) : node(x.node) {}
   ... 
};

重载

template<class T, class Ref, class Ptr> struct __list_iterator  {
    ...
    // 重载
    bool operator==(const self& x) const { return node == x.node; }
    bool operator!=(const self& x) const { return node != x.node; }
    ...
 
    // ++和--是直接操作的指针指向next还是prev, 因为list是⼀个双向链表
    self& operator++() { 
      node = (link_type)((*node).next);
      return *this;
    }
    self operator++(int) { 
      self tmp = *this;
      ++*this;
      return tmp;
    }
    self& operator--() { 
      node = (link_type)((*node).prev);
      return *this;
    }
    self operator--(int)  { 
      self tmp = *this;
      --*this;
      return tmp;
    }
};

list 结构 list ⾃⼰定义了嵌套类型满⾜ traits 编程, list 迭代器是 bidirectional_iterator_tag 类型,并不 是⼀个普通指针。

list在定义 node 节点时, 定义的不是⼀个指针。这⾥要注意。

template <class T, class Alloc = alloc>
class list {
protected:
    typedef void* void_pointer;
    typedef __list_node<T> list_node; // 节点
    typedef simple_alloc<list_node, Alloc> list_node_allocator; // 空间配
置器
public:      
    // 定义嵌套类型
    typedef T value_type;
    typedef value_type* pointer;
    typedef const value_type* const_pointer;
    typedef value_type& reference;
    typedef const value_type& const_reference;
    typedef list_node* link_type;
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;
    
protected:
    // 定义⼀个节点, 这⾥节点并不是⼀个指针.
    link_type node;
    
public:
    // 定义迭代器
    typedef __list_iterator<T, T&, T*>             iterator;
    typedef __list_iterator<T, const T&, const T*> const_iterator;
  ...
};

list 构造和析构函数实现

构造函数前期准备: 每个构造函数都会创造⼀个空的 node 节点,为了保证我们在执⾏任何操作都不会修改迭代器。

list 默认使⽤ alloc 作为空间配置器,并根据这个另外定义了⼀个 list_node_allocator,⽬的是更加⽅便以节点⼤⼩来配置单元。

template <class T, class Alloc = alloc>
class list {
protected:
    typedef void* void_pointer;
    typedef __list_node<T> list_node; // 节点
    typedef simple_alloc<list_node, Alloc> list_node_allocator; // 空间配置器

其中,list_node_allocator(n)表示配置 n 个节点空间。以下四个函数,分别⽤来配置,释放,构造,销毁⼀个节点。

class list {
protected:
  // 配置⼀个节点并返回
  link_type get_node() { return list_node_allocator::allocate(); }
  // 释放⼀个节点
  void put_node(link_type p) { list_node_allocator::deallocate(p); }
  // 产⽣(配置并构造)⼀个节点带有元素初始值
  link_type create_node(const T& x) {
      link_type p = get_node();
      __STL_TRY {
        construct(&p->data, x);
      }
      __STL_UNWIND(put_node(p));
      return p;
  }
//销毁(析构并释放)⼀个节点
  void destroy_node(link_type p) {
    destroy(&p->data);
    put_node(p);
  }
  // 对节点初始化
  void empty_initialize() { 
    node = get_node();
    node->next = node;
    node->prev = node;
  }  
};

基本属性获取

template <class T, class Alloc = alloc>
class list {
    ...
public: 
  iterator begin() { return (link_type)((*node).next); }  // 返回指向头的
指针
const_iterator begin() const { return (link_type)((*node).next); }
    iterator end() { return node; } // 返回最后⼀个元素的后⼀个的地址
    const_iterator end() const { return node; }
    
    // 这⾥是为旋转做准备, rbegin返回最后⼀个地址, rend返回第⼀个地址. 我们放在配
接器⾥⾯分析
    reverse_iterator rbegin() { return reverse_iterator(end()); }
    const_reverse_iterator rbegin() const { 
      return const_reverse_iterator(end()); 
    }
    reverse_iterator rend() { return reverse_iterator(begin()); }
    const_reverse_iterator rend() const { 
      return const_reverse_iterator(begin());
    } 
    
    // 判断是否为空链表, 这是判断只有⼀个空node来表示链表为空.
    bool empty() const { return node->next == node; }
    // 因为这个链表, 地址并不连续, 所以要⾃⼰迭代计算链表的⻓度.
    size_type size() const {
      size_type result = 0;
      distance(begin(), end(), result);
      return result;
    }
    size_type max_size() const { return size_type(-1); }
    // 返回第⼀个元素的值
    reference front() { return *begin(); }
    const_reference front() const { return *begin(); }
    // 返回最后⼀个元素的值
    reference back() { return *(--end()); }
    const_reference back() const { return *(--end()); }
    
    // 交换
    void swap(list<T, Alloc>& x) { __STD::swap(node, x.node); }
    ...
};
template <class T, class Alloc>
inline void swap(list<T, Alloc>& x, list<T, Alloc>& y) {
    x.swap(y);
}

list 的头插和尾插

因为 list 是⼀个循环的双链表, 所以 push 和 pop 就必须实现是在头插⼊, 删除还是在尾插⼊和 删除。

在 list 中,push 操作都调⽤ insert 函数, pop 操作都调⽤ erase 函数。

template <class T, class Alloc = alloc>
class list {
    ...
    // 直接在头部或尾部插⼊
    void push_front(const T& x) { insert(begin(), x); } 
    void push_back(const T& x) { insert(end(), x); }
    // 直接在头部或尾部删除
    void pop_front() { erase(begin()); } 
    void pop_back() { 
      iterator tmp = end();
      erase(--tmp);
    }
    ...
};

上⾯的两个插⼊函数内部调⽤的 insert 函数。

class list {
    ...
public:
  // 最基本的insert操作, 之插⼊⼀个元素
  iterator insert(iterator position, const T& x) {
      // 将元素插⼊指定位置的前⼀个地址
    link_type tmp = create_node(x);
    tmp->next = position.node;
    tmp->prev = position.node->prev;
    (link_type(position.node->prev))->next = tmp;
    position.node->prev = tmp;
    return tmp;
  }

这⾥需要注意的是

节点实际是以 node 空节点开始的。

插⼊操作是将元素插⼊到指定位置的前⼀个地址进⾏插⼊的。

删除操作

删除元素的操作⼤都是由 erase 函数来实现的, 其他的所有函数都是直接或间接调⽤ erase。 list 是链表, 所以链表怎么实现删除, list 就在怎么操作:很简单,先保留前驱和后继节点, 再 调整指针位置即可。

由于它是双向环状链表,只要把边界条件处理好,那么在头部或者尾部插⼊元素操作⼏乎是⼀ 样的,同样的道理,在头部或者尾部删除元素也是⼀样的。

template <class T, class Alloc = alloc>
class list {
    ...
  iterator erase(iterator first, iterator last);
    void clear();   
    // 参数是⼀个迭代器 修改该元素的前后指针指向再单独释放节点就⾏了
  iterator erase(iterator position) {
      link_type next_node = link_type(position.node->next);
      link_type prev_node = link_type(position.node->prev);
      prev_node->next = next_node;
      next_node->prev = prev_node;
      destroy_node(position.node);
      return iterator(next_node);
    }
    ...
};
...
}

list 内部提供⼀种所谓的迁移操作(transfer):将某连续范围的元素迁移到某个特定位置之前,技术上实现其实不难,就是节点之间的指针移动,只要明⽩了这个函数的原理,后⾯的 splice,sort,merge 函数也就⼀⼀知晓了,我们来看⼀下 transfer 的源码:

template <class T, class Alloc = alloc>
class list {
    ...
protected:
    void transfer(iterator position, iterator first, iterator last) {
      if (position != last) {
        (*(link_type((*last.node).prev))).next = position.node;
        (*(link_type((*first.node).prev))).next = last.node;
        (*(link_type((*position.node).prev))).next = first.node;  
        link_type tmp = link_type((*position.node).prev);
        (*position.node).prev = (*last.node).prev;
        (*last.node).prev = (*first.node).prev; 
        (*first.node).prev = tmp;
      }
    }
    ...
};

上⾯代码的七⾏分别对应下图的七个步骤,看明⽩应该不难吧。

另外 list 的其它的⼀些成员函数这⾥限于篇幅,就不贴出源码了,简单说⼀些注意点。

splice函数: 将两个链表进⾏合并:内部就是调⽤的 transfer 函数。

merge 函数: 将传⼊的 list 链表 x 与原链表按从⼩到⼤合并到原链表中(前提是两个链表都是已经从⼩到⼤排序了). 这⾥ merge 的核⼼就是 transfer 函数。

reverse 函数: 实现将链表翻转的功能:主要是 list 的迭代器基本不会改变的特点, 将每⼀个 元素⼀个个插⼊到 begin 之前。

sort 函数: list 这个容器居然还⾃⼰实现⼀个排序,看⼀眼源码就发现其实内部调⽤的 merge 函数,⽤了⼀个数组链表⽤来存储 2î 个元素, 当上⼀个元素存储满了之后继续往下⼀个链表存储, 最后将所有的链表进⾏ merge归并(合并), 从⽽实现了链表的排序。

赋值操作: 需要考虑两个链表的实际⼤⼩不⼀样时的操作

原链表⼤ : 复制完后要删除掉原链表多余的元素

原链表⼩ : 复制完后要还要将x链表的剩余元素以插⼊的⽅式插⼊到原链表中resize 操作: 重新修改 list 的⼤⼩。

传⼊⼀个 new_size,如果链表旧⻓度⼤于 new_size 的⼤⼩, 那就删除后⾯多余的节点

clear 操作: 清除所有节点

遍历每⼀个节点,销毁(析构并释放)⼀个节点

remove 操作: 清除指定值的元素

遍历每⼀个节点,找到就移除

unique 操作: 清除数值相同的连续元素,注意只有“连续⽽相同的元素”,才会被移除剩⼀ 个。 遍历每⼀个节点,如果在此区间段有相同的元素就移除之

list 总结

我们来总结⼀下。

list 是⼀种双向链表。每个结点都包含⼀个数据域、⼀个前驱指针 prev 和⼀个后驱指针 next。

由于其链表特性,实现同样的操作,相对于 STL 中的通⽤算法, list 的成员函数通常有更⾼ 的效率,内部仅需做⼀些指针的操作,因此尽可能选择 list 成员函数。

优点

不适⽤连续内存完成动态操作 在内部⽅便进⾏插⼊删除操作。 可在两端进⾏push和pop操作。

缺点

不⽀持随机访问,即下标操作和.at()。 相对于vector占⽤内存多。

15.5 deque

下⾯到了最硬核的内容了,接下来我们学习⼀下双端队列 deque 。 deque 的功能很强⼤。 ⾸先来⼀张图吧。

上⾯就是 deque 的示例图,deque 和 vector 的最⼤差异⼀在于 deque 允许常数时间内对头端或尾端进⾏元素的插⼊或移除操作。 ⼆在于 deque 没有所谓的容量概念,因为它是动态地以分段连续空间组合⽽成随时可以增加⼀块新的空间并拼接起来。

虽然 deque 也提供 随机访问的迭代器,但它的迭代器和前⾯两种容器的都不⼀样,其设计相当复杂度和精妙,因此,会对各种运算产⽣⼀定影响,除⾮必要,尽可能的选择使⽤ vector ⽽⾮ deque。⼀⼀来探究下吧。

deque 的中控器

deque 在逻辑上看起来是连续空间,内部是由⼀段⼀段的定量连续空间构成。⼀旦有必要在 deque 的前端或尾端增加新空间,便配置⼀段定量的连续空间,串接在整个 deque 的头部或尾部。

设计 deque 的⼤师们,想必是让 deque 的最⼤挑战就是在这些分段的定量连续空间上,维护其整体连续的假象,并提供其随机存取的接⼝,从⽽避开了像 vector 那样的“重新配置-复制-释放”开销三部曲。这样⼀来,虽然开销降低,却提⾼了复杂的迭代器架构。 因此数据结构的设计和迭代器前进或后退等操作都⾮常复杂。

deque 采⽤⼀块所谓的 map (注意不是STL⾥⾯的map容器)作为中控器,其实就是⼀⼩块 连续空间,其中的每个元素都是指针,指向另外⼀段较⼤的连续线性空间,称之为缓冲区。, 在后⾯我们看到,缓冲区才是 deque 的储存空间主体。

#ifndef __STL_NON_TYPE_TMPL_PARAM_BUG
template <class T, class Ref, class Ptr, size_t BufSiz>
class deque {
public:
  typedef T value_type;
  typedef value_type* pointer;
  ...
protected:
  typedef pointer** map_pointer;
  map_pointer map;//指向 map,map 是连续空间,其内的每个元素都是⼀个指针。
  size_type map_size;
  ...
};

其示例图如下:deque 的结构设计中,map 和 node-buffer 的关系如下:

deque 的迭代器 deque 是分段连续空间,维持其“整体连续”假象的任务,就靠它的迭代器来实现,也就是 operator++ 和 operator-- 两个运算⼦上⾯。 在看源码之前,我们可以思考⼀下,如果让你来设计,你觉得 deque 的迭代器应该具备什么样的结构和功能呢?

⾸先第⼀点,我们能想到的是,既然是分段连续,迭代器应该能指出当前的连续空间在哪⾥;其次,第⼆点因为缓冲区有边界,迭代器还应该要能判断,当前是否处于所在缓冲区的边缘,如果是,⼀旦前进或后退,就必须跳转到下⼀个或上⼀个缓冲区;

第三点,也就是实现前⾯两种情况的前提,迭代器必须能随时控制中控器。有了这样的思想准备之后,我们再来看源码,就显得容易理解⼀些了。

template <class T, class Ref, class Ptr, size_t BufSiz>
struct __deque_iterator {
  // 迭代器定义
  typedef __deque_iterator<T, T&, T*, BufSiz>             iterator;
  typedef __deque_iterator<T, const T&, const T*, BufSiz> 
const_iterator;
  static size_t buffer_size() {return __deque_buf_size(BufSiz, 
sizeof(T)); }
  // deque是random_access_iterator_tag类型
  typedef random_access_iterator_tag iterator_category;
  // 基本类型的定义, 满⾜traits编程
  typedef T value_type;
  typedef Ptr pointer;
  typedef Ref reference;
  typedef size_t size_type;
  typedef ptrdiff_t difference_type;
  // node
  typedef T** map_pointer;
  map_pointer node;
  typedef __deque_iterator self;
  ...
};

deque 的每⼀个缓冲区由设计了三个迭代器(为什么这样设计?)

struct __deque_iterator {
  ...
  typedef T value_type;
  T* cur;
  T* first;
  T* last;
  typedef T** map_pointer;
  map_pointer node;
  ...
};

那,为什么要这样设计呢?回到前⾯我们刚才说的,因为它是分段连续的空间,下图描绘了 deque 的中控器、缓冲区、迭代器之间的相互关系 :

看明⽩了吗,每⼀段都指向⼀个缓冲区 buffer,⽽缓冲区是需要知道每个元素的位置的,所以需要这些迭代器去访问。 其中 cur 表示当前所指的位置; first 表示当前数组中头的位置; last 表示当前数组中尾的位置。

这样就⽅便管理,需要注意的是 deque 的空间是由 map 管理的, 它是⼀个指向指针的指针, 所以三个参数都是指向当前的数组,但这样的数组可能有多个,只是每个数组都管理这3个变量。

那么,缓冲区⼤⼩是谁来决定的呢?这⾥呢,⽤来决定缓冲区⼤⼩的是⼀个全局函数:

inline size_t __deque_buf_size(size_t n, size_t sz) {
  return n != 0 ? n : (sz < 512 ? size_t(512 / sz): size_t(1));
}
//如果 n 不为0,则返回 n,表示缓冲区⼤⼩由⽤户⾃定义
//如果 n == 0,表示 缓冲区⼤⼩默认值
//如果 sz = (元素⼤⼩ sizeof(value_type)) ⼩于 512 则返回 521/sz
//如果 sz 不⼩于 512 则返回 1

假设我们现在构造了⼀个 int 类型的 deque,设置缓冲区⼤⼩等于 32,这样⼀来,每个缓冲区可以容纳 32/sizeof(int) = 8(64位系统) 个元素。经过⼀番操作之后,deque 现在有 20 个元素了,那么成员函数 begin() 和 end() 返回的两个迭代器应该是怎样的呢?如下图所示:

20 个元素需要 20/(sizeof(int)) = 5(图中只展示3个) 个缓冲区。所以 map 运⽤了三个节点。迭代器 start 内的 cur 指针指向缓冲区的第⼀个元素,迭代器 finish 内的 cur 指针指向缓冲区的最后⼀个元素(的下⼀个位置)。

注意,最后⼀个缓冲区尚有备⽤空间,如果之后还有新元素插⼊,则直接插⼊到备⽤空间。deque 迭代器的操作前进和后退operator++ 操作代表是需要切换到下⼀个元素,这⾥需要先切换再判断是否已经到达缓冲区的末尾。

self& operator++() { 
  ++cur;          //切换⾄下⼀个元素
  if (cur == last) {    //如果已经到达所在缓冲区的末尾
  set_node(node+1);  //切换下⼀个节点
  cur = first;    
  }
  return *this;
}

operator-- 操作代表切换到上⼀个元素所在的位置,需要先判断是否到达缓冲区的头部,再后退。

self& operator--() {     
  if (cur == first) {    //如果已经到达所在缓冲区的头部
  set_node(node - 1); //切换前⼀个节点的最后⼀个元素
  cur = last;   
  }
  --cur;           //切换前⼀个元素
  return *this;
}

deque 的构造和析构函数

构造函数. 有多个重载函数, 接受⼤部分不同的参数类型. 基本上每⼀个构造函数都会调⽤create_map_and_nodes, 这就是构造函数的核⼼, 待会就来分析这个函数实现.

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
    ...
public:                         // Basic types
  deque() : start(), finish(), map(0), map_size(0){
    create_map_and_nodes(0);
  }   // 默认构造函数
  deque(const deque& x) : start(), finish(), map(0), map_size(0) {
    create_map_and_nodes(x.size());
     __STL_TRY {
      uninitialized_copy(x.begin(), x.end(), start);
    }
    __STL_UNWIND(destroy_map_and_nodes());
  }
    // 接受 n:初始化⼤⼩, value:初始化的值
  deque(size_type n, const value_type& value) : start(), finish(), 
map(0), map_size(0) {
    fill_initialize(n, value);
  }
  deque(int n, const value_type& value) : start(), finish(), map(0), 
map_size(0) {
    fill_initialize(n, value);
  } 
  deque(long n, const value_type& value) : start(), finish(), map(0), 
map_size(0){
    fill_initialize(n, value);
  }
  ...

下⾯我们来学习⼀下 deque 的中控器是如何配置的

void 
deque<T,Alloc,BufSize>::create_map_and_nodes(size_type_num_elements) {
  //需要节点数= (每个元素/每个缓冲区可容纳的元素个数+1)
  //如果刚好整除,多配⼀个节点
  size_type num_nodes = num_elements / buffer_size() + 1;
  //⼀个 map 要管理⼏个节点,最少 8 个,最多是需要节点数+2
  map_size = max(initial_map_size(), num_nodes + 2);
  map = map_allocator::allocate(map_size);
 // 计算出数组的头前⾯留出来的位置保存并在nstart.
  map_pointer nstart = map + (map_size - num_nodes) / 2;
  map_pointer nfinish = nstart + num_nodes - 1;
  map_pointer cur;//指向所拥有的节点的最中央位置
  ...
}

注意了,看了源码之后才知道:deque 的 begin 和 end 不是⼀开始就是指向 map 中控器⾥开头和结尾的,⽽是指向所拥有的节点的最中央位置。

这样带来的好处是可以使得头尾两边扩充的可能性和⼀样⼤,换句话来说,因为 deque 是头尾插⼊都是 O(1), 所以 deque 在头和尾都留有空间⽅便头尾插⼊。

那么,什么时候 map 中控器 本身需要调整⼤⼩呢?触发条件在于 reserve_map_at_back 和 reserve_map_at_front 这两个函数来判断,实际操作由 reallocate_map 来执⾏。

那 reallocate_map ⼜是如何操作的呢?这⾥先留个悬念。

// 如果 map 尾端的节点备⽤空间不⾜,符合条件就配置⼀个新的map(配置更⼤的,拷⻉原来
的,释放原来的)
void reserve_map_at_back (size_type nodes_to_add = 1) {
  if (nodes_to_add + 1 > map_size - (finish.node - map))
    reallocate_map(nodes_to_add, false);
}
// 如果 map 前端的节点备⽤空间不⾜,符合条件就配置⼀个新的map(配置更⼤的,拷⻉原来
的,释放原来的)
void reserve_map_at_front (size_type nodes_to_add = 1) {
  if (nodes_to_add > start.node - map)
    reallocate_map(nodes_to_add, true);
}

deque 的插⼊元素和删除元素

因为 deque 的是能够双向操作,所以其 push 和 pop 操作都类似于 list 都可以直接有对应的操作,需要注意的是 list 是链表,并不会涉及到界线的判断, ⽽deque 是由数组来存储的,就需要随时对界线进⾏判断。

push 实现

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
    ...
public:                         // push_* and pop_*
    // 对尾进⾏插⼊
    // 判断函数是否达到了数组尾部. 没有达到就直接进⾏插⼊
  void push_back(const value_type& t) {
    if (finish.cur != finish.last - 1) {
      construct(finish.cur, t);
      ++finish.cur;
    }
    else
      push_back_aux(t);
  }
    // 对头进⾏插⼊
    // 判断函数是否达到了数组头部. 没有达到就直接进⾏插⼊
  void push_front(const value_type& t) {
    if (start.cur != start.first) {
      construct(start.cur - 1, t);
      --start.cur;
    }
    else
      push_front_aux(t);
  }
    ...
};

pop 实现

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
    ...
public: 
    // 对尾部进⾏操作
    // 判断是否达到数组的头部. 没有到达就直接释放
    void pop_back() {
    if (finish.cur != finish.first) {
      --finish.cur;
      destroy(finish.cur);
    }
    else
      pop_back_aux();
  }
    // 对头部进⾏操作
    // 判断是否达到数组的尾部. 没有到达就直接释放
  void pop_front() {
    if (start.cur != start.last - 1) {
      destroy(start.cur);
      ++start.cur;
    }
    else 
     pop_front_aux();
  }
    ...
};

reserve_map_at⼀类函数. pop和push都先调⽤了reserve_map_at_XX函数, 这些函数主要是为了判断前后空间是否⾜够.

删除操作 不知道还记得,最开始构造函数调⽤ create_map_and_nodes 函数,考虑到 deque 实现前后插⼊时间复杂度为O(1),保证了在前后留出了空间,所以 push 和 pop 都可以在前⾯的数组进⾏操作。

现在就来看 erase,因为 deque 是由数组构成,所以地址空间是连续的,删除也就像 vector⼀样,要移动所有的元素。

deque 为了保证效率尽可能的⾼,就判断删除的位置是中间偏后还是中间偏前来进⾏移动。

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
    ...
public:                         // Erase
  iterator erase(iterator pos) 
  {
    iterator next = pos;
    ++next;
    difference_type index = pos - start;
      // 删除的地⽅是中间偏前, 移动前⾯的元素
    if (index < (size() >> 1)) 
    {
      copy_backward(start, pos, next);
      pop_front();
    }
      // 删除的地⽅是中间偏后, 移动后⾯的元素
    else {
    copy(next, finish, pos);
      pop_back();
    }
    return start + index;
  }
  // 范围删除, 实际也是调⽤上⾯的erase函数.
  iterator erase(iterator first, iterator last);
  void clear(); 
    ...
};

最后讲⼀下 insert 函数

deque 源码的基本每⼀个insert 重载函数都会调⽤了 insert_auto 判断插⼊的位置离头还是尾⽐较近。 如果离头进:则先将头往前移动,调整将要移动的距离,⽤ copy 进⾏调整。 如果离尾近:则将尾往前移动,调整将要移动的距离,⽤ copy 进⾏调整。 注意 : push_back是先执⾏构造在移动 node, ⽽ push_front 是先移动 node 在进⾏构造. 实现的差异主要是finish是指向最后⼀个元素的后⼀个地址⽽first指向的就只第⼀个元素的地址. 下⾯ pop 也是⼀样的。

源码⾥还有⼀些其它的成员函数,限于篇幅,这⾥就不贴源码,简单的过⼀遍 还有⼀些函 数: reallocate_map:判断中控器的容量是否够⽤,如果不够⽤,申请更⼤的空间,拷⻉元素过去,修改 map 和 start,finish 的指向。

fill_initialize 函数::申请空间,对每个空间进⾏初始化,最后⼀个数组单独处理. 毕竟最后⼀个数组⼀般不是会全部填充满。

clear函数. 删除所有元素. 分两步执⾏:

⾸先从第⼆个数组开始到倒数第⼆个数组⼀次性全部删除,这样做是考虑到中间的数组肯定都是满的,前后两个数组就不⼀定是填充满的,最后删除前后两个数组的元素。

deque的swap操作也只是交换了start, finish, map, 并没有交换所有的元素.

resize函数. 重新将deque进⾏调整, 实现与list⼀样的.

析构函数: 分步释放内存.

deque 总结

deque 其实是在功能上合并了 vector 和 list。 优点:

1、随机访问⽅便,即⽀持 [ ] 操作符和 vector.at(); 2、在内部⽅便的进⾏插⼊和删除操作; 3、可在两端进⾏ push、pop

缺点:因为涉及⽐较复杂,采⽤分段连续空间,所以占⽤内存相对多。

使⽤区别: 1、如果你需要⾼效的随即存取,⽽不在乎插⼊和删除的效率,使⽤ vector。 2、如果你需要⼤量的插⼊和删除,⽽不关⼼随机存取,则应使⽤ list。 3、如果你需要随机存取,⽽且关⼼两端数据的插⼊和删除,则应使⽤ deque 。

15.6 以 deque 为底层容器的适配器

最后要介绍的三种常⽤的数据结构,准确来说其实是⼀种适配器,底层都是已其它容器为基准。

栈-stack:先⼊后出,只允许在栈顶添加和删除元素,称为出栈和⼊栈。

队列-queue:先⼊先出,在队⾸取元素,在队尾添加元素,称为出队和⼊队。

优先队列-priority_queue:带权值的队列。

常⻅栈的应⽤场景包括括号问题的求解,表达式的转换和求值,函数调⽤和递归实现,深度优先遍历DFS等;

常⻅的队列的应⽤场景包括计算机系统中各种资源的管理,消息缓冲队列的管理和⼴度优先遍历BFS等。

源码之前,了⽆秘密,翻⼀下源码,就知道 stack 和 queue 的底层其实就是使⽤ deque,⽤ deque 为底层容器封装。

stack 的源码:

#ifndef __STL_LIMITED_DEFAULT_TEMPLATES
template <class T, class Sequence = deque<T> >
#else
template <class T, class Sequence>
#endif
class stack {
public:
  typedef typename Sequence::value_type value_type;
  typedef typename Sequence::size_type size_type;
  typedef typename Sequence::reference reference;
  typedef typename Sequence::const_reference const_reference;
protected:
  Sequence c;

queue 的源码:

#ifndef __STL_LIMITED_DEFAULT_TEMPLATES
template <class T, class Sequence = deque<T> >
#else
template <class T, class Sequence>
#endif
class queue {
public:
  typedef typename Sequence::value_type value_type;
  typedef typename Sequence::size_type size_type;
  typedef typename Sequence::reference reference;
  typedef typename Sequence::const_reference const_reference;
protected:
  Sequence c;

heap 最后我们来看⼀下,heap ,heap 并不是⼀个容器, 所以他没有实现⾃⼰的迭代器, 也就没有 遍历操作, 它只是⼀种算法。

push_heap 插⼊元素 插⼊函数是push_heap. heap只接受RandomAccessIterator类型的迭代器.

template <class RandomAccessIterator>
inline void push_heap(RandomAccessIterator first, RandomAccessIterator 
last) {
  __push_heap_aux(first, last, distance_type(first), value_type(first));
}
 
template <class RandomAccessIterator, class Distance, class T>
inline void __push_heap_aux(RandomAccessIterator first, 
RandomAccessIterator last, Distance*, T*) {
    // 这⾥传⼊的是两个迭代器的⻓度, 0, 还有最后⼀个数据
  __push_heap(first, Distance((last - first) - 1), Distance(0),  T(*
(last - 1)));
}

pop_heap 删除元素

pop操作其实并没有真正意义去删除数据, ⽽是将数据放在最后, 只是没有指向最后的元素⽽已, 这⾥arrary也可以使⽤, 毕竟没有对数组的⼤⼩进⾏调整. pop的实现有两种, 这⾥都罗列了出来, 另⼀个传⼊的是 cmp 伪函数.

template <class RandomAccessIterator, class Compare>
inline void pop_heap(RandomAccessIterator first, RandomAccessIterator 
last,
                     Compare comp) {
    __pop_heap_aux(first, last, value_type(first), comp);
}
template <class RandomAccessIterator, class T, class Compare>
inline void __pop_heap_aux(RandomAccessIterator first,
                           RandomAccessIterator last, T*, Compare comp) 
{
  __pop_heap(first, last - 1, last - 1, T(*(last - 1)), comp,
  distance_type(first));
}
template <class RandomAccessIterator, class T, class Compare, class 
Distance>
inline void __pop_heap(RandomAccessIterator first, RandomAccessIterator 
last,
                       RandomAccessIterator result, T value, Compare 
comp,
                       Distance*) {
  *result = *first;
  __adjust_heap(first, Distance(0), Distance(last - first), value, 
comp);
}
template <class RandomAccessIterator, class T, class Distance>
inline void __pop_heap(RandomAccessIterator first, RandomAccessIterator 
last,
                       RandomAccessIterator result, T value, Distance*) 
{
  *result = *first; // 因为这⾥是⼤根堆, 所以first的值就是最⼤值, 先将最⼤值保
存.
  __adjust_heap(first, Distance(0), Distance(last - first), value);
}
  

make_heap 将数组变成堆存放

template <class RandomAccessIterator>
inline void make_heap(RandomAccessIterator first, RandomAccessIterator 
last) {
  __make_heap(first, last, value_type(first), distance_type(first));
}
template <class RandomAccessIterator, class T, class Distance>
void __make_heap(RandomAccessIterator first, RandomAccessIterator last, 
T*,
                 Distance*) {
  if (last - first < 2) return;
    // 计算⻓度, 并找出中间的根值
    Distance len = last - first;
  Distance parent = (len - 2)/2;
    
  while (true) {
      // ⼀个个进⾏调整, 放到后⾯
    __adjust_heap(first, parent, len, T(*(first + parent)));
    if (parent == 0) return;
    parent--;
  }
}

sort_heap 实现堆排序 其实就是每次将第⼀位数据弹出从⽽实现排序功能.

template <class RandomAccessIterator>
void sort_heap(RandomAccessIterator first, RandomAccessIterator last) {
  while (last - first > 1) pop_heap(first, last--);
}
template <class RandomAccessIterator, class Compare>
void sort_heap(RandomAccessIterator first, RandomAccessIterator last,
               Compare comp) {
  while (last - first > 1) pop_heap(first, last--, comp);
}

priority_queue

最后我们来看⼀下 priority_queue

上⼀节分析 heap 其实就是为 priority_queue 做准备. priority_queue 是⼀个优先级队列, 是带权值的. ⽀持插⼊和删除操作, 其只能从尾部插⼊,头部删除, 并且其顺序也并⾮是根据加⼊的顺序排列的。

priority_queue 因为也是队列的⼀种体现, 所以也就跟队列⼀样不能直接的遍历数组, 也就没有迭代器. priority_queue 本身也不算是⼀个容器, 它是以 vector 为容器以 heap为数据操作的配置器。

类型定义

#ifndef __STL_LIMITED_DEFAULT_TEMPLATES
template <class T, class Sequence = vector<T>, 
          class Compare = less<typename Sequence::value_type> >
#else
template <class T, class Sequence, class Compare>
#endif
class  priority_queue {
public:
  // 符合traits编程规范
  typedef typename Sequence::value_type value_type;
  typedef typename Sequence::size_type size_type;
  typedef typename Sequence::reference reference;
  typedef typename Sequence::const_reference const_reference;
protected:
  Sequence c; // 定义vector容器的对象
  Compare comp; // 定义⽐较函数(伪函数)
  ...
};

属性获取

priority_queue 只有简单的 3 个属性获取的函数, 其本身的操作也很简单, 只是实现依赖了 vector 和 heap 就变得⽐较复杂。

class  priority_queue {
  ...
public:
  bool empty() const { return c.empty(); }
  size_type size() const { return c.size(); }
  const_reference top() const { return c.front(); }
    ...
};

push 和 pop 实现

push 和 pop 具体都是采⽤的 heap 算法。

priority_queue 本身实现是很复杂的,但是当我们已经了解过 vector,heap 之后再来看,它 其实就简单了。

就是将 vector 作为容器, heap 作为算法来操作的配置器,这也体现了 STL 的灵活性: 通过 各个容器与算法的结合就能实现另⼀种功能。

最后,来⾃实践⽣产环境的⼀个体会:上⾯所列的所有容器的⼀个原则:为了避免拷⻉开销, 不要直接把⼤的对象直接往⾥塞,⽽是使⽤指针。

⼗六、 2 万字 10 图带你⼿撕 STL 关联

16.1 前⾔

STL 源码剖析系列已经出了三篇:

上⼀篇,我们剖析了序列式容器,这⼀篇我们来学习下关联式容器。

在 STL 编程中,容器是我们经常会⽤到的⼀种数据结构,容器分为序列式容器和关联式容 器。

两者的本质区别在于:序列式容器是通过元素在容器中的位置顺序存储和访问元素,⽽关联容 器则是通过键 (key) 存储和读取元素。

本篇着重剖析关联式容器相关背后的知识点,来⼀张思维导图

16.2 容器分类

前⾯提到了,根据元素存储⽅式的不同,容器可分为序列式和关联式,那具体的⼜有哪些分类 呢,这⾥我画了⼀张图来看⼀下。

关联式容器⽐序列式容器更好理解,从底层实现来分的话,可以分为 RB_tree 还是 hash_table,所有暴露给⽤户使⽤的关联式容器都绕不过底层这两种实现。

不多 BB。我们先来分析其底层的两种实现,后⾯在逐个⼀⼀剖析其外在形式,这样对于新⼿还是⽼⼿,对于其背后核⼼的设计和奥秘,理解起来都会丝滑顺畅。

16.3 RB-tree 介绍与应⽤

⾸先来介绍红⿊树,RB Tree 全称是 Red-Black Tree,⼜称为“红⿊树”,它⼀种特殊的⼆叉查找树。红⿊树的每个节点上都有存储位表示节点的颜⾊,可以是红 (Red) 或⿊ (Black)。

红⿊树的特性:

每个节点或者是⿊⾊,或者是红⾊。

根节点是⿊⾊。

每个叶⼦节点(NIL)是⿊⾊。 [注意:这⾥叶⼦节点,是指为空(NIL或NULL)的叶⼦节点!]

如果⼀个节点是红⾊的,则它的⼦节点必须是⿊⾊的。

从⼀个节点到该节点的⼦孙节点的所有路径上包含相同数⽬的⿊节点。

注意:

特性 (3)中的叶⼦节点,是只为空(NIL或null)的节点。 特性 (5)确保没有⼀条路径会⽐其他路径⻓出俩倍。因⽽,红⿊树是相对是接近衡的⼆叉 树。

红⿊树示意图如下:

红⿊树保证了最坏情形下在 O(logn) 时间复杂度内完成查找、插⼊及删除操作;效率⾮常之⾼。 因此红⿊树可⽤于很多场景,⽐如下图。

好了,红⿊树介绍到这⾥差不多了,关于红⿊树的分析在深⼊⼜是另⼀篇⽂章了,下⾯我们在 简单介绍⼀下红⿊树的两种数据操作⽅式。

16.4 RB-tree 的基本操作

红⿊树的基本操作包括 添加、删除。

在对红⿊树进⾏添加或删除之后,都会⽤到旋转⽅法。原因在于添加或删除红⿊树中的节点之后,红⿊树就发⽣了变化,可能不满⾜红⿊树的 5 条性质,也就说不再是⼀颗红⿊树了,⽽是⼀颗普通的树。

⽽通过旋转,可以使这颗树重新成为红⿊树。简单点说,旋转的⽬的是让树保持红⿊树的特性。

在红⿊树⾥的旋转包括两种:左旋和右旋。 左旋: 对节点 X 进⾏左旋,也就说让节点 X 成为左节点。 右旋: 对节点 X 进⾏右旋,也就说让节点 X 成为右节点。

说完了旋转,我们再来看⼀下它的插⼊,有两种插⼊⽅式:

//不允许键值重复插⼊
pair<iterator, bool> insert_unique(const value_type& x);
 
//允许键值重复插⼊
iterator insert_equal(const value_type& x);

RB-tree ⾥⾯分两种插⼊⽅式,⼀种是允许键值重复插⼊,⼀种不允许。可以简单的理解,如果调⽤ insert_unique 插⼊重复的元素,在 RB-tree ⾥⾯其实是⽆效的。其实在 RB-tree 源码⾥⾯,上⾯两个函数⾛到最底层,调⽤的是同⼀个 __insert() 函数。

知道了数据的操作⽅式,我们再来看 RB-tree 的构造⽅式:内部调⽤ rb_tree_node_allocator ,每次恰恰配置⼀个节点,会调⽤ simple_alloc 空间配置器来配置节点。

并且分别调⽤四个节点函数来进⾏初始化和构造化。

get_node(), put_node(), create_node(), clone_node(), destroy_node();

RB-tree 的构造⽅式也有两种:⼀种是以现有的 RB-tree 复制⼀个新的 RB-tree,另⼀种是产⽣⼀棵空的树。

16.5 哈希表(hashtable)介绍和应⽤

红⿊树的介绍就到这⾥了,下⾯我们来看⼀下哈希表。

我们知道数组的特点是:寻址容易,插⼊和删除困难;⽽链表的特点是:寻址困难,插⼊和删 除容易。

那么我们能不能综合两者的特性,做出⼀种寻址容易,插⼊删除也容易的数据结构? 答案是肯定的,这就是哈希表。哈希表,也被称为散列表,是⼀种常⽤的数据结构,这种结构在插⼊、删除、查找等操作上也 具有”常数平均时间“的表现。也可以视为⼀种字典结构。

在讲具体的 hashtable 源码之前,我们先来认识两个概念:

散列函数:使⽤某种映射函数,将⼤数映射为⼩数。负责将某⼀个元素映射为⼀个”⼤⼩可接受内的索引“,这样的函数称为 hash function(散列函数)。

使⽤散列函数可能会带来问题:可能会有不同的元素被映射到相同的位置,这⽆法避免,因为元素个数有可能⼤于分配的 array 容量,这就是所谓的碰撞问题,解决碰撞问题⼀般有:线性探测、⼆次探测、开链等。

不同的⽅法有不同的效率差别,本⽂以 SGI STL 源码⾥采⽤的开链法来进⾏ hashtable 的学习。

拉链法,可以理解为“链表的数组”,其思路是:如果多个关键字映射到了哈希表的同⼀个位置处,则将这些关键字记录在同⼀个线性链表中,如果有重复的,就顺序拉在这条链表的后⾯。

注意,bucket 维护的链表,并不采⽤ STL 的 list ,⽽是⾃⼰维护的 hash table node,⾄于 buckets 表格,则是以 vector 构造完成,以便具有动态扩充能⼒。

hash table 的定义:

//模板参数定义
/* 
Value: 节点的实值类型 
Key:   节点的键值类型 
HashFcn: hash function的类型 
ExtractKey:从节点中取出键值的⽅法(函数或仿函数) 
EqualKey:判断键值是否相同的⽅法(函数或仿函数) 
Alloc:空间配置器
*/ 
//hash table的线性表是⽤ vector 容器维护
template <class _Val, class _Key, class _HashFcn,
          class _ExtractKey, class _EqualKey, class _Alloc>
class hashtable {
public:
  typedef _Key key_type;
  typedef _Val value_type;
  typedef _HashFcn hasher;
  typedef _EqualKey key_equal;
 
  typedef size_t            size_type;
  typedef ptrdiff_t         difference_type;
  typedef value_type*       pointer;
  typedef const value_type* const_pointer;
  typedef value_type&       reference;
  typedef const value_type& const_reference;
 
  hasher hash_funct() const { return _M_hash; }
  key_equal key_eq() const { return _M_equals; }
 
private:
  typedef _Hashtable_node<_Val> _Node;

这⾥需要注意的是,hashtable 的迭代器是正向迭代器,且必须维持这整个 buckets vector 的关系,并记录⽬前所指的节点。其前进操作是⽬前所指的节点,前进⼀个位置。

//以下是hash table的成员变量
private:
  hasher                _M_hash;
  key_equal             _M_equals;
  _ExtractKey           _M_get_key;
  vector<_Node*,_Alloc> _M_buckets;//⽤vector维护buckets
  size_type             _M_num_elements;//hashtable中list节点个数
 
public:
  typedef 
_Hashtable_iterator<_Val,_Key,_HashFcn,_ExtractKey,_EqualKey,_Alloc>
   iterator;
  typedef 
_Hashtable_const_iterator<_Val,_Key,_HashFcn,_ExtractKey,_EqualKey,
                                    _Alloc>
  const_iterator;
 
public:
  //构造函数
  hashtable(size_type __n,
            const _HashFcn&    __hf,
            const _EqualKey&   __eql,
            const _ExtractKey& __ext,
            const allocator_type& __a = allocator_type())
    : __HASH_ALLOC_INIT(__a)
      _M_hash(__hf),
      _M_equals(__eql),
      _M_get_key(__ext),
      _M_buckets(__a),
      _M_num_elements(0)
  {
    _M_initialize_buckets(__n);//预留空间,并将其初始化为空0
  //预留空间⼤⼩为⼤于n的最⼩素数
  }

提供两种插⼊元素的⽅法:insert_equal允许重复插⼊;insert_unique不允许重复插⼊。

//插⼊元素节点,不允许存在重复元素
  pair<iterator, bool> insert_unique(const value_type& __obj) {
    //判断容量是否够⽤, 否则就重新配置 
  resize(_M_num_elements + 1);
   //插⼊元素,不允许存在重复元素
    return insert_unique_noresize(__obj);
  }
  //插⼊元素节点,允许存在重复元素
  iterator insert_equal(const value_type& __obj)
  {//判断容量是否够⽤, 否则就重新配置
    resize(_M_num_elements + 1);
  //插⼊元素,允许存在重复元素
    return insert_equal_noresize(__obj);
  }

16.6 hashtable 的基本操作

后⾯⻢上要介绍的关联容器 set、multiset、map 和 multimap 的底层机制都是基于 RB-Tree 红⿊树,虽然能够实现在插⼊、删除和搜素操作能够达到对数平均时间,可是要求输⼊数据有⾜够的随机性。

⽽ hash table 不需要要求输⼊数据具有随机性,在插⼊、删除和搜素操作都能达到常数平均时间。

SGI 中实现 hash table 的⽅式,是在每个 buckets 表格元素中维护⼀个链表, 然后在链表上执⾏元素的插⼊、搜寻、删除等操作,该表格中的每个元素被称为桶 (bucket)。

虽然开链法并不要求表格⼤⼩为质数,但 SGI STL 仍然已质数来设计表格⼤⼩,并且将 28 个质数计算好,以备随时访问。

// Note: assumes long is at least 32 bits.
// 注意:假设long⾄少为32-bits, 可以根据⾃⼰需要修改
//定义28个素数⽤作hashtable的⼤⼩ 
enum { __stl_num_primes = 28 };
 
static const unsigned long __stl_prime_list[__stl_num_primes] = {
  53ul,         97ul,         193ul,       389ul,       769ul,
  1543ul,       3079ul,       6151ul,      12289ul,     24593ul,
  49157ul,      98317ul,      196613ul,    393241ul,    786433ul,
  1572869ul,    3145739ul,    6291469ul,   12582917ul,  25165843ul,
  50331653ul,   100663319ul,  201326611ul, 402653189ul, 805306457ul, 
  1610612741ul, 3221225473ul, 4294967291ul
};
 
//返回⼤于n的最⼩素数
inline unsigned long __stl_next_prime(unsigned long __n) {
  const unsigned long* __first = __stl_prime_list;
  const unsigned long* __last = __stl_prime_list + 
(int)__stl_num_primes;
  const unsigned long* pos = lower_bound(__first, __last, __n);

hashtable的节点配置和释放分别由 new_node 和 delete_node 来完成,并且插⼊操作和表格重整分别由 insert_unique 和 insert_equal ,resize 三个函数来完成。限于篇幅,这⾥⽤三张图来展示:

C++ STL 标准库中,不仅是 unordered_xxx 容器,所有⽆序容器的底层实现都采⽤的是哈希表存储结构。更准确地说,是⽤“链地址法”(⼜称“开链法”)解决数据存储位置发⽣冲突的哈希表,整个存储结构如图所示。

其中,Pi 表示存储的各个键值对。

最左边的绿⾊称之为 bucket 桶,可以看到,当使⽤⽆序容器存储键值对时,会先申请⼀整块连续的存储空间,但此空间并不⽤来直接存储键值对,⽽是存储各个链表的头指针,各键值对真正的存储位置是各个链表的节点。

在 C++ STL 标准库中,将图 1 中的各个链表称为桶(bucket),每个桶都有⾃⼰的编号(从 0 开始)。当有新键值对存储到⽆序容器中时,整个存储过程分为如下⼏步:

将该键值对中键的值带⼊设计好的哈希函数,会得到⼀个哈希值(⼀个整数,⽤ H 表示);

将 H 和⽆序容器拥有桶的数量 n 做整除运算(即 H % n),该结果即表示应将此键值对存储到的桶的编号;

建⽴⼀个新节点存储此键值对,同时将该节点链接到相应编号的桶上。

另外值得⼀提的是,哈希表存储结构还有⼀个重要的属性,称为负载因⼦(load factor)。

该属性同样适⽤于⽆序容器,⽤于衡量容器存储键值对的空/满程序,即负载因⼦越⼤,意味着容器越满,即各链表中挂载着越多的键值对,这⽆疑会降低容器查找⽬标键值对的效率;反之,负载因⼦越⼩,容器肯定越空,但并不⼀定各个链表中挂载的键值对就越少。

举个例⼦,如果设计的哈希函数不合理,使得各个键值对的键带⼊该函数得到的哈希值始终相同(所有键值对始终存储在同⼀链表上)。这种情况下,即便增加桶数是的负载因⼦减⼩,该容器的查找效率依旧很差。

⽆序容器中,负载因⼦的计算⽅法为:负载因⼦ = 容器存储的总键值对 / 桶数

默认情况下,⽆序容器的最⼤负载因⼦为 1.0。如果操作⽆序容器过程中,使得最⼤复杂因⼦超过了默认值,则容器会⾃动增加桶数,并重新进⾏哈希,以此来减⼩负载因⼦的值。

需要注意的是,此过程会导致容器迭代器失效,但指向单个键值对的引⽤或者指针仍然有效。

这也就解释了,为什么我们在操作⽆序容器过程中,键值对的存储顺序有时会“莫名”的发⽣变动。 C++ STL 标准库为了⽅便⽤户更好地管控⽆序容器底层使⽤的哈希表存储结构,各个⽆序容 器的模板类中都提供表 所示的成员⽅法。

成员⽅法
功能

bucket_count()

返回当前容器底层存储键值对时,使⽤桶的数量

max_bucket_count()

返回当前系统中,unordered_xxx 容器底层最多可以使⽤多少个桶

bucket_size(n)

返回第 n 个桶中存储键值对的数量

bucket(key)

返回以 key 为键的键值对所在桶的编号

load_factor()

返回 unordered_map 容器中当前的负载因⼦

max_load_factor()

返回或者设置当前 unordered_map 容器的最⼤负载因⼦

rehash(n) 。

尝试重新调整桶的数量为等于或⼤于 n 的值。如果 n ⼤于当前容器使⽤的桶数,则该⽅法会是容器重新哈希,该容器新的桶数将等于或⼤于 n。反之,如果 n 的值⼩于当前容器使⽤的桶数,则调⽤此⽅法可能没有任何作⽤

reserve(n)

将容器使⽤的桶数(bucket_count() ⽅法的返回值)设置为最适合存储 n 个元素的桶

hash_function()

返回当前容器使⽤的哈希函数对象

16.7 set、 multiset、 unordered_set、

有了前⾯的 RB_tree 做铺垫,下⾯来学习 set/multiset 和 map/multimap 就容易多了。 先来看⼀下 set 的性质

set 以 RB-tree 作为其底层机制,所有元素都会根据元素的键值⾃动被排序。

set 的元素就是键值,set 不允许两个元素有相同的键值。

不允许通过 set 的迭代器来改变 set 的元素值,因为 set 的元素值就是键值,更改了元素值就会影响其排列规则,如果任意更改元素值,会严重破坏 set 组织,因此在定义 set 的迭代器时被定义成了 RB-tree 的 const_iterator。

由于 set 不允许有两个相同的键值,所以插⼊时采⽤的是 RB-tree 的 insert_unique ⽅式这⾥的类型的定义要注意⼀点, 都是 const 类型, 因为 set 的主键定义后就不能被修改了, 所以这⾥都是以const类型。

下⾯来看⼀下 set 的源码 set 的主要实现⼤都是调⽤ RB-tree 的接⼝,这⾥的类型的定义要注意⼀点, 都是 const 类型, 因为 set 的主键定义后就不能被修改了,所以这⾥都是以 const 类型。

#ifndef __STL_LIMITED_DEFAULT_TEMPLATES
template <class Key, class Compare = less<Key>, class Alloc = alloc>
#else
template <class Key, class Compare, class Alloc = alloc>
#endif
class set {
public:
  // typedefs:
  typedef Key key_type;
  typedef Key value_type;
  typedef Compare key_compare;
  typedef Compare value_compare;
private:
  // ⼀RB-tree为接⼝封装
  typedef rb_tree<key_type, value_type, identity<value_type>, 
key_compare, Alloc> rep_type; 
  rep_type t;  // red-black tree representing set
public:
  // 定义的类型都是const类型, 不能修改
  typedef typename rep_type::const_pointer pointer;
  typedef typename rep_type::const_pointer const_pointer;
  typedef typename rep_type::const_reference reference;
  typedef typename rep_type::const_reference const_reference;
  typedef typename rep_type::const_iterator iterator;
  typedef typename rep_type::const_iterator const_iterator;
  typedef typename rep_type::const_reverse_iterator reverse_iterator;
  typedef typename rep_type::const_reverse_iterator 
const_reverse_iterator;
  typedef typename rep_type::size_type size_type;
  typedef typename rep_type::difference_type difference_type;
  ...
};

构造函数构造成员的时候调⽤的是 RB-tree 的 insert_unique。

class set {
public:
    ...
  set() : t(Compare()) {}
  explicit set(const Compare& comp) : t(comp) {}  // 不能隐式转换
 
    // 接受两个迭代器
    // 构造函数构造成员的时候调⽤的是RB-tree的insert_unique
  template <class InputIterator>
  set(InputIterator first, InputIterator last)
    : t(Compare()) { t.insert_unique(first, last); }
  template <class InputIterator>
  set(InputIterator first, InputIterator last, const Compare& comp)
    : t(comp) { t.insert_unique(first, last); }
 
  set(const value_type* first, const value_type* last) 
    : t(Compare()) { t.insert_unique(first, last); }
  set(const value_type* first, const value_type* last, const Compare& 
comp)
    : t(comp) { t.insert_unique(first, last); }
 
  set(const_iterator first, const_iterator last)
    : t(Compare()) { t.insert_unique(first, last); }
  set(const_iterator first, const_iterator last, const Compare& comp)
    : t(comp) { t.insert_unique(first, last); }
     ...
};

成员属性获取

class set {
public:
    ...
  // 所有的操作都是通过调⽤RB-tree获取的
  key_compare key_comp() const { return t.key_comp(); }
  value_compare value_comp() const { return t.key_comp(); }
  iterator begin() const { return t.begin(); }
  iterator end() const { return t.end(); }
  reverse_iterator rbegin() const { return t.rbegin(); } 
  reverse_iterator rend() const { return t.rend(); }
  bool empty() const { return t.empty(); }
  size_type size() const { return t.size(); }
  size_type max_size() const { return t.max_size(); }
    // 交换
  void swap(set<Key, Compare, Alloc>& x) { t.swap(x.t); }
    // 其他的find, count等都是直接调⽤的RB-tree的接⼝
  iterator find(const key_type& x) const { return t.find(x); }
  size_type count(const key_type& x) const { return t.count(x); }
  iterator lower_bound(const key_type& x) const {
    return t.lower_bound(x);
  }
  iterator upper_bound(const key_type& x) const {
    return t.upper_bound(x); 
  }
  pair<iterator,iterator> equal_range(const key_type& x) const {
    return t.equal_range(x);
  }
    ...
};

insert 操作源码

class set {
public:
    ...
    // pair类型我们准备下⼀节分析, 这⾥是直接调⽤insert_unique, 返回插⼊成功就是
pair( , true), 插⼊失败则是( , false)
  typedef  pair<iterator, bool> pair_iterator_bool; 
  pair<iterator,bool> insert(const value_type& x) { 
    pair<typename rep_type::iterator, bool> p = t.insert_unique(x); 
    return pair<iterator, bool>(p.first, p.second);
  }
    // 指定位置的插⼊
  iterator insert(iterator position, const value_type& x) {
    typedef typename rep_type::iterator rep_iterator;
    return t.insert_unique((rep_iterator&)position, x);
  }
    // 可接受范围插⼊
  template <class InputIterator>
  void insert(InputIterator first, InputIterator last) {
    t.insert_unique(first, last);
  }
    ...
};

erase 的实现是通过调⽤ RB-tree 实现的 erase.

class set {
public:
    ...
 // erase的实现是通过调⽤RB-tree实现的erase
  void erase(iterator position) { 
    typedef typename rep_type::iterator rep_iterator;
    t.erase((rep_iterator&)position); 
  }
  size_type erase(const key_type& x) { 
    return t.erase(x); 
  }
  void erase(iterator first, iterator last) { 
    typedef typename rep_type::iterator rep_iterator;
    t.erase((rep_iterator&)first, (rep_iterator&)last); 
  }
  void clear() { t.clear(); }
  ...
};

最后剩下⼀个重载运算符,也是以 RB-tree 为接⼝调⽤。

到这⾥,set ⼤部分的源码都已经过了⼀遍。

multiset 与 set 特性完全相同,唯⼀差别在于它允许键值重复,因此插⼊操作采⽤的是底层机制 RB-tree 的 insert_equal() ⽽⾮ insert_unique()。

接下来我们来了解⼀下两个新的数据结构:hash_set 与 unordered_set。 它们都属于基于哈希表(hash table)构建的数据结构,并且是关键字与键值相等的关联容器。那 hash_set 与 unordered_set 哪个更好呢?实际上 unordered_set 在C++11的时候被引⼊标准库了,⽽ hash_set 并没有,所以建议还是使⽤ unordered_set ⽐较好,这就好⽐⼀个是官⽅认证的,⼀个是⺠间流传的。

在 SGI STL 源码剖析⾥,是以 hash_set 剖析的。

hash_set 将哈希表的接⼝在进⾏了⼀次封装, 实现与 set 类似的功能.

#ifndef __STL_LIMITED_DEFAULT_TEMPLATES
template <class Value, class HashFcn = hash<Value>,
          class EqualKey = equal_to<Value>,
          class Alloc = alloc>
#else
template <class Value, class HashFcn, class EqualKey, class Alloc = 
alloc>
#endif
class hash_set {
private:
    // 定义hashtable
  typedef hashtable<Value, Value, HashFcn, identity<Value>,  EqualKey, 
Alloc> ht;
  ht rep;
 
public:
  typedef typename ht::key_type key_type;
  typedef typename ht::value_type value_type;
  typedef typename ht::hasher hasher;
  typedef typename ht::key_equal key_equal;
 
    // 定义为const类型, 键值不允许修改
  typedef typename ht::size_type size_type;
  typedef typename ht::difference_type difference_type;
  typedef typename ht::const_pointer pointer;
  typedef typename ht::const_pointer const_pointer;
  typedef typename ht::const_reference reference;
  typedef typename ht::const_reference const_reference;
 
    // 定义迭代器
  typedef typename ht::const_iterator iterator;
  typedef typename ht::const_iterator const_iterator;
    // 仿函数
  hasher hash_funct() const { return rep.hash_funct(); }
  key_equal key_eq() const { return rep.key_eq(); }
    ...
};

构造函数

class hash_set
{
...
public:
  hash_set() : rep(100, hasher(), key_equal()) {} // 默认构造函数, 表⼤⼩默
认为100最近的素数
  explicit hash_set(size_type n) : rep(n, hasher(), key_equal()) {}
  hash_set(size_type n, const hasher& hf) : rep(n, hf, key_equal()) {}
  hash_set(size_type n, const hasher& hf, const key_equal& eql)
    : rep(n, hf, eql) {}
 
#ifdef __STL_MEMBER_TEMPLATES
  template <class InputIterator>
  hash_set(InputIterator f, InputIterator l)
    : rep(100, hasher(), key_equal()) { rep.insert_unique(f, l); }
  template <class InputIterator>
  hash_set(InputIterator f, InputIterator l, size_type n)
    : rep(n, hasher(), key_equal()) { rep.insert_unique(f, l); }
  template <class InputIterator>
  hash_set(InputIterator f, InputIterator l, size_type n,
           const hasher& hf)
    : rep(n, hf, key_equal()) { rep.insert_unique(f, l); }
  template <class InputIterator>
  hash_set(InputIterator f, InputIterator l, size_type n,
           const hasher& hf, const key_equal& eql)
    : rep(n, hf, eql) { rep.insert_unique(f, l); }
 ...
};

插⼊删除等操作

insert调⽤的是insert_unqiue函数

class hash_set
{
    ...
public:
    // 都是调⽤hashtable的接⼝, 这⾥insert_unqiue函数
  pair<iterator, bool> insert(const value_type& obj)
    {
      pair<typename ht::iterator, bool> p = rep.insert_unique(obj);
      return pair<iterator, bool>(p.first, p.second);
    }

set、multiset、unordered_set、unordered_multiset 总结

性质

set

multiset

unordered_set

unordered_multiset

底层实现

红⿊树

红⿊树

哈希表

哈希表

键值重复

不允许

允许

不允许

允许

插⼊元素

insert_unique

insert_equal

insert_unique

insert_equal

元素有序

有序 ****

有序

⽆序

⽆序

是否⽀持[]运算符

不⽀持

不⽀持

不⽀持

不⽀持

迭代器性质

const_iterator

const_iterator

const_iterator

const_iterator

16.8 map、multimap、unordered_map、unordered_multimap

在分析 map 之前,我们来分析⼀下 pair 这种结构。 pair 是⼀个有两个变量的结构体, 即谁都可以直接调⽤它的变量, 毕竟 struct 默认权限都是 public, 将两个变量⽤ pair 绑定在⼀起, 这就为 map<T1, T2> 提供的存储的基础.

template <class T1, class T2> // 两个参数类型
struct pair {
  typedef T1 first_type;
  typedef T2 second_type;
  // 定义的两个变量
  T1 first; 
  T2 second;
    
    // 构造函数
  pair() : first(T1()), second(T2()) {}
  pair(const T1& a, const T2& b) : first(a), second(b) {}
#ifdef __STL_MEMBER_TEMPLATES
  template <class U1, class U2>
  pair(const pair<U1, U2>& p) : first(p.first), second(p.second) {}
#endif
};

重载实现:

template <class T1, class T2>
inline bool operator==(const pair<T1, T2>& x, const pair<T1, T2>& y) { 
  return x.first == y.first && x.second == y.second; 
}
template <class T1, class T2>
inline bool operator<(const pair<T1, T2>& x, const pair<T1, T2>& y) { 
  return x.first < y.first || (!(y.first < x.first) && x.second < 
y.second); 
}

整体 pair 的功能与实现都是很简单的,这都是为 map 的实现做准备的,接下来我们就来分析 map 的实现。

map 基本结构定义

#ifndef __STL_LIMITED_DEFAULT_TEMPLATES
template <class Key, class T, class Compare = less<Key>, class Alloc = 
alloc>
#else
template <class Key, class T, class Compare, class Alloc = alloc>
#endif
class map {
public:
  typedef Key key_type; // 定义键值
  typedef T data_type;  // 定义数据
  typedef T mapped_type;
  typedef pair<const Key, T> value_type; // 这⾥定义了map的数据类型为pair, 
且键值为const类型, 不能修改
  typedef Compare key_compare;
    
private:
  typedef rb_tree<key_type, value_type, 
                  select1st<value_type>, key_compare, Alloc> rep_type;  
// 定义红⿊树, map是以rb-tree结构为基础的
  rep_type t;  // red-black tree representing map 
public:
...

构造函数:map 所有插⼊操作都是调⽤的 RB-tree 的 insert_unique,不允许出现重复的键

class map {
public:
  ...
public:
  // allocation/deallocation
  map() : t(Compare()) {} // 默认构造函数
  explicit map(const Compare& comp) : t(comp) {}
#ifdef __STL_MEMBER_TEMPLATES
    // 接受两个迭代器
  template <class InputIterator>
  map(InputIterator first, InputIterator last)
    : t(Compare()) { t.insert_unique(first, last); }
  template <class InputIterator>
  map(InputIterator first, InputIterator last, const Compare& comp)
    : t(comp) { t.insert_unique(first, last); }
    ...

基本属性的获取

class map {
public:
  ...
public:
    // 实际调⽤的是RB-tree的key_comp函数
  key_compare key_comp() const { return t.key_comp(); }
    // value_comp实际返回的是⼀个仿函数value_compare
  value_compare value_comp() const { return value_compare(t.key_comp()); 
}
    // 以下的begin, end等操作都是调⽤的是RB-tree的接⼝
  iterator begin() { return t.begin(); }
  const_iterator begin() const { return t.begin(); }
  iterator end() { return t.end(); }
  const_iterator end() const { return t.end(); }
  reverse_iterator rbegin() { return t.rbegin(); }
  const_reverse_iterator rbegin() const { return t.rbegin(); }
  reverse_iterator rend() { return t.rend(); }
  const_reverse_iterator rend() const { return t.rend(); }
  bool empty() const { return t.empty(); }
  size_type size() const { return t.size(); }
  size_type max_size() const { return t.max_size(); }
    // 交换, 调⽤RB-tree的swap, 实际只交换head和count
  void swap(map<Key, T, Compare, Alloc>& x) { t.swap(x.t); }
    ...
};
template <class Key, class T, class Compare, class Alloc>
inline void swap(map<Key, T, Compare, Alloc>& x, 
                 map<Key, T, Compare, Alloc>& y) {
  x.swap(y);
}

重载的分析

class map {
public:
  ...
public:
  T& operator[](const key_type& k) {
    return (*((insert(value_type(k, T()))).first)).second;
  }
    ...
};

insert(value_type(k, T()) : 查找是否存在该键值, 如果存在则返回该pair, 不存在这重新构造⼀该键值并且值为空

*((insert(value_type(k, T()))).first) : pair的第⼀个元素表示指向该元素的迭代器, 第⼆个元素指的是(false与true)是否存在, first 便是取出该迭代器⽽ * 取出pair.

(*((insert(value_type(k, T()))).first)).second : 取出pair结构中的second保存的数据

这⾥有坑,初学者容易掉进去,请注意: 重载 operator[],这⼀步返回是实值 value(即pair.second)的引⽤,假如原先没有定义 map 对象,即你访问的键值 key 不存在,则会⾃动新建⼀个 map 对象,键值 key 为你访问的键值 key,实值 value 为空,看下⾯的例⼦就明⽩了。

我在⾃⼰的开发机上测试,int 类型默认 value 为 0,bool 类型默认 value 为 false,string 类型默认是空。

_Tp& operator[](const key_type& __k) {
    iterator __i = lower_bound(__k);
    // __i->first is greater than or equivalent to __k.
    if (__i == end() || key_comp()(__k, (*__i).first))
      __i = insert(__i, value_type(__k, _Tp()));
    return (*__i).second;
  //其实简单的⽅式是直接返回
  //return (*((insert(value_type(k, T()))).first)).second;
  }

map 的其他 insert, erase, find 都是直接调⽤ RB-tree 的接⼝函数实现的, 这⾥就不直接做分析了。

16.9 map、 multimap、unordered_map、unordered_multimap 总结

map 和 multimap 的共同点:

两者底层实现均为红⿊树,不可以通过迭代器修改元素的键,但是可以修改元素的值;拥有和 list 某些相同的特性,进⾏元素的新增和删除后,操做前的迭代器依然可⽤;

不同点: map 键不能重复,⽀持 [] 运算符;

multimap ⽀持重复的键,不⽀持 [] 运算符;

map 并不像 set ⼀样将 iterator 设为 RB-tree 的 const_iterator,因为它允许⽤户通过其迭代器修改元素的实值。

map 和 unordered_map 共同点:

两者均不能有重复的建,均⽀持[]运算符

不同点:

map 底层实现为红⿊树

unordered_map 底层实现为哈希表 unordered_map 是不允许存在相同的键存在,底层调⽤的 insert_unique() 插⼊元素

unordered_multimap 可以允许存在多个相同的键,底层调⽤的 insert_equal() 插⼊元素 map 并不像 set ⼀样将 iterator 设为 RB-tree 的 const_iterator,因为它允许⽤户通过其迭代器修改元素的实值。

性质

map

multimap

unordered_map

unordered_multimap

底层实现

红⿊树

红⿊树哈希表

哈希表

哈希表

键值重复

不允许

允许

不允许

允许

插⼊元素

insert_unique

insert_equal

insert_unique

insert_equal

元素有序

有序

有序

⽆序

⽆序

是否⽀持[]运算符

⽀持

不⽀持

⽀持

不⽀持

迭代器性质

⾮ const_iterator

⾮ const_iterator

⾮ const_iterator

⾮ const_iterator

是否能修改元素值

不能修改key,可以修改value

不能修改key,可以修改value

不能修改key,可以修改value

不能修改key,可以修改value

16.10 思考

为什么 std::set 不⽀持[]运算符?

对于 std::map ⽽⾔,我们看⼀个例⼦:

std::map<std::string,int> m = { {"a",1}, {"b", 2 } };

m["a"] 返回的是1所在单元的引⽤。

⽽如果对于 std::set std::string s = { "a", "b" }; ⽽⾔ s["a"] 应该是个什么类型呢?

std::map std::string,int m = { {"a",1}, {"b", 2 } };

我们⽤索引取⼀个容器的元素 a[key] = value 的前提是既有 key ⼜有 value。

set 只有 key 没有 value,加了[]会导致歧义。

⼗七、万字⻓⽂+ STL 算法总结

17.1 前⾔

上⼀篇更新了 STL 关联式容器源码,今天我们来学习下 STL 算法。

STL 算法博⼤精深,涵盖范围之⼴,其算法之⼤观,细节之深⼊,泛型思维之于字⾥⾏间,每 每阅读都会有不同的收获。

STL 将很多常⻅的逻辑都封装为现成的算法,熟悉这些算法的使⽤和实现很多时候可以⼤⼤简 化编程。

并且在需要的时候能够对 STL 进⾏扩展,将⾃定义的容器和算法融⼊到 STL 中。

侯捷⼤师在书中说到:深⼊源码之前,先观察每⼀个算法的表现和⼤观,是⼀个⽐较好的学习 ⽅式。

不多 BB,先上思维导图:

17.2 回顾

STL 源码剖析系列:

5 千字⻓⽂+ 30 张图解 | 陪你⼿撕 STL 空间配置器源码

万字⻓⽂炸裂!⼿撕 STL 迭代器源码与 traits 编程技法

超硬核 | 2 万字+20 图带你⼿撕 STL 序列式容器源码

硬核来袭 | 2 万字 + 10 图带你⼿撕 STL 关联式容器源码

17.3 基本算法

在 STL 标准规格中,并没有区分基本算法或复杂算法,然⽽ SGI 却把常⽤的⼀些算法定义于 <stl_algobase.h>之中,其它算法定义于 <stl_algo.h>中。

常⻅的基本算法有 equal、 fill、 fill_n、 iter_swap、 lexicographical_compare、 max、 min、 mismatch、 swap、 copy、 copy_backward 等。

17.4 质变算法和⾮质变算法

所有的 STL 算法归根到底,都可以分为两类。

所谓“质变算法”是指作⽤在由迭代器[first,last]所标示出来的区间,上运算过程中会更改区间内的元素内容:

⽐如拷⻉(copy)、互换(swap)、替换(replace)、填写(fill)、删除(remove)、排列组合 (permutation)、分割(partition)。随机⃞排(random shuffling)、排序(sort)等算法,都属于这⼀类。

⽽⾮质变算法是指在运算过程中不会更改区间内的元素内容。⽐如查找(find),匹配(search)、 计数(count)、遍历(for_each)、⽐较(equal_mismatch)、寻找极值(max,min)等算法。

17.5 输⼊参数

所有泛型算法的前两个参数都是⼀对迭代器,通过称为first, last。⽤来标示算法的操作区间。

每⼀个 STL 算法的声明,都表现出它所需要的最低程度的迭代器类型。⽐如find() 需要⼀个 inputiterator ,这是它的最低要求,但同时也可以接受更⾼类型的迭代器。

如 Forwarditerator、 Bidirectionaliterator 或 RandomAcessIterator,因为,前者都可以看做 是⼀个 inputiterator,⽽如果你给 find() 传⼊⼀个 Outputiterator,会导致错误。

将⽆效的迭代器传给某个算法,虽然是⼀种错误,但不保证能够在编译器期间就被捕捉出来。 因为所谓“迭代器类型”并不是真实的型别,它们只是function template的⼀种型别参数。

许多 STL 算法不仅⽀持⼀个版本,往往第⼀个版本算法会采⽤默认的⾏为,另⼀个版本会提 供额外的参数,接受⼀个仿函数,以便采取其它的策略。 例如 unique() 默认情况下会使⽤ equality 操作符来⽐较两个相邻元素,但如果这些元素的型 别并没有提供,那么便可以传递⼀个⾃定义的函数(或者叫仿函数)。

17.6 算法的泛型化

将⼀个表述完整的算法转化为程序代码,是⼀个合格程序员的基本功。

如何将算法独⽴于其所处理的数据结构之外,不受数据的牵绊,使得设计的算法在即将处理的 未知的数据结构上(也许是 array,也许是 vector,也许是 list,也许是 deque)上,正确地实现所有操作呢?

这就需要进⼀步思考,关键在于只要把操作对象的型别加以抽象化,把操作对象的标示法和区 间⽬标的移动⾏为抽象化,整个算法也就在⼀个抽象层⾯上⼯作了。

这个过程就叫做算法的泛型化,简称泛化。⽐如在 STL 源码剖析这本书⾥举了⼀个 find 的例 ⼦,如果⼀步步改成 template + 迭代器的形式,来说明了泛化的含义。

下⾯我们就来看看 STL 那些⽜批的算法,限于篇幅,算法的代码没有贴出。 具体源码细节可以去开头的 GitHub 仓库⾥研究,还有注释哦。

17.7 构成

17.8 分类

17.9 填充

fill() / fill_n() ⽤于填充相同值, generate() / generate_n() ⽤于填充不同值。

17.10 遍历/变换

17.11 最⼤最⼩

17.12 排序算法(12个):提供元素排序策略

17.13 反转/旋转

17.14 随机

17.15 查找算法(13个):判断容器中是否包含某个值

统计

查找

搜索

边界

17.16 删除和替换算法(15个)

复制

移除

替换

去重

交换

17.17 算术算法(4个)

17.18 关系算法(4个) <stl_algobase.h>

17.19 集合算法(6个)

17.20 排列组合算法:提供计算给定集合按⼀定顺序的所有可能排列组合

17.21 堆算法(4个)

Last updated