Haskell是功能齐全且非常简洁的语言。 任何曾经尝试用Haskell编写代码的人都会注意到,与用命令式语言编写相同的东西相比,它简洁而优雅。 在我看来,在Java中实现相同目标是不可能的,但是Kotlin允许您朝这个方向发展,并尝试使用功能齐全的样式。 我们可以从3个最著名的函数的开始基础上得出我们可能需要的所有复杂函数:map,filter,reduce。 另外,我创建了一个
存储库 ,您可以研究和查看测试。
在开始之前,我想提一下一个事实,那就是不值得以这种方式来实现功能性方法,因为代码将严重缓慢并且不应在生产应用程序中使用。 当然有改进它的选项,但是本文的目的不是公开这些选项,而是考虑一种替代的编写代码的方法。 无论如何,了解这种方法将有助于您使用递归数据结构,并且您可能会欣赏到代码的读取方式和理解的容易程度。
基本功能
列表在该语言中起着非常重要的作用,并且为此实现了许多有用的功能。 让我们看看其中的一些,以及如何在Kotlin上实现它们。
head (x:_) = x head [] = badHead
如果列表中有元素,则将返回第一个,否则将返回错误。
我们没有机会编写这样的代码,但是总的来说,如果仔细观察,它与模板时非常相似。 我们还将使用扩展功能,以便以后能够在列表上使用此方法,并且有一种更简洁的获取值的方法,而不是像方法调用一样,在末尾没有方括号。
val <T> List<T>.head: T get() = when (this.isEmpty()) { true -> throw NoSuchElementException("List is empty.") false -> this[0] }
为了方便使用递归,我们还希望将列表拆分为第一个元素+所有其他元素。 让我们尝试为此实现tail函数。
这是haskell上的样子:
tail (_:xs) = xs tail [] = errorEmptyList "tail"
不幸的是,Kotlin没有提供开发人员可以用相同样式描述的这种模式匹配级别,因此在这里我们不得不写点时间。
val <T> List<T>.tail: List<T> get() = drop(1)
使用语言库中的函数有点不诚实,但另一方面,无论如何,我们都必须为此方法编写代码,因此最好使用已经可以使用的方法。
现在我们可以将列表划分为第一个元素+列表的其余部分。 我们还需要将列表和一个元素连接在一起的功能,稍后将在列表中进行转换和其他操作时使用这些元素。
operator fun <T> List<T>.plus(x: T): List<T> = ArrayList(this).also { it.add(x) }
现在,我们可以在元素的末尾添加一个列表,并且我们的map函数实现可以正常使用了。 不幸的是,再也没有办法以任何更方便的方式将对象添加到列表中,因此我们使用
add方法。
目前,我们几乎拥有所需的一切。 现在我们唯一需要的是能够描述退出递归的边界条件。 为此,我们将使用标准的
isEmpty()方法。 让我们停下来看看现在我们有什么:
- isEmpty()-列表中是否有任何元素
- head-列表的第一个元素
- tail-没有第一个元素的列表
- list +元素-我们可以将列表与对象连接
实际上,这就是我们需要的所有方法所需要的。
以我的口味,在
when语句中使用列表长度比较会更方便。 Kotlin已经为我们提供了
尺寸 ,以获取此列表的长度。 但是,假设我们要自己实现它。 使用我们的功能,将非常简单:
val <T> List<T>.size: Int get() = when (this.isEmpty()) { true -> 0 false -> 1 + this.tail.size }
基本功能的应用
考虑最常见的例子。 假设我们有一个整数列表,我们想将它们加起来,而忽略循环的存在。 我们所拥有的就是上面派生的方法和递归。 为此,我们将使用与计算列表大小时相同的方法:
fun sum(xs: List<Int>): Int = when (xs.size) { 0 -> 0 else -> xs.head + sum(xs.tail) }
这个想法很简单:如果列表中没有元素,则总和为0;否则为0。 否则,它是第一个元素的和与尾部的和的递归调用。
尽管我们并不关心此代码中的速度和优化,但我们不禁会想起该语言使用尾部递归的功能。 尾递归是递归的一种特殊情况,其中递归调用是从函数返回之前的最后一个操作。 这种递归是值得注意的,因为可以保证允许您重建代码以进行迭代。 如您所知,递归的主要问题是在执行函数期间必须存储调用堆栈,以便在达到边界条件时可以返回并重新计算最终结果。
似乎我们描述的金额的函数就是这样,因为最后一次调用是
sum(xs.tail) 。 但是,事实并非如此。 如果您对代码进行一些不同的描述,它将变得显而易见:
fun sum(xs: List<Int>): Int = when (xs.size) { 0 -> 0 else -> { val head = xs.head val tailSum = sum(xs.tail) head + tailSum } }
现在我们看到,实际上最后一次调用是第一个元素与尾部其余部分的和。
好消息是,如果将
tailrec修饰符添加到函数中,IDE会告诉您该函数不是。 但是,解决此问题非常简单。 纠正函数的常见技巧是使用辅助变量来存储结果。 看起来像这样:
tailrec fun sum(xs: List<Int>, acum: Int): Int = when (xs.size) { 0 -> acum else -> sum(xs.tail, xs.head + acum) }
为了计算元素的总和,只需将0作为第二个参数传递,并且要使其完全惯用,我们将重做一些功能,将主要计算隐藏在内部函数中,而外部世界无需访问该参数即可不需要。
fun sum(xs: List<Int>):Int { tailrec fun sumInner(xs: List<Int>, acum: Int): Int = when (xs.size) { 0 -> acum else -> sumInner(xs.tail, xs.head + acum) } return sumInner(xs, 0) }
有了这些知识,您可以看到我们上面实现的size函数不满足尾递归的必要条件。
现在我们已经准备好使用Kotlin来实现地图,过滤,精简。 稍后我们将看到仅实现后者就足够了,其余的通常是后者的派生。 但是首先是第一件事。
主要功能
地图
此函数的迭代实现涉及使用转换函数在列表中进行顺序移动,并将所有接收到的元素添加到新集合中。 我们将使用边界条件为空列表的递归调用。 然后,实现将如下所示:
fun <T, R> List<T>.map(f: (T) -> R): List<R> = when (this.size) { 0 -> listOf() else -> f(head) + tail.map(f) }
如果原始列表中没有元素,则返回一个空列表,否则,将转换应用于第一个元素,并在列表的其余部分末尾添加一个递归调用。
但是,我们仍然没有连接元素和列表的功能。 但是我们已经意识到了。 首先,我们得出了连接一对列表的更一般的情况,然后使用它为元素添加另一个列表。
operator fun <T> List<T>.plus(xs: List<T>): List<T> = when (xs.size) { 0 -> ArrayList(this) else -> (this + xs.head) + xs.tail } operator fun <T> T.plus(xs: List<T>): List<T> = listOf(this) + xs
筛选条件
实现将非常类似于map。 唯一的区别是您需要了解是否需要将当前元素添加到结果中。 为此,我们将调用作为参数接收的lambda。 该实现将如下所示:
fun <T> List<T>.filter(f: (T) -> Boolean): List<T> = when (this.size) { 0 -> listOf() else -> if (f(head)) head + tail.filter(f) else tail.filter(f) }
如果当前元素满足过滤条件,则将其递归添加到列表的尾部;否则,我们将继续仅使用列表的尾部。
减少
最难理解的功能,同时也是最强大的功能(在功能世界中,它被称为
fold )。 通常,它用于将列表折叠为单个项目。 您有一个确定的起始值
s0 ,还有一个元素列表
a []和一个函数
f ,该函数为该列表的起始值和下一个元素返回一个新值。
f(s0,a [0])= s1 。 因此,我们依次浏览了整个元素列表,并在输出中获得了某种单一值。 一个相当常见的示例是数组元素的总和。 在这种情况下,起始值为0,该函数返回两个元素的和:
f(s,a [i])= s + a [i] 。 考虑一下我们如何递归实现此功能。
fun <T, R> reduce(s: T, xs: List<R>, f: (T, R) -> T): T = when (xs.size) { 0 -> s else -> reduce(f(s, xs.head), xs.tail, f) }
原则上,实现与我们上面回顾的完全相同。 如果列表中没有元素,则返回当前值,否则,我们计算新的第一个元素,然后再次为其调用reduce函数以及列表的尾部。
请注意,我们也可以对此功能进行修改。 例如,不要传递起始值,而是为此使用列表的第一个元素。 要了解reduce并不止于此,请想象我们使用其他列表作为起始值。 在这种情况下,每次迭代时,我们将不存储一个值,而是存储一个列表,因此,我们的功能将大大增加。 例如,让我们尝试以某种方式应用reduce函数,使输出为原始列表:
fun <T> reduceSame(xs: List<T>) = reduce(listOf<T>(), xs) { ys, s -> ys + s }
现在,我想,您猜想我们可以使用reduce作为map的替代实现filter。 由于我们学会了使用reduce返回完全相同的列表,因此我们只需进行很少的更改即可转换每个元素。 对于过滤器,一切都非常相似。
fun <T, R> List<T>.map2(f: (T) -> R): List<R> = reduce(mutableListOf(), this) { xs, s -> (xs + f(s)).toMutableList() } fun <T> List<T>.filter2(f: (T) -> Boolean): List<T> = reduce(mutableListOf(), this) { ys, s -> if (f(s)) return@reduce (ys + s).toMutableList() else ys }
另外,他们经常忘记我们也可以不从列表的开头而是从结尾使用reduce。 当然,我们可以扩展列表,然后应用reduce,但这并不有趣。 让我们尝试编写和理解reduce如何以相反的顺序折叠列表。
fun <T, R> reduceRight(s: T, xs: List<R>, f: (T, R) -> T): T = when (xs.size) { 0 -> s else -> f(reduceRight(s, xs.tail, f), xs.head) }
如果列表不为空,则将函数f应用于折叠列表尾部和列表首部的结果。 因此,第一个元素将最后处理; 倒数第二-第二等。 对于此选项,您还可以添加将列表的最后一个元素用作起始值等的修改。
几乎总是,在使用列表时,可以结合使用这3个功能来获得您感兴趣的结果。
我们还实现
zip功能,该功能将允许我们组合2个列表。
在入口处,我们得到2张清单。 我们想返回一个长度等于原始列表的最小对的列表。
和往常一样,您需要考虑退出递归并编写一个函数。
fun <T, R> zip(xs: List<T>, ys: List<R>): List<Pair<T, R>> { return when (xs.isEmpty() || ys.isEmpty()) { true -> listOf() false -> Pair(xs.head, ys.head) + zip(xs.tail, ys.tail) } }
您可以添加自己的修改,这将使您不必将一对元素应用于两个元素,而无需返回一对元素。 在Haskell中,此函数称为
zipWith 。 它是通过我们非常简单地编写的功能实现的:
fun <T, R, C> zipWith(xs: List<T>, ys: List<R>, f: (T, R) -> C): List<C> = zip(xs, ys).map { f(it.first, it.second) }
通常,在使用功能方法时,当您需要不基于列表中的对象而是基于索引执行操作时,会出现问题。 例如,我们需要对列表的所有偶数元素求和。 您可以尝试通过reduce来实现这一点,将当前值保持为Pair <Int,Boolean>并在flag == true时添加该值,并在每次执行下一步时取负标志。 但是,这看起来不太漂亮,读者必须弄清楚您要用此代码表达的内容。 Kotlin具有无限的序列,对于解决此问题非常有用。 如果我们分析我们想做的事情,结果是我们想过滤掉所有具有奇数索引的元素,并对其余元素求和。 并且为了能够获取索引,只需为列表和
序列 [0,1,2 ..]调用
zip就足够了。
fun sumWithEvenIndexes(xs: List<Int>) = zip(xs, generateSequence(0) { it + 1 }.take(xs.size).toList()) .filter { it.second % 2 == 0 } .map { it.first } .sum()
在Kotlin标准库中,您可以找到序列对的zip函数。
现在,让我们看一个简单的难题,它启发了我编写本指南,以及在Kotlin中以及在Haskell的末尾用命令式语言实现的实现方式。
必须计算整数数组中成对的相邻数字之间的最大数量。 数组的长度大于1,您不必担心求和元素时会溢出。
命令式Java方法:
public Integer maxSum(List<Integer> array) { Integer max = array.get(0) + array.get(1); for (int i = 2; i < array.size(); i++) if (array.get(i) + array.get(i-1) > max) max = array.get(i) + array.get(i-1); return max; }
使用书面函数在Kotlin上的一种函数方法(我建议自己实施max函数作为培训):
fun maxSum(xs: List<Int>) = zipWith(xs, xs.tail, {a, b -> a + b}).max()
Haskell的实现:
maxSum xs = maximum $ zipWith (+) xs (tail xs)
如我们所见,我们在Kotlin上实现的功能(顺便说一句,我们可以使用reduce来解决此问题)与Haskell可以编写的内容非常相似。
结论
无疑,这不应在开发中使用,因为所有操作都是非最优地执行,只是为了演示一种功能方法。 另外,几乎所有编写的内容都在Kotlin标准库中,因此,也许在将来,您将使用Kotlin为我们提供的功能样式,而不是编写另一个for循环。
功能样式中最困难的可能是可以用不同的方法解决问题。 最明显的内容将来可能很麻烦且难以理解,撰写最容易理解的内容可能需要花费时间和认真的思考。 唯一可以帮助您掌握的是不断的练习和训练。
PS:如上所述,您可以看到包含本文中所有示例的
存储库 。 运行测试,看看它如何工作!
PPS:您还可以查看实现类似
功能的替代方法。
并确保稍后查看
https://arrow-kt.io/ 。 在我看来,您不应马上就去那里,因为一切看起来都非常可怕,但是以后,当函子和单子函数不会吓到您时,请务必进行研究。