В Scala уже есть механизмы логического программирования, почти как в Прологе.
В Scala уже есть механизмы логического программирования, почти как в Прологе.

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

Моделирование задачи

Scala, как и многие другие языки программирования, позволяют закодировать одну и ту же логику в разных стилях. Давайте посмотрим как это может выглядеть на примере простой задачи:

  • загрузить текст по ссылке;

  • найти в тексте лимитированное количество чисел;

  • вывести их в консоль;

  • если распечатали хотя бы одно число, то вернуть ExitCode 0.

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

Сперва сформулируем бизнес-функции программы:

import cats.effect.{ExitCode, IO}

type ArgsParser    = List[String]     => IO[ScanUrl]      // получаем url из списка аргументов
type ContentLoader = ScanUrl          => IO[Content]      // загружаем текст
type LimitGetter   = Any              => IO[NumLimit]     // получаем лимит
type ContentParser = ParsingContent   => IO[List[Num]]    // извлекаем числа
type NumberPrinter = Num              => IO[PrintedNum]// выводим в консоль одно число
type Resulter      = List[PrintedNum] => ExitCode         // определяем код выхода

Это верхнеуровневая бизнес-логика и для простоты мы не будем опускаться на уровень ООП-шных сервисов.

Наши функции переводят программы из одного состояния в следующее, из них только начальное и конечное жёстко фиксированы сигнатурой метода IOApp.run: List[String] => ExitCode. Прочие состояния опишем как классы-обёртки над примитивными типами:

case class ScanUrl(str: String)                           // Адрес сканируемого сайта
case class Content(str: String)                           // Контент сайта
case class NumLimit(n: Int)                               // Ограничение на количество чисел
type ParsingContent = (content: Content, limit: NumLimit) // Контент с ограничением для разбора
case class Num(n: Int)                                    // Искомые числа
case class PrintedNum()                                   // Признак выполненой задачи печати

Для сравнения стилей нам достаточно только типобезопасности, поэтому обойдёмся простыми case class вместо более строгих решений вроде непрозрачных псевдонимов или уточнённых типов.

Реализация бизнес-функций

Теперь нужно предоставить реализации бизнес-функций. Для сетевого запроса воспользуемся «янтарным» клиентом из http4s, для которого подключим журналирование из log4cats, а лимит чисел просто захардкодим:

import cats.effect.{ExitCode, IO}  
import logic.models.*  
import org.http4s.ember.client.EmberClientBuilder  
import org.typelevel.log4cats.LoggerFactory  
import org.typelevel.log4cats.slf4j.Slf4jFactory

given loggerFactory: LoggerFactory[IO] = Slf4jFactory.create[IO]  
  
given parseArgs: ArgsParser = args =>  
  IO  
    .fromOption(args.headOption)(new Throwable("Передайте Url!"))  
    .map(ScanUrl.apply)  
  
given loadContent: ContentLoader = url =>  
  EmberClientBuilder  
    .default[IO].build  
    .use(_.expect[String](url.str))  
    .map(Content.apply)  
  
given extractNumbers: ContentParser =  
  case (content, limit) => IO.blocking: // для ОЧЕНЬ БОЛЬШИХ файлов и лимитов
    "\\d+".r  
      .findAllIn(content.str)  // ищем подстроки из цифр
      .flatMap(_.toIntOption)  
      .take(limit.n)           // ограничиваем количество
      .map(Num.apply)  
      .toList  
  
given obtainLimit: LimitGetter = _ => IO.pure(NumLimit(10)) // хардкод для разнообразия  
  
given printNumber: NumberPrinter = num =>  
  IO  
    .println(num.n)  
    .as(PrintedNum())  
  
given calcExitCode: Resulter =  
  case _ :: _ => ExitCode.Success  // если распечатали хоть что-то, то 0
  case Nil    => ExitCode.Error    // если ничего не нашлось, то 1

В глаза сразу бросается «толстый намёк» в виде given, но пока можете не обращать внимания. По сути, это обычные переменные val только с опцией размещения значений в контекст области видимости. Шаги и их реализации нарочно выбраны «разношёрстными»: есть функции с эффектом IO и «простые» (calcExitCode), с одним аргументом и с несколькими (extractNumbers). Это позволяет захватить больше аспектов типичного программного продукта.

Императивный стиль

В наиболее популярном императивном стиле итоговую программу можно собрать так:

import cats.syntax.all.*

object ioApp extends IOApp:
  def run(args: List[String]) = for  
    url     <- parseArgs(args)  
    content <- loadContent(url)  
    limit   <- obtainLimit(())  
    nums    <- extractNumbers(content, limit)  
    printed <- nums.traverse(printNumber)  
  yield calcExitCode(printed)

Это, конечно, не пресловутый direct style, но разница не принципиальна — программа состоит из последовательности императивов (приказов) вида «передай переменную в функцию» и «положи вычисленный результат в переменную». Такой дедовской технике обучают ещё со школы и она привычна большинству программистов. На неё ориентированы инструменты разработчика и среда исполнения. И всё же, императивный стиль содержит ряд неустранимых недостатков, провоцирующих неочевидные ошибки.

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

  • в стеке накапливаются «мусорные» переменные которые могут по ошибке использоваться вместо нужных (в наших примерах мы хорошо защищены типами, но в реальных проектах встречалось и не такое безобразие),

  • какие-то вычисленные переменные могут быть вообще забыты вследствие перегрузкок функций (компилятор обычно об этом предупреждает, но всё равно пропускает),

  • порядок шагов может быть перепутан, особенно когда игнорируются результаты выполнения команд; в лучшем случае это даст неоптимальные алгоритмы (если вызов obtainLimit переместить в начало, то это вычисление окажется лишним, когда в программу даже не передали адрес сайта), а в худшем неправильный порядок эффектов приведёт к несогласованному состоянию.

