我正在尝试学习 Haskell,并且已经完成了所有基础知识。但现在我被困住了,试图让我的头脑围绕函子。
我读过“仿函数将一个类别转换为另一个类别”。这是什么意思?
我知道有很多问题要问,但是谁能给我一个简单的函子英文解释或者一个简单的用例?
我正在尝试学习 Haskell,并且已经完成了所有基础知识。但现在我被困住了,试图让我的头脑围绕函子。
我读过“仿函数将一个类别转换为另一个类别”。这是什么意思?
我知道有很多问题要问,但是谁能给我一个简单的函子英文解释或者一个简单的用例?
我不小心写了一个
我将使用示例回答您的问题,并将类型放在下面的评论中。
注意类型中的模式。
fmap
是一个概括map
函子是为了给你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 数据类型,它有两种类型的值,Nothing
和Just x
. (您可以用Nothing
表示未能获得答案,而Just x
表示答案。)
fmap (+7) (Just 10) = Just 17
fmap (+7) Nothing = Nothing
-- Int->Int Maybe Int Maybe Int
好的,再次,在 Maybe内部fmap
使用。我们也可以 fmap 其他函数。找到一个列表的长度,所以我们可以 fmap 它(+7)
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
fmap
在IO上特别爽现在我最喜欢使用 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!
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”:
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,所以有 typeIO Bool
有一个 Boolean 值,因为它产生 a Bool
, eg2[4,5,6]
有Int
s 。)
fmap :: (a -> b) -> Something a -> Something b
这适用于Something
列表(书面[]
)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
时候?
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]
into ,因为不仅仅是数据发生了变化 - 该数据的结构或上下文发生了变化。[1,2,3,6]
Right 4
Left 4
当我在做一个图形用户界面项目时,我曾经遇到过这个规则——我希望能够编辑这些值,但是如果不改变下面的结构我就无法做到这一点。没有人会真正注意到差异,因为它具有相同的效果,但意识到它不遵守函子规则让我重新思考了我的整个设计,现在它更干净、更流畅、更快了。
其次是组合:这意味着您可以选择是一次 fmap 一个函数,还是同时 fmap 两个函数。如果fmap
不考虑值的结构/上下文并使用其给定的功能对其进行编辑,则它也将适用于此规则。
数学家有一个秘密的第三条规则,但我们不称它为 Haskell 中的规则,因为它看起来就像一个类型声明:
fmap :: (a -> b) -> something a -> something b
例如,这会阻止您将该函数仅应用于列表中的第一个值。该法律由编译器执行。
为什么我们有它们?确保fmap
不会在幕后偷偷做任何事情或改变我们没有预料到的任何事情。它们不是由编译器强制执行的(要求编译器在编译您的代码之前证明一个定理是不公平的,并且会减慢编译速度 - 程序员应该检查)。这意味着您可以稍微欺骗法律,但这是一个糟糕的计划,因为您的代码可能会产生意想不到的结果。
Functor 的法则是确保fmap
在没有任何其他更改的情况下公平、平等、无处不在地应用你的函数。这是一个很好的、干净的、清晰的、可靠的、可重复使用的东西。
一个模糊的解释是 aFunctor
是某种容器和一个关联的函数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]
还有可能版本:
fmap :: (a -> b) -> Maybe a -> Maybe b
Functor
您可以通过查询 GHCI来获取有关标准实例的信息,:i Functor
并且许多模块定义了更多Functor
s 实例(和其他类型类)。
不过,请不要把“容器”这个词看得太重。Functor
s 是一个定义明确的概念,但您通常可以通过这个模糊的类比来推断它。
了解正在发生的事情的最佳选择是简单地阅读每个实例的定义,这应该让您对正在发生的事情有一个直觉。从那里开始,您只需迈出一小步就可以真正正式地理解您对这个概念的理解。需要添加的是澄清我们的“容器”到底是什么,并且每个实例都非常满足一对简单的定律。
重要的是要在头脑中区分仿函数本身和应用了仿函数的类型中的值。仿函数本身是一个类型构造函数,如Maybe
,IO
或列表构造函数[]
。仿函数中的值是应用了该类型构造函数的类型中的某个特定值。egJust 3
是类型中的一个特定值Maybe Int
(该类型是Maybe
应用于类型的函子Int
),putStrLn "Hello World"
是类型中的一个特定值IO ()
,并且[2, 4, 8, 16, 32]
是类型中的一个特定值[Int]
。
我喜欢将应用函子的类型中的值视为与基本类型中的值“相同”,但具有一些额外的“上下文”。人们经常将容器类比用于函子,这对相当多的函子很自然地工作,但是当你不得不说服自己IO
或者(->) r
就像一个容器时,它变得更像是一个障碍而不是帮助。
所以如果anInt
代表一个整数值,那么aMaybe Int
代表一个可能不存在的整数值(“可能不存在”就是“上下文”)。An[Int]
表示具有多个可能值的整数值(这与列表函子的解释与列表单子的“非确定性”解释相同)。AnIO Int
表示一个整数值,其精确值取决于整个 Universe(或者,它表示可以通过运行外部进程获得的整数值)。AChar -> Int
是任何Char
值的整数值(“r
作为参数的函数”是任何类型的函子r
;r
asChar
(->) Char
是类型构造函数,它是函子,Int
(->) Char Int
Char -> Int
中缀表示法)。
使用通用函子唯一可以做的就是fmap
使用类型Functor f => (a -> b) -> (f a -> f b)
。fmap
将对正常值进行操作的函数转换为对具有由仿函数添加的附加上下文的值进行操作的函数;对于每个仿函数,这到底是做什么的是不同的,但你可以对所有仿函数都这样做。
因此,Maybe
函子fmap (+1)
是计算一个比其输入的可能不存在整数高 1 的可能不存在整数 1 的函数。使用列表函子fmap (+1)
是计算比其输入的非确定整数高 1 的非确定整数的函数。使用IO
函子,fmap (+1)
是计算比其输入整数高 1 的整数的函数 - 其值取决于外部宇宙。使用(->) Char
函子,fmap (+1)
是一个将 1 加到依赖于 a 的整数的函数Char
(当我将 aChar
输入到返回值时,我得到的值比将其输入Char
到原始值得到的值高 1)。
但一般来说,对于一些未知的仿函数f
,fmap (+1)
应用到一些值中的是普通s上f Int
的函数的“仿函数版本” 。它在这个特定函子所具有的任何“上下文”中将整数加 1。(+1)
Int
就其本身而言,fmap
不一定有用。通常,当您编写一个具体的程序并使用一个仿函数时,您正在使用一个特定的仿函数,并且您通常认为它对那个特定的仿函数fmap
做了什么。当我使用 时,我通常不会将我的值视为不确定的整数,我只是将它们视为整数列表,而且我的想法与我的想法相同。[Int]
[Int]
fmap
map
那么为什么要打扰函子呢?为什么不只拥有map
for 列表、applyToMaybe
for Maybe
s 和applyToIO
for IO
s?然后每个人都会知道他们在做什么,而没有人需要理解函子等奇怪的抽象概念。
关键是要认识到那里有很多函子。一开始几乎所有的容器类型(因此容器类比函子是什么)。它们中的每一个都有对应于 的操作fmap
,即使我们没有函子。每当您仅根据fmap
操作(或map
,或为您的特定类型调用的任何内容)编写算法时,如果您根据函子而不是您的特定类型编写算法,那么它适用于所有函子。
它也可以作为一种文档形式。如果我将我的列表值之一交给您编写的对列表进行操作的函数,它可以做很多事情。但是,如果我将列表交给您编写的对任意函子中的值进行操作的函数,那么我知道您的函数的实现不能使用列表功能,只能使用函子功能。
回想一下如何在传统的命令式编程中使用仿函数的东西可能有助于看到好处。诸如数组、列表、树等容器类型通常会有一些用于迭代它们的模式。尽管库通常会提供标准的迭代接口来解决这个问题,但不同的容器可能会略有不同。但是每次你想要迭代它们时,你仍然最终会编写一个小的 for 循环,当你想要做的是为容器中的每个项目计算一个结果并收集你通常最终混合在逻辑中的所有结果时用于构建新容器。
fmap
是你将编写的那种形式的每个for 循环,在你坐下来编程之前,由库编写者一劳永逸地排序。此外,它还可以与类似的东西一起使用Maybe
,(->) r
这可能不会被视为与在命令式语言中设计一致的容器接口有任何关系。
在 Haskell 中,仿函数捕获了拥有“东西”容器的概念,这样您就可以在不改变容器形状的情况下操纵这些“东西”。
Functors 提供了一个函数 ,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
是 aJust
它不能成为一个Nothing
. 用正式的术语来说,我们要求fmap id = id
,即如果你fmap
是恒等函数,没有任何变化。
到目前为止,我一直在使用“容器”这个术语,但它实际上比这更笼统。例如,IO
也是一个函子,在这种情况下我们所说的“形状”是指fmap
在一个IO
动作上不应该改变副作用。事实上,任何 monad 都是仿函数2。
在范畴论中,函子允许你在不同的范畴之间进行转换,但在 Haskell 中我们实际上只有一个范畴,通常称为 Hask。因此,Haskell 中的所有函子都从 Hask 转换为 Hask,所以它们就是我们所说的 endofunctors(从一个类别到自身的函子)。
以最简单的形式,函子有点无聊。只需一项操作,您就可以做很多事情。但是,一旦开始添加操作,您就可以从常规函子到应用函子再到 monad,事情很快就会变得更有趣,但这超出了这个答案的范围。
1但Set
不是,因为它只能存储Ord
类型。函子必须能够包含任何类型。
2由于历史原因,Functor
不是 的超类Monad
,虽然很多人认为应该是。
让我们看看类型。
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
. iefmap g
是一个函数,将类型的东西转换为类型f a
的东西f b
。请注意,我们无法获取类型a
或b
此处的内容。该函数g :: a -> b
以某种方式用于处理类型的事物f a
并将它们转换为类型的事物f b
。
注意f
是一样的。只有另一种类型发生变化。
这意味着什么?这可能意味着很多事情。f
通常被视为东西的“容器”。然后,fmap g
可以g
对这些容器的内部进行操作,而不会打开它们。结果仍然被封闭在“内部”,类型类Functor
没有为我们提供打开它们或窥视内部的能力。我们得到的只是不透明事物内部的一些转变。任何其他功能都必须来自其他地方。
另请注意,这并不是说这些“容器”只携带一种类型的“东西” a
;它的“内部”可以有许多单独的“事物”,但都是相同的类型a
。
最后,函子的任何候选人都必须遵守函子定律:
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
这意味着无论在a
,b
和c
类型之间存在什么样的关系,通过连接线(可以说是g
和h
),也存在于f a
,f b
和f c
类型之间,通过连接函数和的线。fmap g
fmap h
或者,任何连通图可以在“左侧”绘制,在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