一、概述
函数式编程语言在移动游戏研发中使用十分普遍,例如客户端的Lua、JavaScript,服务端的Ruby、Erlang、JavaScript。但目前对移动游戏开发框架的设计大多是基于传统面向对象的方法,并没有很好地利用函数式编程语言的特性。针对项目代码模块的通信问题,通过借鉴面向对象(OOP)中的观察者模式以及现有的函数式编程语言的开源框架Node.js,设计并实现一种适用于函数式编程语言的事件驱动模型FPEDM(Functional Programming Event Driven Model)。该模型具有简单易用,扩展性强,与移动游戏软件耦合度低、复用性好的特点,应用到项目开发中可大大提高开发效率,简化项目框架的复杂度。
随着移动智能设备的配置不断提高以及移动互联网技术的快速发展,移动游戏的开发设计变得越来越重要。移动游戏的开发和传统的端游开发有着很大的区别,端游一般开发周期长,项目庞大复杂,引擎技术由于不开源而更新周期较长,开发语言一般为面向对象语言,如C++、Java等,而移动游戏则开发周期短,游戏版本迭代快,引擎技术开源使得引擎技术更新较快,开发语言趋向于函数式编程的动态语言,如Lua,JavaScript,Ruby等。目前在移动游戏开发框架设计领域的研究比较少,实际开发中很多也还是照搬了传统面向对象(OOP)的设计思想,没有很好地利用函数式编程语言的特性,使游戏项目变得庞大复杂,并不能很好地适应开发周期短、项目变更快的移动开发,同时编程语言本身的差异也使得在实现OOP设计思想上有困难。
二、函数式编程语言与常规编程语言的区别及其特点。
1、函数式编程经常使用递归。纯函数式的程序没有变量 和副作用(Side effect)。因为纯函数式程序设计语言没有变量,函数没有副作用,编写出的程序可以利用记忆化、公共子表达式消除和并发计算在运行时和编译时得到大量优化。我们常见的编程语言有数十种之多。编程语言种类有很多,如果按照程序设计的方法,可分为以下几种程序语言: (1)结构化编程语言,比如 C 语言等。 (2)函数式编程语言,比如 OCaml、Lisp 等。 (3)逻辑式编程语言,比如 Prolog 等。 (4)面向对象程序语言,比如 Java 等。
通过比较可以发现,函数式编程语言有以下几个特点:
(1)并行 。在函数式编程中,程序员无需对程序修改, 程序就可以并发运行。程序运行期间,不会产生死锁现象。 原因是通过函数式编程所得到的程序,在程序中不会出现 某一数据被同时修改两次及以上的情况,同样的,两个不 同的线程就更不用说了。由于函数式编程有这样的优点, 导致了程序员完全不用花费精力去考虑增加某个线程带来 的并发问题。 在函数编程语言中,编译器会分析代码,辨认出潜在 耗时的创建字符串 s1 和 s2 的函数,然后将他们并行的运 行。这样的做法,是程序员在使用普通的命令式程序语言 时不可能做到的。而使用函数式程序语言可以自动的找出 那些可以并发执行的函数。
(2)单元测试。在函数式编程中,由于程序中的每一 个符号都是 final 后的,所以这样的函数不会产生副作用。 这就导致了在某个地方产生修改,同时不会有函数修改过 在自身范围之外的变量或者状态被另外的函数所使用。这 就导致了函数的返回结果只是返回值。只有函数自身的参 数才会影响函数的返回值,所以在编程的时候,对程序中 的每个函数而言,程序员只需在控制它们的参数,而不用 在意函数自己点顺序以及函数外部变量和状态就能正确的 编程。与函数式编程相比,命令式编程就没有这样的优势 了,在检查函数的返回值的同时程序员还必须检查函数是 否影响到了函数的外部状态和变量。
(3) 没有额外作用。副作用是指的是函数内部与外部 互动。比如,函数在自身内部可以对函数以外的其他变量 进行修改,这样就会产生其他结果。 在函数式编程中,想要达到这样的目的就必须让函数 自身要保持独立。在函数式程序语言中,所有的功能的结 果就是一个返回值,不存在其他的行为,包括对外部变量 的修改。
(4)不修改状态。在函数式编程中,程序语言在使用 中是会不修改变量的,它的一个特性可以使得函数式编程
语言区别于其他的程序语言。在其他类型的语言中,变量是用来保存状态的。由于函数式编程不修改变量,导致了 这些状态不能存在于变量中。那么,函数式编程语言保存 状态的方法是使用参数来保存,递归方法是最好的例子。 由于采用了递归方法,函数式编程语言在运行速度上相对 于其他语言较慢,所以,速度不够快是函数式编程语言长期不能广泛使用的主要原因。
(5) 引用透明。在函数式编程中,引用透明指的是运 行函数的时候,函数的没一个步骤都不会不牵连到函数的 外部变量或状态,而是只依赖于函数输入的参数,相同的 参数输入总会得到相同的函数返回值。而在其他类型的语 言中,函数的返回值不仅仅与函数的参数传入有关,也与 当前的系统状态有关。在不同的系统状态的情况下,函数 的返回值不同。
(6)代码部署热。在以前,假如想在 Windows 上安装 更新,安装之后重启计算机是必须进行的步骤,可能还不 只一次的重启。即使是仅仅安装了一个小的软件也不能免 于重启的步骤。一些特殊的系统,比如电信系统,这样的 系统必须保证任何时间都在运行。因为如果在系统更新时 紧急拨号失效,就可能造成很大的损失。最理想的情况是 在完全不停止系统任何组件的情况下,达到更新相关的代 码的目的。这样的想法在命令式编程中是不可能的。 对函数式的程序,所有的状态即传递给函数的参数都 被保存在了堆栈上,这使的热部署轻而易举。实际上,所 有我们需要做的就是对工作中的代码和新版本的代码做一 个差异比较,然后部署新代码。其他的工作将由一个语言 工具自动完成。
三、函数式编程在JAVA程序中的应用
函数式编程作为当前最流行的编程规范之一,主流语言都对其进行了支持,JAVA语言也在最新的JDK8中新增了相关特性,这就是lambda(λ)表达式及Stream类。它使得JAVA语言进一步与当今流行趋势结合,增强了JAVA语言的表现力,拓展了它的应用范围,优化了程序的结构与可读性。
1、 JAVA中的lambda表达式
lambda(λ)表达式是JDK8最大的更新之一,旨在引入函数式编程思想优化JAVA程序。其表达形式如下:
(int even,int odd)->even+odd
在JDK8中,使用->符号引起表达式,该符号左边为表达式的参数,右边为表达式的行为。Lambda表达式可使用在多种场合,例如作为参数直接传入某个函数:
button.addActionListemer(event->System.out.println(“button clicked!!”));
其中button是一个AWT Button对象,由此我们可以看出,在传统的需要匿名内部类的地方可由lambda表达式代替,另外,传统的函数参数需要一个对象,而引入了lambda表达式之后,则可以将函数作为参数传入,从而在代码上更加简洁。
引入lambda表达式的优点首先体现着对代码的重构上,传统的JAVA程序有一个重要的概念即匿名内部类,这个类在某些只使用一次即销毁的情况下创建,例如常见的为按钮添加事件:
button.addActionListener(new ActionListener(){
public void actionPerformed(ActionEvent event){
System.out.println("button clicked");}
});
但是该代码当中有若干行是纯粹的样板代码,没有任何实际意义,不仅语法冗长,而且破坏了代码的真实意图,而采用lambda表达式改写后,该段代码的目的一目了然,如下所示:
button.addActionListener(event->System.out.println("button⁃clicked");
其次,lambda表达式配合jdk8新增的的Stream类可以提高程序——特别是循环结构——的执行效率,在JDK8之前,传统的循环结构采用的都是外循环结构,例如试图取得所有来自北京的教师
可以看到,传统方式中,集合内部的数据与外部的循环语句不停的进行交换,外部程序不得不占用一部分空间为结果集做准备,从时间上到空间上都造成了浪费。而经过lambda表达式和Stream改造,原有的外部循环成为内部循环,如下例所示:
long count=allArtists.stream().filter(artist->artist.isFrom("London")).count();
可以看出,使用lambda表达式后,内部循环只是在符合条件的集合个体中做出标识,不占用额外内存,当程序不发出最后的指令(如要求立即返回结果)时,内部循环不作出任何操作,称为lazy模式,这样就节省了时间。
2、用lambda表达式优化程序
初学者在使用lambda表达式时,可将其应用在集合操作中,优化其操作方式,lambda表达式与Stream类所支持的集合优化有map、filter、flatmap以及reduce等。下面将详细介绍这几种方式。
在这之前,首先定义一个领域模型,模仿现实世界中的某些业务需求,这个领域模型的结构如下:
作者Author,包含名称(String name)、所属机构(String orig⁃ine)和若干成员(String[]members)
著作 Book,包含名称(String name),若干章节(List chap⁃ters)和若干作者(List authors)
章节 Chapter,包含章节名称(String name)和字数(int chars)
作者集合authors,著作集合books和章节集合chapters。
首先来介绍Stream类中的第一种操作,即map操作,该操作负责将集合当中的元素进行符合条件的转换。例如,需要所有作者的所属机构列表,则使用lambda表达式和Stream代码如下:
List<String> origines=authors.Stream().map(author->author.
getOrigine()).collect(toList());
第二种常用操作是filter操作,旨在筛选出集合当中符合条件的元素,例如,需要找到所有成员数为1的作者(即该作者不是团队而是个人),代码如下
authors.Stream().filter(author->author.getMembers().length<2);
最后一种常用操作为reduce,该操作类似于数据库中的聚合函数,可对结果进行各种统计,如汇总、小计、总计等,例如要求计算所有所有作者全部著作的总字数,则代码可以如下:
chapters.Stream().map(chapter->chapter.getChars()).reduce(0,(base,acc)->base+acc);
以上介绍了函数式编程在java中的简单应用,作为java8中最重要的新特性,函数式编程极大的简化了代码的编写,使得java这一语言焕发了新的生命力,在未来的开发中,拥有面向对象及面向函数双重特征的java语言必定会发挥更大的作用。
四、函数式编程语言的使用意义总结
1、函数式编程语言的代码十分简单,加快了开发的速度。并且由于在使用函数式编程语言时,程序员会大量使用到函数,从而减少了重复的代码,因而程序比较短。
2、函数式编程语言更加接近我们使用的自然语言,程序员在学习和使用它的时候更加快捷容易。函数式编程语 言的自由度很高,十分接近自然语言写出的代码。另外, 函数式编程语言的代码管理更加方便。函数式编程不会对 外部产生依赖,也不会修改外界的状态。程序员只需把指 定的参数给函数,相同的参数其返回的结果必定是相同的。 另外,函数式编程语言还支持并发编程,这就使得程序员 在进行函数式编程时完全不用考虑死锁的问题,因为它根本就不修改变量,所以就不存在锁线程的问题。最后,函 数式编程语言的代码支持代码热升级。
3、单元测试和调试查错。通常理解的变量在函数式编程中也被函数代替了,变量仅仅代表某个表达式,那么在程序运行时就不能修改任何变量,也没有地方可以修改全局变量。这意味着函数执行结果的唯一因素就是它的返回值,而影响其返回值的唯一因素就是它的参数。在测试的时候只需要关注传递的参数就可以了,完全不用担心函数调用时的顺序,也不用费心设置外部状态值,唯一需要做的就是传递一些可以代表边界条件的参数。反观指令式编程,仅仅检查函数的返回值是不够的,代码可以修改外部状态值,因此还需要检查这些外部状态值的正确性。FP代码的调试工作也很简单,因为这些错误是可以百分之一百重现的,因为FP程序中的错误不依赖之前运行过的代码、而在指令式编程中,由于函数的运行依赖外部变量,这样可能会导致一个bug有时复现。另外,对指令式程序中函数返回值的检查并不能保证这个函数是正确运行的。还要逐一检查若干作用域以外的对象以确保这个函数没有对这些关联的对象做出什么行为。对于一个FP程序,你要做的仅仅是看一下函数的返回值。
4、 并发执行。不需要任何改动,所有FP程序都是可以并发执行的。由于根本不需要采用锁机制,因此完全不需要担心死锁或是并发竞争的发生。在FP程序中没有哪个线程可以修改任何数据,更不用说多线程之间了。既然是这样,为什么没有人在那些高度并行的那些应用程序中采用FP编程呢?事实上,这样的例子并不少见。爱立信开发了一种FP语言,名叫Erlang,并应用在他们的电信交换机上,而这些交换机不仅容错度高而且拓展性强。FP关于并行的优势不仅于此。就算某个FP程序本身只是单线程的,编译器也可以将其优化成可以在多CPU上运行的并发程序。
5、机器辅助优化及证明。FP语言有一个特性,那就是它们是可以用数学方法来分析的。FP语言本身就是形式系统的实现,只要是能在纸上写出来的数学运算就可以用这种语言表述出来。于是只要能够用数学方法证明两段代码是一致的,编译器就可以把某段代码解析成在数学上等同的但效率又更高的另外一段代码。关系数据库已经用这种方法进行优化很多年了,同理在常规的软件行业就不能应用这种技术。另外,还可以用这种方法来证明代码的正确性,甚至可以设计出能够自动分析代码并为单元测试自动生成边缘测试用例的工具。对于那些对缺陷零容忍的系统,如心脏起搏器、飞行管控系统来说,这一功能简直就是无价之宝,是必须满足的需求。
6、惰性求值。惰性求值(或是延迟求值)是一种有趣的技术,而当我们采用函数式编程后这种技术就有了得以实现的可能。先看如下代码:
var s1=somewhatLongOperation1();
var s2=somewhatLongOperation2();
var s3=concat(s1,s2);在指令式语言中以上代码执行的顺序:先计算somewhatLong Operation1,然后执行somewhatLongOperation2,最后执行concat。由于每个函数都有可能改动或者依赖于其外部的状态,因此必须顺序执行。函数式语言就不一样了,somewhatLongOperation1和somewhatLongOperation2是可以并发执行的,因为函数式语言保证了一点:没有函数会影响或者依赖于全局状态。可是万一我们不想要这两个函数并发执行呢?这种情况下是不是也还是要顺序执行这些函数?答案是否定的。只有到了执行需要s1、s2作为参数的函数的时候,才真正需要执行这两个函数。于是在concat这个函数没有执行之前,都没有需要去执行这两个函数:这些函数的执行可以一直推迟到concat()中需要用到s1和s2的时候。
惰性求值使得代码具备了巨大的优化潜能。支持惰性求值的编译器会像数学家看待代数表达式那样看待函数式程序:抵消相同项从而避免执行无谓的代码,安排代码执行顺序从而实现更高的执行效率甚至是减少错误。在此基础上优化是不会破坏代码正常运行的。严格使用形式系统的基本元素进行编程带来的最大的好处,是可以用数学方法分析处理代码,因为这样的程序是完全符合数学法则的。
惰性求值技术允许定义无穷数据结构,这要在严格语言中实现将非常复杂。例如一个储存Fibonacci数列数字的列表。很明显这样一个列表是无法在有限的时间内计算出这个无穷的数列并存储在内存中的。在像Java这样的严格语言中,可以定义一个Fibonacci函数,返回这个序列中的某个数。而在Haskell或是类似的语言中,可以把这个函数进一步抽象化并定义一个Fibonacci数列的无穷列表结构。由于语言本身支持惰性求值,这个列表中只有真正会被用到的数才会被计算出来。这让我们可以把很多问题抽象化,然后在更高的层面上解决它们(比如可以在一个列表处理函数中处理无穷多数据的列表)。
参考文献:
[1]叶俊,谭庆平,李暾.面向特征编程范式的形式化验证技术研究综述[J].计算机工程与科学,2010(9):89.
[2]杨路.不等式机器证明的降维算法与通用程序[J].高技术通讯, 1998(7):20.
[3]王明文,孙永强.对象式Lambda演算的自作用部分计值[J].软件学报,2001(8):1154.
[4]Paul Graham,阮一峰译,黑客与画家[M].人民邮电出版社,2011. 149.
[5]俞黎敏.函数式编程思想[J].程序员,2010,9.
[6] Node.js的模块机制.https://Node.js.org/api/modules.html.
[7]张迎周,张卫丰.Haskell:一种现代纯函数式语言[J].南京邮电大学学报:自然科学版,2007(4).
[8] 李向阳,连小绮.函数式程序范式在语义web中的应用[J].中国科技信息,2006,12(24):79,87.