最近在研究SwiftUI中的Combine框架,主要是学习这本书的内容:Using Combine,其中一个很重要的概念就是Functional Programming,也就是函数式编程。我相信这个概念大家肯定都听过,但要把它简单的讲明白,也不是一件容易的事儿,在这篇文章中,我将用一个实例来做一个讲解。
本文的主要目的就是弄明白下边几个关键词:
- 函数式编程
- 不可变状态和副作用
- 一等公民和高阶函数
- 偏函数和纯函数
- 引用透明
- 递归
- 命令式和声明式编程
函数式编程
var name = "小明"
name = "小红"
上边这段代码,实在是太简单了,我们一般这样描述:创建了一个变量name
,给它赋值为小明,再修改它的值为小红。从我们学习编程开始,就是这么写代码的,这有什么问题吗?
并无任何问题,大家可能不知道,这其实是一种命令式的编程风格,简而言之,你创建了一个变量,然后你主动为其赋值。变量这个概念,还是有点怪的,大家仔细想想,变量可以在程序运行过程中被改变,这就引入了一个时间的观念,因此,我们在阅读代码的时候,不得不考虑程序执行的时间因素,花费大量的精力来分析某个变量的生命周期,我相信大家一定都经历过这样痛苦。
举个🌰:
func eatApple() {
print("我吃了一个大🍏")
name = "翠花"
}
eatApple()
也不知道哪位同学写的代码,在eatApple()
中,修改了name
,eatApple()
就是为了吃苹果,为什么要修改name
呢!!!,其实,你懂的,eatApple()
早已隐藏在万千代码之中。
print("\(name)不爱吃酸菜!!!")
翠花不爱吃酸菜!!!
某天,我心血来潮,打印这行数据,我本来想要的结果是小红不爱吃酸菜!!!
,怎么变成了翠花不爱吃酸菜!!!
,TMD,谁都知道翠花卖酸菜的,怎么可能不爱吃酸菜呢!!!
大家看到了没?这真心不是一个笑话,在项目中,绝对存在,所以跟我大声喊出来,这很不函数啊。
不可变状态和副作用
要想避免上边的问题,我们可以这么做:
let name2 = "小明"
name2 = "小红"
当我们把name2
声明成let
时,name2
就变成了一个不可变状态的变量,在程序的任何地方都不可以修改其值,否则编译器会报错:Cannot assign to value: 'name2' is a 'let' constant
。
这时候,name2
就变成了史上最固执的人,没有什么人比我更懂。。。不对,没有什么人能够改变我,就是这么任性。
当不希望某个变量改变状态时,我们可以为其增加限制,比如只读,静态等。
上边的eatApple()
函数就是一个有副作用的函数,这里的副作用通常指的是函数修改了其他的外部变量。
这种有副作用的函数特别像定时炸弹。
排序
有这样一个题目,在某个班级的考试统计系统中,记录了学生的姓名,年龄和分数,老师让小明打印一份名单,按照姓名中的字母进行排序。
小明是一名很有经验的程序猿,头脑中立马出现各种各样的排序算法,没想到有这么多的排序算法:
849589-20190306165258970-1789860540.png于是,小明选择了一个最简单的插入排序算法,他的原理大概是这样的:
Kapture 2020-08-20 at 9.49.36.gif小明先创建了一个Student
的结构体,用来描述学生这个对象:
struct Student {
let name: String
let age: Int
let score: Int
}
然后把学生的信息保存在一个数组中:
let students = [
Student(name: "Calista", age: 18, score: 85),
Student(name: "Griselda", age: 20, score: 88),
Student(name: "Annabelle", age: 24, score: 92),
Student(name: "Polly", age: 22, score: 93),
Student(name: "Maud", age: 16, score: 95),
]
接下来,就是让人兴奋的算法编码环节了,小明喝了3杯来自俄罗斯的小鸟伏特加,撸起袖子,开始写代码:
func sortedNames(for students: [Student]) -> [String] {
var sortedStudents = students
var current: Student
for i in (0..<sortedStudents.count) {
current = students[I]
for j in stride(from: i, to: -1, by: -1) {
if current.name.localizedCompare(sortedStudents[j].name) == .orderedAscending {
sortedStudents.remove(at: j + 1)
sortedStudents.insert(current, at: j)
}
}
}
var names = [String]()
for student in sortedStudents {
names.append(student.name)
}
return names
}
小明只用了2个小时,就写完了这个算法,咱们验证一下,结果是否正确:
print(sortedNames(for: students))
["Annabelle", "Calista", "Griselda", "Maud", "Polly"]
没毛病啊 ,结果完全正确,小明抄起电脑,就走到老师的办公室,大声的讲起了上边代码的实现原理,那真是唾沫横飞啊,小明无意间看了一眼老师的脸,只见老师怒目圆睁,脸上斑斑点点,这像是爆发的边缘啊。
“滚出去!!! 这写的什么玩意,重写!!!”
一等公民和高阶函数
小明虽然不服气,但也没办法啊,官大一级压死人啊,和珅曾经说过:臣,胆战心惊,如履薄冰的伺候了你这么多年,如今官降三级,怎能不让人寒心!!!
于是小明上网搜集资料,发现了一等公民和高阶函数,这是怎么一回事呢?
在函数式编程中,函数是一等公民,不再把函数想象成一个处理过程,而是把它当作一个对象或者变量来对待。
思维一旦转变,就是另一片天,当函数是变量后,我们就可以把它作为参数或者返回值放到另一个函数中。
所谓的高阶函数,就是把函数作为函数参数的函数。我觉得我这句话说的挺不错的,挺绕的,嘿嘿。
1. Map
在Swift中的Collection
类型,都有一个map(_:)
方法,它接受一个函数作为参数,其目的是对原集合中的元素进行转换,因此map(_:)
的输出结果也是一个数组。
我们可以把Map想象成一个包子生产机器,进去的是馅,出来的是包子,也可以想象成一个爆米花机器,进去的是玉米,出来的是爆米花。总之,这是一个转换过程,我更喜欢称之为映射过程。想想线性代数,集合从一个坐标空间映射到另一个坐标空间,实在是奇妙,人类的想象力真是伟大。
小明已经迫不及待的想要试试这个map(_:)
了,他看了眼之前写的算法,有这样一段代码:
var names = [String]()
for student in sortedStudents {
names.append(student.name)
}
目的是获取所有学生的name
,那么用上map(_:)
,岂不是完美,说做就做,小明开始写代码:
func sortedNamesWithMap(for students: [Student]) -> [String] {
var sortedStudents = students
var current: Student
for i in (0..<sortedStudents.count) {
current = students[I]
for j in stride(from: i, to: -1, by: -1) {
if current.name.localizedCompare(sortedStudents[j].name) == .orderedAscending {
sortedStudents.remove(at: j + 1)
sortedStudents.insert(current, at: j)
}
}
}
/// 新增内容
let names = sortedStudents.map{ $0.name }
return names
}
print(sortedNamesWithMap(for: students))
["Annabelle", "Calista", "Griselda", "Maud", "Polly"]
嗯,虽然结果完全相同,但是感觉代码一下子高级了不少啊,小明有一种奇怪的感觉,却无法用语言表达。如果把let names = sortedStudents.map{ $0.name }
替换成let scores = sortedStudents.map{ $0.score }
那岂不是就得到了一个分数数组?
这种感觉就像,你告诉map(_:)
你要什么东西,而不是直接命令式的编码。
Filter
在Swift中的Collection
类型,都有一个filter(_:)
方法,它接受一个函数作为参数,其目的是对原集合中的元素进行筛选,因此filter(_:)
的输出结果也是一个数组。
我们可以把Filter想象成一个筛子,只有符合条件的豆粒才能通过。Filter的关键词是过滤。
小明立马想到了快速排序算法,快排的核心思想是:
Kapture 2020-08-20 at 10.40.56.gif快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
小明想到,如果把上边的插入排序替换成快速排序,速度就更快了,也正好能用上filter(_:)
,说干就干,小明开始飞速编码:
extension Array where Element: Comparable {
func quickSorted() -> [Element] {
if self.count > 1 {
let (pivot, remaining) = (self[0], dropFirst())
let lhs = remaining.filter{ $0 <= pivot}
let rhs = remaining.filter{ $0 > pivot}
return lhs.quickSorted() + [pivot] + rhs.quickSorted()
}
return self
}
}
同学们,上边快排的Swift实现,看上去是如此的优雅啊。要想使用上边代码中的算法,还需要让Student
实现Comparable
协议。
extension Student: Comparable {
static func < (lhs: Student, rhs: Student) -> Bool {
lhs.name < rhs.name
}
static func == (lhs: Student, rhs: Student) -> Bool {
lhs.name == rhs.name
}
}
let sortedNames = students.quickSorted().map{ $0.name }
print(sortedNames)
["Annabelle", "Calista", "Griselda", "Maud", "Polly"]
binggo,同样可以实现name
的排序,由于把quickSorted()
写到成了Array的扩展方法,它会直接修改原数组的内容,因此,我们需要把之前的代码:
let students = [
Student(name: "Calista", age: 18, score: 85),
Student(name: "Griselda", age: 20, score: 88),
Student(name: "Annabelle", age: 24, score: 92),
Student(name: "Polly", age: 22, score: 93),
Student(name: "Maud", age: 16, score: 95),
]
=====>>>> 改成
var students = [
Student(name: "Calista", age: 18, score: 85),
Student(name: "Griselda", age: 20, score: 88),
Student(name: "Annabelle", age: 24, score: 92),
Student(name: "Polly", age: 22, score: 93),
Student(name: "Maud", age: 16, score: 95),
]
3. Reduce
在Swift中的Collection
类型,都有一个reduce(_:)
方法,它接受一个函数作为参数,其目的是对原集合中的元素进行合并,因此reduce(_:)
的输出结果也是一个数值。
Reduce最有意思的一点是,他入参函数的第一个参数是上一个的结果值,因此它才有了合并的意义。我们可以想象成拿着框剪苹果,左右是框,右手是苹果,就是这个意思。
小明聪明的大脑正在高速思考,Reduce有什么用呢?啊哈,太有用了,比如可以统计所有学生的总分数,虽然好像没什么意义。
let totalScore = students.map{ $0.score }.reduce(0){ $0 + $1}
print(totalScore)
425
偏函数和纯函数
小明实在是太兴奋了,Map
,Filter
和Reduce
完全是一套变化无穷的剑法,就像打dota一样,大家使用的所有东西都是一样的,为何你是菜鸡?除了天赋,高手还有自己的小秘密,嘿嘿。
如果问你偏函数是什么?你嘿嘿一笑,说那不是微积分的知识吗?no,在编程的世界中,偏函数指的是该函数的返回值是一个函数,就好像在说,你以为这个函数是这样的,其实它是那样的。偏了。
老师又给小明提出了3个新的需求:
- 筛选出
age
大于20岁的同学 - 筛选出
score
大于90分的同学 - 筛选出
age
大于20并且score
大于90分的同学
小明觉得太简单了,用高阶函数来实现,十分easy啊,于是开始写代码:
/// 筛选 age > 20
print(students.filter{ $0.age > 20}.map{ $0.name })
/// 筛选 score > 90
print(students.filter{ $0.score > 90}.map{ $0.name })
/// 筛选 age > 20 && score > 90
print(students.filter{ $0.age > 20 && $0.score > 90 }.map{ $0.name })
["Annabelle", "Polly"]
["Annabelle", "Polly", "Maud"]
["Annabelle", "Polly"]
其实,到这里,我觉得应该点到为止了,哈哈,对于大部分同学来说,写出这样的代码已经足够优秀了,我很怕,如果再进一步的话,某些同学可能要爆炸了。
不过我们还要继续,我们把上边的代码改造成偏函数:
enum FilterType {
case ageGreaterThan20
case scoreGreaterThan90
case ageGreaterThan20AndScoreGreaterThan90
}
func filter(for filterType: FilterType) -> ([Student]) -> [String] {
return { students in
switch filterType {
case .ageGreaterThan20:
return students.filter{ $0.age > 20}.map{ $0.name }
case .scoreGreaterThan90:
return students.filter{ $0.score > 90}.map{ $0.name }
case .ageGreaterThan20AndScoreGreaterThan90:
return students.filter{ $0.age > 20 && $0.score > 90 }.map{ $0.name }
}
}
}
其实,并不难,filter()函数的核心思想就是根据参数filterType返回不同的函数。当我们需要过滤数据的时候,就会像下边这样使用:
print(filter(for: .ageGreaterThan20)(students))
print(filter(for: .scoreGreaterThan90)(students))
print(filter(for: .ageGreaterThan20AndScoreGreaterThan90)(students))
["Annabelle", "Polly"]
["Annabelle", "Polly", "Maud"]
["Annabelle", "Polly"]
我们再研究一个有点极端的例子:
infix operator ^^
func ^^ (radix: Int, power: Int) -> Int {
return Int(pow(Double(radix), Double(power)))
}
我们可以自定义操作符,在上边的代码中,infix表示要定义中间的操作符,在两个数中间。
print("2³ = \(2 ^^ 3)")
2³ = 8
我们再简单聊聊什么是纯函数,只看名字我们也能猜个大概,它应该是一个很“纯”的函数,一般指的是很单一的东西。因此一个纯函数必须满足一下两个条件:
- 相同的入参必定输出相同结果,也就是说它只依赖入参
- 函数没有任何副作用
简而言之,纯函数是数据流模式的基础,在响应式编程的世界里,数据在pipline中随意流动,它就不应该存在任何副作用,pipline的输入输出存在确定性。
小明随手写下了一个纯函数:
func studentsWithAgeUnder(_ age: Int, from students: [Student]) -> [Student] {
return students.filter{ $0.age < age }
}
print(studentsWithAgeUnder(18, from: students))
[__lldb_expr_1.Student(name: "Maud", age: 16, score: 95)]
引用透明
网上的相关资料很多,在这里就不做更多解释了,对于纯函数,它就是引用透明的,编译器可以对引用透明的函数做优化,比如,如果某个函数是引用透明的,编译器可以缓存入参和输出,当遇到相同入参的时候,直接使用缓存的输出结果。
递归
说到递归,是一个令人头疼的玩意,要想精通算法,则必须要战胜递归。我们在上边的快排中就用到了递归:
extension Array where Element: Comparable {
func quickSorted() -> [Element] {
if self.count > 1 {
let (pivot, remaining) = (self[0], dropFirst())
let lhs = remaining.filter{ $0 <= pivot}
let rhs = remaining.filter{ $0 > pivot}
return lhs.quickSorted() + [pivot] + rhs.quickSorted()
}
return self
}
}
在下个人觉得,理解递归需要从两个维度:
- 宏观角度,以目的为导向,以上边的代码为例,宏观思想就是拿到一个数据,然后把小于它的数据放到左边,大于的数据放到右边
- 微观角度,对局部再此应用宏观规则,当我们获得了左边的数据后,左边的数据并不是排好序的,因此,我们对这一局部数据再次应用宏观规则,于是就产生了递归
因此,我们得出这样的结论,递归产生在局部数据里,当然,别忘了给出打破递归的条件。
命令式和声明式编程
小明最近自学了SwiftUI和Flutter,已经深深爱上了这种声明式的编程风格。在上边的很多小节中,相信大家应该对声明式编程有了深刻的体会。
我在这里不想说太多,放上两段代码,大家自己对比下:
override func viewDidLoad() {
super.viewDidLoad()
let label = UILabel(frame: CGRect(x: 100, y: 100, width: 100, height: 100))
label.text = "世界和平"
label.textColor = .purple
view.addSubview(label)
}
vs
var body: some View {
Text("世界和平")
.color(.purple)
}
- 声明式的核心思想是描述你想要什么?
- 命令式的核心是想是如何去做?
参考
[An Introduction to Functional Programming in Swift
网友评论