震惊!Kotlin 居然不支持 else if !
可能许多人一看标题就要开喷了:“我明明天天都在写,怎么可能不支持!”
且别急,如果你天天都在写,那么有极大的危险掉坑里。
陷阱重现
先看一段代码,
这是一个 kotlin 新手十分容易犯的错误:
fun main() {
val a = if (System.currentTimeMillis() % 2L == 0L) 666 else 888
if (a == 666) {
println("666")
a
} else if (a == 888) {
println("888")
a
} else {
null
}?.let {
println(a)
}
}
当 a == 666 时会怎么输出呢?
相信不少人都觉得应该会输出两次 666,然而现实是只会输出一次:
666
Process finished with exit code 0
怎么样,有没有出乎你的意料?
如果没有,那么恭喜,你的 Kotlin 编码经验挺丰富的,那么你知道为什么会是这个结果吗?
震惊的本源
我们知道,kotlin 是 JVM 平台的语言,那么它的执行逻辑应该和 Java 一样,编译成 JVM 识别的字节码,然后丢给 JVM 去跑。
所以入手途径还是字节码,那么直接逐行认真地读一下它的字节码吧。
直接在 IDEA 上 Tools -> Kotlin -> Show Kotlin Bytecode:
public final static main()V
L0
LINENUMBER 2 L0
INVOKESTATIC java/lang/System.currentTimeMillis ()J // 调用静态方法获取毫秒数
LDC 2 // 将常量 2 入栈
LREM // 计算 (毫秒数 % 2)
LCONST_0 // 将 0L 入栈
LCMP // 比较 (毫秒数 % 2) 与0L
IFNE L1 // 如果不等于 0 则跳到 L1 (a == 888)
SIPUSH 666 // 将 666 入栈(a == 666)
GOTO L2 // 跳转到 L2
L1
SIPUSH 888 // 将 888 入栈
L2
ISTORE 0 // 取出栈顶元素存入局部变量表 slot 0(a 的值)
L3 // ********** 判断:a == 666
LINENUMBER 5 L3
ILOAD 0 // 取出局部变量表 slot 0 的元素(a 的值)
SIPUSH 666 // 将 666 入栈
IF_ICMPNE L4 // 如果两个int类型值不相等(即 a != 666),则跳转到 L4
L5
LINENUMBER 6 L5 // 从 L5 - L10 的逻辑是 a == 666 的逻辑
LDC "666" // 把常量池中的 "666" 压入栈
ASTORE 1 // 将 “666”引用放入局部变量表 slot 1
L6
ICONST_0 // 将 0 入栈
ISTORE 2 // 将 0 放入局部变量表 slot 2
L7
GETSTATIC java/lang/System.out : Ljava/io/PrintStream; // 获取静态类 java.io.PrintStream
ALOAD 1 // 加载局部变量表 slot 1 的元素(“666”)
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V // 使用 println 输出刚才取出的 “666”
L8
L9
LINENUMBER 7 L9
L10
GOTO L11 // a == 666 的分支到此结束
L4
LINENUMBER 8 L4 // L4 - L37 是 a != 666 时的处理逻辑
L12 // ************ 判断:a == 888
LINENUMBER 13 L12
ILOAD 0 // 取出局部变量表 slot 0 的元素(a 的值)
SIPUSH 888 // 将 888 入栈
IF_ICMPNE L13 // 如果两个int类型值不相等(即 a != 888),则跳转到 L13
L14
LINENUMBER 9 L14 // L14 - L19 为 a == 888 的逻辑,类似 a == 666,所以不赘述
LDC "888"
ASTORE 1
L15
ICONST_0
ISTORE 2
L16
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 1
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L17
L18
LINENUMBER 10 L18
ILOAD 0
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
L19
GOTO L20
L13
LINENUMBER 12 L13 // L13,L21 为 a == null 时的逻辑
ACONST_NULL // 将null对象引用压入栈
L21
LINENUMBER 8 L21
L20 // ************判断:a == null (这里对应的应该就是 ?.let 语句里面的 ? 操作了)
DUP // 复制栈顶部一个字长内容
IFNULL L22 // 如果等于null,则跳转到 L22 (从逻辑上看即结束了)
ASTORE 1 // 这是不等于 null 的处理逻辑:加载局部变量表 slot 1 的引用 “666”
L23
LINENUMBER 13 L23
L24
ICONST_0
ISTORE 2
L25
ICONST_0
ISTORE 3
L26
ALOAD 1 // 加载局部变量表 slot 1 的引用 “666”
CHECKCAST java/lang/Number // 类型转换检查
INVOKEVIRTUAL java/lang/Number.intValue ()I // 执行类型转换(“666” 变为 666)
ISTORE 4 // 666 存到局部变量表 slot 4
L27
ICONST_0
ISTORE 5
L28 // 将 a 的值从 slot 0 转移到 slot 6
LINENUMBER 14 L28
ILOAD 0
ISTORE 6
L29
ICONST_0
ISTORE 7
L30 // println(a)
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ILOAD 6
INVOKEVIRTUAL java/io/PrintStream.println (I)V
L31
L32
LINENUMBER 15 L32
NOP
L33
L34
LINENUMBER 13 L34
L35
GOTO L36
L22
POP
L36
L37
LINENUMBER 15 L37
L11
LINENUMBER 16 L11 // 结束
RETURN
L38
LOCALVARIABLE it Lkotlin/Unit; L26 L32 4
LOCALVARIABLE $i$a$-let-ApplicationKt$main$1 I L27 L32 5
LOCALVARIABLE a I L3 L37 0
MAXSTACK = 4
MAXLOCALS = 8
由于行数太多,我除了开头示范性地注释每个指令的作用外,仅在重要的分支点添加注释。
可以看到,当 a == 666 时,执行的命令步骤是 L5 - L9,而当 a != 666 时,命令步骤是 L4 - L36。
然后在 L4 - L36 中: L12 步骤里执行判断 a == 888,然后再在 L20 判断 a == null。
综合步骤如下:
L0 - L2:算 a 的值
L3:if (a != 666) L4 否则 L5
L5 - L10:a == 666 的逻辑(注意执行完跳到了 L11)
L4, L12:if(a != 888) L13 否则 L14
L14 - L19:a == 888 的逻辑(执行完走 L20)
L13, L21, L20:if (a == null) L22 否则 L23
L23 - L35:let 逻辑
L22, L36, L37, L11:结束
也就是说 Kotlin 把 if - elseif - else 拆分成 了两个 if - else 嵌套!而 let 语句顺理成章地跟在了第二个 if 表达式后面。
即实际上的逻辑是这样的:
这也太反常识了吧!
很遗憾,实际就是这样的。
下面是 Kotlin 语法的 BNF 文档:
https://kotlinlang.org/docs/reference/grammar.html#ifExpression
看看它对 if 表达式是怎么描述的:
可能许多人发现语法的定义里没有 else if 形式的语法,
其实也不对,controlStructureBody
其实是包含了 ifExpression
的,但是 else 语句后面跟的括号说明了一切:
controlStructureBody
是作为一个独立的整体存在的,不管表现形式是 ifExpression
还是其它。
所以 else if 在 Kotlin 中并不原生支持,它的存在仅仅是恰好有 if 表达式跟在 else 后面的结果,所以 else if 也不具有连贯的语义,这时候应该把 else 和 if 拆开来理解。
稍微改写下最初的代码,在 if 式子整段逻辑上加括号就能达到最初想要的效果了:
然而这种写法并不提倡,
你可以清楚看到 IDE 提示建议使用 when 表达式(使用 when 确实能达到想要的效果,跟着建议走一般不会有问题)。
另外如果有常量,变量或者字面量作为 ifExpression 的返回值,其实最开始那种写法也会报警告:
看来如果不知道的话,这个连续 if 表达式的问题是个大坑啊。
更意料之外的事实
经历完上述的解析和实锤,是不是有种想锤 Kotlin 设计者的冲动?
为什么连基本的 else if 都不支持?
为什么不能像其它语言,比如 Java 一样原生支持 else if ?
促成这一事情的真相,真的仅仅是因为 Kotlin 不原生支持 else if 吗?
我们来看看 Java 是怎么做的吧,来写段 demo:
查看字节码(需要先编译生成 class 文件),
IDEA 上 View -> Show Bytecode:
惊了吧, Java 的字节码结构和 Kotlin 版的是一样的啊!没有看到什么 else if 的逻辑啊!
我们再翻翻 Java 的文档(此处用的是 Java 11):
https://docs.oracle.com/javase/specs/jls/se11/html/jls-19.html
官方实锤了,Java 也没有专门的 else if 语句定义!
但是 Kotlin 上出现的问题,Java 上没有,为什么呢?
相信熟悉一点 Kotlin 的都已经想到了:
最开始的例子中的问题是 Kotlin 的语言特性导致的,
准确说是 Kotlin 的if表达式
和扩展函数
这两个语言特性相结合而导致的。
Java 没有上述特性所以安全避开此天坑。
结论:
无论是在 Java 还是在 Kotlin 中, 并不存在原生的 else if 逻辑;
else if 形式的存在只是恰好有 if (表达式)跟在 else 后面的结果,其底层实现是 if - else 的嵌套;
因为这个原因,在 Kotlin 的 If 表达式与扩展函数两个特性结合使用时会出现问题;
所以在用 Kotlin 作开发时应该避免这种写法,可以使用 when 表达式代替。