深入学习java内存化和函数式协同

前言

所有编程语言都在增加函数特性,因为运行时已变得强大到足够适应性能或内存开销。函数式编程的许多收益之一是,您可将麻烦或容易出错的任务卸载到运行时。另一个收益是将函数特性简洁地组合到您代码中的能力。

在本期文章中,我将探讨 Java 下一代语言中的内存化。然后,通过利用 Clojure 示例,我将展示通过利用函数特性之间的协调作用,如何实现常见问题的一般解决方案。

内存化

内存化 这个词是 Donald Michie(一位英国人工智能研究人员)发明的,用于表示重复的值的函数级缓存。如今,内存化在函数式编程语言中很常见,它要么被用作一个内置特性,要么被用作一个相对容易实现的特性。

内存化在以下场景中很有帮助。假设您必须反复调用一个注重性能的函数。一个常见解决方案是构建内部缓存。每次计算某个参数集的值时,您都会将该值放入缓存中,作为参数值的线索。在未来,如果该函数使用以前的参数调用,那么它将会从缓存返回值,而不是重新计算它。函数缓存是一种经典的计算机科学权衡:它使用更多内存(我们常常拥有丰富的内存)来不断实现更高的性能。

函数必须是纯粹的,缓存技术才能发挥其作用。纯函数 是没有副作用的函数:它没有引用任何其他易变的类字段,没有设置除返回值以外的任何值,而且仅依赖于参数作为输入。java.lang.Math 类中的所有方法都是纯函数的良好示例。显然,只有在函数可靠地为一组给定的参数返回相同值时,您才能成功地重用缓存的结果。

Groovy 中的内存化

内存化在 Groovy 中很简单,Groovy 在 Closure 类上包含一系列 memoize() 函数。例如,假设您有一个昂贵的哈希算法,以至于您需要缓存结果来提高效率。为此,您可以使用闭包块语法来定义方法,在返回时调用 memoize() 函数,如清单 1 所示。我并不是暗示清单 1 中使用的 ROT13 算法(即凯撒密码 的一个版本)的性能面临挑战,只是假设缓存在这个示例中很重要。

清单 1. Groovy 中的内存化

class NameHash {
def static hash = {name ->
name.collect{rot13(it)}.join()
}.memoize()
public static char rot13(s) {
char c = s
switch (c) {
case 'A'..'M':
case 'a'..'m': return c + 13
case 'N'..'Z':
case 'n'..'z': return c - 13
default: return c
}
}
}
class NameHashTest extends GroovyTestCase {
void testHash() {
assertEquals("ubzre", NameHash.hash.call("homer")) }
}

正常情况下,Groovy 函数定义看起来像清单 1 中的 rot13(),方法主体位于参数列表之后。hash() 函数定义使用了稍微不同的语法,将代码块分配给 hash 变量。该定义的最后一部分是对 memoize() 的调用,它自动为重复的值创建一个内部缓存,与该参数建立联系。

memoize() 方法实际上是一个方法系列,为您提供了对缓存特征的一定控制,如表 1 所示。

表 1. Groovy 的 memoize() 系列

方法 用途
memoize() 返回闭包的一个包含缓存的实例
memoizeAtMost() 为缓存元素的数量设置一个上限
memoizeAtLeast(int protectedCacheSize) 为缓存元素的数量设置一个下限,保护一定数量的元素免遭垃圾收集
memoizeBetween(int protectedCacheSize, int maxCacheSize) 为缓存元素的数量设置一个下限和上限

表 1 中的方法为您提供了对缓存特征粗粒度的控制,这不是直接调优缓存特征的细粒度方式。内存化应是一种通用机制,您可以用它来轻松优化常见的缓存情形。

Clojure 中的内存化

