如何实现“最少知识”代码?

在实际的软件开发中,我们经常会写下面这样的代码:

ini 复制代码
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

代码看上去没有太大的问题,但实际上其中任意一个方法发生变化时,这段代码都需要修改。因为调用是依赖的每一个细节,不仅增加了耦合,也使代码结构僵化。

迪米特法则正是为了避免对象间出现这样过多的细节依赖而被提出来,所以今天我们就来一起了解下迪米特法则带给我们的一些启示。

1、什么是迪米特法则

迪米特法则(Law of Demeter,简称 LoD) 是由 Ian Holland 于 1987 年提出来的,它的核心原则是:

  • 一个类只应该与它直接相关的类通信;
  • 每一个类应该知道自己需要的最少知识。

换句话说,在面向对象编程中,它要求任何一个对象(O)的方法(m),只应该调用以下对象:

  • 对象(O)自身;
  • 通过方法(m)的参数传入的对象;
  • 在方法(m)内创建的对象;
  • 组成对象(O)的对象;
  • 在方法(m)的范围内,可让对象(O)访问的全局变量。

在分层架构中,每一层的模块只能调用自己层中的模块,跳过某一层直接调用另一层中的模块其实就是违反了分层架构的原则。

虽然迪米特法则符合封装的原理,但是却和聚合原则相冲突,因为对象需要尽可能少地考虑其他对象,不能轻易地和其他对象自由组合。

如何实现满足迪米特法则的代码

下面我们通过一个经典案例来看下如何去实现满足迪米特法则的代码。

假设一个在超市购物的场景:顾客选好商品后,到收银台找收银员结账。这里我们定义一个顾客类(Customer)、收银员类(PaperBoy )、钱包类(Wallet ),示例代码如下:

typescript 复制代码
//顾客
public class Customer {
    private String firstName;
    private String lastName;
    private Wallet myWallet;
    public String getFirstName(){
        return firstName;
    }
    public String getLastName(){
        return lastName;
    }
    public Wallet getWallet(){
        return myWallet;
    }
}
//钱包类
public class Wallet {
    private float value;
    public float getTotalMoney() {
        return value;
    }
    public void setTotalMoney(float newValue) {
        value = newValue;
    }
    public void addMoney(float deposit) {
        value += deposit;
    }
    public void subtractMoney(float debit) {
        value -= debit;
    }
}
//收银员
public class Paperboy {
    public void charge(Customer myCustomer, float payment) {
        payment = 2f;
        Wallet theWallet = myCustomer.getWallet();
        if (theWallet.getTotalMoney() > payment) {
            theWallet.subtractMoney(payment);
        } else {
            //钱不够的处理
        }
    }
}

这里我们看一下当顾客开始进行结账操作(charge())时,整段代码的具体实现逻辑是怎样的。

  • 收银员算出商品总价后说:"拿出钱包交给我!"
  • 顾客将钱包递给收银员,收银员开始检查钱包里的钱是否够支付。
  • 如果钱够了,收银员拿出相应的钱并退还钱包给顾客;如果钱不够,收银员告诉顾客钱不够需要想办法。

虽然这个场景在现实中不会发生,但是对于这里的收银员类 PaperBoy 来说,直接调用 Wallet 显然是不满足迪米特法则的,因为 Wallet 是属于 Customer 的内部成员------组成对象(O)的对象。

当我们理解了迪米特法则后,要考虑的就应该是如何减少顾客和收银员之间的直接耦合 。而这里,通过钱这个要素来作为中间层进行解耦就是非常合适的选择。所以,在考虑收银员的角色时,就不应该去管钱包里的钱够不够,而应该是负责判断有没有收到足够的钱;同时,对于顾客角色,应该让他自己管好自己的钱包,只负责判断要支付多少钱。这样,顾客和收银员就不会因为钱包的职责划分不清而耦合在一起了。

接下来,我们来看看如何修改代码。

首先,我们将顾客"获取钱包"的动作修改为"支付账单",代码修改前后对比如下图:

将 getWallet() 方法重构为 pay() 方法

这里我们将顾客支付这个动作封装成方法 pay 单独拆出来,并将收银员对于钱是否足够的判断动作交还给顾客,如下所示:

csharp 复制代码
    public float pay(float bill) {
        if (myWallet != null) {
            if (myWallet.getTotalMoney() > bill) {
                myWallet.subtractMoney(bill);
                return bill;
            }
        }
        return 0;
    }

其次,再将收银员"获取顾客钱包"的动作,修改为"收取顾客支付的钱并和账单比对",代码对比如下图:

这里收银员的动作就从获取钱包变为根据收到的钱进行判断的动作,如下所示:

arduino 复制代码
public class PaperBoy {
    public void charge(Customer myCustomer, float payment {
        payment = 2f; // "我要收取2元!"
        float paidAmount = myCustomer.pay(payment);
        if (paidAmount == payment) {
            // 说声谢谢,欢迎下次光临
        } else {
            // 可以稍后再来
        }
    }
}

到此,我们就利用迪米特法则顺利地实现了代码的解耦。

你发现没,使用迪米特法则后,不仅降低了代码的耦合,还提高了代码的重用性。即使未来顾客不想用现金支付,改用微信支付、找朋友代付等,都不会影响收银员的行为。

2、应用迪米特法则时需要注意的问题

虽然应用迪米特法则有很多好处,但是迪米特法则因为太过于关注局部的简化,而容易导致别的问题出现。

问题一:容易为了简化局部而忽略整体的简化。 迪米特法则在优化局部时很有效,因为会做一定程度的信息隐藏,但同时这也是它最大的劣势。因为太过于关注每个类之间的直接关系,往往会让更大类之间的关系变得复杂,比如,容易造成超大类。在类对象中加入很多其他类,这样并没有违反迪米特法则(与直接相关的类耦合,没有关注职责分离),但是随着其他类与更多类发生耦合后,最开始的类的关系其实已经变得非常庞大了。

问题二:拆分时容易引入很多过小的中间类和方法。 迪米特法则本质上是在对类和方法做隔离,虽然它判断的标准是对象间的直接关系,但是对这个直接关系并没有一个非常好的衡量标准,在实际的开发中,基本上是依据程序员自己的经验来进行判断。于是,我们经常能在代码里看见一些接口的实现功能只是调用了另一个方法的情况,这其实就是因为过多的隔离带来的中间适配,适配就会产生很多中间类和方法,甚至有的方法没有任何逻辑,只是做了一次数据透传,为的就是避免直接耦合。

问题三:不同模块之间的消息传递效率可能会降低。 比如,分层架构就是典型的例子,当 Controller 层想要调用 DAO 层的模块时,如果遵循迪米特法则,保持层之间密切联系,那么就意味着需要跨越很多中间层中的模块才行,这样消息传递效率自然会变低。

三、扩展:面向切面编程(AOP)

我们都知道,面向切面编程(Aspect Oriented Programming,简称 AOP)在面试中是高频问题之一,尤其对于 Java 程序员来说,AOP 不仅是 Spring 框架的核心功能,还是在业务中降低耦合性的有效手段之一。

面向切面编程,简单来说,就是可以在不修改已有程序代码功能的前提下给程序动态添加功能的一种技术

如果说迪米特法则是在程序设计时(静态) 降低代码耦合的方法的话,那么面向切面编程就是在程序运行期间(动态) 降低代码耦合的方法。比如,我们熟知的在 Spring 框架中大量使用的 AspectJ 工具就是面向切面编程的一种最佳实践。

不过,这里尤其要注意,面向切面编程(AOP)和面向对象编程(OOP)虽然最终想要达到的目的相同,都是降低代码耦合性,但关注点却是截然不同的。这也是很多人在面试或设计时容易搞混淆的地方。

这里我们结合具体的例子来说明一下。对"会员用户"这个业务单元进行封装时,使用 OOP 思想,你会自然建立一个"Member"类,并在其中封装会员用户对应的属性和行为;而如果使用 AOP 思想,你会发现使用"Member"无法统一属性和行为,因为不同的会员可能有不同的偏好属性和行为。同样,对于"性能统计"这个统一的动作来说,使用 AOP 会很方便,而使用 OOP 封装又会变成每一个类都要加一个性能统计的动作,变得过于累赘。

从以上例子你可能已经发现,面向切面编程(AOP)和面向对象编程(OOP)这两种设计思想有着本质的差异。OOP 强调对象内在的自洽性,更适合业务功能,比如商品、订单、会员。而对于统一的行为动作,如日志记录、性能统计、权限控制、事务处理等,使用 AOP 则更合适,通过关注系统本身的行为,而不去影响功能业务逻辑的实现和演进。

文章(专栏)将持续更新,欢迎关注公众号:服务端技术精选。欢迎点赞、关注、转发

相关推荐
Tech Synapse1 小时前
Java根据前端返回的字段名进行查询数据的方法
java·开发语言·后端
.生产的驴1 小时前
SpringCloud OpenFeign用户转发在请求头中添加用户信息 微服务内部调用
spring boot·后端·spring·spring cloud·微服务·架构
微信-since811921 小时前
[ruby on rails] 安装docker
后端·docker·ruby on rails
代码吐槽菌3 小时前
基于SSM的毕业论文管理系统【附源码】
java·开发语言·数据库·后端·ssm
豌豆花下猫3 小时前
Python 潮流周刊#78:async/await 是糟糕的设计(摘要)
后端·python·ai
YMWM_3 小时前
第一章 Go语言简介
开发语言·后端·golang
码蜂窝编程官方3 小时前
【含开题报告+文档+PPT+源码】基于SpringBoot+Vue的虎鲸旅游攻略网的设计与实现
java·vue.js·spring boot·后端·spring·旅游
hummhumm4 小时前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
J老熊4 小时前
JavaFX:简介、使用场景、常见问题及对比其他框架分析
java·开发语言·后端·面试·系统架构·软件工程
AuroraI'ncoding4 小时前
时间请求参数、响应
java·后端·spring