稍微也提一下Scope函数

在Kotlin里面,经常会用到Scope函数,在一个值上面创建一个临时的作用域来进行各种操作。更具体的说,这是一类定义在任意类型上的扩展函数,接受一个以这个类型作为参数的lambda回调,这样的话,这个值就可以被「注入」进作用域,来实现更简洁的声明式写法了。

Scope函数一般来说有这么几种主要的使用场合:

  • 和可空类型还有nullable调用链结合,去掉繁琐的precondition

    比如说命令式常见的检测if的代码例如这个:

    1
    2
    3
    if (canBeNull != null) {
    doSomething(canBeNull)
    }

    就可以被改写成这样:

    1
    canBeNull?.let { doSomething(it) }

    甚至:

    1
    canBeNull?.let(::doSomething)

    在涉及到多个可空变量或者连续调用的情况下,Scope函数的写法显然更简洁清晰,更「Kotlinic」。

  • 提供一种简洁的声明式链式调用的风格

    假设一种Java里常见的数据提取过程:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    try (ResultSet resultSet = stmt.executeQuery(selectSql)) {
    List<Employee> employees = new ArrayList<>();
    while (resultSet.next()) {
    Employee emp = new Employee();
    emp.setId(resultSet.getInt("emp_id"));
    emp.setName(resultSet.getString("name"));
    emp.setPosition(resultSet.getString("position"));
    emp.setSalary(resultSet.getDouble("salary"));
    employees.add(emp);
    }
    }

    同样的代码在Kotlin里面可以被改造成这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    stmt.executeQuery(selectSql).use { resultSet ->
    val employees = mutableListOf<Employee>()
    while (resultSet.next()) {
    employees.add(Employee().apply {
    id = resultSet.getInt("emp_id")
    name = resultSet.getString("name")
    position = resultSet.getString("position")
    salary = resultSet.getDouble("salary")
    })
    }
    ...
    }

    注意到这里同时结合了applyuse两种不同的Scope函数。因为emp变量被隐含进了上下文,代码看起来简洁很多,而且变量字段的修改被限定进了apply函数里,也避免影响了外层作用域,减轻代码的心智负担。

    use可以在Kotlin里面实现类似C++或C#的RAII的机制,这个之后会提到。

    如果要把Kotlinic延伸到极致的话,这段代码还可以进一步改造,去掉while循环,当然这里就先不讨论了。

    总的来说,这种写法可以算用更Kotlin的方式解决Java中常见的「创建一个空对象然后多次对字段赋值」的命令式模式。

  • 避免耗时运算的多次调用

    另一个例子就是在调用到耗时比较长的函数的时候,例如:

    1
    2
    3
    if (longComputation() != null && longComputation().result > 10) {
    println("The value is ${longComputation().result}")
    }

    同样的代码等价于这样的写法:

    1
    2
    3
    4
    5
    longComputation()?.let { 
    if (it > 10) {
    println("The value is $it")
    }
    }

    当然有人可以说可以在外面提前把运算计算完存进变量,那么实际上Scope函数做的是同一件事情,与此同时,it的值只能在Scope函数内部被调取,可以减少对外层作用域的干扰。这也是Scope的含义,就是提供一个作用域。

  • 模拟类似RAII的语法

    前面提到的use就是一种Scope函数,同时也起到了模拟RAII的语义。大概的作用是,不需要显式地关闭Closeable的资源,而是让use函数来接管资源的关闭并且处理可能的异常。

    use的语法其实很像Java的try-with-resources语句,不过不同的是,比起创建一种新的语法,Kotlin里面只是简单地复用了函数作用和Scope函数的语法,降低了学习成本。

  • 模拟例如C#等语言里的??=语义

    C#里面,??=允许把一个值赋予给可空的变量,如果变量为空。大概是这样:

    1
    ref ??= "backingValue"

    Kotlin里面的话,一般是用takeIf或者run来模拟的:

    1
    2
    ref = ref.takeIf { it != null } ?: "backingValue"
    ref = ref.run { this ?: "backingValue" }

    当然,这种写法其实不太直观,而且也有点hacking的味道。一般来说,大部分人会想到的也就是把赋值语句包含在if子句里了。Kotlin在这方面比起C#,还是有所欠缺的。

本质上,Scope函数是一种定义在泛型类型上的扩展函数,换句话说,这些函数在支持扩展函数语法和泛型的语言上一般都是可以定义出来的。let最简单的写法大概可以是这样:

1
2
3
4
5
6
7
fun <T, U> T.naiveLet(fn: (T) -> U): U = fn(this)

fun callWithNaiveLet() {
val a = 14
val b = a.naiveLet { it + 29 }
b.naiveLet(System.out::println)
}

不过反编译一下的话,会发现可能没那么简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static final Object naiveLet(Object $this$naiveLet, @NotNull Function1 fn) {
Intrinsics.checkNotNullParameter(fn, "fn");
return fn.invoke($this$naiveLet);
}

public static final void callWithNaiveLet() {
int a = 14;
int b = ((Number)naiveLet(Integer.valueOf(a), (Function1)null.INSTANCE)).intValue();
naiveLet(b, (Function1)(new Function1(System.out) {
// $FF: synthetic method
// $FF: bridge method
public Object invoke(Object var1) {
this.invoke(((Number)var1).intValue());
return Unit.INSTANCE;
}

public final void invoke(int p1) {
((PrintStream)this.receiver).println(p1);
}
}));
}

