单子、组合和计算顺序

3
所有单子文章经常表明,单子允许您按顺序编排效果。
但是简单的组合呢?不是在使用单子时也能实现吗?
f x = x + 1
g x = x * 2

result = f g x

需要在计算f ...之前计算g x吗?

单子是否也可以处理效果,与此类似?


3
这不是函数组合,正确的写法应该是(g . f) x。其次,由于Haskell采用了惰性求值的方式,所以 f x 的结果并不需要立即求出来。f 函数会在尽可能晚的时候被执行,也就是在打印 result 时才会实际执行。最后,是的,Monad类似于隐藏的参数,它代表了世界的状态,并将这些参数链接在操作之间,以便尽管采用了惰性求值,效果仍然按照意图的顺序发生。 - nothingmuch
它也无法通过类型检查。f g x(f g) x相同,因此您将尝试将1添加到一个函数中。 - chepner
来吧,OP显然是指f(g x),是的,如果f对其参数严格要求,那么可能需要先计算g x再计算f。但是,如果f是惰性的,那么就不需要这样做。然而,在这里,这个特定的f是严格的,因为+是严格的。 - Will Ness
3个回答

12

免责声明: 单子(Monads)是许多事情。它们被众所周知的难以解释,因此我不会在这里尝试解释单子的一般概念,因为问题不要求那样做。我假设您已经基本了解Monad接口以及如何将其应用于某些有用的数据类型,例如Maybe, EitherIO


什么是效果?

你的问题开始于一个注释:

所有单子文章通常都会说明,单子允许您按顺序序列化效果。

嗯。这很有趣。实际上,它有几个有趣之处,其中之一是:它意味着单子让你创建某种排序。这是正确的,但它只是部分表象:它还指出排序发生在效果上。

然而,问题在于……什么是“效果”?将两个数相加算作一种效果吗?根据大多数定义,答案是否定的。那么,将某些内容打印到终端,是否算作一种效果呢?在这种情况下,我认为大多数人会同意答案是肯定的。但是,考虑一些更微妙的事情:通过产生Nothing来中断计算是否算作效果?

错误效果

让我们看一个例子。考虑以下代码:

> do x <- Just 1
     y <- Nothing
     return (x + y)
Nothing
第二行代码示例中的“short-circuits”是由于MaybeMonad实例引起的。这可以被视为一种效应吗?在某种意义上,我认为是的,因为它是非局部的,但在另一方面,可能不是。毕竟,如果将x <- Just 1y <- Nothing这两行交换,结果仍然相同,所以顺序并不重要。
然而,考虑一个稍微复杂一些的例子,它使用Either而不是Maybe:
> do x <- Left "x failed"
     y <- Left "y failed"
     return (x + y)
Left "x failed"

现在变得更有趣了。如果你现在交换前两行,你会得到不同的结果!但是,这是否是类似于你在问题中提到的“效应”的表示呢?毕竟,它只是一堆函数调用。正如你所知道的,do符号只是一堆>>=操作符的替代语法,因此我们可以将其展开:

> Left "x failed" >>= \x ->
    Left "y failed" >>= \y ->
      return (x + y)
Left "x failed"

我们甚至可以使用Either特定的定义来替换>>=操作符,以完全摆脱单子:

> case Left "x failed" of
    Right x -> case Left "y failed" of
      Right y -> Right (x + y)
      Left e -> Left e
    Left e -> Left e
Left "x failed"
因此,很明显单子确实施加了某种排序,但这不是因为它们是单子和单子是魔法,而只是因为它们碰巧启用了一种编程风格,比 Haskell 通常允许的看起来更加杂乱无章。
单子和状态
但也许这对您来说还不够令人满意。错误处理并不令人信服,因为它只是简单地短路,结果中并没有任何排序!好吧,如果我们使用一些稍微复杂的类型,就可以做到这一点。例如,考虑 Writer 类型,它允许使用单子界面进行一种“日志记录”:
> execWriter $ do
    tell "hello"
    tell " "
    tell "world"
"hello world"

现在这比以前更有趣了,因为do块中每个计算的结果都没有被使用,但它仍然影响输出!这显然是具有副作用的,而且顺序非常重要!如果我们重新排列tell表达式,我们将得到一个非常不同的结果:

> execWriter $ do
    tell " "
    tell "world"
    tell "hello"
" worldhello"

不过这怎么可能呢?好吧,我们可以再次重写它以避免使用do符号:

execWriter (
  tell "hello" >>= \_ ->
    tell " " >>= \_ ->
      tell "world")

