Effective C++ 条款简要梳理

注:文章内容总结自 Effective C++ 第三版

一  习惯 C++

1  把 C++ 看做一个语言联邦,而非单一语言

它的次语言有4个:C、Object-Oriented C++、Template C++、STL。C++的高效守则在这4个次语言中是有区别的,需要具体问题具体分析,使用对应于该次语言的最佳实践。

2  尽量以const、enum、inline替换#define

(1)

问题:

#define MAX 100

场景:

类似这样的定义,宏 MAX 可能并未进入记号表,而编译器提示出错信息为100,如果这个文件不是自己写的,追溯将异常困难。

解决:

const int MAX = 100;

常量定义式通常被放在头文件内,以便被不同的源码包含。

Tips:若要定义字符串常量,最好使用 std::string 而非 const char*。

(2)

为了将常量的作用域(scope)限制于类内,必须让其成为类成员,如果需要确保这个常量只有一份实体,需要使其成为static成员。

问题:

class A{

    private:

    static const int MAX = 100; //常量声明式,并非定义式。

    //新式编译器才支持 static 成员在声明时获得初始值

};

如果是类专属 static 整数类型(int,char,bool)常量,需要特殊处理:

  • 不取其地址:可以只声明,并使用
  • 取地址:(或者编译器有毛病就是要求看到定义式)必须提供定义式

解决:

const int A::MAX; //声明时已经获得初始值,定义时就不可以再设初始值。

Note:这个式子应该位于实现文件中,而非头文件中。

(3)

问题:

宏常量 MAX 不可以用作 in class 初值设定,如 int array[MAX]。

一个属于枚举类型的数值可权充int被使用。

解决:

class A{

    private:

    enum{ NUM = 5}; //取一个enum的地址是不合法的

    int array[NUM];

};

(4)

一个常见的 #define 误用情况是以它实现宏。该宏看起来像函数,但不会招致函数调用带来的额外开销。

问题:

#define MAX(a,b) f( (a)>(b) ? (a) : (b) )

Tips:这种形式的宏,必须为每个实参加括号,防止错误,如:边际效应。

解决:

template

inline void MAX(const T& a, const T& b)

{

   f(a > b ? a : b);

}

3  确定对象使用前已被初始化

(1)

问题:

读取未初始化的值会导致程序不明确的行为。在某些平台,该行为可能让你的程序停止运行;也可能读取一些半随机bits,污染了正在进行读取动作的那个对象。

Note:C++规定,成员变量的初始化动作发生在进入构造函数本体之前。

因此形如:

class A{
public:

   A(string a,int b){
      the_a = a;
      the_b = b;

   }

private:
    string the_a;
    int the_b;
};

这种类构造函数其实是赋值,并不是初始化。

除去内置类型(因为它们没有构造函数),类成员变量的初始化发生在这些成员的 default 函数被自动调用之时(比进入构造函数本体的时间更早)。

内置类型不保证一定在赋值动作之前获得初值。

Tips:最佳初始化 member initialization list(成员初值列):

class A{
public:
    A(string a,int b): the_a(a), the_b(b){ }

private:
    string the_a;
    int the_b;
};

C++中,基类先于子类被初始化,成员变量总是以声明顺序被初始化(无论其在初值列中顺序如何),最好按照声明次序来写成员初值列。

(2)

C++中 static 对象,其寿命从被构造直到程序结束为之。这种对象包括:

  • 全局对象
  • namespace 作用域内的对象
  • 在 class内(或函数内、file作用域内)被声明为 static 的对象。

函数内的 static 对象成为 local static 对象,其他对象成为 non-local static对象。它们的析构函数会在main函数结束时调用。

(3)

编译单元(translation unit)是指产出单一目标文件的源码,它们基本上是单一源码文件加上其所包含的头文件。

问题:如果某一编译单元的某 non-local static 对象的初始化使用了另一编译单元的某non-local static对象,它所用的这个对象可能还没被初始化。

