普通视图

发现新文章,点击刷新页面。
昨天以前Casa Taloyum

跳出面向对象思想(三) 封装

作者 Casa Taloyum
2015年1月12日 00:00




简述


我认为"封装"的概念在面向对象思想中是最基础的概念,它实质上是通过将相关的一堆函数和一堆对象放在一起,对外有函数作为操作通道,对内则以变量作为操作原料。只留给外部程序员操作方式,而不暴露具体执行细节。大部分书举的典型例子就是汽车和灯泡的例子:你不需要知道不同车子的发动机原理,只要踩油门就可以跑;你不需要知道你的灯泡是那种灯泡,打开开关就会亮。我们都会很直觉地认为这种做法非常棒,是吧?

但是有的时候还是会觉得有哪些地方不对劲,使用面向对象语言的时候,我隐约觉得封装也许并没有我们直觉中认为的那么好,也就是说,面向对象其实并没有我们直觉中的那么好,虽然它已经流行了很多很多年。




1. 将数据结构和函数放在一起是否真的合理?


函数就是做事情的,它们有输入,有执行逻辑,有输出。 数据结构就是用来表达数据的,要么作为输入,要么作为输出。

两者本质上是属于完全不同的东西,面向对象思想将他们放到一起,使得函数的作用被限制在某一个区域里,这样做虽然能够很好地将操作归类,但是这种归类方法是根据"作用领域"来归类的,在现实世界中可以,但在程序的世界中,有些不妥。

不妥的理由有如下几个:

在并行计算时,由于执行部分和数据部分被绑定在一起,这就使得这种方案制约了并行程度。在为了更好地实现并行的时候,业界的工程师们发现了一个新的思路:函数式编程。将函数作为数据来使用,这样就能保证执行的功能在时序上的正确性了。但你不觉得,只要把数据表达和执行部分分开,形成流水线,这不就能够非常方便地将并行数提高了么?

我来举个例子: 在数据和函数没有分开时,程序的执行流程是这样:

A.function1() -> A.function2() -> A.function3()     最后得到经过处理的A

当处于并发环境时,假设有这么多任务同时到达

A.f1() -> A.f2() -> A.f3()     最后得到经过处理的A
B.f1() -> B.f2() -> B.f3()     最后得到经过处理的B
C.f1() -> C.f2() -> C.f3()     最后得到经过处理的C
D.f1() -> D.f2() -> D.f3()     最后得到经过处理的D
E.f1() -> E.f2() -> E.f3()     最后得到经过处理的E
F.f1() -> F.f2() -> F.f3()     最后得到经过处理的F
...

假设并发数是3,那么完成上面类似的很多个任务,时序就是这样

| time | 1   | 2   | 3   | 4   | 5   | 6   | 7   | 8   | 9   | 10  | 11  | 12  |
|------|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| A    | A.1 | A.2 | A.3 |     |     |     |     |     |     |     |     |     |
| B    | B.1 | B.2 | B.3 |     |     |     |     |     |     |     |     |     |
| C    | C.1 | C.2 | C.3 |     |     |     |     |     |     |     |     |     |
| D    |     |     |     | D.1 | D.2 | D.3 |     |     |     |     |     |     |
| E    |     |     |     | E.1 | E.2 | E.3 |     |     |     |     |     |     |
| F    |     |     |     | F.1 | F.2 | F.3 |     |     |     |     |     |     |
| G    |     |     |     |     |     |     | G.1 | G.2 | G.3 |     |     |     |
| H    |     |     |     |     |     |     | H.1 | H.2 | H.3 |     |     |     |
| I    |     |     |     |     |     |     | I.2 | I.2 | I.3 |     |     |     |
| J    |     |     |     |     |     |     |     |     |     | J.1 | J.2 | J.3 |
| K    |     |     |     |     |     |     |     |     |     | K.1 | K.2 | K.3 |
| L    |     |     |     |     |     |     |     |     |     | L.1 | L.2 | L.3 |

当数据和函数分开时,并发数同样是3,就能形成流水线了,有没有发现吞吐量一下子上来了?

| time | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10| 11| 12|
|------|---|---|---|---|---|---|---|---|---|---|---|---|
| f1() | A | B | C | D | E | F | G | H | I | J | K | L |
| f2() | Z | A | B | C | D | E | F | G | H | I | J | K |
| f3() | Y | Z | A | B | C | D | E | F | G | H | I | J |

你要是粗看一下,诶?怎么到了第13个周期K才刚刚结束?上面一种方案在第12个周期的时候就结束了?不能这么看的哦,其实在12个周期里面,Y、Z也已经交付了。因为流水线吞吐量的提升是有过程的,我截取的片段应该是机器在持续运算过程中的一个片段。

我们不能单纯地去看ABCD,要看交付的任务数量。在12个周期里面,大家都能够完成12个任务,在11个周期里面,流水线完成了11个任务,前面一种只完成了9个任务,流水线的优势在这里就体现出来了:每个时间段都能稳定地交付任务,吞吐量很大。而且并发数越多,跟第一种方案比起来的优势就越大,具体的大家也可以通过画图来验证。


数据部分就是数据部分,执行部分就是执行部分,不同类的东西放在一起是不合适的

函数就是一个执行黑盒,只要满足函数调用的充要条件(给够参数),就是能够确定输出结果的。面向对象思想将函数和数据绑在一起,这样的封装扩大了代码重用时的粒度。如果将函数和数据拆开,代码重用的基本元素就由对象变为了函数,这样才能更灵活更方便地进行代码重用。

嗯,谁都经历过重用对象时,要把这个对象所依赖的所有东西都要移过来,哪怕你想用的只是这个对象里的一个方法,然而很有可能你的这些依赖是跟你所需要的方法无关的。

但如果是函数的话,由于函数自身已经是天然完美封装的了,所以如果你要用到这个函数,那么这个函数所有的依赖你都需要,这才是合理的。




2. 是否所有的东西都需要对象化?


面向对象语言一直以自己做到"一切皆对象"为荣,但事实是:是否所有的东西都需要对象化?

在iOS开发中,有一个类叫做NSNumber,它封装了所有数值:double,float,unsigned int, int...等等类型,在使用的时候它弱化了数值的类型,使得非常方便。但问题也来了,计算的时候是不能直接对这个对象做运算的,你得把它们拆成数值,然后进行运算,然后再把结果变成NSNumber对象,然后返回。这是第一点不合理。第二点不合理的地方在于,运算的时候你不知道原始数据的类型是什么,拆箱装箱过程中难免会导致内存的浪费(比如原来uint8_t的数据变成unsigned int),这也十分没有必要。

还有就是我们的file descriptor,它本身是一个资源的标识号,如果将资源抽象成对象,那么不可避免的就会使得这个对象变得非常庞大,资源有非常多的用法,你需要将这些函数都放到对象里去。在真正传递资源的时候,其实我们也只是关心资源标识而已,其它的真的无需关心。

我们已经有函数作为黑盒了,拿着数据塞到黑盒里就够了。




3. 类型爆炸


由于数据和函数绑定到了一起,在逻辑上有派生关系的两种对象往往可以当作一种,以派生链最上端的那个对象为准。单纯地看这个现象直觉上会觉得非常棒,父亲有的儿子都有。但在实际工程中,派生是非常不好控制的,它导致同一类类型在工程中泛滥:ViewController、AViewController、BViewController、ThisViewController、ThatViewController...

你有没有发现,一旦把执行和数据拆解开,就不需要这么多ViewController了,派生只是给对象添加属性和方法。但事实上是这样:

struct A {              Class A extends B
    struct B b;         {
    int number;             int number;
}                       {

前者和后者的相同点是:在内存中,它们的数值部分的布局是一模一样的。不同点是:前者更强烈地表达了组合,后者更强烈地表达的是继承。然而我们都知道一个常识:组合要比继承更加合适,这在我这一系列的第一篇文章中有提到。

上两者的表达在内存中没有任何不同,但在实际开发阶段中,后者会更容易把项目引入一个坏方向。




总结


为什么面向对象会如此流行?我想了一下业界关于这个谈论的最多的是以下几点:


  1. 它能够非常好地进行代码复用
  2. 它能够非常方便地应对复杂代码
  3. 在进行程序设计时,面向对象更加符合程序员的直觉


第一点在理论上确实成立,但实际上大家都懂,在面向对象的大背景下,写一段便于复用的代码比面向过程背景下难多了。关于第二点,你不觉得正是面向对象,才把工程变复杂的么?如果层次清晰,调用规范,无论面向对象还是面向过程,处理复杂业务都是一样好,等真的到了非常复杂的时候,对象间错综复杂的关系只会让你处理起来更加头疼,不如面向过程来得简洁。关于第三点,这其实是一个障眼法,因为无论面向什么的设计,最终落实下来,还是要面向过程的,面向对象只是在处理调用关系时符合直觉,在架构设计时,理清需求是第一步,理清调用关系是第二步,理清实现过程是第三步。面向对象让你在第二步时就产生了设计完成的错觉,只有再往下落地到实现过程的时候,你才会发现第二步中都有哪些错误。

所以综上所述,我的观点是:面向对象是在架构设计时非常好的思想,但如果只是简单映射到程序实现上来,引入的缺点会让我们得不偿失。





后记


距离上一次博文更新已经快要一个月了,不是我偷懒,实在是太忙,现在终于有时间可以把"跳出面向对象"系列完成了。针对面向对象的3个支柱概念我写了三篇文章来挑它的刺,看上去有一种全盘否定的感觉,而我倒不至于希望大家回去下一个项目就开始面向过程的开发,我希望大家能够针对这一系列文章提出的面向对象的弊端,严格规范代码的行为,知道哪些可行哪些不可行。过去的工作中我深受其苦,往往没有时间去详细解释为什么这么直觉的东西实际上不可行,要想解释这些东西就得需要各种长篇大论。最痛苦的是,即便长篇大论说完了,最后对方还无法理解,照样写出垃圾代码出来害人。

现在好了,长篇大论落在纸上了,说的时候听不懂,回去总可以翻文章慢慢理解了吧。




跳出面向对象思想(二) 多态

作者 Casa Taloyum
2014年12月16日 00:00




简述


多态一般都要跟继承结合起来说,其本质是子类通过覆盖或重载(在下文里我会多次用到覆盖或重载,我打算把它简化成覆重,意思到就好,不要太纠结这种名词。)父类的方法,来使得对同一类对象同一方法的调用产生不同的结果。这里需要辨析的地方在:同一类对象指的是继承层级再上一层的对象,更加泛化。

举个例子:

Animal -> Cat
Animal -> Dog

Animal.speak()  // I'm an Animal
Cat.speak()     // I'm a Cat
Dog.speak()     // I'm a Dog

此处CatDog虽然不是同一种对象,但它们算是同一类对象,因为他们的父类都是Animal的表达可能不是很对,其实我也不知道谁更大一点,在文章中我打算用这样的符号来表示两者区别:^^^

^ 表示他们是同一类
^^ 表示他们同种同类

Animal -> Cat
Animal -> Dog

Cat kitty, kate
Dog lucky, lucy

我们可以这么说:

    kitty ^^ kate       同种同类,他们都是猫
    kitty ^ lucy        同类不同种,他们都是Animal
    kitty !^^ lucy      因为kitty是猫lucy是狗
    kitty ^ kate        他们当然同种啦,都是Animal

应该算是能够描述清楚了吧?嗯,我们开始了。




多态


一般来说我们采用多态的场景还是很多的,有些在设计的时候就是用于继承的父类,希望子类覆盖自己的某些方法,然后才能够使程序正常运行下去。比如:

BaseController需要它的子类去覆盖loadView等方法来执行view的显示逻辑
BaseApiManager需要它的子类去覆盖methodName等方法来执行具体的API请求

以上是我列举的应用多态的几个场景,在基于上面提到的需求,以及站在代码观感的立场,我们在实际采用多态的时候会有下面四种情况:

  1. 父类有部分public的方法是不需要,也不允许子类覆重
  2. 父类有一些特别的方法是必须要子类去覆重的,在父类的方法其实是个空方法
  3. 父类有一些方法是可选覆重的,一旦覆重,则以子类为准
  4. 父类有一些方法即便被覆重,父类原方法还是要执行的

这四种情况在大多数支持多态的语言里面都没有做很好的原生限制,在程序规模逐渐变大的时候,会给维护代码的程序员带来各种各样的坑。



父类有部分public的方法是不需要,也不允许子类覆重

对于客户程序员来说,他们是有动机去覆重那些不需要覆重的方法的,比如需要在某个方法调用的时候做UserTrack,或者希望在方法调用之前做一些额外的事情,但是又找不到外面应该在哪儿做,于是就索性覆重一个了。这样做的缺点在于使得一个对象引入了原本不属于它的业务逻辑。如果在引入的这些额外逻辑中又对其他模块产生依赖,那么这个对象在将来的代码复用中就会面临一个艰难的选择:

  • 是把这些不必要的逻辑删干净然后移过去?
  • 还是所以把依赖连带着这个对象一起copy过去?

前者太累,后者太蠢。

如果是要针对原来的对象进行功能拓展,但拓展的时候发现是需要针对原本不允许覆重的函数进行操作,那么这时候就有理由怀疑父类当初是不是没有设计好了。



父类有一些特别的方法是必须要子类去覆重的,在父类的方法其实是个空方法

这非常常见,由于逻辑的主要代码在父类中,若要跑完整个逻辑,则需要调用一些特定的方法来基于不同的子类获得不同的数据,这个特定的方法最终交由子类通过覆重来实现。如果不在父类里面写好这个方法吧,父类中的代码在执行逻辑的时候就调用不到。如果写了吧,一个空函数放在那儿十分难看。

也有的时候客户程序员会不知道在派生之后需要覆重某个方法才能完成完整逻辑,因为空函数在那儿不会导致warning或error,只有在发现程序运行结果不对的时候,才会感觉哪儿有错。如果这时候程序员发现原来是有个方法没覆重,一定会拍桌子骂娘。

总结一下,其实就是代码不好看,以及有可能忘记覆重。



父类有一些方法是可选覆重的,一旦覆重,则以子类为准

这是大多数面向对象语言默认的行为。设计可选覆重的动机其中有一个就是可能要做拦截器,在每个父类方法调用时,先调一个willDoSomething(),然后调用完了再调一个didFinishedSomething(),由子类根据具体情况进行覆重。

一般来说这类情况如果正常应用的话,不会有什么问题,就算有问题,也是前面提到的容易使得一个对象引入原本不属于它的业务逻辑



父类有一些方法即便被覆重,父类原方法还是要执行的

这个是经典的坑,尤其是交付给客户程序员的时候是以链接库的模式交付的。父类的方法是放在覆重函数的第一句调用呢还是放在最后一句调用?这是个值得深思的问题。更有甚者索性就直接忘记调用了,各种傻傻分不清楚。




解决方案


面向接口编程(Interface Oriented Programming, IOP)是解决这类问题比较好的一种思路。下面我给大家看看应该如何使用IOP来解决上面四种情况的困境:

(示例里面有些表达的约定,可以在这里看完整的上下文规范。)

<ManagerInterface> : APIName()                我们先定义一个ManagerInterface接口,这个接口里面含有原本需要被覆重的方法。
<Interceptor> : willRun(), didRun()         我们再定义一个Interceptor的接口,它用来做拦截器。


BaseManager.child<ManagerInterface>         在BaseController里面添加一个property,叫做child,这就要求这个child必须要满足<ManagerInterface></ManagerInterface>这个接口,但是BaseManager不需要满足<ManagerInterface>这个接口。


BaseManager.init() {

    ...

    self.child = self                       在init的时候把child设置成自己

    # 如果语言支持反射,那么我们可以这么写:
    if self.child implemented <ManagerInterface> {
        self.child = self
    }
    # 如上的写法就能够保证我们的子类能够基于这些接口有对应的实现

    self.interceptor = self                 # interceptor可以是自己,也可以在初始化的时候设为别的对象,这个都可以根据需求不同而决定。

    ...

}


BaseManager.run() {

    self.interceptor.willRun()

    ...

    apiName = self.child.APIName()          # 原本是self.APIName(),然后这个方法是需要子类覆重的,现在可以改为self.child.APIName()了,就不需要覆重了。
    request with apiName

    ...

    self.interceptor.didRun()

}

通过引入这样面向接口编程的做法,就能相对好地解决上面提到的困境,下面我来解释一下是如何解决困境的:



  • 父类有部分public的方法是不需要,也不允许子类覆重


由于子类必须要遵从<ManagerInterface>,架构师可以跟客户程序员约定所有的public方法在一般情况下都是不需要覆重的。除非特殊需要,则可以覆重,其他情况都通过实现接口中定义的方法解决。由于这是接口方法,所以即便引入了原本不需要的逻辑,也能很容易将其剥离



  • 父类有一些特别的方法是必须要子类去覆重的,在父类的方法其实是个空方法


因为引入了child,父类不再需要摆一个空方法在那儿了,直接从child调用即可,因为child是实现了对应接口的,所以可以放心调用。空方法就消灭了。



  • 父类有一些方法是可选覆重的,一旦覆重,则以子类为准


我们可以通过在接口中设置哪些方法是必须要实现,哪些方法是可选实现的来处理对应的问题。这本身倒不是缺陷,正是多态希望的样子。



  • 父类有一些方法即便被覆重,父类原方法还是要执行的


由于我们通过接口规避了多态,那么这些其实是可以通过在接口中定义可选方法来实现的,由父类方法调用child可选方法,调用时机就可以由父类决定。这两个方法不必重名,因此也不存在多态时,不能分辨调用时机或是否需要调用父类方法的情况。


总结一下,通过IOP,我们做好了两件事:


  1. 将子类与可能被子类引入的不相关逻辑剥离开来,提高了子类的可重用性,降低了迁移时可能的耦合。
  2. 接口实际上是子类头上的金箍,规范了子类哪些必须实现,哪些可选实现。那些不在接口定义的方法列表里的父类方法,事实上就是不建议覆重的方法。




什么时候用多态


由于多态和继承紧密地结合在了一起,我们假设父类是架构师去设计,子类由客户程序员去实现,那么这个问题实际上是这样的两个问题:


  1. 作为架构师,我何时要为多态提供接入点?
  2. 作为客户程序员,我何时要去覆重父类方法?


这本质上需要程序员针对对象建立一个角色的概念。

举个例子:当一个对象的主要业务功能是搜索,那么它在整个程序里面扮演的角色是搜索者的角色。在基于搜索派生出的业务中,会做一些跟搜索无关的事情,比如搜索后进行人工加权重排列表,搜索前进行关键词分词(假设分词方案根据不同的派生类而不同)。那么这时候如果采用多态的方案,就是由子类覆重父类关于重排列表的方法,覆重分词方法。如果在编写子类的程序员忘记这些必要的覆重或者覆重了不应该覆重的方法,就会进入上面提到的四个困境。所以这时候需要提供一套接口,规范子类去做覆重,从而避免之前提到的四种困境:

Search : { search(), split(), resort()}

采用多态的方案
Search -> ClothSearch : { [ Search ], @split(), @resort() }

function search() {

    ...

    self.split()    # 如果子类没有覆重这个方法而父类提供的只是空方法这里就很容易出问题如果子类在覆重的时候引入了其他不相关逻辑那么这个对象就显得不够单纯角色复杂了

    ...

    self.resort()

    ...

}


采用IOP的方案
<SearchManager> : {split(), resort()}
Search<SearchManager> : { search(), assistant<SearchManager> }      # 也可以是这样Search : { search(), assistant<SearchManager> },这么做的话则要求子类必须实现<SearchManager>

function search() {

    ...

    self.assistant.split()  # self.assistant可以就是self也可以由初始化时候指定为其他对象将来进行业务剥离的时候只要将assistant里面的方法剥离或者讲assistant在初始化时指定为其他对象也好

    ...

    self.assistant.resort()

    ...

}

Search -> ClothSearch<SearchManager> : { [ Search ], split(), resort() }    # 由于子类被接口要求必须实现split()和resort()方法因而规避了前文提到的风险在剥离业务的时候也能非常方便

外面使用对象时ClothSearch.search()

如果示例中不同的子类对于search()方法有不同的实现,那么这个时候就适用多态。

Search : { search() }

ClothSearch : { [Search], @search() }

此时适用多态外面使用对象时ClothSearch.search()



总结是否决定应当使用多态的两个要素:


  • 如果引入多态之后导致对象角色不够单纯,那就不应当引入多态,如果引入多态之后依旧是单纯角色,那就可以引入多态
  • 如果要覆重的方法是角色业务的其中一个组成部分,例如split()和resort(),那么就最好不要用多态的方案,用IOP,因为在外界调用的时候其实并不需要通过多态来满足定制化的需求。


其实这是一个角色问题,越单纯的角色就越容易维护。还有一个就是区分被覆重的方法是否需要被外界调用的问题。好了,现在我们回到这一节前面提出的两个问题:何时引入接入点和何时采用覆重。针对第一个问题架构师一定要分清楚角色,在保证角色单纯的情况下可以引入多态。另外一点要考虑被覆重的方法是否需要被外界使用,还是只是父类运行时需要子类通过覆重提供中间数据的。如果是只要子类通过覆重提供中间数据的,一律应当采用IOP而不是多态



针对第二个问题,在必须要覆重的场合下就采取覆重的方案好了,主要是可覆重可不覆重的情况下,客户程序员主要还是要遵守:


  • 覆重的方法本身是跟逻辑密切相关的,不要在覆重方法里做跟这个方法本意不相关的事情
  • 如果要覆重一系列的方法,那么就要考虑角色问题和外界是否需要调用的问题,这些方法是不是这个对象的角色应当承担的任务


比如说不要在一个原本要跑步的函数里面去做吃饭的事情,如果真的要吃饭,父类又没有,实在不行的时候,就需要在覆重的方法里面启用IOP,在子类里面弥补架构师的设计缺陷。把这个不属于跑步的事情IOP出去,负责实现对应接口的可以是self,也可以是别人。只要不是强耦合地去覆重,这样在代码迁移的时候,由于IOP的存在,使得代码接收方也可以接受并实现对应的interface,从而不影响整体功能,又能提供迁移的灵活性。




总结


多态在面向对象程序中的应用相当广泛,只要有继承的地方,或多或少都会用到多态。然而多态比起继承来,更容易被不明不白地使用,一切看起来都那么顺其自然。在客户程序员这边,一般是只要多态是可行方案的一种,到最后大部分都会采用多态的方案来解决问题。

然而多态正如它名字中所暗示的,它有非常大的潜在可能引入不属于对象初衷的逻辑,巨大的灵活性也导致客户程序员在面对问题的时候不太愿意采用其他相对更优的方案,比如IOP。在决定是否采用多态时,我们要有一个清晰的角色概念,做好角色细分,不要角色混乱。该是拦截器的,就给他制定一个拦截器接口,由另一个对象(逻辑上的另一个对象,当然也可以是自己)去实现接口里的方法集。不要让一个对象在逻辑上既是拦截器又是业务模块。这样才方便未来的维护。另外也要注意被覆重方法的作用,如果只是单纯为了提供父类所需要的中间数据的,一律都用IOP,这是比直接采用多态更优的方案。

IOP能够带来的好处当然不止文中写到的这些,它在其他场合也有非常好的应用,它最主要的好处就在于分离了定义和实现,并且能够带来更高的灵活性,灵活到既可以对语言过高的自由度有一个限制,也可以灵活到允许同一接口的不同实现能够合理地组合。在架构设计方面是个非常重要的思想。

跳出面向对象思想(一) 继承

作者 Casa Taloyum
2014年12月1日 00:00




简述


我会在这篇这一系列文章中谈谈面向对象思想的几个部分,并且给出对应的解决方案,这些解决方案有些是用面向过程的思路解决的,有些也还是停留在面向对象中。到最后我会给大家一个比较,然后给出结论。




上下文规范


在进一步地讨论这些概念之前,我需要跟大家达成一个表达上的共识,我会采用下面的语法来表达对象相关的信息:

所有的大写字母都是类或对象小写字母表示属性或方法

FOO:{ isLoading, _data, render(), _switch() }   这表示一个FOO对象isLoading_data是它的属性render()_switch()是它的方法加下划线表示私有

A -> B                                          这表示从A派生出了BA是父类

A -> B:{ [a, b, c(), d()], e, f() }             []里面是父类的东西ef()是派生类的东西

B:{ [ A ], e, f() }                             省略了对父类的描述用类名A代替其他同上

B:{ [ A ], e, f(), @c() }                       省略了对父类的描述函数前加@表示重载了父类的方法

B:{ [ A,D ], e, f() }                           多继承B继承了A和D

B<protocol>                                     符合某个protocol接口的对象

<protocol>:{foo(), bar}                         protocol这个接口中包含foo()这个方法bar这个属性

foo(A, int)                                     foo这个函数接收A类和int类型作为参数




来,我们谈谈对象


面向对象思想三大支柱:继承、封装、多态。这篇文章说的是继承。当然面向对象和面向过程都会有好有坏,但是做决定的时候,更多地还是去权衡值得不值得放弃。关于这样的立场问题,我都会给出非常明确的倾向,不会跟你们打太极。
如果说这个也好那个也好,那还发表毛个观点,那叫没有观点。




继承


继承从代码复用的角度来说,特别好用,也特别容易被滥用和被错用。不恰当地使用继承导致的最大的一个缺陷特征就是高耦合
在这里我要补充一点,耦合是一个特征,虽然大部分情况是缺陷的特征,但是当耦合成为需求的时候,耦合就不是缺陷了。耦合成为需求的例子在后面会提到。
我们来看下面这个场景:




有一天,产品经理Yuki说:

我们不光首页要有一个搜索框,在进去的这个页面,也要有一个搜索框,只不过这个搜索框要多一些功能,它是可以即时给用户搜索提示的。


Casa接到这个任务,他研究了一下代码,说:OK,没问题~
Casa知道代码里已经有了一个现成的搜索框,Casa立刻从HOME_SEARCH_BAR派生出PAGE_SEARCH_BAR
嗯,目前事情进展到这里还不错:

HOME_SEARCH_BAR:{textField, search(), init()}
PAGE_SEARCH_BAR:{ [ HOME_SEARCH_BAR ], overlay, prompt() }




过了几天,产品经理Yuki要求:

用户收藏的东西太多了,我们的app需要有一个本地搜索的功能。


Casa轻松通过方法覆盖摆平了这事儿:

HOME_SEARCH_BAR:{textField, search()}
PAGE_SEARCH_BAR:{ [ HOME_SEARCH_BAR ], overlay, prompt() }
LOCAL_SEARCH_BAR:{ [ HOME_SEARCH_BAR ], @search() }




app上线一段时间之后,UED不知哪根筋搭错了,决定要修改搜索框的UI,UED跟Casa说:

把HOME_SEARCH_BAR的样式改成这样吧,里面PAGE_SEARCH_BAR还是老样子就OK。


Casa表示这个看似简单的修改其实很蛋碎,HOME_SEARCH_BAR的样式一改,PAGE_SEARCH_BARLOCAL_SEARCH_BAR都会改变,怎么办呢? 与其每个手工修一遍,Casa不得已只能给HOME_SEARCH_BAR添加了一个函数:initWithStyle()

    HOME_SEARCH_BAR:{ textField, search(), init(), initWithStyle() }
    PAGE_SEARCH_BAR:{ [ HOME_SEARCH_BAR ], overlay, prompt() }
    LOCAL_SEARCH_BAR:{ [ HOME_SEARCH_BAR ], @search() }

于是代码里面就出现了各种init()和initWithStyle()混用的情况。


无所谓了,先把需求应付过去再说。

Casa这么想。




有一天,另外一个team的leader来对Casa抱怨:

搞什么玩意儿?为毛我要把LOCAL_SEARCH_BAR独立出来还特么连带着把那么多文件都弄出来?我就只是想要个本地搜索的功能而已!!

这是因为LOCAL_SEARCH_BAR依赖于它的父类HOME_SEARCH_BAR,然而HOME_SEARCH_BAR本身也带着API相关的对象,同时还有数据解析的对象。 也就是说,要想把LOCAL_SEARCH_BAR移植给另外一个TEAM,拔出萝卜带出泥,差不多整个Networking框架都要移植过去。 嗯,Casa又要为了解耦开始一个不眠之夜了~




以上是典型的错误使用继承的案例,虽然继承是代码复用的一种方案,但是使用继承仍然是需要好好甄别代码复用的方式的,不是所有场景的代码复用都适用于继承。

继承是紧耦合的一种模式,主要的体现就在于牵一发动全身。

  • 第一种类型的问题是改了一处,到处都要改,但解决方案还算方便,多添加一个特定的函数(initWithStyle())就好了。只是代码里面难看一点。
  • 第二种类型的问题是代码复用的时候,要跟着把父类以及父类所有的相关依赖也复制过去,高耦合在复用的时候造成了冗余。




对于这样的问题,业界其实早就给出了解决方案:用组合替代继承。将Textfield和search模块拆开,然后通过定义好的接口进行交互,一般来说可以选择Delegate模式来交互。


解决方案:

   <search_protocol>:{search()}

   SEARCH_LOGIC<search_protocol>

   SEARCH_BAR:{textField, SEARCH_LOGIC<search_protocol>}

   HOME_SEARCH_BAR:{SearchBar1, SearchLogic1}
   PAGE_SEARCH_BAR:{SearchBar2, SearchLogic1}
   LOCAL_SEARCH_BAR:{SearchBar2, SearchLogic2}


这样一来,搜索框和搜索逻辑分别形成了两个不同的组件,分别在HOME_SEARCH_BAR, PAGE_SEARCH_BAR, LOCAL_SEARCH_BAR中以不同的形态组合而成。 textFieldSEARCH_LOGIC<search_protocol>之间通过delegate的模式进行数据交互。 这样就解决了上面提到的两种类型的问题。 大部分我们通过代码复用来选择继承的情况,其实都是变成组合比较好。 因此我在团队中一直在推动使用组合来代替继承的方案。 那么什么时候继承才有用呢?

纠结了一下,貌似实在是没什么地方非要用继承不可的。但事实上使用继承,我们得要分清楚层次,使用继承其实是如何给一类对象划分层次的问题。在正确的继承方式中,父类应当扮演的是底层的角色,子类是上层的业务。举两个例子:


Object -> Model
Object -> View
Object -> Controller

ApiManager -> DetailManager
ApiManager -> ListManager
ApiManager -> CityManager




这里是有非常明确的层次关系的,我在这里也顺便提一下使用继承的3大要点:



父类只是给子类提供服务,并不涉及子类的业务逻辑

    Object并不影响Model, View, Controller的执行逻辑和业务  
    Object为子类提供基础服务,例如内存计数等

    ApiManager并不影响其他的Manager  
    ApiManager只是给派生的Manager提供服务而已,ApiManager做的只会是份内的是,对于子类做的事情不参与。



层级关系明显,功能划分清晰,父类和子类各做各的。

    Object并不参与MVC的管理中,那些都只是各自派生类自己要处理的事情

    DetailManager, ListManager, CityManager都只是处理各自业务的对象  
    ApiManager并不应该涉足对应的业务。



父类的所有变化,都需要在子类中体现,也就是说此时耦合已经成为需求

    Object对类的描述,对内存引用的计数方式等,都是普遍影响派生类的。  
    ApiManager中对于网络请求的发起,网络状态的判断,是所有派生类都需要的。  
    此时,牵一发动全身就已经成为了需求,是适用继承的




此时我们回过头来看为什么HOME_SEARCH_BAR,PAGE_SEARCH_BAR,LOCAL_SEARCH_BAR采用继承的方案是不恰当的:

  • 他们的父类是HOME_SEARCH_BAR,父类不只提供了服务,也在一定程度上影响了子类的业务逻辑。派生出的子类也是为了要做搜索,虽然搜索的逻辑不同,但是互相涉及到搜索这一块业务了。


  • 子类做搜索,父类也做搜索,虽然处理逻辑不同,但是这是同一个业务,与父类在业务上的联系密切。在层级关系上,HOME_SEARCH_BAR和其派生出的LOCAL_SEARCH_BAR, PAGE_SEARCH_BAR其实是并列关系,并不是上下层级关系。


  • 由于这里所谓的父类和子类其实是并列关系而不是父子关系,且并没有需要耦合的需求,相反,每个派生子类其实都不希望跟父类有耦合,此时耦合不是需求,是缺陷。




总结

可见,代码复用也是分类别的,如果当初只是出于代码复用的目的而不区分类别和场景,就采用继承是不恰当的。我们应当考虑以上3点要素看是否符合,才能决定是否使用继承。就目前大多数的开发任务来看,继承出现的场景不多,主要还是代码复用的场景比较多,然而通过组合去进行代码复用显得要比继承麻烦一些,因为组合要求你有更强的抽象能力,继承则比较符合直觉。然而从未来可能产生的需求变化和维护成本来看,使用组合其实是很值得的。另外,当你发现你的继承超过2层的时候,你就要好好考虑是否这个继承的方案了,第三层继承正是滥用的开端。确定有必要之后,再进行更多层次的继承。

所以我的态度是:万不得已不要用继承,优先考虑组合



❌
❌