函数式编程入门经典

Java基础

浏览数:143

2019-6-20

导读

这是我的第二篇博文。前一阵在看朱志文前辈的《Node.js区块链开发》的时候发现,亿书这款类比特币产品的底层源码使用了大量函数式编程,层层叠叠的函数回调令人着实不安。通过查询资料发现,现在针对函数式编程的文献资料比较陈旧和分散,有价值的文章也比较少,因此本文是自己在学习过程中的一些体会和总结,希望可以给和我一样对函数式编程感兴趣的人一个参考。

本文主要的参考文献如下:

函数式编程初探  http://www.ruanyifeng.com/blog/2012/04/functional_programming.html

可能是最好的函数式编程入门 https://www.jianshu.com/p/390147c78967

傻瓜函数式编程 https://www.kancloud.cn/kancloud/functional-programm-for-rest/56931 

一.什么是函数式编程?

 函数式编程思想来源于伟大数学家阿隆佐设计的lambda验算,是指用函数来解决与计算相关的几乎所有问题。与我们平时常见的指令式编程相对,也是一种典型的编程范式。举个例子。

需要计算的数学表达式为:

  (1 + 2) * 3 – 4

指令式的编程方式如下:

  var a = 1 + 2;

  var b = a * 3;

  var c = b – 4;

而函数式编程则会将每一个运算过程定义为不同的函数,编程方式如下:

  var result = subtract(multiply(add(1,2), 3), 4);

从以上的对比例子可以看出,和面向对象编程以对象为模块的思想一样,函数式编程是以函数为核心来组织模块的,这种组织方式更有利于写出模块化的代码。

二.函数式编程的基本准则

与学习面向对象编程一样,函数式编程同样有几个鲜明的特点,需要我们在编程时牢记。

1.函数永远是“一等公民”

所谓一等公民是指,函数与我们平时所使用的其他数据类型地位一样:

(1)可以赋值给一个变量

(2)可以作为参数进行传递

(3)可以作为别的函数的返回值

将函数作为一等公民有利用代码的模块化,接下来我们来举个例子:

程序需要完成的目标: 

  有数组numberList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

  1.将numberList中的每个元素加1得到一个新的数组

  2.将numberList中的每个元素乘2得到一个新的数组

  3.将numberList中的每个元素模3得到一个新的数组

函数不是一等公民的情况:

  // 将numberList中的每个元素加1得到一个新的数组

   newList = []

  for num in numberList:

    newList.append(num + 1)

  // 将numberList中的每个元素乘2得到一个新的数组 

  newList = []

    for num in numberList:

      newList.append(num * 2)

  // 将numberList中的每个元素模3得到一个新的数组

  newList = []

    for num in numberList:

      newList.append(num % 3)

函数是一等公民的情况:

//高阶函数:接收一个函数和一个数组作为input,函数体中将这个函数作用于数组内的每一个元素作为返回值返回

def map(mappingFuction, numberList):

   newList = []

   for num in numberList:

     newList.append(mappingFuction(num))

  //lambada关键字用来定义匿名函数,x表示输入,:后面是函数体同时也是返回值

  // 将numberList中的每个元素加1得到一个新的数组

    map(lambda x: x + 1, numberList)

  // 将numberList中的每个元素乘2得到一个新的数组 

    map(lambda x: x * 2, numberList) 

  // 将numberList中的每个元素模3得到一个新的数组

    map(lambda x: x % 3, numberList)

从以上例子可以看出,函数式编程的模块化程度更高,且代码量更少

2.尽量写“纯函数”

所谓纯函数是指,给定相同输入总能得到相同输出的函数。纯函数需要同时满足下面两个条件:

  (1)函数的结果只依赖于输入的参数且与外部变量和环境无关——只要输入相同,返回值总是不变的。

  (2)除了返回值外,不修改程序的外部状态(比如全局变量、入参)——FP中所有的变量都是final的,这样设计的原因是因为lambda验算只关心计算的结果而不关心每个状态的值。

同样,举一个 纯函数VS不纯函数 的例子:

不纯的:

  var minimum=20;

  var checkAge=function(age){

    return age>=minimum;

  };

 纯的:

  var checkAge=function(age){

    var minimum=20;

    return age>=minimum;

  };

从上面不纯函数版本可以看出,checkAge函数的结果依赖于minimum这个外部变量,尽管可以带来便利,但是也会出现很多的副作用。

在数学领域函数的定义为,假设有两个变量x和y,如果对于任意一个x都有一个唯一确定的y与其对应,那么就称y是x的函数。从这个层面上来理解的话,所谓的纯函数就是数学函数。

三.函数式编程的优势

 1.单元测试与debug都十分的容易