编译单元1:

class A{
public:
    int num() const;
};

extern A a;

编译单元2:

class B{
public:
    B(){
        int b = a.num();//调用a对象
    }
};

除非a在B对象调用之前被初始化,否则B构造函数将会用到未初始化的a。

解决:

将每个 non-local static 对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个引用指向它所含的对象。然后用户调用这些函数。也就是说:non-local static对象被 local static 对象替换了。这是单例模式的一个常见实现手法。

编译单元1:

class A{…};
A& A::getA(){
    static A a;
    return a;
}

编译单元2:

class B{};
B::B(){
    b = A::getA().XXX();
}

缺点:

内含 static 对象的函数在多线程系统中有不确定性。

Tips:为免除“跨编译单元之初始化次序”问题,以 local static 对象替换 non-local static 对象。

二  构造、析构、赋值运算

4  了解 C++ 默认编写并调用的函数

对于空类:

class A{}

C++编译器默认为其提供(这些函数被调用时它们才会被创建出来):

class A{
public:
    A(){…}    //默认构造函数
    A(const A&){…}    //copy构造函数
    ~A(){…}    //非虚析构函数
    A& operator=(const A&){…}    //copy assignment 操作符
}

5  该拒绝使用编译器自动生成的函数的时候,就要明确拒绝

(1)

问题:

如果一个类,它希望自己是独一无二的(有这种需求),那么这个类中就不能有 copy 构造函数,以及 copy assignment 操作符。

思路:

C++编译器自动产出的函数都是public的。

解决:

把copy构造函数,以及copy assignment操作符声明为private,不去定义它。

class A{
private:
    A(const A&);
    A& operator=(const A&);
}

隐忧:

该类的成员函数和友元函数仍然可以调用这个类中的private函数。如果这么做了,编译器将抛出一个链接错误(linkage error)。

问题:

如何杜绝 member 函数和 friend 函数的这种错误操作?也就是,如何将链接错误移到编译期?(error越早被检测出来越好)

解决:

专门设计一个为了阻止copying动作的基类,让目的类继承此基类

class Uncopyable{
protected:
    Uncopyable(){}
    ~ Uncopyable(){}

private:
    Uncopyable(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
};

class A: private Uncopyable{};

此时,任何一个成员函数或者友元函数,尝试拷贝A对象时,编译器就会试着生成一个copy构造函数或者拷贝赋值操作符,那么这些生成的函数将会调用基类中的对应函数,而基类中的该函数是 private,因此被拒绝。

Tips:为驳回编译器自动提供的功能,可将相应的成员函数声明为 private 并且不予实现(定义),C++ iostream 程序库使用此方法防止 copy。使用继承方法也可。

6  为多态基类声明virtual析构函数

一个多态类:

class TimeKeeper{
public:
    TimeKeeper();
    ~TimeKeeper();
};

class AtomClock: public TimeKeeper{…};    //原子钟
class WaterClock: public TimeKeeper{…};    //水钟
class WristClock: public TimeKeeper{…};    //腕表

问题:

TimeKeeper* ptr = new WristClock();
delete ptr;

这个时候,delete 的对象不包括子类中不包括基类的部分,也就是删去了基类部分,子类独有的部分被保留。造成资源泄露。

解决:

Tips:将多态基类的析构函数声明为虚函数。

将析构函数声明为虚函数的情况:该类中至少有一个虚成员函数,或者该类是多态基类,不能把每个类的析构函数都声明为虚函数。

point:

polymorphic(带多态性质的)基类应该声明一个virtual析构函数。如果类带有任何virtual函数,那么它应该拥有一个virtual析构函数。类设计的目的如果不是作为基类使用,或不是为了具备多态性,就不该声明虚析构函数。STL容器不带虚析构函数。

7  不让异常逃离析构函数

问题:

如果一个容器中有一个以上的元素在析构期间,抛出异常,会导致不明确的行为。C++不希望析构函数吐出异常。

解决1:

使用try catch捕捉异常,并且为用户提供一个手工“析构”(关闭、删除)的函数。

解决2:

如果发生异常就结束程序,阻止异常的传播所导致的不明确行为。

1:

class DBConnection{
public:
    void close(){
        db.close();
        closed = true;
    }

