注册

Kotlin系列之听说这个函数的入参也是函数?

整洁是kotlin语法的一大特性,它主要体现在扩展函数,中缀调用,运算符重载,约定,以及对lambda表达式的活用,其实在用java写Android的时候我们已经遇到过lambda了,比如当你在设置一个控件的点击监听事件的时候


image.png
我们通常会这样写,然后就发现在点击事件的参数部分,代码变灰然后还有一条波浪线,提示说匿名类View.OnClickListener()可以被替换成lambda表达式,所以我们就按照提示将这个点击事件转换成lambda


bindingView.button.setOnClickListener(v -> {});

很简洁的一行代码就生成了,其中v是参数,后面箭头紧跟着一个花括号,花括号里面就是你要写的逻辑代码,相信这个大家都清楚,而在kotlin中,做了进一步简化,它可以将这个lambda表达式放在括号外面,并且可以将参数省略


bindingView.button.setOnClickListener {}

代码更加简洁了,而lambda在kotlin中的表现远远不止这些,还可以将整个lambda作为一个函数的参数,典型的例子就是在使用标准库中的filter,map函数,或者Flow里面的操作符,举个例子,在一个名字的集合中,我们要对这个集合做一个过滤的操作,首字母为s的才可以被输出,代码如下


listOf("shifang","zhaoerzhu","sundashen").filter { it.startsWith("s") }

在这个例子中filter函数就是接收了一个lambda参数,我们将整个lambda表达式显示出来就是这样


listOf("shifang","zhaoerzhu","sundashen").filter { it -> it.startsWith("s") }

所以在kotlin中,将类似于filter这样可以接受lambda或者函数引用作为参数的函数,或者返回值是lambda或者函数引用的函数,称之为高阶函数,这篇文章,会从以下几点慢慢介绍高阶函数



  • 什么是函数类型
  • 如何去调用一个高阶函数
  • 给函数类型设置默认值
  • 返回值为函数类型的高阶函数
  • 内联函数
  • inline,noinline和crossinline修饰符
  • 在lambda中使用return

函数类型


我们刚开始学敲代码的时候,基本都是从数据类型开始学的,什么整数类型,浮点数类型,布尔值类型,都很熟悉了已经,到了kotlin这边,又多出来了一个函数类型,这是啥?我们刚刚说到filter是高阶函数,而入参是函数的才能被叫做是高阶函数,所以我们看看filter这个函数里面长什么样子的


ac1.png
我们看到filter的参数部分,predicate是变量,而冒号后面就是跟的参数类型了,我们终于看到函数类型长啥样了,一个括号,里面跟一个泛型T,其实也就是函数的参数类型,后面一个箭头,箭头后面跟着返回值类型,所以我们声明一个函数类型的变量可以这样做


val findName : (String) -> Boolean
val sum : (Int,Int) -> Int

括号里面就是函数的参数类型跟参数数量,箭头后面是函数的返回值类型,这个时候我们在想一个问题,既然是函数类型,那肯定接受的就是一个函数,我们知道在kotlin中一个函数如果什么也不用返回,那么这个函数的返回值可以用Unit的来表示


fun showMessage():Unit {
println()
}

但通常我们都是省略Unit


fun showMessage() {
println()
}

那是不是函数类型里面,返回值如果是Unit,我们也可以省略呢?这样是不行的,函数类型中就算这个函数什么都不返回,我们也要显示的将返回类型Unit表示出来,同样的,如果函数没有参数,也要指定一个空的括号,表示这个函数无参


val showMessage:() -> Unit

