Pull to refresh

Реализация Common Lisp Condition System на C#

Reading time 11 min
Views 5K
Одним из самых замечательных и притягательных свойств языка Common Lisp является, безусловно, его система обработки исключений.

Более того, по моему, лично, мнению, подобный подход к исключениям является единственно правильным для всех императивных языков, и вот по какой простой причине:

Механизм «исключений»(или, как они называются в мире CL — conditions) в Common Lisp отделен от механизма раскрутки стека, а это, соответственно, позволяет обрабатывать любые всплывающие в программе исключительные(да и не только исключительные) ситуации прямо в том месте, где они возникли, без потери контекста выполнения программы, что влечет за собой удобство разработки, отладки, да и вообще, удобство построения логики программы.

Наверное, следует сказать, что Common Lisp Condition System, несмотря на свою уникальность в среде высокоуровневых языков программирования, очень близка известным многим разработчикам низкоуровневым средствам современных операционных систем, а именно: синхронным сигналам UNIX и, гораздо ближе, механизму SEH(Structured Exception Handling) из Windows. Ведущие реализации CL основывают такие элементы управления потоком вычислений, как механизм обработки исключений и раскрутка стека, именно на них.

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

Для полноценной реализации CLCS от языка программирования, а скорее даже — от его рантайма, требуется следующие несколько вещей:
  • Строгая стековая модель исполнения. Здесь я имею ввиду отсутствие в языке полноценных «продолжений»(continuations). Этот пункт достаточно условный, но поскольку продолжения вносят в механизм управления потоком вычислений огромную размытость, и не позволяют с достаточной точностью определить основные примитивы, от которых отталкивается CLCS, их наличие крайне нежелательно.
  • Функции высшего порядка, анонимные функции, и замыкания. Конечно, если постараться, можно все реализовать и через объекты и классы, но в таком случае пользоваться всем этим, по моему мнению, будет крайне неудобно.
  • Динамические окружения и, в частности, динамические переменные. Про динамические окружения и переменные я более-менее подробно писал в своей статье о семантике современных лиспов: love5an.livejournal.com/371169.html
    При отсутствии похожей концепции в языке программирования, она, впрочем, эмулируется с помощью следующих двух пунктов:
  • Операторы try, catch и throw, или их аналоги. Эти операторы есть в любом языке программирования, поддерживающем исключения.
  • Примитив UNWIND-PROTECT или его аналог(блок try-finally, RAII и т.д.).


Мы перенесем на C# следующие примитивы системы обработки исключений CL:
  1. handler-bind — устанавливает обработчик исключений на время выполнения тела оператора. При отлове исключения обработчик может принять решение о раскрутке стека, но не обязан этого делать.
  2. handler-case — устанавливает обработчик исключений на время выполнения тела оператора. При отлове исключения производится раскрутка стека и оператор возвращает значение, вычисленное в теле обработчика.
  3. signal — сигнализирует о возникновении исключения вышестоящему обработчику, если такой присутствует.
  4. error — сигнализирует о возникновении исключения вышестоящему обработчику, а в случае отсутствия оного, или в случае отказа всех обработчиков справиться с исключением — выбрасывает исключение обычным методом, т.е. оператором throw(Это в нашей реализации. В Common Lisp функция error вызывает дебаггер, если тот подключен, или, в противном случае — завершает работу отдельного потока вычислений(thread) или всей лисп-системы.)
  5. restart-bind — устанавливает «перезапуск», не вызывающий механизм раскрутки стека. Перезапуск это функция, в текущем динамическом окружении(см. ссылку на статью выше), способная как-либо отреагировать на возникшее исключение. Перезапуски обычно ставятся в тех местах программы, где можно как-либо исправить возникшую ошибку. Запускаются они обычно из обработчиков исключений(см. далее).
  6. restart-case — устанавливает «перезапуск», завершающийся раскруткой стека.
  7. find-restart — находит «перезапуск» по имени.
  8. invoke-restart — находит «перезапуск» по имени и запускает его.
  9. compute-restarts — вычисляет список всех установленных в текущем динамическом окружении «перезапусков».
  10. unwind-protect — выполняет блок тела оператора, а после — вне зависимости от того, завершилось ли выполнение нормальным образом, или через принудительную раскрутку стека — выполняет все указанные «защищающие» блоки(функции).


Подробнее про эти, и другие примитивы, связанные с обработкой исключений, можно прочитать в замечательной книжке Питера Сибеля «Practical Common Lisp», в главе 19:
lisper.ru/pcl/beyond-exception-handling-conditions-and-restarts

Вся реализация у нас будет содержаться в статическом классе Conditions. Далее я буду описывать его методы.
Но сначала следует описать пару статических переменных.

