程序员进阶-重构与设计模式

1,294 阅读14分钟

image.png   大家好,我是 Alang, 一个平凡却努力改变的人。初一第一堂英语课,英语老师让每个人给自己取一个英文名,我给自己取名 Runner , 希望自己能成为一个永远在学习的道路上奔跑的人,学无止境,与君共勉。

  今天给大家分享的是重构和几个简答的设计原则,本篇文章其实去年已经在我们公司团队中分享过了,今天重新整理出来,希望能帮助到更多人。

前言

  重构就好像是在收拾自己的房间。刚搬进出租屋的时候,我总是想着怎样才能放下更多的家具,也胡乱买了很多装饰品(地毯、泡沫地板等等)。后面家里的东西越来越多,杂乱,无用。所以,一般每个周末,我都会静下来好好想想每个物件放置的合理性和实用性。

  这就和代码重构工作也很类似,项目的需求下来的的一段时间里,我们总是忙着怎样完成,并没太多时间去思考代码的合理性、可扩展性、复用性、可阅读性...。不过,需求开发完,我们也需要不定期的去 “打扫”这些 “坏味道“。

  工作以来接触了大大小小的项目,似乎每个项目都在重构。刚开始只是觉得代码没法扩展,新增和修改功能及其困难。所以想到优化。但大部分人的优化是按照自己觉得好的方式去重构,或者说自己看的懂的方式去重构,这样确实能快速理解这块逻辑,但实际重构的结果并不一定是好的,甚至更糟糕,因为这正是我经常会犯的错误。

那么什么才是好的重构,有没有一个标准可以量化呢?有,也可以说没有。

客观先别着急,继续往下看。

tu

重构

什么是重构

对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高可理解性,降低其修改成本

为什么要重构/重构的意义是什么?

我们平时抱怨最多的一句话就是:这个代码谁写的,根本没法改。 其实从这句话就能体现出重构的一个意义,那就是要其他人好修改。即便我们写的代码并不优雅,也存在性能问题,但好改,凭这一点我们就可以认为这段代码是有意义的。

  • 重构使软件更容易理解
  • 重构能改进软件的设计
  • 重构能帮助找到 bug
  • 重构能提高编程速度

代码越多,做正确的修改就越困难,因为更多的代码需求理解。

差的设计

差的

好的设计 好的

从上面的两个图中,我们可以直观得看出,随着时间的增长,差的设计新增功能变得越来越难,后期基本保持维护状态;而好的设计却能为我们的软件系统提前打好基础,新增的功能也能随时间增长变得越来越多。

何时重构?

纳闷我们应当在什么时候开始重构呢?下面列举了一些重构的时机

  • 预备性重构

    重构的最佳时机就在添加新功能之前。新增功能我们不得不去了解原有代码,过程中我们会发现一些问题,例如一些代码无法理解,变量命名不清晰,调用关系复杂等等。这个时候就可以试着去重构一下。

  • 帮助理解性重构

    需要思考 “这段代码到底在做什么”,能不能重构这段代码让结构一目了然。这些都是重构的机会。 重构带来的帮助常常不是立竿见影,一些小细节上的重构来帮助理解,例如给一两个函数改名,让它们更清楚表达它们的作用;或者将一个长函数拆分成几个小函数。往往就是因为这些细小的整理,才能看到隐藏在一片混乱背后的机遇。

  • 捡垃圾式重构(见机行事)

    如果我们已经理解代码在做什么,并发现这块逻辑迂回复杂。如果发现这些垃圾很容易重构,就立即重构,如果眼下要完成的任务跑题太多,可以先记下来后面再回来重构它。

  • 有计划的重构

    专门安排一段时间来重构,为了保证后面的新功能很好扩展,修改起来更容易。

  • 长期重构

    例如想替换一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个版本,等旧库已完全未使用了,替换新库就变得容易很多。

  • CodeReview

    有经验的开发者把知识传播给比较欠缺的人,也能帮助更多人理解软件系统的更多部分。有限的时间里个人的想法也有限,可以在 Review 的过程中得到他人的帮助。

何时不应该重构?

  • 在不了解其工作原理的情况下,且丑陋的代码被隐藏的封闭的 API 内,我们可以容忍其保持丑陋,当我们真正理解其原理的情况下重构才有价值。

  • 重写比重构简单,但是我们很难评估出一段代码重构的难度,只能靠自己的判断力和丰富经验决定。

代码坏味道

并没有完全意义上何时必须重构的的精确度标准,没有任何见识比得上见识广播者的直觉。我们在开发的过程中要能快速嗅察到坏味道。

命名需求表达自己的功能和用途,好的名字能节省未来用在猜谜上的大把时间。

  • 重复代码

可以使用提炼函数

  • 过长函数

函数越长越难理解。小的函数有更好的阐述力、易于分享、更多的选择。每当我们需要用注释来说的什么的时候,就需要把说明的东西写入一个独立的函数,并以其用途命名。

  • 过长参数列表

可以使用查询取代参数。

// bad
data () {
  return {
    name: 'alang',
    age: 18
  }
},
methods: {
	getPerson () {
		this.getInfo(this.name, this.age)
  },
  getInfo (name, age) {
		// ...
  }
}