到了这里,我们就已经清楚了为什么在lambda表达式里{x,y -> x+y},或者开头那个例子,filter函数中{ it -> it.startsWith("s"),变量的类型都省略了,那就是因为这些变量类型已经在函数类型的声明中被指定了


当然函数类型也是可以为空的,同其他数据类型一样,当你要声明一个可空的函数类型的时候,我们可以这样做


val sum : (Int,Int) -> Int?

上述代码其实犯了一个错误,它并不能表示一个可空的函数类型,它只能表示这个函数的返回值可以为空,那如何表示一个可空的函数类型呢?我们应该在整个函数类型外面加一个括号,然后在括号后面指定它是可以为空的,像这样


val sum : ((Int,Int) -> Int)?

调用高阶函数


知道了函数类型以后,我们就要开始去手写高阶函数了,比如现在有一个需求,要求编辑框内输入的内容里面只能包含字母以及空格,其他的都要过滤掉,那我们就给String添加一个扩展函数吧,这个函数接受一个函数类型的变量,这个函数类型的参数是一个字符,返回类型是一个布尔值,表示符合条件的字符才可以被输出,我们看下这个函数如何实现


fun String.findLetter(judge:(Char) -> Boolean):String{
val mBuilder = StringBuilder()
for(index in indices){
if(judge(get(index))){
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

内部实现就是这样,对输入的字符串逐个字符进行遍历,通过调用judge函数来判断每个字符,符合条件的就输出,不符合的就过滤掉,高阶函数有了,我们现在去调用它


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' || it == ' ' })
复制代码

整个花括号里面就是一个函数,它作为一个参数传递给findLetter,我们看下运行结果


 I  what is kotlin

完全按照条件输出,这样做的好处就是,如果下次需求变了,要求空格也不能输出,那么我们完全不需要去更改findLetter的代码,只需要更改一下作为函数类型的函数就可以了,就像这样


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' })

运行结果就变成了


I  whatiskotlin

我们再换个例子,刚刚是给String定义了一个类似于过滤作用的函数,现在去定义一个映射作用函数,比如给输入的内容每个字符之间都用逗号隔开,我们该怎么做呢


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit(get(index)))
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

代码与findLetter相似,稍微做了一点变化,我们看到这个高阶函数的入参类型变成了(Char)->String,表示输入一个字符,返回的是一个字符串,函数类型addSplit在这里就充当着一个字符串的角色,我们看下如何去调用这个高阶函数


println("abcdefg".turn { "${it}," })

我们看见turn后面的花括号里面就一个字符串,这个字符串是每个字符后面追加一个逗号,我们看下运行结果


 I  a,b,c,d,e,f,g

函数类型的默认值


对于映射函数turn,我们再改下需求,某些场景下,我们输入什么就希望输出什么,比如用户设置昵称,基本是没有任何条件限制的,我们改造下turn函数,让它可以接收空的函数类型,那这个我们在刚刚函数类型那部分讲过,只需要在整个函数类型外面加个括号,然后加上可空标识就好了,改造完之后turn函数就变成了这样


fun String.turn(addSplit: ((Char) -> String)?): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit?.let { it(get(index)) })
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

看起来没什么问题,但是当你去调用这个turn函数,不传入任何函数类型参数的时候,我们发现代码提示报错了


ac2.png
理由是addSplit这个参数一定要有个值,也就是说必须得传点啥吗?也不一定,我们知道kotlin函数中,参数是可以设置默认值的,那么函数类型的参数当然也可以设置默认值,就算什么也不传,它默认有一种实现方式,这样不就好了吗,我们再改下turn函数


fun String.turn(addSplit: ((Char) -> String)? = { it.toString() }): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这样就不报错了,默认输入啥就输出啥,我们看下运行结果


 I  abcdefg

函数类型作为返回值


刚刚我们举的例子是作为入参的函数类型,现在我们看下作为返回值的函数类型,这个其实我们平时开发当中也经常遇到,比如在一段代码中由于某个或者某几个条件,决定的不是一个值,而是会走到不同的逻辑代码中,这个时候我们脑补下如果这些代码都写在一起那是不是一个函数就显的比较臃肿了,可读性也变差了,所以我们就像return某一个值一样,将一段逻辑代码也return出去,这样代码逻辑就显的清晰很多,我们新增一个combine函数,返回值是函数类型


fun String.combine(): (String) -> String {
val mBuilder = StringBuilder()
return {
mBuilder.append(it)
for (index in indices) {
if (index != indices.last) {
mBuilder.append("${get(index)},")
} else {
mBuilder.append(get(index))
}
}
mBuilder.toString()
}
}

combine不接收任何参数了,返回值变成了(String) -> String,我们现在尝试着调用combine函数看看会输出什么呢


println("abcdefg".combine())

  I  Function1<java.lang.String, java.lang.String>

我们看到并没有输出期望的结果,这个是为什么呢?我再回到代码中看看


image.png
我们发现在返回值代码的边上,标明的这个返回值是一个lambda,并不是一个String,这个也就是函数类型作为返回值造成的结果,返回的是一个函数,函数你不去执行它,怎么可能会有结果呢,所以执行这个函数的方法就是调用invoke


println("abcdefg".combine().invoke("转换字符串:"))

invoke方法我们还是很熟悉的,在java里面去反射某一个类里面的方法的时候,最终去执行这个method就是用的invoke,而kotlin里面的invoke其实还是一个约定,当lambda要去调用invoke函数去执行lambda本身的函数体时,invoke可以省略,直接在lambda函数体后面加()以及参数,至于约定这里就不展开说了,我会另起一篇文章单独讲,所以上面的代码我们还可以这样写


println("abcdefg".combine()("转换字符串:"))

两种写法的运行结果都一样的,结果都是


 I  转换字符串:a,b,c,d,e,f,g

内联函数


lambda带来的性能开销


我们刚刚看到一个lambda的函数需要调用invoke方法才可以执行,那么这个invoke方法从哪里来的呢?凭什么调用它这个函数就可以执行了呢?我们将之前写的代码转换成java找找原因


public static final Function1 combine(@NotNull final String $this$combine) {
final StringBuilder mBuilder = new StringBuilder();
return (Function1)(new Function1() {
public Object invoke(Object var1) {
return this.invoke((String)var1);
}

@NotNull
public final String invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it");
mBuilder.append(it);
int index = 0;
for(int var3 = ((CharSequence)$this$combine).length(); index < var3; ++index) {
if (index != StringsKt.getIndices((CharSequence)$this$combine).getLast()) {
mBuilder.append("" + $this$combine.charAt(index) + ',');
} else {
mBuilder.append($this$combine.charAt(index));
}
}
String var10000 = mBuilder.toString();
return var10000;
}
});
}

通过反编译我们看到,原来这个lambda表达式就是定义了一个回调方法是invoke的匿名类Function1,Function后面跟着的1其实就是参数个数,我们点到Function1里面看看


public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}

