Функциональные программы проще понимать
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
}
Итого - используйте функциональную архитектуру, при необходимости в реализации чистых функций используйте изменяемое состояние и будет вам счастье.