Рефал-05

Введение в язык, цели

Внимание! Эта страница документации немножко устарела.

Рефал-05 — минималистичный самоприменимый компилятор минималистичного диалекта Рефала, имеющий общее подмножество с классическим Рефалом-5. На этом подмножестве он и написан.

Были поставлены следующие цели разработки:

Второстепенными, но тоже важными целями были:

Пользуясь языком математиков, можно сказать, что основные цели — это оптимизируемые параметры в задаче оптимизации, а второстепенные — граничные условия.

Разработка велась не с нуля, а на основе старой версии компилятора Простого Рефала, путём его подгонки под упомянутые цели. Поэтому Рефал-05 наследует некоторые особенности своего предшественника, такие как архитектура компилятора, подход «символы-слова есть имена функций», подход к генерации кода и наличие библиотеки LibraryEx.

Примечание. Далее под Простым Рефалом будет пониматься та редакция Простого Рефала, которая положена в основу этого компилятора. Если надо будет упомянуть о версии Простого Рефала, поддерживаемой актуальным компилятором Рефал-5λ, об этом будет сказано особо.

Рассмотрим цели подробнее.

Минималистичный самоприменимый компилятор Рефала

Прежде всего это должен быть компилятор Рефала. Языка, оперирующего объектными выражениями — строками символов со спаренными круглыми скобками, анализирующего их при помощи рефальского сопоставления с образцом, имеющего поле зрения и переписывающего в нём содержимое ведущей пары скобок конкретизации (иначе это был бы AMBIT).

Поэтому в языке должны быть рефальские образцы, скобки вызова (конкретизации), объектные выражения из символов и круглых скобок, поле зрения, содержащее символы, круглые скобки и скобки вызова.

Это должен быть самоприменимый компилятор — транслятор Рефала в некоторый низкоуровневый язык, написанный на себе. А значит, реализуемый диалект Рефала должен быть достаточно удобен и выразителен для написания самоприменимого компилятора.

И наконец, компилятор должен быть минималистичным. Сокращение выразительных возможностей входного языка, с одной стороны, сокращает код и компилятора, и библиотеки поддержки времени выполнения (далее будем пользоваться жаргонизмом «рантайм»). С другой стороны, компилятор самоприменимый, а значит, на бедном языке его сложнее разрабатывать и кода, скорее всего, потребуется наоборот больше. При этом есть граничное условие: язык должен оставаться Рефалом.

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

Выбор подмножества языка

Вторая цель предписывает нам использовать синтаксис, похожий на Рефал-5, притом компилятор должен быть написан на общем подмножестве: он должен транслироваться и собой, и Рефалом-5, и при этом одинаково работать в обоих случаях.

Базисный Рефал

Будем выбирать подмножество Рефала-5. Условия и блоки отбрасываем — они Турчиным и так декларируются как расширения. Кроме того, в Простом Рефале, на основе которого создавался Рефал-05, их и так нет. Остаётся базисное подмножество. Что нам нужно в нём?

Типы символов: символы-функции вместо символов-слов

Символы-литеры — нужны. Ведь нужно как минимум читать исходный файл и писать целевой.

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

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

В официальной реализации Рефала-5 (PZ) символы-слова представляются в виде C-строк const char *. Сравнивать их при помощи strcmp() неэффективно, поэтому реализация поддерживает их уникальность с помощью глобальной хеш-таблицы.

В Простом Рефале (на основе которого реализован Рефал-05) одновременно существуют две конкурирующие концепции. Это указатели на функции и метки.

Указатели на функцию (символы-функции) записываются просто как имя глобальной функции, сравниваются по указателю (одноимённые локальные функции в разных файлах не равны), их можно прямо или косвенно помещать после <. Прямо — понятно, обычный вызов функции <F …>, косвенно — <s.F …>, где переменная s.F связана с некоторым символом-функцией.

Реализованы символы-функции как пара указателей: указатель на функцию языка Си++ и указатель на имя как const char *. В сравнении на равенство участвует первый указатель.

Метки записываются как имя, предварённое знаком #, например #Success. Исходно их надо было определять при помощи ключевого слова $LABEL, но позже явное определение было сделано необязательным. За счёт хитрого трюка Си++ с шаблонами удалось обеспечить одновременно и глобальность указателей, и раздельную трансляцию. Реализуются так:

//$LABEL ИмяМетки
template <typename T>
struct ИмяМетки {
  static const char *name() {
    return "ИмяМетки";
  }
};

