Java17-快速语法参考-全-
Java17 快速语法参考(全)
一、你好世界
安装
在使用 Java 编程之前,您需要下载并安装一个 Java 开发工具包(JDK),例如 Oracle 网站上的标准版(JDK SE)。1JDK 包括 Java 编译器、类库以及运行 Java 应用所需的虚拟机等。您还应该下载一个集成开发环境(IDE ),因为它会使 Java 开发变得更加容易。一个这样的 Java IDE 是 Apache NetBeans,2,它可以在 Windows、macOS 和 Linux 上免费获得。如果您根本不想使用任何 IDE,使用常规文本编辑器也是一种选择。若要在没有 IDE 的情况下工作,可以使用。java 扩展—例如 myapp . Java—并在您选择的文本编辑器中打开它。
创建项目
如果您决定使用 ide(推荐),您需要创建一个项目,它将管理 Java 源文件和其他资源。要在 NetBeans 中创建项目,请单击“文件”“➤新建项目”。在对话框中,选择 Java with Ant 类别下的 Java 应用程序项目,然后单击 Next。在此对话框中,将项目名称设置为“MyProject ”,将主类的名称设置为“myproject”。MyApp”。如果需要,更改项目的位置,然后单击 Finish 生成项目。该项目的唯一文件 MyApp.java 将会打开,其中包含一些默认代码。您可以删除所有代码,以便从一个空的源文件开始。
你好世界
当您设置好项目和编程环境后,您将创建的第一个应用程序是 Hello World 程序。这个程序将教你如何编译和运行 Java 应用程序,以及如何输出一个字符串到一个命令窗口。
创建这个程序的第一步是向 MyApp.java 源文件添加一个公共类。该类的名称必须与物理源文件的名称相同,没有文件扩展名,在本例中为“MyApp”在 Java 中,每个文件有多个类是合法的,但是只允许有一个公共类,并且它的名字必须与文件名匹配。记住,Java 是区分大小写的。类名后面的花括号界定了属于该类并且必须包含的内容。括号及其内容被称为代码块,或简称为块。
public class MyApp {}
Java 类被组织成包,类似于其他语言中的名称空间。package 语句需要出现在文件的顶部,以指定文件属于哪个包。这个名称必须与文件所在的目录(相对于项目的源目录)相匹配,所以在这种情况下,包名是myproject
。
package myproject;
public class MyApp {}
接下来,在类中添加 main 方法。这是应用程序的起点,必须始终包含在如下代码所示的相同表单中。关键字本身将在后面的章节中讨论。
package myproject;
public class MyApp {
public static void main(String[] args) {}
}
完成 Hello World 程序的最后一步是通过调用print
方法输出文本。这个方法位于System
类中,然后在out
类中再往下一层。该方法采用单个参数——要打印的字符串——并以分号结束,Java 中的所有语句都是如此。
package myproject;
public class MyApp {
public static void main(String[] args) {
System.out.print("Hello World");
}
}
注意,点运算符(.
)用于访问类的成员。与print
类似,还有println
方法,它自动在打印字符串的末尾添加一个换行符。System
类属于 java.lang 包,它总是包含在 java 项目中。
代码提示
如果您不确定一个特定的类包含什么,或者一个方法接受什么参数,您可以利用一些 ide 中的代码提示,比如 NetBeans。代码提示窗口会在您键入代码的任何时候出现,并且有多个预先确定的选项。您也可以通过按 Ctrl+空格键手动调出它。这是一个强大的特性,它让您可以快速访问类库及其成员,以及描述。
二、编译并运行
从 IDE 运行
完成 Hello World 程序后,您可以用两种方式之一编译和运行它。第一种方法是从您正在使用的 IDE 的菜单栏中选择 Run。在 NetBeans 中,菜单命令是运行➤运行项目。然后,IDE 将编译并运行该应用程序,该应用程序将在 IDE 的输出窗口中显示文本“Hello World”。
从控制台窗口运行
另一种方式是使用控制台窗口手动编译程序,比如 Windows 下的 C:\Windows\System32\cmd.exe。最方便的方法是首先将 JDK bin 目录添加到PATH
环境变量中。在 Windows 中,您可以使用SET PATH
命令,然后将路径附加到您的 JDK 安装的 bin 文件夹,用分号隔开。请注意,确切的路径取决于您安装的 JDK 版本。
SET PATH=%PATH%;"C:\Program Files\Java\jdk-17.0.2\bin"
通过这样做,控制台将能够在这个控制台会话期间从任何文件夹中找到 Java 编译器。PATH
变量也可以永久改变。 1 接下来,导航到 Java 源文件所在的文件夹,通过键入 javac 后跟完整的文件名来运行编译器。
C:\MyProject\src\myproject> javac MyApp.java
程序将被编译成一个名为 MyApp.class 的类文件,这个类文件包含的是字节码而不是机器码,所以要执行它,你需要通过键入 java 后跟全限定类名来调用 Java 虚拟机,全限定类名包括包名。该命令需要从父文件夹(项目的源文件夹)中执行。请注意。编译文件时使用 java 扩展名,但。运行时不使用类扩展。
C:\MyProject\src> java myproject.MyApp
或者,从 Java 11 开始,您可以通过给 Java 命令提供完整的文件名来编译和运行源文件:
java MyApp.java
评论
注释用于在源代码中插入注释,对结束程序没有影响。Java 有标准的 C++注释符号,有单行注释和多行注释。
// single-line comment
/* multi-line
comment */
除了这些,还有 Javadoc 注释。该注释用于通过使用 JDK bin 文件夹中包含的实用程序生成文档,该实用程序也称为 Javadoc。
/** javadoc
comment */
预览功能
预览功能是一项新功能,在未来的 JDK 版本中可能会有所变化。若要编译包含预览功能的代码,必须为项目指定一个附加的命令行选项。在 NetBeans 中,这是通过首先打开文件➤项目属性窗口来完成的。在那里,从构建类别中选择编译选项卡,并在该窗口的底部,在标有“附加编译器选项”的输入框中添加“- enable-preview”。单击“确定”,将为此项目启用预览功能。
三、变量
变量用于在程序执行期间将数据存储在内存中。
数据类型
根据您需要存储的数据,有几种数据类型。Java 语言内置了八种类型,称为原语。整数类型有byte
、short
、int
和long
。float
和double
类型代表浮点数(实数)。char
类型保存 Unicode 字符,而boolean
类型包含 true 或 false 值。除了这些基本类型,Java 中的其他所有类型都由一个类、一个接口或一个数组来表示。
数据类型
|
大小(位)
|
描述
|
| --- | --- | --- |
| byte``short``int``long
| eightSixteenThirty-twoSixty-four | 带符号整数 |
| float``double
| Thirty-twoSixty-four | 浮点数 |
| char
| Sixteen | Unicode 字符 |
| boolean
| one | 布尔值 |
声明变量
要声明(创建)一个变量,从你希望它保存的数据类型开始,后面跟着一个变量名。名称可以是您想要的任何名称,但是最好给变量起一个与它们所包含的值密切相关的名称。变量的标准命名约定是第一个单词应该小写,随后的所有单词都应该大写。
int myInt;
分配变量
要给变量赋值,可以使用赋值运算符(=
)后跟值。当一个变量被初始化(赋值)时,它就被定义(声明和赋值)。
myInt = 10;
声明和赋值可以合并成一条语句:
int myInt = 10;
如果您需要多个相同类型的变量,有一种使用逗号运算符(,)来声明或定义它们的简便方法:
int myInt = 10, myInt2 = 20, myInt3;
使用变量
一旦定义了变量,您就可以通过引用变量的名称来使用它,例如,打印它:
System.out.print(myInt);
整数类型
如前所述,有四种有符号整数类型可供使用,这取决于您需要变量保存多大的数字:
byte myInt8 = 2; // -128 to +127
short myInt16 = 1; // -32768 to +32767
int myInt32 = 0; // -2³¹ to +2³¹-1
long myInt64 = -1; // -2⁶³ to +2⁶³-1
除了标准的十进制记数法,整数也可以用八进制或十六进制记数法来赋值。从 Java 7 开始,也可以使用二进制表示法。
int myHex = 0xF; // hexadecimal (base 16)
int myOct = 07; // octal (base 8)
int myBin = 0b10; // binary (base 2)
数字中的数字可以用下划线(_)分隔。这个特性是在 Java 7 中引入的,提供它只是为了提高可读性。
int bigNumber = 10_000_000;
浮点类型
浮点类型可以存储整数,也可以存储浮点数。它们可以用十进制或指数记数法来赋值。
double myDouble = 3.14;
double myDouble2 = 3e2; // 3*10² = 300
注意,Java 中的常量浮点数在内部总是以双精度形式保存。因此,如果您试图将一个 double 赋值给一个 float,您将会得到一个错误,因为 double 比 float 具有更高的精度。为了正确赋值,您可以在常量后面添加一个字符 F ,这表示该数字实际上是一个浮点数。
float myFloat = 3.14; // error
float myFloat = 3.14F; // ok
更常见和有用的方法是使用显式强制转换。通过将所需的数据类型放在要转换的变量或常量之前的括号中,执行显式强制转换。这将在赋值发生之前将值转换为指定的类型—在本例中为float
。
float myFloat = (float)3.14;
字符类型
char
数据类型可以包含一个 Unicode 字符,用单引号分隔:
char myChar = 'A';
也可以使用特殊的十六进制表示法来分配字符,该表示法允许访问所有 Unicode 字符:
char myChar = '\u0000'; // \u0000 to \uFFFF
布尔型
boolean
类型可以存储一个布尔值,这个值只能是真或假。这些值由关键字true
和false
指定。
boolean myBool = false;
变量作用域
变量的范围指的是代码块,在这个代码块中可以无限制地使用该变量。例如,局部变量是在方法中声明的变量。这样的变量只有在声明后,才能在方法的代码块中使用。一旦方法的作用域(代码块)结束,局部变量将被销毁。
public static void main(String[] args)
{
int localVar; // local variable
}
除了局部变量,Java 还有字段和参数类型的变量,这将在后面的章节中介绍。但是 Java 不像 C++那样有全局变量。
匿名块
您可以使用一个匿名(未命名)代码块来限制局部变量的范围。这种结构很少使用,因为如果一个方法足够大,可以保证使用匿名块,那么更好的选择通常是将代码分成单独的方法。
public static void main(String[] args)
{
// Anonymous code block
{
int localVar = 10;
}
// localVar is unavailable from here
}
类型推理
从 Java 10 开始,可以用var
声明局部变量,让编译器根据变量的赋值自动确定变量的类型。因此,以下两个声明是等效的:
var i = 5; // Implicit type
int i = 5; // Explicit type
何时使用var
取决于个人喜好。如果变量的类型从赋值中显而易见,使用var
可能会更好地缩短声明并提高可读性。如本例所示,使用非基元类型的好处变得更加明显。
// No type inference
java.util.ArrayList a = new java.util.ArrayList();
// With type inference
var a = new java.util.ArrayList();
请记住,var
只能在局部变量同时被声明和初始化时使用。
四、运算符
运算符是用来对值进行运算的特殊符号。专门处理数字的运算符可以分为五种类型:算术、赋值、比较、逻辑和按位运算符。
算术运算符
算术运算符包括四种基本算术运算,以及用于获得除法余数的模数运算符(%
):
float x = 3+2; // addition (5)
x = 3-2; // subtraction (1)
x = 3*2; // multiplication (6)
x = 3/2; // division (1)
x = 3%2; // modulus (1)
请注意,除法符号给出了不正确的结果。这是因为它对两个整数值进行运算,因此会对结果进行舍入并返回一个整数。要获得正确的值,必须将其中一个数字显式转换为浮点类型。
float x = (float)3/2; // 1.5
赋值运算符
第二组是赋值操作符——最重要的是赋值操作符本身(=
),它给变量赋值:
int i = 0; // assignment
赋值运算符和算术运算符的一个常见用途是对变量进行运算,然后将结果保存回同一个变量中。使用组合赋值操作符可以缩短这些操作。
i += 5; // i = i+5;
i -= 5; // i = i-5;
i *= 5; // i = i*5;
i /= 5; // i = i/5;
i %= 5; // i = i%5;
递增和递减运算符
另一种常见的操作是将变量加 1 或减 1。这可以通过递增(++
)和递减()运算符来简化。
++i; // i = i+1
−−i; // i = i-1
这两者都可以用在变量之前或之后:
++i; // pre-increment
−−i; // pre-decrement
i++; // post-increment
i−−; // post-decrement
无论使用哪个变量,变量的结果都是相同的。不同的是,后运算符在改变变量之前返回原始值,而前运算符先改变变量,然后返回值。
int x, y;
x = 5; y = x++; // y=5, x=6
x = 5; y = ++x; // y=6, x=6
比较运算符
比较运算符比较两个值,并返回 true 或 false。它们主要用于指定条件,即评估为真或假的表达式。
boolean b = (2==3); // equal to (false)
b = (2!=3); // not equal to (true)
b = (2>3); // greater than (false)
b = (2<3); // less than (true)
b = (2>=3); // greater than or equal to (false)
b = (2<=3); // less than or equal to (true)
逻辑运算符
逻辑运算符通常与比较运算符一起使用。如果左侧和右侧都为真,则逻辑和 ( &&
)计算为真,如果左侧或右侧为真,则逻辑或 ( ||
)为真。为了反转布尔结果,有一个逻辑非 ( !
)运算符。请注意,对于逻辑和以及逻辑或,如果结果已经由左侧确定,则不会对右侧进行评估。
boolean b = (true && false); // logical and (false)
b = (true || false); // logical or (true)
b = !(true); // logical not (false)
按位运算符
按位运算符可以操作整数类型中的单个位。例如,右移位运算符(>>
)将除符号位之外的所有位向右移动,而零填充右移位(>>>
)将包括符号位在内的所有位向右移动。
byte b = 5 & 4; // 101 & 100 = 100 (4) // and
b = 5 | 4; // 101 | 100 = 101 (5) // or
b = 5 ^ 4; // 101 ^ 100 = 001 (1) // xor
b = 4 << 1; // 100 << 1 = 1000 (8) // left shift
b = 4 >> 1; // 100 >> 1 = 10 (2) // right shift
b = 4 >>>1; // 100 >>>1 = 10 (2) // zero-fill right shift
b = ~4; // ~00000100 = 11111011 (-5) // invert
这些位运算符有速记赋值运算符,就像算术运算符一样:
int i = 5;
i &= 4; // "and" and assign
i |= 4; // or and assign
i ^= 4; // xor and assign
i <<= 1; // left shift and assign
i >>= 1; // right shift and assign
i >>>= 1; // right shift and assign (move sign bit)
运算符优先级
在 Java 中,表达式通常从左到右计算。但是,当表达式包含多个运算符时,这些运算符的优先级决定了它们的求值顺序。下表显示了优先顺序。同样的顺序也适用于许多其他语言,如 C++和 C#。
|优先
|
操作员
|
优先
|
操作员
|
| --- | --- | --- | --- |
| one | ++ −− !~ | seven | & |
| Two | * / % | eight | ^ |
| three | + − | nine | | |
| four | << >> > > > | Ten | && |
| five | < <= > >= | Eleven | || |
| six | == != | Twelve | = 操作员 = |
例如,逻辑和 ( &&
)的绑定弱于关系运算符,而关系运算符又弱于算术运算符:
boolean b = 2+3 > 1*4 && 5/5 == 1; // true
为了避免学习所有运算符的先例,并阐明意图,可以使用括号来指定首先计算表达式的哪一部分:
boolean b = ( (2+3) > (1*4) ) && ( (5/5) == 1 ); // true
五、字符串
Java 中的String
类是一种可以保存字符串文字的数据类型。 String 是一种引用数据类型,所有非原始数据类型也是如此。这意味着变量包含内存中对象的地址,而不是对象本身。在内存中创建一个String
对象,并将该对象的地址返回给变量。如以下代码所示,字符串由双引号分隔:
String a = "Hello";
字符串存储在所谓的字符串池中,由 String 类维护。出于性能原因,任何等于先前创建的字符串的字符串文字都将引用池中的同一字符串对象。这是可行的,因为 Java 中的字符串是不可变的,因此如果不创建一个新的 String 对象就不能更改。
String a1 = "Hello";
String a2 = "Hello"; // refers to same object as a1
组合字符串
加号用于组合两个字符串。在这个上下文中称为串联操作符(+
),它有一个伴随的赋值操作符(+=
),将一个字符串追加到另一个字符串并创建一个新字符串。
String a = "Hello";
String b = " World";
String c = a+b; // "Hello World"
a += b; // "Hello World"
请注意,尽管一条语句可以分成多行,但一个字符串必须在一行中,除非使用串联运算符将其拆分:
String x
= "Hello " +
"World";
转义字符
对于向字符串本身添加新行,有一个转义字符(\n
)。这种反斜杠符号用于书写特殊字符,如反斜杠和双引号。在特殊字符中还有一个 Unicode 字符符号,用于书写任何字符。下表列出了所有的转义字符。
性格;角色;字母
|
意义
|
| --- | --- |
| \n
| 新行 |
| \t
| 横表 |
| \b
| 退格 |
| \r
| 回车 |
| \uFFFF
| Unicode 字符(四位十六进制数字) |
| \f
| 换页 |
| \’
| 单引号 |
| \”
| 双引号 |
| \\
| 反斜线符号 |
字符串比较
比较两个字符串的方法是使用String
类的equals
方法。如果使用相等运算符(==
),将会比较内存地址。
boolean x = a.equals(b); // compares string
boolean y = (a == b); // compares address
请记住,Java 中的所有字符串都是String
对象。因此,可以直接在常量字符串上调用方法,就像在变量上一样。
boolean z = "Hello".equals(a); // true
StringBuffer 类
String
类有大量可用的方法,但是它不包含任何操作字符串的方法。那是因为 Java 中的字符串是不可变的。一旦创建了一个String
对象,其内容就不能改变,除非整个字符串被完全替换。因为大多数字符串从未被修改过,所以这样做是为了让String
类更有效。对于需要可修改字符串的情况,可以使用StringBuffer
类,这是一个可变的字符串对象。
StringBuffer sb = new StringBuffer("Hello");
这个类有几个操作字符串的方法,包括append
、delete
和insert
:
sb.append(" World"); // add to end of string
sb.delete(0, 5); // remove 5 first characters
sb.insert(0, "Hello"); // insert string at beginning
您可以使用toString
方法将StringBuffer
对象转换回常规字符串,该方法返回对象的字符串表示。它存在于 Java 的每个类中,因为它是由所有类继承的Object
定义的。
String s = sb.toString();
文本块
文本块是由三个双引号("""
)分隔的多行字符串。它提供了一种简化的方法来编写跨越多行的字符串,而不必指定转义字符,如换行符或引号。
String textBlock = """
line 1
line 2""";
开始分隔符后的换行符是强制的,因此文本块必须跨越多行。文本块中使用的任何换行符都将被自动解释为换行符,因此前面的文本块相当于以下字符串:
String s = "line 1\nline 2";
相对于其他行缩进一行文本的空白将被保留。但是,任何用于缩进所有行的初始空格都将被删除。
String html = """
<div>
<p>Hi</p>
</div>""";
因此,该文本块与以下字符串相同:
String html = "<div>\n <p>Hi</p>\n</div>";
文本块作为预览特性在 Java 13 中引入,并在 Java 15 中成为标准特性。
六、数组
一个数组是一个固定大小的数据结构,用于存储一组单一类型的值。
数组声明
要声明一个数组,需要将一组方括号附加到数组将包含的数据类型上,后跟数组的名称。数组可以用任何数据类型声明,并且它的所有元素都必须是该类型。
int[] x;
或者,括号可以放在数组名称之后。但是,不鼓励这种形式。因为括号影响类型,所以它们应该出现在类型旁边。
int y[]; // discouraged form
数组分配
数组被分配了new
关键字,然后是数据类型和一组包含数组的长度的方括号——数组可以包含的固定数量的元素。一旦创建了数组,元素将自动分配给该数据类型的默认值,在 int 数组的情况下是零(0
)。
int[] y = new int[3]; // allocate 3 elements with value 0
数组赋值
要填充数组,可以通过将元素的数字索引放在方括号内,然后给它们赋值,一次引用一个元素。请注意,索引从零开始。
y[0] = 1;
y[1] = 2;
y[2] = 3;
或者,可以使用花括号符号一次性赋值。如果同时声明数组,可以选择省略new
关键字、数据类型和方括号。
int[] x = new int[] {1,2,3};
int[] x = {1,2,3};
初始化数组元素后,可以通过引用方括号内的元素索引来访问它们:
System.out.print(x[0] + x[1] + x[2]); // "6"
多维数组
多维数组的声明、创建和初始化类似于一维数组,只是它们有额外的方括号。它们可以有任意数量的维度,并且为每个维度添加另一组方括号。
String[][] x = {{"00","01"},{"10","11"}};
String[][] y = new String[2][2];
y[0][0] = "00";
y[0][1] = "01";
y[1][0] = "10";
y[1][1] = "11";
System.out.print(x[0][0] + x[1][1]); // "0011"
数组列表类
关于数组,需要记住的重要一点是,它们的长度是固定的,没有办法改变它们的大小。数组的大小可以通过数组的length
成员来获取。
Int[] x = new int[3];
int size = x.length; // 3
对于需要可调整大小的数组的情况,可以使用通用的ArrayList<T>
类,它位于 java.util 包中。该列表将保存的数据类型在尖括号(<>
)中指定。泛型类将在后面的章节中详细讨论。
import java.util.ArrayList;
// ...
// Create an ArrayList collection for strings
java.util.ArrayList<String> a = new java.util.ArrayList<>();
ArrayList
类有几个有用的方法来改变列表,比如add
、set
和remove
:
a.add("Hi"); // add an element
a.set(0, "Hello"); // change first element
a.remove(0); // remove first element
要从ArrayList<T>
中检索一个元素,可以使用get
方法。然后,必须将元素显式转换回其原始类型,因为它在内部存储为对象类型,可以保存任何引用数据类型。
a.add("Hello World");
String s = (String)a.get(0); // Hello World
七、条件语句
条件语句用于根据不同的条件执行不同的代码块。
如果语句
只有当括号内的条件被评估为真时,if
语句才会执行。条件可以包括任何比较和逻辑运算符。
int x = 1;
// ...
if (x == 1) {
System.out.println(x + " = 1");
}
为了测试其他条件,if
语句可以被任意数量的else-if
子句扩展。只有当所有先前的条件都为假时,才会测试每个附加条件。
else if (x > 1) {
System.out.println(x + " > 1");
}
对于处理所有其他情况,可以在末尾有一个else
子句,如果前面的所有条件都为假,则执行该子句:
else {
System.out.println(x + " < 1");
}
如果只需要有条件地执行一条语句,可以省去花括号。但是,包含它们被认为是一种好的做法,因为它们可以提高代码的可读性。
if (x == 1)
System.out.println(x + " = 1");
else if (x > 1)
System.out.println(x + " > 1");
else
System.out.println(x + " < 1");
交换语句
switch
语句检查一个值和一系列事例标签之间的相等性。然后,它执行匹配的案例。该语句可以包含任意数量的事例,并且可以以处理所有其他事例的默认标签结束。
switch (x)
{
case 0: System.out.println(x + " is 0"); break;
case 1: System.out.println(x + " is 1"); break;
default: System.out.println(x + " is something else");
}
注意,每个case
标签后面的语句没有用花括号括起来。相反,语句以关键字break
结束。没有了break
,死刑将会落到下一个案子。如果需要以相同的方式评估几个案例,这可能会很有用。
任何整数数据类型都可以与switch
语句一起使用,包括byte
、short
、int
和char
。从 Java 7 开始,String
类型也是允许的。
String fruit = "apple";
switch (fruit)
{
case "apple": System.out.println("apple"); break;
default: System.out.println("not an apple");
}
开关表达式
这个开关在 Java 12 中扩展了新的预览特性,成为 Java 14 中的标准特性。考虑以下用于模拟表达式(计算值的代码)的开关:
String result;
switch (x)
{
case 1: result = "one"; break;
case 2:
case 3: result = "two or three"; break;
default: result = "many";
}
使用箭头标签(->
)代替传统的 case 标签可以使代码更加简洁。使用这种形式时,箭头标签后只能出现一个表达式或语句,并且每个 case 可以包含多个常量,用逗号分隔。箭头标签不允许穿透,因此不使用 break 关键字。
String result;
switch (x)
{
case 1 -> result = "one";
case 2, 3 -> result = "two or three";
default -> result = "many";
}
这个 switch 语句可以进一步简化为一个 switch 表达式。在这种形式下,开关将计算匹配事例后面的表达式。请记住,默认标签随后会变成强制标签,这样所有可能的输入值都会产生一个有效的表达式。
String result = switch (x)
{
case 1 -> "one";
case 2, 3 -> "two or three";
default -> "many";
};
如果需要多个表达式,可以包含一个完整的代码块。在这样的块中,yield 语句用于指定 switch 表达式将计算的值。
String result = switch (x)
{
case 1 -> "one";
case 2, 3 -> "two or three";
default -> {
if (x == 4) yield "four";
else yield "many";
}
};
三元运算符
三元运算符(?:
)可以用要返回的值替换单个if-else
子句。运算符有三个表达式。如果第一个表达式的计算结果为真,则返回第二个表达式,如果为假,则计算并返回第三个表达式。它是 Java 中唯一接受三个操作数的运算符。
x = (x < 0.5) ? 0 : 1; // ternary operator (?:)
这个三元语句相当于下面的 if-else 子句:
if (x < 0.5) { x = 0; }
else { x = 1; }
八、循环
Java 中有四种循环结构。它们用于多次执行一个特定的代码块。与条件语句if
一样,如果代码块中只有一条语句,循环的花括号可以省去。
当循环
只有当指定的条件为真时,while
循环才会在代码块中运行,并且只要条件保持为真,循环就会继续。下面的循环将打印出数字 0 到 4:
int i = 0;
while (i < 5) {
System.out.print(i++); // "01234"
}
注意,循环的条件必须评估为一个boolean
值。仅在每次迭代(循环)开始时检查该条件。
Do While 循环
除了检查代码块之后的条件之外,do while
循环的工作方式与while
循环相同。因此,它将始终至少在代码块中运行一次。
int i = 0;
do {
System.out.print(i++);
} while (i < 5); // "01234"
For 循环
for
循环用于遍历一个代码块特定的次数。它使用三个参数。第一个参数初始化一个计数器,并且总是在循环之前执行一次。此计数器变量的范围仅限于 for 循环,并且在循环后不可访问。第二个参数保存循环的条件,并在每次迭代之前进行检查。最后,第三个参数包含计数器的增量,在每次迭代结束时执行。
for (int i = 0; i < 5; i++) {
System.out.print(i); // "01234"
}
for
回路可能有几种变化。例如,可以使用逗号运算符将第一个和第三个参数分成几个语句。
for (int k = 0, m = 0; k < 5; k++, m--) {
System.out.print(k + m); // "00000"
}
您也可以选择省略一个或多个参数。例如,第三个参数可以移到循环体中。
for (int k = 0, m = 0; k < 5;) {
System.out.print(k + m); // "00000"
k++; m--;
}
对于每个循环
“for
each”循环提供了一种简单的方法来遍历数组。在每次迭代中,数组中的下一个元素被赋给指定的变量,循环继续执行,直到遍历完整个数组。
int[] array = { 1,2,3 };
for (int element : array) {
System.out.print(element); // "123"
}
中断并继续
有两个特殊的关键字可以在循环中使用:break
和continue
。break
关键字结束循环结构,continue
跳过当前迭代的剩余部分,并在下一次迭代的开始处继续。
for (int i = 0; i < 10; i++)
{
if (i == 5) break; // end loop
if (i == 3) continue; // start next iteration
System.out.print(i); // "0124"
}
要中断当前循环之上的循环,必须首先通过在该循环前添加一个后跟冒号的名称来标记该循环。有了这个标签,现在可以将它用作break
语句的参数,告诉它从哪个循环中退出。这也适用于continue
关键字 d,以便跳到指定循环的下一次迭代。
myLoop: for (int i = 0; i < 10; i++)
{
for (int j = 0; j < 10; j++)
{
break myLoop; // end outer for loop
}
}
标记块
一个标记为的块,也称为一个名为的块,通过在一个匿名代码块前放置一个标签来创建。关键字break
可以用来脱离这样的块,就像在带标签的循环中一样。这可能是有用的,例如,当执行验证时,如果一个验证步骤失败,整个过程必须中止。
validation:
{
if(true)
break validation;
}
标记块对于将大型方法组织成几个部分很有用。在大多数情况下,将方法拆分是一个更好的主意。但是,如果新方法需要很多参数,或者如果该方法仅在单个位置使用,则一个或多个标记块可能是优选的。
九、方法
方法是可重用的代码块,只在被调用时执行。
定义方法
您可以通过键入返回类型,后跟方法名、一组括号和代码块来创建方法。关键字void
可以用来指定该方法不返回值。方法的命名约定与变量的相同——一个描述性的名称,第一个单词小写,后面所有单词的第一个字母大写。
class MyApp
{
void myPrint()
{
System.out.println("Hello");
}
}
调用方法
前面的方法将简单地打印出一条文本消息。为了让从 main 方法中调用(调用),必须首先创建一个MyApp
类的实例。然后在实例名后面使用点操作符,以访问其成员,包括myPrint
方法。
public static void main(String[] args)
{
MyApp m = new MyApp();
m.myPrint(); // "Hello"
}
方法参数
方法名后面的括号用于向方法传递参数。为此,必须首先将相应的参数以逗号分隔列表的形式添加到方法声明中。
void myPrint(String s)
{
System.out.println(s);
}
一个方法可以被定义为接受任意数量的参数,并且它们可以有任意的数据类型。只要确保以正确的顺序使用相同类型和数量的参数调用该方法。
public static void main(String[] args)
{
MyApp m = new MyApp();
m.myPrint("Hello"); // "Hello"
}
准确地说,参数出现在方法定义中,而参数出现在方法调用中。然而,这两个术语有时会被错误地互换使用。
返回语句
方法可以返回值。然后用该方法将返回的数据类型替换void
关键字,并且用指定返回类型的参数将return
关键字添加到方法体中。
public class MyApp
{
String getString()
{
return "Hello";
}
}
Return
是一个跳转语句,它使方法退出,并将指定的值返回到调用该方法的地方。例如,前面的方法可以作为参数传递给println
方法,因为该方法的计算结果是一个字符串。
public static void main(String[] args)
{
MyApp m = new MyApp();
System.out.println( m.getString() ); // "Hello"
}
return
语句也可以在void
方法中使用,以便在到达结束块之前退出。在此上下文中使用时,不指定返回值。
void myPrint(String s)
{
if (s == "") { return; } // skip if string is empty
System.out.println(s);
}
方法重载
只要参数的类型或数量不同,就可以用相同的名称声明多个方法。称为方法重载,例如,这可以在System.out.println
方法的实现中看到。这是一个强大的特性,允许一个方法处理各种参数,而程序员不需要知道使用不同的方法。
void myPrint(String s)
{
System.out.println(s);
}
void myPrint(int i)
{
System.out.println(i);
}
传递参数
Java 与许多其他语言的不同之处在于,所有方法参数都是通过值传递的。事实上,它们不能通过引用来传递。对于值数据类型(基本类型),这意味着在方法中只有变量的本地副本被更改,所以更改不会影响原始变量。对于引用数据类型(类、接口和数组),这意味着只将内存地址的副本传递给方法。因此,如果整个对象被替换,更改不会传播回调用者,但是对对象的更改将影响原始对象,因为副本指向相同的内存位置。
public class MyApp
{
public static void main(String[] args)
{
MyApp m = new MyApp();
int x = 0; // value data type
m.set(x); // value is passed
System.out.println(x); // "0"
int[] y = {0}; // reference data type
m.set(y); // address is passed
System.out.println(y[0]); // "10"
}
void set(int a) { a = 10; }
void set(int[] a) { a[0] = 10; }
}
十、类
一个类是一个用来创建对象的模板。类由成员组成,其中主要的两个是字段和方法。字段是保存对象状态的变量,而方法定义了对象能做什么——所谓的对象行为。
class MyRectangle
{
int x, y;
int getArea() { return x * y; }
}
对象创建
要从定义类的外部访问(非静态)字段或方法,必须首先创建该类的对象。这是使用new
关键字完成的,它将在系统内存中创建一个新对象。
public class MyApp
{
public static void main(String[] args)
{
// Create an object of MyRectangle
MyRectangle r = new MyRectangle();
}
}
一个对象也被称为一个实例。该对象将包含自己的一组实例变量(非静态字段),这些变量可以保存与该类的其他实例不同的值。
访问对象成员
除了创建对象之外,需要在类定义中将超出其包可访问的类成员声明为public
。
class MyRectangle
{
public int x, y;
public int getArea() { return x * y; }
}
现在可以通过在实例名称后使用点运算符来访问该对象的成员:
public class MyApp
{
public static void main(String[] args)
{
MyRectangle r = new MyRectangle();
r.x = 10;
r.y = 5;
int area = r.getArea(); // 50 (5*10)
}
}
构造器
一个类可以有一个构造器,一种用于实例化(构造)对象的特殊方法。它总是与该类同名,并且没有返回类型,因为它隐式返回该类的一个新实例。为了能被不在其包中的另一个类访问,需要用public
访问修饰符来声明它。当使用new
语法创建了一个MyRectangle
类的新实例时,将调用构造器方法,在下面的示例中,该方法将字段设置为指定的默认值。
class MyRectangle
{
int x, y;
public MyRectangle() { x = 10; y = 20; }
}
像任何其他方法一样,构造器可以有一个参数列表。如下面的代码所示,这可用于使字段的初始值取决于创建对象时传递的参数。
class MyRectangle
{
int x, y;
public MyRectangle(int a, int b) { x = a; y = b; }
}
public class MyApp
{
public static void main(String[] args)
{
MyRectangle r = new MyRectangle(20, 15);
}
}
这个关键字
在构造器内部,以及在属于对象的其他方法中,可以使用一个名为this
的特殊关键字。this
关键字是对该类的当前实例的引用。例如,如果构造器的参数与相应的实例变量同名,那么实例变量仍然可以通过使用this
关键字来访问,即使它们被参数所掩盖。
class MyRectangle
{
int x, y;
public MyRectangle(int x, int y)
{
this.x = x;
this.y = y;
}
}
构造器重载
为了支持不同的参数列表,可以重载构造器。在下面的示例中,如果类在没有任何参数的情况下被实例化,则这些字段将被赋予指定的默认值。对于一个参数,两个字段都将被设置为所提供的值,而对于两个参数,每个字段都将被分配一个单独的值。
class MyRectangle
{
int x, y;
public MyRectangle() { x = 10; y = 20; }
public MyRectangle(int a) { x = a; y = a; }
public MyRectangle(int a, int b) { x = a; y = b; }
}
试图用错误的参数数量或错误的数据类型创建对象将导致编译时错误,就像任何其他方法一样。
构造器链接
还可以使用this
关键字从一个构造器调用另一个构造器。被称为构造器链接,这允许更大的代码重用。请注意,关键字以方法调用的形式出现,并且必须位于构造器的第一行。
public MyRectangle() { this(10, 20); }
public MyRectangle(int a) { this(a, a); }
public MyRectangle(int a, int b) { x = a; y = b; }
初始字段值
如果类中有需要分配默认值的字段,比如在刚刚显示的第一个构造器中,可以在声明字段的同时简单地分配它们。这些初始值将在调用构造器之前赋值。
class MyRectangle
{
int x = 10, y = 20;
}
默认构造器
即使没有定义构造器,也可以创建一个类。这是因为编译器会自动创建一个默认的无参数构造器。
public class MyApp
{
public static void main(String[] args)
{
// Default constructor used
MyApp a = new MyApp();
}
}
如果定义了任何自定义构造器,编译器将不会添加默认的无参数构造器。
空
内置常量null
用于表示未初始化的对象。它只能赋给对象,不能赋给基元类型的变量。等号运算符(==
)可以用来测试一个对象是否为空。
String s = null;
// ...
if (s == null) s = new String();
默认值
对象的默认值是null
。对于原始数据类型,默认值如下:整数类型变为0
,浮点类型变为 0.0,char 具有表示零的 Unicode 字符(\0000
),Boolean 为false
。缺省值将由编译器自动分配,但只适用于字段,不适用于局部变量。但是,显式指定字段的默认值被认为是好的编程方式,因为这使得代码更容易理解。对于局部变量,默认值不是由编译器设置的。取而代之的是,编译器强迫程序员给所使用的任何局部变量赋值,以避免与错误地使用未赋值变量相关的问题。
public class MyApp
{
int x; // field is assigned default value 0
int dummy() {
int x; // local variable must be assigned if used
}
}
垃圾收集工
Java 运行时环境有一个垃圾收集器,当不再需要对象时,它会定期释放对象使用的内存。这将程序员从繁琐且容易出错的内存管理任务中解放出来。当一个对象不再被引用时,它就有资格被销毁。例如,当对象超出范围时,就会出现这种情况。也可以通过将对象的引用设置为null
来显式删除对象。
public class MyApp
{
public static void main(String[] args)
{
MyApp a = new MyApp();
// Make object available for garbage collection
a = null;
}
}
十一、静态
关键字static
用于创建无需创建类实例就可以访问的字段和方法。静态(类)成员只存在于一个副本中,该副本属于类本身,而实例(非静态)成员是作为每个新对象的新副本创建的。这意味着静态方法不能使用实例成员,因为这些方法不是实例的一部分。另一方面,实例方法可以使用静态成员和实例成员。
class MyCircle
{
float r = 10; // instance field
static float pi = 3.14F; // static/class field
// Instance method
float getArea() { return newArea(r); }
// Static/class method
static float newArea(float a) { return pi*a*a; }
}
访问静态成员
要从类外部访问静态成员,先使用类名,然后使用点运算符。该操作符与用于访问实例成员的操作符相同,但是要访问它们,需要一个对象引用。试图通过使用对象引用(而不是类名)来访问静态成员将导致警告,因为这使得更难看到静态成员正在被使用。
public static void main(String[] args)
{
float f = MyCircle.pi;
MyCircle c = new MyCircle();
float g = c.r;
}
静态方法
静态成员的优点是它们可以被其他类使用,而不必创建该类的实例。因此,当只需要变量的一个实例时,应该将字段声明为静态的。如果方法执行独立于任何实例变量的通用函数,那么它们应该被声明为静态的。一个很好的例子是只包含静态方法和字段的Math
类。
double pi = Math.PI;
Math
是每个 Java 应用程序默认包含的类之一,因为它属于 java.lang 包,该包总是被导入。这个包包含 Java 语言的基础类,比如String
、Object
和System
。
静态字段
静态字段具有在应用程序的整个生命周期中保持不变的优势。这意味着它们可以用来记录一个方法在类的所有实例中被调用的次数。静态字段的初始值只设置一次,有时在使用类或字段之前。
class MyCircle
{
static void foo() { count++; }
static int count = 0;
}
静态初始化块
如果静态字段的初始化需要不止一行或一些其他逻辑,则可以使用静态 初始化块。与构造器不同,这个块只运行一次,与静态字段同时初始化。
class MyClass
{
static int[] array = new int[5];
// Static initialization block
static
{
int i = 0;
for(int element : array)
element = i++;
}
}
实例初始化块
一个初始化块提供了另一种分配实例字段的方法。这个块放在类级别,就像静态初始化块一样,但是没有使用static
关键字。任何放在括号中的代码都会被编译器复制到每个构造器的开头。
class MyClass
{
int[] array = new int[5];
// Initialization block
{
int i = 0;
for(int element : array) element = i++;
}
}
一个类可以有多个实例初始化块和静态初始化块。
十二、继承
继承允许一个类获得另一个类的成员。在下面的例子中,Apple
从Fruit
继承而来。这是用extends
关键字指定的。Fruit
然后成为苹果的超类,苹果又成为Fruit
的子类。除了自己的成员,Apple
还获得了Fruit
中所有可访问的成员,除了任何构造器。
// Superclass (parent class)
class Fruit
{
public String flavor;
}
// Subclass (child class)
class Apple extends Fruit
{
public String variety;
}
Java 中的一个类只能从一个超类继承,如果没有指定类,它将隐式地从Object
继承。因此,Object
是所有类的根类。
// Same as class MyClass {}
class MyClass extends Object {}
向上抛
从概念上讲,子类是超类的特化。这意味着Apple
是一种Fruit
,也是一种Object
,因此可以用在任何需要Fruit
或Object
的地方。例如,如果创建了一个Apple
的实例,它可以被向上转换为到Fruit
,因为子类包含了超类中的所有内容。
Apple a = new Apple();
Fruit f = a;
通过这个变量,Apple
被视为一个Fruit
,因此只有Fruit
成员可以被访问:
f.flavor = "Sweet";
向下铸造
当类被向下转换回到Apple
时,特定于Apple
的字段将被保留。那是因为Fruit
只包含了Apple
——它没有将 ?? 转化为苹果。向下转换必须使用 Java 转换格式显式进行,因为不允许将实际的Fruit
对象向下转换为Apple
。
Apple b = (Apple)f;
运算符的实例
作为一项安全预防措施,您可以在运行时进行测试,看看是否可以通过使用instanceof
操作符将一个对象转换为一个特定的类。如果左侧对象可以被转换为右侧类型而不会导致异常,则该操作符返回true
。
if (f instanceof Apple)
{
Apple myApple = (Apple)f;
// use myApple here
}
像这样使用 instanceof 操作符是很常见的,其中条件检查之后是类型转换。因此,我们添加了一个更简洁的语法,将指定的变量包含在条件中。变量的范围仅限于条件块。
if (f instanceof Apple myApple)
{
// use myApple here
}
这是 instanceof 操作符的模式匹配特性的一部分,它成为 Java 14 中的预览特性,然后成为 Java 16 中的标准特性。该操作符被扩展为不仅接受类型,还允许在单个表达式中提取和测试类型。
class Speed
{
public int velocity = 10;
}
public class MyApp
{
public static void main(String[] args) {
Object o = new Speed();
// ...
if ( (o instanceof Speed s) && (s.velocity > 5) ) {
System.out.println("Speed is " + s.velocity);
}
}
模式匹配开关
Java 17 增加了 switch 语句和表达式的模式匹配作为预览特性。这扩展了 switch,使其可以处理任何类型模式,而不像以前那样只处理数字、字符串和枚举类型。将事例标签与模式一起使用时,选择由模式匹配而不是相等检查来确定。在下面的代码中,object 变量的值与长模式匹配,并且将执行与该案例相关联的代码。
Object o = 5L; // L suffix means Long type
String myType = switch(o)
{
case null -> "null";
case Integer i -> "integer is " + i;
case Long l -> "long is " + l;
default -> o.toString();
}
System.out.println(myType) // "long is 5"
限制继承
可以将类声明为 final,以防止任何类继承它:
// Cannot be inherited
final class Fruit {}
一种限制较少的方法是使用 sealed 修饰符只允许某些类继承。这些类是在任何 extends 子句右侧的逗号分隔的许可证子句中指定的。
// Can be inherited only by Apple or Orange
sealed class Fruit permits Apple, Orange {}
从密封类继承的允许类必须依次声明为非密封的、密封的或最终的。非密封类可以被任何类继承,而最终类不允许再有子类。
// Can be inherited by any class
non-sealed class Lemon extends Fruit{}
// Can be inherited only by RedDelicious class
sealed class Apple extends Fruit permits RedDelicious{}
// Cannot be inherited
final class Orange extends Fruit {}
Java 15 中增加了密封类作为预览特性。sealed 和 final 修饰符也可以应用于接口和抽象类。
十三、覆盖
子类中的成员可以重定义超类中的成员。这通常是为了给实例方法新的实现。
覆盖方法
在下面的例子中,Rectangle
的getArea
方法在Triangle
中被覆盖,方法是用相同的方法签名重新声明它。签名包括方法的名称、参数和返回类型。但是,可以更改访问级别,以允许比被覆盖的方法更多的访问。
class Rectangle
{
public int w = 10, h = 10;
public int getArea() { return w * h; }
}
class Triangle extends Rectangle
{
public int getArea() { return w * h / 2; }
}
覆盖注释
为了表明这种覆盖是有意的,@Override
注释应该放在方法之前。这个注释是在 Java 5 中添加的,以防止意外覆盖并提高可读性。如果带注释的方法实际上没有覆盖任何东西,编译器也会给出警告,如果签名与父类中的方法不匹配,就会发生这种情况。
class Triangle extends Rectangle
{
@Override
public int getArea() {
return w * h / 2;
}
}
从Triangle
实例调用getArea
方法将调用Triangle
的方法版本:
Triangle o = new Triangle();
o.getArea(); // (50) calls Triangle's version
如果Triangle
的实例被向上转换为Rectangle
,那么Triangle
的方法版本仍然会被调用,因为Rectangle
的版本已经被覆盖:
Rectangle o = new Triangle();
o.getArea(); // (50) calls Triangle's version
隐藏方法
这只适用于实例方法,不适用于类(静态)方法。如果一个名为newArea
的类方法被添加到Rectangle
并在Triangle
中重新定义,那么Triangle
的方法版本将只隐藏Rectangle
的实现。因此,没有使用@Override
注释。
class Rectangle
{
public int w = 10, h = 10;
public static int newArea(int a, int b) {
return a * b;
}
}
class Triangle extends Rectangle
{
public static int newArea(int a, int b) {
return a * b / 2;
}
}
从Triangle
的类中调用newArea
将会调用Triangle
的版本,但是从Rectangle
的类中调用方法将会调用Rectangle
的实现:
Triangle o = new Triangle();
Triangle.newArea(10,10); // (50) calls Triangle's version
Rectangle r = o;
Rectangle.newArea(10,10); // (100) calls Rectangle's version
重定义的实例方法在 Java 中总是被覆盖,重定义的类方法总是被隐藏。没有办法改变这种行为,例如,在 C++或 C#中可以做到。
隐藏字段
在 Java 中不能覆盖字段,但是可以通过声明一个与继承字段同名的字段来隐藏它们。字段的类型及其访问级别可以不同于继承字段。通常不建议隐藏字段,因为这会使代码更难阅读。
class Rectangle
{
public int w = 10, h = 10;
}
class Triangle extends Rectangle
{
public int w = 5, h = 5; // hide inherited fields
}
public class MyApp
{
public static void main(String args[]) {
Triangle t = new Triangle();
Rectangle r = t;
System.out.println(t.w); // "5"
System.out.println(r.w); // "10"
}
}
访问重新定义的成员
被覆盖的方法(或隐藏的实例字段)仍然可以使用super
关键字从子类内部访问。这个关键字是对超类的当前实例的引用。
class Triangle extends Rectangle
{
@Override
public int getArea() {
return super.getArea() / 2;
}
}
调用父构造器
另一个可以使用super
关键字的地方是在构造器的第一行。在那里,它可以执行一个调用超类的构造器的方法调用。
public Triangle(int a, int b) { super(a,b); }
如果一个构造器的第一行不是对另一个构造器的调用,Java 编译器会自动添加对超类的无参数构造器的调用。这确保了所有的祖先类都被正确构造。
public Triangle() { super(); }
十四、包和导入
包用于避免命名冲突,并将代码文件组织到不同的目录中。到目前为止,在本书中,代码文件位于项目源目录的根目录下,因此属于所谓的默认包。在 Java 中,文件所属的目录(相对于项目的源目录)对应于包名。
要将代码文件分配给包(例如 mypackage ),必须将其移动到项目目录下以该名称命名的文件夹中。此外,文件必须使用关键字package
后跟包名(和路径)来指定它属于哪个包。每个源文件中只能有一个 package 语句,而且必须是第一行代码,任何注释除外。请注意,包的命名约定都是小写的。
// This file belongs to mypackage
package mypackage;
包可以是任意深度的目录层,层次结构中的层由点分隔。例如,如果包含代码文件的 mypackage 文件夹被放在一个名为 sub 的项目文件夹中,那么包声明应该如下所示。
package sub.mypackage;
访问包
为了演示如何访问包成员,在项目源目录下的 sub\mypackage 文件夹中放置了一个名为 MyClass.java 的文件。该文件包含一个名为MyClass
的公共类。
package sub.mypackage;
public class MyClass {}
MyClass
可以通过两种方式之一从另一个源文件访问。第一种方法是键入完全限定名。
// Fully qualified class name
sub.mypackage.MyClass m;
第二种选择是通过包含带有import
关键字的类来缩短完全限定名。在代码文件中,import
语句必须位于包声明语句之后,所有其他成员之前。除了让程序员不必键入完全限定名之外,它没有别的用途。
import mypackage.sub.MyClass;
// ...
MyClass m;
除了导入特定的类之外,包内的所有类型(类或接口)都可以通过使用星号(*
)来导入。注意,这并没有导入任何子包。
import java.util.*;
import
语句的第三种变化是静态导入,它导入一个类的所有静态成员。一旦静态成员被导入,就可以使用它们,而不必指定类名。
import static java.lang.Math.*;
// ...
double pi = PI; // Math.PI
十五、模块
**模块是一组可重用的相关包和资源文件以及模块描述符文件。它们应该是自给自足的,并且只公开接口来使用模块的功能。
*## 创建模块
NetBeans 有一个特殊的项目类型来管理多个模块。要创建这样一个项目,转到文件➤新项目,并从那里,选择 Java 与 Ant 类别下的 Java 模块化项目。单击 Next,将项目命名为 MyModules,然后单击 Finish 创建项目。继续,通过在“项目”窗口中右键单击 MyModules 项并选择“新建➤模块”,将名为“firstmodule”的模块添加到该项目中。
在“项目”窗口中可以看到,一个模块有一个名为 module-info.java 的特殊文件。这个模块描述符文件必须位于将要编译成模块的包的根文件夹中。文件中有一个模块描述符,它由模块关键字、模块名和一组花括号组成。
module firstmodule {
}
接下来,让我们创建一个包,其中包含一个要包含在该模块中的类。右键单击项目窗口中的“firstmodule”项,并选择“新建➤ Java 类”。将其命名为 util。MyClass 自动将它放在一个名为 util 的新包中。该包是强制性的,因为在模块中不允许将除模块描述符之外的文件放在默认包(顶级目录)中。在新的源文件中键入以下代码示例:
// util.MyClass.java
package util;
public class MyClass {
public static void sayHi() {
System.out.println("Hello Module");
}
}
返回到模块描述符文件,使用关键字exports
加上完全限定的包名(firstmodule.util)为 util 包添加一个导出语句。这将使该包对使用该模块的任何其他模块可见。任何其他没有被显式导出的包,包括子包,从模块外部都是不可访问的。
module firstmodule {
exports firstmodule.util; // make package visible
}
使用模块
我们现在将创建第二个模块来利用第一个模块。向项目中添加一个名为 secondmodule 的新模块。在其模块描述符文件中,导入 firstmodule,使其导出的包在这个新模块中可见。
module secondmodule {
requires firstmodule; // import module
}
添加一个名为 app 的类。MyApp 到模块,所以类文件被放在一个名为 App 的包中。在该文件中包含以下代码,它利用了第一个模块中公开的 util 包:
// app.MyApp.java
package app;
public class MyApp {
public static void main(String[] args) {
util.MyClass.sayHi(); // "Hello Module"
}
}
这是让第二个模块使用第一个模块公开的功能所需的全部代码。编译并运行项目,让 main 方法从导入的模块中调用函数,这将显示“Hello Module”文本字符串。*
十六、访问级别
Java 中有四个可用的访问级别:public
、protected
、private
和包私有。Package-private 没有使用关键字显式声明。相反,它是 Java 中每个成员的默认访问级别。
public int myPublic; // unrestricted access
protected int myProtected;// package or subclass access
int myPackage; // package access
private int myPrivate; // class access
私有访问
无论访问级别如何,所有成员都可以在声明它们的类(包含类)中进行访问。这是唯一可以访问私有成员的地方。
package mypackage;
public class MyApp
{
public int myPublic;
protected int myProtected;
int myPackage;
private int myPrivate;
void test()
{
myPublic = 0; // allowed
myProtected = 0; // allowed
myPackage = 0; // allowed
myPrivate = 0; // allowed
}
}
包-私人访问
可以在包含包的任何地方访问包私有成员,但不能从另一个包访问:
package mypackage;
public class MyClass
{
void test(MyApp m)
{
m.myPublic = 0; // allowed
m.myProtected = 0; // allowed
m.myPackage = 0; // allowed
m.myPrivate = 0; // inaccessible
}
}
受保护的访问
受保护的成员在子类中和包含包中是可访问的。在下面的代码中,可以访问受保护的成员,因为 MyChild 是定义该成员的 MyApp 的子类:
package newpackage;
import mypackage.MyApp;
public class MyChild extends MyApp
{
void test()
{
myPublic = 0; // allowed
myProtected = 0; // allowed (in subclass)
myPackage = 0; // inaccessible
myPrivate = 0; // inaccessible
}
}
请注意,除了子类之外,受保护的成员也可以在包含包的任何地方访问。这种行为不同于其他语言,如 C++和 C#,在这些语言中,受保护的成员只能从子类和包含类中访问。
package mypackage;
public class MyTest
{
void test(MyApp m)
{
m.myPublic = 0; // allowed
m.myProtected = 0; // allowed (same package)
m.myPackage = 0; // inaccessible
m.myPrivate = 0; // inaccessible
}
}
公共访问
public
修饰符允许从任何可以引用成员的地方进行无限制的访问:
package newpackage;
import mypackage.MyApp;
public class MyClass
{
void test(MyApp m)
{
m.myPublic = 0; // allowed
m.myProtected = 0; // inaccessible
m.myPackage = 0; // inaccessible
m.myPrivate = 0; // inaccessible
}
}
顶级访问
在包中直接声明的成员(顶级成员)只能在包私有和公共访问之间进行选择。例如,没有访问修饰符的顶级类将默认为 package-private。这样的类只能在包含的包中访问。相反,显式声明为public
的顶级类也可以从其他包中访问。
// Accessible only from containing package
class PackagePrivateClass {}
// Accessible from any package
public class PublicClass {}
嵌套类访问
Java 允许在其他类中定义类,这些被称为嵌套类。这样的类可以有四个访问级别中的任何一个。如果嵌套类不可访问,它就不能被实例化或继承。
public class MyClass
{
// Only accessible within MyClass
private class PrivateNestedClass {}
}
请记住,嵌套成员可以受到它们自己的访问级别和包含类的访问级别的限制。例如,包私有类中的公共嵌套类将不能从其他包中访问。
class MyClass
{
// Only accessible within containing package
public class PrivateNestedClass {}
}
访问级别指南
作为一项准则,在选择访问级别时,通常最好使用最严格的级别。这是因为一个成员可以被访问的位置越多,它可以被错误访问的位置就越多,这使得代码更难调试。使用限制性访问级别还使得修改该类变得更加容易,而不会破坏使用该类的任何其他开发人员的代码。
十七、常量
Java 中的变量可以通过在数据类型前添加final
关键字而变成常量。这个修饰符意味着变量一旦被设置就不能被重新分配,任何这样做的尝试都会导致编译时错误。
局部常量
通过应用 final 关键字,可以将局部变量声明为常量。这样的常量必须总是在声明的同时被初始化。Java 对常量的命名约定是全部使用大写字母,并用下划线分隔单词。
final double PI = 3.14;
常量字段
类和实例变量可以声明为final
,如下例所示:
class MyClass
{
final double E = 2.72;
static final double C = 3e8;
final static double D = 1.23; // alternative order
}
与局部常量相反,常量字段不必在声明时赋值。常量实例字段可以选择在构造器或实例初始化块中赋值,而常量静态字段可以通过使用静态初始化块来赋值。如果需要计算常量的值,并且单个代码行无法容纳该值,那么这些可选的赋值可能会很有用。
class MyClass
{
final double E;
final double PI;
static final double C;
public MyClass() { E = 2.72; }
{ PI = 3.14; }
static { C = 3e8; }
}
恒定方法参数
另一个可能应用final
修饰符的地方是方法参数,使它们不可更改。这样做向其他开发人员提供了一个信号,即该方法不会修改传递给它的参数。
void f(final String A) {}
编译时和运行时常量
像大多数其他语言一样,Java 既有编译时常量,也有运行时常量。然而,在 Java 中只有类常量可以是编译时常量,并且只有在编译时它们的值是已知的。对final
的所有其他使用都将创建运行时常量。对于编译时常量,编译器会用它的值替换代码中任何地方的常量名。因此,它们比运行时常量更快,运行时常量在程序运行之前不会被设置。不过,运行时常量可以被赋予动态值,这些值在不同的程序运行之间可能会有所不同。
class MyClass
{
// Compile-time constant (static and known at compile-time)
final static double C = 3e8;
// Run-time constant (not static)
final double E = 2.72;
// Run-time constant (not known at compile-time)
final static int RND = (new
java.util.Random()).nextInt();
}
不变准则
一般来说,如果不需要重新分配,最好总是将变量声明为final
,将常量字段声明为static final
。这确保了字段和变量不会在程序的任何地方被错误地更改,这反过来有助于防止错误。
十八、接口
类型用于指定使用接口的类必须实现的方法。这些方法是用关键字interface
创建的,后跟一个名称和一个代码块。它们的命名约定与类相同,每个单词的第一个字母大写。
interface MyInterface {}
当一个接口没有嵌套在另一个类型中时,它的访问级别可以是 package-private 或 public,就像任何其他顶级成员一样。
界面成员
首先,接口的代码块可以包含实例方法的签名。这些方法没有任何实现。相反,他们的身体被分号代替。默认情况下,接口成员具有公共访问权限,因此可以省略这个修饰符。
interface MyInterface {
int myMethod(); // method signature
}
接口可以包含的第二个成员是常量。在接口中创建的任何字段都将被隐式声明为static final
,因此这些修饰符也可以被省略。
interface MyInterface {
int c = 10; // constant
}
除了方法签名和常量,接口还可以包含嵌套的包含类型,如类或其他接口:
interface MyInterface
{
// Types
class Class {}
interface Interface {}
enum Enum {}
}
界面示例
下面的例子展示了一个名为Comparable
的接口,它有一个名为compare
的方法:
interface Comparable
{
int compare(Object o);
}
下面的类使用类名后的关键字implements
实现了这个接口。按照惯例,如果类有 implements 子句,那么 implements 子句放在extends
子句之后。请注意,尽管一个类只能从一个超类继承,但它可以通过在逗号分隔的列表中指定接口来实现任意数量的接口。
class Circle implements Comparable
{
public int r;
// ...
}
因为Circle
实现了Comparable
,所以它必须定义compare
方法。对于这个类,该方法将返回圆半径之间的差值。实现的方法必须是公共的,并且必须与接口中定义的方法具有相同的签名。
class Circle implements Comparable
{
public int r;
@Override
public int compare(Object o) {
return r - ( (Circle)o ).r;
}
}
功能界面
演示了接口的第一个用途,即定义一个类可以共享的特定功能。它使得在不知道类的实际类型的情况下使用接口成员成为可能,这个概念叫做多态。为了说明这一点,下一个例子展示了一个简单的方法,它接受两个Comparable
对象并返回最大的一个。这个方法适用于任何实现了Comparable
接口的类,因为这个方法只使用通过那个接口公开的功能。
public static Object largest(Comparable a, Comparable b)
{
return (a.compare(b) > 0) ? a : b;
}
类接口
使用接口的第二种方法是为一个类提供一个实际的接口,通过这个接口可以使用这个类。下面的例子为MyClass
定义了一个名为MyInterface
的接口。该接口仅包括使用MyClass
的程序员可能需要的功能。
interface MyInterface
{
void exposed();
}
class MyClass implements MyInterface
{
@Override
public void exposed() {}
public void hidden() {}
}
然后,接口类型用于保存实现类,因此只能通过该接口看到该类:
public static void main(String[] args)
{
MyInterface i = new MyClass();
}
这种抽象提供了两个好处。首先,它使其他程序员更容易使用该类,因为他们现在只能访问相关的方法。其次,它使类更加灵活,因为只要遵循接口,它的实现就可以改变,而不会被使用该类的其他程序员注意到。
接口类
如前所述,一个接口可以包含嵌套类型,比如类。与方法不同,这些类型是在接口内部实现的。例如,这可以用于提供一个包含对实现类有用的静态方法的类。这些嵌套类型仅对实现接口的类可见,对这些类的对象不可见。
interface MyInterface
{
class HelperClass {
public static void helperMethod() {}
}
}
默认接口方法
Java 8 增加了在接口中定义默认方法的能力。这种方法是使用关键字default
指定的,然后可以在接口中包含一个实现。
interface MyInterface
{
default void defaultMethod() {
System.out.println("default");
}
}
将使用默认方法,除非它被实现类覆盖。这提供了一种向后兼容的方式来向接口添加新方法,而不会破坏使用该接口的现有类。
public class MyApp implements MyInterface
{
public static void main(String[] args) {
MyInterface i = new MyApp();
i.defaultMethod(); // "default"
}
}
静态接口方法
Java 8 中引入的另一个特性是静态接口方法。与静态类方法类似,这些方法属于接口,只能从接口上下文中调用。
interface MyInterface
{
public static void staticMethod() {
System.out.println("static");
}
}
class MyApp
{
public static void main(String[] args) {
MyInterface.staticMethod(); // "static"
}
}
从 Java 9 开始,静态接口方法可以进行私有访问。这使得冗长的默认接口方法可以跨私有静态接口方法进行拆分,从而减少代码重复。
interface MyInterface
{
private static String getString() {
return "string";
}
default void printString() {
System.out.println(getString());
}
}
十九、抽象
一个抽象类提供了一个部分实现,其他类可以在此基础上构建。当一个类被声明为abstract
时,意味着除了普通的类成员之外,它还可以包含必须在子类中实现的不完整的方法。这些方法没有实现,只指定了它们的签名,而它们的主体被分号替换。
abstract class Shape
{
public int x = 100, y = 100;
public abstract int getArea();
}
抽象类示例
如果一个名为Rectangle
的类继承了抽象类Shape
,那么Rectangle
将被强制覆盖抽象方法getArea
。唯一的例外是如果Rectangle
也被声明为abstract
,在这种情况下,它不必实现任何抽象方法。
class Rectangle extends Shape
{
@Override public int getArea() {
return x * y;
}
}
抽象类不能被实例化,但它可以用来保存其子类的实例:
public class MyApp
{
public static void main(String[] args) {
Shape s = new Rectangle();
}
}
尽管抽象类不能被实例化,但它可能有构造器,可以使用super
关键字从子类的构造器中调用这些构造器:
abstract class Shape
{
public int x = 100, y = 100;
public Shape(int a, int b) {
x = a;
y = b;
}
}
class Rectangle extends Shape
{
public Rectangle(int a, int b) {
super(a,b);
}
}
public class MyApp
{
public static void main(String[] args) {
Rectangle s = new Rectangle(5, 10);
}
}
抽象类和接口
抽象类在许多方面类似于接口。它们都可以定义子类必须实现的方法签名,它们都不能被实例化。一个关键的区别是,抽象类可以包含任何抽象或非抽象成员,而接口仅限于抽象成员、嵌套类型和静态常量,以及静态方法、默认方法和私有方法。另一个区别是,一个类可以实现任意数量的接口,但只能从一个类继承,不管是不是抽象的。
接口或者用于定义一个类可以拥有的特定功能,或者为使用该类的其他开发人员提供一个接口。相反,抽象类用于提供部分类实现,留给子类来完成。当子类有一些共同的功能,但也有一些必须为每个子类不同地实现的功能时,这是有用的。
二十、枚举类型
一个枚举,或枚举,是一个由固定的命名常量列表组成的类型。要创建一个,使用enum
关键字,后跟一个名称和一个代码块,代码块包含一个以逗号分隔的常量元素列表。枚举的访问级别与类的访问级别相同。默认情况下是 Package-private,但是如果在同名文件中声明,也可以将其设置为 public。与类一样,枚举也可以包含在类中,然后可以设置为任何访问级别。
enum Speed
{
STOP, SLOW, NORMAL, FAST
}
刚才显示的枚举类型的对象可以保存四个定义的常量中的任何一个。枚举常量就像类的静态字段一样被访问。
Speed s = Speed.SLOW;
switch
语句提供了一个枚举何时有用的好例子。与使用普通常量相比,枚举的优点是允许程序员清楚地指定允许哪些常量值。这提供了编译时类型安全。注意,当在switch
语句中使用枚举时,case 标签没有用枚举的名称限定。
public class MyApp
{
public static void main(String args[]) {
Speed s = Speed.NORMAL;
// ...
switch(s) {
case STOP: break;
case SLOW: break;
case NORMAL: break;
case FAST: break;
}
}
}
枚举类
在 Java 中,enum 类型比其他语言(如 C++或 C#)中的 enum 类型更强大。本质上是一种特殊的类,它可以包含一个类可以包含的任何东西。要添加类成员,常量列表必须以分号结束,并且成员必须在常量之后声明。在下面的示例中,一个整数被添加到枚举中,它将保存元素所表示的实际速度。
enum Speed
{
STOP, SLOW, NORMAL, FAST;
public int velocity;
// ...
}
要设置这个字段,还需要添加一个构造器。枚举中的构造器总是私有的,不会像普通类那样被调用。相反,构造器的参数在常量元素之后给出,如下例所示。如果一个Speed
枚举对象被赋予常量SLOW
,那么参数5
将被传递给该枚举实例的构造器。
enum Speed
{
STOP(0), SLOW(5), NORMAL(10), FAST(20);
public int velocity;
private Speed(int s) { velocity = s; }
}
public class MyApp
{
public static void main(String args[]) {
Speed s = Speed.SLOW;
System.out.println(s.velocity); // "5"
}
}
与常规类相比,枚举类型的另一个区别是它们隐式地从java.lang.Enum
类扩展而来。除了从这个类继承的成员之外,编译器还会自动给枚举添加两个静态方法,分别是values
和valueof
。values
方法返回枚举中声明的常量元素的数组,valueof
返回指定枚举名称的枚举常量。
Speed[] a = Speed.values();
String s = a[0].toString(); // "STOP"
Speed b = Speed.valueOf(s); // Speed.STOP
二十一、异常处理
异常处理允许程序员处理程序中可能出现的意外情况。比如 java.io 包中的FileReader
类用来打开一个文件。创建该类的一个实例将会导致 IDE 给出一个提示,提示该类的构造器可能会抛出一个FileNotFoundException
。试图运行程序也会导致编译器指出这一点。
import java.io.*;
public class MyClass
{
public static void main(String[] args)
{
// Compile-time error
FileReader file = new FileReader("missing.txt");
}
}
试着接住
为了处理这个编译时错误,必须使用一个try-catch
语句来捕获异常。该语句由一个包含可能导致异常的代码的try
块和一个或多个catch
子句组成。如果try
块成功执行,程序将在try-catch
语句后继续运行,但如果出现异常,执行将传递给第一个能够处理该异常类型的catch
块。
try {
FileReader file = new FileReader("missing.txt");
}
catch(FileNotFoundException e) {}
捕捉块
在前面的例子中,catch
块仅被设置为处理FileNotFoundException
。如果try
块中的代码可以抛出更多种类的异常,并且所有的异常都应该以同样的方式处理,那么可以捕捉更一般的异常,比如所有异常都源自的Exception
类本身。这个catch
子句将能够处理从这个类继承的所有异常,包括FileNotFoundException
。请记住,一个更一般的异常需要在一个更具体的异常之后被捕获。子句必须总是定义一个异常对象。这个对象可以用来获得关于异常的更多信息,比如使用getMessage
方法对异常的描述。
catch(FileNotFoundException e) {
System.out.print(e.getMessage());
}
catch(Exception e) {
System.out.print(e.getMessage());
}
从 Java 7 开始,可以使用单个catch
块捕获不同类型的多个异常。这有助于避免代码重复,在以相同方式处理多个异常的情况下,不必捕获过于一般化的异常类型。在catch
子句中,每个异常用竖线(|
)分隔。
catch(IOException | SQLException e) {
// Handle exception
}
最终阻止
作为try-catch
语句的最后一个子句,可以添加一个finally
块。这个块用于清理分配在try
块中的资源,并且无论是否有异常都会执行。在本例中,在try
块中打开的文件应该被关闭,但前提是它被成功打开。为了能够从finally
子句访问FileReader
对象,它必须在try
块之外声明。此外,因为close
方法也可以抛出异常,所以该方法需要用另一个try-catch
块包围。请记住,如果您忘记关闭一个资源对象,Java 的垃圾收集器最终会为您关闭该资源,但是自己关闭它是一个很好的编程实践。
FileReader file = null;
try {
file = new FileReader("missing.txt");
}
catch(FileNotFoundException e) {
System.out.print(e.getMessage());
}
finally {
if (file != null) {
try { file.close(); }
catch(IOException e) {}
}
}
Java 7 增加了资源尝试特性。该特性允许通过在try
关键字后的括号中定义资源对象来自动关闭资源对象。为此,资源必须实现java.lang.AutoClosable
接口。这个接口仅由close
方法组成,该方法在隐式的finally
语句中被自动调用。因此,前面的例子可以简化如下:
try(FileReader file = new FileReader("missing.txt")) {
// Read file
}
catch(FileNotFoundException e) {
// Handle exception
}
对于自动关闭,可以包括多个资源对象,用分号分隔。为了提高可读性,Java 9 使得在括号外声明的对象可以被 try-with-resources 语句引用,只要这些资源是最终的或者实际上是最终的。
// Final resource
final FileReader file1 = new FileReader("file1.txt");
// Effectively final resource (never changed)
FileReader file2 = new FileReader("file2.txt");
try(file1; file2) {
// Read files
}
catch(FileNotFoundException e) {
// Handle exception
}
抛出异常
当某个方法无法恢复的情况发生时,它可以生成自己的异常,通知调用者该方法已经失败。它使用throw
关键字,后跟一个Throwable
类型的新实例。
static void makeException()
{
throw new Throwable("My Throwable");
}
已检查和未检查的异常
Java 中的异常分为两类——检查的和未检查的——这取决于它们是否需要被指定。抛出检查异常的方法(例如,IOException
)将不会编译,除非在方法的参数列表后使用throws
子句指定它,并且调用方法捕获异常。另一方面,未检查的异常,如ArithmeticException
,不必被捕获或指定。请注意,要指定多个异常,异常类型由逗号分隔。
static void MakeException()
throws IOException, FileNotFoundException
{
// ...
throw new IOException("My IO exception");
// ...
throw new FileNotFoundException("File missing");
}
异常层次结构
像 Java 中的大多数其他东西一样,异常是存在于层次结构中的类。这个层次结构的根(在Object
下面)是Throwable
类,这个类的所有后代都可以被抛出和捕获。从Throwable
继承而来的是Error
和Exception
类。从Error
开始下降的类用于指示不可恢复的异常,例如OutOfMemoryError
。这些是未检查的,因为一旦它们发生,即使它们被发现,程序员也不可能对它们做任何事情。
从Exception
往下是RuntimeExceptions
,也是未勾选的。这些异常几乎在任何代码中都可能发生,因此捕捉和指定它们会很麻烦。例如,被零除会抛出一个ArithmeticException
,但是用一个try-catch
包围每个除法运算会很麻烦。还有一些与检查异常相关的开销,检查这些异常的成本通常超过捕获它们的好处。其他的Exception
后代,那些不继承RuntimeExceptions
的,都被查了。这些是可以从中恢复的异常,并且必须被捕获和指定。
二十二、装箱和拆箱
在对象中放置一个原始变量被称为装箱。装箱允许在需要对象的地方使用原语。为此,Java 提供了包装器类来实现每个原始类型的装箱,即Byte
、Short
、Integer
、Long
、Float
、Double
、Character
和Boolean
。例如,一个Integer
对象可以保存一个int
类型的变量。
int iPrimitive = 5;
Integer iWrapper = new Integer(iPrimitive); // boxing
自然,装箱的反义词是 unboxing ,它将对象类型转换回其原始类型。
iPrimitive = iWrapper.intValue(); // unboxing
包装类属于java.lang
包,它总是被导入。当使用包装器对象时,请记住等号运算符(==
)检查两个引用是否指向同一个对象,而equals
方法用于比较对象表示的值。
Integer x = new Integer(1000);
Integer y = new Integer(1000);
boolean b = (x == y); // false
b = x.equals(y); // true
汽车尾气与汽车尾气排放
Java 5 引入了自动装箱和自动拆箱。这些特性允许原语和它们的包装对象之间的自动转换。
Integer iWrapper = iPrimitive; // autoboxing
iPrimitive = iWrapper; // autounboxing
注意,这只是为了让代码更容易阅读而设计的语法糖。编译器将使用valueOf
和intValue
方法为您添加必要的代码来装箱和取消装箱原语。
Integer iWrapper = Integer.valueOf(iPrimitive);
iPrimitive = iWrapper.intValue()
原语和包装准则
当不需要对象时,应该使用基本类型。这是因为原语通常比对象更快,内存效率更高。相反,当需要数值但需要对象时,包装器是有用的。例如,要在集合类中存储数值,比如ArrayList<>
,就需要包装类。
import java.util.ArrayList;
// ...
java.util.ArrayList<Integer> a = new java.util.ArrayList<>();
a.add(10); // autoboxing
int i = a.get(0); // autounboxing
请记住,如果速度很重要,原语和包装对象之间的转换应该保持较低的速度。任何装箱和取消装箱操作都会带来固有的性能损失。
二十三、泛型
泛型指的是类型参数的使用,它提供了一种定义方法、类和接口的方式,这些方法、类和接口可以操作不同的数据类型。泛型的好处是它们提供了编译时类型安全,并且消除了大多数类型转换的需要。
通用类
泛型类允许类成员使用类型参数。这种类是通过在类名后添加类型参数部分来定义的,该部分包含一个用尖括号括起来的类型参数。类型参数的命名约定是它们应该由一个大写字母组成。通常使用字母T
代表型。下面的示例定义了一个泛型容器类,它可以保存泛型类型的单个元素:
// Generic container class
class MyBox<T> { public T box; }
当这个泛型类的对象被实例化时,type 参数必须被替换为实际的数据类型,比如Integer
:
MyBox<Integer> iBox = new MyBox<Integer>();
或者,从 Java 7 开始,泛型类可以用一组空的类型参数来实例化。只要编译器能够从上下文中推断(确定)类型参数,这种类型的实例化就是可能的。
MyBox<Integer> iBox = new MyBox<>();
当MyBox
的实例被创建时,类定义中的每个类型参数都被替换为传入的类型参数。因此,该对象表现得像一个常规对象,只有一个Integer
类型的字段。
iBox.box = 5;
Integer i = iBox.box;
请注意,当从box
字段设置或检索存储值时,不需要转换。此外,如果泛型字段被错误地赋值或设置为不兼容的类型,编译器会指出来。
iBox.box = "Hello World"; // compile-time error
String s = iBox.box; // compile-time error
通用方法
通过在方法的返回类型前声明一个类型参数节,可以使方法成为泛型方法。类型参数可以像方法内部的任何其他类型一样使用。您还可以在throws
子句中将其用于方法的返回类型及其参数类型。下一个示例显示了一个接受泛型数组参数的泛型类方法,其内容被打印出来。
class MyClass
{
public static <T> void printArray(T[] array)
{
for (T element : array)
System.out.println(element);
}
}
前面显示的类不是泛型的。无论封闭类或接口是否为泛型,方法都可以声明为泛型。构造器也是如此,如下例所示:
public class MyApp
{
private String s;
public <T> MyApp(T t) {
s = t.toString(); // convert to string
}
public static void main(String[] args) {
MyApp o = new MyApp(10);
System.out.println(o.s); // "10"
}
}
调用泛型方法
泛型方法通常像常规(非泛型)方法一样调用,不指定类型参数:
Integer[] iArray = { 1, 2, 3 };
MyClass.printArray(iArray);
在大多数情况下,Java 编译器可以推断出泛型方法调用的类型参数,所以不必包含它。但是如果不是这样,那么需要在方法名之前显式指定类型参数:
MyClass.<Integer>printArray(iArray);
通用接口
用类型参数声明的接口成为泛型接口。泛型接口与常规接口有两个相同的目的:要么创建它们来公开将被其他类使用的类的成员,要么强制一个类实现特定的功能。实现泛型接口时,必须指定类型参数。泛型接口可以由泛型和非泛型类实现:
// Generic functionality interface
interface IGenericCollection<T>
{
void store(T t);
}
// Non-generic class implementing generic interface
class Box implements IGenericCollection<Integer>
{
private Integer myBox;
public void store(Integer i) { myBox = i; }
}
// Generic class implementing generic interface
class GenericBox<T> implements IGenericCollection<T>
{
private T myBox;
public void store(T t) { myBox = t; }
}
泛型类型参数
泛型的传入类型参数可以是类类型、接口类型或其他泛型类型参数,但不能是基元类型。泛型可以定义多个类型参数,方法是在逗号分隔的列表中的尖括号之间添加多个类型参数。请记住,括号中的每个参数都必须是唯一的。
class MyClass<T, U> {}
如果泛型定义了多个类型参数,则在使用泛型时,需要指定相同数量的类型参数。
MyClass<Integer, Float> m = new MyClass<>();
通用变量用法
泛型只是 Java 中的一个编译时构造。在编译器检查了与泛型变量一起使用的类型是正确的之后,它将从泛型代码中删除所有类型参数和实参信息,并插入适当的类型转换。这意味着泛型不会比非泛型代码提供任何性能优势,因为它们移除了运行时强制转换,就像在 C#中一样。这也意味着泛型类型不能用于任何需要运行时信息的事情——比如创建泛型类型的新实例或者使用带有类型参数的instanceof
操作符。允许的操作包括声明泛型类型的变量,将 null 赋给泛型变量,以及调用Object
方法。
class MyClass<T>
{
public void myMethod(Object o)
{
T t1; // allowed
t1 = null; // allowed
System.out.print(t1.toString()); // allowed
if (o instanceof T) {} // invalid
T t2 = new T(); // invalid
}
}
从通用代码中移除类型信息的过程被称为类型擦除。例如,MyBox<Integer>
将被简化为MyBox
,这被称为原始类型。执行这一步是为了保持与泛型成为 Java 5 语言的一部分之前编写的代码的向后兼容性。
有界类型参数
可以对泛型可能使用的类型参数的种类应用编译时强制限制。这些限制称为边界,在类型参数部分使用extends
关键字指定。类型参数可以由超类或接口限定。例如,下面的类B
只能用一个类型参数来实例化,该类型参数要么是A
类型,要么将该类作为超类。
// T must be or inherit from A
class B<T extends A> {}
class A {}
下一个示例指定一个接口作为绑定。这将把类型参数限制为仅实现指定接口或属于接口类型本身的那些类型。
// T must be or implement interface I
class C<T extends I> {}
interface I {}
通过在由&符号分隔的列表中指定多个界限,可以将多个界限应用于类型参数:
class D<T extends A & I> {}
与号代替逗号作为分隔符,因为逗号已经用于分隔类型参数:
class E<T extends A & I, U extends A & I> {}
除了将泛型的使用限制在特定的参数类型之外,应用边界的另一个原因是增加被边界类型支持的允许方法调用的数量。未绑定的类型只能调用Object
方法。但是,通过应用超类或接口绑定,该类型的可访问成员也将变得可用。
class Fruit
{
public String name;
}
class FruitBox<T extends Fruit>
{
private T box;
public void FruitBox(T t) { box = t; }
public String getFruitName()
{
// Use of Fruit member allowed since T extends Fruit
return box.name;
}
}
泛型和对象
在 Java 5 中引入泛型之前,Object
类型用于创建可以存储任何类型对象的容器类。随着泛型的出现,应该避免使用Object
类型作为通用容器。这是因为编译器有助于确保泛型在编译时是类型安全的,这在使用Object
类型时是做不到的。
Java 库中的集合类,包括ArrayList
,都被替换成了通用版本。即便如此,任何泛型类仍然可以像非泛型类一样使用,只需省略类型参数部分。默认的Object
类型将被用作类型参数。这就是为什么非通用版本的ArrayList
仍然被允许。考虑非通用ArrayList
的以下使用:
import java.util.ArrayList;
// ...
// Object ArrayList
ArrayList a = new ArrayList();
a.add("Hello World");
// ...
Integer b = (Integer)a.get(0); // run-time error
通过抛出一个ClassCastException
,这种String
到Integer
的转换将在运行时失败。如果使用一个通用的ArrayList
来代替,错误的转换将会在编译时被发现,或者立即在一个 IDE(比如 NetBeans)中被发现。与其他编码方法相比,这种编译时调试特性是使用泛型的一个主要优势。
import java.util.ArrayList;
// ...
// Generic ArrayList (recommended)
ArrayList<String> a = new ArrayList<>();
a.add("Hello World");
// ...
Integer b = (Integer)a.get(0); // compile-time error
使用泛型替代,只有指定的类型参数才被允许进入ArrayList
集合。此外,从集合中获取的值不必强制转换为正确的类型,因为编译器会负责这一点。
二十四、Lambda 表达式
Java 8 引入了 lambda 表达式,它提供了一种使用表达式表示方法的简洁方式。lambda 表达式由三部分组成:参数列表、箭头操作符(->
)和主体。下面的 lambda 采用两个整数参数并返回它们的和。
(int x, int y) -> { return x + y; };
通常不需要指定参数类型,因为编译器可以自动确定这些类型。这种类型推断也适用于返回类型。如果正文只包含一条语句,可以省略花括号,然后返回语句的结果。
(x, y) -> x + y;
λ对象
lambda 表达式是一个函数接口的表示,它是一个定义单一抽象方法的接口。因此,只要它的函数方法具有匹配的签名,它就可以绑定到这种接口的对象。
interface Summable
{
public int combine(int a, int b);
}
public class MyApp
{
public static void main(String[] args) {
Summable s = (x, y) -> x + y;
s.combine(2, 3); // 5
}
Java 8 中添加的java.util.function
包中定义了常用的函数接口。在这个例子中,可以使用BinaryOperator<T>
接口。它表示一个方法,该方法采用两个参数并返回与参数类型相同的结果。它的功能方法被命名为apply
。
import java.util.function.*;
public class MyApp
{
public static void main(String[] args) {
BinaryOperator<Integer> adder = (x, y) -> x + y;
adder.apply(2, 3); // 5
}
}
当处理单个操作数并返回相同类型的值时,可以使用UnaryOperator
函数接口。注意,当只有一个参数时,参数周围的括号可以省略。
UnaryOperator<Integer> doubler = x -> x*2;
doubler.apply(2); // 4
λ参数
与方法不同,lambda 表达式不属于任何类。它们本身就是对象,因为它们是函数接口的实例。这样做的好处是,它们提供了一种方便的方式将功能作为参数传递给另一个方法。在下面的例子中,使用了Runnable
接口,它有一个不带参数也不返回值的函数方法。这个接口属于java.lang
,它的抽象方法被命名为run
。
public class MyApp
{
static void starter(Runnable s) { s.run(); }
public static void main(String[] args) {
Runnable r = () -> System.out.println("Hello");
starter(r); // "Hello"
}
}
你也可以通过定义一个匿名的内部类来实现这个功能,但是这种方法比 lambda 表达式要冗长得多。
Runnable r = new Runnable() {
@Override public void run() {
System.out.println("Hello");
}
};
starter(r); // "Hello"
lambda 表达式可以从其上下文中捕获变量,前提是引用的变量是 final 或有效的 final(仅赋值一次)。在下一个例子中,使用了Consumer
函数接口,它表示一个接受一个参数并且不返回值的函数。
import java.util.function.*;
public class MyApp
{
final static String GREETING = "Hi ";
public static void main(String[] args) {
Consumer<String> c = (s) ->
System.out.println(GREETING + s);
c.accept("John"); // "Hi John"
}
}
在幕后,编译器将实例化一个包含单个方法的匿名类来表示一个 lambda 表达式。这使得 lambdas 能够完全向后兼容早期版本的 Java 运行时环境。