Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

命名方法论 #83

Open
peng-yin opened this issue Jul 20, 2022 · 0 comments
Open

命名方法论 #83

peng-yin opened this issue Jul 20, 2022 · 0 comments

Comments

@peng-yin
Copy link
Owner

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

——《易经》

命名常常被认为是编程中的细节问题,其重要性往往被低估。而所谓的工匠精神,往往就是体现在细节之处,就日本的“煮饭仙人”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()这样的组合则不对称,容易使人迷惑。下面列出一些常见的对仗词组:

  • add/remove –
  • increment/decrement
  • open/close
  • begin/end
  • insert/delete
  • show/hide
  • create/destroy
  • lock/unlock
  • source/target
  • first/last
  • min/max
  • start/stop
  • get/set
  • next/previous
  • up/down
  • old/new

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)命名,如何写注释,如何让代码自明地表达自己,以及如何保持命名风格的一致性。



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





Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant