1. Introduction

今天我们将更深入地探讨函数复合。虽然函数组合看起来很简单,但要将我们每天使用的现有函数融入到我们的组合中并不总是那么容易。让我们探索一些可重用的技术,这些技术可以帮助我们将难以组合成合适的组件的功能进行转换。

2. Curry

在关于副作用的那一集中,我们通过将对世界的可变状态的依赖转移到函数的inputs来控制函数的副作用。

func greet(at date: Date, name: String) -> String {
  let seconds = Int(date.timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(seconds) seconds past the minute."
}

我们不能将其他函数组合到这个函数中,因为它接受两个输入,但我们发现可以通过一个技巧来解决这个问题,即预先依赖Date并立即返回一个全新的合成函数。

func greet(at date: Date) -> (String) -> String {
  return { name in
    let seconds = Int(date.timeIntervalSince1970) % 60
    return "Hello \(name)! It's \(seconds) seconds past the minute."
  }
}

我们以一种非常特别的方式修正了这个问题,但我们可以一般化这个过程,引入接受多个参数的现有函数,并生成一个接受单个参数的嵌套函数链。 这叫做currying(柯里化)。

func curry<A, B, C>(_ f: @escaping (A, B) -> C) -> (A) -> (B) -> C {
  return { a in { b in f(a, b) } }
}

现在我们有一个通用函数,给定一个从两个参数A和B到C的函数,它返回一个在A中有一个参数的函数并返回一个从B到C的新函数。

让我们看看greet函数。

greet(at:name:) // (Date, String) -> String

Swift允许我们通过指定参数而省略数据来引用函数或方法。

当我们向它输入原始的greet函数时会发生什么?

curry(greet(at:name:)) // (Date) -> (String) -> String

这看起来就像我们的manually-curried版本。

greet(at:) // (Date) -> (String) -> String

如果你想知道为什么它被称为“curry”,它是以逻辑学家和数学家Haskell curry的名字命名的,编程语言Haskell也是以这个名字命名的。 当他推广currying的想法时,摩西Schönfinkel发现了它。有些人想把这个过程称为“Schönfinkelization”,但幸运的是,我们不需要经常拼写这个单词。

在Swift标准库、核心框架和第三方代码中,我们每天都要处理大量的多参数函数。 通过currying,我们现在可以使用这些函数并将它们放入我们的合成中。

例如,我们有一个同时接受Data和Encoding的String初始化式:

String.init(data:encoding:)
// (Data, String.Encoding) -> String?

Let’s curry it.

curry(String.init(data:encoding:))
// (Data) -> (String.Encoding) -> String?

现在我们有了一个全新的函数,它可以更好地组合到我们的管道中。

3. Flip

当我们curry这些函数并尝试用它们组合时,我们开始看到一种模式。 多参数函数似乎通常以一种非常特定的方式排列它们的参数:它们将重要的、手边的数据作为第一个参数,然后是任何配置选项。

在这个String初始化式中,我们有一个函数,它先取数据,然后取编码。当我们curry它的时候,它似乎仍然不能和我们的程序组合。在运行时最相关的数据是data作为输入,可选的String作为输出,但我们这里有一个函数,它将data作为输入并返回一个新函数,(String.Encoding) -> String?作为输出。

我们可以组合成一个新函数来处理这个配置。

curry(String.init(data:encoding:)) // (Data) -> (String.Encoding) -> String?
  >>> { $0(.utf8) }
// (Data) -> String?

这有点尴尬,特别是当我们考虑到我们必须在使用这些函数的任何地方偷偷地使用这个配置时。我们想要的是没有重复的可重用性。在这种情况下,编码通常是固定的配置,通常是UTF-8,所以减少或对调用者隐藏这个细节是有意义的。 其中一种方法是将配置提前,但curry似乎改变了调用函数时所关心的参数顺序。

我们应该能够推广翻转函数参数的过程。

func flip<A, B, C>(_ f: @escaping (A) -> (B) -> C) -> (B) -> (A) -> C {
  return { b in { a in f(a)(b) } }
}

让我们试着在String初始化式中使用它。

flip(curry(String.init(data:encoding:))) // (String.Encoding) -> (Data) -> String?

我们现在可以将其存储在helper中。

let stringWithEncoding = flip(curry(String.init(data:encoding:)))

我们可以为最常见的情况推出另一个辅助方法。

let utf8String = stringWithEncoding(.utf8) // (Data) -> String?

现在我们有了更好的组件。Data分散在我们的应用程序中:我们从api获取数据,从磁盘读取数据。 同时,我们可以将UTF-8编码推向极限,因为我们只需要为每个调用配置一次。

到目前为止,我们只构建了这些工具来处理带有2个参数的函数,但我们可以编写任意数量的curry重载,接受带有3个、4个或更多参数的函数。我们的flip函数也被推广到只翻转curry函数的前两个参数。如果我们想要颠倒3个或更多参数的顺序,事情可能看起来更棘手。我们得留到以后的一集。

4. Unbound methods

在我们的工具链中有了这些通用的flipcurry函数,我们现在可以很容易地将功能组合出来,而这些功能可能不是现成的。但是仍然有大量的代码看起来是不可获取的:methods。

Swift在这里做了一些很好的事情:它为类型上的每个实例方法生成静态函数。这很好,因为我们可以在没有数据的情况下抽象地引用这个功能。让我们看一个示例方法调用:

"Hello".uppercased(with: Locale(identifier: "en")) // "HELLO"

我们可以在类型本身上引用这个方法:

String.uppercased(with:) // (String) -> (Locale?) -> String

哈!这是怎么回事? We have a curried function from String to Locale? to String again. 我们现在无需数据就可以访问方法逻辑! 让我们试着用一下。

String.uppercased(with:)("Hello") // (Locale?) -> String

现在我们需要插入locale。

String.uppercased(with:)("Hello")(Locale.init(identifier: "en")) // "HELLO"

它成功了,但我们的ordering问题又来了! 我们被迫提前提供数据,而配置(Locale)则被延迟。 Luckily, we have flip!

flip(String.uppercased(with:)) // (Locale?) -> (String) -> String

这个看起来不错。我们重新排列了配置的优先级,这样,给定一个Locale,我们就有一个可重用的函数来获取数据。我们现在可以从这个基本函数创建helpers

let uppercasedWithLocale = flip(String.uppercased(with:))

我们可以进一步创建更具体的助手。

let uppercasedWithEn = uppercasedWithLocale(Locale(identifier: "en"))

我们终于可以输入一些数据了。

"Hello" |> uppercasedWithEn // "HELLO"

非常神奇!这是一个简单的例子,但我们已经看到这些类型的函数很容易组合! 我们每天都要处理大量现有代码,很高兴看到当这些函数和方法无法组合时,我们可以使用一些小工具来解决这个问题! 看来我们都搞清楚了!

5. A problem

不完全是。让我们看另一个例子。String上还有另一个大写方法。

String.uppercased // (String) -> () -> String

这个函数签名看起来有点滑稽,但我们可以理解它。它中间有一个空的参数列表,而不是我们前面看到的locale

如果我们回想一下静态curry方法,SelfString,它是一个需要被调用的方法,但调用时不带任何参数,也就是empty(),在返回String之前。

让我们试试翻转它:

flip(String.uppercased) // (Locale?) -> (String) -> String

发生了什么事? 该Locale?哪里来的? 从何而来? 这个方法在Swift中是重载的,在没有指定参数的情况下,最好使用将与flip函数一起编译的版本。我们定义flip的方式,对零参数方法不起作用。

这应该很容易修复!让我们定义一个重载。

func flip<A, C>(_ f: @escaping (A) -> () -> C) -> () -> (A) -> C {
  return { { a in f(a)() } }
}

这看起来有点有趣,但我们的函数现在像我们期望的那样翻转。

flip(String.uppercased) // () -> (String) -> String

那么我们如何使用这个函数呢?在它当前的状态下,它没有参数,所以我们叫它。

flip(String.uppercased)() // (String) -> String

这看起来很奇怪,但更好,我们终于可以发送数据了。

"Hello" |> flip(String.uppercased)() // "HELLO"

让我们再看看这个。

flip(String.uppercased)   // () -> (String) -> String
flip(String.uppercased)() // (String) -> String

这些括号。看起来我们有另一个括号的问题。

6. Zurry

那么,我们能用这些函数做什么呢? 我们唯一能做的就是求出它们的值并从中得到A类型的值。 你可以把它想象成一个零参数的Curry(柯里化):

func zurry<A>(_ f: () -> A) -> A {
  return f()
}

它只是一个lil helper函数,它将一个不带参数的函数降低为它返回的值。

现在我们可以使用翻转的uppercased函数并对它进行zury。

zurry(flip(String.uppercased)) // (String) -> String

我们可以通过管道传输数据。

"Hello" |> zurry(flip(String.uppercased)) // "HELLO"

Zury是个不错的主意!它有一个可爱的名字,它是一个具有简单任务的函数,它有助于解决我们的括号问题。我们可以通过直接调用翻转的零参数函数来接受它或放弃它,但无论何时我们处理这个问题时,zury仍然是一个有趣的概念。

7. Higher-order

我们从curry、flip和zury中获得了很多优势! 有趣的是它们都是以函数为输入,以函数为输出的函数。我们的很多合成函数都是这样做的! 这些函数被称为“高阶函数(higher order functions)”,看看它们是如何与复合函数一起工作的,这很有趣。

让我们来探索另一个例子,一个我们经常使用但可能在方法世界中合成不好的方法:

[1, 2, 3]
  .map(incr)
  .map(square)

在方法的世界里,复合很难看到,所以让我们把它带入自由函数的世界。我们能用我们的新工具吗? 如果我们从curry开始,它看起来像什么?

curry(Array.map)
// Cannot convert value of type '(Array<_>) -> ((_) throws -> _) throws -> [_]' to expected argument type '(_, _) -> _'

我们有麻烦了。数组是泛型的。我想我们知道这里用的是Int,所以我们可以约束它。

curry([Int].map)
// Cannot convert value of type '([Int]) -> ((Int) throws -> _) throws -> [_]' to expected argument type '(_, _) -> _'

map方法的返回值也是泛型的,所以看起来我们需要指定整个东西。

curry([Int].map as ([Int]) -> ((Int) -> Int) -> [Int])
// Cannot convert value of type '([Int]) -> ((Int) throws -> _) throws -> [_]' to type '([Int]) -> ((Int) -> Int) -> [Int]' in coercion

它还是不快乐! 有很多我们没考虑到的throws

这看起来不妙:curry(可能还有flip)似乎在涉及泛型和抛出的复杂情况下变得不那么有用了。

让我们避免这些问题,手工编写一个免费的功能版本的map。我们可以用自己的术语来定义它! 让我们通过先使用可配置的transform函数,将一个新函数从数据数组返回到一个新数组,来flipcurry内容。

func map<A, B>(_ f: @escaping (A) -> B) -> ([A]) -> [B] {
  return { $0.map(f) }
}

让我们试一下。

map(incr) // ([Int]) -> [Int]

这是整洁的。我们有一个从[Int]到[Int]的全新函数。

map(square) // ([Int]) -> [Int]

另一个可重用的函数。

map(incr) >>> map(square) // ([Int]) -> [Int]

通过释放map,它以一种非常有趣的方式变得可重用。我们甚至可以改变类型。

map(incr) >>> map(square) >>> map(String.init) // ([Int]) -> [String]

我们已经建立了另一个没有数据的管道!

我们以前也讨论过这个问题:组合map与一次性组合函数传递给map是一样的。

map(incr >>> square >>> String.init) // ([Int]) -> [String]

这样也更有效率。

还有其他的高阶函数。像filter

Array(1...10)
  .filter { $0 > 5 }
// [6, 7, 8, 9, 10]

让我们定义一个自由filter函数。

func filter<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> [A] {
  return { $0.filter(p) }
}

因为我们对这个函数进行了curry处理,并遵循了参数排序的规则,所以我们可以非常容易地构建轻量级的、可重用的过滤函数。

filter { $0 > 5 } // ([Int]) -> [Int]

此外,这些过滤函数组成了我们的map函数。

filter { $0 > 5 }
  >>> map(incr)
// ([Int]) -> [Int]

我们可以进一步按照直觉指导的方式进行创作。

filter { $0 > 5 }
  >>> map(incr >>> square)
// ([Int]) -> [Int]

我们甚至在没有数据的情况下构建了一个很好的功能! 在使用程序逻辑之前,我们可以用一种抽象的方式来描述它。让我们来看看它的作用:

Array(1...10)
  |> filter { $0 > 5 }
  >>> map(incr >>> square)
// [49, 64, 81, 100, 121]

还有很多其他的高阶函数等着被curried,flipped,composed在一起。

8. What’s the point?

是时候问问我们自己,“这有什么意义?” 我们引入了curry, zury和flip作为可重复使用的机器,以改变功能的形状和解锁组成。当它工作时很好,但我们很快就遇到了一些限制。是否值得将这些函数引入到我们的代码库中?

我们是这样认为的! 当它们发挥作用时,效果非常好,为我们节省了大量的样板文件。当它们不起作用时,我们仍然在使用概念并为它们建立直觉。currying和flipping的想法很简单,它们将在未来解锁很多构图。