“名为万物之始,万物始于无名,道生一,一生二,二生三,三生万物。”—— 《易经》

命名常常被认为是编程中的细节问题,其重要性往往被低估。而所谓的工匠精神,往往就是体现在细节之处,就像日本的“煮饭仙人”50年专注1碗米饭,更何况命名不仅仅是可有可无的细节问题。虽然换一个名字并不影响程序的执行,但是却对代码的表达能力和可读性有着重要的影响。

在程序员的工作中,大概有80%的时间都在阅读和理解代码,而软件中的名称对于软件可读性又有着很大的作用。好的命名让代码的概念清晰,能增加代码的表达力。而词不达意的命名让人摸不着头脑,破坏我们思考的连贯性,分散我们有限的注意力。

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的消亡。

1.2 命名其实很难

起名字这件事,看似不难,确实,随随便便地进行命名的确不是什么难事。但是要经过深思熟虑,取出名副其实,有很好表达性的名字,并不是一件很容易的事。

有人做过一个调查,问程序员最头痛的事情是什么,通过Quora和Ubuntu Forum的调查结果显示,程序员最头疼的事情是命名。如果你曾经为了一个命名而绞尽脑汁,就不会对这个结果感到意外。

命名为什么这么难呢?因为命名的过程本身就是一个抽象和思考的过程,我们在工作中不止一次发现,当我们不能给一个模块,一个对象,一个函数,甚至一个变量找到合适的名称的时候,往往说明我们对问题的理解还不够透彻,需要重新去挖掘问题的本质,对问题域进行重新分析和抽象,有时候还要调整设计和重构代码,因此好的命名是我们写出好代码的基础。

就像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.)”

1.3 有意义的命名

代码即文档,可读性好的代码应该有一定的自明性,也就是不借助注释和文档,代码本身就能显性化地表达开发者的意图,而这种自明性很大程度上,依赖于我们对问题域的理解,以及我们的命名是否合理。

通常,如果你无法想出一个合适的名字,很可能是意味着代码“坏味道”,意味着设计可能有问题。你的一个方法里是不是实现了太多的功能?或者你的类的封装,内聚性不够?抑或你对此问题理解得还不够透彻,需要先获取更多的信息。

1.3.1 变量名

变量名应该是名词,要能正确地描述业务,有表达力,最好做到“望文知义”。如果一个变量名需要注释来补充说明,那么很可能命名就有问题。

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

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

int elapsedTimeInDays;

类似的还有魔术数,数字86400应该用常量SECONDS_PER_DAY来表达。每页显示10行记录的,10应该用PAGE_SIZE来表达。因为如果不结合上下文光看10这个数字,几乎不会有人知道它代表什么意思。

这样做还有一个好处,即代码的可搜索性,在代码中查找PAGE_SIZE很容易,但是想找10就麻烦了,它可能是某些注释或者常量定义的一部分,出现在不同意图的各种表达式中。

1.3.2 函数名

函数命名要具体, 因为空泛的命名没有意义。例如,processData()就不是一个好的命名,因为所有的方法都是对数据的处理,这样的命名并没有表明要做的事情,相比较而言,validateUserCredentials() 或者 eliminateDuplicateRequests()就要好许多。

另一个需要注意的地方是,函数的命名要体现做什么,而不是怎么做。假如我们将雇员信息存储在一个栈中,现在要从栈中获取最近存储的一个雇员信息,那么getLatestEmployee()就比popRecord()要好,因为栈数据结构是底层实现细节,命名应该提升抽象层次,体现业务语义,那么合理的命名可以使你省掉记住“出栈”的脑力步骤,你只需要简单地说“取最近雇员的信息”。

1.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[])

1.3.4 包名

包(Package)代表了一组有关系的类的集合,起到了分类组合和命名空间的作用,在JavaScript的早期阶段,因为缺乏明确的分包机制,导致程序,特别是大型程序,很容易陷入混乱。

包名应该要反映一组类在更高抽象层次上的联系。例如,我们有一组类叫Apple、Pear、Orange,我们可以将它们放在一个包中,并命名为fruit。

包的命名要适中,不能太抽象也不能太具体,此处以上面提到的水果作为例子,如果包名过于具体,如称为Apple,那么Pear和Orange放进该包中就不恰当了;如果报名太抽象,称为Object,而Object无所不包,就失去了包用来限定范围的作用。

1.3.5 模块名

这里说的模块(Module)主要是指Maven中的Module,相对于包来说,Module的粒度更大,通常一个Module是包含了多个Package。

