clean code 笔记

Names!

一些起名字的碎碎念…

起名字的核心要义首先是考虑所携带的“信息”,其次是考虑所携带的“上下文信息”。

什么是携带的信息的呢?

比方说 i, j, k 是几乎不携带信息的,一般除了打ACM 或者写比较抽象的基础库/函数(比如 cmp)我们也不会选择起这样的名字;相应的,类似 flaggedCell 这样的名字是带有信息的。

一个比较典型的使用表意名称的例子:

1
2
3
4
5
6
7
public List<int[]> getFlaggedCells() {
List<int[]> flaggedCells = new ArrayList<int[]>();
for (int[] cell : gameBoard)
if (cell[STATUS_VALUE] == FLAGGED)
flaggedCells.add(cell);
return flaggedCells;
}

尽量不要直接使用 “1, 2, ‘A’” 这样的 magic number 或者是 list1 list2 这样之类的值。

但如果写测试怎么办呢?比如这样的:

1
2
3
4
5
6
7
8
9
10
var ll = List(List(1, 2, 3, 4, 5),
List("A", "B", "C", "D", "E"),
List("2015-01-10", "2017-08-22", "2016-03-03", "2011-02-02", "2017-02-12"))
val df = FrameX(ll)

val ll2 = List(List(3), List("C"), List("2016-03-03"))
val ll24 = List(List(3, 4, 5), List("C", "D", "E"), List("2016-03-03", "2011-02-02", "2017-02-12"))
val df2 = df(2)
df(2).equals(FrameX(ll2)) shouldEqual true
df(2, 4).equals(FrameX(ll24)) shouldEqual true

里面的 ll2, ll24 完全不表意,但测试用例里面其实就是想构造一些多样的 cases。我想的是尽可能把不同测试数据所测试的用意写进去,比如 ll2 换成 listMatrixFromRowForHappyPass, ll24 换成 listMatrixFromSliceForHappyPass 这样的。

disinformation or noninformation

我以前有一个比较“糟糕”的习惯。就是对于包含复杂成员变量的 class, 总会起类似 ProductInfo, OrderData 这样的名字。其实这里的 “Data”, “Info” 是不表意的,有时候也很难分清 “ProductInfo” 和 “ProductData” 之前的区别。设想两个人在合作写一段业务逻辑,一个人满屏的 OrderInfo, 另一个人满屏的 ProductData, 那之后起名字到底应该带 Info后缀还是应该带 Data后缀?这里的 Data, Info 就是 disinformation。

而 noninformation 是指 a1, a2 这样的名字,一般只在抽象基础方法里使用。

还有一个比较糟糕的习惯是沿自匈牙利命名法的,就是在名字后面添加类型后缀。比如 java 程序里 List<Order> orderList = new ArrayList<Order>();就好像你不写 orderList 你就不知道它是个 List 似的(除非你在用 python)。实际上,使用 orders 可能会更好,尤其你在对代码做类型重构时,比如将 List 换成 Vector, orders 依然表意,orderList 就 gg 了。

Domain Names

在写领域驱动的业务代码时,可以考虑事先商榷一套领域名称来保证命名的一致性。比如 fetch / add / insert ,还有 controller / manager , district / areaName 这种类似名称,最好统一成一个。

Class Name and method Name

给类起名字, 尽量使用名字及名词短语。避免使用 Data / Info 这样的模糊词汇;给方法起名字,方法名往往都是动词,标志着类对象的各种行为。

补充:但是在使用了 “method as property” 的特性或习惯的语言会存在反例,例如 Python 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Foobar:
def __init__(self, x, y):
self.__x = x
self.__y = y

@property
def x(self):
return self.__x
@property
def y(self):
return self.__y

if __name__ == "__main__":
foobar = Foobar(1, 2)
print(foobar.x)
print(foobar.y)

当一个类有多个重载构造器时(constructor overloaded), 书里推荐声明不同的重载方法,如 FromRealNumber(), FromInteger() 这样。但是对 Python 这样的 Duck Typing Language 并不适用,因为在 Duck Typing 里类型实际由类的行为来真实确定,因此一个类并不知道外部会有多少种构造其的方式;对 Scala 这样频繁使用 apply() 的语言似乎也不适用。

to build meaningful context by multiple meaningful functions?

按照我以前的理解,一段过程是否抽象成函数,取决于这段过程是否会被重复使用,是否是上下文无关的,是否可以剥离其他副作用。而在书里,一个过程可以为了过程表意而单独抽成方法,尽管这样的方法可能仅会被调用一次,例如:

一段表意不明的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void printGuessStatistics(char candidate, int count) { 
String number;
String verb;
String pluralModifier;
if (count == 0) {
number = "no";
verb = "are";
pluralModifier = "s";
} else if (count == 1) {
number = "1";
verb = "is";
pluralModifier = "";
} else {
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
String guessMessage = String.format(
"There %s %s %s%s", verb, number, candidate, pluralModifier );
print(guessMessage);
}

与之对比,考虑如下的重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class GuessStatisticsMessage { 
private String number;
private String verb;
private String pluralModifier;
public String make(char candidate, int count) {
createPluralDependentMessageParts(count); return String.format(
"There %s %s %s%s",
verb, number, candidate, pluralModifier );
}
private void createPluralDependentMessageParts(int count) {
if (count == 0) {
thereAreNoLetters();
} else if (count == 1) {
thereIsOneLetter();
} else {
thereAreManyLetters(count);
}
}
private void thereAreManyLetters(int count) {
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
private void thereIsOneLetter() {
number = "1";
verb = "is";
pluralModifier = "";
}
private void thereAreNoLetters() {
number = "no";
verb = "are";
pluralModifier = "s";
}
}

这里的 “thereAreManyLetters / thereIsOneLetter / thereAreNoLetters “ 即是为了表意而被抽出来的方法。这些方法是否会被多次重复使用?不一定,从里面的各种硬编码就可以看出来。这样做是否是有意义的?是的。有没有其他可替换的方式?也许有。考虑如下另一种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void printGuessStatistics(char candidate, int count) { 
String number;
String verb;
String pluralModifier;
if (count == 0) {
// thereAreNoLetters
number = "no";
verb = "are";
pluralModifier = "s";
} else if (count == 1) {
// thereIsOneLetter
number = "1";
verb = "is";
pluralModifier = "";
} else {
// thereAreManyLetters
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
String guessMessage = String.format(
"There %s %s %s%s", verb, number, candidate, pluralModifier );
print(guessMessage);
}

比起我们将方法抽出来,我们选择在每段条件分支加上一行注释,看上去似乎也能解决问题。只是在这里使用注释是否是合适的,是否会在代码重构时被落下,都有待商榷。

书里标准的改进做法是上面第二种写法,第三种是我自己的改进方式,尽管仍然有很多重复代码,但是个人不赞成为了表意而添加不会重复使用的方法这一行为。

Functions!

一些函数定义的碎碎念。。。

你是否定义过这样的函数或方法:一个 get 方法,调用后会向 db session 或者其他全局变量里写东西;写作 getXXX(),读作 saveToDB()…

再严重,你是否碰到过这样的函数,函数的内部过程和逻辑严重依赖于系统上下文,当系统满足这样那样的条件时,调用这样函数才是 work 的。可以设想当团队来一个新人,理解这个函数需要理解系统庞大的上下文;而且为了能够让这样的函数对人 make sense,你也需要在函数头部写上大量的解释 comment…

这样的函数是灾难性的,那些动辄1000行的函数与这样的函数比起来都是小 case。

理想情况下,一个定义的函数应该具有幂等性。

什么是具有幂等性的函数?即满足 f(x) = f(f(x)) 的函数。即:对同一个系统,使用同样的条件,一次请求和多次请求对函数输出,对系统上下文的影响是一致的。

所以书里说的无论是“One function(/ mehod) one thing ”, 还是诸如 “no side effect” 其实都是对幂等性的各类表达。一个函数如果做了不止一件事情,那么重用性就会造成影响;一个函数如果是有副作用的,那么多次调用该函数对整个系统的影响往往是不可预知的。

有人也许会问,怎样衡量一个函数只做了一件事情?所谓一件事情,从不同层次的抽象上看,可能也是多件事情。这里我直接引用书里原句:

If a function does only those steps that are one level below the started name of the function, then the function is doing one thing.

passing parameters

关于传参……

基本思想是能穿一个参就不传多参,传多参时考虑这些参数是否具有相关性,是否可以包成一个便于理解。

函数/方法 往往可以扮演两种角色:一种是数据流中的数据变换器,另一种是 event 发生器。前一种接收一个或多个参数,并带有一个或多个返回值。而另一种接收或不接受参数,在函数体内触发操作,没有任何返回值。

但唯独没有这种角色:函数传入一个参数,在里面把这个参数搞一下,再把这个参数显式/隐式吐出来(output argument)。在老式的 C / C++ 库中经常见到这种风格, 这是由于早期的程序常常较多涉及对指针和数据引用的操作,一般很难将数据作为一个 instance 传入或传出。比如 opencv 里:

1
2
3
4
5
6
7
8
9
10
void cv::cuda::rotate(	
InputArray src,
OutputArray dst,
Size dsize,
double angle,
double xShift = 0,
double yShift = 0,
int interpolation = INTER_LINEAR,
Stream & stream = Stream::Null()
)

里面将dst 作为参数传如,变换了一下作为结果隐式传了出来(函数没有返回值)。这种风格的代码往往令人困惑,因为它将输入和输出混在了一起。而且带有副作用,很难说这样的函数是否具有幂等性。

Exception vs ErrorCode

书里推荐用 throw exception 代替 errorcode。理由如下:如果使用 ErrorCode, 则往往会通过一个 Enum Class 来维护它。当已有的 Enum Item,也就是可选errcode 满足不了业务表意需求时,你需要不断地在对应的 Enum Class 里添加新的错误子类型。而往往这样的Enum Class 在大型项目中会以基础组件的形式出现,你很难有权限修改它(不断修改它,意味着对基础组件频繁的重新编译和版本升级,这对一个多人复用的组件来说是非常草率的),于是只能尽可能去使用意义相近的现存ErrorCode, gg。但 Exception 不同,你可以自由地定义自己需要的 Exception, 然后让它 extends 原生 Exception 就好了。

但是我在不同的书籍中曾看到过不同的观点:在函数式语言里比起 try catch exception 更推崇 return a value 这种方式。是因为 throw exception 破坏了数据 stream 流程,而这种流程在函数式编程里非常重要。函数式编程里会使用一种 Option 类型(在 Haskell 里叫 Maybe)来做异常处理。Option 是一个代数数据类型,它包含两个子类型: Some(value) 和 None。当返回值合法时,会返回Some(value), 其中value 就是对应的数据。而当出现错误和异常时返回 None。

由于 Option / Maybe 本身是个 Monad, 因此可以通过 flatMap 方式将错误处理 包含在连续一个计算流程中,如以下 Haskell 例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interp :: ExprC a -> Env -> Store a -> Maybe (Ma a a)
interp expr env store =
case expr of
NumC num -> Just $ Mahou ((NumV num), store)
PlusC e1 e2 ->
do
Mahou (v1, _) <- interp e1 env store
Mahou (v2, s2) <- interp e2 env store
case numPlus v1 v2 of
Just v -> Just $ Mahou(v, s2)
_ -> Nothing
MultC e1 e2 ->
do
Mahou(v1, _) <- interp e1 env store
Mahou(v2, s2) <- interp e2 env store
case numMult v1 v2 of
Just v -> Just $ Mahou(v, s2)
_ -> Nothing
FunC name param body -> Just $ Mahou ((FunV name param body), store)

其中,interp e1 env store, interp e2 env storenumPlus v1 v2 都是一个返回 带 Maybe 的方法()。按照传统 throw exception 思路,这几个部分都需要考虑用 try catch 包起来,整体代码显得十分冗长。而 Haskell 里面使用 Monad 特性就整洁许多。

最后,无论是起个好名字还是写一个幂等的函数都不是一蹴而就的事情,需要不断的讨论和重构。因此 feel free to write ugly code at first time. 只是在提 PR 时改过来就好了。