《Pro Swift》 第六章:函数式编程(Functional programming)

如果需要每个元素的索引及其值,可以使用enumerated()方法遍历数组:

for (index, element) in loggerContent.enumerated() {
   logfiles["logfile\(index).txt"] = element
}

-- Veronica Ray (@nerdonica), software engineer at LinkedIn

什么是函数式编程?

取决于你来自哪里,函数式编程可能是一种非常正常的代码编码方式,它可能是某种偷偷溜出大学校园的学术工具,也可能是你用来吓唬孩子睡觉的东西。事实上,函数式编程的使用范围很广,从“我基本上在阅读代数”到“我主要使用面向对象的代码,以及一些我需要的函数式技术”。

我读过很多关于函数式编程的教程,这些教程的本意是好的,但最终可能弊大于利。你看,函数式编程并不难用实际的方法来学习和使用,但是你也可以用 monadfunctor 来压倒人们。我的目的是教会你函数式编程的好处,同时也减少你不太可能从中受益的部分。我不道歉:这整本书都是教你如何立即动手改进编码,而不是解释什么是引用透明性。

我想用 Andy Matuschak 所说的“轻量级接触”来教你一些函数式编程。这意味着我们将集中精力寻找简单、小的好处,您可以立即理解和使用。Andy 是一位经验丰富的函数式编程人员,但他在学习方法方面也很务实——我希望他不会对我简化(甚至抛弃)这么多理论太反感!

在讨论函数代码之前,我想让你大致了解一下为什么我们可能要更改当前的工作方式。你可能在过去广泛地使用过面向对象:你已经创建了类,从这些类生成子类,添加了方法和属性,等等。如果你已经阅读了关于引用和值类型的章节,你还将知道对象是引用类型,这意味着属性可以由它的任何多个所有者进行更改。

我们认为面向对象很简单,因为这是我们所知道的。如果我告诉你PoodlePoodle继承自Dog,具有barkVolume属性和biteStrength属性,以及barkWorseThanBite()方法,你马上就会明白这意味着什么。但这并不简单。它很复杂:一个“简单”的类混合了状态、功能、继承等等,所以你需要在脑子里记住很多东西才能跟上。

函数式编程——或者至少是我将在这里使用的稍微简化的定义——可以极大地简化代码。首先,这将导致问题,因为你正在有效地对抗面向对象的肌肉记忆:你通过创建一个新类来解决每个问题的本能需要暂时搁置,至少目前是这样。

相反,我们将应用 5 条原则,帮助你在不使用代数的情况下实现函数式编程的好处。

首先,函数是一等数据类型。这意味着它们可以像整数和字符串一样被创建、复制和传递。其次,因为函数是一等数据类型,所以它们可以用作其他函数的参数。第三,为了使我们的函数能够以不同的方式重用,当给定特定的输入时,它们应该总是返回相同的输出,并且不会产生任何副作用。第四,由于函数总是为某些给定的输入返回相同的输出,所以我们应该使用不可变的数据类型,而不是使用函数来更改可变变量。第五,也是最后一点,因为我们的函数不会产生副作用,而且变量都是不可变的,所以我们可以减少我们在程序中跟踪的状态的数量——并且常常可以完全消除它。

我知道一下子要理解很多东西,所以让我试着把每一个都分解得更详细些。

你应该已经知道函数在 Swift 中是一等数据类型——毕竟,你可以复制闭包并传递它们。这是第一条。接下来,将函数作为参数传递给其他函数也是你可能已经做过的事情,比如使用闭包调用sort()。你可能会遇到“高阶函数”这个名称,它是为接受另一个函数作为参数的函数指定的名称。

当我们编写的函数总是为给定的输入生成相同的输出时,事情就变得有点复杂了——但有趣得多。这意味着,如果你编写一个函数lengthOf(string:),它接受一个字符串数组并根据每个字符串的长度返回一个整数数组,那么当给定["Taylor", "Paul", "Adele"]时,该函数将返回[6, 4, 5]。不管程序中发生了什么,也不管函数被调用的频率:相同的输入必须返回相同的输出。

