kotlin入门潜修之进阶篇—方法及尾递归原理

本文收录于 kotlin入门潜修专题系列,欢迎学习交流。

创作不易,如有转载,还请备注。

方法

在前面的文章中已经多次使用过kotlin的方法,但是始终没有对其做一个完备的阐述,况且kotlin还有诸如高阶方法、内联方法、中缀方法、尾递归等之类的存在,因此很有必要对kotlin中的方法进行一遍梳理。

本篇文章将首先阐述kotlin中常见的方法定义及其用法,接着阐述了kotlin中的中缀方法的定义和实现,最后阐述了kotlin中的尾递归的用法,并分析了其实现原理。

方法定义

方法的定义使用关键字fun,如下所示:

    fun m1(i: Int): Int {
        return i * i;
    }

kotlin方法参数是以name:type的形式定义的,其中type类型命名要符合Pascal命名规范(开头字母大写的驼峰命名规范,这也是自定义class的命名规范),name的命名规范即是常见首字母小写的驼峰命名规范。上面m1方法的入参i:Int 就表示name是i,type是Int。

kotlin方法允许有默认参数,这和java不一样(java方法参数不允许有默认值),如下所示:

//提供了一个默认参数j,值为100
    fun m1(i: Int, j: Int = 100): Int {
        return i * j
    }

需要注意的是,当子类复写父类中的、有默认入参值的方法时,子类方法不能再显示提供默认值,如下所示:

//父类Test,定义了一个方法m1
open class Test {
    open fun m1(i: Int, j: Int = 100): Int {
        return i * j
    }
}
//子类MyTest,复写了父类Test的方法m1,此时由于父类Test中的
//m1方法为入参j提供了默认值,因此子类Test中的m1方法必须省略入参j的默认值。
class MyTest : Test() {
    override fun m1(i: Int, j: Int): Int {
        return i * j
    }
}

kotlin并没有强制默认参数的顺序,但是如果将默认参数位置放到普通参数之前,当只传入普通参数时,需要显示指定参数name进行赋值。如下所示:

class Test {
//有默认值的参数j被放在了第一个位置
    open fun m1(j: Int = 100, i: Int): Int {
        return i * j
    }
}
fun main(args: Array) {
//注意这里,这里的意图是只要传入一个参数i,但是
//由于参数j有默认值且位于无默认值参数的前面,所
//以需要显示指定i,表示j使用默认值,而i使用我们传入的值。
    println(Test().m1(i = 1))
}

但是对于最后一个参数是lambda表达式的则不必再显示指定默认入参,如下所示:

class Test {
//lambda表达式作为最后一个参数
    fun m1(j: Int = 100, i: Int = 10, test: () -> Unit) {
        println(i * j)
        test()
    }
}
fun test() {
    println("hello test")
}
fun main(args: Array) {
    Test().m1 { test() }//可以不必显示指定默认参数j、i
}

kotlin还支持命名参数,如下所示:

class Test {
    fun m1(j: Int = 100, i: Int = 10) {
    }
}
fun main(args: Array) {
    Test().m1(1, 1)//不使用命名参数
    Test().m1(j = 1, i = 1)//指定命名参数
}

从代码显然可以看出,命名参数增加了代码的可读性,因为当传入多个相同类型的值时,我们很难一眼看出哪个值属于哪个参数,而命名参数则能解决这一问题。

如果一个方法既有默认值参数又有命名参数会是什么效果?示例如下:

class Test {
    fun m1(j: Int, i: Int = 10) {
    }
}
//测试方法main
fun main(args: Array) {
    Test().m1(1)//正确!j=1,而i使用默认值10
    Test().m1(1, 1)//正确,j=1,i=1
    Test().m1(1, i = 1)//正确
    Test().m1(j = 1, 1)//错误
}

解释下最后两个方法调用为什么一个是正确的,一个是错误的:kotlin规定在同时有可选参数和命名参数的时候,命名参数必须放到可选参数之后。

kotlin还支持可变参数,如下所示:

class Test {
//可变参数使用vararg修饰
    fun m1(vararg args: String) {
        println(args)
    }
}
fun main(args: Array) {
    Test().m1("hello", "word", "test")//可以随意传参
}

