peng-yin / note

:shipit: This is My GitHub Pages
https://note-seven-rho.vercel.app/
8 stars 0 forks source link

命名方法论 #83

Open peng-yin opened 2 years ago

peng-yin commented 2 years ago

名为万物之始,万物始于无名,道生一,一生二,二生三,三生万物。

——《易经》

命名常常被认为是编程中的细节问题,其重要性往往被低估。而所谓的工匠精神,往往就是体现在细节之处,就日本的“煮饭仙人”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 变量名

变量名应该是名词,能够正确地描述业务,有表达力。如果一个变量名需要注释来补充说明,那么很可能说明命名就有问题。

  1. int d; // 表示过去的天数

观察上面的命名,我们只能从注释中知道变量d指的是什么。如果没有注释,阅读代码的人为了知道d的含义,就不得不去寻找它的实例以获取线索。如果我们能够按照下面这样的方式命名这个变量,阅读代码的人就能够很容易地知道这个变量的含义。

  1. 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,可以这样写:

  1. CSVHelper.parse(String)
  2. CSVHelper.create(int[])

但是我更建议将CSVHelper拆开:

  1. CSVParser.parse(String)
  2. 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 统计 | count

4.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中。

  1. Matcher matcher = headerPattern.matcher(line);
  2. if(matcher.find()){
  3. headers.put(matcher.group(1), matcher.group(2));
  4. }

用中间变量,可以写成如下形式:

  1. Matcher matcher = headerPattern.matcher(line);
  2. if(matcher.find()){
  3. String key = matcher.group(1);
  4. String value = matcher.group(2);
  5. headers.put(key, value);
  6. }

中间变量的这种简单用法,显性地表达了第一个匹配组是key,第二个匹配组是value。只要把计算过程打散成一系列良好命名的中间值,不透明的语义自然会变得透明。

5.2 设计模式语言

使用设计模式语言也是代码自明的重要手段之一,在技术人员之间共享和使用设计模式语言,可以极大地提升沟通的效率。当然,前提是大家都要理解和熟悉这些模式,否则就会变成“鸡同鸭讲”。因此,我们有必要在命名上就将设计模式显性化出来,这样阅读代码的人能很快领会到设计者的意图。

例如,Spring里面的ApplicationListener就充分体现了它的设计和用处。通过这个命名,我们知道它使用了观察者模式,每一个被注册的ApplicationListener在Application状态发生变化时,都会接收到一个notify。这样我们就可以在容器初始化完成之后进行一些业务操作,比如数据加载、初始化缓存等。

又如,在进行EDM(邮件营销)时要根据一些规则过滤掉一些客户,比如没有邮箱地址的客户、没有订阅关系不能发送邮件的客户、3天内不能重复发送邮件的客户等。

下面是一个典型的pipeline处理方式,责任链在处理该问题上是一个很好的选项,FilterChain这个名字非常恰当地表达出了作者的意图,Chain表示用的是责任链模式,Filter表示用来进行过滤。

  1. FilterChain filterChain = FilterChainFactory.buildFilterChain(
  2. NoEmailAddressFilter.class,
  3. EmailUnsubscribeFilter.class,
  4. EmailThreeDayNotRepeatFilter.class);
  5. //具体的Filter
  6. public class NoEmailAddressFilter implements Filter {
  7. @Override
  8. public void doFilter(Object context, FilterInvoker nextFilter) {
  9. Map<String, Object> contextMap = (Map<String, Object>)context;
  10. String email = ConvertUtils.convertParamType (contextMap. Get ("email"), String.class);
  11. if(StringUtils.isBlank(email)){
  12. return;
  13. }
  14. nextFilter.invoke(context);
  15. }
  16. }

5.3 小心注释

如果注释是为了阐述代码背后的意图,那么这个注释是有用的;如果注释是为了复述代码功能,那么就要小心了,这样的注释往往意味着“坏味道”(在Martin Fowler的《重构:改善既有代码的设计》一书中,注释就是“坏味道”之一),是为了弥补我们代码表达能力的不足。就像Brian W.Kernighan说的那样:“别给糟糕的代码加注释——重新写吧。”

1.不要复述功能

为了复述代码功能而存在的注释,主要作用是弥补我们表达意图时遭遇的失败,这时要考虑这样的注释是否是必需的。如果编程语言足够有表达力,或者我们擅长用代码显性化地表达意图,那么也许根本就不需要注释。因此,在写注释时,你应该自省自己是否在表达能力上存在不足,真正的高手是尽量不写注释。

在JDK的源码java.util.logging.Handler中,我们可以看到如下代码:

  1. public synchronized void setFormatter(Formatter newFormatter) {
  2. checkPermission();
  3. // Check for a null pointer:
  4. newFormatter.getClass();
  5. formatter = newFormatter;
  6. }

如果没有注释,那么可能没人知道“newFormatter.getClass();”是为了判空,注释“Check for a null pointer”就是为了弥补代码表达能力的失败而存在的。如果我们换一种写法,使用java.util.Objects.requireNonNull进行判空,那么注释就完全是多余的,代码本身足以表达其意图。

2.要解释背后意图

注释要能够解释代码背后的意图,而不是对功能的简单重复。例如,我们在一个系统中看到如下代码:

  1. try {
  2. //在这里等待2秒
  3. Thread.sleep(2000);
  4. } catch (InterruptedException e) {
  5. LOGGER.error(e);
  6. }

这里的注释和没写是一样的,因为它只是对sleep的简单复述。正确的做法应该是阐述sleep背后的原因,比如改写成如下形式就会好很多。

  1. try {
  2. //休息2秒,为了等待关联系统处理结果
  3. Thread.sleep(2000);
  4. } catch (InterruptedException e) {
  5. LOGGER.error(e);
  6. }

或者直接用一个private方法将其封装起来,用显性化的方法名来表达意图,这样就不需要注释了。

  1. private void waitProcessResultFromA( ){
  2. try {
  3. Thread.sleep(2000);
  4. } catch (InterruptedException e) {
  5. LOGGER.error(e);
  6. }
  7. }

6 命名工具

当你不知道如何优雅地给变量命名时,可以使用命名工具,快速搜索大型项目中的变量命名,看其他大型项目源码是如何命名的,哪些变量名的使用频率高。特别是对于英语非母语的我们,命名工具会非常有用。

我们可以在IDE中安装一个搜索插件,便于搜索海量的互联网上的开源代码。举例说明,如图1-1所示,作者一般会安装一个叫作OnlineSearch的插件,插件里自带了像SearchCode这样的代码搜索工具,也可以自己配置像Codelf这样的代码搜索工具。

图1-1 OnlineSearch插件

7 小结

命名在软件设计中有着举足轻重的作用,命名的力量就是语言的力量,好的命名可以保证代码不仅是被机器执行的指令,更是人和人之间沟通的桥梁。

命名的重要性不仅体现在提升代码的可读性上,有意义的命名更能够引导我们更加深入地理解问题域,理清关键业务概念,进行合理的业务抽象,从而设计出更加符合业务语义、易于理解的系统。

因此,每一个程序员都应该掌握一套命名的方法论:了解如何给软件制品(Artifact,包括Module、Package、Class、Function和Variable)命名,如何写注释,如何让代码自明地表达自己,以及如何保持命名风格的一致性。



本文摘自《代码精进之路 从码农到工匠》,张建飞 著