重构 - Reorganize Data and Control Structure
这是重构的第四篇文章,主要讲解对于数据结构的重建问题。着重介绍了子封装方法代替字段、数据对象替代数据值、数据对象值和数据对象引用的转换、数组数据的拆分、数组数据对象的封装,也介绍了对于单向和双向数据绑定的处理、字段取代子类(组合而非继承)等惯用的数据组织习惯。在第二部分,讲解了对于控制结构的重构,包括表达式清晰化、控制结构优化、卫语句,最后介绍了空对象和多态对于减少控制结构样板代码的作用。
数据的封装
Self Encapsulate Field
略,Scala 具有统一对象访问,不允许访问字段,访问的都是自动编译的同名函数。
Replace Data Value with Object
下面介绍了数据值和对象值的区别,以及你为什么应该使用对象值而不是数据值。对于字段 a 而言,它是一个字符串,但是承担了过多的责任。方法 getSchoolAndCity 使用了复杂的逻辑来从中抽取合理的信息 —— 使用了脆弱的协议,基于一个带有空格的下划线表示分割,这意味着,当前后字段中出现此下划线时,拆分将错误,如果没有下划线时,拆分将错误。
修改方法就是使用一个数据对象,在这里,很自然的将其表示成为两个字段。当这个对象执行越来越多的操作,使用 Move Method 和 Move Field 手法将很多函数迁移到其对象内部时,这时候面向对象编程的优点才会淋漓尽致的表现出来。比如这里的 sayHello。对于命令式,想象一下如果要调用 1000 个入参基本类型是如何的痛苦吧。
一点忠告
新手,尤其是 OOP 的新手,很愿意使用数据值和脆弱的约定,这是一种难以扩展的、易碎的错误的选择。从很大程度上来说,这是一个意识的问题。他们可能从 C 过来,按照计算机的思维思考,所有的封装最后总是转换成命令执行 —— 但这不意味着开发者要忍受这种痛苦。实际上,人尽管可以以非常大量、快速的方式接受信息,但是得益于注意系统,我们可以维持对于极少部分内容的深度关注。知识是无穷尽的,领域有专攻,当一个对象具有更加明确的责任边界,事情处理起来会方便很多,这意味着你可以不用在自己代码中扮演上帝,当然,如果你没有承受过过程式的上万行代码之苦的话。
object ReplaceDataValueWithObject extends App {
val a = "Central China Normal University | Wuhan"
def getSchoolAndCity(in:String):(String,String) = {
val array = in.split("\\|").map(_.trim)
val school = array.headOption match {
case Some(i) => if (i.isEmpty) "NoSchool" else i
case None => "NoSchool"
}
val city = if (array.length < 2) "NoCity" else {
val city = array.reverse.head
if (city.isEmpty) "NoCity" else city
}
(school,city)
}
def sayHello(school:String, city:String): Unit = {
println(s"I came from $school, it is in $city")
}
println(getSchoolAndCity(a))
sayHello(getSchoolAndCity(a)._1,getSchoolAndCity(a)._2)
val b = Place("Central China Normal University", "Wuhan")
println(b)
b.sayHello()
}
case class Place(school:String, city:String) {
def sayHello(): Unit = {
println(s"I came from $school, it is in $city")
}
override def toString: String = s"($school, $city)"
override def hashCode(): Int = super.hashCode()
//对于值对象,需要重写 equals 和 hashCode 进行比较
override def equals(obj: Any): Boolean = obj match {
case o: Place =>
if (this.school == o.school && this.city == o.city) true
else false
case _ => false
}
}
一个教训
我曾经在写一个心理学 GUI 程序的时候 —— 使用的是自己的框架,因为偷懒,所以没有涉及数据收集步骤。所有的数据收集都是通过遍历日志一行一行进行的。开始很轻松,只用对每行进行分析,如果满足条件,那么用正则表达式提取即可,但是慢慢的,问题出现了,因为当初定义打印到日志中的格式改变了,逐渐项目变得非常复杂,解析日志的程序经常崩溃报错,或者收集不到数据。
在同一个程序中,我还将结果使用 csv 输出,并且在用户为 csv 添加了一列后,对新的 csv 进行重新处理。这是噩梦的源泉,读取一个文档可能遇见各种的奇怪的错误,尤其是一个被用户编辑的文档,此外 Excel 也总是自作聪明的篡改 CSV 的数据呈现格式。更有甚者,在 csv 中间插入一列这个需求都难以实现 —— 因为 csv 是 log 文件直接解析的,为 csv 输出添加功能就不得不修改 log 解析程序,一行一行找对应的列,在合适的位置打印输出。
这一切的噩梦都来自于使用了该死的文本数据。在后来的程序中,替换成了 Java 的二进制序列对象,短短几行代码,基本上再也没有出过问题。
Change Value to Reference
Change Reference to Value
注意一个基本值转换成为对象后,其还存在一个引用和不可变的问题。对于简单的、不怎么修改的对象,使用不可变的对象值更有优点,尤其是对于多线程并发调用来说。这个时候,唯一需要做的操作就是重写 equals 和 hashCode 方法来提供对于两个不可变对象的比较的方法。
显然,硬币是两面的。在一些时候,可能我们需要可变的对象引用,而不是不可变的对象值,其差别一般表现在是否含有较多的非 final 字段(对于 Scala 而言则是非 val 的 var)。这时候,如果是从之前的基本值重构过来的话,可能我们需要通过工厂来达到引用的目的。
object PlaceFactory {
val buffer: mutable.Buffer[Place] = mutable.Buffer[Place]()
def getPlace(school:String, city:String): Place = {
buffer.find(p => p.school == school && p.city == city) match {
case None =>
val nPlace = Place(school, city)
buffer.append(nPlace)
nPlace
case Some(p) => p
}
}
}
val a1 = Place("a","b")
val a2 = Place("a","b")
println(a1 == a2)
println(a1.hashCode(), a2.hashCode())
val c = PlaceFactory.getPlace("CCAU","Wuhan")
val d = PlaceFactory.getPlace("CCAU","Wuhan")
println(c.hashCode(), d.hashCode())
注意上文的 a1 和 a2,它们相等,这是因为 equal 判断我们重写了,但是 hashCode 不同,这是因为其属于不同的方法。而 c 和 d 也相等,并且 hashCode 相同,这是因为它们代表了相同的引用。
集合的封装
Replace Array with Object
Encapsulate Collection
对于集合而言,如果是那种 MATLAB 风格浓郁的每个矩阵的每列代表不同的字段,如果这种风格迁移到了 Java 上,这会是一个让人头痛的问题。典型的处理方式是,使用 ArrayList 替代 Object[],此外,将这个数组对象包装成一个字段,控制外部对它的访问,一个更好的主意是,当别人对其访问的时候,转换成只读的数据结构,不允许对其进行操作,比如下面的 languages 字段在 get 的时候被转成了 Array,并且对此 Array 的操作不影响 languages 本身。
class People(val name:String, var age:Int) {
private val languages: mutable.Buffer[String] = mutable.Buffer("Scala")
def getLanguages:Array[String] = languages.toArray
}
封装的可获得性
Unidirectional Association and Bidirectional Association
单向和双向绑定在某些场景下,都是有道理的。当程序因为单向绑定而难以操作 —— 必须费尽心机获取其另一半引用的时候,就应该重构为双向绑定,反之亦然。
在处理双向绑定的时候,要注意值为空的问题,这是造成 Java 指针为空错误的最大的罪魁祸首。Scala 可以使用 Map 的 API,或者 Option 包装函数返回值,使用 match 解析结果保证没有空对象。在现代的语言中,通常使用 .? 的方式获取对象的一个方法,如果存在的话。
很显然,如果你不原因这么做,可以在 getXXX 方法中使用一个 Fake Object,来避免这种错误。
此外,获得的可能是一个集合。单向和双向数据绑定是 DDD 的一个重要课题,在 Hibernate(JPA) 等 ORM 框架中经常会处理到。JPA 一个臭名昭著的问题是,当对一个集合字段声明为 @Column,如果不给与初始值,那么调用将会直接出错。因此,对于集合字段,一般给一个空的初始值更好,或者在 getXXX 中进行这种处理,提高性能的同时避免错误。
封装替代子类化
Replace Subclass with Fields
class People(val name:String, var age:Int) {
private val languages: mutable.Buffer[String] = mutable.Buffer("Scala")
def getLanguages:Array[String] = languages.toArray
}
class Man(name:String, age:Int) extends People(name,age) {
def getCode:String = "M"
}
class Woman(name:String, age:Int) extends People(name,age) {
def getCode:String = "F"
}
可以重构为:
class SuperPeople(name:String, age:Int,
private val code:String) extends People(name,age) {
def getCode:String = code
}
object PeopleFactory {
def getPeople(name:String, age:Int, code:String): People = {
new SuperPeople(name,age,code)
}
}
注意,这本质上是委托和继承之间的选择。总而言之,委托总是更方便的,而继承在原本类不可修改的时候会有优势。从语义上来说,委托适合 has 关系的情况,但是继承只适合于 is 关系的情况。从实际来看,委托的使用情景多一些。
对于上述的这种,对于委托和继承都合适,是一种 is 关系,但是,使用参数而非子类,结构很清晰易懂,同时避免了过多的不必要的类嵌套 —— 如果能够控制类构造器的构造,通过工厂方法来返回对象实例就最好了。
控制结构的重构
分支判断优化 - 分解条件表达式
其目的是为了提升语意清晰度。
条件表达式很容易变得逻辑不清晰,因此,总是考虑使用函数查询或者中间变量来提升条件结构的可读性。 这在很大程度上替代了注释。
需要注意的是,你可以使用 final 变量或者一个函数来代表一个表达式,但是对于变量而言,注意,其被赋值过之后,行为表现和函数查询不同,因此,总是考虑使用函数查询来替代条件表达式,因为控制结构几乎总是和状态判断相关的,而变量可能不会很好代表当前状态,并且其相比较函数而言,更加难以重构,除非它就放在条件表达式上面。
object AAA {
val isSummer = true
val price = 20.0
val number = 4000
def getResult: Double = {
if (isSummer || number > 3000) price * number * 0.9
else price * number * 1.0
}
def needDiscount: Boolean = {
isSummer || number > 3000
}
def getResult2: Double = {
if (needDiscount) price * number * 0.9
else price * number * 1.0
}
}
分支结构缩减 - 合并条件表达式
多个返回值相同的控制结构合并或、单层嵌套控制结构合并与。
多个控制结构返回相同的值,是一种不良的习惯。它们很容易被合并成为一个控制结构,以及一个大的表达式。 除非是你觉得这些检查互相独立,那么不能合并成一个可解释的内容,则不合并。
object BBB {
val isSummer = true
val price = 20.0
val number = 4000
def getResult: Double = {
if (isSummer) return price * number * 0.9
if (number > 3000) return price * number * 0.9
price * number * 0.9
}
def needDiscount: Boolean = {
isSummer || number > 3000
}
def getResult2: Double = {
if (needDiscount) return price * number * 0.9
price * number * 0.9
}
def getResult3: Double = {
if (isSummer) {
if (number > 3000) {
if (price > 10) {
return price * number * 0.9
}
}
}
price * number
}
def isExpensive: Boolean = price > 10
//注意,这种单个或者少数的逻辑符号和多个函数查询的控制结构,
//就不用继续拆分了
def getResult4: Double = {
if (needDiscount && isExpensive) price * number * 0.9
else price * number
}
}
分支结构增加 - 使用卫语句
卫语句可以替代嵌套的条件表达式,或者抽取公共逻辑,简而言之,其可以减少分支
object DDD {
case class School(name:String, address:Address)
case class Address(name:String, city:String)
val a = School("CCNU",Address("Wuhan","China"))
def printSchoolInfo(): Unit = {
if (a != null) {
if (a.address != null) {
if (!a.address.name.isEmpty) {
println(a.address.name)
}
}
}
}
def printSchoolInfo2(): Unit = {
if (a == null) return
if (a.name.isEmpty) return
if (a.address != null && a.address.name == null) return
println(a.address.name)
//其实这个例子不太好,因为这三个分支显然有关系,因此可以使用合并条件表达式,较少分支的方法
}
def isSchoolHaveNoAddressName(school: School):Boolean = {
school != null && school.address != null &&
school.name != null && !school.address.name.isEmpty
}
def printSchoolInfo3(): Unit = {
if (isSchoolHaveNoAddressName(a)) println(a.address.name)
}
}
区块代码优化 - 抽取公共语句
合并重复的条件片段 【多个逻辑中的公共语句应该抽取】
和多个返回值相同的控制结构类似,不过这里的限制是,当所有分支都执行同一操作,才可以提取,否则,应该
1、合并控制结构的分支,其更适合平等关系的分支。 2、使用卫语句,当合并分支没有意义时,使用卫语句,先排除不太可能的分支。卫语句更适合那种并非平等关系的分支逻辑。
object CCC {
def methodA(): Unit = {
if (1 > 1) {
println("Hello1")
println("Hello, World")
} else if (2 > 2) {
println("Hello2")
println("Hello, World")
}
}
def methodA2(): Unit = {
if (1 > 1) {
println("Hello1")
} else if (2 > 2) {
println("Hello2")
}
println("Hello, World")
}
}
区块代码优化 - 循环和多返回
移除循环停止标记,使用 continue 和 break;
允许多个分支直接返回,使用 return(在 Scala 中不适用,请绝对不要尽可能的在 Scala 中使用 return 提前返回)
高级技术 - 多态替代分支判断
abstract class Employee(val salary:Int) {
def getSalary:Int = salary
}
class Manager(salary:Int, val bound:Int) extends Employee(salary) {
override def getSalary: Int = super.getSalary + bound
}
class Engineer(salary:Int) extends Employee(salary)
class Others(salary:Int, val others:Int) extends Employee(salary) {
override def getSalary: Int = super.getSalary + (others * 0.7).toInt
}
object Department {
def printSalary(employee: Employee): Unit = {
println("\n========== Salary Info ==========")
println(employee.getSalary)
println("========== End ==========\n")
}
def printSalary2(employee: Employee): Unit = {
println("\n========== Salary Info ==========")
employee match {
case manager: Manager => println(manager.bound + manager.salary)
case engineer: Engineer => println(engineer.salary)
case others: Others => println(others.salary + others.others * 0.7)
}
println("========== End ==========\n")
}
}
高级技术 - Null 对象
trait NullObject
package NullTest {
case class School(name:String, address:Address = AddressFactory.fakeAddress)
case class Address(name:String, city:String)
object AddressFactory {
val fakeAddress: Address = new Address("Nothing","Nothing") with NullObject
}
object Test {
def main(args: Array[String]): Unit = {
val cc = School("CC")
val address = cc.address
println(address.isInstanceOf[NullObject]) //true
val city = cc.address.city
println(city)
}
}
}
2019-05-06 撰写本文。