《重构》学习笔记(06)-- 重新组织数据

在面向对象的语言中,通常会有直接访问数据还是通过方法访问数据的争论。同时,面向对象的语言也允许使用自己定义的新类型取代传统语言的简单数据类型。将数组转换为对象、自封装字段魔法数字的消除,都是本周要介绍的点。

Self encapsulate Field(自封装字段)

你可以直接访问一个字段,但是字段之间的耦合关系会逐渐变得笨拙。因此为字段设置set/get方法,并且只以这些方法来访问字段。就称为自封装字段。

public class RefactorMain {
    private int _low, _high;

    boolean includes(int arg) {
        return (arg >= _low && arg <= _high);
    }
}

重构为

public class RefactorMain {
    //重构之后
    private int _low, _high;

    boolean includes(int arg) {
        return (arg >= getLow() && arg <= getHigh());
    }

    int getLow() {
        return _low;
    }

    int getHigh() {
        return _high;
    }
}

在“字段访问方式”这个问题上,存在两种截然不同的观点。总结优缺点如下:间接访问变量的好处是,子类可以通过覆写一个函数而改变获取数据的路径,它还支持更灵活的数据管理方式,例如懒加载。直接访问的方式的好处是代码比较容易阅读,阅读代码时不用查看函数定义才知道用法。通常重构做法:

  • 为待封装字段建立取值/设值函数。
  • 找出该字段的所有引用点,将它们全部改为调用取值/设值函数。
  • 将该字段声明为private.
  • 复查,确保找出所有引用点。
  • 编译、测试。

Replace Data Value with Object(以对象取代数据值)

如有有一个数据项,需要与其他数据和行为放在一起使用才有意义,那么将其变成对象

重构前

重构后
重构后

随着程序的开发,一些原本简单的字符串,可能与其他数据适合组装成为一个对象。通常的重构做法为:

  • 为待替换数值新建一个类,并在这个新类中新建一个const字段(Java final)并保持其类型和源类中你需要替换的数值类型一样,然后在新类中加入一个这个字段的取值函数(get),并加上一个接受此字段为参数的构造函数。
  • 编译。
  • 将源类中待替换数值的类型改为你前面新建的类类型。
  • 修改源类中关于这个字段的取值函数,令他调用新类的取值函数。
  • 如果源类构造函数中用到这个待替换字段(多半是赋值动作)你就应该修改构造函数,让它变为用新类的构造函数来给这个字段赋值。
  • 修改源类中待替换字段的设值函数(set)令他为新类创建一个实例。
  • 编译,测试。

Change Value to Reference(将值对象改为引用对象)

本节理解欠佳,需重复阅读。

你有一个class,衍生出许多相等实体(equal instances),你希望将它们替换为单一对象。将这个value object(实值对象)变成一个reference object(引用对象)。

我们举一个例子。设计一个顾客与订单的系统,在这个系统中,一个订单对应一个顾客,但是多个订单可能是一个顾客产生的。原代码如下:

class Customer {
    public Customer(String name) {
       _name = name;
    }

    public String getName() {
       return _name;
    }
    private final String _name;
}

它被以下的order class使用:

class Order...
    public Order(String customerName) {
       _customer = new Customer(customer);
    }

    public String getCustomerName() {
       return _customer.getName();
    }
    
    public void setCustomer(String customerName) {
       _customer = new Customer(customerName);
    }
    private Customer _customer;

此外,还有一些代码也会使用Customer对象:

private static int numberOfOrdersFor(Collection orders, String customer) {
    int result = 0;
    Iterator iter = orders.iterator();
    while(iter.hasNext()) {
       Order each = (Order)iter.next();
       if(each.getCustomerName().equals(customer)) result ++;
    }
    return result;
}

这种设计中,即使多份订单同属于一个客户,但是每个Order对象还是拥有各自的Customer对象。

我们对这段代码进行重构,为简单起见,我们在Customer中新建一个static字段模拟静态字典。

class Customer...
    static void loadCustomers() {
       new Customer("Lemon Car Hire").store();
        new Customer("Associated Coffee Machines").store();
        new Customer("Bilston Gasworks").store();
    }
    private void store() {
       _instance.put(this.getName(), this);
    }

现在,我要修改factory method,让它返回预先创建好的Customer对象:

public static Customer create(String name) {
    return (Customer)_instance.get(name);
}

