Як і чому працює монада Haskell Cont?


77

Ось як визначається монада Cont:

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

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

Не могли б ви пояснити, як і чому це працює? Що це робить?


1
Ви знайомі з CPS? Якщо ні, то слід шукати навчальні посібники з цього приводу (сам я їх не знаю), оскільки це значно полегшило б Cont.
John L

Відповіді:


122

Перше, що слід усвідомити щодо продовження монади, це те, що, по суті, це насправді взагалі нічого не робить . Це правда!

Основна ідея продовження загалом полягає в тому, що воно представляє решту обчислень . Скажімо , у нас є такий вислів , як це: foo (bar x y) z. Тепер витягніть лише частину в дужках, bar x y--це частина загального виразу, але це не просто функція, яку ми можемо застосувати. Замість цього, це те , що нам потрібно застосувати функцію до . Отже, ми можемо говорити про "решту обчислень" у даному випадку як про те \a -> foo a z, що ми можемо застосувати bar x yдля реконструкції повної форми.

Зараз трапляється, що ця концепція "решти обчислень" корисна, але з нею незручно працювати, оскільки це щось поза підвиразом, який ми розглядаємо. Для того, щоб зробити речі краще працювати, ми можемо перетворити речі навиворіт: екстракт підвираз ми зацікавлені в тому , а потім оберніть його в функцію , яка приймає аргумент , який представляє інші обчислення: \k -> k (bar x y).

Ця модифікована версія надає нам велику гнучкість - вона не тільки витягує підвираз із свого контексту, але й дозволяє нам маніпулювати цим зовнішнім контекстом у самому підвиразі . Ми можемо сприймати це як своєрідне призупинене обчислення , що дає нам явний контроль над тим, що відбувається далі. Тепер, як ми могли б це узагальнити? Ну, підвираз майже незмінний, тож давайте просто замінимо його параметром на функцію \x k -> k xвивороту , надаючи нам - іншими словами, не що інше, як додаток функції, зворотне . Ми могли б так само легко написати flip ($)або додати трохи екзотичного смаку іноземної мови та визначити його як оператора |>.

Тепер перекласти кожен фрагмент виразу в цю форму було б просто, хоч і нудно і жахливо заплутано. На щастя, є кращий спосіб. Як програмісти Haskell, коли ми думаємо будувати обчислення у фоновому контексті, наступне, що, на наш погляд, це сказати, це монада? І в цьому випадку відповідь - так , так.

Щоб перетворити це на монаду, ми почнемо з двох основних будівельних блоків:

  • Для монади mзначення типу m aпредставляє наявність доступу до значення типу aв контексті монади.
  • Основою наших "призупинених обчислень" є перевернутий додаток функцій.

Що означає мати доступ до чогось типу aв цьому контексті? Це просто означає , що для деякого значення x :: a, ми застосували flip ($)до x, що дає нам функцію , яка приймає функцію , яка приймає аргумент типу a, і застосовує цю функцію x. Скажімо, у нас призупинено обчислення, що містить значення типу Bool. Який тип це нам дає?

> :t flip ($) True
flip ($) True :: (Bool -> b) -> b

Тож для призупинених обчислень тип m aпрацює на (a -> b) -> b... що, мабуть, є антиклімаксом, оскільки ми вже знали підпис Cont, але поки що мені сподобатися.

Цікаво відзначити, що своєрідний "розворот" також застосовується до типу монади: Cont b aпредставляє функцію, яка приймає функцію a -> bта обчислює b. Як продовження представляє "майбутнє" обчислення, так і тип aу підписі представляє в якомусь сенсі "минуле".

Отже, замінивши (a -> b) -> bна Cont b a, який монадичний тип для нашого основного будівельного блоку програми зворотних функцій? a -> (a -> b) -> bперекладається на a -> Cont b a... підпис того ж типу, що returnі, власне, це саме те, що воно є.

З цього моменту все майже випадає безпосередньо з типів: По суті, немає розумного способу реалізації, >>=крім фактичної реалізації. Але що це насправді робить ?

