В заметке Ричарда Тауэрса (Richard Towers) Typescripting the technical interview (есть перевод на Хабре: Руны и лёд: техническое собеседование по TypeScript) по ходу повествования была решена классическая задача расстановки 8 ферзей на шахматной доске. Для решения использовалась система типов TypeScript. Мне захотелось посмотреть, как эта задача будет выглядеть на Scala. Т.к. Scala 3 помимо развитой системы типов предлагает превосходную поддержку метапрограммирования, то здесь мы рассмотрим не только решение на типах, но и мета-программное решение. Без необходимости запуска программы компилятор сформирует тип, представляющий решение.
Для удобства этот результирующий тип можно конвертировать в экземпляр, значение которого будет выведено при запуске программы. Вслед за Richard Towers введём собственные типы для Boolean: целых чисел: и списков: Определим операции для Boolean: Также потребуется операция ИЛИ для списка Boolean: Вычитание (абсолютное значение) (исправлено по замечанию ARad): и сравнение чисел: генерация диапазона чисел: Объявим "структуру данных", представляющую одного ферзя: Начальная расстановка ферзей — все ферзи, находящиеся в колонках cols, получают одинаковое значение row: Одна королева угрожает другой в одном их трёх случаев — совпадают x, совпадают y, или абсолютная разность по осям одинаковая: Отфильтруем уже размещённых ферзей исходя из того, угрожают ли они заданному ферзю: Ферзь находится "в безопасности", если этот список пуст: Теперь мы можем отфильтровать уже всех потенциальных кандидатов, чтобы оставить только варианты, безопасные относительно уже размещённых ферзей: Можно сформировать следующий ряд потенциальных расстановок ферзей: (Здесь упомянут размер доски N, который будет определён ниже.) Функция Next принимает на вход список уже размещённых ферзей и номер следующего ряда. Возвращает набор ферзей-кандидатов в этом ряду, которые не конфликтуют с уже размещёнными ферзями. Следующие функции рекурсивно решают задачу поиска решения. Разберём работу этих функций немного подробнее. Solve принимает на вход список кандидатов candidates, которых надо рассмотреть, находящихся в ряду row, при том, что уже имеются расставленые ферзи placedQueens. В случае, если мы оказались на последнем ряду Equals[row, N], и у нас имеется хоть сколько-то кандидатов, то мы берём из них первого кандидата, приклеиваем к уже размещённым ферзям и возвращаем готовое решение. Если это ещё не последний вариант, то мы пробегаем каждого из предложенных вариантов, приклеиваем его к размещённым ферзям и продвигаем решение вперёд. Если для какого-то кандидата решения не найдено, то убираем этого кандидата и продолжаем с оставшимися кандидатами. Если же решение найдено, то приклеиваем этого кандидата уже окончательно и снова решаем задачу уже зная, что решение существует. Теперь осталось подготовить пустое поле и запустить поиск решения: Решение представляет собой тип списка ферзей. Чтобы отобразить тип в виде обычной строки, воспользуемся набором implicit объектов. Во-первых, нам необходимо тип целых чисел уметь конвертировать в число: Далее нам надо уметь преобразовывать разные типы в строки: В частности, целые числа: и ферзей Осталось собрать список: И отрисовать его: В переводе и в оригинале шахматная доска, похоже, имеет размер 7x7. В этом случае решение имеет вид: В случае шахматной доски 8х8: В этом варианте решения мы воспользуемся тем, что Scala 3 предлагает неплохой набор базовых типов, пригодных для вычислений, и ряд синтаксических улучшений, упрощающих работу с типами. Нет необходимости создавать свой тип Boolean. Благодаря синглетонным типам символы true и false можно непосредственно использовать на уровне типов. Пакет import scala.compiletime.ops.boolean.* содержит все необходимые операции. Можно только для удобства добавить If: В Scala имеется поддержка целых чисел на уровне типов в пакете import scala.compiletime.ops.int.{S, Abs, -, +}. Равенство типов можно проверить с использованием типа == — import scala.compiletime.ops.any.{==}.  Список сохраним такой же, как был объявлен ранее, чтобы не добавлять сторонних библиотек. (В библиотеках Scala можно найти, например, HList, который можно было бы использовать здесь.): Вместо Cons мы используем операторное имя ::, благодаря которому можно красиво и удобно конструировать тип: x :: xs =:= ::[x,xs]. Добавим несколько вспомогательных операций, которые обыкновенно используются при работе со списками: Как видно, объявления функций на типах мало чем отличаются от объявлений обычных функций, работающих со значениями. С использованием этих возможностей можно немного упростить решение на типах. Объявим "структуру данных", представляющую одного ферзя: Здесь мы требуем, чтобы координаты были целочисленными. Начальная расстановка ферзей — все ферзи, находящиеся в колонках cols, получают одинаковое значение row: Одна королева угрожает другой в одном их трёх случаев — совпадают x, совпадают y, или абсолютная разность по осям одинаковая: Любопытно, что выражение на типах выглядит совершенно также, как выражение на значениях. Ферзь находится "в безопасности", если не существует уже размещённого ферзя, который бы ему угрожал: Теперь мы можем отфильтровать уже всех потенциальных кандидатов, чтобы оставить только варианты, безопасные относительно уже размещённых ферзей: Можно сформировать следующий ряд потенциальных расстановок ферзей: (Здесь упомянут размер доски N, который будет определён ниже.) Функция Next принимает на вход список уже размещённых ферзей и номер следующего ряда. Возвращает набор ферзей-кандидатов в этом ряду, которые не конфликтуют с уже размещёнными ферзями. Следующие функции рекурсивно решают задачу поиска решения. Теперь осталось подготовить пустое поле и запустить поиск решения: Как и раньше, решение может быть преобразовано в значения с использованием implicit'ов. При запуске программы будет выполнена некоторая последовательность шагов, в результате которой будет получено решение. Программу можно получить путём ряда замен: Вот получившаяся программа: Результаты (для 6 и 7): Без необходимости запуска программы компилятор выполнит вычисления, и получит экземпляр, представляющий решение, значение которого будет выведено при запуске программы. Программа, находящая решение, уже реализована выше. Нам остаётся лишь вызвать её в процессе компиляции и результат представить в виде выражения. Вызов макроса производится с использованием сплайсинга: Сам макрос при вызове находит решение и формирует выражение, представляющее результат: Т.к. мы используем наш собственный тип Queen, а простое Expr(queen) для пользовательских типов не работает, то его необходимо представлять и в процессе работы макроса (Expr[Queen]) и в самом выражении (Queen). Для этого нам потребуется конвертер ToExpr: В настоящей заметке проблема расстановки ферзей решена несколькими способами: С прикладной точки зрения, выполнение вычислений на этапе компиляции позволяет алгоритмически конструировать корректные программы, которые будут работать в реальном времени. Простой пример — вычисление коэффициентов цифрового фильтра в процессе компиляции позволяет получить маленькую компактную программу, не включающую сложную библиотеку для расчётов. Выполнение вычислений на типах сродни доказательству теорем о программе. Например, можно совместить реализацию алгоритма с механизмом расчёта типов и гарантировать некоторые свойства решения, выраженные в типах. Такие гарантии гораздо надёжнее, чем юнит-тесты, проверяющие лишь несколько отдельных случаев. Как обычно, с кодом можно ознакомиться на github. Scala архитектор