Больше удовольствия от аппликативных функторов

Ранее Я спросил о переводе монадического кода на использование только экземпляра прикладного функционала Parsec. К сожалению, у меня появилось несколько ответов, которые отвечали на вопрос, который я буквально задавал, но на самом деле не очень хорошо понимали. Поэтому позвольте мне попробовать это снова...

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

Класс Applicative определяется в Control.Applicative, чей список Haddock помогает с помощью методов класса и функций полезности разделять классные экземпляры классов между ними, чтобы затруднить быстрый просмотр всего экрана сразу. Но соответствующие сигнатуры типа

pure ::    x              -> f x
<*>  :: f (x -> y) -> f x -> f y
 *>  :: f  x       -> f y -> f y
<*   :: f  x       -> f y -> f x
<$>  ::   (x -> y) -> f x -> f y
<$   ::    x       -> f y -> f x

Отличный смысл, правильно?

Ну, Functor уже дает нам fmap, что в основном <$>. I.e., Учитывая функцию от x до y, мы можем сопоставить f x с a f y. Applicative добавляет два существенно новых элемента. Один из них pure, который имеет примерно тот же тип, что и return (и несколько других операторов в классах теории категорий). Другой - <*>, что дает нам возможность взять контейнер функций и контейнер входов и создать контейнер с выходами.

Используя вышеприведенные операторы, мы можем очень аккуратно сделать что-то вроде

foo <$> abc <*> def <*> ghi

Это позволяет нам взять N-арную функцию и передать ее аргументы из N функторов таким образом, который легко обобщается на любой N.


Это я уже понимаю. Есть две основные вещи, которые я еще не понимаю.

Во-первых, функции *>, <* и <$. Из их типов <* = const, *> = flip const и <$ может быть что-то подобное. Предположительно, это не описывает, что на самом деле выполняют эти функции. (??!)

Во-вторых, при написании парсера Parsec каждый анализируемый объект обычно выглядит примерно так:

entity = do
  var1 <- parser1
  var2 <- parser2
  var3 <- parser3
  ...
  return $ foo var1 var2 var3...

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

Ответ 1

Функции <* и *> очень просты: они работают так же, как >>. <* будет работать так же, как <<, кроме << не существует. В принципе, учитывая a *> b, вы сначала "делаете" a, затем "делаете" b и возвращаете результат b. Для a <* b вы по-прежнему сначала "делаете" a, затем "делаем" b, но вы возвращаете результат a. (Разумеется, для соответствующих значений "делать" ).

Функция <$ - это просто fmap const. Итак, a <$ b равно fmap (const a) b. Вы просто выбрасываете результат "действия" и вместо этого возвращаете постоянное значение. Функция Control.Monad void, имеющая тип Functor f => f a -> f (), может быть записана как () <$.

Эти три функции не являются фундаментальными для определения аппликативного функтора. (<$, фактически, работает для любого функтора.) Это опять-таки похоже на >> для монад. Я считаю, что они в классе, чтобы упростить их оптимизацию для конкретных экземпляров.

Когда вы используете аппликативные функторы, вы не "извлекаете" значение из функтора. В монаде это то, что делает >>=, и что foo <- ... desugars to. Вместо этого вы передаете завернутые значения в функцию напрямую с помощью <$> и <*>. Поэтому вы можете переписать свой пример как:

foo <$> parser1 <*> parser2 <*> parser3 ...

Если вы хотите использовать промежуточные переменные, вы можете просто использовать оператор let:

let var1 = parser1
    var2 = parser2
    var3 = parser3 in
foo <$> var1 <*> var2 <*> var3

Как вы правильно поняли, pure - это просто другое имя для return. Итак, чтобы сделать общую структуру более очевидной, мы можем переписать ее так:

pure foo <*> parser1 <*> parser2 <*> parser3

Надеюсь, это прояснит ситуацию.

Теперь немного заметьте. Люди рекомендуют использовать аппликативные функторные функции для синтаксического анализа. Однако вы должны использовать их только в том случае, если они имеют больше смысла! Для достаточно сложных вещей версия monad (особенно с do-notation) может быть на самом деле понятнее. Причина, по которой люди рекомендуют это, заключается в том, что

foo <$> parser1 <*> parser2 <*> parser3

является более коротким и читаемым, чем

do var1 <- parser1
   var2 <- parser2
   var3 <- parser3
   return $ foo var1 var2 var3

По существу, f <$> a <*> b <*> c по существу похож на приложение с отмененными функциями. Вы можете представить, что <*> является заменой пространства (например, функционального приложения) таким же образом, что fmap является заменой для приложения функции. Это также должно дать вам интуитивное представление о том, почему мы используем <$> - это как снятая версия $.

Ответ 2

Я могу сделать несколько замечаний здесь, надеюсь, полезно. Это отражает мое понимание, которое само по себе может быть неправильным.

pure необычно назван. Обычно функции называются ссылкой на то, что они производят, но в pure x это x, что является чистым. pure x дает аппликативный функтор, который "несет" чистый x. Конечно, "переносит", является приблизительным. Пример: pure 1 :: ZipList Int - это ZipList, несущий чистое значение Int, 1.

