Kotlin基础知识_08-高阶函数_内联函数_noinline_crossinline

Kotlin基础知识_08-高阶函数&内联函数&inline&noinline&crossinline

1. 高阶函数

kotlin中的高阶函数允许传入的函数体来决定函数的执行结果。当需要调用一个高阶函数时,调用方式分为两种:

  • 使用函数引用的方式去调用;
  • 使用Lambda表达式的方式去调用;

1.1 使用函数引用的方式去调用

ex: 根据传入的函数来对视频进行转码:

比如我们需要开发一个视频转码功能时,市面上可能有多种视频转码方案,我们可以将视频转码方案作为函数参数暴露在外面,在实际调用时进行自由切换:

/**
 * 使用FFmpeg对视频进行转码
 *
 * @param videoPath 源视频路径
 * @param outputFmt 输出的视频格式(转码后的视频格式)
 * @return 转码后的文件,若失败则返回<code>null</code>
 */
fun transcodeVideoByFFmpeg(videoPath: String, outputFmt: Int): File {
    //... 模拟进行视频转码
    println("use FFmpeg do video transcoding")
    return File("E:\\output_$outputFmt.mp4")
}

/**
 * 使用GStreamer对视频进行转码
 *
 * @param videoPath 源视频路径
 * @param outputFmt 输出的视频格式(转码后的视频格式)
 * @return 转码后的文件,若失败则返回<code>null</code>
 */
fun transcodeVideoByGStreamer(videoPath: String, outputFmt: Int): File {
    //... 模拟进行视频转码
    println("use GStreamer do video transcoding")
    return File("E:\\output_$outputFmt.mp4")
}

/**
 * 根据传入的转码方案进行视频转码
 *
 * @param videoPath 源视频路径
 * @param outputFmt 输出的视频格式(转码后的视频格式)
 * @param transcodeMethod 调用的转码算法
 * @return 转码后的文件,若失败则返回<code>null</code>
 */
fun transcodeVideo(videoPath: String, outputFmt: Int, transcodeMethod: (String, Int) -> File): File {
    return transcodeMethod(videoPath, outputFmt)
}


fun main() {
    val videoPath = "E:\\HAHAWTF_intro.rmvb"
    val outputFormat = 0 // 0代表MP4

    // 调用FFmpeg进行视频转码
    transcodeVideo(videoPath, outputFormat, ::transcodeVideoByFFmpeg)
    // 调用GStreamer进行视频转码
    transcodeVideo(videoPath, outputFormat, ::transcodeVideoByGStreamer)
}

输出:

use FFmpeg do video transcoding
use GStreamer do video transcoding

注意这里调用 transcodeVideoByFFmpegtranscodeVideoByGStreamer的方式,第三个参数使用了::transcodeVideoByFFmpeg::transcodeVideoByGStreamer这种写法。这是一种函数引用方式的写法,表示将transcodeVideoByFFmpeg()transcodeVideoByGStreamer()函数作为参数传递给transcodeVideo()函数。而由于transcodeVideo()函数中使用了传入的函数类型参数来决定具体的运算逻辑,因此这里实际上就是分别使用了transcodeVideoByFFmpeg()transcodeVideoByGStreamer()函数来对两个数字进行运算。

1.2 使用Lambda表达式的方式去调用

使用函数引用的方式去调用,每次还需要额外定义调用的函数名,函数体。可以使用Lambda表达式的方式对上述code进行简化,修改上述code:

/**
 * 根据传入的转码方案进行视频转码
 *
 * @param videoPath 源视频路径
 * @param outputFmt 输出的视频格式(转码后的视频格式)
 * @param transcodeMethod 调用的转码算法
 * @return 转码后的文件,若失败则返回<code>null</code>
 */
fun transcodeVideo(videoPath: String, outputFmt: Int, transcodeMethod: (String, Int) -> File): File {
    return transcodeMethod(videoPath, outputFmt)
}


fun main() {
    val videoPath = "E:\\HAHAWTF_intro.rmvb"
    val outputFormat = 0 // 0代表MP4

    // 调用FFmpeg进行视频转码
    transcodeVideo(videoPath, outputFormat) { path, outputFmt ->
        //... 模拟进行视频转码
        println("use FFmpeg do video transcoding")
        File("E:\\output_$outputFmt.mp4")
    }
    // 调用GStreamer进行视频转码
    transcodeVideo(videoPath, outputFormat) { path, outputFmt ->
        //... 模拟进行视频转码
        println("use GStreamer do video transcoding")
        File("E:\\output_$outputFmt.mp4")
    }
}

运行:

use FFmpeg do video transcoding
use GStreamer do video transcoding