    ~ DBConnection(){
        if(!closed){
            try{
                db.close();
            }catch(…){
                //记录异常信息
            }
        }
    }

private:
    DBConnection db;
    bool closed;
};

相当于一个双重保险,把 close 的任务从析构函数移交到使用函数的客户身上。

2:

try{
    db.close();
}catch(…){
    //记录异常信息
    std::abort();
}

point:

析构函数绝对不要吐出异常,如果一个被析构函数调用的函数可能抛出异常,析构函数应捕捉这个异常,并处理(吞下)它(不传播)或者结束程序(abort)。如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应提供一个普通函数(而不是在析构函数中)执行该操作。

8  不在构造和析构过程中调用virtual函数

问题:

class A{
public:
    A();
    virtual void log() const = 0;//纯虚函数
};

A::A(){
    log();
}

class B: public A{
public:
    virtual void log() const;
}

场景:

B b;

意为着B的构造函数被调用,而在这之前基类的构造函数会被调用,意为着虚函数log会被调用,而这个log函数一般是只被声明不被定义的,那么编译器将发出警告或者产生链接错误,因为找不到A::log()的实现代码。

其实这种错误,关键在于写好自己的多态机制。

解决:

class A{
public:
    A(const std::string& info);
    void log(const std::string& info);
};

A::A(const std::string& info){
    log(info);
}

class B: public A{
public:
    B(param): A(creatInfo(param)){ //将log信息传递给基类构造函数
}

private:
    static std::string creatInfo(param);
}

由于无法使用虚函数从基类向下调用,因此在构造函数期间可以令子类将必要的信息向上传递给基类构造函数。

point:

在构造和析构期间不要调用虚函数,因为这类调用不会下降到子类中执行。

9  令operator=返回引用指向当前对象

标准:

class T{
    T& operator=(const T& t){  
        //也适用于+=、-=等
        //注意:t 和 this 有可能指向同一对象
        //确保要进行的操作不会被此现象影响即可,无需过多额外操作
        //若处理:可以使用identify test(证同测试)if(&t == this)
        //也可:T tmp(t); swap(t,*this);return *this;
        if (this != &t){
            // 最重要的是通过新创建的对象 tmp(也可能是数据成员)交换 this 和 t 的数据成员
            // 之后,tmp对象自动析构,释放掉原本 this 中的数据成员
            // 之所以这么做,而不是 delete this,再 this = new type[n],再 copy(this, t)
            // 是因为:防止 this 对象释放内存后,申请内存失败,从而造成 this 被破坏
            T tmp(t);
            swap(tmp, *this);
        }
        return *this;
    }
};

10  复制对象时复制所有成分

point:

拷贝函数应复制 “对象内的所有成员变量” 和 “所有 base class 成分”

三  资源管理

11  以对象管理资源

问题:

void close () {
    Obj *obj = createObj();  //通过工厂函数创建对象
    …
    delete obj;
}

如果:

  • “...” 区域存在 return 语句且被调用
  • “...” 区域内抛出异常

这两种情况导致delete语句无法得到执行,从而造成资源泄露。

解决:

为确保资源总是被释放,需要将这些资源放进对象内,依靠 C++ 的析构函数自动调用机制确保资源释放。

许多资源被动态分配于 heap 内而后被用于单一区块或函数内,它们应该在控制流离开此区域后被释放,使用智能指针auto_ptr (C++11已弃用)可达到此效果,其析构函数自动对所指对象调用 delete。

void close() {
    std::auto_ptr ptr( createObj() );
    …
}

“以对象管理资源”常被称为“资源取得时机便是初始化时机(Resource acquisition is initialization,RAII)。

注意:

auto_ptr 被销毁时自动删除它所指对象,所以不要让多个 auto_ptr 指向同一对象,这样会导致对象被删除一次以上,导致程序“未定义行为”。

若使用拷贝构造函数复制 auto_ptr 对象,它将变成null,而复制所得的 auto_ptr 对象将获取资源的唯一拥有权。

一般使用 share_ptr 而不是 auto_ptr,因为 share_ptr 不会有复制问题,确保它们被销毁时其指向对象也销毁。

但是,若有环状引用存在,share_ptr 不适用(当share_ptr引用次数为0时,删除其所指向的对象)。

Note:share_ptr 和 auto_ptr 都在其析构函数内做 delete 操作而不是 delete[] 操作,这就意味着动态分配数组时使用 share_ptr 或  auto_ptr 不合适。

12  在资源管理类中小心copy行为

 

对于 RAII 对象的复制行为,有两种选择:

  • 禁止复制:使copy函数私有,或以私有方式继承 uncopyable 类,见条款 5
  • 对底层资源使用引用计数法(reference-count):在”希望保有资源,并且在它的引用为0时释放资源“时,采用这种方式
void lock(Mutex *pm);
void unlock(Mutex *pm);

class Lock{
public:
    explicit Lock(Mutex *pm): mutexPtr(pm, unlock)
        //指定删除器为unlock函数
        //当Lock析构时会调用成员变量的析构函数,意味着shared_ptr的析构函数调用
        //当资源引用计数为0时进行析构,unlock函数作为删除器被自动调用,解锁资源
    {
        lock(mutexPtr.get());
    }

private:
    std::tr1::shared_ptr mutexPtr;
};

13  以相同形式成对使用new和delete

问题:

string* ptr = new string[100];
delete ptr;  //只删除了一个string对象,后99个不会被适当删除

难点:

delete 被调用时,针对此内存有一个或多个析构函数被调用,然后内存才得到释放,那么这个内存中究竟有多少对象?

当对象单一时,对象内存不需要额外的辅助内存,而当存在一个对象数组时,会有额外的内存用于标记对象数组的大小。

因此,当对一个单一对象使用 delete[],可能导致不恰当的多次析构函数调用;当对一个对象数组使用 delete,可能导致只有一个对象被恰当的删除。

typedef string array[4];

string* ptr = new array;  //等同于 new string [4]

这个时候容易错误使用delete ptr来删除对象。因此最好避免对数组的 typedef。转而使用vector

point:

new T[n] – delete[] ; new T – delete。

14  以独立语句将newed对象放入智能指针

有两个函数:

int priority();
void A(shared_ptr a, int priority);

如下的调用可能被写出:

A(shared_ptr(new A), priority());

这可能造成资源泄露,因为这条语句的执行语序并不确定(但是b必定在c之前):

问题:

如果执行顺序为:b-a-c,那么如果 a 执行产生异常,b 操作返回的指针将会丢失,造成资源泄露。

解决:

shared_ptr ptr(new A);
A(ptr, priority());

四  设计与声明

15  设计class犹如设计type

需要考虑以下几点:

(1)新 type 的对象应该如何被创建和销毁?

(2)对象的初始化和对象的赋值该有什么样的差别?

(3)新 type 的对象如果被 passed by value,意味着什么?

(4)什么是新 type 的合法值?确定某些成员变量的取值是否需要约束为 enum 型?

(5)新 type 需要配合某个继承图系(inheritance Graph)吗?

(6)新 type 需要什么样的转换?考虑显式和隐式类型转换?

(7)如何设计操作符、成员函数和非成员函数?

(8)是否驳回某些 C++ 默认为你编写的函数?(将之置为private)?

(9)谁可以调用新 type 的成员?如何决定 public、protected、private?如何决定 friends?

(10)什么是新 type 的未声明接口?(undeclared interface)?

(11)新 type 有多一般化?如果要定义的是一整个 types 家族,考虑 class template

(12)设计新 type 是必须的吗?如果只是定义新的衍生类为已有的基类添加功能,也许为基类定义一个非成员函数或者templates就可以达到要求?

16  宁以pass-by-reference-to-const代替pass-by-value

通过设计 const T& t 这样的参数列表,可以有效的避免类及类成员的构造函数和析构函数被多次调用。

以 by-value 方式传递参数可以避免对象切割问题。

class A{
public:
   virtual void display() const;
}

class B : public A{
public:
   virtual void display() const;
}

void print(A a){
   a.display();
}

当有以下调用:

B b;
print(b);

此时,b 被视为一个基类(A)对象,A 的 copy 构造函数被调用,而造成 B 的特有性质被切割掉,仅仅留下基类部分。

解决:

void print(const A& a){
   a.display();
}

reference 往往以指针实现出来,因此如果对象是内置类型,那么 pass-by-value 的效率高于 pass-by-reference。

point:

一般而言,如果对象是内置类型或者 STL 迭代器和函数对象,使用 pass-by-value 更为合理,至于其他,宁以 pass-by-reference-to-const 代替 pass-by-value。

17  必须返回对象时,不要返回其reference

(1)

考虑以下情况:(像 * 这样可以连续操作的操作符:a * b *c,最好返回值传递,如此即可以正确得到结果,还保证不内存泄漏)

class Num{
public:
   Num(int x,int y);

private:
   int num1,num2;
   friend const Num operator* (const Num& l, const Num& r){
       return Num(l.num1 * r.num1, l.num2 * r.num2);//返回值传递的正确做法
   }
};

这个 * 以 pass-by-value 的方式返回两个 Num 相 * 的计算结果。但是需要支付相应的代价:构造函数。

错误观点:

如果返回 reference,那么可以避免支付相应的代价。

错误原因:

Notes:reference 只是个名称,代表着某个既有对象,任何时候看到一个引用,都要马上考虑,它的另一个名称是什么?

对于这样的程序(如果操作符返回 reference):

Num a(1,2);
Num b(3,5);
Num c = a*b;

这意味着原本就存在一个 Num 对象(3,10),非常不合理,如果要返回一个 reference 指向这个对象,那么必须先创建这个对象。

错误做法:

friend const Num& operator* (const Num& l, const Num& r){
   Num res(l.num1 * r.num1, l.num2 * r.num2);//大错特错,reference指向局部栈对象
   return res;//退出函数即销毁对象
}

错误做法:

friend const Num& operator* (const Num& l, const Num& r){
   Num *res = new Num(l.num1 * r.num1, l.num2 * r.num2);
   return *res;
}

(2)

考虑这样的调用:

Num a,b,c,d;
d = a*b*c  //等同于operator*( (operator*(a,b),c)

a*b 返回一个临时的引用,那么如何销毁 a*b 所返回的引用指向的对象?并没有合理的方法,因此只能释放 w 的内存,从而造成内存泄漏。

错误做法:

friend const Num& operator* (const Num& l, const Num& r){
   static Num res;
    res = …;
   return res;
}

一则 static 会带来多线程问题,二者考虑以下程序:

bool operator== (const Num& l, const Num& r);
Num a,b,c,d;
if((a * b) == (c * d)){//永远返回true,因为a*b和c*d都指向同一个static Num对象。
    …
}

18  将成员变量声明为private

为什么这么做?

(1)语法一致性

如果成员变量不是 public,客户唯一访问类内成员变量的方法就是通过成员函数。通过成员函数,可以让类设计者对成员变量的处理有更精确的控制,例如:禁止访问、只读、只写等。

(2)封装

类内部的实现对于用户来说是不可知的,将成员变量隐藏在函数接口的背后,可以为“所有可能的实现”提供弹性,用户只需要关注接口(函数)说明书即可获得他们需要的处理结果。

Note:protected 成员变量的封装性并非好过 public 成员变量。

成员变量的封装性与“成员变量的内容改变时所破坏的代码数量”成反比。如果删去一个 protected 变量,意味着所有子类都被破坏。

19  宁以non-member、non-friend替换member函数

考虑这样的类:

class Browser{
public:
   void clearCache();
   void clearHistory();
   void clearCookies();
   //方式1:成员函数
    void clearAll(){
        clearCache();
        clearHistory();
        clearCookies();
    }
};

//方式2:非成员函数
void clearAll(){
   clearCache();
   void clearHistory();
   void clearCookies();
}

这两种方式哪一种较好?

从封装来说:越多东西被封装,越少客户可以看到它,类设计者就有更大的弹性去变化它。封装使我们改变事物,并且只影响有限客户。

越少代码可以访问数据,越多的数据就可以被封装。以能访问该数据的函数数量,大致作为该数据的封装性度量。

Note:如果成员函数和非成员函数提供相同的功能(由于成员函数可以访问私有成员),导致较大封装性的是 non-member、non-friend函数,它们并不增加可以访问类内私有成员的函数的数量。

注意:该非成员函数也可以是另一个类的成员函数。这意味着可以建立一个统一管理类。

Tips:标准做法

namespace BrowserStuff{
    class Browser{};
    void clearBrowser(Browser& browser);
}

通常一个 Browser 类里面有大量的函数,可能有书签、打印这类的函数,用户如果只使用其中一小部分,但是却包含了一个Browser.h 这样一个庞大的头文件,这将增大函数间的编译相依关系。可以这样做来降低编译相依性:

//browser.h
namespace BrowserStuff{
    class Browser{};  //核心功能
    …              //非成员函数
}

//browserbookmarks.h
namespace BrowserStuff{
    …              //与书签相关的非成员函数
}

//browserprint.h
namespace BrowserStuff{
    …              //与打印相关的非成员函数
}

Tips:上述方式是C++STL的组织方式

20  若所有参数需要类型转换,应采用non-member函数

考虑这样的有理数设计:

class Rational{
public:
    Rational(int numerator = 0,int denominator = 1);  // 非显示声明,且带有默认参数,很可能造成隐式类型转换
    const Ration operator*( const Ration& rational) const;

private:
    …
};

Rational a(1,8);
Rational b(2,2);
Rational c = a*b;  //以上都是同类型对象的*操作,没问题
Rational d = a*2;  //正确,2被隐式类型转换为Rational
Rational e = 2*a;  //错误,因为2是int类型,不是Rational,也就没有操作符重载

(1)如果想要支持混合计算这种存在函数类型转换的操作符重载,需要不同的设计:

class Rational {…};
const Ration operator* (const Ration& a, const Ration& b){
    return Rational(a.numerator * b.numerator, a.denominator * b.denominator);
}

2. 如果不想要支持这种混合计算,就把构造函数使用 explicit 强制显式声明。

五  实现

21  尽量延后变量定义式的出现时间

(1)

考虑变量被定义,但是却并没有使用的情况:

string pwd(const string& password){
    string code;
    if(password.length() > MAXPWDLENGTH){
        throw logic_error(“pwd is too long!”);
        //在这之后做声明更好,如果可能,给出初值实参,这样更好。
        …
        return code;
    }
}

如果抛出错误,那么这个函数需要支付字符串 code 的析构和构造费用。

(2)

考虑循环中的变量声明:

1个构造+1个析构+n个赋值

Widget w;
for(int i : nums){
    w = … ;
}

n个构造+n个析构+n个赋值

for(int i : nums){
    Widget w = … ;
}

当一个赋值操作的成本 < 一组构造+析构的成本时,(1)做法更好,但是(1)引入的变量作用域广于(2),程序的可理解性和易维护性将下降;如果程序是效率高度敏感(performance-sensitive)的,应该使用做法(1),否则使用做法(2)。

22  尽量少做转型动作

C-Style 类型转换:

(T) expression
T (expression)

C++新式转型:

