代码整洁之道

关于如何写整洁代码的一些总结和思考

最近在KM上看了不少关于Code Review的文章,情不自禁的翻出《代码整洁之道》又看了一下,于是在这里顺便做个总结。其实能遵守腾讯代码规范写出来的代码质量已经不差了,这里主要是代码规范中容易犯的一些错和自己的额外总结。

目录

整洁代码.png

衡量好坏代码的标准

什么样的代码算整洁的代码?好的代码?谈到代码好坏一定少不了这张图。

wtfm.jpg (500×471) (osnews.com)

WTFs/minute简而言之就是你代码被人“感叹”的频率,代码必定是有好坏之分的,但在每个人心里的标准又不一样,没法量化一个好坏代码的标准,但是如果一段代码让人难以读懂,乱七八糟,难以扩展和维护,让人完全没有读下去的欲望,那肯定不是一份好代码。

为什么要注重代码整洁

代码就像自己的孩子,作为父母肯定都希望孩子长的好看一点,出去被人夸长的好看,人见人夸,而不是见者WTF!

这写的是啥

增加可维护性,降低维护成本

从可读性来说,代码是写给人看的,团队不乏人员交替的负责一份代码的迭代和维护,如果别人阅读你的代码很难读懂,那他在代码的理解上肯定会有问题,比如某些细节没理解清楚,就可能会埋下一个bug坑。

从可扩展性上来说,如果你只是修改一个简单的功能,但是要涉及大量的代码改动,不仅开发难度加大,测试难度也会加大,甚至到了最后难以扩展需要被重构,这无疑给团队带来了灾难。

对团队和个人产生积极的影响

首先是对自己的影响,自己写的代码被别人review的时候或者被后人修改的时候,不会被频繁WTF,不会让后面的维护者气冲冲的敲下git blame并口里大喊着:“这人不讲码德呀!谁写的!”乃至在后面晋升职级时候的代码评审也会有好的帮助。

不讲码德.png

其次代码可能是会传染的。比如你要维护一份烂代码,很可能你都不想碰,更别说重构了,这样一直在烂代码上堆积if else等逻辑,无疑会让代码腐烂下去。但如果你代码写的干净整洁,遵守规范,容易被人阅读和维护,别人看到之后或许也会被你传染,也许他原来不遵守代码规范,看到你的代码之后恍然大悟,从此开始注重代码整洁度和代码质量。

如何写整洁的代码

这里省略一些诸如不要用拼音命名,函数之间要有空行,统一缩进等此类人人都知道且很少会犯的点

规范

遵守团队规范

无规矩不成方圆,写代码也是,遵守团队的代码规范(腾讯代码规范)是作为程序员的基本素养。这些规范都是经验丰富的顶级大佬总结出来的,能成为公司标准必然是经过深思熟虑的,有时候我们应该舍弃一些个人风格,保持团队统一。

规矩.png

有时候规范不一定是绝对的,比如C++缩进2空格还是4空格的问题,这并没有孰好孰坏,只有个人风格问题,但在一个团队中,最好还是保持风格一致,风格统一的代码看起来才不会太乱。如果是C++则可以定一个统一的clang format文件,团队统一格式化,golang则使用go fmt即可(其实这个工具也是为了统一风格不是吗)。

再比如golang强制大括号的换行方式不也是为了统一格式在努力吗?

入乡随俗,遵循语言风格

不要把其他语言的风格带到另一个语言中。比如写Python,尽量使自己的代码更加Pythonic。下面是一些列子:

  1. 交换两个数

    C/C++中你习惯这样交换两个数:

    int temp = a;
    a = b;
    b = tmp;
    

    Python:

    a, b = b, a
    
  2. 列表推导

    在Python可以这样获取[1,10)之间的偶数

    [i for i in range(1, 10) if i % 2 == 0]
    
  3. 比较

    其他语言比较

    if a > 10 && a < 20
    

    Python

    if 10 < a < 20
    

    还有更多这里不一一列举了

目录结构

目录结构要有设计

对于项目级别的目录要有良好的设计,目录结构设计好,后期项目越来越大的时候才不至于太乱,难以管理。

及时分类

当一个目录文件过多,且类型比较杂的时候,要考虑按照类型分多个目录/包,不要偷懒,这样才不至于让一个目录无限膨胀下去,对代码分包,分类也有助于梳理代码,使代码结构更加整洁。

文件

文件不要过大

文件行数不要过多,任何规范肯定都会有,这里还是强调一下,golang不超过800行。一般情况下,单个文件过大,对阅读会造成一定的困难,如果格式好一点还好,如果格式乱的话简直就是噩梦。虽然现在的IDE都具备一键折叠代码的功能,但一个文件内容过多说明你没有及时对齐进行分类整理。别人维护的时候难以快速定位到关注点。

