荷兰政府最近被迫发布其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