因此,函数不应该产生可能影响其他函数的副作用。例如,如果我们有另一个函数fetchSystemTime()返回时间,那么调用fetchSystemTime()不会影响lengthOf(strings:)的结果。对于给定的输入,总是返回相同的结果而不会产生副作用的函数通常称为纯函数。我想这就使得所有的函数都不纯了,你不想写很脏很脏的函数,对吧?

关于纯函数的混淆的一个来源是围绕副作用的含义。如果一个函数做了写磁盘之类的事情,这是一个副作用还是实际上只是函数的要点?关于这个有很多争论,我就不深入了。相反,我要说的是,函数式程序员应该渴望创建纯函数,但是当涉及到关键问题时,应该优先考虑已知输入的可预测输出,而不是避免副作用。也就是说,如果你想编写一个将一些数据写入磁盘的函数(一个副作用?实际效果如何?你想怎么调用就怎么调用!),然后继续,但至少要确保在给定相同的数据时,它所写的内容完全相同。

我已经讨论了不可变数据类型和值(而不是引用)的重要性,所以我不会再讨论它们,只是说类在函数代码中就像在血友病公约中刺猬一样受欢迎。

最后,状态的缺乏是很棘手的,因为它已经深入到对象定向中。“状态”是由程序存储的一系列值,这并不总是一件坏事——它包括缓存一些东西来提高性能,以及一些重要的东西,比如用户设置。当在函数中使用这种状态时,问题就出现了,因为这意味着函数不再是可预测的。

使用lengthOf(strings:)的例子,考虑如果我们有一个名为returnLengthsAsBinary的布尔值设置会发生什么——当给定["Taylor", "Paul", "Adele"]时,该函数可能返回[6, 4, 5],也可能返回['110', '10', '101'],这取决于一个外部的布尔值。要务实,不要不惜任何代价避免状态,但绝不要让它污染你的函数代码,并尽可能地减少它的使用。

当这五个原则结合在一起时,你会得到许多即时的、有价值的好处。当你编写产生可预测输出的函数时,你可以为它们编写简单的单元测试。当你使用不可变的值类型而不是引用类型时,你将删除应用程序中不可预料的依赖项,并使你的代码更容易推理。当你构建一些小的、可组合的功能,这些功能可以与高阶功能结合,并以多种方式重新使用,你可以通过将许多小的、简单的部件组合在一起来构建功能强大的应用程序。

注意:我将在后面的章节中提到这五个原则。我不想一遍又一遍地重复,我只想说希望你能记住这五个函数原则:

  • 一等数据类型
  • 高阶函数
  • 纯函数
  • 不变性
  • 减少状态

好吧,理论足够了。我希望我已经成功地说服了你,函数式编程可以为每个人提供一些东西,即使你只从下面几节中获取一些概念,这也是一个很大的改进。

map()

让我们从函数编程的最简单的map()方法开始。这将从容器中取出一个值,对其应用一个函数,然后将该函数的结果放回返回给你的新容器中。Swift 的数组、字典和集合都内置了map()方法,它通常用于遍历数组中的每一项,同时将函数应用于每个值。

我已经提到过一个lengthOf(string:)函数,它接受一个字符串数组并根据输入字符串的大小返回一个整数数组。你可以这样写:

func lengthOf(strings: [String]) -> [Int] {
   var result = [Int]()
   for string in strings {
      result.append(string.characters.count)
   }
   return result
}

该函数接受一个字符串数组,并基于这些字符串返回一个整数数组。这是map()的完美用法,实际上我们可以用这个替换所有代码:

func lengthOf(strings: [String]) -> [Int] {
   return strings.map { $0.characters.count }
}

很明显,函数式方法更短,但它的好处不仅仅是编写更少的代码。相反,函数式版本向编译器传达了更重要的含义:现在很明显,我们想要对数组中的每一项应用一些代码,而要高效地实现这一点,取决于 Swift 。就我们所知,Swift 可以并行化你的闭包,这样它可以一次处理 4 项,或者它可以以比从头到尾更有效的顺序处理这些项。

使用map()也向其他程序员表明了我们的意图:它将遍历数组中的每一项并对其应用一个函数。使用传统的for循环,你可能会在执行到一半的时候有一个中断—map()不可能做到这一点—而找到这个中断的惟一方法是读取所有代码。如果你遵循我已经列出的 5 条函数原则(特别是使用纯函数和避免状态),那么阅读你代码的人就会立即知道map()使用的闭包不会试图存储全局状态。

