用最简单的例子,从最简单的设计开始,重构着讲解设计原则与模式——从DIP中“倒置”的含义说接口的正确使用

前端之家收集整理的这篇文章主要介绍了用最简单的例子,从最简单的设计开始,重构着讲解设计原则与模式——从DIP中“倒置”的含义说接口的正确使用前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。

提纲

开灯的例子

选开灯做例子,是因为这个例子既常见又简单,而且潜在的需求多样。对于最简单的灯,从功能上讲,按下灯上的开关,灯就开了。

代码实现这样一个有开关功能的灯,也是一件很容易的事情。

public class Light
{
    void TurnOn() { Console.WriteLine("Light Turn On"); }
    void TurnOff() { Console.WriteLine("Light Turn Off"); }
}

代码1

一个具有开关功能的灯就完成了。这个灯,功能完备、也满足当下的需求。一切美好。

直到有一天,有个客户说,灯上的开关坏了,能不能换一个?我才意识到这个灯的设计有问题——它的开关是换不了的。一面给用户解释,一面考虑着把灯和开关分开。

咱也是学过设计模式的人,知道要面向接口编程,绝不应该简单地把Light类拆解成Light和Switcher两个类。因为Switcher不应该依赖于具体实现,于是写出了下面的代码

 
 
namespace Me.Lighting
interface ILightable
    {
        void ShowLight();
        void HideLight();
    }
class Light : ILightable
    {
void ShowLight() { Console.WriteLine("Light Turn On"); }
void HideLight() { Console.WriteLine("Light Turn Off"); }
    }
}
namespace Me.Switch
{
using Me.Lighting;
 
  
class Switcher
public ILightable Light { get; set; }
void TurnOn() { Light.ShowLight(); }
void TurnOff() { Light.HideLight(); }
}

代码 2

这个设计,不仅分离了灯和开关,甚至可以让这个开关灵活地控制要开关哪个灯。只要在开关前设置一下就可以,多方便。我自信满满地迁入了代码

事实也证明这样的设计是成功的,产品的灵活设计得到了用户的认可,销量直线上升。

亲,请看下代码,在不使用什么别的设计模式的前提下,您觉得代码2有什么问题?无论是什么角度的都可以(当然,可能您的角度不是本文讨论的重点),最好先回复下留个底,别事后诸葛。

如果您一眼看到了问题,请直接阅读DIP那一节。

暗流涌动

公司壮大之后 ,开始考虑向收音机行业进军。而且公司希望,这种灵活的设计可以沿用下去,收音机和灯的开关应该可以通用,对用户而言,都是拨那么一下。

我听到这个信息也是相当兴奋,但是当我开始着手写代码时,发现一些坏味道,开关依赖于ILightable 接口,那么我的收音机不得不写成这个样子才能与现有的开关兼容。

class Radio : ILightable
{ 
"Play radio"); }
"Stop radio"); } 
}

代码3

虽然可以工作,但是这是严重的坏味道。因为如果有一天,灯的接口变化,我却要连收音机的代码一起改。这种情况绝不应该出现。且不用把LSP(Liskov替换原则)搬出来说教,很显然Radio其实并没有完成ILightable所定义的功能——发光。无论从哪个角度讲都是错的。

一个可行的设计是,让开关支持收音机的开启和停止。像下面这样。

namespace Me.Radio
interface IRadio
void Play();
void Stop();
class Radio : IRadio
void Play() { Console.WriteLine("Play radio"); }
void Stop() { Console.WriteLine("Stop radio"); }
using Me.Radio;
 
  
class Switcher
public ILightable Light { get; set; }
public IRadio Radio { get; set; }
void TurnOn()
        {
            if (Light != null) Light.ShowLight();
            else if (Radio != null) Radio.Play();
        }
 代码4

