bmstu-iu9 / refal-5-lambda

Компилятор Рефала-5λ
https://bmstu-iu9.github.io/refal-5-lambda
Other
78 stars 35 forks source link

Обработка исключений #180

Open Mazdaywik opened 6 years ago

Mazdaywik commented 6 years ago

Мотивация

Встроенные функции ввода-вывода Рефала-5 не могут сообщить об ошибке, собственно, ввода-вывода. Если файла не существует, то Open аварийно остановит программу. Та же проблема будет при попытке писать файл, если на диске закончилось место. И т.д.

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

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

Хорошо известны два способа обработки подобных внештатных ситуаций — возврат кода ошибки и выбрасывание исключения (которое можно затем перехватить и обработать).

Возврат кода ошибки довольно просто и естественно реализуется в Рефале. Например, та же функция открытия файла (назовём её SafeOpen) могла бы иметь следующий формат:

<SafeOpen s.Mode s.FileNo e.FileName>
  == Success
  == Fails e.ErrorMessage

<SafeFOpen s.Mode e.FileName>
  == Success s.FileNo
  == Fails e.ErrorMessage

Но у этого подхода есть два недостатка:

Поэтому предлагается реализовать исключения.

Библиотечные средства для работы с исключениями

Никакой новый синтаксис не вводится, поскольку для работы с исключениями в минимальном варианте достаточно ввести три новых функции (разумеется, они потребуют правок рантайма).

Первая функция — это генерация исключения

<Raise t.FuncCall e.ErrorMessage> == нет возврата

t.FuncCall ::= (s.FUNCTION e.Arg)
e.Arg, e.ErrorMessage ::= e.ANY-EXPR

Семантика. Если исключение не перехвачено (см. далее), то функция выводит в файл дампа e.ErrorMessage (в том же виде, что и Prout), заменяет свой вызов на t.FuncCall (но с угловыми скобками) и возвращает refalrts::cRecognitionImpossible. Таким образом, останов будет выглядеть, как будто упал вызов t.FuncCall, но с дополнительным текстом ошибки. Пример.

<Raise (&Open 'r' 10 'file.txt') FileNotFound>

Напечатается FileNotFound, вызов <Raise …> заменится на <Open 'r' 10 'file.txt'> и программа остановится с выдачей аварийного дампа.

Вторая функция — это конструкция перехвата исключения:

<Try t.Callable Catch s.CatchFunction> == e.Result
t.Callable ::= s.SingleFunction | (s.ArgFunction e.Arg)
s.SingleFunction, s.ArgFunction ::= s.FUNCTION
<s.SingleFunction> == e.Result
<s.ArgFunction e.Arg> == e.Result
<s.CatchFunction t.FuncCall e.ErrorMessage> == e.Result

Семантика. Первый аргумент функции t.Callable — вызываемая функция, которая может упасть. Второй аргумент — обработчик исключения. Если функция, заданная в t.Callable, завершилась без исключений, то её возвращаемое значение совпадает с возвращаемым значением функции Try. Если исключение произошло (была где-то вызвана функция Raise), то вызывается s.CatchFunction с аргументом вызова Raise. Возвращаемое значение обработчика становится возвращаемым значением функции Try. Пример. Немножко надуманный, но иллюстрирует.

<Try
  {
    = Success <LoadFile e.FileName>;
  }
Catch
  {
    (&FOpen 'r' e.FileName^) FileNotFound = Fails FileNotFound e.FileName;
    (&FOpen 'r' e.FileName^) PermissionDenied = Fails PermissionDenied e.FileName;
    t.Callable e.ErrorMessage = <ReRaise t.Callable e.ErrorMessage>;
  }
>

В примере встретилась третья функция — ReRaise, которая служит для продолжения обработки исключения внешним перехватчиком.

<ReRaise t.FuncCall e.ErrorMessage>

Семантика. Может быть вызвана только из s.CatchFunction. Передаёт управление наружному обработчику исключения, как если бы предыдущего обработчика Try не было. Если больше нет обработчиков, программа выводит аварийный дамп, если есть — исключение перехватывается в следующем из них.

Преимущества выбранного подхода:

Какие ошибки перехватывать?

Очевидно, нужно описывать исключениями ошибки функций ввода-вывода (см. мотивацию).

Технически можно генерировать исключение даже для ошибок невозможности отождествления или недостатка памяти (хотя здесь засада в том, что как программа будет продолжать работу, если память закончилась), но надо ли? На мой взгляд — скорее, не надо. Внутренние ошибки должны приводить к падениям, скрывать их нельзя. Но, с другой стороны, операционные системы и языки программирования позволяют перехватывать и ошибки доступа к памяти, и ошибки деления на нуль, значит, это зачем-то может быть нужно.