在Maven中,模块名就是一个坐标: <groupId, artifactId>,一方面,其名称保证了模块在Maven仓库中的唯一性;另一方面,名称要反映模块在系统中的职责。例如,在我们的COLA架构中,模块代表着架构层次,因此,对任何应该遵循COLA规范的应用,都有着xxx-controller、xxx-app、xxx-domain和xxx-Infrastructure几个标准模块。更多内容可参考12.4.3节。

1.4 保持一致性

保持命名的一致性,可以提高代码的可读性,从而简化复杂度。因此,我们要小心选择命名,一旦选中,就要持续遵循,保证名称的从一而终。

1.4.1 每个概念一个词

每个概念对应一个词,并且一以贯之。例如,使用fetch, retrieve, get, find和query都可以表示查询的意思,如果不加约定的给多个类中的同种查询方法命名,你怎么记得是哪个类中的哪个方法呢?同样,在一推代码中,同时存在manager、controller和handler,就会令人困惑。

因此,在项目中,作者通常会进行如表1-1所示的预定,以保持命名的一致性。
表1-1 方法名约定

CRUD操作 方法名约定
新增 create
添加 add
删除 remove
修改 update
查询(返回单个结果) get
查询(返回多个结果) list
分页查询 page
统计 count

1.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

1.4.3 后置限定词

很多程序都有表示计算结果的变量:总额、平均值、最大值等等。如果读者要用类似于Total、Sum、Average、Max、Min这样的限定词来修改某个命名,那么要记住把限定词加到名字的最后,并在项目中贯彻执行,保持命名风格的一致性。

这种方法有很多优点。首先,变量名中最重要的那部分,即为这一变量赋予主要含义的部分应当位于最前面,这样,这一部分就可以显得最为突出,并会被首先阅读到。其次,可以避免同时在程序中使用totalRevenue和revenueTotal而产生的歧义。如果贯彻限定词后置的原则,我们就能收获一组非常优雅的,具有对称性的变量命名,如revenueTotal(总收入)、expenseTotal(总支出)、revenueAverage(平均收入)和expenseAverage(平均支出)。

唯一需要注意的是Num这个限定词,Num放在变量名的结束位置表示一个下标:customerNum表示的是当前客户的序号。为了避免Num带来的麻烦,作者建议用Count或者Total来表示总数,用Id表示序号。这样,customerCount表示客户的总数,customerId代表客户的编号。

1.4.4 统一业务语言

为什么要统一业务语言呢?试想一下,如果你每天与业务方讨论的是一套语言,而在团队内部交流、设计画图时使用另一套语言,然后编写的代码中体现出来的又是毫无章法、随意翻译的内容,这就无形中降低了代码的表达能力,在业务语义和文档及代码之间就出现了一条无形的鸿沟。

统一语言就是要确保团队在内部的所有交流、模型、代码和文档中都要使用同一套语言。实际上,统一语言(Ubiquitous Language)也是领域驱动设计(Domain Driven Design,DDD)中的重要概念,我们会在7.4.1节中进行更加详细的介绍。

1.4.5 统一技术语言

有些技术语言是通用的,只要你是圈内的人,你一说出来,大家都能理解。这个时候我们就应该尽量的使用这些术语来进行命名,这些通用技术语言包括DO、DAO、DTO、ServiceI、ServiceImpl、Component、Repository等。例如,在代码中看到OrderDO和OrderDAO,马上就能知道OrderDO中的字段就是数据库中Order表字段,对Order表的操作都在OrderDAO里面。

1.5 自明的代码

有人说“代码是最好的文档”,作者不完全赞同,作者认为更准确的表达应该是加上一个定语:“好的代码是最好的文档”,也就是说,代码想要具备文档的功能,前提必须是代码本身要具备很好的可读性和自明性。所谓自明性,就是在不借助其他辅助手段的情况下,代码本身就能向读者清晰地传达自身的含义。

1.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。只要把计算过程打散成一系列良好命名的中间值,不透明的语义就会突然变得透明。

1.5.2 设计模式语言

使用设计模式语言也是代码自明的重要手段之一,在技术人员之间共享和使用设计模式语言,可以增加极大地提升沟通的效率。当然,前提是大家都要理解和熟悉这些模式,否则会变成“鸡同鸭讲”。

鉴于设计模式在技术人员之间起到的沟通桥梁作用,我们有必要在命名上就将其显性化出来,这样阅读代码的人就能很快领会到你的设计意图。

例如,Spring里面的ApplicationListener,这个命名就充分体现了它的设计和用处,通过这个命名,我们就知道他是使用了观察者模式,每一个被注册的ApplicationListenerApplication状态发生变化的时候,都会接受到一个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);
    }
}

1.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);
        }
    }

1.6 命名工具

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

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

1.7 小结

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

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

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

来源:https://blog.csdn.net/epubit17/article/details/104022509

作者:人邮异步社区