1979年C++诞生
1998 年,C++ 标准委员会发布了第一版 C++ 标准,并将其命名为 C++ 98 标准
截止到目前,C++ 的发展还历经了以下 4 个标准:
2003年,C++ 标准委员会在 2003 年对 C++ 98 标准做了一次修改(称为 C++ 03 标准),但由于其仅仅修复了一些 C++ 98 标准中存在的漏洞,并未修改核心语法,因此人们习惯将这次修订和 C++ 98 合称为 C++98/03 标准。
2011 年,新的 C++ 11 标准诞生,用于取代 C++ 98 标准。此标准还有一个别名,为“C++ 0x”;
2014 年,C++ 14 标准发布,该标准库对 C++ 11 标准库做了更优的修改和更新;
2017 年底,C++ 17 标准正式颁布。
以上 4个标准中,相比对前一个版本的修改和更新程度,C++ 11 标准无疑是颠覆性的,该标准在 C++ 98 的基础上修正了约 600 个 C++ 语言中存在的缺陷,同时添加了约 140 个新特性,这些更新使得 C++ 语言焕然一新。读者可以这样理解 C++ 11 标准,它在 C++ 98/03 标准的基础上孕育出了全新的 C++ 编程语言,造就了 C++ 新的开始。
2.1 auto类型推导
1、auto的作用:使用了 auto 关键字以后,编译器会在编译期间自动推导出变量的类型,这样我们就不用手动指明变量的数据类型了。
2、auto的基本使用方法:
auto n = 10; //10 是一个整数,默认是 int 类型,所以推导出变量 n 的类型是 int。
auto f = 12.8; //12.8 是一个小数,默认是 double 类型,所以推导出变量 f 的类型是 double。
auto p = &n; //&n 的结果是一个 int* 类型的指针,所以推导出变量 p 的类型是 int*。
vector::iterator it;
auto p = it;
我们也可以连续定义多个变量:
auto a = 10, b = 11;
注意:这里我们要注意,推导的时候不能有二义性。编译器会根据 a = 10 自动推导出auto是int类型,后面的 b 变量自然也为 int 类型,所以把 11 赋值给它也是正确的, 但是如果我们将b 赋值为 12.3就是错误的,因为 12.3 是double 类型,这和 int 是冲突的。
auto的限制:
使用 auto 的时候必须对变量进行初始化
auto x; //这种写法是错误的
auto 不能在函数的参数中使用
因为我们在定义函数的时候只是对参数进行了声明,指明了参数的类型,但并没有给它赋值,只有在实际调用函数的时候才会给参数赋值,而 auto 要求必须对变量进行初始化,所以这是矛盾的
void func(auto x) //错误的用法
{}
auto 关键字不能定义数组,比如下面的例子就是错误的:
char data[] = "hello ";
auto str[] = data; //data 为数组,所以不能使用 auto
2、auto的应用
使用 auto 定义迭代器:我们在使用 stl 容器的时候,需要使用迭代器来遍历容器里面的元素;不同容器的迭代器有不同的类型,在定义迭代器时必须指明。而迭代器的类型有时候比较复杂,书写起来很麻烦。我们可以在定义迭代器对象的时候使用auto
#include
using namespace std;
int main(){
vector< vector<int> > v;
auto i = v.begin(); //使用 auto 代替具体的类型
return 0;
}
2.2 decltype类型推导
1、decltype 是 C++11 新增的一个关键字,它和 auto 的功能一样,都用来在编译时期进行自动类型推导
2、auto使用的前提是:必须要对auto声明的类型进行初始化,否则编译器无法推导出auto的实际类型 。但有时候可能需要根据表达式运行完成之后,对结果的类型进行推导,因为编译期间,代码不会运行,此时auto也就无能为力。
3、我们来看这么一段代码:
template
T1 Add(const T1& left, const T2& right)
{
return left + right;
}
如果能用 加完之后结果的实际类型作为函数的返回值类型就不会出错 ,但这需要程序运行完才能知道结果的实际类型,即**RTTI(Run-Time Type Identification 运行时类型识别)。**运行时类型识别的缺陷是降低程序运行的效率。
4、decltype是根据表达式的实际类型推演出定义变量时所用的类型 ,比如:
推演表达式类型作为变量的定义类型
#include
using namespace std;
class Student{
public:
static int total;
string name;
int age;
float scores;
};
int Student::total = 0;
int main() {
short a = 32670;
short b = 32670;
// 用decltype推演a+b的实际类型,作为定义c的类型
decltype(a + b) c;
cout << typeid(c).name() << endl; // int
int n = 0;
const int &r = n;
decltype(n) x = n; //n 为 int 类型,x 被推导为 int 类型
decltype(r) y = n; //r 为 const int& 类型, y 被推导为 const int& 类型
decltype(Student::total) c = 0; //total 为类 Student 的一个 int 类型的成员变量,c 被推导为 int 类型
decltype(stu.name) url = "hello"; //total 为类 Student 的一个 string 类型的成员变量, url 被推导为 string 类型
return 0;
}
推演函数返回值的类型
void* GetMemory(size_t size)
{
return malloc(size);
}
int main() {
// 如果没有带参数,推导函数的类型
cout << typeid(decltype(GetMemory)).name() << endl; // void * __cdecl(unsigned int)
// 如果带参数列表,推导的是函数返回值的类型,注意:此处只是推演,不会执行函数
cout << typeid(decltype(GetMemory(0))).name() <<endl; // void *
return 0;
}
2.3 基于范围的for循环
C++ 11 标准中为 for 循环添加了一种全新的语法格式,如下所示:
for (declaration : expression){
//循环体
}
其中,两个参数各自的含义如下:
declaration:表示此处要定义一个变量,该变量的类型为要遍历序列中存储元素的类型。需要注意的是,C++ 11 标准中,declaration参数处定义的变量类型可以用 auto 关键字表示,该关键字可以使编译器自行推导该变量的数据类型。
expression:表示要遍历的序列,常见的可以为事先定义好的普通数组或者容器,还可以是用 {} 大括号初始化的序列。
vector v = {1,2,3,4,5};
for (auto item : v)
cout << item << endl;
在使用新语法格式的 for 循环遍历某个序列时,如果需要遍历的同时修改序列中元素的值,实现方案是在 declaration 参数处定义引用形式的变量:
vector v = {1,2,3,4,5};
for (auto &item : v)
item++;
2.4 列表初始化
在 C++11 中,初始化列表的适用性被大大增加了。它现在可以用于任何类型对象的初始化。
class Foo
{
public:
int _x;
Foo(int x):_x(x) //列表初始化
{}
private:
Foo(const Foo &);
};
int main(void)
{
Foo a1(123);
Foo a2 = 123; //error: 'Foo::Foo(const Foo &)' is private
Foo a3 = { 123 };
Foo a4 { 123 };
int a5 = { 3 };
int a6 { 3 };
return 0;
}
2.5 使用{}进行初始化
在C++98中,标准允许使用花括号{}对数组元素进行统一的列表初始值设定。比如:
int array1[] = {1,2,3,4,5};
int array2[5] = {0};
C++11标准提供了使用{}对标准容器的初始化:
// 动态数组,在C++98中不支持
int* arr3 = new int[5]{1,2,3,4,5};
// 标准容器
vector<int> v{1,2,3,4,5};
map<int, int> m{{1,1}, {2,2},{3,3},{4,4}};
vector<int> v1 = {1,2,3,4,5};
map<int, int> m1 = {{1,1}, {2,2},{3,3},{4,4}};
使用{}对自定义类型的列表初始化:
class Point
{
public:
Point(int x = 0, int y = 0):
_x(x),
_y(y)
{}
private:
int _x;
int _y;
};
int main() {
Point p{ 1, 2 };
return 0;
}
2.6 使用using定义别名
在C语言和C++中可以通过 typedef 重定义一个类型:
typedef unsigned int uint_t;
使用using定义别名:
using db = double; //c++11
typedef void(*function)(int, int);//c99,函数指针类型定义
using function = void(*)(int, int);//c++11,函数指针类型定义
// 重定义std::map
typedef std::map
using map_int_t = std::map
2.7 final关键字
1、final关键字用来修饰类表示该类不可被继承。如下代码编译不通过:
class A final
{
A(){}
void a(){ cout << "hello world A!";}
};
class B : public A
{
B(){}
void b(){ cout << "hello world B!";}
};
2、final 用来修饰虚函数,则表示该虚函数不可被子类重写。如下代码编译不通过:
class C
{
public:
C(){}
virtual void c() final { cout << "hello world C!";}
};
class D : public C
{
public:
D(){}
void c(){ cout << "hello world D!";}
};
2.8 右值引用
1、左值和右值
表示的是可以 获取地址的表达式 ,它能出现在赋值语句的左边,对该表达式进行赋值。但是修饰符const的出现使得可以声明如下的标识符,它可以取得地址,但是没办法对其进行赋值。
const int& a = 10;
右值:表示无法获取地址的对象,有常量值、函数返回值、lambda表达式等。无法获取地址,但不表示其不可改变,当定义了右值的右值引用时就可以更改右值。
注意:判断一个值到底是右值还是左值并不是由这个值在赋值表达式中的位置来决定的!!!
int b = 10; // b 是一个左值
a = b; // a、b 都是左值,只不过将 b 可以当做右值使用
2、左值的引用
在 C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:
int num = 10;
int &b = num; //正确
int &c = 10; //错误
如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。
3、右值引用
之所以要进行右值引用,是因为右值往往是没有名称的,在实际开发中我们可能需要对右值进行修改,因此要使用它只能借助引用的方式。
C++标准委员会在选定右值引用符号时,既希望能选用现有 C++ 内部已有的符号,还不能与 C++ 98 /03 标准产生冲突,最终选定了 2 个 ‘&’ 表示右值引用。
类型&& 引用变量名字 = 右值;
int num = 10;
//int && a = num; //右值引用不能初始化为左值
int && a = 10;
右值引用可以对右值进行修改,例如:
int && a = 10;
a = 100;
cout << a << endl;
//程序输出结果为 100。
4、右值引用的实际用途
(1)移动语义
如果一个类中涉及到资源管理(比如指针),用户必须显式提供拷贝构造、赋值运算符重载以及析构函数,否则编译器将会自动生成一个默认的,如果遇到拷贝对象或者对象之间相互赋值,就会出错,比如:
class String
{
public:
String(char* str = "")
{
cout << "String(char* str)" << endl;
if (nullptr == str)
str = "";
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
String(const String& s)
: _str(new char[strlen(s._str) + 1])
{
cout << " String(const String& s)" << endl;
strcpy(_str, s._str);
}
String& operator=(const String& s)
{
if (this != &s)
{
char* pTemp = new char[strlen(s._str) +1];
strcpy(pTemp, s._str);
delete[] _str;
_str = pTemp;
}
return *this;
}
~String()
{
if (_str)
delete[] _str;
}
friend ostream &operator << (ostream &out, const String &t)
{
out << t._str ;
return out;
}
private:
char* _str;
};
假设现在有一个函数,返回值为一个String类型的对象:
String GetString(char* pStr)
{
String strTemp(pStr);
return strTemp;
}
int main() {
String s1("hello");
String s2(GetString("world"));
return 0;
}
我们思考一下以上代码可能会有什么问题?
imagepng
上述代码看起来没有什么问题,但是有一个不太尽人意的地方:GetString函数返回的临时对象,将s2拷贝构造成功之后,立马被销毁了(临时对象 的空间被释放),再没有其他作用;而s2在拷贝构造时,又需要分配空间,一个刚释放一个又申请,有点多此一举。那能否将GetString返回的临时对象的空间直接交给s2呢? 这样s2也不需要重新开辟空间了,代码的效率会明显提高。
注意:我们在编译运行时发现并没有调用拷贝构造函数,其实这是编译器在编译时对拷贝构造函数的调用进行了优化,我们可以使用使用命令对程序进行编译
g++ main.cpp -fno-elide-constructors
imagepng
将一个对象中资源移动到另一个对象中的方式,称之为移动语义。在C++11中如果需要实现移动语义,必须使用右值引用。
String(String&& s): _str(s._str) //移动构造函数
{ s._str = nullptr; }
通过实现一个参数为右值引用的构造函数,在构造函数中没有重新分配空间,这样可以提高程序运行的效率。
总结:右值引用与移动语义结合,减少无必要资源的开辟来提高代码的运行效率。
总结:
我们用对象a初始化对象b,然后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间;
移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。
(2)move函数
C++11中,std::move()函数位于头文件中,这个函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,通过右值引用使用该值,实现移动语义。
注意:被转化的左值,其声明周期并没有随着左右值的转化而改变,即std::move转化的左值变量lvalue不会被销毁。
为了保证移动语义的传递,程序员在编写移动构造函数时,最好使用std::move转移拥有资源的成员为右值。
#if 0
A(A&& s):_str(s._str)
{
cout << "A(A&& s):_str(s._str)" << endl;
s._str = nullptr;
}
#endif
A(A&& s):_str(move(s._str))
{
cout << "A(A&& s):_str(s._str)" << endl;
}
2.9 lambda表达式
1、我们回顾一下:如果想要对一个数据集合中的元素进行排序,可以使用std::sort方法,如果待排序元素为自定义类型,需要用户定义排序时的比较规则:
struct Goods
{
string _name;
double _price;
};
classCompare
{
public:
bool operator()(const Goods& gl, const Goods& gr)
{
return gl._price <= gr._price;
}
};
int main() {
Goods gds[] = { { "苹果", 2.1 }, { "香蕉", 3 }, { "橙子", 2.2 }, {"菠萝", 1.5} };
sort(gds, gds+sizeof(gds) / sizeof(gds[0]), Compare());
return 0;
}
随着C++语法的发展, 人们开始觉得上面的写法太复杂了,每次为了实现一个algorithm算法, 都要重新去写一个类,如果每次比较的逻辑不一样,还要去实现多个类,特别是相同类的命名,这些都给编程者带来了极大的不便 。因此,在C11语法中出现了Lambda表达式。
int main() {
Goods gds[] = { { “苹果”, 2.1 }, { “相交”, 3 }, { “橙子”, 2.2 }, {“菠萝”, 1.5} };
sort(gds, gds + sizeof(gds) / sizeof(gds[0]), [](const Goods& l, const Goods& r)
{
return l._price < r._price;
});
return 0;
}
上述代码就是使用C++11中的lambda表达式来解决,可以看出lambda表达式实际是一个匿名函数。
2、lambda表达式语法
[capture-list] (parameters) mutable -> return-type { statement }
各部分说明:
[capture-list] : 捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用
(parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略
mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空)
->return-type:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
{statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
注意: 在lambda函数定义中, 参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空 。 因此C++11中 最简单的lambda函数为:[]{} ; 该lambda函数不能做任何事情。
#include
#include
using namespace std;
int g;
int main()
{
// 最简单的lambda表达式, 该lambda表达式没有任何意义
[] {};
// 省略参数列表和返回值类型,返回值类型由编译器推导为int
int a = 3, b = 4;
[a] {return a + 3; };
// 省略了返回值类型,无返回值类型
// g不是捕捉到的,而是因为g是全局变量,所以可以直接使用。
auto fun1 = [&](int c) {g = 8; b = a + c; };
fun1(10);
cout << a << " " << b << endl; // 3 13
// 各部分都很完善的lambda函数
// =捕获副作用域所有变量,其中b是由引用方式捕获可以被更改
// 在此a不能被更改,若想修改需要加上mutable关键字,即
// auto fun2 = [=, &b](int c)mutable->...
auto fun2 = [=, &b](int c)->int {return b += a + c; };
//auto fun2 = [&, a](int c)->int{return b += a + c; };
// 前后捕获不可重复,引用、变量均不可重复
//auto fun2 = [&, &a](int c)->int{return b += a + c; };
//auto fun2 = [=, a](int c)->int{return b += a + c; };
cout << fun2(10) << endl; // 26
// 复制捕捉x
int x = 10;
auto add_x = [x](int a) mutable { x *= 2; return a + x; };
cout << add_x(10) << endl; // 30
cout << g; // 8
return 0;
}
通过上述例子可以看出,lambda表达式实际上可以理解为匿名函数,该函数无法直接调用,如果想要直接调用,可借助auto将其赋值给一个变量。
1、为什么需要智能指针?
为了更好的解决 内存泄漏 所以出现了智能指针
当我们malloc或者new出来的内存没有去释放时就会出现问题,就会存在内存泄漏问题。
异常安全问题:如果在malloc/new和free/delete之间如果存在抛异常,那么还是有内存泄漏。
当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露(没有将基类的析构函数定义为虚函数)
2、什么是内存泄漏
内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
void func()
{
string str = new(string(“hello”));
}
3、内存泄漏分类
C/C++程序中一般我们关心两种方面的内存泄漏:
堆内存泄漏(Heap leak):堆内存指的是程序执行中依据须要分配通过 malloc / calloc / realloc / new等 从堆中分配的一块内存,用完后必须通过调用相应的 free 或者 delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
系统资源泄漏:指程序使用系统分配的资源,比方 套接字、文件描述符、管道等 没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
为了更加容易(更加安全)的使用动态内存,引入了智能指针的概念。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。
(1)RAII(资源获取就是初始化)
RAII(Resource Acquisition Is Initialization)是一种 利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
不需要显式地释放资源。
对象所需的资源在其生命期内始终保持有效。
#include
#include
using namespace std;
// 使用RAII思想设计的SmartPtr类
template<class T>
class SmartPtr {
public:
SmartPtr(T* ptr = nullptr)
: _ptr(ptr)
{}
~SmartPtr(){
if (_ptr)
delete _ptr;
}
private:
T* _ptr;
};
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
// 将tmp指针委托给了sp对象,相当给tmp指针找了一个可怕的女朋友!天天管着你,直到你go die^^
SmartPtr<int> sp(tmp);
}
上述的SmartPtr还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->去访问所指空间中的内容,因此: AutoPtr模板类中还得需要将 * 、->重载下,才可让其像指针一样去使用。
template <class T>
class smart_ptr
{
T* m_ptr;
public:
smart_ptr(T* ptr = nullptr)
: m_ptr(ptr)
{}
~smart_ptr(){
if (m_ptr){
delete[] m_ptr;
m_ptr = nullptr;
}
}
T& operator *(){
return *m_ptr;
}
T* operator ->(){
return m_ptr;
}
T& operator [](int i){
return m_ptr[i];
}
};
(2) auto_ptr (C++98/03)
auto_ptr是这样一种指针:利用管理权转移的思想, 它是“它所指向的对象”的拥有者。这种拥有具有唯一性,即一个对象只能有一个拥有者,严禁一物二主。当auto_ptr指针被摧毁时,它所指向的对象也将被隐式销毁,即使程序中有异常发生,auto_ptr所指向的对象也将被销毁。
#include
#include // C++库中的智能指针都定义在memory这个头文件中
using namespace std;
class Test
{
public:
int m_a;
};
int main()
{
auto_ptr<int> ap(new int);
auto_ptr<Test> apt(new Test);
*ap = 5;
apt->m_a = 6;
cout << *ap << ' ' << apt->m_a << endl;
auto_ptr<int> ap2 = ap;
cout << *ap2 << ' ' << apt->m_a << endl;
return 0;
}
auto_ptr的实现:
template<class T>
class AutoPtr
{
public:
AutoPtr(T* ptr = NULL)
: _ptr(ptr)
{}
~AutoPtr() {
if (_ptr)
delete _ptr;
}
// 一旦发生拷贝,就将ap中资源转移到当前对象中,然后另ap与其所管理资源断开联系,
// 这样就解决了一块空间被多个对象使用而造成程序奔溃问题
AutoPtr(AutoPtr<T>& ap)
: _ptr(ap._ptr)
{
ap._ptr = NULL;
}
AutoPtr<T>& operator=(AutoPtr<T>& ap)
{
// 检测是否为自己给自己赋值
if(this != &ap)
{
// 释放当前对象中资源
if(_ptr){
delete _ptr;
}
// 转移ap中资源到当前对象中
_ptr = ap._ptr;
ap._ptr = NULL;
}
return *this;
}
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
private:
T* _ptr;
};
(3)unique_ptr(C++11)
unique_ptr的设计思路非常的粗暴:防拷贝,也就是不让拷贝和赋值
#include
#include
using namespace std;
class Test
{
public:
int m_a;
};
int main()
{
unique_ptr<int> ap(new int);
unique_ptr<Test> apt(new Test);
*ap = 5;
apt->m_a = 6;
cout << *ap << ' ' << apt->m_a << endl;
// unique_ptr ap2 = ap;
// cout << *ap2 << ' ' << apt->m_a << endl;
return 0;
}
(3) shared_ptr(C++11)
shared_ptr:通过引用计数支持智能指针对象的拷贝。
#include
#include
using namespace std;
class Date
{
public:
Date() { cout << "Date()" << endl; }
~Date() { cout << "~Date()" << endl; }
int _year;
int _month;
int _day;
};
int main() {
shared_ptr<Date> sp(new Date);
shared_ptr<Date> copy(sp);
cout << "ref count:" << sp.use_count() << endl;
cout << "ref count:" << copy.use_count() << endl;
return 0;
}
shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源
shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享;
在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一;
如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;
如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。