<*>, *> и <* являются не функциями, а методами (это отвечает на вашу первую проблему). f в своих типах не является общим (например, для функций), а конкретным, как указано конкретным экземпляром. Вот почему они действительно не просто $, flip const и const. Специализированный тип f указывает семантику комбинации. В обычном аппликативном стиле программирования сочетание означает приложение. Но с функторами присутствует дополнительное измерение, представленное типом "несущая" f. В f x имеется "содержимое", x, но есть также "контекст", f.

Стиль "аппликативные функторы" призван обеспечить программирование "аппликативного стиля" с эффектами. Эффекты представлены функторами, носителями, поставщиками контекста; "аппликативный", относящийся к нормальному аппликативному стилю функционального применения. Написание только f x для обозначения приложения было когда-то революционной идеей. Больше нет необходимости в дополнительном синтаксисе, никаких операторов (funcall f x), no CALL, ни одна из этих дополнительных вещей - комбинация не была приложением... Не так, с эффектами, казалось бы, снова возникла необходимость в специальном синтаксисе, при программировании с эффектами. Забитый зверь снова появился.

Итак, появилось Applicative Programming with Effects, чтобы снова сделать комбинацию средним просто приложением - в специальном (возможно, эффективном) контексте, если они действительно были в таком контексте. Таким образом, для a :: f (t -> r) и b :: f t (почти равная) комбинация a <*> b имеет приложение перенесенного содержимого (или типов t -> r и t), в заданном контекст (типа f).

Основное отличие от монадов: монады нелинейны. В

do x <- a
   y <- b
   z <- c
   return (x,y,z)

return имеет доступ ко всем переменным выше. Функции вложены:

a >>= (\x -> b >>= (\y -> c >>= (\z ->  .... )))

Это можно сделать плоским, сделав этапы вычислений возвращенными переупакованными, составными данными (это касается вашей второй проблемы):

a >>= (\x       -> b >>= (\y-> return (x,y)))
  >>= (\(x,y)   -> c >>= (\z-> return (x,y,z)))
  >>= (\(x,y,z) -> ..... )

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

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


в качестве иллюстрации, первый пример из этой статьи показывает, как "монадическая" функция

sequence :: [IO a] → IO [a]
sequence [ ] = return [ ]
sequence (c : cs) = do
  x ← c
  xs ← sequence cs
  return (x : xs)

может быть действительно закодирован в этом "плоском, линейном" стиле как

sequen :: (Applicative f) => [f a] -> f [a]
sequen [] = pure []
sequen (c : cs) = pure (:) <*> c <*> sequen cs

Здесь нет никакой пользы для способности монады вступать в предыдущие результаты.


заметка о отличном pair представляет собой комбинацию без приложения. Это показывает, что сущность того, что аппликативные функторы добавляют к простым функторам, - это способность сочетать. Затем приложение достигается старым добрым fmap. Это предполагает комбинаторные функции как возможно лучшее имя.

Ответ 3

Вы можете просмотреть функторы, аппликаторы и монады следующим образом: все они несут своего рода "эффект" и "ценность". (Обратите внимание, что термины "эффект" и "значение" являются только приближениями - на самом деле не обязательно должны быть какие-либо побочные эффекты или значения - например, в Identity или Const.)

  • С помощью Functor вы можете изменять возможные значения внутри, используя fmap, но вы ничего не можете сделать с эффектами внутри.
  • С помощью Applicative вы можете создать значение без какого-либо эффекта с помощью pure, и вы можете использовать эффекты последовательности и объединить свои значения внутри. Но эффекты и значения разделены: при эффектах секвенирования эффект не может зависеть от значения предыдущего. Это отражено в <*, <*> и *>: они влияют на последовательность и объединяют их значения, но вы не можете каким-либо образом изучить значения внутри.

    Вы можете определить Applicative с помощью этого альтернативного набора функций:

    fmap     :: (a -> b) -> (f a -> f b)
    pureUnit :: f ()
    pair     :: f a -> f b -> f (a, b)
    -- or even with a more suggestive type  (f a, f b) -> f (a, b)
    

    (где pureUnit не имеет никакого эффекта) и определите из них pure и <*> (и наоборот). Здесь pair последовательности двух эффектов и запоминает значения обоих из них. Это определение выражает тот факт, что Applicative является моноидальным функтором.

    Теперь рассмотрим произвольное (конечное) выражение, состоящее из pair, fmap, pureUnit и некоторых примитивных аппликативных значений. У нас есть несколько правил, которые мы можем использовать:

    fmap f . fmap g           ==>     fmap (f . g)
    pair (fmap f x) y         ==>     fmap (\(a,b) -> (f a, b)) (pair x y)
    pair x (fmap f y)         ==>     -- similar
    pair pureUnit y           ==>     fmap (\b -> ((), b)) y
    pair x pureUnit           ==>     -- similar
    pair (pair x y) z         ==>     pair x (pair y z)
    

    Используя эти правила, мы можем изменить порядок pair s, нажать fmap наружу и исключить pureUnit s, поэтому в конечном итоге такое выражение можно преобразовать в

    fmap pureFunction (x1 `pair` x2 `pair` ... `pair` xn)
    

    или

    fmap pureFunction pureUnit
    

    Итак, мы можем сначала собрать все эффекты вместе с помощью pair, а затем изменить полученное значение внутри, используя чистую функцию.

  • С Monad эффект может зависеть от значения предыдущего монадического значения. Это делает их настолько мощными.

