重构荷兰政府那个非常有效的代码

荷兰政府最近被迫发布其DigiD数字认证iOS应用程序的源代码,好事者在review它的代码时,发现了一段非常有趣的函数:

![getPercentageRoundsOriginal.png]

乍看起来,这段代码非常的粗鄙粗暴粗糙。

不过等你想重构的时候,你再看这段代码,或许又有不一样的感觉,

为什么一眼你就看清楚这个函数的业务逻辑?很明显它把百分比转换成打分的字符,用十个字符来表示百分比。我们经常会使用这段逻辑,比如给商品打分或者投票。

这段代码的妙处就在于实现很直接,没有使用什么技巧,非常适合阅读,过了两年你再读这段代码,无需注释一分钟内你也能领悟这段代码的逻辑。

当然这段也不是性能最优的代码,也不是代码行数最短的代码,貌似也没有技术含量。如果照Java资深架构师的设计,又会按照某种工厂模式重构成一堆复杂文件、类和方法,号称可以支持更好的未来的重构,比如将显示字符换成星号、字符的颜色需要改变,字符的数量从10个重构成可变的等等。有时候我把这些叫"伪需求", Go的祖师爷Rob Pike很早以前就写过一篇这种Java重构的bad例子。所以看到这段代码的时候我倒是觉得很亲切。

当然,这段代码也不是最简单最可读的,性能也不是最好的。 网上最近几天针对这个例子讨论了好多,也有用其它语言重构的。今天我把网上同学讨论的思路用Go语言写出来,并使用Benchmark做比较。还是那句话,性能最好的不一定是最优的,有时候可读性比性能可能更重要。

原始版本

原始版本的这个可读性已经非常高了。传入一个百分比的值(取值范围为0 ~ 1, 这个范围我们就假定是这个,不会超过这个范围),然后按照十分制转化成字符串表示。

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
func getPercentageRoundsOriginal(percentage float64) string {
if percentage == 0.0 {
return "☆☆☆☆☆☆☆☆☆☆"
}
if percentage > 0.0 && percentage <= 0.1 {
return "★☆☆☆☆☆☆☆☆☆"
}
if percentage > 0.1 && percentage <= 0.2 {
return "★★☆☆☆☆☆☆☆☆"
}
if percentage > 0.2 && percentage <= 0.3 {
return "★★★☆☆☆☆☆☆☆"
}
if percentage > 0.3 && percentage <= 0.4 {
return "★★★★☆☆☆☆☆☆"
}
if percentage > 0.4 && percentage <= 0.5 {
return "★★★★★☆☆☆☆☆"
}
if percentage > 0.5 && percentage <= 0.6 {
return "★★★★★★☆☆☆☆"
}
if percentage > 0.6 && percentage <= 0.7 {
return "★★★★★★★☆☆☆"
}
if percentage > 0.7 && percentage <= 0.8 {
return "★★★★★★★★☆☆"
}
if percentage > 0.8 && percentage <= 0.9 {
return "★★★★★★★★★☆"
}
return "★★★★★★★★★★"
}

简化判断

实际上,我们可以简化if条件的判断。因为每一个分支进行判断的时候,前面的分支条件都已经判断过了,所以每一个分钟只需做一次判断就可以了。

下面的代码简化了上面的分支判断,可读性更强。

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
func getPercentageRoundsOriginalOpt(percentage float64) string {
if percentage <= 0.0 {
return "☆☆☆☆☆☆☆☆☆☆"
}
if percentage <= 0.1 {
return "★☆☆☆☆☆☆☆☆☆"
}
if percentage <= 0.2 {
return "★★☆☆☆☆☆☆☆☆"
}
if percentage <= 0.3 {
return "★★★☆☆☆☆☆☆☆"
}
if percentage <= 0.4 {
return "★★★★☆☆☆☆☆☆"
}
if percentage <= 0.5 {
return "★★★★★☆☆☆☆☆"
}
if percentage <= 0.6 {
return "★★★★★★☆☆☆☆"
}
if percentage <= 0.7 {
return "★★★★★★★☆☆☆"
}
if percentage <= 0.8 {
return "★★★★★★★★☆☆"
}
if percentage <= 0.9 {
return "★★★★★★★★★☆"
}
return "★★★★★★★★★★"
}

switch

将if分支转换成switch, 可以更有效:

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
func getPercentageRoundsOriginalSwitch(percentage float64) string {
switch int(percentage * 10) {
case 0:
return "☆☆☆☆☆☆☆☆☆☆"
case 1:
return "★☆☆☆☆☆☆☆☆☆"
case 2:
return "★★☆☆☆☆☆☆☆☆"
case 3:
return "★★★☆☆☆☆☆☆☆"
case 4:
return "★★★★☆☆☆☆☆☆"
case 5:
return "★★★★★☆☆☆☆☆"
case 6:
return "★★★★★★☆☆☆☆"
case 7:
return "★★★★★★★☆☆☆"
case 8:
return "★★★★★★★★☆☆"
case 9:
return "★★★★★★★★★☆"
default:
return "★★★★★★★★★★"
}
}

