如何写好一个方法——一个看起来很基础,很多人却没有做好的问题

大学毕业一年后,一个不是程序员的同学问:为什么程序员总是看着别人的代码不顺眼?当初的我,感觉这是对程序员赤裸裸的挑战,直接否认了:不是这样的吧?我就感觉某些人的代码挺好的。但是这个问题却一直记在心上。

工作了这么多年,一次测试提了一个Bug单,让我修改。这个模块我不太熟悉,但凭经验感觉不难,可是看到代码时,我的内心是:“唉呀妈呀,你个傻X,你写的这个是什么?”,但是我转念一想,对方这样写代表了他对这个功能的理解,只是我没有理解。忽然我好想找到了问题的答案。

 

程序员这个岗位和其他岗位有一个很大的差异:深度的思想合作。从业务理解,到方案设计,到实施时类的命名、变量名称、参数顺序。你的每一个决定,都会被合作者接触到,被以后的维护人员接触到,你的每一个决定都在和其他人的想法碰撞。所以能让人舒畅的理解意图,是多么的重要呀。难怪《Clean Code》这本书第一章,用了整整一章的篇幅来讲代码可读性的重要性。

这个就是我同学开始提出问题的答案:我认为程序员之间互相就看着不顺眼的主要原因是——没有能很好的使用程序语言表达意图。

我重新阅读代码,想找到问题原因

  • 方法命名不好吗?看起来都挺对的。

  • 变量命名不好吗?也不差。

  • 函数太长吗?基本都在20行内,短得令人发指。

  • 圈复杂度高吗?函数都不长,能复杂到哪里去呢?

看起来代码符合各种开发规范,过程质量很不错,但是结果还是不易读。

经过一段时间的思考,我得到这么几个点

关注业务

《Clean Code》中对函数的要求:短小;只做一件事。其实还有一个要求:每个函数一个抽象层级。我理解为:关注业务。

 

在我们一般的项目中,都有三级结构Controller、Service、Repository。每层负责不同的职责,Controller负责处理Http请求项关键的传输内容,Service负责处理业务逻辑,Repository负责处理持久化。当Service层接收到HttpRequest对象的时候,我们能明显的知道越级了。但是实际上我们的业务并不只有三级,每个函数都应该当做一级处理,每一级都有自己的业务。

你可能会说:作为一个程序员,Show me your code。OK,上代码。下面代码负责计算一个任务,检测任务是否有效,加载任务信息,计算Kpi数据,保存结果即可

public void calculate(CalculateTask task) {
    if (!task.isValidate()) {
        return;
    }
    List<TaskData> taskDatas = loadTaskData(task);
    List<TaskKpiData> kpiData = calculateAllKpi(taskDatas);

    // Save Result
    String version = getVersion(task);
    List<String> ids = getKpiIds(kpiData);
    esObjectClient.deleteByVersion(version, ids);
    esObjectClient.saveAll(kpiData);
    updateLatest(kpiData, task);
}

这里的逻辑比较简单,代码前面写得也挺简单,后面的代码块通过注释也能看出来是负责保存计算结果的,但是calculate方法的业务应该是对整个Task的调度过程,此过程仅仅关心保存这个动作什么时候进行,不关心如何保存数据的细节,因此这样才更为合理

public void calculate(CalculateTask task) {
    if (!task.isValidate()) {
        return;
    }
    List<TaskData> taskDatas = loadTaskData(task);
    List<TaskKpiData> kpiData = calculateAllKpi(taskDatas);

    saveResult(kpiData, task);
}

这里将代码块提成一个方法,并不是因为代码行太多了,而是因为这个方法的功能是描述计算过程,不是细节。因此代码写成下面的样子也是不合理的

public void calculate(CalculateTask task) {
    if (!task.isValidate()) {
        return;
    }
    loadAndCalculateKpiAndSaveResult(task);
}

calculate的逻辑应该包含:校验、加载、计算、保存,四个过程。这四个过程需要在方法中直接有体现,不多也不能少


