函数式编程介绍

历史来源

讲述历史来源,不喜欢的可以跳过。但是我个人认为这对理解有帮助。

在计算机的世界中,有两位巨擘对问题的可计算性做了模型化描述[4]。

一位是阿兰.图灵(Alan Turing),他提出的图灵机。计算机系的各种学科中都充斥着这个概念,假设有一个纸带和一个打孔机,然后有一套指令,能够控制打孔机在纸带上移动、能够读取当前位置是否打了孔、能够在当前位置打一个孔,这就是一个图灵机,假设一个问题能够靠这个纸带+打孔机+指令的方式解决,那就说明这个问题是“可计算的”。

另外一个位巨擘,是阿隆佐·邱奇(Alonzo Church)。邱奇是个数学家,他提出了Lambda演算(Lambda Calculus)的概念,用函数组合的方式来描述计算过程,换句话来说,如果一个问题能够用一套函数组合的算法来表达,那就说明这个问题是可计算的。

我个人对这两种计算过程的模型是这样理解的,不一定对:

图灵机是通过一系列指令和状态来完成某种过程的,即,在某种状态下使用怎样的指令转移到某种状态。
Lambda演算是通过一个函数来解决这个问题,而这个函数又是由一系列别的函数组成,这样递归下去,最终达到常量。有些类似动态规划那种一个问题由多个子问题组成,子问题又有自己的子问题,最后到达一些已知解的问题。
不是全部的Lambda演算思想都可以运用到实际中,因Lambda演算在设计的时候就不是为了在各种现实世界中的限制下工作的。
编程范式

编程语言主要有三种类型[3]:

命令式编程(Imperative Programming): 专注于”如何去做”,这样不管”做什么”,都会按照你的命令去做。解决某一问题的具体算法实现。
函数式编程(Functional Programming):把运算过程尽量写成一系列嵌套的函数调用。
逻辑式编程(Logical Programming):它设定答案须符合的规则来解决问题,而非设定步骤来解决问题。过程是事实+规则=结果。
关于这个问题也有一些争议,有人把函数式归结为声明式的子集,还有一些别的七七八八的东西,这里就不做阐述了。
声明式编程:专注于”做什么”而不是”如何去做”。在更高层面写代码,更关心的是目标,而不是底层算法实现的过程。
如, css, 正则表达式,sql 语句,html,xml…
在这儿来对比一下命令式和函数式的一些区别。

命令式编程

命令式编程是通过赋值(由表达式和变量组成,assignment,我觉得翻译成任务有点奇怪,也不懂翻译成赋值行不行)以及控制结构(如,条件分支、循环语句等)来修改可修改的变量。

它的运作方式具有图灵机特性,且和冯诺依曼体系的对应关系非常密切。甚至可以说,命令式程序就是一个冯诺依曼机的指令序列:

变量 →
内存
变量引用 →
输入设备
变量赋值 →
输出设备
控制结构 →
跳转操作
我们知道的,冯诺依曼结构需要用总线来传输数据,我们只能一个字节一个字节处理。

这也就形成了运行的瓶颈。程序执行的效率取决于执行命令的数量,因此才会出现大O表示法等等表示时间空间复杂度的符号。

函数式编程

相比于命令式编程关心解决问题的步骤,函数式编程是面向数学的抽象,关心数据(代数结构)之间的映射关系。函数式编程将计算描述为一种表达式求值。

在狭义上,函数式编程意味着没有可变变量,赋值,循环和其他的命令式控制结构。即,纯函数式编程语言。

Pure Lisp, XSLT, XPath, XQuery, FP
Haskell (without I/O Monad or UnsafPerformIO)
在广义上,函数式编程意味着专注于函数

Lisp, Scheme, Racket, Clojure
SML, Ocaml, F#
Haskell (full language)
Scala
Smalltalk, Ruby
函数

函数式编程中的函数,这个术语不是指命令式编程中的函数(我们可以认为C++程序中的函数本质是一段子程序Subroutine),而是指数学中的函数,即自变量的映射(一种东西和另一种东西之间的对应关系)。也就是说,一个函数的值仅决定于函数参数的值,不依赖其他状态。

在函数式语言中,函数被称为一等函数(First-class function),与其他数据类型一样,作为一等公民,处于平等地位,可以在任何地方定义,在函数内或函数外;可以赋值给其他变量;可以作为参数,传入另一个函数,或者作为别的函数的返回值。

纯函数

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

不依赖外部状态
不改变外部状态
比如Javascript里slice和splice,这两个函数的作用相似[5]。
slice符合纯函数的定义是因为对相同的输入它保证能返回相同的输出。
splice却会嚼烂调用它的那个数组,然后再吐出来;这就会产生可观察到的副作用,即这个数组永久地改变了。

var xs = [1,2,3,4,5];

// 纯的
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]


// 不纯的
xs.splice(0,3);
//=> [1,2,3]
xs.splice(0,3);
//=> [4,5]
xs.splice(0,3);
//=> []
在函数式编程中,我们讨厌这种会改变数据的笨函数。我们追求的是那种可靠的,每次都能返回同样结果的函数,而不是像splice这样每次调用后都把数据弄得一团糟的函数,这不是我们想要的。

来看看另一个例子。

// 不纯的
var minimum = 21;

var checkAge = function(age) {
return age >= minimum;
};


