Это адаптированный перевод статьи Pragmatic Functional Programming Роберта Мартина, которого в сообществе называют «дядюшка Боб». Повествование ведется от лица автора оригинала.
Разработчики начали массово интересоваться функциональным программированием примерно 10 лет назад: языки Scala, Clojure и F# постепенно становились все более и более популярны.
Согласно закону Мура, вычислительная мощность будет удваиваться каждые два года пропорционально количеству транзисторов на интегральной схеме. Это эмпирическое наблюдение работало с 1960 по 2000 годы, но с тех пор, как частота тактовых импульсов достигла 3 Гц, она перестала расти. Сигнал физически не может проходить сквозь поверхность чипа с большей частотой.
В 2000-х годах разработчики процессоров изменили стратегию — вместо того, чтобы увеличивать мощность одного процессора, они добавили в компьютер больше процессоров — мы знаем их как ядра. Для этого пришлось отказаться от большей части кэша и конвейерной архитектуры чипа. В результате производительность компьютеров увеличилась.
Первый двухъядерный компьютер появился у меня примерно 10 лет назад, два года спустя я поменял его на четырехъядерный. Резкое увеличение количества ядер в процессоре должно было сильно отразиться на разработке софта.
Первой реакцией сообщества на этот вызов стал интерес к функциональному программированию (ФП). Эта парадигма предполагает, что состояние переменной после ее инициализации не меняется, что сильно сказывается на многопоточности Если вы не можете изменить состояние переменной, race condition исключается. Если вы не можете обновить значение переменной, исключается задача многопоточного обновления.
Это, конечно, подразумевалось как решение проблемы многоядерного процессора. Так как количество ядер продолжало расти, многопоточность, даже нет — одновременность, могла стать значимой проблемой. ФП было вынуждено предложить стиль программирования, который позволял бы мигрировать задачи, чтобы справляться с 1024 ядрами в одном процессоре.
Тогда все начали изучать Clojure, Scala, F# или Haskell, потому что знали, что в их направлении движется грузовой состав и хотели быть готовыми к моменту его прибытия.
Но состав так и не прибыл. Шесть лет назад я приобрёл четырёхядерный ноутбук. После этого у меня было ещё два. Следующий лаптоп скорее всего тоже будет четырёхядерным. Что, опять застой?
Маленькое отступление. Прошлым вечером я смотрел фильм 2007 года. Героиня использовала ноутбук, просматривая страницы в популярном браузере, искала что-то через гугл и на телефон-раскладушку ей приходили смс. Всё было очень похожим. И старым. Было заметно, что ноутбук не новой модели, браузер устаревшей версии, а телефон-раскладушка — печальный отголосок сегодняшних смартфонов. Но сегодня всё изменилось не настолько масштабно, как с 2000 по 2011. И изменения сильно далеки от тех, что произошли с 1990 по 2000. Неужели мы вошли в стагнацию компьютерных технологий и софта?
Возможно, ФП не настолько критичный навык, как мы когда-то предполагали. Возможно мы не потонем в море ядер. Может быть нам не нужно беспокоиться о 32768-ядерных чипах. Может быть, мы можем расслабиться и вернуться к обновлению своих переменных.
Но мне кажется это будет ошибкой. Колоссальной. Мне кажется это будет такой же серьёзной ошибкой, как безудержное использование goto
. И настолько же опасно, как отказ от динамической диспетчеризации.
Почему? Можно начать с причины, которая беспокоила нас с самого начала. ФП делает синхронность намного безопасней. Если вы строите систему со множеством связей или процессов, то использование ФП значительно сокращает количество ошибок, которые могут появиться при состоянии гонки или многопоточных обновлениях.
Ещё? ФП проще писать, читать, тестировать и понимать. Я представляю, как некоторые из вас сейчас машут руками и кричат на экран. Вы пробовали ФП и оно вам показалось далеко не лёгким. Все эти преобразования, редукции и рекурсии, особенно хвостовая рекурсия — ну никак не легкая штука. Конечно. Я всё это понимаю. Но всё это — проблема привычки. Как только вы привыкните к этим концептам (а на выработку привычки не уйдёт много времени), программирование станет намного проще.
Почему оно станет проще? Потому что вам не нужно будет следить за состоянием системы. Состояние переменных не будет изменяться, поэтому состояние системы будет неизменным. И исчезнет необходимость в отслеживании не только состояния системы. Вам не нужно будет отслеживать состояние списка, набора, стека или очереди, потому что эти структуры данных не могут измениться. Когда вы пушите элемент в стек в языке ФП, вы получаете новый стек, а не изменяете старый. Это значит, что нужно будет жонглировать с меньшим количеством шариков в воздухе. Меньше запоминать. Меньше отслеживать. А код при этом намного проще писать, читать, понимать и тестировать.
Так какой же язык ФП стоит использовать? Мой любимый — Clojure. Причина в том, что Clojure простой до абсурда. Это диалект Lisp, а Lisp красивый и простой язык. Давайте я вам покажу.
Вот функция в Java: f(x);
Теперь превратим её в функцию в Lisp. Просто сдвигаете первую круглую скобку влево: (f x)
.
Когда вы знаете 95% Lisp, вы знаете 90% Clojure. Этот простой скобочный синтаксис и есть вся фишка синтаксиса в данных языках. Они абсурдно просты.
Возможно, вы видели программы на Lisp, и вам не понравились все эти скобки. Может быть, вам не нравятся всякие CAR
, CDR
и CADR
. Не беспокойтесь. В Clojure немного больше пунктуации, чем в Lisp, поэтому скобок там меньше. В Clojure CAR
, CDR
и CADR
заменены на first
, rest
и second
. В дополнение Clojure построен на JVM и даёт полный доступ ко всей библиотеке Java, и любой другой библиотеке или фреймворку Java, которые вы хотите использовать. Совместимость быстрая и лёгкая. Что ещё лучше, Clojure даёт полный доступ к объектно-ориентированной (ОО) функциональности JVM.
Я слышу, как вы говорите: "Погоди!". "ФП и OO взаимонесовместимы!" Кто вам такое сказал? Это бред! Правда только, что в ФП вы не можете изменить состояние объекта, ну и что? Если запушить целое число в стек – это даст вам новый стек. Точно также когда вы вызовете метод, который устанавливает значение объекта, вы получите новый объект вместо изменения старого. С этим очень просто справиться, как только вы к этому привыкнете.
Но вернёмся к ОО. Одна из функциональностей ОО, которую я нахожу самой полезной на уровне архитектуры приложений, это динамический полиморфизм. А Clojure даёт полный доступ к динамическому полиморфизму Java. Возможно примером объяснить лучше всего.
(defprotocol Gateway
(get-internal-episodes [this])
(get-public-episodes [this]))
Выше написанный код определяет полиморфический interface
для JVM. В Java этот интерфейс выглядел бы так:
public interface Gateway {
List<Episode> getInternalEpisodes();
List<Episode> getPublicEpisodes();
}
На уровне JVM байт-код на выходе идентичен. Программа на Clojure может реализовать Java-интерфейс. В Clojure это выглядит так:
(deftype Gateway-imp [db]
Gateway
(get-internal-episodes [this]
(internal-episodes db))
(get-public-episodes [this]
(public-episodes db)))
Обратите внимание на аргумент конструктора db
, и как все методы могут иметь к нему доступ. В данном случае реализации интрерфейса просто делегируют что-то локальным функциям, передавая db
.
Но самое лучшее, возможно, это то, что Lisp, а значит и Clojure (готовы?) гомоиконны, что означает: код – это данные, которыми может манипулировать программа. Это легко увидеть. Вот этот код: (1 2 3)
представляет из себя список из трёх целых чисел. Если первый элемент списка — функция, как тут: (f 2 3)
, то код становится вызовом функции. Поэтому все функции, вызываемые в Clojure — это списки, а списками можно напрямую манипулировать с помощью кода. Значит, программа может собирать и исполнять другие программы.
В заключение. Функциональное программирование — важная штука. Вам стоит его изучить. И если вы размышляете над тем, какой язык использовать, чтобы изучать ФП, я советую Clojure.