如何写好一个Java方法?

什么样的方法才是最好的方法

要回答这个问题,我们首先要确定的是我们需要什么样子的方法。无论我们出于什么样子的目的产生对方法的需求,我可以说精准地满足我们需求的方法就是好方法。精准的含义是不过也不少。

那么我们需要什么样的方法呢?就我个人的理解而言,我们对于方法的需求源于软件系统本身,系统是为需求服务的,而方法则为系统服务。所以我们的方法是为了更好地实现需求。那么对于项目需求而言,能够满足需求的设计、实现、迭代的方法就是最好的方法。但往往在进行代码开发时候,我们更关注的是功能上线,从而忽略了后续的迭代部分(当然不乏少数情况完全无法上线)。而这样带来的后果是:将方法的开发成本后移。击鼓传花就是现状。

在这里,我先给出结论:

如果一个方法,实现了作者预期的功能,而当别人阅读的时候可以轻易的理解作者的意图,这就是好方法。

当然,事实上我们有一系列的设计原则方法来来对类、方法设计的抽象。但是这并不是本文的重点。本文只提出一个观点那就是:易于理解,就是好的方法。

请循环播放歌单

易于理解便是好的方法,但是这个概念仍然是比较抽象。那么下面的描述就更便于理解。

  • 精短

  • 语义层级一致

  • 单一职责

  • 使用异常而非业务码

  • 描述性名称

  • 精确参数

  • 杜绝复制

你可以把以上的描述加入到你的歌单然后进行循环播放,直到你对你的方法结构满意。而我们进行code review时,也可以根据以上的描述,判断方法是否触犯了某些条目。

但我们并不会限制任何一位*的程序员。以上的描述是仅供参考,即便触犯了其中的某些条目,只要方法本身便于维护、易于理解,那么他仍然是一个好方法。因为重要的不是规则本身,而是规则希望达到的目的。

您给翻译翻译,什么叫做好

我们有了一些列的对于好方法的描述,那么是时候好好聊一聊他们了。

精短

方法要精短,精致、短小。

短小其实是好理解了的,就是方法的行数,很多大家的建议是不要超过80行,但是这个仅仅是一个容忍的上限。一般来说,如果方法的行数看起来接近一屏了,那你就应该检查一下是否有可以进行提取的内容了。由于开发人员在进行代码阅读的时候,实际上是在脑内重新构建代码书写人员的逻辑,当我们理解了一个方法的时候,就会将这个方法所达到的事情替换方法内流程的本身。但在方法本身就具有很长的流程的时候,那就没有办法了。所以长方法会提高代码的理解成本,而让隐患得以藏匿。

精致则是针对if、else if、while等语句说的。在这些语句中,不应该使用长的复杂的逻辑表达式,而应该使用一个可以表达判断意图的变量,或方法来代替。从而保证在关键的逻辑分支是,开发人员的意图表达的足够明确。同样的他们的内部嵌套也不应该太多,大于两层的嵌套就应该考虑是否可以抽取方法,或者用卫语句改写。

语义层级一致

方法应该由一组语义层级一致的逻辑构成。如果我们发现方法中的不同逻辑他们的语义层级不一致的话,那往往是更细粒度的语义应该被再次的抽离到另外的方法中。

例如:

    public static String getResult(String something){
        return getDetails(something) +
                ":" +
                something.length() +
                "\n";
    }

在上面的这个例子中,我们就会发现,关于getDetails(something)的抽象概念是要高于":"+something.length();的,如果将概念高低不同的逻辑放到同一层级的话,倒是的问题是后续的读者可能会针对不同层级的概念进行误解。一旦细节出现,就容易将更重要的高概念淹没在细节中,同时也会对一些真正的高概念产生困惑。

尽管抽象层级的判断不是那么容易,但是至少让细节代码单独在一起

单一职责

单一职责是我们的重头戏,先说总则:一个方法应该只做一件事。这里的单一职责是和单一职责原则的理念一样的,只不过是针对方法来进行描述的。

我们需要方法为单一职责,但是与此同时,我们需要一个定义来描述单一职责,这里我们的定义为:

可以通过“要xxx(方法),做xxxx;做xxxx。”的语句来进行描述的方法,如果语句足够简洁那么我们认为方法是单一职责的。

如果对象的动作,都是直接支撑方法的目的,那么它就足够简洁了。事实上,如果语义层级不一致,就会发现中间的很多的逻辑描述并不是支持方法本身的,而是支持一些间接目的的,那么他们就可以抽象成一个下一层方法。仍然以上一小节的代码来举例,我们可以发现后面的部分的三个拼接实际上是在聚合后缀,其实也可以单独地封装为一个方法。

事实上,对于平时的代码编写过程中,我们有很多的情况很容易打破这个条件,下面我列举一下几种情况。

switch

因为switch天生的就是为了解决多种职责的,如果我们传入一个订单类型,然后通过switch来作为路由调用订单类型的方法。这种方式在最开始的时候可能还好,但随着业务的增长,这种方法会使得方法行数越来越长,并且中间的选项充斥着上下文信息,冗长、且难以理解。

对于switch来说,建议通过抽象工程的形式来进行抽取,或者使用策略模式。总的目的是将switch的路由属性与直接的业务调用解耦。

例如:

    public Money calculatePay(Order order){
        return switch (order.type) {
            case BIG -> calculateBig(order);
            case MIDDLE -> calculateMiddle(order);
            case SMALL -> calculateSmall(order);
            default -> throw new IllegalArgumentException();
        };
    }

