ReactiveCocoa学习笔记(二):函数式的世界

本文主要讲了我自己对函数式编程的理解。涉及到编程范式的方面,个人的理解不免有遗漏、不准确甚至错误的地方,希望能多多批评、交流、指正。

我们认识事物,总是要问一下「What」「Why」「How」。那么,ReactiveCocoa是什么呢?

说到ReactiveCocoa,相信大部分看过介绍的人都会看到一句开场白:

ReactiveCocoa是一个函数式响应式的编程框架。

WTF,这个函数式响应式编程是个什么鬼?这一篇文章,希望从我的理解出发,先来聊聊函数式编程。

函数式编程是什么

介绍函数式编程的文章,一般都逃不过这句话:

函数式编程中,函数是一等公民。(Functions are first-class objects)

怎么去理解这句话呢?我的理解是,无论是函数式编程中的「函数是一等公民」,还是面向对象式编程中的「万物皆对象」,都是对事物的不同抽象方式。

在面向对象式的编程中,对象的概念深入人心。我们的类是「人」,阿猫阿狗的类是「宠物」,我们都有一个父类「动物」(这些话在每本面向对象语言的入门书籍中都会出现)。而行为必须依赖于对象,比如「吃东西」是「动物」的一个行为,所以子类「人」和「宠物」都会吃东西。寻找事物的共同点,并把它们归类,面向对象以这种抽象方式来描述这个世界。

而函数式编程则换了一种思路:它是对「行为」的抽象。在函数式编程里,「吃东西」就是「吃东西」,输入一些食物,输出一些经过加工的结果(咳咳,这里就不具体说是什么了……),这是多么纯粹的一个行为。既然只涉及到对食物(数据)的处理这一行为,那为什么需要具体的类呢?类似的还有对「睡觉」,「打豆豆」……等等行为的抽象。这些对行为的抽象是函数式编程对这个世界的描述方式。

既然是一等公民,那么自然地,函数式编程中,函数可以以参数、返回值的形式从一个函数传递到另一个函数,也就产生了高阶函数。这种传递本质上是对数据的流式处理。

举个栗子,「计算一个数乘以3的结果」,这是一个相当纯粹的行为吧,我们很容易写出这样的函数:

// 代码1
- (NSNumber *)multipliedByThree:(NSNumber *)x {
    return @(x.integerValue * 3);
}

输入一个参数,输出一个结果,平平无奇的一个函数哈。但如果刚好有另外一个函数,它能够对一个集合里的所有元素进行一个操作,然后返回一个新集合:

// 代码2
@implementation NSArray(SomeExcute)
    
- (NSArray *)map:(id (^)(id obj))block
{
    NSMutableArray *result = [NSMutableArray arrayWithCapacity:self.count];
    [self enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
        id value = block(obj) ?: [NSNull null];
        [result addObject:value];
    }];
    return result;
}

那么,我们就可以将multipliedByThree这个函数和map这个函数进行一次激情的碰撞:

// 代码3
[@[@(1), @(2), @(3), @(4)] map:^id(id x){
        return @(x.integerValue * 3);
}];

瞬间就将原来的数组变成了另外一个经过处理的数组了,干脆利落。

虽然代码很简单,但是还是可以稍作分析:multipliedByThree函数是我们对一个处理数据的「行为」的抽象,map也是我们对一个处理数据(数组或其他集合)的「行为」的抽象,同时,map是一个高阶函数:它接受另一个函数作为参数。通过multipliedByThreemap的抽象,我们得以在代码3中,使用简明干练的语法和代码,完成对一个集合的复杂处理:我们需要返回一个新数组,它的每一个元素值都是原数组中对应值的3倍

注意上面最后一句话,这是很直观的一个「声明式」的语句。再回头看看上面的代码3,你会发现这段代码也充满了「声明式」的意味。我们在这里只是告诉计算机,我们「需要的」是什么,而不是去描述我们应当「怎么去做」(遍历集合中的每个元素,将该值乘以3,并添加到一个新的集合中,最后返回这个新的集合)。

这正是函数式编程的魅力之一:它将原本的命令式编程(Imperative)变成了声明式编程(Declarative),将「How to do」变成了「What we want」,将思考「如何把这个问题用代码实现」转变成了「如何用代码去描述这个问题」,从而使得代码更加简洁明了和具有自注释性。诸如map的函数正是对「行为」的抽象,而通过抽象出若干个更小的、更通用的行为,将细节(如iteration)隐藏在了抽象中,我们外部的代码也就具有了「声明式」的性质。

此外,由于有了map的抽象和其接受另一个函数为参数的高阶函数特性,我们能够将不同作用的函数应用于同一份map代码上,来实现对数据的不同的处理,从而使得代码的功能结构更加清晰,模块性和可复用性大大增加。这就好比同样的原材料,同样的一台车床,只要更换不同的刀头,我们就能做出不一样的成品来。

ReactiveCocoa学习笔记(二):函数式的世界_第1张图片

对于上面这几段代码,还有两点想补充解释:

  1. 这里的map函数表现为NSArray的一个方法。但是,从本质上说,这个操作是可以普遍存在的,不依赖于这一个类。下文对此会有更详细的描述。
  2. map函数的内部实现使用了循环。这里对于在函数式编程中遍历集合中的元素是否能使用循环我尚有疑问,但是函数式编程中的函数内部,应当是使用递归而不是循环。递归也是「自解释性」的,「声明式」的,而循环则是「命令式」的代码。更重要的是,循环是会使用到变量和状态,而这正是函数式编程想要消除的(下文会讲到)。

「一等公民」许可证

In computer science, functional programming is a programming paradigm that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data. -- wiki

以上这段话是wiki中函数式编程的定义。在这段话中,有两个地方值得注意:

首先,「mathematical functions」,即数学意义上的函数意味着什么呢?它意味着一个函数要想成为函数式编程中的一等公民,可是有门槛的——它必须是纯函数(pure function)

来看这样一段代码:

// 代码4
@interface ClassA : NSObject

@property (readwrite) NSInteger count;

@end

@implementation ClassA

- (NSInteger)doAdd:(NSInteger)x {
    return self.count + x;
}
@end

其中的doAdd函数的返回值,毫无疑问是依赖于外部的变量count的。当count的值发生变化时,即使入参x的值不变,该函数也会返回一个完全不同的值。而这是纯函数绝不容许发生的:在数学上,一个函数的返回值是完全取决于输入的参数的,即对于相同的参数,必然会产生相同的结果。这就是纯函数所必须的第一个要求——确定性。

其次,「avoids changing-state and mutable data」说的则是“一等公民”的另一个重要的特点——纯函数必须不能产生副作用副作用是指一个函数在运行过程中对函数外部世界的影响,比如修改外部的变量(changing-state),改变可变数据(mutable data),或者进行io操作等等。此外,由于「循环」操作需要依靠循环变量作为中间值,所以在纯函数中,循环也是不被允许的,其可被「递归」代替。而递归所产生的调用栈太深的问题,可以由「尾递归优化」解决,这里不多深入探讨了。

一句话总结,纯函数必须只完成自己的工作,对外部世界一无所知,既不依赖于外部世界,也不会对外部世界产生影响。

函数式编程中的「状态」和「副作用」

命令式编程中,「状态」和「变量」算是不能再常见的东西了。当我们需要一步步编写一条条命令的时候,必须得有临时变量或者全局变量来保存上一步的操作结果或者状态,以便下一步的命令使用。最常见的就是「赋值」操作了:我们将一个中间的数据或状态赋值给一个变量,然后在以后的某个时刻去使用它。而「状态」和「副作用」也确实是一个真正的工程项目所不可或缺的。真的会有工程项目不需要进行io操作吗?但是像上文所说的,如果函数式编程中的函数必须是纯函数的话,该怎么处理这些呢?

函数式编程并不是完全抛弃了它们,只不过,函数式编程是通过参数的传递来完成这一切的。将「状态」(即「上下文」)和「副作用」包裹在参数中,在真正核心的数据传递的时候,将这些必不可少的信息同时打包传递下去,这样就可以在保证函数的纯洁性的同时,保存数据处理所必需的上下文信息。这就好比将真正需要处理的数据和必要的上下文信息,副作用等封装在了一个盒子里,然后让它在一个个纯函数的“管道”中流通、处理,不需要中间变量,也不需要全局状态。数据就这样“脚不沾地”地一步步变成了我们最终需要的模样。

但是,桥豆麻袋!想象一下,如果各个“管道”所进行的操作不一样:有的仅仅会返回一个值类型,有的会将参数的盒子打开,处理之后再返回一个新的“盒子”类型……此外,如果在每个管道里都要进行打开盒子-->取出核心的数据-->处理-->然后再将一些「副作用」等等一起封装成一个新的盒子-->传递出去这样一个流程的话,那么也太繁琐了点。其实,这些都是函数式编程中另一个重要的概念——monad所要解决的事情。

monad的概念google一下可谓众说纷纭。在这里,我觉得可以将其理解为函数式编程中一个在函数间约定好的数据类型。用Objective-C的语言来说,它是一个实现了monad protocol的“盒子”类型。monad protocol定义了如下两个接口函数:

-(BoxValue)return:(Value)value;
-(BoxValue)bind:(Value->BoxValue)f;

而且,monad protocol是一个派生接口,其派生关系为Functor->Applicative->Monad,因此,可以认为monad protocol中还继承了下列两个接口函数:

// 继承自Functor Protocol
-(BoxValue)map:(Value->Value)f;

// 继承自Applicative Protocol 
-(BoxValue)applied:(BoxValue(Value->Value)f); //applied也被称为<*>

