写点什么

Java 戏法

2014 年 10 月 28 日

我们经常遇到这样的情况,有些代码的行为出乎意料。Java 语言有很多奇怪的地方,即使有经验的开发者也可能会感到意外。

老实说,经常有资历较浅的同事来问,“执行这段代码有什么样的结果?”,让人措手不及。“我可以告诉你,但是如果你自己找出答案,学到的会更多”,这是很常见的答复。现在可别这么说了,可以先吸引一下他的注意力(哦……我想我看到安吉丽娜·朱莉了,藏在我们的构建服务器后面呢,你可以快去看一下吗?),利用这个时间,快速过一下这篇文章吧。

本文将介绍一些 Java 的奇怪之处,以帮助开发者做好更充分的准备,使他们再遇到结果令人意外的代码时,能够很好地应对。

对于每个技巧,我们都会提供一些看似简单的代码,但是这段代码在编译时或运行时的行为就不那么直观了。表现如何,为什么会这样,我们会讲清楚背后的原理。这些例子的复杂性不同,有的非常简单,有的则很费脑细胞。

不可理喻的标识符

我们很熟悉定义合法的 Java 标识符的规则:

  • 一个标识符是由一个或多个字符(可以是字母、数字、$ 或下划线)组成的集合。
  • 标识符必须以字母、$ 或下划线开头。
  • Java 关键字不能用作标识符。
  • 标识符中的字符没有数量限制。
  • 也可以使用从\u00c0 到\ud7a3 之间的 Unicode 字符。

规则非常简单,但有些有趣的例子会让人惊讶。比如,开发者可以将类名用作标识符,这是没有限制的:

复制代码
// 类名可以用作标识符
String String = "String";
Object Object = null;
Integer Integer = new Integer(1);
// 让代码难以理解怎么样?
Float Double = 1.0f;
Double Float = 2.0d;
if (String instanceof String) {
if (Float instanceof Double) {
if (Double instanceof Float) {
System.out.print("Can anyone read this code???");
}
}
}

下面的标识符也都是合法的:

复制代码
int $ =1;
int € = 2;
int £ = 3;
int _ = 4;
long $€£ = 5;
long €_£_$ = 6;
long $€£$€£$€£$€£$€£$€£$€_________$€£$€£$€£$€£$€£$€£$€£$€£$€£_____ = 7;

此外,请记住,同样的名字可以同时用于变量和标签。通过分析上下文,编译器知道引用的是哪一个。

复制代码
int £ = 1;
£: for (int € = 0; € < £; €++) {
if (€ == £) {
break £;
}
}

当然,不要忘了标识符的规则可以应用于变量名、方法名、标签和类名:

复制代码
class $ {}
interface _ {}
class extends $ implements _ {}

所以我们学到了很厉害的一招,那就是可以编写没有人能理解的代码,包括我们自己!

NullPointerException 从何而来?

自动装箱是在 Java 5 中引入的,给我们带来了很多方便,我们不用在基本类型和其包装器类型之间跳来跳去了:

复制代码
int primitiveA = 1;
Integer wrapperA = primitiveA;
wrapperA++;
primitiveA = wrapperA;

运行时并没有为了支持这种变化而做修改,大部分工作都是编译时完成的。对于前面这段代码,编译器会生成类似下面这样的代码:

复制代码
int primitiveA = 1;
Integer wrapperA = new Integer(primitiveA);
int tmpPrimitiveA = wrapperA.intValue();
tmpPrimitiveA++;
wrapperA = new Integer(tmpPrimitiveA);
primitiveA = wrapperA.intValue();

前面的自动装箱也可以应用于方法调用:

复制代码
public static int calculate(int a) {
int result = a + 3;
return result;
}
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
System.out.println(calculate(i1));
System.out.println(calculate(i2));
}

真棒,对于以基本类型为参数的方法,我们可以向其传递相应的包装器类型,让编译器来执行变换:

复制代码
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
System.out.println(calculate(i1));
int i2Tmp = i2.intValue();
System.out.println(calculate(i2Tmp));
}

