12 虚函数与多态

12 虚函数与多态

上一节就说过, 当我们搭配虚函数的时候, 继承才会发挥最强有力的效果

Inheritance (继承) with virtual functions (虚函数)

在语法上, 我们只要在任何一个成员函数之前加上 virtual 关键字, 它就成为了一个虚函数

在继承关系里面, 所有的东西都可以继承下来. 数据的继承从内存角度来理解很简单, 就是占用了内存的一部分; 函数的继承不能从内存角度来理解, 函数的继承, 继承的是调用权, 子类可以调用父类的函数

class Shape{
public:
    virtual void draw() const = 0;  // pure virtual
    virtual void error(const std::string& msg);     // impure virtual
    int objectID() const;       // non-virtual
    //...
};

class Rectangle:public Shape{/*...*/};
class Ellipse:public Shape{/*...*/};

成员函数可以分为 3 种

  • non-virtual 函数

    你不希望 derived class (子类) 重新定义 (override, 覆写) 它

  • virtual 函数

    你希望子类重新定义 (override, 覆写) 它, 并且你对它已经有了默认定义

  • pure virtual 函数

    你希望子类一定要重新定义 (override, 覆写) 它, 你对它没有默认定义

上面代码 pure virtual 就是纯虚函数, 纯虚函数是没有定义的

其实纯虚函数可以有定义, 但是在我们这里不去提它

例如我们现在这个代码, 我们在这里设计了一个形状类, 但是世界上并没有叫作 "形状" 的形状, 世界上只有三角形椭圆矩形这些. 形状是一个非常抽象的概念, 现在我们在抽象的层面上思考, 我们说我们需要一个形状, 将来我们继承下去才有矩形椭圆这些, 它们都继承自形状这个类

然后我们想, 我们在这抽象的这一层次, 可以写什么, 这里写的三个函数, 分别是三种函数

第一个函数叫 objectID, 我这个形状派生下面之后, 在运行过程中产生的各个对象, 我要给它的 ID, 就相当于身份证号, 不管你是矩形还是什么形状, 反正我可以用一套固定的算法来生成这个 ID, 所以下面的子类不需要重新定义它, 那么我们就把它设计为非虚函数

另外我又设计了一个函数叫 error, 这个 error 函数的意思是说将来程序运行的时候, 就好像 PPT 一样, 有各种各样的形状让你操作, 如果操作失败的话, 我作为设计者希望丢出一个错误代码, 这个错误代码我有一个预设的想法, 一个默认的想法, 比如就是弹个提示信息说对不起我们出错了之类的.

但是为什么把它设计为虚函数呢 ? 因为我决定让下面的这些子类, 矩形三角形这些子类, 如果这些子类有更好的想法, 不是打出刚才那些默认的提示信息的话, 我作为父类的设计者, 允许你们这些子类 override 去重新定义它. 重新定义之后, 在运行的过程里面, 看是哪一种形状出错, 自然就会调用那一种形状的 error().

还有一个函数叫 draw(), 这里把它设计为纯虚函数, 这个函数一定要被所有子类重新定义, 因为我现在作为父类我根本不知道该怎么定义它. draw, 我不知道怎么去画出一个叫 "形状" 的形状.

所以继承这个东西最重要的是搭配虚函数. 具体父类成员函数的选择, 就是像上面这样思考.

下面我们举一个非常经典的例子

Inheritance (继承) with virtual

举个例子, 我们现在用 PPT, 当我们开启一个文件的时候, 我们把这个菜单打开来选下去. 之后会弹出来一个对话框, 对话框里面列出来可以我可以选择的 file name, 然后点击确认之后, 程序应该收到一个 file name, 程序应该去检测 file name 是否合法, 然后它应该到硬盘里去找这个 file 在不在, 找到了之后就把它 open, 打开之后把它读出来

这些动作, 任何人来写这段代码应该都差不多, 这些动作里面只有最后打开文件之后读取的内容没办法事先写好, 除此之外所有的都可以提前先写好

那我们可以先给大家把这些可以事先写好的东西给它写好, 剩下的不管你是写 word 还是写 excel, 最后那一部分就有具体的使用者来自己弄了


由于这个和文件有关, 那就先写一个 class 叫作 document, document 里面有一个函数叫 OnFileOpen()

然后这个 OnFileOpen() 就把刚才说的那些步骤全部写好, 但是有一个动作这里写不出来, 就是读取内容的这个动作

所以根据刚刚的说法, 有一些函数没有办法提前写出来, 要让子类去写, 这一种函数就必须把它设为 virtual function. 所以这里面的读取的动作, 也就是 Serialize(), 这个函数应该是一个虚函数

你可以把它设计为纯虚函数, 如果设计为纯虚函数的话, 这个意思就是下面的子类就一定要重新定义它

你也可以设计为一般的虚函数, 那你应该要有一个默认的定义, 可是谁也不知道这个默认定义该怎么办, 也许有些人是在这里写一个空函数. 但是空函数和纯虚函数意义不一样, 空函数是子类不一定要去覆写它, 当然为了功能体现子类应该去覆写它, 但是万一子类没有写它这样也可以通过编译

父类这个是提前写好了一个 Serialize(), 然后我们子类这里把它补全 virtual Serialize(){/*...*/}

然后我们看看主函数看它怎么用, 非常经典的虚函数用法

在 main 里面, 我先创建一个子类的对象, 我通过子类对象调用父类的函数. 这里图上用了灰色的虚线条来引导大家, 这一行代码调用的这个函数, 全名是下面这样 CDocument::OnFileOpen(&myDoc);, 所以编译器就知道你要调用的是这个函数, 然后粗的灰色线就到左边来, 一路执行, 执行到 Serialize() 这里的时候, 发现你子类有写这个函数, 它就会跑到子类这里来, 完了之后再回到父类去, 然后再回到 main

这就是虚函数最重要的一种用途

这个做法有一个专属的名称, OnFileOpen() 里面做的一系列动作, 把其中的一个关键部分 (Serialize) 延缓到子类去决定

延缓就是这个关键动作在父类写不出来的, 在图上就是斜体的表示抽象, 延缓就是一年以后三年以后由子类把它写出来. 我们把这个 OnFileOpen() 函数的做法叫作 template method. 23 个设计模式之一.

在这种应用程序的框架里面就会大量用到这种 template method

看图片 Serialize 其实是通过 this pointer 来调用, this 就是 myDoc, 所以这里调用 Serialize 就会跑到子类来执行

Inheritance (继承), 表示 is-a

这一页就把刚刚的过程用代码模拟写一次, CDocument 的这些动作, 这里用 cout 来模拟, 模拟那个菜单拉下来, 然后对话框打开, 读到 file name 然后去检查等等这些动作

然后里面调用了 Serialize, 注意这里第 19 行, 这个上一页没有写出来是不对的, 这里要注意

Inheritance+Composition 继承和复合关系下的构造和析构

在继承加复合的情况下, 构造函数和析构函数是个什么样的情况呢 ?

我们看图, Base 就是父类, Derived 就是子类, 子类又有一个 Component

我们先看上面的部分, 从内存的角度去画, 我们可以说子类里面有父类的成分, 又有 Component 在里面, 就是右边这个图

所以你可以想象, 当你去创建这个 Derived 的时候, 你的构造函数应该去调用上面 Base 也去调用右边 Component, 可是谁先就不知道了, 这个大家可以自行去探索, 比如看看汇编代码观察谁先谁后

下面这种情况, 子类里面有父类的部分, 然后父类里面有一个 Component, 这就好说了, 子类构造函数先去调用 Component 的构造函数, 再去调用 Base 的构造函数, 再去调用 Derived

析构函数正好相反

Delegation (委托) + Inheritance (继承)

在类与类的三种关系里面, 功能最强大的是委托+继承

这一段代码是在解决, 四个窗口同时在看同一个东西, 如果一个窗口的内容发生了变化, 其它三个窗口都要跟着变, 因为这四个窗口真正的数据只有一份

我们设计一个 class 叫 subject 专门存放数据, 我们再设计一个 class 叫 observer, 用来观察 subject

左跟右, 我们可以让左边拥有很多个右边, 因为使用者可以开出很多很多的 observer 观察者

所以我们应该怎么准备数据呢 ? 在左边这里我们准备了一个容器, 这个容器现在选择的是向量 vector, 括号里面说我要放指针去指向右边这种东西

这样就确定下来了这个是委托, 毕竟有指针嘛,. 而指向右边, 我们前面有个例子是字符串, 左边是字符串然后右边是字符串的实现, 当时我们说这样其实没什么用, 因为左和右是一对一的不能变化

现在呢, 右边这一个可以被继承, 所以 observer 只是一个父类, 将来派生出来的子类全部都是 is-a, 是一种 observer. 那既然都是 observer, 所以这些子类创建出来之后都可以通通放到 vector 容器里面.

左边作为被观察对象, 应该提供一种注册和注销的动作, 你们谁想要观察我得先找我注册, 这个例子提供的是 attach() 函数, 传进来观察者指针, 然后把它放到容器里面, 这个容器在这里叫 m_views. 当然还应该有一个注销的功能, 这里没有写出来

还应该有一个函数叫 notify(), 把容器中的所有的观察者都遍历循环一遍, 去一个个通知, 通知这个动作怎么完成, 就需要左右两边都说好. 这里右边说好了我右边有一个函数 update 然后左边说我要调用这个 update, 就是通知所有的观察者准备更新数据

这个就是观察者模式

其它链接

C++组合,继承,委托,多态

观察者模式