想要一个 break
相较于原生的 for
关键字, kotlin (包括 1.8 版本后的 Java) 为集合引入了符合函数式编程范式的 forEach
方法,提升了代码编写的体验。
1 2 3 4 5 val data = List(5 ) { it.toString() }data .forEach { println("Parsing $it " ) val parsedData = it.toInt() }
但是,随着业务发展,我发现需要在以前的 forEach
循环中加入条件,满足某些条件后直接退出循环,也就是说需要一个 break
关键字。
forEach
有没有 break
查看 kotlin 库中 forEach
的源码会发现, forEach
并不是一个原生的关键字,而是库为我们包装好的内联函数:
1 2 3 public inline fun <T> Iterable<T> .forEach (action: (T ) -> Unit ) : Unit { for (element in this ) action(element) }
而我们在花括号中传入的代码块最后会被处理为 lambda 函数传入,在其内部的 for
语句中实现循环;而在一段函数中当然不能中断函数外的循环了。
怎么实现一个 break
既然库中没有提供一个 break
关键字,那么我们当然要自己造一个轮子出来了。
1. 返回这个方法 查看源码后的第一反应,满足中断条件就应当是不执行后面的处理逻辑,那么直接使用返回不就好了吗?
1 2 3 4 5 6 7 8 9 val data = List(5 ) { it.toString() }var parsed = 0 data .forEach { if (it.toInt() > 2 ) { return @forEach } parsed += 1 } assertEquals(3 , parsed)
然而仔细考虑就会发现这种其实很有问题:循环并不是真正地被中断,完整地走完了循环,只是部分数据没有进行操作而已:
1 2 3 4 5 6 7 8 9 10 11 12 val data = List(5 ) { it.toString() }var before = 0 var after = 0 data .forEach { before += 1 if (it.toInt() > 3 ) { return @forEach } after += 1 } assertEquals(3 , after) assertEquals(3 , before)
这是 Continue
, 不是 break
。
2. 暴力中断 break
中断本质是想要从循环体中直接退出到循环外,那么最简单的办法…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class LoopBreakException : Throwable ()val data = List(5 ) { it.toString() }var before = 0 var after = 0 try { data .forEach { before += 1 if (it.toInt() > 2 ) { throw LoopBreakException() } after += 1 } } catch (e: LoopBreakException) { } assertEquals(4 , before) assertEquals(3 , after)
又不是不能用.jpg ,系统中断不也是这个思路吗?
3. 不提供不需要处理的数据 既然 不处理不想要的数据 行不通,那么我们应当换一个思路,考虑 不提供不需要处理的数据 这种做法。
首先复习一下 kotlin 给我们提供的集合的操作方法,知道它是可以通过链式调用 map
filter
takeWhile
等方式对数据进行预处理的,而 takeWhile
就是我们需要的:
1 2 3 4 5 6 7 8 9 10 11 12 val data = List(5 ) { it.toString() }var before = 0 var after = 0 data .takeWhile { before += 1 it.toInt() <= 2 }.forEach { it.toInt() after += 1 } assertEquals(3 , after) assertEquals(4 , before)
可以看到循环次数确实有了减少。
但是在示例中,为了判断是否中断需要解析数据(String.toInt()
),在实际处理中又解析了一次。为了减少重复代码和处理消耗,我们应该充分利用链式调用,提前处理好数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 val data = List(5 ) { it.toString() }var parsing = 0 var before = 0 var after = 0 data .map { parsing += 1 it.toInt() }.takeWhile { before += 1 it <= 2 }.forEach { doYourBiz(it) after += 1 } assertEquals(3 , after) assertEquals(4 , before) assertEquals(5 , parsing)
在这里会发现,因为数据处理发生在 takeWhile
之前,而 Collection
的操作是先循环执行完前一段,再用结果作为新的集合去循环执行下一段,显然会有时间与内存上的消耗。
这里可以用 Sequence
做进一步的优化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 val data = List(5 ) { it.toString() }var parsing = 0 var before = 0 var after = 0 data .asSequence() .map { parsing += 1 it.toInt() }.takeWhile { before += 1 it <= 2 }.forEach { doYourBiz(it) after += 1 } assertEquals(3 , after) assertEquals(4 , before) assertEquals(4 , parsing)
对 Collection
和 Sequence
的链式处理逻辑感兴趣的同学,可以自行将 parsing before after 打印出来,观察其中的区别。
总结:高效又优雅地实现中断 归根结底,一开始想要在 forEach
中使用 break
是自己的观念没有转变好:在这种链式调用处理数据的函数式编程场景,仍然抱着指令式编程的观念。在这种情况下应该细分所需的业务逻辑,将其归类分层,才能写出简洁直观的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 for (data in dataList) { val parsed = parseData(data ) if (shouldBreak(parsed)) { break } else { yourBiz(parsed) } } dataList.asSequence() .map { parseData(it) }.takeWhile { !shouldBreak(it) }.forEach { yourBiz(it) }
Java 1.8 后提供的 stream
流也有上述类似的概念,本文也可供参考。
进阶:中断前的特殊处理 上面讨论的情况是满足 break
条件后即退出,然而现实的需求总会超过你最坏的想象。
考虑这样一个场景:满足需要中断条件的数据并不是 不需要处理的第一个数据 , 而是 需要另一种处理方式的最后一个数据 :
1 2 3 4 5 6 7 8 9 for (data in dataList) { val parsed = parseData(data ) if (shouldBreak(parsed)) { anotherBiz(parsed) break } else { yourBiz(parsed) } }
1. 将中断数据纳入数据流 首先我们需要考虑的,是将这个特殊的中断数据加入到数据流中。思路是在 takeWhile
遇到中断数据时仍然返回 true
,然后在随后的第一个数据里返回 false
,这需要一个变量帮助:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 var breakFlag = false dataList .asSequence() .map { parseData(it) }.takeWhile { val tmpBreak = breakFlag breakFlag = !shouldBreak(it) !tmpBreak }.forEach { if (shouldBreak(it)) { anotherBiz(it) } else { yourBiz(it) } }
在这种实现方式中,shouldBreak()
被调用了 2n + 1 次,如果这个判断也比较耗时的话,可以有进一步的优化。
2. 将判断结果与数据绑定 上一节的代码中,每个有效数据都被判定了 2 次,想要节省这部分的时间,那么需要将第一次的判定结果保存下来,可以考虑的方式有:
在原数据结构中增加一个标识位字段;
定义一个新的数据类同时保存判定结果和数据;
使用 kotlin
提供的 Pair
。
在这里使用3是最方便的:
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 var parsing = 0 var bizCall = 0 var anoBizCall = 0 fun parseData (data : String ) : Int { parsing += 1 return data .toInt() } fun shouldBreak (data : Int ) = data == 2 fun yourBiz (data : Int ) { bizCall += 1 } fun anotherBiz (data : Int ) { anoBizCall += 1 } val dataList = List(5 ) { it.toString() }var breakFlag = false dataList .asSequence() .map { val data = parseData(it) shouldBreak(data ) to data }.takeWhile { (lastData, data ) -> val tmpBreak = breakFlag breakFlag = lastData !tmpBreak }.forEach { (dataFlag, data ) -> if (dataFlag) { anotherBiz(data ) } else { yourBiz(data ) } } assertEquals(4 , parsing) assertEquals(2 , bizCall) assertEquals(1 , anoBizCall)
3. 如何避免 if
的使用 2 中的代码在 forEach
块中使用了条件语句,我们也可以尝试在代码中将其去除,使用到的是 partition
:
1 2 3 4 5 6 7 8 9 10 11 12 13 var breakFlag = false val (commonData, lastData) = dataList .asSequence() .map { val data = parseData(it) shouldBreak(data ) to data }.takeWhile { (lastData, data ) -> val tmpBreak = breakFlag breakFlag = lastData !tmpBreak }.partition { (lastData, data ) -> !lastData } commonData.forEach { yourBiz(it.second) } lastData.map { (_, data ) -> data }.forEach { anotherBiz(it) }
虽然我们尝试的是去除 if
,但在原本的数据流中也确实混杂了两种需要分别处理的数据,虽然可以使用 partition
进行分流然后分别处理,但这里将其结果取出然后分别遍历处理后,也实在是算不上是一次连贯完整的链式调用了。与使用条件语句相比,我也判断不出两种方式孰优孰劣,就由读者自行结合业务判断了。
总结 如何将一坨 for
循环代码转换成优雅高效的 forEach
与链式调用结合起来,十分考验开发人员对于业务逻辑的归纳提炼能力,具体的实践其实与业务关系很大,免不了具体情况具体分析,而本文也仅仅是提供一些思路,希望能有所启发。