为有牺牲多壮志,敢教日月换新天。

[RxSwift]3.4、函数式思想案例

★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★
➤微信公众号:山青咏芝(let_us_code)
➤博主域名:https://www.zengqiang.org
➤GitHub地址:https://github.com/strengthen/LeetCode
➤原文地址: https://www.cnblogs.com/strengthen/p/13563466.html
➤如果链接不是山青咏芝的博客园地址,则可能是爬取作者的文章。
➤原文已修改更新!强烈建议点击原文地址阅读!支持作者!支持原创!
★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★

热烈欢迎,请直接点击!!!

进入博主App Store主页,下载使用各个作品!!!

注:博主将坚持每月上线一个新app!!!

 

函数在 Swift 中是一等值 (first-class-values),换句话说,函数可以作为参数被传递到其它函数,也可以作为其它函数的返回值。如果你习惯了使用像是整型,布尔型或是结构体这样的简单类型来编程,那么这个理念可能看来非常奇怪。在本章中,我们会尽可能解释为什么一等函数是很有用的语言特性,并实际地提供本书的第一个函数式编程案例。

函数和方法将会贯穿整个章节。方法是函数的一种特殊情况:它是定义在类型里的函数。

案例:Battleship

我们将会用一个小案例来引出一等函数:这个例子是你在编写战舰类游戏时可能需要实现的一个核心函数。我们把将要看到的问题归结为,判断一个给定的点是否在射程范围内,并且距离友方船舶和我们自身都不太近。

首先,你可能会写一个很简单的函数来检验一个点是否在范围内。为了简明易懂,我们假定我们的船位于原点。这样一来,我们就可以将想要描述的区域形象化,如图 1

图 1: 位于原点的船舶射程范围内的点

图 1: 位于原点的船舶射程范围内的点

首先,我们定义两种类型,Distance 和 Position

typealias Distance = Double
    
    struct Position {
        var x: Double
        var y: Double
    }
    

注意,我们使用了 Swift 的 typealias 关键字,它允许我们为已经存在的类型创建一个别名。这里我们将 Distance 定义为 Double 的别名,目的在于让 API 的语义更清晰。

然后我们在 Position 中添加一个方法 within(range:),用于检验一个点是否在图 1 中的灰色区域里。使用一些基础的几何知识,我们可以像下面这样定义这个函数:

extension Position {
        func within(range: Distance) -> Bool {
            return sqrt(x * x + y * y) <= range
        }
    }
    

如果假设我们总是位于原点,那现在这样就可以正常工作了。但是船舶还可能在原点以外的其它位置出现,我们可以更新一下形象化图,如图 2 所示:

图 2: 允许船有它自己的位置

图 2: 允许船有它自己的位置

考虑到这一点,我们引入一个结构体 Ship,它有一个属性为 position

struct Ship {
        var position: Position
        var firingRange: Distance
        var unsafeRange: Distance
    }
    

目前,姑且先忽略附加属性 unsafeRange。一会儿我们会回到这个问题。

我们向结构体 Ship 中添加一个 canEngage(ship:) 方法对其进行扩展,这个函数允许我们检验是否有另一艘船在范围内,不论我们是位于原点还是其它任何位置:

extension Ship {
        func canEngage(ship target: Ship) -> Bool {
            let dx = target.position.x - position.x
            let dy = target.position.y - position.y
            let targetDistance = sqrt(dx * dx + dy * dy)
            return targetDistance <= firingRange
        }
    }
    

也许现在你已经意识到,我们同时还想避免目标船舶离得过近。可以用图 3 来说明新情况,我们想要瞄准的仅仅只有那些对我们当前位置而言在 unsafeRange (不安全范围)外的敌人:

图 3: 避免与过近的敌方船舶交战

图 3: 避免与过近的敌方船舶交战

这样一来,我们需要再一次修改代码,使 unsafeRange 属性能够发挥作用:

extension Ship {
        func canSafelyEngage(ship target: Ship) -> Bool {
            let dx = target.position.x - position.x
            let dy = target.position.y - position.y
            let targetDistance = sqrt(dx * dx + dy * dy)
            return targetDistance <= firingRange && targetDistance > unsafeRange
        }
    } 
    

最后,我们还需要避免目标船舶过于靠近我方的任意一艘船。我们再一次将其形象化,见图 4

图 4: 避免敌方过于接近友方船舶

图 4: 避免敌方过于接近友方船舶

相应地,我们可以向 canSafelyEngage(ship:) 方法添加另一个参数代表友好船舶位置:

extension Ship {
        func canSafelyEngage(ship target: Ship, friendly: Ship) -> Bool {
            let dx = target.position.x - position.x
            let dy = target.position.y - position.y
            let targetDistance = sqrt(dx * dx + dy * dy)
            let friendlyDx = friendly.position.x - target.position.x
            let friendlyDy = friendly.position.y - target.position.y
            let friendlyDistance = sqrt(friendlyDx * friendlyDx +
                friendlyDy * friendlyDy)
            return targetDistance <= firingRange
                && targetDistance > unsafeRange
                && (friendlyDistance > unsafeRange)
        }
    }
    

随着代码逐渐发展,它变得越来越难以维护。这个方法包含了一大段复杂的运算代码,不过也没有那么糟,我们可以在 Position 中添加一个辅助方法和一个计算属性来负责几何运算,从而让这段代码变得清晰易懂一些:

extension Position {
        func minus(_ p: Position) -> Position {
            return Position(x: x - p.x, y: y - p.y)
        }
        var length: Double {
            return sqrt(x * x + y * y)
        }
    }
    

添加了辅助方法和属性之后,上述方法变成了下面这样:

extension Ship {
        func canSafelyEngage2(ship target: Ship, friendly: Ship) -> Bool {
            let targetDistance = target.position.minus(position).length
            let friendlyDistance = friendly.position.minus(target.position).length
            return targetDistance <= firingRange
                && targetDistance > unsafeRange
                && (friendlyDistance > unsafeRange)
        }
    }
    

现在的代码相较之前已经易读多了,但是我们还想更进一步,用一种声明式的方法来明确现有的问题。

一等函数

在当前 canSafelyEngage(ship:friendly) 的方法中,主要的行为是为构成返回值的布尔条件组合进行编码。在这个简单的例子中,虽然想知道这个函数做了什么并不是太难,但我们还是想要一个更模块化的解决方案。

我们已经在 Position 中引入了辅助方法使几何运算的代码更清晰易懂。用同样的方式,我们现在要添加一个函数,以更加声明式的方式来判断一个区域内是否包含某个点。

原来的问题归根结底是要定义一个函数来判断一个点是否在范围内。这样一个函数的类型应该是像下面这样的:

func pointInRange(point: Position) -> Bool {
        // 方法的具体实现
    }

由于该函数的类型将会非常重要,所以我们打算给它一个独立的名字:

typealias Region = (Position) -> Bool
    

从现在开始,Region 类型将指代把 Position 转化为 Bool 的函数。严格来说这不是必须的,但是它可以让我们更容易理解在接下来即将看到的一些类型。

我们使用一个能判断给定点是否在区域内的函数来代表一个区域,而不是定义一个对象或结构体来表示它。如果你不习惯函数式编程,这可能看起来会很奇怪,但是记住:在 Swift 中函数是一等值!我们有意识地选择了 Region 作为这个类型的名字,而非 CheckInRegion 或 RegionBlock 这种字里行间暗示着它们代表一种函数类型的名字。函数式编程的核心理念就是函数是值,它和结构体、整型或是布尔型没有什么区别 —— 对函数使用另外一套命名规则会违背这一理念。

我们现在要写几个函数来创建、控制和合并各个区域。

我们定义的第一个区域是以原点为圆心的圆 (circle):

func circle(radius: Distance) -> Region {
        return { point in point.length <= radius }
    }
    

值得注意的是,以半径 r 为参数,调用 circle(radius: r) 返回的是一个函数。这里我们使用了 Swift 的闭包来构造我们期待的返回函数。给定一个表示位置的参数 point,然后检查该 point 是否在以原点为中心且给定半径的圆所限定的区域中。

当然,并不是所有圆的圆心都是原点。我们可以给 circle 函数添加更多的参数来解决这个问题。要得到一个圆心是任意定点的圆,我们只需要添加另一个代表圆心的参数,并确保在计算新区域时将这个参数考虑进去:

func circle2(radius: Distance, center: Position) -> Region {
        return { point in point.minus(center).length <= radius }
    }
    

然而,如果我们想对更多的图形组件 (例如,想象我们不仅有圆,还有矩形或其它形状) 做出同样的改变,可能需要重复这些代码。更加函数式的方式是写一个区域变换函数。这个函数按一定的偏移量移动一个区域:

func shift(_ region: @escaping Region, by offset: Position) -> Region {
        return { point in region(point.minus(offset)) }
    }
    

当需要在函数返回之后使用其参数(如:region)时,该参数需要被标记为 @escaping。即使忘记标记,编译器也将会提醒我们。如果你想了解更详细的信息,请参阅 Apple 的 The Swift Programming Language 一书中《逃逸闭包》的部分。

调用 shift(region, by: offset) 函数会将区域向右上方移动,偏移量分别是 offset.x 和 offset.y。我们需要的是一个传入 Position 并返回 Bool 的函数 Region。为此,我们需要另写一个闭包,它接受我们要检验的点,这个点减去偏移量之后我们得到一个新的点。最后,为了检验新点是否在原来的区域内,我们将它作为参数传递给 region 函数。

这是函数式编程的核心概念之一:为了避免创建像 circle2 这样越来越复杂的函数,我们编写了一个 shift(_:by:) 函数来改变另一个函数。例如,一个圆心为 (5, 5) 半径为 10 的圆,可以用下面的方式表示:

let shifted = shift(circle(radius: 10), by: Position(x: 5, y: 5))
    

还有很多其它的方法可以变换已经存在的区域。例如,也许我们想要通过反转一个区域以定义另一个区域。这个新产生的区域由原区域以外的所有点组成:

func invert(_ region: @escaping Region) -> Region {
        return { point in !region(point) }
    }
    

我们也可以写一个函数将既存区域合并为更大更复杂的区域。比如,下面两个函数分别可以计算参数中两个区域的交集和并集:

func intersect(_ region: @escaping Region, with other: @escaping Region)
        -> Region {
        return { point in region(point) && other(point) }
    }
    
    func union(_ region: @escaping Region, with other: @escaping Region)
        -> Region {
        return { point in region(point) || other(point) }
    }
    

当然,我们可以利用这些函数来定义更丰富的区域。difference 函数接受两个区域作为参数 —— 原来的区域和要减去的区域 —— 然后为所有在第一个区域中且不在第二个区域中的点构建一个新的区域:

func subtract(_ region: @escaping Region, from original: @escaping Region)
        -> Region {
        return intersect(original, with: invert(region))
    }
    

这个例子告诉我们,在 Swift 中计算和传递函数的方式与整型或布尔型没有任何不同。这让我们能够写出一些基础的图形组件 (比如圆),进而能以这些组件为基础,来构建一系列函数。每个函数都能修改或是合并区域,并以此创建新的区域。比起写复杂的函数来解决某个具体的问题,现在我们完全可以通过将一些小型函数装配起来,广泛地解决各种各样的问题。

现在让我们把注意力转回原来的例子。关于区域的小型函数库已经准备就绪,我们可以像下面这样重构 canSafelyEngage(ship:friendly:) 这个复杂的方法:

extension Ship {
        func canSafelyEngage(ship target: Ship, friendly: Ship) -> Bool {
            let rangeRegion = subtract(circle(radius: unsafeRange),
                from: circle(radius: firingRange))
            let firingRegion = shift(rangeRegion, by: position)
            let friendlyRegion = shift(circle(radius: unsafeRange),
                by: friendly.position)
            let resultRegion = subtract(friendlyRegion, from: firingRegion)
            return resultRegion(target.position)
        }
    }
    

这段代码定义了两个区域:firingRegion 和 friendlyRegion。通过计算这两个区域的差集 (即在 firingRegion 中且不在 friendlyRegion 中的点的集合),我们可以得到我们感兴趣的区域。将这个区域函数作用在目标船舶的位置上,我们就可以计算所需的布尔值了。

面对同一个问题,与原来的 canSafelyEngage(ship:friendly:) 方法相比,使用 Region 方法重构后的版本是更加声明式的解决方案。我们坚信后一个版本会更容易理解,因为我们的解决方案是装配式的。你可以探究组成它的每个部分,例如 firingRegion 和 friendlyRegion,看一看它们是如何被装配并解决原来的问题的。另一方面,原来庞大的方法混合了各个组成区域的描述语句和描述它们所需要的算式。通过定义我们之前提出的辅助函数将这些关注点进行分离,显著提高了复杂区域的组合性和易读性。

能做到这样,一等函数是至关重要的。虽然 Objective-C 也支持一等函数,或者说是 block,即使可以做到类似的事情,但难掩遗憾,在 Objective-C 中使用 block 十分繁琐。一部分原因是因为语法问题:block 的声明和 block 的类型与 Swift 的对应部分相比并不是那么简单。在后面的章节中,我们也将看到泛型如何让一等函数更加强大,远比 Objective-C 中用 blocks 实现更加容易。

我们定义 Region 类型的方法有它自身的缺点。我们选择了将 Region 类型定义为简单类型,并作为 (Position) -> Bool 函数的别名。其实,我们也可以选择将其定义为一个包含单一函数的结构体:

struct Region {
        let lookup: (Position) -> Bool
    }
    

接下来我们可以用 extensions 的方式为结构体定义一些类似的方法,来代替对原来的 Region 类型进行操作的自由函数。这可以让我们能够通过对区域进行反复的函数调用来变换这个区域,直至得到需要的复杂区域,而不用像以前那样将区域作为参数传递给其他函数:

rangeRegion.shift(ownPosition).difference(friendlyRegion)
    

这种方法有一个优点,它需要的括号更少。再者,这种方式下 Xcode 的自动补全在装配复杂的区域时会十分有用。不过,为了便于展示,我们选择了使用简单的 typealias 以突出在 Swift 中使用高阶函数的方法。

此外,值得指出的是,现在我们不能够看到一个区域是如何被构建的:它是由更小的区域组成的吗?还是单纯只是一个以原点为圆心的圆?我们唯一能做的是检验一个给定的点是否在区域内。如果想要形象化一个区域,我们只能对足够多的点进行采样来生成 (黑白) 位图。

后面的章节中,我们将使用另外一种设计,来帮助你解答这些问题。

我们在本章节,或者说是整本书中使用的命名方式稍有违背 Swift API 设计指南。Swift 指南要求在设计时考虑方法名。例如,如果 intersect 被定义为一个方法,那么该方法名应该为 intersecting 或 intersected,因为返回值是一个新值,而非对现有区域进行修改。尽管如此,在编写顶级函数时,我们决定使用 intersect 这样的基本形式。

类型驱动开发

在引言中,我们提到了函数式编程可以用规范的方式将函数作为参数装配为规模更大的程序。在本章中,我们看到了一个以这种函数式方式设计的具体例子。我们定义了一系列函数来描述区域。每一个函数单打独斗的话都并不强大。然而装配到一起时,却可以描述你绝不会想要从零开始编写的复杂区域。

解决的办法简单而优雅。这与单纯地将 canSafelyEngage(ship:friendly:) 方法拆分为几个独立的方法那种重构方式是完全不同的。我们确定了如何来定义区域,这是至关重要的设计决策。当我们选择了 Region 类型之后,其它所有的定义就都自然而然,水到渠成了。这个例子给我们的启示是,我们应该谨慎地选择类型。这比其他任何事都重要,因为类型将左右开发流程。

注解

本章提供的代码受到了一个 Haskell 解决方案的启发,该方案解决了由美国高等研究计划局 (ARPA) 的 Hudak and Jones (1994) 提出的一个问题。

Objective-C 通过引入 blocks 实现了对一等函数的支持:你可以将函数和闭包作为参数并轻松地使用内联的方式定义它们。然而,在 Objective-C 中使用它们并不像在 Swift 中一样方便,尽管两者在语意上完全相同。

从历史上看,一等函数的理念可以追溯到 Church 的 lambda 演算 (Church 1941; Barendregt 1984)。此后,包括 Haskell,OCaml,Standard ML,Scala 和 F# 在内的大量 (函数式) 编程语言都不同程度地借鉴了这个概念。

案例研究:封装 Core Image

前一章介绍了高阶函数的概念,并展示了将函数作为参数传递给其它函数的方法。不过,使用的例子可能与你日常写的“真实”代码相去甚远。在本章中,我们将会围绕一个已经存在且面向对象的 API,展示如何使用高阶函数将其以小巧且函数式的方式进行封装。

Core Image 是一个强大的图像处理框架,但是它的 API 有时可能略显笨拙。Core Image 的 API 是弱类型的 —— 我们通过键值编码 (KVC) 来配置图像滤镜 (filter)。在使用参数的类型或名字时,我们都使用字符串来进行表示,这十分容易出错,极有可能导致运行时错误。而我们开发的新 API 将会利用类型来避免这些原因导致的运行时错误,最终我们将得到一组类型安全而且高度模块化的 API。

即使你不熟悉 Core Image,或者不能完全理解本章代码片段的细节,也大可不必担心。我们的目标并不是围绕 Core Image 构建一个完整的封装,而是要说明如何把像高阶函数这样的函数式编程概念运用到实际的生产代码中。

滤镜类型

CIFilter 是 Core Image 中的核心类之一,用于创建图像滤镜。当实例化一个 CIFilter 对象时,你 (几乎) 总是通过 kCIInputImageKey 键提供输入图像,再通过 outputImage 属性取回处理后的图像。取回的结果可以作为下一个滤镜的输入值。

在本章即将开发的 API 中,我们会尝试封装应用这些键值对的具体细节,从而呈现给用户一个安全的强类型 API。我们将 Filter 类型定义为一个函数,该函数接受一个图像作为参数并返回一个新的图像:

typealias Filter = (CIImage) -> CIImage
    

我们将以这个类型为基础进行后续的构建。

构建滤镜

现在我们已经定义了 Filter 类型,接着就可以开始定义函数来构建特定的的滤镜了。这些函数在接受特定滤镜所需要的参数之后,构造并返回一个 Filter 类型的值。它们的基本形态大概都是下面这样:

func myFilter(...) -> Filter
    

注意这里的返回值 Filter,也是一个函数。稍后我们将会借助它来组合多个滤镜以实现期待的图像效果。