Ответ 4

Ответы, которые уже даны, превосходны, но есть одна небольшая (иш) точка, которую я хотел бы описать явно, и она имеет отношение к <*, <$ и *>.

Одним из примеров был

do var1 <- parser1
   var2 <- parser2
   var3 <- parser3
   return $ foo var1 var2 var3

который также может быть записан как foo <$> parser1 <*> parser2 <*> parser3.

Предположим, что значение var2 не имеет значения для foo - например. это просто разделительные пробелы. Тогда также не имеет смысла иметь foo принять этот пробел только для его игнорирования. В этом случае foo должен иметь два параметра, а не три. Используя do -notation, вы можете записать это как:

do var1 <- parser1
   parser2
   var3 <- parser3
   return $ foo var1 var3

Если вы хотите написать это, используя только <$> и <*>, это должно быть что-то вроде одного из этих эквивалентных выражений:

(\x _ z -> foo x z) <$> parser1 <*> parser2 <*> parser3
(\x _ -> foo x) <$> parser1 <*> parser2 <*> parser3
(\x -> const (foo x)) <$> parser1 <*> parser2 <*> parser3
(const  . foo) <$> parser1 <*> parser2 <*> parser3

Но это сложно сделать с большим количеством аргументов!

Однако вы также можете написать foo <$> parser1 <* parser2 <*> parser3. Вы можете называть foo семантическую функцию, которая получает результат parser1 и parser3, игнорируя при этом результат parser2. Отсутствие > означает указание на игнорирование.

Если вы хотите проигнорировать результат parser1, но используйте два других результата, вы можете написать foo <$ parser1 <*> parser2 <*> parser3, используя <$ вместо <$>.

Я никогда не пользовался большим количеством использования для *>, я обычно писал бы id <$ p1 <*> p2 для синтаксического анализатора, который игнорирует результат p1 и просто анализирует с помощью p2; вы можете записать это как p1 *> p2, но это увеличивает когнитивную нагрузку для читателей кода.

Я понял этот способ мышления только для парсеров, но позже он был обобщен на Applicative s; но я думаю, что это нотация происходит из uuparsing library; по крайней мере, я использовал его в Утрехте 10 лет назад.

Ответ 5

Я хотел бы добавить/переписать пару вещей на очень полезные существующие ответы:

Применения являются "статическими". В pure f <*> a <*> b, b не зависит от a, и поэтому может быть проанализирован статически. Это то, что я пытался показать в моем ответе на ваш предыдущий вопрос (но, я думаю, я провалился - извините) - что, поскольку на самом деле не было последовательной зависимости парсерами, монады не нуждались.

Ключевое отличие, которое приносит монады к таблице, - (>>=) :: Monad m => m a -> (a -> m b) -> m a, или, альтернативно, join :: Monad m => m (m a). Обратите внимание, что всякий раз, когда у вас есть x <- y внутри do нотация, вы используете >>=. Они говорят, что монады позволяют использовать значение "внутри" монады для создания новой монады "динамически". Это не может быть сделано с помощью аппликатора. Примеры:

-- parse two in a row of the same character
char             >>= \c1 ->
char             >>= \c2 ->
guard (c1 == c2) >>
return c1

-- parse a digit followed by a number of chars equal to that digit
--   assuming: 1) `digit`s value is an Int,
--             2) there a `manyN` combinator
-- examples:  "3abcdef"  -> Just {rest: "def", chars: "abc"}
--            "14abcdef" -> Nothing
digit        >>= \d -> 
manyN d char 
-- note how the value from the first parser is pumped into 
--   creating the second parser

-- creating 'half' of a cartesian product
[1 .. 10] >>= \x ->
[1 .. x]  >>= \y ->
return (x, y)

Наконец, Атрибуты разрешают отмену функции, как указано @WillNess. Чтобы попытаться понять, как выглядят "промежуточные" результаты, вы можете посмотреть параллели между обычным и отмененным функциями приложения. Предполагая add2 = (+) :: Int -> Int -> Int:

-- normal function application
add2 :: Int -> Int -> Int
add2 3 :: Int -> Int
(add2 3) 4 :: Int

-- lifted function application
pure add2 :: [] (Int -> Int -> Int)
pure add2 <*> pure 3 :: [] (Int -> Int)
pure add2 <*> pure 3 <*> pure 4 :: [] Int

-- more useful example
[(+1), (*2)]
[(+1), (*2)] <*> [1 .. 5]
[(+1), (*2)] <*> [1 .. 5] <*> [3 .. 8]

К сожалению, вы не можете достоверно напечатать результат pure add2 <*> pure 3 по той же причине, что вы не можете для add2... разочарования. Вы также можете посмотреть Identity и его экземпляры typeclass, чтобы получить дескриптор прикладных программ.