其中,return函数解决的是“如何封装”的问题:接收一个值类型的数据,返回一个当前的“盒子”类型的数据(封装后的值);mapbind函数(也被称为>>=)解决的是“如何传递”的问题:接收一个“由值类型到值类型”或“由值类型到盒子类型”的数据处理的函数,返回一个当前的“盒子”类型的数据。

(注:这里只是简单说明了monad的概念,感兴趣的可以深入阅读参考资料中的相关内容)

实现了上述两个接口函数的数据结构就是monad。根据其中封装的「副作用」的不同,产生了各种各样的monad,比如可以包含若干可能类型的值的Maybe或者进行io操作的io monad;而有了mapbind函数来保证管道前后的数据类型的一致性,我们则可以玩出这样的花样:

// 代码5
[[[[BoxValue bind:block1] 
             bind:block2]
              map:block3]
             bind:block4];

我们不仅可以像这样协调串联起各个不同类型,不同作用的函数(block1block2block3block4)所组成的整个传递链,使得数据可以在这样的链式操作中进行一步步地操作,而且在传递的过程中还可以由monad自动处理上下文的信息,使得我们可以专注于处理真正的值。这就很厉害了,可以说monad是函数式编程可以进行链式操作(有的地方也叫流式操作)的基础。

ReactiveCocoa学习笔记(二):函数式的世界_第2张图片

回过头看我们上面的代码2代码3,发现什么端倪了吗?没错,代码2就是「数组」这个数据类型对map函数的实现,而代码3则是“一段管道”,所以,在这里「数组」就是一个Functor了。

而我们这个系列的主角——ReactiveCocoa,也是建立在一个monad的基础上的——RACStream类及其子类,这个monad封装了「异步」的副作用,从而使得函数式响应式的编程得以实现,当然这是后话了,在后续系列的文章中会详尽阐述。

总结

函数式编程的优点总结起来大概有这么几点:

  1. 如上文所说,函数式编程中通过抽象出函数,从而隐藏了实现的细节,使得代码更具有「声明性」,逻辑更加清晰。此外,函数的抽象还有利于逻辑层面的代码复用。
  2. 由于函数式编程中的函数为「纯函数」,对于输入输出具有确定性,所以可测试性是很强的,调试的时候也会更加方便。
  3. 由于函数式编程中的函数对外部世界一无所知,既不依赖于外部世界,也不会对外部世界产生影响,所以更加适合并发编程,不用到处使用线程锁来手动保证线程安全。

函数式编程也有一些劣势:

  1. 函数式编程的认知成本比较大,学习曲线比较陡峭,至少对于我来说是这样的(sigh~)。
  2. 有些真实客观存在的事物确实不是函数式编程的建模方式所擅长描述的,函数式编程要想描述出这些事物可能会比面向对象式编程要显得复杂和拙劣。

这或许就是我们使用(不用)函数式编程的原因吧。

在我看来,编程范式并不是一个统一的标准。函数式编程具有的数学上的严谨的美感,以及处理数据具有的优势,使得它在应用程序的底层的数据处理,或是科学研究中的数据处理方面有着很大的价值;而面向对象式编程则也许会在GUI编程中发挥更大的作用。

此外,现在很多语言也引入了函数式编程的特性和风格,例如js,ruby,python等等。在代码的编写中我们大可以充分灵活地运用这两种编程思想各自的优点,毕竟,代码还是为人服务的嘛。


Reference

javascript王国之函数教主

[Wikipedia: Functional Programming][2]
[2]: https://en.wikipedia.org/wiki/Functional_programming

[Why Functional Programming Matters][3]
[3]: http://www.cs.utexas.edu/~shmat/courses/cs345/whyfp.pdf

[Functional Thinking: Why functional programming is on the rise][4]
[4]: https://www.ibm.com/developerworks/java/library/j-ft20/index.html

[Functional Programming for JavaScript People][5]
[5]: https://medium.com/@chetcorcos/functional-programming-for-javascript-people-1915d8775504

[函数式编程扫盲篇][6]
[6]: http://blog.csdn.net/jiajiayouba/article/details/49983325

[Don’t Be Scared Of Functional Programming][7]
[7]: https://www.smashingmagazine.com/2014/07/dont-be-scared-of-functional-programming/

[Functor and Monad in Swift][8]
[8]: http://www.javiersoto.me/post/106875422394

[揭开 Monad 的神秘面纱][9]
[9]: http://joeyio.com/2016/04/24/RevealMonad/

[Functor、Applicative 和 Monad][10]
[10]: http://blog.leichunfeng.com/blog/2015/11/08/functor-applicative-and-monad/

[怎样用简单的语言解释 monad][11]
[11]: https://www.zhihu.com/question/24972880

[Imperative and Declarative Programming][12]
[12]: http://theproactiveprogrammer.com/design/imperative-and-declarative-programming/?utm_source=tuicool&utm_medium=referral

[函数式编程--CoolShell][13]
[13]: https://coolshell.cn/articles/10822.html

你可能感兴趣的:(ReactiveCocoa学习笔记(二):函数式的世界)