小例子背后的大道理——Adapter模式详解

允我心安 提交于 2020-03-20 02:21:01

上回问题回顾

     前文说到一位用户拿着业界标准开关(一个标准的StandardSwitcher,它依赖IStandardSwitchable接口才能工作,然而目前我们的灯并不支持这个接口)出现在我面前,叫嚣着他的“标准开关”应该能打开我们的灯。好吧,这个需求是合理的,的确应该支持。但是该死的是,为什么没有早一点儿知道这个标准的存在呢?这样就不需要花费时间和人力定义这个接口,现在也不会这么纠结。和上次一样,先讲故事、演进方案,再分析背后的思想。

     这回主要讲解Adapter模式,GoF讲解了这个模式是什么,怎么用,用在什么地方。我想来解释一下Adapter模式的要点是什么,对Adapter模式的延展,以及对Adapter模式的误用。顺便得瑟一下我对面向对象设计的理解。

两个方案

     现在有两个选择。

  1. 让我们的灯直接支持标准开关。也就是让灯实现IStandardSwitchable接口。

    • 好处:成本低,实现方式优雅。

    • 坏处:相当于放弃了已经买了我们的灯,又想用标准开关的用户。

  2. 不改变现在的灯,让标准开关能打开我们的灯。标准接口我们改不了,灯也不能改。好在计算机界有句话,叫“加一层可以解决一切问题”。这让我想到了买外国电器附赠的那个电源接口转换器。现在,我们的灯需要个类似的玩意儿。

    • 好处:支持所有的灯。

    • 坏处:这东西都是要附赠的,会降低我们的利润。

     第一个方案很简单,就是让Light多实现个接口就OK了。图就不给了。

     现在分析第二个方案,标准接口依赖IStandardSwitchable接口,那我们必须有一个类来实现它,并完成所需要的功能——操作灯。咱也是学过设计模式的人,这个问题很明显可以用Adapter模式来解释。

     相关类图很容易就可以画出来。

clip_image002

图1 让灯支持IStandardSwitchable接口的方案

 

     其对应的代码会是这个样子:

    public interface IStandardSwitchable
    {
        void TurnOn();
        void TurnOff();
    }
 
    public class SwitcherAdapter : IStandardSwitchable
    {
        public Light Switchee { get; set; }
 
        public void TurnOn()
        {
            Switchee.TurnOn();
        }
 
        public void TurnOff()
        {
            Switchee.TurnOff();
        }
    }

代码1

     Job Done。Light通过SwitcherAdapter支持了新的接口,这简直就是应用适配器模式的典范啊。(嗯,这句的确是反话,不过你猜出来为什么这个Adapter不属于适配器模式吗?)

     “上回真是白跟你说了那么多,平时没觉得你这么不开窍啊。你自己好好想想吧!”背后看着我画UML图的设计Guru好像有点儿生气。

      上回?我冷静下来回想上回的内容和现在的问题。上回讲的DIP,讲不要依赖实现,要依赖抽象。再想想目前的需求,我们有灯,有收音机,如果用户说要用标准开关开收音机,难道还要实现一个RadioAdapter不成?这显然违反了OCP。

      需求是要“通过加一层让灯支持标准开关”,但是并不是说这一层就要使用灯,为了让这个Adapter更加通用,应该让Adapter依赖ISwitchable接口。像下面这个样子。

clip_image004

图2 Adapter模式

 

      与代码1的差别,仅仅是SwitcherAdapter里的Switchee属性的类型改成ISwitchable而已。代码就不再贴了。其所体现的原则就是上一篇讲的DIP。

      这个事儿其实任何人静静地想想都能想到。但我绕这个弯子,其实是想顺便表达这样一个意思:一个紧急需求来了的时候,人们更容易倾向于把完成工作放在第一位,从而一时忽视了设计的严谨度,事后又忘了重构,于是Bad Smell就这样产生了。当然,这些大家也都知道。

