Java 8怎么了之二:函数和原语

【编者按】本文做者为专一于天然语言处理多年的 Pierre-Yves Saumont,Pierre-Yves 著有30多本主讲 Java 软件开发的书籍,自2008开始供职于 Alcatel-Lucent 公司,担任软件研发工程师。html

本文主要介绍了 Java 8 中的函数与原语,由国内 ITOM 管理平台 OneAPM 编译呈现。java

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 语法对结果并无任何影响。依然存在匿名类文件, 和一个命名函数。app

一个有意思的问题是:“x 是什么类型?”第一个例子中的类型很明显。能够根据函数类型推断出来。Java 知道函数参数类型是 Integer,由于函数类型明显是 Function<Integer, Integer>。第一个 Integer 是参数的类型,第二个 Integer 是返回类型。框架

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

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

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,或者参数和返回类型同为 int、long 或 Object,产生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);

注意结果不是“包含值五、八、十一、14和17的流”,一开始的流也不会包含值一、二、三、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 官方技术博客

本文转自 OneAPM 官方博客

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

相关文章
相关标签/搜索