可以看到,如果使用Lambda表达式的方法,就不用再定义引用函数的函数名或函数体了。这里再复习一下Lambda表达式的调用方式,其实本来的写法应该是这样的:

    // 调用GStreamer进行视频转码
    transcodeVideo(videoPath, outputFormat, { path, outputFmt ->
        //... 模拟进行视频转码
        println("use GStreamer do video transcoding")
        File("E:\\output_$outputFmt.mp4")
    })

即Lambda表达式所在的代码块本应该是调用函数的最后一个参数,但是根据Lambda表达式的使用规则,当Lambda表达式是调用函数的最后一个参数时,可以将其写在调用函数的外面。

1.3 为输入的函数类型参数指定作用范围

有时候可以给输入的函数类型参数指定作用范围,从而可以进一步简化code:

ex: 优化字符串拼接

fun StringBuilder.build(block: StringBuilder.() -> String): String {
    return block()
}

fun main() {
    // 简化StringBuilder的使用
    val result = StringBuilder().build {
        append("Name: Luna\n")
        append("Age: 13\n")
        append("Address: Beijing\n")
        toString()
    }
    println("result: $result")
}

这里输入的参数类型为: StringBuilder.(), 代表该函数类型作用于 StringBuilder类型当中,这样在Lambda表达式中调用 StringBuilder类上的函数时,可以不用写 StringBuilder对象,因为它会自动拥有StringBuilder的上下文, 而我们刚好是为 StringBuilder类添加的扩展函数,所以调用的StringBuilder对象将自动作为Lambda表达式的上下文。

2. 内联函数

2.1 高阶函数的缺陷

高阶函数确实非常神奇,用途也十分广泛,可是你知道它背后的实现原理是怎样的吗?当然,这个话题并不要求每个人都必须了解,但是为了接下来可以更好地理解内联函数这个知识点,我们还是简单分析一下高阶函数的实现原理。

这里仍然使用刚才编写的transcodeVideo()函数来举例,代码如下所示:

/**
 * 根据传入的转码方案进行视频转码
 *
 * @param videoPath 源视频路径
 * @param outputFmt 输出的视频格式(转码后的视频格式)
 * @param transcodeMethod 调用的转码算法
 * @return 转码后的文件,若失败则返回<code>null</code>
 */
fun transcodeVideo(videoPath: String, outputFmt: Int, transcodeMethod: (String, Int) -> File): File {
    return transcodeMethod(videoPath, outputFmt)
}

fun main() {
    val videoPath = "E:\\HAHAWTF_intro.rmvb"
    val outputFormat = 0 // 0代表MP4

    // 调用FFmpeg进行视频转码
    transcodeVideo(videoPath, outputFormat) { path, outputFmt ->
        //... 模拟进行视频转码
        println("use FFmpeg do video transcoding")
        File("E:\\output_$outputFmt.mp4")
    }
}

可以看到,上述代码中调用了transcodeVideo()函数,并通过Lambda表达式打印了结果。这段代码在Kotlin中非常好理解,因为这是高阶函数最基本的用法。可是我们都知道,Kotlin的代码最终还是要编译成Java字节码的,但Java中并没有高阶函数的概念。

那么Kotlin究竟使用了什么魔法来让Java支持这种高阶函数的语法呢?这就要归功于Kotlin强大的编译器了。Kotlin的编译器会将这些高阶函数的语法转换成Java支持的语法结构,上述的
Kotlin代码大致会被转换成如下Java代码:

    public static File transcodeVideo(String videoPath, int outputFmt, Function operation) {
        File file = operation.invoke(videoPath, outputFmt);
        return file;
    }

    public static void main(String[] args) {
        File file = transcodeVideo("E:\\test.rmvb", 0, new Function() {
            public File invoke(String videoPath, int outputFmt) {
                System.out.println("use FFmpeg do video transcoding");
                return new File("E:\\output_$outputFmt.mp4");
            }
        });
    }

考虑到可读性,我对这段代码进行了些许调整,并不是严格对应了Kotlin转换成的Java代码。可以看到,在这里transcodeVideo()函数的第三个参数变成了一个Function接口,这是一种Kotlin内置的接口,里面有一个待实现的invoke()函数。而transcodeVideo()函数其实就是调用了Function接口的invoke()函数,并把videoPathoutputFmt参数传了进去。

在调用transcodeVideo()函数的时候,之前的Lambda表达式在这里变成了Function接口的匿名内部类实现,然后在invoke()函数中实现了打印逻辑,并将结果返回。这就是Kotlin高阶函数背后的实现原理。你会发现,原来我们一直使用的Lambda表达式在底层被转换成了匿名内部类的实现方式。这就表明,我们每调用一次Lambda表达式,都会创建一个新
的匿名内部类实例,当然也会造成额外的内存和性能开销。

