让代码说话!
原文
Let the code speak!
你见过这样的代码么:
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();
}
我赌你见过(或者以后会见到)。类似的代码存在于一些遗留系统中,并且通常是老系统。见过这样的代码之后,你肯定会很难受。
这段代码的问题不仅过于冗长,更大的问题是,他 隐藏了业务逻辑(这段代码里还有很多其他的问题,我们之后会慢慢讨论)。在企业应用中,我们写代码来解决问题。因此,我们不应该用代码引入新的问题。有时为了更好的性能或者有些解决成本很高的问题,我们写的一些“系统代码”和“组件”,是可以适当牺牲可读性的。但即便如此,我们也应该小心避免晦涩的代码而隐藏业务逻辑。
Robert C. Martin (Uncle Bob) 写的书:《Clean Code: A Handbook of Agile Software Craftsmanship》(中文译作:《代码整洁之道》)中说到:“阅读代码与编写代码的时间比远远超过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;
}
现在,我们可以用Java8 的stream来编写更简洁、高可读性的代码
整洁的代码!
整洁的代码不是 让代码看起来更漂亮, 而是 提高代码的可维护性 。当代码晦涩时,大部分的时间都会花在阅读上,从而导致开发者的生产力下降。维护晦涩代码的开发者往往会把代码改的比之前更糟,不是开发者的编码能力不足,而是他们没时间。当我们在维护晦涩代码时,我们很难准确评估修复一个bug要花多少时间,在这基础上开发新功能要花多少时间。因为系统的架构和设计,都被隐藏在这堆晦涩的代码里。因此我们维护这样的代码只能做一些hack工作,继续累积技术债务。而整洁的代码则不同,他能清晰表述其作者的意图,所以即使代码里有BUG,我们也能方便的找到并且修复他。整洁的代码能长期提升开发速度。推荐两本书,分别是Robert C. Martin的《代码整洁之道》 和 Martin Fowler and Kent Beck 的 《重构:改善既有代码的设计》
我们可能需要花几个月甚至更多的时间来进行重构,才能让晦涩的代码变得更整洁。但是业务不可能停下来让开发者重构。所以,有什么办法能推进代码变得整洁呢?
童子军军规
Uncle Bob提出的一条“童子军军规”:“让营地比你来时更干净”
背后想法是:让代码比你发现时更整洁
。只要你发现了一段烂代码,你都可以适当去做一些改进。不要走捷径导致代码更难烂,而是要认真的对待这些代码。这条规则侧重于开发者应具备的素质,这样系统会更好维护,开发者的生活才能更轻松。
我真心承认处理遗留系统非常困难——尤其是在没有测试用例、或者测试工具不再维护的情况下——但我们仍然应该找机会让代码变得更加整洁。有一些技术值得参考(有一本很好的书是Michael Feathers的《修改代码的艺术》)。但这篇文章里,我想集中讨论一些我认为有用的通用的建议,帮助我们写更富有表现力的代码
思考先于编码
外界对于开发者有一些误解:认为程序员(就)是写代码的。实际上,程序员用代码解决问题,代码只是解决问题的工具。乱敲键盘会是写代码么?当然不是,因为乱敲键盘写出来的东西计算机理解不了。同样对于编码来说,如果不先思考我们要解决的问题就开始编码,也和乱按键盘是一样的。因此在开始编码之前,我们需要认真的思考,思考我们的方案是否是清晰且无歧义的。不能为了写代码而写代码,代码是用来解决问题的,而不是用来引入新的问题的。
你有没有这样的经历:在做Code Review时发现代码全写错了,唯一的方法是从头写一遍?我见过许多程序员接到一个任务后,马上就打开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;
}
}
稍微修改了 https://en.wikipedia.org/wiki/Strategy_pattern#Java 上面的示例
这个例子里好像没有什么不好的东西对吧?其实有问题!这里的 策略模式 会体现了这个系统中的价格是灵活的。但这个例子和Wikipedia上的例子不太一样:这里只有一种策略,而且短期内也不会有其他策略出现。此时例子里的 策略模式 就会让阅读者造成误解:要实现设计模式是有成本的,这里用到了是否有深层的用意?Y.A.G.N.I. ——“You aren't gonna need it(你不需要它)” ,意思就是“不做不必要的事”。预测之后需求的可能性很难,有时候经验会有所帮助,但大部分时候,保持简单是更安全的。
设计模式可以帮助我们优雅地解决特定的问题,同时也很容易相互沟通。但如果特定的问题并不存在(比如上面的例子中价格并不需要拓展),过度使用设计模式会把读者带上歪路,以为真的存在问题。我对设计模式没有任何意见,我爱设计模式!但有些人过度使用设计模式来解决问题——仅仅是因为他们知道那些“设计模式”。
如果把业务需求和设计模式混用在一起,也经常会出现类似的问题。我觉得遇到问题时可以先用“肮脏”办法先解决。解决之后,再看看设计模式或是抽象能否帮助代码提升可读性和灵活度。这个规则无论我是否进行TDD都适用,先让代码成功运行,然后再优化它(当然如果在用TDD,TDD 的 3定律也会推动这个规则)
记住!只是代码跑起来不等于工作完成了,代码跑起来后,我们的工作才完成了一半。接下来还要思考如何用代码把我们的意图传达给代码阅读者。
我们有很多工具可以使用,我们有责任让他们在适当的场景下解决特定的问题。对于一些框架、组件:“因为大家都在用,所以我也要用” 是没有意义的。我们必须学习这些工具是用来解决什么问题的,同时怎么使用这些工具来保证业务逻辑不会被隐藏。Uncle Bob 有一篇关于“如何对待框架和组件”的好文章:Make the Magic go away.
努力提升表现力
现在很多编程语言都有流式支持,比如Java, Kotlin, JavaScript ... 它能帮助我们编写更有表现力的代码;它能帮助我们去掉冗余的循环和if判断;它能帮助我们聚焦用声明的方式做数据转换,而不是用命令的方式。循环整个集合然后找出比某个数小的值这种事没有意义,用filter
方法方便直接。
map
filter
reduce
几乎被添加到了所有开发语言中,所以大家都能理解你写的代码,就好像大家看到for循环或者if判断时一样。关于这个主题 Martin Fowler 有一篇很棒的文章:Collection Pipeline
(中文译:技术专栏集合管道模式(上)(下))
用这种表达方式处理数据真的很给力,因为首先你就不需要测试这个功能。你注意到第一个例子中的单字节溢出了么😃?同时他让我们的程序向着函数式方向演进,函数式开发的各种好处都能对应到这篇博客中(如果你想进一步学习函数式编程,我推荐这篇文章:Practical Functional Programming,当然还有 Harold Abelson, Gerald Jay Sussman and Julie Sussman 大名鼎鼎的书籍:《Structure and Interpretation of Computer Programs》(中文译:《计算机程序的构造和解释》))。但我想重点介绍它是如何提高代码的可读性的。
下面是用流式API,实现文章中第一个例子的代码:
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);
}
你希望第二个参数在调用完这个方法后改变了么?它是在表达什么意思?它的命名合理么?调用它你真的"get"到东西了么?
如果这么写呢?
List<Integer> getPositiveNumbers(List<Integer> numbers) {
return numbers.stream()
.filter(num -> num > 0)
.collect(toList());
}
上面这种写法,返回了一个新的List。没有参数会被改掉,只是读取了参数返回了新结果。这让人更容易理解这个方法的作用和如何使用它。这种方法可以很容易地用其他方法组成,这是流和函数编程的一个最重要的好处。能让我们在更高的层面思考数据的转换过程。能更清楚的表达我们要 做什么 和 怎么做 。从而大大的提高了代码的可读性。
把大的问题分解成小的问题,分别解决小的问题,最后把他们组合起来,解决一开始的问题是个很好的办法。另一方面这种编程方式能提高整体性能,这里有一个很有趣的故事:McIlroy vs Knuth story
※ 译注:水平有限, "On the other hand, the imperative style might be essential when the main goal is performance." 翻译成了 “另一方面这种编程方式能提高整体性能”
注意的是Java8中toList()
方法返回的是一个可变集合(mutableList
),而在函数式编程里,我们经常使用的是不可变数据类型。当然为了提高可读性,实际上方法中我们会有效利用返回值,同时把入参数据作为只读来对待。方法有一个很重要的属性:或是有副作用(像一个命令)或是有返回值(像一个查询),当然大部分情况下他们不会同时出现在一个方法里。有关这部分的讨论可以阅读这篇文章。
编写表现力强的代码不是一件容易的事。爱因斯坦曾经说过:“如果你没办法简单说明,代表你了解得不够透彻”。所以当我看到代码在不同的抽象层里混用,比如在界面层直接调用DAO或者直接进行数据库操作,或者一些代码对外暴露了太多不应该暴露的细节。我都会指出这不仅仅违反了S.O.L.I.D 中的单一职责,而且会导致对问题思考的混乱。用注释在代码里说明这些并不是解决问题的办法,就像接下来这篇文章会说的,我相信一个人的代码写得越简洁,越有表现力,那么他对整个问题的思考应该也更深入到位
拥抱“不变性”
如果一个对象的状态改变了而我们没注意到,是很可怕的。类似的危险还有半构造,尤其是在多线程环境里,传递这样的数据很难保证系统的健壮。相对的,不可变对象即是线程安全,而且能被很好的缓存,因为他们的状态不会随意被改变。
那么为什么人们还会使用可变类型呢?我想是因为他们觉得这样能提高系统的性能,因为修改数据用的内测更少。此外在对象的生命周期里改变对象的状态会也很自然,OOP里学到的就是这样,而且这么多年了我们一直也在用可变对象来进行代码开发。
但是比起十年前,现在系统的内存已经高出了几个数量级,我们更多考虑的是系统的可扩展性。计算速度虽然这几年没有提升,但是我们有更多的CPU核心。所以为了拓展项目的规模,我们要更好的适应、利用目前的现状。我们的程序会运行在多核心上,因此我们要提升在多核方面的安全性。如果使用可变对象,我们需要用各种锁来保证其状态的一致。并发不是一件小事,如果你对并发有兴趣,建议阅读 Brian Goetz 的 《Java Concurrency in Practice》 (中文译:《JAVA并发编程实践》)。但不可变对象的本质决定了他在多线程、多核心系统中的安全。事实上减少使用synchronization
也给了我们有更多的机会构建高吞吐低延迟的系统。也就是说“不可变”是一种更安全的选择。
另外在可扩展方面,“不可变”能让我们的代码更整洁。在上一小节的例子里,其中一个参数在传入方法后,值就被改变了。但如果我们使用的是不可变集合,那这种行为就会被自动禁止。因此“不可变”能推动我们找到更好的解决方案。同时对于阅读者来说,不用时刻在脑海中担忧数据是否在什么时间被改变了。他们只要记得变量是什么值,而不用管这个值上一次被改成了什么样。
更多关于“不可变”和程序设计的建议可以阅读 Joshua Bloch 的《Effective Java (2nd Edition)》(中文译:《高效JAVA编程》)还有一个很好的视频:The Value of Values with Rich Hickey
Programs must be written for people to read, and only incidentally for machines to execute.
― Harold Abelson, Structure and Interpretation of Computer Programs
程序首先必须能让人读懂,计算机执行只是附带
— Harold Abelson,《计算机程序的构造和解释》
这篇文章致力于给出一些编码方面的建议,用于如何写出可读性更高,更有表现力的代码。 In future posts, we will discuss smells in production code as well as in test code. We will also see how we can find possible design problems in our production code only by looking at our tests. Stay tuned!
相关阅读
- Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin
- Refactoring: Improving the Design of Existing Code by Martin Fowler and Kent Beck
- Working Effectively with Legacy Code by Michael Feathers
- Structure and Interpretation of Computer Programs by Harold Abelson, Gerald Jay Sussman and Julie Sussman
- Java Concurrency in Practice by Brian Goetz
- Effective Java (2nd Edition) by Joshua Bloch
- Make the Magic go away
- Collection Pipeline
- Practical Functional Programming
- More shell, less eggs (McIlroy vs Knuth story)
- CommandQuerySeparation
- The Value of Values with Rich Hickey