曾用名:以明确函数取代参数(Replace Parameter with Explicit Methods)
function setDimension(name, value) {
if (name === "height") {
this._height = value;
return;
}
if (name === "width") {
this._width = value;
return;
}
}
function setHeight(value) {
this._height = value;
}
function setWidth(value) {
this._width = value;
}动机
“标记参数”是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑。例如,我可能有下面这样一个函数:
function bookConcert(aCustomer, isPremium) {
if (isPremium) {
// logic for premium booking
} else {
// logic for regular booking
}
}要预订一场高级音乐会(premium concert),就得这样发起调用:
bookConcert(aCustomer, true);标记参数也可能以枚举的形式出现:
bookConcert(aCustomer, CustomerType.PREMIUM);或者是以字符串(或者符号,如果编程语言支持的话)的形式出现:
bookConcert(aCustomer, "premium");我不喜欢标记参数,因为它们让人难以理解到底有哪些函数可以调用、应该怎么调用。拿到一份 API 以后,我首先看到的是一系列可供调用的函数,但标记参数却隐藏了函数调用中存在的差异性。使用这样的函数,我还得弄清标记参数有哪些可用的值。布尔型的标记尤其糟糕,因为它们不能清晰地传达其含义——在调用一个函数时,我很难弄清 true 到底是什么意思。如果明确用一个函数来完成一项单独的任务,其含义会清晰得多。
premiumBookConcert(aCustomer);并非所有类似这样的参数都是标记参数。如果调用者传入的是程序中流动的数据,这样的参数不算标记参数;只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;只有参数值影响了函数内部的控制流,这才是标记参数。
移除标记参数不仅使代码更整洁,并且能帮助开发工具更好地发挥作用。去掉标记参数后,代码分析工具能更容易地体现出“高级”和“普通”两种预订逻辑在使用时的区别。
如果一个函数有多个标记参数,可能就不得不将其保留,否则我就得针对各个参数的各种取值的所有组合情况提供明确函数。不过这也是一个信号,说明这个函数可能做得太多,应该考虑是否能用更简单的函数来组合出完整的逻辑。
做法
针对参数的每一种可能值,新建一个明确函数。
如果主函数有清晰的条件分发逻辑,可以用分解条件表达式(260)创建明确函数;否则,可以在原函数之上创建包装函数。
对于“用字面量值作为参数”的函数调用者,将其改为调用新建的明确函数。
范例
在浏览代码时,我发现多处代码在调用一个函数计算物流(shipment)的到货日期(delivery date)。一些调用代码类似这样:
aShipment.deliveryDate = deliveryDate(anOrder, true);另一些调用代码则是这样:
aShipment.deliveryDate = deliveryDate(anOrder, false);面对这样的代码,我立即开始好奇:参数里这个布尔值是什么意思?是用来干什么的?
deliveryDate 函数主体如下所示:
function deliveryDate(anOrder, isRush) {
if (isRush) {
let deliveryTime;
if (["MA", "CT"].includes(anOrder.deliveryState)) deliveryTime = 1;
else if (["NY", "NH"].includes(anOrder.deliveryState)) deliveryTime = 2;
else deliveryTime = 3;
return anOrder.placedOn.plusDays(1 + deliveryTime);
} else {
let deliveryTime;
if (["MA", "CT", "NY"].includes(anOrder.deliveryState)) deliveryTime = 2;
else if (["ME", "NH"].includes(anOrder.deliveryState)) deliveryTime = 3;
else deliveryTime = 4;
return anOrder.placedOn.plusDays(2 + deliveryTime);
}
}原来调用者用这个布尔型字面量来判断应该运行哪个分支的代码——典型的标记参数。然而函数的重点就在于要遵循调用者的指令,所以最好是用明确函数的形式明确说出调用者的意图。
对于这个例子,我可以使用分解条件表达式(260),得到下列代码:
function deliveryDate(anOrder, isRush) {
if (isRush) return rushDeliveryDate(anOrder);
else return regularDeliveryDate(anOrder);
}
function rushDeliveryDate(anOrder) {
let deliveryTime;
if (["MA", "CT"].includes(anOrder.deliveryState)) deliveryTime = 1;
else if (["NY", "NH"].includes(anOrder.deliveryState)) deliveryTime = 2;
else deliveryTime = 3;
return anOrder.placedOn.plusDays(1 + deliveryTime);
}
function regularDeliveryDate(anOrder) {
let deliveryTime;
if (["MA", "CT", "NY"].includes(anOrder.deliveryState)) deliveryTime = 2;
else if (["ME", "NH"].includes(anOrder.deliveryState)) deliveryTime = 3;
else deliveryTime = 4;
return anOrder.placedOn.plusDays(2 + deliveryTime);
}这两个函数能更好地表达调用者的意图,现在我可以修改调用方代码了。调用代码
aShipment.deliveryDate = deliveryDate(anOrder, true);可以改为
aShipment.deliveryDate = rushDeliveryDate(anOrder);另一个分支也类似。
处理完所有调用处,我就可以移除 deliveryDate 函数。
这个参数是标记参数,不仅因为它是布尔类型,而且还因为调用方以字面量的形式直接设置参数值。如果所有调用 deliveryDate 的代码都像这样:
const isRush = determineIfRush(anOrder);
aShipment.deliveryDate = deliveryDate(anOrder, isRush);那我对这个函数的签名没有任何意见(不过我还是想用分解条件表达式(260)清理其内部实现)。
可能有一些调用者给这个参数传入的是字面量,将其作为标记参数使用;另一些调用者则传入正常的数据。若果真如此,我还是会使用移除标记参数(314),但不修改传入正常数据的调用者,重构结束时也不删除 deliveryDate 函数。这样我就提供了两套接口,分别支持不同的用途。
直接拆分条件逻辑是实施本重构的好方法,但只有当“根据参数值做分发”的逻辑发生在函数最外层(或者可以比较容易地将其重构至函数最外层)的时候,这一招才好用。函数内部也有可能以一种更纠结的方式使用标记参数,例如下面这个版本的 deliveryDate 函数:
function deliveryDate(anOrder, isRush) {
let result;
let deliveryTime;
if (anOrder.deliveryState === "MA" || anOrder.deliveryState === "CT")
deliveryTime = isRush? 1 : 2;
else if (anOrder.deliveryState === "NY" || anOrder.deliveryState === "NH") {
deliveryTime = 2;
if (anOrder.deliveryState === "NH" && !isRush)
deliveryTime = 3;
}
else if (isRush)
deliveryTime = 3;
else if (anOrder.deliveryState === "ME")
deliveryTime = 3;
else
deliveryTime = 4;
result = anOrder.placedOn.plusDays(2 + deliveryTime);
if (isRush) result = result.minusDays(1);
return result;
}这种情况下,想把围绕 isRush 的分发逻辑剥离到顶层,需要的工作量可能会很大。所以我选择退而求其次,在 deliveryDate 之上添加两个函数:
function rushDeliveryDate(anOrder) {
return deliveryDate(anOrder, true);
}
function regularDeliveryDate(anOrder) {
return deliveryDate(anOrder, false);
}本质上,这两个包装函数分别代表了 deliveryDate 函数一部分的使用方式。不过它们并非从原函数中拆分而来,而是用代码文本强行定义的。
随后,我同样可以逐一替换原函数的调用者,就跟前面分解条件表达式之后的处理一样。如果没有任何一个调用者向 isRush 参数传入正常的数据,我最后会限制原函数的可见性,或是将其改名(例如改为 deliveryDateHelperOnly),让人一见即知不应直接使用这个函数。