├── .gitignore
├── LICENSE
├── README.md
├── docs
├── .vuepress
│ └── config.js
├── README.md
├── ch1.md
├── ch10.md
├── ch11.md
├── ch12.md
├── ch2.md
├── ch3.md
├── ch4.md
├── ch5.md
├── ch6.md
├── ch7.md
├── ch8.md
├── ch9.md
├── cover.jpg
└── figures
│ ├── image00280.jpeg
│ ├── image00281.jpeg
│ ├── image00282.jpeg
│ ├── image00283.jpeg
│ ├── image00284.jpeg
│ ├── image00286.jpeg
│ ├── image00288.jpeg
│ ├── image00290.jpeg
│ ├── image00292.jpeg
│ ├── image00293.jpeg
│ ├── image00294.jpeg
│ ├── image00296.jpeg
│ ├── image00298.jpeg
│ ├── image00300.jpeg
│ ├── image00302.jpeg
│ ├── image00304.jpeg
│ ├── image00306.jpeg
│ ├── image00308.jpeg
│ ├── image00310.jpeg
│ ├── image00312.jpeg
│ ├── image00314.jpeg
│ ├── image00316.jpeg
│ ├── image00318.jpeg
│ ├── image00319.jpeg
│ ├── image00321.jpeg
│ ├── image00323.jpeg
│ ├── image00325.jpeg
│ ├── image00327.jpeg
│ ├── image00329.jpeg
│ ├── image00331.jpeg
│ ├── image00333.jpeg
│ ├── image00335.jpeg
│ ├── image00337.jpeg
│ ├── image00339.jpeg
│ ├── image00341.jpeg
│ ├── image00343.jpeg
│ ├── image00345.jpeg
│ ├── image00347.jpeg
│ ├── image00349.jpeg
│ ├── image00351.jpeg
│ ├── image00353.jpeg
│ ├── image00355.jpeg
│ ├── image00357.jpeg
│ ├── image00359.jpeg
│ ├── image00361.jpeg
│ ├── image00363.jpeg
│ ├── image00365.jpeg
│ ├── image00367.jpeg
│ ├── image00369.jpeg
│ ├── image00371.jpeg
│ ├── image00373.jpeg
│ ├── image00375.jpeg
│ ├── image00377.jpeg
│ ├── image00379.jpeg
│ ├── image00381.jpeg
│ ├── image00383.jpeg
│ ├── image00385.jpeg
│ ├── image00387.jpeg
│ ├── image00389.jpeg
│ ├── image00391.jpeg
│ ├── image00392.jpeg
│ ├── image00393.jpeg
│ ├── image00395.jpeg
│ ├── image00397.jpeg
│ ├── image00399.jpeg
│ └── image00401.jpeg
├── gitee-deploy.sh
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | docs/.vuepress/dist/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018-present, Yuxi (Evan) You
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Refactoring2-zh
2 |
3 | 《重构 改善既有代码的设计第二版》中文翻译
4 |
5 | 在线阅读:[http://gdut_yy.gitee.io/doc-refact2/](http://gdut_yy.gitee.io/doc-refact2/)
6 |
7 |
8 |
9 | ## 前言
10 |
11 | ## Index
12 |
13 | - [第 1 章 重构,第一个示例](./docs/ch1.md)
14 | - [第 2 章 重构的原则](./docs/ch2.md)
15 | - [第 3 章 代码的坏味道](./docs/ch3.md)
16 | - [第 4 章 构筑测试体系](./docs/ch4.md)
17 | - [第 5 章 介绍重构名录](./docs/ch5.md)
18 | - [第 6 章 第一组重构](./docs/ch6.md)
19 | - [第 7 章 封装](./docs/ch7.md)
20 | - [第 8 章 搬移特性](./docs/ch8.md)
21 | - [第 9 章 重新组织数据](./docs/ch9.md)
22 | - [第 10 章 简化条件逻辑](./docs/ch10.md)
23 | - [第 11 章 重构 API](./docs/ch11.md)
24 | - [第 12 章 处理继承关系](./docs/ch12.md)
25 |
26 | ## 本地开发 & 阅读
27 |
28 | 本项目基于 vuepress 进行开发,以提供比 github mardown 更佳的阅读体验
29 |
30 | 依赖于 `node.js`、`yarn`、`vuepress` 等环境
31 |
32 | ```sh
33 | # vuepress
34 | yarn global add vuepress
35 |
36 | # 本地开发
37 | git clone https://github.com/gdut-yy/Refactoring2-zh.git
38 | cd Refactoring2-zh/
39 | yarn docs:dev
40 |
41 | # 本地阅读
42 | http://localhost:8080/doc-refact2/
43 | ```
44 |
45 | ## 更多书籍
46 |
47 | [https://github.com/xx-zh/xx-zh-roadmap](https://github.com/xx-zh/xx-zh-roadmap)
48 |
49 | ## License
50 |
51 | [MIT](./LICENSE)
52 |
--------------------------------------------------------------------------------
/docs/.vuepress/config.js:
--------------------------------------------------------------------------------
1 | // .vuepress/config.js
2 | module.exports = {
3 | title: "《重构 改善既有代码的设计第二版》中文翻译",
4 | base: "/doc-refact2/",
5 | themeConfig: {
6 | repo: "gdut-yy/Refactoring2-zh",
7 | repoLabel: "Github",
8 | docsRepo: "gdut-yy/Refactoring2-zh",
9 | docsBranch: "master/docs",
10 | editLinks: true,
11 | editLinkText: "帮助我们改善此页面!",
12 | lastUpdated: "Last Updated",
13 | sidebarDepth: 2,
14 | nav: [],
15 | sidebar: {
16 | "/": [
17 | "",
18 | "ch1.md",
19 | "ch2.md",
20 | "ch3.md",
21 | "ch4.md",
22 | "ch5.md",
23 | "ch6.md",
24 | "ch7.md",
25 | "ch8.md",
26 | "ch9.md",
27 | "ch10.md",
28 | "ch11.md",
29 | "ch12.md",
30 | ],
31 | },
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # 目录
2 |
3 |
4 |
5 | - [第 1 章 重构,第一个示例](ch1.md)
6 | - [第 2 章 重构的原则](ch2.md)
7 | - [第 3 章 代码的坏味道](ch3.md)
8 | - [第 4 章 构筑测试体系](ch4.md)
9 | - [第 5 章 介绍重构名录](ch5.md)
10 | - [第 6 章 第一组重构](ch6.md)
11 | - [第 7 章 封装](ch7.md)
12 | - [第 8 章 搬移特性](ch8.md)
13 | - [第 9 章 重新组织数据](ch9.md)
14 | - [第 10 章 简化条件逻辑](ch10.md)
15 | - [第 11 章 重构 API](ch11.md)
16 | - [第 12 章 处理继承关系](ch12.md)
17 |
--------------------------------------------------------------------------------
/docs/ch11.md:
--------------------------------------------------------------------------------
1 | # 第 11 章 重构 API
2 |
3 | 模块和函数是软件的骨肉,而 API 则是将骨肉连接起来的关节。易于理解和使用的 API 非常重要,但同时也很难获得。随着对软件理解的加深,我会学到如何改进 API,这时我便需要对 API 进行重构。
4 |
5 | 好的 API 会把更新数据的函数与只是读取数据的函数清晰分开。如果我看到这两类操作被混在一起,就会用将查询函数和修改函数分离(306)将它们分开。如果两个函数的功能非常相似、只有一些数值不同,我可以用函数参数化(310)将其统一。但有些参数其实只是一个标记,根据这个标记的不同,函数会有截然不同的行为,此时最好用移除标记参数(314)将不同的行为彻底分开。
6 |
7 | 在函数间传递时,数据结构常会毫无必要地被拆开,我更愿意用保持对象完整(319)将其聚拢。函数需要的一份信息,究竟何时应该作为参数传入、何时应该调用一个函数获得,这是一个需要反复推敲的决定,推敲的过程中常常要用到以查询取代参数(324)和以参数取代查询(327)。
8 |
9 | 类是一种常见的模块形式。我希望尽可能保持对象不可变,所以只要有可能,我就会使用移除设值函数(331)。当调用者要求一个新对象时,我经常需要比构造函数更多的灵活性,可以借助以工厂函数取代构造函数(334)获得这种灵活性。
10 |
11 | 有时你会遇到一个特别复杂的函数,围绕着它传入传出一大堆数据。最后两个重构手法专门用于破解这个难题。我可以用以命令取代函数(337)将这个函数变成对象,这样对函数体使用提炼函数(106)时会更容易。如果稍后我对该函数做了简化,不再需要将其作为命令对象了,可以用以函数取代命令(344)再把它变回函数。
12 |
13 | ## 11.1 将查询函数和修改函数分离(Separate Query from Modifier)
14 |
15 | ```js
16 | function getTotalOutstandingAndSendBill() {
17 | const result = customer.invoices.reduce((total, each) => each.amount + total, 0);
18 | sendBill();
19 | return result;
20 | }
21 |
22 |
23 | function totalOutstanding() {
24 | return customer.invoices.reduce((total, each) => each.amount + total, 0);
25 | }
26 | function sendBill() {
27 | emailGateway.send(formatBill(customer));
28 | }
29 | ```
30 |
31 | ### 动机
32 |
33 | 如果某个函数只是提供一个值,没有任何看得到的副作用,那么这是一个很有价值的东西。我可以任意调用这个函数,也可以把调用动作搬到调用函数的其他地方。这种函数的测试也更容易。简而言之,需要操心的事情少多了。
34 |
35 | 明确表现出“有副作用”与“无副作用”两种函数之间的差异,是个很好的想法。下面是一条好规则:任何有返回值的函数,都不应该有看得到的副作用——命令与查询分离(Command-Query Separation)[mf-cqs]。有些程序员甚至将此作为一条必须遵守的规则。就像对待任何东西一样,我并不绝对遵守它,不过我总是尽量遵守,而它也回报我很好的效果。
36 |
37 | 如果遇到一个“既有返回值又有副作用”的函数,我就会试着将查询动作从修改动作中分离出来。
38 |
39 | 你也许已经注意到了:我使用“看得到的副作用”这种说法。有一种常见的优化办法是:将查询所得结果缓存于某个字段中,这样一来后续的重复查询就可以大大加快速度。虽然这种做法改变了对象中缓存的状态,但这一修改是察觉不到的,因为不论如何查询,总是获得相同结果。
40 |
41 | ### 做法
42 |
43 | 复制整个函数,将其作为一个查询来命名。
44 |
45 | 如果想不出好名字,可以看看函数返回的是什么。查询的结果会被填入一个变量,这个变量的名字应该能对函数如何命名有所启发。
46 |
47 | 从新建的查询函数中去掉所有造成副作用的语句。
48 |
49 | 执行静态检查。
50 |
51 | 查找所有调用原函数的地方。如果调用处用到了该函数的返回值,就将其改为调用新建的查询函数,并在下面马上再调用一次原函数。每次修改之后都要测试。
52 |
53 | 从原函数中去掉返回值。
54 |
55 | 测试。
56 |
57 | 完成重构之后,查询函数与原函数之间常会有重复代码,可以做必要的清理。
58 |
59 | ### 范例
60 |
61 | 有这样一个函数:它会遍历一份恶棍(miscreant)名单,检查一群人(people)里是否混进了恶棍。如果发现了恶棍,该函数会返回恶棍的名字,并拉响警报。如果人群中有多名恶棍,该函数也只汇报找出的第一名恶棍(我猜这就已经够了)。
62 |
63 | ```js
64 | function alertForMiscreant(people) {
65 | for (const p of people) {
66 | if (p === "Don") {
67 | setOffAlarms();
68 | return "Don";
69 | }
70 | if (p === "John") {
71 | setOffAlarms();
72 | return "John";
73 | }
74 | }
75 | return "";
76 | }
77 | ```
78 |
79 | 首先我复制整个函数,用它的查询部分功能为其命名。
80 |
81 | ```js
82 | function findMiscreant(people) {
83 | for (const p of people) {
84 | if (p === "Don") {
85 | setOffAlarms();
86 | return "Don";
87 | }
88 | if (p === "John") {
89 | setOffAlarms();
90 | return "John";
91 | }
92 | }
93 | return "";
94 | }
95 | ```
96 |
97 | 然后在新建的查询函数中去掉副作用。
98 |
99 | ```js
100 | function findMiscreant(people) {
101 | for (const p of people) {
102 | if (p === "Don") {
103 | setOffAlarms();
104 | return "Don";
105 | }
106 | if (p === "John") {
107 | setOffAlarms();
108 | return "John";
109 | }
110 | }
111 | return "";
112 | }
113 | ```
114 |
115 | 然后找到所有原函数的调用者,将其改为调用新建的查询函数,并在其后调用一次修改函数(也就是原函数)。于是代码
116 |
117 | ```js
118 | const found = alertForMiscreant(people);
119 | ```
120 |
121 | 就变成了
122 |
123 | ```js
124 | const found = findMiscreant(people);
125 | alertForMiscreant(people);
126 | ```
127 |
128 | 现在可以从修改函数中去掉所有返回值了。
129 |
130 | ```js
131 | function alertForMiscreant(people) {
132 | for (const p of people) {
133 | if (p === "Don") {
134 | setOffAlarms();
135 | return;
136 | }
137 | if (p === "John") {
138 | setOffAlarms();
139 | return;
140 | }
141 | }
142 | return;
143 | }
144 | ```
145 |
146 | 现在,原来的修改函数和新建的查询函数之间有大量的重复代码,我可以使用替换算法(195),让修改函数使用查询函数。
147 |
148 | ```js
149 | function alertForMiscreant(people) {
150 | if (findMiscreant(people) !== "") setOffAlarms();
151 | }
152 | ```
153 |
154 | ## 11.2 函数参数化(Parameterize Function)
155 |
156 | 曾用名:令函数携带参数(Parameterize Method)
157 |
158 | ```js
159 | function tenPercentRaise(aPerson) {
160 | aPerson.salary = aPerson.salary.multiply(1.1);
161 | }
162 | function fivePercentRaise(aPerson) {
163 | aPerson.salary = aPerson.salary.multiply(1.05);
164 | }
165 |
166 | function raise(aPerson, factor) {
167 | aPerson.salary = aPerson.salary.multiply(1 + factor);
168 | }
169 | ```
170 |
171 | ### 动机
172 |
173 | 如果我发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。这个重构可以使函数更有用,因为重构后的函数还可以用于处理其他的值。
174 |
175 | ### 做法
176 |
177 | 从一组相似的函数中选择一个。
178 |
179 | 运用改变函数声明(124),把需要作为参数传入的字面量添加到参数列表中。
180 |
181 | 修改该函数所有的调用处,使其在调用时传入该字面量值。
182 |
183 | 测试。
184 |
185 | 修改函数体,令其使用新传入的参数。每使用一个新参数都要测试。
186 |
187 | 对于其他与之相似的函数,逐一将其调用处改为调用已经参数化的函数。每次修改后都要测试。
188 |
189 | 如果第一个函数经过参数化以后不能直接替代另一个与之相似的函数,就先对参数化之后的函数做必要的调整,再做替换。
190 |
191 | ### 范例
192 |
193 | 下面是一个显而易见的例子:
194 |
195 | ```js
196 | function tenPercentRaise(aPerson) {
197 | aPerson.salary = aPerson.salary.multiply(1.1);
198 | }
199 | function fivePercentRaise(aPerson) {
200 | aPerson.salary = aPerson.salary.multiply(1.05);
201 | }
202 | ```
203 |
204 | 很明显我可以用下面这个函数来替换上面两个:
205 |
206 | ```js
207 | function raise(aPerson, factor) {
208 | aPerson.salary = aPerson.salary.multiply(1 + factor);
209 | }
210 | ```
211 |
212 | 情况可能比这个更复杂一些。例如下列代码:
213 |
214 | ```js
215 | function baseCharge(usage) {
216 | if (usage < 0) return usd(0);
217 | const amount =
218 | bottomBand(usage) * 0.03
219 | + middleBand(usage) * 0.05
220 | + topBand(usage) * 0.07;
221 | return usd(amount);
222 | }
223 |
224 | function bottomBand(usage) {
225 | return Math.min(usage, 100);
226 | }
227 |
228 | function middleBand(usage) {
229 | return usage > 100 ? Math.min(usage, 200) - 100 : 0;
230 | }
231 |
232 | function topBand(usage) {
233 | return usage > 200 ? usage - 200 : 0;
234 | }
235 | ```
236 |
237 | 这几个函数中的逻辑明显很相似,但是不是相似到足以支撑一个参数化的计算“计费档次”(band)的函数?这次就不像前面第一个例子那样一目了然了。
238 |
239 | 在尝试对几个相关的函数做参数化操作时,我会先从中挑选一个,在上面添加参数,同时留意其他几种情况。在类似这样处理“范围”的情况下,通常从位于中间的范围开始着手较好。所以我首先选择了 middleBand 函数来添加参数,然后调整其他的调用者来适应它。
240 |
241 | middleBand 使用了两个字面量值,即 100 和 200,分别代表“中间档次”的下界和上界。我首先用改变函数声明(124)加上这两个参数,同时顺手给函数改个名,使其更好地表述参数化之后的含义。
242 |
243 | ```js
244 | function withinBand(usage, bottom, top) {
245 | return usage > 100 ? Math.min(usage, 200) - 100 : 0;
246 | }
247 |
248 | function baseCharge(usage) {
249 | if (usage < 0) return usd(0);
250 | const amount =
251 | bottomBand(usage) * 0.03
252 | + withinBand(usage, 100, 200) * 0.05
253 | + topBand(usage) * 0.07;
254 | return usd(amount);
255 | }
256 | ```
257 |
258 | 在函数体内部,把一个字面量改为使用新传入的参数:
259 |
260 | ```js
261 | function withinBand(usage, bottom, top) {
262 | return usage & gt;
263 | bottom ? Math.min(usage, 200) - bottom : 0;
264 | }
265 | ```
266 |
267 | 然后是另一个:
268 |
269 | ```js
270 | function withinBand(usage, bottom, top) {
271 | return usage & gt;
272 | bottom ? Math.min(usage, top) - bottom : 0;
273 | }
274 | ```
275 |
276 | 对于原本调用 bottomBand 函数的地方,我将其改为调用参数化了的新函数。
277 |
278 | ```js
279 | function baseCharge(usage) {
280 | if (usage < 0) return usd(0);
281 | const amount =
282 | withinBand(usage, 0, 100) * 0.03
283 | + withinBand(usage, 100, 200) * 0.05
284 | + topBand(usage) * 0.07;
285 | return usd(amount);
286 | }
287 |
288 | function bottomBand(usage) {
289 | return Math.min(usage, 100);
290 | }
291 | ```
292 |
293 | 为了替换对 topBand 的调用,我就得用代表“无穷大”的 Infinity 作为这个范围的上界。
294 |
295 | ```js
296 | function baseCharge(usage) {
297 | if (usage < 0) return usd(0);
298 | const amount =
299 | withinBand(usage, 0, 100) * 0.03
300 | + withinBand(usage, 100, 200) * 0.05
301 | + withinBand(usage, 200, Infinity) * 0.07;
302 | return usd(amount);
303 | }
304 |
305 | function topBand(usage) {
306 | return usage > 200 ? usage - 200 : 0;
307 | }
308 | ```
309 |
310 | 照现在的逻辑,baseCharge 一开始的卫语句已经可以去掉了。不过,尽管这条语句已经失去了逻辑上的必要性,我还是愿意把它留在原地,因为它阐明了“传入的 usage 参数为负数”这种情况是如何处理的。
311 |
312 | ## 11.3 移除标记参数(Remove Flag Argument)
313 |
314 | 曾用名:以明确函数取代参数(Replace Parameter with Explicit Methods)
315 |
316 | ```js
317 | function setDimension(name, value) {
318 | if (name === "height") {
319 | this._height = value;
320 | return;
321 | }
322 | if (name === "width") {
323 | this._width = value;
324 | return;
325 | }
326 | }
327 |
328 | function setHeight(value) {
329 | this._height = value;
330 | }
331 | function setWidth(value) {
332 | this._width = value;
333 | }
334 | ```
335 |
336 | ### 动机
337 |
338 | “标记参数”是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑。例如,我可能有下面这样一个函数:
339 |
340 | ```js
341 | function bookConcert(aCustomer, isPremium) {
342 | if (isPremium) {
343 | // logic for premium booking
344 | } else {
345 | // logic for regular booking
346 | }
347 | }
348 | ```
349 |
350 | 要预订一场高级音乐会(premium concert),就得这样发起调用:
351 |
352 | ```js
353 | bookConcert(aCustomer, true);
354 | ```
355 |
356 | 标记参数也可能以枚举的形式出现:
357 |
358 | ```js
359 | bookConcert(aCustomer, CustomerType.PREMIUM);
360 | ```
361 |
362 | 或者是以字符串(或者符号,如果编程语言支持的话)的形式出现:
363 |
364 | ```js
365 | bookConcert(aCustomer, "premium");
366 | ```
367 |
368 | 我不喜欢标记参数,因为它们让人难以理解到底有哪些函数可以调用、应该怎么调用。拿到一份 API 以后,我首先看到的是一系列可供调用的函数,但标记参数却隐藏了函数调用中存在的差异性。使用这样的函数,我还得弄清标记参数有哪些可用的值。布尔型的标记尤其糟糕,因为它们不能清晰地传达其含义——在调用一个函数时,我很难弄清 true 到底是什么意思。如果明确用一个函数来完成一项单独的任务,其含义会清晰得多。
369 |
370 | ```js
371 | premiumBookConcert(aCustomer);
372 | ```
373 |
374 | 并非所有类似这样的参数都是标记参数。如果调用者传入的是程序中流动的数据,这样的参数不算标记参数;只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;只有参数值影响了函数内部的控制流,这才是标记参数。
375 |
376 | 移除标记参数不仅使代码更整洁,并且能帮助开发工具更好地发挥作用。去掉标记参数后,代码分析工具能更容易地体现出“高级”和“普通”两种预订逻辑在使用时的区别。
377 |
378 | 如果一个函数有多个标记参数,可能就不得不将其保留,否则我就得针对各个参数的各种取值的所有组合情况提供明确函数。不过这也是一个信号,说明这个函数可能做得太多,应该考虑是否能用更简单的函数来组合出完整的逻辑。
379 |
380 | ### 做法
381 |
382 | 针对参数的每一种可能值,新建一个明确函数。
383 |
384 | 如果主函数有清晰的条件分发逻辑,可以用分解条件表达式(260)创建明确函数;否则,可以在原函数之上创建包装函数。
385 |
386 | 对于“用字面量值作为参数”的函数调用者,将其改为调用新建的明确函数。
387 |
388 | ### 范例
389 |
390 | 在浏览代码时,我发现多处代码在调用一个函数计算物流(shipment)的到货日期(delivery date)。一些调用代码类似这样:
391 |
392 | ```js
393 | aShipment.deliveryDate = deliveryDate(anOrder, true);
394 | ```
395 |
396 | 另一些调用代码则是这样:
397 |
398 | ```js
399 | aShipment.deliveryDate = deliveryDate(anOrder, false);
400 | ```
401 |
402 | 面对这样的代码,我立即开始好奇:参数里这个布尔值是什么意思?是用来干什么的?
403 |
404 | deliveryDate 函数主体如下所示:
405 |
406 | ```js
407 | function deliveryDate(anOrder, isRush) {
408 | if (isRush) {
409 | let deliveryTime;
410 | if (["MA", "CT"].includes(anOrder.deliveryState)) deliveryTime = 1;
411 | else if (["NY", "NH"].includes(anOrder.deliveryState)) deliveryTime = 2;
412 | else deliveryTime = 3;
413 | return anOrder.placedOn.plusDays(1 + deliveryTime);
414 | } else {
415 | let deliveryTime;
416 | if (["MA", "CT", "NY"].includes(anOrder.deliveryState)) deliveryTime = 2;
417 | else if (["ME", "NH"].includes(anOrder.deliveryState)) deliveryTime = 3;
418 | else deliveryTime = 4;
419 | return anOrder.placedOn.plusDays(2 + deliveryTime);
420 | }
421 | }
422 | ```
423 |
424 | 原来调用者用这个布尔型字面量来判断应该运行哪个分支的代码——典型的标记参数。然而函数的重点就在于要遵循调用者的指令,所以最好是用明确函数的形式明确说出调用者的意图。
425 |
426 | 对于这个例子,我可以使用分解条件表达式(260),得到下列代码:
427 |
428 | ```js
429 | function deliveryDate(anOrder, isRush) {
430 | if (isRush) return rushDeliveryDate(anOrder);
431 | else return regularDeliveryDate(anOrder);
432 | }
433 | function rushDeliveryDate(anOrder) {
434 | let deliveryTime;
435 | if (["MA", "CT"].includes(anOrder.deliveryState)) deliveryTime = 1;
436 | else if (["NY", "NH"].includes(anOrder.deliveryState)) deliveryTime = 2;
437 | else deliveryTime = 3;
438 | return anOrder.placedOn.plusDays(1 + deliveryTime);
439 | }
440 | function regularDeliveryDate(anOrder) {
441 | let deliveryTime;
442 | if (["MA", "CT", "NY"].includes(anOrder.deliveryState)) deliveryTime = 2;
443 | else if (["ME", "NH"].includes(anOrder.deliveryState)) deliveryTime = 3;
444 | else deliveryTime = 4;
445 | return anOrder.placedOn.plusDays(2 + deliveryTime);
446 | }
447 | ```
448 |
449 | 这两个函数能更好地表达调用者的意图,现在我可以修改调用方代码了。调用代码
450 |
451 | ```js
452 | aShipment.deliveryDate = deliveryDate(anOrder, true);
453 | ```
454 |
455 | 可以改为
456 |
457 | ```js
458 | aShipment.deliveryDate = rushDeliveryDate(anOrder);
459 | ```
460 |
461 | 另一个分支也类似。
462 |
463 | 处理完所有调用处,我就可以移除 deliveryDate 函数。
464 |
465 | 这个参数是标记参数,不仅因为它是布尔类型,而且还因为调用方以字面量的形式直接设置参数值。如果所有调用 deliveryDate 的代码都像这样:
466 |
467 | ```js
468 | const isRush = determineIfRush(anOrder);
469 | aShipment.deliveryDate = deliveryDate(anOrder, isRush);
470 | ```
471 |
472 | 那我对这个函数的签名没有任何意见(不过我还是想用分解条件表达式(260)清理其内部实现)。
473 |
474 | 可能有一些调用者给这个参数传入的是字面量,将其作为标记参数使用;另一些调用者则传入正常的数据。若果真如此,我还是会使用移除标记参数(314),但不修改传入正常数据的调用者,重构结束时也不删除 deliveryDate 函数。这样我就提供了两套接口,分别支持不同的用途。
475 |
476 | 直接拆分条件逻辑是实施本重构的好方法,但只有当“根据参数值做分发”的逻辑发生在函数最外层(或者可以比较容易地将其重构至函数最外层)的时候,这一招才好用。函数内部也有可能以一种更纠结的方式使用标记参数,例如下面这个版本的 deliveryDate 函数:
477 |
478 | ```js
479 | function deliveryDate(anOrder, isRush) {
480 | let result;
481 | let deliveryTime;
482 | if (anOrder.deliveryState === "MA" || anOrder.deliveryState === "CT")
483 | deliveryTime = isRush? 1 : 2;
484 | else if (anOrder.deliveryState === "NY" || anOrder.deliveryState === "NH") {
485 | deliveryTime = 2;
486 | if (anOrder.deliveryState === "NH" && !isRush)
487 | deliveryTime = 3;
488 | }
489 | else if (isRush)
490 | deliveryTime = 3;
491 | else if (anOrder.deliveryState === "ME")
492 | deliveryTime = 3;
493 | else
494 | deliveryTime = 4;
495 | result = anOrder.placedOn.plusDays(2 + deliveryTime);
496 | if (isRush) result = result.minusDays(1);
497 | return result;
498 | }
499 | ```
500 |
501 | 这种情况下,想把围绕 isRush 的分发逻辑剥离到顶层,需要的工作量可能会很大。所以我选择退而求其次,在 deliveryDate 之上添加两个函数:
502 |
503 | ```js
504 | function rushDeliveryDate(anOrder) {
505 | return deliveryDate(anOrder, true);
506 | }
507 | function regularDeliveryDate(anOrder) {
508 | return deliveryDate(anOrder, false);
509 | }
510 | ```
511 |
512 | 本质上,这两个包装函数分别代表了 deliveryDate 函数一部分的使用方式。不过它们并非从原函数中拆分而来,而是用代码文本强行定义的。
513 |
514 | 随后,我同样可以逐一替换原函数的调用者,就跟前面分解条件表达式之后的处理一样。如果没有任何一个调用者向 isRush 参数传入正常的数据,我最后会限制原函数的可见性,或是将其改名(例如改为 deliveryDateHelperOnly),让人一见即知不应直接使用这个函数。
515 |
516 | ## 11.4 保持对象完整(Preserve Whole Object)
517 |
518 | ```js
519 | const low = aRoom.daysTempRange.low;
520 | const high = aRoom.daysTempRange.high;
521 | if (aPlan.withinRange(low, high))
522 |
523 |
524 | if (aPlan.withinRange(aRoom.daysTempRange))
525 | ```
526 |
527 | ### 动机
528 |
529 | 如果我看见代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,我会更愿意把整个记录传给这个函数,在函数体内部导出所需的值。
530 |
531 | “传递整个记录”的方式能更好地应对变化:如果将来被调的函数需要从记录中导出更多的数据,我就不用为此修改参数列表。并且传递整个记录也能缩短参数列表,让函数调用更容易看懂。如果有很多函数都在使用记录中的同一组数据,处理这部分数据的逻辑常会重复,此时可以把这些处理逻辑搬移到完整对象中去。
532 |
533 | 也有时我不想采用本重构手法,因为我不想让被调函数依赖完整对象,尤其是在两者不在同一个模块中的时候。
534 |
535 | 从一个对象中抽取出几个值,单独对这几个值做某些逻辑操作,这是一种代码坏味道(依恋情结),通常标志着这段逻辑应该被搬移到对象中。保持对象完整经常发生在引入参数对象(140)之后,我会搜寻使用原来的数据泥团的代码,代之以使用新的对象。
536 |
537 | 如果几处代码都在使用对象的一部分功能,可能意味着应该用提炼类(182)把这一部分功能单独提炼出来。
538 |
539 | 还有一种常被忽视的情况:调用者将自己的若干数据作为参数,传递给被调用函数。这种情况下,我可以将调用者的自我引用(在 JavaScript 中就是 this)作为参数,直接传递给目标函数。
540 |
541 | ### 做法
542 |
543 | 新建一个空函数,给它以期望中的参数列表(即传入完整对象作为参数)。
544 |
545 | 给这个函数起一个容易搜索的名字,这样到重构结束时方便替换。
546 |
547 | 在新函数体内调用旧函数,并把新的参数(即完整对象)映射到旧的参数列表(即来源于完整对象的各项数据)。
548 |
549 | 执行静态检查。
550 |
551 | 逐一修改旧函数的调用者,令其使用新函数,每次修改之后执行测试。
552 |
553 | 修改之后,调用处用于“从完整对象中导出参数值”的代码可能就没用了,可以用移除死代码(237)去掉。
554 |
555 | 所有调用处都修改过来之后,使用内联函数(115)把旧函数内联到新函数体内。
556 |
557 | 给新函数改名,从重构开始时的容易搜索的临时名字,改为使用旧函数的名字,同时修改所有调用处。
558 |
559 | ### 范例
560 |
561 | 我们想象一个室温监控系统,它负责记录房间一天中的最高温度和最低温度,然后将实际的温度范围与预先规定的温度控制计划(heating plan)相比较,如果当天温度不符合计划要求,就发出警告。
562 |
563 | #### 调用方...
564 |
565 | ```js
566 | const low = aRoom.daysTempRange.low;
567 | const high = aRoom.daysTempRange.high;
568 | if (!aPlan.withinRange(low, high))
569 | alerts.push("room temperature went outside range");
570 | ```
571 |
572 | #### class HeatingPlan...
573 |
574 | ```js
575 | withinRange(bottom, top) {
576 | return (bottom >= this._temperatureRange.low) && (top <= this._temperatureRange.high);
577 | }
578 | ```
579 |
580 | 其实我不必将“温度范围”的信息拆开来单独传递,只需将整个范围对象传递给 withinRange 函数即可。
581 |
582 | 首先,我在 HeatingPlan 类中新添一个空函数,给它赋予我认为合理的参数列表。
583 |
584 | #### class HeatingPlan...
585 |
586 | ```js
587 | xxNEWwithinRange(aNumberRange) {
588 | }
589 | ```
590 |
591 | 因为这个函数最终要取代现有的 withinRange 函数,所以它也用了同样的名字,再加上一个容易替换的前缀。
592 |
593 | 然后在新函数体内调用现有的 withinRange 函数。因此,新函数体就完成了从新参数列表到旧函数参数列表的映射。
594 |
595 | #### class HeatingPlan...
596 |
597 | ```js
598 | xxNEWwithinRange(aNumberRange) {
599 | return this.withinRange(aNumberRange.low, aNumberRange.high);
600 | }
601 | ```
602 |
603 | 现在开始正式的替换工作了,我要找到调用现有函数的地方,将其改为调用新函数。
604 |
605 | #### 调用方...
606 |
607 | ```js
608 | const low = aRoom.daysTempRange.low;
609 | const high = aRoom.daysTempRange.high;
610 | if (!aPlan.xxNEWwithinRange(aRoom.daysTempRange))
611 | alerts.push("room temperature went outside range");
612 | ```
613 |
614 | 在修改调用处时,我可能会发现一些代码在修改后已经不再需要,此时可以使用移除死代码(237)。
615 |
616 | #### 调用方...
617 |
618 | ```js
619 | const low = aRoom.daysTempRange.low;
620 | const high = aRoom.daysTempRange.high;
621 | if (!aPlan.xxNEWwithinRange(aRoom.daysTempRange))
622 | alerts.push("room temperature went outside range");
623 | ```
624 |
625 | 每次替换一处调用代码,每次修改后都要测试。
626 |
627 | 调用处全部替换完成后,用内联函数(115)将旧函数内联到新函数体内。
628 |
629 | #### class HeatingPlan...
630 |
631 | ```js
632 | xxNEWwithinRange(aNumberRange) {
633 | return (aNumberRange.low >= this._temperatureRange.low) &&
634 | (aNumberRange.high <= this._temperatureRange.high);
635 | }
636 | ```
637 |
638 | 终于可以去掉新函数那难看的前缀了,记得同时修改所有调用者。就算我所使用的开发环境不支持可靠的函数改名操作,有这个极具特色的前缀在,我也可以很方便地全局替换。
639 |
640 | #### class HeatingPlan...
641 |
642 | ```js
643 | withinRange(aNumberRange) {
644 | return (aNumberRange.low >= this._temperatureRange.low) &&
645 | (aNumberRange.high <= this._temperatureRange.high);
646 | }
647 | ```
648 |
649 | #### 调用方...
650 |
651 | ```js
652 | if (!aPlan.withinRange(aRoom.daysTempRange))
653 | alerts.push("room temperature went outside range");
654 | ```
655 |
656 | ### 范例:换个方式创建新函数
657 |
658 | 在上面的示例中,我直接编写了新函数。大多数时候,这一步非常简单,也是创建新函数最容易的方式。不过有时还会用到另一种方式:可以完全通过重构手法的组合来得到新函数。
659 |
660 | 我从一处调用现有函数的代码开始。
661 |
662 | #### 调用方...
663 |
664 | ```js
665 | const low = aRoom.daysTempRange.low;
666 | const high = aRoom.daysTempRange.high;
667 | if (!aPlan.withinRange(low, high))
668 | alerts.push("room temperature went outside range");
669 | ```
670 |
671 | 我要先对代码做一些整理,以便用提炼函数(106)来创建新函数。目前的调用者代码还不具备可提炼的函数雏形,不过我可以先做几次提炼变量(119),使其轮廓显现出来。首先,我要把对旧函数的调用从条件判断中解放出来。
672 |
673 | #### 调用方...
674 |
675 | ```js
676 | const low = aRoom.daysTempRange.low;
677 | const high = aRoom.daysTempRange.high;
678 | const isWithinRange = aPlan.withinRange(low, high);
679 | if (!isWithinRange) alerts.push("room temperature went outside range");
680 | ```
681 |
682 | 然后把输入参数也提炼出来。
683 |
684 | #### 调用方...
685 |
686 | ```js
687 | const tempRange = aRoom.daysTempRange;
688 | const low = tempRange.low;
689 | const high = tempRange.high;
690 | const isWithinRange = aPlan.withinRange(low, high);
691 | if (!isWithinRange) alerts.push("room temperature went outside range");
692 | ```
693 |
694 | 完成这一步之后,就可以用提炼函数(106)来创建新函数。
695 |
696 | #### 调用方...
697 |
698 | ```js
699 | const tempRange = aRoom.daysTempRange;
700 | const isWithinRange = xxNEWwithinRange(aPlan, tempRange);
701 | if (!isWithinRange) alerts.push("room temperature went outside range");
702 | ```
703 |
704 | #### 顶层作用域...
705 |
706 | ```js
707 | function xxNEWwithinRange(aPlan, tempRange) {
708 | const low = tempRange.low;
709 | const high = tempRange.high;
710 | const isWithinRange = aPlan.withinRange(low, high);
711 | return isWithinRange;
712 | }
713 | ```
714 |
715 | 由于旧函数属于另一个上下文(HeatingPlan 类),我需要用搬移函数(198)把新函数也搬过去。
716 |
717 | #### 调用方...
718 |
719 | ```js
720 | const tempRange = aRoom.daysTempRange;
721 | const isWithinRange = aPlan.xxNEWwithinRange(tempRange);
722 | if (!isWithinRange) alerts.push("room temperature went outside range");
723 | ```
724 |
725 | #### class HeatingPlan...
726 |
727 | ```js
728 | xxNEWwithinRange(tempRange) {
729 | const low = tempRange.low;
730 | const high = tempRange.high;
731 | const isWithinRange = this.withinRange(low, high);
732 | return isWithinRange;
733 | }
734 | ```
735 |
736 | 剩下的过程就跟前面一样了:替换其他调用者,然后把旧函数内联到新函数中。重构刚开始的时候,为了清晰分离函数调用,以便提炼出新函数,我提炼了几个变量出来,现在可以把这些变量也内联回去。
737 |
738 | 这种方式的好处在于:它完全是由其他重构手法组合而成的。如果我使用的开发工具支持可靠的提炼和内联操作,用这种方式进行本重构会特别流畅。
739 |
740 | ## 11.5 以查询取代参数(Replace Parameter with Query)
741 |
742 | 曾用名:以函数取代参数(Replace Parameter with Method)
743 |
744 | 反向重构:以参数取代查询(327)
745 |
746 | ```js
747 | availableVacation(anEmployee, anEmployee.grade);
748 |
749 | function availableVacation(anEmployee, grade) {
750 | // calculate vacation...
751 |
752 |
753 | availableVacation(anEmployee)
754 |
755 | function availableVacation(anEmployee) {
756 | const grade = anEmployee.grade;
757 | // calculate vacation...
758 | ```
759 |
760 | ### 动机
761 |
762 | 函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式。和任何代码中的语句一样,参数列表应该尽量避免重复,并且参数列表越短就越容易理解。
763 |
764 | 如果调用函数时传入了一个值,而这个值由函数自己来获得也是同样容易,这就是重复。这个本不必要的参数会增加调用者的难度,因为它不得不找出正确的参数值,其实原本调用者是不需要费这个力气的。
765 |
766 | “同样容易”四个字,划出了一条判断的界限。去除参数也就意味着“获得正确的参数值”的责任被转移:有参数传入时,调用者需要负责获得正确的参数值;参数去除后,责任就被转移给了函数本身。一般而言,我习惯于简化调用方,因此我愿意把责任移交给函数本身,但如果函数难以承担这份责任,就另当别论了。
767 |
768 | 不使用以查询取代参数最常见的原因是,移除参数可能会给函数体增加不必要的依赖关系——迫使函数访问某个程序元素,而我原本不想让函数了解这个元素的存在。这种“不必要的依赖关系”除了新增的以外,也可能是我想要稍后去除的,例如为了去除一个参数,我可能会在函数体内调用一个有问题的函数,或是从一个对象中获取某些原本想要剥离出去的数据。在这些情况下,都应该慎重考虑使用以查询取代参数。
769 |
770 | 如果想要去除的参数值只需要向另一个参数查询就能得到,这是使用以查询取代参数最安全的场景。如果可以从一个参数推导出另一个参数,那么几乎没有任何理由要同时传递这两个参数。
771 |
772 | 另外有一件事需要留意:如果在处理的函数具有引用透明性(referential transparency,即,不论任何时候,只要传入相同的参数值,该函数的行为永远一致),这样的函数既容易理解又容易测试,我不想使其失去这种优秀品质。我不会去掉它的参数,让它去访问一个可变的全局变量。
773 |
774 | ### 做法
775 |
776 | 如果有必要,使用提炼函数(106)将参数的计算过程提炼到一个独立的函数中。
777 |
778 | 将函数体内引用该参数的地方改为调用新建的函数。每次修改后执行测试。
779 |
780 | 全部替换完成后,使用改变函数声明(124)将该参数去掉。
781 |
782 | ### 范例
783 |
784 | 某些重构会使参数不再被需要,这是我最常用到以查询取代参数的场合。考虑下列代码。
785 |
786 | #### class Order...
787 |
788 | ```js
789 | get finalPrice() {
790 | const basePrice = this.quantity * this.itemPrice;
791 | let discountLevel;
792 | if (this.quantity > 100) discountLevel = 2;
793 | else discountLevel = 1;
794 | return this.discountedPrice(basePrice, discountLevel);
795 | }
796 |
797 | discountedPrice(basePrice, discountLevel) {
798 | switch (discountLevel) {
799 | case 1: return basePrice * 0.95;
800 | case 2: return basePrice * 0.9;
801 | }
802 | }
803 | ```
804 |
805 | 在简化函数逻辑时,我总是热衷于使用以查询取代临时变量(178),于是就得到了如下代码。
806 |
807 | #### class Order...
808 |
809 | ```js
810 | get finalPrice() {
811 | const basePrice = this.quantity * this.itemPrice;
812 | return this.discountedPrice(basePrice, this.discountLevel);
813 | }
814 |
815 | get discountLevel() {
816 | return (this.quantity > 100) ? 2 : 1;
817 | }
818 | ```
819 |
820 | 到这一步,已经不需要再把 discountLevel 的计算结果传给 discountedPrice 了,后者可以自己调用 discountLevel 函数,不会增加任何难度。
821 |
822 | 因此,我把 discountedPrice 函数中用到这个参数的地方全都改为直接调用 discountLevel 函数。
823 |
824 | #### class Order...
825 |
826 | ```js
827 | discountedPrice(basePrice, discountLevel) {
828 | switch (this.discountLevel) {
829 | case 1: return basePrice * 0.95;
830 | case 2: return basePrice * 0.9;
831 | }
832 | }
833 | ```
834 |
835 | 然后用改变函数声明(124)手法移除该参数。
836 |
837 | #### class Order...
838 |
839 | ```js
840 | get finalPrice() {
841 | const basePrice = this.quantity * this.itemPrice;
842 | return this.discountedPrice(basePrice, this.discountLevel);
843 | }
844 |
845 | discountedPrice(basePrice, discountLevel) {
846 | switch (this.discountLevel) {
847 | case 1: return basePrice * 0.95;
848 | case 2: return basePrice * 0.9;
849 | }
850 | }
851 | ```
852 |
853 | ## 11.6 以参数取代查询(Replace Query with Parameter)
854 |
855 | 反向重构:以查询取代参数(324)
856 |
857 | ```js
858 | targetTemperature(aPlan)
859 |
860 | function targetTemperature(aPlan) {
861 | currentTemperature = thermostat.currentTemperature;
862 | // rest of function...
863 |
864 |
865 | targetTemperature(aPlan, thermostat.currentTemperature)
866 |
867 | function targetTemperature(aPlan, currentTemperature) {
868 | // rest of function...
869 | ```
870 |
871 | ### 动机
872 |
873 | 在浏览函数实现时,我有时会发现一些令人不快的引用关系,例如,引用一个全局变量,或者引用另一个我想要移除的元素。为了解决这些令人不快的引用,我需要将其替换为函数参数,从而将处理引用关系的责任转交给函数的调用者。
874 |
875 | 需要使用本重构的情况大多源于我想要改变代码的依赖关系——为了让目标函数不再依赖于某个元素,我把这个元素的值以参数形式传递给该函数。这里需要注意权衡:如果把所有依赖关系都变成参数,会导致参数列表冗长重复;如果作用域之间的共享太多,又会导致函数间依赖过度。我一向不善于微妙的权衡,所以“能够可靠地改变决定”就显得尤为重要,这样随着我的理解加深,程序也能从中受益。
876 |
877 | 如果一个函数用同样的参数调用总是给出同样的结果,我们就说这个函数具有“引用透明性”(referential transparency),这样的函数理解起来更容易。如果一个函数使用了另一个元素,而后者不具引用透明性,那么包含该元素的函数也就失去了引用透明性。只要把“不具引用透明性的元素”变成参数传入,函数就能重获引用透明性。虽然这样就把责任转移给了函数的调用者,但是具有引用透明性的模块能带来很多益处。有一个常见的模式:在负责逻辑处理的模块中只有纯函数,其外再包裹处理 I/O 和其他可变元素的逻辑代码。借助以参数取代查询,我可以提纯程序的某些组成部分,使其更容易测试、更容易理解。
878 |
879 | 不过以参数取代查询并非只有好处。把查询变成参数以后,就迫使调用者必须弄清如何提供正确的参数值,这会增加函数调用者的复杂度,而我在设计接口时通常更愿意让接口的消费者更容易使用。归根到底,这是关于程序中责任分配的问题,而这方面的决策既不容易,也不会一劳永逸——这就是我需要非常熟悉本重构(及其反向重构)的原因。
880 |
881 | ### 做法
882 |
883 | 对执行查询操作的代码使用提炼变量(119),将其从函数体中分离出来。
884 |
885 | 现在函数体代码已经不再执行查询操作(而是使用前一步提炼出的变量),对这部分代码使用提炼函数(106)。
886 |
887 | 给提炼出的新函数起一个容易搜索的名字,以便稍后改名。
888 |
889 | 使用内联变量(123),消除刚才提炼出来的变量。
890 |
891 | 对原来的函数使用内联函数(115)。
892 |
893 | 对新函数改名,改回原来函数的名字。
894 |
895 | ### 范例
896 |
897 | 我们想象一个简单却又烦人的温度控制系统。用户可以从一个温控终端(thermostat)指定温度,但指定的目标温度必须在温度控制计划(heating plan)允许的范围内。
898 |
899 | #### class HeatingPlan...
900 |
901 | ```js
902 | get targetTemperature() {
903 | if (thermostat.selectedTemperature > this._max) return this._max;
904 | else if (thermostat.selectedTemperature < this._min) return this._min;
905 | else return thermostat.selectedTemperature;
906 | }
907 | ```
908 |
909 | #### 调用方...
910 |
911 | ```js
912 | if (thePlan.targetTemperature > thermostat.currentTemperature) setToHeat();
913 | else if (thePlan.targetTemperature<thermostat.currentTemperature)setToCool();
914 | else setOff();
915 | ```
916 |
917 | 系统的温控计划规则抑制了我的要求,作为这样一个系统的用户,我可能会感到很烦恼。不过作为程序员,我更担心的是 targetTemperature 函数依赖于全局的 thermostat 对象。我可以把需要这个对象提供的信息作为参数传入,从而打破对该对象的依赖。
918 |
919 | 首先,我要用提炼变量(119)把“希望作为参数传入的信息”提炼出来。
920 |
921 | #### class HeatingPlan...
922 |
923 | ```js
924 | get targetTemperature() {
925 | const selectedTemperature = thermostat.selectedTemperature;
926 | if (selectedTemperature > this._max) return this._max;
927 | else if (selectedTemperature < this._min) return this._min;
928 | else return selectedTemperature;
929 | }
930 | ```
931 |
932 | 这样可以比较容易地用提炼函数(106)把整个函数体提炼出来,只剩“计算参数值”的逻辑还在原地。
933 |
934 | #### class HeatingPlan...
935 |
936 | ```js
937 | get targetTemperature() {
938 | const selectedTemperature = thermostat.selectedTemperature;
939 | return this.xxNEWtargetTemperature(selectedTemperature);
940 | }
941 |
942 | xxNEWtargetTemperature(selectedTemperature) {
943 | if (selectedTemperature > this._max) return this._max;
944 | else if (selectedTemperature < this._min) return this._min;
945 | else return selectedTemperature;
946 | }
947 | ```
948 |
949 | 然后把刚才提炼出来的变量内联回去,于是旧函数就只剩一个简单的调用。
950 |
951 | #### class HeatingPlan...
952 |
953 | ```js
954 | get targetTemperature() {
955 | return this.xxNEWtargetTemperature(thermostat.selectedTemperature);
956 | }
957 | ```
958 |
959 | 现在可以对其使用内联函数(115)。
960 |
961 | #### 调用方...
962 |
963 | ```js
964 | if (thePlan.xxNEWtargetTemperature(thermostat.selectedTemperature) >
965 | thermostat.currentTemperature)
966 | setToHeat();
967 | else if (thePlan.xxNEWtargetTemperature(thermostat.selectedTemperature) <
968 | thermostat.currentTemperature)
969 | setToCool();
970 | else
971 | setOff();
972 | ```
973 |
974 | 再把新函数改名,用回旧函数的名字。得益于之前给它起了一个容易搜索的名字,现在只要把前缀去掉就行。
975 |
976 | #### 调用方...
977 |
978 | ```js
979 | if (thePlan.targetTemperature(thermostat.selectedTemperature) >
980 | thermostat.currentTemperature)
981 | setToHeat();
982 | else if (thePlan.targetTemperature(thermostat.selectedTemperature) <
983 | thermostat.currentTemperature)
984 | setToCool();
985 | else
986 | setOff();
987 | ```
988 |
989 | #### class HeatingPlan...
990 |
991 | ```js
992 | targetTemperature(selectedTemperature) {
993 | if (selectedTemperature > this._max) return this._max;
994 | else if (selectedTemperature < this._min) return this._min;
995 | else return selectedTemperature;
996 | }
997 | ```
998 |
999 | 调用方的代码看起来比重构之前更笨重了,这是使用本重构手法的常见情况。将一个依赖关系从一个模块中移出,就意味着将处理这个依赖关系的责任推回给调用者。这是为了降低耦合度而付出的代价。
1000 |
1001 | 但是,去除对 thermostat 对象的耦合,并不是本重构带来的唯一收益。HeatingPlan 类本身是不可变的——字段的值都在构造函数中设置,任何函数都不会修改它们。(不用费心去查看整个类的代码,相信我就好。)在不可变的 HeatingPlan 基础上,把对 thermostat 的依赖移出函数体之后,我又使 targetTemperature 函数具备了引用透明性。从此以后,只要在同一个 HeatingPlan 对象上用同样的参数调用 targetTemperature 函数,我会始终得到同样的结果。如果 HeatingPlan 的所有函数都具有引用透明性,这个类会更容易测试,其行为也更容易理解。
1002 |
1003 | JavaScript 的类模型有一个问题:无法强制要求类的不可变性——始终有办法修改对象的内部数据。尽管如此,在编写一个类的时候明确说明并鼓励不可变性,通常也就足够了。尽量让类保持不可变通常是一个好的策略,以参数取代查询则是达成这一策略的利器。
1004 |
1005 | ## 11.7 移除设值函数(Remove Setting Method)
1006 |
1007 | ```js
1008 | class Person {
1009 | get name() {...}
1010 | set name(aString) {...}
1011 |
1012 |
1013 | class Person {
1014 | get name() {...}
1015 | ```
1016 |
1017 | ### 动机
1018 |
1019 | 如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。这样一来,该字段就只能在构造函数中赋值,我“不想让它被修改”的意图会更加清晰,并且可以排除其值被修改的可能性——这种可能性往往是非常大的。
1020 |
1021 | 有两种常见的情况需要讨论。一种情况是,有些人喜欢始终通过访问函数来读写字段值,包括在构造函数内也是如此。这会导致构造函数成为设值函数的唯一使用者。若果真如此,我更愿意去除设值函数,清晰地表达“构造之后不应该再更新字段值”的意图。
1022 |
1023 | 另一种情况是,对象是由客户端通过创建脚本构造出来,而不是只有一次简单的构造函数调用。所谓“创建脚本”,首先是调用构造函数,然后就是一系列设值函数的调用,共同完成新对象的构造。创建脚本执行完以后,这个新生对象的部分(乃至全部)字段就不应该再被修改。设值函数只应该在起初的对象创建过程中调用。对于这种情况,我也会想办法去除设值函数,更清晰地表达我的意图。
1024 |
1025 | ### 做法
1026 |
1027 | 如果构造函数尚无法得到想要设入字段的值,就使用改变函数声明(124)将这个值以参数的形式传入构造函数。在构造函数中调用设值函数,对字段设值。
1028 |
1029 | 如果想移除多个设值函数,可以一次性把它们的值都传入构造函数,这能简化后续步骤。
1030 |
1031 | 移除所有在构造函数之外对设值函数的调用,改为使用新的构造函数。每次修改之后都要测试。
1032 |
1033 | 如果不能把“调用设值函数”替换为“创建一个新对象”(例如你需要更新一个多处共享引用的对象),请放弃本重构。
1034 |
1035 | 使用内联函数(115)消去设值函数。如果可能的话,把字段声明为不可变。
1036 |
1037 | 测试。
1038 |
1039 | ### 范例
1040 |
1041 | 我有一个很简单的 Person 类。
1042 |
1043 | #### class Person...
1044 |
1045 | ```js
1046 | get name() {return this._name;}
1047 | set name(arg) {this._name = arg;}
1048 | get id() {return this._id;}
1049 | set id(arg) {this._id = arg;}
1050 | ```
1051 |
1052 | 目前我会这样创建新对象:
1053 |
1054 | ```js
1055 | const martin = new Person();
1056 | martin.name = "martin";
1057 | martin.id = "1234";
1058 | ```
1059 |
1060 | 对象创建之后,name 字段可能会改变,但 id 字段不会。为了更清晰地表达这个设计意图,我希望移除对应 id 字段的设值函数。
1061 |
1062 | 但 id 字段还得设置初始值,所以我首先用改变函数声明(124)在构造函数中添加对应的参数。
1063 |
1064 | #### class Person...
1065 |
1066 | ```js
1067 | constructor(id) {
1068 | this.id = id;
1069 | }
1070 | ```
1071 |
1072 | 然后调整创建脚本,改为从构造函数设值 id 字段值。
1073 |
1074 | ```js
1075 | const martin = new Person("1234");
1076 | martin.name = "martin";
1077 | martin.id = "1234";
1078 | ```
1079 |
1080 | 所有创建 Person 对象的地方都要如此修改,每次修改之后要执行测试。
1081 |
1082 | 全部修改完成后,就可以用内联函数(115)消去设值函数。
1083 |
1084 | #### class Person...
1085 |
1086 | ```js
1087 | constructor(id) {
1088 | this._id = id;
1089 | }
1090 | get name() {return this._name;}
1091 | set name(arg) {this._name = arg;}
1092 | get id() {return this._id;}
1093 | set id(arg) {this._id = arg;}
1094 | ```
1095 |
1096 | ## 11.8 以工厂函数取代构造函数(Replace Constructor with Factory Function)
1097 |
1098 | 曾用名:以工厂函数取代构造函数(Replace Constructor with Factory Method)
1099 |
1100 | ```js
1101 | leadEngineer = new Employee(document.leadEngineer, "E");
1102 |
1103 | leadEngineer = createEngineer(document.leadEngineer);
1104 | ```
1105 |
1106 | ### 动机
1107 |
1108 | 很多面向对象语言都有特别的构造函数,专门用于对象的初始化。需要新建一个对象时,客户端通常会调用构造函数。但与一般的函数相比,构造函数又常有一些丑陋的局限性。例如,Java 的构造函数只能返回当前所调用类的实例,也就是说,我无法根据环境或参数信息返回子类实例或代理对象;构造函数的名字是固定的,因此无法使用比默认名字更清晰的函数名;构造函数需要通过特殊的操作符来调用(在很多语言中是 new 关键字),所以在要求普通函数的场合就难以使用。
1109 |
1110 | 工厂函数就不受这些限制。工厂函数的实现内部可以调用构造函数,但也可以换成别的方式实现。
1111 |
1112 | ### 做法
1113 |
1114 | 新建一个工厂函数,让它调用现有的构造函数。
1115 |
1116 | 将调用构造函数的代码改为调用工厂函数。
1117 |
1118 | 每修改一处,就执行测试。
1119 |
1120 | 尽量缩小构造函数的可见范围。
1121 |
1122 | ### 范例
1123 |
1124 | 又是那个单调乏味的例子:员工薪资系统。我还是以 Employee 类表示“员工”。
1125 |
1126 | #### class Employee...
1127 |
1128 | ```js
1129 | constructor (name, typeCode) {
1130 | this._name = name;
1131 | this._typeCode = typeCode;
1132 | }
1133 | get name() {return this._name;}
1134 | get type() {
1135 | return Employee.legalTypeCodes[this._typeCode];
1136 | }
1137 | static get legalTypeCodes() {
1138 | return {"E": "Engineer", "M": "Manager", "S": "Salesman"};
1139 | }
1140 | ```
1141 |
1142 | 使用它的代码有这样的:
1143 |
1144 | #### 调用方...
1145 |
1146 | ```js
1147 | candidate = new Employee(document.name, document.empType);
1148 | ```
1149 |
1150 | 也有这样的:
1151 |
1152 | #### 调用方...
1153 |
1154 | ```js
1155 | const leadEngineer = new Employee(document.leadEngineer, "E");
1156 | ```
1157 |
1158 | 重构的第一步是创建工厂函数,其中把对象创建的责任直接委派给构造函数。
1159 |
1160 | #### 顶层作用域...
1161 |
1162 | ```js
1163 | function createEmployee(name, typeCode) {
1164 | return new Employee(name, typeCode);
1165 | }
1166 | ```
1167 |
1168 | 然后找到构造函数的调用者,并逐一修改它们,令其使用工厂函数。
1169 |
1170 | 第一处的修改很简单。
1171 |
1172 | #### 调用方...
1173 |
1174 | ```js
1175 | candidate = createEmployee(document.name, document.empType);
1176 | ```
1177 |
1178 | 第二处则可以这样使用工厂函数。
1179 |
1180 | #### 调用方...
1181 |
1182 | ```js
1183 | const leadEngineer = createEmployee(document.leadEngineer, "E");
1184 | ```
1185 |
1186 | 但我不喜欢这里的类型码——以字符串字面量的形式传入类型码,一般来说都是坏味道。所以我更愿意再新建一个工厂函数,把“员工类别”的信息嵌在函数名里体现。
1187 |
1188 | #### 调用方...
1189 |
1190 | ```js
1191 | const leadEngineer = createEngineer(document.leadEngineer);
1192 | ```
1193 |
1194 | #### 顶层作用域...
1195 |
1196 | ```js
1197 | function createEngineer(name) {
1198 | return new Employee(name, "E");
1199 | }
1200 | ```
1201 |
1202 | ## 11.9 以命令取代函数(Replace Function with Command)
1203 |
1204 | 曾用名:以函数对象取代函数(Replace Method with Method Object)
1205 |
1206 | 反向重构:以函数取代命令(344)
1207 |
1208 | ```js
1209 | function score(candidate, medicalExam, scoringGuide) {
1210 | let result = 0;
1211 | let healthLevel = 0;
1212 | // long body code
1213 | }
1214 |
1215 | class Scorer {
1216 | constructor(candidate, medicalExam, scoringGuide) {
1217 | this._candidate = candidate;
1218 | this._medicalExam = medicalExam;
1219 | this._scoringGuide = scoringGuide;
1220 | }
1221 |
1222 | execute() {
1223 | this._result = 0;
1224 | this._healthLevel = 0;
1225 | // long body code
1226 | }
1227 | }
1228 | ```
1229 |
1230 | ### 动机
1231 |
1232 | 函数,不管是独立函数,还是以方法(method)形式附着在对象上的函数,是程序设计的基本构造块。不过,将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我称之为“命令对象”(command object),或者简称“命令”(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。
1233 |
1234 | 与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。除了函数调用本身,命令对象还可以支持附加的操作,例如撤销操作。我可以通过命令对象提供的方法来设值命令的参数值,从而支持更丰富的生命周期管理能力。我可以借助继承和钩子对函数行为加以定制。如果我所使用的编程语言支持对象但不支持函数作为一等公民,通过命令对象就可以给函数提供大部分相当于一等公民的能力。同样,即便编程语言本身并不支持嵌套函数,我也可以借助命令对象的方法和字段把复杂的函数拆解开,而且在测试和调试过程中可以直接调用这些方法。
1235 |
1236 | 所有这些都是使用命令对象的好理由,所以我要做好准备,一旦有需要,就能把函数重构成命令。不过我们不能忘记,命令对象的灵活性也是以复杂性作为代价的。所以,如果要在作为一等公民的函数和命令对象之间做个选择,95%的时候我都会选函数。只有当我特别需要命令对象提供的某种能力而普通的函数无法提供这种能力时,我才会考虑使用命令对象。
1237 |
1238 | 跟软件开发中的很多词汇一样,“命令”这个词承载了太多含义。在这里,“命令”是指一个对象,其中封装了一个函数调用请求。这是遵循《设计模式》[gof]一书中的命令模式(command pattern)。在这个意义上,使用“命令”一词时,我会先用完整的“命令对象”一词设定上下文,然后视情况使用简略的“命令”一词。在命令与查询分离原则(command-query separation principle)中也用到了“命令”一词,此时“命令”是一个对象所拥有的函数,调用该函数可以改变对象可观察的状态。我尽量避免使用这个意义上的“命令”一词,而更愿意称其为“修改函数”(modifier)或者“改变函数”(mutator)。
1239 |
1240 | ### 做法
1241 |
1242 | 为想要包装的函数创建一个空的类,根据该函数的名字为其命名。
1243 |
1244 | 使用搬移函数(198)把函数移到空的类里。
1245 |
1246 | 保持原来的函数作为转发函数,至少保留到重构结束之前才删除。
1247 |
1248 | 遵循编程语言的命名规范来给命令对象起名。如果没有合适的命名规范,就给命令对象中负责实际执行命令的函数起一个通用的名字,例如“execute”或者“call”。
1249 |
1250 | 可以考虑给每个参数创建一个字段,并在构造函数中添加对应的参数。
1251 |
1252 | ### 范例
1253 |
1254 | JavaScript 语言有很多缺点,但把函数作为一等公民对待,是它最正确的设计决策之一。在不具备这种能力的编程语言中,我经常要费力为很常见的任务创建命令对象,JavaScript 则省去了这些麻烦。不过,即便在 JavaScript 中,有时也需要用到命令对象。
1255 |
1256 | 一个典型的应用场景就是拆解复杂的函数,以便我理解和修改。要想真正展示这个重构手法的价值,我需要一个长而复杂的函数,但这写起来太费事,你读起来也麻烦。所以我在这里展示的函数其实很短,并不真的需要本重构手法,还望读者权且包涵。下面的函数用于给一份保险申请评分。
1257 |
1258 | ```js
1259 | function score(candidate, medicalExam, scoringGuide) {
1260 | let result = 0;
1261 | let healthLevel = 0;
1262 | let highMedicalRiskFlag = false;
1263 |
1264 | if (medicalExam.isSmoker) {
1265 | healthLevel += 10;
1266 | highMedicalRiskFlag = true;
1267 | }
1268 | let certificationGrade = "regular";
1269 | if (scoringGuide.stateWithLowCertification(candidate.originState)) {
1270 | certificationGrade = "low";
1271 | result -= 5;
1272 | } // lots more code like this
1273 | result -= Math.max(healthLevel - 5, 0);
1274 | return result;
1275 | }
1276 | ```
1277 |
1278 | 我首先创建一个空的类,用搬移函数(198)把上述函数搬到这个类里去。
1279 |
1280 | ```js
1281 | function score(candidate, medicalExam, scoringGuide) {
1282 | return new Scorer().execute(candidate, medicalExam, scoringGuide);
1283 | }
1284 |
1285 | class Scorer {
1286 | execute(candidate, medicalExam, scoringGuide) {
1287 | let result = 0;
1288 | let healthLevel = 0;
1289 | let highMedicalRiskFlag = false;
1290 |
1291 | if (medicalExam.isSmoker) {
1292 | healthLevel += 10;
1293 | highMedicalRiskFlag = true;
1294 | }
1295 | let certificationGrade = "regular";
1296 | if (scoringGuide.stateWithLowCertification(candidate.originState)) {
1297 | certificationGrade = "low";
1298 | result -= 5;
1299 | } // lots more code like this
1300 | result -= Math.max(healthLevel - 5, 0);
1301 | return result;
1302 | }
1303 | }
1304 | ```
1305 |
1306 | 大多数时候,我更愿意在命令对象的构造函数中传入参数,而不让 execute 函数接收参数。在这样一个简单的拆解场景中,这一点带来的影响不大;但如果我要处理的命令需要更复杂的参数设置周期或者大量定制,上述做法就会带来很多便利:多个命令类可以分别从各自的构造函数中获得各自不同的参数,然后又可以排成队列挨个执行,因为它们的 execute 函数签名都一样。
1307 |
1308 | 我可以每次搬移一个参数到构造函数。
1309 |
1310 | ```js
1311 | function score(candidate, medicalExam, scoringGuide) {
1312 | return new Scorer(candidate).execute(candidate, medicalExam, scoringGuide);
1313 | }
1314 | ```
1315 |
1316 | #### class Scorer...
1317 |
1318 | ```JS
1319 | constructor(candidate){
1320 | this._candidate = candidate;
1321 | }
1322 |
1323 | execute (candidate, medicalExam, scoringGuide) {
1324 | let result = 0;
1325 | let healthLevel = 0;
1326 | let highMedicalRiskFlag = false;
1327 |
1328 | if (medicalExam.isSmoker) {
1329 | healthLevel += 10;
1330 | highMedicalRiskFlag = true;
1331 | }
1332 | let certificationGrade = "regular";
1333 | if (scoringGuide.stateWithLowCertification(this._candidate.originState)) {
1334 | certificationGrade = "low";
1335 | result -= 5;
1336 | }
1337 | // lots more code like this
1338 | result -= Math.max(healthLevel - 5, 0);
1339 | return result;
1340 | }
1341 | ```
1342 |
1343 | 继续处理其他参数:
1344 |
1345 | ```js
1346 | function score(candidate, medicalExam, scoringGuide) {
1347 | return new Scorer(candidate, medicalExam, scoringGuide).execute();
1348 | }
1349 | ```
1350 |
1351 | #### class Scorer...
1352 |
1353 | ```js
1354 | constructor(candidate, medicalExam, scoringGuide){
1355 | this._candidate = candidate;
1356 | this._medicalExam = medicalExam;
1357 | this._scoringGuide = scoringGuide;
1358 | }
1359 | execute () {
1360 | let result = 0;
1361 | let healthLevel = 0;
1362 | let highMedicalRiskFlag = false;
1363 |
1364 | if (this._medicalExam.isSmoker) {
1365 | healthLevel += 10;
1366 | highMedicalRiskFlag = true;
1367 | }
1368 | let certificationGrade = "regular";
1369 | if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
1370 | certificationGrade = "low";
1371 | result -= 5;
1372 | }
1373 | // lots more code like this
1374 | result -= Math.max(healthLevel - 5, 0);
1375 | return result;
1376 | }
1377 | ```
1378 |
1379 | 以命令取代函数的重构到此就结束了,不过之所以要做这个重构,是为了拆解复杂的函数,所以我还是大致展示一下如何拆解。下一步是把所有局部变量都变成字段,我还是每次修改一处。
1380 |
1381 | #### class Scorer...
1382 |
1383 | ```js
1384 | constructor(candidate, medicalExam, scoringGuide){
1385 | this._candidate = candidate;
1386 | this._medicalExam = medicalExam;
1387 | this._scoringGuide = scoringGuide;
1388 | }
1389 |
1390 | execute () {
1391 | this._result = 0;
1392 | let healthLevel = 0;
1393 | let highMedicalRiskFlag = false;
1394 |
1395 | if (this._medicalExam.isSmoker) {
1396 | healthLevel += 10;
1397 | highMedicalRiskFlag = true;
1398 | }
1399 | let certificationGrade = "regular";
1400 | if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
1401 | certificationGrade = "low";
1402 | this._result -= 5;
1403 | }
1404 | // lots more code like this
1405 | this._result -= Math.max(healthLevel - 5, 0);
1406 | return this._result;
1407 | }
1408 | ```
1409 |
1410 | 重复上述过程,直到所有局部变量都变成字段。(“把局部变量变成字段”这个重构手法是如此简单,以至于我都没有在重构名录中给它一席之地。对此我略感愧疚。)
1411 |
1412 | #### class Scorer...
1413 |
1414 | ```js
1415 | constructor(candidate, medicalExam, scoringGuide){
1416 | this._candidate = candidate;
1417 | this._medicalExam = medicalExam;
1418 | this._scoringGuide = scoringGuide;
1419 | }
1420 |
1421 | execute () {
1422 | this._result = 0;
1423 | this._healthLevel = 0;
1424 | this._highMedicalRiskFlag = false;
1425 |
1426 | if (this._medicalExam.isSmoker) {
1427 | this._healthLevel += 10;
1428 | this._highMedicalRiskFlag = true;
1429 | }
1430 | this._certificationGrade = "regular";
1431 | if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
1432 | this._certificationGrade = "low";
1433 | this._result -= 5;
1434 | }
1435 | // lots more code like this
1436 | this._result -= Math.max(this._healthLevel - 5, 0);
1437 | return this._result;
1438 | }
1439 | ```
1440 |
1441 | 现在函数的所有状态都已经移到了命令对象中,我可以放心使用提炼函数(106)等重构手法,而不用纠结于局部变量的作用域之类问题。
1442 |
1443 | #### class Scorer...
1444 |
1445 | ```js
1446 | execute () {
1447 | this._result = 0;
1448 | this._healthLevel = 0;
1449 | this._highMedicalRiskFlag = false;
1450 |
1451 | this.scoreSmoking();
1452 | this._certificationGrade = "regular";
1453 | if (this._scoringGuide.stateWithLowCertification(this._candidate.originState)) {
1454 | this._certificationGrade = "low";
1455 | this._result -= 5;
1456 | }
1457 | // lots more code like this
1458 | this._result -= Math.max(this._healthLevel - 5, 0);
1459 | return this._result;
1460 | }
1461 | scoreSmoking() {
1462 | if (this._medicalExam.isSmoker) {
1463 | this._healthLevel += 10;
1464 | this._highMedicalRiskFlag = true;
1465 | }
1466 | }
1467 | ```
1468 |
1469 | 这样我就可以像处理嵌套函数一样处理命令对象。实际上,在 JavaScript 中运用此重构手法时,的确可以考虑用嵌套函数来代替命令对象。不过我还是会使用命令对象,不仅因为我对命令对象更熟悉,而且还因为我可以针对命令对象中任何一个函数进行测试和调试。
1470 |
1471 | ## 11.10 以函数取代命令(Replace Command with Function)
1472 |
1473 | 反向重构:以命令取代函数(337)
1474 |
1475 | ```js
1476 | class ChargeCalculator {
1477 | constructor(customer, usage) {
1478 | this._customer = customer;
1479 | this._usage = usage;
1480 | }
1481 | execute() {
1482 | return this._customer.rate * this._usage;
1483 | }
1484 | }
1485 |
1486 | function charge(customer, usage) {
1487 | return customer.rate * usage;
1488 | }
1489 | ```
1490 |
1491 | ### 动机
1492 |
1493 | 命令对象为处理复杂计算提供了强大的机制。借助命令对象,可以轻松地将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建。但这种强大是有代价的。大多数时候,我只是想调用一个函数,让它完成自己的工作就好。如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。
1494 |
1495 | ### 做法
1496 |
1497 | 运用提炼函数(106),把“创建并执行命令对象”的代码单独提炼到一个函数中。
1498 |
1499 | 这一步会新建一个函数,最终这个函数会取代现在的命令对象。
1500 |
1501 | 对命令对象在执行阶段用到的函数,逐一使用内联函数(115)。
1502 |
1503 | 如果被调用的函数有返回值,请先对调用处使用提炼变量(119),然后再使用内联函数(115)。
1504 |
1505 | 使用改变函数声明(124),把构造函数的参数转移到执行函数。
1506 |
1507 | 对于所有的字段,在执行函数中找到引用它们的地方,并改为使用参数。每次修改后都要测试。
1508 |
1509 | 把“调用构造函数”和“调用执行函数”两步都内联到调用方(也就是最终要替换命令对象的那个函数)。
1510 |
1511 | 测试。
1512 |
1513 | 用移除死代码(237)把命令类消去。
1514 |
1515 | ### 范例
1516 |
1517 | 假设我有一个很小的命令对象。
1518 |
1519 | ```js
1520 | class ChargeCalculator {
1521 | constructor(customer, usage, provider) {
1522 | this._customer = customer;
1523 | this._usage = usage;
1524 | this._provider = provider;
1525 | }
1526 | get baseCharge() {
1527 | return this._customer.baseRate * this._usage;
1528 | }
1529 | get charge() {
1530 | return this.baseCharge + this._provider.connectionCharge;
1531 | }
1532 | }
1533 | ```
1534 |
1535 | 使用方的代码如下。
1536 |
1537 | #### 调用方...
1538 |
1539 | ```js
1540 | monthCharge = new ChargeCalculator(customer, usage, provider).charge;
1541 | ```
1542 |
1543 | 命令类足够小、足够简单,变成函数更合适。
1544 |
1545 | 首先,我用提炼函数(106)把命令对象的创建与调用过程包装到一个函数中。
1546 |
1547 | #### 调用方...
1548 |
1549 | ```js
1550 | monthCharge = charge(customer, usage, provider);
1551 | ```
1552 |
1553 | #### 顶层作用域...
1554 |
1555 | ```js
1556 | function charge(customer, usage, provider) {
1557 | return new ChargeCalculator(customer, usage, provider).charge;
1558 | }
1559 | ```
1560 |
1561 | 接下来要考虑如何处理支持函数(也就是这里的 baseCharge 函数)。对于有返回值的函数,我一般会先用提炼变量(119)把返回值提炼出来。
1562 |
1563 | #### class ChargeCalculator...
1564 |
1565 | ```js
1566 | get baseCharge() {
1567 | return this._customer.baseRate * this._usage;
1568 | }
1569 | get charge() {
1570 | const baseCharge = this.baseCharge;
1571 | return baseCharge + this._provider.connectionCharge;
1572 | }
1573 | ```
1574 |
1575 | 然后对支持函数使用内联函数(115)。
1576 |
1577 | #### class ChargeCalculator...
1578 |
1579 | ```js
1580 | get charge() {
1581 | const baseCharge = this._customer.baseRate * this._usage;
1582 | return baseCharge + this._provider.connectionCharge;
1583 | }
1584 | ```
1585 |
1586 | 现在所有逻辑处理都集中到一个函数了,下一步是把构造函数传入的数据移到主函数。首先用改变函数声明(124)把构造函数的参数逐一添加到 charge 函数上。
1587 |
1588 | #### class ChargeCalculator...
1589 |
1590 | ```js
1591 | constructor (customer, usage, provider){
1592 | this._customer = customer;
1593 | this._usage = usage;
1594 | this._provider = provider;
1595 | }
1596 |
1597 | charge(customer, usage, provider) {
1598 | const baseCharge = this._customer.baseRate * this._usage;
1599 | return baseCharge + this._provider.connectionCharge;
1600 | }
1601 | ```
1602 |
1603 | #### 顶层作用域...
1604 |
1605 | ```js
1606 | function charge(customer, usage, provider) {
1607 | return new ChargeCalculator(customer, usage, provider).charge(
1608 | customer,
1609 | usage,
1610 | provider
1611 | );
1612 | }
1613 | ```
1614 |
1615 | 然后修改 charge 函数的实现,改为使用传入的参数。这个修改可以小步进行,每次使用一个参数。
1616 |
1617 | #### class ChargeCalculator...
1618 |
1619 | ```js
1620 | constructor (customer, usage, provider){
1621 | this._customer = customer;
1622 | this._usage = usage;
1623 | this._provider = provider;
1624 | }
1625 |
1626 | charge(customer, usage, provider) {
1627 | const baseCharge = customer.baseRate * this._usage;
1628 | return baseCharge + this._provider.connectionCharge;
1629 | }
1630 | ```
1631 |
1632 | 构造函数中对 `this._customer` 字段的赋值不删除也没关系,因为反正没人使用这个字段。但我更愿意去掉这条赋值语句,因为去掉它以后,如果在函数实现中漏掉了一处对字段的使用没有修改,测试就会失败。(如果我真的犯了这个错误而测试没有失败,我就应该考虑增加测试了。)
1633 |
1634 | 其他参数也如法炮制,直到 charge 函数不再使用任何字段:
1635 |
1636 | #### class ChargeCalculator...
1637 |
1638 | ```js
1639 | charge(customer, usage, provider) {
1640 | const baseCharge = customer.baseRate * usage;
1641 | return baseCharge + provider.connectionCharge;
1642 | }
1643 | ```
1644 |
1645 | 现在我就可以把所有逻辑都内联到顶层的 charge 函数中。这是内联函数(115)的一种特殊情况,我需要把构造函数和执行函数一并内联。
1646 |
1647 | #### 顶层作用域...
1648 |
1649 | ```js
1650 | function charge(customer, usage, provider) {
1651 | const baseCharge = customer.baseRate * usage;
1652 | return baseCharge + provider.connectionCharge;
1653 | }
1654 | ```
1655 |
1656 | 现在命令类已经是死代码了,可以用移除死代码(237)给它一个体面的葬礼。
1657 |
--------------------------------------------------------------------------------
/docs/ch2.md:
--------------------------------------------------------------------------------
1 | # 第 2 章 重构的原则
2 |
3 | 前一章所举的例子应该已经让你对重构有了一个良好的感觉。现在,我们应该回头看看重构的一些大原则。
4 |
5 | ## 2.1 何谓重构
6 |
7 | 一线的实践者们经常很随意地使用“重构”这个词——软件开发领域的很多词汇都有此待遇。我使用这个词的方式比较严谨,并且我发现这种严谨的方式很有好处。(下列定义与本书第 1 版中给出的定义一样。)“重构”这个词既可以用作名词也可以用作动词。名词形式的定义是:
8 |
9 | 重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
10 |
11 | 这个定义适用于我在前面的例子中提到的那些有名字的重构,例如提炼函数(106)和以多态取代条件表达式(272)。
12 |
13 | 动词形式的定义是:
14 |
15 | 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
16 |
17 | 所以,我可能会花一两个小时进行重构(动词),其间我会使用几十个不同的重构(名词)。
18 |
19 | 过去十几年,这个行业里的很多人用“重构”这个词来指代任何形式的代码清理,但上面的定义所指的是一种特定的清理代码的方式。重构的关键在于运用大量微小且保持软件行为的步骤,一步步达成大规模的修改。每个单独的重构要么很小,要么由若干小步骤组合而成。因此,在重构的过程中,我的代码很少进入不可工作的状态,即便重构没有完成,我也可以在任何时刻停下来。
20 |
21 | ::: tip
22 | 如果有人说他们的代码在重构过程中有一两天时间不可用,基本上可以确定,他们在做的事不是重构。
23 | :::
24 |
25 | 我会用“结构调整”(restructuring)来泛指对代码库进行的各种形式的重新组织或清理,重构则是特定的一类结构调整。刚接触重构的人看我用很多小步骤完成似乎可以一大步就能做完的事,可能会觉得这样很低效。但小步前进能让我走得更快,因为这些小步骤能完美地彼此组合,而且——更关键的是——整个过程中我不会花任何时间来调试。
26 |
27 | 在上述定义中,我用了“可观察行为”的说法。它的意思是,整体而言,经过重构之后的代码所做的事应该与重构之前大致一样。这个说法并非完全严格,并且我是故意保留这点儿空间的:重构之后的代码不一定与重构前行为完全一致。比如说,提炼函数(106)会改变函数调用栈,因此程序的性能就会有所改变;改变函数声明(124)和搬移函数(198)等重构经常会改变模块的接口。不过就用户应该关心的行为而言,不应该有任何改变。如果我在重构过程中发现了任何 bug,重构完成后同样的 bug 应该仍然存在(不过,如果潜在的 bug 还没有被任何人发现,也可以当即把它改掉)。
28 |
29 | 重构与性能优化有很多相似之处:两者都需要修改代码,并且两者都不会改变程序的整体功能。两者的差别在于其目的:重构是为了让代码“更容易理解,更易于修改”。这可能使程序运行得更快,也可能使程序运行得更慢。在性能优化时,我只关心让程序运行得更快,最终得到的代码有可能更难理解和维护,对此我有心理准备。
30 |
31 | ## 2.2 两顶帽子
32 |
33 | Kent Beck 提出了“两顶帽子”的比喻。使用重构技术开发软件时,我把自己的时间分配给两种截然不同的行为:添加新功能和重构。添加新功能时,我不应该修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,我可以衡量自己的工作进度。重构时我就不能再添加功能,只管调整代码的结构。此时我不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试。
34 |
35 | 软件开发过程中,我可能会发现自己经常变换帽子。首先我会尝试添加新功能,然后会意识到:如果把程序结构改一下,功能的添加会容易得多。于是我换一顶帽子,做一会儿重构工作。程序结构调整好后,我又换上原先的帽子,继续添加新功能。新功能正常工作后,我又发现自己的编码造成程序难以理解,于是又换上重构帽子……整个过程或许只花 10 分钟,但无论何时我都清楚自己戴的是哪一顶帽子,并且明白不同的帽子对编程状态提出的不同要求。
36 |
37 | ## 2.3 为何重构
38 |
39 | 我不想把重构说成是包治百病的万灵丹,它绝对不是所谓的“银弹”。不过它的确很有价值,尽管它不是一颗“银弹”,却可以算是一把“银钳子”,可以帮你始终良好地控制自己的代码。重构是一个工具,它可以(并且应该)用于以下几个目的。
40 |
41 | ### 重构改进软件的设计
42 |
43 | 如果没有重构,程序的内部设计(或者叫架构)会逐渐腐败变质。当人们只为短期目的而修改代码时,他们经常没有完全理解架构的整体设计,于是代码逐渐失去了自己的结构。程序员越来越难通过阅读源码来理解原来的设计。代码结构的流失有累积效应。越难看出代码所代表的设计意图,就越难保护其设计,于是设计就腐败得越快。经常性的重构有助于代码维持自己该有的形态。
44 |
45 | 完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是消除重复代码。代码量减少并不会使系统运行更快,因为这对程序的资源占用几乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。代码越多,做正确的修改就越困难,因为有更多代码需要理解。我在这里做了点儿修改,系统却不如预期那样工作,因为我没有修改另一处——那里的代码做着几乎完全一样的事情,只是所处环境略有不同。消除重复代码,我就可以确定所有事物和行为在代码中只表述一次,这正是优秀设计的根本。
46 |
47 | ### 重构使软件更容易理解
48 |
49 | 所谓程序设计,很大程度上就是与计算机对话:我编写代码告诉计算机做什么事,而它的响应是按照我的指示精确行动。一言以蔽之,我所做的就是填补“我想要它做什么”和“我告诉它做什么”之间的缝隙。编程的核心就在于“准确说出我想要的”。然而别忘了,除了计算机外,源码还有其他读者:几个月之后可能会有另一位程序员尝试读懂我的代码并对其做一些修改。我们很容易忘记这这位读者,但他才是最重要的。计算机是否多花了几个时钟周期来编译,又有什么关系呢?如果一个程序员花费一周时间来修改某段代码,那才要命呢——如果他理解了我的代码,这个修改原本只需一小时。
50 |
51 | 问题在于,当我努力让程序运转的时候,我不会想到未来出现的那个开发者。是的,我们应该改变一下开发节奏,让代码变得更易于理解。重构可以帮我让代码更易读。开始进行重构前,代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的意图——更清晰地说出我想要做的。
52 |
53 | 关于这一点,我没必要表现得多么无私。很多时候那个未来的开发者就是我自己。此时重构就显得尤其重要了。我是一个很懒惰的程序员,我的懒惰表现形式之一就是:总是记不住自己写过的代码。事实上,对于任何能够立刻查阅的东西,我都故意不去记它,因为我怕把自己的脑袋塞爆。我总是尽量把该记住的东西写进代码里,这样我就不必记住它了。这么一来,下班后我还可以喝上两杯 Maudite 啤酒,不必太担心它杀光我的脑细胞。
54 |
55 | ### 重构帮助找到 bug
56 |
57 | 对代码的理解,可以帮我找到 bug。我承认我不太擅长找 bug。有些人只要盯着一大段代码就可以找出里面的 bug,我不行。但我发现,如果对代码进行重构,我就可以深入理解代码的所作所为,并立即把新的理解反映在代码当中。搞清楚程序结构的同时,我也验证了自己所做的一些假设,于是想不把 bug 揪出来都难。
58 |
59 | 这让我想起了 Kent Beck 经常形容自己的一句话:“我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的程序员。”重构能够帮助我更有效地写出健壮的代码。
60 |
61 | ### 重构提高编程速度
62 |
63 | 最后,前面的一切都归结到了这一点:重构帮我更快速地开发程序。
64 |
65 | 听起来有点儿违反直觉。当我谈到重构时,人们很容易看出它能够提高质量。改善设计、提升可读性、减少 bug,这些都能提高质量。但花在重构上的时间,难道不是在降低开发速度吗?
66 |
67 | 当我跟那些在一个系统上工作较长时间的软件开发者交谈时,经常会听到这样的故事:一开始他们进展很快,但如今想要添加一个新功能需要的时间就要长得多。他们需要花越来越多的时间去考虑如何把新功能塞进现有的代码库,不断蹦出来的 bug 修复起来也越来越慢。代码库看起来就像补丁摞补丁,需要细致的考古工作才能弄明白整个系统是如何工作的。这份负担不断拖慢新增功能的速度,到最后程序员恨不得从头开始重写整个系统。
68 |
69 | 下面这幅图可以描绘他们经历的困境。
70 |
71 | 
72 |
73 | 但有些团队的境遇则截然不同。他们添加新功能的速度越来越快,因为他们能利用已有的功能,基于已有的功能快速构建新功能。
74 |
75 | 
76 |
77 | 两种团队的区别就在于软件的内部质量。需要添加新功能时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入 bug 的可能性就会变小,即使引入了 bug,调试也会容易得多。理想情况下,我的代码库会逐步演化成一个平台,在其上可以很容易地构造与其领域相关的新功能。
78 |
79 | 我把这种现象称为“设计耐久性假说”:通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。我还无法科学地证明这个理论,所以我说它是一个“假说”。但我的经验,以及我在职业生涯中认识的上百名优秀程序员的经验,都支持这个假说。
80 |
81 | 20 年前,行业的陈规认为:良好的设计必须在开始编程之前完成,因为一旦开始编写代码,设计就只会逐渐腐败。重构改变了这个图景。现在我们可以改善已有代码的设计,因此我们可以先做一个设计,然后不断改善它,哪怕程序本身的功能也在不断发生着变化。由于预先做出良好的设计非常困难,想要既体面又快速地开发功能,重构必不可少。
82 |
83 | ## 2.4 何时重构
84 |
85 | 在我编程的每个小时,我都会做重构。有几种方式可以把重构融入我的工作过程里。
86 |
87 | ::: tip
88 | 三次法则
89 |
90 | Don Roberts 给了我一条准则:第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。
91 |
92 | 正如老话说的:事不过三,三则重构。
93 | :::
94 |
95 | ### 预备性重构:让添加新功能更容易
96 |
97 | 重构的最佳时机就在添加新功能之前。在动手添加新功能之前,我会看看现有的代码库,此时经常会发现:如果对代码结构做一点微调,我的工作会容易得多。也许已经有个函数提供了我需要的大部分功能,但有几个字面量的值与我的需要略有冲突。如果不做重构,我可能会把整个函数复制过来,修改这几个值,但这就会导致重复代码——如果将来我需要做修改,就必须同时修改两处(更麻烦的是,我得先找到这两处)。而且,如果将来我还需要一个类似又略有不同的功能,就只能再复制粘贴一次,这可不是个好主意。所以我戴上重构的帽子,使用函数参数化(310)。做完这件事以后,接下来我就只需要调用这个函数,传入我需要的参数。
98 |
99 | ::: tip
100 | 这就好像我要往东去 100 公里。我不会往东一头把车开进树林,而是先往北开 20 公里上高速,然后再向东开 100 公里。后者的速度比前者要快上 3 倍。如果有人催着你“赶快直接去那儿”,有时你需要说:“等等,我要先看看地图,找出最快的路径。”这就是预备性重构于我的意义。
101 |
102 | ——Jessica Kerr
103 | :::
104 |
105 | 修复 bug 时的情况也是一样。在寻找问题根因时,我可能会发现:如果把 3 段一模一样且都会导致错误的代码合并到一处,问题修复起来会容易得多。或者,如果把某些更新数据的逻辑与查询逻辑分开,会更容易避免造成错误的逻辑纠缠。用重构改善这些情况,在同样场合再次出现同样 bug 的概率也会降低。
106 |
107 | ### 帮助理解的重构:使代码更易懂
108 |
109 | 我需要先理解代码在做什么,然后才能着手修改。这段代码可能是我写的,也可能是别人写的。一旦我需要思考“这段代码到底在做什么”,我就会自问:能不能重构这段代码,令其一目了然?我可能看见了一段结构糟糕的条件逻辑,也可能希望复用一个函数,但花费了几分钟才弄懂它到底在做什么,因为它的函数命名实在是太糟糕了。这些都是重构的机会。
110 |
111 | 看代码时,我会在脑海里形成一些理解,但我的记性不好,记不住那么多细节。正如 Ward Cunningham 所说,通过重构,我就把脑子里的理解转移到了代码本身。随后我运行这个软件,看它是否正常工作,来检查这些理解是否正确。如果把对代码的理解植入代码中,这份知识会保存得更久,并且我的同事也能看到。
112 |
113 | 重构带来的帮助不仅发生在将来——常常是立竿见影。我会先在一些小细节上使用重构来帮助理解,给一两个变量改名,让它们更清楚地表达意图,以方便理解,或是将一个长函数拆成几个小函数。当代码变得更清晰一些时,我就会看见之前看不见的设计问题。如果不做前面的重构,我可能永远都看不见这些设计问题,因为我不够聪明,无法在脑海中推演所有这些变化。Ralph Johnson 说,这些初步的重构就像扫去窗上的尘埃,使我们得以看到窗外的风景。在研读代码时,重构会引领我获得更高层面的理解,如果只是阅读代码很难有此领悟。有些人以为这些重构只是毫无意义地把玩代码,他们没有意识到,缺少了这些细微的整理,他们就无法看到隐藏在一片混乱背后的机遇。
114 |
115 | ### 捡垃圾式重构
116 |
117 | 帮助理解的重构还有一个变体:我已经理解代码在做什么,但发现它做得不好,例如逻辑不必要地迂回复杂,或者两个函数几乎完全相同,可以用一个参数化的函数取而代之。这里有一个取舍:我不想从眼下正要完成的任务上跑题太多,但我也不想把垃圾留在原地,给将来的修改增加麻烦。如果我发现的垃圾很容易重构,我会马上重构它;如果重构需要花一些精力,我可能会拿一张便笺纸把它记下来,完成当下的任务再回来重构它。
118 |
119 | 当然,有时这样的垃圾需要好几个小时才能解决,而我又有更紧急的事要完成。不过即便如此,稍微花一点工夫做一点儿清理,通常都是值得的。正如野营者的老话所说:至少要让营地比你到达时更干净。如果每次经过这段代码时都把它变好一点点,积少成多,垃圾总会被处理干净。重构的妙处就在于,每个小步骤都不会破坏代码——所以,有时一块垃圾在好几个月之后才终于清理干净,但即便每次清理并不完整,代码也不会被破坏。
120 |
121 | ### 有计划的重构和见机行事的重构
122 |
123 | 上面的例子——预备性重构、帮助理解的重构、捡垃圾式重构——都是见机行事的:我并不专门安排一段时间来重构,而是在添加功能或修复 bug 的同时顺便重构。这是我自然的编程流的一部分。不管是要添加功能还是修复 bug,重构对我当下的任务有帮助,而且让我未来的工作更轻松。这是一件很重要而又常被误解的事:重构不是与编程割裂的行为。你不会专门安排时间重构,正如你不会专门安排时间写 if 语句。我的项目计划上没有专门留给重构的时间,绝大多数重构都在我做其他事的过程中自然发生。
124 |
125 | ::: tip
126 | 肮脏的代码必须重构,但漂亮的代码也需要很多重构。
127 | :::
128 |
129 | 还有一种常见的误解认为,重构就是人们弥补过去的错误或者清理肮脏的代码。当然,如果遇上了肮脏的代码,你必须重构,但漂亮的代码也需要很多重构。在写代码时,我会做出很多权衡取舍:参数化需要做到什么程度?函数之间的边界应该划在哪里?对于昨天的功能完全合理的权衡,在今天要添加新功能时可能就不再合理。好在,当我需要改变这些权衡以反映现实情况的变化时,整洁的代码重构起来会更容易。
130 |
131 | ::: tip
132 | 每次要修改时,首先令修改很容易(警告:这件事有时会很难),然后再进行这次容易的修改。
133 |
134 | ——Kent Beck
135 | :::
136 |
137 | 长久以来,人们认为编写软件是一个累加的过程:要添加新功能,我们就应该增加新代码。但优秀的程序员知道,添加新功能最快的方法往往是先修改现有的代码,使新功能容易被加入。所以,软件永远不应该被视为“完成”。每当需要新能力时,软件就应该做出相应的改变。越是在已有代码中,这样的改变就越显重要。
138 |
139 | 不过,说了这么多,并不表示有计划的重构总是错的。如果团队过去忽视了重构,那么常常会需要专门花一些时间来优化代码库,以便更容易添加新功能。在重构上花一个星期的时间,会在未来几个月里发挥价值。有时,即便团队做了日常的重构,还是会有问题在某个区域逐渐累积长大,最终需要专门花些时间来解决。但这种有计划的重构应该很少,大部分重构应该是不起眼的、见机行事的。
140 |
141 | 我听过的一条建议是:将重构与添加新功能在版本控制的提交中分开。这样做的一大好处是可以各自独立地审阅和批准这些提交。但我并不认同这种做法。重构常常与新添功能紧密交织,不值得花工夫把它们分开。并且这样做也使重构脱离了上下文,使人看不出这些“重构提交”的价值。每个团队应该尝试并找出适合自己的工作方式,只是要记住:分离重构提交并不是毋庸置疑的原则,只有当你真的感到有益时,才值得这样做。
142 |
143 | ### 长期重构
144 |
145 | 大多数重构可以在几分钟——最多几小时——内完成。但有一些大型的重构可能要花上几个星期,例如要替换一个正在使用的库,或者将整块代码抽取到一个组件中并共享给另一支团队使用,再或者要处理一大堆混乱的依赖关系,等等。
146 |
147 | 即便在这样的情况下,我仍然不愿让一支团队专门做重构。可以让整个团队达成共识,在未来几周时间里逐步解决这个问题,这经常是一个有效的策略。每当有人靠近“重构区”的代码,就把它朝想要改进的方向推动一点。这个策略的好处在于,重构不会破坏代码——每次小改动之后,整个系统仍然照常工作。例如,如果想替换掉一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个库的接口。一旦调用方已经完全改为使用这层抽象,替换下面的库就会容易得多。(这个策略叫作 Branch By Abstraction[mf-bba]。)
148 |
149 | ### 复审代码时重构
150 |
151 | 一些公司会做常规的代码复审(code review),因为这种活动可以改善开发状况。代码复审有助于在开发团队中传播知识,也有助于让较有经验的开发者把知识传递给比较欠缺经验的人,并帮助更多人理解大型软件系统中的更多部分。代码复审对于编写清晰代码也很重要。我的代码也许对我自己来说很清晰,对他人则不然。这是无法避免的,因为要让开发者设身处地为那些不熟悉自己所作所为的人着想,实在太困难了。代码复审也让更多人有机会提出有用的建议,毕竟我在一个星期之内能够想出的好点子很有限。如果能得到别人的帮助,我的生活会滋润得多,所以我总是期待更多复审。
152 |
153 | 我发现,重构可以帮助我复审别人的代码。开始重构前我可以先阅读代码,得到一定程度的理解,并提出一些建议。一旦想到一些点子,我就会考虑是否可以通过重构立即轻松地实现它们。如果可以,我就会动手。这样做了几次以后,我可以更清楚地看到,当我的建议被实施以后,代码会是什么样。我不必想象代码应该是什么样,我可以真实看见。于是我可以获得更高层次的认识。如果不进行重构,我永远无法得到这样的认识。
154 |
155 | 重构还可以帮助代码复审工作得到更具体的结果。不仅获得建议,而且其中许多建议能够立刻实现。最终你将从实践中得到比以往多得多的成就感。
156 |
157 | 至于如何在代码复审的过程中加入重构,这要取决于复审的形式。在常见的 pull request 模式下,复审者独自浏览代码,代码的作者不在旁边,此时进行重构效果并不好。如果代码的原作者在旁边会好很多,因为作者能提供关于代码的上下文信息,并且充分认同复审者进行修改的意图。对我个人而言,与原作者肩并肩坐在一起,一边浏览代码一边重构,体验是最佳的。这种工作方式很自然地导向结对编程:在编程的过程中持续不断地进行代码复审。
158 |
159 | ### 怎么对经理说
160 |
161 | “该怎么跟经理说重构的事?”这是我最常被问到的一个问题。毋庸讳言,我见过一些场合,“重构”被视为一个脏词——经理(和客户)认为重构要么是在弥补过去犯下的错误,要么是不增加价值的无用功。如果团队又计划了几周时间专门做重构,情况就更糟糕了——如果他们做的其实还不是重构,而是不加小心的结构调整,然后又对代码库造成了破坏,那可就真是糟透了。
162 |
163 | 如果这位经理懂技术,能理解“设计耐久性假说”,那么向他说明重构的意义应该不会很困难。这样的经理应该会鼓励日常的重构,并主动寻找团队日常重构做得不够的征兆。虽然“团队做了太多重构”的情况确实也发生过,但比起做得不够的情况要罕见得多了。
164 |
165 | 当然,很多经理和客户不具备这样的技术意识,他们不理解代码库的健康对生产率的影响。这种情况下我会给团队一个较有争议的建议:不要告诉经理!
166 |
167 | 这是在搞破坏吗?我不这样想。软件开发者都是专业人士。我们的工作就是尽可能快速创造出高效软件。我的经验告诉我,对于快速创造软件,重构可带来巨大帮助。如果需要添加新功能,而原本设计却又使我无法方便地修改,我发现先重构再添加新功能会更快些。如果要修补错误,就得先理解软件的工作方式,而我发现重构是理解软件的最快方式。受进度驱动的经理要我尽可能快速完成任务,至于怎么完成,那就是我的事了。我领这份工资,是因为我擅长快速实现新功能;我认为最快的方式就是重构,所以我就重构。
168 |
169 | ### 何时不应该重构
170 |
171 | 听起来好像我一直在提倡重构,但确实有一些不值得重构的情况。
172 |
173 | 如果我看见一块凌乱的代码,但并不需要修改它,那么我就不需要重构它。如果丑陋的代码能被隐藏在一个 API 之下,我就可以容忍它继续保持丑陋。只有当我需要理解其工作原理时,对其进行重构才有价值。
174 |
175 | 另一种情况是,如果重写比重构还容易,就别重构了。这是个困难的决定。如果不花一点儿时间尝试,往往很难真实了解重构一块代码的难度。决定到底应该重构还是重写,需要良好的判断力与丰富的经验,我无法给出一条简单的建议。
176 |
177 | ## 2.5 重构的挑战
178 |
179 | 每当有人大力推荐一种技术、工具或者架构时,我总是会观察这东西会遇到哪些挑战,毕竟生活中很少有晴空万里的好事。你需要了解一件事背后的权衡取舍,才能决定何时何地应用它。我认为重构是一种很有价值的技术,大多数团队都应该更多地重构,但它也不是完全没有挑战的。有必要充分了解重构会遇到的挑战,这样才能做出有效应对。
180 |
181 | ### 延缓新功能开发
182 |
183 | 如果你读了前面一小节,我对这个挑战的回应便已经很清楚了。尽管重构的目的是加快开发速度,但是,仍旧很多人认为,花在重构的时间是在拖慢新功能的开发进度。“重构会拖慢进度”这种看法仍然很普遍,这可能是导致人们没有充分重构的最大阻力所在。
184 |
185 | ::: tip
186 | 重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。
187 | :::
188 |
189 | 有一种情况确实需要权衡取舍。我有时会看到一个(大规模的)重构很有必要进行,而马上要添加的功能非常小,这时我会更愿意先把新功能加上,然后再做这次大规模重构。做这个决定需要判断力——这是我作为程序员的专业能力之一。我很难描述决定的过程,更无法量化决定的依据。
190 |
191 | 我清楚地知道,预备性重构常会使修改更容易,所以如果做一点儿重构能让新功能实现更容易,我一定会做。如果一个问题我已经见过,此时我也会更倾向于重构它——有时我就得先看见一块丑陋的代码几次,然后才能提起劲头来重构它。也就是说,如果一块代码我很少触碰,它不会经常给我带来麻烦,那么我就倾向于不去重构它。如果我还没想清楚究竟应该如何优化代码,那么我可能会延迟重构;当然,有的时候,即便没想清楚优化的方向,我也会先做些实验,试试看能否有所改进。
192 |
193 | 我从同事那里听到的证据表明,在我们这个行业里,重构不足的情况远多于重构过度的情况。换句话说,绝大多数人应该尝试多做重构。代码库的健康与否,到底会对生产率造成多大的影响,很多人可能说不出来,因为他们没有太多在健康的代码库上工作的经历——轻松地把现有代码组合配置,快速构造出复杂的新功能,这种强大的开发方式他们没有体验过。
194 |
195 | 虽然我们经常批评管理者以“保障开发速度”的名义压制重构,其实程序员自己也经常这么干。有时他们自己觉得不应该重构,其实他们的领导还挺希望他们做一些重构的。如果你是一支团队的技术领导,一定要向团队成员表明,你重视改善代码库健康的价值。合理判断何时应该重构、何时应该暂时不重构,这样的判断力需要多年经验积累。对于重构缺乏经验的年轻人需要有意的指导,才能帮助他们加速经验积累的过程。
196 |
197 | 有些人试图用“整洁的代码”“良好的工程实践”之类道德理由来论证重构的必要性,我认为这是个陷阱。重构的意义不在于把代码库打磨得闪闪发光,而是纯粹经济角度出发的考量。我们之所以重构,因为它能让我们更快——添加功能更快,修复 bug 更快。一定要随时记住这一点,与别人交流时也要不断强调这一点。重构应该总是由经济利益驱动。程序员、经理和客户越理解这一点,“好的设计”那条曲线就会越经常出现。
198 |
199 | ### 代码所有权
200 |
201 | 很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系。比如我想给一个函数改名,并且我也能找到该函数的所有调用者,那么我只需运用改变函数声明(124),在一次重构中修改函数声明和调用者。但即便这么简单的一个重构,有时也无法实施:调用方代码可能由另一支团队拥有,而我没有权限写入他们的代码库;这个函数可能是一个提供给客户的 API,这时我根本无法知道是否有人使用它,至于谁在用、用得有多频繁就更是一无所知。这样的函数属于已发布接口(published interface):接口的使用者(客户端)与声明者彼此独立,声明者无权修改使用者的代码。
202 |
203 | 代码所有权的边界会妨碍重构,因为一旦我自作主张地修改,就一定会破坏使用者的程序。这不会完全阻止重构,我仍然可以做很多重构,但确实会对重构造成约束。为了给一个函数改名,我需要使用函数改名(124),但同时也得保留原来的函数声明,使其把调用传递给新的函数。这会让接口变复杂,但这就是为了避免破坏使用者的系统而不得不付出的代价。我可以把旧的接口标记为“不推荐使用”(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去。
204 |
205 | 由于这些复杂性,我建议不要搞细粒度的强代码所有制。有些组织喜欢给每段代码都指定唯一的所有者,只有这个人能修改这段代码。我曾经见过一支只有三个人的团队以这种方式运作,每个程序员都要给另外两人发布接口,随之而来的就是接口维护的种种麻烦。如果这三个人都直接去代码库里做修改,事情会简单得多。我推荐团队代码所有制,这样一支团队里的成员都可以修改这个团队拥有的代码,即便最初写代码的是别人。程序员可能各自分工负责系统的不同区域,但这种责任应该体现为监控自己责任区内发生的修改,而不是简单粗暴地禁止别人修改。
206 |
207 | 这种较为宽容的代码所有制甚至可以应用于跨团队的场合。有些团队鼓励类似于开源的模型:B 团队的成员也可以在一个分支上修改 A 团队的代码,然后把提交发送给 A 团队去审核。这样一来,如果团队想修改自己的函数,他们就可以同时修改该函数的客户端的代码;只要客户端接受了他们的修改,就可以删掉旧的函数声明了。对于涉及多个团队的大系统开发,在“强代码所有制”和“混乱修改”两个极端之间,这种类似开源的模式常常是一个合适的折中。
208 |
209 | ### 分支
210 |
211 | 很多团队采用这样的版本控制实践:每个团队成员各自在代码库的一条分支上工作,进行相当大量的开发之后,才把各自的修改合并回主线分支(这条分支通常叫 master 或 trunk),从而与整个团队分享。常见的做法是在分支上开发完整的功能,直到功能可以发布到生产环境,才把该分支合并回主线。这种做法的拥趸声称,这样能保持主线不受尚未完成的代码侵扰,能保留清晰的功能添加的版本记录,并且在某个功能出问题时能容易地撤销修改。
212 |
213 | 这样的特性分支有其缺点。在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支。但如果有几个人同时在各自的特性分支上工作,这个办法并不能真正解决问题,因为合并与集成是两回事。如果我从主线合并到我的分支,这只是一个单向的代码移动——我的分支发生了修改,但主线并没有。而“集成”是一个双向的过程:不仅要把主线的修改拉(pull)到我的分支上,而且要把我这里修改的结果推(push)回到主线上,两边都会发生修改。假如另一名程序员 Rachel 正在她的分支上开发,我是看不见她的修改的,直到她将自己的修改与主线集成;此时我就必须把她的修改合并到我的特性分支,这可能需要相当的工作量。其中困难的部分是处理语义变化。现代版本控制系统都能很好地合并程序文本的复杂修改,但对于代码的语义它们一无所知。如果我修改了一个函数的名字,版本控制工具可以很轻松地将我的修改与 Rachel 的代码集成。但如果在集成之前,她在自己的分支里新添调用了这个被我改名的函数,集成之后的代码就会被破坏。
214 |
215 | 分支合并本来就是一个复杂的问题,随着特性分支存在的时间加长,合并的难度会指数上升。集成一个已经存在了 4 个星期的分支,较之集成存在了 2 个星期的分支,难度可不止翻倍。所以很多人认为,应该尽量缩短特性分支的生存周期,比如只有一两天。还有一些人(比如我本人)认为特性分支的生命还应该更短,我们采用的方法叫作持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)。在使用 CI 时,每个团队成员每天至少向主线集成一次。这个实践避免了任何分支彼此差异太大,从而极大地降低了合并的难度。不过 CI 也有其代价:你必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,还必须使用特性开关(feature toggle,也叫特性旗标,feature flag)将尚未完成又无法拆小的功能隐藏掉。
216 |
217 | CI 的粉丝之所以喜欢这种工作方式,部分原因是它降低了分支合并的难度,不过最重要的原因还是 CI 与重构能良好配合。重构经常需要对代码库中的很多地方做很小的修改(例如给一个广泛使用的函数改名),这样的修改尤其容易造成合并时的语义冲突。采用特性分支的团队常会发现重构加剧了分支合并的困难,并因此放弃了重构,这种情况我们曾经见过多次。CI 和重构能够良好配合,所以 Kent Beck 在极限编程中同时包含了这两个实践。
218 |
219 | 我并不是在说绝不应该使用特性分支。如果特性分支存在的时间足够短,它们就不会造成大问题。(实际上,使用 CI 的团队往往同时也使用分支,但他们会每天将分支与主线合并。)对于开源项目,特性分支可能是合适的做法,因为不时会有你不熟悉(因此也不信任)的程序员偶尔提交修改。但对全职的开发团队而言,特性分支对重构的阻碍太严重了。即便你没有完全采用 CI,我也一定会催促你尽可能频繁地集成。而且,用上 CI 的团队在软件交付上更加高效,我真心希望你认真考虑这个客观事实[Forsgren et al]。
220 |
221 | ### 测试
222 |
223 | 不会改变程序可观察的行为,这是重构的一个重要特征。如果仔细遵循重构手法的每个步骤,我应该不会破坏任何东西,但万一我犯了个错误怎么办?(呃,就我这个粗心大意的性格来说,请去掉“万一”两字。)人总会有出错的时候,不过只要及时发现,就不会造成大问题。既然每个重构都是很小的修改,即便真的造成了破坏,我也只需要检查最后一步的小修改——就算找不到出错的原因,只要回滚到版本控制中最后一个可用的版本就行了。
224 |
225 | 这里的关键就在于“快速发现错误”。要做到这一点,我的代码应该有一套完备的测试套件,并且运行速度要快,否则我会不愿意频繁运行它。也就是说,绝大多数情况下,如果想要重构,我得先有可以自测试的代码[mf-stc]。
226 |
227 | 有些读者可能会觉得,“自测试的代码”这个要求太高,根本无法实现。但在过去 20 年中,我看到很多团队以这种方式构造软件。的确,团队必须投入时间与精力在测试上,但收益是绝对划算的。自测试的代码不仅使重构成为可能,而且使添加新功能更加安全,因为我可以很快发现并干掉新近引入的 bug。这里的关键在于,一旦测试失败,我只需要查看上次测试成功运行之后修改的这部分代码;如果测试运行得很频繁,这个查看的范围就只有几行代码。知道必定是这几行代码造成 bug 的话,排查起来会容易得多。
228 |
229 | 这也回答了“重构风险太大,可能引入 bug”的担忧。如果没有自测试的代码,这种担忧就是完全合理的,这也是为什么我如此重视可靠的测试。
230 |
231 | 缺乏测试的问题可以用另一种方式来解决。如果我的开发环境很好地支持自动化重构,我就可以信任这些重构,不必运行测试。这时即便没有完备的测试套件,我仍然可以重构,前提是仅仅使用那些自动化的、一定安全的重构手法。这会让我损失很多好用的重构手法,不过剩下可用的也不少,我还是能从中获益。当然,我还是更愿意有自测试的代码,但如果没有,自动化重构的工具包也很好。
232 |
233 | 缺乏测试的现状还催生了另一种重构的流派:只使用一组经过验证是安全的重构手法。这个流派要求严格遵循重构的每个步骤,并且可用的重构手法是特定于语言的。使用这种方法,团队得以在测试覆盖率很低的大型代码库上开展一些有用的重构。这个重构流派比较新,涉及一些很具体、特定于编程语言的技巧与做法,行业里对这种方法的介绍和了解都还不足,因此本书不对其多做介绍。(不过我希望未来在我自己的网站上多讨论这个主题。感兴趣的读者可以查看 Jay Bazuzi 关于如何在 C++中安全地运用提炼函数(106)的描述[Bazuzi],借此获得一点儿对这个重构流派的了解。)
234 |
235 | 毫不意外,自测试代码与持续集成紧密相关——我们仰赖持续集成来及时捕获分支集成时的语义冲突。自测试代码是极限编程的另一个重要组成部分,也是持续交付的关键环节。
236 |
237 | ### 遗留代码
238 |
239 | 大多数人会觉得,有一大笔遗产是件好事,但从程序员的角度来看就不同了。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的(瑟瑟发抖)。
240 |
241 | 重构可以很好地帮助我们理解遗留系统。引人误解的函数名可以改名,使其更好地反映代码用途;糟糕的程序结构可以慢慢理顺,把程序从一块顽石打磨成美玉。整个故事都很棒,但我们绕不开关底的恶龙:遗留系统多半没测试。如果你面对一个庞大而又缺乏测试的遗留系统,很难安全地重构清理它。
242 |
243 | 对于这个问题,显而易见的答案是“没测试就加测试”。这事听起来简单(当然工作量必定很大),操作起来可没那么容易。一般来说,只有在设计系统时就考虑到了测试,这样的系统才容易添加测试——可要是如此,系统早该有测试了,我也不用操这份心了。
244 |
245 | 这个问题没有简单的解决办法,我能给出的最好建议就是买一本《修改代码的艺术》[Feathers],照书里的指导来做。别担心那本书太老,尽管已经出版十多年,其中的建议仍然管用。一言以蔽之,它建议你先找到程序的接缝,在接缝处插入测试,如此将系统置于测试覆盖之下。你需要运用重构手法创造出接缝——这样的重构很危险,因为没有测试覆盖,但这是为了取得进展必要的风险。在这种情况下,安全的自动化重构简直就是天赐福音。如果这一切听起来很困难,因为它确实很困难。很遗憾,一旦跌进这个深坑,没有爬出来的捷径,这也是我强烈倡导从一开始就写能自测试的代码的原因。
246 |
247 | 就算有了测试,我也不建议你尝试一鼓作气把复杂而混乱的遗留代码重构成漂亮的代码。我更愿意随时重构相关的代码:每次触碰一块代码时,我会尝试把它变好一点点——至少要让营地比我到达时更干净。如果是一个大系统,越是频繁使用的代码,改善其可理解性的努力就能得到越丰厚的回报。
248 |
249 | ### 数据库
250 |
251 | 在本书的第 1 版中,我说过数据库是“重构经常出问题的一个领域”。然而在第 1 版问世之后仅仅一年,情况就发生了改变:我的同事 Pramod Sadalage 发展出一套渐进式数据库设计[mf-evodb]和数据库重构[Ambler & Sadalage]的办法,如今已经被广泛使用。这项技术的精要在于:借助数据迁移脚本,将数据库结构的修改与代码相结合,使大规模的、涉及数据库的修改可以比较容易地开展。
252 |
253 | 假设我们要对一个数据库字段(列)改名。和改变函数声明(124)一样,我要找出结构的声明处和所有调用处,然后一次完成所有修改。但这里的复杂之处在于,原来基于旧字段的数据,也要转为使用新字段。我会写一小段代码来执行数据转化的逻辑,并把这段代码放进版本控制,跟数据结构声明与使用代码的修改一并提交。此后如果我想把数据库迁移到某个版本,只要执行当前数据库版本与目标版本之间的所有迁移脚本即可。
254 |
255 | 跟通常的重构一样,数据库重构的关键也是小步修改并且每次修改都应该完整,这样每次迁移之后系统仍然能运行。由于每次迁移涉及的修改都很小,写起来应该容易;将多个迁移串联起来,就能对数据库结构及其中存储的数据做很大的调整。
256 |
257 | 与常规的重构不同,很多时候,数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚。比如,要改名一个字段,我的第一次提交会新添一个字段,但暂时不使用它。然后我会修改数据写入的逻辑,使其同时写入新旧两个字段。随后我就可以修改读取数据的地方,将它们逐个改为使用新字段。这步修改完成之后,我会暂停一小段时间,看看是否有 bug 冒出来。确定没有 bug 之后,我再删除已经没人使用的旧字段。这种修改数据库的方式是并行修改(Parallel Change,也叫扩展协议/expand-contract)[mf-pc]的一个实例。
258 |
259 | ## 2.6 重构、架构和 YAGNI
260 |
261 | 重构极大地改变了人们考虑软件架构的方式。在我的职业生涯早期,我被告知:在任何人开始写代码之前,必须先完成软件的设计和架构。一旦代码写出来,架构就固定了,只会因为程序员的草率对待而逐渐腐败。
262 |
263 | 重构改变了这种观点。有了重构技术,即便是已经在生产环境中运行了多年的软件,我们也有能力大幅度修改其架构。正如本书的副标题所指出的,重构可以改善既有代码的设计。但我在前面也提到了,修改遗留代码经常很有挑战,尤其当遗留代码缺乏恰当的测试时。
264 |
265 | 重构对架构最大的影响在于,通过重构,我们能得到一个设计良好的代码库,使其能够优雅地应对不断变化的需求。“在编码之前先完成架构”这种做法最大的问题在于,它假设了软件的需求可以预先充分理解。但经验显示,这个假设很多时候甚至可以说大多数时候是不切实际的。只有真正使用了软件、看到了软件对工作的影响,人们才会想明白自己到底需要什么,这样的例子不胜枚举。
266 |
267 | 应对未来变化的办法之一,就是在软件里植入灵活性机制。在编写一个函数时,我会考虑它是否有更通用的用途。为了应对我预期的应用场景,我预测可以给这个函数加上十多个参数。这些参数就是灵活性机制——跟大多数“机制”一样,它不是免费午餐。把所有这些参数都加上的话,函数在当前的使用场景下就会非常复杂。另外,如果我少考虑了一个参数,已经加上的这一堆参数会使新添参数更麻烦。而且我经常会把灵活性机制弄错——可能是未来的需求变更并非以我期望的方式发生,也可能我对机制的设计不好。考虑到所有这些因素,很多时候这些灵活性机制反而拖慢了我响应变化的速度。
268 |
269 | 有了重构技术,我就可以采取不同的策略。与其猜测未来需要哪些灵活性、需要什么机制来提供灵活性,我更愿意只根据当前的需求来构造软件,同时把软件的设计质量做得很高。随着对用户需求的理解加深,我会对架构进行重构,使其能够应对新的需要。如果一种灵活性机制不会增加复杂度(比如添加几个命名良好的小函数),我可以很开心地引入它;但如果一种灵活性会增加软件复杂度,就必须先证明自己值得被引入。如果不同的调用者不会传入不同的参数值,那么就不要添加这个参数。当真的需要添加这个参数时,运用函数参数化(310)也很容易。要判断是否应该为未来的变化添加灵活性,我会评估“如果以后再重构有多困难”,只有当未来重构会很困难时,我才考虑现在就添加灵活性机制。我发现这是一个很有用的决策方法。
270 |
271 | 这种设计方法有很多名字:简单设计、增量式设计或者 YAGNI[mf-yagni]——“你不会需要它”(you arenʼt going to need it)的缩写。YAGNI 并不是“不做架构性思考”的意思,不过确实有人以这种欠考虑的方式做事。我把 YAGNI 视为将架构、设计与开发过程融合的一种工作方式,这种工作方式必须有重构作为基础才可靠。
272 |
273 | 采用 YAGNI 并不表示完全不用预先考虑架构。总有一些时候,如果缺少预先的思考,重构会难以开展。但两者之间的平衡点已经发生了很大的改变:如今我更倾向于等一等,待到对问题理解更充分,再来着手解决。演进式架构[Ford et al.]是一门仍在不断发展的学科,架构师们在不断探索有用的模式和实践,充分发挥迭代式架构决策的能力。
274 |
275 | ## 2.7 重构与软件开发过程
276 |
277 | 读完前面“重构的挑战”一节,你大概已经有这个印象:重构是否有效,与团队采用的其他软件开发实践紧密相关。重构起初是作为极限编程(XP)[mf-xp]的一部分被人们采用的,XP 本身就融合了一组不太常见而又彼此关联的实践,例如持续集成、自测试代码以及重构(后两者融汇成了测试驱动开发)。
278 |
279 | 极限编程是最早的敏捷软件开发方法[mf-nm]之一。在一段历史时期,极限编程引领了敏捷的崛起。如今已经有很多项目使用敏捷方法,甚至敏捷的思维已经被视为主流,但实际上大部分“敏捷”项目只是徒有其名。要真正以敏捷的方式运作项目,团队成员必须在重构上有能力、有热情,他们采用的开发过程必须与常规的、持续的重构相匹配。
280 |
281 | 重构的第一块基石是自测试代码。我应该有一套自动化的测试,我可以频繁地运行它们,并且我有信心:如果我在编程过程中犯了任何错误,会有测试失败。这块基石如此重要,我会专门用一章篇幅来讨论它。
282 |
283 | 如果一支团队想要重构,那么每个团队成员都需要掌握重构技能,能在需要时开展重构,而不会干扰其他人的工作。这也是我鼓励持续集成的原因:有了 CI,每个成员的重构都能快速分享给其他同事,不会发生这边在调用一个接口那边却已把这个接口删掉的情况;如果一次重构会影响别人的工作,我们很快就会知道。自测试的代码也是持续集成的关键环节,所以这三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应。
284 |
285 | 有这三大实践在手,我们就能运用前一节介绍的 YAGNI 设计方法。重构和 YAGNI 交相呼应、彼此增效,重构(及其前置实践)是 YAGNI 的基础,YAGNI 又让重构更易于开展:比起一个塞满了想当然的灵活性的系统,当然是修改一个简单的系统要容易得多。在这些实践之间找到合适的平衡点,你就能进入良性循环,你的代码既牢固可靠又能快速响应变化的需求。
286 |
287 | 有这三大核心实践打下的基础,才谈得上运用敏捷思想的其他部分。持续交付确保软件始终处于可发布的状态,很多互联网团队能做到一天多次发布,靠的正是持续交付的威力。即便我们不需要如此频繁的发布,持续集成也能帮我们降低风险,并使我们做到根据业务需要随时安排发布,而不受技术的局限。有了可靠的技术根基,我们能够极大地压缩“从好点子到生产代码”的周期时间,从而更好地服务客户。这些技术实践也会增加软件的可靠性,减少耗费在 bug 上的时间。
288 |
289 | 这一切说起来似乎很简单,但实际做起来毫不容易。不管采用什么方法,软件开发都是一件复杂而微妙的事,涉及人与人之间、人与机器之间的复杂交互。我在这里描述的方法已经被证明可以应对这些复杂性,但——就跟其他所有方法一样——对使用者的实践和技能有要求。
290 |
291 | ## 2.8 重构与性能
292 |
293 | 关于重构,有一个常被提出的问题:它对程序的性能将造成怎样的影响?为了让软件易于理解,我常会做出一些使程序运行变慢的修改。这是一个重要的问题。我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道。已经有很多软件因为速度太慢而被用户拒绝,日益提高的机器速度也只不过略微放宽了速度方面的限制而已。但是,换个角度说,虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:先写出可调优的软件,然后调优它以求获得足够的速度。
294 |
295 | 我看过 3 种编写快速软件的方法。其中最严格的是时间预算法,这通常只用于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源,包括时间和空间占用。每个组件绝对不能超出自己的预算,就算拥有组件之间调度预配时间的机制也不行。这种方法高度重视性能,对于心律调节器一类的系统是必需的,因为在这样的系统中迟来的数据就是错误的数据。但对其他系统(例如我经常开发的企业信息系统)而言,如此追求高性能就有点儿过分了。
296 |
297 | 第二种方法是持续关注法。这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,继而减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜通常事与愿违,因为性能改善一旦被分散到程序各个角落,每次改善都只不过是从对程序行为的一个狭隘视角出发而已,而且常常伴随着对编译器、运行时环境和硬件行为的误解。
298 |
299 | ::: tip
300 | 劳而无获
301 |
302 | 克莱斯勒综合薪资系统的支付过程太慢了。虽然我们的开发还没结束,这个问题却已经开始困扰我们,因为它已经拖累了测试速度。
303 |
304 | Kent Beck、Martin Fowler 和我决定解决这个问题。等待大伙儿会合的时间里,凭着对这个系统的全盘了解,我开始推测:到底是什么让系统变慢了?我想到数种可能,然后和伙伴们谈了几种可能的修改方案。最后,我们就“如何让这个系统运行更快”,提出了一些真正的好点子。
305 |
306 | 然后,我们拿 Kent 的工具度量了系统性能。我一开始所想的可能性竟然全都不是问题肇因。我们发现:系统把一半时间用来创建“日期”实例(instance)。更有趣的是,所有这些实例都有相同的几个值。
307 |
308 | 于是我们观察日期对象的创建逻辑,发现有机会将它优化。这些日期对象在创建时都经过了一个字符串转换过程,然而这里并没有任何外部数据输入。之所以使用字符串转换方式,完全只是因为代码写起来简单。好,也许我们可以优化它。
309 |
310 | 然后,我们观察这些日期对象是如何被使用的。我们发现,很多日期对象都被用来产生“日期区间”实例——由一个起始日期和一个结束日期组成的对象。仔细追踪下去,我们发现绝大多数日期区间是空的!
311 |
312 | 处理日期区间时我们遵循这样一个规则:如果结束日期在起始日期之前,这个日期区间就该是空的。这是一条很好的规则,完全符合这个类的需要。采用此规则后不久,我们意识到,创建一个“起始日期在结束日期之后”的日期区间,仍然不算是清晰的代码,于是我们把这个行为提炼成一个工厂函数,由它专门创建“空的日期区间”。
313 |
314 | 我们做了上述修改,使代码更加清晰,也意外得到了一个惊喜:可以创建一个固定不变的“空日期区间”对象,并让上述调整后的工厂函数始终返回该对象,而不再每次都创建新对象。这一修改把系统速度提升了几乎一倍,足以让测试速度达到可接受的程度。这只花了我们大约五分钟。
315 |
316 | 我和团队成员(Kent 和 Martin 谢绝参加)认真推测过:我们了若指掌的这个程序中可能有什么错误?我们甚至凭空做了些改进设计,却没有先对系统的真实情况进行度量。
317 |
318 | 我们完全错了。除了一场很有趣的交谈,我们什么好事都没做。
319 |
320 | 教训是:哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。
321 |
322 | ——Ron Jeffries
323 | :::
324 |
325 | 关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。你花时间做优化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些时间就都被浪费掉了。
326 |
327 | 第三种性能提升法就是利用上述的 90%统计数据。采用这种方法时,我编写构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——那通常是在开发后期。一旦进入该阶段,我再遵循特定的流程来调优程序性能。
328 |
329 | 在性能优化阶段,我首先应该用一个度量工具来监控程序的运行,让它告诉我程序中哪些地方大量消耗时间和空间。这样我就可以找出性能热点所在的一小段代码。然后我应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于把注意力都集中在热点上,较少的工作量便可显现较好的成果。即便如此,我还是必须保持谨慎。和重构一样,我会小幅度进行修改。每走一步都需要编译、测试,再次度量。如果没能提高性能,就应该撤销此次修改。我会继续这个“发现热点,去除热点”的过程,直到获得客户满意的性能为止。
330 |
331 | 一个构造良好的程序可从两方面帮助这一优化方式。首先,它让我有比较充裕的时间进行性能调整,因为有构造良好的代码在手,我能够更快速地添加功能,也就有更多时间用在性能问题上(准确的度量则保证我把这些时间投在恰当地点)。其次,面对构造良好的程序,我在进行性能分析时便有较细的粒度。度量工具会把我带入范围较小的代码段中,而性能的调整也比较容易些。由于代码更加清晰,因此我能够更好地理解自己的选择,更清楚哪种调整起关键作用。
332 |
333 | 我发现重构可以帮助我写出更快的软件。短期看来,重构的确可能使软件变慢,但它使优化阶段的软件性能调优更容易,最终还是会得到好的效果。
334 |
335 | ## 2.9 重构起源何处
336 |
337 | 我曾经努力想找出“重构”(refactoring)一词的真正起源,但最终失败了。优秀程序员肯定至少会花一些时间来清理自己的代码。这么做是因为,他们知道整洁的代码比杂乱无章的代码更容易修改,而且他们知道自己几乎无法一开始就写出整洁的代码。
338 |
339 | 重构不止如此。本书中我把重构看作整个软件开发过程的一个关键环节。最早认识重构重要性的两个人是 Ward Cunningham 和 Kent Beck,他们早在 20 世纪 80 年代就开始使用 Smalltalk,那是一个特别适合重构的环境。Smalltalk 是一个十分动态的环境,用它可以很快写出功能丰富的软件。Smalltalk 的“编译-链接-执行”周期非常短,因此很容易快速修改代码——要知道,当时很多编程环境做一次编译就需要整晚时间。它支持面向对象,也有强大的工具,最大限度地将修改的影响隐藏于定义良好的接口背后。Ward 和 Kent 努力探索出一套适合这类环境的软件开发过程(如今,Kent 把这种风格叫作极限编程)。他们意识到:重构对于提高生产力非常重要。从那时起他们就一直在工作中运用重构技术,在正式的软件项目中使用它,并不断精炼重构的过程。
340 |
341 | Ward 和 Kent 的思想对 Smalltalk 社区产生了极大影响,重构概念也成为 Smalltalk 文化中的一个重要元素。Smalltalk 社区的另一位领袖是 Ralph Johnson,伊利诺伊大学厄巴纳-香槟分校教授,著名的 GoF[gof]之一。Ralph 最大的兴趣之一就是开发软件框架。他揭示了重构有助于灵活高效框架的开发。
342 |
343 | Bill Opdyke 是 Ralph 的博士研究生,对框架也很感兴趣。他看到了重构的潜在价值,并看到重构应用于 Smalltalk 之外的其他语言的可能性。他的技术背景是电话交换系统的开发。在这种系统中,大量的复杂情况与日俱增,而且非常难以修改。Bill 的博士研究就是从工具构筑者的角度来看待重构。Bill 对 C++的框架开发中用得上的重构手法特别感兴趣。他也研究了极有必要的“语义保持的重构” (semantics-preserving refactoring),并阐明了如何证明这些重构是语义保持的,以及如何用工具实现重构。Bill 的博士论文[Opdyke]是重构领域中第一部丰硕的研究成果。
344 |
345 | 我还记得 1992 年 OOPSLA 大会上见到 Bill 的情景。我们坐在一间咖啡厅里,Bill 跟我谈起他的研究成果,我还记得自己当时的想法:“有趣,但并非真的那么重要。”唉,我完全错了。
346 |
347 | John Brant 和 Don Roberts 将“重构工具”的构想发扬光大,开发了一个名为 Refactoring Browser (重构浏览器)的重构工具。这是第一个自动化的重构工具,多亏 Smalltalk 提供了适合重构的编程环境。
348 |
349 | 那么,我呢?我一直有清理代码的倾向,但从来没有想到这会如此重要。后来我和 Kent 一起做一个项目,看到他使用重构手法,也看到重构对开发效能和质量带来的影响。这份体验让我相信:重构是一门非常重要的技术。但是,在重构的学习和推广过程中我遇到了挫折,因为我拿不出任何一本书给程序员看,也没有任何一位专家打算写这样一本书。所以,在这些专家的帮助下,我写下了这本书的第 1 版。
350 |
351 | 幸运的是,重构的概念被行业广泛接受了。本书第 1 版销量不错,“重构”一词也走进了大多数程序员的词汇库。更多的重构工具涌现出来,尤其是在 Java 世界里。重构的流行也带来了负面效应:很多人随意地使用“重构”这个词,而他们真正做的却是不严谨的结构调整。尽管如此,重构终归成了一项主流的软件开发实践。
352 |
353 | ## 2.10 自动化重构
354 |
355 | 过去 10 年中,重构领域最大的变化可能就是出现了一批支持自动化重构的工具。如果我想给一个 Java 的方法改名,在 IntelliJ IDEA 或者 Eclipse 这样的开发环境中,我只需要从菜单里点选对应的选项,工具会帮我完成整个重构过程,而且我通常都可以相信,工具完成的重构是可靠的,所以用不着运行测试套件。
356 |
357 | 第一个自动化重构工具是 Smalltalk 的 Refactoring Browser,由 John Brandt 和 Don Roberts 开发。在 21 世纪初,Java 世界的自动化重构工具如雨后春笋般涌现。在 JetBrains 的 IntelliJ IDEA 集成开发环境(IDE)中,自动化重构是最亮眼的特性之一。IBM 也紧随其后,在 VisualAge 的 Java 版中也提供了重构工具。VisualAge 的影响力有限,不过其中很多能力后来被 Eclipse 继承,包括对重构的支持。
358 |
359 | 重构也进入了 C#世界,起初是通过 JetBrains 的 Resharper,这是一个 Visual Studio 插件。后来 Visual Studio 团队直接在 IDE 里提供了一些重构能力。
360 |
361 | 如今的编辑器和开发工具中常能找到一些对重构的支持,不过真实的重构能力各有高低。重构能力的差异既有工具的原因,也受限于不同语言对自动化重构的支持程度。在这里,我不打算分析各种工具的能力,不过谈谈重构工具背后的原则还是有点儿意思的。
362 |
363 | 一种粗糙的自动化重构方式是文本操作,比如用查找/替换的方式给函数改名,或者完成提炼变量(119)所需的简单结构调整。这种方法太粗糙了,做完之后必须重新运行测试,否则不能信任。但这可以是一个便捷的起步。在用 Emacs 编程时,没有那些更完善的重构支持,我也会用类似的文本操作宏来加速重构。
364 |
365 | 要支持体面的重构,工具只操作代码文本是不行的,必须操作代码的语法树,这样才能更可靠地保持代码行为。所以,今天的大多数重构功能都依附于强大的 IDE,因为这些 IDE 原本就在语法树上实现了代码导航、静态检查等功能,自然也可以用于重构。不仅能处理文本,还能处理语法树,这是 IDE 相比于文本编辑器更先进的地方。
366 |
367 | 重构工具不仅需要理解和修改语法树,还要知道如何把修改后的代码写回编辑器视图。总而言之,实现一个体面的自动化重构手法,是一个很有挑战的编程任务。尽管我一直开心地使用重构工具,对它们背后的实现却知之甚少。
368 |
369 | 在静态类型语言中,很多重构手法会更加安全。假设我想做一次简单的函数改名(124):在 Salesman 类和 Server 类中都有一个叫作 addClient 的函数,当然两者各有其用途。我想对 Salesman 中的 addClient 函数改名,Server 类中的函数则保持不变。如果不是静态类型,工具很难识别调用 addClient 的地方到底是在使用哪个类的函数。Smalltalk 的 Refactoring Browser 会列出所有调用点,我需要手工决定修改哪些调用点。这个重构是不安全的,我必须重新运行所有测试。这样的工具仍然有用,但在 Java 中的函数改名(124)重构则可以是完全安全、完全自动的,因为在静态类型的帮助下,工具可以识别函数所属的类,所以它只会修改应该修改的那些函数调用点,对此我可以完全放心。
370 |
371 | 一些重构工具走得更远。如果我给一个变量改名,工具会提醒我修改使用了旧名字的注释。如果我使用提炼函数(106),工具会找出与新函数体重复的代码片段,建议代之以对新函数的调用。在编程时可以使用如此强大的重构功能,这就是为什么我们要使用一个体面的 IDE,而不是固执于熟悉的文本编辑器。我个人很喜欢用 Emacs,但在使用 Java 时,我更愿意用 IntelliJ IDEA 或者 Eclipse,很大程度上就是为了获得重构支持。
372 |
373 | 尽管这些强大的重构工具有着魔法般的能力,可以安全地重构代码,但还是会有闪失出现。通过反射进行的调用(例如 Java 中的 Method.invoke)会迷惑不够成熟的重构工具,但比较成熟的工具则可以很好地应对。所以,即便是最安全的重构,也应该经常运行测试套件,以确保没有什么东西在不经意间被破坏。我经常会间杂进行自动重构和手动重构,所以运行测试的频度是足够的。
374 |
375 | 能借助语法树来分析和重构程序代码,这是 IDE 与普通文本编辑器相比具有的一大优势。但很多程序员又喜欢用得顺手的文本编辑器的灵活性,希望鱼与熊掌兼得。语言服务器(Language Server)是一种正在引起关注的新技术:用软件生成语法树,给文本编辑器提供 API。语言服务器可以支持多种文本编辑器,并且为强大的代码分析和重构操作提供了命令。
376 |
377 | ## 2.11 延展阅读
378 |
379 | 在第 2 章就开始谈延展阅读,这似乎有点儿奇怪。不过,有大量关于重构的材料已经超出了本书的范围,早些让读者知道这些材料的存在也是件好事。
380 |
381 | 本书的第 1 版教很多人学会了重构,不过我的关注点是组织一本重构的参考书,而不是带领读者走过学习过程。如果你需要一本面向入门者的教材,我推荐 Bill Wake 的《重构手册》[Wake],其中包含了很多有用的重构练习。
382 |
383 | 很多重构的先行者同时也活跃于软件模式社区。Josh Kerievsky 在《重构与模式》[Kerievsky]一书中紧密连接了这两个世界。他审视了影响巨大的 GoF[gof]书中一些最有价值的模式,并展示了如何通过重构使代码向这些模式的方向演化。
384 |
385 | 本书聚焦讨论通用编程语言中的重构技巧。还有一些专门领域的重构,例如已经引起关注的《数据库重构》[Ambler & Sadalage](由 Scott Ambler 和 Pramod Sadalage 所著)和《重构 HTML》[Harold](由 Elliotte Rusty Harold 所著)。
386 |
387 | 尽管标题中没有“重构”二字,Michael Feathers 的《修改代码的艺术》[Feathers]也不得不提。这本书主要讨论如何在缺乏测试覆盖的老旧代码库上开展重构。
388 |
389 | 本书(及其前一版)对读者的编程语言背景没有要求。也有人写专门针对特定语言的重构书籍。我的两位前同事 Jay Fields 和 Shane Harvey 就撰写了 Ruby 版的《重构》[Fields et al.]。
390 |
391 | 在本书的 Web 版和重构网站(refactoring.com)[ref.com]上都可以找到更多相关材料的更新。
392 |
--------------------------------------------------------------------------------
/docs/ch3.md:
--------------------------------------------------------------------------------
1 | # 第 3 章 代码的坏味道
2 |
3 | ——Kent Beck 和 Martin Fowler
4 |
5 | “如果尿布臭了,就换掉它。”
6 |
7 | ——语出 Beck 奶奶,论保持小孩清洁的哲学
8 |
9 | 现在,对于重构如何运作,你已经有了相当好的理解。但是知道“如何”不代表知道“何时”。决定何时重构及何时停止和知道重构机制如何运转一样重要。
10 |
11 | 难题来了!解释“如何删除一个实例变量”或“如何产生一个继承体系”很容易,因为这些都是很简单的事情,但要解释“该在什么时候做这些动作”就没那么顺理成章了。除了露几手含混的编程美学(说实话,这就是咱们这些顾问常做的事),我还希望让某些东西更具说服力一些。
12 |
13 | 撰写本书的第 1 版时,我正在为这个微妙的问题大伤脑筋。去苏黎世拜访 Kent Beck 的时候,也许是因为受到刚出生的女儿的气味影响吧,他提出用味道来形容重构的时机。
14 |
15 | “味道,”你可能会说,“真的比含混的美学理论要好吗?”好吧,是的。我们看过很多很多代码,它们所属的项目从大获成功到奄奄一息都有。观察这些代码时,我们学会了从中找寻某些特定结构,这些结构指出(有时甚至就像尖叫呼喊)重构的可能性。(本章主语换成“我们”,是为了反映一个事实:Kent 和我共同撰写本章。你应该可以看出我俩的文笔差异——插科打诨的部分是我写的,其余都是他写的。)
16 |
17 | 我们并不试图给你一个何时必须重构的精确衡量标准。从我们的经验看来,没有任何量度规矩比得上见识广博者的直觉。我们只会告诉你一些迹象,它会指出“这里有一个可以用重构解决的问题”。你必须培养自己的判断力,学会判断一个类内有多少实例变量算是太大、一个函数内有多少行代码才算太长。
18 |
19 | 如果你无法确定该采用哪一种重构手法,请阅读本章内容和书后附的“重构列表”来寻找灵感。你可以阅读本章或快速浏览书后附的“坏味道与重构手法速查表”来判断自己闻到的是什么味道,然后再看看我们所建议的重构手法能否帮到你。也许这里所列的“坏味道条款”和你所检测的不尽相符,但愿它们能够为你指引正确方向。
20 |
21 | ## 3.1 神秘命名(Mysterious Name)
22 |
23 | 读侦探小说时,透过一些神秘的文字猜测故事情节是一种很棒的体验;但如果是在阅读代码,这样的体验就不怎么好了。我们也许会幻想自己是《王牌大贱谍》中的国际特工 1,但我们写下的代码应该直观明了。整洁代码最重要的一环就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法。
24 |
25 | 然而,很遗憾,命名是编程中最难的两件事之一[mf-2h]。正因为如此,改名可能是最常用的重构手法,包括改变函数声明(124)(用于给函数改名)、变量改名(137)、字段改名(244)等。很多人经常不愿意给程序元素改名,觉得不值得费这个劲,但好的名字能节省未来用在猜谜上的大把时间。
26 |
27 | 改名不仅仅是修改名字而已。如果你想不出一个好名字,说明背后很可能潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简。
28 |
29 | 1《王牌大贱谍》(International Man of Mystery)是 1997 年杰伊·罗奇执导的一部喜剧谍战片。——译者注
30 |
31 | ## 3.2 重复代码(Duplicated Code)
32 |
33 | 如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序会变得更好。一旦有重复代码存在,阅读这些重复的代码时你就必须加倍仔细,留意其间细微的差异。如果要修改重复代码,你必须找出所有的副本来修改。
34 |
35 | 最单纯的重复代码就是“同一个类的两个函数含有相同的表达式”。这时候你需要做的就是采用提炼函数(106)提炼出重复的代码,然后让这两个地点都调用被提炼出来的那一段代码。如果重复代码只是相似而不是完全相同,请首先尝试用移动语句(223)重组代码顺序,把相似的部分放在一起以便提炼。如果重复的代码段位于同一个超类的不同子类中,可以使用函数上移(350)来避免在两个子类之间互相调用。
36 |
37 | ## 3.3 过长函数(Long Function)
38 |
39 | 据我们的经验,活得最长、最好的程序,其中的函数都比较短。初次接触到这种代码库的程序员常常会觉得“计算都没有发生”——程序里满是无穷无尽的委托调用。但和这样的程序共处几年之后,你就会明白这些小函数的价值所在。间接性带来的好处——更好的阐释力、更易于分享、更多的选择——都是由小函数来支持的。
40 |
41 | 早在编程的洪荒年代,程序员们就已认识到:函数越长,就越难理解。在早期的编程语言中,子程序调用需要额外开销,这使得人们不太乐意使用小函数。现代编程语言几乎已经完全免除了进程内的函数调用开销。固然,小函数也会给代码的阅读者带来一些负担,因为你必须经常切换上下文,才能看明白函数在做什么。但现代的开发环境让你可以在函数的调用处与声明处之间快速跳转,或是同时看到这两处,让你根本不用来回跳转。不过说到底,让小函数易于理解的关键还是在于良好的命名。如果你能给函数起个好名字,阅读代码的人就可以通过名字了解函数的作用,根本不必去看其中写了些什么。
42 |
43 | 最终的效果是:你应该更积极地分解函数。我们遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。我们可以对一组甚至短短一行代码做这件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,我们也该毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。
44 |
45 | 百分之九十九的场合里,要把函数变短,只需使用提炼函数(106)。找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。
46 |
47 | 如果函数内有大量的参数和临时变量,它们会对你的函数提炼形成阻碍。如果你尝试运用提炼函数(106),最终就会把许多参数传递给被提炼出来的新函数,导致可读性几乎没有任何提升。此时,你可以经常运用以查询取代临时变量(178)来消除这些临时元素。引入参数对象(140)和保持对象完整(319)则可以将过长的参数列表变得更简洁一些。
48 |
49 | 如果你已经这么做了,仍然有太多临时变量和参数,那就应该使出我们的杀手锏——以命令取代函数(337)。
50 |
51 | 如何确定该提炼哪一段代码呢?一个很好的技巧是:寻找注释。它们通常能指出代码用途和实现手法之间的语义距离。如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数中去。
52 |
53 | 条件表达式和循环常常也是提炼的信号。你可以使用分解条件表达式(260)处理条件表达式。对于庞大的 switch 语句,其中的每个分支都应该通过提炼函数(106)变成独立的函数调用。如果有多个 switch 语句基于同一个条件进行分支选择,就应该使用以多态取代条件表达式(272)。
54 |
55 | 至于循环,你应该将循环和循环内的代码提炼到一个独立的函数中。如果你发现提炼出的循环很难命名,可能是因为其中做了几件不同的事。如果是这种情况,请勇敢地使用拆分循环(227)将其拆分成各自独立的任务。
56 |
57 | ## 3.4 过长参数列表(Long Parameter List)
58 |
59 | 刚开始学习编程的时候,老师教我们:把函数所需的所有东西都以参数的形式传递进去。这可以理解,因为除此之外就只能选择全局数据,而全局数据很快就会变成邪恶的东西。但过长的参数列表本身也经常令人迷惑。
60 |
61 | 如果可以向某个参数发起查询而获得另一个参数的值,那么就可以使用以查询取代参数(324)去掉这第二个参数。如果你发现自己正在从现有的数据结构中抽出很多数据项,就可以考虑使用保持对象完整(319)手法,直接传入原来的数据结构。如果有几项参数总是同时出现,可以用引入参数对象(140)将其合并成一个对象。如果某个参数被用作区分函数行为的标记(flag),可以使用移除标记参数(314)。
62 |
63 | 使用类可以有效地缩短参数列表。如果多个函数有同样的几个参数,引入一个类就尤为有意义。你可以使用函数组合成类(144),将这些共同的参数变成这个类的字段。如果戴上函数式编程的帽子,我们会说,这个重构过程创造了一组部分应用函数(partially applied function)。
64 |
65 | ## 3.5 全局数据(Global Data)
66 |
67 | 刚开始学软件开发时,我们就听说过关于全局数据的惊悚故事——它们是如何被来自地狱第四层的恶魔发明出来,胆敢使用它们的程序员如今在何处安息。就算这些烈焰与硫黄的故事不那么可信,全局数据仍然是最刺鼻的坏味道之一。全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。一次又一次,全局数据造成了那些诡异的 bug,而问题的根源却在遥远的别处,想要找到出错的代码难于登天。全局数据最显而易见的形式就是全局变量,但类变量和单例(singleton)也有这样的问题。
68 |
69 | 首要的防御手段是封装变量(132),每当我们看到可能被各处的代码污染的数据,这总是我们应对的第一招。你把全局数据用一个函数包装起来,至少你就能看见修改它的地方,并开始控制对它的访问。随后,最好将这个函数(及其封装的数据)搬移到一个类或模块中,只允许模块内的代码使用它,从而尽量控制其作用域。
70 |
71 | 可以被修改的全局数据尤其可憎。如果能保证在程序启动之后就不再修改,这样的全局数据还算相对安全,不过得有编程语言提供这样的保证才行。
72 |
73 | 全局数据印证了帕拉塞尔斯的格言:良药与毒药的区别在于剂量。有少量的全局数据或许无妨,但数量越多,处理的难度就会指数上升。即便只是少量的数据,我们也愿意将它封装起来,这是在软件演进过程中应对变化的关键所在。
74 |
75 | ## 3.6 可变数据(Mutable Data)
76 |
77 | 对数据的修改经常导致出乎意料的结果和难以发现的 bug。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是一个功能失效了——如果故障只在很罕见的情况下发生,要找出故障原因就会更加困难。因此,有一整个软件开发流派——函数式编程——完全建立在“数据永不改变”的概念基础上:如果要更新一个数据结构,就返回一份新的数据副本,旧的数据仍保持不变。
78 |
79 | 不过这样的编程语言仍然相对小众,大多数程序员使用的编程语言还是允许修改变量值的。即便如此,我们也不应该忽视不可变性带来的优势——仍然有很多办法可以用于约束对数据的更新,降低其风险。
80 |
81 | 可以用封装变量(132)来确保所有数据更新操作都通过很少几个函数来进行,使其更容易监控和演进。如果一个变量在不同时候被用于存储不同的东西,可以使用拆分变量(240)将其拆分为各自不同用途的变量,从而避免危险的更新操作。使用移动语句(223)和提炼函数(106)尽量把逻辑从处理更新操作的代码中搬移出来,将没有副作用的代码与执行数据更新操作的代码分开。设计 API 时,可以使用将查询函数和修改函数分离(306)确保调用者不会调到有副作用的代码,除非他们真的需要更新数据。我们还乐于尽早使用移除设值函数(331)——有时只是把设值函数的使用者找出来看看,就能帮我们发现缩小变量作用域的机会。
82 |
83 | 如果可变数据的值能在其他地方计算出来,这就是一个特别刺鼻的坏味道。它不仅会造成困扰、bug 和加班,而且毫无必要。消除这种坏味道的办法很简单,使用以查询取代派生变量(248)即可。
84 |
85 | 如果变量作用域只有几行代码,即使其中的数据可变,也不是什么大问题;但随着变量作用域的扩展,风险也随之增大。可以用函数组合成类(144)或者函数组合成变换(149)来限制需要对变量进行修改的代码量。如果一个变量在其内部结构中包含了数据,通常最好不要直接修改其中的数据,而是用将引用对象改为值对象(252)令其直接替换整个数据结构。
86 |
87 | ## 3.7 发散式变化(Divergent Change)
88 |
89 | 我们希望软件能够更容易被修改——毕竟软件本来就该是“软”的。一旦需要修改,我们希望能够跳到系统的某一点,只在该处做修改。如果不能做到这一点,你就嗅出两种紧密相关的刺鼻味道中的一种了。
90 |
91 | 如果某个模块经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。当你看着一个类说:“呃,如果新加入一个数据库,我必须修改这 3 个函数;如果新出现一种金融工具,我必须修改这 4 个函数。”这就是发散式变化的征兆。数据库交互和金融逻辑处理是两个不同的上下文,将它们分别搬移到各自独立的模块中,能让程序变得更好:每当要对某个上下文做修改时,我们只需要理解这个上下文,而不必操心另一个。“每次只关心一个上下文”这一点一直很重要,在如今这个信息爆炸、脑容量不够用的年代就愈发紧要。当然,往往只有在加入新数据库或新金融工具后,你才能发现这个坏味道。在程序刚开发出来还在随着软件系统的能力不断演进时,上下文边界通常不是那么清晰。
92 |
93 | 如果发生变化的两个方向自然地形成了先后次序(比如说,先从数据库取出数据,再对其进行金融逻辑处理),就可以用拆分阶段(154)将两者分开,两者之间通过一个清晰的数据结构进行沟通。如果两个方向之间有更多的来回调用,就应该先创建适当的模块,然后用搬移函数(198)把处理逻辑分开。如果函数内部混合了两类处理逻辑,应该先用提炼函数(106)将其分开,然后再做搬移。如果模块是以类的形式定义的,就可以用提炼类(182)来做拆分。
94 |
95 | ## 3.8 霰弹式修改(Shotgun Surgery)
96 |
97 | 霰弹式修改类似于发散式变化,但又恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。
98 |
99 | 这种情况下,你应该使用搬移函数(198)和搬移字段(207)把所有需要修改的代码放进同一个模块里。如果有很多函数都在操作相似的数据,可以使用函数组合成类(144)。如果有些函数的功能是转化或者充实数据结构,可以使用函数组合成变换(149)。如果一些函数的输出可以组合后提供给一段专门使用这些计算结果的逻辑,这种时候常常用得上拆分阶段(154)。
100 |
101 | 面对霰弹式修改,一个常用的策略就是使用与内联(inline)相关的重构——如内联函数(115)或是内联类(186)——把本不该分散的逻辑拽回一处。完成内联之后,你可能会闻到过长函数或者过大的类的味道,不过你总可以用与提炼相关的重构手法将其拆解成更合理的小块。即便如此钟爱小型的函数和类,我们也并不担心在重构的过程中暂时创建一些较大的程序单元。
102 |
103 | ## 3.9 依恋情结(Feature Envy)
104 |
105 | 所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。无数次经验里,我们看到某个函数为了计算某个值,从另一个对象那儿调用几乎半打的取值函数。疗法显而易见:这个函数想跟这些数据待在一起,那就使用搬移函数(198)把它移过去。有时候,函数中只有一部分受这种依恋之苦,这时候应该使用提炼函数(106)把这一部分提炼到独立的函数中,再使用搬移函数(198)带它去它的梦想家园。
106 |
107 | 当然,并非所有情况都这么简单。一个函数往往会用到几个模块的功能,那么它究竟该被置于何处呢?我们的原则是:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。如果先以提炼函数(106)将这个函数分解为数个较小的函数并分别置放于不同地点,上述步骤也就比较容易完成了。
108 |
109 | 有几个复杂精巧的模式破坏了这条规则。说起这个话题,GoF[gof]的策略(Strategy)模式和访问者(Visitor)模式立刻跳入我的脑海,Kent Beck 的 Self Delegation 模式[Beck SBPP]也在此列。使用这些模式是为了对抗发散式变化这一坏味道。最根本的原则是:将总是一起变化的东西放在一块儿。数据和引用这些数据的行为总是一起变化的,但也有例外。如果例外出现,我们就搬移那些行为,保持变化只在一地发生。策略模式和和访问者模式使你得以轻松修改函数的行为,因为它们将少量需被覆写的行为隔离开来——当然也付出了“多一层间接性”的代价。
110 |
111 | ## 3.10 数据泥团(Data Clumps)
112 |
113 | 数据项就像小孩子,喜欢成群结队地待在一块儿。你常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。首先请找出这些数据以字段形式出现的地方,运用提炼类(182)将它们提炼到一个独立对象中。然后将注意力转移到函数签名上,运用引入参数对象(140)或保持对象完整(319)为它瘦身。这么做的直接好处是可以将很多参数列表缩短,简化函数调用。是的,不必在意数据泥团只用上新对象的一部分字段,只要以新对象取代两个(或更多)字段,就值得这么做。
114 |
115 | 一个好的评判办法是:删掉众多数据中的一项。如果这么做,其他数据有没有因而失去意义?如果它们不再有意义,这就是一个明确信号:你应该为它们产生一个新对象。
116 |
117 | 我们在这里提倡新建一个类,而不是简单的记录结构,因为一旦拥有新的类,你就有机会让程序散发出一种芳香。得到新的类以后,你就可以着手寻找“依恋情结”,这可以帮你指出能够移至新类中的种种行为。这是一种强大的动力:有用的类被创建出来,大量的重复被消除,后续开发得以加速,原来的数据泥团终于在它们的小社会中充分发挥价值。
118 |
119 | ## 3.11 基本类型偏执(Primitive Obsession)
120 |
121 | 大多数编程环境都大量使用基本类型,即整数、浮点数和字符串等。一些库会引入一些小对象,如日期。但我们发现一个很有趣的现象:很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。于是,我们看到了把钱当作普通数字来计算的情况、计算物理量时无视单位(如把英寸与毫米相加)的情况以及大量类似 if (a < upper && a > lower)这样的代码。
122 |
123 | 字符串是这种坏味道的最佳培养皿,比如,电话号码不只是一串字符。一个体面的类型,至少能包含一致的显示逻辑,在用户界面上需要显示时可以使用。“用字符串来代表类似这样的数据”是如此常见的臭味,以至于人们给这类变量专门起了一个名字,叫它们“类字符串类型”(stringly typed)变量。
124 |
125 | 你可以运用以对象取代基本类型(174)将原本单独存在的数据值替换为对象,从而走出传统的洞窟,进入炙手可热的对象世界。如果想要替换的数据值是控制条件行为的类型码,则可以运用以子类取代类型码(362)加上以多态取代条件表达式(272)的组合将它换掉。
126 |
127 | 如果你有一组总是同时出现的基本类型数据,这就是数据泥团的征兆,应该运用提炼类(182)和引入参数对象(140)来处理。
128 |
129 | ## 3.12 重复的 switch (Repeated Switches)
130 |
131 | 如果你跟真正的面向对象布道者交谈,他们很快就会谈到 switch 语句的邪恶。在他们看来,任何 switch 语句都应该用以多态取代条件表达式(272)消除掉。我们甚至还听过这样的观点:所有条件逻辑都应该用多态取代,绝大多数 if 语句都应该被扫进历史的垃圾桶。
132 |
133 | 即便在不知天高地厚的青年时代,我们也从未无条件地反对条件语句。在本书第 1 版中,这种坏味道被称为“switch 语句”(Switch Statements),那是因为在 20 世纪 90 年代末期,程序员们太过于忽视多态的价值,我们希望矫枉过正。
134 |
135 | 如今的程序员已经更多地使用多态,switch 语句也不再像 15 年前那样有害无益,很多语言支持更复杂的 switch 语句,而不只是根据基本类型值来做条件判断。因此,我们现在更关注重复的 switch:在不同的地方反复使用同样的 switch 逻辑(可能是以 switch/case 语句的形式,也可能是以连续的 if/else 语句的形式)。重复的 switch 的问题在于:每当你想增加一个选择分支时,必须找到所有的 switch,并逐一更新。多态给了我们对抗这种黑暗力量的武器,使我们得到更优雅的代码库。
136 |
137 | ## 3.13 循环语句(Loops)
138 |
139 | 从最早的编程语言开始,循环就一直是程序设计的核心要素。但我们感觉如今循环已经有点儿过时,就像喇叭裤和植绒壁纸那样。其实在撰写本书第 1 版的时候,我们就已经开始鄙视循环语句,但和当时的大多数编程语言一样,当时的 Java 还没有提供更好的替代品。如今,函数作为一等公民已经得到了广泛的支持,因此我们可以使用以管道取代循环(231)来让这些老古董退休。我们发现,管道操作(如 filter 和 map)可以帮助我们更快地看清被处理的元素以及处理它们的动作。
140 |
141 | ## 3.14 冗赘的元素(Lazy Element)
142 |
143 | 程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。可能有这样一个函数,它的名字就跟实现代码看起来一模一样;也可能有这样一个类,根本就是一个简单的函数。这可能是因为,起初在编写这个函数时,程序员也许期望它将来有一天会变大、变复杂,但那一天从未到来;也可能是因为,这个类原本是有用的,但随着重构的进行越变越小,最后只剩了一个函数。不论上述哪一种原因,请让这样的程序元素庄严赴义吧。通常你只需要使用内联函数(115)或是内联类(186)。如果这个类处于一个继承体系中,可以使用折叠继承体系(380)。
144 |
145 | ## 3.15 夸夸其谈通用性(Speculative Generality)
146 |
147 | 这个令我们十分敏感的坏味道,命名者是 Brian Foote。当有人说“噢,我想我们总有一天需要做这事”,并因而企图以各式各样的钩子和特殊情况来处理一些非必要的事情,这种坏味道就出现了。这么做的结果往往造成系统更难理解和维护。如果所有装置都会被用到,就值得那么做;如果用不到,就不值得。用不上的装置只会挡你的路,所以,把它搬开吧。
148 |
149 | 如果你的某个抽象类其实没有太大作用,请运用折叠继承体系(380)。不必要的委托可运用内联函数(115)和内联类(186)除掉。如果函数的某些参数未被用上,可以用改变函数声明(124)去掉这些参数。如果有并非真正需要、只是为不知远在何处的将来而塞进去的参数,也应该用改变函数声明(124)去掉。
150 |
151 | 如果函数或类的唯一用户是测试用例,这就飘出了坏味道“夸夸其谈通用性”。如果你发现这样的函数或类,可以先删掉测试用例,然后使用移除死代码(237)。
152 |
153 | ## 3.16 临时字段(Temporary Field)
154 |
155 | 有时你会看到这样的类:其内部某个字段仅为某种特定情况而设。这样的代码让人不易理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。
156 |
157 | 请使用提炼类(182)给这个可怜的孤儿创造一个家,然后用搬移函数(198)把所有和这些字段相关的代码都放进这个新家。也许你还可以使用引入特例(289)在“变量不合法”的情况下创建一个替代对象,从而避免写出条件式代码。
158 |
159 | ## 3.17 过长的消息链(Message Chains)
160 |
161 | 如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象……这就是消息链。在实际代码中你看到的可能是一长串取值函数或一长串临时变量。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。
162 |
163 | 这时候应该使用隐藏委托关系(189)。你可以在消息链的不同位置采用这种重构手法。理论上,你可以重构消息链上的所有对象,但这么做就会把所有中间对象都变成“中间人”。通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看能否以提炼函数(106)把使用该对象的代码提炼到一个独立的函数中,再运用搬移函数(198)把这个函数推入消息链。如果还有许多客户端代码需要访问链上的其他对象,同样添加一个函数来完成此事。
164 |
165 | 有些人把任何函数链都视为坏东西,我们不这样想。我们的冷静镇定是出了名的,起码在这件事上是这样的。
166 |
167 | ## 3.18 中间人(Middle Man)
168 |
169 | 对象的基本特征之一就是封装——对外部世界隐藏其内部细节。封装往往伴随着委托。比如,你问主管是否有时间参加一个会议,他就把这个消息“委托”给他的记事簿,然后才能回答你。很好,你没必要知道这位主管到底使用传统记事簿还是使用电子记事簿抑或是秘书来记录自己的约会。
170 |
171 | 但是人们可能过度运用委托。你也许会看到某个类的接口有一半的函数都委托给其他类,这样就是过度运用。这时应该使用移除中间人(192),直接和真正负责的对象打交道。如果这样“不干实事”的函数只有少数几个,可以运用内联函数(115)把它们放进调用端。如果这些中间人还有其他行为,可以运用以委托取代超类(399)或者以委托取代子类(381)把它变成真正的对象,这样你既可以扩展原对象的行为,又不必负担那么多的委托动作。
172 |
173 | ## 3.19 内幕交易(Insider Trading)
174 |
175 | 软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。
176 |
177 | 如果两个模块总是在咖啡机旁边窃窃私语,就应该用搬移函数(198)和搬移字段(207)减少它们的私下交流。如果两个模块有共同的兴趣,可以尝试再新建一个模块,把这些共用的数据放在一个管理良好的地方;或者用隐藏委托关系(189),把另一个模块变成两者的中介。
178 |
179 | 继承常会造成密谋,因为子类对超类的了解总是超过后者的主观愿望。如果你觉得该让这个孩子独立生活了,请运用以委托取代子类(381)或以委托取代超类(399)让它离开继承体系。
180 |
181 | ## 3.20 过大的类(Large Class)
182 |
183 | 如果想利用单个类做太多事情,其内往往就会出现太多字段。一旦如此,重复代码也就接踵而至了。
184 |
185 | 你可以运用提炼类(182)将几个变量一起提炼至新类内。提炼时应该选择类内彼此相关的变量,将它们放在一起。例如,depositAmount 和 depositCurrency 可能应该隶属同一个类。通常,如果类内的数个变量有着相同的前缀或后缀,这就意味着有机会把它们提炼到某个组件内。如果这个组件适合作为一个子类,你会发现提炼超类(375)或者以子类取代类型码(362)(其实就是提炼子类)往往比较简单。
186 |
187 | 有时候类并非在所有时刻都使用所有字段。若果真如此,你或许可以进行多次提炼。
188 |
189 | 和“太多实例变量”一样,类内如果有太多代码,也是代码重复、混乱并最终走向死亡的源头。最简单的解决方案(还记得吗,我们喜欢简单的解决方案)是把多余的东西消弭于类内部。如果有 5 个“百行函数”,它们之中很多代码都相同,那么或许你可以把它们变成 5 个“十行函数”和 10 个提炼出来的“双行函数”。
190 |
191 | 观察一个大类的使用者,经常能找到如何拆分类的线索。看看使用者是否只用到了这个类所有功能的一个子集,每个这样的子集都可能拆分成一个独立的类。一旦识别出一个合适的功能子集,就试用提炼类(182)、提炼超类(375)或是以子类取代类型码(362)将其拆分出来。
192 |
193 | ## 3.21 异曲同工的类(Alternative Classes with Different Interfaces)
194 |
195 | 使用类的好处之一就在于可以替换:今天用这个类,未来可以换成用另一个类。但只有当两个类的接口一致时,才能做这种替换。可以用改变函数声明(124)将函数签名变得一致。但这往往还不够,请反复运用搬移函数(198)将某些行为移入类中,直到两者的协议一致为止。如果搬移过程造成了重复代码,或许可运用提炼超类(375)补偿一下。
196 |
197 | ## 3.22 纯数据类(Data Class)
198 |
199 | 所谓纯数据类是指:它们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。这样的类只是一种不会说话的数据容器,它们几乎一定被其他类过分细琐地操控着。这些类早期可能拥有 public 字段,若果真如此,你应该在别人注意到它们之前,立刻运用封装记录(162)将它们封装起来。对于那些不该被其他类修改的字段,请运用移除设值函数(331)。
200 |
201 | 然后,找出这些取值/设值函数被其他类调用的地点。尝试以搬移函数(198)把那些调用行为搬移到纯数据类里来。如果无法搬移整个函数,就运用提炼函数(106)产生一个可被搬移的函数。
202 |
203 | 纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。但也有例外情况,一个最好的例外情况就是,纯数据记录对象被用作函数调用的返回结果,比如使用拆分阶段(154)之后得到的中转数据结构就是这种情况。这种结果数据对象有一个关键的特征:它是不可修改的(至少在拆分阶段(154)的实际操作中是这样)。不可修改的字段无须封装,使用者可以直接通过字段取得数据,无须通过取值函数。
204 |
205 | ## 3.23 被拒绝的遗赠(Refused Bequest)
206 |
207 | 子类应该继承超类的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?它们得到所有礼物,却只从中挑选几样来玩!
208 |
209 | 按传统说法,这就意味着继承体系设计错误。你需要为这个子类新建一个兄弟类,再运用函数下移(359)和字段下移(361)把所有用不到的函数下推给那个兄弟。这样一来,超类就只持有所有子类共享的东西。你常常会听到这样的建议:所有超类都应该是抽象(abstract)的。
210 |
211 | 既然使用“传统说法”这个略带贬义的词,你就可以猜到,我们不建议你这么做,起码不建议你每次都这么做。我们经常利用继承来复用一些行为,并发现这可以很好地应用于日常工作。这也是一种坏味道,我们不否认,但气味通常并不强烈,所以我们说,如果“被拒绝的遗赠”正在引起困惑和问题,请遵循传统忠告。但不必认为你每次都得那么做。十有八九这种坏味道很淡,不值得理睬。
212 |
213 | 如果子类复用了超类的行为(实现),却又不愿意支持超类的接口,“被拒绝的遗赠”的坏味道就会变得很浓烈。拒绝继承超类的实现,这一点我们不介意;但如果拒绝支持超类的接口,这就难以接受了。既然不愿意支持超类的接口,就不要虚情假意地糊弄继承体系,应该运用以委托取代子类(381)或者以委托取代超类(399)彻底划清界限。
214 |
215 | ## 3.24 注释(Comments)
216 |
217 | 别担心,我们并不是说你不该写注释。从嗅觉上说,注释不但不是一种坏味道,事实上它们还是一种香味呢。我们之所以要在这里提到注释,是因为人们常把它当作“除臭剂”来使用。常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。这种情况的发生次数之多,实在令人吃惊。
218 |
219 | 注释可以带我们找到本章先前提到的各种坏味道。找到坏味道后,我们首先应该以各种重构手法把坏味道去除。完成之后我们常常会发现:注释已经变得多余了,因为代码已经清楚地说明了一切。
220 |
221 | 如果你需要注释来解释一块代码做了什么,试试提炼函数(106);如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明(124)为它改名;如果你需要注释说明某些系统的需求规格,试试引入断言(302)。
222 |
223 | ::: tip
224 | 当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。
225 | :::
226 |
227 | 如果你不知道该做什么,这才是注释的良好运用时机。除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。
228 |
--------------------------------------------------------------------------------
/docs/ch4.md:
--------------------------------------------------------------------------------
1 | # 第 4 章 构筑测试体系
2 |
3 | 重构是很有价值的工具,但只有重构还不行。要正确地进行重构,前提是得有一套稳固的测试集合,以帮我发现难以避免的疏漏。即便有工具可以帮我自动完成一些重构,很多重构手法依然需要通过测试集合来保障。
4 |
5 | 我并不把这视为缺点。我发现,编写优良的测试程序,可以极大提高我的编程速度,即使不进行重构也一样如此。这让我很吃惊,也违反许多程序员的直觉,所以我有必要解释一下这个现象。
6 |
7 | ## 4.1 自测试代码的价值
8 |
9 | 如果你认真观察大多数程序员如何分配他们的时间,就会发现,他们编写代码的时间仅占所有时间中很少的一部分。有些时间用来决定下一步干什么,有些时间花在设计上,但是,花费在调试上的时间是最多的。我敢肯定,每一位读者一定都记得自己花数小时调试代码的经历——而且常常是通宵达旦。每个程序员都能讲出一个为了修复一个 bug 花费了一整天(甚至更长时间)的故事。修复 bug 通常是比较快的,但找出 bug 所在却是一场噩梦。当修复一个 bug 时,常常会引起另一个 bug,却在很久之后才会注意到它。那时,你又要花上大把时间去定位问题。
10 |
11 | 我走上“自测试代码”这条路,源于 1992 年 OOPSLA 大会上的一个演讲。有个人(我记得好像是 Bedarra 公司的 Dave Thomas)提到:“类应该包含它们自己的测试代码。”这让我决定,将测试代码和产品代码一起放到代码库中。
12 |
13 | 当时,我正在迭代方式开发一个软件,因此,我尝试在每个迭代结束后把测试代码加上。当时我的软件项目很小,我们每周进行一次迭代。所以,运行测试变得相当简单——尽管非常简单,但也非常枯燥。因为每个测试都把测试结果输出到控制台中,我必须逐一检查它们。我是一个很懒的人,所以总是在当下努力工作,以免日后有更多的活儿。我意识到,其实完全不必自己盯着屏幕检验测试输出的信息是否正确,而是让计算机来帮我做检查。我需要做的就是把我所期望的输出放到测试代码中,然后做一个对比就行了。于是,我只要运行所有测试用例,假如一切都没问题,屏幕上就只出现一个“OK”。现在我的代码都能够“自测试”了。
14 |
15 | 从此,运行测试就像执行编译一样简单。于是,我每次编译时都会运行测试。不久之后,我注意到自己的开发效率大大提高。我意识到,这是因为我没有花太多时间去测试的缘故。如果我不小心引入一个可被现有测试捕捉到的 bug,那么只要运行测试,它就会向我报告这个 bug。由于代码原本是可以正常运行的,所以我知道这个 bug 必定是在前一次运行测试后修改代码引入的。由于我频繁地运行测试,每次测试都在不久之前,因此我知道 bug 的源头就是我刚刚写下的代码。因为代码量很少,我对它也记忆犹新,所以就能轻松找出 bug。从前需要一小时甚至更多时间才能找到的 bug,现在最多只要几分钟就找到了。之所以能够拥有如此强大的 bug 侦测能力,不仅仅是因为我的代码能够自测试,也得益于我频繁地运行它们。
16 |
17 | ::: tip
18 | 确保所有测试都完全自动化,让它们检查自己的测试结果。
19 | :::
20 |
21 | 注意到这一点后,我对测试的积极性更高了。我不再等待每次迭代结尾时再增加测试,而是只要写好一点功能,就立即添加它们。每天我都会添加一些新功能,同时也添加相应的测试。这样,我很少花超过几分钟的时间来追查回归错误。
22 |
23 | 从我最早的试验开始到现在为止,编写和组织自动化测试的工具已经有了长足的发展。1997 年,Kent Beck 从瑞士飞往亚特兰大去参加当年的 OOPSLA 会议,在飞机上他与 Erich Gamma 结对,把他为 Smalltalk 撰写的测试框架移植到了 Java 上。由此诞生的 JUnit 框架在测试领域影响力非凡,也在不同的编程语言中催生了很多类似的工具[mf-xunit]。
24 |
25 | ::: tip
26 | 一套测试就是一个强大的 bug 侦测器,能够大大缩减查找 bug 所需的时间。
27 | :::
28 |
29 | 我得承认,说服别人也这么做并不容易。编写测试程序,意味着要写很多额外的代码。除非你确实体会到这种方法是如何提升编程速度的,否则自测试似乎就没什么意义。很多人根本没学过如何编写测试程序,甚至根本没考虑过测试,这对于编写自测试也很不利。如果测试需要手动运行,那的确是令人烦闷。但是,如果测试可以自动运行,编写测试代码就会真的很有趣。
30 |
31 | 事实上,撰写测试代码的最好时机是在开始动手编码之前。当我需要添加特性时,我会先编写相应的测试代码。听起来离经叛道,其实不然。编写测试代码其实就是在问自己:为了添加这个功能,我需要实现些什么?编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。预先写好的测试代码也为我的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
32 |
33 | Kent Beck 将这种先写测试的习惯提炼成一门技艺,叫测试驱动开发(Test-Driven Development,TDD)[mf-tdd]。测试驱动开发的编程方式依赖于下面这个短循环:先编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。这个“测试、编码、重构”的循环应该在每个小时内都完成很多次。这种良好的节奏感可使编程工作以更加高效、有条不紊的方式开展。我就不在这里再做更深入的介绍,但我自己确实经常使用,也非常建议你试一试。
34 |
35 | 大道理先放在一边。尽管我相信每个人都可以从编写自测试代码中收益,但这并不是本书的重点。本书谈的是重构,而重构需要测试。如果你想重构,就必须编写测试。本章会带你入门,教你如何在 JavaScript 中编写简单的测试,但它不是一本专门讲测试的书,所以我不想讲得太细。但我发现,少许测试往往就足以带来惊人的收益。
36 |
37 | 和本书其他内容一样,我以示例来介绍测试手法。开发软件的时候,我一边写代码,一边写测试。但有时我也需要重构一些没有测试的代码。在重构之前,我得先改造这些代码,使其能够自测试才行。
38 |
39 | ## 4.2 待测试的示例代码
40 |
41 | 这里我展示了一份有待测试的代码。这份代码来自一个简单的应用,用于支持用户查看并调整生产计划。它的(略显粗糙的)界面长得像下面这张图所示的这样。
42 |
43 | 
44 |
45 | 每个行省(province)都有一份生产计划,计划中包含需求量(demand)和采购价格(price)。每个行省都有一些生产商(producer),他们各自以不同的成本价(cost)供应一定数量的产品。界面上还会显示,当商家售出所有的商品时,他们可以获得的总收入(full revenue)。页面底部展示了该区域的产品缺额(需求量减去总产量)和总利润(profit)。用户可以在界面上修改需求量及采购价格,以及不同生产商的产量(production)和成本价,以观察缺额和总利润的变化。用户在界面上修改任何数值时,其他的数值都会同时得到更新。
46 |
47 | 这里我展示了一个用户界面,是为了让你了解该应用的使用方式,但我只会聚焦于软件的业务逻辑部分,也就是那些计算利润和缺额的类,而非那些生成 HTML 或监听页面字段更新的代码。本章只是先带你走进自测试代码世界的大门,因而最好是从最简单的例子开始,也就是那些不涉及用户界面、持久化或外部服务交互的代码。这种隔离的思路其实在任何场景下都适用:一旦业务逻辑的部分开始变复杂,我就会把它与 UI 分离开,以便能更好地理解和测试它。
48 |
49 | 这块业务逻辑代码涉及两个类:一个代表了单个生产商(Producer),另一个用来描述一个行省(Province)。Province 类的构造函数接收一个 JavaScript 对象,这个对象的内容我们可以想象是由一个 JSON 文件提供的。
50 |
51 | 下面的代码能从 JSON 文件中构造出一个行省对象。
52 |
53 | #### class Province...
54 |
55 | ```js
56 | constructor(doc) {
57 | this._name = doc.name;
58 | this._producers = [];
59 | this._totalProduction = 0;
60 | this._demand = doc.demand;
61 | this._price = doc.price;
62 | doc.producers.forEach(d => this.addProducer(new Producer(this, d)));
63 | }
64 | addProducer(arg) {
65 | this._producers.push(arg);
66 | this._totalProduction += arg.production;
67 | }
68 | ```
69 |
70 | 下面的函数会创建可用的 JSON 数据,我可以用它的返回值来构造一个行省对象,并拿这个对象来做测试。
71 |
72 | #### 顶层作用域...
73 |
74 | ```js
75 | function sampleProvinceData() {
76 | return {
77 | name: "Asia",
78 | producers: [
79 | { name: "Byzantium", cost: 10, production: 9 },
80 | { name: "Attalia", cost: 12, production: 10 },
81 | { name: "Sinope", cost: 10, production: 6 },
82 | ],
83 | demand: 30,
84 | price: 20,
85 | };
86 | }
87 | ```
88 |
89 | 行省类中有许多设值函数和取值函数,它们用于获取各类数据的值。
90 |
91 | #### class Province...
92 |
93 | ```js
94 | get name() {return this._name;}
95 | get producers() {return this._producers.slice();}
96 | get totalProduction() {return this._totalProduction;}
97 | set totalProduction(arg) {this._totalProduction = arg;}
98 | get demand() {return this._demand;}
99 | set demand(arg) {this._demand = parseInt(arg);}
100 | get price() {return this._price;}
101 | set price(arg) {this._price = parseInt(arg);}
102 | ```
103 |
104 | 设值函数会被 UI 端调用,接收一个包含数值的字符串。我需要将它们转换成数值,以便在后续的计算中使用。
105 |
106 | 代表生产商的 Producer 类则基本只是一个存放数据的容器。
107 |
108 | #### class Producer...
109 |
110 | ```js
111 | constructor(aProvince, data) {
112 | this._province = aProvince;
113 | this._cost = data.cost;
114 | this._name = data.name;
115 | this._production = data.production || 0;
116 | }
117 | get name() {return this._name;}
118 | get cost() {return this._cost;}
119 | set cost(arg) {this._cost = parseInt(arg);}
120 |
121 | get production() {return this._production;}
122 | set production(amountStr) {
123 | const amount = parseInt(amountStr);
124 | const newProduction = Number.isNaN(amount) ? 0 : amount;
125 | this._province.totalProduction += newProduction - this._production;
126 | this._production = newProduction;
127 | }
128 | ```
129 |
130 | 在设值函数 production 中更新派生数据的方式有点丑陋,每当看到这种代码,我便想通过重构帮它改头换面。但在重构之前,我必须记得先为它添加测试。
131 |
132 | 缺额的计算逻辑也很简单。
133 |
134 | #### class Province...
135 |
136 | ```js
137 | get shortfall() {
138 | return this._demand - this.totalProduction;
139 | }
140 | ```
141 |
142 | 计算利润的逻辑则要相对复杂一些。
143 |
144 | #### class Province...
145 |
146 | ```js
147 | get profit() {
148 | return this.demandValue - this.demandCost;
149 | }
150 | get demandCost() {
151 | let remainingDemand = this.demand;
152 | let result = 0;
153 | this.producers
154 | .sort((a,b) => a.cost - b.cost)
155 | .forEach(p => {
156 | const contribution = Math.min(remainingDemand, p.production);
157 | remainingDemand -= contribution;
158 | result += contribution * p.cost;
159 | });
160 | return result;
161 | }
162 | get demandValue() {
163 | return this.satisfiedDemand * this.price;
164 | }
165 | get satisfiedDemand() {
166 | return Math.min(this._demand, this.totalProduction);
167 | }
168 | ```
169 |
170 | ## 4.3 第一个测试
171 |
172 | 开始测试这份代码前,我需要一个测试框架。JavaScript 世界里这样的框架有很多,这里我选用的是使用度和声誉都还不错的 Mocha。我不打算全面讲解框架的使用,而只会用它写一些测试作为例子。看完之后,你应该能轻松地学会用别的框架来编写类似的测试。
173 |
174 | 以下是为缺额计算过程编写的一个简单的测试:
175 |
176 | ```js
177 | describe("province", function () {
178 | it("shortfall", function () {
179 | const asia = new Province(sampleProvinceData());
180 | assert.equal(asia.shortfall, 5);
181 | });
182 | });
183 | ```
184 |
185 | Mocha 框架组织测试代码的方式是将其分组,每一组下包含一套相关的测试。测试需要写在一个 it 块中。对于这个简单的例子,测试包含了两个步骤。第一步设置好一些测试夹具(fixture),也就是测试所需要的数据和对象等(就本例而言是一个加载好了的行省对象);第二步则是验证测试夹具是否具备某些特征(就本例而言则是验证算出的缺额应该是期望的值)。
186 |
187 | ::: tip
188 | 不同开发者在 describe 和 it 块里撰写的描述信息各有不同。有的人会写一个描述性的句子解释测试的内容,也有人什么都不写,认为所谓描述性的句子跟注释一样,不外乎是重复代码已经表达的东西。我个人不喜欢多写,只要测试失败时足以识别出对应的测试就够了。
189 | :::
190 |
191 | 如果我在 NodeJS 的控制台下运行这个测试,那么其输出看起来是这样:
192 |
193 | ```js
194 | ''''''''''''''
195 |
196 | 1 passing (61ms)
197 | ```
198 |
199 | 它的反馈极其简洁,只包含了已运行的测试数量以及测试通过的数量。
200 |
201 | 当我为类似的既有代码编写测试时,发现一切正常工作固然是好,但我天然持怀疑精神。特别是有很多测试在运行时,我总会担心测试没有按我期望的方式检查结果,从而没法在实际出错的时候抓到 bug。因此编写测试时,我想看到每个测试都至少失败一遍。我最爱的方式莫过于在代码中暂时引入一个错误,像这样:
202 |
203 | ::: tip
204 | 总是确保测试不该通过时真的会失败。
205 | :::
206 |
207 | #### class Province...
208 |
209 | ```js
210 | get shortfall() {
211 | return this._demand - this.totalProduction * 2;
212 | }
213 | ```
214 |
215 | 现在控制台的输出就有所改变了:
216 |
217 | ```js
218 | !
219 |
220 | 0 passing (72ms)
221 | 1 failing
222 |
223 | 1) province shortfall:
224 | AssertionError: expected -20 to equal 5
225 | at Context.<anonymous> (src/tester.js:10:12)
226 | ```
227 |
228 | 框架会报告哪个测试失败了,并给出失败的根本原因——这里是因为实际算出的值与期望的值不相符。于是我总算见到有什么东西失败了,并且还能马上看到是哪个测试失败,获得一些出错的线索(这个例子中,我还能确认这就是我引入的那个错误)。
229 |
230 | 一个真实的系统可能拥有数千个测试。好的测试框架应该能帮我简单快速地运行这些测试,一旦出错,我能马上看到。尽管这种反馈非常简单,但对自测试代码来说却尤为重要。工作时我会非常频繁地运行测试,要么是检验新代码的进展,要么是检查重构过程是否出错。
231 |
232 | ::: tip
233 | 频繁地运行测试。对于你正在处理的代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一次所有的测试。
234 | :::
235 |
236 | Mocha 框架允许使用不同的库(它称之为断言库)来验证测试的正确性。JavaScript 世界的断言库,连在一起都可以绕地球一周了,当你读到这里时,可能有些仍然还没过时。我现在使用的库是 Chai,它可以支持我编写不同类型的断言,比如“assert”风格的:
237 |
238 | ```js
239 | describe("province", function () {
240 | it("shortfall", function () {
241 | const asia = new Province(sampleProvinceData());
242 | assert.equal(asia.shortfall, 5);
243 | });
244 | });
245 | ```
246 |
247 | 或者是“expect”风格的:
248 |
249 | ```js
250 | describe("province", function () {
251 | it("shortfall", function () {
252 | const asia = new Province(sampleProvinceData());
253 | expect(asia.shortfall).equal(5);
254 | });
255 | });
256 | ```
257 |
258 | 一般来讲我更倾向于使用 assert 风格的断言,但使用 JavaScript 时我倒是更常使用 expect 的风格。
259 |
260 | 环境不同,运行测试的方式也不同。使用 Java 编程时,我使用 IDE 的图形化测试运行界面。它有一个进度条,所有测试都通过时就会显示绿色;只要有任何测试失败,它就会变成红色。我的同事们经常使用“绿色条”和“红色条”来指代测试的状态。我可能会讲“看到红条时永远不许进行重构”,意思是:测试集合中还有失败的测试时就不应该先去重构。有时我也会讲“回退到绿条”,表示你应该撤销最近一次更改,将测试恢复到上一次全部通过的状态(通常是切回到版本控制的最近一次提交点)。
261 |
262 | 图形化测试界面的确很棒,但并不是必需的。我通常会在 Emacs 中配置一个运行测试的快捷键,然后在编译窗口中观察纯文本的反馈。要点在于,我必须能快速地知道测试是否全部都通过了。
263 |
264 | ## 4.4 再添加一个测试
265 |
266 | 现在,我将继续添加更多测试。我遵循的风格是:观察被测试类应该做的所有事情,然后对这个类的每个行为进行测试,包括各种可能使它发生异常的边界条件。这不同于某些程序员提倡的“测试所有 public 函数”的风格。记住,测试应该是一种风险驱动的行为,我测试的目标是希望找出现在或未来可能出现的 bug。所以我不会去测试那些仅仅读或写一个字段的访问函数,因为它们太简单了,不太可能出错。
267 |
268 | 这一点很重要,因为如果尝试撰写过多测试,结果往往反而导致测试不充分。事实上,即使我只做一点点测试,也从中获益良多。测试的重点应该是那些我最担心出错的部分,这样就能从测试工作中得到最大利益。
269 |
270 | 接下来,我的目光落到了代码的另一个主要输出上,也就是总利润的计算。我同样可以在一开始的测试夹具上,对总利润做一个基本的测试。
271 |
272 | ::: tip
273 | 编写未臻完善的测试并经常运行,好过对完美测试的无尽等待。
274 | :::
275 |
276 | ```js
277 | describe("province", function () {
278 | it("shortfall", function () {
279 | const asia = new Province(sampleProvinceData());
280 | expect(asia.shortfall).equal(5);
281 | });
282 | it("profit", function () {
283 | const asia = new Province(sampleProvinceData());
284 | expect(asia.profit).equal(230);
285 | });
286 | });
287 | ```
288 |
289 | 这是最终写出来的测试,但我是怎么写出它来的呢?首先我随便给测试的期望值写了一个数,然后运行测试,将程序产生的实际值(230)填回去。当然,我也可以自己手动计算,不过,既然现在的代码是能正常运行的,我就选择暂时相信它。测试可以正常工作后,我又故技重施,在利润的计算过程插入一个假的乘以 2 逻辑来破坏测试。如我所料,测试会失败,这时我才满意地将插入的假逻辑恢复过来。这个模式是我为既有代码添加测试时最常用的方法:先随便填写一个期望值,再用程序产生的真实值来替换它,然后引入一个错误,最后恢复错误。
290 |
291 | 这个测试随即产生了一些重复代码——它们都在第一行里初始化了同一个测试夹具。正如我对一般的重复代码抱持怀疑,测试代码中的重复同样令我心生疑惑,因此我要试着将它们提到一处公共的地方,以此来消灭重复。一种方案就是把常量提取到外层作用域里。
292 |
293 | ```js
294 | describe("province", function () {
295 | const asia = new Province(sampleProvinceData()); // DON'T DO THIS
296 | it("shortfall", function () {
297 | expect(asia.shortfall).equal(5);
298 | });
299 | it("profit", function () {
300 | expect(asia.profit).equal(230);
301 | });
302 | });
303 | ```
304 |
305 | 但正如代码注释所说的,我从不这样做。这样做的确能解决一时的问题,但共享测试夹具会使测试间产生交互,这是滋生 bug 的温床——还是你写测试时能遇见的最恶心的 bug 之一。使用了 JavaScript 中的 const 关键字只表明 asia 的引用不可修改,不表明对象的内容也不可修改。如果未来有一个测试改变了这个共享对象,测试就可能时不时失败,因为测试之间会通过共享夹具产生交互,而测试的结果就会受测试运行次序的影响。测试结果的这种不确定性,往往使你陷入漫长而又艰难的调试,严重时甚至可能令你对测试体系的信心产生动摇。因此,我比较推荐采取下面的做法:
306 |
307 | ```js
308 | describe("province", function () {
309 | let asia;
310 | beforeEach(function () {
311 | asia = new Province(sampleProvinceData());
312 | });
313 | it("shortfall", function () {
314 | expect(asia.shortfall).equal(5);
315 | });
316 | it("profit", function () {
317 | expect(asia.profit).equal(230);
318 | });
319 | });
320 | ```
321 |
322 | beforeEach 子句会在每个测试之前运行一遍,将 asia 变量清空,每次都给它赋一个新的值。这样我就能在每个测试开始前,为它们各自构建一套新的测试夹具,这保证了测试的独立性,避免了可能带来麻烦的不确定性。
323 |
324 | 对于这样的建议,有人可能会担心,每次创建一个崭新的测试夹具会拖慢测试的运行速度。大多数时候,时间上的差别几乎无法察觉。如果运行速度真的成为问题,我也可以考虑共享测试夹具,但这样我就得非常小心,确保没有测试会去更改它。如果我能够确定测试夹具是百分之百不可变的,那么也可以共享它。但我的本能反应还是要使用独立的测试夹具,可能因为我过去尝过了太多共享测试夹具带来的苦果。
325 |
326 | 既然我在 beforeEach 里运行的代码会对每个测试生效,那么为何不直接把它挪到每个 it 块里呢?让所有测试共享一段测试夹具代码的原因,是为了使我对公用的夹具代码感到熟悉,从而将眼光聚焦于每个测试的不同之处。beforeEach 块旨在告诉读者,我使用了同一套标准夹具。你可以接着阅读 describe 块里的所有测试,并知道它们都是基于同样的数据展开测试的。
327 |
328 | ## 4.5 修改测试夹具
329 |
330 | 加载完测试夹具后,我编写了一些测试来探查它的一些特性。但在实际应用中,该夹具可能会被频繁更新,因为用户可能在界面上修改数值。
331 |
332 | 大多数更新都是通过设值函数完成的,我一般也不会测试这些方法,因为它们不太可能出什么 bug。不过 Producer 类中的产量(production)字段,其设值函数行为比较复杂,我觉得它倒是值得一测。
333 |
334 | ```js
335 | describe('province'...
336 | it('change production', function() {
337 | asia.producers[0].production = 20;
338 | expect(asia.shortfall).equal(-6);
339 | expect(asia.profit).equal(292);
340 | });
341 | ```
342 |
343 | 这是一个常见的测试模式。我拿到 beforeEach 配置好的初始标准夹具,然后对该夹具进行必要的检查,最后验证它是否表现出我期望的行为。如果你读过测试相关的资料,就会经常听到各种类似的术语,比如配置-检查-验证(setup-exercise-verify)、given-when-then 或者准备-行为-断言(arrange-act-assert)等。有时你能在一个测试里见到所有的步骤,有时那些早期的公用阶段会被提到一些标准的配置步骤里,诸如 beforeEach 等。
344 |
345 | ::: tip
346 | (其实还有第四个阶段,只是不那么明显,一般很少提及,那就是拆除阶段。此阶段可将测试夹具移除,以确保不同测试之间不会产生交互。因为我是在 beforeEach 中配置好数据的,所以测试框架会默认在不同的测试间将我的测试夹具移除,相当于我自动享受了拆除阶段带来的便利。多数测试文献的作者对拆除阶段一笔带过,这可以理解,因为多数时候我们可以忽略它。但有时因为创建缓慢等原因,我们会在不同的测试间共享测试夹具,此时,显式地声明一个拆除操作就是很重要的。)
347 | :::
348 |
349 | 在这个测试中,我在一个 it 语句里验证了两个不同的特性。作为一个基本规则,一个 it 语句中最好只有一个验证语句,否则测试可能在进行第一个验证时就失败,这通常会掩盖一些重要的错误信息,不利于你了解测试失败的原因。不过,在上面的场景中,我觉得两个断言本身关系非常紧密,写在同一个测试中问题不大。如果稍后需要将它们分离到不同的 it 语句中,我可以到时再做。
350 |
351 | ## 4.6 探测边界条件
352 |
353 | 到目前为止我的测试都聚焦于正常的行为上,这通常也被称为“正常路径”(happy path),它指的是一切工作正常、用户使用方式也最符合规范的那种场景。同时,把测试推到这些条件的边界处也是不错的实践,这可以检查操作出错时软件的表现。
354 |
355 | 无论何时,当我拿到一个集合(比如说此例中的生产商集合)时,我总想看看集合为空时会发生什么。
356 |
357 | ```js
358 | describe('no producers', function() {
359 | let noProducers;
360 | beforeEach(function() {
361 | const data = {
362 | name: "No proudcers",
363 | producers: [],
364 | demand: 30,
365 | price: 20
366 | };
367 | noProducers = new Province(data);
368 | });
369 | it('shortfall', function() {
370 | expect(noProducers.shortfall).equal(30);
371 | });
372 | it('profit', function() {
373 | expect(noProducers.profit).equal(0);
374 | });
375 | ```
376 |
377 | 如果拿到的是数值类型,0 会是不错的边界条件:
378 |
379 | ```js
380 | describe('province'...
381 | it('zero demand', function() {
382 | asia.demand = 0;
383 | expect(asia.shortfall).equal(-25);
384 | expect(asia.profit).equal(0);
385 | });
386 | ```
387 |
388 | 负值同样值得一试:
389 |
390 | ```js
391 | describe('province'...
392 | it('negative demand', function() {
393 | asia.demand = -1;
394 | expect(asia.shortfall).equal(-26);
395 | expect(asia.profit).equal(-10);
396 | });
397 | ```
398 |
399 | 测试到这里,我不禁有一个想法:对于这个业务领域来讲,提供一个负的需求值,并算出一个负的利润值意义何在?最小的需求量不应该是 0 吗?或许,设值方法需要对负值有些不同的行为,比如抛出错误,或总是将值设置为 0。这些问题都很好,编写这样的测试能帮助我思考代码本应如何应对边界场景。
400 |
401 | 设值函数接收的字符串是从 UI 上的字段读来的,它已经被限制为只能填入数字,但仍然有可能是空字符串,因此同样需要测试来保证代码对空字符串的处理方式符合我的期望。
402 |
403 | ::: tip
404 | 考虑可能出错的边界条件,把测试火力集中在那儿。
405 | :::
406 |
407 | ```js
408 | describe('province'...
409 | it('empty string demand', function() {
410 | asia.demand = "";
411 | expect(asia.shortfall).NaN;
412 | expect(asia.profit).NaN;
413 | });
414 | ```
415 |
416 | 可以看到,我在这里扮演“程序公敌”的角色。我积极思考如何破坏代码。我发现这种思维能够提高生产力,并且很有趣——它纵容了我内心中比较促狭的那一部分。
417 |
418 | 这个测试结果很有意思:
419 |
420 | ```js
421 | describe('string for producers', function() {
422 | it('', function() {
423 | const data = {
424 | name: "String producers",
425 | producers: "",
426 | demand: 30,
427 | price: 20
428 | };
429 | const prov = new Province(data);
430 | expect(prov.shortfall).equal(0);
431 | });
432 | ```
433 |
434 | 它并不是抛出一个简单的错误说缺额的值不为 0。控制台的报错输出实际如下:
435 |
436 | ```js
437 | '''''''''!
438 |
439 | 9 passing (74ms)
440 | 1 failing
441 |
442 | 1) string for producers :
443 | TypeError: doc.producers.forEach is not a function
444 | at new Province (src/main.js:22:19)
445 | at Context.<anonymous> (src/tester.js:86:18)
446 | ```
447 |
448 | Mocha 把这也当作测试失败(failure),但多数测试框架会把它当作一个错误(error),并与正常的测试失败区分开。“失败”指的是在验证阶段中,实际值与验证语句提供的期望值不相等;而这里的“错误”则是另一码事,它是在更早的阶段前抛出的异常(这里是在配置阶段)。它更像代码的作者没有预料到的一种异常场景,因此我们不幸地得到了每个 JavaScript 程序员都很熟悉的错误(“...is not a function”)。
449 |
450 | 那么代码应该如何处理这种场景呢?一种思路是,对错误进行处理并给出更好的出错响应,比如说抛出更有意义的错误信息,或是直接将 producers 字段设置为一个空数组(最好还能再记录一行日志信息)。但维持现状不做处理也说得通,也许该输入对象是由可信的数据源提供的,比如同个代码库的另一部分。在同一代码库的不同模块之间加入太多的检查往往会导致重复的验证代码,它带来的好处通常不抵害处,特别是你添加的验证可能在其他地方早已做过。但如果该输入对象是由一个外部服务所提供,比如一个返回 JSON 数据的请求,那么校验和测试就显得必要了。不论如何,为边界条件添加测试总能引发这样的思考。
451 |
452 | 如果这样的测试是在重构前写出的,那么我很可能还会删掉它。重构应该保证可观测的行为不发生改变,而类似的错误已经超越可观测的范畴。删掉这条测试,我就不用担心重构过程改变了代码对这个边界条件的处理方式。
453 |
454 | ::: tip
455 | 如果这个错误会导致脏数据在应用中到处传递,或是产生一些很难调试的失败,我可能会用引入断言(302)手法,使代码不满足预设条件时快速失败。我不会为这样的失败断言添加测试,它们本身就是一种测试的形式。
456 | :::
457 |
458 | 什么时候应该停下来?我相信这样的话你已经听过很多次:“任何测试都不能证明一个程序没有 bug。”确实如此,但这并不影响“测试可以提高编程速度”。我曾经见过好几种测试规则建议,其目的都是保证你能够测试所有情况的一切组合。这些东西值得一看,但是别让它们影响你。当测试数量达到一定程度之后,继续增加测试带来的边际效用会递减;如果试图编写太多测试,你也可能因为工作量太大而气馁,最后什么都写不成。你应该把测试集中在可能出错的地方。观察代码,看哪儿变得复杂;观察函数,思考哪些地方可能出错。是的,你的测试不可能找出所有 bug,但一旦进行重构,你可以更好地理解整个程序,从而找到更多 bug。虽然在开始重构之前我会确保有一个测试套件存在,但前进途中我总会加入更多测试。
459 |
460 | ::: tip
461 | 不要因为测试无法捕捉所有的 bug 就不写测试,因为测试的确可以捕捉到大多数 bug。
462 | :::
463 |
464 | ## 4.7 测试远不止如此
465 |
466 | 本章我想讨论的东西到这里就差不多了,毕竟这是一本关于重构而不是测试的书。但测试本身是一个很重要的话题,它既是重构所必要的基础保障,本身也是一个有价值的工具。自本书第 1 版以来,我很高兴看到重构作为一项编程实践在逐步发展,但我更高兴见到业界对测试的态度也在发生转变。之前,测试更多被认为是另一个独立的(所需专业技能也较少的)团队的责任,但现在它愈发成为任何一个软件开发者所必备的技能。如今一个架构的好坏,很大程度要取决于它的可测试性,这是一个好的行业趋势。
467 |
468 | 这里我展示的测试都属于单元测试,它们负责测试一块小的代码单元,运行足够快速。它们是自测试代码的支柱,是一个系统中占绝大多数的测试类型。同时也有其他种类的测试存在,有的专注于组件之间的集成,有的会检验软件跨越几个层级的运行结果,有的用于查找性能问题,不一而足。(而且,同行们对于如何归类测试的争论,恐怕比繁多的测试种类本身还要多。)
469 |
470 | 与编程的许多方面类似,测试也是一种迭代式的活动。除非你技能非常纯熟,或者非常幸运,否则你很难第一次就把测试写对。我发觉我持续地在测试集上工作,就与我在主代码库上的工作一样多。很自然,这意味着我在增加新特性时也要同时添加测试,有时还需要回顾已有的测试:它们足够清晰吗?我需要重构它们,以帮助我更好地理解吗?我拥有的测试是有价值的吗?一个值得养成的好习惯是,每当你遇见一个 bug,先写一个测试来清楚地复现它。仅当测试通过时,才视为 bug 修完。只要测试存在一天,我就知道这个错误永远不会再复现。这个 bug 和对应的测试也会提醒我思考:测试集里是否还有这样不被阳光照耀到的犄角旮旯?
471 |
472 | 一个常见的问题是,“要写多少测试才算足够?”这个问题没有很好的衡量标准。有些人拥护以测试覆盖率[mf-tc]作为指标,但测试覆盖率的分析只能识别出那些未被测试覆盖到的代码,而不能用来衡量一个测试集的质量高低。
473 |
474 | ::: tip
475 | 每当你收到 bug 报告,请先写一个单元测试来暴露这个 bug。
476 | :::
477 |
478 | 一个测试集是否足够好,最好的衡量标准其实是主观的,请你试问自己:如果有人在代码里引入了一个缺陷,你有多大的自信它能被测试集揪出来?这种信心难以被定量分析,盲目自信不应该被计算在内,但自测试代码的全部目标,就是要帮你获得此种信心。如果我重构完代码,看见全部变绿的测试就可以十分自信没有引入额外的 bug,这样,我就可以高兴地说,我已经有了一套足够好的测试。
479 |
480 | 测试同样可能过犹不及。测试写得太多的一个征兆是,相比要改的代码,我在改动测试上花费了更多的时间——并且我能感到测试就在拖慢我。不过尽管过度测试时有发生,相比测试不足的情况还是稀少得多。
481 |
--------------------------------------------------------------------------------
/docs/ch5.md:
--------------------------------------------------------------------------------
1 | # 第 5 章 介绍重构名录
2 |
3 | 本书剩余的篇幅是一份重构的名录。最初这个名录只是我的个人笔记,我用它来提示自己如何以安全且高效的方式进行重构。然后我不断精炼这份名录,对一些重构的深入探索又引出了更多的重构手法。对于不太常用的重构手法,我还是会不断参阅这份名录。
4 |
5 | ## 5.1 重构的记录格式
6 |
7 | 介绍重构时,我采用一种标准格式。每个重构手法都有如下 5 个部分。
8 |
9 | - 首先是名称(name)。要建造一个重构词汇表,名称是很重要的。这个名称也就是我将在本书其他地方使用的名称。如今重构经常会有多个名字,所以我会同时列出常见的别名。
10 | - 名称之后是一个简单的速写(sketch)。这部分可以帮助你更快找到你所需要的重构手法。
11 | - 动机(motivation)为你介绍“为什么需要做这个重构”和“什么情况下不该做这个重构”。
12 | - 做法(mechanics)简明扼要地一步一步介绍如何进行此重构。
13 | - 范例(examples)以一个十分简单的例子说明此重构手法如何运作。
14 |
15 | 速写部分会以代码示例的形式展示重构带来的转变。速写的用意不是解释重构的用途,更不是详细讲解如何操作这个重构;但如果你曾经看过这个重构手法,速写能帮你回忆起它。如果你是第一次接触到这个重构手法,可能最好是先阅读范例部分。我还给每个重构手法画了一幅小图。同样,我也不指望这些小图能说清重构手法的内容,只是提供一点图像记忆的线索。
16 |
17 | “做法”出自我自己的笔记。这些笔记是为了让我在一段时间不做某项重构之后还能记得怎么做。它们也颇为简洁,通常不会解释“为什么要这么做那么做”。我会在“范例”中给出更多解释。这么一来,“做法”就成了简短的笔记。如果你知道该使用哪个重构,但记不清具体步骤,可以参考“做法”部分(至少我是这么使用它们的);如果你初次使用某个重构,可能只参考“做法”还不够,你还需要阅读“范例”。
18 |
19 | 撰写“做法”的时候,我尽量将重构的每个步骤拆得尽可能小。我强调安全的重构方式,所以应该采用非常小的步骤,并且在每个步骤之后进行测试。真正工作时,我通常会采用比这里介绍的“婴儿学步”稍大些的步骤,然而一旦出问题,我就会撤销上一步,换用比较小的步骤。这些步骤还包含一些特殊情况的参考,所以它们也有检查清单的作用。我自己经常忘掉这些该做的事情。
20 |
21 | 绝大多数时候我只列出了重构的一套做法,但其实一个重构并非只有一套做法。我在本书中选择介绍这些做法,因为它们大多数时候都管用。等你经过练习获得更多重构经验,你可能会调整重构的做法,那完全没问题。只要牢记一点:小步前进,情况越复杂,步子就要越小。
22 |
23 | “范例”像是简单而有趣的教科书。我使用这些范例是为了帮助解释重构的基本要素,最大限度地避免其他枝节,所以我希望你能原谅其中的简化工作(它们当然不是优秀的业务建模例子)。不过我敢肯定,你一定能在那些更复杂的情况中使用它们。某些十分简单的重构干脆没有范例,因为我觉得为它们加上一个范例不会有多大意义。
24 |
25 | 更明确地说,加上范例仅仅是为了阐释当时讨论的重构手法。通常那些代码最终仍有其他问题,但修正那些问题需要用到其他重构手法。某些情况下数个重构经常被一并运用,这时候我会把范例带到另一个重构中继续使用。大部分时候,一个范例只为一项重构而设计,这么做是为了让每一项重构手法自成一体,因为这份重构名录的首要目的还是作为参考工具。
26 |
27 | 修改后的代码可能被埋没在未修改的代码中,难以一眼看出,所以我使用不同的颜色突出显示修改过的代码。但我并没有突出显示所有修改过的代码,因为一旦修改过的代码太多,全都突出显示反而不能突显重点。
28 |
29 | ## 5.2 挑选重构的依据
30 |
31 | 这不是一份巨细靡遗的重构名录。我只是认为这些重构手法最值得被记录下来。之所以说它们“最值得”,因为这些都是很常用的重构手法,并且值得给它们命名和详细的介绍:其中一些做法很有意思,能帮助读者提高整体重构技能水平,另外一些则对于代码设计质量的提升效果显著。
32 |
33 | 有些重构没有进入这份名录,因为它们太小、太简单,我觉得没必要多加赘述。例如,在撰写第 1 版时我就曾经考虑过移动语句(223),这个重构我经常使用,但我觉得没必要将它放进名录里(显然我在写第 2 版的时候改变了想法)。以后也许还有类似这样的重构会被加进书里,不过那要看我投入多少精力在新增重构上了。
34 |
35 | 还有一些没有进入名录的重构,要么是我用得很少,要么是与其他重构非常相似。本书中的每个重构,逻辑上来说,都有一个反向的重构。但我并没有把所有反向重构都写下来,因为我发现很多反向重构没太大意思。例如,封装变量(132)是一个常用又好用的重构,但它的反向重构我几乎从来不会做(而且就算要做也非常简单),所以我觉得没必要将这个反向重构放进名录。
36 |
--------------------------------------------------------------------------------
/docs/ch7.md:
--------------------------------------------------------------------------------
1 | # 第 7 章 封装
2 |
3 | 分解模块时最重要的标准,也许就是识别出那些模块应该对外界隐藏的小秘密了[Parnas]。数据结构无疑是最常见的一种秘密,我可以用封装记录(162)或封装集合(170)手法来隐藏它们的细节。即便是基本类型的数据,也能通过以对象取代基本类型(174)进行封装——这样做后续所带来的巨大收益通常令人惊喜。另一项经常在重构时挡道的是临时变量,我需要确保它们的计算次序正确,还得保证其他需要它们的地方能获得其值。这里以查询取代临时变量(178)手法可以帮上大忙,特别是在分解一个过长的函数时。
4 |
5 | 类是为隐藏信息而生的。在第 6 章中,我已经介绍了使用函数组合成类(144)手法来形成类的办法。此外,一般的提炼/内联操作对类也适用,见提炼类(182)和内联类(186)。
6 |
7 | 除了类的内部细节,使用隐藏委托关系(189)隐藏类之间的关联关系通常也很有帮助。但过多隐藏也会导致冗余的中间接口,此时我就需要它的反向重构——移除中间人(192)。
8 |
9 | 类与模块已然是施行封装的最大实体了,但小一点的函数对于封装实现细节也有所裨益。有时候,我可能需要将一个算法完全替换掉,这时我可以用提炼函数(106)将算法包装到函数中,然后使用替换算法(195)。
10 |
11 | ## 7.1 封装记录(Encapsulate Record)
12 |
13 | 曾用名:以数据类取代记录(Replace Record with Data Class)
14 |
15 | ```js
16 | organization = { name: "Acme Gooseberries", country: "GB" };
17 |
18 | class Organization {
19 | constructor(data) {
20 | this._name = data.name;
21 | this._country = data.country;
22 | }
23 | get name() {
24 | return this._name;
25 | }
26 | set name(arg) {
27 | this._name = arg;
28 | }
29 | get country() {
30 | return this._country;
31 | }
32 | set country(arg) {
33 | this._country = arg;
34 | }
35 | }
36 | ```
37 |
38 | ### 动机
39 |
40 | 记录型结构是多数编程语言提供的一种常见特性。它们能直观地组织起存在关联的数据,让我可以将数据作为有意义的单元传递,而不仅是一堆数据的拼凑。但简单的记录型结构也有缺陷,最恼人的一点是,它强迫我清晰地区分“记录中存储的数据”和“通过计算得到的数据”。假使我要描述一个整数闭区间,我可以用{start: 1, end: 5}描述,或者用{start: 1, length: 5}(甚至还能用{end: 5, length: 5},如果我想露两手华丽的编程技巧的话)。但不论如何存储,这 3 个值都是我想知道的,即区间的起点(start)和终点(end),以及区间的长度(length)。
41 |
42 | 这就是对于可变数据,我总是更偏爱使用类对象而非记录的原因。对象可以隐藏结构的细节,仅为这 3 个值提供对应的方法。该对象的用户不必追究存储的细节和计算的过程。同时,这种封装还有助于字段的改名:我可以重新命名字段,但同时提供新老字段名的访问方法,这样我就可以渐进地修改调用方,直到替换全部完成。
43 |
44 | 注意,我所说的偏爱对象,是对可变数据而言。如果数据不可变,我大可直接将这 3 个值保存在记录里,需要做数据变换时增加一个填充步骤即可。重命名记录也一样简单,你可以复制一个字段并逐步替换引用点。
45 |
46 | 记录型结构可以有两种类型:一种需要声明合法的字段名字,另一种可以随便用任何字段名字。后者常由语言库本身实现,并通过类的形式提供出来,这些类称为散列(hash)、映射(map)、散列映射(hashmap)、字典(dictionary)或关联数组(associative array)等。很多编程语言都提供了方便的语法来创建这类记录,这使得它们在各种编程场景下都能大展身手。但使用这类结构也有缺陷,那就是一条记录上持有什么字段往往不够直观。比如说,如果我想知道记录里维护的字段究竟是起点/终点还是起点/长度,就只有查看它的创建点和使用点,除此以外别无他法。若这种记录只在程序的一个小范围里使用,那问题还不大,但若其使用范围变宽,“数据结构不直观”这个问题就会造成更多困扰。我可以重构它,使其变得更直观——但如果真需要这样做,那还不如使用类来得直接。
47 |
48 | 程序中间常常需要互相传递嵌套的列表(list)或散列映射结构,这些数据结构后续经常需要被序列化成 JSON 或 XML。这样的嵌套结构同样值得封装,这样,如果后续其结构需要变更或者需要修改记录内的值,封装能够帮我更好地应对变化。
49 |
50 | ### 做法
51 |
52 | 对持有记录的变量使用封装变量(132),将其封装到一个函数中。
53 |
54 | 记得为这个函数取一个容易搜索的名字。
55 |
56 | 创建一个类,将记录包装起来,并将记录变量的值替换为该类的一个实例。然后在类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数,令其使用这个访问函数。
57 |
58 | 测试。
59 |
60 | 新建一个函数,让它返回该类的对象,而非那条原始的记录。
61 |
62 | 对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例对象的函数调用。使用对象上的访问函数来获取数据的字段,如果该字段的访问函数还不存在,那就创建一个。每次更改之后运行测试。
63 |
64 | 如果该记录比较复杂,例如是个嵌套解构,那么先重点关注客户端对数据的更新操作,对于读取操作可以考虑返回一个数据副本或只读的数据代理。
65 |
66 | 移除类对原始记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除。
67 |
68 | 测试。
69 |
70 | 如果记录中的字段本身也是复杂结构,考虑对其再次应用封装记录(162)或封装集合(170)手法。
71 |
72 | ### 范例
73 |
74 | 首先,我从一个常量开始,该常量在程序中被大量使用。
75 |
76 | ```js
77 | const organization = { name: "Acme Gooseberries", country: "GB" };
78 | ```
79 |
80 | 这是一个普通的 JavaScript 对象,程序中很多地方都把它当作记录型结构在使用。以下是对其进行读取和更新的地方:
81 |
82 | ```js
83 | result += `<h1>${organization.name}</h1>`;
84 | organization.name = newName;
85 | ```
86 |
87 | 重构的第一步很简单,先施展一下封装变量(132)。
88 |
89 | ```js
90 | function getRawDataOfOrganization() {
91 | return organization;
92 | }
93 | ```
94 |
95 | #### 读取的例子...
96 |
97 | ```js
98 | result += `<h1>${getRawDataOfOrganization().name}</h1>`;
99 | ```
100 |
101 | #### 更新的例子...
102 |
103 | ```js
104 | getRawDataOfOrganization().name = newName;
105 | ```
106 |
107 | 这里施展的不全是标准的封装变量(132)手法,我刻意为设值函数取了一个又丑又长、容易搜索的名字,因为我有意不让它在这次重构中活得太久。
108 |
109 | 封装记录意味着,仅仅替换变量还不够,我还想控制它的使用方式。我可以用类来替换记录,从而达到这一目的。
110 |
111 | #### class Organization...
112 |
113 | ```js
114 | class Organization {
115 | constructor(data) {
116 | this._data = data;
117 | }
118 | }
119 | ```
120 |
121 | 顶层作用域
122 |
123 | ```js
124 | const organization = new Organization({
125 | name: "Acme Gooseberries",
126 | country: "GB",
127 | });
128 |
129 | function getRawDataOfOrganization() {
130 | return organization._data;
131 | }
132 | function getOrganization() {
133 | return organization;
134 | }
135 | ```
136 |
137 | 创建完对象后,我就能开始寻找该记录的使用点了。所有更新记录的地方,用一个设值函数来替换它。
138 |
139 | #### class Organization...
140 |
141 | ```js
142 | set name(aString) {this._data.name = aString;}
143 | ```
144 |
145 | #### 客户端...
146 |
147 | ```js
148 | getOrganization().name = newName;
149 | ```
150 |
151 | 同样地,我将所有读取记录的地方,用一个取值函数来替代。
152 |
153 | #### class Organization...
154 |
155 | ```js
156 | get name() {return this._data.name;}
157 | ```
158 |
159 | #### 客户端...
160 |
161 | ```js
162 | result += `<h1>${getOrganization().name}</h1>`;
163 | ```
164 |
165 | 完成引用点的替换后,就可以兑现我之前的死亡威胁,为那个名称丑陋的函数送终了。
166 |
167 | ```js
168 | function getRawDataOfOrganization() {
169 | return organization._data;
170 | }
171 | function getOrganization() {
172 | return organization;
173 | }
174 | ```
175 |
176 | 我还倾向于把\_data 里的字段展开到对象中。
177 |
178 | ```js
179 | class Organization {
180 | constructor(data) {
181 | this._name = data.name;
182 | this._country = data.country;
183 | }
184 | get name() {
185 | return this._name;
186 | }
187 | set name(aString) {
188 | this._name = aString;
189 | }
190 | get country() {
191 | return this._country;
192 | }
193 | set country(aCountryCode) {
194 | this._country = aCountryCode;
195 | }
196 | }
197 | ```
198 |
199 | 这样做有一个好处,能够使外界无须再引用原始的数据记录。直接持有原始的记录会破坏封装的完整性。但有时也可能不适合将对象展开到独立的字段里,此时我就会先将\_data 复制一份,再进行赋值。
200 |
201 | ### 范例:封装嵌套记录
202 |
203 | 上面的例子将记录的浅复制展开到了对象里,但当我处理深层嵌套的数据(比如来自 JSON 文件的数据)时,又该怎么办呢?此时该重构手法的核心步骤依然适用,记录的更新点需要同样小心处理,但对记录的读取点则有多种处理方案。
204 |
205 | 作为例子,这里有一个嵌套层级更深的数据:它是一组顾客信息的集合,保存在散列映射中,并通过顾客 ID 进行索引。
206 |
207 | ```js
208 | "1920": {
209 | name: "martin",
210 | id: "1920",
211 | usages: {
212 | "2016": {
213 | "1": 50,
214 | "2": 55,
215 | // remaining months of the year
216 | },
217 | "2015": {
218 | "1": 70,
219 | "2": 63,
220 | // remaining months of the year
221 | }
222 | }
223 | },
224 | "38673": {
225 | name: "neal",
226 | id: "38673",
227 | // more customers in a similar form
228 | ```
229 |
230 | 对嵌套数据的更新和读取可以进到更深的层级。
231 |
232 | #### 更新的例子...
233 |
234 | ```js
235 | customerData[customerID].usages[year][month] = amount;
236 | ```
237 |
238 | #### 读取的例子...
239 |
240 | ```js
241 | function compareUsage(customerID, laterYear, month) {
242 | const later = customerData[customerID].usages[laterYear][month];
243 | const earlier = customerData[customerID].usages[laterYear - 1][month];
244 | return { laterAmount: later, change: later - earlier };
245 | }
246 | ```
247 |
248 | 对这样的数据施行封装,第一步仍是封装变量(132)。
249 |
250 | ```js
251 | function getRawDataOfCustomers() {
252 | return customerData;
253 | }
254 | function setRawDataOfCustomers(arg) {
255 | customerData = arg;
256 | }
257 | ```
258 |
259 | #### 更新的例子...
260 |
261 | ```js
262 | getRawDataOfCustomers()[customerID].usages[year][month] = amount;
263 | ```
264 |
265 | #### 读取的例子...
266 |
267 | ```js
268 | function compareUsage(customerID, laterYear, month) {
269 | const later = getRawDataOfCustomers()[customerID].usages[laterYear][month];
270 | const earlier = getRawDataOfCustomers()[customerID].usages[laterYear - 1][
271 | month
272 | ];
273 | return { laterAmount: later, change: later - earlier };
274 | }
275 | ```
276 |
277 | 接下来我要创建一个类来容纳整个数据结构。
278 |
279 | ```js
280 | class CustomerData {
281 | constructor(data) {
282 | this._data = data;
283 | }
284 | }
285 | ```
286 |
287 | #### 顶层作用域...
288 |
289 | ```js
290 | function getCustomerData() {
291 | return customerData;
292 | }
293 | function getRawDataOfCustomers() {
294 | return customerData._data;
295 | }
296 | function setRawDataOfCustomers(arg) {
297 | customerData = new CustomerData(arg);
298 | }
299 | ```
300 |
301 | 最重要的是妥善处理好那些更新操作。因此,当我查看 getRawDataOfCustomers 的所有调用者时,总是特别关注那些对数据做修改的地方。再提醒你一下,下面是那步更新操作。
302 |
303 | #### 更新的例子...
304 |
305 | ```js
306 | getRawDataOfCustomers()[customerID].usages[year][month] = amount;
307 | ```
308 |
309 | “做法”部分说,接下来要通过一个访问函数来返回原始的顾客数据,如果访问函数还不存在就创建一个。现在顾客类还没有设值函数,而且这个更新操作对结构进行了深入查找,因此是时候创建一个设值函数了。我会先用提炼函数(106),将层层深入数据结构的查找操作提炼到函数里。
310 |
311 | #### 更新的例子...
312 |
313 | ```js
314 | setUsage(customerID, year, month, amount);
315 | ```
316 |
317 | #### 顶层作用域...
318 |
319 | ```js
320 | function setUsage(customerID, year, month, amount) {
321 | getRawDataOfCustomers()[customerID].usages[year][month] = amount;
322 | }
323 | ```
324 |
325 | 然后我再用搬移函数(198)将新函数搬移到新的顾客数据类中。
326 |
327 | #### 更新的例子...
328 |
329 | ```js
330 | getCustomerData().setUsage(customerID, year, month, amount);
331 | ```
332 |
333 | #### class CustomerData...
334 |
335 | ```js
336 | setUsage(customerID, year, month, amount) {
337 | this._data[customerID].usages[year][month] = amount;
338 | }
339 | ```
340 |
341 | 封装大型的数据结构时,我会更多关注更新操作。凸显更新操作,并将它们集中到一处地方,是此次封装过程最重要的一部分。
342 |
343 | 一通替换过后,我可能认为修改已经告一段落,但如何确认替换是否真正完成了呢?检查的办法有很多,比如可以修改 getRawDataOfCustomers 函数,让其返回一份数据的深复制的副本。如果测试覆盖足够全面,那么当我真的遗漏了一些更新点时,测试就会报错。
344 |
345 | #### 顶层作用域...
346 |
347 | ```js
348 | function getCustomerData() {
349 | return customerData;
350 | }
351 | function getRawDataOfCustomers() {
352 | return customerData.rawData;
353 | }
354 | function setRawDataOfCustomers(arg) {
355 | customerData = new CustomerData(arg);
356 | }
357 | ```
358 |
359 | #### class CustomerData...
360 |
361 | ```js
362 | get rawData() {
363 | return _.cloneDeep(this._data);
364 | }
365 | ```
366 |
367 | 我使用了 lodash 库来辅助生成深复制的副本。
368 |
369 | 另一个方式是,返回一份只读的数据代理。如果客户端代码尝试修改对象的结构,那么该数据代理就会抛出异常。这在有些编程语言中能轻易实现,但用 JavaScript 实现可就麻烦了,我把它留给读者作为练习好了。或者,我可以复制一份数据,递归冻结副本的每个字段,以此阻止对它的任何修改企图。
370 |
371 | 妥善处理好数据的更新当然价值不凡,但读取操作又怎么处理呢?这有几种选择。
372 |
373 | 第一种选择是与设值函数采用同等待遇,把所有对数据的读取提炼成函数,并将它们搬移到 CustomerData 类中。
374 |
375 | #### class CustomerData...
376 |
377 | ```js
378 | usage(customerID, year, month) {
379 | return this._data[customerID].usages[year][month];
380 | }
381 | ```
382 |
383 | #### 顶层作用域...
384 |
385 | ```js
386 | function compareUsage(customerID, laterYear, month) {
387 | const later = getCustomerData().usage(customerID, laterYear, month);
388 | const earlier = getCustomerData().usage(customerID, laterYear - 1, month);
389 | return { laterAmount: later, change: later - earlier };
390 | }
391 | ```
392 |
393 | 这种处理方式的美妙之处在于,它为 customerData 提供了一份清晰的 API 列表,清楚描绘了该类的全部用途。我只需阅读类的代码,就能知道数据的所有用法。但这样会使代码量剧增,特别是当对象有许多用途时。现代编程语言大多提供直观的语法,以支持从深层的列表和散列[mf-lh]结构中获得数据,因此直接把这样的数据结构给到客户端,也不失为一种选择。
394 |
395 | 如果客户端想拿到一份数据结构,我大可以直接将实际的数据交出去。但这样做的问题在于,我将无从阻止用户直接对数据进行修改,进而使我们封装所有更新操作的良苦用心失去意义。最简单的应对办法是返回原始数据的一份副本,这可以用到我前面写的 rawData 方法。
396 |
397 | #### class CustomerData...
398 |
399 | ```js
400 | get rawData() {
401 | return _.cloneDeep(this._data);
402 | }
403 | ```
404 |
405 | #### 顶层作用域...
406 |
407 | ```js
408 | function compareUsage(customerID, laterYear, month) {
409 | const later = getCustomerData().rawData[customerID].usages[laterYear][month];
410 | const earlier = getCustomerData().rawData[customerID].usages[laterYear - 1][
411 | month
412 | ];
413 | return { laterAmount: later, change: later - earlier };
414 | }
415 | ```
416 |
417 | 简单归简单,这种方案也有缺点。最明显的问题是复制巨大的数据结构时代价颇高,这可能引发性能问题。不过也正如我对性能问题的一贯态度,这样的性能损耗也许是可以接受的——只有测量到可见的影响,我才会真的关心它。这种方案还可能带来困惑,比如客户端可能期望对该数据的修改会同时反映到原数据上。如果采用了只读代理或冻结副本数据的方案,就可以在此时提供一个有意义的错误信息。
418 |
419 | 另一种方案需要更多工作,但能提供更可靠的控制粒度:对每个字段循环应用封装记录。我会把顾客(customer)记录变成一个类,对其用途(usage)字段应用封装集合(170),并为它创建一个类。然后我就能通过访问函数来控制其更新点,比如说对用途(usage)对象应用将引用对象改为值对象(252)。但处理一个大型的数据结构时,这种方案异常繁复,如果对该数据结构的更新点没那么多,其实大可不必这么做。有时,合理混用取值函数和新对象可能更明智,即使用取值函数来封装数据的深层查找操作,但更新数据时则用对象来包装其结构,而非直接操作未经封装的数据。我在“Refactoring Code to Load a Document”[mf-ref-doc]这篇文章中讨论了更多的细节,有兴趣的读者可移步阅读。
420 |
421 | ## 7.2 封装集合(Encapsulate Collection)
422 |
423 | ```js
424 | class Person {
425 | get courses() {return this._courses;}
426 | set courses(aList) {this._courses = aList;}
427 |
428 |
429 | class Person {
430 | get courses() {return this._courses.slice();}
431 | addCourse(aCourse) { ... }
432 | removeCourse(aCourse) { ... }
433 | ```
434 |
435 | ### 动机
436 |
437 | 我喜欢封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便。我们通常鼓励封装——使用面向对象技术的开发者对封装尤为重视——但封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。
438 |
439 | 为避免此种情况,我会在类上提供一些修改集合的方法——通常是“添加”和“移除”方法。这样就可使对集合的修改必须经过类,当程序演化变大时,我依然能轻易找出修改点。
440 |
441 | 只要团队拥有良好的习惯,就不会在模块以外修改集合,仅仅提供这些修改方法似乎也就足够。然而,依赖于别人的好习惯是不明智的,一个细小的疏忽就可能带来难以调试的 bug。更好的做法是,不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。
442 |
443 | 一种避免直接修改集合的方法是,永远不直接返回集合的值。这种方法提倡,不要直接使用集合的字段,而是通过定义类上的方法来代替,比如将 aCustomer.orders.size 替换为 aCustomer.numberOfOrders。我不同意这种做法。现代编程语言都提供了丰富的集合类和标准接口,能够组合成很多有价值的用法,比如集合管道(Collection Pipeline)[mf-cp]等。使用特殊的类方法来处理这些场景,会增加许多额外代码,使集合操作容易组合的特性大打折扣。
444 |
445 | 还有一种方法是,以某种形式限制集合的访问权,只允许对集合进行读操作。比如,在 Java 中可以很容易地返回集合的一个只读代理,这种代理允许用户读取集合,但会阻止所有更改操作——Java 的代理会抛出一个异常。有一些库在构造集合时也用了类似的方法,将构造出的集合建立在迭代器或枚举对象的基础上,因为迭代器也不能修改它迭代的集合。
446 |
447 | 也许最常见的做法是,为集合提供一个取值函数,但令其返回一个集合的副本。这样即使有人修改了副本,被封装的集合也不会受到影响。这可能带来一些困惑,特别是对那些已经习惯于通过修改返回值来修改原集合的开发者——但更多的情况下,开发者已经习惯于取值函数返回副本的做法。如果集合很大,这个做法可能带来性能问题,好在多数列表都没有那么大,此时前述的性能优化基本守则依然适用(见 2.8 节)。
448 |
449 | 使用数据代理和数据复制的另一个区别是,对源数据的修改会反映到代理上,但不会反映到副本上。大多数时候这个区别影响不大,因为通过此种方式访问的列表通常生命周期都不长。
450 |
451 | 采用哪种方法并无定式,最重要的是在同个代码库中做法要保持一致。我建议只用一种方案,这样每个人都能很快习惯它,并在每次调用集合的访问函数时期望相同的行为。
452 |
453 | ### 做法
454 |
455 | 如果集合的引用尚未被封装起来,先用封装变量(132)封装它。
456 |
457 | 在类上添加用于“添加集合元素”和“移除集合元素”的函数。
458 |
459 | 如果存在对该集合的设值函数,尽可能先用移除设值函数(331)移除它。如果不能移除该设值函数,至少让它返回集合的一份副本。
460 |
461 | 执行静态检查。
462 |
463 | 查找集合的引用点。如果有调用者直接修改集合,令该处调用使用新的添加/移除元素的函数。每次修改后执行测试。
464 |
465 | 修改集合的取值函数,使其返回一份只读的数据,可以使用只读代理或数据副本。
466 |
467 | 测试。
468 |
469 | ### 范例
470 |
471 | 假设有个人(Person)要去上课。我们用一个简单的 Course 来表示“课程”。
472 |
473 | #### class Person...
474 |
475 | ```js
476 | constructor (name) {
477 | this._name = name;
478 | this._courses = [];
479 | }
480 | get name() {return this._name;}
481 | get courses() {return this._courses;}
482 | set courses(aList) {this._courses = aList;}
483 | ```
484 |
485 | #### class Course...
486 |
487 | ```js
488 | constructor(name, isAdvanced) {
489 | this._name = name;
490 | this._isAdvanced = isAdvanced;
491 | }
492 | get name() {return this._name;}
493 | get isAdvanced() {return this._isAdvanced;}
494 | ```
495 |
496 | 客户端会使用课程集合来获取课程的相关信息。
497 |
498 | ```js
499 | numAdvancedCourses = aPerson.courses
500 | .f ilter(c => c.isAdvanced)
501 | .length
502 | ;
503 | ```
504 |
505 | 有些开发者可能觉得这个类已经得到了恰当的封装,毕竟,所有的字段都被访问函数保护到了。但我要指出,对课程列表的封装还不完整。诚然,对列表整体的任何更新操作,都能通过设值函数得到控制。
506 |
507 | #### 客户端代码...
508 |
509 | ```js
510 | const basicCourseNames = readBasicCourseNames(filename);
511 | aPerson.courses = basicCourseNames.map(name => new Course(name, false));
512 | ```
513 |
514 | 但客户端也可能发现,直接更新课程列表显然更容易。
515 |
516 | #### 客户端代码...
517 |
518 | ```js
519 | for (const name of readBasicCourseNames(filename)) {
520 | aPerson.courses.push(new Course(name, false));
521 | }
522 | ```
523 |
524 | 这就破坏了封装性,因为以此种方式更新列表 Person 类根本无从得知。这里仅仅封装了字段引用,而未真正封装字段的内容。
525 |
526 | 现在我来对类实施真正恰当的封装,首先要为类添加两个方法,为客户端提供“添加课程”和“移除课程”的接口。
527 |
528 | #### class Person...
529 |
530 | ```js
531 | addCourse(aCourse) {
532 | this._courses.push(aCourse);
533 | }
534 | removeCourse(aCourse, fnIfAbsent = () => {throw new RangeError();}) {
535 | const index = this._courses.indexOf(aCourse);
536 | if (index === -1) fnIfAbsent();
537 | else this._courses.splice(index, 1);
538 | }
539 | ```
540 |
541 | 对于移除操作,我得考虑一下,如果客户端要求移除一个不存在的集合元素怎么办。我可以耸耸肩装作没看见,也可以抛出错误。这里我默认让它抛出错误,但留给客户端一个自己处理的机会。
542 |
543 | 然后我就可以让直接修改集合值的地方改用新的方法了。
544 |
545 | #### 客户端代码...
546 |
547 | ```js
548 | for (const name of readBasicCourseNames(filename)) {
549 | aPerson.addCourse(new Course(name, false));
550 | }
551 | ```
552 |
553 | 有了单独的添加和移除方法,通常 setCourse 设值函数就没必要存在了。若果真如此,我就会使用移除设值函数(331)移除它。如果出于其他原因,必须提供一个设值方法作为 API,我至少要确保用一份副本给字段赋值,不去修改通过参数传入的集合。
554 |
555 | #### class Person...
556 |
557 | ```js
558 | set courses(aList) {this._courses = aList.slice();}
559 | ```
560 |
561 | 这套设施让客户端能够使用正确的修改方法,同时我还希望能确保所有修改都通过这些方法进行。为达此目的,我会让取值函数返回一份副本。
562 |
563 | #### class Person...
564 |
565 | ```js
566 | get courses() {return this._courses.slice();}
567 | ```
568 |
569 | 总的来讲,我觉得对集合保持适度的审慎是有益的,我宁愿多复制一份数据,也不愿去调试因意外修改集合招致的错误。修改操作并不总是显而易见的,比如,在 JavaScript 中原生的数组排序函数 sort()就会修改原数组,而在其他语言中默认都是为更改集合的操作返回一份副本。任何负责管理集合的类都应该总是返回数据副本,但我还养成了一个习惯,只要我做的事看起来可能改变集合,我也会返回一个副本。
570 |
571 | ## 7.3 以对象取代基本类型(Replace Primitive with Object)
572 |
573 | 曾用名:以对象取代数据值(Replace Data Value with Object)
574 |
575 | 曾用名:以类取代类型码(Replace Type Code with Class)
576 |
577 | ```js
578 | orders.filter(o => "high" === o.priority
579 | || "rush" === o.priority);
580 |
581 |
582 | orders.filter(o => o.priority.higherThan(new Priority("normal")))
583 | ```
584 |
585 | ### 动机
586 |
587 | 开发初期,你往往决定以简单的数据项表示简单的情况,比如使用数字或字符串等。但随着开发的进行,你可能会发现,这些简单数据项不再那么简单了。比如说,一开始你可能会用一个字符串来表示“电话号码”的概念,但是随后它又需要“格式化”“抽取区号”之类的特殊行为。这类逻辑很快便会占领代码库,制造出许多重复代码,增加使用时的成本。
588 |
589 | 一旦我发现对某个数据的操作不仅仅局限于打印时,我就会为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地可去了。这些小小的封装值开始可能价值甚微,但只要悉心照料,它们很快便能成长为有用的工具。创建新类无须太大的工作量,但我发现它们往往对代码库有深远的影响。实际上,许多经验丰富的开发者认为,这是他们的工具箱里最实用的重构手法之一——尽管其价值常为新手程序员所低估。
590 |
591 | ### 做法
592 |
593 | 如果变量尚未被封装起来,先使用封装变量(132)封装它。
594 |
595 | 为这个数据值创建一个简单的类。类的构造函数应该保存这个数据值,并为它提供一个取值函数。
596 |
597 | 执行静态检查。
598 |
599 | 修改第一步得到的设值函数,令其创建一个新类的对象并将其存入字段,如果有必要的话,同时修改字段的类型声明。
600 |
601 | 修改取值函数,令其调用新类的取值函数,并返回结果。
602 |
603 | 测试。
604 |
605 | 考虑对第一步得到的访问函数使用函数改名(124),以便更好反映其用途。
606 |
607 | 考虑应用将引用对象改为值对象(252)或将值对象改为引用对象(256),明确指出新对象的角色是值对象还是引用对象。
608 |
609 | ### 范例
610 |
611 | 我将从一个简单的订单(Order)类开始。该类从一个简单的记录结构里读取所需的数据,这其中有一个订单优先级(priority)字段,它是以字符串的形式被读入的。
612 |
613 | #### class Order...
614 |
615 | ```js
616 | constructor(data) {
617 | this.priority = data.priority;
618 | // more initialization
619 | ```
620 |
621 | 客户端代码有些地方是这么用它的:
622 |
623 | #### 客户端...
624 |
625 | ```js
626 | highPriorityCount = orders.filter(o => "high" === o.priority
627 | || "rush" === o.priority)
628 | .length;
629 | ```
630 |
631 | 无论何时,当我与一个数据值打交道时,第一件事一定是对它使用封装变量(132)。
632 |
633 | #### class Order...
634 |
635 | ```js
636 | get priority() {return this._priority;}
637 | set priority(aString) {this._priority = aString;}
638 | ```
639 |
640 | 现在构造函数中第一行初始化代码就会使用我刚刚创建的设值函数了。
641 |
642 | 这使它成了一个自封装的字段,因此我暂可放任原来的引用点不理,先对字段进行处理。
643 |
644 | 接下来我为优先级字段创建一个简单的值类(value class)。该类应该有一个构造函数接收值字段,并提供一个返回字符串的转换函数。
645 |
646 | ```js
647 | class Priority {
648 | constructor(value) {
649 | this._value = value;
650 | }
651 | toString() {
652 | return this._value;
653 | }
654 | }
655 | ```
656 |
657 | 这里的转换函数我更倾向于使用 toString 而不用取值函数(value)。对类的客户端而言,一个返回字符串描述的 API 应该更能传达“发生了数据转换”的信息,而使用取值函数取用一个字段就缺乏这方面的感觉。
658 |
659 | 然后我要修改访问函数,使其用上新创建的类。
660 |
661 | #### class Order...
662 |
663 | ```js
664 | get priority() {return this._priority.toString();}
665 | set priority(aString) {this._priority = new Priority(aString);}
666 | ```
667 |
668 | 提炼出 Priority 类后,我发觉现在 Order 类上的取值函数命名有点儿误导人了。它确实还是返回了优先级信息,但却是一个字符串描述,而不是一个 Priority 对象。于是我立即对它应用了函数改名(124)。
669 |
670 | #### class Order...
671 |
672 | ```js
673 | get priorityString() {return this._priority.toString();}
674 | set priority(aString) {this._priority = new Priority(aString);}
675 | ```
676 |
677 | #### 客户端...
678 |
679 | ```js
680 | highPriorityCount = orders.filter(o => "high" === o.priorityString
681 | || "rush" === o.priorityString)
682 | .length;
683 | ```
684 |
685 | 这里设值函数的名字倒没有使我不满,因为函数的参数能够清晰地表达其意图。
686 |
687 | 到此为止,正式的重构手法就结束了。不过当我进一步查看优先级字段的客户端时,我在想让它们直接使用 Priority 对象是否会更好。于是,我着手在订单类上添加一个取值函数,让它直接返回新建的 Priority 对象。
688 |
689 | #### class Order...
690 |
691 | ```js
692 | get priority() {return this._priority;}
693 | get priorityString() {return this._priority.toString();}
694 | set priority(aString) {this._priority = new Priority(aString);}
695 | ```
696 |
697 | #### 客户端...
698 |
699 | ```js
700 | highPriorityCount = orders.filter(o => "high" === o.priority.toString()
701 | || "rush" === o.priority.toString())
702 | .length;
703 | ```
704 |
705 | 随着 Priority 对象在别处也有了用处,我开始支持让 Order 类的客户端拿着 Priority 实例来调用设值函数,这可以通过调整 Priority 类的构造函数实现。
706 |
707 | #### class Priority...
708 |
709 | ```js
710 | constructor(value) {
711 | if (value instanceof Priority) return value;
712 | this._value = value;
713 | }
714 | ```
715 |
716 | 这样做的意义在于,现在新的 Priority 类可以容纳更多业务行为——无论是新的业务代码,还是从别处搬移过来的。这里有些例子,它会校验优先级的传入值,支持一些比较逻辑。
717 |
718 | #### class Priority...
719 |
720 | ```js
721 | constructor(value) {
722 | if (value instanceof Priority) return value;
723 | if (Priority.legalValues().includes(value))
724 | this._value = value;
725 | else
726 | throw new Error(`<${value}> is invalid for Priority`);
727 | }
728 | toString() {return this._value;}
729 | get _index() {return Priority.legalValues().findIndex(s => s === this._value);}
730 | static legalValues() {return ['low', 'normal', 'high', 'rush'];}
731 |
732 | equals(other) {return this._index === other._index;}
733 | higherThan(other) {return this._index > other._index;}
734 | lowerThan(other) {return this._index < other._index;}
735 | ```
736 |
737 | 修改的过程中,我发觉它实际上已经担负起值对象(value object)的角色,因此我又为它添加了一个 equals 方法,并确保它的值不可修改。
738 |
739 | 加上这些行为后,我可以让客户端代码读起来含义更清晰。
740 |
741 | #### 客户端...
742 |
743 | ```js
744 | highPriorityCount = orders.filter(o => o.priority.higherThan(new Priority("normal")))
745 | .length;
746 | ```
747 |
748 | ## 7.4 以查询取代临时变量(Replace Temp with Query)
749 |
750 | ```js
751 | const basePrice = this._quantity * this._itemPrice;
752 | if (basePrice > 1000)
753 | return basePrice * 0.95;
754 | else
755 | return basePrice * 0.98;
756 |
757 |
758 | get basePrice() {this._quantity * this._itemPrice;}
759 |
760 | ...
761 |
762 | if (this.basePrice > 1000)
763 | return this.basePrice * 0.95;
764 | else
765 | return this.basePrice * 0.98;
766 | ```
767 |
768 | ### 动机
769 |
770 | 临时变量的一个作用是保存某段代码的返回值,以便在函数的后面部分使用它。临时变量允许我引用之前的值,既能解释它的含义,还能避免对代码进行重复计算。但尽管使用变量很方便,很多时候还是值得更进一步,将它们抽取成函数。
771 |
772 | 如果我正在分解一个冗长的函数,那么将变量抽取到函数里能使函数的分解过程更简单,因为我就不再需要将变量作为参数传递给提炼出来的小函数。将变量的计算逻辑放到函数中,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮我发现并避免难缠的依赖及副作用。
773 |
774 | 改用函数还让我避免了在多个函数中重复编写计算逻辑。每当我在不同的地方看见同一段变量的计算逻辑,我就会想方设法将它们挪到同一个函数里。
775 |
776 | 这项重构手法在类中施展效果最好,因为类为待提炼函数提供了一个共同的上下文。如果不是在类中,我很可能会在顶层函数中拥有过多参数,这将冲淡提炼函数所能带来的诸多好处。使用嵌套的小函数可以避免这个问题,但又限制了我在相关函数间分享逻辑的能力。
777 |
778 | 以查询取代临时变量(178)手法只适用于处理某些类型的临时变量:那些只被计算一次且之后不再被修改的变量。最简单的情况是,这个临时变量只被赋值一次,但在更复杂的代码片段里,变量也可能被多次赋值——此时应该将这些计算代码一并提炼到查询函数中。并且,待提炼的逻辑多次计算同样的变量时,应该能得到相同的结果。因此,对于那些做快照用途的临时变量(从变量名往往可见端倪,比如 oldAddress 这样的名字),就不能使用本手法。
779 |
780 | ### 做法
781 |
782 | 检查变量在使用前是否已经完全计算完毕,检查计算它的那段代码是否每次都能得到一样的值。
783 |
784 | 如果变量目前不是只读的,但是可以改造成只读变量,那就先改造它。
785 |
786 | 测试。
787 |
788 | 将为变量赋值的代码段提炼成函数。
789 |
790 | 如果变量和函数不能使用同样的名字,那么先为函数取个临时的名字。
791 |
792 | 确保待提炼函数没有副作用。若有,先应用将查询函数和修改函数分离(306)手法隔离副作用。
793 |
794 | 测试。
795 |
796 | 应用内联变量(123)手法移除临时变量。
797 |
798 | ### 范例
799 |
800 | 这里有一个简单的订单类。
801 |
802 | #### class Order...
803 |
804 | ```js
805 | constructor(quantity, item) {
806 | this._quantity = quantity;
807 | this._item = item;
808 | }
809 |
810 | get price() {
811 | var basePrice = this._quantity * this._item.price;
812 | var discountFactor = 0.98;
813 | if (basePrice > 1000) discountFactor -= 0.03;
814 | return basePrice * discountFactor;
815 | }
816 | }
817 | ```
818 |
819 | 我希望把 basePrice 和 discountFactor 两个临时变量变成函数。
820 |
821 | 先从 basePrice 开始,我先把它声明成 const 并运行测试。这可以很好地防止我遗漏了对变量的其他赋值点——对于这么个小函数是不太可能的,但当我处理更大的函数时就不一定了。
822 |
823 | #### class Order...
824 |
825 | ```js
826 | constructor(quantity, item) {
827 | this._quantity = quantity;
828 | this._item = item;
829 | }
830 |
831 | get price() {
832 | const basePrice = this._quantity * this._item.price;
833 | var discountFactor = 0.98;
834 | if (basePrice > 1000) discountFactor -= 0.03;
835 | return basePrice * discountFactor;
836 | }
837 | }
838 | ```
839 |
840 | 然后我把赋值操作的右边提炼成一个取值函数。
841 |
842 | #### class Order...
843 |
844 | ```js
845 | get price() {
846 | const basePrice = this.basePrice;
847 | var discountFactor = 0.98;
848 | if (basePrice > 1000) discountFactor -= 0.03;
849 | return basePrice * discountFactor;
850 | }
851 |
852 | get basePrice() {
853 | return this._quantity * this._item.price;
854 | }
855 | ```
856 |
857 | 测试,然后应用内联变量(123)。
858 |
859 | #### class Order...
860 |
861 | ```js
862 | get price() {
863 | const basePrice = this.basePrice;
864 | var discountFactor = 0.98;
865 | if (this.basePrice > 1000) discountFactor -= 0.03;
866 | return this.basePrice * discountFactor;
867 | }
868 | ```
869 |
870 | 接下来我对 discountFactor 重复同样的步骤,先是应用提炼函数(106)。
871 |
872 | #### class Order...
873 |
874 | ```js
875 | get price() {
876 | const discountFactor = this.discountFactor;
877 | return this.basePrice * discountFactor;
878 | }
879 |
880 | get discountFactor() {
881 | var discountFactor = 0.98;
882 | if (this.basePrice > 1000) discountFactor -= 0.03;
883 | return discountFactor;
884 | }
885 | ```
886 |
887 | 这里我需要将对 discountFactor 的两处赋值一起搬移到新提炼的函数中,之后就可以将原变量一起声明为 const。
888 |
889 | 然后,内联变量:
890 |
891 | ```js
892 | get price() {
893 | return this.basePrice * this.discountFactor;
894 | }
895 | ```
896 |
897 | ## 7.5 提炼类(Extract Class)
898 |
899 | 反向重构:内联类(186)
900 |
901 | ```js
902 | class Person {
903 | get officeAreaCode() {return this._officeAreaCode;}
904 | get officeNumber() {return this._officeNumber;}
905 |
906 |
907 | class Person {
908 | get officeAreaCode() {return this._telephoneNumber.areaCode;}
909 | get officeNumber() {return this._telephoneNumber.number;}
910 | }
911 | class TelephoneNumber {
912 | get areaCode() {return this._areaCode;}
913 | get number() {return this._number;}
914 | }
915 | ```
916 |
917 | ### 动机
918 |
919 | 你也许听过类似这样的建议:一个类应该是一个清晰的抽象,只处理一些明确的责任,等等。但是在实际工作中,类会不断成长扩展。你会在这儿加入一些功能,在那儿加入一些数据。给某个类添加一项新责任时,你会觉得不值得为这项责任分离出一个独立的类。于是,随着责任不断增加,这个类会变得过分复杂。很快,你的类就会变成一团乱麻。
920 |
921 | 设想你有一个维护大量函数和数据的类。这样的类往往因为太大而不易理解。此时你需要考虑哪些部分可以分离出去,并将它们分离到一个独立的类中。如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。一个有用的测试就是问你自己,如果你搬移了某些字段和函数,会发生什么事?其他字段和函数是否因此变得无意义?
922 |
923 | 另一个往往在开发后期出现的信号是类的子类化方式。如果你发现子类化只影响类的部分特性,或如果你发现某些特性需要以一种方式来子类化,某些特性则需要以另一种方式子类化,这就意味着你需要分解原来的类。
924 |
925 | ### 做法
926 |
927 | 决定如何分解类所负的责任。
928 |
929 | 创建一个新的类,用以表现从旧类中分离出来的责任。
930 |
931 | 如果旧类剩下的责任与旧类的名称不符,为旧类改名。
932 |
933 | 构造旧类时创建一个新类的实例,建立“从旧类访问新类”的连接关系。
934 |
935 | 对于你想搬移的每一个字段,运用搬移字段(207)搬移之。每次更改后运行测试。
936 |
937 | 使用搬移函数(198)将必要函数搬移到新类。先搬移较低层函数(也就是“被其他函数调用”多于“调用其他函数”者)。每次更改后运行测试。
938 |
939 | 检查两个类的接口,去掉不再需要的函数,必要时为函数重新取一个适合新环境的名字。
940 |
941 | 决定是否公开新的类。如果确实需要,考虑对新类应用将引用对象改为值对象(252)使其成为一个值对象。
942 |
943 | ### 范例
944 |
945 | 我们从一个简单的 Person 类开始。
946 |
947 | #### class Person...
948 |
949 | ```js
950 | get name() {return this._name;}
951 | set name(arg) {this._name = arg;}
952 | get telephoneNumber() {return `(${this.officeAreaCode}) ${this.officeNumber}`;}
953 | get officeAreaCode() {return this._officeAreaCode;}
954 | set officeAreaCode(arg) {this._officeAreaCode = arg;}
955 | get officeNumber() {return this._officeNumber;}
956 | set officeNumber(arg) {this._officeNumber = arg;}
957 | ```
958 |
959 | 这里,我可以将与电话号码相关的行为分离到一个独立的类中。首先,我要定义一个空的 TelephoneNumber 类来表示“电话号码”这个概念:
960 |
961 | ```js
962 | class TelephoneNumber {}
963 | ```
964 |
965 | 易如反掌!接着,我要在构造 Person 类时创建 TelephoneNumber 类的一个实例。
966 |
967 | #### class Person...
968 |
969 | ```js
970 | constructor() {
971 | this._telephoneNumber = new TelephoneNumber();
972 | }
973 | ```
974 |
975 | #### class TelephoneNumber...
976 |
977 | ```js
978 | get officeAreaCode() {return this._officeAreaCode;}
979 | set officeAreaCode(arg) {this._officeAreaCode = arg;}
980 | ```
981 |
982 | 现在,我运用搬移字段(207)搬移一个字段。
983 |
984 | #### class Person...
985 |
986 | ```js
987 | get officeAreaCode() {return this._telephoneNumber.officeAreaCode;}
988 | set officeAreaCode(arg) {this._telephoneNumber.officeAreaCode = arg;}
989 | ```
990 |
991 | 再次运行测试,然后我对下一个字段进行同样处理。
992 |
993 | #### class TelephoneNumber...
994 |
995 | ```js
996 | get officeNumber() {return this._officeNumber;}
997 | set officeNumber(arg) {this._officeNumber = arg;}
998 | ```
999 |
1000 | #### class Person...
1001 |
1002 | ```js
1003 | get officeNumber() {return this._telephoneNumber.officeNumber;}
1004 | set officeNumber(arg) {this._telephoneNumber.officeNumber = arg;}
1005 | ```
1006 |
1007 | 再次测试,然后再搬移对电话号码的取值函数。
1008 |
1009 | #### class TelephoneNumber...
1010 |
1011 | ```js
1012 | get telephoneNumber() {return `(${this.officeAreaCode}) ${this.officeNumber}`;}
1013 | ```
1014 |
1015 | #### class Person...
1016 |
1017 | ```js
1018 | get telephoneNumber() {return this._telephoneNumber.telephoneNumber;}
1019 | ```
1020 |
1021 | 现在我需要做些清理工作。“电话号码”显然不该拥有“办公室”(office)的概念,因此我得重命名一下变量。
1022 |
1023 | #### class TelephoneNumber...
1024 |
1025 | ```js
1026 | get areaCode() {return this._areaCode;}
1027 | set areaCode(arg) {this._areaCode = arg;}
1028 |
1029 | get number() {return this._number;}
1030 | set number(arg) {this._number = arg;}
1031 | ```
1032 |
1033 | #### class Person...
1034 |
1035 | ```js
1036 | get officeAreaCode() {return this._telephoneNumber.areaCode;}
1037 | set officeAreaCode(arg) {this._telephoneNumber.areaCode = arg;}
1038 | get officeNumber() {return this._telephoneNumber.number;}
1039 | set officeNumber(arg) {this._telephoneNumber.number = arg;}
1040 | ```
1041 |
1042 | TelephoneNumber 类上有一个对自己(telephone number)的取值函数也没什么道理,因此我又对它应用函数改名(124)。
1043 |
1044 | #### class TelephoneNumber...
1045 |
1046 | ```js
1047 | toString() {return `(${this.areaCode}) ${this.number}`;}
1048 | ```
1049 |
1050 | #### class Person...
1051 |
1052 | ```js
1053 | get telephoneNumber() {return this._telephoneNumber.toString();}
1054 | ```
1055 |
1056 | “电话号码”对象一般还具有复用价值,因此我考虑将新提炼的类暴露给更多的客户端。需要访问 TelephoneNumber 对象时,只须把 Person 类中那些 office 开头的访问函数搬移过来并略作修改即可。但这样 TelephoneNumber 就更像一个值对象(Value Object)[mf-vo]了,因此我会先对它使用将引用对象改为值对象(252)(那个重构手法所用的范例,正是基于本章电话号码例子的延续)。
1057 |
1058 | ## 7.6 内联类(Inline Class)
1059 |
1060 | 反向重构:提炼类(182)
1061 |
1062 | ```js
1063 | class Person {
1064 | get officeAreaCode() {return this._telephoneNumber.areaCode;}
1065 | get officeNumber() {return this._telephoneNumber.number;}
1066 | }
1067 | class TelephoneNumber {
1068 | get areaCode() {return this._areaCode;}
1069 | get number() {return this._number;}
1070 | }
1071 |
1072 |
1073 | class Person {
1074 | get officeAreaCode() {return this._officeAreaCode;}
1075 | get officeNumber() {return this._officeNumber;}
1076 | ```
1077 |
1078 | ### 动机
1079 |
1080 | 内联类正好与提炼类(182)相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),我就会挑选这一“萎缩类”的最频繁用户(也是一个类),以本手法将“萎缩类”塞进另一个类中。
1081 |
1082 | 应用这个手法的另一个场景是,我手头有两个类,想重新安排它们肩负的职责,并让它们产生关联。这时我发现先用本手法将它们内联成一个类再用提炼类(182)去分离其职责会更加简单。这是重新组织代码时常用的做法:有时把相关元素一口气搬移到位更简单,但有时先用内联手法合并各自的上下文,再使用提炼手法再次分离它们会更合适。
1083 |
1084 | ### 做法
1085 |
1086 | 对于待内联类(源类)中的所有 public 函数,在目标类上创建一个对应的函数,新创建的所有函数应该直接委托至源类。
1087 |
1088 | 修改源类 public 方法的所有引用点,令它们调用目标类对应的委托方法。每次更改后运行测试。
1089 |
1090 | 将源类中的函数与数据全部搬移到目标类,每次修改之后进行测试,直到源类变成空壳为止。
1091 |
1092 | 删除源类,为它举行一个简单的“丧礼”
1093 |
1094 | ### 范例
1095 |
1096 | 下面这个类存储了一次物流运输(shipment)的若干跟踪信息(tracking information)。
1097 |
1098 | ```js
1099 | class TrackingInformation {
1100 | get shippingCompany() {
1101 | return this._shippingCompany;
1102 | }
1103 | set shippingCompany(arg) {
1104 | this._shippingCompany = arg;
1105 | }
1106 | get trackingNumber() {
1107 | return this._trackingNumber;
1108 | }
1109 | set trackingNumber(arg) {
1110 | this._trackingNumber = arg;
1111 | }
1112 | get display() {
1113 | return `${this.shippingCompany}: ${this.trackingNumber}`;
1114 | }
1115 | }
1116 | ```
1117 |
1118 | 它作为 Shipment(物流)类的一部分被使用。
1119 |
1120 | #### class Shipment...
1121 |
1122 | ```js
1123 | get trackingInfo() {
1124 | return this._trackingInformation.display;
1125 | }
1126 | get trackingInformation() {return this._trackingInformation;}
1127 | set trackingInformation(aTrackingInformation) {
1128 | this._trackingInformation = aTrackingInformation;
1129 | }
1130 | ```
1131 |
1132 | TrackingInformation 类过去可能有很多光荣职责,但现在我觉得它已不再能肩负起它的责任,因此我希望将它内联到 Shipment 类里。
1133 |
1134 | 首先,我要寻找 TrackingInformation 类的方法有哪些调用点。
1135 |
1136 | #### 调用方...
1137 |
1138 | ```js
1139 | aShipment.trackingInformation.shippingCompany = request.vendor;
1140 | ```
1141 |
1142 | 我将开始将源类的类似函数全都搬移到 Shipment 里去,但我的做法与做搬移函数(198)时略微有些不同。这里,我先在 Shipment 类里创建一个委托方法,并调整客户端代码,使其调用这个委托方法。
1143 |
1144 | #### class Shipment...
1145 |
1146 | ```js
1147 | set shippingCompany(arg) {this._trackingInformation.shippingCompany = arg;}
1148 | ```
1149 |
1150 | #### 调用方...
1151 |
1152 | ```js
1153 | aShipment.trackingInformation.shippingCompany = request.vendor;
1154 | ```
1155 |
1156 | 对于 TrackingInformation 类中所有为客户端调用的方法,我将施以相同的手法。这之后,我就可以将源类中的所有东西都搬移到 Shipment 类中去。
1157 |
1158 | 我先对 display 方法应用内联函数(115)手法。
1159 |
1160 | #### class Shipment...
1161 |
1162 | ```js
1163 | get trackingInfo() {
1164 | return `${this.shippingCompany}: ${this.trackingNumber}`;
1165 | }
1166 | ```
1167 |
1168 | 再继续搬移“收货公司”(shipping company)字段。
1169 |
1170 | ```js
1171 | get shippingCompany() {return this._trackingInformation._shippingCompany;}
1172 | set shippingCompany(arg) {this._trackingInformation._shippingCompany = arg;}
1173 | ```
1174 |
1175 | 我并未遵循搬移字段(207)的全部步骤,因为此处我只是改由目标类 Shipment 来引用 shippingCompany,那些从源类搬移引用至目标类的步骤在此并不需要。
1176 |
1177 | 我会继续相同的手法,直到所有搬迁工作完成为止。那时,我就可以删除 TrackingInformation 类了。
1178 |
1179 | #### class Shipment...
1180 |
1181 | ```js
1182 | get trackingInfo() {
1183 | return `${this.shippingCompany}: ${this.trackingNumber}`;
1184 | }
1185 | get shippingCompany() {return this._shippingCompany;}
1186 | set shippingCompany(arg) {this._shippingCompany = arg;}
1187 | get trackingNumber() {return this._trackingNumber;}
1188 | set trackingNumber(arg) {this._trackingNumber = arg;}
1189 | ```
1190 |
1191 | ## 7.7 隐藏委托关系(Hide Delegate)
1192 |
1193 | 反向重构:移除中间人(192)
1194 |
1195 | ```js
1196 | manager = aPerson.department.manager;
1197 |
1198 |
1199 | manager = aPerson.manager;
1200 |
1201 | class Person {
1202 | get manager() {return this.department.manager;}
1203 | ```
1204 |
1205 | ### 动机
1206 |
1207 | 一个好的模块化的设计,“封装”即使不是其最关键特征,也是最关键特征之一。“封装”意味着每个模块都应该尽可能少了解系统的其他部分。如此一来,一旦发生变化,需要了解这一变化的模块就会比较少——这会使变化比较容易进行。
1208 |
1209 | 当我们初学面向对象技术时就被教导,封装意味着应该隐藏自己的字段。随着经验日渐丰富,你会发现,有更多可以(而且值得)封装的东西。
1210 |
1211 | 如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。我可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。这么一来,即使将来委托关系发生变化,变化也只会影响服务对象,而不会直接波及所有客户端。
1212 |
1213 | ### 做法
1214 |
1215 | 对于每个委托关系中的函数,在服务对象端建立一个简单的委托函数。
1216 |
1217 | 调整客户端,令它只调用服务对象提供的函数。每次调整后运行测试。
1218 |
1219 | 如果将来不再有任何客户端需要取用 Delegate(受托类),便可移除服务对象中的相关访问函数。
1220 |
1221 | 测试。
1222 |
1223 | ### 范例
1224 |
1225 | 本例从两个类开始,代表“人”的 Person 和代表“部门”的 Department。
1226 |
1227 | #### class Person...
1228 |
1229 | ```js
1230 | constructor(name) {
1231 | this._name = name;
1232 | }
1233 | get name() {return this._name;}
1234 | get department() {return this._department;}
1235 | set department(arg) {this._department = arg;}
1236 | ```
1237 |
1238 | #### class Department...
1239 |
1240 | ```js
1241 | get chargeCode() {return this._chargeCode;}
1242 | set chargeCode(arg) {this._chargeCode = arg;}
1243 | get manager() {return this._manager;}
1244 | set manager(arg) {this._manager = arg;}
1245 | ```
1246 |
1247 | 有些客户端希望知道某人的经理是谁,为此,它必须先取得 Department 对象。
1248 |
1249 | #### 客户端代码...
1250 |
1251 | ```js
1252 | manager = aPerson.department.manager;
1253 | ```
1254 |
1255 | 这样的编码就对客户端揭露了 Department 的工作原理,于是客户知道:Department 负责追踪“经理”这条信息。如果对客户隐藏 Department,可以减少耦合。为了这一目的,我在 Person 中建立一个简单的委托函数。
1256 |
1257 | #### class Person...
1258 |
1259 | ```js
1260 | get manager() {return this._department.manager;}
1261 | ```
1262 |
1263 | 现在,我得修改 Person 的所有客户端,让它们改用新函数:
1264 |
1265 | #### 客户端代码...
1266 |
1267 | ```js
1268 | manager = aPerson.department.manager;
1269 | ```
1270 |
1271 | 只要完成了对 Department 所有函数的修改,并相应修改了 Person 的所有客户端,我就可以移除 Person 中的 department 访问函数了。
1272 |
1273 | ## 7.8 移除中间人(Remove Middle Man)
1274 |
1275 | 反向重构:隐藏委托关系(189)
1276 |
1277 | ```js
1278 | manager = aPerson.manager;
1279 |
1280 | class Person {
1281 | get manager() {return this.department.manager;}
1282 |
1283 |
1284 | manager = aPerson.department.manager;
1285 | ```
1286 |
1287 | ### 动机
1288 |
1289 | 在隐藏委托关系(189)的“动机”一节中,我谈到了“封装受托对象”的好处。但是这层封装也是有代价的。每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人(81),此时就应该让客户直接调用受托类。(这个味道通常在人们狂热地遵循迪米特法则时悄然出现。我总觉得,如果这条法则当初叫作“偶尔有用的迪米特建议”,如今能少很多烦恼。)
1290 |
1291 | 很难说什么程度的隐藏才是合适的。还好,有了隐藏委托关系(189)和删除中间人,我大可不必操心这个问题,因为我可以在系统运行过程中不断进行调整。随着代码的变化,“合适的隐藏程度”这个尺度也相应改变。6 个月前恰如其分的封装,现今可能就显得笨拙。重构的意义就在于:你永远不必说对不起——只要把出问题的地方修补好就行了。
1292 |
1293 | ### 做法
1294 |
1295 | 为受托对象创建一个取值函数。
1296 |
1297 | 对于每个委托函数,让其客户端转为连续的访问函数调用。每次替换后运行测试。
1298 |
1299 | 替换完委托方法的所有调用点后,你就可以删掉这个委托方法了。
1300 |
1301 | 这能通过可自动化的重构手法来完成,你可以先对受托字段使用封装变量(132),再应用内联函数(115)内联所有使用它的函数。
1302 |
1303 | ### 范例
1304 |
1305 | 我又要从一个 Person 类开始了,这个类通过维护一个部门对象来决定某人的经理是谁。(如果你一口气读完本书的好几章,可能会发现每个“人与部门”的例子都出奇地相似。)
1306 |
1307 | #### 客户端代码...
1308 |
1309 | ```js
1310 | manager = aPerson.manager;
1311 | ```
1312 |
1313 | #### class Person...
1314 |
1315 | ```js
1316 | get manager() {return this._department.manager;}
1317 | ```
1318 |
1319 | #### class Department...
1320 |
1321 | ```js
1322 | get manager() {return this._manager;}
1323 | ```
1324 |
1325 | 像这样,使用和封装 Department 都很简单。但如果大量函数都这么做,我就不得不在 Person 之中安置大量委托行为。这就该是移除中间人的时候了。首先在 Person 中建立一个函数,用于获取受托对象。
1326 |
1327 | #### class Person...
1328 |
1329 | ```js
1330 | get department() {return this._department;}
1331 | ```
1332 |
1333 | 然后逐一处理每个客户端,使它们直接通过受托对象完成工作。
1334 |
1335 | #### 客户端代码...
1336 |
1337 | ```js
1338 | manager = aPerson.department.manager;
1339 | ```
1340 |
1341 | 完成对客户端引用点的替换后,我就可以从 Person 中移除 manager 方法了。我可以重复此法,移除 Person 中其他类似的简单委托函数。
1342 |
1343 | 我可以混用两种用法。有些委托关系非常常用,因此我想保住它们,这样可使客户端代码调用更友好。何时应该隐藏委托关系,何时应该移除中间人,对我而言没有绝对的标准——代码环境自然会给出该使用哪种手法的线索,具备思考能力的程序员应能分辨出何种手法更佳。
1344 |
1345 | 如果手边在用自动化的重构工具,那么本手法的步骤有一个实用的变招:我可以先对 department 应用封装变量(132)。这样可让 manager 的取值函数调用 department 的取值函数。
1346 |
1347 | #### class Person...
1348 |
1349 | ```js
1350 | get manager() {return this.department.manager;}
1351 | ```
1352 |
1353 | 在 JavaScript 中,调用取值函数的语法跟取用普通字段看起来很像,但通过移除 department 字段的下划线,我想表达出这里是调用了取值函数而非直接取用字段的区别。
1354 |
1355 | 然后我对 manager 方法应用内联函数(115),一口气替换它的所有调用点。
1356 |
1357 | ## 7.9 替换算法(Substitute Algorithm)
1358 |
1359 | ```js
1360 | function foundPerson(people) {
1361 | for(let i = 0; i < people.length; i++) {
1362 | if (people[i] === "Don") {
1363 | return "Don";
1364 | }
1365 | if (people[i] === "John") {
1366 | return "John";
1367 | }
1368 | if (people[i] === "Kent") {
1369 | return "Kent";
1370 | }
1371 | }
1372 | return "";
1373 | }
1374 |
1375 |
1376 | function foundPerson(people) {
1377 | const candidates = ["Don", "John", "Kent"];
1378 | return people.find(p => candidates.includes(p)) || '';
1379 | }
1380 | ```
1381 |
1382 | ### 动机
1383 |
1384 | 我从没试过给猫剥皮,听说有好几种方法,我敢肯定,其中某些方法会比另一些简单。算法也是如此。如果我发现做一件事可以有更清晰的方式,我就会用比较清晰的方式取代复杂的方式。“重构”可以把一些复杂的东西分解为较简单的小块,但有时你就必须壮士断腕,删掉整个算法,代之以较简单的算法。随着对问题有了更多理解,我往往会发现,在原先的做法之外,有更简单的解决方案,此时我就需要改变原先的算法。如果我开始使用程序库,而其中提供的某些功能/特性与我自己的代码重复,那么我也需要改变原先的算法。
1385 |
1386 | 有时我会想修改原先的算法,让它去做一件与原先略有差异的事。这时候可以先把原先的算法替换为一个较易修改的算法,这样后续的修改会轻松许多。
1387 |
1388 | 使用这项重构手法之前,我得确定自己已经尽可能分解了原先的函数。替换一个巨大且复杂的算法是非常困难的,只有先将它分解为较简单的小型函数,我才能很有把握地进行算法替换工作。
1389 |
1390 | ### 做法
1391 |
1392 | - 整理一下待替换的算法,保证它已经被抽取到一个独立的函数中。
1393 | - 先只为这个函数准备测试,以便固定它的行为。
1394 | - 准备好另一个(替换用)算法。
1395 | - 执行静态检查。
1396 | - 运行测试,比对新旧算法的运行结果。如果测试通过,那就大功告成;否则,在后续测试和调试过程中,以旧算法为比较参照标准。
1397 |
--------------------------------------------------------------------------------
/docs/ch9.md:
--------------------------------------------------------------------------------
1 | # 第 9 章 重新组织数据
2 |
3 | 数据结构在程序中扮演着重要的角色,所以毫不意外,我有一组重构手法专门用于数据结构的组织。将一个值用于多个不同的用途,这就是催生混乱和 bug 的温床。所以,一旦看见这样的情况,我就会用拆分变量(240)将不同的用途分开。和其他任何程序元素一样,给变量起个好名字不容易但又非常重要,所以我常会用到变量改名(137)。但有些多余的变量最好是彻底消除掉,比如通过以查询取代派生变量(248)。
4 |
5 | 引用和值的混淆经常会造成问题,所以我会用将引用对象改为值对象(252)和将值对象改为引用对象(256)在两者之间切换。
6 |
7 | ## 9.1 拆分变量(Split Variable)
8 |
9 | 曾用名:移除对参数的赋值(Remove Assignments to Parameters)
10 |
11 | 曾用名:分解临时变量(Split Temp)
12 |
13 | ```js
14 | let temp = 2 * (height + width);
15 | console.log(temp);
16 | temp = height * width;
17 | console.log(temp);
18 |
19 | const perimeter = 2 * (height + width);
20 | console.log(perimeter);
21 | const area = height * width;
22 | console.log(area);
23 | ```
24 |
25 | ### 动机
26 |
27 | 变量有各种不同的用途,其中某些用途会很自然地导致临时变量被多次赋值。“循环变量”和“结果收集变量”就是两个典型例子:循环变量(loop variable)会随循环的每次运行而改变(例如 for(let i=0; i<10; i++)语句中的 i);结果收集变量(collecting variable)负责将“通过整个函数的运算”而构成的某个值收集起来。
28 |
29 | 除了这两种情况,还有很多变量用于保存一段冗长代码的运算结果,以便稍后使用。这种变量应该只被赋值一次。如果它们被赋值超过一次,就意味它们在函数中承担了一个以上的责任。如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。
30 |
31 | ### 做法
32 |
33 | 在待分解变量的声明及其第一次被赋值处,修改其名称。
34 |
35 | 如果稍后的赋值语句是“i=i+某表达式形式”,意味着这是一个结果收集变量,就不要分解它。结果收集变量常用于累加、字符串拼接、写入流或者向集合添加元素。
36 |
37 | 如果可能的话,将新的变量声明为不可修改。
38 |
39 | 以该变量的第二次赋值动作为界,修改此前对该变量的所有引用,让它们引用新变量。
40 |
41 | 测试。
42 |
43 | 重复上述过程。每次都在声明处对变量改名,并修改下次赋值之前的引用,直至到达最后一处赋值。
44 |
45 | ### 范例
46 |
47 | 下面范例中我要计算一个苏格兰布丁运动的距离。在起点处,静止的苏格兰布丁会受到一个初始力的作用而开始运动。一段时间后,第二个力作用于布丁,让它再次加速。根据牛顿第二定律,我可以这样计算布丁运动的距离:
48 |
49 | ```js
50 | function distanceTravelled (scenario, time) {
51 | let result;
52 | let acc = scenario.primaryForce / scenario.mass;
53 | let primaryTime = Math.min(time, scenario.delay);
54 | result = 0.5 * acc * primaryTime * primaryTime;
55 | let secondaryTime = time - scenario.delay;
56 | if (secondaryTime > 0) {
57 | let primaryVelocity = acc * scenario.delay;
58 | acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
59 | result += primaryVelocity * secondaryTime + 0.5 * acc * secondaryTime * secondaryTime;
60 | }
61 | return result;
62 | }
63 | ```
64 |
65 | 真是个丑陋的小东西。注意观察此例中的 acc 变量是如何被赋值两次的。acc 变量有两个责任:第一是保存第一个力造成的初始加速度;第二是保存两个力共同造成的加速度。这就是我想要分解的东西。
66 |
67 | 在尝试理解变量被如何使用时,如果编辑器能高亮显示一个符号(symbol)在函数内或文件内出现的所有位置,会相当便利。大部分现代编辑器都可以轻松做到这一点。
68 |
69 | 首先,我在函数开始处修改这个变量的名称,并将新变量声明为 const。接着,我把新变量声明之后、第二次赋值之前对 acc 变量的所有引用,全部改用新变量。最后,我在第二次赋值处重新声明 acc 变量:
70 |
71 | ```js
72 | function distanceTravelled (scenario, time) {
73 | let result;
74 | const primaryAcceleration = scenario.primaryForce / scenario.mass;
75 | let primaryTime = Math.min(time, scenario.delay);
76 | result = 0.5 * primaryAcceleration * primaryTime * primaryTime;
77 | let secondaryTime = time - scenario.delay;
78 | if (secondaryTime > 0) {
79 | let primaryVelocity = primaryAcceleration * scenario.delay;
80 | let acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
81 | result += primaryVelocity * secondaryTime + 0.5 * acc * secondaryTime * secondaryTime;
82 | }
83 | return result;
84 | }
85 | ```
86 |
87 | 新变量的名称指出,它只承担原先 acc 变量的第一个责任。我将它声明为 const,确保它只被赋值一次。然后,我在原先 acc 变量第二次被赋值处重新声明 acc。现在,重新编译并测试,一切都应该没有问题。
88 |
89 | 然后,我继续处理 acc 变量的第二次赋值。这次我把原先的变量完全删掉,代之以一个新变量。新变量的名称指出,它只承担原先 acc 变量的第二个责任:
90 |
91 | ```js
92 | function distanceTravelled (scenario, time) {
93 | let result;
94 | const primaryAcceleration = scenario.primaryForce / scenario.mass;
95 | let primaryTime = Math.min(time, scenario.delay);
96 | result = 0.5 * primaryAcceleration * primaryTime * primaryTime;
97 | let secondaryTime = time - scenario.delay;
98 | if (secondaryTime > 0) {
99 | let primaryVelocity = primaryAcceleration * scenario.delay;
100 | const secondaryAcceleration = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
101 | result += primaryVelocity * secondaryTime +
102 | 0.5 * secondaryAcceleration * secondaryTime * secondaryTime;
103 | }
104 | return result;
105 | }
106 | ```
107 |
108 | 现在,这段代码肯定可以让你想起更多其他重构手法。尽情享受吧。(我敢保证,这比吃苏格兰布丁强多了——你知道他们都在里面放了些什么东西吗?1 )
109 |
110 | ### 范例:对输入参数赋值
111 |
112 | 另一种情况是,变量是以输入参数的形式声明又在函数内部被再次赋值,此时也可以考虑拆分变量。例如,下列代码:
113 |
114 | ```js
115 | function discount (inputValue, quantity) {
116 | if (inputValue > 50) inputValue = inputValue - 2;
117 | if (quantity > 100) inputValue = inputValue - 1;
118 | return inputValue;
119 | }
120 | ```
121 |
122 | 这里的 inputValue 有两个用途:它既是函数的输入,也负责把结果带回给调用方。(由于 JavaScript 的参数是按值传递的,所以函数内部对 inputValue 的修改不会影响调用方。)
123 |
124 | 在这种情况下,我就会对 inputValue 变量做拆分。
125 |
126 | ```js
127 | function discount (originalInputValue, quantity) {
128 | let inputValue = originalInputValue;
129 | if (inputValue > 50) inputValue = inputValue - 2;
130 | if (quantity > 100) inputValue = inputValue - 1;
131 | return inputValue;
132 | }
133 | ```
134 |
135 | 然后用变量改名(137)给两个变量换上更好的名字。
136 |
137 | ```js
138 | function discount (inputValue, quantity) {
139 | let result = inputValue;
140 | if (inputValue > 50) result = result - 2;
141 | if (quantity > 100) result = result - 1;
142 | return result;
143 | }
144 | ```
145 |
146 | 我修改了第二行代码,把 inputValue 作为判断条件的基准数据。虽说这里用 inputValue 还是 result 效果都一样,但在我看来,这行代码的含义是“根据原始输入值做判断,然后修改结果值”,而不是“根据当前结果值做判断”——尽管两者的效果恰好一样。
147 |
148 | 1 苏格兰布丁(haggis)是一种苏格兰菜,把羊心等内脏装在羊胃里煮成。由于它被羊胃包成一个球体,因此可以像球一样踢来踢去,这就是本例的由来。“把羊心装在羊胃里煮成……”,呃,有些人难免对这道菜恶心,Martin Fowler 想必是其中之一。——译者注
149 |
150 | ## 9.2 字段改名(Rename Field)
151 |
152 | ```js
153 | class Organization {
154 | get name() {...}
155 | }
156 |
157 |
158 | class Organization {
159 | get title() {...}
160 | }
161 | ```
162 |
163 | ### 动机
164 |
165 | 命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。数据结构对于帮助阅读者理解特别重要。多年以前,Fred Brooks 就说过:“只给我看你的工作流程却隐藏表单,我将仍然一头雾水。但是如果你给我展示表单,或许不需要流程图,就能柳暗花明。”现在已经不太有人画流程图了,不过道理还是一样的。数据结构是理解程序行为的关键。
166 |
167 | 既然数据结构如此重要,就很有必要保持它们的整洁。一如既往地,我在一个软件上做的工作越多,对数据的理解就越深,所以很有必要把我加深的理解融入程序中。
168 |
169 | 记录结构中的字段可能需要改名,类的字段也一样。在类的使用者看来,取值和设值函数就等于是字段。对这些函数的改名,跟裸记录结构的字段改名一样重要。
170 |
171 | ### 做法
172 |
173 | 如果记录的作用域较小,可以直接修改所有该字段的代码,然后测试。后面的步骤就都不需要了。
174 |
175 | 如果记录还未封装,请先使用封装记录(162)。
176 |
177 | 在对象内部对私有字段改名,对应调整内部访问该字段的函数。
178 |
179 | 测试。
180 |
181 | 如果构造函数的参数用了旧的字段名,运用改变函数声明(124)将其改名。
182 |
183 | 运用函数改名(124)给访问函数改名。
184 |
185 | ### 范例:给字段改名
186 |
187 | 我们从一个常量开始。
188 |
189 | ```js
190 | const organization = { name: "Acme Gooseberries", country: "GB" };
191 | ```
192 |
193 | 我想把 name 改名为 title。这个对象被很多地方使用,有些代码会更新 name 字段。所以我首先要用封装记录(162)把这个记录封装起来。
194 |
195 | ```js
196 | class Organization {
197 | constructor(data) {
198 | this._name = data.name;
199 | this._country = data.country;
200 | }
201 | get name() {
202 | return this._name;
203 | }
204 | set name(aString) {
205 | this._name = aString;
206 | }
207 | get country() {
208 | return this._country;
209 | }
210 | set country(aCountryCode) {
211 | this._country = aCountryCode;
212 | }
213 | }
214 |
215 | const organization = new Organization({
216 | name: "Acme Gooseberries",
217 | country: "GB",
218 | });
219 | ```
220 |
221 | 现在,记录结构已经被封装成类。在对字段改名时,有 4 个地方需要留意:取值函数、设值函数、构造函数以及内部数据结构。这听起来似乎是增加了重构的工作量,但现在我可以分别小步修改这 4 处,而不必一次修改所有地方,所以其实是降低了重构的难度。小步修改就意味着每一步出错的可能性大大减小,因此会省掉很多工作量——如果我从不犯错,小步前进不会节省工作量;但“从不犯错”这样的梦,我很久以前就已经不做了。
222 |
223 | 由于已经把输入数据复制到内部数据结构中,现在我需要将这两个数据结构区分开,以便各自单独处理。我可以另外定义一个字段,修改构造函数和访问函数,令其使用新字段。
224 |
225 | #### class Organization...
226 |
227 | ```js
228 | class Organization {
229 | constructor(data) {
230 | this._title = data.name;
231 | this._country = data.country;
232 | }
233 | get name() {
234 | return this._title;
235 | }
236 | set name(aString) {
237 | this._title = aString;
238 | }
239 | get country() {
240 | return this._country;
241 | }
242 | set country(aCountryCode) {
243 | this._country = aCountryCode;
244 | }
245 | }
246 | ```
247 |
248 | 接下来我就可以在构造函数中使用 title 字段。
249 |
250 | #### class Organization...
251 |
252 | ```js
253 | class Organization {
254 | constructor(data) {
255 | this._title = data.title !== undefined ? data.title : data.name;
256 | this._country = data.country;
257 | }
258 | get name() {
259 | return this._title;
260 | }
261 | set name(aString) {
262 | this._title = aString;
263 | }
264 | get country() {
265 | return this._country;
266 | }
267 | set country(aCountryCode) {
268 | this._country = aCountryCode;
269 | }
270 | }
271 | ```
272 |
273 | 现在,构造函数的调用者既可以使用 name 也可以使用 title(后者的优先级更高)。我会逐一查看所有调用构造函数的地方,将它们改为使用新名字。
274 |
275 | ```js
276 | const organization = new Organization({
277 | title: "Acme Gooseberries",
278 | country: "GB",
279 | });
280 | ```
281 |
282 | 全部修改完成后,就可以在构造函数中去掉对 name 的支持,只使用 title。
283 |
284 | #### class Organization...
285 |
286 | ```js
287 | class Organization {
288 | constructor(data) {
289 | this._title = data.title;
290 | this._country = data.country;
291 | }
292 | get name() {
293 | return this._title;
294 | }
295 | set name(aString) {
296 | this._title = aString;
297 | }
298 | get country() {
299 | return this._country;
300 | }
301 | set country(aCountryCode) {
302 | this._country = aCountryCode;
303 | }
304 | }
305 | ```
306 |
307 | 现在构造函数和内部数据结构都已经在使用新名字了,接下来我就可以给访问函数改名。这一步很简单,只要对每个访问函数运用函数改名(124)就行了。
308 |
309 | #### class Organization...
310 |
311 | ```js
312 | class Organization {
313 | constructor(data) {
314 | this._title = data.title;
315 | this._country = data.country;
316 | }
317 | get title() {
318 | return this._title;
319 | }
320 | set title(aString) {
321 | this._title = aString;
322 | }
323 | get country() {
324 | return this._country;
325 | }
326 | set country(aCountryCode) {
327 | this._country = aCountryCode;
328 | }
329 | }
330 | ```
331 |
332 | 上面展示的重构过程,是本重构手法最重量级的做法,只有对广泛使用的数据结构才用得上。如果该数据结构只在较小的范围(例如单个函数)中用到,我可能可以一步到位地完成所有改名动作,不需要提前做封装。何时需要用上全套重量级做法,这由你自己判断——如果在重构过程中破坏了测试,我通常会视之为一个信号,说明我需要改用更渐进的方式来重构。
333 |
334 | 有些编程语言允许将数据结构声明为不可变。在这种情况下,我可以把旧字段的值复制到新名字下,逐一修改使用方代码,然后删除旧字段。对于可变的数据结构,重复数据会招致灾难;而不可变的数据结构则没有这些麻烦。这也是大家愿意使用不可变数据的原因。
335 |
336 | ## 9.3 以查询取代派生变量(Replace Derived Variable with Query)
337 |
338 | ```js
339 | get discountedTotal() {return this._discountedTotal;}
340 | set discount(aNumber) {
341 | const old = this._discount;
342 | this._discount = aNumber;
343 | this._discountedTotal += old - aNumber;
344 | }
345 |
346 |
347 | get discountedTotal() {return this._baseTotal - this._discount;}
348 | set discount(aNumber) {this._discount = aNumber;}
349 | ```
350 |
351 | ### 动机
352 |
353 | 可变数据是软件中最大的错误源头之一。对数据的修改常常导致代码的各个部分以丑陋的形式互相耦合:在一处修改数据,却在另一处造成难以发现的破坏。很多时候,完全去掉可变数据并不现实,但我还是强烈建议:尽量把可变数据的作用域限制在最小范围。
354 |
355 | 有些变量其实可以很容易地随时计算出来。如果能去掉这些变量,也算朝着消除可变性的方向迈出了一大步。计算常能更清晰地表达数据的含义,而且也避免了“源数据修改时忘了更新派生变量”的错误。
356 |
357 | 有一种合理的例外情况:如果计算的源数据是不可变的,并且我们可以强制要求计算的结果也是不可变的,那么就不必重构消除计算得到的派生变量。因此,“根据源数据生成新数据结构”的变换操作可以保持不变,即便我们可以将其替换为计算操作。实际上,这是两种不同的编程风格:一种是对象风格,把一系列计算得出的属性包装在数据结构中;另一种是函数风格,将一个数据结构变换为另一个数据结构。如果源数据会被修改,而你必须负责管理派生数据结构的整个生命周期,那么对象风格显然更好。但如果源数据不可变,或者派生数据用过即弃,那么两种风格都可行。
358 |
359 | ### 做法
360 |
361 | 识别出所有对变量做更新的地方。如有必要,用拆分变量(240)分割各个更新点。
362 |
363 | 新建一个函数,用于计算该变量的值。
364 |
365 | 用引入断言(302)断言该变量和计算函数始终给出同样的值。
366 |
367 | 如有必要,用封装变量(132)将这个断言封装起来。
368 |
369 | 测试。
370 |
371 | 修改读取该变量的代码,令其调用新建的函数。
372 |
373 | 测试。
374 |
375 | 用移除死代码(237)去掉变量的声明和赋值。
376 |
377 | ### 范例
378 |
379 | 下面这个例子虽小,却完美展示了代码的丑陋。
380 |
381 | #### class ProductionPlan...
382 |
383 | ```js
384 | get production() {return this._production;}
385 | applyAdjustment(anAdjustment) {
386 | this._adjustments.push(anAdjustment);
387 | this._production += anAdjustment.amount;
388 | }
389 | ```
390 |
391 | 丑与不丑,全在观者。我看到的丑陋之处是重复——不是常见的代码重复,而是数据的重复。如果我要对生产计划(production plan)做调整(adjustment),不光要把调整的信息保存下来,还要根据调整信息修改一个累计值——后者完全可以即时计算,而不必每次更新。
392 |
393 | 但我是个谨慎的人。“可以即时计算”只是我的猜想——我可以用引入断言(302)来验证这个猜想。
394 |
395 | #### class ProductionPlan...
396 |
397 | ```js
398 | get production() {
399 | assert(this._production === this.calculatedProduction);
400 | return this._production;
401 | }
402 |
403 | get calculatedProduction() {
404 | return this._adjustments
405 | .reduce((sum, a) => sum + a.amount, 0);
406 | }
407 | ```
408 |
409 | 放上这个断言之后,我会运行测试。如果断言没有失败,我就可以不再返回该字段,改为返回即时计算的结果。
410 |
411 | #### class ProductionPlan...
412 |
413 | ```js
414 | get production() {
415 | assert(this._production === this.calculatedProduction);
416 | return this.calculatedProduction;
417 | }
418 | ```
419 |
420 | 然后用内联函数(115)把计算逻辑内联到 production 函数内。
421 |
422 | #### class ProductionPlan...
423 |
424 | ```js
425 | get production() {
426 | return this._adjustments
427 | .reduce((sum, a) => sum + a.amount, 0);
428 | }
429 | ```
430 |
431 | 再用移除死代码(237)扫清使用旧变量的地方。
432 |
433 | #### class ProductionPlan...
434 |
435 | ```js
436 | applyAdjustment(anAdjustment) {
437 | this._adjustments.push(anAdjustment);
438 | this._production += anAdjustment.amount;
439 | }
440 | ```
441 |
442 | ### 范例:不止一个数据来源
443 |
444 | 上面的例子处理得轻松愉快,因为 production 的值很明显只有一个来源。但有时候,累计值会受到多个数据来源的影响。
445 |
446 | #### class ProductionPlan...
447 |
448 | ```js
449 | constructor (production) {
450 | this._production = production;
451 | this._adjustments = [];
452 | }
453 | get production() {return this._production;}
454 | applyAdjustment(anAdjustment) {
455 | this._adjustments.push(anAdjustment);
456 | this._production += anAdjustment.amount;
457 | }
458 | ```
459 |
460 | 如果照上面的方式运用引入断言(302),只要 production 的初始值不为 0,断言就会失败。
461 |
462 | 不过我还是可以替换派生数据,只不过必须先运用拆分变量(240)。
463 |
464 | ```js
465 | constructor (production) {
466 | this._initialProduction = production;
467 | this._productionAccumulator = 0;
468 | this._adjustments = [];
469 | }
470 | get production() {
471 | return this._initialProduction + this._productionAccumulator;
472 | }
473 | ```
474 |
475 | 现在我就可以使用引入断言(302)。
476 |
477 | #### class ProductionPlan...
478 |
479 | ```js
480 | get production() {
481 | assert(this._productionAccumulator === this.calculatedProductionAccumulator);
482 | return this._initialProduction + this._productionAccumulator;
483 | }
484 |
485 | get calculatedProductionAccumulator() {
486 | return this._adjustments
487 | .reduce((sum, a) => sum + a.amount, 0);
488 | }
489 | ```
490 |
491 | 接下来的步骤就跟前一个范例一样了。不过我会更愿意保留 calculatedProduction Accumulator 这个属性,而不把它内联消去。
492 |
493 | ## 9.4 将引用对象改为值对象(Change Reference to Value)
494 |
495 | 反向重构:将值对象改为引用对象(256)
496 |
497 | ```js
498 | class Product {
499 | applyDiscount(arg) {this._price.amount -= arg;}
500 |
501 |
502 | class Product {
503 | applyDiscount(arg) {
504 | this._price = new Money(this._price.amount - arg, this._price.currency);
505 | }
506 | ```
507 |
508 | ### 动机
509 |
510 | 在把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不动,更新内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。
511 |
512 | 如果把一个字段视为值对象,我可以把内部对象的类也变成值对象[mf-vo]。值对象通常更容易理解,主要因为它们是不可变的。一般说来,不可变的数据结构处理起来更容易。我可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。我可以在程序各处复制值对象,而不必操心维护内存链接。值对象在分布式系统和并发系统中尤为有用。
513 |
514 | 值对象和引用对象的区别也告诉我,何时不应该使用本重构手法。如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。
515 |
516 | ### 做法
517 |
518 | 检查重构目标是否为不可变对象,或者是否可修改为不可变对象。
519 |
520 | 用移除设值函数(331)逐一去掉所有设值函数。
521 |
522 | 提供一个基于值的相等性判断函数,在其中使用值对象的字段。
523 |
524 | 大多数编程语言都提供了可覆写的相等性判断函数。通常你还必须同时覆写生成散列码的函数。
525 |
526 | ### 范例
527 |
528 | 设想一个代表“人”的 Person 类,其中包含一个代表“电话号码”的 Telephone Number 对象。
529 |
530 | #### class Person...
531 |
532 | ```js
533 | constructor() {
534 | constructor() {
535 | this._telephoneNumber = new TelephoneNumber();
536 | }
537 |
538 | get officeAreaCode() {return this._telephoneNumber.areaCode;}
539 | set officeAreaCode(arg) {this._telephoneNumber.areaCode = arg;}
540 | get officeNumber() {return this._telephoneNumber.number;}
541 | set officeNumber(arg) {this._telephoneNumber.number = arg;}
542 | ```
543 |
544 | #### class TelephoneNumber...
545 |
546 | ```js
547 | get areaCode() {return this._areaCode;}
548 | set areaCode(arg) {this._areaCode = arg;}
549 |
550 | get number() {return this._number;}
551 | set number(arg) {this._number = arg;}
552 | ```
553 |
554 | 代码的当前状态是提炼类(182)留下的结果:从前拥有电话号码信息的 Person 类仍然有一些函数在修改新对象的属性。趁着还只有一个指向新类的引用,现在是时候使用将引用对象改为值对象将其变成值对象。
555 |
556 | 我需要做的第一件事是把 TelephoneNumber 类变成不可变的。对它的字段运用移除设值函数(331)。移除设值函数(331)的第一步是,用改变函数声明(124)把这两个字段的初始值加到构造函数中,并迫使构造函数调用设值函数。
557 |
558 | #### class TelephoneNumber...
559 |
560 | ```js
561 | constructor(areaCode, number) {
562 | this._areaCode = areaCode;
563 | this._number = number;
564 | }
565 | ```
566 |
567 | 然后我会逐一查看设值函数的调用者,并将其改为重新赋值整个对象。先从“地区代码”(area code)开始。
568 |
569 | #### class Person...
570 |
571 | ```js
572 | get officeAreaCode() {return this._telephoneNumber.areaCode;}
573 | set officeAreaCode(arg) {
574 | this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber);
575 | }
576 | get officeNumber() {return this._telephoneNumber.number;}
577 | set officeNumber(arg) {this._telephoneNumber.number = arg;}
578 | ```
579 |
580 | 对于其他字段,重复上述步骤。
581 |
582 | #### class Person...
583 |
584 | ```js
585 | get officeAreaCode() {return this._telephoneNumber.areaCode;}
586 | set officeAreaCode(arg) {
587 | this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber);
588 | }
589 | get officeNumber() {return this._telephoneNumber.number;}
590 | set officeNumber(arg) {
591 | this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg);
592 | }
593 | ```
594 |
595 | 现在,TelephoneNumber 已经是不可变的类,可以将其变成真正的值对象了。是不是真正的值对象,要看是否基于值判断相等性。在这个领域中,JavaScript 做得不好:语言和核心库都不支持将“基于引用的相等性判断”换成“基于值的相等性判断”。我唯一能做的就是创建自己的 equals 函数。
596 |
597 | #### class TelephoneNumber...
598 |
599 | ```js
600 | equals(other) {
601 | if (!(other instanceof TelephoneNumber)) return false;
602 | return this.areaCode === other.areaCode &&
603 | this.number === other.number;
604 | }
605 | ```
606 |
607 | 对其进行测试很重要:
608 |
609 | ```js
610 | it("telephone equals", function () {
611 | assert(
612 | new TelephoneNumber("312", "555-0142").equals(
613 | new TelephoneNumber("312", "555-0142")
614 | )
615 | );
616 | });
617 | ```
618 |
619 | 这段测试代码用了不寻常的格式,是为了帮助读者一眼看出上下两次构造函数调用完全一样。
620 |
621 | 我在这个测试中创建了两个各自独立的对象,并验证它们相等。
622 |
623 | 在大多数面向对象语言中,内置的相等性判断方法可以被覆写为基于值的相等性判断。在 Ruby 中,我可以覆写==运算符;在 Java 中,我可以覆写 Object.equals()方法。在覆写相等性判断的同时,我通常还需要覆写生成散列码的方法(例如 Java 中的 Object.hashCode()方法),以确保用到散列码的集合在使用值对象时一切正常。
624 |
625 | 如果有多个客户端使用了 TelephoneNumber 对象,重构的过程还是一样,只是在运用移除设值函数(331)时要修改多处客户端代码。另外,有必要添加几个测试,检查电话号码不相等以及与非电话号码和 null 值比较相等性等情况。
626 |
627 | ## 9.5 将值对象改为引用对象(Change Value to Reference)
628 |
629 | 反向重构:将引用对象改为值对象(252)
630 |
631 | ```js
632 | let customer = new Customer(customerData);
633 |
634 | let customer = customerRepository.get(customerData.id);
635 | ```
636 |
637 | ### 动机
638 |
639 | 一个数据结构中可能包含多个记录,而这些记录都关联到同一个逻辑数据结构。例如,我可能会读取一系列订单数据,其中有多条订单属于同一个顾客。遇到这样的共享关系时,既可以把顾客信息作为值对象来看待,也可以将其视为引用对象。如果将其视为值对象,那么每份订单数据中都会复制顾客的数据;而如果将其视为引用对象,对于一个顾客,就只有一份数据结构,会有多个订单与之关联。
640 |
641 | 如果顾客数据永远不修改,那么两种处理方式都合理。把同一份数据复制多次可能会造成一点困扰,但这种情况也很常见,不会造成太大问题。过多的数据复制有可能会造成内存占用的问题,但就跟所有性能问题一样,这种情况并不常见。
642 |
643 | 如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的困难。此时我必须找到所有的副本,更新所有对象。只要漏掉一个副本没有更新,就会遭遇麻烦的数据不一致。这种情况下,可以考虑将多份数据副本变成单一的引用,这样对顾客数据的修改就会立即反映在该顾客的所有订单中。
644 |
645 | 把值对象改为引用对象会带来一个结果:对于一个客观实体,只有一个代表它的对象。这通常意味着我会需要某种形式的仓库,在仓库中可以找到所有这些实体对象。只为每个实体创建一次对象,以后始终从仓库中获取该对象。
646 |
647 | ### 做法
648 |
649 | 为相关对象创建一个仓库(如果还没有这样一个仓库的话)。
650 |
651 | 确保构造函数有办法找到关联对象的正确实例。
652 |
653 | 修改宿主对象的构造函数,令其从仓库中获取关联对象。每次修改后执行测试。
654 |
655 | ### 范例
656 |
657 | 我将从一个代表“订单”的 Order 类开始,其实例对象可从一个 JSON 文件创建。用来创建订单的数据中有一个顾客(customer)ID,我们用它来进一步创建 Customer 对象。
658 |
659 | #### class Order...
660 |
661 | ```js
662 | constructor(data) {
663 | this._number = data.number;
664 | this._customer = new Customer(data.customer);
665 | // load other data
666 | }
667 | get customer() {return this._customer;}
668 | ```
669 |
670 | #### class Customer...
671 |
672 | ```js
673 | constructor(id) {
674 | this._id = id;
675 | }
676 | get id() {return this._id;}
677 | ```
678 |
679 | 以这种方式创建的 Customer 对象是值对象。如果有 5 个订单都属于 ID 为 123 的顾客,就会有 5 个各自独立的 Customer 对象。对其中一个所做的修改,不会反映在其他几个对象身上。如果我想增强 Customer 对象,例如从客户服务获取到了更多关于顾客的信息,我必须用同样的数据更新所有 5 个对象。重复的对象总是会让我紧张——用多个对象代表同一个实体(例如一名顾客),这会招致混乱。如果 Customer 对象是可变的,问题就更加严重,因为各个对象之间的数据可能不一致。
680 |
681 | 如果我想每次都使用同一个 Customer 对象,那么就需要有一个地方存储这个对象。每个应用程序中,存储实体的地方会各有不同,在最简单的情况下,我会使用一个仓库对象[mf-repos]。
682 |
683 | ```js
684 | let _repositoryData;
685 |
686 | export function initialize() {
687 | _repositoryData = {};
688 | _repositoryData.customers = new Map();
689 | }
690 |
691 | export function registerCustomer(id) {
692 | if (!_repositoryData.customers.has(id))
693 | _repositoryData.customers.set(id, new Customer(id));
694 | return findCustomer(id);
695 | }
696 |
697 | export function findCustomer(id) {
698 | return _repositoryData.customers.get(id);
699 | }
700 | ```
701 |
702 | 仓库对象允许根据 ID 注册顾客,并且对于一个 ID 只会创建一个 Customer 对象。有了仓库对象,我就可以修改 Order 对象的构造函数来使用它。
703 |
704 | 在使用本重构手法时,可能仓库对象已经存在了,那么就可以直接使用它。
705 |
706 | 下一步是要弄清楚,Order 的构造函数如何获得正确的 Customer 对象。在这个例子里,这一步很简单,因为输入数据流中已经包含了顾客的 ID。
707 |
708 | #### class Order...
709 |
710 | ```js
711 | constructor(data) {
712 | this._number = data.number;
713 | this._customer = registerCustomer(data.customer);
714 | // load other data
715 | }
716 | get customer() {return this._customer;}
717 | ```
718 |
719 | 现在,如果我在一条订单中修改了顾客信息,就会同步反映在该顾客拥有的所有订单中。
720 |
721 | 在这个例子里,我在第一个引用该顾客信息的 Order 对象中新建了 Customer 对象。另一个常见的做法是:首先获取一份包含所有 Customer 对象的列表,将其填入仓库对象,然后在读取 Order 对象时关联到对应的 Customer 对象。如果这样做,那么 Order 对象包含的顾客 ID 必须指向一个仓库中已有的 Customer 对象,否则就表示程序中有错误。
722 |
723 | 上面的代码还有一个问题:构造函数与一个全局的仓库对象耦合。全局对象必须小心对待:它们就像强力的药物,少用一点儿大有益处,用过量就是毒药。如果想解决这个问题,可以将仓库对象作为参数传递给构造函数。
724 |
--------------------------------------------------------------------------------
/docs/cover.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/cover.jpg
--------------------------------------------------------------------------------
/docs/figures/image00280.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00280.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00281.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00281.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00282.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00282.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00283.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00283.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00284.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00284.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00286.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00286.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00288.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00288.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00290.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00290.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00292.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00292.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00293.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00293.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00294.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00294.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00296.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00296.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00298.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00298.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00300.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00300.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00302.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00302.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00304.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00304.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00306.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00306.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00308.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00308.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00310.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00310.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00312.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00312.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00314.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00314.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00316.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00316.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00318.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00318.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00319.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00319.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00321.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00321.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00323.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00323.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00325.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00325.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00327.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00327.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00329.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00329.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00331.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00331.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00333.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00333.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00335.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00335.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00337.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00337.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00339.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00339.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00341.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00341.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00343.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00343.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00345.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00345.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00347.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00347.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00349.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00349.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00351.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00351.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00353.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00353.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00355.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00355.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00357.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00357.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00359.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00359.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00361.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00361.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00363.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00363.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00365.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00365.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00367.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00367.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00369.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00369.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00371.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00371.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00373.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00373.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00375.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00375.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00377.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00377.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00379.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00379.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00381.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00381.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00383.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00383.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00385.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00385.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00387.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00387.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00389.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00389.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00391.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00391.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00392.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00392.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00393.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00393.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00395.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00395.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00397.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00397.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00399.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00399.jpeg
--------------------------------------------------------------------------------
/docs/figures/image00401.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NxeedGoto/Refactoring2-zh/a87ae8e4cdb2b69073b1deac26424c02bbc0f2e5/docs/figures/image00401.jpeg
--------------------------------------------------------------------------------
/gitee-deploy.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # abort on errors
4 | set -e
5 |
6 | # build
7 | yarn docs:build
8 |
9 | # navigate into the build output directory
10 | cd docs/.vuepress/dist
11 |
12 | # if you are deploying to a custom domain
13 | echo 'http://gdut_yy.gitee.io/doc-refact2/' > CNAME
14 |
15 | git init
16 | git add -A
17 | git commit -m 'deploy'
18 |
19 | # if you are deploying to https://.github.io
20 | git push -f git@gitee.com:gdut_yy/doc-refact2.git master
21 |
22 | # if you are deploying to https://.github.io/
23 | # git push -f git@github.com:/.git master:gh-pages
24 |
25 | cd -
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "docs:dev": "vuepress dev docs --open --host 127.0.0.1",
4 | "docs:build": "vuepress build docs"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------