这本书最早在大约十年前曾经囫囵的读过一遍,但和当时差不多同一时期看的<模式识别>一样,都看的云里雾里的,前阵子又翻出来看了一遍,突然发现很多东西都能看明白了,甚至还能对书中的一些观点提出一些不同的理解了,看来真应了那句老话——纸上得来终觉浅,绝知此事要宫……要躬行啊,经验这个东西确认讲究一个厚重哈!

这本书的核心思想可以总结为以下几点:

  • 重构是保证代码在开发过程中始终处于易于维护的状态的唯一手段
  • 重构需要在开发过程中坚持不懈的进行
  • 要记住代码首先是给人看的(请把自己也当个人看),其次才是给机器看的

不过这本书的思想中也有一个我认为略显偏激的部分,即作者认为所有代码都应该以可读性为优先,但在像游戏行业这种对性能要求很高的领域,我觉得重构的目标应该在某种程度上努力达到性能与可读性的一种平衡(极致的追求性能往往会牺牲可读性),我认为性能优化本身也应该包含为重构的一部分。

总的来说这本书全面的阐述了重构的概念与思想,并总结汇列了日常开发中比较典型的需要进行重构的问题代码类型以及其对应的重构策略,几乎可以认为是一本软件工程开发人员必读的好书!

PS1:重读完发现好像前两年出了更新的第二版,我这次真应该读第二版的……

PS2:我把笔记部分也贴出来了,有些凌乱,不过有兴趣的朋友可以读读

笔记

最重要的一件事就是坚持以持续不断的重构行为来整理代码

我:作为开发者应该通过不断的调整,来让自己的代码始终保持在最佳状态中

重构就是再代码写好之后改进它的设计

第一章:第一个重构案例

每当我要进行重构的时候,第一个步骤永远相同:我得为即将修改的代码建立一组可靠的测试代码。而且这些测试还必须得自我检测能力。(即测试可以帮你直接判断出结果的正确性,而不是输出测试内容,再人工复合正确性)

我:在心动小镇项目中重构ResManager的时候由于原本的逻辑就存在缺陷,因此只能在没有可靠测试环境的情况下进行重构,最终能顺利完成重构的结果现在看来无疑还是多少有些幸运的

重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它

更变变量名称是值得的行为吗?绝对值得!好的代码应该清楚表达出自己的功能,而变量名称是代码清晰的关键

我:实际工作中变量名应该也是常改常新(随着重构的进行而不断修改)

只有写出人类容易理解的代码,才是优秀的程序员。

我:作者在第一个重构实例中使用了”用接口来代替临时变量“的做法,在我看来这对于性能是有所损失的,为了极致追求可读性而损失性能这一点上可能还值得推敲

我:构造函数中是不是没有必要也追求使用封装好的Set函数??

第二章:重构原则

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

我:如果书中提倡的重构是追求人的可阅读性,我则认为重构更重要的是维护好结构性(降低未来的修改成本)和性能,可读性相对来说则是稍稍可以妥协的(相信继任者的一定代码阅读水平,保持正常水平即可)

重构的目的是使软件更容易被理解和修改

和重构一样,性能优化通常是不会改变组件的行为(除了执行速度),只会改变其内部结构,但两者出发点不同,性能优化往往是代码较难理解。

两顶帽子:

  • 添加新功能时,你不应该修改既有代码,只管添加新功能
  • 重构时你不能再添加功能,只管改进程序结构,此时你不应该添加任何测试

设计不良的程序往往需要更多代码,这常常是因为代码在不同的地方是用完全相同的语句做同样的事。因此改进设计的一个重要的方向就是消除重复代码。代码愈多,正确的修改就愈困难,因为有更多代码需要理解

很多人说重构是为了未来的程序员来接受相关的开发工作,但更多的时候那个未来的开发者就是我们自己,这样去想重构就显得尤其重要了。我是个懒惰的程序员,我的懒惰表现形式之一就是:总是记不住自己写过的代码

如果不对代码做些修改,也许我永远看不见它们,因为我的聪明才智不足以在脑子里把这一切都想象出来

要记住”磨刀不误砍柴工“!适当的重构绝对可以提高编程速度

重构本来就不是一件应该特别拨出时间做的事情,重构应该随时随地地进行,你不应该为了重构而重构

