在代码中命名

避免使用单个字母命名

数学家以简洁为荣,他们爱把表达式浓缩成最精简的模式。

问题在于你不用编辑数学式子,但需要编辑代码

因为一个字母无法体现一个变量的相关信息

绝对不要缩写

缩写的含意依赖于上下文,而你并不总是了解这个上下文。这就会造成读代码的时间可能比写代码的时间还多

因此强行让自己去理解各种不同的命名方式,只会让阅读陌生代码更加困难

既然现在我们有了会自动补全的IDE和大屏幕,为什么不把名字写完整些?

在变量名中带上单位

比如说你有一个接收延迟时间为参数的函数

1
void execute(int delay)

如果这个值的单位为秒

1
void execute(int delaySeconds)

这样这个函数的用户就能明白,应该传入秒数为参数

同时也让编辑这个类的人能够明白,这个函数用的是什么单位

当然,更好的做法是使用枚举,这样用户就不用关心具体的单位

1
2
3
4
5
6
7
8
@Getter
public enum Delay {
SECONDS_1(1),
SECONDS_5(5),
SECONDS_10(10),

private final int seconds;
}
1
void execute(Delay delay)

补充:接口枚举规范

顺带一提,接口入参可以使用枚举,但返回值不要用枚举。因为在阿里巴巴的Java开发手册中有提到

二方库里可以定义枚举类型,参数可以使用枚举类型,但是接口返回值不允许使用枚举类型或者包含枚举类型的 POJO 对象

笔者注:

二方库 也称作二方包,一般是指发布到公司内部私服上面的 jar 包,可以供公司内部其他服务依赖使用;

一方库 是指本工程内部子项目模块依赖的库;

三方库 是指公司之外的开源库,比如常见的 Spring、Mybatis、Dubbo 等等。

先解释为什么不能返回枚举[^1]

一般情况下,A系统想要提供一个远程接口给别人调用的时候,就会定义一个二方库,告诉其调用方如何构造参数,调用哪个接口。

而这个二方库的调用方B会根据A定义的内容来进行调用。而参数的构造过程是由B系统完成的,如果B系统使用到的是一个旧的二方库,使用到的枚举自然是已有的一些,新增的就不会被用到,所以这样也不会出现问题。

但如果A返回值包含二方库中的枚举类型,那么当A的二方库版本更新而它的下游服务没有更新时,返回二方库中的枚举就会发生IllegalArgumentException,进而引起接口调用异常

简单来说就是:上游的二方库版本总是 ≥ 下游的二方库版本,所以为了避免下游被新知识整懵,别携带二方库的枚举

那为什么参数中可以使用枚举类型呢?[^2]

因为枚举类型是由提供方进行维护的,提供方包含的枚举类型属性肯定是多于等于消费方的枚举类型属性的,所以接口的入参中使用枚举类型是不会出现问题的。而返回值里面就不一样了,如果提供方返回了一个消费方不认识的枚举属性,就会抛出上述的异常。

不用在名称上突出基类

如无必要,不用在基类上特意加上Base或者Abstract,修改子类的名字就足够了

TrackBaseTrack更合适一些,加上Base反而会让人思考编写者的真实用意

  • 如果有人拿到的是Track,那就是一辆卡车。他们不需要知道子类的细节。
  • 如果需要指定具体的卡车类型,那就用它的子类比如TrailerTruck

不要滥写Utils

不要一股脑的把方法都放到Utils类里面

如果我们有一堆关于电影类的函数,我们可以考虑把一些处理电影的方法放到Movie类里面,把一些处理电影集合的方法放到MovieColletion类里面

如果有需要,我们可以让一些类接受泛型,比如Pager<T>,这样我们也可以处理其他类的分页了

组合优于继承

组合和继承都是为了解决同一个问题:代码复用

为什么不要继承?

当你为了复用代码而继承某个类时,你不得不和基类发生耦合,你可能需要重写基类中那些你用不到的无意义方法,即使你只是让他们抛个异常。而为了解决这个问题,你可能需要另开一个或几个类,然后对代码重构。

