就像Stack Overflow的创始人Joel Spolsky所说的:“起一个好名字应该很难,因为一个好名字需要把要义浓缩在一到两个词中。(Creating good names is hard, but it should be hard, because a great name captures essential meaning in just one or two words.)”
此外,Martin Fowler也表示过,他最喜欢的一句谚语是:“在计算机科学中有两件难事:缓存失效和命名。(There are only two hard things in Computer Science: cache invalidation and naming things.)”
如果没有注释,那么可能没人知道“newFormatter.getClass();”是为了判空,注释“Check for a null pointer”就是为了弥补代码表达能力的失败而存在的。如果我们换一种写法,使用java.util.Objects.requireNonNull进行判空,那么注释就完全是多余的,代码本身足以表达其意图。
名为万物之始,万物始于无名,道生一,一生二,二生三,三生万物。
——《易经》
命名常常被认为是编程中的细节问题,其重要性往往被低估。而所谓的工匠精神,往往就是体现在细节之处,就日本的“煮饭仙人”50年专注于做好1碗米饭。一个名字虽然并不影响程序的执行,但是却对代码的表达力和可读性有着重要的影响。
在程序员的工作中,大部分的时间都在阅读和理解代码,好的命名能够让代码的概念清晰,增加代码的表达力;词不达意的命名会破坏我们思考的连贯性,分散有限的注意力。
1 命名的力量
无论是对于人名,还是企业名、产品名,命名都有着巨大的力量。
在阿里巴巴初创的时期,马云想做一个国际化的电子商务网站,要起一个全球化的名字。有一天,他在旧金山的街上发现阿里巴巴这个名字蛮有意思的,正在思考时,一名服务员送咖啡过来。马云问他:“你知道阿里巴巴吗?”他说:“当然知道了,就是open seasame(芝麻开门)”。然后马云在街上找了来自不同国家的数十个人,问他们知道阿里巴巴吗?他们大多能讲到芝麻开门。在英文单词里,“a”排名又在第一位。而且大多数人一听(看)到阿里巴巴这个名字,都会感到奇怪,这样足以给人留下深刻的印象。
在Java企业级应用开发的历史上,也有一段和命名有关的有趣历史。在2000年左右,EJB(Enterprise Java Bean)大行其道,这让Martin Fowler、Rebecca Parsons和Josh MacKenzie等人感到很困惑。后来他们发现人们之所以不愿意在他们的系统中使用普通的Java对象,是因为其缺少一个酷炫的名字,因此他们在一次会议上给普通的Java对象起了个名字——POJO(Plain Old Java Object)。当时的EJB在开发和部署上给开发者带来了沉重的负担,POJO概念的提出很快得到了开发者的拥护。Spring等一系列轻量级框架的诞生,很快终结了EJB的统治地位,因此在一定程度上,POJO这个名字加速了EJB的消亡。
2 命名其实很难
起名字这件事看似不难,但是要经过深思熟虑,取出名副其实、表达性好的名字并不是一件很容易的事。
命名为什么难呢?因为命名的过程本身就是一个抽象和思考的过程,在工作中,当我们不能给一个模块、一个对象、一个函数,甚至一个变量找到合适的名称的时候,往往说明我们对问题的理解还不够透彻,需要重新去挖掘问题的本质,对问题域进行重新分析和抽象,有时还要调整设计和重构代码。因此,好的命名是我们写出好代码的基础。
就像Stack Overflow的创始人Joel Spolsky所说的:“起一个好名字应该很难,因为一个好名字需要把要义浓缩在一到两个词中。(Creating good names is hard, but it should be hard, because a great name captures essential meaning in just one or two words.)”
此外,Martin Fowler也表示过,他最喜欢的一句谚语是:“在计算机科学中有两件难事:缓存失效和命名。(There are only two hard things in Computer Science: cache invalidation and naming things.)”
3 有意义的命名
代码即文档,可读性好的代码应该有一定的自明性,也就是不借助注释和文档,代码本身就能显性化地表达开发者的意图。这种自明性在很大程度上依赖于我们对问题域的理解,以及命名是否合理。
通常,如果你无法想出一个合适的名字,很可能意味着代码“坏味道”、设计有问题。这时可以思考一下:是不是一个方法里实现了太多的功能?或者类的封装内聚性不够?又或者是你对问题的理解还不够透彻,需要获取更多的信息?
3.1 变量名
变量名应该是名词,能够正确地描述业务,有表达力。如果一个变量名需要注释来补充说明,那么很可能说明命名就有问题。
int d; // 表示过去的天数
观察上面的命名,我们只能从注释中知道变量d指的是什么。如果没有注释,阅读代码的人为了知道d的含义,就不得不去寻找它的实例以获取线索。如果我们能够按照下面这样的方式命名这个变量,阅读代码的人就能够很容易地知道这个变量的含义。
int elapsedTimeInDays;
类似的还有魔术数,数字86400应该用常量SECONDS_PER_DAY来表达;每页显示10行记录的,10应该用PAGE_SIZE来表达。
这样做还有一个好处,即代码的可搜索性,在代码中查找PAGE_SIZE很容易,但是想找到10就很麻烦了,它可能是某些注释或者常量定义的一部分,出现在不同作用的各种表达式中。
3.2 函数名
函数命名要具体,空泛的命名没有意义。例如,processData()就不是一个好的命名,因为所有的方法都是对数据的处理,这样的命名并没有表明要做的事情,相比之下,validateUserCredentials()或者eliminateDuplicateRequests()就要好许多。
函数的命名要体现做什么,而不是怎么做。假如我们将雇员信息存储在一个栈中,现在要从栈中获取最近存储的一个雇员信息,那么getLatestEmployee()就比popRecord()要好,因为栈数据结构是底层实现细节,命名应该提升抽象层次、体现业务语义。合理的命名可以使你省掉记住“出栈”的脑力步骤,你只需要简单地说“取最近雇员的信息”。
3.3 类名
类是面向对象中最重要的概念之一,是一组数据和操作的封装。对于一个应用系统,我们可以将类分为两大类:实体类和辅助类。
实体类承载了核心业务数据和核心业务逻辑,其命名要充分体现业务语义,并在团队内达成共识,如Customer、Bank和Employee等。
辅助类是辅佐实体类一起完成业务逻辑的,其命名要能够通过后缀来体现功能。例如,用来为Customer做控制路由的控制类CustomerController、提供Customer服务的服务类CustomerService、获取数据存储的仓储类CustomerRepository。
对于辅助类,尽量不要用Helper、Util之类的后缀,因为其含义太过笼统,容易破坏SRP(单一职责原则)。比如对于处理CSV,可以这样写:
CSVHelper.parse(String)
CSVHelper.create(int[])
但是我更建议将CSVHelper拆开:
CSVParser.parse(String)
CSVBuilder.create(int[])
3.4 包名
包(Package)代表了一组有关系的类的集合,起到分类组合和命名空间的作用。在JavaScript的早期阶段,因为缺乏明确的分包机制,导致程序(特别是大型程序)很容易陷入混乱。
包名应该能够反映一组类在更高抽象层次上的联系。例如,有一组类Apple、Pear、Orange,我们可以将它们放在一个包中,命名为fruit。
包的命名要适中,不能太抽象,也不能太具体。此处以上面提到的水果作为例子,如果包名过于具体,比如Apple,那么Pear和Orange放进该包中就不恰当了;如果报名太抽象,称为Object,而Object无所不包,这就失去了包用来限定范围的作用。
3.5 模块名
这里说的模块(Module)主要是指Maven中的Module,相对于包来说,模块的粒度更大,通常一个模块中包含了多个包。
在Maven中,模块名就是一个坐标: <groupId, artifactId>。一方面,其名称保证了模块在Maven仓库中的唯一性;另一方面,名称要反映模块在系统中的职责。例如,在COLA架构中,模块代表着架构层次,因此,对任何应该遵循COLA规范的应用都有着xxx-controller、xxx-app、xxx-domain和xxx-Infrastructure这4个标准模块。更多内容请参考12.3节。
4 保持一致性
保持命名的一致性,可以提高代码的可读性,从而简化复杂度。因此,我们要小心选择命名,一旦选中,就要持续遵循,保证名称始终一致。
4.1 每个概念一个词
每个概念对应一个词,并且一以贯之。例如,fetch、retrieve、get、find和query都可以表示查询的意思,如果不加约定地给多个类中的同种查询方法命名,你怎么记得是哪个类中的哪个方法呢?同样,在一段代码中,同时存在manager、controller和handler,会令人感到困惑。
因此在项目中,作者通常按照表1-1所示的约定,保持命名的一致性。
表1-1 方法名约定
CRUD操作 | 方法名约定 -- | -- 新增 | create 添加 | add 删除 | Remove 修改 | update 查询(单个结果) | get 查询(多个结果) | list 分页查询 | page 统计 | count4.2 使用对仗词
遵守对仗词的命名规则有助于保持一致性,从而提高代码的可读性。像first/last这样的对仗词就很容易理解;而像fileOpen()和fClose()这样的组合则不对称,容易使人迷惑。下面列出一些常见的对仗词组:
4.3 后置限定词
很多程序中会有表示计算结果的变量,例如总额、平均值、最大值等。如果你要用类似Total、Sum、Average、Max、Min这样的限定词来修改某个命名,那么记住把限定词加到名字的最后,并在项目中贯彻执行,保持命名风格的一致性。
这种方法有很多优点。首先,变量名中最重要的部分,即为这一变量赋予主要含义的部分应位于最前面,这样可以突出显示,并会被首先阅读到。其次,可以避免同时在程序中使用totalRevenue和revenueTotal而产生的歧义。如果贯彻限定词后置的原则,我们就能收获一组非常优雅、具有对称性的变量命名,例如revenueTotal(总收入)、expenseTotal(总支出)、revenueAverage(平均收入)和expenseAverage(平均支出)。
需要注意的一点是Num这个限定词,Num放在变量名的结束位置表示一个下标,customerNum表示的是当前客户的序号。为了避免Num带来的麻烦,我建议用Count或者Total来表示总数,用Id表示序号。这样,customerCount表示客户的总数,customerId表示客户的编号。
4.4 统一业务语言
为什么要统一业务语言呢?试想一下,如果你每天与业务方讨论的是一种编程语言,而在团队内部交流、设计画图时使用另一种语言,编写的代码中体现出来的又是毫无章法、随意翻译的内容,这无疑会降低代码的表达能力,在业务语义和文档、代码之间出现了一条无形的鸿沟。
统一语言就是要确保团队在内部的所有交流、模型、代码和文档中都要使用同一种编程语言。实际上,统一语言(Ubiquitous Language)也是领域驱动设计(Domain Driven Design,DDD)中的重要概念,在7.4.1节中会有更加详细的介绍。
4.5 统一技术语言
有些技术语言是通用的,业内人士都能理解,我们应该尽量使用这些术语来进行命名。这些通用技术语言包括DO、DAO、DTO、ServiceI、ServiceImpl、Component和Repository等。例如,在代码中看到OrderDO和OrderDAO,马上就能知道OrderDO中的字段就是数据库中Order表字段,对Order表的操作都在OrderDAO里面。
5 自明的代码
有人说“代码是最好的文档”,我并不完全赞同,我认为更准确的表达应该加上一个定语:“好的代码是最好的文档”。也就是说,代码若要具备文档的功能,前提必须是其本身要具备很好的可读性和自明性。所谓自明性,就是在不借助其他辅助手段的情况下,代码本身就能向读者清晰地传达自身的含义。
5.1 中间变量
我们可以通过添加中间变量让代码变得更加自明,即将计算过程打散成多个步骤,并用有意义的变量名来命名中间变量,从而把隐藏的计算过程以显性化的方式表达出来。
例如,我们要通过Regex来获得字符串中的值,并放到map中。
Matcher matcher = headerPattern.matcher(line);
if(matcher.find()){
headers.put(matcher.group(1), matcher.group(2));
}
用中间变量,可以写成如下形式:
Matcher matcher = headerPattern.matcher(line);
if(matcher.find()){
String key = matcher.group(1);
String value = matcher.group(2);
headers.put(key, value);
}
中间变量的这种简单用法,显性地表达了第一个匹配组是key,第二个匹配组是value。只要把计算过程打散成一系列良好命名的中间值,不透明的语义自然会变得透明。
5.2 设计模式语言
使用设计模式语言也是代码自明的重要手段之一,在技术人员之间共享和使用设计模式语言,可以极大地提升沟通的效率。当然,前提是大家都要理解和熟悉这些模式,否则就会变成“鸡同鸭讲”。因此,我们有必要在命名上就将设计模式显性化出来,这样阅读代码的人能很快领会到设计者的意图。
例如,Spring里面的ApplicationListener就充分体现了它的设计和用处。通过这个命名,我们知道它使用了观察者模式,每一个被注册的ApplicationListener在Application状态发生变化时,都会接收到一个notify。这样我们就可以在容器初始化完成之后进行一些业务操作,比如数据加载、初始化缓存等。
又如,在进行EDM(邮件营销)时要根据一些规则过滤掉一些客户,比如没有邮箱地址的客户、没有订阅关系不能发送邮件的客户、3天内不能重复发送邮件的客户等。
下面是一个典型的pipeline处理方式,责任链在处理该问题上是一个很好的选项,FilterChain这个名字非常恰当地表达出了作者的意图,Chain表示用的是责任链模式,Filter表示用来进行过滤。
FilterChain filterChain = FilterChainFactory.buildFilterChain(
NoEmailAddressFilter.class,
EmailUnsubscribeFilter.class,
EmailThreeDayNotRepeatFilter.class);
//具体的Filter
public class NoEmailAddressFilter implements Filter {
@Override
public void doFilter(Object context, FilterInvoker nextFilter) {
Map<String, Object> contextMap = (Map<String, Object>)context;
String email = ConvertUtils.convertParamType (contextMap. Get ("email"), String.class);
if(StringUtils.isBlank(email)){
return;
}
nextFilter.invoke(context);
}
}
5.3 小心注释
如果注释是为了阐述代码背后的意图,那么这个注释是有用的;如果注释是为了复述代码功能,那么就要小心了,这样的注释往往意味着“坏味道”(在Martin Fowler的《重构:改善既有代码的设计》一书中,注释就是“坏味道”之一),是为了弥补我们代码表达能力的不足。就像Brian W.Kernighan说的那样:“别给糟糕的代码加注释——重新写吧。”
1.不要复述功能
为了复述代码功能而存在的注释,主要作用是弥补我们表达意图时遭遇的失败,这时要考虑这样的注释是否是必需的。如果编程语言足够有表达力,或者我们擅长用代码显性化地表达意图,那么也许根本就不需要注释。因此,在写注释时,你应该自省自己是否在表达能力上存在不足,真正的高手是尽量不写注释。
在JDK的源码java.util.logging.Handler中,我们可以看到如下代码:
public synchronized void setFormatter(Formatter newFormatter) {
checkPermission();
// Check for a null pointer:
newFormatter.getClass();
formatter = newFormatter;
}
如果没有注释,那么可能没人知道“newFormatter.getClass();”是为了判空,注释“Check for a null pointer”就是为了弥补代码表达能力的失败而存在的。如果我们换一种写法,使用java.util.Objects.requireNonNull进行判空,那么注释就完全是多余的,代码本身足以表达其意图。
2.要解释背后意图
注释要能够解释代码背后的意图,而不是对功能的简单重复。例如,我们在一个系统中看到如下代码:
try {
//在这里等待2秒
Thread.sleep(2000);
} catch (InterruptedException e) {
LOGGER.error(e);
}
这里的注释和没写是一样的,因为它只是对sleep的简单复述。正确的做法应该是阐述sleep背后的原因,比如改写成如下形式就会好很多。
try {
//休息2秒,为了等待关联系统处理结果
Thread.sleep(2000);
} catch (InterruptedException e) {
LOGGER.error(e);
}
或者直接用一个private方法将其封装起来,用显性化的方法名来表达意图,这样就不需要注释了。
private void waitProcessResultFromA( ){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
LOGGER.error(e);
}
}
6 命名工具
当你不知道如何优雅地给变量命名时,可以使用命名工具,快速搜索大型项目中的变量命名,看其他大型项目源码是如何命名的,哪些变量名的使用频率高。特别是对于英语非母语的我们,命名工具会非常有用。
我们可以在IDE中安装一个搜索插件,便于搜索海量的互联网上的开源代码。举例说明,如图1-1所示,作者一般会安装一个叫作OnlineSearch的插件,插件里自带了像SearchCode这样的代码搜索工具,也可以自己配置像Codelf这样的代码搜索工具。
图1-1 OnlineSearch插件
7 小结
命名在软件设计中有着举足轻重的作用,命名的力量就是语言的力量,好的命名可以保证代码不仅是被机器执行的指令,更是人和人之间沟通的桥梁。
命名的重要性不仅体现在提升代码的可读性上,有意义的命名更能够引导我们更加深入地理解问题域,理清关键业务概念,进行合理的业务抽象,从而设计出更加符合业务语义、易于理解的系统。
因此,每一个程序员都应该掌握一套命名的方法论:了解如何给软件制品(Artifact,包括Module、Package、Class、Function和Variable)命名,如何写注释,如何让代码自明地表达自己,以及如何保持命名风格的一致性。
本文摘自《代码精进之路 从码农到工匠》,张建飞 著