可以发现这里多了几个对象的创建。

实际上,Kotlin的标准库函数也确实很简单,抛开contract(用来保证函数调用的precondition的,不影响函数实际运行)不提的话,只是多了inline关键字和InlineOnly注解。意思是把block这个lambda直接内联进调用处的函数。

1
2
3
4
5
6
7
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}

对比以下的代码和编译结果也可以看出,函数调用的开销被消除了:

1
2
3
4
5
6
7
8
9
10
11
fun callWithInvoke() {
val a = 14
val b = a + 29
System.out.println(b)
}

fun callWithLet() {
val a = 14
val b = a.let { it + 29 }
b.let(System.out::println)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static final void callWithInvoke() {
int a = 14;
int b = a + 29;
System.out.println(b);
}

public static final void callWithLet() {
int a = 14;
int var4 = false;
int b = a + 29;
PrintStream var3 = System.out;
int var5 = false;
var3.println(b);
}

当然,如果过度依赖内联函数的话,生成的字节码也会急剧膨胀。不过一般来说, 对于这种依赖lambda的函数进行内联是没问题的。

这也说明了另一个问题,在没有内联函数或等同的语言机制的其他语言里,直接照搬Scope函数的写法,可能会导致性能的明显下降。

最后也顺便做一下测试好了,用JMH来对比直接调用和套了一层let的区别。虽然刚才得出的结论应该是它们没区别。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(value = 2, jvmArgsAppend = ["-XX:+UseG1GC", "-Xms2g", "-Xmx4g"])
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
open class ScopeBenchmark {

@Param("0", "1", "2", "5", "10", "20", "50", "100", "200", "500", "1000", "2000")
lateinit var size: Integer

@Param("ArrayList", "LinkedList")
lateinit var type: String

lateinit var functionCalls: List<(Int) -> Int>

@Setup
fun setup() {
val random = Random(0)

val _functionCalls: MutableList<(Int) -> Int> = mutableListOf()

for (i in 0 until size.toInt()) {
_functionCalls += { x -> x + random.nextInt(500) }
}

this.functionCalls = _functionCalls
System.gc()
}

@Benchmark
fun functionCallByInvoke(blackhole: Blackhole) {
for (v in functionCalls) {
blackhole.consume(v(1))
}
}

@Benchmark
fun functionCallByScopeFunction(blackhole: Blackhole) {
for (v in functionCalls) {
blackhole.consume(1.let(v))
}
}
}

测试结果:

Benchmark (size) (type) Mode Cnt Score Error Units
ScopeBenchmark.functionCallByInvoke 100 ArrayList avgt 10 0.319 ± 0.001 us/op
ScopeBenchmark.functionCallByInvoke 100 LinkedList avgt 10 0.321 ± 0.004 us/op
ScopeBenchmark.functionCallByInvoke 200 ArrayList avgt 10 0.641 ± 0.007 us/op
ScopeBenchmark.functionCallByInvoke 200 LinkedList avgt 10 0.679 ± 0.007 us/op
ScopeBenchmark.functionCallByInvoke 500 ArrayList avgt 10 1.567 ± 0.008 us/op
ScopeBenchmark.functionCallByInvoke 500 LinkedList avgt 10 1.577 ± 0.030 us/op
ScopeBenchmark.functionCallByInvoke 1000 ArrayList avgt 10 3.137 ± 0.028 us/op
ScopeBenchmark.functionCallByInvoke 1000 LinkedList avgt 10 3.147 ± 0.052 us/op
ScopeBenchmark.functionCallByInvoke 2000 ArrayList avgt 10 6.301 ± 0.087 us/op
ScopeBenchmark.functionCallByInvoke 2000 LinkedList avgt 10 6.240 ± 0.018 us/op
ScopeBenchmark.functionCallByScopeFunction 100 ArrayList avgt 10 0.321 ± 0.001 us/op
ScopeBenchmark.functionCallByScopeFunction 100 LinkedList avgt 10 0.320 ± 0.004 us/op
ScopeBenchmark.functionCallByScopeFunction 200 ArrayList avgt 10 0.663 ± 0.030 us/op
ScopeBenchmark.functionCallByScopeFunction 200 LinkedList avgt 10 0.642 ± 0.006 us/op
ScopeBenchmark.functionCallByScopeFunction 500 ArrayList avgt 10 1.609 ± 0.070 us/op
ScopeBenchmark.functionCallByScopeFunction 500 LinkedList avgt 10 1.592 ± 0.027 us/op
ScopeBenchmark.functionCallByScopeFunction 1000 ArrayList avgt 10 3.160 ± 0.029 us/op
ScopeBenchmark.functionCallByScopeFunction 1000 LinkedList avgt 10 3.160 ± 0.036 us/op
ScopeBenchmark.functionCallByScopeFunction 2000 ArrayList avgt 10 6.313 ± 0.046 us/op
ScopeBenchmark.functionCallByScopeFunction 2000 LinkedList avgt 10 6.323 ± 0.057 us/op

可以看出两种调用方式基本上没啥区别。


稍微也提一下Scope函数
http://inori.moe/2023/09/13/scope-fun/
作者
inori
发布于
2023年9月13日
许可协议