转载

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));

这些代码会产生以下结果:

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 是返回类型。

装箱被自动用于按照需要将 intInteger 来回转换。下文会详谈这一点。

可以使用匿名函数吗?可以,不过类型就会有问题。这样行不通:

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 ,返回数字化原语的方法被称为 applyAsIntapplyAsLong ,或 applyAsDouble 。返回 boolean 的函数有个方法被称为 test ,供应商的方法叫做 getgetAsIntgetAsLonggetAsDouble ,或 getAsBoolean 。(他们没敢把带有 test 方法、不带函数的 BooleanSupplier 称为“谓语”。笔者真的很好奇为什么!)

值得注意的一点,是并没有对应 bytecharshortfloat 的函数,也没有对应两个以上元数的函数。

不用说,这样真是太荒谬了,然而我们又不得不坚持下去。只要 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 ,返回类型为 intlongdouble ,或者参数和返回类型同为 intlongObject ,产生729种可能性中的9种结果。)

更好的解决办法是使用拆箱。只需要使用 IntegerLongBoolean 等等,接下来就让 Java 去处理。任何其他行动都会成为万恶之源,例如过早优化(详见 http://c2.com/cgi/wiki?PrematureOptimization )。

另外一个办法(除了创建三个参数的功能接口之外)就是采取柯里化。如果参数不在同一时间求值,就会强制柯里化。而且它还允许只用一种参数的函数,将可能的函数数量限制在81之内。如果只使用 booleanintlongdouble ,这个数字就会降到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));

你怎么确定 xyzw 是什么?实际上有个简单的规则:通过直接使用方法求值的参数在第一位,按照使用方法的顺序,例如, taxlimitdelivery 对应的就是 xyz 。来自流的参数最后使用,因此它对应的是 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 官方技术博客。

原文地址: https://dzone.com/articles/whats-wrong-java-8-part-ii

原文  http://blog.oneapm.com/apm-tech/672.html
正文到此结束
Loading...