Введение
Среди особенностей моего подхода к разработке у моих заказчиков, коллег и студентов наибольшее сопротивление вызывает использование Spring Data JDBC, а не [Spring Data] JPA (де-факто стандарта работы с БД на платформе Java).
Изначально я собирался писать пост "Почему не JPA", но немного подумав понял, что ответ умещается в одно предложение: потому что JPA по своей природе (persistence context и dirty checking) не поддерживает неизменяемую модель данных - неотъемлемую часть функционального стиля программирования, который, в свою очередь, является неотъемлемой частью моего подхода к разработке. И это объективный факт.
Почему для себя я выбрал ФП, а не "нормальное" императивное программирование? На этот вопрос также можно ответить одним предложением: потому что функциональный стиль помогает мне снижать стоимость разработки для бизнеса и делать руководителей проектов счастливыми.
Уверен, многие не согласятся с истинностью утверждения "применение функционального стиля ведёт к снижению стоимости разработки". Поэтому я пока буду называть его Гипотезой и приведу факты, доказывающие её истинность.
Главный из этих фактов - эмпирическое подтверждение Гипотезы, описанное в книге Structured Design.
Структурный дизайн
В 60-ых годах прошлого века учёный Ларри Константин написал книгу Structured Design. В этой книге он впервые ввёл понятия сцепленности (coupling) и функциональной связанности (cohesion), которые до сих пор лежат в основе большинства подходов к проектированию и кодированию программ.
Помимо этого, в главе "THE MORPHOLOGY OF SIMPLE SYSTEMS" (для просмотра необходимо пройти бесплатную регистрацию и "занять" книгу), Константин приводит иллюстрацию того, что он называет моделью, ориентированной на трансформацию (выделение цветами моё):
Упрощённо, эта модель разбивает код на четыре вида - ввод, вывод, трансформации и координацию.
Затем Константин пишет:
It [transform-centered model] was derived empirically from a careful review of the morphology of systems, comparing systems that had proven to be cheap to implement, to maintain, and to modify with ones that had been expensive.
[...]
The umpteenth round in this game produced what came to be called the transform-centered model. Most of the cheap systems had it - none of the costly systems did!
---
Она [модель, ориентированная на трансформацию] была получена эмпирически на основе тщательного анализа морфологии систем со сравнением систем, которые были доказано дёшевы в разработки, обслуживании и модификации, с системами, которые были дорогостоящими.
[...]
Энный раунд этой игры породил то, что стало называться моделью, ориентированной на трансформации. Она присутствовала в большинстве дешёвых систем - ни одна из дорогостоящих систем не имела её!
— Larry Constantine, Structured Design, p. 144
И если предположить, что трансформации являются чистым функциями в терминах ФП, то в этой модели отчётливо видна функциональная архитектура:
красные афферентные модули (ввод), жёлтые эфферентные модули (вывод) и голубой модуль координации - императивная оболочка
зелёные модули центральной трансформации - чистое ядро.
Константин не пишет прямым текстом, что трансформации - это чистые функции, но если изучить книгу и её исторический контекст, то это становится очевидно.
Для того чтобы функция была чистой, она не должна иметь побочных эффектов:
Ввод-вывод (в консоль, файл, сеть и т.д.);
Выброс исключения;
Запуск нового потока;
Изменение полей структур данных, переданных ей на вход по указателю;
Чтение и изменение полей глобальных структур данных, доступных по статической ссылке (обращение к общему окружению, синглтоны).
Первый вид эффектов (ввод-вывод) в подпрограммах трансформации исключён самой моделью.
Выброс исключения, запуск нового потока и изменение структур данных по указателю исключены историческим контекстом - все эти штуки только-только начали появляться в момент разработки структурного дизайна (исключения, потоки, указатели).
Любопытный факт — с указателями Советский Союз смог догнать и перегнать Америку и придумал их в 1955 году, на 12 лет раньше западных учёных.
Касательно обращения к общему окружению, Константин вроде бы прямым текстом говорит, что этого не следует избегать любой ценой. Но он приводит такую иллюстрацию "эффекта общего окружения", которой хочется избежать любой ценой.
Whenever two or more modules interact with a common data environment, those modules are said to be common-environment coupled.
[...]
A common environment may be a shared communication region, a conceptual file in any storage medium, a physical device or file, a common data base area, and so on.
[...]
The point is not that common-environment coupling is bad, or that it should be avoided at all cost. To the contrary, there are circumstances in which this may be the method of choice. However, it should be clear that a small number of elements shared among a few modules can enormously complicate the structure of a system - from the point of view of understanding it, maintaining it, or modifying it.
— Larry Constantine, Structured Design
Всякий раз, когда два или более модуля взаимодействуют с данными в общем окружении, считается, что эти модули сцеплены через общее окружение.
[...]
Общим окружением может быть общая область обмена данными, концептуальный файл на любом носителе информации, физическое устройство или файл, общая область базы данных и так далее.
[...]
Посыл не в том, что сцепленность через общее окружение плоха или что её следует избегать любой ценой. Напротив, существуют обстоятельства, при которых этот метод может быть предпочтительным. Однако должно быть очевидно, что небольшое количество элементов, совместно используемых несколькими модулями, может чрезвычайно усложнить структуру системы с точки зрения её понимания, обслуживания или модификации.
— Larry Constantine, Structured Design
Наконец, в статье Structured Design Константин пишет:
A predictable, or well-behaved, module is one that, when given the identical inputs, operates identically each time it is called. Also, a well-behaved module operates independently of its environment.
---
Предсказуемый, или [хорошо управляемый/исправный/хорошо себя ведущий], модуль - это модуль, который при задании идентичных входных данных работает одинаково при каждом его вызове. Кроме того, исправный модуль работает независимо от своей среды.
— Larry Constantine, Structured Design
Это определение well-behaved модуля (подпрограммы) буквально является определением чистой функции.
Таким образом, мы получаем:
Трансформации физически не могли иметь никаких эффектов, кроме обращения к общему окружению;
При этом Константин иллюстрирует "эффект общего окружения" картинкой, которая повергнет в ужас любого разработчика;
И называет исправными те подпрограммы, которые обладают свойствами чистых функций - в том числе не обращаются к общему окружению.
Достаточно ли этого, для того чтобы прийти к выводу, что трансформации де-факто были чистыми функциями и, следовательно, модель, ориентированная на трансформации, является эквивалентом функциональной архитектуры? Я считаю что да.
Это, в свою очередь, значит что есть эмпирические свидетельства тому, что применение функциональной архитектуры ведёт к системам "дешёвым в разработке, обслуживании и модификации".
Это могло бы быть неопровержимым доказательством Гипотезы, если бы не одно но. Результаты этого исследования не были опубликованы в рецензируемом научном журнале, потому что "исходные данные и заметки были утеряны в беспорядочной гибели института".
Тем не менее, я считаю, что слова учёного с мировым именем заслуживают доверия. В том числе потому, что они подтверждаются другими экспертами-практиками и моим собственным опытом.
Косвенные подтверждения Гипотезы
ФП в книгах
Призывы максимум кода выделять в чистые функции встречаются во множестве книг, опубликованных начиная с 60-ых годов и публикуемых по сей день.
Functional Design: Principles, Patterns, and Practices (2023), Robert Martin
Свежая книга Роберта Мартина, пожалуй, самого цитируемого человека на собеседованиях и конференциях по дизайну и хорошему коду, называется Functional Design: Principles, Patterns, and Practices.
Unit Testing: Principles, Practices, and Patterns (2020), Vladimir Khorikov
Лучшая, на мой взгляд, книга по автоматизации тестирования включает в себя три раздела на 23 страницы, посвящённых функциональной архитектуре. Потому что она является необходимым
Patterns, Principles, and Practices of Domain-Driven Design (2015), Scott Millett
Side effects can make code harder to reason about and harder to test, and they can often be the source of bugs. In a broad programming context, avoiding side effecting functions as much as possible is generally considered good advice. You even saw in the previous chapter how being side-effect‐free and immutable were two of the main strengths of value objects. But if avoiding side effects is good advice, avoiding hidden side effects is a fundamental expectation.
---
Побочные эффекты могут усложнить анализ кода и его тестирование, а так же они часто бывают источником ошибок. Избегать, насколько это возможно, функций с побочными эффектами - обычно считается хорошим советом в программировании. Вы даже видели в предыдущей главе, что отсутствие побочных эффектов и неизменяемость были двумя основными преимуществами объектов-значений. Но если избегать побочных эффектов - хороший совет, то избегать скрытых побочных эффектов - фундаментальное ожидание.
— Scott Millett, Patterns, Principles, and Practices of Domain-Driven Design, section Favor Hidden‐Side‐Effect‐Free Functions
Clean Code (2008), Robert Martin
Side effects are lies.
Your function promises to do one thing, but it also does other hidden things. Sometimes it will make unexpected changes to the variables of its own class. Sometimes it will make them to the parameters passed into the function or to system globals. In either case they are devious and damaging mistruths that often result in strange temporal couplings and order dependencies.
---
Побочные эффекты - это ложь.
Ваша функция обещает сделать что-то одно, но она также выполняет и другие скрытые действия. Иногда она вносит неожиданные изменения в переменные своего класса. Иногда делает это с параметрами, передаваемым в функцию, или глобальными переменными системы. В любом случае это коварные и разрушительные обманы, которые часто приводят к странным временнЫм связям и зависимостям в порядке выполнения.
— Robert Martin, Clean code, section Have No Side Effects
Domain-Driven Design (2003), Eric Evans
Place as much of the logic of the program as possible into functions, operations that return results with no observable side effects. Strictly segregate commands (resulting in modifications to observable state) into very simple operations that do not return domain information. Further control side effects by moving complex logic into VALUE OBJECTS with conceptual definitions fitting the responsibility.
---
Помещайте возможный максимум логики программы в функции — операции, которые возвращают результат без наблюдаемых побочных эффектов. Строго выделяйте команды (приводящие к изменениям в наблюдаемом состоянии) в очень простые операции, которые не возвращают доменную информацию. Дальнейший контроль побочных эффектов осуществляется путём переноса сложной логики в ОБЪЕКТЫ-ЗНАЧЕНИЯ, чьё концептуальное описание подходит для включения ответственности [за выполнение этой логики].
— Eric Evans, Domain-Driven Design, section SIDE-EFFECT-FREE FUNCTION
Object-Oriented Software Construction (1997), Bertrand Meyer
The first question that we must address will have a deep effect on the style of our designs. Is it legitimate for functions — routines that return a result — also to produce a side effect, that is to say, to change something in their environment?
The gist of the answer is no, but we must first understand the role of side effects, and distinguish between good and potentially bad side effects.
---
Первый вопрос, на который мы должны ответить, окажет глубокое влияние на стиль наших дизайнов. Законно ли, чтобы функции — подпрограммы, возвращающие результат, — также производили побочный эффект, то есть изменяли что-то в своей среде?
Суть ответа - нет, но сначала мы должны понять роль побочных эффектов и провести различие между хорошими и потенциально плохими побочными эффектами.
— Bertrand Meyer, Object-Oriented Software Construction, section 23.1 SIDE EFFECTS IN FUNCTIONS
Следующим косвенным доказательством преимущества ФП-стиля является тот факт, что большинство его адептов в начале своей карьеры были сторонниками императивного стиля.
ФП-перебежчики
Наиболее яркими, на мой взгляд, представителями людей, слишком слабых духом для императивного программирования, являются Роберт Мартин и Рич Хикки.
Мартин — человек, чьё имя приходит на ум первым после слов "объектно-ориентированный дизайн" или "чистый код" - десять лет назад перешёл с C++ на Clojure и теперь пишет посты-оды функциональному стилю и книги по функциональному дизайну.
Хикки некогда был адвокатом C++, но в итоге настолько устал от проблем, вызванных императивным стилем, что сделал собственный функциональный язык с персистентными структурами данных и примитивами безопасного конкурентного программирования, собственную функциональную СУБД и собственную модель функционального программирования. И это всё не академические изыскания - Clojure активно используется в коммерческой разработке (хотя масштаб использования не сопоставим с Java/C#/Kotlin и т.д., конечно же).
ФП в технологиях
Технологии, в частности языки программирования и фреймворки, призваны сделать разработку программ дешевле. И сейчас наблюдается отчётливый тренд увеличения процента технологий, базирующихся на принципах ФП. На основании чего можно предположить, что эти принципы помогают авторам удешевить разработку программ на базе их технологий.
Единственный популярный известный мне язык родом из этого века, который не поддерживает ФП - Go. Все остальные - Kotlin, Swift, Rust - поддерживают. Естественно, я не говорю о целом ворохе чисто функциональных языков программирования.
Сейчас даже Java семимильными шагами идёт в сторону ФП стиля — записи, закрытые иерархии, паттерн мэтчинг, лямбды в конце концов.
А GUI-фреймворки, некогда бывшие безраздельной вотчиной ООП, в XXI веке все как один - React, SwiftUI, Jetpack Compose (ну ладно, основные для наиболее используемых платформ - Web, Android, iOS) — предлагают функциональную модель.
Вероятная причина низкой стоимости ФП-программ
Я полагаю, что при прочих равных, в разработке дешевле те программы, которые проще понять.
Потому что при работе с понятной программой, разработчику требуется меньше времени (=денег) на её изучение, для того чтобы внести требуемые правки. И при изменении понятной программы меньше вероятность внести ошибку, которая потребует дополнительного времени (=денег) на её исправление.
И я хотя понятность кода во многом является субъективно метрикой, программы, написанные в функциональном стиле, объективно более понятны.
Это можно продемонстрировать на двух областях, которые строят точные модели программ - формальная верификация программ и оптимизирующие компиляторы. Для обеих этих областей подпрограммы, написанные в функциональном стиле, являются объективно более понятными:
Оптимизирующие компиляторы не могут применять многие из оптимизаций к программам с эффектами из-за того, что не могут предсказать последствия этих оптимизаций ([1]
Наличие оператора присваивания в программе существенно усложняет задачу её формальной верификации ([2]).
Есть и более близкая большинству разработчиков область, в которой большая простота функционального стиля на фоне императивного не вызывает сомнений, - многопоточное программирование.
Главная сложность написания многопоточных программ в императивном стиле (с разделяемыми изменяемыми структурами данных) в том, что в любой момент ваш код могут остановить и поменять данные, с которыми вы только что работали. Это как будто вы подносите ко рту конфетку, в последнюю секунду моргаете, открываете глаза, а там уже шпинат.
По сути та же ситуация может возникнуть и в однопоточной программе в момент вызова функции с передачей в неё изменяемой структуры данных. Да, тут всё значительно проще - вы точно знаете когда и примерно знаете кто (в случае полиморфного вызова в закрытой системе) может подменить вам конфетку. Но для того чтобы быть уверенным, что вы съедите именно конфетку, вам надо перед каждым использованием функции (и после каждого её изменения) заглядывать внутрь и изучать, что она делает с вашей конфеткой.
Эту проблему императивного стиля можно проиллюстрировать на следующем примере:
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 в Kotlin) - ничего.
Потому что sum
может быть реализована, например, так:
fun sum(els: ArrayList<Int>): Int {
var sum = 0
while (els.isNotEmpty()) {
sum += els.remove(0)
}
return sum
}
С такой реализацией вызов этой программы завершится выбросом IndexOutOfBoundsException. Это хоть и синтетический пример, но он основан на реальных событиях - из-за подобного кода я лично вносил баг, который нашли только в проде.
Для того чтобы снизить стоимость разработки, не надо знать теорию категорий
Пуристы во главе с Эриком Мейером со мной не согласятся, но прагматики во главе в анкл Бобом меня поддержат в том, что для получения пользы от ФП-стиля не обязательно уходить в идеально чистое функциональное программирование.
Для этого достаточно с помощью функциональной архитектуры разделить ввод-вывод и логику и сделать модель данных неизменяемой. А императивную оболочку, как это ни странно, намного удобнее писать в императивном стиле. И если локальная переменная не утекает за пределы функции - она вполне может быть изменяемой (см. примеры кода в конце поста).
Я не знаю единого хорошего источника по пролетарскому прагматичному ФП, но могу порекомендовать трек его изучения.
Сначала стоит прочитать пост Владимира Хорикова - Иммутабельная архитектура.
В этом посте можно быстро схватить основную идею функциональной архитектуры информационных систем. Но в реализации информационных систем в функциональном стиле слишком много нюансов для одного поста, поэтому одним постом ограничиться не получится.
Затем можно так же быстро посмотреть ещё пару небольших примеров решения "реальных" задач в ФП-стиле в моих постах:
После этого можно прочитать книгу того же Хорикова Unit Testing Principles, Practices, and Patterns (на русском)
Тут больше деталей, но так как фокус книги всё-таки на тестировании, не хватает главной части — моделирования неизменяемых данных.
Моделирование незименяемых данных хорошо раскрыто в книге Domain Modeling Made Functional.
Тут уже прям ФП-ФП с монадами, но после материалов Хорикова у вас будет выбор - идти в эту кроличью нору или нет.
Наконец, всё это можно полернуть свежей книгой анкл Боба Functional Design: Principles, Patterns, and Practices.
В этой книге нет ни одного сложного эффекта (всё эффекты в памяти) и примеры на Clojure, но она хорошо иллюстрирует, тот самый
пролетарскийпрагматичный стиль ФП.
Заключение
Итого мы имеем следующие факты:
Учёный с мировым именем утверждает, что у него были эмпирические данные, свидетельствующие о том, что дешёвые программы имеют структуру, которая чрезвычайно напоминает функциональную архитектуру;
Множество экспертов-практиков со страниц своих книг призывают возможный максимум кода выносить в чистые функции;
Множество экспертов-практиков переходят с императивного стиля на функциональный;
Множество вендеров включают принципы функционального подхода в основу своих технологий;
Программы, написанные в функциональном стиле, проще верифицировать и оптимизировать;
Писать многопоточные программы намного проще в функциональном стиле;
При этом код в функциональном стиле может быть вполне понятен человеку без степени доктора математических наук.
Достаточно ли этих фактов, для доказательства того, что функциональный стиль снижает стоимость разработки? Для меня, особенно с учётом того, что они согласуются с моим опытом, - да.