三次法则:当你三次做有件事并产生反感时,你就应该重构 —— 事不过三,三则重构

”结对编程“的形式,把代码复审的积极性发挥到了极致

系统当下的行为,只是整个故事的一部分,如果没有认清这一点,你无法长期从事编程工作。如果你为求完成今天的任务而不择手段,导致不可能在明天完成明天的任务,那么最终还是会失败。

我:但同时也不应该为了明日的任务,而在今天采用过度设计

很多经理嘴巴上说自己”质量驱动”,其实更多是”进度驱动”,对此争议的建议是:不要告诉经理!

间接层是一把双刃剑,会让程序难以阅读;而好处是允许逻辑共享并可以分离意图与实现。有一种比较少见的重构游戏:找出不值得间接层,并将它拿掉

学习一种可以大幅提高生产力的新技术时,往往难以察觉其不适用的场合。新技术的展示项目往往只是个别情景,这种情况你很难看出什么会造成这种新技术成效不彰或者形成危害(ECS刚刚流行的时候我就是这种感觉)

对于修改接口,重构时需要特别谨慎,尤其是已经发布的接口,这意味着你需要同时维护新旧两个接口,直到所有用户都有时间对这个变化做出反应

何时不该重构:

有时候既有代码实在太混乱了,重构还不如重新写一个来得简单。做出这种决定很困难。重写(而非重构)的一个清楚讯号就是:现有代码根本不能正常运行!(与ResManager何其相似!)

未完成的重构工作就像是”债务”。很多公司都需要借债来使自己更有效地运转。但是借债就需要付利息,过于复杂的代码所造成的维护和扩展的额外成本就是利息

思考 -> 设计 -> 编码 -> 重构

不一定要在一开始就找到”最正确”的解决方案,此刻的你只需要得到一个足够合理的方案就够了,因为在实现初始方案的时候,你对问题的理解也会逐渐加深,这时就是重构发挥功效的时候

重构让日后的修改成本不在高昂

当下只管建造可运行的最简化系统,至于灵活而复杂的设计,唔,多数时候你都不会需要它(不要过度设计)

哪怕你完全了解系统,也请实际测试它的性能,不要臆测

我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道

我:作者提到了书中重构的思想是”除了一些对性能有严格要求的实时系统“(很不幸游戏开发即在此列中),所以游戏开发者,可能需要对重构的尺度稍微做一些调整

第三章:代码的坏味道

一些典型的”坏”代码:

  • 重复代码
  • 过长的函数
    • 早期的编程语言中,子程序调用需要额外的开销,但现代OO语言几乎已经免除了进程内的函数调用开销
  • 过大的类
  • 过长的参数列表
  • 发散式变化
    • 如果某个类经常因为不同的原因在不同的方向上发生变化,就是发散式变化
    • 此时也许将对象拆分为两个会更好
  • 霰弹式修改
    • 如果你每次改动,都必须在许多不同的类内做许多小修改,就是霰弹式修改
  • 依恋情结
    • 函数对某个类的兴趣高于自己所处类的兴趣(通常是数据)
    • 解决思路是移动函数至其更适合的位置
  • 数据泥团
    • 总在一起出现的数据应该拥有属于它们自己的对象
  • 基本类型偏执
    • (作者鼓励使用对象来代替基础类型数据,这样对性能的损伤相比可读性的提升要更大吧?)
  • Switch现身
    • (作者鼓励使用多态来代替Switch,又是一种牺牲来换取可读性的方案,但我并不觉得多态会比Switch更具有可读性)
  • 平行继承体系
    • 每当你为某个类添加一个子类,就必须为另一个类相应的增加一个子类的情况
  • 冗赘类
    • 在开发和重构的过程中逐渐失去其原本作用的类(应当被果断消灭)
  • 夸夸其谈未来性
    • 不提前为”未来”进行实现
  • 令人迷惑的暂时字段
    • 仅为特定情况而设置的临时对象
    • 需要通过Extract Class来将相关代码提取
  • 过度耦合的消息链 / 中间人
    • 不要有”中间商”赚”差价”——尽量减少事件传递中不必要的路径
  • 不合适的”亲密”
    • 两个类直接的对象过分的互相使用
    • 通过Extract Class / Move Method来”斩断”其中一方对另一方的依赖
  • 异曲同工
    • 相同的函数功能,不同的接口名称
  • 未完成的类
  • 纯粹的数据类
    • 需要随时调整数据类对外暴露的访问接口
  • 被拒绝的馈赠
    • 父类中有子类并不需要的数据和接口(不想继承)
    • 这通常意味着继承体系上的设计错误
  • 过多的注释
    • 注释过多通常是因为代码很糟糕(笑)
    • 当你感觉需要撰写注释时,请先尝试重构,试着让所有的注释都变得多余

