1. Introduction

在Swift 4.1中提出了一个更改,该更改将作废flatMap的重载,这与其他flatMap略有不同,并为该方法提供了一个新名称。 这一改变得到了褒贬不一的回应,但最终还是被接受了,尽管由于社区的反馈,名称又作了进一步的更改。

我们想要研究的是为什么这个改变会在一开始被提出,以及为什么第一个被提出的名字可能会让我们对进一步的概念开放。我们希望表明,有时命名真的很重要,可以帮助我们以新的、意想不到的方式利用以前的直觉。

2. A tale of two flatMaps

在早期,Swift通过在标准库中提供map、filter、reduce和flatMap等函数来拥抱函数编程模式。

Swift 1.0附带的flatMap定义在Array上,其签名我们都很熟悉:

// extension Array {
//   func flatMap<B>(_ f: @escaping (Element) -> [B]) -> [B] {
//   }
// }

这对于将返回数组的操作链接在一起非常有用。

例如,给定一个包含逗号和换行符分隔的值列表的字符串:

let csv = """
1,2,3,4
3,5,2
8,9,4
"""

如果我们想处理这个字符串并提取逗号之间的所有值,该怎么办?

我们可以从换行开始。

csv
  .split(separator: "\n")
// ["1,2,3,4", "3,5,2", "8,9,4"]

现在我们要深入这个数组,用逗号进一步分割每个字符串。

我们很熟悉使用map来修改数组。

csv
  .split(separator: "\n")
  .map { $0.split(separator: ",") }
// [["1", "2", "3", "4"], ["3", "5", "2"], ["8", "9", "4"]]

这将返回嵌套的值数组,但我们想要一个平面的值数组。

这正是flatMap让我们做的。它对数组的每个元素应用一个函数,然后将其展开。

csv
  .split(separator: "\n")
  .flatMap { $0.split(separator: ",") }
// ["1", "2", "3", "4", "3", "5", "2", "8", "9", "4"]

此后不久,Swift在Optional上引入了flatMap,其签名如下:

// extension Optional {
//   func flatMap<B>(_ f: @escaping (Element) -> B?) -> B? {
//   }
// }

这对于将操作链接在一起比返回可选项更有用。

例如,String有一个可失败的初始化式,它接受数据并返回一个可选字符串:

String(data: Data(), encoding: .utf8)
// Optional("")

与数组一样,我们可以在可选项上map以转换包装的值。假设我们想要将可选字符串转换为整数。

String(data: Data(), encoding: .utf8)
  .map(Int.init)
// Optional(nil)

我们得到nil,因为我们用空字符串调用Int初始化式,但这里有些奇怪。 Optional类似是?

_: Int? =  String(data: Data(), encoding: .utf8)
  .map(Int.init)
// Value of optional type 'Int??' not unwrapped

它不是! 使用map会得到一个双可选的Int??。我们在一个可选对象上使用map,初始化式返回一个可选对象,结果是一组可选对象!我们真正想使用的是flatMap

_: Int? =  String(data: Data(), encoding: .utf8)
  .flatMap(Int.init)
// nil

现在编译,这意味着我们的值被平铺了。它仍然返回nil,让我们提供一些数据。

String(data: Data([55]), encoding: .utf8)
  .flatMap(Int.init)
// Optional(7)

通过flatMap,我们可以获取可选字符串,将函数应用到其未包装的值上,然后获取可选结果并将其扁平化为单个可选整数。

还有第三个在Array上的flatMap将这些世界模糊在一起:它接受数组元素并通过返回可选参数的转换发送它们。让我们看一个字符串数组。

["1", "2", "buckle", "my", "shoe"]

如果我们想把每个字符串转换成整数呢? 使用map,我们得到以下结果:

["1", "2", "buckle", "my", "shoe"]
  .map(Int.init)
// [{some 1}, {some 2}, nil, nil, nil]

我们将返回一个数组,成功的转换被包装在一个可选的数组中,而失败的转换则为nil。

如果我们使用flatMap代替,我们可以丢弃那些nil并安全地展开整数。

