🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
曾用名:自封装字段(Self-Encapsulate Field) 曾用名:封装字段(Encapsulate Field) ![](https://box.kancloud.cn/c16ab4247b4653b517a4e05eb249c8d3_352x225.jpeg) `let defaultOwner = {firstName: "Martin", lastName: "Fowler"};`![](https://box.kancloud.cn/a3bed334e2e1f6d1a46c5039deb25af9_91x152.jpeg) ``` let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"}; export function defaultOwner() {return defaultOwnerData;} export function setDefaultOwner(arg) {defaultOwnerData = arg;} ``` ### 动机 重构的作用就是调整程序中的元素。函数相对容易调整一些,因为函数只有一种用法,就是调用。在改名或搬移函数的过程中,总是可以比较容易地保留旧函数作为转发函数(即旧代码调用旧函数,旧函数再调用新函数)。这样的转发函数通常不会存在太久,但的确能够简化重构过程。 数据就要麻烦得多,因为没办法设计这样的转发机制。如果我把数据搬走,就必须同时修改所有引用该数据的代码,否则程序就不能运行。如果数据的可访问范围很小,比如一个小函数内部的临时变量,那还不成问题。但如果可访问范围变大,重构的难度就会随之增大,这也是说全局数据是大麻烦的原因。 所以,如果想要搬移一处被广泛使用的数据,最好的办法往往是先以函数形式封装所有对该数据的访问。这样,我就能把“重新组织数据”的困难任务转化为“重新组织函数”这个相对简单的任务。 封装数据的价值还不止于此。封装能提供一个清晰的观测点,可以由此监控数据的变化和使用情况;我还可以轻松地添加数据被修改时的验证或后续逻辑。我的习惯是:对于所有可变的数据,只要它的作用域超出单个函数,我就会将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。处理遗留代码时,一旦需要修改或增加使用可变数据的代码,我就会借机把这份数据封装起来,从而避免继续加重耦合一份已经广泛使用的数据。 面向对象方法如此强调对象的数据应该保持私有(`private`),背后也是同样的原理。每当看见一个公开(`public`)的字段时,我就会考虑使用封装变量(在这种情况下,这个重构手法常被称为封装字段)来缩小其可见范围。一些更激进的观点认为,即便在类内部,也应该通过访问函数来使用字段——这种做法也称为“自封装”。大体而言,我认为自封装有点儿过度了——如果一个类大到需要将字段自封装起来的程度,那么首先应该考虑把这个类拆小。不过,在分拆类之前,自封装字段倒是一个有用的步骤。 封装数据很重要,不过,不可变数据更重要。如果数据不能修改,就根本不需要数据更新前的验证或者其他逻辑钩子。我可以放心地复制数据,而不用搬移原来的数据——这样就不用修改使用旧数据的代码,也不用担心有些代码获得过时失效的数据。不可变性是强大的代码防腐剂。 ### 做法 - 创建封装函数,在其中访问和更新变量值。 - 执行静态检查。 - 逐一修改使用该变量的代码,将其改为调用合适的封装函数。每次替换之后,执行测试。 - 限制变量的可见性。 > 有时没办法阻止直接访问变量。若果真如此,可以试试将变量改名,再执行测试,找出仍在直接使用该变量的代码。 - 测试。 - 如果变量的值是一个记录,考虑使用封装记录(162)。 ### 范例 下面这个全局变量中保存了一些有用的数据: `let defaultOwner = {firstName: "Martin", lastName: "Fowler"};`使用它的代码平淡无奇: `spaceship.owner = defaultOwner;`更新这段数据的代码是这样: `defaultOwner = {firstName: "Rebecca", lastName: "Parsons"};`首先我要定义读取和写入这段数据的函数,给它做个基础的封装。 ``` function getDefaultOwner() {return defaultOwner;} function setDefaultOwner(arg) {defaultOwner = arg;} ``` 然后就开始处理使用`defaultOwner`的代码。每看见一处引用该数据的代码,就将其改为调用取值函数。 `spaceship.owner = getDefaultOwner();`每看见一处给变量赋值的代码,就将其改为调用设值函数。 `setDefaultOwner({firstName: "Rebecca", lastName: "Parsons"});`每次替换之后,执行测试。 处理完所有使用该变量的代码之后,我就可以限制它的可见性。这一步的用意有两个,一来是检查是否遗漏了变量的引用,二来可以保证以后的代码也不会直接访问该变量。在JavaScript中,我可以把变量和访问函数搬移到单独一个文件中,并且只导出访问函数,这样就限制了变量的可见性。 ##### defaultOwner.js... ``` let defaultOwner = {firstName: "Martin", lastName: "Fowler"}; export function getDefaultOwner() {return defaultOwner;} export function setDefaultOwner(arg) {defaultOwner = arg;} ``` 如果条件不允许限制对变量的访问,可以将变量改名,然后再次执行测试,检查是否仍有代码在直接使用该变量。这阻止不了未来的代码直接访问变量,不过可以给变量起个有意义又难看的名字(例如`__privateOnly_defaultOwner`),提醒后来的客户端。 我不喜欢给取值函数加上`get`前缀,所以我对这个函数改名。 ##### defaultOwner.js... ``` let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"}; export function getdefaultOwner() {return defaultOwnerData;} export function setDefaultOwner(arg) {defaultOwnerData = arg;} ``` JavaScript有一种惯例:给取值函数和设值函数起同样的名字,根据有没有传入参数来区分。我把这种做法称为“重载取值/设值函数”(Overloaded Getter Setter)\[mf-orgs\],并且我强烈反对这种做法。所以,虽然我不喜欢`get`前缀,但我会保留`set`前缀。 ### 封装值 前面介绍的基本重构手法对数据结构的引用做了封装,使我能控制对该数据结构的访问和重新赋值,但并不能控制对结构内部数据项的修改: ``` const owner1 = defaultOwner(); assert.equal("Fowler", owner1.lastName, "when set"); const owner2 = defaultOwner(); owner2.lastName = "Parsons"; assert.equal("Parsons", owner1.lastName, "after change owner2"); // is this ok? ``` 前面的基本重构手法只封装了对最外层数据的引用。很多时候这已经足够了。但也有很多时候,我需要把封装做得更深入,不仅控制对变量引用的修改,还要控制对变量内容的修改。 这有两个办法可以做到。最简单的办法是禁止对数据结构内部的数值做任何修改。我最喜欢的一种做法是修改取值函数,使其返回该数据的一份副本。 ##### defaultOwner.js... ``` let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"}; export function defaultOwner() {return Object.assign({}, defaultOwnerData);} export function setDefaultOwner(arg) {defaultOwnerData = arg;} ``` 对于列表数据,我尤其常用这一招。如果我在取值函数中返回数据的一份副本,客户端可以随便修改它,但不会影响到共享的这份数据。但在使用副本的做法时,我必须格外小心:有些代码可能希望能修改共享的数据。若果真如此,我就只能依赖测试来发现问题了。另一种做法是阻止对数据的修改,比如通过封装记录(162)就能很好地实现这一效果。 ``` let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"}; export function defaultOwner() {return new Person(defaultOwnerData);} export function setDefaultOwner(arg) {defaultOwnerData = arg;} class Person {  constructor(data) {   this._lastName = data.lastName;   this._firstName = data.firstName  }  get lastName() {return this._lastName;}  get firstName() {return this._firstName;}  // and so on for other properties ``` 现在,如果客户端调用`defaultOwner`函数获得“默认拥有人”数据、再尝试对其属性(即`lastName`和`firstName`)重新赋值,赋值不会产生任何效果。对于侦测或阻止修改数据结构内部的数据项,各种编程语言有不同的方式,所以我会根据当下使用的语言来选择具体的办法。 “侦测和阻止修改数据结构内部的数据项”通常只是个临时处置。随后我可以去除这些修改逻辑,或者提供适当的修改函数。这些都处理完之后,我就可以修改取值函数,使其返回一份数据副本。 到目前为止,我都在讨论“在取数据时返回一份副本”,其实设值函数也可以返回一份副本。这取决于数据从哪儿来,以及我是否需要保留对源数据的连接,以便知悉源数据的变化。如果不需要这样一条连接,那么设值函数返回一份副本就有好处:可以防止因为源数据发生变化而造成的意外事故。很多时候可能没必要复制一份数据,不过多一次复制对性能的影响通常也都可以忽略不计。但是,如果不做复制,风险则是未来可能会陷入漫长而困难的调试排错过程。 请记住,前面提到的数据复制、类封装等措施,都只在数据记录结构中深入了一层。如果想走得更深入,就需要更多层级的复制或是封装。 如你所见,数据封装很有价值,但往往并不简单。到底应该封装什么,以及如何封装,取决于数据被使用的方式,以及我想要修改数据的方式。不过,一言以蔽之,数据被使用得越广,就越是值得花精力给它一个体面的封装。