模糊

让我们来定义第一个简单的滤镜 —— 高斯模糊滤镜。定义它只需要模糊半径这一个参数:

func blur(radius: Double) -> Filter {
        return { image in
            let parameters: [String: Any] = [
                kCIInputRadiusKey: radius,
                kCIInputImageKey: image
            ]
            guard let filter = CIFilter(name: "CIGaussianBlur",
                withInputParameters: parameters) 
                else { fatalError() }
            guard let outputImage = filter.outputImage 
                else { fatalError() }
            return outputImage
        }
    }
    

一切就是这么简单。blur 函数返回一个新函数,新函数接受一个 CIImage 类型的参数 image,并返回一个新图像 (return filter.outputImage)。因此,blur 函数的返回值满足我们之前定义的 (CIImage) -> CIImage,也就是 Filter 类型。

CIFilter 的初始化及 filter 的 outputImage 属性都返回可选值,我们使用了 guard 语句来解包这些可选值。如果这些值中有 nil,那么就意味着发生了程序错误的情况。比方说,如果我们向滤镜传递了错误的参数,就会导致这种情况。结合 fatalError() 使用 guard 语句而非强制解包可选值使得这种意图更明显。

这个例子仅仅只是对 Core Image 中一个已经存在的滤镜进行的简单封装。我们可以反复使用相同的模式来创建自己的滤镜函数。

颜色叠层

现在让我们来定义一个能够在图像上覆盖纯色叠层的滤镜。Core Image 默认不包含这样一个滤镜,但是我们完全可以用已经存在的滤镜来组成它。

我们将使用的两个基础组件:颜色生成滤镜 (CIConstantColorGenerator) 和图像覆盖合成滤镜 (CISourceOverCompositing)。首先让我们来定义一个生成固定颜色的滤镜:

func generate(color: UIColor) -> Filter {
        return { _ in
            let parameters = [kCIInputColorKey: CIColor(cgColor: color.cgColor)]
            guard let filter = CIFilter(name: "CIConstantColorGenerator",
                withInputParameters: parameters) 
                else { fatalError() }
            guard let outputImage = filter.outputImage 
                else { fatalError() }
            return outputImage
        }
    }
    

这段代码看起来和我们用来定义模糊滤镜的代码非常相似,但是有一个显著的区别:颜色生成滤镜不检查输入图像。因此,我们不需要给返回函数中的图像参数命名。取而代之,我们使用一个匿名参数 _ 来强调滤镜的输入图像参数是被忽略的。

接下来,我们将要定义合成滤镜:

func compositeSourceOver(overlay: CIImage) -> Filter {
        return { image in
            let parameters = [
                kCIInputBackgroundImageKey: image,
                kCIInputImageKey: overlay
            ]
            guard let filter = CIFilter(name: "CISourceOverCompositing",
                withInputParameters: parameters) 
                else { fatalError() }
            guard let outputImage = filter.outputImage 
                else { fatalError() }
            return outputImage.cropping(to: image.extent)
        }
    }
    

在这里我们将输出图像剪裁为与输入图像一致的尺寸。严格来说,这不是必须的,完全取决于我们希望滤镜如何工作。不过,这个选择在我们即将涉及的例子中效果很好。

最后,我们通过结合两个滤镜来创建颜色叠层滤镜:

func overlay(color: UIColor) -> Filter {
        return { image in
            let overlay = generate(color: color)(image).cropping(to: image.extent)
            return compositeSourceOver(overlay: overlay)(image)
        }
    }
    

我们再次返回了一个接受图像作为参数的函数。我们在定义该函数的整个过程中所做的一切可大致概括为:首先使用先前定义的颜色生成滤镜函数 generate(color:) 来生成一个新叠层。然后以颜色作为参数调用该函数,返回 Filter 类型值。而 Filter 类型本身就是一个从 CIImage 到 CIImage 的函数,因此我们还可以向 generate(color:) 函数传递一个 image 参数,最终通过计算能够得到一个 CIImage 类型的新叠层。

与叠层的创建相似,所构建的滤镜函数的返回值也有着相同的结构:先通过调用 compositeSourceOver(overlay:) 来创建一个滤镜。然后以输入图像为参数调用这个滤镜。

组合滤镜

到现在为止,我们已经定义了模糊滤镜和颜色叠层滤镜,可以把它们组合在一起使用:首先将图像模糊,然后再覆盖上一层红色叠层。让我们来载入一张图片试试看:

let url = URL(string: "http://www.objc.io/images/covers/16.jpg")!
    let image = CIImage(contentsOf: url)!
    

现在我们可以链式地将两个滤镜应用到载入的图像上:

let radius = 5.0
    let color = UIColor.red.withAlphaComponent(0.2)
    let blurredImage = blur(radius: radius)(image)
    let overlaidImage = overlay(color: color)(blurredImage)
    

我们再一次通过创建滤镜来处理图像,例如先创建 blur(radius:radius) 滤镜,随后将其运用到图像上。

复合函数

当然,我们可以将上面代码里两个调用滤镜的表达式简单合为一体:

let result = overlay(color: color)(blur(radius: radius)(image))
    

然而,由于括号错综复杂,这些代码很快失去了可读性。更好的解决方式是构建一个可以将滤镜合二为一的函数:

func compose(filter filter1: @escaping Filter, 
        with filter2: @escaping Filter) -> Filter 
    {
        return { image in filter2(filter1(image)) }
    }
    

compose(filter:with:) 函数接受两个 Filter 类型的参数,并返回一个新滤镜。这个复合滤镜接受一个 CIImage 类型的图像参数,然后将该参数传递给 filter1,取得返回值之后再传递给 filter2。这里我们举一个例子,来阐述我们可以如何使用 compose(filter:with:) 定义自己的复合滤镜:

let blurAndOverlay = compose(filter: blur(radius: radius), 
        with: overlay(color: color))
    let result1 = blurAndOverlay(image)
    

为了让代码更具可读性,我们还可以再进一步,为组合滤镜引入自定义运算符。诚然,随意自定义运算符并不一定对提升代码可读性有帮助。不过,在图像处理库中,滤镜的组合是一个反复被讨论的问题。一旦你知道了这个运算符,滤镜的定义也会随之变得易读:

infix operator >>>
    
    func >>>(filter1: @escaping Filter, filter2: @escaping Filter) -> Filter {
        return { image in filter2(filter1(image)) }
    }
    

现在我们可以使用 >>> 运算符达到与之前使用 compose(filter:with:) 相同的目的:

let blurAndOverlay2 = 
        blur(radius: radius) >>> overlay(color: color)
    let result2 = blurAndOverlay2(image)
    

由于运算符 >>> 默认是左结合的 (left-associative),就像 Unix 的管道一样,因此滤镜将以从左到右的顺序被应用到图像上。

我们定义的组合滤镜运算符是一个复合函数的例子。在数学中,f 和 g 两个函数构成的复合函数有时候被写作 f · g,表示定义的新函数将输入的 x 映射到 f(g(x))。除了顺序,这恰恰也是我们的 >>> 运算符所做的:将一个图像参数传递给运算符操作的两个滤镜。

理论背景:柯里化

在本章中,我们已经反复写了好多次类似下面这样的代码:

blur(radius: radius)(image)
    

先调用一个函数,该函数返回新函数 (本例指 Filter),然后传入另一个参数并调用之前返回的新函数。事实上也可以简单地传递两个参数给 blur 函数,然后直接返回图像,两者效果完全相同:

let blurredImage = blur(image: image, radius: radius)
    

写一个函数返回另一个函数,只是为了再次调用被返回的函数。为什么我们要选择这个看似更复杂的方式呢?

结合上文我们知道,有两种等效的方式能够定义一个可以接受两个 (或更多) 参数的函数。对于大多数程序员来说,应该会觉得第一种风格更熟悉:

func add1(_ x: Int, _ y: Int) -> Int {
        return x + y
    }
    

add1 函数接受两个整型参数并返回它们的和。然而在 Swift 中,我们对该函数的定义还可以有另一个版本:

func add2(_ x: Int) -> ((Int) -> Int) {
        return { y in x + y }
    }
    

这里的 add2 函数接受第一个参数 x 之后,返回一个闭包,然后再接受第二个参数 y。这与滤镜函数的结构一模一样。

因为该函数的箭头向右结合 (right-associative),所以我们可以移除包围在结果函数类型周围的括号。从而得到本质上与 add2 完全等价的函数 add3

func add3(_ x: Int) -> (Int) -> Int {
        return { y in x + y }
    }
    

add1 与 add2 的区别在于调用方式:

add1(1, 2) // 3
    add2(1)(2) // 3
    

在第一种方法中,我们将两个参数同时传递给 add1;而第二种方法则首先向函数传递第一个参数 1,然后将返回的函数应用到第二个参数 2。两个版本是完全等价的:我们可以根据 add2 来定义 add1,反之亦然。

add1 和 add2 的例子向我们展示了如何将一个接受多参数的函数变换为一系列只接受单个参数的函数,这个过程被称为柯里化 (Currying),它得名于逻辑学家 Haskell Curry;我们将 add2 称为 add1 的柯里化版本。

那么,为什么说柯里化很有趣呢?正如迄今为止我们在本书中所看到的,在一些情况下,你可能想要将函数作为参数传递给其它函数。如果我们有像 add1 一样未柯里化的函数,那我们就必须同时用到它的全部两个参数来调用这个函数。然而,对于一个像 add2 一样被柯里化了的函数来说,我们有两个选择:可以使用一个或两个参数来调用。

在本章中为了创建滤镜而定义的函数全部都已经被柯里化了 —— 它们都接受一个附加的图像参数。按照柯里化风格来定义滤镜,我们可以很容易地使用 >>> 运算符将它们进行组合。假如我们用这些函数未柯里化的版本来构建滤镜的话,虽然依然可以写出相同的滤镜,但是这些滤镜的类型将根据它们所接受的参数不同而略有不同。这样一来,想要为这些不同类型的滤镜定义一个统一的组合运算符就要比现在困难得多了。

讨论

这个例子再一次阐释了将复杂的代码拆解为小块的方式,而这些小块可以使用函数的方式进行重新装配,并形成完整的功能。本章的目标并不是为 Core Image 定义一个完整的 API,而是想说明在更实际的案例中如何使用高阶函数和复合函数。

为什么要做这么多努力呢?事实上 Core Image API 已经很成熟并能够提供几乎所有你可能需要的功能。但是尽管如此,我们相信本章所设计的 API 也有一些优点:

  • 安全 — 使用我们构筑的 API 几乎不可能发生由未定义键或强制类型转换失败导致的运行时错误。

  • 模块化 — 使用 >>> 运算符很容易将滤镜进行组合。这样你可以将复杂的滤镜拆解为更小,更简单,且可复用的组件。此外,组合滤镜与组成它的组件是完全相同的类型,所以你可以交替使用它们。

  • 清晰易懂 — 即使你从未使用过 Core Image,也应该能够通过我们定义的函数来装配简单的滤镜。你完全不需要关心 kCIInputImageKey 或 kCIInputRadiusKey 这样的特定键如何进行初始化。单看类型,你几乎就能够知道如何使用 API,甚至不需要更多文档。

我们的 API 提供了一系列能够用来定义和组合滤镜的函数。使用和复用任何自定义的滤镜都是安全的。每个滤镜都能被独立测试和理解。比起原来的 Core Image API,我们的设计会更让人偏爱。如果要说理由的话,相信上面几点足够让人信服。

Map、Filter 和 Reduce

接受其它函数作为参数的函数有时被称为高阶函数。在 Swift 标准库中就有几个作用于数组的高阶函数,本章中,我们将对它们进行探索。伴随这个过程,同时将介绍 Swift 的泛型,以及展示如何将复杂计算运用于数组。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

可选值

Swift 的可选类型可以用来表示可能缺失或是计算失败的值。本章将介绍如何有效地利用可选类型,以及它们在函数式编程范式中的使用方式。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

案例研究:QuickCheck

近些年来,在 Objective-C 中,测试变得越来越普遍。现在有许多流行的库通过持续集成工具来进行自动化测试。XCTest 是用来写单元测试的标准框架。此外,很多第三方框架 (例如 Specta、 Kiwi 和 FBSnapshotTestCase) 也已经可供使用,同时现阶段还有若干 Swift 的新框架正在开发中。

