C++ primer 动态内存与智能指针

一、动态内存

  C++内存获取和释放 new/delete,new[]/delete[]
  C语言内存获取和释放 malloc/free,calloc/realloc
  上述8个函数/操作符是c/c++语言里常用来做动态内存的申请和释放的,要理解这些接口,大概需要下面几个维度的了解:

1、了解OS的进程空间模型

  一个进程的地址空间,一般划分为内核区、用户区,用户区又划分为栈区、堆区、数据区、代码区。这里的‘堆区’,‘栈区’,‘数据区’,‘内核区’,其实就是一个虚拟地址区间,动态内存最终都是从OS的’堆区’上获取的。

2、brk、mmap 系统调用

  brk系统调用,可以让进程的堆指针增长一定的大小,逻辑上消耗掉一块本进程的虚拟地址区间,malloc向OS获取的内存大小比较小时,将直接通过brk调用获取虚拟地址,结果是将本进程的brk指针推高

  mmap系统调用,可以让进程的虚拟地址区间里切分出一块指定大小的虚拟地址区间vma_struct,并返回给用户态进程,被mmap映射返回的虚拟地址,逻辑上被消耗了,直到用户进程调用munmap,才回收回来。malloc向系统获取比较大的内存时,会通过mmap直接映射一块虚拟地址区间。mmap系统调用用处非常多,比如一个进程的所有动态库文件.so的加载,都需要通过mmap系统调用映射指定大小的虚拟地址区间,然后将.so代码动态映射到这些区域,以供进程其他部分代码访问;另外,多进程通讯,也可以使用mmap,这块另开文章详解。

  无论是brk还是mmap返回的都是虚拟地址,在第一次访问这块地址的时候,会触发缺页异常,然后内核为这块虚拟地址申请并映射物理页框,建立页表映射关系,后续对该区间虚拟地址的访问,通过页表获取物理地址,然后就可以在物理内存上读写了。

3、malloc/free 是libc库函数

  malloc/free是 libc实现的库函数,主要实现了一套内存管理机制,当其管理的内存不够时,通过brk/mmap等系统调用向内核申请进程的虚拟地址区间,如果其维护的内存能满足malloc调用,则直接返回,free时会将地址块返回空闲链表。

  malloc(size) 的时候,这个函数会多分配一块空间,用于保存size变量,free的时候,直接通过指针前移一定大小,就可以获取malloc时保存的size变量,从而free只需要一个指针作为参数就可以了calloc 库函数相当于 malloc + memset(0)

  除了libc自带的动态内存管理库malloc, 有时候还可以使用其他的内存管理库替换,比如使用google实现的tcmalloc ,只需要编译进程时链接上 tcmalloc的静态库并包含响应头文件,就可以透明地使用tcmalloc 了,与libc 的malloc相比, tcmalloc 在内存管理上有很多改进,效率和安全性更好。

4、new/new[]/delete/delete[]

运算符new分配内存,delete释放new分配的内存。new返回一个指向该对象的指针。
int *pi = new int; // pi指向一个动态分配的未初始化的无名对象
int *pi1 = new int(520);
int *pi2 = new int(); 

  new/delete是c++ 内置的运算符,支持运算符重载,除了分配和回收内存外,还负责调用构造函数和析构函数。

  new的实现会调用malloc,对于基本类型变量,它只是增加了一个cookie结构, 比如需要new的对象大小是 object_size, 则事实上调用 malloc 的参数是 object_size + cookie, 这个cookie 结构存放的信息包括对象大小,对象前后会包含两个用于检测内存溢出的变量,所有new申请的cookie块会链接成双向链表。由于内置了内存溢出检测,所以比malloc更安全。

  对于自定义类型,new会先申请上述的大小空间,然后调用自定义类型的构造函数,对object所在空间进行构造。c++比c强大的一个方面就是c++编译器可以自动做构造和析构,new运算符会自动计算需要的空间大小,然后根据类型自己调用构造函数,如果存在子类型对象,或者存在继承的基类型,new都会自动调用子类型的构造函数和基类型的构造函数完成构造。同样,delete 操作符根据cookie的size知道object的大小,如果是自定义类型,会调用析构函数对object所在空间进行析构,如果有子类型或继承,自动调用子类型和基类型的析构函数,然后将cookie块从双向链表摘除,最后调用 free_dbg 释放。

  new[] 和delete[]是另外两个操作符,用于数组类型的动态内存获取和释放,实现过程类似new/delete 。

