call / cc реализация?

Пытаюсь узнать, как реализован call / cc. Лучшее, что я нашел, - это фрагмент Haskell:

callCC f = Cont $ \k -> runCont (f (\a -> Cont $ \_ -> k a)) k

Хотя это не так просто, как хотелось бы из-за Cont и runCont. Я также нашел описания того, что он делает, хотя никогда не было так ясно, как реальный код.

Так как же это реализовать в простейшей форме? Я помечу это как Scheme и Haskell, так как это два языка, которые я предпочитаю.


person Pubby    schedule 29.01.2012    source источник
comment
Для стороны схемы см. Множество отличных ответов на этот связанный вопрос: stackoverflow .com / questions / 6512 / как-то реализовать-продолжения.   -  person Ryan Culpepper    schedule 29.01.2012
comment
В буквальном смысле вы уже знаете, как это реализовано; строка кода, которую вы процитировали, является реализацией. Вам нужно понимать, что вы видите. ehird уже дал отличный ответ, но нужно просто вырезать Cont и runCont; они существуют просто из-за обертки newtype вокруг типа Cont. Итак, рассмотрим callCC f k = f (\ a _ - ›k a) k   -  person Paul Johnson    schedule 29.01.2012


Ответы (5)


«Реализация call/cc» на самом деле не имеет смысла на уровне, на котором вы работаете; если вы можете реализовать call/cc на языке, это просто означает, что он имеет встроенную конструкцию, по крайней мере, столь же мощную, как call/cc. На уровне самого языка call/cc в основном является примитивным оператором потока управления, как и должно быть в некоторой форме ветвления.

Конечно, вы можете реализовать язык с call/cc на языке без него; это потому, что он находится на более низком уровне. Вы переводите конструкции языка определенным образом и оформляете этот перевод так, чтобы можно было реализовать call/cc; то есть, как правило, стиль передачи продолжения (хотя для непереносимой реализации на C вы также можете просто скопировать стек напрямую; позже я более подробно расскажу о стиле передачи продолжения). На самом деле это не дает сколько-нибудь глубокого понимания call/cc самого - понимание заключается в модели, с помощью которой вы делаете это возможным. Вдобавок call/cc - это просто оболочка.

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

Технически это определение callCC действительно вводится, если вы просто удалите приложения Cont и runCont. Но это не поможет вам понять, как это работает в контексте монады Cont, поэтому давайте вместо этого посмотрим на ее определение. (Это определение не используется в текущей библиотеке преобразователей монад, потому что все монады в ней построены на основе своих версий преобразователя, но оно соответствует использованию фрагмента Cont (который работает только со старой версией), и значительно упрощает ситуацию.)

newtype Cont r a = Cont { runCont :: (a -> r) -> r }

Итак, Cont r a это просто (a -> r) -> r, а runCont позволяет нам получить эту функцию из значения Cont r a. Достаточно просто. Но что это значит?

Cont r a - это вычисление с продолжением передачи с окончательным результатом r и результатом a. Что значит конечный результат? Что ж, давайте более подробно напишем тип runCont:

runCont :: Cont r a -> (a -> r) -> r

Итак, как мы видим, «окончательный результат» - это значение, которое мы получаем из runCont в конце. Теперь, как мы можем проводить вычисления с Cont? Экземпляр монады поучительно:

instance Monad (Cont r) where
  return a = Cont (\k -> k a)
  m >>= f = Cont (\k -> runCont m (\result -> runCont (f result) k))

Хорошо, хорошо, это поучительно, если вы уже знаете, что это значит. Ключевым моментом является то, что когда вы пишете Cont (\k -> ...), k - это остальная часть вычисления - он ожидает, что вы дадите ему значение a, а затем выдаст вам окончательный результат вычисления (типа r , помните) обратно, которое затем можно использовать как собственное возвращаемое значение, потому что ваш тип возврата тоже r. Ух! И когда мы запускаем Cont вычисление с runCont, мы просто указываем последний k - «верхний уровень» вычисления, дающего окончательный результат.

