代码整洁之道
关于如何写整洁代码的一些总结和思考
最近在KM上看了不少关于Code Review的文章,情不自禁的翻出《代码整洁之道》又看了一下,于是在这里顺便做个总结。其实能遵守腾讯代码规范写出来的代码质量已经不差了,这里主要是代码规范中容易犯的一些错和自己的额外总结。
目录
衡量好坏代码的标准
什么样的代码算整洁的代码?好的代码?谈到代码好坏一定少不了这张图。
WTFs/minute简而言之就是你代码被人“感叹”的频率,代码必定是有好坏之分的,但在每个人心里的标准又不一样,没法量化一个好坏代码的标准,但是如果一段代码让人难以读懂,乱七八糟,难以扩展和维护,让人完全没有读下去的欲望,那肯定不是一份好代码。
为什么要注重代码整洁
代码就像自己的孩子,作为父母肯定都希望孩子长的好看一点,出去被人夸长的好看,人见人夸,而不是见者WTF!
增加可维护性,降低维护成本
从可读性来说,代码是写给人看的,团队不乏人员交替的负责一份代码的迭代和维护,如果别人阅读你的代码很难读懂,那他在代码的理解上肯定会有问题,比如某些细节没理解清楚,就可能会埋下一个bug坑。
从可扩展性上来说,如果你只是修改一个简单的功能,但是要涉及大量的代码改动,不仅开发难度加大,测试难度也会加大,甚至到了最后难以扩展需要被重构,这无疑给团队带来了灾难。
对团队和个人产生积极的影响
首先是对自己的影响,自己写的代码被别人review的时候或者被后人修改的时候,不会被频繁WTF,不会让后面的维护者气冲冲的敲下git blame
并口里大喊着:“这人不讲码德呀!谁写的!”乃至在后面晋升职级时候的代码评审也会有好的帮助。
其次代码可能是会传染的。比如你要维护一份烂代码,很可能你都不想碰,更别说重构了,这样一直在烂代码上堆积if else
等逻辑,无疑会让代码腐烂下去。但如果你代码写的干净整洁,遵守规范,容易被人阅读和维护,别人看到之后或许也会被你传染,也许他原来不遵守代码规范,看到你的代码之后恍然大悟,从此开始注重代码整洁度和代码质量。
如何写整洁的代码
这里省略一些诸如不要用拼音命名,函数之间要有空行,统一缩进等此类人人都知道且很少会犯的点
规范
遵守团队规范
无规矩不成方圆,写代码也是,遵守团队的代码规范(腾讯代码规范)是作为程序员的基本素养。这些规范都是经验丰富的顶级大佬总结出来的,能成为公司标准必然是经过深思熟虑的,有时候我们应该舍弃一些个人风格,保持团队统一。
有时候规范不一定是绝对的,比如C++
缩进2空格还是4空格的问题,这并没有孰好孰坏,只有个人风格问题,但在一个团队中,最好还是保持风格一致,风格统一的代码看起来才不会太乱。如果是C++
则可以定一个统一的clang format
文件,团队统一格式化,golang
则使用go fmt
即可(其实这个工具也是为了统一风格不是吗)。
再比如golang
强制大括号的换行方式不也是为了统一格式在努力吗?
入乡随俗,遵循语言风格
不要把其他语言的风格带到另一个语言中。比如写Python,尽量使自己的代码更加Pythonic。下面是一些列子:
-
交换两个数
C/C++中你习惯这样交换两个数:
int temp = a; a = b; b = tmp;
Python:
a, b = b, a
-
列表推导
在Python可以这样获取[1,10)之间的偶数
[i for i in range(1, 10) if i % 2 == 0]
-
比较
其他语言比较
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:\MyProjects\python\cc_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年前这样做可能还有点用,这虽然算不上奇技淫巧)。这样只会让人理解代码加多一步。
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行封顶最佳,通常情况函数过长意味着:
- 可复用性低
- 理解难度高
- 不符合高内聚、低耦合的设计,不易维护,比如函数做了AB两件事,我本来只需要关心B,但却需要把A相关的代码也阅读一遍。
只做一件事
如果你的函数名出现了doFooAndBar
此类,说明你可以把Foo
和Bar
这两件事拆开两个函数了。
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
还有就是循环中善用continue
和break
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些什么](