Стрелки как подход к представлению систем на Java

в 11:08, , рубрики: functional programming, java, Анализ и проектирование систем, Программирование, функциональное программирование

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

В статье рассматривается подход к представлению структурных схем с использованием концепции стрелок (arrows), описанных Джоном Хьюзом и нашедших применение в Haskell в FRP-фреймворках Yampa и Netwire, а также в XML-фреймворке Haskell XML Toolbox.

Особенностью структурных схем является наглядное представление последовательностей операций (блоков) без акцентирования внимания на самих обрабатываемых данных (переменных) и их состояниях. Для примера рассмотрим радиоприёмник прямого усиления

структурная схема приёмника

Как же реализовать такой способ описания систем и вычислений в рамках существующих мейнстримовых языков программирования?

Традиционное описание такой схемы на C-подобном языке программирования выглядело бы примерно так

// Создаём блоки обработки
Antenna antenna = new Antenna(Ether.getInstance());
Filter filter1 = new Filter(5000);      // параметр - частота настройки
Filter filter2 = new Filter(5000);
Filter filter3 = new Filter(5000);

Detector detector = new Detector("AM"); // тип модуляции - амплитудная
Amplifier amp = new Amplifier(5);       // коэффициент усиления
Speaker speaker = new Speaker(10);      // громкость

Signal inputSignal = antenna.receive();

# Описываем связи между блоками
Signal filter1Res = filter1.filter(inputSignal);
Signal filter2Res = filter2.filter(filter1Res);
Signal filter3Res = filter3.filter(filter2Res);
Signal detected = detector.detect(filter3Res);
Signal amplified = amp.amplify(detected);

speaker.speak(amplified);

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

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


Receiver receiver = Receiver.join(filter1).join(filter2).join(filter3)
    .join(detector).join(amp).join(speaker);

receiver.apply(antenna.receive());

Метод join() описывает последовательное соединение блоков, то есть a.join(b) означает, что результат обработки блоком a будет передан на вход блока b. При этом лишь требуется, чтобы соединяемые классы Filter, Amplifier, Detector, Speaker дополнительно реализовывали метод apply(), выполняющий "действие по умолчанию" (для фильтра Filterfilter(), для Amplifieramplify() и т. д.) и позволяющий вызывать объект как функцию.

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

Receiver receiver = Receiver.join(filter(5000)).join(filter(5000)).join(filter(5000))
    .join(detector("AM")).join(amplifier(5)).join(speaker(10));

receiver.apply(antenna.receive());

Стрелки как способ описания вычислений

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

Стрелки (arrows) также являются комбинатором и позволяют обобщенно описывать составные вычисления. В этой статье используется реализация стрелок jArrows, написанная на Java 8.

Что такое стрелка

Стрелка Arrow<In, Out> a от функции Out f(In x) представляет вычисление, которое выполняется функцией f. Как вы уже могли догадаться In — тип входного значения стрелки (принимаемого функцией f), Out — тип выходного значения (возвращаемого функцией f). Преимуществом представления вычислений в виде стрелок является возможность явного комбинирования вычислений различными способами.

Например вычисление y = x * 5.0, на Java представленное функцией

double multBy5_0(int in) { 
    return in*5.0; 
}

можно представить в виде стрелки

Arrow<Integer, Double> arrMultBy5_0 = Action.of(multBy5_0);

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

Композиция стрелок

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

Arrow<Integer, String> mult5Plus10toStr = arrMultBy5_0.join(in -> in+10.5)
                                                      .join(in -> String.valueOf(in));
mult5Plus10toStr.apply(10);      //  "60.5"                  

Получившееся вычисление, представленное составной стрелкой mult5Plus10toStr, можно представить в виде структурной схемы:

Стрелки как подход к представлению систем на Java - 2

Вход этой стрелки имеет тип Integer (входной тип первого вычисления в цепочке), а выход имеет тип String (выходной тип последнего вычисления в цепочке).

Метод someArrow.join(g) соединяет в цепочку вычисление, представленное стрелкой someArrow с вычислением, представленным g, при этом g может быть другой стрелкой, лямбда-функцией, методом, или чем-то ещё, что реализует интерфейс Applicable с методом apply(x), который можно применить к входному значению x.

Несколько упрощенная реализация join

class Action<In, Out> implements Arrow<In, Out>, Applicable<In, Out> {
    Applicable<In, Out> func;

