欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《编程机制探析》第十八章 函数式语法

程序员文章站 2022-05-17 13:36:42
...
《编程机制探析》第十八章 函数式语法

从本章开始,我们将开始接触到函数式编程语言的语法和代码。本书采用的是两种函数式语言——ErLang和Haskell。
我们从ErLang语法开始讲起,因为,ErLang语法比较简单易懂。不过,需要说明的是,这里的“简单易懂”,是对我们命令式程序员来说的。ErLang语法与命令式语法比较接近,命令式程序员看起来更舒服。相比之下,Haskell的语法虽然更简洁,但是,相距命令式语法甚远,读起来颇有难度。
读者可能会说,既然这样,你就干脆只采用ErLang得了,干嘛还要多讲一个Haskell。
我也想这么做,但问题在于,Haskell语言中存在着很多ErLang语言中不具有的特性,而那些特性涉及到函数式编程中的重要概念。所以,我们先讲ErLang,明白了函数式语法的基本特点之后,再看Haskell,就不会觉得那么陌生了。这个学习流程,是我根据自己的经验教训总结出来的,希望对大家也有用。
ErLang是Ericsson(爱立信)公司开发的一门开源的函数式语言。ErLang原本是针对电信领域的大规模并行实时数据处理而研发的,在并行计算方面有所长。不过,这个特点不是本书要讲的内容。关心ErLang并行编程的读者,可以找一下专门的ErLang语言来看。本书只是把ErLang作为一种函数式语言入门的敲门砖,讲解的都是函数式编程中的概念和模型。
面向对象语言中,数据类型分为两种——基本类型和对象类型(即class)。
函数式语言中,数据类型也分为两种——基本类型和函数类型。其中,基本类型和面向对象语言类似,包括整数类型、浮点数类型、字符类型(char)、字节类型(byte)等;还包括容器类型,如Tuple(元组)——对应命令式语言中的数组类型;至于List,则是函数式语言中的基本数据结构。
面向对象语言中,基本类型都是固定类型,没什么发挥余地;我们主要关注的是可以自定义的对象类型(class)。同样,函数式语言中,我们关注的是可以自定义的函数类型。
我们首先从ErLang的函数定义讲起。这里的例子还是那个我们已经非常熟悉的Fibonacci常规问题,其数学表达式为:
f(1) = 1
f(2) = 1
f(n) = f(n – 1) + f(n – 2)
用ErLang来写,就是:
fibonacci (0) -> 0 ;
fibonacci (1) -> 1 ;
fibonacci (N) -> fibonacci (N-1) + fibonacci (N-2) .
在命令式程序员看来,这种写法有些像三个同名函数并存(overload)的情况。但是,这并不是三个同名函数,而是一个函数。
在ErLang中,函数的结尾是用句号(英文的句号,一个点)来表示的。因此,上述的代码中,只有一个函数定义。
ErLang语句中的标点符号用法很象文章的标点符号。 函数名后面跟着 ->,表示函数体的开始。整个函数定义结束用一个句号“.”;同一个函数中,并列的逻辑分支之间,用分号“;”分界;顺序语句之间,用逗号“,”分隔。
那些看上去像是同名函数的定义,实际上只是不同逻辑的分支。在函数式语言中,这是一种常见的写法。这种写法叫做Pattern Match(模式匹配)。其匹配的情况很广,可以根据参数值匹配,也可以根据参数类型匹配。匹配的顺序是从前向后,类似于case语句。上面的代码完全可以写成:
fibonacci (N) –>
  case N of
1 -> 1;
2 -> 1;
N -> fibonacci (N-1) + fibonacci (N-2)
end.
其中的case of语句,相当于命令式语言中的switch case语句。
讲到这里,有一点需要特别说明一下。在函数式语言中,表达式中是可以存在条件分支的。
这在命令式语句中很少见。我只记得C语言中的类似语句,b = (1 == 2)? 20 : 10;
但是,在函数式语言中,表达式的语法极为丰富,可以包含各种条件分支,最常见的就是基于模式匹配的case of 语句。
另外,有些支持函数式语法特性的动态类型语言也支持在表达式中包含条件分支。
回头看前面的代码。认真的读者会发现,上述代码中有一个问题。代码中忽略了一个基本条件,N > 0。我们可以用一种叫做Guard(卫兵)的语法形式—— when + 条件判断语句 —— 来修饰我们的逻辑分支。
fibonacci (0) -> 0 ;
fibonacci (1) -> 1 ;
fibonacci (N) when N > 1 -> fibonacci (N-1) + fibonacci (N-2) .
Guard也可以加在case of 语句里面。例如:
  case N of
1 -> 1;
2 -> 1;
N when N > 0 -> fibonacci (N-1) + fibonacci (N-2)
end.
同样,Guard语句也是函数式语句中常见的语法形式。不过,Guard并没什么了不起的,只不过是一种类似于if语句的语法糖——挪到了Pattern Match(模式匹配)语句的后面——而已。了不起的是Pattern Match语句。
Pattern Match语句的形式十分丰富多样,不仅可以用来表现条件分支——如前面代码中所示,还可以用来进行位置匹配赋值。
在ErLang中,Tuple(元组)类型用大括号{}表示。我们可以这样进行赋值。
{A, B} = {12, 13}
就可以把一个元组中的元素一次性按照位置匹配到对应的变量里。这里,我们就一次性地把12和13两个数据同时赋值到A和B中。
我们还可以这么写,一次将一个嵌套元组结构中的数据全部都赋值到对应的变量中。
{A, {B, C}, D} = { 1, {2, 3}, 4 }
我们还可以用占位符 _ 代替不需要赋值的位置。
{A, {B, _}, _} = { 1, {2, 3}, 4 }
这里,用下划线 _ 作为占位符表示“忽略其位置或者数值”。这种表达方式也常见于函数式语言中。
List是函数式编程中最为常见的数据结构,在匹配模式中用到更多,比如,Map算法。
map(Visitor, []) -> [ ] ;
map(Visitor, [Head | Tail ]) -> [ Visitor(Head) | map(Visitor, Tail) ].
map操作本身就需要创建一个List,并不需要节省运行栈,没有写成尾递归的形式,不需要List反转操作。在真实的List函数库中,很多List算法都不是尾递归形式的,并也不需要List反转操作。
我们看到,在上面的模式匹配代码中,条件分支判断和赋值操作是同时发生的,直接把遇到的非空List的头元素和尾元素设置到Head和Tail两个变量中。
浏览前面所有的代码,我们还可以发现一个规律。ErLang代码中,所有的变量名(包括函数变量名)的第一个字母都是大写的,所有的定义出来的函数名都是小写的。这也是ErLang语言的约定。所有变量名的第一个字母大写。所有的常量名(包括定义的函数名和常量定义名)的第一个字母小写。
在ErLang语言中,所有的常量名,同时也是一个atom(原子字符串)。其概念类似于Ruby语言中的symbol概念,既代表一个固定的字符串常量,又代表一个固定不变的名字。所有的atom都可以转换成字符串,也可以从字符串转换过来。ErLang中的字符串实质上都是List。字符串和atom之间的转换通过list_to_atom和atom_to_list来转换。
于是我们可以这样获取MyFunciton:MyFunction = list_to_atom(“outer”)
如果outer函数已经定义,那么MyFucntion就等于outer函数,如果outer函数没有定义,那么list_to_atom(“outer”)会产生一个新的叫做outer的atom,MyFucntion就等于这个新产生的atom。
如果需要强制产生一个已经存在的atom,那么我们需要调用list_to_existing_atom转换函数,这个函数不会产生新的atom,而是返回一个已经存在了的atom。
关于ErLang的函数,我们还可以通过fun这个关键字来定义匿名函数。
比如,MyFunction = fun(x) -> x + 1.
这种匿名定义函数的语法,也是常见的函数式语法之一。有些资料中把这种匿名函数定义叫做lambda(拉丁字母λ),这是来自于数学公式中的概念。函数式编程模型与数学模型之间的联系之紧密,由此可见一斑。
另外,我们从前面所有的ErLang代码中还可以看出一个规律:所有的ErLang函数定义,都没有定义参数类型。这也是ErLang语言的特点。
ErLang是一种解释型的带有语法编译器的虚拟机语言。ErLang是解释语言,又不需要声明参数,看起来就像是一种动态类型的语言。因此,ErLang的函数签名只有两个组成部分——函数名和参数个数。这两个信息唯一确定了ErLang模块(Module)中的函数——同其他很多语言一样,ErLang每个文件就是一个Module。
当然,ErLang只是表现得如同动态类型语言,实际上,ErLang函数是有类型的。ErLang编译器在分析ErLang源代码的时候,能够推导出相应的参数类型和返回值类型。这叫做类型推导(Type Inference)。
比如,前面的fibonacci函数,其类型描述为:
fibonacci (integer()) -> integer()
表示,fibonacci接受一个整数,返回一个整数。
这是最简单的情况,我们来看复杂的情况。比如,前面的map函数,其类型表现形式为:
map(Visitor, SourceList) -> TargetList
Types:
Visitor = fun((A) -> B)
SourceList = [A]
TargetList = [B]
A = B = term()
这段类型描述表达的是什么意思呢?
map(Visitor, List) -> TargetList
表示,map算法接受两个参数——Visitor和SourceList,返回一个TargetList。
其中,Visitor是一个函数,接受一个类型为A的参数,返回一个类型为B的结果。
SourceList是一个元素类型为A的List。
TargetList是一个元素类型为B的List。
A和B是都是类型名。
我们看到,在这个类型描述中,并没有给出具体的类型,而是用A和B两个类型参数来代替。这种类型参数也是函数式语言中常见的类型表达方式。
在函数式语言中,这种包含类型参数的情况,也称为多态。不过,比起“多态类型”这个词,我更喜欢“抽象类型”这个词,这更能反映函数式语言中的参数化类型的本质意义。
在一些主流面向对象语言中,存在一个叫做“abstract”(抽象)的关键字,可以修饰类定义和方法定义,用来表示没有实现的类或者方法。这也是一种抽象类。但这里抽象掉的的是具体实现。而函数式语言的“抽象类型”抽象掉的是具体类型。
在形式上和语义上,函数式语言的参数化类型(抽象类型)与命令式语言的参数化类型(泛型)有很多共通之处。因此,也有资料称函数式语言的参数化类型为泛型。
为了避免各种名词引起的混淆,这种情况,无论是面向对象语言,还是函数式语言,我们都一律称为参数化类型。
看过了ErLang的基本语法特性之后,我们就可以开始学习Haskell了。
Haskell语言的命名来自于一个数学家Haskell Curry。Haskell成为语言的名字,而Curry则成为Haskell语言中的一个重要特性的名字。Haskell语言以λ演算(lambda)为基础发展而来。所以,Haskell中的匿名函数得名lambda。
Haskell由数学模型发展而来,其形式也非常接近数学公式。
比如,Fibonacci常规问题,其数学表达式为:
f(1) = 1
f(2) = 1
f(n) = f(n – 1) + f(n – 2)
用Haskell表达,就是:
fibonacci :: Integer -> Integer
fibonacci 1 = 1
fibonacci 2 = 1
fibonacci n = fibonacci (n-1) + fibonacci (n-2)
从这段代码中,我们可以看到Haskell的语法特征。
Haskell是静态类型的语言。我们可以进行类型声明。如果不声明,编译器就会进行类型推导(Type Inference),分析出其类型。
第一行是类型声明,表示:fibonacci接受一个整数作为参数,并返回一个整数。
后面的函数定义部分,基本上就是三个数学公式,定义了三个逻辑分支。这种模式匹配的定义方法与ErLang相似。
我们看到,Haskell语法尽可能地减省,没有什么标点符号,而且不要求函数定义中用圆括号()包裹参数。后面的函数调用中,(n-1) 和 (n-2) 之所以被()圆括号包裹是因为它们是表达式。如果是它们是变量名的话,是不需要()圆括号包裹的。比如,f2 x y = f x + f y。所以,在Haskell中,空格非常重要。
同ErLang一样,上述的Haskell代码可以写成case of 的形式。
fibonacci :: Integer -> Integer
fibonacci n =
  case n of
