技术: C++ 智能指针实现

说完了Boost和std中的智能指针, 也尝试实现看看。

引用计数, 标记擦除, 智能指针还有其他实现方式么?

本篇主要涉及:

  • 尝试自己实现 引用计数类型的智能指针
  • 仔细说说 auto_ptrunique_ptr

引子

如果明白了RAII手法, 引用计数原理, 标记擦除原理, 那么实现一个scoped_ptr, shared_ptr, 或者unique_ptr都不是难事儿. 难得的是实现的时候, 对于下面两个函数的处理:

  • 赋值函数
  • 拷贝构造函数

当然也包括右值问题.

正文

智能指针实现机制

按照其实现机制, 大致可以把智能指针分为如下几类:

  • 引用计数机制
    解释: 引用计数主要是使用系统记录对象被引用的次数。当对象被引用的次数变为0时,该对象即可被视为“垃圾”,从而可以被回收。使用引用计数做垃圾回收的算法的一个优点是实现很简单,与其它垃圾回收算法相比,该方法不会造成程序暂停,因为计数的增减与对象的使用时紧密结合的。
  • 跟踪处理的垃圾回收机制 根据不同算法又可以重新划分
    • 标记-清除(Mark-Sweep)
      首先该算法将程序中正在使用的对象视为“根对象”,从根对象开始查找它们所引用的堆空间,并在这些堆空间上做标记。当标记结束后,所有被标记的对象就是可达对象或活对象,而没有被标记的对象就被认为是垃圾,在第二步的清扫阶段会被回收掉; 这种方法的特点是活的对象不会被移动,但是其存在会出现大量的内存碎片问题。
    • 标记-整理(Mark-Compact)
      这个算法标记的方法和标记-清除方法一样,但是标记完之后,不再遍历所有对象清扫垃圾了,而是将活跃的对象向“左”靠齐,这就解决了内存碎片的问题。
      标记-整理算法有个特点就是移动活的对象,因此相应的,程序中所有对堆内存的引用都要更新。
    • 标记-拷贝(Mark-Copy)
      这种算法的一大特点就是将堆空间分为两部分:From,To。开始的时候我们只在From里分配,当From分配满的时候出发垃圾收集,这个时候会找出From空间里所有的存活对象,然后将这些存活的对象拷贝到To空间里。这样From空间里剩下的就都全是垃圾,而且对象拷贝到To里,在To里是紧凑排列的。这个事儿做完了之后From和To的角色就转变了一下。原来的From变成了To,原来的To变成了现在的From。现在又可以在这个完全是空的From里分配了。这个算法实现起来也很简单,高效(Sun JVM的新生代的垃圾回收就使用了这种算法)。不过这个算法有一个问题,堆的利用率只有一半了,这对那些内存占用率比较低的对象还算好,如果随着应用的内存占用率的增高,问题就出现了,第一个要拷贝的对象太多了,还有可能无法回收内存了。程序失败了。
  • RAII机制(资源和对象绑定, 局部对象自动销毁)
    这种机制把资源的声明周期和对象的声明周期绑定, 存在的问题就是资源的当前使用者是唯一的, 出现赋值则情况需要特殊处理.

引用计数简单实现

如果把引用计数器也当做一种(共享)资源, 那么很容易可以给出一种实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
template<class _T>  
class SmartPtr
{
private:
_T *key;
size_t *counter;
void decrease() {
if (--*counter == 0)
delete key;
}

public:
SmartPtr (_T *t = NULL) : key(t), counter(new size_t(1)) {
}

SmartPtr(const SmartPtr & sp) {
key = sp.key;
counter = sp.counter;
(*counter)++;
}

SmartPtr & operator = (const SmartPtr & sp) {
decrease();
key = sp.key;
counter = sp.counter;
(*counter)++;
}

virtual ~SmartPtr() {
decrease();
}
};
```
核心实现代码非常简单:
* 拷贝构造是共同拥有, 指向统一资源(因为拷贝构造时, 原来是没有指向的);
* 赋值是转移所有权(赋值是原来指向的内容要判断一下是否减少引用计数, 然后再接收之后的指向).



## auto_ptr

说先要说这个智能指针当前已经废弃了, 平常大概也就会这么用:
```c++
auto_ptr<T> pt( new T );//只有new, 没有delete调用

代替原来的:

1
2
3
T* pt( new T );  
//...
delete pt;

当然和其他职能指针一样, 也包含其他方法:
reset()函数