二分法提高性能

如果想提高性能,我们可以采用二分查找法。

上面的两个例子都是自上而下依次查找,自然我们想到使用二分查找法,按照树的结构查找条件分支。数的根节点是0.5,子节点是0.2, 0.7。

按照这种方式查找,性能应该是$$lg^n$$的时间复杂度。

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
35
36
37
38
39
40
41
42
43
func getPercentageRoundsTree(percentage float64) string {
if percentage <= 0.5 {
if percentage <= 0.2 {
if percentage <= 0.1 {
if percentage <= 0.0 {
return "☆☆☆☆☆☆☆☆☆☆"
} else {
return "★☆☆☆☆☆☆☆☆☆"
}
} else {
return "★★☆☆☆☆☆☆☆☆"
}
} else {
if percentage <= 0.3 {
return "★★★☆☆☆☆☆☆☆"
} else {
if percentage <= 0.4 {
return "★★★★☆☆☆☆☆☆"
} else {
return "★★★★★☆☆☆☆☆"
}
}
}
} else {
if percentage <= 0.7 {
if percentage <= 0.6 {
return "★★★★★★☆☆☆☆"
} else {
return "★★★★★★★☆☆☆"
}
} else {
if percentage <= 0.8 {
return "★★★★★★★★☆☆"
} else {
if percentage <= 0.9 {
return "★★★★★★★★★☆"
} else {
return "★★★★★★★★★★"
}
}
}
}
}

子字符串方式

这是一种黑客的方式。

我们预定义一个字符串,返回的结果肯定是这个字符串的一个子字符串。 通过将百分比转换成字符串的索引,可以快速返回结果。

这个可读性就差了很多,需要仔细分析这段代码,并且通过值进行演算,才能确定结果。

1
2
3
4
5
func getPercentageRoundsSubstring(percentage float64) string {
symbols := "★★★★★★★★★★☆☆☆☆☆☆☆☆☆☆"
offset := 10 - int(percentage*10.0)
return symbols[offset*3 : (offset+10)*3]
}

拼字符串

还有一种方式是拼字符串。计算出需要几个"★"字符,几个"☆"字符,再把他们拼装起来。
这个可读性中等,那个数量的计算公式容易出错。另外这个方式性能是比较差的,因为涉及到了字符串的生成和拼接。

1
2
3
4
func getPercentageRoundsConcat(percentage float64) string {
count := int(math.Ceil(percentage * 10.0))
return strings.Repeat("★", count) + strings.Repeat("☆", 10-count)
}

Benchmark

最后,我们写一个benchmark测试一下各种实现的性能:

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
func BenchmarkPercentageRounds(b *testing.B) {
percentage := float64(rand.Intn(10000)) / 10000
b.Run("original", func(b *testing.B) {
for i := 0; i < b.N; i++ {
getPercentageRoundsOriginal(percentage)
}
})
b.Run("originalOpt", func(b *testing.B) {
for i := 0; i < b.N; i++ {
getPercentageRoundsOriginalOpt(percentage)
}
})
b.Run("tree", func(b *testing.B) {
for i := 0; i < b.N; i++ {
getPercentageRoundsTree(percentage)
}
})
b.Run("substring", func(b *testing.B) {
for i := 0; i < b.N; i++ {
getPercentageRoundsSubstring(percentage)
}
})
b.Run("concat", func(b *testing.B) {
for i := 0; i < b.N; i++ {
getPercentageRoundsConcat(percentage)
}
})
}

测试结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
goos: darwin
goarch: amd64
pkg: github.com/smallnest/study/percent
cpu: VirtualApple @ 2.50GHz
BenchmarkPercentageRounds
BenchmarkPercentageRounds/original
BenchmarkPercentageRounds/original-8 174230814 6.876 ns/op 0 B/op 0 allocs/op
BenchmarkPercentageRounds/originalOpt
BenchmarkPercentageRounds/originalOpt-8 283260400 4.220 ns/op 0 B/op 0 allocs/op
BenchmarkPercentageRounds/switch
BenchmarkPercentageRounds/switch-8 545121896 2.209 ns/op 0 B/op 0 allocs/op
BenchmarkPercentageRounds/tree
BenchmarkPercentageRounds/tree-8 497970338 2.401 ns/op 0 B/op 0 allocs/op
BenchmarkPercentageRounds/substring
BenchmarkPercentageRounds/substring-8 569429242 2.111 ns/op 0 B/op 0 allocs/op
BenchmarkPercentageRounds/concat
BenchmarkPercentageRounds/concat-8 8086656 142.5 ns/op 67 B/op 3 allocs/op

测试结果如下, 子字符串的性能最好, switch次之, 二分法再次之, 优化分支法第四, 原始方法第五, 拼接方法非常差:
getPercentageRoundsConcat << getPercentageRoundsOriginal << getPercentageRoundsOriginalOpt << getPercentageRoundsTree << getPercentageRoundsOriginalSwitch << getPercentageRoundsSubstring