【函数式 Swift】Map、Filter 和 Reduce

1,168 阅读10分钟

说明:本文及所属系列文章为图书《函数式 Swift》的读书笔记,旨在记录学习过程、分享学习心得。文中部分代码摘自原书开源代码库 Github: objcio/functional-swift,部分内容摘自原书。如需深入学习,请购买正版支持原书。(受 @SwiftLanguage 微博启发,特此说明)


标题中的三个数组操作函数我们并不陌生,本章将借助这些 Swift 标准库中的函数,再次探索函数式思想的应用。

本章关键词

请带着以下关键词阅读本文:

  • 函数式思想
  • 泛型

案例:City Filter

使用 City 结构体描述城市信息(名字、人口数量),并定义一个城市数组:

struct City {
    let name: String
    let population: Int
}

let paris = City(name: "Paris", population: 2241) // 单位为“千”
let madrid = City(name: "Madrid", population: 3165)
let amsterdam = City(name: "Amsterdam", population: 827)
let berlin = City(name: "Berlin", population: 3562)

let cities = [paris, madrid, amsterdam, berlin]

问题:输出 cities 数组中所有人口超过百万的城市信息,并将人口数量单位转换为“个”。

开始解决问题之前,请大家先忘掉标题中 Map、Filter 和 Reduce 等函数,无论之前是否使用过,我们尝试从零开始逐步向函数式思想过渡。

我们先使用一个简单的思路来解决这个问题,即,遍历输入的城市数组,然后依次判断每个城市的人口数量,超过一百万的城市输出其信息:

func findCityMoreThanOneMillion(_ cities: [City]) -> String {
    var result = "City: Population\n"
    for city in cities {
        if city.population > 1000 {
            result = result + "\(city.name): \(city.population * 1000)\n"
        }
    }
    return result
}

let result = findCityMoreThanOneMillion(cities)
print(result)
// City: Population
// Paris: 2241000
// Madrid: 3165000
// Berlin: 3562000

对于一个具体问题来说,我们的解法并不算差,满足需求、代码也简单,但是它只能正常工作于这样局限的场景中,显然,这不符合函数式思想。

我们从上述代码开始分析,findCityMoreThanOneMillion 函数主要完成了以下三个工作:

  1. 过滤:通过 city.population > 1000 过滤出人口超过百万的城市;
  2. 转换单位:通过 city.population * 1000 将单位转换为“个”;
  3. 拼接结果:使用 var result 将结果拼接起来,并最终返回。

这三步自然的帮我们将原问题分解成了三个子问题,即:

  1. 数组元素过滤问题(Filter);
  2. 数组元素修改问题(Map);
  3. 数组遍历与结果拼接问题(Reduce)。

为了解决原始问题,我们需要优先解决这三个子问题,很明显,它们对应了标题中的函数,下面一一讨论(为了匹配原书内容,我们从 Map 开始)。

Map

案例中,我们需要将 city.population 的单位转换为“个”,本质上就是将一个数值转换为另一个数值,下面编写一个函数来实现这个功能:

func transformArray(xs: [Int]) -> [Int] {
    var result: [Int] = []
    for x in xs {
        result.append(x * 1000)
    }
    return result
}

使用该函数可以帮助我们将一个 [Int] 数组中的每个元素乘以 1000,这样就能满足我们从“千”到“个”的单位转换需求,然而,这个函数存在的问题也非常明显:

  1. 入参和返回值均固定为 [Int],扩展性差;
  2. 数值变换方式固定为 x * 1000,场景局限。

试想,如果输入数组可能为 [Double][Int],需要将单位从“千”转换为“万”、“百万”或者“千万”,输出为 [Int][Double],就不得不去修改这个函数,或是添加更多相似的函数。

如何解决呢?先来了解一个概念:泛型(Generics),Swift 官方文档对泛型的定义如下:

Generic code enables you to write flexible, reusable functions and types that can work with any type, subject to requirements that you define. You can write code that avoids duplication and expresses its intent in a clear, abstracted manner.

可见,泛型的目标就是编写灵活、可复用,并且支持任意类型的函数,避免重复性的代码。以官方代码为例:

func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}

var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt)
// someInt is now 107, and anotherInt is now 3

var someString = "hello"
var anotherString = "world"
swapTwoValues(&someString, &anotherString)
// someString is now "world", and anotherString is now "hello"

使用泛型定义的 swapTwoValues 函数能够接受任意类型的入参,无论是 Int 还是 String 均能正常工作。

回到 transformArray 函数:

引入泛型可以帮助我们解决第一个问题,即,入参和返回值均固定为 [Int],既然入参和返回值可以是不相关的两种数组类型,那么我们可以使用两个泛型来表示它们,例如 [E][T]

此时,transformArray 函数的入参和返回值变成了 [E][T],那么函数内部所要完成的任务就是将 [E] 转换为 [T],而转换过程正好对应了第二个问题,即,数值变换方式固定位 x * 1000,解决它只需要调用方将这个“转换过程”传递给 transformArray 函数即可,也就是说,我们需要一个形如这样的函数作为入参:

typealias Transform = (E) -> (T)
// 由于没有定义 E、T 泛型,所以这里仅作示意

然后,将 transformArray 函数改写如下:

func transformArray<E, T>(xs: [E], transform: Transform) -> [T] {
    var result: [T] = []
    for x in xs {
        result.append(transform(x))
    }
    return result
}

这样就完成了一个类似 Map 的函数,将这个函数加入 Array 中,并将函数名改为 map,就可以使用 Array 对象来调用这个方法了:

extension Array {
    func map<T>(transform: (Element) -> T) -> [T] {
        var result: [T] = []
        for x in self {
            result.append(transform(x))
        }
        return result
    }
}
// 其中 Element 使用 Array 中 Element 泛型定义

我们知道,map 函数已经存在于 Swift 标准库中(基于 Sequence 协议实现),因此并不需要自己来实现,我们通过 Swift 源码来学习一下(路径:swift/stdlib/public/Sequence.swift):

public protocol Sequence {
    ...

    func map<T>(
        _ transform: (Iterator.Element) throws -> T
    ) rethrows -> [T]
}

extension Sequence {
    ...

    public func map<T>(
        _ transform: (Iterator.Element) throws -> T
    ) rethrows -> [T] {
        let initialCapacity = underestimatedCount
        var result = ContiguousArray<T>()
        result.reserveCapacity(initialCapacity)

        var iterator = self.makeIterator()

        // Add elements up to the initial capacity without checking for regrowth.
        for _ in 0..<initialCapacity {
            result.append(try transform(iterator.next()!))
        }
        // Add remaining elements, if any.
        while let element = iterator.next() {
            result.append(try transform(element))
        }
        return Array(result)
    }
}

除了一些额外的处理,核心部分与我们的实现是相同的,使用方法如下:

let arr = [10, 20, 30, 40]
let arrMapped = arr.map { $0 % 3 }
print(arrMapped)
// [1, 2, 0, 1]

Filter

有了 Map 的经验,对于 Filter 的设计就方便多了,我们参考 transformArray 函数可以这样设计 Filter 函数:

  1. 入参和返回值均为 [T]
  2. 入参的 transform 修改为 isIncluded,类型为 (T) -> Bool,用于判断是否应该包含在返回值中。

实现代码如下:

func filterArray<T>(xs: [T], isIncluded: (T) -> Bool) -> [T] {
    var result: [T] = []
    for x in xs {
        if isIncluded(x) {
            result.append(x)
        }
    }
    return result
}

同样的,filter 函数也已经存在于 Swift 标准库中,源码如下(路径:swift/stdlib/public/Sequence.swift):

public protocol Sequence {
    ...

    func filter(
        _ isIncluded: (Iterator.Element) throws -> Bool
    ) rethrows -> [Iterator.Element]
}

extension Sequence {
    ...

    public func filter(
        _ isIncluded: (Iterator.Element) throws -> Bool
    ) rethrows -> [Iterator.Element] {

        var result = ContiguousArray<Iterator.Element>()
        var iterator = self.makeIterator()

        while let element = iterator.next() {
            if try isIncluded(element) {
                result.append(element)
            }
        }

        return Array(result)
    }
}

核心部分实现也是相同的,使用方法如下:

let arr = [10, 20, 30, 40]
let arrFiltered = arr.filter { $0 < 35 }
print(arrFiltered)
// [10, 20, 30]

Reduce

Reduce 与 Map 不同之处在于,Map 每次将集合中的元素抛给 transform 闭包,然后得到一个“变形”后的元素,而 Reduce 是将集合中的元素连同当前上下文中的变量一起抛给入参闭包(此处命名为 combine),以便于该闭包处理,然后返回处理后的结果,因此 combine 的定义类似:

typealias Combine = (T, E) -> (T)
// 由于没有定义 E、T 泛型,所以这里仅作示意

因此 reduce 函数可以定义如下:

func reduceArray<E, T>(xs: [E], initial: T, combine: Combine) -> T {
    var result: T = initial
    for x in xs {
        result = combine(result, x)
    }
    return result
}

Swift reduce 函数源码如下(路径:swift/stdlib/public/Sequence.swift):

