i@yujinyan.me

Blog

一种判断代码是否优雅的方式

我们在评价代码的时候会说这个代码「优雅」或者说「不优雅」,这里面包含了方方面面的东西。《代码整洁之道》《代码大全》很多书都有详细的介绍。在 review 代码的时候可以看到有些是小问题,比如命名、注释等等,有些却是大问题,需要特别注意。

我们应当非常在意代码对外提供的 interface,即对外接口。判断一个函数优雅不优雅,最直接的途径是看它的函数签名是不是 make sense。函数签名包含的内容包括:函数名、参数及其类型、返回值及其类型。除了函数签名之外可能还可以包含注解、注释等辅助性的内容。如果函数在一个 class 里面作为成员方法,那么还包含这个类的本身。唯一不能包含在内的就是函数的 body,这一点极为重要。我们看到一个函数调用,如果不点进去函数体看具体实现就无法理解的话,那么这个函数的设计就不够优雅。举一个最近在项目中看到的例子:

/**
 * 获取视频封面,url / object key 皆可
 * @param string $object
 * @param Asset $asset
 * @return string
 */
public static function getCover(string $object, Asset $asset = null)

我们把函数体隐去,看看这个函数做的是什么事情?函数方法名叫 getCover,返回值是 string 类型,那么我们可以猜出来这个函数是要获取一个视频封面,这个事情注释上头也复述了一遍(所以注释里「获取视频封面」这句话重复了方法名,是一句废话,可以删去)。蹊跷的是这个函数有两个参数,一个是 string 类型的 object,一个是 asset。熟悉项目的同学会知道,object 指的是对象存储的路径,asset 指的是存在数据库里的数据结构,里面有个 string 类型的 url 字段,记录了文件的 url。现在这个方法需要我同时传这两个参数,我就疑惑了,这两个参数之间需要满足什么关系?

进一步观察,第二个 asset 参数其实是可选的,所以说我其实可以 getCover(anObjectKey) 这样调用。这似乎可以理解,将一个对象存储的路径转换成封面图的路径。那如果我有一个 asset 我的第一个参数 object 应该传什么呢?传一个 null 吗?但是这不符合 string 的类型限制。传空字符串吗?还是随便传一个字符串?我要是同时传一个合法的 object key 以及 asset 进去,这个函数会返回什么?要解答这些问题我必须进这个函数去看才行。这个时候,这个函数封装的意义被打破了,我其实不需要进去看就已经能判定,这个设计是有问题的,这个函数是不优雅的,因为它提供了一个错误的接口,一个令调用方感到困惑的接口。

对这些「问题代码」比较有经验的同学可能可以猜得到,这段代码里面其实做了判断,如果有第二个参数 asset 的时候会根据 asset 上的 url 做字符串的处理,否则对第一个字符串做处理。其实是一件非常简单的事情,更加合适的做法应该把这个函数改造一下,只接受一个 string 类型的参数,如果调用方手里拿了一个 asset,根据函数签名里要一个 string 类型的入参这个事实,应该能够很自然地想到把 asset.url 传进来。如果要更进一步,在有函数重载的语言里,可以重载 getCover 方法,没有重载的语言可以用不同的函数名,比如:

public static function getCoverFromObjectKey(string $key)
public static function getCoverFromAsset(Asset $asset)

可以注意到,在这些函数里面还消除了可选 nullable 参数的情况,是一个非常简单的纯函数。

有些人对这些问题不以为然,觉得不就是一个 if 条件嘛,总归去不掉,无非就是放在这个函数里还是放在那个函数位置不一样的区别。如果就事论事看这个问题,还真去掉了这个 if 条件。本来调用的位置就不一样,在上传的时候用的 object key,入库了以后有 asset 就没有 object key 了,根本就是两个不同的位置,不需要额外加戏,把两种类型的参数都收进来混在一起做判断。

更重要的是,代码是给人用来读的,只不过恰好可以给机器执行(SICP)。在结构化编程(Structured Programming)中,函数或者说更一般的「子程序」(subroutine)帮助我们将一系列逻辑完整的操作组合在一起,并赋予一个有含义的名字。我们在需要使用这段子程序的地方,只需要用这个名字就能执行这段逻辑自洽的语句。阅读代码的时候,我们看到函数名称就能够在一个高层次「脑补」这段逻辑的意图,大概要做什么事情,不需要脱离上下文就能清楚地把握住代码的逻辑流。如果代码里面所有的子程序都莫名其妙,意图不明,违背直觉,我们就必须不断进入函数内部一看究竟,这样递归下去回溯回来以后上下文(context)可能都丢失了,不记得原来代码里在干嘛,又得再看一遍,这样不断上下文切换,逻辑流被打乱,令人头痛。

所以说,我们在写函数、方法的时候,必须从调用方的角度去考虑,我写的方法对外提供了一个什么样的功能,别人在调的时候是不是方便,如果单看函数签名是不是能够猜出来函数里面在做什么事情。这其实就是一种「设计」。如果思路无法做出这样的转变,代码是不可能优雅起来的。

这让我联想到 Java 里头有的时候为啥我就一个实现的类,却还要写一个 interface,不是多此一举吗?这么想来,写 interface 其实正好促使我们抛开具体实现,先好好想一下上面提到的这些问题,先做一下设计的工作。把接口定下来以后再去考虑实现。我们在实现功能的时候太容易一下子就钻入实现的细节里头出不来,忽视了设计的工作。

回到这个上面这个函数,大家或许也可以猜到,一开始这个函数并不是长这样的,而是被维护成这样,后面的可选参数是后来被加上去的。所以仔细想来其实也可以理解,毕竟在 PHP 这样动态类型的语言里,似乎还是小心为妙。遇到这种情况,想来我自己也是不太敢大刀阔斧地改动。但是一边迭代做新需求,代码却不做适宜的重构,没有新陈代谢,质量怎么可能高呢?这又是另外的话题了。