Haskell中的functor如何工作?

53

我正在尝试学习Haskell,已经掌握了所有基础知识。但是现在我陷入了困境,试图理解functor。

我读到过“functor将一个范畴转换成另一个范畴”。这是什么意思?

我知道这很难,但有人能否给我提供一个通俗易懂的functor解释或者一个简单的用例呢?


3
我发现 Gabriel 写的博客文章「函子设计模式」非常好。虽然不是完全使用通俗易懂的语言,但你应该读一下它,看看是否有所帮助。 - Dan Burton
Anton Guryanov的链接:https://en.wikibooks.org/wiki/Haskell/Category_theory - David Eisenstat
5个回答

128

我不小心写错了

Haskell 函子教程

我将使用示例回答您的问题,并在注释中放置类型。

注意类型中的模式。

fmapmap 的一般化

函子用于提供给您fmap函数。fmap的工作方式类似于map,因此让我们首先检查map

map (subtract 1) [2,4,8,16] = [1,3,7,15]
--    Int->Int     [Int]         [Int]

所以它在列表内使用函数(subtract 1)。事实上,对于列表,fmap恰好执行与map相同的操作。这一次我们把每个元素都乘以10:

fmap (* 10)  [2,4,8,16] = [20,40,80,160]
--  Int->Int    [Int]         [Int]

我将描述这个过程为将乘以10的函数映射到列表上。

fmap也适用于Maybe

我还可以对什么进行fmap操作呢?让我们使用Maybe数据类型,它有两种类型的值,NothingJust x。(你可以使用Nothing表示无法获取答案,而Just x表示一个答案。)

fmap  (+7)    (Just 10)  = Just 17
fmap  (+7)     Nothing   = Nothing
--  Int->Int  Maybe Int    Maybe Int

好的,那么再次重申一下,fmap 在 Maybe 内部使用 (+7)。我们也可以对其他函数使用 fmap 。length 用于计算列表的长度,因此我们可以将其映射到 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 或失败的 Left errordetails,有时只是将两种类型的值混合在一起。无论如何,Either 数据类型的函数式子仅适用于 Right - 它不会对 Left 值进行操作。这特别有意义,特别是如果您使用 Right 值作为成功的值(事实上,如果我们尝试使其同时适用于两者,我们可能就 无法 实现,因为它们的类型不一定相同)。让我们以类型 Either String Int 为例。

fmap (5*)      (Left "hi")     =    Left "hi"
fmap (5*)      (Right 4)       =    Right 20
-- Int->Int  Either String Int   Either String Int

它使(5*)在Either内起作用,但对于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

在IO上使用fmap特别酷

现在我最喜欢使用fmap的方法之一是在IO值上使用它,以编辑某个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's your name?"
    name <- getLine
    putStrLn ("Hello, " ++ name)

如果我们想要以更加整洁的方式书写,我有些困难。我必须写

greet2 :: IO ()
greet2 = putStrLn "What's your name?" 
         >> getLine >>= (\name -> putStrLn ("Hello, " ++ name))

这个版本比do的版本好。实际上,do符号存在的目的就是为了让你不必这样做。但是fmap可以帮忙吗?可以的。("Hello, "++)是一个函数,我可以用它来处理getLine

fmap ("Hello, " ++)  getLine   = -- read a line, return "Hello, " in front of it
--   String->String  IO String    IO String

我们可以像这样使用它:

greet3 :: IO ()
greet3 = putStrLn "What's your name?" 
         >> fmap ("Hello, "++) getLine >>= putStrLn
我们可以对我们得到的任何东西使用这个技巧。让我们就输入的是“True”还是“False”不达成一致:

我们可以对我们得到的任何东西使用这个技巧。让我们就输入的是“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,例如2:[4,5,6]中有Int。)

fmap :: (a -> b) -> Something a -> Something b

这适用于SomethingList-of(写作 []),Maybe, Either String, Either Int, IO和许多其他情况。 如果以合理的方式使用,则我们将其称为Functor(稍后会有一些规则)。

fmap的实际类型是:

fmap :: Functor something => (a -> b) -> something a -> something b

但是通常为了简洁起见,我们会用f代替something。不过对编译器来说,这都是一样的:

fmap :: Functor f => (a -> b) -> f a -> f b

回顾一下类型并检查这始终有效-仔细思考 Either String Int -那个时候的f是什么?