文件末尾留一行

  • 文件末尾新增一行时,如果原来文件末尾没有换行,版本控制会把最后一行也算作修改(增加了换行符)

    比如这里在原来文件末尾没有换行的情况下,新增一行cal

    # before
    #!/usr/bin/env bash
    python cc_auto_check_in.py
    
    # after
    #!/usr/bin/env bash
    python cc_auto_check_in.py
    cal
    
    PS D:MyProjectspythoncc_auto_check_in> git diff 0158a324da9c991c8cbfa8bffe03736150855a7a .cc_auto_check_in.sh
    diff --git a/cc_auto_check_in.sh b/cc_auto_check_in.sh
    index 2875f19..2ba4a4c 100644
    --- a/cc_auto_check_in.sh
    +++ b/cc_auto_check_in.sh
    @@ -1,2 +1,3 @@
     #!/usr/bin/env bash
    -python cc_auto_check_in.py
     No newline at end of file
    +python cc_auto_check_in.py
    +cal
     No newline at end of file
    
    
  • 如果文本文件中的最后一行数据没有以换行符或回车符/换行符终止,则许多较旧的工具将无法正常工作。他们忽略该行,因为它以^ Z(eof)终止。

  • 文件是流式的,可以被任意的拼接并且拼接后仍然保证完整性。PS:[为什么C语言文件末尾不加换行会warning](Jim Wilson - Re: wny does GCC warn about "no newline at end of file"? (gnu.org))

  • 光标在最后一行的时候更加舒适

命名

有意义的命名

我们都知道了命名不要用一个字母,不要用拼音,要遵守规范驼峰或者下划线等等,但常常忽略了一点,很多人喜欢用自创的缩写来代替原单词,比如:ListenServerPort缩写为LSP,不知道的还以为是Language Server Protocol 或者老色批的缩写呢。不要为了写短一点而忽略了可读性,命名长一些没关系。只有那些非常面熟的再用缩写。

尽量有意义,不要用1,2,3等

good:

void copyChars(const char *source, char *destination)

bad:

void copyChars(const char *a1, char *a2)

缩写全大写

good:

userID
QQ
SQL

bad:

userId
Qq
Sql

避免误导性命名

命名的时候多想想,不要起名字太随意了。函数名表达函数功能,曾经见过用ABC三个单词排列组合来命名多个函数,完全不知道这n个函数功能有啥区别。

good:

func doSomething()

bad:

// ABC是任意单词且不代表顺序
func doABC()
func doBAC()
func doCAB()

表达式

简单

比如在go中可以把能省略下划线的省略:

good:

for key := mapFoo {
}
for index := listFoo {
}

bad:

for key, _ := mapFoo {
}
for index, _ := listFoo {
}

少用奇技淫巧

很多人习惯把乘除2的倍数用位运算代替来提高性能,然而经过编译器优化最后结果都一样(如果是20年前这样做可能还有点用,这虽然算不上奇技淫巧)。这样只会让人理解代码加多一步。

redis源码注释中的这篇文章也有提到这点:

The poster child of strength reduction is replacing x / 2 with x >> 1 in source code. In 1985, that was a good thing to do; nowadays, you're just making your compiler yawn.

good:

a /= 2

bad:

a >>= 1

函数

尽量短

函数尽量短小,超过40行就要考虑这个函数是不是做了过多的事,20行封顶最佳,通常情况函数过长意味着:

  1. 可复用性低
  2. 理解难度高
  3. 不符合高内聚、低耦合的设计,不易维护,比如函数做了AB两件事,我本来只需要关心B,但却需要把A相关的代码也阅读一遍。

只做一件事

如果你的函数名出现了doFooAndBar此类,说明你可以把FooBar这两件事拆开两个函数了。

good:

func init() {
    initConfig()
    initRPC()
}

func initConfig() {
    // init config code
}

func initRPC() {
    // init RPC code
}

bad:

func initConfigAndRPC() {
    // init config code
    // init RPC code
}

圈复杂度低

圈复杂度是衡量代码复杂程度的一种方法,简单来说就是一个函数条件语句、循环语句越多,圈复杂度越高,越不易被人理解。一般来说,不要高于10。 写go的同学可以用gocyclo这个工具来计算你的圈复杂度。

善用临时变量

有些变量只用到一次的,可以用临时变量代替,少一个变量名可以减少理解成本,也可以使得函数更短。

good:

return getData()

bad:

data := getData()
return data

简化条件表达式