这种简化是很重要的,这是一个关注点的改变。Javier SotoTwitter 著名的功能支持者和敏捷黑客—这样总结map()的用处:它允许我们表达我们想要实现什么,而不是如何实现。也就是说,我们只需说这是我们想对这个数组中的项做的事情,这比手工编写循环和手工创建数组更容易读、写和维护。

另一件事:注意类型签名没有发生变化。这意味着我们编写func lengthOf(String: [String]) -> [Int],不管我们是否在内部使用函数方法。这意味着你可以更改函数的内部结构,以采用函数方法,而不影响它们与应用程序其余部分的交互方式——你可以一点一点地更新代码,而不是一次更新所有代码。

例子

为了帮助你更好地使用map(),这里有一些示例。

这段代码将字符串转换为大写:

let fruits = ["Apple", "Cherry", "Orange", "Pineapple"]
let upperFruits = fruits.map { $0.uppercased() }

该代码段将整型表示的得分数组转换为格式化的字符串:

let scores = [100, 80, 85]
let formatted = scores.map { "Your score was \($0)" }

这两个代码段使用三目运算符创建字符串数组,根据特定的条件匹配每个项。第一段代码检查在 85 分以上的得分,第二段检查在 45 - 55 (含 45 - 55 )范围内的位置:

let scores = [100, 80, 85]
let passOrFail = scores.map { $0 > 85 ? "Pass" : "Fail" }

let position = [50, 60, 40]
let averageResults = position.map { 45...55 ~= $0  ? "Withinaverage" : "Outside average" }

最后,这个例子使用sqrt()函数来计算数字的平方根:

import Foundation
let numbers: [Double] = [4, 9, 25, 36, 49]
let result = numbers.map(sqrt)

如你所见,map()之所以叫这个名称,是因为它指定了从一个数组到另一个数组的映射。也就是说,如果你给它传递一个数组[a, b, c]和函数f()Swift 会给你等价的[f(a),f(b), f(c)]

Optional map

重复我前面所说的,map()从容器中取出一个值,应用一个函数,然后将该函数的结果放回一个返回给你的新容器中。到目前为止,我们一直在使用数组,但是如果你仔细想想, Optional 其实就是一个存放值的容器。它们的定义如下:

enum Optional<Wrapped> {
   case none
   case some(Wrapped)
}

因为它们只是单个值的简单容器,所以我们也可以在 Optional 上使用map()。原理是一样的:从容器中取出值,应用函数,然后再将值放回容器中。

let i: Int? = 10
let j = i.map { $0 * 2 }
print(j)

这将打印 Optional(20) :值 10Optional 容器中取出,乘以 2 ,然后放回 Optional 容器中。如果inilmap()只会返回nil。此行为使map()在操作可选值时非常有用,特别是与空值合并运算符组合时。

举个例子,考虑下面的函数:

func fetchUsername(id: Int) -> String? {
   if id == 1989 {
      return "Taylor Swift"
   } else {
      return nil 
   }
}

它返回一个可选字符串,因此我们要么返回 Taylor Swift 要么返回nil。如果我们想打印一条欢迎信息——但只有当我们得到一个用户名时——那么可选的map是完美的选择:

var username: String? = fetchUsername(id: 1989)
let formattedUsername = username.map { "Welcome, \($0)!" } ?? "Unknown user"
print(formattedUsername)

要用非函数式的方法来写,另一种方法要长得多:

let username = fetchUsername(id: 1989)
let formattedUsername: String
if let username = username {
   formattedUsername = "Welcome, \(username)!"
} else {
   formattedUsername = "Unknown user"
}
print(formattedUsername)

我们可以使用一个更短的替代方法,但它涉及到三目运算符和强制展开:

let username = fetchUsername(id: 1989)
let formattedUsername = username != nil ? "Welcome, \(username!)!" : "Unknown user"
print(formattedUsername)

forEach

map()有一个名为forEach()的紧密关系,它也遍历数组并对每个项执行一个函数。主要的区别在于返回值:map()返回一个新的项数组,而forEach()根本不返回任何项——这只是循环每个项的函数方法。

