Retro: современный диалект Forth

У меня уже давно руки чесались что-то написать на Forth. И тут мне на глаза попалась замечательная система:

gemini://retroforth.org/

http://retroforth.org/

Репозиторий проекта:

Мне действительно понравился этот диалект Forth'а:

"Retro is not a traditional Forth"

Любой Forth это, фактически, виртуальная машина: память, два сетка и обратная польская нотация. При грубом рассмотрении Retro не сильно отличается от прочих реализаций Forth. Но если вникать в процесс интерпретации, то разница с классическими реализациями будет ощутимая. Подробности описывать здесь не возьмусь, но интересующихся отошлю к главе "Syntax" книги-руководства (doc/RETRO-Book.md). А так же есть отдельный документ, посвященный механизму интерпретации в Retro: doc/Interpreter.md.

Retro вводит понятие "сигил". Сигил позволяет по односимвольному префиксу маршрутизировать обработку токенов (слов, разделённых пробельными символами) конкретному обработчику. Показательным примером, на мой взгляд, является реализация строк-литералов. Для задания строки-литерала используется символ-префикс ' за которым идёт содержимое строки. Поэтому если интерпретатор, выделяя очередной токен, встретит что-то вроде 'hello, то такой токен целиком будет передан обработчика сигила строк (') который сформирует содержимое строки и поместит ссылку на эту строку в стек. Или другой яркий пример: задание нового слова. Если в классическом Forth мы выделяем символ : в отдельный токен:

: name ;

То в Retro символ : является сигилом и его не нужно отделять от имени слова в отдельный токен:

:name ;

Сводная таблица самых ярких различий Retro от классического Forth сформирована в файле doc/Cross-Reference.md.

Помимо сигилов в Retro широким мазком добавлены элементы функционального программирования: неименованные вложенные функции ([ ]) и функции, оперирующие другими функциями. Например условный оператор принимает на вход не только логический флаг, но и адрес исполняемого кода, который требуется исполнить, если флаг TRUE:

s:get

s:length n:zero? [ 'empty_string s:put nl ] if
empty string

Где:

Есть даже своеобразный аналог замыкания: Hyperstatic Global Environment. Подробности можно узнать в документе `doc/Hyperstatic.md`.

Nga

Виртуальная двухстековая машина Retro, исполняющая низкоуровневые инструкции, называется Nga.

В каталоге vm/ представлены реализации на разных языках программирования. Эталонной (и используемой по-умолчанию) является реализация на Си (vm/nga-c/). Но если интересны детали реализации, то стоит обратить внимание на файл doc/Nga.md в котором в формате unu (будет рассмотрен дальше) даётся реализация виртуальной машины с подробными комментариями.

Muri

Исполняет виртуальная машина Retro не инструкции "реального" процессора, а специально для неё спроектированный ассемблер - Muri. Набор инструкций Nga относится к классу MISC:

Википедия: Minimal instruction set computer

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

    st       sw       li       du
    00010000:00000100:00000001:00000010

Подробнее набор инструкций Muri описан в разделе "Internals: Nga Virtual Machine" книги-руководства по Retro.

Устройства виртуальной машины (I/O extensions)

Помимо набора инструкций виртуальная машина Nga включает в себя расширения ввода-вывода (устройства). Через эти устройства виртуальная машина Nga может общаться с "внешним миром". Устройства сгруппированы по типам:

Все устройства, кроме нулевого, могут отсутствовать. Доступные устройства конфигурируются на момент сборки системы. Например на Windows-сборке отсутствуют системные вызовы Unix.

Реализация разделена на нативный код (файлы vm/nga-c/dev-*.c) и forth-обёртки (файлы interface/*.retro).

Более подробную документацию можно найти в файле doc/DEVICES.txt.

Начальный образ: ngaImage

Начальный образ, который загружает и исполняет Nga расположен в бинарном файле ngaImage. Он собирается (в основном) из retro.muri and retro.forth. В этих файлах (из директории image/) можно найти реализацию языка и основных слов стандартной библиотеки.

Unu

Файлы исходного кода (в основном с расширением .retro) написаны в формате unu. Формат unu выворачивает принципы файлов исходных текстов "на изнанку": вместо написания кода и помечания особых строкам (или кусков строк) комментариями в unu нужно помечать блоки кода, а все остальные строки в файле игнорируются.

Блоки кода должны начинаться и заканчиваться строкой ~~~

Например следующий код печатает строку "Привет, мир!" (без кавычек):

~~~

'Привет,_мир! s:put nl

~~~

А привычные блоки кода, обрамлённые строками ```, в этом формате содержат тестовый код. Такой код исполняется, например, при запуске retro с ключом -t. В этом режиме исполняются оба типа блоков.

Спорные решения

Есть ряд решений, логика появления которых понятна, но (на мой субъективный взгляд) довольно спорна.

Но при этом стоит отметить, что целостное ведение проекта (как это происходит в случае с Retro), даже с не самыми удачными решениями, зачастую лучше разношёрстного набора "best practices".

Преобразование _ в символ пробела в литералах

Первое обо что придётся споткнуться, наверное, каждому: преобразование символа _ в пробел в литералах. Откуда взялось это поведение вполне понятно: в отличии от классического Forth, в Retro вначале входной потом символов токенизируется (то есть, фактически, разбивается по пробельным символам), а затем отдельные токены обрабатываются сигилом (если токен начинается с соответствующего символа, например ' для строк).

Для получения непосредственно символа _ руководство языка предлагает использовать вызов форматирования строк:

'This_has_spaces_and_under\_scored_words. s:format

Добавление 0-символа при вызове buffer:add

Есть (казалось бы) довольно простое слово - buffer:add.

$ retro-describe buffer:add
buffer:add

  Data:  n-
  Addr:  -
  Float: -

Append a value to the current buffer.

Class: class:word | Namespace: buffer | Interface Layer: all

Однако это слово не только добавляет переданный символ в буфер, но пишет 0-символ после дополненного символа (цитата из книги-руководства):

Use `buffer:add` to append a value to the buffer. This takes a single value and will also add an ASCII:NULL after the end of the buffer.

Это, фактически, значит, что выделяя буфер, который в дальнейшем будет обрабатываться вызовом buffer:add, мне нужно выделять на одну ячейку больше (для ASCII:NULL). Даже если в буфере будет храниться не строка. Не люблю такие вещи, которые нужно постоянно "держать в уме".

Реализация s:split/*

Есть два слова в Retro, которые кажутся очень полезными:

$ retro-describe s:split/string
s:split/string

  Data:  ss-ss
  Addr:  -
  Float: -

Split a string on the first occurrence of the specified string. After the split, the top stack item will be the part of the string before the specified substring, and the second item will be the rest of the original string.

Class: class:word | Namespace: s | Interface Layer: all
$ retro-describe s:split/char  
s:split/char

  Data:  sc-ss
  Addr:  -
  Float: -

Split a string on the first occurrence of the specified character.

Class: class:word | Namespace: s | Interface Layer: all

И только если заглянуть в реализацию (вероятно я пропустил эту информацию в книге-руководстве) мы видим, что поведение "не определено", если элемент по которому строка разбивается отсутствует в строке (из файла image/retro.forth):

The `s:split` splits a string on the first instance of a given character. Results are undefined if the character can not be located.

Вы же тоже "любите" неопределённое поведение? На моих простых тестах я видел возврат пустой строки и вылет виртуальной машины. То есть (по хорошему) перед каждым вызовом s:split/string или s:split/char нужно ОБЯЗАТЕЛЬНО проверить наличие разделителя в исходной строке. И вот "держать в уме" стало нужно чуть больше.

Временные строки

Есть ряд слов, которые возвращают (формируют) новые строки: s:get, s:format, s:reverse и так далее. Но при этом они никак не принимают размер строки.

В Retro есть концепция временных строк: есть циклический буфер из которого выделяются TempStrings (по умолчанию 32) строки на TempStringMax (по умолчанию 512) символов. И все возвращённые ("новые") строки пишутся в такой временный буфер.

Разберем конкретный пример. Попытаемся ввести 600 символов вызовом s:get. Что будет? Да что угодно... реализация будет писать за пределы строки. В "лучшем случае" будет перезаписана следующая временная строка из циклического буфера (которая выделится на следующей итерации и заполнится своими данными, побив наш результат).

$ retro
RETRO 12 (2024.9)
524288 Max, 44626 Used, 479662 Free
s:get
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
s:put
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxs:put

Но на практике у меня случалось и падение виртуальной машины:

$ retro
RETRO 12 (2024.9)
524288 Max, 44626 Used, 479662 Free
s:get 
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

ERROR (nga/execute): Invalid instruction!
At 1545, opcode 120
Instructions: 120d 0d 0d 0d 

И количество неявной информации, которую снова нужно "держать в уме", растёт.

Проверки безопасности

Всё эти неявные параметры, которые нужно "подразумевать" сделаны в угоду производительности. И (с одной стороны) хорошо, что мы не платим за то, за что не просили. Но хотелось бы иметь возможность более строгих проверок. Например какой-то отладочный режим, когда плевать на производительность и нужно убедиться в корректности свой программы. К сожалению "из коробки" таких возможностей в Retro нет. Хотя нечто подобное есть в классическом Forth.

Проба пера

Несмотря на все неприятные особенности, которые я описал выше, мне понравилось писать на Retro. В качестве "подопытного" я сделал Gemini фронтенд для Google переводчика:

GTransl 🔁 Gemini фронтенд к Google переводчику

Я написал его в несколько итераций, постепенно изучая внутреннюю логику системы. Думаю я продолжу писать на Retro небольшие проекты "для себя". И, судя по его устройству, можно легко кастомизировать систему под свои пожелания.

Комментарии через ActivityPub (Fediverse) можно оставить здесь:

https://honk.any-key.press/u/continue/h/4Qr7Z63gFwM491k6r9