面向对象的设计并不是对现实的模拟

      (这一节算是一个插曲吧,因为这个论点太大,写出来都觉得不自量力,不写又觉得对不起自己爱得瑟的作风。一点拙见,大家多多批评。觉得偏题太远的话可以直接看一下节。)

       但是(重点来了),为什么紧张时做出的直观设计更可能是错误的呢?因为人一紧张就容易凭感觉,而使用直觉做设计时,大都会以现实世界为原本,但是良好的面向对象设计,是绝对不能仅仅依靠现实世界的。其实图1 的设计从直觉上来讲是符合需求,也很符合人们对这个世界的认知的。但是它并不是一个良好的面向对象设计。图2是相对良好的设计,但是图2显然又没有图1 那么直观,那么好理解,那么符合这个世界的真实状态

      图2和图1 的差别仅仅在于Adapter要依赖谁上,Adapter要依赖于ISwitchable接口这个事儿,并不是为了更真实地模拟这个世界,而纯粹地是为了解耦合而出现(或者说,为了依赖抽象)。但是在现实世界中,是不存在解不解耦合的概念的。解耦合是为了保证设计上的灵活性引入的概念。

        现实中事物间的依赖都是具体的是为了复用、灵活性等才引入的抽象,客观现实是不存在抽象的。抽象是要取决于你是如何看待客观事物的。举个例子,在动物学家看来,人与动物间有IS-A关系;但是如果你是要开发一款MMORPG游戏,人(NPC和Avatar)和动物(一般会是怪物)应该是不会有IS-A关系的。观察的角度不同,就会得出不同的设计;这些设计没有对错之分,只有是否满足需求之别。

     所以,有些地方,把面向对象的设计过程解释为对现实世界的模拟是很片面的。如果仅仅以现实世界的样子对系统进行设计,得出的设计很可能是僵化的,就像图1那样。(有人可能想说我曲解了人家的意思,但是我想说,你写成那个样子明明就是故意给人误解的,至少是很容易引导人误解。容易被误解,就是有问题。没什么好狡辩的。)

     但是,这并不意味着做设计就要全面地抽象,模拟现实世界的好处是代码容易理解,但是如果全部抽象成图2那样,所有都抽象出个接口,所有都依赖抽象,那代码的可读性显然会下降。所以,好的面向对象设计,会是真实地模拟现实与抽象现实间的取舍的过程。如果你看过一些功能相似、但实现不同的开源框架,会发现有些好理解,有些不好理解,其根本原因就是其抽象的层次或者说抽象的程度不一样。抽象度过高,灵活性也许上去了,但是并不见得就是好事儿。过度设计,就是因为对现实的抽象度太高,造成可读性差,不好维护,还没解决问题,就先被问题解决掉了。

     上面的例子可能依然没有什么说服力。我再举一个。上篇文章有人回复说,

    “开关里面还包含一个开关接口 ,很奇怪的方式。
     在我看来应该是灯光有开关”。

     我想感谢一下这位朋友,因为他提出的这个思路,我一开始就潜意识地无视掉了。经他一提,我才意识这也是设计过程中一类常见的问题。这个设计是一个很真实地反应现实的设计。但是并不是一个可行的类设计。如果你按这个方案写代码,就会发现很多问题。原因我已经回复了。

     总结一下,做面向对象设计的时候,请记得自己要做的是什么?不要让现实世界的“真实”的样子混淆了视听。面向对象设计,是以可复用地、灵活地实现需求为目标的,对现实的抽象,而不是对现实的模拟;抽象的结果很可能在现实中并不存在。

Adapter模式的关键

     Adapter模式最关键的要求是:Adapter是对两个功能相近的接口间的适配。如果被适配的对象是个具体类,那么多数情况下,Adapter非但不会带来好处,反而是仅仅增加了维护成本,就像前面说的,有一个新的具体类出现,就要同时添加一个Adapter。

     (如果你非说你见过很多 “适配”具体类的,你是对的,但是那叫Proxy,不叫Adapter,解决的也不是同一种问题,而且多数情况下,Proxy是可以自动生成的,所以不需要担心加一个类,就要自己实现一个对应的Proxy的问题。可以用下面这个图对比一下,来自《敏捷软件开发》)

clip_image006

