Effective C++ chapter_5

5 实现

条款 26 尽可能延后变量定义式出现的时间

​ 我们写代码的时候,可能会定义一些没有使用的变量,特别是当你过早的定义一个变量,从定义到使用中间如果出现异常且该异常被丢出,那么你就要承担不必要的构造和析构成本。

​ 所以尽量在你使用到变量的前一刻再定义它。

请记住 :

1. 尽可能延后定义式的出现。这样做可以增加程序的清晰度并改善程序的效率。

条款 27 尽量少做转型动作

C++ 规则的设计目标之一是,保证“类型错误”绝不可能发生。

C++ 提供了四种转型方法 :

  • const_cast 通常用来将对象的常量性去除。
class Test{...};
const Test con_T;
Test T = const_cast<Test>(con_T);
  • dynamic_cast 主要用来执行“安全向下转型”,可以将基类的指针类型和应用类型转换为派生类的指针类型和引用类型。该转换会耗费大量的运行成本。
class Base{
  public:
    Base() { }
};

class Derived : public Base {
  public:
    Derived() { }
}

Derived* der = dynamic_cast<Derived*>(new Base);//正确
Base* base = dynamic_cast<Base*>(new Base);//错误
  • reinterpret_cast 意图执行低级转型,实际动作及结果取决于编译器,因此不可移植。例如将一个 int* 转型为 int
  • static_cast 用来强迫隐式转换,例如将 non-const 转型为 const ,或将 int 转型为 double 等等。但它是不安全的。

通过以上我们知道,我们之所以需要 dynamic_cast 通常是因为我们想在一个我们认定为 derived class 对象上执行其操作,但我们只拥有一个 Base* 或 Base&。

解决方法:

  1. 使用容器并在其中存储直接指向 derived class 对象的指针,通常是智能指针。
  2. 在 base class 内提供相应的 virtual 函数,这样可以利用多态性通过基类指针或引用来使用派生类的方法。

必须要避免使用一连串的 dynamic_cast 这样产生的代码非常臃肿,且不稳定。

请记住 :

1. 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast。如果有个设计需要转型动作,试着发展无需转型的替代设计。

2. 如果转型是必要的,试着将他隐藏在某个函数背后。客户随时可以调用该函数,而不需要将转型放进它们的代码中。

3. 宁可使用 C++ Style 转型,不要使用旧式转型。前者很容易辨别出来,而且也比较有着分门别类的*。

条款 28 避免返回 handles 指向对象内部成分

​ 首先我们来讨论一下什么是 handles ?中文普遍翻译成 “句柄” ,意思就是我可以通过它来取得对象(不是副本,就是本体)。像指针、引用、迭代器这些都是 handles 。返回一个 handle 会降低对象的封装性。即使在返回值中加入 const 限定也容易导致空悬指针。所以还是尽量不要这么干。

请记住 :

