首页 » Java程序员修炼之道 » Java程序员修炼之道全文在线阅读

《Java程序员修炼之道》1.3 Coin项目中的修改

关灯直达底部

Coin项目主要给Java 7引入了6个新特性,它们分别是switch语句中的String、数字常量的新形式、改进的异常处理、try-with-resources、钻石语法,还有变参警告位置的修改。

我们会详细讲解Coin项目中的这些变化,讨论这些新特性的语法和含义,并尽可能解释推出这些特性背后的动机。当然,我们也不是要把提案全部照搬过来,coin-dev邮件列表的归档里有完整的提案,如果你是一个好奇的语言设计师,可以去那里看看,还可以和大家讨论你的想法。

闲言少叙,开始介绍第一个Java 7新特性——switch语句中的String值。

1.3.1 switch语句中的String

switch语句是一种高效的多路语句,可以省掉很多繁杂的嵌套if判断,比如像这样:

public void printDay(int dayOfWeek) {  switch (dayOfWeek) {    case 0: System.out.println("Sunday"); break;    case 1: System.out.println("Monday"); break;    case 2: System.out.println("Tuesday"); break;    case 3: System.out.println("Wednesday"); break;    case 4: System.out.println("Thursday"); break;    case 5: System.out.println("Friday"); break;    case 6: System.out.println("Saturday"); break;    default: System.err.println("Error!"); break;  }}  

在Java 6及之前,case语句中的常量只能是bytecharshortint(也可以是对应的封装类型 ByteCharacterShortInteger)或枚举常量。Java 7规范中增加了String,毕竟它也是常量类型。

public void printDay(String dayOfWeek) {  switch (dayOfWeek) {    case "Sunday": System.out.println("Dimanche"); break;    case "Monday": System.out.println("Lundi"); break;    case "Tuesday": System.out.println("Mardi"); break;    case "Wednesday": System.out.println("Mercredi"); break;    case "Thursday": System.out.println("Jeudi"); break;    case "Friday": System.out.println("Vendredi"); break;    case "Saturday": System.out.println("Samedi"); break;    default: System.out.println("Error: '"+ dayOfWeek +"' is not a day of the week"); break;  }}  

除此之外,switch语句和以前完全一样。像Coin项目中的许多新特性一样,这不过是一个让你更轻松的小小改进。

1.3.2 更强的数值文本表示法

当时有几个与整型语法相关的提案,最终被选中的是下面这两个:

  • 数字常量(如基本类型中的integer)可以用二进制文本表示;
  • 在整型常量中可以使用下划线来提高可读性。

这两个改变乍看起来都不起眼,但它们确实解决了一直困扰着Java程序员的一些小麻烦。

这两个新特性对系统底层程序员,就是那些整天处理原始网络协议、加密或沉迷于摆弄比特的人们特别有用。先来看一下二进制文本。

1.二进制文本

在Java 7之前,如果要处理二进制值,就必须借助棘手(又容易出错)的基础转换,或者调用parseX方法。比如说,如果想让int x用位模式表示十进制值102,你可以这样写:

int x = Integer.parseInt("1100110", 2);    

为了确保x是正确的位模式,你需要敲许多代码。这种方式尽管看起来还行,但实际上存在很多问题:

  • 十分繁琐;
  • 方法调用对性能有影响;
  • 需要知道parseInt的双参形式;
  • 需要记住双参的parseInt的处理细节;
  • JIT编译器更难实现;
  • 用运行时的表达式表示应该在编译时确定的常量,导致x不能用在switch语句中;
  • 如果在位模式中有拼写错误(能通过编译),会在运行时抛出RuntimeException

现在好了,用Java 7可以写成:

int x = 0b1100110;  

我们没说这种方法无所不能,但它确实解决了上面提到的那些问题。

你在跟二进制打交道时,这个小特性会是你的得力助手。比如在处理字节时,可以在switch语句中使用由位模式定义的二进制常量。

另外一个新特性虽然小,但却很实用——可以在表示一组二进制位或其他长数值的数字中加入下划线。

2.数字中的下划线