1
2
3
<memeory>
auto_ptr<T> pt( new T(1) );
pt.reset( new T(2) ); // 删除由"new T(1)"分配出来的第一个T, 改变指向

以及成员函数:

1
2
X* get() const throw();       //返回保存的指针, 原指针中仍保留指向空间
X* release() const throw(); //返回保存的指针, 原指针已经不再指向原来空间

这个智能指针实现的时候, 在唯一所有权的问题上没有处理好, 或者说赋值函数没有处理好. 例如下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
void TestFunc(auto_ptr<A> Obj)
{
Obj->SetA(20);
cout << Obj->GetA() << endl;
}

int main()
{
auto_ptr<A> pAObj(new A(10));
TestFunc(pAObj); //注意这里发生值拷贝
cout << pAObj->GetA() << endl; //pAObj 智能指针此时已经不再指向先关数据了.
}

然后最后一句报错了, 也就是说, auto_ptr不能共享内存, 在同一时间, 只有一个auto_ptr指向一个指定的内存(托管转移). 为什么不能? 因为他的实现机制是, 一个对象绑定它的成员资源.

  • 利用特点”栈上对象在离开作用范围时会自动析构”
  • 对于动态分配的内存,其作用范围是程序员手动控制的,这给程序员带来了方便但也不可避免疏忽造成的内存泄漏,毕竟只有编译器是最可靠的
  • auto_ptr通过在栈上构建一个对象a,对象a中wrap了动态分配内存的指针p,所有对指针p的操作都转为对对象a的操作

或者你从他的源码也可以看到:

1
2
3
4
5
6
7
8
// 赋值构造函数
auto_ptr& operator=(auto_ptr& __a) __STL_NOTHROW {
if (&__a != this) {
delete _M_ptr;
_M_ptr = __a.release();//放弃__a保留的原指针, 转让给当前接受者
}
return *this;
}

既然不能共享所有权, 那么凡是调用 operator=() 的操作都是危险的, 比如吧auto_ptr放入容器内, 即

1
vector<auto_ptr<MyClass>>m_example;

(当然你可以使用std:ref智能引用进行包装, 不过一拷贝, 原对象就失效, 总的来说还是不能放入容器).

正是auto_ptr实现机制上的问题(原件一拷贝就失效, 你受得了?), 所以后来引入标准库的 std::unique_ptr用静态检验的方式去确认用户是否需要在赋值后转移所有权, 并且采用引用计数的shared_ptr可以共享所有权(除了一个相互引用比较烦人以外). 唯一可惜的是, shared_ptr(配合weaked_ptr使用)效率会有微小损失, 而auto_ptr则没有. 哦, scoped_ptr 这又是一个对所有权的几种做法: 不允许转移, 也就是说保证资源从一而终(此时也保障了唯一所有者).

补充: auto_ptr还不能存储数组, 即auto_ptr 原因可能是因为析构的时候, 没有调用delete[]处理, 而只有 delete.

unique_ptr

std::unique_ptr 它也是通过指针的方式来管理对象资源, 并且在 unique_ptr 的生命期结束后释放该资源. unique_ptr 持有对对象的独有权, 即两个 unique_ptr 不能指向一个对象, 不能进行复制操作只能进行移动操作(某种意义上禁止了拷贝语义, 只保留move语义, 查看函数原型就能发现), 即右值(临时变量)转移给某个具名值(左值)或者函数返回. 那么它为什么就比 auto_ptr好呢?

即unique_ptr赋值拷贝构造有多个重载, 但是都不具备拷贝语义, 而只有移动语义. (从其原型看出, 它其实是强制你在需要转义的时候使用move语句, 因为根本找不到T&, 只有T&&)

转移仅仅发生在:

  • unique_ptr 所要接收的对象即将销毁(右值)
  • unique_ptr 所要接收的对象显示的放弃了所有权, 比如release(), 即左值或者非const引用电泳move语句.

通俗的说:
auto_ptr是可以说你随便赋值,但赋值完了之后原来的对象就不知不觉的报废.搞得你莫名其妙.而unique就干脆不让你可以随便去复制,赋值.如果实在想传个值就哪里,显式的说明内存转移std:move一下.然后这样传值完了之后,之前的对象也同样报废了.只不过整个move你让明显的知道这样操作后会导致之前的unique_ptr对象失效.

显示的说明你要转移所有权, 而不再是 auto_ptr 莫名其妙地隐式转移权限; 直接禁止拷贝语义, 保留转移语义(只有右值引用的版本允许调用)

