zoukankan      html  css  js  c++  java
  • Effective C++(20) 继承与面向对象设计

    本文主要参考《Effective C++ 3rd》中的第六章部分章节的内容。

    关注的问题集中在继承、派生、virtual函数等。如:

    • virtual? non-virtual? pure virtual?
    • 缺省参数值与virtual函数有什么交互影响?
    • 继承如何影响C++的名称查找规则?
    • 什么情况下有比virtual更好的选择?

    这些都是我们将要从这一章里学到的内容。


    1 确定你的public继承可以塑模出is-a关系

    谨记public继承的含义:

        如果class D以public形式继承class B,则每一个类型D的对象同时也是一个类型B的对象,反之不成立。

        即,B比D表现出更一般化的概念,而D比B表现出更特殊的概念。

    如:

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

    这个体系告诉我们:每个学生都是人,但并非每个人都是学生。

    从C++的角度来看,任何函数,如果期望获得一个类型为Person(或指向Person对象的指针或引用),也都愿意接受一个Student对象(或指针或引用)。

    需要留意的一点是:

        以我们在生活中的直觉为基础来塑模is-a关系有时是错误的,可以说犯了“经验主义错误”。

    如:

        class Square应该以public形式继承class Rectangle吗?

        即正方形是一个(is-a)矩形吗?

    至少我们在学校里是这么学到的:正方形是一个矩形,但是矩形不一定是正方形。

    那么我们来写一些这个继承

    class Rectangle {
    
    public:
    
        virtual void setHeight(int newHeight);
    
        virtual void setWidth(int newWidth);
    
        virtual int height() const;
    
        virtual int width() const;
    
        ......
    
    };
    
    void makeBigger (Rectangle& r) {
    
        int oldHeght = r.height();
    
        r.setWidth(r.width() + 10);
    
        assert( r.heght() == oldHeght );    // 判断r的高度是否改变,永为真。
    
    }

    在这个矩形的基础上派生出一个正方形

    class Square : public Rectangle { ... };
    
    Square s;
    ...
    assert( s.width() == s.height() );
    makeBigger(s);
    assert( s.widht() == s.height() );

    显然makeBigger只改变矩形的宽度,而不改变矩形的长度。这和s是个正方形矛盾。

    public所包含的含义为:能够使用在base class对象身上的每件事,应该同样可以使用在derived class对象身上。

    由此可见,其他领域或者生活中,我们习得的直觉,在软件领域并不总是正确的。

    因此,除了is-a关系,我们还要更多地思考和在适当的场合使用has-a和is-implemented-in-terms-of(根据某物实现出)

    小结:

    “public继承”意味着is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,每一个derived class对象也都是一个base class对象。


     

    2 避免遮掩继承而来的名称

    关键字:作用域。

    先看一个简单的例子:

    int x;
    
    void someFunc() {
        double x;
        std::cin >> x;
    }

    这个读取数据的语句使用的是局部变量x,而不是全局变量x。因为内层作用域的名称会遮掩外围作用域的名称。

    加入继承机制,有如下的代码:

    class Base {
    
    private:
    
        int x;
    
    public:
    
        virtual void mf1() = 0;
    
        virtual void mf2() ;
    
        void mf3();
    
        ....
    
    };
    
    class Derived : public Base {
    public:
    
        virtual void mf1();
    
        void mf4();
        ....
    };

    mf4函数中有如下实现:

    void Derived::mf4() {
        ...
        mf2();
        ...
    }

    编译器的查找作用域顺序:

    local作用域--->class Derived覆盖的作用域

    ---> class覆盖的作用域(本例到这停止)

    ---> Base的那个namespace作用域

    ---> global作用域。

    现在来为上面的两个类加几个成员函数:

    class Base {
    
    private:
    
        int x;
    
    pubic:
    
        virtual void mf1() = 0;
    
        virtual void mf1( int );
    
        virtual void mf2() ;
    
        void mf3();
    
        viod mf3( double );
    
        ....
    
    };
    
    class Derived : public Base {
    
    public:
    
        virtual void mf1();
    
        void mf3();
    
        void mf4();
    
        ....
    
    };

    这样做会有什么效果呢?

    Derived d;
    
    int x;
    
    ......
    
    d.mf1();
    d.mf1(x);    //error
    d.mf2();
    d.mf3();
    d.mf3(x);    //error

    由此可见,基于作用的名称遮掩规则,并没有因为重载函数而特殊处理,那些名字相同的重载函数同样被遮掩掉了。

    如果我们想在子类中继承那些重载函数,并重写其中的一部分(像本例中的mf1和mf3),那么可以使用using语句

    让Base class内名为mf1和mf3的所有东西(所有重载函数)在Derived作用域内都是可见的。

    class Base {
    
    private:
    
        int x;
    
    public:
    
        Base() {};
    
        virtual void mf1() = 0;
    
        virtual void mf1( int m ) { std::cout << "Base mf1 int: "<< m << std::endl; } ;
    
        virtual void mf2() { std::cout << "Base mf2 " << std::endl; };
    
        void mf3() { std::cout << "Base mf3" << std::endl;};
    
        void mf3( double m ) { std::cout << "Base mf3 double:" << m << std::endl; };
    
    };
    
    class Derived : public Base {
    
    public:
    
        using Base::mf1;        // 让Base class内名为mf1和mf3的所有东西(所有重载函数)
    
        using Base::mf3;        // 在Derived作用域内都是可见的。
    
        virtual void mf1() { std::cout << "Derived mf1" << std::endl; };
    
        void mf3() { std::cout << "Derived mf3" << std::endl; };
    
        void mf4() { std::cout << "Derived mf4" << std::endl; };
    
    };

    调用:

    Derived* d = new Derived();
    
    d->mf1();
    d->mf1(1);
    d->mf2();
    d->mf3();
    d->mf3(1);
    d->mf4();

    运行截图:

    clipboard

    上面这种技术告诉我们如何继承所有重载函数,实现is-a的关系。

    有时候我们并不想继承base classes的所有函数,而是用public继承和名字遮掩规则又不符合public继承所包含的is-a关系。

    因此,这里介绍一种转交函数技术,很简单,看一个例子就懂了。

    class Base {
    public:
        virtual void mf1() = 0;
        virtual void mf1(int );
        ....
    };
    
    class Derived : private Base {
    
    public:
    
        virtual void mf1() { Base::mf1(); }   // 转交函数
        ......
    };

    小结:

    derived classes内的名称会遮掩base classes内的所有相同名称的重载函数,在public继承下这个机制并不希望发挥作用。

    可使用using声明式或转交函数来调用被遮掩的重载函数。


     

    3 区分接口继承和实现继承

    选择继承的集中情况:

    • a:希望derived classes只继承成员函数的接口
    • b:希望derived classes同时继承函数的接口和实现,又希望能够重写它们所继承的实现
    • c:希望derived classes同时继承函数的藉口和实现,并且不允许重写任何东西

    看一个几何图形例子:

    class Shape {
    
    public:
    
        virtual void draw() const = 0;
    
        virtual void error(const std::string& msg);
    
        int objectID() const;
    
    };
    
    class Rectangle: public Shape { ...... };
    
    class Ellipse: public Shape { ...... };

    首先考虑纯虚函数draw

    pure virtual函数有两个最突出的特征:

    • 它们必须被任何“继承了它们”的子类重新声明
    • 它们在抽象class中通常没有定义

    综合上面两个特征:声明一个纯虚函数的目的是为了让derived class只继承函数接口

    满足了本节开头的情景a。

    考虑虚函数error。

    虚函数的目的是让derived classes继承该函数的接口和缺省实现。满足了情景b。

    最后,考虑non-virtual函数objectID。

    声明non-virtual函数的目的是为了令derived classes继承函数的接口及一份强制性实现。

    对应了情景c。

    纯虚函数、虚函数和非虚函数使得你可以精确地指定你想要derived classes继承的东西。

    小结:

    接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口

    pure virtual函数只具体指定接口继承。

    virtual 函数具体指定接口继承及缺省实现继承。

    non-virtual函数具体指定接口继承以及强制性实现继承。


     

    4 考虑virtual函数之外的选择

    考虑为游戏内的人物设计一个继承体系。

    class GameCharacter {
    public:
        virtual int healthValue() const;    // 返回人物的健康指数。
        ......
    };

    有时候,常规的面向对象设计方法往往看起来是那么的自然,以至于我们从未考虑其他的一些解法。

    这一节就让我们跳出常规设计的思维,考虑一些不那么常规的设计方法。

    方法1:借由non-virtual interface手法实现Template Method模式

    class GameCharacter {
    
    public:
    
        int healthValue() const  {
            ...
            int retVal = doHealthValue();
            ...
            return retVal;
        }
    
        ....
    
    private:
        virtual int doHealthValue() const {
            ...
        }
    
    };

    让客户通过public non-virtual成员函数间接调用private virtual函数,称为non-virtual interface(NVI)手法。

    这个non-virtual函数(healthValue)称为virtual函数的包装器(wrapper)。

    从程序执行的角度来看,derived classes重新定义了virtual函数,从而赋予它们“如何实现功能”的控制能力,base classes保留控制“函数何时被调用”的权利。

    方法2:借由Function Pointer实现Strategy模式

    代码如下:

    class GameCharacter:;
    
    int defaultHealthCalc(const GameCharacter& gc);
    
    class GameCharacter {
    
    public:
    
        typedef int ( *HealthCalcFunc ) ( const GameCharacter& );
    
        explicit GameCharacter( HealthCalcFunc hcf = defaultHealthCalc ) : healthFunc(hcf) { }
    
        int healthValue() const {
            return healthFunc(*this);
        }
    
        ...
    
    private:
        HealthCalcFunc healthFunc;
    };

    还有其他的一些方法,在此并不一一讨论,详见《Effective C++》

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

    在子类中重定义继承而来的non-virtual函数,会导致子类你的设计出现矛盾。

    比如在class Base有一个non-virtual函数setBigger,而所有继承Base的子类都可以执行变大的动作,那么这个动作就是一个不变性(共性)。

    而在class Derived : public Base子类中,重写了setBigger函数,那么class Derived便无法反映出“不变性凌驾于特性”的性质。

    从另一方面说,如果setBigger操作真的需要在子类中重定义,那么就不应该把它设定为一个共性(non-virtual)。

    因此,重新定义继承来的non-virtual函数可能并不会对你的程序的运行造成太大的困扰,但是正如上面提到的,这是设计上的矛盾,或者说缺陷。

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

    本小节的讨论局限于“继承一个带有缺省参数值的virtual函数”。

    理由:virtual函数动态绑定,缺省参数值静态绑定。

    class Shape {
    
    public:
        Shape() {};
    
        enum ShapeColor { Red = "red", Green = "green" , Blue = "blue"};
    
        virtual void draw(ShapeColor color = Red) const = 0
        {
            std::cout << "This shape is " << color << std::endl;
        }
    };
    
    class Rectangle : public Shape {
    public:
        Rectangle() {};
        virtual void draw ( ShapeColor color = Green ) const;
    };
    
    class Circle : public Shape {
    public:
        virtual void draw(ShapeColor color) const;
    };

    先考虑如下指针

    Shape* ps;
    Shape* pc = new Circle;
    Shape* pr = new Rectangle;

    ps、pc、pr的静态类型都是Shape*

    所谓动态类型就是“目前所指对象的类型”。也就是说动态类型可以表现出一个对象将会有什么行为。

    在本例中,ps没有动态类型,pc的动态类型为Circle*,pr的动态类型为Rectangle*。

    动态类型可以在程序执行过程中改变(通常是经由赋值动作)。如

    ps = pc;    // ps的动态类型现在是Circle
    ps = pr;    // ps的动态类型现在是Rectangle

    上面是对动态绑定和静态绑定的简单复习。

    现在,考虑带有缺省参数值的virtual函数。

    在上面的例子中,Shape中的draw函数的color默认参数是Red,而子类中的draw函数的color默认参数是Green。

    Shape* shape = new Rectangle();
    shape->draw();

    根据动态绑定规则,上述代码的输出应该为:This shape is 1

    但是运行代码之后会发现,结果并不是我们想的那样。

    clipboard[1]

    我们来分析一下导致这种结果的原因:

    shape的动态类型为Rectangle*,调用draw时,根据动态绑定,调用的应该为Rectangle的版本

    Rectangle版本的draw的默认参数应该为Green(1)

    而结果是Red(0)

    为了更清楚的看一下究竟调用的是哪一个draw,我们多加一点打印信息。

    clipboard[2]

    ok,结果很清楚了,函数调用的版本是Rectangle*,但是默认参数调用的是Shape*中定义的。

    所以结论就是:这个函数调用由class Shape和class Rectangle class的draw声明式各出一半力。

    那么C++为什么有这么奇怪的设定呢?简单的说,就是:性能。运行期对参数动态绑定缺省值很慢很复杂,所以考虑到性能问题,并没有支持默认参数的动态绑定。

    小结:

    所以正如本节的标题,禁止重载一个继承而来的默认参数值,因为缺省参数值都是静态绑定,而使用这些默认参数的virtual函数却是动态绑定。


    参考资料:

    《Effective C++ 3rd》

  • 相关阅读:
    SAP HANA概述——SAP HANA学习笔记系列
    如何在Visual Studio 2010中使用CppUTest建立TDD的Code Kata的环境
    客户旅程分析 Customer Journey Mapping
    “用户故事”来做展会——敏捷嘉年华(敏捷之旅2012上海站)经验分享
    相机翻拍书本
    外网访问路由
    新编全医药学大词典下载地址
    MKV声道切换花屏处理
    几何画板动态表达式(文本与变量合并)
    修改IP地址
  • 原文地址:https://www.cnblogs.com/suzhou/p/effectivecpp20.html
Copyright © 2011-2022 走看看