重构,改善代码的既有设计——重新组织数据

重新组织数据

数据结构在程序中扮演着重要的角色,我有一组重构手法专门用于数据结构的组织。将一个值用于多个不同的用途,这就是催生混乱和 bug 的温床。

拆分变量

动机

变量有各种不同的用途,其中某些用途会很自然地导致临时变量被多次赋值。“循环变量”和“结果收集变量“就是两个典型例子:循环变量即为 for 循环中的 i;结果收集变量则将”通过整个函数的运算“而构成的某个值收集起来。

做法

  1. 在待分解变量的声明及第一次被赋值处,修改其名称。
  2. 如果可能,将新的变量声明为不可修改。
  3. 以该变量的第二次赋值动作为界,修改此前对该变量的所有引用,让它们引用新变量。
  4. 测试
  5. 重复上述过程,每次都在声明处对变量改名,并修改下次赋值之前的引用,直至到达最后一处赋值。

范例

下面我要计算一个苏格兰布丁运动的距离

function distanceTravelled(scenario, time) {
  let result;
  let acc = scenario.primaryForce / scenario.mass;
  let primaryTime = Math.min(time, scenario.delay);
  result = 0.5 * acc * primaryTimr * primaryTime;
  let secondaryTime = time - scenario.delay;
  if (secondaryTime > 0) {
    let primaryVelocity = acc * scenario.delay;
    acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
    result +=
      primaryVelocity * secondaryTimr +
      0.5 * acc * secondaryTime * secondaryTime;
  }
  return result;
}

上面代码真是丑陋。 acc 变量有两个责任:第一是保存第一个力造成的初始加速度,第二是保存两个力共同造成的加速度。将 acd 以两个变量替换。

function distanceTravelled(scenario, time) {
  let result;
  let primaryAcceleration = scenario.primaryForce / scenario.mass;
  let primaryTime = Math.min(time, scenario.delay);
  result = 0.5 * acc * primaryTimr * primaryTime;
  let secondaryTime = time - scenario.delay;
  if (secondaryTime > 0) {
    let primaryVelocity = acc * scenario.delay;
    const secondaryAcceleration =
      (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
    result +=
      primaryVelocity * secondaryTimr +
      0.5 * secondaryAcceleration * secondaryTime * secondaryTime;
  }
  return result;
}

字段改名

动机

命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。

做法

  1. 如果记录对作用域较小,可以直接修改所有该字段的代码,然后测试。
  2. 如果记录还未封装,请先封装。
  3. 在对象内部对私有字段改名,对应调整内部访问该字段的函数。
  4. 测试。
  5. 如果构造函数的参数使用了旧的字段名,运用改变函数声明将其改名。
  6. 运用函数改名给访问函数改名

范例:给字段改名

先从一个常量开始

const organization = {
  name: "Acme Gooseberries",
  country: "GB",
};

// 先进行封装
class Organization {
  constructor(data) {
    this._name = data.name;
    this._country = data.country;
  }
  get name() {
    return this._name;
  }
  set name(aString) {
    this._name = aString;
  }
  get country() {
    return this._country;
  }
  set country(aCountryCode) {
    this._country = aCountryCode;
  }
}
const organization = new Organization({
  name: "Acme Gooseberries",
  country: "GB",
});

现在,记录结构已经被封装成类。在对字段改名时,有 4 个地方需要留意:取值函数、设值函数、构造函数以及内部数据结构。我现在可以分别修改这 4 处,小步修改意味着每一步出错的可能性大大减少。

class Organization {
  constructor(data) {
    // 先修改构造函数中的值。现在既可以使用 name 也可以使用 title
    this._title = data.title !== undefined ? data.title : data.name;
    this._country = data.country;
  }
  // 设值取值都返回修改后的值
  get name() {
    return this._title;
  }
  set name(aString) {
    this._title = aString;
  }
  get country() {
    return this._country;
  }
  set country(aCountryCode) {
    this._country = aCountryCode;
  }
}

接下来就可以在构造函数中使用 title 字段

// 修改为新字段
const organization = new Organization({
  title: "Acme Gooseberries",
  country: "GB",
});
// 之后查看所有调用构造函数的地方,改为新名
class Organization {
  constructor(data) {
    this._title = data.title;
    this._country = data.country;
  }
  // 接下来改名访问函数和设值函数
  get title() {
    return this._title;
  }
  set title(aString) {
    this._title = aString;
  }
  get country() {
    return this._country;
  }
  set country(aCountryCode) {
    this._country = aCountryCode;
  }
}

以查询取代派生变量

动机

可变数据是软件中最大的错误源头之一。对数据的修改常常导致代码段各个部分以丑陋的方式互相耦合:在一处修改数据,却在另一处造成难以发现的破坏,完全去掉可变数据并不现实,但我还是强烈建议:精良吧可变数据的作用域限制在最小范围。

做法

  1. 识别出所有对变量做更新的地方。如有必要可使用拆分变量
  2. 新建一个函数,用于计算该变量的值。
  3. 用引入断言断言该变量和计算函数始终给出同样的值。
  4. 测试。
  5. 修改读取该变量的代码,令其调用新建的函数。
  6. 测试。
  7. 移除死代码去掉变量的声明和赋值。

范例

下面这个例子虽小,却完美展示了代码的丑陋。

class ProductionOPlan...
    get production() {
        return this._production
    }
applyAdjustment(anAdjustment) {
    this._adjustments.push(anAdjustment)
    this._production += anAdjustment.amount
}

我可以对 production 进行即时计算

class ProductionOPlan...
    get production() {
        return this.calculatedProduction
    }
get calculatedProduction() {
    return this._adjustments
        .reduce((sum, a) => sum + a.amount, 0)
}

之后用内联函数将计算逻辑内联到 production 函数内

class ProductionOPlan...
    get production() {
        return this._adjustments
            .reduce((sum, a) => sum + a.amount, 0)
    }
applyAdjustment(anAdjustment) {
    this._adjustments.push(anAdjustment)
}

将引用对象改为值对象

动机

在把一个对象嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不变,更行内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。

一般来说,不可变的数据结构处理起来更容易,我可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。值对象在分布式系统和并发系统中尤为有用。

如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。

做法

  1. 检查重构目标是否为不可变对象,或是否可修改为不可变对象。
  2. 移除设值函数注意去掉所有设值函数。
  3. 提供一个基于值的相等性判断函数,在其中使用值对象字段。

范例

设想一个代表“人”的 Person 类,其中包含一个代表“电话号码”的 Telephone Number 对象

class Person...
    constructor() {
        this._telephoneNumber = new TelephoneNumber()
    }
// 可以看到类中仍然有一些函数在修改新对象的属性
get officeAreaCode() {
    return this._telephoneNumber.areaCode
}
set officeAreaCode(arg) {
    this._telephoneNumber.areaCode = arg
}
get officeNumber() {
    return this._telephoneNumber.number
}
set officeNumber(arg) {
    this._telephoneNumber.number = arg
}

class TelephoneNumber...
    get areaCode() {
        return this._areaCode
    }
set areaCode(arg) {
    this._areaCode = arg
}
get number() {
    return this._number
}
set number(arg) {
    this._number = arg
}

先把 TelephoneNumber 类变为不可变的。

class TelephoneNumber...
    constructor(areaCode, number) {
        this._areaCode = areaCode
        this._number = number
    }

class Person...
    get officeAreaCode() {
        return this._telephoneNumber.areaCode
    }
set officeAreaCode(arg) {
    this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber)
}
get officeNumber() {
    return this._telephoneNumber.number
}
set officeNumber(arg) {
    this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg)
}

