函子和应用
函子和应用
原文:https://medium.com/hackernoon/functors-and-applicatives-b9af535b1440
在我早期的一篇文章中,我试图用一种平易近人的方式来解释单子。在这里,我们将尝试对同一个族中的其他成员——函子和应用函子进行同样的操作。尽管这里解释的概念和原理在函数式编程中是通用的,但是在 Scala 上(像往常一样)会有轻微的强调。
函子
如果您阅读了前面提到的关于单子的文章,您可能还记得我是如何将它们描述为值的包装器的。包装在单子中的每个值都变成了一个配备了两种方法的对象,即单位和平面图(使用 Scala 命名约定)。
所以,如果你把单子看作是提供这两种方法的包装器(我也喜欢“context”这个词),那么你可以把函子看作是另一种包装器;一种提供一个稍微弱一点的单子的物质。
您得到的只是:
- 在哈斯克尔绘制(fmap;就像 monad 的 flatMap,但是没有展平部分)
函数映射可能至少听起来很熟悉,尽管我很确定你们中的大多数人已经在实践中使用了它。初学者通常是用 list/array/somethern collection 的例子来介绍的,但问题是所有的函子都有这个功能。您可以映射列表或集合中的每个元素,但也可以将未来或选项中的值映射到另一个值(相同或不同类型)。
继续前进,但首先我们必须解决一些问题。我没有声明未来、选项、列表等吗?成为单子文章中的单子?是的,我做了。所有的单子都是函子;你可以从面向对象的角度来看,单子是函子的一个子类(或子类型)。好吧,不要到处对每个人说这个,因为如果你偶然发现范畴理论的数学家,他们可能会开始谈论函子只是元素的映射和范畴之间的态射,而单子实际上是内函子范畴中的幺半群,自然变换被定义为函子合成和恒等式函子。我不是在开玩笑;他们实际上是这样说话的。但是,是的,从程序员的角度来看,完全有理由说单子实际上是一个函子,还有额外的东西。更具体地说,在 functor 的 map 之上,monad 有一个flat(也称 join ),这使得它能够定义 map 的升级版本——我们钟爱的 flatMap (也称 bind ) 。记住,单子还有一个不太有趣,但同样重要的单元方法。
记得单子定律吗?我们这里也有一些法律。假设 m 是我们的仿函数实例(例如 List 或 Future ),它保存一些值(例如 Int ),函数 f 和 g 是转换该值的单参数函数(在我们的例子中,它们必须具有签名 Int → Something ),那么我们可以定义两个仿函数定律如下:
- 同一律 : map id = id 或 Scala-way:m . map(identity)= = m
- 分配律 : F 图 F 图 g = F 图(g♀F) 或 Scala-way: m.map(f)。map(g)= = m . map(x =>g(f(x))
这里 identity 是 Scala 中 predef 包中定义的 identity 函数(给定一个值,它只是返回相同的值),而♀是函数组合的标准数学符号(意思是“f 之后是 g”,或者“应用 f 然后是 g”)。注意,如果你想在 Scala 中组合两个函数,你可以使用 compose 或then;然而,这不是本文的重点,所以我选择了最简单的形式,简单地一个接一个地应用它们: g(f(x)) 。
重物提升
我们现在将 curry 它,而不是 map()接受两个参数。这意味着函数例如 (Int,Int) → Int 现在将变成 Int → Int → Int 。记住这是右联的,所以和 Int → (Int → Int) 一样。如果你不熟悉 currying,我们将很快回到它。
让我们以一个列表作为函子;我们的起点(也就是我们的起始仿函数实例)可以是,例如,List(1,2,3)。我们还可以使用一些函数来映射我们的列表,例如:
val f = (x: Int) => x.toString
在 Scala 中,我们将这个列表映射为简单的列表(1,2,3)。地图(f)。现在,如果我们像以前一样改变我们的视点,使 map 变成一个二元函数,我们可以调用它作为 map(List(1,2,3),f) 。第一个参数是仿函数实例,第二个参数是函数。(注意,我这里是补句法;我只是在阐述一个概念。在所有标准的 Scala 构造中,map()总是一个类方法,在该类的一个实例上被调用,并且它只有一个参数——映射函数。这是惯例。)
我们说过我们会更进一步,所以我们的 map 函数的调用现在变成了这样(注意,我交换了参数的位置,因为这样更容易说明我的观点;如果函数的参数在签名中的顺序被打乱,函数不需要改变它的实现):
map(f)(List(1, 2, 3))
Currying 就是取一个 n 参数的函数,把它变成 n 单参数函数。任何 n 参数函数都可以这样进行;实际上,在 Haskell 中,这是实现具有多个参数的函数的唯一方法。比如说。你不能有一个取两个数并相乘的函数,你只能有一个取一个数并返回一个数的函数。这样就像我们之前说的, (Int,Int) → Int 变成了 Int → Int → Int 。或者如果你更喜欢显式地写优先级,它就变成了 Int → (Int → Int )。
所以我们的地图现在实际上是一个单参数函数,它返回另一个单参数函数。这允许我们像这样传递第一个参数:
map(f) // returns a function List[Int] => List[String]
如果我们继续并将我们的列表(1,2,3)传递给 map(f)的结果,我们将得到一个列表(“1”,“2”,“3”)。但是如果我们在这里停止,我们得到的是一个函数,它接受一个整数列表并返回一个字符串列表。Curried 方法给出了一个高阶函数,它将 typeA → typeB 映射到函子【typeA】→函子【typeB】。这种高阶函数俗称 lift 。它接受一个函数并“提升”它,这样它的操作数就被放入某个上下文中(比如一个函子或一个单子)。
这意味着我们可以这样编写 map()的签名:
(A → B) → F[A] → F[B]
这是在很多函数式编程书籍和编程语言中都能找到的经典地图签名。由于它是两个 curried 函数的链,我们可以从两个方面来看待它:
- map 采用一个函数 A → B 和一个仿函数实例 F[A] ,并产生另一个仿函数实例 F[B] (我们的经典非简化 map)
- map 采用函数 A → B 并返回函数 F[A] → F[B]
正是谄媚的可怕本质让我们有了这两种不同的观点。
函子的问题
我们看到了地图功能的三种不同表示:
- Scala 方式:
- 作为一个双参数函数: map(m,f)
- 作为约定函数:映射(m)(f) 或映射(f)(m)
尽管第二种和第三种方法在函数式编程领域更为常见,但 Scala,不仅是 FP 语言,也是 OOP 语言,决定选择第一种,因为它更接近于面向对象的范式。
现在,我们知道 map()只接受一个函数的一个参数——这个参数与函子中的底层值属于同一类型。例如,整数列表上的 map 采用一个接受单个 Int 的函数(但它可以返回任何类型)。
然而,我们也知道使用奉承我们可以欺骗一点。如果我们有一个有 n 个参数的函数,我们可以把它转换成一个只有一个参数的函数,返回一个有 n-1 个参数的函数。这将允许我们为 map()提供函数和任意数量的参数(当然,第一个参数必须是合适的类型,以匹配封装在函子中的参数,例如 Future[Int] 可以用函数Int→Whatever→Whatever→Whatever→…)进行映射。
想法不错,但是 map()不太喜欢。
为什么?一个简单的例子可以说明这个问题。让我们用一个将两个整数相加的函数来处理它:
val f = (x: Int) => (y: Int) => x + y
如果我们给这个函数提供一个值,我们将得到一个比原始函数少一个参数的函数,这样我们就只有一个参数(在我们的例子中称为“y”)。因此,如果我们给函数 f 输入值 42,我们将得到一个函数,它接受一个数字并给它加上 42。
酷毙了。因此,让我们将我们的 curried 函数提供给 map():
val f = (x: Int) => (y: Int) => x + yval result = Future(42).map(f)// result is Future(x => x + 42)
我们得到的是一个未来,其中我们的整数 42 被转换成一个函数,该函数接受某个整数 x 并返回 x +42 。现在这有点麻烦了。正如我们之前说过的,通常当你找到一个有 n 个参数的函数,并给它提供第一个参数时,你会得到一个有 n-1 个参数的函数。如果我们的函数 f 将两个数相加,我们将得到一个函数 Int → Int 。但是当我们把那个函数提供给 map(),我们得到的不是 Int → Int ,而是Future【Int→Int】。我们不能再继续将东西应用到 curried 函数的其余部分,因为 map()不知道如何处理包装在 future 中的函数。
让我再重复一遍,这是关键部分。因此,如果我们想用某个函数来映射我们的未来,比如说,这次是四个参数,比如 f(a: Int,b: Int,c: Int,d: Int) ,我们会将函数转换成 Int→ Int→ Int→ Int 并将其提供给 map(),但这就是全部。Map 会消耗第一个参数,但不会留给我们 Int→ Int→ Int 。如果是的话,我们可以以同样的方式继续下去。相反,它留给我们的是 Future[Int → Int → Int] 。
我们现在需要的是一个适用函子。
应用函子
适用函子(有时简称为适用的)是我们前面看到的通用函子的升级版本。虽然函子只使用单参数函数,但应用函子可以使用任意数量参数的函数。他们还引入了 unit/return 方法,该方法将给定的类型 A 提升到 F[A]中。(注:适用函子还附带了与普通函子稍有不同的 套法则;我不会在这里深入讨论它们,因为我的意图只是解释适用函子的概念,而将“机械的”部分(如定律)留给你个人研究,一旦你熟悉了这个概念)
让我们再来考虑一些四参数函数 f 的简化形式。它的签名是 Int → Int → Int → Int。现在我们把它喂给某个未来的 map()【Int】。结果是未来[Int → Int → Int] 。一分钟前我们已经讨论过了。好的,但是现在很酷的事情发生了:我们的新朋友应用函子开始发挥作用,并且说:“嘿,你已经把你的函数包装到一点上下文中了,嗯?别担心,我知道如何应用这些包装函数。是的,你听得很清楚;我可以在仿函数上下文(如 Future、Option 或 List)中应用函数。为了避免混淆,我们将把术语“映射”留给标准函子,而在应用函子中使用术语“应用”来表示这个新的酷函数。因此,如果映射被定义为(使用柯里化符号):
- 映射[A,B](f: F[A])(f: A → B): F[B]
然后我们的新方法 apply()被定义为:
- *应用A,B(F:F【A→B】):F【B】*
定义适用函子还有两种方法:用同样强大的映射 2 替换应用,或者用乘积和映射的组合替换。
以下是所有三个定义:
- 单位[A](a: A): F[A] 应用[A,B](f: F[A])(f: F[A → B]): F[B]
- 单位[A](a: A): F[A] map2[A,B,C](fa: F[A],fb: F[B])(f: (A,B) = > C): F[C]
- 单位[A](a: A): F[A] 映射A(F:A =>B):F[B] 乘积[A,B](fa: F[A],fb: F[B]): F[(A,B)]
当我说方法应用和映射 2 是“同等强大”的时候,我的意思是你可以通过使用另一个来表达一个。产品稍弱,需要配图。我不会在这里展示这些定义之间的翻译,但是你可以在网上找到它们(例如这里的)或者试着自己解决它们。
我们现在将使用 apply 版本,但是我将向您展示它与其他两个定义的关系。
解决函子问题
好吧。那么如果我们用函数 Int → Int → Int → Int 来喂未来的 map()会怎么样呢?我们得到一个未来[Int → Int → Int]。然后我们可以用 Future[Int → Int → Int] 来填充它的 apply(),得到一个 FFuture[Int→Int]。最后,我们应用 Future[Int → Int] ,剩下 Future[Int]。
让我们完成前面的“加 42”的例子。请注意,我将编写更多的伪 Scala,因为将来不会有以这种方式定义的 apply()方法。现在,假设这样的方法存在,我们稍后将解释这种困境。
因此,为了给函子提供一个双参数函数,我们要做:
val f = (x: Int) => (y: Int) => x + y
val r1: Future[Int => Int] = Future(42).map(f)
Future(10).apply(r1) // results in Future(52)
你看到这里发生了什么吗?我们采用了 Future[Int]类型的两个值(Future(42)和 Future(10))和一个函数 f ,签名为 Int → Int ,我们设法生成了另一个 Future,其潜在值是将我们的两个起始 Future 的值应用于函数 f 的结果。这真是太棒了。假设您从数据库中获取三角形的三个点,最后得到三个 Future[Int] 值。如何用函数 calculate(a: Int,b: Int,c: Int) 计算由那些点组成的三角形的周长?你不能只把点提供给函数,因为点不是 Int 类型的,而是 Future[Int]类型的。
使用应用函子可以:
val f1 = Future(3)
val f2 = Future(4)
val f3 = Future(5)val calculate = (a: Int) => (b: Int) => (c: Int) => a + b + c// btw you can also do:
// val calculate = ( (a:Int, b:Int, c:Int) => a + b + c ).curriedf1.apply(f2.apply(f3.apply(unit(calculate)))) // Future(12)
很酷,对吧?注意,我们必须通过使用单元将函数包装到应用上下文中,以便应用能够使用它。
伪 Scala 的东西已经够多了。让我们写一些实际的代码。我们将使用 scalaz 库:
import scalaz._, Scalaz._val f1 = Future(3)
val f2 = Future(4)
val f3 = Future(5)val calculate = (a: Int) => (b: Int) => (c: Int) => a + b + c
val area = f1 <*> (f2 <*> (f3 <*> *Future*(calculate))) // Future(12)
操作器是对施加的操作。对于单元,我简单使用了未来构造函数。
还记得我们讨论过应用程序的三种不同定义吗?让我们看看如何使用产品+地图:
import scalaz._, Scalaz._val f1 = Future(3)
val f2 = Future(4)
val f3 = Future(5)val calculate = (a: Int) => (b: Int) => (c: Int) => a + b + c
val area = (f1 |@| f2 |@| f3)(calculate)// Future(12)
运算符|@|是的乘积运算。我们在这里所做的是将三个应用程序放在一起,并将它们“合并”成一个(我们计算了它们的乘积,然后我们用我们的计算函数映射那个乘积。注意,我们没有显式调用 map(),因为这是适用产品的 scalaz 语法的工作方式;通过使用|@|将您的应用程序组合到一个产品中,会产生一个 ApplicativeBuilder,它接受一个在产品上执行的函数(因为产品+地图是一个非常常见的用例)。注意一个细节:计算不能在这里进行,因为应用产品采用一个无约束的多参数函数(在本例中为 arity 3)。我
如果使用产品定义比使用应用定义更容易推断整个过程,那实际上是很正常的。我已经向你展示了两种方法,你可以自由选择你喜欢的。你能用单位 + 应用做的事情,你能用单位 + 产品 + 地图做,反之亦然。第三个定义也是一样,单位+地图 2 (我会让你自己去研究那个定义;反正它已经从 scalaz7 中移除了,所以如果你决定跳过它也没关系)。
顺便说一下,敏锐的读者会注意到产品版本实际上在常规 Scala 中是可行的。是的,那是一个拉链。
val area = (f1 zip f2 zip f3) map {
case ((a, b), c) => calculate(a, b, c)
} // Future(12)
有点笨拙,但可行。
结论
“常规”函子基本上是某种类型值的包装器。List[Int]、Future[String]和 Option[Whatever]都是函子的例子。
可应用的函子稍微复杂一些——它们知道如何应用包装在函子上下文中的函数。比如给定一个 Future[Int]函子,我们可以对它应用一个函数 Future[Int → Int] ,而正则函子中的正则 map()只知道如何应用 Int → Int。或者,你可以这样看:在正则函子的映射功能之上,应用性函子增加了单元函数,它将一个普通的 A 提升到函子上下文 F[A]中,乘积函数,它可以用来将多个函子合二为一。
因此,有多种描述应用函子的方法,可以得到相同的基本原理。适用函子类似于函子,但是:
- 应用函子可以应用多个参数的函数
- 应用函子可以应用包装在函子上下文中的函数
- 应用函子可以将多个函子组合成一个乘积
- 等等。
在我们结束之前,还有一个有趣的问题:
你可能已经知道了一些单子,也可能还不知道。嗯,单子更进一步,给你提供了一个更强大的操作。这是它们的比较,三个都是(我省略了单元,因为它不是说明这一点所必需的):
- 函子:映射(f: F[A])(f: A → B): F[B]
- 适用函子 : 适用(f: F[A])(f: F[A → B]): F[B]
- 单子 : 平面图(f: F[A])(f: A → F[B]): F[B]
正如应用程序可以使用其他定义一样(例如, product + map 而不是 apply ),单子也是如此(例如, flatten + map 而不是 flatMap )。单子是最强大的;它们可以是“有条件的”,这意味着一个单子可以根据前一个单子操作的结果采取行动。或者,如果您愿意,它们可以串行工作,而应用程序可以并行工作。看到单子定义里那个 f: A → F[B] 了吗?这是实现这种依赖性的部分;它说“从单子 F[A]中取 A,并基于它计算单子 F[B]”。
因此,如果你有 n 个异步请求(比如数据库查询)导致 n 个未来,你应该根据你的需要选择一个合适的抽象:
- 如果你的未来不互相依赖,使用一个应用程序(例如,你可以把它们放入一个产品中,并用一个处理成功/失败的函数映射每个结果)
- 如果你的未来确实相互依赖,例如,你想连续调用它们,并在第一次成功或第一次失败时停止,那么使用一个单子(它允许你用一个函数来平面映射一个未来,该函数检查它的值并基于它执行下一个单子操作)
这就是为什么你只能通过使用单子为上下文相关的语法构建解析器,而对于上下文无关的语法,使用应用程序就足够了。
好了,暂时就这些了。像往常一样,如果你觉得有什么不清楚、令人困惑、误导或不正确的地方,请在这里给我留言,或者给我发一封关于 [email protected] 的电子邮件。也可以随时在推特上找我。
干杯!
黑客中午是黑客如何开始他们的下午。我们是 @AMI 家庭的一员。我们现在接受投稿并乐意讨论广告&赞助机会。
要了解更多信息,请阅读我们的“关于”页面 , 喜欢/在脸书给我们发消息,或者简单地,发推文/DM @HackerNoon。