一文读懂Swift函数式编程

最近在研究SwiftUI中的Combine框架,主要是学习这本书的内容:Using Combine,其中一个很重要的概念就是Functional Programming,也就是函数式编程。我相信这个概念大家肯定都听过,但要把它简单的讲明白,也不是一件容易的事儿,在这篇文章中,我将用一个实例来做一个讲解。

本文的主要目的就是弄明白下边几个关键词:

  • 函数式编程
  • 不可变状态和副作用
  • 一等公民和高阶函数
  • 偏函数和纯函数
  • 引用透明
  • 递归
  • 命令式和声明式编程

函数式编程

var name = "小明"
name = "小红"

上边这段代码,实在是太简单了,我们一般这样描述:创建了一个变量name,给它赋值为小明,再修改它的值为小红。从我们学习编程开始,就是这么写代码的,这有什么问题吗?

并无任何问题,大家可能不知道,这其实是一种命令式的编程风格,简而言之,你创建了一个变量,然后你主动为其赋值。变量这个概念,还是有点怪的,大家仔细想想,变量可以在程序运行过程中被改变,这就引入了一个时间的观念,因此,我们在阅读代码的时候,不得不考虑程序执行的时间因素,花费大量的精力来分析某个变量的生命周期,我相信大家一定都经历过这样痛苦。

举个🌰:

func eatApple() {
    print("我吃了一个大🍏")
    name = "翠花"
}
eatApple()

也不知道哪位同学写的代码,在eatApple()中,修改了nameeatApple()就是为了吃苹果,为什么要修改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的关键词是过滤。

小明立马想到了快速排序算法,快排的核心思想是:

快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:

  • 从数列中挑出一个元素,称为 “基准”(pivot);
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
Kapture 2020-08-20 at 10.40.56.gif

小明想到,如果把上边的插入排序替换成快速排序,速度就更快了,也正好能用上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,FilterReduce完全是一套变化无穷的剑法,就像打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) 
    }
  • 声明式的核心思想是描述你想要什么?
  • 命令式的核心是想是如何去做?

参考

十大经典排序算法(动图演示)

Swift自定义操作符

[An Introduction to Functional Programming in Swift