В каждом потоке выполнения программы обработчики исключений и перезапуски при установке формируют стек. Вообще, формально говоря, стек формируют динамические окружения каждого треда, но так как динамические окружения в C#, строго говоря, отсутствуют, мы будем «руками» связывать с каждым тредом структуру данных «стек».

static ConditionalWeakTable<Thread, Stack<Tuple<Type, HandlerBindCallback>>> _handlerStacks;
static ConditionalWeakTable<Thread, Stack<Tuple<string, RestartBindCallback>>> _restartStacks;

static Conditions()
{
  _handlerStacks = new ConditionalWeakTable<Thread, Stack<Tuple<Type, HandlerBindCallback>>>();
  _restartStacks = new ConditionalWeakTable<Thread, Stack<Tuple<string, RestartBindCallback>>>();
}


Для словаря «тред -> стек» я здесь выбрал класс ConditionalWeakTable, добавленный в .NET 4.0, но можно использовать любую другую подобную структуру данных. ConditionalWeakTable хорош тем, что является хеш-табличкой со «слабыми указателями»(WeakPointer — отсюда и Weak в названии класса) на ключи, а это, соответственно, значит, что при удалении объекта треда(Thread) сборщиком мусора, у нас не возникнет утечки памяти.

Обработчики и сигнализирование исключений


HandlerBind

public static T HandlerBind<T>(Type exceptionType, HandlerBindCallback handler, HandlerBody<T> body)
{
  if (null == exceptionType)
    throw new ArgumentNullException("exceptionType");
  if (!exceptionType.IsSubclassOf(typeof(Exception)))
    throw new InvalidOperationException("exceptionType is not a subtype of System.Exception");
  if (null == handler)
    throw new ArgumentNullException("handler");
  if (null == body)
    throw new ArgumentNullException("body");
  Thread currentThread = Thread.CurrentThread;
  var clusters = _handlerStacks.GetOrCreateValue(currentThread);
  clusters.Push(Tuple.Create(exceptionType, handler));
  try
  {
    return body();
  }
  finally
  {
    clusters.Pop();
  }
}

Метод HandlerBind у нас принимает три параметра — тип исключения, с которым связывается обработчик(как видно из тела метода, он должен быть подклассом Exception), коллбек, определяющий код обработчика, и еще один делегат, определяющий код, исполняемый в теле оператора.
Типы делегатов handler и body такие:
public delegate void HandlerBindCallback(Exception exception);
public delegate T HandlerBody<T>();

Параметр exception, передаваемый обработчику в аргументы это собственно сам объект исключения.

Как видно, реализация HandlerBind проста — к стеку обработчиков, связанных с текущим тредом, мы добавляем новый, после — выполняем код тела оператора, и в итоге, в теле finally, убираем обработчик со стека. Таким образом, стек обработчиков исключений связывается со стеком выполнения текущего треда, и каждый установленный обработчик становится недействительным при выходе из соответствующего стекового кадра потока выполнения программы.

HandlerCase

public static T HandlerCase<T>(Type exceptionType, HandlerCaseCallback<T> handler, HandlerBody<T> body)
{
  if (null == exceptionType)
    throw new ArgumentNullException("exceptionType");
  if (!exceptionType.IsSubclassOf(typeof(Exception)))
    throw new InvalidOperationException("exceptionType is not a subtype of System.Exception");
  if (null == handler)
    throw new ArgumentNullException("handler");
  if (null == body)
    throw new ArgumentNullException("body");
  var unwindTag = new UnwindTag<T>();
  HandlerBindCallback handlerCallback = (e) =>
  {
    unwindTag.Value = handler(e);
    throw unwindTag;
  };
  try
  {
    return HandlerBind(exceptionType, handlerCallback, body);
  }
  catch (UnwindTag<T> e)
  {
    if (e == unwindTag)
    {
      return e.Value;
    }
    else
      throw;
  }
}


Реализация HandlerCase несколько сложнее. Отличие от HandlerBind, напомню, в том, что этот оператор раскручивает стек до точки, в которой установлен обработчик. Так как в C# запрещены явные escaping continuations(то есть, грубо говоря, мы не можем сделать goto или return из лямбды, передаваемой вниз по стеку, во внешний блок), то для раскрутки стека мы используем обычные try-catch, а блок обработчика идентифицируем объектом вспомогательного класса UnwindTag
class UnwindTag<T> : Exception
{
  public T Value { get; set; }
}


HandlerCaseCallback отличается от HandlerBindCallback только тем, что возвращает какое-либо значение:
public delegate T HandlerCaseCallback<T>(Exception exception);


Signal

