《Effective C++》读书笔记——第五章:Implementations

这章主要讲的就是在实现时需要注意的一些问题,比如过度使用cast会导致运行变慢、难以维护并且有细微的bug,返回对象内部数据的句柄会破坏封装性,不考虑异常安全会导致资源泄露而且数据结构崩坏,过多使用内联反而会导致代码膨胀,代码耦合过多会导致编译时间过长等等


ITEM 26: POSTPONE VARIABLE DEFINITIONS AS LONG AS POSSIBLE

尽量延缓变量的声明,这一点应该是大多数人都比较熟悉的一个规范,变量本来就应当是要用的时候再去创建,特别是代码中的一些临时变量。同时,一个函数中变量声明与销毁相隔的行数越多,这个函数也就越复杂,越难以维护。我们应该尽可能让逻辑集中,避免潜在的问题。首先看一个简单的例子:

// this function defines the variable "encrypted" too soon
std::string encryptPassword(const std::string& password)
{
  using namespace std;

  string encrypted;

  if (password.length() < MinimumPasswordLength) {
      throw logic_error("Password is too short");
  }
  ...                        // do whatever is necessary to place an
                             // encrypted version of password in encrypted
  return encrypted;
}

很明显,这个例子中encrypted变量并不一定要用到,如果遇到异常的话我们完全不需要声明这个变量,所以我们应该尽可能推迟变量的声明,直到我们真正需要用到它,最后优化的版本应该是这样的:

// finally, the best way to define and initialize encrypted
std::string encryptPassword(const std::string& password)
{
  ...                                     // check length

  std::string encrypted(password);        // define and initialize
                                          // via copy constructor

  encrypt(encrypted);
  return encrypted;
}

注意这里等到有值去初始化它我们才进行了声明,避免了不必要的构造和析构
对于循环,我们一般有两种声明的方法:

// Approach A: define outside loop   // Approach B: define inside loop

Widget w;
for (int i = 0; i < n; ++i){         for (int i = 0; i < n; ++i) {
  w = some value dependent on i;       Widget w(some value dependent on i);
  ...                                  ...
}                                    }

这两种方法对应的开销分别如下:

  • A:一次构造,n次赋值,一次析构
  • B:n次构造,n次析构
    当赋值比构造+析构更快时,显然第一种方式更加高效,尤其是n特别大的时候。否则的话显然是第二种方式更好,因为第一种方式让变量处在了更大的一个作用域内,使得程序更难理解和维护,而第二种方式的变量只在循环内有效,可读且易维护。所以通常我们应该选择第二种方式

总结:

尽可能延迟变量定义,这样能提高程序的清晰度和效率


ITEM 27: MINIMIZE CASTING

通常来说C++是类型安全的,但是cast会打破这种安全,C++给了我们很大的自由,来对变量类型进行转化,但同时这也增加了很多风险和问题。首先来看一下C风格的cast,这两种方式没有什么区别:

(T) expression                      // cast expression to be of type T
T(expression)                       // cast expression to be of type T

而C++提供了四种cast:

const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)

这四种cast的功能分别如下:

  1. const_cast<T>:用来去除变量的const属性
  2. dynamic_cast<T>:通常用来向下转型,也就是将基类安全的转化成派生类,有一定的运行时开销
  3. reinterpret_cast<T>:用于生成依赖于实现(即不可移植)结果的低级强制转换,比如将指针转换为int,不推荐使用
  4. static_cast<T>:可以用于强制隐式转化,比如非常量到常量,int到double

作为C++程序员,我们应该摈弃老的C风格的cast,使用C++提供的新型cast,一是它们更容易在代码中被识别到,二是每个cast限定了使用的范围,更容易发现错误。比如尝试将常量转化为非常量又不使用const_cast,编译器就会报错

许多程序员(我自己也是)认为cast只是告诉编译器把一种类型当作另一种类型使用,其实这是不对的。任何形式的类型转化(显式的使用cast或者靠编译器隐式转化)都会带来运行时的开销,比如

int x, y;
...
double d = static_cast<double>(x)/y;           // divide x by y, but use
                                               // floating point division

这个例子中将int转化为double就会带来额外的开销,毕竟这两种类型的布局是不一样的。还有一个例子:

class Base { ... };

class Derived: public Base { ... };

Derived d;

Base *pb = &d;                         // implicitly convert Derived* ⇒ Base*

在这个例子中,我们用基类指针指向了一个派生类的地址,但是有时候这两个指针的值会不同(特别是在多继承的情况下),这时候在运行时就需要给基类指针加一个偏移量才能得到派生类的地址,我自己写了一个测试程序进行验证,确实此时两个指针的地址不可能相同,因为它们存放的是不同类型的对象(两个基类对象):地址偏移
所以我们就不应该对底层布局有任何假设,比如把指针地址转化为char*然后进行一些操作。还有一些看起来正确其实有问题的做法,比如:

class Window {                                // base class
public:
  virtual void onResize() { ... }             // base onResize impl
  ...
};

class SpecialWindow: public Window {          // derived class
public:
  virtual void onResize() {                   // derived onResize impl;
    static_cast<Window>(*this).onResize();    // cast *this to Window,
                                              // then call its onResize;
                                              // this doesn't work!

    ...                                       // do SpecialWindow-
  }                                           // specific stuff

  ...

};

这段代码本来是想调用基类的onResize方法,于是使用了cast,但是其实这样做是错误的,上面的cast会生成一个对*this的拷贝,然后在这个拷贝上调用onResize,最后导致整个对象的状态不对,因为相当于只调用了派生类的方法。要避免这个错误我们只需要把cast去掉就可以了:

class SpecialWindow: public Window {
public:
  virtual void onResize() {
    Window::onResize();                    // call Window::onResize
    ...                                    // on *this
  }
  ...

};

然后讲了关于dynamic_cast的问题,dynamic_cast通常会有比较大的开销,因为它的底层实现可能会通过比较类名来完成,这意味着每有一层继承可能就要调用一次strcmp,在多继承中这就更加复杂,所以运行起来更慢。主要有两种方法来避免使用它:

  1. 用智能指针保存派生类从而保证类型安全,不使用dynamic_cast,修改前:
class Window { ... };

class SpecialWindow: public Window {
public:
  void blink();
  ...
};
typedef                                            // see Item 13 for info
  std::vector<std::tr1::shared_ptr<Window> > VPW;  // on tr1::shared_ptr

VPW winPtrs;

...

for (VPW::iterator iter = winPtrs.begin();         // undesirable code:
     iter != winPtrs.end();                        // uses dynamic_cast
     ++iter) {
  if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()))
     psw->blink();
}

修改后:

typedef std::vector<std::tr1::shared_ptr<SpecialWindow> > VPSW;

VPSW winPtrs;

...

for (VPSW::iterator iter = winPtrs.begin();        // better code: uses
     iter != winPtrs.end();                        // no dynamic_cast
     ++iter)
  (*iter)->blink();

当然,这种方法不能对多种派生类适用
2. 通过虚函数规避dynamic_cast

class Window {
public:
  virtual void blink() {}                       // default impl is no-op;
  ...                                           // see Item 34 for why
};                                              // a default impl may be
                                                // a bad idea

class SpecialWindow: public Window {
public:
  virtual void blink() { ... };                 // in this class, blink
  ...                                           // does something
};

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;

VPW winPtrs;                                    // container holds
                                                // (ptrs to) all possible
...                                             // Window types

for (VPW::iterator iter = winPtrs.begin();
     iter != winPtrs.end();
     ++iter)                                    // note lack of
  (*iter)->blink();                             // dynamic_cast

这样做的话对基类会什么都不做,对派生类就执行相应的操作,而且也避免了dynamic_cast
最后还有一件事需要避免的就是瀑布式的dynamic_cast

class Window { ... };

...                                     // derived classes are defined here

typedef std::vector<std::tr1::shared_ptr<Window> > VPW;

VPW winPtrs;

...

for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
  if (SpecialWindow1 *psw1 =
       dynamic_cast<SpecialWindow1*>(iter->get())) { ... }

  else if (SpecialWindow2 *psw2 =
            dynamic_cast<SpecialWindow2*>(iter->get())) { ... }

  else if (SpecialWindow3 *psw3 =
            dynamic_cast<SpecialWindow3*>(iter->get())) { ... }

  ...
}

这种生成的代码很大而且运行的很慢,而且扩展性也很差,因为每次Window类发生变化就得加上一个分支在这里,这种代码几乎肯定需要使用虚函数的方法进行重构。

好的C++代码很少使用cast,但是要完全不使用它也是不太现实的,我们应该尽可能把cast隐藏在函数的内部,让调用函数的人不需要关心这些问题

总结:

1. 尽量避免使用cast,特别是dynamic_cast,尽可能用不需要cast的方法去替代它
2. 如果需要使用cast,把它封装在函数内,这样客户端不需要在代码中进行处理
3. 尽量使用C++风格的cast而非老的风格,因为它们更加容易使用,职责也更明确


ITEM 28: AVOID RETURNING “HANDLES” TO OBJECT INTERNALS

