让你的代码变得更简洁、干净

你是否见过下面这样的代码?

public String getProductNames(List<Product> products) {
StringBuilder strBuf = new StringBuilder();
int i = 0;
strBuf.append(products.get(0).name);
while (i < products.size()) {
strBuf.append(", ");
strBuf.append(products.get(i++).name);
}
return strBuf.toString();
}

这样的代码存在于一些保留系统(Legacy System)中,通常很旧。当你看到这样的代码时,很可能感觉不太友好。

这段代码的问题在于:不仅太冗长,更重要的是它隐藏了逻辑(还有一些其他问题,后面讨论)。我们编写代码来解决问题,因此,不应该在代码中创造新的问题。请注意,当编写“系统代码”或者以高性能为目标的库时、或者我们需要解决的问题技术太过复杂时,是可以牺牲可读性。但是即便如此,还是应该尽量避免编写隐藏逻辑且晦涩难懂的代码。

罗伯特·马丁 (Bob大叔)在他的书《Clean Code》中提到 “阅读代码和编写代码的时间比例远远超过10比1”。在一些保留系统中,你要花费大量的时间试图理解如何 阅读代码,而不是实际阅读代码。测试和调试这样的系统也是非常棘手的,大多数情况下,你需要用非常规的方式完全来完成。

代码也在讲故事

写代码也是写作的一种。代码不应该隐藏解决问题的逻辑或算法。相反,它应该条理清晰的列出各种函数名称,模块等,甚至代码的格式看起来像已被谨慎和专业地处理过的。

看到下面这段代码有什么感觉?

int calc(int arr[])
{
int i;
int val = 0;
for ( i = 0;i<arr.length;i=i +1 )
val = val+1;


int j = 0;
int sum = 0;
while (arr.length>j) {sum += arr[j++] ;}
int ret = sum - val;
return ret;

}

这段代码看起来像战后的战场。看上去碰过这段代码的开发者都很烦它,且尽力回避它,让它处于更糟的状态。凌乱的格式和糟糕的定义清楚地表明,不止一个开发人员掉过坑,听起来像破窗理论对吧?想明白这段代码的功能并不容易(不仅仅是因为你看代码时感觉辣眼睛),这段代码其实返回的是数组的总和减去元素的数量。让我们以更简单的方式来做到这一点:

int sumMinusCount(int arr[]) {
int sum = IntStream.of(arr).sum();
int count = arr.length;

return sum - count;
}

现在,用Java 8的Stream,让代码更加简洁并增强可读性。

干净的代码

干净的代码不是让代码看起来漂亮,而是使代码更易于维护。当代码太晦涩难懂时,大部分时间浪费在阅读上,导致开发者的生产力降低了,而且使用它的开发人员通常也会让它变得更糟,就像上面讲的那样。原因并不是因为他们无法理清代码,而是由于时间和精力的限制。碰到这样的代码时,很难估计修复bug、植入一个新模块或实现一个新功能需要多长时间,因为原框架和设计隐藏在代码中,不容易发现。因此,为了完成工作,最终会选择草草了事。干净的代码可以表明作者的意图,即便在代码中存在错误,也很容易找到并修复它,也可以长期节省维护时间。

解决(或提高)代码可维护性的方法是花费几个月(或更多)的时间来重构和清理,但是不管企业还是个人通常不太可能接受暂停开发,重构代码。所以,我们能做些什么?

童子军原则

正如Bob大叔所说,“童子军原则”背后的想法其实非常简单:让代码更干净!任何时候你碰到旧的代码,应该先妥善地清理好它。不要只是快捷方式处理,让代码更难理解,而应该认真地对待。这个原则更多地关注开发者应该具有的心态,通过把代码变得更易于维护,让他们的日子过得更舒服。

在大多数情况下处理保留系统是很不容易的,特别是当没有测试或测试套件不再被维护的时候,仍然应该努力使代码更干净。关于如何编写更具表达性的代码,这篇文章更关注一些有用的、一般性的建议。

编写前的思考

