最近 Kotlin 特别流行,并且我也赞同 Kotlin 是一个经过深思熟虑后被设计出的语言,除了下面提到的缺点之外。
我会在本文向你分析一些我在开发过程中遇到的陷阱,并且教你如何避免他们。
谜一样的 null
当你使用 Kotlin 编写程序的时候,无需考虑 null
的处理。这会让你忘记 null
其实是无处不在的,只不过被隐藏了起来。看看下面这个表面看起来没有问题的类:
class Foo { private val c: String init { bar() c = "" } private fun bar() { println(c.length) } }
如果你尝试初始化这个类,那么代码就会抛出一个 NullPointerException
。因为 bar
方法尝试在 c
变量初始化之前就访问它。
尽管这个代码本身就是有问题的,才导致异常抛出。但是更糟糕的是你的编译器不会发现这一点。
Kotlin 可以帮你在绝大部分情况下避免 null
,但是你不能因此而忘记 null
的存在。否则迟早有一天你会碰上类似的问题。
来自 JDK 的 null
Kotlin 的标准库能够很好地处理 null
。但是如果你使用了 JDK 中的类,你需要自己处理关于 JDK 方法调用可能产生的空指针。
大部分情况下 Kotlin 的标准库就足够了,但是有时你需要使用到 ConcurrentHashMap:
val map = ConcurrentHashMap<string string="">() map["foo"] = "bar" val bar: String = map["foo"]!! </string>,>
这时,你需要使用 !!
操作符。但某些情况下你还可以使用像 (?
) 这样的对 null
安全的操作符来替换它。尽管如此,当你使用 !!
或者 ?
,或者编写了一个适配器来使用 Java 类库的时候,你会发现代码因为这些修改而变的混乱。这是你无法避免的问题。
你还可能会碰上更多更可怕的问题。当你使用 JDK 类中的方法的时候,返回值可能是 null
,而且没有什么像 Map
访问一样的语法糖。
考虑如下例子:
val queue: Queue<string> = LinkedList() queue.peek().toInt() </string>
这种情况下,你使用了可能返回 null
值的 peek
方法。但是 Kotlin 编译器不会提示你这个问题,所以当你的 Queue
是空队列的的时候,可能会触发 NullPointerException
异常。
问题在于我们使用的 Queue
是 JDK 的一个接口,并且当你查看 peek
方法的文档时:
/** * Retrieves, but does not remove, the head of this queue, * or returns {@code null} if this queue is empty. * * @return the head of this queue, or {@code null} if this queue is empty */ E peek();
文档中说 peek 方法会返回一个 E 类型的对象,但是 Kotlin 认为 E 是不可空的。在接下来的 Kotlin 版本中可能会解决这个问题,但是现在当你在你的工程中使用类似接口的时候,一定要注意:
val queue: Queue<string> = LinkedList() queue.peek()?.toInt() </string>?>
内部 it
当一个 lambda 表达式只有一个参数的时候,你可以在你的代码中将其省略,并用 it
代替。
it:单参数的内部名称。当你表达式只有一个参数的时候,这是一个很有用的特性,声明的过程可以省略(就像 ->),并且参数名称为 it。
问题是,当你的代码中存在向下面例子一样的嵌套函数的时候:
val list = listOf("foo.bar", "baz.qux") list.forEach { it.split(".").forEach { println(it) } }
it
参数会混淆。解决方法就是像下面这样显示的声明:
list.forEach { item -> item.split(".").forEach { part -> println(part) } }
看起来是不是好多了!
隐藏的复制
注意观察下面的类:
data class Foo(val bars: MutableList<string>) </string>
data
类提供了一系列的方法,并且你可以通过拷贝得到其镜像。猜猜下面的代码会输出什么?
val bars = mutableListOf("foobar", "wombar") val foo0 = Foo(bars) val foo1 = foo0.copy() bars.add("oops") println(foo1.bars.joinToString())
控制台会输出 foobar, wombar, oops
。问题出在 copy
方法并没有真正的复制一个完整的对象, 而是复制了对象的引用。当你忘记编写单元测试类,并且将你的 data
类按照不可变类来传递的时候,就可能出现这种问题。
解决方法就是当你使用 data
类的时候一定要多加小心,并且当你必须将其作为值对象的时候,像下面这样:
data class Foo(val bars: List<string>)</string>
data
类还有一个问题:其 equals / hashCode 方法所用到的属性不可变。你只能通过手工重写这些方法的方式来修改返回值。谨记上面这一点。
内部方法暴露
仔细思考下面的例子:
class MyApi { fun operation0() { } internal fun hiddenOperation() { } }
当你在 Kotlin 的项目中引用这个类的时候,internal
关键字是生效的。但是当你从一个 Java 项目中使用的时候,hiddenOperation
就变成了一个公共方法!为了避免这种情况,我建议使用接口的方式来隐藏实现的细节:
interface MyApi { fun operation0() } class MyApiImpl: MyApi { override fun operation0() { } internal fun hiddenOperation() { } }
特殊的全局扩展
毫无疑问,扩展函数的功能非常重要。但通常,能力越大责任越大。例如,你可以编写全局的 JDK 类扩展函数。但是当这个函数只在本地上下文中有意义,却是全局可见的时候,就会带来很多麻烦。
fun String.extractCustomerName() : String { // ... }
每个跳转到你的方法的人都会不知所措。所以我认为在你编写这样的方法之前务必三思。下面就是一个建议:
/** * Returns an element of this [List] wrapped in an Optional * which is empty if `idx` is out of bounds. */ fun <t> List<t>.getIfPresent(idx: Int) = if (idx >= size) { Optional.empty() } else { Optional.of(get(idx)) } /** * Negates `isPresent`. */ fun <t> Optional<t>.isNotPresent() = isPresent.not() </t></t></t></t>
lambdas Unit 返回值 vs Java SAM 转换
如果你的函数参数是 lambdas
表达式,并且返回值类型是 Unit
的时候,你可以省略 return
关键字:
fun consumeText(text: String, fn: (String) -> Unit) { } // usage consumeText("foo") { println(it) }
这是一个很有趣的特性,但是当你在 Java 代码中调用该方法的时候会比较尴尬:
consumeText("foo", (text) -> { System.out.println(text); return Unit.INSTANCE; });
这对于 Java 端来说是不友好的,如果你想在 Java 中成功调用该方法,你需要定义如下接口:
nterface StringConsumer { fun consume(text: String) } fun consumeText(text: String, fn: StringConsumer) { }
然后你就能使用 Java 的 SAM
转换。
consumeText("foo", System.out::println);
但是在 Kotlin 这边看起来就很糟糕了:
consumeText("foo", object: StringConsumer { override fun consume(text: String) { println(text) } })
问题关键点在于只有 Java 支持 SAM 转换,Kotlin 并不支持。我的建议是简单的场景中,只是用 Java 的 SAM 接口作为一个消费者:
fun consumeText(text: String, fn: Consumer<string>) { } // usage from Kotlin consumeText("foo", Consumer { println(it) }) // usage from Java consumeText("foo", System.out::println); </string>
Java 中使用不可变集合
Kotlin 提供了 JDK 集合类的不可变版本。
fun createSet(): Set<string> = setOf("foo") // ... createSet().add("bar") // oops, compile error </string>
这是一个很好的补充。但是当你在看 Java JDK 的 Set
类 API 的时候会发现:
createSet().add("bar"); // UnsupportedOperationException
当你尝试修改这个 Set
的时候,就会抛出这个异常,就像你使用了 Collections.unmodifiableSet()
方法一样。我不知道这种情况是否合理,但是你在使用 Kotlin 不可变版本的 Java 集合类的时候,需要谨记这一点。
接口中没有重载
Kotlin 在接口上不支持使用 @JvmOverloads
注解,当然 override
也不行。
interface Foo { @JvmOverloads // OUCH! fun bar(qux: String) } class FooImpl : Foo { @JvmOverloads // YIKES! override fun bar(qux: String) { } }
你只能像下面这样手动定义:
interface Foo { fun bar() fun bar(qux: String) }
要记住你可以使用 Kotlin 中的 KEEP (Kotlin Evolution and Enhancement Process) 来改善。KEEP 与 Java 中的 JEP 类似,但是与 JEP 相比要简洁许多。
总结
Kotlin 现下很流行,并且我也认为他是一个增强版的 Java。但是在使用 Kotlin 的时候你仍需要保持清醒,尤其是当你身处各种各样的关于 Kotlin 的宣传之中时。如果你要使用 Kotlin 的话,一定要注意我们在上面提到的 Kotlin 相关的缺陷。
最后我还是想说,上述提到的问题都比较容易解决,并且不会对语言的使用方面带来本质性的伤害。
也感谢您的阅读!可以在我的 Blog 上读到更多的文章。
原文链接: Kotlin pitfalls and how to avoid them
感谢覃云对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论