"野性袒露着灵魂纯粹"
条款13:以对象管理资源
(1)什么是资源?
C++中最常使用的资源就是动态内存分配,在系统编程层面上,文件描述符(fd)、互斥锁(mutex)、套接字网络socket……不管是哪一种资源,重要的是,你不使用时,就应该将其释放掉。
(2)谈谈智能指针
C++11中引入了这种方式来对类对象资源的管理。
我们在此基础上使用库文件提供的智能指针,对代码进行优化。
void f()
{
//调用工厂factory函数 经由auto_ptr管理控制该资源
std::auto_ptr<Investment> pInv(Investment::GetInstance());
//Investment* ptr = Investment::GetInstance();
//delete ptr;
}
auto_ptr的析构函数,会在其析构函数中自动调用管理对象的delete,从而能够有效避免f函数潜在的资源泄漏的问题。
用"对象管理资源",我们得出了两个关键想法:
①获得资源后,立即将资源放入对象中管理。
②管理对象运用析构函数确保资源释放。
勿乱copy智能指针:
由于auto_ptr对象在出了自己的作用域时,会去调用其析构函数,并delete掉自己所指向的对象。如果我们让多个auto_ptr指向同一个对象呢?? 那是否意味着会 对同一个对象调用多次析构函数???显然是这样的!但是auto_ptr绝不会让我们进行这样的操作。
"要让auto_ptr通过copy构造或者copy assignment操作复制它们,它们就会变为null。而复制所得的指针才拥有取得资源的唯一拥有权!"
计数型智能指针:
auto_ptr的复制行为,只能产生唯一一个指针对对象拥有管理权力,即"受到auto_ptr管理的对象,在这之前必须没有任何一个以上的auto_ptr同时指向它"。显然,auto_ptr对于动态分配资源的管理,并非神兵利器!举个例子map的例子,红黑树的结点通过new出来的,但是map是支持深拷贝的(STL容器都允许这样),那么这些容器显然不能使用auto_ptr管理其元素对象。
替代auto_ptr的方案是,引入"计数型智能指针"(RCSP)。RCSP工作的原理是,持续追踪有多少个对象指向某笔资源,并在无人指向它时,自动删除该资源。
但,shared_ptr无法打破环状引用。(例如一个对象它们互相指向彼此)。当然此种情况并非本篇所要细讲的。
勿用智能指针管理动态分配的array:
auto_ptr 和 shared_ptr两者都在析构函数内做的是delete 而不是 delete[]。
//这两种形式 都是不可取的
std::auto_ptr<std::string>
ptr(new std::string[10]);
std::shared_ptr<int> spi(new int[1024]);
编译器虽然能让我们编译通过,但采取这样的行为,一定是个馊主意。
(3)开胃菜
我们建立模拟一个投资的行为。并且采用工厂设计模式,对外提供一个获取该类对象的接口函数。
class Investment
{
public:
static Investment* GetInstance()
{
return new Investment(); //获取指针
}
};
void f()
{
Investment* ptr = Investment::GetInstance(); //获取指针
//...进行一系列操作
delete ptr; //对new 申请的资源进行释放
}
f()
从代码本身来看,似乎没什么明显的错误。我们动态申请了一块内存空间指向Investment类对象,并在使用完成后对其进行了 delete操作。但问题是真的没有隐含的危险吗??
例举以下情况,可能造成delete释放资源失败:
①或许在 "..."区域, 过早进行了return。一旦函数return后,return之后的语句也不会被执行流执行了。
②在"..."区域进行了 抛异常,很不幸捕获异常的地方不在该函数内。
……
当这些情况出现时,我们很难对曾经向系统申请的资源进行有效地管理释放。因此,单纯依赖、相信着f()函数会执行到"delete ptr"这一步是很不可靠的!
"以对象管理资源"提出的设计模式很像一个类对象的析构函数。什么情况下意味着该内存资源对象应该被释放呢?答案是,"出了f()的作用域后"。
而这种利用对象的生命周期来控制资源管理的技术,又叫做,"RAII"。也叫做智能指针。
auto_ptr:
std::auto_ptr<Investment> pInv1(Investment::GetInstance());
shared_ptr:
std::shared_ptr<Investment> pInv1(Investment::GetInstance());
最后,单纯地用工厂函数返回Investment* (raw pointer),本身就是造成资源泄漏的前提。调用者本身就可能忘记对这个指针的delete。更好的做法是,在返回对象指针的函数体内,就将该指针放入到智能指针里进行管理。
请记住:
1.为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
2.两个常被使用的RAIII classes分别是auto_ptr \ shared_ptr。前者是较佳选择,因为其copy行为比较直观。若选择auto_ptr赋值动作会使它们指向null。
条款14:在资源管理类中小心copying行为
在条款13引入了这样一个观念,"资源取得时机就是初始化时机。"。事实上很多系统资源并非像heap-based那样,我们可以用auto_ptr 和 shared_ptr对资源进行释放管理。既然如此,有时候就需要,我们自己去实现一个资源管理的类。
例如,我们使用一个基于C的API接口函数,该处理函数是处理Mutex互斥对象的锁,并提供了lock、unlock函数。
void lock(Mutex* pm); //锁定互斥锁
void unlock(Mutex* pm); //解除锁定
我们为了防止调用者忘记unlock,解除释放掉锁资源,并为管理Mutex而设计一个Lock类
class Lock
{
public:
Lock(Mutex* pm)
:mutexptr(pm){
lock(mutexptr); //构造函数时 锁定互斥锁
}
~Lock(){
unlock(mutexptr); //析构函数时 解除锁
}
Mutex* mutexptr;
};
//现在用户对lock进行调用
Mutex m; //创建锁对象
{
Lock ml(&m); // 锁定互斥锁
} //释放资源
我们可以看到,当客户按照这样的思路定义使用互斥锁时,当不再使用互斥锁时,因为对象出作用域自动调用析构函数的机制,Lock会为调用着自动解除锁。
如果此时Lock对象发生拷贝复制会发生什么?我们面对的问题是什么?
Lock m1(&m);
Lock m2(m1); //拷贝构造
禁止复制:
很多情况下,让RAII对象被复制就是不合理的做法。对于像Lock这样的一个class,一个已经被加锁的对象,只是因为你允许类的拷贝,又会再一次对这个对象进行了一次加锁,并且在出了Lock对象的作用域时,原管理对象会调用自己的析构函数,但又因为支持拷贝构造的缘故,它的副本又会去进行一次锁释放!因此,如果RAII的赋值操作不合理,你就应该禁止掉!
我们可以参考将类对象的拷贝构造声明为私有,C++11引入的 delete关键字禁用掉,或者采用类继承的方式,私有继承一个不能拷贝的基类。
class Lock
{
private:
//1.声明私有
//Lock(const Lock&);
//Lock& operator=(const Lock&);
//2.delete
Lock(const Lock&) = delete;
Lock& operator=(const Lock&) = delete;
}
//3.继承防拷贝的基类
class Lock:private Uncopyable
{}
对底层资源祭出"引用计数法":
但有时候,我们又需要保有资源,直到最后一个使用者不再使用时,才将它进行销毁。这种情况下的对RAII对象复制拷贝时,我们仅希望于该资源的"引用"递增,我们看到的shared_ptr就是这样处理拷贝的。
如果对于上述Lock对象里,我们想实现引用计数的RAII管理,即允许Lock类对象进行拷贝。虽然shared_ptr的默认行为是,当没有对象引用时,会直接做出释放资源的动作,而我们想要的不是删除Mutex,而是释放Mutex。不过,shared_ptr区别于auto_ptr的是,它提供了所谓的"定制删除器"。
我们可以更改当引用对象为0时,"释放资源"更改为让该资源"解除锁定"。
class Lock
{
public:
Lock(Mutex* pm)
:mutexptr(pm,unlock) //填写定制删除器
{
lock(mutexptr.get()); //获取 raw pointer
}
//使用shared_ptr 替换原始的 raw pointer
std::shared_ptr<Mutex> mutexptr;
};
此时还需要析构函数嘛??不需要!当shared_ptr引用的次数为0时,会去自动调用其指定的删除器(unlock)。不过这不是说你轻松了许多,你只是依赖了编译器的默认行为罢了。
为此,你总希望你的 RAII对象能够根据不同的场景,做出合理的选择。
复制底部资源:
如果你希望这一份资源拥有任意数量的副本,当你不需要使用这个副本时,又需要确保其资源得以释放。在这样的情况下,那么你在复制其资源时,也得同样拷贝一份管理该资源的对象。
很现实的例子,我们向heap内存区申请空间,存放一系列string类型,这些字符串内含一个指向heap区的指针,并通过返回指向这些指向字符串的起始地址的指针供使用者管理。当这样的字符串发生拷贝时,不论其指针所指向的内存空间内容,还是该指针,都将会被制作成一个副本。这种行为也叫做"深拷贝"。
转移底部资源拥有权:
如果你就单纯希望,确保这一个未加工的资源始终永远只会受到一个RAII对象管理。那么RAII对象的复制拷贝,应该参照像auto_ptr那样,此时的"拥有权会从被复制物转移到目标物上"。
当然如果你厌恶RAII对象的管理中出现copying 的行为,你就应该明确地拒绝!
请记住:
1.复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。
2.普遍而常见的RAII class的copying行为是: 抑制copying \ 施行引用计数法。不过其他行为也都可能被实现。
条款15:在资源管理类中提供对原始资源的访问
看完上面的好几条条款,emm资源管理类很棒!它可以确保我们在"正常使用它"的情况下,协助调用者进行系统资源的管理。我们处理资源的动作,变为与维护、管理这个资源的classes类进行互动。不过,大千世界无奇不有,可能你会遇到很多种API接口直接指涉原始资源(raw pointer),除非你发誓你不会用这些APIs,否则我们不得不绕过资源管理类对象,直接访问其包含的原始资源。
(1)获取资源管理类中的原始资源
当然,在RAII中提供对管理对象的原始资源,并非空穴来风。在C++标准库中,智能指针都会提供一个叫get()的函数,用来获取包裹下的原始资源。
我们回到条款13举例的类
class Investment
{
public:
static Investment* GetInstance()
{
return new Investment();
}
};
int daysHeld(const Investment* pi) //返回天数
{
return 0;
}
int main()
{
std::shared_ptr<Investment> pInv(Investment::GetInstance());
int days = daysHeld(pInv); 错误! 类型不匹配
return 0;
}
①auto_ptr\shared_ptr提供的get()方法
int days = daysHeld(pInv.get());
通过get()函数获取RAII管理对象的原始资源,这也叫做显示类型转换。
②auto_ptr\shared_ptr的operator重载
智能指针重载了(operator-> 或者 operator*),他们允许隐式类型转换成底部的原始指针。
③class提供的函数转换
很多情况下,我们必须取得RAII对象内的原始资源,由此,RAII的一些设计者干脆提供一个隐式类型转换的函数。我们来看看下面的RAII class。
FrontHandle getFont(){} //C API接口
void realseFont(FrontHandle fh){} // C API接口
class Font
{
public:
Font(FrontHandle f) //pass-by-value
:fh(f)
{}
~Font() { realseFont(fh); }
private:
FrontHandle fh; //原始raw
};
假设客户大量的调用函数与C API接口相关,也就意味着它们需要频繁地处FrontHandle(原始资源),为此,Font class可以为之提供诸如auto_ptr \ shared_ptr一样的get()方法。
class Font
{
public:
//...
FrontHandle get() { return fh; }
//...
};
void changeFontSize(FrontHandle f, int newSize) {}
int main()
{
int newSize;
Font f(getFont());
changeFontSize(f.get(), newSize); //显示转换
return 0;
}
虽然这个提供get()方法,能够有效解决,"Font -> FrontHandle"的转换,但是如此般地显示转换未免显得太麻烦。麻烦的事情,总会让人容易犯错。
另一种方法,是令Font提供隐式类型转换函数,转换为FrontHandle。
class Font
{
public:
//...
operator FrontHandle() const
{
return fh; //隐式类型转换
}
//...
}
int main()
{
Font f(getFont());
int newSize;
changeFontSize(f, newSize); //Font隐式转换为FrontHandle
}
不过,隐式类型转换也会增加发生错误的机会。例如,客户创建Font时,意外创建FrontHandle。
int main()
{
Font f(getFont());
//...
//愿意是让f1 去构造一个f2的类对象
//但实际上却是让f1 隐式类型转换为底部的FrontHandle 并复制它
FrontHandle f2 = f1;
return 0;
}
由此,一旦f1被销毁后,f2其实也只是徒有虚名。原始资源已经被释放掉,它因而成为"虚吊"。
因此,是否该为一个RAII对象提供一个显示转换函数,还是隐式类型转换函数,拿到底层的原始资源,主要取决于RAII被设计执行的工作情况。
通过get()方法显示类型转换得到原始资源也许更加地受欢迎,并将"非有意转换"的可能性降到最小化。然而有时候,隐式类型转化的"自然而然"又会引发天平的倾斜。
不过,获取原始资源 != 破坏了封装。RAII对象类的出现不是为了实现封装,而是为了实现资源的有效管理,确保资源释放的行为发生。此外也有某些RAII classes结合十分松散的底层资源封装,其目的就是为了获得真正的原始资源。例如,shared_ptr将引用计数封装了起来,但是外界可以更容易访问到其内部所含的指针。
请记住:
1.APIs往往要求访问原始资源,所以RAII class应该提供一个"取得其所管理之资源"的办法。
2.对原始资源的访问可能经由显示转换或隐式转换,一般而言显示转换比较安全,但隐式转换对于客户比较方便。
条款16:成对使用new和delete时采用相同形式
我们来看看下述代码:
int main()
{
std::string* StringArray = new std::string[100];
//....
delete StringArray;
return 0;
}
唔……我们使用操作符new向堆空间申请一批空间,也配套使用了delete。但程序运行起来就出问题了!
当你使用new时:
第一件事,内存被分配出来(new 在底层会转化为operator new())。
第二件事,针对此内存,会一次(多次)去调用构造函数。
当你使用delete时:
第一件事,针对此内存,会一次(多次)调用析构函数。
第二件事,内存被释放掉(delete 在底层会转化为operator delete())。
因此,上述现象最大的问题在于,到底调用一次还是多次(构造\析构)函数。换句话说,该指针,指向的这块即将被释放掉的内存资源,是单个对象 或是 数组对象?
从现象来看,我们给delete 填上"[]",问题也就能够解决了。
由此:
当你调用new时 使用 [] ,那么你在delete时也要带上[]。
反之如果你在调用new时没有使用[] ,那么delete 也就不需要使用[]。
尽量不要对数组进行typedef:
我想开辟一个类型为int,个数为4个的数组。
typedef std::string AddressLine[4];
int main()
{
std::string* sptr = new AddressLine; //类型是什么??
delete[] sptr; //正常释放
delete sptr; //结果未定义
}
出现上述代码的动作,让使用者难以区分到底改用delete[] 还是 delete。为此,还是少用为好,比较STL提供了vector这种超值的序列式容器。
请记住:
1.new 与 delete成对使用时要采取相同的形式。
条款17:以独立语句将newed对象性置入智能指针
假设我们有个函数用来揭示处理程序的优先权,另一个函数用来在某动态分配所得的Widget上进行某些带有优先权的处理:
int priority(){}
void ProcessWidget(Widget* pw,int priority){}
诶,由于我们是读过 《Effictive C++》的,并且费了老大精力"研究","以对象管理资源"。因此,我们这里用智能指针(这里采用shared_ptr)来管理这个Widget对象。
int priority(){}
void ProcessWidget(std::shared_ptr<Widget> pw,int priority){}
emm,现在到我们该调用了。
ProcessWidget(new Widget, priority());
但其实是编译不过的!因为函数参数的类型是std::shared_ptr<Widget>。
//ProcessWidget(new Widget, priority())
ProcessWidget(std::shared_ptr<Widget> (new Widget), priority());
我们现在来分析分析函数调用的过程。
可以看出,priority()函数的调用次序可以是多种情况。但最能存在潜在危险的是第一种情况!因为,如果priority()的调用一旦抛异常,此时向堆空间申请的 Widget对象,尚未传递参数给ProcessWidget中的智能指针构造,是的,我们期盼筑牢防范内存泄漏的武器并没有被使用上!从而,在调用ProcessWidget的过程中发生了内存泄漏。
这是,"资源被创建"和"资源转换为资源管理对象"的两个时间点之间,可能会受到异常的干扰!
为此,简单的做法是,分离语句!在创建Widget对象时,就将其置入智能指针当中。
std::shared_ptr<Widget> pw(new Widget); //单独的语句 将对象置入智能指针
ProcessWidget(pw, priority()); //此时就绝不会发生内存泄漏
此时,"new Widget"与"priority()",经由语句分割开来。此时编译器也不能在它们之间任意选择执行次序。
请记住:
以独立的语句将newed对象置入智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄漏。
本篇到此结束,感谢你的阅读。
祝你好运,向阳而生~