flang — полный язык поверх FTS Два класса программ: тотальная и обычная
0%

Два класса программ: тотальная и обычная

Два класса программ: тотальная и обычная

Есть старая развилка. Язык, на котором можно выразить любое вычисление, не позволяет доказать про произвольную программу, что она остановится — это проблема остановки, и обойти её нельзя. Язык, где завершение доказуемо всегда, неизбежно чего-то не выражает.

Обычно язык встаёт на одну сторону. Промышленные языки берут полноту и отказываются от гарантий. Помощники доказательств вроде Coq или Agda берут гарантии и требуют, чтобы рекурсия была структурной.

flang не выбирает. Он делит программы на два класса, и — это важнее самого деления — класс проверяется компилятором, а не декларируется в документации. Формулировка из flang/SPEC.md, раздел 1, приведена там же в виде таблицы:

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

Как это выглядит в коде

Разница — в одном слове перед функция. Возьмём одну и ту же программу, рекурсию по числу, и напишем её дважды.

Без пометки — принимается:

модуль «Проба»

функция «Обратный счёт»
  принимает н: число
  возвращает число
  если н не больше 0
    то 0
    иначе 1 плюс «Обратный счёт» от (н минус 1)
$ node flang/bin/flang.mjs check проба.flang --pretty
{
  "valid": true,
  "module": "Проба",
  "functions": [ { "name": "Обратный счёт", "total": false } ],
  "types": [],
  "diagnostics": []
}

Добавляем тотальная — и та же программа перестаёт собираться:

$ node flang/bin/flang.mjs check проба.flang --pretty
{
  "code": "FLANG_NOT_TOTAL",
  "message": "тотальная функция «Обратный счёт»: рекурсивный вызов «Обратный счёт»
    не убывает — аргумент 1 («н» sub 1) не выведен ни из одного параметра.
    Передавайте часть аргумента: хвост списка из образца «голова и хвост»,
    поле варианта из образца, поле записи или элемент коллекции",
  "severity": "error",
  "span": { "line": 8, "column": 18 }
}

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

Зачем это нужно

Деление имело бы мало смысла, если бы за ним ничего не следовало. Следует три вещи.

Факт-чекинг

Встраиваемый режим (flang/src/factcheck.mjs, раздел 8 спецификации) отвечает на вопрос «утверждение подтверждается данными или нет». Система, дающая такой ответ, не имеет права зависнуть — поэтому режим допускает только тотальные функции, и это проверяется до вычисления, по транзитивному замыканию вызовов.

Тотальная функция считается:

$ node flang/bin/flang.mjs facts факты.flang --facts факты.json \
    --claims '["«Сумма больше порога» от суммы, порог равно да"]' --pretty
{
  "ok": true,
  "results": [ {
    "claim": "«Сумма больше порога» от суммы, порог равно да",
    "holds": true,
    "why": "«Сумма больше порога» от факта «суммы», факта «порог» = да;
            требование «равно да» выполнено",
    "status": "verified"
  } ]
}

Обычная — отклоняется, и в ответе написано почему:

{
  "ok": false,
  "results": [ {
    "claim": "«Обратный счёт» от н равно 3",
    "holds": false,
    "why": "функция «Обратный счёт» не помечена как «тотальная»;
            факт-чекинг допускает только тотальные функции —
            иначе ответ может не наступить",
    "status": "refused"
  } ]
}

Ответ «не могу проверить, вот причина» здесь честнее, чем попытка и таймаут. Формулировка из шапки модуля: «Не „попробуем и посмотрим“: попытка исполнить нетотальную функцию и есть тот риск, ради которого введены два класса».

Примеры-тесты

Примеры лежат внутри функции и исполняются командой flang test. У тотальной функции они гарантированно завершатся. У обычной — как повезёт: спасает только лимит шагов интерпретатора.

Печать в целевые языки

Спецификация связывает класс с возможностью кодогенерации. Здесь стоит быть точным, потому что реальность отличается от таблицы в SPEC.md.

Таблица обещает, что тотальные программы печатаются «на C/Rust/Java/…», а обычные — «только JS/TS». На практике бэкендов пять (c, go, js, python, rust), и они печатают функции обоих классов; разница в том, что у напечатанного кода нет лимита шагов. Из шапки flang/src/emit/c.mjs:

Лимита шагов [нет]. Незавершающаяся обычная функция здесь крутится вечно, как и в напечатанном JS. А вот предел ГЛУБИНЫ есть, и это не прихоть: в JS переполнение стека даёт исключение, в C — падение процесса.

То есть класс не запрещает печать, а определяет, что вы получаете на выходе: для тотальной функции завершение доказано и вне интерпретатора, для обычной — нет. Подробнее в главе «Кодогенерация».

Цена решения

Она реальная и видна в стандартной библиотеке. Модуль flang/stdlib/strings.flang открывается признанием:

Главное ограничение, которое здесь видно в каждой функции: строку нельзя разобрать структурно. Образцы «пусто» и «голова и хвост» работают только со списком […] Поэтому всё, что идёт по строке посимвольно, — обычная функция: её завершение обеспечено лимитом шагов интерпретатора, а не доказательством.

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

Второй вид цены — обходные приёмы. Чтобы остаться в тотальном классе, авторы stdlib и core заменяют посимвольный обход свёрткой по заранее известному списку. Экранирование JSON в flang/core/json.flang сворачивается по таблице из 34 замен; подпись слова в задаче про анаграммы — по списку из 26 букв алфавита. Работает, но это именно приём, а не естественная запись.

Что здесь стоит запомнить

Идея деления не нова: тотальное программирование обсуждается давно, а языки с проверкой завершения существуют. Практически интересно другое — что деление проведено внутри одного обычного языка, помечается одним словом, проверяется на сборке и имеет ясные последствия для того, что с программой можно сделать дальше.

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

Дальше — глава «Инструмент», чтобы получить инструмент в руки.

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

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

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

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