Перейти к основному содержимому

Функциональное программирование на Java

· 17 мин. чтения

1. Введение

В этом руководстве мы разберемся с основными принципами парадигмы функционального программирования и узнаем, как применять их на практике в языке программирования Java. Мы также рассмотрим некоторые передовые методы функционального программирования.

Это также позволит нам оценить преимущества, которые мы получаем от функционального программирования, особенно на Java.

2. Что такое функциональное программирование

По сути, функциональное программированиеэто стиль написания компьютерных программ, в котором вычисления рассматриваются как вычисление математических функций . Итак, что такое функция в математике?

Функция — это выражение, которое связывает входной набор с выходным набором.

Важно отметить, что вывод функции зависит только от ее ввода. Что еще интереснее, мы можем скомпоновать две или более функций вместе, чтобы получить новую функцию.

2.1. Лямбда-исчисление

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

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

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

2.2. Категоризация парадигм программирования

Конечно, функциональное программирование — не единственный практический стиль программирования. Вообще говоря, стили программирования можно разделить на императивные и декларативные парадигмы программирования:

Императивный подход определяет программу как последовательность операторов, которые изменяют состояние программы до тех пор, пока она не достигнет конечного состояния. Процедурное программирование — это тип императивного программирования, при котором мы создаем программы, используя процедуры или подпрограммы. Одна из популярных парадигм программирования, известная как объектно-ориентированное программирование (ООП) , расширяет концепции процедурного программирования.

Напротив, декларативный подход выражает логику вычислений без описания потока управления в терминах последовательности операторов. Проще говоря, декларативный подход фокусируется на определении того, чего должна достичь программа, а не на том, как она должна этого достичь. Функциональное программирование — это подмножество декларативных языков программирования.

Эти категории имеют дополнительные подкатегории, и таксономия становится довольно сложной, но мы не будем вдаваться в нее в этом уроке.

2.3. Категоризация языков программирования

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

Чисто функциональные языки, такие как Haskell, допускают только чисто функциональные программы.

Однако другие языки допускают как функциональные, так и процедурные программы и считаются нечистыми функциональными языками. В эту категорию попадают многие языки, включая Scala, Kotlin и Java.

Важно понимать, что большинство популярных сегодня языков программирования — это языки общего назначения, и, следовательно, они, как правило, поддерживают несколько парадигм программирования.

3. Основные принципы и концепции

В этом разделе будут рассмотрены некоторые основные принципы функционального программирования и способы их применения в Java. Обратите внимание, что многие функции, которые мы будем использовать, не всегда были частью Java, и рекомендуется использовать Java 8 или более позднюю версию для эффективного выполнения функционального программирования .

3.1. Функции первого класса и высшего порядка

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

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

Традиционно в Java можно было передавать функции только с использованием таких конструкций, как функциональные интерфейсы или анонимные внутренние классы. Функциональные интерфейсы имеют ровно один абстрактный метод и также известны как интерфейсы единого абстрактного метода (SAM).

Допустим, нам нужно предоставить собственный компаратор для метода Collections.sort :

Collections.sort(numbers, new Comparator<Integer>() {
@Override
public int compare(Integer n1, Integer n2) {
return n1.compareTo(n2);
}
});

Как мы видим, это утомительный и многословный метод — определенно не то, что побуждает разработчиков использовать функциональное программирование. К счастью, в Java 8 появилось много новых функций, упрощающих этот процесс, таких как лямбда-выражения, ссылки на методы и предопределенные функциональные интерфейсы .

Давайте посмотрим, как лямбда-выражение может помочь нам с той же задачей:

Collections.sort(numbers, (n1, n2) -> n1.compareTo(n2));

Однозначно, так лаконичнее и понятнее. Однако обратите внимание, что, хотя это может создать у нас впечатление использования функций в качестве полноправных объектов в Java, это не так.

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

3.2. Чистые функции

В определении чистой функции подчеркивается, что чистая функция должна возвращать значение, основанное только на аргументах, и не должна иметь побочных эффектов . Теперь это может звучать совершенно противоречащим всем лучшим практикам в Java.

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

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

