企业🤖AI Agent构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
![](https://box.kancloud.cn/84c89efa581501cbeacda2ba3ef29194_505x283.jpeg) ``` class Person { get courses() {return this._courses;} set courses(aList) {this._courses = aList;} ``` ![](https://box.kancloud.cn/a3bed334e2e1f6d1a46c5039deb25af9_91x152.jpeg) ``` 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()`就会修改原数组,而在其他语言中默认都是为更改集合的操作返回一份副本。任何负责管理集合的类都应该总是返回数据副本,但我还养成了一个习惯,只要我做的事看起来可能改变集合,我也会返回一个副本。