好久不见, 忙完一阵子开始继续更新了, 先让我水一篇杂的. 前段时间为了更好地重构自己的代码而看了《重构: 改善既有代码的设计》这本书, 以下是当时阅读期间做的简单笔记.
这本书也算是软件工程的必读书之一了, 非常简单易懂地介绍了如何让自己的程序代码更合理易读, 优化开发的进度. 想要设计出合理的代码架构, 让开发过程更加顺畅的话, 一定要认真研读这本书.
本篇自我感觉写得不好, 因此就不分开两篇水篇数了, 一口气放出吧. 全文1.3w字, 加油. 才疏学浅错漏在所难免, 本文也同步到Github仓库, 有错误会在那里更新. (https://github.com/ZFhuang/Study-Notes/blob/main/Content/%E3%80%8A%E9%87%8D%E6%9E%84%E3%80%8B%E7%AC%94%E8%AE%B0/README.md)
如果项目内多个地方看到了相同的代码, 那就想办法将其合为一
多对大函数进行分解, 每当需要长注释的时候就应该将所需的分段进行包装了, 有时候替换后的函数只包含一行代码也没关系.
注释, 条件表达式和循环, 都是提炼代码的信号. 提炼的时候可能会产生过长的参数列表, 考虑如何将长参数包装为一个参数对象进行传递
和大函数一样, 当某个类负责了太多内容时就会产生冗余和混乱, 最好按照类所进行的工作为每个方法都提炼出接口, 然后慢慢分解
太长的参数列难以理解, 太多参数会造成前后不一致、不易使用, 且一旦需要更多数据就不得不修改它. 因此可以包装一个足够全面的参数类, 然后让目标函数自己从参数对象中获取自己需要的参数
但是有时候不希望两个对象由于大量和互相使用而耦合, 那种时候还是需要适当提炼为函数, 但是还是要保持参数列别太长
我们希望软件能够更容易被修改, 一旦需要修改, 我们希望能够跳到系统的某一点, 只在该处做修改. 如果不能做到这点, 一个类由于外界发生对变化需要进行不同部分对修改时, 发散式变化发生了
这种时候我们应该将这个类进行拆分, 另外界某一功能的修改产生的变化只发生在一个类中
和3.5类似, 但这是一个外界变化产生的修改发生在各处. 这种问题需要将所有需要修改的代码整合为一个类集中修改, 如果眼下没有合适的类那就创建一个
如果一个函数高度依赖多个类的属性, 那么应该判断哪个类被这个函数使用得最多, 然后将函数放到这个类中
这也就是核心:总是将一起变化的东西放到一起, 保持变化只在一处发生
总是绑定在一起使用的数据应该拥有属于自己的对象, 判断方法就是删除这堆数据中某个, 看看这堆数据是不是一起失去了意义, 失去了就代表该绑定了
不要吝啬使用小对象, 将一些基本类型包装为类很实用
switch的问题在于重复, 相近的switch常常遍布程序各处. 而我们使用switch的目的常常是达到一种简单的多态, 那更好的选择就是将switch提取为一个函数然后将其放到可以复用的类里
这是3.6的特殊情况, 我们可能在某个时候发现修改需要发生在两个平行继承的类中(霰弹创建的多个平行类), 此时可以让一个继承体系的类的实例去引用另一个继承体系的实例, 避免产生新的麻烦
尽管我们需要分解代码来保持逻辑的清晰, 但是一旦我们发现某些类的存在是不必要的, 徒增了理解的难度, 那就要及时将其删除
不要过度设计, 绝大多数预先设计都是无用的. 如果我们发现某个设计除了在测试样例中外毫无作用, 那么我们应该讲这个设计连同这个测试样例一起删掉
如果一个类中有一个复杂算法, 需要好几个临时变量的协作, 那么我们应该将这些操作和变量拆分提取到一个独立类中, 提炼后称为函数对象. 由这个类去维护那些临时变量, 也方便我们传递这些变量
长长的消息链并不一定是坏事, 有时候是被逼无奈的. 重要的是防止消息链过度耦合, 使得一个小小的修改影响了整个链的运作. 我们应该将消息链尽量提取和拆分, 提炼一些小函数作为链条中间的接口, 当用户可以从链的任何节点开始运行时, 解耦就做得差不多了
避免太多的委托和中间人的设计, 如果发现某个类和另一个类的交流中有一半以上的接口都由中间人来负责的话, 不如将中间人的相应实现提回对话的两边, 然后消除这个无用的中间人. 中间人应该只负责一点点粘合工作, 两个类间如果可以的话尽量不要中间人
不要让两个类过于亲密, 大量的private都能互相访问, 我们应该将这种耦合的类的相关方法尽量提取到新的类中, 然后让这两个类一起使用中间类来交互.
相似的, 如果子类对父类在继承中有了过多的了解, 也应该用委托来减少过多的试探, 和3.16似乎有冲突/取舍, 3.17这里主要是针对访问private的问题
如果出现两个函数做着一样的事情但是名称和接口不太一样, 应该尽量将其整合为一个函数, 或者用一个类来包装
库类的设计有时候也有一些不好的问题, 如果只想修改库类的一两个函数, 可以运用后面的Introduce Foreign Method方法;如果想要添加一大堆额外行为, 就得运用Introduce Local Extension.
用来管理字段的数据类很好, 但是我们要注意对其进行良好的封装, 尽量少暴露其内部的接口, 并为其设计一些非常常用的功能以物尽其用
传统思想中常常告诉我们超类应该是抽象的, 但是这并不是很大的问题, 我们常常需要继承来服用父类的方法, 所以没必要保持抽象. 而且如果遇到了我们需要继承一个类, 可是这个类中有些接口我们不想继承, 不要立刻为父类创建兄弟类, 我们只需拒绝继承超类的实现即可, 其实接口的多余继承并不重要
注释是一种好习惯, 但是当感觉需要撰写注释时, 先尝试重构试着让所有注释都变得多余, 通过提炼函数, 重命名各种函数和变量, 用断言替代注释中的规格声明. 注释的良好运用时机是记述将来的打算和自己都没有十足把握的区域. 这类信息可以帮助将来的修改者, 尤其是将来的自己
当看见一个过长的函数或者一段需要注释才能理解用途的代码, 就会将这段代码放进一个独立函数. 只有当能够对小型函数合适地命名时, 它们才能很好地起作用.
命名的关键在于函数名称和本体之间的语义距离. 如果提炼可以强化代码的清晰度, 那就去做, 就算函数名称比提炼出来的代码还长也无所谓. 只要新名字能更清晰地表现代码, 那就改名, 但如果想不出更好的名字, 那就别动.
提炼的困难点在于临时变量和局部变量的处理, 最简单的方法是用参数来连接, 如果变量太多可能需要用到参数对象之类的进一步方法. 当后续代码需要用到这些变量时, 用返回值进行连接, 尽量让返回变量的命名足够清晰, 别老用result之类的模糊的词. 返回值尽量保持只有一个, 需要多个返回值很多时候就是提炼还不到位.
是“提炼函数”的反操作, 有时会遇到某些函数的内部代码和函数名称同样清晰易读. 果真如此就应该去掉这个函数, 直接使用其中的代码. 间接性可能带来帮助, 但非必要的间接性总是让人不舒服.
另一种情况是:手上有一群组织不甚合理的函数, 可以将它们都内联到一个大型函数中, 再从中提炼出组织合理的小型函数, 也就是分层处理, 以比较大比较合理的模块进行重构会比较轻松.
如果内联需要进行的额外操作非常多例如破坏了一体性, 破坏递归等等, 那么就不应该进行内联.
有一个临时变量只被一个简单表达式赋值一次, 而它妨碍了其他重构手法. 将所有对该变量的引用动作, 替换为对它赋值的那个表达式自身. 这一条和下一条一般一起出现.
程序以一个临时变量保存某一表达式的运算结果. 将这个表达式提炼到一个独立函数中. 将这个临时变量的所有引用点替换为对新函数的调用. 此后, 新函数就可被其他函数使用, 这个重构是为了简化其他的重构步骤, 因为临时变量是重构的大敌, 驱使着我们写出更长的代码.
有时候会遇到带有计数的临时变量, 可以尝试将循环提炼出来使得计数也被提炼到查询中.
和其他性能问题一样现在不用管, 因为十有八九根本不会造成任何影响. 若是性能真的出了问题也可以在优化时期解决它. 代码组织良好往往就能够发现更有效的优化方案:若没有进行重构, 好的优化方案就可能失之交臂. 如果性能实在太糟糕, 要把临时变量放回去也是很容易的.
这是上面两条的反操作, 但是目的是类似的. 你有一个复杂的表达式, 将该复杂表达式(或其中一部分)的结果放进一个临时变量, 以此变量名称来解释表达式用途. 尽管提取出名称更合适的函数更实用且常用, 但当提取函数非常难以进行时, 引入解释性变量就可解燃眉之急.
你的程序有某个临时变量被赋值超过一次(承担多种责任),它既不是循环变量,也不被用于收集计算结果。针对每次赋值创造一个独立对应的临时变量来表达, 防止读者混乱
千万不要对参数进行赋值, 尽全力使得参数只用来表示(传进来的东西), 而对于拥有返回参数特性的语言, 除非性能瓶颈之类的考虑, 尽量不要用这种技巧. Java可以将参数标记为final来防止参数的重新赋值.
当大型函数对局部变量的使用使你无法采用ExtractMethod(110)时, 将这个函数放进一个单独对象中,如此一来局部变量就成了对象内的字段。然后你可以在同一个对象中将这个大型函数分解为多个小型函数。例如让外部对此的调用都变为: 初始化一个对象, 然后调用对象的compute()方法. 这种优化可以使得你减少参数的传递.
重构就是使得能以清晰的方式取代复杂的方式, 尽量减少重复性, 提高复用能力. 遇到一个屎山时, 先一步步对复杂函数进行分解但保留外部接口一致, 直到最后能完全替换掉复杂函数时再开始具体的算法优化.
当有类中有一个函数对于另一个类有着更多的交流, 也就是与另一个类高度耦合, 那么将这个函数搬移到那个类中.
有时候即使移动了其他函数还是很难对眼下这个函数做出决定。这也没什么大不了的。如果真的很难做出决定,那么或许“移动这个函数与否”并不那么重要。
将源函数的代码复制到目标函数中。调整后者,使其能在新家中正常运行。如果目标函数使用了源类中的特性,你得决定如何从目标函数引用源对象。如果目标类中没有相应的引用机制,就把源对象的引用当作参数,传给新建立的目标函数。如果源函数包含异常处理,你得判断逻辑上应该由哪个类来处理这一异常。如果应该由源类来负责,就把异常处理留在原地。如果你经常要在源对象中引用目标函数,那么将源函数作为委托函数保留下来会比较简单。
如果目标函数需要太多源类特性,就得进一步重构。通常这种情况下,我会分解目标函数,并将其中一部分移回源类。
某个字段被其所处的类之外的另一个类更多地用到。在目标类新建一个字段,修改源字段的所有用户,令它们改用新字段。为了稳定搬运, 都是先在目标类建立相同的字段和对应的设/取值函数, 然后慢慢对接直到最终从源类中删除字段. 这里提醒了建立设/取值函数能使得后期需要修改引用点功能的时候不会太困难
某个类做了两个类的事情时, 适当地将其切分为两个.如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。
分离的时候记得先分离低层函数再搬移高层函数, 然后搬移后尽量不要建立新旧类之间的链接. 旧类一般分离后都是类似基类的存在, 可以适当缩紧访问控制.
7.3的反操作, 当一个类没做多少事情时将其特性搬运到基类中.
客户通过一个委托类来调用另一个对象, 在服务类上建立客户所需的所有函数,用以隐藏委托关系。这种封装使得客户可以尽可能少地了解底层原本的实现, 同时也要调整客户令其只调用委托类.
7.5的反操作, 原先的类的复杂程度已经完全不方便导致客户需要频繁调用委托类, 但是委托类并没有做多少额外的操作, 单单是不停转发源类的操作时, 直接把委托类去掉令客户调用源类更加方便
当需要给一个无法修改的类添加功能时, 可以在客户中建立一个函数以参数形式接受目标类, 然后自己包装新的功能. 但这种做法只是权宜之计, 可以话还是修改目标类解决最好.
和7.7类似, 但是更进一步, 使用一个新的类包装老的类来扩展功能. 新的类必须要包含老的类的所有功能, 这种新类称为本地扩展, 必须保证在任何使用原类的地方都可以用本地扩展替代
本地扩展的实现有包装类(原类是新类的一个成员)和子类(原类是新类的基类)两种, 通常子类比较好实现, 但是需要接管原类的构造, 在原类构造后则只能通过逐元素拷贝构造来实现副本, 而包装类能做到的事情更多, 用户应该能够自然地将包装类当作原类使用.
一般来说, 本地扩展应该只添加新函数而非覆写, 防止增加用户的学习成本和防止混淆
直接访问字段时, 字段之间越来越耦合, 此时应该用设/取值函数来包装字段, 提供访问字段的更复杂的功能, 并进一步优化子类访问字段时的能力. 一般来说为了方便可以先直接访问, 直到需要增加访问功能的时候再使用设/取值函数.
要注意初始化途中最好少用设/取值函数, 防止语义混淆. 建议额外建立数据初始化函数. 处理数据项时, 最好让值对象是只读的, 也就是get函数返回副本, set函数创建新对象. 这样能防止用户使用的时候出现一些引用别名问题
当一些数据必须依赖于其它数据才有存在的意义(例如xyz坐标)时, 将这些数据进一步包装为数据对象减少耦合.
8.1的Tips的进阶版, 主要是修改get的返回值令其返回引用. 当我们需要返回的一个应该同步改变且拷贝代价较大的对象时使用.
8.3的反面, 对于那些很细小且不太应该同步改变的数据项返回内部不可变的一份拷贝, 在分布式系统中这种不可变对象比较常见因为不用太考虑同步问题. 也就是确保返回回来的值我们可以随意修改且不管什么用户在一个阶段内get的都是同一份值
简单的数组结构保存多个数据不够精准明了, 用对象包装这个数组并用设/取值函数作为入口, 然后将内部的数组写为private
主要是GUI编程会遇到的问题, 核心是观察者模式, 令GUI显示的代码与内部处理业务数据的代码分离开, 让GUI组件仅负责传递输入输出指令和结果, 后台业务代码负责真正的设值计算. 通常上也就是GUI类只有get, set和update函数, 后端业务代码处理算法逻辑, 然后额外建立一个领域类, 领域类中存放了用于计算的数据, GUI只保存用于显示的数据, 与领域类脱离. 后端结构处理领域类中的数据后, 调用update刷新显示GUI的数据.
当两个类都需要对方特性时, 繁复的get/set不够方便, 使用两个指针来直接操作对方数据. 通常关联有主次之分, 一对多的情况下, 那个一就是主, 负责更多的控制函数, 一对一或多对多时则无所谓.
8.7的反面, 主要是防止大量的引用导致的僵尸对象, 也就是某个对象已经死亡了但是由于主控制类对其的引用还存在所以始终不被清理. 方法是重新用设/取函数替代指针调用.
主要就是使用常量给魔数命名来提高代码的可读性
也就是public转private
有时候用户需要繁复的设/取值函数来控制目标类, 改为拷贝一个精简的副本(clone)打包供用户自己内部使用, 同时避免了用户始终在目标类上直接操作.
通过数据类来分析记录类数组的元素并替代
如果类型码不会影响当前类的行为的话(例如仅用于一些常量函数), 用一个小小的类包装一下名称即可, 如果会影响当前类则应该考虑使用多态处理. 经过包装后的类型码主要是为了方便后续修改时的引用点查找, 提高代码的可读性而不是仅仅看着一堆不明所以的数字, 还适配了编译器的类型检查.
8.13的进阶, 对于会影响目标类行为的类型码, 可以尝试用多态子类来处理, 用工厂函数包装和类型码有关的目标类, 然后返回不同的子类, 这样未来也方便进行扩展, 尽量保证与类型码相关的switch只出现在工厂函数中.
8.14的扩展, 对于无法经过多态处理优化的类型码场景, 例如某个对象携带的类型码自身在运行中会变化, 则应该使用状态/策略设计模式. 首先建立一个类并按照类型码用途进行命名, 这是状态对象. 然后为这个类添加对应各个类型码的子类, 然后在我们的目标类中保存这个类型码类, 增加对应的set函数, set函数会从工厂函数生成所需的类型码对象. 目标类后续的switch以这个类型码对象进行判断, 和8.13相似, 区主要是8.13的目标类不负责有关类型码的计算, 仅仅承载储存功能.
当很多子类之间的差别仅仅是返回的某个常量数据不同的时候, 直接将这个常量数据字段放到基类中然后撤销这些子类, 因为仅有常量函数不同的子类没有足够的价值, 徒增复杂性而已.
不要在if-else中写太复杂的逻辑, 改为在if-else中调用子函数提高代码可读性, 让代码就像注释一样清楚
如果多个if检测条件不同但结果相同, 则用一个命名合适的函数将这多个if合在一起简化. 这个重构要注意不要引发副作用且要保证语义的完整性和可读性
一组条件表达式中都出现了相同的代码, 那么将这段代码包装好并提炼到条件式外面
尽量用break, continue, return语句代替isDone之类的循环跳出标记, 这种做法虽然不那么适合"单一出口原则", 但能让语义更清晰, 看着办吧.
条件表达式通常有两种表现形式。第一种形式是:所有分支都属于正常行为。第二种形式则是:条件表达式提供的答案中只有一种是正常行为,其他都是不常见的情况。如果是第一种则应该用if-else组合, 而如果是第二种则单独检查那些特殊条件, 并将这些条件视为一个特殊的if直接跳出函数, 强烈表达我们对出现这种情况后的代码不再感兴趣了. 这种风格同样会破坏单一出口原则, 但是很多时候代码更清晰.
和第8章对于类型码的处理类似, 就是将switch提前到基类工厂函数的意思
null对象模式的核心是构造一种特殊的多态子类, 其是我们正常多态的分支, 可以和普通对象一样调用各种我们所需的功能, 但是其回应都是之前对象为null时应该返回的特殊值, 并带有isNull函数用来做基础判断. 这种模式一般都是用在与用户交互的任务中, 例如GUI环境, 能减少很多复杂的null判断.
本质上null对象模式属于Special Case模式(特例类模式), 最常见的就是float标准的NaN特例
断言(Assert)本身就是一个特殊的条件语句, 用来表示那些由于程序员错误导致某些函数无法进行的致命问题. 断言通常都是debug阶段使用的, 在正式版本中断言会被全部移除因为普通用户不应该触发断言
函数名应该和注释一样清晰, 让人可以只看名字就理解函数用途
过长的参数列不是好事情, 小心着点
随便移除参数也很危险, 当对代码不是完全清楚时, 不要随便移除参数, 应该自创一个新的包装函数来完成需求
对于任何一个有返回值的函数都不应该有看得到的副作用(内部优化查询缓存之类的操作是允许的), 应该将设值和取值功能分离. 当有返回值提供给调用者时, 必须确保返回值是可丢弃的.
多线程系统中常常有在同一个函数中完成检查和赋值操作(同步锁操作)的情况, 这种情况是允许的, 最好将这类函数清楚命名为synchronized, 然后作为设/取值函数外的第三种函数单独提供, 且其内部最好还是分别调用私有的设/取值函数组成.
多个命名不同但是内部行为类似的函数, 应该用参数取代混乱的命名.
10.5的反面, 主要是当参数可选范围很小时, 提高语义的清晰度使用.
是一个有两面性的重构, 主要是考虑传参的时候要传数据对象还是只传对象中的某些数据项. 一般来说传整个数据对象更方便也更清晰(甚至直接传递this指针), 但是只传参可以提高函数的泛用性, 降低耦合度.
当某个函数的某个参数固定由另一个函数提供时, 将这个函数调用吸收到这个函数内简化参数列表. 当然如果这个提供值的函数调用依赖于调用者那我们就无法精简这个参数, 但是这种情况出现说明这几个函数可能耦合了
同8.2将数据团包装为数据对象
提供设值函数暗示这个字段可以随时改变, 如果你不希望这个字段在创建后改变的话就移除这种函数同时将字段设为final
没被其它类用到的函数就设为private, 尽全力降低类的可见度
同 8.14以子类取代类型码 的优化
需要函数调用者执行向下转型的操作, 不如自己用函数封装起这个下转型操作来提高语义清晰度, 别让用户承担这些风险. 编译器能自动将子类上转型, 所以设计得当后无须提供上转型函数
这个重构和执行效率有冲突, 其余没什么特别的
如果某个异常调用者自己就可以避免的话, 让调用者提前测试并正常返回来避免引发异常
两个子类拥有相同字段时, 上移到基类, 注意别破坏访问控制性
同11.1, 这类操作能大大减少测试难度. 如果两个函数相似但不相同, 则可以试着用模板类来优化并提取
只在子类中保留构造函数有区别的部分, 然后主体通过调用基类构造来实现
11.2的反面, 主要是当一些子类完全用不到某个操作时下移
同11.4
当某些方法只被某些实例使用(一般是受到类型码影响)时, 提炼子类, 用继承取代
类似11.6, 将相似特性融合, 最小化重复的代码
实际上就是多继承在Java中的替代品
将基类和子类合并
就是使用模板来提炼那些操作流程相似只是类型不同的函数. Java实现起来复杂一些
当子类只使用到基类的一部分方法和字段时, 可以去掉继承关系, 用一个字段保存基类, 然后改为委托基类处理所需的功能. 成本是需要在基类中增加委托函数, 但一般难度不大
11.11的反面, 当委托了太多基类函数时, 干脆就收了吧. 但是要注意改为继承之后就无法令多个对象共享基类数据了, 因为数据共享是委托才能处理的功能, 需要权衡
这是庞大的重构指引, 是前面所有重构技巧的总结, 是需要大量时间和整个团队一起努力才能完成的任务, 目的是优化整个项目的结构, 使得后续开发更稳妥. 大型重构不必要一次性完成, 可以将其摊分在很长时间里, 一点一滴取得进步.
让每个继承体系只承担一种责任, 最常见的就是让数据显示和数据处理两个部分分离, 让同一套显示代码以参数或委托的形式接受各种不同的数据, 数据则自己处理自己
将数据变为对象, 将大块的行为切分并移动到数据对象中, 让分离的数据自己可以进行一些计算, 从而方便后续其它的重构而不用被一个大型的过程函数限制.
也就是用MVC设计模式设计GUI, 核心是对每个窗口建立一个储存自身数据的领域类(Field), 将与计算和显示通过领域类分离开来
某个类做了太多工作, 大多数工作是依靠条件表达式分离的, 那么提炼子类. 这是渐进式开发常见的情况, 也是一个非常漫长的重构, 需要仔细设计后再动手, 一切花费的时间都是为了以后更好地开发.
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有