代码简洁之道笔记

代码简洁之道

1. 代码整洁的意义

1.1 糟糕代码的坏处

  • 生产力低下

    • 代码不敢该,改一次花费大量的时间来梳理背后逻辑,应付性堆加,项目延期。

    • 项目现场,频繁出问题,出了问题难以排查,耗费人力,资源浪费。

    • 加新人,恶行循环,导致团队瘫痪。

  • 坏的示范作用。

1.2 何谓整洁代码

  • 优雅。
  • 阅读起来赏心悦目,可读性强,扩展性强,维护性强。
  • 抽象简洁,职责单一。
  • 。。。

2. 有意义的命名

  • 名副其实

  • 避免误导

  • 类名

    类名一般采用名词或者名词短语

  • 方法名

    方法名一般采用动词或者动词短语

    getXXX

    setXXX

    isXXX

3. 函数 (重点)

重点:

只做一件事(一个函数一个抽象层级)、函数参数 (参数优化策略)、优化if-else 和 switch-case、返回Null 和 判Null、标识变量做参数

  • 短小

    建议:if、for、while这种代码块不超过3行

  • 只做一件事情

    函数只做一件事情,可以确保逻辑单一简洁,可阅读性强,只有一个变化因素。

    函数只做一件事情,这一件事情可以分为多个步骤,但是这几个步骤必须是同一个抽象层级。如果引入其他低级别的含义,代码就比较臃肿混乱

    // 比如:查找一班同学中成绩TopN的同学
    func findTopNStudentsInClass(classId string, topN int) []Students {
      students := findStudents(classId)
      // 实现排序
      for i, stu := range students{
          if students[i].Bigger(students[j]) {
              // swap...
          }
      }
      // 返回TopN
      return stduents[:topN]
    }
    

    实例中这个函数就违背了只做一件事(函数的几个操作步骤应该在同一抽象层级)这个原则。导致了代码的整洁性破坏。

    另外还引入函数变化的另外的因素:比如换一种排序方式,就要重新测试该函数,而如果把抽象层级排序函数拎出来,单独实现,变化了,只测试该排序函数即可,本函数不受影响。

    // 优化改进版
    func findTopNStudentsInClass(classId string, topN int) []Students {
      students := findStudents(classId)
      // 实现排序
        students = sort(students)
      // 返回TopN
      return stduents[:topN]
    }
    
    func sort([]stduents) []students{
        // 实现排序算法
    }
    

    可以看到,改进后,函数的几个操作步骤都在统一抽象层级中。另外引起函数变化的因素也减少了。

    总结:函数只做一件事情的核心是,几个操作步骤都在统一抽象层级中。同一抽象层级的核心是围绕一个操作主题(比如示例中几个步骤都是对 主题Students进行处理)

  • 每个函数一个抽象层级

    描述见上面

  • 函数参数

    总体原则:函数参数不要超过两个或者三个。

    无参函数 > 一个参数函数 > 两个参数函数 (阅读使用更加简洁,参数越少,理解成本越低)

    • 一元参数

      一元参数 要表达的是函数要操作对象的语意。 比如 write(file string), send(msg) 。这样更整洁,好理解

      另外一种是操作event

    • 避免使用标识符参数 (即:避免传布尔类型参数)

      向函数传布尔类型参数令人骇人听闻,表示这个函数不止做一件事情,违背了函数只做一件事的原则 (如为true,这样做;如为false,则那样做)。

      应该把函数一分为二,用两个单独的函数来封装。

      // 坏代码
      func render(isSuite bool) {
          if isSuite {
              // suite 测试
          }else {
              // 单个用力测试
          }
      }
      // 整洁代码
      func renderForSuiteTest(){
          
      }
      func renderForSingleTest() {
          
      }
      
    • 二元函数

      二元函数比一元函数难懂。如 writeFiled(name)writeFiled(outputStream, name)好懂。第一个看一下就明白,第二个要停下来理解一下。

      再比如 assertEquals(expect, actual)这样的二元函数,好多次会搞错参数的顺序。这就是使用二元函数的代价。

      应尽量利用一些机制将二元函数转换成一元函数。可以有下面几种方式:

      • writeFiled 写成 outputStream的 成员方法。然后直接调用 outputStream.writeFiled(name)
      • outputStream 写成当前类的成员变量,从而无需再传递它。 直接调用 xxx.writeFiled(name)
      • 还可以分离出类似 FiledWriter 新类,在其构造器中传入 outputStream, 并提供一个write方法。然后调用 fileWriter.write(name)
    • 三元函数

      三元函数比二元函数难懂,需要花时间理解参数含义,传递顺序等。

      如果函数需要二、三个、三个以上参数,就说明一些参数需要封装为类了。 改写机制参数二元函数说明。

    • 变参函数

      变参部分可以理解为 函数列表 (list或者slice), 这样变参函数就变成了一元或者二元函数了。

      format(fmt string, args ...interface{})

      pint(args ...interface{})

    • 给函数取好名字

      取好名字能够较好的理解函数的意图,以及参数的顺序和意图。

      对于一元函数,函数和参数应该形成良好的 动/名词对形式,如 write(Name)

  • 输出返回值

    函数输出要尽量明确,避免把参数当作输出,会破坏一些使用惯例。

  • 用多态替代 if/else 或者 switch/case

    if/else 或者 switch/case这类代码结构,是代码腐化的常见示例,违背了开闭原则。

    通常可以通过 多态将其优化掉

    type Play struct{
          palyType string
        price float32
    }
    
    const (
        stragedyPriceRate = 1.25
        comedyPriceRate = 1.1
    )
    
    func CalcAmount(play Play) float32{
        amount := 0.0
        switch play.playType {
            case "stragedy":
              amount = stragedyPriceRate * price
            case "comedy":
              amount = comedyPriceRate * price
          default:
        }
        return amount
    }
    

    如果要增加新的 戏曲类型,比如京剧、豫剧... 等等,都要去修改这个函数,违背开闭原则。通过多态 or 依赖倒置原则来优化

    type Play interface{
      Amount() float32
    }
    
    type Stragedy struct{
        price float32
        rate float32
    }
    
    func (s Stragedy) Amount() float32{
      return s.price * s.rate
    }
    
    type Comedy struct{
        price float32
        rate float32
    }
    
    func (c Comedy) Amount() float32{
      return c.price * c.rate
    }
    
    func CalcAmount(play Play) float32{
        return return play.Amount()
    }
    

    通过依赖倒置优化,扩展性大大提高

  • 无副作用

    符合只做一件事是一个原则。不能在一个语义函数中做其他事情,比如再checkXXX中做一些初始化操作,就带了副作用

    func checkMsgValidte(msg *Msg) {
      if msg.Set == nil {
          msg.Set.Init()
      }
    }
    
  • 分割命令和查询

    函数要么做什么事,要么回答什么事情,二者不可兼得。要么修改值,要么返回值,两样都干可能会导致混乱。

    // 设置某个属性,成功返回True, 不存在返回False
    func (o Object)set(attribute string, value string) bool {
        if o.hasAttribute(attribute) {
            o.attribute = attribute
            return true
        }
        return false
    }
    
    // 给使用者会带来混淆。
    // 到底是name属性之前不为空,还是设置name为tom成功呢?
    // 接口有歧义,带来一定程度的误解
    if (o.set("name", "tom")) {
      // ...   
    }
    

    要解决这个问题,可以将 set函数重命名为 setAndCheckIfExists。或者采用更好的解决方案,把命令和查询分割开来,防止发生混淆:

    if attributeExist("name") {
      SetAttribute("name", "tom")
    }
    

    新的建议:

    1. 函数命名,值得花时间去推敲,反复斟酌修改。先写出来看看,不合适再改,直到简洁满意。

    2. 写代码和写别的东西很像(比如写文章)。先想什么就写什么,然后再打磨它。粗稿也许粗陋无序,反复斟酌推敲,直到满意。

      我写函数时,一开始都冗长复杂。有太多缩进和嵌套,有过长的参数列表,名字也是随意取,也有重复代码。不过我会配上一套单元测试,覆盖每一行丑陋代码。打磨、分解函数、修改名称、消除重复,保持测试通过,遵照本章的规则进行优化。并不是一开始就按照这些规则来写函数,我想没人做得到。

  • 消除特性依赖 (非强制)

    类的方法应该尽量只对类自身的变量和函数感兴趣,不该垂青其他类的变量和函数。

    当类的方法通过其他外部类来实现自身,它就依赖了该外部类。那么应该将该方法放到这个外部类中,这样就能直接访问了。

    type HourlyEmployee struct{}
    
    func(e HourlyEmployee) GetWorkHours() int {}
    func(e HourlyEmployee) GetHourSalary() int {}
    
    type HourlyPayCalculator struct{}
    
    func (h HourlyPayCalculator)CalcultateWeeklyPay(e HourlyEmployee) {
        workHours := e.GetWorkHours()
        hourSalary := e.GetHourSalary()
        return workHours * hourSalary
    }
    

    如上面示例,HourlyPayCalculator 通过HourlyEmployee 来计算工资, HourlyPayCalculatorHourlyEmployee产生依赖。

    应该将 CalcultateWeeklyPay 放到 HourlyEmployee类中

