Effective C++读书笔记~3 资源管理

条款13:以对象管理资源

Use objects to manage resources.

传统new/delete 申请、释放资源的问题

class Investment { ... };
class Factory 
{ 
public:    
    static Investment* createInvestment(); // 工厂函数
};

void f( ) // 客户函数
{
        Investment* pInv =Factory::createInvestment(); // 调用工厂函数,创建Investment对象
    ...
    delete pInv; // 释放pInv所指对象
}

正常时,没有问题。而"..."代码段如果提前return,或者出现异常,提前退出f()函数,那么pInv所指Investment对象就无法释放。
为解决这个问题,可以使用RAII(Resource Acquisition Is Initialization,资源取得时机便是初始化时机)对象的机制,即对象创建时便初始化资源,对象析构时便销毁资源。而智能指针便是一种较好的管理资源对象的方式。

使用智能指针unique_ptr管理对象

对于上述问题,可以使用智能指针管理资源。
unique_ptr是一种智能指针,能确保某个时刻只能有一个unique_ptr指向一个给定对象。当unique_ptr被销毁时,它所指向的对象也被销毁。C++11引入,可用来替换auto_ptr。

void f()
{
       unique_ptr<Investment> pInv(Factory::createInvestment());  // OK
       //...
       unique_ptr<Investment> pInv2(pInv); // 错误:unique_ptr不支持2个指针指向同一个对象
       unique_ptr<Investment> pInv3;
       pInv3 = pInv; // 错误:unique_ptr不支持2个指针指向同一个对象
       // f退出前调用unique_ptr析构函数,自动删除pInv
}

使用引用计数智能指针shared_ptr管理对象

unique_ptr的缺点是无法复制,同一时刻只能有一个指针指向被管理的对象,也就是说资源无法共享。
可以使用引用计数型智能指针shared_ptr,来追踪有多少个对象指向某个资源,并且在无人指向它时自动删除该资源。
shared_ptr的缺点:无法打破环状引用,即2个已经没被使用的对象彼此互指,资源无法正常释放。

void f()
{
       shared_ptr<Investment> pInv(Factory::createInvestment());  // OK
       //...
       shared_ptr<Investment> pInv2(pInv); // OK
       shared_ptr<Investment> pInv3;
       pInv3 = pInv; // OK
       // f退出前调用shared_ptr析构函数,自动删除pInv, pInv2, pInv3
}

需要注意的是:unique_ptr和shared_ptr不能释放array。
因为两者在其析构函数内做delete,而不是delete[]。如果要使用自动释放的数组,建议用vector、string,或者boost::scoped_array和boost::shared_array classed。

小结

1)为防止资源泄漏,建议使用RAII对象,在构造函数中获得资源,在析构函数中释放资源;
2)两个经常被使用的RAII classes是:unique_ptr, shared_ptr。排他性的资源,前者较佳;共享性的资源,后者较佳。两者都只适合用来管理head-based资源。

[======]

条款14:在资源管理类中心小心coping行为

Think carefully about copying behavior in resource-managing classes.

对于不是heap-based资源,unique_ptr和shared_ptr 往往不适合作为资源的掌管着(resource handlers)。

我们以对POSIX 的pthread库里的互斥量用C++进行封装为例:

// Lock掌管不是heap-based的资源
class Lock {
public:
    // 这里互斥锁是RAII资源,用Lock进行管理
    explicit Lock(pthread_mutex_t *pm): mutexPtr(pm) { // 构造即获得互斥锁
        pthread_mutex_lock(mutexPtr); // 获得互斥锁
        cout << "locked" << endl;
    }
    ~Lock() { // 析构即释放锁
        pthread_mutex_unlock(mutexPtr); // 释放互斥锁
        cout << "unlocked" << endl;
    }
private:
    pthread_mutex_t *mutexPtr; // RAII资源
};

// 客户多Lock的用法
pthread_mutex_t m; // 定义需要的互斥锁
void func()
{
    Lock ml1(&m); // OK
    Lock ml2(ml1); // 拷贝ml1到ml2上,会发生什么?
}

当一个RAII对象被复制时,会发生什么?
通常,会有2种选择:
1)禁止复制。参见条款06,可以将基类的copying操作声明为private,也可以使用delete禁止编译器合成copy构造函数和copy assignment运算符。
2)对底层资源使用“引用计数法”(reference-count)。有时,我们希望保持资源,共享给多个用户,直到最后一个使用者(某个对象)被销毁。此时应该使用shared_ptr。

