0
点赞
收藏
分享

微信扫一扫

[5+1]迪米特法则(二)

前言

面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。

[5+1]迪米特法则(二)_面向对象

↑ 五个,再加一个,就是5+1个。哈哈哈。↑


这六个设计原则的位置有点不上不下。论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。


所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。



迪米特法则

关于是什么、为什么和怎么做,请看​​上集​​。



迪米特法则与面向对象

全能型人才在整个人类历史上都十分抢眼。中国有思想、教育、文学、政治、军事一把抓的圣贤;外国有头顶神学家、物理学家、数学家、炼金术师、哲学家一众头衔的牛人。近几年的诺贝尔奖得主也不乏生物、化学、计算机的跨界人才。最近还有人抛出观点,认为交叉学科、交叉领域会是下一个学术和经济的爆发点。


推己而言,我绝不愿意当一个“单细胞动物”,绝不愿意在一个领域内闭目塞聪、固步自封。由此及人,我相信大多数人都愿意活得丰富多彩一些。无论是全栈工程师,还是“斜杠青年”,甚或是“芦·猫之使徒·哮喘征服者·笛”,都是人生的多样性。

[5+1]迪米特法则(二)_面向对象_02

↑如果没有多样性,生命抗不过五次大灭绝。↑


所以,如果说面向对象是在模拟现实世界,那么,“迪米特法则”是在模拟什么呢?


虽然在生活中追求丰富多彩,但是在工作上,我还是更愿意专岗专责、专人专事。而这里的“专”,不仅指“专业”、“专精”,也指“专注”、“专心”。而无论是哪一“专”,都要求我们心无旁骛地深入到一件事情中,必要时主动地切断或隔绝一些不必要的影响。老话说:人不能同时踏入两条河流。神经科学研究表明:人类大脑可以轻松地同时处理3到5件事时,但同时5到7件事时就比较吃力了。这些都告诉了我们:与“博”相比,“专”更能提高效率。


这一点在工业实践中已经得到了证明。流水线上的每一个环节都只需专注于自己的动作,而不用关注上下游的工作,因此工作效率得到极大提升,流水线的可移植性、可复用性也大大提高。标准件、通用件只需专注于自己的标准,而不用关注零部件的最终用途,因此零部件的可移植性高、生产线的可复用性也大大提高了。


这就是迪米特法则在做的事情:让一个类尽可能少与其它类产生关联、从而尽可能少的受其它类的影响,进而尽可能地让这个类“专”于自己的职责。


诚然,这么做有点“反人类”:我们都看过卓别林的《摩登时代》,都痛恨这种对人的“异化”。但是,不从做人、而从做事的角度来看,“专”确实是一种做事的好办法。

[5+1]迪米特法则(二)_SOLID_03

↑这样做人很痛苦,但是这样做事确实快。↑


迪米特与抽象

迪米特法则与抽象设计是一对合作无间的好战友。


我们设计抽象的目标就是隐藏实现细节,不让调用方了解、接触到它们。而迪米特法则恰恰告诉调用方“少管别人的事”。上哪儿找这么好的伙伴去?


想象一下,你春节回家,正犯愁要怎么应对亲戚们问对象问工作呢。谁知亲戚们一进门,你妈就告诫他们:管好你们的嘴啊,只许吃东西不许瞎比比。你难道不应该拥抱一下妈妈么?

[5+1]迪米特法则(二)_迪米特法则_04

↑拥抱妈妈吧。她永远爱你。↑


妈妈可以维护你的私人边界;迪米特法则可以维护你的抽象边界。拥抱完妈妈之后,是不是也可以拥抱一下“迪米特”女神呢?


迪米特与高内聚低耦合

由于迪米特法则是对调用者的要求,因此它与高内聚没什么直接关系,而对低耦合有莫大的帮助。


我们知道,耦合有很多种类型。这些耦合几乎都是因为调用方知道了服务提供方的某种实现细节而引起的。如果调用方知道了服务方的代码实现——例如从服务方复制了一份代码,就产生了内容耦合。如果调用方知道了服务方的某种数据结构——例如Dubbo的客户端jar包所做的那样,就产生了外部耦合。当然,代码层面的“知道”并不只是“依赖”,本质上是指“使用”。


迪米特法则告诉我们说:“不要和陌生人说话”、“只拥有与其关系最密切的那部分模块的知识”。这就可以帮助我们降低模块间的耦合。


例如,我们有这样一个类:

public class SomeResult{
private SomeData data;
}


public class SomeData{
private SomeInfo info;
}


private SomeInfo{
private SomeStateEnum state;
}
如果我们要判断返回结果中SomeInfo的状态,显然就不得不这样做:
SomeResult result = ....;
if(result!=null && result.getData()!=null
&& result.getData().getState()!=null){
switch(result.getData().getState()){
case: STATE_A:
break;
case: STATE_B:
break;
}
}