["1", "2", "buckle", "my", "shoe"]
  .flatMap(Int.init)
// [1, 2]

这是非常有用的!我们经常做这种事。但是这个版本的flatMap感觉有点不同于其他版本,因为它混合了数组的一些特性和可选特性。

当同时使用这两种方法时,事情变得更加混乱。 让我们以前面的CSV为例,进一步将每个值转换为整数,然后再相加。

csv.split(separator: "\n")
  .flatMap { $0.split(separator: ",") }
  .flatMap { Int($0) }
  .reduce(0, +)
// 41

在这里,第一个flatMap负责获取所有用逗号分隔的值,并将它们平铺到一个数组中,然后下一个flatMap尝试从字符串创建一个Int,并丢弃任何失败的值。这是两种非常不同的操作,但我们用的是相同的名称,所以很难一眼就把它们区分开来。

我们花了很多时间思考类型,尤其是函数的形状。 有时,在方法的定义中可能会忽略这一点:容器类型会淡出声明,而函数和参数名称会让事情变得更模糊。 让我们将这些函数签名隔离到类型本身。我们将使用(Configuration) -> (Data) -> ReturnValue的自由函数语法:

// flatMap : ((A) -> [B]) -> ([A]) -> [B]
// flatMap : ((A) ->  B?) -> ( A?) ->  B?

// flatMap : ((A) ->  B?) -> ([A]) -> [B]

其中一个形状和其他形状不一样。 最上面的两个flatMaps使用单一的容器类型:Array and Optional,但第三个flatMap同时操作两个容器。

如果我们脱糖,泛型是这样的:

// flatMap : ((A) ->    Array<B>) -> (   Array<A>) ->    Array<B>
// flatMap : ((A) -> Optional<B>) -> (Optional<A>) -> Optional<B>

// flatMap : ((A) -> Optional<B>) -> (   Array<A>) ->    Array<B>

如果我们可以把容器Array and Optional类型看作它们自己的泛型类型会怎么样呢? 我们可以这样写东西,我们可以扔掉他们的名字,得出这样的东西:

// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>
// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>

// flatMap : ((A) -> N<B>) -> (M<A>) -> M<B>

哇,前两个签名最后变成了一模一样的东西! 但是对于第三个签名,我们正在处理两种不同的泛型容器类型,语义变得更加混乱。N是怎么产生M的? 在其他版本中,我们可以理解transform函数与重新组合到容器类型有关。 在这个版本中,N并没有给我们太多线索。

为了理解它,我们必须回到具体类型。

// flatMap : ((A) -> B?) -> (M<A>) -> M<B>

这还是有点让人困惑。任何M可以这样与Optional一起工作吗? 也许这第三个flatMap不像其他的那么通用。

3. Optional promotion

对于返回Optionals的操作,在Array上重载flatMap还有另一个问题:可选升级(optional promotion)。出于人机工程学的考虑,当可选参数需要时,Swift会自动将值包装在可选类型中。 这可能导致更简洁的代码,它可以减少一些样板工程师的负担,否则需要显式地将值用.some包装,但类型推断是一把双刃剑,在闭包返回可选结果的情况下,任何事情都是公平的游戏。

考虑以下代码片段:

[1, 2, 3]
  .flatMap { $0 + 1 }
// [2, 3, 4]

这将编译、运行并生成一个结果,但语义很奇怪:我们不会从提供的闭包返回可选值。编译器会自动为我们包装这个值,像这样:

[1, 2, 3]
  .flatMap { .some($0 + 1) }
// [2, 3, 4]

手动包装我们的值表明我们这里的逻辑有点奇怪。 我们总是返回.some,从不返回到nil。因为这个操作永远不会失败,我们可以直接使用map

[1, 2, 3]
  .map { $0 + 1 }
// [2, 3, 4]

由于可选升级,任何适用于map的操作也适用于flatMap。 通常情况下,我们很容易避免使用map,因为flatMap似乎总是有效,但map有时并不有效。这有点不幸,因为如果我们到处使用flatMap,就会失去一些语义意义。如果同时使用flatMap和map,就会显式记录哪些操作可以失败,哪些操作不能失败。

