• Effective C++ —— 实现(五)


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

      1. 你不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。这样,不仅能够避免构造(和析构)非必要对象,还可以避免无意义的default构造行为

      2. 考虑下面两个循环:

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

    在Widget函数内部,以上两种写法的成本如下:

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

    2. 做法B:n个构造函数 + n个析构函数;

      如果classes的一个赋值成本低于一组构造+析构成本,做法A大体而言比较高效。尤其当n值很大的时候。否则做法B或许较好。此外做法A造成名称w的作用域(覆盖整个循环)比做法B更大,有时那对程序的可理解性和易维护性造成冲突。因此除非(1)你知道赋值成本比“构造+析构”成本低, (2)你正在处理代码中效率高度敏感的部分,否则,你应该使用做法B.(将变量定义于循环内

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

    条款27 : 尽量少做转型动作

      目前,C++支持三种转型语法:

      1. C风格转型动作: (T)expression  // 将expression转型为T 

       2. 函数风格转型动作: T(expression)  // 将expression转型为T

    上面两种皆是“旧时转型”,C++还提供四种新式转型:

      3.  新式转型详见C++新式转型

        const_cast<T>(expression)

        dynamic_cast<T>(expression)

        reinterpret_cast<T>(expression)

        static_cast<T>(expression)

    新式转型各有不同目的

      1. const_cast<T>(expression):通常被用来将对象的常量性移除。它也是唯一有此能力的C++-style转型操作符。

      2. dynamic_cast<T>(expression):主要用来执行“安全向下转型”(基类向子类的向下转型(Down Cast)),(“安全向下转型”,一般而言,基类向子类转型为不安全转型,会引发程序异常,使用dynamic_cast,这类转型则会返回一个Null值,消除了异常,实现了“安全”)也就是用来决定某对象是否归属继承体系中的某个类型。它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作。表达式dynamic_cast<T*>(a) 将a值转换为类型为T的对象指针。如果类型T不是a的某个基类型,该操作将返回一个空指针。

      3. reinterpret_cast<T>(expression):意图执行低级转型,实际动作(及结果)可能取决于编译器,这也就表示它不可移植。

      4. static_cast<T>(expression):用来强迫隐式转换,例如将non-const对象转为const对象,几乎旧式转型的操作都可以通过static_cast来替换实现。但它无法将const转为non-const——这个只有const_cast才办得到。

      旧式转型仍然合法,但新式转型较受欢迎。可以说唯一使用旧式转型的时机是:当我要调用一个explicit构造函数将一个对象传递给一个函数式。

      尽量少做转型动作,有如下情况:

      情况1:任何一个类型转换(无论是通过转型操作而进行的显式转换,或通过编译器完成的隐式转换)往往真的令编译器编译出运行期间执行的码。如下:

    class Base { .... };
    class Derived:public Base { ... };
    Derived d;
    Base* pb = &d;       // 隐喻地将Derived* 转换为Base*

      上述例子中,单一对象(例如一个类型为Derived的对象)可能拥有一个以上的地址(例如,“以Base* 指向它”时的地址和“以Derived*指向它”时的地址。)C、Java、C#都不可能发生这种事,但C++可能!实际上一旦使用多重继承,这事几乎一直发生着。即使在单一继承中可能发生。
      情况2:考虑下面代码:

    class Window
    {
        public:
            virtual void onResize() { .... }     // base onResize实现代码
            .....
    };
    class SpecialWindow:public Window 
    {
        public:
            virtual void onResize() {
                static_cast<Window>(*this).onResize();       // 将*this转型为Window,然后调用其onResize;然而,这不可行
                .......... // 这里进行SpecialWindow专属行为
        }
        .....
    };

      一如你所预期,这段程序将*this转型为Window,对函数onResize的调用也因此调用了Window::onResize。但恐怕你没想到,它调用的并不是当前对象上的函数,而是稍早转型动作所建立的一个“*this对象之base class成分”的暂时副本身上的onResize!重要的事情再说一次:上述代码并非在当前对象身上调用Window::onResize之后又在该对象身上执行SpecialWindow专属动作。不!它是在“当前对象之base class成分”的副本上调用Window::onResize,然后在当前对象身上执行SpecialWindow专属动作。如果Window::onResize修改了对象内容,当前对象其实没被改动,改动的是副本。然而SpecialWindow::onResize内如果也修改对象,当前对象真的会被改动。这使得当前对象进入一种“伤残”状态:其base class成分的更改没有落实,而derived class成分的更改倒是落实了

    解决方案:去掉转型,直接调用base class版本的onResize函数,令它作用于当前对象身上

    class SpecialWindow:public Window 
    {
        public:
            virtual void onResize() {
                Window::onResize();       // 调用Window::onResize作用于*this身上
                .......... // 这里进行SpecialWindow专属行为
        }
        .....
    };

      情况3:除了对一般转型保持机敏于猜疑,更应该在注重效率的代码中对dynamic_cast保持机敏于猜疑。

      之所以需要dynamic_cast,通常是因为你想在一个你认定derived class对象身上执行derived class操作函数,但你手上却只有一个“指向base”的pointer或reference,你只能靠它们来处理对象。可以采用以下两个做法避免这个问题:

      1. 使用容器并在其中存储直接指向derived class对象的指针(通常是智能指针,条款13),如此便消除了“通过base class接口处理对象”的需要。如下代码:

    //试着不要采用以下形式
    class Window { ... };
    class SpecialWindow:public Window
    {
        public:
            void blink();
            ....
    };
    typedef std::vector<std::tr1::shared_ptr<Window>> VPW;    //条款13
    VPW winPtrs;
    ......
    for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end; ++iter)      //需要使用到dynamic_cast
    {
        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(); iter != winPtrs.end; ++iter)       //不使用dynamic_cast
    {
        (*iter)->blink();
    }

      上述代码,会使你无法在同一个容器内存储指针“指向所有可能之各种Window派生类”。如果真的需要多种派生类型,你可能需要多个容器

      2. 另一种做法可让你通过base class接口处理“所有可能之各种Window派生类”,那就是在base class 内提供virtual函数做你想对各个Window派生类做的事。

    class Window 
    {
        public:
            virtual void blink() {}    //条款34告诉你为什么缺省实现代码可能是个馊主意
            ...
    };
    class SpecialWindow:public Window
    {
        public:
            virtual void blink() {  ....  };
            ....
    };
    typedef std::vector<std::tr1::shared_ptr<Window>> VPW;    //条款13
    VPW winPtrs;
    ......
    for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end; ++iter)      //需要使用到dynamic_cast
        (*iter)->blink();      //这里没有dynamic_cast

      不论哪一种写法——“使用类型安全容器”或“将virtual函数往继承体系上方移动”——都并非放之四海皆准,但在许多情况下它们都提供了一个可行的dynamic_cast替代方案。

      绝对必须避免的一件事是所谓的“连串dynamic_cast”,应该以某些“基于virtual函数调用”的东西取而代之。“连串dynamic_cast”也就是看起来像这样的东西:

    class Window {    ... };
    ......
    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())) { ... }
        .....
    }

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

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

      3. 宁可使用C++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。

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

      考虑如下代码:

    class Point   // 表示 点  
    {
        public:
            point(int x, int y);
            ...
            void setX(int newVal);
            void setY(int newVal);
            ...
    };
    struct RectData      // 表示 矩阵
    {
        Point ulhc;     // 左上角
        Point lrhc;     // 右下角
    };
    class Rectangle
    {
        public:
            ...
            Point& upperLeft() const { return pData->ulhc; }   // 错误
            Point& lowerRight() const { return pData->lrhc; }
            ...
        private:
            std::tr1::shared_ptr<RectData> pData;     //条款13
    };
    // 考虑以下调用
    Point coord1(0, 0);
    Point coord2(100, 100);
    const Rectangle rec(coord1, coord2);
    // 下面调用,rec左上角从(0,0)变成(50,0)
    rec.upperLeft().setX(50);     

      问题1:上面upperLeft和lowerRight函数自相矛盾:一方面,函数被声明为const成员函数,目的为不让客户修改Rectangle;另一方面,函数却返回reference指向private内部数据,调用者可通过这些references更改内部数据。(成员变量的封装性最多只等于“返回其reference”的函数的访问级别,返回指针或迭代器,情况相同)。

      reference、指针和迭代器统统都是所谓的handles(号码牌,用来取得某个对象),而返回一个“代表对象内部数据”的handle,随之而来的便是“降低对象封装性”的风险。同时,一如稍早所见,它也可能导致“虽然调用const成员函数却造成对象状态被更改”。任何调用者返回一个指针(reference/迭代器)指向一个“访问级别较低”的内部成员,都会导致“降低对象封装性”的风险。

      可对上面upperLeft和lowerRight函数进行修改:对它们的返回类型加上const,使之符合要求:

    class Rectangle
    {
        public:
            ...
            const Point& upperLeft() const { return pData->ulhc; }   
            const Point& lowerRight() const { return pData->lrhc; }
            ...
        private:
            std::tr1::shared_ptr<RectData> pData;     //条款13
    };

      问题2:返回“代表对象内部”的handles有可能导致dangling handles(空悬的号码牌):handles所指东西(的所属对象)不复存在。关键在于:一旦有个handle被传出去,你就要承担“handle比其所指对象寿命更长”的风险。(对象只有语句内的生命周期;或者对象生命周期在语句块,离开语句块对象将被销毁;对诸如此情况的对象返回reference,等同于返回 局部对象的reference,将导致空悬指针。)如下:

    class GUIObject { ... };
    const Rectangle   // 注意,这里以by value方式返回一个矩形
            boundingBox(const GUIObject& obj);
    // 考虑以下调用
    GUIObject* pgo;
    .....
    const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());  // (1)

      上面代码显示,由于boundingBox以by value方式返回一个矩形,对boundingBox的调用将获得一个新的、暂时的Rectangle对象,暂且叫这个对象temp。随后upperLeft作用于temp身上,并返回一个reference指向temp的一个内部成分。接下来,当语句(1)结束之后,boundingBox的返回值temp将被销毁,而那间接导致temp内的Points析构。最终,pUpperLeft指向一个不再存在的对象。

    故而:

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

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

      考虑下面代码:

    class PrettyMenu
    {
        public:
            ....
            void changeBackground(std::istream& imgSrc);
            ....
        private:
            Metex mutex;
            Image* bgImage;
            int imageChanges;
    };
    //changeBackground的一个可能实现
    void PrettyMenu::changeBackground(std::istream& imgSrc)
    {
        lock(&mutex);     // 互斥器
        delete bgImage;    //
        ++imageChanges;
        bgImage = new Image(imgSrc);
        unlock(&mutex);
    }

    上面这个函数实现很糟,“异常安全”有两个条件,而这个函数没有满足其中任何一个:

      1. 不泄漏任何资源。上述代码,一旦“new Image(imgSrc)”导致异常,对unlock的调用就绝不会执行,互斥器永远被锁住。

      2. 不允许数据败坏。如果“new Image(imgSrc)”抛出异常,bgImage将指向一个已被删除的对象,imageChanges已被累加,而并没有新的图像被成功创建。

    解决方案:

      1. 首先,资源泄漏问题很容易解决,条款13讨论如何“以对象管理资源”,条款14也导入Lock class作为一种“确保互斥器被及时释放”的方法

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

    有一个一般性规则:较少代码就是较好的代码,因为出错机会比较少,而且一旦有所改变,被误解的机会也比较少。

      2. 数据败坏问题。首先来看一下异常安全函数提供的三个保证:

      (1)基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态。

      (2)强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到”调用函数之前“的状态。

      (3)不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总能完全原先承诺的功能。作用于内置类型身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。

      一般而言,我们很难在C part of C++领域中完全没有调用任何一个可能抛出异常的函数。任何使用动态内存的东西(例如所有STL容器)如果无法找到足够内存以满足需求,通常便会抛出一个bad_alloc异常(条款49)。通常,我们提供的异常保证往往落在基本保证和强烈保证两者身上。

    所以,可将changeBackground作如下修改(method1. 在PrettyMenu类中对图像数据直接进行引用):

    class PrettyMenu
    {
        ....
        std::tr1::shared_ptr<Image> bgImage;
        ....
    };
    
    void PrettyMenu::changeBackground(std::istream& imgSrc)
    {
        Lock ml(&mutex); 
        bgImage.reset(new Image(imgSrc));  
        ++imageChanges;  // 计数累加置于new之后,应用策略:不要为了表示某件事情发生而改变对象状态,除非那件事情真的发生了
    }

      std::tr1::shared_ptr::reset函数只有在其参数(也就是"new Image(imgSrc)"的执行结果)被成功生成之后才会被调用。delete只在reset函数内被使用,所以如果从未进入那个函数也就绝对不会使用delete。由于Image构造函数仍可能抛出异常,所以changeBackground在解决这个问题之前只提供了基本的异常安全保证。

      有个一般化的设计策略很典型地会导致”强烈保证“(这里假设Image构造函数不抛出异常),这个策略被称为copy and swap。原则很简单:为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。

      实现上通常是将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object,即副本)。这种手法常被称为pimpl idiom,条款31详细描述了它。对PrettyMenu而言,可作如下修改:(method2. 将所有图像数据抽离到PMImpl结构体中,然后在PrettyMenu类中对结构体PMImpl进行引用,也即对所有图像数据进行整体操作。)

    struct PMImpl {    // PrettyMenu的数据封装性已经由于"pImpl是private"而获得保证,所以可以令PMImpl为一个struct
    {
        std::tr1::shared_ptr<Image> bgImage;   // 这里需要智能指针,是为了保证bgImage可以使用到智能指针的reset功能
        int imageChanges;
    };
    class PrettyMenu 
    {
        ...
        private:
            Mutex mutex;
            std::tr1::shared_ptr<PMImpl> pImpl;   //这里使用智能指针,是为了便于对结构体进行管理
    };
    void PrettyMenu::changeBackground(std::istream& imgSrc)
    {
        using std::swap;      //条款25,当缺省swap不满足我们的效率要求,实现我们自己的swap
        Lock ml(&mutex);
        std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
        pNew->bgImage.reset(new Image(imgSrc));        // 修改副本
        ++pNew->imageChanges;
    
        swap(pImpl, pNew);     // 置换,只要置换PMImpl指针则可完成对象置换,高效
    }

    虽然“copy-and-swap”策略是对对象状态做出的“全有或全无”改变的一个很好办法,但其却不保证整个函数有强烈的异常安全性。如下:

    void someFunc()
    {
            ...         // 对local状态做一份副本
            f1();
            f2();
            ...         // 将修改后的状态置换过来
    }

    很显然,如果f1或f2的异常安全性比“强烈保证”低,就很难让someFunc成为“强烈异常安全”。

    小结:

      提供函数异常安全性,通常需要作如下考虑:以对象管理资源(条款13),那可阻止资源泄漏;挑选三个“异常安全保证”中的某一个实施于你所写的每一个函数身上。

    故而:

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

      2. “强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备现实意义(效率和复杂程度带来的成本)。

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

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

      inline函数,比宏好得多(条款02),“免除函数调用成本”;编译器最优化机制能对它(函数本体)执行语境相关最优化。然而,过多热衷inlining会增加目标码大小,造成程序体积太大(对可用空间而言),这进而会导致换页行为,降低指令高速缓存装置的击中率,以及伴随而来的效率损失。

      切记,inline只是对编译器的一个申请(编译器不一定批准,详见下面“备注1”),不是强制命令。这项申请可用隐喻提出,也可以明确提出。隐喻方式是将函数定义于class定义式内,明确声明inline函数的做法则是在其定义式前加上关键字inline:

    class Person
    {
        public:
            ....
            int age() cosnt { return theAge; }      // 一个隐喻的inline申请
            ...
        private:
            int theAge;
    };
    // 明确声明inline函数的做法则是在其定义式前加上关键字inline。
    template<typename T>
    inline const T& std::max(const T& a, const T& b)     // 明确申请inline
    { return a < b ? b : a; }  

    注意,inline函数和template两者通常都被定义于头文件内。当各有不同:

      1. Inline函数通常一定被置于头文件,因为大多数建置环境在编译过程中进行inlining(少数可以在连接期和运行期),而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样。Inlining在大多数C++程序中是编译器行为

      2. template通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样。(少数建置环境可以在连接期才执行template具现化。)

      Template的具现化于inlining无关。如果你正在写一个template而你认为所有根据此template具现出来的函数都应该inlined,请将此template声明为inline;否则,就应该避免将这个template声明为inline(inlining需要成本)。

    备注1,编译器拒绝(1,2,3)或不应使用inlining(4)的情况:

      1. 大部分编译器拒绝将太过复杂(例如带有循环或递归)的函数inlining;

      2. 所有对virtual函数的调用(除非是最平淡无奇的)也都会使inlining落空。因为virtual意味“等待,直到运行期才确定调用哪个函数”,而inline意味“执行前,先将调用动作替换为被调用函数的本体”。如果编译器不知道该调用哪个函数,则拒绝将函数本体inlining。

      3. 有时候虽然编译器有意愿inlining某个函数,还是可能为该函数生成一个函数本体。例如,如果程序要取某个inline函数的地址,编译器通常必须为此函数生成一个outlined函数本体。毕竟编译器哪有能力提出一个指针指向并不存在的函数呢?于此并提的是,编译器通常不对“通过函数指针而进行的调用”实施inlining,这意味对inline函数的调用有可能被inlined,也可能不被inlined,取决于该调用的实施方式:

    inline void func() { ... }     // 假设编译器有意愿inline“对func的调用”
    void (*pf) () = func;    // pf 指向func
    ...
    func();       // 这个调用将被inlined,正常调用
    pf();         //这个调用或许不被inlined,因为它通过函数指针达成

      4. 构造函数和析构函数往往是inlining的糟糕候选人。考虑如下代码:

    class Base
    {
        public:
            ...
        private:
            std::string bm1, bm2;      // base 成员1, 成员2
    };
    class Derived : public Base
    {
        public:
            Derived() {}         // Derived构造函数是空的,哦,是吗??
            ...
        private:
            std::string dm1, dm2, dm3;       //derived成员1-3
    };

      C++对于“对象被创建和被销毁时发生什么事”做了各种各样的保证。包括构造、析构,copying函数和异常,等等。在这些情况中C++描述了什么一定会发生,但没有说如何发生。“事情如何发生”是编译器实现者的权责,不过至少有一点很清楚,那就是它们不可能凭空发生。你的程序内一定有某些代码让那些事情发生,而那些代码——由编译器于编译期间代为产生并安插到你的程序中的代码——肯定存在于某个地方。有时就放在你的构造函数和析构函数内,所以,上面Derived构造函数所产生代码,相当于如下:

    Derived::Derived()
    {
        Base::Base();         // 初始化“Base成分”
        tyr {   //试图构造dm1.如果抛出异常就销毁base class成分,并传播该异常
                dm1.std::string::string(); 
            }
        catch( ... ) {
            Base::~Base();
            throw;
        }
    
        tyr {   //试图构造dm2.如果抛出异常就销毁dm1, base class成分,并传播该异常
                dm2.std::string::string(); 
            }
        catch( ... ) {
            dm1.std::string::~string(); 
            Base::~Base();
            throw;
        }
        tyr {   //试图构造dm3.如果抛出异常就销毁dm1,dm2,base class成分,并传播该异常
                dm3.std::string::string(); 
            }
        catch( ... ) {
            dm1.std::string::~string(); 
            dm2.std::string::~string(); 
            Base::~Base();
            throw;
        }
    }

       这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更精致复杂的做法来处理异常。相同理由也适用于Base构造函数。

      程序库设计者必须评估“将函数声明为inline”的影响:inline函数无法随着程序库的升级而升级。换句话说,如果f是程序库内的一个inline函数,客户将“f函数本体”编译进其程序中,一旦程序库设计者决定改变f,所有用到f(inline函数)的客户端程序都必须重新编译。而如果f是non-inline函数,一旦它有任何修改,客户端只需重新连接就好,远比重新编译的负担少很多。如果程序库采取动态连接,升级版函数甚至可以不知不觉地被应用程序吸纳。

    故而:

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

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

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

      假设你对C++程序的某个class实现文件做了些轻微修改。注意,修改的不是class接口,而是实现,而且只改private成分。而当你编译的时候,却编译了整个工程,这绝对不可被接受。问题出在C++并没有把“将接口从实现中分离”这事做得很好。Class的定义式不只详细叙述了class接口,还包括十足的实现细目。如:

    // 如果没有加入一下头文件,Person无法通过编译
    // 因为编译器没有取得其实现代码所用到的classes string, Date 和 Address的定义式
    #include <string>
    #include "date.h"
    #include "address.h"
    //
    class Person
    {
        public:
            Person(const std::string& name, const Date& birthDate, const Address& addr);
            std::string name() const;
            std::string birthDate() const;
            std::string address() const;
            ....
        private:
            std::string theName;      //实现细目
            Date thBirthDate;
            Address theAddress;
    };

      #include头文件,这么一来却在Person定义文件和其含入文件之间形成了一种编译依存关系。如果这些头文件中有任何一个被改变,或这些头文件所倚赖的其他头文件有任何改变,那么每一个含入Person class的文件就得重新编译,任何使用Person class的文件也必须重新编译。(其实,现实中有很多的工程项目即是如此,改动头文件便会导致所有关联的文件重新编译,所以在实际中尽量不去改动到头文件。)

      可以尝试将实现细目从class定义式中抽离出来,如下代码(下面代码有问题):

    namespace std {
        // string不是个class,它是个typedef(定义式为basic_string<char>)
        // 下面针对string的前置声明并不正确,正确的前置声明比较复杂
        //然而,你本来也就不该尝试手工声明一部分标准程序库,而是采用#include完成。
        class string;       
    }
    class Date;
    class Address;
    class Person
    {
        public:
            Person(const std::string& name, const Date& birthDate, const Address& addr);
            std::string name() const;
            std::string birthDate() const;
            std::string address() const;
            ....
    };
    
    //针对上面代码,下面的调用却有问题
    int main()
    {
        int x;     //定义一个int
        Person p(params);   //定义一个Person
    }

      当编译器看到x的定义式,它知道必须分配多少内存(栈区)。但当看到p的定义式,它也知道必须分配足够空间以放置一个Person,然而,由于Person不含实现细目,编译器无法得知一个Person需要多少空间。此问题在Java等语言上并不存在,因为当我们以那种语言定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用。也就是说它们(Java)将上述代码视为:

    int main()
    {
        int x;     //定义一个int
        Person *p(params);   //定义一个指针指向Person对象
        ...
    }

    解决方案1:制造Handle class:“将对象实现细目隐藏于一个指针背后”(将对象实现细目抽离到另一个类里面,在原来类中保留一个指向实现细目所在类的指针,也即pimpl)。针对Person:把Person分割为两个classes,一个只提供接口,另一个负责实现该接口:

    #include <string>      // 标准程序库组件不该被前置声明
    #include <memory>    // 为了tr1::shared_ptr而含入
    
    class PersonImpl;          // Person实现类的前置声明
    class Date;
    class Address;
    
    class Person
    {
        public:
            Person(const std::string& name, const Date& birthDate, const Address& addr);
            std::string name() const;
            std::string birthDate() const;
            std::string address() const;
            ....
        private:
            std::tr1::shared_ptr<PersonImpl> pImpl;     //智能指针(条款13),指向实现物
    };

      这里,Person只内含一个指针成员,指向其实现类(PersonImpl)。这般设计常被称为pimpl idiom(pimpl 是 “pointer to implementation”的缩写)。这种classes内的指针名称往往就是pImpl,如上。
      这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。下面三点都源于对上述策略的运用

      1. 如果使用object references 或 object pointers 可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的references 和 pointers;但如果定义某类型的objects, 就需要用到该类型的定义式。

      2. 如果能够,尽量以class声明式替换class定义式。注意,当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以by value方式传递该类型的参数(或返回值)亦然:

    class Date;      // class 声明式
    Date today();   // 没问题
    void clearAppointments(Date d);    // 以by value传递参数(条款20),Date的定义式

      声明today函数和clearAppointments函数而无需定义Date。但是,一旦任何人调用拿些函数,调用之前Date定义式一定得先曝光。

      3. 为声明式和定义式提供不同的头文件。两个头文件,一个用于声明式,一个用于定义式,当然,这些文件必须保持一致性。故而,上面代码可修改为:

    #include "datefwd.h"    //这个头文件内声明(但未定义)class Date
    Date today();  
    void clearAppointments(Date d);   

    备注只含声明式的那个头文件名为 "datefwd.h",命名方式取法C++标准程序库头文件(条款54)的<iosfwd>。<iosfwd>内含iostream各组件的声明式,其对应定义则分布在若干不同的头文件内,包括<sstream>,<streambuf>,<fstream>和<iostream>。

      <iosfwd>具有启发意义的另一个原因是,它分外彰显"本条款适用于templates也适用于non-templates"。虽然条款30说过,在许多建置环境中template定义式通常被置于头文件内,但也有某些建置环境允许template定义式放在“非头文件”内,这么一来就可以将“只含声明式”的头文件提供给templates。<iosfwd>就是这样一份头文件。

      像上面Person这样使用pimpl idiom的classes,往往被称为Handle classes。也许你会纳闷,这样的class如何真正做点事情。办法之一是将它们的所有函数转交给相应的实现类(implementation classes)并由后者完成实际工作。如下:

    #include "Person.h"   //我们正在实现Person class,所以必须#include其定义式
    #include "PersonImpl.h"   // 我们也必须#include PersonImpl的class定义式,否则无法调用其成员
                                            // 注意:PersonImpl有着和Person 完全相同的成员函数,两者接口完全相同。
    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();
        }

      请注意:Person构造函数以new(条款16)调用PersonImpl构造函数,以及Person::name函数内调用PersonImpl::name。这很重要,让Person变成一个Handle class并不会改变它做的事,只会改变它做事的方法。

    解决方案2令Person成为一种特殊的abstract base class(抽象基类),称为Interface class(将原来类里面的接口抽离到另一个类(抽象基类)里面,包含实现细目的类继承此抽象基类,同时实现其抽象接口.注意,抽象基类里面的接口为static并且virtual,迫使派生类必须自己重新实现这个继承而来的接口.).这种class的目的是详细一一描述derived classes的接口(条款34),因此它通常不带成员变量,也没有构造函数,只有一个virtual析构函数(条款7)以及一组pure virtual函数,用来叙述整个接口。

      Interface class类似Java和.NET的Interfaces, Java和.NET都不允许在Interfaces内实现成员变量或成员函数,但C++不禁止这两样东西。(条款36)“non-virtual函数的实现”对继承体系内所有classes都应该相同(这也是函数之所以声明为non-virtual的原因,目的就是使整个类继承体系都拥有相同的函数),所以将此等函数实现为Interface class(其中写有相应声明)的一部分也是合理的。所以,Interface class可以如下实现:

    class Person
    {
        public:
            .....
            virtual ~Person();
            virtual std::string name() const = 0;
            virtual std::string birthDate() const = 0;
            virtual std::string address() const = 0;
            ..... 
            static std::tr1::shared_ptr<Person>     // 返回一个tr1::shared_ptr,指向一个新的Person,并以给定参数初始化
                create(const std::string& name,        // 条款18 告诉你为什么返回tr1::shared_ptr
                          const Date& birthday,
                          const Address& addr);
            .....
    };
    
    // 如下使用
    std::string name;
    Date dateOfBirth;
    Address address;
    ...
    //创建一个对象,支持Person接口
    std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
    ...
    std::cout << pp->name()         // 提供Person的接口使用这个对象
                  << " was born on " 
                  << pp->birthDate() 
                  << " and now lives at " 
                  << pp->address();
    ....              // 当pp离开作用域,对象会被自动删除,条款13

    当然,支持Interface class接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。一切都在virtual构造函数实现码所在的文件内秘密发生,如下:

    class RealPerson:public Person
    {
        public:
            RealPerson(const std::string& name, const Date& birthday, 
                            const Address& addr)
            :theName(name), theBirthDate(birthday), theAddress(addr)
            {}
            virtual ~RealPerson() {}
            std::string name() const;
            std::string birthDate() const;
            std::string address() const;
        private:
            std::string theName;
            Date theBirthDate;
            Address theAddress;
    };
    // 有了RealPerson之后,就可以写出Person::create的实现了
    std::tr1::shared_ptr<Person> Person::create(const std::string& name, 
                                                                        const Date& birthday, 
                                                                        const Address& addr)
    {
        return 
            std::tr1::shared_ptr<Person> (new RealPerson(name, birthday, addr));  // new 不同的派生类对象,但都用抽象类指向它
    }

    RealPerson示范实现Interface class的两个最常见机制之一:从Interface class(Person)继承接口规格,然后实现出接口所覆盖的函数。Interface class的第二个实现法涉及多重继承(条款40)。

    注意:Handle classes 和 Interface classes,需要付出的成本:间接访问,额外内存开销,vptr内存消耗,virtual函数调用的间接跳跃等等。

    故而:

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

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

      

  • 相关阅读:
    电路原理图分析
    GPIO学习——用户空间操作
    在Android上运行Java和C程序
    Android命令行工具学习总结
    Android蓝牙学习笔记
    33 把数组排成最小的数
    233 Number of Digit One
    32 从1到n整数中1出现的次数
    31 连续子数组的最大和
    《大型网站技术架构》学习笔记
  • 原文地址:https://www.cnblogs.com/yyxt/p/4805646.html
Copyright © 2020-2023  润新知