由于create()总是返回既有的Customer对象,所以我应该使用Rename Method(273)修改这个factory method的名称,以便强调(说明)这一点。

class Customer...
public static Customer getNamed(String name) {
    return (Customer)_instances.get(name);
}

总结下,这种重构通常的做法为:

  • 使用Replace Constructor with Factory Method方法,编译测试。
  • 决定由什么对象负责提供访问新对象的途径。
  • 决定这些引用应用预先创建好,或是应该动态创建。
  • 修改工厂函数,令它返回引用对象。编译测试。

Change Reference to Value(将引用对象改为值对象)

值对象应该是不可变的。无论何时,调用此对象的查询函数得到的都是一个结果,比如第一个例子中,customer作为值对象,每个order 都有自己的一份customer。

order1.getCustomer("张三").setTelepho("123");
order2.getCustomer("张三").getTelepho();

如果张三开始的号码是135。order2得到的customer 的值还是135,虽然order1已经改变了张三的电话号码。
引用对象应该是可变的,确保某一对象修改,自动会更新其它代表某一相同事物的其它对象的修改。要把reference Object 变成value Object 只需要重写equals()和hashCode()两个方法,并且去掉Method Factory 对构造函数的调用。通常用的做法为:

  • 检查重构目标是否为不可变对象,或是否可修改为不可变对象。
    -- 如果改对象目前还不是不可变的,使用remove setting method,直到其成为不可变的为止。
    -- 如果无法将对象修改为不可变的,就放弃使用本项重构。
  • 建立equal()、hashcode()。这两个函数的修改必须同时进行,负责依赖hash的任何集合对象(hashtable、hashset、hashmap……)都可能产生意外行为。
  • 考虑是否可以删除工厂函数,并将构造函数声明为public.
    注意:要把一个引用对象变成值对象,关键动作:检查是否不可变。如果不是,就不能使用本项重构。可变的值对象会造成烦人的别名问题。

Replace Array with Object(以对象取代数组)

如果你有一个数组,但是数组中并没有排列的关系,那么以对象替换数组,对于数组中的每个元素,以一个字段来表示。

String[] row = new String[3];
row[0] = "Livepool";
row[1] = "15";

重构后

performance row = new Performance();
row.setName("Livepool");
row.setWins("15");

使用这种重构手段,可以用变量名去自注释。这种重构方法,应该重视调用地方不要漏改。通常的做法为:

  • 新建一个类表示数组所拥有的信息,并在其中以一个public字段保存原先的数组。
  • 修改数组的所有用户,让它们改用新类的实例。
  • 编译、测试。
  • 逐一为数组元素条件取值/设值函数。根据元素的用途,为这些访问函数命名。修改客户端代码,让它们通过访问函数取用数组内的元素。每次修改后,编译并测试。
  • 当所有对数组的直接访问转而调用访问函数后,将新类中保存该数组的字段声明为private。
  • 编译。
  • 对应数组内的每个元素,在新类中创建一个类型相当的字段。修改元素的访问函数,令它改用上述的新建字段。
  • 修改每个元素,编译并测试。
  • 数组的所有元素都有了相应的字段后,删除该数组。

Duplicate Observed Data(复制“被监视数据”)

一个设计良好的系统,view层和业务逻辑应该分开。一方面业务层可能支撑不同的view层,另一方面有利于模块解耦。由于前端框架大部分都考虑了MV分离,因此本节不再详细描述。有需要的同学可以购买《重构》这本书了解。
这里描述下duplicate Obeserved Data的通常做法:
做法

  • 修改展现类,使其成为领域类的Observer。
  • 针对GUI类中的领域数据,使用Self Encapsulate Field。
  • 编译,测试。
  • 在时间处理函数中调用设值函数,直接更新GUI组件。
  • 编译,测试。
  • 在领域类中定于数据及其相关访问函数。
  • 修改展现类中的访问函数,将它们的操作对象改为领域对象。
  • 修改Observer的update(),使其从相应的领域对象中将所需数据复制给GUI组件。
  • 编译,测试。

Change Unidirectional Association to Bidirectional(将单向关联改为双向关联)