这一条主要就是在说尽量不要返回对象内部的句柄了,原理其实也比较简单,一个是破坏封装性,一个是不安全,这里我们可以详细的看一下这样做可能产生的问题

现在假设我们写了一个Rectangle类,为了减小对象的大小,我们用一个指针来指向实际的数据结构

class Point {                      // class for representing points
public:
  Point(int x, int y);
  ...

  void setX(int newVal);
  void setY(int newVal);
  ...
};

struct RectData {                    // Point data for a Rectangle
  Point ulhc;                        // ulhc = " upper left-hand corner"
  Point lrhc;                        // lrhc = " lower right-hand corner"
};

class Rectangle {
  ...

private:
  std::tr1::shared_ptr<RectData> pData;          // see Item 13 for info on
};                                               // tr1::shared_ptr

然后我们可能需要知道这个矩形的具体细节,比如顶点的位置,很自然的我们可能就需要这样的函数来返回顶点,然后由于返回的类型是自定义类型,自然的会想返回引用来减少开销

class Rectangle {
public:
  ...
  Point& upperLeft() const { return pData->ulhc; }
  Point& lowerRight() const { return pData->lrhc; }
  ...
};

引用、指针或迭代器都可以看作是“句柄”,因为我们可以通过它们去操纵实际的变量。所以这里就会出现问题了,虽然我们将这两个函数声明为了常量成员函数,但由于它返回了引用,于是我们可以通过这个函数的返回值去修改成员变量,这跟我们的意图是矛盾的,所以最好是给返回值也加上const

class Rectangle {
public:
  ...
  const Point& upperLeft() const { return pData->ulhc; }
  const Point& lowerRight() const { return pData->lrhc; }
  ...
};

这样修改后虽然可以保证只读的权限,但是返回一个引用仍然是危险的,因为它可能变成悬挂引用,考虑如下的例子:

class GUIObject { ... };

const Rectangle                             // returns a rectangle by
  boundingBox(const GUIObject& obj);        // value; see Item 3 for why
                                            // return type is const

GUIObject *pgo;                             // make pgo point to
...                                         // some GUIObject

const Point *pUpperLeft =                   // get a ptr to the upper
  &(boundingBox(*pgo).upperLeft());         // left point of its
                                            // bounding box

这里看上去没有问题,但是要注意boundingBox这个函数会返回一个临时创建的Rectangle对象,然后去调用upperLeft并把这个Point对象的地址传递给我们的指针。而这条语句结束以后这个临时对象就会被销毁,它内部的Point对象自然也会销毁了,于是这个指针就成了悬挂指针。所以返回句柄总是危险的,因为你不知道你所指向的对象究竟还存不存在。不过有一个例外就是vectorstring类的operator[]函数返回的也是引用

总结

避免返回对象内部的句柄(指针,引用,迭代器),这样做可以增加封装性,让const函数如预期的工作,也减少了悬挂的产生


ITEM 29: STRIVE FOR EXCEPTION-SAFE CODE

这一节主要就是讲异常的安全了,虽然目前工作中很少接触,不过还是了解一下。首先看如下的函数:

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  lock(&mutex);                      // acquire mutex (as in Item 14)

  delete bgImage;                    // get rid of old background
  ++imageChanges;                    // update image change count
  bgImage = new Image(imgSrc);       // install new background

  unlock(&mutex);                    // release mutex
}

通常来说异常安全要防止两件事情:

  1. 内存泄露
  2. 数据结构损坏

但是以上的函数无法做到这两点:
对于第一点,如果new Image抛出异常,我们就无法解锁,于是mutex就永远被占用了
对于第二点,如果new Image抛出异常,bgImage会指向一个被释放的对象,而且我们还给imageChanges进行了增加,这都是不正确的状态

解决内存泄露的问题我们只需要参照之前说的使用资源管理类来帮助我们管理内存即可,对于保证数据结构的正确性我们需要做出一些选择,首先是了解一下函数对于异常安全的三种等级:

  1. 提供基本保证的函数保证当抛出异常时程序是在一种有效的状态,所有的数据和对象没有损坏而且状态一致,但是无法预测处在哪种状态
  2. 提供强保证的函数保证如果抛出了异常,程序的状态就不会发生改变,类似于这个函数可以看成原子操作,要么成功然后状态也变为成功,要么失败然后状态维持原样
  3. 提供无异常保证的函数保证不会抛出异常,也就是它们永远会达到预期的结果,所有内置类型的操作都是这种类型,是最高的异常安全等级

显然,第二种比第一种用起来更加简单,因为它只可能有两种状态(成功和失败)而第一种则可能是任意状态。异常安全的代码必须提供以上三种安全等级之一,否则就不能算异常安全的代码,所以现在的问题就是我们应该选择哪一种