1 -> 1
2 -> 1
_ -> fibonacci (n-1) + fibonacci (n-2)

其中下划线 _ 表示占位符,代表任何数值。
同ErLang一样,Haskell语言中也有Guard。
ErLang语言的Guard是when + 条件判断语句。
Haskell语言的Guard是 竖线 | + 条件判断语句。
上面的fibonacci函数可以写成:
fibonacci :: Integer -> Integer
fibonacci 1 = 1
fibonacci 2 = 1
fibonacci n | n > 0 = fibonacci (n-1) + fibonacci (n-2)
同ErLang一样,Haskell的Guard也没什么了不起,只不过是一种类似于if语句的语法糖。
Haskell的匿名函数(lambda)的形式有些怪,用一个正斜线 \ 来开头,比如,\x -> x + 1。
我们看到,Haskell的匿名函数定义中用到了箭头 -> ,这和ErLang是一样的。
到此为止,我们看到的Haskell语法和ErLang语法相当的接近,只有一些形式上的不同。我们再来看一个复杂点的例子——Haskell的map算法。
map :: (a -> b) -> [a] -> [b]
map visitor [] = []
map visitor (element : list) = ( visitor element : map visitor list )
这个算法的实现与前面的ErLang版本类似,这不是我们关心的内容。我们关心的是这个函数的类型部分,即:(a -> b) -> [a] -> [b]
其中,a和 b 为类型参数。[a]表示一个元素类型为a的List,[b]表示一个元素类型为b的List。(a -> b)表示一个函数类型,其参数为a类型,返回值为b类型。
这个类型声明初看起来,与ErLang版本的map算法的类型描述很像。但是,我们细看一下,就会发现其中的不同之处。
在ErLang中,map函数的类型描述为map(Visitor, SourceList) -> TargetList。参数和返回值之间分得很清楚。
而在Haskell中,map函数的类型描述中,所有的参数和返回值之间都是用箭头->相连的。
Haskell为什么要采用这种表达方式?这和Haskell的一个重要语法特性相关。这个语法特性就是curry。
什么叫curry?用直白的语言来讲,就是这样:一个函数F1有N个参数,按理来说,我们调用这个函数的时候,应该一次把所有参数给齐。但是,在支持curry的函数式语言中,我们可以只给该函数M个参数(M < N)。我们得到的结果是什么?当然不是我们希望的返回值。那是什么?我们得到的是一个函数F2,这个函数F2的参数为(M – N)= X 个,我们继续给这个函数F2提供Y个参数(Y < X)。这时候,参数个数还是不够,我们又得到一个函数F3,其参数个数为(X – Y)= Z个。这一次,我们把Z个参数传给函数F3,这一次,参数个数终于够了,我们获得了返回值。这个返回值和一次就把N个参数都提供给函数F1是一样的。
我们看到,在这个过程中,我们可以分成几个步骤给一个函数提供参数,如果提供的参数个数不够,就得到另一个函数,该函数可以继续接受接下来的参数。这样分步骤提供参数的结果,和一次就提供所有参数得到的最终结果是一致的。这种特性就叫做curry。
例如下面的代码:
double :: Integer -> Integer
double x = 2 * x
然后我们这样调用map函数。doubleMap = map double
得到的doubleMap就是这样一种函数类型 :: [Integer] -> [Integer]
doubleMap接受一个整数List作为参数,返回一个整数List。其用法为
doubleMap [1, 2, 3] 得到结果 [2, 4, 6]
curry特性允许函数部分参数赋值,其作用相当于设置缺省参数值。
相对于面向对象编程来说,函数式编程少了同时携带对象的数据和方法的手段。而curry恰好就弥补了这个缺憾。
curry返回的另一个函数,可以看作是一个函数对象;curry过程中设置的部分参数值,可以看做是该函数对象携带的数据。这就有效地模拟了面向对象的对象结构。
因此,curry是函数式编程的一个很有用的特性。遗憾的是,并不是所有函数式语言都像Haskell一样支持curry。
ErLang就不支持curry。在ErLang中,想要如同面向对象编程中一样同时携带数据和方法的话,就得采用另一种语法特性——闭包(closure)。
何谓闭包?其实就是匿名函数。匿名函数是在函数体内部声明的,可以访问该函数体作用域内的变量。我们可以把一个访问了内部变量的匿名函数当做返回值,返回到外部调用函数,客观上就实现了类似于curry的功能——同时携带数据和方法。比如下面的ErLang代码:
make_closure(A, B) –>
  fun(X, Y) -> A + X – B - Y end.
