近来,经常有人问我在Go 语言的项目里,什么样的代码算好代码,什么样的代码算坏代码。
我发现这个练习很有趣。嗯,这个项目太有趣了,所以我决定写一篇博客和大家分享一下。
为了更好的和大家说明我的观点,我利用一个我工作中碰到的问题为例,给大家讲讲。这个项目是一个空中流量控制系统(ATM)。
代码我放在Github[1]上了。
背景
首先,我简单介绍一下这个项目。
EurocontrolEurocontrolAir Navigation Service Provider (ANSP)AFTNADEXPICAO
其实我也不知道这两类信息有啥区别,不过我们当成两类不同的信息就好了。
每种消息类型有它自己的格式,不过两者传达的信息都是等价的(或多或少)。
我们这个项目最关键的是性能。
ADEXP
一种当然是比较差的实现(包名是bad) 一种是老司机的实现(当然是好的啦,包名自然是good)
我们在这个练习里,只处理其中的一小部分。
我们的主要目的是为了说明问题,不是证明我有多强,对吧?
解析
ADEXP
ARCIDARIDACA878
-EETFIR EHAA 0853
-EETFIR EBBU 0908
FIRFIR
FIREHAA 0853EBBU 0908
GEOGEOIDLATTDLONGTD
为了提高处理的效率,我们自然希望通过并发的方式来处理这些连续的数据。
所以,基本我们的算法如下:
goroutinegoroutineADEXPICAO
adexp.goParseAdexpMessage()
咱们下面一步步来比较一下
让我们现在一步步的来实现这个算法。通过两种方式,我们可以比较一下那种写法更好,那种写法比较差,以及为什么。
String vs []byte
字节BytestrimregexpAFTNTCP
译者注:直接用byte处理会速度快一些
Error 管理
在差的实现的这个包里的代码实现是非常糟糕的。(说的非常好,不然呢)。在第二个参数里返回的一些潜在的错误甚至没有得到处理。
preprocessed, _ := preprocess(string)
好的代码会处理各种潜在的错误:
如下的这种实现也不好,还有错误:
if len(in) == 0 {
return '', fmt.Errorf('Input is empty')
}
errors.New()
敲黑板啦,所以好的实现应该是这样的:
避免嵌套
译者注:嵌套是初级程序员跨不去的一个坎 :)
mapLine()
func mapLine(msg *Message, in string, ch chan string) {
if !startWith(in, stringComment) {
token, value := parseLine(in)
if token != '' {
f, contains := factory[string(token)]
if !contains {
ch <- 'ok'
} else {
data := f(token, value)
enrichMessage(msg, data)
ch <- 'ok'
}
} else {
ch <- 'ok'
return
}
} else {
ch <- 'ok'
return
}
}
相反,好的实现可以尽量地避免嵌套:
用我的观点来看,这样的代码更易读一些。另外,错误处理也最好这样实现。
例如:
a, err := f1()
if err == nil {
b, err := f2()
if err == nil {
return b, nil
} else {
return nil, err
}
} else {
return nil, err
}
译者注:这可能是烂的不能再烂的代码了
对比一些这个:
再说一遍,这样看起来好多了。
通过传值或引用传递数据
典型的差的实现如下:
func preprocess(in container) (container, error) {
}
container
因为采用了分片(一个简单的忽略了底层数据的24字节的结构)处理,所以好的实现不存在这些问题。
通过传值或传引用的方式传递参数其实是一个和语言无关的事。通过传值这种方式传递参数可以避免一些副作用,比如数据被修改,等等。
译者注:如果大家对这点有点疑惑,需要去做做功课咯。传值一般是复制一份数据,传递到另外的函数去处理。这样的缺点是浪费内存,优点是数据不会修改。传递指针(也就是引用的方式)优点是节约内存,缺点是数据可能被修改。我看很多C++程序员就是故意这样去处理的。。。很费解。有C++程序员站出来解释一下~
传值还有一些优点,比如在单元测试的时候,或者重构并发的代码等等(否则我们需要每次都检查一下数据是否被修改)。
我非常相信具体在选择传值还是引用的问题上,一定要根据项目的背景去权衡。(谁说不是呢,外国人写点东西废话真不少。。。)
并发
goroutinegoroutine
for i := 0; i < len(lines); i++ {
go mapLine(&msg, lines[i], ch)
}
msg
mapLine()
msgmapLine()msgchannel
给一个共享的Message变量发送指针违反了Go的原则:
不要通过共享内存来通信,通过通信来共享内存。
共享变量有两个缺点:
缺点#1:造成Slice的并发修改
因为可以并发地修改 Slice(两个或两个以上的go routine并发地修改),在差的实现中,我们不得不处理mutex。
例如,Message 包含 Estdata [] estdata。通过append 别的estdata修改slice,一定要这样才行:
事实上,除了一些很特殊的场景,使用在go routine 中使用 mutex 可能本身就是臭代码。
缺点#2:假共享
因为潜在的假共享(一个CPU的core cache的cache line对于别的CPU core cache可能是无效的)的问题,所以通过线程或者goroutine 共享内存不是个好主意。也就是说我们要尽可能地避免在不同的线程或goroutine间共享会被修改的变量。
虽然在这里例子中,因为输入文件很小,所以假共享的作用看不出来。但是,以我来看,最好在开发者牢记这点。
让我们看看好的实现怎么处理并行:
for _, line := range in {
go mapLine(line, ch)
}
mapLine()
goroutine
goroutinegoroutine
我认为这样实现最好,更符合Go语言仅仅通过通信去共享内存的原则。
spawngoroutinegoroutinegoroutine
行处理结束后通知
goroutine
ch <- 'ok'
chan struct{}ch <- struct{}{}chan interface{}ch <- nil
If
Go 允许在判断条件前加其他语句。
如下的代码,
可以简写一下:
if f, contains := factory[sToken]; contains {
// Do something
}
经过重构,提高了一点可读性,有没有呢?
Switch
差的实现中,switch没有添加处理默认情况。
虽然说对于开发者来说,默认值是可选的。不过假如有默认值,肯定会更好:
switch simpleToken.token {
case tokenTitle:
msg.Title = value
case tokenAdep:
msg.Adep = value
case tokenAltnz:
msg.Alternate = value
// Other cases
default:
log.Errorf('unexpected token type %v', simpleToken.token)
return Message{}, fmt.Errorf('unexpected token type %v', simpleToken.token)
}
添加默认值,有助于尽可能地避免开发者开发过程中产生的错误。
递归
parseComplexLines()
Go不支持tail-call(尾调用,在函数体末尾返回另一个函数的调用)优化子函数调用。好的实现通过遍历算法达到了同样的结果。
func parseComplexToken(token string, value []byte) interface{} {
if value == nil {
log.Warnf('Empty value')
return complexToken{token, nil}
}
var v []map[string]string
currentMap := make(map[string]string)
matches := regexpSubfield.FindAll(value, -1)
for _, sub := range matches {
h, l := parseLine(sub)
if _, contains := currentMap[string(h)]; contains {
v = append(v, currentMap)
currentMap = make(map[string]string)
}
currentMap[string(h)] = string(bytes.Trim(l, stringEmpty))
}
v = append(v, currentMap)
return complexToken{token, v}
}
第二种实现比第一种性能更好。
常量管理
我们需要把ADEXP和ICAO消息分开。差的实现如下:
更优雅的Go实现如下:
const (
AdexpType = iota
IcaoType
)
它们的计算结果是一样的,下面的这种写法可以减少潜在的程序员犯的错。
Receiver function
每个解析器提供了函数来判断消息是否是超过了upper level(至少一个点超过了level 350)。
比较差的代码实现如下:
这意味着我们必须把Message传递给函数。然而好的代码使用Message receiver 简化了函数:
func (m *Message) IsUpperLevel() bool {
for _, r := range m.RoutePoints {
if r.FlightLevel > upperLevel {
return true
}
}
return false
}
下面的这些实现更受人喜欢。我们给Message struct 添加了具体的实现。
它可能也是使用Go interface的第一步。假如有一天我们需要创建具有同样行为(IsUpperLevel())的另一个struct,甚至不需要重构初始化的代码(因为Message已经implement这个实现了)。
注释
还有一个很明显的问题就是差的代码注释很少。
另一方面,我尽量像我在真实项目中一样注释“好的代码”。即使这样,我也不是那种会注释每一行代码的开发者,我仍然认为在一个复杂的函数里,至少每个函数以及主要的步骤都应该注释一下。
例如:
关于注释一个很有说服力的例子如下:
// 解析一行并返回header(token名)和值
// 例如: -COMMENT TEST 应该返回 COMMENT 和 TEST (in byte slices)
func parseLine(in []byte) ([]byte, []byte) {
// ...
}
这样的代码真的可以帮助到其他程序员更好的理解现存的项目
雖然最後才說,但不表示它最不重要,根据Go 的最佳实践,package最好也需要注释一下。
日志
另一个显而易见的问题是在差的实现里缺少日志。因为我不喜欢Go标准库的log package,所以我使用了Logrus[2].
go fmt
Go 提供了很好的格式化工具,go fmt。很不幸,我们忘记了在差的实现上执行了(译者注:你确定不是故意的吗?),但是我们再好的代码上执行了。(译者注:好意外啊);
DDD
(略)
性能比较结果
在一台 i7–7700 4x 3.60Ghz机子上,我分别跑了一下两个解析器:
差的实现:60430 ns/op 好的实现:45996 ns/op
差的代码比好的实现慢30%。
结论
对我来说,其实定义好的实现和差的实现是比较难的。离开场景谈应用都是耍流氓。
好的代码的第一个特征是根据需求提供正确的解决方案。一个不满足需求的代码,哪怕性能再好,也没什么卵用。简单、可维护、性能好的代码是值得开发者时刻关注的几个点。
性能不会凭空提升,它和代码的复杂度增加相关。
好的开发者能够根据需求和这些特点中找到平衡。
和DDD一样,context是关键!:)
同时,对于开发者来说
附录:
ADEXP 文件如下:
-TITLE IFPL
-ADEP CYYZ
-ALTNZ EASTERN :CREEK'()+,./
-ADES AFIL
-ARCID ACA878
-ARCTYP A333
-CEQPT SDE3FGHIJ3J5LM1ORVWXY
-EETFIR KZLC 0035
-EETFIR KZDV 0131
-EETFIR KZMP 0200
-EETFIR CZWG 0247
-EETFIR CZUL 0349
-EETFIR CZQX 0459
-EETFIR EGGX 0655
-EETFIR EGPX 0800
-EETFIR EGTT 0831
-EETFIR EHAA 0853
-EETFIR EBBU 0908
-EETFIR EDGG 0921
-EETFIR EDUU 0921
-ESTDATA -PTID XETBO -ETO 170302032300 -FL F390
-ESTDATA -PTID ARKIL -ETO 170302032300 -FL F390
-GEO -GEOID GEO01 -LATTD 490000N -LONGTD 0500000W
-GEO -GEOID GEO02 -LATTD 500000N -LONGTD 0400000W
-GEO -GEOID GEO04 -LATTD 520000N -LONGTD 0200000W
-BEGIN RTEPTS
-PT -PTID CYYZ -FL F000 -ETO 170301220429
-PT -PTID JOOPY -FL F390 -ETO 170302002327
-PT -PTID GEO01 -FL F390 -ETO 170302003347
-PT -PTID BLM -FL F171 -ETO 170302051642
-PT -PTID LSZH -FL F014 -ETO 170302052710
-END RTEPTS
-SPEED N0456 ARKIL
-SPEED N0457 LIZAD
-MSGTXT (ACH-BEL20B-LIML1050-EBBR-DOF/150521-14/HOC/1120F320 -18/PBN/B1 DOF/150521 REG/OODWK RVR/150 OPR/BEL ORGN/LSAZZQZG SRC/AFP RMK/AGCS EQUIPPED)
-COMMENT ???FPD.F15: N0410F300 ARLES UL153 PUNSA/N0410F300 UL153
VADEM/N0400F320 UN853 PENDU/N0400F330 UN853 IXILU/N0400F340 UN853
DIK/N0400F320 UY37 BATTY
原文链接:/good-code-vs-bad-code-in-golang-84cb3c5da49d