kotlin入门潜修之特性及其原理篇—异常
本文收录于 kotlin入门潜修专题系列,欢迎学习交流。
创作不易,如有转载,还请备注。
java中异常
很多语言都有异常机制,异常能够改变正常的程序执行流程,主要用于终止一些非法的逻辑流程。这些流程如果我们不及时终止,则可能会引起后续的一系列错误甚至程序崩溃。
我们都知道java中有两类异常,一类是受检异常,另一类是运行时异常。受检异常是指,在编译期间就必须要进行显示处理的异常,否则就会编译不通过;而运行时异常则在运行期间发生的异常,如果不处理会直接导致程序崩溃。来看个java异常的例子:
public class JavaMain {
//main方法
public static void main(String[] args) {
testException();
}
//受检异常,jdk在代码声明的时候,就已经显示抛出的异常
//这里异常是FileNotFoundException,该方法的处理方式是继续向上抛出
//这里我们故意写了一个不存在的文件路径:a/b/filename
//在代码执行的时候就会抛出FileNotFoundException异常
private static void writeFile() throws FileNotFoundException {
OutputStream os = new FileOutputStream("a/b/filename");
}
//受检异常,同wiretFIle方法,只不过这里的处理方式是自己处理(try-catch)
private static void writeFile2() {
try {
OutputStream os = new FileOutputStream("a/b/filename");
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
//运行时异常,编译期将不会强制对该类型异常进行处理
//而是在运行的时候,如果非法则抛出对应异常(这里是ArithmeticException),
//当然,在这里,我们也可以在编写代码的时候
//就是用try-catch块将其包括,这样可以避免程序崩溃
private static void divisionByZero() {
int i = 10 / 0;
}
//异常测试方法
private static void testException() {
try {
writeFile();
} catch (FileNotFoundException e) {
e.printStackTrace();
}
writeFile2();
divisionByZero();
}
}
上面代码中,注释已经写的比较清晰,这不再阐述代码的意义。
关于受检异常还需要表明一点,受检异常可以有两种处理方式:一种是自己不处理,继续像上层抛出异常(见writeFile方法),如果上层处理不了,则还要继续向上抛出,直到能处理为止(或许会到程序的执行入口main方法),也就是说,这种方法终归要在一个地方进行异常处理;另一种则是自己处理掉异常,即使用try-catch包裹可能出现异常的代码块,见(writeFile2方法),这种方式则不必再继续向上抛出。具体采用哪种方法,需要根据实际场景进行选择。
kotlin中的异常
在上一小节,我们简单演示了java中的异常概念,因为我们的主题是kotlin,所以不再展开java异常,本节开始对kotlin中的异常进行阐述。
kotlin中的所有异常都继承自一个类:Throwable,先来看看其定义:
public open class Throwable(open val message: String?, open val cause: Throwable?) {
constructor(message: String?) : this(message, null)
constructor(cause: Throwable?) : this(cause?.toString(), cause)
constructor() : this(null, null)
}
从Throwable的构造方法可以看出,kotlin中的异常可以接受描述信息,同时也可以接收另一个异常入参,用于表示该异常的引起原因。
kotlin中的异常同样有try-catch-finally流程处理机制,try即表示尝试执行其代码块;catch则用于捕获try块中的代码可能出现的异常;而finally代码块则表示其中的代码,无论如何都会被执行到的,我们一般会在finally代码块中做些资源释放等操作。
来看个kotlin中异常的例子:
fun main(args: Array<String>) {
test()
}
//测试方法,显示抛出一个异常
fun test() {
throw Exception("test exception...")
}
上面代码在执行的时候,就会抛出描述信息为test exception...的异常。由于我们没有对该异常做任何处理,所以该异常会直接导致程序crash。如果我们避免程序crash,则需要利用try-catch-finally机制进行异常捕获。
这里先说下try-catch-finally三者之间的关系。首先,要处理异常必须要使用try来包裹代码块;其次,try后面必须跟着catch或者finally中的至少一个,当然也可以同时存在。来看个例子:
fun main(args: Array<String>) {
//使用了try-catch进行包裹
try {
test()
} catch (e: Exception) {//catch块可以捕获try块中的异常
println(e.message)//打印异常信息
}
}
//抛出异常的test方法
fun test() {
throw Exception("test exception...")
}
我们同样可以在try块后只跟finally块,但是,finally块的意义更多的是用于做些资源回收之类的操作,并不能阻止程序异常退出,如下所示:
fun main(args: Array<String>) {
try {
test()
} finally {
println("in finally...")
}
}
fun test() {
throw Exception("test exception...")
}
上面代码会首先打印"in finally..."字符串,然后就会异常退出。所以说finally是无法阻止程序的异常退出的。
当然,我们可以同时使用try-catch-finally,这样我们既能处理异常,保证程序不崩溃,又可以在发生异常时及时回收资源。
经典的异常处理流程问题
在本小节,来看一个经典的异常处理流程问题,先上代码,如下所示:
//测试方法
fun test(): Int {
var i = 1
try {
i = 2
println("in try block: i = 2")
return i
} catch (e: Exception) {
i = 3
} finally {
i = 4
println("in finally block: i = 4")
}
return i
}
//main方法
fun main(args: Array<String>) {
println(test())
}
猜测下上面代码会打印什么?不卖关子,上面代码打印如下:
in try block: i = 2
in finally block: i = 4
2
由此可知,方法的调用首先执行了try块中的语句,最后执行了finally代码块中的语句,并且确实改变了i的值。然而,当方法返回的时候,我们发现i的值却是try代码块中的值,这有点不符合打印逻辑(毕竟我们在finally块中改变了i值!),为什么?
先不解释为什么,再来看个例子:
//测试方法
fun test2(): Int {
var i = 1
try {
i = 2
println("in try block: before i = i / 0")
i = i / 0
println("in try block: after i = i / 0")
return i
} catch (e: Exception) {
i = 3
println("in catch block: i = 2")
return i
} finally {
i = 4
println("in finally block: i = 4")
}
}
//main方法
fun main(args: Array<String>) {
println(test2())
}
上面代码执行过后,打印结果如下所示:
in try block: before i = i / 0
in catch block: i = 2
in finally block: i = 4
3
上面这个例子,我们在try代码块以及catch代码块中都指定了return语句,显然,try代码块中的return语句会被异常中断,之后进入到catch块处理。最后,我们同样在finally块中改变了要返回的i的值,然而,我们发现return的i值却依然是catch块中的i值,而不是finally块修改后的i值,这是为什么?
先不着急解释上面两个例子return值为什么没有被改变,这里先结合上面两个例子的输出,对异常的执行流程做出以下总结:
- 异常确实能够改变程序的正常执行流程,在发生异常的地方,其后边的语句都会被终止执行,转而执行catch(如果有的话)或者finally(如果有的话)中的代码块。
- 对于没有返回值的方法(即没有return语句),无论是try代码块、catch代码块还是finally代码块中执行的代码都会生效,而且以最后一个代码块执行的结果为准。
- 对于有返回值的方法,代码的执行结果,会以第一个有效的return语句返回值为准。这句话意思是说,如果try语句正常执行,则以其内的return返回值为准;如果try再执行return之前抛出了异常,则以catch块中的返回值为准,否则以finally块的返回值为准。
总结也总结完了,那么上述流程的背后原理是什么?想要了解其背后的原理,可以结合字节码来看一下。
照例,先贴出我们要分析的源代码:
fun test2(): Int {
var i = 1
try {
i = 2
i = i / 0
println("in try block: after i = " + i)
return i
} catch (e: Exception) {
i = 3
println("in catch block: i = " + i)
return i
} finally {
i = 4
println("in finally block: i = " + i)
}
}
然后,贴出上述代码对应的字节码,字节码比较长,只需要关注以下几个节点(参见字节码中的注释)即可把整个流程串起来,如下所示:
public final static test2()I
TRYCATCHBLOCK L0 L1 L2 java/lang/Exception
TRYCATCHBLOCK L0 L1 L3 null
TRYCATCHBLOCK L2 L4 L3 null
TRYCATCHBLOCK L3 L5 L3 null
L6
LINENUMBER 9 L6
ICONST_1
ISTORE 0//!!!环节1,存储常量1到局部变量表索引0处,即i=1
L7
LINENUMBER 10 L7
L0
NOP
L8
LINENUMBER 11 L8
ICONST_2
ISTORE 0//!!!环节2,存储常量2到局部变量表索引0处,即i=2
L9
LINENUMBER 12 L9
ILOAD 0
ICONST_0//常量0
IDIV//!!!环节3,执行除以0的计算,即 i = i / 0,这里显然会抛出运行是异常,意味着下面这条字节码根本不会被执行
ISTORE 0 //!!!环节4,这个实际不会被执行!存储计算结果到局部变量表0索引处
L10
LINENUMBER 13 L10
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "in try block: after i = "
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ILOAD 0
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 1//!!!环节5,存储字符串到局部变量表索引为1的位置,字符串对应于"in try block: after i = " + i
L11
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 1//!!!环节6,将存储的字符串加载到操作数栈顶,打印
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V//执行打印
L12
L13
LINENUMBER 14 L13
ILOAD 0//!!!环节6,将局部变量表索引为0的值加载到操作数栈,即加载的是i的值
ISTORE 1//环节7,存储到局部变量表索引为1的位置
L1
LINENUMBER 20 L1
ICONST_4//!!!环节8,神奇!!,这个实际上对应的是finally块中的i=4
ISTORE 0//将4存储到局部变量表为0的位置
L14
LINENUMBER 21 L14
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "in finally block: i = "
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ILOAD 0!!!环节9,加载环节7中存储的4,用于字符串拼接,即对应于 println("in finally block: i = " + i)语句中的i值的获取
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 2//!!!环节9,将字符串值存储到局部变量表2索引处
L15
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2//这个就是环节9中的值
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L16
L17
ILOAD 1//!!!环节10,注意,这里加载的是局部变量索引1处的值,最近的一次存储是在环节7处,显然和finally代码块中的代码没有关系了
//因为finally中的代码实际上将i的值存在了局部变量表的索引0处
IRETURN//!!!环节11,返回i的值,实际上对应于try块。
L2
LINENUMBER 15 L2
ASTORE 1
L18
LINENUMBER 16 L18
ICONST_3
ISTORE 0!!!环节12,这个实际上对应的是catch块中的代码,即 i = 3
L19
LINENUMBER 17 L19//下面的打印语句同try中的一致,不再展开
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "in catch block: i = "
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ILOAD 0
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 2
L20
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V//catch中的打印语句结束
L21
L22
LINENUMBER 18 L22
ILOAD 0//!!!环节13,这个加载的是环节12中保存的i值,即3
ISTORE 2//!!!环节14,将该值存到了局部变量表中索引2的位置
L4
LINENUMBER 20 L4//下面实际上对应的又是finally代码块中的位置!!!,和try之后的基本一样,不再展开
ICONST_4
ISTORE 0
L23
LINENUMBER 21 L23
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "in finally block: i = "
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ILOAD 0
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 3
L24
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 3
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L25
L26
ILOAD 2//!!!环节15,将环节14中存储的i值,加载到操作数栈
IRETURN//!!!环节16,返回该值
L27
LINENUMBER 22 L27
L3
ASTORE 1
L5
LINENUMBER 20 L5//下面实际上又是finally块对应的代码!!,不再展开。
ICONST_4
ISTORE 0
L28
LINENUMBER 21 L28
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "in finally block: i = "
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ILOAD 0
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 2
L29
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L30
L31
ALOAD 1//!!!环节17,这个局部变量表1中的值,实际上是环节7存储的i值,在异常发生的时候,会先存储该值。
ATHROW//!!!环节18,这个是抛出异常指令,如果try中发生异常,则会执行该指令
字节码中的注释已经很详细了,如果仔细看应该能够理解,不理解的话也没有关系,这里给出一个宏观的总结:
- 如果有finally代码块,则finally代码块会被多次编译,其插入位置分别位于try块之后、catch块之后,同时其自身所处的代码位置也会被再次编译一次。也就是说,最多可被编译3次。这也是为什么finally一定会被执行的原因。
- 含有return语句的try-catch-finally代码,其执行流程实际上确实和代码的书写顺序保持一致(这里的一致是指try-catch-finally的执行顺序),但是如果每个块中有返回值,则以第一个有效的return值为准(字节码注释已经详细进行了说明)。这是为什么?
要解答这个问题,则需要结合上面的第一点,即如果有finally代码块,则无论是try块还是catch块,后面都会被插入finally代码,进而会按照try-finally或者catch-finally的代码块顺序来执行,只不过try-catch-finally中的局部变量值,在局部变量表中的存储位置不同,而编译器在执行返回的时候,实际上执行的是,第一个有效return语句对应的局部变量表的值。这里之所以加上“有效”两个字,主要是考虑到了多种场景。比如,如果try中有return语句,在执行该语句之前没有抛出任何异常,则一定会执行其return语句;但是如果再执行该语句之前抛出了异常,则会进到catch块,不再执行其后的return语句;如果catch块中有return语句,则会执行其return语句;最后才会执行方法体最后的return语句,这就是整个异常执行的流程。
实际上,java中的try-catch-finally执行机制也是如此。
kotlin异常机制的注意点
本小节阐述几个kotlin中异常需要注意的几个点。
无受检异常。
kotlin异常机制同java最明显的不同就是不再有受检异常。kotlin给出了很多不提供受检异常的理由,这里不再阐述。从个人观点来看,受检异常最大的坏处就是在每次写代码的时候都要显示进行异常处理,无论实际上有没有可能发生该异常,所以这就意味着会做很多无用功,而且代码看着及其不简洁(自行脑补下try-catch-finally...,如果还不够,再脑补下嵌套的try-catch...)。
异常表达式
在kotlin中,try是一个表达式,throw也是一个表达式!如下所示:
fun test2() {
var i = 1
var j = 0
i = try {
i / j
1
} catch (e: Exception) {
-1
} finally {
2
}
println(i)
}
上面代码演示了try作为表达式的案例,上面代码由于j=0,所以在i/j的时候显然会抛出异常,这个时候就会执行catch表达式,所以会打印-1。如果将上述代码改成j=1,则会返回try块中的值,即打印1。
那么finally块中的值哪儿去了?答案是无效!在try作为表达式的时候,finally代码块的返回值将会被忽略。即使在finally块中修改了一个全局的成员变量,虽然该变量的值会被改变,但是,在finally被修改后的值依然无法影响到try-catch的返回值。如下所示:
var k = 1//定义一个top-level级别的变量k
fun test2() {//测试方法test2
val i = try {
k = 0//我们在try块中将k赋值为了0
k//k会被作为返回值返回
} finally {
k = 3//!!!我们在finally块中修改了k的值
}
println(i)
println(k)
}
上面的代码打印如下:
0
3
由此可证明上面我们的论断。
下面再来看一个catch作为表达式的例子,如下所示:
fun test2(str: String?) {
//throw作为表达式
val result = str ?: throw ArithmeticException("divide by zero!")
println(result)
}
//main方法
fun main(args: Array<String>) {
test2("test")//正确,打印“test"
test2(null)//抛出ArithmeticException异常!
}
上面语句val result = str ?: throw ArithmeticException("divide by zero!")就是异常作为表达式的写法。有没有发现很奇怪?throw ArithmeticException明明是没有返回值的,为什么还可以被赋值给result(即在str为null的时候)?这是怎么做到的?
实际上,这就涉及到了kotlin中一个特殊的类型Nothing,这个类型没有任何值,只是标识代码是“unreachable code”,即用于表示永远不会执行到的代码。所以,这背后,实际上是kotlin编译器帮我们做了剩下的工作,即当发现这种类型的时候,就不在执行后面的代码。
实际上,我们已经用过多次Nothing类型了,只不过这个Nothing类型有点特殊,我们一般无法看到,但是通过下面测试,我们可以看出一二:
fun main(args: Array<String>) {
val result = null
val result = null
}
上面代码显然是错误的,因为我们定义了两个同名变量,但是这里不是关注这些,而是关注此时编译器给我们的一些提示:
Conflicting declarations : val result : Nothing?,val result : Nothing?
从上面的提示可以看出,编译器实际上将result定义为了Nothing?类型。确实是这样的,如果我们在声明一个变量的时候,没有显示指定其具体类型,并将其值初始化为了null,这个时候,kotlin就会自动推断该变量类型为Nothing?。
Nothing?显然表示该变量值可以为null,那么如果不允许为null,其又有什么使用场景?来看个例子:
//定义了一个Person类,有个name属性,可为null
class Person(name: String?) {
var name: String? = name
}
//测试方法,注意我们使用了Nothing作为其返回值,
//如果不使用Nothing的话,则默认返回Unit
fun test(): Nothing {
throw IllegalArgumentException("person name can not be null!")
}
//main方法,测试方法
fun main(args: Array<String>) {
var result = Person("张三").name ?: test()
println(result)//打印'张三'
result = Person(null).name ?: test()//抛出IllegalArgumentException异常
println(result)
}
上面演示了Nothing类型的一种使用场景,其实和作为语句时基本类似。我们都知道,如果没有指定方法的返回值,则编译器会默认返回Unit,那么如果没有指定test方法返回值为Nothing的话,上述代码会执行吗?
答案是会的,而且两种写法的背后机制基本一致,但还是有些许差别,具体体现在test方法的返回值以及调用处,这个可以通过对比字节码来二者的不同,先来看下test方法生成的字节码:
//没有指定test方法返回值为Nothing
public final static test()V
L0
LINENUMBER 18 L0
NEW java/lang/IllegalArgumentException
DUP
LDC "divide by zero!"
INVOKESPECIAL java/lang/IllegalArgumentException.<init> (Ljava/lang/String;)V
CHECKCAST java/lang/Throwable
ATHROW
L1
MAXSTACK = 3
MAXLOCALS = 0
//指定其返回值为Nothing
public final static test()Ljava/lang/Void;
@Lorg/jetbrains/annotations/NotNull;() // invisible
L0
LINENUMBER 18 L0
NEW java/lang/IllegalArgumentException
DUP
LDC "divide by zero!"
INVOKESPECIAL java/lang/IllegalArgumentException.<init> (Ljava/lang/String;)V
CHECKCAST java/lang/Throwable
ATHROW
L1
MAXSTACK = 3
MAXLOCALS = 0
通过对比,我们发现,使用Nothing的test方法,其返回值会被编译成java.lang.void类型,而Unit则不会。其他则没有任何差别。
再来看test方法的调用,如下所示:
//指定了返回值为Nothing
NEW Person
DUP
ACONST_NULL
INVOKESPECIAL Person.<init> (Ljava/lang/String;)V
INVOKEVIRTUAL Person.getName ()Ljava/lang/String;
DUP
IFNULL L8
GOTO L9
L8
POP
INVOKESTATIC MainKt.test ()Ljava/lang/Void;//调用test方法
ACONST_NULL
ATHROW//直接throw异常
L9
ASTORE 1
L10
//返回值没有指定Nothing
LINENUMBER 13 L7
NEW Person
DUP
ACONST_NULL
INVOKESPECIAL Person.<init> (Ljava/lang/String;)V
INVOKEVIRTUAL Person.getName ()Ljava/lang/String;
DUP
IFNULL L8
GOTO L9
L8
POP
INVOKESTATIC MainKt.test ()V//调用test方法
GETSTATIC kotlin/Unit.INSTANCE : Lkotlin/Unit;//这里返回了Unit实例
L9
ASTORE 1
通过上面几处注释,我们发现,指定返回类型为Nothing的test方法,其字节码指令最后会直接throw一个异常,也就是上面我们代码中写的 throw IllegalArgumentException("divide by zero!")异常。但是没有指定返回类型为Nothing的test方法,仅仅是按照正常方法调用,最后返回了默认的Unit实例。
换句话说,使用Nothing类型作为方法返回值类型的时候,其被编译的字节码流程中已经被嵌入了throw异常的指令,是按照代码的正常执行逻辑,一步步执行的;而不使用Nothing类型作为方法返回值类型时,字节码层次并没有在其流程中插入抛出异常指令,显然,此时,如果出现异常,则会在运行时抛出。
在语法上,指定返回类型是Nothing和不指定返回类型为Nothing的最大区别是:当指定返回类型是Nothing时,方法是不能写任何返回值的;而不指定的时候,则可以显示写返回值为Unit,当然也可以省略。
那么问题来了,我们既然指定了方法的返回值是Nothing,而前面又说我们不能写任何返回值,这不是自相矛盾吗?
确实如此,既然方法有返回值,同时该返回值类型也不是Unit,所以,我们理所当然要显示指定其返回值类型,但使用Nothing修饰的方法又不能有返回值,貌似是个无法解开的闭环问题?想来想去,就只还有个方法:我们能不能显示返回Nothing实例?
很遗憾,答案是不能,因为Nothing的构造方法是私有的:
public class Nothing private constructor()
看到这里一定会绝望的,但实际上这一切kotlin已经给我们制定好了规则:对于Nothing这个类型,在kotlin的世界里面,表示一个“永远不存在的值”,Nothing类型没有任何实例。当Nothing修饰方法时,表示该方法永远没有返回值。那么怎么做,才能让方法没有返回值?那就是该方法必须抛出异常。
至此,kotlin异常相关的机制阐述完毕。