查看原文
其他

Swift的一次函数式之旅

搜狐焦点 向辉 搜狐技术产品 2021-07-27


本文字数:5791 

预计阅读时间:26 分钟

本文适合哪些人?

本文针对的是已经有一部分Swift开发的基础,同时对函数式范式比较感兴趣的开发者。 当然,如果只对函数式范式感兴趣,我觉得这篇文章也值得一看。

函数式编程是什么?

首先来看这个词语”Functional Programming“,它是什么?

当需要去查一个专业术语的定义的时候,我的第一反应是来查询Wikipedia:

In computer science, fucnitonal programming is a programming paradigm where programs are constructed by applying and composing fucntions.

在这个定义里,有一个很熟悉的词——programming paradigm, 一般翻译为编程范式,可是我对这个翻译还是有些迷糊,于是我又在wikipedia中查找这个词语的含义:

Programming paradigms are a way to classify programming languages based on their features. 

编程范式(编程范例)是一种基于语言自身的特性来给编程语言分类的方式。

  同时wikipedia中还总结了常见的编程范式的分类:

  • imperative
    • procedural
    • object-oriented
  • declarative
    • functional
    • logic
    • mathematical
    • reactive

那么究竟什么是编程范式呢?我们知道编程是一门工程学,它的目的是去解决问题,而解决问题可以有很多的方法,编程范例就是代表着解决问题的不同思路。如果说我们是编程世界的造物主的话,那么编程范例应该就是我们创造这个世界的方法论。所以我非常喜欢台湾那边对programming paradigm 的翻译:程式設計法。

为什么我要强调编程范例是什么东西,而且还分门别类的列举了出来这些编程范例呢?

因为编程本身是抽象的,编程范例其实就是我们如何抽象这个世界的方法,我只是想通过这个具体的定义来说明函数式本身就是一种方法论。所以我们学习的时候没必要害怕它,遇到引用透明,副作用,科里化,函子,单子,惰性求值等等等等这些概念的时候,畏惧的原因只是不熟悉而已,就想我们学习面向对象的时候:继承,封装,多态,动态绑定,消息传递等等等等,这些概念我们一开始也不熟悉,所以当我们熟悉了函数式这些概念的时候,一切自然水到渠成。 在我们熟悉的面向对象的编程范式中,我们知道它的思想是:一切皆对象,而在纯函数式的编程范式中,可以说:一切皆函数。在函数式编程中,函数是一等公民,那什么是一等公民呢?就是它可以作为参数,返回值,也可以赋值给变量,也就是说它的地位其实是和Int,String, Double等基本类型是一样的,换言之,要像使用基本类型一样去使用它!

不同的思想就是创建世界的方法论的不同之处,这里我举个例子,那就是状态,比如登录的各种状态,维护状态会大大增加系统的复杂性,特别是状态很多的时候,而且引入状态这个概念之后,会带来很多复杂的问题:状态持久化,环境模型等等等,而如果使用面向对象的编程范例,可以将每一个状态都定义为一个对象如C#中的状态机的实现,而在函数式编程里呢?在SICP中提到,状态是随着时间改变的,所以状态是否可以使用f(t)来表示呢?这就是使用函数式的思路来抽象状态。

当然,我这里并不是说只能使用一种编程范式,我也并不鼓吹函数式就一直是好的,但是掌握函数式可以让我们在解决问题的时候提供更多的选择,更有效率的解决问题,事实上,我们解决问题(创造世界)肯定会使用很多种方法论即多种编程范式,一般情况下,更现代的编程语言都支持多范式编程,这里用swift里的RxSwift来举例:

public class Observable<Element> : ObservableType {
    internal init()
    
    public func subscribe<Observer>(_ observer: Observer) -> Disposable where Element == Observer.Element, Observer : RxSwift.ObserverType

    public func asObservable() -> Observable<Element>
}

// 观察者
final internal class AnonymousObserver<Element> : ObserverBase<Element> {

    internal typealias EventHandler = (Event<Element>) -> Void

    internal init(_ eventHandler: @escaping EventHandler)

    override internal func onCore(_ event: Event<Element>)
}



extension ObservableType {
    public func flatMap<Source>(_ selector: @escaping (Element) throws -> Source) -> Observable<Source.Element> where Source : RxSwift.ObservableConvertibleType
}

extension ObservableType {
    public func map<Result>(_ transform: @escaping (Element) throws -> Result) -> Observable<Result>
}

它的Observable和Observer都抽象成了类,并且添加了相应的行为,承担了相应的职责,这是面向对象范式;它实现了OberveableType协议,并且拓展了该协议,添加了大量的默认实现,这是面向协议范式;它实现了map,和flatMap方法,可以说Observable是一个函数单子(Monad),同时也提供了大量的操作符可供使用和组合,这是函数式范式;同时,总所周知,Reactive框架是一个响应式的框架,所以它也是响应式范式......

更何况,编程能力不就是抽象能力的体现吗?所以我认为掌握函数式是非常必要的!那么具体来说为什么重要呢?

在1984年的时候,John Hughes 有一篇很著名的论文《Why Functional Programming Matters》, 它解答了我们的疑问。

为什么函数式编程重要?

通常网络上的一些文章都会总结它的优点:它没有赋值,没有副作用,没有控制流等等等等,不同的只是它们对于各个关键词诸如引用透明,无副作用的种种解释,单是这只是列出了很多函数式程序"没有"什么,却没有说它“有”什么,所以这些优点其实没有太大的说服力。而且我们实际上去写程序的时候,也不可能特意去写一个缺少了赋值语句或者特别引用透明的程序,这也不是衡量质量的尺度,那么真正重要的是什么呢?

在这篇论文中提到,模块化设计是成功的程序化设计的关键,这一观点已经被普遍接受了,但有一点经常容易被忽略,那就是编写一个模块化程序解决问题的时候,程序员首先要把问题分解为子问题,然后解决这些子问题并把解决方案合并。程序员能够以什么方式分解问题,直接取决于他能以什么方式把解决方案粘起来。而函数式范式其实提供给我们非常重要的粘合剂,它可以让我们设计一些更小、更简洁、更通用的模块,同时使用黏合剂粘合起来。

那么它提供了哪些黏合剂呢?这篇论文介绍了两种:

黏合函数:高阶函数

The first of the two new kinds of glue enables simple functions to be glued together to make more complex ones.

黏合简单的函数变为更复杂的函数。这样的好处是我们模块化的颗粒度是更细的,可以组合的复杂函数也是更多的。如果非要做一个比喻的话,我觉得就像乐高的基础组件:

这种聚合就是一个泛化的高阶函数和一些特化函数的聚合,这样的高阶函数一旦定义,很多操作都可以很容易地编写出来。

黏合程序:惰性求值

The other new kind of glue that functional languages provide enables whole programs to be glued together.

函数式语言提供的另一种黏合剂就是可以使得程序黏在一起。假设有这么一个函数:

g(f(input))

传统上,需要先计算f,然后再计算g,这是通过将f的输出存储在临时文件中实现的,这种方法的问题是临时文件会占用太大的空间,会让程序之间的黏合变得不太现实。而函数式语言提供的这一种解决方案,程序f和g严格的同步运行,只有当g视图读取输入时,f才启动。这种求值方式尽可能得少运行,因此被称为"惰性求值"它将程序模块化为一个产生大量可能解的生成器与一个选取恰当解的选择器的方案变得可行。

大家如果有时间还是应该去读读这一篇论文,在论文中,它讲述了三个实例:牛顿-拉夫森求根法,数值微分,数值积分,以及启发性搜索,并使用函数式来实现它们,非常的精彩,这里我就不复述这些实例了。最后我再引用一下该论文的结论:

在本文中,我们指出模块化是成功的程序设计的关键。以提高生产力为目标的程序语言,必须良好地支持模块化程序设计。但是,新的作用域规则和分块编译的技巧是不够的——“模块化”不仅仅意味着“模块”。我们分解程序的能力直接取决于将解决方案粘在一起的能力。为了协助模块化程序设计,程序语言必须提供优良的黏合剂。函数式程序语言提供了两种新的黏合剂——高阶函数与惰性求值。

一颗枣树(例子)

这个例子我参考了Objc.io的《函数式Swift》书籍中关于如何使用函数式的方式来封装滤镜的案例。

Core Image是一很强大的图像处理框架,但是它的API是弱类型的 —— 可以通过键值编码来配置图像滤镜,这样就导致很容易出错,所以可以使用类型来避免这些原因导致的运行时错误,什么意思呢?就是说我们可以封装一些基础的滤镜Filter, 并且还可以实现它们之间的聚合方式。这就是上述论文中介绍的函数式编程提供的黏合剂之一:使简单的函数可以聚合起来形成复杂的函数。

