目 录CONTENT

文章目录

函数响应式编程 ( FRP ) 从入门到放弃——基础概念篇

ByteNews
2022-01-31 / 0 评论 / 1 点赞 / 6,031 阅读 / 5,714 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-01-31,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

函数响应式编程 ( FRP ) 从入门到放弃——基础概念篇

前言

研究 ReactiveCocoa 一段时间了,是时候总结一下学到的一些知识了。

一.函数响应式编程

说道函数响应式编程,就不得不提到函数式编程,它们俩到底有什么关系呢?今天我们就详细的解析一下他们的关系。

现在有下面 4 个概念,需要我们理清一下它们之间的关系:
面向对象编程 Object Oriented Programming
响应式编程 Reactive Programming
函数式编程 Functional Programming
函数响应式编程 Functional Reactive Programming

我们先来说说什么是函数式编程 Functional Programming,我们先来看看wikipedia上的相关定义:

Functional Programming is a programming paradigm

  1. treats computation as the evaluation of mathematical functions.
  2. avoids changing-state and mutable data

总结一下函数式编程具有以下几个特点:

  1. 函数是"第一等公民"
  2. 闭包和高阶函数
  3. 不改变状态(由此延伸出”引用透明”的概念)
  4. 递归
  5. 只用"表达式",不用"语句",没有副作用

接下来我们依次说明一下这些特点。

一. 函数是"第一等公民"

所谓"第一等公民"(first class),指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。

一等函数的理念可以追溯到 Church 的 lambda 演算 (Church 1941; Barendregt 1984)。此后,包括 Haskell,OCaml,Standard ML,Scala 和 F# 在内的大量 (函数式) 编程语言都不同程度地借鉴了这个概念。

PS:世界上最纯粹的函数式编程语言非 Haskell 莫属。

二.闭包和高阶函数

闭包是起函数的作用并可以像对象一样操作的对象。与此类似,函数式编程语言支持高阶函数。高阶函数可以用另一个函数(间接地,用一个表达式) 作为其输入参数,在大多数情况下,它甚至返回一个函数作为其输出参数。这两种结构结合在一起使得可以用优雅的方式进行模块化编程,这是使用函数式编程的最大好处。

三. 不改变状态(由此延伸出”引用透明”的概念)

不改变状态:
函数式编程只是返回新的值,不修改系统变量。因此,不修改变量,也是它的一个重要特点。在其他类型的语言中,变量往往用来保存"状态"(state)。不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。

避免使用程序状态和可变对象,是降低程序复杂度的有效方式之一,而这也正是函数式编程的精髓。函数式编程强调执行的结果,而非执行的过程。我们先构建一系列简单却具有一定功能的小函数,然后再将这些函数进行组装以实现完整的逻辑和复杂的运算,这是函数式编程的基本思想。

引用透明:
如果提供同样的输入,那么函数总是返回同样的结果。就是说,表达式的值不依赖于可以改变值的全局状态。这使您可以从形式上推断程序行为,因为表达式的意义只取决于其子表达式而不是计算顺序或者其他表达式的副作用。

这里有出现了一个问题:

面试题: 纯函数式的闭包是否满足函数式编程里面不改变函数状态的特性?

根据纯函数的定义

在计算机编程中,假如满足下面这两个句子的约束,一个函数可能被描述为一个纯函数:

  1. 给出同样的参数值,该函数总是求出同样的结果。该函数结果值不依赖任何隐藏信息或程序执行处理可能改变的状态或在程序的两个不同的执行,也不能依赖来自 I/O 装置的任何外部的输入(通常是这样的--看下面的描述)。
  1. 结果的求值不会促使任何可语义上可观察的副作用或输出,例如易变对象的变化或输出到 I/O 装置。

函数的返回值是不需要依赖所有(或任何)参数值,必须不依赖参数值以外的东西。函数可能返回多重结果值,并且对于被认为是纯函数的函数,这些条件必须应用到所有返回值。假如一个参数通过引用调用,任何内部参数变化将改变函数外部的输入参数值,它将使函数变为非纯函数。

回到我们讨论的这个问题上来:

闭包虽然可以把闭包外部的变量捕获到闭包内部,但是闭包还是满足不改变状态的特性的。假设 f(x)的返回值是 g(x),而 g(x)是会依靠 f(x)的参数返回的,g(x)相当于拥有 f(x)的闭包。这个时候就会有一种错误的感觉,g(x)捕捉了 f(x)入参的变量,从而产生了不同的闭包。从而得出 g(x)不是纯函数式的,因为它改变了状态。如果我们站在更高的层面去看待这个问题,函数在函数式编程里面是一等值,和结构体,整型,布尔类型没有区别。回到上述的问题中来,由于我们传入了不同参数,但是闭包里面的整体算法是没有变化的。更加详细的例子,f(x)返回一个计算 x 平方的函数 g(x),g(x)虽然每次都会由 f(x)传入的 x 值变化而变化,但是 g(x)整体算法就是计算 x 的平方,这个计算方法是没有变化的,不根据外部状态改变而改变的。那么这个 g(x)的 block 是满足函数式编程的不改变函数状态的特性的。所以它也是引用透明的。

额外需要说明的一点,__block 这个关键字其实是破坏了函数式编程的。

面试题: 如何理解引用透明?

如果一个函数只会受到入参的变化,那么这个函数每次的调用都会是相同的
一个函数 f(x),里面调用了 g(x),g(x)里面又调用了 h(x),h(x)最终计算出了结果,作为 f(x)的返回值返回了。如果所有的状态都没有改变,f(x)下一次再调用相同的参数的时候,应该会得到完全一样的结果,那这个时候其实不用再调用 g(x)和 h(x)了,也可以得到完全一样的结果。当一个函数,不依赖“外部”变量和状态,只依赖入参的变化而影响函数最终返回值,也就是说入参相同,得到的返回值结果一定相同,如果函数具有这种性质,就可以说这个函数是引用透明的。

typedef int(^intFx)(int a);

intFx transparent(intFx origin) {
    NSMutableDictionary *results = [NSMutableDictionary dictionary];
    return ^int(int p) {
        if (results[@(p)]) {
            return [results[@(p)] intValue];
        }
        results[@(p)] = @(origin(p));
        return [results[@(p)] intValue];
    };
}

在上述例子中可以看到,如果 result 里面有我们需要的值了,我们就不会再去调用回调的闭包,这样 transparent 的函数每次传入相同的值,肯定会返回相同的结果。

一个纯函数在执行的过程中,只跟入参有关,在函数体中并不会引用外部全局变量,或者说是一个类方法里面的其他成员变量。另外,纯函数除了返回值之外,也不会去改变外部的变量值。满足上面这两点的纯函数,就可以说它是引用透明的。也有说法叫这种特性为幂等性

四.递归

函数式编程是用递归做为控制流程的机制。

五.只用"表达式",不用"语句",没有副作用

