左值与右值?
左值:能取到地址;
右值:取不到地址;
左值引用:
- 左值引用只能引用左值,不能引用右值;
- 但被const修饰的左值引用还是能够引用右值的,如:
const int& rx = 10; const int& ry = add(10,20);
右值引用:
- 右值引用只能引用右值,不能引用左值;
- 但可以引用move以后的左值,如:
int y=10; int && rx=10; int && ry=move(y);
右值引用的意义?
引用的意义:函数传递参数时会拷贝参数,数据量很多的参数拷贝效率很低。引用的作用就是减少拷贝,如:
template<class T>
void func1(const T& x)
{
cout << "func1" << endl;
}
int main()
{
vector<int> v1(10, 0);
func1(v1);//传递的时左值
func1(vector<int>(10, 0));//传递的是右值
return 0;
}
但左值引用解决了所有拷贝问题吗?好像没有,如:
template<class T>
T func3(const T& x)
{
cout << "func3" << endl;
T x;
return x;
}
int main()
{
vector<int> v1(10, 0);
func3(v1);//传递的时左值
func3(vector<int>(10, 0));//传递的是右值
return 0;
}
右值经过右值引用后属性会变成左值
右值引用之后编译器会开辟一个空间来存储右值的数据,这个空间的地址可以通过取地址操作符得到,说明右值经过右值饮用后属性会变成一个左值;如下:
void Fun(int& x) { cout << "左值引用" << endl; } void Fun(const int& x) { cout << "const 左值引用" << endl; } void Fun(int&& x) { cout << "右值引用" << endl; } void Fun(const int&& x) { cout << "const 右值引用" << endl; } template<class T> void func1(T&& x) { Fun(x); } int main() { int x = 10; const int y = 20; func1(x);//左值 func1(20);//右值 func1(y);//const 左值 func1(move(y));//const 右值 return 0; }
运行结果:func1(20);//右值 在进入Fun(x);时已经变成左值属性;
完美转换 -- 保持原来的属性
void func1(T&& x) { //保持原来的属性 Fun(std::forward<T>(x)); }
万能引用?---模板的右值引用
面对左值我们得写一个函数来单独进行处理,面对右我们又得写一个函数来单独进行单独处理,是否有个通用引用?
void func1(int& x) { cout << "func1(int &)" << endl; } void func1(int&& y) { cout << "func2(int &&)" << endl; } int main() { int x = 10; func1(x); func1(10); return 0; }
模板的右值引用即可以引用左值也可以引用右值,我们把这样的引用称为万能引用,这里不仅能传左值和右值还能传const左值和const 右值,比如说下面的代码:
template<class T> void func1(T&& x) { cout << "func1" << endl; } int main() { int x = 10; const int y = 20; func1(x);//左值 func1(10);//右值 func1(y);//const 左值 func1(move(y));//const 右值 return 0; }
范围for
void test4() { vector<int> v1{ 1,2,3,4,5 }; for (auto ch : v1) { cout << ch << " "; } cout << endl; vector<int>::iterator it1 = v1.begin(); while (it1 != v1.end()) { cout << *it1 << " "; ++it1; } cout << endl; }
关键字
final
- 在类定义中的使用: 在类定义中,将
final
修饰符放在类名称的后面,表示该类是不可继承的,任何试图从该类派生出新类的操作都将导致编译错误。例如:
cpp Copy Code class Base final { // 类成员和函数声明 };
- 在成员函数声明中的使用: 将
final
放在成员函数声明后面,表示该成员函数不可被派生类重写。例如:
class Base { public: virtual void foo() final; }; class Derived : public Base { public: // 下面的代码会导致编译错误,因为试图重写被声明为 final 的虚函数 // void foo() override; };
- 在虚函数覆盖中的使用: 将
final
放在派生类中覆盖的虚函数声明后面,表示该虚函数不可被进一步派生类重写。例如:
class Base { public: virtual void foo(); }; class Derived : public Base { public: void foo() final; // Derived 类中的 foo 函数不可被进一步派生类重写 };
final
关键字的引入使得在设计类层次结构时可以更加明确地控制继承和函数重写的行为,提高了代码的可读性和可维护性。
default
在 C++11 中,default
是一个关键字,用于指示编译器生成默认的特殊成员函数的实现。它可以应用于默认构造函数、复制构造函数、移动构造函数、赋值操作符和析构函数。
- 默认构造函数: 如果一个类没有显式定义构造函数,编译器会自动生成默认构造函数。但如果类定义了其他构造函数,编译器不会自动生成默认构造函数。此时,可以通过在类内声明中使用
default
来显式指示编译器生成默认构造函数。例如:
class MyClass { public: // 使用 default 指示编译器生成默认构造函数 MyClass() = default; };
- 复制构造函数和赋值操作符: 在某些情况下,我们可能需要显式声明复制构造函数和赋值操作符,并指示编译器使用默认实现。这可以通过使用
default
来实现。例如:
class MyClass { public: // 使用 default 指示编译器生成默认的复制构造函数 MyClass(const MyClass&) = default; // 使用 default 指示编译器生成默认的赋值操作符 MyClass& operator=(const MyClass&) = default; };
- 移动构造函数和移动赋值操作符: 类似于复制构造函数和赋值操作符,我们也可以使用
default
来指示编译器生成默认的移动构造函数和移动赋值操作符。例如:
class MyClass { public: // 使用 default 指示编译器生成默认的移动构造函数 MyClass(MyClass&&) noexcept = default; // 使用 default 指示编译器生成默认的移动赋值操作符 MyClass& operator=(MyClass&&) noexcept = default; };
- 析构函数: 如果类不需要显式定义析构函数,可以使用
default
来指示编译器生成默认析构函数。例如:
class MyClass { public: // 使用 default 指示编译器生成默认析构函数 ~MyClass() = default; };
总之,default
关键字的主要作用是告诉编译器使用默认的实现来生成特殊成员函数,以简化代码并遵循 C++ 的语义。
noexcept
noexcept
是 C++11 中引入的关键字,用于指示函数是否抛出异常。它可以用于函数声明和函数定义中。
- 函数声明中的使用: 在函数声明中,
noexcept
关键字用于指示该函数是否会抛出异常。如果函数不会抛出异常,则可以使用noexcept
关键字来声明该函数。例如:
void myFunction() noexcept;
这表示 myFunction
函数不会抛出任何异常。
- 函数定义中的使用: 在函数定义中,
noexcept
关键字用于确保函数不会抛出异常。如果函数在定义时标记为noexcept
,但实际上发生了异常,程序将会终止运行。例如:
void myFunction() noexcept { // 函数体不会抛出异常 }
- 条件
noexcept
:noexcept
还可以与表达式一起使用,用于指示某些操作是否可能抛出异常。例如,可以使用noexcept
运算符来检查某个表达式是否会抛出异常。例如:
cpp Copy Code bool isNoExcept = noexcept(myFunction());
- 移动构造函数和移动赋值操作符中的
noexcept
: 在移动构造函数和移动赋值操作符中,通常应该标记为noexcept
,以提高性能并确保在移动语义下不会抛出异常。例如:
cpp Copy Code class MyClass { public: // 移动构造函数标记为 noexcept MyClass(MyClass&&) noexcept; // 移动赋值操作符标记为 noexcept MyClass& operator=(MyClass&&) noexcept; };
总之,noexcept
关键字用于指示函数是否会抛出异常,这对于优化和确定代码行为非常有用。
delete
如何让一个类无法被拷贝呢?答案有很多个,其中一个就是将拷贝构造私有比如说下面这样的代码:
class A { public: A(){} ~A(){delete[] p;} private: A(const A& aa) :p(aa.p) {} private: int* p = new int[10]; }; int main() { A aa1; A aa2(aa1); //报错 return 0; }
但是这么写只能保证在类外面无法被拷贝,但是在里面依然还是可以使用拷贝构造的,比如说下面的代码:
class A { public: void func(){A tmp(*this);} A(){} ~A(){delete[] p;} private: A(const A& aa) :p(aa.p) {} private: int* p = new int[10]; }; int main() { A aa1; aa1.func(); return 0; }
并且将这段代码运行一下还会发现这里报错了,因为这里的拷贝是浅拷贝导致了同一块空间被析构了两次:
第二个种方式就是只声明不实现。这样的话调用拷贝构造函数就无法正常的链接从而阻止拷贝,那么这种方法就是c++98采用的方法,这里的代码如下:
class A { public: void func(){A tmp(*this);} A(){} ~A(){delete[] p;} private: A(const A& aa); private: int* p = new int[10]; }; int main() { A aa1; aa1.func(); //报错 return 0; }
第三种方式就是delete,被delete修饰的函数就称为被删除的函数无法被调用,编译器不生成对应函数的默认版本比如说下面 的代码:
class A { public: void func(){A tmp(*this);} A(){} ~A(){delete[] p;} // C++11 A(const A& aa) = delete; private: int* p = new int[10]; }; int main() { A aa1; aa1.func(); //报错 return 0; }
lambda表达式 --- 增加可读性
函数指针 --- 复杂、不易读
在学习c语言的时候我们学习过一个东西叫做函数指针,这个东西可以让我们更加方便的使用函数传递函数,但是这个东西在遇见一些比较复杂的情况时会变得很难理解,比如说下面的代码:
void(*func(void (*f)()))()
大家知道这是一个什么东西吗?很复杂对吧!上面代码表示的意思就是一个函数的参数是一个函数指针这个函数不需要参数并且没有返回类型,并且这个函数的返回值也是一个函数指针并且这个函数不需要参数也没有返回值,听起来很简单但是写起来就很复杂那么这就是c语言函数指针的缺点,那么为了解决这个缺点c++就提出来一个东西叫做仿函数,它可以大大的降低函数指针的可读性,比如说下面的代码:
仿函数 --- 起名困难,不复用
#include <algorithm> #include <functional> struct Goods { string _name; // 名字 double _price; // 价格 int _evaluate; // 评价 // ... Goods(const char* str, double price, int evaluate) :_name(str) , _price(price) , _evaluate(evaluate) {} }; struct ComparePriceLess { bool operator()(const Goods& gl, const Goods& gr) { return gl._price < gr._price; } }; int main() { vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } }; sort(v.begin(), v.end(), ComparePriceLess()); return 0; }
这里我们创建了一个可以比较物品价格的仿函数,有了这个仿函数之后就可以联合sort函数来对数组里面的数据进行价格上的排序;
如果我想让按照价格降序进行排序又得创建一个不一样的仿函数来实现差不多的内容,比如说下面的代码:
struct ComparePriceLess { bool operator()(const Goods& gl, const Goods& gr) { return gl._price < gr._price; } }; struct ComparePriceGreater { bool operator()(const Goods& gl, const Goods& gr) { return gl._price > gr._price; } }; int main() { vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } }; sort(v.begin(), v.end(), ComparePriceLess()); sort(v.begin(), v.end(), ComparePriceGreater()); return 0; }
lambda表达式 --- 易读性
#include <algorithm> #include <functional> struct Goods { string _name; // 名字 double _price; // 价格 int _evaluate; // 评价 // ... Goods(const char* str, double price, int evaluate) :_name(str) , _price(price) , _evaluate(evaluate) {} }; int main() { vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } }; sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) { return g1._price < g2._price; }); sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) { return g1._price > g2._price; }); return 0; }
lambda表达式用法
[capture-list](parameters) mutable ->return-type{statement}
- [capature-list]:捕捉列表,该列表总是出现在lambda函数开始的位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量提供的lambda函数使用。必须写
- parameters:参数列表,与普通函数的参数列表一致,如果没有参数传递则可以连同()一起省略。有参数就写没有参数就可以不写
- mutable:默认情况下lambda函数总是一个const函数,而添加了mutable便可以取消其常量属性。使用该修饰符时,即使参数列表为空这里也依然不能省略,那么这个东西一般可以不写。
- ->returntype:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可以省略。返回值类型明确的情况下也可以省略,因为编译器会自动的对返回类型进行推导。
- {statement}:函数体。在函数体内除了可以使用其参数外,还可以使用所有捕获的变量。这个必须要写的。
[](int x, int y){ return x>y ;};//没有添加返回值的形式 [](int x, int y)->bool{ return x>y ;};//添加了返回值的形式 如何使用这个lambda表达式呢?答案是先创建一个lambda对象来接收这个表达式然后在用这个 对象来使用这个表达式: int main() { auto compare= [](int x, int y)->bool { return x > y; }; if (compare(1, 2)) { cout << "第一个参数大" << endl; } else { cout << "第二个参数大" << endl; } return 0; }
使用外部变量
int main() { int a = 1; int b = 2; auto add1 = [](int x, int y) {return x + y; }; cout << add1(a, b) << endl; auto add2 = [a,b]() {return a + b;}; cout << add2() << endl; return 0; } 这里的捕捉默认情况下是传值捕捉,外部的a和内部的a不是同一个a并且内部的a还添加了一个 const进行修饰,如果你想要去掉其常性的话就得添加mutable,比如说下面的代码: int main() { int a = 1; int b = 2; auto add2 = [a, b]() mutable {a++; b++; }; cout << a << " " << b; return 0; } 虽然这里的代码运行结果没有报错但是普通捕捉里面修改捕捉变量的值是不会改变外部的被捕捉 变量的,如果想要改变的话就得使用引用捕捉,那么这里的代码就如下: int main() { int a = 1; int b = 2; auto add2 = [&a, b]() mutable {a++; b++; }; add2(); cout << a << " " << b; return 0; } 除了这种争对单独变量的捕捉c++11还提供了一些特殊的捕捉方式: [=]:表示值传递方式捕获所有父作用域中的变量(包括this),这里的父作用域指的是lambda外面一层的作用域 [&]:表示引用传递捕捉所有父作用域中的变量(包括this) [this]:表示值传递方式捕捉当前的this指针 [=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量 [&,a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量 但是这里有几点需要大家注意一下, 第一点:捕捉列表不允许变量重复传递,否则就会编译错误。 比如: [=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复, 第二点:捕捉列表在捕捉变量的时候只会向上捕捉,对于在父作用域但是位于捕捉列表下方的 变量是捕捉不到的,比如说下面的代码: int main() { int a = 1; int b = 2; auto add2 = [=]() {cout << c << endl; }; //报错,捕捉不到c int c = 3; add2(); return 0; }
lambda的底层
class Rate { public: Rate(double rate) : _rate(rate) {} double operator()(double money, int year) { return money * _rate * year; } private: double _rate; }; int main() { // 函数对象 double rate = 0.49; Rate r1(rate); r1(10000, 2); // lamber auto r2 = [=](double monty, int year)->double {return monty * rate * year; }; r2(10000, 2); return 0; }
仿函数对象反汇编:
创建仿函数对象的时候是调用Rate类的构造函数来进行创建,
在调用仿函数对象来执行内容的时候是调用的operator()重载来实现具体的内容,
lambda表达式的反汇编:
跟仿函数一样,首先调用lambda表达式的构造函数创建对象,然后调用对象的时候使用operator重载来实现具体内容;
可变参数模板
C++11 之前,这通常通过函数重载、使用 va_list
和 va_arg
等宏来实现。
自 C++11 开始,引入了更安全、更易用的可变参数模板(Variadic Templates)机制,使得处理可变数量参数的函数更加直观和类型安全。
第一种写法 --- 递归打印参数包内容
#include <iostream> // 递归终止函数 void print() { std::cout << std::endl; } // 可变参数模板函数 template<typename T, typename... Args> void print(T first, Args... args) { std::cout << first << " "; print(args...); } int main() { print(1, 2.5, "hello", 'a'); return 0; }
在上面的示例中,print
函数是一个可变参数模板函数。它的参数列表中使用了模板参数包(typename... Args
),表示接受任意数量的参数。
print()
函数是一个递归终止函数,用于结束递归调用。print(T first, Args... args)
函数是递归调用的部分。它接受一个参数first
和一个模板参数包Args
,然后递归地调用自己,将参数包args
展开。first
会接收第一个参数,然后其他的参数就会全部都给参数包args;
第二种写法 --- 数组辅助
template<class T> void PrintArg(T t) { cout << t << " "; } template <class ...Args> void ShowList( Args... args) { int arr[] = { (PrintArg(args),0)... }; } int main() { ShowList(1); cout << endl; ShowList(1, 1.1); cout << endl; ShowList(1,1.1,string("xxxxx")); return 0; }
数组里面的内容为一个逗号表达式,逗号表达式的取值为最后一个表达式的取值;
数组在初始化的时候得推断数组中有多少个元素,数组里面添加了三个点,这个就表示数组在推断的时候需要把这个参数包进行展开,展开的空间为多大,这个数组的大小就是多大;
展开的过程中需要将参数传递给函数PrintArg,然后在函数里面就接着打印传递过来的值;
这么写不是很好,我们可以将代码进行一下改造,不添加逗号表达式并让上面的函数返回一个0,那么这里的代码如下:
template<class T> int PrintArg(T t) { cout << t << " "; return 0; } template <class ...Args> void ShowList( Args... args) { int arr[] = { PrintArg(args)... }; } int main() { ShowList(1); cout << endl; ShowList(1, 1.1); cout << endl; ShowList(1,1.1,string("xxxxx")); return 0; }
适配器
为什么会有适配器?
首先大家来看看下面这行代码:
ret = func(x);
上面func可能是什么呢?那么func可能是函数名?函数指针?函数对象(仿函数对象)?也有可能是lamber表达式对象?所以这些都是可调用的类型!如此丰富的类型,可能会导致模板的效率低下!为什么呢?我们来看看下面的代码:
template<class T> void func(T t) { static int x = 0; cout << "x的值为:" << x++ << endl; cout << "x的地址为:" << &x << endl; } int main() { func(1); func(2); func(3); return 0; }
因为我们这里只传递了一个整型的参数所以模板就实例化出来了一个类型,而static修饰的变量是存放在静态区的它不会随着函数的结束而结束,所以每次调用函数的时候都可以看到它的值在不断的累加并且地址也是一样的,但是如果我们传递另外一个类型的参数呢?比如说下面的代码:
template<class T> void func(T t) { static int x = 0; cout << "x的值为:" << x++ << endl; cout << "x的地址为:" << &x << endl; } int main() { func(1); func(2); func(3); func(1.1); func(2.2); func(3.3); return 0; }
代码的运行结果如下:
可以看到这里的x就有了两个,并且通过之前的学习我们知道静态变量是在函数第一次运行的时候进行创建,而这里有两个静态成员变量,那这是不是就说明了模板实例化出来两个函数呢?好看到这里我们知道了静态变量可以用来观察模板实例化出来了多少个模板对象,那么我们再来看看下面的代码:
template<class F, class T> T useF(F f, T x) { static int count = 0; cout << "count:" << ++count << endl; cout << "count:" << &count << endl; return f(x); } double f(double i) { return i / 2; } struct Functor { double operator()(double d) { return d / 3; } }; int main() { // 函数名 cout << useF(f, 11.11) << endl; // 函数对象 cout << useF(Functor(), 11.11) << endl; // lamber表达式 cout << useF([](double d)->double { return d / 4; }, 11.11) << endl; return 0; }
可以看到这里传递三个不同的对象过去,并且函数模板也实例化出来了三个不同的函数对象,但是这是不是有点浪费啊,我们能不能将这三个类型的对象合成一个呢?为了实现这个功能c++11就提供了一个东西叫做包装器,包装器包含在头文件functional里面,那么接下来我们就来看看function的使用。
function
function接收函数
template <class Ret, class... Args> class function<Ret(Args...)>; //Ret:表示被调用函数的返回类型,Args…:被调用函数的形参 //包装器中含有其他函数之后就可以用包装器来调用里面的函数,比如说下面的代码: #include<functional> int f(int x, int y) { return x + y; } int main() { function<int(int, int)> f1(f); f1(2, 3); //用包装器调用函数 function<int(int, int)> f2; f2 = f; f2(3, 4); //用包装器调用函数 return 0; }
function接收仿函数对象
struct Functor { public: int operator() (int a, int b) { return a + b; } }; int main() { Functor ft; function<int(int, int)> f1(ft); function<int(int, int)> f2 = Functor(); cout<<f1(2, 3)<<endl; cout << f2(3, 4) << endl; return 0; }
function接收lambda表达式
int main() { Plus plus; function<int(int, int)> func6 = [&plus](double x, double y)->double {return plus.plusd(x, y); }; cout<<func6(1.1, 2.2)<<endl; return 0; }
再使用包装器依次包装并调用之前的函数后,运行结果显示只生成了一个模板函数:
bind
在C++中,std::bind
函数模板是一个非常有用的工具,它允许您将参数绑定到函数对象上,并创建一个新的可调用对象(函数对象)。std::bind
允许您延迟调用函数,并且可以部分地应用参数,这使得代码更加灵活。
std::bind
的一般语法如下:
cpp Copy Code #include <functional> auto new_callable = std::bind(function, args);
其中:
function
是您想要绑定参数的函数或函数对象。args
是您想要绑定的参数。
std::bind
返回一个可调用对象 new_callable
,它会在调用时使用绑定的参数执行原始函数或函数对象。
以下是一个简单的示例,展示了如何使用 std::bind
:
cpp Copy Code #include <iostream> #include <functional> // 原始函数 void greet(const std::string& name, int age) { std::cout << "Hello, " << name << "! You are " << age << " years old." << std::endl; } int main() { // 使用 std::bind 绑定 greet 函数的第一个参数 auto greet_function = std::bind(greet, "Alice", std::placeholders::_1); // 调用绑定后的函数对象 greet_function(30); // 输出: Hello, Alice! You are 30 years old. return 0; }
在这个示例中,我们使用 std::bind
将 greet
函数的第一个参数绑定为 "Alice",然后创建了一个新的可调用对象 greet_function
。在调用 greet_function
时,我们只提供了一个参数,它会自动填充到 greet
函数的第二个参数上。
std::bind
还可以用于更复杂的情况,例如绑定成员函数、成员函数指针、绑定函数参数顺序等等。这使得它成为一个非常强大的工具,可以用于实现各种灵活的函数调用方案。
智能指针
为什么会有智能指针?
道使用异常可能会导致部分资源没有被正常释放,因为异常抛出之后会直接跳转到捕获异常的地从而跳过了一些很重要的的代码,比如说下面的情况:
int div() { int a, b; cin >> a >> b; if (b == 0) throw invalid_argument("除0错误"); return a / b; } void Func() { int* p1 = new int; int* p2 = new int; cout << div() << endl; cout << "delete p1" << endl; delete p1; cout << "delete p2" << endl; delete p2; } int main() { try{Func();} catch (exception& e) {cout << e.what() << endl;} return 0; }
main函数中调用了func函数,func函数里面调用了div函数,func函数中没有捕捉异常,但是在main函数里面却捕捉了异常,所以出现异常的话就会导致func函数中的部分代码没有被执行,进而导致内存泄漏;
如果除以0的话p1和p2指向的空间就无法被正常的释放:
为了解决这个问题我们就有了重新抛出这个概念,在func函数里面添加捕获异常的代码,然后在catch里面对资源进行释放最后重新将异常进行抛出,最后交给main函数中的catch进行处理,比如说下面的代码:
int div() { int a, b; cin >> a >> b; if (b == 0) throw invalid_argument("除0错误"); return a / b; } void Func() { int* p1 = new int; int* p2 = new int; try { cout << div() << endl; } catch (exception& e) { cout << "delete p1" << endl; delete p1; cout << "delete p2" << endl; delete p2; throw invalid_argument("除0错误"); } } int main() { try{Func();} catch (exception& e) {cout << e.what() << endl;} return 0; }
,但是这么写难道就完成正确了吗?肯定没有,因为new本身也是会抛异常的,当内存不足却又使用new申请空间的话就会导致开辟空间失败从而抛出异常,那new抛异常会导致什么结果呢?分两种情况:p1抛出异常,无泄漏,p2抛出异常,p1内存泄露;并且div等别的地方也会抛出异常,这样处理异常会很麻烦!所以就有了智能指针!
智能指针模拟实现 --- 模板类,通过类对象生命周期的结束来释放资源
首先智能指针是一个类,并且这个类要处理各种各样的数据,所以这个类就得是模板类,比如说下面的代码:
template<class T> class Smart_Ptr { public: private: T* _ptr; };
然后还需要构造函数和析构函数,构造函数就需要一个参数,然后拿这个参数来初始化内部的_ptr就行,还有一个就是析构函数,析构函数就是在内部使用delete释放指针指向的空间即可,那么这里的代码如下:
template<class T> class Smart_Ptr { public: Smart_Ptr(T*ptr) :_ptr(ptr) {} ~Smart_Ptr() { delete[] _ptr; cout << "~ptr:" <<_ptr <<endl; } private: T* _ptr; };
有了这个智能指针类之后我们就可以将申请资源的地址赋值给智能指针对象,从而解决上述的问题,比如说下面的代码:
void Func() { int* p1 = new int; int* p2 = new int; Smart_Ptr<int> sp1 = p1; Smart_Ptr<int> sp2 = p2; cout << div() << endl; cout << "delete p1" << endl; delete p1; cout << "delete p2" << endl; delete p2; throw invalid_argument("除0错误"); }
可以看到即使出现了除0错误这里也可以将两个申请的空间进行释放,原理就是智能指针对象的生命周期属于Func函数,当除0错误抛出异常的时候会直接跳转到main函数里面,这个时候Func函数也跟着结束,它一结束类对象的生命也就跟着结束,结束的时候就会调用析构函数来释放空间,所以就解决了之前的问题;
只有构造函数和析构函数还完全达不到我们的需求,构造函数负责存储资源,析构函数负责释放资源,还需要一些函数来帮助我们使用这些资源,于是我们就可以添加三个操作符的重载函数,一个是解引用重载,一个是->操作符重载,一个是方括号重载,那么这三个函数的实现就如下:
template<class T> class Smart_Ptr { public: Smart_Ptr(T*ptr) :_ptr(ptr) {} T& operator *(){return *_ptr;} T* operator->(){return _ptr;} T& operator[](size_t pos) { return _ptr[pos]; } ~Smart_Ptr() { delete[] _ptr; cout << "~ptr:" <<_ptr <<endl; } private: T* _ptr; };
有了这三个函数之后我们就可以通过智能指针来修改地址指向的内容,比如说下面的代码:
int main() { Smart_Ptr<int> sp1(new int[10]{ 1,2,3,4,5 }); cout << sp1[3] << endl; sp1[3]++; cout << sp1[3] << endl; return 0; }
可以看到这里实现了内部数据读取的功能,我们把上面智能指针的形式称为RAII,RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
库中的智能指针
在看库中的智能指针之前我们首先来看看下面这段代码:
void func2() { Smart_Ptr<int>sp1(new int(10)); Smart_Ptr<int>sp2(sp1); }
原因很简单:我们自己实现的类里面没有拷贝构造函数,所以编译器自动生成了一个,并且采用浅拷贝的方式进行构造,这样就导致两个智能指针指向了同一个空间,所以当函数调用结束,对象的生命周期结束时就会调用delete将同一份空间析构两次,所以就会报出上面的错误,那么为了解决这个问题我们就得自己来实现一个拷贝构造函数,可是这里的拷贝构造,并不能是深拷贝因为我们这个类的目的就是让其想指针一样,当我们把一个指针赋值给另外一个指针时,是两个指针指向同一块空间,而不是两个指针分别指向两个空间,所以这里就不能采用深拷贝的形式来进行拷贝构造,那么库中是如何来解决这个问题的呢?我们首先来看看出现最早的auto_ptr来如何解决这个问题。
auto_ptr
使用方法跟我们自己实现的智能指针是一样的,那么我们就可以写出下面这样的代码:
void func2() { auto_ptr<int>sp1(new int(10)); auto_ptr<int>sp2(sp1); }
但是我们再使用解引用查看指针指向的内容时就会发现这里报错了比如说下面的代码:
void func2() { auto_ptr<int>sp1(new int(10)); auto_ptr<int>sp2(sp1); cout << *sp1 << endl; cout << *sp2 << endl; }
报错的原因就和auto_ptr实现的方法有关,auto_ptr的解决方法就是将管理权进行转移,把原来的智能指针变为空,让新的智能指针指向这个空间,比如说下面的图片:
拷贝构造之前sp1里面储存着数据,但是经历完拷贝构造之后sp1里面的数据就变成下面这样:
所以上面报错的原因就是对空指针进行了解引用访问,那么如果我们想要实现上面这种形式的拷贝构造的话就得将参数中的const去掉,比如说下面的代码:
Smart_Ptr(Smart_Ptr<T>& sp) :_ptr(sp._ptr) { sp._ptr = nullptr; }
这就是auto_ptr的实现原理,可不必了解因为这种形式没人用。甚至很多公司都明确要求不能使用这种形式的智能指针,原因就是auto_ptr使用的方式太不合常理了,那么后来为了解决auto_ptr难用的问题,就有了unique_ptr和share_ptr/weak_ptr。
unique_ptr
来看看使用unique_ptr会不会也出现auto_ptr的问题,那么这里的代码如下:
#include<memory> void func2() { unique_ptr<int>sp1(new int(10)); unique_ptr<int>sp2(sp1); cout << *sp1 << endl; cout << *sp2 << endl; }
可以看到使用unique_ptr也会出现问题但是这个问题跟auto_ptr不一样,它是因为拷贝构造函数被删除了所以出现了问题,那么通过这个例子我们便可以知道unique_ptr解决拷贝构造问题的思路就是直接不让用拷贝构造,那么这里的实现逻辑就如下:
Smart_Ptr(const Smart_Ptr<T>& sp) = delete;
这种实现逻辑肯定不大行,所以我们也没有必要过深的了解,我们接着看下一个形式的智能指针。
shared_ptr
void func2() { shared_ptr<int>sp1(new int(10)); shared_ptr<int>sp2(sp1); cout << *sp1 << endl; cout << *sp2 << endl; *sp1=20; cout << *sp1 << endl; cout << *sp2 << endl; }
可以看到使用share_ptr既不会出现不让拷贝的情况,也不会出现拷贝完置空的情况,而且这个智能指针跟普通的指针一样指向的是同一块区域的内容,那么这就是符合我们需求的智能指针,那么它是如何实现的呢?
原理很简单采用引用计数的方式来实现,当使用智能指针存储数据时我们还开辟一个空间用整型来存储当且这个数据被多少个对象所指向,当被指向的对象数目为0时析构函数里面就使用delete来释放这个空间,比如说下面的图片:
但是这里就有个问题计数变量的空间如何来分配呢?
可以是个普通的整型变量放到对象里面吗?
很明显不可以。因为当计数变量的值发生改变时,所有指向该空间对象的内部计数变量都得改变,那我怎么找到这些对象啊对吧,敌人在暗我在明,很明显这是很难实现的。
那么可以使用静态成员变量来实现吗?
感觉上好像可以,因为不管类实例化出来了多少个对象,这个静态变量只有一个,并且所有对象都会共享这个静态变量,那么这时只要一个对象对这个静态变量进行修改的话,其他对象都会跟着一起修改,那这是不是就达到了我们的目的呢?其实没有,因为静态变量虽然一个对象修改所有对象都可以查看,但是这个对象指的是这个类所有实例化出来的对象,有些智能指针指向的是整型数组arr1,有些智能指针指向的是整型数组arr2,但是因为静态变量只有一个所以他们内部的计数变量都是一样的值,那这是不是就不符合逻辑了啊对吧,所以使用静态变量来计数的方式也是不行的。
最后一个方法:在构造函数里面使用new开辟一个空间。
空间里面记录被指向的个数,然后在类里面添加一个整型的指针变量,让指针指向开辟的空间,那么这里的构造函数代码就如下:
template<class T> class shared_ptr { public: shared_ptr(T* ptr) :_ptr(ptr) , _pcount(new int(1)) {} private: int* _pcount; T* _ptr; };
析构函数在释放空间的时候就得先进行一下判断,如果当前对象的计数变量等于1的话我们就将两个空间全部都释放掉,如果计数的变量大于1的话我们就将计数的值减去1就可以了,那么这里的代码如下:
~shared_ptr() { if (--(*_pcount) == 0) { delete _pcount; delete _ptr; } }
剩下的三个操作符重载也是同样的道理这里就不多解释,直接上代码:
T& operator*() { return *_ptr; } T* operator->() { return _ptr; } T& operator[](size_t pos) { return _ptr[pos]; }
智能指针的线程安全问题
在我们写代码的时候可能会出现多个线程共享同一个资源的情况,那我们上面实现的智能指针在面对多线程的时候会不会出现问题呢?我们添加一个函数来进行验证:
int get() { return *_pcount; }
这个函数就可以帮助我们获取对象里面的计数变量的值,然后我们就可以用下面的代码来进行测试:
void func3() { int n = 50000; YCF::shared_ptr<int> sp1(new int(10)); thread t1([&]() { for (int i = 0; i < n; i++) { YCF::shared_ptr<int> sp2(sp1); } }); thread t2([&]() { for (int i = 0; i < n; i++) { YCF::shared_ptr<int> sp3(sp1); } }); t1.join(); t2.join(); cout << sp1.get() << endl; }
如果运行的结果一直为1就说明代码是安全的,如果运行的结果出现了其他的值就说明上面的代码存在问题:
可以看到这里运行了多次但是每次的结果都不大一样,那这是为什么呢?原因很简单,多个线程在工作的时候都是相互独立,进程1对计数变量++的时候进程2也会拿这个变量进行++,但是++并不是一步就能完成的它也需要一些步骤,这就会导致一个进程还没有对这个变量执行完加一另外一个进程就会接着拿这个变量执行加一,比如说当前的计数变量x的值为1,进程1拿x进行++但是++分为3步,当进程1执行到第一步的时候进程2又会拿x的值进行++,进程1执行完的结果是2;
如何解决? --- 加锁
shared_ptr(const shared_ptr<T>& sp) :_ptr(sp._ptr) , _pcount(sp._pcount) ,_pmtx(sp._pmtx) { _pmtx->lock(); ++(*_pcount); _pmtx->unlock(); }
虽然添加锁可以帮助我们避免出错,但是在析构函数的我们还得对这个锁进行删除,那我们能可以这么写吗?
void Release() { _pmtx->lock(); if (--(*_pcount) == 0) { delete _pcount; delete _ptr; delete _pmtx; //报错,因为删除锁的时候锁还处于工作状态 } _pmtx->unlock(); } ~shared_ptr() { Release(); }
原因也很简单因为删除锁的时候锁还处于工作状态,所以我们就创建一个变量flag用来记录当前是否能够删除锁,如果进入了if语句里面我们就修改flag的值,锁解开之后就判断flag的值如果flag为true的话我们就删除锁,那么这里的代码就如下:
void Release() { bool flag = false; _pmtx->lock(); if (--(*_pcount) == 0) { delete _pcount; delete _ptr; flag = true; } _pmtx->unlock(); if (flag) { delete _pmtx; } } ~shared_ptr() { Release(); }
这里的flag不会有线程安全问题,因为多线程的局部变量存储的位置在栈上,而每个线程都有一个属于自己的栈,所以不会发生冲突,计数变量有问题是因为这个变量存储的位置在堆上,不管我们有多少个线程堆都只有一个,所以就可能出现问题,通过上面的改动我们让智能指针的内部计数变量是线程安全的;
但是智能指针指向的对象一定是线程安全的吗?我们来看看下面的代码:
struct Date { int _year = 0; int _month = 0; int _day = 0; }; void func3() { int n = 50000; YCF::shared_ptr<Date> sp1(new Date); thread t1([&]() { for (int i = 0; i < n; i++) { YCF::shared_ptr<Date> sp2(sp1); sp2->_day++; sp2->_month++; sp2->_year++; } }); thread t2([&]() { for (int i = 0; i < n; i++) { YCF::shared_ptr<Date> sp3(sp1); sp3->_day++; sp3->_month++; sp3->_year++; } }); t1.join(); t2.join(); cout << "day: " << sp1->_day << endl; cout << "month: " << sp1->_month << endl; cout << "year: " << sp1->_year << endl; }
可以看到上面改进得到的结果是让智能指针的计数变量变得安全,但是没有让智能指针指向的对象变得安全,原理也很简单,我们上面做的改进都是在对象的内部,而对智能指针指向的对象都是在对象的外部,所以这两个是没有关系的,那么要想保证指向的内容也是线程安全的话就得在修改的时候也添加锁上去,那么这里的代码就如下:
void func3() { int n = 50000; mutex mtx;//这个也可以被捕捉 YCF::shared_ptr<Date> sp1(new Date); thread t1([&]() { for (int i = 0; i < n; i++) { YCF::shared_ptr<Date> sp2(sp1); mtx.lock(); sp2->_day++; sp2->_month++; sp2->_year++; mtx.unlock(); } }); thread t2([&]() { for (int i = 0; i < n; i++) { YCF::shared_ptr<Date> sp3(sp1); mtx.lock(); sp3->_day++; sp3->_month++; sp3->_year++; mtx.unlock(); } }); t1.join(); t2.join(); cout << "day: " << sp1->_day << endl; cout << "month: " << sp1->_month << endl; cout << "year: " << sp1->_year << endl; }
那么这时智能指针指向的内容和智能指针的内部计数变量都是线程安全的了。
waek_ptr --- 解决shared_ptr循环智能指针问题
weak_ptr只负责指向不会负责管理引用计数;
template<class T> class weak_ptr { public: weak_ptr() :_ptr(nullptr) {} weak_ptr(const shared_ptr<T>& sp) :_ptr(sp.get()) //这里改了一下get是获取内容的地址 {} weak_ptr<T>& operator=(const shared_ptr<T>& sp) { _ptr = sp.get(); return *this; } // 像指针一样 T& operator*() { return *_ptr; } T* operator->() { return _ptr; } public: T* _ptr; }; }
定制删除器
我们的shared_ptr的析构函数实现方法如下:
void Release() { bool flag = false; _pmtx->lock(); if (--(*_pcount) == 0) { delete _pcount; delete _ptr; flag = true; } _pmtx->unlock(); if (flag) { delete _pmtx; } } ~shared_ptr() { Release(); }
使用new申请一个类型空间的时候是用delete来释放空间,而当我们使用new来申请一个数组的空间时则是用delete [ ]来释放空间,但是我们怎么知道使用者是拿共享指针指向单个数据还是一个数组呢?比如说下面的代码:
void func5() { YCF::shared_ptr<string> n1(new string[10]); }
直接报错了,但是这里的报错并不是我们的问题,使用库中的共享指针也会出现相同的问题,比如说下面的代码:
void func5() { std::shared_ptr<string> n1(new string[10]); }
为了解决这个问题我们就提出一个概念叫做定制删除器,通过观察库中的文档便可以看到定制删除器的身影:
定制删除器就相当于是一个仿函数,我们自己实现的删除方式在删除一些数据的时候可能会出现问题,那么这个时候你就可以给我们提供一个删除方式,如果你提供了我们就用你提供的方式来进行删除,比如说上面在删除数组的时候出现了问题,我们就可以提供一个仿函数专门用来释放数组的数据,那么这里的代码就如下:
template<class T> struct DeleteArray { void operator()(const T* ptr) { delete[] ptr; cout << "delete [] "<<ptr<< endl; } };
那么我们将这个定制删除器传递过去比如说下面的代码:
void func5() { std::shared_ptr<string> n1(new string[10],DeleteArray<string>()); }
并且这里不仅可以传递仿函数还可以传递lambda表达式,比如说下面的代码:
void func5() { std::shared_ptr<string> n1(new string[10], DeleteArray<string>()); std::shared_ptr<string> n2(new string[10], [](string* ptr) {delete[] ptr; }); std::shared_ptr<FILE> n3(fopen("test.cpp","r"), [](FILE* ptr) { fclose(ptr); }); }