《重构》学习拆分 statement 函数
Posted RikkaTheWorld
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《重构》学习拆分 statement 函数相关的知识,希望对你有一定的参考价值。
目录
自己在项目中开始承担越来越重的任务,但是代码的水平没有怎么提高,CodeReview时经常被leader质疑这质疑那,只能说还需要提高程序设计水平。
之前阅读过 《大话设计模式》,粗略的学习了设计原则,收获比较多的是了解了些最常用的设计模式,例如 单例、工厂模式、建造模式等,还了解了诸如单一职责等设计原则,最重要的是学会了画UML图。
但仅仅这样是还不够的,这种东西只有学的越多,才能刻入到自己写的代码中,所以我想通过学习 《重构第二版》一书,来提高coding能力,以便以后在设计代码时考虑的更全面一些。
全书400页左右,算是一般的量,预计3个月完成(因为现在在同时进行 Flutter 的学习)。书里面是用 javascript 写的,我这里会转化成 Kotlin 的形式进行学习。
1.1 起点
假设我们是一个剧团, 我们剧团会被邀请去各种剧场表演。
一般由客户(custom)来指定剧目,剧团就根据观众数量和剧目类型来向客户收费,我们需要设计一个系统来实现,需要有下面这些功能点:
- 该剧团目前只有两种类型的戏剧:喜剧(comedy)、悲剧(tragedy)
- 需要根据 观众(audience) 和 剧目类型 (type) 来算出账单
- 给客户发出账单时,剧团需要根据到场观众数量,给出“观众量积分”(volume credit),下次客户再来看表演时,可以通过积分获得折扣
首先我们把剧团的剧目信息存储在一个 map 中:
data class PlayInfo(val name: String, val type: String)
val playInfos: MutableMap<String, PlayInfo> = mutableMapOf(
Pair("西红市首富", PlayInfo("西红市首富", "comedy")),
Pair("夏洛的烦恼", PlayInfo("夏洛的烦恼", "comedy")),
Pair("新宿事件", PlayInfo("新宿事件", "tragedy"))
)
其次,客户请剧团演出后的相关信息如下:
data class PerformancesData(val playId: String, val audience: Int)
data class Invoices(val customer: String, val performances: List<PerformancesData>)
val invoices = Invoices(
"中船戏院", listOf(
PerformancesData("西红市首富", 55),
PerformancesData("夏洛的烦恼", 35),
PerformancesData("新宿事件", 48)
)
)
最后,我们剧团要打印出这个客户的账单,代码如下:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
var totalAmount = 0
var volumeCredits = 0
var result = "这里您的账单: $invoice.customer \\n"
for (perf in invoice.performances)
val playInfo = plays[perf.playId] ?: continue
var thisAmount = 0
when (playInfo.type)
"tragedy" ->
thisAmount = 40000
if (perf.audience > 30)
thisAmount += 1000 * (perf.audience - 30)
"comedy" ->
thisAmount = 30000
if (perf.audience > 20)
thisAmount += 10000 + 500 * (perf.audience - 20)
thisAmount += 300 * perf.audience
// 加积分
volumeCredits += (perf.audience - 30).coerceAtLeast(0)
if (playInfo.type == "comedy") volumeCredits += floor(perf.audience / 5f).toInt()
// 打印当前戏剧的演出费用
result += " $playInfo.name: $thisAmount / 100f ($perf.audience 观众) \\n "
totalAmount += thisAmount
result += "所欠金额:¥$totalAmount / 100f\\n"
result += "你获取了 $volumeCredits 信用分 \\n"
return result
打印如下:
这里您的账单: 中船戏院
西红市首富: 740.0 (55 观众)
夏洛的烦恼: 580.0 (35 观众)
新宿事件: 580.0 (48 观众)
所欠金额:¥1900.0
你获取了 66 信用分
1.2 函数评价
这个程序的设计怎么样呢? 我自己认为是能看得懂,但是也有一些可以改进的地方,比如归纳其计算策略算法。
代码组织不清晰,但这也在可以接受的范围内。 这样小的程序,不做任何深入的设计,也不会太难理解, 但这是因为这个例子非常小,假如把这段代码有上百行,那么把这些代码放到一个函数里面就很难理解的。
差劲的系统是很难修改的,因为难以找到修改点,难以了解做出的修改与现有代码如何协作实现我想要的行为。如果很难找到修改点,就很有可能犯错而引入Bug。
所以,如果我们需要修改一个上百行的程序,我们会希望其有良好的结构,即被分解成一系列函数和其他程序要素。这能帮我们更易于清楚了解这段代码在做什么。
如果你要给程序添加特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构那个程序,使其容易添加该特性,然后再添加该特性
上面这段代码无法应对需求的变化,例如:
- 输出希望以表单的形式,而不是以 String 的形式,这会导致 result 变量要加很多分支逻辑
- 或许还有更多类型的戏剧,那计费方式、积分计算都会被影响
需求的变化使得重构变得必要,如果一段代码能正常工作且不需要被修改,那么它就完全没有重构的必要, 但是如果有人想要理解其工作原理,那最好还是改进一下代码
1.3 重构第一步
当需要进行重构的时候, 第一步永远是:保证被修改的代码有一组可靠的测试用例, 这能帮助我们在修改代码时不会引入Bug。放到本人的开发来说,就是有一套单元测试来测试被修改类。
写测试系统的好处:
- 写单元测试其实就是对代码进行二次检查,降低犯错概率
- 虽然写单元测试需要花费时间,但其实却节省了后期debug调试时间
statement
函数返回的就是一个字符串,重构前后,我们可以通过输入几个 invoices、plays 来借助测试框架检查输出,即 期望输出和实际输出,来判断我们的修改是否会产生问题。
1.4 分解 Statement 函数
每当看到这样长长的函数,就要下意识的从整个函数中分离出不同的关注点。
1.4.1 分离 when 语句
首先,先着眼的是中间那段 when
的语句:
....
when (playInfo.type)
"tragedy" ->
thisAmount = 40000
if (perf.audience > 30)
thisAmount += 1000 * (perf.audience - 30)
"comedy" ->
thisAmount = 30000
if (perf.audience > 20)
thisAmount += 10000 + 500 * (perf.audience - 20)
thisAmount += 300 * perf.audience
...
这段代码的作用是用来计算每种类型戏剧的演出费。当然如果没有细看代码的话,我们并不会清楚每个类型的戏剧是如何计算的,因为它里面做了一些运算。
为了理解这段代码,我们得先把它抽成一个独立的函数,按照其作用给这个函数命名,比如命名为 amountFor()
。
注:每次想要将一块代码抽取成一个函数时,都会遵循一个标准流程,最大程度减少犯错的可能,所以我们需要把这个流程记录了下来,并命名为提炼函数(001),以便日后可以方便的引用
首先需要检查一下,如果提炼到一个独立的函数里,有哪些变量会离开本函数作用域。
在这个示例中, 是 playInfo
、perf
、thisAmout
这个三个变量, 前两个变量会被提炼后的函数使用,但是不会被修改,所以独立函数可以将他们以参数的方式传递进来, 我们更关系那些会被修改的变量,这里的就是 thisAmout
,因此可以将它从函数中直接返回,我们还可以将其初始化放到提炼后的函数中去,代码如下:
fun amountFor(perf: PerformancesData, play: PlayInfo): Int
var thisAmount = 0
when (play.type)
"tragedy" ->
thisAmount = 40000
if (perf.audience > 30)
thisAmount += 1000 * (perf.audience - 30)
"comedy" ->
thisAmount = 30000
if (perf.audience > 20)
thisAmount += 10000 + 500 * (perf.audience - 20)
thisAmount += 300 * perf.audience
else -> throw Exception("unknown type:$play.type")
return thisAmount
现在 statement() 就可以调用这个函数了:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
var totalAmount = 0
var volumeCredits = 0
var result = "这里您的账单: $invoice.customer \\n"
for (perf in invoice.performances)
val playInfo = plays[perf.playId] ?: continue
// 这里使用提炼后的函数
val thisAmount = amountFor(perf, playInfo)
volumeCredits += (perf.audience - 30).coerceAtLeast(0)
if (playInfo.type == "comedy") volumeCredits += floor(perf.audience / 5f).toInt()
result += " $playInfo.name: $thisAmount / 100f ($perf.audience 观众) \\n "
totalAmount += thisAmount
result += "所需缴费:¥$totalAmount / 100f\\n"
result += "你获取了 $volumeCredits 信用分 \\n"
return result
在书中,每当进行了类似于这样的修改,需要重新编译并执行测试,保证修改不会出错,然后提交 commit, 最后push时,将这些零散的commit通过合成一个有意义的commit再push到远程仓库上。
1.4.2 命名讲究
这里书中将提炼函数 amountFor()
的 thisAmount 命名为 result
,永远将返回值命名为 result
,这样就能一眼知道这个函数的作用。
1.4.3 移除 playInfo 变量
我们继续观察这个 amountFor()
,我们看看它的参数都从哪里来, perf
是从循环变量中来的,所以自然每次循环都会改变,但 playInfo
变量 是由 PerformancesData
计算得到的,因此没有必要将它作为参数传入,我可以在 amountFor()
函数中重新计算得到它。 当我们分解一个长函数时,需要将 play 这样的变量移除掉,因为它们创建了很多具有局部作用域的临时变量,这会导致提炼函数更加复杂。这里我们要使用重构手法来取代临时变量。
就是将复制表达式的右边提炼出一个函数:
fun playFor(perf: PerformancesData, plays: MutableMap<String, PlayInfo>): PlayInfo = plays[perf.playId]?: PlayInfo()
然后通过内敛函数的形式在顶层作用域调用:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
...
for (perf in invoice.performances)
val thisAmount = amountFor(perf, playFor(perf, plays)) // 1
volumeCredits += (perf.audience - 30).coerceAtLeast(0)
if (playFor(perf, plays).type == "comedy") volumeCredits += floor(perf.audience / 5f).toInt() // 2
result += " $playFor(perf, plays).name: $thisAmount / 100f ($perf.audience 观众) \\n " // 3
totalAmount += thisAmount
...
看到这里,可能有些同学会感到疑问: 重构之前,获取 play
变量的代码,每次循环只执行了一次,但是重构之后,却执行了三次,这不是看起来变得更加麻烦了吗??
我们会在后面探讨重构与性能之间的关系,但是现在,我认为这次改动还不太可能对性能有严重影响,即使真的有所影响,后续再对一段i建二狗良好的代码继续进行性能调优,也容易得多。
接下来我们继续移除掉 amountFor()
的 play 变量,使其:
fun amountFor(perf: PerformancesData, plays: MutableMap<String, PlayInfo>): Int
var result: Int
when (playFor(perf, plays).type)
...
else -> throw Exception("unknown type:$playFor(perf, plays).type")
return result
顶层调用:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
...
for (perf in invoice.performances)
val thisAmount = amountFor(perf, plays)
volumeCredits += (perf.audience - 30).coerceAtLeast(0)
if (playFor(perf, plays).type == "comedy") volumeCredits += floor(perf.audience / 5f).toInt()
result += " $playFor(perf, plays).name: $thisAmount / 100f ($perf.audience 观众) \\n "
totalAmount += thisAmount
...
移除局部变量的好处就是做提炼时会简单得多,因为需要操心的局部作用域减少了。
实际上,在做任何提炼时,一般都会先去移除局部变量。
之后我们看下顶层作用域的 amountFor
计算出来的 thisAmount, 它之后不会再被修改了,所以我们又可以采用内敛的手法内敛它:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
...
for (perf in invoice.performances)
volumeCredits += (perf.audience - 30).coerceAtLeast(0)
if (playFor(perf, plays).type == "comedy") volumeCredits += floor(perf.audience / 5f).toInt()
result += " $playFor(perf, plays).name: $amountFor(perf, plays) / 100f ($perf.audience 观众) \\n "
totalAmount += amountFor(perf, plays)
...
1.4.4 提炼计算观众量积分逻辑
我们移除了 play 变量后,这使得我们提炼观众积分的计算逻辑又简单了一些。
我们仍需要处理其他两个局部变量, perf 同样可以做为参数传入,但 volumeCredits
变量则有些棘手, 它是一个累加变量,每次循环迭代都会更新它的值。 所以最简单的方式,就是将整块逻辑提炼到新函数中,然后在新函数中直接返回 volumeCredits,我们命名为 volumeCreditsFor
,如下:
fun volumeCreditsFor(perf: PerformancesData, plays: MutableMap<String, PlayInfo>): Int
var volumeCredits = 0
volumeCredits += (perf.audience - 30).coerceAtLeast(0)
if (playFor(perf, plays).type == "comedy") volumeCredits += floor(perf.audience / 5f).toInt()
return volumeCredits
提炼使用后,顶层作用域就是这样的:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
...
for (perf in invoice.performances)
volumeCredits += volumeCreditsFor(perf, plays)
result += " $playFor(perf, plays).name: $amountFor(perf, plays) / 100f ($perf.audience 观众) \\n "
totalAmount += amountFor(perf, plays)
...
之后, 我们将 volumeCreditsFor
中重新命名 volumeCredits => result 就可以了
1.4.5 移除 观众量积分总和
临时变量往往会带来麻烦, 它们指在对其进行处理的代码块中有用,因此临时变量实质上是鼓励你写长而复杂的函数。 因此下一步需要替换掉一些临时变量,从 volumeCredits 变量入手,这个变量比较微妙,因为它是在迭代过程中累加得到的, 第一步就是拆分循环,将 volumeCredits 的累加过程分离出来:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
var totalAmount = 0
var result = "这里您的账单: $invoice.customer \\n"
for (perf in invoice.performances)
result += " $playFor(perf, plays).name: $amountFor(perf, plays) / 100f ($perf.audience 观众) \\n "
totalAmount += amountFor(perf, plays)
// 拆出这个循环
var volumeCredits = 0
for (perf in invoice.performances)
volumeCredits += volumeCreditsFor(perf, plays)
result += "所需缴费:¥$totalAmount / 100f\\n"
result += "你获取了 $volumeCredits 信用分 \\n"
return result
然后抽出循环后如下所示:
fun statement(invoice: Invoices, plays: MutableMap<String, PlayInfo>): String
var totalAmount = 0
var result = "这里您的账单: $invoice.customer \\n"
for (perf in invoice.performances)
result += " $playFor(perf, plays).name: $amountFor(perf, plays) / 100f ($perf.audience 观众) \\n "
totalAmount += amountFor(perf, plays)
result += "所需缴费:¥$totalAmount / 100f\\n"
result += "你获取了 $totalVolumeCredits(invoice, plays) 信用分 \\n"
return result
fun totalVolumeCredits(invoice: Invoices, plays: MutableMap<String, PlayInfo>)
var volumeCredits = 0
for (perf in invoice.performances)
volumeCredits += volumeCreditsFor(perf, plays)
到这里,我们先斯多普一下。 我们在这里修改了循环,肯定会有人警惕(包括我),因为我们对循环重复了一次。
大多数时,重复一下这样的循环性能的影响都可忽略不计,如果你在重构前后进行计时,很可能甚至都注意不到运行
以上是关于《重构》学习拆分 statement 函数的主要内容,如果未能解决你的问题,请参考以下文章