如何实施重构
稍微复杂的重构过程,都是由一系列的基本重构手法组成.《重构》一书中针对各种重构场景,给出了大量的重构手法.这些手法有的复杂,有的简单,如果不加以系统化的整理和提炼,很容易迷失在细节中.
另外,在不同场景下重构手法的使用是非常讲究其顺序的.一旦顺序不当,很容易让重构失去安全性,或者干脆让某些重构变得很难完成.
本节是个人对重构手法的整理和提炼,帮助大家跳出细节,快速掌握重要的重构手法并且能够尽快在自己的重构实践中进行使用.随后我们整理了重构手法应用顺序的背后思想,帮助大家避免死记硬背,可以根据自己的重构场景推导出合理的重构顺序.
基本手法
根据 2-8 原则,我们平时 80% 的工作场景中只使用到 20% 的基本重构手法. 而往往复杂的重构手法,也都是由一些小的基本手法组合而成. 熟练掌握基本手法,就能够完成绝大多数重构任务. 再按照一定顺序对其加以组合,就能够完成大多数的复杂重构.
经过对《重构》一书中的所有重构手法进行分析,结合日常工作中的使用情况,我们认为以下几类重构手法为基本手法:
- 重命名 (rename)
- 提炼 (extract)
- 内联 (inline)
- 移动 (move)
以上每一类的命名皆是动词,其宾语可以是变量,函数,类型。对于某些语言,例如 C/C++,还应该再包含文件(特指 “物理重构”).
例如对重命名操作,包含重命名变量,重命名函数,重命名类,以及重命名文件,它们皆为基本重构手法,都属于重命名这一类.
其它所有的重构手法大多数都是上述基本手法的简单变异,或者干脆由一系列基本手法组成.
例如常用的Self Encapsulate Field(自封装字段)
,本质上就是简化版的Extract Method
.
再例如稍微复杂的Replace Condition with Polymorphism(以多态取代条件表达式)
,就是由Extract Method
和 Move Method
组成的.
所以我们学习重构手法,只要能够熟练掌握上面四类基本手法,就可以满足日常绝大多数重构场景.通过对基本重构手法的组合,我们就能完成复杂的重构场景.
原子步骤
在我们提炼出了上述四类基本手法后,我们还是想问,既然重构手法都是代码等价变化操作,它们背后是否存在哪些共性的东西? 因为即使是四类基本手法,展开后也包含了不少手法,而且要去死记硬背每种手法的具体操作过程,也是相当恼人的.
事实上每种重构手法为了保证代码的等价变化,必须是安全且小步的,其背后的操作步骤都是相似的.我们对组成每种基本重构手法的步骤加以整理和提炼,形成一些原子步骤.一项基本重构手法是由原子步骤组成的.每一个原子步骤实施之后我们保证代码的功能等价性.
我们可以认为,基本上重构手法都是由以下两个有序的原子步骤组成:
-
setup
- 根据需要创建一个新的代码元素. 例如:变量,函数,类,或者文件
- 新创建的代码元素需要有好的名称,更合适的位置和访问性.更好体现出设计意图
- 新的代码元素的实现,可以copy原有代码,在 copy 过来的代码基础之上进行修改.(注意是copy)
- 这一原子步骤的操作过程中,不会对原有代码元素进行任何修改.
- 这一过程的安全性只需要编译的保证
-
substitute
- 将原子步骤 1 中新创建的代码元素替换回原有代码
- 这一过程需要搜索待替换元素在老代码中的所有引用点
- 对引用点进行逐一替换; 一些场景下为了方便替换,需要先创建引用"锚点"
- 这一过程是一个修改源代码的过程,所以每一次替换之后,都应该由测试来保证安全性
原子步骤 1,2 的交替进行,可以完成一项基本重构或者复杂重构. 在这里 1 和 2 可以称之为原子步骤,除了因为大多数的重构手法可以拆解成这两个原子步骤.更是因为每个原子步骤也是一项代码的等价变换 (只是层次更低),严苛条件下我们可以按照原子步骤的粒度进行代码的提交或者回滚.然而我们之所以不把原子步骤叫做手法,是因为原子步骤的单独完成往往不能独立达成一项重构目标. 灵活掌握了原子步骤的应用,我们除了不用死记硬背每种重构手法背后的繁琐步骤,更可以使自己的重构过程更安全和小步,做到更小粒度的提交和回滚,快速恢复代码到可用状态.
以下以两段 C++ 代码做示例,展示如何应用原子步骤完成基本重构手法:
-
重命名变量 (Rename Variable)
unsigned int start = Date::getTime(); // load program ... unsigned int offset = Date::getTime() - start; cout << "Load time was: " << offset/1000 << "seconds" << endl;
在上面的示例代码中,变量
offset
的含义太过宽泛,我们将其重命名为elapsed
,第一步我们执行原子步骤 setup,创建一个新的变量eclapsed
,并且将其初始化为offset
.在这里为了更好的体现设计意图,我们将其定义为 const.unsigned int start = Date::getTime(); // load program ... unsigned int offset = Date::getTime() - start; const unsigned int elapsed = offset; cout << "Load time was: " << offset/1000 << "seconds" << endl;
经过这一步,我们完成了原子步骤 1. 在这个过程中,我们只是增加了新的代码元素,并没有修改原有代码.新增加的代码元素体现了更好的设计意图. 最后我们编译现有代码,保证这一过程的安全性.
接下来我们进行原子步骤 substitute,首先找到待替换代码元素的所有引用点.对于我们的例子就是所有使用变量 offset 的地方.对于每个引用点逐一进行替换和测试.unsigned int start = Date::getTime(); // load program ... unsigned int offset = Date::getTime() - start; const unsigned int elapsed = offset; cout << "Load time was: " << elapsed/1000 << "seconds" << endl;
最后别忘了变量定义之处的替换:
unsigned int start = Date::getTime(); // load program ... const unsigned int elapsed = Date::getTime() - start; cout << "Load time was: " << elapsed/1000 << "seconds" << endl;
每一次替换之后都需要运行测试,保证对源代码修改的安全性.
在上述例子中,对于变量 start 和 elapsed 可以有更好的命名,这两个变量最好能够体现其代表时间的单位,例如可以叫做 startMs 以及 elapsedMs,大家可以自行练习替换. 另外程序中存在魔术数字 1000,可以自行尝试用原子步骤进行
extract variable
重构手法,完成用变量对 1000 的替换. -
提炼函数 (Extract Method)
void printAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } cout << "The amount of items is " << sum << endl; }
上述函数完成了两件事,首先统计一个 items 的集合的所有元素 value 的总和,然后对总和进行打印.
为了把统计和打印职责分开,我们提炼一个函数calcAmount
用来专门对一个给定的 Items 集合求总和.为了完成 Extract Method 重构手法,我们首先使用原子步骤 setup.首先建立
calcAmount
函数的原型,int calcAmount(const Items& items) { return 0; } void printAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } cout << "The amount of items is " << sum << endl; }
接下来完成
calcAmount
函数的实现.这一步需要将源函数中相关部分 copy 到calcAmount
中并稍加修改.切记由于原子步骤 1 中不能修改源代码,所以这里千万不要用剪切,否则一旦重构出错,是很难快速将代码回滚到正确的状态的,这点新手尤其需要注意!int calcAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } return sum; } void printAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } cout << "The amount of items is " << sum << endl; }
到目前为止,原子步骤 1 就已經 OK 了,我们运行编译,保证新增加的代码元素是可用的.
接下来我们进行原子步骤 substitute.将新函数calcAmount
替换到每一个对 Items 计算总量的地方.对于我们的例子,只有一个地方就是printAmount
函数 (相信对于真实代码,这类对 Items 求总量的计算会到处都是