首先确定我们的滤镜类型,该函数应该接受一个图像作为参数并返回一个新的图像:

typalias Filter = (CIImage) -> CIImage

在这里引用一段书中的原话:

我们应该谨慎地选择类型。这比其他任何事情都重要,因为类型将左右开发流程。

然后可以开始定义函数来构件特定的基础滤镜了:

/// sobel提取边缘滤镜
func sobel() -> Filter {
    return { image in
        let sobel: [CGFloat] = [-1, 0, 1, -2, 0, 2, -1, 0, 1]
        let weight = CIVector(values: sobel, count: 9)
        guard let filter = CIFilter(name: "CIConvolution3X3",
                                    parameters: [kCIInputWeightsKey: weight,
                                                 kCIInputBiasKey: 0.5,
                                                 kCIInputImageKey: image]) else { fatalError() }
        
        guard let outImage = filter.outputImage else { fatalError() }
        
        return outImage.cropped(to: image.extent)
    }
}

/// 颜色反转滤镜
func colorInvert() -> Filter {
    return { image in
        guard let filter = CIFilter(name: "CIColorInvert",
                                    parameters: [kCIInputImageKey: image]) else { fatalError() }
        guard let outImage = filter.outputImage else { fatalError() }
        return outImage.cropped(to: image.extent)
    }
}


/// 颜色变色滤镜
func colorControls(h: NSNumber, s: NSNumber, b: NSNumber) -> Filter {
    return { image in
        guard let filter = CIFilter(name: "CIColorControls", parameters: [kCIInputImageKey: image, kCIInputSaturationKey: h, kCIInputContrastKey: s, kCIInputBrightnessKey: b]) else { fatalError() }
        
        guard let outImage = filter.outputImage else { fatalError() }
        
        return outImage.cropped(to: image.extent)
    }
}

直接黏合

基础组件已经有了,接下来就可以堆积木了。如果有一个滤镜需要:先提取边缘 -> 颜色反转 -> 颜色变色,那么我们可以实现如下:

let newFilter: Filter = { image in
    return colorControls(h: 97, s: 8, b: 85)(colorInvert()(sobel()(image)))
}

上述做法有一些问题:

  • 可读性差:无法代码即注释,无法很容易的知道滤镜的执行顺序
  • 不易拓展:API不友好,添加新的滤镜时,需要考虑顺序和括号,很容易出错

自定义函数黏合

首先我们解决可读性差的问题,因为直接使用嵌套调用方法,所以会可读性差。所以我们要避免嵌套调用,直接定义combine方法来组合滤镜:

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

// sobel -> invertColor
let newFilter1: Filter = compose(sobel(), colorInvert()) // 左结合的

这是左结合的,所以可读性是OK的,但是如果有三个滤镜组合呢?四个滤镜组合呢?要定义那么多方法吗? 巧了,还真有人是这么干的:


如果大家去看RxSwift的话,就会看见它组合多个Observable的函数: zip , combineLastest ,每一个方法簇都提供了支持多个参数的组合方法,可是这就意味着我们在这个案例也是可以这样做的,但是这显然不是最好的解决方案。

如果使用combine这里三个滤镜组合的方案:

let newFilter2: Filter = compose(compose(sobel(), colorInvert()), colorControls(h:97, s:8, b:85)))

可读性还行,但是还是在添加新的滤镜的时候容易出错,不那么容易拓展。如果要再组合多个滤镜,那么就需要多个combine函数嵌套调用。

自定义操作符黏合

如果对应到数学领域的话,其实这几个滤镜的组合不就是四则运算中的  +  吗?一层一层效果的叠加,当然,确切地说,从效果上和 + 更相似,但是从特性来说更符合减法 - 的,都是向左结合,而且都不满足交换律。

所以我们可以自定义操作符来处理滤镜的结合:

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

当然还有一个小问题,就是如果有三个滤镜组合的话,会报错,因为我们没有指定它组合的方式(左结合,还是右结合)所以这里我们让它继承加法的优先级,因为它和加法一样都是左结合的:

infix operator >>>: AdditionPrecedence // 让它继承+操作符的优先级, 左结合
func >>>(filter1: @escaping Filter, filter2: @escaping Filter) -> Filter {
    return { image in
        filter2(filter1(image))
    }
}