更糟糕的是,我们可以对类型进行预期会出现编译时错误的更改,但由于重载的flatMap和可选的提升,它编译良好,但有意外的运行时行为。 例如,给定一个具有可选名称的User结构体和一个用户数组:

struct User {
  let name: String?
}

let users = [User(name: "Blob"), User(name: "Math")]

给定一组用户,我们可能会试图对它们进行map并提取出它们的名称。

users
  .map { $0.name }
// [{some "Blob"}, {some "Math"}]

但现在我们有了这个可选值数组。我们真正想使用的是flatMap

users
  .flatMap { $0.name }
// ["Blob", "Math"]

我们可能会围绕这样的类型构建许多代码,并且有一天我们可能会更改User类型(name 是必须的)。我们逐渐认识到,每当我们改变类型的外观时,一个强大的类型系统可以帮助指导我们重构代码。

struct User {
  let name: String
}

当代码重新编译时,运行时行为会发生什么变化?

users
  .flatMap { $0.name }
// ["B", "l", "o", "b", "M", "a", "t", "h"]

这是意想不到的! 也许这个异常值被重新命名是件好事。

4. compactMap and filterMap

因此,考虑到重载的flatMap与其他flatMap不太一样的复杂性,决定弃用它并引入一个新名称。非常重要的是要记住,带有返回数组的转换的数组上的flatMap,以及返回可选项的可选项上的flatMap不弃用。这只是一个离群值方法。

最初建议的名称是filterMap,因为您正在对数组进行映射,然后丢弃nil值。它可以这样定义:

extension Array {
  func filterMap<B>(_ transform: (Element) -> B?) -> [B] {
    var result = [B]()
    for x in self {
      switch transform(x) {
      case let .some(x):
        result.append(x)
      case let .none:
        continue
      }
    }
    return result
  }
}

在进化邮件列表上进行了一些调整之后,它最终被更改为compactMap,这很好,因为它共享了Ruby中的一些现有技术,其中compact是数组上的一个方法,它丢弃了nil值。让我们来定义compactMap:

extension Array {
  func compactMap<B>(_ transform: (Element) -> B?) -> [B] {
    var result = [B]()
    for x in self {
      switch transform(x) {
      case let .some(x):
        result.append(x)
      case let .none:
        continue
      }
    }
    return result
  }
}

它只是一个重命名,所以我们不需要更改函数体。

5. Generalizations of filterMap

compactMap名称的一个缺点是,它与我们对数组所做的操作有很强的联系:我们通过删除nil来“压缩”它,使它更小。这使我们无法看到compactMap在哪些方面有更广泛的应用。

但是,filterMap这个名字的一个奇妙之处在于它可以引出一些很好的概括。

让我们从观察一个断言 (A) -> Bool在A上自然诱导一个函数(A) -> A?

func filterSome<A>(_ p: @escaping (A) -> Bool) -> (A) -> A? {
  return { p($0) ? .some($0) : .none }
}

它只是在断言计算为true时返回.some值,否则返回.none

有了这个函数,我们现在可以在数组上定期重新实现ole filter:

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

让我们试一下。我们可以为偶数创建一个过滤器。

filter { $0 % 2 == 0 }
// ([Int]) -> [Int]

And pipe an array through.

Array(0..<10)
  |> filter { $0 % 2 == 0 }
// [2, 4, 6, 8, 10]

这很简洁,但不是立即有用。我们可以自己定义过滤器,或者直接使用标准库中的过滤器。但是,我们想要以这种方式研究过滤器的原因是,也许它会引导我们进一步一般化。

一种方法是记住**Either<A, B>是Optional**的泛化。

enum Either<A, B> {
  case left(A)
  case right(B)
}

我们可以提供一个不同的B类型的值来代替A的空值。

类似于filterSome的函数是这样的:

func partitionEither<A>(_ p: @escaping (A) -> Bool) -> (A) -> Either<A, A> {
  return { p($0) ? .right($0) : .left($0) }
}