一条基本原则就是,我们应该提供实际可行的情况下最高的安全等级,无异常的等级确实很好,但是这几乎不可能实现,因为所有需要用到动态分配内存的代码(比如所有的STL容器类)都有可能抛出bad_alloc异常,所以能实现无异常是最好,但绝大多数情况下我们都是在强保证和基本保证中间进行取舍

对于上面的函数,我们稍微改造一下就能让它满足强保证,首先我们用资源管理类来避免内存泄露,然后我们调整一下语句的顺序使得抛出异常时程序的状态是正常的即可:

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

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  Lock ml(&mutex);

  bgImage.reset(new Image(imgSrc));  // replace bgImage's internal
                                     // pointer with the result of the
                                     // "new Image" expression
  ++imageChanges;
}

这里我们需要了解一种很方便实现强保证的方法,就是拷贝替换(copy-and-swap),具体做法就是先拷贝一个原有的对象,在这个拷贝上进行需要的操作,最后将它们替换。这样一来如果进行操作时出错,就不会影响到原来的对象,如果没有出错就成功达成想要的效果

struct PMImpl {                               // PMImpl = "PrettyMenu
  std::tr1::shared_ptr<Image> bgImage;        // Impl."; see below for
  int imageChanges;                           // why it's a struct
};

class PrettyMenu {
  ...

private:
  Mutex mutex;
  std::tr1::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  using std::swap;                            // see Item 25

  Lock ml(&mutex);                            // acquire the mutex

  std::tr1::shared_ptr<PMImpl>                // copy obj. data
    pNew(new PMImpl(*pImpl));

  pNew->bgImage.reset(new Image(imgSrc));     // modify the copy
  ++pNew->imageChanges;

  swap(pImpl, pNew);                          // swap the new
                                              // data into place

}                                             // release the mutex

不过要注意的是,虽然这种方法能保证某个函数的异常安全,但是并不能保证整体都是安全的

void someFunc()
{
  ...                                     // make copy of local state
  f1();
  f2();
  ...                                     // swap modified state into place
}

在上面的代码段中,如果f1或者f2不是强保证的异常安全,那么整体的肯定也不会是强保证的异常安全,而即使两者都是强保证的异常安全,也无法保证在其他地方有没有进行什么状态的修改。如果这个函数进行了数据库的操作,那就更难保证异常安全,因为可能其他地方已经看到了错误的提交。还有一个问题也就是效率问题,因为拷贝替换方法需要多进行一次拷贝,而这本来是不需要的。总之,对某些函数提供强保证是可行的,但对许多其他函数来说,为了效率我们可能只提供基本的保证

但是如果我们不提供异常安全的保证,那整个代码就都会受到影响。好比一颗老鼠屎烂了一锅粥,只要某个函数没有提供异常安全的保证,那么所有调用到它的地方也都没法提供异常安全的保证,整个系统也就是不安全的

总结

1. 异常安全的函数即使抛出异常,也不会有内存泄露和数据崩坏,它分为基本、强和无异常三种等级
2. 强保证的异常安全通常可以通过拷贝替换的方法来达成,但它并不使用于所有的函数
3. 一个函数提供的保证通常不会比它调用的函数中最弱的保证强


ITEM 30: UNDERSTAND THE INS AND OUTS OF INLINING

这一节的话主要是讲内联函数的使用,切忌过度使用。我们知道内联函数可以通过替换函数体的代码来避免函数调用的开销,如果用的太多的话,我们的代码会变得很臃肿,到处都是函数体。如果内联函数体很小,那么就能比函数调用的开销更小,从而减小我们的生成代码。通常我们有两种方法来实现内联,一种是隐式方法,直接在头文件中进行实现,一种是显式方法,通过使用inline关键字达成

class Person {
public:
  ...
  int age() const { return theAge; }    // an implicit inline request: age is
  ...                                   // defined in a class definition

private:
  int theAge;
};

template<typename T>                               // an explicit inline
inline const T& std::max(const T& a, const T& b)   // request: std::max is
{ return a < b ? b : a; }                          // preceded by "inline"

编译器通常会拒绝会比较复杂的函数进行内联(比如带循环或者递归),虚函数也无法进行内联,因为虚函数必须在运行时决定调用什么函数,而内联是编译时进行的,所以编译器无从知道应该拿什么函数体去替代它,通常内联失败时编译器会有一个警告。有时候虽然有的函数可以进行内联,但由于需要对它取地址,编译器还是会生成函数体,否则无法满足这个要求,比如如下的例子

inline void f() {...}      // assume compilers are willing to inline calls to f