那接下来我们愉快地使用它吧:

let filter = sobel() >>> colorInvert() >>> colorControls(h: 97, s: 8, b: 85)
let outputImage = filter(inputImage)
imageView.image = UIImage(ciImage: outputImage)
函数式Swift.001.jpeg

那么这里来总结一下这一波过程,假设需求是存在的:

我们定义了很多基础滤镜层(Filter),接下来肯定需要组合基础滤镜为我们实际需求需要的滤镜,有的滤镜可能是有三个基础滤镜组合的,有的需要五个基础滤镜组合,当然极限情况下,可能还有需要十个滤镜组合的。

所以我们需要定义不同滤镜组合的黏合函数,我们一共经历了三个组合方案的变迁:

  1. 直接组合
  2. 定义compose函数
  3. 自定义操作符

当然,诸君也可以使用更好的组合方案,如果可以希望留个言,共同探讨探讨。

还有一颗也是枣树(例子)

接下来这个例子,是一个我们使用Objective-C编程的时候经常会遇到的问题,需求如下:第二行数据必须等待第一行请求结束之后才可以开始请求。


么开始吧!

首先我们来看最容易的实现方案:

    @objc func syncData() {
        self.statusLabel.text = "正在同步火影忍者数据"
        
        WebAPI.requestNaruto { (firstResult) in
            if case .success(let result) = firstResult {
                self.sectionOne = result.map { $0 as? String ?? "" }
                DispatchQueue.main.async {
                    self.tableView.reloadSections([0], with: .automatic)
                    
                    self.statusLabel.text = "正在同步海贼王数据"
                    WebAPI.requestOnePiece { (secondResult) in
                        if case Result.success(let result) = secondResult {
                            self.sectionTwo = result.map { $0 as? String ?? "" }
                            DispatchQueue.main.async {
                                self.statusLabel.text = "同步海贼王数据成功"
                                self.tableView.reloadSections([1], with: .automatic)
                            }
                        }
                    }
                }
            }
        }
    }

熟悉吗?当然熟悉,直接在第一个请求的callback中直接进行第二个请求,但是请注意,这和OC写的有区别吗?我们这样和写和简单的人肉翻译机有区别吗?我们写的是Swift这个多范式的编程语言吗?

回到例子,我们就事论事,我觉得这样写会有几个问题:

  1. 数据修改和UI修改耦合在了一起
  2. 多重嵌套
  3. 违背了OCP(Open Closed Principle)法则:应该对修改闭合,对拓展开放
  4. 丑!

解决数据和UI耦合

从重要性的角度,我觉得应该先解决第4个问题,但是出于节奏,我们还是从第一个问题开始解决吧~

    @objc func syncDataThere() {
        // 嵌套函数
        func updateStatus(text: String, reload: (isReload: Bool, section: Int)) {
            DispatchQueue.main.async {
                self.statusLabel.text = text
                if reload.isReload { self.tableView.reloadSections([reload.section], with: .automatic) }
            }
        }
        
        updateStatus(text: "正在同步火影忍者数据", reload: (false, 0))
        
        requestNaruto {
            updateStatus(text: "正在同步海贼王数据", reload: (true, 0))
            self.requestOnePiece {
                updateStatus(text: "同步数据成功", reload: (true, 1))
            }
        }
    }

这里我把网络请求和数据处理都封装到了网络请求中,而且使用了swift的特性:嵌套函数,剥离了一部分重复代码,这样整个请求就变得非常清晰明了了,而且数据和UI就隔离开来了,并没有耦合在一起。

可是嵌套的问题还是存在,如何解决呢?

解决多重嵌套

还记得我介绍的第一棵枣树吗?我使用了自定义操作符来解决了函数调用的嵌套,这里其实也是一样的思路,但是要更复杂些。

这里我还需要重复引用一下《函数式Swift》中的那句话:

我们应该谨慎地选择类型。这比其他任何事情都重要,因为类型将左右开发流程。

第一步抽象

这里有两个类型需要抽象,第一是执行单个语句的函数(这里是更新UI),第二个是对应网络请求的函数

infix operator ->> AdditionPrecedence
typealias Action = () -> Void
typealias Request = (@escaping Action) -> Void

第二步抽象

那么如何将原来的函数拆解为使用类型表示的函数呢?

