从函数字面量发现函数式编程

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/bluishglc/article/details/45291533

版权声明:本文由本人撰写并发表于2015年3月下半月的《程序员》杂志,原文题目《从字面量发现函数式编程》,本文版权归《程序员》杂志所有,未经许可不得转载。

引言

我相信很多像我一样初次接触函数式编程的程序员来说,对于“函数字面量”这个概念会感到迷惑和不解。伴随着深入地学习,在清晰地理解了这个概念之后,我进行了一些梳理和回溯,作为函数式编程思想延伸到最基层的语言元素,我深刻地觉得“函数字面量”这个概念的背后影射出的是函数式编程的核心用意和理念。所以我想以函数字面量作为一个切入点和观察视角来讨论一下它背后蕴含的函数式编程思想的动机和意图。

函数字面量:因何而生?

对于“函数字面量”这个概念,我个人倾向于这样解释:它是基于某类函数的“类型”声明,以内联(in-line)方式写成的对应该函数类型的一个“值”(或者称实例)!就像我们在Scala里定义一个普通变量“var num:Int=1”一样,数字“1”就是一个Int型的“字面量”,它代表着一个Int类型的“值”。而对于定义里提到的“内联(in-line)”,我想也许用“就地”这个词形容会更为到位,它不是在普通函数定义时写就的,而是在需要提供一个函数的“值”(也就字面量)时随手写就的,就像我们重新给变量num赋一个新值“2”一样,数字“2”就是随手写就的。其实所有的“量”或者说“字面量”都是内联的,是随手写就的,这里之所以要强调“内联”是为了和传统的函数定义形式进行区分!这一点我们后面会再提到。

追根溯源,是什么样的原因导致了函数字面量的产生?在过去传统的非函数式编程语言里,函数就是函数,是一种包含了函数名,参数列表,返回值和函数体的代码结构,这里不存在函数“类型”和“值”(字面量)的说法,因为这两者在传统的函数定义形式里是融合在一起的,函数的定义既是函数的“类型”描述又是函数的“值”!但是当我们进入到函数式编程语言的范畴之后,一些“实质性”的东西发生了变化,函数的地位被抬升到前所未有的高度,它变成了一类(first-class)的,变得和其他的数据类型在使用方式上完全一致了。一个典型的例子就是我们可以在函数式编程语言里像定义一个普通变量那样去定义一个函数(稍后我们会看到一个完整的示例),我们知道变量的定义都是由变量名,类型和值三部分组成,所以对于函数来讲,进行“类型”和“值”的切分是不可避免的。我们先不去考虑为什么一定要这样定义和使用函数,一个既成事实是,这种改变使得函数开始有“值”了,也就是所谓的“函数字面量”。

函数的“类型”和“值”:从何而来?

仅仅从概念上去理解“函数字面量”是比较困难的,如果我们能了解函数“类型”和“值”的产生方式,将有助于我们加深对这个概念的理解,同时也能更加准确地认识函数式编程里的“函数”。

让我们先来讨论一下什么是函数的“类型”以及如何去描述它。老实说,这个问题并不难解决,因为函数的类型“特征”显然体现在它的参数和返回值的类型上,如果我们把这些类型从函数定义中提取出来,然后按照函数签名的形式排列好,我相信大多数人是可以很容易地理解并接受这种描述方法的,而在Scala里,也确实是这样做的。让我们来看一个用Scala编写的传统形式的函数定义:

def plusOne(num:Int):Int = {
    num+1
}

这个函数将传入的Int参数加1之后返回,按照我们前面提到的类型提取原则,这个函数的“类型”可以这样描述:

(Int)=>Int

整体上我们要尽量保留函数原始的形态,所以小括号依然存在,表示参数列表(在Scala里,当只有一个参数时,小括号是可以省略的),其内部包括的是各个参数的类型,多个参数类型之间还是使用逗号分隔,向右的箭头作为参数列表和返回值之间的分割,箭头右侧就是返回值的类型。这个“类型”描述了这样“一类”函数(记住,是一类函数而不是一个函数,这很重要,后面我们会再次讨论这有什么差别):它们接受一个Int类型参数,返回一个Int类型的结果。这种描述方法看上去准确、形象,完全可以被接受,所以这正是Scala对函数类型的描述语法。

接下来的问题是如何描述函数的“值”。我们前面提到,传统的函数定义形式是函数“类型”和“值”的混合体,如果说我们按照前面提取函数类型的做法从函数定义中剔除类型声明的部分,那么剩下的自然就是关于“值”的部分了。依然是前面给出的示例函数,按照这个思路,它的“值”应该是这样一段代码:

(num)=>{
    num+1
}

我们在这段代码里唯一做的一点调整就是把函数定义中的参数列表和函数体之间的“=”换成了“=>”,是的,这是为了迎合Scala的语法要求,而这个形式也与前面的类型声明形式相呼应,所以函数“值”的描述方法可以就这样确定了。

最后,让我们把函数的“类型”和“值”联合起来,看看一个函数是如何像一个普通的变量那样被定义出来的:

val plusOne:Int=>Int={
    (num)=>num+1
}

这个例子中,我们定义了一个变量(确切地说是一个不允许二次赋值的变量(single assignment variable)):plusOne,“Int=>Int”是这个变量的类型,很显然它指代的是一类函数,等号右侧是这个变量的值,也就是一个函数字面量。变量plusOne完全等同于前面定义的函数plusOne,从某种角度上说,变量plusOne的这种函数存在形态才是函数式编程语言对函数最本质的认知。这样的存在形态印证了函数式编程将函数视作一类(first-class)的信条,因为它从定义到使用都和其他的类型与值没有任何区别了。

像传值一样传递函数:一场“裂变”式的能量释放

