有时候,一个关键字就是一扇通往新世界的大门。两年前,身边开始有人讨论函数式编程,拿关键字Functional Programming一搜,全是新鲜的概念和知识,顺藤摸瓜,看到的技术文章和框架也越来越多。
我有个习惯,在接收新知识的时候,我都会用已有的知识去做对比,我更关注新事物能对现有产品和知识体系带来哪些好处。
计算机发展到今天,已经很久很久没有理论层面的升级了,现今绝大部分新的知识都是基于已有的内核做包装。函数式编程不是新生事物,也不是独立的知识孤岛,函数式编程核心思想离我们也没那么远。
这篇文章会抛开陌生的术语和概念,只站在抽象和基础的层面,去聊下函数式编程和我们现有编程习惯的联系。
Functional Programming翻译为函数式编程,初次接触的时候会不由自主的认为,这种编程范式的核心在于对Functional的理解,或者说是对函数的理解。函数我们每天都在写,还有什么需要特别去理解的吗?我个人觉得这是个误区,相对于理解「函数」,我们更需要理解的其实是「状态」。如果叫做Stateless Functional Programming可能会更贴切一点。
函数和状态是人人都熟悉的概念,可越是简单的每日可见的概念,越难理解的透彻。说到状态,很多同学会联想到变量,局部变量,全局变量,property,model,这些都可以成为状态,但变量和状态又不是一回事,要真正理解状态,得先理解下面一行代码:
int i = 0
简单的一行代码,分析起来却有不少门道。
「 i 」就是我们所说的变量,一个变量可以看做是一个实体,真实存在于内存空间的实体。int是它的类型信息,是对于它的一种约束,类似于上帝对于人类性别的约束,每个人都需要有性别。0是它被赋予的一个值,值是外部信息,类似于人的职业,职业不是与生俱来的,人一生可以选择从事不同的职业。
变量是我们要分析的目标,它的类型信息,值信息虽然会约束变量的行为,但不是我们关注的重点,真正让变量变得 危险 的是中间的等号, = 是个赋值操作,意味着改变i的值,原本处于静态的i,由于一个=发生了变化,它的值发生了变化,它可以变为1,或者10000,或者其他任何值,这个看似简单的改变可以说是我们程序的bug之源,值的变化可以像扔进湖面的石头,层层叠叠影响其他空间和实体。
一旦一个变量开始与=打交道,一旦变量的值会发生变化,我们就可以说这个变量有了 状态 。或者我们可以说,有=就有状态。
状态也是个相对的概念,变量都有其生命周期,一旦变量被回收,其所包含的状态也随之消失,所以状态所带来的影响是受限于变量的生命周期的。我们看下这段代码:
- (int)doNothing { int i = 0; return i; }
i是函数doNothing内部的临时变量,分配在内存的栈上,一旦return,i的生命周期也随之结束。
站在doNothing函数内部这个空间范畴来说,i是有状态的,i被赋予了值0,当renturn执行之后,i被内存回收了,i随之消失,其所对应的状态也消失了,所以一旦出了doNothing,i又变得没有状态了。代码虽然执行了return i,但返回的其实是i所代表的值,i将自己的值交出来之后,就完成了自己的使命。
所以站在doNothing函数外部空间的角度来说,doNothing的使用者是感受不到i的存在的,doNothing的调用方可以认为doNothing是无状态(stateless)的,无状态意味着静止,静止的事物都是安全的,飞驰而过的火车和静止的石块,当然是后者感觉更安全。
我们编写代码的时候会经常谈论状态,函数的状态,类的状态,App的状态,归根结底,我们所讨论的是:在某个空间范畴内会发生变化的变量。
函数式编程当中的函数f(x)强调无状态,其实是强调将状态锁定在函数的内部,一个函数它不依赖于任何外部的状态,只依赖于它的入参的值,一旦值确定,这个函数所返回的结果就是确定的。可能有人会觉得入参也是状态,是外部传入的状态,其实不然,我前面说过变量才会有状态,值是没有状态的,入参传入的只是值,而不是变量。下面两个函数,一个入参是传值,一个入参是传变量:
- (void)doNothing:(int)v //传值 { } - (void)doNothing:(NSMutableArray*)arr //传变量 { }
第二个版本的doNothing,不但是传入了变量,还是可以变化的变量,是真正意义上的外部状态。很有可能在你遍历这个arr的时候,外部某个同时执行的线程正在尝试改变这个arr里的元素,是不是很危险?
所以对于下面两种调用来说:
[self doNothing:user.userID]; [self doNothing:user.friends];
第一个调用只是传入了userID所对应的值,第二个调用却传入了friends这个变量实体。第一个没依赖,第二个有依赖,第一个没状态,对调用方来说是安全的,对整个app来说也是安全的,既避免了依赖外部的状态,也不会修改外部的状态,即:不会产生side effect,没有副作用。
所以让我来总结函数式编程当中的函数,可以一句话归结为: 隔绝一切外部状态,传入值,输出值 。
我们再来看看函数式编程当中的纯函数(Pure Function)的定义:
In computer programming , a function may be considered a pure function if both of the following statements about the function hold:
纯函数即为函数式编程所强调的函数,上述两点可翻译为:
所以对函数式编程当中函数的理解,最后还是落实到状态的理解。静止的状态是安全的,变化的状态是危险的,之所以危险可以从两个维度去理解,时间和空间。
变量一旦有了状态,它就有可能随着时间而发生变化,时间是最不可预知的因素,时间会将我们引至什么样的远方不得而知,我们每创造一个变量,真正控制它的不是我们,是时间。
时间的武器是变化,是赋值,赋予变量新的值,在不可预知的未来埋下隐患。
- (void)setUserName:(NSString*)name { //before assignment _userName = name; //after assignment }
一旦有了赋值操作,时间就找到了空隙,可以对我们代码的执行产生影响。或许是在此刻,或许是明天,或许是在appDidFinishLaunch,或许是在didReceiveMemoryWarning。每一个赋值操作都是一颗种子,可以结出新feature或者新bug。
变量会随着时间变化,有状态的函数也会随着时间的流动产生不同的输出,Pure Function却是对时间免疫的,纯函数没有状态,无论站在多长的时间跨度去执行一个纯函数,它所输出的结果永远不会变,从这一角度看,纯函数处于永恒的静止状态。
如果把一个线程看成一个独立的空间,在程序的世界当中,空间会产生交叉重叠。一个变量如果可以被两个线程同时访问,它的值如果可以在两个空间发生变化,这个变量同样变得很危险。
Pure Function同样是对空间免疫的,无论多少个线程同时执行一个纯函数,纯函数总是产生相同的输出,而且不会对外部环境产生任何干扰。
多线程的bug调试起来非常困难,因为我们的大脑并不擅长多路并发的思考方式,而函数式编程可以帮我们解决这一痛点,每一个纯函数都是线程安全的。
函数式编程通过Pure Function,使得我们的代码经得起时间和空间的考验。
我们可以把一个App的代码按照函数式编程的方式,打散成一个个合格的pure function,再通过某种方式串联起来,要方便的串联函数,需要把函数变为一等公民,需要能像使用变量一样方便的使用函数。
一个Pure Function可以是stateless的,但我们的App可以变成stateless吗?显然不能。
离开了变量和状态,我们很难完整的描述业务。用户购物车里的商品总是会发生变化,今天或明天,我们总是需要在一个地方接收这种变化,保存这种变化,继而反应这种变化。所以,大多数时候,我们离不开状态,但我们能做的是,将一定会变化的状态,锁定在尽可能小的时间和空间跨度之内,通过改变代码的组织方式或架构,将必须改变的难以管教的状态,囚禁在特定的模块代码之中,让不可控变得尽量可控。
其实,即使不严格遵从函数式编程,我们同样可以写出带有Functional Programming精髓的代码,一切的一切,都是对于状态(state)的理解。
在我看来,NSMutableArray的copy,也是颇具函数式编程精髓的。
当我们把函数改造成pure function之后,会产生一些奇妙的化学连锁反应,这些反应甚至会改变我们的编程习惯。
一旦我们有了绝对安全的纯函数,我们当然希望能尽最大可能的去发挥它的价值,增加它出现和被使用的场景。为了加大纯函数的使用率,我们需要在语言层面做一些改造或者增强,以提高纯函数传递性。怎么增强呢?答案是 将函数变为一等公民 。
当我们的变量可以指向函数时,这个变量就有了函数的身份。当我们把这个变量当做函数的参数传入,或者函数的返回值传出的时候,这个变量就有了自由迁徙的能力。
一个函数A,可以接收另一个函数B作为参数,然后再返回第三个函数C作为返回值。类似下面的一段swift代码:
func funcA(funcB: @escaping (Int) -> Int) -> (Int) -> Int { return { input in return funcB(input) } //funcC }
在funcA的定义里,funcB是作为参数传入,funcC(匿名的)是作为返回值返回。funcB和funcC在这个语境里就称之为 first class function 。而funcA作为funcB和funcC的管理者,有个更高端的称谓: high order function 。
有了first class function和high order function,我们还会收获另一个成果: 语言的表达力更灵活,更简洁,更强大了。 举个例子,我们写一段代码来实现一个功能:参加party前选一件衣服。用传统的方式来写:
func chooseColor(gender: Int) -> Int { return 0 } func dressup(dressColor: Int) -> Int { return 1 } //imperative let dressColor = chooseColor(gender: 1) let dress = dressup(dressColor: dressColor) user.dress = dress
先定义函数,再分三步依次调用chooseColor, dressup,然后赋值。
如果用first class function和high order function的方式来写就是:
func gotoParty(dressup: @escaping (Int) -> Int, chooseColor: @escaping (Int) -> Int) -> (Int) -> Int { return { gender in let dressColor = chooseColor(gender) return dressup(dressColor) } } //declarative let prepare = gotoParty(dressup: { color in return 1 }, chooseColor: { gender in return 0 }) user.dress = prepare(1)
gotoParty函数柔和了dressup和chooseColor,gotoParty成了一个high order function,当我们读gotoParty的代码的时候,这单一一个函数就将我们的目的和结果都表明了。
这就是high order function的神奇之处,原先啰啰嗦嗦的几句话变成一句话就说清楚了,它更接近我们自然语言的表达方式,比如gotoParty可以这样阅读:我要挑选一件颜色适合的衣服去参加party,这样的代码是不是语意更简洁更美呢?
注意,functional programming并不会减少我们的代码量,它改变的只是我们书写代码的方式。
这种更为强大的表达力我们也有个行话来称呼它: declarative programming 。而我们传统的代码表达方式(OOP当中所使用的方式)则叫做: imperative programming 。
imperative programming更强调实现的步骤,而declarative programming则重在表达我们想要的结果。这句话理解起来可能有些抽象,实在理解不了也没啥关系,只要记住declarative programming能更简洁精炼的表达我们想要的结果即可。
以上都是我们将function变为一等公民所产生的结果,这一改变还有更多的妙用,比如 lazy evaluation 。
上述代码中的dressup和chooseColor虽然都是function,但是他们在传入gotoParty的时候并不会立马执行(evaluation),而是等gotoParty被执行的时候再一起执行。这也很大程度上增强了我们的表达能力,dressup和chooseColor都具备了lazy evaluation的属性,可以被拼装,被delay,最后在某一时刻才被执行。
所以,functional programming改变了我们使用函数的方式,之前使用OOP,我们对于怎么处理变量(定义变量,修改值,传递值,等)轻车熟路,到了函数式编程的世界,我们要学会如何同函数打交道了,要能像使用变量一样灵活自如的使用函数,这在刚开始的时候确实需要一段适应期。
函数式编程近几年颇受技术圈的关注,Peak君觉得对于新接触的知识,我们更应该关注其诞生的 目的 ,及其背后隐含的 思想 ,抓住了本质,理解那些令人望而生畏的技术术语就更有底气了。