// good
methods: {
	getPerson () {
		this.getInfo(t)
  },
  getInfo () {
		const name = this.name
    const age = this.age
  }
}

不适用:移除参数会给函数本身增加不必要的依赖关系。

  • 全局数据

全局数据是最刺鼻的坏味道之一,代码库的任意一个角落都可以修改它,而没有办法能探测出数据的修改,会产生很多诡异的 bug,要找出问题的根源更是难于登天。

例如:我们在某个子页面中修改了 element-ui 的 table 样式。却不小心作用到了项目中所有用到 el-table 的地方。排查起来也非常困难。

// bad
<template>
</template>
<script>
  // sub-page.vue
</script>
<style lang="scss">
  .el-table {
    padding: 20px;
  }
</style>
  • 可变数据
    可以通过封装变量来确保所有数据更新操作都通过很少几个函数来进行,使其更容易被监控。
// bad
let name = 'alang'
let age = 18
function a () {
  name = 'xiaoming'
}
function b () {
  name = 'xiaohua'
}
a()
b()

// good
let name = 'alang'
let age = 18
function setName (newName) {
  name = newName
}
function setAge (newAge) {
  age = newAge
}
function a () {
  setName('xiaoming')
  setAge(19)
}
function b () {
  setName('xiaoming')
  setAge(20)
}
a()
b()
  • 发散式变化

    实例成其他形态时需要修改内部函数

  • 霰弹式修改

    遇到改变,需要在许多不同的类中做出许多小修改。

  • 依赖情节

    首先我们要了解模块化是什么?最大化区域内部的交互,最小化跨区域的交互。 但是有时候你会发现,一个函数跟另一个模块中的函数或数据交流格外频繁,远胜于内部交流,这就是依赖情节。处理办法也很简单,将总是一起变化的东西放在一块。如果这个函数依赖了多个模块,可以将改函数移动到使用次数最多的模块中。

  • 数据泥团

    某几个数据,在用到的地方总是成团在一起。此时它们应该有属于自己的对象。

  • 重复的 switch

    在不同的地方反复使用相同逻辑的 switch 代码片段。如果你想增加一份分支时,必须找到所有的 switch 修改。

  • 循环语句

    在不考虑性能的前提下尽量使用语义化的管道操作,如 map、filter、find、findIndex、some、every 等。

  • 夸夸其谈通用性

    企图各式各样的钩子和特殊情况来处理一些非必要的事情,这样会导致系统更难理解和维护。(针对并没有用到的扩展属性或函数)

  • 注释

    从嗅觉上讲,注释不是坏味道,而是一种带有香味的除臭剂。如果你发现一段代码有着长长的注释,这些注释之所以存在是因为代码很糟糕。

    当你感觉需求撰写注释时,请尝试重构它,试着让注释变得多余。

    注释应用时机:

    1、你不知道该做什么,记录将来的打算。

    2、在并无十足把握的地方写下自己"为什么做某某事"

重构的具体方法

  • 提炼函数
  • 内联函数
  • 提炼变量
  • 内联变量
  • 改变函数声明
  • 变量改名
  • 引入参数对象
  • 函数组合成类
  • ...

重构的挑战

  • 延缓新功能开发

    这个时候我们需要权衡利弊,如果新加的功能很小,我们可以先加上再决定大规模重构。

  • 管理者会以“保证开发速度”的名义压制重构

    需要向团队声明重构对改善代码健康的重要性和价值。

  • 合理的判断何时应该重构何时不应该重构

    只能通过经验积累或者有经验的人指导。重点要了解重构的意义不在意把代码库打磨得闪闪发光,而是纯粹经济角度出发的考量。明确重构的目标,是让我们更快的添加新功能和修复 bug、

重构的步骤

  1. 自测代码,测试用例来大大我们提升重构的信心。
  2. 消除味道
  3. 保持始终工作,重构过程不能破坏甚至改变软件外在功能。
  4. 持续集成,重构的影响会被快速反馈出来。
  5. 随时中止,有紧急的Feature,我可以随时暂停重构,立即切换到Feature开发上。

好的重构应该像一边开车一边换轮胎一样,保证系统随时可工作的前提下,还可以对其结构做出安全高效的调整。

借助工具:圈复杂度分析、eslint。

设计原则

上面说了这么多重构,大部分归根结底还是前期没设计好(当然也不排除后期需求大),那么怎么从一开始就能写出易扩展,好修改的代码呢?这里我们就不得不学学设计模式了,把这些设计模式烂熟于心,写代码时各种设计模式信手拈来,那么你就成功一大半了。下面重点看看 SOLID 。

首字母指代概念
S单一功能原则认为“对象应该仅具有一种单一功能”的概念。
O开闭原则认为“软件体应该是对于扩展开放的,但是对于修改封闭的”的概念。
L里氏替换原则认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”的概念。参考契约式设计
I接口隔离原则认为“多个特定客户端接口要好于一个宽泛用途的接口”[5] 的概念。
D依赖反转原则认为一个方法应该遵从“依赖于抽象而不是一个实例”[5] 的概念。 依赖注入是该原则的一种实现方式。