但是我们的故事还远远没有结束,如果说这样的分离仅仅让我们像使用其他数据类型一样使用函数,那我实在看不出如此大费周章的价值在哪里。如果我们像在非函数式编程语言里那样把使用到的函数预先定义好,在用到它的时候调用它,那我们完全可以依赖这种方式在函数式编程语言里“安稳度日”。在某个需要声明函数的地方声明一个函数类型,在调用的时候传一个对应的函数字面量,这样“曲折”的做法究竟好在哪里?看上去,必须要有一个强有力的证据来揭示一些我们还没有意识到的东西,而我相信,最好的例证莫过于“高阶函数”了。

“高阶函数”并不是一个高深的概念,一个接受其他函数作参数或着返回一个函数的函数就是高阶函数,我们来看一个简单的例子:

def hof(list:List[Int],f:(Int)=>Int):List[Int]={
    list match {
        case List() => Nil
        case head :: tail => f(head) :: hof(tail, f)
    }
}

hof是一个高阶函数,它接受一个List参数和一个函数参数,这个函数参数本身接受一个Int型参数并返回一个Int型的结果,hof的工作就是取出list中的每个元素交给f处理, 将处理后的结果放入一个新的List并返回。实际上你大概已经看出来了,这个hof其实是集合类型里自带的map函数的一个简化版本,但是我们不会在这里讨论map函数,我们只是想借这个简化的例子帮我们发现我们正在寻找的东西。回头看我们前面刚刚定义过的函数变量plusOne,它正是hof可以接受的那类函数,所以我们把它传给hof看一下:

scala> hof(List(1,2,3),plusOne)
res0: List[Int] = List(2, 3, 4)

一个新的List返回了,每一个元素都加了1。回到我们前面质疑的地方,如果我们不让hof接受一个函数参数,而是直接在函数实现里去调用plusOne这个函数,那么我们会得到一个非高阶函数的实现:nonHof,它会是这个样子:

def nonHof(list:List[Int]):List[Int]={
    list match {
        case List() => Nil
        case head :: tail => plusOne(head) :: nonHof(tail)
    }
}

OK,这也没有什么问题,返回的结果都是一样的,那么差别在哪里?hof之于nonHof到底有什么优势?我想我们的讨论已经无限接近这篇文章将要触及的最核心的部分了,那么,让我们再引入一个函数吧,关键的角色要登场了:

val double:Int=>Int={
    (num)=>num*2
}

新函数double,它把输入的Int参数乘以2并返回,看看它的参数类型“Int=>Int”,和plusOne是同一类型的,同时也是高阶函数hof接受的第二参数类型,这是否意味着double也可以传给hof呢?让我们试试看:

scala> hof(List(1,2,3),double)
res1: List[Int] = List(2, 4, 6)

是的,确实可以!对于没有接触过函数式编程的人来说这多少有些让人吃惊,我们没有对hof进行任何的改动,但它现在看起来却像“变了个人一样”,它有了新的“玩法”,它的处理逻辑发生了明显的变化,虽然整体的处理流程没有改变,也就是依次迭代每一个元素进行处理,但是在局部,它对每一个元素的处理方式已经彻底改变了。你有没有看到函数hof蕴含的巨大潜力?它规定了对一个集合的框架性处理流程,却不指明对个体元素的处理方法,而是改由调用方基于自身的需要在调用时动态传入。这意味着函数hof的每一次调用,不只是处理的数据可能不同,连处理的逻辑也会发生变化,甚至相同的数据也可以按不同的方法去处理。

反观函数nonHof,在同样的场景下,它具有和hof一样对局部处理逻辑进行“切换”和“插拔”的能力吗?显然不行!由于它在函数体内调用的是“一个”具体的函数实现,这就等同于把这个函数的实现代码以硬编码(hard code)的方式嵌入到了函数体内。从更高的抽象层次看,在一个函数里直接调用另一个已定义的函数实际上是完全“固化”了产生和处理这个中间(局部)值的逻辑和算法,这个中间(局部)值不可能再通过另外一套不同逻辑和算法来产生了,在这种情况下,开发者要么修改现有的nonHof,要么针对double需求提供另外一个版本的nonHof实现。

或许具有深厚OO背景且目光敏锐的程序员会立即反应过来,如果在OO语言里,这是一个典型的可以使用“模板方法+策略”模式进行重构的场景,最终一样可以实现对局部算法的动态“切换”和“插拔”,但是如果我们把hof和经过OO优化设计的方案相比较,你会发现后者显得太笨重了,而且针对每一种策略的实现也都是预定义的,这与在函数式编程语言里随手就可以写好的一个函数字面量比起来,灵活性和开发效率上都相差甚远。但是请不要误解,我们并不是在比较OO与函数式编程孰优孰劣,它们有着各自的优势和擅长领域,这是另外一个话题。在这里,我们只想通过hof和nonHof的对比来揭示函数式编程的一项重要特性以及由此带来的深远影响:

函数“类型”和“值”的分离,使得函数可以作为一个值(函数字面量)传递给其他的高阶函数,高阶函数的行为会因为传入函数的不同而表现地高度灵活和多变,也可以说具有了某种“动态”特性;另一方面,由于变化的部分都剥离给了外部传入的函数,高阶函数自身就变得高度可复用,所有这些都是函数式编程天然的优势,是在语言层面上直接支持的,你甚至不需要精心设计你的程序就可以轻易获取这些优势。

结语

“类型”归类型,“值”归值,“类型”与“值”的分离是函数跻身一等公民的必然结果,而这样的切分使函数可以按“类型”声明,按“值”传递,彻底解放了函数的使用方式,在高阶函数里,函数的这个特性被发挥到了极致,从而彻底改变了编程的思维和模式,造就了今天的函数式编程。

展开阅读全文

没有更多推荐了,返回首页