Допустим, мы хотим найти сумму всех чисел, которые мы только что отсортировали:

Integer sum(List<Integer> numbers) {
return numbers.stream().collect(Collectors.summingInt(Integer::intValue));
}

Теперь этот метод зависит только от аргументов, которые он получает, поэтому он детерминирован. Более того, он не вызывает никаких побочных эффектов.

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

Однако мы можем рассуждать о том, как мы справляемся с законными побочными эффектами. Например, нам может понадобиться сохранить результат в базе данных по реальным причинам. Что ж, в функциональном программировании есть приемы обработки побочных эффектов при сохранении чистых функций.

Мы обсудим некоторые из них в следующих разделах.

3.3. неизменность

Неизменяемость является одним из основных принципов функционального программирования и относится к свойству, согласно которому сущность не может быть изменена после создания экземпляра . Теперь в функциональном языке программирования это поддерживается дизайном на уровне языка. Но в Java мы должны сами принять решение о создании неизменяемых структур данных.

Обратите внимание, что сама Java предоставляет несколько встроенных неизменяемых типов , например, String . В первую очередь это сделано из соображений безопасности, поскольку мы активно используем String при загрузке классов и в качестве ключей в структурах данных на основе хэшей. Есть несколько других встроенных неизменяемых типов, таких как примитивные оболочки и математические типы.

Но как насчет структур данных, которые мы создаем в Java? Конечно, по умолчанию они не являются неизменяемыми, и нам нужно внести несколько изменений, чтобы добиться неизменности. Использование ключевого слова final является одним из них, но этим оно не ограничивается:

public class ImmutableData {
private final String someData;
private final AnotherImmutableData anotherImmutableData;
public ImmutableData(final String someData, final AnotherImmutableData anotherImmutableData) {
this.someData = someData;
this.anotherImmutableData = anotherImmutableData;
}
public String getSomeData() {
return someData;
}
public AnotherImmutableData getAnotherImmutableData() {
return anotherImmutableData;
}
}

public class AnotherImmutableData {
private final Integer someOtherData;
public AnotherImmutableData(final Integer someData) {
this.someOtherData = someData;
}
public Integer getSomeOtherData() {
return someOtherData;
}
}

Обратите внимание, что мы должны тщательно соблюдать несколько правил:

  • Все поля неизменяемой структуры данных должны быть неизменяемыми.
  • Это также должно применяться ко всем вложенным типам и коллекциям (включая то, что они содержат).
  • При необходимости должен быть один или несколько конструкторов для инициализации.
  • Должны быть только методы доступа, возможно, без побочных эффектов

Нелегко каждый раз делать все правильно , особенно когда структуры данных начинают усложняться. Однако несколько внешних библиотек могут упростить работу с неизменяемыми данными в Java. Например, Immutables и Project Lombok предоставляют готовые к использованию платформы для определения неизменяемых структур данных в Java.

3.4. Ссылочная прозрачность

Ссылочная прозрачность, возможно, является одним из наиболее сложных для понимания принципов функционального программирования. Однако концепция довольно проста. Мы называем выражение ссылочно прозрачным, если замена его соответствующим значением не влияет на поведение программы .

Это позволяет использовать некоторые мощные методы функционального программирования, такие как функции высшего порядка и ленивые вычисления. Чтобы лучше понять это, давайте возьмем пример:

public class SimpleData {
private Logger logger = Logger.getGlobal();
private String data;
public String getData() {
logger.log(Level.INFO, "Get data called for SimpleData");
return data;
}
public SimpleData setData(String data) {
logger.log(Level.INFO, "Set data called for SimpleData");
this.data = data;
return this;
}
}

Это типичный класс POJO в Java, но нам интересно узнать, обеспечивает ли он ссылочную прозрачность. Обратим внимание на следующие утверждения:

String data = new SimpleData().setData("ForEach").getData();
logger.log(Level.INFO, new SimpleData().setData("ForEach").getData());
logger.log(Level.INFO, data);
logger.log(Level.INFO, "ForEach");