4. 注释

尽量用代码做注释,因为它是最真实的,注释有可能是老旧或者有误导性的。尝试让代码变得简洁而有表达力,比花时间来写注释更有意义。

  • 用代码来表达,而非注释

    尝试让代码变得简洁而有表达力,比花时间来写注释更有意义

  • 好注释 (值得写的注释)

5. 格式

  • 垂直方向上的空白区分

    每行展现一个表达式或者子句。每组代码行展示一个完整的思路。这些思路用空白行分割开来。

    再包声明、包导入、变量或常量定义、函数之间,都有空白行隔开,这些机简单的规则很大的影响了代码的阅读体验

  • 垂直方向上的靠近

    比如都是成员变量,中间最好不要隔开,避免要移动视线才直到这几个是成员变量

  • 相关原则

    一个函数调用另外一个函数,就应该放到一起。调用者尽可能的放在被调用者上面,负责自上而下的阅读顺序。

6. 错误处理

原则:要弄清错误处理与整洁代码之间的关系。错误处理很重要,但是如果它搞乱了代码的逻辑,就是错误的做法。

正确的做法是,将错误处理隔离开,单独于主要逻辑之外,就能写出整洁的代码。

  • 使用异常而非错误码

    因为错误检查容易遗忘。遇到错误时,抛出一个异常,调用代码会比较整洁,逻辑不会被错误处理搞乱

  • 别传递Null值

    返回Null值是容易引发错误的做法。调用者需要不停的检查返回是否为null.

    // 示例:
    func registerItem(item Item) {
        if item != nil {
            registery := store.GetItemRegistery
            if registery != nil {
                registery.GetItem(item.GetId())
            }
        }
    }
    

    这段代码糟糕透了,返回Null值,是在给自己增加工作量,也是给调用者添乱。只要忘记检查Null,就会导致程序失控。
    可以显示的返回错误,或者返回一个特例对象。

    // 返回Null的场景
    employees := getEmployees()
    if employees != nil {
      for _, employee := range employees{
          total += employee.getPay()
      }
    }
    
    // 如果getEmployees() 返回默认特例,可以避免判Null, 逻辑更简洁
    employees := getEmployees()
    for _, employee := range employees{
        total += employee.getPay()
    }
    
    func getEmployees()[]employee{
        if false {
            return emptyEmployees
        }
        return employees
    }
    
  • 被传递Null值
    方法中返回Null是糟糕的做法,但是把 Null值传递给其他函数更糟糕。

    // 示例:
    func xProject(p1, p2 *Point) double {
        return (p1.x - p2.x) * 1.5
    }
    
    // 如果有人传递了Null值会怎么样? 程序会直接得到一个NULL指针异常
    xProject(Null, &Point{1})
    
    // 如何修正? 可以创建一个 NullPointerException 异常
    // 只是稍微比Panic好一点,但是还要捕获处理这个异常
    func xProject(p1, p2 *Point) double {
        if p1 == nil || p2 == nil{
            throw InvalidArgumentException("invalid arguments")
        }
        return (p1.x - p2.x) * 1.5
    }
    
    // 还可以使用断言,但是也会产生运行时错误
    func xProject(p1, p2 *Point) double {
        if p1 == nil || p2 == nil{
            panic("invalid arguments")
        }
        return (p1.x - p2.x) * 1.5
    }
    
    // 总结:最好的方式,还是编码的时候,时刻牢记不能传Null。调用者保证。
    

