Swift函数式编程探索

HansenJoan 发布于1年前

以前在iOS上,除了RAC,比较少有函数式编程方面的实践。swift对函数式做了较多的支持,随着swift的普及,在iOS社区,函数式编程被越来越多的开发者所接受。并且因为函数式编程的一些优点,也越来越多的语言开始支持函数式的开发范式。

因为最近也在项目中开始实践函数式编程,也能逐渐感受到函数式强大之处。目前也有一点心得,本文就谈下自己对函数式编程的理解。

不过在开始之前,先推荐个小段子: 好久不见

编程范式

在讲函数式编程之前,我们先来看看这两大编程范式:

  • 命令式
  • 声明式

来粘贴一个关于它们的定义:

命令式编程通过一系列改变程序状态的指令来完成计算。命令式编程模拟电脑运算,是行动导向的,关键在于定义解法,即“怎么做”,因而算法是显性而目标是隐性的。

声明式编程只描述程序应该完成的任务。声明式编程模拟人脑思维,是目标驱动的,关键在于描述问题,即“做什么”,因而目标是显性而算法是隐性的。

这两种范式的关键区别就在于,一个主要表达“怎么做”,一个主要表达“做什么”。通过两段代码,可以直观的感受一下它们的区别:

Swift函数式编程探索 Swift函数式编程探索

这两段代码都是做的同一件事,把数组里每个元素都乘2,再打印出来。

可以看出来,我们平时使用的主要都是命令式的写法,包括使用的最多的面向对象编程(OOP),其实还是属于命令式编程的范畴。而这篇文章要讲的函数式编程,则是属于声明式编程。

函数式编程的优势

  • 减轻程序员思考的负担(防秃),降低出错的可能性

就拿上面举的例子来说,先不说一眼望去,命令式写法的复杂性就明显要高得多,即使是一个如此简单的逻辑,也容易写出错误。

当时写这个例子的时候,为了突出它们的区别,我故意没用for in range这种比较常规的写法,而使用了while,自己维护index。然鹅第一次跑起来就死循环了,再检查,发现是红框里的index加1漏了写。

函数式写法中,因为没有了状态的维护,杜绝了这种错误的发生。

  • 代码可读性高
  • 更简洁、更少的代码

看回定义,声明式编程描述“做什么”,目标是显性而算法是隐性的。而阅读代码时我们往往只关心代码做了什么,而不关心怎么做,这与声明式编程的思想是不谋而合的,所以函数式编程具有更高的代码可读性。

同时为了实现这种思想,算法的实现往往是内建的,或是隐藏的,这也让函数式的实现具有更少的代码,看起来更简洁。

  • 适用于并发环境
  • 易于优化

函数式编程中所使用的函数,大多要求为纯函数,这里可以先理解为数学意义上的函数,在相同的参数输入的情况下,一定会有同样的输出。

这一特点决定了纯函数可以并行的调用,而无需任何修改,因为无论并行与否,它都能输出正确的结果。

Swift函数式编程探索

上图parallelMap函数的实现就是将数组分成多段,然后在多个线程中分别调用map,最后再拼回一整个。通过这一点改动,就简单的对这个程序进行了多线程的性能优化。

  • 细粒度的重用(函数级别)
  • 易于测试

纯函数除了之前说的,还要求“没有任何可观察的副作用,不依赖外部环境的状态”。

后半还是容易理解的,因为只要依赖了外部环境,就不能保证相同输入得到相同输出了。

前半所说的副作用,是指除了返回值外,函数还通过其他方式对调用环境产生了影响,例如修改全局变量,写文件,print到控制台等等。

纯函数很容易进行单元测试,只需关心输入输出即可。重用也是同理,不必考虑调用的顺序,不用担心影响后续逻辑,放心的复用。

Swift函数式编程探索

  • 易于重构

原因也就是前面说的那些,纯函数之间的依赖关系就是一个树,很容易理清。而对象之间的依赖就复杂得多,做过重构的同学应该都有体会,如果没有把代码看的很熟,是不敢轻易去动的。书里有一句话总结的很到位:

Swift函数式编程探索

函数式编程指南

概念都了解了,那要怎么应用到实际的项目中呢?只要在编写代码时,往下面这几点靠,自然就能写出函数式风格的代码了:

  • 只定义纯函数
  • 不要用可变量
  • 递归代替迭代
  • 尽量少的数据结构
  • 闭包、高阶函数的应用
  • 尽可能使用内置的数据结构
  • 尽可能使用内置的函数式工具

但是并非所有功能都适合用函数式来编写,很多情况下副作用是无法避免的:

函数式语言和逻辑式语言擅长基于数理逻辑的应用,命令式语言擅长基于业务逻辑的、尤其是交互式或事件驱动型的应用。

现阶段遇到这些情况时,还是不要强行应用函数式了,特别是苹果的系统框架还是基于面向对象的。

