layout: post
title: 八股总结(一)C++语言特性、基础语法、类与模板、内存管理、拷贝控制、STL及C++11新特性
description: 八股总结(一)C++语言特性、基础语法、类与模板、内存管理、拷贝控制、STL及C++11新特性
tag: 八股总结
总结的大部分来自拓跋阿秀的校招八股文
文章目录
- 基础语法
- 语言特性
- 面向对象的三大特性?
- C++从代码到可执行程序经历了什么?
- 静态链接和动态链接的概念与区别
- 动态编译与静态编译
- 为什么C++没有垃圾回收机制?这点跟java不一样。
- C/C++的内存对齐机制
- C++中新增了string,它与C语言中的char*有什么区别吗?它是如何实现的?
- strcpy和memcpy的区别是什么?
- 如何用代码判断大小端存储
- 网络字节序与主机字节序
- C和C++的类型安全
- 什么是类型安全?
- C的类型安全
- extern"C"的用法
- 关键字
- 重载(overload)、重写(override)、隐藏(hide)
- override和final
- volatile、mutable和explicit关键字用法
- 宏定义与静态变量
- 内联函数和宏定义的区别
- 内联函数inline适用的场景
- 为什么不能把所有的函数都写成内联函数?
- define宏定义和const的区别?
- const和static的作用
- 指针与引用
- 一个指针占多少字节?
- 指针与引用的区别与联系?
- 在传递函数参数,如何决定是使用指针还是引用?
- 顶层const与底层const的区别?
- 数组名和指针(指向数组首元素的指针)的联系与区别?
- this指针
- 野指针和悬空指针定义、解决办法。
- 函数指针
- 什么是函数指针?
- 函数指针的声明方法?
- 为什么有函数指针?
- 两种方法赋值
- 类与继承
- 空类的大小是多少?
- 类大小受哪些因素影响?
- struct与class的区别?
- C++有哪几种构造函数?
- 成员初始化列表的概念,用它为什么会快一些?
- public、protected和private访问权限和继承权限
- 什么是虚拟继承
- 友元
- 构造函数、析构函数的执行顺序是什么?
- 抽象类
- 虚表与虚基表指针
- 基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间
- 为什么析构函数一般写成虚函数
- 构造函数能否声明为虚函数或者纯虚函数?析构函数呢?
- 构造函数、析构函数、虚函数可否声明为内联函数
- 模板与泛型编程
- 模板类和模板函数的区别是什么?
- C++模板是什么,底层如何实现
- 模板特化、偏特化
- 内存管理与拷贝控制
- 内存安全管理
- 内存分区
- 堆与栈的区别
- 什么是内存池,如何实现
- C++中类的数据成员和成员函数内存分布情况
- 什么是内存泄露,如何避免与检测
- new / delete / 与malloc / free的异同
- 拷贝控制
- 浅拷贝和深拷贝的区别
- 初始化和赋值的区别
- 什么情况下会调用拷贝构造函数。
- 说说移动构造函数
- 成员函数里memset(this, 0, sizeof(*this))会发生什么
- 异常处理
- 常见错误类型
- 写C++代码时有一类错误是 coredump ,很常见,你遇到过吗?怎么调试这个错误?
- 标准模板库(Standard Template Library,STL)
- 算法
- 容器
- STL的两级空间配置器
- STL中vector的实现
- STL中list的实现
- STL中的deque实现
- STL中的stack和queue的实现
- STL中的heap实现
- STL中set的实现
- STL中map的实现
- 红黑树的概念
- STL中hashtable的实现是怎样的?
- 解决hashtable冲突的方法有哪些?
- 迭代器
- C++新特性
- 新语法及关键字
- NULL和nullptr的区别
- auto、decltype和decltype(auto)的用法
- 智能指针
- 为什么引入智能指针?
- 使用智能指针管理内存资源符合RALL,什么是RALL
- C++的四种智能指针
- 智能指针shared_ptr的代码实现
- 说说你了解的auto_ptr的作用
- lambda
- 多线程
- 互斥量
- mutex/recursive_mutex
- timed_mutex/recursive_timed_mutex
- lock_guard和unique_lock
- lock_guard和unique_lock的区别
- 条件变量condition_variable
- 写三个线程交替打印ABC
- 原子变量
- 异步操作
- future/aysnc
- packaged_task
- promise
基础语法
语言特性
面向对象的三大特性?
- 封装:将客观事物封装为抽象的类,并且类可以把自己的数据和方法只让可信的类或对象操作,对不信的进行信息隐藏。
- 继承:使用现有类的所有功能,无需重写,直接对基类进行功能拓展延伸。
- 多态:同一事物可以表现为不同事物的能力,不同对象在接收时产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)。实现多态有两种方式:使用override(重写)和使用overload(重载)。
C++从代码到可执行程序经历了什么?
- 预编译
主要处理源代码文件中以“#”开头的预编译指令。处理规则如下:
(1)删除所有的#define,展开所有的宏定义。
(2)处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
(3)处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他 文件。
(4)删除所有的注释,“//”和“/**/”。
(5)保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重 复引用。
(6)添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是 能够显示行号。 - 编译
把预编译生成的xxx.i或xxx.ii文件,进行一系列词法分析,语法分析,语义分析及优化后,生成相应的汇编代码文件。
(1)词法分析:利用类似“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。
(2)语法分析:语法分析对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
(3)语义分析:语法分析只是完成了对表达式语法层次的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行时才能确定的语义。
(4)优化:源代码级别的一个优化过程。
(5)目标代码生成:由代码生成器将中间代码转为目标机器代码,生成一系列的代码序列——汇编语言表示。
(6)目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式,使用位移来替换乘法运算,删除多余指令等。 - 汇编
将汇编代码变成机器可以执行的指令(机器码文件)。汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来。汇编过程产生目标文件(Linux:xxx.o,Window:xxx.obj)
4.链接
将不同源文件产生的目标文件进行链接,从而形成一个可以执行的程序,链接分为静态链接和动态链接。
静态链接和动态链接的概念与区别
- 静态链接:将所有程序模块都链接为一个单独的可执行文件
- 动态链接:把程序按照模块拆分为各个相对独立的部分,在程序运行时才将它们链接在一起,形成一个完整的程序。
静态链接以及具备所有程序执行所需要的东西,执行时速度更快,但是因为每个可执行程序对所需的目标文件都有一份副本,所以如果多个程序依赖同一个目标文件,就存在多个目标文件的副本,造成空间浪费;此外,因为整个程序都已经编译完成,后续如果要修改更新,就需要全部重新编译执行,造成更新困难,使用动态链接则解决了这个问题。
动态编译与静态编译
- 静态编译:编译器在编译可执行文件时,把需要用到的对应动态链接库中的部分提取处理,链接到可执行文件中去,使可执行文件在运行时不需要依赖于动态链接库。
- 动态编译的可执行文件需要附带一个动态链接库,在执行时,需要调用其对应动态链接库的命令。所以其优点是一方面缩小了执行文件本身的体积,另一方面加快了编译速度,节省了系统资源。缺点是哪怕是很简单的程序,只用到了链接库的一两条命令,也需要附带一个相对庞大的链接库。二是如果其他计算机上没有安装相应的运行库,则用动态编译的可执行库文件就不能运行。
为什么C++没有垃圾回收机制?这点跟java不一样。
- 首先,实现一个垃圾回收期会带来额外的空间和时间开销,你需要开辟一定的空间保存指针的引用计数和对它们进行标记mark,然后需要单独开辟一个线程在空闲的时候进行free操作。
- 垃圾回收使得C++不适合进行很多底层的操作。
C/C++的内存对齐机制
举个例子,下边这段代码,s包含一个int(4字节),一个char(1字节),理论上来讲,它的大小是5字节,然而编译器输出却是8字节。
//32位系统
#include<stdio.h>
struct{
int x;
char y;
}s;
int main()
{
printf("%d\n",sizeof(s); // 输出8
return 0;
}
现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。
尽管内存是以字节为单位,但是大部分CPU访问内存数据时会受到地址总线宽度
的限制,它一般会以地址总线宽度的倍数(2字节、4字节、8字节等)为单位来读取内存。上述存取单位也称为内存存取的粒度
。
因此假如没有内存对齐机制,数据任意存放,现在一个int变量,存放在地址1开始的连续的4个字节中,当处理器读取时,先从0位置一次读取4字节,提出前边不要的一个字节,再从位置4开始读取4字节块,剔除后边不要的3字节。这个过程很低效。
而有了内存对齐,int类型只能根据内存对齐规则存放在自身大小的倍数的位置,就可以一次读出:
//32位系统
#include<stdio.h>
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)); // 输出8
printf("%d\n",sizeof(x2)); // 输出12
printf("%d\n",sizeof(x3)); // 输出8
return 0;
}
内存对齐后的实际存放情况:
C++中新增了string,它与C语言中的char*有什么区别吗?它是如何实现的?
string继承自basic_string,其实是对char进行了封装,封装的string包含了char数组,容量、长度等属性。
string可以动态拓展,每次拓展时,另外申请了一块原空间两倍大小的空间,然后将原字符串拷贝过去,并加上新增的内容。
strcpy和memcpy的区别是什么?
- 复制的内容不同,strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组,整型,结构体,类等。
- 复制的方法不同,strcpy不需要指定长度,遇到被赋值字符的串结束符“\0”才结束,所以容易溢出,memcpy是根据第三个参数决定赋值的长度。
- 用途不同,通常在复制字符串时用strcpy,而复制其他类型数据时一般用memcpy。
如何用代码判断大小端存储
大端存储:数据高位存在低地址中。
小端存储:数据低位存在低地址中。
以unsigned int value = 0x12345678
为例:
在小端模式下:随着地址增长,存放的是更高位的数据,因此高地址的0x12是数据的最高位数据。
使用代码判断大小端机器的方法:int整形强转为char型
由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分,低地址部分对应数据低位,说明是小端机器,否则为大端机器
#include <iostream>
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++的类型安全
什么是类型安全?
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。
C的类型安全
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++提供了新的机制保障类型安全:
- 操作new返回指针类型严格与对象匹配,而不是void*
- C中很多void*为参数的函数可以改写为C++函数模板,而函数模板是支持类型检查的。
- 引入了const关键字,代替#define constants,它是有类型,有作用域的,而#define constants只是简单的文本替换。
- 一些#define宏,可以被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保障类型安全。
- C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。
extern"C"的用法
为了能够正确的在C++代码中调用C语言的代码:在程序中加上extern "C"后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++;
哪些情况下使用extern “C”:
(1)C++代码中调用C语言代码;
(2)在C++中的头文件中使用;
(3)在多个人协同开发时,可能有人擅长C语言,而有人擅长C++;
关键字
重载(overload)、重写(override)、隐藏(hide)
- 重载(overload):在同一范围定义中的同名成员函数才存在重载关系。特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否为虚函数无关。
- 重写(override):重写指的是在派生类中覆盖基类中的同名虚函数,重写就是重写函数体,要求基类函数必须是虚函数且与基类的虚函数有相同的参数个数、参数类型和返回值类型。
重载和重写的区别:
- 重写是父类与子类之间的垂直关系,重载是同名不同参数的函数之间的水平关系。
- 重写要求参数列表相同,重载则要求参数列表不同,返回值不同。
- 重写关系中,调用方法根据对象类型决定,重载根据调用时实参与形参表的对应关系选择函数体。
- 隐藏(hide)
隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包含以下情况:
- 两个函数参数相同,但是基类函数不是虚函数,和重写的区别在于基类函数是否为虚函数。
- 两个函数参数不同,无论基类函数是否为虚函数,都会被隐藏,和重载的区别在于,两个函数不在同一个子类中。
override和final
当在父类中使用虚函数时,子类可以对这个函数进行重写。
class A
{
virtual void foo();
}
class B : public A
{
void foo(); //OK
virtual void foo(); // OK
void foo() override; //OK
}
使用override指定重写父类的虚函数,编译器可以帮助我们检查父类中是否有该虚函数。
比如,假定我们将foo写成了f00,有override的话,编译器发现父类中没有该虚函数不会通过编译,而不加override,会被认定为子类的新的方法,造成错误。
当不希望某个类被继承、或者不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。
volatile、mutable和explicit关键字用法
- volatile(易变的、不稳定的)关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如操作系统、硬件或者其他线程。
遇到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(明确的)关键字用来修饰类的构造函数,被修饰构造函数的类,不能发生相应的隐式类型转换,只能以显式的方式进行类型转换。
注意:
- explicit关键字只能用于
类内部的构造函数的声明
- explicit关键字作用于单个参数的构造函数
- 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换。
宏定义与静态变量
内联函数和宏定义的区别
- 在使用时:宏只做简单字符串替换,而内联函数可以进行参数类型检查且具有返回值。
- 内联函数在编译时直接将函数代码嵌入到目标代码中,省去了函数调用时的开销,以提高执行效率,并且进行参数类型检查,有返回值,可以实现重载。
- 宏定义时要注意书写(参数括起来),否则容易出现歧义,内联函数不会产生歧义。
内联函数inline适用的场景
- 使用宏定义的地方都可以使用 inline 函数。
- 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率。
为什么不能把所有的函数都写成内联函数?
一次函数调用包含一系列工作:调用前保存寄存器,返回时恢复,可能需要拷贝实参,程序转向另一个新的位置执行等。将函数指定为内联函数可以避免函数调用时的开销,通常内联函数会在它每个调用点上“内联地”展开。
内联函数以代码复杂为代码,省去了函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处的内联函数的调用都要赋值代码,消耗很多的内存空间。因此以下情况不适宜使用内联函数:
- 函数体内的代码比较长,将导致内存消耗代价。
- 函数体内有循环,函数执行开销比函数调用开销大。
define宏定义和const的区别?
- 编译阶段上:
define是在编译的预处理阶段起作用,而const是在编译、运行时候起作用。 - 安全性上:
define只做替换,不做类型检查和计算,也不求解,容易产生错误,一般最好加一个大括号包住全部的宏定义内容,不然容易出错,而const是常量是有数据类型的,编译器可以对它进行安全检查。 - 内存占用上:
define只是将宏名称进行替换,在内存中会产生多份相同的备份,const在程序运行中只有一份备份,且可以执行常量折叠,能将复杂的的表达式计算出结果放入常量表。
const和static的作用
static
-
不考虑类的情况:
- 隐藏的功能,不加static的全局变量和全局函数具有全局可见性,可以在其他文件中使用,加了static之后的全局变量和全局函数只能在该文件所在的编译模块中使用。
- 默认初始化为0,存储在静态数据区,省去了置零的操作。
- 保存数据内容的持久化,具备记忆性。在程序的执行路径第一次经过对象定义语句的时候初始化,并且直到程序终止才被销毁,期间的任务数据修改都具有持久记忆性。(不会因为退出函数等被销毁,除非再次被修改,否则直到程序结束前,值都是不变的)。
-
考虑类的情况:
- static静态成员函数和静态成员不与类对象绑定在一起,所有类对象的静态成员共享一份内存数据。不由类的构造函数初始化,因此
必须在类的外部定义和初始化每个静态成员
- static静态成员函数不具有this指针(因为不与类对象绑定,所有类对象共享访问),static成员函数内不可以访问非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile.
- static静态成员函数和静态成员不与类对象绑定在一起,所有类对象的静态成员共享一份内存数据。不由类的构造函数初始化,因此
const
- 不考虑类的情况:
- const常量在定义时必须初始化,初始化后不能修改。
- const形参可以接收const和非const类型实参
- 考虑类的情况:
- const成员变量:
不能在类定义外部初始化
(与static静态成员变量相反),只能通过构造函数初始化成员列表进行初始化,并且必须有构造函数,不同类对象的const数据成员值可以不同,所以不能在类中声明时初始化。 - const成员函数:const对象不可以调用非const成员函数,不可改变非mutable数据的值。
- const成员变量:
指针与引用
一个指针占多少字节?
64位的编译环境下,指针占用大小为8(8 ×8 = 64)个字节,在32位编译环境下,指针占用大小为 4 (4 ×8 = 32)个字节。
指针与引用的区别与联系?
- 指针是一个变量,存储的是一个地址,引用是变量的别名,跟原变量是一个东西。
- 指针可以有多级,即可以定义指针的指针,而引用则不能。
- 指针可以为空,可以初始化为null,初始化后还可以改变指针的指向,而引用则必须在定义时初始化,一旦初始化绑定变量后,就不可再改变。
- 指针与引用作为参数传递时,都可以改变实参的值,不同的是,指针作为参数传递时,是将指针的一个拷贝传递给形参,两者指向相同地址,但不是同一个变量,在函数中改变这个变量的指向,不会影响实参,但引用可以。
- 引用的本质是一个指针常量(指针中的常量),指向不可改变。
在传递函数参数,如何决定是使用指针还是引用?
- 需要返回函数内局部变量的内存的时候使用指针,指针传参需要开辟内存,用完需要释放指针,否则会内存泄露,而返回局部变量的引用是没有意义的。
- 对栈空间大小敏感(比如递归)的时候使用引用,不需要创建临时变量,内存开销小。
- 类对象作为参数传递的时候,使用引用,这是C++类对象传递的标准方式。
顶层const与底层const的区别?
- 顶层const: *const,指针常量,指针中的常量,指向不可修改。
- 底层const:const (int)✳, 常量指针,常量的指针,所指对象视为常量,不可改变所指对象。
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
运算符不再能得到原数组的大小了。
this指针
- this指针是类的指针,指向对象的首地址
- this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能使用。
- this指针只有在成员函数中才有定义,且存储位置会因为编译器不同而有不同的存储位置。
- this指针的用处:指针指针并不是对象本身的一部分,不会影响sizeof(对象)的结果,this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候,编译器会自动将对象本身的地址作为一个隐含参数传递给函数,也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this指针的。它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。
- this指针的使用:一种情况是,在类的非静态成员函数中,返回类对象本身时,可以直接使用return *this。另外一种情况是,当形参与成员变量名相同时,使用this加以区分。this->n = n;
- 在成员函数中调用delete this会出现什么问题?对象还可以使用吗?
类对象的内存空间只有数据成员和虚函数表指针,不包含代码内容,类的成员函数单独放在代码区,调用delete this时,类对象的内存空间被释放,之后的任何其他函数调用,只要不涉及this指针的内容,可以正常运行,一旦设计到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的错误。
野指针和悬空指针定义、解决办法。
- 野指针:没有初始化的指针
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++智能指针的本质就是避免悬空指针的产生。
- 产生原因和解决方法:
野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空(= nullptr)。
悬空指针:指针free或delete后没有及时置空 =>释放操作后立即置空。
函数指针
什么是函数指针?
函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。
函数指针的声明方法?
int (*pf)(const int&, const int&);
上边的pf就是一个函数指针,指向所有返回类型为int,并带有两个const int&参数的函数,注意*pf两边的括号是必须的,否则上边的定义就变成
int *pf(const int&, const int&); (2)
成为一个函数声明,返回值类型为int *。
为什么有函数指针?
一个函数地址是该函数的进入点,也就是调用函数的地址,函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变元传递给其他函数。
两种方法赋值
指针名 = 函数名
指针名 = &函数名
类与继承
空类的大小是多少?
- C++空类不为0,因为每个类实例都必须有独一无二的地址,编译器自动会为空类分配一个字节大小的内存;
- 带有虚函数的C++类大小不为1,因为每一个对象都会有一个虚函数表指针指向虚函数表,具体大小依据指针大小而定。
类大小受哪些因素影响?
- 类的非静态成员大小,静态成员、成员函数(包含静态与非静态)不占类空间大小。
- 非静态成员声明的顺序,因为有内存对齐操作,因此成员变量的定义顺序也会影响类的大小。
- 虚函数的话,会在类对象插入vptr指针,加上指针大小。
- 当该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在派生类中的空间,也会拓展派生类的空间。
struct与class的区别?
- 相同点
- 两者都拥有成员函数、公有和私有部分
- 任何可以使用class完成的工作,同样可以使用struct完成
- 不同点
-
两者中如果不对成员不指定公私有,struct默认是公有的,class则默认是私有的
-
class默认是private继承, 而struct默认是public继承
成员类别上,struct默认成员为public,class默认成员为private;继承关系上,struct默认公有继承,private默认私有继承。
-
C++有哪几种构造函数?
- 默认构造函数
- 初始化构造函数(有参数)
- 拷贝构造函数
- 移动构造函数(move和右值引用)
- 委托构造函数
- 转换构造函数
成员初始化列表的概念,用它为什么会快一些?
在类的构造函数中,不在函数体内对成员变量赋值,而是 在花括号前边使用冒号和初始化列表赋值。
用它会快一些的原因是,对于有类成员的情况,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用,(函数体中需要一次默认构造,加一次赋值,而初始化成员列表只做一次赋值操作),而对于内置数据类型成员则没有差异。
public、protected和private访问权限和继承权限
- public成员类内和类外都可以访问,protected成员只能在类内或者派生类中访问,private的成员只能在类内访问。
什么是虚拟继承
C++允许一个类继承多个类,虽然实际开发中不建议多继承。
多继承可能导致菱形继承问题,即两个子类继承自同一个基类,又有某个派生类采用多继承的方式,同时继承了这两个子类。
为了解决菱形继承问题,C++中提出了虚继承的概率,在继承之前加上virtual关键字。
虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。**虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量,此类指针被称为bptr,如上图所示。如果既存在vptr又存在bptr,某些编译器会将其优化,合并为一个指针。
友元
- 友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制,通过友元,一个函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能够提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
- 友元函数:友元函数是定义在类外的普通函数,不属于任何类,可以访问其他类的私有成员,但是需要在类的定义中声明所有可以访问它的友元函数。一个函数可以是多个类的友元函数但是,每个类中都要使用关键字
friend
声明这个函数。 - 友元类:友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员),需要在另一个类中使用关键字
friend
声明。 - 友元关系不可继承
- 友元关系是单向的。
- 友元关系不具备传递性。
构造函数、析构函数的执行顺序是什么?
- 构造函数顺序
(1) 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
(2)成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。
(3)派生类构造函数。
- 析构函数顺序
(1)调用派生类的析构函数;
(2)调用成员类对象的析构。
(3)调用基类的析构函数。
可以看出构造和析构的顺序刚好相反。
抽象类
1、抽象类的定义: 称带有纯虚函数的类为抽象类。
2、抽象类的作用: 抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
3、 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。
4、纯虚函数定义 纯虚函数是一种特殊的虚函数,它的一般格式如下:
class <类名> { virtual <类型><函数名>(<参数表>)=0; … };
在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。 纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。
虚表与虚基表指针
虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表。
虚表指针:在含有虚函数表的类实例化对象时,对象地址的前4个字节存储的指向虚函数表的指针。
上图中展示了虚表和虚函数指针在基类对象和派生类对象中的模型,下边阐述实现多态的过程:
虚函数表是类似static成员类型一样,C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区,同一类的不同对象共用一张虚函数表。
(1)编译器发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址。
(2)编译器会在每个对象的前4个字节保存一个虚表指针即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚表指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能够找到正确的函数。
(3)在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表,当调用子类构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表。
(4)当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表,当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表,当派生类中有自己的虚函数时,在自己的虚表中将虚函数地址添加在后边。
这样指向派生类的基类指针在运行时,就可以根据派生类虚函数重写情况,动态的进行调用,从而实现多态性。
基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
虚表指针vptr的初始化时间是构造函数时进行初始化的。
为什么析构函数一般写成虚函数
由于类的多态性,基类指针可以指向派生类对象,如果删除该基类指针,就会调用派生类析构函数,而派生类析构函数又自动调用基类的析构函数,这样这个派生类的对象完全被释放。
如果析构函数不被声明为虚函数,则编译器实施静态绑定,在删除基类指针时只会调用基类的析构函数,而不调用派生类析构函数,造成派生类对象析构不完全,导致内存泄露。
所以将析构函数声明为虚函数是十分必要的。
构造函数能否声明为虚函数或者纯虚函数?析构函数呢?
构造函数:
(1)从存储空间角度
虚函数对应一个vtable,这大家都知道,可是这个vtable其实是存储在对象的内存空间的。问题出来了,如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。
(2)从使用角度
虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
析构函数:
- 析构函数可以声明为虚函数,并且一般情况下基类析构函数必须定义为虚函数。
- 析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化,派生类可以根据自身需求重写基类中的纯虚函数。
构造函数、析构函数、虚函数可否声明为内联函数
首先,讲这些函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联。
模板与泛型编程
模板类和模板函数的区别是什么?
函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用而类模板只能显示调用。在使用时类模板必须加<T>,而函数模板不必
C++模板是什么,底层如何实现
-
编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。
-
这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。
模板特化、偏特化
编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化
- 全特化:将所有类型的参数全部明确指定
- 偏特化:将部分类型参数明确指定。
模板特例化:
特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。
template<typename T1, typename T2>
class Test{
public:
Test(T1 x, T2 y):a(x),b(y){}
private:
T1 a;
T2 b;
}
template<> //全特化,所有的类型参数全部指定
class Test<int, char> {
Test(int x, char y):a(x),b(y){}
private:
int a;
char b;
}
template<typename T> //偏特化,部分类型参数指定
class Test<T, char> {
Test(T x, char y):a(x),b(y){}
private:
T a;
char b;
}
内存管理与拷贝控制
内存安全管理
内存分区
C++程序在执行时,将供用户使用内存大致划分为四个区域:
(1)代码区:存放函数体的二进制代码,由操作系统进行管理;
(2)全局区:存放全局变量和静态(全局、局部)变量和字符串常量;
(3)栈区(stack):由编译器自动分配释放, 存放函数的参数值,局部变量等;
(4)堆区(heap):由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。
堆与栈的区别
- 申请方式不同:堆区是自行申请和释放,栈区是系统分配和释放。
- 申请空间大小、排布、限制不同:栈区是一片连续的空间,大小由操作系统预定好,在Windows下是2M,空间较小,由高地址向低地址生长,如果申请空间大于剩余空间,分配失败,栈溢出。堆区在内存中是不连续的(系统使用链表存储空闲内存地址,自然是不连续的),堆大小受限于计算机系统中的有效虚拟内存(32位机理论上为4G),堆区较大。
- 申请效率不同:栈有系统自动分配,申请效率高,堆由开发者申请,效率低,且容易产生内存碎片。
形象的比喻
栈就像我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
什么是内存池,如何实现
内存池(memory pool)是一种内存分配方式,通常我们习惯直接使用new、malloc等申请内存,这样做的缺点在于:由于所申请的内存块大小不定,当频繁使用时会造成大量的内存碎片,降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够在继续申请新的内存。这样的一个显著优点就是避免了内存碎片,使得内存分配效率得到提升。
《STL源码剖析》中的内存池实现机制:
allocate封装malloc,deallocate封装free。
一般是一次20 * 2(一个标准)个申请,先用一半,留着一半。
- 首先客户端会调用malloc()配置一定数量的区块(固定大小的内存块,通常为8的倍数),假设40个32byte的区块,其中20个区块(一半)给程序实际使用,一个区块交出,另外19个区块处于维护状态。剩余20个(一半,20*32byte)留给内存层。
- 客户端之后有内存需要,想申请(2064byte)个区块,这时内存池只有(2032byte),就先将(10*64byte)个区块返回,一个区块交出,另外9个处于维护状态,此时内存池空空如也。
- 接下来如果还有内存需要,就必须再调用malloc()配置空间,此时新申请的区块数量会增加一个随着配置次数越来越大的附加量,同样一半提供程序使用,另一半留给内存池。申请内存的时候,永远先看内存池有无剩余,有的话就先用上,然后挂在0-15号某一条链表上,要不然就重新申请。
- 如果整个堆的空间都不够了,就会在原先已经分配区块中寻找能满足当前需求的区块数量,如果能满足就返回,否则报错“bad_alloc”
C++中类的数据成员和成员函数内存分布情况
C++类是由结构体发展而来,所以他们的成员变量的内存分配机制是一样的。
- 一个类对象的地址就是类所包含的这一片内存空间的首地址,这个首地址也就对应具体某一个成员变量的地址。
- 成员函数不占用对象的内存,这是因为所有的函数都是存放在代码区的,不管是成员函数还是全局函数。
- 静态成员函数与一般的成员函数的唯一区别就是没有this指针,因此不能访问非静态数据成员。静态函数也不例外,存放在代码区。
什么是内存泄露,如何避免与检测
内存泄露:
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、 new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了
避免内存泄露的几种方式:
- 计数法:使用new或者malloc时,让该数加1,delete或free时,该数减1,程序执行完毕时打印这个数,如果不为0,表示存在内存泄露。
- 一定要将基类中的析构函数声明为虚函数。
- 对象数组的释放一定要使用delete[]
- 有new就有delete,有malloc就有free,保证他们成对出现。
new / delete / 与malloc / free的异同
- 相同点
- 都可以用于内存的动态申请和释放
- 不同点
- 从定义上讲,new与delte是C++运算符,malloc和free是C++标准库函数,因此new与delete,不依赖外部库函数文件,malloc和free则需要依赖外部库函数文件。
- 从使用上讲, new自动计算分配空间大小,malloc申请的空间大小需要手工计算; new是类型安全的,malloc不是。
- 从执行过程上讲:
new调用名为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 = 2
return 0;
}
什么情况下会调用拷贝构造函数。
- 用类的一个实例化对象去初始化另一个对象时。
- 函数的参数是类的对象时(非引用传递)
- 函数的返回值是函数体局部对象的类时,返回值调用拷贝构造函数。
说说移动构造函数
- 移动构造函数设计的初衷:当我们用对象a初始化对象b后,对象a不再使用了,但是对象a的空间还在(没有析构),既然拷贝的目的是为了把a对象的内容复制一份给b,那么使用移动构造函数能够直接使用a的空间,避免新空间的分配,大大降低了构造成本。
- 拷贝构造函数中,对于指针我们一定要采用深拷贝,而移动构造函数中,对于指针,我们采用浅拷贝,浅拷贝之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。
- 移动构造函数的参数和拷贝构造函数不同,拷贝构造参数是一个左值引用,移动构造函数的参数是一个右值引用。
成员函数里memset(this, 0, sizeof(*this))会发生什么
有时候类里边定义了很多int、char、struct等C语言里边那些类型的变量,可以使用memset(this, 0, sizeof(*this)),将整个对象内存全部置为0,简化一个一个初始化为0时的代码量,但包含下面两种情况不可以用:
- 类含有虚函数表:这样做会破坏虚函数表,后序对于虚函数的调用都将出现异常。
- 类中含有C++类型的对象,例如类中定义了一个list的对象,对于在构造函数函数体的代码执行之前就对list完成了初始化,假设list在它的构造函数里分配了内存,那么这样做就破坏了list对象的内存。
异常处理
常见错误类型
写C++代码时有一类错误是 coredump ,很常见,你遇到过吗?怎么调试这个错误?
coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。
标准模板库(Standard Template Library,STL)
C++STL从广义上讲包括了三类,算法,容器和迭代器。
- 算法包括排序、复制以及针对不同容器的特定算法
- 容器就是数据的存放形式,包括序列式容器(list、vector),关联式容器(set,map)。
- 迭代器就是在不暴露容器内部结构情况下对容器的遍历。
算法
容器
STL的两级空间配置器
首先明白为什么需要二级空间配置器?
我们知道动态开辟内存时,要在堆上申请,但若是我们需要频繁的在堆开辟释放内存,则就会在堆上造成很多外部碎片,浪费了内存空间;每次都要进行调用malloc、free函数等操作,使空间就会增加一些附加信息,降低了空间利用率;随着外部碎片增多,内存分配器在找不到合适内存情况下需要合并空闲块,浪费了时间,大大降低了效率。于是就设置了二级空间配置器,当开辟内存<=128bytes时,即视为开辟小块内存,则调用二级空间配置器。
- 一级配置器
一级空间配置器中重要的函数就是allocate、deallocate、reallocate 。 一级空间配置器是以malloc(),free(),realloc()等C函数执行实际的内存配置 。大致过程是:
1、直接allocate分配内存,其实就是malloc来分配内存,成功则直接返回,失败就调用处理函数
2、如果用户自定义了内存分配失败的处理函数就调用,没有的话就返回异常
3、如果自定义了处理函数就进行处理,完事再继续分配试试
- 二级配置器
STL中vector的实现
vector是一种序列式容器,其数据安排以及操作方式与array非常类似,两者的唯一差别就是对于空间运用的灵活性,众所周知,array占用的是静态空间,一旦配置了就不可以改变大小,如果遇到空间不足的情况还要自行创建更大的空间,并手动将数据拷贝到新的空间中,再把原来的空间释放。vector则使用灵活的动态空间配置,维护一块连续的线性空间,在空间不足时,可以自动扩展空间容纳新元素,做到按需供给。其在扩充空间的过程中仍然需要经历:重新配置空间,移动数据,释放原空间等操作。这里需要说明一下动态扩容的规则:以原大小的两倍配置另外一块较大的空间(或者旧长度+新增元素的个数),源码:
Vector扩容倍数与平台有关,在Win + VS 下是 1.5倍,在 Linux + GCC 下是 2 倍
STL中list的实现
list是双向链表,而slist(single linked list)是单向链表,它们的主要区别在于:前者的迭代器是双向的Bidirectional iterator,后者的迭代器属于单向的Forward iterator。虽然slist的很多功能不如list灵活,但是其所耗用的空间更小,操作更快。
根据STL的习惯,插入操作会将新元素插入到指定位置之前,而非之后,然而slist是不能回头的,只能往后走,因此在slist的其他位置插入或者移除元素是十分不明智的,但是在slist开头却是可取的,slist特别提供了insert_after()和erase_after供灵活应用。考虑到效率问题,slist只提供push_front()操作,元素插入到slist后,存储的次序和输入的次序是相反的
STL中的deque实现
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;//指向map
size_type map_size;//map的大小
public:
...
iterator begin();
itertator end();
...
}
deque内部有一个指针指向map,map是一小块连续空间,其中的每个元素称为一个节点,node,每个node都是一个指针,指向另一段较大的连续空间,称为缓冲区,这里就是deque中实际存放数据的区域,默认大小512bytes。整体结构如上图所示。
STL中的stack和queue的实现
stack这种单向开口的数据结构很容易由双向开口的deque和list形成,只需要根据stack的性质对应移除某些接口即可实现,stack的源码如下:
template <class T, class Sequence = deque<T> >
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 T, class Sequence = deque<T> >
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();}
};
STL中的heap实现
heap(堆)并不是STL的容器组件,是priority queue(优先队列)的底层实现机制,因为binary max heap(大根堆)总是最大值位于堆的根部,优先级最高。
binary heap本质是一种complete binary tree(完全二叉树),整棵binary tree除了最底层的叶节点之外,都是填满的,但是叶节点从左到右不会出现空隙,如下图所示就是一颗完全二叉树
STL中set的实现
标准的STL set以RB-tree(红黑树)作为底层机制,几乎所有的set操作行为都是转调用RB-tree的操作行为。
STL中map的实现
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的实现是怎样的?
STL中的hashtable使用的是开链法解决hash冲突问题,如下图所示。
解决hashtable冲突的方法有哪些?
- 开放地址法:从发生冲突的那个单元起,按照一定的次序,从哈希表中找到一个空闲的单元。然后把发生冲突的元素存入到该单元的一种方法。开放定址法需要的表长度要大于等于所需要存放的元素。线行探查法是开放定址法中最简单的冲突处理方法,它从发生冲突的单元起,依次判断下一个单元是否为空,当达到最后一个单元时,再从表首依次判断。直到碰到空闲的单元或者探查完全部单元为止。
- 拉链法:拉链法的思路是将哈希值相同的元素构成一个同义词的单向链表,并将单向链表的头指针存放在哈希表的第 i 个单元中,查找、插入和删除主要在同义词链表中进行。
- 再哈希法:发生冲突时使用另一种hash函数再计算一个地址,直到不冲突。
- 公共溢出区法:将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
迭代器
C++新特性
- nullptr替代NULL
- 引入了auto和decltype这两个关键字实现自动类型推导
- 基于范围的for循环
- 类和结构体中初始化成员列表
- lambda表达式(匿名函数)
- std::forward_list(单向链表)
- 右值引用和move语义
新语法及关键字
NULL和nullptr的区别
引入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 <iostream>
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、decltype和decltype(auto)的用法
auto让编译器通过初始值来进行类型推演。从而获得定义变量的类型,所以说auto定义的变量必须有初始值!!!
- 有时,我们希望从表达式中推断出定义变量的类型,但却不想用表达式的值去初始化变量,还有可能是函数的返回类型为某表达式的值传递,这时候auto就无力。C++11引入decltype,让编译器通过分析表达式得到它的类型,却不进行实际的计算。
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绑定在了一起
- decltype(auto)
decltype(auto)是C++14新增的类型指示符,可以用来声明变量以及指示函数的返回类型,在使用时,会将“=”号右边的表达式替换为auto,再根据decltype的语法规则来确定类型。例如:
int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e
智能指针
为什么引入智能指针?
- 引入智能指针一方面可以自动释放内存,减少出错,另一方面可以共享所有权指针的传播和释放,节省资源。
使用智能指针管理内存资源符合RALL,什么是RALL
- RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。
- 智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。
C++的四种智能指针
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。
- shared_ptr
实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向该对象,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。
- 智能指针将一个计数器与类指向的对象相关联,引用计数器跟踪共有多少个类对象共享同一指针。
- 每次创建类的新对象时,初始化指针并将引用计数置为1。
- 当对象作为另一个对象的副本而创建时,拷贝构造函数拷贝指针,并增加与之对应的引用计数。
- 对一个对象进行赋值时,赋值操作符较少左操作对象的引用计数(如果减至0,则删除对象),并增加右操作对象的引用计数。
- 调用析构函数时,构造函数减少引用计数(如果引用计数减至0,删除基础对象)。
- unique_ptr
unique_ptr采用的是独享所有权语义,一个非空的unique_ptr总是拥有它所指的对象资源,转移一个unique_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空;所以unique_ptr不支持普通的拷贝和赋值操作(可以移动拷贝和赋值)。 - weak_ptr
weak_ptr:弱引用,引用计数有一个问题就是互相引用形成环,这样两个指针指向的内存都无法释放,需要使用weak_ptr打破环形引用。weak_ptr是一个弱引用,它是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象,而不影响所指对象的生命周期,就是说它只引用,但不改变计数。如果一块内存同时被shared_ptr和weak_ptr引用,当所有的shard_ptr析构后,不管还有没有weak_ptr引用该内存,内存也会被释放,所以weak_ptr不保证它指向的内存一定是有效的,因此在使用之前一定要用函数lock()检查weak_ptr是否为空指针。 - auto_ptr
主要是为了解决“有异常抛出时发送内存泄露”的问题,因为发送异常而无法正常释放内存。
auto_ptr有拷贝语义,拷贝后源对象变得无效,这可能引发很严重的问题,而unique_ptr则无拷贝语义,但提供了移动语义。
智能指针shared_ptr的代码实现
template<typename T>
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<T>& 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* _ptr
int * _pcount;
};
说说你了解的auto_ptr的作用
- auto_ptr的出现,主要是为了解决“有异常抛出时的发生内存泄露” 的问题,抛出异常,将导致指针p所指向的空间得不到释放而导致内存泄漏;
- auto_ptr构造时取得某个对象的控制权,在析构时释放该对象。我们实际上是创建一个auto_ptr类型的局部对象,该局部对象析构时,会将自身所拥有的指针空间释放,所以不会有内存泄漏;
- auto_ptr的构造函数是explicit,阻止了一般指针隐式转换为 auto_ptr的构造,所以不能直接将一般类型的指针赋值给auto_ptr类型的对象,必须用auto_ptr的构造函数创建对象;
- 由于auto_ptr对象析构时会删除它所拥有的指针,所以使用时避免多个auto_ptr对象管理同一个指针;
- Auto_ptr内部实现,析构函数中删除对象用的是delete而不是delete[],所以auto_ptr不能管理数组;
- auto_ptr支持所拥有的指针类型之间的隐式类型转换。
- 可以通过*和->运算符对auto_ptr所有用的指针进行提领操作;
- T* get(),获得auto_ptr所拥有的指针;T* release(),释放auto_ptr的所有权,并将所有用的指针返回
lambda
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
// 函数体
}
语法规则:lambda表达式可以看成是一般函数的函数名被略去,返回值使用了一个 -> 的形式表示。唯一与普通函数不同的是增加了“捕获列表”。如果要指定lambda返回类型,必须是采用尾置返回值类型。
下边这段代码,将vi中每个元素转为它的相反数。
transform(vi.begin(), vi.end(), vi.begin(), [](int i) -> int{if(i < 0 return -i; else return i;});
多线程
互斥量
mutex/recursive_mutex
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 <iostream> // std::cout
#include <thread> // std::thread
#include <mutex> // 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
带超时的互斥量timed_mutex
和recursive_timed_mutex
std::timed_mutex比std::mutex多了两个超时获取锁的接口:try_lock_for
和try_lock_until
lock_guard和unique_lock
mutex
的使用需要.lock 和.unlock
,这就类似new
和delete
,相对于手动lock和unlock,我们可以使用RAII(通过类的构造析构管理资源,是C++中常用的管理资源,避免内存泄露的编程理念)来实现更好的编码方式。
unique_lock,lock_guard这两种锁都可以对std::mutex进行封装,在创建时自动加锁,在销毁时自动解锁。实现RAII的效果。
#include <iostream> // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::lock_guard
#include <stdexcept> // 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<std::mutex> 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;
}
lock_guard和unique_lock的区别
这两种锁都可以对std::mutex进行封装,实现RAII的效果。绝大多数情况下这两种锁是可以互相替代的,区别是unique_lock比lock_guard能提供更多的功能特性(但需要付出性能的一些代价),如下:
- unique_lock可以实现延时锁,即先生成unique_lock对象,然后在有需要的地方调用lock函数,lock_guard在对象创建时就自动进行lock操作了;
- unique_lock可以在需要的地方调用unlock操作,而lock_guard只能在其对象生命周期结束后自动Unlock;
正是由于这两个差异特性,unique_lock可以用于一次性锁多个锁以及用于条件变量的搭配使用,而lock_guard做不到。
总结:需要结合notify+wait的场景使用unique_lock; 如果只是单纯的互斥使用lock_guard
#include <iostream>
#include <deque>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <unistd.h>
std::deque<int> q;
std::mutex mu;
std::condition_variable cond;
int count = 0;
void fun1() {
while (true) {
// {
std::unique_lock<std::mutex> locker(mu);
q.push_front(count++);
locker.unlock(); // 这里是不是必须的?
cond.notify_one();
// }
sleep(1);
}
}
void fun2() {
while (true) {
std::unique_lock<std::mutex> 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;
}
条件变量condition_variable
互斥量是多线程间同时访问某一共享变量时,保证变量可被安全访问的手段。但单靠互斥量无法实现线程的同步。线程同步是指线程间需要按照预定的先后次序顺序进行的行为。C++11对这种行为也提供了有力的支持,这就是条件变量。条件变量位于头文件#include<condition_variable>
下。
条件变量使用过程:
- 拥有条件变量的线程获取互斥量
- 循环检查某个条件,如果条件不满足则阻塞直到条件满足,如果条件满足,则向下执行;
- 某个线程满足条件执行完毕之后,调用notify_one或notify_all唤醒一个或者所有等待的线程。
条件变量提供了两类操作:wait和notify。这两个操作,构成了多线程同步的基础。
写三个线程交替打印ABC
#include<iostream>
#include<thread>
#include<mutex>
#include<condition_variable>
using namespace std;
mutex mymutex;
condition_variable cv;
int flag = 0;
void printa() {
unique_lock<mutex> 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<mutex> 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<mutex> 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<int> count(0);
成员函数:store(), load()
count.store(x, std::memory_order_relaxed);
count.load(std::memory_order_relaxed);
#include<iostream>
#include<atomic>
#include<thread>
std::atomic<int> 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个接口
- std::future : 异步指向某个任务,然后通过future特性去获取任务函数的返回结果。
- std::aysnc: 异步运行某个任务函数。
- std::packaged_task :将任务和feature绑定在一起的模板,是一种对任务的封装。
- std::promise:期货(来自金融里的概念)
future/aysnc
future是期望的意思,期望得到一个返回值,就想执行函数的返回值。线程可以周期性的在这个future上等待一小段时间,检测future是否就绪(ready),如果没有,线程可以先去做另一个任务,如果就绪,则future无法复位(是一次性的)。
#include<future>
中声明了两种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
- std::launch::async,表明函数会在创建的新线程上运行。
- std::launch::defered表明该函数会被延迟调用,直到在future上调用get()或者wait()为止。
- std::launch::sync = std::launch::defered,表明该函数会被延迟调用
- std::launch::any = std::launch::defered | std::launch::async,表明该函数会被延迟调用,调用时在新线程上运行。
下边是使用的例子:
这里我们future(期望)了两个函数,第一个函数设置为异步,那么 std::future<int> result = std::async(std::launch::async,find_result_to_add);
,即future被创建时便开始创建新线程调用,而第二个采用默认参数,延迟调用,只有在result2.get()
时,才会创建一个新线程运行。
#include <iostream>
#include <future>
#include <thread>
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<int> result = std::async(std::launch::async,find_result_to_add);
//std::future<decltype (find_result_to_add())> 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<decltype (find_result_to_add2(0, 0))> 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;
}
packaged_task
std::packaged_tast是将任务和feature绑定在一起的模板,是一种对任务的封装
,后边可以通过调用get_feature()的方法获取到任务执行后返回的future
。packaged_task同样是一种模板,模板参数为函数名。
#include <iostream>
#include <future>
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<int(int, int, int)> task(add); // 封装任务
do_other_things();
std::future<int> result = task.get_future();
task(1, 1, 2); //必须要让任务执行,否则在get()获取future的值时会一直阻塞
std::cout << "result:" << result.get() << std::endl;
return 0;
}
promise
promise提供了一种设置值
的方式,他可以在这之后通过相关联
的std::future对象进行读取,换种说法,之前已经说过std::future可以读取一个函数的返回值了,那么promise
是提供了一种方式,手动让future就绪。具体可见下面的例子:
//1-5-promise
#include <future>
#include <string>
#include <thread>
#include <iostream>
using namespace std;
void print(std::promise<std::string>& 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<std::string> promise;
std::future<std::string> 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赋值。就像在金融市场中,你并没有生成货物,但是开出了期货
,作为提货的凭据,这个期货凭据,本质上是一种口头上的承诺
。可以在后边再生产兑现。