图3 Proxy模式

 

      这不是在死抠Adapter模式的含义。因为只有理解Adapter的目标、适用范围之后,才不会误用这个模式。见过不少人理解力很好或是英文很好,看到Adapter这个词是个模式就想当然地觉得自己“知道”了这个模式的用法(毕竟这个模式也的确不复杂),并“用”了起来。比如图1的那个例子,就是最常见的误用之一。

     这也不是在死抠名词。给模式命名的好处之一就是让两个都懂模式的人沟通起来更顺畅。模式名所表达的,不是一个简单的类关系图,而是对要解决问题的类型的定位和解决问题的策略。

     Adapter,表示遇到的问题是接口不匹配。

     Proxy,表示遇到的问题是主体逻辑与附加逻辑(持久化、网络传输等)纠缠。

     名词用错了,就可能会带来不必要的误会。

     如果你就是觉得没必要死抠概念,下面的“广义Adapter模式”可能会比较适合你。

广义Adapter模式

     这年头好像什么东西都非要搞出个狭义和广义之分。我个人比较反感这一点,因为狭广之分的存在,本身就是一种对概念的模糊。这导致人们在沟通时,如果遇到问题,常常要想一下对方说的是广义的还是狭义的,而不是把焦点放在问题本身。这像是给自己和对方找借口或是后路。或许是因为大家都想给自己留个后路,这东西才会这么流行。附经典对白一则:

     “嗯?不对吧,不应该是XXXX吗?”

    “呃,我说的是一种广义上的XXXX。”

    “哦。(Shit!)”

     每个人们学习模式,总会有自己的理解,自己的抽象。当理解的角度不同的时候,就会把Adapter模式的内涵延展到不同的地方。这就导致了不同人对广义Adapter的定义是不同的。

     比如《敏捷软件开发》,从逻辑关系出发,把Adapter的概念延展为:使用一个特定的类,实现对方法调用的定向派发(我自己总结的,原文没这话)。从这个概念上讲,Adapter模式可以用于对具体类的适配。因为这个延展的概念实际上已经超出了原有的GoF的定义。这显然不能说是错误的,你甚至会觉得这个人水平真高,能对设计模式进行再抽象,再扩展。

     但是问题是,不同人对同一概念的延展方向是不同的。你觉得Adapter和Delegate/Event有什么相似之处吗?我相信更多人会觉得Observer模式与Delegate/Event的相似之处更多些。因为无数的人和书都说过C#的Delegate/Event机制就是Observer模式的一种具体实现。如果你面试的时候说,Delegation就是一种Adapter,你的面试可能就直接Pass了。这事儿也的确真实地发生过

     但是如果去看《Pro Objective-C Design Pattern for iOS》第112页,对Adapter的描述真的是这样的。

       “The Delegation pattern was once one of the inspirations for cataloging the Adapter

          pattern in the “Gang of Four” book.

      如果你怕我断章取义,可以自己去看。

      这个人是从类与类之间的关系出发,把具有相似结构、交互方式的类的组合都定义为Adapter。你说他的理解错了吗?我只能说:“狭义来讲,是错的,广义来讲,是对的。”但这是这个世界上最操蛋的答案之一。

      像上面链接的博客里描述的那个面试者,显然就成了广义与狭义之分的牺牲品——他说的是广义的Adapter,但是面试官想听到的是狭义的Adapter。(不过从后面的叙述来看,那个面试官也是半瓶子醋,问Delegate的时候居然会顺便问异步,让我不得不怀疑他是不是认为事件是异步触发的。)

      对Adapter有独特的理解很好,能把Adapter, Observer, Delegation, Proxy全统一起来理解更是NB。但是,其实在多数情况下,越是独到的见解,越可能会给面对面的沟通带来障碍。这些独到的见解在个人顿悟模式的过程中很有用,写到书里也很好,毕竟读者可以细细体味,帮助读者从不同的角度思考问题;但想在面试之类的当面沟通的场合上装逼,然后自己的口才又不咋地。怕只会画虎不成反类犬。

