Статьи

Kata — реализация функциональной структуры данных List в Kotlin

Я видел упражнение в главе 3 превосходной книги « Функциональное программирование в Scala», которое посвящено определению функциональных структур данных и использует связанный список в качестве примера того, как приступить к разработке такой структуры данных. Я хотел попробовать этот образец, используя Kotlin, чтобы увидеть, в какой степени я могу воспроизвести образец.

Скелет scala образца доступен в коде компаньона для книги здесь, и моя попытка в Kotlin сильно вдохновлена ​​(скопирована!) Ключом ответа в хранилище.

основной

Вот как выглядит базовое представление List в Kotlin:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
sealed class List<out A> {
 
    abstract val head: A
 
    abstract val tail: List<A>
}
 
data class Cons<out T>(override val head: T, override val tail: List<T>) : List<T>()
 
object Nil : List<Nothing>() {
    override val head: Nothing
        get() {
            throw NoSuchElementException("head of an empty list")
        }
 
    override val tail: List<Nothing>
        get() {
            throw NoSuchElementException("tail of an empty list")
        }
}

Список был определен как запечатанный класс, это означает, что все подклассы запечатанного класса будут определены в одном файле. Это полезно для сопоставления с образцом в типе экземпляра и будет появляться в большинстве функций.

Есть две реализации этого списка —

1. Минусы непустого списка, состоящего из элемента head и хвостового списка,

2. Ноль пустой список

Это уже очень полезно в его текущей форме, рассмотрим следующее, которое создает список и извлекает из него элементы:

1
2
3
4
5
6
val l1:List<Int> = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))
assertThat(l1.head).isEqualTo(1)
assertThat(l1.tail).isEqualTo(Cons(2, Cons(3, Cons(4, Nil))))
 
 
val l2:List<String> = Nil

Соответствие шаблону с выражением «когда»

Теперь перейдем к реализации некоторых методов List. Поскольку List является запечатанным классом, он позволяет обеспечить хорошее сопоставление с образцом, скажем, для получения суммы элементов в List:

1
2
3
4
5
6
fun sum(l: List<Int>): Int {
    return when(l) {
        is Cons -> l.head + sum(l.tail)
        is Nil -> 0
    }
}

Компилятор понимает, что Cons и Nil — это единственные два пути для совпадения в экземпляре списка.

Немного более сложная операция: «удалить» некоторое количество элементов из начала списка и «dropWhile», который принимает предикат и удаляет элементы с начала, соответствующие предикату:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
fun drop(n: Int): List<A> {
    return if (n <= 0)
        this
    else when (this) {
        is Cons -> tail.drop(n - 1)
        is Nil -> Nil
    }
}
 
val l = list(4, 3, 2, 1)
assertThat(l.drop(2)).isEqualTo(list(2, 1))
 
fun dropWhile(p: (A) -> Boolean): List<A> {
    return when(this) {
        is Cons -> if (p(this.head)) this.tail.dropWhile(p) else this
        is Nil -> Nil
    }
}
 
val l = list(1, 2, 3, 5, 8, 13, 21, 34, 55, 89)
assertThat(l.dropWhile({e -> e < 20})).isEqualTo(list(21, 34, 55, 89))

Они демонстрируют силу сопоставления с образцом с выражением «когда» в Kotlin .

Небезопасная дисперсия!

Чтобы коснуться складки, посмотрите, как список определен с параметром типа, который объявлен как «out T», это называется «декларация сайта дисперсии», что в этом случае делает список ко-вариантным для типа T. Объявление сайта дисперсия красиво объясняется с документацией Kotlin . Благодаря тому, как List объявлен, он позволяет мне делать что-то вроде этого:

1
2
val l:List<Int> = Cons(1, Cons(2, Nil))
val lAny: List<Any> = l

Теперь рассмотрим функцию «добавить», которая добавляет другой список:

1
2
3
4
5
6
fun append(l: List<@UnsafeVariance A>): List<A> {
    return when (this) {
        is Cons -> Cons(head, tail.append(l))
        is Nil -> l
    }
}