我看来看去都觉得这个代码太恶心了,因为Switcher的实现方式违反了OCP(开放—封闭原则),如果这样发展下去,公司的产品越丰富,这坨代码就越难以维护。我的末日也就越近。

于是我的考虑Switcher的设计是不是有问题,我已经用上面向接口编程了,为什么还是有问题呢?

Guru眼中的依赖

我把代码发给了我的导师,一个设计Guru,他看完之后哭笑着说,你的基本功很扎实,理论知识也很全面,可惜却缺乏一定的经验。面向接口编程没有错,但是更重要的是模型的建立。

简单而言,你的开关的依赖关系错了。问你一个问题你就明白了,开关为什么要依赖ILightable呢?但是好在你有一定的设计基础,知道要提取出一个接口,所以要改成正确的设计也非常容易。你只需要把ILightable这个接口的名字改成ISwitchable,再把接口方法名字改下,并把它与Switcher放一起就行了。

听罢,我恍然大悟。原来接口的名字和位置,也会给使用者带来如此大的困扰。在先进的开发工具的帮助下,瞬间就完成了这个简单的重命名和移动操作。现在的代码像这个样子了。

using Me.Switch;
class Light : ISwitchable
namespace Me.Radio
using Me.Switch;
class Radio : ISwitchable
"Play radio"); }
"Stop radio"); }
}
namespace Me.Switch
interface ISwitchable
    { 
void TurnOn();
void TurnOff();
public ISwitchable Switchee { get; set; } 
void TurnOn() { Switchee.TurnOn(); } 
void TurnOff() { Switchee.TurnOff(); }
 代码5

注意:这个代码与之前有问题的代码2,只是各种名称上的变化。结构上一点儿没变。

以后有新的产品,也只需要实现ISwitchable接口,就可以支持这个开关了。之前的失败设计,看似与这个设计相差无几,但是其中蕴含的设计思想天差地远,也正是在这种地方,才更能体现出设计师间的差距。这一种设计所体现的,即是DIP(依赖倒置原则),的表现之一,接口应当被其使用者所拥有,而非其实现者。@H_161_301@1

DIP(依赖倒置原则)

具体问题解决了,还需要把整个问题抽象一下,从本质上了解一下DIP的含义。(我会尽量清楚,可能会有些啰嗦,但这比在回复里争论要舒坦得多。)

假设有如下所示的类图。假设我们要把这种关系解耦合。

图1

注:图1中的User表示使用者(调用者),而不是用户的意思。

为什么要解耦合?

我说“假设要解耦合”,是因为在尝试解耦这种依赖关系之前,应该先确定有没有解耦的必要。这种关系在代码中比比皆是,如果把所有的依赖都解耦,不仅工作量大、带不来任何好处,而且引入了不必要的复杂度,最终演变成了过度设计,增加了编码成本和维护成本。(我已经被人骂怕了,怕不说清楚这一点,总要有人跳出来说我滥用模式,说这种关系要不要解耦要看情况,云云。都是好意,我也心领了,谢谢。但被人假设狗屁不通,总不太舒服。)

明确某个依赖关系是否需要被分解,是一件很复杂的事情,个人觉得并没有什么准则能让你轻松地做出这个判断。因为几乎所有的依赖,在一句经典的“我以后可能会换一种方式实现它”面前,都变得似乎需要被解耦。这种理由,听上去合理,其实是狗屁。换一种方式实现它,并不意味着要用一个接口来抽象它,接口是用来抽象并解耦依赖关系的,应该被用在:同时存在多个实现、实现未知或需要模块化的情况下(还有一种情况,是方便多人开发时工作内容的解耦,但我还没有想明白,引入接口来达到这个目的是否合适:因管理需要导致的复杂度上升。所以先不讨论这种情况)

具体解释一下,“同时存在多个实现”的意思。以IComparable接口为例,很多数据类(比如DTO)大都实现了这个接口,因为上层的功能(比如排序)依赖类的对象有相互比较的能力,同时每个类的实现方式又都不一样,即所谓的同时存在多个实现。

所以,对于需要“换一种方式实现它”的情况,大可以把原来的代码删除然后重新写一个。

有句话叫“拿着锤子,看什么都像钉子”。了解一项技术,不仅仅要了解他能做什么,更要了解这个技术适用在什么地方。所以千万别今天听了解耦的概念觉得很前卫,第二天就去把所有的类都提取出个接口。多数情况当然不会这么夸张,但滥用其实就在一念之间。

接口的坏味道

我承认,上面解释也许正确,但没什么用。懂的人懂,不懂的还是不懂;所以我还是举些接口有问题的坏味道吧。

最常见的接口坏味儿包括:(注意,总可以找到反例,所以一开始就说了,没有准则,总要具体问题具体分析,但是如果使用接口的原因是如下几种之下,我觉得应该再仔细考虑一下)

  1. 为了提取出某一个类所提供的Public方法。接口应该用来抽象依赖,而不是抽象实现。后面再解释。你想知道或控制一个类有哪些Method的方法有很多,但是引入一个接口,不仅达不到你的目的,还引入了复杂度——每当你要加一个方法,都要修改两个地方,一个是接口,一个是实现。

  2. 接口抽象出来了,但是和实现放了一起,或者根本没用到这个接口。比如,如果你写出了:

    Interface f = new Implementation();

    这样的代码,而且这个接口只被这样用过,那或许需要考虑一下使用这个接口的用法了。我并不是指你需要一个依赖注入的框架。但是这至少看上去不太对劲,像是为了使用接口而提取出了这个接口。

  3. 接口中包含了互不相关的方法。如果某个方法出现在这个接口里会让人觉得惊讶,那这个接口就是有问题的。不能因为有两个以上的类都有这个方法,所以就提取出来了。要看这两个方法有没有关系,还要看上层是不是一定会同时依赖这两个方法使用者使用接口中的方法时,应该全部都用得到。如果没全用到,可能需要考虑一下这个接口划分的是否合理?的粒度是不是太粗了?还是把接口当成了Common Service Host来用了?

同一张类图的不同解释——真假DIP

扯得有点儿远了。回来继续正题,考虑如何把User和Implementation解耦合。所有人都知道,解耦的方法是:

  1. 定义接口I
  2. Implementation实现接口I
  3. User使用接口I,则不是Implementation。

这个描述已经很细了,而且画出来的类图也是唯一的。但是很可惜,这个描述是不明确的,有歧义的。

代码2和代码5都符合这个描述,但是其实是不同的设计。用图来描述会更清楚一些。

图2

图3

或许有人一看到学术派的设计图就兴奋起来,一眼就看出有一个设计是有问题的。但是当你看到代码2时,你有一眼看出问题吗?到你自己的项目代码中,你能一眼看出问题吗?问题总是出现在“混乱”中,简化成图2、图3这样,只要知道DIP的人,恐怕都能看出问题。但到项目中,那就是另一回事儿了。就像多数人都很鄙视国家组织的“软考”,考得再好,也不表示有相当的设计水平。这种简化了的问题和考题一样,也许能明白,但是能在该用的时候记得用,并不是个容易的事儿。

我来解释一下,其中根本的区别在于谁依赖谁。至于谁持有接口,只是表象。从逻辑上,调用方很明显地依赖着实现方,因为实现方才是功能的实现者,没有实现方,调用方就工作不了。但是在图3的设计中,其设计意图是,实现方要实现的功能,由调用方来决定,而不是实现方实现了什么,调用方就用什么。也就是说,要让实现方依赖调用方。这,就是DIP(依赖倒置原则)的含义。其具体表现就是,调用方定义并持有接口。

从概念上来讲,DIP的定义如下2

  1. 高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。
  2. 抽象(Abstractions)不应该依赖于实现(details),实现应该依赖于抽象。

目前在网上找到的对DIP的解释,多数都停留在第一项,即模块依赖抽象上,都没有解释清楚“倒置”这个词的含义。希望本文中的图2和图3解释清楚了“倒置”的含义。从概念上来讲,“抽象不应该依赖于实现”,就是要求“倒置”。因为如果像图2那种思路,从实现中抽象出接口,那么这个接口就是依赖于实现的。重复一下之前说过的:接口,应该是对依赖的抽象,而不是对实现(底层功能)的抽象,这就是所谓的倒置。(这里的依赖的含义是,调用者所需要的功能,而不是实现者实现了的功能。)

另外,还是这个类图,还有一种常见的组织形式。像下面这样。

图4

从箭头的方向上来看,这个更倒置。但是模块的细分,箭头方向的颠倒,并不意味着这个设计真的是倒置的。这要取决于抽象层中的接口,是与图2中的接口定位一致呢?还是与图3中的接口定位一致?单纯地把接口放在抽象层里,就和单纯地定义一个接口,却没有地方用到它一样没有意义。

所以说,清楚地表达一个设计,并能让人确切地明白你的设计。其实是一件非常不容易的事情。可能把UML的所有功能都用上,才能做到这一点。仅仅画个框框、线线、写俩字儿,是很容易让人误会的。开会的时候有人解释着还好,如果写出的文档如果是这样,对新手而言还不如没有,因为基本上一定会被误解。

了解DIP有什么用?DIP能用在什么地方?

我猜不少人看到这里会很想问,知道“倒置”到底是什么意思有个鸟用?有好的创意去开发项目才是正经事儿,把项目按时保质地做出来才是正经事儿,老子按时下班才是正经事儿。

首先,我非常同意!然后,回答这个问题,这个每个人的个性使然。就像天天研究吃什么健康有个鸟用?中国的食品安全都保证不了,还健康?!但是就是有人就好这口,不是么?而且,我在这里只是解释DIP,也并没有说做的项目里,都要符合DIP啊。项目管理和架构是很灵活的,不是几个P就可以规范的起来的。有时候,直接找个开源的产品一搭,多快好省,一个P也用不着。如果非要给出个理由,我想恬不知耻地说句,追求卓越。(好吧,根本原因是,我喜欢得瑟,但是又不喜欢被明白人骂成猪头,所以我选择先搞明白了再去得瑟。)

但是我还是要说说了解这个原则的好处,不然写这文章不是打自己脸么?了解依赖倒置的意义,并不限于设计,还在于思想上的转变。理解这个原则之后,你会发现自己明明已经把这个原则用上了,比如做需求分析的时候,肯定是问用户想要什么,而不是我们能做到什么。

这个原则在协作上也有用处。请回想一下,在工作中,是否遇到过上层开发人员等下层开发接口的情况呢?如果遇到过,当时有没有想过,这个依赖关系是不是反了呢?其实,应该是下层模块的开发者依赖上层开发者呀。上层开发者定义好他依赖的接口,下层开发者来实现,同时,因为接口已经定义好了,上层也不用等下层开发者,完全可以用些Mock框架进行测试嘛。但是,如果让下层开发者定义接口,显然上层开发者就必须等,Mock类也写不了。

关于这个原则,我还见到过更广义,更天下大同的解释。在客户关系上,我们常见的依赖是开发者依赖客户,客户说什么我们就得做什么,一点主动权都没有。于是有人就把依赖倒置的原则拿来,说,应该让客户依赖开发者!大有,“我们说什么,客户就听什么!”的派头。到底哪个依赖是倒置的我就不在这儿争了,因为我觉得这完全不是依赖的方向性问题。而是店大欺客还是客大欺店的问题。如果你在IBM、在SAP、在四大,你可以让客户听你的。如果你在一个小屁公司,或者客户是政府部门,你倒置个试试?

猜你在找的设计模式相关文章