- 软件设计:从专业到卓越
- 张刚
- 2772字
- 2023-03-10 17:11:55
2.2 有意义的命名
在编程规范中,肯定会提到命名。不过,命名仅仅出现在编程规范中是不够的。命名问题远远超出了“风格一致性”可以概括的范畴。好的命名能反映领域模型的概念(详见4.1节),同时也是意图导向编程的重要基础。
2.2.1 一个糟糕的例子
我们先来看下面这段示例代码。它节选自一个名为Yhsj.java的文件,这是一次大学课堂作业的学生作品。如果不继续向下看,我相信很多读者会和我一样,完全猜不到这个文件的内容是什么意思。
public class Yhsj { public int[][] yanghui(int r) { int a[][] = new int[r][]; for (int i = 0; i < r; i++) a[i] = new int[i + 1]; for (int i = 0; i < r; i++) { for (int j = 0; j < a[i].length; j++) { if (i == 0 || j == 0 || j == a[i].length-1) a[i][j] = 1; else a[i][j] = a[i-1][j-1] + a[i-1][j]; } } return a; } }
代码清单2.1 一个不好的杨辉三角形的实现
类似这种代码,在大学作业中,甚至是在一些教材中都不算罕见。这段代码有很多问题。其中,类似于格式或者命名的问题是最显而易见的。例如,将Yhsj(杨辉三角)这样的拼音缩写作为类名,是很多编程规范明确禁止的。更进一步,方法名yanghui也是一个有点别扭的命名。或许有读者会问,杨辉是一个中国人,不叫yanghui那叫什么?
如果代码的作者在编写这段代码的时候能略仔细一点,那么仅需简单查阅就可以知道:杨辉三角的英文名是Pascal Triangle。显然,尊重一个领域的通用说法才更合理,也能大大降低理解代码的成本。
类名和方法名都和本节要讨论的命名有关。不过我们的分析还得更进一步。这段代码还有一个更严重、也更隐蔽的问题,就是应该出现的概念没有出现在代码中。
2.2.2 命名应该反映业务概念
具有一定编程经验的程序员都会明白,命名是软件开发中最重要和最困难的事情之一。不过,一旦掌握了命名逻辑,就会发现——为命名犯难不应该在编程的时候才发生,它应该被前移到问题分析阶段。命名困难的本质,是没有对业务概念建立正确的理解。
在代码清单2.1中会出现Yhsj、yanghui这样的命名,是因为编写者不了解Pascal Triangle这个数学领域的通用概念。此外,代码中出现了大量的a[][]、i、j变量,变量名是简单了,但是业务概念丢失了,代码的可理解性自然不可能太好。
糟糕的命名会伤害代码的可理解性
不好的命名等于是在给代码加密。有一定开发经验的读者可能听说过代码混淆(Obfuscated code)。在一定的业务场景下,出于保护源码的目的,可以对发布的编译后二进制代码进行混淆处理。例如,在Java语言中,如果没有进行代码混淆,那么可以反编译为Java字节码,得到近乎完整的源码。但是,如果代码经过混淆,那么即使反编译成功,也很难分析出程序的真正语义,这样就提高了逆向破解应用的难度。
那么,代码混淆是如何做到的呢?重命名标识符就是最基本的混淆手段。经过重命名,代码标识符和业务概念已经完全没关系了,代码自然会变得非常难以理解。换句话说,在该使用业务概念的地方使用a、b、c这些变量,和混淆后的代码效果差不多——大幅提升了理解的难度。
让我们回到杨辉三角形的例子,先从纯粹数学的角度来理解一下背后的逻辑。杨辉三角形打印出来应该是下面这样的。
1 1 1 1 2 1 1 3 3 1 1 4 6 4 1
它包含如下规律。
规律1:第行的数有个。
规律2:每行的第一个数和最后一个数为。
规律3:每行的其他数是它正上方的数和左上方的数之和。
让我们记住这些规律,然后来看下面的代码。
public class PascalTriangle { public int[] dataOf(int row) { int[] data = new int[row + 1]; for (int col = 0; col <= row; col++) { data[col] = valueOf(row, col); } return data; } private int valueOf(int row, int col) { if (isFirstOrLastElement(row, col)) { return 1; } return valueOfUpper(row, col) + valueOfUpperLeft(row, col); } private int valueOfUpper(int row, int col) { return valueOf(row-1, col); } private int valueOfUpperLeft(int row, int col) { return valueOf(row-1, col-1); } private boolean isFirstOrLastElement(int row, int col) { return (col == 0 || col == row); } }
代码清单2.2 杨辉三角形的更优实现
代码清单2.2中的第3行至第9行反映的是规律1,这部分代码只是完成了数值的拼装,它把如何计算数值的逻辑委托给了valueOf方法。第11行至第16行反映的是规律2和规律3。isFirstOrLastElement、valueOfUpper、valueOfUpperLeft都是为了表达规律2和规律3中的语义而引入的方法名,虽然这三个方法的实现代码都只有一行,且看起来多了一次方法调用,但是从可理解性的角度看,理解isFirstOrLastElement显然要比理解col == 0 || col == row更容易。如果不愿关心更多细节,甚至可以直接忽略第17行以后的代码。
这是一个最简单的来自数学领域的小例子。在规模更大的业务系统中,情况其实也高度类似。一旦识别到了业务概念,那么代码的命名就有了清晰的业务概念作为基础,不会太困难。
领域模型是对业务概念更规范的表述。本书的第4章将会介绍如何发现领域模型,第8章还会进一步介绍如何把领域模型映射到代码中。这些方法对提升代码的命名质量具有非常重要的指导意义。
2.2.3 避免从开发视角命名业务概念
从业务视角而不是开发视角命名代码,是程序员努力的方向。我们同样看一个简单且非常普遍的例子,请对比下面两段代码。
class Customer { Address address; void setAddress(Address address){ this.address = address; } }
代码清单2.3 从开发视角命名方法
class Customer { Address address; void changeAddress(Address address){ this.address = address; } }
代码清单2.4 从业务视角命名方法
setAddress这个方法名反映的是程序员思维:Customer类有一个成员变量叫address,setAddress用来改变这个变量的值。changeAddress这个方法名反映的则是业务思维:存在一个业务概念叫客户(Customer),客户可以变更自己的地址(changeAddress)。
如何才能写出更有业务意义的代码呢?提升编码时的业务意识只是一个方面,还需要一些较为高级的技巧。在第9章我们将会讨论由外而内的设计,这种实现方式配合第8章介绍的领域驱动设计的战术模式,能产出质量更高、更契合业务概念的代码。
2.2.4 面向设计意图进行命名优化
或许有些细心的读者已经注意到了,代码清单2.2中的一些代码命名和常见的约定有所不同。例如,在许多命名规范中,类名一般是名词,方法名一般是动词,或者动词+名词形成的动宾短语。例如,代码清单2.4中的Customer就是一个名词,changeAddress是一个动宾短语。但是,为什么在代码清单2.2中,方法名出现了类似于dataOf(row) 的词呢?
确实,在大多数时候,一个合格的方法名应该是动宾短语。不过,在更好的设计中可以以更灵活的方式优化命名。形如dataOf的命名,是一种基于业务概念的进一步优化,其目的是提升代码的可读性。我们来看一段调用dataOf方法的代码。
class Main { public static void main(String[] args) { int rows = 5; PascalTriangle triangle = new PascalTriangle(); for (int row = 0; i < rows; i++){ print(triangle.dataOf(row)); } } }
代码清单2.5 面向意图表达的方法命名示例
请读者重点关注第6行的语句:print(triangle.dataOf(row));。把这条语句逐字翻译成文字,就是“打印三角形的第row行的数据”。如果僵化地遵循“方法名应该是动词或动宾短语”,那这条语句就要写成print(triangle.calculateData(row));。仔细体会这两种写法,会明显感觉到前者的可读性要优于后者。
好的代码,应该让人读起来像在阅读文章一样。在现代的框架、工具或者流行的项目中,有大量类似的写法。例如,在早期的JUnit测试框架中,仅提供了一种写断言的形式,类似于assertEquals(0,result)。而现在,我们可以使用assertThat(result, is(0)) 这样的断言形式。从本质上讲这两个断言的写法是一样的。但是,从可读性上讲,明显后者要优于前者。
把这种更接近业务语义的命名方式应用到接口定义上,就形成了一种更加有趣、也更加易用的接口定义方式,我们称之为流畅接口(Fluent Interface)。对比如下两段代码。
Person person = new Person(); person.setName("name"); person.setGender(Gender.Female); person.setAge(10);
代码清单2.6 使用setter的对象构造
Person person = Person.builder() .name("name") .gender(Gender.Female) .age(10) .build();
代码清单2.7 使用流畅接口的对象构造
这两段代码都是创建了Person对象并初始化其数据。对比来看,显然后者的表达力更强,代码也更为简洁优雅。