我们再来一个例子🌰,这里的功能是从文件加载发票数据,按照发票号分组保存到数据库,并将发票对应范围也保存入库。明确一下需求点

  • 从文件加载发票数据

  • 按照发票号分组

  • 分组后发票信息保存到数据库

  • 发票对应范围保存入库

    我们再来看看实现

void saveFileToDb() {
    Stream<InvoiceInterfaceEntity> invoiceEntities = loadInvoiceFromFile(); 
    saveDataToDb(getSortedData(invoiceEntities));
}

public void saveDataToDb(List<InvoiceInterfaceEntity> entityList) {
    entityList.forEach(entity -> {
        entityManager.persist(entity);
    });

    saveInvoiceRecorder(entityList);
}

private void saveInvoiceRecorder(List<InvoiceInterfaceEntity> interfaceEntities) {
    Map<String, List<InvoiceInterfaceEntity>> invoiceToEntityMap = interfaceEntities.stream()
            .collect(Collectors.groupingBy(InvoiceInterfaceEntity::getInvoiceId));

    getInvoiceRecorderEntityStream(invoiceToEntityMap).forEach(entity -> {
        entityManager.persist(entity);
    });
}

private Stream<InvoiceRecorderEntity> getInvoiceRecorderEntityStream(Map<String, List<InvoiceInterfaceEntity>> invoiceToEntityMap) {
  // 将invoiceToEntityMap中的每组发票信息转换为InvoiceRecorderEntity,InvoiceRecorderEntity是用于记录发票开始Id和结束Id的对象
  return ...
}

private List<InvoiceInterfaceEntity> getSortedData(Stream<InvoiceInterfaceEntity> invoiceEntities) {
  // 对发票数据按照发票号排序
}

可以看得出来,这里的命名可读、有意义,方法短小,但你看懂它在干什么了吗?问题出在哪里呢?

需求中四个需求点,分散在各地,这导致了可读性极差,这是维护人员的噩梦。下面是改进后结果

void saveFileToDb() {
    loadInvoiceFromFile()
            .collect(Collectors.groupingBy(InvoiceInterfaceEntity::getInvoiceId))
            .values().stream()
            .forEach(invoiceGroup -> {
                InvoiceRecorderEntity record = saveToDb(invoiceGroup);
                entityManager.persist(record);
            });
}

private InvoiceRecorderEntity saveToDb(List<InvoiceInterfaceEntity> invoiceGroup) {
    String invoiceId = invoiceGroup.get(0).getInvoiceId();
    long minId = Long.MAX_VALUE;
    long maxId = Long.MIN_VALUE;

    for (InvoiceInterfaceEntity entity : invoiceGroup) {
        entityManager.persist(entity);
        minId = Math.min(minId, entity.getId());
        maxId = Math.max(maxId, entity.getId());
    }

    return new InvoiceRecorderEntity(invoiceId, minId, maxId);
}

这里方法saveFileToDb中几乎一行对应一个需求,在一个方法内将整个功能的核心逻辑表述清楚了。

传递方法强相关参数

《Clean Code》里面说:最理想的参数数量是零。可能有人看了这个规则,就将代码就写下了下面的代码。

下面代码用于从Excel文件的smpfp页中读取价格信息,然后转换为ServicePackage信息

public Stream<XXXDto> getServicePackages() {
    Map<String, Optional<XXXDto>> prices = parsePrices();
    // ...
    return null;
}

private Map<String, Optional<XXXDto>> parsePrices() {
    return getSheet(() -> "smpfp")
            .getObjects(XXXDto.class)
            .stream()
            .collect(groupingBy(this::generatePackageIdentity,
                            reducing((l, r) -> l))
            );
}

private SheetAgency getSheet(Supplier<String> sheetNameSpplier) {
    return this.excelReader.getSheet(sheetNameSpplier.get());
}

大家看这段代码,是否你也好奇:

  • parsePrices从哪里解析的价钱?

  • getSheet怎么需要这么奇怪的一个参数?

我想《Clean Code》中说的“最理想的参数数量是零”的意思是参数数量尽量少,但绝不是说为了减少参数数量不择手段。方法名称是parsePrices,意思是要从某个东西里面解析Price信息,缺少了这个“某个东西”语义就不完善了,所以这个“某个东西”一定要有体现,这里的Price也必须返回。而且需要什么就传递什么参数,不需要的内容也不要传递。这里的方法getSheet需要指定Sheet页的名字,所以只要名字就够了,怎么生成的名字不是重点,相当于传递了过多的信息给getSheet。

下面是修改后的结果

public Stream<XXXDto> getServicePackages() {
    Map<String, Optional<XXXDto>> prices = parsePrices(excelReader);
    // ...
    return null;
}

private Map<String, Optional<XXXDto>> parsePrices(ExcelReader excelReader) {
    return getSheet(“smpfp”, excelReader)
            .getObjects(XXXDto.class)
            .stream()
            .collect(groupingBy(this::generatePackageIdentity,
                            reducing((l, r) -> l))
            );
}

private SheetAgency getSheet(String sheetName, ExcelReader excelReader) {
    return this.excelReader.getSheet(sheetName);
}

函数需要传递逻辑相关的重要参数,不多也不少,函数名称和参数能表达完整含义。

参数分贵贱

这个世界不是平等的,同为参数,但是参数分贵贱。

下面方法是一个检测数值是否在指定范围的方法,如果不在范围内则记录错误信息

private void checkValue(MessageRecorder messageRecorder,
                        Set<String> range,
                        String value,
                        ExcelRowWrapper<XXXData> row,
                        String title)

调整顺序后会更为友好

