layout: post
title: 八股总结(一)C++语言特性、基础语法、类与模板、内存管理、拷贝控制、STL及C++11新特性
description: 八股总结(一)C++语言特性、基础语法、类与模板、内存管理、拷贝控制、STL及C++11新特性
tag: 八股总结
总结的大部分来自拓跋阿秀的校招八股文
4.链接
将不同源文件产生的目标文件进行链接,从而形成一个可以执行的程序,链接分为静态链接和动态链接。
静态链接以及具备所有程序执行所需要的东西,执行时速度更快,但是因为每个可执行程序对所需的目标文件都有一份副本,所以如果多个程序依赖同一个目标文件,就存在多个目标文件的副本,造成空间浪费;此外,因为整个程序都已经编译完成,后续如果要修改更新,就需要全部重新编译执行,造成更新困难,使用动态链接则解决了这个问题。
举个例子,下边这段代码,s包含一个int(4字节),一个char(1字节),理论上来讲,它的大小是5字节,然而编译器输出却是8字节。
//32位系统
#include
struct{int x;char y;
}s;int main()
{printf("%d\n",sizeof(s); // 输出8return 0;
}
现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。
尽管内存是以字节为单位,但是大部分CPU访问内存数据时会受到地址总线宽度
的限制,它一般会以地址总线宽度的倍数(2字节、4字节、8字节等)为单位来读取内存。上述存取单位也称为内存存取的粒度
。
因此假如没有内存对齐机制,数据任意存放,现在一个int变量,存放在地址1开始的连续的4个字节中,当处理器读取时,先从0位置一次读取4字节,提出前边不要的一个字节,再从位置4开始读取4字节块,剔除后边不要的3字节。这个过程很低效。
而有了内存对齐,int类型只能根据内存对齐规则存放在自身大小的倍数的位置,就可以一次读出:
//32位系统
#include
struct
{int i; char c1; char c2;
}x1;struct{char c1; int i; char c2;
}x2;struct{char c1; char c2; int i;
}x3;int main()
{printf("%d\n",sizeof(x1)); // 输出8printf("%d\n",sizeof(x2)); // 输出12printf("%d\n",sizeof(x3)); // 输出8return 0;
}
内存对齐后的实际存放情况:
string继承自basic_string,其实是对char进行了封装,封装的string包含了char数组,容量、长度等属性。
string可以动态拓展,每次拓展时,另外申请了一块原空间两倍大小的空间,然后将原字符串拷贝过去,并加上新增的内容。
大端存储:数据高位存在低地址中。
小端存储:数据低位存在低地址中。
以unsigned int value = 0x12345678
为例:
在小端模式下:随着地址增长,存放的是更高位的数据,因此高地址的0x12是数据的最高位数据。
使用代码判断大小端机器的方法:int整形强转为char型
由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分,低地址部分对应数据低位,说明是小端机器,否则为大端机器
#include
using namespace std;
int main()
{int a = 0x1234;//由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分char c = (char)(a);if (c == 0x12)cout << "big endian" << endl;else if(c == 0x34)cout << "little endian" << endl;
}
主机字节序:
就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。小端/大端的区别是指低位数据存储在内存低位还是高位的区别。其中小端机器指:数据低位存储在内存地址低位,高位数据则在内存地址高位;大端机器正好相反。
网络字节序:
4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。这种传输次序称作大端字节序。由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。
所以: 在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是大端。
由于 这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再 赋给socket。
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。
C只在局部上下文中表现出类型安全,比如试图从一种结构体指针转为另一种结构体指针时,编译器将会报告错误,除非使用显式类型转换。然而C中相当多的操作是不安全的。以下是两个十分常见的例子:
printf格式输出
malloc的函数返回值
malloc是C中进行内存分配的函数,它的返回类型是void*即空类型指针,常常有这样的用法char* pStr=(char*)malloc(100*sizeof(char))
,这里明显做了显式的类型转换。
类型匹配尚且没有问题,但是一旦出现int* pInt=(int*)malloc(100*sizeof(char))
就很可能带来一些问题,而这样的转换C并不会提示错误。
####C++的类型安全
如果C++使用得当,它将远比C更有类型安全,相对于C语言,C++提供了新的机制保障类型安全:
为了能够正确的在C++代码中调用C语言的代码:在程序中加上extern "C"后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++;
哪些情况下使用extern “C”:
(1)C++代码中调用C语言代码;
(2)在C++中的头文件中使用;
(3)在多个人协同开发时,可能有人擅长C语言,而有人擅长C++;
重载和重写的区别:
当在父类中使用虚函数时,子类可以对这个函数进行重写。
class A
{virtual void foo();
}
class B : public A
{void foo(); //OKvirtual void foo(); // OKvoid foo() override; //OK
}
使用override指定重写父类的虚函数,编译器可以帮助我们检查父类中是否有该虚函数。
比如,假定我们将foo写成了f00,有override的话,编译器发现父类中没有该虚函数不会通过编译,而不加override,会被认定为子类的新的方法,造成错误。
当不希望某个类被继承、或者不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。
遇到volatile关键字,编译器对访问该变量的代码不再进行优化,从而可以提供对特殊地址的稳定访问。volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量需要定义为volatile类型
volatile指针:volatile 指针和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念
修饰由指针指向的对象、数据是 const 或 volatile 的:
const char* cpch;volatile char* vpch;
修饰指针自身的值——即地址本身是const或者volatile的:
char* const pchc;char* volatile pchv;
mutable(可变的,易变的),C++中mutable是为了突破const的限制而设置的,被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数。
explicit
explicit(明确的)关键字用来修饰类的构造函数,被修饰构造函数的类,不能发生相应的隐式类型转换,只能以显式的方式进行类型转换。
注意:
类内部的构造函数的声明
一次函数调用包含一系列工作:调用前保存寄存器,返回时恢复,可能需要拷贝实参,程序转向另一个新的位置执行等。将函数指定为内联函数可以避免函数调用时的开销,通常内联函数会在它每个调用点上“内联地”展开。
内联函数以代码复杂为代码,省去了函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处的内联函数的调用都要赋值代码,消耗很多的内存空间。因此以下情况不适宜使用内联函数:
static
不考虑类的情况:
考虑类的情况:
必须在类的外部定义和初始化每个静态成员
const
不能在类定义外部初始化
(与static静态成员变量相反),只能通过构造函数初始化成员列表进行初始化,并且必须有构造函数,不同类对象的const数据成员值可以不同,所以不能在类中声明时初始化。64位的编译环境下,指针占用大小为8(8 ×8 = 64)个字节,在32位编译环境下,指针占用大小为 4 (4 ×8 = 32)个字节。
int i =0;
int *const p1 = &i; // 顶层const,不可改变p1
const int ci = 42;
const int *p2 = &ci; // 允许改变p2,不可改变ci,这是底层const
const int *const p3 = p2; // 靠右的const是顶层const,靠左的const是底层const
const int &r = ci; // 用于声明引用的const都是底层const
当执行对象的拷贝操作时,顶层const和底层const区别明显。其中顶层const不受影响,底层const的限制不可忽视。一般来讲,非常量可以转为常量,而常量不可转为非常量。
i = ci; // 正确
p2 = p3; // p2和p3所指对象类型相同,p3顶层const的部分不受影响。
int *p = p3; // 错误,p3包含底层const的含义,即所指对象不可修改,而p没有,如果这样定义,可能出现通过p修改常量的错误操作
p2 = p3; // 正确,p2和p3都是底层const
p2 = &i; // 正确,int * 可以转成 const int *
int &r = ci; // 错误,普通的int不可以绑定到int常量上,如果这样做,可能出现通过r修改常量ci的错误操作
const int &r2 = i; // 正确,const int & 可以绑定到一个普通的int上。
事实上底层const出现限制的原因主要在于由于底层const所指对象为常量,故绑定引用或者给指针赋值时,可能出现通过该引用或指针去修改常量的后果,出现这种后果的拷贝操作都是错误的
sizeof
运算符不再能得到原数组的大小了。int main(void) { int* p; // 未初始化std::cout<< *p << std::endl; // 未初始化就被使用return 0;
}
int main(void) { int * p = nullptr;int* p2 = new int;p = p2;delete p2;
}
此时 p和p2就是悬空指针,指向的内存已经被释放。继续使用这两个指针,行为不可预料。需要设置为p=p2=nullptr。此时再使用,编译器会直接保错。 避免野指针比较简单,但悬空指针比较麻烦。c++引入了智能指针,C++智能指针的本质就是避免悬空指针的产生。
函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。
int (*pf)(const int&, const int&);
上边的pf就是一个函数指针,指向所有返回类型为int,并带有两个const int&参数的函数,注意*pf两边的括号是必须的,否则上边的定义就变成
int *pf(const int&, const int&); (2)
成为一个函数声明,返回值类型为int *。
一个函数地址是该函数的进入点,也就是调用函数的地址,函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变元传递给其他函数。
指针名 = 函数名
指针名 = &函数名
两者中如果不对成员不指定公私有,struct默认是公有的,class则默认是私有的
class默认是private继承, 而struct默认是public继承
成员类别上,struct默认成员为public,class默认成员为private;继承关系上,struct默认公有继承,private默认私有继承。
在类的构造函数中,不在函数体内对成员变量赋值,而是 在花括号前边使用冒号和初始化列表赋值。
用它会快一些的原因是,对于有类成员的情况,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用,(函数体中需要一次默认构造,加一次赋值,而初始化成员列表只做一次赋值操作),而对于内置数据类型成员则没有差异。
C++允许一个类继承多个类,虽然实际开发中不建议多继承。
多继承可能导致菱形继承问题,即两个子类继承自同一个基类,又有某个派生类采用多继承的方式,同时继承了这两个子类。
为了解决菱形继承问题,C++中提出了虚继承的概率,在继承之前加上virtual关键字。
虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。**虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量,此类指针被称为bptr,如上图所示。如果既存在vptr又存在bptr,某些编译器会将其优化,合并为一个指针。
friend
声明这个函数。friend
声明。(1) 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
(2)成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。
(3)派生类构造函数。
可以看出构造和析构的顺序刚好相反。
1、抽象类的定义: 称带有纯虚函数的类为抽象类。
2、抽象类的作用: 抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
3、 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。
4、纯虚函数定义 纯虚函数是一种特殊的虚函数,它的一般格式如下:
class <类名> { virtual <类型><函数名>(<参数表>)=0; … };
在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。 纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。
虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表。
虚表指针:在含有虚函数表的类实例化对象时,对象地址的前4个字节存储的指向虚函数表的指针。
上图中展示了虚表和虚函数指针在基类对象和派生类对象中的模型,下边阐述实现多态的过程:
虚函数表是类似static成员类型一样,C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区,同一类的不同对象共用一张虚函数表。
(1)编译器发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址。
(2)编译器会在每个对象的前4个字节保存一个虚表指针即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚表指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能够找到正确的函数。
(3)在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表,当调用子类构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表。
(4)当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表,当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表,当派生类中有自己的虚函数时,在自己的虚表中将虚函数地址添加在后边。
这样指向派生类的基类指针在运行时,就可以根据派生类虚函数重写情况,动态的进行调用,从而实现多态性。
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
虚表指针vptr的初始化时间是构造函数时进行初始化的。
由于类的多态性,基类指针可以指向派生类对象,如果删除该基类指针,就会调用派生类析构函数,而派生类析构函数又自动调用基类的析构函数,这样这个派生类的对象完全被释放。
如果析构函数不被声明为虚函数,则编译器实施静态绑定,在删除基类指针时只会调用基类的析构函数,而不调用派生类析构函数,造成派生类对象析构不完全,导致内存泄露。
所以将析构函数声明为虚函数是十分必要的。
构造函数:
(1)从存储空间角度
虚函数对应一个vtable,这大家都知道,可是这个vtable其实是存储在对象的内存空间的。问题出来了,如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。
(2)从使用角度
虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
析构函数:
首先,讲这些函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联。
函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用而类模板只能显示调用。在使用时类模板必须加
编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。
这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。
编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化
模板特例化:
特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。
template
class Test{
public:Test(T1 x, T2 y):a(x),b(y){}
private:T1 a;T2 b;
}template<> //全特化,所有的类型参数全部指定
class Test {Test(int x, char y):a(x),b(y){}
private:int a;char b;
}template //偏特化,部分类型参数指定
class Test { Test(T x, char y):a(x),b(y){}
private:T a;char b;
}
C++程序在执行时,将供用户使用内存大致划分为四个区域:
(1)代码区:存放函数体的二进制代码,由操作系统进行管理;
(2)全局区:存放全局变量和静态(全局、局部)变量和字符串常量;
(3)栈区(stack):由编译器自动分配释放, 存放函数的参数值,局部变量等;
(4)堆区(heap):由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。
形象的比喻
栈就像我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
内存池(memory pool)是一种内存分配方式,通常我们习惯直接使用new、malloc等申请内存,这样做的缺点在于:由于所申请的内存块大小不定,当频繁使用时会造成大量的内存碎片,降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够在继续申请新的内存。这样的一个显著优点就是避免了内存碎片,使得内存分配效率得到提升。
《STL源码剖析》中的内存池实现机制:
allocate封装malloc,deallocate封装free。
一般是一次20 * 2(一个标准)个申请,先用一半,留着一半。
C++类是由结构体发展而来,所以他们的成员变量的内存分配机制是一样的。
内存泄露:
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、 new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了
避免内存泄露的几种方式:
operator new
的标准库函数,分配足够空间,并调用相关对象的构造函数,delete对指针所指对象运行对应的析构函数,然后通过调用名为operator delete
的标准库函数释放该对象所用的内存。malloc与free均没有相关的调用。new 是封装了malloc,直接free不会报错,但只是释放内存,不会析构对象。int *p = new float[2]; // 编译错误
int *p = (int*)malloc(2 * sizeof(double)); // 编译无错误
class A{
public:int num1;int num2;
public:A(int a=0, int b=0):num1(a),num2(b){};A(const A& a){};//重载 = 号操作符函数A& operator=(const A& a){num1 = a.num1 + 1;num2 = a.num2 + 1;return *this;};
};
int main(){A a(1,1);A a1 = a; //拷贝初始化操作,调用拷贝构造函数A b;b = a;//赋值操作,对象a中,num1 = 1,num2 = 1;对象b中,num1 = 2,num2 = 2return 0;
}
有时候类里边定义了很多int、char、struct等C语言里边那些类型的变量,可以使用memset(this, 0, sizeof(*this)),将整个对象内存全部置为0,简化一个一个初始化为0时的代码量,但包含下面两种情况不可以用:
coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。
C++STL从广义上讲包括了三类,算法,容器和迭代器。
首先明白为什么需要二级空间配置器?
我们知道动态开辟内存时,要在堆上申请,但若是我们需要频繁的在堆开辟释放内存,则就会在堆上造成很多外部碎片,浪费了内存空间;每次都要进行调用malloc、free函数等操作,使空间就会增加一些附加信息,降低了空间利用率;随着外部碎片增多,内存分配器在找不到合适内存情况下需要合并空闲块,浪费了时间,大大降低了效率。于是就设置了二级空间配置器,当开辟内存<=128bytes时,即视为开辟小块内存,则调用二级空间配置器。
vector是一种序列式容器,其数据安排以及操作方式与array非常类似,两者的唯一差别就是对于空间运用的灵活性,众所周知,array占用的是静态空间,一旦配置了就不可以改变大小,如果遇到空间不足的情况还要自行创建更大的空间,并手动将数据拷贝到新的空间中,再把原来的空间释放。vector则使用灵活的动态空间配置,维护一块连续的线性空间,在空间不足时,可以自动扩展空间容纳新元素,做到按需供给。其在扩充空间的过程中仍然需要经历:重新配置空间,移动数据,释放原空间等操作。这里需要说明一下动态扩容的规则:以原大小的两倍配置另外一块较大的空间(或者旧长度+新增元素的个数),源码:
Vector扩容倍数与平台有关,在Win + VS 下是 1.5倍,在 Linux + GCC 下是 2 倍
list是双向链表,而slist(single linked list)是单向链表,它们的主要区别在于:前者的迭代器是双向的Bidirectional iterator,后者的迭代器属于单向的Forward iterator。虽然slist的很多功能不如list灵活,但是其所耗用的空间更小,操作更快。
根据STL的习惯,插入操作会将新元素插入到指定位置之前,而非之后,然而slist是不能回头的,只能往后走,因此在slist的其他位置插入或者移除元素是十分不明智的,但是在slist开头却是可取的,slist特别提供了insert_after()和erase_after供灵活应用。考虑到效率问题,slist只提供push_front()操作,元素插入到slist后,存储的次序和输入的次序是相反的
vector是单向开口(尾部)的连续线性空间,deque则是一种双向开口的连续线性空间,虽然vector也可以在头尾进行元素操作,但是其头部操作的效率十分低下(主要是涉及到整体的移动)
deque和vector的最大差异一个是deque运行在常数时间内对头端进行元素操作,二是deque没有容量的概念,它是动态地以分段连续空间组合而成,可以随时增加一段新的空间并链接起来。
deque虽然也提供随机访问的迭代器,但是其迭代器并不是普通的指针,其复杂程度比vector高很多,因此除非必要,否则一般使用vector而非deque。如果需要对deque排序,可以先将deque中的元素复制到vector中,利用sort对vector排序,再将结果复制回deque
deque由一段一段的定量连续空间组成,一旦需要增加新的空间,只要配置一段定量连续空间拼接在头部或尾部即可,因此deque的最大任务是如何维护这个整体的连续性
deque的数据结构如下:
class deque
{...
protected:typedef pointer* map_pointer;//指向map指针的指针map_pointer map;//指向mapsize_type map_size;//map的大小
public:...iterator begin();itertator end();...
}
deque内部有一个指针指向map,map是一小块连续空间,其中的每个元素称为一个节点,node,每个node都是一个指针,指向另一段较大的连续空间,称为缓冲区,这里就是deque中实际存放数据的区域,默认大小512bytes。整体结构如上图所示。
stack这种单向开口的数据结构很容易由双向开口的deque和list形成,只需要根据stack的性质对应移除某些接口即可实现,stack的源码如下:
template >
class stack
{...
protected:Sequence c;
public:bool empty(){return c.empty();}size_type size() const{return c.size();}reference top() const {return c.back();}const_reference top() const{return c.back();}void push(const value_type& x){c.push_back(x);}void pop(){c.pop_back();}
};
类似的,queue这种“先进先出”的数据结构很容易由双向开口的deque和list形成,只需要根据queue的性质对应移除某些接口即可实现,queue的源码如下:
template >
class queue
{...
protected:Sequence c;
public:bool empty(){return c.empty();}size_type size() const{return c.size();}reference front() const {return c.front();}const_reference front() const{return c.front();}void push(const value_type& x){c.push_back(x);}void pop(){c.pop_front();}
};
heap(堆)并不是STL的容器组件,是priority queue(优先队列)的底层实现机制,因为binary max heap(大根堆)总是最大值位于堆的根部,优先级最高。
binary heap本质是一种complete binary tree(完全二叉树),整棵binary tree除了最底层的叶节点之外,都是填满的,但是叶节点从左到右不会出现空隙,如下图所示就是一颗完全二叉树
标准的STL set以RB-tree(红黑树)作为底层机制,几乎所有的set操作行为都是转调用RB-tree的操作行为。
map的特性是所有元素会根据键值进行自动排序。map中所有的元素都是pair,拥有键值(key)和实值(value)两个部分,并且不允许元素有相同的key
一旦map的key确定了,那么是无法修改的,但是可以修改这个key对应的value,因此map的迭代器既不是constant iterator,也不是mutable iterator
标准STL map的底层机制是RB-tree(红黑树)
1、它是二叉搜索树:
若左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值。
若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值。
左、右子树也分别为二叉搜素树。
2、它满足如下几点要求:
树中所有节点非红即黑。
根节点必为黑节点。
红节点的子节点必为黑(黑节点子节点可为黑)。
从根到NULL的任何路径上黑结点数相同。
3、查找时间一定可以控制在O(logn)。
STL中的hashtable使用的是开链法解决hash冲突问题,如下图所示。
引入nullptr是为了与C语言进行兼容。
NULL来自C语言,由宏定义实现,在C语言中,NULL被定义为(void *) 0,而在C++中NULL则被定义为整数0.
编译器一般实际定义如下:
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
C++中指针必须有明确的类型定义,将NULL定义为0带来的另一个问题是无法与整数0区分,因为C++中允许有函数重载。
#include
using namespace std;void fun(char* p) {cout << "char*" << endl;
}void fun(int p) {cout << "int" << endl;
}int main()
{fun(NULL);return 0;
}
//输出结果:int
在传入NULL参数,会把NULL当做整数0来看,如果我们想调用参数是指针的函数,该怎么办呢?nullptr被引入解决这一问题,nullptr可以明确区分整型和指针类型,能够根据环境自动转为相应的指针类型,但不会转为任何整型,所以不会导致参数传递错误。
auto让编译器通过初始值来进行类型推演。从而获得定义变量的类型,所以说auto定义的变量必须有初始值!!!
int func() {return 0};//普通类型
decltype(func()) sum = 5; // sum的类型是函数func()的返回值的类型int, 但是这时不会实际调用函数func()
int a = 0;
decltype(a) b = 4; // a的类型是int, 所以b的类型也是int//不论是顶层const还是底层const, decltype都会保留
const int c = 3;
decltype(c) d = c; // d的类型和c是一样的, 都是顶层const
int e = 4;
const int* f = &e; // f是底层const
decltype(f) g = f; // g也是底层const//引用与指针类型
//1. 如果表达式是引用类型, 那么decltype的类型也是引用
const int i = 3, &j = i;
decltype(j) k = 5; // k的类型是 const int&//2. 如果表达式是引用类型, 但是想要得到这个引用所指向的类型, 需要修改表达式:
int i = 3, &r = i;
decltype(r + 0) t = 5; // 此时是int类型//3. 对指针的解引用操作返回的是引用类型
int i = 3, j = 6, *p = &i;
decltype(*p) c = j; // c是int&类型, c和j绑定在一起//4. 如果一个表达式的类型不是引用, 但是我们需要推断出引用, 那么可以加上一对括号, 就变成了引用类型了
int i = 3;
decltype((i)) j = i; // 此时j的类型是int&类型, j和i绑定在了一起
int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e
C++包含四种智能指针shared_prt、unique_ptr、weak_ptr和auto_ptr。其中shared_ptr使用引用计数共享对象的所有权,而unique_ptr则独占对象的所有权,weak_ptr不影响引用计数,用于辅助shared_ptr,解决循环引用问题,auto_ptr是旧版本的unique_ptr,相对于新版本的unique_ptr,我们不能在容器中保存auto_ptr,也不能从函数中返回auto_ptr。
template
class SharedPtr{
public:SharedPtr(T* ptr = NULL): _ptr(ptr), _pcount(new int (1)) {}SharedPtr(const SharedPtr& s):_ptr(s._ptr), _pcount(s.s_pcont){(*_pcount)++;}SharedPtr& operator = (const SharedPtr& s) {if (this != &s) {if (--(*(this->_pcount)) == 0) {delete this->_ptr;delete this->_pcount;}_ptr = s._ptr;_pcount = s._pcount;*(_pcount)++;}return *this;}T& operator*() {return *(this->_ptr);}~SharedPtr() {--(*(this->_pcount));if (*(this->_pcount) == 0) {delete _ptr;_ptr = NULL;delete _pcount;_pcount = NULL;}}private:T* _ptrint * _pcount;
};
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
// 函数体
}
语法规则:lambda表达式可以看成是一般函数的函数名被略去,返回值使用了一个 -> 的形式表示。唯一与普通函数不同的是增加了“捕获列表”。如果要指定lambda返回类型,必须是采用尾置返回值类型。
下边这段代码,将vi中每个元素转为它的相反数。
transform(vi.begin(), vi.end(), vi.begin(), [](int i) -> int{if(i < 0 return -i; else return i;});
mutex 是C++11 中最基本的互斥量
,std::mutex 对象提供了独占所有权的特性——即不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock 则可以递归地对互斥量对象上锁。
recursive_mutex递归锁允许同一个线程多次获取该互斥锁
,可以用来解决同一线程需要多次获取互斥量时死锁的问题。
下面这个例子开辟了10个线程自增counter变量,如果使用try_lock()就不一定每次都能拿到锁去自增,如果使用lock()就会将counter自增到10 × 10K。
注意其中用到了关键字volatile int counter(0); // non-atomic counter
在多线程中,每个线程都共享该变量。
//1-2-mutex1
#include // std::cout
#include // std::thread
#include // std::mutex
volatile int counter(0); // non-atomic counter
std::mutex mtx; // locks access to counter
void increases_10k()
{
for (int i=0; i<10000; ++i) {
// 1. 使用try_lock的情况
// if (mtx.try_lock()) { // only increase if currently not locked:
// ++counter;
// mtx.unlock();
// }
// 2. 使用lock的情况{mtx.lock();++counter;mtx.unlock();}}
}
int main()
{std::thread threads[10];for (int i=0; i<10; ++i) threads[i] = std::thread(increases_10k);for (auto& th : threads) th.join();std::cout << " successful increases of the counter " << counter << std::endl;
return 0;
}
带超时的互斥量timed_mutex
和recursive_timed_mutex
std::timed_mutex比std::mutex多了两个超时获取锁的接口:try_lock_for
和try_lock_until
mutex
的使用需要.lock 和.unlock
,这就类似new
和delete
,相对于手动lock和unlock,我们可以使用RAII(通过类的构造析构管理资源,是C++中常用的管理资源,避免内存泄露的编程理念)来实现更好的编码方式。
unique_lock,lock_guard这两种锁都可以对std::mutex进行封装,在创建时自动加锁,在销毁时自动解锁。实现RAII的效果。
#include // std::cout
#include // std::thread
#include // std::mutex, std::lock_guard
#include // std::logic_error
using namespace std;
std::mutex mtx;
void print_even(int x) {if (x % 2 == 0) std::cout << x << " is even\n";else std::cout << x << " not even\n";
}
void print_thread_id(int id) {try {// using a local lock_guard to lock mtx guarantees unlocking on destruction / exception:std::lock_guard lck(mtx);print_even(id);}catch (std::logic_error&) {std::cout << "[exception caught]\n";}
}
int main()
{std::thread threads[10];// spawn 10 threads:for (int i = 0; i < 10; ++i)threads[i] = std::thread(print_thread_id, i + 1);for (auto& th : threads) th.join();return 0;
}
这两种锁都可以对std::mutex进行封装,实现RAII的效果。绝大多数情况下这两种锁是可以互相替代的,区别是unique_lock比lock_guard能提供更多的功能特性(但需要付出性能的一些代价),如下:
总结:需要结合notify+wait的场景使用unique_lock; 如果只是单纯的互斥使用lock_guard
#include
#include
#include
#include
#include
#include
std::deque q;
std::mutex mu;
std::condition_variable cond;
int count = 0;
void fun1() {
while (true) {// {std::unique_lock locker(mu);q.push_front(count++);locker.unlock(); // 这里是不是必须的?cond.notify_one();// }sleep(1);}
}
void fun2() {while (true) {std::unique_lock locker(mu);cond.wait(locker, [](){return !q.empty();});auto data = q.back();q.pop_back();// locker.unlock(); // 这里是不是必须的?std::cout << "thread2 get value form thread1: " << data << std::endl;}
}
int main() {std::thread t1(fun1);std::thread t2(fun2);t1.join();t2.join();return 0;
}
互斥量是多线程间同时访问某一共享变量时,保证变量可被安全访问的手段。但单靠互斥量无法实现线程的同步。线程同步是指线程间需要按照预定的先后次序顺序进行的行为。C++11对这种行为也提供了有力的支持,这就是条件变量。条件变量位于头文件#include
下。
条件变量使用过程:
条件变量提供了两类操作:wait和notify。这两个操作,构成了多线程同步的基础。
#include
#include
#include
#include
using namespace std;mutex mymutex;
condition_variable cv;
int flag = 0;void printa() {unique_lock lk(mymutex);for (int i = 0; i < 10; ++i) {while (flag != 0) cv.wait(lk);cout << "thread 1 : a" << endl;flag = 1;cv.notify_all();count++;}cout << "my thread 1 finish" << endl;
}void printb() {unique_lock lk(mymutex);for (int i = 0; i < 10; ++i) {while (flag != 1) cv.wait(lk);cout << "thread 2: b" << endl;flag = 2;cv.notify_all();}cout << "my thread 1 finish" << endl;
}void printc() {unique_lock lk(mymutex);for (int i = 0; i < 10; ++i) {while (flag != 2) cv.wait(lk);cout << "thread 3: c" << endl;flag = 0;cv.notify_all();}cout << "my thread 1 finish" << endl;
}int main() {thread th1(printa);thread th2(printb);thread th3(printc);th1.join();th2.join();th3.join();cout << "main thread "<< endl;
}
对原子变量的操作是原子操作,能保证在任何情况下都不被打断,是线程安全的,不需要加锁。
对于少量代码,用原子变量代替锁,效率更高。
使用:
std::atomic
成员函数:store(), load()
count.store(x, std::memory_order_relaxed);
count.load(std::memory_order_relaxed);
#include
#include
#includestd::atomic count(0);
void set_count(int x)
{std::cout << "set_count" << x << std::endl;count.store(x, std::memory_order_relaxed);
}void print_count()
{int x;do {x = count.load(std::memory_order_relaxed);} while (x == 0);std::cout << "count:" << x << '\n';
}int main()
{std::thread t1(print_count);std::thread t2(set_count, 10);t1.join();t2.join();std::cout << "main finish\n";return 0;
}
C++11为异步操作提供了4个接口
future是期望的意思,期望得到一个返回值,就想执行函数的返回值。线程可以周期性的在这个future上等待一小段时间,检测future是否就绪(ready),如果没有,线程可以先去做另一个任务,如果就绪,则future无法复位(是一次性的)。
#include
中声明了两种future,std::future和std::shared_future
,这两个是参数unique_ptr
和shared_ptr
设立的,前者的实例是仅有一个指向关联事件的实例,后者可以有多个实例指向同一个关联事件,当事件就绪时,所有指向同一事件的std::shared_future实例就会变成就绪。
std::future
的使用:
首先future也是一个模板,尖括号内是期望返回值的类型,future被用于线程间通信,但其本身并不提供同步访问。后边可以通过future.get()
获取到返回值。
future的使用时机是当你不需要立刻得到一个结果的时候,你可以开启一个线程去帮你完成这个任务,并期待这个任务的返回值,但是future
本身并不支持开启另一个线程的功能,这就需要用到async
跟thread类似,async允许你通过将额外的参数添加到调用中,来将附加参数传递给函数
。如果传入的函数指针是某个类的成员函数,则还需要将类对象指针传入(直接传入,传入指针,或者是std::ref封装)。
默认情况下,std::async是启动一个新线程,或者在等待future时,任务是否同步运行都取决于你给定的参数,默认选项参数被设置为std::launch::any
下边是使用的例子:
这里我们future(期望)了两个函数,第一个函数设置为异步,那么 std::future
,即future被创建时便开始创建新线程调用,而第二个采用默认参数,延迟调用,只有在result2.get()
时,才会创建一个新线程运行。
#include
#include
#include
using namespace std;
int find_result_to_add() {//std::this_thread::sleep_for(std::chrono::seconds(2)); // 用来测试异步延迟的影响std::cout << "find_result_to_add" << std::endl;return 1 + 1;
}
int find_result_to_add2(int a, int b) {//std::this_thread::sleep_for(std::chrono::seconds(5)); // 用来测试异步延迟的影响return a + b;
}
void do_other_things() {std::cout << "do_other_things" << std::endl;std::this_thread::sleep_for(std::chrono::seconds(5));
}
int main() {//async异步 std::future result = std::async(std::launch::async,find_result_to_add);//std::future result = std::async(find_result_to_add);//auto result = std::async(find_result_to_add); // 推荐的写法用aoto do_other_things();std::cout << "result: " << result.get() << std::endl; // 延迟是否有影响?//std::future result2 = std::async(find_result_to_add2, 10, 20);//不写默认any auto result2=std::async(find_result_to_add2, 10, 20);std::cout << "result2: " << result2.get() << std::endl; // 延迟是否有影响?std::cout << "main finish" << endl;return 0;
}
std::packaged_tast是将任务和feature绑定在一起的模板,是一种对任务的封装
,后边可以通过调用get_feature()的方法获取到任务执行后返回的future
。packaged_task同样是一种模板,模板参数为函数名。
#include
#include
using namespace std;
int add(int a, int b, int c) {std::cout << "call add\n";return a + b + c;
}
void do_other_things() {std::cout << "do_other_things" << std::endl;
}
int main() {std::packaged_task task(add); // 封装任务do_other_things();std::future result = task.get_future();task(1, 1, 2); //必须要让任务执行,否则在get()获取future的值时会一直阻塞std::cout << "result:" << result.get() << std::endl;return 0;
}
promise提供了一种设置值
的方式,他可以在这之后通过相关联
的std::future对象进行读取,换种说法,之前已经说过std::future可以读取一个函数的返回值了,那么promise
是提供了一种方式,手动让future就绪。具体可见下面的例子:
//1-5-promise
#include
#include
#include
#include
using namespace std;
void print(std::promise& p)
{p.set_value("There is the result whitch you want.");
}
void do_some_other_things()
{std::cout << "Hello World" << std::endl;
}
int main()
{std::promise promise;std::future result = promise.get_future();std::thread t(print, std::ref(promise));do_some_other_things();std::cout << result.get() << std::endl;t.join();return 0;
}
promise
源于金融中期货
的概念,从上边的例子中可以看到,promise创建的时候并没有绑定任何函数,执行任何线程,但是它却可以调用.get_future()方法,给future赋值。就像在金融市场中,你并没有生成货物,但是开出了期货
,作为提货的凭据,这个期货凭据,本质上是一种口头上的承诺
。可以在后边再生产兑现。