Effective C++笔记(3)
本文最后更新于 585 天前,其中的信息可能已经有所发展或是发生改变。

资源管理

条款13: 以对象管理资源

在C++中,资源多数是指动态分配的内存。如果你只用new来分配内存却不在使用完后delete掉,将会导致内存泄漏

内存泄漏:程序在中申请的动态内存,在程序使用完成时没有得到及时的释放。当这些变量的生命周期已结束时,该变量在堆中所占用的内存未能得到释放,从而就导致了堆中可使用的内存越来越少,最终可能产生系统运行较慢或者系统因内存不足而崩溃的问题。

假设我们在为不同类型的投资写一个库:

class Investment{...};            //Investment继承层级的基类
Investment* createInvestment();   //返回一个动态分配的Investment层级对象指针

返回一个指针就说明我们要负责在用毕后及时释放资源:

void f(){
  Intestment* pInv = createInvestment(); //分配
  ...                                    //使用
  delete pInv;                           //释放
}

存在的问题:若中间部分存在并触发了return语句,则delete会被跳过;若delete语句在某个循环中,若触发了breakgoto语句,delete也不会被执行;若中间代码抛出异常,该指针也不会被删除……

解决方案1(已被弃用):利用C++的对象析构函数自动调用机制,把资源封装在对象里面,这样当对象完成了它的使用周期,资源就会保证被释放。使用标准库的模板智能指针auto_ptr,它的析构函数会自动调用delete来释放它所指向的对象。

void f(){
  std::auto_ptr<Investment> pInv(createInvestment());
  // auto_ptr (deprecated in C++11; removed in C++17)
  ...
} // 无需手动释放

RAII(Resource Acquisition Is Initialization)

获得资源后要立即传递给资源管理对象用来初始化。
资源管理类要利用析构函数来释放资源。当对象超出了它的作用域(scope)时,它的析构函数会自动调用,所以用析构函数来释放资源是保证安全的。

⚠️因为auto_ptr会在使用周期后自动删除资源,所以不要使用多个auto_ptr指向同一个对象,否则同一个对象会被释放两次,是一个非法操作。为了防止这种操作,标准库给auto_ptr定义了一个奇怪的特性: 拷贝旧指针到一个新指针,旧指针会被设为NULL:

std::auto_ptr<Investment> pInv1(createInvestment()); 
std::auto_ptr<Investment> pInv2(pInv1); //通过构造函数来拷贝,pInv1现在是NULL
pInv1 = pInv2;                          //通过赋值运算符来拷贝,pInv2现在是NULL

解决方案2:使用引用计数的智能指针(Reference-Counting Smart Pointer, RCSP),它在运行时会统计有多少对象指向当前的资源,然后当没有任何对象指向当前资源时便会自动释放。C++标准库shared_ptr为例:

void f(){
  std::shared_ptr<Investment> pInv(createInvestment());
  ...
}  //同样无需手动释放

shared_ptr可以在STL容器中使用,成为一个更好的选择,因为shared_ptr没有auto_ptr自动把原对象设为NULL的拷贝特性,也因为当使用容器的算法功能生成本地拷贝时,此时有两个对象指向了这个资源。即使拷贝的析构函数被调用了,原有的对象依然在指向这个资源,该资源便不会被提前释放。

使用智能指针只是用对象管理资源的方法之一,而且也存在着局限性。例如我们不能使用标准库的智能指针来指向一个动态分配的数组,因为析构函数调用的是delete而不是delete[],虽然这样做依然可以通过编译。

std::auto_ptr<std::string> aps(new std::string[10]);
std::tr1::shared_ptr<int> spi(new int[10]);
//析构函数并不会调用delete[]

条款14: 小心考虑资源管理类的拷贝行为

上一条款提到了如何用智能指针管理基于堆(heap)的资源,但对于堆以外的资源(如mutex锁)智能指针不再那么好用了,因此需要写自己的资源管理类。

假设使用C API函数处理类型为Mutex的互斥器对象(mutex objects),有两个函数可用:

void lock(Mutex* pm);    //锁住pm指向的锁
void unlock(Mutex* pm);  //解锁pm指向的锁

同时我们有一个符合RAII规范的类来管理这些锁,RAII即获取资源在对象构造过程中,释放资源在对象析构过程中:

class Lock{
  public:
    explicit Lock(Mutex* pm) 
    :mutexPtr(pm)
    {lock(mutexPtr);}             //在构造时获取资源,上锁
    ~Lock(){ unlock(mutexPtr); }  //在析构时释放资源,解锁
  private:
    Mutex* mutexPtr;
};

例如访问临界区(critical section), 临界区即线程必须互斥地访问某些资源,这些资源必须只能由最多一个线程访问,我们就需要以RAII的方式来进行操作:

Mutex m;       //定义需要的互斥器
...
{              //创建一个代码块来定义临界区
  Lock ml(&m); //构造锁ml,锁住m
  ...          //执行临界区操作
}              //临界区结束,调用ml的析构函数,自动解锁

在创建自己的RAII资源管理类时,我们必须要思考需要如何规定这个类的拷贝行为。

解决方案1:禁止拷贝
有些对象的拷贝是没有意义的,就比如栗子中的这个锁,没有人会给同一个资源上两个锁,对于这样的类,我们就干脆禁止掉拷贝。(见条款06)

class Lock : private Uncopyable{  //见条款06
  public:
     ...  //与之前相同的定义
};

解决方案2:给资源引用计数:

可以替代裸指针把shared_ptr作为RAII对象的数据成员来实现这个功能,将mutexPtr的类型从Mutex*变成shared_ptr<Mutex>。shared_ptr提供了一个特殊的可定义函数,删除器(deleter),即在引用计数为零时调用的函数,是shared_ptr构造函数的一个附加参数。这个函数在auto_ptr中是不存在的,因此它不能有自定义的删除行为,只能删除掉它包括的指针。

class Lock{
  public:
    explicit Lock(Mutex* pm)
    :mutexPtr(pm, unlock)    //将unlock函数绑定到删除器
    {lock(mutexPtr.get());}
    //这里其实不需要定义析构函数(告诉别人我们没有忘记析构函数,只是利用了C++的特性。)
  private:
    std::shared_ptr<Mutex> mutexPtr; //使用shared_ptr,不使用裸指针
};

这里没有定义析构函数,因为类的析构函数会调用它的非静态数据成员的析构函数(条款05)。这个例子中,Lock类的析构函数会调用它的成员mutexPtr的析构函数,而在当mutexPtr的引用计数为零时,它的析构函数则会调用删除器,即我们绑定的unlock函数。

常用的RAII类的拷贝行为有禁止拷贝,使用引用计数,拷贝资源,转移所有权,但也可以用其他做法来符合特殊需要。

条款15: 在资源管理类中提供对原始资源的访问

假设在为不同类型的投资写函数库:

//某个Investment的智能指针对象
std::shared_ptr<Investment> pInv(createInvestment()); 

//假设有个daysHeld函数
int daysHeld(const Investment* pi); //返回某个Investment对象的持有时间

//若直接把智能指针对象传入该函数
int days = daysHeld(pInv);   // error! 

此时编译器会报错,因为函数需要裸指针类型的参数,而你传入的是智能指针类型。你需要做的也很简单,把智能指针转换为裸指针,使用隐式转换或者显式转换

shared_ptrauto_ptr都有一个成员函数get(),用来执行显式转换,返回智能指针对象所包含的裸指针:

int days = daysHeld(pInv.get());

shared_ptr和auto_ptr也重载了指针的解引用运算符,即->和*,这意味着我们可以通过它们来实现隐式转换:

class Investment{  //定义Investment继承层次的基类
  public:
    boos isTaxFree() const;
  ...
};

Investment* createInvestment();                            //用来返回指向Investment对象的指针
std::shared_ptr<Investment> pi(createInvestment()); //使用shared_ptr管理资源
bool taxable = !(pi->isTaxFree());                  //使用->操作符接触资源
taxable = !((*pi).isTaxFree());                     //使用*操作符接触资源

定义自己的RAII资源管理类:

class Font{
  public:
    explicit Font(FontHandle fh): //C只能使用值传递
    f(fh) {} //构造时获取资源
    ~Font() {releaseFont(f);}     //析构时释放资源
    ...
  private:
    FontHandle f;
};

如果我们要使用某些C API只能使用FontHandle类型,我们就需要把Font类型显式转换FontHandle类型,因此我们定义一个显式转换的函数get()