使用shared_ptr当引用次数为0时,默认行为是调用delete操作符 删除所指物。而使用向互斥锁时,我们期望的是引用计数为0时,就解锁而非删除,因此需要手动指定其“删除器”(deleter)。删除器的本质是一个函数或函数对象(function object),当引用次数为0时调用。

// RAII资源管理类
class Lock {
public:
    explicit Lock(pthread_mutex_t *pm): mutexPtr(pm, pthread_mutex_unlock) {
        lock(mutexPtr.get());
    }
    Lock(const Lock& lck) {
        mutexPtr = lck.mutexPtr; // mutexPtr次数-1, lck.mutexPtr次数+1. mutexPtr引用次数为0时, 指向内存释放, 并指向lck.mutexPtr指向的内存
        lock(mutexPtr.get());
    }
#if 1 // 析构函数可以依靠自动合成的, 也可以手动编写
    ~Lock() { // 因为编译器自动合成的析构函数, 调用每个non-static对象成员的析构函数
        // mutexPtr的析构函数会在互斥锁引用次数为0时, 自动调用其创建时指定的删除器
        unlock(mutexPtr.get()); // get获得shared_ptr指向的内存, i.e. shared_ptr第一个参数
    }
#endif
private:
    shared_ptr<pthread_mutex_t > mutexPtr; // 使用shared_ptr替换了raw pointer, 指向RAII资源
    int lock(pthread_mutex_t *pm) {
        int res = pthread_mutex_lock(pm);
        cout << "shared locked" << endl;
        return res;
    }
    int unlock(pthread_mutex_t *pm) {
        int res = pthread_mutex_unlock(pm);
        cout << "unlocked" << endl;
    }
};

pthread_mutex_t m;
void func()
{
    Lock ml1(&m); // OK
    Lock ml2(ml1); // OK. 使用shared_ptr管理mutex后, 允许多个用户同时指向同一个互斥锁资源, i.e. 互斥锁资源被共享了

    cout << "exit func" << endl;
}

int main() {
    func();
    cout << "exit main" << endl;
    return 0;
}

这里用shared_ptr<pthread_mutex_t > mutexPtr替换了原来的pthread_mutex_t *mutexPtr,允许共享资源。

复制底部资源

需要“资源管理类”的唯一理由:当不再需要某个副本时,可以确保它被释放。此时,复制资源管理对象,应该同时也复制其所包含的资源。i.e. 复制资源管理器对象时,进行的是“深度拷贝”(deep copying)。
深度拷贝是指:不仅复制指针本身,还复制指针指向的内存内容。

转移底部资源的拥有权

某些场合下,你可能希望确保永远只有一个RAII对象指向一个未加工资源(raw resource),即使RAII对象被复制依然如此。此时,资源的拥有权会从被复制物转移到目标物。如条款13所述,unique_ptr的复制意义。
同一时刻,只允许一个unique_ptr指向给定对象。如果要从一个unique_ptr转移到另外一个,可以使用如下方法:

unique_ptr p1(new string("hello")); // 让p1指向string对象
unique_ptr p2(p1.release()); // 将p1指向的string对象转移给p2

小结

1)复制RAII对象必须一并复制它所管理的资源(深度拷贝),所以资源的copying行为决定RAII对象的copying行为;
2)通常的RAII class copying行为是:抑制copying(禁用copying函数),或者引用计数法(reference counting)(可使用shared_ptr)。

[======]

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

Provide access to raw resources in resource-managing classes.

条款13中的pInv是对象,如果要访问shared_ptr指向的那块Investment对象(称为原始资源,raw resource),要怎么办?

shared_ptr<Investment> pInv(Factory::createInvestment());  // OK

比如,希望通过daysHeld返回投资天数

int daysHeld(const Investment* pi); // 返回投资天数

要如何调用?
要将RAII class对象转换为内含的原始资源,可以分为两种方式:

1. 显示转换

使用shared_ptr的get成员函数(unique_ptr也提供),返回智能指针内部的原始指针(的副本),来进行显式转换。

int days = daysHeld(pInv.get()); // get获取shared_ptr指向的内存

2. 隐式转换

使用shared_ptr重载了的指针取值操作符(operator ->和operator *),允许隐式转换至所指向的资源的原始指针。unique_ptr同样重载了这2个操作符。

class Investment {
    public:
    bool isTaxFree() const;
    ...
};
class Factory {
public:
    static Investment* createInvestment() // 工厂函数
  { return new Investment;}
};

shared_ptr<Investment> pi1(Factory::createInvestment()); // shared_ptr管理资源
bool taxable1 = !(pi1->isTaxFree()); // 利用shared_ptr的 -> 操作符, 访问raw resource对象的成员函数

unique_ptr<Investment> pi2(Factory::createInvestment()); //unique_ptr管理资源
bool taxable2 = !((*pi2).isTaxFree()); // // 利用unique_ptr的 * 操作符, 访问raw resource对象的成员函数

小结

1)APIs往往要求访问原始资源(raw resources),所以每个RAII class应该提供一个“取得其所管理的资源”的办法(就像shared_ptr的get方法,解引用方法(*),指针访问所指目标的-> 方法);
2)对原始资源的访问可能经由显示转换/隐式转换。一般而言,显示转换比较安全,但隐式转换对客户比较方便。

[======]

条款16:成对使用new和delete时要采取相同形式

Use the same from in corresponding uses of new and delete.

对象、数组的申请与释放

string* stringPtr1 = new string; // 申请一个string对象
string* stringPtr2 = new string[100]; // 申请一个string对象数组,大小为100

delete stringPtr1; // 删除一个对象
delete[] stringPtr2; //删除一个由对象组成的数组

1)如果在new表达式中使用[],必须在相应的delete表达式中也使用[]。如果在new表达式中不用[],一定不要在相应的delete表达式中使用[]。

[======]

条款17:以独立语句将newed对象置入智能指针

Store newd objects in smart pointers in standalone statements.

资源创建和资源转换之间的异常

假设我们有函数priority用于揭示程序的优先权,而函数processWidget用于动态分配的Widget上进行带有优先权的处理,

class Widget {};
int priority();
void processWidget(shared_ptr<Widget> pw, int priority);

我们“以对象管理资源”(条款13),在processWidget中使用Widget的raw pointer来构造智能指针shared_ptr,来管理Widget:

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

上述代码看似没有问题,却可能造成资源泄漏。原因是,priority()发送异常时,Widget的raw pointer丢失,无法被正常释放。

调用processWidget之前,编译其会创建代码做以下三件事:

  • 调用priority
  • 执行new Widget
  • 调用shared_ptr构造函数

然而,编译器以何种次序完成这三件事却没有定数,唯一能确定的是“执行new Widget”发生在“调用shared_ptr构造函数”之前。如果编译器为了取得更高效的代码,可能最终的操作序列会是这样:
1)执行new Widget
2)调用priority
3)调用shared_ptr构造函数

当执行到第2)步时,如果调用priority发生异常,那么第1)步new Widget得到的指针就会丢失,从而造成内存泄漏。

注意:
1)调用惯例(cdecl, thiscall)只能确保参数的入栈顺序,并不能确保在这之前的参数构造、核算顺序。
2)其他语言如Java、C#不存在这个问题,因为它们总是以特定次序完成函数参数的核算。

解决办法

这类发生在“资源被创建(new Widget)”和“资源被转换为资源管理对象(shared_ptr)”2个时间点之间的异常干扰的问题,要如何解决?
办法很简单,就是分离语句,让资源的创建、转换和可能异常语句不要在同一行,分别写出:
1)创建Widget并置入智能指针内;
2)把指针传递给processWidget;

shared_ptr<Widget> pw(new Widget); // 单独语句内以智能指针存储newed所得对象
processWidget(pw, priority()); // 该调用不会因异常而导致Widget指针丢失, 从而造成泄漏的问题

这么做有效的原因是因为:编译器对于“跨越语句的各项操作”没有重新排列的资源(只有在语句内才拥有那个自由度)

小结

1)以独立语句将newd对象存储于(置入)智能指针内。如果不这样做,一旦被抛出异常,有可能导致难以察觉的资源泄漏。

[======]

posted @ 2021-11-17 10:05  明明1109  阅读(98)  评论(0编辑  收藏  举报