Три вызова logger семантически эквивалентны, но непрозрачны с точки зрения ссылок. Первый вызов не прозрачен с точки зрения ссылок, поскольку он производит побочный эффект. Если мы заменим этот вызов его значением, как в третьем вызове, мы пропустим логи.

Второй вызов также не прозрачен с точки зрения ссылок, поскольку SimpleData является изменяемым. Вызов data.setData в любом месте программы затруднит его замену его значением.

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

4. Методы функционального программирования

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

4.1. Функциональная композиция

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

Как правило, любой интерфейс с одним абстрактным методом может служить функциональным интерфейсом . Следовательно, мы можем довольно легко определить функциональный интерфейс. Однако Java 8 по умолчанию предоставляет нам множество функциональных интерфейсов для различных вариантов использования в пакете java.util.function .

Многие из этих функциональных интерфейсов обеспечивают поддержку композиции функций с точки зрения стандартных и статических методов. Давайте выберем интерфейс функций , чтобы лучше понять это. Функция — это простой и универсальный функциональный интерфейс, который принимает один аргумент и выдает результат.

Он также предоставляет два метода по умолчанию, compose и andThen , которые помогут нам в композиции функций:

Function<Double, Double> log = (value) -> Math.log(value);
Function<Double, Double> sqrt = (value) -> Math.sqrt(value);
Function<Double, Double> logThenSqrt = sqrt.compose(log);
logger.log(Level.INFO, String.valueOf(logThenSqrt.apply(3.14)));
// Output: 1.06
Function<Double, Double> sqrtThenLog = sqrt.andThen(log);
logger.log(Level.INFO, String.valueOf(sqrtThenLog.apply(3.14)));
// Output: 0.57

Оба эти метода позволяют объединить несколько функций в одну, но предлагают разную семантику. В то время как compose сначала применяет функцию, переданную в аргументе, а затем функцию, для которой она вызывается, а затем делает то же самое в обратном порядке.

Некоторые другие функциональные интерфейсы имеют интересные методы для использования в составе функций , такие как методы по умолчанию и, или и отрицание в интерфейсе Predicate . Хотя эти функциональные интерфейсы принимают один аргумент, существуют специализации с двумя аритетами , такие как BiFunction и BiPredicate .

4.2. Монады

Многие из концепций функционального программирования происходят из теории категорий , которая является общей теорией функций в математике . В нем представлены несколько концепций категорий, таких как функторы и естественные преобразования. Для нас важно лишь знать, что это основа использования монад в функциональном программировании.

Формально монада — это абстракция, позволяющая структурировать программы в общем виде. Таким образом, монада позволяет нам оборачивать значение, применять набор преобразований и возвращать значение со всеми примененными преобразованиями . Конечно, есть три закона, которым должна следовать любая монада: левая идентичность, правая идентичность и ассоциативность, но мы не будем вдаваться в подробности.

В Java есть несколько монад, которые мы используем довольно часто, например, Optional и Stream :

Optional.of(2).flatMap(f -> Optional.of(3).flatMap(s -> Optional.of(f + s)))

Теперь, почему мы называем Optional монадой? Здесь Необязательный позволяет нам обернуть значение с помощью метода и применить ряд преобразований. Мы применяем преобразование добавления другого обернутого значения с помощью метода flatMap .

Если мы хотим, мы можем показать, что Optional следует трем законам монад. Тем не менее, критики сразу же укажут, что в некоторых случаях Optional нарушает законы монад. Но для большинства практических ситуаций этого должно быть достаточно для нас.

Если мы понимаем основы монад, мы скоро поймем, что в Java есть много других примеров, таких как Stream и CompletableFuture . Они помогают нам достигать разных целей, но все они имеют стандартную композицию, в которой обрабатываются манипуляции или преобразования контекста.