给出谓语(A) -> Bool,它返回一个函数,该函数可以将类型(A)的值划分为Either< AA >中的两种情况之一。

现在让我们使用这个与partition和Either的连接来泛化filterMap:

extension Array {
  func partitionMap<A, B>(_ transform: (Element) -> Either<A, B>) -> (lefts: [A], rights: [B]) {
    var result = (lefts: [A](), rights: [B]())
    for x in self {
      switch transform(x) {
      case let .left(a):
        result.lefts.append(a)
      case let .right(b):
        result.rights.append(b)
      }
    }
    return result
  }
}

我们可以从filterMap推导出filter。我们能从partitionMap推导出partition吗?

func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
  return { $0.partitionMap(partitionEither(p)) } // error
}

类型系统在这里的元组名称上有点麻烦,但我们可以通过解构和重组来编译:

func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
  return {
    let (lefts, rights) = $0.partitionMap(partitionEither(p))
    return (lefts, rights)
  }
}

这很好,因为partition甚至不在标准库中,但是我们通过探索filter和filterMap之间的链接,以及从Optional到Either的泛化,自动地进入了这个目录。

现在我们看到两个平行的故事在这里形成:

  • 将一个函数的谓词(A) -> Bool提升为可选的(A) -> A? 自然而然地引出了filterMap函数,它又引出了我们已经熟悉的filter函数。
  • 将一个函数的谓词(A) -> Bool提升为either (A) -> either < A, A >会自然地引导我们得到partitionMap函数,该函数反过来又派生出分区函数。

我们还可以将其与之前章节中创建的其他机制结合起来,比如functional setters。我们可以定义非常小的泛型setter,它们以非常复杂的方式拼接在一起。我们看到这在自由函数中工作得最好,所以让我们定义一个免费版本的partitionMap:

func partitionMap<A, B, C>(_ p: @escaping (A) -> Either<B, C>) -> ([A]) -> (lefts: [B], rights: [C]) {
  return { $0.partitionMap(p) }
}

让我们定义一个可以与它一起使用的函数。

let evenOdds = { $0 % 2 == 0 ? Either.left($0) : .right($0) }
// (Int) -> Either<Int, Int>

我们可以把这个函数交给 partitionMap.

partitionMap(evenOdds)
// ([Int]) -> (lefts: [Int], rights: [Int])

现在我们有了一个全新的函数,给定一个整数数组,返回一个元组分区,左边是偶数,右边是奇数。

Let’s try it out.

Array(1...10)
  |> partitionMap(evenOdds)
// ([2, 4, 6, 8, 10], [1, 3, 5, 7, 9])

按预期工作。我们甚至可以使用元组可组合setter来组合它。让我们把偶数都平方。

Array(1...10)
  |> partitionMap(evenOdds)
  |> (first <<< map)(square)
// ([4, 16, 36, 64, 100], [1, 3, 5, 7, 9])

在短短几行和一个表达式中,我们就能够在修改其中一个集合之前,应用一个复杂的谓词将数组分割成两个,同时保持分区的完整性。

6. What’s the point?

我们花了整整一集的时间来讨论命名问题,命名是一个很快就会像bikeshed一样的话题,就像flatMap的重命名一样。 最重要的是,这个名字一开始就值得吗? 弃用它会导致大量代码基础的变动。人们大部分时间都在使用现有的flatMap进行日常代码编写,所以他们可能会在Swift 4.1中加载项目并问:“有什么意义?”这些重命名的练习值得吗?

有趣的是,我们可以退一步,更抽象地看待事物,并以有趣的方式将事物联系在一起。通过观察flatMap的形状,我们能够弄清楚为什么其中一个是异常值,以及另外两个如何为未来更多的flatMap提供共同的直觉。

与此同时,在将异常值重命名为filterMap时,我们有机会以其他方式一般化它。

命名是有争议的! 根据名称,我们可以在相关概念之间创建牢固的联系,这可以引导我们发现有趣的东西,正如我们看到的filter和filterMap引导我们到partition和partitionMap!

现在flatMap的异常值已经被重命名为compactMap,我们将在未来在更多类型上自由地探索它。