Как называется эта «остальная часть вычислений»? продолжение, потому что это продолжение вычислений!

(>>=) на самом деле довольно прост: мы запускаем вычисление слева, давая ему собственное остальное вычисление. Остальные вычисления просто передают значение в f, который производит собственное вычисление. Мы запускаем это вычисление, передавая его остальным вычислениям, которые были даны нашему комбинированному действию. Таким образом, мы можем объединить вычисления в Cont:

computeFirst >>= \a ->
computeSecond >>= \b ->
return (a + b)

или, в более знакомой нотации do:

do a <- computeFirst
   b <- computeSecond
   return (a + b)

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

Все идет нормально. Теперь давайте запутаем ситуацию.

wtf :: Cont String Int
wtf = Cont (\k -> "eek!")

aargh :: Cont String Int
aargh = do
  a <- return 1
  b <- wtf
  c <- return 2
  return (a + b + c)

Что тут происходит?! wtf - это Cont вычисление с окончательным результатом String и результатом Int, но не видно Int.

Что происходит, когда мы запускаем aargh, скажем, с runCont aargh show, т. Е. Запускаем вычисление и show его Int результат как String для получения окончательного результата?

Получаем "eek!" обратно.

Помните, как k является «остальной частью вычислений»? То, что мы сделали в wtf, хитроумно не назовем это, а вместо этого предоставили наш собственный окончательный результат, который затем становится, ну, ну, окончательным!

Это первое, что могут сделать продолжения. Что-то вроде Cont (\k -> k 1 + k 2) выполняет оставшуюся часть вычисления, как если бы оно возвращало 1, и снова, как если бы оно возвращало 2, и складывает два окончательных результата вместе! Продолжения в основном позволяют выразить произвольно сложный нелокальный поток управления, делая их столь же мощными, сколь и сбивающими с толку. Действительно, продолжения настолько общие, что в некотором смысле каждая монада частный случай Cont. В самом деле, вы можете думать о (>>=) в целом как об использовании своего рода стиля передачи продолжения:

(>>=) :: (Monad m) => m a -> (a -> m b) -> m b

Второй аргумент - это продолжение, принимающее результат первого вычисления и возвращающее остальную часть вычисления для выполнения.

Но я так и не ответил на вопрос: что с этим callCC? Ну, он вызывает функцию, которую вы передаете с текущим продолжением. Но постойте, разве это не то, что мы уже делали с Cont? Да, но сравните типы:

Cont   :: ((a -> r)        -> r)        -> Cont r a
callCC :: ((a -> Cont r b) -> Cont r a) -> Cont r a

Хм. Видите ли, проблема с Cont в том, что мы не можем упорядочить действия из внутри функции, которую мы передаем - мы просто получаем результат r в чистом виде. callCC позволяет получить доступ к продолжению, передать его и просто повозиться с вычислениями из внутри Cont. Когда у нас есть

do a <- callCC (\cc -> ...)
   foo ...

Вы можете представить cc функцию, которую мы можем вызвать с любым значением внутри функции, чтобы сделать это возвращаемое значение callCC (\cc -> ...) вычисления самого. Или, конечно, мы могли бы просто вернуть значение обычным образом, но тогда вызов callCC в первую очередь был бы немного бессмысленным :)

Что касается таинственного b, то это просто потому, что вы можете использовать cc foo для замены любого типа, который вы хотите, поскольку он ускользает от обычного потока управления и, как Я сказал, сразу пользуется этим как результат всего callCC (\cc -> ...). Таким образом, поскольку ему никогда не нужно фактически создавать значение, он может уйти, вернув значение любого типа, который он хочет. Подлый!

Что подводит нас к фактической реализации:

callCC f = Cont (\k -> runCont (f (\a -> Cont (\_ -> k a))) k)