void (*pf)() = f;          // pf points to f

...

f();                      // this call will be inlined, because it's a "normal" call

pf();                     // this call probably won't be, because it's through
                          // a function pointer

最后还要记住,构造函数和析构函数都不适合内联,虽然可能有的函数体是空的或者看起来很短,我们也不应该内联它。因为我们知道,当调用一个对象的构造函数时,我们会首先调用父类的构造函数,然后调用成员对象的构造函数,最后才调用它自己的构造函数,所以构造函数其实做了很多事情,它只是看起来为空,当然也就不适合内联

从调试的角度来说,内联也是不利于调试的,因为没法在函数体中放置断点。总之,慎用内联,如果要用,就精确的用在能真正提升性能的地方(这里说到了代码的2-8原则,也就是通常一个程序花了80%的时间跑了20%的代码)

总结:

1. 只在短小的且频繁被调用的函数使用内联,这样可以增加可调试性、二进制文件升级性、减少代码膨胀的可能性以及提高程序运行的效率
2. 不要因为模板函数出现在头文件就把它们声明为内联函数


ITEM 31: MINIMIZE COMPILATION DEPENDENCIES BETWEEN FILES

这一节主要就是讲减少文件间的依赖了,正好这周的share也讲到了这个,我觉得还是蛮有意义的。我们可能都经历过,一个文件改了一行代码但是编译却需要五分钟,这就是因为依赖太多了,所有用到它的文件都要重新进行编译。为了提高编译的效率,我们应该尽量减少文件之间的依赖

首先我们要知道,#include是怎么工作的。它是一条预编译指令,其实做的事情就是把头文件中的内容完全复制粘贴到当前的文件中,而如果包含的头文件太多的话生成出来的代码就会很大。然后我们要知道,头文件的作用是什么。其实头文件的作用就是进行声明,然后让源文件进行实现。而进行类的定义时,编译器需要知道类具体的大小从而为它们分配空间,所以如果某个类包含其他类的成员变量,我们就必须包含定义了这个类的头文件,否则编译器无法知道它的大小,也就无法分配空间,比如

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;        // implementation detail
      Date theBirthDate;          // implementation detail
      Address theAddress;         // implementation detail
};

这个定义的Person类中我们有三个成员变量,分别是std::stringDateAddress,所以我们必须加上如下的语句才能编译:

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

这样就会导致文件之间依赖的产生,如果这三个头文件发生了变化,那么我们的Person类就要重新编译,用到它的文件也都需要重新编译。一种解决办法是接口与实现相分离:

#include <string>                      // standard library components
                                       // shouldn't be forward-declared

#include <memory>                      // for tr1::shared_ptr; see below

class PersonImpl;                      // forward decl of Person impl. class
class Date;                            // forward decls of classes used in

class Address;                         // Person interface
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:                                   // ptr to implementation;
  std::tr1::shared_ptr<PersonImpl> pImpl;  // see Item 13 for info on
};                                         // std::tr1::shared_ptr

这里我们用了前向声明的方式来声明成员函数,然后成员变量仅仅包含一个指向实现的指针。这样一来如果实现发生了改变,所有用到Person的代码都不需要重新编译,因为它们看不到实现的细节,编译器只需要分配一个4字节的大小给指针即可。这种分离的关键是用对声明的依赖替换对定义的依赖,这就是最小化编译依赖性的本质:尽量让头文件自给自足而不引用其他头文件,如果需要的话,依赖其他文件中的声明而不是定义

  1. 如果可以使用对象的引用或指针就不要使用对象(使用聚合模式而不是组合模式):只需要定义就能使用指向对象的引用或指针,而如果要使用对象则必须要知道它的实现
  2. 依赖类的定义而不是实现:即使某个函数按值传递或返回了某个类,我们也不需要它的定义
class Date;                        // class declaration

Date today();                      // fine — no definition
void clearAppointments(Date d);    // of Date is needed

注意,这里不需要有定义,只需要前向声明Date即可,原因其实很自然:如果有人调用了这个函数,那么他一定已经知道了Date的定义,既然如此我们就没必要在头文件中再包含它的定义。而且并不是每个客户端都需要调用这个函数,通过将定义的责任转到调用的客户端,我们避免了对该类型的依赖

  1. 将声明和实现的头文件分开(也就是原来的类和实现类两个头文件),后面主要在讲Handle类和Interface类,平常工作中已经用的很多了就略过了

总结

1. 最小化编译时间的基本思想就是依赖声明而不是实现,Handle类和Interface类两种方法都可以达成这种目的
2. 标准库的头文件总是应该被完整的引用,不管有没有用到模板

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值