二、智能指针

1、智能指针背后的设计思想

  我们先来看一个简单的例子:

void remodel(std::string & str)
{
    std::string * ps = new std::string(str);
    ...
    if (weird_thing())
        throw exception();
    str = *ps; 
    delete ps;
    return;
}

  当出现异常时(weird_thing()返回true),delete将不被执行,因此将导致内存泄露。

  如何避免这种问题?有人会说,这还不简单,直接在throw exception();之前加上delete ps;不就行了。是的,你本应如此,问题是很多人都会忘记在适当的地方加上delete语句(连上述代码中最后的那句delete语句也会有很多人忘记吧),如果你要对一个庞大的工程进行review,看是否有这种潜在的内存泄露问题,那就是一场灾难!

  这时我们会想:当remodel这样的函数终止(不管是正常终止,还是由于出现了异常而终止),本地变量都将自动从栈内存中删除—因此指针ps占据的内存将被释放,如果ps指向的内存也被自动释放,那该有多好啊。

  我们知道析构函数有这个功能。如果ps有一个析构函数,该析构函数将在ps过期时自动释放它指向的内存。但ps的问题在于,它只是一个常规指针,不是有析构凼数的类对象指针。如果它指向的是对象,则可以在对象过期时,让它的析构函数删除指向的内存。

  这正是智能指针背后的设计思想。我简单的总结下就是:将基本类型指针封装为类对象指针(这个类肯定是个模板,以适应不同基本类型的需求),并在析构函数里编写delete语句删除指针指向的内存空间。

  shared_ptr允许多个指针指向同一个对象;unique_ptr则“独占”所指向的对象。标准库还定义了一个名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象。这三种类型都定义在memory头文件中。

2、普通指针存在的问题

  我们来看看普通指针的悬垂指针问题。当有多个指针指向同一个基础对象时,如果某个指针delete了该基础对象,对这个指针来说它是明确了它所指的对象被释放掉了,所以它不会再对所指对象进行操作,但是对于剩下的其他指针来说呢?它们还傻傻地指向已经被删除的基础对象并随时准备对它进行操作。于是悬垂指针就形成了,程序崩溃也“指日可待”。我们通过代码+图来来探求悬垂指针的解决方法。

int * ptr1 = new int (1);
int * ptr2 = ptr1;
int * ptr3 = prt2;

cout << ptr1 << endl;
cout << ptr2 << endl;
cout << *ptr3 << endl;

delete ptr1;

cout << *ptr2 << endl;

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
  从图可以看出,错误的产生来自于ptr1的”无知“:它并不知道还有其他指针共享着它指向的对象。如果有个办法让ptr1知道,除了它自己外还有两个指针指向基础对象,而它不应该删除基础对象,那么悬垂指针的问题就得以解决了。如下图:
在这里插入图片描述
在这里插入图片描述

3、shared_ptr类

在这里插入图片描述
在这里插入图片描述

(1)make_shared函数

  最安全的分配和使用动态内存的方法是调用一个名为make_shared的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr。

shared_ptr<int> p = make_shared<int>(42);//效率比下面的高  
shared_ptr<int> p2(new int(42));// 不推荐,为了避免智能指针与普通指针的混用,所以最后使用make_shared,这样在内存分配之后立刻与智能指针绑定到一起.

(2)shared_ptr的拷贝和赋值

auto p = make_shared<int>(42);
auto q(p);
auto r = p; //p递增,r递减。

  每个shared_ptr都有一个关联的计数器,通常称为引用计数

  无论何时我们拷贝一个shared_ptr,计数器都会递增,当用一个shared_ptr初始化另一个shared_ptr,或将它作为参数传递给一个参数以及作为函数返回值时,它所关联的计数器会递增;当我们给shared_ptr赋予一个新值或者shared_ptr被销毁时(如一个局部的shared_ptr离开其作用域),引用计数会递减。

  一旦引用计数变为0,shared_ptr就会自动释放自己所管理的对象。