第四章:构筑测试体系

编写优良的测试程序,可以极大地提高编程速度

每个程序员都能讲出花一整天(甚至更多)时间只为找出一个小问题的故事

每个类都应该包含它们自己的测试代码

编写测试代码时,我往往一开始先让它们失败,是为了向自己证明:测试机制的确可以运行

功能测试的主要目的则是用来保证软件能够正常运作,功能测试尽可能把整个系统当作一个黑箱

进行重构时,单元测试是我们的好朋友

测试的要诀是:测试你最担心出错的部分。测试的一项重要技巧就是”寻找边界条件”

当测试数量达到一定程度之后,继续增加测试带来的效益就会呈现递减态势

第五章:重构列表

对于强类型的编程语言中,你往往可以直接删除旧部分,让编译器来帮你找出因此而被悬挂起来的引用点(Rider等现代IDE可以帮我们完成这个工作)

编译器无法找到通过反射机制而得到的引用点,这也是我们应该小心使用反射的原因之一

在单进程软件中,你永远不必操心多么频繁地调用某个函数,因为成本足够低;但在分布式软件中,函数的往返必须被减至最低限度

第六章:重新组织函数

1. Extract Method

将过长的函数中部分值得复用的部分提取为单独的函数

每个函数的粒度都很小,那么函数被复用的机会就更大;此外,这也使得高层函数读起来象是一系列注释;最后,函数的腹泻也会变得更容易

关键在于函数名称和函数本题之间的语义距离(如果判定函数长度是否合适)

2. Inline Method / Temp

将不必要的函数调用或临时变量声明直接展开放入函数中 / 如果临时变量妨碍到其他的重构,可以对其进行内联化(如果要方便调试的话,则不应该内联化)

间接层有其价值,但不是所有的间接层都有价值

3. Replace Temp with Query

使用接口来代替局部变量的赋值与使用,潜在以牺牲性能为代价,我不是很赞同

此外在我的经验中,如果大量编写get这种小函数,实际上会对代码的可读性逐渐产生负面的效果

4. Split Temporary Variable

局部变量尽量不要一心二用。针对每次赋值,创造一个独立、对应的临时变量

同一个临时变量承担两件不同的事情,会令代码阅读者糊涂

5. Remove Assignments to Parameters

不要对函数的参数进行赋值(主要当作常量使用,按引用传递的话应当做return来处理)

6. Replace Method with method Object

将函数中用到的局部变量也封装为对象,对于这类通过引入新的类型或增加成员变量来换取可读性的方法军不是很赞成

第章:在对象之间搬移特性

1. Move Method / Field

如果一个类中的方法/变量主要被另一个使用,那么应该移动至更”亲近”的类中

2. Extract / Inline Class

如果部分成员变量具有共同的逻辑特性,那么就应该被提取至单独的类中;反之则应该融合至同一个类中

3. Hide Delegate / Remove Middle Man

通过添加新的接口来隐藏委托调用,或去除中间接口/类来进行直接调用(去除委托)

4. Introduce Foreign Method

如果你使用的对象中缺少部分函数功能,而你又无法修改(第三方库),则可以通过外加函数(全局或静态)来实现,但如果你需要大量的外加函数,则说明应该使用”Introduce Local Extension”

5. Introduce Local Extension

对第三方的库中的类进行封装,来实现功能的扩展或者希望的接口上的修改

第七章:重新组织数据

1. Self Encapsulate Field

全部是用Get/Set函数来对成员变量进行访问(好处我能理解,即在未来出现问题时非常方便调试,但是感觉写起来就很繁琐)

此外的好处还有子类中可以仅通过覆写一个函数就做到改变获取数据的途径,也支持延迟初始化等(只有在需要用到某值时才对其进行初始化)

