首页 > 代码库 > 《Effective C++》学习笔记(八)

《Effective C++》学习笔记(八)

原创文章,转载请注明出处:http://blog.csdn.net/sfh366958228/article/details/38962661


条款29:为“异常安全”而努力是值得的

看完这个条款,第一感觉就是之前写的代码多么具有风险。

先看一个书上的例子,假设有个class用来表现夹带背景图案的GUI菜单,这个class也要用于多线程环境当中,所以我们考虑用了一个互斥器(mutex)作为并发控制(concurrency control)之用:

class PrettyMenu
{
public:
    ...
    void changeBackground(std::istream& imgSrc); //改变图片背景
    ...
private:
    Mutex mutex;       //互斥器
    Image* bgImage;    //目前的背景图像
    int imageChangesCounts; //背景图像被改变的次数
};

// 下面是PrettyMenu的changeBackground函数的一个可能实现:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    lock(&mutex);          //取得互斥器
    delete bgImage;        //摆脱旧的背景图像
    ++imageChangesCounts;  //修改变更次数
    bgImage_ = new Image(imgSrc); //安装新的背景图像
    unlock(&mutex);        //释放互斥器
}
从“异常安全性”来看,这个函数很糟糕。“异常安全”有两个条件,而这个函数没有满足其中任意一个条件。

1)不泄露任何资源:一旦“new Image(imgSrc)”导致异常,对unlock的调用就绝对不会执行,于是互斥器就永远锁住了。

2)不允许数据破坏:如果“new Image(imgSrc)”抛出异常,bgImage就是指向了一个已被删除的对象,且imageChanges也被累加,而其实没有新的图像被成功安装。

解决资源泄露很简单,,我们可以用资源管理类来确保互斥器锁定后一定会释放。

异常安全函数提供以下三种保证之一:

基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。

强烈保证:如果异常被抛出,程序状态不改变。

不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。

一般而言我们会想提供最高保证,但是我们很难在C part of C++中调用一个完全没有可能抛出异常的函数。任何使用动态内存的东西一旦没有足够内存满足要求,都会抛出一个bad_alloc异常。

class PrettyMenu
{
    ...
    std::tr1::shared_ptr<Image> bgImage;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    Lock(&mutex);          //获得互斥器并确保它稍后被释放    
    bgImage.reset(new Image(imgSrc)); 
    ++imageChangesCounts;  
}
上面是我们优化后的代码,但是该函数也只能提供“基本的异常安全保证”。因为如果Image构造函数抛出异常,有可能输入流的读取记号已被移走,而这样的搬移对程序其余部分是一种可见的状态改变。
有个一般化的设计策略可以达到这个目的,这个策略被称为“copy and swap”,俗话说“名如其人”,这个原则其实就是:为你打算修改的对象保存一个副本,然后在该副本上修改。若修改过程中发生异常,问题不大,因为原对象状态没有被改变。修改动作完成以后进行“副本与原对象互换”操作。

struct PMImpl
{
    std::tr1::shared_ptr<Image> bgImage;
    int imageChangesCounts;
};
class PrettyMenu
{
    ...
    void changeBackground(std::istream& imgSrc)
    {
        using std::swap;
        Lock m1(&mutex);
        std::tr1::shared_ptr<PMImpl> pNewImpl(new PMImpl(*pImpl)); //make copy
        pNewImpl->bgImage_.reset(new Image(imgSrc));//handle copy
        ++pNew->imageChangesCounts;
        swap(pImpl,pNewImpl); //swap 
    }
private:
    Mutex mutex_;
    std::tr1::shared_ptr<PMImpl> pImpl;
};
我们注意到copy-and-swap的关键在于“修改对象数据副本,然后在一个不抛异常的函数中将修改后的数据和原件置换”,因此必须为每一个即将被改动的对象做一个副本,那得耗用你
可能无法供应的时间和空间。

这是一个很实际的问题:我们都希望提供“强烈保证”;当它可被实现时你的确应该提供它,但“强烈保证”并非在任何时刻都显得实际。

当强烈保证不切实际的时候,你就必须提供基本保证。

在实际的开发当中你可以为某些函数提供强烈保证,但效率和复杂度带来的成本会使得你不得不去放弃它,万一实际不可行,使你退而求其次地只提供基本保证,任何人都不该因此责难你。对许多函数而言,“异常安全性之基本保证”是一个绝对通情达理的选择。

总结:

1)异常安全函数(Exception-safe functions)即使发生异常也不会泄漏资源或允许任何数据结构被破坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
2)“强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。
3)函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。


条款30:透彻了解inlining的里里外外

inline函数,你可以调用它们又不蒙受函数调用招致的额外开销,编译器就会对函数本体执行语境相关最优化,而大部分编译器不会对着一个非inline函数调用动作执行如此之最优化。

由于对inline函数的每一个调用都以函数本体替换之,所以说这样就可能增加你的目标代码,在一台有限的机器上,过度热衷inlining会造成程序体积太大,即使拥有虚拟内存,inline造成的代码膨胀亦会导致额外的换页行为,降低指令高速缓存装置的击中率,以及伴随而来的效率损失。

以上是inline函数的优缺点,所以我们依旧要辩证性的看待inline函数。

不能忽视的一点是,inline函数仅仅是一个申请,而不是强制命令,所以它是会可能被编译器驳回的。这个申请可以隐喻提出,也可以明确提出。

隐喻提出就是将函数定义于class定义式内,这样的函数通常是成员函数或者是friend函数。如下:

class Person
{
public:
    ...
    int age() const
    {
        return theAge; // 一个隐喻的inline申请
    }
    ...
private:
    int theAge;
}
明确声明就是在函数之前加关键字"inline"。例如标准的max template往往这样实现出来:

template<typename T>
inline const T& std::max(const T &a, const T &b)
{
    return a < b ? b : a;
}

inline函数通常一定要被置于头文件内,其在大多数C++程序中是编译期行为。

大部分编译器拒绝将太过复杂(含有循环、递归等)的函数inlining,而所有的virtual函数都不能inlining,因为virtual意味着“等待,知道运行期才确定调用哪个函数”,而inline意味“执行前先将动作替换为被调用函数的本体”。如果编译器不知道该调用哪个函数,你就很难责备它们拒绝将函数本体inlining。

有时候编译器inline某个函数的同时,还可能为其生成一个函数本体(比如程序要取某个line函数地址),值得一提的是,编译器通常不对“通过函数指针而进行的调用”实施inling,这就是说line函数的调用可能是被inlined,也可能不被inlined,取决于调用的实施方式。

“将构造函数和析构函数进行inling”是一个很糟糕的想法。看下面这段代码:

Derived::Derived()
{ // 空白Derived构造函数"的观念性实现
    Base::Base(); // 初始化Base成分
    
    try{
        dm1.std::string::string();
    }catch(...){
        Base::~Base();
        throw;
    }
    
    try{
        dm2.std::string::string();
    }catch(...){
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }
    
    try{
        dm3.std::string::string();
    }catch(...){
        dm2.std::string::~string();
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }
}
这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更精致复杂的做法来处理异常。尽管如此,这已能准确反映Derived的空白构造函数必须提供的行为。

程序库设计者必须评估“将函数声明为inline”的冲击:inline函数无法随着程序库的升级而升级。


总结:

1)将大多数inlining限制在小型,被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary upgradability)更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
2)不要因为function templates出现在头文件,就将它们声明为inline。


条款31:将文件间的编译依存关系降至最低

假设你对c++程序的某个class实现文件做了些轻微改变,修改的不是接口,而是实现,而且只改private成分。

然后重新建置这个程序,并预计只花数秒就好,当按下“Build”或键入make,会大吃一惊,因为你意识到整个世界都被重新编译和链接了!

问题是在c++并没有把“将接口从实现中分离”做得很好。class 的定义式不只详细叙述了class接口,还包括十足的实现细目:

class Person{ 
public: 
    Person(const std::string& name, const Date& birthday, const Address& addr); 
    std::string name() const; 
    std::string birthDate() const; 
    std::string address() const; 
    ... 
private: 
    std::string theName;        //实现细目 
    Date theBirthDate;          //实现细目 
    Address theAddress;         //实现细目 
};

这个class Person无法通过编译,Person定义文件的最上方可能存在这样的东西:

#include <string> 
#include "date.h" 
#include "address.h"
这样写显然在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。可能就会导致开头我们提到的使你陷入窘境的情形出现。所以这里我们采取了另外一种实现方式,即将对象实现细则隐藏与一个指针背后。具体这样做:把Person类分割为两个类,一个只提供接口,另一个负责实现该接口。

#include <string>
#include <memory>
class PersonImpl;
class Date;
class Address;
class Person{
public:
    Person(const std::string& name,const Date& birthday,const Address& addr);
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;
    ...
private:
    std::tr1::shared_ptr<PersonImpl> pImpl;
}

这里,Person只内含一个指针成员,指向其实现类(PersonImpl)。这个设计常被称为pimpl idiom(pimpl是“pointer to implementation”的缩写)。

这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每件事都源自于这个简单的涉及策略。

1)如果用object reference 或 object pointer可以完成任务,就不要用objects。

2)如果能够,尽量以class声明式替换class定义式。

3)为声明式和定义式提供不同的头文件。

先Person这样使用pimpl idiom的classes,往往被称为Handle classes。

另一个制作Handle class的办法是,令Person成为一种特殊的abstract base class称之为Interface class。这种class只有一个virtual析构函数以及一组pure virtual函数,用来叙述整个接口。一个针对Person而写的Interface class或许看起来像这样:

//Person.h
...
using std::string;
class Date;
class Address;
class Person
{
public:
    virtual ~Person();
    virtual string name()const = 0;
    virtual string birthDate()const = 0;
    virtual string address()const = 0;
    ...
    static std::tr1::shared_ptr<Person>
    create(const string& name,const Date& birthday,const Address& addr);
};
...

// person.cpp
...
class RealPerson:public Person
{
public:
    RealPerson(const string& name,const Date& birthday,const Address& addr);
    virtual ~RealPerson(){}
    string name()const;
    ...
private:
    string name_;
    Date theBirthDate_;
    Address theAddress_;
};
std::tr1::shared_ptr<Person> Person::create(const string& name,
                                            const Date& birthday,
                                            const Address& addr){
    return std::tr1::shared_ptr<Person>(new RealPerson(name,birthday,addr));
}
Handle classes和Interface classes解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。注意一点,两种class的实现方案带来的运行成本也是不容忽视的。如果你应该从你的实际出发,考虑用渐近方式去使用这些技术。


总结:

1)支持“编译依存性最小化”的一般构想是:相依于声明式,而不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
2)程序库头文件应该以“完全且仅有的声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及templates都适用。


结语

虽然只有三个条款,但是篇幅比较长,逻辑相对而言也不是特别容易理解,在之后还应该继续多回顾回顾。

《Effective C++》学习笔记(八)