这段代码明显违反了迪米特法则;同时,它也带来了一种比较强的耦合:外部耦合。外部耦合会带来什么样的问题,这里不做赘述,可以参考《》。


针对外部耦合,我们可以这样优化代码:

public class SomeResult{
private SomeData data;
public static SomeStateEnum getInfoState(SomeResult result){
return Optional.ofNullable(result)
.map(SomeResult::getData)
.map(SomeData::getState)
.orElse(null);
}
}
SomeStateEnum state = SomeResult.getInfoState(result);
if(state!=null && ){
switch(state){
case: STATE_A:
break;
case: STATE_B:
break;
}
}


这样一来,调用方就只知道SomeResult类,而不知道SomeData类,更不知道两个类的数据关系了。外部耦合自然也就不见了。


迪米特与封装继承多态

一个好的抽象必须做好封装。迪米特法则从调用方的角度促使服务方设计一个好的抽象。显然,迪米特法则同样也要求服务方做好封装。


可是,迪米特法则提出的封装要求常常会被我们忽视。这个原因,可能需要追溯到“充血模型”和“贫血模型”分道扬镳的那一刻。

[5+1]迪米特法则(二)_迪米特法则_05

↑这一刻,堪称面向对象的“人猿相揖别”。↑


当迪米特法则于1987年面世的时候,它面对的还是“充血模型”。因此,拿到一个对象,不仅会拿到它的数据、还会获得它的行为。而后者恰恰是调用者不想了解、往往也不应该了解的。


谁知,随着“贫血模型”在2000年前后的大斧一挥,“轻清者上浮为”Dto、“重浊者下沉为”Service了。我们把Service对象委托给了容器(例如Spring)管理,只把Dto对象的创建和管理留给了自己。


从此,迪米特法则目光所及多是Dto,而非Service。顺带一提,似乎面向对象的所有法则——从抽象、高内聚/低耦合到设计原则、设计模式——从此都只关注Service,而把Dto抛到九霄云外了。


这样一来,除了个别显而易见的烂代码之外,调用方只会从容器中获取Service对象,而不会从另一个对象中获取。从迪米特法则的角度来看,这些Service对象都是调用方的亲密战友能。


而Dto呢?除了个别精心设计过的代码之外,大部分调用方都需要从一个Dto中获取另一个Dto,如此嵌套往复循环。如果好朋友会告诫你“不要和陌生人说话”,那这些Dto就是悄悄往你家带陌生人的“塑料姐妹花”。


随着领域模型热度再起,贫血模型也许会再受质疑。把数据和行为剥离开来,就像把UI和UE剥离开一样。服务调用方就像用户一样:不管你怎么做、也不在乎你找谁做,只要点一个按钮就能得到想要的东西,这才是好的体验。


至于继承和多态,那属于“你怎么做”和“你找谁做”的范畴,迪米特法则和用户一样,不关心。


迪米特法则与其它设计原则

迪米特与单一职责原则

迪米特法则与单一职责原则之间,似乎没有什么明确的关系。


迪米特与开闭原则

虽然与单一职责原则没什么关系,但是迪米特法则与开闭原则之间确有显而易见的关系:遵循迪米特法则的同时,我们的代码也会更加符合开闭原则。


例如,我们都知道,在调用三方系统接口时,不要像下面这样,用枚举来声明接口返回字段:

public class RespVo{
private TypeEnum type;
}
public enum TypeEnum{
TypeA,
TypeB,
TypeC
}


这种接口返回字段的设计,有一个明显弊端:如果服务提供方为type字段增加了一个枚举,而服务调用方没有及时将这个新枚举值添加到TypeEnum中,就有可能出现反序列化异常。


显然,这种设计违反了开闭原则。不仅如此,尽管这里并没有什么链式调用,它也违反了迪米特法则。这个RespVo不仅知道“接口响应中有个type字段”,而且知道“type字段个枚举”。


“type字段是个枚举”这句话,可大大超过了“与其关系最密切的那部分模块的知识”的范围。绝大多数情况下,RespVo只需要知道自己关心的若干type取值,而并不需要知道除此之外还有哪些值、以及这些值是否构成了type值的全集。这可不就违反了“最小知识法则”么?


如果我们把代码改成下面这样,就可以既循迪米特法则、又符合开闭原则的要求了:

public class RestVo{
private String type;
public boolean isTypeA{
return "TypeA".euqlas(type);
}
}


其实,即使不用代码举例,只需要用常识想想,我们也应该能清楚地看到迪米特法则与开闭原则之间的关联。俗话说的“船小好调头”,不就是说“越是遵循迪米特法则、就越是符合开闭原则”么?经济学上的“沉没成本”、心理学中的“惰性思维”,不都是“越是违反迪米特法则、就越是不符合开闭原则”么?