所有的这些框架都遵循一个相似的模式:测试通常由一些代码片段和预期结果组成。执行代码之后,将它的结果与测试中定义的预期结果相比较。不同的库测试的层次会有所不同 —— 有的测试独立的方法,有的测试类,还有一些执行集成测试 (运行整个应用)。在本章中,我们将通过迭代的方法,一步一步完善并最终构建一个针对 Swift 函数进行“特性测试 (property-based testing)”的小型库。

在写单元测试的时候,输入数据是程序员定义的静态数据。比方说,在对一个加法进行单元测试时,我们可能会写一个验证 1 + 1 等于 2 的测试。如果加法的实现方式破坏了这个特性,测试就会失败。不过,为了更一般化,我们可以选择测试加法的交换律 —— 换句话说,就是验证 a + b 等于 b + a。为了进行这项测试,我们可以写一个测试用例来验证 42 + 7 等于 7 + 42

QuickCheck (Claessen and Hughes 2000) 是一个用于随机测试的 Haskell 库。相较于独立的单元测试中每个部分都依赖特定输入来测试函数是否正确,QuickCheck 允许你描述函数的抽象特性并生成测试来验证这些特性。当一个特性通过了测试,就没有必要再证明它的正确性。更确切地说,QuickCheck 旨在找到证明特性错误的临界条件。在本章中,我们将用 Swift (部分地) 移植 QuickCheck 库。

这里举例说明会比较好。假设我们想要验证加法是一个满足交换律的运算。于是,首先为两个整型数值 x 和 y 写了一个函数,来检验 x + y 与 y + x 是否相等:

func plusIsCommutative(x: Int, y: Int) -> Bool {
        return x + y == y + x
    }
    

用 QuickCheck 检验这条语句就像调用 check 函数一样简单:

check("Plus should be commutative", plusIsCommutative)
    
    /** 打印:
        "Plus should be commutative" passed 10 tests.
    */
    

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

不可变性的价值

Swift 有一些机制,用于控制值的变化方式。在本章中,我们将介绍这些不同的机制是如何工作的,以及如何区别值类型和引用类型,并证明为什么限制可变状态的使用是一个良好的理念。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

枚举

在设计和实现 Swift 应用时,类型扮演着非常重要的角色,这也是本书想说明的重点之一。在这一章,我们将介绍 Swift 中的枚举类型。借此,你可以创建更为严密的类型,来表示应用中使用的数据。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

纯函数式数据结构

在前面的章节中,我们了解了如何利用枚举针对正在开发的应用来定义特定类型。而本章中,我们会定义可递归的枚举,并向大家展示,如何利用这个特性来定义一些高性能且非可变的数据结构。

所谓纯函数式数据结构 (Purely Functional Data Structures) 指的是那些具有不变性的高效的数据结构。像 C 或 C++ 这样的指令式语言中的数据结构往往是可变的,直接将这类数据结构使用在函数式语言中往往会水土不服。通过本章,我们想向您展示函数式语言中的纯函数式数据结构的构建方式和特点。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

案例研究:图表

在本章中,我们会看到一种描述图表的函数式方式,并讨论如何利用 Core Graphics 来绘制它们。通过对 Core Graphic 进行一层函数式的封装,可以得到一个更简单且易于组合的 API。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

迭代器和序列

在本章中,我们将关注点放在了迭代器 (Iterators) 和序列 (Sequences) 上,正是它们,组成了 Swift 中 for 循环的基础体系。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

案例研究:解析器组合算子

在试图将一系列符号 (通常是一组字符) 转换为结构化的数据时,解析器 (parser) 是一个非常有用的工具。有些时候,你可以使用正则表达式作为替代,来解析一串简单的字符串,但正则表达式能做的事很有限,且难以在短时间内理解其含义。一旦你知道了如何去编写一个解析器 (或是如何使用一个已有的解析器库),它就会变成你工具箱中非常有用的工具之一。

想要创建一个解析器,通常有这样几种方式:首先,你可以手工编码解析器,通过使用类似于标准库里 Scanner 类的这样的辅助工具,或许会让整个过程变得简单一些。或者,你可以使用额外的工具来生成一个解析器,比如 Bison 或是 YACC。又或是,你可以使用一个解析器组合算子库,在很多情况下,与之前各有优劣的两种方案相比,这种方案会更为折中。

解析器组合算子是一种函数式的解析方案。不同于对解析器的可变状态进行管理 (比如我们即将要处理的字符),解析器组合算子使用纯函数来避免可变状态。在本章,我们将探索解析器组合算子的工作方式,同时自行编写一些解析器组合算子库的核心部件。作为本章的示例,我们将尝试解析类似 1+2*3 这样简单的算术表达式。

我们的目标并非编写一个全面的组合算子库,而只是学习解析器组合算子库背后的工作原理,以便你能够毫不费力地使用它们。相对于自己编写来说,在大部分情况下,使用一个已有的库总是更好的选择。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

案例研究:构建一个表格应用

在本章中,我们将对上一章中用于解析算术表达式的代码进行拓展,并基于这些代码构建出一个简易的电子表格应用。我们会把这项工作分为三步:首先,我们要为希望被解析的表达式编写解析器;然后,我们会为这些表达式编写一个求值器;最后,我们会将以上代码嵌入一套简单的用户界面中。

抱歉,本章其余内容不包含于预览中

请考虑购买本书,或者点击这里了解更多关于本书的内容。

函子、适用函子与单子

在本章中,我们会解释一些函数式编程中的专用术语和一些常见模式,比如函子 (Functor)、适用函子 (Applicative Functor) 和单子 (Monad) 等。理解这些常见的模式,会有助于你设计自己的数据类型,并为你的 API 选择更合适的函数。

函子

迄今为止,我们已经遇到了几个被命名为 map 的方法,类型分别如下:

extension Array {
        func map<R>(transform: (Element) -> R) -> [R]
    }
    extension Optional {
        func map<R>(transform: (Wrapped) -> R) -> R?
    }
    
    extension Parser {
        func map<T>(_ transform: @escaping (Result) -> T) -> Parser<T> 
    }
    