当if条件过多的时候,可以把某个判断封装成函数,这样别人理解这个条件时,只需要阅读函数名就基本知道代码的含义了,而且也可以降低代码的圈复杂度。当然遇到更为复杂的逻辑可以考虑设计模式(工厂,策略等)解决。

还可以根据情况,合理对条件进行拆分和合并。

下面的代码演示了健身房打架的一个小例子,需要对人物进行校验:

good:

func checkOldMan(oldMan Man) bool {
  if oldMan.Name == "马煲锅" && len(oldMan.Skills) == 2 && oldMan.Skills[0] == "接化发" && oldMan.Skills[1] == "松果糖豆闪电鞭" && oldMan.Age == 69 {
    return true
  }
  return false
}

func checkYoungMan(youngMan Man) bool {
  if len(youngMan.Skills) != 1 {
    return false
  }
  if youngMan.Weight != 80 && youngMan.Weight != 90 {
    return false
  }
  if youngManA.Age >= 30 && youngManA.Skills[0] == "泰拳" {
    return true
  }
  return false
}

func FightInGym(oldMan, youngManA, youngManB Man) {
  if !checkOldMan(oldMan) || !checkYoungMan(youngManA) || !checkYoungMan(youngManB) {
    return
  }
  sneakAttack(youngManA, oldMan)
	sneakAttack(youngManB, oldMan)
}

bad:

func FightInGym(oldMan, youngManA, youngManB Man) {
	if oldMan.Name == "马煲锅" && len(oldMan.Skills) == 2 && oldMan.Skills[0] == "接化发" && oldMan.Skills[1] == "松果糖豆闪电鞭" && oldMan.Age == 69 && youngManA.Weight == 90 && len(youngManA.Skills) == 1 && youngManA.Skills[0] == "泰拳" && youngManA.Age >= 30 && youngManB.Weight == 80 && len(youngManB.Skills) == 1 && youngManB.Skill[0] == "泰拳" && youngManB.Age >= 30 {
		sneakAttack(youngManA, oldMan)
		sneakAttack(youngManB, oldMan)
	}
}

可以看到代码虽然边长了,但是可读性增加了,而且把年轻人的校验和老年人分开,到时候如果要修改偷袭者或者被偷袭者的判断条件,很容易定位到check函数去修改。checkYoungMan函数则根据条件特点,进行了条件拆分和合并,并且提前return减少嵌套。

不要过度嵌套

嵌套层数过多(一般超过4层就算多),圈复杂度将变得很高,每嵌套一层,造成理解难度将大大增加,难以维护且更容易出错。

一个技巧是类似上面例子中提前return

还有就是循环中善用continuebreak

good:

for i := 0; i < 10; i++ {
  if i % 2 != 0 {
    continue
  }
  fmt.println(i)
  // .. more code
}

bad:

for i := 0; i < 10; i++ {
  if i % 2 == 0 {
    fmt.println(i)
    // .. more code
  }
}

这里只展示了一个简单的例子,如果注释那部分的代码又有嵌套或者比较复杂,则可以降低一层嵌套,增加可读性。

每个函数调用在同一个抽象层级

函数中混杂不同抽象层级,会让人迷惑。函数调用链是像树一样有层级的,能做到函数短小,功能单一,再对调用关系进行梳理,会更容易做到这一点。

比如上面健身房的例子,后续要有两个操作,小朋友发问和录制自拍视频:

good:

func FightInGym(oldMan, youngManA, youngManB Man) {
  if !checkOldMan(oldMan) || !checkYoungMan(youngManA) || !checkYoungMan(youngManB) {
    return
  }
  sneakAttack(youngManA, oldMan)
	sneakAttack(youngManB, oldMan)
  AskByKid()
  RecordVedio()
}

bad:

func FightInGym(oldMan, youngManA, youngManB Man) {
  if !checkOldMan(oldMan) || !checkYoungMan(youngManA) || !checkYoungMan(youngManB) {
    return
  }
  sneakAttack(youngManA, oldMan)
	sneakAttack(youngManB, oldMan)
  // 小朋友发问 实现细节...
  // 小朋友发问 实现细节...
  // 小朋友发问 实现细节...
  RecordVedio()
}

上面的例子,很明显小朋友发问和录制自拍视频的功能应该是同一个抽象层级,但这里却出现了小朋友发问的细节,就会显得很突兀,如果这一大段细节代码出现,将大大提升理解这段代码的难度,而如果封装成AskByKid(),我只需要读一下这个函数名即可,无需关注他的实现细节。

参数

  • 参数尽量少(不超过5个)

  • 参数过多的时候不要用map传,考虑用结构体

