11 组合与继承

11 组合与继承

遇到复杂问题的时候, 就需要类与类之间发生关系

类与类之间有些什么关系 ?

  • Inheritance (继承)

  • Composition (复合)

  • Delegation (委托)

Composition 复合, 表示 has-a

template<class T, class Sequence = deque<T> >
class queue{
    //...

protected:
    Sequence c; // 底层容器
public:
    // 以下完全利用 c 的操作函数完成
    boot empty() const { return c.empty(); }
    size_type size() const { return c.size(); }
    reference front() { return c.front(); }
    reference back() { return c.back(); }
    // deque 是两端可进出, queue 是末端进前端出 (先进先出)
    void push(const value_type& x) { c.push back(x); }
    void pop() { c.pop_front(); }
};

queue 念作 Q

Adapter

template <class T>
class queue{
    //...
protected:
    deque<T> c; // 底层容器
    boot empty() const { return c.empty(); }
    size_type size() const { return c.size(); }
    reference front() { return c.front(); }
    reference back() { return c.back(); }
    //
    void push(const value_type& x) { c.push_back(x); }
    void pop { c.pop_front(); }
};

这个 class 叫作 queue, 里面有一个变量 c, c 是 deque 这个模板类型

一个 class 里面有一个这种东西, 这样我们叫作 复合 (Composition)

我里头有另外一种东西, 这就是复合, 表现出来的是 has-a, 我有一个或者 N 个这种关系

queue 容纳了 deque

以这个例子来看, queue 拥有这个 c 之后, queue 里面所有的功能都没有自己写, 它都是调用它所拥有的这个 c 来完成的

这个例子是个很特殊的例子, 意思是说, queue 所有的功能已经在 deque 那里完成了

而我这个 queue 想借用这个已经完成的功能来实现我自己的功能, 所以我这个 queue 的代码特别简单

当然这是个特例, 我们说只要 a 拥有 b 就可以是 composition, 但是这里是 a 拥有 b, 并且 a 的所有功能都让 b 来做, a 自己不再做新的功能了. 而这种特例让我们感受到 deque 这种东西很强大, 它可以完全满足 queue 所有的功能

queue 是先进先出的一个队列, deque 的意思是两端都可以进出. 那你想想看两端进出的功能当然是比一端进一端出的功能要强大.

我们借由这个例子讲复合, 然后这个例子又比较独特, 表现出来的是, 你已经有了一个功能强大的东西了, 现在你只要把它改装一下就可以变成另一个 class 了

而说不定这个 deque 里面有一百个功能, 而被这个 queue 包进来以后只开放了 6 个功能, 而且名字也可能换了. 存在这种情况的话, 这就表现出一种设计模式, 叫作 Adapter

什么叫 adapater, 改造适配配接

客户要一个东西, 而事实上你已经有了一个更强大的东西, 所以你只需要把手头上这个改造一下就好了, 把名字和接口换一下

这个例子 queue 就是 adapter


我们再往下看, 刚刚举的例子, 这边出现 queue, 这里后面出现 deque. 我现在想用内存的角度去解释 composition

这个 queue 里头, 我把它的数据全部拿出来, 就是这个 c, 这个 c 是 deque 类型的; 然后我们把 deque 列出来, deque 里面有 4 个数据, 这 4 个数据里面又有一次 composition, 然后我们把这个 Itr 类型给挖出来看看; Itr 有四个指针

上面标出来的大小, 所以最终 queue 的大小为 40 字节

composition (复合) 关系下的构造和析构

既然我拥有你, 我们两个是不同的 class, 你有你的构造函数, 我有我的构造函数, 我也不能去做你的构造函数的工作, 我应该去关注我自己. 所以它们的构造函数之间是一个什么关系呢 ?

我们看图, 是 Container 拥有 Component; 从内存的角度是右边这样

构造的时候, 外界要构造外部的东西

我们看规则是这样, 外部的 Container 的构造函数先调用内部 Component 的默认构造函数, 然后才执行自己.

注意黄色这一段, 构造一定要由内而外构造, 这样基础才稳定. 你现实中做个东西, 一定要从里面到外面把基础做扎实, 所以 C++ 也把这种特性表现了出来

所以我要模拟出这一个动作, 我要用这种特殊的语法. 这个构造函数有它的 初值列, 也就是红色的部分, 我要表现的就是, 外部的构造函数先调用内部的构造函数, 等它执行完了之后再做外部这个构造函数自己的事情, 这里我们强调的就是这个先后的关系

红色这一部分是编译器帮我们加上去的

