Java 8怎么了之二:函数和原语
【编者按】本文作者为专注于自然语言处理多年的 Pierre-Yves Saumont,Pierre-Yves 著有30多本主讲 Java 软件开发的书籍,自2008开始供职于 Alcatel-Lucent 公司,担任软件研发工程师。
本文主要介绍了 Java 8 中的函数与原语,由国内 ITOM 管理平台 OneAPM 编译呈现。
Tony Hoare 把空引用的发明称为“亿万美元的错误”。也许在 Java 中使用原语可以被称为“百万美元的错误”。创造原语的原因只有一个:性能。原语与对象语言毫无关系。引入自动装箱和拆箱是件好事,不过还有很多有待发展。可能以后会实现(据说已经列入 Java 10的发展蓝图)。与此同时,我们需要对付原语,这可是个麻烦,尤其是在使用函数的时候。
Java 5/6/7的函数
在 Java 8之前,使用者可以创建下面这样的函数:
public interface Function<T, U> {
U apply(T t);
}
Function<Integer, Integer> addTax = new Function<Integer, Integer>() {
@Override
public Integer apply(Integer x) {
return x / 100 * (100 + 10); }
};
System.out.println(addTax.apply(100));
这些代码会产生以下结果:
110
Java 8 带来了 Function<T, U>接口和 lambda 语法。我们不再需要界定自己的功能接口, 而且可以使用下面这样的语法:
Function<Integer, Integer> addTax = x -> x / 100 * (100 + 10);
System.out.println(addTax.apply(100));
注意在第一个例子中,笔者用了一个匿名类文件来创建一个命名函数。在第二个例子中,使用 lambda 语法对结果并没有任何影响。依然存在匿名类文件, 和一个命名函数。
一个有意思的问题是:“x 是什么类型?”第一个例子中的类型很明显。可以根据函数类型推断出来。Java 知道函数参数类型是 Integer,因为函数类型明显是 Function<Integer, Integer>。第一个 Integer 是参数的类型,第二个 Integer 是返回类型。
装箱被自动用于按照需要将 int 和 Integer 来回转换。下文会详谈这一点。
可以使用匿名函数吗?可以,不过类型就会有问题。这样行不通:
System.out.println((x -> x / 100 * (100 + 10)).apply(100));
这意味着我们无法用标识符的值来替代标识符 addTax 本身( addTax 函数)。在本案例中,需要恢复现在缺失的类型信息,因为 Java 8 无法推断类型。
最明显缺乏类型的就是标识符 x。可以做以下尝试:
System.out.println((Integer x) -> x / 100 * 100 + 10).apply(100));
毕竟在第一个例子中,本可以这样写:
Function<Integer, Integer> addTax = (Integer x) -> x / 100 * 100 + 10;
这样应该足够让 Java 推测类型,但是却没有成功。需要做的是明确函数的类型。明确函数参数的类型并不够,即使已经明确了返回类型。这么做还有一个很严肃的原因:Java 8对函数一无所知。可以说函数就是普通对象加上普通方法,仅此而已。因此需要像下面这样明确类型:
System.out.println(((Function<Integer, Integer>) x -> x / 100 * 100 + 10).apply(100));
否则,就会被解读为:
System.out.println(((Whatever<Integer, Integer>) x -> x / 100 * 100 + 10).whatever(100));
因此 lambda 只是在语法上起到简化匿名类在 Function(或 Whatever)接口执行的作用。它实际上跟函数毫不相关。
假设 Java 只有 apply 方法的 Function 接口,这就不是个大问题。但是原语怎么办呢?如果 Java 只是对象语言,Function 接口就没关系。可是它不是。它只是模糊地面向对象的使用(因此被称为面向对象)。Java 中最重要的类别是原语,而原语与面向对象编程融合得并不好。
Java 5 中引入了自动装箱,来协助解决这个问题,但是自动装箱对性能产生了严重限制,这还关系到 Java 如何求值。Java 是一种严格的语言,遵循立即求值规则。结果就是每次有原语需要对象,都必须将原语装箱。每次有对象需要原语,都必须将对象拆箱。如果依赖自动装箱和拆箱,可能会产生多次装箱和拆箱的大量开销。
其他语言解决这个问题的方法有所不同,只允许对象,在后台解决了转化问题。他们可能会有“值类”,也就是受到原语支持的对象。在这种功能下,程序员只使用对象,编译器只使用原语(描述过于简化,不过反映了基本原则)。Java 允许程序员直接控制原语,这就增大了问题难度,带来了更多安全隐患,因为程序员被鼓励将原语用作业务类型,这在面向对象编程或函数式程序设计中都没有意义。(笔者将在另一篇文章中再谈这个问题。)
不客气地说,我们不应该担心装箱和拆箱的开销。如果带有这种特性的 Java 程序运行过慢,这种编程语言就应该进行修复。我们不应该试图用糟糕的编程技巧来解决语言本身的不足。使用原语会让这种语言与我们作对,而不是为我们所用。如果问题不能通过修复语言来解决,那我们就应该换一种编程语言。不过也许不能这样做,原因有很多,其中最重要的一条是只有 Java 付钱让我们编程,其他语言都没有。结果就是我们不是在解决业务问题,而是在解决 Java 的问题。使用原语正是 Java 的问题,而且问题还不小。
现在不用对象,用原语来重写例子。选取的函数采用类型 Integer 的参数,返回 Integer。要取代这些,Java 有 IntUnaryOperator 类型。哇哦,这里不对劲儿!你猜怎么着,定义如下:
public interface IntUnaryOperator {
int applyAsInt(int operand);
...
}
这个问题太简单,不值得调出方法 apply。
因此,使用原语重写例子如下:
IntUnaryOperator addTax = x -> x / 100 * (100 + 10);
System.out.println(addTax.applyAsInt(100));
或者采用匿名函数:
System.out.println(((IntUnaryOperator) x -> x / 100 * (100 + 10)).applyAsInt(100));
如果只是为了 int 返回 int 的函数,很容易实现。不过实际问题要更加复杂。Java 8 的 java.util.function 包中有43种(功能)接口。实际上,它们不全都代表功能,可以分类如下:
- 21个带有一个参数的函数,其中2个为对象返回对象的函数,19个为各种类型的对象到原语或原语到对象函数。2个对象到对象函数中的1个用于参数和返回值属于相同类型的特殊情况。
- 9个带有2个参数的函数,其中2个为(对象,对象)到对象,7个为各种类型的(对象,对象)到原语或(原语,原语)到原语。
- 7个为效果,非函数,因为它们并不返回任何值,而且只被用于获取副作用。(把这些称为“功能接口”有些奇怪。)
- 5个为“供应商”,意思就是这些函数不带参数,却会返回值。这些可以是函数。在函数世界里,有些特殊函数被称为无参函数(表明它们的元数或函数总量为0)。作为函数,它们返回的值可能永远不变,因此它们允许将常量当做函数。在
Java 8,它们的职责是根据可变语境来返回各种值。因此,它们不是函数。
真是太乱了!而且这些接口的方法有不同的名字。对象函数有个方法叫 apply,返回数字化原语的方法被称为 applyAsInt、applyAsLong,或 applyAsDouble。返回 boolean 的函数有个方法被称为 test,供应商的方法叫做 get 或 getAsInt、getAsLong、 getAsDouble,或 getAsBoolean。(他们没敢把带有 test 方法、不带函数的 BooleanSupplier 称为“谓语”。笔者真的很好奇为什么!)
值得注意的一点,是并没有对应 byte、 char、 short 和 float 的函数,也没有对应两个以上元数的函数。
不用说,这样真是太荒谬了,然而我们又不得不坚持下去。只要 Java 能推断类型,我们就会觉得一切顺利。然而,一旦试图通过功能方式控制函数,你将会很快面对 Java 无法推断类型的难题。最糟糕的是,有时候 Java 能够推断类型,却会保持沉默,继续使用另外一个类型,而不是我们想用的那一个。
如何发现正确类型
假设笔者想使用三个参数的函数。由于 Java 8没有现成可用的功能接口,笔者只有一个选择:创建自己的功能接口,或者如前文(Java 8 怎么了之一)中所说,采取柯里化。创建三个对象参数、并返回对象的功能接口直截了当:
interface Function<T, U, V, R> {
R apply(T, t, U, u, V, v);
}
不过,可能出现两种问题。第一种,可能需要处理原语。参数类型也帮不上忙。你可以创建函数的特殊形式,使用原语,而不是对象。最后,算上8类原语、3个参数和1个返回值,只不过得到6561中该函数的不同版本。你以为甲骨文公司为什么没有在 Java 8中包含 TriFunction?(准确来说,他们只放了有限数量的 BiFunction,参数为 Object,返回类型为 int、long或double,或者参数和返回类型同为 int、long 或 Object,产生729种可能性中的9种结果。)
更好的解决办法是使用拆箱。只需要使用 Integer、Long、Boolean 等等,接下来就让 Java 去处理。任何其他行动都会成为万恶之源,例如过早优化(详见 http://c2.com/cgi/wiki?PrematureOptimization)。
另外一个办法(除了创建三个参数的功能接口之外)就是采取柯里化。如果参数不在同一时间求值,就会强制柯里化。而且它还允许只用一种参数的函数,将可能的函数数量限制在81之内。如果只使用 boolean、int、long 和double,这个数字就会降到25(4个原语类型加上两个位置的 Object 相当于5 x 5)。
问题在于在对返回原语,或将原语作为参数的函数来说,使用柯里化可能有些困难。以下是前文(Java 8怎么了之一)中使用的同一例子,不过现在用了原语:
IntFunction<IntFunction<IntUnaryOperator>>
intToIntCalculation = x -> y -> z -> x + y * z;
private IntStream calculate(IntStream stream, int a) {
return stream.map(intToIntCalculation.apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
注意结果不是“包含值5、8、11、14和17的流”,一开始的流也不会包含值1、2、3、4和5。newStream 在这个阶段并没有求值,因此不包含值。(下篇文章将讨论这个问题)。
为了查看结果,就要对这个流求值,也许通过绑定一个终端操作来强制执行。可以通过调用 collect 方法。不过在这个操作之前,笔者要利用 boxed 方法将结果与一个非终端函数绑定在一起。boxed 方法将流与一个能够把原语转为对应对象的函数绑定在一起。这可以简化求值过程:
System.out.println(newStream.boxed().collect(toList()));
这显示为:
[5,8, 11, 14, 17]
也可以使用匿名函数。不过,Java 不能推断类型,所以笔者必须提供协助:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((IntFunction<IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
柯里化本身很简单,只要别忘了笔者在其他文章中提到过的一点:
(x, y, z) -> w
解读为:
x -> y -> z -> w
寻找正确类型稍微复杂一些。要记住,每次使用一个参数,都会返回一个函数,因此你需要一个从参数类型到对象类型的函数(因为函数就是对象)。在本例中,每个参数类型都是 int,因此需要使用经过返回函数类型参数化的 IntFunction。由于最终类型为 IntUnaryOperator(这是 IntStream 类的 map 方法的要求),结果如下:
IntFunction<IntFunction<...<IntUnaryOperator>>>
笔者采用了三个参数中的两种,所有参数类型都是 int ,因此类型如下:
IntFunction<IntFunction<IntUnaryOperator>>
可以与使用自动装箱版本进行比较:
Function<Integer, Function<Integer, Function<Integer, Integer>>>
如果你无法决定正确类型,可以从使用自动装箱开始,只要替换上你需要的最终类型(因为它就是 map 参数的类型):
Function<Integer, Function<Integer, IntUnaryOperator>>
注意,你可能正好在你的程序中使用了这种类型:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((Function<Integer, Function<Integer, IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
接下来可以用你使用的原语版本来替换每个 Function<Integer...,如下所示:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((Function<Integer, IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a)); }
然后是:
private IntStream calculate(IntStream stream, int a) { return stream.map(((IntFunction<IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a)); }
注意,三个版本都可编译运行,唯一的区别在于是否使用了自动装箱。
何时匿名
在以上例子中可见,lambdas 很擅长简化匿名类的创建,但是不给创建的范例命名实在没有理由。命名函数的用处包括:
- 函数复用
- 函数测试
- 函数替换
- 程序维护
- 程序文档管理
命名函数加上柯里化能够让函数完全独立于环境(“引用透明性”),让程序更安全、更模块化。不过这也存在难度。使用原语增加了辨别柯里化函数类别的难度。更糟糕的是,原语并不是可使用的正确业务类型,因此编译器也帮不上忙。具体原因请看以下例子:
double tax = 10.24;
double limit = 500.0;
double delivery = 35.50;
DoubleStream stream3 = DoubleStream.of(234.23, 567.45, 344.12, 765.00);
DoubleStream stream4 = stream3.map(x -> {
double total = x / 100 * (100 + tax);
if ( total > limit) {
total = total + delivery;
}
return total;
});
要用命名的柯里化函数来替代匿名“捕捉”函数,确定正确类型并不难。有4个参数,返回 DoubleUnaryOperator,那么类型应该是 DoubleFunction<DoubleFunction<DoubleFunction<DoubleUnaryOperator>>>。不过,很容易错放参数位置:
DoubleFunction<DoubleFunction<DoubleFunction<DoubleUnaryOperator>>> computeTotal = x -> y -> z -> w -> {
double total = w / 100 * (100 + x);
if (total > y) {
total = total + z;
}
return total;
};
DoubleStream stream2 = stream.map(computeTotal.apply(tax).apply(limit).apply(delivery));
你怎么确定 x、y、z 和 w 是什么?实际上有个简单的规则:通过直接使用方法求值的参数在第一位,按照使用方法的顺序,例如,tax、limit、delivery 对应的就是 x、y 和 z。来自流的参数最后使用,因此它对应的是 w。
不过还存在一个问题:如果函数通过测试,我们知道它是正确的,但是没有办法确保它被正确使用。举个例子,如果我们使用参数的顺序不对:
DoubleStream stream2 = stream.map(computeTotal.apply(limit).apply(tax).apply(delivery));
就会得到:
[1440.8799999999999, 3440.2000000000003, 2100.2200000000003, 4625.5]
而不是:
[258.215152, 661.05688, 379.357888, 878.836]
这就意味着不仅需要测试函数,还要测试它的每次使用。如果能够确保使用顺序不对的参数不会被编译,岂不是很好?
这就是使用正确类型体系的所有内容。将原语用于业务类型并不好,从来就没有好结果。但是现在有了函数,就更多了一条不要这么做的理由。这个问题将在其他文章中详细讨论。
敬请期待
本文介绍了使用原语大概比使用对象更为复杂。在 Java 8中使用原语的函数一团糟,不过还有更糟糕的。在下一篇文章中,笔者将谈论在流中使用原语。
OneAPM 能为您提供端到端的 Java 应用性能解决方案,我们支持所有常见的 Java 框架及应用服务器,助您快速发现系统瓶颈,定位异常根本原因。分钟级部署,即刻体验,Java 监控从来没有如此简单。想阅读更多技术文章,请访问 OneAPM 官方技术博客。
本文转自 OneAPM 官方博客
原文地址: https://dzone.com/articles/whats-wrong-java-8-part-ii
Java 8怎么了之二:函数和原语的更多相关文章
- 20145221 《Java程序设计》实验报告二:Java面向对象程序设计
20145221 <Java程序设计>实验报告二:Java面向对象程序设计 实验要求 初步掌握单元测试和TDD 理解并掌握面向对象三要素:封装.继承.多态 初步掌握UML建模 熟悉S.O. ...
- 24、jQuery常用AJAX-API/Java调用MySQL / Oracle过程与函数
1)掌握jQuery常用AJAX-API 2)掌握Java调用MySQL / Oracle过程与函数 一)jQuery常用AJAX-API 目的:简化客户端与服务端进行局部刷新的异步通讯 (1)取 ...
- java之jvm学习笔记二(类装载器的体系结构)
java的class只在需要的时候才内转载入内存,并由java虚拟机的执行引擎来执行,而执行引擎从总的来说主要的执行方式分为四种, 第一种,一次性解释代码,也就是当字节码转载到内存后,每次需要都会重新 ...
- java面试题—精选30道Java笔试题解答(二)
摘要: java面试题-精选30道Java笔试题解答(二) 19. 下面程序能正常运行吗() public class NULL { public static void haha(){ System ...
- 201521123061 《Java程序设计》第十二周学习总结
201521123061 <Java程序设计>第十二周学习总结 1. 本周学习总结 1.1 以你喜欢的方式(思维导图或其他)归纳总结多流与文件相关内容. 2. 书面作业 将Student对 ...
- 201521123072《java程序设计》第十二周学习总结
201521123072<java程序设计>第十二周学习总结 1. 本周学习总结 1.1 以你喜欢的方式(思维导图或其他)归纳总结多流与文件相关内容. 2. 书面作业 将Student对象 ...
- 201521123038 《Java程序设计》 第十二周学习总结
201521123038 <Java程序设计> 第十二周学习总结 1. 本周学习总结 1.1 以你喜欢的方式(思维导图或其他)归纳总结多流与文件相关内容. 2. 书面作业 将Student ...
- 201521123122 《java程序设计》第十二周学习总结
## 201521123122 <java程序设计>第十二周实验总结 ## 1. 本周学习总结 1.1 以你喜欢的方式(思维导图或其他)归纳总结多流与文件相关内容. 2. 书面作业 将St ...
- 20175305张天钰Java结对编程四则运算(二)
Java结对编程四则运算(二) 一.题目描述及要求 Git提交粒度不要太粗,建议一个文件/一个类/一个函数/一个功能/一个bug修复都进行提交,不能一天提交一次,更不能一周一次,参考Commit Me ...
随机推荐
- CK表达式编辑器
1. 什么是表达式编辑器? 这个工具允许技术员传入一系列的参数,由用户编辑一个公式返回一种特定的结果.之所以需要使用表达式编辑器,就是因为用户编辑的公式经常变,技术员无法想出一办法来适应用户 ...
- chkconfig 命令详解
chkconfig命令主要用来更新(启动或停止)和查询系统服务的运行级信息.谨记chkconfig不是立即自动禁止或激活一个服务,它只是简单的改变了符号连接. 使用语法: chkconfig [--a ...
- [Environment Build] Maven环境配置
1. 下载并解压maven文件 2. 在环境变量中配置一个JAVA_HOME的变量,指向你本地的JDK 3. 在系统变量中新建一个名为:MAVEN_HOME的变量,指向你的maven解压文件的bin目 ...
- Nginx源码结构
上一章对Nginx的架构有了一个初步的了解.这章,为了对源码仔细的剖析,先要对Nginx的源码结构有一个了解.从宏观上把握源码模块的结构. 一.nginx源码的3个目录结构 在安装的nginx的目录下 ...
- android开发系列之6*0.9不等于5.4
昨天晚上我们客户端平台上面曝出了一个很奇诡的bug,那就是本来在客户端里面有个商品买6元,但是因为碰巧赶上打9折,这个时候我们很自然的处理就是6*0.9.好吧你以为so easy的事情,其实就出错了, ...
- jquery.form的使用
插件API http://malsup.com/jquery/form/#api Jquery.form.js是支持文件异步上传的插件,jq插件自然基本前提当然是要引用Jquery.js 1.0 基本 ...
- Object-c 语法 - NSObject常用方法和反射
NSObject常用方法 - (BOOL)isKindOfClass:(Class)aClass //判断是否为aClass或者aClass的子类的实例,aClass可以通过[类名 class]获取 ...
- 基于swift语言iOS8的蓝牙连接(初步)
看过一些蓝牙App的事例,大体上对蓝牙的连接过程进行了了解.但是开始真正自己写一个小的BLE程序的时候就举步维艰了.那些模棱两可的概念在头脑中瞬间就蒸发了,所以还是决定从最基本的蓝牙连接过程进行.这里 ...
- Call C# in powershell
How to call C# code in powershell Powershell Command Add-Type usage of Add-Type we use Add-Type -Typ ...
- Objective-C面向对象(三)
1.类的继承 OC的继承是单继承,每个子类只有一个直接父类 1.1 继承的特点 OC继承的语法 @interface SubClass :SuperClass { //成员变量定义 } //方法定义部 ...