众所周知,人脑和电脑有很多不同的地方,对于数字的处理方式就是其中之一。通常人们都不太喜欢面对一大串数字。这也是我们发明十进制的原因之一——因为人脑更擅于处理信息量大的短字串,而不是每个字符信息量都不太多的长字串。

也就是说,我们觉得1c372ba3要比00011100001101110010101110100011更容易处理,但电脑只认第二种。人们在处理长串数字时会采用分隔法,比如用404-555-0122表示电话号码。

注意 如果你跟作者(欧洲人)一样,想知道为什么美国电影或书里的电话号码总是以555开头,我可以告诉你。555-01xx是保留号段,用于虚构的情境。这是为了避免现实生活中的人接到那些对好莱坞电影过分投入的人打来的电话。

其他带有分隔符的一长串数字:

  • 100 000 000美元(一大笔钱);
  • 08-92-96(英国银行的排序代码)。

可在代码中处理数字时不能用逗号(,)和连字符(-)作分隔符,因为它们可能会引发歧义。Coin项目中的提案借用了Ruby的创意,用下划线(_)作分隔符。注意,这只是为了让你阅读数字时更容易理解而做的一个小修改,编译器会在编译时把这些下划线去掉,只保留原始数字。

也就是说,为了不把100 000 000 和10 000 000搞混,你可以在代码中将100 000 000写成100_000_000,以便很容易区分它和10_000_000的差别。来看下面两个例子,至少你应该对其中一个比较熟悉:

long anotherLong = 2_147_483_648L;  int bitPattern = 0b0001_1100__0011_0111__0010_1011__1010_0011;  

注意:赋给anotherLong的数值现在看起来清楚多了。

警告 在Java中可以用小写字母l表示长整型数值,比如1010100l。但最好还是用大写字母L,以免维护代码的人把数字1和字母l搞混:1010100L看起来要清楚得多。

现在你应该清楚这些变化给整数处理带来的好处了!让我们继续前进,去看看Java 7中的异常处理。

1.3.3 改善后的异常处理

异常处理有两处改进——multicatch和final重抛。要知道它们对我们有什么帮助,请先看一段Java 6代码。下面这段代码试图查找、打开、分析配置文件并处理此过程中可能出现的各种异常:

代码清单1-1 在Java 6中处理不同的异常

public Configuration getConfig(String fileName) {  Configuration cfg = null;  try {    String fileText = getFile(fileName);    cfg = verifyConfig(parseConfig(fileText));  } catch (FileNotFoundException fnfx) {    System.err.println("Config file '" + fileName + "' is missing");  } catch (IOException e) {    System.err.println("Error while processing file '" + fileName + "'");  } catch (ConfigurationException e) {    System.err.println("Config file '" + fileName + "' is not consistent");  } catch (ParseException e) {    System.err.println("Config file '" + fileName + "' is malformed");  }  return cfg;}  

这个方法会遇到的下面几种异常:

  • 配置文件不存在;
  • 配置文件在正要读取时消失了;
  • 配置文件中有语法错误;
  • 配置文件中可能包含无效信息。

这些异常可以分为两大类。一类是文件以某种方式丢失或损坏,另一类是虽然文件理论上存在并且是正确的,却无法正常读取(可能是因为网络或硬件故障)。

如果能把这些异常情况简化为这两类,并且把所有“文件以某种方式丢失或损坏”的异常放在一个catch语句中处理会更好。在Java 7中就可以做到:

代码清单1-2 在Java 7中处理不同的异常

public Configuration getConfig(String fileName) {  Configuration cfg = null;  try {    String fileText = getFile(fileName);    cfg = verifyConfig(parseConfig(fileText));  } catch (FileNotFoundException|ParseException|ConfigurationException e) {    System.err.println("Config file '" + fileName +                       "' is missing or malformed");  } catch (IOException iox) {    System.err.println("Error while processing file '" + fileName + "'");  }  return cfg;}  

异常e的确切类型在编译时还无法得知。这意味着在catch块中只能把它当做可能异常的共同父类(在实际编码时经常用ExceptionThrowable)来处理。