对Adapter模式的误用

      学历史的时候,常常见到“左派”、“右派”这样的词,意思是他们走的路线不对。这个词用得很形象,都是走极端。 模式的误用,常见的误用之一也是走极端。

      图2 的Adapter模式,成功的把标准的开关接口适配到了我们的接口上。于是便有了一个顺理成章的思路,ISwitchable和IStandardSwitchable接口都是对开关的定义,我们通过Adapter模式,让支持IStandardSwitchable的开关能够开我们的灯。

      那么我们之前的这个设计:

clip_image008

图4. 第一回中提出的开关开灯方案(Abstract Server)

 

      是不是应该改成这样?

clip_image010

图5. 试图把Adapter模式用于实现DIP

 

      这个设计相比原来的设计方案,抽象度更高、耦合性更低,Light甚至不需要依赖ISwitchable接口就可以工作,这样我们可以很有信心地说,我们可以让一切类都支持ISwitchable接口!

这个想法很丰满,但是现实很骨感。如果你认真看过了前面的内容,应该已经知道这个方案其实很烂的原因了。

      这个世界很微妙,《敏捷软件开发》(P370)的确就把图5称为Adapter模式,不过你应该懂的,他说的是广义的Adapter模式。并不是说对具体类的Adapter就一定是误用,如果没有违反OCP就不是误用,如果那个Light是个Utility类,就不算是误用。

     (如果你想喷Adapter模式本来就有两种,一种是基于类的,一种是基于对象的,你最好先去把Adapter概念回个炉,我们说的根本不是一码事儿。)

误用的原因

     我自己总结了一下出现这种误用的原因有三(这些原因会让人出现各种形式的误用,而不针对Adapter模式):

  1. 想当然地类推。像上面那样,从适配IStandardSwitchable可行,直接推出适配ISwitchable也可以,毕竟这是同样功能的接口啊。但是,不能这样类推。

  2. 妄图用同一个方式解决所有问题的想法或创造出一个work for everything的东西的想法。我直觉上就想用热力学第二定律来反驳这想法(和work forever差不多意思),不过“no silver bullet”可能更合适些。但是有些人,尤其是Level越高的,就越容易陷入这个泥潭。可能他们觉得不创造些NB的东西出来,就太对不起大家了。当然,这个想法是很好的,但是也要讲求方法,拿着锤子就看什么都是钉子的做法是要不得的。 参考十条不错的编程观点第一条就是,独立思考,妄图通过学习各种模式就可以应对一切设计问题的想法就是要不得的。还有一条让我印象很深的就是关于Google的使用,推荐大家也去看看。

  3. 对设计原则和设计模式的理解不透彻。如果真正理解了Adapter模式的意图、适用范围。是不会犯这样的错误的。但是很可惜,这个世界上的诱惑太多了,哪怕Wikipedia这样看似很权威的地方都在误导着别人(所以,自己思考,自己判断)。Wikipedia上对DIP的解释是这样的:“Applying the dependency inversion principle can also be seen as applying the Adapter pattern, i.e.”直译过来就是“遵循依赖倒置原则可被视同于应用适配器模式”。Oops…用了适配器模式,那的确是DIP了,但是适配器并不用来达到DIP这个目标的,适配器模式虽然DIP,但是如果用来现实DIP,效果却很糟糕,带来了更多 的问题。我猜作者的本意只是想表达:适配器模式本身是符合DIP原则的。这没错。但是我相信有一票人看到这里就去研究适配器模式并计划用它来实现DIP了。(有人嫌我啰嗦,我只是想把问题说清楚,让更多的人无可误解。)

        这里说的缺乏经验可能并不是工作年限不足的问题,更可能的是态度的问题,要么是对Adapter模式想当然、觉得自己在字面上的理解就差不多,要么是想对Adapter模式进行所谓的“活用”,结果犯了激进冒险主义错误。

下回预告

      我们的灯卖得好,用户就多了起来,需求也多了起来。这样一下子来了两个用户,一个要求,我要用两个开关控制同一个灯(床头一个,走廊一个,看来这用户晚上常起夜);另一个要求,我想用一个开关控制屋子里所有的灯(看来这用户不差钱)。

       那么,我们又需要做出怎样的设计来应对这些需求呢?

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!