直接访问变量的好处是:代码比较容易阅读

我比较喜欢先使用直接访问方式,知道这种方式给我来麻烦为止,此时我就会转而使用间接访问方式

2. Replace Data Value with Object

鼓励对每一个成员变量进行类封装吗??好家伙,疯了吧……不认同,不认同

3. Change Value to Reference, Vice versa

如果一个类衍生出许多彼此相等的实例,则希望通过引用来保证他们均指向同一个对象(尽可能保证数据的单一同源性

如果当引用对象开始变得难以使用时,则应该将其改为值对象

4. Replace Array with Object

对于数组中元素各自代表不同东西的情况,应该使用类来进行替换,用不同的字段来表示

5. Duplicate Observed Data

将GUI控件中的数据复制到领域对象中,建立一个Observer模式(大概就是MVC那套吧)

你不能仅仅只是移动数据,必须将它复制到新的对象中,并提供相应的同步机制

6. Change Unidirectional Association to Bidirectional, Vice versa

当两个类都需要使用对方特性,但期间只有一条单向链接。则应添加一个反向指针,并使修改函数能够同时更新两条连接(有点像AssetRequest和BundleRequest)

反之当一个类不再需要另一个类的特性时,则可以去掉不必要的关联

7. Replace Magic Number with Symbolic Constant

魔法数字应该使用常量来表示

8. Encapsulate Filed

其实和第1条说的是同一件事,将public字段改为private,并提供get函数

9. Encapsulate Collection

对于类中的集合对象,于其提供对象本身的引用,更应该提供接口的封装(收敛功能权限)

10. Replace Recode with Data Class

鼓励使用数据类来代替Struct,并提供get接口来访问

11. Replace Type Code with Class / Subclasses / State

和第2条非常类似,但是针对Type Code类似的数据;使用Subclass的方式实际上是利用多态来代替分支语句;使用State模式或Strategy模式来实现(通过Switch语句来创建不同的子类) 12. Replace Subclass with Fields

如果情况足够简单,则可以将多态改回分支

第八章:简化条件表达式

1. Decompose Conditional

将if语句中较复杂的判别式,提取为单独的函数

2. Consolidate Conditional Expression

将多个结果相同的条件判别式,合并成一个(但我觉得大部分情况下还是不合并的好,因为总会需要频繁改动)

3. Consolidate Duplicate Conditional Fragments

将条件分支中重复的代码搬移到条件表达式外

4. Remove Control Flag

使用break或者return语句来代替控制标记

5. Replace Nested Conditional with Guard Clausese

尽量避免使用重复嵌套较深的条件语句,使用平铺的方式来表达(翻译为卫语句?)

6. Replace Conditioanl with Polymorphism

这不又是上一章中的的第11条吗?看到这里已经感觉到这本书中还是存在大量雷同的内容了……

7. Introduce Null Object

在继承体系中引入Null Object类,可以改善你代码中需要大量重复判断null的情况

8. Introduce Assertion

当某一段代码需要对程序状态做出某种假设时,可以使用断言来明确表现这种假设

使用断言时,只有条件为真时,代码才能正常运行。因此断言的失败应该导致一个非受控异常。断言绝不能被系统的其他部分使用。

实际上,程序最后的成品往往将断言统统删除。因此,标记”某些东西是个断言”是很重要的

第九章:简化函数调用

我有一个坚守的很有价值的习惯:明确地将”修改对象状态”的函数(修改函数)和”查询对象状态”的函数(查询函数)分开设计。

1. Rename Method

名字起的不合适的情况

记住,你的代码首先是为人写的,其次才是为计算机写的。而人需要良好名称的函数。

2. Add / Remove Parameter

过长的参数列是不好的味道

程序员可能经常添加参数,却往往不愿意去掉它们。他们打的如意算盘是:无论如何,多余的参数不会引起任何问题,而且以后还可能用得上它

3. Separate Query from Modifier

将查询函数和修改函数分离

如果你在一个多线程系统中工作,肯定知道这样一个重要的惯用手法:在同一个动作中完成检查和赋值

4. Parameterize Method

例如将fivePercentRaise()和tenPercentRaise()合并为raise(percentage)

5. Replace Parameter with Explicit Method

将setValue(width, height)拆分成setWidth()和setHeight()

6. Preserve Whole Object

将需要从同一对象中访问的多个属性值,封装成统一的对象来返回(连作者都说这条具有两面性)

7. Replace Parameter with Methods

通过函数内部调用其他函数的方式来减少传递的参数的数量

8. Introduce Parameter Object

上一条思想的延续,通过将多个参数合并为新的类对象来减少传递参数的数量

9. Remove Setting Method

有些人甚至会在构造函数中使用设值函数!(那么构造函数中是否建议调用Init()函数来完成初始化)

10. Hide Method

对于外部不会调用的函数应当修改为private

11. Replace Constructor with Factory Method

通过工厂函数来创建对象,例如:(好处主要是可以集中管理Code Type的逻辑)

Employee(int type) { _type = type; }

改为:

static Employee create(int type) { return new Employee(type); }

12. Encapsulate Downcast

将类型转换(Casting)的动作移到函数中,你不应该要求用户来承担Casting的工作,应该由接口提供准确的数据类型

13. Replace Error Code with Exception

异常是语言级别的错误(崩溃)处理机制,它可以让错误分析变得更加容易,但并不能消除错误(崩溃)

异常,这种方式之所以更好,因为它清楚地将”普通程序”和”错误处理”分开了

使用错误码的缺点是不便于理解

14. Replace Exception with Test

异常应该只用于异常的、罕见的行为,也就是那些产生意料之外的错误的行为,而不应该成为条件检查的代替品

第十章:处理概括关系

1. Pull Up Field / Method

如果子类中的多个字段/函数使用方式相似,那么他就应当被归纳到超类中

2. Pull Up Constructor Body

字段的初始化应当尽量在超类中完成,子类通过调用超类的构造函数来完成对应字段的初始化

3. Push Down Field / Method

当超类中的字段/方法只是为某一个子类实现的,那么它应当移至对应的子类中

4. Extract Subclass

如果类中的某些特性只被某些(并非全部)实例用到,那么应当将这部分特性移至子类中

5. Extract Superclass

和第1条思想相同(又是在凑字数……)

6. Extract Interface

不同的类中相同的逻辑最好通过接口的方式来组织

7. Collapse Hierarchy

子类和超类区别不大时,可以合并在一起

8. Form TemPlate Method

将子类直接一些存在区别的操作分别放进独立的函数中,并保持相同的签名,在原函数变得相同后,上移至超类

将子类中不同的实现提取至专门的接口中,核心目标是尽可能减少重复代码

9. Replace Inheritance with Delegation, Vice versa

使用组合代替继承!(这个我强烈赞同)

如果实际上还是使用到了受托类的所有函数,那么就还是应该使用继承来代替委托方式(组合)(只要没有使用到所有函数,那么还是倾向于使用组合)

第十一章:大型重构

你不可能说服精力把系统停止运行两个月来让你进行重构。你只能一点一点地作你的工作,今天一点点,明天一点点

整个团队都必须意识到:有一个大型的重构正在进行,每个人都应该相应的安排自己的行动。

只有持续而无处不在的重构才能有可能竟其功

四个大型重构:

1. Tease Apart Inheritance:梳理并分解继承体系

某个继承体系同时承担了两项责任,则应该分别建立独立继承体系,并通过组合的方式来实现互相调用

AssetRequest和BundleRequest就是活生生的例子

2. Convert Procedural Design to Objects

将过程化设计转化为对象设计

作者是明显倾向于对象化实现的,但实际上在游戏开发中,很多功能比较单一的模块中处于性能的考虑是会更倾向于过程化的实现风格的!

3. Separate Domain from Presentation

MVC那一套,把领域逻辑从GUI类中分离出来(这个不是再前面已经讲过了吗??严重凑字数!)

MVC模式最核心的价值在于:它将用户界面代码(GUI)和领域逻辑(即模型)分离了

4. Extract Hierarchy

当你的类做了太多工作,其中一部分工作是以大量条件表达式完成的,则应该为其建立对应的继承体系

第十二章:重构、复用与现实

综合的总结性讨论,这一章大概读读就好

第十三章:重构工具

安利作者开发的Smalltalk语言的重构工具,不太适用于绝大多数开发者……