重构 - Refactoring

重构

什么是重构

对软件内部进行调整, 使其在不改变行为的前提下, 提高其可读性, 降低其修改成本.

在编程这个行当通常是前人挖坑, 后人填坑, 随着时间和需求不断变更, 不重视重构的代码, 往往会变得晦涩难以理解, 在测试不足的情况下, 修改起来更为困难, 一个简单的问题可能会牵扯出一堆问题, 最后花费了大量的时间和精力, 正所谓代码不规范, 同行两行泪.

为何重构

随着程序的膨胀, 代码也随之走向腐败, 代码变得越来越难以阅读, 做正确的修改变得越发困难, 在整个系统变得凌乱不堪之前, 及时重构, 方便他人, 更方便自己.
重构的过程中有利于发现bug, 清晰的代码使得bug无处藏身.
重构短期看来增加了工作, 但长期来看减少了维护成本, 提高编程效率.

何时重构

重构的最佳时机在添加新功能之前.
其次是在需要理解一段代码时.

但注意:
重构不需要专门花时间, 一次重构应该是短时间内能够完成的, 但并非是一蹴而就, 代码随需求改变, 重构应该是时有发生, 频繁迭代的.
如果不经意间看到的凌乱代码, 但它藏身于某API中并能够正常运行, 那么可以不用管它, 只有等到需要理解其工作原理的时候, 再重构它.
对没能理解透彻的代码, 最好不要碰它, 重构有时并非易事, 需要专业的判断和丰富的经验, 无法做出重构决策时, 重写可能是正确的选择.

重构的原则

在不改变程序的可观察行为下重构. 如何保证重构不会引入其他bug? 最好能有一套自检测代码或依赖于某些IDE的自动化重构.
若没有自动化重构的环境, 那么坚持在一开始就编写能够自测试的代码.

重构与性能

重构不是性能优化, 重构并不保证性能提升(甚至有可能下降), 但重构应该优先于性能, 在性能存在问题时, 再以性能优化的角度编写为高性能的代码.

关于性能, 要知道20%的代码耗费着80%的性能, 也就意味着一视同仁的优化代码, 大部分时间都是平白劳神.
性能优化应该着重于那20%的代码, 以较少的工作量便能使性能立竿见影.

坏代码的味道

重构名录

提炼函数

目的: 将意图和实现分开.

可能会遇到的情况:

所需要提取的代码段无需变量: 直接提炼即可.
所提炼的代码需要某些变量的值, 但不会改变它们, 那么以参数形式替换之.
所提炼的代码需要对某些变量赋值, 那么拆分变量, 将其作为提炼函数的临时变量, 然后在将其值返回.

提炼后的函数更短小, 意图更明确, 取名更方便直接明朗.

内联函数

可以视为提炼函数过度, 使其成为了非必要的间接层, 此时应该将其与调用点内联. 至于是否非必要可能需要凭感觉, 或者使其内联后会更舒服呢.

提炼变量

将复杂的表达式提炼为变量, 变量会有一次命名, 这给解释这团表达式提供机会.
若这一团表达式上下文相关, 并多次出现, 那么将其提炼成函数是更好的选择.

内联变量

若提炼的变量本身并不比表达式更具有表现力, 那么变量反而妨碍理解, 此时就将其与表达式内联.

改变函数的声明

第一点当然任属于命名, 好的命名使我们顾名思义而不需要关注其内部实现.
对于参数, 看是否有必要缩小其功能, 参数功能越少, 模块之间的依赖就越小, 降低了耦合. 试想在使用内部变量时, 似乎就没人负担, 若传递的参数是一个拥有众多变量的类, 那函数内部可能会动用这些变量, 动的越多耦合度越大, 函数越发难以理解.