现在, TelephoneNumber 类已经是不可变的类,可以将其变成真正的值对象了。可以创建自己的 equal 函数用于值的相等性判断。

class TelephoneNumber...
    equals(other) {
        if (!(other instanceof TelephoneNumber)) return false
        return this.areaCode === other.areaCode && this.number === other.number
    }

// 进行测试
it('telephone equals', function() {
    assert(new TelephoneNumber('312', '55-0142').equals(new TelephoneNumber('312', '55-0142')))
})

将值对象改为引用对象

动机

一个数据结构可能包含多个记录,而这些记录都关联到同一个逻辑数据结果。把值对象改为引用对象会带来以恶搞结果:对于一个客观实体,只有一个代表它的对象,只为某个实体创建一次对象,以后始终从仓库中获取该对象。

做法

  1. 为相关对象创建一个仓库
  2. 确保构造函数有办法找到关联对象的正确实例
  3. 修改宿主对象的构造函数,令其从长苦衷获取关联对象,每次修改后执行测试。

范例

我将从一个代表“订单”的 Order 类开始,其实例对象从一个 JSON 文件创建,用来创建订单数据中有一个顾客 customer ID,我们用它来进一步创建 Customer 对象。

class Order...
    constructor(data) {
        this._number = data.number
        this._customer = new Customer(data.customer)
    }
get customer() {
    return this._customer
}

class Customer...
    constructor(id) {
        this._id = id
    }
get id() {
    return this._id
}

这种方式创建的 Customer 是值对象。如果有 5 个订单都属于 ID 为 123 的顾客,就会有 5 个独自的 Customer 对象。对其中一个的修改,不会反映在其他几个对象上。如果我想增强 Customer 对象,比如接受了更多关于客户的信息,我就必须用同样的数据更新所有 5 个对象。

如果我每次都使用同一个 Customer 对象,那么就需要有一个地方存储这个对象。最简单的情况下,我会使用一个仓库对象。

let _repositoryData;

export function initialize() {
  _repositoryData = {};
  _repositoryData.customers = new Map();
}

export function registerCustomer(id) {
  if (!_repositoryData.customers.has(id))
    _repositoryData.customers.set(id, new Customer(id));
  return findCustomer(id);
}

export function findCustomer(id) {
  return _repositoryData.customers.get(id);
}

仓库对象根据 ID 注册顾客,并且对于一个 ID 只会创建一个 Customer 对象。有了仓库对象,我就可以修改 Order 对象的构造函数来使用它。

class Order...
    constructor(data) {
        this._number = data.number
        this._customer = _repositoryData(data.customer)
    }
get customer() {
    return this._customer
}

上面的代码还有一个问题:构造函数与一个全局的仓库对象耦合。如果想解决这个问题,可以将仓库对象作为参数传递给构造函数。