另外一个新语法可以为重新抛出异常提供帮助。开发人员经常要在重新抛出异常之前对它进行处理。在前几个版本的Java中,经常可以看到下面这种代码:

try {  doSomethingWhichMightThrowIOException;  doSomethingElseWhichMightThrowSQLException;} catch (Exception e) {  ...  throw e;}  

这会强迫你把新抛出的异常声明为Exception类型——异常的真实类型却被覆盖了。

不管怎样,很容易看出来异常只能是IOExceptionSQLException。既然你能看出来,编译器当然也能。下面的代码中用了Java 7的语法,只改了一个单词:

try {  doSomethingWhichMightThrowIOException;  doSomethingElseWhichMightThrowSQLException;} catch (final Exception e) {  ...  throw e;}  

关键字final表明实际抛出的异常就是运行时遇到的异常——在上面的代码中就是IOExceptionSQLException。这被称为final重抛,这样就不会抛出笼统的异常类型,从而避免在上层只能用笼统的catch捕获。

上例中的关键字final不是必需的,但实际上,在向catch和重抛语义调整的过渡阶段,留着它可以给你提个醒。

Java 7对异常处理的改进不仅限于这些通用问题,对于特定的资源管理也有所提升,我们马上就会讲到。

1.3.4 Try-with-resources(TWR)

这个修改说起来容易,但其实暗藏玄机,最终证明做起来比最初预想的要难。其基本设想是把资源(比如文件或类似的东西)的作用域限定在代码块内,当程序离开这个代码块时,资源会被自动关闭。

这是一项非常重要的改进,因为没人能在手动关闭资源时做到100%正确,甚至不久前Sun提供的操作指南都是错的。在向Coin项目提交这一提案时,提交者宣称JDK中有三分之二的close用法都有bug,真是不可思议!

好在编译器可以生成这种学究化、公式化且手工编写易犯错的代码,所以Java 7借助了编译器来实现这项改进。

这可以减少我们编写错误代码的几率。相比之下,想想你用Java 6写段代码,要从一个URL(url)中读取字节流,并把读取到的内容写入到文件(out)中,这么做很容易产生错误。代码清单1-3是可行方案之一。

代码清单1-3 Java 6中的资源管理语法