接下来举个:chestnut::

我们项目中有这样一个需求,在一个列表中,有多种元素(文件、文件夹、笔记等)。还有多种操作,每种操作支持的类型、数量不同。现在选中了一些元素,求能对这些元素进行哪些操作。

例如,选中一个文件、一个文件夹,这时可以进行移动操作(批量、不支持笔记),但不能进行重命名操作(只支持单个)。

这是一个很常见的需求,并且不涉及UI、交互,适合用函数式来改造一下。

最终我们需要这样一个东西,输入选择的元素列表,得到支持的操作集合。我们把它定义成Strategy类型,是一类函数,在函数式编程里,函数就是一等公民。

typealias Strategy = (_ items: [Any]) -> Set<WYOperationType>

光有Strategy还不够,我们要直接实现这样一个函数还是太过复杂,而且也不够灵活,需要把它拆解为更小的单元。

我采用的方法是,先定义一个默认的操作集合,包含了最常规的操作。然后定义一些Modifier来修改默认的操作集合。不同类型的元素支持哪些操作就定义在不同的Modifier里。

static let defaultStrategy: Strategy = { _ in
	[.delete, .share, .safeBoxMoveIn,
     .safeBoxMoveOut, .shareDirWithFriend, .rename,
     .download, .jumpToDir, .openIn,
     .favour, .move, .inbox, .unInbox,
     .fileInfo, .ocr, .groupMove,
     .noteGroup, .genPDF, .genGIF]
}

typealias Modifier = (_ items: [Any], _ operations: Set<WYOperationType>) -> Set<WYOperationType>

class func modify(_ strategy: @escaping Strategy, _ modifier: @escaping Modifier) -> Strategy {
    return { items in
        modifier(items, strategy(items))
    }
}

这里的modify函数,其实就是一个带items参数的compose(组合)操作。

下面我们就来定义一个目录的modifier:

static let dirSupported: Operand = { items in
    let dirs = items.lazy.compactMap { $0 as? WeiyunDir }
    return dirs.first.map {
        if $0.isCollecting() {
            return [.share, .shareDirWithFriend, .safeBoxMoveIn, .move, .inbox, .unInbox, .delete, .rename]
        } else {
            return [.share, .shareDirWithFriend, .safeBoxMoveIn, .move, .inbox, .delete, .rename]
        }
    }
}

static let dirModifier: Modifier = buildModifier(dirSupported, intersection)

dirSupported判断items中是否有目录,有的话就返回目录支持的操作,通过buildModifier和intersection,构造了一个“如果含有目录,就将目前的操作集合与目录支持的操作集合取交集”的一个Modifier。

考虑到Modifier其实也就只有支持、不支持、添加三种情况,分别对应集合的交、差、并集,就通过“返回操作集合”的Operand函数,和三种SetOperation来build一个Modifier,将Modifier的逻辑也细分:

typealias SetOperation = (_ first: Set<WYOperationType>, _ second: Set<WYOperationType>) -> Set<WYOperationType>

static let union: SetOperation = { $0.union($1) }
static let subtracting: SetOperation = { $0.subtracting($1) }
static let intersection: SetOperation = { $0.intersection($1) }

typealias Operand = (_ items: [Any]) -> Set<WYOperationType>?

class func buildModifier(_ operand: @escaping Operand, _ setOperation: @escaping SetOperation) -> Modifier {
    return { items, operations in
        if let ret = operand(items) {
            return setOperation(operations, ret)
        }
        return operations
    }
}

下面定义了几个用于将Modifier进行组合的函数:

static let emptyModifier: Modifier = { $1 }

class func concat(_ modifierLeft: @escaping Modifier, _ modifierRight: @escaping Modifier) -> Modifier {
    return { items, operations in
        return modifierRight(items, modifierLeft(items, operations))
    }
}

class func concatModifiers(_ modifiers: [Modifier]) -> Modifier {
    return modifiers.reduce(emptyModifier) { concat($0, $1) }
}

有了Modifier的compose函数,就可以定义更复杂的文件和笔记的Modifier了。

最后还有一个count的Modifier,是专门用来在多选的情况下,去除只支持单元素的操作。

static let fileSupported: Operand = { items in
    let files = items.lazy.filter { !($0 is WeiyunNote) }.compactMap { $0 as? WeiyunFile }
    return files.first.map { _ in
        [.download, .share, .shareDirWithFriend,
         .fileInfo, .safeBoxMoveIn, .move,
         .jumpToDir, .delete, .favour,
         .rename, .openIn, .ocr,
         .groupMove, .genGIF]
    }
}