func syncDataF() {
    ......
 requestNaruto {
     updateStatus(text: "正在同步海贼王数据", reload: (true, 0))
        self.requestOnePiece {
         updateStatus(text: "同步数据成功", reload: (true, 1))
        }
 }
)

我们由上往下,那么抽象的过程应该就是

  • (Request, Action) -> Request

第一个请求 和 回调中的第一个Action,但是第一个请求还没有结束,所以返回的还是Request

  • (Request, Request) -> Request

处理了第一个Action的第一请求 + 第二个请求,  但是请求还是没有结束,所以返回的还是Request

  • (Request, Action) -> Action

第二个请求加上最后需要处理的Action , 完毕!

所以结果如下:

@objc func syncDataFour() {
 func updateStatus(text: String, reload: (isReload: Bool, section: Int)) {
      DispatchQueue.main.async {
         self.statusLabel.text = text
            if reload.isReload { 
                self.tableView.reloadSections([reload.section], with: .automatic) 
            }
        }
    }
    updateStatus(text: "正在同步火影忍者数据", reload: (false, 0))
    // 我们来拆解一下函数:要把函数抽象出来,这一点非常的重要
    // (Request, Action) -> Request
    // (Request, Request) -> Request
    // (Request, Action) -> Action
    // 通过这样的拆解方式就可以开始定义方法了
    let task: Action =
      requestNaruto
            ->> { updateStatus(text: "正在同步海贼王数据", reload: (true, 0)) }
            ->> requestOnePiece
            ->> { updateStatus(text: "同步数据成功", reload: (true, 1)) }
    task()
}

结果呢?我解决了嵌套的问题,很好,很完美,可是也很天真。

解决OCP问题

即使我们使用了自定义操作符,也没有解决OCP问题,因为如果我们要添加请求的话,我们还是需要修改原来的方法,依然违背了OCP法则。

那么怎么解决呢?

嗯嗯,具体的,请各位自己去试验吧!

我在文章尾部添加了相应的引用信息,这个例子是基于2016年的国内的Swift大会中翁阳的分享《Swift, 改善既有代码的设计》,如果有时间,希望大家可以去看看这个分享。

在分享中,他使用了面向协议的思路解决了OCP问题,很抽象,很精彩。

总结

很开心诸位看到了这里,我觉得这篇文章的能量密度应该不会浪费你们的时间。

在这边文章中,我首先是追问了函数式编程,以及编程范式的定义,只是想告诉大家:函数式编程之所以复杂只是因为我们不熟悉,同时它也应该是我们必须的工具。

然后我介绍了《Why Functional Programming Matters》这篇论文,它说明了为什么函数式编程重要,提到函数式范式的两大武器:高阶函数和惰性求值。

最后我使用了两颗枣树来给大家看一看Swift语言结合函数式的思想可以有哪些奇妙的化学反应。

那么这一次Swift的一次函数式之旅就结束了。但是还是想补充几句,每一年的WWDC其实Swift都更新了很多的内容,Swift本身也一直在增加新的特性,一直在稳健的迭代着,如果我们还是使用Objective-C的思维去写Swift的话,其实本身是落后于语言发展的。

最后引用王安石的《游褒禅山记》中的一段话:

而世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。

与君共勉!

引用

  1. wikipedia. "Functional programming".(https://en.wikipedia.org/wiki/Functional_programming)
  2. wikipedia. "Programming paradigm". (https://en.wikipedia.org/wiki/Programming_paradigm)
  3. John Hughes. "Why Functional Programming Matters".(PDF) (https://www.cs.rice.edu/~javaplt/411/21-spring/Readings/whyfp90.pdf)
  4. objc. "Functional Swift".(eBook)(https://objccn.io/products/functional-swift/)
  5. 翁阳. "Swift, 改善既有代码的设计".(Video)(https://www.youtube.com/watch?v=z4rETUhZdKc&list=PLb4_C8I-kVdlz54GIdtPixEduoVbsoLiB&index=7)
  6. 包函卿. "Swift函数式实践".(Video)(https://www.youtube.com/watch?v=lf92tk3t4IA&list=PLb4_C8I-kVdlz54GIdtPixEduoVbsoLiB&index=6)
  7. ScottWlaschin. "The Functional ToolKit".(Video)(https://www.bilibili.com/video/BV1ex411d7Nt?p=2)




    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存