【C++】《Effective C++》第五章
阅读原文时间:2023年07月09日阅读:1

第五章 实现

只要定义了一个变量而其类型带有一个构造函数或析构函数,那么

  • 当程序的控制流到达这个变量定义式时,你得承受这个构造成本。
  • 当这个变量离开这个作用域时,你得承受这个析构成本。

即使这个变量最终并未被使用,仍然需要耗费这些成本,所以应该尽可能避免这种情形,即延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。

如果变量只在循环内使用,那么把它定义于循环外并在每次循环迭代是赋值给它比较好,还是该把它定义在循环内?

// 方法A:定义于循环外
Widget w;
for(inti i = 0; i < n; ++i) {
    w = 取决于i的某个值;
    // ...
}

// 方法B:定义于循环内
for(int i = 0; i < n; ++i) {
    Widget w(取决于i的某个值);
    // ...
}

以上的两种做法的成本如下:

  • 做法A:1个构造函数 + 1个析构函数 + n个赋值操作
  • 做法B:n个构造函数 + n个析构函数

从效率上看,如果类的一个赋值成本低于一组构造和析构成本,做法A大体而言比较高效,尤其是当n的数值很大时。否则做法B更好。

从可理解性和维护性上看,做法A造成w的作用域比做法B更大,可维护性和可理解性相对较差。

请记住

  • 尽可能延后变量定义式的出现,这样做可增加程序的清晰度并该改善程序效率。

显然,转型(casts)会破坏类型系统(type system),但是有时也不得不这样做。

转型分类包括:

  • 旧式转型(old-style casts):

    • C风格:(T) expression
    • 函数风格:T(expression)
  • 新式转型(C++-style casts):

    • const_cast<T>(expression);

      • 通常被用来将对象的常量性转除(cast away the constness)。
    • dynamic_cast<T>(expression);

      • 主要用来执行"安全向下转型",也就是用来决定某对象是否归属继承体系中的某个类型、即主要用于将基类指针转换成派生类指针(或引用),通常需要知道转换源和转换目标的类型。可能会耗费重大的运行成本。
    • reinterpret_cast<T>(expression);

      • 通常为算术对象的位模式提供较低层次上的重新解释。例如将int*转换为char*,比较危险的转换!
    • static_cast<T>(expression);

      • 用来强制隐式转换,只要不包含底层const,都可以使用。适合将较大算术类型转换为较小算术类型。例如将non-const对象转换为const对象int转为double等等。唯一例外是无法将const转为non-const,这个只要const_cast才能做到。

应该尽可能使用新式转型,主要有两点原因:

  • 它们很容易在代码中被辨别出来(无论是人工还是使用工具如grep等),因而得以简化"找出类型系统在哪个地方呗破坏"的过程。
  • 各转型动作的目标越窄化,编译器越可能诊断出错误的运用。

尽管如此,还是应该尽量少做转型,原因如下:

  • 转型不只是告诉编译器把某种类型视为另一种类型这么简单,任何一个转型动作往往令编译器编译出运行期间执行的代码。

    • int转型为double几乎肯定会产生一些代码,因为在大部分体系结构中,int的底层表述不同于double的底层表述。

      int x, y;
      // …
      double d = static_cast(x)/y;

    • 会有个偏移量在运行期被实施于Derived*指针上,用以取得正确的Base*地址。

      class Base {};
      class Derived : public Base {};
      Derived d;
      Base* pb = &d;

  • 很容易写出似是而非的代码。

    class Window {
    public:
    virtual void onResize();
    // …
    };

    // 错误的用法,因为转型并非在当前对象身上调用Window::onResize(),而是当前对象的base class成分的副本上调用Window::onResize()。
    class SpecialWindow: public Window {
    public:
    virtual void onResize() {
    static_cast(this)->onResize();
    // 进行SpecialWindow的专属行为
    }
    };

    // 正确的用法
    class SpecialWindow: public Window {
    public:
    virtual void onResize() {
    Window::onResize();
    // 进行SpecialWindow的专属行为
    }
    };

  • 继承中的类型转换效率低:

    • C++通过dynamic_cast实现继承中的类型转换,通常是因为想在一个认定为derived class对象身上执行derived class操作没,但是拥有的是一个指向base的指针或引用。这样做其效率都是相当低的!这种情况下有两种办法可以避免转型:

      • 使用容器并在其中存储直接指向derived class对象的指针
      • derived class中的操作上升到base class内,成为virtual函数,base class提供一份缺省实现