[5+1]迪米特法则(二)_迪米特法则_06

↑小船吃水浅,自然好调头。↑


面向对象思想与现实世界之间的关联,由此也可见一斑。


迪米特与里氏替换原则

迪米特法则与里氏替换原则的关系,和它与开闭原则的关系恰好相反:如果我们遵循了里氏替换原则,那么我们就离迪米特法则更近了一步。


个中道理非常简单。里氏替换原则要求我们编写的父类可以安全地替换为子类。所谓“安全地”替换,起码应当让调用方感知不到。如果调用方连子类父类都感知不到,自然也就无法获得“调用的是子类还是父类”这种知识了。既然调用方不知道这个知识点也可以正常调用服务,说明子类还是父类不属于与调用方“关系最密切的那部分模块的知识”。不知道这部分知识,说明这段代码遵循了迪米特法则的要求。


我这里有个反例。当我们的代码中需要使用泛型,并根据泛型的实际类型把数据转发到不同流程上去时,我们的系统中常常能看到这样的代码:

public class Dispatcher<T extends Dto>{
private Map<Class<? extends T>, Service<T>> serviceMap;
public void doDispatch(T dto){
serviceMap.entrySet()
.stream()
.filter(e->e.key().isInstance(dto))
.map(e->e.value())
.findFirst()
.ifPresent(s->s.proc(dto));
}
}
public class Dto{
}
public class SubDtoA extends Dto{
}
public class SbuDtoB extends Dto{
}


这样的代码看起来很不错。但是,如果我们为SubDtoA增加一个子类,这段逻辑就有问题了:

public class SubDtoA1 extends SubDtoA{
}
public class Dispatcher<T extends Dto>{
private Map<Class<? extends T>, Service<T>> serviceMap;
public void doDispatch(T dto){
serviceMap.entrySet()
.stream()
.filter(e->e.key().isInstance(dto))
.map(e->e.value())
.findFirst()
.ifPresent(s->s.proc(dto));
}
}


这个问题既可以归咎于代码没有遵循里氏替换原则、也可以归因于代码没有遵循迪米特法则。从里氏替换原则的角度来说,由于直接指定了子类类型,因此Dispatcher类无法安全地用子类来替代父类。从迪米特法则的角度来说,“直接指定了子类类型”就导致Dispatcher类掌握了它本不该了解的知识,因此,它也无法对“新增一个子类”开放了。


按照里氏替换原则和迪米特法则来改写之后,这个问题就迎刃而解了:

public class Dto{
protected TypeEnum type;
public Dto(){this(TypeEnum.Father);}
public Dto(TypeEnum type){this.type=type;}
}
public class SubDtoA extends Dto{
public SubDtoA(){super(TypeEnum.A);}
public SubDtoA(TypeEnum type){super(type);}
}
public class SbuDtoB extends Dto{
public SubDtoB(){super(TypeEnum.B);}
}
public class SbuDtoA1 extends SubDtoA{
public SbuDtoA1(){super(TypeEnum.A1);}
}
public class Dispatcher<T extends Dto>{
private Map<TypeEnum, Service<T>> serviceMap;
public void doDispatch(T dto){
serviceMap.get(dto.getType()).doService(dto);
}
}


迪米特与接口隔离原则

接口隔离原则其实就是迪米特法则在接口设计层面的一种实践方式。


从接口设计角度来说,一个接口暴露的方法,也就是它向调用方提供的知识:这个接口不仅能做这件事,还能做那件事。


接口隔离原则恰恰要求我们不要把无关的方法放到同一个接口上。换句话说,接口不要把不该透露给调用方的知识透露出去。


这不就是迪米特法则所要求的:让调用方只知道与其密切相关的、有限的知识么。


迪米特与依赖倒置原则

与接口隔离原则类似,依赖倒置原则也可以说是迪米特法则在依赖管理方面的一种实践。


依赖倒置原则要求模块代码之间不直接产生依赖关系,而由中间层或胶水层来管理这种依赖。在这一规则下,调用方只知道如何接入另一个模块,而不知道在另一个模块中,到底是哪个类用怎样的代码来处理的。


这不就是迪米特法则所要求的:让调用方只知道与其密切相关的、有限的知识么。

[5+1]迪米特法则(二)_面向对象_07

↑没错,就是这样。↑


这么说来,虽然我没用过(甚至可能没听过)迪米特法则,但我用过spring,所以我已经用过迪米特法则了。


我在面试时时常遇到这样的说法:我不了解这项技术/设计思想,但我写代码时肯定用到过它。


