原作者:Stuart W. Marks/2018-03-22 原文链接:http://openjdk.java.net/projects/amber/LVTIstyle.html
译者的话
Java 10给我们带了了一个很实用的特性——局部变量类型推断,此特性能大大的增加代码的整洁度与可读性。但是译者发现,如果滥用此特性,代码的可读性不升反降,这显然与Java引入该特性的初衷相悖。本文作者为现Oracle公司Principal Member of Technical Staff,在Sun、Oracle公司任职多年。由于译者水平有限,翻译错误在所难免,若发现错误还请及时指出。
引言
Java SE 10 引入了局部变量类型推断。在此之前,任何局部变量的声明都需要在语句左侧给定一个显式类型(explicit type)。而引入局部变量类型推断后,含初始值的局部变量声明时所需的显式类型,就可以被保留类型名(reserved type name)var替换。该变量的类型将会根据其初始值的类型推导而得。
此特征引起了一定的争议。一部分人乐于接受其给代码带来的整洁,而另一部分人则担心它的引入会让代码的阅读者失去重要的类型信息,从而降低代码的可读性。这两种观点都是正确的。局部变量类型推断在消除冗余信息,使代码更具可读性的同时,也去除了一些有用的信息,反而降低了代码的可读性。此外,还有一些人担心这个特性会被滥用,以至于人们编写出更多差的Java代码。这也是正确的,不过此特性的加入也可能使人们编写出更多好的Java代码。和所有特性一样,局部变量类型推断的使用也需要加以判断。当然,它的使用与否并没有一个通用的规则。
事实上,局部变量的声明并不会单独存在。而它周围的代码一般都会起到提示作用,甚至于弥补使用var带来的信息缺失。这个文档的目的是考察周围代码对var声明的影响,说明一些使用var时需要的权衡取舍,并提供有效使用var的编码规范。
准则
P1. 阅读代码比书写代码更加重要
代码的阅读总是比书写更加频繁。进一步的讲,当编写代码时,我们的脑海中通常会有关于代码的完整上下文,而且一般有充裕的时间;当阅读代码时,我们通常需要在脑海中切换到另一个上下文,而且一般时间紧迫。所以一个特定语言特性的使用与否与方式,比起取决于编码者,更应取决于它对程序未来阅读者的影响。较之长的程序,编写短的程序更为可取。但是对程序的过度简化,又会导致一些有助于理解程序的信息的缺失。所以,核心的问题是找到使程序最易理解的适当的代码长度。
我们并不关心怎么编写程序可以使打字次数更少。虽然此特性给予编码者简化代码的便利,但过于关注这一点会使我们偏离提升代码可理解性的本意。
P2. 代码应能仅通过局部的推理理解
通过阅读var声明和变量的使用,代码的阅读者应该能立刻理解这个变量的意义。理想状况下,只通过代码片段和补丁(patch)中的上下文就应该可以轻松的理解这段代码。如果理解某处的var声明需要读者查看代码的多个不同位置,那么此处就不应使用var。而且,这也意味着代码本身可能存在一些问题。
P3. 代码的可读性不应依赖IDE
我们通常使用IDE来编写、阅读代码,所以我们很容易过度依赖IDE的代码分析功能。那么对于类型推导,在可以通过IDE而简单确定一个变量类型的情况下,我们为何不在任何地方都使用var呢?
有两个原因。其一是我们经常需要在不便使用IDE的情况下阅读代码。很多时候,代码出现之处并不能使用IDE,比如文档中的代码片段,在网上浏览的仓库,或者是在补丁文件中。若仅仅是为了理解这些代码的意义就需要将其导入到IDE中,那就得不偿失了。
其二是即便使用IDE,在向IDE查询关于变量的更多信息时,一个显式操作也通常难以避免。例如,要查询一个用var声明的变量的类型,我们可能需要把鼠标移到变量上并等待一个提示框出现。即便只需要片刻,但是这样的等待也会影响代码阅读的连贯性。
说到底,代码本身就应该可被理解,而这并不需要借助其他工具。
P4. 显式声明类型需要权衡利弊
Java过去要求局部变量声明时必须显式指定类型。尽管显式类型可能对理解有很大帮助,但有时它们并不是很重要,甚至可能影响代码的阅读。要求显式类型有时会使代码变得混乱,以致重要信息的空间被挤占,变得难以查找。
此时,如果省略显式类型不会降低代码的可理解性,那么省略它就可以减少混乱。类型并不是向读者传递信息的唯一途径。诸如变量名称或初始值的表达式都能起到类似的作用。在判断是否可以阻断其中一种途径时,我们应该考虑所有其他的途径。
编码规范
G1. 选择能提供有效信息的变量名
即使抛开局部变量推断,这也是一个好的编程习惯,而它在var的情境下又显得尤为重要。在var声明中,我们可以使用变量名称来传递有关变量含义和用法的信息。在使用var替换显式类型的时候,通常,我们应该同时改进变量名称。例如:
// 原始 List<Customer> x = dbconn.executeQuery(query); // 正例 var custList = dbconn.executeQuery(query);
如同上述情况,我们得以将一个无意义的变量名替换成一个能体现变量类型的变量名,这就使得变量类型得以体现在var声明中。
在变量名称中加入变量类型和其逻辑意义通常会演变为“匈牙利命名法”。如同显式类型,匈牙利命名法时而有利,时而也会使代码显得凌乱。在这个例子中,变量名custList表明了初始化语句将返回一个List。事实上,表达返回值的类型有时并不是很重要。比起表达具体的类型,有时选择一个能表达变量用途或性质的名称会更重要,比如“customers”:
// 原始 try (Stream<Customer> result = dbconn.executeQuery(query)) { return result.map(...) .filter(...) .findAny(); } // 正例 try (var customers = dbconn.executeQuery(query)) { return customers.map(...) .filter(...) .findAny(); }
G2. 最小化局部变量的作用域
抛开局部变量推断讲,限制局部变量的作用域也同样是一个好的编程习惯。Effective Java (第三版)第57条(译者注:第二版为第45条)就提及了这一习惯。同样,在使用var时,它也会发挥更多的作用。
在下面的例子中,add方法将一个特殊项添加为列表的最后一项,所以和预期相同,它将于最后处理。
var items = new ArrayList<Item>(...); items.add(MUST_BE_PROCESSED_LAST); for (var item : items) ...
现在,试想程序员为了删除重复项而将代码中的ArrayList修改为HashSet:
var items = new HashSet<Item>(...); items.add(MUST_BE_PROCESSED_LAST); for (var item : items) ...
因为集合并没有定义迭代顺序,所以这段代码现在有BUG了。不过,程序员一般会立刻修复这个BUG。因为items变量的定义和使用相邻,所以这个BUG并不难发现。
现在,试想这段代码实际上是一个大方法的一部分,而items也有相应的非常大的作用域:
var items = new HashSet<Item>(...); // ... 100 行代码 ... items.add(MUST_BE_PROCESSED_LAST); for (var item : items) ...
因为变量items的使用距离它的声明非常远,所以由ArrayList变更为HashSet的影响并没有那么容易看出来。而这个BUG也很可能因此存在更长一段时间。
如果变量iteams被显式的声明为List<String>类型,那么在改变初始值的同时,我们也需要把它的类型改为Set<String>。这可能会促使编码者检查方法的其余部分,以找到可能受此类更改影响的代码。(当然,编码者也可能不会意识到)若使用var,编码者就缺少了这种提醒,而这也就增加了在这种代码中出现BUG的风险。
这看起来是在反对使用var,不过其实并不是这样。例如,第一个例子中var的使用就非常恰当。只有当变量的作用域非常大的时候,上述var声明的弊端才会出现。比起全盘拒绝使用var,减少变量作用域的大小之后再使用var才是更明智的选择。
G3. 当初始值能提供足够的信息时考虑使用var
局部变量的初始化通常伴随着其构造方法的调用,而左侧的显式类型通常会重复被构造类的名称。若类型的名称很长,那么使用var不仅能简化代码,也不会损失任何信息:
// 原始 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); // 正例 var outputStream = new ByteArrayOutputStream();
同样,对于初始值是方法返回值(例如静态工厂方法)的变量,如果方法名称能提供足够多的信息,那么使用var也是十分合理的:
// 原始 BufferedReader reader = Files.newBufferedReader(...); List<String> stringList = List.of("a", "b", "c"); // 正例 var reader = Files.newBufferedReader(...); var stringList = List.of("a", "b", "c");
在这种情况下,方法名称明显的说明了其返回值的类型,而这正可以给变量的类型推断提供足够的信息。
G4. 通过var引入局部变量以拆分链式表达式与嵌套表达式
试想编写一段从一个集合中找出出现次数最多的字符串的代码。你可能会编写如下的代码:
return strings.stream() .collect(groupingBy(s -> s, counting())) .entrySet() .stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey);
虽然这段代码是正确的,但是这段代码很容易使读者困惑,因为链式调用使其看上去像是只使用了一次流水线(stream pipeline)。事实上,这里首先是一个短的流,之后是一个作用于之前流的流,最后是对第二个流Optional结果的映射(mapping)。为了将这段代码改写的更易理解,我们需要将其拆分为两或三个语句——首先将集合转化为一个Map,之后筛选这个Map,最后再将键取出并返回(如果存在):
Map<String, Long> freqMap = strings.stream() .collect(groupingBy(s -> s, counting())); Optional<Map.Entry<String, Long>> maxEntryOpt = freqMap.entrySet() .stream() .max(Map.Entry.comparingByValue()); return maxEntryOpt.map(Map.Entry::getKey);
不过,因为中间变量的类型实在是太长了,所以编程者很可能不会去拆分,而是相反的将两个流写在一起。使用var就可以使我们在避免写出中间变量冗长的显式类型的情况下,写出跟加自然的代码:
var freqMap = strings.stream() .collect(groupingBy(s -> s, counting())); var maxEntryOpt = freqMap.entrySet() .stream() .max(Map.Entry.comparingByValue()); return maxEntryOpt.map(Map.Entry::getKey);
你可能会更喜欢第一种仅运用一个长长的方法调用链的写法,这当然使合理的。不过,有时把方法调用链拆开编写要更好。而此时使用var就是一个不错的办法,因为如同第二段代码,其中间变量的类型实在是太冗长了。正确使用var带来的影响如同那句谚语:上帝关上了一扇门(删去了显式类型),但是打开了一扇窗(带来了更好的变量名和代码结构)。
G5. 不要过度关心局部变量的接口编程
Java编程中一个很普遍的习惯是,构造一个具体类型的实例,但是将其赋值给一个接口类型的变量。这使代码得以与抽象而不是具体实现相关联,从而给未来代码的维护保留了灵活性。例如:
// 原始 List<String> list = new ArrayList<>();
然而,在使用var时,变量类型将会被推断为具体类型:
// 推导变量list的类型为ArrayList<String>. var list = new ArrayList<String>();
这里我们必须重申var只能作用于局部变量的前提。它不能被用于推断字段类型、方法参数类型和方法返回类型。而接口编程的原则于这些情景依旧非常重要。
主要的问题是,使用var声明变量会使代码形成对那些具体实现的依赖。若使用var声明变量,那么在未来更改变量的初始值时,变量的推导类型的变更就可能使后续使用该变量的代码产生错误或者BUG。
如果遵循G2而局部变量的作用域较小的话,那么具体实现“泄露”到后续代码的风险就会得到限制。如果变量的使用仅仅距声明数行,那避免问题的出现与出现问题之后的修正也会容易许多。
更何况在上述情况下,ArrayList仅仅包含少数List没有的方法,即ensureCapacity和trimToSize。这些方法并不会影响列表的内容,所以调用它们也不会影响程序的正确性。这进一步减少了变量推断为具体类型而不是接口的影响。
G6. 在钻石操作符和泛型方法上使用var需要注意
var和钻石操作符都可被用于在类型信息已存在的情况下,推断出变量的具体类型。那么,是否能在一个声明中同时使用它们呢?考虑如下代码:
PriorityQueue<Item> itemQueue = new PriorityQueue<Item>();
使用钻石操作符或var都可以在不丢失类型信息的情况下,改写这段代码:
// 正例:都声明了类型为PriorityQueue<Item>的变量 PriorityQueue<Item> itemQueue = new PriorityQueue<>(); var itemQueue = new PriorityQueue<Item>();
同时使用var和钻石操作符使合法的,不过推断得到的类型也会随之变更:
// 危险:变量类型推断为PriorityQueue<Object> var itemQueue = new PriorityQueue<>();
推导时,钻石操作符会使用目标类型(通常处于声明语句的左侧)或构造方法参数的类型。如果这两者都不存在,那么变量就会被推导为最广泛适用的类型(通常为Object)。而一般来讲,这并不是编码者想要的。
应用在泛型方法上的类型推断是十分成功的,甚至编码者很少需要提供一个显式类型作为参数。在没有传入能提供足够类型信息的实参时,泛型方法返回类型的推断将会依赖于其目标类型。然而在var声明中,目标类型并不存在,所以和使用钻石操作符时类似的问题同样存在。例如:
// 危险:变量类型推断为List<Object> var list = List.of();
使用钻石操作符和泛型方法时,构造方法或方法的实参能提供额外的类型信息,从而使程序推断出预期的变量类型。因此,如下用法是正确的:
// 正例:变量itemQueue的类型推断为PriorityQueue<String> Comparator<String> comp = ... ; var itemQueue = new PriorityQueue<>(comp); // 正例:变量类型推断为List<BigInteger> var list = List.of(BigInteger.ZERO);
如果你打算在使用var的同时使用钻石操作符或泛型方法,那么你需要确保传入方法或构造方法的参数足以提供足够的类型信息,从而能使推断出的类型符合你的预期。否则,就需要避免在声明中同时使用var和钻石操作符或泛型方法。
G7. 在常量上使用var需要注意
在var声明中,基本类型的常量也同样可以用于初始化变量。然而,由于它们的类型名并不是很长,所以使用var并不会带来很大的优势。不过,有时var也是有用的,比如对齐变量名。
于布尔常量、字符常量、长整型常量和字符串常量使用var并没有问题。在常量上的类型推导是很精确的,因此var声明的含义非常明确:
// 原始 boolean ready = true; char ch = '\ufffd'; long sum = 0L; String label = "wombat"; // 正例 var ready = true; var ch = '\ufffd'; var sum = 0L; var label = "wombat";
不过当初始值是数值,尤其是整数常量时,我们应该特别小心。当左侧提供了显式类型时,整型常量将会被隐式的放大或缩小为int之外的类型。但是当使用var时,变量类型就会被推断为int,这可能与预期相悖。
// 原始 byte flags = 0; short mask = 0x7fff; long base = 17; // 危险:变量类型都将被推断为int var flags = 0; var mask = 0x7fff; var base = 17;
不过,浮点常量的含义倒是基本准确:
// 原始 float f = 1.0f; double d = 2.0; // 正例 var f = 1.0f; var d = 2.0;
注意,float常量也可能会被隐式的扩大为double类型。当然,用显式指定float的常量(比如3.0f)初始化double变量有点傻,不过使用float字段初始化double变量是有可能的。而在这种情况下使用var时应当注意:
// 原始 static final float INITIAL = 3.0f; ... double temp = INITIAL; // 危险:变量类型推断为float var temp = INITIAL;
(事实上,由于初始值没能提供足够供读者获得变量推断类型的信息,这个示例还违背了规范G3)
示例
这一小节提供了一些使用var能获得最大效益的例子。
以下代码实现了从Map中删除最符合的最多max项。该方法使用了通配类型限制以提升灵活性,而代码也随之变得冗长。更糟的是,这还使Iterator的类型变为嵌套的通配符,从而导致其声明更加冗长。这个声明长到for循环的头部甚至超过了一行,从而使得代码更加难以阅读。
// 原始 void removeMatches(Map<? extends String, ? extends Number> map, int max) { for (Iterator<? extends Map.Entry<? extends String, ? extends Number>> iterator = map.entrySet().iterator(); iterator.hasNext();) { Map.Entry<? extends String, ? extends Number> entry = iterator.next(); if (max > 0 && matches(entry)) { iterator.remove(); max--; } } }
在这里使用var可以免去局部变量冗余的类型声明。况且,在这种循环中,标出Iterator和Map.Entry的显式类型并没有太大的必要。而且,使用var还能使for的头部变短,使其仅需一行就可写下,进一步提升了可读性。
// 正例 void removeMatches(Map<? extends String, ? extends Number> map, int max) { for (var iterator = map.entrySet().iterator(); iterator.hasNext();) { var entry = iterator.next(); if (max > 0 && matches(entry)) { iterator.remove(); max--; } } }
试想编写使用try-with-resources语句来从套接字(socket)中读取一行文字的代码。由于网络和I/O接口采用了对象包装风格(object wrapping idiom),所以,我们必须将每个中间对象声明为资源变量,以便打开后续包装器的过程中出错时将其正确的关闭。传统的写法需要我们在变量声明的左侧重复与右侧相同的类型,于是代码就会变得很混乱:
// 原始 try (InputStream is = socket.getInputStream(); InputStreamReader isr = new InputStreamReader(is, charsetName); BufferedReader buf = new BufferedReader(isr)) { return buf.readLine(); }
而使用var可以显著改善这种情况:
// 正例 try (var inputStream = socket.getInputStream(); var reader = new InputStreamReader(inputStream, charsetName); var bufReader = new BufferedReader(reader)) { return bufReader.readLine(); }
结论
通过去除混乱的显式类型,在声明时使用var可以提升代码的质量,从而使更重要的信息脱颖而出。不过,不加选择的使用var也会使代码反而变得更难阅读。若能正确的使用,var语法有助于编写更高质的代码,并能在不影响可读性的情况下使代码更加整洁与简短。
References
JEP 286: Local-Variable Type Inference
Bloch, Joshua. Effective Java, 3rd Edition. Addison-Wesley Professional, 2018.
开坑的时间是:2018年7月11日 @ 22:16
就算我出去浪了九天,好像我的速度也有点慢了emmmmm
获得成就:佛系作者
感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/b1hit1 欢迎点赞支持!
使用开发者头条 App 搜索 69380 即可订阅《KAAAsS Blog》