Функция Signal это самое сердце системы обработки исключений CL. В отличие от throw и сотоварищей, из других языков программирования, она не раскручивает стек вызовов, а всего лишь сигнализирует о произошедшем исключении, то есть просто вызывает подходящий обработчик.

public static void Signal<T>(T exception)
  where T : Exception
{
  if (null == exception)
    throw new ArgumentNullException("exception");
  Thread currentThread = Thread.CurrentThread;
  var clusters = _handlerStacks.GetOrCreateValue(currentThread);
  var i = clusters.GetEnumerator();
  while (i.MoveNext())
  {
    var type = i.Current.Item1;
    var handler = i.Current.Item2;
    if (type.IsInstanceOfType(exception))
    {
      handler(exception);
      break;
    }
  }
}


Как видно — всё очень просто. Из текущего стека обработчиков исключений мы берем первый, способный работать с классом исключений, экземпляром которого является объект, переданный нам в параметр exception.

Error

public static void Error<T>(T exception)
  where T : Exception
{
  Signal(exception);
  throw exception;
}


Error отличается от Signal только тем, что прерывает нормальный поток выполнения программы в случае отсутствия подходящего обработчика. Если бы мы писали полноценную реализацию Common Lisp под .NET, вместо «throw exception» было бы что-то вроде «InvokeDebuggerOrDie(exception);»

Перезапуски


RestartBind и RestartCase

RestartBind и RestartCase очень похожи на HandlerBind и HandlerCase, с тем отличием, что работают со стеком перезапусков, и ставят в соответствие делегату-обработчику не тип исключения, а строку, имя перезапуска.
public delegate object RestartBindCallback(object param);

public delegate T RestartCaseCallback<T>(object param);

public static T RestartBind<T>(string name, RestartBindCallback restart, HandlerBody<T> body)
{
  if (null == name)
    throw new ArgumentNullException("name");
  if (null == restart)
    throw new ArgumentNullException("restart");
  if (null == body)
    throw new ArgumentNullException("body");
  Thread currentThread = Thread.CurrentThread;
  var clusters = _restartStacks.GetOrCreateValue(currentThread);
  clusters.Push(Tuple.Create(name, restart));
  try
  {
    return body();
  }
  finally
  {
    clusters.Pop();
  }
}

public static T RestartCase<T>(string name, RestartCaseCallback<T> restart, HandlerBody<T> body)
{
  if (null == name)
    throw new ArgumentNullException("name");
  if (null == restart)
    throw new ArgumentNullException("restart");
  if (null == body)
    throw new ArgumentNullException("body");
  var unwindTag = new UnwindTag<T>();
  RestartBindCallback restartCallback = (param) =>
  {
    unwindTag.Value = restart(param);
    throw unwindTag;
  };
  try
  {
    return RestartBind(name, restartCallback, body);
  }
  catch (UnwindTag<T> e)
  {
    if (e == unwindTag)
    {
      return e.Value;
    }
    else
      throw;
  }
}


FindRestart и InvokeRestart

FindRestart и InvokeRestart, в свою очередь, очень похожи на метод Signal — первая функция находит перезапуск в соответствующем стеке текущего треда по имени, а вторая не только находит его, но и сразу запускает.
public static RestartBindCallback FindRestart(string name, bool throwOnError)
{
  if (null == name)
    throw new ArgumentNullException("name");
  Thread currentThread = Thread.CurrentThread;
  var clusters = _restartStacks.GetOrCreateValue(currentThread);
  var i = clusters.GetEnumerator();
  while (i.MoveNext())
  {
    var restartName = i.Current.Item1;
    var restart = i.Current.Item2;
    if (name == restartName)
      return restart;    
  }
  if (throwOnError)
    throw new RestartNotFoundException(name);
  else
    return null;
}

public static object InvokeRestart(string name, object param)
{
  var restart = FindRestart(name, true);
  return restart(param);
}


ComputeRestarts

ComputeRestarts просто возвращает список всех установленных в данный момент перезапусков — это может быть полезно, например, обработчику исключений, чтобы он, при вызове, мог выбрать подходящий перезапуск для какой-то конкретной ситуации.
public static IEnumerable<Tuple<string, RestartBindCallback>> ComputeRestarts()
{
  var restarts = new Dictionary<string, RestartBindCallback>();
  Thread currentThread = Thread.CurrentThread;
  var clusters = _restartStacks.GetOrCreateValue(currentThread);
  return clusters.AsEnumerable();
}


UnwindProtect


Наша реализация UnwindProtect просто оборачивает блок try-finally.
public static T UnwindProtect<T>(HandlerBody<T> body, params Action[] actions)
{
  if (null == body)
    throw new ArgumentNullException("body");
  if (null == actions)
    actions = new Action[0];
  try
  {
    return body();
  }
  finally
  {
    foreach (var a in actions)
      a();
  }
}