有个常见的误解:开发的人员开发软件或系统时仅仅是写代码。事实并非如此,相反,我们编写代码来解决问题。代码只是一个媒介,而不是实际的解决方案。随便敲几个键盘是写代码吗?当然不是,因为不可能被电脑编译。同样,没有先考虑如何解决的问题就开始编写代码也不是常规做法。因此,当写代码时,必须三思而后行,以便通过代码提供的解决方案清晰且不模糊。不应该为了写代码而写代码,代码应该解决问题,而不是创建新的问题。

有没有在检查代码时,意识到代码完全错误,唯一的解决办法是从头再写?许多开发人员接到新任务,就立刻开始在IDE中输入内容。他们认为,这样做看起来像在认真工作。大多数情况下,这被证明是错误的方法,因为没有思考就开始写代码会导致错误的方向。当然,不排除一些非常有经验的开发人员可以立刻开始编写,并且仍然朝着正确的方向发展,但是大多数开发者在碰键盘前还是需要仔细计划好。

考虑一下下面这个例子:

class Customer {
private List<Double> drinks;
private BillingStrategy strategy;

public Customer(BillingStrategy strategy) {
this.drinks = new ArrayList<Double>();
this.strategy = strategy;
}

public void add(final double price, final int quantity) {
drinks.add(strategy.getActPrice(price*quantity));
}

// Payment of bill
public void printBill() {
double sum = 0;
for (Double i : drinks) {
sum += i;
}
System.out.println("Total due: " + sum);
drinks.clear();
}
}

interface BillingStrategy {
double getActPrice(final double rawPrice);
}

// Normal billing strategy (unchanged price)
class NormalStrategy implements BillingStrategy {

@Override
public double getActPrice(final double rawPrice) {
return rawPrice;
}

}

上面这段代码来源于维基百科。这个例子中的代码没有什么不好,是吧?其实不然,这段代码使用了Strategy Pattern,表明它需要有一定的灵活性。但在这个例子中,与维基百科不同,只有一个策略实现,且并没有实现更多策略的计划。这里使用Strategy Pattern的意图可能会误导读者,通常实现一个Pattern需要费不少功夫,所以读者自然会想,用这个的原因是什么。YAGNI原则(You aren’t going to need it),除非你需要它,否则别创建新功能,避免创建你不要的代码。预测未来需要什么是很难的,有时候过往的经验会有帮助,但是在大多数情况下,保持简单还是比较安全的。

使用Pattern帮助我们以一种易于沟通的优雅方式来解决特定的问题。如果本身问题不存在,读者将被误导,反而认为问题确实存在。请注意,并不是反对Pattern,问题是人们总是试图在Pattern解决的问题同时创造新问题,就因为他们知道Pattern这回事。

不要以为代码可以正常编译,就完活了!其实,在代码编写完成的时候,只是完成了一半,要继续工作使代码可以向读者传达我们的意图。

我们的工具集中有很多工具,要在适当的时候使用它们。仅仅因为每个人都在用框架和库就随大流是没有任何意义的。要了解他们到底解决了什么问题,学习以一种不隐匿的方式来使用它们。如何处理框架和库,这里Bob大叔 还有一个很棒的帖子:《Make the Magic go away》。

提高表达性

如今,许多编程语言都支持Stream来帮助我们编写表达性代码,例如Java,Kotlin,JavaScript等。Stream已经用if语句取代了冗长的循环,相对于命令式编程,帮助我们用更具声明式编程的方式来考虑数据转换。为了要找到所有小于某个值的元素就反复声明是没有意义的,只需简单地将过滤器应用于Stream

  • 命令式编程:命令机器如何 去做事情(how),这样不管你想要的是什么(what),它都会按照你的命令实现。
  • 声明式编程:告诉机器你想要的是什么(what),让机器想出如何 去做(how)

几乎所有支持Stream的语言都有Map, filter 和 reduce。所以,每个人都可以理解你写的东西,就像每个人都能理解一个循环或者一个if语句一样。用这样的表达式来处理数据是非常强大的。首先你就不用测试这个功能。有没有注意到第一个例子中的差一错误 ?这也使我们走向了函数式编程

第一个例子中的基于Stream 的解决方案如下:

public String getProductNames(List<Product> products) {
return products.stream()
.map(p -> p.name)
.collect(Collectors.joining(", "));
}

简单又干净,很容易理解它做什么。现在,考虑下面的例子:

void getPositiveNumbers(List<Integer> l1, List<Integer> l2) {
for (Integer el1: l1)
if (el1 > 0)
l2.add(el1);
}

当你调用这个method时,第二个参数会改变吗?这个method是按照编写的去实现吗?Method的名称是否合适?你真的能“get”吗?

这样呢?

List<Integer> getPositiveNumbers(List<Integer> numbers) {
return numbers.stream()
.filter(num -> num > 0)
.collect(toList());
}

在这个例子中,返回值是一个新的列表,没有参数受到影响。我们只是读取参数并产生一个新的结果,但要理解这个method在做什么以及如何使用它,第二段显然更容易。这种method也可以很容易地与其他method组合,一般而言,组合是Stream和函数式编程的最重要的好处之一。组合能使我们从更高层次的数据转换、过滤等方面进行思考,并编写出更具声明式编程和表达性的代码,让代码表达我们想要实现的(what)而不是如何完成的(how),这大大改善了代码的可读性。

把一个问题分解成多个子问题,解决每一个子问题,然后组合这些解决方案,为最初的问题提供解决方案,就容易多了。但另一方面,当主要目标是性能时,命令式编程可能又是必不可少的。

请注意,Jave 8中的toList()收集器返回一个可变列表,而在函数式编程中,我们通常使用不可变数据结构。不过,生成新数据并将参数视为只读可以提高method可读性和表现。虽然有些method可能有副作用,但对于一种method来说,要么有副作用(表现为command),要么有返回值(表现为query),不会同时存在。

写更具表达性的代码不是一件容易的事情。爱因斯坦说过:“If you can’t explain it simply, you don’t understand it well enough”。所以,当看到抽象层次代码混合时,例如与数据访问对象(DAO)交互的UI类,或者直接与数据库talk,或者低层次的细节在不应该暴露的时候暴露,可以说不仅违反了单一职责原则,而且还有些混乱。通过使用注释来解决并非最佳解决方案。有人写的越简单、越具表达性,说明他对这个问题的理解就越好。

拥抱不可变对象

当对象的状态发生变化,而又没有注意到它的时候,是非常让人困惑的。对构造一半的对象使用return时也是很危险的,特别是处理多线程的程序时。而不可变对象对多线程是安全的,也是完美的缓存对象,因为它们的状态不会改变。

但是为什么选择可变对象呢?最有可能的原因是为了性能表现,因为占用的内存会少一些,因为改变是在原地(in-place)进行的。而且,让一个对象的状态在其整个生命周期中发生变化是很自然的,这是我们在面向对象编程(OOP)中学到的。这些年来,我们一直在写程序,其中大部分的对象都是可变的。

今时不同往日,机器内存的数量、性能比之前翻了N个数量级,真正面临的问题是可扩展性。处理器的速度虽然不再像过去十几年那样猛增了,但现在有了N多核的CPU。所以,需要利用好现在的情况来规划好程序。由于程序需要能够在多个内核上运行,所以要以一种安全的方式来编写。使用可变对象,必须处理好locking以确保其状态的一致性。并发(详解)不是很好处理的。而不可变对象(状态在对象被创建之后就不再变化)在多线程和处理器之间共享是固有安全的,且不需要同步的特性为创建具有低延迟和高吞吐量的系统提供了机会。因此,不可变对象是实现可扩展,更安全的选择。

除了可扩展的好处,不可变对象使我们的代码更简洁、干净了。在上一节的示例中,作为参数传递的集合在调用method后发生了改变。如果是不可变集合,则是不允许的。因此,不可变对象可以促进更好的解决方案。另外,由于状态不变,读者也不需要费心思记,只需要将一个对象名称与一个值关联起来。

程序必须是为人们阅读而写的,只是偶尔地让机器执行。

—— Harold Abelson《计算机程序的构造和解释》

坚持原创分享,您的支持将鼓励我继续创作!