0. 译者序
笔者读了《函数式Swift》、各种网文等,对函数式编程的理解依然有些模糊。
出现这种现象与以下几种原因有关:
- 函数式程序设计所依赖的理论基础(Lambda演算)很深奥(相对于程序员这种更多是技术型工作的人来说),导致其理论与实现都难于理解
- 日常接触的编程语言大多都是命令式的,所以对函数式编程这种是声明式的编程范式缺乏直观感受
- 没有时间亲自动手实践(^_^,比较懒的委婉说法)
幸运的是无意中发现的这篇文章——Functional Programming in Swift: an Unusual yet Powerful Paradigm大大提高了我对函数式编程的理解。之前,对基于Swift的函数式编程理解更多的是盲人摸象——了解高阶函数、不变性、单子、函子等概念。读了这篇文章,对于函数式编程的理论基础、适用场景、代码示例、与OOP的对比等都有比以前深入的理解,基本上能够达到对“大象”有全貌的认识。
为了加深自己的理解,同时也有助于其他网友理解的目的,决定对此文进行翻译。本文就是借助Google翻译的译文,由于E文不好,尽最大努力做到——信达雅。感兴趣的读者可以看原文。
译者注
这篇文章的深度依然不高,适合对函数式编程不了解的程序猿学习函数式编程的入门级指导。
1. 概述
图1.png 自从Swift问世以来,“函数式编程”这个词语就在iOS社区广泛流传开来。
这是因为许多Swift语言的特性都是受函数式编程语言的启发,而被引入到Swift语言。
译者注
像高阶函数、值类型(不可变性)、包装值(Result等)等这些语言特性。
这些引入的特性使某些任务更加容易完成。 一个示例是易于配置的网络请求回调。
虽然Swift不是一种纯的函数式编程语言,但是我们可以从函数式编程语言中吸取许多经验,以便在我们的iOSApp中写出更好的Swift代码。
2. 目录
目录.png2.1 Swift是多范式的程序设计语言
第一节.pngSwift不是纯函数式编程语言,它是一种支持多种编程范式的语言。可以使用Swift写出符合函数式思想的代码,但是你应该在合适的场景使用函数式编程思想。
2.1.1 Swift不是纯函数式编程语言并且iOS的SDK是面向对象的
在我们探讨Swift中的函数编程思想之前,值得一提的是,Swift不是一种纯函数式编程语言,同时也不意味着Swift想成为一种纯的函数式编程语言。
Swift与纯粹的函数式语言(例如Haskell)不同,你习惯的面向对象范式与函数式编程范式有很大不同。
例如,纯函数式编程语言没有像for或while那样的循环结构
(稍后我将向你展示如何在没有它们的情况下仍然可以编写程序)。 此外,在这些语言中,您需要了解函子和单子之类的概念。
译者注
如果想要了解函子、适用函子、单子之类的概念,可以自行百度,这里就不展开了。
在Swift中情况并非如此。
诸如Lisp之类的某些函数式语言是多范例的,因为它们还允许面向对象的编程。 但是它们始终保持函数式的核心思想,并且像Clojure这样的Lisp方言避开了传统的面向对象方法。
但是,在iOS中,我们必须主要使用面向对象的范例来编写程序。
一些开发人员试图将函数式编程概念塞入iOS开发的各个方面,甚至想要视图控制器中也使用它。
我发现这样做是适得其反的。
你写的函数式代码对大多数其他iOS开发人员都不熟悉。 更重要的是,你很快就会遇到平台的限制。 视图控制器是具有特定生命周期的类。
译者注
VC是具有生命周期的,这与函数式编程理念具有严重的冲突。
同时,没有办法规避这一点。
2.1.2 函数式编程的支柱:将输入转换为输出
在本文中,我将构建一个小型的Eliza聊天机器人,向您展示如何在实际应用中使用函数式编程概念。 您可以在GitHub上找到完整的项目。
Eliza是基于Rogerian心理疗法的自然语言处理程序。
虽然这听起来可能很复杂,但Eliza所做的只是将你写的所有内容变成一个问题。 例如,如果你说“我感到难过”,它将询问诸如“你经常感到难过吗?”之类的内容。
Eliza不使用任何像深度学习或神经网络这样的奇特的AI算法。 它仅查询预定义答案的列表。
Eliza的核心是围绕函数式编程的支柱构建的:它将输入转换为输出。 这使它成为广泛的函数式编程练习,您可以在网络上找到许多实现。
实际上,我将在本文中向你展示的实现是我十年前编写的用于学习函数式编程的代码。
2.1.3 以从高层到底层来替代从底层到高层的方式来编写一个App
我们将以自上而下的方式实现此应用。 我们将从用户界面开始,再到视图控制器级别,最后到底层算法细节。
我通常在许多文章中都采用自下而上的方法。 这使我更容易分解问题并逐步解释代码。
但是,自上而下编写代码也是一种合理的方法,有时甚至是更可取的方法,尤其是在你遵循依赖关系反转原则的情况下。
因此,我们将从应用程序的故事板开始。 它仅包含一个简单的表视图控制器,该控制器将显示与Eliza的对话。
Eliza故事版.png
并非所有UI都适合storyboard。 我们的视图控制器需要设置一个输入视图,用户可以在其中输入消息。 尽管可以在storyboard要场景中布置视图,但我们还需要一些设置代码。
class ChatViewController: UITableViewController {
@IBOutlet var messageInputView: UIView!
@IBOutlet var textField: UITextField!
override var inputAccessoryView: UIView? {
return messageInputView
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
textField.becomeFirstResponder()
}
override var canBecomeFirstResponder: Bool {
return true
}
}
2.1.4 视图控制器和其他一些UIKit代码不能转换为函数式
我们的视图控制器所需要做的就是填充其表视图。
通常,我将为动态UITableView创建一个单独的数据源类,但是为了使此示例保持简单,我将使用视图控制器。
class ChatViewController: UITableViewController {
@IBOutlet var messageInputView: UIView!
@IBOutlet var textField: UITextField!
var messages: [String] = ["Hello, I'm Eliza. What is bothering you today?"]
override var inputAccessoryView: UIView? {
return messageInputView
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
textField.becomeFirstResponder()
}
override var canBecomeFirstResponder: Bool {
return true
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return messages.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let row = indexPath.row
let identifier = row.isMultiple(of: 2) ? "ElizaCell" : "UserCell"
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! MessageCell
cell.message = messages[row]
return cell
}
}
class MessageCell: UITableViewCell {
@IBOutlet weak var label: UILabel!
var message: String? {
didSet { label.text = message }
}
}
在这里,我们将聊天消息保存在简单的字符串数组中。 然后,要为气泡使用正确的颜色,我们为具有偶数索引的行的Eliza单元出队,为用户为奇数行的单元出队。 (不要忘记在storyboard中设置单元格标识符。)
将新消息推送到聊天中很简单。 我们要做的就是:
- 在message数组的末尾附加新消息;
- 在表格视图的底部插入新行; 和
- 滚动表格视图,以使最后一条消息始终可见。
private extension ChatViewController {
func push(_ message: String) {
messages.append(message)
let newMessageIndexPath = IndexPath(row: messages.count - 1, section: 0)
tableView.insertRows(at: [newMessageIndexPath], with: .fade)
tableView.scrollToRow(at: newMessageIndexPath, at: .bottom, animated: true)
}
}
从较高的视角来看,我们的Eliza聊天机器人所需要做的就是回复信息。
struct Eliza {
func reply(to message: String) -> String {
return ""
}
}
最后,当用户发送消息时,我们将其推送到聊天中,然后推送Eliza的回复。
class ChatViewController: UITableViewController {
@IBOutlet var messageInputView: UIView!
@IBOutlet var textField: UITextField!
var messages: [String] = ["Hello, I'm Eliza. What is bothering you today?"]
override var inputAccessoryView: UIView? {
return messageInputView
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
textField.becomeFirstResponder()
}
override var canBecomeFirstResponder: Bool {
return true
}
@IBAction func send(_ sender: Any) {
guard let message = textField.text, !message.isEmpty else {
return
}
push(message)
textField.text = nil
push(Eliza().reply(to: message))
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return messages.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let row = indexPath.row
let identifier = row.isMultiple(of: 2) ? "ElizaCell" : "UserCell"
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! MessageCell
cell.message = messages[row]
return cell
}
}
由于我们是自上而下编写代码的,因此,目前Eliza结构的Reply(to :)方法仅返回一个空字符串。
这使我们可以在专注于Eliza算法之前完成ChatViewController的实现。 在整篇文章中,我将遵循这种方法。
2.2 基于Swift的函数式编程
image.pngSwift的函数式编程范例不只是一种编写代码的方式。它是一种使你以一种新的视角思考代码的不一样的编程范式。
2.2.1 命令式范式着重于描述程序的工作方式
在开始编写功能代码之前,我们首先需要回答一个基本问题。
什么使函数式编程如此不同?
顾名思义,就是它使用函数。 但是我们已经将所有Swift代码都放入了函数中。 所以,那不是函数式编程吗?
显然不是,否则我不会写这篇文章。 区别在于范例。你(以及大多数开发人员)习惯的编程范例称为命令式。
在命令式编程中,我们编写了一系列更改程序内部状态的指令。 我们关注的是描述程序的运行方式。
命令式编程基于称为图灵机的数学计算模型。
简单来说,一个图灵机有以下特点:
- 具有内部状态
- 无限的存储空间
- 可以在存储空间左右移动并进行读写操作
-
一张描述其如何工作的指令
每条指令读取机器的状态和磁带上的数据。 结果,磁头可以在磁带上移动并写入新数据,并且机器可以更改状态。
图灵机.png
你不难发现该模型与你编写的程序之间的相似性。
我们在上面的ChatViewController中编写的代码就是命令式的。 每个方法均由一系列指令组成,这些指令可更改应用程序的内部状态。
2.2.2 函数式编程关注与程序做什么而不是怎么做
图灵机不是唯一的数学计算模型。
还有另一种叫做Lambda演算的方法,它是在Turing机器之前发明的。 虽然公平地说,图灵机是基于一个世纪前的分析引擎。
乍一看,lambda演算看起来很奇怪。
Lambda演算.png
尽管这是一个有趣的话题,但我们无需关心lambda演算的工作原理(如果您想知道为什么我对它如此了解,我就此做了一篇硕士论文)。
我们关心的是它基于纯函数的数学思想。
一个纯函数:
- 返回值基于其参数
它不访问任何全局变量(如单例,非局部变量(如类或结构的属性)或任何其他输入流(文件,设备传感器等)。 - 不产生任意的副作用
它仅返回一个值,而不会在其范围外改变任何状态。
我们在Eliza结构中编写的平凡的Reply(to :)方法是一个纯函数。 我们将在本文中找到更多示例。
事实证明,图灵机和Lambda演算是等效的。 这意味着我们还可以基于Lambda微积分建立编程范例,这使我们能够执行命令式编程中可以做的任何事情。
这正是Lisp的创建者所做的,他们创建了函数式编程。
函数式程序不是命令式的,而是声明性的,这意味着它专注于程序的功能而不是程序的工作方式。
2.2.3 在Swift代码中使用函数式编程的好处
此时,出现了一个明显的问题。
我们为什么要关心呢?
毕竟,我们已经可以使用命令式范例编写Swift程序了。 谁在乎很少开发人员使用的精美学术语言。
事实证明,纯函数具有一些理想的属性。
- 相同的输入必定输出也相同
这使得它们易于测试。 要测试产生副作用的代码,我们需要数倍的测试工作和其他复杂的技术。 - 易于阅读和理解
读取一段代码使用纯函数时,无需担心其内部实现。 只关心返回值,因为你确定它不会产生任何副作用。 - 天生支持并发
由于它们不访问任何共享资源,因此不会引起竞争条件的风险。 你可以从并行线程安全地调用它们,而不必依赖锁或操作队列,从而避免了死锁的风险。
如上所述,Swift不是一种纯函数式语言,因此无法使iOS应用程序中的所有函数/方法都纯粹。 这就是为什么我们的ChatViewController的代码是命令式的。
但是,在实现应用程序的业务逻辑时,函数式编程会起作用。
2.2.4 通过方法链来编写声明式的函数式Swift代码
现在,我们已经从理论角度了解命令式和函数式范式之间的区别,现在让我们从实践角度进行研究。
这种差异不会立即显现,因此我们将在本文的其余部分中逐步探索。
在命令式编程中,我们通常会考虑获得结果所需经历的离散步骤。 为此,我们使用赋值,分支语句和for循环。
相反,在函数式编程中,我们考虑如何通过一系列连续的函数将输入转换为输出。
Eliza算法使用预定义的模式表为用户消息创建回复。
由于我们必须将用户消息与其中一种模式进行匹配,因此清理用户输入,删除符号并使所有字母都变为小写字母非常有用。
在这里,我们可以看到声明式编程的第一个示例:
- 从用户消息中删除符号
- 将上一步的结果转换为小写
- 将上一步的结果转换为答复
struct Eliza {
func reply(to message: String) -> String {
let message = message
.removingAllSymbols()
.lowercased()
return transform(message: message)
}
}
private extension Eliza {
func transform(message: String) -> String {
return message
}
}
extension String {
func removingAllSymbols() -> String {
return self
}
}
该代码之所以具有函数式的特性,是因为它仅使用纯函数来转换前一个函数的输出。
在这里,你可以看到在Swift中我们有两种编写纯函数的方法:
- transform(message :)函数将其输入作为参数。
- 相反,removeingAllSymbols()和lowercased()函数是String类型的方法。 它们的输入就是被调用的值。 它们在技术上仍然是纯函数,因为它们仅取决于输入,不会产生副作用。
2.3 函数式编程的核心概念
函数式编程核心概念.png函数式编程使用的结构与你在命令式编程中发现的结构不同。 函数式编程不依赖多态性和循环,而使用高阶函数进行抽象,而使用map,filter和reduce函数进行迭代。
2.3.1 使用高阶函数在函数式编程中进行代码抽象
在任何程序中,我们经常需要重用代码以避免重复。 当然,函数是重用代码的主要方法。
当我们重用代码时,我们使其变得更加抽象,以接受尽可能多的情况。
但是,仅使用函数作为一种抽象机制尚不完整。
在Swift的面向对象或面向协议的程序设计中,我们通过以下方式多种多态类型来使我们的代码更加抽象:
- 将方法移到超类或协议中,以便所有子类型都可以继承它
- 使用超类,协议和泛型作为函数参数的类型
在纯函数式语言中,没有类或协议。 你猜到了,唯一可用的抽象是函数。
我们仍然可以使用高阶函数使代码更通用。
高阶函数是具有以下特性的之一的函数: - 函数的参数是函数
- 返回值是函数
当然了,同时具有以上两个特性,依然是高阶函数。
高阶功能可实现函数组合,使你可以将两个函数组合在一起以创建一个新函数。 在Swift中,你可能已经使用了许多高阶函数。
例如,URLSession的dataTask(with:completionHandler :)方法是一个高阶函数。 完成处理程序是在网络传输完成时调用的函数。
在函数式编程中,有三个重要的高阶函数:map,filter和reduce。
译者注
这里讲到高阶函数,可以参考译者写的这篇文章——Swift高阶函数解析,以加深对高阶函数的理解。
2.3.2 filter从序列中删除特定的元素
让我们从filter开始,它是三个常用高阶函数中最简单的一个。
简而言之,filter从序列中删除所有不满足谓词的元素。
更详细地讲,在Swift中,Sequence
协议的filter(_ :)
函数:
- 接受一个返回布尔值的谓词;
- 返回一个数组,其中包含谓词为其返回true的元素。
例如,我们可以使用过滤器来获取数组中所有等于或大于另一个数字的数字。
let numbers = [1, 9, 7, 2, 5, 4, 6]
numbers.filter { $0 > 3 }
// returns [9, 7, 5, 4, 6]
numbers.filter { $0.isMultiple(of: 2) }
// returns [2, 4, 6]
在我们的Eliza中,可以使用filter把符号从信息中移除。
extension String {
func removingAllSymbols() -> String {
return self.filter { character in
return character
.unicodeScalars
.allSatisfy{ CharacterSet.symbols.contains($0) }
}
}
}
译者注
以上代码中character是一个字符,从人类阅读角度来说的一个字符;但是,一个字符可能包含多个unicode标量。
在Swift中字符串可以看成字符的序列,所以filter可以应用在字符串之上。
在Swift中,没有直接的方法可以知道字符是否是符号。 因此,在上面的代码中,谓词是一个函数序列,当字符的Unicode标量不在符号的CharacterSet中时,该谓词将返回true。
allSatisfy(_ :)
函数是另一个高阶函数。
在这里,您可以看到关于如何编写无循环程序的第一部分解释。 像filter(_ :)
之类的函数会在一个序列上迭代并返回另一个序列,因此我们不需要循环。
我已经听到您的反对:“但是,等等,这只是一个把戏! 循环只是隐藏在filter函数中”。
@inlinable
public __consuming func filter(
_ isIncluded: (Element) throws -> Bool
) rethrows -> [Element] {
return try _filter(isIncluded)
}
@_transparent
public func _filter(
_ isIncluded: (Element) throws -> Bool
) rethrows -> [Element] {
var result = ContiguousArray<Element>()
var iterator = self.makeIterator()
while let element = iterator.next() {
if try isIncluded(element) {
result.append(element)
}
}
return Array(result)
}
正如我所说,这只是部分解释。 稍后我会给你完整的答案。
2.3.3 map函数把序列转换为另外一个序列
一组“迭代”函数中的下一个是map
函数。
简而言之,map将序列中的所有元素一一转换并返回结果。
更详细地讲,在Swift中,Sequence
协议的`map(_ :)1函数:
- 接收一个转换函数;
- 返回一个数组,其中包含将转换函数应用于序列的每个元素的结果。
例如,我们可以使用map(_ :)
来计算数组中所有数字的平方。
func square(x: Int) -> Int {
return x * x
}
let numbers = [1, 9, 7, 2, 5, 4, 6]
numbers.map(square)
//returns [1, 81, 49, 4, 25, 16, 36]
Eliza回复信息时,必须更改人称代词和动词到第一或第二人称。
例如,“我不能和父亲说话”这样的句子应该变成“您和父亲说话需要什么?”。 回复的第一部分来自模板,第二部分来自用户消息,其中我已更改为你。
让我们开始编写一个反射单个单词的函数。 我们可以通过将所有可能的转换放入字典中来完成此操作,因为它们并不多。
struct Reflector {
}
private extension Reflector {
func reflect(word: String) -> String {
return StaticData.reflections[word] ?? word
}
}
struct StaticData {}
extension StaticData {
static var reflections: [String: String] {
return [
"am": "are",
"was": "were",
"i": "you",
"i'm": "you are",
"i'd": "you would",
"i've": "you have",
"i'll": "you will",
"my": "your",
"me": "you",
"are": "am",
"you're": "I am",
"you've": "I have",
"you'll": "I will",
"your": "my",
"yours": "mine",
"you": "me"
]
}
}
为了反射完整的句子,我们可以将其分解为单词,然后在它们上面调用reflect(word:_)
函数。 之后,我们加入结果数组以返回一个字符串。
struct Reflector {
func reflect(sentence: String) -> String {
return sentence
.components(separatedBy: .whitespaces)
.map (reflect(word:))
.joined(separator: " ")
}
}
private extension Reflector {
func reflect(word: String) -> String {
return StaticData.reflections[word] ?? word
}
}
这样做可以,但是我们的函数不再是纯函数。reflect(word:_)
函数不但依赖输入word,而且依赖StaticData
。
由于我们没有使用纯粹的函数式语言,因此我们不必使每个函数都是纯函数。 但是,如果您想要或需要,可以使用一个简单的技巧。
反射字典只是reflect(word:_)
的另一个输入。 我们只需要明确一点即可。
struct Reflector {
func reflect(sentence: String) -> String {
return sentence
.components(separatedBy: .whitespaces)
.map { reflect(word: $0, with: StaticData.reflections) }
.joined(separator: " ")
}
}
private extension Reflector {
func reflect(word: String, with reflections: [String: String]) -> String {
return reflections[word] ?? word
}
}
reflect(word:with:)
现在是纯函数了。
然而reflect(sentence:_)
依然不是纯函数,你可以使用相同的技巧来实现。但是,总会在一些地方,你将有一个非纯函数。这是iOS的App的必然现象。
2.3.4 reduce函数把序列的元素进行结合
这三个迭代函数中的最后一个是reduce函数。
简单来说,reduce
函数把序列的元素值结合成一个单一的值。
详细来讲,在Swift中,Sequence
协议的reduce(_:)
函数是:
- 具有一个初始值
- 具有一个函数,此函数把两个元素的值累加到一个
- 迭代序列的元素,并将当前元素与先前组合的结果组合在一起
在这三个迭代函数中,reduce(_ :)
是最难使用且最不常用的函数。 但是还是有必要不时进行。
例如,我们可以使用reduce(_ :)
对一个数组中的所有数字求和或相乘,或者合并一个布尔值列表。
let numbers = [1, 9, 7, 2, 5, 4, 6]
numbers.reduce(0, +)
// returns 34
numbers.reduce(1, *)
// returns 15120
let booleans = [true, false, true, true, false]
booleans.reduce(true, { $0 && $1 })
// returns false
booleans.reduce(false, { $0 || $1 })
// returns true
如您所见,初始值取决于您在序列中组合元素的方式。 总而言之,我们从0开始,它是加法运算的标识元素。 在产品中,我们从1开始。
对于布尔值,&& 仅当所有值都为true时,operator才返回true,这就是我们的初始值。 || 当至少一个值是true时,operator会返回true,因此我们从false开始。
reduce(_ :)
函数的初始值并不总是很简单。
例如,如果您想连接单词,则仅当你不需要单词之间的空格时,才可以从空字符串开始。
在reflect(sentence :)
函数中,我们使用Array
的joind(separator :)
函数将反射词连接到一个句子中。 这是一个依赖于reduce(_ :)
的便捷函数,使我们免于选择初始值的麻烦。
如果我们没有该功能,而必须使用reduce(_ :)
,则这就是我们写reflect(sentence :)
的方式:
struct Reflector {
func reflect(sentence: String) -> String {
let reflected = sentence
.components(separatedBy: .whitespaces)
.map { reflect(word: $0, with: StaticData.reflections) }
return reflected
.dropFirst()
.reduce("\(reflected[0])", { $0 + " " + $1 })
}
}
为了避免在句子的开头放置空格,reduce(_ :)
的初始值必须是序列的第一个元素。 这意味着我们只需要在序列的其余元素上调用reduce(_ :)
。
2.3.5 Eliza的函数式算法
现在,我们已经为我们的应用奠定了基础,让我们详细了解一下Eliza算法。
Eliza聊天机器人基于一组预定义的规则。 每个规则都提供了一些固定的答案,聊天机器人可以使用这些答案将用户所说的内容转换为问题。
每个规则都有:
- 用于匹配信息的模式
每个模式都包含一个或多个通配符(*),它们可以匹配句子的任意部分。 例如,我需要的模式*可以匹配“我需要冰淇淋”或“我需要我父亲爱我”之类的句子。 - 一组回复
回复中还可以包含通配符,该通配符将被匹配的feflect
所替代。 例如,“我需要”模式的响应之一是“为什么需要?”,它会产生诸如“为什么需要冰淇淋?”之类的答案。 或“为什么您需要父亲爱您?”
让我们开始定义规则的类型。
struct Rule {
let pattern: String
let replies: [String]
func matchFor(sentence: String) -> String? {
return nil
}
}
matchFor(sentence :)
函数将返回模式中通配符匹配的句子部分。 “我需要冰淇淋”和我需要*之间的匹配是“冰淇淋”。
显然,规则仅匹配特定类型的句子。 例如,上面的模式与句子“我认为我很沮丧”不匹配。 这就是为什么match(sentence :)
返回可选内容的原因。
2.3.6 命令式的代码有时比函数式的代码更容易写
我们把规则的列表保存在StaticData
结构之中。
extension StaticData {
static var rules: [Rule] = [
Rule(pattern: "", replies: ["Speak up! I can't hear you."]),
Rule(pattern: "I need *", replies: [
"Why do you need *?",
"Would it really help you to get *?",
"Are you sure you need *?"])
]
}
有太多的规则了,这里就不一一列举了,完整的列表请看Xcode Project。
现在,我们可以编写整个Eliza算法,其中:
- 寻找与用户的信息匹配的规则;
- 选择一个随机答复;
- 在回复中映射代词和动词;
- 用映射的匹配替换答案中的通配符
该算法的描述不是函数式的。 没关系。 我们仍在编写Swift。 我们可以使用for循环编写算法。
private extension Eliza {
func transform(message: String) -> String {
for rule in StaticData.rules {
guard let match = rule.matchFor(sentence: message) else {
continue
}
let reply = Int.random(in: 0 ..< rule.replies.count)
return rule
.replies[reply]
.replacingOccurrences(of: "*", with: Reflector().reflect(sentence: match))
}
return "..."
}
}
你当然也可以把这些代码转换为函数式的代码。
但是,这样做并非易事。 有时,以循环的方式思考比考虑转换序列更自然。
这是transform(message :)
的样子。
private extension Eliza {
func transform(message: String) -> String {
return StaticData.rules
.map { ($0, $0.matchFor(sentence: message)) }
.first(where: { (rule, result) in result != nil })
.map { (rule, result) -> String in
guard let result = result else { return "" }
return rule.replies[Int.random(in: 0 ..< rule.replies.count)]
.replacingOccurrences(of: "*", with: Reflector().reflect(sentence: result))
} ?? "..."
}
}
如你所见,循环需要转换为map
,filter
和reduce
的顺序应用程序(或派生类,如上面的first(where :)
便利函数)。 这不像循环那样可读,并且性能也可能更差。
请注意,最后一个map(_ :)
是在Optional类型上而不是在序列上调用的。 在我的Swift可选内容指南中,我对此进行了更详细的讨论。
2.4 递归
递归.png 在函数式编程中,可以编写任何命令式编程范式编写的程序。 这意味着函数式范式需要一种复制循环的机制。
这种机制是递归的,它不仅在函数式编程中有用,而且在处理命令式程序中的复杂数据结构时也很有用。
2.4.1 函数式的标识循环的方法:递归
我们终于要了解函数式编程的核心了。
怎样编写程序可以不用循环?!
答案就是:递归。
通过递归,我们将一个问题分解为相同类型的较小问题,然后组合其结果来解决。
像往常一样,示例比定义容易理解。
递归的一个典型示例是计算正整数n的阶乘,用n!表示,n是n之前的所有正整数的乘积。
例如: 5! = 5 ✕ 4 ✕ 3 ✕ 2 ✕ 1 = 120
这是一个迭代的描述。 接下来,我们可以编写一个使用循环的命令式函数。
但是,如果从递归的角度来看问题,则整数的阶乘就是该整数乘以前一个阶乘的阶乘。
因此,我们可以重写5! 如5✕4!
在编程中,递归函数使用对问题的子集进行调用的结果。
func factorial(of number: Int) -> Int {
if number == 0 {
return 1
}
return number * factorial(of: number - 1)
}
注意一个重要的细节。 任何递归函数都需要一个或多个基本案例,对此我们有一个预定义的解决方案。 有必要使用基本情况来停止递归。 否则,递归将无限期进行。
在我们的factorial(of :)
函数中,基本情况为0。这不是任意的。 数学定义为0! 是1。
递归是lambda演算再次引入的概念,但它不仅限于函数式编程。
即使在命令式编程中,递归也是解决某些问题的更直观的方法。 主要是在使用复杂的数据结构(例如链表,树和图)时才是正确的。
你可以使用循环来解决这些问题,但是这比使用递归要困难得多。
2.4.2 使用递归对序列进行迭代
由于递归是表达循环的函数式方法,因此必须了解递归如何在序列上起作用。
我们将从一个简单的例子开始。 假设我们要编写一个函数,该函数把一个数字数组的元素加倍。
递归包括解决同一问题的较小部分并合并解决方案。
在这种情况下,我们能想到的最小问题是什么?
空数组。
从那里开始,我们可以一次将问题扩大一个步骤。
- 将空数组加倍会产生一个空数组。 这是我们的基本情况。
- 将数字数组加倍意味着将第一个元素加倍,并在其后面附加数组其余部分的两倍。
在某个时候,递归将到达数组的末尾,其余元素为空数组。 这是停止递归的基本情况。
func double(of numbers: [Int]) -> [Int] {
if numbers.isEmpty {
return []
}
let first = numbers[0] * 2
return [first] + double(of: Array(numbers.dropFirst()))
}
double(of: [1, 2, 3, 4, 5])
// retunrs [2, 4, 6, 8, 10]
一般而言,这是你对序列进行递归迭代的方式。
你将处理应用于第一个元素,并将其与对其余项目的递归调用结合在一起。 要停止递归,需要为空序列提供一个基本案例。
例如,我们现在可以不使用循环就重写map(_ :)
函数。
extension Array {
func recursiveMap<T>(transform: (Element) -> T) -> [T] {
if self.isEmpty {
return []
}
let first = transform(self[0])
return [first] + Array(self.dropFirst()).recursiveMap(transform: transform)
}
}
let numbers = [1, 2, 3, 4, 5]
numbers.recursiveMap { $0 * 3 }
// returns [3, 6, 9, 12, 15]
2.4.3 递归表达问题的解决方案
现在你已经知道递归的要点,我们将为Eliza实现匹配算法。 这不是一个小问题,通过递归比使用循环更容易解决。
在继续之前,有个警告——此功能将很复杂。
可以说,这不是学习递归的最直接的例子,但是可以在网上找到很多这样的例子。
只有复杂的问题,才能了解递归的功能。 如果想熟悉这个概念,则必须通过简单的示例并尝试解决更棘手的问题。
首先,我们需要一个递归定义,以找到模式和句子之间的匹配。 通常,仅当以下情况之间存在匹配
- 可以将句子的一部分放入模式的通配符中
-
句子中的其他字母与模式完全匹配。
模式匹配.png
由此,我们可以对将使用递归的方式有所了解:每个字符串中的字符。
2.4.4 定义递归的终止事例
匹配算法特别复杂,因为我们有两个需要使用递归的算法。
这也意味着我们将在函数中包含许多基本案例和许多递归调用,以涵盖所有可能的组合。
让我们开始定义基本情况。
我们在两个字符串上使用递归,因此当我们到达一个或两个的结尾时,我们需要一个基本的情况。
- 如果两个字符串都为空,则它们匹配。 因此,匹配项为空字符串。
- 如果两个字符串之一为空,但另一个不是,则没有匹配项。 在这种情况下,我们返回nil。
struct Rule {
let pattern: String
let replies: [String]
func matchFor(sentence: String) -> String? {
return match(between: pattern.lowercased(), and: sentence)
}
}
private extension Rule {
func match(between pattern: String, and sentence: String) -> String? {
switch (pattern, sentence) {
case ("", ""): return ""
case ("", _), (_, ""): return nil
default: return ""
}
}
}
一些注意事项:
-
matchFor(sentence :)
方法仅使用一个参数,因为它使用Rule结构的pattern属性。 由于我们需要对两个参数使用递归,因此我们需要一个额外的match(between:and :)
方法。 - 我正在使用带模式匹配的switch语句,这是功能语言的另一个常见功能。
2.4.5 使用尾调递归来解决问题
现在让我们进入递归调用。
我们的递归仅对每个字符串的第一个字符进行操作。 这给了我们两种组合:模式的第一个字符是或者不是通配符。
这是简单的部分。 每个案例都有其子案例。
我们将从模式的第一个字符不是通配符开始,因为这是两者中最简单的一个。
- 如果模式字符与句子中的第一个字符不同,则没有匹配项。
-
否则,我们将在其余两个字符串中寻找匹配项。
image.png
通常,使用递归时,可以通过两种方式找到问题的解决方案:
- 解决方案是递归调用的结果,或者
- 解决方案是根据递归调用的结果和其他一些值组合而成的。
在这种情况下,我们使用两个选项中的第一个,称为尾调用。 函数式语言使用一种称为尾部调用优化的技术来使递归与其他语言中的循环一样有效。
有时可以将非尾调用递归转换为尾调用递归,但是Swift不能保证尾调用的优化,因此烦恼没有多大意义。
我们针对这种情况的代码如下:
private extension Rule {
func match(between pattern: String, and sentence: String) -> String? {
switch (pattern, sentence) {
case ("", ""): return ""
case ("", _), (_, ""): return nil
case (let pattern, let sentence) where pattern.first != "*":
return pattern.first == sentence.first
? match(between: pattern.droppingFirst(), and: sentence.droppingFirst())
: nil
default: return ""
}
}
}
extension String {
func droppingFirst() -> String {
return String(self.dropFirst())
}
}
在这里,我使用了更简洁的三元运算符,但是如果愿意,可以使用普通的if语句。
在函数式语言中,所有语句都返回值,因此不需要return关键字。 在这里使用三元运算符看起来“功能更强大”。
在撰写本文时,正在积极审查一项Swift提案,以使return关键字在某些情况下是可选的。
2.4.6 递归调用后组装问题的解决方案
当模式的第一个字符是通配符时,发生第二个递归情况。
并非所有模式的末尾都有通配符。 例如,有一条规则可以检测您提及“母亲”一词的任何句子。
extension StaticData {
static var rules: [Rule] = [
Rule(pattern: "", replies: ["Speak up! I can't hear you."]),
Rule(pattern: "I need *", replies: [
"Why do you need *?",
"Would it really help you to get *?",
"Are you sure you need *?"]),
Rule(pattern: "* mother *", replies: [
"Tell me more about your mother.",
"What was your relationship with your mother like?",
"How do you feel about your mother?",
"How does this relate to your feelings today?",
"Good family relations are important."])
]
}
与其他规则不同,此规则在通配符后包含更多文本。 这意味着在我们的匹配算法中,当找到通配符时,我们必须查看两个字符串的其余部分以查看是否存在匹配项。
- 如果两个字符串的其余部分产生匹配项,则该匹配项是句子的第一个字符。
- 否则,通配符可能会匹配更多字母。 在此,匹配由句子的第一个字符以及整个模式与句子其余部分之间的匹配组成。
-
如果以上两种情况均不成立,则没有匹配项。
这是算法中最难的部分。 仅当您同时考虑所有情况时,该解决方案才有意义。
当找到通配符时,我们将在句子上前进,直到达到最长的匹配,从而允许模式结尾与句子匹配。
image.png
这是一个非常复杂的问题的示例,可以通过递归更轻松地解决。 这不是尾递归,也没有办法做到。
private extension Rule {
func match(between pattern: String, and sentence: String) -> String? {
switch (pattern, sentence) {
case ("", ""): return ""
case ("", _), (_, ""): return nil
case (let pattern, let sentence) where pattern.first != "*":
return pattern.first == sentence.first
? match(between: pattern.droppingFirst(), and: sentence.droppingFirst())
: nil
default:
if let _ = match(between: pattern.droppingFirst(), and: sentence.droppingFirst()) {
return String(sentence.first!)
} else if let longMatch = match(between: pattern, and: sentence.droppingFirst()) {
return String(sentence.first!) + longMatch
} else {
return nil
}
}
}
}
我花了一些时间来整理这段代码。
我甚至不知道从哪里开始使用for循环编写此算法。 因此,不用担心,如果你不明白的话。 希望您仍然从本文的其余部分中学到很多东西。
我们的Eliza算法终于完成了。
app-running-1.gif
2.5 结论
如我们所见,函数式编程具有一些与可读性,测试和并发性相关的理想属性。
因此,尽管Swift不是一种纯函数式语言,但你可以通过以一种功能风格编写部分代码来获得一些好处。
像其他任何东西一样,它可能会失控。
- 某些代码以命令式格式更具可读性
你可以尝试在所编写的任何代码上强制使用函数式范例,但是在某些情况下,这只是徒劳的。 - 还请记住,大多数Swift开发人员只熟悉命令性代码
新的团队成员可能很难理解您的功能代码,尤其是如果您开始使用自定义运算符或Swift功能编程库时。 - 最后,递归并不总是最有效的代码编写方式
当然,通常您的数据是如此之小,以至于没有明显的差异。 尽管如此,仅在尾部递归的情况下,递归的复杂性才可与循环之一相媲美。 使用循环的命令式解决方案性能更高,因此仅在使问题更易于管理时才使用递归。
就个人而言,我以命令式的方式编写了大多数代码,但是我确实在有意义的地方编写了一些函数式代码。 总的来说,我使用应用程序体系结构中的函数式编程概念来获得我上面列出的好处。
一个明确的示例是,遵循MVC模式,将值类型用于应用程序的模型。 此外,在SwiftUI中,视图也是值类型,因此MVC模式更为关键。 您可以在下面的免费指南中找到原因。
网友评论