可能许多人一看标题就要开喷了:“我明明天天都在写,怎么可能不支持!”

且别急,如果你天天都在写,那么有极大的危险掉坑里。

陷阱重现

先看一段代码,
这是一个 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 表达式是怎么描述的:
ifExpression BNF

可能许多人发现语法的定义里没有 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:
探究 Java 的  else if 实现

查看字节码(需要先编译生成 class 文件),
IDEA 上 View -> Show Bytecode:

Java 版的字节码

惊了吧, Java 的字节码结构和 Kotlin 版的是一样的啊!没有看到什么 else if 的逻辑啊!

我们再翻翻 Java 的文档(此处用的是 Java 11):
https://docs.oracle.com/javase/specs/jls/se11/html/jls-19.html

Java 中描述 If 的 BNF

官方实锤了,Java 也没有专门的 else if 语句定义!

但是 Kotlin 上出现的问题,Java 上没有,为什么呢?
相信熟悉一点 Kotlin 的都已经想到了:
最开始的例子中的问题是 Kotlin 的语言特性导致的,
准确说是 Kotlin 的if表达式扩展函数这两个语言特性相结合而导致的。

Java 没有上述特性所以安全避开此天坑。

结论:

无论是在 Java 还是在 Kotlin 中, 并不存在原生的 else if 逻辑;
else if 形式的存在只是恰好有 if (表达式)跟在 else 后面的结果,其底层实现是 if - else 的嵌套;
因为这个原因,在 Kotlin 的 If 表达式与扩展函数两个特性结合使用时会出现问题;
所以在用 Kotlin 作开发时应该避免这种写法,可以使用 when 表达式代替。