就返回了一个函数,这个函数中携带了A 和 B这两个make_closure函数内部的变量,并接受两个参数——X和Y。用法为:
F = make_closure(1, 2)
result = F(3, 4)
Haskell也支持闭包,这个例子用Haskell写的话就是这样。(为了清晰起见,我在类型声明中用了和参数名一样的类型参数名。)
make_closure :: a -> b -> (x -> y -> z)
make_closure a b = \x -> \y -> a + x – b – y
用法为:
f = make_closure 1 2
result = f 3 4
说明一下,Haskell中,匿名函数如果有多个参数的话,就写成 \x -> \y 这样的形式。
Haskell还可以采用curry的写法。
g :: a -> b -> x -> y -> z
g a b x y = a + x – b – y
用法为:
f = g 1 2
result = f 3 4
在面向对象语言中,对应于匿名函数的结构就是匿名类。以Java为例。
Java的类方法定义的内部,可以定义匿名类。
匿名类方法内部,也可以访问外部方法的局部变量,而且匿名类的对象实例也可以作为参数和返回值,因此也可以模拟闭包的功能。从概念上讲,闭包带出的数据应该是只读的,于是Java规定,匿名类只能访问外部的final变量,以符合概念模型。
我个人是不喜欢匿名类的,也不喜欢匿名函数。能少用就少用,能不用就不用。但函数式编程中,携带数据和函数的方案只有两种,匿名函数和curry。所以,我喜欢curry。可惜的是,很多函数式语言都不支持curry。
有读者说了,Tuple里面不也可以同时放置数据和方法吗?而且是属性表和方法表放在一起,用起来就和Javascript对象一样方便。
这是不一样的。函数式语言没有面向对象语言的this(或者self)参数,写起来复杂得多,每次调用一个Tuple里包含的函数,必须手动的、显式的把该Tuple作为参数,传递给函数。这就相当于回到了C语言的年代。不仅没实现面向对象的优点,而且连函数式的优点也丢失了。这是典型的邯郸学步,东施效颦。
curry和闭包构造出来的函数对象就不一样了。它既有接受数据(curry参数,或者闭包能够访问的外部函数的局部变量)的构造函数,还拥有自己的一个方法(即构造出来的函数本身)。其表现同面向对象里面的对象几乎完全一样。区别只在于,函数对象的内部数据是不可改变的,而且只有一个方法(即本身)。