Scala初学指南之函数的部分应用和柯里化

x33g5p2x  于2021-03-14 发布在 Scala  
字(4.9k)|赞(0)|评价(0)|浏览(363)

上一章重点在于代码重复:提升现有的函数功能、或者将函数进行组合。这一章,我们来看看另外两种函数重用的机制:函数的部分应用(Partial Application of Functions)柯里化(Currying)

部分应用的函数

和其他遵循函数式编程范式的语言一样,Scala 允许_部分_应用一个函数。调用一个函数时,不是把函数需要的所有参数都传递给它,而是仅仅传递一部分,其他参数留空;这样会生成一个新的函数,其参数列表由那些被留空的参数组成。(不要把这个概念和偏函数混淆)

为了具体说明这一概念,回到上一章的例子:假想的免费邮件服务,能够让用户配置筛选器,以使得满足特定条件的邮件显示在收件箱里,其他的被过滤掉。

Email 类看起来仍然是这样:

case class Email(
  subject: String,
  text: String,
  sender: String,
  recipient: String)
type EmailFilter = Email => Boolean

过滤邮件的条件用谓词 Email => Boolean 表示, EmailFilter 是其别名。调用适当的工厂方法可以生成这些谓词。

上一章,我们创建了两个这样的工厂方法,它们检查邮件内容长度是否满足给定的最大值或最小值。这一次,我们使用部分应用函数来实现这些工厂方法,做法是,修改 sizeConstraint ,固定某些参数可以创建更具体的限制条件:

其修改后的代码如下:

    type IntPairPred = (Int, Int) => Boolean
    def sizeConstraint(pred: IntPairPred, n: Int, email: Email) =
      pred(email.text.size, n)

上述代码为一个谓词函数定义了别名 IntPairPred ,该函数接受一对整数(值 n 和邮件内容长度),检查邮件长度对于 n 是否 OK。

请注意,不像上一章的 sizeConstraint ,这一个并不返回新的 EmailFilter,它只是简单的用参数做计算,返回一个布尔值。秘诀在于,你可以部分应用这个 sizeConstraint 来得到一个 EmailFilter

遵循 DRY 原则,我们先来定义常用的 IntPairPred 实例,这样,在调用 sizeConstraint 时,不需要重复的写相同的匿名函数,只需传递下面这些:

    val gt: IntPairPred = _ > _
    val ge: IntPairPred = _ >= _
    val lt: IntPairPred = _ < _
    val le: IntPairPred = _ <= _
    val eq: IntPairPred = _ == _

最后,调用 sizeConstraint 函数,用上面的 IntPairPred 传入第一个参数:

    val minimumSize: (Int, Email) => Boolean = sizeConstraint(ge, _: Int, _: Email)
    val maximumSize: (Int, Email) => Boolean = sizeConstraint(le, _: Int, _: Email)

对所有没有传入值的参数,必须使用占位符 _ ,还需要指定这些参数的类型,这使得函数的部分应用多少有些繁琐。Scala 编译器无法推断它们的类型,方法重载使编译器不可能知道你想使用哪个方法。

不过,你可以绑定或漏掉任意个、任意位置的参数。比如,我们可以漏掉第一个值,只传递约束值 n

    val constr20: (IntPairPred, Email) => Boolean =
      sizeConstraint(_: IntPairPred, 20, _: Email)

    val constr30: (IntPairPred, Email) => Boolean =
      sizeConstraint(_: IntPairPred, 30, _: Email)

得到的两个函数,接受一个 IntPairPred 和一个 Email 作为参数,然后利用谓词函数 IntPairPred 把邮件长度和 2030 比较,只不过比较方法的逻辑 IntPairPred 需要另外指定。

由此可见,虽然函数部分应用看起来比较冗长,但它要比 Clojure 的灵活,在 Clojure 里,必须从左到右的传递参数,不能略掉中间的任何参数。

从方法到函数对象

在一个方法上做部分应用时,可以不绑定任何的参数,这样做的效果是产生一个函数对象,并且其参数列表和原方法一模一样。通过这种方式可以将方法变成一个可赋值、可传递的函数!

    val sizeConstraintFn: (IntPairPred, Int, Email) => Boolean = sizeConstraint _

更有趣的函数

部分函数应用显得太啰嗦,用起来不够优雅,幸好还有其他的替代方法。

也许你已经知道 Scala 里的方法可以有多个参数列表。下面的代码用多个参数列表重新定义了 sizeConstraint

    def sizeConstraint(pred: IntPairPred)(n: Int)(email: Email): Boolean =
      pred(email.text.size, n)

如果把它变成一个可赋值、可传递的函数对象,它的签名看起来会像是这样:

    val sizeConstraintFn: IntPairPred => Int => Email => Boolean = sizeConstraint _

这种单参数的链式函数称做 柯里化函数 ,以发明人 Haskell Curry 命名。在 Haskell 编程语言里,所有的函数默认都是柯里化的。

