• 代码整洁之道


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

    最近在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些什么](

  • 相关阅读:
    Android移动view动画问题
    GIT常用操作
    linux下mysql安装
    jdk安装
    linux下Tomcat安装
    猜测性能瓶颈
    MySQL没有远程连接权限设置
    linux下jmeter使用帮助
    BI的核心价值[转]
    BI与大数据
  • 原文地址:https://www.cnblogs.com/dupengcheng/p/14098041.html
Copyright © 2020-2023  润新知