kotlin为可变参数提供了一个关键字:vararg。我们都知道java可变参数最终会转换为数字,那么kotlin中的可变参数是不是也是这样?来顺便看下m1方法的字节码:

  public final transient varargs m1([Ljava/lang/String;)V

上面的代码是显而易见的,m1接收了数组类型的入参([Ljava/lang/String即表示字符串数组),所以,kotlin中的可变参数同java一样,最终都会实现为数组。

最后,如果一个方法既有可变参数又有其他的参数,且可变参数不是方法的第一个参数,那么必须要采用命名参数进行传值。

上面的几个例子当中,有些方法有返回值,有些方法没有返回值,这个实际上是根据自己需要来定义的。如果一个方法没有显示的定义返回值,则该方法会默认返回Unit。可以理解为类c语言的void。

当一个方法的方法体中只有一条语句的时候,kotlin允许我们简写该方法的实现,如下所示:

class Test {
  //这是一般的方法写法
    fun sum(i: Int, j: Int): Int {
        return i + j;
    }
//由于sum方法体中只有一个表达式,因此可以简写成如下代码:
fun sum(i: Int, j: Int) : Int = i + j
//甚至可以连返回值都省略,kotlin会为我们自动推断
fun sum(i: Int, j: Int) = i + j
}

中缀表示法(Infix notation)

kotlin还提供了一个关键字:infix,infix可以用来修饰方法,但该方法必须要满足以下条件:

  1. 这类方法必须是成员方法或者扩展方法
  2. 这类方法必须有且只有有一个入参
  3. 这类方法不能有可变方法也不能有默认值
    结合上面规定,我们可以自己实现一个中缀方法,功能是来完成方法计算,示例如下:
//我们为Int类定义了一个中缀方法sum
infix fun Int.sum(i: Int): Int = this + i
fun main(args: Array) {
    println(1 sum 2)//调用sum中缀方法
}

注意,中缀方法的优先级要比运算操作符、类型转换、rangeTo操作符低,意思是当中缀方法和上述几个类型同时存在的时候将会最后生效,这里给出一个例子如下所示:

infix fun Int.sum(i: Int): Int = this + i
fun main(args: Array) {
    println(1 - 1 sum 2)
}

上面代码执行过后会打印2,因为运算符的优先级高于中缀方法

再来看一个中缀成员方法的实现,示例如下:

//自定义一个MyString类
class MyString(private val value: String) {
//实现了一个add中缀成员方法,完成字符串的拼接
        infix fun add(str: MyString) = this.value.plus(str.value)
}
//测试方法main
fun main(args: Array) {
//打印两个MyString对象相加的结果
    println(MyString("hello ") add MyString("word"))
}

上面代码的主要功能是完成两个自定义字符串的相加,其中add就是MyString的中缀方法。中缀方法需要保证receiver和参数类型一致。receiver可以理解为方法所处的类型。

kotlin还支持在方法中定义方法,如下所示:

class Test {
    fun m1() {
        val str2 = "hello "
//在m1中又定义一个方法m2
        fun m2(str: String): String {
//可以看出,内部方法m2可以访问外部方法m1
            return str2.plus(str)
        }
//在m1方法中调用内部的m2方法
        println(m2("word"))
    }
}
//测试方法main
fun main(args: Array) {
    Test().m1()//打印:'hello word'
}

除了上面的本地方法,当然还有一种我们非常熟悉的成员方法,就是在类中定义的方法,这里不再阐述。

尾递归

最后来看一个kotlin中比较有名的尾递归方法。

什么是尾递归?这里先不着急解释,先来看看正常的递归调用,示例如下:

//这是个简单的阶乘计算
fun recursive(initVal: Int): Int {
    if (initVal == 1)
        return 1
    return initVal * recursive(initVal - 1)
}
//测试类main
fun main(args: Array) {
    println(recursive(10000))//计算10000的阶乘,可以运行
    println(recursive(100000))//计算100000的阶乘,会抛出运行时异常!!!
}

上面一段代码存在两个问题,一个是溢出问题,即10000或者100000的阶乘已经远远超过long的表示范围,超过表示范围无非是得不到正确的结算结果,这个不是我们关注的重点。

另一个需要我们关注的问题就是,当计算10000阶乘的时候虽然结果会溢出但是方法的调用至少是正确的,而当计算100000阶乘的时候,发现直接抛出了运行时异常,如下所示:

Exception in thread "main" java.lang.StackOverflowError

这个异常是显而易见的:栈溢出异常!为什么会栈溢出呢?

要理解这个问题,先回顾下方法的执行过程。一个应用程序的执行实际上可以看做是一个个方法入栈出栈的过程。当调用一个方法的时候,会将该方法入栈,当方法执行完毕后,就会执行出栈操作。这个栈可以被称为方法栈,方法栈是有长度限制的(实际上栈也是一块内存区域),当我们入栈的方法长度超过了方法栈的最大限制就会抛出StackOverflowError异常。

那为什么我们平时很少碰到这个异常?这是因为,正常的方法调用都会在该方法执行完成后被清理出栈,因此栈长度一般都会在最大的长度范围之内。

再来看看上面的递归方法,重点在return initVal * recursive(initVal - 1)这一句,很显然,在recursive(initVal)方法返回之前,一定会等待recursive(initVal - 1)方法的返回,所以方法栈就会不断的将recursive方法入栈,直到initVal == 1的时候才会慢慢将入栈的方法出栈。当initVal的值比较小的时候recursive方法的调用次数相对较少,也就会在方法栈的长度限制之内;而当initVal的值比较大时recursive方法的调用次数就会相对增多,直到超出方法栈的长度限制,一旦超出就会抛出StackOverflowError异常。

上面就是常规递归操作存在的问题,而kotlin提供的尾递归就能解决这个问题。kotlin为尾递归提供了一个关键字tailrec,下面就阐述下kotlin中的尾递归。

既然知道了kotlin为我们提供了tailrec关键字,那么是不是就直接在fun前面加上这个关键字就可以了呢?先来看看这种写法,如下所示:

//这里使用了tailrec关键字来表示这是个尾递归方法
tailrec fun tailRec(initVal: Int): Int {
    if (initVal == 1 || initVal == 0)
        return 1
    return initVal * tailRec(initVal - 1)
}
//测试方法main
fun main(args: Array) {
    println(tailRec(100000))//!!!运行时异常!依然会抛出栈溢出异常。
}

运行上面代码,依然会抛出StackOverflowError异常!这是为什么?

这是因为,虽然我们告诉了编译器这个要进行尾递归优化,但是实际上我们的写法却是普通的递归写法,编译器对此也无可奈何!那么如何写才能满足尾递归的写法?示例如下:

//initVal依然是初始值,tempResult表示上一次计算的结果,初始值是1
tailrec fun tailRec(initVal: Int, tempResult: Int): Int {
    if (initVal == 1 || initVal == 0)
        return tempResult
    return tailRec(initVal - 1, initVal * tempResult)
}
//main方法
fun main(args: Array) {
    println(tailRec(100000, 1))//运行正确!不在抛出异常
}

上面就是kotlin中尾递归的正确写法,那么问题来了,既然写法上满足了尾递归的写法,为啥还要加上tailrec关键字?如果我们去掉tailrec关键字会怎么样?答案是必须要用tailrec关键字,如果去掉依然会抛出StackOverflowError的异常,这是为什么?

想解决这个疑问,显然就到了刨根问底的环节了,看字节码!

下面是一个带有尾递归关键字修饰和不带有尾递归关键字修饰的两个方法,需要注意的是,这两个方法都满足尾递归的写法,来对比下二者的不同。

需要对比的源代码

//tailRec使用了关键字tailrec修饰
tailrec fun tailRec(initVal: Int, tempResult: Int): Int {
    if (initVal == 1 || initVal == 0)
        return tempResult
    return tailRec(initVal - 1, initVal * tempResult)
}
//tailRec2没有使用tailrec关键字修饰
fun tailRec2(initVal: Int, tempResult: Int): Int {
    if (initVal == 1 || initVal == 0)
        return tempResult
    return tailRec2(initVal - 1, initVal * tempResult)
}

上面代码生成的字节码如下所示:

// ================MainKt.class =================
// class version 50.0 (50)
// access flags 0x31
public final class MainKt {
//tailRec方法
  // access flags 0x19
  public final static tailRec(II)I
   L0
    LINENUMBER 3 L0
    ILOAD 0
    ICONST_1
    IF_ICMPEQ L1
    ILOAD 0
    IFNE L2
   L1
    LINENUMBER 4 L1
    ILOAD 1
    IRETURN
   L2
    LINENUMBER 5 L2
    ILOAD 0
    ICONST_1
    ISUB
    ILOAD 0
    ILOAD 1
    IMUL
    ISTORE 1
    ISTORE 0
    GOTO L0
   L3
    LOCALVARIABLE initVal I L0 L3 0
    LOCALVARIABLE tempResult I L0 L3 1
    MAXSTACK = 3
    MAXLOCALS = 2

//tailRec2方法
  // access flags 0x19
  public final static tailRec2(II)I
   L0
    LINENUMBER 9 L0
    ILOAD 0
    ICONST_1
    IF_ICMPEQ L1
    ILOAD 0
    IFNE L2
   L1
    LINENUMBER 10 L1
    ILOAD 1
    IRETURN
   L2
    LINENUMBER 11 L2
    ILOAD 0
    ICONST_1
    ISUB
    ILOAD 0
    ILOAD 1
    IMUL
    INVOKESTATIC MainKt.tailRec2 (II)I
    IRETURN
   L3
    LOCALVARIABLE initVal I L0 L3 0
    LOCALVARIABLE tempResult I L0 L3 1
    MAXSTACK = 3
    MAXLOCALS = 2
}

上面字节码主要关注的是tailRec2方法对应字节码中的一句,如下所示:

INVOKESTATIC MainKt.tailRec2 (II)I
IRETURN

显然在tailRec2对应的字节码中又调用了tailRec2自身,最后才执行返回(即IRETURN)。而tailRec对应的字节码却无此调用,而是执行了GOTO L0操作,即又跳到了tailRec方法体继续执行,而不是再次调用tailRec方法。这就是使用tailrec关键字和不使用tailrec关键字的区别。

最后总结下,所谓的尾递归优化实际上是kotlin编译器在编译期间进行的优化,尾递归必须要满足以下两个条件才能被优化:

  1. 必须使用tailrec关键字来修饰。意思是告诉编译器,执行尾递归优化。
  2. 递归的写法必须要满足尾递归的写法,否则即使使用tailrec关键字修饰也没有用。

你可能感兴趣的:(kotlin入门潜修之进阶篇—方法及尾递归原理)