Сначала мы получаем всю остальную часть вычисления и называем ее k. Но о чем эта f (\a -> Cont (\_ -> k a)) часть? Что ж, мы знаем, что f принимает значение типа (a -> Cont r b), и это то, что представляет собой лямбда - функция, которая принимает значение для использования в качестве результата callCC f и возвращает вычисление Cont, которое игнорирует его продолжение и просто возвращает это значение через k - «остальная часть вычислений» с точки зрения callCC f. Итак, результатом этого f вызова является еще одно вычисление Cont, которое нам нужно будет предоставить для продолжения для запуска. Мы просто передаем то же продолжение еще раз, поскольку, если все идет нормально, мы хотим, чтобы все, что возвращает вычисление, было нашим возвращаемым значением, и продолжаем нормально. (Действительно, передача другого значения не имеет смысла - это "вызов с текущим продолжением", а не "вызов с продолжением, отличным от того, которое вы на самом деле запускаете. с участием".)

В общем, я надеюсь, что вы нашли это столь же поучительным, сколь и длинным. Продолжения очень эффективны, но может потребоваться много времени, чтобы получить интуитивное представление о том, как они работают. Я предлагаю поиграть с Cont (который вам нужно будет вызвать cont, чтобы все работало с текущим mtl) и выяснить, как вы получите результаты, которые вы делаете, чтобы почувствовать поток управления.

Рекомендуемая дополнительная литература по продолжениям:

person ehird    schedule 29.01.2012

call/cc реализовать несложно. Самая сложная часть - это настройка среды, в которой возможно реализовать.

Сначала мы должны определить среду выполнения стиля передачи продолжения (CPS). В этой среде ваши функции (или подобные функции) не возвращают значения напрямую; вместо этого им передается функция, которая представляет «следующий шаг» в вычислении - «продолжение» - и они передают туда свой результат. В Haskell это выглядит так:

newtype Cont r a = Cont { runCont :: (a -> r) -> r }

Как видите, действие монады Cont на самом деле является функцией, которая принимает продолжение (a -> r), передает результат a продолжению и возвращает окончательный результат r, который он просто передает вызывающей стороне (т. Е. Cont монаде действие должно быть хвостовым вызовом в продолжении). runCont просто извлекает его из оболочки newtype - вы также можете думать об этом как о вызове действия с определенным продолжением, как в runCont someAction someContinuation.

Затем мы можем превратить это в монаду:

instance Monad (Cont r) where
    return x = Cont $ \cc -> cc x
    (Cont f) (>>=) g = Cont $ \cc -> f (\r -> runCont (g r) cc)

Как видите, return просто получает продолжение cc и передает его значение продолжению. (>>=) немного сложнее, он должен вызывать f с продолжением, которое затем вызывает g, возвращает действие, а затем передает внешнее продолжение этому новому действию.

Таким образом, с учетом этой структуры, добраться до продолжения очень просто. Мы просто хотим вызвать функцию с ее продолжением дважды. Сложная часть состоит в том, что нам нужно переформатировать это продолжение в новое монадическое действие, которое отбрасывает существующее продолжение. Итак, давайте немного разберемся:

-- Invoke a raw continuation with a given argument, throwing away our normal 
-- continuation
gotoContinuation :: (a -> r) -> a -> Cont r x
gotoContinuation continuation argument = Cont $ \_ -> continuation argument

-- Duplicate the current continuation; wrap one up in an easy-to-use action, and
-- the other stays the normal continuation for f
callCC f = Cont $ \cc -> runCont (f (gotoContinuation cc)) cc

Все просто, не правда ли?

В других языках, таких как Scheme, принцип тот же, хотя он может быть реализован как примитив компилятора; причина, по которой мы можем сделать это в Haskell, состоит в том, что передача продолжения была тем, что мы определили в Haskell, а не на более низком уровне среды выполнения. Но принцип тот же - сначала вам нужен CPS, а затем call/cc - тривиальное приложение этой модели выполнения.