请记住

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
  • 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进它们自己的代码内。
  • 宁可使用C++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职责。

references、指针和迭代器统统都是所谓的handles,即号码牌,用来取得某个对象。

  • 增加封装性:如果成员函数返回handles,那么相当于成员变量的封装性从private上升到public,这显然与条款22:将成员变量声明为private冲突。
  • 使得"通过const修改对象的数据"成为可能:对象只包含指针成员,实际数据通过这个指针指向,const成员函数返回一个这个指针所指对象的引用,并不会造成指针被修改,也就符合bitwise constness,但是通过这个引用却可以改变对象实际的数据。
  • 防止"虚吊"发生:若返回的handles指向一个临时对象,那么返回后临时对象被销毁,handles就会成为"虚吊的",只要handles被传出去,就会面临"handles比其所指对象更长寿"的风险。

请记住

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

考虑一个例子,有一个菜单类,changeBg函数可以改变它的背景,切换背景计数,同时提供线程安全。

class Menu {
public:
    // ...
    void changeBg(std::istream& src);

private:
    Mutex mutex;    // 互斥器,提供多种线程互斥访问
    Image* bg;  // 背景图片
    int changeCount;    // 切换背景次数
};

void Menu::changeBg(std::istream& src) {
    lock(&mutex);
    delete bg;
    ++changeCount;
    bg = new Image(src);
    unlock(&mutex);
}
  • 异常安全的两个条件:

    • 不泄漏任何资源:即发生异常时,异常发生之前获得的资源都应该释放,不会因为异常而泄漏。在上面的例子中,如果new Image(src)发生异常,那么unlock就永远不会被调用,因此锁资源会泄漏。
    • 不允许数据破坏:如果new Image(src)发生异常,背景图片会被删除,计数也会改变,但是新背景并未设置成功。

对于资源泄漏,条款13:以对象管理资源可以解决;使用智能指针指定删除器也可以解决;

void Menu::changeBg(std::istream& src) {
    Lock m1(&mutex);
    delete bg;
    ++changeCount;
    bg = new Image(src);
}

对于数据破坏,需要注意异常安全函数的三个保证:

  • 基本承诺:抛出异常后,对象仍然处于合法(valid)的状态,但是不确定处于哪个状态。
  • 强烈保证:如果抛出了异常,状态并不会发生任何改变。就像没调用这个函数一样。
  • 不抛掷保证:这是最强的保证,承诺绝不抛出异常,函数总是能完成它所承诺的事情。

对于前面的例子,可以使用智能指针,以及重排changeBg的语句顺序来满足"强烈保证"。

class Menu {
public:
    // ...
    void changeBg(std::istream& src);

private:
    Mutex mutex;    // 互斥器,提供多种线程互斥访问
    std::shared_ptr<Image> bg;  // 背景图片
    int changeCount;    // 切换背景次数
};

void Menu::changeBg(std::istream& src) {
    Lock m1(&mutex);
    bd.reset(new Image(src));   // 以new的执行结果设定bg内部指针
    ++changeCount;
}

可以使用 copy-and-swap策略,它通常能够为对象提供异常安全的"强烈保证"。当我们要改变一个对象时,先把它复制一份,然后去修改它的副本,改好了再与原对象交换。

struct MentImpl {
    std::shared_ptr<Image> bg;  // 背景图片
    int changeCount;    // 切换背景次数
};

class Menu {
public:
    // ...
    void changeBg(std::istream& src);

private:
    Mutex mutex;    // 互斥器,提供多种线程互斥访问
    std::shared_ptr<MentImpl> pImpl;
};

void Menu::changeBg(std::istream& src) {
    using std::swap;
    Lock m1(&mutex);    // 获得mutex的副本数据
    std::shared_ptr<MenuImpl> pNew(new MenuImpl(*pImpl));
    pNew->bg.reset(new Image(src));   // 修改副本
    ++pNew->changeCount;
    swap(pImpl, pNew);  // 置换数据,释放mutex
}

请记住

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

使用inline的优劣:

  • 优势:较少函数调用的开销;编译器对inline的优化;
  • 劣势:目标代码的增加,程序体积增大,导致额外的换页行为,降低指令高速缓存装置的命中率。