1. 避免返回 handles (包括 references、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const,并将发生“虚吊 handles”的可能性降至最低。

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

​ 我们来讨论一下异常安全性

例(异常不安全)

class PrettyMenu {
public:
    ...
    void changeBackground(std::istream& imgSrc);
    ...
private:
    Mutex mutex;
    Image* bgImage;
    int imageChanges;
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
    unlock(&mutex);
}

当异常被抛出时,带有异常安全性的函数会 :

  • 不泄漏任何资源。 如果上例的 new Image(imgSrc) 导致异常,那么将不会执行 unlock(&mutex),也就是说互斥器永远被锁住,无法再利用这个资源。
  • 不允许数据败坏。new Image(imgrc) 导致异常,那么原来的 bgImage 已被删除,而且也没有获得新的值,也就是说它指向一个败坏的值。

要解决上面的例子的资源泄漏问题,我们可以使用一个 Lock 类来管理互斥器,在其构造函数调用 lock(&mutex),在其析构函数调用unlock(&mutex)

class Lock {
public:
    Lock(Mutex* m) : mutex(m) { lock(m); }
    ~Lock() { unlock(mutex); }
private:
    Mutex* mutex;
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    Lock m1(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
}

这样互斥器会随着局部变量 m1 的生命周期结束而解锁。

在解决数据败坏问题前,先说明异常安全函数提供的三个保证之一(只提供其中一个保证) :

  • 基本承诺 : 如果异常被抛出,程序内的任何事物仍然保持在有效的状态下。没有任何对象或数据结构会败坏,但其真实状态是什么也是不可预测的,因为我们只需要保证状态有效即可。

  • 强烈保证 : ** 程序在调用有强烈保证**的函数后,只会有两种状态,一是成功后的状态,而是失败后回到的函数被调用之前的状态。

  • **不抛掷保证 : ** 承诺绝不抛出异常,即使函数内产生异常也能自己处理掉。像作用于内置类型的操作就提供这个保证。

    ​ 可以在函数后加 noexecpt 关键字来保证函数不抛出异常,当函数抛出异常时,就会发生严重错误终止程序。

    void doSomething() noexecpt;
    

由于我们很难保证一个函数不发生异常,所以一般提供强烈保证基本承诺

对于强烈保证,我们可以使用一些特定的语句排列顺序来保证,例如

class PrettyMenu {
public:
    ...
private:
    ...
    std::shared_ptr<Image> bgImage;    
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    Lock m1(&mutex);
   	bgImage.reset(new Image(imgSrc));
    ++imageChanges;
}

new Image(imgSrc) 发生异常,则不会进入 reset ,那么旧值也不会被释放。也就意味这回到了原来的状态。

还有一种策略是 copy and swap,也就是先对原对象做出一份副本,然后对副本进行操作,当操作全部正常执行完后,才将副本与原对象置换(swap),若发生了异常,被改变的只是副本,原对象的状态没有发生任何变化。

但这个策略又是也不能保证整个函数有强烈的异常安全性 :

void someFunc()
{
    ...
    f1();
    f2();
    ...
}

我们来分析一下:

  • 若 f1 或 f2 的异常安全性比“强烈保证”低,那么 someFunc 的异常安全性也会比“强烈保证”低。
  • 若 f1 和 f2 都提供“强烈保证”,但由于连带影响,someFunc 也很难提供“强烈保证”。因为若 f1圆满结束,那么内部的状态发生改变,因此 f2 或 someFunc 内部发生异常便很难回到原来的状态。(仔细理解这段话!!!)

因此,若实在保证不了“强烈保证”,也要保证“基本承诺”。

请记住 :

1. 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证 : 基本型、强烈型、不抛出异常型。

2. “强烈保证”往往能够以 copy-and-swap 实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义。

3. 函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的“异常安全保证”中的最弱者。

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

​ 当给一个给一个函数的定义式加上 inline 关键字的时候,编译器就有能力对函数本体执行语境相关最优化。

​ 并不是说只要加了 inline ,程序里面的相关函数调用就会像宏一样替换掉代码。只是你给了编译器权利,编译器去优化代码。编译器认为函数体代码简单就会去将其 “inlining”。如果函数过于复杂(如带有循环和递归),编译器将拒绝 “inlining”。

​ 编译器拒绝 inlining 还有一种情况就是,函数通过"函数指针"去调用函数,其实非常好理解,你既然使用了函数指针那么肯定会存在一个 outlined 的本体。否则编译器去哪里找一个指针给你?

​ 关于如何正确使用 inline :

  1. Inline 函数置于头文件中,因为大多数 build environments 在编译过程中进行 inlining ,编译器必须知道那个函数长什么样子,你不放在头文件中,编译器怎么在编译期间知道呢?
  2. 不要随意将构造函数和析构函数定义为 inline。当你定义一个类的时候,写那种不带参数,函数体空白的默认构造函数是不是很爽?你可能会想,这么简单的函数那我用 inline 不就大大节省了程序的开销吗?这样其实是不对的,想想都知道,构造函数不可能这么简单,其实它被后隐藏了大量的代码,除非你有足够的理由,否则不建议这样使用。

80-20经验法则 : 平均而言一个程序往往将80%的执行时间花费在20%的代码上头。

你要做的是将这20%的代码找出来,尽可能的将其瘦身和 inline

请记住 :

1. 将大多数 inlining 限制在小型、被频繁调用的函数身上。这可以使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。

2. 不要只因为 function template 出现在头文件,就将它们声明为 inline

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

​ 我们经常会遇到一种情况 :“当我们稍微修改某个类的实现文件,重新编译时,大部分甚至所有文件都重新编译了一遍”,其实问题出现在你没有将接口和实现分离!

有两种比较常见的方法可以实现接口和实现的分离 :

  1. Handle class

    定义两个类,一个只提供接口,一个负责实现接口并存储实现细目(成员变量),其中接口类内部存储一个指向实现类的指针。

    例如我们要定义一个 Person 类,我们可以这样做 :

    //Person.h
    #include <memory>
    #include <string>
    
    class Date;
    class Address;
    class PersonImpl;
    
    class Person {
    public:
        Person(const std::string& name, const Date& birthday, const Address& addr);
        std::string name() const;
        std::string birthday() const;
        std::string address() const;
    private:
        std::shared_ptr<PersonImpl> pImpl;
    };
    
    //PersonImpl.h
    #include <string>
    #include "Date.h"
    #include "Address.h"
    
    class PersonImpl {
    public:
        Person(const std::string& name, const Date& birthday, const Address& addr);
        std::string name() const;
        std::string birthday() const;
        std::string address() const;
    private:
        std::string theName;
        Date 		theBirthday;
        Address		theAdress;
    };
    
    
    //下面是前置声明类
    //这里有点小题大作了,只是为了示范
    /***********************************************************************************/
    //datefwd.h
    #include "Date.h"
    class Date;
    ...//某些函数声明
    
    /***********************************************************************************/
    //addressfwd.h
    #include "Address.h"
    class Address;
    ...//某些函数声明
    
    /***********************************************************************************/
    //Address.h
    class Address {
        ...
    };
    
    /***********************************************************************************/
    //Date.h
    class Date {
        ...
    }
    /***********************************************************************************/
    
    //Person.cpp
    //接口实现
    #include "Person.h"
    #include "PersonImpl.h"
    
    PersonImpl::PersonImpl(const std::string& name, const Date& birthday, const Address& addr)
        : theName(name), theBirthday(birthday), theAddress(address)
        { }
    
    std::string PersonImpl::name() const {
        return theName;
    }
    
    std::string PersonImpl::birthday() const {
        return theBirthday;
    }
    
    std::string PersonImpl::address() const {
        return theAddress;
    }
    
    Person::Person(const std::string& name, const Date& birthday, const Address& addr)
        : Pimpl(new PersonImpl(name, birthday, addr))
        { }
    
    std::string Person::name() const {
        return pImpl->name();
    }
    
    std::string Person::birthday() const {
        return pImpl->birthday();
    }
    
    std::string Person::address() const {
        return pImpl->address();
    }
    
    

    这里主要是三个文件 Person.hPersonImpl.hPerson.cpp

    这样,我们如果想要修改 Person 的实现细节就无需改动 Person 类,而是修改 PersonImpl 类,重新编译也只需要编译和连接PersonImpl.h

    如此便做到接口和实现分离~

    这个分离的关键在于 : 现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件的声明式相依。

    • 如果使用 objects reference 或 objects pointer 可以完成任务,就不要使用 objects,因为前者只需要声明式,后者需要定义式。

    • 如果能够,尽量以 class 声明式替换 class 定义式。当你声明一个函数而使用某个类时,也是只需要声明式,即使是 by value,这和上面那条相悖,是个例外。

      class Date;
      Date today();
      void someFunc(Date d);
      
    • 为声明式和定义式提供不同的头文件。如上述 xxxfwd.hXxx.h

    1. Interface class

    也叫做抽象基类,其实就是 Java 中的 interface。不可定义实体,只提供接口,其继承类的操作全部通过基类指针或引用来调用。

    抽象基类内必定有纯虚函数,也可以有普通的 virtual 函数,甚至你可以给他加以实现,作为默认版本。当继承类没有定义它时便调用默认版本。如下 :

    //Person.h
    //接口类
    #include <string>
    #include <memory>
    
    class Date;
    class Address;
    
    class Person {
    public:
        virtual ~Person();
        virtual std::string name() const = 0;
        virtual std::string birthday() const = 0;
        virtual std::string address() const = 0;
        //Factory 函数
        //甚至能加一些 info 来返回不同的派生类指针
        static std::shared_ptr<Person>
            creat(const std::string& name,
                  const Date& birthday,
                  const Address& addr);
    }
    
    //RealPerson.h
    //实现类
    #include "Person.h"
    #include "Date.h"
    #include "Address.h"
    
    class RealPerson : public Person {
    public:
        RealPerson(const std::string& name, const Date& birthday,
                  const Address& addr)
            : theName(name), theBirthday(birthday), theAddress(addr)
            {}
        virtual ~RealPerson() { }
        std::string name() const;
        std::string birthday() const;
        std::string address() const;
    private:
        std::string theName;
        Date		theBirthday;
        Address		theAddress;
    };
    
    //RealPerson.cpp
    #include “RealPerson.h”
    
    std::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday,
                  const Address& addr)
    {
        return std::shared_ptr<Person>(new RealPerson(name, birthday, addr));
    }
    //各种实现
    ...
    

好的,我们现在介绍完了两种方法,但其实它们也会带来额外的开销或访问步骤。仔细想想确实是这样,一个要通过指针访问,一个需要简介的跳跃以及维护虚函数表。

但相较于额外的开销,实现接口和分离才是我们真正想要的。但现实中也需要你对两者进行权衡利弊。

请记住 :

1. 支持“编译依存性最小化”的一般构想是 : 相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle classes 和 Interface classes。

2. 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及 template 都适用。

上一篇:substitute Chapter to Ch and chapter to ch in springboot idea project


下一篇:chapter §34 求最小生成树----prim算法