
Это первая часть небольшого цикла, посвящённого логическому программированию на языке 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. Сейчас же предлагаю задуматься, насколько проще могла бы выглядеть современная кодовая база, если бы доминирующей стала парадигма логического программирования и именно на неё были бы ориентированы компиляторы, среды исполнения, инструменты разработки, система обучения и даже само мышление программистов!