static let fileUnsupported: Operand = { items in
    let files = items.lazy.filter { !($0 is WeiyunNote) }.compactMap { $0 as? WeiyunFile }
    let noOcr = files.filter { !$0.isShouldShowOCRAction() }.first
    let noImgVid = files.filter { !$0.isImageFile() && !$0.isVideoFile() }.first
    let noLivePhoto = files.filter { !$0.isLivePhoto }.first
    let secondFile = files.dropFirst().first

    let result: [WYOperationType] = (noOcr != nil ? [.ocr] : []) + (noImgVid != nil ? [.groupMove, .genGIF] : []) + (noLivePhoto != nil ? [.genGIF] : []) + (secondFile != nil ? [.genGIF] : [])
    return Set<WYOperationType>(result)
}

static let fileModifier: Modifier = concatModifiers([buildModifier(fileSupported, intersection), buildModifier(fileUnsupported, subtracting)])

static let noteSupported: Operand = { items in
    let notes = items.lazy.compactMap { $0 as? WeiyunNote }
    return notes.first.map { _ in
        return [.share, .delete, .noteGroup, .favour]
    }
}

static let noteUnsupported: Operand = { items in
    let notes = items.lazy.compactMap { $0 as? WeiyunNote }
    return notes.dropFirst().first.map { _ in
        return [.favour]
    }
}

static let noteModifier: Modifier = concatModifiers([buildModifier(noteSupported, intersection), buildModifier(noteUnsupported, subtracting)])

static let countUnsupported: Operand = { items in
    let wyItems = items.lazy.compactMap { $0 as? WeiyunItem }
    return wyItems.dropFirst().first.map { _ in
        return [.ocr, .fileInfo, .rename, .jumpToDir, .openIn, .inbox, .unInbox]
    }
}

static let countModifier: Modifier = buildModifier(countUnsupported, subtracting)

最后定义一个applyModifiers来应用一组Modifier:

class func applyModifiers(_ strategy: @escaping Strategy, _ modifiers: [Modifier]) -> Strategy {
    return modifiers.reduce(strategy) { modify($0, $1) }
}

static let filesTabStrategy: Strategy = applyModifiers(defaultStrategy, [dirModifier, fileModifier, noteModifier, countModifier])

filesTabStrategy就可以拿到我们APP的文件列表去用了,选中一些项目后,根据Strategy返回的操作集合来展示操作菜单。

如果其他场景有不同的需求,可以编写自己的Modifier,再与现有的一顿组合即可。

主要思想就是把函数、闭包作为数据来操作,运用于高阶函数。还要熟悉函数式编程的三板斧(map、filter、reduce)和lazy等函数式工具,遵循开头的那几个要点来编写代码,就算是入了函数式的大门了。

其他

  • 纯函数的引用透明

    引用透明网上有很多种解释,函数式里,是指函数运行的结果与函数本身,是可以互相替代的。显然纯函数是具有引用透明性的,所以可以延迟执行而不影响结果,这是lazy能正确执行的理论前提。

    这里给出一个比较有意思的lazy filter的实现:

    Swift函数式编程探索
  • 记忆

    可以简单的对纯函数进行缓存,提高性能。

    Swift里虽然没有内建的实现,但是自己实现一个也很简单:

    Swift函数式编程探索

    生产环境使用时还要考虑内存占用、淘汰等。

  • 递归代替迭代

    Swift函数式编程探索

    这里实现的是“每隔1、2、3、4……个数,取一个数”。

    不能使用var时,我们需要将迭代换成递归实现。好处就不再赘述了,并且符合函数式的原则。

    这里需要注意的是要用上尾递归,防止爆栈。

    然而我在swift上测试时,还是出现了爆栈的情况,网上查了资料说是swift不保证尾递归优化,好吧……

  • let的性能问题

    Swift函数式编程探索 Swift函数式编程探索

    纯函数式语言大概是不会出现这样的情况的,运行时的优化,数据结构的高度优化,结果应该是跟用var差不多的性能才对。至少以我的知识,都能实现一个插入时间复杂度为O(log n)的不可变字典。

    swift估计是没有做这方面的优化,赋值给var时应该是发生了拷贝。并且let的dict也没有插入一个值,返回一个新let dict的方法。

  • 运算符重载

    Swift函数式编程探索
  • ReactiveX

将Async、Lazy、Multi-threading版本的函数式工具,引入了面向对象编程,非常值得使用。

总结

虽然说swift引入了许多函数式编程的东西,光从“没有尾递归优化”和“不可变数据结构优化”这两点来看,实际上还是不能完全按照纯函数式语言的那一套来编码的。

函数式编程,现阶段也还不会在项目中大量运用,但是这种思维,确实可以给我们平时的编码带来不一样的启发。

以目前swift的能力来说,一些比较简单的函数式的应用还是可以胜任的。

即使在OO的编程中,这些函数式的工具也能够对简化代码、逻辑起到很大的作用。

参考资料

《函数式编程思维》

《Swift函数式编程》

查看原文: Swift函数式编程探索

  • orangemouse
  • purplepeacock
  • ticklishcat
  • yellowkoala
  • heavybird
  • goldenrabbit
  • purplerabbit