对比一下就知道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

auto_ptr<int> ap(new int(88));
auto_ptr<int> one (ap) ; // ok
auto_ptr<int> two = one; //ok

//但unique_ptr不支持上述操作
unique_ptr<int> ap(new int(88));
unique_ptr<int> one (ap) ; // 会出错
unique_ptr<int> two = one; //会出错

//uniuqe_ptr 可以进行移动操作
//例如作为函数的返回值(直接右值)
unique_ptr<int> GetVal()
{
unique_ptr<int> up(new int(88));
return up;
}

unique_ptr<int> uPtr = GetVal(); //ok

//或者直接move
unique_ptr<int> up(new int(88) );
//这里是显式的所有权转移, 把up所指的内存转给uPtr2了
unique_ptr<int> uPtr2 = std:move(up) ;

cppreference上的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <iostream>
#include <memory>

struct Foo { // object to manage
Foo() { std::cout << "Foo ctor\n"; }
Foo(const Foo&) { std::cout << "Foo copy ctor\n"; }//真正拷贝
Foo(Foo&&) { std::cout << "Foo move ctor\n"; } //真正转移
~Foo() { std::cout << "~Foo dtor\n"; }
};



struct D { // deleter
D() {};
D(const D&) { std::cout << "D copy ctor\n"; }
D(D&) { std::cout << "D non-const copy ctor\n";}
D(D&&) { std::cout << "D move ctor \n"; }
void operator()(Foo* p) const {
std::cout << "D is deleting a Foo\n";
delete p;
};
};

int main()
{
std::cout << "Example constructor(1)...\n";
std::unique_ptr<Foo> up1; // up1 is empty
std::unique_ptr<Foo> up1b(nullptr); // up1b is empty

std::cout << "\nExample constructor(2)...\n";
{
std::unique_ptr<Foo> up2(new Foo); //up2 now owns a Foo
} // Foo deleted

std::cout << "\nExample constructor(3)...\n";
D d;
{ // deleter type is not a reference
std::unique_ptr<Foo, D> up3(new Foo, d); // deleter copied
}
{ // deleter type is a reference
std::unique_ptr<Foo, D&> up3b(new Foo, d); // up3b holds a reference to d
}

std::cout << "\nExample constructor(4)...\n";
{ // deleter is not a reference
std::unique_ptr<Foo, D> up4(new Foo, D()); // deleter moved
}

std::cout << "\nExample constructor(5)...\n";
{
std::unique_ptr<Foo> up5a(new Foo);
std::unique_ptr<Foo> up5b(std::move(up5a)); // ownership transfer
}

std::cout << "\nExample constructor(6)...\n";
{
std::unique_ptr<Foo, D> up6a(new Foo, d); // D is copied
std::unique_ptr<Foo, D> up6b(std::move(up6a)); // D is moved

std::unique_ptr<Foo, D&> up6c(new Foo, d); // D is a reference
std::unique_ptr<Foo, D> up6d(std::move(up6c)); // D is copied
}

std::cout << "\nExample constructor(7)...\n";
{
std::auto_ptr<Foo> up7a(new Foo);
std::unique_ptr<Foo> up7b(std::move(up7a)); // ownership transfer
}
}

可以看到凡是涉及到左值的, 全部都用了move()调用了右值引用的重载版本.

个人觉得, unique支持容器, 并且支持数组类型, unique_ptr<T[]>, 这才是重点.

最初我也怀疑, unique不支持拷贝, 怎么能放入容器: auto_ptr不可做为容器元素, 而unique_ptr也同样不能直接做为容器元素.

实际上, 发现用右值, 或者移动语义是可以实现的:

1
2
3
4
5
unique_ptr<int> sp(new int(88) );
vector<unique_ptr<int> > vec;
vec.push_back(std::move(sp)); //显示地说明你要放入
//vec.push_back( sp ); 这样不行,会报错的.
//cout<<*sp<<endl;但这个也同样出错,说明sp添加到容器中之后,它自身报废了.

上面的学习, 也可以看到, unique_ptr和auto_ptr真的太像了, 只不过unique_ptr支持移动语义, 被c++11所推崇(并且其转移是可控的, 用户的显示行为).

尾巴

感觉已经说的够详细了.

文章目录
  1. 1. 引子
  2. 2. 正文
    1. 2.1. 智能指针实现机制
    2. 2.2. 引用计数简单实现
    3. 2.3. unique_ptr
  3. 3. 尾巴
|