С делением на нуль тоже не очевидно. Функции длинной арифметики написаны на Рефале, поэтому сейчас сообщение об ошибке деления на нуль в дампе не очевидно. В дампе будет вызов не <Div … 0>, а одной из вспомогательных функций, затрудняюсь сказать какой (запускать лень). Функция Raise могла бы прояснить сообщение об ошибке.

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

Всё это надо обдумать.

Mazdaywik commented 5 years ago

Возможная реализация

Ниже приводится возможная реализация частично на Рефале, частично на псевдокоде. К ней следует относиться как к наброску. Для наглядности будут рассмотрены только Try и Raise, поскольку ReRaise несколько усложнит картину.

Обработку исключений можно сделать почти полностью библиотечной — с минимальными правками рантайма.

Функцию Try можно вообще описать на Рефале:

Try {
  s.Callable Catch s.Catcher = <Try-Aux <s.Callable> Catch s.Catcher>;
  (s.Callable e.Arg) Catch s.Catcher = <Try-Aux <s.Callable e.Arg> Catch s.Catcher>;
}

Try-Aux {
  e.Result Catch s.Catcher = e.Result;
}

Функция Try-Aux выполняет две роли. Во-первых, ограничивает в поле зрения контролируемые вычисления. Контролируемая функция выполняется между &Try-Aux и Catch. Во-вторых, когда контролируемые вычисления заканчиваются (аргумент становится пассивным), она сама себя удаляет из поля зрения.

Функцию Raise нужно описывать на Си++. Функция Raise имеет тот же формат, что и s.Catcher, поэтому в коде ниже будет опущена проверка аргумента — аргумент Raise напрямую передаётся в s.Catcher. Поле зрения на момент вызова Raise имеет вид:

… <Try-Aux … <Raise t.FuncCall e.Message> … > …

Вот её код на Си++ с элементами псевдокода:

$ENTRY Raise {
%%
  Iter pRaise = arg_begin->next;
  Iter try_aux_begin, try_aux_end, try_aux_func;
  do {
    try_aux_begin = refalrts::pop_stack(vm);
    if (try_aux_begin != 0) {
      try_aux_end = refalrts::pop_stack(vm);
      try_aux_func = try_aux_begin->next;
    } else {
      try_aux_func = 0;
    }
  while (try_aux_func != 0 && try_aux_func->function_info == &Try-Aux);

  if (try_aux_func == 0) {
    Распечатать при помощи Prout’а e.Message
    Заменить вызов <Raise (s.Func e.Arg) e.Message> на <s.Func e.Arg>
    return refalrts::cUnhandledException;
  }

  Iter sCatcher = try_aux_end->prev;
  Iter pCatch = sCatcher->prev;
  // поле зрения
  // … <Try-Aux … <Raise arg> … Catch s.Catcher> …
  refalrts::splice_stvar(pRaise, sCatcher);
  // … <Try-Aux … <s.Catcher Raise arg> … Catch> …
  refalrts::splice_to_freelist(pRaise, pRaise);
  // … <Try-Aux … <s.Catcher arg> … Catch> …
  refalrts::splice_to_freelist(try_aux_func, arg_begin);
  // … <s.Catcher arg> … Catch> …
  refalrts::splice_to_freelist(arg_end, pCatch);
  // … <s.Catcher arg> …
  refalrts::push_stack(vm, try_aux_end);
  refalrts::push_stack(vm, try_aux_begin);
  return refalrts::cSuccess;
%%
}

В рантайм потребуется добавить константу cUnhandledException и функцию pop_stack(). По смыслу cUnhandledException ничем не отличается от cRecognitionImpossible за исключением другого сообщения об ошибке. Функция pop_stack() есть в реализации виртуальной машины, её нужно только протянуть в интерфейс.

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

Функция ReRaise заметно усложняет реализацию. В предложенной реализации функция Raise заменяет вызов Try-Aux на вызов перехватчика, стирая всё поле зрения вокруг аргумента Raise и внутри Try-Aux, после чего вызывает перехватчик. Для корректной работы ReRaise сначала должен быть запущен перехватчик (который может вызвать ReRaise), а после работы перехватчика вызов Try-Aux должен замениться на результат работы перехватчика.

Но это всё технические сложности, а не принципиальные. Даже с учётом ReRaise правки рантайма окажутся минимальными и код без исключений выполнится эффективно. Реализация функции Try, скорее всего, окажется такой же, но вот Raise и ReRaise будут страшнее.

Mazdaywik commented 4 years ago

Функция ReRaise не особо нужна, по крайней мере на первом этапе.

Mazdaywik commented 4 years ago

Задача вполне приемлема для летней практики.

Mazdaywik commented 4 years ago

Если с ReRaise, то задача достаточно сложная для курсовой. Если нет — маловато (но тоже можно).

Mazdaywik commented 4 years ago

А, вообще-то, с ReRaise вполне приемлемая для курсовой. Без неё тоже приемлемая, но почти халявная, ведь решение подробно расписано.

Mazdaywik commented 3 years ago

Плохая задача для курсовой, даже с ReRaise. Кода всего на 100 строк. В записке нечего писать будет, а надо 20 страниц. Хорошо, что никто не выбрал.

Mazdaywik commented 3 years ago

Есть один нюанс в предложенной выше реализации. Она не будет работать при использовании прямой кодогенерации, если исключение произошло в условии, а обработчик (Try-Aux) снаружи условия.

Для выполнения условия интерпретатор в режиме прямой кодогенерации вызывается рекурсивно. Вложенный вызов завершается, когда выполняется функция-условие (FuncName?n). Обработка исключения просто напросто перескакивает через этот вложенный вызов, а значит системный стек и стек Рефала рассогласуются. Если обработчик исключения сам находился в условии, то завершение этого условия приведёт к возврату в условие перепрыгнутой функции и возобновится не та функция, которая должна быть. Указатели на поле зрения, которые хранились в прерванной функции, будут уже указывать совсем не туда.

Решить эту проблему невозможно без переписывания поддержки условий или вообще генерации кода C++, причём не очевидно как. И такой задачи масштабной переработки сейчас не стоит.

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

Кстати и в режиме интерпретации тоже не всё так просто. Если между Raise и Try-Aux находится одно или несколько условий, то нужно снять со стека соответствующее число запомненных состояний. Это крайне не элегантно, а вернее сказать, костыльно.

Поэтому разобранное выше в комментариях решение не подходит. Вернее, подходило бы только для базисного Рефала.


Поэтому другое решение. Если ранее Raise несла всю логику, а Try + Try-Aux были почти пустышкой, то теперь наоборот.

Функция Raise ничего не делает, кроме как возвращает на стек скобок свои скобки и возвращает refalrts::cException. Когда код прямой кодогенерации рекурсивно вызывает главный цикл, он проверяет код возврата рефал-машины. Если он не cSuccess, значение возвращается как есть.

Функция Try реализуется на уровне рантайма. Она рекурсивно вызывает Рефал-машину (для контролируемой функции), запоминая верхушку стека состояний и стека скобок активации. Если рекурсивный вызов завершился успешно, функция Try просто оставляет в поле зрения результат контролируемой функции. Если не cException, то завершает главный цикл с этим кодом возврата.

Если рекурсивный вызов вернул cException, то на вершине стека скобок активации находится вызов <Raise …>, на вершине стека состояний — лишние состояния для пропущенных условий. Функция Try снимает со стека состояний лишние узлы (ведь она до этого сохранила предыдущую вершину), верхушку стека активаций она просто присваивает, из вызова Raise берёт аргумент для обработчика. Затем удаляет лишний мусор (поле зрения вокруг бывшего <Raise …>, но внутри <Try …>) и вызывает обработчик с аргументом <Raise …>.

Как реализовать ReRaise? Мусор не удалять, подменять вызов <Raise …> вызовом обработчика. А ReRaise сделать синонимом Raise. В этом случае текст в поле зрения сохранится, а значит, сохранится и контекст исключения. Только вопрос — а зачем это нужно? Можно придумать и более хитрую реализацию, с рекурсивным вызовом обработчика. И вместо cException возвращать cRaise и cReRaise, чтобы различать эти случаи. В случае Raise мусор удалять, в случае ReRaise, удалять скобки активации <Try …>, т.к. семантика такова, что исключение передаётся следующему обработчику, как если бы предыдущего не было. Но, ещё раз, зачем нужен этот кривой ReRaise, ведь обработчик может подменить сообщение об исключении? Сценарий, когда полезен ReRaise, мне не очевиден.

Но и с этим вариантом решения задача слишком маленькая для курсовой. И даже в этом варианте решения потребуются ≈100 строк кода. Да и исключения пока не особо нужны.