修改是完美设计的天敌。继承会自然而然地让你把所有公有的部分放进一个基类,但当你发现特例时,就需要大改

那什么是组合?

不通过继承复用代码就是在组合。

假设说我们有一个DrawImage类,那么比起直接继承Image类来复用Image类的部分方法,还不如直接给他添加一个Image类型的属性,也就是

1
2
3
4
public class DrawImage{
Image image;
Pen pen;
}

这样不就和Image类解耦了吗,我们可以专注的实现DrawImage的方法,同时该类方法入参中含有Image的,还能对Image的所有子类生效

总结

继承的两大特色就是复用和构造抽象(也就是子类必须要实现某些部分),如果我们仅仅只需要复用,组合就足矣了。

继承的正确做法是让消费者认为它拿到的是某个类的实例,也就是要符合里氏代换原则[^3]

另外,如果父类的某方面抽象能力不是所有子类都需要实现的,是不是就该接口上场了?

由于接口隔离原则[^4],接口都是最小化实现的。接口不关注实现它的是谁,它只关心它的实现类都实现了它的功能

组合并不是绝对完美的,但好歹比继承带来的“摩擦力”要小一些。

不要过早抽象

少量的重复代码和过度耦合相比,它不会在修改代码时造成那么大的痛苦

这里说的抽象同时包含了抽象类和接口

耦合是抽象的反作用力。抽象层次越高,耦合越高

不要过早抽象,也不要因为有重复代码而做意义不大的抽象。如果只是为了减少一两行的代码而将两个没有什么关系的方法抽象成接口,那完全没必要,毕竟它并没有什么逻辑在里面。

但如果重复的地方很多,比如3种以上,又或者我们要接连调用这些有重复部分的代码,那么抽象的性价比就会高很多,也许就有必要抽象了

举个例子,如果只有SaveJsonSaveXml,那完全没必要特地为了他们而抽象。但如果我们还有MysqlSaverAwsSaver,或者我们要延迟或重复保存,那我们甚至可以专门来定义一个SaverFactory类来帮助抽象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SaverFactory {
private GameConfig config;

public SaverFactory(GameConfig config) {
this.config = config;
}

public FileSaver create() {
switch (config.getSaveMode()) {
case Xml:
return new SaveXml(config.getSaveUri());
case Json:
return new SaveJson(config.getSaveUri());
case SqlLite:
return new MysqlSaver(config.getSaveUri());
case Aws:
return new AwsSaver(config.getAwsKey());
default:
throw new IllegalArgumentException("Invalid save option");
}
}
}

“勿写注释”

代码本身要比其上的注释更有助于解释意图

一般来说要是你觉得需要用人类语言来解释你的代码,先考虑一下能不能让你的代码更像人话**(see if you could make you code more human)**

你应该通过命名让读者明白你的代码在干什么

通过注释解释代码的原理表明你为什么要这么做

通过文档解释代码的用法

清晰命名

对于下面的代码,为了解释5所代表的含意,我们可以加上注释

1
2
3
4
# 5 代表发送消息
if (status == 5) {
message.markSent();
}

但更好的做法是定义一个常量表示它。现在这行if语句本身读起来就像注释了

1
2
3
4
MESSAGE_SENT = 5;
if (status == MESSAGE_SENT) {
message.markSent();
}

使用泛型

为了更好的约束维护者,比起在你的注释声明某段代码需要接受什么类型,还不如直接给原代码上个泛型

使用Optional

如果我们的某个方法返回String,但String是可选的,也就是说返回null也是有逻辑含意的,那我们可以使用Optional

1
2
3
4
5
6
7
public class Example {
public Optional<String> getString() {
// 假设这里有一些逻辑来获取字符串
String result = ...;
return Optional.ofNullable(result); // 返回可能为null的值的Optional
}
}

这样,我们就暗示告诉调用方他们需要处理返回null的情况,而不用特意给出注释

1
2
3
4
5
6
7
8
9
Example example = new Example();
Optional<String> optionalString = example.getString();

