关于 if/else 和 for 的可读性问题
谈谈关于代码中 if/else 和 for 语句的可读性问题。
可读性意味着可维护性,可维护性又关联着个人和他人维护的效率。维护代码过程中,我们阅读代码的时间肯定要比写代码的时间长,部分决定了 Bug 需要花多长的时间去接口,所以可读性还是很重要的。
(在和朋友讨论的过程中,发生了一个争论,关于可读性和性能的优先级,从而有了重写这篇博客的想法。主要改动了结构和增加了关于可读性和性能的讨论。)
问题 #
undefined
首先看一张很久以前见过,非常耸人听闻的嵌套代码。图中代码的问题是,嵌套层级太深,会导致读代码读到中间的时候已经忘记之前的判定条件是什么。
在我们项目的代码里虽然没有这么夸张,但是嵌套三/四层有时候还是会见到的。虽然在早期公司商业模式没有验证,代码质量比较差可以理解,但是给后来的同学给出来不好的榜样。
所以我们的重构目标就是要去掉多层的缩进,减少阅读代码的负担。
if/else #
上面的例子太夸张,这里举一些简单一些的例子,重构手法是一致的。
例子一 #
这是一段做 EPUB 格式的电子书分发的代码。需要根据书的 id 查询出所有的电子书版本,然后判定书籍的电子版的有效性,有效的话需要去注册 ISBN 信息,注册成功的版本需要发出通知。
这里问题是有三层嵌套,要简化逻辑,我们要拆掉嵌套。
function distributeEpubs(bookId) {
let epubs = getEpubsByBookId(bookId);
for (let epub of epubs) {
if (epub.isValid()) {
let registered = registerIsbn(epub);
if (registered) {
sendEpub(epub);
}
}
}
}
要拆嵌套,我们先想到的是可以把循环里面抽出一个函数出来。(这里的循环我们在下面重构)
function distributeEpubs1(bookId) {
let epubs = getEpubsByBookId(bookId);
for (let epub of epubs) {
distributeEpub(epub);
}
}
function distributeEpub() {
if (epub.isValid()) {
let registered = registerIsbn(epub);
if (registered) {
sendEpub(epub);
}
}
}
其次 if 嵌套可以先把处理完的返回,一般都是先把错误情况处理完就返回。
function distributeEpub() {
if (!epub.isValid()) {
return;
}
let registered = registerIsbn(epub);
if (!registered) {
return;
}
sendEpub(epub);
}
只有一层嵌套,非常漂亮。
例子二 #
这是一个网络聊天室,只有两个人都连接上才能聊天。这里还是嵌套的问题,还有 else 的问题。
function ConnectPeer2Peer(pA, pB, manager) {
if (pA.isConnected()) {
manager.Prepare(pA);
if (pB.isConnected()) {
manager.Prepare(pB);
if (manager.ConnectTogther(pA, pB)) {
pA.Write("connected");
pB.Write("connected");
return S_OK;
} else {
return S_ERROR;
}
} else {
pA.Write("Peer is not Ready, waiting...");
return S_RETRY;
}
} else {
if (pB.isConnected()) {
pB.Write("Peer is not Ready, waiting...");
return S_RETRY;
} else {
pA.Close();
pB.Close();
return S_ERROR;
}
}
}
按照我的习惯,会把有预期的错误都处理完,最后可以无嵌套的处理成功的情况。
function ConnectPeer2Peer(pA, pB, manager) {
if (!pA.isConnected() || !pB.isConnected()) {
pA.Close();
pB.Close();
return S_ERROR;
}
if (pA.isConnected() && !pB.isConnected()) {
manager.Prepare(pA);
pA.Write("Peer is not Ready, waiting...");
return S_RETRY;
}
if (!pA.isConnected() && pB.isConnected()) {
manager.Prepare(pB);
pB.Write("Peer is not Ready, waiting...");
return S_RETRY;
}
if (!manager.ConnectTogther(pA, pB)) {
return S_ERROR;
}
pA.Write("connected");
pB.Write("connected");
return S_OK;
}
例子三 #
这个坏味道在我们的代码中也比较常见,就是重复的 if/else 或者 switch/case 。
function getBookPrice(user, book) {
let price = book.getPrice();
switch (user.getLevel()) {
case UserLevel.SILVER:
return price * 0.9;
case UserLevel.GOLD:
return price * 0.8;
case UserLevel.PLATINUM:
return price * 0.75;
default:
return price;
}
}
function getEpubPrice(user, epub) {
let price = epub.getPrice();
switch (user.getLevel()) {
case UserLevel.SILVER:
return price * 0.95;
case UserLevel.GOLD:
return price * 0.85;
case UserLevel.PLATINUM:
return price * 0.8;
default:
return price;
}
}
这里重复的问题是缺少关于 level 的模型,我们可以通过增加 UserLevel 的模型把重复的 if/else 去掉。我这里给出 typescrite 的版本,通过显示的接口说明对外屏蔽细节。
interface IUserLevel {
getBookPrice: (book: Book) => number
getEpubPrice: (epub: Epub) => number
}
class RegularUserLevel implements IUserLevel {
getBookPrice(book: Book) {
return book.getPrice();
}
getEpubPrice(epub: Epub) {
return epub.getPrice();
}
}
class SilverUserLevel implements IUserLevel {
getBookPrice(book: Book) {
return book.getPrice() * 0.9;
}
getEpubPrice(epub: Epub) {
return epub.getPrice() * 0.85;
}
}
class GoldUserLevel implements IUserLevel {
getBookPrice(book: Book) {
return book.getPrice() * 0.8;
}
getEpubPrice(epub: Epub) {
return epub.getPrice() * 0.85;
}
}
class PlatinumUserLevel implements IUserLevel {
getBookPrice(book: Book) {
return book.getPrice() * 0.75;
}
getEpubPrice(epub: Epub) {
return epub.getPrice() * 0.8;
}
}
function getBookPrice(user: IUser, book: Book) {
let level = user.getUserLevel()
return level.getBookPrice(book);
}
function getEpubPrice(user: IUser, epub: Epub) {
let level = user.getUserLevel()
return level.getEpubPrice(epub);
}
小结 #
- 嵌套和 else 都是一种坏味道,能不写就不写。
- 这种快速返回的处理方法叫卫语句,一般把容易处理的情况快速返回达到减少分支的目的,从而最后可以无嵌套的处理正常情况。
- 重复的 if/else 可以通过多态屏蔽重复的代码。实现上通过接口屏蔽细节,在需要使用的时候根据类型传入相应的接口实现,从而让使用方不需要关系这些不同的类型。
for #
例子一 #
for 循环的问题是代码是过程式的,需要人脑一个元素一个元素的取出来去跑函数体,我们可以通过 map filter reduce 的写法达到声明式效果,只需要告诉程式要做什么而不是怎么做。
function toParameters(chapters) {
let parameters = [];
for (const chapter of chapters) {
if (chapter.isApproved()) {
parameters.add(toChapterParameter(chapter));
}
}
return parameters;
}
通过 filter map 重构后:
function toParameters(chapters) {
return chapters
.filter(chapter => chapter.isApproved())
.map(chapter => toChapterParameter(chapter));
}
例子二 #
还有经常可以看到的代码是,一个循环里面做了太多的事情。
let averageAge = 0;
let totalSalary = 0;
for (const p of people) {
averageAge += p.age;
totalSalary += p.salary;
}
averageAge = averageAge / people.length;
一般我们会把 for 循环里面做的不同的事情拆到不同的函数中去,这里例子在这里不太好,不过这不是我们的最终版本。
let averageAge = 0;
let totalSalary = 0;
for (const p of people) {
averageAge = calAverageAge(p.age, averageAge);
totalSalary = calTotalSalary(p.salary, totalSalary);
}
function calAverageAge(age, totalAge) {
return age + totalAge;
}
function calTotalSalary(salary, totalSalary) {
return salary + totalSalary;
}
averageAge = averageAge / people.length;
有一个在大家看来比较激进的做法,用两个 for 循环去做这两件事,大家会担心性能的问题。有几个观点可以和大家讨论下。
- 性能瓶颈是压测出来的,在一开始开发的时候,可以把可读性放到性能的前面。
- 函数拆小后,可以提高可读性,一个函数只做一件事。
- 函数拆小后,可以提供更高的复用性。
let totalSalary = 0;
for (const p of people) {
totalSalary += p.salary;
}
let averageAge = 0;
for (const p of people) {
averageAge += p.age;
}
averageAge = averageAge / people.length;
小结 #
- 通过一些基本的函数式函数 map filter reduce 来简化 for 循环,从而达到声明式写法的简洁。
- 过程式是描述怎么做,声明式是描述做什么,抽象层级更高。
- 某些情况我们可以拆分循环函数,把函数拆小,让函数只做一件事,因为只有小函数才有更有可能去复用。
总结 #
- 我们可以通过卫语句,多态,声明式编程,降低代码的复杂度,提高的代码的可读性和可维护性。
- 有一个方式可以计算一个函数的复杂度,叫做圈复杂度 Cyclomatic complexity。我们可以通过一些工具来检查代码是否有复杂度太高的函数。
- 更多例子可以参考 Github 地址。
References #
- 重构(第2版)
- 以卫语句取代嵌套的条件表达式 Replace Nested Conditional with Guard Clauses
- 以多态取代条件表达式 Replace Conditional with Polymorphism
- 以管道取代循环 Replace Loop with Pipeline
- 拆分循环 Split Loop
- Refactoring Catalog
- 代码之丑
- 如何重构“箭头型”代码
- 编程的智慧
- Previous: Nginx Too many open files
- Next: [10, 2, 1].sort() 在 JS 中会输出什么