7. 边界

对于第三方模型或者框架的引用 形成了边界。为了减少边界变化对业务代码的影响,一般要对边界代码进行封装。

一种是类封装,另外一种是Adaptor,将一种接口转换成另外一种接口。

8. 单元测试

单元测试与生产代码一样重要,它不是二等公民。它需要被思考、设计和照料,应该像生产代码一样保持整洁。

没有测试代码,无法保证 对代码的修改 能如改动前一样正常工作。不会影响到其他部分。

如此,会导致故障增加,不敢修改代码,不再清理生产代码,因为修改带来的成本、风险太高。

如此延续下次,生产代码开始腐败。

单元测试可以让代码可扩展、可维护、可复用。 (其中,为了满足代码的可测试性,要求模块外部依赖可以注入,如此更好扩展)

代码优化,单元测试先行

测试带来的好处:

有了测试,不怕修改引入新问题。测试用例不变,只要修改前后,用例执行的结果一致,就认为修改前后模型的行为一致。

如此,可以放心的优化代码,改进架构设计

  • TDD: 测试驱动开发。测试代码先行 (比较理想的一种理念,未必值得推崇)

  • 整洁测试的三要素:可读性。如何做到可读:明确、简洁。

  • 测试用例格式:构造 -- 操作 -- 检验 (Build -- Operate -- Check)模式。

    每个测试都可以清晰地拆分为三个环节