sizeConstraintFn 接受一个 IntPairPred ,返回一个函数,这个函数又接受 Int 类型的参数,返回另一个函数,最终的这个函数接受一个 Email ,返回布尔值。

现在,可以把要传入的 IntPairPred 传递给 sizeConstraint 得到:

    val minSize: Int => Email => Boolean = sizeConstraint(ge)
    val maxSize: Int => Email => Boolean = sizeConstraint(le)

被留空的参数没必要使用占位符,因为这不是部分函数应用。

现在,可以通过这两个柯里化函数来创建 EmailFilter 谓词:

    val min20: Email => Boolean = minSize(20)
    val max20: Email => Boolean = maxSize(20)

也可以在柯里化的函数上一次性绑定多个参数,直接得到上面的结果。传入第一个参数得到的函数会立即应用到第二个参数上:

    val min20: Email => Boolean = sizeConstraintFn(ge)(20)
    val max20: Email => Boolean = sizeConstraintFn(le)(20)

函数柯里化

有时候,并不总是能提前知道要不要将一个函数写成柯里化形式,毕竟,和只有单参数列表的函数相比,柯里化函数的使用并不清晰。而且,偶尔还会想以柯里化的形式去使用第三方的函数,但这些函数的参数都在一个参数列表里。

这就需要一种方法能对函数进行柯里化。这种的柯里化行为本质上也是一个高阶函数:接受现有的函数,返回新函数。这个高阶函数就是 curriedcurried 方法存在于 Function2Function3 这样的多参数函数类型里。如果存在一个接受两个参数的 sum ,可以通过调用 curried 方法得到它的柯里化版本:

    val sum: (Int, Int) => Int = _ + _
    val sumCurried: Int => Int => Int = sum.curried

使用 Funtion.uncurried 进行反向操作,可以将一个柯里化函数转换成非柯里化版本。

函数化的依赖注入

在这一章的最后,我们来看看柯里化函数如何发挥其更大的作用。来自 Java 或者 .NET 世界的人,或多或少都用过依赖注入容器,这些容器为使用者管理对象,以及对象之间的依赖关系。在 Scala 里,你并不真的需要这样的外部工具,语言已经提供了许多功能,这些功能简化了依赖注入的实现。

函数式编程仍然需要注入依赖:应用程序中上层函数需要调用其他函数。把要调用的函数硬编码在上层函数里,不利于它们的独立测试。从而需要把被依赖的函数以参数的形式传递给上层函数。

但是,每次调用都传递相同的依赖,是不符合 DRY 原则的,这时候,柯里化函数就有用了!柯里化和部分函数应用是函数式编程里依赖注入的几种方式之一。

下面这个简化的例子说明了这项技术:

    case class User(name: String)
    trait EmailRepository {
      def getMails(user: User, unread: Boolean): Seq[Email]
    }
    trait FilterRepository {
      def getEmailFilter(user: User): EmailFilter
    }
    trait MailboxService {
      def getNewMails(emailRepo: EmailRepository)(filterRepo: FilterRepository)(user: User) =
        emailRepo.getMails(user, true).filter(filterRepo.getEmailFilter(user))
      val newMails: User => Seq[Email]
    }

这个例子有一个依赖两个不同存储库的服务,这些依赖被声明为 getNewMails 方法的参数,并且每个依赖都在一个单独的参数列表里。

MailboxService 实现了这个方法,留空了字段 newMails,这个字段的类型是一个函数: User => Seq[Email],依赖于 MailboxService 的组件会调用这个函数。

扩展 MailboxService 时,实现 newMails 的方法就是应用 getNewMails 这个方法,把依赖 EmailRepositoryFilterRepository 的具体实现传递给它:

    object MockEmailRepository extends EmailRepository {
      def getMails(user: User, unread: Boolean): Seq[Email] = Nil
    }
    object MockFilterRepository extends FilterRepository {
      def getEmailFilter(user: User): EmailFilter = _ => true
    }
    object MailboxServiceWithMockDeps extends MailboxService {
      val newMails: (User) => Seq[Email] =
        getNewMails(MockEmailRepository)(MockFilterRepository) _
    }

调用 MailboxServiceWithMockDeps.newMails(User("daniel") 无需指定要使用的存储库。在实际的应用程序中,这个服务也可能是以依赖的方式被使用,而不是直接引用。

这可能不是最强大、可扩展的依赖注入实现方式,但依旧是一个非常不错的选择,对展示部分函数应用和柯里化更广泛的功用来说,这也是一个不错的例子。如果你想知道更多关于这一点的知识,推荐看 Debasish Ghosh 的幻灯片“Dependency Injection in Scala”。

总结

这一章讨论了两个附加的可以避免代码重复的函数式编程技术,并且在这个基础上,得到了很大的灵活性,可以用多种不同的形式重用函数。部分函数应用和柯里化,这两者或多或少都可以实现同样的效果,只是有时候,其中的某一个会更为优雅。下一章会继续探讨保持灵活性的方法:类型类(type class)。

相关文章

微信公众号

最新文章

更多