Java

Часть 1, Часть 2, Часть 3, Часть 4

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

Поддержка функционального программирования в Java 8

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

  • Без состояния. Переменные не хранят своего состояния или значения. Отсутствуют глобальные переменные.
  • Без памяти. Отсутствует память и побочные эффекты.
  • Выполняют параллельные вычисления. Не требуют синхронизации. Намного проще, чем многопоточность.

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

1. Хранение функции в объекте

В Java 8 появился интерфейс java.util.Function<T, R>. Он может хранить функцию, которая принимает аргумент, а возвращать — объект. Generic T — это тип аргумента, а R — тип возвращаемого объекта. Пример:

public static Integer calculate(Function<Integer, Integer> function, Integer value) {
return function.apply(value);
}

2. Функциональный интерфейс

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

class Test
{
 public static void main(String args[])
 {
 new Thread(new Runnable()
  {
  @Override
  public void run()
  {
    System.out.println(“Hello”);
   }
 }).start();
 }
}

Использование лямбда-выражения в функциональном интерфейсе:

class Test
{
 public static void main(String args[])
 {
 new Thread(()->
 {System.out.println(“Hello lambda”);}).start();
 }
}

Подробнее о таких выражениях поговорим чуть позже.

3. Стандартные методы в интерфейсе

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

Почему бы не продолжить работать с абстрактными классами?
Абстрактные классы так и остались одним из способов представления состояния или реализации главных методов объекта. Они идут в паре с состоянием. Стандартные методы в интерфейсе используются для чистого поведения.

Пример метода по умолчанию: nullLast в интерфейсе Comparator.

Comparator<Obj> newComparator = Comparator.nullLast(oldComparator);

Затем появились лямбда-выражения и новая трудность — поддержка этих выражений в существующих интерфейсах коллекций. С этой целью потребовалось переписать все интерфейсы коллекций. Так и возникла концепция стандартных методов в интерфейсах.

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


4. Никаких методов с синхронизацией интерфейса

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

Например, метод run из класса Runnable можно синхронизировать. Если это сделать, то перед запуском метода run блокировка объекта Runnable будет занята другим процессом.

Синхронизация сводится к блокировке. Блокировка — это контроль над общим доступом к изменяемому состоянию. Каждый объект имеет свои правила синхронизации, в которых определяется, какие «замки» применяются к тем или иным состояниям переменных. Многие объекты используют собственную политику синхронизации — Java Monitor Pattern, в которой состояние объекта защищено встроенной блокировкой.

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

5. Optional

Все мы ненавидим null и их проверку. Проверять каждый аргумент на «пустоту» — это сущий ад.

В Java 8 появился java.util.Optional<T>, созданный для обработки объектов, которые могут и не существовать. Это объект-контейнер с включением еще одного объекта. Generic T — тип содержимого объекта.

Integer i = 5;
Optional<Integer> optionalInteger = Optional.of(i);

В классе Optional нет публичного конструктора. Для создания класса, который ни при каких обстоятельствах не может оказаться пустым, используется Optional.of(object). Для null-объектов применяется Optional.ofNullable(object).

6. Лямбда-выражения

Являются читабельным и выразительным способом обработки списков и коллекций по средством кода. Это метод без объявления (напр., модификатор доступа); возвращает значение и имя. Экономит время на объявлении и написании отдельного метода для содержащего класса.

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

  • Передаются как аргументы функций высшего порядка.
  • Используются для построения результата возвращаемой функции для функции высшего порядка.
  • Передаются как аргументы (основное назначение).

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

MathOperation addition = (int a, int b) -> a + b;
addition(a,b);

7. Стримы

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

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

Несколько примеров использования стримов:

Простой стрим

public void convertObjects() {
Stream<String> objectStream = Stream.of(“Hello”, “World”);
}

Массив в стриме

public void convertStuff() {
String[] array = {“hello”, “world”};
Stream<String> arrayStream = Arrays.stream(array);
}

Конкатенация нескольких списков в стрим

public void concatList() {
List<Integer> list1 = Arrays.asList(1, 2, 3);
List<Integer> list2 = Arrays.asList(4, 5, 6);
Stream.of(list1, list2) //Stream<List<Integer>>
.flatMap(List::stream) //Stream<Integer>
.forEach(System.out::println); // 1 2 3 4 5 6
}

Применение фильтра с условиями для стрима

public void filterNull() {
Stream.of(2, 1, null, 3).filter(Objects::nonNull).map(num -> num) 
// without filter, you would’ve got a NullPointerExeception
.forEach(System.out::println); 
}

Использование коллектора (Collector) для преобразования стрима в список

public void filterNull() {
Stream.of(2, 1, null, 3).filter(Objects::nonNull).map(num -> num) 
// without filter, you would’ve got a NullPointerExeception
.forEach(System.out::println); 
}

Пример с reduce в решении задачи

public void showCollect() {
List<Integer> filtered = Stream.of(0, 1, 2, 3).filter(num -> num).collect(Collectors.toList());
}

Сортировка данных в стриме

ppublic void showSort() {
Stream.of(3, 2, 4, 0).sorted((c1, c2) -> c1 — c2).forEach(System.out::println); // 0 2 3 4
}

8. Дополнительная информация

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


Поддержка реактивного программирования в Java 9

Реактивное программирование основано на потоках данных. Потоки данных исходят из одного компонента и движутся в другой. EventBus, события click, лента в Twitter — это те же асинхронные стримы событий или потоки данных. Класс Observable и интерфейс Observer — отличный пример парадигмы реактивности. Иначе говоря, реактивное программирование сводится к созданию следующей архитектуры: Event driven или Message driven (асинхронная), масштабируемой, отказоустойчивой и отзывчивой.

Java 9 представила Flow API и общие интерфейсы для пошаговой реализации реактивного программирования. Flow API берет на себя всю часть по взаимодействию (запрос, замедление, просмотр, блокировка и т.д.) и упрощает наш переход на реактивное программирование, позволяя работать без дополнительных библиотек (к примеру, RxJava и Project Reactor и т.д.). Во Flow предусмотрено 4 вложенных интерфейса:

  • Flow.Processor<T,R> → для преобразования входящего сообщения и его передачи следующему Подписчику нужна реализация интерфейса Processor. Подходит для последовательной передачи элементов от Издателей к Подписчикам.
public static interface Processor<T,R> extends Subscriber<T>, Publisher<R> {
}
  • Flow.Publisher<T> → создание/публикация элементов и обработка сигналов.
@FunctionalInterface
public static interface Publisher<T> {
public void subscribe(Subscriber<? super T> subscriber);
}
  • Flow.Subscriber<T> → Для получения сообщений и сигналов необходимо реализовать интерфейс Subscriber.
public static interface Subscriber<T> {
public void onSubscribe(Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
  • Flow.Subscription → связывает Издателя и Подписчика.
public static interface Subscription {
public void request(long n);
public void cancel();
}
  • java.util.concurrent.SubmissionPublisher<T> → Во Flow API есть только один класс Издателя с реализацией Flow.Publisher<T> для создания элементов, которые поддерживаются инициативой Reactive Streams.

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


Опубликованные и потребленные сообщения

В простом реактивном потоке Издатель публикует сообщения, а обычный Подписчик их потребляет — по одному в процессе появления. Издатель публикует поток данных, на который асинхронно подписан Подписчик.

В примере ниже показано, как класс SubmissionPublisher реализует Publisher. В Publisher есть только один метод — subscribe(), он позволяет подписчикам получать созданные события. А метод отправки SubmissionPublisher создает элементы.

public class FlowMain {

public static void main(String[] args) {

SubmissionPublisher<String> publisher = new SubmissionPublisher<>();
MySubscriber<String> subscriber1 = new MySubscriber<>(“One”);
MySubscriber<String> subscriber2 = new MySubscriber<>(“Two”);
publisher.subscribe(subscriber1);
publisher.subscribe(subscriber2);
publisher.submit(“Hello”);
publisher.submit(“Universe”);

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
publisher.close();
}
}

Теперь поговорим о методах интерфейса для Подписчика:

  • onSubscribe(subcription) → Издатель вызывает этот метод при появлении нового Подписчика. Обычно подписка либо сразу сохраняется, т.к. она нужна для отправки сигналов Издателю (запрос большего количества элементов, отмена подписки), либо непосредственно используется для запроса первого элемента (как и показано в примере).
  • onNext(item) → Этот метод вызывается при получении нового элемента. Как правило, там же происходит и обработка элемента, его логирование и запрос нового.
  • onError(throwable) → Вызывается Издателем, предупреждает Подписчика о каких-либо проблемах. Здесь же ведутся логи сообщений о сбоях при размещении элемента Издателем.
  • onComplete() → Вызывается при отсутствии элементов для отправки у Издателя. Указывает, что Подписка завершена.

Теперь посмотрим на реализацию Подписчика:

public class MySubscriber<T> implements Subscriber<T> {

private Subscription subscription;
private String name;

public MySubscriber(String name) {
this.name = name;
}

@Override
public void onComplete() {
System.out.println(name + “: Completed”);
}

@Override
public void onError(Throwable t) {
System.out.println(name + “: Weird... its an error”);
t.printStackTrace();
}

@Override
public void onNext(T msg) {
System.out.println(name + “: “ + msg.toString() + “ received a message”);
subscription.request(1);
}

@Override
public void onSubscribe(Subscription subscription) {
System.out.println(name + “: onSubscribe”);
this.subscription = subscription;
subscription.request(1);
}
}

В чем их отличие от шаблона «Наблюдатель»?

Такой вопрос может возникнуть. В Java уже есть класс Observable и интерфейс Observer. Так что зачем это новшество, и в чем их отличия?

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

Перевод статьи Madhu Pathy: A Beginner’s Guide to Java: Part 4 of 4

Предыдущая статьяСоветы молодым разработчикам
Следующая статьяЖизнь в качестве программиста-фрилансера