处理继承关系
继承关系十分使用,却也经常被误用,而且常等你用上一段时间,遇见了痛点,才能察觉误用所在。
函数上移
动机
避免重复代码是很重要的。重复的两个函数现在也许能够正常工作,但假以时日却只会成为滋生 bug 的温床。如果某个函数在各个子类中的函数体都相同,这是最显而易见的函数上移场合。当然,情况并不总是如此明显。
函数上移过程中最麻烦的一点就是,被提升的函数可能会引用只出现于子类而不出现与超类的特性。此时,我就得使用字段上移和函数上移先将这些特性提升到超类。
做法
- 检查待提升函数,确定它们完全一致。
- 检查函数体内引用的所有函数调用和字段都能从超类中调用到。
- 如果待提升函数的签名不同,使用改变函数声明将那些签名都修改为你想要在超类中使用的签名。
- 在超类中新建一个函数,将一个待提升函数的代码复制到其中。
- 执行静态检查。
- 移除一个待提升的子类函数。
- 测试。
- 逐一移除待提升的子类函数,直到只剩下超类中的函数为止。
范例
我手上有两个子类,他们之中各有一个函数做了相同的事情。
class Employee extends Party...
get annualCost() {
return this.monthlyCost * 12
}
class Department extends Party...
get totalAnnualCost() {
return this.monthlyCost * 12
}
两个函数各有不同的名字,因第一步是用改变函数声明统一它们的函数名
class Department...
get annualCost() {
return this.monthlyCost * 12
}
// 之后从其中一个子类将 annualCost 函数复制到超类并移除子类上的 annualCost
class Party...
get annualCost() {
return this.monthlyCost * 12
}
但是如果是其他语言,没有自动提供 this.monthlyCost
,此时需要明确传达出‘继承 party
类的子类需要提供一个 monthlyCost
实现这个信息,也有很大价值。其中一种好的传达方式是添加一个陷阱函数
class Party...
get monthlyCost() {
throw new SubclassResponsibilityError()
}
字段上移
动机
如果各子类是分别开发的,或者是在重构过程中组合起来的,你常会发现它们拥有重复特性,特别是字段更容易重复。
字段上移基本上是引用构造函数上移的必然结果。
做法
- 针对待提升的字段,检查它们的所有使用点,确认它们以相同方式使用。
- 如果这些字段的名称不同,先使用变量改名为它们取个相同名字。
- 在超类中新建一个字段。
- 移除子类中的字段。
- 测试。
构造函数本体上移
动机
构造函数是很奇妙的东西。它们不是普通函数,使用它们比使用普通函数受到更多的限制。
如果重构过程过于复杂,我会考虑转而使用以工厂函数取代构造函数。
做法
- 如果超类还不存在构造函数,首先为其定义一个。确保让子类调用超类的构造函数。
- 使用移动语句将子类中的构造函数中的公共语句移动到超类的构造函数调用语句之后。
- 逐一移除子类间的公共代码,将其提升至超类构造函数中。对于公共代码中引用到的变量,将其作为参数传递给超类中的构造函数。
- 测试。
- 如果存在无法简单提升至超类的公共代码,先应用提炼函数,再利用函数上移提升之。
范例
以下列’雇员‘的例子开始
class Party {}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super();
this._id = id;
this._name = name;
this._monthlyCost = monthlyCost;
}
// rest of class...
}
class Department extends Party {
constructor(name, staff) {
super();
this._name = name;
this._staff = staff;
}
// rest of class...
}
我们看到对 name
字段的赋值为公共代码。直接将 name
字段提升至超类然后子类使用 super(name)
调用即可。
但有时也需要将共用行为的初始化提升至超类,这是问题就来了。
class Employee...
constructor(name) {
...
}
get isPrivileged() {
...
}
assignCar() {
...
}
class Manager extends Employee...
constructor(name, grade) {
super(name)
this._grade = grade
// 每个子类都有的行为
if (this.isPrivileged) this.assignCar()
}
get isPrivileged() {
return this._grade > 4
}
在这种情况下,可以使用提炼函数后使用函数上移
class Employee...
finishConstruction() {
if (this.isPrivileged) this.assignCar()
}
class Manager...
constructor(name, grade) {
super(name)
this._grade = grade
// 每个子类都有的行为
this.finishConstruction()
}
函数下移
动机
如果超类中的某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类上去。
做法
- 将超类中的 3 函数本体复制到每一个需要此函数的子类中。
- 删除超类中的函数。
- 测试。
- 将函数从所有不需要它的那些子类中删除。
- 测试。
字段下移
动机
如果某个字段只被一个或小部分子类用到,就将其搬移到需要该字段的子类中。
做法
- 在所有需要该字段的子类中声明该字段。
- 将该字段从超类中去除。
- 测试。
- 将该字段从所有不需要它的那些子类中删掉。
- 测试。
以子类取代类型码
动机
软件系统经常要表现“相似但又不同的东西”,比如员工可以按职位分类,订单可以按优先级分类。表现分类关系的第一种工具是类型码字段——更具具体的编程语言,可能实现为枚举、符号、字符串或数字。类型码的取值经常来自给系统提供数据的外部服务。
大多数时候,有这样的类型码就足够了。但有些时候,我可以再往前一步,引入子类。继承有两个诱人之处。首先,可以用多态来处理条件逻辑。另外,有些字段或函数只对特定的类型码取值才有意义。
在使用以子类取代类型码时,我需要考虑一个问题:因嘎嘎 i 直接处理携带类型码的这个类,还是应该处理类型码本身呢?举个例子,我是应该让“工程师”成为“员工”的子类,还是应该在“员工”类包含“员工类别”属性,从后者继承出“工程师”和“经理”等子类型呢?直接的子类继承(前一种方案)比较简单,但职位类别就布恩那个用在其他场合了。
做法
- 自封装类型码字段。
- 任选一个类型码取值,为其创建一个子类。覆写类型码类的字面量值。
- 创建一个选择器逻辑,把类型码参数映射到新的子类。
- 测试。
- 针对每个类型码取值,重复上述“创建子类、添加选择器逻辑”的过程。每次修改后执行测试。
- 去除类型码字段。
- 测试。
- 使用函数下移和以多态取代条件表达式处理原本访问了类型码的函数。全部处理完后,就可以移除类型码的访问函数。
范例
下面有个员工管理系统的例子
class Employee...
constructor(name, type) {
this.validateType(type)
this._name = name
this._type = type
}
validateType(arg) {
if (!['engineer', 'manager', 'salesman'].includes(arg))
throw new Error(`Employee cannot be of type ${arg}`)
}
toString() {
return `${this._name} (${this._type})`
}
第一步是用封装变量将类型码自封装起来
class Employee...
get type() {
return this._type
}
toString() {
return `${this._name} (${this._type})`
}
我打算采用直接继承的方案,也就是继承 Employee
类。
class Engineer extends Employee {
get type() {
return 'engineer'
}
}
class Manager extends Employee {
get type() {
return 'manager'
}
}
class Salesman extends Employee {
get type() {
return 'salesman'
}
}
// 我可以新建一个工厂函数以便安放选择器逻辑
function createEmployee(name, type) {
switch (type) {
case 'engineer':
return new Engineer(name, type)
case 'manager':
return new Manager(name, type)
case 'salesman':
return new Salesman(name, type)
}
return new Employee(name, type)
}
// 修改完成后,我就可以去掉类型码字段机器在超类中的取值函数
class Employee...
constructor(name, type) {
this.validateType(type)
this._name = name
// this._type = type
}
// get type() {return this._type}
toString() {
return `${this._name} (${this._type})`
}
之后去除 validateType
函数,因为它和分发逻辑做的是一回事。再把构造函数中的类型参数去除。
class Employee...
constructor(name) {
// this.validateType(type)
this._name = name
}
function createEmployee(name) {
switch (type) {
case 'engineer':
return new Engineer(name)
case 'manager':
return new Manager(name)
case 'salesman':
return new Salesman(name)
default:
throw new Error(`Employee cannot be of type ${type}`)
}
return new Employee(name, type)
}
之后再把子类中获取类型码的访问函数—— get type
函数全部去除。
范例:使用间接继承
还是前面两个例子,我们回到最起初的状态,不过这次我已经有了“全职员工”和“兼职员工”两个子类,所以不饿能再根据员工类别代码创建子类了。另外我可能需要允许员工类别动态调整,这也会导致布恩那个直接使用直接继承的方案。
class Employee...
constructor(name, type) {
this.validateType(type)
this._name = name
this._type = type
}
validateType(arg) {
if (!['engineer', 'manager', 'salesman'].includes(arg))
throw new Error(`Employee cannot be of type ${arg}`)
}
get type() {
return this._type
}
set type(arg) {
this._type = arg
}
get capitalizedType() {
return this._type.charAt(0).toUpperCase() + this._type.substr(1).toLowerCase()
}
toString() {
return `${this._name} (${this.capitalizedType})`
}
这次的 toString
函数要更复杂一些,以便稍后展示用
首先,我用以对象取代基本类型包装类型码
class EmployeeType {
constructor(aString) {
this._value = aString
}
toString() {
return this._value
}
}
class Employee...
get typeString() {
return this._type.toString()
}
之后使用以子类取代类型码的老套路,把员工类别代码变成子类
class Employee...
set type(arg) {
this._type = Employee.createEmployeeType(arg)
}
static createEmployeeType(aString) {
case 'engineer': return new Emgineer()
case 'manager': return new Manager()
case 'salesman': return new Salesman()
default: throw new Error(`Employee cannot be of type ${aString}`)
}
class EmployeeType {}
class Engineer extends EmployeeType {
toString() {
return 'engineer'
}
}
class Manager extends EmployeeType {
toString() {
return 'manager'
}
}
class Salesman extends EmployeeType {
toString() {
return 'Salesman'
}
}
如果重构到此为止的话,空的 EmployeeType
类可以去掉。但我更愿意留着它,用来明确表达各个子类之间的关系,同时也方便把其他行为搬移进去。
移除子类
动机
子类很有用,它们为数据结构的多样和行为的多态提供支持,他们是针对差异编程的好工具。但随着软件的演化,子类所支持的变化可能会被搬移到别处,甚至完全去除,这是子类就失去了价值。
做法
- 以工厂函数取代构造函数,把子类的构造函数包装到超类的工厂函数中。
- 如果有任何代码检查子类的类型。先用提炼函数把类型检查逻辑包装起来,然后用搬移函数将其搬到超类。每次修改后执行测试。
- 新建一个字段,用于表示子类的类型。
- 将原本针对子类的类型做判断的函数改为使用新建的类型字段。
- 删除子类。
- 测试。
范例
一开始,代码中遗留了两个子类
class Person...
constructor(name) {
this._name = name
}
get name() {
return this._name
}
get genderCode() {
return 'X'
}
// snip
class Male extends Person {
get genderCode() {
return 'M'
}
}
class Female extends Person {
get genderCode() {
return 'F'
}
}
// 客户端...
const numberOfMales = people.filter(p => p instanceof Male).length
如果子类就干这点事,那真没必要存在。当想要改变某个东西的表现形式时,我会将当下的表现形式封装起来,从而尽量减少对客户端代码的影响。对于“创建子类对象”而言,封装的方式就是以工厂函数取代构造函数。在这里,实现工厂有两种方式。
最直接的方式是为每个构造函数分别创建一个工厂函数
function createPerson(name) {
return new Person(name);
}
function createMale(name) {
return new Male(name);
}
function createFemale(name) {
return new Female(name);
}
虽然这是最直接的选择,但这样的对象经常是从输入源加载出来,直接根据性别代码创建对象。
function loadFromInput(data) {
const result = [];
data.forEach((aRecord) => {
let p;
switch (aRecord.gender) {
case "M":
p = new Male(aRecord.name);
break;
case "F":
p = new Female(aRecord.name);
break;
default:
p = new Person(aRecord.name);
break;
}
result.push(p);
});
return result;
}
所以这里更好的办法是用提炼函数把选择哪个类来实例化的逻辑提炼成工程函数。
function createPerson(aRecord) {
switch (aRecord.gender) {
case "M":
return new Male(aRecord.name);
break;
case "F":
return new Female(aRecord.name);
break;
default:
return new Person(aRecord.name);
break;
}
}
function loadFromInput(data) {
const result = [];
data.map((aRecord) => createPerson(aRecord));
return result;
}
代码中还有一处用到 instanceof
运算符——这从来不是什么好味道。我用提炼函数把这个类型检查逻辑提炼出来。
// 客户端...
const numberOfMales = people.filter((p) => isMale(p)).length;
function isMale(aPerson) {
return aPerson instanceof Male;
}
此时可以用搬移函数将其搬移到 Person
类
class Person...
get isMale() {
return this instanceof Male
}
// 客户端...
const numberOfMales = people.filter(p => p.isMale).length
重构到这一步,所有子类相关的内容都已经安全地包装再超类和工厂函数中。
消灾,添加一个字段来表示子类之间的差异。既然有来自别处的一个类型代码,直接用它也无妨。
class Person...
constructor(name, genderCode) {
this._name = name
this._genderCode = genderCode || 'X'
}
get genderCode() {
return this._genderCode
}
在初始化时先将其设置为默认值。另外,虽然大所属人可以归类为男性和女性,但确实有些人不是这两种性别中的一种,忽视这些人的存在,是一个常见的建模错误。
function createPerson(aRecord) {
switch (aRecord.gender) {
case 'M':
return new Person(aRecord.name, 'M');
break
case 'F':
return new Person(aRecord.name, 'F');
break
default:
return new Person(aRecord.name, 'M');
break
}
}
class Person...
constructor(name, genderCode) {
this._name = name
// this._genderCode = genderCode || 'X'
}
提炼超类
动机
如果看见两个类再做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。很多技术作家在谈到面向对象时,认为继承必须预先仔细计划,应该更具“真实世界”的分类结构建立对象模型。但很多时候,合理的继承方式是在程序演化过程中才浮现出来的。
另一种选择就是提炼类。这两种方案之间的选择,其实就是继承和委托之间的选择,总之目的就是把重复代码收拢一处。
做法
- 为原本的类新建一个空白的超类。
- 测试。
- 逐一将子类的共同元素上移到超类。
- 检查留在子类中的函数,看它们是否还有共同成分。如果有,可以将其提炼出来,再搬到超类。
- 检查所有使用原本的类的客户端代码,考虑将其调整为使用超类的接口。
范例
下面两个类,仔细考虑之下,是有一些共同之处的
class Employee {
constructor(name, id, monthlyCost) {
this._id = id;
this._name = name;
this._monthlyCost = monthlyCost;
}
get monthlyCost() {
return this._monthlyCost;
}
get name() {
return this._name;
}
get id() {
return this._id;
}
get annualCost() {
return this.monthlyCost * 12;
}
}
class Department {
constructor(name, staff) {
this._name = name;
this._staff = staff;
}
get staff() {
return this._staff;
}
get name() {
return this._name;
}
get totalMonthlyCost() {
return this.staff
.map((e) => e.monthlyCost)
.reduce((sum, cost) => sum + cost);
}
get headCount() {
return this.staff.length;
}
get totalAnnualCost() {
return this.totalMontylyCost * 12;
}
}
首先创建一个超类,让原来的两个类来继承它
class Party {}
class Employee extends Party {
...
}
class Department extends Party {
...
}
先搬移字段,再搬移函数
class Party...
constructor(name) {
this._name = name
}
get name() {
return this._name
}
// 两个子类的 monthlyCost 和 totalMonthlyCost 意图一致,可以合并为一个函数
get annualCost() {
return this.monthlyCost * 12
}
class Employee..
constructor(name, id, monthlyCost) {
super(name)
// ...
}
class Department..
constructor(name, id, monthlyCost) {
super(name)
// ...
}
折叠继承体系
动机
随着继承体系的演化,我有时会发现一个类与其超类已经没多大差别,此时我就会把超类和子类合并起来。
做法
- 选择想要移除的类:是超类还是子类?
- 把所有元素移到同一个类中。
- 调整即将被移除的那个类的所有引用点,令它们改而引用合并后留下的类。
- 移除我们的目标。
- 测试。
以委托取代子类
动机
如果一个对象的行为有明显的类别之分,继承是很自然的表达方式。但继承也有其短板,最明显的是,继承这张牌只能打一次。导致行为不同的原因可能有很多种,但继承只能用于处理一个方向上的变化。比如说,我可能希望“人”的行为根据“年龄段”的不同,并且根据“收入水平”不同。使用继承的话,子类可以是“年轻人”和“老人”,也可以是“富人”和“穷人”,但不能采用两种继承方式。
更大的问题在于,继承给类之间引入了非常紧密的关系,在超类上做任何修改,都很有可能破坏子类。
这两个问题用委托都能解决,对于不同的变化原因,我可以委托给不同的类。与继承关系相比,使用委托关系时接口更清晰、耦合更少,因此,继承关系遇到问题时运用以委托取代子类是常见的情况。
有一条流行的原则:“对象组合优于类继承”。但这并不表示“继承有害”,继承是一种很有价值的机制,大部分时候能达到效果,不会带来问题。所以我会从继承剋之,如果开始继承出现问题,再转而使用委托。
做法
- 如果构造函数有多个调用者,首先以工厂函数取代构造函数把构造函数包装起来。
- 创建一个空的委托类,这个类的构造函数应该接受所有子类特有的数据项,并且经常以参数形式接受一个指回超类的引用。
- 在超类中添加一个字段,用于安放委托对象。
- 修改子类的创建逻辑,使其初始化上述委托字段,放入一个委托对象的实例。
- 选择一个子类中的函数,将移入委托类。
- 使用搬移函数手法搬移上述函数,不要删除类中的委托代码。
- 如果被搬移的原函数还在子类之外被调用了,就把留在源类中的委托代码从子类移到超类,并在委托代码之前加上卫语句,检查委托对象存在。如果子类之外已经没有其他调用者,就是用移除死代码去掉已经没人使用的委托代码。
- 测试。
- 重复上述过程,直到子类中所有函数都搬移到委托类。
- 找到所有调用子类构造函数的地方,逐一将其改为使用超类的构造函数。
- 测试。
- 运用移除死代码去掉子类。
范例
下面这类用于处理演出 show
和预定 booking
class Booking...
constructor(show, date) {
this._show = show
this._date = date
}
get hasTalkback() {
return this._show.hasOwnProperty('talkback') && !this.isPeakDay
}
get basePrice() {
let result = this._show.price
if (this.isPeakDay) result += Math.round(result * 0.15)
return result
}
// 有一个子类,专门用于与订购高级 premium 票,这个子类要考虑各种附加服务 extra
class PremiumBooking extends Booking...
constructor(show, date, extras) {
super(show, date)
this._extras = extras
}
// PremiumBooking 类在超类的基础上做了好些改变。在这种“针对差异编程”的风格中,子类常会覆写超类的方法
// 先来看一处简单的覆写
get hasTalkback() {
return this._show.hasOwnProperty('talkback')
}
// 定价逻辑也是相似到逻辑,不过 PremiumBooking 调用了超类中的方法。
get basePrice() {
return Math.round(super.basePrice + this._extras.premiumFee)
}
// 最后一个例子是 Premium 提供了一个超类中没有的行为
get hasDinner() {
return this._extras.hasOwnProperty('dinner') && !this.isPeakDay
}
继承在这个例子中工作良好,即使不了解子类,我同样也可以理解超类的逻辑。子类只描述自己与超类的差异。
但它也并非如此完美。超类的一些结构旨在特定的子类存在时才有意义——有些函数的组织方式完全就是为了覆写特定类型的行为。所以尽管大部分时候我可以修改超类而不必理解子类,但如果不可以不关注子类的存在,在修改超类时偶尔有可能会破坏子类。
那么既然情况还不算坏,为什么我想以委托取代子类来做出改变呢?因为继承只能使用一次,如果我有别的原因想使用继承,并且这个新的原因别“高级预定”更有必要,就需要更换一种方式类处理高级预定。另外,我可能需要动态地把普通预定升级成高级预定,例如提供 aBooking.bePremium()
这样一个函数。有时我可以新建一个对象(就好像通过 HTTP 请求 从服务器端加载全新的数据结构),从而避免“对象本身升级”的问题。但有时我需要修改数据本身的结构,而不重建整个数据结构。如果一个 Booking
对象被很多地方引用,也很难将其整个替换掉。此时,就有必要允许在“普通预定”和“高级预定”之间来回转换。
当这样的需求累积到一定程度时,我就该使用以委托取代子类了。现在客户端直接调用两个类的构造函数类创建不同的预定。
// 进行普通预定的客户端
aBooking = new Booking(show, date);
// 进行高级预定的客户端
aBooking = new PremiumBooking(show, date, extras);
去除子类会改变对象创建的方式,所以我先要以工厂函数取代构造函数把构造函数封装起来。
// 顶层作用域...
function createBooking(show, date) {
return new Booking(show, date);
}
function createPremiumBooking(show, date, extras) {
return new PremiumBooking(show, date, extras);
}
aBooking = createBooking(show, date);
aBooking = createPremium(show, date, extras);
新建一个委托类。这个类的构造函数参数由两部分:首先是指向 Booking
对象的反向引用,随后是只有之类才需要的哪些数据。我需要传入反向引用,是因为子类的几个函数需要访问超类中的数据。有继承关系的时候,访问这些数据很容易;而在委托关系中,就得通过反向引用来访问。
class PremiumBookingDelegate...
constructor(hostBooking, extras) {
this._host = hostBooking
this._extras = extras
}
现在可以把新建的委托对象与 Booking
对象关联起来。在“创建高级预定”的工厂函数中修改即可。
function createPremiumBooking(show, date, extras) {
const result = new PremiumBooking(show, date, extras)
result._bePremium(extras)
return result
}
class Booking...
// _bePremium 函数以下划线开头,表示这个函数不应该被当作 Booking 类的公共接口。当然,如果最终我们希望允许普通预定转换成高级预定,这个函数也可以成为公共接口
_bePremium(extras) {
this._premiumDelegate = new PremiumBookingDelegate(this, extras)
}
结构设置好了,现在该动手搬移行为了,我首先考虑 hasTalkback
函数简单的覆写逻辑。现在的代码如下
class Booking...
get hasTalkback() {
return this._show.hasOwnProperty('talkback') && !this.isPeakDay
}
class PremiumBooking...
get hasTalkBack() {
return this._show.hasOwnProperty('talkback')
}
接接下来把子类中的函数搬移到委托类中。原本访问超类中的数据的代码,现在要改为调用 _host
对象。
class PremiumBookingDelegate...
get hasTalkback() {
return this._host._show.hasOwnProperty('talkback')
}
// 调用点有代理对象就用代理对象
class PremiumBooking...
// get hasTalkback() {
// return this._premiumDelegate.hasTalkback
// }
class Booking...
get hasTalkback() {
return (this._premiumDelegate) ?
this._premiumDelegate.hasTalkback :
this._show.hasOwnProperty('talkback') && !this.isPeakDay
}
下一个要处理的是 basePrice
函数
class Booking...
get basePrice() {
let result = this._show.price
if (this.isPeakDay) result += Math.round(result * 0.15)
return result
}
class PremiumBooking...
get basePrice() {
return Math.round(super.basePrice + this._extras.premiumFee)
}
情况大致相同,但是子类调用了超类中的同名函数。把子类的代码移到委托类时,需要继续调用超类的逻辑——但我不能直接调用 this._host.basePrice
,这会导致无穷递归。
有两个办法处理这个问题。一种办法是用提炼函数把“基本价格”的计算逻辑提炼出来,从而把分发逻辑和价格计算逻辑拆开。(剩下的操作就和前面一样)
class Booking...
get basePrice() {
return (this._premiumDelegate) ?
this._premiumDelegate.basePrice :
this._privateBasePrice
}
get _privateBasePrice() {
let result = this._show.price
if (this.isPeakDay) result += Math.round(result * 0.15)
return result
}
class PremiumBookingDelegate...
get basePrice() {
return Math.round(this._host._privateBasePrice + this._extras.premuimFee)
}
另一个方法是,重新定义委托对象中的函数,使其成为基础函数的扩展
class Booking...
get basePrice() {
let result = this._show.price
if (this.isPeakDay) result += Math.round(result * 0.15)
return (this._premiumDelegate) ?
this._premiumDelegate.extendBasePrice(result) :
result
}
class PremiumBOokingDelegate...
extendBasePrice(base) {
return Math.round(base + this._extras.premiumFee)
}
范例:取代继承体系
前面的例子展示了如何已委托取代子类去除单个子类。还可以用这个重构手法去除整个继承体系。
function createBird(data) {
switch (data.type) {
case "EuropeanSwallow":
return new EuropeanSwallow(data);
case "AfricanSwallow":
return new AfricanSwallow(data);
case "NorwegianBlueParrot":
return new NorwegianBlueParrot(data);
default:
return new Bird(data);
}
}
class Bird {
constructor(data) {
this._name = data.name;
this._plumage = data.plumage;
}
get name() {
return this._name;
}
get plumage() {
return this._plumage || "average";
}
get airSpeedVelocity() {
return null;
}
}
class EuropeanSwallow extends Bird {
get airSpeedVelocity() {
return 35;
}
}
class AfricanSwallow extends Bird {
constructor(data) {
super(data);
this._numberOfCoconuts = data.numberOfCoconuts;
}
get airSpeedVelocity() {
return 40 - 2 * this._numberOfCoconuts;
}
}
class NorwgianBLueParrot extends Bird {
constructor(data) {
super(data);
this._voltage = data.voltage;
this._isNailed = data.isNailed;
}
get plumage() {
if (this._voltage > 100) return "scorched";
else return this._plumage || "beautiful";
}
get airSpeedVelocity() {
return this._isNailed ? 0 : 10 + this._woltage / 10;
}
}
上面这个关于鸟 bird
的系统很快有一个大的变化:这些鸟是“野生的 wild
”,有些鸟是“家养的 captive
”,两者之间的行为会有很大差异。这种差异可以建模为 Bird
类的两个子类: WildBird
和 CaptiveBird
。但继承只能使用一次,所以如果想用子类来表现”野生“和”家养“的差异,就得先去掉关于”不同品种”的继承关系。
在涉及多个子类时,我回一次处理一个子类,先从简单的开始——这里最简单的是 EuropeanSwallow
(欧洲燕)。我先给它建一个空的委托类。
class EuropeanSwallowDelegate {}
委托类中展示还没有传入任何数据或反向引用。在这个例子中,我会在需要时引入这些参数。
现在需要决定如何初始化委托字段。由于构造函数接受的唯一参数 data
包含了所有信息,我决定在构造函数中初始化委托字段。考虑到有多个委托对象要添加,我会建一个函数,其中更具类型码 data.type
来选择适当的委托对象。
class Bird...
constructor(data) {
this._name = data.name;
this._plumage = data.plumage
this._speciesDelegate = this.selectSpeciesDelegate(data)
}
selectSpeciesDelegate(data) {
switch (data.type) {
case 'EuropeanSwallow':
return new EuropeanSwallowDelegate()
default:
return null
}
}
结构设置完毕,可以用搬移函数把 EuropeanSwallow
的 airSpeedVelocity
函数搬到委托函数中。
class EuropeanSwallowDelegate...
get airSpeedVelocity() {
return 35
}
// 修改超类的 airSpeedVelocity 函数,如果发现有委托对象存在,旧调用之
class Bird...
get airSpeedVelocity() {
return this._speciesDelegate ? this._speciesDelegate.airSpeedVelocity : null
}
// class EuropeanSwallow...
// get airSpeedVelocity() {return this._speciesDelegate.airSpeedVelocity}
function createBird(data) {
switch (data.type) {
//case 'EuropeanSwallow':
// return new EuropeanSwallow(data)
case 'AfricanSwallow':
return new AfricanSwallow(data)
case 'NorwegianBlueParrot':
return new NorwegianBlueParrot(data)
default:
return new Bird(data)
}
}
接下来处理 AfricanSwallow
子类。为它创建一个委托类,这次委托类的构造函数需要传入 data
参数。
class AfricanSwallowDelegate...
constructor(data) {
this._numberOfCoconuts = data.numberOfCoconuts
}
get airSpeedVelocity() {
return 40 - 2 * this._numberOfCoconuts
}
class Bird...
selectSpeciesDelegate(data) {
switch (data.type) {
case 'EuropeanSwallow':
return new EuropeanSwallowDelegate()
case 'AfricanSwallow':
return new AfricanSwallowDelegate(data)
default:
return null
}
}
// class AfricanSwallow extends Bird {
// ...
// }
function createBird(data) {
switch (data.type) {
//case 'EuropeanSwallow':
// return new EuropeanSwallow(data)
//case 'AfricanSwallow':
// return new AfricanSwallow(data)
case 'NorwegianBlueParrot':
return new NorwegianBlueParrot(data)
default:
return new Bird(data)
}
}
接下来是 NorwegianBlueParrot
子类。步骤和前面一样
class NorwegianBlueParrotDelegate...
constructor(data) {
this._voltage = data.voltage
this._isNailed = data.isNailed
}
get airSpeedVelocity() {
return (this._isNailed) ? 0 : 10 + this._voltage / 10
}
class Bird...
selectSpeciesDelegate(data) {
switch (data.type) {
case 'EuropeanSwallow':
return new EuropeanSwallowDelegate()
case 'AfricanSwallow':
return new AfricanSwallowDelegate(data)
case 'NorwegianBlueParrot':
return new NorwegianBlueParrot(data)
default:
return null
}
}
但 NorwegianBlueParrot
还覆写了 plumage
属性,首先我还是用搬移函数把 plumage
函数搬移到委托类中,之后修改构造函数,放入 Bird
对象的反向引用。
class NorwegianBlueParrot..
get plumage() {
return this._speciesDelegate.plumage
}
class NorwegianBlueParrotDelegate...
constructor(data, bird) {
this._bird = bird
this._voltage = data.voltage
this._isNailed = data.isNailed
}
get plumage() {
if (this._voltage > 100) return 'scorched'
else return this._bird._plumage || 'beautiful'
}
class Bird...
selectSpeciesDelegate(data) {
switch (data.type) {
// ...
case 'NorwegianBlueParrot':
return new NorwegianBlueParrot(data, this)
default:
return null
}
}
麻烦之处在于如何去掉子类中的 plumage
函数。此时可以采用继承——用提炼超类从各个代理类中提炼出一个共同继承的超类。
class SpeciesDelegate {
constructor(data, bird) {
this._bird = bird
}
get plumage() {
return this._bird._plumage || 'average'
}
get airSpeedVelocity() {
return null
}
}
class EuropeanSwallowDelegate extends SpeciesDelegate {
get airSpeedVelocity() {
return 35
}
}
class AfricanSwallowDelegate extends SpeciesDelegate {
constructor(data) {
this._numberOfCoconuts = data.numberOfCoconuts
}
get airSpeedVelocity() {
return 40 - 2 * this._numberOfCoconuts
}
}
class NorwegianBlueParrotDelegate extends SpeciesDelegate {
constructor(data, bird) {
this._bird = bird
this._voltage = data.voltage
this._isNailed = data.isNailed
}
get airSpeedVelocity() {
return (this.isNailed) ? 0 : 10 + this._voltage / 10
}
get plumage() {
if (this._voltage > 100) return 'scorched'
else return this._bird._plumage || 'beautiful'
}
}
function createBird(data) {
return new Bird(data)
}
class Bird...
constructor(data) {
this._name = data.name
this._plumage = data.plumage
this._speciesDelegate = this.selectSpeciesDelegate(data)
}
get name() {
return this._name
}
get plumage() {
return this._speciesDelegate.plumage
}
get airSpeedVelocity() {
return this._speciesDelegate.airSpeedVelocity
}
selectSpeciesDelegate(data) {
switch (data.type) {
case 'EuropeanSwallow':
return new EuropeanSwallowDelegate()
case 'AfricanSwallow':
return new AfricanSwallowDelegate(data)
case 'NorwegianBlueParrot':
return new NorwegianBlueParrot(data)
default:
return new SpeciesDelegate(data, this)
}
}
// rest of Bird code...
在这个例子中,我用一系列委托类取代了原来的多个子类,与原来非常相似的继承结构被转移到 SpeciesDelegate
下面。除了给 Bird
类重新被继承的机会,同时新的继承体系范围更收拢了,只涉及各个品种不同的数据和行为,各个瓶中相同的代码则全部留在了 Bird
类中,他未来的子类也将得益于这些公共的行为。
以委托取代超类
动机
在面向对象程序中,通过继承来复用现有功能,是一种既强大又便捷的手段。我只要继承一个已有的类,覆写一些功能,再添加一些功能,就能达成目的。但继承也有可能造成困扰和混乱。
在对象技术发展早期,有一个经典的误用继承的例子:让栈 stack
继承列表 list
。这个想法的出发点是想复用类的数据存储和操作能力。但这个继承关系有问题:列表类的所有操作都会出现在栈类的接口上,然而其中大部分操作对一个栈来说并不适用,这就是一个用得上以委托取代超类手法的例子——如果超类的一些函数对子类并不适用,就说明我不应该通过继承来获得超类的功能。
比如一个车模类,其中有些属性我可能想用来表示真正的汽车,然而汽车终究不是模型,所以此时如果把继承关系将部分职能委托给另一个对象,这些混乱和错误本事可以轻松避免的。使用委托关系更能清晰地表达"这是另一个东西,我只是需要用到其中携带的一些功能",
做法
- 在子类中新建一个字段,使其使用超类的一个对象,并将这个委托引用初始化为超类的新实例。
- 针对超类的每个函数,在子类中创建一个转发函数,将调用请求转发给委托引用。每转发一块完整逻辑,都要执行测试。
- 当所有超类函数都被转发函数覆写后,就可以去掉继承关系。
范例
我最近给一个古城里存放上古卷轴 scroll
的图书馆做了咨询。他们给卷轴的信息编制了一份目录 catalog
,每份卷轴都有一个 ID 号,并记录了卷轴的标题 title
和一些列标签 tag
。
class CatalogItem...
constructor(id, title, tags) {
this._id = id
this._title = title
this._tags = tags
}
get id() {
return this._id
}
get title() {
return this._title
}
hasTag(arg) {
return this._tags.includes(arg)
}
// 这些卷轴需要日常清扫,因此代表卷轴的 scroll 类继承了代表目录项的 CatalogItem 类,并扩展出与“需要清扫”相关的数据
class Scroll extends CatalogItem...
constructor(id, title, tags, dateLastCleaned) {
super(id, title, tags)
this._lastCleaned = dateLastCleaned
}
needsCleaning(targetDate) {
const threshold = this.hasTag('revered') ? 700 : 1500
return this.daysSinceLastCleaning(targetDate) > threshold
}
daysSinceLastCleaning(targetDate) {
return this._lastCleaned.until(targetDate, ChronoUnit.DAYS)
}
这是一个常见的建模错误,真是存在的卷走和只存在于纸面上的目录项,是完全不同的两种东西。我希望改变这两个类的关系。
首先在 Scroll
类中创建一个属性,令其指向一个新建的 CatalogItem
实例。
class Scroll extends CatalogItem...
constructor(id, title, tags, dateLastCleaned) {
super(id, title, tag)
this._catalogItem = new CatalogItem(id, title, tags)
this._lastCleaned = dateLastCleaned
}
// 对于子类中用到所有属于超类的函数,需要逐一为它们创建转发函数
class Scroll...
get id() {
return this._catalogItem.id
}
get title() {
return this._catalogItem.title
}
hasTag(aString) {
return thsi._catalogItem.hasTag(aString)
}
之后去除 Scroll
与 CatalogItem
之间的继承关系
class Scroll {
constructor(id, title, tags, dateLastCleaned) {
// super(id, title, tag)
this._catalogItem = new CatalogItem(id, title, tags)
this._lastCleaned = dateLastCleaned
}
前面的重构把 CatalogItem
变成了 scroll
的一个组件:每个 Scroll
对象包含一个独一无二的 CatalogItem
对象。在使用本重构的很多情况下,这样处理就足够了,但这个例子中,更好的建模方式应该是:关于灰鳞病的一个目录项,对应于图书馆中的 6 份卷轴,应为这 6 分卷轴都是同一个标题。这实际上是要运用将值对象改为引用对象。
但在原本的继承结构中, Scroll
类使用了 CatalogItem
类的 id
字段来保存自己的 ID,但此时我应该使用 Scroll
类自己的 id
字段。
class Scroll {
constructor(id, title, tags, dateLastCleaned) {
this._id = id
this._catalogItem = new CatalogItem(null, title, tags)
this._lastCleaned = dateLastCleaned
}
get id() {
return this._id
}
用 null
作为 ID 值创建目录项,这种操作一般而言应该触发警告了,所以等我重构完成,多个卷轴会指向一个共享的目录项,而后者也会有合适的 ID,此时把整个目录对象及目录项的 ID 都作为参数传给 Scroll
的构造函数。
class Scroll...
// Scroll 的构造函数已经不再需要传入 title 和 tags 这两个参数了
constructor(id, dateLastCleaned, catalogID, catalog) {
this._id = id
// 用传入的 catalogID 来查找对应的 CatalogItem 对象,并引用这个对象(而不是引用这个对象)
this._catalogItem = catalog.get(catalogID)
this._lastCleaned = dateLastCleaned
}
end