"表达式"(expression)是一个单纯的运算过程,总是有返回值;"语句"(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。
原因是函数式编程的开发动机,一开始就是为了处理运算(computation),不考虑系统的读写(I/O)。"语句"属于读写操作,所以就被排斥在外。
函数式编程强调没有"副作用",意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

举个例子来说明一下函数式编程和指令式编程的区别:


// 指令式编程
int factorial1(int x) {
    int result = 1;
    for (int i = 1; i <= x; i ++) {
        result *= i;
    }
    return result;
}

// 函数式编程
int factorial2(int x) {
    if (x == 1) return 1;
    return x * factorial2(x - 1);
}

上面这个例子就是计算阶乘的例子。我们先来看看指令式编程。指令式编程,像机器一条条命令一样思考问题。指令式的思想就类似于汇编,一条条指令告诉计算机该怎么去处理这个问题。所以在指令式编程里面就有很多的状态量语句。而在函数式编程里面,思想是利用数学方法来思考问题。阶乘在数学定义里面就是 f(n) = n * f(n - 1) (n > 1),f(n) = 1(n = 1)。在函数式编程里面是基本上没有状态量,只有表达式,也没有赋值语句。利用了递归解决了问题。

再来看看指令式编程和响应式编程的区别


void test() {
    int a = 5;
    int b = 8;
    int c = a + b;
    a = 10;
    NSLog(@"%d",c);
}

在指令式编程里面,计算是一种瞬间的操作。而响应式编程,计算是相互相应的,相互之间都存在关系,某些变化了,相互之间的关系会使相应的值随之变化。响应式编程有 2 个典型的例子:Excel,当单元格变化了,相互之间的单元格也会立即变化。Autolayout,当父 View 变化了,根据相互之间的关系 Constraint,子 View 的 frame 也会随之变化。

在面向对象语言中也是可以实现响应式编程的,具体做法应该是,把关系抽象出来,然后把变化抽象出来,用关系把变化事件传递下去。Cocoa 框架下 RAC 的实现就是如此。

最后再来说说函数响应式编程。
首先函数响应式编程肯定是满足函数式编程的上述特性的。函数响应式编程是面向离散事件流的,在一个时间轴上会产生一些离散事件,这些事件会依次向下传递。

RAC 就是 Cocoa 框架下的函数响应式编程的实现。它提供了基于时间变化的数据流的组合和变化。

接着再来说说之前说的 4 种编程范式,总结出来,如果按照类似继承图谱来看的话,应该如下图:

首先在声明式编程里面有 2 大家族,那就是函数式编程和数据流编程,数据流编程下面就是响应式编程,而函数响应式编程是"继承"于函数式编程和响应式编程的。

面向对象编程就属于指令式编程的范畴。从上面 2 张图来看,我们可以很明显看出这 4 者是什么关系了。

面试题: 函数式编程是面向对象编程的升级产品
由上面的说明来看,这个说法肯定是错误的,关系根据上面 2 图来看就很明显了。

面试题: 函数式语言主张不变量的原因是什么?

  1. 函数保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不修改外部变量的值。由于这一主张,我们不需要考虑线程"死锁"问题,线程之间一定是安全的,因为它不修改变量,所以根本不存在"锁"线程的问题。
  2. 进一步,函数式语言更加趋向于数学公式的推导,在数学公式里面其实是完全不存在变量这一概念的,此时如果又不存在变量了,那整个程序的执行顺序其实就不必要了,这样可以使我们更加容易的进行并发编程,更加有效率的利用多核 cpu 的计算处理能力。

二.链式调用

定义:f(x),表示的是一种态射,从 x 的定义域到 f(x)值域的态射。如果定义域和值域是完全相同的话,这种映射也成为单元态射。那么满足单元态射的函数,就可以进行链式调用。

以 RAC 为例,把 RACSignal 链式传递下去,subscribeNext 就会返回一个 RACSignal,定义域和值域都是 RACSignal,那么就满足了单元态射的要求,就可以链式调用下去。

面试题: 组成链式调用的必要条件就是在方法里面返回对象自己

这个说法是错误,举个例子:RAC 每次做信号变换的时候,都产生了一个新的信号,所以返回自己就并不是必要条件。其实如果返回自己的同类或者和自己类似的类型,里面也包含可以继续链式调用的方法,也是可以组成链式调用的。

三.关于 RAC 的其他一些概念

面试题: ReactiveCocoa 是 Facebook 出的一个 FRP 开源库

错误,是写 Github 客户端时候的附属品,附带开发出的一个开源框架。

面试题: ReactiveCocoa 是基于 KVO 的一个开源库

错误。KVO 是 RAC 非常次要的部分,甚至可以说没有 KVO,RAC 依旧可以存在。

面试题: ReactiveCocoa 是一个纯函数式编程的库

错误,由于 Cocoa 框架并不是函数式,RAC 又是在 Cocoa 框架下,所以就不是纯函数式。在命令式编程的语言范畴里面实现纯函数编程,需要折中的方法,我们可以封装命令式编程,使其向上层可以形成纯函数式的,但是下层肯定就是命令式编程实现的。

最后我们再来区分一个概念:

面试题: RAC 中 Pull-driver 和 Push-driver 的区别?

Pull-driver 是指的是任何时刻,我们如果需要数据了,都可以从 pull-driver 里面拿走数据,因为数据先存储了。整个取数据的时间控制在调用者手上。典型的例子就是 for-in 循环,这就是一个 pull-driver 的操作。不管你循环几次,每次循环如何操作,数组或者字典里面的数据都一直存在在那里,“躺”在那里。
Push-driver 是相反的,在任何时刻,当有数据或者事件产生,都会 push 给你,如果你此时没有处理,该事件或者数据就丢失了。整个取数据的时间并不控制在调用者的手里。

Pull-driver 可以类比看书,知识和文字不管你看不看,一直都在书里。
Push-driver 可以类比看电视,节目不管你看不看,都一直播放,你错过了就是错过了。

在 RAC 里面,Sequence 就是一个 pull-driver,Signal 就是一个 push-driver。

未完待续……

我会不定期把关于 RAC 相关难理解易混淆的概念都整理进来……欢迎大家指点。

1

评论区