提出inline的两种方式:

  • 显式提出
  • 隐式提出(类内实现成员函数)

大部分编译器拒绝将太过复杂的(例如带有循环或递归)函数inlining,而所有对virtual函数的调用也会拒绝inlining,因为virtual意味着"等待,直到运行期才能确定调用哪个函数",而inline意味准备"执行前,先将调用动作替换为被调用函数的本体"。

总的来说,一个表面上看似inline的函数是否真是inline,取决于你的环境,主要取决于编译器。幸运的是大多数编译器提供了一个诊断级别:如果它们无法将你要求的函数inline化,会给你一个警告信息。inline函数无法随着程序库的升级而升级。

构造函数和析构函数往往是inlining的糟糕候选人。对于以下代码:

class Base {
public:
    // ...
private:
    std::string bm1, bm2;
};

class Derived: public Base {
public:
    Derived() {}
    // ...
private:
    std::string bm1, bm2, bm3;
};

虽然看上去Derived构造函数为空,符合一个函数成为inline的特性。但是为了确保C++对于"对象被创建和被销毁时发生什么事"做出的各式各样的保证,编译器会在其中安插代码。因此实际的Derived构造函数可能是这样的:

Derived::Derived() {

    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;
    }
}

对于上述代码。显而易见,真正的编译器会以更精细复杂的做法来处理异常。相同理由也使用与析构函数。

80-20经验法则平均而言一个程序往往将80%的执行时间花费在20%的代码上。这个法则提醒我们,作为一个软件开发者,你的目标是找出这可以有效增进程序整体效率的20%代码,然后将它inline或竭尽所能地将它瘦身。但除非你选对目标,否则一切都是徒劳。

请记住

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

C++并没有把"将接口从实现中分离"这件事做的很好,例如:

#include <string>
#include "date.h"
#include "address.h"

class Person {
public:
    // ...
private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
};

如果没有引入头文件,那么编译将无法通过。但是这样会在Person定义文件和其含入文件之间形成了一种编译依赖关系如果这些头文件中任何一个被改变,或这些文件多依赖的其他头文件有任何改变,那么每个含有Person class的文件都得重新编译,任何使用Person class的文件也必须重新编译,这样的连串编译依存关系会对许多项目造成难以形容的灾难

可能会想着将实现细目分开:

namespace std {
    class string;   // 前置声明,但不正确
}
class Data; // 前置声明
class Address;  // 前置声明

class Person {
public:
    // ...
};

如果这样做,Person的客户就只需要在Person接口被修改过时才重新编译,但是这种想法存在两个问题:

  • string并不是class,它是个typedef,上述前置声明不正确,正确的前置声明较为复杂。
  • 最重要的是,编译器必须在编译期间知道对象的大小:

解决这个问题的方法主要有两种:

  • 第一种方法:handle classes,把Person分割成两个类:一个只提供接口(Person),一个负责实现接口(PersonImpl)。即使用条款25:接口class中只包含一个负责实现接口的class的指针,因此任何改变都只是在负责实现接口的class中进行。这正是接口和实现分离,这种情况下,想Person这样使用pImplclass往往被称为handle classes

    #include
    #include

    class PersonImpl;

    class Person {
    public:
    Person(string& name);
    string name() const;
    private:
    std::shared_ptr pImpl; // 指针,指向实现物
    };

    Person::Person(string& name): pImpl(new PersonImpl(name)){}
    string Person::name() {
    return pImpl->name();
    }

  • 第二种方法:Interface classes,令Person成为一种特殊的abstract class

    class Person {
    public:
    virtual ~Person();
    virtual std::string name() const = 0;

    static std::shared_ptr<Person> create(string& name);    // 由于客户不能实例化这个类,只能使用它的引用和指针,所以需要提供一种方法来获得一个实例

    };

    std::shared_ptr Person::create(string& name) {
    return std::shared_ptr(new RealPerson(name));
    }

    class RealPerson: public Person {
    public:
    RealPerson(const std::string& name): theName(name) {}
    virtual ~RealPerson(){}
    std::string name() const;
    private:
    std::string theName;
    };

    // 使用
    std::shared_ptr p(Person::create("parzulpan"));
    std::cout << p->name() << std::endl;

请记住

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