在本章中,将详细定义和解释 Java 编程运算符、表达式和语句的三个核心元素。讨论将以具体例子加以支持,说明这些要素的关键方面。
将涵盖以下主题:
- Java 编程的核心元素是什么?
- Java 运算符、表达式和语句
- 操作数的运算符优先级和求值顺序
- 扩大和缩小基元类型的转换范围
- 基本类型和引用类型之间的装箱和取消装箱
- 参考类型的
equals()
方法 - 练习-命名语句
在第 2 章、Java 语言基础中,我们对 Java 作为一种语言的许多方面进行了概述,甚至定义了什么是语句。现在,我们将更系统地深入研究 Java 的核心元素。
“elements”这个词相当重载(与方法重载进行类比)。在第 5 章Java 语言元素和类型中,我们介绍了由 Java 规范标识的输入元素:空格、注释和标记。这就是 Java 编译器解析源代码并理解它的方式。令牌列表包括标识符、关键字、分隔符、文字和运算符。这就是 Java 编译器为它遇到的标记添加更多含义的方式。
在讨论输入元素时,我们解释了它们用于构建更复杂的语言元素。在本章中,我们将从操作符标记开始,并展示如何使用它构造表达式——一个更复杂的 Java 元素。
但是,并非所有 Java 操作符都是令牌。instanceof
和new
操作符是关键字,.
操作符(字段访问或方法调用)、::
方法引用操作符和( type )
cast 操作符是分隔符。
正如我们在第 2 章Java 语言基础中所说,Java 中的语句起着类似于英语中的句子的作用,它表达了一个完整的思想。在编程语言中,语句是执行某些操作的完整代码行。
另一方面,表达式是计算为值的语句的一部分。每个表达式都可以是语句(如果忽略结果值),而大多数语句不包含表达式。
这就是 Java 操作符、表达式和语句的三个核心元素之间的关系。
以下是 Java 中所有 44 个运算符的列表:
| 操作员 | 说明 |
| +
、-
、*
、/
、%
| 算术一元和二元运算符 |
| ++
、--
| 增量和减量一元运算符 |
| ==
、!=
| 相等运算符 |
| <
、>
、<=
、>=
| 关系运算符 |
| !
、&
、|
| 逻辑运算符 |
| &&
、||
、?
、:
| 条件运算符 |
| =
、+=
、-=
、*=
、/=
、%=
| 赋值运算符 |
| &=
、|=
、^=
、<<=
、>>=
、>>>=
| 赋值运算符 |
| &
、|
、~
、^
、<<
、>>
、>>>
| 位操作符 |
| ->
、::
| 箭头和方法引用运算符 |
| new
| 实例创建操作符 |
| .
| 字段访问/方法调用运算符 |
| instanceof
| 类型比较运算符 |
| ( target type )
| 铸造操作工 |
一元表示与单个操作数一起使用,而二进制表示需要两个操作数。
在下面的小节中,我们将定义并演示大多数运算符,但很少使用的赋值运算符&=
、|=
、^=
、<<=
、>>=
、>>>=
以及位运算符除外。
此外,请注意,&
和|
运算符在应用于整数(按位)和布尔(逻辑)值时表现不同。在本书中,我们将仅作为逻辑运算符讨论这些运算符。
箭头运算符->
和方法参考运算符::
将在第 17 章、Lambda 表达式和函数编程中定义和讨论。
了解运营商的最佳方式是看到他们的行动。下面是我们的演示应用程序代码(注释中捕获了结果),解释了一元运算符+
和-
:
public class Ch09DemoApp {
public static void main(String[] args) {
int i = 2; //unary "+" is assumed by default
int x = -i; //unary "-" makes positive become negative
System.out.println(x); //prints: -2
int y = -x; //unary "-" makes negative become positive
System.out.println(y); //prints: 2
}
}
下面的代码演示了二进制运算符+
、-
、*
、/
和%
:
int z = x + y; //binary "+" means "add"
System.out.println(z); //prints: 0
z = x - y; //binary "-" means "subtract"
System.out.println(z); //prints: -4
System.out.println(y - x); //prints: 4
z = x * y;
System.out.println(z); //prints: -4
z = x / y;
System.out.println(z); //prints: -1
z = x * y;
System.out.println(z % 3); //prints: -1
System.out.println(z % 2); //prints: 0
System.out.println(z % 4); //prints: 0
正如您可能猜到的,%
运算符(称为模)将左侧操作数除以右侧操作数,并返回余数。
一切看起来都合乎逻辑,正如预期的那样。但是,然后我们尝试将一个整数除以另一个整数和余数,但得不到预期结果:
int i1 = 11;
int i2 = 3;
System.out.println(i1 / i2); //prints: 3 instead of 3.66...
System.out.println(i1 % i2); //prints remainder: 2
结果i1/i2
应该大于3
。它必须是3.66...
或类似的东西。这个问题是由操作中涉及的所有数字都是整数这一事实引起的。在这种情况下,Java 假定结果也应表示为整数,并删除(不舍入)小数部分。
现在,让我们将其中一个操作数声明为double
类型,值为 11,然后再次尝试除法:
double d1 = 11;
System.out.println(d1/i2); //prints: 3.6666666666666665
这一次,我们得到了预期的结果,还有其他方法可以达到同样的结果:
System.out.println((float)i1 / i2); //prints: 3.6666667
System.out.println(i1 / (double)i2); //prints: 3.6666666666666665
System.out.println(i1 * 1.0 / i2); //prints: 3.6666666666666665
System.out.println(i1 * 1f / i2); //prints: 3.6666667
System.out.println(i1 * 1d / i2); //prints: 3.6666666666666665
如您所见,您可以将任何操作数强制转换为float
或double
类型(取决于所需的精度),也可以包括float
或double
类型编号。您可能还记得在第 5 章Java 语言元素和类型中,带小数部分的值默认为double
。或者,您可以显式地选择一种类型的增值,就像我们在前面代码的最后两行中所做的那个样。
无论你做什么,在除法两个整数时都要小心。如果不想删除小数部分,请将至少一个操作数强制转换为float
或double
,以防万一(稍后在*强制转换运算符:(目标类型)*部分中介绍有关强制转换运算符的更多信息。然后,如果需要,可以将结果四舍五入到您喜欢的任何精度,或者将其强制转换回int
:
int i1 = 11;
int i2 = 3;
float r = (float)i1 / i2;
System.out.println(r); //prints: 3.6666667
float f = Math.round(r * 100f) / 100f;
System.out.println(f); //prints: 3.67
int i3 = (int)f;
System.out.println(i3); //prints: 3
Java 整数除法:如果有疑问,则生成一个操作数double
或float
,或者简单地向其中一个操作数添加一个1.0
乘法器。
在String
的情况下,二进制运算符+
表示串联,该运算符通常称为串联运算符:
String s1 = "Nick";
String s2 = "Samoylov";
System.out.println(s1 + " " + s2); //prints: Nick Samoylov
String s3 = s1 + " " + s2;
System.out.println(s3); //prints: Nick Samoylov
作为提醒,在第 5 章Java 语言元素和类型中,我们演示了应用于原语类型char
的算术运算使用字符的代码点–字符的数值:
char c1 = 'a';
char c2 = '$';
System.out.println(c1 + c2); //prints: 133
System.out.println(c1/c2); //prints: 2
System.out.println((float)c1/c2); //prints: 2.6944444
只有记住符号a
的代码点是 97,而符号$
的代码点是 36,这些结果才有意义。
大多数情况下,Java 中的算术运算非常直观,不会引起混淆,但以下两种情况除外:
- 当除法的所有操作数都是整数时
- 当
char
变量用作算术运算符的操作数时
下面的代码显示了++
和--
操作符的工作方式,具体取决于它们的位置、变量之前(前缀)或变量之后(后缀):
int i = 2;
System.out.println(++i); //prints: 3
System.out.println("i=" + i); //prints: i=3
System.out.println(--i); //prints: 2
System.out.println("i=" + i); //prints: i=2
System.out.println(i++); //prints: 2
System.out.println("i=" + i); //prints: i=3
System.out.println(i--); //prints: 3
System.out.println("i=" + i); //prints: i=2
如果作为前缀放置,它将在返回变量值之前将其值更改 1。但当作为后缀放置时,它在返回变量值后将其值更改 1。
++x
表达式在返回结果之前递增x
变量,而x++
表达式首先返回结果,然后递增x
变量。
这需要时间来适应。但一旦你这样做了,你会觉得写++x;
或x++
比写x = x + 1;
更容易。在这种情况下,使用前缀或后缀增量并没有什么区别,因为它们最终都会增加x
:
int x = 0;
++x;
System.out.println(x); //prints: 1
x = 0;
x++;
System.out.println(x); //prints: 1
例如,前缀和后缀之间的差异仅在使用返回值时出现,而不是后缀返回后的变量值。下面是演示代码:
int x = 0;
int y = x++ + x++;
System.out.println(y); //prints: 1
System.out.println(x); //prints: 2
y
的值由第一个x++
返回 0,然后将x
增加 1 形成。第二个x++
获取 1 作为当前x
值并返回,因此y
值变为 1。同时,第二个x++
将x
的值再次增加 1,因此x
的值变为 2。
如果我们将此功能包含在表达式中,则此功能更有意义:
int n = 0;
int m = 5*n++;
System.out.println(m); //prints: 0
System.out.println(n); //prints: 1
它允许我们首先使用变量的当前值,然后将其增加 1。因此,后缀递增(递减)运算符具有递增(递减)变量值的副作用。正如我们已经提到的,它对数组元素访问特别有益:
int k = 0;
int[] arr = {88, 5, 42};
System.out.println(arr[k++]); //prints: 88
System.out.println(k); //prints: 1
System.out.println(arr[k++]); //prints: 5
System.out.println(k); //prints: 2
System.out.println(arr[k++]); //prints: 42
System.out.println(k); //prints: 3
通过将k
设置为-1
并将++
向前移动,也可以获得相同的结果:
int k = -1;
int[] arr = {88, 5, 42};
System.out.println(arr[k++]); //prints: 88
System.out.println(k); //prints: 1
System.out.println(arr[++k]); //prints: 5
System.out.println(k); //prints: 2
System.out.println(arr[++k]); //prints: 42
System.out.println(k); //prints: 3
但是,使用k=0
和k++
读取效果更好,因此成为访问阵列组件的典型方式。但是,它仅在需要按索引访问数组元素时才有用。例如,如果需要访问以索引2
开头的数组,则需要使用索引:
int[] arr = {1,2,3,4};
int j = 2;
System.out.println(arr[j++]); //prints: 3
System.out.println(arr[j++]); //prints: 4
但是,如果要从索引 0 开始按顺序访问数组,则有更经济的方法。参见第 10 章、控制流报表。
相等运算符==
(表示等于)和!=
(表示不等于)比较相同类型的值,如果操作数的值相等,则返回Boolean
值true
,否则返回false
。整数和布尔基元类型的相等性很简单:
char a = 'a';
char b = 'b';
char c = 'a';
System.out.println(a == b); //prints: false
System.out.println(a != b); //prints: true
System.out.println(a == c); //prints: true
System.out.println(a != c); //prints: false
int i1 = 1;
int i2 = 2;
int i3 = 1;
System.out.println(i1 == i2); //prints: false
System.out.println(i1 != i2); //prints: true
System.out.println(i1 == i3); //prints: true
System.out.println(i1 != i3); //prints: false
boolean b1 = true;
boolean b2 = false;
boolean b3 = true;
System.out.println(b1 == b2); //prints: false
System.out.println(b1 != b2); //prints: true
System.out.println(b1 == b3); //prints: true
System.out.println(b1 != b3); //prints: false
在这段代码中,char
类型与算术运算一样,被视为等于其代码点的数值。否则,不容易理解以下行的结果:
System.out.println((a + 1) == b); //prints: true
但从以下结果来看,这条线的解释是显而易见的:
System.out.println(b - a); //prints: 1
System.out.println((int)a); //prints: 97
System.out.println((int)b); //prints: 98
a
的码点为97
,b
的码点为98
。
对于基元类型float
和double
,相等运算符的工作方式似乎相同。下面是一个double
类型相等的示例:
double d1 = 0.42;
double d2 = 0.43;
double d3 = 0.42;
System.out.println(d1 == d2); //prints: false
System.out.println(d1 != d2); //prints: true
System.out.println(d1 == d3); //prints: true
System.out.println(d1 != d3); //prints: false
但是,这是因为我们将创建为文字的数字与固定的小数部分进行比较。如果我们比较以下计算的结果,很可能结果值永远不会等于预期结果,因为某些数字(例如1/3
)无法准确表示。那么1/3
的具体情况是什么?以十进制表示,它有一个永无止境的小数部分:
System.out.println((double)1/3); //prints: 0.3333333333333333
这就是为什么在比较类型float
和double
的值时,使用关系运算符<
、>
、<=
或=>
(见下一小节)更可靠。
对于对象引用,相等运算符将比较引用本身,而不是对象及其值:
SomeClass c1 = new SomeClass();
SomeClass c2 = new SomeClass();
SomeClass c3 = c1;
System.out.println(c1 == c2); //prints: false
System.out.println(c1 != c2); //prints: true
System.out.println(c1 == c3); //prints: true
System.out.println(c1 != c3); //prints: false
System.out.println(new SomeClass() == new SomeClass()); //prints: false
必须使用equals()
方法执行基于其包含值的对象相等。我们在第 2 章、Java 语言基础中讨论了它,稍后将在引用类型的方法 equals()部分中详细讨论。
关系运算符只能与基元类型一起使用:
int i1 = 1;
int i2 = 2;
int i3 = 1;
System.out.println(i1 > i2); //prints: false
System.out.println(i1 >= i2); //prints: false
System.out.println(i1 >= i3); //prints: true
System.out.println(i1 < i2); //prints: true
System.out.println(i1 <= i2); //prints: true
System.out.println(i1 <= i3); //prints: true
System.out.println('a' >= 'b'); //prints: false
System.out.println('a' <= 'b'); //prints: true
double d1 = 1/3;
double d2 = 0.34;
double d3 = 0.33;
System.out.println(d1 < d2); //prints: true
System.out.println(d1 >= d3); //prints: false
在前面的代码中,我们看到,int
类型值按预期相互比较,char
类型值根据它们的数字代码点值相互比较。
原语类型char
的变量在用作算术运算符、相等运算符或关系运算符的操作数时,分配的数值等于它们所表示的字符的代码点。
到目前为止,除了最后一行,没有什么意外。我们已经确定表示为小数的1/3
应该是0.3333333333333333
,大于0.33
。那么为什么d1 >= d3
返回false
?如果你说这是因为整数除法,你是对的。即使分配给类型为double
的变量,结果也是 0.0,因为整数除法1/3
在分配给d1
之前先发生。下面是演示它的代码:
double d1 = 1/3;
double d2 = 0.34;
double d3 = 0.33;
System.out.println(d1 < d2); //prints: true
System.out.println(d1 >= d3); //prints: false
System.out.println(d1); //prints: 0.0
double d4 = 1/3d;
System.out.println(d4); //prints: 0.3333333333333333
System.out.println(d4 >= d3); //prints: true
但除此之外,将float
和double
类型与关系运算符一起使用比将它们与相等运算符一起使用会产生更可预测的结果。
在比较类型float
和double
的值时,使用关系运算符<
、>
、<=
或=>
代替等式运算符==
和!=
。
与实验物理学一样,在比较float
和double
类型的值时,要考虑精度。
让我们首先定义每个逻辑运算符:
- 如果操作数为
false
,一元运算符!
返回true
,否则返回false
- 如果两个操作数都是
true
,则二进制运算符&
返回true
- 如果至少有一个操作数为真,则二进制运算符
|
返回真
以下是演示代码:
boolean x = false;
System.out.println(!x); //prints: true
System.out.println(!!x); //prints: false
boolean y = !x;
System.out.println(y & x); //prints: false
System.out.println(y | x); //prints: true
boolean z = true;
System.out.println(y & z); //prints: true
System.out.println(y | z); //prints: true
请注意,!
运算符可以多次应用于相同的值。
我们可以重用前面的代码示例,但是使用&&
和||
操作符而不是&
和|
操作符:
boolean x = false;
boolean y = !x;
System.out.println(y && x); //prints: false
System.out.println(y || x); //prints: true
boolean z = true;
System.out.println(y && z); //prints: true
System.out.println(y || z); //prints: true
结果并没有什么不同,但在执行过程中存在差异。运算符&
和|
始终检查两个操作数的值。同时,在&&
的情况下,如果左侧的操作数返回false
,则&&
运算符返回false
,而不计算右侧的操作数。并且,在||
的情况下,如果左侧的操作数返回true
,则||
运算符返回true
,而不计算右侧的操作数。下面是演示此差异的代码:
int i = 1, j = 3, k = 10;
System.out.println(i > j & i++ < k); //prints: false
System.out.println("i=" + i); //prints: i=2
System.out.println(i > j && i++ < k); //prints: false
System.out.println("i=" + i); //prints: i=2
两个操作符-&
和&&
-返回false
。但是在&&
的情况下,不检查第二个操作数i++ < k
,并且变量i
的值不改变。如果第二个操作数需要时间计算,则这种优化可以节省时间。
如果左侧条件在&&
情况下已通过测试(返回false
),或在||
情况下已成功(返回true
),则&&
和||
操作员不评估右侧条件。
然而,当需要始终检查第二个操作数时,&
运算符很有用。例如,第二个操作数可能是一个方法,在某些罕见的情况下,该方法可能会引发异常并更改逻辑流。
第三个条件运算符称为三值运算符。下面是它的工作原理:
int n = 1, m = 2;
System.out.println(n > m ? "n > m" : "n <= m"); //prints: n <= m
System.out.println(n > m ? true : false); //prints: false
int max = n > m ? n : m;
System.out.println(max); //prints: 2
它评估条件,如果为真,则返回第一个条目(在问号?
之后);否则,它将返回第二个条目(在冒号:
之后)。在两个选项之间进行选择是一种非常方便和紧凑的方式,而不是使用完整的if-else
语句结构:
String result;
if(n > m){
result = "n > m";
} else {
result = "n <= m";
}
我们将在第 10 章、控制流语句中讨论这些语句(称为条件语句)。
虽然我们不是第一次讨论它们,但它们是最常用的运算符,尤其是=
简单赋值运算符,它只为变量赋值(也称为为变量赋值)。我们已经多次看到简单赋值用法的示例。
使用简单赋值时唯一可能的警告是,左侧变量的类型与右侧的值或变量类型不同。在基本类型的情况下,类型的差异可能导致值的缩小或加宽,或者在一个类型是基本类型而另一个类型是引用类型的情况下导致装箱或拆箱。我们将在后面的原语类型和的加宽和缩小转换以及原语和引用类型之间的装箱和拆箱章节中讨论此类赋值。
其余的赋值运算符(+=``-=``*=``/=``%=
称为复合赋值运算符:
x += 2;
分配此加法的结果:x = x + 2;
x -= 2;
分配此减法的结果:x = x - 2;
x *= 2;
分配此乘法的结果:x = x * 2;
x /= 2;
分配该除法的结果:x = x / 2;
x %= 2;
分配该除法的剩余部分:x = x + x % 2;
运算x = x + x % 2;
基于运算符优先级规则,我们将在后面的运算符优先级和操作数的求值顺序部分中讨论。根据这些规则,首先执行%
运算符(模数),然后执行+
运算符(加法),然后将结果分配给左侧操作数变量x
。下面是演示代码:
int x = 1;
x += 2;
System.out.println(x); //prints: 3
x -= 1;
System.out.println(x); //prints: 2
x *= 2;
System.out.println(x); //prints: 4
x /= 2;
System.out.println(x); //prints: 2
x %= 2;
System.out.println(x); //prints: 0
再次强调,每次遇到整数除法时,我们最好将其转换为float
或double
除法,然后根据需要对其进行四舍五入或将其转换为整数。在我们的例子中,分数部分没有任何损失。但如果我们不知道x
的值,代码可能如下所示:
x = 11;
double y = x;
y /= 3; //That's the operation we wanted to do on x
System.out.println(y); //prints: 3.6666666666666665
x = (int)y;
System.out.println(x); //prints: 3
//or, if we need to round up the result:
double d = Math.round(y); //prints: 4.0
System.out.println(d);
x = (int) d;
System.out.println(x); //prints: 4
在这段代码中,我们假设我们不知道x
的值,所以我们切换到double
类型以避免小数部分的丢失。计算结果后,我们要么将其转换为int
(小数部分丢失),要么将其四舍五入到最接近的整数。
在这个简单的除法中,我们可能会丢失分数部分并得到3
,即使不转换为double
类型。但在实际计算中,公式通常不是那么简单,因此人们可能永远不知道整数除法可能发生在哪里。这就是为什么在开始计算之前将值转换为float
和double
是一种很好的做法。
到目前为止,我们已经看到了多次使用new
运算符的示例。它通过为新对象分配内存并返回对该内存的引用来实例化(创建)类的对象。然后,该引用通常被分配给与用于创建对象或其父对象类型的类相同类型的变量,尽管我们也看到了从未分配引用的情况。例如,在第 6 章中接口、类和对象构造中,我们使用此代码演示如何调用构造函数:
new Child();
new Child("The Blows");
但这种情况非常罕见,大多数情况下,我们需要引用新创建的对象以调用其方法:
SomeClass obj = new SomeClass();
obj.someMethod();
调用new
操作符并分配内存后,相应的(显式或默认)构造函数初始化新对象的状态。我们详细讨论了第 6 章、接口、类和对象构造。
由于数组也是对象,因此也可以使用new
运算符和任何 Java 类型创建它们:
int[] arrInt = new int[42];
[]
符号允许我们在前面的代码中设置数组长度(组件的最大数量,也称为元素)–42
。一个潜在的混淆源可能来自以下事实:在编译时,Java 允许将值分配给索引大于数组长度的组件:
int[] arrInt = new int[42];
arrInt[43] = 22;
但程序运行时,arrInt[43] = 22
行会抛出异常:
也可以不使用new
运算符而使用数组初始值设定项来创建数组:
int[] arrInt = {1,2,3,4};
类实例只能使用new
操作符创建。可以使用new
运算符或{}
初始值设定项创建数组。
我们在第 5 章、Java 语言元素和类型中对此进行了广泛讨论。如果没有显式初始化,数组的值将设置为取决于类型的默认值(我们在第 5 章、Java 语言元素和类型中也对其进行了描述)。下面是一个代码示例:
int[] arrInt = new int[42];
//arrInt[43] = 22;
System.out.println(arrInt[2]); //prints: 0
System.out.println(arrInt.length); //prints: 42
int[] arrInit = {1,2,3,4};
System.out.println(arrInit[2]); //prints: 3
System.out.println(arrInit.length); //prints: 4
提醒一下,数组第一个元素的索引是 0。
instanceof
运算符需要两个引用类型的操作数。这是因为它检查对象的父子关系,包括接口的实现。如果左侧操作数(对象引用)扩展或实现右侧的类型,则其计算结果为true
,否则为false
。显然,每个引用instanceof Object
都返回true
,因为在 Java 中,每个类都隐式继承Object
类。当instanceof
应用于任何类型的数组时,它仅对右侧操作数Object
返回true
。而且,由于null
不是任何类型的实例,null instanceof
为任何类型返回false
。以下是演示代码:
interface IntrfA{}
class ClassA implements IntrfA {}
class ClassB extends ClassA {}
class ClassX implements IntrfA {}
private void instanceofOperator() {
ClassA classA = new ClassA();
ClassB classB = new ClassB();
ClassX classX = new ClassX();
int[] arrI = {1,2,3};
ClassA[] arrA = {new ClassA(), new ClassA()};
System.out.println(classA instanceof Object); //prints: true
System.out.println(arrI instanceof Object); //prints: true
System.out.println(arrA instanceof Object); //prints: true
//System.out.println(arrA instanceof ClassA); //error
System.out.println(classA instanceof IntrfA); //prints: true
System.out.println(classB instanceof IntrfA); //prints: true
System.out.println(classX instanceof IntrfA); //prints: true
System.out.println(classA instanceof ClassA); //prints: true
System.out.println(classB instanceof ClassA); //prints: true
System.out.println(classA instanceof ClassB); //prints: false
//System.out.println(classX instanceof ClassA); //error
System.out.println(null instanceof ClassA); //prints: false
//System.out.println(classA instanceof null); //error
System.out.println(classA == null); //prints: false
System.out.println(classA != null); //prints: true
}
大多数结果都是直接的,并且可能是预期的。唯一可能被期待的是classX instanceof ClassA
。ClassX
和ClassA
都实现了相同的接口IntrfA
,因此它们之间有一定的亲和力–每个都可以转换到IntrfA
接口:
IntrfA intA = (IntrfA)classA;
intA = (IntrfA)classX;
但该关系不是父子关系类型,因此instanceof
运算符甚至不能应用于它们。
instanceof
操作符允许我们检查类实例(对象)是否有某个类作为父类或实现了某个接口。
我们在classA instanceof null
中看到了类似的问题,因为null
根本不引用任何对象,尽管null
是引用类型的文本。
在前面代码的最后两条语句中,我们展示了如何将对象引用与null
进行比较。这种比较通常在对引用调用方法之前使用,以确保引用不是null
。这有助于避免可怕的NullPointerException
,它会破坏执行流程。我们将在第 10 章、控制流语句中进一步讨论异常。
instance of
操作员非常有帮助。在这本书中我们已经用过好几次了。但是,有些情况可能需要我们重新考虑使用它的决定。
每次考虑使用instanceof
操作符时,试着看看是否可以通过使用多态性来避免它。
为了说明这一技巧,这里有一些代码可以从多态性中获益,而不是使用intanceof
运算符:
class ClassBase {
}
class ClassY extends ClassBase {
void method(){
System.out.println("ClassY.method() is called");
}
}
class ClassZ extends ClassBase {
void method(){
System.out.println("ClassZ.method() is called");
}
}
class SomeClass{
public void doSomething(ClassBase object) {
if(object instanceof ClassY){
((ClassY)object).method();
} else if(object instanceof ClassZ){
((ClassZ)object).method();
}
//other code
}
}
如果我们运行以下代码段:
SomeClass cl = new SomeClass();
cl.doSomething(new ClassY());
我们将看到:
然后,我们注意到ClassY
和ClassZ
中的方法具有相同的签名,因此我们可以将相同的方法添加到基类ClassBase:
class ClassBase {
void method(){
System.out.println("ClassBase.method() is called");
}
}
并简化SomeClass
实现:
class SomeClass{
public void doSomething(ClassBase object) {
object.method();
//other code
}
调用new SomeClass().doSomething(new ClassY())
后,我们仍然得到相同的结果:
这是因为method()
在子对象中被重写。ClassBase
中实现的方法可以有所作为,也可以无所作为。这并不重要,因为它永远不会被执行(除非您通过使用super
关键字从子类强制转换它来特别调用它)。
并且,在重写时,不要忘记使用@Override
注释:
class ClassZ extends ClassBase {
@Override
void method(){
System.out.println("ClassY.method() is called");
}
}
注释将帮助您验证您没有弄错,并且每个子类中的方法与父类中的方法具有相同的签名。
在类或接口内部,可以仅通过名称访问该类或接口的字段或方法。但在类或接口之外,可以使用 dot(.
运算符和以下命令访问非私有字段或方法:
- 如果字段或方法是非静态的(实例成员),则对象名称
- 如果字段或方法是静态的,则接口或类名
点运算符(.
可用于访问非私有字段或方法。如果字段或方法是静态的,则点运算符将应用于接口或类名。如果字段或方法是非静态的,则点运算符将应用于对象引用。
我们已经看到许多这样的例子。因此,我们只需在一个接口和实现它的类中总结所有情况。假设我们有以下名为InterfaceM
的接口:
interface InterfaceM {
String INTERFACE_FIELD = "interface field";
static void staticMethod1(){
System.out.println("interface static method 1");
}
static void staticMethod2(){
System.out.println("interface static method 2");
}
default void method1(){
System.out.println("interface default method 1");
}
default void method2(){
System.out.println("interface default method 2");
}
void method3();
}
我们可以使用点运算符(.
),如下所示:
System.out.println(InterfaceM.INTERFACE_FIELD); //1: interface field
InterfaceM.staticMethod1(); //2: interface static method
InterfaceM.staticMethod2(); //3: interface static method
//InterfaceM.method1(); //4: compilation error
//InterfaceM.method2(); //5: compilation error
//InterfaceM.method3(); //6: compilation error
System.out.println(ClassM.INTERFACE_FIELD); //7: interface field
案例 1、2 和 3 都很简单。案例 4、5 和 6 生成编译错误,因为只能通过实现接口的类的实例(对象)访问非静态方法。情况 7 是可能的,但不是访问接口字段(也称为常量)的推荐方法。使用接口名访问它们(如案例 1 中所示)使代码更容易理解。
现在让我们创建一个实现InterfaceM
接口的ClassM
类:
class ClassM implements InterfaceM {
public static String CLASS_STATIC_FIELD = "class static field";
public static void staticMethod2(){
System.out.println("class static method 2");
}
public static void staticMethod3(){
System.out.println("class static method 3");
}
public String instanceField = "instance field";
public void method2(){
System.out.println("class instance method 2");
}
public void method3(){
System.out.println("class instance method 3");
}
}
下面是使用点运算符(.
访问类字段和方法的所有可能情况:
//ClassM.staticMethod1(); //8: compilation error
ClassM.staticMethod2(); //9: class static method 2
ClassM.staticMethod3(); //10: class static method 3
ClassM classM = new ClassM();
System.out.println(ClassM.CLASS_STATIC_FIELD);//11: class static field
System.out.println(classM.CLASS_STATIC_FIELD);//12: class static field
//System.out.println(ClassM.instanceField); //13: compilation error
System.out.println(classM.instanceField); //14: instance field
//classM.staticMethod1(); //15: compilation error
classM.staticMethod2(); //16: class static method 2
classM.staticMethod3(); //17: class static method 3
classM.method1(); //18: interface default method 1
classM.method2(); //19: class instance method 2
classM.method3(); //20: class instance method 3
}
案例 8 生成编译错误,因为静态方法属于实现它的类或接口(在本例中)。
案例 9 是静态方法隐藏的一个示例。具有相同签名的方法在接口中实现,但被类实现隐藏。
案例 10 和 11 很简单。
情况 12 是可能的,但不推荐。使用类名访问静态类字段使代码更容易理解。
类 13 是一个明显的错误,因为只能通过实例(对象)访问实例字段。
第 14 类是案例 13 的正确版本。
类 15 是一个错误,因为静态方法属于实现它的类或接口(在本例中),而不是类实例。
情况 16 和 17 是可能的,但不推荐使用静态方法。使用类名(而不是实例标识符)访问静态方法使代码更容易理解。
案例 18 演示了接口如何为类提供默认实现。这是可能的,因为ClassM implements InterfaceM
有效地继承了接口的所有方法和字段。我们之所以说有效,是因为法律上正确的术语是类实现接口。但实际上,实现接口的类获取接口的所有字段和方法的方式与子类继承它们的方式相同。
案例 19 是重写接口默认实现的类的示例。
案例 20 是经典接口实现的一个示例。这就是接口的最初想法:提供 API 的抽象。
cast 运算符用于类型转换,将一种类型的值指定给另一种类型的变量。通常,它用于启用编译器不允许的转换。例如,当我们讨论整数除法时,我们使用类型转换,char
类型作为数字类型,并将类引用分配给具有一个实现接口类型的变量:
int i1 = 11;
int i2 = 3;
System.out.println((float)i1 / i2); //prints: 3.6666667
System.out.println((int)a); //prints: 97
IntrfA intA = (IntrfA)classA;
铸造时有两个潜在问题需要注意:
-
对于基元类型,该值应小于目标类型可以容纳的最大值(我们将在后面的基元类型的加宽和缩小转换部分详细讨论)
-
对于引用类型,左侧操作数应该是右侧操作数的父级(即使是间接的),或者左侧操作数应该是由右侧操作数表示的类实现的接口(即使是间接的):
interface I1{}
interface I2{}
interface I3{}
class A implements I1, I2 {}
class B extends A implements I3{}
class C extends B {}
class D {}
public static void main(String[] args) {
C c = new C(); //1
A a = (A)c; //2
I1 i1 = (I1)c; //3
I2 i2 = (I2)c; //4
I3 i3 = (I3)c; //5
c = (C)a; //6
D d = new D(); //7
//a = (A)d; //8 compilation error
i1 = (I1)d; //9 run-time error
}
在这段代码中,情况 6 是可能的,因为我们知道对象a
最初是基于对象c
进行强制转换的,所以我们可以将其强制转换回类型C
,并期望它作为类C
的对象完全起作用。
案例 8 未编译,因为编译器可以验证其父子关系。
案例 9 对于编译器来说并不那么容易,原因超出了本书的范围。因此,在编写代码时,IDE 不会给您任何提示,您可能会认为一切都会按预期工作。但在运行时,您可以得到ClassCastException
:
程序员很高兴看到它,就像他们喜欢看到我们之前演示的NullPointerException
或ArrayOutOfBoundException
一样。这就是为什么对接口的转换要比对类的转换更加小心。
类型转换是将一种类型的值赋给另一种类型的变量。执行此操作时,请确保目标类型可以保留该值,并在必要时对照最大目标类型值进行检查。
也可以将基元类型强制转换为匹配的引用类型:
Integer integer1 = 3; //line 1
System.out.println(integer1); //prints: 3
Integer integer2 = Integer.valueOf(4);
int i = integer2; //line 4
System.out.println(i); //prints: 4
在第 1 行和第 4 行中,强制转换是隐式完成的。我们将在后面的基本类型和引用类型之间的装箱和拆箱一节中更详细地讨论这种强制转换(也称为转换,或装箱和拆箱)。
正如我们在本节开头所说,表达式仅作为语句的一部分存在,后者是完整的动作(我们将在下一小节中讨论)。这意味着表达式可以是动作的构造块。有些表达式甚至可以在添加分号后成为完整操作(表达式语句)。
表达式的显著特征是可以对其求值,这意味着它可以作为执行的结果生成某些内容。这可能是三件事之一:
- 变量,如
i = 2
- 一个值,例如
2*2
- Nothing,当表达式是对不返回任何内容(void)的方法的调用时。这样的表达式只能是在表达式语句末尾加分号的完整操作。
表达式通常包含一个或多个运算符,并进行计算。它可以生成一个变量、一个值(包含在进一步的计算中),或者可以调用一个不返回任何内容的方法(void)。
表达式的求值也会产生副作用。也就是说,除了变量赋值或返回值外,它还可以执行其他操作,例如:
int x = 0, y;
y = x++; //line 2
System.out.println(y); //prints: 0
System.out.println(x); //prints: 1
第 2 行中的表达式为变量y
赋值,但也有将1
添加到变量x
值上的副作用。
根据其形式,表达式可以是:
- 主要表达式:
- 文字(某些值)
- 对象创建(使用
new
操作符或{}
数组初始值设定项) - 字段访问(对于外部类使用点运算符,对于此实例不使用点运算符)
- 方法调用(对于外部类使用点运算符,对于此实例不使用点运算符)
- 方法引用(使用 lambda 表达式中的
::
运算符) - 数组访问(带有
[]
符号,它携带要访问的元素的索引)
- 一元运算符表达式(例如,
x++
或-y
) - 二元运算符表达式(例如,
x+y
或x*y
) - 三元运算符表达式(例如,
x > y ? "x>y" : "x<=y"
) - 一个 lambda 表达式
i -> i + 1
(参见第 17 章、lambda 表达式与函数式编程)
表达式根据它们产生的操作命名:对象创建表达式、强制转换表达式、方法调用表达式、数组访问表达式、赋值表达式等等。
由其他表达式组成的表达式称为复杂表达式。括号通常用于清楚地标识每个子表达式,而不是依赖于运算符优先级(请参阅后面的运算符优先级和操作数的求值顺序部分)。
实际上,我们在第 2章*Java 语言基础中定义了一条语句。*这是一个可以执行的完整动作。它可以包括一个或多个表达式,并以分号;
结尾。
Java 语句描述一个操作。它是可以执行的最小构造。它可能包括也可能不包括一个或多个表达式。
Java 语句的可能类型有:
- 类或接口声明语句,如
class A {...}
- 只包含一个符号
;
的空语句 - 局部变量声明语句
int x;
- 同步语句–超出本书的范围
- 表达式语句,可以是以下语句之一:
- 方法调用语句,如
method();
- 赋值语句,如
x = 3;
- 对象创建语句,如
new SomeClass();
- 一元递增或递减语句,如
++x ;``--x;``x++;``x--;
- 方法调用语句,如
- 控制流报表(见第 10 章、控制流报表:
- 选择语句:
if-else
或switch-case
- 迭代语句:
for
、while
或do-while
- 异常处理语句,如
try-catch-finally
或throw
- 分支语句,如
break
、continue
、label:
、return
、assert
- 选择语句:
通过在语句前面放置标识符和冒号:
,可以将语句标记为。分支语句break
和continue
可以使用此标签重定向控制流。在第 10 章控制流语句中,我们将向您展示如何进行。
大多数情况下,语句组成一个方法体,这就是程序的编写方式。
当在同一个表达式中使用多个运算符时,在没有既定规则的情况下如何执行它们可能并不明显。例如,在计算以下右侧表达式后,将分配给变量x
的值是什么:
int x = 2 + 4 * 5 / 6 + 3 + 7 / 3 * 11 - 4;
我们知道怎么做,因为我们在学校里学过运算符优先级,只是先从左到右应用乘法和除法运算符,然后再从左到右应用加法和减法运算符。但是,事实证明,作者实际上想要这个操作符执行序列:
int x = 2 + 4 * 5 / 6 + ( 3 + 7 / 3 * (11 - 4));
这会产生不同的结果。
运算符优先级和括号决定表达式各部分的求值顺序。操作数的求值顺序为每个操作定义其操作数求值的顺序。
括号有助于识别复杂表达式的结构,并建立求值序列,该序列覆盖运算符优先级。
Java 规范没有在一个位置提供运算符优先级。我们必须把它从不同的部分拉到一起。这就是为什么互联网上的不同来源有时会有一些不同的操作符执行顺序,所以不要感到惊讶,如果有疑问,请进行实验或设置括号,以按照需要的顺序指导计算。
下表显示了运算符的优先级,从列表中第一个运算符的最高优先级(第一次执行)到最后的最低优先级。具有相同优先级的运算符在从左向右移动时,根据其在表达式中的位置执行(前提是不使用括号):
-
一个表达式,用于计算
[]
符号中数组元素的索引,例如x = 4* arr[i+1]
;字段访问和方法调用点运算符.
,如在x = 3*someClass.COUNT
或x = 2*someClass.method(2, "b")
中 -
一元后缀递增
++
和递减--
运算符,如int m = 5*n++
中的x++
或x--
;请注意,这样的运算符在递增/递减其值之前返回变量的旧值,因此具有递增值的副作用 -
带有
++
和--
运算符的一元前缀,如++x
或--x
;一元+
和-
运算符,如在+x
或-x
中;逻辑运算符 NOT,如在!b
中,其中 b 是布尔变量;一元非按位~
(不在本书范围内) -
一个 cast 运算符
()
,如double x = (double)11/3
,其中 11 首先被转换为double
,从而避免了丢失小数部分的整数除法问题;实例创建操作符new
,如new SomeClass()
中的 -
乘法运算符
*
、/
、%
-
加法运算符
+
、-
、字符串连接+
-
位移位运算符
<<
、>>
、>>>
; -
关系运算符
<
、>
、>=
、<=
、instanceof
-
相等运算符
==
、!=
-
逻辑和位运算符
&
-
位运算符
^
-
逻辑和位运算符
|
-
条件运算符
&&
-
条件运算符
||
-
条件运算符
?:
(三元) -
箭头操作符
->
-
作业操作员
=
、+=
、-=
、*=
、/=
、%=
、>>=
、<<=
、>>>=
、&=
、^=
、|=
如果存在括号,则首先计算最里面的括号内的表达式。例如,请看以下代码段:
int p1 = 10, p2 = 1;
int q = (p1 += 3) + (p2 += 3);
System.out.println(q); //prints: 17
System.out.println(p1); //prints: 13
System.out.println(p2); //prints: 4
赋值运算符的优先级最低,但如果在括号内,则首先执行赋值运算符,如前面的代码所示。为了证明这一点,我们可以删除第一组括号,然后再次运行相同的代码:
p1 = 10;
p2 = 1;
q = p1 += 3 + (p2 += 3);
System.out.println(q); //prints: 17
System.out.println(p1); //prints: 17
System.out.println(p2); //prints: 4
如您所见,现在第一个运算符赋值+=
在右侧表达式中最后执行。
使用括号可以提高复杂表达式的可读性。
您可以利用运算符优先级编写一个只有很少括号(如果有的话)的表达式。但是,代码的质量不仅取决于它的正确性。易于理解,以便其他可能不太熟悉运算符优先级的程序员能够维护它,这也是编写良好代码的标准之一。此外,即使是代码的作者,经过一段时间后,也可能难以理解结构不清晰的表达式。
计算表达式时,首先考虑括号和运算符优先级。然后,当从左向右移动时,表达式中具有相同执行优先级的部分将按照它们的显示进行计算。
使用括号可以提高对复杂表达式的理解,但嵌套的括号太多会使它变得模糊。如果有疑问,考虑把复杂的表达分成几句话。
最后,计算归结到每个运算符及其操作数。二元运算符的操作数从左到右求值,以便在开始计算右运算符之前,对左操作数进行完全求值。正如我们所看到的,左侧操作数可能会产生影响右侧运算符行为的副作用。下面是一个简单的例子:
int a = 0, b = 0;
int c = a++ + (a * ++b); //evaluates to: 0 + (1 * 1);
System.out.println(c); //prints: 1
在现实生活中的例子中,表达式可以包括具有复杂功能和广泛副作用的方法。左手操作数甚至可以引发异常,因此永远不会对右手操作数求值。但是,如果左侧求值在没有异常的情况下完成,Java 保证在执行运算符之前对两个操作数进行完全求值。
此规则不适用于条件运算符&&
、||
和?:
(请参见*条件运算符:&&|?:(三元)*部分)。
对于引用类型,将子对象引用指定给父类类型的变量称为加宽引用转换或向上转换。将父类类型引用赋值给子类类型的变量称为缩小引用转换或向下转换。
例如,如果类SomeClass
扩展了SomeBaseClass
,则可以进行以下声明和初始化:
SomeBaseClass someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass();
而且,由于默认情况下每个类都扩展了java.lang.Object
类,因此也可以进行以下声明和初始化:
Object someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass(); //line 2
在第 2 行中,我们为超类类型的变量分配了一个子类实例引用。存在于子类中但不在超类中的方法无法通过超类类型的引用进行访问。第 2 行中的赋值称为引用的加宽,因为它变得不那么专业。
将父对象引用指定给子类类型的变量称为缩小引用转换或向下转换。只有在首先应用加宽参照转换后,才能进行此操作。
下面是一个代码示例,演示了这种情况:
class SomeBaseClass{
void someMethod(){
...
}
}
class SomeClass extends SomeBaseClass{
void someOtherMethod(){
...
}
}
SomeBaseClass someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass();
someBaseClass.someMethod(); //works just fine
//someBaseClass.someOtherMethod(); //compilation error
((SomeClass)someBaseClass).someOtherMethod(); //works just fine
//The following methods are available as they come from Object:
int h = someBaseClass.hashCode();
Object o = someBaseClass.clone();
//All other public Object's methods are accessible too
窄化转换需要强制转换,我们在讨论强制转换操作符(参见强制转换操作符部分)时详细讨论了这一点,包括对接口的强制转换,这是另一种向上转换形式。
当一个数值类型的值(或变量)被分配给另一个数值类型的变量时,新类型可能包含较大的数值或较小的最大数值。如果目标类型可以容纳更大的数字,则转换将变宽。否则,这是一个缩小转换,通常需要使用 cast 操作符进行类型转换。
数字类型可以容纳的最大位数由分配给该类型的位数决定。为了提醒您,以下是每个数字类型表示的位数:
byte
:8 位- 【位:T016】
- 【位:T016】
int
:32 位long
:64 位float
:32 位double
:64 位
Java 规范定义了 19 个扩展原语转换:
byte
至short
、int
、long
、float
或double
short
至int
、long
、float
或double
char
至int
、long
、float
或double
int
至long
、float
或double
long
至float
或double
float
至double
在扩大整数类型之间的转换以及某些从整数类型到浮点值的转换时,结果值与原始值保持不变。但根据规范,可能会导致从int
转换为float
,或从long
转换为float
,或从long
转换为double
:
精度损失-也就是说,结果可能会丢失一些值的最低有效位。在这种情况下,使用 IEEE 754 舍入到最近模式,得到的浮点值将是整数值的正确舍入版本
让我们通过代码示例来了解这种效果,并从int
类型转换为float
和double
开始:
int n = 1234567899;
float f = (float)n;
int r = n - (int)f;
System.out.println(r); //prints: -46
double d = (double)n;
r = n - (int)d;
System.out.println(r); //prints: 0
如说明书所述,只有从int
到float
的转换失去了精度。从int
到double
的转换还可以。现在,让我们转换long
类型:
long l = 1234567899123456L;
float f = (float)l;
long rl = l - (long)f;
System.out.println(rl); //prints: -49017088
double d = (double)l;
rl = l - (long)d;
System.out.println(rl); //prints: 0
l = 12345678991234567L;
d = (double)l;
rl = l - (long)d;
System.out.println(rl); //prints: -1
从long
到float
的转换在很大程度上失去了精度。嗯,说明书警告我们了。但从long
到double
的转换起初看起来不错。然后,我们将long
值增加了大约十倍,得到了-1
的精度损失。所以,这取决于这个值有多大。
尽管如此,Java 规范不允许任何由转换引起的运行时异常。在我们的示例中,我们也没有遇到异常。
数值基元类型的缩小转换发生在从较宽类型到较窄类型的相反方向,通常需要强制转换。Java 规范确定了 22 种缩小原语转换:
short
至byte
或char
char
至byte
或short
int
至byte
、short
或char
long
至byte
、short
、char
或int
float
至byte
、short
、char
、int
或long
double
至byte
、short
、char
、int
、long
或float
这可能会导致值的大小损失,也可能导致精度损失。缩小的程序比扩大的程序更复杂,对它的讨论超出了入门课程的范围。至少可以确保原始值小于目标类型的最大值:
double dd = 1234567890.0;
System.out.println(Integer.MAX_VALUE); //prints: 2147483647
if(dd < Integer.MAX_VALUE){
int nn = (int)dd;
System.out.println(nn); //prints: 1234567890
} else {
System.out.println(dd - Integer.MAX_VALUE);
}
dd = 2234567890.0;
System.out.println(Integer.MAX_VALUE); //prints: 2147483647
if(dd < Integer.MAX_VALUE){
int nn = (int)dd;
System.out.println(nn);
} else {
System.out.println(dd - Integer.MAX_VALUE); //prints: 8.7084243E7
}
正如您从这些示例中看到的,当数字适合目标类型时,可以很好地进行缩小转换,但是如果原始值大于目标类型的最大值,我们甚至不尝试进行转换。
在强制转换之前,请考虑目标类型可以容纳的最大值,尤其是在缩小值类型时。
但这不仅仅是为了避免完全失去价值。类型char
和类型byte
或short
之间的转换尤其复杂。原因是类型char
是无符号数字类型,而类型 byte 和 short 是有符号数字类型,因此可能会丢失一些信息。
转换并不是将一种基本类型转换为另一种基本类型的唯一方法。每个基元类型都有一个对应的引用类型–一个称为基元类型的包装类的类。
所有包装类都位于java.lang
包中:
java.lang.Boolean
java.lang.Byte
java.lang.Character
java.lang.Short
java.lang.Integer
java.lang.Long
java.lang.Float
java.lang.Double
除了Boolean
和Character
类之外,它们大多数都扩展了java.lang.Number
类,该类具有以下抽象方法声明:
byteValue()
shortValue()
intValue()
longValue()
floatValue()
doubleValue()
这意味着每个Number
类孩子都必须实现所有这些。此类方法也在Character
类中实现,而Boolean
类具有booleanValue()
方法。这些方法也可用于扩大和缩小基元类型。
此外,每个包装类都有允许将数值的String
表示转换为相应的基元数值类型或引用类型的方法,例如:
byte b = Byte.parseByte("3");
Byte bt = Byte.decode("3");
boolean boo = Boolean.getBoolean("true");
Boolean bool = Boolean.valueOf("false");
int n = Integer.parseInt("42");
Integer integer = Integer.getInteger("42");
double d1 = Double.parseDouble("3.14");
Double d2 = Double.valueOf("3.14");
之后,可以使用前面列出的方法(byteValue()
、shortValue()
等)将值转换为另一个原语类型。
并且每个包装类都有静态方法toString(primitive value)
将原语类型值转换为其String
表示:
String s1 = Integer.toString(42);
String s2 = Double.toString(3.14);
包装器类还有许多其他有用的方法,可以从一种原语类型转换为另一种原语类型,以及转换为不同的格式和表示形式。因此,如果您需要类似的东西,请首先查看java.lang
包中的数值类型类包装。
其中一种类型转换允许从相应的基元类型创建包装类对象,反之亦然。我们将在下一节讨论这种转换。
装箱将基元类型的值转换为相应包装器类的对象。取消装箱将包装类的对象转换为相应基元类型的值。
可以自动(称为自动装箱)或显式使用每个包装器类型中可用的valueOf()
方法装箱基本类型:
int n = 12;
Integer integer = n; //an example of autoboxing
System.out.println(integer); //prints: 12
integer = Integer.valueOf(n);
System.out.println(integer); //prints: 12
Byte b = Byte.valueOf((byte)n);
Short s = Short.valueOf((short)n);
Long l = Long.valueOf(n);
Float f = Float.valueOf(n);
Double d = Double.valueOf(n);
请注意,Byte
和Short
包装器的valueOf()
方法的输入值需要强制转换,因为这是一个原始类型的缩小,我们在上一节中讨论过。
可以使用在每个包装器类中实现的Number
类的方法来完成拆箱:
Integer integer = Integer.valueOf(12);
System.out.println(integer.intValue()); //prints: 12
System.out.println(integer.byteValue()); //prints: 12
System.out.println(integer.shortValue()); //prints: 12
System.out.println(integer.longValue()); //prints: 12
System.out.println(integer.floatValue()); //prints: 12.0
System.out.println(integer.doubleValue()); //prints: 12.0
与自动装箱类似,也可以自动取消装箱:
Long longWrapper = Long.valueOf(12L);
long lng = longWrapper; //implicit unboxing
System.out.println(lng); //prints: 12
但是,这并不称为自动取消装箱。而是使用术语隐式取消装箱。
相等运算符应用于引用类型时,比较的是引用值,而不是对象的内容。只有当两个引用(变量值)指向同一个对象时,它才会返回true
。我们已经多次演示过:
SomeClass o1 = new SomeClass();
SomeClass o2 = new SomeClass();
System.out.println(o1 == o2); //prints: false
System.out.println(o1 == o1); //prints: true
o2 = o1;
System.out.println(o1 == o2); //prints: true
这意味着,即使比较具有相同字段值的同一类的两个对象,相等运算符也会返回false
。这通常不是程序员所需要的。相反,当它们具有相同的类型和相同的字段值时,通常需要考虑两个对象是相等的。有时,我们甚至不想考虑所有字段,而只希望在程序逻辑中将对象标识为唯一的。例如,如果一个人改变了发型或衣着,我们仍然将他或她识别为同一个人,即使描述该人的对象具有字段hairstyle
或dress
。
对于通过字段值对对象进行比较,应使用equals()
方法。在第 2 章Java 语言基础中,我们已经建立了所有引用类型(隐式)扩展java.lang.Object
类,该类实现了equals()
方法:
public boolean equals(Object obj) {
return (this == obj);
}
如您所见,它只比较使用相等运算符的引用,这意味着如果一个类或其父类之一未实现equals()
方法(该方法覆盖Object
类的实现),则使用equals()
方法的结果将与使用相等运算符==
的结果相同。让我们来演示一下。以下类未实现equals()
方法:
class PersonNoEquals {
private int age;
private String name;
public PersonNoEquals(int age, String name) {
this.age = age;
this.name = name;
}
}
如果我们使用它并比较equals()
方法和==
操作符的结果,我们将看到以下内容:
PersonNoEquals p1 = new PersonNoEquals(42, "Nick");
PersonNoEquals p2 = new PersonNoEquals(42, "Nick");
PersonNoEquals p3 = new PersonNoEquals(25, "Nick");
System.out.println(p1.equals(p2)); //false
System.out.println(p1.equals(p3)); //false
System.out.println(p1 == p2); //false
p1 = p2;
System.out.println(p1.equals(p2)); //true
System.out.println(p1 == p2); //true
正如我们所期望的,无论我们使用equals()
方法还是==
操作符,结果都是相同的。
现在,让我们来实现equals()
方法:
class PersonWithEquals{
private int age;
private String name;
private String hairstyle;
public PersonWithEquals(int age, String name, String hairstyle) {
this.age = age;
this.name = name;
this.hairstyle = hairstyle;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PersonWithEquals person = (PersonWithEquals) o;
return age == person.age && Objects.equals(name, person.name);
}
}
注意,在建立对象的相等性时,我们忽略了hairstyle
字段。另一个需要注释的方面是使用java.utils.Objects
类的equals()
方法。以下是它的实施:
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
如您所见,它首先比较引用,然后确保引用不是null
(以避免NullPointerException
),然后使用java.lang.Object
基类的equals()
方法或作为参数值传入的子类中可能存在的重写实现。在我们的例子中,我们传入了类型为String
的参数对象,这些参数对象实现了equals()
方法,它比较String
类型的值,而不仅仅是引用(我们将很快讨论)。因此,对象的任何字段PersonWithEquals
中的任何差异都将导致该方法返回false
。
如果我们再次运行测试,我们将看到:
PersonWithEquals p11 = new PersonWithEquals(42, "Kelly", "Ponytail");
PersonWithEquals p12 = new PersonWithEquals(42, "Kelly", "Pompadour");
PersonWithEquals p13 = new PersonWithEquals(25, "Kelly", "Ponytail");
System.out.println(p11.equals(p12)); //true
System.out.println(p11.equals(p13)); //false
System.out.println(p11 == p12); //false
p11 = p12;
System.out.println(p11.equals(p12)); //true
System.out.println(p11 == p12); //true
现在,equals()
方法不仅在引用相等时返回true
(因此它们指向相同的对象),而且在引用不同但它们引用的对象具有相同类型和对象标识中包含的某些字段的相同值时返回true
。
我们可以创建一个基类Person
,它只有两个字段age
和name
以及equals()
方法,正如前面实现的那样。然后,我们可以使用PersonWithHair
类对其进行扩展(该类具有附加字段hairstyle
:
class Person{
private int age;
private String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
}
class PersonWithHair extends Person{
private String hairstyle;
public PersonWithHair(int age, String name, String hairstyle) {
super(age, name);
this.hairstyle = hairstyle;
}
}
PersonWithHair
类的对象将以与前面的PersonWithEquals
测试相同的方式进行比较:
PersonWithHair p21 = new PersonWithHair(42, "Kelly", "Ponytail");
PersonWithHair p22 = new PersonWithHair(42, "Kelly", "Pompadour");
PersonWithHair p23 = new PersonWithHair(25, "Kelly", "Ponytail");
System.out.println(p21.equals(p22)); //true
System.out.println(p21.equals(p23)); //false
System.out.println(p21 == p22); //false
p21 = p22;
System.out.println(p21.equals(p22)); //true
System.out.println(p21 == p22); //true
这是可能的,因为PersonWithHair
的对象也属于Person
类型,所以采用这一行:
Person person = (Person) o;
equals()
方法中的前一行没有抛出ClassCastException
。
然后我们可以创建PersonWithHairDresssed
类:
PersonWithHairDressed extends PersonWithHair{
private String dress;
public PersonWithHairDressed(int age, String name,
String hairstyle, String dress) {
super(age, name, hairstyle);
this.dress = dress;
}
}
如果我们再次运行相同的测试,它将产生相同的结果。但我们认为服装和发型不是鉴定的一部分,所以我们可以进行测试,比较Person
的孩子:
Person p31 = new PersonWithHair(42, "Kelly", "Ponytail");
Person p32 = new PersonWithHairDressed(42, "Kelly", "Pompadour", "Suit");
Person p33 = new PersonWithHair(25, "Kelly", "Ponytail");
System.out.println(p31.equals(p32)); //false
System.out.println(p31.equals(p33)); //false
System.out.println(p31 == p32); //false
那不是我们所期望的!由于Person
基类的equals()
方法中的这一行,孩子们被认为是不平等的:
if (o == null || getClass() != o.getClass()) return false;
前一行失败,因为getClass()
和o.getClass()
方法返回子类名–使用new
运算符实例化的子类名。为了摆脱这种困境,我们使用以下逻辑:
- 我们对
equals()
方法的实现位于Person
类中,因此我们知道当前对象的类型为Person
- 为了比较类,我们需要做的就是确保另一个对象也是类型
Person
如果我们更换这一行:
if (o == null || getClass() != o.getClass()) return false;
使用以下代码:
if (o == null) return false;
if(!(o instanceof Person)) return false;
结果将是:
Person p31 = new PersonWithHair(42, "Kelly", "Ponytail");
Person p32 = new PersonWithHairDressed(42, "Kelly", "Pompadour", "Suit");
Person p33 = new PersonWithHair(25, "Kelly", "Ponytail");
System.out.println(p31.equals(p32)); //true
System.out.println(p31.equals(p33)); //false
System.out.println(p31 == p32); //false
这就是我们想要的,不是吗?通过这种方式,我们实现了最初的想法,即不将发型和服装包括在身份识别中。
在对象引用的情况下,相等运算符==
和!=
比较引用本身,而不是对象字段(状态)的值。如果需要比较对象状态,请使用重写了Object
类中的方法的equals()
方法。
原始类型的String
类和包装器类也覆盖equals()
方法。
在第 5 章、Java 语言元素和类型中,我们已经讨论了这一点,甚至还回顾了源代码。这是:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ?
StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
如您所见,它重写了Object
类实现,以便比较值,而不仅仅是引用。这段代码证明了这一点:
String sl1 = "test1";
String sl2 = "test2";
String sl3 = "test1";
System.out.println(sl1 == sl2); //1: false
System.out.println(sl1.equals(sl2)); //2: false
System.out.println(sl1 == sl3); //3: true
System.out.println(sl1.equals(sl3)); //4: true
String s1 = new String("test1");
String s2 = new String("test2");
String s3 = new String("test1");
System.out.println(s1 == s2); //5: false
System.out.println(s1.equals(s2)); //6: false
System.out.println(s1 == s3); //7: false
System.out.println(s1.equals(s3)); //8: true
System.out.println(sl1 == s1); //9: false
System.out.println(sl1.equals(s1)); //10: true
您可以看到,等式运算符==
有时正确比较String
对象值,有时则不正确。然而,equal()
方法总是正确地比较值,即使它们被包装在不同的对象中,而不仅仅是引用文字。
我们在测试中加入了相等运算符,以澄清人们在互联网上阅读的String
值的错误解释比预期的要多的情况。错误的解释基于支持String
实例不变性的 JVM 实现(请阅读第 5 章、Java 语言元素和类型中的String
不变性及其动机)。JVM 不会两次存储相同的String
值,而是在名为字符串 interning的过程中重用已存储在名为字符串池的区域中的值。在了解到这一点后,一些人认为使用带有String
值的equals()
方法是不必要的,因为相同的值将具有相同的参考值。我们的测试证明,在类String
中包含String
值的情况下,等式运算符无法正确比较其值,必须使用equals()
方法。还有一些情况下,String
值未存储在字符串池中。
要按值比较两个String
对象,请始终使用equals()
方法,而不是相等运算符==
。
一般来说,equals()
方法不如==
操作符快。但是,正如我们在第 5 章*Java 语言元素和类型中已经指出的,*类字符串的equals()
方法首先比较引用,这意味着在调用equals()
方法之前,不需要尝试节省性能时间和比较代码中的引用。只要调用equals()
方法即可。
String
型行为的模糊性有时像原始型,有时像参考型,这让我想起了物理学中基本粒子的双重性质。粒子有时表现得像小的集中物体,但有时像波。幕后到底发生了什么,事情的本质是什么?那里也有不可改变的东西吗?
如果我们对包装类运行测试,结果将是:
long ln = 42;
Integer n = 42;
System.out.println(n.equals(42)); //true
System.out.println(n.equals(ln)); //false
System.out.println(n.equals(43)); //false
System.out.println(n.equals(Integer.valueOf(42))); //true
System.out.println(n.equals(Long.valueOf(42))); //false
根据我们对Person
子类的经验,我们可以非常自信地假设包装类的equals()
方法包括类名的比较。让我们看看源代码。以下是Integer
类的equals()
方法:
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
这正是我们所期望的。如果一个对象不是Integer
类的实例,则永远不能将其视为等于另一个类的对象,即使它携带完全相同的数值。它看起来像古代的社会阶级体系,不是吗?
下面的语句叫什么?
i++;
String s;
s = "I am a string";
doSomething(1, "23");
以下语句称为:
- 增量报表:
i++;
- 变量声明语句:
String s;
- 转让声明:
s = "I am a string";
- 方法调用语句:
doSomething(1, "23");
在本章中,我们了解了 Java 编程的三个核心元素是运算符、表达式和语句,以及它们之间的关系。我们向您介绍了所有 Java 操作符,用示例讨论了最流行的操作符,并解释了使用它们可能出现的问题。本章的大部分内容专门讨论数据类型转换:加宽和缩小、装箱和取消装箱。引用类型的equals()
方法也在各种类和实现的特定示例上进行了演示和测试。String
类被突出使用,解决了流行的对其行为的错误解释。
在下一章中,我们将开始使用控制流语句编写程序逻辑,控制流语句将在许多示例中定义、解释和演示:条件语句、迭代语句、分支语句和异常。