(3)shared_ptr自动销毁所管理的对象

  shared_ptr的析构函数会递减它所指向的对象的引用计数,当引用计数变为0时,shared_ptr就会通过析构函数自动释放自己所管理的对象。当动态对象不再使用时,shared_ptr会自动释放对象,这一特性使得动态内存的使用变得非常容易(尽量使用智能指针管理动态内存)。如果将shared_ptr放于容器中,而后不再需要全部元素,而只是使用其中一部分,要记得用erase删除不再需要的那些元素。

  在多线程程序中,一个对象如果被多个线程访问,一般使用shared_ptr,通过引用计数来保证对象不被错误的释放导致其他线程访问出现问题。

(4)使用动态内存的原因:

  • 程序不知道自己需要多少对象。(如容器类)

  • 程序不知道所需对象的准确类型。

  • 允许多个对象共享相同的状态。

(5)使用shared_ptr的一个例子:

#include <iostream>
#include <string>
#include <vector>
#include <memory>
using namespace std;
class StrBlob
{
public:
	typedef vector<string>::size_type st;
	StrBlob():data(make_shared<vector<string>>()){}
	StrBlob(initializer_list<string> il):data(make_shared<vector<string>>()){}
	inline st size() const { return data->size(); } 
	inline bool empty() const { return data->empty(); }
	inline void push_back(const string &t) { data->push_back(t); }
	void pop_back();
	const string & front();
	const string & back();
private:
	shared_ptr<vector<string>> data;
	void check(st i,const string &msg) const;
};
void StrBlob::check(st i,const string &msg) const
{
	if(i >= data->size())
		throw out_of_range(msg);
}
const string & StrBlob::front()
{
	StrBlob::check(0,"front on empty StrBlob");
	return data->front();
}
const string & StrBlob::back()
{
	StrBlob::check(0,"back on empty StrBlob");
	return data->back();
}
void StrBlob::pop_back()
{
	StrBlob::check(0,"pop_back on empty StrBlob");
	return data->pop_back();	
}
int main(int argc, char const *argv[])
{
    StrBlob b1;
    StrBlob b2 = {"a","an","the"};
    b1 = b2;
    b2.push_back("about");
    cout << b1.size() << endl;
    cout << b2.size() << endl;
    return 0;  
	return 0;
}

4、shared_ptr的实现和循环引用问题

template<typename T>
class SmartPtr {
private:
    T *_ptr;    // 指向对应的对象
    int *_cnt;   // 计数
public:
    SmartPtr(T *ptr) : _ptr(ptr), _cnt(new int(1)) {}
    // 拷贝
    SmartPtr(const SmartPtr &p) : _ptr(p._ptr), _cnt(p._cnt) { (*_cnt)++; }
    // 赋值
    SmartPtr & operator=(const SmartPtr &p) {
        (*(p._cnt))++; // 给右侧的对象的计数++
        (*_cnt)--;     // 给左侧的对象的计数--
        if (*_cnt == 0) {
            delete _ptr;
            delete _cnt;
        }
        _ptr = p._ptr;
        _cnt = p._cnt;
        return *this;
    }
    ~SmartPtr() {
        (*_cnt)--;
        if (*_cnt == 0) {
            delete _cnt;
            delete _ptr;
        }
    }
};

  但是这里还有一个严重的问题,就是关于循环引用(会引起内存泄漏) 的问题。对于什么是循环引用?我们用下面这个测试用例来解释:

class B;
class A
{
public:
  shared_ptr<B> m_b;
};
class B
{
public:
  shared_ptr<A> m_a;
};
void fun()
{
	shared_ptr<A> pa(new A); // new出来的A的引用计数此时为1
	shared_ptr<B> pb(new B); // new出来的B的引用计数此时为1
	pa->m_b = b; // B的引用计数增加为2
	pb->m_a = a; // A的引用计数增加为2
}
int main()
{
	fun();
	return 0;
}

在这里插入图片描述
  分析class A对象的引用情况,该对象被main函数中的pa和class B对象中的ptr管理,因此pa引用计数是2,class B对象同理。

  在这种情况下,在fun函数结束的时候,pa和pb的析构函数被调用,但是class A对象和class B对象仍然被一个智能指针管理,pa和pb引用计数变成1,于是这两个对象的内存无法被释放,造成内存泄漏,如下图所示:
在这里插入图片描述
  因此,在这里标准库就引用了weak_ptr,将类里面的shared_ptr换成weak_ptr即可,由于weak_ptr并不会增加引用计数use的值,所以这里就能够打破shared_ptr所造成的循环引用问题。但是这里要注意一点,就是weak_ptr并不能单独用来管理空间。

5、weak_ptr类

  由于在shared_ptr(强引用:每创建一个变量引用该对象时,该对象的计数就增加1)的析构函数中,只有当use=1,进行减减之后为0,才会释放_ptr所指向的空间,所以在这里a和b都不会被释放,因此也不会调用析构函数,所以这里就出现了内存泄漏。

  由于在shared_ptr单独使用的时候会出现循环引用的问题,造成内存泄漏,所以标准库又从boost库当中引入了weak_ptr(弱引用:不更改引用计数,类似普通指针)。对上面的测试用例进行修改:

class B;
class A
{
public:
  weak_ptr<B> m_b;
};
class B
{
public:
  weak_ptr<A> m_a;
};
void fun()
{
	shared_ptr<A> pa(new A); // new出来的A的引用计数此时为1
	shared_ptr<B> pb(new B); // new出来的B的引用计数此时为1
	pa->m_b = b; // B的引用计数增加为2
	pb->m_a = a; // A的引用计数增加为2
}
int main()
{
	fun();
	return 0;
}

  解决方法很简单,把class A或者class B中的shared_ptr改成weak_ptr即可,由于weak_ptr不会增加shared_ptr的引用计数,所以pa和pb中有一个的引用计数为1,在pa和pb析构时,会正确地释放掉内存。


  weak_ptr是一种不控制所指向对象生存周期的智能指针,他指向由一个shared_ptr管理的对象。将一个weak_ptr绑定到一个shared_ptr,不会改变shared_ptr的引用计数。
  由于对象 可能不存在,我们不能使用weak_ptr直接访问对象,而必须调用lock。如果对象存在,lock返回一个指向共享对象的shared_ptr。

    auto p = make_shared<int>(10); // 使用shared_ptr来初始化。
    weak_ptr<int> wp(p);      
    if(shared_ptr<int> np = wp.lock()) // 访问对象必须调用lock()
    {  
      //使用np访问共享对象  
    }  
    

6、unique_ptr类

  unique_ptr 独占智能指针,某个时刻只能有一个unique_ptr 指向一个给定对象。当unique_ptr被销毁时,它所指向的对象也被销毁。unique_ptr 不支持拷贝赋值等操作,除非这个unique_ptr将要被销毁,这种情况,编译器执行一种特殊的"拷贝"

unique_ptr<int> p1(new int(42));//必须直接初始化。
unique_ptr<int> p2(p1);//error
unique_ptr<int> p3 = p1;/error
unique_ptr<int> clone(int p)
{
  unique_ptr<int> ret(new int(p));
  return ret; //ok
}

这里写图片描述
  虽然不能拷贝或者赋值unique_ptr,但是通过调用release或者reset将指针的所有权从一个(非const)unique_ptr转移给另一个unique_ptr。调用release会切断unique_ptr和它原来管理对象间的联系。release返回的指针通常用来初始化另一个智能指针或者给另一个智能指针赋值。

#include <iostream>
#include <memory>
using namespace std;
unique_ptr<int> clone(int p);
int main(int argc, char const *argv[])
{
	unique_ptr<double> p;//
	unique_ptr<string> p1(new string("ABC"));//使用new返回的指针初始化。
	cout << *p1 << endl;
	unique_ptr<string> p2(p1.release());//放弃对p1的控制权,返回指针并置空,然后初始化另一个指针。
	cout << *p2 << endl;
	unique_ptr<string> p3(new string("abc"));
	p2.reset(p3.release());//释放p2的对象,并将p3的所有权转移给p2。
	cout << *p2 << endl;
	cout << *clone(10) << endl;
	return 0;
}
unique_ptr<int> clone(int p)
{
	return unique_ptr<int>(new int(p));//unique_ptr不能拷贝或者赋值,但是可以返回一个unique_ptr。
}

7、unique_ptr类为何优于auto_ptr类

  可能大家认为前面的例子已经说明了unique_ptr为何优于auto_ptr,也就是安全问题,下面再叙述的清晰一点。请看下面的语句:

auto_ptr<string> p1(new string ("auto")// #1
auto_ptr<string> p2;                      // #2
p2 = p1;                                  // #3

  在语句#3中,p2接管string对象的所有权后,p1的所有权将被剥夺。前面说过,这是好事,可防止p1和p2的析构函数试图刪同—个对象;但如果程序随后试图使用p1,这将是件坏事,因为p1不再指向有效的数据。下面来看使用unique_ptr的情况:

unique_ptr<string> p3 (new string ("auto");   // #4
unique_ptr<string> p4;                       // #5
p4 = p3;                                      // #6

  编译器认为语句#6非法,避免了p3不再指向有效数据的问题。因此,unique_ptr比auto_ptr更安全。但unique_ptr还有更聪明的地方。有时候,会将一个智能指针赋给另一个并不会留下危险的悬挂指针。假设有如下函数定义:

unique_ptr<string> demo(const char * s)
{
    unique_ptr<string> temp (new string (s))return temp;
}
unique_ptr<string> ps;
ps = demo('Uniquely special")

  demo()返回一个临时unique_ptr,然后ps接管了原本归返回的unique_ptr所有的对象,而返回时临时的 unique_ptr 被销毁,也就是说没有机会使用 unique_ptr 来访问无效的数据,换句话来说,这种赋值是不会出现任何问题的,即没有理由禁止这种赋值。实际上,编译器确实允许这种赋值,这正是unique_ptr更聪明的地方。

  总之,当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做,比如:

unique_ptr<string> pu1(new string ("hello world"));
unique_ptr<string> pu2;
pu2 = pu1;                                      // #1 not allowed
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string ("You"));   // #2 allowed

  其中#1留下悬挂的unique_ptr(pu1),这可能导致危害。而#2不会留下悬挂的unique_ptr,因为它调用 unique_ptr 的构造函数,该构造函数创建的临时对象在其所有权让给 pu3 后就会被销毁。这种随情况而已的行为表明,unique_ptr 优于允许两种赋值的auto_ptr 。

8、使用智能指针需要注意的问题:

这里写图片描述

9、如何选择智能指针?

  在掌握了这几种智能指针后,应使用哪种智能指针呢?

  • 如果程序要使用多个指向同一个对象的指针,应选择shared_ptr。这样的情况包括:有一个指针数组,并使用一些辅助指针来标示特定的元素,如最大的元素和最小的元素;两个对象包含都指向第三个对象的指针;STL容器包含指针。很多STL算法都支持复制和赋值操作,这些操作可用于shared_ptr,但不能用于unique_ptr(编译器发出warning)和auto_ptr(行为不确定)。如果你的编译器没有提供shared_ptr,可使用Boost库提供的shared_ptr。

  • 如果程序不需要多个指向同一个对象的指针,则可使用unique_ptr如果函数使用new分配内存,并返还指向该内存的指针,将其返回类型声明为unique_ptr是不错的选择。这样,所有权转让给接受返回值的unique_ptr,而该智能指针将负责调用delete。可将unique_ptr存储到STL容器在那个,只要不调用将一个unique_ptr复制或赋给另一个算法(如sort())。

三、动态数组和allocator类

1、动态数组

(1)使用new和delete管理动态内存三个常见的问题:

a、忘记释放(delete)内存。

  忘记释放动态内存会导致人们常说的 “内存泄漏(memory leak)” 问题 ,因为这种内存永远不可能归还系统,除非程序退出。比如在某个作用域的代码如下:向系统申请了一块内存,离开作用域之前没有接管用户这块内存,也没有释放这块内存。

{  
    //....  
    int *p = new int(0);  
    //....   
}  

  有两个方法可以避免以上问题:

1) 在p离开它new所在作用域之前,释放这块内存。如:delete p

{  
     //....  
     int *p = new int(0);  
     //....  
     delete p;      //释放p的向系统申请的内存  
     p = nullptr;   //避免出现野指针。 
}  

2) 接管p的向系统申请的内存。 比如通过赋值,函数返回值等。

int *pAnother;
{
    //....
    int *p = new int(0);
    //....
    pAnother = p; //pAnother接管p所指向的内存。
}
//pAnother  do something
delete pAnother;   //通关pAnother,将p所申请的内存归还系统。