尽管函数式编程的条件要求比较苛刻,但是在后期的调试中它的体验感却好过指令式程序好几个level。因为FP程序中的错误不依赖于之前运行过的不相关的代码,如果一段FP程序没有按照预期设计那样运行,这些错误是百分之一百可以重现的。在这种情况下,你只需要按图索骥的逐个检查返回值即可。而在一个指令式程序中,一个bug可能有时能重现而有些时候又不能。因为这些函数的运行依赖于某些外部状态, 而这些外部状态又需要由某些与这个bug完全不相关的代码通过某个特别的执行流程才能修改。

2.可并行执行

在函数式编程中,不需要进行任何改动,所有FP程序(即使是单线程)都可以并发执行。那是因为在函数式编程中没有采用锁机制,因此就不存在死锁或者并发竞争的问题。以下面的程序为例:

  var t1 = pureFunction1(arg1)

  var t2 = pureFunction2(arg2)

  var result = concatFuction(t1, t2)

在指令式编程中,因为每一个函数都可能改变其状态其后面的函数会依赖于前一个函数,因此,往往是顺序执行。而在函数式编程中,编译器可对代码进行分析,从而分析出pureFunction1和pureFunction2哪个函数费时,从而安排他们并行执行。

 3.热部署

所谓热部署是指可在不停机的状态下进行状态更新,目前大部分的软件都支持这一功能。

4.currying技术实现函数封装

currying技术将接收多个参数的函数变换为接收其中部分参数,并且返回接收余下参数的新函数。通俗的来讲就是,只向函数传递一部分参数来调用它,让它返回一个函数去处理剩下的参数。可以快速且简单的实现函数封装。为了便于理解还是举一个例子:

  var add=function(x){

    return function(y){

      return  x+y;

    }

  };

//函数调用

var addTen=add(10);

addTwo(2);

//结果为12

这里是自定义的函数add,它接收一个参数并返回一个新的函数,调用add之后返回的函数就会以闭包的方式记住add的第一个参数。

5.延迟求值

延迟求值是指表达式不在它被绑定到变量时就立即求值,而是在该值被用到的时候才计算求值。很显然,延迟求值的正确性需要纯函数的保证,即无论什么时候被执行,结果都不变。延迟求值在处理无穷数列时特别有效。下面举个例子:

对于斐波那契数列这样一个无穷数列,如何

 输出斐波那契数列的第10个到第20个数

 输出斐波那契数列中前十个偶数

 输出斐波那契数列数列的前五个能被3整除的数

如果不考虑具体的语言和实现,可以将问题拆解成几个函数。一个函数负责生成斐波那契数列,一个函数负责筛选数列中的偶数,再写个函数挑出任意数列中能被3整除的数。
将第一个函数的输出作为后面两个函数的输入,问题就得到解决了。

但问题是斐波那契数列是一个无穷数列,一般的语言无法输出一个无穷的数据结构。不过这对于支持延迟求值的语言来说不成什么问题,因为每个值只有在真正被用到的时候才会被计算出来,因此完全可以像这样定义一组无穷的斐波那契数列

  fiboSequence = createFibonacci()

然后完成上面三个需求只需要这样:

  print fiboSequence[10 : 20] #数列中第20个之后的数不会被计算

  evenFiboSequence = pickEven(fiboSequence) # 函数pickEven从斐波那契数列中挑出所有的偶数,此时并不会真正计算

  print evenFiboSequence[0 : 10] #直到输出时才会把用到的值计算出来

 

 newList = pick3(fiboSequence) #函数pick3从斐波那契数列中挑出所有能被3整除的数,此时并不会真正计算

  newList = pick3(fiboSequence) print newLIst[0 : 5] #直到输出时才会把用到的值计算出来

  

6.强制惰性语言顺序执行

 在以上所讲的惰性语言中,因无法保证第一行在第二行之前执行,如果我们直接使用则无法处理IO请求。为了使得惰性语言可以顺序执行,我们引入了continuation。函数可以根据continuation将程序传递到指定位置,从而实现IO请求。

假如我们不想编译器打乱以下代码的执行顺序,保证其顺序执行,则我们就需要使用continuation技术。

  var i=add(5,10);

  var j = square(i);

  var j = add(5,10,square);

在上例中,add多了一个参数:一个函数,add必须在完成自己的计算后,调用这个函数并把结果传给它。这时square就是add的一个continuation。上面两段程序中j的值都是225。

这样我们在理解下面的IO请求时就很容易理解了。

  System.out.println(“Please enter your name: “);

  System.in.readLine();

  System.out.println(“Please enter your name: “, System.in.readLine);

小结

以上就是函数式编程的一些概念知识点,为了对函数式编程有一个更加直观的体验,在接下来的日子里我会继续更新有关Haskell的语法梳理,敬请关注。

作者:阿童木的眷恋