Контекстные исключения с метапрограммированием 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, тем самым сохраняя токен имени первого слова, которое не является частью контекста исключения. Аналогично ifor 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. ;.

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

Печать xt находится непосредственно слева от места, на которое указывает nt.
Печать xt находится непосредственно слева от места, на которое указывает nt.

В качестве побочного эффекта этого макета нам фактически не нужно писать весь заголовок самостоятельно. После того, как наше дополнительное поле написано, мы можем просто вызвать 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

Или, скорее, даже больше, чем само имя может сказать вам, как будто будущее определение с тем же именем затмевает это, лексема имени по-прежнему будет указывать на то же слово.

Комментарии (0)