的确会有这种情况:经验总是比知识领先一步。在懂得浮力定理之前,我们就知道用木头造船了。在懂得杠杠原理之前,我们就知道用桔槔汲水了。时至今日,我们能够研制出一些高温超导体,却仍不知道高温超导的原理;我们知道重启能够解决Windows电脑的大部分问题,却仍不知道重启时电脑内部都发生了些什么。


知识有着经验无法比拟的优势。最显著的一点是:经验往往局限于曾经经历过的情况,而知识可以应对从未见过的新问题、开创新的局面。面对黑暗,经验告诉我们多点几支蜡烛,知识却可以给我们带来电灯。面对通信,经验告诉我们要八百里加急,知识却可以给我们带来电报、电话和视频聊天。面对天空,经验告诉我们要振动翅膀才能飞翔,知识却可以给我们带来飞机、火箭和空间站。

[5+1]迪米特法则(二)_迪米特法则_08

↑不懂空气动力学,达芬奇也只能设计出这个。↑


世界日新月异,新问题、新局面层出不穷。如果满足于经验、不汲取和探求知识,我们永远会慢人一步,甚至可能逐渐陷入死胡同、最终被时代抛弃。抛开宏大叙事不谈,在心理学上,也有“将下意识的反应转化为有意识的自觉,从而获得自我的觉醒和成长”的观点。


落到软件研发领域上,当我们需要和产品争论需求方案时,当我们需要和其他研发进行代码评审时,当我们需要向领导汇报系统架构时,是“我觉得这样比较好”、“我们一直都是这样做的”比较有说服力,还是“你的方案不符合xxx设计法则”、“这两个模块之间存在内容耦合”、“考虑到xxx原则,我们选择了这样的架构方案”更有说服力呢?


如果根本就不了解这些知识,如果只是凭借经验开展工作,我们要怎么保证能按自己的经验开展工作,而不被别人的强势和歪理牵着鼻子走呢?


往期索引

​​《面向对象是什么》​​



从具体的语言和实现中抽离出来,面向对象思想究竟是什么?



公众号:景昕的花园​​面向对象是什么​​


《​​抽象​​》


抽象这个东西,说起来很抽象,其实很简单。


花园的景昕,公众号:景昕的花园​​抽象​​


《​​高内聚与低耦合​​》

《​​细说几种内聚​​》

《​​细说几种耦合​​》


"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。


花园的景昕,公众号:景昕的花园​​高内聚与低耦合​​


《​​封装​​》

《​​继承​​》

《​​多态》​​



——“面向对象的三大特性是什么?”

——“封装、继承、多态。”




​​《[5+1]单一职责原则》
​​



单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。



花园的景昕,公众号:景昕的花园​​[5+1]单一职责原则​​


​​《[5+1]开闭原则(一)​​》

​​《[5+1]开闭原则(二)​​》



什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。

什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。



花园的景昕,公众号:景昕的花园​​[5+1]开闭原则(一)​​


​​《[5+1]里氏替换原则(一)​​》

​​《[5+1]里氏替换原则(二)​​》



里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另一位女性计算机科学家周以真(Jeannette Marie Wing)合作发表论文,正式提出了这条面向对象设计原则



花园的景昕,公众号:景昕的花园​​[5+1]里氏替换原则(一)​​


《​​[5+1]接口隔离原则(一)​​》

《​​[5+1]接口隔离原则(二)​​》



一般我们会说,接口隔离原则是指:把庞大而臃肿的接口拆分成更小、更具体的接口。

不过,这并不是接口隔离原则的定义。实际上,接口隔离原则的定义其实是这样的……客户端不应被迫依赖它们压根用不上的接口;或者反过来说,客户端应该只依赖它们要用的接口。




花园的景昕,公众号:景昕的花园​​[5+1]接口隔离原则(一)​​


《​​[5+1]依赖倒置原则(一)​​》

《​​[5+1]依赖倒置原则(二)​​》


在Java世界里谈到依赖倒置原则,相信90%的人都会立即想起SpringIOC;还有9%的人会想起“面向接口编程”。最多只有1%的人能想起依赖倒置原则的真正定义。


花园的景昕,公众号:景昕的花园​​[5+1]依赖倒置原则(一)​​



《​​[5+1]迪米特法则(一)​​》


迪米特法则可以用一句话概括:Only talk to your friends。

 “只和你的朋友说话”,这是1987年的表述。2003/2004年左右,Karl Liebertherr对迪米特法则做了一次升级:由“Only talk to your friends”升级为了“Only talk to your friends who share your concerns”——“只和与你同忧同乐的朋友说话”。


花园的景昕,公众号:景昕的花园​​[5+1]迪米特法则​​


[5+1]迪米特法则(二)_面向对象_09

举报

相关推荐

0 条评论