软件设计与软件工程(4)-优秀代码

简洁的行为实现

这个规则背后的原理就是奥卡姆剃刀原理,包括我们平时经常遇到的最小权限原则实际上也反应了这个原理。这个原理在我们的开发和生活中往往很难以做到。我们的惯性思维 往往是多多益善。但是弄清楚边界往往是更大的挑战。也是对模糊性的反抗。

无论是易于理解,还是易于演进,都意味着要编写更为简洁的代码。简洁,就是“少”​“清晰”​“简单”​。简洁的反面是繁复,意味着“多”​“烦琐”​“复杂”​。我们将从代码特征的角度,介绍简洁的行为实现的三个重要方面。

(1) 代码元素(方法、类等)要尽量简短。(2) 代码的表达要清晰,抽象层次要一致。(3) 方法的实现复杂度要尽量低。这三个方面经常是彼此促进的,做好其中一个方面也会为另外两个方面带来提升。

代码元素要尽量简短

没有人喜欢看长长的代码。在工作环境中,经常看到程序员把一个横着的显示器竖起来放,这往往是一个不太好的信号:代码太长了。不过,​“代码元素(方法、类等)要尽量简短”这句话存在一定的歧义。并不是简单的指代码行数少。而是在满足其它要求的情况下代码行数少。

简短是指“认知”层面的简短

回到“认知”这个理解代码的核心维度上来,并不是哪段代码的总长度更短,哪段就更简洁。从认知层面讲,类、方法各是一个抽象层级。当代码阅读者理解一个类的时候,更关心方法这个层级,对于方法是怎么实现的则并不关心。更进一步,如果类的方法声明中区分了public和private,那么代码阅读者首先会关心public方法。只有当理解了一个方法的时候,查看的才是实现方法的代码行这个层级。

设置一个关于简短的警戒值

在方法层级,尽管严格约定每个方法的长度是不现实的,但是设定一个警戒值还是有着重要的实践意义。过长的代码往往是设计不良的信号。Martin Fowler在《重构》[插图]中,将过长的方法列为代码的“坏味道”之一。至于多长才算是过长,在不同的语言、不同的业务上下文中可能有不同的解释。较好的处理办法是设定一个警戒值。例如,我会把警戒值设为50行,只要一个方法达到50行,我就会比较警惕:是不是这个方法过于复杂了?由于50行很容易感知,所以将它作为警戒值就很直观,并不需要一个代码统计工具作为辅助。

代码的表达要清晰,抽象层次要一致

在2.2节讨论命名问题时,我们讲到了“好的代码,应该让人读起来像在阅读文章一样”​。高质量的命名和一致的抽象层次,共同组成了这样的好代码。抽象和分层是人类认知事物最基本的模式 。

方法的实现复杂度要尽量低

计算机非常善于处理条件判断和循环逻辑,不过对人类来讲,条件语句和循环语句的组合及嵌套实在复杂。复杂了就容易出错。针对控制代码结构的复杂性,有一些专门的度量指标,如圈复杂度(McCabe复杂度)和认知复杂度。不过,在日常的编码场景中,并不需要依赖度量指标来感知复杂度,只要多留意嵌套控制结构的数量即可。一旦超过两层,就应该非常警惕:是不是设计已经变得过于复杂了?复杂的控制结构,是非常容易识别的代码坏味道。一旦识别出这种问题,就需要关注控制结构的业务逻辑,重新组织代码结构,如提取方法或者进行抽象,以获得更为简短的代码。

高内聚和低耦合的结构

现实世界中的项目规模往往相当庞大。例如,一些大型项目可能有数十万行,甚至上百万、上千万行代码。如何才能在这样的项目上良好工作?模块化就是提升代码可理解性、可演进性、可复用性的关键。即使是只有几百行代码的程序,高质量的模块化和低质量的模块化带来的影响也截然不同。从设计层面看,模块化分解的最高指导原则是高内聚,模块间协作的最高指导原则是低耦合。高内聚、低耦合是提升代码可理解性、可演进性、可复用性的关键。MHR(模块 ,层次,服用)

高内聚

高内聚描述了一个代码元素边界内内容的紧密程度。高内聚意味着以下两点。代码元素视划分粒度的不同而不同,如子系统、模块、类、方法等。**.凡是紧密相关的东西,都应该放在一起。.凡是被放在一起的东西,都是紧密相关的。**下图是一个关于内聚的示意图,能方便读者对高内聚建立更深刻的印象。我们必须把紧密联系的元素放在一起。

在这里插入图片描述

低内聚代码的不良影响

为什么要强调高内聚呢?因为内聚性密切影响了易于理解、易于演进、易于复用的特征。不内聚的代码会增加理解难度、降低演进能力、降低复用的可能性。我们先来看一段真实代码,这段代码具有明显的内聚问题。

