"join"的直观含义是什么?

What is the intuitive meaning of "join"?

join对于Monad的直观含义是什么?

monads-as-containers 类比对我来说很有意义,在这些类比中 join 也很有意义。一个值被双重包装,我们打开一层。但众所周知,monad 不是容器。

在正常情况下,比如在 IO 中,如何使用 join 编写合理、易于理解的代码?

一个action :: IO (IO a)是一种生成方式,一种生成方式是a。那么,join action 是 运行 最外层生产者 action 生产 a 的一种方式,获取它生产的生产者,然后 运行 也, 最终得到那个多汁的 a.

对于 List monad,join 就是 concat,而 concatMap 就是 join . fmap。 所以 join 隐式出现在任何使用 concat 的列表表达式中 或 concatMap.

假设你被要求找出所有除数的数字 输入列表中的编号。如果你有一个 divisors 函数:

divisors :: Int -> [Int]
divisors n = [ d | d <- [1..n], mod n d == 0 ]

你可能会这样解决问题:

foo xs = concat $ (map divisors xs)

这里我们考虑先映射 除数对所有输入元素起作用,然后连接起来 所有结果列表。你甚至可能认为这是一个非常 "functional"解决问题的方法。

另一种方法是写一个列表理解:

bar xs = [ d | x <- xs, d <- divisors x ]

或使用do-notation:

bar xs = do x <- xs
            d <- divisors
            return d

这里可以说我们想多了一点 命令式 - 首先从列表 xs 中抽取一个数字;然后画 一个除数的除数并产生它。

但事实证明,foobar 是完全相同的函数。

此外,这两种方法在 any monad 中完全相同。 也就是说,对于任何单子,以及适当的单子函数 f 和 g:

do x <- f      
   y <- g x           is the same as:    (join . fmap g) f
   return y

例如,在 IO monad 中,如果我们设置 f = getLineg = readFile, 我们有:

do x <- getLine
   y <- readFile x    is the same as:   (join . fmap readFile) getLine
   return y

do-block 是一种更加命令式的动作表达方式:首先读取一个 输入行;然后将 returned 字符串作为文件名,读取内容 文件,最后 return 结果。

IO-monad 中的等效连接表达式似乎有点不自然。 然而,它不应该是因为我们正在以与我们完全相同的方式使用它 在第一个示例中使用 concatMap

join 折叠类型构造函数的连续层。

一个有效的 join 必须满足 属性 ,对于类型构造函数的任意数量的连续应用,我们折叠层的顺序无关紧要。

例如

ghci> let lolol = [[['a'],['b','c']],[['d'],['e']]]
ghci> lolol :: [[[Char]]]
ghci> lolol :: [] ([] ([] Char)) -- the type can also be expressed like this

ghci> join (fmap join lolol) -- collapse inner layers first
"abcde"
ghci> join (join lolol) -- collapse outer layers first
"abcde"

(我们使用 fmap 到 "get inside" 外层 monadic 层,以便我们可以先折叠内层。)

一个小的非容器示例,其中 join 很有用:对于函数 monad (->) ajoin 等价于 \f x -> f x x,类型为 [=18 的函数=] 将相同的参数两次应用到另一个函数。

给定一个动作产生 另一个 动作,运行 动作然后 运行 它产生的动作。

如果你想象某种 Parser x monad 解析 x,那么 Parser (Parser x) 是一个解析器,然后 returns 是另一个解析器。所以 join 会将其扁平化为一个 Parser x,只有 运行 两个动作和 returns 最后的 x

你一开始为什么要 Parser (Parser x)?基本上,因为 fmap。如果你有一个解析器,你可以 fmap 一个函数来改变它的结果。但是如果你 fmap 一个函数本身 returns 一个解析器,你最终会得到一个 Parser (Parser x),你可能只想 运行 这两个动作。 join 实施 "just run both actions".

我喜欢这个解析示例,因为解析器通常有一个 runParser 函数。很明显 Parser Int 不是 整数。它可以解析一个整数,你给它一些输入来解析。我想很多人最终都认为 IO Int 只是一个普通的整数,但是有了这个烦人的 IO 位,你无法摆脱它。 不是。 这是未执行的 I/O 操作。没有整数"inside"它;在您实际 执行 之前,整数不存在 I/O.

我发现通过写出类型并稍微重构它们以揭示函数的作用,这些东西更容易解释。

Reader 单子

Reader类型就是这样定义的,其join函数的类型如下所示:

newtype Reader r a = Reader { runReader :: r -> a }

join :: Reader r (Reader r a) -> Reader r a

因为这是一个 newtype,这意味着类型 Reader r a 同构 r -> a。所以我们可以重构类型定义来为我们提供这种类型,尽管它不一样,但它确实是 "the same" 加引号:

在与[=32=同构的(->) r单子中,join是函数:

join :: (r -> r -> a) -> r -> a

所以 Reader join 是一个采用双位函数 (r -> r -> a) 并在其两个参数位置应用相同值的函数。

作家单子

因为 Writer 类型有这样的定义:

newtype Writer w a = Writer { runWriter :: (a, w) }

...然后当我们删除 newtype 时,它的 join 函数的类型同构于:

join :: Monoid w => ((a, w), w) -> (a, w)

Monoid 约束需要存在,因为 WriterMonad 实例需要它,它让我们可以立即猜测函数的作用:

join ((a, w0), w1) = (a, w0 <> w1)

状态单子

同样,由于 State 有这样的定义:

newtype State s a = State { runState :: s -> (a, s) }

...那么它的join是这样的:

join :: (s -> (s -> (a, s), s)) -> s -> (a, s)

...你也可以冒险直接写:

join f s0 = (a, s2)
  where 
    (g, s1) = f s0
    (a, s2) = g s1


{- Here's the "map" to the variable names in the function:

               f     g      s2   s1      s0        s2
    join :: (s -> (s -> (a, s ), s )) -> s  -> (a, s )
-}

如果你仔细看一下这个类型,你可能会认为它与 ReaderWriter 的类型有些相似他们的 join 行动。你是对的! ReaderWriterState monad 都是更通用的模式 update monads.

的实例

列出单子

join :: [[a]] -> [a]

正如其他人所指出的,这是 concat 函数的类型。

解析 monad

这里有一件非常巧妙的事情要实现。通常,"fancy" monad 是 "basic" 的组合或变体,例如 ReaderWriterState 或列表。因此,当我遇到一个新颖的 monad 时,我经常会问:它与哪个基本 monad 相似,又如何相似?

以解析 monad 为例,这已在此处的其他答案中提出。一个简单的解析器 monad(不支持错误报告等重要的事情)看起来像这样:

newtype Parser a = Parser { runParser :: String -> [(a, String)] }

一个Parser是一个函数,它接受一个字符串作为输入,returns一个候选解析列表,其中每个候选解析是一对的:

  1. a类型的解析结果;
  2. leftovers(在该解析中未消耗的输入字符串的后缀)。

但是请注意,这种类型看起来非常像状态 monad:

newtype Parser  a = Parser { runParser :: String -> [(a, String)] }
newtype State s a = State  { runState  :: s      ->  (a, s)       }

这绝非偶然!解析器 monads 是 nondeterministic state monads,其中状态是输入字符串的未使用部分,并且解析步骤生成 alternatives,稍后可能会被拒绝进一步输入的光。列表 monad 通常称为 "nondeterminism" monad,因此解析器类似于状态和列表 monad 的混合也就不足为奇了。

并且可以通过使用 monad transfomers 将这种直觉系统化。 state monad transformer 定义如下:

newtype StateT s m a = StateT { runStateT :: s -> m (a, s) }

也就是说上面的Parser类型也可以这样写:

type Parser a = StateT String [] a

...及其 Monad 实例从 StateT[].

机械地 之后

IO monad

想象一下,我们可以枚举所有可能的 原始 IO 动作,有点像这样:

{-# LANGUAGE GADTs #-}

data Command a where
  -- An action that writes a char to stdout
  putChar :: Char -> Command ()

  -- An action that reads a char from stdin
  getChar :: Command Char

  -- ...

那么我们可以将 IO 类型看作是这样的(我从高度推荐的 Operational monad tutorial 中改编而来):

data IO a where
  -- An `IO` action that just returns a constant value.
  Return :: a -> IO a

  -- An action that binds the result of a `Command` to 
  -- a function that computes the next step after it.
  Bind :: Command x -> (x -> IO a) -> IO a

instance Monad IO where ...

然后 join 操作将如下所示:

join :: IO (IO a) -> IO a

-- If the action is just `Return`, then its payload already
-- is what we need to return.
join (Return ioa) = ioa

-- If the action is a `Bind`, then its "next step" function
-- `f` produces `IO (IO a)`, so we can just recursively stick 
-- a `join` to its result end.
join (Bind cmd f) = Bind cmd (join . f)

所以 join 在这里所做的就是 "chase down" IO 操作,直到它看到符合模式 Return (ma :: IO a) 的结果,然后去掉外部 Return.

那我在这里做了什么?就像解析器 monad 一样,我刚刚定义(或者更确切地说是复制)了一个 IO 类型的玩具模型,它具有 透明 的优点。然后我从玩具模型中计算出 join 的行为。