Как Forth реализует исключения
20 сентября 2021 г. · 12 минут чтения
Эта статья является частью серии «Начальная загрузка» , в которой я начинаю с 512-байтного начального источника и пытаюсь загрузить реальную систему.
Предыдущий пост:
Ветвления: сборка не требуется
Следующее сообщение:
Контекстные исключения с метапрограммированием Forth
Учитывая низкоуровневую природу Forth, некоторые могут удивиться тому, насколько хорошо он подходит для обработки исключений. Но действительно, ANS Forth определяет простой механизм обработки исключений. Поскольку в Форте нет системы типов, способной поддерживать такой механизм, как в Rust Result
, исключения являются предпочтительной стратегией обработки ошибок. Давайте подробнее рассмотрим, как они используются и как реализуются.
Точка зрения пользователя
Механизм исключения состоит из двух обращенных к пользователю слов: catch
и throw
. В отличие от других слов потока управления, которые действуют как дополнительный синтаксис, им catch
просто требуется токен выполнения 1 на вершине стека, что обычно означает, что [']
он будет использоваться для его получения непосредственно перед вызовом catch
(хотя и вне определения, '
вместо этого используется ).
Если execute
переданный ему токен выполнения catch
ничего не выдает, a 0
чтобы указать на успех:
42 ' dup catch .s ( <3> 42 42 0 ok )
С другой стороны, если throw
выполняется , throw
аргумент остается в стеке, чтобы указать тип исключения:
: welp 7 throw ;
1 2 ' welp catch .s ( <3> 1 2 7 ok )
Однако элементы стека под этим кодом исключения — это не только то, что было там throw
, когда он был запущен — если существует более одного возможного throw
местоположения, расположение стека стало бы непредсказуемым. Именно поэтому catch
запоминает глубину стека, чтобы throw
можно было его восстановить. В результате, если наш welp
поместит дополнительные элементы в стек, они будут отброшены:
: welp 3 4 5 7 throw ;
1 2 ' welp catch .s ( <3> 1 2 7 ok )
и если он потребляет некоторые элементы стека, их место будет заполнено неинициализированными слотами при перемещении указателя стека:
: welp 2drop 2drop 7 throw ;
1 2 3 4 ' welp catch .s ( <5> 140620924927952 7 140620924967784 56 7 ok )
Чтобы думать об этом, нужно рассматривать эффект стека ' foo catch
в целом. Например, если foo
имеет эффект стека ( a b c -- d )
, то ' foo catch
имеет ( a b c -- d 0 | x1 x2 x3 exn )
, где - x?
слоты, занятые аргументами a b c
, которые можно заменить практически любым значением, и поэтому их можно только отбросить.
Ключевым моментом здесь является то, что количество элементов в стеке становится известным, и теперь мы можем безопасно отбросить то, что могло быть затронуто foo
, чтобы получить доступ ко всему, что мы могли бы хранить ниже.
Давайте посмотрим на более полный пример того, как все это можно использовать. Предположим, у нас есть /
слово, которое реализует деление, но вылетает при попытке разделить на ноль. Давайте завершим его быстрой проверкой, которая вместо этого выдает исключение.
Во-первых, нам нужно выбрать целое число, которое будет обозначать тип нашего исключения. Нет никаких соглашений относительно того, как это должно быть сделано, за исключением некоторых зарезервированных значений:
0
используется дляcatch
обозначения «без исключения»значения в диапазоне
{-255...-1}
зарезервированы для ошибок, определенных стандартомзначения в диапазоне
{-4095...-256}
зарезервированы для ошибок, определенных реализацией Forth
Поскольку стандарт присваивает идентификатор для «деления на ноль», мы могли бы также использовать его.
-10 constant exn:div0
На самом деле я не смог найти никаких указаний о том, как они обычно выбираются для исключений для конкретных приложений. Если бы мне пришлось угадывать, я бы начал с небольших положительных целых чисел, а не с -4096
понижения. Что бы это ни стоило, расширенный механизм исключений Miniforth обходит это, используя адреса памяти в качестве идентификаторов.
Во всяком случае, выдача исключения выглядит именно так, как вы ожидаете:
: / ( a b -- a/b )
dup 0= if
exn:div0 throw
then / ( предыдущее, неоговоренное определение / - не рекурсия )
;
Затем вы можете использовать его так:
: /. ( a b -- )
over . ." divided by " dup . ." is "
['] / catch if
2drop ( / принимает 2 аргумента, поэтому нам нужно отбросить 2 слота)
." infinity" ( sad math pedant noises )
else . then ;
Это работает так, как вы ожидаете:
7 4 /. 7 разделить на 4 равно 1 ок
7 0 /. 7 разделить на 0 это бесконечность
Конечно, явная проверка делителя нуля в этом случае, вероятно, имела бы больше смысла, но более реалистичный пример слишком затенил бы детали обработки исключений...
0 throwи его использование
Прежде чем мы рассмотрим реализацию throw
и catch
самих себя, я хотел бы выделить еще один частный случай. В частности, throw
проверяет, равен ли номер исключения нулю, и если да, то фактически не выдает его — 0 throw
всегда не работает.
Есть несколько аспектов, почему это так. Во-первых, фактическое выбрасывание нуля может сбивать с толку, так как catch
он используется для обозначения того, что исключение не было перехвачено. Но подождите, Форт не совсем в характере проверять это. Есть куча других способов испортить. Они могли бы сказать: «Он съест носок, если вы попытаетесь это сделать» и отпраздновать победу в производительности.
И даже если вы проверите, зачем делать это бездействующим? Разве вы не должны вместо этого генерировать исключение «попытался сгенерировать ноль», чтобы убедиться, что ошибка замечена?
Ответ достаточно прост: это не обязательно ошибка. Есть несколько полезных идиом, которые сосредоточены на том 0 throw
, чтобы быть бездействующим.
Один касается более краткого способа проверки условия:
: / ( a b -- a/b )
dup 0= exn:div0 and throw / ;
Это работает, поскольку в Forth каноническое значение true
имеет все установленные биты (в отличие от C, в котором устанавливается только младший бит), поэтому true exn:div0 and
оценивается как exn:div0
. Конечно, при использовании этой идиомы нужно быть осторожным, чтобы использовать канонически закодированный флаг, а не что-то, что может возвращать произвольные значения, которые оказались правдивыми.
Другая идиома позволяет выставить интерфейс на основе кода ошибки, который удобно использовать как интерфейс на основе исключений. Например, allocate
(который выделяет память динамически, как C malloc
) имеет эффект стека size -- ptr err
. Если вызывающая сторона хочет обработать ошибку распределения здесь и сейчас, она может сделать
... allocate if ( it failed :/ ) exit then
( happy path )
Но для создания исключения при возврате ошибки требуется только allocate throw
--- если ошибки не произошло, то она 0
будет удалена.
Внутренности
Как же делается эта "колбаса"? jonesforth
, очень популярная грамотная программная реализация Forth, предлагает реализовать исключения, по существу, просматривая throw
стек возврата для определенного адреса в реализации catch
. Это похоже на то, к чему можно прийти после изучения сложных механизмов раскручивания в таких языках, как C++ или Rust 2 — они тоже раскручивают стек, используя очень сложный механизм поддержки, охватывающий всю цепочку инструментов. Однако причина, по которой им это необходимо, заключается в запуске деструкторов объектов в кадрах стека, которые вот-вот будут отброшены.
Форт, как вы, наверное, знаете, не имеет деструкторов. Это позволяет найти гораздо более простое решение — вместо того, чтобы сканировать стек возврата в поисках позиции, где catch
она выполнялась последней, мы можем просто сохранить catch
эту позицию в переменной.
variable catch-rp ( return [stack] pointer at CATCH time )
Помимо простоты, этот подход также имеет преимущества в производительности и надежности — помните, что и>r
циклы выполнения также могут помещать данные в стек возврата. Было бы очень обидно, если бы такое значение совпало с адресом специального маркера, который сканируется... 4
Для поддержки вложенных вызовов catch
нам нужно сохранить предыдущее значение catch-rp
в стеке. Пока мы на этом, это также хорошее место для сохранения указателя стека параметров. Это эффективно создает связанный список «кадров обработки исключений», размещенных в стеке возврата:
Обратите внимание, что запись «возврат к catch
» находится над данными, нажатыми catch
. Это связано с тем, что первый запускается только после catch
вызова слова, не являющегося ассемблерным, — в данном случае , которое execute
в конечном итоге потребляет токен выполнения.
Требуется некоторая сборка
Поскольку указатели стека сами по себе не являются частью модели программирования Forth, нам потребуется написать несколько слов на ассемблере, чтобы манипулировать ими. Слова для указателя стека возврата просты:
:code rp@ ( -- rp ) bx push, di bx movw-rr, next,
:code rp! ( rp -- ) bx di movw-rr, bx pop, next,
(этот синтаксис (как и реализация ассемблера) был объяснен в предыдущей статье )
Манипулирование указателем стека данных немного сложнее отслеживать, так как значение указателя стека само проходит через стек данных. В итоге я выбрал следующее правило: sp@
нажимает указатель на элемент, который был вверху до того, как sp@
был выполнен. В частности, это средство sp@ @
делает то же самое, что и dup
:
Эта диаграмма немного искажает реальность, так как вершина стека хранится в bx
, а не в памяти , в качестве оптимизации. Таким образом, нам сначала нужно сохранить bx
в памяти:
:code sp@ bx push, sp bx movw-rr, next,
sp!
работает аналогично, с руководящим принципом, который sp@ sp!
должен быть неактивным:
:code sp! bx sp movw-rr, bx pop, next,
Обратите внимание, что на самом деле нет никаких различий в реализации между sp@
/ sp!
и их аналогами стека возврата (кроме использования sp
регистра вместо di
). Просто нужно больше думать об одном, чем о другом...
Последнее :code
слово, которое нам понадобится, это execute
, которое принимает токен выполнения и переходит к нему.
:code execute bx ax movw-rr, bx pop, ax jmp-r,
Интересно, что execute
на самом деле это не нужно реализовывать на ассемблере. С тем же успехом мы могли бы сделать это и на Форте с некоторыми предположениями о том, как компилируется код — записать токен выполнения в скомпилированное представление самого себя execute
, как раз перед тем, как мы достигнем точки, когда он будет прочитан:
: execute [ here 3 cells + ] literal !
( любое слово может быть здесь, поэтому... ) drop ; ( chosen by a fair dice roll... )
Однако, на мой взгляд, этот вид обмана излишне умный. На самом деле у него нет никаких преимуществ переносимости, поскольку он так много предполагает о реализации Forth, на которой он работает, и, кроме того, он, вероятно, больше и медленнее. Тем не менее, это достаточно интересно, чтобы упомянуть, даже если мы фактически не используем его в конце.
Собираем все вместе
Давайте еще раз посмотрим, как должен выглядеть стек возврата:
Давайте построим это тогда:
: catch ( i*x xt -- j*x 0 | i*x n )
sp@ >r catch-rp @ >r
rp@ catch-rp !
Тогда пришло время execute
. На самом деле он вернется только в том случае, если не будет выброшено исключение, поэтому затем мы обрабатываем счастливый путь, нажимая 0
:
execute 0
Наконец, мы помещаем то, что запихнули в стек возврата. Предыдущее значение catch-rp
должно быть восстановлено, но указатель стека данных должен быть удален, так как в этом случае мы не должны восстанавливать глубину стека.
r> catch-rp ! rdrop ;
throw
начинается с проверки того, что код исключения не равен нулю, а затем откатывает стек возврата в сохраненное место.
: throw dup if
catch-rp @ rp!
Восстановление catch-rp
происходит так, как вы ожидаете:
r> catch-rp !
Сохраненный SP несколько сложнее. Во-первых, мы не хотим потерять код исключения, поэтому нам нужно сохранить его в стеке возврата перед восстановлением SP:
r> swap >r sp!
Во-вторых, при sp@
запуске токен выполнения все еще находился в стеке — нам нужно удалить этот слот стека, прежде чем помещать на его место код исключения:
drop r>
else ( the 0 throw case ) drop then ;
Но подождите, есть еще!
Мы видели, как работает стандартный механизм исключений в Forth. Предусмотрены средства броска и ловли, но в весьма зачаточном виде. В моем следующем посте я объясню, как Miniforth строит этот механизм для присоединения контекста к исключениям, что приводит к интерактивным сообщениям об ошибках, когда исключение всплывает на верхний уровень. Увидимся там!
Понравилась эта статья?
Возможно, вам понравятся и другие мои посты . Если вы хотите получать уведомления о новых, вы можете подписаться на меня в Твиттере или подписаться на RSS-канал .
Я хотел бы поблагодарить моих спонсоров GitHub за их поддержку: Michalina Sidor и Tijn Kersjes.
1
Проще говоря "указатель на функцию". ↩
2
Это не значит, что jonesforth
в целом все плохо. Основа системы прочная, и она довольно хорошо объясняет задействованные концепции. Я определенно рекомендую его как введение во внутренности Форта и даже, возможно, как способ изучения самого Форта. ↩
3
Казалось бы, производительность исключений никогда не должна становиться узким местом. Я согласен, хотя я хотел бы воспользоваться этой возможностью, чтобы указать на стиль программирования, который я недавно видел, в котором производительность обработки исключений действительно имеет значение. А именно, взгляните на примеры в разделе Exceptions руководства ATS . Рекомендуется усмотрение зрителя. ↩
4
Вероятно, это может иметь некоторые последствия для безопасности, но, надеюсь, в любом случае никто не пишет критически важные для безопасности вещи на Forth. Однако, учитывая, где мы находимся с C... ↩