Стрілки як підхід до представлення систем на 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()
, що виконує "дія за замовчуванням" (для фільтра
Filter
filter()
, для
Amplifier
amplify()
і т. д.) і дозволяє викликати об'єкт як функцію.
При функціональному підході ці класи були б функціями, які повертають функції, так що нам не довелося б навіть створювати екземпляри класів і вся програма виглядала б приблизно так:
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
, можна представити у вигляді структурної схеми:

Вхід цієї стрілки має тип
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


Вийшла "широка" стрілка
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)


Паралельне з'єднання в даному випадку означає, що результати двох обчислень, з'єднаних паралельно, не залежать один від одного — на відміну від послідовного з'єднання методом
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)



Ці методи аналогічні методам
someArrow.bypass2nd()
та
someArrow.bypass1st()
відповідно.
Повнота опису
Згідно Хьюзу, опис обчислення можливо з використанням лише трьох функцій:
  • 1) Конструктора, який будує стрілку з функції (у даній реалізації Action.of)
  • 2) Функції, послідовно з'єднує дві стрілки (Arrow::join)
  • 2) Функції, що застосовує обчислення до частини входу (Arrow::first)
Реалізація
jArrows
також розширена додатковими методами, які спрощують опис систем.
Висновки
Високорівневе опис процесів у вигляді блок-схем практично не використовується імперативний підхід в програмуванні. У теж час такий опис добре вкладається в функціональний реактивний підхід, одержує все більше поширення.
Як показано в статті Хьюза, стрілки, по-суті, є реалізацією опису систем у вигляді блок-схем в рамках функціонального програмування, є більш узагальненим описом, ніж монади, які вже набули поширення в мейнстрімі, зокрема у вигляді їх реалізації
Optional
в Java 8. У даній статті описані основні принципи цього підходу, надалі становить інтерес адаптація існуючих і розробка нових патернів для застосування стрілок у мейнстрімовий розробці програмного забезпечення.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.