稍作修改,再来试试:

复制代码
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
Integer i3 = null;
System.out.println(calculate(i1));
System.out.println(calculate(i2));
System.out.println(calculate(i3));
}

和前面一样,这段代码会被翻译成:

复制代码
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
Integer i3 = null;
System.out.println(calculate(i1));
int i2Tmp = i2.intValue();
System.out.println(calculate(i2Tmp));
int i3Tmp = i3.intValue();
System.out.println(calculate(i3Tmp));
}

当然,这段代码会让我们看到老朋友 NullPointerException。像下面这种更简单的情况,同样如此:

复制代码
public static void main(String args[]) {
Integer iW = null;
int iP = iW;
}

所以在使用自动拆箱时一定要非常小心,它可能导致 NullPointerException;而在该特性引入之前,是不可能遇到此类异常的。更糟糕的是,识别这些代码模式有时并不容易。如果必须将一个包装器类型的变量转换成基本类型变量,而且不确定其是否可能为 null,那就要为代码做好保护措施。

包装器类型遭遇同一性危机

继续自动装箱这个话题,看一下下面的代码:

复制代码
Short s1 = 1;
Short s2 = s1;
System.out.println(s1 == s2);

当然打印 true 了。现在来点有趣的:

复制代码
Short s1 = 1;
Short s2 = s1;
s1++;
System.out.println(s1 == s2);

输出成了 false。等等,什么情况?难道 s1 和 s2 引用的不是同一个对象吗?JVM 真是疯了!还是用前面提到的代码翻译机制来看看吧:

复制代码
Short s1 = new Short((short)1);
Short s2 = s1;
short tempS1 = s1.shortValue();
tempS1++;
s1 = new Short(tempS1);
System.out.println(s1 == s2);

哦……这么看是更合理了,不是吗?使用自动装箱的时候总得小心!

妈妈快看,没有异常!

下面这个非常简单,但是很多有经验的 Java 开发者都会中招。闲话少说,看代码:

复制代码
NullTest myNullTest = null;
System.out.println(myNullTest.getInt());

当看到这段代码时,很多人会以为会出现 NullPointerException。果真如此吗?看看其余代码再说:

复制代码
class NullTest {
public static int getInt() {
return 1;
}
}

永远记住,类变量和类方法的使用,仅仅依赖引用的类型。即使引用为 null,仍然可以调用。从良好实践的角度来看,明智的做法是使用 NullTest.getInt() 来代替 myNullTest.getInt(),但鬼知道什么时候会碰上这样的代码。

变长参数和数组,必要的变通

变长参数特性带来了一个强大的概念,可以帮助开发者简化代码。不过变长参数的背后是什么呢?不多不少,就是一个数组。

复制代码
public void calc(int... myInts) {}
calc(1, 2, 3);

编译器会将前面的代码翻译成类似这样:

复制代码
int[] ints = {1, 2, 3};
calc(ints);

空调用语句相当于传递了一个空数组。

复制代码
calc();
等价于
int[] ints = new int[0];
calc(ints);

当然,下面的代码会导致编译错误,因为两条语句是等价的:

复制代码
public void m1(int[] myInts) { ... }
public void m1(int... myInts) { ... }

可变的常量

大部分开发者认为,当变量定义中出现 final 关键字时,指示的就是一个常量,也就是说,这个变量的值不可改变。这并不完全正确,当 final 关键字应用于变量时,只是说明该变量只能赋值一次。

复制代码
class MyClass {
private final int myVar;
private int myOtherVar = getMyVar();
public MyClass() {
myVar = 10;
}
public int getMyVar() {
return myVar;
}
public int getMyOtherVar() {
return myOtherVar;
}
public static void main(String args[]) {
MyClass mc = new MyClass();
System.out.println(mc.getMyVar());
System.out.println(mc.getMyOtherVar());
}
}

前面的代码将打印 10 0。因此,在处理 final 变量时,必须区分两种情况:一种是在编译时就赋了默认值的,这种就是常量;另一种是在运行时初始化的。

