2019独角兽企业重金招聘Python工程师标准>>>
引子
JDK8中引入了lambda函数式编程的概念,那么什么是函数式编程,函数式编程又有什么好处,今天我们就来说说函数式编程
我们先了解一下函数式编程的由来 一个名叫阿隆佐·邱奇的数学家设计了一个名为lambda演算的系统。这个系统实质上是为其中一个超级机器设计的编程语言。在这种语言里面,函数的参数是函数,返回值也是函数。这种函数用希腊字母lambda(λ)表示,这种系统因此得名
? 演算之所以这么重要,用 Benjamin C. Pierce 的话说在于它具有某种 “二象性”:它既可以被看作 一种简单的程序设计语言,用于描述计算过程,也可以被看作一个数学对象,用于推导证明一些命题
除此之外,艾伦·图灵也在进行类似的研究。他设计了一种完全不同的系统(后来被称为图灵机),并用这种系统得出了和阿隆佐相似的答案。到了后来人们证明了图灵机和lambda演算的能力是一样的。
函数式编程是阿隆佐思想的在现实世界中的实现。不过不是全部的lambda演算思想都可以运用到实际中,因lambda演算在设计的时候就不是为了在各种现实世界中的限制下工作的。所以,就像面向对象的编程思想一样,函数式编程只是一系列想法,而不是一套严苛的规定。有很多支持函数式编程的程序语言,它们之间的具体设计都不完全一样。
特点
下面我们来看看纯粹的函数式编程都有哪些特点
函数是"第一等公民"
所谓第一等公民,指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。
声明式与命令式编程
函数式的特点就是用声明来表示程序, 用声明的组合来表达程序的组合. 而非函数式编程则习惯于用命令来表示程序, 用命令的顺序执行来表达程序的组合
声明式编程与命令式编程最大的不同其实在于: 声明式编程关心数据的映射,命令式编程关心解决问题的步骤
- 命令式编程:命令“机器”如何去做事情(how),这样不管你想要的是什么(what),它都会按照你的命令实现。
- 声明式编程:告诉“机器”你想要的是什么(what),让机器想出如何去做(how)。
所以声明式比命令式更容易理解
事例
SQL 最大的特点就是只声明我想要什么(What) , 就是不说怎么做(How)。 这个怎么做的部分是由数据库管理系统来完成的, 当然具体的细节自然需要用命令式的编程风格来干活了:
像下面的语句
SELECT * from dogs
INNER JOIN ownersWHERE dogs.owner_id = owners.id
如果我们要用命令式编程来实现
var dogsWithOwners = []
var dog, ownerfor(var di=0; di < dogs.length; di++) {dog = dogs[di]for(var oi=0; oi < owners.length; oi++) {owner = owners[oi]if (owner && dog.owner_id == owner.id) {dogsWithOwners.push({dog: dog,owner: owner})}}}
}
在很多情况中,命令式编程很好用。当我们写业务逻辑,我们通常必须要写命令式代码,没有可能在我们的专项业务里也存在一个可以归纳抽离的实现。 但是,如果我们花时间去学习(或发现)声明式的可以归纳抽离的部分,它们能为我们的编程带来巨大的便捷。首先,我可以少写代码,这就是通往成功的捷径。而且它们能让我们站在更高的层面是思考,站在云端思考我们想要的是什么,而不是站在泥里思考事情该如何去做。
一般来说计算更容易归纳和抽离,所以函数式编程在实践使用中更适合数据计算聚合等操作,比如Java8的Stream.
不可变
在其他类型的语言中,变量往往用来保存"状态"。 不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。下面的代码是一个将字符串逆序排列的函数,它演示了不同的参数如何决定了运算所处的"状态"。
String reverse(String string) {if(string.length() == 0) {return string;} else {return reverse(string.substring(1, string.length())) + string.substring(0, 1);}}
这种方式运行起来会相对慢一些,因为它重复调用自己。同时它也会大量的消耗内存,因为它会不断的分配创建内存对象
无副作用
由不可变这个特点带来的另一个特点就是无副作用 所谓副作用,指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量)或修改参数
例如
如果赋值语句修改了内存原有的值,就会产生副作用。i++,这个就是修改了内存中i的值。 如果赋值语句不在内存原有值的基础上进行修改,而是重新申请一块内存保存运算结果,避免了对内存原有值的修改就是没有副作用
因为的每个符号都是final的,于是没有什么函数会有副作用。谁也不能在运行时修改任何东西,也没有函数可以修改在它的作用域之外修改什么值给其他函数继续使用(在命令式编程中可以用类成员或是全局变量做到)。这意味着决定函数执行结果的唯一因素就是它的返回值,而影响其返回值的唯一因素就是它的参数。
引用透明
引用透明的概念与函数的副作用相关,且受其影响, 引用透明,指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。 其他类型的语言,函数的返回值往往与系统状态有关,不同的状态之下,返回值是不一样的。 这就叫"引用不透明",很不利于观察和理解程序的行为。 纯函数式语言没有变量,所以它们都具有引用透明性
只用"表达式",不用"语句"
函数式编程要求,只使用表达式,不使用语句
表达式是简单的、可以计算并产生结果的代码块,因此,方法调用、任何布尔值的使用,或者整数运算,都是表达式; 而语句是能够影响程序的状态,但没有任何结果的代码块。 不返回任何值的方法调用就是语句,因为它会影响程序的状态,而不管方法做了什么;赋值也会更改状态。
程序1:
int a, b, r;
void add_abs() {r = abs(a) + abs(b)print(r);
}
程序2:
int add_abs(int a, int b) {return abs(a) + abs(b);
}
比如程序1就是一个语句,因为它会通过赋值操作影响程序的状态,并且没有返回值
而程序2就是一个表达式,通过函数组合来返回一个值,是常见的函数式写法
高阶函数
对于Java这样的命令式语言来说,如果所有的变量都是必须是final的,那么确实很束手束脚。然而对函数式语言来说,情况就不一样了。函数式语言提供了一种特别的抽象工具,这种工具将帮助使用者编写函数式代码,让他们甚至都没想到要修改变量的值。高阶函数就是这种工具之一
我们把可以接收、创建或返回函数的函数或方法被视为高阶函数
你写了一大堆程序而不考虑什么类结构设计,然后发现有一部分代码重复了几次,于是你就会把这部分代码独立出来作为一个函数以便多次调用。如果你发现这个函数里有一部分逻辑需要在不同的情况下实现不同的行为,那么你可以把这部分逻辑独立出来作为一个高阶函数
例子
假设有一段Java的客户端程序用来接收消息,用各种方式对消息做转换,然后发给一个服务器。
class MessageHandler {void handleMessage(Message msg) {msg.setClientCode("ABCD_123");sendMessage(msg);}}
再进一步假设,整个系统改变了:额外的那个服务器需要用另外一种格式发送消息。应该如何处理这种情况呢?我们可以先检查一下消息要发送到哪里,然后选择相应的格式把这个消息发出去:
class MessageHandler {void handleMessage(Message msg) {if(msg.getDestination().equals("server1") {msg.setClientCode("ABCD_123");} else {msg.setClientCode("123_ABC");}sendMessage(msg);}
}
可是这样的实现是不具备扩展性的。如果将来需要增加更多的服务器,上面函数的大小将呈线性增长。面向对象的编程方法告诉我们,可以把MessageHandler变成一个基类,然后将针对不同格式的消息编写相应的子类。
abstract class MessageHandler {void handleMessage(Message msg) {msg.setClientCode(getClientCode());sendMessage(msg);}abstract String getClientCode();
}class MessageHandlerOne extends MessageHandler {String getClientCode() {return "ABCD_123";}
}class MessageHandlerTwo extends MessageHandler {String getClientCode() {return "123_ABCD";}
}
这样一来就可以为每一个接收消息的服务器生成一个相应的类对象,添加服务器就变得更加容易维护了。可是,这一个简单的改动引出了很多的代码。仅仅是为了支持不同的客户端行为代码,就要定义两种新的类型!
如果Java支持高阶函数那么事件就变的简单代码可能像这样:
class MessageHandler {void handleMessage(Message msg, Function getClientCode) {Message msg1 = msg.setClientCode(getClientCode());sendMessage(msg1);}
}String getClientCodeOne() {return "ABCD_123";
}String getClientCodeTwo() {return "123_ABCD";
}MessageHandler handler = new MessageHandler();
handler.handleMessage(someMsg, getClientCodeOne);
上面的例子中我们没有创建任何新的类型或是多层类的结构。仅仅是把相应的函数作为参数进行传递,就做到了和用面向对象编程一样的事情,而且还有额外的好处:一是不再受限于多层类的结构,这样做可以做运行时传递新的函数,可以在任何时候改变这些函数,而且这些改变不仅更加精准而且触碰的代码更少。这种情况下编译器其实就是在替我们编写面向对象的“粘合”代码
加里化
int pow(int i, int j);int square(int i)
{return pow(i, 2);
}
上面的代码中square函数计算一个整数的平方,这个函数的接口被转换成计算一个整数的任意整数次幂。这种简单的技术就被叫做加里化 加里化常常用于转化一个函数的接口以便于其他代码调用。函数的接口就是它的参数,于是加里化通常用于减少函数参数的数量。 函数式语言生来就支持这一技术,于是没有必要为某个函数手工创建另外一个函数去包装并转换它的接口,这些函数式语言已经为你做好了
square = int pow(int i, 2);
在函数式语言中加里化就这么简单:一种可以快速且简单的实现函数封装的捷径。我们可以更专注于自己的设计,编译器则会为你编写正确的代码.
惰性求值
惰性求值是一种有趣的技术,而当我们使用函数式编程的后这种技术就有了得以实现的可能。
String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);
在指令式语言中以上代码执行的顺序是显而易见的。由于每个函数都有可能改动或者依赖于其外部的状态,因此必须顺序执行。先是计算somewhatLongOperation1,然后到somewhatLongOperation2,最后执行concatenate。函数式语言就不一样了。 somewhatLongOperation1和somewhatLongOperation2是可以并发执行的,因为函数式语言保证了一点:没有函数会影响或者依赖于全局状态。可是万一我们不想要这两个函数并发执行呢?这种情况下是不是也还是要顺序执行这些函数?答案是否定的。只有到了执行需要s1、s2作为参数的函数的时候,才真正需要执行这两个函数。于是在concatenate这个函数没有执行之前,都没有需要去执行这两个函数:这些函数的执行可以一直推迟到concatenate()中需要用到s1和s2的时候。假如把concatenate换成另外一个函数,这个函数中有条件判断语句而且实际上只会需要两个参数中的其中一个,那么就完全没有必要执行计算另外一个参数的函数了
无穷数据结构
惰性求值技术允许定义无穷数据结构,这要在严格语言中实现将非常复杂 很明显一个列表是无法在有限的时间内计算出这个无穷的数列并存储在内存中的,但是有了惰性求值就可以在真正使用的时候去计算,这样就提供了一个抽象在更高的层次去解决问题
函数式的优点
那么根据函数式编程的几个特点我们可以看出函数式编程具有下面几个优点
代码简洁,开发快速,易于理解 函数式编程大量使用函数,减少了代码的重复,因此程序比较短,开发速度较快 并且声明式的函数调用更接近自然语言的代码,更容易理解。
更利用调试 函数式程序中的错误不依赖于之前运行过的不相关的代码。而在一个命令式程序中,一个bug可能有时能重现而有些时候又不能。因为这些函数的运行依赖于某些外部状态, 而这些外部状态又需要由某些与这个bug完全不相关的代码通过某个特别的执行流程才能修改。在函数式编程中这种情况完全不存在:如果一个函数的返回值出错了,它一直都会出错,无论你之前运行了什么代码。 因此,每一个函数都可以被看做独立单元,很有利于进行单元测试和debug,以及模块化组合
易于"并发编程" 函数式编程不需要考虑线程安全,因为它不修改变量,所以根本不存在锁线程的问题。 不必担心一个线程的数据,被另一个线程修改,这样就不用关心复杂的同步问题。
面向对象与函数式
面向对象式编程因为引入了类、对象、实例等概念,非常贴合人类对于世间万物的认知方式和思考方式。对于复杂的事物,人类是如何去认识、归纳、总结的?面向对象式编程就是在努力回答这个问题,而答案的核心就是两个字:抽象。所以面向对象式编程特别适合处理业务逻辑,因此被广泛应用于目前的软件开发当中。因为我们开发软件就是为了解决问题,面向对象式编程符合人类对于“问题”的认知方式。
在处理业务逻辑的高度抽象层面,面向对象式编程已经非常符合我们的需要了,但是当进入具体运算的时候,在考虑更低一层的代码实现的时候,我们仍然依赖于数据(比如使用变量存储)和状态(比如全局作用域)的计算处理,这就导致了粒度更细的处理还是复杂
函数式编程最大的特点之一就是摒弃了数据与状态的计算模型,同时也就避免了诸如作用域等细节给我们带来的副作用,函数这个概念并不难理解,我们经常把对于数据和状态的处理封装为函数去隐藏其中的复杂;而函数式编程范式就是把函数提升为基本的编程要素,从语言设计的层面上着眼于函数本身,消除对数据和状态进行处理的过程。
面向对象和函数式哪个更好呐?其实两个都有问题
面向对象的问题在于它对“对象”的定义,它试图将所有事情就纳入到这个概念里。这种做法极端化后,你就得出来一个一切皆为对象思想。但这种思想是错误的,
因为有些东西不是对象,函数就不是对象。 大多数的面向对象语言里都缺乏正确的实现一等函数的机制。Java语言是一个极致,它完全不允许将函数当作数据来传递。你可以将全部的函数都封装进对象,然后称它们为方法,但这是绑架。缺乏一等函数是为什么Java里需要这么多设计模式的主要原因。一旦有了一等函数,你将不再需要大部分的这些设计模式。
相似的,函数式编程走向极端、成为一种纯函数式编程语言后,也是有问题的,纯函数式编程语言也并不合适, 因为副作用是真实存在的,要实现完全的无副作用,就显得相当困难, 其次在工程上想要大规模使用函数式编程仍然有很多待解决的问题,尤其是对于规模比较大的工程而言。如果对函数式编程的理解不够深刻就会导致跟面相对象一样晦涩难懂的局面。
综上所述那么组合使用可能是最好的办法,在拥有复杂的业务模型领域是使用面向对象最合适的地方,用函数式用来编写复杂的计算。
JDK8 中的函数式支持
首先Java 本身是基于对象的并没有函数这样的基本类型
Java 8也没有创建新的类型,而是通过编译器将Lambda表达式自动转换成一个类的实例,这个类由类型推断来决定, lambda的作用跟匿名类没有本质区别。
所以Java 8的Lambda表达式没有神奇地转变成函数式语言,自然对纯函数式语言引用透明,无副作用等特点支持不佳 有的是更好的语法支持Lambda表达式。 值得关注的是Collection类库得到了增强,允许Java开发人员采用更简单的函数式风格来简化代码。
Java 8引入一些新的类来表示函数的基本构造块,如java.util.function中的Predicate、Function和Consumer接口。这些新增的功能使Java 8能够“稍微函数式编程”,但Java为了兼容旧版本实现上也是使用库的方式而不是语言特性,它离纯粹的函数式语言还十分遥远