栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

Java 8 函数式编程的技巧

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Java 8 函数式编程的技巧

本篇内容

  • 一等成员、高阶方法、科里化以及局部应用
  • 持久化数据结构
  • 生成Java Stream 时的延迟计算和延迟列表
  • 模式匹配以及如何在Java中应用
  • 应用透明性和缓存
1.无处不在的函数

   在前一篇中我们使用术语“函数式编程”意指函数或者方法的行为应该像“数学函数”一样——没有任何副作用。对于使用函数式语言的程序员而言,这个术语的范畴更加宽泛,它还意味着函数可以像任何其他值一样随意使用:可以作为参数传递,可以作为返回值,还能存储在数据结构中。能够像普通变量一样使用的函数称为一等函数(first-class function)。这是Java 8补充的全新内容:通过::操作符,你可以创建一个方法引用,像使用函数值一样使用方法,也能使用Lambda表达式(比如,(int x) -> x + 1)直接表示方法的值。Java 8中使用下面这样的方法引用将一个方法引用保存到一个变量是合理合法的:

Function strToInt = Integer::parseInt;
1.1 高阶函数

   目前为止,我们使用函数值属于一等这个事实只是为了将它们传递给Java 8的流处理操作,达到行为参数化的效果,Apple::isGreenApple作为参数值传递给filterApples方法那样。但这仅仅是个开始。另一个有趣的例子是静态方法Comparator.comparing的使用,它接受一个函数作为参数同时返回另一个函数(一个比较器),代码如下所示。

Comparator c = comparing(Apple::getWeight);

   函数式编程的世界里,如果函数,比如Comparator.comparing,能满足下面任一要求就可以被称为高阶函数(higher-order function):

  • 接受至少一个函数作为参数
  • 返回的结果是一个函数

   这些都和Java 8直接相关。因为Java 8中,函数不仅可以作为参数传递,还可以作为结果返回,能赋值给本地变量,也可以插入到某个数据结构。比如,一个计算口袋的程序可能有这样的一个Map>,它将字符串sin映射到方法Function,实现对Math::sin的方法引用。

副作用和高阶函数
   在前面我们了解到传递给流操作的函数应该是无副作用的,否则会发生各种各样的问题(比如错误的结果,有时由于竞争条件甚至会产生我们无法预期的结果)。这一原则在你使用高阶函数时也同样适用。编写高阶函数或者方法时,你无法预知会接收什么样的参数——一旦传入的参数有某些副作用,我们将会一筹莫展!如果作为参数传入的函数可能对你程序的状态产生某些无法预期的改变,一旦发生问题,你将很难理解程序中发生了什么;它们甚至会用某种难于调试的方式调用你的代码。因此,将所有你愿意接收的作为参数的函数可能带来的副作用以文档的方式记录下来是一个不错的设计原则,最理想的情况下你接收的函数参数应该没有任何副作用!

1.2 科里化

   它是一种可以帮助你模块化函数、提高代码重用性的技术。

科里化的理论定义

   科里化是一种将具备2个参数(比如,x和y)的函数f转化为使用一个参数的函数g,并且这个函数的返回值也是一个函数,它会作为新函数的一个参数。后者的返回值和初始函数的返回值相同,即f(x,y) = (g(x))(y)。
   当然,我们可以由此推出:你可以将一个使用了6个参数的函数科里化成一个接受第2、4、 6号参数,并返回一个接受5号参数的函数,这个函数又返回一个接受剩下的第1号和第3号参数的函数。
   一个函数使用所有参数仅有部分被传递时,通常我们说这个函数是部分应用的(partially applied)。

2.持久化数据结构

   这一节中,我们会探讨函数式编程中如何使用数据结构。这一主题有各种名称,比如函数式数据结构、不可变数据结构,不过最常见的可能还要算持久化数据结构(不幸的是,这一术语和数据库中的持久化概念有一定的冲突,数据库中它代表的是“生命周期比程序的执行周期更长的数据”)。
   我们应该注意的第一件事是,函数式方法不允许修改任何全局数据结构或者任何作为参数传入的参数。为什么呢?因为一旦对这些数据进行修改,两次相同的调用就很可能产生不同的结构——这违背了引用透明性原则,我们也就无法将方法简单地看作由参数到结果的映射。

2.1 破坏式更新和函数式更新的比较

   让我们看看不这么做会导致怎样的结果。假设你需要使用一个可变类TrainJourney(利用一个简单的单向链接列表实现)表示从A地到B地的火车旅行,你使用了一个整型字段对旅程的一些细节进行建模,比如当前路途段的价格。旅途中你需要换乘火车,所以需要使用几个由onward字段串联在一起的TrainJourney对象;直达火车或者旅途最后一段对象的onward字段为null:

class TrainJourney { 
 public int price; 
 public TrainJourney onward; 
 public TrainJourney(int p, TrainJourney t) { 
 price = p; 
 onward = t; 
 } 
}

   假设你有几个相互分隔的TrainJourney对象分别代表从X到Y和从Y到Z的旅行。你希望创建一段新的旅行,它能将两个TrainJourney对象串接起来(即从X到Y再到Z)。
一种方式是采用简单的传统命令式的方法将这些火车旅行对象链接起来,代码如下:

static TrainJourney link(TrainJourney a, TrainJourney b){ 
	 if (a==null) return b; 
	 TrainJourney t = a; 
	 while(t.onward != null){ 
	 t = t.onward; 
	 } 
	 t.onward = b; 
	 return a; 
}

   这个方法是这样工作的,它找到TrainJourney对象a的下一站,将其由表示a列表结束的null替换为列表b(如果a不包含任何元素,你需要进行特殊处理)。

   这就出现了一个问题:假设变量firstJourney包含了从X地到Y地的线路,另一个变量secondJourney包含了从Y地到Z地的线路。如果你调用link(firstJourney, secondJourney)
方法,这段代码会破坏性地更新 firstJourney ,结果 secondJourney 也会加被入到firstJourney,最终请求从X地到Z地的用户会如其所愿地看到整合之后的旅程,不过从X地到Y地的旅程也被破坏性地更新了。这之后,变量firstJourney就不再代表从X到Y的旅程,而是一个新的从X到Z的旅程了!这一改动会导致依赖原先的firstJourney代码失效!假设firstJourney表示的是清晨从伦敦到布鲁塞尔的火车,这趟车上后一段的乘客本来打算要去布鲁塞尔,可是发生这样的改动之后他们莫名地多走了一站,最终可能跑到了科隆。现在你大致了解了数据结构修改的可见性会导致怎样的问题了,作为程序员,我们一直在与这种缺陷作斗争。

   函数式编程解决这一问题的方法是禁止使用带有副作用的方法。如果你需要使用表示计算结果的数据结果,那么请创建它的一个副本而不要直接修改现存的数据结构。这一最佳实践也适用于标准的面向对象程序设计。不过,对这一原则,也存在着一些异议,比较常见的是认为这样做会导致过度的对象复制,有些程序员会说“我会记住那些有副作用的方法”或者“我会将这些写入文档”。但这些都不能解决问题,这些坑都留给了接受代码维护工作的程序员。采用函数式编程方案的代码如下:

static TrainJourney append(TrainJourney a, TrainJourney b){ 
 return a==null ? b : new TrainJourney(a.price, append(a.onward, b)); 
}

   很明显,这段代码是函数式的(它没有做任何修改,即使是本地的修改),它没有改动任何现存的数据结构。不过,也请特别注意,这段代码有一个特别的地方,它并未创建整个新TrainJourney对象的副本——如果a是n个元素的序列,b是m个元素的序列,那么调用这个函数后,它返回的是一个由n+m个元素组成的序列,这个序列的前n个元素是新创建的,而后m个元素和TrainJourney对象b是共享的。另外,也请注意,用户需要确保不对append操作的结果进
行修改,因为一旦这样做了,作为参数传入的TrainJourney对象序列b就可能被破坏。

2.2 另一个使用 Tree 的例子

   转入新主题之前,让我们再看一个使用其他数据结构的例子——我们想讨论的对象是二叉查找树,它也是HashMap实现类似接口的方式。我们的设计中Tree包含了String类型的键,以及int类型的键值,它可能是名字或者年龄:

class Tree { 
	 private String key; 
	 private int val; 
	 private Tree left, right; 
	 public Tree(String k, int v, Tree l, Tree r) { 
	 key = k; val = v; left = l; right = r; 
	 } 
}
class TreeProcessor { 
	 public static int lookup(String k, int defaultval, Tree t) { 
	 if (t == null) return defaultval; 
	 if (k.equals(t.key)) return t.val; 
	 return lookup(k, defaultval, 
	 k.compareTo(t.key) < 0 ? t.left : t.right); 
	 } 
	 // 处理Tree的其他方法 
}

   你希望通过二叉查找树找到String值对应的整型数。现在,我们想想你该如何更新与某个键对应的值(简化起见,我们假设键已经存在于这个树中了):

public static void update(String k, int newval, Tree t) { 
	 if (t == null) {  } 
	 else if (k.equals(t.key)) t.val = newval; 
	 else update(k, newval, k.compareTo(t.key) < 0 ? t.left : t.right); 
}
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/343075.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号