为什么说Kotlin的可读性比Java好?
不久之前,我看了一篇文章,大意是 Kotlin 与 Java 之间的对比,像这种文章,我一般是直接忽略的,但是那天我还是打开了,然后就看到一个非常吃惊的结果。里面有一段是关于 Kotlin 与 Java 之间可读性的对比的文章,作者的结论是:Kotlin 并不比 Java 更具有可读性,所有认为 Kotlin 比 Java 更具有可读性的结论都是“主观性”的。
并且作者举了一个在我看来,不知道该怎么来描述的例子:
这个作者的大意是,上面这段文章,你多读个两三遍,你也会很快的理解它的意思,所以“对于熟练的读者而言,外观很少会成为可读性的障碍。”
我不知道,如果某一天,这个作者突发奇想,决定全部使用大写字母来写代码——所有的类名、方法名、局部变量成员变量名等等全部使用大写,我不知道跟作者合作的同事是不是会欣然的耐心的把作者所有的代码先读它个两三遍,然后再来慢慢的理解它的意思。如果是我,我不会。如果在小红书有个同事非要执意这样写代码,理由是“你多读个两三遍不就好了嘛?”我想我只能把他开除了。
其实,如果一段代码需要你多读个两三遍才能很好的理解,这本身不就说明,这段代码的可读性不高吗?这里的重点是,这里的这一段大写的文字你看个三遍,再看的话,是熟悉了,但是再看别的用大写写的文字片段,你依然要很费劲。所以,这个例子是不能代表大写这种风格的可读性的。在比较两种不同的风格的可读性的时候,你不能用具体的某一个一次性的片段来说明。
另外,这篇文章还暗含了这样一个观点,那就是,代码的可读性,仅仅是指,看到一段代码,能不能理解这段代码的含义。这是一个很多人都会错误的观点。
但是,在真正工作中,代码的可读性,恐怕不至这一个方面。为了考察所谓代码的可读性涉及到哪些方面,我们来假设两个 case:
你去到一家新公司,接手一个新项目。这个时候,你的需求是,快速了解某个类、某个模块、某个方法做的是什么事情。在这个基础上,整个 app、模块的结构是怎么样的。
你老板叫你 fix 一个 bug,这个 bug 是另外一个同事写的,今天这个同事请假了不在。在这个 case 里面,你需要的是,快速的定位到出问题的代码在什么地方,然后再尽快的了解这个地方的代码做了什么事情,并且保证你的理解是对的。
所以,总结一下,代码的可读性,可以归纳成三点:
- 理解一段代码的速度
- 找到你关心的代码的速度
- 确保正确理解一段代码的难易程序。这跟第一点看似一样,其实还真不一样,下面你会看到。
下面,依次解释一下这三点,以及为什么说,Kotlin 的可读性会对 Java 高。
1. 理解一段代码的速度
如果大家仔细的思考下,你会发现,我们在理解一段代码的时候,大多数情况下,我们是想要了解这段代码做了什么事情,是这段代码的意图(Intention),而不是具体这个事情是怎么做的。比如一个 Button 被点击了,我们的 App 做了什么,是做了什么运算,发了网络请求,还是保证了一些数据到数据库。也就是说,大多数情况下,我们关心的是 What,而不是 How。只有少数情况下,我们会关心“How”,一是出于学习的目的,我们想要了解一个算法是怎么实现的,一个效果是怎么实现的,这个时候,我们会关心“How”。二是当这个“How”出了问题的时候,就是有了 Bug,我们要去了解这个 “How”,然后再 fix 过来。而且,即使是在这些少数情况下,了解“How”的过程,也只不过是了解一个个子“What”的过程。
敏捷开发和 TDD 先驱、JUnit 开发作者和一系列经典编程书籍作者 Kent Beck 提出了一个著名的“four rules of simple design”,是以下 4 条:
- Passes the tests
- Reveals intention
- No duplication
- Fewest elements
第一条 Passes the test 说的是程序的正确性。第二条 Reveals Intention,说的就是我们这里讨论的“What”。
那么,Kotlin 相对于 Java,在帮助我们了解“What”,在帮助 Reveals Intention 这方面,有什么样的优势呢?我们看一个简单的例子:
在这段 Java 代码例子中,这 7 行代码做的事情很简单,就是从 personList 中找出 id 值等于 somePersonId 这个变量的值的那个 Person,然后赋值给 person 这个变量。要理解这段代码并不难(其实后面你会看到,要确保正确理解这么代码也没那么简单),但是速度并不快,你必须从头到尾看完这 8 行代码,就算你说最后两行可以一扫而过,那也必须看完前面 6 行,你才能知道“哦,原来这段代码做的事情是,从 personList 中找出 id 值等于。。。”
下面,我们来看对应的 Kotlin 代码是怎么样的:
val person = personList.find { it.id == somePersonId }
是的,就一行代码。看完这行代码,你就知道了它做的是什么事情。因为在这行代码中,find 这个单词就已经表达出了这里做的事情是“找出一些东西”,而大括号里面,就是找出它的条件。也就是说,Kotlin 的写法直接就帮我们表达出了“What”。如果平均来说,一个人理解一行 Java 代码的速度跟理解一行 Kotlin 代码的速度是一样的(虽然在我看来,理解一行 Kotlin 代码会更容易,因为 Kotlin 里面有很多帮助开发者减轻负担的语法,接下来会提到这一点),那么在这个例子中,一个人理解 Kotlin 代码的速度是理解对应的 Java 代码的 5~6 倍。之所以说 5~6,是因为在 Java 里面,你还可以写成 foreach 语法,如果写成 foreach 语法的话,那么 Java 代码是 5 行。但是以我的经历,多数情况下大家还是会习惯性的写成 fori,因为这两者差别并没那么大,优势也不是那么明显。
在 Kotlin 里面,Collection 类有一整套像 find 这样,直接可以 reveal intention 的方法,简单点的有 filter、count、reduce,map、any、all 等等,高级点的有 mapTo、zip、associate、flatMap、groupBy 等等。绝大多数情况下,所有需要手动 for 循环的地方,都有对应的更加能“reveal intention”的方法。
当然,如果只有一个 collection,就说 Kotlin 在 Reveal Intention 这点上比 Java 更有优势,那是不够的。Kotlin 有一系列的机制和便利,能帮助开发者更好的达到“Reveal intension”的目。比如 null 的处理,if、when 表达式(不是语句),比如循环的处理,比如所有对象都有的 let, apply, run 等方法,比如 data class 以及它的 copy 方法等等等等。此外,通过 Extension Function 这个机制,Kotlin 对 Java 中绝大多数的常用类都作了扩展。前面提到的各种 Collection 方法,也是使用这种方式来进行扩展的。此外,就算有一些类没有你想要的扩展,你也可以非常轻松容易的自己写一个扩展方法,来让你的代码更加“Reveal Intension”。
相比之下,跟 Kotlin 相比,Java 代码更像逼我们去通过了解“how”之后,来总结归纳出它的“what”。在描述一门语言的时候,有一个术语叫做抽象程度,也就是一门言语表达“What”、屏蔽“How”的能力。从这点来说,无疑 Kotlin 的抽象程度是比 Java 要高的,就像是 C 语言的抽象程度比汇编要高一样。实际上,我还还真有个朋友拿 Java 比作汇编。他是写 Scala 的,有一天他这么跟我说:
我之前一年多时间都是写 Scala 的,现在我看到 Java 代码,就像在看汇编一样。
基于 Kotlin 的抽象程度更接近 Scala,我想写一年多 Kotlin 之后,你也会有类似的感觉。OK,第一点讲到这里。接下来我们来看第二点。
找到你关心的代码的速度
当谈到 Kotlin 的优势时,有一点我相信是公认的,那就是 Kotlin 比 Java 更简洁。而简洁带来的好处之一,就是能够让人更快的找到他关心的代码,而不用在一堆杂七杂八的没用的代码里面去翻找自己在乎的代码。我们还是以一个例子来说明吧,以下两段代码。
如果说,要你找出点击 loginButton 以后,代码做了什么事情,那以上两段代码中,无疑第二段代码能让你更快的找到。
上面这个例子还大大地简化了很多东西,实际开发过程中,代码更加复杂,Kotlin 的优势也更明显。
确保正确理解一段代码的难易程序
这是很多人会忽略的事情。能否理解一段代码,跟确保正确的理解这段代码,其实中间还是有一些差别的。很多代码看起来很简单,但是要确保自己正确的理解,其实还是非常费劲的。还是看文章开头这个例子:
这一段代码要确保正确的理解,容易吗?其实没那么容易,如果你工作年限多一点,你一定碰到过这样的代码,在 for 循环里面,i 是从 1 开始的,而不是从 0 开始的,或者是中间的终止条件是 i < personList.size() - 1,而不是 i < personList.size(),或者最后部分不是 i++,而是 i = i + 2,或者 i--。很多人更是碰到过很多 bug,就是因为没有看清楚这里面 i 的起始值、终止条件,或者是步长导致的。我就曾经碰到过很多这样的 bug,也曾经因为没有注意这些地方,而导致过很多 bug,最后调了半天,发现原来是因为 for 里面是 i=1,而不是 i=0。那时候,就只能在心里默默的大叫一声:FUCK!
因为有这些非典型代码的存在,所以现在,每次看到这样写的 for 循环,我心里都会觉得如履薄冰,会特别小心翼翼的看得很仔细,确保 i 的初始值是什么,终止条件是什么,步长是什么。这在无形之中会给人增加特别大的心理负担。然而因为是无形之中的,是潜意识里面的,所以一般人不会注意到。毕竟,大家都这么写,而且写了几十年了,能有什么问题呢?其实,是有的。这也是为什么 Java5 增加了 Foreach 语法的原因,然而可惜的是,大部分人并不清楚具体为什么要使用 foreach,而且还声称 fori 比 foreach 性能更高,这真是令人遗憾。
说回 Kotlin,那为什么说 Kotlin 代码能让人更容易正确的理解呢?
让我们再看一下上面的例子对应的 Kotlin 代码:
val person = personList.find { it.id == somePersonId }
在这一行代码中,你根本无需担心 i 的初始值、终止条件、步长,因为这里面根本没有这些东西。所以,一个很大的心理担负消失了。你也不用担心这里面有没有 break,或者你是否忘了写 break。
这就是 Kotlin 让代码更容易理解的地方。同样的,像这种减轻看代码的人心理负担的机制 Kotlin 里面有很多,这里再介绍一个很重要的“小”特性:使用 val 把一个变量定义成不可变的“变量”。我之前一篇文章说过,Kotlin 的 nullsafety 是我最喜欢的特性,如果说第二喜欢的特性是什么,那就是 val 关键字。在团队里面,我也一遍一遍的跟同事强调,能用 val 的地方就不要用 var。因为它带来的心理上的 relief,是巨大的。我们看以下 LinearLayout 里面的代码。
如果你写了个自定义 Layout 继承自 LinearLayout,结果它表现出来的样子不符合你的预期,你可能会去看源码。看到上面这段,最后你发现,原来是 mBaselineAlignedChildIndex 这个成员变量的值不对。那么,你怎么知道是哪里改变了这个变量的值,导致它被赋给了一个错误的值呢?你可能要在这个类里面找出所有会改变这个变量的地方,然后一个一个去 check,哪里会走到,哪里不会走到。更糟糕的是,也许这个值在某个 public 方法里面被改变了,这样的话,你还要去找出所有调用这个 public 方法的地方,然后去确定到底是谁,在哪里调用了这个方法,而这些调用的地方又是谁调用的,导致出错了。这想想就是一件非常头疼的事情。
但是,如果这个值是 final 的话,这些麻烦就都不存在了。它的值要么是在它定义的地方就确定了,要么是在构造方法里面确定的,你只需要检查两个地方就可以了,这对于代码理解,是一件极大的减少工作量的事情。这,就是为什么 Effective Java 里面,建议把所有能用 final 修饰的地方都用 final 修饰的原因。很多人认为,使用 final 是为了避免多线程同步的问题。但是,诚实的说,算了吧,作为安卓开发的你,上一次碰到多线程同步的原因导致一个变量的值出错,是什么时候的事了呢?final 的真正优点,在于让人在看代码的时候,不用到处去找可能会改变这个值的地方,也消去“这个值会不会在哪里被改变”的大心理负担。
思考深入的读者可能会发现,其实上面的这个例子有点矛盾。因为我说的是使用 final 来定义变量,但是像上面的 mBaselineAlignedChildIndex 这个成员变量,是不能加 final 的,因为它就是要可变的啊,它就是需要在某些条件下被重新赋值的啊,这不是矛盾了吗?
是的,很多时候,我们不能使用 final 来定义一个成员变量。但是,如果你试着给那些可以加上 final 的成员变量加上 final,你会发现,其实大部分成员变量和几乎所有局部变量都是可以加上 final 的,但是现实情况是什么呢?是几乎所有的成员变量和局部变量,我们都没有使用 final 来定义。我们写代码的默认设置是,先不加 final,如果在哪个地方编译出错了——比如写一个匿名内部类,引用了前面的局部变量——迫使我们使用 final 来修饰一个变量的时候,我们才加上。为什么会出现这种情况呢?有两点原因:
final 的好处并不为大家所知,也不是一眼能看出来的。
使用 final 要写多写一个单词。
当一个东西的优势不是很容易被识别(注意,不容易被识别,不代表这个优势不大,或者不重要,这是两回事),同时又要多付出一些努力的时候,我们写代码的默认设置是不加 final,这就非常合情合理了。
那 Kotlin 在这点上,又有什么优势呢?Kotlin 的优势有几个,先讲一个不起眼的优势:使用 val 来定义“变量”。这里之所以给“变量”加上双引号,是因为使用 val 来定义的“变量”一旦被赋值,是不能被改变的,所以好像称他们为“变”量不大合适。但我又找不到合适的词来叫这个东西,所以暂且还是称作“变量”吧。
不要小看了这个优势。当你可以使用 var 或 val 这两个看起来写起来都差别不大的方式来定义一个东西的时候,人们自然会想要去了解,这两者到底有什么区别?我应该使用哪个?这时候,就会有一些讨论,有一些标准出来,人们就会认识到,不可变性(Immutability)原来有这么大的价值,原来是这么好的一个东西。同时,因为 val 和 var 写起来没有差别,所以人们也会更愿意使用 val 来定义“变量”。
当然,要我说,kotlin 这一点做得还不够。应该像 Rust 一样,让可变的变量定义起来比不可变的变量定义起来更费劲,这才能更加促进不可变量这种好的 practice 的发扬光大。
在 StackOverflow 的调查中(2017,2016),Rust 连续几年被评为“程序员最喜爱的语言(Most Loved)”,这不是没有原因的,它的设定也不是没有原因的。除此之外,Kotlin 还使用了一些方式,来让原本不能定义为 val 的变量,也可以使用 val 来定义,比如 by lazy 和 lateinit,这些机制综合起来,即让 val 写起来很容易,也扩大了 val 的适合范围。
上面花了很多篇幅来解释,Kotlin 中 val 的价值。跟 Collection 中的众多扩展方法一样,这些都是 Kotlin 中,一些让代码更容易理解的机制。像这样的机制还有很多,比如说在 Kotlin 中,if、when(Kotlin 中的 switch)都是表达式(Expression,会返回一些值),而不像在 Java 中,只是语句(Statement,不会有返回值),比如说对 null 的处理,如果你看过多层嵌套的 null 判断,你就知道那种代码看起来有多费劲了。而使用 Kotlin,结合 val,在定义的时候把它定义成非 null,你可以明显的告诉代码的读者,也告诉你自己,这个地方是不需要进行 null 判断的。这就大大的减少了 null 判断的数量。
由于篇幅的关系,这些还有剩下的一些机制,这里就不展开讲了。当你写 kotlin 代码的时候,多思考一下,Kotlin 为什么要这样设定,你就会明白,都是有原因的,多数情况下,都是有优势的。
为什么代码的可读性这么重要?
以上从三个方面解释了什么叫代码的可读性,可以看到,无论在哪个方面,Kotlin 都有比 Java 更大的优势。那接下来的一个问题就是,So what?可读性有这么重要吗?能吃吗?值多少钱?
别说,可读性还真可以吃,而且很值钱!关于可读性的重要性,其实上面分析什么叫可读性的时候,已经提到了,这里归纳一下,只说两点:
- 更快的找到你关心的代码,更快的理解代码。要知道,我们现实开发过程中,大部分时间是在看代码,而不是在写代码。更快的理解代码,意味着更高的工作效率,节省更多的时间,时间就是金钱,所以更高的可读性,意味着省钱。或者用省下来的时间去赚更多的钱。
- 更容易正确的理解代码,从而不会因为对老代码的理解不到位而改错,造成新的 bug。大家可以回想一下,过去有多少 bug 的发生,是因为对遗留代码的理解不到位,不全面导致的呢?在小红书,这个比例不少,也造成过不小的问题。痛定思痛,我们现在能做的,就是引以为戒。写代码的时候,重视可读性,让后来的人,让后来的自己,不要再吃这样的亏,不要再背这样的锅。