附录:什么是函子规则,为什么我们需要它们?

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,因为不仅数据发生了变化-数据的结构或上下文也发生了变化。

我曾经在一个图形用户界面项目中遇到过这个规则-我想能够编辑值,但我无法在不改变结构的情况下实现。虽然没有人会真正注意到这种差异,因为它具有相同的效果,但意识到它不遵守函子规则让我重新思考整个设计,现在它更加清晰、流畅和快速。

其次是组合性:这意味着您可以选择一次fmap一个函数,或同时fmap两个函数。如果fmap保持您的值的结构/上下文不变,只是用给定的函数进行编辑,则它也符合此规则。

数学家有一个秘密的第三条规则,但我们在Haskell中不将其称为规则,因为它看起来只是一个类型声明:

fmap :: (a -> b) -> something a -> something b

这个定律可以防止你仅将函数应用于列表中的第一个值。这个规则由编译器执行。

为什么我们要有它们?为了确保 fmap 不会在幕后偷偷改变任何我们没有预料到的东西。这些规则不是由编译器强制执行的(要求编译器在编译代码之前证明一个定理是不公平的,而且会减慢编译速度 - 程序员应该检查)。这意味着你可以在这些规则上作弊,但这是个坏主意,因为你的代码可能会产生意想不到的结果。

Functor 的规则是为了确保 fmap 公平、平等、无差别地应用你的函数,而且没有其他变化。这是好的、清晰的、可靠的、可重复使用的东西。


2
非常好结构化的解释,而且没有使用大量其他术语,这些术语对于不理解函数子的人来说是不会知道的(我想说“参数化”是主要罪魁祸首)。 - HenryRootTwo
2
这是我迄今为止看过的最好的解释。您是否有意外地编写了适用和单子的教程?我愿意支付任何费用来查看它们 =D - Ivan Wang
1
@AndrewC,鞠躬致谢!感谢您为我们提供如此清晰明了的解释所付出的努力! - Swapnil B.
1
我赞同, 请为applicative和Monad各写一个。 - CarbonMan
1
@AndrewC 解释得非常好。喜欢这些例子。现在我明白了 :) - dewijones92
显示剩余4条评论

63

一种模糊的解释是,Functor 是某种容器以及一个相关的函数 fmap,允许您通过提供转换容器元素的功能来更改容器中包含的任何内容。

例如,列表就是这种类型的容器,因此 fmap (+1) [1,2,3,4] 会产生 [2,3,4,5]

Maybe 也可以成为一个 Functor,使得 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

您可以通过使用:i Functor查询GHCI来了解标准Functor实例的信息,许多模块定义了更多的Functor实例(以及其他类型类)。

请不要过于认真对待“容器”这个词。 Functor是一个明确定义的概念,但您经常可以用这种模糊的比喻来推理它。

了解正在发生什么的最佳方法就是阅读每个实例的定义,这应该可以给您一些直觉。从那里开始,只需迈出一小步即可真正形式化您对概念的理解。需要添加的是关于我们的“容器”实际上是什么以及每个实例都必须满足一对简单法律的澄清。


4
根据我的经验,我可以说,对于函子来说,将其比作容器会使它们更难理解(特别是涉及到IO的情况)。因此,在开始时,人们可以将其视为附加到值的某种“计算”,而不是包含值的“容器”。 - Anton Guryanov
1
IO 本身不是纯 Haskell。我发现这个解释更清晰:考虑 putStrLn :: String -> IO()。这个函数接受一个 String 并返回一个 IO(),可以将其视为一种计算,即在此情况下输出到 stdout。这里的容器解释不如计算解释清晰。但这只是我的观点。 - Anton Guryanov
2
@WillNess @Anton 是正确的。容器类比只能用于简单性说明。然而,它并不是一种严格定义。例如,考虑 Const a 函子 :) - is7s
16
在描述一个比喻时,如果它被描述为模糊的,那么没有必要纠缠于细节。所有的比喻都有缺陷,而萨拉正确地引导了OP去更深入、更基于法律的理解。容器是一个好的开始,制造商是下一步。计算背景是通用的,但对于初学者来说可能太抽象了。(以我作为教师的观点)。OP无论如何都要求使用简单易懂的英语,所以我们不要太理论化。 - AndrewC
1
@CMCDragonkai 这是否意味着我可以将[]视为从Hask -> Hask的函子箭头? 是的。这是否意味着作为函子,[]将对象如a映射到[a]之类的对象? 是的。但是[]函子映射态射是什么意思? 在Hask中,一个函子有两个部分,一种是从类型到类型的映射,另一种是从函数到函数的映射。[]map合作,对于类型,它给出了[] a = [a],对于函数,它给出了map f为什么[]不在Hask范畴内被简单地称为态射? 因为在Hask中,态射是作用于值的函数,而不是作用于类型和函数的函子。 - AndrewC
显示剩余2条评论