// 纯的
var checkAge = function(age) {
var minimum = 21;
return age >= minimum;
};

在不纯的版本中,checkAge的结果将取决于minimum这个可变变量的值。换句话说,它取决于系统状态(system state)。

除了输入值之外的因素能够左右checkAge的返回值,不仅让它变得不纯,而且会增加程序的认知难度(cognitive load),使我们理解整个程序的时候都非常痛苦。甚至,这种依赖状态会影响系统的复杂度(http://www.curtclifton.net/storage/papers/MoseleyMarks06a.pdf)。

函数与方法

当然在现在很多(非纯)函数式编程语言中也有方法和函数的区别。比如scala[1]:

scala> def m(x:Int) = 2*x //定义一个方法
m: (x: Int)Int

scala> m //方法不能作为最终表达式出现
<console>:9: error: missing arguments for method m;
follow this method with '_' if you want to treat it as a partially applied function
m
^

scala> val f = (x:Int) => 2*x //定义一个函数
f: Int => Int = <function1>

scala> f //函数可以作为最终表达式出现
res9: Int => Int = <function1>

方法就是命令式编程中的函数,而函数则是函数式编程中的函数。

变量与表达式

纯函数式编程语言中的变量也不是命令式编程语言中的变量(存储状态的内存单元),而是数学代数中的变量,即一个值的名称。

变量的值是不可变的(immutable),也就是说不允许像命令式编程语言中那样能够多次给一个变量赋值。比如说在命令式编程语言我们写x = x + 1。

函数式语言中的条件语句,循环语句等也不是命令式编程语言中的控制语句,而是一种表达式。

“表达式”(expression)是一个单纯的运算过程,总是有返回值;
“语句”(statement)是执行某种操作(更多的是逻辑语句。),没有返回值。
函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。比如在Scala语言中,if else不是语句而是三元运算符,是有返回值的。

严格意义上的函数式编程意味着不使用可变的变量,赋值,循环和其他命令式控制结构进行编程。 当然,很多所谓的函数式编程语言并没有严格遵循这一类的准则,只有某些纯函数式编程语言,如Haskell等才是完完全全地依照这种准则设计的。

状态

首先要意识到,我们的程序是拥有“状态”的。 想一想我们调试C++程序的时候,经常会在某处设置一个断点。程序执行断点就暂停了,也就是说程序停留在了某一个状态上。

 

这个状态包括了当前定义的全部变量,以及一些当前系统的状态,比如打开的文件、网络的连接、申请的内存等等。具体保存的信息和语言有关系。

比如使用过Matlab、R之类的科学计算语言的朋友会知道,在退出程序的时候它会让你选择是否要保存当前的session,如果保存了,下次打开时候它会从这个session开始继续执行,而不是清空一切重来。你之前定义了一个变量x = 1,现在这个x还在那里,值也没变。

这个状态就是图灵机的纸带。有了状态,我们的程序才能不断往前推进,一步步向目标靠拢的。

函数式编程不一样。函数式编程强调无状态,不是不保存状态,而是强调将状态锁定在函数的内部,不依赖于外部的任何状态。更准确一点,它是通过函数创建新的参数或返回值来保存程序的状态的。

状态完全存在栈上。

函数一层层的叠加起来,其中每个函数的参数(是参数,不是变量)或返回结果来代表了程序的一个中间状态。如果你需要保存一个状态一段时间并且时不时的修改它,那么你可以编写一个递归函数。

举个例子,试着写一个计算斐波那契数的函数:

// C++
int fab(int n) {
return n == 1 || n == 2 ? 1 : fab(n - 1) + fab(n - 2);
}

-- Haskell
fab :: (Num a) => a -> a
fab n = if n == 1 || n == 2 then 1 else fab(n - 1) + fab(n - 2)

对于C++而言,调用fab(5)的过程是:

fab(5) →
fab(4) →
fab(3) →
fab(2)
                →
fab(1)
             →
fab(2)
          →
fab(3) →
fab(2)
             →
fab(1)

我们看到,fab(3)被求值了两遍。为了计算fab(5),我们实际执行了8次函数调用。

对于Haskell而言,如果没有使用尾递归,那么情况和C++的调用方式相同。如果使用了尾递归优化,调用fab(5)的过程是:

fab(5) →
fab(4) →
fab(3) →
fab(2)
                →
fab(1)
             →
fab(2)
          →
fab(3)

总共只有6次应用。注意我说的是应用而不是调用。因为函数式语言里的函数本意并不是命令式语言里面的“调用”或者“执行子程序”的语义,而是“函数与函数之间的关系”的意思。比如fab函数中出现的两次fab的应用,实际上说明要计算fab函数,必须先计算后续的两个fab函数。这并不存在调用的过程。因为所有的计算都是静态的。Haskell可以认为所有的fab都是已知的。因此实际上所有遇到的fab函数,Haskell只是实际地计算一次,然后就缓存了结果。

函数式编程的特性

高阶函数(Higher-order function)
偏应用函数(Partially Applied Functions)
柯里化(Currying)
闭包(Closure)
---------------------
作者:SuPhoebe
来源:CSDN
原文:https://blog.csdn.net/u013007900/article/details/79104110
版权声明:本文为博主原创文章,转载请附上博文链接!

posted @ 2019-07-26 18:52  zzfx  阅读(236)  评论(0编辑  收藏  举报