Порождённые такими ошибками сбои бывает очень сложно обнаружить.

Псевдофункциональный стиль

Императивный стиль часто противопоставляют «монадическому»:

def run(args: List[String]) =  
  parseArgs(args)  
    .flatMap(loadContent)  
    .product(obtainLimit(()))  
    .flatMap(extractNumbers)  
    .flatMap(_.traverse(printNumber))  
    .map(calcExitCode)

Его также называют «функциональным», но на самом деле это просто ООП-шный паттерн «текучего интерфейса» (fluent interface). Впрочем, даже в таком виде он почти полностью устраняет недостатки императивного стиля:

  • нет никаких мимолётных переменных, кроме args — практически невозможно запутаться и ошибиться;

  • каждый шаг атомарен — он знает только результат предыдущего шага, следовательно, невозможно перепутать последовательность шагов.

Некоторым программистам «монадический» стиль не нравится потому, что тут много точек, скобок и тяжеловесных flatMap, а пробелов, упрощающих чтение, не хватает. Но тот же самый код можно записать чуть по-другому, используя инфиксную форму вызова этих методов:

def run(args: List[String]) =  
  parseArgs(args)           flatMap  
  loadContent               product  
  obtainLimit(())           flatMap  
  extractNumbers            flatMap  
  {_.traverse(printNumber)} map  
  calcExitCode

Обратите внимание: в левой колонке находится самое важное — последовательность наших бизнес-действий, а комбинаторы перенесены в правую колонку. И больше никаких «мусорных» точек и скобочек!

По аналогии со стрелочками <- из for-выражений можно было бы использовать символьные псевдонимы комбинаторов вроде >>=. Но полезных комбинаторов оказывается слишком много для запоминания их псевдонимов. Кроме того, символьные и буквенные операторы плохо сочетаются, так как у них разный приоритет в Scala (да и читаться будет ужасненько). Так что лучше использовать только буквенные комбинаторы.

«Монадический» стиль неплохо справляется со своей задачей, но ещё более простым и выразительным оказывается истинно функциональный стиль.

Функциональный стиль

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

Нашу программу можно записать в функциональном бесточечном стиле так:

val program =
  parseArgs                        andThenF  
  (loadContent mergeF obtainLimit) andThenF  
  extractNumbers                   andThenTraverse  
  printNumber                      andThenMap  
  calcExitCode  
  
def run(args: List[String]) = program(args)

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

Недостающие комбинаторы

Реализации недостающих комбинатор достаточно тривиальны:

extension [F[_] : Functor as F, A, B](afb: A => F[B])  
  infix def andThenMap[C](bc: B => C): A => F[C] =  
    afb andThen bc.liftN[F]  
  
extension [F[_]: Applicative as F, A, B](afb: A => F[B])  
  infix def mergeF[C](afc: A => F[C]): A => F[(B, C)] =  
    (afb merge afc) andThen F.product  
  
extension [F[_]: Monad as F, G[_]: Traverse as G, A, B](afgb: A => F[G[B]])  
  infix def andThenTraverse[C](bfc: B => F[C]): A => F[G[C]] =  
    afgb andThenF (bfc.liftN[G] andThen G.sequence) // {_.traverse(bfc)}

Можно было бы обойтись и без них, но с ними код становится приятнее.

«Бесточечность» избавляет от упоминания любых значений-состояний, вроде args и (). Код в функциональном стиле выглядит наиболее читаемым, выразительным и защищённом от возникновения ошибок. Ровно до тех пор, пока на сцене не появляется

Стиль логического программирования

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

Конечный автомат для нашей программы.
Конечный автомат для нашей программы.

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

Scala позволяет автоматизировать единственную минималистичную композицию функций в нужную сигнатуру, если сами функции это вообще позволяют. С такой автоматизацией мы сможем просто приказать компилятору вывести (infer) реализацию программы, чем сразу закроем все вопросы о стиле:

def run(using args: List[String]) = Infer[IO[ExitCode]]

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

Описанные в начале статьи типы бизнес-функций A => IO[B] объявляют теоремы, в которых из истинности (обитаемости) A следует истинность (обитаемость) IO[B]. Реализации же этих функций являются доказательствами теорем. Мы размещаем их в контекст с помощью ключевого слова given так, чтобы всегда можно по теореме найти её доказательство (тип => значение). Выражение Infer[IO[ExitCode]] заставляет компилятор найти в контексте подходящие доказательства теорем (в том числе и args: List[String]) и с их помощью вывести доказательство обитаемости IO[ExitCode] — алгоритм вычисления его значения!

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
А какие стили вы считаете наиболее интересными?
25%Direct style1
50%Императивный стиль с for-выражениями2
0%«Монадический» fluent-стиль0
0%«Монадический» стиль с инфиксными комбинаторами0
25%Функциональный бесточечный стиль1
50%Логическое программирование2
0%Вайб-коддинг0
Проголосовали 4 пользователя. Воздержавшихся нет.