反过来看析构, 析构的次序刚好相反. 你有一个东西它有三层, 当然是外面的一层先扒掉, 再去扒里面, 然后再去扒更里面

为了实现这个析构由外而内, 外部的这个析构函数先执行自己, 然后再执行内部 Component 的析构函数

我要模拟这种事情的话我就这么写

注意代码的区别, 它先把自己的事情做完, 然后才去调用内部的析构函数, 注意这个次序

当然, 其实这些都是编译器帮我们安排好了的

我们这有两个 class, 我们只需要管好各自的构造函数析构函数就好, 编译器会自动帮我们加上这种红色的部分, 我们自己不用去写

上面提到默认构造函数, 为什么要用默认的的呢 ? 因为红色的是编译器加的, 它要帮我们调用内部的构造函数, 也许内部的构造函数有很多个, 编译器不知道要帮你调用哪一个, 于是它唯一能帮你的是调用默认的这个构造函数

如果这个默认的构造函数不符合你的需求的话, 你就要自己在外部的构造函数写上自己明确调用内部的某一个构造函数, 也就是把红色的部分替换成自己所想要的, 什么参数都要自己写

只有构造函数有这些事情, 析构函数是没有的

Delegation (委托). Composition by reference

我这个类和你这个类之间, 什么样叫作 delegation 呢 ? 如果我有一根指针

我们先看这个例子, 这个例子有一个字符串, 跟我们前面单一 class 的字符串非常地像

由于它有指针所以我们要注意 big 3

现在, 黄色部分这个指针的设计, 我们之前的那个指针是指向一个字符, 现在它却指向另外一个东西, 这个东西写在了右边

这个东西叫 StringRep

这样我们说左边的 class 和右边的 class 是有什么关系呢, 它是用指针指过来的, 画成图就是空心的菱形, 之前的是黑色实心的菱形, 空心的表示的是指针

也就是说我左边仍然有一个右边, 但是这个 "有" 有点虚, 不是那么扎实, 我只是有一个指针指向它, 至于什么时候真的拥有右边, 目前还不知道

这样的关系我们叫 delegation

为什么叫委托呢 ? 我拥有这个指针指向你之后, 在任何一个我想要的时间点, 我就可以调用你, 然后把任务委托给你

当然前面的 composition 复合, 我也可以把任务交给我复合的那个东西, 也叫委托啊, 这个就只是术语上的问题了

关于委托, 它还有另外的一个术语就很好, 叫作 Compositino by reference.

它也是有东西, 只不过是有指针, 所以叫 Composition by reference.

为什么不叫 by pointer 呢 ? 其实我们从一开始到现在, 从来没有提过 by pointer, 因为学术界从来不谈 by pointer, 只谈 by reference, 即使你是用指针在传, 我们也叫 by reference

所以两个类之间, 什么样的情况叫 delegation, 就是用指针相连的情况. 那用指针相连的话, 它们的寿命就不一致了

前面的 composition, 是如果有了一个外部的, 就有了内部, 它们的生命是一起出现的

现在不一样了, 我可能是 String 先创建起来, 可是里面有指针, 等到需要右边这个 StringRep 的时候, 才去创建这个, 不同步

OK, 现在的这种写法其实非常有名, 就是我的字符串本身该怎么设计, 我不在这里面写出来, 该怎么设计在右边写出来. 这个左边只是一个对外的接口, 至于真正怎么实现是在右边写出来. 当左边这个需要做事情的时候都是调用右边的这个类的函数来服务.

左跟右永远都是这样的关系, 这种写法非常有名, 叫作 pointer to implementation 实现. 我有一根指针指向为我实现所有功能的那一个类, 这个简称就是 pimpl, 它的另外一个名字叫 Hand and Body (Handle/Body), 左边是 handle, 右边是 body

外界看到的字符串是看到左边的 handle

它为什么这么有名呢, 因为我们如果把所有的类都写成这样的话, 左手边对外不变, 右手边是真正的实现, 我们可以去切换啊. 这个字符串目前里面该怎么实现呢, 目前的版本是右边这样, 右边这样画成图就是左下角这样, 等会再去解释这个图, 我现在的意思是这个指针将来也可以去指向不同的实现类

这就是有一种弹性, 也就是右边这个怎么变动都不影响左边, 也就不影响客户端, 客户怎么看这个字符串都没影响. 这一个手法又叫作 编译防火墙, 左边不用再编译, 要编译的是右边

