近年来函数式编程这种概念渐渐流行起来,尤其是在 React/Vuejs 这两个前端框架的推动下,函数式编程就像股新思潮一般瞬间席卷整个技术圈。虽然博主接触到的前端技术并不算深入,可这并不妨碍我们通过类似概念的延伸来理解这种概念。首先,函数式编程是一种编程范式,而我们所熟悉的常见编程范式则有命令式编程(Imperative Programmming)、函数式编程(Functional Programming)、逻辑式编程(Logic Programming)、**声明式编程(Declarative Programming)和响应式编程(Reactive Programming)**等。现代编程语言 在发展过程中实际上都在借鉴不同的编程范式,比如 Lisp 和 Haskell 是最经典的函数式编程语言,而 SmartTalk、C++和 Java 则是最经典的命令式编程语言。微软的 C#语言最早主要借鉴 Java 语言,在其引入 lambda 和 LINQ 特性以后,使得 C#开始具备实施函数式编程的基础,而最新的 Java8 同样开始强化 lambda 这一特性,为什么 lambda 会如此重要呢?这或许要从函数式编程的基本术语开始说起。
什么是函数式编程
我们提到函数式编程是一种编程范式,它的基本思想是将计算机运算当作是数学中的函数,同时避免了状态和变量的概念。一个直观的理解是,在函数式编程中面向数据,函数是第一等公民,而我们传统的命令式编程中面向过程,类是第一等公民。为什么我们反复提到 lambda 呢?因为函数式编程中最重要的基础是 lambda 演算(Lambda Calculus),并且 lambda 演算的函数可以接受函数作为参数和返回值,这听起来和数学有关,的确函数式编程是面向数学的抽象,任何计算机运算在这里都被抽象为表达式求值,简而言之,函数式程序即为一个表达式。值得一提的是,函数式编程是图灵完备的,这再次说明数学和计算机技术是紧密联系在一起的。虽然在博主心目中认为,图灵这位天纵英才的英国数学家,是真正的计算机鼻祖,但历史从来都喜欢开玩笑的,因为现代计算机是以冯.诺依曼体系为基础的,而这一体系天生就是面向过程即命令式的,在这套体系下计算机的运算实则是硬件的一种抽象,命令式程序实际上是一组指令集。因此,函数式程序目前依然需要编译为该体系下的计算机指令来执行,这听起来略显遗憾,可这对我们来说并不重要,下面让我们来一窥函数式编程的真容:
squares = map(lambda x: x * x, [0, 1, 2, 3, 4])
print squares
这是使用 Python 编写的函数式编程风格的代码,或许看到这样的代码,我们内心是完全崩溃的,可是它实现得其实是这样一个功能,即将集合{0, 1, 2, 3, 4}中的每个元素进行平方操作,然后返回一个新的集合。如果使用命令式编程,我们注定无法使用如此简单的代码实现这个功能。而这个功能在.NET 中其实是一个 Select 的功能:
int[] array = new int[]{0, 1, 2, 3, 4};
int[] result = array.Select(m => m * m).ToArray();
这就是函数式编程的魅力,我们所做的事情都是由一个个函数来完成的,这个函数定义了输入和输出,而我们只需要将数据作为参数传递给函数,函数会返回我们期望的结果。好了,下面再看一个例子:
sum = reduce(lambda a, x: a + x, [0, 1, 2, 3, 4])
print sum
即使我们从来没有了解过函数式编程,从命名我们依然可以看出这是一个对集合中的元素求和的功能实现,这就是规范命名的重要性。幸运的是.NET 中同样有类似的扩展方法,我喜欢 Linq,我喜欢 lambda:
int[] array = new int[]{0, 1, 2, 3, 4};
int result = array.Sum();
考虑到博主写不出更复杂的函数式编程的代码示例,这里不再列举更多的函数式编程风格的代码,可是我们从直观上来理解函数式编程,就会发现函数式编程同 lambda 密不可分,函数在这里扮演着重要的角色。好了,下面我们来了解下函数式编程中的常用术语。
函数式编程的常用术语
函数式编程首先是一种编程范式,这意味着它和面向对象编程一样,都是一种编程的思想。而函数式编程最基本的两个特性就是不可变数据和表达式求值。基于两个基础特性,我们延伸出了各种函数式编程的相关概念,而这些概念就是函数式编程的常用术语。常用的函数式编程术语有高阶函数、柯里化/局部调用、惰性求值,递归等。在了解这些概念前,我们先来理解,什么是函数式编程的不可变性。不可变性,意味着在函数式编程中没有变量的概念,即操作不会改变原有的值而是修改新产生的值。举一个基本的例子,.NET 中 IEnumerable接口提供了大量的如 Select、Where 等扩展方法,而这些扩展方法同样会返回 IEnumerable类型,并且这些扩展方法不会改变原来的集合,所有的修改都是作用在一个新的集合上,这就是函数式编程的不可变性。实现不可变性的前提是纯函数,即函数不会产生副作用。一个更为生动的例子是,如果我们尝试对一个由匿名类型组成的集合进行修改,会被提示该匿名类型的属性为只读属性,这意味着数据是不可改变的,如果我们要坚持对数据进行“修改”,唯一的方法就是调用一个函数。
高阶函数(Higer-Order-Function)
高阶函数是指函数自身能够接受函数,并返回函数的一种函数。这个概念听起来好像非常复杂的样子,其实在我们使用 Linq 的时候,我们就是在使用高阶函数啦。这里介绍三个非常有名的高阶函数,即 Map、Filter 和 Fold,这三个函数在 Linq 中分别对应于 Select、Where 和 Sum。我们可以通过下面的例子来理解:
- Map 函数需要一个元素集合和一个访问该元素集合中每一个元素的函数,该函数将生成一个新的元素集合,并返回这个新的元素集合。通过 C#中的迭代器可以惰性实现 Map 函数:
IEnumerable<R> Map<T,R>(Func<T,R> func, IEnumerable<T> list)
{
foreach(T item in list)
yield return func(item);
}
- Filter 函数需要一个元素集合和一个筛选该元素结合的函数,该函数将从原始元素集合中筛选中符合条件的元素,然后组成一个新的元素集合,并返回这个新的元素集合。通过 C#中的 Predicate委托类型,我们可以写出下面的代码:
IEnumerable<T> Filter<T>(Predicate<T> predicate, IEnumerable<T> list)
{
foreach(T item in list)
{
if(predicate(item))
yield return item;
}
}
- Fold 函数实际上代表了一系列函数,而最重要的两个例子是左折叠和右折叠,这里我们选择相对简单地左折叠来实现累加的功能,它需要一个元素集合,一个累加函数和一个初始值,我们一起来看下面的代码实现:
R Fold<T,R>(Func<R,T,R> func, IEnumerable<T> list, R startValue = default(R))
{
R result = startValue;
foreach(T item in list)
result = func(result, item);
return result;
}
相信现在大家应该理解什么是高阶函数了,这种听起来非常数学的名词,当我们尝试用代码来描述的时候会发现非常简单。相信大家都经历过学生时代,临近期末考试的时候死记硬背名词解释的情形,其实可以用简洁的东西描述清楚的概念,为什么需要用这种方式来理解呢?为什么我这里选择了 C#中的委托来编写这些示例代码呢?自然是同样的道理啦,因为我们都知道,在 C#中委托是一种类似函数指针的概念,因为当我们需要传入和返回一个函数的时候,选择委托这种特殊的类型可谓是恰如其分啦,这样并不会影响我们去理解高阶函数。
柯里化(Curring)/局部套用
柯里化(Curring)得名于数学家 Haskell Curry,你的确没有看错,这位伟大的数学家不仅创造了 Haskell 这门函数式编程语言,而且提出了局部套用(Currin)这种概念。所谓局部套用,就是指不管函数中有多少个参数,都可以函数视为函数类的成员,而这些函数只有一个形参,局部套用和部分应用息息相关,尤其是部分应用是保证函数模块化的两个重要技术之一(部分应用和组合**(Composition)**是保证函数模块化的两个重要技术)。众所周知,在 C#中一个函数一旦完成定义,那么它的参数列表就是确定的,即相对静态。它不能像 Python 和 Lua 一样去动态改变参数列表,虽然我们可以通过缺省参数来减少参数的个数,可是在大多数情况下,我们都需要在调用函数前准备好所有参数,而局部套用所做的事情与这个理念截然相反,它的目标是用非完全的参数列表去调用函数。我们来一起看下面这个例子:
Func<int,int,int> add = (x,y) => {return x + y;};
这是一个由匿名方法定义的委托类型,显然我们需要在调用这个方法前准备好两个参数 x 和 y,这意味着 C#不允许我们在改变参数列表的情况下调用这个方法。而通过局部套用:
Func<int,int,int> curriedAdd => (x) =>
{
return (y) => { return x + y;};
};
实际上在这里两个参数 x 和 y 的顺序对最终结果没有任何影响,我们这样写仅仅是为了符合人类正常的认知习惯,而此时我们注意到我们在调用 curriedAdd 时会发生质的的变化:
//x和y同时被传入add
add(x,y)
//x和y可以不同时被传入curriedAdd
curriedAdd(x)(y);
而如果我们将这里的函数用 Lambda 表达式来表示,则会发现:
Func<int,int,int> add = (x,y) => return x + y;
Func<int,Fucn<int,int>> curriedAdd = x = > y => x + y;
至此,对一般的局部套用,存在:
Func<...> f = (part1, part2, part3, ...) => ... 可转换为:
Func<...> cf = part1 => part2 => part3 ... => ...
则称后者为前者的局部套用形式。
惰性求值
我们在前文中曾经提到过,在函数式编程中函数是第一等公民,而这里的函数更接近数学意义上的函数,即将函数视为一个可以对表达式求值的纯函数,所以我们这里自然而然地就提到了惰性求值。首先,博主这里想说说求值策略这个问题,求值策略通常有严格求值和非严格求值两种,而对 C#语言来讲,它在大多数情况下使用严格求值策略,即参数在传递给函数前求值。与之相对应的,我们将参数在传递给函数前不进行求值或者延迟求值的这种情况,称为非严格求值策略。一个经典的例子是 C#中的“短路”效应:
bool isTrue = (10 < 5) && (MyCheck())
因为在这里表达式的第一部分返回值为 false,因此在实际调用中第二部分根本不会执行,因为无论第二部分返回 true 还是 false,实际上对整个表达式的结果都不会产生影响。这是一个非常经典的非严格求值的例子,同样的,布尔运算中的"||“运算符,同样存在这个问题。所以,至此我们可以领会到惰性求值的优点,即使程序的执行效率更好,尤其是在避免高昂运算代价的时候,我们要牢记:懒惰是程序员的一种美德,使用更简洁的代码来满足需求,是一名游戏程序员的永恒追求。我们可以联想那些在代码片段中优先 return 的场景,这大概勉强可以用这种理论来解释吧!例如我们强大的 Linq,原谅我如此执著于举 Linq 的例子,Linq 的一个特点是当数据需要被使用的时候开始计算,即数据是延迟加载的,而在此之前我们所有对数据的操作,从某种意义上来讲,更像是定义了一系列函数,这好像和数据库中的事务非常相近啦,其实这就是在告诉我们,懒惰是一种美德啊,哈哈!
函数式编程的利弊探讨
好了,现在让我们从函数式编程的各种术语中解放出来,高屋建瓴般地从更高的层面上探讨下函数式编程的利弊。当你讨论一种东西的利弊时,一种习惯性的做法是找一种东西来和它作比较,如果 Windows 和 Linux、SQL 和 NoSQ、面向对象和函数式…等等,我们常常关注一件事物的利弊,而非去寻找哪一个是最好。可惜自以为是的人类,常常以此来自我设限,划分各自的阵营,这当真是件无聊的事情,就像我一直不喜欢 SQL 和正则表达式,所以我就去了解数据库的设计、模式匹配相关内容,最终感觉颇有一番收获,我想这是我们真正的目的吧!好了,下面我们说说函数式编程有哪些优缺点?首先,函数式编程极大地改善了程序的模块化程度,高阶函数、递归和惰性求值让程序充分函数化,函数式让编程可以以一种声明式的风格来增强程序语义。当然,函数式编程的缺点是,我们这个现实世界本来就不是纯粹的,函数式编程强调的数据不可变性,意味着我们无法去模拟事物状态变化,因此我们不能为了追求无副作用、无锁而忽视现实,这个世界上总有些肮脏的问题,无法让我们用纯函数的思维去解决,这个时候我们不能说要让设计去适应这个世界,任何技术或者框架的诞生归根到底是为了解决问题,而函数式编程或者是面向对象编程,本质都是一种编程思想,我们最终是为了解决问题,就像这个世界有时候并不是面向对象的,我们用面向对象来描述这个世界,或许仅仅是我们自己的理解,这个世界到底是什么样子的,大概只有上帝会知道吧!
本文小结
本文主要对函数式编程及其常见术语进行了简要讨论,主要根据《C#函数式程序设计》一书整理并辅以博主的理解而成。首先,函数式编程中强调无状态、不可变性,认为函数是一等公民,并且在函数式编程中每一个函数都是一个纯函数,它是数学概念咋计算机领域的一种延伸,和冯.诺依曼计算机体系不同,函数式编程的核心思想是以 lambda 演算为基础的表达式求值,并且函数式编程强调无副作用。本文对函数式编程中的常见术语如高阶函数、局部套用/柯里化、惰性求值等结合 C#语言进行了简单分析。或许对我们而言,函数式编程是一个新鲜事物,可正如我们第一次接触面向对象编程时一样,我们并不知道这样一种编程思想会持续到今天。我不认为函数式编程会彻底替代面向对象编程,就像 Web 开发无法彻底替换原生开发一样,函数式编程会作为面向对象的一种延伸和补充,所以本文对函数式编程的理解实际上是非常肤浅的,可这个世界本来就是在不断变化的,希望我们可以在恰当的场景下去权衡选择什么样的技术,对这个世界而言,我们永远都是探索者,或许永远都不存在完全能满足现实场景的编程范式吧!