class Person {
  get courses() {return this._courses;}
  set courses(aList) {this._courses = aList;}
 
 
class Person {
  get courses() {return this._courses.slice();}
  addCourse(aCourse) { ... }
  removeCourse(aCourse) { ... }

动机

我喜欢封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便。我们通常鼓励封装——使用面向对象技术的开发者对封装尤为重视——但封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。

为避免此种情况,我会在类上提供一些修改集合的方法——通常是“添加”和“移除”方法。这样就可使对集合的修改必须经过类,当程序演化变大时,我依然能轻易找出修改点。

只要团队拥有良好的习惯,就不会在模块以外修改集合,仅仅提供这些修改方法似乎也就足够。然而,依赖于别人的好习惯是不明智的,一个细小的疏忽就可能带来难以调试的 bug。更好的做法是,不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。

一种避免直接修改集合的方法是,永远不直接返回集合的值。这种方法提倡,不要直接使用集合的字段,而是通过定义类上的方法来代替,比如将 aCustomer.orders.size 替换为 aCustomer.numberOfOrders。我不同意这种做法。现代编程语言都提供了丰富的集合类和标准接口,能够组合成很多有价值的用法,比如集合管道(Collection Pipeline)[mf-cp]等。使用特殊的类方法来处理这些场景,会增加许多额外代码,使集合操作容易组合的特性大打折扣。

还有一种方法是,以某种形式限制集合的访问权,只允许对集合进行读操作。比如,在 Java 中可以很容易地返回集合的一个只读代理,这种代理允许用户读取集合,但会阻止所有更改操作——Java 的代理会抛出一个异常。有一些库在构造集合时也用了类似的方法,将构造出的集合建立在迭代器或枚举对象的基础上,因为迭代器也不能修改它迭代的集合。

也许最常见的做法是,为集合提供一个取值函数,但令其返回一个集合的副本。这样即使有人修改了副本,被封装的集合也不会受到影响。这可能带来一些困惑,特别是对那些已经习惯于通过修改返回值来修改原集合的开发者——但更多的情况下,开发者已经习惯于取值函数返回副本的做法。如果集合很大,这个做法可能带来性能问题,好在多数列表都没有那么大,此时前述的性能优化基本守则依然适用(见 2.8 节)。

使用数据代理和数据复制的另一个区别是,对源数据的修改会反映到代理上,但不会反映到副本上。大多数时候这个区别影响不大,因为通过此种方式访问的列表通常生命周期都不长。

采用哪种方法并无定式,最重要的是在同个代码库中做法要保持一致。我建议只用一种方案,这样每个人都能很快习惯它,并在每次调用集合的访问函数时期望相同的行为。

做法

如果集合的引用尚未被封装起来,先用封装变量(132)封装它。

在类上添加用于“添加集合元素”和“移除集合元素”的函数。

如果存在对该集合的设值函数,尽可能先用移除设值函数(331)移除它。如果不能移除该设值函数,至少让它返回集合的一份副本。

执行静态检查。

查找集合的引用点。如果有调用者直接修改集合,令该处调用使用新的添加/移除元素的函数。每次修改后执行测试。

修改集合的取值函数,使其返回一份只读的数据,可以使用只读代理或数据副本。

测试。

范例

假设有个人(Person)要去上课。我们用一个简单的 Course 来表示“课程”。

class Person…

  constructor (name) {
  this._name = name;
  this._courses = [];
}
get name() {return this._name;}
get courses() {return this._courses;}
set courses(aList) {this._courses = aList;}

class Course…

  constructor(name, isAdvanced) {
  this._name = name;
  this._isAdvanced = isAdvanced;
}
get name() {return this._name;}
get isAdvanced() {return this._isAdvanced;}

客户端会使用课程集合来获取课程的相关信息。

  numAdvancedCourses = aPerson.courses
  .f ilter(c => c.isAdvanced)
  .length
;

有些开发者可能觉得这个类已经得到了恰当的封装,毕竟,所有的字段都被访问函数保护到了。但我要指出,对课程列表的封装还不完整。诚然,对列表整体的任何更新操作,都能通过设值函数得到控制。

客户端代码…

  const basicCourseNames = readBasicCourseNames(filename);
aPerson.courses = basicCourseNames.map(name => new Course(name, false));

但客户端也可能发现,直接更新课程列表显然更容易。

客户端代码…

for (const name of readBasicCourseNames(filename)) {
  aPerson.courses.push(new Course(name, false));
}

这就破坏了封装性,因为以此种方式更新列表 Person 类根本无从得知。这里仅仅封装了字段引用,而未真正封装字段的内容。

现在我来对类实施真正恰当的封装,首先要为类添加两个方法,为客户端提供“添加课程”和“移除课程”的接口。

class Person…

  addCourse(aCourse) {
  this._courses.push(aCourse);
}
removeCourse(aCourse, fnIfAbsent = () => {throw new RangeError();}) {
  const index = this._courses.indexOf(aCourse);
  if (index === -1) fnIfAbsent();
  else this._courses.splice(index, 1);
}

对于移除操作,我得考虑一下,如果客户端要求移除一个不存在的集合元素怎么办。我可以耸耸肩装作没看见,也可以抛出错误。这里我默认让它抛出错误,但留给客户端一个自己处理的机会。

然后我就可以让直接修改集合值的地方改用新的方法了。

客户端代码…

for (const name of readBasicCourseNames(filename)) {
  aPerson.addCourse(new Course(name, false));
}

有了单独的添加和移除方法,通常 setCourse 设值函数就没必要存在了。若果真如此,我就会使用移除设值函数(331)移除它。如果出于其他原因,必须提供一个设值方法作为 API,我至少要确保用一份副本给字段赋值,不去修改通过参数传入的集合。

class Person…

  set courses(aList) {this._courses = aList.slice();}

这套设施让客户端能够使用正确的修改方法,同时我还希望能确保所有修改都通过这些方法进行。为达此目的,我会让取值函数返回一份副本。

class Person…

get courses() {return this._courses.slice();}

总的来讲,我觉得对集合保持适度的审慎是有益的,我宁愿多复制一份数据,也不愿去调试因意外修改集合招致的错误。修改操作并不总是显而易见的,比如,在 JavaScript 中原生的数组排序函数 sort()就会修改原数组,而在其他语言中默认都是为更改集合的操作返回一份副本。任何负责管理集合的类都应该总是返回数据副本,但我还养成了一个习惯,只要我做的事看起来可能改变集合,我也会返回一个副本。