Конечно, мы можем определить наши собственные типы монад в Java для достижения различных целей , таких как монада журнала, монада отчета или монада аудита. Помните, как мы обсуждали обработку побочных эффектов в функциональном программировании? Что ж, как оказалось, монада — это один из методов функционального программирования для достижения этой цели.

4.3. карри

Каррирование — это математический метод преобразования функции, принимающей несколько аргументов, в последовательность функций, принимающих один аргумент . Но зачем они нужны в функциональном программировании? Это дает нам мощную технику композиции, когда нам не нужно вызывать функцию со всеми ее аргументами.

Более того, каррированная функция не реализует своего эффекта, пока не получит все аргументы.

В чисто функциональных языках программирования, таких как Haskell, каррирование хорошо поддерживается. На самом деле все функции каррируются по умолчанию. Однако в Java все не так просто:

Function<Double, Function<Double, Double>> weight = mass -> gravity -> mass * gravity;

Function<Double, Double> weightOnEarth = weight.apply(9.81);
logger.log(Level.INFO, "My weight on Earth: " + weightOnEarth.apply(60.0));

Function<Double, Double> weightOnMars = weight.apply(3.75);
logger.log(Level.INFO, "My weight on Mars: " + weightOnMars.apply(60.0));

Здесь мы определили функцию для расчета нашего веса на планете. В то время как наша масса остается неизменной, гравитация зависит от планеты, на которой мы находимся. Мы можем частично применить функцию , передав только гравитацию, чтобы определить функцию для конкретной планеты. Более того, мы можем передать эту частично примененную функцию в качестве аргумента или возвращаемого значения для произвольной композиции.

Каррирование зависит от языка, обеспечивающего две основные функции: лямбда-выражения и замыкания . Лямбда-выражения — это анонимные функции, которые помогают нам рассматривать код как данные. Ранее мы видели, как реализовать их с помощью функциональных интерфейсов.

Теперь лямбда-выражение может закрываться в своей лексической области видимости, которую мы определяем как замыкание. Давайте посмотрим пример:

private static Function<Double, Double> weightOnEarth() {   
final double gravity = 9.81;
return mass -> mass * gravity;
}

Обратите внимание, как лямбда-выражение, которое мы возвращаем в методе выше, зависит от объемлющей переменной, которую мы называем замыканием. В отличие от других языков функционального программирования, у Java есть ограничение, заключающееся в том, что объемлющая область видимости должна быть final или эффективно final .

Интересным результатом является то, что каррирование также позволяет нам создавать в Java функциональный интерфейс произвольной арности.

4.4. Рекурсия

Рекурсия — еще одна мощная техника функционального программирования, позволяющая разбивать задачу на более мелкие части . Основное преимущество рекурсии заключается в том, что она помогает нам устранить побочные эффекты, типичные для любого императивного цикла.

Давайте посмотрим, как мы вычисляем факториал числа с помощью рекурсии:

Integer factorial(Integer number) {
return (number == 1) ? 1 : number * factorial(number - 1);
}

Здесь мы вызываем ту же функцию рекурсивно, пока не достигнем базового случая, а затем начинаем вычислять наш результат. Обратите внимание, что мы делаем рекурсивный вызов перед вычислением результата на каждом шаге или словами в начале вычисления. Следовательно, этот стиль рекурсии также известен как головная рекурсия .

Недостатком этого типа рекурсии является то, что каждый шаг должен содержать состояние всех предыдущих шагов, пока мы не достигнем базового случая. На самом деле это не проблема для небольших чисел, но сохранение состояния для больших чисел может быть неэффективным.

Решением является немного другая реализация рекурсии, известная как хвостовая рекурсия . Здесь мы гарантируем, что рекурсивный вызов будет последним вызовом, который делает функция. Давайте посмотрим, как мы можем переписать приведенную выше функцию, чтобы использовать хвостовую рекурсию:

Integer factorial(Integer number, Integer result) {
return (number == 1) ? result : factorial(number - 1, result * number);
}

Обратите внимание на использование аккумулятора в функции, что избавляет от необходимости сохранять состояние на каждом шаге рекурсии. Реальное преимущество этого стиля заключается в использовании оптимизации компилятора, когда компилятор может принять решение отказаться от кадра стека текущей функции, метод, известный как устранение хвостовых вызовов.