返回值

  • 可以返回元组的语言,返回值的数量不要过多
  • 对于golang,error作为最后一个参数

消除重复代码

及时把重复代码做抽象(其实保证职责单一就很少有重复代码了)

安全

对于资源管理的时候,用语言特性保证安全

比如golang的defer

Python的with

当你需要把数据和行为进行封装的时候,或者需要利用多态性质的时候再考虑用面向对象来封装,有时候面向过程更清爽

五大原则

五大原则耳朵听出茧子了,简单略过。

  • 职责单一:保证类的功能单一,不要做过多的事情,及时按职责拆分。

  • 接口隔离:小而多的接口,而不是少量通用接口。

  • 开闭原则:最扩展开放,对修改关闭

  • 依赖倒置原则:依赖抽象接口,不依赖具体类

  • 里氏替换原则:子类型应该能够替换它们的基类,反之则不可以

公私分明

不要所有的成员变量和方法都是public的,应当考虑哪些需要public,其余的private。

注释

避免无用注释

不要注释一眼看代码就能看出来的东西,多注释代码之外的东西,比如业务为什么这样做。

good:

func isAdult(age int) bool {
  // 这个产品是给朝鲜用的,所以成年年龄是17岁,以后考虑做成可配置的,目前只有朝鲜市场
  return age >= 17
}

bad:

func isAdult(age int) bool {
  // 大于等于17岁
  return age >= 17
}

注释和实现一致

有些时候修改了代码没有修改注释,容易造成注释和实现不一致的情况,改代码的同时应该修改注释。

一些注释交给版本控制

不要注释无用代码,应当删掉,版本控制记录了历史变化,即使想找之前的代码也很容易

不要在注释中写修改日期,修改人,这个是很早之前没有版本控制才这样做。

关键信息

涉及到时间等有单位的变量,注释单位,比如下面的我根本不知道是毫秒还是秒,当然也可以把单位体现在命名里。

good:

const expire = 1000 // 过期时间,单位:毫秒
const expireMS = 1000

bad:

const expire = 1000 // 过期时间

错误处理

传递还是处理

明确你这里是要处理掉错误还是只需要向上传递,有些时候上层不需要知道错误详情,给一个默认值就行的,可以直接在原地处理掉。一般处理操作:打日志、设置默认值。一般情况可传递至最外层处理。

下面的例子不明确是处理还是传递,造成日志冗余打印

good:

func getSingerAge(singerID int) int {
  singerAge, err := getSingerAgeByRPC(singerID)
  if err != nil {
    log.error("getSingerName fail: %w", err)
    // 前端展示未知
    return -1
  }
  return singerAge
}

bad:

func getSingerAge(singerID int) (int, error) {
  singerAge, err := getSingerAgeByRPC(singerID)
  if err != nil {
    log.error("getSingerName fail: %w", err)
    // 前端展示未知
    return -1, err	// 上层很可能会继续打印一次error日志,还要加多一次error是否为空的判断
  }
  return singerAge, nil
}

加上追踪信息

有时候错误传递层数过多,无法定位到最底层是哪,可以在传递的时候加上一些额外的信息,帮助定位错误。

good:

return fmt.Errorf("module xxx: %w", err)

bad:

return err

日志处理

可搜索

日志加一些可搜索的字符串,便于搜索,如果存储介质是ES,则考虑ES分词后是否可快速搜索。

不乱打日志

调试时候乱打的日志,调试完删掉,不要想着提前预埋足够的日志打印,关键处打印即可。

明确日志的类型,不要无脑全部error乱打。

防止日志打印爆炸,注意不要在大的循环里频繁打日志。

设计

简单

考虑最简单的解决方法,不要过度设计。

合理使用设计模式

不要为了使用设计模式而使用设计模式,只在需要的时候用,问清楚产品需求,未来改动,扩展的几率是多大。

严格的设计

如果是大型需求,设计尽量严格,尽量考虑细节,虽然很多是编码阶段考虑的,也可以提前画一下简单的UML图,代码写之前心中有数,不要做到最后代码乱七八糟。

心态

不将就

任何人都不可能一次性写出来的代码是完美的,发现需要优化的时候就及时去做,尽量保证每次打开代码都比上次更好,不要想着能跑就行,不将就。

代码评审

作为coder:

  • 提交代码评审前自己先过一遍
  • reviewer提出的点如果自己有不同意见及时交流,不要认为这是在针对你

作为reviewer:

  • 针对代码,不针对人
  • 要求严格,对代码仓库的质量进行把关

参考文献

《代码整洁之道》

[[KM]Code Review我都CR些什么](

原文地址:https://www.cnblogs.com/dupengcheng/p/14098041.html