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

Примечание. Вы можете найти блокнот со всем кодом в этом репозитории GitHub.

В прошлой статье мы начали с реализации небольшого фреймворка для функционального реактивного программирования на Scala. Нашей целью было написать реализации для Signal и Var, которые позволят нам делать следующее:

Предполагается, что вызов total() будет всегда возвращать объединенный баланс обоих наших BankAccount.

Мы добились реализации для BankAccount, которая работала так, как мы ожидали, но консолидация еще не совсем удалась. Причина заключалась в том, что с нашей реализацией Signal и Var, consolidated вычислялся один раз при инициализации, а затем оставался неизменным навсегда. Напомним, что это была наша реализация Signal и Var:

Что привело к сбою нашей реализации, так это то, что мы немедленно оцениваем initVal при передаче его в конструктор Signal. Более поздние изменения не имеют значения просто потому, что значение Signal не переоценивается.

Итак, как нам это обойти? Давайте воспользуемся некоторыми инструментами из функционального программирования.

Использование функционального программирования

Мы хотим добиться того, чтобы total пересчитывалась каждый раз, когда изменяется один из BankAccount балансов. Другими словами, Signal, возвращаемый consolidated, должен быть функцией других сигналов, от которых он зависит. (Если вы бегло просмотрели эту часть, возможно, вы захотите прочитать ее еще раз.)

Добро пожаловать в Функциональное программирование. До сих пор мы передавали только целые числа. Теперь мы хотим передать произвольные функции.

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

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

(Если вы немного не уверены в вызове по значению и вызове по имени, ознакомьтесь с этим кратким и простым объяснением).

Давайте посмотрим, как это можно реализовать:

Мы внесли здесь несколько изменений:

  1. Мы заменили наши целочисленные типы универсальными типами во всех применимых местах.
  2. Мы переименовали initVal в expr, чтобы проиллюстрировать, что мы больше не просто передаем целочисленные значения, а работаем с произвольными выражениями. Кроме того, мы определили expr как вызов по имени (expr: => T), что означает, что он не будет оцениваться немедленно.
  3. Мы добавили еще одну переменную curExpr, которая хранит наше выражение без его оценки и может быть обновлена ​​до нового выражения при необходимости. Возможно, потребуется несколько взглядов на синтаксис var curExpr: () => T = () => expr, чтобы осмыслить. Он определяет var типа () => T (анонимная функция) со значением () => expr. Затем мы можем вызвать curExpr(), чтобы оценить выражение.
  4. Наш update метод теперь обновляет как curExpr, так и curVal

Это приближает нас на шаг ближе к нашей первой работающей реализации. Но, как вы уже догадались, пока не работает. expr оценивается, когда мы достигаем private var curVal = expr. Так что ничего особенного не меняется. (Если вы запустите код из конца последней статьи с этими реализациями Signal и Var, вы получите те же результаты. Я рекомендую вам попробовать это на себе.)

Как я писал выше, total необходимо пересчитывать каждый раз, когда изменяется один из наших BankAccount балансов. Чтобы гарантировать это, нам нужно отслеживать Signals, которые зависят от наших индивидуальных балансов (т. Е. «Наблюдать»). Если мы не знаем, какие Signal зависят от наших балансов, мы не знаем, что нам нужно пересчитать после их изменения.

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

Итак, как мы можем решить эту проблему для нашей реализации Signal и Var? Давайте посмотрим.

Отслеживание зависимостей

Один из простых подходов к отслеживанию того, какие Signal должны быть пересчитаны после изменения конкретного Signal, - это специально передать «объекты», от которых он зависит, его конструктору. Когда мы инициализируем Signal, нам нужно сообщить ему две вещи:

  1. Выражение, которое предполагается вычислить
  2. Остальные Signal должны следить за изменениями, поэтому он может пересчитывать свое значение, когда они меняются.

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

Мы изменили здесь еще несколько вещей:

  1. Теперь есть необязательный параметр конструктора observed: List[Signal[_]] = Nil. Его можно использовать для передачи списка Signal, от которого зависит определенный Signal. Как видите, по умолчанию используется Nil. Итак, если вы ничего не передадите, наши вновь определенные Signal не будут обновлены, когда другие Signal изменят свои значения.
  2. Мы добавили private var observers, который инициализирован как пустой Set. Когда инициализируется новый Signal, он проходит через observed и добавляется к observers из всех наблюдаемых Signal: observed.foreach( obs => obs.observers += this )
  3. Мы добавили метод computeValue, который обновляет текущее значение Signal, оценивая его текущее выражение, и все его наблюдатели также обновляют свои значения.
  4. update использует наш новый метод computeValue

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

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

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

Плохая новость заключается в том, что наш код довольно часто повторяется и подвержен ошибкам. При определении нашей функции consolidated нам нужно передать как функцию, которую она вычисляет (accts.map(_.balance()).sum), так и Signals, от которых она зависит (accts.map(_.balance)), в конструктор Signal. Как видите, это почти один и тот же код дважды - по крайней мере, в этом простом случае.

(Кроме того, при обновлении нашего выражения в Var, мы в настоящее время не будем обновлять observed Сигналы. Мы, вероятно, могли бы обойти это, передав новый observed с вызовами update, но скоро это станет уродливым.)

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

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