单一职责

单一职责原则(SRP:Single responsibility principle)又称单一功能原则。

任何一个软件模块都应该有且仅有一个被修改的原因

如果一个类承担的职责过多,就等于把这些职责耦合在一起了。一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。

例如:我们要分别向导师、leader、项目经理自我介绍。

class SelfIntroduction {
  constructor (name, sex, age) {
    this.name = name
    this.sex = sex
    this.age = age
  }
  toTutor () {
    return '导师您好~' + this.getContent()
  }
  toLeader () {
    return 'leader您好~' + this.getContent()
  }
  toProductManager () {
    return '项目经理您好~' + this.getContent()
  }
  getContent () {
    return `我的名字叫:${this.name},性别:${this.sex},今年${this.age}岁,`
  }
}

SelfIntroduction 类看似没问题,如果有一天,你换了部门,来到新的部门,你想在介绍里单独对你的 leader 说你之前的情况,那么修改如下:

class SelfIntroduction {
  // 以上省略
  // change
  getContent () {
    return `我的名字叫:${this.name},性别:${this.sex},今年${this.age}岁,我之前在数据中台部~`
  }
}

如果不加注意,上诉修改就会不小心同步到其他人,这并不是我们期望的。

class SelfIntroduction {
  constructor (name, sex, age) {
    this.name = name
    this.sex = sex
    this.age = age
  }
  getContent () {
    return `我的名字叫:${this.name},性别:${this.sex},今年${this.age}岁`
  }
}
class ToTutor extends SelfIntroduction{
  constructor (name, sex, age) {
    super(name, sex, age)
  }
  getToTutorContent () {
    this.getContent() + '我之前在数据中台部~'
  }
}

开闭原则

良好的设计应易于扩展,抗拒修改

将系统划分成一系列组价,并且这些组件的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。

我们应该通过扩展来实现变化,而不是通过修改已有的代码。

例子:还是上面的自我介绍,现在我们需要加一个向老板自我介绍的需求,那么我们可以用 ts 的抽象类去实现,在不改变原有代码的基础上去扩展。

abstract class SelfIntroduction {
  constructor (name, sex, age) {
    this.name = name
    this.sex = sex
    this.age = age
  }
  getBaseContent () {
    return `我的名字叫:${this.name},性别:${this.sex},今年${this.age}岁`
  }
  abstract getContent(): void;
}

// 向 leader 自我介绍
class ToLeader extends SelfIntroduction {
  constructor (name, sex, age) {
    super(name, sex, age)
  }
  getContent () {
    return 'leader 您好,' + this.getBaseContent()
  }
}

// 新增--向老板自我介绍
class ToBoss extends SelfIntroduction {
  constructor (name, sex, age) {
    super(name, sex, age)
  }
  getContent () {
    return '老板您好,' + this.getBaseContent()
  }
}

注意:抽象类中的抽象方法不包含具体实现并且必须在派生类中实现

里氏替换

派生类(子类)对象可以在程序中代替其基类(超类)对象。阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承,以及其中蕴含的原理。是对开闭原则的补充,是对实现抽象化的具体步骤的规范。

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
  • 子类中可以增加自己特有的方法
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松
  • 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等

接口隔离原则

和单一职责类似,都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想。接口隔离是对接口依赖的隔离,一个类对另一个类的依赖应该建立在最小的接口上。将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

下面同样给出一个违反接口隔离原则的示例:

class SelfIntroduction {
  constructor (name, sex, age) {
    this.name = name
    this.sex = sex
    this.age = age
  }
  toTutor () {
    return '导师您好~' + this.getContent()
  }
  toLeader () {
    return 'leader您好~' + this.getContent()
  }
  toProductManager () {
    return '项目经理您好~' + this.getContent()
  }
  getContent () {
    return `我的名字叫:${this.name},性别:${this.sex},今年${this.age}岁,`
  }
}

正确的方法应该是 把自我介绍的对象放到不同的类中,参考上面开闭原则的示例。

依赖反转

  1. 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口
  2. 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。

实际的业务开发中,“依赖反转” 确实带给了我很大转变、通过上层次的抽象往往能解决项目中很多复杂问题。

例如: 有一个数据表列表,点击每一个数据进入到对应类型的表详情。

方案一: 每一个表类型新建一个详情页面,点击时判断不同的表类型,进入对应的详情页面.

这样做会有一个问题, 详情 A 和 详情 B 页面随着业务的发展会变得越来越复杂,并且两个页面存在太多相似之处。此时,如果我们用的 Vue, 就不得不新建一个 mixins 解决重复公用问题。这样做能解决问题,不过随之而来的是大量隐式调用。代码混乱,且不方便维护。

方案二: 新增一个上层框架,把表详情页面主体按一定逻辑划分。具体每一个部分的实现再由不同的模块去实现。

这里有点面想对象开发的意思。我们创建了一个上层接口组件,实现了详情页的大体框架,框架的每一个部分细分给具体的表类型模块去实现。

参考书籍

  • 《架构整洁之道》
  • 《重构-改善既有代码的设计》