但不得不说,JAVA14后对单行switch的写法有优化,虽然这无法阻止这部分的膨胀。但是无论是代码的行数还是代码理解成本都降低了很多(单行省略break)。

副作用

如果方法本身在方法名称描述的职责外仍然有逻辑,那么这个逻辑被称之为副作用。副作用问题在于,十分容易被后续的开发人员忽略。因为如果第一次看该方法的时候忽略了这个副作用,再后续进行逻辑梳理的时候也很难意识到。

举例而言:

    public boolean checkCountGreaterThan(Counter counter, int target){
        if(counter.v > target){
            counter.init();
            return true;
        }
        return false;
    }

上述的checkCountGreaterThan的方法中 对目标Counter对象进行了判断。当满足条件的时候将值进行了初始化。那么这个时候,这个init()方法就是该方法的副作用。如果不看到方法中的具体实现,则很容易将这个初始化动作忽略。

针对这种情况,应该对方法名进行重命名,或者将初始化方法进行上移。

个指令与查询

一般来说,指令方法是让人类做什么事;查询方法是让类回答什么信息。这两种事情不应该同时进行,否则会导致逻辑混乱。比如:

public boolean updateMoney(Order order,Money money);

那么返回的这个int的结果是什么呢?如果没有对应的下层实现代码知识的话,不好轻易的判断这个是说是否有成功更新,还是money值已经相同。对于这种情况来说,最后将语义明确,或者单独用方法来检查情况(可能的情况下)。

使用异常而非业务码

一般对于业务码的判断,会导致方法中对于非成功状态的判断逻辑增加。而真正的核心能力被包裹在很多层的嵌套内。而如果使用异常来代替业务码,那么就可以省略外部的业务码判断,而只需要再业务主路径最外侧进行Try/catch的包裹,从而保证了主业务的完整性。

但是对于Try/catch部分,最好将其单独从业务主体中抽离,形成单独的函数,这样让实际处理业务逻辑的方法更加纯净,例如:


public Money getPayMoney(Long orderId){
        final Order order = findOrder(orderId);
        return calculate(order);
    }

    private Money calculate(Order order){
        try {
            return calculatePay(order);
        }catch (Exception e){
            log.error("Failed to calculate");
        }
        return Money.ZERO();
    }

描述性名称

我们说方法是用于描述业务的,所以好的方法名应该是业务故事中的一部分。一个好的精准的描述性名称,比一段有意义的注释要更好,因为它会直接出现在你的业务逻辑里而非需要在进行专门查看的时候才能展示出来。直接顺序阅读总比反复翻阅要方便得多。

而事实上,当你觉得无法进行一个描述行的起名的时候,往往是由于这部分的代码处理的功能太多了,也就形成了“没时间解释了,快上车!”的情况。那这时候我觉得,就是时候再看看这部分逻辑了,怎么样才能进行一下提取。

精确参数

如果可以形成业务逻辑的话,无参方法好过一个的,而一个的好过两个的。当方法的参数增加的时候,并不是简单的业务逻辑扩展性增加了。伴随而来的是各种的情况组合分支,往往每种组合都是一种逻辑,那就需要开发人员对每种分支都要进行梳理,这是一件十分挑战的情况。

控制参数就是控制方法的入口,对于方法来说可能不是一个id、一个数值那么简单。如果控制不得当,开发人员很容易无法分辨出类型的参数(待填写对象)。所以我们要小心一下这几种情况:

  1. 单个入参的情况,要明确方法是“转换”参数为另一个信息,还是“查询”一个关于这个参数的问题。如果混淆则会导致困惑。

  2. 禁用boolean作为入参,这是毫无意义的,这个参数本身就说明这个方法可以被拆成两个方法。

  3. 小心两个、三个参数时候的参数顺序问题,可以通过“方法名”描述参数的顺序,这样可以减轻记忆负担。

  4. 多个参数就应该封装成参数对象了,他们应该是一个概念领域的。

杜绝复制

那这个就是经典的DRY,别复制自己。

4份复制代码会让成功仅剩一份,但是问题会放大4倍。如果当他们重复出现的时候,不光代码会变得臃肿不堪,也会让新增的特性无法统一维护。即便在旧的代码上进行4份修改,重复的修改也会导致问题出现的概率增加。

但是注意不要进入误区,杜绝复制并不意味着过度抽象,如果反而降低了代码可读性了,那么就与咱们最开始的目标背道而驰了。

忘记规则

虽然本文列举出了很多的注意实现,但是你会发现这里面仍然有很多的是模棱两可的指导性意见。对于代码来说优先的是一种知识的转换。如果因为规则而打断思维那就是得不偿失了。

所以,忘记规则。然后去书写你的业务逻辑,不要让规则成为你的负担。然后业务完毕,那么才到了Check List的时候了。好的代码是不断打磨出来的,但如果一开始就按照这些原则设计,看不上进度的话请不要拉黑我。

最后

文在《Clean Code》第二章函数的基础上,加入了自己的个人理解,重新对案例归类。与原书籍一样,本文也是提供一种思想,其中的一些点与公司目前的代码规范一致,撰写本文让我的理解又加深了。

上一篇:Koltin31,2021年安卓开发者跳槽指南


下一篇:自己实现一个SQL解析引擎