整洁测试的5大规则:

  • 快速

    测试应该足够快。如果运行缓慢,你就不想 频繁运行。这样就不能够及早发现问题,不能及时清理优化代码,导致代码腐化。

  • 独立

    每个用例可独立运行。

  • 可重复

  • 自足验证

    测试应该有布尔值输出。不能通过主观的判断确认用例是否通过,而应该采用客观比对。

  • 及时

    用例编写要及时,这样既可以测试功能正确,也可以通过测试对代码进行重构(可测试的)

9. 类

  • 类应该尽量小。遵循SRP 单一职责原则,只有一个引起变化的因素。避免改动一个,影响另一个。
  • 内聚
  • OCP 开闭原则
  • DIP 依赖倒置原则

10. 简单设计原则

下面关于简单设计的四条原则,对创建良好设计的软件很有帮助

  • 运行所有测试

    可测试性也是衡量代码松耦合设计的一个指标。

    紧耦合的代码难以测试,写的测试越多,越会使用依赖注入、接口和抽象等手段减少耦合,遵循DIP之类规则。如此,设计也就有长足进步。

  • 重构

    有了测试,通过增量式地重构,就能保持代码整洁。消除了清理代码的恐惧。

    重构过程中,可以从 改进命名、优化函数接口参数、优化函数逻辑(一个抽象层级); 优化类设计、优化模块结构设计; 提高内聚、降低耦合。

  • 消除重复

    消除重复,抽取公共。通过注入函数(差异化各自实现)或者模板方法模式来优化。

  • 尽量减少类、方法

你可能感兴趣的:(代码简洁之道笔记)