flang — полный язык поверх FTS Тотальность: как доказывается завершение
0%

Тотальность: как доказывается завершение

Тотальность: как доказывается завершение

Пометка тотальная ничего не значила бы, если бы её не проверяли. Проверяет flang/src/totality.mjs — 418 строк, из которых первые шестьдесят представляют собой развёрнутое объяснение метода. Это самая нестандартная часть языка, и разбирать её стоит подробно.

Идея

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

Из шапки модуля:

Задача модуля: превратить обещание «эта функция завершается» в проверенный факт.

Происхождение значений

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

null                                      — про значение ничего не известно
{ param: i, name: "элементы", depth: 0 }  — это сам параметр i
{ param: i, name: "элементы", depth: k }  — часть параметра i, k разборов вглубь

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

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

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

Вызов убывает на позиции j, если j-й аргумент имеет происхождение { param: j, depth ≥ 1 } — то есть на месте j-го параметра стоит собственная часть j-го параметра вызывающей функции.

Отсюда канонический вид тотальной рекурсии:

тотальная функция «Длина»
  принимает элементы: список числа
  возвращает число
  разбор элементов
    случай пусто
      то 0
    случай голова и хвост
      то 1 плюс «Длина» от хвоста

хвост пришёл из образца, значит его глубина на единицу больше, чем у элементы. Убывание доказано.

Единая позиция в цикле

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

Требование единой позиции выглядит избыточно строгим, пока не увидишь контрпример — он приведён прямо в модуле:

«А»(a, b) вызывает «Б»(хвост a, добавить x к b)   — убывает на позиции 1
«Б»(a, b) вызывает «А»(добавить x к a, хвост b)   — убывает на позиции 2

Каждое ребро где-то убывает, но пара (a, b) по кругу растёт, и цикл бесконечен. Правило «каждое ребро убывает хоть где-нибудь» неверно; единая позиция даёт настоящую убывающую цепочку частей одного значения, а она обязана оборваться.

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

Что анализ отвергает

Список записан в самом модуле, в разделе «Чего анализ не умеет (сознательно)»:

  • лексикографическое убывание (функция Аккермана);
  • убывание по числовому счётчику (n − 1);
  • убывание через результат другой функции (хвост от «Отсортировать» от списка);
  • разные убывающие позиции у разных рекурсивных вызовов одной функции.

И тут же — принцип: «Анализ консервативен по построению: он никогда не признаёт тотальной функцию, которая может зациклиться, и цена этому — отказ части действительно завершающихся программ».

Самый частый случай на практике — второй. Проверяем:

тотальная функция «Обратный счёт»
  принимает н: число
  возвращает число
  если н не больше 0
    то 0
    иначе 1 плюс «Обратный счёт» от (н минус 1)
{"code":"FLANG_NOT_TOTAL",
 "message":"тотальная функция «Обратный счёт»: рекурсивный вызов «Обратный счёт»
   не убывает — аргумент 1 («н» sub 1) не выведен ни из одного параметра.
   Передавайте часть аргумента: хвост списка из образца «голова и хвост»,
   поле варианта из образца, поле записи или элемент коллекции",
 "span":{"line":8,"column":18}}

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

Как с этим живут

В репозитории видно три приёма обхода.

Свёртка по заранее известному списку

Самый частый. Вместо цикла «пока есть символы» берётся конечный список и сворачивается. Из решения про анаграммы (flang/examples/leetcode/049-group-anagrams.flang):

свёртка ["a", "b", "c", …, "z"] начиная с "" как акк и буква
  соединить (соединить акк с (к строке («Счёт буквы» от слово и буква))) с ","

Свёртка по литеральному списку конечна по построению, поэтому функция остаётся тотальной, хотя «по смыслу» это цикл.

То же в flang/core/json.flang: экранирование строки не обходит её по символам, а сворачивается по таблице из 34 замен.

Стек как список

В flang/core/lexer.flang стек отступов — список чисел, и закрытие нескольких уровней сразу становится рекурсией по хвосту стека. В комментарии к файлу записано, что это сняло «единственное место, где напрашивалась бы рекурсия по числу».

Список-топливо

Когда убывание по мере всё-таки нужно, в сигнатуру добавляется список, длина которого ограничивает число шагов. В index.json этот приём назван честно: «Обход есть (список-топливо), но он засоряет сигнатуру».

Или отказ от тотальности

Последний вариант — просто не ставить пометку. Так поступает flang/stdlib/strings.flang для всего, что идёт по строке посимвольно, и решение LeetCode 20 для функции «Символы с позиции». Комментарий в решении объясняет разделение:

«Скобки в списке» принимает список символов и тотальна: стек живёт в накопителе свёртки […] «Скобки в строке» принимает строку, как в условии, и тотальной быть не может: чтобы получить символы, строку надо обойти, а убывание строки анализ завершаемости не признаёт.

Сколько это стоит в цифрах

Единственная имеющаяся мера — статистика решений LeetCode из flang/examples/leetcode/index.json: решено 26 задач, тотальны 20. Шесть нетотальных — почти все из-за посимвольного обхода строки.

В том же файле «разложение строки» названо самой дорогой недостачей языка: «она одна делает нетотальными задачи 13, 14, 20, 125».

Ядро FTS, переписанное на flang, держится лучше: в flang/core/SPEC.md слова «Тотальность: долгов нет» стоят под каждым из четырёх слоёв — лексером, печатью JSON, вычислителем и парсером. Все 300 функций ядра помечены и доказаны totality.mjs. Особенно показателен парсер: в нём 208 функций, он разбирает обе поверхности FTS — и отступную, и скобочную, — и всё равно обходится без рекурсивного спуска по потоку токенов, потому что «остаток потока стал короче» анализ убыванием не считает. Скобочный диалект спускается не по потоку, а по дереву скобок, где поддерево — поле варианта, то есть честно меньшая часть значения.

Достигнуто это ценой упомянутых приёмов, и одно расхождение с оригиналом (нормализация NFC) осталось именно потому, что выразить её через разделить и подстрока нельзя.

Стоит ли оно того

Аргумент за — глава «Два класса программ»: тотальность даёт факт-чекингу право отвечать, а не гадать. Аргумент против виден в тех же файлах: чтобы остаться в классе, код местами пишется не так, как думает автор, а так, как примет анализ. Свёртка по алфавиту вместо обхода строки — приём умный, но это приём.

Ключевое, что здесь сделано правильно: анализ не притворяется умнее, чем есть. Он перечисляет свои ограничения в собственном исходнике, отказывает предсказуемо и объясняет отказ. Для суточного языка это заметно лучше среднего.

Дальше — глава «Стандартная библиотека», где все эти приёмы видны в работе.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов
Дальше