Как функторы работают в haskell?
Я пытаюсь научиться Haskell, и я прошел через все основы. Но теперь я застрял, пытаясь окунуться в мозги.
Я читал, что "Функтор преобразует одну категорию в другую категорию". Что это значит?
Я знаю это много, чтобы спросить, но может ли кто-нибудь дать мне простое английское объяснение функторов или, возможно, простой пример использования?
Ответы
Ответ 1
Нечеткое объяснение будет заключаться в том, что Functor
- это своего рода контейнер и связанная с ним функция fmap
, которая позволяет вам изменять все, что содержится, с помощью функции, которая преобразует содержащиеся.
Например, списки представляют собой такие контейнеры, что fmap (+1) [1,2,3,4]
дает [2,3,4,5]
.
Maybe
также можно сделать функтором, так что fmap toUpper (Just 'a')
дает Just 'A'
.
Общий тип fmap
показывает довольно аккуратно, что происходит:
fmap :: Functor f => (a -> b) -> f a -> f b
И специализированные версии могут стать более ясными. Здесь версия списка:
fmap :: (a -> b) -> [a] -> [b]
И версия Maybe:
fmap :: (a -> b) -> Maybe a -> Maybe b
Вы можете получить информацию о стандартных экземплярах Functor
, запросив GHCI с помощью :i Functor
, и многие модули определяют больше экземпляров Functor
(и других классов типов).
Пожалуйста, не принимайте слово "контейнер" слишком серьезно. Functor
- это четко определенная концепция, но вы можете часто рассуждать об этом с помощью этой нечеткой аналогии.
Лучше всего понять, что происходит, просто прочитать определение каждого из экземпляров, которое должно дать вам интуицию о том, что происходит. Оттуда это всего лишь небольшой шаг, чтобы действительно формализовать ваше понимание концепции. Что нужно добавить, это разъяснение того, что на самом деле представляет собой наш "контейнер", и что каждый экземпляр намного удовлетворяет паре простых законов.
Ответ 2
Я случайно написал
Учебное пособие Haskell Functors
Я отвечу на ваш вопрос, используя примеры, и я поставлю типы под комментариями.
Следите за шаблоном в типах.
fmap
является обобщением map
Функторы предназначены для предоставления функции fmap
. fmap
работает как map
, поэтому сначала проверьте map
:
map (subtract 1) [2,4,8,16] = [1,3,7,15]
-- Int->Int [Int] [Int]
Поэтому он использует функцию (subtract 1)
внутри списка. Фактически, для списков fmap
делает exaccty, что делает map
. Пусть на этот раз умножьте все на 10:
fmap (* 10) [2,4,8,16] = [20,40,80,160]
-- Int->Int [Int] [Int]
Я бы описал это как отображение функции, которая умножается на 10 по списку.
fmap
также работает на Maybe
Что еще я могу сделать fmap
? Пусть используется тип данных Maybe, который имеет два типа значений, Nothing
и Just x
. (Вы можете использовать Nothing
для представления отказа получить ответ, а Just x
представляет ответ.)
fmap (+7) (Just 10) = Just 17
fmap (+7) Nothing = Nothing
-- Int->Int Maybe Int Maybe Int
ОК, так что fmap
использует (+7)
внутри Maybe.
И мы можем также использовать другие функции. length
находит длину списка, поэтому мы можем fmap его над Maybe [Double]
fmap length Nothing = Nothing
fmap length (Just [5.0, 4.0, 3.0, 2.0, 1.573458]) = Just 5
-- [Double]->Int Maybe [Double] Maybe Int
На самом деле length :: [a] -> Int
, но я использую его здесь на [Double]
, поэтому я его специализировал.
Позвольте использовать show
, чтобы превратить материал в строки. В тайне фактический тип show
равен Show a => a -> String
, но это немного длиннее, и я использую его здесь на Int
, поэтому он специализируется на Int -> String
.
fmap show (Just 12) = Just "12"
fmap show Nothing = Nothing
-- Int->String Maybe Int Maybe String
также, оглядываясь на списки
fmap show [3,4,5] = ["3", "4", "5"]
-- Int->String [Int] [String]
fmap
работает на Either something
Позвольте использовать его в несколько иной структуре, Either
. Значения типа Either a b
являются значениями Left a
или Right b
. Иногда мы используем Either для представления успеха Right goodvalue
или fail Left errordetails
, а иногда просто для смешивания значений двух типов в один. Во всяком случае, функтор для любого типа данных работает только с Right
- он оставляет только значения Left
. Это имеет смысл, особенно если вы используете правильные значения как успешные (и на самом деле мы не сможем заставить его работать на обоих, потому что типы не обязательно одинаковы). Давайте используем тип Either String Int
в качестве примера
fmap (5*) (Left "hi") = Left "hi"
fmap (5*) (Right 4) = Right 20
-- Int->Int Either String Int Either String Int
Он делает работу (5*)
внутри Либо, но для Eithers меняются только значения Right
. Но мы можем сделать это наоборот на Either Int String
, пока функция работает на строках. Положим ", cool!"
в конец материала, используя (++ ", cool!")
.
fmap (++ ", cool!") (Left 4) = Left 4
fmap (++ ", cool!") (Right "fmap edits values") = Right "fmap edits values, cool!"
-- String->String Either Int String Either Int String
Особенно полезно использовать fmap
на IO
Теперь один из моих любимых способов использования fmap - использовать его для значений IO
, чтобы отредактировать значение, которое дает мне некоторая операция ввода-вывода. Давайте сделаем пример, который позволит вам ввести что-то, а затем распечатать его сразу:
echo1 :: IO ()
echo1 = do
putStrLn "Say something!"
whattheysaid <- getLine -- getLine :: IO String
putStrLn whattheysaid -- putStrLn :: String -> IO ()
Мы можем написать это таким образом, чтобы чувствовать себя более аккуратно:
echo2 :: IO ()
echo2 = putStrLn "Say something"
>> getLine >>= putStrLn
>>
делает одно за другим, но причина, по которой мне это нравится, заключается в том, что >>=
берет строку, которую getLine
дал нам и передал ее putStrLn
, которая берет строку.
Что, если мы хотим просто приветствовать пользователя:
greet1 :: IO ()
greet1 = do
putStrLn "What your name?"
name <- getLine
putStrLn ("Hello, " ++ name)
Если бы мы хотели написать это более аккуратным способом, я немного застрял. Мне нужно написать
greet2 :: IO ()
greet2 = putStrLn "What your name?"
>> getLine >>= (\name -> putStrLn ("Hello, " ++ name))
который не лучше, чем версия do
. На самом деле обозначение do
существует, поэтому вам не нужно это делать. Но может ли fmap
прийти на помощь? Да, оно может. ("Hello, "++)
- это функция, которую я могу fmap над getLine!
fmap ("Hello, " ++) getLine = -- read a line, return "Hello, " in front of it
-- String->String IO String IO String
мы можем использовать его следующим образом:
greet3 :: IO ()
greet3 = putStrLn "What your name?"
>> fmap ("Hello, "++) getLine >>= putStrLn
Мы можем вытащить этот трюк на все, что нам дано. Не соглашайтесь с тем, было ли введено "True" или "False":
fmap not readLn = -- read a line that has a Bool on it, change it
-- Bool->Bool IO Bool IO Bool
Или просто сообщите размер файла:
fmap length (readFile "test.txt") = -- read the file, return its length
-- String->Int IO String IO Int
-- [a]->Int IO [Char] IO Int (more precisely)
Выводы: что делает fmap
и что он делает?
Если вы просматривали шаблоны в типах и думали о примерах, вы заметили, что fmap принимает функцию, которая работает с некоторыми значениями, и применяет эту функцию к чему-то, что имеет или производит эти значения каким-то образом, редактируя ценности. (например, readLn должен был читать Bool, так что тип IO Bool
там содержал в нем логическое значение в том смысле, что он создает Bool
, eg2 [4,5,6]
имеет Int
в нем.)
fmap :: (a -> b) -> Something a -> Something b
это работает для того, что есть List-of (записано []
), Maybe
, Either String
, Either Int
, IO
и множество вещей. Мы называем это Functor, если это работает разумным образом (есть некоторые правила - позже). Фактический тип fmap
fmap :: Functor something => (a -> b) -> something a -> something b
но мы обычно заменяем something
на f
для краткости. Это все равно для компилятора:
fmap :: Functor f => (a -> b) -> f a -> f b
Оглянитесь на типы и проверьте, что это всегда работает - вещь о Either String Int
тщательно - что f
то время?
Приложение: Каковы правила Functor и почему мы их имеем?
id
- тождественная функция:
id :: a -> a
id x = x
Вот правила:
fmap id == id -- identity identity
fmap (f . g) == fmap f . fmap g -- composition
Во-первых, идентичность: если вы сопоставляете функцию, которая ничего не делает, это ничего не меняет. Это звучит очевидно (много правил), но вы можете интерпретировать это, говоря, что fmap
разрешено изменять значения, а не структуру. fmap
не разрешено превращать Just 4
в Nothing
или [6]
в [1,2,3,6]
, или Right 4
в Left 4
, потому что больше, чем просто данные изменились - структура или контекст для этих данных изменились.
Я ударил это правило один раз, когда работал над графическим проектом пользовательского интерфейса - я хотел иметь возможность редактировать значения, но я не мог этого сделать, не меняя структуру внизу. Никто не замечал бы разницу, потому что это имело тот же эффект, но осознание того, что оно не подчинилось правилам functor, заставило меня переосмыслить весь мой дизайн, и теперь он стал намного чище, скользнее и быстрее.
Во-вторых, состав: это означает, что вы можете выбрать, следует ли fmap одной функции за раз, или fmap их обоих одновременно. Если fmap
оставляет структуру/контекст ваших значений в одиночку и просто редактирует их с помощью функции, указанной в ней, она также будет работать с этим правилом.
Почему у нас есть они? Чтобы убедиться, что fmap
не скрывает ничего за кулисами или не изменяет ничего, чего мы не ожидали. Они не исполняются компилятором (просить компилятор доказать теорему, прежде чем компилировать ваш код нечестно, и замедлит компиляцию - программист должен проверить). Это означает, что вы можете обмануть, но это плохой план, потому что ваш код может дать неожиданные результаты.
Ответ 3
Важно держать раздельно в вашей голове различие между самим функтором и значением в типе, к которому применяется функтор. Сам функтор является конструктором типа Maybe
, IO
или конструктором списка []
. Значение в функторе - это определенное значение в типе с применяемым конструктором этого типа. например Just 3
- это одно конкретное значение в типе Maybe Int
(этот тип является функтором Maybe
, применяемым к типу Int
), putStrLn "Hello World"
- это одно конкретное значение в типе IO ()
, а [2, 4, 8, 16, 32]
- одно конкретное значение в типе [Int]
.
Мне нравится думать о значении в типе, где функтор применяется как "тот же", что и значение в базовом типе, но с некоторым дополнительным "контекстом". Люди часто используют аналог контейнера для функтора, который работает довольно естественно для многих функторов, но затем становится скорее помехой, чем помощь, когда вам нужно убедить себя, что IO
или (->) r
подобны контейнеру.
Итак, если Int
представляет целочисленное значение, то Maybe Int
представляет собой целочисленное значение, которое может отсутствовать ( "может не присутствовать" ) является "контекстом" ). [Int]
представляет целочисленное значение с рядом возможных значений (это та же интерпретация функтора списка, что и интерпретация "недетерминизма" монады списка). IO Int
представляет целочисленное значение, точное значение которого зависит от всего юниверса (или, наоборот, оно представляет целочисленное значение, которое может быть получено путем запуска внешнего процесса). A Char -> Int
является целочисленным значением для любого значения Char
( "функция, принимающая r
в качестве аргумента" ) является функтором для любого типа r
, а r
как Char
(->) Char
является конструктором типа который является функтором, который применяется к Int
, становится (->) Char Int
или Char -> Int
в инфиксной нотации).
Единственное, что вы можете сделать с общим функтором, это fmap
, с типом Functor f => (a -> b) -> (f a -> f b)
. fmap
преобразует функцию, которая работает с нормальными значениями, в функцию, которая работает с значениями с дополнительным контекстом, добавленным функтором; что именно это делает, различно для каждого функтора, но вы можете сделать это со всеми из них.
Таким образом, с Maybe
functor fmap (+1)
- это функция, которая вычисляет возможно-не настоящее целое число 1 выше, чем его входное возможно-не настоящее целое. С функтором списка fmap (+1)
- это функция, которая вычисляет недетерминированное целое число 1 выше его входного недетерминированного целого числа. С функтором IO
fmap (+1)
- это функция, которая вычисляет целое число 1 выше, чем его входное целое число, значение которого зависит от внешнего мира. С помощью функтора (->) Char
fmap (+1)
- это функция, которая добавляет 1 к целому числу, которое зависит от a Char
(когда я корню a Char
к возвращаемому значению, я получаю 1 выше, чем я получил бы подавая то же самое Char
в исходное значение).
Но вообще говоря, для некоторого неизвестного функтора f
, fmap (+1)
, примененного к некоторому значению в f Int
, есть "функциональная версия" функции (+1)
на обычном Int
s. Он добавляет 1 к целому числу в любом виде "контекста", который имеет этот конкретный функтор.
Само по себе fmap
не обязательно является полезным. Обычно, когда вы пишете конкретную программу и работаете с функтором, вы работаете с одним конкретным функтором, и вы часто думаете о fmap
как о том, что он делает для этого конкретного функтора. Когда я работаю с [Int]
, я часто не думаю о своих значениях [Int]
как недетерминированные целые числа, я просто думаю о них как о списках целых чисел, и я думаю о fmap
так же, как я думаю о map
.
Так зачем беспокоиться о функторах? Почему не только map
для списков, applyToMaybe
для Maybe
s, а applyToIO
для IO
s? Тогда все будут знать, что они делают, и никто не должен понимать странные абстрактные понятия, такие как функторы.
Ключом является признание того, что там много функторов; почти все типы контейнеров для запуска (отсюда и аналог контейнера для каких функторов). Каждая из них имеет операцию, соответствующую fmap
, даже если у нас нет функторов. Всякий раз, когда вы пишете алгоритм исключительно в терминах операции fmap
(или map
или что-то еще, что он вызвал для вашего конкретного типа), то, если вы пишете его с точки зрения функторов, а не вашего конкретного типа, то он работает для все.
Он также может служить формой документации. Если я передам одно из значений моего списка функции, которую вы написали, которая работает в списках, она может делать любое количество вещей. Но если я передам свой список функции, которую вы написали, которая работает с значениями в произвольном функторе, тогда я знаючто реализация вашей функции не может использовать функции списка, только функции функтора.
Мысль о том, как вы будете использовать functorish вещи в традиционном императивном программировании может помочь увидеть преимущества. Там типы контейнеров, такие как массивы, списки, деревья и т.д., Обычно имеют некоторый шаблон, который вы используете для перебора по ним. Он может немного отличаться для разных контейнеров, хотя библиотеки часто предоставляют стандартные итерационные интерфейсы для решения этой проблемы. Но вы все равно в конце концов пишете цикл for-loop каждый раз, когда хотите перебирать их, и когда то, что вы хотите сделать, вычисляет результат для каждого элемента в контейнере и собирает все результаты, которые, как правило, вы смешиваете в логике для создания нового контейнера по ходу.
fmap
каждый для цикла той формы, которую вы когда-либо пишете, отсортированной раз и навсегда библиотечными писателями, прежде чем вы сядете за программу. Кроме того, он также может использоваться с такими вещами, как Maybe
и (->) r
, которые, вероятно, не будут рассматриваться как имеющие какое-либо отношение к разработке согласованного интерфейса контейнера в императивных языках.
Ответ 4
В Haskell функторы фиксируют понятие наличия контейнеров "материала", так что вы можете манипулировать этим "материалом" без изменения формы контейнера.
Функторы предоставляют одну функцию fmap
, которая позволяет вам это делать, выполняя регулярную функцию и "поднимая" ее на функцию из контейнеров одного типа элементов в другой:
fmap :: Functor f => (a -> b) -> (f a -> f b)
Например, []
, конструктор типа списка, является функтором:
> fmap show [1, 2, 3]
["1","2","3"]
а также многие другие конструкторы типа Haskell, такие как Maybe
и Map Integer
1:
> fmap (+1) (Just 3)
Just 4
> fmap length (Data.Map.fromList [(1, "hi"), (2, "there")])
fromList [(1,2),(2,5)]
Обратите внимание, что fmap
не разрешено изменять "форму" контейнера, поэтому, если вы, например, вы fmap
список, результат имеет одинаковое количество элементов, а если вы fmap
a Just
он не может стать Nothing
. Формально мы требуем, чтобы fmap id = id
, т.е. Если вы fmap
функция идентификации, ничего не меняется.
До сих пор я использовал термин "контейнер", но это действительно немного более общий, чем это. Например, IO
также является функтором, а то, что мы подразумеваем под "формой" в этом случае, состоит в том, что fmap
в действии IO
не должно изменять побочные эффекты. На самом деле любая монада является функтором 2.
В теории категорий функторы позволяют конвертировать между различными категориями, но в Haskell у нас действительно есть только одна категория, которую часто называют Hask. Поэтому все функторы в Haskell преобразуются из Hask в Hask, поэтому они называются endofunctors (функторы от категории к себе).
В их простейшей форме функторы несколько скучны. Существует только так много всего, что вы можете сделать только с одной операцией. Однако, как только вы начнете добавлять операции, вы можете перейти от обычных функторов к аппликативным функторам к монадам, и все быстро становится намного интереснее, но это выходит за рамки этого ответа.
1 Но Set
нет, поскольку он может хранить только типы Ord
. Функторы должны иметь возможность содержать любой тип.
2 Из-за исторических причин Functor
не является суперклассом Monad
, хотя многие думают, что это должно быть.
Ответ 5
Посмотрите на типы.
Prelude> :i Functor
class Functor f where fmap :: (a -> b) -> f a -> f b
Но что это значит?
Во-первых, f
является переменной типа здесь, и он обозначает конструктор типа: f a
- тип; a
- это переменная типа, стоящая для некоторого типа.
Во-вторых, с учетом функции g :: a -> b
вы получите fmap g :: f a -> f b
. То есть fmap g
- это функция, преобразующая вещи типа f a
в вещи типа f b
. Заметьте, мы не можем получить здесь вещи типа a
и b
здесь. Функция g :: a -> b
каким-то образом работает над вещами типа f a
и преобразует их в вещи типа f b
.
Обратите внимание, что f
- то же самое. Изменяется только другой тип.
Что это значит? Это может означать много чего. f
обычно рассматривается как "контейнер" материала. Затем fmap g
позволяет g
действовать внутри этих контейнеров, не открывая их. Результаты все еще закрыты "внутри", typeclass Functor
не дает нам способностей открыть их или заглянуть внутрь. Просто какая-то трансформация внутри непрозрачных вещей - это все, что мы получаем. Любая другая функциональность должна появиться откуда-то еще.
Также обратите внимание, что он не говорит, что эти "контейнеры" несут только одну "вещь" типа a
; может быть много отдельных "вещей" "внутри", но все одинакового типа a
.
Наконец, любой кандидат на функтор должен подчиняться законам-функторам:
fmap id === id
fmap (f . g) === fmap f . fmap g