关于Kotlin语法糖的阶段性总结与思考
1. 引言
在Android应用的开发语言上,是从Java再发展到Kotlin的,所以Kotlin语言的开发习惯中不可避免会带有Java的痕迹,所以很多关于Kotlin的语法糖的使用,容易被忽略。而对于语法糖,有的人觉得很香,也有人觉得不值一提,各有各想法。
此文,仅以表述个人对于Kotlin语法糖的一些优势总结:
- 使代码更简洁专注
- 使代码更贴近阅读习惯
- 使代码更具维护性
- 使代码更加安全
2. 使代码更简洁专注
以启动线程执行业务为例,
Java代码:
new Thread() {
@Override
public void run() {
System.out.println("hello");
}
}.start();
Kotlin代码可以照搬Java方式:
object : Thread() {
override fun run() {
println("hello")
}
}.start()
Kotlin里启动线程还有另一种语法糖:
thread {
println("hello")
}
特别注意:注意是thread
而不是Thread
,如果是后者其实调用了 Thread(Runnable target)
的构造方法。
不难看出,Kotlin里的这个语法糖非常简洁,可以使得开发者在无论在写代码还是代码阅读上,都只需要专注于线程中的执行逻辑,而Java代码则显得繁琐。
中间的Kotlin代码以Java方式实现,功能上没有任何问题,因为Java的代码实现方式,在Kotlin中始终可以使用。只不过这时候代码结构也与Java完全一致,相当于吃了Kotlin语法却没吃到糖,不过既然吃语法,为何不吃糖?
或许有人疑惑,这种方式看似简洁了,但是如果只想创建线程,又不想立即启动,好像就不能这么写了?
其实也可以:
val xThread = thread(start = false) {
println("hello")
}
这样便不会立即启动线程,又拿到了线程对象的引用,自然可以根据需要在适当的时候再调用start()
启动线程了。
其实,Kotlin里的写法之所以如此简洁而且又不局限,是因为这里的thread
是个函数,声明如下:
public fun thread(
start: Boolean = true,
isDaemon: Boolean = false,
contextClassLoader: ClassLoader? = null,
name: String? = null,
priority: Int = -1,
block: () -> Unit
): Thread
之所以Kotlin启动线程简洁且不局限,其实是结合多种语法糖的综合结果:函数缺省参数、函数类型、拖尾的lambda表达式。
最后,放一起更直观地对比Kotlin代码中以Java方式实现以及语法糖方式实现的启动线程的区别:
object : Thread() {
override fun run() {
println("hello")
}
}.start()
thread {
println("hello")
}
不妨想想,上下两种方式,在代码阅读维护的时候,何种方式的代码更好?
下面的语法糖果不重要么?确实不重要,因为就算不吃这个糖,Java方式的Kotlin代码也可以实现,但是吃下这颗糖,可以使代码更简洁专注啊,何乐而不为?
毕竟,是语法糖,不甜的话,能叫糖么?
这里仅讨论语法糖本身内容,Kotlin中的函数类型设计不仅限于语法糖,更多内容可参照:
函数类型,一个更好的选择
3. 使代码更贴近阅读习惯
本节将以Java的静态函数和Kotlin的拓展函数类作为对比。
以前Java代码中,经常会这样判断一个字符串是否为null或空字符串:
if (TextUtils.isEmpty(str)) {
System.out.println("str is null or empty");
}
在Kotlin中,可以这样:
if (str.isNullOrEmpty()) {
println("str is null or empty")
}
Java里的静态函数和Kotlin里的拓展函数更直观的对比:
TextUtils.isEmpty(str) // Java静态函数
str.isNullOrEmpty() // Kotlin拓展函数
按照从左往右的阅读习惯,拓展函数更符合阅读习惯。
如上面例子的目标其实是:判断字符串是否为null或空字符串。
所以str.isNullOrEmpty()
更符合阅读思维,从左到右阅读出来的信息即为上述意义,而Java中的TextUtils.isEmpty(str)
一行从左到右阅读起来则是文字工具中判断是否为空方法函数调用,函数参数再传入被判断的字符串。
以前Java里是没得选,因为Java中没有拓展函数,所以要用静态方法来实现;但Kotlin有拓展函数这个语法糖了,所以可以用更符合阅读习惯的方式进行封装和调用了。
简单作个类比,现金支付类比于Java静态函数、手机支付类比于Kotlin拓展函数,有得选的情况下,相信大多数人会选择手机支付(Kotlin拓展函数)更为方便,但没得选的情况下(商家不支持手机支付,类似于没有Kotlin语言支持时),那么现金支付已足够支持完成交易。
手机支付也好,现金支付也罢,本质上都是货币交易的载体;类似的Kotlin拓展函数也好,Java静态函数也罢,本质上都是JVM命令的编译与执行。
手机支付的存在并不是为了否定现金支付,同理Kotlin拓展函数的存在也不是为了否定Java静态函数的意义,只是Kotlin的拓展函数提供了更贴近阅读习惯的使用方式。
4. 使代码更具维护性
本节以Kotlin中的属性访问器(getter/setter)设计为例。
Java里关于属性有下述经典写法:
public class JavaDemoBean {
private String data;
public String getData() {
return data;
}
public void setData(String data) {
this.data = data;
}
}
假定需要对data数据统一增加后缀"@test",所以getData()
里稍微改下:
public String getData() {
return data + "@test";
}
但是,这里有个问题,因为类内部是可以直接访问私有的data数据的,所以类内部直接访问属性data的地方都需要统一使用getData()
才能达到效果,而且后续新增代码时扔直接访问私有变量而造成额外的代码评审和维护成本。
相对的,Kotlin中的getter/setter访问器方法可解决上述情形的痛点:
class KotlinDemoBean {
var data = ""
get() = "${field}@test"
}
这样,无论是类内访问还是类以外的地方调用,访问属性的时候都自动加上了后缀,这样就满足了当前需求:每次访问数据值时都会给真实的数据值加上了相应后缀。
更简洁,且更好维护。
注:内存里储存的对象仍将是不带后缀的字符串,只是每次读取属性时拼接了后缀,这时候属性的幕后字段在属性访问器以外的地方将无法读取。
这里稍微延展下,Java方式里,可以同时通过私有变量直接访问到字段原本值和加上后缀的内容,但是Kotlin的上述写法则无法做到读取字段原本值。其实Kotlin里要做到同时访问两者也可以,不过相对麻烦点,这里用幕后属性方式作为示例:
class KotlinDemoBean {
private var _data: String = ""
var data: String
get() = "${_data}@test"
set(value) {
_data = value
}
}
这样的话,如果想访问不带后缀的属性,则使用_data
,想使用自动加后缀的属性,则使用data
。
如果不需要访问原来值,则是前面非常简洁的代码,相对地,需求更多时再产生更多的代码是合理的。
更多细节:Kotlin在默认情况下类外部调用属性总是通过访问器间接读写的,想要如Java那样直接暴露Kotlin类的属性字段而不需要访问器函数封装,注解@JvmField 又是一种选择。
总而言之,Kotlin的属性访问器语法糖设计,只是在原来Java的基础上,使得代码维护性变得更好了。
5. 使代码更加安全
Kotlin与Java的差异中,空安全总是最为突出的一个点,提供的语法糖也是最为繁杂的。
在Java中,关于判空,一般会这样写:
public class JavaExample {
private Dialog dialog;
public void showDialog() {
if (dialog != null) {
dialog.setTitle("abc");
dialog.show();
}
}
}
Kotlin中如果照搬Java写法,则会出现下面这种代码:
class KotlinExample {
private var dialog: Dialog? = null
fun showDialog() {
if (dialog != null) {
dialog!!.setTitle("abc")
dialog!!.show()
}
}
}
注意,最后调用dialog的对象方法的时候,如果没有操作符!!
上述代码将提示编译错误。
是不是很奇怪?为什么在调用方法前已经进行了判空,为什么这里还是要加!!
才能通过编译?
其实,不加!!
时Android Studio已经提示具体原因了:
简单来说,就是Kotlin检测到在调用对象方法时dialog属性可能已经被改变。
更具体的说,由于dialog是可空且是可变的,所以可能在判空条件成立后,调用对象方法之前已经被另一线程置空,所以即使进行了判空后再调用对象方法,仍可能产生空指针。
所以,这时候在Kotlin里照搬Java的代码风格就显得不合适了。
Kotlin里正确的条件判空姿势,应该是下面这种用法:
fun showDialog() {
val curDialog = dialog
if (curDialog != null) {
curDialog.setTitle("abc")
curDialog.show()
}
}
本质上是利用局部变量进行判空。
为什么对局部变量进行判空有效而对成员变量进行判空则仍编译错误?
这时候要提及JVM堆栈内存的概念了,JVM中,堆内存是所有线程共享的,栈内存仅为某线程运行时所私有,而所有对象都创建在堆内存上,Java中的变量只是对象的一个引用(指针值),而类中的成员变量在堆内存中分配,而局部变量则在栈变量分配,所以成员变量判空后再执行对象方法时可能已经被另一线程所置空,而局部变量则没有这种可能。
这时候估计会有疑惑:代码这里有没有涉及多线程调用,为什么非要考虑到多线程的影响呢?
Java里对于空安全是交于代码运行时的,Kotlin对于空安全是交于编译时的(除非使用!!
),一个是出现问题再改,一个是强迫在写代码的时候就去处理问题。既然Kotlin设计了空安全,那么还是得考虑多线程情况下的空安全。
注:Kotlin的空安全只是保证了多线程时不会触发空指针异常,但是没有保证数据在多线程条件下的一致性和同步性。
对于这种局部变量的判空方式,肯定是较为麻烦的,起名纠结症患者甚至对此嗤之以鼻,所以又有下面这种语法糖写法:
fun showDialog() {
dialog?.setTitle("abc")
dialog?.show()
}
看起来简洁又安全,但却不够优雅。
首先,Kotlin中每个?
操作符判空的背后,都是一次对于局部变量的赋值与判空。
也就是,这里有两次判空的命令执行。
更好的方式,是利用标准函数来优化重复的判空操作:
比如用let
:
fun showDialog() {
dialog?.let {
it.setTitle("abc")
it.show()
}
}
这里个人更倾向于用run
,因为run的单词语义与上下文表意更贴切:
fun showDialog() {
dialog?.run {
setTitle("abc")
show()
}
}
两者在此时本质上都是在编译时产生了一个局部变量进行判空并调用,原理上等同于上面的手写局部变量条件判空的方式,只不过这时候,局部变量名交予了编译器产生。
Kotlin的空安全语法糖,无论哪一种较之于Java复杂一些,但是对于空指针确实更安全了!
6. 关于对语法糖的思考
有人调侃,Kotlin一个最明显的特点便是语法糖永远比Java多。
退一步说,即使不用Kotlin的各种语法糖,按照Java的方式也基本可以实现功能,所以为什么要去过多纠结Kotlin设计的语法糖呢?
这里借用鲁迅先生的《孔乙己》中回字的四种写法来作类比,一般惯以回字四种写法嘲讽无用的知识和炫耀,然而把这件事剥开看,如果读书人只会炫耀回字的四种写法,那自然是无用知识。但是,如果能理解不同写法的使用场景、字体含义,在合适的前后文(字体、书函、学科等)中正确地使用不同的回字,以达到更贴切、更丰富的表达,这时候回字的不同写法的知识一定程度上便是有意义的内容。
只着眼于回字的四种写法表面,自然是无用知识,鲁迅先生借此讽刺的是封建制度对于读书人的限制以及摧残。
更多地,只着眼于知识本身而忽略对于知识本身的理解,这种学习是否正确呢?
回到开发语言上,只着眼于语法糖本身而去否定语法糖,忽略对语法糖设计的理解,是否正确?
从Java转战Kotlin过程,过于着眼于Kotlin的语法糖本身而忽略了语法糖设计带来的变化和改进,忽略了对于Kotlin语言本身的设计的理解,是否正确?
这些问题,每个人有不同的思考,这里也不是应试教育不必追求标准答案,本文也仅给出个人目前对于Kotlin语法糖的初步总结:
- 使代码更简洁专注
- 使代码更贴近阅读习惯
- 使代码更具维护性
- 使代码更加安全
这些内容或许可以有更多的维度的解读和理解,与语法糖本身相比,语法糖背后的思考与理解或许会更重要。