В коде на них ссылаются как &ИмяМетки<int>::name. Тонкость в том, что повторные определения шаблонных функций из разных объектных файлов линковщик удаляет, а значит, для идентификатора ИмяМетки в исполнимом файле останется только один экземпляр функции ИмяМетки<int>::name(), и все указатели на неё станут идентичными. Равенство проверяется путём сравнения указателей.

Символы-функции в Простом Рефале появились исторически раньше символов-меток и использовались не только как имена вызываемых функций (т.е. после <), но и вместо символов-слов. Такие функции в Простом Рефале определялись как пустые (язык разрешает функции без предложений, они падают на любом аргументе), для их определения использовался синтаксический сахар:

$ENUM LocalName;

$EENUM GlobalName;

Ключевое слово $ENUM определяет локальную пустую функцию, $EENUM — пустую entry-функцию.

Как будет сказано далее, в качестве целевого языка был выбран чистый Си, C89. И этот выбор практически предопределил использование символов-функций вместо символов-слов.

Вариант Рефала-5 с представлением идентификаторов как const char * хорош в интерпретаторе, который контролирует загрузку всех файлов и может отслеживать глобальную таблицу имён (и, тем самым, устранять дубликаты). Из-за раздельной трансляции в Си потребуется или хитрая логика устранения повторных строк в рантайме, или придётся использовать strcmp для сравнения символов.

В языке Си (во всяком случае, в стандарте) нет никакой возможности провернуть трюк с устранением повторных определений, как это можно сделать с шаблонами C++. В конкретных реализациях можно нашаманить устранение повторных определений, но это непереносимо.

Другими важными факторами в пользу этого варианта было то, что, во-первых, поддержка символов-функций в языке и рантайме уже была, во-вторых, они дают ограниченную поддержку функций высшего порядка. Библиотечные функции Map, Reduce и MapAccum заметно облегчают написание программ, даже если в них передаются указатели на глобальные функции, возможно, каррированные (см. библиотечную функцию Apply). При определённой аккуратности функции высшего порядка можно использовать и в подмножестве, совместимом с Рефалом-5.

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

Удалены символы-файлы

Простой Рефал поддерживал символы-файлы, которые могли хранить указатель void *. Они использовались для реализации файлового ввода-вывода стандартной библиотеки, где функция FOpen в такой символ упаковывала FILE *, а остальные функции с этим файлом работали. От таких символов было решено отказаться, поскольку для ввода-вывода будут использоваться встроенные функции Рефала-5, которые оперируют целочисленными номерами файлов. А значит этот тип символов будет не нужен.

Остальное остаётся как есть

То, что было в Простом Рефале, но отсутствует в базисном подмножестве Рефала-5, решено удалить — из соображений минимализма. Удалены:

В отличие от Рефала-5, между < и > можно записывать произвольное выражение, имя функции сразу после < жёстко не требуется. Это упрощает лексический и синтаксический анализ (что в духе минимализма) и облегчает использование функций высшего порядка.

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

Выбор языка Си как целевого

Рефал можно компилировать как в промежуточный интерпретируемый код, традиционно называемый RASL’ом (Refal assembly language), так и непосредственно в исходный код на другом языке программирования, например, Простой Рефал компилировался в C++.

Была выбрана компиляция в язык высокого уровня. Её преимущества:

Было решено выбрать в качестве целевого язык ANSI/ISO C89 по следующим причинам:

В Простом Рефале уже был зачаток интерпретации, сделанный Вадимом Сухаревым и Игорем Дрогуновым — его было решено удалить. Интерпретируемый код Дрогунова был добавлен в транслятор когда исходники уже отслеживались в Git, поэтому достаточно было просто аккуратно пересобрать дерево коммитов без фиксаций, посвящённых интерпретации. Это оказалось несложно. Интерпретатор Сухарева удалён из исходников обычным образом.

Классическая списковая реализация

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

Всё, как в Рефале-2 и Простом Рефале.

Реализация была выбрана по двум причинам. Во-первых, она уже реализована. Во-вторых, она простая для понимания и не требует сборки мусора и/или подсчёта ссылок.

Что ещё удалено из Простого Рефала

Был подчищен не только Простой Рефал как язык, был подчищен ещё и репозиторий бывшего Простого Рефала.