如果两个类都需要用到对方的特性,但其间只有一条单向链接。这时候就需要加一条"反向指针"。不过笔者以为双向关联会增加系统的复杂度,不符合现代软件“依赖倒置”原则。除非非常有必要,否则不要使用双向关联。
单向关联改双向关联的通常做法为:

  • 在被引用类中增加一个字段,用以保存反向指针。
  • 决定由哪个类——引用段还是被引用端——控制关联关系。
  • 在被控制建立一个辅助函数。如果既有的修改在控制端,让那个它负责更新反向指针。
  • 如果既有的修改函数在被控制,就在控制端建立一个控制函数,并让既有的修改函数调用这个新建的控制函数。
    重构前
class Order {
  getCustomer() {
    return this._customer
  } 
  setCustomer(arg) {
    this._customer = arg
  }
}

重构后

class Customer {
  _orders = new Set()
 
  friendOrders() {
    return this._orders
  }
 
  addOrder(arg) {
    arg.setCustomer(this)
  }
}
 
class Order {
  getCustomer() {
    return this._customer
  }
 
  /**
   * 控制函数
   * @param {} arg 
   */
  setCustomer(arg) {
    if(arg) {
      this._customer.friendOrders().delete(this)
    }
    this._customer = arg
    if(this._customer) {
      this._customer.friendOrders().add(this)
    }
  }
}

以上例子中,Order新增加了一个控制函数进行对Customer的控制。通常,一对多的系统由单一方承担控制者角色。如果多对多,那么无所谓。

Change Bidirectional Association to Unidirectional(将双向关联改为单向关联)

双向关联的弊端在于要维护双向连接、确保对象被正确的创建和删除而增加复杂度,并且大量的双向连接容易造成"僵尸对象"。只有真正需要双向关联的时候才去使用它,否则就去掉其中一条关联。
改为单向关联的通常做法为:

  • 找出保存你想去除的指针的字段,检查它的每一个用户,判断是否可以去除该指针。
  • 如果客户使用了取值函数,先运用Self Encapsulate Field将待删除字段自我封装起来,然后使用Substitute Algorithm对付取值函数,令它不再使用该字段。然后编译、测试。
  • 如果客户并未使用取值函数,那就直接修改待删除字段的所有被引用点,改为以其他途径获得该字段所保存的对象。每次修改后,编译并测试。
  • 如果已经没有任何函数使用待删除字段,移除所有对该字段的更新逻辑,然后删除该字段。
  • 编译,测试。
    使用上一节的例子。
class Customer {
  _orders = new Set()
 
  friendOrders() {
    return this._orders
  }
 
  addOrder(arg) {
    arg.setCustomer(this)
  }
 
  getPriceFor(order) {
    return order.getDiscountedPrice()
  }
}
 
class Order {
  getCustomer() {
    return this._customer
  }
 
  /**
   * 控制函数
   * @param {} arg 
   */
  setCustomer(arg) {
    if(arg) {
      this._customer.friendOrders().delete(this)
    }
    this._customer = arg
    if(this._customer) {
      this._customer.friendOrders().add(this)
    }
  }
 
  getDiscountedPrice() {
    return this.getGrossPrice() * (1- this._customer.getDiscount())
  }
}

重构为

class Customer {
  _orders = new Set()
 
  friendOrders() {
    return this._orders
  }
 
  addOrder(arg) {
    arg.setCustomer(this)
  }
 
  getPriceFor(order) {
    return order.getDiscountedPrice(this)
  }
}
 
class Order {
  getDiscountedPrice(customer) {
    return this.getGrossPrice() * (1- customer.getDiscount())
  }
}

Replace Magic Number with Symbolic Constant(以字面常量取代魔法数)

代码中的魔法数字是最悠久的不良现象之一,它的缺点在于无法自注释,而且多个地点引用同一逻辑数,不符合开闭原则。

mass * 9.8 * height

可以重构为

static final double GRAVITATIONAL = 9.8;
...
mass * GRAVITATIONAL * height;

注意:通常常量要大写。
这种重构的做法为:

  • 声明一个常量,令其值为原本的魔法数值。
  • 找出这个魔法数的所有引用点。
  • 检查是否可以使用这个新声明的常量来替换该魔法数。如果可以,便以此常量替换之。
  • 编译。
  • 所有魔法数都被替换完毕后,编译并测试。此时整个程序应该运转如常,就像没有做任何修改一样。

Encapsulate Field(封装字段)