我们可以再次内联 >>= 的定义,但对于Writer 来说它太长了,不能很好地说明问题。然而,关键是,Writer 只是一个完全普通的Haskell数据类型,不执行任何I/O或类似的操作,但是我们已经使用了单子接口来创建类似有序效果的东西。

我们甚至可以通过使用 State 类型来创建一个看起来像是可变状态的接口:

> flip execState 0 $ do
    modify (+ 3)
    modify (* 2)
6

再次说明,如果我们重新排列这些表达式,我们会得到一个不同的结果:

> flip execState 0 $ do
    modify (* 2)
    modify (+ 3)
3

明显地,单子是创建具有良好定义的顺序并具有类似状态的接口的有用工具,尽管实际上只是普通函数调用。

为什么单子能做到这一点?

是什么赋予了单子这种能力?好吧,它们并不神奇-它们只是普通的纯Haskell代码。但是请考虑>>=的类型签名:

(>>=) :: Monad m => m a -> (a -> m b) -> m b

注意第二个参数如何依赖于a,而获取a的唯一方法是从第一个参数中获得?这意味着>>=需要在应用第二个参数之前“运行”第一个参数以产生一个值。这与评估顺序无关,更多地涉及实际编写能够进行类型检查的代码。

现在,确实Haskell是一种惰性语言。但是Haskell的惰性不太重要,因为所有这些代码实际上都是纯的,甚至包括使用State的示例!它只是一种模式,以纯粹的方式编码看起来有点状态的计算,但如果你自己实现了State,你会发现它只是在>>=函数的定义中传递“当前状态”。没有任何实际的变化。

就是这样。由于它们的接口,单子对其参数可以进行评估的顺序施加了一种排序,并且Monad的实例利用这个排序创建具有类似状态的外观的接口。您不需要Monad来进行评估排序,正如您所发现的那样;显然,在(1 + 2) * 3中,加法将在乘法之前进行评估。

IO呢??

好吧,你抓住我了。这里的问题是:IO是魔法。

单子不是魔法,但IO却是。以上所有示例都是纯函数式的,但是读取文件或写入stdout显然是不纯的。那么IO怎么办?

嗯,IO由GHC运行时实现,您无法自己编写它。但是,为了使其与Haskell的其余部分良好配合,需要有一个明确定义的评估顺序!否则,事情会以错误的顺序打印出来,所有其他的异常也会发生。

结果,Monad的接口是确保评估顺序可预测的有效方法,因为它已经适用于纯代码。所以IO利用相同的接口来保证评估顺序相同,而运行时实际上定义了该评估的含义。

但是,不要被误导!在纯语言中进行I/O不需要单子,而且在具有单子效应的情况下也不需要IOHaskell的早期版本尝试过一种非单子方式进行I/O,本回答的其他部分解释了如何在纯单调效应中实现它们。请记住,单子并不是特殊或神圣的,它们只是Haskell程序员发现其各种属性有用的模式。


6

是的,你提出的函数对于标准的数值类型是严格的。但并不是所有的函数都是这样的!

f _ = 3
g x = x * 2
result = f (g x)

并不是必须在计算f (g x)之前先计算g x


在我看来,这个问题并不是关于懒惰或严格的求值顺序,而是关于单子接口如何允许效果的排序以及它在普通的Haskell求值中提供了什么(无论它是懒惰还是非懒惰)。请参阅我的过长回答,了解我认为OP在问什么,并随时纠正我如果您认为我完全错了。 - Alexis King
3
懒惰绝对是问题的核心:使用单子在 Haskell 中变得流行,正是因为与严格语言不同,懒惰语言可以将某些事情完全不进行评估,因此如果我们将"produce effect"的内容留在懒惰片段中,这些效果可能根本不会发生! 单子后来被证明是有用的; 但最初,它们存在是为了解决一个问题,并且我的观点是,这个问题确切地要求为什么纯朴的解决方案不够好。 - Daniel Wagner
嗯,这很公正,即使我不完全同意(我认为这种推理方式主要适用于IO,但是虽然IO可能是单子发明的驱动力,但历史对于新学习者来说有些无关紧要)。我不知道单子“发现”的历史,如果您有任何介绍它们引入的文献,我会非常感兴趣。尽管如此,我认为我不同意您的最后一句话:我的答案明确说明了单子效应的想法除了IO之外是纯粹的。不过只有OP可以澄清意图。 - Alexis King

2

是的,单子使用函数组合来序列化效果,并不是实现序列化效果的唯一方法。

严格语义和副作用

在大多数语言中,通过对表达式的函数部分首先应用严格语义,然后依次对每个参数进行排序,最后将函数应用于参数来进行排序。因此,在JS中,函数应用形式如下:

<Code 1>(<Code 2>, <Code 3>)