Удалён генератор лексического анализа, лексический анализатор переписан вручную. Обоснование: поддержка дополнительного инструмента не отвечает требованию минимализма, использование lexgen’а из Рефала-5λ ставит пользователя в зависимость от последнего, лексический анализатор, написанный вручную, сопоставим по сложности и объёму, с написанным с использованием lexgen’а.

Удалена srmake. Обоснование: поддерживать (и вообще использовать) инструмент для сборки минималистичного компилятора (из десятка исходников) избыточно.

Совместимость с Рефалом-5

Выбор диалекта Рефала

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

Его можно хранить как в готовом виде — в виде исполнимого файла ОС, либо в полуготовом — в данном случае в виде исходников целевого языка. Именно так и хранился Простой Рефал в репозитории, была папка bootstrap с файлами на C++.

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

Значит, нужно брать какой-то диалект Рефала и делать язык, совместимый с его подмножеством (подмножество — из соображений минимализма).

Ну, можно оставить Простой Рефал, у него есть «сторонняя» реализация — Рефал-5λ. Не совсем сторонняя, поскольку написана тем же автором. Недостаток: Простой Рефал никто не знает, литературы по нему почти нет (есть устаревшее недописанное руководство). Не подходит.

Примечание. Такой вариант рассматривался, автор сначала хотел написать минималистичный «классический» Простой Рефал, очищенный от избыточных конструкций. Но потом передумал, потому что Простой Рефал не нужен, когда есть Рефал-5λ.

Рефал-2 отпадает сразу. Во-первых, он синтаксически сильно отличается от уже реализованного Простого Рефала, потребуется как существенная переделка парсера, так и полное переписывание исходников. Не подходит.

Рефал-5. Рефал «по умолчанию», обычно, если сторонние люди знакомятся с Рефалом, то начинают или с Рефала-2, или с Рефала-5. Доступна документация в интернете, начиная с учебника Турчина, заканчивая разнообразными статьями и методичками. Есть реализации под разные платформы: скомпилированные модули для Windows, исходные тексты, которые можно собрать на POSIX (работают на Linux и macOS).

Его базисное подмножество синтаксически похоже на Простой Рефал, поэтому не потребует существенной переделки синтаксического анализа (а лексика всё равно переписывалась), исходники потребуют буквально косметических правок — замены однострочных комментариев // на *. Также потребуется написание новой библиотеки встроенных функций. Подходит!

Рефал-6. Менее известен и популярен, чем Рефал-5 (простите меня, Аркадий Валентинович). Есть исполнимые файлы для Windows, о переносе на POSIX ничего не известно. Документация есть — хороший учебник и справочник.

Базисное подмножество тоже синтаксически похоже на Простой Рефал, потребуются минимальные правки синтаксического анализатора, косметические правки исходников: замена однострочных комментариев и добавление ; после }, новая библиотека встроенных функций тоже потребуется. Тоже подходит!

Рефал Плюс. Тоже достаточно хорошо известен, хорошо документирован, вроде даже переносится на Linux — нашёлся пакет для AltLinux.

Базисное подмножество отличается существеннее. Вместо единиц трансляции модули, состоящие из двух файлов: интерфейса и реализации. У функций должны быть их описания типа, часть библиотечных функций возвращает неуспехи. Плохо подходит.

Итого: хорошо подходят только Рефал-5 и Рефал-6. Был выбран Рефал-5 как более популярный и более переносимый, а также потому, что автор больше с ним работал и лучше его знает. Отсюда и название языка — Рефал-05.

А как же $ENUM и $EENUM?

Вместо символов-слов предлагается использовать символы-функции, а каждая функция должна быть или объявлена как $EXTERN, или определена. Для функций, используемых только ради имён, остались от Простого Рефала ключевые слова $ENUM и $EENUM.

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

Есть другое предложение: спрятать их от Рефала-5. В псевдокомментарии.

Если однострочный комментарий непосредственно после * содержит валидное ключевое слово, то он комментарием не считается, его содержимое обычным образом парсится Рефалом-05. Но в конце такого псевдокомментария подразумевается точка с запятой. То есть строчка

*$EENUM Begin, Middle, End

эквивалентна строке

$EENUM Begin, Middle, End;

А строка

*$ENTRY Hello { = <Prout 'Hello'> }

эквивалентна (и это действительно работает)

$ENTRY Hello { = <Prout 'Hello'> };

Видно, что от Рефала-5 можно прятать даже entry-функции. Можно, но только не нужно. Псевдокомментарии нужны только для $ENUM и $EENUM.