public AccountService {
    public LoginResultDTO login(String accountName, String securedPassword) {
        Account account = getAccount(accountName, securedPassword);
        if (account == null) {
            throw new RequestedResourceNotFound("账号或密码不正确!";
        }
        LoginResultDTO r = new LoginResultDTO();
        if (AccountType.STUDENT.equals(account.getDomain())) {
            List<RecordConsumptionDTO> consumptionRecords = consumptionService
            .getByAccount(account.getId());
            if (consumptionRecords != null && consumptionRecords.size() > 0) {
                // 存在消费记录, 代码略
            } else {
                // 不存在消费记录, 代码略
            }
        }
        String token = buildSession(account);
        r.setToken(token);
        r.setAccount(helper.toAccountDTO(account));
        return r;
    }
}

上面的代码是低内聚的,它的问题非常容易分辨。从方法名上看,这个方法和登录(login)相关。这段代码的第3行至第7行、第17行至第20行确实都是处理和登录相关的内容。比较奇怪的是:从第8行开始,这个方法做了一些别的工作,它会根据账户类型去查询消费记录。出现这种情况的原因很可能是在登录功能开发完成后,收到了在登录成功界面上根据消费记录展示某些信息的新需求。需求固然没有问题,但是不应该这样设计。把和消费记录相关的逻辑加入login方法中,会产生以下几个显而易见的后果。.从易于理解角度看,代码的可理解性下降了。代码阅读者的本意只是搞懂登录的逻辑,却不得不了解和消费记录相关的问题。.从易于演进角度看,代码变更的可能性增加了。登录逻辑的变化频率一般较低,但是消费记录的逻辑,以及消费记录的展示是否要和登录动作放在一起都有更多变化的可能。一段代码多了一个变化源,代码变更的可能性必然也会增加。.从易于复用的角度看,登录功能本来是一个通用资产,可在各种场景下使用,但是加入了和消费记录相关的信息后,就只能在本系统中使用了。最简单的方法是引入一个新的模块将两者整合在一起。

尽管在某个特定的业务场景下,消费记录和用户的登录动作会被组合,但是从概念上看,这样的设计是不内聚的,它们至多是相关的概念,很难说是“紧密相关”​。如何才能优化上面代码的内聚性呢?一个可能的改造方法如下。.让AccountService::login方法聚焦登录相关的业务逻辑;.新建或复用消费记录相关的类ConsumptionRecordService,提供和消费记录相关的服务;.在外围增加一个面向特定应用场景的类UserLoginService,组合AccountService和ConsumptionRecordService的能力。下图展示了重新分配职责之后的结果。这样的设计让登录模块的职责变得内聚,相应地也增强了这一模块的可理解性、稳定性、可复用性。

在这里插入图片描述

更高标准的高内聚

内聚性差一般是显而易见的。如上面这样的代码,只要阅读者稍微有点内聚性的意识,就不难判断这是低内聚代码,存在改进空间。但是,有大量设计处于内聚性差和内聚性好的中间地带,这才是设计的难点所在。下面的代码就是一段较难判断内聚性好坏的代码,作用是打印特定目录下的Java文件名。

import java.io.File;
import java.util.Arrays;
import org.apache.commons.io.FilenameUtils;
public class JavaFileNamePrinter {
    /**
     * 入口方法 打印出所有以 .java 结尾的文件名
     * @param rootPath - 需要查找的根路径
     */
    public void printJavaFiles(String rootPath) {
        File dir = new File(rootPath);
        printJavaFiles(dir);
    }
    /**
     * 实际执行的可递归调用的打印 Java 文件名的方法
     */
    private void printJavaFiles(File node) {
        if (isJavaFile(node))
            System.out.println(node.getAbsolutePath());
        // 遍历子节点(包含文件和目录),递归调用 printJavaFiles
        if (!node.isDirectory()) return;
        File[] subnodes = node.listFiles();
        Arrays.asList(subnodes).forEach(subnode->printJavaFiles(subnode));
    }
    private boolean isJavaFile(File node) {
        if (!node.isFile()) return false;
        return FilenameUtils.getExtension(node.getName()).endsWith("java");
    }
}

从表面上看,无论编写规范性、命名规范性,还是方法的简短性,这段代码都是合格的。同时它的功能也不复杂,职责看起来也比较相关。那这段代码有没有内聚性问题呢?其实,这是没有唯一答案的。在有些场景下,这是合格的代码。换一种场景,这段代码就可能需要改进​。它们均源自一个重要的设计概念:关注点分离。之所以在这里要特别提到这种更高标准的高内聚,是希望能留意软件的高内聚、低耦合和上下文(context,业务场景)强烈相关,设计不足和过度设计都是不可取的。一分不多一分不少是程序员在设计方面需要追求的目标,而演进式设计的思想在这一目标的达成上扮演了关键的角色。

低耦合

内聚反映了设计单元内部的相关性,耦合则是设计单元之间相关性的表征。如果两个设计单元之间存在某种关系,使得当一个设计单元发生变化或者出现故障时,另外一个设计单元也会受到影响,那我们就说这二者之间存在耦合。内聚和耦合是彼此影响的两个因素。不然的话,只要简单地把所有代码都写在一个模块里面,那耦合自然就消失了。但是这样的模块不可能是高内聚的。耦合不可避免。只要是模块化设计,就必然会出现耦合—设计单元之间的协作是实现丰富功能的基础。但是,不同设计产生的耦合是不一样的。过度耦合是软件设计不稳定、不健壮的根源。如何才能避免过度耦合的设计呢?要理解耦合,必须先理解依赖。耦合和依赖有着紧密的联系。一般来说,管理好了依赖,也就解决了大多数耦合问题。在我们的开发过程中会遇到不同类型的 依赖关系,相应的,这些依赖关系都有对应的解决方案来降低耦合度。提高设计质量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yyc_audio

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值