这为编译器和代码的读者提供了更多的信息:通过使用forEach(),你清楚地表明你没有操纵数组的内容,这使得 Swift 优化器可以做得更好。

除了返回值外,forEach()map()使用相同:

["Taylor", "Paul", "Adele"].forEach { print($0) }

forEach()map()之间还有一个不同之处,即执行顺序:forEach()保证按照数组的顺序遍历数组中的元素,而map()可以按照它喜欢的任何顺序执行。

在幕后,forEach()实际上可以归结为一个常规的for-in循环——它没有什么特别之处。下面是forEach()的内部 Swift 源代码,直接取自 Apple

public func forEach(_ body: (Iterator.Element) throws -> Void) rethrows {
   for element in self {
      try body(element)
    }
}

flatMap()

我不打算骗你,flatMap()一开始看起来很吓人。我把它放在这里,直接跟在令人惊讶的简单和有用的map()函数后面,因为这两个函数是紧密相关的,而不是因为我想用flatMap()让你在函数式生涯的早期感到震惊!

如你所见,map()从容器(如数组)中取出一个项,对其应用一个函数,然后将其放回容器中。最直接的用例是数组,但是 Optional 也可以。

当数组包含数组时,即数组的数组,你可以访问join()方法的一个版本,该方法将数组的数组转换为单个数组,如下所示:

let numbers = [[1, 2], [3, 4], [5, 6]]
let joined = Array(numbers.joined())
// [1, 2, 3, 4, 5, 6]

因此,join()将数组复杂度降低了一层:通过连接项,二维数组变成了一维数组。

flatMap()函数是在一个调用中有效地组合使用map()join(),按这个顺序。它使用你提供的函数将数组 A 中的项映射到数组 B 中,然后连接结果。当你记住数组和 Optional 都是容器时,这就变得很有价值了,因此flatMap()能够删除一个层级的容器确实非常受欢迎。

首先,让我们看看我们的朋友map()

let albums: [String?] = ["Fearless", nil, "Speak Now", nil, "Red"]
let result = albums.map { $0 }
print(result)

使用$0映射只意味着“返回现有值”,因此代码将打印以下内容:

[Optional("Fearless"), nil, Optional("Speak Now"), nil, Optional("Red")]

这是很多的可选值,一些nil分布其中。切换到flatMap()而不是map()可以帮助:

let albums: [String?] = ["Fearless", nil, "Speak Now", nil, "Red"]
let result = albums.flatMap { $0 }
print(result)

只需将map { $0 }更改为flatMap { $0 },结果就会发生显著变化:

["Fearless", "Speak Now", "Red"]

可选值没有了,nil也被移除——完美!

这种魔力的原因在于flatMap()的返回值:map()将保留它处理的项的可选性,而flatMap()将删除它。因此,在下面的代码中,mapResult的类型是[String?]flatMapResult类型为[String]

let mapResult = albums.map { $0 }
let flatMapResult = albums.flatMap { $0 }

Optional flat map

如果flatMap()的用处还不是很清楚,请跟我来!让我们再来看看这个例子:

let albums: [String?] = ["Fearless", nil, "Speak Now", nil, "Red"]
let result = albums.flatMap { $0 }
print(result)

albums数组的类型是[string?],因此它包含一个可选字符串数组。在这里使用flatmap()去掉了可选性,但实际上这只是连接可选容器的效果——它不做任何类型的映射转换。

现在,flatmap()变得非常棒:因为连接发生在映射之后,你可以有效地说“对这些项做一些有趣的事情,然后删除返回nil的任何项。”

举个实际的例子,假设有一个学校成绩计算器:学生被要求输入各种考试的分数,它将输出他们的估计成绩。这需要将用户输入的数字转换为整数,这是有问题的,因为它们可能出错或输入无意义的数字。因此,从字符串创建一个整数将返回一个可选整数——如果输入是“Fish”,则返回nil; 如果输入是“100”,则返回100

这对于flatMap()来说是一个完美的问题:我们可以取一个像["100","90","Fish", "85"]这样的数组,映射每个值,将其转换为一个可选的整数,然后加入得到的数组,删除可选性和任何无效的值:

let scores = ["100", "90", "Fish", "85"]
let flatMapScores = scores.flatMap { Int($0) }
print(flatMapScores)

这将输出[100,90,85] -- 完美!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容