class Font{
  public:
  ...
  FontHandle get() const {return f;}  //显式转换函数
  ...
};

但是这样显式转换的缺点就是每次使用都要调用get()函数,比较麻烦

void changeFontSize(FontHandle f, int newSize); //改变字体大小的C API
Font f(getFont());
int newFontSize;
...
changeFontSize(f.get(), newFontSize);  //需要使用get()来显式转换,麻烦

另外一个缺点就是,我们既然写了RAII资源管理类,为什么还要每次只使用它的原始资源,这不是跟我们希望避免资源泄漏的初衷背道而驰吗?我们下面来看看隐式转换

class Font{
  public:
    ...
    operator FontHandle() const  //隐式转换函数
    {return f;}
    ...
};

这样调用API就会简单很多:

Font f(getFont());
int newFontSize;
...
changeFontSize(f, newFontSize); //隐式转换

但是隐式转换也有缺点,某些类型错误就不会被编译器探测到了:

Font f1(getFont());
...
FontHandle f2 = f1;

我们希望把一个Font对象拷贝进另一个Font对象,但倘若输入的Font变成了FontHandle,这样就把我们的资源管理对象变成了原始资源对象,编译器也不会报错

结果就是,程序有一个FontHandle对象被f1封装和管理,但这个FontHandle通过上面的操作被拷贝进了f2,这样f1f2同时控制着一个FontHandle。如果f1被释放,这个FontHandle也将被释放,就会导致f2的字体被损坏。

条款16: newdelete要对应使用

std::string strPtr1 = new std::string;  //分配单个对象
std::string strPtr2 = new str::string[100];  //分配一个数组
...
delete strPtr1;   //使用delete删除单个对象
delete[] strPtr2; //使用delete[]删除数组

如若未对应起来使用,结果皆为未定义。

typedef类型中需要另加注意,最好在代码注释中标出要使用什么版本的delete(最好别用typedef定义数组):

typedef std::string AddressLines[4]; //定义AddressLines类型为
                                     //一个包含4个字符串的数组
                                     //所以需要对其使用delete[]
std::string *pal = new AddressLines; //相当于new string[4]
...
delete pal;   //错误
delete[] pal; //正确

条款17: 使用单独的语句创建智能指针对象

假设我们有如下函数:

int priority();  //返回处理优先级
void processWidget(std::shared_ptr<Widget> pw, int priority); //处理对象根据不同优先级

现在用如下的语句调用这些函数:

processWidget(new Widget, priority());

这句调用必然会导致编译器报错,因为除非定义过隐式转换的运算符,裸指针对象不能被隐式转换为智能指针对象,下面才是语法正确的调用方式:

processWidget(std::shared_ptr<Widget>(new Widget), priority());

⚠️即使你在这里使用了智能指针,内存泄漏依然有发生的可能。

解析该函数的参数分为三步:

  • 调用priority()函数
  • 执行new语句
  • 调用智能指针构造函数

不像Java或者C#的编译器只会以固定的顺序解析参数,C++编译器多种多样,而且根据优化选项的不同,编译器可能会改变这三步的执行顺序,以此利用指令流水线停顿的周期(pipeline stall),获得更高效率的代码。假设某编译器生成的机器代码实际执行顺序如下:

  1. 执行new语句
  2. 调用priority()函数
  3. 调用智能指针函数

如果priority()函数抛出了异常呢?那么从new语句动态分配的资源在到达智能指针构造函数之前就会泄露了。解决方法也很简单,使用一个单独的语句来创建智能指针对象:

std::shared_ptr<Widget> pw(new Widget); //放在单独的语句里
processWidget(pw,priority());           //这样就不会泄露资源了

编译器是逐语句编译的,通过使用一个单独的语句来构造智能指针对象,编译器就不会随意改动解析顺序,保证了生成的机器代码顺序是异常安全的,以及这样的代码写出来也更加美观。

「用一个单独的语句把裸指针储存到智能指针中。否则资源泄漏可能就会这么意想不到地发生了。」

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 。转载请注明出处!
您可以通过 RSS 订阅本站文章更新。

评论

  1. EchoForger
    Macintosh Safari
    2 年前
    2022-11-05 21:35:40

    支持!

    • 博主
      EchoForger
      Macintosh Chrome
      2 年前
      2022-11-05 22:59:45

      一起加油!

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