При этом, если комментарий не начинается с корректного ключевого слова, то он считается обычным комментарием (ошибкой не является):

*$FORMAT C:

*$FROM LibraryEx

*$MST_FROM_ENTRY;

Функции высших порядков или тонкости функции Mu

Оказывается, что в последней версии Рефала-5 (PZ Oct 29 2004) функция Mu может вызывать по имени не только функции, определённые или объявленные в текущей единице трансляции, но и вообще любые entry-функции, определённые где бы то ни было в программе. Об этом не написано в учебнике Турчина, но есть в официальных дополнениях к книге. Этой «тайной» возможностью пользуется, например, SCP4.

Т.е. если мы напишем такую библиотеку:

* mylibrary.ref

/**
  <ForEach s.Fucn t.Item*> == empty

  Performs <s.Func t.Item> for each term.
*/
$ENTRY ForEach {
  s.Func t.Item e.Items =
    <Nil <Mu s.Func t.Item>> <ForEach s.Func e.Items>;

  s.Func /* empty */ = /* empty */;
}

Nil { e.X = }

и такую свою функцию

* myprogram.ref

*$FROM mylibrary.ref
$EXTERN ForEach;

...

PrintErrors {
  e.Errors = <ForEach PrintOneError e.Errors>;
}

$ENTRY PrintOneError {
  (s.Line e.Message) =
    <Prout 'Error at ' <Symb s.Line> ': ' e.Message>;
}

...

То всё будет работать. Не смотря на то, что функция PrintOneError в файле mylibrary.ref не объявлена как внешняя, функция Mu её найдёт, поскольку после поиска в локальной области видимости она ищет entry-функцию по всей программе.

Что нам это даёт? Функция Mu в Рефале-05 определена примерно так (на самом деле, она написана на Си):

$ENTRY Mu {
  s.Func e.Arg = <s.Func e.Arg>;
}

Поэтому пример выше с mylibrary.ref и myprogram.ref будет корректно работать и на Рефале-05. Рефал-05 трактует слово PrintOneError в правой части PrintErrors не как идентификатор, а как указатель на функцию. А функция Mu в mylibrary.ref этот указатель просто вызовет.

Отсюда вывод. Для того, чтобы писать программы, работающие одновременно на Рефале-05 и Рефале-5, косвенный вызов нужно осуществлять только через Mu, а вызываемые функции объявлять как $ENTRY. Нюансы: в файле, где располагается вызов Mu, не должно быть определено функций с тем же именем, что и вызываемая опосредованно; если функция Mu и косвенно вызываемая функция расположены в одном файле, $ENTRY можно не писать.

Практический инструмент

Самая первая версия Простого Рефала (001) была только модельной реализацией — могла только самоприменяться. Для вызова компилятора C++ использовал команду call_cpp_compiler, которая вызывала .bat-файл, лежащий в текущей папке. Стандартные пути поиска не поддерживались — для компилируемых файлов нужно было указывать полные пути. Из арифметических функций поддерживались только Add и Sub, поскольку только они использовались в исходниках. «Встроенные» функции Простого Рефала находились прямо в refalrts.cpp, т.е. библиотека не была выделена в отдельный файл.

Такой вариант, конечно, прост и минималистичен, но сразу превращает компилятор в совсем бесполезную игрушку. Поэтому решено было сохранить (а позже развить) возможность использовать Рефал-05 как практический инструмент. Но при этом достичь минимализма.

Чтобы упростить чтение командной строки, параметры «используемый компилятор Си» и «пути поиска» стали читаться из переменных окружения, соответственно, R05CCOMP и R05PATH. При этом, пути поиска R05PATH автоматически добавляются к строке запуска сишного компилятора как каталоги include. Для этого используется опция -Iпуть, которая поддерживается всеми известными мне компиляторами.

Если Рефал-05 установлен в соответствии с README.md, то для компиляции файла my_program.ref достаточно ввести в командной строке

refal05c my_program 〈другие исходники〉 Library refalrts

И будет создан исполнимый файл с именем my_program.exe, a.exe или a.out в зависимости от используемого компилятора Си.

Компилятор как набор повторно используемых компонентов

Процедура установки в README.md рекомендует поместить в R05PATH пути к папкам lib и src. Папку lib добавлять необходимо — в ней находятся файлы рантайма (refalrts.h и refalrts.c) и библиотека встроенных функций Library.c. Без файлов рантайма просто ничего не скомпилируется, без встроенных функций содержательную программу просто не напишешь (если, конечно, не подключить альтернативную библиотеку).

