C++ Primer 学习笔记_38_面向对象编程(九)-虚函数与多态(六):auto_ptr与shared_ptr、对象/值语义、资源管理(RAII)、实现auto_ptr、Ptr_vector
C++ Primer 学习笔记_38_面向对象编程(9)--虚函数与多态(六):auto_ptr与shared_ptr、对象/值语义、资源管理(RAII)、实现auto_ptr、Ptr_vector
一、auto_ptr与shared_ptr
1、auto_ptr
auto_ptr是当前C++标准库中提供的一种智能指针。
auto_ptr类是接受一个类型形参的模板,它为动态分配的对象提供异常安全,auto_ptr类在头文件memory中定义。
【小心地雷】
auto_ptr只能用于管理从new返回的一个对象,它不能管理动态分配的数组[会导致未定义的运行时行为]。
当auto_ptr被复制或复制时,有不寻常的行为,因此,不能将auto_ptr存储在标准库容器类型中。
每个auto_ptr对象绑定到一个对象或者指向一个对象。当auto_ptr对象指向一个对象的时候,可以说它“拥有”该对象。当auto_ptr对象超出作用域或者另外撤销的时候,就自动回收auto_ptr所指向的动态分配对象。
我们可以这样使用auto_ptr来提高地吗安全性
int* p = new int(0); auto_ptr<int> ap(p);
从此我们不必关系何时释放p,也不用担心发生异常会有内存泄漏,这是因为auto_ptr的析构函数会执行指针的释放,而析构函数会在ap出了作用域以后执行。
注意:
(1)因为auto_ptr析构的时候肯定会删除它所拥有的哪个对象,因此,两个auto_ptr不能同时拥有同一个对象。比如:
int* p = new int(0); auto_ptr<int> ap1(p); auto_ptr<int> ap2(p); //error
(2)因为auto_ptr的析构函数中删除指针用的是delete,而不是delete[],所以auto_ptr不应该用来管理一个数组指针。比如:
int* p = new int[10]; auto_ptr<int> ap(p);
与引用计数型智能指针不同的,auto_ptr要求其对“裸”指针的完全占有性。也就是说一个“裸”指针不能同时被两个以上的auto_ptr所拥有。所以,拷贝或赋值的目标对象将先释放其原来所拥有的对象。
(3)因为一个auto_ptr被拷贝或赋值后,其已经失去对原对象的所有权,这时候,对这个auto_ptr进行解引用操作是不安全的。如下
int* p = new int(0); auto_ptr<int> ap1(p); auto_ptr<int> ap2 = ap1; cout << *ap1; //Error,此时ap1只剩一个NULL指针
——较为隐蔽的情形是将auto_ptr作为函数参数按值传递,在函数调用过程中在函数的作用域中会产生一个局部对象来接收传入的auto_ptr(拷贝构造)。此时,传入的实参auto_ptr就失去了其对原对象的所有权。如下
void f(auto_ptr<int> ap) {cout << *ap;} auto_ptr<int> ap1(new int(0)); f(ap1); cout << *ap1; //Error,经过f(ap)函数调用,ap1已经不再拥有任何对象了
(4)因为auto_ptr不具有值语义,所以auto_ptr不能被用在STL标准容器中。值语义下面详细讲。
2、shared_ptr
auto_ptr由于它的破坏性复制语义,无法满足标准容器对元素的要求,因而不能放在标准容器中。boost库中提供了一种新型的智能指针shared_ptr,它解决了在多个指针间共享对象所有权的问题,同时也满足容器对元素的要求,因此可以安全放入容器中。
shared_ptr的作用同指针,但会记录有多少个shared_ptr共同指向一个对象。这便是所谓的引用计数。一旦最后一个这样的指针被销毁,也就是某个对象的引用计数变为0,这个对象会被自动删除。这在非环形数据结构中防止资源泄漏很有帮助。
【例】关于C++标准模板库,下列说法错误的有哪些()。(多选)
A、std::auto_ptr<class A>类型的对象,可以放到std::vector<std::auto_ptr<class A>>容器中
B、std::shared_ptr<class A>类型的对象,可以放到std::vector<std::shared_ptr<class A>>容器中
C、对于复杂类型T的对象tobj,++tobj和tobj++的执行效率相比,前者更高
D、采用new操作符创建对象时,如果没有足够内存空间而导致创建失败,则new操作符会返回NULL
解答:AD。
C选项,即便是基础类型++x的效率也比x++高,因为++x是对x加1然后返回x本身,而x++则要构建一个临时变量用来保存x的值,然后x本身++,最后返回的是临时变量,所以一般情况下在程序中如果只是简单要求对对象加1,推荐使用++x。
D选项,new在失败后,抛出标准异常std::bad_alloc而不是返回NULL。
二、对象语义与值语义
(1)、值语义是指对象的拷贝与原对象无关。拷贝之后就与原对象脱离关系,彼此独立互不影响(深拷贝)。比如说int,C++中的内置类型都是值语义,前面学过的三个标准库类型string,vector,map也是值语义
(2)、对象语义指的是面向对象意义下的对象
对象拷贝是禁止的(Noncopyable)
OR
一个对象被系统标准的复制方式复制后,与被复制的对象之间依然共享底层资源,对任何一个的改变都将改变另一个(浅拷贝)
(3)、值语义对象生命期容易控制
(4)、对象语义对象生命期不容易控制(通过智能指针来解决,见本文下半部分)。智能指针实际上是将对象语义转化为值语义,利用局部对象(智能指针)的确定性析构,包括auto_ptr, shared_ptr, weak_ptr, scoped_ptr。
(5)、值语义与对象语义是分析模型决定的,语言的语法技巧用来匹配模型。
(6)、值语义对象通常以类对象的方式来使用,对象语义对象通常以指针或引用方式来使用
(7)、一般将只使用到值语义对象的编程称为基于对象编程,如果使用到了对象意义对象,可以看作是面向对象编程。
(8)、基于对象与面向对象的区别
很多人没有区分“面向对象”和“基于对象”两个不同的概念。面向对象的三大特点(封装,继承,多态)缺一不可。通常“基于对
象”是使用对象,但是无法利用现有的对象模板产生新的对象类型,继而产生新的对象,也就是说“基于对象”没有继承的特点。而“多
态”表示为父类类型的子类对象实例,没有了继承的概念也就无从谈论“多态”。现在的很多流行技术都是基于对象的,它们使用一些
封装好的对象,调用对象的方法,设置对象的属性。但是它们无法让程序员派生新对象类型。他们只能使用现有对象的方法和属
性。所以当你判断一个新的技术是否是面向对象的时候,通常可以使用后两个特性来加以判断。“面向对象”和“基于对象”都实现了“封装”的概念,但是面向对象实现了“继承和多态”,而“基于对象”没有实现这些。
假设现在有这样一个继承体系:
其中Node,BinaryNode 都是抽象类,AddNode 有两个Node* 成员,Node应该实现为对象语义:
1、禁止拷贝。
比如
AddNode ad1(left, right); AddNode ad2(ad1);
假设允许拷贝且没有自己实现拷贝构造函数(默认为浅拷贝),则会有两个指针同时指向一个Node对象,容易发生析构两次的运行时错误。
下面看如何禁止拷贝的两种方法:
(1)方法一:将Node 的拷贝构造函数和赋值运算符声明为私有,并不提供实现
#include <iostream> using namespace std; //抽象类 class Node { public: Node() { } virtual double Calc() const = 0; virtual ~Node(void) {} private: Node(const Node &); const Node &operator=(const Node &); }; //抽象类 class BinaryNode : public Node { public: BinaryNode(Node *left, Node *right): left_(left), right_(right) {} ~BinaryNode() { delete left_; delete right_; } protected: Node *const left_; Node *const right_; }; class AddNode: public BinaryNode { public: AddNode(Node *left, Node *right): BinaryNode(left, right) { } double Calc() const { return left_->Calc() + right_->Calc(); } }; class NumberNode: public Node { public: NumberNode(double number): number_(number) { } double Calc() const { return number_; } private: const double number_; }; int main() { NumberNode *left = new NumberNode(3); NumberNode *right = new NumberNode(4); AddNode ad1(left, right); cout << ad1.Calc() << endl; // AddNode ad2(ad1); //Error return 0; }
运行结果:
7
最后一行会编译出错,注释掉就可以运行。即要拷贝构造一个AddNode 对象,最远也得从调用Node类的拷贝构造函数开始(默认拷贝构造函数会调用基类的拷贝构造函数,如果是自己实现的而且没有显式调用,将不会调用基类的拷贝构造函数),因为私有,故不能访问。
需要注意的是,因为声明了Node类的拷贝构造函数,故必须实现一个构造函数,否则没有默认构造函数可用。
(2)方法二:Node类继承自一个不能拷贝的类,如果有很多类似Node类的其他类,此方法比较合适
class NonCopyable { protected: //构造函数可以被派生类调用,但不能直接构造对象 NonCopyable() {} ~NonCopyable() {} private: NonCopyable(const NonCopyable &); const NonCopyable &operator=(const NonCopyable &); }; //抽象类,对象语义,禁止拷贝(首先需要拷贝NonCopyable) class Node : private NonCopyable { public: virtual double Calc() const = 0; virtual ~Node(void) {} };
注意NonCopyable 类的构造函数声明为protected,则不能直接构造对象,如NonCopyable nc; // error
但在构造派生类,如最底层的AddNode类时,可以被间接调用。
同样地,NonCopyable类的拷贝构造函数和赋值运算符为私有,故如 AddNode ad2(ad1); 编译出错。
三、资源管理
(一)、资源所有权
1、局部对象
资源的生存期为嵌入实体的生存期。
(1)、一个代码块拥有在其作用域内定义的所有自动对象(局部对象)。释放这些资源的任务是完全自动的(调用析构函数)。如:
void fun() { Test t; //局部对象 }
(2)、所有权的另一种形式是嵌入。一个对象拥有所有嵌入其中的对象。释放这些资源的任务也是自动完成(外部对象的析构函数调用内部对象的析构函数)。如
class A { private: B b; //先析构A,再析构b };
【附加:析构顺序可以用下列代码测试】
#include <iostream> using namespace std; class Test { public: Test() { cout<<"Test constructor"<<endl; } ~Test() { cout<<"Test destructor"<<endl; } }; class Test1 { public: Test1() { cout<<"Test1 constructor"<<endl; } ~Test1() { cout<<"Test1 destructor"<<endl; } }; class Test2 { public: Test2() { cout<<"Test2 constructor"<<endl; } ~Test2() { cout<<"Test2 destructor"<<endl; } private: Test a; Test1 b; }; int main() { Test2 *c=new Test2; delete c; return 0; }
运行结果:
Test constructor
Test1 constructor
Test2 constructor
Test2 destructor
Test1 destructor
Test destructor
2、动态对象(new 分配内存)
(1)、对于动态分配对象就不是这样了,它总是通过指针访问。在它们的生存期内,指针可以指向一个资源序列,若干指针可以指向相同的资源。动态分配资源的释放不是自动完成的,需要手动释放,如delete 指针。
(2)、如果对象从一个指针传递到另一个指针,所有权关系就不容易跟踪。容易出现空悬指针、内存泄漏、重复删除等错误。
(二)、RAII 与 auto_ptr
一个对象可以拥有资源。在对象的构造函数中执行资源的获取(指针的初始化),在析构函数中释放(delete 指针)。这种技法把它称之为RAII(Resource Acquisition Is Initialization:资源获取即初始化),如前所述的资源指的是内存,实际上还可以扩展为文件句柄,套接字,互斥量,信号量等资源。
对应于智能指针auto_ptr,可以理解为一个auto_ptr对象拥有资源的裸指针,并负责资源的释放。
下面先来看auto_ptr 的定义:
// TEMPLATE CLASS auto_ptr template<class _Ty> class auto_ptr { .... private: _Ty *_Myptr; // the wrapped object pointer }
实际上auto_ptr 是以模板方式实现的,内部成员变量只有一个,就是具体类的指针,即将这个裸指针包装起来。auto_ptr 的实现里面还封装了很多关于裸指针的操作,这样就能像使用裸指针一样使用智能指针,如->和* 操作;负责裸指针的初始化,以及管理裸指针指向的内存释放。
这样说还是比较难理解,可以自己实现一个模拟 auto_ptr<Node> 类的NodePtr 类,从中体会智能指针是如何管理资源的:
Node.h:
#ifndef _NODE_H_ #define _NODE_H_ class Node { public: Node(); ~Node(); void Calc() const; }; class NodePtr { public: explicit NodePtr(Node* ptr = 0) : ptr_(ptr) {} NodePtr(NodePtr& other) : ptr_(other.Release()) {} NodePtr& operator=(NodePtr& other) { Reset(other.Release()); return *this; } ~NodePtr() { if (ptr_ != 0) delete ptr_; } Node& operator*() const { return *Get(); } Node* operator->() const { return Get(); } Node* Get() const { return ptr_; } Node* Release() { Node* tmp = ptr_; ptr_ = 0; return tmp; } void Reset(Node* ptr = 0) { if (ptr_ != ptr) { delete ptr_; } ptr_ = ptr; } private: Node* ptr_; }; #endif // _NODE_H_
Node.cpp:
#include <iostream> #include "Node.h" Node::Node() { std::cout << "Node ..." << std::endl; } Node::~Node() { std::cout << "~Node ..." << std::endl; } void Node::Calc() const { std::cout << "Node::Calc ..." << std::endl; }
main.cpp:
#include <iostream> using namespace std; #include "DebugNew.h" #include "Node.h" int main(void) { Node *p1 = new Node; NodePtr np(p1); np->Calc(); NodePtr np2(np); Node *p2 = new Node; NodePtr np3(p2); np3 = np2; //np3先delete p2,接着接管p1; return 0; }
从输出可以看出,通过NodePtr 智能指针对象包装了裸指针,NodePtr类通过重载-> 和 * 运算符实现如同裸指针一样的操作,如
np->Calc(); 程序中通过智能指针对象的一次拷贝构造和赋值操作之后,现在共有3个局部智能指针对象,但np 和 np2 的成员ptr_ 已经被设置为0;第二次new 的Node对象已经被释放,现在np3.ptr_ 指向第一次new 的Node对象,程序结束,np3局部对象析构,delete ptr_,析构Node对象。
从程序实现可以看出,Node 类是可以拷贝,而且是默认浅拷贝,故是对象语义对象,现在使用智能指针来管理了它的生存期,不容易发生内存泄漏问题。
所以简单来说,智能指针的本质思想就是:用栈上对象(智能指针对象)来管理堆上对象的生存期。
在本文最前面的程序中,虽然实现了禁止拷贝,但如上所述,对象语义对象的生存期仍然是 不容易控制的,下面将通过智能指针auto_ptr<Node> 来解决这个问题,通过类比上面NodePtr 类的实现可以比较容易地理解auto_ptr<Node>的作用:
#include <iostream> #include <memory> using namespace std; //抽象类 class Node { public: Node() { } virtual double Calc() const = 0; virtual ~Node(void) {} private: Node(const Node &); const Node &operator=(const Node &); }; //抽象类 class BinaryNode : public Node { public: BinaryNode(std::auto_ptr<Node> left, std::auto_ptr<Node> right) : left_(left), right_(right) {} ~BinaryNode() { // delete left_; // delete right_; } protected: std::auto_ptr<Node> left_; std::auto_ptr<Node> right_; }; class AddNode: public BinaryNode { public: AddNode(std::auto_ptr<Node> left, std::auto_ptr<Node> right) : BinaryNode(left, right) { } double Calc() const { return left_->Calc() + right_->Calc(); } }; class NumberNode: public Node { public: NumberNode(double number): number_(number) { } double Calc() const { return number_; } private: const double number_; }; int main() { AddNode ad1(std::auto_ptr<Node>(new NumberNode(3)), std::auto_ptr<Node>(new NumberNode(4))); cout << ad1.Calc() << endl; //AddNode ad2(ad1); //Error return 0; }
运行结果:
7
需要注意的是,在BinaryNode 中现在裸指针的所有权已经归智能指针所有,由智能指针来管理Node 对象的生存期,故在析构函数中不再需要delete 指针; 的操作。
参考:
C++ primer 第四版
C++ primer 第五版
版权声明:本文为博主原创文章,未经博主允许不得转载。