    public Arrow<In, OutB> join(Applicable<Out, OutB> b) {
        return Action.of(i -> b.apply(this.func.apply(i)));
    }
}

Здесь In — тип входных данных стрелки a, OutB — тип выходных данных b, и он же — тип выходных данных получившейся новой составной стрелки a_b = a.join(b), Out — тип выходных данных стрелки a, он же — тип входных данных стрелки b.

Функция func хранится в экземпляре стрелки, инициализируется при её создании и выполняет само вычисление. Аргумент b поддерживает интерфейс Applicable и может быть другой стрелкой или функцией, поэтому мы просто применяем b к результату применения a.func(i) к входным данным i стрелки a_b. Сам входные данные будут переданы при вызове apply составной стрелки a_b, так что a_b.apply(x) вернёт результат вычисления b.func(a.func(x)).

Другие способы композиции стрелок

Кроме последовательного соединения методом join стрелки можно соединять параллельно методами combine, cloneInput и split. Пример использования метода combine для описания вычисления sin(x)^2+cos(x)^2

Arrow<Pair<Double, Double>, Pair<Double, Double>> 
    sin_cos = Action.of(Math::sin).combine(Math::cos);

Arrow<Double, Double> sqr = Action.of(i -> i*i);

Arrow<Pair<Double, Double>, Double> sum_SinCos = sin_cos.join(sqr.combine(sqr))
                            .join(p -> p.left + p.right);

sum_SinCos.apply(Pair.of(0.7, 0.2));    // 1.38

Стрелки как подход к представлению систем на Java - 3

Получившаяся "широкая" стрелка sin_cos принимает на вход пару значений типа Pair<Double, Double>, первое значение pair.left пары попадает на вход первой стрелки (функция sin), второе pair.right — на вход второй стрелки (функция cos), их результаты тоже объединяются в пару. Следующая составная стрелка sqr.combine(sqr) принимает на вход значение типа Pair<Double, Double>и возводит оба значения пары в квадрат. Последняя стрелка суммирует результат.

Метод someArrow.cloneInput(f) создаёт стрелку, параллельно соединяя someArrow и f и применяя их к входу, её выход представляется в виде пары, объединяющей результаты вычилений этих стрелок. Входные типы someArrow и f должны совпадать.

Arrow<Integer, Pair<Integer, Double>> sqrAndSqrt = Action.of((Integer i) -> i*i)
                            .cloneInput(Math::sqrt); 
sqrAndSqrt.apply(5);  // Pair(25, 2.236)

Стрелки как подход к представлению систем на Java - 4

Параллельное соединение в данном случае означает, что результаты двух вычислений, соединённых параллельно, не зависят друг от друга — в отличии от последовательного соединения методом join, когда результат одного вычисления передаётся на вход другого. Многопоточные параллельные соединения реализуется классом ParallelAction.

Метод someArrow.split(f, g) — дополнительный метод, эквивалентный someArrow.join(f.cloneInput(g)). Результат вычисления someArrow параллельно передаётся на вход f и g, выходом такой стрелки будет пара с результатами вычислений f и g.

Обход вычислений

Иногда нужно передать часть входного значения стрелки далее по цепочке вместе с результатом вычисления. Это реализуется методом someArrow.first() и дополняющим его someArrow.second(), преобразующим стрелку someArrow так, что получившаяся стрелка принимает на вход пару значений и применяет вычисление лишь к одному из элементов этой пары

Arrow<Integer, Double> arr = Action.of(i -> Math.sqrt(i*i*i));

Pair input = Pair.of(10, 10);

arr.<Integer>first().apply(Pair.of(10, 10)));     // Pair(31.623, 10)
arr.<Integer>second().apply(Pair.of(10, 10)));    // Pair(10, 31.623)

Стрелки как подход к представлению систем на Java - 5

Стрелки как подход к представлению систем на Java - 6

Эти методы аналогичны методам someArrow.bypass2nd() и someArrow.bypass1st() соответственно.

Полнота описания

Согласно Хьюзу, описание любого вычисления возможно с использованием лишь трёх функций:

  • 1) Конструктора, строящего стрелку из функции (в данной реализации Action.of)
  • 2) Функции, последовательно соединяющей две стрелки (Arrow::join)
  • 2) Функции, применяющей вычисление к части входа (Arrow::first)

Реализация jArrows также расширена дополнительными методами, упрощающими описание систем.

Выводы

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

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

Автор: yarric

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js