Функциональные программы проще понимать

December 22, 2022

Последние несколько дней перечитывают блог (кто бы мог подумать, чтоб блоги тоже можно перечитывать) Тэда Каминского. Во-первых, в очередной раз настоятельно рекомендую его почитать - там очень много очень мудрых мыслей, мужик прям понимает суть программирования.

Во-вторых, там снова откопал пост, который объясняет почему ФП проще.

Автор берёт за аксиому то, что понимать программы сильно сложнее, когда они используют подпрограммы (методы, функции, процедуры), ссылки/указатели и изменяемое состояние. Их комбинация лишает разрботчика возможности понять что делает подпрограмма, глядя только на саму подпрограмму (и декларации вызываемых подпрограмм). Уберите любое из трёх - и такая возможность появляется. ФП убирает изменяемое состояние. И делает возможным понять что делает подпрограмма, глядя только на саму подпрограмму. Что проще чем, изучить всё поддерево вызовов.

ч.т.д.

Осталось только доказать аксиому. Я не могу привести формального доказательства этой аксиомы, но небольшой примерчик в её пользу - вполне:)

Возьмём программу:

fun main() {
    val els: ArrayList<Int> = arrayListOf(2, 2)
    val sum = sum(els)
    println("Сумма ${els[0]} + ${els[1]} = $sum")
}

fun sum(els: ArrayList<Int>): Int = TODO()

Что мы можем сказать про поведение этой программы? Да ничего. Даже если вынести за скобки то, что прямо сейчас она вылетит с исключением (так реализована TODO) - всё равно ничего.

Потому что sum может быть реализована например так:

fun sum(els: ArrayList<Int>): Int {
    var sum = 0
    while (els.isNotEmpty()) {
        sum += els.remove(0)
    }
    return 0
}

В результате у нас будет IndexOutOfBoundsException. И не надо закатывать глаза, мол это синтетический пример - у меня был баг в проде из-за подобного кода. Возможно именно с тех пор я такой - я был жертвой такого сюрприза и внёс баг вызвав казалось бы безопасную функцию.

Теперь давайте давайте посмотрим, что будет если убрать любую из трёх штук из начала поста.

Убираем подпрограммы - адская реализация sum попадает в локальную область видимости, всё встаёт на свои места.

Убираем изменяемое состояние или заменяем передачу списка по ссылке на передачу по значению - и мы уже можем быть уверены в том, что наша программа выведет "Сумма 2 + 2 = $sum".

Понятно, что в Котлине (не чистом функциональном языке) даже если мы передадим в sum неизменяемый массив, нам в ответ всё равно может прилететь исключение или функция может потихоньку продовую базу дропнуть. Но если мы на уровне соглашений и ревью поделим код на императивный и чистый и максимум кода будем делать чистым, то мы существенно обезопасим себя от таких сюрпризов.

При том первый пример с инлайном показывает, что локальное изменяемое состояние не нарушает локальности рассуждений и вполне допуситмо. А моя практика показывает, что некоторые задачи решаются проще с помощью изменяемого состояния.

Например, такую функцию без состояния:

private val signals = Signal.values()
private infix fun Int.hasBit(n: Int) = this shr n and 1 == 1

fun calculateHandshake(n: Int) =
        signals
            .filterIndexed { i, _ -> n hasBit i }
            .let { if (n hasBit signals.size) it.reversed() else it }

намного сложнее понять, чем её аналог с состоянием:

fun calculateHandshake(number: Int): List<Signal> {
    val signalsList = mutableListOf<Signal>()
    if (number and 1 == 1) signalsList.add(Signal.WINK)
    if (number and 2 == 2) signalsList.add(Signal.DOUBLE_BLINK)
    if (number and 4 == 4) signalsList.add(Signal.CLOSE_YOUR_EYES)
    if (number and 8 == 8) signalsList.add(Signal.JUMP)
    if (number and 16 == 16) signalsList.reverse()
    return signalsList
}

Итого - используйте функциональную архитектуру, при необходимости в реализации чистых функций используйте изменяемое состояние и будет вам счастье.