代码整洁:开发者的务实之道

143 阅读11分钟

大家好,我是 方圆。近半年看了不少关于代码整洁的内容,尤其是在读了《软件设计哲学》之后,想写好代码的想法达到了顶峰,便断断续续搜罗了许多相关书籍,并花时间看了以极简主义风格著称的 Mybatis 源码,收获良多。所以,想趁着这个机会将其中谈到的最直接的方法论分享给大家,希望能有一些帮助,如果大家想深入了解,可以参考文末的书籍。

关于方法的长度和方法拆分

之前我在读完《代码整洁之道》时,非常痴迷于写小方法这件事,记得某次代码评审时,有同事对将一个大方法拆分成多个小方法提出了异议:拆分出的小方法不能算作做了一件事,它们都只是大方法中的一个“动作”而已,所以不应该拆分巴拉巴拉。这个观点让我说不出什么,后来我也在想:如果按照这个观点,多大的方法都可以概括成只做了一件事,那么我们就需要将所有的逻辑都“摊”到一个方法中吗?我觉得拆分方法目的不是在界定一件事还是一个动作上,而是 关注方法的可读性,拆分方法太多确实让代码变得不好读,需要辗转在多个方法之间,但是不拆的可读性也会差,所以接下来我想根据 Mybatis 这段代码来简单谈谈我对写方法的观点:

public class XMLConfigBuilder extends BaseBuilder {

    private void parseConfiguration(XNode root) {
        try {
            propertiesElement(root.evalNode("properties"));
            Properties settings = settingsAsProperties(root.evalNode("settings"));
            loadCustomVfsImpl(settings);
            loadCustomLogImpl(settings);
            typeAliasesElement(root.evalNode("typeAliases"));
            pluginsElement(root.evalNode("plugins"));
            objectFactoryElement(root.evalNode("objectFactory"));
            objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
            reflectorFactoryElement(root.evalNode("reflectorFactory"));
            settingsElement(settings);
            environmentsElement(root.evalNode("environments"));
            databaseIdProviderElement(root.evalNode("databaseIdProvider"));
            typeHandlersElement(root.evalNode("typeHandlers"));
            mappersElement(root.evalNode("mappers"));
        } catch (Exception e) {
            throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
        }
    }

}

如上是解析配置文件中的各个标签的方法,它将每个标签的解析都单独定义出了一个方法,这也是我一直遵循的写方法的观点:最顶层方法应该是短小清晰的步骤,在主方法中编排好方法的执行内容,这样主方法便是清晰明了的执行过程,我们便能一眼清晰的知道该方法做了什么事情,而针对各个具体的环节或者要改动哪些逻辑,直接跳转到对应的方法即可。至于该不该将某段逻辑抽象成一个方法,我的观点是 能不能一眼看明白这段逻辑在干什么,如果不能,那么就应该被抽象到一个方法中,否则将其保留在原方法中也是没有问题的对方法的抽象从来都不在于方法的长度可读性 应得到更多的关注。

此外,还有一个能提高代码可读性的方法是:“合理使用换行符”,如下代码所示:

public class Configuration {
    // ...
    
    public Configuration() {
        typeAliasRegistry.registerAlias("JDBC", JdbcTransactionFactory.class);
        typeAliasRegistry.registerAlias("MANAGED", ManagedTransactionFactory.class);

        typeAliasRegistry.registerAlias("JNDI", JndiDataSourceFactory.class);
        typeAliasRegistry.registerAlias("POOLED", PooledDataSourceFactory.class);
        typeAliasRegistry.registerAlias("UNPOOLED", UnpooledDataSourceFactory.class);

        typeAliasRegistry.registerAlias("PERPETUAL", PerpetualCache.class);
        typeAliasRegistry.registerAlias("FIFO", FifoCache.class);
        typeAliasRegistry.registerAlias("LRU", LruCache.class);
        typeAliasRegistry.registerAlias("SOFT", SoftCache.class);
        typeAliasRegistry.registerAlias("WEAK", WeakCache.class);

        typeAliasRegistry.registerAlias("DB_VENDOR", VendorDatabaseIdProvider.class);

        typeAliasRegistry.registerAlias("XML", XMLLanguageDriver.class);
        typeAliasRegistry.registerAlias("RAW", RawLanguageDriver.class);

        typeAliasRegistry.registerAlias("SLF4J", Slf4jImpl.class);
        typeAliasRegistry.registerAlias("COMMONS_LOGGING", JakartaCommonsLoggingImpl.class);
        typeAliasRegistry.registerAlias("LOG4J", Log4jImpl.class);
        typeAliasRegistry.registerAlias("LOG4J2", Log4j2Impl.class);
        typeAliasRegistry.registerAlias("JDK_LOGGING", Jdk14LoggingImpl.class);
        typeAliasRegistry.registerAlias("STDOUT_LOGGING", StdOutImpl.class);
        typeAliasRegistry.registerAlias("NO_LOGGING", NoLoggingImpl.class);

        typeAliasRegistry.registerAlias("CGLIB", CglibProxyFactory.class);
        typeAliasRegistry.registerAlias("JAVASSIST", JavassistProxyFactory.class);

        languageRegistry.setDefaultDriverClass(XMLLanguageDriver.class);
        languageRegistry.register(RawLanguageDriver.class);
    }
}