15
在头脑中清楚区分functor本身和应用了functor的类型中的值之间的区别非常重要。Functor本身是一种类型构造器,比如 MaybeIO 或者列表构造器 []。Functor中的值是应用了该类型构造器的某个特定类型中的某个特定值。例如,Just 3 是类型 Maybe Int 中某个特定值(该类型是将类型构造器 Maybe 应用于类型 Int),putStrLn "Hello World" 是类型 IO () 中的某个特定值,[2, 4, 8, 16, 32] 是类型 [Int] 中的某个特定值。
我喜欢将应用了functor的类型中的值看作是基本类型中的值,但带有一些额外的“上下文”。人们经常使用容器类比来解释functor,对于许多functor来说,这个类比相当自然,但当你不得不让自己相信 IO 或者 (->) r 就像一个容器时,它就变得更加阻碍理解了。因此,如果一个Int表示一个整数值,那么一个Maybe Int表示可能不存在的整数值("可能不存在"是上下文)。[Int]表示具有许多可能值的整数值(这与列表函子的“非确定性”解释相同)。IO Int表示整数值,其精确值取决于整个宇宙(或者,它表示可以通过运行外部进程获得的整数值)。对于任何Char值,Char -> Int都是一个整数值(“以r为参数的函数”是任何类型r的函子;对于rChar(->) Char是一个函子类型构造器,它应用于Int变成(->) Char Int或用中缀符号表示为Char -> Int)。
您可以使用通用函子做的唯一事情是fmap,类型为Functor f => (a -> b) -> (f a -> f b)fmap将在普通值上操作的函数转换为在由函子添加附加上下文的值上操作的函数;对于每个函子,这样做的具体方式不同,但您可以对它们使用fmap。使用 Maybe 函子,fmap (+1) 是一个用于计算可能不存在的整数,返回它的输入加一后的结果(如果存在)。使用列表函子 fmap (+1) 是一个用于计算非确定性整数的函数,返回它的输入加一后的非确定性整数。对于 IO 函子,fmap (+1) 是一个用于计算依赖于外部环境的整数 input integer-whose-value-depends-on-the-external-universe,并返回其加一后的结果。对于 (->) Char 函子,fmap (+1) 是一个将依赖于 Char 的整数加一的函数(当我把一个 Char 作为返回值的参数时,我得到比将同样的 Char 作为原始值参数时多一的结果)。
但是,对于某个未知的函子 f,应用于 f Int 中的某个值的 fmap (+1) 是普通 Int 类型上 (+1) 函数的“函子版本”。它将一添加到这个特定函子的“上下文”中的整数中。
单独来看,fmap 并不一定有很大的用处。通常,当您编写具体的程序并使用函子时,您会使用特定的函子,并且会将 fmap 视为它在该特定函子上所执行的操作。当我使用 [Int] 时,我通常不会将我的 [Int] 值视为非确定性整数,而是将它们视为整数列表,并且我将 fmap 的作用方式视为与 map 相同。那么为什么要使用函数对象?为什么不仅仅为列表使用map,对于Maybe使用applyToMaybe,对于IO使用applyToIO呢?这样每个人都会知道它们的作用,而且没有人需要理解函数对象这种奇怪的抽象概念。
关键在于认识到有很多函数对象;几乎所有容器类型都是如此(因此函数对象的类比是容器)。它们中的每一个都有一个对应于fmap的操作,即使我们没有函数对象。每当你仅通过fmap操作(或map,或任何适用于特定类型的命名)编写算法时,如果你将其编写为函数对象而不是特定类型,则它适用于所有函数对象。
它还可以作为一种文档形式。如果我将我的列表值交给您编写的操作列表的函数,它可能会执行任意数量的操作。但是,如果我将我的列表传递给您编写的操作任意函数对象值的函数,则我 知道 您的函数实现不能使用列表功能,只能使用函数对象功能。
回顾一下在传统的命令式编程中如何使用函数对象可能有助于看到它的好处。在那里,类似于数组、列表、树等的容器类型通常会有一些模式用于迭代它们。不同的容器可能略有不同,尽管库通常提供标准迭代接口以解决这个问题。但是,每次想要迭代它们时,你仍然需要编写一个小的for循环,当你要为容器中的每个项目计算结果并收集所有结果时,你通常会混合构建新容器的逻辑。