/// You rarely need to use iterators directly, because a `for`-`in` loop is the
/// more idiomatic approach to traversing a sequence in Swift. Some
/// algorithms, however, may call for direct iterator use.
///
/// One example is the `reduce1(_:)` method. Similar to the `reduce(_:_:)`
/// method defined in the standard library, which takes an initial value and a
/// combining closure, `reduce1(_:)` uses the first element of the sequence as
/// the initial value.
///
/// Here's an implementation of the `reduce1(_:)` method. The sequence's
/// iterator is used directly to retrieve the initial value before looping
/// over the rest of the sequence.
///
///     extension Sequence {
///         func reduce1(
///             _ nextPartialResult: (Iterator.Element, Iterator.Element) -> Iterator.Element
///         ) -> Iterator.Element?
///         {
///             var i = makeIterator()
///             guard var accumulated = i.next() else {
///                 return nil
///             }
///
///             while let element = i.next() {
///                 accumulated = nextPartialResult(accumulated, element)
///             }
///             return accumulated
///         }
///     }

reduce 函数与上面两个函数不太相同,Apple 将其实现以另一个 reduce1 函数放在了注释中,原因应该如注释所说,for-in loop 方式更加常用,但我们仍然可以正常使用 reduce 函数,方法如下:

let arr = [10, 20, 30, 40]
let arrReduced = arr.reduce(output) { result, x in
    return result + "\(x) "
}
print(arrReduced)
// Arr contains 10 20 30 40

函数式解决方案

在准备好了 Map、Filter 和 Reduce 工具库之后,我们再来解决 City Filter 问题:

let result =
    cities.filter { $0.population > 1000 }
        .map { $0.cityByScalingPopulation() }
        .reduce("City: Population") { result, c in
            return result + "\n" + "\(c.name): \(c.population)"
        }
print(result)
// City: Population
// Paris: 2241000
// Madrid: 3165000
// Berlin: 3562000

extension City {
    func cityByScalingPopulation() -> City {
        return City(name: name, population: population * 1000)
    }
}

借助 Map、Filter 和 Reduce 等方法,可以方便的使用链式语法对原数组进行处理,并得到最终结果。


思考

函数式思想

当我们讨论函数式思想时,我们到底在说什么?

简单说,函数式思想是通过构建一系列简单、实用的函数,再“装配”起来解决实际问题,对于这句话的理解,我想至少有三点:

  1. 目标转换:基于函数式思想解决问题时,目标不再“急功近利”直接解决具体问题,而是庖丁解牛,把具体问题分解成为小规模的,甚至是互不相干的子模块,攻克这些子模块才是更高优先级的工作;
  2. 函数设计:分解出的每个子模块,实际上也就对应了一个、或一组能够独立工作的函数,良好的函数设计不仅有助于我们解决当前问题,更能为我们构建一个优秀的工具库,去解决很多其他问题;
  3. 问题解决:有时具体问题的解决好像已经被我们遗忘了,在解决了子问题、构建了工具库后,简单“装配”就能轻松解决原始问题。借助函数式思想,我们也更容易发现问题之间的共同点,从而快速解决,换句话说,解决问题成为了函数式思想下的“副产品”

泛型

Swift 中对于泛型的应用非常广泛,使用泛型能够使我们事半功倍,一个函数可以“瞬间”支持几乎所有类型,更重要的是,因为 Swift 语言的“类型安全”特性,使得这一切都安全可靠。

泛型之所以安全,是因为它仍然处于编译器的类型控制下,而 Swift 中的 Any 类型就不那么安全了,表面上看两者都能表示任意类型,但使用 Any 类型能够避开编译器的检查,从而可能造成错误,来看下面的例子:

func exchange<T>(_ income: T) -> T {
    return "Money: \(income)" // error
}

func exchangeAny(_ income: Any) -> Any {
    return "Money: \(income)"
}

同样的函数体,使用泛型的 exchange 会提示错误:error: cannot convert return expression of type 'String' to return type 'T',而使用 AnyexchangeAny 则不提示任何错误。如果我们不清楚 exchangeAny 的返回值类型,而直接调用,则可能导致运行时错误,是非常危险的。因此,善用泛型能够让我们在“无须牺牲类型安全就能够在编译器的帮助下写出灵活的函数”。

更多关于泛型的讨论请参阅原书,或官方文档。


参考资料

  1. Github: objcio/functional-swift
  2. The Swift Programming Language: Generics
  3. The Swift Programming Language (Source Code)

本文属于《函数式 Swift》读书笔记系列,同步更新于 huizhao.win,欢迎关注!