if (optionalString.isPresent()) {
String value = optionalString.get();
System.out.println("Value: " + value);
} else {
System.out.println("Value is absent.");
}

或者,也可以直接使用orElse方法来指定一个默认值,以防返回的Optional为空:

1
2
String valueOrDefault = optionalString.orElse("Default Value");
System.out.println("Value: " + valueOrDefault);

有时候注释也是必要的

  • 如果一段代码为了性能优化写得很嗨涩,注释可以解释这里为何这么怪
  • 如果一段代码用到了某种数学公式或算法,可以考虑给出链接便于后续维护者参考

成为“不嵌套主义者”

——Linus Torvalds

从方法开始,如果把每一个左花括号都视作在加深函数的嵌套深度,那么一个没有内部代码块的函数嵌套深度就为1

当函数内有两级嵌套的时候,函数的嵌套深度就为3——这也是无嵌套主义者的最高忍耐限度,再加深到4就有些不礼貌了,比如这个

1
2
3
4
5
6
7
8
9
10
11
12
13
private int calculate(int bottom, int top) {
if (top > bottom) {
int sum = 0;
for (int number = bottom; number <= top; number++) {
if (number % 2 == 0){
sum += number;
}
}
return sum;
} else {
return 0;
}
}

提炼函数

把函数的一部分提炼成一个单独的函数,这样我们可以将刚才的函数变成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private int filterNumber(int number) {
if (number %2 == 0) {
return number;
}
}

private int calculate(int bottom, int top) {
if (top > bottom) {
int sum = 0;
for (int number = bottom; number <= top; number++) {
sum += filterNumber(number);
}
return sum;
} else {
return 0;
}
}

反转

反转条件,使函数尽早返回

也就是把负面分支放在前面,当遇到错误条件,直接返回。将正面的分支放在最下面

这样就形成了一种验证守护(validation gatekeeping),就像是声明了函数的要求,然后一步步过滤入参,直到正确条件

再将上面的例子修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private int filterNumber(int number) {
if (number %2 == 0) {
return number;
}
}

private int calculate(int bottom, int top) {
if (top < bottom) {
return 0;
}

int sum = 0;
for (int number = bottom; number <= top; number++) {
sum += filterNumber(number);
}
return sum;
}

不要过早优化

“Premature Optimization is the root of all evil” —— Donald Knuth

过早优化是万恶之源 ——高德纳

大部分关于性能的讨论都是在浪费时间,并不是说性能不重要,而是因为人们对性能看得太重太重了

假设我们将项目开发的三个重点——性能、开发速度、可拓展性视为一个三角形的三个角

  • 专注于速度意味着尽可能快地把某个东西搞出来,找到实现功能的最短路径,置未来维护者的生死于不顾。在这个过程中你会欠下大量技术上的债,最终会拖你后腿。
  • 适应性谈的是要把代码写得能够适应新的需求,比如说可复用、可扩展的组件,精心打造的接口和可配置性。只要有良好的设计,就可以通过减少增加新特性所需要的改动来提高开发速度。但要是你让它太能适应了,速度也会慢下来。要是你打造了一个过度可适应的系统,它甚至能够适应各种根本不会出现的情况,那最终也不过是浪费时间。高度可适应的系统也会降低性能。

应该结合项目所处阶段来考虑我们要将三角形的重心往哪倾斜。

性能是问题,但通常不是第一个出现的问题。你需要慎重考虑要朝哪一角倾斜

通常“更快地找到问题的解决方案”都要好过“更快的代码却迟迟解决不了问题”

所以就笔者而言,可读性 + 适度的拓展性 >> 过早考虑那些可能的性能问题

要根据实际场景来看待性能问题,就好比一个访问量不大的网站,只有20万条数据的表,直接用like来模糊查询也不见得比大费周章的使用ES快多少

参考资料

[^1]: 为什么阿里 Java 开发手册规定接口返回值不允许包含枚举类型
[^2]: 求你了,不要再在对外接口中使用枚举类型了!
[^3]: OrionLi’s Blog-软件设计原则-里氏代换原则
[^4]: OrionLi’s Blog-软件设计原则-接口隔离原则