改变函数声明意味着在所有调用点都需要做修改, 若此项工作能够直接并快速的进行, 那么直接做便是了. 但若调用点太多或者根本无法修改完全, 那么使用一个得体的新函数包裹它, 旧函数被标记为遗弃, 这样在不妨碍项目工作的同时, 又得到了一个好的函数, 当然可以的话, 最后应该尽可能的删除那些旧的函数和调用.

封装变量

变量比函数更麻烦, 函数可以设计包裹, 而变量一旦改变, 则使用到该变量的所有地方都需要进行改变.
将变量设为私有(private), 再以函数的形式对改变量进行访问.

变量改名

好的命名是整洁编程的核心, 好的变量名可以很好地解释一段代码在做什么.

使用范围越广的变量, 命名显得尤为重要.
对于使用范围广的变量, 考虑封装变量.

引入参数对象

一组数据总是结伴同行, 甚至作为分散的参数传入函数, 此时应该将其封装为一个类型, 减少了函数的参数列表, 也提升了代码的一致性.

函数组合成类

一组函数总是形影不离的操纵同一数据, 那么将这个参数作为一个类的数据成员, 函数作为函数成员.

函数组合成变换

拆分阶段

若函数具有多个功能, 那么久应该拆分它.

封装

以对象取代基本类型

例如使用一个字符串表示电话号码, 何不新建一个电话号码的类.

以查询取代临时变量

将临时变量所代表的表达式封装成函数, 适合哪种计算一次, 不会被修改的变量.

提炼类

!!! Player可是一个大类, 几万行代码 !!! 但如何提炼, 可是一门技术活, 有待具体考究.

内联类

不存在的, 更多的要提炼出来…

隐藏委托关系

player->family-> chairman => player->chairman, 隐藏family

搬移特性

搬移函数

若函数频繁的引用其他上下文中的元素, 而对自身上下文中的元素关心甚少时, 就应该将此函数搬移到与那些元素更亲密的地方与之相会.
不需要切换上下文, 即使在同一个上下文中, 逻辑处理部分与数据定义部分相差很远时, 要理解这个变量, 都会向上翻很久.

搬移字段

数据结构往往才是健壮程序的根基, 坏的数据结构本身会掩盖程序的真正意图.
搬移字段的意义在于设计出合理的数据结构.

搬移语句到函数

!!! 消除重复, 消除重复, 消除重复

拆分循环

一个循环做一件事 -> 拆分的循环提炼成函数 -> 先如此重构, 在考虑性能

以管道代替循环

C++20! Ranges Library

重新组织数据

拆分变量

当一个变量充当了多个责任, 例如被多次赋值, 并且其表达的含义并不相同, 那么就应该拆分这为多个变量, 每个变量承担不同的任务.

以查询取代派生变量

对数据的修改常常导致代码的各个部分以丑陋的形式相互耦合, 在一处修改了变量, 造成了另一处的破坏…

尽可能把可变数据的作用域限制在最小范围.

传递值对象还是引用对象

不希望源数据发生改变, 则传递值对象, 如需要对象彼此都能够更新, 就传递引用对象.

简化条件逻辑

复杂的条件逻辑是最常导致复杂度上升的地点之一.

分解条件表达式

将if/else里的内容提炼成函数, 在以三元运算符表达之.

合并条件表达式

检查的条件不相同, 但最终的行为却一致 -> 使用逻辑运算符将这些条件合并, 有必要的话在提炼成一个bool函数

以卫语句代替嵌套条件表达式

在if/else的形式下, 如果两个分支的代码都属于正常行为, 那么没有问题, 表示对他们一视同仁.
但若其中一个为正常行为, 一个为异常行为, 那么对待异常行为就应该严格重视, 将其单独成一个分支, 以示其严重性.

以多态取代条件表达式

复杂课题

引入断言

如果发现代码假设某个条件始终为真, 那么就应该加入一个断言说明这种情况. 断言提供了一种交流价值.

重构API

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