这种在Java中毕竟常见,将一个public 字段 增加set/get方法,并将自己修改为private,达到“数据隐藏”的效果。例如

private String _name;
public String getName(){
    return _name;
}
public void setName(String name){
    _name = name;
}

这种重构毕竟简单,为了规范化也写上常用的步骤。

  • 为public字段提供取值/设值函数。
  • 找到这个类之外使用该字段的所有地点。如果客户只是读取该字段,就把引用替换为对取值函数的调用;如果客户修改了该字段,就将此引用点替换为对设值函数的调用。
  • 每次修改后,编译并测试。
  • 将字段的所有用户修改完毕后,将字段声明为private。
  • 编译,测试。

Encapsulate Collection(封装集合)

如果类中包含一个集合。那么取值函数不应该返回集合自身,因为这会让用户得以修改集合内容而集合拥有者却一无所知。不应该为整个集合提供一个设值函数,但应该提供用以为集合添加/移除元素的函数。这样,集合拥有者(对象)就可以控制集合元素的添加和移除。
举个例子:

class Course {
  constructor(name, isAdvanced) {
    this._name = name
    this._isAdvanced = isAdvanced
  }
 
  isAdvanced() {
    return this._isAdvanced
  }
}
 
class Person {
  getCourses() {
    return this._courses
  }
 
  setCourses(arg) {
    this._courses = arg
  }
}

重构为:

class Course {
  constructor(name, isAdvanced) {
    this._name = name
    this._isAdvanced = isAdvanced
  }
 
  isAdvanced() {
    return this._isAdvanced
  }
}
 
class Person {
  constructor() {
    this._courses = []
  }
 
  addCourse(arg) {
    return this._courses.push(arg)
  }
 
  removeCourse(arg) {
    this._courses.filter(item => item !== arg)
  }
 
  initializeCourses(arg) {
    this._courses = this._courses.concat(arg)
  }
 
  getCourses() {
    return this._courses.map(item => item)
  }
}

思想就是隐藏和封装

Replace Record with Data Class(以数据类取代记录)

在前端中遇到较少,暂不做笔记

Replace Type Code with Subclass(以子类取代类型码)

在前端中遇到较少,暂不做笔记

Replace Type Code with State/Strategy(以State/Strategy取代类型码)

在前端中遇到较少,暂不做笔记

Replace Subclass with Fields(以字段取代子类)

如果各子类中只有“常量函数”,那么就可以将子类去除,只保留超类。例如以下结构


重构前

可以重构为


重构后

这种重构常用的方法为:
  • 对所有子类使用Replace Constructor with Factory Method。
  • 如果有任何代码直接引用子类,令它改而引用超类。
  • 针对每个常量函数,在超类中声明一个final字段。
  • 为超类声明一个protected构造函数,用以初始化这些新增字段。
  • 新建或修改子类构造函数,使它调用超类的新增构造函数。
  • 编译,测试。
  • 在超类中实现所有常量函数,令它们返回相应的字段,然后将函数从子类中删掉。
  • 每删除一个常量函数,编译并测试。
  • 子类中所有的常量函数都被删除后,使用Inline Method将子类构造函数内联到超类的工厂函数中。
  • 编译,测试。
  • 将子类删掉。
  • 编译,测试。
  • 重复“内联构造函数,删除子类”过程,直到所有子类都被删除。

本章所述部分方法互为镜像,通常需要开发者结合代码总体情况采用不同的重构手段进行重构。在重构过程中,要时刻牢记代码重构原则:【单一职责】【里氏替换】【迪米特法则】【依赖倒置原则】【接口隔离原则】【开闭原则】。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容

  • 1 Self Encapsulate Field(自封装字段) 直接访问一个字段,会导致字段之间的的耦合关系过于笨...
    hklbird阅读 523评论 0 0
  • chapter 1 重构,第一个案例 1.1 什么时候需要重构 需要为程序添加一个特性,但代码结构无法使自己方便的...
    VictorBXv阅读 2,015评论 0 1
  • 《重构》读书笔记 总览 第一部分 第一章从实例程序出发,展示设计的缺陷,对其重构可以了解重构的过程和方法。 第二部...
    白桦叶阅读 2,375评论 2 5
  • 可以先看【推荐】:https://www.jianshu.com/p/d6ff54d72afb原文:http://...
    郭某人1阅读 1,834评论 0 0
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,085评论 1 32