Функциональное программирование в Java
Как уже говорилось ранее, в текущем разделе мы попробуем охватить возможности функционального программирования, которые предлагает Java.
В рамках данного урока постараемся разобраться с концептуальной разницей императивной и функциональной парадигм программирования, понять отношения между ООП и ФП, а также постараемся на примере нескольких блоков кода разобраться, в чем ключевая разница между императивным и функциональным подходом в Java.
Теория. Скучно, нудно, но нужно
Строго говоря, текущую статью нельзя назвать статьей о функциональном программировании. Теоретическую часть этой концепции мы затронем совсем поверхностно, ровно настолько, насколько необходимо для понимания основных механизмов ФП в Java. Возможно, мы еще вернемся к базису ФП в конце курса, но в целом – я не вижу реальной необходимости в данных знаниях для junior-специалистов. Все же, глубокое понимание ООП для Java-разработчика намного важнее.
Итак, что такое императивная и функциональная парадигмы и в чем их различие?
Для начала, постараемся понять, что такое парадигма программирования.
Парадигма программирования – набор основополагающих концепций и подходов, определяющих стиль написания программного кода. Какие-то парадигмы могут использоваться совместно, какие-то являются взаимоисключающими. По крайней мере, теоретически.
Ниже приведем примеры подходов, характерные для разных парадигм.
С объектно-ориентированной парадигмой мы уже знакомы. Она основана на представлении информационной системы (и, как следствие, кода, который эту систему описывает) в виде взаимодействия различных объектов, где каждый объект – экземпляр определенного шаблона (класса). Понятия объектов, классов, а также правила их организации на различных уровнях взаимодействия и представляют собой концепции и подходы, характерные для данной парадигмы.
Императивная парадигма – парадигма, основанная на написании программного кода через последовательно выполняющиеся инструкции, характеризующаяся стремлением к сохранению состояния (скажем, в переменных).
Строго говоря, код, который мы писали в рамках курса, является примером императивного программирования.
Раскрывая более подробно, у императивного подхода есть следующие отличительные черты:
· Использование переменных. Необходимо для сохранения состояния программы;
Состояние – множество параметров, описывающих программу (в узком смысле – подпрограмму, метод) в конкретный момент времени. Сама система (программа) в таком случае представляет собой набор сменяющих друг друга состояний.
· Использование оператора присваивания.
В узком смысле – именно этот оператор позволяет состояние записывать:
int i = 5;
Переменная i находится в состоянии (имеет значение) пять.
В более широком смысле, этот оператор позволяет и изменять состояние, базируясь на предыдущем значении:
i = i + 6;
Состояние (значение) i представляется как состояние до текущей операции + 6. К слову, подобные конструкции многих отпугивают от программирования на ранних этапах;
· Использование подпрограмм.
Подпрограммой будем считать обособленный именованный набор инструкций: функций (в императивном понимании), процедур. В случае Java – методов.
Полагаю, каждый из пунктов вам знаком на практике в той, или иной степени. Вероятно, большинство не понимают, как вообще возможен какой-то другой подход. Это нормально, ведь Java – императивный язык. Остальные парадигмы, которые в нем реализованы (в том числе, ООП), вынуждены с этим считаться.
Функциональная парадигма – как один из представителей декларативной парадигмы, может быть описана как подход, основанный не на наборе инструкций «как сделать», а на общей концепции – спецификации – объясняющей, что ожидается на выходе из программы (подпрограммы).
Определение выше популярное, очень широкое и мало что объясняющее. Поэтому попробуем проще.
Функциональный подход предполагает написание программ, как набора функций в математическом плане: f(x) = 2x и вот это вот все.
В таком подходе, теоретически, нет необходимости в сохранении состояния в переменных – достаточно входных параметров, а далее эти параметры будут обрабатываться функциями (как х для получения у), а результат каждой функции будет обрабатываться следующей функцией до тех пор, пока не будет получен конечный результат, который и будет, так или иначе, отправлен пользователю.
На практике, безусловно, необходимы и переменные, и сам переход функционально-описанного кода в императивный – чтобы процессор смог понять, что же от него требуется.
Но функциональная парадигма является более высокоуровневой, чем императивная. Т.е. имеет больший уровень абстракции. Поэтому состояния, присваивания и последовательные инструкции в той или иной степени скрыты от программиста. В каких-то языках (в т.ч. в Java) – в меньшей степени, в каких-то – в большей.
Рассматривая особенности функциональной парадигмы, можно выделить следующие характеристики (не все из них действительно актуальны для функционального стиля в Java):
· Функция, вызванная с одними и теми же параметрами, всегда вернет одинаковый результат. В императивном подходе результат может разниться, если учитывает какой-то "внешний" контекст;
· Код представляет собой набор цепочек из обращений к функциям. Визуально может отличаться из-за особенностей синтаксиса конкретного языка, но смысл обычно схожий;
· Используемые типы – immutable. Это могут быть примитивы, объекты (или другие структуры), используемые в качестве аргументов или результатов вычисления функций. Локальные переменные – отсутствуют;
· За счет отсутствия переменных как способа хранения состояния – отсутствуют циклы в привычном виде. Зачастую цикл заменяется рекурсией.
Грубо говоря, итеративный (с помощью цикла) способ вычисления факториала, невозможен без сохранения промежуточного значения в переменных. А рекурсивный – возможен.
Очень важно понимать, что практически все популярные на данный момент языки программирования – императивные. И поэтому реализации функционального программирования в них так или иначе вынуждены опираться на тот императивный базис, который существует. Поэтому, несмотря на красивую теорию об отсутствии состояния, во многих задачах это состояние приходится хранить. Отчасти из-за ограничений языка, отчасти из соображений оптимизации.
Полагаю, на данном этапе уже понятно, что теоретически функциональный (глядя шире - декларативный) подход можно противопоставлять императивному. И это, в сущности, верно, ведь эти подходы исходят из изначально разных концепций.
В контексте сравнения объектно-ориентированного и функционального подхода, все немного интереснее.
ООП, так или иначе, детище императивной парадигмы. Но де-факто, ООП определяет, в первую очередь, верхнеуровневое взаимодействие компонентов системы (классов). Наследование, инкапсуляция, даже полиморфизм, направлены на построение архитектуры решения. В сущности, ни один из этих принципов не регламентирует способы обработки данных внутри методов.
Таким образом, объектно-ориентированная парадигма не противоречит и не конфликтует с функциональной – они проявляют себя на разных уровнях организации кода, если утрировать.
Безусловно, при реализации проекта с использованием функциональной парадигмы, внешний вид системы будет отличаться от вида, получившегося при использовании императивной. Но заметить это можно лишь открыв конкретные классы, посмотрев на методы (иногда – уже на уровне декларации, не говоря о теле). Но никогда – на уровне диаграммы классов. По крайней мере, в Java.
Примеры императивной и функциональной реализаций в Java
На данном этапе, не факт, что примеры в функциональном стиле будут полностью понятны – инструменты, которые в них используются, мы будем рассматривать в ближайших уроках. Но общий смысл будет понятен уже сейчас, как и контраст с императивным стилем.
Пример 1:
Существует переменная, ссылающаяся на объект типа Квартира – «Flat». Допустим, что класс Flat имеет поле family соответствующего типа Family, указывающий на семью, которая в этой квартире живет. В классе Family есть поле father типа Human – отец семейста. У Human есть свое поле father того же типа Human – отец конкретного человека. Также класс Human содержит поле car типа Car – машина, принадлежащая человеку. И тип Car содержит поле «номер», обозначающий номер машины.
Опустим для лаконичности модификаторы доступа, не интересующие нас поля, конструкторы, геттеры и сеттеры и опишем классы, обозначенные выше:
class Flat {
Family family;
}
class Family {
Human father;
}
class Human {
Human father;
Car car;
}
class Car {
String number;
}
Итак, перед нами стоит задача написать метод, возвращающий номер машины, принадлежащей отцу человека, который является отцом в семье, проживающей в конкретной квартире. В теории все просто:
String getCarNumberBySonFlat(Flat flat) {
return flat.getFamily()
.getFather()
.getFather()
.getCar()
.getNumber();
}
Но оказалось, что сама квартира может еще не существовать, да и семьи в ней может не жить, или отец семейства как вышел лет 20 назад за сигаретами, так и пропал…
В общем, каждое из значений может оказаться null. В таком случае, реализация выше имеет все шансы упасть с NullPointerException. С этого момента наша императивная жизнь превращается в локальный ад. У ада могут быть разные уровни вложенности if-ов, в зависимости от выбранной реализации, но не суть. Опишем более-менее читабельную реализацию:
String getCarNumberBySonFlat(Flat flat) {
if (flat == null) {
return null;
}
Family family = flat.getFamily();
if (family == null) {
return null;
}
Human familyFather = family.getFather();
if (familyFather == null) {
return null;
}
Human father = familyFather.getFather();
if (father == null) {
return null;
}
Car car = father.getCar();
if (car == null) {
return null;
}
return car.getNumber();
}
!NB: на некоторых языках, в том числе JS, данный код можно было бы привести практически к первой реализации, будь проблема лишь в проверке на null. Но пример условный, а Java – далеко не самый лаконичный язык.
Итак, как обстоят дела в императивной парадигме примерно понятно. Такой код мог бы написать каждый из вас уже давно. Но что предлагает Java для функциональной реализации той же задачи?
String getCarNumberBySonFlat(Flat flat) {
return Optional.ofNullable(flat)
.map(Flat::getFamily)
.map(Family::getFather)
.map(Human::getFather)
.map(Human::getCar)
.map(Car::getNumber)
.orElse(null);
}
Полагаю, не вызывает сомнений, что такой код более лаконичный. При ситуации, когда результатом какой-то из промежуточных функций окажется null – остаток цепочки будет пропущен и вернется значение по умолчанию, указанное в orElse(). В нашем случае – тоже null, но можно было указать и другое.
Код в примере выглядит достаточно пресно. В конце концов, можно было обернуть все в try-catch и просто отловить возможную NPE. Некрасиво, но тоже работает.
Однако достаточно заменить каждую из проверок на null на валидацию по другим условиям (только для трехкомнатной квартиры, только если отец семейства не курит…) и простые решения исчезнут, останется лишь закапываться в if’ы. Функциональная реализация в таком случае тоже претерпит некоторые изменения, но останется не менее лаконичной. В рамках примера оставим проверки на null, чтобы не вводить лишние поля, раздувая исходные классы.
Пример 2
Существует компания (Company), внутри которой существует список отделов (Department), в каждом из которых есть список сотрудников (Employee). Необходимо получить мапу, в которой ключом будет возраст сотрудника, а значением – список сотрудников подходящего возраста. Вишенка на торте – список сотрудников в каждом из отделов может содержать null’ы.
Вкратце опишем классы, необходимые для данного примера:
class Company {
List<Department> departments;
}
class Department {
List<Employee> employees;
}
class Employee {
int age;
}
Решение задачи в императивном стиле может быть чуть лучше или чуть хуже, в зависимости от того, насколько вы владеете коллекциями и умеете в алгоритмизацию, но будет примерно таким:
Map<Integer, List<Employee>> getEmployeesByAge(Company company) {
List<Employee> employees = new ArrayList<>();
for (Department department : company.getDepartments()) {
for (Employee employee : department.getEmployees()) {
if (employee != null) {
employees.add(employee);
}
}
}
Map<Integer, List<Employee>> employeesByAge = new HashMap<>();
for (Employee employee : employees) {
int age = employee.getAge();
if (!employeesByAge.containsKey(age)) {
employeesByAge.put(age, new ArrayList<>());
}
employeesByAge.get(age).add(employee);
}
return employeesByAge;
}
Описывая алгоритм, нам необходимо сформировать список всех сотрудников из разных отделов, игнорируя null’ы, а потом сформировать мапу и наполнить ее сотрудниками. Для каждой итерации добавления сотрудника в мапу будем проверять – существует ли список под конкретный возраст, если нет – создаем список и лишь потом добавляем сотрудника.
Теперь решение той же задачи в функциональном стиле:
Map<Integer, List<Employee>> getEmployeesByAge(Company company) {
return company.getDepartments()
.stream()
.map(Department::getEmployees)
.flatMap(Collection::stream)
.filter(Objects::nonNull)
.collect(Collectors.groupingBy(Employee::getAge));
}
В данном случае алгоритм выглядит примерно так:
- Отделы компании представим в виде Stream'а (еще выясним, что за он);
- Представим каждый отдел как список сотрудников;
- Преобразуем стрим, сделав его единицей не список сотрудников - а одиночного сотрудника;
- Оставим лишь тех сотрудников, которые не null;
- Соберем данные в коллекцию посредством группировки, указав, что группировать единицы данных (сотрудников) будем по возрасту.
Полагаю, вы уже догадались, что лямбда-выражения (или, как в данном случае, method reference’ы) концептуально представляют собой те самые аналоги математических функций, на которых и строится функциональное программирование – обрабатывая результат одной функции следующей функцией, мы не имеем явной необходимости в лишних переменных, фиксирующих промежуточные состояния. Для многих классических задач методы могут быть сведены к вызову return и цепочки вызовов над входным параметром (или параметрами), которые в итоге приведут к ожидаемому результату.
При этом методы, в которые передаются лямбды - указывают, какого типа преобразование мы хотим увидеть - из одного типа данных в другой, отфильтровать по какому-то признаку и пр. А сами лямбда-выражения описывают механизм преобразования - что именно хотим получить на выходе.
Условно, строчку
.map(Department::getEmployees)
Можно описать как "представить каждый отдел как список его сотрудников".
Подводя итог
Функциональный подход в Java – очень мощный инструмент в обработке данных. Это в меньшей степени очевидно на учебных примерах, но весьма чувствительно на реальных проектах, когда многие задачи сводятся к получению одних массивов данных из других, фильтрации и агрегации (группировке) полученных данных. В общем-то, оба примера выше – вольная интерпретация реальных задач из практики автора. Разве что немного упрощенная.
В следующих уроках мы разберемся и с тем, что такое Optional, и с тем, что такое Stream (нет, к I/O Stream’ам это отношения не имеет).
Пока же советую хотя бы базово уложить в голове, что такое парадигма программирования и как соотносятся императивный, функциональный и объектно-ориентированные подходы между собой. Не ограничивайтесь данной статьей. Ее задача – лишь заинтересовать функциональным подходом в Java и дать хоть какой-то плацдарм, на котором можно строить восприятие описанных концепций.
С теорией на сегодня все! Урок теоретический, практику отложим до следующего урока.

Если что-то непонятно или не получается – welcome в комменты к посту или в лс:)
Канал: https://t.me/+relA0-qlUYAxZjI6
Мой тг: https://t.me/ironicMotherfucker
Дорогу осилит идущий!