明确区分出”有副作用”和”无副作用”的函数, 然后分离有副作用的那部分 – 任何有返回值的函数, 都不应该有看得到的副作用.
(函数副作用指当调用函数时, 除了返回函数值之外, 还对主调用函数产生附加的影响. 例如修改全局变量, 修改函数外的变量或修改参数)

函数参数化

一些函数只是其中同一个位置的数值不同, 合并这些函数, 将该数值参数化.

移除标记参数

“标记参数”是指用来指示被调用函数应该执行那部分逻辑, 类似于switch中的参数, 而这个数是通过函数参数传进来的.
bool标记尤其糟糕, 应该它不能清晰的传达其含义, true/false分别代表了什么意思呢?
移除标记函数是代码更加整洁, 当然其好处不止这一点.

针对每一种可能新建一个函数, 分解条件表达式.

如果需要重构的代码比较复杂, 以重构的函数对其进行包裹.

保持对象完整

从一个对象中取出值分别作为函数的参数??? 何不直接将这个对象传入函数! 这样一来, 如果函数还需要这个对象的其他参数, 便勿需修改函数接口了.

以查询取代参数

如果调用函数时需要传递一个值, 而这个值由函数自己来获得也同样容易, 这就造成了重复.
例如如果一个参数可以通过另一个参数通过查询就能得到, 那么这个参数就没有理由被传递.

以参数取代查询

该重构大多数情况下是希望改变代码的依赖关系, 将函数内部的变量替换为参数, 引用关系的责任转义给了函数的调用者.

函数的引用透明性: 若给函数传递同样的参数总是能获得同样的值, 那么这样的函数就是具有引用透明性的, 否则说明, 函数内部含有不具有引用透明性的的元素, 这时只需要把该不具有引用透明性的元素提炼成参数, 这样函数就能够保证具有引用透明性了. 这样的而函数理解起来更容易.

函数的调用者因此会显得复杂, 这是为了降低耦合而付出的代价…

移除设置函数

有些值在设定好了之后, 就不需要也不应该被改变, 那么它就不需要设值函数.

以工厂函数取代构造函数

工厂模式, 勿需多言

以命令取代函数

参见命令模式

处理继承关系

继承很有用, 但很容易被误用, 当发现去中的猫腻时… 往往…为时已晚, 两行泪

函数上移

只要含有重复代码, 就肯定会面临只修改了一处而忘了修改另一处的风险.
如果某个函数在各个子类中都相同, 那么就应该将该函数上移至父类, 同时伴随着的可能还会有参数上移, 函数参数化等重构方式, 以确保上移的函数能够获取到需要的参数.
如果子类中的函数工作内容相同, 但实现细节又有差异, 那么需要先将其重构一致, 在进行上移. 这里或许模板函数会有所帮助.

字段上移

子类父类可能由于相隔甚远, 某些字段其效用是一致的, 却以不同的名字在子类父类中分别存在, 这样就造成了字段重复, 此时需要上移字段, 即删除子类该字段并记得改名.

构造函数本体上移

字段上移了, 那么对该字段作为初始化该字段的构造函数当然也应该上移, 当然此上移指的是函数体的内容上移.

函数下移/字段下移

如果函数只与其中一个或几个子类有关, 那么该函数就不应该存在于父类中, 将它们下移至相应子类. 字段同理.

移除子类/提炼父类

折叠继承体系

子类和父类合并

以委托取代继承

此重构的缘由往往是因为为了一时的便利而错误地在代码中使用到了继承机制.
合理的继承关系的一个特征: 子类的所有实例都应该是父类的实例, 通过父类的接口来使用子类的实例应该完全不出问题.

当某个子类只使用父类接口中一部分, 或者根本不需要继承而来的数据, 或父类的一些函数对子类并不适用, 强行使用继承导致子类和父类的耦合过强, 父类的变化很容易破坏子类的功能.
此时应该以委托取代继承: 在子类中新建一个字段指向父类, 调整子类函数, 令它改而委托父类调用. 然后去掉两者之间的继承关系.