Java 10 局部变量类型推断浅析
前言
java10引进一种新的闪闪发光的特性叫做局部变量类型推断。听起来很高大上吧?它是什么呢?下面的两个情景是我们作为Java开发者认为Java比较难使用的地方。
上下文:陈词滥调和代码可读性
也许日复一日,你希望不再需要重复做一些事情。例如在下面的代码(使用Java9的集合工厂),左边的类型也许会感觉到冗余和平淡。
importstaticjava.util.Map.entry; Listcities=List.of("Brussels","Cardiff","Cambridge") Map citiesPopulation =Map.ofEntries(entry("Brussels",1_139_000), entry("Cardiff",341_000));
这是一个非常简单的例子,不过它也印证了传统的Java哲学:你需要为所有包含的简单表达式定义静态类型。再让我们来看看有一些复杂的例子。举例来说,下面的代码建立了一个从字符串到词的柱状图。它使用groupingBy收集器将流聚合进Map。groupingBy收集器还可以以一个分类函数为第一个参数建立映射的键和第二个收集器的(counting())键计算关联的数量。下面就是例子:
Stringsentence="AsimpleJavaexamplethatexploreswhatJava 10hastooffer"; Collector>byOccurrence =groupingBy(Function.identity(),counting()); Map wordFrequency =Arrays.stream(sentence.split("")) .collect(byOccurrence);
复杂表达式提取到一个变量或方法来提升代码的可读性和重用性,这是非常有意义的。在这里例子中,建立柱状图的逻辑使用了收集器。不幸地是,来自groupingBy的结果类型几乎是不可读的!对于这一点你毫无办法,你能做的只有观察。
最重要的一点是当Java中增加新的类库的时候,他们开发越来越多的泛型,这就为开发者引进了更多的公式化代码(boilerplatecode),从而带来了额外的压力。上面的例子并不是说明了编写类型就不好。很明显,强制将为变量和方法签名定义类型的操作执行为一种需要被尊重的协议,将有益于维护和理解。然而,为中间表达式声明类型也许会显得无用和冗余。
类型推断的历史
我们已经在Java历史上多次看到语言设计者添加“类型推断”来帮助我们编写更简洁的代码。类型推断是一种思想:编译器可以帮你推出静态类型,你不必自己指定它们。
最早从Java5开始就引入了泛型方法,而泛型方法的参数可以通过上下文推导出来。比如
这段代码:
List
可以简化成:
List
然后,在Java7中,可以在表达式中省略类型参数,只要这些参数能通过上下文确定。比如:
Map
可以使用尖括号<>运算符简化成:
Map
一般来说,编译器可以根据周围的上下文来推断类型。在这个示例中,从左侧可以推断出HashMap包含字符串列表。
从Java8开始,像下面这样的Lambda表达式
Predicate
可以省略类型,写成
Predicate
局部变量类型推断
随着类型越来越多,泛型参数有可能是另一个泛型,这种情况下类型推导可以增强可读性。Scala和C#语言允许将局部变量的类型声明为var,由编译器根据初始化语句来填补合适的类型。比如,前面对userChannels的声明可以写成这样:
varuserChannels=newHashMap>();
也可以是根据方法的返回值(这里返回列表)来推断:
varchannels=lookupUserChannels("Tom"); channels.forEach(System.out::println);
这种思想称为局部变量类型推断,它已经在Java10中引入!
例如下面的代码:
Pathpath=Paths.get("src/web.log"); try(Streamlines=Files.lines(path)){ longwarningCount =lines .filter(line->line.contains("WARNING")) .count(); System.out.println("Found"+warningCount+"warningsinthe logfile"); }catch(IOExceptione){ e.printStackTrace(); }
在Java10中可以重构成这样:
varpath=Paths.get("src/web.log"); try(varlines=Files.lines(path)){ varwarningCount =lines .filter(line->line.contains("WARNING")) .count(); System.out.println("Found"+warningCount+"warningsinthe logfile"); }catch(IOExceptione){ e.printStackTrace(); }
上述代码中的每个表达式仍然是静态类型(即值的类型):
- 局部变量path的类型是Path
- 变量lines的类型是Stream
- 变量warningCount的类型是long
也就是说,如果给这些变量赋予不同值则会失败。比如,像下面这样的二次赋值会造成编译错误:
varwarningCount=5; warningCount="6"; |Error: |incompatibletypes:java.lang.Stringcannotbeconvertedtoint |warningCount="6"
然而还有一些关于类型推断的小问题;如果类Car和Bike都是Vehicle的子类,然后声明
varv=newCar();
这里声明的v的类型是Car还是Vehicle?这种情况下很好解释,因为初始化器(这里是Car)的类型非常明确。如果没有初始化器,就不能使用var。稍后像这样赋值
v=newBike();
会出错。换句话说,var并不能完美地应用于多态代码。
那应该在哪里使用局部变量类型推断呢?
什么情况下局部类型推断会失效?你不能在字段和方法签名中使用它。它只能用于局部变量,比如下面的代码是不正确的:
publiclongprocess(varlist){}
不能在不明确初始化变量的情况下使用var声明局部变量。也就是说,不能使用var语法声明一个没有赋值的变量。下面这段代码
varx;
这会产生编译错误:
|Error: |cannotinfertypeforlocalvariablex |(cannotuse'var'onvariablewithoutinitializer) |varx; |^----^
也不能把var声明的变量初始化为null。实事上,在后期初始化之前它究竟是什么类型,这并不清楚。
|Error: |cannotinfertypeforlocalvariablex |(variableinitializeris'null') |varx=null; |^-----------^
不能在Lambda表达式中使用var,因为它需要明确的目标类型。下面的赋值就是错的:
varx=()->{} |Error: |cannotinfertypeforlocalvariablex |(lambdaexpressionneedsanexplicittarget-type) |varx=()->{}; |^---------------^
但是,下面的赋值却是有效的,原因是等式右边确实有一个明确的初始化。
varlist=newArrayList<>();
这个列表的静态类型是什么?变量的类型被推导为ArrayList
对无法表示的类型(Non-DenotableTypes)进行推断
Java中存在大量无法表示的类型——这些类型存在于程序中,但是却不能准确地写出其名称。比如匿名类就是典型的无法表示的类型,你可以在匿名类中添加字段和方法,但你没办法在Java代码中写出匿名类的名称。尖括号运算符不能用于匿名类,而var受到的限制会稍微少一些,它可以支持一些无法表示的类型,详细点说就是匿名类和交叉类型。
var关键字也能让我们更有效地使用匿名类,它可以引用那些不可描述的类型。一般来说是可以在匿名类中添加字段的,但是你不能在别的地方引用这些字段,因为它需要变量在赋值时指定类型的名称。比如下面这段代码就不能通过编译,因为productInfo的类型是Object,你不能通过Object类型来访问name和total字段。
ObjectproductInfo=newObject(){ Stringname="Apple"; inttotal=30; }; System.out.println("name="+productInfo.name+",total="+ productInfo.total);
使用var可以打破这个限制。把一个匿名类对象赋值给以var声明的局部变量时,它会推断出匿名类的类型,而不是把它当作其父类类型。因此,匿名类上声明的字段就可以引用到。
varproductInfo=newObject(){ Stringname="Apple"; inttotal=30; }; System.out.println("name="+productInfo.name+",total="+ productInfo.total);
乍一看这只是语言中比较有趣的东西,并不会有太大用处。但在某些情况下它确实有用。比如你想返回一些值作为中间结果的时候。一般来说,你会为此创建并维护一个新的类,但只会在一个方法中使用它。在Collectors.averagingDouble()的实现中就因为这个原因,使用了一个double类型的小数组。
有了var之后我们就有了更好的处理办法-用匿名类来保存中间值。现在来思考一个例子,有一些产品,每个都有名称、库存和货币价值或价值。我们要计算计算每一项的总价(数量*价值)。这些是我们要将每个Product映射到其总价所需要的信息,但是为了让信息更有意义,还需要加入产品的名称。下面的示例描述了在Java10中如何使用var来实现这一功能:
varproducts=List.of( newProduct(10,3,"Apple"), newProduct(5,2,"Banana"), newProduct(17,5,"Pear")); varproductInfos=products .stream() .map(product->newObject(){ Stringname=product.getName(); inttotal=product.getStock()*product.getValue(); }) .collect(toList()); productInfos.forEach(prod-> System.out.println("name="+prod.name+",total="+ prod.total)); Thisoutputs: name=Apple,total=30 name=Banana,total=10 name=Pear,total=85
并非所有无法表示的类型都可以用var-它只支持匿名类和交叉类型。由通配符匹配的类型就不能被推断,这会避免与通配符相关的错误被报告给Java程序员。支持无法表示的类型的目的是在推断类型中尽量保留更多信息,让人们可以利用局部变量并更好地重构代码。这一特性的初衷并不是要人们像上面的示例中那样编写代码,而是为了使用var简化处理无法表示类型相关的一些问题。以后是否会使用var来处理无法表示的类型的一些细节问题,尚不可知。
类型推断建议
类型推断确实有助于快速编写Java代码,但是可读性如何呢?开发者大约会花10倍于写代码的时候来阅读代码,因此应该让代码更易读而不是更易写。var对此带来的改善程度总是主观评价的,不可避免地会有人喜欢它,也会有人讨厌它。你应该关注的是如何帮助团队成员阅读你的代码,所以如果他们喜欢阅读使用var的代码,那就用,不然就不用。
有时候,显示类型也会降低可读性。比如,在循环遍历Map的entryset时,你需要找到Map.Entry对象的类型参数。这里有一个遍历Map的示例,这个Map将国家名称映射到其中的城市名称列表。
Map>countryToCity=newHashMap<>(); //... for(Map.Entry >citiesInCountry:countryToCity.entrySet()){ List cities=citiesInCountry.getValue(); //... }
然后用var来重写这段代码,减少重复和繁琐的东西:
varcountryToCity=newHashMap>(); //... for(varcitiesInCountry:countryToCity.entrySet()){ varcities=citiesInCountry.getValue(); //... }
这里不仅带来了可读性方面的优势,在改进和维护代码方面也带来了优势。如果我们在显式类型的代码中将城市从String表示的名称改为City类,以保留更多城市信息,那就需要重写所有依赖于特定类型的代码,比如:
Map>countryToCity=newHashMap<>(); //... for(Map.Entry >citiesInCountry:countryToCity.entrySet()){ List cities=citiesInCountry.getValue(); //... }
但使用了var关键字和类型推导,我们就只需要修改第一行代码就好:
varcountryToCity=newHashMap>(); //... for(varcitiesInCountry:countryToCity.entrySet()){ varcities=citiesInCountry.getValue(); //... }
这说明了一个使用var变量的重要原则:不要为了易于编码而优化,也不要为了易读而优化,而要了易维护性而优化。同时要考虑部分代码可能以后会修改而要折衷考虑代码的可读性。当然如果说添加类型推断对代码只会有好处略显武断,有时明确的类型有助于代码可读性。特别是当某些生成的表达式类型不是很直观时,我将选择显式而不是隐式类型,比如从下边的代码中我并不能看出getCitiest()方法会返回什么对象:
Map>countryToCity=getCities(); varcountryToCity=getCities();
既然要同时考虑到可读性和var,那么如何折衷就成了一个新问题,一个建议是:关注变量名,这很重要!因为var失去代码的易读性,看到这样的代码你根本不知道代码的意图是什么,这就使得起好一个变量名更加重要。理论上这是JAVA程序员应努力的方面之一,实际上许多Java代码可读性的问题根本不在语言的特性本身,而存在于一些变量的命名不太恰当上。
IDE中的类型推断
许多IDE都有提取局部变量的功能,它们可以正确地推断出变量的类型,并为你写出来。这一特性与Java10的var有一些重复。IDE的这个特性和var一样都可以消除显式书写类型的必要性,但是它们在其它方面有一些不同。
局部提取功能会在代码中生成完整的、类型明确的局部变量。而var则是消除了在代码写显式书写类型的必要。所以虽然他们在简化书写代码方面有着类似的作用,但var对代码可读性的影响是局部提取功能所不具备的。就像我们前面提到,它多数时候会提高可读性,但有时候会可能会降低可读性。
与其它编程语言比较
Java并不是首先实现变量类型推断的语言。类型推断在近几十年来被广泛应用于其它语言中。实际上,Java10中通过var带来的类型推断非常有限,形式上也相对拘束。这是一种简单的实现,可以将与var声明相关的编译错误限制在一条语句当中,因为var推断算法只需要计算赋值给变量的表达式的类型。此外,用在大多数语言中的Hindley-Milner类型推断算法在最坏的情况下会花费指数级时间,这会降低javac的速度。
总结
var对于Java语言的生产力和可读性来说是一项很不错的新特性,但不应该止步于此。将来版本的Java将会继续保持语言的革新和现代性。举例来说,在Java10发布仅仅6个月之后,就将发布并长期支持的Java11,其var关键字将可以在lambda表达式的参数中使用。这能让你拥有正式的参数类型推断能力,这很有用,不过,你还是需要加上Java注解,下面是例子:
(@Nonnullvarx,vary)->x.process(y)
一些函数式编程的想法已经被实现,并且已经为与将来的Java版本的结合做好准备。举例来说,模式匹配和值类型。这并不意味着Java会变得不再是我们熟悉和喜爱的Java,它只是会变得比以前更灵活、可读性更强,并且更简洁。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。