覆盖的特色

请记住,从 Java 5 开始,覆盖方法的返回类型可以与被覆盖方法不同。唯一的规则是,覆盖方法的返回类型是被覆盖方法的返回类型的子类型。所以在 Java 5 中下面的代码成了合法的:

复制代码
class A {
public A m() {
return new A();
}
}
class B extends A {
public B m() {
return new B();
}
}

重载操作符

就操作符重载而言,Java 不是特别强,但它确实支持 + 操作符的重载。该操作符可以用于算术加法和字符串连接,具体取决于上下文。

复制代码
int val = 1 + 2;
String txt = "1" + "2";

当字符串中混入了数值类型,事情就复杂了。但是规则很简单,在遇到字符串操作数之前,会一直执行算术加法。一出现字符串,两个操作数都会被转为字符串(如果需要的话),并执行一次字符串连接。下面例子说明了不同的组合:

复制代码
System.out.println(1 + 2); // 执行加法,打印 3
System.out.println("1" + "2"); // 执行字符串连接,打印 12
System.out.println(1 + 2 + 3 + "4" + 5); // 执行加法,直到发现 "4",然后执行字符串连接,打印 645
System.out.println("1" + "2" + "3" + 4 + 5); // 执行字符串连接,打印 12345

奇怪的日期格式

这个花招与 _DateFormat_ 的实现有关,其使用方式有一定的误导性,而且有的时候,代码到了产品中,问题才会暴露出来。

DateFormat 的 _parse_ 方法会解析一个字符串,并生成一个日期。解析过程是根据定义的日期格式掩码来工作的。根据 Java Doc ,如果指定的字符串的开头部分无法解析,会抛出一个 _ParseException_。这个定义很模糊,可以有不同的解释。大部分开发者认为,如果字符串参数与定义的格式不匹配,会抛出 _ParseException_。但情况并非总是如此。

对于 SimpleDateFormat,大家应该非常小心。当面对下面的代码时,大部分开发者认为会抛出 ParseException。

复制代码
String date = "16-07-2009";
SimpleDateFormat sdf = new SimpleDateFormat("ddmmyyyy");
try {
Date d = sdf.parse(date);
System.out.println(DateFormat.getDateInstance(DateFormat.MEDIUM,
new Locale("US")).format(d));
} catch (ParseException pe) {
System.out.println("Exception: " + pe.getMessage());
}

运行这段代码,会产生下列输出:Jan 16, 0007。真是奇怪,编译器竟然没有指出字符串与预期的格式不匹配,而是继续处理,而且尽其最大努力来解析文本。请注意,这里有两个隐藏的花招。其一,月份的掩码是 MM,而 mm 用于分钟,这就解释了为什么月份被设置成了一月。其二,_DecimalFormat_ 类的 _parse_ 方法将一直解析文本,直到遇到无法解析的字符,返回的是到目前这个位置已经处理过的数字。所以“7-20”将翻译成 7 年。这种差异很容易看出来,但如果使用的是“yyyymmdd”,情况就更复杂了,输出将是“Jan 7, 0016”。解析“16-0”,直到遇到第一个不可解析的字符,所以 16 会被当成年份。“-0”不会影响结果,它会被理解为 0 分钟。之后“7-”就被映射到天数了。

译者注:文中关于自动装箱的说明不够准确,像“Integer wrapperA = primitiveA;”这条语句,编译器的处理策略是将其映射为“Integer wrapperA = Integer.valueOf(primitive);”,Short 的处理类似。有兴趣的读者可以自行测试。

另外,对 Java 谜题感兴趣的读者可以阅读 Joshua Bloch 的《Java 解惑》一书,其中列出了很多容易出错的地方。

关于作者

Paulo Moreira是葡萄牙的一位自由软件工程师,目前在卢森堡的财政部门工作。他毕业于米尼奥大学,获得了计算机科学和系统工程的硕士学位。从 2001 年起,他一直使用 Java,从事服务器端的开发工作,涉及电信、零售、软件和金融市场等领域。