fmap 是你以后会写的所有这种格式的 for 循环,由库作者一次性排序好了,让你在编程之前就可以使用。此外,它还可以与像 Maybe(->) r 这样的东西一起使用,这些东西在命令式语言中可能不会被视为设计一致的容器接口。


8
在Haskell中,functor捕捉了具有“东西”容器的概念,以便您可以在不改变容器形状的情况下操作该“东西”。
Functor提供了一个函数fmap,它允许您通过将常规函数“提升”到从一个元素类型的容器到另一个元素类型的容器的函数来实现这一点。
fmap :: Functor f => (a -> b) -> (f a -> f b) 

例如,[],即列表类型构造器,是一个函子。
> fmap show [1, 2, 3]
["1","2","3"]

许多其他的Haskell类型构造器,如MaybeMap Integer1也是如此:

> fmap (+1) (Just 3)
Just 4
> fmap length (Data.Map.fromList [(1, "hi"), (2, "there")])
fromList [(1,2),(2,5)]

请注意,fmap 不允许更改容器的“形状”,因此如果您 fmap 一个列表,结果将具有相同数量的元素,而如果您 fmap 一个 Just,它不能变成一个 Nothing。从正式的角度来说,我们要求 fmap id = id,也就是说,如果您 fmap 身份函数,什么都不会改变。
到目前为止,我一直在使用“容器”这个术语,但它实际上比那更通用。例如,IO 也是一个函子,在这种情况下,“形状”的意思是 fmap 对于一个 IO 操作不应该改变副作用。事实上,任何单子都是一个函子。
在范畴论中,函子允许您在不同的范畴之间进行转换,但在 Haskell 中,我们只有一个类别,通常称为 Hask。因此,在 Haskell 中的所有函子都可以从 Hask 转换到 Hask,因此它们是我们所谓的自函子(从一个类别到它自己的函子)。
在其最简单的形式中,函子有点无聊。只有一个操作你能做的事情很有限。但是,一旦您开始添加操作,您可以从常规函子转移到适用的函子到单子,事情很快变得更加有趣,但这超出了本答案的范围。
但是 Set 不是函子,因为它只能存储 Ord 类型。函子必须能够包含任何类型。由于历史原因,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 的东西。请注意,我们不能在这里获取类型为 ab 的东西。函数 g :: a -> b 如何在类型为 f a 的东西上工作,并将它们转换为类型为 f b 的东西。
请注意,f 是相同的,只有其他类型发生了变化。
那意味着什么?它可能意味着很多事情。 f 通常被看作是 "容器"。然后,fmap g 使得 g 可以在这些容器的内部进行操作,而不会打开它们。结果仍然被封闭在 "内部",类型类 Functor 并没有为我们提供打开它们或窥视内部的能力。我们只得到了一些在不透明物体内部的转换。任何其他功能都必须来自其他地方。
还要注意,它并没有说这些 "容器" 只携带一个类型为 a 的 "东西";它们可以有许多单独的 "东西" "在里面",但所有东西的类型都相同 a
最后,任何 Functor 的候选者都必须遵守 Functor 法则
fmap id      ===  id
fmap (h . g) ===  fmap h . fmap g

请注意两个(.)操作符的类型是不同的:

     g  :: a -> b                         fmap g  :: f a -> f b
 h      ::      b -> c           fmap h           ::        f b -> f c
----------------------          --------------------------------------
(h . g) :: a      -> c          (fmap h . fmap g) :: f a        -> f c

这意味着,无论在 abc 类型之间存在什么关系,通过连接函数(例如 gh)的“电线”,也可以通过连接函数 fmap gfmap h 的“电线”在 f af bf c 类型之间存在同样的关系。

或者说,无论在 a, b, c, ... 世界中可以绘制出什么连接图,“右侧”的 f a, f b, f c, ... 世界中都可以通过将函数 g, h, ... 改为函数 fmap g, fmap h, ... 并将函数 id :: a -> a 改为 fmap id,这些函数本身也只是 id :: f a -> f a,从而满足函子定律。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接