《重构》读后感(第九章,第十章)

第九章:重新组织数据。

一、拆分变量
动机:
变量有各种不同的用途,其中某些用途会很自然地导致临时变量被多次赋值。”循环变量”和”结果收集变量”就是两个例子:循环变量会随循环的每次运行而改变;结果收集变量负责将”通过整个函数的运算”而构成的某个值收集起来。

除了以上两种情况,还有很多变量用于保存一段冗长代码的运算结果,以便稍后使用。这种变量应该只被赋值一次。如果被赋值超过一次,就意味着它们在函数中承担了一个以上的责任。如果变量承担多个责任,就应该被替换(分解)为多个变量,每个变量只承担一个责任。同一个变量承担两件不同的事情,会令代码阅读者糊涂。

具体展现:

// 重构前
let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);

// 重构后
const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);

二、字段改名
动机:
命名很重要,这个不需要再过多的重复了。

具体展现:

// 重构前
class Organization {
    get name() { ... }
}

// 重构后
class Organization {
    get title() { ... }
}

三、以查询取代派生变量
动机:
可变数据是软件中最大的错误源头之一。

有些变量其实可以很容易地随时计算出来。如果能去掉这些变量,也能消除可变性。计算常能更清晰地表达数据的含义,而且也避免了”源数据修改时忘了更新派生变量”的错误。

具体展现:

// 重构前
get discountedTotal() { return this._discountedTotal; }
set discount(aNamber) {
    const old = this._discount;
    this._discount = aNamber;
    this._discountedTotal += old - aNamber;
}

// 重构后
get discountedTotal() { return this._baseTotal - this._discount; }
set discount(aNamber) { this._discount = aNamber; }

四、将引用对象改为值对象
动机:
在把一个对象(或数据结构)嵌入另一个对象时,位于内部的对象可以被视为引用对象,也可以被视为值对象。其最明显的差别在于:视为引用对象的话,在更新其属性时,会保留原对象不动,更新内部对象的属性。如果视为值对象,就可以替换整个内部对象,把内部对象的类也变成值对象。

值对象是不可变的。因此可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。可以在程序各处复制值对象,而不必操心维护内存链接。

如果想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。

具体展现:

// 重构前
class Product {
    applyDiscount(arg) { this._price.amount -= arg; }
}

// 重构后
class Product {
    applyDiscount(arg) {
        this._price = new Money(this._price.amount - arg, this._price.currency);
    }
}

五、将值对象改为引用对象
动机:
正如上面的重构方法所知,把数据作为值对象和引用对象都可以。但需要根据具体需求而定。

如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的问题。对于这种情况,可以考虑将多分数据副本变成单一的引用,这样对一处数据的修改就会立即反映到所有引用的数据中。

如果要将值对象转换成引用对象,可以创建一个仓库对象,仓库对象存储所有唯一的值,在需要使用到值的构造函数中将仓库引用进来就可以了。

具体展现:

// 重构前
let customer = new Customer(customerData);

// 重构后
let customer = customerRepository.get(customerData.id);

第十章:简化条件逻辑

程序的大部分威力来自条件逻辑,但很不幸,程序的复杂程度也大多来自条件逻辑。

一、分解条件表达式
动机:
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。

和任何大块头代码一样,对于复杂逻辑的函数,我们可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。

具体展现:

// 重构前
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
    charge = quantity * plan.summerRate;
} else {
    charge = quantity * plan.regularRate + plan.regularServiceCharge;
}

// 重构后
if (summer()) {
    charge = summerCharge();
} else {
    charge = regularCharge();
}

二、合并条件表达式
动机:
如果一串检查条件各不相同,但最终行为一致,就应该使用”逻辑或”和”逻辑与”将它们合并为一个条件表达式。

具体展现:

// 重构前
if (anExployee.seniority < 2) return 0;
if (anExployee.monthsDisabled > 12) return 0;
if (anExployee.isPartTime) return 0;

// 重构后
if (isNotEligibleForDisability()) return 0;

function isNotEligibleForDisability() {
    return ((anExployee.seniority < 2) 
            || (anExployee.monthsDisabled > 12) 
            || (anExployee.isPartTime));
}

三、以卫语句取代嵌套条件表达式
动机:
条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格是:只有一个条件分支是正常行为,另一个分支则是异常的情况。

如果两条分支都是正常行为,就应该使用形如if … else …的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时从函数中返回。这样的单独检查常常被称为”卫语句”(guard clauses)。

卫语句的精髓就是:给某一条分支以特别的重视。卫语句告诉读者:“这种情况不是本函数的核心逻辑所关心的,如果真的发生了,请做一些必要的整理工作,然后退出”。

具体展现:

// 重构前
function getPayAmount() {
    let result;
    if (isDead) {
        result = deadAmount();
    } else {
        if (isSeparated) {
            result = separatedAmount();
        } else {
            if (isRetired) {
                result = retiredAmount();
            } else {
                result = normalPayAmount();
            }
        }
    }
    return result;
}

// 重构后
function getPayAmount() {
    if (isDead) return deadAmount();
    if (isSeparated) return separatedAmount();
    if (isRetired) return retiredAmount();
    return normalPayAmount();
}

四、 以多态取代条件表达式
动机:
一种常见场景:如果有好几个函数都有基于类型代码的switch语句,我们就可以针对每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。

另一种情况:基础逻辑可能是最常用的,也可能是最简单的。可以把基础逻辑放进超类,这样就首先可以理解在和部分逻辑,暂时不管各种变体,然后可以把每种变体逻辑单独放进一个子类,其中的代码着重强调与基础逻辑的差异。

具体展现:

// 重构前
switch (brid.type) {
    case 'EuropeanSwallow':
        return 'average';
    case 'AfricanSwallow':
        return (bird.numberOfCoconuts > 2) ? 'tired' : 'average';
    case 'NorwegianBlueParrot':
        return (bird.voltage > 100) ? 'scorched' : 'beautiful';
    default:
        return 'unknown';
}

// 重构后
class EuropeanSwallow {
  get plumage() {
    return 'average';
  }
}

class AfricanSwallow {
  get plumage() {
    return (this.numberOfCoconuts > 2) ? 'tired' : 'average';
  }
}

class NorwegianBlueParrot {
  get plumage() {
    return (this.voltage > 100) ? 'scorched' : 'beautiful';
  }
}

五、引入特例
动机:
有一种常见的重复代码情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果发现代码库中有多处以同样方式应对同一个特殊值,应该把这个处理逻辑收拢在一起。处理这种情况的一个好办法就是使用“特例”模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。

一个通常需要特殊处理的值就是null,这也是这个模式常被叫做”Null对象”(Null Object)模式的原因,Null对象是特例的一种特例。

具体展现:

// 重构前
if (aCustomer === "unknown") customerName = "occupant";

// 重构后
class UnknownCustomer {
    get name() { return "occupant"; }
}

六、引入断言
动机:
常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。这样的假设通常并没有在代码中明确的表现出了,我们可以使用断言来明确标明这些假设。

断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误,断言的失败不应被系统任何地方捕获,整个程序的行为在有没有断言出现的时候都应该完全一样。

断言是一种很有价值的交流形式——它告诉阅读者,程序执行到这一点时,对当前状态做出了何种假设。另外断言对调试也很有帮助。

另外,不要滥用断言。不要使用断言来检查“我认为应该为真”的条件,应该来检查“必须为真”的条件。

具体展现:

// 重构前
if (this.discountRate) {
    base = base - (this.discountRate * base);
}

// 重构后
assert(this.discountRate >= 0);
if (this.discountRate) {
    base = base - (this.discountRate * base);
}