查看英文原文: Java Sleight of Hand

2014 年 10 月 28 日 08:327457
用户头像
臧秀涛 略懂技术的运营同学。

发布了 300 篇内容, 共 114.8 次阅读, 收获喜欢 21 次。

关注

评论

发布
暂无评论
发现更多内容

二十多岁的年纪是怎么成功四面字节跳动,最终拿到offer的?

Java架构之路

Java 程序员 架构 面试 编程语言

Java岗四面字节跳动成功之前,我都刷了那些面试题以及做了那些准备!

Java架构之路

Java 程序员 架构 面试 编程语言

架构大作业一

Geek_michael

极客大学架构师训练营

扫地阿姨看完都学会了!万字长文总结Android多进程,满满干货指导

欢喜学安卓

android 程序员 面试 移动开发

面试必问的 Redis:主从复制

Java架构师迁哥

用一把吃鸡的时间,免费上云搭建网站应用

华为云开发者社区

服务 建站

PostgreSQL 13 RPM中有哪些新功能?

PostgreSQLChina

数据库 postgresql 开源

在wildfly 21中搭建cluster集群

程序那些事

程序那些事 wildfly wildfly21 集群部署 集群架构

速来围观!阿里P8大牛写出的JDK源码剖析及大型网站技术架构与业务架构融合之道

Java架构之路

Java 程序员 架构 面试 编程语言

支持 gRPC 长链接,深度解读 Nacos 2.0 架构设计及新模型

阿里巴巴云原生

云计算 阿里云 开源 微服务 云原生

SpringBoot,来实现MySQL读写分离技术

Java架构师迁哥

CAP 原理 <笔记>

raox

极客大学架构师训练营

LeetCode题解:剑指 Offer 40. 最小的k个数,快速排序,JavaScript,详细注释

Lee Chen

算法 LeetCode 前端进阶训练营

Flash Player终将成为历史,HTML5正站在舞台的中央

Geek_Willie

云上可靠性测试:让我们一起给开发找点事儿

华为云开发者社区

安全 云服务 可靠性

K8S 资源可视化利器:Kubectl-Graph

郭旭东

Kubernetes Kubernetes Plugin

一个企业用电有多浪费?90后开发者大显身手,让每度电从此更“聪明”!

华为云开发者社区

AI 物联网 智慧园区

软件测试必须掌握的http网络协议知识

测试人生路

软件测试

Spring Cloud 2020.0.0 正式发布,对开发者来说意味着什么?

阿里巴巴云原生

阿里云 容器 开发者 云原生 架构师

测开之函数进阶· 第4篇《匿名函数》

清菡

测试开发

JAVA并发编程原理与实战

Geek_53983e

原理 java 并发 实战

Demo分享丨看ModelArts与HiLens是如何让车自己跑起来的

华为云开发者社区

人工智能 智能车 hilens

GitHub标星力推!我掏空了各大搜索引擎,给你整理了188道Java面试题,满满干货记得收藏

Java架构之路

Java 程序员 架构 面试 编程语言

Spring知识点总结!已整理成142页离线文档(源码笔记+思维导图)

Crud的程序员

spring 程序员

姐夫半夜不睡觉,竟躲在厕所看这“57道Redis面试题”?

Java架构之路

Java 程序员 架构 面试 编程语言

架构师训练营 - 大作业 2

阿甘

加密猫MIMI系统APP开发|加密猫MIMI软件开发

开發I852946OIIO

系统开发

2021 云原生走向何处?

云原生实验室

面试官:Android事件分发机制及设计思路,跳槽薪资翻倍

欢喜学安卓

android 程序员 面试 移动开发

7. JDK拍了拍你:字符串拼接一定记得用MessageFormat#format

YourBatman

Spring Framework 类型转换 MessageFormat DateFormat

架构师训练营 - 大作业1

阿甘

微服务架构下如何保证事务的一致性

微服务架构下如何保证事务的一致性

Java戏法-InfoQ