private void checkValue(
        String value,       // 需要检测的内容是核心对象
        String title,       // Title不是核心信息,但是它是核心信息的关键附属信息
        Set<String> range,  // 数据所属范围是次级重要信息
        MessageRecorder messageRecorder,  // 如果出错负责保存信息的对象,参与核心操作
        ExcelRowWrapper<XXXData> row) {   // 完善性信息放在最后

这个没有什么好说的,自己感受吧。

正向命名

在《重构》中关于代码的坏味道第一条是神秘名称,强调名称一定要准确。 但是命名准确就没有麻烦吗?请看下面的例子

案例一:

public boolean notValidated() {
    return date == null || !date.isValidated();
}

这是检测一个Task是否无效的方法,命名很准确,能看出来无效时返回true。 使用的代码如下:

public void someFun() {
    ToDoItem item = new ToDoItem();
    if (item.notValidated()) {
        showRedColor();
    }
    //...
    
    if (!item.notValidated()) {	// 这是什么鬼逻辑?
        queryMoreDetail();
    }
}

案例二:我要做一个查询数据库的工具,我希望提供一个灵活的表示条件的结构

public class StringEqualCriteria {
    private String field;   // 需要查询的数据库字段
    private String value;   // 字段需要匹配的值
}

// 查询所有完成的任务
queryTask(Arrays.asList(new StringEqualCriteria("status", "完成")));

有了这个StringEqualCriteria,想要查询任意条件的组合就方便多了。 不过需求总是在变的,有一天需要增加不等于的条件,于是StringEqualCriteria变成了这个样子

public static class StringEqualCriteria {
    private String field;   // 需要查询的数据库字段
    private String value;   // 字段需要匹配的值
    private boolean isNot;  // 表示条件是否取反
}

为了查询所有没有完成的任务就有了这样的代码

queryTask(Arrays.asList(new StringEqualCriteria("status", "完成", true)));

是不是感觉哪里怪怪的?你是否也感觉是下面这个样子的?

new StringEqualCriteria("status", "完成", true);   // 感觉表示状态为完成的
new StringEqualCriteria("status", "完成", false);  // 感觉表示状态没有完成的

这个代码在我所在项目里面已经出现了,因为是一个公共的工具,很多地方都在用,不能随便修改参数含义。虽然已经导致了几次Bug,但是我们还是不能奈何他。

 

可能有人会问:StringUtils似乎就提供了isNotEmpty方法呀?

我想说:无论工具是否提供isNotEmpty,但它一定提供了isEmpty。

惨痛的经验告诉我们:方法属性命名时可以出现反向命名,但是正向命名的方法或者属性不能丢掉。规则简单点就是——正向命名

合理注释

好多书中都有对注释不好的评价

  • 《重构》中写道

      如果你需要注释来解释一块代码做了什么,试试提炼函数 
      如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明 

     

  • 《Clean Code》中写道

     注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败。注意,我用了“失败”一词。我是说真的。注释总是一种失败。

     

所以我们现在对注释深恶痛绝,禁止了所有注释。

但大家注意到没有,《重构》也为注释平反了,书上说

别担心,我们并不是说你不该写注释。从嗅觉上说,注释不但不是一种坏味道, 事实上它们还是一种香味呢。

《Clean Code》更是大方的给了注释整整一章,细细的讲解如何使用注释。 第四章第一句话:

什么也比不上放置良好的注释来得有用。

后面又特别强调:

注释并不像辛德勒的名单。 

《Clean Code》4.3好注释中部分推荐的注释场景

  • 法律信息

    在公司规范要求是增加

  • 对意图的解释
private static synchronized String getUniqueTail() {
    // Reset the counter if it is greater than 99999 无用注释,这个注释只是对代码的翻译
    // 和ECC约定编号从0-99999循环变化                  这个是意图解释的注释
    if (counter > 99999) {
        counter = 0;
    }
    counter = counter + 1;
    return Long.toString(System.currentTimeMillis()) + "_" + counter;
}
  • 警告
// Simple DateFormat is not thread safe
// So we need to use this cache in single thread
// We must not add 'static' qualifler
private Map<String, DateFormat> formatMap = new HashMap<>();

public static <T> Long newVersion(ESDocumentWrapper<T> wrapper) {
    // When we don't find doc in es, It does not mean the doc never appear,
    //  when the doc appear in es sometime, the doc version will can't start from 1
    //  So we return null when not find doc.It may be overwrite doc sometime.
    return (wrapper == null) ? null : wrapper.getVersion() + 1;
}
  • TODO注释

这个大家一般都会用

SearchRequestBuilder requestBuilder = client.prepareSearch(indexName)
        .setSize(10000) // TODO: 应该变更为Scroll的方式读取。当前数据量在10000以内,可以使用这个临时方案
        .setQuery(queryBuilder);
  • 公共API的Java Doc

如果代码是公共库,千万记得添加注释

/**
 * 按照Key分组
 * 前一个Iterator必须是已经根据key排序好的
 * @param keyGenerator key的获取方法
 * @param <K> key的类型
 * @return 按照key分组后的Entity迭代器
 */
public <K> EasyIterator<Map.Entry<K, List<T>>> groupBy(Function<T, K> keyGenerator) {
    return new GroupSortedListIterator<>(this, keyGenerator, v -> v);
}

/**
 * 实现某个数据类型和字符串之间的互相转换逻辑
 * @param <T> converter负责处理的数据类型
 * @param <S> 子类类型,用于返回自身的时候不用cast
 */
public interface IPrimitiveConverter<T, S extends IPrimitiveConverter<T, S>> {
    /**
     * 返回所有支持的数据类型。注册的时候会使用这个类型注册,如果类型重复后一个类型对应的Converter会被注册进去
     * @return 所有支持的数据类型
     */
    Class[] dataTypes();

注释不是万恶之源,必要的时候该出手时就出手。

无论什么注释,要有意义。没意义的注释一定要删除。

注释不能美化糟糕的代码。如果有办法通过代码表达信息,尽量使用代码。

代码没有表达清楚的时候,一定要添加注释,有注释的代码比表达不清楚的代码好。

公共代码要有良好的注释。

总结

  • 一个函数应该将好把自己的功能描述清楚,不多也不少。

  • 函数需要传递逻辑相关的重要参数,不多也不少,函数名称和参数能表达完整含义。

  • 参数分贵贱。按照语义和相关度排列。

  • 函数或者变量需要正向命名。

  • 工具类需要有注释,最好还有demo。

参考

阿里巴巴规范:https://alibaba.github.io/Alibaba-Java-Coding-Guidelines/

Google规范:https://google.github.io/styleguide/javaguide.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章