现在谈一谈左下角这个图, 这是根据上面代码画出来的. 这左边的字符串里面有一根指针, 所以我画出小黑点, 有三个所以是三个字符串. 每一个指针指向右边这种东西, 我看一看它的数据在哪里把它画出来, 右边它的数据呢是一个整数然后还有一个指针指向字符, 整数就是这个 n, 然后指针指向字符, 所以右边就是虚线的这个圆圈.

这一种做法可以让你立即感受到它想做 reference counting, 也就是引用计数, 就像现在这种情况, 有三个字符串都在用同一个 Hello, 共享同一个 Hello, 这个 n 将会是 3. 共享当然是好事情, 内存当然是节省下来了. 当然共享的前提是内容要一样, 内容不一样如何共享是吧.

我们现在讲的是 Delegation, 但是我现在讲的这个例子可以表现出 Handle/Body 这种经典的手法

要跟人家共享, 就要注意千万不能轻易改动全局, 现在 a b c 三个都是 Hello, 就是 a 想要去改变 Hello 它就不能去影响 b 和 c

这个怎么做呢, 非常简单, 当你要改变内容的时候, 这个整个就单独拿一个出来给你改. 所以本来是三个人共享, a 想改变内容, 那就 copy 一份给 a 来改着玩, b c 剩下两个人就共享原来的内容. 这叫做 copy on write, 就是写入的时候给一个副本让你去写

当然这里扯远了, 这里是第二种关系, 我们再往下看

Inheritance (继承), 表示 is-a

有的人会有误解, 觉得继承才是面向对象, 其它两种太简单了. 其实这三种关系都是面向对象的一部分.

看图, 这里有一个 class, 虽然这个是 struct 但其实它是一种 class

C++ 的 struct 和 class

这两个之间想要表现一种继承关系的话, 先认识一下语法, 语法就是加上黄色的这一行. 在 class 后面加上这一行告诉编译器我要继承谁

画成图就是右边这样, 我们用这种空心三角形来表现, 从子类往父类画, 画一个空心三角形, 下面的是子类上面的是父类, T 表示 type 类型

关于继承 C++ 给了你三种继承方式, 这是其中一种是 public, 除此之外还有 private 继承和 protected 继承, 最重要的是 public 这一种

使用 public 继承就是在传达一种逻辑是说 is-a, "是一种", 最容易理解的就是生物里面的命名方式, 界门纲目科属种, 生物界里面的动物有灵长类哺乳类, 然后细分有人类, 这一层层往下有七层, 每一层是 is-a, 是一种上面那种东西, 比如人类是一种动物, 动物是一种生物

如果你用 public 去继承, 两个 class 的关系但不是如此的话, 将来可能会在某一个时间点出错, 也许不会出错, 这要看你有没有碰到特殊的情况, 所以一定要注意这个代表 is-a 的关系

现在我们从内存的角度看它, 上面的 class 有两个自己的数据, 下面的有一个自己的数据, 现在的这个设计其实没有函数只有数据, 这个设计知识利用了继承的一个观念 : 数据是可以被继承下来的. 父类的数据是被完整继承下来的. 上面的 class 有两个指针, 下面的有自己的一个数据, 所以下面的这个 class 的对象, 它里面拥有的除了这个自己的数据之外, 还有父类的两个指针.

这个并不是继承的最有价值的一部分, 它最有价值的是和虚函数搭配

我们从这个例子看看内存.

Inheritance (继承) 关系下的构造和析构

所以在继承关系下就有父类叫 base 跟子类 (或者叫派生类) Derived, 我们画图就画成这样

从内存的角度来看, 就是子类的对象, 里面有一个父类的 part, 子类对象有父类的成分

这也是一种外部里面包着内部的玩意

我们刚才探讨过 Composition 的构造和析构, 现在我们看看继承的情况下构造和析构是怎么样的

不变的是构造的时候是要由内而外, 析构的时候是由外而内

所以外界在创建子类对象的时候, 子类的构造函数首先调用父类的默认构造函数, 然后才执行自己

这个红色部分就是编译器帮我们写的, 我们自己不用写, 当然如果你不想调用默认构造函数的话就得自己写然后加上参数, 告诉编译器你要调用哪一个构造函数了

析构函数也和之前的 compo 是一样的

这里注意, 父类 base class 的析构函数必须是 virtual, 否则会出现 undefined behavior, 也就不会出现这种由外而内的行为, 这个原因暂时这么理解

只要你认为你的 class 将来会变为父类, 你就把它的析构函数设为 virtual

我们接下来就会提到 virtual function

刚刚的例子只是想要表现出子类对象比较大, 因为它涵盖了父类里面的数据, 但实际上继承主要的用法是搭配虚函数, 所以我们接下来就讲虚函数