На цьому етапі ми повертаємось до того, що я сказав спочатку: продовження монади насправді нічого не робить . Щось типу Cont r aтривіально еквівалентно чомусь просто типу a, просто подаючи idяк аргумент призупиненого обчислення. Це може змусити запитати, якщо Cont r aмонада, але перетворення настільки тривіальна, чи не повинно aпоодинці також бути монадою? Звичайно, це не працює так, як є, оскільки немає конструктора типу, який можна визначити як Monadекземпляр, але, скажімо, ми додаємо тривіальну обгортку, наприклад data Id a = Id a. Це справді монада, а саме монада ідентичності.

Що робить >>=для ідентичності монади? Підпис типу є Id a -> (a -> Id b) -> Id b, що еквівалентно a -> (a -> b) -> b, що є просто простою функціональною програмою знову. Встановивши, що Cont r aтривіально еквівалентно Id a, ми можемо зробити висновок, що і в цьому випадку (>>=)це просто застосування функції .

Звичайно, Cont r aце божевільний перевернутий світ, де у кожного є козли, тож те, що насправді відбувається, включає перемішування речей навколо неясними способами, щоб об’єднати два призупинених обчислення в нове призупинене обчислення, але по суті, насправді не відбувається нічого незвичного на! Застосування функцій до аргументів, хо хом, ще один день у житті функціонального програміста.


5
Я щойно вирівнявся в Хаскелі. Яка відповідь.
clintm

6
"Щось типу Contra тривіально еквівалентно чомусь просто типу a, просто надаючи id як аргумент для призупиненого обчислення." Але ви не можете вказати ідентифікатор, якщо a = r, про який, на мою думку, слід хоча б згадати.
Омар Антолін-Камарена

Отже, в основному, bind - це просто перетворена функцією CPS функція?
saolof

1
Також зверніть увагу, що в розділах операторів у Haskell ви можете писати flip ($) aяк ($ a).
Рубен Стінекамп,

41

Ось Фібоначчі:

fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)

Уявіть, у вас є машина без стека викликів - вона дозволяє лише рекурсію хвоста. Як виконати fibна цій машині? Ви можете легко переписати функцію для роботи в лінійному, а не експоненціальному часі, але для цього потрібен крихітний огляд і не є механічним.

Перешкодою для того, щоб зробити його хвостиком рекурсивним, є третій рядок, де є два рекурсивні дзвінки. Ми можемо зробити лише один дзвінок, який також повинен дати результат. Ось де входять продовження.

Ми змусимо fib (n-1)взяти додатковий параметр, який буде функцією, що вказує, що слід робити після обчислення його результату, викликати його x. Звичайно, це буде додавати fib (n-2)до нього. Отже: для обчислення fib nви обчислюєте fib (n-1)після цього, якщо ви викликаєте результат x, ви обчислюєте fib (n-2), після цього, якщо ви викликаєте результат y, ви повертаєтесь x+y.

Іншими словами, ви повинні сказати:

Як зробити наступне обчислення: " fib' n c= обчислити fib nі застосувати cдо результату"?

Відповідь полягає в тому, що ви робите наступне: "обчислити fib (n-1)і застосувати dдо результату", де d xозначає "обчислити fib (n-2)і застосувати eдо результату", де e yозначає c (x+y). У коді:

fib' 0 c = c 0
fib' 1 c = c 1
fib' n c = fib' (n-1) d
           where d x = fib' (n-2) e
                 where e y = c (x+y)

Крім того, ми можемо використовувати лямбди:

fib' 0 = \c -> c 0
fib' 1 = \c -> c 1
fib' n = \c -> fib' (n-1) $ \x ->
               fib' (n-2) $ \y ->
               c (x+y)

Для того, щоб отримати фактичну ідентичність Фібоначчі використання: fib' n id. Можна подумати, що рядок fib (n-1) $ ...передає свій результат xнаступній.

Останні три рядки пахнуть doблоком, і насправді

fib' 0 = return 0
fib' 1 = return 1
fib' n = do x <- fib' (n-1)
            y <- fib' (n-2)
            return (x+y)

є однаковим, аж до нових типів, за визначенням монади Cont. Зверніть увагу на відмінності. Там \c ->на початку, замість x <- ...там ... $ \x ->і cзамість return.

Спробуйте писати factorial n = n * factorial (n-1)у рекурсивному стилі хвоста, використовуючи CPS.

Як >>=працює? m >>= kеквівалентно

do a <- m
   t <- k a
   return t

Здійснюючи переклад назад, у тому ж стилі, що і в fib', ви отримуєте

\c -> m $ \a ->
      k a $ \t ->
      c t

спрощуючи \t -> c tдоc

m >>= k = \c -> m $ \a -> k a c

Додаючи нові типи, які ви отримуєте

m >>= k  = Cont $ \c -> runCont m $ \a -> runCont (k a) c

який знаходиться вгорі цієї сторінки. Це складно, але якщо ви знаєте, як перекласти doпозначення та безпосереднє використання, вам не потрібно знати точне визначення >>=! Продовження монади стає набагато зрозумілішим, якщо поглянути на do-блоки.

Монади та продовження

Якщо ви подивитеся на використання списку монад ...

do x <- [10, 20]
   y <- [3,5]
   return (x+y)

[10,20] >>= \x ->
  [3,5] >>= \y ->
    return (x+y)

([10,20] >>=) $ \x ->
  ([3,5] >>=) $ \y ->
    return (x+y)

це виглядає як продовження! Насправді, (>>=)коли ви застосовуєте один аргумент, має тип, (a -> m b) -> m bякий є Cont (m b) a. Пояснення див. У Матері всіх монад sigfpe . Я вважав би це хорошим підручником для продовження монади, хоча, мабуть, він не мався на увазі як такий.

Оскільки продовження та монади настільки тісно пов’язані в обох напрямках, я думаю, що стосується монад стосується продовжень: тільки важка праця навчить вас їх, а не читання якоїсь метафори буріто чи аналогії.


1
Отже, машина не має стека викликів, але вона дозволяє довільно глибокі закриття? напр.where e y = c (x+y)
Томас Едінг

Так. Я знаю, що це трохи штучно.
sdcvvc

18

РЕДАКТУВАТИ: стаття перенесена за посиланням нижче.

Я написав навчальний посібник, який безпосередньо стосується цієї теми, і, сподіваюся, вам стане в нагоді. (Це, безумовно, допомогло закріпити моє розуміння!) Це занадто довгий час, щоб зручно вміститися в темі переповнення стека, тому я переніс його на Haskell Wiki.

Будь ласка, дивіться: MonadCont під капотом


9

Я думаю, що найпростіший спосіб Contзрозуміти монаду - зрозуміти, як використовувати її конструктор. Поки що я збираюся прийняти таке визначення, хоча реалії transformersпакету дещо відрізняються:

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

Це дає:

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

тому, щоб побудувати значення типу Cont r a, нам потрібно надати функцію Cont:

value = Cont $ \k -> ...

Тепер, kсам має тип a -> r, і тіло лямбди повинно мати тип r. Очевидно, що потрібно було б застосувати kзначення значення aі отримати значення типу r. Так ми можемо зробити, але це насправді лише одна з багатьох речей, які ми можемо зробити. Пам’ятайте, що він valueне повинен бути поліморфним r, він може бути типу Cont String Integerабо чогось іншого конкретного. Так:

  • Ми могли б застосувати kдо кількох значень типу aі якось поєднати результати.
  • Ми могли б застосувати kдо значення типу a, спостерігати за результатом, а потім на kоснові цього вирішити застосувати щось інше.
  • Ми могли б ігнорувати kвзагалі і просто створити значення типу rсамі.

Але що все це означає? Що в kкінцевому підсумку буває ? Ну, у do-block ми можемо мати щось подібне до цього:

flip runCont id $ do
  v <- thing1
  thing2 v
  x <- Cont $ \k -> ...
  thing3 x
  thing4

Ось найцікавіша частина: ми можемо, на наш погляд і дещо неформально, розділити do-block на дві частини при появі Contконструктора, а решту всього обчислення після нього розглядати як значення саме по собі. Але зачекайте, що це таке, залежить від того, що xє, тож це насправді функція від значення xтипу aдо деякого значення результату:

restOfTheComputation x = do
  thing3 x
  thing4

Насправді це грубо кажучи те, що в кінцевому підсумку restOfTheComputationбуває . Іншими словами, ви дзвоните зі значенням, яке стає результатом вашого обчислення, решта обчислень виконується, а потім вироблений входить назад у вашу лямбда-результат як результат дзвінка . Так:kkxContrk

  • якщо ви дзвонили kкілька разів, решта обчислень запускатиметься кілька разів, і результати можуть бути об'єднані, як завгодно.
  • якщо ви взагалі не дзвонили k, решта всього обчислення буде пропущена, а runContвиклик, що додає , просто поверне вам будь-яке значення типу, яке rвам вдалося синтезувати. Тобто, якщо якась інша частина обчислення не закликає вас зі свого k і не возиться з результатом ...

Якщо ви все ще зі мною на даний момент, видно, це може бути досить потужним. Щоб трохи сказати це, давайте реалізуємо кілька стандартних класів типів.

instance Functor (Cont r) where
  fmap f (Cont c) = Cont $ \k -> ...

Нам дається Contзначення з результатом прив'язки xтипу aта функції f :: a -> b, і ми хочемо зробити Contзначення з результатом прив'язки f xтипу b. Ну, щоб встановити результат прив'язки, просто зателефонуйте k...

  fmap f (Cont c) = Cont $ \k -> k (f ...

Стривай, звідки ми беремося x? Ну, це буде включати c, що ми ще не використовували. Згадайте, як це cпрацює: йому отримують функцію, а потім викликає цю функцію з результатом прив'язки. Ми хочемо викликати нашу функцію з, fзастосованою до цього результату прив'язки. Так:

  fmap f (Cont c) = Cont $ \k -> c (\x -> k (f x))

Тада! Далі Applicative:

instance Applicative (Cont r) where
  pure x = Cont $ \k -> ...

Це просто. Ми хочемо, щоб результат прив'язки був тим, який xми отримуємо.

  pure x = Cont $ \k -> k x

Зараз <*>:

  Cont cf <*> Cont cx = Cont $ \k -> ...

Це трохи хитріше, але використовує, по суті, ті самі ідеї, що і у fmap: спочатку отримайте функцію з першого Cont, зробивши лямбду для виклику:

  Cont cf <*> Cont cx = Cont $ \k -> cf (\fn -> ...

Потім отримайте значення xз другого і зробіть fn xрезультат прив’язки:

  Cont cf <*> Cont cx = Cont $ \k -> cf (\fn -> cx (\x -> k (fn x)))

Monadприблизно те саме, хоча вимагає runContабо футляр, або дозволити розпакувати новий тип.

Ця відповідь вже досить довга, тому я не буду вдаватися до неї ContT(коротше кажучи: вона точно така ж, як Cont! Різниця лише у типі конструктора типу, реалізації всього ідентичні) або callCC(корисний комбінатор, який забезпечує зручний спосіб ігнорування k, здійснюючи достроковий вихід із підблоку).

Для простого та правдоподібного додатка спробуйте допис у блозі Едварда З. Янга, який реалізує позначку break і продовжуйте цикли for .


1

Намагаючись доповнити інші відповіді:

Вкладені лямбди жахливі для читабельності. Це саме те, чому впустити ... і ... і ... де ... існувати, щоб позбутися вкладених лямбда, використовуючи проміжні змінні. Використовуючи їх, реалізацію прив'язки можна перетворити на:

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

instance Monad (Cont r) where
    return a = Cont ($ a)
    m >>= k  = k a
            where a = runCont m id

Що, сподіваємось, робить те, що відбувається ясніше. Поле повернення реалізації значення з лінивим застосуванням. Використання ідентифікатора runCont застосовує ідентифікатор до упакованого значення, яке повертає початкове значення.

Для будь-якої монади, де будь-яке вкладене значення може бути просто розпаковано, зазвичай існує тривіальна реалізація прив'язки, яка полягає в тому, щоб просто розпакувати значення та застосувати до нього монадичну функцію.

Щоб отримати незрозумілу реалізацію у вихідному питанні, спочатку замініть ka на Cont $ runCont (ka), який, у свою чергу, можна замінити на Cont $ \ c-> runCont (ka) c

Тепер ми можемо перемістити де в субвираз, так що з нами залишиться

Cont $ \c-> ( runCont (k a) c where a = runCont m id )

Вираз у дужках може бути розмитим на \ a -> runCont (ka) c $ runCont m id.

На завершення ми використовуємо властивість runCont, f (runCont mg) = runCont m (fg), і ми повертаємось до початкового затуманеного виразу.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.