为了解决这个问题,Kotlin提供了内联函数的功能,它可以将使用Lambda表达式带来的运行时开销完全消除。

2.2 内联函数 inline

内联函数的用法非常简单,只需要在定义高阶函数时加上inline关键字的声明即可,如下所示:

/**
 * 根据传入的转码方案进行视频转码
 *
 * @param videoPath 源视频路径
 * @param outputFmt 输出的视频格式(转码后的视频格式)
 * @param transcodeMethod 调用的转码算法
 * @return 转码后的文件,若失败则返回<code>null</code>
 */
inline fun transcodeVideo(videoPath: String, outputFmt: Int, transcodeMethod: (String, Int) -> File): File {
    return transcodeMethod(videoPath, outputFmt)
}

加了inline关键字之后,Kotlin编译器会将内联函数中的代码在编译的时候自动替换到调用它的地方,这样也就不存在运行时的开销了。

PS: 将高阶函数声明成内联函数是一种良好的编程习惯,事实上,绝大多数高阶函数是可以直接声明成内联函数的。

2.3 避免某个参数被内联 —— noinline

noinline 的意思很直白:inline 是内联,而 noinline 就是不内联。不过它不是作用于函数的,而是作用于函数的参数

一个高阶函数中如果接收了两个或者更多函数类型的参数,这时我们给函数加上了inline关键字,那么Kotlin编译器会自动将所有引用的Lambda表达式全部进行内联

比如有下面这样一个函数:

inline fun test(block1: () -> Unit, block2: () -> Unit) {
    block1()
    println("The test() method has been called")
    block2()
}

fun main() {
    println("start execute main()")
    test({
        println("This is block1")
    }, {
        println("This is block2")
    })
    println("stop execute main()")
}

由于加了inline关键字,它在实际编译时,会被转换成下面的代码:

fun main() {
    println("start execute main()")
    println("This is block1")
    println("The test() method has been called")
    println("This is block2")
    println("stop execute main()")
}

这本来没有什么问题,但有些情况下,这样写会带来另外的问题。

上面上把block1, block2 作为参数传递,要是作为返回值呢?比如:

inline fun test(block1: () -> Unit, block2: () -> Unit): () -> Unit {
    block1()
    println("The test() method has been called")
    block2()
    return block2
}

按说应会转换成:

fun main() {
    println("start execute main()")
    println("This is block1")
    println("The test() method has been called")
    println("This is block2")
    return println("This is block2")
    println("stop execute main()")
}

不过感觉怪怪的,直接return 了,底下的 println("stop execute main()")都不执行了...

所以其实这个地方是不需要内联的,我可能只是返回一个函数类型的参数给别的地方调用,并不希望所有的Lambda表达式均被内联,全部内联反而有错,这种情况下就可以给不需要内联的参数加上 noinline关键字:

inline fun test(block1: () -> Unit, noinline block2: () -> Unit): () -> Unit {
    block1()
    println("The test() method has been called")
    block2()
    return block2
}

另外,内联函数和非内联函数还有一个重要的区别,那就是内联函数所引用的Lambda表达式中是可以使用return关键字来进行函数返回的,而非内联函数只能进行局部返回。为了说明这
个问题,我们来看下面的例子。

fun printString(str: String, block: (String) -> Unit) {
    println("printString() begin")
    block(str)
    println("printString() end")
}

fun main() {
    println("start execute main()")
    val str = ""
    printString(str) {
        println("Lambda start")
        if (it.isEmpty()) return@printString
        println(it)
        println("Lambda end")
    }
    println("stop execute main()")
}

输出:

start execute main()
printString() begin
Lambda start
printString() end
stop execute main()

这里定义了一个叫作printString()的高阶函数,用于在Lambda表达式中打印传入的字符串参数。但是如果字符串参数为空,那么就不进行打印。注意,Lambda表达式中是不允许直接使用return关键字的,这里使用了return@printString的写法,表示进行局部返回,并且不再执行Lambda表达式的剩余部分代码。

可以看到,除了Lambda表达式中return@printString语句之后的代码没有打印,其他的日志是正常打印的,说明return@printString确实只能进行局部返回。

但是如果我们将printString()函数声明成一个内联函数,那么情况就不一样了,如下所示:

inline fun printString(str: String, block: (String) -> Unit) {
    println("printString() begin")
    block(str)
    println("printString() end")
}

fun main() {
    println("start execute main()")
    val str = ""
    printString(str) {
        println("Lambda start")
        if (it.isEmpty()) return
        println(it)
        println("Lambda end")
    }
    println("stop execute main()")
}

输出:

start execute main()
printString() begin
Lambda start