InputStream is = null;try {  is = url.openStream;  OutputStream out = new FileOutputStream(file);  try {    byte buf = new byte[4096];    int len;    while ((len = is.read(buf)) >= 0)      out.write(buf, 0, len);   } catch (IOException iox) {               // 处理异常(能读或写)   } finally {     try {       out.close;      } catch (IOException closeOutx) {      // 遇到异常也做不了什么      }   } } catch (FileNotFoundException fnfx) {      // 处理异常 } catch (IOException openx) {               // 处理异常 } finally {    try {      if (is != null) is.close;    } catch (IOException closeInx) {         // 遇到异常也做不了什么    } }  

看明白了吗?重点是在处理外部资源时,墨菲定律(任何事都可能出错)一定会生效,比如:

  • URL中的InputStream无法打开,不能读取或无法正常关闭;
  • OutputStream对应的File无法打开,无法写入或不能正常关闭;
  • 上面的问题同时出现。

最后一种情况是最让人头疼的——异常的各种组合拳打出来令人难以招架。

新语法能大大减少错误发生的可能性,这正是它受欢迎的主要原因。编译器不会犯开发人员编写代码时易犯的错误。

让我们看看代码清单1-3中的代码用Java 7写出来什么样。和前面一样,url是一个指向下载目标文件的URL对象,file是一个保存下载数据的File对象。

代码清单1-4 Java 7中的资源管理语法

try (OutputStream out = new FileOutputStream(file);     InputStream is = url.openStream ) {  byte buf = new byte[4096];  int  len;  while ((len = is.read(buf)) > 0) {    out.write(buf, 0, len);  }}  

这是资源自动化管理代码块的基本形式——把资源放在try的圆括号内。C#程序员看到这个也许会觉得有点眼熟,是的,它的确很像C#中的从句,带着这种理解使用这个新特性是个不错的起点。在这段代码块中使用的资源在处理完成后会自动关闭。

但使用try-with-resources特性时还是要小心,因为在某些情况下资源可能无法关闭。比如在下面的代码中,如果从文件(someFile.bin)创建ObjectInputStream时出错,FileInputStream可能就无法正确关闭。

try ( ObjectInputStream in = new ObjectInputStream(new      FileInputStream("someFile.bin")) ) {  ...}  

假定文件(someFile.bin)存在,但可能不是ObjectInput类型的文件,所以文件无法正确打开。因此不能构建ObjectInputStream,所以FileInputStream也没办法关闭。

要确保try-with-resources生效,正确的用法是为各个资源声明独立变量。

try ( FileInputStream fin = new FileInputStream("someFile.bin");          ObjectInputStream in = new ObjectInputStream(fin) ) {    ...}  

TWR的另一个好处是改善了错误跟踪的能力,能够更准确地跟踪堆栈中的异常。在Java 7之前,处理资源时抛出的异常信息经常会被覆盖。TWR中可能也会出现这种情况,因此Java 7对跟踪堆栈进行了改进,现在开发人员能看到可能会丢失的异常类型信息。

比如在下面这段代码中,有一个返回InputStream的值为null的方法:

 try(InputStream i = getNullStream) {   i.available;}  

在改进后的跟踪堆栈中能看到提示,注意其中被抑制的NullPointerException(简称NPE):

 Exception in thread "main" java.lang.NullPointerException   at wgjd.ch01.ScratchSuprExcep.run(ScratchSuprExcep.java:23)  at wgjd.ch01.ScratchSuprExcep.main(ScratchSuprExcep.java:39)  Suppressed:java.lang.NullPointerException   at wgjd.ch01.ScratchSuprExcep.run(ScratchSuprExcep.java:24)       1 more  

TWR与AutoCloseable

目前TWR特性依靠一个新定义的接口实现AutoCloseable。TWR的try从句中出现的资源类都必须实现这个接口。Java 7平台中的大多数资源类都被修改过,已经实现了AutoCloseable(Java 7中还定义了其父接口Closeable),但并不是全部资源相关的类都采用了这项新技术。不过,JDBC 4.1已经具备了这个特性。

然而在你自己的代码里,在需要处理资源时一定要用TWR,从而避免在异常处理时出现bug。

希望你能尽快使用try-with-resources,把那些多余的bug从代码库中赶走。

1.3.5 钻石语法

针对创建泛型定义和实例太过繁琐的问题,Java 7做了一项改进,以减少处理泛型时敲键盘的次数。比如你用userid(整型值)标识一些user对象,每个user都对应一个或多个查找表1。这用代码应该如何表示呢?

1 一种为提高处理速度而用查询取代计算的处理机制。一般是将事先计算好的结果存在数组或映射中,然后在需要该结果时直接读取,比如用三角表查某一角度的正弦值。——译者注

Map<Integer, Map<String, String>> usersLists =        new HashMap<Integer, Map<String, String>>;    

这简直太长了,并且几乎一半字符都是重复的。如果能写成

Map<Integer, Map<String, String>> usersLists = new HashMap<>;  

让编译器推断出右侧的类型信息是不是更好?神奇的Coin项目满足了你这个心愿。在Java 7中,像这样的声明缩写完全合法,还可以向后兼容,所以当你需要处理以前的代码时,可以把过去比较繁琐的声明去掉,使用新的类型推断语法,这样可以省出点儿空间来。

编译器为这个特性采用了新的类型推断形式。它能推断出表达式右侧的正确类型,而不是仅仅替换成定义完整类型的文本。

为什么叫“钻石语法”

把它称为”钻石语法”是因为这种类型信息看起来像钻石。原来提案中的名字是“为泛型实例创建而做的类型推断改进”(Improved Type Inference for Generic Instance Creation)。这个名字太长,可缩写ITIGIC听上去又很傻,所以干脆就叫钻石语法了。

新的钻石语法肯定会让你少写些代码。我们最后还要探讨Coin项目中的一个特性——使用变参时的警告信息。

1.3.6 简化变参方法调用

这是所有修改里最简单的一个,只是去掉了方法签名中同时出现变参和泛型时才会出现的类型警告信息。

换句话说,除非你写代码时习惯使用类型为T的不定数量参数,并且要用它们创建集合,否则你就可以进入下一节了。如果你想要写下面这种代码,那就继续阅读本节:

public static <T> Collection<T> doSomething(T... entries) {  ...}  

还在?很好。这到底是怎么回事?

变参方法是指参数列表末尾是数量不定但类型相同的参数方法。但你可能还不知道变参方法是如何实现的。基本上,所有出现在末尾的变参都会被放到一个数组中(由编译器自动创建),并作为一个参数传入。

这是个好主意,但是存在一个公认的Java泛型缺陷——不允许创建已知类型的泛型数组。比如下面这段代码,编译就无法通过:

HashMap<String, String> arrayHm = new HashMap<>[2];    

不可以创建特定泛型的数组,只能这样写:

HashMap<String, String> warnHm = new HashMap[2];  

可这样编译器会给出一个只能忽略的警告。你可以将warnHm的类型定义为HashMap<String,String>数组,但不能创建这个类型的实例,所以你不得不硬着头皮(或至少忘掉警告)硬生生地把原始类型(HashMap数组)的实例塞给warnHm

这两个特性(编译时生成数组的变参方法和已知泛型数组不能是可实例化类型)碰到一起时,会令人有点头疼。看看下面这段代码:

HashMap<String, String> hm1 = new HashMap<>;HashMap<String, String> hm2 = new HashMapCollection<HashMap<String, String>> coll = doSomething(hm1,hm2);  

编译器会尝试创建一个包含hm1hm2的数组,但这种类型的数组应该是被严格禁止使用的。面对这种进退两难的局面,编译器只好违心地创建一个本来不应出现的泛型数组实例,但它又觉得自己不能保持沉默,所以还得嘟囔着警告你这是“未经检查或不安全的操作”。

从类型系统的角度看,这非常合理。但可怜的开发人员本想使用一个十分靠谱的API,一看到这些吓人的警告,却得不到任何解释,不免会内心忐忑。

1.Java 7中的警告去了哪里

Java 7的这个新特性改变了警告的对象。构建这些类型毕竟有破坏类型安全的风险,这总得有人知道。但 API 的用户对此是无能为力的,不管doSomething是不是干了坏事,破坏了类型安全,都不在API用户的控制范围之内。

真正需要看到这个警告信息的是写doSomething的人,即API的创建者,而不是使用者。所以Java 7把警告信息从使用API的地方挪到了定义API的地方。

过去是在编译使用API的代码时触发警告,而现在是在编译这种可能会破坏类型安全的API时触发。编译器会警告创建这种API的程序员,让他注意类型系统的安全。

为了减轻API开发人员的负担,Java 7还提供了一个新注解java.lang.SafeVarargs。把这个注解应用到API方法或构造方法之中,则会产生类型警告。通过用@SafeVarargs对这种方法进行注解,开发人员就不会在里面进行任何危险的操作,在这种情况下,编译器就不会再发出警告了。

2.类型系统的修改

虽然把警告信息从一个地方挪到另一个地方不是改变游戏规则的语言特性,但也证明了我们之前提到的观点——Coin项目曾奉劝诸位贡献者远离类型系统,因为把这么一个小变化讲清楚要大费周章。这个例子表明搞清楚类型系统不同特性之间如何交互是多么费心费力,而且对语言的修改被实现后又会怎么影响这种交互。这还不是特别复杂的修改,更大的变动所涉及的内容还会更多,其中还包括大量微妙的分支。

最后这个例子阐明了由小变化引发的错综复杂的影响。我们对Coin项目中改进的讨论也结束了。尽管它们几乎全都是语法上的小变化,但跟实现它们的代码量相比,它们所带来的正面影响还是很可观的。一旦开始使用,你就会发现这些特性对程序真的很有帮助!