person bdonlan    schedule 29.01.2012

Вы слышали о Haskell-стороне уравнения; Я дам вам Racket / Scheme, и какой из них наиболее полезен для вас, вы можете использовать его.

Мой ответ будет намного короче, потому что я думаю, что лучший источник, который я могу дать вам для реализации call / cc в простом оценщике рэкетов, исходит от PLAI, в частности раздел 20. Я подумал о включении соответствующей части интерпретатора - она ​​на странице 205 - но после нескольких попыток переформатировать ее, я решил, что она сделает больше смысл в нужном месте на странице.

Опять же, я не пытаюсь объяснить здесь идею call / cc, просто укажу вам на работающую реализацию. Дайте мне знать, если у вас возникнут другие вопросы.

person John Clements    schedule 29.01.2012

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

Чтобы понять, почему ваша попытка реализовать call/cc должна потерпеть неудачу, вы должны понимать, какой стиль передачи продолжения является. Как только вы это поймете, это будет довольно просто:

  • call/cc не может быть реализован в прямом стиле.
  • Однако это тривиально реализовать в стиле передачи продолжения.

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

Любая программа может быть механически преобразована в стиль передачи продолжения с помощью так называемого, вполне уместно, преобразования CPS. Многие системы Scheme фактически работают так: программа анализируется, к ней применяется преобразование CPS, а затем абстрактное синтаксическое дерево CPS либо интерпретируется, либо транслируется в объектный код.

Вот как бы вы реализовали call/cc в стиле передачи продолжения (используя continuation в качестве имени дополнительного аргумента для продолжения):

(define (call/cc-cps proc continuation)
  (proc continuation continuation))

Как вы должны видеть, (а) вы не можете реализовать это в прямом стиле (противоположном CPS), и (б) это тривиально в CPS. call/cc - это просто процедура, которая принимает другую процедуру в качестве аргумента и (обязательного) продолжения и вызывает эту процедуру с продолжением как в качестве аргумента, так и в качестве продолжения.

person Luis Casillas    schedule 30.01.2012

Рискуя оказаться вне языка, я думаю, что в Smalltalk продолжение можно реализовать и понять проще всего. Причина в том, что в Smalltalk стек выполнения формируется из обычных объектов, к которым можно получить доступ и которыми можно манипулировать, как с любым другим объектом.

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

Continuation>>initializeFromContext: aContext
    context := aContext.
    stream := WriteStream on: (Array new: 200).
    [ context notNil ] whileTrue: [
        stream nextPut: context.
        1 to: context class instSize do: [ :index |
            stream nextPut: (context instVarAt: index) ].
        1 to: context size do: [ :index |
            stream nextPut: (context at: index) ].
        context := context sender ].
    values := stream contents

Второй метод - возобновить выполнение: сначала мы разворачиваем текущий стек (опять же, это простой цикл по стеку выполнения), затем мы восстанавливаем захваченные кадры стека, повторно присоединяем их к текущему кадру стека thisContext и возобновляем выполнение с аргумент anObject:

Continuation>>value: anObject
    self terminate: thisContext.
    stream := values readStream.
    [ stream atEnd ] whileFalse: [
        context := stream next.
        1 to: context class instSize do: [ :index |
            context instVarAt: index put: stream next ].
        1 to: context size do: [ :index |
            context at: index put: stream next ] ]
    thisContext swapSender: values first.
    ^ anObject

С помощью этих двух методов мы можем легко построить callCC:

Continuation class>>callCC: aBlock
    ^ aBlock value: (self new initializeFromContext: thisContext sender)

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

Приведенный выше код взят из веб-инфраструктуры Seaside. Чтобы поиграть с кодом, вы можете использовать готовый дистрибутив и перейти к классам WAContinuation и WAContinuationTest.

person Lukas Renggli    schedule 29.01.2012