здесь второй список берется в качестве параметра для функции добавления, однако Kotlin будет отмечать этот параметр — это потому, что можно возвращать ко-вариантный тип, но не принимать его в качестве параметра. Однако, поскольку мы знаем, что список в его текущей форме неизменен, я могу обойти это, пометив параметр типа аннотацией «@UnsafeVariance».

Раскладной

Операции складывания позволяют «свернуть» список в результат, основанный на некоторой агрегации по отдельным элементам в нем.

Рассмотрим foldLeft:

01
02
03
04
05
06
07
08
09
10
fun <B> foldLeft(z: B, f: (B, A) -> B): B {
    tailrec fun foldLeft(l: List<A>, z: B, f: (B, A) -> B): B {
        return when (l) {
            is Nil -> z
            is Cons -> foldLeft(l.tail, f(z, l.head), f)
        }
    }
 
    return foldLeft(this, z, f)
}

Если список должен состоять из элементов (2, 3, 5, 8), то foldLeft эквивалентен «f (f (f (f (z (2), 3), 5), 8)»

С этой функцией более высокого порядка функция суммы может быть выражена следующим образом:

1
2
val l = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))
assertThat(l.foldLeft(0, {r, e -> r + e})).isEqualTo(10)

FoldRight выглядит следующим образом в Kotlin:

1
2
3
4
5
6
fun <B> foldRight(z: B, f: (A, B) -> B): B {
    return when(this) {
        is Cons -> f(this.head, tail.foldRight(z, f))
        is Nil -> z
    }
}

Если список должен состоять из элементов (2, 3, 5, 8), тогда foldRight эквивалентен «f (2, f (3, f (5, f (8, z))))»

Эта версия foldRight, хотя внешний вид кулера не является хвостовой рекурсией, может быть реализована более дружественная к стеку версия с использованием ранее определенной хвостовой рекурсивной foldLeft путем простого обращения к List и внутреннего вызова foldLeft следующим образом:

1
2
3
4
5
6
7
fun reverse(): List<A> {
    return foldLeft(Nil as List<A>, { b, a -> Cons(a, b) })
}
 
fun <B> foldRightViaFoldLeft(z: B, f: (A, B) -> B): B {
    return reverse().foldLeft(z, { b, a -> f(a, b) })
}

карта и плоская карта

map — это функция, которая преобразует элемент этого списка:

1
2
3
4
5
6
fun <B> map(f: (A) -> B): List<B> {
    return when (this) {
        is Cons -> Cons(f(head), tail.map(f))
        is Nil -> Nil
    }
}

Пример использования этой функции:

1
2
3
val l = Cons(1, Cons(2, Cons(3, Nil)))
val l2 = l.map { e -> e.toString() }
assertThat(l2).isEqualTo(Cons("1", Cons("2", Cons("3", Nil))))

Вариант карты, в котором функция преобразования возвращает другой список, а окончательные результаты сглаживают все, что лучше всего продемонстрировать на примере после реализации:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
fun <B> flatMap(f: (a: A) -> List<@UnsafeVariance B>): List<B> {
    return flatten(map { a -> f(a) })
}
 
companion object {
    fun <A> flatten(l: List<List<A>>): List<A> {
        return l.foldRight(Nil as List<A>, { a, b -> a.append(b) })
    }
}
 
 
val l = Cons(1, Cons(2, Cons(3, Nil)))
 
val l2 = l.flatMap { e -> list(e.toString(), e.toString()) }
 
assertThat(l2)
        .isEqualTo(
                Cons("1", Cons("1", Cons("2", Cons("2", Cons("3", Cons("3", Nil)))))))

Это охватывает основы, связанные с реализацией структуры функциональных списков данных с использованием Kotlin, было несколько грубых моментов по сравнению с версией scala, но я думаю, что это в основном работает. По общему признанию, образец может быть значительно улучшен, если у вас есть какие-либо замечания о том, как улучшить код, пожалуйста, пришлите мне PR на
GitHub репо для этого образца или как комментарий к этому сообщению.

См. Оригинальную статью здесь: Kata — реализация функциональной структуры данных List в Kotlin.

Мнения, высказанные участниками Java Code Geeks, являются их собственными.