为什么这三个截然不同的方法会拥有相同的方法名呢?要回答这个问题,我们需要先探究这三个方法的相似之处。在开始之前,将 Swift 使用的一些简写符号进行展开会帮助我们更容易理解发生了什么。像 Int? 这样的可选值也可以被显式地写作 Optional<Int>。同样地,我们也可以使用 Array<T> 来替换 [T]。如果我们按照上面的书写方式定义数组和可选值的 map 方法,相似之处就变得明显了起来:

extension Array {
        func map<R>(transform: (Element) -> R) -> Array<R>
    }
    extension Optional {
        func map<R>(transform: (Wrapped) -> R) -> Optional<R>
    }
    
    extension Parser {
        func map<T>(_ transform: @escaping (Result) -> T) -> Parser<T> 
    }
    

Optional 与 Array 都是需要一个泛型作为参数来构建具体类型的类型构造体 (Type Constructor)。对于一个实例来说,Array<T> 与 Optional<Int> 是合法的类型,而 Array 本身却并不是。每个 map 方法都需要两个参数:一个即将被映射的数据结构,和一个类型为 (T) -> U 的函数 transform。对于数组或可选值参数中所有类型为 T 的值,map 方法会使用 transform 将它们转换为 U。这种支持 map 运算的类型构造体 —— 比如可选值或数组 —— 有时候也被称作函子 (Functor)。

实际上,我们之前定义的很多类型都是函子。比如,我们可以为第八章中的 Result 类型实现一个 map 方法:

extension Result {
        func map<U>(f: (T) -> U) -> Result<U> {
            switch self {
            case let .success(value): return .success(f(value))
            case let .error(error): return .error(error)
            }
        }
    }
    

类似地,我们之前看到的二叉搜索树、字典树以及解析器组合算子等类型都是函子。函子有时会被描述为一个储存特定类型值的“容器”。而 map 方法则用来对储存在容器中的值进行转换。作为一个直观的印象,这样理解或许会有帮助,但却有点过于狭隘。还记得我们在第二章中看到的 Region 类型么?

struct Position {
        var x: Double
        var y: Double
    }
    
typealias Region = (Position) -> Bool
    

如果只使用 Region 的定义 (一个返回布尔值的函数),我们至多能生成一些非黑即白的位图。可以将这个定义泛型化,对每一个位置关联信息的类型做一次抽象:

struct Region<T> {
        let value: (Position) -> T
    }
    

通过这样的定义,我们可以为每个位置关联一个布尔值、RGB 色值、或者任意其他的信息。我们甚至还可以为这些泛型区域定义一个 map 方法。实际上,上述定义可以被归纳为一个复合函数:

extension Region {
        func map<U>(transform: @escaping (T) -> U) -> Region<U> {
            return Region<U> { pos in transform(self.value(pos)) }
        }
    }
    

就反驳“函子是一个容器”这一直观印象来说,上文定义的 Region 算得上是一个绝佳的反例。在这里,我们用来表示区域的类型是一个函数,与“容器”可谓是风马牛不相及了。

基本上,你在 Swift 中定义的所有泛型枚举都是一个函子。如果能为这些枚举提供一个强大而又熟悉的 map 方法,我们的开发者小伙伴们一定会乐不可支的。

适用函子

除了 map ,许多函子还支持其它的运算。比如第十二章中的解析器,它除了是一个函子之外,还定义了以下运算:

func <*><A, B>(lhs: Parser<(A) -> B>, rhs: Parser<A>) 
        -> Parser<B> {
    

运算符 <*> 将两个解析器顺序化合并:第一个解析器返回一个函数,而第二个解析器为这个函数返回一个参数。对该运算符的选用并非是偶然。对于任意的类型构造体,如果我们可以为其定义恰当的 pure 与 <*> 运算,我们就可以将其称之为一个适用函子 (Applicative Functor)。或者再严谨一些,对任意一个函子 F,如果能支持以下运算,该函子就是一个适用函子:

func pure<A>(_ value: A) -> F<A>
    func <*><A, B>(f: F<A -> B>, x: F<A>) -> F<B>
    

虽然我们并没有为 Parser 定义 pure 运算,但你自己也可以很容易的实现它。实际上,适用函子一直隐藏在本书的各个角落中。比如,上一节定义的 Region 也是一个适用函子:

precedencegroup Apply { associativity: left }
    infix operator <*>: Apply
    
    func pure<A>(_ value: A) -> Region<A> {
        return Region { pos in value }
    }
    
    func <*><A, B>(regionF: Region<(A) -> B>, regionX: Region<A>) -> Region<B> {
        return Region { pos in regionF.value(pos)(regionX.value(pos)) }
    }
    

现在,函数 pure 可以使任意区域都返回某个特定的值。而运算符 <*> 则会将结果区域的参数 Position 分别传入它的两个参数区域,其中一个参数会生成一个类型为 (A) -> B 的函数,另一个则会生成一个类型为 A 的值。接着,合并这两个区域的方法相信你也猜得到,将第一个参数返回的函数应用在第二个参数生成的值上。

许多为 Region 定义的函数都可以借由这两个基础构建模块简短地描述出来。参照第二章中的内容,这里以适用函子的形式编写了一小部分函数作为示例:

func everywhere() -> Region<Bool> {
        return pure(true)
    }
    
    func invert(region: Region<Bool>) -> Region<Bool> {
        return pure(!) <*> region
    }
    
    func intersection(region1: Region<Bool>, region2: Region<Bool>)
        -> Region<Bool>
    {
        let and: (Bool, Bool) -> Bool = { $0 && $1 }
        return pure(curry(and)) <*> region1 <*> region2
    }
    

上述代码展示的,便是利用 Region 类型的适用实例逐个定义区域运算的方式。

适用函子并不仅限于区域和解析器。Swift 内建的可选类型就是适用函子的另一个例子。对应的定义简单明了:

func pure<A>(_ value: A) -> A? {
        return value
    }
    
    func <*><A, B>(optionalTransform: ((A) -> B)?, optionalValue: A?) -> B? {
        guard let transform = optionalTransform,
              let value = optionalValue else { return nil }
        return transform(value)
    }
    

pure 函数将一个值封装在可选值中。由于这个过程通常会被 Swift 做隐式的处理,我们定义的这个版本并不是很实用。而运算符 <*> 就会更有意思一些:传入一个 (可能为 nil 的) 函数和一个 (可能为 nil 的) 参数,它会在两个可选值同时有值时,将函数“适用”在参数上,并将结果返回。如果两个参数中任意一个为 nil,则运算结果也会是 nil。我们也可以为第八章中的 Result 类型定义类似的 pure 与 <*>

这些定义单独来看并不能发挥什么特别厉害的功效,但组合起来就会很有意思。我们可以回顾一些之前的例子,还能回想起之前的 addOptionals 函数么?一个尝试计算两个可能为 nil 的整数之和的函数:

func addOptionals(optionalX: Int?, optionalY: Int?) -> Int? {
        guard let x = optionalX, y = optionalY else { return nil }
        return x + y
    }
    

利用之前的定义,只需要一条 return 语句,我们就可以定义出一版更为简洁的 addOptionals

func addOptionals(optionalX: Int?, optionalY: Int?) -> Int? {
        return pure(curry(+)) <*> optionalX <*> optionalY
    }
    

一旦你理解了 <*> 以及类似运算符中所包含的控制流,以上述方式去组织一些复杂的计算就变得轻而易举了。

在可选值章节中还有一个值得回顾的例子:

func populationOfCapital(country: String) -> Int? {
        guard let capital = capitals[country], population = cities[capital]
            else { return nil }
        return population * 1000
    }
    

在这里,我们需要查阅一个字典 capitals,来检索特定国家的首都城市。接着查阅另一个字典 cities 来确定该城市的人口数量。尽管与前例中 addOptionals 的运算过程差不太多,可这个函数却无法以适用的方式来编写。如果我们尝试这样做的话,会发生以下的情况:

func populationOfCapital(country: String) -> Int? {
        return { pop in pop * 1000 } <*> capitals[country] <*> cities[...]
    }
    

这里的问题在于,之前的版本中,第一次查询的结果被绑定在了变量 capital 上,而第二次查询需要调用这个变量。如果只使用适用方式来运算,就会被卡在这里:一个适用运算的结果 (上例中的 capitals[country]) 是无法影响另一个适用运算 (在 cities 中进行查询) 的。要解决这个问题,我们还需要其它的接口。

单子

第五章中,我们给出了 populationOfCapital 的另一种定义方式:

func populationOfCapital3(country: String) -> Int? {
        return capitals[country].flatMap { capital in
            return cities[capital]
        }.flatMap { population in
            return population * 1000
        }
    }
    

在这里,我们使用了内建的 flatMap 方法来组合可选值的计算过程。这与适用接口的不同点在哪里呢?答案是,类型。在适用运算符 <*> 中,两个参数都是可选值,反观 flatMap 方法,第二个参数却是一个返回可选值的函数。也正是因此,我们才得以将第一个字典的检索结果传递给第二个字典来进行查阅。

只在适用函数之间进行组合是无法定义出 flatMap 方法的。实际上,flatMap 是单子结构支持的两个函数之一。更通俗地说,如果一个类型构造体 F 定义了下面两个函数,它就是一个单子 (Monad):

func pure<A>(_ value: A) -> F<A>
    
    func flatMap<A, B>(x: F<A>)(_ f: (A) -> F<B>) -> F<B>
    

flatMap 函数有时会被定义为一个运算符 >>=。鉴于它将第一个参数的计算结果绑定到第二个参数的输入上去,这个运算符也被称为“绑定 (bind)”运算。

除了 Swift 的可选值类型之外,第八章中定义的枚举 Result 也是一个单子。按照这个思路,将一些可能返回 Error 的运算连接在一起就变得可行了。比如,我们可以定义一个用来将一个文件的内容复制到另一个文件的函数,如下例:

func copyFile(sourcePath: String, targetPath: String, encoding: Encoding)
        -> Result<()>
    {
        return readFile(sourcePath, encoding).flatMap { contents in
            writeFile(contents, targetPath, encoding)
        }
    }
    

如果对 readFile 与 writeFile 中任何一个方法的调用失败,一个 Error 就会被抛出。虽然上例不如 Swift 的可选值绑定机制那么好用,但也十分接近了。

除了处理错误之外,单子还有很多其它的用武之地。比如,数组也是一个单子。在标准库中,数组的 flatMap 方法已经被定义了,不过你也可以像这样来实现:

func pure<A>(_ value: A) -> [A] {
        return [value]
    }
    
    extension Array {
        func flatMap<B>(_ f: (Element) -> [B]) -> [B] {
            return map(f).reduce([]) { result, xs in result + xs }
        }
    }
    

我们能从这些定义中得到什么呢?作为一个单子结构,数组提供了一种便利的方式来定义各种各样的可组合函数,又或是解决搜索相关的问题。举个例子,假设我们需要计算两个数组 xs 与 ys 的笛卡尔积。笛卡尔积是一个由多元组组成的新数组,每个多元组的第一部分都从 xs 中抽取,第二部分则从 ys 中抽取。直接使用 for 循环的话,我们可能会这样写:

func cartesianProduct1<A, B>(xs: [A], ys: [B]) -> [(A, B)] {
        var result: [(A, B)] = []
        for x in xs {
            for y in ys {
                result += [(x, y)]
            }
        }
        return result
    }
    

我们现在可以使用 flatMap 替换 for 循环来重写 cartesianProduct

func cartesianProduct2<A, B>(xs: [A], ys: [B]) -> [(A, B)] {
        return xs.flatMap { x in ys.flatMap { y in [(x, y)] } }
    }
    

方法 flatMap 允许我们从第一个数组 xs 中提取一个元素 x,然后在 ys 中提取一个元素 y。对于每一组 x 与 y,我们都会返回一个数组 [(x, y)]。最后,flatMap 方法会将所有这些数组合并为一个结果集。

尽管这个例子看起来有点勉强,flatMap 方法在数组中还是用很多很重要的应用场景。像是 Haskell 与 Python 这样的语言,会专门提供一些语法糖来定义列表,这被称作列表推导式 (list comprehensions)。这些列表推导式允许你从已经存在的列表中抽取元素并检查这些元素是否符合某些指定的规则。所有列表推导式语法糖都可以脱糖为 mapfilter 与 flatMap 的组合。列表推导式与 Swift 中的可选值绑定很相似,只不过它们作用于列表而不是可选值。

posted @ 2020-08-26 10:03  为敢技术  阅读(322)  评论(0编辑  收藏  举报