现在我们知道刚刚没有调用invoke方法的时候,为什么会输出那一段信息了,其实那个就是把整个接口名称输出打印出来,只有调用了invoke这个回调方法,才会真正的去执行逻辑代码,把真正的结果输出,与此同时,我们注意到在反编译代码中,每一次调用turn函数,都会生成一个Function1的对象,如果被多次调用的话,很容易会造成一定的性能损耗,针对这种情况,我们应该怎么去避免呢


inline


针对lamnda带来的性能开销,kotlin里面会使用inline修饰符去解决,用法也很简单,只要在高阶函数的最前面用inline去修饰就好了,我们新增一个inlineturn函数,与turn函数相似,只是用inline去修饰


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

inline fun String.inlineturn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

两个函数的代码基本相似,只是inlineturn函数是用inline修饰的,在kotlin里面,对这种用inline修饰的高阶函数称之为内联函数,我们去调用下这两个函数,然后反编译看看有什么区别吧


kotlin代码
println("abcdefg".turn { "${it}," })
println("abcdefg".inlineturn { "${it}," })

反编译后的java代码
String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var6 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var6; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) {
char it = $this$inlineturn$iv.charAt(index$iv);
int var8 = false;
String var10 = "" + it + ',';
mBuilder$iv.append(var10);
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到turn方法不出所料,每次调用都会生成一个Function1的对象,而inlineturn函数反编译后我们发现,这不就是将invoke方法里面的代码复制出来放到外面来执行吗,所以现在我们知道内联函数的工作原理了,就是将函数体复制到调用处去执行,而此时,内联函数inlineturn的函数类型参数addSplit就不再是一个对象,而只是一个函数体了


noinline和crossinline


我们现在已经有了一个概念了,inline修饰符什么时候适合使用



  • 当函数是一个高阶函数
  • 由于编译器需要将内联函数体代码复制到调用处,所以函数体代码量比较小的时候适合用inline修饰

但有些场景下,即使函数是高阶函数,也是不推荐使用inline修饰符的,比如说你的函数类型参数需要当作对象传给其他普通函数


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
turnAnother(addSplit)//这一行编译报错
return mBuilder.toString()
}

还有一种场景就是当你的函数类型参数是可空的


inline fun String.inlineturn(addSplit: ((Char)->String)?): String {//参数部分编译报错
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这两段代码都会编译报错,而报错的信息也基本一致,信息当中都会有这一句提示



Add 'noinline' modifier to the parameter declaration



到了这里我们遇到了一个新的修饰符noinline,从字面意思上并联系上下文,我们知道了这个noinline的作用,就是在内联函数中,使用noinline修饰的函数类型参数可以不参与内联,它依然是一个对象,反编译的时候它依然会被转成一个匿名类,尽管它是在一个内联函数中。
我们使用noinline修饰符更改一下inlineturn函数,然后再反编译看看java代码中的区别


String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
Function1 addSplit$iv = (Function1)null.INSTANCE;
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var7 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var7; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) { mBuilder$iv.append((String)addSplit$iv.invoke($this$inlineturn$iv.charAt(index$iv)));
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
StringKt.turnAnother(addSplit$iv);
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到原本是将函数体复制出来的地方,现在变成了生成一个Function1的对象了,说明addSplit对象已经不参与内联了,而这个时候我们注意到了,inlineturn函数前面的inline修饰符有了一个警告,提示说这个修饰符已经不需要了,建议去掉


image.png
对于这种警告我觉得还是不能去忽略的,因为我们已经在反编译的代码中看到了,尽管addSplit不参与内联,但还是会将函数体的代码复制出来,对于编译器来讲还是会有损耗的,所以这种情况下还是把inline和noinline修饰符去掉,让它变成一个普通的高阶函数


现在我们再换个场景,有时候一个函数类型的对象它执行起来比较耗时,我们不能让它在主线程运行,那就必须在将这个对象套在一个线程里面运行


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
Runnable{
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))//addSplit这边编译报错
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