Configuration 的构造方法中,进行注册别名操作时使用了换行符进行分割,它将 TransactionFactory 相关的紧挨在一起作为一组,再将 DataSourceFactory 相关的紧挨在一起等等,这样在分门别类查看和处理这段代码便是相对清晰的。

方法的编排

在《代码整洁之道》中提出了代码中方法要 从上到下排列方法像读报纸一样,因为方法被抽象提炼出来,阅读时必然会造成在多个方法间切换的问题,那么如果我们将方法从上到下依次排列,能够在屏幕中同时看到所有相关方法的话,那么这样的确方便了阅读,比如 methodA 依赖 commonMethod 方法的排列:

@Override
public void methodA() {
    commonMethod();   
}

private void commonMethod() {
    // ...
}

此时如果增加 methodB() 也要复用 commonMethod() 的话,那么我并不会像下面这样排列方法:

@Override
public void methodA() {
    commonMethod();
}

private void commonMethod() {
    // ...
}

@Override
public void methodB() {
    commonMethod();
}

因为我们在看一个方法时,始终要坚持 自上往下读 的原则,不能在看 methodB() 的时候,再跳回到上面去,而是需要像这样:

@Override
public void methodA() {
    commonMethod();
}

@Override
public void methodB() {
    commonMethod();
}

private void commonMethod() {
    // ...
}

那么这也就意味着,如果 某个方法被复用的次数过多,它的位置则越靠近类的下方。在《软件设计哲学》中也提到过 专用方法上移,通用方法下移 的观点,这也是在提醒开发者,当看见某个私有方法在类的尾部时,它可能是一个非常通用的方法。

方法的声明

在业务代码中经常会看到接口中某方法声明会抛出异常:

public interface Demo {
    void method(Object parameter) throws Exception;
}

但是对要抛出的具体异常类型并没有确切的声明,只知道会抛出 Exception,对于具体的原因和类型一无所知,如果想清楚的了解,可以借助注释(如果有的话),否则就需要去探究它的具体实现,这对想直接调用该方法的研发人员来说是非常不友好的,那该怎么办呢?

《图解Java多线程设计模式》中提到过一个例子非常有启发性,它说方法签名中标记 throws InterruptedException 能表示两种含义:第一种比较容易被想到,表示该方法可以被打断/取消;第二种含义是,这个方法耗时可能比较长。比如 Thread.join() 方法,它声明了 throws InterruptedException,它的作用是让当前执行的线程暂停运行,直到调用 join() 方法的线程执行完毕,当我们在一个线程实例上调用 join() 方法时,当前执行的线程将被阻塞,阻塞时间可能会很长,如果在阻塞期间如果另一个线程中断(interrupt)了它,那么它将抛出一个 InterruptedException。所以,我们能够在 throws 声明中,获取某方法关于某异常的信息。

在 Mybatis 源码中也有类似的例子,如下:

public interface Executor {
    int update(MappedStatement ms, Object parameter) throws SQLException;
}

它声明出 throws SQLException 表示 SQL 执行的异常。我认为直接将方法上声明 throws Exception 的签名并不添加任何注释的行为是一种懒惰。异常精细化能给我们带来很多好处,比如日常报警容易看,增加方法可读性能够通过声明知道这个方法会抛出关于什么类型的异常,便能让接口的调用者判断是处理异常还是抛出异常。

方法的参数声明也很重要,我认为在业务代码中除了要遵循方法入参不要过多以外,还需要遵循 随着重要程度向后排序 的原则,以 Mybatis 中如下方法为反例:

public class DefaultResultSetHandler implements ResultSetHandler {
    // ...
    private final Map<String, Object> ancestorObjects = new HashMap<>();
    
    private void putAncestor(Object resultObject, String resultMapId) {
        ancestorObjects.put(resultMapId, resultObject);
    }
}

向缓存中添加元素的方法 putAncestor 将入参 String resultMapId 放在第一位更合适。

关于代码自解释

每次提到命名或者在为接口命名时,之后我都会有一种非常强烈的让它自解释的想法,但是随着对软件开发的变化这种想法的欲望逐渐降低,原因有二:

  1. 阅读习惯:对国人来说,可能大多数人没有先去读英文的习惯,更倾向于读中文相关的内容,比如注释

  2. 英语水平参差:可能有时候想要自解释的初心是好的,但是如果使接口名变成了长难句,可读性将降低

即使是这样,也并不能降低命名的标准,应该有一个适度的折中:不引入长难句,将其中难以表达的内容考虑使用注释来补充。此外,我觉得命名保持一致性也非常重要,比如在项目中对于补购已经命名为 AddBuy,那么便不要再引入 SupplementaryPurchaseReplenishment 等命名,团队内成员将知识统一才是最好的,并不在于它在英文语境下是否表达准确。

但是 Mybatis 为什么能够在很少注释的情况下又保证了它的源码自解释呢?因为它方法做的事情足够简单,像简单的 querydoQuery 方法,或者再复杂一些的 handleRowValuesForNestedResultMap 也能知道它是在处理循环引用的结果映射集。

当然也的确有一些命名方法能够帮我们提高方法的可读性,比如 instantiateXxx 表示创建某对象,initialXxx 表示为某对象中字段赋值。但是如果想在业务代码中保证代码自解释的话,还是需要认真的去写注释。因为业务功能相对复杂,而方法本身所能表现的东西又非常有限,通常并不能仅通过方法来表达其含义,注释能够在此处为方法表达带来增益,但是如果认为注释是弥补方法名表达能力欠佳的补丁的话,就有些偏颇了,因为随着注释写的越来越多,你会发现:注释其实是代码的一部分,因为它不光提供代码之外的重要信息,还能隐藏复杂性,提高抽象程度,这还反映了开发者对代码的设计和重视,随着时间的推移,有新的开发者加入时,也能让他快速理解代码,降低出现 Bug 的概率。

还有一点值得注意的是,Mybatis 源码中在目录下创建 package-info.java 来注释包路径非常值得学习,以 src/main/java/org/apache/ibatis/cache/decorators/package-info.java 为例,它注释了该目录都是缓存的装饰器:

/**
 * Contains cache decorators.
 */
package org.apache.ibatis.cache.decorators;

这样我们就能够知道该路径下的定义是与什么有关了。不过,这会使得该文件夹杂在各个类之中,如果能在命名前加上 a- 成为 a-package-info.java 被置于顶部的话,会更整洁一些:

a-package-info.png

“能用就行” 其实远远不够

虽然我们始终都在围绕着如何将代码写得更整洁和优雅做讨论,但是我们还是需要学会“负重前行”:和凌乱的代码相处。一些凌乱的代码可能写过一次后便不再变更,所以有时候没有必要为了优雅强迫症而去重构它们,它们可能始终会被隐藏在某个方法后面,默默地提供着稳定的功能,如果你深受其扰,可以考虑在你读过之后为这段代码添加注释,之后看这段代码的开发者也能理解和感谢你的用心,否则因为优雅的重构导致线上生产事故,可就得不偿失了。

实际上,能写好代码对于程序员来说并不是一件特别厉害的事情,它只能算是一项基本要求,而且随着 AI 的不断发展,它在未来可能会帮我们生成很好的设计。当然,这也不是放任的理由,写烂代码的行为还是需要被摒弃的。在最后我想借先前读过的雷军的博客《我十年的程序员生涯》中的节选来结束本文:

有的人学习编程技术,是把高级程序员做为追求的目标,甚至是终身的奋斗目标。后来参与了真正的商品化软件开发后,反而困惑了,茫然了。

一个人只要有韧性和灵性,有机会接触并学习电脑的编程技术,就会成为一个不错的程序员。刚开始写程序,这时候学得多的人写的好,到了后来,大家都上了一个层次,谁写的好只取决于这个人是否细心、有韧性、有灵性。掌握多一点或少一点,很快就能补上。成为一个高级程序员并不是件困难的事。

当我上学的时候,高级程序员也曾是我的目标,我希望我的技术能得到别人的承认。后来发现无论多么高级的程序员都没用,关键是你是否能够出想法出产品,你的劳动是否能被社会承认,能为社会创造财富。成为高级程序员绝对不是追求的目标

希望大家不仅能写出好代码,还能做出属于自己的产品,为生活乃至世界添一份彩。

巨人的肩膀