В то время как многие языки, такие как Scala, поддерживают устранение хвостовых вызовов, в Java по-прежнему нет такой поддержки. Это часть невыполненной работы по Java, и, возможно, она будет реализована в рамках более крупных изменений, предложенных в рамках Project Loom .

5. Почему функциональное программирование имеет значение?

После прохождения учебника до сих пор мы должны задаться вопросом, почему мы вообще хотим прилагать столько усилий. Для тех, кто имеет опыт работы с Java, сдвиг, которого требует функциональное программирование, нетривиален . Таким образом, должны быть некоторые действительно многообещающие преимущества для принятия функционального программирования на Java.

Самым большим преимуществом функционального программирования на любом языке, включая Java, являются чистые функции и неизменяемые состояния . Если мы оглянемся назад, то увидим, что большинство проблем программирования так или иначе связаны с побочными эффектами и изменяемым состоянием. Простое избавление от них делает нашу программу более легкой для чтения, анализа, тестирования и сопровождения .

Декларативное программирование как таковое приводит к очень лаконичным и читабельным программам . Функциональное программирование, являющееся подмножеством декларативного программирования, предлагает несколько конструкций, таких как функции высшего порядка, композиция функций и цепочка функций. Подумайте о преимуществах, которые Stream API привнес в Java 8 для обработки манипуляций с данными.

Но не поддавайтесь искушению переключиться, пока не будете полностью готовы. Обратите внимание, что функциональное программирование — это не простой шаблон проектирования, который мы можем сразу же использовать и извлечь из него пользу. Функциональное программирование — это скорее изменение того, как мы рассуждаем о проблемах и их решениях, а также о том, как структурировать алгоритм.

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

6. Подходит ли Java?

Хотя трудно отрицать преимущества функционального программирования, мы не можем не спросить себя, подходит ли для этого Java. Исторически Java развивалась как язык программирования общего назначения, более подходящий для объектно-ориентированного программирования . Даже думать об использовании функционального программирования до Java 8 было утомительно! Но после Java 8 все определенно изменилось.

Тот факт, что в Java нет настоящих типов функций, противоречит основным принципам функционального программирования. Функциональные интерфейсы под маской лямбда-выражений в значительной степени компенсируют это, по крайней мере, синтаксически. Кроме того, тот факт, что типы в Java по своей природе изменяемы , и нам приходится писать так много шаблонов для создания неизменяемых типов, не помогает.

Мы ожидаем от функционального языка программирования других вещей, которые отсутствуют или сложны в Java. Например, стандартная стратегия оценки аргументов в Java — нетерпеливая . Но ленивое вычисление является более эффективным и рекомендуемым способом в функциональном программировании.

Мы по-прежнему можем добиться ленивых вычислений в Java, используя сокращение операторов и функциональные интерфейсы, но это более сложно.

Список, безусловно, неполный и может включать в себя поддержку дженериков с удалением типов, отсутствующую поддержку оптимизации хвостового вызова и другие вещи. Тем не менее, мы получаем широкое представление. Java определенно не подходит для запуска программы с нуля в функциональном программировании .

Но что, если у нас уже есть программа, написанная на Java, вероятно, в объектно-ориентированном программировании? Ничто не мешает нам получить некоторые преимущества функционального программирования, особенно с Java 8.

Именно в этом заключаются основные преимущества функционального программирования для Java-разработчика. Сочетание объектно-ориентированного программирования с преимуществами функционального программирования может иметь большое значение .

7. Заключение

В этом уроке мы рассмотрели основы функционального программирования. Мы рассмотрели основные принципы и то, как мы можем применить их в Java. Далее мы обсудили некоторые популярные приемы функционального программирования с примерами на Java.

Наконец, мы рассмотрели некоторые преимущества внедрения функционального программирования и ответили, подходит ли для этого Java.

Исходный код статьи доступен на GitHub .