  • const_cast( exp ):消除变量的常量性(此能力在 C++中是唯一)
  • dynamic_cast 执行安全向下转型(safe downcasting),花费巨大。当用户需要在子类对象上,执行子类函数操作,但是手头只有指向基类的指针时,可能用到这个转型动作。
  • reinterpret_cast 执行低级转型
  • static_cast 强迫隐式转换,除了做不到 const to non-const,其他都能做到。

新式类型转换为什么受到欢迎?

  • 它们很容易在代码中识别出来;
  • 各转型动作的目标越窄化,编译器越可能诊断出错误的应用。

point:

(1)尽量避免转型,特别注意在注重效率的代码中避免 dynamic_cast;

(2)如果转型是必要的,则应尝试将它隐藏于某个函数背后,客户可以随后调用该函数,而不用自己写转型代码;

(3)宁用新式,勿用旧式。

23  异常安全

异常安全函数(Exception-safe functions)提供以下三个保证之一:

(1)基本承诺

若异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或者数据结构被破坏。但是程序的现实状态可能无法预料。例如:changeBg 发生异常时,bg 对象可以继续保有原背景图像,或者令其拥有其他默认背景图像,但用户无法得知具体是什么,只有通过调用成员函数才知道。

(2)强烈保证

若异常被抛出,程序状态不改变。若函数成功,就是完全成功不成功,则回退到调用函数之前的状态。

(3)不抛掷(nothrow)保证

承诺绝不抛出异常,因为它们总能完成预期的功能。作用于内置类型的所有操作都提供 nothrow 保证。

24  透彻了解inline

inline优点:

(1)对函数的每一次调用,都以函数本体替换之,免除函数调用成本

(2)编译器最优化机制通常用来浓缩那些不含函数调用的代码,所以当inline某个函数,使得编译器有能力对它进行语境相关最优化。

inline缺点:

(1)增加代码大小,占用内存,即使拥有虚内存,inline 造成的代码膨胀可能会导致换页(paging)行为,降低高速缓存装置的击中率(instruction cache hit rate),以及伴随这些而来的效率损失。

(2)Note:inline 函数无法随着程序库的升级而升级。例如:f 是程序库内的一个 inline 函数,一旦函数设计者改变了 f 函数,所有用到 f 函数的客户端都必须重新编译(因为发生了代码替换)。而若 f 是 non-inline 函数,一旦 f 有修改,客户端重新连接即可。

(3)调试器对 inline 不友好,因为你如何在一个不存在的函数内部设置断点呢?

注意:

inline只是对编译器的申请,并非强制命令。

Note:对用所有的 virtual 函数,inline 调用将被编译器无视,以为 virtual 意味着直到运行期才知道哪个函数被调用,而 inline 意味着执行前,先将调用动作替换为被调用函数的本体。

六  继承与面向对象设计

25  public继承意味着is-a

point:

public 继承意味着 is-a 。适用于基类的每一件事情也一定适用于子类,因为每个子类对象同时也是基类对象。

26  绝不重新定义继承而来的non-virtual函数

考虑情况:

class B{
public:
    void mf();
}

class D: public B{
    void mf();
}

D d;
B* pb = &d;
pb->mf();  //调用B::mf()
D* pd = &d;
pd->mf();  //调用D::mf()

造成这种情况的原因是?

Tips:non-virtual 函数都是静态绑定(statically bound),这意味着,由于 pb 被声明为一个 pointer-to-B,通过 pb 调用的 non-virtual函数永远是 B 所定义的版本。

virtual 函数是动态绑定(dynamically bound),这意味着,如果 mf 是个 virtual 函数,不论是通过 pb 还是 pd 调用 mf,都将调用 D::mf(),因为 pb 和 pd 均指向类型为 D 的对象。

Tips:也就是说,如果在基类中声明一个 non-virtaul 函数,就意味着建立一个不变性(invariant),凌驾于特异性(specialization)之上,这意味着类设计者希望这是一个共性,子类中无需重新定义。如果希望的是提供一份默认操作,或者接口,或者随着子类的变化而产生相应的操作的函数,那么应该将之声明为 virtual,然后在子类中重新定义。

27  绝不重新定义继承而来的缺省参数值

为什么?

因为缺省参数值是静态绑定。

point:

Tips:virtual 函数是类设计者唯一应该覆写的东西。

28  通过复合构造has-a关系或“根据某物实现出”

考虑简单的复合(composition):

class Address{};
class PhoneNumber{};

class Person{
…
private:
    Address ads;
    PhoneNumber pn;
};

以上称作 has-a,意味人有一个地址、一个电话号码。

新定义一个用 list 实现的 set,那么将 set 中置入私有成员 list lst,通过 lst 的 insert()、size() 等操作来实现对应的 set 的操作。

以上称作“根据某物实现出“(is-implemented-in-terms-of)

29  正确地使用private继承

(1)

Note:private继承并不意味着 is-a关系。它意味着“根据某物实现出”

考虑这样的代码:

class Person{};
class Student : private Person{};

void eat(Person& p);
void study(Student& s);

Person p;
Student s;
eat(p);  //正确
eat(s)  //错误!

private 继承的行为规则:

  • Note:若采用 private 继承,编译器不会自动将子类对象转换为基类对象,如上例;
  • 由 private 继承而来的成员,都将在子类中变为 private 属性。

选择复合还是私有继承实现“根据某物实现出”?

尽可能使用复合,必要时才使用私有继承。

考虑这样一个情况:设计一个可以计算 Widget 类,可以获悉它的成员函数调用频繁情况。

考虑Timer类:

class Timer{
public:
    explicit Timer(int frequency);
    virtual void onTick() const;  //定时器每滴答一次,此函数自动调用一次
};

那么,让 Widget 类继承 Timer 类即可,但是 public 继承并不恰当,首先 Widget 并不是个 Timer,其次,如果这样设计,用户可以对一个 Widget 对象调用 onTick(),容易造成接口的误用。因此必须以 private 继承 Timer:

class Widget : private Timer{
private:
    …
    virtual void onTick() const;
};

这样的设计效果同样可以由复合实现出来:

class Widget {
private:
    class TimeWidget : public Timer{
        public:
        …
        virtual void onTick() const;
    };

    TimeWidget tw;
};

(2)

考虑如下代码

class Empty{};  // 无任何数据

class KeepAnInt{
private:
    int a;
    Empty e;
};

sizeof(KeepAnInt) > sizeof(int),在大多数编译器中,sizeof(Empty) = 1,面对“大小为零之独立(非附属)对象”,C++通常安插一个 char 到对象内,而齐位需求可能造成编译器为KeepAnInt这样的类加一些衬垫(padding),所以造成KeepAnInt对象大小不止一个 char 大小(很有可能是两个int大小)。

但是,若:

class KeepAnInt : private Empty{
private:
    int a;
};

那么sizeof(KeepAnInt) = sizeof(int),这称作 EBO(empty base optimization,空白基类最优化)。

Tips:使用 private 继承可以做空白基类优化。

30  正确地使用多重继承

多重继承(multiple inheritance,MI),如果一个子类同时继承类A和类B,而A、B中又同时含有某个相同的成员,那么将造成歧义。虚基类和虚继承为解决此问题而生。

Tips:将造成衍生类中含有重复成员的类声明为虚基类,所有直接继承自它的类必须采用 virtual 继承。

如:

class File{};
class IptFile:virtual public File{};
class OtpFile:virtual public File{};
class IOFile : public IptFile, public OtpFile{};

这样可以避免 File 中成员被 copy 至 IOFile 中若干份。

 

 

 

你可能感兴趣的:(C++)