Квитанции как способ отражения сделанной работы на уровне типов

4 мин на чтение

Функциональное программирование одной из целей ставит отражение логики программы в типах входных/выходных значений функций. Типы аргументов и результатов накладывают существенные ограничения на то, как может быть реализована функция. Тем самым, позволяют делать разумные выводы о работе функции, ориентируясь только на её сигнатуру. Такое явление называется “параметричность”. Замечательным примером параметричности служит такая сигнатура:

val f: [A] => A => A

Эту сигнатуру можно прочитать так: для любого типа, получив значение этого типа, вернуть какое-то значение того же типа. Исходя из того, что тип может быть любым, и никаких операций над этим типом мы не определили, единственной продуктивно завершающейся реализацией является identity. Здесь и далее мы исключаем непродуктивные решения вида f(a) = f(a) (зависание/отсутствие завершения) или f(a) = throw Exception() (исключение).

Для представления эффектов часто используется конструкция IO[A]. Значение из этого объекта можно получить, только выполнив код, содержащийся внутри. Довольно часто можно столкнуться с ситуацией, когда само значение нам не настолько интересно, как факт выполнения определённой операции. Обычно используется тип возвращаемого значения IO[Unit]. В этой заметке предлагается воспользоваться параметричностью, чтобы получить определённые гарантии.

Гарантия логирования

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

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

sealed trait LogReceipt

def log(message: String): IO[LogReceipt] = 
  IO{/* собственно логирование */}
    .as(new LogReceipt{})

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

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

Гарантия сохранения в базу

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

В библиотеке сделаем тип с дженерик-параметром:

sealed trait SavedToDBReceipt[A]

def saveToDB[A](a: A): IO[SavedToDBReceipt[A]] =
  IO{/*эффект — сохранение в БД*/}
    .as(new SavedToDBReceipt[A]{})

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

Альтернативная реализация

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

opaque type SavedToDBReceipt[A] = Long

def saveToDB[A](a: A): IO[SavedToDBReceipt[A]] =
  IO{/*эффект — сохранение в БД, возвращает идентификатор из БД*/}
    .map(id => id: SavedToDBReceipt[A])

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

Свидетельство нескольких эффектов

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

Накапливать свидетельства можно в обыкновенном tuple. В Scala 3 появился удобный оператор *:, наподобие HList‘а.

def create[A](a: A): F[(LogReceipt, SavedToDBReceipt[A], SavedToDBReceipt[Event[A]])] = 
  for
    lr <- log("create")
    sa <- saveToDB(a)
    sea <-saveToDB(event(a, Created))
  yield
    (lr, sa, sea)

Tuple помимо собственно значений и их типов также сохраняет порядок. Если для уровня бизнес-требований порядок неважен, то можно воспользоваться структурой в пространстве типов — множеством. Один из вариантов такой структуры реализован в библиотеке type-sets:

def create[A](a: A): F[Set3[LogReceipt, SavedToDBReceipt[A], SavedToDBReceipt[Event[A]]]] = 
  for
    lr <- log("create")
    sa <- saveToDB(a)
    sea <-saveToDB(event(a, Created))
  yield
    Set(lr, sa, sea)

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

def handle(request): IO[Unit] = 
  for
    a        <- request.as[A]
    receipts <- create(a)
    _        = setEquals[Set3[
      SavedToDBReceipt[A], 
      SavedToDBReceipt[Event[A]], 
      LogReceipt
    ]](receipts)
    _        = setIsASuperset[Set1[LogReceipt]](receipts)
  yeild
    Http.SuccessOk

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

(Внимание! упомянутые функции реализованы в пространстве типов, работают на этапе компиляции, имеют сложность O(n^2).)

Проблема F[Unit]

В свете вышеизложенного можно понять, что привычный способ представления эффектов в виде F[Unit] обладает существенным недостатком — на уровне типов не отражается существенное явление с точки зрения бизнес-логики. Следовательно, компилятор не защищает нас от ошибок пропуска важного действия. Функция f(): F[Unit], внутри которой имеется логирование, ничем снаружи не отличается от функции g(): F[Unit], в которой такого логирования нет.

Если же мы будем требовать сигнатуру вида def serve(f: () => F[LogReceipt]), то такому требованию может удовлетворить только функция, на самом деле выполняющая требуемый эффект.

Взаимодействие с “полномочиями” (capabilities)

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

val f: [A] => ((a: A) => F[SavedToDB[A]]) ?=> F[SavedToDB[A]]

(Естественно, результат может содержать что-то ещё, помимо квитанции.)

Заключение

В настоящей заметке кратко изложена идея использования квитанций для подтверждения выполненной работы. Обычное представление “результата” эффекта в виде Unit‘а, не позволяет на верхнем уровне приложения убедиться, что все необходимые эффекты были на самом деле выполнены.

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

Дата изменения: