第 2 章 Java 基本句法

第 2 章 Java 基本句法

本章简练而全面地介绍 Java 句法,主要针对之前有些编程经验但刚接触这门语言的读者,对完全没有编程经验的新手也有一些帮助。如果已经了解 Java,可以把这一章当成语言参考。为了方便学过其他编程语言的读者,本章还对 Java 与 C 和 C++ 进行了比较。

本章先介绍非常低层的 Java 句法,然后以此为基础,介绍高级结构。本章包含以下内容。

  • 编写 Java 程序的字符,以及这些字符的编码。

  • 组成 Java 程序的字面量、标识符和其他标记。

  • Java 能处理的数据类型。

  • 在 Java 中把单独的标记放在一起组成复杂表达式的运算符。

  • 语句:把表达式和其他语句放在一起组成 Java 代码逻辑块。

  • 方法:一系列 Java 语句,有名字,可被其他 Java 代码调用。

  • 类:由一系列方法和字段组合而成。类是 Java 程序的核心元素,也是面向对象编程的基础。第 3 章专门介绍类和对象。

  • 包:由一系列相关的类组合而成。

  • Java 程序:由一个或多个交互的类组成,这些类可能来自一个或多个包。

大多数编程语言的句法都很复杂,Java 也不例外。一般来说,介绍一门语言的某些元素时,难免会提到一些尚未接触的元素。例如,介绍 Java 支持的运算符和语句时,不可避免地要提到对象;类似地,介绍对象时也不能不提 Java 的运算符和语句。在学习 Java 或任何其他语言的过程中,都要这样交叉学习。

2.1 Java程序概览

在详细介绍 Java 句法之前,我们先花点儿时间概述 Java 程序。Java 程序由一个或多个 Java 源码文件(或叫编译单元)组成。本章末尾会介绍 Java 文件的结构,并且会讲解如何编译和运行 Java 程序。每个编译单元都以可选的 package 声明开始,后面跟着零个或多个 import 声明。这些声明指定一个命名空间,编译单元中定义的名称都在这个命名空间里,而且还指定了编译单元从哪些命名空间中导入名称。2.10 节会介绍 packageimport 声明。

在可选的 packageimport 声明之后,是零个或多个引用类型定义。第 3 章和第 4 章会介绍各种可用的引用类型,现在你只需要知道,这些往往都是 classinterface 定义。

在引用类型的定义体中有一些成员,例如字段方法构造方法。其中,方法是最重要的成员类型。方法是一段由语句组成的 Java 代码。

了解这些基本术语之后,下面开始详细介绍 Java 程序的基本句法单元。句法单元经常被称为词法标记(lexical token)。

2.2 词法结构

本节说明 Java 程序的词法结构,首先介绍编写 Java 程序的 Unicode 字符集,然后介绍组成 Java 程序的标记,包括注释、标识符、保留字和字面量等。

2.2.1 Unicode字符集

Java 程序使用 Unicode 字符编写。在 Java 程序中,任何地方都能使用 Unicode 字符,包括 注释和标识符,例如变量名。7 位 ASCII 字符集只对英语有用,8 位 ISO Latin-1 字符集只 对大多数西欧语言有用,而 Unicode 字符集能表示世界上几乎所有常用的书写语言。

 如果使用不支持 Unicode 的文本编辑器,或者不想强制查看或编辑你代码的程序员使用支持 Unicode 的编辑器,你可以使用特殊的 Unicode 转义序列 \uxxxx,把 Unicode 字符嵌入 Java 程序。Unicode 转义序列由反斜线、小写的字母 u 和四个十六进制字符组成。例如,\u0020 是空格,\u03c0 是字符 π。

Java 投入了大量时间和工程努力,确保能最好地支持 Unicode。如果业务应用面向全球用户,特别是西方之外的市场,Java 平台是很好的选择。

2.2.2 区分大小写与空白

Java 语言区分大小写,关键字使用小写,而且必须这么用;也就是说,WhileWHILEwhile 关键字不是一回事。类似地,如果在程序中把变量命名为 i,就不能使用 I 引用这个变量。

 一般来说,通过大小写来区分标识符是非常糟糕的主意。在代码中不要这么做,尤其不要使用和关键字同名但大小写不同的标识符。

Java 会忽略空格、制表符、换行符和其他空白,除非这些符号出现在引号或字符串字面量 中。为了易读,程序员一般会使用空白格式化和缩进代码。本书的示例代码会使用一些常用的缩进约定。

2.2.3 注释

注释是使用自然语言编写的文本,供某一程序的人类读者阅读。Java 编译器会忽略注释。Java 支持三种注释。第一种是单行注释,以 // 字符开始,直到行尾结束。例如:

int i = 0;  // 初始化循环变量

第二种是多行注释,以 /* 字符开始,不管有多少行,直到 */ 字符结束。javac 会忽略 /**/ 之间的所有文本。虽然这种形式一般用于多行注释,但也可以用于单行注释。

这种注释不能嵌套,即 /* */ 中不能再有 /* */。编写多行注释时,程序员经常使用额外的 * 字符,突出注释的内容。下面是个典型的多行注释:

/*
 * 首先,连接服务器。
 * 如果尝试连接失败,立即退出。
 */

第三种注释是第二种的一个特例。如果注释以 /** 开头,会被当成特殊的文档注释。和普通的多行注释一样,文档注释也以 */ 结尾,而且不能嵌套。如果你编写了一个 Java 类,希望让其他程序员使用,可以直接在源码中嵌入关于这个类和其中每个方法的文档。名为 javadoc 的程序会提取这些文档,经过处理后生成这个类的在线文档。文档注释中可以包含 HTML 标签和 javadoc 能理解的其他句法。例如:

/**
 * 把文件上传到Web服务器中。
 *
 * @param file要上传的文件。
 * @return <tt>true</tt>表示上传成功,
 *         <tt>false</tt>表示上传失败。
 * @author David Flanagan
 */

第 7 章会详细介绍文档注释的句法,第 13 章会详细介绍 javadoc 程序。

注释可以出现在 Java 程序中的任何标记之间,但不能出现在标记中。注释尤其不能出现在双引号字符串字面量中。字符串字面量中的注释就是这个字符串的一部分。

2.2.4 保留字

以下是 Java 的保留字(它们是 Java 语言句法的一部分,不能用来命名变量和类等):

abstract   const      final        int         public        throw
assert     continue   finally      interface   return        throws
boolean    default    float        long        short         transient
break      do         for          native      static        true
byte       double     goto         new         strictfp      try
case       else       if           null        super         void
catch      enum       implements   package     switch        volatile
char       extends    import       private     synchronized  while
class      false      instanceof   protected   this

后文还会见到这些保留字,其中有些是基本类型的名称,有些是 Java 语句的名称,这两种保留字稍后都会进行介绍。还有一些用于定义类和成员,第 3 章会介绍。

注意,虽然 Java 语言不使用 constgoto,但它们也是保留字;interface 还有另外一种形式——@interface,用来定义注解类型。有些保留字(尤其是 finaldefault)根据不同的上下文有不同的意义。

2.2.5 标识符

标识符就是 Java 程序中某个部分的名称,例如类、类中的方法和方法中声明的变量。标识符的长度不限,可以包含 Unicode 字符集中的任意字母和数字,但是不能以数字开头。一般来说,标识符不能包含标点符号,不过可以包含 ASCII 字符集中的下划线(_)和美元符号($),以及 Unicode 字符集中的其他货币符号,例如£和 ¥。

 货币符号主要用在自动生成的源码中,例如 javac 生成的代码。不在标识符中使用货币符号,可以避免自己的标识符和自动生成的标识符冲突。

按照规定,可以出现在标识符开头和之中的字符由 java.lang.Character 类中的 isJavaIdentifierStart()isJavaIdentifierPart() 方法定义。

以下是合法标识符示例:

i    x1    theCurrentTime    the_current_time 獺

特别注意,其中有个 UTF-8 标识符——獺。这是一个汉字,英文是“otter”,完全是个合法的 Java 标识符。在主要是由西方人编写的程序中不常见到使用非 ASCII 字符的标识符,但偶尔也有。

2.2.6 字面量

字面量是直接出现在 Java 源码中的值,包括整数、浮点数、单引号中的单个字符、双引号中的字符串,以及保留字 truefalsenull。例如,以下都是字面量:

1    1.0    '1'    "one"    true    false    null

2.3 节会详细介绍表示数字、字符和字符串字面量的句法。

2.2.7 标点符号

Java 标记中也有一些是标点符号。Java 语言规范把这些字符分成两类(有点随意):分隔符和运算符。分隔符有 12 个:

(   )   {   }   [    ]
... @ ::
;  ,   .

运算符如下:

+  -   *    /    %    &   |    ^    <<    >>   >>>
+=  -=  *=    /=   %=   &=  |=   ^=    <<=  >>=  >>>=
=   ==  !=    <    <=   >    >=
!   ~    &&  ||   ++   --   ?    :   ->

整本书中都会见到分隔符,2.4 节会分别介绍每个运算符。

2.3 基本数据类型

Java 支持八种基本数据类型,包括一种布尔类型、一种字符类型、四种整数类型和两种浮点数类型,如表 2-1 所示。四种整数类型和两种浮点数类型的区别在于位数不同,因此能表示的数字范围也不同。

表2-1:Java的基本数据类型

类型

取值

默认值

大小

范围

boolean

truefalse

false

1 位

NA

char

Unicode 字符

\u0000

16 位

\u0000~\uFFFF

byte

有符号的整数

0

8 位

-128~127

short

有符号的整数

0

16 位

-32768~32767

int

有符号的整数

0

32 位

-2147483648~2147483647

long

有符号的整数

0

64 位

-9223372036854775808~9223372036854775807

float

IEEE 754 浮点数

0.0

32 位

1.4E-45~3.4028235E+38

double

IEEE 754 浮点数

0.0

64 位

4.9E-324~1.7976931348623157E+308

下面几节简要介绍这些基本数据类型。除了基本数据类型之外,Java 还支持称为引用类型的非基本数据类型,2.9 节会介绍。

2.3.1 布尔类型

布尔类型(boolean)表示真值,只有两个可选值,表示两种逻辑状态:开或关,是或否,真或假。Java 使用保留字 truefalse 表示这两个布尔值。

从其他编程语言,尤其是 JavaScript,转到 Java 的程序员要注意,Java 比其他语言对布尔值的要求严格得多:布尔类型既不是整数类型也不是对象类型,而且不能使用不兼容的值代替布尔类型。也就是说,在 Java 中不能使用下面的简写形式:

Object o = new Object();
int i = 1;

if (o) {
  while(i) {
    // ...
  }
}

相反,Java 强制要求编写简洁的代码,明确表明想做什么比较:

if (o != null) {
  while(i != 0) {
    // ...
  }
}

2.3.2 字符类型

字符类型(char)表示 Unicode 字符。Java 使用一种稍微独特的方式表示字符:在传给 javac 的输入中,标识符使用 UTF-8 编码(一种变长编码方式),但在内部使用定长编码(16 位)表示字符。

不过,开发者一般无需担心这个区别。大多数情况下,只需记住,如果想在 Java 程序中使用字符字面量,只需把字符放在单引号中即可:

char c = 'A';

当然,字符字面量可以使用任何一个 Unicode 字符,也可以使用 Unicode 转义序列 \u。而且,Java 还支持一些其他转义序列,用来表示常用的非打印 ASCII 字符,例如换行符以及转义 Java 中某些有特殊意义的标点符号。例如:

char tab = '\t', nul = '\000', aleph = '\u05D0', slash = '\\';

表 2-2 列出了可在字符字面量中使用的转义字符。这些字符也可以在字符串字面量中使用,下一节会介绍。

表2-2:Java转义字符

转义序列

字符值

\b

退格符

\t

水平制表符

\n

换行符

\f

换页符

\r

回车符

\"

双引号

\'

单引号

\\

反斜线

\xxx

xxx 编码 的Latin-1 字符,其中 xxx 是八进制数,介于 000 到 377 之间。\x\xx 两种形式也是合法的,例如 \0,但不推荐这么用,因为转义序列只有一个数字,在字符串常量中会导致歧义。这种用法在 \uxxxx 中也不鼓励使用

\uxxxx

xxxx 编码的 Unicode 字符,其中 xxxx 是四个十六进制数。Unicode 转义序列可以出现在 Java 程序的任意位置,而不只局限于字符和字符串字面量

字符可以转换成整数类型,也可以从整数类型转换而来。字符类型对应的是 16 位整数类型。字符类型与 byteshortintlong 不同,没有符号。Character 类定义了一些有用的静态方法(static method),用于处理字符,例如 isDigit()isJavaLetter()isLowerCase()toUpperCase()

设计 Java 语言和字符类型时考虑到了 Unicode。Unicode 标准一直在发展,每一个 Java 新版本都会使用最新版 Unicode。Java 7 使用的是 Unicode 6.0,Java 8 使用的是 Unicode 6.2。

最近的几版 Unicode 收录了 16 位编码(或叫码位,codepoint)无法容纳的字符。这些追加的字符是十分少见的汉字象形文字,占用了 21 位,无法使用单个字符表示,必须使用 int 类型表示,或者必须使用“代理对”(surrogate pair)通过两个字符表示。

除非经常使用亚洲语言编写程序,否则很少会遇到这些追加的字符。如果预计要处理无法使用单个字符类型表示的字符,就可以使用 CharacterString 等相关类中提供的方法,使用 int 类型表示码位,然后再处理文本。

字符串字面量

除了字符类型之外,Java 还有一种用于处理字符串的数据类型。不过,String 类型是类,不是基本类型。因为字符串很常用,所以 Java 提供了一种句法,可以直接在程序中插入字符串。字符串字面量是包含在双引号中的任意文本(字符字面量使用单引号)。例如:

"Hello, world"
"'This' is a string!"

字符串字面量中可以包含能在字符字面量中使用的任何一个转义序列(参见表 2-2)。如果想在字符串字面量中插入双引号,可以使用 \" 转义序列。String 是引用类型,本章后面的 2.7.4 节还会深入介绍字符串字面量。第 9 章会更详细地介绍在 Java 中处理 String 对象的一些方式。

2.3.3 整数类型

Java 中的整数类型有 byteshortintlong 四种。如表 2-1 所示,这四种类型之间唯一的区别是位数,即能表示的数字范围有所不同。所有整数类型都表示有符号的数字,Java 没有 C 和 C++ 中的 unsigned 关键字。

这四种类型的字面量形式正如你设想的那样,使用十进制数字,前面还可以加上负号。1 下面是一些合法的整数字面量:

1严格来说,负号是作用在字面量上的运算符,而不是字面量的一部分。

0
1
123
-42000

整数字面量还可以使用十六进制、二进制和八进制形式来表示。以 0x0X 开头的字面量是十六进制数,使用字母 AF(或 af)表示数字的十六进制形式。

整数字面量的二进制形式以 0b 开头,当然,只能使用数字 10。字面量的二进制形式可能很长,所以经常在字面量中使用下划线。在任何数字字面量中,下划线都会被忽略。下划线纯粹是为了提升字面量的可读性。

Java 还支持使用八进制表示整数字面量,以 0 开头,而且不能使用数字 89。这种字面量不常用,除非有必要,否则应该避免使用。下面是一些合法的十六进制、二进制和八进制字面量:

0xff           // 使用十六进制表示的十进制数255
0377           // 使用八进制表示的十进制数255
0b0010_1111    // 使用二进制表示的十进制数47
0xCAFEBABE     // 用来识别Java类文件的魔法数

整数字面量是 32 位 int 类型,如果以 Ll 结尾,就表示 64 位 long 类型:

1234    // int类型
1234L   // long类型
0xffL   // 还是long类型

在 Java 中,如果整数运算超出了指定整数类型的范围,不会上溢或下溢,而是直接回绕。例如:

byte b1 = 127, b2 = 1;       // byte类型的最大值是127
byte sum = (byte)(b1 + b2);  // 加法运算的结果直接回绕到-128,即byte类型的最小值

如果发生了这种情况,Java 编译器和解释器都不会发出任何形式的警告。进行整数运算时,必须确保使用的类型取值范围能满足计算需要。整数除以零,或者计算除以零后得到的余数,都是非法操作,会抛出 ArithmeticException 异常。

每一种整数类型都有对应的包装类:ByteShortIntegerLong。这些类都定义了 MIN_VALUEMAX_VALUE 常量,表示相应的取值范围。而且还定义了一些有用的静态方法,例如 Byte.parseByte()Integer.parseInt(),作用是把字符串转换成整数。

2.3.4 浮点数类型

在 Java 中,实数使用 floatdouble 数据类型表示。如表 2-1 所示,float 类型是 32 位单精度浮点数,double 是 64 位双精度浮点数。这两种类型都符合 IEEE 754-1985 标准。这个标准规定了浮点数的格式和运算方式。

浮点数可以以字面量形式插入 Java 程序,其格式为一些可选的数字,后跟一个小数点和一些数字。下面是几个示例:

123.45
0.0
.01

浮点数字面量还可以使用指数形式(也叫科学记数法)表示,其格式为一个数后面跟着字母 eE 和一个数。第二个数表示 10 的次方,是第一个数的乘数。例如:

1.2345E02    // 1.2345 * 10^2或123.45
1e-6         // 1 * 10^-6或0.000001
6.02e23      // 阿伏加德罗常数:6.02 * 10^23

默认情况下,浮点数是 double 类型。若想在程序中插入 float 类型的字面量,要在数字后面加上 fF

double d = 6.02E23;
float f = 6.02e23f;

浮点数字面量不能使用十六进制、二进制或八进制表示。

浮点数表示的值

由于本质上的限制,大多数实数都不能使用有限的位数进行精确表示。因此,要记住,floatdouble 类型都只能表示实际值的近似值。float 类型是 32 位近似值,至少有 6 个有效数字;double 是 64 位近似值,至少有 15 个有效数字。第 9 章会更详细地说明浮点数表示的值。

除了表示普通的数字之外,floatdouble 类型还能表示四个特殊的值:正无穷大、负无穷大、零和 NaN。如果浮点数运算的结果超出了 floatdouble 能表示的范围上限,得到的是无穷大。如果浮点数的运算结果超出了 floatdouble 能表示的范围下限,得到的是零。

Java 的浮点类型区分正零和负零,具体是哪个值取决于从哪个方向出现的下溢。在实际使用中,正零和负零的表现基本一样。最后一种特殊的浮点数 NaN,是“Not-a-Number”的简称,表示“不是数字”。如果浮点数运算不合法,例如 0.0/0.0,得到的就是 NaN。以下几个例子得到的结果就是这些特殊的值:

double inf = 1.0/0.0;          // 无穷大
double neginf = -1.0/0.0;      // 负无穷大
double negzero = -1.0/inf;     // 负零
double NaN = 0.0/0.0;          // NaN

Java 浮点数类型能处理到无穷大的上溢以及到零的下溢,因此浮点数运算从不抛出异常,就算执行非法运算也没事,例如零除以零,或计算负数的平方根。

floatdouble 基本类型都有对应的类,分别为 FloatDouble。这两个类都定义了一些有用的常量:MIN_VALUEMAX_VALUENEGATIVE_INFINITYPOSITIVE_INFINITYNaN

无穷大浮点数的表现和设想的一样,例如,无穷大之间的加减运算得到的还是无穷大。负零的表现几乎和正零一样,而且事实上,相等运算符 == 会告诉你,负零和正零是相等的。区分负零、正零和普通的零有一种方法——把它作为被除数:1.0/0.0 得到的是正无穷大,但是 1.0 除以负零得到的是负无穷大。因为 NaN 不是数字,所以 == 运算符会告诉我们它不等于任何其他数字,甚至包括它自己。若想检查某个 floatdouble 值是否为 NaN,必须使用 Float.isNaN()Double.isNaN() 方法。

2.3.5 基本类型之间的转换

Java 允许整数和浮点数之间相互转换。而且,由于每个字符都对应 Unicode 编码中的一个数字,所以字符与整数和浮点数之间也可以相互转换。其实,在 Java 中,布尔值是唯一一种不能和其他基本类型之间相互转换的基本类型。

类型转换有两种基本方式。把某种类型的值转换成取值范围更广的类型,此时执行的是放大转换(widening conversion)。例如,把 int 字面量赋值给 double 类型的变量和把字符字面量赋值给 int 类型的变量时,Java 会执行放大转换。

另一种方式是缩小转换(narrowing conversion)。把一个值转换成取值范围没那么广的类型时执行的就是缩小转换。缩小转换并不总是安全的,例如把整数 13 转换成 byte 类型是合理的,但把 13 000 转换成 byte 类型就不合理,因为 byte 类型只能介于 -128 和 127 之间。缩小转换可能丢失数据,所以试图缩小转换时 Java 编译器会发出警告,就算转换后的值能落在更窄的取值范围内也会警告:

int i = 13;
byte b = i;    // 编译器不允许这么做

不过有个例外,如果整数字面量(int 类型)的值落在 byteshort 类型的取值范围内,就能把这个字面量赋值给 byteshort 类型的变量。

如果需要执行缩小转换,而且确信这么做不会丢失数据或精度,可以使用一种称为“校正”(cast)的语言结构强制 Java 转换。若想执行类型校正,可以在想转换的值前面加一个括号,在括号里写上希望转换成哪种类型。例如:

int i = 13;
byte b = (byte) i;   // 把int类型强制转换成byte类型
i = (int) 13.456;    // 把double字面量强制转换成int类型,得到的是13

基本类型的校正最常用于把浮点数转换成整数。执行这种转换时,浮点数的小数部分会被直接截掉,即浮点数向零而不是临近的整数舍入。静态方法 Math.round()Math.floor()Math.ceil() 执行的是另一些舍入方式。

大多数情况下,字符类型的表现都和整数类型类似,所以需要 intlong 类型的地方都 可以使用字符。不过,还记得吗,字符类型没有符号,所以即便字符和 short 类型都是 16 位,表现上也有差异:

short s = (short) 0xffff; // 这些比特表示数字-1
char c = '\uffff';        // 还是这些比特,表示一个Unicode字符
int i1 = s;               // 把short类型转换成int类型,得到的是-1
int i2 = c;               // 把字符转换成int类型,得到的是65535

表 2-3 列出了各种基本类型能转换成何种其他类型,以及转换的方式。其中,字母 N 表示无法转换;字母 Y 表示放大转换,由 Java 自动隐式转换;字母 C 表示缩小转换,需要显式校正。

最后,Y* 表示自动执行的放大转换,但在转换过程中最低有效位可能丢失。把 intlong 类型转换成浮点类型时可能会出现这种情况,详情参见下表。浮点类型的取值范围比整数类型广,所以 intlong 类型都能用 floatdouble 类型来表示。然而,浮点类型是近似值,所以有效数字不一定总与整数类型一样多(浮点数的详细介绍参见第 9 章)。

表2-3:Java基本类型转换

基本类型转换为
booleanbyteshortcharintlongfloatdouble
boolean-NNNNNNN
byteN-YCYYYY
shortNC-CYYYY
charNCC-YYYY
intNCCC-YY\*Y
longNCCCC-Y\*Y\*
floatNCCCCC-Y
doubleNCCCCCC-

2.4 表达式和运算符

到目前为止,我们学习了 Java 程序能处理的基本类型,以及如何在 Java 程序中使用基本类型的字面量。还使用了变量作为值的符号名称。字面量和变量都是组成 Java 程序的标记。

表达式是 Java 程序更高一级的结构。Java 解释器会求出表达式的值。最简单的表达式叫基本表达式,由字面量和变量组成。例如,下面几个例子都是表达式:

1.7     // 一个浮点数字面量
true    // 一个布尔字面量
sum     // 一个变量

Java 解释器计算字面量表达式得到的结果是字面量本身;计算变量表达式得到的结果是存储在变量中的值。

基本表达式没什么意思。使用运算符把基本表达式连在一起可以组成复杂的表达式。例如,下面的表达式使用赋值运算符把两个基本表达式(一个变量,一个浮点数字面量)连在一起,组成赋值表达式:

sum = 1.7

不过,运算符不仅能连接基本表达式,也能在任意复杂度的表达式中使用。如下都是合法的表达式:

sum = 1 + 2 + 3 * 1.2 + (4 + 8)/3.0
sum/Math.sqrt(3.0 * 1.234)
(int)(sum + 33)

2.4.1 运算符概述

一门编程语言能编写什么样的表达式,完全取决于可用的运算符。Java 提供了丰富的运算符,但在有效使用它们之前,要弄清两个重要的概念:优先级结合性。下面几节详细说明这两个概念和运算符。

1. 优先级

在表 2-4 中,P 列是运算符的优先级。优先级指定运算符执行的顺序。优先级高的运算符在优先级低的运算符之前运算。例如,有如下的表达式:

a + b * c

乘号的优先级比加号的优先级高,所以 ab 乘以 c 的结果相加,这与小学数学课上学到的一样。运算符的优先级可以理解为运算符和操作数之间绑定的紧密程度,优先级越高,绑定得越紧密。

运算符默认的优先级可以使用括号改变,括号能明确指定运算的顺序。前面那个表达式可以像下面这样重写,先相加再相乘:

(a + b) * c

Java 采用的默认运算符优先级和 C 语言兼容,C 语言的设计者选定的优先级无需使用括号就能流畅地写出大多数表达式。只有少量的 Java 惯用句法需要使用括号,例如:

// 类校正和成员访问结合在一起
((Integer) o).intValue();

// 赋值和比较结合在一起
while((line = in.readLine()) != null) { ... }

// 位运算符和比较结合在一起
if ((flags & (PUBLIC | PROTECTED)) != 0) { ... }

2. 结合性

结合性是运算符的一个属性,定义如何计算有歧义的表达式。如果表达式中有多个优先级相同的运算符,结合性尤其重要。

大多数运算符由左至右结合,即从左向右计算。不过,赋值和一元运算符由右至左结合。在表 2-4 中,A 列是运算符或运算符组的结合性,L 表示由左至右,R 表示由右至左。

加号和减号的结合性都是由左至右,所以表达式 a+b-c 从左向右计算,即 (a+b)-c。一元运算符和赋值运算符从右向左计算。例如下面这个复杂的表达式:

a = b += c = -~d

计算的顺序是:

a = (b += (c = -(~d)))

和运算符的优先级一样,运算符的结合性也建立了计算表达式的默认顺序。这个默认的顺序可以使用括号改变。然而,Java 选定的默认运算符结合性是为了使用流畅的句法编写表达式,几乎不需要改变。

3. 运算符总结表

表 2-4 总结了 Java 提供的运算符。P 列和 A 列分别表示每类相关运算符的优先级和结合性。这张表可以作为运算符(特别是优先级)的快速参考指南。

表2-4:Java运算符

PA运算符操作数类型执行的运算
16L.对象,成员访问对象成员
[ ]数组,int获取数组中的元素
( args )方法,参数列表调用方法
++--变量后递增,后递减
15R++--变量前递增,前递减
+-数字正号,负号
~整数按位补码
!布尔值逻辑求反
14Rnew类,参数列表创建对象
( type )类型,任何类型校正(类型转换)
13L`*`,/%数字,数字乘法,除法,求余数
12L+-数字,数字加法,减法
+字符串,任何类型字符串连接
11L<<整数,整数左移
>>整数,整数右移,高位补符号
>>>整数,整数右移,高位补零
10L<<=数字,数字小于,小于或等于
>>=数字,数字大于,大于或等于
instanceof引用类型,类型类型比较
9L==基本类型,基本类型等于(值相同)
!=基本类型,基本类型不等于(值不同)
==引用类型,引用类型等于(指向同一个对象)
!=引用类型,引用类型不等于(指向不同的对象)
8L&整数,整数位与
&布尔值,布尔值逻辑与
7L^整数,整数位异或
^布尔值,布尔值逻辑异或
6L|整数,整数位或
|布尔值,布尔值逻辑或
5L&&布尔值,布尔值条件与
4L||布尔值,布尔值条件或
3R? :布尔值,任何类型条件(三元)运算符
2R=变量,任何类型赋值
*=/=%=+=-=<<=>>=>>>=&=^=|=变量,任何类型计算后赋值
1R->参数列表,方法体lambda 表达式

4. 操作数的数量和类型

在表 2-4 中,第 4 列是每种运算符能处理的操作数数量和类型。有些运算符只有一个操作数,这种运算符叫一元运算符。例如,一元减号的作用是改变单个数字的符号:

-n             // 一元减号

不过,大多数运算符都是二元运算符,有两个操作数。- 运算符其实还有一种用法:

a – b         // 减法运算符是二元运算符

Java 还定义了一个三元运算符,经常称作条件运算符,就像是表达式中的 if 语句。它的三个操作数由问号和冒号分开,第二个和第三个操作数必须能转换成同一种类型:

x > y ? x : y  // 三元表达式;计算x和y哪个大

除了需要特定数量的操作数之外,每个运算符还需要特定类型的操作数。表 2-4 中的第 4 列是操作数的类型,其中使用的文本需要进一步说明。

  • 数字

    整数、浮点数或字符(即除了布尔类型之外的任何一种基本类型)。因为这些类型对应的包装类(例如 CharacterIntegerDouble)能自动拆包(参见 2.9.4 节),所以在这些地方也能使用相应的包装类。

  • 整数

    byteshortintlongchar 类型的值(获取数组元素的运算符 [ ] 不能使用 long 类型的值)。因为能自动拆包,所以也能使用 ByteShortIntegerLongCharacter 类型的值。

  • 引用类型

    对象或数组。

  • 变量

    变量或其他符号名称(例如数组中的元素),只要能赋值就行。

5. 返回类型

就像运算符只能处理特定类型的操作数一样,运算得到的结果也是特定类型的值。对算术运算符、递增和递减、位运算符和位移运算符来说,如果至少有一个操作数是 double 类型,返回值就是 double 类型;如果至少有一个操作数是 float 类型,返回值是 float 类型;如果至少有一个操作数是 long 类型,返回值是 long 类型;除此之外都返回 int 类型的值,就算两个操作数都是 byteshortchar 类型,也会放大转换成 int 类型。

比较、相等性和逻辑运算符始终返回布尔值。各种赋值运算符都返回赋予的值,类型和表达式左边的变量兼容。条件运算符返回第二个或第三个操作数(二者的类型必须相同)。

6. 副作用

每个运算符都会计算一个或多个操作数,得到一个结果。但是,有些运算符除了基本的计算之外还有副作用。如果表达式有副作用,计算时会改变 Java 程序的状态,即再次执行时会得到不同的结果。

例如,++ 递增运算符的副作用是递增变量中保存的值。表达式 ++a 会递增变量 a 中的值,返回递增后得到的值。如果再次计算这个表达式,会得到不同的值。各种赋值运算符也有副作用。例如,表达式 a*=2 也可以写成 a=a*2,这个表达式的结果是乘于 2 后得到的值,但是有副作用——把计算结果重新赋值给 a

如果调用的方法有副作用,方法调用运算符 () 也有副作用。有些方法,例如 Math.sqrt(),只是计算后返回一个值,没有任何副作用。可是,一般情况下,方法都有副作用。最后,new 运算符有重大的副作用,它会创建一个新对象。

7. 计算的顺序

Java 解释器计算表达式时,会按照表达式中的括号、运算符的优先级和结合性指定的顺序运算。不过,在任何运算之前,解释器会先计算运算符的操作数(&&||?: 例外,不会总是计算这些运算符的全部操作数)。解释器始终使用从左至右的顺序计算操作数。如果操作数是有副作用的表达式,这种顺序就很重要了。例如下面的代码:

int a = 2;
int v = ++a + ++a * ++a;

虽然乘法的优先级比加法高,但是会先计算 + 运算符的两个操作数。因为这两个操作数都是 ++a,所以得到的计算的结果分别是 34,因此这个表达式计算的是 3 + 4 * 5,结果为 23

2.4.2 算术运算符

算术运算符可用于整数、浮点数和字符(即除了布尔类型之外的所有基本类型)。如果其中有个操作数是浮点数,就按浮点算术运算;否则,按整数算术运算。这一点很重要,因为整数算术和浮点算术是有区别的,例如除法的运算方式,以及上溢和下溢的处理方式。算术运算符如下。

  • 加法(+

    + 号计算两个数之和。稍后会看到,+ 号还能连接字符串。如果 + 号的操作数中有一个是字符串,另一个也会转换成字符串。如果想把加法和连接放在一起使用,一定要使用括号。例如:

    System.out.println("Total: " + 3 + 4);    // 打印“Total: 34”,不是7!
    
  • 减法(-

    - 号当成二元运算符使用时,计算第一个操作数减去第二个操作数得到的结果。例如,7-3 的结果是 4。- 号也可执行一元取负操作。

  • 乘法(*

    * 号计算两个操作数的乘积。例如,7*3 的结果是 21。

  • 除法(/

    / 号用第一个操作数除以第二个操作数。如果两个操作数都是整数,结果也是整数,丢掉余数。如果有一个操作数是浮点数,结果就是浮点数。两个整数相除时,如果除数是零,抛出 ArithmeticException 异常。不过,对浮点数计算来说,如果除以零,得到的是无穷大或 NaN:

    7/3        // 计算结果为2
    7/3.0f     // 计算结果为2.333333f
    7/0        // 抛出ArithmeticException异常
    7/0.0      // 计算结果为正无穷大
    0.0/0.0    // 计算结果为NaN
    
  • 求模(%

    % 运算符计算第一个操作数和第二个操作数的模数,即返回第一个操作数除去第二个操作数的整倍数之后剩下的余数。例如,7%3 的结果是 1。结果的符号和第一个操作数的符号一样。虽然求模运算符的操作数一般是整数,但也可以使用浮点数。例如,4.3%2.1 的结果是 0.1。如果操作数是整数,计算零的模数会抛出 ArithmeticException 异常。如果操作数是浮点数,计算 0.0 的模数得到的结果是 NaN;计算无穷大和任何数的模数得到的结果也是 NaN。

  • 负号(-

    如果把 - 号当成一元运算符使用,即放在单个操作数之前,执行的是一元取负运算。也就是说,会把正数转换成对应的负数,或把负数转换成对应的正数。

2.4.3 字符串连接运算符

+ 号(以及相关的 += 运算符)除了能计算数字之和以外,还能连接字符串。如果 + 号的两个操作数中有一个是字符串,另一个操作数也会转换成字符串。例如:

// 打印“Quotient: 2.3333333”
System.out.println("Quotient: " + 7/3.0f);

因此,如果加法和字符串连接结合在一起使用,要把加法表达式放在括号中。如果不这么做,加号会被理解成连接运算符。

Java 解释器原生支持把所有基本类型转换成字符串。对象转换成字符串时,调用的是对象的 toString() 方法。有些类自定义了 toString() 方法,所以这些类的对象可以使用这种方式轻易地转换成字符串。数组转换成字符串时会调用原生的 toString() 方法,不过可惜,这个方法没有为数组的内容提供有用的字符串形式。

2.4.4 递增和递减运算符

++ 运算符把它的单个操作数增加 1,这个操作数必须是变量、数组中的元素或对象的字段。这个运算符的行为取决于它相对于操作数的位置。放在操作数之前,是前递增运算符,递增操作数的值,并返回递增后的值。放在操作数之后,是后递增运算符,递增操作数的值,但返回递增前的值。

例如,下面的代码把 ij 的值都设为 2:

i = 1;
j = ++i;

但是,下面的代码把 i 的值设为 2,j 的值设为 1:

i = 1;
j = i++;

类似地,-- 运算符把它的单个数字操作数减小 1,这个操作数必须是变量、数组中的元素或对象的字段。和 ++ 运算符一样,-- 的行为也取决于它相对于操作数的位置。放在操作数之前,递减操作数的值,并返回递减后的值。放在操作数之后,递减操作数的值,但返回递减前的值。

表达式 x++x-- 分别等效于 x=x+1x=x-1,不过使用递增和递减运算符时,只会计算一次 x 的值。如果 x 是有副作用的表达式,情况就大不相同了。例如,下面两个表达式不等效:

a[i++]++;             // 递增数组中的一个元素
// 把数组中的一个元素增加1,然后把新值存储在另一个元素中
a[i++] = a[i++] + 1;

这些运算符,不管放在前面还是后面,最常用来递增或递减控制循环的计数器。

2.4.5 比较运算符

比较运算符包括测试两个值是否相等的相等运算符和测试有序类型(数字和字符)数据之间大小关系的关系运算符。这两种运算符计算的结果都是布尔值,因此一般用于 if 语句、whilefor 循环,作为分支和循环的判定条件。例如:

if (o != null) ...;         // 不等运算符
while(i < a.length) ...;    // 小于运算符

Java 提供了下述相等运算符。

  • 等于(==

    如果 == 运算符的两个操作数相等,计算结果为 true;否则计算结果为 false。如果操作数是基本类型,这个运算符测试两个操作数的值是否一样。如果操作数是引用类型,这个运算符测试两个操作数是否指向同一个对象或数组。尤其要注意,这个运算符不能测试两个字符串是否相等。

    如果使用 == 比较两个数字或字符,而且两个操作数的类型不同,在比较之前会把取值范围窄的操作数转换成取值范围宽的操作数类型。例如,比较 short 类型的值和 float 类型的值时,在比较之前会先把 short 类型的值转换成 float 类型。对浮点数来说,特殊的负零和普通的正零相等;特殊的 NaN 和任何数,包括 NaN 自己,都不相等。如果想测试浮点数是否为 NaN,要使用 Float.isNan()Double.isNan() 方法。

  • 不等于(!=

    != 运算符完全是 == 运算符的反运算。如果两个基本类型操作数的值不同,或者两个引用类型操作数指向不同的对象或数组,!= 运算符的计算结果为 true;否则,计算结果为 false

关系运算符可用于数字和字符,但不能用于布尔值、对象和数组,因为这些类型无序。Java 提供了下述关系运算符。

  • 小于(<

    如果第一个操作数小于第二个操作数,计算结果为 true

  • 小于或等于(<=

    如果第一个操作数小于或等于第二个操作数,计算结果为 true

  • 大于(>

    如果第一个操作数大于第二个操作数,计算结果为 true

  • 大于或等于(>=

    如果第一个操作数大于或等于第二个操作数,计算结果为 true

2.4.6 逻辑运算符

如前所示,比较运算符比较两个操作数,计算结果为布尔值,经常用在分支和循环语句中。为了让分支和循环的条件判断更有趣,可以使用逻辑运算符把多个比较表达式合并成一个更复杂的表达式。逻辑运算符的操作数必须是布尔值,而且计算结果也是布尔值。逻辑运算符如下。

  • 条件与(&&

    这个运算符对操作数执行逻辑与运算。仅当两个操作数都是 true 时才返回 true;如果有一个或两个操作数都是 false,计算结果为 false。例如:

    if (x < 10 && y > 3) ... // 如果两个比较表达式的结果都是true
    
    

    这个运算符(以及除了一元运算符 ! 之外的所有逻辑运算符)的优先级没有比较运算符高,因此完全可以编写类似上面的代码。不过,有些程序员倾向于使用括号,明确表明计算的顺序:

    if ((x < 10) && (y > 3))...
    
    

    你觉得哪种写法更易读就用哪种。

    这个运算符之所以叫条件与,是因为它会视情况决定是否计算第二个操作数。如果第一个操作数的结算结果为 false,不管第二个操作数的计算结果是什么,这个表达式的计算结果都是 false。因此,为了提高效率,Java 解释器会走捷径,跳过第二个操作数。因为不一定会计算第二个操作数,所以使用这个运算符时,如果表达式有副作用,一定要注意。不过,因为有这种特性,可以使用这个运算符编写如下的 Java 表达式:

    if (data != null && i < data.length && data[i] != -1)
        ...
    
    

    如果第一个和第二个比较表达式的计算结果为 false,第二个和第三个比较表达式会导致错误。幸好,我们无需为此担心,因为 && 运算符会视情况决定是否执行后面的表达式。

  • 条件或(||

    这个运算符在两个布尔值操作数上执行逻辑或运算。如果其中一个或两个都是 true,计算结果为 true;如果两个操作数都是 false,计算结果为 false。和 && 运算符一样,|| 并不总会计算第二个操作数。如果第一个操作数的计算结果为 true,不管第二个操作数的计算结果是什么,表达式的计算结果都是 true。因此,遇到这种情况时,|| 运算符会跳过第二个操作数。

  • 逻辑非(!

    这个运算符改变操作数的布尔值。如果应用于 true,计算结果为 false;如果应用于 false,计算结果为 true。在下面这种表达式中很有用:

    if (!found) ...          // found是其他地方定义的布尔值
    while (!c.isEmpty()) ... // isEmpty()方法返回布尔值
    
    

    ! 是一元运算符,优先级高,经常必须使用括号:

    if (!(x > y && y > z))
    
  • 逻辑与(&

    如果操作数是布尔值,& 运算符的行为和 && 运算符类似,但是不管第一个操作数的计算结果如何,总会计算第二个操作数。不过,这个运算符几乎都用作位运算符,处理整数操作数。很多 Java 程序员都认为使用这个运算符处理布尔值操作数是不合法的 Java 代码。

  • 逻辑或(|

    这个运算符在两个布尔值操作数上执行逻辑或运算,和 || 运算符类似,但是就算第一个操作数的计算结果为 true,也会计算第二个操作数。| 运算符几乎都用作位运算符,处理整数操作数,很少用来处理布尔值操作数。

  • 逻辑异或(^

    如果操作数是布尔值,这个运算符的计算结果是两个操作数的异或。如果两个操作数中只有一个是 true,计算结果才是 true。也就是说,如果两个操作数都是 truefalse,计算结果为 false。这个运算符与 &&|| 不同,始终会计算两个操作数。^ 运算符更常用作位运算符,处理整数操作数。如果操作数是布尔值,这个运算符等效于 != 运算符。

2.4.7 位运算符和位移运算符

位运算符和位移运算符是低层运算符,处理组成整数的单个位。现代 Java 程序很少使用位运算符,除非处理低层操作(例如网络编程)。这两种运算符用于测试和设定整数中的单个标志位。若想理解这些运算符的行为,必须先理解二进制数以及用于表示负整数的二进制补码方式。

这些运算符的操作数不能是浮点数、布尔值、数组或对象。如果操作数是布尔值,&|^ 运算符执行的是其他运算,前一节已经讲过。

如果位运算符的操作数中有一个是 long 类型,结果就是 long 类型。除此之外,结果都是 int 类型。如果位移运算符左边的操作数是 long 类型,结果为 long 类型;否则,结果是 int 类型。位运算符和位移运算符如下。

  • 按位补码(~

    一元运算符 ~ 是按位补码运算符,或叫位非运算符。它把单个操作数的每一位反相,1 变成 0,0 变成 1。例如:

    byte b = ~12;            // ~00001100 ==> 11110011或十进制数-13
    flags = flags & ~f;      // 把标志集合flags中的f标志清除
    
  • 位与(&

    这个运算符在两个整数操作数的每一位上执行逻辑与运算,合并这两个操作数。只有两个操作数的同一位都为 1 时,结果中对应的位才是 1。例如:

    10 & 7                   // 00001010 & 00000111 ==> 00000010或2
    if ((flags & f) != 0)    // 测试是否设定了f标志
    
    

    前面已经说过,如果操作数是布尔值,& 是不常使用的逻辑与运算符。

  • 位或(|

    这个运算符在两个整数操作数的每一位上执行逻辑或运算,合并这两个操作数。如果两个操作数的同一位中有一个或两个都是 1,结果中对应的位是 1;如果两个操作数的同一位都是 0,结果中对应的位是 0。例如:

    10 | 7                   // 00001010 | 00000111 ==> 00001111或15
    flags = flags | f;       // 设定f标志
    
    

    前面已经说过,如果操作数是布尔值,| 是不常使用的逻辑或运算符。

  • 位异或(^

    这个运算符在两个整数操作数的每一位上执行逻辑异或运算,合并这两个操作数。如果两个操作数的同一位值不同,结果中对应的位是 1;如果两个操作数的同一位都是 1 或 都是 0,结果中对应的位是 0。例如:

    10 ^ 7    // 00001010 ^ 00000111 ==> 00001101或13
    
    

    如果操作数是布尔值,^ 是很少使用的逻辑异或运算符。

  • 左移(<<

    << 运算符把左侧操作数的每一位向左移动右侧操作数指定的位数。左侧操作数的高位被丢掉,右边缺少的位补零。整数向左移 n 位,相当于乘于 2n。例如:如果左侧操作数是 long 类型,右侧操作数应该介于 0 和 63 之间。

    10 << 1    // 00001010 << 1 = 00010100 = 20 = 10*2
    7 << 3     // 00000111 << 3 = 00111000 = 56 = 7*8
    -1 << 2    // 0xFFFFFFFF << 2 = 0xFFFFFFFC = -4 = -1*4
    
    

    如果左侧操作数是 int 类型,右侧操作数应该介于 0 和 31 之间。

  • 带符号右移(>>

    >> 运算符把左侧操作数的每一位向右移动右侧操作数指定的位数。左侧操作符的低位被移除,移入的高位和原来的最高位一样。也就是说,如果左侧操作数是正数,移入的高位是 0;如果左侧操作数是负数,移入的高位是 1。这种技术叫高位补符号,作用是保留左侧操作数的符号。例如:

    10 >> 1     // 00001010 >> 1 = 00000101 = 5 = 10/2
    27 >> 3     // 00011011 >> 3 = 00000011 = 3 = 27/8
    -50 >> 2    // 11001110 >> 2 = 11110011 = -13 != -50/4
    
    

    如果左侧操作数是正数,右侧操作数是 n>> 运算符的计算结果相当于整数除以 2n

  • 不带符号右移(>>>

    这个运算符和 >> 类似,但是不管左侧操作数的符号是什么,高位总是移入 0。这种技术叫高位补零。左侧操作数是无符号的数字时才适用这个运算符(可是 Java 的整数类型都带符号)。下面是一些例子:

    0xff >>> 4     // 11111111 >>> 4 = 00001111 = 15 = 255/16
    -50 >>> 2      // 0xFFFFFFCE >>> 2 = 0x3FFFFFF3 = 1073741811
    
    

2.4.8 赋值运算符

赋值运算符把值存储在某种变量中或赋予某种变量。左侧操作数必须是适当的局部变量、数组元素或对象字段。右侧操作数可以是与变量兼容的任何类型。赋值表达式的计算结果是赋予变量的值。不过,更重要的是,赋值表达式的副作用是执行赋值操作。和其他二元运算符不同的是,赋值运算符是右结合的,也就是说,赋值表达式 a=b=c 从右向左执行,即 a=(b=c)

基本的赋值运算符是 =。别把它和相等运算符 == 搞混了。为了区别这两个运算符,我们建议你把 = 读作“被赋值为”。

除了这个简单的赋值运算符之外,Java 还定义了另外 11 个运算符,其中 5 个与算术运算符一起使用,6 个与位运算符和位移运算符一起使用。例如,+= 运算符先读取左侧变量的值,再和右侧操作数相加。这种表达式的副作用是把两数之和赋值给左侧变量,返回值也是两数之和。因此,表达式 x+=2 几乎和 x=x+2 一样。这两种表达式之间的区别是,+= 运算符只会计算一次左侧操作数。如果左侧操作数有副作用,这个区别就体现出来了。如下两个表达式并不等效:

a[i++] += 2;
a[i++] = a[i++] + 2;

组合赋值运算符的一般格式为:

var op= value

(如果 var 没有副作用)等效于:

var = var op value

可用的组合赋值运算符有:

+=    -=    *=    /=    %=  // 算术运算符加赋值运算符

&=    |=    ^=              // 位运算符加赋值运算符

<<=   >>=   >>>=            // 位移运算符加赋值运算符

其中,最常用的运算符是 +=-=,不过处理布尔值标志时,&=|= 也有用。例如:

i += 2;         // 循环计数器增加2
c -= 5;         // 计数器减小5
flags |= f;     // 在一组整数标志flags中设定f标志
flags &= ~f;    // 在一组整数标志flags中清除f标志

2.4.9 条件运算符

条件运算符 ?: 是有点晦涩的三元运算符(有三个操作数),从 C 语言继承而来,可以在一个表达式中嵌入条件判断。这个运算符可以看成是 if/else 语句的运算符版。条件运算符的第一个和第二个操作数使用问号(?)分开,第二个和第三个操作数使用冒号(:)分开。第一个操作数的计算结果必须是布尔值。第二个和第三个操作数可以是任意类型,但要能转换成同一类型。

条件运算符先计算第一个操作数,如果结果为 true,就计算第二个操作数,并把结果当成表达式的返回值;如果第一个操作数的计算结果为 false,条件运算符会计算并返回第三个操作数。条件运算符绝不会同时计算第二个和第三个操作数,所以使用有副作用的表达式时要小心。这个运算符的使用示例如下:

int max = (x > y) ? x : y;
String name = (name != null) ? name : "unknown";

注意,?: 运算符的优先级只比赋值运算符高,比其他运算符都低,所以一般不用把操作数放在括号里。不过,很多程序员觉得,把第一个操作数放在括号里,条件表达式更易读。的确,毕竟 if 语句的条件表达式都放在括号里。

2.4.10 instanceof操作符

instanceof 操作符与对象和 Java 的类型系统联系紧密。如果你是初次接触 Java,建议你跳过这一节,等你对 Java 的对象有充足了解后再看。

instanceof 操作符的左侧操作数是对象或数组,右侧操作数是引用类型的名称。如果对象或数组是指定类型的实例,计算结果为 true;否则,计算结果为 false。如果左侧操作数是 nullinstanceof 操作符的计算结果始终为 false。如果 instanceof 表达式的计算结果为 true,意味着可以放心校正并把左侧操作数赋值给类型为右侧操作数的变量。

instanceof 操作符只能用于引用类型和对象,不能用于基本类型和值。instanceof 操作符的使用示例如下:

// true:所有字符串都是String类的实例
"string" instanceof String
// true:字符串也是Object类的实例
"" instanceof Object
// false:null不是任何类的实例
null instanceof String

Object o = new int[] {1,2,3};
o instanceof int[]   // true:这个数组是int数组
o instanceof byte[]  // false:这个数组不是byte数组
o instanceof Object  // true:所有数组都是Object类的实例

// 使用instanceof确保能放心校正对象
if (object instanceof Point) {
   Point p = (Point) object;
}

2.4.11 特殊运算符

Java 有六种语言结构,有时当成运算符,有时只当成基本句法的一部分。表 2-4 也列出了这些“运算符”,以便说明相对于其他真正运算符的优先级。本书其他地方会详细介绍这些语言结构的用法,不过这里要简要说明一下,以便在代码示例中能识别它们。

  • 访问对象成员(.)

    对象由一些数据和处理这些数据的方法组成。对象的数据字段和方法称为这个对象的成员。点号运算符(.)用来访问这些成员。如果 o 是一个表达式,而且计算结果为对象引用,f 是这个对象的字段名称,那么,o.f 的计算结果是字段 f 中的值。如果 m 是一个方法的名称,那么,o.m 指向这个方法,而且能使用后面介绍的 () 运算符调用。

  • 访问数组中的元素([]

    数组是由编号的值组成的列表。数组中的每个元素都能使用各自的编号(或叫索引)引用。[ ] 运算符能指向数组中的单个元素。如果 a 是一个数组,i 是能计算为 int 类型的表达式,那么,a[i] 指向 a 中的一个元素。这个运算符不像其他处理整数的运算符,它强制要求数组的索引必须是 int 类型或者取值范围更窄的类型。

  • 调用方法(()

    方法是一些有名称的 Java 代码,在这个名称的后面加上括号,并在括号中放零个或多个以逗号分隔的表达式,可以运行(或叫调用)方法。括号中的表达式计算得到的值是方法的参数。方法会处理这些参数,有时还会返回一个值,这个值是方法调用表达式的返回值。如果 o.m 是一个没有参数的方法,那么这个方法可以使用 o.m() 调用。假设这个方法有三个参数,那么可以使用表达式 o.m(x,y,z) 调用。Java 解释器调用方法之前,会先计算传入的参数。这些表达式始终从左至右计算(如果参数有副作用,就能体现顺序的重要性)。

  • lambda表达式(->

    lambda 表达式是一些匿名的 Java 可执行代码,其实就是方法的主体,由方法的参数列表(零个或多个以逗号分隔的表达式,放在括号中)、lambda 箭头运算符和一段 Java 代码组成。如果代码段只有一个语句,可以省略标识块边界常用的花括号。

  • 创建对象(new

    在 Java 中,对象和数组使用 new 运算符创建。运算符后面跟着想创建的对象类型,括号中还可以指定一些传给对象构造方法的参数。构造方法是一种特殊的代码块,用于实例化新建的对象。创建对象的句法和调用方法的句法类似。例如:

    new ArrayList();
    new Point(1,2)
    
  • 类型转换或强制转换(()

    前面已经介绍过,括号还可以当成执行缩小类型转换(或叫强制转换)的运算符。这个运算符的第一个操作数是想转换的类型,放在括号里;第二个操作数是要转换的值,跟在括号后面。例如:

    (byte) 28           // 把整数字面量校正成byte类型
    (int) (x + 3.14f)   // 把浮点数之和强制转换成整数
    (String)h.get(k)    // 把泛型对象强制转换成字符串
    
    

2.5 语句

语句是 Java 语言中可执行代码的基本单位,表达程序员的某个意图。和表达式不同,Java 语句没有返回值。语句一般包含表达式和运算符(尤其是赋值运算符),执行的目的往往是为了它们的副作用。

Java 定义的很多语句是流程控制语句,例如条件语句和循环语句,它们通过合理的方式改变默认的线性执行顺序。表 2-5 总结了 Java 定义的语句。

表2-5:Java语句

语句

作用

句法

表达式

副作用

var = expr; expr++; method(); new Type();

复合语句

语句组

{ statements }

空语句

无作用

;

标注

为语句命名

label: statement

变量

声明变量

[final] type name[=value][,name[=value]] ...;

if

条件判断

if (expr) statement[ else statement]

switch

条件判断

switch (expr) { [ case expr : statements ] ... [ default: statements ] }

while

循环

while (expr) statement

do

循环

do statement while (expr);

for

简单循环

for (init; test; increment) statement

遍历

迭代集合

for (variable : iterable) statement

break

`退出块

break [label];

continue

重新开始循环

continue [label];

return

结束方法

return [expr];

synchronized

临界区

synchronized (expr) {statements}

throw

抛出异常

throw expr;

try

处理异常

try {statements}[ catch (type name) { statements } ] ... [ finally { statements } ]

assert

验证不变式

assert invariant[ :error];

2.5.1 表达式语句

本章前面已经说过,某些 Java 表达式有副作用。也就是说,这些表达式不仅能计算得到的一个值,还能以某种方式改变程序的状态。只要表达式有副作用,在表达式后面加上分号就能作为语句使用。合法的表达式语句有赋值、递增和递减、方法调用以及对象创建。例如:

a = 1;                              // 赋值
x *= 2;                             // 带运算的赋值
i++;                                // 后递增
--c;                                // 前递减
System.out.println("statement");    // 方法调用

2.5.2 复合语句

复合语句是一些放在花括号里的语句,语句的数量和类型不限。Java 句法规定可以使用语句的地方都可以使用复合语句:

for(int i = 0; i < 10; i++) {
   a[i]++;           // 这个循环体是一个复合语句
   b[i]--;           // 包括两个表达式语句
}                    // 放在花括号里

2.5.3 空语句

在 Java 中,空语句使用一个分号表示。空语句什么也不做,不过这种句法偶尔有用。例如,在 for 循环中可以使用空语句表明循环体为空:

for(int i = 0; i < 10; a[i++]++)  // 递增数组元素
     /* empty */;                 // 循环体是空语句

2.5.4 标注语句

标注语句就是有名称的语句。命名方法是,在语句前加上一个标识符和一个冒号。breakcontinue 语句会用到标注。例如:

rowLoop: for(int r = 0; r < rows.length; r++) {        // 一个标注循环
   colLoop: for(int c = 0; c < columns.length; c++) {  // 另一个
     break rowLoop;                                    // 使用标注
   }
}

2.5.5 局部变量声明语句

局部变量经常直接称为变量,是值存储位置的符号名称,在方法和复合语句中定义。所有变量在使用之前必须先声明,声明变量的方法是使用声明语句。Java 是静态类型语言,声明变量时要指定变量的类型,而且只有这种类型的值才能存储在这个变量中。

变量声明语句最简单的形式只需指定变量的类型和名称:

int counter;
String s;

声明变量时还可以包含一个初始化表达式,用于指定变量的初始值。例如:

int i = 0;
String s = readLine();
int[] data = {x+1, x+2, x+3}; // 稍后会介绍数组初始化表达式

Java 编译器不允许使用未初始化的局部变量,所以,方便起见,通常会在一个语句中同时声明和初始化变量。初始化表达式不必是编译器能计算得到结果的字面量或常量表达式,也可以是程序运行时能计算出结果的任意复杂表达式。

一个变量声明语句可以声明和初始化多个变量,但是所有变量必须是同一类型。变量名称和可选的初始化表达式使用逗号分隔:

int i, j, k;
float x = 1.0, y = 1.0;
String question = "Really Quit?", response;

变量声明语句可以以 final 关键字开头。这个修饰符表明,为变量指定初始值之后,其值就不能改变了:

final String greeting = getLocalLanguageGreeting();

后文,尤其是讨论不可变编程风格时,还会说明 final 关键字。

C 语言程序员要注意,在 Java 代码的任何地方都能使用变量声明语句,而不局限于只能在方法和代码块的开头使用。稍后会介绍,局部变量声明还可以集成到 for 循环的初始化部分里。

局部变量只能在其定义所在的方法和代码块中使用,这叫变量的作用域词法作用域

void method() {           // 定义一个方法
   int i = 0;             // 声明变量i
   while (i < 10) {       // 在这个作用域里可以使用i
     int j = 0;           // 声明变量j;j的作用域从这里开始
     i++;                 // 在这个作用域里可以使用i;递增i
   }                      // 在这个作用域里不能使用j了
   System.out.println(i); // 在这个作用域里仍能使用i
}                         // i的作用域在这结束

2.5.6 if/else语句

if 语句是基本的控制语句,允许 Java 作出判断,或者更准确地说,根据条件决定执行哪些语句。if 语句有关联的表达式和语句,如果表达式的计算结果为 true,解释器会执行关联的语句;如果表达式的计算结果为 false,解释器会跳过关联的语句。

 Java 允许在关联的表达式中使用包装类型 Boolean 代替基本类型 boolean。 此时,包装对象会自动拆包。

下面是一个 if 语句示例:

if (username == null)        // 如果username的值是null
   username = "John Doe";    // 使用默认值

虽然括号看起来不重要,但却是 if 语句句法不可缺少的一部分。前面说过,花括号中的语句块本身也是语句,所以 if 语句还可以写成这样:

if ((address == null) || (address.equals(""))) {
   address = "[undefined]";
   System.out.println("WARNING: no address specified.");
}

if 语句可以包含一个可选的 else 关键字,并在后面跟着另一个语句。在这种形式中,如果表达式的计算结果为 true,会执行第一个语句,否则执行第二个语句。例如:

if (username != null)
   System.out.println("Hello " + username);
else {
   username = askQuestion("What is your name?");
   System.out.println("Hello " + username + ". Welcome!");
}

嵌套使用 if/else 语句时要注意,必须确保 else 子句和正确的 if 语句匹配。例如下面的代码:

if (i == j)
   if (j == k)
     System.out.println("i equals k");
else
   System.out.println("i doesn't equal j");    // 错误!!

在这个例子中,根据句法,内层 if 语句是外层 if 语句的单个语句。但是,(除了缩进给出的提示)else 子句和哪个 if 语句匹配并不明确。而且,这个例子的缩进提示也是错的。规则是这样的,else 子句和最近的 if 语句关联。正确缩进后的代码如下:

if (i == j)
   if (j == k)
     System.out.println("i equals k");
   else
     System.out.println("i doesn't equal j");    // 错误!!

这是合法的代码,但显然没有清楚表明程序员的意图。使用嵌套 if 语句时,应该使用花括号,让代码更易读。下面是这个示例更好的编写方式:

if (i == j) {
   if (j == k)
     System.out.println("i equals k");
}
else {
   System.out.println("i doesn't equal j");
}

else if 子句

if/else 语句适用于测试一个条件,并在两个语句或代码块中选择一个执行。那么需要在多个代码块中选择时怎么办呢?这种情况一般使用 else if 子句。这其实不是新句法,而是标准 if/else 语句的惯用句法。用法如下:

if (n == 1) {
    // 执行代码块#1
}
else if (n == 2) {
    // 执行代码块#2
}
else if ( n== 3) {
    // 执行代码块#3
}
else {
    // 如果前面的条件判断都失败,执行代码块#4
}

这段代码没什么特别,只是一系列 if 语句,其中各 if 语句是前一个语句 else 子句的一部 分。较之完全使用嵌套的形式(如下所示),更推荐使用 else if

if (n == 1) {
   // 执行代码块#1
}
else {
   if (n== 2) {
     // 执行代码块#2
   }
   else {
     if (n == 3) {
       // 执行代码块#3
     }
     else {
       // 如果前面的条件判断都失败,执行代码块#4
     }
   }
}

2.5.7 switch语句

if 语句在程序的执行过程中创建一个分支。如前一节所述,可以使用多个 if 语句创建多个分支。但这么做并不总是最好的方式,尤其是所有分支都判断同一个变量的值时,在多个 if 语句中重复检查这个变量的值效率不高。

更好的方式是使用从 C 语言继承而来的 switch 语句。虽然这种语句的句法没有 Java 中其他语句优雅,但是鉴于它的实用性,还是值得使用。

 switch 语句以一个表达式开始,这个表达式的返回值是 intshortcharbyte(或这四个类型的包装类型)、String 或枚举类型(详细介绍参见第 4 章)。

这个表达式后面跟着一段放在花括号里的代码,这段代码中有多个入口点,对应于表达式各个可能的返回值。例如,下面的 switch 语句等效于前一节的多个 ifelse/if 语句:

switch(n) {
   case 1:               // 如果n == 1,从这开始
     // 执行代码块#1
     break;              // 在这停止
   case 2:               // 如果n == 2,从这开始
     // 执行代码块#2
     break;              // 在这停止
   case 3:               // 如果n == 3,从这开始
     // 执行代码块#3
     break;              // 在这停止
   default:              // 如果前面的条件判断都失败了……
     // 执行代码块#4
     break;              // 在这停止
}

从这个示例可以看出,switch 语句中的各入口点有两种形式:一种使用关键字 case 标注,后面跟着一个整数和一个冒号;另一种使用特殊的关键字 default 标注,后面跟着一个冒号。解释器执行 switch 语句时,先计算括号中表达式的值,然后查找有没有匹配这个值的 case 标注。如果有,解释器就从这个 case 标注后的代码块中第一个语句开始执行;如果没有,解释器从特殊的 default 标注后的代码块中第一个语句开始执行;如果没有 default 标注,解释器会跳过整个 switch 语句主体。

注意,在前面的代码中每个 case 子句末尾都有 break 关键字。本章后面会介绍 break 语句,这里,它的作用是让解释器退出 switch 语句的主体。switch 语句中的 case 子句只用来指定需要执行的代码起始点,各 case 子句后的代码块不是相互独立的,没有任何隐式的结束点。因此,必须使用 break 或相关的语句明确指定各 case 子句在哪里结束。如果没有 break 语句,switch 语句会从匹配的 case 标注后第一个语句开始执行,一直到代码块结束为止。极少数的情况下会这样编写代码,从一个 case 标注执行到下一个 case 标注;99% 的情况下都要在每个 casedefault 子句中加上一个语句,结束执行 switch 语句。一般情况下使用 break 语句,不过 returnthrow 语句也行。

switch 语句可以使用多个 case 子句标注同一个希望执行的语句。例如下面这个方法中的 switch 语句:

boolean parseYesOrNoResponse(char response) {
   switch(response) {
     case 'y':
     case 'Y': return true;
     case 'n':
     case 'N': return false;
     default:
       throw new IllegalArgumentException("Response must be Y or N");
   }
}

switch 语句和 case 标注有些重要的限制。首先,switch 语句关联的表达式必须是适当的类型,可以是 bytecharshortint(及这四种类型的包装类型)、枚举类型或 String 类型,不支持浮点数和布尔类型,虽然 long 也是整数类型,但也不能使用。其次,各 case 标注关联的值必须是编译器能计算的常量或常量表达式。case 标注不能包含运行时表达式,例如变量或方法调用。再者,case 标注中的值必须在 switch 表达式返回值对应数据类型的取值范围内。最后,不能有两个或多个 case 标注使用同一个值,而且 default 标注不能超过一个。

2.5.8 while语句

while 语句是一种基本语句,目的是让 Java 执行重复的操作。换言之,while 语句是 Java 的主要循环结构之一。句法如下:

while (expression)
   statement

while 语句先计算 expression 的值,计算结果必须是布尔值。如果计算结果为 false,解释器跳过循环中的 statement,执行程序中的下一个语句。如果计算结果为 true,解释器执行组成循环主体的 statement,然后再次计算 expression 的值。如果计算结果为 false,解释器执行程序中的下一个语句;否则,再次执行 statement。只要 expression 的计算结果为 true,就会一直循环下去,while 语句结束后(即 expression 的计算结果为 false)解释器才会执行下一个语句。

下面是一个 while 循环示例,打印数字 09

int count = 0;
while (count < 10) {
   System.out.println(count);
   count++;
}

可以看出,在这个示例中,变量 count 的起始值是 0,循环主体每执行一次,count 的值就会增加 1。循环执行 10 次后,表达式的计算结果变成 false(即 count 的值不再小于 10),此时 while 语句结束,Java 解释器继续执行程序中的下一个语句。大多数循环都有一个计数器变量,例如这个例子中的 count。循环计数器变量的名称经常使用 ijk,不过你应该使用意义更明确的名字,以便代码更易理解。

2.5.9 do语句

do 循环和 while 循环很像,不过循环表达式不在循环开头,而在循环末尾测试。也就是说,循环主体至少会执行一次。do 循环的句法如下:

do
   statement
while (expression);

注意,do 循环和更普通的 while 循环有几个不同点。首先,do 循环既需要使用关键字 do 标记循环的开头,也要使用关键字 while 标记循环的结尾,以及引入循环条件。其次,与 while 循环不同的是,do 循环的结尾要使用分号。这是因为 do 循环以循环条件结尾,而不是标记循环主体结束的花括号。下面的 do 循环和前面的 while 循环打印相同的结果:

int count = 0;
do {
   System.out.println(count);
   count++;
} while(count < 10);

do 循环比类似的 while 循环少见得多,因为在实际使用中很少遇到一定会至少先执行一次循环的情况。

2.5.10 for语句

for 语句提供的循环结构往往比 whiledo 循环更便利。for 语句利用了一般循环的执行模式。大多数循环都有一个计数器,或者某种形式的状态变量,在循环开始前初始化,然后测试这个变量的值,决定是否执行循环主体,再次计算表达式的值之前,在循环主体末尾递增或者以某种方式更新这个变量的值。初始化、测试和更新,这三步是循环变量的重要操作,for 语句把这三步作为循环句法的明确组成部分:

for(initialize; test; update) {
    statement
}

for 循环基本等同于下面的 while 循环:

initialize;
while (test) {
   statement;
   update;
}

initializetestupdate 三个表达式放在 for 循环的开头,特别有助于理解循环的作用,还能避免一些错误,例如忘记初始化或更新循环变量。解释器会丢掉 initializeupdate 两个表达式的返回值,所以它们必须有副作用。initialize 一般是赋值表达式,update 一般是递增、递减或其他赋值表达式。

下面的 for 循环与前面的 whiledo 循环一样,打印数字 0 到 9:

int count;
for(count = 0 ; count < 10 ; count++)
   System.out.println(count);

注意,这种句法把循环变量的重要信息都放在同一行,更能看清循环的执行方式。而且,把更新循环变量的表达式放在 for 语句中,还简化了循环主体,只剩一个语句,甚至不需要使用花括号组成语句块。

for 循环还支持一种句法,可以让循环更便于使用。很多循环都只在循环内部使用循环变量,因此 for 循环允许 initialize 是一个完整的变量声明表达式,这样循环变量的作用域是循环主体,在循环外部不可见。例如:

for(int count = 0 ; count < 10 ; count++)
   System.out.println(count);

而且,for 循环的句法不限制只能使用一个变量,initializeupdate 表达式都能使用逗号分隔多个初始化和更新表达式。例如:

for(int i = 0, j = 10 ; i < 10 ; i++, j--)
     sum += i * j;

在目前所举的例子中,计数器都是数字,但 for 循环并不限制计数器只能使用数字。例如,可以使用 for 循环迭代链表中的元素:

for(Node n = listHead; n != null; n = n.nextNode())
   process(n);

for 循环中的 initializetestupdate 表达式都是可选的,只有分隔这些表达式的分号是必须的。如果没有 test 表达式,其值假定为 true。因此,可以使用 for(;;) 编写一个无限循环。

2.5.11 遍历语句

Java 的 for 循环能很好地处理基本类型,但处理对象集合时没什么用,而且笨拙。不过,有种叫作“遍历循环”(foreach loop)的句法可以处理需要循环的对象集合。

遍历循环以关键字 for 开头,后面跟着一对括号,括号里是变量声明(不初始化)、冒号和表达式,括号后面是组成循环主体的语句(或语句块):

for( declaration : expression )
     statement

别被遍历循环这个名字迷惑了,它并不使用关键字 foreach。冒号一般读作“……中”,例如“studentNames 中的各个名字”。

介绍 whiledofor 循环时,都举了一个例子,打印 10 个数字。遍历循环也能做到,但需要迭代一个集合。为了循环 10 次(打印 10 个数字),我们需要一个有 10 个元素的数组或其他集合。我们可以使用下面的代码:

// 这些是我们想打印的数字
int[] primes = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 };
// 这是打印这些数字的循环
for(int n : primes)
     System.out.println(n);

遍历不能做的事

遍历和 whilefordo 循环不同,因为它隐藏了循环计数器或 Iterator 对象。介绍 lambda 表达式时会看出来,这种想法很好,但有些算法不能使用遍历循环自然地表达出来。

假如你想把数组中的元素打印出来,各元素使用逗号分隔。为此,要在数组的每个元素后面打印一个逗号,但最后一个元素后面没有逗号;或者说,数组的每个元素前面都要打印一个逗号,但第一个元素前面没有逗号。使用传统的 for 循环,代码可以这样写:

for(int i = 0; i < words.length; i++) {
     if (i > 0) System.out.print(", ");
     System.out.print(words[i]);
}

这是很简单的任务,但遍历做不到,因为遍历循环没有循环计数器,也没有其他能识别第一次、最后一次或中间某次迭代的方式。

 使用遍历循环迭代集合中的元素也有类似的问题。使用遍历循环迭代数组时无法获取当前元素的索引,同样,使用遍历循环迭代集合也无法获取列举集合元素的 Iterator 对象。

还有一些事情遍历循环做不到:

  • 反向迭代数组或 List 对象中的元素;

  • 使用同一个循环计数器获取两个不同数组同一索引位的元素;

  • 通过调用 Listget() 方法遍历元素,而不是使用它的迭代器。

2.5.12 break语句

break 语句让 Java 解释器立即跳出所在的语句块。我们已经见过 break 语句在 switch 语句中的用法。break 语句最常写成关键字 break 后跟一个分号:

break;

这种形式让 Java 解释器立即退出所在的最内层 whiledoforswitch 语句。例如:

for(int i = 0; i < data.length; i++) {
    if (data[i] == target) {  // 找到需要的数据时
        index = i;                  // 记住数据所在的位置
        break;                      // 然后停止查找
    }
}   // 执行break语句后,Java解释器来到这里

break 语句后面也可以跟着标注语句的名称。此时,break 语句让 Java 解释器立即退出指定的语句块。退出的语句块可以是任何类型,不只局限于循环或 switch 语句。例如:

TESTFORNULL: if (data != null) {
   for(int row = 0; row < numrows; row++) {
     for(int col = 0; col < numcols; col++) {
       if (data[row][col] == null)
         break TESTFORNULL;           // 把数组当成未定义
     }
   }
}  // 执行break TESTFORNULL语句后,Java解释器来到这里

2.5.13 continue语句

break 语句的作用是退出循环,而 continue 语句的作用是中止本次循环,开始下一次循环。continue 语句,不管是无标注还是有标注形式,只能在 whiledofor 循环中使用。如果没指定标注,continue 语句让最内层循环开始下一次循环;如果指定了标注,continue 语句让对应的循环开始下一次循环。例如:

for(int i = 0; i < data.length; i++) { // 循环处理数据
   if (data[i] == -1)                  // 如果缺失某个数据
     continue;                         // 跳到下一次循环
   process(data[i]);                   // 处理数据
}

whiledofor 循环中,continue 语句开始下一次循环的方式稍有不同。

  • while 循环中,Java 解释器直接返回循环开头,再次测试循环条件,如果计算结果为 true,再次执行循环主体。

  • do 循环中,解释器跳到循环的末尾,测试循环条件,决定是否要执行下一次循环。

  • for 循环中,解释器跳到循环的开头,先计算 update 表达式,然后计算 test 表达式,以此决定是否继续循环。由示例可以看出,有 continue 语句的 for 循环和基本等效的 while 循环行为不同:在 for 循环中会计算 update 表达式,而在等效的 while 循环中不会计算。

2.5.14 return语句

return 语句告诉 Java 解释器,终止执行当前方法。如果声明方法时指明了有返回值,return 语句后面必须跟着一个表达式。这个表达式的返回值就是这个方法的返回值。例如,下述方法计算并返回一个数字的平方:

double square(double x) {    // 计算x平方的方法
   return x * x;             // 计算并返回一个值
}

有些方法声明时使用了 void,指明不返回任何值。Java 解释器运行这种方法时,会依次执行其中的语句,直到方法结束为止。执行完最后一个语句时,解释器隐式返回。然而,有时没有返回值的方法要在到达最后一个语句之前显式返回。此时,可以使用后面没有任何

表达式的 return 语句。例如,下述方法只打印不返回参数的平方根。如果参数是负数,直接返回,不打印任何内容:

// 打印x平方根的方法
void printSquareRoot(double x) {
   if (x < 0) return;                // 如果x是负数,返回
   System.out.println(Math.sqrt(x)); // 打印x的平方根
}                                    // 方法结束,隐式返回

2.5.15 synchronized语句

Java 一直支持多线程编程,后文会详细介绍这个话题(尤其是 6.5 节)。不过读者要注意,并发并不容易掌控,并且有很多细节需要了解。

具体而言,处理多线程时,经常必须避免多个线程同时修改同一个对象,以防对象的状态有冲突。Java 提供的 synchronized 语句可以帮助程序员,避免发生冲突。synchronized 语句的句法为:

synchronized ( expression ) {
   statements
}

expression 代表一个对象或者一个数组。statements 是能导致破坏的代码块,必须放在花括号里。

执行语句块之前,Java 解释器先为 expression 所定义的对象或数组获取一个排它锁(exclusive lock),直到语句块执行完毕后再释放。只要某个线程拥有对象的排它锁,其他线程就不能再获取这个锁。

在 Java 中,synchronized 关键字还可以作为方法的修饰符。应用于方法时,synchronized 关键字指明整个方法都被锁定。如果 synchronized 关键字应用于类方法(静态方法),执行方法前,Java 会先为这个类获取一个排它锁。如果 synchronized 关键字应用于实例方法,Java 为类的实例获取一个排它锁。(类和实例在第 3 章介绍。)

2.5.16 throw语句

异常是一种信号,表明发生了某种异常状况或错误。抛出异常时表明有未预料的状况发生。捕获异常的目的是处理异常,使用必要的操作修复。在 Java 中,throw 语句用于抛出异常:

throw expression;

expression 必须是一个异常对象,说明发生了什么异常或错误。稍后会详细介绍异常的种类,现在你只需知道,异常通过有点特殊的对象表示。下面是抛出异常的示例代码:

public static double factorial(int x) {
   if (x < 0)
     throw new IllegalArgumentException("x must be >= 0");
   double fact;
   for(fact=1.0; x > 1; fact *= x, x--)
     /* empty */ ;          // 注意,使用的是空语句
   return fact;
}

Java 解释器执行 throw 语句时,会立即停止常规的程序执行,开始寻找能捕获或处理异常的异常处理程序。异常处理程序使用 try/catch/finally 语句编写,下一节会介绍。Java 解释器先在当前代码块中查找异常处理程序,如果有,解释器会退出这个代码块,开始执行异常处理代码。异常处理程序执行完毕后,解释器会继续执行处理程序后的语句。

如果当前代码块中没有适当的异常处理程序,解释器会在外层代码块中寻找,直到找到为止。如果方法中没有能处理 throw 语句抛出的异常的异常处理程序,解释器会停止运行当前方法,返回调用这个方法的地方,开始在调用方法的代码块中寻找异常处理程序。Java 通过这种方式,通过方法的词法结构不断向上冒泡,顺着解释器的调用堆栈一直向上寻找。如果一直没有捕获异常,就会冒泡到程序的 main() 方法。如果在 main() 方法中也没有处理异常,Java 解释器会打印一个错误消息,还会打印一个堆栈跟踪,指明这个异常在哪里发生,然后退出。

2.5.17 try/catch/finally语句

Java 有两种稍微不同的异常处理机制。经典形式是使用 try/catch/finally 语句。这个语句的 try 子句是可能抛出异常的代码块。try 代码块后面是零个或多个 catch 子句,每个子句用于处理特定类型的异常,而且能处理多个不同类型的异常。如果 catch 块要处理多个异常,使用 | 符号分隔各个不同的异常。catch 子句后面是一个可选的 finally 块,包含清理代码,不管 try 块中发生了什么,始终都会执行。

try 块的句法

catchfinally 子句都是可选的,但每个 try 块都必须有这两个子句中的一个。trycatchfinally 块都放在花括号里。花括号是句法必须的一部分,即使子句只包含一个语句也不能省略。

下述代码演示了 try/catch/finally 语句的句法和作用:

try {
   // 正常情况下,这里的代码从上到下运行,没有问题
   // 但是,有时可能抛出异常
   // 可能是throw语句直接抛出
   // 也可能是调用的方法间接抛出
}
catch (SomeException e1) {
   // 这段代码中的语句用于处理SomeException或其子类类型的异常对象
   // 在这段代码中,可以使用名称e1引用那个异常对象
}
catch (AnotherException | YetAnotherException e2) {
   // 这段代码中的语句用于处理AnotherException、YetAnotherException
   // 或二者的子类类型的异常。在这段代码中,使用名称e2引用传入的异常对象
}
finally {
   // 不管try子句的结束方式如何,这段代码中的语句都会执行:
   //   1)正常结束:到达块的末尾
   //   2)由break、continue或return语句导致
   //   3)抛出异常,由上述catch子句处理
   //   4)抛出异常,未被捕获处理
   // 但是,如果在try子句中调用了System.exit(),解释器会立即退出
   // 不执行finally子句
}

1. try 子句

try 子句的作用很简单,组建一段代码,其中有异常需要处理,或者因某种原因终止执行后需要使用特殊的代码清理。try 子句本身没什么用,异常处理和清理操作在 catchfinally 子句中进行。

2. catch 子句

try 块后面可以跟着零个或多个 catch 子句,指定处理各种异常的代码。每个 catch 子句只有一个参数(可以使用特殊的 | 句法指明 catch 块能处理多种异常类型),指定这个子句能处理的异常类型,以及一个名称,用来引用当前处理的异常对象。catch 块能处理的类型必须是 Throwable 的子类。

有异常抛出时,Java 解释器会寻找一个 catch 子句,它的参数要和异常对象的类型相同,或者是这个类型的子类。解释器会调用它找到的第一个这种 catch 子句。catch 块中的代码应该执行处理异常状况所需的任何操作。假如异常是 java.io.FileNotFoundException,此时或许要请求用户检查拼写,然后重试。

不是所有可能抛出的异常都要有一个 catch 子句处理,有些情况下,正确的处理方式是让异常向上冒泡,由调用方法捕获。还有些情况,例如表示程序错误的 NullPointerException 异常,正确的处理方式或许是完全不捕获,随它冒泡,让 Java 解释器退出,打印堆栈跟踪和错误消息。

3. finally 子句

finaly 子句放在 try 子句后面,一般用来执行清理操作(例如关闭文件和网络连接)。finally 子句很有用,因为不管 try 块中的代码以何种方式结束执行,只要有代码执行,finally 子句中的代码就会执行。事实上,只有一种方法能让 try 子句退出而不执行 finally 子句——调用 System.exit() 方法,让 Java 解释器停止运行。

正常情况下,执行到 try 块的末尾后会继续执行 finally 块,做必要的清理工作。如果因为 returncontinuebreak 语句而离开 try 块,会先执行 finally 块,然后再转向新的目标代码。

如果 try 块抛出了异常,而且有处理该异常的 catch 块,那么先执行 catch 块,然后在执行 finally 块。如果本地没有能处理该异常的 catch 块,先执行 finally 块,然后再向上冒泡到能处理该异常最近的 catch 子句。

如果 finally 块使用 returncontinuebreakthrow 语句,或者调用的方法抛出了异常,从而转移了控制权,那么待转移的控制权中止,改为执行新的控制权转移。例如,如果 finally 子句抛出了异常,这个异常会取代任何正在抛出的异常。如果 finally 子句使用了 return 语句,就算抛出的异常还没处理,方法也会正常返回。

tryfinally 子句可以放在一起使用,不处理异常,也没有 catch 子句。此时,finally 块只是负责清理的代码,不管 try 子句中有没有 breakcontinuereturn 语句,都会执行。

2.5.18 处理资源的try语句

try 块的标准形式很通用,但有些常见的情况需要开发者小心编写 catchfinally 块。这些情况是清理或关闭不再需要使用的资源。

Java(从第 7 版起)提供了一种很有用的机制,能自动关闭需要清理的资源——处理资源的 try 语句(try-with-resources,TWR)。10.1 节会详细介绍 TWR,但为了本节的完整,先介绍它的句法。下面的示例展示了如何使用 FileInputStream 类打开文件(得到的对象需要清理):

try (InputStream is = new FileInputStream("/Users/ben/details.txt")) {
  // ……处理这个文件
}

这种新型 try 语句的参数都是需要清理的对象。2 这些对象的作用域在 try 块中,不管 try 块以何种方式退出,都会自动清理。开发者无需编写任何 catchfinally 块,Java 编译器会自动插入正确的清理代码。

2严格来说,这些对象必须实现 AutoCloseable 接口。

所有处理资源的新代码都应该使用 TWR 形式编写,因为这种形式比自己动手编写 catch 块更少出错,而且不会遇到麻烦的技术问题,例如终结(详情参见 6.4 节)。

2.5.19 assert语句

assert 语句用来验证 Java 代码的设计假想。断言(assertion)由 assert 关键字和布尔表达式组成,程序员认为布尔表达式的计算结果始终应该为 true。默认情况下断言未启用,assert 语句什么作用也没有。

不过,可以启用断言,作为一种调试工具。启用后,assert 语句会计算表达式。如果表达式的计算结果确是 trueassert 语句什么也不做;如果计算结果是 false,断言失败,assert 语句抛出 java.lang.AssertionError 异常。

 在 JDK 库之外,极少使用 assert 语句。用它测试大多数应用都不灵便,一般的开发者很少使用,不过有时用来现场调试复杂的多线程应用。

assert 语句可以包含可选的第二个表达式,使用冒号和第一个表达式分开。如果启用了断言,而且第一个表达式的计算结果为 false,那么第二个表达式的值会作为错误代码或错误消息传给 AssertionError() 构造方法。assert 语句的完整句法如下:

assert assertion;

或者:

assert assertion : errorcode;

为了有效使用断言,必须注意几处细节。首先,要记住,一般情况下程序没有启用断言,只有少数情况才会启用。这意味着,编写断言表达式时要小心,不能有副作用。

 绝不要在自己编写的代码中抛出 AssertionError 异常,如果这么做,可能会在 Java 平台未来的版本中得到意料之外的结果。

如果抛出了 AssertionError 异常,表明程序员的假想之一没有实现。这意味着,在设计的使用范围之外使用了代码,无法正常运行。简单来说,没有看似合理的方式能从 AssertionError 异常中恢复,因此不要尝试捕获这个异常(除非在顶层简单捕获,以对用户更友好的方式显示错误)。

启用断言

为了效率,不应该在每次执行代码时都测试断言,因为 assert 语句认为假想始终为真。因此,默认情况下禁用了断言,assert 语句没有作用。不过,断言代码还是会编译到类文件中,所以需要诊断或调试时可以启用断言。断言可以全局启用,也可以把命令行参数传给 Java 解释器,有选择性地启用。

如果想为系统类之外的所有类启用断言,使用 -ea 参数。如果想为系统类启用断言,使用 -esa 参数。如果想为某个具体的类启用断言,使用 -ea 参数,后跟一个冒号和类名:

java -ea:com.example.sorters.MergeSort com.example.sorters.Test

如果想为包中所有的类和子包启用断言,在 -ea 参数后面加上冒号、包名和三个点号:

java -ea:com.example.sorters... com.example.sorters.Test

使用 -da 参数,通过相同的方式可以禁用断言。例如,为整个包启用断言,但在某个类或子包中禁用,可以这么做:

java -ea:com.example.sorters... -da:com.example.sorters.QuickSort
java -ea:com.example.sorters... -da:com.example.sorters.plugins...

最后,类加载时可以控制是否启用断言。如果在程序中使用自定义的类加载程序(第 11 章会详细介绍自定义类加载),而且想启用断言,可能会对这些方法感兴趣。

2.6 方法

方法是有名称的 Java 语句序列,可被其他 Java 代码调用。调用方法时,可以传入零个或多个值,这些值叫参数。方法执行一些计算,还可以返回一个值。2.4 节介绍过,方法调用是 Java 解释器计算的表达式。不过,因为方法调用可以有副作用,因此,也能作为表达式语句使用。本节不讨论方法调用,只说明如何定义方法。

2.6.1 定义方法

你已经知道如何定义方法的主体了,方法主体就是放在花括号里的任意语句序列。更有趣的是方法的签名(signature)。3 签名指定下述内容:

3在 Java 语言规范中,术语“signature”有技术层面的意义,和这里使用的稍有不同。本书使用方法签名较不正式的定义。

  • 方法的名称;

  • 方法所用参数的数量、顺序、类型和名称;

  • 方法的返回值类型;

  • 方法能抛出的已检异常(签名还能列出未检异常,不过不是必需的);

  • 提供方法额外信息的多个方法修饰符。

方法签名定义了调用方法之前需要知道的一切信息,是方法的规范,而且定义了方法的 API。若想使用 Java 平台的在线 API 参考指南,需要知道如何阅读方法签名。若想编写 Java 程序,需要知道如何定义自己的方法。方法都以方法签名开头。

方法签名的格式如下:

modifiers type name ( paramlist ) [ throws exceptions ]

签名(方法规范)后面是方法主体(方法的实现),即放在花括号里的 Java 语句序列。抽象方法(参见第 3 章)没有实现部分,方法主体使用一个分号表示。

方法签名中可能包含类型变量声明,这种方法叫泛型方法(generic method)。泛型方法和类型变量在第 4 章介绍。

下面是一些方法定义示例,都以签名开头,后面跟着方法主体:

// 这个方法传入的是字符串数组,没有返回值
// 所有Java程序的入口都是这个名称和签名
public static void main(String[] args) {
     if (args.length > 0) System.out.println("Hello " + args[0]);
     else System.out.println("Hello world");
}

// 这个方法传入两个double类型的参数,返回一个double类型的数字
static double distanceFromOrigin(double x, double y) {
     return Math.sqrt(x*x + y*y);
}

// 这是抽象方法,没有主体
// 注意,调用这个方法时可能会抛出异常
protected abstract String readText(File f, String encoding)
    throws FileNotFoundException, UnsupportedEncodingException;

modifiers 是零个或多个特殊的修饰符关键字,之间使用空格分开。例如,声明方法时可以使用 publicstatic 修饰符。允许使用的修饰符及其意义在下一节介绍。

方法签名中的 type 指明方法返回值的类型。如果方法没有返回值,type 必须是 void。如果声明方法时指定了返回类型,就必须包含一个 return 语句,返回一个符合(或能转换为)所声明类型的值。

构造方法是一段类似方法的代码,用于初始化新建的对象。第 3 章会介绍,构造方法的定义方式和方法类似,不过签名中没有 type 部分。

方法的修饰符和返回值类型后面是 name,即方法名。方法名和变量名一样,也是 Java 标识符。和所有 Java 标识符一样,方法名可以包含 Unicode 字符集能表示的任何语言的字母。定义多个同名方法是合法的,往往也很有用,只要各方法的参数列表不同就行。定义多个同名方法叫方法重载(method overloading)。

 和某些其他语言不同,Java 没有匿名方法。不过,Java 8 引入了 lambda 表达式,作用类似于匿名方法,但是 Java 运行时会自动把 lambda 表达式转换成适当的具名方法,详情参见 2.7.5 节。

例如,我们见过的 System.out.println() 方法就是重载方法。具有这个名字的某个方法打印字符串,而具有这个名字的其他方法打印各种基本类型的值。Java 编译器根据传入这个方法的参数类型决定调用哪个方法。

定义方法时,方法名后一定是方法的形参列表(parameters list),而且必须放在括号里。形参列表定义零个或多个传入方法的实参(argument)。4 如果有形参的话,每个形参都包含类型和名称,(如果有多个形参)形参之间使用逗号分开。调用方法时,传入的实参值必须和该方法签名中定义的形参数量、类型和顺序匹配。传入的值不一定要和签名中指定的类型一样,但是必须能不经校正转换为对应的类型。

4parameter 是定义方法时声明的参数,argument 是调用方法时传入的参数。如果二者同时出现,parameter 译为“形参”,argument 译为“实参”。在不引起歧义的情况下,则都译为“参数”。——译者注

 如果 Java 方法没有实参,其形参列表是 (),而不是 (void)。C 和 C++ 程序员要特别注意,Java 不把 void 当作一种类型。

Java 允许程序员定义和调用参数数量不定的方法,使用的句法叫变长参数(varargs),本章后面会详细介绍。

方法签名的最后一部分是 throws 子句,列出方法能抛出的已检异常(checked exception)。已检异常是一系列异常类,必须在能抛出它们的方法中使用 throws 子句列出。如果方法使用 throw 语句抛出一个已检异常,或者调用的其他方法抛出一个没有捕获或处理的已检异常,声明这个方法时就必须指明能抛出这个异常。如果方法能抛出一个或多个已检异常,要在参数列表后面使用 throws 关键字指明能抛出的异常类。如果方法不会抛出异常,无需使用 throws 关键字。如果方法抛出的异常类型不止一个,要使用逗号分隔异常类的名称。稍后还会再说明。

2.6.2 方法修饰符

方法的修饰符包含零个或多个修饰符关键字,例如 publicstaticabstract。下面列出允许使用的修饰符及其意义。

  • abstract

    使用 abstract 修饰的方法没有实现主体。组成普通方法主体的花括号和 Java 语句使用一个分号代替。如果类中有使用 abstract 修饰的方法,类本身也必须使用 abstract 声明。这种类不完整,不能实例化(参见第 3 章)。

  • final

    使用 final 修饰的方法不能被子类覆盖或隐藏,能获得普通方法无法得到的编译器优化。所有使用 private 修饰的方法都隐式添加了 final 修饰符;使用 final 声明的任何类,其中的所有方法也都隐式添加 final 修饰符。

  • native

    native 修饰符表明方法的实现使用某种“本地”语言编写,例如 C 语言,并且开放给 Java 程序使用。native 修饰的方法和 abstract 修饰的方法一样,没有主体:花括号使用一个分号代替。

    实现 native 修饰的方法

    Java 刚出现时,使用 native 修饰方法有时是为了提高效率。现在几乎不需要这么做了。现在,使用 native 修饰方法的目的是,把 Java 代码集成到现有的 C 或 C++ 库中。native 修饰的方法和所在平台无关,如何把实现和方法声明所在的 Java 类链接起来,取决于 Java 虚拟机的实现方式。本书没有涵盖使用 native 修饰的方法。

  • publicprotectedprivate

    这些访问修饰符指定方法是否能在定义它的类之外使用,或者能在何处使用。这些非常重要的修饰符在第 3 章说明。

  • static

    使用 static 声明的方法是类方法,关联在类自己身上,而不是类的实例身上(第 3 章会详细说明)。

  • strictfp

    在这个很少使用的奇怪修饰符中,fp 的意思是“浮点”(floating point)。一般情况下,Java 会利用运行时所在平台的浮点硬件提供的可用扩展精度。添加这个关键字后,运行 strictfp 修饰的方法时,Java 会严格遵守标准,而且就算结果不精确,也只使用 32 位或 64 位浮点数格式进行浮点运算。

  • synchronized

    synchronized 修饰符的作用是实现线程安全的方法。线程调用 synchronized 修饰的方法之前,必须先为方法所在的类(针对 static 修饰的方法)或对应的类实例(针对没使用 static 修饰的方法)获取一个锁,避免两个线程同时执行该方法。

    synchronized 修饰符是实现的细节(因为方法可以通过其他方式实现线程安全),不是方法规范或 API 的正式组成部分。好的文档应该明确说明方法是否线程安全;当编写多线程程序时,不应通过方法中是否有 synchronized 关键词来判断方法是否线程安全。

 注解是特例(注解的详细介绍参见第 4 章)——注解可以看作方法修饰符和额外补充信息的折中方案。

2.6.3 已检异常和未检异常

Java 的异常处理机制会区分两种不同的异常类型:已检异常未检异常

已检异常和未检异常之间的区别在于异常在什么情况下抛出。已检异常在明确的特定情况下抛出,经常是应用能部分或完全恢复的情况。

例如,某段代码要在多个可能的目录中寻找配置文件。如果试图打开的文件不在某个目录中,就会抛出 FileNotFoundException 异常。在这个例子中,我们想捕获这个异常,然后在文件可能出现的下一个位置继续尝试。也就是说,虽然文件不存在是异常状况,但可以从中恢复,这是意料之中的失败。

然而,在 Java 环境中有些失败是无法预料的,这些失败可能是由运行时条件或滥用库代码导致的。例如,无法正确预知 OutOfMemoryError 异常;又如,把无效的 null 传给使用对象或数组的方法,会抛出 NullPointerException 异常。

这些是未检异常。基本上任何方法在任何时候都可能抛出未检异常。这是 Java 环境中的墨菲定律:“会出错的事总会出错。”从未检异常中恢复,虽说不是不可能,但往往很难,因为完全不可预知。

若想区分已检异常和未检异常,记住两点:异常是 Throwable 对象,而且异常主要分为两类,通过 ErrorException 子类标识。只要异常对象是 Error 类,就是未检异常。Exception 类还有一个子类 RuntimeExceptionRuntimeException 类的所有子类都属于未检异常。除此之外,都是已检异常。

处理已检异常

Java 为已检异常和未检异常制定了不同的规则。如果定义的方法会抛出已检异常,就必须在方法签名的 throws 子句中声明这个异常。Java 编译器会检查方法签名,确保的确声明了;如果没声明,会导致编译出错(所以才叫“已检异常”)。

就算自己从不抛出已检异常,有时也必须使用 throws 子句声明已检异常。如果方法中调用了会抛出已检异常的方法,要么加入异常处理代码处理这个异常,要么使用 throws 子句声明这个方法也能抛出这个异常。

例如,下述方法使用标准库中的 java.netURL 类(第 10 章会介绍)访问网页,尝试估算网页的大小。所用的方法和构造方法会抛出各种 java.io.IOException 异常对象,所以在 throws 子句中声明了:

public static int estimateHomepageSize(String host) throws IOException {
    URL url = new URL("htp://"+ host +"/");
    try (InputStream in = url.openStream()) {
        return in.available();
    }
}

其实,上述代码有个问题:协议名拼写错了——没有名为 htp:// 的协议。所以,estimateHomepageSize() 方法会一直失败,抛出 MalformedURLException 异常。

你怎么知道要调用的方法会抛出已检异常呢?可以查看这个方法的签名。如果签名中没有,但又必须处理或声明调用的方法抛出的异常时,Java 编译器会(通过编译错误消息)告诉你。

2.6.4 变长参数列表

方法可以声明为接受数量不定的参数,调用时也可以传入数量不定的参数。这种方法一般叫作变长参数方法。格式化打印方法 System.out.printf()String 类相关的 format() 方法,以及 java.lang.reflect 中反射 API 的一些重要方法,都使用变长参数。

变长参数列表的声明方式为,在方法最后一个参数的类型后面加上省略号(...),指明最后一个参数可以重复零次或多次。例如:

public static int max(int first, int... rest) {
    /* 暂时省略主体 */
}

变长参数方法纯粹由编译器处理,把数量不定的参数转换为一个数组。对 Java 运行时来说,上面的 max() 方法和下面这个没有区别:

public static int max(int first, int[] rest) {
    /* 暂时省略主体 */
}

把变长参数方法的签名转换为真正的签名,只需把 ... 换成 []。记住,参数列表中只能有一个省略号,而且只能出现在最后一个参数中。

下面填充 max() 方法的主体:

public static int max(int first, int... rest) {
    int max = first;
    for(int i : rest) { // 合法,因为rest其实就是数组
        if (i > max) max = i;
    }
    return max;
}

声明这个 max() 方法时指定了两个参数,第一个是普通的 int 类型值,但是第二个可以重复零次或多次。下面对 max() 方法的调用都是合法的:

max(0)
max(1, 2)
max(16, 8, 4, 2, 1)

因为变长参数方法被编译成接受数组参数的方法,所以在编译对这类方法的调用得到的代码中,包含创建和初始化这个数组的代码。因此,调用 max(1,2,3) 被编译成:

max(1, new int[] { 2, 3 })

其实,如果参数的方法已经存储在数组中,完全可以直接把数组传给变长参数方法,而不用把数组中的元素取出来一个一个传入。... 参数可以看成一个数组。不过,反过来就不行了:只有使用省略号声明为变长参数方法,才能使用变长参数方法调用的句法。

2.7 介绍类和对象

我们已经介绍了运算符、表达式、语句和方法,终于可以介绍类了。类是一段代码的名称,其中包含很多保存数据值的字段和操作这些值的方法。是 Java 支持的五种引用类型之一,而且是最重要的一种。我们会在单独的一章(第 3 章)全面介绍类。这里之所以要介绍,是因为类是继方法之后的另一种高级句法,而且本章剩下的内容需要对类的概念有基本的认识,要知道定义类、实例化类和使用所得对象的基本句法。

关于类最重要的事情是,它们定义了一种新数据类型。例如,可以定义一个名为 Point 的类,表示笛卡尔二维坐标系中的数据点。这个类可能会定义两个字段,保存点的 xy 坐标,还可能会定义处理和操作点的方法。Point 类就是一个新数据类型。

谈论数据类型时,要把数据类型和数据类型表示的值区分开,这一点很重要。char 是一种数据类型,用于表示 Unicode 字符。但是一个 char 类型的值表示某个具体的字符。类是一种数据类型,而类表示的值是对象。我们使用“类”这个名称的原因是,每个类定义一种对象。Point 类是一种数据类型,用于表示 (x, y ) 点,而 Point 对象表示某个具体的 (x, y ) 点。正如你想得那样,类和类的对象联系紧密。在接下来的几节中,会介绍这两个概念。

2.7.1 定义类

前面讨论的 Point 类可以使用下面的方式定义:

/** 表示笛卡尔坐标系中的(x,y)点 */
public class Point {
     // 点的坐标
     public double x, y;
     public Point(double x, double y) {    // 初始化字段的构造方法
         this.x = x; this.y = y;
     }

     public double distanceFromOrigin() {  // 操作x和y字段的方法
         return Math.sqrt(x*x + y*y);
     }
}

这个类的定义保存在一个名为 Point.java 的文件中,然后编译成一个名为 Point.class 的文件,供 Java 程序和其他类使用。现在定义这个类只是为了完整性,并提供上下文,不要奢望能完全理解所有细节。第 3 章的大部分内容会专门讲解如何定义类。

记住,你不需要定义想在 Java 程序中使用的每个类。Java 平台包含上千个预先定义好的类,在每台运行 Java 的电脑中都能使用。

2.7.2 创建对象

我们已经定义了 Point 类,现在 Point 是一种新数据类型,我们可以使用下面的代码声明一个变量,存储一个 Point 对象:

Point p;

不过,声明一个存储 Point 对象的变量并不会创建这个对象。要想创建对象,必须使用 new 运算符。这个关键字后面跟着对象所属的类(即对象的类型)和括号中可选的参数列表。这些参数会传入类的构造方法,初始化新对象的内部字段:

// 创建一个Point对象,表示(2,-3.5)
// 声明一个变量p,存储这个新Point对象的引用
Point p = new Point(2.0, -3.5);

// 创建一些其他类型的对象
// 一个Date对象,表示当前时间
Date d = new Date();
// 一个HashSet对象,保存一些对象
Set words = new HashSet();

new 关键字是目前为止在 Java 中创建对象最常用的方式。还有一些其他方式也有必要提一下。首先,有些符合特定条件的类很重要,Java 为这些类定义了专用的字面量句法,用于创建这些类型的对象(本节后面会介绍)。其次,Java 支持动态加载机制,允许程序动态

加载类和创建类的实例,详情参见第 11 章。最后,对象还可以通过反序列化创建。对象的状态可以保存或序列化到一个文件中,然后可以使用 java.io.ObjectInputStream 类重新创建这个对象。

2.7.3 使用对象

我们已经知道如何定义类,如何通过创建对象实例化类,现在要介绍使用对象的 Java 句法。前面说过,类定义了一些字段和方法。每个对象都有自己的字段副本,而且可以访问类中的方法。我们使用点号(.)访问对象的具名字段和方法。例如:

Point p = new Point(2, 3);         // 创建一个对象
double x = p.x;                    // 读取这个对象的一个字段
p.y = p.x * p.x;                   // 设定一个字段的值
double d = p.distanceFromOrigin(); // 访问这个对象的一个方法

这种句法在面向对象语言中很常见,Java 也不例外,因此会经常见到。特别注意一下 p.distanceFromOrigin()。这个表达式告诉 Java 编译器,查找一个名为 distanceFromOrigin() 的方法(在 Point 类中定义),然后使用这个方法对 p 对象的字段进行计算。第 3 章会详细介绍这种操作。

2.7.4 对象字面量

介绍基本类型时我们看到,每种基本类型都有字面量句法,可以直接在程序的代码中插入各种类型的值。Java 还为一些特殊的引用类型定义了字面量句法,介绍如下。

1. 字符串字面量

String 类使用一串字符表示文本。因为程序经常需要通过文字和用户沟通,所以在任何编程语言中处理文本字符串的能力都十分重要。在 Java 中,字符串是对象,表示文本的数据类型是 String 类。现代 Java 程序使用的字符串数据通常比其他程序都多。

因为字符串是如此基本的数据类型,所以 Java 允许在程序中插入文本字面量,方法是把字符放在双引号(")中。例如:

String name = "David";
System.out.println("Hello, " + name);

别把字符串字面量两侧的双引号和字符字面量两侧的单引号搞混了。字符串字面量可以包含字符字面量中能使用的任何一个转义序列(参见表 2-2)。在双引号包围的字符串字面量中嵌入双引号时,转义序列特别有用。例如:

String story = "\t\"How can you stand it?\" he asked sarcastically.\n";

字符串字面量中不能包含注释,而且只能有一行。Java 不支持把两行当成一行的任何接续字符。如果需要表示一串长文本,一行写不下,可以把这个文本拆成多个单独的字符串字面量,再使用 + 运算符把它们连接起来。例如:

// 这么写不合法,字符串字面量不能断行
String x = "This is a test of the
            emergency broadcast system";

String s = "This is a test of the " +      // 要这么写
           "emergency broadcast system";

这种字面量连接在编译程序时,而不是运行时完成,所以无需担心性能会降低。

2. 类型字面量

第二种支持专用对象字面量句法的类型是 Class 类。Class 类的实例表示一种 Java 数据类型,而且包含所表示类型的元数据。若想在 Java 程序中使用 Class 对象字面量,要在数据类型的名称后面加上 .class。例如:

Class<?> typeInt = int.class;
Class<?> typeIntArray = int[].class;
Class<?> typePoint = Point.class;

3. null 引用

null 关键字是一种特殊的字面量,引用不存在的值,或者不引用任何值。null 这个值是独一无二的,因为它是任何一种引用类型的成员。null 可以赋值给属于任何引用类型的变量。例如:

String s = null;
Point p = null;

2.7.5 lambda表达式

Java 8 引入了一个重要的新功能——lambda 表达式。这是十分常见的编程语言结构,在函数式编程语言(Functional Programming Language,例如 Lisp、Haskell 和 OCaml)中使用范围极广。lambda 表达式的功能和灵活性远非局限于函数式语言,在几乎所有的现代编程语言中都能看到它的身影。

定义 lambda 表达式

lambda 表达式其实就是没有名称的函数,在 Java 中可以把它当成一个值。Java 不允许脱离类的概念运行方法,所以 lambda 表达式是在某个类中定义的匿名方法(开发者可能不知道具体是哪个类)。

lambda 表达式的句法如下:

( paramlist ) -> { statements }

下面是一个十分传统的简单示例:

Runnable r = () -> System.out.println("Hello World");

lambda 表达式当成值使用时,会根据要存储的变量类型,自动转换为相应的对象。自动转换和类型推导是 Java 实现 lambda 表达式的基础。但是,这要求正确地理解 Java 的整个类型系统。4.5 节会详细说明 lambda 表达式,现在只需知道句法。

下面是个稍微复杂的示例:

ActionListener listener = (e) -> {
  System.out.println("Event fired at: "+ e.getWhen());
  System.out.println("Event command: "+ e.getActionCommand());
};

2.8 数组

数组是一种特殊的对象,保存零个或多个基本类型或引用类型的值。这些值是数组的元素,是通过所在位置或索引引用的无名变量。数组的类型通过元素的类型表示,数组中的所有元素必须都属于这个类型。

数组元素的编号从零开始,有效的索引范围是零到元素数量减一。例如,索引为 1 的元素,是数组中的第二个元素。数组中的元素数量是数组的长度。数组的长度在创建时指定,从此就不能改变。

数组中元素的类型可以是任何有效的 Java 类型,包括数组类型。也就是说,Java 支持由数组组成的数组,实现多维数组。Java 不支持其他语言中的矩阵式多维数组。

2.8.1 数组的类型

数组的类型和类一样,也是引用类型。数组的实例和类的实例一样,也是对象。5 和类不同的是,数组的类型不用定义,只需在元素类型后面加上一对中括号即可。例如,下述代码声明了三种不同类型的数组:

5讨论数组时,有个术语上的难题。与类和类的实例不同,数组的类型和数组实例都使用“数组”这个术语表示。在实际使用中,一般通过上下文能分清讨论的是类型还是值。

byte b;                        // byte是基本类型
byte[] arrayOfBytes;           // byte[]是由byte类型的值组成的数组
byte[][] arrayOfArrayOfBytes;  // byte[][]是由byte[]类型的值组成的数组
String[] points;               // String[]是由字符串组成的数组

数组的长度不是数组类型的一部分。例如,声明一个方法,并且期望传入恰好由四个 int 类型的值组成的数组,是不可能的。如果方法的参数类型是 int[],调用时传入的数组可以包含任意个元素(包括零个)。

数组类型不是类,但数组实例是对象。这意味着,数组从 java.lang.Object 类继承了方法。数组实现了 Cloneable 接口,而且覆盖了 clone() 方法,确保数组始终能被复制,而且 clone() 方法从不抛出 CloneNotSupportedException 异常。数组还实现了 Serializable 接口,所以只要数组中元素的类型能被序列化,数组就能被序列化。而且,所有数组都有一个名为 length 的字段,这个字段的修饰符是 public final int,表示数组中元素的数量。

1. 数组类型放大转换

因为数组扩展自 Object 类,而且实现了 CloneableSerializable 接口,所以任何数组类型都能放大转换成这三种类型中的任何一种。而且,特定的数组类型还能放大转换成其他数组类型。如果数组中的元素类型是引用类型 T,而且 T 能指定给类型 S,那么数组类型 T[] 就能指定给数组类型 S[]。注意,基本类型的数组不能放大转换。例如,下述代码展示了合法的数组放大转换:

String[] arrayOfStrings;      // 创建字符串数组
int[][] arrayOfArraysOfInt;   // 创建int二维数组
// String可以指定给Object,
// 因此String[]可以指定给Object[]
Object[] oa = arrayOfStrings;
// String实现了Comparable接口
// 因此String[]可以视作Comparable[]
Comparable[] ca = arrayOfStrings;
// int[]是Object类的对象,因此int[][]可以指定给Object[]
Object[] oa2 = arrayOfArraysOfInt;
// 所有数组都是可以复制和序列化的对象
Object o = arrayOfStrings;
Cloneable c = arrayOfArraysOfInt;
Serializable s = arrayOfArraysOfInt[0];

因为数组类型可以放大转换成另一种数组类型,所以编译时和运行时数组的类型并不总是一样。

 这种放大转换叫作“数组协变”(array covariance)。从 4.2.5 节或许可以看出,现代标准认为这是历史遗留的不合理功能,因为编译时和运行时得出的类型不一致。

把引用类型的值存储在数组元素中之前,编译器通常必须插入运行时检查,确保运行时这个值的类型和数组元素的类型匹配。如果运行时检查失败,会抛出 ArrayStoreException 异常。

2. 与C语言兼容的句法

如前所示,指定数组类型的方法是在元素类型后加上一对中括号。为了兼容 C 和 C++,Java 还支持一种声明变量的句法:中括号放在变量名后面,元素类型后面可以放也可以不放中括号。这种句法可用于局部变量,字段和方法的参数。例如:

// 这行代码声明类型为int,int[]和int[][]的局部变量
int justOne, arrayOfThem[], arrayOfArrays[][];

// 这三行代码声明的字段属于同一种数组类型
public String[][] aas1;   // 推荐使用的Java句法
public String aas2[][];   // C语言的句法
public String[] aas3[];   // 令人困惑的混用句法

// 这个方法签名包含两个类型相同的参数
public static double dotProduct(double[] x, double y[]) { ... }

 这种兼容句法极其少见,不要使用。

2.8.2 创建和初始化数组

在 Java 中,使用 new 关键字创建数组,就像创建对象一样。数组类型没有构造方法,但创建数组时要指定长度,在中括号里使用非负整数指定所需的数组大小:

// 创建一个能保存1024个byte类型数据的新数组
byte[] buffer = new byte[1024];
// 创建一个能保存50个字符串引用的数组
String[] lines = new String[50];

使用这种句法创建的数组,每个元素都会自动初始化,初始值和类中的字段默认值相同:boolean 类型元素的初始值是 falsechar 类型元素的初始值是 \u0000,整数元素的初始值是 0,浮点数元素的初始值是 0.0,引用类型元素的初始值是 null

创建数组的表达式也能用来创建和初始化多维数组。这种句法稍微复杂一些,本节后面会介绍。

数组初始化程序

若想在一个表达式中创建数组并初始化其中的元素,不要指定数组的长度,在方括号后面跟着一对花括号,在花括号里写入一些逗号分隔的表达式。当然了,每个表达式的返回值类型必须能指定给数组元素的类型。创建的数组长度和表达式的数量相等。这组表达式的最后一个后面可以加上逗号,但没必要这么做。例如:

String[] greetings = new String[] { "Hello", "Hi", "Howdy" };
int[] smallPrimes = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, };

注意,这种句法无需把数组赋值给变量就能创建、初始化和使用数组。某种意义上,这种创建数组的表达式相当于匿名数组字面量。下面是几个示例:

// 调用一个方法,传入一个包含两个字符串的匿名数组字面量
String response = askQuestion("Do you want to quit?",
                               new String[] {"Yes", "No"});

// 调用另一个方法,传入匿名对象组成的匿名数组
double d = computeAreaOfTriangle(new Point[] { new Point(1,2),
                                               new Point(3,4),
                                               new Point(3,2) });

如果数组初始化程序是变量声明的一部分,可以省略 new 关键字和元素类型,在花括号里列出所需的元素:

String[] greetings = { "Hello", "Hi", "Howdy" };
int[] powersOfTwo = {1, 2, 4, 8, 16, 32, 64, 128};

数组字面量在程序运行时,而不是程序编译时,创建和初始化。例如下述数组字面量:

int[] perfectNumbers = {6, 28};

编译得到的 Java 字节码和下面的代码相同:

int[] perfectNumbers = new int[2];
perfectNumbers[0] = 6;
perfectNumbers[1] = 28;

Java 在运行时初始化数组有个重要的推论:数组初始化程序中的表达式可能会在运行时计算,而且不一定非要使用编译时常量。例如:

Point[] points = { circle1.getCenterPoint(), circle2.getCenterPoint() };

2.8.3 使用数组

创建数组后就可以开始使用了。随后的几节说明访问元素的基本方法,以及常见的数组用法,例如迭代数组中的元素,复制数组或数组的一部分。

1. 访问数组中的元素

数组中的元素是变量。如果元素出现在表达式中,其计算结果是这个元素中保存的值。如果元素出现在赋值运算符的左边,会把一个新值保存到这个元素中。不过,元素和普通的变量不同,它没有名字,只有编号。数组中的元素使用方括号访问。假如 a 是一个表达式,其计算结果为一个数组引用,那么可以使用 a[i] 索引数组,并引用某个元素。其中,i 是整数字面量或计算结果为 int 类型值的表达式。例如:

// 创建一个由两个字符串组成的数组
String[] responses = new String[2];
responses[0] = "Yes";  // 设定数组的第一个元素
responses[1] = "No";   // 设定数组的第二个元素

// 读取这个数组中的元素
System.out.println(question + " (" + responses[0] + "/" +
                   responses[1] + " ): ");

// 数组引用和数组索引都可以是复杂的表达式
double datum = data.getMatrix()[data.row() * data.numColumns() +
                   data.column()];


数组的索引表达式必须是 int 类型,或能放大转换成 int 的类型:byteshort,甚至是 char。数组的索引显然不能是 booleanfloatdouble 类型。还记得吗,数组的 length 字段是 int 类型,所以数组中的元素数量不能超过 Integer.MAX_VALUE。如果使用 long 类型的表达式索引数组,即便运行时表达式的返回值在 int 类型的取值范围内,也会导致编译出错。

2. 数组的边界

还记得吗?数组a的第一个元素是 a[0],第二个元素是 a[1],最后一个元素是 a[a.length-1]

使用数组时常见的错误是索引太小(负数)或太大(大于或等于数组的长度)。在 C 或 C++ 等语言中,如果访问起始索引之前或结尾索引之后的元素,会导致无法预料的行为,而且在不同的调用和不同的平台中有所不同。这种问题不一定会被捕获,如果没捕获,可能过一段时间才会发现。因为在 Java 中容易编写错误的索引代码,所以运行时每次访问数组都会做检查,确保得到能预料的结果。如果数组的索引太小或太大,Java 会立即抛出 ArrayIndexOutOfBoundsException 异常。

3. 迭代数组

为了在数组上执行某种操作,经常要编写循环,迭代数组中的每个元素。这种操作通常使用 for 循环完成。例如,下述代码计算整数数组中的元素之和:

int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };
int sumOfPrimes = 0;
for(int i = 0; i < primes.length; i++)
    sumOfPrimes += primes[i];

这种 for 循环结构很有特色,会经常见到。Java 还支持遍历句法,前面已经介绍过。上述求和代码可以改写成下述简洁的代码:

for(int p : primes) sumOfPrimes += p;

4. 复制数组

所有数组类型都实现了 Cloneable 接口,任何数组都能调用 clone() 方法复制自己。注意,返回值必须校正成适当的数组类型。不过,在数组上调用 clone() 方法不会抛出 CloneNotSupportedException 异常:

int[] data = { 1, 2, 3 };
int[] copy = (int[]) data.clone();

clone() 方法执行的是浅复制。如果数组的元素是引用类型,那么只复制引用,而不复制引用的对象。因为这种复制是浅复制,所以任何数组都能被复制,就算元素类型没有实现 Cloneable 接口也行。

不过,有时只想把一个现有数组中的元素复制到另一个现有数组中。System.arraycopy() 方法的目的就是高效完成这种操作。你可以假定 Java 虚拟机实现会在底层硬件中使用高速块复制操作执行这个方法。

arraycopy() 方法的作用简单明了,但使用起来有些难度,因为要记住五个参数。第一个参数是想从中复制元素的源数组;第二个参数是源数组中起始元素的索引;第三个参数是目标数组;第四个参数是目标索引;第五个参数是要复制的元素数量。

就算重叠复制同一个数组,arraycopy() 方法也能正确运行。例如,把数组 a 中索引为 0 的元素删除后,想把索引为 1n 的元素向左移,把索引变成 0n-1,可以这么做:

System.arraycopy(a, 1, a, 0, n);

5. 数组的实用方法

java.util.Arrays 类中包含很多处理数组的静态实用方法。这些方法中大多数都高度重载,有针对各种基本类型数组的版本,也有针对对象数组的版本。排序和搜索数组时,sort()binarySearch() 方法特别有用。equals() 方法用于比较两个数组的内容。如果想把数组的内容转换成一个字符串,例如用于调试或记录日志,Arrays.toString() 方法很有用。

Arrays 类中还包含能正确处理多维数组的方法,例如 deepEquals()deepHashCode()deepToString()

2.8.4 多维数组

前面已经见过,数组类型的写法是在元素类型后面加一对方括号。char 类型元素组成的数组是 char[] 类型,由 char[] 类型元素组成的数组是 char[][] 类型。如果数组的元素也是数组,我们说这个数组是多维数组。要想使用多维数组,需要了解一些其他细节。

假如想使用多维数组表示乘法表:

int[][] products;     // 乘法表

每对方括号表示一个维度,所以这是个二维数组。若想访问这个二维数组中的某个 int 元素,必须指定两个索引值,一个维度一个。假设这个数组确实被初始化成一个乘法表,那么元素中存储的 int 值就是两个索引的乘积。也就是说,products[2][4] 的值是 8,products[3][7] 的值是 21。

创建多维数组要使用 new 关键字,而且要指定每个维度中数组的大小。例如:

int[][] products = new int[10][10];

在某些语言中,会把这样的数组创建成包含 100 个 int 值的数组,但 Java 不会这样处理。这行代码会做三件事。

  • 声明一个名为 products 的变量,保存一个由 int[] 类型数组组成的数组。

  • 创建一个有 10 个元素的数组,保存 10 个 int[] 类型的数组。

  • 再创建 10 个数组,每个都由 10 个 int 类型的元素组成。然后把这 10 个新数组指定为前一步创建的数组的元素。这 10 个新数组中的每一个 int 类型元素的默认值都是 0。

换种方式说,前面的单行代码等效于下述代码:

int[][] products = new int[10][];  // 保存10个int[]类型值的数组
for(int i = 0; i < 10; i++)        // 循环10次……
    products[i] = new int[10];     // ……创建10个数组

new 关键字会自动执行这些额外的初始化操作。超过两个维度的数组也是一样:

float[][][] globalTemperatureData = new float[360][180][100];

使用 new 关键字创建多维数组时,无需指定所有维度的大小,只要为最左边的几个维度指定大小就行。例如,下面两行代码都是合法的:

float[][][] globalTemperatureData = new float[360][][];
float[][][] globalTemperatureData = new float[360][180][];

第一行代码创建一个一维数组,元素是 float[][] 类型。第二行代码创建一个二维数组,元素是 float[] 类型。不过,如果只为数组的部分维度指定大小,这些维度必须位于最左边。下述代码是不合法的:

float[][][] globalTemperatureData = new float[360][][100]; // 错误!
float[][][] globalTemperatureData = new float[][180][100]; // 错误!

和一维数组一样,多维数组也能使用数组初始化程序初始化,使用嵌套的花括号把数组嵌套在数组中即可。例如,可以像下面这样声明、创建并初始化一个 5×5 乘法表:

int[][] products = { {0, 0, 0, 0, 0},
                     {0, 1, 2, 3, 4},
                     {0, 2, 4, 6, 8},
                     {0, 3, 6, 9, 12},
                     {0, 4, 8, 12, 16} };

如果不想声明变量就使用多维数组,可以使用匿名初始化程序句法:

boolean response = bilingualQuestion(question, new String[][] {
                                                   { "Yes", "No" },
                                                   { "Oui", "Non" }});

使用 new 关键字创建多维数组时,往往最好只使用矩形数组,即每个维度的数组大小相同。

2.9 引用类型

至此,我们已经介绍了数组、类和对象,接下来可以介绍更一般的引用类型了。类和数组是 Java 五种引用类型中的两种。前面已经介绍了类,第 3 章会全面详细地说明类和接口。枚举和注解这两种引用类型在第 4 章介绍。

本节不涉及任何引用类型的具体句法,而是说明引用类型的一般行为,还会说明引用类型和基本类型的区别。本节使用术语“对象”指代引用类型(包括数组)的值或实例。

2.9.1 引用类型与基本类型比较

引用类型和对象与基本类型和基本值有本质的区别。

  • 八种基本类型由 Java 语言定义,程序员不能定义新基本类型。引用类型由用户定义,因此有无限多个。例如,程序可以定义一个名为 Point 的类,然后使用这个新定义类型的对象存储和处理笛卡儿坐标系中的 (x, y) 点。

  • 基本类型表示单个值。引用类型是聚合类型(aggregate type),可以保存零个或多个基本值或对象。例如,我们假设的 Point 类可能存储了两个 double 类型的值,表示点的 xy 坐标。char[]Point[] 数组类型是聚合类型,因为它们保存一些 char 类型的基本值或 Point 对象。

  • 基本类型需要一到八个字节的内存空间。把基本值存储到变量中,或者传入方法时,计算机会复制表示这个值的字节。而对象基本上需要更多的内存。创建对象时会在堆(heap)中动态分配内存,存储这个对象;如果不再需要使用这个对象了,存储它的内存会被自动垃圾回收。

 把对象赋值给变量或传入方法时,不会复制表示这个对象的内存,而是把这个内存的引用存储在变量中或传入方法。

在 Java 中,引用完全不透明,引用的表示方式由 Java 运行时的实现细节决定。如果你是 C 程序员的话,完全可以把引用看作指针或内存地址。不过要记住,Java 程序无法使用任何方式处理引用。

与 C 和 C++ 中的指针不同的是,引用不能转换成整数,也不能把整数转换成引用,而且不能递增或递减。C 和 C++ 程序员还要注意,Java 不支持求地址运算符 &,也不支持解除引用运算符 *->

2.9.2 处理对象和引用副本

下述代码处理 int 类型基本值:

int x = 42;
int y = x;

执行这两行代码后,变量 y 中保存了变量 x 中所存值的一个副本。在 Java 虚拟机内部,这个 32 位整数 42 有两个独立的副本。

现在,想象一下把这段代码中的基本类型换成引用类型后再运行会发生什么:

Point p = new Point(1.0, 2.0);
Point q = p;

运行这段代码后,变量 q 中保存了一份变量 p 中所存引用的一个副本。在虚拟机中,仍然只有一个 Point 对象的副本,但是这个对象的引用有两个副本——这一点有重要的含义。假设上面两行代码的后面是下述代码:

System.out.println(p.x);  // 打印p的x坐标:1.0
q.x = 13.0;               // 现在,修改q的x坐标
System.out.println(p.x);  // 再次打印p.x,这次得到的值是13.0

因为变量 pq 保存的引用指向同一个对象,所以两个变量都可以用来修改这个对象,而且一个变量中的改动在另一个变量中可见。数组也是一种对象,所以对数组来说也会发生同样的事,如下面的代码所示:

// greet保存一个数组的引用
char[] greet = { 'h','e','l','l','o' };
char[] cuss = greet;             // cuss保存的是同一个数组的引用
cuss[4] = '!';                   // 使用引用修改一个元素
System.out.println(greet);       // 打印“hell!”

把基本类型和引用类型的参数传入方法时也有类似的区别。假如有下面的方法:

void changePrimitive(int x) {
    while(x > 0) {
        System.out.println(x--);
    }
}

调用这个方法时,会把实参的副本传给形参 x。在这个方法的代码中,x 是循环计数器,向零递减。因为 x 是基本类型,所以这个方法有这个值的私有副本——这是完全合理的做法。

可是,如果把这个方法的参数改为引用类型,会发生什么呢?

void changeReference(Point p) {
    while(p.x > 0) {
        System.out.println(p.x--);
    }
}

调用这个方法时,传入的是一个 Point 对象引用的私有副本,然后使用这个引用修改对应的 Point 对象。例如,有下述代码:

Point q = new Point(3.0, 4.5); // 一个x坐标为3的点
changeReference(q);            // 打印3,2,1,而且修改了这个Point对象
System.out.println(q.x);       // 现在,q的x坐标是0!

调用 changeReference() 方法时,传入的是变量 q 中所存引用的副本。现在,变量 q 和方法的形参 p 保存的引用指向同一个对象。这个方法可以使用它的引用修改对象的内容。但是要注意,这个方法不能修改变量 q 的内容。也就是说,这个方法可以随意修改引用的 Point 对象,但不能改变变量 q 引用这个对象这一事实。

2.9.3 比较对象

我们已经介绍了基本类型和引用类型在赋值给变量、传入方法和复制时的显著区别。这两种类型在相等性比较时也有区别。相等运算符(==)比较基本值时,只测试两个值是否一样(即每一位的值都完全相同)。而 == 比较引用类型时,比较的是引用而不是真正的对象。也就是说,== 测试两个引用是否指向同一个对象,而不测试两个对象的内容是否相同。例如:

String letter = "o";
String s = "hello";              // 这两个String对象
String t = "hell" + letter;      // 保存的文本完全一样
if (s == t) System.out.println("equal"); // 但是,二者并不相等!

byte[] a = { 1, 2, 3 };
// 内容一样的副本
byte[] b = (byte[]) a.clone();
if (a == b) System.out.println("equal"); // 但是,二者并不相等!

对引用类型来说,有两种相等:引用相等和对象相等。一定要把这两种相等区分开。其中一种方式是,使用“相同”(identical)表示引用相等,使用“相等”(equal)表示对象的内容一样。若想测试两个不同的对象是否相等,可以在一个对象上调用 equals() 方法,然后把另一个对象传入这个方法:

String letter = "o";
String s = "hello";              // 这两个String对象
String t = "hell" + letter;      // 保存的文本完全一样
if (s.equals(t)) {               // equals()方法
    System.out.println("equal"); // 证实了这一点
}

所有对象都(从 Object 类)继承了 equals() 方法,但是默认的实现方式是使用 == 测试引用是否相同,而不测试内容是否相等。想比较对象是否相等的类可以自定义 equals() 方法。Point 类没自定义,但 String 类自定义了,如前面的例子所示。可以在数组上调用 equals() 方法,但作用和使用 == 运算符一样,因为数组始终继承默认的 equals() 方法,比较引用而不是数组的内容。比较数组是否相等可以使用 java.util.Arrays.equals() 实用方法。

2.9.4 装包和拆包转换

基本类型和引用类型的表现完全不同。有时需要把基本值当成对象,为此,Java 平台为每一种基本类型都提供了包装类BooleanByteShortCharacterIntegerLongFloatDouble 是不可变的最终类,每个实例只保存一个基本值。包装类一般在把基本值存储在集合中时使用,例如 java.util.List

// 创建一个List集合
List numbers = new ArrayList();
// 存储一个包装类表示的基本值
numbers.add(new Integer(-1));
// 取出这个基本值
int i = ((Integer)numbers.get(0)).intValue();

Java 支持装包和拆包类型转换。装包转换把一个基本值转换成对应的包装对象,而拆包转换的作用相反。虽然可以通过校正显式指定装包和拆包转换,但没必要这么做,因为把值赋值给变量或传入方法时会自动执行这种转换。此外,如果把包装对象传给需要基本值的 Java 运算符或语句,也会自动执行拆包转换。因为 Java 能自动执行装包和拆包转换,所以这种语言特性一般叫作自动装包(autoboxing)。

下面是一些自动装包和拆包转换的示例:

Integer i = 0;   // 把int类型字面量0装包到Integer对象中
Number n = 0.0f; // 把float类型字面量装包到Float对象中,然后放大转换成Number类型
Integer i = 1;   // 这是装包转换
int j = i;       // i在这里拆包
i++;             // 拆包i,递增,再装包
Integer k = i+2; // 拆包i,再装包两数之和
i = null;
j = i;           // 这次拆包抛出NullPointerException异常

自动装包也把集合处理变得更简单了。下面这个示例,使用 Java 的泛型(4.2 节专门介绍这个语言特性)限制列表和其他集合中能存储什么类型的值:

List<Integer> numbers = new ArrayList<>(); // 创建一个由整数组成的列表
numbers.add(-1);                           // 把int类型的值装包到Integer对象中
int i = numbers.get(0);                    // 把Integer对象拆包成int类型

2.10 包和Java命名空间

包由一些具名的类、接口和其他引用类型组成,目的是把相关的类组织在一起,并为这些类定义命名空间。

Java 平台的核心类放在一些名称以 java 开头的包中。例如,Java 语言最基本的类在 java.lang 包中,各种实用类在 java.util 包中,输入输出类在 java.io 包中,网络类在 java.net 包中。有些包还包含子包,例如 java.lang.reflectjava.util.regex。甲骨文标准化的 Java 平台扩展一般在名称以 javax 开头的包中。有些扩展,例如 javax.swing 及其各种子包,后来集成到了核心平台中。最后,Java 平台还包含几个被认可的标准,这些包以标准制定方命名,例如 org.w3corg.omg

每个类都有两个名称:一个是简称,定义时指定;另一个是完全限定名称,其中包含所在包的名称。例如,String 类是 java.lang 包的一部分,因此它的完全限定名称是 java.lang.String

本节说明如何把自己的类和接口放到包里,以及如何选择包名,避免和其他人的包名有冲突。然后说明如何有选择性地把类型名称或静态成员导入命名空间,避免每次使用类或接口都要输入包名。

2.10.1 声明包

若想指定类属于哪个包,要使用 package 声明。如果 Java 文件中有 package 关键字,必须是 Java 代码的第一个标记(即除了注释和空格之外的第一个标记)。package 关键字后面是包的名称和一个分号。例如,有个 Java 文件以下述指令开头:

package org.apache.commons.net;

那么,这个文件中定义的所有类都是 org.apache.commons.net 包的一部分。

如果 Java 文件中没有 package 指令,那么这个文件中定义的所有类都是一个默认的无名包的一部分。此时,类的限定名称和不限定名称相同。

 包的名称有可能冲突,所以不要使用默认包。项目在增长的过程中越来越复杂,冲突几乎是不可避免的,所以最好从一开始就创建包。

2.10.2 全局唯一的包名

包的重要功能之一是划分 Java 命名空间,避免类名有冲突。例如,只能从包名上区分 java.util.Listjava.awt.List 两个类。不过,因此包名本身就要独一无二。作为 Java 的开发方,甲骨文控制着所有以 javajavaxsun 开头的包名。

常用的命名方式之一是使用自己的域名,倒序排列各部分,作为包名的前缀。例如,Apache 项目开发了一个网络库,是 Apache Commons 项目的一部分。Commons 项目的网址是 http://commons.apache.org/,因此这个网络库的包名是 org.apache.commons.net

注意,API 开发者以前也使用这种包命名规则。如果其他程序员要把你开发的类和其他未知类放在一起使用,你的包名就要具有全局唯一性。如果你开发了一个 Java 程序,但是不会发布任何类供他人使用,那么你就知道部署这个应用需要使用的所有类,因此无需担心无法预料的命名冲突。此时,可以选择一种自己用着方便的命名方式,而不用考虑全局唯一性。常见的做法之一是,使用程序的名称作为主包的名称(主包里可能还有子包)。

2.10.3 导入类型

默认情况下,在 Java 代码中引用类或接口时,必须使用类型的完全限定名称,即包含包名。如果编写的代码需要使用 java.io 包中的 File 类处理文件,必须把这个类写成 java.io.File。不过这个规则有三个例外:

  • java.lang 包中的类型很重要也很常用,因此始终可以使用简称引用;

  • p.T 类型中的代码可以使用简称引用 p 包中定义的其他类型;

  • 已经使用 import 声明导入命名空间里的类型,可以使用简称引用。

前两个例外叫作“自动导入”。java.lang 包和当前包中的类型已经导入到命名空间里了,因此可以不加包名。输入不在 java.lang 包或当前包中的常用类型的包名,很快就会变得冗长乏味,因此要能显式地把其他包中的类型导入命名空间。这种操作通过 import 声明实现。

import 声明必须放在 Java 文件的开头,如果有 package 声明的话,要紧随其后,并且在任何类型定义之前。一个文件中能使用的 import 声明数量不限。import 声明应用于文件中的所有类型定义(但不应用于 import 声明中的类型)。

import 声明有两种格式。若想把单个类型导入命名空间,import 关键字后面是类型的名称和一个分号:

import java.io.File;    // 现在不用输入java.io.File了,输入File就行

这种格式叫“单个类型导入”声明。

import 声明的另一种格式是“按需类型导入”。在这种格式中,包名后面是 .* 字符,表示使用这个包里的任何类型时都不用输入包名。因此,如果除了 File 类之外,还要使用 java.io 包中的其他几个类,可以导入整个包:

import java.io.*;  // java.io包中的所有类都可以使用简称

按需导入句法对子包无效。如果导入了 java.util 包,仍然必须使用完全限定名称 java.util.zip.ZipInputStream 引用这个类。

按需导入类型和一个一个导入包中的所有类型作用不一样。按需导入更像是使用单个类型导入句法把代码中真正用到的各种类型从包中导入命名空间,因此才叫“按需”导入——用到某个类型时才会将其导入。

命名冲突和遮盖

import 声明对 Java 编程极其重要。不过,可能会导致命名冲突。例如,java.utiljava.awt 两个包中都有名为 List 的类型。

java.util.List 是常用的重要接口。java.awt 包中有很多客户端应用常用的重要类型,但 java.awt.List 已经作废了,不是这些重要类型的其中一个。在同一个 Java 文件中既导入 java.util.List 又导入 java.awt.List 是不合法的。下述单个类型导入声明会导致编译出错:

import java.util.List;
import java.awt.List;

使用按需类型导入句法导入这两个包是合法的:

import java.util.*; // 导入集合和其他实用类型
import java.awt.*;  // 导入字体、颜色和图形类型

可是,如果试图使用 List 类型会遇到困难。这个类型可以从两个包中的任何一个“按需”导入,只要试图使用未限定的类型名引用 List 就会导致编译出错。这种问题的解决方法是,明确指定所需的包名。

因为 java.util.Listjava.awt.List 常用得多,所以可以在两个按需类型导入声明后使用单个类型导入声明指明从哪个包中导入 List

import java.util.*;    // 导入集合和其他实用类型
import java.awt.*;     // 导入字体、颜色和图形类型
import java.util.List; // 与java.awt.List区分开

这样,使用 List 时指的是 java.util.List 接口。如果确实需要使用 java.awt.List 类,只要加上包名就行。除此之外,java.utiljava.awt 之间没有命名冲突了,在不指定包名的情况下使用这两个包中的其他类型时,会“按需”将其导入。

2.10.4 导入静态成员

除了类型之外,还可以使用关键字 import static 导入类型中的静态成员(静态成员在第 3 章说明。如果不熟悉这个概念,可以稍后再读这一节)。和类型导入声明一样,静态成员导入声明也有两种格式:单个静态成员导入和按需静态成员导入。假如你在编写一个基于文本的程序,要向 System.out 输出大量内容,那么可以使用下述单个静态成员导入声明减少输入的代码量:

import static java.lang.System.out;

加入这个导入声明后,可以用 out.println() 代替 System.out.println()。又假如你编写的一个程序要使用 Math 类中的很多三角函数和其他函数。在这种明显要大量使用数字处理方法的程序中,重复输入类名“Math”不会让代码的思路更清晰,反而会起到反作用。遇到这种情况,或许应该按需导入静态成员:

import static java.lang.Math.*;

加入这个导入声明后,可以编写 sqrt(abs(sin(x))) 这样简洁的表达式,而不用在每个静态方法前都加上类名 Math

import static 声明另一个重要的作用是把常量导入代码,尤其适合导入枚举类型(参见第 4 章)。假如你想在自己编写的代码中使用下述枚举类型中的值:

package climate.temperate;
enum Seasons { WINTER, SPRING, SUMMER, AUTUMN };

那么,可以导入 climate.temperate.Seasons,然后在常量前加上类型名,例如 Seasons.SPRING。如果想编写更简洁的代码,可以导入这个枚举类型中的值:

import static climate.temperate.Seasons.*;

使用静态成员导入声明导入常量一般来说比实现定义常量的接口更好。

静态成员导入和重载的方法

静态成员导入声明导入的是“名称”,而不是以这个名称命名的某个具体成员。因为 Java 允许重载方法,也允许类型中的字段和方法同名,所以单个静态成员导入声明可能会导入多个成员。例如下述代码:

import static java.util.Arrays.sort;

这个声明把名称“sort” 导入命名空间,而没有导入 java.util.Arrays 里定义的 19 个 sort() 方法中的任何一个。如果使用导入的名称 sort 调用方法,编译器会根据方法的参数类型决定调用哪个方法。

从两个或多个不同的类型中导入同名的静态方法也是合法的,只要方法的签名不同就行。下面举个例子:

import static java.util.Arrays.sort;
import static java.util.Collections.sort;

你可能觉得上述代码会导致句法错误,其实不然,因为 Collections 类中定义的 sort() 方法和 Arrays 类中定义的所有 sort() 方法签名都不一样。在代码中使用“sort”这个名称时,编译器会根据参数的类型决定使用这 21 个方法中的哪一个。

2.11 Java文件的结构

本章从小到大说明了 Java 句法的元素,先介绍了单个字符和标记,然后介绍了运算符、表达式、语句和方法,最后介绍了类和包。从实际使用的角度出发,最常使用的 Java 程序结构单元是 Java 文件。Java 文件是 Java 编译器能编译的 Java 代码的最小单元。一个 Java 文件中包含以下内容:

  • 一个可选的 package 指令;

  • 零个或多个 importimport static 指令;

  • 一个或多个类型定义。

当然,这些元素之间可以穿插注释,但必须是这种顺序。这就是 Java 文件中的全部内容了。所有 Java 语句都必须放在方法中(不含 packageimport 指令,它们不是真正的语句),而所有方法都要放在类型定义中。

Java 文件还有一些其他重要的限制。首先,一个文件中最多只能有一个声明为 public 的顶层类。public 类的目的是供其他包中的类使用。但是,在一个类中,声明为 public 的嵌套类或内部类数量不限。第 3 章会详细介绍 public 修饰符和嵌套类。

第二个限制涉及到 Java 文件的文件名。如果 Java 文件中有一个 public 类,那么这个文件的名称必须和这个类的名称相同,然后再加上扩展名 .java。因此,如果 Point 定义为 public 类,那么它的源码要放在名为 Point.java 的文件中。不管类是否为 public,一个文件中只定义一个类,并使用类名命名文件,是良好的编程习惯。

编译 Java 文件时,其中定义的各个类会编译到独自的类文件中;类文件中是 Java 字节码,由 Java 虚拟机解释执行。类文件的名称和其中定义的类名相同,扩展名为 .class。因此,如果 Point.java 文件中定义了一个名为 Point 的类,那么,Java 编译器编译后得到的文件名为 Point.class。在大多数系统中,类文件都存储在包名对应的目录里。因此,com.davidflanagan.examples.Point 类在 com/davidflanagan/examples/Point.class 文件中定义。

Java 解释器知道标准系统类的类文件存储的位置,需要时会加载这些类文件。解释器运行程序时,如果需要使用名为 com.davidflanagan.examples.Point 的类,它知道这个类的代码存储在名为 com/davidflanagan/examples/ 的目录中;默认情况下,解释器会在当前目录中寻找这个子文件夹。如果想告诉解释器在当前目录之外的位置寻找,调用解释器时必须使用 -classpath 选项,或者设定 CLASSPATH 环境变量。详情参见第 8 章对 Java 解释器(java)的说明。

2.12 定义并运行Java程序

Java 程序包含一系列相互作用的类定义,但不是每个 Java 类或 Java 文件都能当成程序。若想创建程序,必须在一个类中定义一个特殊的方法,签名如下:

public static void main(String[] args)

main() 方法是程序的主要入口,Java 解释器从这里开始运行。这个方法的参数是一个字符串数组,没有返回值。main() 方法返回后,Java 解释器也就退出了(除非 main() 方法创建了其他线程,此时,解释器会等到所有线程都结束后才会退出)。

Java 程序通过 Java 解释器(java)运行,并且要指定 main() 方法所在类的完全限定名称。注意,指定的是类名,而不是包含类的类文件名。命令行中指定的其他参数会传给 main() 方法的 String[] 参数。可能还要指定 -classpath(或 -cp)选项,告诉解释器在哪里寻找程序所需的类。例如,在下述命令中:

java -classpath /opt/Jude com.davidflanagan.jude.Jude datafile.jude

java 是运行 Java 解释器的命令;-classpath /opt/Jude 告诉解释器在哪里寻找类文件;com.davidflanagan.jude.Jude 是要运行的程序名(即定义 main() 方法的类名);datafile.jude 是一个字符串,作为字符串数组的一个元素,传给 main() 方法。

运行程序有一种简单的方式。如果把程序及其所有辅助类都正确打包到一个 Java 档案(Java archive,JAR)文件中,那么只指定 JAR 文件的名称就可以运行这个程序。下面这个示例展示如何运行 Censum 垃圾回收日志分析程序:

java -jar /usr/local/Censum/censum.jar

在某些操作系统中,JAR 文件能自动执行。在这些系统中,可以直接运行:

% /usr/local/Censum/censum.jar

第 13 章会详细说明如何执行 Java 程序。

2.13 小结

本章介绍了 Java 语言的基本句法。编程语言的句法之间环环相扣,如果现在没有完全理解 Java 语言的全部句法,也没有关系,不管是人类还是计算机,都要通过实践才能精通任何一门语言。

还有一点要注意,有些句法比其他句法更常用。例如,strictfpassert 两个关键字几乎从不使用。不要试图掌握 Java 句法的所有细节,最好先熟悉 Java 的核心概念,然后再回过头学习还不理解的句法细节。知道这一点之后,开始读下一章吧。下一章介绍对 Java 来说十分重要的类和对象,以及 Java 实现面向对象编程的基本方式。

目录