【软件重构】详解常用的重构手法
一、重构方向
(1)以正则设计原则和组合式设计为指导进行代码重构,持续演进代码和领域建模
(2)安全小步的重构操作:等价操作,过程可逆,随时终止,断点续传
(3)分离变化,消除重复:
- 数据变化:分离数据与逻辑,数据外置,优化数据格式
- 行为变化:依赖注入,工厂与动态注册发现
- 类型变化:模板技术,代码生成
二、重构手法分类
- 重组手法:重组函数,重组数据
- 简化手法:简化函数调用,简化条件表达
- 提炼手法:提炼类,提炼函数
- 内联函数
- 重命名
- 搬移特性,移动函数
- 处理概括关系
三、详解基本重构手法
(1)内联函数
- 消除多余的间接性,将函数和被调用的代码合并了,方便理解
- 重新组织函数的过程中,把多个函数代码内联到一个函数中,方便后续重构
什么是内联函数
也叫在线函数或编译时期展开函数,是指编译器将指定的函数体插入并取代每一处调用该函数的地方(上下文),从而节省了每次调用函数带来的额外时间开支。
为什么需要内联函数
内联扩展是一种特别的用于消除调用函数时所造成的固有时间消耗方法。一般用于能够快速执行的函数,因为在这种情况下函数调用的时间消耗显得更为突出,提高性能。
具体表现在:
Java中一个方法中调用不仅有执行方法逻辑的开销,还有一部分底层的开销,比如:方法栈帧的生成、局部变量的进栈与出栈、指令执行地址的跳转,所以需要在一些特定时候引入内联机制,减少底层的开销,从而提高运行性能。
内联之前:
public int fun1(int a, int b){
return fun2(a, b);
}
public int fun2(int a, int b){
return a + b;
}
内联优化之后:
public int fun1(int a, int b){
return a + b;
}
这样内联之后减少了fun2栈帧的生成、fun2 局部变量进栈出栈操作,以及从fun1 到 fun2 指令执行的切换开销,可以给我们带来一定的性能开销
使用内联函数的时机
虽然内联函数可以提高性能,但是,并不是我们所有的地方都可以进行内联,原因有两点:
- 热点代码:即一个代码如果常常被我们调用到,那么才有必要进行内联优化,如果一个方法就调用一次,那么很明显没有必要进行优化
- 方法体大小:JVM 中被内联的方法会被编译到机器码放在code cache 中。如果方法体大,会影响缓存热点方法的个数,反而会影响性能。
影响方法内联的因素就是方法调用频率,以及方法大小。
而Java 方法内联则由JVM控制,开发者无法控制,并且是在JIT在运行期进行
我们也可以进行JVM配置,强制指定哪些方法可以被内联。
下面列举部分内敛规则:
- 由 -XX:CompileCommand 中的 inline 指令指定的方法,以及由 @ForceInline 注解的方法(仅限于 JDK 内部方法),会被强制内联。
- 由 -XX:CompileCommand 中的 dontinline 指令或 exclude 指令(表示不编译)指定的方法,以及由 @DontInline 注解的方法(仅限于 JDK 内部方法),则始终不会被内联。
- 如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 native 方法,都将导致方法调用无法内联。
- JVM决定一个方法是否是热的(例如:频繁调用,它不直接受任何可调参数的影响)。如果一个方法由于频繁调用而符合内联条件,那么只有当它的字节码大小小于325字节(或者指定为-XX:MaxFreqInlineSize=N标志)时,它才会内联。否则,只有当它很小:小于35字节(或者指定为-XX:MaxInlineSize=N标志的值)时,它才有资格进行内联
(2)合并条件表达式
适用于条件表达式比较多的场景下。将多个条件合并在一起,可以使合并表达式表示为只有一次条件检查,让使用更加清晰。同时可以为提炼函数做准备。
具体该如何做:
1)先确定这些条件表达式都没有副作用
如果某个条件表达式有副作用,可以先将查询函数和修改函数分离处理
2)使用适当的逻辑运算符,将两个相关条件表达式合并为一个
顺序执行的条件表达式用逻辑或来合并,嵌套的if语句用逻辑与来合并
3)如果存在无法合并的嵌套条件表达式,则可以使用卫语句进行取代;保持代码清晰才是最关键的:如果单一出口能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。如果所有卫语句都引发同样的结果,可以使用合并条件表达式合并之。
4)测试
重复前面的合并过程,知道所有相关的条件表达式都合并在一起。最后可以考虑将合并后的条件表达式提炼成一个函数。
(3)移动函数
让存在关联的东西一起出现,可以使代码更容易理解。通常来说,把相关代码搜集到一处,往往是另一项重构(通常是在提炼函数)开始之前的准备工作。
具体该如何做:
- 确定待移动的代码片段应该被搬往何处。仔细检查待移动片段与目的地之间的 语句,看看搬移后是否会影响这些代码正常工作。如果会,则放弃这项重构
- 往前移动代码片段时,如果片段中声明了变量,则不允许移动到任何变量的声明语句之前。往后移动代码片段时,如果有语句引用了待移动片段中的变量,则不允许移动到该语句之后。往后移动代码片段时,如果有语句修改了待移动片段中引用的变量,则不允许移动到该语句之后。往后移动代码片段时,如果片段中修改了某些元素,则不允许移动到任何引用了这些元素的语句之后
- 剪切源代码片段,粘贴到上一步选定的位置上
- 测试:如果测试失败,那么尝试减小移动的步子:要么是减少上下移动的行数,要 么是一次搬移更少的代码
(4)重组函数
将查询函数和修改函数分离
- 如果某个函数只是向你提供一个值,没有任何看得到的副作用(或说连带影响), 那么这是个很有价值的东西。你可以任意调用这个函数,也可以把调用动作搬到函 数的其他地方。简而言之,需要操心的事情少多了。
- 明确表现出「有副作用」与「无副作用」两种函数之间的差异,是个很好的想法。
- 因此,我们可以定下一条规则:任何有返回值的函数,都不应该有看得到的副作用。
- 如果你遇到一个「既有返回值又有副作用」的函数,就应该试着将查询动作从修改动作中分割出来。
做法:
- 新建一个查询函数,令它返回的值与原函数相同。
- 修改原函数,令它调用查询函数,并返回获得的结果。
- 编译,测试。
- 将「原函数的每一个被调用点」替换为「对查询函数的调用」。然后,在调用査询函数的那一行之后,加上对原函数的调用。每次修改后,编译并测试。
- 将原函数的返回值改为void。删掉其中所有的return 句。
用已明确的函数取代参数
- 如果某个参数有离散取值,而函数内又以条件式检查这些参数值,并根据不同参数值做出不同的反应,那么就应该使用本项重构。
- 调用者原本必须赋予参数适当的值,以决定该函数做出何种响应;现在,既然你提供了不同的函数给调用者使用,就可以避免出现条件式。
- 此外你还可以获得「编译期代码检验」的好处, 而且接口也更清楚。
- 如果以参数值决定函数行为,那么函数用户不但需要观察该函数,而且还要判断参数值是否合法,而「合法的参数值」往往很少在文档中被清楚地提出。
做法:
- 针对参数的每一种可能值,新建一个明确函数。
- 修改条件式的每个分支,使其调用合适的新函数。
- 修改每个分支后,编译并测试。
- 修改原函数的每一个被调用点,改而调用上述的某个合适的新函数。
- 编译,测试。
- 所有调用端都修改完毕后,删除原(带有条件判断的)函数。
(5)提炼函数
- 提取功能,将部分代码提取到独立函数,方便复用和维护
- 提升可读性,提炼到一个独立的函数中, 并以这段代码的用途为这个函数命名,方便理解
做法:
- 创建一个新的空函数
- 将需要提取的代码复制到新函数中
- 根据代码中变量作用域增加参数和局部变量
- 编译,测试
- 在原函数中用新的函数替换掉被提取的代码
- 编译,测试
(6)重组数据之拆分变量
如果变量被多次赋值,且赋值的意义不同,意味着它们在函数中承担了多个职责,容易造成理解困难和修改出错,应该根据不同的职责被拆分成不同的变量,每个变量只承担一个职责。
并非所有多次赋值的变量都承担了多个职责,比如累加计数结果变量、字符串拼接结果等虽然多次赋值但是职责未变。
做法:
- 在待分解变量的声明及其第一次被赋值处, 修改其名称
- 如果可能的话,将新的变量声明为不可修改
- 以该变量的第二次赋值动作为界,修改此前对该变量的所有引用, 让它们引用新变量
- 测试,如果测试失败,那么尝试减小移动的步子:要么是减少上下移动的行数,要 么是一次搬移更少的代码
- 重复上述过程。 每次都在声明处对变量改名, 并修改下次赋值之前的引用, 直至到达最后一处赋值
(7)提炼类
- 单一职责,将不同职责的代码提取到独立的类(模块)
- 重新组织类,将多个类合并到一起,以便按照其他方向进一步拆分
做法:
- 创建新类
- 在需要提取处创建新类的实例
- 逐个搬移字段和函数,修改旧代码通过新类的实例访问
- 每一个搬移都需要编译,测试
(8)内联类
- 消除多余的间接性,消除不在有独立职责的类
- 重新组织类,将多个类合并到一起,以便按照其他方向进一步拆分
做法:
- 对于待内联的类的所有public函数和属性,在目标类上建立一个对应的方法调用原类实例的方法,做一个委托调用
- 目标类中修改所有引用待内联的类public方法和属性的地方,改为调用目标类的委托方法
- 每次替换都执行测试保证正确
- 使用搬移方法逐个将方法从待内联的类移到目标类
- 测试
- 删除待内联的类
- 点赞
- 收藏
- 关注作者
评论(0)