书名:Java核心技术速学版(第3版)
ISBN:978-7-115-62609-7
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
著 [美]凯·S.霍斯特曼(Cay S. Horstmann)
译 曹良亮
责任编辑 蒋 艳
人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
读者服务热线:(010)81055410
反盗版热线:(010)81055315
本书是经典Java开发基础书《Java核心技术》的速学版本。本书首先介绍了Java语言的基础知识,包含接口、Lambda表达式、继承、反射、异常处理、断言、日志、泛型编程、容器等关键概念;其次介绍了流、输入和输出处理、并发、注解、日期和时间API、国际化、编译和脚本、Java平台模块系统等高级特性。本书不仅可以让读者深入了解Java编程的基础知识和核心概念,还可以帮助读者掌握Java应用程序开发所需的基本技能。本书涵盖了Java 17中更新的内容,提供了许多实用的示例代码,还给出了基于作者实际经验的提示、注意和警告。
本书可供希望深入掌握Java应用的初学者使用,也适合打算将Java应用到实际项目中的编程人员使用。
首先,我要一如既往地感谢本书的编辑格雷格·多恩奇(Greg Doench),他热情地支持我完成了这本对Java进行全新介绍的新书。德米特里·基尔萨诺夫(Dmitry Kirsanov)和阿林娜·基尔萨诺娃(Alina Kirsanova)也再次以惊人的速度和严谨的态度将XHTML原稿变成了一本引人入胜的书。特别感谢所有版本的优秀评审团队,他们发现了许多错误并提出了改进建议。他们是安德烈斯·阿尔米雷(Andres Almiray)、盖尔·安德松(Gail Anderson)、保罗·安德松(Paul Anderson)、马库斯·比尔(Marcus Biel)、布里安·戈茨(Brian Goetz)、马克·劳伦斯(Mark Lawrence)、道格·利(Doug Lea)、西蒙·里特尔(Simon Ritter)、柴田佳樹(Yoshiki Shibata)和克里斯蒂安·乌伦布姆(Christian Ullenboom)。
凯·S. 霍斯特曼(Cay S. Horstmann)
2022年8月于德国柏林
凯·S. 霍斯特曼(Cay S. Horstmann)是JavaScript for the Impatient和Scala for the Impatient的作者,是Core Java, Volumes I and II, Twelfth Edition的主要作者,他还为专业编程人员和计算机科学专业的学生撰写了十多本书。他是美国圣何塞州立大学计算机科学专业的荣誉退休教授,也是一名Java Champion。
自1996年首次发布以来,Java语言一直在不断地改进。经典著作《Java核心技术》(Core Java)一书不仅详细介绍了Java的语言特性和所有核心库,还介绍了各个版本之间的大量更新之处。因此该书体量庞大,共分上下两卷,超过2000页。如果你只是想高效地使用现代Java,那么本书就是一个更快、更容易学习Java语言和核心库的途径。本书不回顾Java语言的发展历史,也不纠缠于过去版本的特点,只展示当前Java语言的优秀内容,以便你可以更快地把相关知识应用到实际工作中。
与之前的“Impatient”系列书籍类似,本书将会很快切入主题,向你展示解决编程问题所需的核心知识,而不会总是教条地告诉你一种范式如何优于另一种范式。本书还将相关的信息按照知识点进行碎片化处理,然后再把它们重新组织起来,这样更便于你在需要时快速检索。
假如你已经精通其他的编程语言,如C++、JavaScript、Swift、PHP或Ruby,那么在本书中,你将学习如何成为一名称职的Java编程人员。本书涵盖了目前开发人员需要了解的关于Java语言的方方面面,其中包括Lambda表达式和流这种强大的概念,以及记录类(record class)和封闭类(sealed class)等现代构造。
使用Java的一个关键原因是处理并发编程。由于Java库中提供了并行算法和线程安全的数据结构,因此应用编程人员处理并发编程的方式已经完全改变了。本书也提供了新的内容,向你展示如何使用强大的库特性,而不是使用容易出错的底层构造。
传统上,很多有关Java的书侧重于用户界面编程,但是现在,已经很少有开发人员在台式计算机上制作用户界面了。如果你打算将Java用于服务器端编程或Android编程,那么你将能够更加有效地使用本书,而不会被桌面GUI的代码干扰。
最后,本书是专门为应用编程人员编写的,而不是为大学的Java语言课程或者系统向导编写的,本书基本涵盖了应用编程人员在实践中需要解决的问题,例如记录日志和处理文件,但你将不会学习到如何手动实现链表或如何编写Web服务器。
衷心希望你能喜欢本书以这样的方式快速地介绍现代Java,并希望它能够让你的Java开发工作富有成效,且充满乐趣。
如果你发现本书有错误或对本书有改进建议,请访问异步社区,并前往勘误页面提交你的意见和建议。同时也请务必访问异步社区,下载本书配套的可运行的示例代码。
本书提供思维导图等资源,要获得以上资源,您可以扫描下方二维码,根据指引领取。
作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。
当您发现错误时,请登录异步社区(https://www.epubit.com/),按书名搜索,进入本书页面,点击“发表勘误”,输入勘误信息,点击“提交勘误”按钮即可(见下图)。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。
我们的联系邮箱是contact@epubit.com.cn。
如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。
如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们。
如果您所在的学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。
如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。
“异步社区”(www.epubit.com)是由人民邮电出版社创办的IT专业图书社区,于2015年8月上线运营,致力于优质内容的出版和分享,为读者提供高品质的学习内容,为作译者提供专业的出版服务,实现作者与读者在线交流互动,以及传统出版与数字出版的融合发展。
“异步图书”是异步社区策划出版的精品IT图书的品牌,依托于人民邮电出版社在计算机图书领域30余年的发展与积淀。异步图书面向IT行业以及各行业使用IT技术的用户。
在本章中,你将学习Java语言的基本数据类型和控制结构方面的知识。假设你熟悉其他的程序设计语言,已是一名经验丰富的编程人员。也许你已经掌握了一些关于变量、循环、函数调用和数组的概念,但是你熟悉的那些概念可能和Java语言相比,有一些语法方面的差异。本章将帮助你快速了解Java语言的基础知识。本书也会为你提供一些Java API中处理常见数据类型的非常有用的技巧。
本章重点如下:
1.在Java中,所有方法都在类中声明。当你调用一个非静态方法时,需要通过该方法所属类的对象来进行调用。
2.静态方法的调用不需要对象。程序从静态的main
方法开始执行。
3. Java有8种基本数据类型:4种有符号整数类型、两种浮点类型,以及char
类型和boolean
(布尔)类型。
4.Java的运算符和控制结构与C或JavaScript非常相似。
5.共有4种形式的switch
,分别是带有和不带有直通式(fall-through)的表达式和语句。
6.Math
类提供通用的数学函数。
7.String
对象是字符序列,更准确地说,它们是UTF-16 编码中的Unicode码点。
8.使用文本块语法来声明多行的字符串字面量。
9.使用System.out
对象,可以在终端窗口中显示输出。通过与System.in
绑定的Scanner
可以读取终端输入。
10.数组和容器可以用于收集相同类型的元素。
当学习任何新的编程语言时,传统做法是从一个能够显示“Hello, World!”消息的程序开始,这也是在下面的小节中我们将要做的事情。
话不多说,下面就是Java中的“Hello, world”程序。
package ch01.sec01;
// Our first Java program
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
让我们一起来看看这个程序。
● Java是一种面向对象的语言。在程序中,通常需要控制对象(object)来让它们完成具体工作。操作的每个对象都属于特定的类(class),通常也称这个对象是该类的一个实例(instance)。类定义了对象的状态是什么,以及对象能做什么。在Java中,所有代码都是在类中定义的。第2章将详细介绍对象和类。这个程序是由一个名为HelloWorld
的单一类组成的。
● main
是一个方法(method),也就是在类中声明的一个函数。main
方法是程序运行时调用的第一个方法。main
方法声明为static
,以表示该方法不作用于任何对象。(当调用main
方法时,只有少数的预定义对象,并且它们都不是HelloWorld
类的实例。)main
方法也声明为void
,以表示它不返回任何值。关于main
方法中参数声明String[ ] args
的含义,参见1.8.8小节。
● 在Java中,你可以将许多特性声明为public
或private
。除此之外,Java中还有一些其他可见性级别。这里将HelloWorld
类和main
方法都声明为public
,这是类和方法中最常见的定义形式。
● 包(package)是一组相关类的集合。把相关类放在一个包中是一个很好的做法,这样可以将相关类组合在一起,并避免多个类在具有相同名称时可能会发生的冲突。本书使用章(Chapter)和节(Section)的编号作为包名。因此,示例中类的全名就是ch01.sec01.Helloworld
。第2章会有更多关于包和包命名规范的内容。
● 以 // 开头的行是注释。编译器会忽略从 // 到行末的所有字符,这些字符仅仅用来辅助编程人员阅读程序。
● 最后,来看看main
方法的主体。在示例中,它由一行命令组成,该命令的功能是向System.out
输出一个消息,System.out
对象代表Java程序的“标准输出”。
正如你所见,Java不是一种可以用来快速执行一些简单命令的脚本语言。它的类、包和模块(模块在第15章中介绍)等特性使得它更适合用于编写大型程序。
Java也非常简单和统一。一些编程语言不仅有全局变量和全局函数,还有类内部的变量和方法。在Java中,所有东西都在类中声明,这种统一性可能会导致代码有些冗长,但也使得理解程序的含义变得容易。
注意:你刚刚看到了一个 //
形式的注释,它的注释效果是延伸到行末的。还可以在 /*
和 */
分隔符之间添加多行注释。例如:
/*
This is the first sample program in Core Java for the Impatient.
The program displays the traditional greeting "Hello, World!".
*/
还有第三种注释样式,称为文档注释(documentation comment),使用/**
和 */
作为分隔符。下一章中将会介绍。
要编译和运行一个Java程序,需要安装Java开发工具包(Java Development Kit,JDK),此外,也可以安装集成开发环境(Integrated Development Enviroment,IDE)。可以在异步社区中下载本书的示例代码。
一旦安装了JDK,就可以打开一个终端窗口,并切换到包含ch01
目录的目录,然后运行以下命令:
javac ch01/sec01/HelloWorld.java
java ch01.sec01.HelloWorld
然后,那条熟悉的问候语就将出现在终端窗口中,如图1-1所示。
图1-1 在控制台窗口运行Java程序
需要注意的是,执行程序需要两个步骤。首先,javac
命令将Java源代码编译(compile)成一个与机器无关的中间表示,称为字节码(byte code),并将它们保存在类文件(class file)中;然后,java
命令启动一个虚拟机(virtual machine),该虚拟机会加载类文件并执行编译后的字节码。
一旦编译完成,字节码可以在任意一个Java虚拟机中运行,无论是在你的台式计算机上或者是在遥远银河系中的某个设备上。这个“一次编写,处处运行”的承诺是Java的一个重要设计标准。
注意:javac
编译器是通过文件名进行调用的,使用斜杠分隔文件路径段,文件扩展名为.java
。java
虚拟机的启动器是通过类名进行调用的,使用点号来分隔包的名称段,并且没有扩展名。
注意:如果程序由单个源文件组成,那么可以跳过编译的步骤,直接使用以下命令运行程序:
java ch01/sec01/HelloWorld.java
在后台,程序将会在运行之前进行编译,但不会生成类文件。
注意:在类Unix操作系统上,你可以按照以下步骤将Java文件转换为可执行程序。
(1)重命名文件,删除其扩展名.java
。
mv HelloWorld.java hello
(2)使文件可执行。
chmod +x hello
(3)在文件顶部添加一行bash的运行标记。
#!/path/to/jdk/bin/java --source 17
现在,你就可以通过以下方式运行程序了。
./hello
如果要在 IDE 中运行程序,首先需要按照 IDE 安装说明中描述的方式创建一个项目。然后,选择HelloWorld
类并通过IDE运行它。图1-2显示了程序在Eclipse中的运行情况。Eclipse是一个非常流行的IDE。除此之外,还可以选择许多其他优秀的IDE。随着对Java编程的不断学习和深入了解,还是应该多尝试几种IDE,再从中选择一个自己喜欢的。
图1-2 在Eclipse IDE中运行Java程序
好了,恭喜你刚刚完成了使用Java运行“Hello,World!”程序这一古老的传统,下面我们就准备开始学习Java语言的基础知识。
提示:在异步社区官网中可以下载本书所有章节的示例代码。这些代码经过精心编排和组织,你可以很方便地创建一个包含所有示例程序的单个项目。建议你在仔细阅读本书中内容的同时,下载、运行和学习这些配套代码。
让我们更仔细地看看main
方法中唯一的语句:
System.out.println("Hello, World!");
System.out
是一个对象,它是一个名为PrintStream
的类的实例(instance)。PrintStream
类有println
、print
等方法。这些方法被称为实例方法(instance method),因为它们对类的对象或实例进行操作。
若要在对象上调用实例方法,请使用点符号(dot notation)来表示:
object.methodName(arguments)
在这个例子中,main
方法只有一个参数,即字符串"Hello, World!"
。
让我们用另一个例子来试试,像"Hello, World!"
这样的字符串是String
类的实例。String
类有一个返回String
对象长度的length
方法。若要调用该方法,则需要再次使用点符号:
"Hello, World!".length()
length
方法是通过对象"Hello, World!"
调用的,且该方法没有参数。与println
方法不同,length
方法会返回一个结果。使用该返回结果的一种方法就是将它输出到屏幕:
System.out.println("Hello, World!".length());
一起来试试看。用这个语句来编写一个Java程序并运行它,看看字符串的长度是多少。
在Java中,需要自己构造(construct)大多数对象(不像System.out
和"Hello, World!"
这些对象,它们是已经存在的,可以直接使用)。下面是一个简单的示例。
Random
类的对象可以生成随机数。可以使用new
运算符来构造一个Random
对象:
new Random()
在类名之后的是构造参数列表,在这个例子中该列表是空的。
你可以在构造的对象上调用方法。例如:
new Random().nextInt()
这样就可以通过这个新构造的随机数生成器,生成下一个随机整数。
如果想在一个对象上调用多个方法,那么需要将对象存储在变量中(参见1.3节)。这里我们打印两个随机数:
Random generator = new Random();
System.out.println(generator.nextInt());
System.out.println(generator.nextInt());
注意:Random
类是在java.util
包中声明的。为了在程序中使用这个类,需要添加import
语句,示例如下。
package ch01.sec01;
import java.util.Random;
public class MethodDemo {
...
}
我们将在第2章中更详细地了解包和import
语句。
在1.1.2小节中,你看到了如何编译和运行一个Java程序。JShell程序提供了一个“读取—评估—打印循环”(read-evaluate-print loop,REPL)的方式,它允许你尝试Java代码而无须编译和运行程序。当输入Java表达式时,JShell会评估输入,并打印结果,然后等待下一次输入。如果要启动JShell,只须在终端窗口中输入jshell
,如图1-3所示。
图1-3 运行JShell
JShell以问候语开头,然后显示提示符:
| Welcome to JShell -- Version 17
| For an introduction type: /help intro
jshell>
现在可以输入一个Java表达式,例如:
"Hello, World!".length()
JShell会给你一个反馈,表示运行结果和下一个提示符:
$1 ==> 13
jshell>
需要注意的是,你并没有输入System.out.println
。JShell会自动打印输入的每个表达式的值。
输出中的$1
表示该结果可用于进一步的计算。例如,如果继续输入:
3 * $1 + 3
则JShell的反馈为:
$2 ==> 42
如果需要多次使用一个变量,那么可以给它指定一个更加容易记忆的名字。一定要遵循Java语法,同时指定类型和变量名(参见1.3节)。例如:
jshell> int answer = 42
answer ==> 42
可以让JShell替你填写类型。具体操作是,输入一个表达式,不要按Enter键,而是按住Shift+Tab组合键,然后按V键。例如,当输入:
new Random()
然后按Shift + Tab组合键和V键,你会看到:
jshell> Random = new Random()
这时的光标位于 =
符号之前。此时可以输入一个变量名,然后按Enter键:
jshell> Random generator = new Random()
generator ==> java.util.Random@3fee9989
此外,还有一个很棒的功能是Tab补全功能,例如输入:
generator.
随后按Tab键,你将会得到一个可以在generator
变量上调用的所有方法的列表:
jshell > generator.
doubles( equals( getClass()
hashCode() ints( isDeprecated()
longs( nextBoolean() nextBytes(
nextDouble( nextExponential() nextFloat(
nextGaussian( nextInt( nextLong(
notify() notifyAll() setSeed(
toString() wait(
现在输入ne
再按Tab键。方法名会补全为next
,并且你会得到一个更加简短的列表:
jshell> gengerator.next
nextBoolean() nextBytes( nextDouble(
nextExponential() nextFloat( nextGaussian(
nextInt( nextLong(
如果再按D键和Tab键,方法名会自动补全为nextDouble(
。再次按Tab键,将会看见3个备选:
Signatures:
double java.util.random.RandomGenerator.nextDouble(double bound)
double java.util.random.RandomGenerator.nextDouble(double origin, double bound)
double Random.nextDouble()
<press tab again to see documentation>
按)
键可以选择第3个版本:
jshell> gengerator.nextDouble()
$3 ==> 0.9560346568377398
注意:在自动完成列表中,需要参数的方法只能后跟左括号,如nextDouble(
,而不需要参数的方法有一对完整括号,如nextBoolean()
。
如果要重复运行命令,可以按 ↑ 键,直到看到要重新运行或编辑的行。可以用 ← 键和 → 键移动命令行中的光标,并添加或删除字符,完成后按Enter键。例如,按 ↑ 键并用Int
替换Double
,然后按Enter键:
jshell> generator.nextInt()
$4 ==> -352355569
默认情况下,JShell将会导入以下包:
java.io
java.math
java.net
java.nio.file
java.util
java.util.concurrent
java.util.function
java.util.prefs
java.util.regex
java.util.stream
这就是可以在JShell中使用Random
类而不需要任何导入语句的原因。如果需要导入其他类,可以在JShell提示符下输入导入语句。或者,更方便的是,通过按住Shift+Tab组合键和I键,可以让JShell搜索它。例如,输入Duration
,然后按住Shift+Tab组合键和I键,你将获得一个潜在操作的列表:
jshell> Duration
0: Do nothing
1: import: java.time.Duration
2: import: javafx.util.Duration
3: import: javax.xml.datatype.Duration
Choice:
输入1
,然后你将收到一个确认信息:
Imported: java.time.Duration
随后显示:
jshell> Duration
这样就完成了导入工作,然后就可以继续工作了。这些命令足以让你开始使用 JShell。要获得更加详细的帮助,输入/help
并按Enter键。如果要退出JShell环境,输入/exit
并按Enter键,或者只须按Ctrl+D组合键。
JShell 使得 Java 语言和相关库的学习变得轻松而有趣,且无须启用庞大的开发环境和编写复杂的public static void main
等代码。
尽管Java是一种面向对象的编程语言,但这也并不代表所有Java的值都是对象。Java中的一些值依然属于基本类型(primitive type)。基本类型中有4种类型是有符号整数类型;两种是浮点类型;一种是在字符串编码中使用的char
类型;另一种是表示真值的boolean
(布尔)类型。在下面的小节中我们将详细学习这些类型。
有符号整数类型适用于没有小数部分的数,可以是负数。Java提供了4种有符号整数类型,如表1-1所示。
表1-1 Java中的有符号整数类型
类型 |
存储容量 |
取值范围(含) |
|
1字节 |
−128~127 |
|
2字节 |
−32 768~32 767 |
|
4字节 |
−2 147 483 648~2 147 483 647 ( 刚好超过20亿) |
|
8字节 |
−9 223 372 036 854 775 808~9 223 372 036 854 775 807 |
注意:常量Integer.MIN_VALUE
与Integer.MAX_VALUE
分别是int
类型的最小值和最大值。此外,Long
、Short
和Byte
类也分别都有MIN_VALUE
和MAX_VALUE
常量。
在大多数情况下,int
类型是最实用的。但是如果想表示整个地球的居住人口数量,就需要使用long
类型了。byte
和short
类型主要用于特定的应用场合,例如,底层文件处理,或者存储空间有限的大数组。
注意:如果long
类型依然不够,那么可以使用BigInteger
类。参见1.4.6小节了解详细信息。
在Java中,整数类型的范围不取决于运行程序的机器。毕竟,Java是被设计为“一次编写,处处运行”的语言。相比之下,C和C++程序中的整数类型的大小还取决于编译该程序的处理器。
可以使用后缀L
来表示长整型字面量(例如,4000000000L
)。但是,byte
类型或short
类型的字面量无法通过后缀区分。这时需要使用强制转换符号(参见1.4.4小节)。例如,(byte)127
表示byte
类型。十六进制字面量具有前缀0x
(例如,0xCAFEBABE
)。二进制数值具有前缀0b
,例如,0b1001
是9
。
警告:八进制数值具有前缀0
,例如,011
是9
。但这样的形式可能会容易混淆,因此最好远离八进制字面量和0
开头的数值。
你可以在数字字面量中添加下划线进行长数字的分组,例如,使用1_000_000
(或0b1111_0100_ 0010_0100_0000
)来表示100万。但这里的下划线仅仅是为了让人更易阅读,Java编译器会删除它们。
注意:如果使用的整数值永远不会是负数,并且确实需要一个额外的数位来存储数据,那么可以将有符号整数值解释为无符号数,但是需要非常仔细。例如,一个byte
类型的值b
的表示范围通常是−128~127。如果想表示0~255的范围,仍然可以将其存储在byte
类型中。由于二进制算术运算的性质,如果不发生溢出,那么加法、减法和乘法都是可以正常工作的。对于其他运算,可以调用Byte.toUnsignedInt(b)
来获得0~255的int
类型的值,然后就可以处理整数值,并将结果强制转换回byte
类型。Integer
和Long
类也有用于处理无符号数的除数和余数的方法。
浮点类型表示有小数部分的数值。Java中的两种浮点类型如表1-2所示。
表1-2 浮点类型
类型 |
存储容量 |
取值范围 |
|
4字节 |
−3.40282347E+38F~+3.40282347E+38F(6~7位十进制有效数字) |
|
8字节 |
−1.79769313486231570E+308~+1.79769313486231570E+308(15位十进制有效数字) |
很多年前,当内存还是一种稀缺资源时,4 字节的浮点数是最常用的。但现在 7 位有效数字已经不太适用了,因此“双精度”数是系统的默认值。只有当需要存储大量的浮点数时,使用float
类型才有意义。
float
类型的数值有一个后缀F
(例如,3.14F
);没有后缀F
的浮点数(例如,3.14
)是double
类型的。当然,你可以选择使用后缀D
(例如,3.14D
)来表示double
类型的数值。
注意:你可以用十六进制来表示浮点数。例如,0.0009765625 = 2−10,也可以写成0x1.0p-10
。在十六进制符号中,你需要使用p
而不是e
来表示指数。(因为e
是一个十六进制数字。)请注意,即使数字是十六进制的,但指数(即2的幂)也需要使用十进制。
Java中有一些特殊的浮点值:Double.POSITIVE_INFINITY
表示∞;
Double
.NEGATIVE_INFINIY
表示−
∞
;Double.NaN
表示“非数值”。例如,算式1.0/0.0
的结果是正无穷大。算式0.0/0.0
或负数的平方根会生成NaN。
警告:所有“非数值”都会被认为是各不相同的。因此,你不能使用条件测试语句if (x == Double.NaN)
来检查 x
是否为 NaN。而是应该使用 if (Double.isNaN(x))
来判断。此外,也应当使用Double.isInfinite
来测试±
∞,用Double.isFinite
来检查一个浮点数既不是无穷也不是NaN。
浮点数并不适用于金融计算的场景,因为它在计算中发生的一些舍入误差对金融领域来讲可能是无法容忍的。例如,System.out.println(2.0 - 1.7)
将会打印出0.30000000000000004
,而不是你所期望的0.3
。这种舍入误差是由浮点数在二进制系统中的表示规则造成的。此外,小数3/10也没有精确的二进制表示,就像十进制系统中1/3没有精确的表示一样。如果你需要任意精度且没有舍入误差的数值计算,可以使用1.4.6小节中介绍的BigDecimal
类。
char
类型char
类型描述了Java使用的UTF-16字符编码中的“代码单元”。有关的详细信息颇有一些技术难度,请参见1.5节。你可能不会经常使用char
类型。
偶尔你可能会遇到用单引号括起来的字符字面量。例如,'J'
是值为 74(或十六进制4A)的字符字面量,表示Unicode字符“U+004A,拉丁大写字母J”的代码单元。这里的代码单元可以用十六进制表示,并使用\u
作为前缀。例如,'\u004A'
与'J'
相同。更奇特的例子是'\u263A'
,☺的代码单元,“u+263A白色笑脸”。
此外一些特殊的代码,例如'\n
'
、
'\r'
、
'\t'
、
'\b'
分别表示换行、回车、制表和退格。
如果需要使用'\'
则需要使用两个斜杠表示'\\'
。
b
oolean
类型boolean
(布尔)类型有两个值:false
和true
。
在Java中,boolean
类型不是数值类型。boolean
类型的值与整数类型中的0
和1
并没有任何关系。
在下面的小节中,你将学习如何声明和初始化变量和常量。
Java是一种强类型语言。每个变量只能保存一种特定类型的值。声明变量时,需要指定变量的类型、名称和一个可选的初始值。例如:
int total = 0;
你可以在一个语句中声明相同类型的多个变量:
int total = 0, count; // count is an uninitialized integer
但是,大多数的Java程序员都喜欢单独声明每个变量。
一起来看下面这个变量声明:
Random generator = new Random();
在这个声明中,对象的类的名称出现了两次。其中,第一个Random
表示的是变量generator
的类型;第二个Random
是构造该类的对象的new
表达式的组成部分。
为了避免这种重复,可以使用var
关键字:
var generator = new Random();
现在,变量的类型是初始化该变量的表达式的类型。在这个例子中,g
enerator
是一个类型为R
andom
的变量。
当声明变量的类型非常明显时,本书会使用var
关键字。
变量、方法或类的名称统称为标识符(identifier)。在Java中,标识符必须以字母开头,由任意字母、数字、_
符号和$
符号组成。但是,$
符号用于自动生成的标识符,因此,你不应该直接使用它。最后,_
符号本身并不是有效的标识符。
在Java中,字母和数字可以来自任何字母,而不仅仅是拉丁字母。例如,π
和élévation
也是有效的标识符。此外,标识符区分字母的大小写,count
和Count
是不同的标识符。
你不能在标识符中使用空格或符号。最后,也不能使用double
等关键字作为标识符。
按照惯例,变量和方法的名称以小写字母开头,类的名称以大写字母开头。Java程序员喜欢使用“驼峰式拼写法”(camel case,也称为骆驼式拼写法),即当名称由多个单词组成时,使用大写字母标识每一个单词的首字母,如countOfValidInputs
。
当在一个方法中声明变量时,必须先对其进行初始化,然后才能使用它。例如,以下代码会导致编译时错误:
int count;
count++; // Error—uses an uninitialized variable
编译器必须能够验证变量在使用之前是否已经初始化。例如,以下代码也是一种错误:
int count;
if (total == 0) {
count = 0;
} else {
count++; // Error—count might not be initialized
}
Java允许在方法中的任何位置声明变量。在第一次需要使用变量之前,尽可能晚地声明变量被认为是一种较好的编程的风格。例如:
var in = new Scanner(System.in); // See Section 1.6.1 for reading input
System.out.println("How old are you?");
int age = in.nextInt();
变量在其初始值可用时声明即可。
final
关键字表示赋值后不能被再次更改的值。在其他的语言中,通常可以将这样的值称为常量(constant)。例如:
final int DAYS_PER_WEEK = 7;
按照惯例,常量的名称应当全部使用大写字母。
你也可以使用static
关键字来声明一个在方法外的常量:
public class Calendar {
public static final int DAYS_PER_WEEK = 7;
...
}
这样一来,该常量就可以在多个方法中被使用。在Calendar
内部,你可以通过DAYS
_PER_WEEK
来表示该常量。但是,若要在另一个类中使用该常量,需要在该常量之前加上类名,即Calendar
. DAYS_PER_WEEK
。
注意:System
类中声明了一个常量,如下所示。
public static final PrintStream out
这样,可以在任何地方通过System.out
的形式使用它。它也是少数几个没有用大写字母表示的常量之一。
延迟final
变量的初始化是合法的,只需要在首次使用它之前初始化即可。例如,以下代码是合法的:
final int DAYS_IN_FEBRUARY;
if (leapYear) {
DAYS_IN_FEBRUARY = 29;
} else {
DAYS_IN_FEBRUARY = 28;
}
这也就是称它为“最终”变量的原因。一旦赋值,它就是最终变量,永远无法更改。
注意:有时,你需要一组相关的常量,示例如下。
public static final int MONDAY = 0;
public static final int TUESDAY = 1;
...
在这种情况下,你可以定义一个枚举(enumeration)。
enum Weekday { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
SATURDAY, SUNDAY };
这样,Weekday
就是一种带有Weekday.MONDAY
等数值的类型。下面是如何声明和初始化Weekday
变量。
Weekday startDay = Weekday.MONDAY;
我们将在第4章详细讨论枚举。
Java使用任何基于C的语言中常见的运算符,如表1-3所示,我们将在下面的小节学习如何使用它们。
表1-3 Java运算符
运算符 |
结合性 |
|
左结合 |
|
右结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
左结合 |
|
右结合 |
注意:在本表中,运算符是按照优先级递减的顺序排列的。例如,由于 +
的优先级高于 <<
,因此3
+ 4 <<5
等价于(3
+ 4) << 5
。当一个运算符是从左向右进行分组时,我们称它是左结合的。例如,3
– 4 − 5
表示(3 − 4) − 5
。但 −=
是右结合的,例如I −= j −= k
表示 i −= (j −= k)
。
表1-3中的最后一行表示赋值运算符,例如:
x = expression;
以上语句会将x
的值设置为右侧表达式的值,同时替换掉x
之前的数值。
赋值是一个带有值的运算,具体来讲就是所赋的那个值。因此,在另一个表达式中使用赋值运算是合法的。例如:
(next = in.read()) != -1
以上语句中,next
被in.read()
的返回值赋值,如果该值不是−1,则赋值运算的值就不是−1,最后整个表达式的值为true
。
当 =
前面有另一个运算符时,该运算符将左侧和右侧组合起来,并计算得到结果。例如:
amount -= fee
等同于
amount = amount - fee;
加法、减法、乘法和除法分别用 +
、
−
、
*
和 /
表示。例如,2
* n + 1
表示将2
和n
相乘再加1
。
使用 /
运算符时,一定要小心。如果两个操作数都是整数类型,它表示整数除法,将得到整数结果并丢弃余数。例如,17
/ 5
是 3
,而17.0
/ 5
是3.4
。
整数除以零会产生一个异常,如果未捕捉到该异常,则程序会终止运行。(有关异常处理的更多信息,参见第5章。)一个浮点数除以零会生成一个无穷或NaN(参见1.2.2小节),并且不会导致异常。
使用 %
运算符将会得到余数。例如,17 % 5
的结果是2
,即17减去15(5的最接近17的整数倍)后的余数。如果a % b
的结果为零,则a
是b
的整数倍。
%
运算符的一个基本用途就是测试整数是否为偶数。如果n
为偶数,则表达式n % 2
的结果为0
。如果n
是奇数呢?这时,如果n
为正,则n % 2
为1
;如果n
为负,则n % 2
为 −1
。在实践中,处理负数是比较复杂的。当 %
运算符与那些可能为负的操作数一起使用时,一定要注意这些问题。
考虑一下这个问题。你需要计算一个时钟的时针位置。你需要调整时针,并将其标准化为一个介于0~11的数字。那么处理起来很简单:使用(position + adjustment) % 12
即可。但是如果adjustment
使时针的位置为负呢?那么你可能会得到一个负数。所以这时你必须引入一个分支,或者使用((position + adjustment) % 12 + 12) % 12
。不管怎样,这样处理都很麻烦。
提示:在这种情况下,使用Math.floorMod
方法会更容易。
Math.floorMod(position + adjustment, 12)
将总是产生一个介于0
和11
之间的值。但遗憾的是,floorMod
针对负除数的运算也会给出负数的结果,但这种情况在实际应用中并不常见。
Java有递增和递减运算符:
n++; // Adds one to n
n--; // Subtracts one from n
和其他基于C的语言类似,这些运算符也有前缀形式。n++
和++n
都会使变量n
的值递增,但在表达式中使用时,它们可能会有不同的值。第一种形式在n
递增之前生成表达式的值,第二种形式则在n
递增之后生成表达式的值。例如:
String arg = args[n++];
以上语句将arg
设置为args[n]
,然后再使n
递增。大概30年前,当编译器不能很好地优化代码时,这样做是有意义的。但是如今,使用两条单独的语句并不会产生性能上的差异,并且许多程序员认为显式的形式更容易阅读。
在Java中,没有运算符能够实现幂运算。因此需要调用Math.pow()
方法来实现:Math.pow(
x, y)
将得到x
y
。如果要计算x
的平方根,则需要调用Math.sqrt(x)
。这些方法都是静态方法,因此与static
常量一样,只需要在方法前添加类名,并不需要通过对象来调用这些方法。
此外,比较常用的还有Math.min()
和Math.max
()
方法,可用于计算两个值的最小值和最大值。
此外,Math
类还提供了三角函数、对数函数,还有常量Math.PI
和常量Math.E
。
注意:Math
类提供了几种方法以确保整数的算术运算更安全。当计算溢出时,算术运算符会悄悄返回一个错误的结果。例如,10亿乘以3(1000000000 * 3
)计算得到的结果为−1294967296
,这是因为最大的in
t
值恰好刚刚超过20亿。如果你调用Math.multiplyExact(1000000000, 3)
,那么将会产生一个异常。你可以捕获该异常,或者让程序终止,而不是使用错误的计算结果,并让程序继续运行。此外,还有addExact
、
subtractExact
、
increment
Exact
、
decrement
Exact
、
negateExact
等方法,它们都使用int
和long
作为参数。
在其他类中也有一些数学方法。例如,Integer
类和Long
类中有compareUnsigned
、
divideUnsigned
和 remainderUnsigned
方法来处理无符号数。
当运算符的操作数是不同的数值类型时,在运算之前,这些数值会自动转换为一个通用的类型。转换是按照以下顺序进行的。
● 如果两个操作数中有一个为double
类型,则另一个将转换为double
类型。
● 如果其中一个操作数是float
类型,则另一个将转换为float
类型。
● 如果其中一个操作数是long
类型,则另一个将转换为long
类型。
● 否则,两个操作数都转换为int
类型。
例如,如果计算3.14 + 42
,那么第二个操作数将会从int
类型转换为double
类型的值42.0
,然后进行加法计算,得到double
类型的值45.14
。
如果计算 'J' + 1
,则char
类型的值'J'
将被转换成为int
类型的值74
,最后结果为int
类型的值75
。至于如何将该值转换回char
类型,还需要继续阅读后面的内容。
当你将数值类型的值赋值给一个变量,或将其作为参数传递给一个方法时,如果类型不匹配,则必须转换该值的类型。例如,在以下赋值中,值42
会自动从int
类型转换为double
类型。
double x = 42;
在Java中,如果没有精度损失,那么将发生以下形式的转换:
● 从byte
类型到short
类型、int
类型、long
类型,或者double
类型;
● 从short
类型和char
类型到int
类型、long
类型,或者double
类型;
● 从int
类型到long
类型或者doub
le
类型。
所有的整数类型都会被转换成为浮点类型。
警告:以下转换可能会丢失精度信息。
● 从int
类型到float
类型。
● 从long
类型到float
类型或double
类型。
例如,考虑以下赋值:
float f = 123456789;
因为float
类型只有7位有效数字,所以f
实际上是1.23456792E
8
。
为了实现这些自动转换之外的类型转换,需要使用强制类型转换运算符,强制类型转换的语法格式是在圆括号中指定目标类型的名称。例如:
double x = 3.75;
int n = (int) x;
在这种情况下,小数部分将会被舍弃,n
会被设置为3
。
如果想四舍五入到最接近的整数,可以使用Math.round
方法,该方法返回一个long
类型的值。如果明确知道结果更加适合int
类型,可以调用:
int n = (int) Math.round(x);
在这个示例中,x
为3.75
,n
被设置为4
。
如果需要将整数类型转换为另一个字节更少的类型,也需要使用强制转换:
int n = 1;
char next = (char)('J' + n); // Converts 75 to 'K'
在这种强制转换中,只保留最后的字节,例如:
int n = (int) 3000000000L; // Sets n to -1294967296
注意:如果担心强制类型转换会无警告地丢弃数值的重要部分,那么可以使用Math.to
IntExact
方法。当该方法无法将long
类型转换为int
类型时,就会产生异常。
==
和 !
=
运算符的功能是测试相等性。例如,当n
不为0
时,表达式n
!= 0
的值为true
。此外,<
(小于)、>
(大于)、<=
(小于或等于)和 >=
(大于或等于)运算符都是常用的运算符。
你也可以将boolean
类型的表达式与 &&
(与)、||
(或)和 !
(非)运算符组合。例如:
0 <= n && n < length
当n
介于0
(包含0
)和
length
(不包含length
)之间时,表达式为真。
以上表达式中,如果第一个条件为false
,则第二个条件不会被计算。如果第二个条件可能会产生错误,那么这种“短路”测试的方式非常有用。考虑以下条件:
n != 0 && s + (100 - s) / n < 50
如果n
为0
,那么第二个条件,即其中包含除n
运算的条件永远不会被求值,因此也不会出现除数为0
的计算错误。
短路测试也可以用于“或”运算,只要一个操作数为true
时,其余的求值就会停止,即不计算第二个条件。例如:
n == 0 || s + (100 - s) / n >= 50
如果n
为0
,则以上表达式将得到true
,并且第二个条件不会被计算。
最后,条件(conditional)运算符接受3个操作数:一个条件和两个值。如果条件为true
,整个表达式的结果是第一个值,否则是第二个值。例如:
time < 12 ? "am" : "pm"
表示如果t
ime < 12
为真,则得到字符串"am"
,否则得到字符串"pm"
。
注意:Java还有位运算符 &
(位与)、|
(位或) 、^
(位异或)。它们是针对整数,按照位模式进行运算的。例如,由于0xF
的二进制数字是0
...01111
,因此n & 0xF
就会得到n
的二进制数字中的最低4位; n = n | 0xF
将会将n
的二进制值的最低4位设置为1
; n =
n ^ 0xF
则将翻转n
的最低4位。与 !
运算符类似的位运算符是 ~
,它的功能是翻转操作数的所有位,即~0xF
的结果是1
...10000
。
此外,还有在位模式下向左或向右移位的运算符。例如,0xF << 2
将得到二进制数字为0
...0111100
。Java中共有两个右移运算符,其中 >>
是将符号位扩展到顶部,而 >>>
则会用0
来填充顶部的符号位。如果你在程序中进行移位运算,那么你必须要知道这意味着什么;如果你并不了解移位运算,那么也就表明你可能不需要使用这些运算符了。
警告:如果移位运算符号的左操作数是int
类型,那么右操作数的模是32
;如果左操作数是long
类型,那么右操作数的模是64
。例如,1
<< 35
的值与1
<< 3
或8
相同。
提示:当 &
(位与)和 |
(位或)运算符应用于boolean
值时,在计算结果之前将会对两个操作数进行强制求值。当然,这种用法非常罕见。加入右操作数没有副作用,它们就像 &&
和 ||
一样,只是效率较低。除非确实需要强制对第二个操作数求值,并将其赋值给一个b
oolean
变量,以使得执行流程清晰可见。
如果基本类型的整数和浮点数的精度无法满足实际需求,那么可以使用java.math
包中的BigInteger
类和BigDecimal
类。这些类的对象可以表示具有任意长度数字序列的数值。BigInteger
类可以实现任意精度的整数运算,BigDecimal
也可以对浮点数实现同样的功能。当然,使用大数的计算效率远远低于使用基本数据类型的计算效率。
静态方法valueOf
可以将long
类型转换为BigInteger
:
BigInteger n = BigInteger.valueOf(876543210123456789L);
你还可以从数字形式的字符串中构造一个BigInteger
的对象:
var k = new BigInteger("9876543210123456789");
此外还有一些预定义的常量,例如,BigInteger.ZERO
、
BigInteger.ONE
、
BigInteger
.TWO
和BigInteger.TEN
。
Java不允许对象使用运算符,因此必须调用对应的方法来处理大数的运算。
BigInteger r = BigInteger.valueOf(5).multiply(n.add(k)); // r = 5 * (n + k)
1.2.2 小节中,你看到了浮点数减法 2.0 - 1.7
的结果为0.30
000000000000004
。使用BigDecimal
类可以计算出精确结果。
调用BigDecimal.valueOf(n, e)
将返回一个值为n
×
10
−e
的BigDecimal
实例。例如,以下方法调用将准确地得到结果0
.3
:
BigDecimal.valueOf(2, 0).subtract(BigDecimal.valueOf(17, 1))
字符串是一个字符的序列。在Java中,字符串可以包含任意的Unicode字符。例如,字符串"Java™"
或"Java\u2122"
由5个字符构成,分别是J
、
a
、
v
、
a
和 ™
。其中最后一个字符是“U+2122,注册商标”。
使用 +
运算符可以拼接两个字符串。例如:
String location = "Java";
String greeting = "Hello " + location;
以上两条语句将greeting
设置为字符串"Hello Java"
。(注意第一个操作数末尾的空格。)
当你将一个字符串与另一个值拼接时,该值将会转换为字符串:
int age = 42;
String output = age + " years";
现在字符串output
是"42 years
"
。
警告:如果混合使用拼接和加法运算,那么可能会得到意想不到的结果。示例如下。
"Next year, you will be " + age + 1 // Error
首先,字符串拼接age
,然后再拼接1
,因此,最后得到的结果是"Next year, you will be 421"
。在这种情况下,需要使用括号。
"Next year, you will be " + (age + 1) // OK
如果要组合多个字符串,并使用分隔符将他们分隔开,请使用join
方法:
String names = String.join(", ", "Peter", "Paul", "Mary");
// Sets names to "Peter, Paul, Mary"
join
方法的第一个参数是分隔符字符串,后面是要拼接的字符串。它们的数量可以是任意多个,你也可以以字符串数组传递参数(数组在1.8节中有介绍)。如果需要连接大量的字符串,那么这种方法的效率会有些低。在这种情况下,请使用StringBuilder
来代替join
方法:
var builder = new StringBuilder();
while (more strings) {
builder.append(next string);
}
String result = builder.toString();
如果拆分字符串,可以使用substring
方法。例如:
String greeting = "Hello, World!";
String location = greeting.substring(7, 12); // Sets location to "World"
substring
方法的第一个参数是要提取子串的起始位置,位置从0
开始表示。
第二个参数是不包含子串的第一个位置。在以上的示例中,greeting
的第12
个位置是 !
,这个是我们不需要的字符的位置。该方法需要指定一个不需要的字符的位置,这可能看起来很奇怪,但这样做有一个优点:12 – 7
将会是这个子串的长度。
有时,你可能希望从一个由分隔符分隔的字符串中提取所有子串。split
方法将能够实现这个功能,并返回一个由子串组成的数组。
String names = "Peter, Paul, Mary";
String[] result = names.split(", ");
// An array of three strings ["Peter", "Paul", "Mary"]
这里的分隔符可以是任何正则表达式(参见第9章)。例如,input.split("\\s+")
将在空白处拆分input
字符串。
如果要比较两个字符串是否相等,请使用equals
方法。例如:
location.equals("World")
当location
字符串恰好为"World"
时,表达式将返回true
。
警告:永远不要使用 ==
运算符来比较字符串。在下面的比较中,仅当location
和"World"
在内存中是完全相同的对象时才能返回true
。
location == "World" // Don’t do that!
即在虚拟机中,每个字符串字面量只用一个实例,因此只有"World" =
= "World"
才能为true
。但如果location
是被计算得到的,例如,
String location = greeting.substring(7, 12);
那么结果将会被放置到一个单独的String
对象中,location == "World"
将返回false
!
与其他任何对象一样,String
变量可以是null
。null
表示这个变量不指向任何对象,甚至不指向一个空字符串。
String middleName = null;
如果要测试一个对象是否为null
,可以使用 == 运算符:
if (middleName == null) ...
需要注意的是,null
与空字符串""
不同。空字符串是长度为零的字符串,而null
表示根本不存在任何字符串。
警告:针对null
调用任何方法都会导致“空指针异常”。和所有异常一样,如果你不显式地处理它,该异常会中断程序的运行。
提示:当将字符串与字符串字面量进行比较时,最好将字符串字面量放在前面,示例如下。
if ("World".equals(location)) ...
这样的优势在于,即使l
ocation
为null
,该测试也能正常工作。
如果在比较两个字符串时需要忽略字符的大小写,可以使用equalsIgnoreCase
方法。例如:
"world".equalsIgnoreCase(location);
当location
是"World"
、
"world"
或者"WORLD"
等情况时,都会返回true
。
有时,你可能需要将字符串按顺序排列。调用compareTo
方法可以判断两个字符串是否按字典顺序排列:
first.compareTo(second)
如果first
在second
之前,那么该方法返回一个负整数(不一定是−1
);如果first
在s
econd
之后,则返回正整数(不一定是1
);如果两者相等,则返回0
。
compareTo
方法会依次比较每一个字符,直到其中一个字符串到达末尾,或者两个字符串不匹配。例如,当比较"
wor
d"
和"
wor
ld"
时,前3个字符是匹配的,第4个字符d
的Unicode值小于l
。因此,"word"
字符串在前。所以"word".compareTo("world")
返回−8
,该值是d
和l
的Unicode值之间的差。
这种比较方式对很多人来说可能不是很直观,因为它取决于字符的 Unicode 值的大小。例如,"blue/green"
在"bluegreen"
之前,因为字符 /
的Unicode值恰好小于g
的Unicode值。
提示:在对相对比较容易阅读的字符串进行排序时,可以使用支持特定语言排序规则的Collator
对象。有关更多信息参见第13章。
要将整数转换为字符串,可以调用静态Integer.toString
方法:
int n = 42;
String str = Integer.toString(n); // Sets str to "42"
这个方法也可以有第二个参数,即一个基数(范围为2~36):
String str2 = Integer.toString(n, 2); // Sets str2 to "101010"
注意:更简单地将整数转换为字符串的方法是用空字符串和整数拼接,例如:"" + n
。但是有些人认为这样的代码很不美观,且效率稍低。
相反地,如果要将包含整数的字符串转换成为数值,那么可以使用Integer.parseInt
方法:
String str = "101010";
int n = Integer.parseInt(str); // Sets n to 101010
同样地,该方法也可以指定转换基数:
int n2 = Integer.parseInt(str, 2); // Sets n2 to 42
对于浮点数和字符串之间的相互转换,可以使用Double.toString
和Double.parse
Double
方法:
String str = Double.toString(3.14); // Sets str to "3.14"
double x = Double.parseDouble(str); // Sets x to 3.14
就像你期望的那样,String
类定义了大量的方法。表1-4列出了一些经常使用的方法及其功能。
表1-4 String类常用方法
方法 |
功能 |
|
检查字符串是否以给定的字符串开头、结尾,或是否包含给定字符串 |
|
获取 |
|
将所有出现的 |
|
将原始字符串中的所有字符转换为大写或小写,并返回新字符串 |
|
返回通过删除所有前导空格和末尾空格获得的新字符串 |
需要注意的是,在Java中,String
类是不可变(immutable)的。也就是说,String
的众多方法中没有一个方法能够修改字符串本身内容。例如,
greeting.toUpperCase()
将返回一个新字符串"HELLO,WORLD!"
,但并不会改变greeting
。同样需要注意的是,有些方法具有CharSequence
类型的参数。这是String
、
StringBuilder
和其他字符序列的通用超类。如果需要查询每个String
方法的详细描述,请参阅在线Java API文档。在搜索框中输入类名并选择匹配的类型即可得到如图1-4所示的信息(在本例中为java.lang.String
)。
图1-4 检索API文档
随后,你将会获得一个记录每个方法的页面,如图1-5所示。当然,如果你碰巧知道某个方法的名称,可以直接在搜索框中输入方法的名称进行检索。
图1-5 API文档中的String
方法
本书没有详细介绍API的具体细节,因为很多时候直接浏览API文档会更快捷。如果你不能保证总是可以连接到互联网,那么你可以下载并解压离线文档,进行脱机浏览。
Java第一次发布时,就非常自豪地采纳了同样是新生事物的Unicode标准。Unicode标准旨在解决字符编码这个非常棘手的问题。在Unicode之前,有许多互相不兼容的字符编码。以英语为例,有几乎可以作为通用标准的7位ASCII编码标准,该标准为所有英文字母、十进制数字和许多符号分配了介于0~127的编码。在西欧,ASCII还被扩展为8位代码,用来容纳类似ä和é等重音字符。在俄罗斯,ASCII也同样被扩展,俄罗斯使用128~255的位置表示一些斯拉夫字符。在日本,通常使用可变长度编码对英语和日语字符进行编码。此外,还有多种不兼容的中文字符编码也在被广泛使用。总之,在使用不同编码的情况下交换文件是一个很困难的问题。
Unicode通过介于0~65535的唯一的16位编码,来对所有书写系统的每个字符分配唯一的编码,来解决困扰大家已久的字符编码问题。1991年,Unicode 1.0发布,该标准使用了略少于半数的有效65536编码。Java从一开始就被设计成使用16位Unicode字符的系统,这一点对比其他使用传统8位字符编码的编程语言,是一个重大进步。但随后又发生了一些尴尬的事情,即汉字的数量远远超过了之前的预估值,这就迫使Unicode必须使用超过16位的编码方案。
如今,Unicode需要21位进行编码。每个有效的Unicode值称为码点(code point),其基本形式为U+与其后的4个或多个十六进制的数字。例如,字符A的码点是U+0041,而表示八元数集合的数学符号的码点是U+1D546 。
还有一种更加清楚的方式来表示码点,例如使用int
值,但这显然是非常浪费的。Java使用一种变长的编码形式,称为UTF-16,它将所有“经典”的Unicode字符表示为单个16位的值,此外对于所有超过U+FFFF的字符编码,都需要通过一个16位的值组合配对表示,这个16位的值表示一个特殊的代码区域,通常被称为“代理字符”。在UTF-16编码中,字符A可以通过一个char
值来表示,记作\u0041
;而会被记作一对char
值\ud835\udd46
。
换句话说,char
并不是Unicode字符或码点。它只是一个代码单元(code unit),是UTF-16编码中所使用的一个16位的量。
如果你并不使用中国的汉字,并且愿意把等特殊字符抛在脑后的话,那么字符串是一个Unicode字符序列的事情就对你没有太大影响,你当它是一个神话传说就行。在这种情况下,可以这样获得第 i
个字符:
char ch = str.charAt(i);
也可以这样获取字符串的长度:
int length = str.length();
但是如果你想正确地处理字符串,那么必须工作得更加辛苦一些。例如,要获取Unicode的第i
个码点,需要调用:
int codePoint = str.codePointAt(str.offsetByCodePoints(0, i));
码点总数为:
int length = str.codePointCount(0, str.length());
循环提取每一个码点:
int i = 0;
while (i < s.length()) {
int cp = sentence.codePointAt(i);
i += Character.charCount(cp);
... // Do something with cp
}
或者,也可以使用codePoints
方法来生成一个int
值的流(stream),这样每个int
值都对应一个码点。我们将在第8章中讨论流。你也可以将流转换为一个数组,如:
int[] codePoints = str.codePoints().toArray();
注意:过去,字符串总是在内部采用UTF-16编码表示,以char
值数组的形式来表示。现在,String
对象会尽可能地以ISO-8859-1字符的byte
数组的形式来表示。未来版本的Java内部可能会改用UTF-8。
使用文本块语法可以更加方便地提供跨行形式的字符串文本。文本块以"""
开头,后面可以直接使用换行符,结尾则使用另一个"""
来标记:
String greeting = """
Hello
World
""";
greeting
字符串包含两个换行符:一个在Hello
之后,另一个在World
之后。字符串文本中不包含起始的"""
标记后的换行符。
如果你不希望在最后一行使用换行符,那么可以将终止标记符"""
放在最后一个字符之后:
String prompt = """
Hello, my name is Hal. \
Please enter your name:""";
在任何一行的末尾,你都可以在行末添加反斜杠作为禁止换行的标志:
String prompt = """
Hello, my name is Hal. \
Please enter your name:""";
这样,字符串内就不包含任何换行符了。
文本块特别适用于一些包含其他语言代码的情况,例如SQL或HTML。只须将其粘贴在一对三重引号之内:
String html = """
<div class="Warning">
Beware of those who say "Hello" to the world
</div>
""";
需要注意的是,使用文本块时,你可以不用转义引号。但是,文本块中还是有两种特殊情况需要转义引号:
● 文本块以引号结尾;
● 文本块包含3个或更多引号。
遗憾的是,文本块中你仍然需要转义所有反斜杠。
常规字符串中的所有转义序列在文本块中的使用方式都相同。
可以通过删除末尾的空格,或者将Windows系统的换行符(\r\n
)更改为更加简单的换行符(\n
)的方式来规范文本的换行符。如果你仍旧需要保留末尾的空格,请将最后一个空格转换为\s
转义符。以下字符串就以两个空格结尾:
String prompt = """
Hello, my name is Hal.
Please enter your name: \s""";
对于前导空格来说,事情就更加复杂了。考虑一个典型的变量声明,需要从左边距进行缩进。可以缩进文本块:
String html = """
<div class="Warning">
Beware of those who say "Hello" to the world
</div>
""";
这样就会去除文本块中所有行共有的最长前导空格序列。实际字符串是:
"<div class=\"Warning\">\n Beware of those who say \"Hello\" to the world\n</div>\n"
注意,第一行和第三行中没有缩进。
文本块终止标记符"""
之前的空格非常重要。但是,在删除缩进的过程中,整行的空格并不会被压缩。
警告:作为前缀的空格必须与文本块中的所有行完全匹配。如果混合使用制表符和空格,你可能会发现删减的空格会比预期的少。
为了让示例程序更加生动有趣,它们应该能够与用户进行交互。在下面的小节中,你将了解如何读取终端输入,以及如何实现格式化输出。
当调用System.out.println
时,输出被发送到“标准输出流”,从而在终端窗口中显示出来。如果要从“标准输入流”读取信息则没那么简单,因为对应的System.in
对象只有一些读取单个字节的方法。为了读取字符串和数字,还需要构造一个能够连接到System.in
对象的Scanner
:
var in = new Scanner(System.in);
nextLine
方法能够读取一整行输入:
System.out.println("What is your name?");
String name = in.nextLine();
这里使用nextLine
方法的主要原因是输入中可能包含空格。如果要读取由空格分隔的单个单词,需要调用:
String firstName = in.next();
如果要读取整数,可以使用nextInt
方法:
System.out.println("How old are you?");
int age = in.nextInt();
类似地,你也可以使用nextDouble
方法读取下一个浮点数。可以使用hasNextLine
、
hasNext
、
hasNextInt
和hasNextDouble
方法检查是否有新的行、单词、整数或浮点数可用:
if (in.hasNextInt()) {
int age = in.nextInt();
...
}
Scanner
类位于java
.util
包中,为了使用这个类,应当在程序的开头添加以下代码:
import java.util.Scanner;
提示:如果要读取密码,你可能就不会想使用Scanner
类了,因为Scanner
类会使得输入在终端中可见。建议使用Console
类,示例如下。
Console terminal = System.console();
String username = terminal.readLine("User name: ");
char[] passwd = terminal.readPassword("Password: ");
这样用户输入的密码将以字符数组形式返回。这比将密码存储在String
中更安全,因为可以在读取操作完成后重新处理数组。
提示:如果你想从文件中读取输入或将输出写入文件,可以使用shell的重定向语法。
java mypackage.MainClass < input.txt > output.txt
现在System.in
将会从input.txt
中读取信息,System.out
向output
.txt
中写入信息。你将在第9章中看到如何执行更加通用的文件输入和输出操作。
你已经看到了System.out
对象中的println
方法,可以用于编写一行输出。此外还有一种print
方法,该方法的输出不会每次输出都开始新的一行。该方法通常用于输入提示:
System.out.print("Your age: "); // Not println
int age = in.nextInt();
这样光标将会停留在提示信息之后,而不是下一行。
当你使用print
或println
方法打印一个小数时,除末尾的零以外的所有数字将会被显示。例如:
System.out.print(1000.0 / 3.0);
将会打印:
333.333333333333
但是,如果你想显示美元和美分,这就会是一个问题了。为了限制输出位数,可以这样使用printf
方法:
System.out.printf("%8.2f", 1000.0 / 3.0);
格式化串(format string)"%8.2f"
表示的含义是,以8个字段宽度(field width)和2位精度(precision)的形式打印浮点数。也就是说,最终打印输出中会包含2个前导空格和6个字符:
333.33
你还可以为printf
提供多个参数。例如:
System.out.printf("Hello, %s. Next year, you'll be %d.\n", name, age);
每个以%
字符开头的格式说明符(format specifier)都将被替换为相应的参数。格式说明符的末尾是转换说明符(conversion character),表示要格式化的值的类型:f
表示浮点数,s
表示字符串,d
表示十进制整数。表1-5列出了所有转换说明符。
表1-5 格式化输出的转换说明符
转换说明符 |
功能 |
示例 |
|
十进制整数 |
|
|
十六进制整数(有关十六进制格式的更多信息,请使用 |
|
|
八进制整数 |
|
|
固定型浮点数 |
|
|
指数型浮点数 |
|
|
通用型浮点数:如果指数大于精度要求或指数 |
|
|
十六进制浮点数 |
|
|
字符串 |
|
|
字符 |
|
|
|
|
|
哈希码(参见第4章) |
|
|
日期和时间(已过时,参见第12章) |
|
|
百分号 |
|
|
平台相关的行分隔符 |
|
此外,你可以指定标志符来控制格式化输出的外观。表1-6列出了所有标志符。
表1-6 格式化输出标志
标志符 |
功能 |
示例 |
|
打印正数或者负数的标志 |
|
空格 |
在正数前添加空格 |
|
|
左对齐标记 |
|
|
添加前导0 |
|
|
将负值括在括号中 |
|
|
使用分组符号 |
|
|
始终包含小数点 |
|
|
添加 |
|
|
指定要格式化的参数的索引。例如, |
|
|
格式化前面说明的数值。例如, |
|
例如,逗号标志可以添加分组分隔符,+ 符号会为正数添加正数符号。以下语句:
System.out.printf("%,+.2f", 100000.0 / 3.0);
将会打印
+33,333.33
你也可以使用formatted
方法创建格式化字符串,而无须打印它:
String message = "Hello, %s. Next year, you'll be %d.\n".formatted(name, age);
在以下几小节中,你将学习如何实现分支和循环。Java 语言的这部分语法与其他常用语言(特别是C/C++和JavaScript)非常相似。
if
语句在圆括号内有一个分支条件,后面会有一个语句或一组括在花括号中的语句:
if (count > 0){
double average = sum / count;
System.out.println(average);
}
你也可以添加一个else
分支以在条件不满足时运行该分支:
if (count > 0) {
double average = sum / count;
System.out.println(average);
} else {
System.out.println(0);
}
else
分支的语句中也可以再添加另外一个if
语句:
if (count > 0) {
double average = sum / count;
System.out.println(average);
} else if (count == 0) {
System.out.println(0);
} else {
System.out.println ("Huh?");
}
switch
语句switch
表达式的功能是将一个操作数与多个选项进行比较,并为每个具体情况生成一个值:
String seasonName = switch (seasonCode) { // switch expression
case 0 -> "Spring";
case 1 -> "Summer";
case 2 -> "Fall";
case 3 -> "Winter";
default -> {
System.out.println("???");
yield "";
}
};
需要注意的是,switch
在这里是一个表达式(expression),并且有一个值,即5个字符串"Spring" "Summer""Fall""Winter"""
中的一个。这个switch
表达式的值被赋值给seasonName
变量。
其实最常见的情况是,一个case
后面跟着一个表达式。你也可以在一个花括号括起来的语句块中做一些其他的额外工作,就像前面示例中的default
部分一样。然后,你需要在语句块中使用yield
语句来生成一个值。
switch
还有一种语句形式,如下所示:
switch (seasonCode) { // switch statement
case 0 -> seasonName = "Spring";
case 1 -> seasonName = "Summer";
case 2 -> seasonName = "Fall";
case 3 -> seasonName = "Winter";
default -> {
System.out.println("???");
seasonName = "";
}
}
在前面的示例中,case
标签是整数。你可以使用以下任意类型的值:
● char
类型、byte
类型、short
类型或int
类型的常量表达式(或与其相对应的封装类Character
、
Byte
、
Short
和Integer
,将在1.8.3小节中介绍);
● 字符串字面量;
● 枚举的值(参见第4章)。
每个case
都可以有多个标签,并用逗号分隔:
int numLetters = switch (seasonName) {
case "Spring", "Summer", "Winter" -> 6;
case "Fall" -> 4;
default -> throw new IllegalArgumentException();
};
注意:整数或String
上的switch
表达式总是有一个default
部分。无论操作数值是什么,switch
表达式都必须生成一个值。此外,如前一个示例所示,大小写的区别可能会引发异常。异常将在第5章中具体介绍。
警告:如果switch
的操作数值为null
,那么一个NullPointerException
异常将会被抛出。当操作数类型为String
或枚举时,会发生这种情况。
在前面的示例中,switch
表达式和语句中,对于给定的操作数值只有一个case
分支被执行。当然有时也可能会发生一些例外,这种情况通常被称作直通(fall-through,也称贯通)。即其从匹配的case
分支开始执行,然后继续执行下一个case
,除非被yield
或break
语句打断。switch
的直通式变体同样也具有表达式和语句形式。在下面的示例中,当seasonName
为"Spring"
时会发生这种直通。
int numLetters = switch (seasonName) { // switch expression with fall-through
case "Spring":
System.out.println("spring time!");
case "Summer", "Winter":
yield 6;
case "Fall":
yield 4;
default:
throw new IllegalArgumentException();
};
switch (seasonName) { // switch statement with fall-through
case "Spring":
System.out.println("spring time!");
case "Summer", "Winter":
numLetters = 6;
break;
case "Fall":
numLetters = 4;
break;
default:
throw new IllegalArgumentException();
}
需要注意的是,在直通式变体中,每个 case
后面都跟一个冒号,而不是一个 ->
。这样可以在冒号后跟任意数量的语句,并且不需要花括号。此外,在带有直通的switch
表达式中,必须使用yield
来生成一个值。
警告:在直通式变体中,忘记yield
或break
是一个常见的错误。除非真的需要直通行为,否则请避免使用这种变体。
while
循环会依据具体的条件,反复执行其循环体的语句。例如,假定有一个对数值求和的任务,直到数值的总和达到目标值。我们将使用随机数生成器作为数值的来源,其由java.util
包中的Random
类提供:
var generator = new Random();
下面的调用将会生成0~9的一个随机整数:
int next = generator.nextInt(10);
以下是用于求和的循环:
while(sum < target) {
int next = generator.nextInt(10);
sum += next;
count++;
}
这是while
循环的典型用法。当总和小于目标值时,循环会持续执行。
有时你需要先执行循环体,然后才能评估循环条件。假设你想知道达到特定值所需的具体时间,那么在测试循环条件之前,需要先进入循环并获取到那个测试值。在这种情况下,要使用do/while
循环:
int next;
do {
next = generator.nextInt(10);
count++;
} while (next != target);
这样就可以先进入循环体,再设定next
的值,然后再评估是否满足循环条件。只要满足循环条件,循环体就会重复执行。
在前面的示例中,循环迭代的次数都是未知的。然而,在实践中的许多循环中,循环迭代的次数都是固定的。在这些情况下,最好使用for
循环。
例如,下面示例中的循环计算固定数量的随机值之和:
for (int i = 1; i <= 20; i++){
int next = generator.nextInt(10);
sum += next;
}
这个循环将会执行20次,每次循环迭代中,i
分别会被设置为1、2、……、20。
可以将任何一个for
循环重写为while
循环。上面的循环等效于:
int i = 1;
while (i <= 20){
int next = generator.nextInt(10);
sum += next;
i++;
}
在while
循环中,变量i
的初始化、测试和更新分散在循环体的不同位置。而使用for
循环,变量i
的初始化、测试和更新可以很紧凑地聚集在一起。此外,for
循环中变量的初始化、测试和更新可以采用任意形式。例如,当一个值小于目标值时,可以将其加倍:
for (int i = 1; i < target; i *= 2) {
System.out.println(i);
}
也可以不在for
循环的头部声明变量,而是初始化现有变量:
for (i = 1; i <= target; i++) // Uses existing variable i
或者可以声明或初始化多个变量并提供多个变量的更新,用逗号分隔。例如:
for (int i = 0, j = n - 1; i < j; i++, j--)
如果不需要初始化或更新,那么也可以将其留空。如果忽略该条件,则认为该条件总是为true
:
for (;;) // An infinite loop
你将在下一小节中看到如何退出这种无限循环。
break
和continue
如果想从循环迭代的过程中退出,可以使用break
语句。例如,假设你想处理用户输入的单词,直到用户输入字母Q为止。下面是一个使用boolean
变量来控制循环的解决方案:
boolean done = false;
while (!done) {
String input = in.next();
if ("Q".equals(input)) {
done = true;
} else {
Process input
}
}
下面的循环使用break
语句执行相同的任务:
while(true) {
String input = in.next();
if ("Q".equals(input)) break; // Exits loop
Process input
}
// break jumps here
当到达break
语句时,循环将立即退出。
continue
语句类似于break
,但它不会跳到循环的终点,而是跳到当前循环迭代的终点。可以使用它来略过不需要的输入,例如:
while (in.hasNextInt()) {
int input = in.nextInt();
if (input < 0) continue; // Jumps to test of in.hasNextInt()
Process input
}
在for
循环中,continue
语句将会跳转到下一个更新语句处:
for (int i = 1; i <= target; i++) {
int input = in.nextInt();
if (n < 0) continue; // Jumps to i++
Process input
}
break
语句仅从紧邻着的封闭循环或switch
中跳转出来。如果要跳转到另一个封闭语句的末尾,请使用带标签的 break
语句。在需要退出的语句处打上标签,例如:
outer:
while(...){
...
while (...) {
...
if (...) break outer:
...
}
...
}
// Labeled break jumps here
标签可以是任何名称。
警告:虽然你在语句的顶部打上了标签,但break
语句将跳转到末尾。
常规break
语句只能用于退出循环或switch
,但带标签的break
语句可以将控制转移到任何语句的末尾,甚至是块语句:
exit: {
...
if(...)break exit;
...
}
// Labeled break jumps here
还有一个带标签的continue
语句,它跳转到标签处开始下一次迭代。
提示:许多编程人员发现break
语句和continue
语句令人困惑。需要知道的是,这些语句完全是可选的,没有它们也是可以表达相同的逻辑的。本书不会使用break
语句或continue
语句。
现在,你已经看到了使用了嵌套形式的语句块的示例。这是一个很好的开始,下面我们即将开始学习变量作用域的一些基本规则。局部变量(local variable)就是在方法中声明的任何变量,甚至包括方法的参数变量。变量的作用域(scope)就是可以在程序中访问该变量的范围。局部变量的作用域是从变量声明处开始,一直延伸到当前的封闭块的末尾:
while (...) {
System.out.println(...);
String input = in.next(); // Scope of input starts here
...
// Scope of input ends here
}
换言之,每个循环在迭代时,都会创建一个新的input
变量的副本,并且该变量在循环之外并不存在。
参数变量的作用域是整个方法:
public static void main(String[] args) { // Scope of args starts here
...
// Scope of args ends here
}
这里还有一种需要理解作用域规则的情况。以下的循环计算了获取特定随机数字需要尝试的次数:
int count = 0;
int next;
do {
next = generator.nextInt(10);
count++;
} while (next != target);
这里的next
变量必须在循环外部声明,以便在循环中实现条件判断。如果在循环内部声明,那么它的作用域将只延伸到循环体的结尾。
当你在for
循环中声明变量时,它的作用域将延伸到循环的结尾,包括测试和更新语句:
for (int i = 0; i < n; i++) { // i is in scope for the test and update
...
}
// i not defined here
如果需要循环后的i
值,就请在外部声明变量:
int i;
for (i = 0; !found && i < n; i++) {
...
}
// i still available
在Java中,不能在重叠的作用域内有名称相同的局部变量:
int i = 0;
while (...) {
String i = in.next(); // Error to declare another variable i
...
}
但是,如果作用域不重叠,则变量名可以相同:
for (int i = 0; i < n / 2; i++) { ... }
for (int i = n / 2; i < n; i++) { ... } // OK to redefine i
数组是一种能够容纳相同类型的多个数据的基本程序结构。Java语言中内置了数组类型,并且提供了一个ArrayList
类,该类实现了数组按需增、减的操作。ArrayList
类是Java语言中庞大的容器框架的一部分,该框架将在第7章中详细介绍。
每种数据类型都有一个对应的数组类型。一个整数数组的类型是int[]
,一个String
对象数组的类型是String[]
,以此类推。下面是一个保存字符串的数组:
String[] names;
以上语句中的变量尚未初始化。因此我们需要先用一个新的数组初始化这个变量。为此,需要使用new
运算符:
names = new String[100];
当然,可以将这两个语句组合在一起:
String[] names = new String[100];
现在names
就成为了一个包含100个元素的数组,可以通过names[0]...names[99]
的形式来访问数组中的这些元素。
警告:如果试图访问不存在的元素,例如names[-1]
或names[100]
,则会发生ArrayIndexOutOfBoundsException
异常。
数组的长度可通过array
.length
获得。例如,以下循环使用空字符串填充字符串数组:
for (int i = 0; i < names.length; i++) {
names[i] = "";
}
注意:使用C风格的语法形式来声明数组变量也是合法的,即将数组的[]
跟在变量名后,而不是数据类型后。
int numbers[] ;
但是,这种语法形式并不友好,因为这样的声明形式很容易混淆了变量名numbers
和类型int[]
。因此,几乎没有Java编程人员这样定义数组。
当你使用new
运算符构造数组时,它会使用默认值来填充数组:
● 数值类型(包括char
)的数组用0填充;
● boolean
数组用false
填充;
● 对象数组用null
引用填充。
警告:在构造对象数组时,需要用对象进行填充,示例如下:
BigInteger[] numbers = new BigInteger[100];
此时,数组中还没有任何BigInteger
对象,只有100个null
引用。需要将它们替换为对BigInteger
对象的引用。
for (int i = 0; i < 100; i++)
numbers[i] = BigInteger.valueOf(i);
如前一小节所述,也可以通过编写一个循环语句来用值填充数组。然而,如果知道数组元素确切的值,就可以直接在花括号内列出它们:
int[] primes = { 2, 3, 5, 7, 11, 13 };
如果不使用new
运算符,也不指定数组长度,那么可以在末尾用逗号表示,这样可以方便随时添加数组值:
String[] authors = {
"James Gosling",
"Bill Joy",
"Guy Steele",
// Add more names here and put a comma after each name
};
如果不想为数组指定名称,那么可以使用类似初始化的语法。例如,下面的示例将数组赋值给现有数组变量:
primes = new int[] { 17, 19, 23, 29, 31 };
注意:长度为0的数组是合法的。可以使用new int[0]
或new int
[]{}
这种形式来构造一个数组。例如,如果一个方法返回一个匹配的数组,但是没有特定的输入,那么可以返回一个长度为 0 的数组。需要注意的是,长度为0的数组与null
不同。如果a
是长度为0的数组,那么a.length
为0
;如果a
是null
,则a.length
将会导致NullPointer
Exception
异常。
当构造一个数组时,需要明确知道它的长度。一旦数组构造好,长度就永远不会改变了。这在许多实际应用中是不方便的。有一种补救方法是使用java.util
包中的ArrayList
类。ArrayList
对象在内部管理一个数组。当该数组太小或不够用时,另一个内部数组会被自动创建,并且元素会被移入其中。使用数组列表的编程人员看不到这个过程。
数组和数组列表的语法完全不同。数组使用特殊语法——[]
运算符来访问元素,通过Type
[]
语法来标记数组的类型,以及通过new
Type
[
n
]
的语法来构造数组。相反,数组列表是类,需要使用正常的语法来构造实例和调用方法。
此外,与目前看到的类不同,ArrayList
类是一个带有类型参数的类——泛型类(generic class),第6章将详细介绍泛型类。
对于泛型类,需要使用尖括号来指定数组列表内元素的类型。例如,保存String
对象的数组列表的类型应当表示为ArrayList<String>
。
为了声明和初始化这种类型的变量,可以使用以下3个语句中的任意一个:
ArrayList<String> friends = new ArrayList<String>();
var friends = new ArrayList<String>();
ArrayList<String> friends = new ArrayList<>();
注意,最后一个声明使用了空的<>
,编译器会根据变量的类型推断其类型。[此快捷方式称为菱形语法(diamond syntax),因为空的尖括号具有菱形形状。]
此调用中没有构造参数,但仍需要在末尾提供()
。
结果可以生成长度为0的数组列表。可以使用add
方法在末尾添加元素:
friends.add("Peter");
friends.add("Paul");
由于数组列表没有设定初始值的语法,因此最好的方式是通过以下途径构造一个数组列表:
var friends = new ArrayList<>(List.of("Peter", "Paul"));
这里的List.of
方法生成一个不可修改的给定元素列表,然后可以再使用该列表来构造一个ArrayList
实例。
可以在ArrayList
中的任何位置添加和删除元素:
friends.remove(1);
friends.add(0, "Paul"); // Adds before index 0
为了访问元素,必须调用对应的方法,而不能使用[]
语法。数组列表使用 ge
t
方 法读取元素,使用se
t
方 法修改元素:
String first = friends.get(0);
friends.set(1, "Mary");
size
方法可以获取数组列表当前的大小。以下示例使用循环来遍历所有元素:
for (int i = 0; i < friends.size(); i++) {
System.out.println(friends.get(i));
}
泛型类在一些方面存在限制,即不能将基本类型用作泛型类的类型参数。例如,ArrayList<int>
是非法的。因此最好使用基本类型的封装类。每个基本类型,都有一个相应的封装类:Integer
、
Byte
、
Short
、
Long
、
Character
、
Float
、
Double
和Boolean
。如果要创建整数的数组列表,可以使用ArrayList<Integer>
:
var numbers = new ArrayList<Integer>();
numbers.add(42);
int first = numbers.get(0);
基本类型与其对应的封装类之间的类型转换是自动实现的。在调用add
方法的过程中,一个保存了值42
的Integer
对象会被自动构造,这种对象的自动构造过程叫作自动装箱(autoboxing)。
在以上示例代码中的最后一行,调用get
方法将会返回一个Integer
对象。在赋值给int
变量之前,该对象会被拆箱(unboxing)以转换生成int
值。
警告:基本类型和封装类之间的关系对编程人员几乎完全透明。只有一个例外,=
=
和 !=
运算符比较的是对象的引用,而不是对象的内容。if
(numbers.get(i) == numbers.get(j))
条件并不会测试索引i
和j
处的数值是否相同。就像字符串一样,你需要记住使用包装对象调用equals
方法来判断两者是否相等。
for
循环你经常会希望访问数组的所有元素。例如,以下是计算数字数组中所有元素总和的方法:
int sum = 0;
for (int i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
由于这种循环的使用场景非常多,因此Java有一种更加方便的快捷方式来实现这种循环,通常称为增强for
(enhanced for
)循环:
int sum = 0;
for (int n : numbers) {
sum += n;
}
增强for
循环的循环变量会遍历数组的元素而不是索引值。变量n
会被numbers[0]
、
n
umbers[1]
等元素依次赋值。
也可以将增强for
循环与数组列表一起使用。如果friends
是字符串数组列表,则可以使用这样的循环打印所有元素:
for (String name : friends) {
System.out.println(name);
}
可以将一个数组变量复制到另一个数组中,但实际情况是这两个变量将引用相同的数组,如图 1-6所示。
int[] numbers = primes;
numbers[5] = 42; // Now primes[5] is also 42
图1-6 两个变量引用同一个数组
如果不想这样让两个数组变量共享一份数据,那么就需要创建一个数组的副本。以下示例使用静态方法Arrays.copyOf
进行复制:
int[] copiedPrimes = Arrays.copyOf(primes, primes.length);
Arrays.copyOf
方法构造了一个长度为所需值的新数组,并将原始数组的元素复制到新数组中。
数组列表引用的工作方式也和数组复制的情况类似:
ArrayList<String> people = friends;
people.set(0, "Mary"); // Now friends.get(0) is also "Mary"
为了复制数组列表,可以从现有的数组列表中构造一个新的数组列表:
var copiedFriends = new ArrayList<>(friends);
该构造器还可以用于将数组复制到数组列表中。使用List.of
方法可以将数组封装到一个不可变的列表中,然后构造一个ArrayList
:
String[] names = ...;
var friends = new ArrayList<>(List.of(names));
同样可以将数组列表复制到数组中。出于兼容性这样令人失望的原因,你必须提供一个正确类型的数组。兼容性问题将在第6章中解释。
String[] names = friends.toArray(new String[0]);
注意:基本类型数组和对应的封装类数组列表之间没有简单的方法。例如,要在int
[]
和Array
List<Integer>
之间进行转换,需要使用显式循环或int
Stream
(参见第8章)。
Arrays
和Collections
类都为数组和数组列表提供通用算法的实现。下面是填充数组或数组列表的方法:
Arrays.fill(numbers, 0); // int[] array
Collections.fill(friends, ""); // ArrayList<String>
要对数组或数组列表进行排序,请使用sort
方法:
Arrays.sort(names);
Collections.sort(friends);
注意:对于数组(而不是数组列表)来说,如果数组较大,可以使用parallelSort
方法将任务分配到多个处理器上。
Arrays.toString
方法可以生成数组的字符串表示。这对打印调试系统的数组非常有用:
System.out.println(Arrays.toString(primes));
// Prints [2, 3, 5, 7, 11, 13]
数组列表也有一个toString
方法,该方法也能实现同样的功能:
String elements = friends.toString();
// Sets elements to "[Peter, Paul, Mary]"
如果只是为了打印,你甚至不需要调用它,println
方法会自动处理:
System.out.println(friends);
// Calls friends.toString() and prints the result
对于数组列表而言,还有一些有用的算法,但是数组无法使用这些算法:
Collections.reverse(names); // Reverses the elements
Collections.shuffle(names); // Randomly shuffles the elements
正如你已经看到的那样,每个Java程序的main
方法都有一个字符串数组作为参数:
public static void main(String[] args)
当程序被执行时,这个参数会被设置为命令行中指定的参数。例如,下面这个程序:
public class Greeting {
public static void main(String[] args) {
for (int i = 0; i < args.length; i++) {
String arg = args[i];
if (arg.equals("-h")) arg = "Hello";
else if (arg.equals("-g")) arg = "Goodbye";
System.out.println(arg);
}
}
}
如果这个程序被这样调用:
java Greeting -g cruel world
那么args[0]
就是"-g"
,args[1]
就是"cruel"
,args[2]
就是"world"
。
请注意,命令行中的"java"
和"Greeting"
是不会被传递到m
ain
方法内的。
Java语言中并没有真正的多维数组,它们被实现为数组的数组。例如,以下是声明和实现二维整数数组的方法:
int[][] square = {
{ 16, 3, 2, 13 },
{ 5, 10, 11, 8 },
{ 9, 6, 7, 12 },
{ 4, 15, 14, 1}
};
从技术上讲,这是一个一维的int[]
数组,见图1-7。
要访问一个元素,请使用两对方括号:
int element = square[1][2]; // Sets element to 11
其中,第一个索引选择行数组square[1]
,第二个索引表示从该行中选取元素。
你甚至可以交换一整行,例如:
int[] temp = square[0];
square[0] = square[1];
square[1] = temp;
图1-7 二维数组
如果未提供初始值,则必须使用new
运算符并指定行数和列数:
int[][] square = new int[4][4]; // First rows, then columns
在之后的应用场景中,行数组的每一行都由一个数组填充。这里并不要求行数组具有相等的长度。例如,你可以存储帕斯卡三角形:
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
...
首先构造一个n
行的数组:
int[][] triangle = new int[n][];
然后使用循环构造每一行,并填充:
for (int i = 0; i < n; i++) {
triangle[i] = new int[i + 1];
triangle[i][0] = 1;
triangle[i][i] = 1;
for (int j = 1; j < i; j++) {
triangle[i][j] = triangle[i - 1][j - 1] + triangle[i - 1][j];
}
}
要遍历一个二维数组,需要使用两个循环,一个用于行,另一个用于列:
for (int r = 0; r < triangle.length; r++) {
for (int c = 0; c < triangle[r].length; c++) {
System.out.printf("%4d", triangle[r][c]);
}
System.out.println();
}
也可以使用增强for
循环:
for (int[] row : triangle) {
for (int element: row) {
System.out.printf("%4d", element);
}
System.out.println();
}
这些循环适用于矩阵数组以及具有不同行长度的数组。
提示:打印二维数组的元素列表用于调试,可以调用以下方法。
System.out.println(Arrays.deepToString(triangle));
// Prints [[1], [1, 1], [1, 2, 1], [1, 3, 3, 1], [1, 4, 6, 4, 1], ...]
注意:Java 中没有二维数组列表,但可以声明一个 ArrayList<ArrayList><Integer>
类型的变量,并自己构建每一行。
如果main
方法太长,那么可以将程序分解到多个类中,这将在第2章中介绍。对于一些简单的程序,可以将所有程序代码放入同一个类的不同方法中。具体细节将在第 2 章中详细讲解,这些方法必须像main
方法本身一样,使用stati
c
修饰符声明。
当声明一个方法时,需要在方法头(method header)中提供返回值的类型(如果该方法不返回任何值,则为void
)、方法名以及参数的类型和名称。然后在方法体(method body)部分提供说明,并使用return
语句返回结果:
public static double average(double x,double y){
double sum = x + y;
return sum / 2;
}
请将该方法与main
方法放在同一个类中。具体位置是在main
方法之前还是之后均可。然后,可以这样调用该方法:
public static void main(String[] args){
double a = ...;
double b = ...;
double result = average(a, b);
...
}
可以将数组传递到方法中。这时方法只须接收一个数组的引用即可,方法可以通过该引用修改参数数组。以下示例中的方法交换了数组中的两个元素:
public static void swap(int[] values, int i, int j) {
int temp = values[i];
values[i] = values[j];
values[j] = temp;
}
方法也可以返回数组。以下方法返回由给定数组的第一个和最后一个值组成的新数组(不对原数组进行修改):
public static int[] firstLast(int[] values) {
if (values.length == 0) return new int[0];
else return new int[] { values[0], values[values.length - 1] };
}
某些方法允许调用者提供数量可变的参数。其实在编程中你已经见过这样的方法——printf
方法。例如以下调用:
System.out.printf("%d", n);
和
System.out.printf("%d %s", n, "widgets");
这两个语句都调用了相同的printf
方法,但是一个调用有2个参数,另一个调用则有3个参数。
现在让我们使用可变参数的形式重新定义average
方法,这样就可以计算任意多个参数的平均数了。例如,average(3, 4.5, -5, 0)
。声明可变参数的方法是在类型后使用...
符号:
public static double average(double... values)
这时的参数实际上是一个double
类型的数组。当调用该方法时,一个数组会被创建并用参数填充,在方法体内,你可以像使用任何其他数组一样使用它。
public static double average(double... values) {
double sum = 0;
for (double v : values) sum += v;
return values.length == 0 ? 0 : sum / values.length;
}
现在可以调用:
double avg = average (3, 4.5, -5, 0);
如果已经将参数存储在一个数组中,那么也没必要对它们进行解包。可以直接传递该数组,而不是传递数组列表:
double[] scores = { 3, 4.5, -5, 0 };
double avg = average(scores);
变量参数必须是方法的最后一个参数,但它之前可以有其他参数。例如,以下示例中的方法确保至少有一个参数:
public static double max(double first, double... rest) {
double result = first;
for (double v : rest) result = Math.max(v, result);
return result;
}
1.编写一个程序,读取一个整数并将其以二进制、八进制和十六进制形式打印出来。计算该整数的倒数,并以十六进制浮点数形式打印。
2.编写一个程序,读取一个整数角度值(可能为正或负),并将其标准化为0度~359度的值。请先使用 %
运算符计算,然后使用floorMod
重复实现该功能。
3.只使用条件运算符,编写一个读取3个整数,并打印其中的最大值的程序。然后请使用Math.max
方法重复该功能。
4.编写一个程序,打印double
类型的正的最小值和最大值。(提示:查找Java API中的Math.nextUp
方法。)
5.当将一个double
类型的值转换为int
类型的值时,如果该值可能大于最大int
值时会发生什么?试试看。
6.编写一个计算阶乘n! = 1 × 2 × ... × n的程序,使用BigInteger
。计算1000的阶乘。
7.编写一个程序,读入0~4294967295的两个整数,将它们存储在int
变量中,并计算和显示它们的无符号数的和、差、积、商和余数。不要将它们转换为long
类型的值。
8.编写一个程序,读取字符串并打印其所有非空子串。
9.1.5.3小节的示例中,使用s.equals(t)
比较两个字符串s
和t
,而不能使用s != t
。提出一个不使用substring
的不同示例。
10.编写一个程序,通过生成一个随机的long
值并将其以36进制输出,生成一个随机的字母和数字字符串。
11.编写一个程序,读取一行文本并打印所有非ASCII字符及其Unicode值。
12.编写一个switch
表达式,当给定一个指南针方向为"N""S""E"
或"
W"
的字符串时,会生成一个x-偏移和y-偏移的数组。例如,"W"
应生成 new int[] { -1, 0 }
。
13.编写一个switch
语句,当给定一个指南针方向为"N""S""E"
或"
W"
的字符串时,调整变量x
和y
。例如,"W"
会让x
减少1。
14.能在switch
语句中使用break
而不使用直通吗?在switch
表达式中呢?为什么?
15.提出一个有用的场景,其中直通行为对于switch
表达式或switch
语句是有益的。大多数网络搜索的结果都是针对C或C++的示例,其中执行会从case A
跳转到case
B
,而不执行任何操作。在Java中,这样的操作并没有什么意义,因为可以直接使用case A, B
。
16.“Quine”是一个程序,它不需要读取任何输入或文件,就可以打印自己的源代码。使用Java文本块编写这样的程序。
17.Java开发工具包包含一个Java库的源代码文件src.zip
。将其解压缩并使用你最喜爱的文本搜索工具,查找带标记的break
和c
ontinue
序列的用法。选择一个用法并重新编写它,不使用带标签的语句。
18.编写一个程序,打印一组彩票号码,选择1~49的6个不同的数字。为了选择6个不同的数字,请从填充由1到49的数组列表开始,然后随机选择一个索引并删除该元素。重复6次,最后按顺序打印结果。
19.编写一个程序,读取二维整数数组,并确定它是否为幻方(即所有行、所有列和对角线的总和是否相等,相等的即为幻方)。二维数组按照行输入,并在用户输入空行时停止。例如,输入:
16 3 2 13
5 10 11 8
9 6 7 12
4 15 14 1
(空行)
你的程序应该判断出该二维数组为幻方。
20.编写一个程序,将给定n的一个帕斯卡三角形存储到ArrayList<ArrayList<Integer>>
中。
21.改进average
方法,以便至少通过一个参数来调用它。