我们发现这边又编译报错了,内联函数怎么回事啊?事儿这么多。。。我们看下这次报错提示是什么



Can't inline 'addSplit' here: it may contain non-local returns. Add 'crossinline' modifier to parameter declaration 'addSplit'



意思是不能对addSplit进行内联,原因是调用函数类型addSplit的地方与内联函数inlineturn属于不同的域,或者在inlineturn里面调用addSplit属于间接调用,所以在kotlin里面,如果内联函数中调用的函数类型,与内联函数本身属于间接调用的关系,那么函数类型前面需要加上crossinline修饰符,表示加强内联关系,我们修改一下inlineturn函数,给addSplit加上
crossinline修饰符,代码就变成了


inline fun String.inlineturn(crossinline addSplit: ((Char)->String)): String {
val mBuilder = StringBuilder()
Runnable {
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

学到这里我相信不少人已经对高阶函数有了一个比较清晰的了解了,其实我们在学习Flow的时候已经接触过这些高阶函数和内联函数了,比如我们看下map操作符里面


image.png
map就是一个内联函数,而它里面的transform参数就是一个被crossinline修饰的函数类型的挂起函数,因为map里面的函数体必需要运行在一个协程域里面,而map又是运行在另一个协程域里面,map与transform之间属于间接调用的关系,这才用crossinline修饰


在lambda中使用return


现在给String再增加一个扩展函数,功能很简单,遍历String里面的每个字符,然后将字符在lambda的参数里面打印出来,同时要求如果遍历到字母,那么就停止打印。


fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

代码大概就是这样去实现,但是我们发现写完代码后编译器在return的那个地方报错了,提示说这里不允许使用return



'return' is not allowed here



这个是什么原因呢,kotlin官方文档中有这么一段描述



要退出一个 lambda 表达式,我们必须使用一个标签,并且在 lambda 表达式内部禁止使用裸 return,因为 lambda 表达式不能使包含它的函数返回



kotlin为什么要这么设计呢?我们结合上面讲到的内联函数就清楚了,因为当我们在filterAndPrint函数里面return,退出的函数完全取决于它是不是内联函数,如果是,我们知道编译器会讲函数复制到外面调用处的位置,那么return的就是test函数,而如果不是内联,那么退出的就是filterAndPrint本身,所以对于这么一种可能会导致冲突的作法,kotlin就限制了在普通lambda表达式里面不能使用return,如果一定要用,必需加上标签,也就是在return后面加上@以及lambda所在的函数名,我们更改一下上面的test函数


private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return@filterAndPrint
}
println(it)
}
println("outside of foreach")
}

加上标签以后编译器不报错了,我们看下运行结果


 I  1
I 5
I 3
I 6
I 6
I 7
I outside of foreach

我们看到reutrn@filterAndPrint的时候并没有跳出test函数,只是跳过了a,继续循环打印后面的字符,这个就很想java里面continue的作法,但我们的需求不是这样描述的,我们希望遇到字母以后就不打印后面的字符了,也就是直接跳出test函数,没错,就是将filterAndPrint变成内联函数就好了


inline fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

当lambda所在函数是内联函数的时候,lambda内部是可以return的,而且可以不用加标签,这个时候退出的函数就是调用内联函数所在的函数,也就是例子中的test(),我们把这种返回称为非局部返回,我们看下现在的运行结果


 I  1
I 5
I 3

现在这个才是我们想要的结果,现在回想一下当初刚开始学kotlin的时候,对没有break和continue关键字还有点不习惯,现在知道kotlin把这俩关键字去掉的原因了,因为完全不需要,一个return加上内联函数就够了,想在哪个地方退出循环就在哪个地方退出。


总结


这篇文章我们逐步从函数类型开始,慢慢的认识了高阶函数,会去写高阶函数,也掌握了inline,noinline,crossinline这些修饰符的作用以及使用场景,如果说之前你对高阶函数还很陌生的话,那么通过这篇文章,应该会对它熟悉一点了


作者:Coffeeee
链接:https://juejin.cn/post/7194214376352514105
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册