Папку src изначально предполагалось добавлять только ради библиотеки LibraryEx, которая содержит удобные вспомогательные функции типа Map, LoadFile/SaveFile, Inc/Dec и т.д. Но потом я подумал — ведь тогда пользователю становятся доступны исходники самого компилятора, может их сделать библиотеками? Так и сделано.

Доступны следующие стандартные библиотеки:

Таким образом, компилятор предоставляет возможность удобной разработки других инструментальных средств для Рефала-05: другие компиляторы, препроцессоры, анализаторы, суперкомпиляторы и т.д.

Например, если встанет задача исследовать другое представление данных (векторное, векторно-списковое Скоробогатова, на односвязных кольцевых списках Эйсымонта, деки Окасаки и т.д.), то достаточно будет переписать генератор, рантайм и библиотеку встроенных функций. Всё остальное уже есть.

Аналогично, можно разработать и альтернативный front-end для исследования другого синтаксиса — кодогенератор, стандартная библиотека и рантайм уже готовы.

Для написания препроцессора, работающего на уровне потока лексем (например, выполняющего макроподстановки), можно использовать один R05-Lexer — он будет служить и front-end’ом — загружать последовательность токенов, и back-end’ом — формировать из преобразованной последовательности готовый код на Рефале-05.

Для разработки верификатора достаточно одного front-end’а.

Если стоит задача преобразования программ, то в роли front-end’а будет использоваться R05-Parser, а в роли back-end’а можно использовать либо R05-AST — оно умеет восстанавливать из дерева исходный текст на Рефале, либо R05-Generator, который будет порождать код на Си.

Примечание. Компоненты LibraryEx и R05-AST удалены из репозитория — используются аналогичные библиотеки из стороннего фреймворка.

Прозрачность исходников

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

В первую очередь, исходники для этого должны быть компактными, что сочетается с первой целью — минималистичностью. На момент написания этих строк (2018-10-07) исходники компилятора (src/*.ref) и библиотек (refalrts.*, Library.c) занимают 5416 строк кода.

Во-вторых, сам код должен быть читабельным. Для этого нужно, чтобы

Поэтому в коде используются только «говорящие» имена переменных (никаких s.K, e.0, t.1 и т.д.), жёстко заданы правила расстановки скобок и отступов, префиксы и суффиксы для важных категорий функций и переменных и т.д.

Полностью правила оформления кода описаны в Приложении A (на самом деле они заимствованы из Рефала-5λ).

В исходных текстах компилятора почти все функции — чистые. Побочный эффект эксплуатируется только на верхнем уровне — лексер (а вместе с ним и парсер) загружает текст из указанного файла, генератор сохраняет в файле сформированные строки кода на Си. Модуль верхнего уровня refal05c.ref единственный выводит на экран сообщения и читает аргументы командной строки. Модуль утилит R05-CompilerUtils.ref читает переменные окружения и вызывает компилятор Си при помощи функции System. А всё остальное чистое.

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

Обновление 2019-04-02: копилка всё же добавлена.

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

Переносимость компилятора

Компилятор должен работать под современными популярными платформами: Windows NT (т.е. не Windows 9x/ME), Linux и macOS. Поэтому для его раскрутки используются сторонние переносимые компиляторы Рефала: Рефал-5 и Рефал-5λ.

Также ради большей переносимости вместо специализированных утилит сборки (make, CMake и др.) используются скрипты командного интерпретатора — .bat-файлы для Windows и bash-скрипты для POSIX. Утилиты make, идущие в составе с разными компиляторами (GNU make, nmake для Visual C++…), понимают разный синтаксис Makefile’ов. А описать скрипт самоприменения makeself.* скорее всего будет трудно описать столь же лаконично и прозрачно с применением CMake и других подобных инструментов.

Задача поддержки неактуальных архитектур, DOS и Windows 9x/ME, не ставилась. Поэтому .bat-файлы писались в синтаксисе cmd.exe, а не command.com. Хотя я не исключаю, что Рефал-05 можно собрать и использовать на Windows 9x/ME. На DOS реального режима компилятор самопримениться не сможет, поскольку требует 4 Мбайт памяти — текст генерируемых файлов на Си сначала целиком формируется в поле зрения, а потом сохраняется в файл. Переделать можно, но не нужно.