按照指定顺序运行四个代码片段:1,2,3,然后检查1的输出是否为函数,接着使用这两个计算出的参数调用该函数。这样做是因为任何一个步骤都可能会产生副作用。代码示例:

const logVal = (log, val) => {
  console.log(log);
  return val;
};
logVal(1, (a, b) => logVal(4, a+b))(
  logVal(2, 2),
  logVal(3, 3));

这对于那些语言是有效的。在这个上下文中,我们可以说这些是“副作用”,这意味着JS的类型系统没有任何方法让您知道它们存在。

Haskell确实有一个严格的应用程序原语,但它想要保持“纯净”,这大致意味着它希望类型系统跟踪效果。因此,他们引入了一种“元编程”的形式,其中他们的一种类型是类型级形容词,“计算_____的程序”。程序与真实世界交互;理论上,Haskell代码不会。您必须定义“main是计算单位类型的程序”,然后编译器实际上只是为您构建该程序作为可执行二进制文件。当该文件被运行时,Haskell实际上已经不再存在!

因此,这比普通函数应用程序更加“具体”,因为我在JavaScript中编写的抽象问题是:

  1. 我有一个计算{从(X,Y)对到计算Z的程序的函数}的程序。
  2. 我还有一个计算X的程序和一个计算Y的程序。
  3. 我想将所有这些组合成一个计算Z的程序。

这不仅仅是函数组合本身。但是函数可以做到这一点。

窥视单子内部

单子是一种模式。该模式是,有时您有一个形容词,重复它时不会增加太多内容。例如,当您说“延迟的延迟x”或“零个或多个(零个或多个xs)”或“任意一个null或者任意一个null或者一个x”时,没有添加太多内容。同样对于IO单子,“计算程序以计算x”比“计算x”的可用性不高。

模式是有一些规范的合并算法可以合并:

join:给定一个<形容词> <形容词> x,我将为您创建一个<形容词> x

我们还添加了另外两个属性,形容词应该是输出的,

map:给定一个x -> y和一个<形容词> x,我将为您创建一个<形容词> y

并且是通用嵌入的,

pure:给定一个x,我将为您创建一个<形容词> x

在这些三个事物和几个公理的基础上,您恰好拥有一个常见的“单子”概念,可以为其开发一种真正的语法。

现在这个元编程的想法显然包含一个单子。在JS中,我们会这样写:
interface IO<x> {
  run: () => Promise<x>
}
function join<x>(pprog: IO<IO<x>>): IO<x> {
  return { run: () => pprog.run().then(prog => prog.run()) };
}
function map<x, y>(prog: IO<x>, fn: (in: x) => y): IO<y> {
  return { run: () => prog.run().then(x => fn(x)) }
}
function pure<x>(input: x): IO<x> {
  return { run: () => Promise.resolve(input) }
}
// with those you can also define,
function bind<x, y>(prog: IO<x>, fn: (in: x) => IO<y>): IO<y> {
  return join(map(prog, fn));
}

但是仅仅存在一个模式并不意味着它有用!我声称这些函数事实上就足以解决以上问题。而且,很容易理解为什么:你可以使用 bind 来创建一个函数作用域,在其中形容词不存在,并对你的值进行操作:

function ourGoal<x, y, z>(
  fnProg: IO<(inX: x, inY: y) => IO<z>>,
  xProg: IO<x>,
  yProg: IO<y>): IO<z> {
    return bind(fnProg, fn =>
      bind(xProg, x =>
        bind(yProg, y => fn(x, y))));
}

如何回答你的问题

请注意,在上述示例中,我们通过编写三个bind的方式来选择操作顺序。我们可以以其他方式编写这些内容,但是我们需要所有参数才能运行最终程序。

我们对如何安排操作顺序的选择确实在函数调用中得到了体现: 你是完全正确的。 但是,仅使用函数组合的方式存在缺陷,因为它会将效果降级为副作用,以便使类型正常工作。


1
我真的不想显得消极或对抗,但我不知道该怎么说:我并不是很喜欢这个答案。它更像是一篇冗长的单子教程博客文章,而不是试图回答OP相当具体的问题,即使作为一个经验丰富的Haskeller,我也不认为它非常易读。它似乎在论证monads是模式,这是好的,但大部分答案都花在了说“这就是一个monad;它是一个数学东西;它有一些操作和法则”然后说“看哪,IO monad”,这似乎并不是很有说服力。 - Alexis King
1
@AlexisKing 感谢您的反馈;我并没有将其视为负面或对抗性的。它确实比我通常做的更冗长;这部分是因为它是匆忙写成的,另一部分是因为我真的认为人们在意识到自己正在进行元编程之前不会理解IO单子。 - CR Drost

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