Functors: Identity, Composition, and fmap

2025年3月26日

在编写软件时,我们经常遇到值存在于上下文(可以理解为某种容器)中的情况。对于简单值来说,标准的函数应用非常直接,但在这些情况下,应用函数会面临挑战。例如,Haskell 中的 Maybe 数据类型封装了一个值可能不存在的情况。 对包裹在 Maybe 中的值应用函数需要不同的方法,因为直接应用函数会导致类型错误。

例如,将函数 (+4) 应用于整数 2 非常简单:

ghci> (+4) 2
6

但是,尝试将同一个函数直接应用于 Maybe 包裹的值会导致类型错误:

ghci> x = Just 2
ghci> (+4) x
<interactive>:25:1: error: [GHC-39999]
  • No instance for ‘Num (Maybe Integer)’ arising from a use of ‘it’
  • In the first argument of ‘print’, namely ‘it’
   In a stmt of an interactive GHCi command: print it

这时 fmap 就派上用场了。fmap 提供了一种将函数应用于包裹在上下文中的值的方法。对于 Maybefmap 允许我们将 (+4) 应用于 Just 上下文中的整数:

ghci> fmap (+4) (Just 2)
Just 6

这引出了 Functor 的概念。Haskell 中的 Functor typeclass 1 表示任何可以被映射的类型。例如,列表是 Functor typeclass 的实例。Functor typeclass 的定义如下:

class Functor f where
  fmap :: (a -> b) -> f a -> f b

这个 typeclass 只定义了一个函数,fmap。这个多态函数适用于任何作为 Functor typeclass 实例的类型构造器 f。它接受:

  1. 一个类型为 a -> b 的函数,它将类型 a 的值转换为类型 b 的值。
  2. 一个类型为 f a 的值,它表示 Functor 上下文 f 中的类型 a 的值。然后它返回一个类型为 f b 的值,它是同一 Functor 上下文 f 中的类型 b 的值。本质上,fmap 将函数应用于 Functor 上下文中的值。

正如前面演示的,我们可以使用 fmap (+4) (Just 2),因为 Maybe 是一个 Functor。 fmap 将函数应用于 Maybe 上下文中的值。 Maybe 类型本身的定义如下:

data Maybe a = Nothing | Just a
instance Functor Maybe where
  fmap f (Just x) = Just (f x)
  fmap f Nothing = Nothing

这个实现表明,如果我们有一个 Nothing 值,fmap 将返回 Nothing。 如果我们有一个包裹在 Just 中的值,fmap 将函数应用于 Just 上下文的内容。

在像 Go 这样的缺少显式 Maybe 类型的语言中,我们经常使用错误处理或其他机制来实现类似的结果。

例如,考虑以下 Go 代码:

metadata, err := user.GetMetadata()
if err != nil {
  return err
}
if metadata == nil {
  return nil
}
return metadata.ProjectedValue

Haskell 等价物:

getProjectedValue <$> (User.getMetadata "uuid")

在这里,<$>fmap 的中缀版本。 如果 User.getMetadata 返回 Just Metadata,我们可以从中提取 projected value。 如果它返回 Nothing,我们将返回 Nothing

Functor Law

为了使一个类型被认为是合适的 Functor,它必须遵守某些定律:

Identity Law (同一性定律)

应用 fmap id 不应更改 Functor 的值。

此定律确保 Functor 在使用不应修改任何内容的函数遍历其结构时,不会引入任何意外更改或副作用。

fmapMaybe 实现中我们可以看到,这个定律成立。

instance Functor Maybe where
  fmap f (Just x) = Just (f x)
  fmap f Nothing = Nothing

同一性定律的应用

Maybe:

ghci> fmap id (Just "change")
Just "change"
ghci> id (Just "change")
Just "change"
ghci> fmap id Nothing
Nothing
ghci> id Nothing
Nothing

List:

ghci> fmap id [1, 2, 3, 4]
[1, 2, 3, 4]
ghci> id [1, 2, 3, 4]
[1, 2, 3, 4]

IO:

ghci> fmap id (return "test")
"test"
ghci> id (return "test")
"test"

即使在 IO Functor 中,fmap id 也不会更改该操作。

Composition Law (组合律)

fmap 应用于组合函数应与按顺序分别应用 fmap 相同。

这确保 Functor 在处理函数组合时表现一致。 这对于保持可预测性并更容易推理使用 Functor 的代码至关重要。

fmap (f . g) == fmap f . fmap g

组合律的应用

Maybe:

ghci> let f = (*3)
ghci> let g = (+5)
ghci> let c = f . g
ghci> fmap c (Just 10)
Just 45
ghci> fmap f (fmap g (Just 10))
Just 45

List:

ghci> let f = (*3)
ghci> let g = (+5)
ghci> let c = f . g
ghci> fmap c [1, 2, 3]
[18,21,24]
ghci> fmap f (fmap g [1, 2, 3])
[18,21,24]

结论

Haskell 中的 Functor 为将函数应用于包裹在上下文中的值(例如 Maybe、列表或 IO)提供了一个强大的抽象。 通过遵循 identitycomposition laws,它们确保了可预测的行为并保持了结构,同时实现了干净、简洁和富有表现力的代码。 通过利用 fmap,我们可以无缝地处理不同上下文中的值,从而使函数式编程更加灵活且易于推理。

  1. Haskell 中的 typeclass 为不同类型定义了一个共享接口,通过类型特定的实现启用多态性 ↩︎