Haskell 函数式编程体验

概述

本文经过完成一个简单的练习题(Exercise),对照在命令式编程(Imperative Programming)语言和函数式编程(Functional Programming)语言中的多种实现,在函数式编程的思惟方式上给予你们一些直观的感觉和体验。python

前言

在学习和讨论函数式编程的时候,我曾经被问到这么一个问题:函数式编程有什么特别优秀的地方?或者说有什么事情是函数式编程能够作到而在传统命令式编程里作不到或者很难作到的?记得这个问题当时回答得很敷衍,以致于如今都想不起来那时是怎么说的。以后又通过了长时间的学习,练习和思考,到目前为止仍是以为这是一个很难回答的问题。在此我愿意记录一下如今的理解,相信这个不会是最终的答案,随着学习的深刻和经验的积累,甚至于未来会有比较大的变化。首先,流行的编程语言,无论是命令式的仍是函数式的都已经被证明具备非凡的表现能力,变幻无穷的软件系统和应用就是证据。仅仅只有几条规则的lambda演算被证实是图灵完整的(https://en.wikipedia.org/wiki...),也就是说理论上仅仅基于lambda演算就能够实现全部的智能和逻辑。有一门研究性质的编程语言叫Brain F*ck,验证了只须要6个算符(或者能够称做编程原语)就能够实现一门图灵完整的编程语言,虽然它的Hello world简直就是天书(https://en.wikipedia.org/wiki...)。所以我相信咱们不可能找到什么东西是在命令式编程语言中不能实现的。区别仅在于一些问题域或是思惟方式与函数式编程的方式和风格特别地契合,从而演化出直接,简明,高效的表述,显得特别有表现力。但若是咱们试图在函数式编程语言里沿用命令式编程的思惟方式,就会遇到不少的困难,出来的结果也将会晦涩难懂,低效易错,难于修改或扩展。程序员

练习题目

有一种植物,一年有两个生长周期,春天高度加倍,夏天高度增长1米,秋冬季休眠。给出该植物在某个冬季的初始高度 i(米),请计算给出在 n 个生长周期后的高度。编码实现函数 f(i, n)
这是一个来自某个在线解题(Online Judge)网站的题目。题目自己很是的简单直接。使得咱们没必要关注于精巧的数据结构和高深的算法,从而专一于不一样的思惟方式。算法

Python 实现

def f(i, n):
    l = i
    for c in range(n):
        l = l + 1 if (c % 2) else l * 2
    return l

这里咱们选择了Python来做为命令式语言的表明。Python的实现直接明了。你们能够打开Python的交互式命令环境,键入并测试这段代码。一些可能的测试结果以下:编程

>>> f (1, 3)
6
>>> f (2, 7)
46
>>> f (2, 2)
5
>>> f (2, 3)
10
>>> f (2, 4)
11

让咱们简单分析一下代码:数组

  1. 建立一个局部变量 l 用于存放计算的中间和最终结果。其初始值就是给定的初始值。在计算过程当中,该变量的值会发生变化,始终存放当前的计算结果。在计算完成以后,其中保持的值就是最终的结果,被直接返回。
  2. 使用for循环配合range产生的n个元素的列表,分步骤地计算。
  3. 使用(exp) if (bool) else (exp)条件表达式根据生长周期的不一样运用不一样的算式。这里的条件表达式至关于C/C++/Java中的三目条件运算符?:。也可使用条件语句(Condition statement),表述一样简洁明了。

Haskell 实现(1)

Haskell是一门基于Lambda演算的“纯粹”的函数式编程语言。Haskell里没有语句(Statement)的概念,程序是由函数定义和表达式组成。函数和数值都是其中的一等公民(First Class Citizen),能够被方便地表述(Represent),存储(Store),传递(Pass/Transfer),使用(Use/Apply)和操纵(Manipulate)。一般的函数都是纯粹的(Pure),无反作用的(Ineffective),简单地说就是传入相同的参数,只能返回相同的结果。
如下是Haskell的一个实现:安全

f :: Int -> Int -> Int
f i n = foldl (\acc c -> if (odd c) then acc + 1 else acc * 2) i (take n [0,1..])

你们能够将以上代码存成一个.hs文件(Haskell源代码文件的扩展名),而后加载到ghci中测试。Ghci是Haskell的一个交互式命令环境,关于ghci安装和使用不在本文的范围内,请参见网上的其它资料。也能够在ghci里直接键入以上内容,可是不要忘记先键入一行“:{”,而后在结尾添加一行“:}”, :{和:}是ghci中用于键入多行代码必须的标识。如下是在ghci中的函数定义和测试结果:数据结构

Prelude> :{
Prelude| f :: Int -> Int -> Int
Prelude| f i n = foldl (\acc c -> if (odd c) then acc + 1 else acc * 2) i (take n [0,1..])
Prelude| :}
Prelude> f 1 3
6
Prelude> f 2 7
46
Prelude> f 2 2
5
Prelude> f 2 3
10
Prelude> f 2 4
11

咱们来一步一步地解释代码:架构

  1. 第一句f :: Int -> Int -> Int是一个函数的类型申明。能够理解为申明了一个名为f的函数,它依次接受两个类型为Int的参数,计算并返回一个Int型的值做为结果。在Haskell的函数申明里,最后的那一个->XXX能够被理解为函数的返回值的类型定义。
  2. Haskell里没有循环语句,咱们使用foldl函数实现可控的循环。熟悉Python/Java的程序员应该知道Python里有reduce函数,Java(>=8)中有stream.reduce,跟这里的foldl是相同的函数。foldl接受一个带两个参数的函数,一个初始的累积值,从一个列表的最左边开始将当前的累积值和列表的一个元素做为参数喂给该函数,而后将结果做为新的累积值参加下一步计算。累积值的初始值就是传入的初始累积值,当列表迭代完成时的累积值就是foldl的最终结果。例如 foldl (+) 0 [1,2,3]能够将列表中的全部整数累加起来,而foldl (*) 1 [1,2,3]则计算出列表中全部整数的乘积。
  3. [0,1..]是一个无穷的序列,依次包含从0开始的全部整数。take函数从一个给定的列表中从头取出n(take的第一个参数)个元素并以列表返回。能够看到take n [0,1..]事实上构造了一个和Python中range(n)相同的序列。Haskell的惰性求值(Lazy evaluation)容许咱们方便的构造无穷序列并在适当的时候合理使用。
  4. (\acc c ->...)是一个lambda函数,跟Python/Java中的lambda函数相似,能够理解为在申明/定义的地方一次性使用的匿名函数。定义中‘\’标识了lammbda函数定义的开始,以后紧跟函数的参数,多个参数间以空格隔开,以后有一个‘->’,而后就是函数的实现。lambda函数跟一般的函数同样,其实现都是一个表达式,在实现中能够访问全部的参数以及包含该lambda函数的表达式中定义和可访问的名字。在这里,咱们仅访问该lambda函数本身的参数。
  5. Haskell中没有相似if..then..else..的条件语句。这里看到的是条件表达式,相似于上面Python代码中的条件表达式和C/C++/Java中的?:运算符。在条件表达式中then和else子表达式都不可省略,并且必须具备相同的结果类型。在这里,条件表达式实现了根据生长周期的不一样而调用不一样的算式。
  6. 那个odd函数是一个测试(Predict)函数,顾名思义,它接受一个整数,判断其奇偶性,当参数是奇数时返回True,不然返回False。

总结一下,咱们使用foldl函数实现了可控循环,使用条件表达式实现了条件分发,odd函数判断生长周期的属性(奇偶),take函数配合无穷的天然数序列构造了一个有限的序列用于循环控制。这个解法虽然工做,可是显得比较笨拙,特别是那个包含条件表达式的lambda函数。其根本缘由在于咱们一路秉承的命令式编程的思路,而后在函数式语言中寻找相应的结构或函数。那么在函数式编程语言中咱们能够怎样更好地思考并解决这个问题呢?编程语言

Haskell 实现(2)

记得咱们提到过在函数式编程语言中函数是一等公民吗?咱们能够方便地表述,存储,传递而且使用函数?下面咱们来构造一个函数序列来解决这个问题:函数式编程

f' :: Int -> Int -> Int
f' i n = foldl (flip ($)) i (take n $ cycle [(*2), (+1)])

foldl和take函数咱们以前已经了解了。 让咱们来看看别的新出现的东西。

  1. 首先是函数名f',在Haskell里单引号是合法的符号字符,能够用于任何名字中,不过不能被用于名字的开头。在Haskell代码中咱们常用f',f''甚至是f'''来表示和f有些关系又不彻底同样的定义/申明。
  2. Haskell里全部的运算符其实都只是普通的函数。当咱们写2 + 4时,事实上和函数调用(+) 2 4彻底等价。(+1)的意思能够理解为咱们使用(+)这个接受两个参数的函数,而且传入数值1,使其绑定到(+)的第二个参数上,从而生成一个新的函数,该函数接受一个参数,并在该参数上加上数值1做为计算结果。这样的函数咱们称之为部分应用(Partial Applied)函数,或者更加专业一点的中文说法:偏函数。在C/C++/Python/Java这种严格求值(Strict Evaluation)的编程语言里,咱们很难作到调用一个函数,只传递给它部分的参数,使其“停”在这个中间状态,等待其它参数的传入(C++的模版库里有偏函数的实现,Python也有偏函数的库,都费了老劲了,使用又不方便,写出的代码难于理解,限制还多)。而Haskell是非严格求值(Non-strict evaluation)的或者说咱们以前提到过,惰性求值,这使得咱们很容易绑定部分的参数到一个函数,从而造成一个新的,接受更少参数的函数。事实上函数类型上的Int -> Int -> Int,咱们说过能够理解为一个函数依次接受两个Int型的参数并返回一个Int型的值做为结果,“->”其实也是一个函数(运算符),它是右结合的, 那么Int -> Int -> Int就等价于Int -> (Int -> Int),能够看到这实际上是说该函数接受一个Int类型的参数并返回一个(Int -> Int)类型的函数。回到咱们的主题,这里(*2)和(+1)均是这样的偏函数,当传入一个参数并求值时,分别将参数加倍和加一。
  3. cycle函数接受一个列表,把它经过循环重复的方式扩展为一个无穷序列。所以cycle [(*2), (+1)]就是这样一个无穷序列[(*2),(+1),(*2),(+1),(*2),(+1)......]
  4. $是一个二目算符,它接受一个函数f和一个参数a,把a做为参数喂给函数f。f $ a等价于($) f a也等价于f a。因为$是右结合的,并且优先级最低,咱们经常用它来减小括号的使用。这里的(take n $ cycle [(*2), (+1)])就等价于(take n (cycle [(*2), (+1)]))
  5. 函数(flip ($))是用在foldl中的累积函数,咱们须要重点解释一下。咱们已经知道($)是一个二目函数,也就是一个接受两个参数的函数,它认定第一个参数是一个函数,而后把第二个参数做为参数喂给该函数并将其计算结果做为本身的计算结果。flip函数能够简单地理解为改造一个多参数的函数,翻转其第一个和第二个参数的顺序。这样(flip ($))就等价于($)的参数翻转的版本,(flip ($))认定第二个参数是一个函数,把第一个参数做为参数喂给该函数而获得结果。咱们知道foldl的累积函数有两个参数,第一个是累积值(开始时就是初始值),第二个是从后面的序列中取到的元素,在这里序列中的元素是函数(*2)或者(+1),所以咱们须要累积函数将第一个参数做为参数喂给第二个参数(函数)。提及来很绕口,不过请记得关于foldl的三条规则:A)foldl的结果,给定的累积函数的返回值和给定的初始值有相同的类型 B)累积函数的第一个参数和初始值类型相同 C)累积函数的第二个参数和给定的序列中的元素有相同的类型。

总结一下:咱们经过cycle [(*2), (+1)]构造了一个加倍和加一函数交替出现的无穷序列。而后用take函数依据给定的生长周期数目获得一个有限的函数序列,该序列能够看做是一个数据加工的动做序列。以后咱们使用foldl函数迭代该动做序列,依次将植物的高度做为参数喂给序列中的动做并把结果做为下次动做的参数。而将做为累积值的植物高度喂给动做函数的任务则由函数(flip ($))来完成。这个解题思路就比较符合函数式编程的思惟方式和风格了。那么还有没有更加酷一点的方法呢?有的!至少我以为下面的实现(3)就还要酷炫一点。

Haskell 实现(3)

在实现(2)中咱们表述(Represent)了偏函数,而且将偏函数存储(Store)在列表中,以后将这些偏函数传递(Pass/Transfer)给高阶函数(高阶函数就是能处理函数的函数)foldl和(flip ($)),最后经过高阶函数把所需的参数喂给这些偏函数,应用(Apply)了它们并最终求值(Evaluation)成功。不过彷佛少了同样,说好的操纵(Manipulate)函数呢?别急,这就来:

f'' :: Int -> Int -> Int
f'' n = foldl (flip (.)) id $ take n $ cycle [(*2), (+1)]

这里除了咱们的老朋友foldl,take,cycle以及偏函数(*2),(+1)以外,有这些新的内容:

  1. 你们也许注意到f''函数类型申明没有发生变化,仍然是Int -> Int -> Int,但是它的实现为何只有一个参数f'' n而不是像以前的f和f'那样有两个参数(f i n和f' i n)呢?这是函数的参数约简在起做用。在数学上,对于两个一元函数f和g,若是对全部可能的输入参数a都有f a = g a,那么咱们就说函数f等价于函数g,记为f = g。在Haskell中这条规则彻底成立,无论何时若是有f i = g i,只要f和g中没有包含i,咱们均可以安全地把参数i约去而获得f = g。因此实际上f'' n = foldl ...是由f'' n i = (foldl ...) i经过两边约掉相同的i简化得来的。那为何参数i和n的顺序变了呢?下面咱们立刻会说到。
  2. 这个解法的思路是咱们先使用动做偏函数序列以及给定的周期数目构造一个大的all-in-one的函数,而后把给定的初始高度传给这个函数,其结果就是咱们须要的最终结果。所以咱们很天然地把先用到的周期数目n放到参数的第一个。那么就要求f'' n构造并返回一个函数,该函数接受一个参数,就是初始高度,计算并返回最终结果。
  3. 这里咱们两次使用$来减小括号的使用,能够把$都换成左括号‘(’,而后在表达式的末尾加上相应数量的右括号‘)’,获得的表达式是等价的。你们能够自行验证。
  4. 这里foldl用的累积函数是(flip (.)),咱们已经知道flip是作什么的。而运算符(.)是组合函数的函数。在数学中函数的组合(或者叫复合函数)能够定义为,对于函数f和g,若是f能够操做g的结果集,定义函数组合f.g,对于任何g定义域中的元素a,有(f.g) a = f (g a)。在Haskell里这被彻底直接地表述为f.g,对于任何a(能够做为g的参数)有(f.g) a = f (g a). 而咱们知道Haskell语法上中缀运算符能够转换为函数调用,f.g等价于(.) f g。根据运算符(.)的定义,咱们能够知道在把最终参数喂给组合函数并求值的时候,第二个函数会被先应用求值,而后结果再传给第一个函数计算出结果。而foldl是从序列的左端开始折叠,在咱们的动做序列中左端的动做是须要先作的,就是说咱们但愿在组合函数中左端的(也就是第一个参数)函数先执行求值。这就是咱们用到flip的缘由了。
  5. 咱们以前说过foldl的累积初始值的类型和折叠的结果类型要一致,这里咱们要获得一个函数,该函数接受植物的初始高度,计算并返回最终高度,它的类型应该是Int -> Int。那么咱们就须要一个一样类型的函数做为foldl的累积初始值。注意到这个初始函数会被组合到最后的组合函数链的尾端(也就是最内层)。若是咱们为这个函数命名h的话,最后的组合函数链看起来应该像这个样子.... (+1).(*2).(+1).(*2).h。而咱们并不但愿这个函数会对结果有任何影响或改变,很天然地咱们认定这个函数应该直接返回传给它的参数而不作任何更改,该函数的定义应该是h=\x->x。在Haskell里,预约义的id正是这么一个函数。因而咱们看到id被做为foldl的累积初始值。

能够在ghci中键入并验证这个实现,注意参数n和i的顺序不一样于以前的f和f',除非你使用(flip f'')。

Prelude> :{
Prelude| f'' :: Int -> Int -> Int
Prelude| f'' n = foldl (flip (.)) id $ take n $ cycle [(*2), (+1)]
Prelude| :}
Prelude> f'' 3 1
6
Prelude> f'' 7 2
46
Prelude> f'' 2 2
5
Prelude> f'' 3 2
10
Prelude> f'' 4 2
11

总结一下:咱们如方法(2)中同样构建了一个由偏函数(*2)和(+1)交替出现的动做序列。而后使用foldl和组合函数(.)将序列中的前n个元素组合(函数操纵Manipulate的一种情形)成为一个大的函数。最后该函数接受到做为参数的植物的初始高度,求值计算出结果,也就是最后的植物高度。
或者咱们能够这样理解:咱们在方法(3)中构建了一套逻辑/理论架构,在给定生长周期数目的时候,该理论架构发生塌缩,生成一个Int -> Int的函数,咱们能够理解为规模较小的理论架构。而后当传入植物的初始高度时,这个较小的理论架构再次发生塌缩,从而计算/获得最终的结果,也就是咱们想要获得的植物的最终高度。这个理解完美地契合了函数式建模/编程的一种世界观,那就是编程就是设计和建模一个理论架构,而后当接收到外来刺激的时候,该理论架构就会发生塌缩,从而造成/产生在该刺激条件下的你们所期待的完美结果。是的,完美!

相关文章
相关标签/搜索