Напоследок — несколько примеров использования.

  1. Использование HandlerBind с функцией, сигнализирующей о возникшем исключении.
    static int DivSignal(int x, int y)
    {
      if (0 == y)
      {
        Conditions.Signal(new DivideByZeroException());
        return 0;
      }
      else
        return x / y;
    }
    

    int r = Conditions.HandlerBind(
              typeof(DivideByZeroException),
              (e) =>
              {
                Console.WriteLine("Entering handler callback");
              },
              () =>
              {
                Console.WriteLine("Entering HandlerBind with DivSignal");
                var rv = DivSignal(123, 0);
                Console.WriteLine("Returning {0} from body", rv);
                return rv;
              });
    Console.WriteLine("Return value: {0}\n", r);
    

    Здесь функция DivSignal, при делителе равном нулю, сигнализирует о возникшей ситуации, но тем не менее, сама «справляется» с ней(возвращает нуль). В данном случае ни обработчик, ни сама функция не прерывают нормальный ход программы.
    Вывод на консоль получается такой:
    Entering HandlerBind with DivSignal
    Entering handler callback
    Returning 0 from body
    Return value: 0
    

  2. Использование HandlerCase и UnwindProtect с функцией, сигнализирующей ошибку через Error.
    static int DivError(int x, int y)
    {
      if (0 == y)
        Conditions.Error(new DivideByZeroException());
      return x / y;
    }
    

    int r = Conditions.HandlerCase(
              typeof(DivideByZeroException),
              (e) =>
              {
                Console.WriteLine("Entering handler callback");
                Console.WriteLine("Returning 0 from handler");
                return 0;
              },
              () =>
              {
                Console.WriteLine("Entering HandlerCase with DivError and UnwindProtect");
                return Conditions.UnwindProtect(
                         () =>
                         {
                           Console.WriteLine("Entering UnwindProtect");
                           var rv = DivError(123, 0);
                           Console.WriteLine("This line should not be printed");
                           return rv;
                         },
                         () =>
                         {
                           Console.WriteLine("UnwindProtect exit point");
                         });
              });
    Console.WriteLine("Return value: {0}\n", r);
    

    В данном случае, функция DivError выбрасывает исключение, но обработчик перехватывает его, раскручивает стек, и возвращает свое значение(в данном случае — 0). По ходу раскрутки стека, поток вычисления проходит через UnwindProtect.
    Данный пример, в отличие от остальных, можно было бы переписать с помощью обычных try, catch и finally.
    Вывод на консоль:
    Entering HandlerCase with DivError and UnwindProtect
    Entering UnwindProtect
    Entering handler callback
    Returning 0 from handler
    UnwindProtect exit point
    Return value: 0
    

  3. Использование HandlerBind с функцией, в которой установлен перезапуск.
    static int DivRestart(int x, int y)
    {
      return Conditions.RestartCase(
              "ReturnValue",
              (param) =>
              {
                Console.WriteLine("Entering restart ReturnValue");
                Console.WriteLine("Returning {0} from restart", param);
                return (int)param;
              },
              () =>
              {
                Console.WriteLine("Entering RestartCase");
                return DivError(x, y);
              });
    }
    

    DivRestart устанавливает перезапуск с именем «ReturnValue», который, при активации, просто возвращает значение, переданное ему через параметр(param). Тело RestartCase вызывает DivError описанную в предыдущем примере.
    int r = Conditions.HandlerBind(
              typeof(DivideByZeroException),
              (e) =>
              {
                Console.WriteLine("Entering handler callback");
                Console.WriteLine("Invoking restart ReturnValue with param = 0");
                Conditions.InvokeRestart("ReturnValue", 0);
              },
              () =>
              {
                Console.WriteLine("Entering HandlerBind with DivRestart");
                return DivRestart(123, 0);
              });
    Console.WriteLine("Return value: {0}", r);
    

    Обработчик, установленный в HandlerBind, при вызове ищет перезапуск «ReturnValue» и передает ему в параметр число 0, после этого «ReturnValue» активируется, раскручивает стек до своего уровня, и возвращает это самое число из RestartCase, установленного в DivRestart, как видно выше.
    Вывод:
    Entering HandlerBind with DivRestart
    Entering RestartCase
    Entering handler callback
    Invoking restart ReturnValue with param = 0
    Entering restart ReturnValue
    Returning 0 from restart
    Return value: 0
    



Полный исходный код библиотеки и примеров доступен на github: github.com/Lovesan/ConditionSystem
Tags:
Hubs:
+5
Comments 8
Comments Comments 8

Articles