Контекстные исключения с метапрограммированием Forth
20 сентября 2021 г. · 13 минут чтения
Эта статья является частью серии «Начальная загрузка» , в которой я начинаю с 512-байтного начального источника и пытаюсь загрузить реальную систему.
Предыдущий пост:
Как Forth реализует исключения
Типичная система Forth предоставляет простой механизм обработки исключений, в котором генерируется одно целое число, идентифицирующее исключение. Если мы в конечном итоге поймаем исключение, эта схема работает достаточно хорошо. Однако, если он всплывает до самого верха и отображается пользователю, мы хотели бы показать немного больше контекста.
Такие системы, как Gforth, печатают обратную трассировку и сопоставляют выброшенное целое число с текстовым описанием (по аналогии с errno в Unix), но информация, относящаяся к конкретной ошибке, теряется. Например, если возникает ошибка ввода-вывода, мы хотели бы включить базовый код ошибки, номер блока и, возможно, некоторый идентификатор устройства. Если файл не может быть найден, нам нужно его имя файла. До сих пор я не мог найти ни одной существующей системы Forth, которая решает эту проблему.
В этом посте я описываю решение Miniforth для этой проблемы. Мы создадим простое расширение для механизма throw
-and- catch
, чтобы разрешить сообщения об ошибках, например:
i/o-error
in-block: 13
error-code: 2
unknown-word
word: has-typpo
Последний пример также иллюстрирует, почему исключения находятся на критическом пути начальной загрузки — они будут механизмом, с помощью которого новый внешний интерпретатор сообщает о неизвестных словах. Конечно, мы могли бы обойти эту потребность, но в любом случае потребуется некоторая форма раскручивания, так как ошибка может возникнуть внутри слова синтаксического анализа, такого как '
, потенциально глубоко в программе пользователя.
В более старых системах Forth использовалась более простая стратегия abort
входа в REPL верхнего уровня при первой ошибке, которая влекла за собой полную очистку стека возврата и переход к точке входа системы. Я решил, что это упрощение того не стоит, так как в конечном итоге мне понадобится полная гибкость исключений — планирую заранее, я моделирую возможный текстовый редактор после , vi
и я рассматриваю возможность привязки командной строки к :
Forth REPL (с дополнительный словарь, активируемый для команд, специфичных для редактирования текста). В таком случае выходить из редактора после первой опечатки было бы не очень приятно.
Механизм, который я здесь описываю, построен на основе стандарта catch
и throw
слов, поэтому, если вам нужно освежить в памяти их поведение или посмотреть, как они реализованы, см. эту мою статью .
Дизайн
Механизм был бы наиболее гибким, если бы пользователь мог просто зарегистрировать произвольный фрагмент кода для вывода типа исключения. Однако большинство исключений фактически не используют эту гибкость в полной мере. Таким образом, конструкция состоит из двух основных частей:
поиск слова, которое печатает данное исключение, и
синтаксис для простого определения типичного печатного слова.
Поиск функции печати
Поиск обработчика печати очень похож на поиск строкового описания, что делают другие системы. Таким образом, мы могли бы адаптировать решение Гфорта, создав связанный список, очень похожий на сам словарь, сопоставив номера исключений с процедурами печати:
Подобное явное сопоставление действительно может быть лучшим способом поиска печатного слова, если кто-то ищет совместимость с существующими программами, выбрасывающими целые числа волей-неволей. Однако это не является целью Miniforth, который предлагает более простое решение — напрямую передать указатель на функцию печати.
: my-exn ." Hello!" cr ;
' my-exn throw ( prints Hello! )
Таким образом, не требуются дополнительные структуры данных, что экономит память и время выполнения 1 . Даже если вы не хотите печатать исключение, а, например, проверяете, является ли перехваченное вами исключение тем, которое вы хотите обработать, ничто не мешает вам сравнивать эти указатели, как непрозрачные токены.
: print-half ( n -- )
['] halve catch case
0 of ." The half is " . endof
['] its-odd of ." It's odd!" endof
( default ) throw
endcase ;
Конечно, выбрасывание жетона выполнения таким образом приведет к сильному взрыву, когда кто-то выкинет простое целое число. Если желательна совместимость, две схемы можно было бы каким-то образом объединить. Решение, которое мне здесь нравится больше всего, состоит в том, чтобы зарезервировать один числовой идентификатор в традиционной системе для всех «причудливых» исключений, а затем сохранить фактический токен выполнения в файле variable
. throw
В этом случае потребуется обертка вокруг , но мы [']
также можем использовать эту возможность, чтобы объединить в нее:
variable exn-xt
-123 constant exn:fancy
: (throw-fancy) exn-xt ! exn:fancy throw ;
: [throw] postpone ['] postpone (throw-fancy) ; immediate
( usage: )
: halve dup 1 and if
[throw] its-odd
then 2/ ;
В любом случае, Miniforth соглашается на прямое выбрасывание токенов выполнения — однако эта альтернатива может быть полезна при интеграции этих идей в другие системы.
Определение исключений
Чтобы упростить определение исключений, Miniforth предоставляет следующий синтаксис:
exception
str string-field:
uint integer-field:
end-exception my-exception
Это создает переменные string-field:
и integer-field:
, и слово my-exception
, которое печатает свое имя и значения всех полей:
my-exception
integer-field: 42
string-field: hello
Как видите, соглашение об именовании окончаний полей исключений также :
служит для отделения имен от значений при печати исключения. Хотя было бы нетрудно заставить код добавлять a :
сам по себе, я не думаю, что это было бы к лучшему — соглашение об именах означает, что вам не нужно беспокоиться о том, что имена ваших полей конфликтуют с другими словами Forth. Например, unknown-word
исключение включает в себя word:
поле, но word
уже является известным словом, которое анализирует токен из входного потока. Должен признаться, что сначала я рассматривал гораздо более сложные идеи пространства имен, прежде чем понял, что двоеточие может служить соглашением об именах.
Альтернативные конструкции
Конечно, это не единственный возможный способ присоединения контекста. Во-первых, мы могли бы изменить то, как это catch
влияет на стек, и хранить любые значения, описывающие исключение, в стеке сразу под токеном выполнения. Тем не менее, throw
намеренно сбрасывает глубину стека до того, что было catch
до вызова, чтобы сделать возможным манипулирование стеком после перехвата исключения. Хотя вместо этого вы могли бы отслеживать размер обрабатываемого исключения и соответствующим образом управлять стеком, я не могу себе представить, чтобы это было приятно.
Можно также рассмотреть возможность использования динамически выделяемых структур исключений. В конце концов, именно это и делают языки более высокого уровня. Однако нет смысла откладывать исключение на потом, и в каждый момент времени генерируется только одно исключение. Я признаю, что можно связать исключения вместе, имея поле причины , например:
writeback-failed
buffer-at: $1400
caused-by:
io-error
block-number: 13
error-code: $47
Тем не менее, ситуация, когда в причинно-следственной цепочке присутствуют два исключения одного и того же типа, несколько надуманная и, на мой взгляд, не оправдывает повышенную сложность — для реализации этой работы потребуется динамический аллокатор и механизм деструктора для объекты исключений.
В конце концов, хранение контекста в глобальных переменных имеет очень приятное преимущество: контекст можно записать спекулятивно, когда это удобно. Лучше всего это проиллюстрировано на примере, поэтому давайте взглянем на must-find
, который превращает интерфейс Zero-is-Failure find
в интерфейс, ориентированный на исключения. Реализация сохраняет свою входную строку word:
перед вызовом find
, независимо от того, будет ли на самом деле выброшено исключение или нет:
: find ( str len -- nt | 0 ) ... ;
: must-find ( str len -- nt ) 2dup word: 2! find
dup 0= ['] unknown-word and throw ;
Если бы вместо этого нам пришлось оставить его в стеке, коду потребовался бы отдельный путь кода для счастливого случая впоследствии, чтобы отбросить контекст, который больше не нужен:
: must-find ( str len -- nt ) 2dup find
dup if
>r 2drop r>
else
word: 2! ['] unknown-word throw
then ;
У этой стратегии есть оговорка: если вы не будете осторожны, слово, вызванное между сохранением контекста и выдачей исключения, может перезаписать указанный контекст, т.е.
: inner s" its-inner" ctx: 2! ['] exn maybe-throw ;
: outer s" its-outer" ctx: 2! inner ['] exn maybe-throw ;
( even when outer throws the exception, ctx: contains "its-inner" )
Однако на практике это не является большой проблемой, так как чаще всего определение исключения и все слова, которые его вызывают, находятся непосредственно рядом друг с другом, поэтому легко заметить, может ли это произойти. Я полагаю, что у рекурсии будет самый высокий шанс вызвать это. Если эта проблема возникает в контексте, отличном от рекурсивного слова, ваши исключения, вероятно, в любом случае являются излишне общими и должны быть разделены на более детальные типы.
Реализация
Итак, как нам реализовать эту exception
... end-exception
структуру? Большая часть работы фактически выполняется самостоятельно end-exception
. Это связано с тем, что нам нужно сгенерировать переменные с их базовым хранилищем, а также код функции печати исключения, и мы не можем сделать и то, и другое одновременно — мы быстро закончим тем, что поместим заголовок словаря переменной в середина нашего кода. 2
Поэтому сначала определяются сами переменные контекста, а затем end-exception
выполняется обход по словарю для обработки всех переменных после их определения.
Обходя словарь, мы можем указать на запись в двух местах:
Маркер имени ( для nt
краткости) указывает на самое начало заголовка. Это значение, хранящееся в latest
полях и полях ссылок, позволяет узнать столько же, сколько само название слова. 3 С другой стороны, у нас есть токен выполнения ( xt
для краткости), который прямо указывает на код слова. Это значение, которое мы можем передать execute
, скомпилировать в определение с помощью ,
или вообще сделать что-то, где имеет значение только поведение. Обратите внимание, что из-за поля имени переменной длины мы можем превратить токен имени в токен выполнения (что и >xt ( nt -- xt )
происходит), но не наоборот.
Поскольку нам нужно знать, когда остановить наш обход, exception
запоминает значение latest
, тем самым сохраняя токен имени первого слова, которое не является частью контекста исключения. Аналогично if
or begin
мы можем просто поместить это значение в стек:
: exception ( -- dict-pos ) latest @ ;
end-exception
также начинается с выборки latest
, тем самым устанавливая другой конец диапазона, через который мы будем выполнять итерацию. Затем :
выполняется синтаксический анализ имени, следующего за end-exception
, и создание соответствующего заголовка слова.
: end-exception ( dict-pos -- ) latest @ :
( ... )
Одна повторяющаяся операция, которую необходимо выполнить печатающему слову, — это печать имени некоторого слова — либо самого имени исключения, либо одной из переменных. Давайте вынесем это в print-name,
, который берет токен имени, преобразует его в имя с помощью >name
и компилирует действие печати этого имени.
: print-name, ( nt -- )
>name postpone 2literal postpone type ;
Затем мы можем использовать его для печати только что проанализированного имени :
:
: end-exception ( dict-pos -- ) latest @ :
latest @ print-name, postpone cr
Вот диаграмма, которая визуализирует точки в словаре, на которые указывают различные указатели, которые мы получили до сих пор:
Следующим шагом будет перебор словаря и обработка всех полей. Как вы можете видеть на диаграмме выше, нам нужно прекратить итерацию, как только два указателя станут равными, проверяя перед обработкой каждого поля.
begin ( end-pos cur-pos ) 2dup <> while
dup print-field, ( we'll see print-field, later )
( follow the link field: ) @
repeat 2drop
Наконец, мы заканчиваем печатное слово с помощью ;
. Нам нужно отложить его, так как в противном случае это положит конец определению самого end-exception
себя.
postpone ;
;
Итак, как print-field,
работает? Сначала нужно напечатать само имя, что мы можем сделать с помощью print-name,
. Но как отображается значение поля?
Поскольку печать строки сильно отличается от печати числа, поле должно каким-то образом сообщать нам, как ее напечатать. Для этого в заголовке переменных исключений есть дополнительное поле, указывающее на слово, например : print-uint @ u. ;
.
Поначалу может показаться, что нет места для такого расширения заголовка. У нас есть поле со ссылкой, затем сразу идет название, а когда оно заканчивается, идет код. Однако мы можем поместить его слева от поля ссылки:
В качестве побочного эффекта этого макета нам фактически не нужно писать весь заголовок самостоятельно. После того, как наше дополнительное поле написано, мы можем просто вызвать variable
или похожее определяющее слово, и оно дополнит все остальное:
: print-uint @ u. ; : uint ['] print-uint , variable ;
: print-str 2@ type ; : str ['] print-str , 2variable ;
Затем это используется print-field,
. Для строковой переменной с именем word:
, будет сгенерирован следующий код:
s" word:" type space word: print-str cr
Вот как вы это делаете:
: print-field, ( nt -- )
dup print-name, postpone space
dup >xt , ( e.g. word: )
1 cells - @ , ( e.g. print-str )
postpone cr ;
На этом суть реализации заканчивается. Единственное, что осталось, это добавить execute
в код обработки исключений интерпретатора, что мы вскоре и сделаем, когда перейдем к внешнему интерпретатору на чистом Форте.
На самом деле код там уже есть в репозитории GitHub , с кодом из этой статьи в block14.fth
и новым внешним интерпретатором в блоках 20-21. Если вы хотите поиграть с ним, следуйте инструкциям в README, чтобы создать образ диска и запустить его в QEMU. Ввод 1 load
загрузит, среди прочего кода, новый интерпретатор и обработку исключений.
Если вам нравится то, что вы видите, не стесняйтесь адаптировать этот механизм исключений к вашей системе Forth. Хотя код, вероятно, не будет работать точно так, как написано — в конце концов, я широко использую внутренние детали словаря. Если бы я писал это с упором на переносимость, я бы, вероятно, в конечном итоге использовал отдельный связанный список для хранения пар (variable_nt, printing_xt)
(и слов, подобных uint
, расширяющих его).
И даже если вы не собираетесь добавлять контекст к вашим исключениям, я надеюсь, вы нашли это интересной демонстрацией возможностей метапрограммирования Форта.
Понравилась эта статья?
Возможно, вам понравятся и другие мои посты . Если вы хотите получать уведомления о новых, вы можете подписаться на меня в Твиттере или подписаться на RSS-канал .
Я хотел бы поблагодарить моих спонсоров GitHub за их поддержку: Michalina Sidor и Tijn Kersjes.
1
Хотя фактор времени, вероятно, не будет иметь значения — печать исключений далеко не горячая точка.↩
2
Мы могли бы попробовать перепрыгнуть через эти заголовки, но на данный момент это не похоже на то, что это что-то упрощает.↩
3
Или, скорее, даже больше, чем само имя может сказать вам, как будто будущее определение с тем же именем затмевает это, лексема имени по-прежнему будет указывать на то же слово.↩