b、使用已经释放内存的对象。

  这种行为是未定义的,通过在释放内存后将指针设置位空指针(nullptr),有时可以避免这个问题(这是基于一个前提条件,使用动态分配内存对象前,需要检查该对象是否指向空(nullptr))。假如不对已经释放内存的对象赋值空指针,他的值是未定义的,就好比其他变量,使用未初始化的对象,其行为大都是未定义。

  nullptr(C++11刚引入)是一种特殊类型的字面值,它可以被转换成任何其他指针类型。过去程序使用NULL的预处理变量来给指针赋值。 他们的值都是0。

  使用已经释放内存的对象,如下代码:

{
    int *p = new int(0);
    delete p;
    std::cout<<*p<<std::endl; //*p的值是未定义
}

  避免以上问题:(对已经释放内存对象赋于一个空指针,使用前进行判断是否为空指针)

{  
    int *p = new int(0);  
    delete p; 
    p = nullptr;  
    if(p!=nullptr) 
        std::cout<<*p<<std::endl;   
}  

  同样当我们定义一个指针时,如果没有立即为它分配内存,也需要将指针设置为空指针,防止不恰当使用。这里也涉及一个问题,new出来的内存也应该初始化。

c、同一块内存释放两次。

  当有两个指针指向相同的动态分配对象时,可能发生这种错误。如果对其中一个对象进行了delete操作,对象的内存就归还给系统,如果我们随后有delete第二个指针,堆空间可能被破坏。产生问题代码:

int *pAnother;  
{  
    //....  
    int *p = new int(0);  
    pAnother =p;  
    //p do something....  
    delete p;  
}  
delete pAnother;  //未定义行为 

  避免这个问题:在delete p 之后, 将p置为一个空指针。
  其次明白一个道理:delete p, p 必须指向一个空指针或者动态分配的内存,否则其行为未定义。这也很好就解释了为什么delete一个对象之后需要将该对象置为空指针,一是为了避免再次访问它出现未定义行为,二是为了避免再次delete它出现未定义行为。

【Note】:
1)定义一个指针需要初始化为空指针,(除非在定义的时候给它申请一块内存)。
2)访问一个指针需要先判断该指针是否为空指针。
3)释放一个指针之后,应该将它置为空指针,防止出现野指针。

(2)动态数组的一个例子

#include <iostream>
#include <memory>
using namespace std;
int main(int argc, char const *argv[])
{
    int *p = new int[10];
    delete [] p;
    unique_ptr<int []> up(new int[10]);//使用unique_ptr管理动态数组。
    up.release();
    //动态申请高维数组。
    int row = 3;
    int col = 5;
    //先申请int* 型数组的的空间,该空间存放的是int*型的指针变量,然后把这块指针变量的空间首地址赋给array。
    int** array=new int* [row];
    for(int i=0 ; i<row ; ++i)
    {
        array[i] = new int[col];//array[i]仍然是指针。
    }
    for(int i=0 ; i<row ; ++i)
    {
        delete[] array[i];
    }
    delete[] array;
    return 0;
}

2、allocator类


在这里插入图片描述

  allocator类帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。

#include <iostream>
#include <memory>
#include <string>
using namespace std;
int main(int argc, char const *argv[])
{
	const int n = 10;
	allocator<string> alloc;
	auto p = alloc.allocate(n);//只分配内存。
	string s;
	auto q = p;
	while(cin >> s && q != p+n)
	{
		alloc.construct(q++,s);//必须调用construct创建对象并赋值。
	}
	while(q != p)
	{
		alloc.destroy(--q);//逐个销毁对象,destory接受一个指针。
	}
	alloc.deallocate(p,n);//分配多少内存,释放多少。
	system("pause");
	return 0;
}

参考:https://www.cnblogs.com/jiayy/p/3420122.html
http://blog.csdn.net/chenkaixin_1024/article/details/69390586
http://blog.csdn.net/lanxuezaipiao/article/details/41603883
http://blog.csdn.net/qq_33850438/article/details/52994314
http://makaidong.com/libin1105/1/1903_12244995.html
https://www.jianshu.com/p/5bd06545c3ce
https://blog.csdn.net/zhwenx3/article/details/82789537

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值