可以看到 ,当printString()函数变成了内联函数后,在Lambda表达式中使用return关键字时,此时的return就代表返回外层的调用函数,也就是main()函数,因此main()函数剩余的代码就不执行了。

注意:Kotlin 里面 Lambda 表达式里是不允许使用 return的,除非这个 Lambda 是内联函数的参数。

2.4 crossinline

我们先来看代码。这里有一个内联函数,还有一个对它的调用:

image-20231107102917666

假如我往这个 Lambda 表达式里加一个 return:

image-20231107102941836

那这个 return 会结束哪个函数的执行?是它外面的 hello() 还是再往外一层的 main()

按照通常的规则,肯定是结束 hello() 的对吧?因为 hello() 离它近啊,return 所结束的肯定是直接包裹住它的那个函数。可是大家想一想,这个 hello() 是个内联函数对不对?内联函数在编译优化之后会怎么样?会被铺平是不是?而这个调用,在铺平后会变成这样:

image-20231107103103253

那你再看看,return 结束的是哪个函数?是外层的对吧?也就是说,对于内联函数,它的参数中 Lambda 的 return 结束的不是这个内联函数,而是那个调用这个内联函数的更外层的函数。是这个道理吧!

道理是这个道理,但这就有问题了。什么问题?我一个 return 结束哪个函数,竟然要看这个函数是不是内联函数!那岂不是我每次写这种代码都得钻到原函数里去看看有没有 inline 关键字,才能知道我的代码会怎么执行?那这也太难了吧!

这种不一致性会给我们带来极大困扰,因此 Kotlin 制定了一条规则:

Lambda 表达式里不允许使用 return,除非这个 Lambda 是内联函数的参数

那这样的话规则就简单了:

  1. Lambda 里的 return,结束的不是直接的外层函数,而是外层再外层的函数;
  2. 只有内联函数的 Lambda 参数可以使用 return。

注:Lambda 可以用 return@label 的方式来显式指定返回的位置,也就是我们上文提到的局部返回

这样就既消了歧义,也避免了需要反复查看每个函数是不是内联函数的麻烦。

不过,这样还是有问题,比如下面的code:

image-20231107103511596

这里我用 runOnUiThread() 把这个参数放在了主线程执行,这是一种很常见的操作。

但这样也带来了一个麻烦:本来要在调用处最后那行的 return 处结束掉它最外层的 main() 函数的:

image-20231107103701762

但现在因为它被放在了 runOnUiThread() 里,hello() 对它的调用就变成了间接调用。所谓间接调用,直白点说就是它和外层的 hello() 函数之间的关系被切断了(因为真正执行的函数块跑到另外一个线程了)。和 hello() 的关系被切断,那就更够不着更外层的 main()函数了,也就是说这个间接调用,导致 Lambda 里的 return 无法结束最外面的 main() 函数了。

这就表示什么?当内联函数的 Lambda 参数在函数内部是间接调用的时候,Lambda 里面的 return 会无法按照预期的行为进行工作。

这就比较严重了,因为这造成了 Kotlin 这个语言的稳定性的问题了。结果是不可预测的,那怎么办?

Kotlin 的选择依然是霸气一刀切:内联函数里的函数类型的参数,不允许这种间接调用。

image-20231107104015693

那我如果真的有这种需求呢?如果我真的需要间接调用,怎么办?使用 crossinline

crossinline 也是一个用在参数上的关键字。当你给一个需要被间接调用的参数加上 crossinline,就对它解除了这个限制,从而就可以对它进行间接调用了:

image-20231107104107543

不过这就又会导致前面说过的「不一致」的问题,比如如果我在这个 Lambda 里加上一句 return:

image-20231107104140304

它结束的是谁?是包着它的 runOnUiThread(),还是依然是最外层的 main()

对于这种不一致,Kotlin 增加了一条额外规定:

内联函数里被 crossinline 修饰的函数类型的参数,将不再享有Lambda 表达式可以使用 return的福利, 也就是说只要被crossline修饰,就不能再在调用处被return了。所以这个 return 并不会面临「要结束谁」的问题,而是直接就不许这么写。

image-20231107104313209

也就是说,间接调用和 Lambda 的 return,你只能选一个。

2.5 inline, noinline, crossline 总结

  1. inline 可以让你用内联——也就是函数内容直插到调用处的方式来优化代码结构,从而减少函数类型的对象的创建;
  2. noinline 是局部关掉这个优化,来摆脱 inline 带来的「不能把函数类型的参数当对象使用」的限制;
  3. crossinline 是局部加强这个优化,让内联函数里的函数类型的参数可以被当做对象使用。

<完>

参考链接

  1. 朱凯-kotlin 中的inline 与 noinline关键字
posted @ 2023-11-06 17:44  夜行过客  阅读(296)  评论(0编辑  收藏  举报