内置于 Clojure 中的内存化。您可以使用 (memoize ) 函数内存化任何函数。例如,如果您已经拥有一个 (hash "homer") 函数,那么您可以通过 (memoize (hash "homer")) 针对一个缓存版本而对其进行内存化。清单 2 在 Clojure 中实现了 清单 1 中的名称哈希示例。

清单 2. Clojure 内存化

(defn name-hash [name]
(apply str (map #(rot13 %) (split name #"\d"))))
(def name-hash-m (memoize name-hash))
(testing "name hash"
(is (= "ubzre" (name-hash "homer"))))
(testing "memoized name hash"
(is (= "ubzre" (name-hash-m "homer")))))

请注意,在 清单 1 中,调用内存化的函数需要调用 call() 方法。在 Clojure 版本中,内存化的方法调用在表面上完全相同,但增加了对方法用户不可见的间接性和缓存。

Scala 中的内存化

Scala 没有直接实现内存化,但有一个名为 getOrElseUpdate() 的集合方法来处理实现它的大部分工作,如清单 3 所示。

清单 3. Scala 内存化

def memoize[A, B](f: A => B) = new (A => B) {
val cache = scala.collection.mutable.Map[A, B]()
def apply(x: A): B = cache.getOrElseUpdate(x, f(x))
}
def nameHash = memoize(hash)

清单 3 中的 getOrElseUpdate() 函数是建立缓存的完美的运算符。它检索匹配的值,或者在没有匹配值时创建一个新条目。

组合函数特性

通过复合 (composition) 来组合

复合 在软件开发中有许多含义。函数复合 指组合函数来获得复合结果的能力。在数学术语中,如果您有一个 f(x) 函数和一个 g(x) 函数,那么您应能够执行 f(g(x))。在软件术语中,如果您有一个将字符串转换为大写的 a() 函数和一个删除过量空格的 b() 函数,那么复合函数将执行这两项任务。

在上一节和前几期 Java 下一代 文章中,我介绍了函数式编程的许多细节,尤其是与 Java 下一代语言相关的细节。但是,函数式编程的真正强大之处在于各种特性与解决方案的执行方式的组合。

面向对象的程序员倾向于不断创建新数据结构和附带的运算。毕竟,构建新类和在它们之间传递的消息是主要的语言模式。但是构建如此多的定制结构,会使在最低层级上构建可重用代码变得很困难。函数式编程语言引用一些核心代码结构并构建优化的机制来理解它们。

以下是一个示例。清单 4 给出了来自 Apache Commons 框架的 indexOfAny() 方法,该框架为 Java 编程提供了大量帮助器。

清单 4. 来自 Apache Commons 的 indexOfAny()

// From Apache Commons Lang, http://commons.apache.org/lang/
public static int indexOfAny(String str, char[] searchChars) {
if (isEmpty(str) || ArrayUtils.isEmpty(searchChars)) {
return INDEX_NOT_FOUND;
}
int csLen = str.length();
int csLast = csLen - 1;
int searchLen = searchChars.length;
int searchLast = searchLen - 1;
for (int i = 0; i < csLen; i++) {
char ch = str.charAt(i);
for (int j = 0; j < searchLen; j++) {
if (searchChars[j] == ch) {
if (i < csLast && j < searchLast && CharUtils.isHighSurrogate(ch)) {
if (searchChars[j + 1] == str.charAt(i + 1)) {
return i;
}
} else {
return i;
}
}
}
}
return INDEX_NOT_FOUND;
}

清单 4 中 1/3 的代码负责边缘检查和实现嵌套迭代所需的变量的初始化。我将逐步将此代码转换为 Clojure。作为第一步,我将删除边角情形,如清单 5 所示。

清单 5. 删除边角情形

public static int indexOfAny(String str, char[] searchChars) {
when(searchChars) {
int csLen = str.length();
int csLast = csLen - 1;
int searchLen = searchChars.length;
int searchLast = searchLen - 1;
for (int i = 0; i < csLen; i++) {
char ch = str.charAt(i);
for (int j = 0; j < searchLen; j++) {
if (searchChars[j] == ch) {
if (i < csLast && j < searchLast && CharUtils.isHighSurrogate(ch)) {
if (searchChars[j + 1] == str.charAt(i + 1)) {
return i;
}
} else {
return i;
}
}
}
}
return INDEX_NOT_FOUND;
}
}

Clojure 会智能地处理 null 和 empty 情形,拥有 (when ...) 等智能函数,该函数仅在字符存在时返回 true。Clojure 具有动态(且强)类型,消除了在使用前声明变量类型的需求。因此,我可以删除类型声明,获得清单 6 中所示的代码。

清单 6. 删除类型声明

indexOfAny(str, searchChars) {
when(searchChars) {
csLen = str.length();
csLast = csLen - 1;
searchLen = searchChars.length;
searchLast = searchLen - 1;
for (i = 0; i < csLen; i++) {
ch = str.charAt(i);
for (j = 0; j < searchLen; j++) {
if (searchChars[j] == ch) {
if (i < csLast && j < searchLast && CharUtils.isHighSurrogate(ch)) {
if (searchChars[j + 1] == str.charAt(i + 1)) {
return i;
}
} else {
return i;
}
}
}
}
return INDEX_NOT_FOUND;
}
}

for 循环 (命令式语言的主要元素)允许依次访问每个元素。函数式语言倾向于更多地依靠集合方法,这些方法已理解(或避免)了边角情形,所以我可删除 isHighSurrogate()(它检查字符编码)等方法和索引指针的操作。此转换的结果如清单 7 所示。

清单 7. 一个用于替换最里面的 for 的 when 子句

// when clause for innermost for
indexOfAny(str, searchChars) {
when(searchChars) {
csLen = str.length();
for (i = 0; i < csLen; i++) {
ch = str.charAt(i);
when (searchChars(ch)) i;
}
}
}

在清单 7 中,我将代码折叠到一个方法中,该方法会检查受欢迎的字符是否存在,在找到这些字符时,它会返回其索引。尽管我既未使用 Java 也未使用 Clojure,而是提供了一段陌生的伪代码,但这个 when 方法并不总是存在。但 Clojure 中还有 (when ) 方法,此代码会慢慢变成该方法。

接下来,我将最顶层的 for 循环替换为一种更加简洁的代码,使用 for comprehension: 一个结合了集合的访问和过滤(等)的宏。演变后的代码如清单 8 所示。

清单 8. 添加一个 comprehension

// add comprehension
indexOfAny(str, searchChars) {
when(searchChars) {
for ([i, ch] in indexed(str)) {
when (searchChars(ch)) i;
}
}
}

要理解清单 8 中的 for comprehension,首先您必须理解一些部件。Clojure 中的 (indexed ...) 函数接受一个 Sequence

并返回一个包含编号的元素的序列。例如,如果我调用 (indexed '(a b c)),返回值为 ([0 a] [1 b] [2 c])。(单个撇号告诉 Clojure,我想要一个字符的文字序列,但并不希望执行一个包含两个参数的 (a )。)for comprehension 在我的搜索字符上创建这个序列,然后应用内部的 when 来查找匹配字符的索引。

此转换的最后一步是将代码转换为合适的 Clojure 语法,还原真实函数和语法的外观,如清单 9 所示。

清单 9. Clojure 化的代码

// Clojure-ify
(defn index-filter [pred coll]
(when pred
(for [[index element] (indexed coll) :when (pred element)] index)))

在清单 9 中的最终的 Clojure 版本中,我将语法转换为合适的 Clojure 并添加一次升级:此函数的调用方现在可传递任何判定函数(一个返回布尔值结果的函数),而不只是检查一个空字符串。Clojure 的一个目标是实现创建可读的代码的能力(在您理解圆括号之后),而且这个函数证实了这种能力:对于带索引的集合,在您的判定与元素匹配时,将会返回索引。

Clojure 的另一个目标是使用最少量的字符来表达清楚目的;在这方面,Java 与 Clojure 相差甚远。表 2 比较了 清单 4 中的 “移动部件” 和 清单 9 中的相应部件。

表 2.比较 “移动部件”

要求 函数
函数 1 1
1 0
内部退出点 2 0
变量 3 0
分支 4 0
布尔运算符 1 0
函数调用 6 3
总计 18 4

复杂性上的差异一目了然。尽管 Clojure 代码更简单,但它也更加通用。这里,我对一个硬币翻转序列建立了索引,建模为 Clojure :h(头)和 :t(尾)关键字:

(index-filter #{:h} [:t :t :h :t :h :t :t :t :h :h])
-> (2 4 8 9)

请注意,返回值是所有匹配的索引位置的序列,而不只是第一个。Clojure 中的列表操作尽可能是 惰性的,包括这一个操作。如果我仅想要第一个值,那么我可以通过 (take 1 ) 从结果中获得该值,或者我可以全部打印它们,就像我在这里所做的那样。

我的 (index-filter ) 函数是通用的,所以我可在数字上使用它。例如,我可确定其斐波纳契值超过 1,000 的第一个数字:

(first
(index-filter #(> % 1000) (fibo)))
-> 17

(fibo) 函数返回一个没有限制但惰性的斐波纳契数字序列;(index-filter ) 找到第一个超过 1,000 的值。(事实证明,18 的斐波纳契值为 1,597。)函数结构、动态类型、惰性和简洁语法相结合,得到的是更强大的功能。

惰性

惰性 — 尽可能延迟表达式计算 — 是函数式语言在不需要或只需很少开发人员成本的情况下添加功能的另一个优秀示例。请参阅 “函数式思维:探索 Java 中的惰性计算” 和 “函数式思维:深入剖析惰性计算”,了解 Java 下一代语言中的惰性讨论和示例。

结束语

函数式编程结构在零散使用时可带来优势,在结合使用它们时会带来更多的优势。所有 Java 下一代语言在一定程度上都是函数式的,支持增加这种开发风格的使用。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 深入了解java 8的函数式编程

    前言 关于"Java 8为Java带来了函数式编程"已经有了很多讨论,但这句话的真正意义是什么? 本文将讨论函数式,它对一种语言或编程方式意味着什么.在回答"Java 8的函数式编程怎么样"之前,我们先看看Java的演变,特别是它的类型系统,我们将看到Java 8的新特性,特别是Lambda表达式如何改变Java的风景,并提供函数式编程风格的主要优势. 函数式编程语言是什么? 函数式编程语言的核心是它以处理数据的方式处理代码.这意味着函数应该是第一等级(First-

  • Java 8 Function函数式接口及函数式接口实例

    函数式接口(Functional Interface)就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口. 函数式接口可以被隐式转换为lambda表达式. 函数式接口可以现有的函数友好地支持 lambda. 介绍 函数式接口其实就是一个抽象接口类,在Java 8之前已有的函数式接口有以下. java.lang.Runnable java.util.concurrent.Callable java.util.Comparator 等等... 使用方法 其实上述所说的接口类只需要使用Fun

  • 浅谈Java内存模型之happens-before

    happens-before原则非常重要,它是判断数据是否存在竞争.线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题.下面我们就一个简单的例子稍微了解下happens-before : i = 1;       //线程A执行 j = i ;      //线程B执行 j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-be

  • 浅谈Java内存泄露

    纳尼,Java 不是自动管理内存吗?怎么可能会出现内存泄泄泄泄泄泄漏! Java 最牛逼的一个特性就是垃圾回收机制,不用像 C++ 需要手动管理内存,所以作为 Java 程序员很幸福,只管 New New New 即可,反正 Java 会自动回收过期的对象... 那么 Java 都自动管理内存了,那怎么会出现内存泄漏,难道 Jvm 有 bug? 不要急,且听我慢慢道来.. 怎么判断可以被回收 先了解一下 Jvm 是怎么判断一个对象可以被回收.一般有两种方式,一种是引用计数法,一种是可达性分析.

  • 实例详解Java8函数式接口

    以下我们继续深入Java8函数式编程模型 public class Test1 { public static void main(String[] args) { List<Integer> list = Arrays.asList(1,2,3,4,5,6,7,8,9,10); list.forEach(new Consumer<Integer>() { @Override public void accept(Integer integer) { System.out.prin

  • Java内存之happens-before和重排序

    happens-before原则规则: 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作: 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作: volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作: 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C: 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作: 线程中断规则:对线程interrup

  • 浅谈Java 8 新增函数式接口到底是什么

    从 Java 8 开始便出现了函数式接口(Functional Interface,以下简称FI) 定义为: 如果一个接口只有唯一的一个抽象接口,则称之为函数式接口.为了保证接口符合 FI ,通常会在接口类上添加 @FunctionalInterface 注解.理解了函数式接口可以为 Java 函数式编程打下基础,最终可通过运用函数式编程极大地提高编程效率. 函数式接口 (Functional Interface) 就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口. 函数式接口可以对

  • Java内存泄漏问题处理方法经验总结

    JVM问题,一般会有三种情况,目前遇到了两种,线程溢出和JVM不够用 1.线程溢出:unable to create new native thread 1.1问题描述: 系统在1月4号左右,突然发现会产生内存溢出问题,从日志上看,错误信息为: 导致系统不能使用,对外不能相应,但是观察gc等又处于正常情况,free 系统内存也正常.开始重启机器进行解决,真正的原因查找,过程比较坎坷,经历也比较痛苦. 1.2 问题解决 pstree查看线程数,发现系统线程数不断增长,直到OOM. 命令:pstre

  • 浅谈Java并发中的内存模型

    什么是JavaMemoryModel(JMM)? JMM通过构建一个统一的内存模型来屏蔽掉不同硬件平台和不同操作系统之间的差异,让Java开发者无需关注不同平台之间的差异,达到一次编译,随处运行的目的,这也正是Java的设计目的之一. CPU和内存 在讲JMM之前,我想先和大家聊聊硬件层面的东西.大家应该都知道执行运算操作的CPU本身是不具备存储能力的,它只负责根据指令对传递进来的数据做相应的运算,而数据存储这一任务则交给内存去完成.虽然内存的运行速度虽然比起硬盘快非常多,但是和3GHZ,4GH

  • Java8简单了解Lambda表达式与函数式接口

    Java8被称作Java史上变化最大的一个版本.其中包含很多重要的新特性,最核心的就是增加了Lambda表达式和StreamAPI.这两者也可以结合在一起使用.首先来看下什么是Lambda表达式. 使用Lambda表达式不仅让代码变的简单.而且可读.最重要的是代码量也随之减少很多.然而,在某种程度上,这些功能在Scala等这些JVM语言里已经被广泛使用. 并不奇怪,Scala社区是难以置信的,因为许多Java 8里的内容看起来就像是从Scala里搬过来的.在某种程度上,Java 8的语法要比Sc

  • 简单了解java函数式编码结构及优势

    前言 当垃圾回收成为主流时,它消除了所有类别的难以调试的问题,使运行时能够为开发人员管理复杂的.容易出错的进程.函数式编程旨在为您编写的算法实现同样的优化,这样您就可以从一个更高的抽象层面开展工作,同时运行时执行复杂的优化. Java 下一代语言并不都占用从命令式到函数式的语言频谱的同一位置,但都展现出函数功能和习语.函数式编程技术有明确定义,但语言有时为相同的函数式概念使用不同的术语,使得我们很难看到相似之处.在本期文章中,我比较了 Scala.Groovy 和 Clojure 的函数式编码风

随机推荐