LOADING

加载过慢请开启缓存 浏览器默认开启

JAVA核心技术 卷1

2025/6/25 JAVA

作者:Cay S.Horstmann

介绍了JAVA语言的基本概念以及用户界面程序设计的基础知识

Learn Java 核心技术

目录

第一章 JAVA程序设计概述#

1.1 JAVA程序设计平台#

JAVA是一个完整的平台,有一个庞大丰富的库,其中包含了大量可重用的代码,还有一个提供诸如安全性、跨操作系统的可移植性以及自动垃圾收集等服务的执行环境。

import java.awt.*;
import java.io.*;
import javax.swing.*;

/**
 * A program for viewing images.
 * @version 1.31 2018-04-10
 * @author Cay Horstmann
 */
public class ImageViewer
{
   public static void main(String[] args)
   {
      EventQueue.invokeLater(() ->
         {
            var frame = new ImageViewerFrame();
            frame.setTitle("ImageViewer");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setVisible(true);
         });
   }
}

/**
 * A frame with a label to show an image.
 */
class ImageViewerFrame extends JFrame
{
   private static final int DEFAULT_WIDTH = 300;
   private static final int DEFAULT_HEIGHT = 400;

   public ImageViewerFrame()
   {
      setSize(DEFAULT_WIDTH, DEFAULT_HEIGHT);

      // use a label to display the images
      var label = new JLabel();
      add(label);

      // set up the file chooser
      var chooser = new JFileChooser();
      chooser.setCurrentDirectory(new File("."));

      // set up the menu bar
      var menuBar = new JMenuBar();
      setJMenuBar(menuBar);

      var menu = new JMenu("File");
      menuBar.add(menu);

      var openItem = new JMenuItem("Open");
      menu.add(openItem);
      openItem.addActionListener(event ->
         {
            // show file chooser dialog
            int result = chooser.showOpenDialog(null);

            // if file selected, set it as icon of the label
            if (result == JFileChooser.APPROVE_OPTION)
            {
               String name = chooser.getSelectedFile().getPath();
               label.setIcon(new ImageIcon(name));
            }
         });

      var exitItem = new JMenuItem("Exit");
      menu.add(exitItem);
      exitItem.addActionListener(event -> System.exit(0));
   }
}

1.2 JAVA白皮书的关键术语#

白皮书以及11个关键术语的概述

1.2.1 简单性#

JAVA语法是C++语法的一个纯净版本,没有头文件、指针运算(甚至没有指针语法)、结构、联合、操作符重载、虚基类等,但设计者并没有试图修正C++的所有不适当的特性。

1.2.2 面向对象#

面向对象设计是一种程序设计技术,它将重点放在数据(即对象)和对象的接口上。Java与C++的不同点主要在于多重继承,在Java中取而代之的是更简单的接口概念。与C++相比,Java提供了更丰富的运行时自省功能。

1.2.3 分布式#

Java应用程序能够通过URL打开和访问网上的对象,其便捷程度就好像访问本地的文件一样(以前的特性,如今已理所当然)。

1.2.4 健壮性#

Java编译器能够检测许多其他语言在运行时才能检测出来的问题。

1.2.5 安全性#

使用Java可以构建防病毒、防篡改的系统。

从一开始,Java就设计成能够防范各种攻击,其中包括:

  • 运行时堆栈溢出,这是蠕虫和病毒常用的攻击手段

  • 破坏自己的进程空间之外的内存

  • 未经授权读写文件

    ActiveX:使用数字签名进行代码交付

1.2.6 体系结构中立#

编译器生成体系结构中立的目标文件格式,这是一种编译型代码,即Java通过生成与特定计算机体系结构无关的字节码指令来实现这一特性。

Java虚拟机还有一些其他优点,如它可以检查指令序列的行为,从而增强安全性。

1.2.7 可移植性#

数值类型有固定的字节数,二进制数据以固定的格式进行存储和传输,消除了有关字节顺序的困扰,字符串则采用标准的Unicode格式存储。可以很好地支持平台独立性。(C/C++中int可以是16/32位整数/编译器开发商指定的其他大小,在Java中总是32位)

1.2.8 解释性#

Java解释器可以在任何移植了解释器的机器码上直接执行Java字节码。

1.2.9 高性能#

即时编译器可以监控哪些代码频繁执行,并优化这些代码以提高速度。跟为复杂的优化是消除函数调用(内联)。

1.2.10 多线程#

第一个支持并发程序设计。

1.2.11 动态性#

库可以自由地添加新方法和实例变量,而对客户端没有任何影响。让一个正在运行的程序实现演进。

1.3 JAVA applet 与 Internet#

在网页中运行的JAVA程序称为applet

1.4 JAVA发展简史#

2.jpg

1.5关于JAVA的常见误解#

  1. JAVA是HTML的扩展

    JAVA是一种程序设计语言,而HTML是一种描述网页结构的方式。

  2. 我使用XML,不需要JAVA

    XML是一种描述数据的方式,可以使用任意的程序设计语言处理XML数据,JAVA API对XML处理提供了很好的支持。

  3. JAVA非常容易学习

    数千个类和接口,数万个函数

  4. JAVA将适用于所有平台

    有可能,但某些领域其他语言有更出色的表现。比如objective C和后来的Swift在iOS设备上就有着无可替代的地位,浏览器中的处理几乎由JavaScript掌控,Windows程序通常用C++或C#编写,JAVA在服务器编程和跨平台客户端应用领域则很有优势。

  5. JAVA一般

    JAVA的成功源于其类库能够让人们很轻松地完成原本有一定难度的工作/JAVA减少了指针错误……

  6. JAVA是专用的,应避免使用

    已开源

  7. JAVA是解释性的,所以执行速度太慢了

    现在的JAVA虚拟机使用了即时编译器(JIT,just in time),因此用JAVA编写的代码运行速度与C++相差无几,有些情况甚至更快

  8. 所有JAVA程序都在网页中运行

    大多数程序都在服务器上运行,为网页生成代码或者计算业务逻辑

  9. JAVA程序存在重大安全风险

    虚拟机提供保护,JAVA应用比C或C++编写的应用要安全的多

  10. JavaScript是Java的简易版

    JavaScript是一种可以在网页中使用的脚本语言,Java是强类型的,能够捕获类型滥用导致的很多错误,而JavaScript只有当程序运行时才能发现这些错误。

  11. 使用JAVA时可以用廉价的Internet设备代替桌面计算机

​ 网络计算机(是一种以网络为中心的计算设备,强调通过网络访问应用和数据,而不是依赖本地资源。它的理念在现代云计算、虚拟 化和瘦客户端技术中得到了延续。虽然在 20 世纪末未能大规模普及,但它为后来的网络化、云化计算奠定了基础)没有本地存储且 功能有限

第二章 JAVA编程环境#

2.1 安装Java开发工具包(JDK)#

2.1.1 下载JDK#

Java Downloads | Oracle

2.1.2 设置JDK#

重点:

  • 最好不要接受默认位置,自己选择路径,推荐安装在其他盘(如D/E盘)

  • 如何检测安装是否成功?

    javac -version

​ 出现版本信息则成功,如果报错则需仔细检查安装

  • 将JDK 的bin目录添加到可执行路径中——可执行路径是操作系统查找可执行文件时所遍历的目录列表

    Linux系统:在/.bashrc或/.bash_profile文件的最后一行添加:

    export PATH=jdk/bin:$PATH

    windows系统:在设置中搜索环境变量,编辑环境变量/点击Win+R,输入sysdm.cpl,然后选择高级,再单击环境变量,在用户变量列表中找到并选择一个名为Path的变量,单击Edit再单击New,添加一个新值——jdk的bin目录

2.1.3 安装源文件和文档#

解压lib/src.zip到新建的目录javasrc中,可通过终端创建该目录

mkdir javasrc
cd javasrc
jar xvf jdk/lib/src.zip
cd ..

文档则包含在独立于JDK的一个压缩文件中,后续略

2.2使用命令行工具#

cd dir(该文件所在目录)
javac xxx.java
java xxx

此过程中javac编译器将xxx.java编译成xxx.class,java程序启动jvm,jvm执行编译器编译到类文件中的字节码

初学者易犯错误)

2.3 使用集成开发环境#

下载JetBrains Toolbox,验证学生身份后即可免费使用,然后在工具箱中下载IntelliJ IDEA

jetbrains工具箱教程:下载IDEA社区版与完全版的步骤-CSDN博客

2.4 JShell#

键入一个JAVA表达式,JShell会评估输入,打印结果并等待下一个输入

10.png

另一个有用的特性是“tab补全”,如果键入Math.然后按一次tab键,会得到Math类调用的所有方法的一个列表

键入l,再按一次tab键,方法名会补全为log,然后得到一个比较小的列表

小技巧:重复运行某个命令可按↑知道看到想要重复运行或编辑的命令行,←→移动光标至指定位置增加或删除指定字符

第三章 JAVA的基本程序设计结构#

3.1 一个简单的JAVA程序#

public class FirstSample {
    public static void main(String[] args) {
        System.out.println("We will not use 'hello world!'");
    }
}
  1. JAVA区分大小写

  2. 关键字public被称为访问修饰符,用于控制程序的其他部分对这段代码的访问级别

  3. 关键字class表明JAVA程序中的全部内容都包含在类中,类可以看做是程序逻辑的一个容器,定义了应用程序的行为;类是所有JAVA应用的构建模块,JAVA程序中的所有内容都必须放在类中

  4. 类名必须以字母开头,后面可以跟字母和数字的任意组合,长度没有限制,但是不能使用JAVA关键字(或被称为保留字)。标准命名约定为:类名是以大写字母开头的名词,如果名字由多个单词组成,那么每个单词的第一个字母都应该大写(驼峰/骆驼命名法)

  5. 源代码的文件名必须和类名一致,并以.java作为扩展名,正确编译这个源代码之后,会得到一个包含这个类字节码的文件,JAVA编译器将这个字节码文件自动命名为FirstSample.class,并存储在源文件所在的同一个目录下,随便便可运行程序

    java FirstSample
  6. 运行一个已编译的程序时,Java虚拟机总是从指定类中main方法(即函数)的代码开始执行,因此类的源代码必须包含一个main方法

  7. main方法必须声明为public,详情可见Java语言规范

  8. 大括号用来划分程序的各个部分(通常称为块),任何方法的代码都必须以“{”开始,“}”结束;大括号的使用风格因人而异,JAVA编译器会忽略空白符,所以你可以选用自己喜欢的任何大括号风格

    public class FirstSample {
        public static void main(String[] args) {
            System.out.println("We will not use 'hello world!'");
        }
    }
    public class FirstSample 
    {
        public static void main(String[] args) 
        {
            System.out.println("We will not use 'hello world!'");
        }
    }
    教材推荐风格(把匹配的大括号对齐)
  9. JAVA中的所有函数都是某个类的方法,因此,JAVA中的main方法必须有一个外壳(shell)类

  10. 与C/C++相同,关键字void表示这个方法不返回值,但不同的是main方法不会为操作系统返回一个“退出码”。如果main方法正常退出,那么JAVA程序的退出码为0,表示成功地运行了程序,如果要以其他退出码终止程序,则需要使用System.exit方法

  11. 每个语句必须用分号结束,回车不是语句结束的标志,如果需要一条语句可以跨多行

  12. .用于调用方法,JAVA使用的通用语法是object.method(parameters),这等价于一个函数调用,JAVA中的方法可以没有参数,也可以有一个或多个参数,即使没有参数,也需要使用空括号

  13. println(a)=print(a)+print(‘’)

3.2 注释#

JAVA中的注释不会出现在可执行程序中。所以可以添加任意多的注释而无须担心代码膨胀

共有三种注释方法

  1. 从//开始到本行结束都是注释

    //这是注释
  2. 可以使用 /* 和 */ 注释界定符将一段比较长的注释括起来

    /*
    这是注释
    这是注释
     */

    /* / 不能嵌套*

  3. 以 /** 开始,以 */ 结束的注释可以用来自动生成文档

/**
 * 这是注释
 * @version 111666
 * 888
 */

3.3 数据类型#

JAVA是一种强类型语言,必须为每一个变量声明一个类型。在Java中,一共有8种基本类型,其中4种整型、2种浮点类型,1种字符类型,1种用于表示真值的boolean类型

[!NOTE]

JAVA有一个能够表示任意精度的算数包,所谓的“大数”(big number)是JAVA对象而非基本类型。

3.3.1 整型#

整型用于表示没有小数部分的数,可以是负数。

类型 存储需求 字节数 取值范围
int 4 -21 4748 3648~21 4748 3647(略高于20亿)
short 2 -3 2768~3 2767
long 8 -922 3372 0368 5477 5808~922 3382 0368 5477 5807
byte 1 -128~127

在Java中,整型的范围和运行Java代码的机器无关(保证在所有机器上都能得到相同的运行结果),与此相反,C/C++会针对不同的处理器选择最高效的整型。JAVA没有无符号形式的int,long,byte,short类型

[!CAUTION]

如果使用不可能为负的整数值而且确实需要额外的一位(bit),也可以把有符号整数值解释为无符号数。例如,一个byte值b可以不表示-128~127的范围,如果你想表示0-255的范围,也可以存储在一个byte中。基于二进制算术运算的性质,只要不溢出,加法、减法、乘法都能正常运算。但对于其他运算,需要调用Byte.tuUnsignedInt(b)来得到一个0-255的int值,然后处理这个整数值,再把它转换回Byte.Integer和Long类都提供了处理无符号除法和余数的方法。

长整型的后缀有一个L或1(如40 0000 0000L),十六进制数有一个前缀0x或0X(如0xCAFE),八进制有一个前缀0(010对应十进制里面的8),八进制易混淆,所以不建议使用,二进制数有一个前缀0b或0B。另外可以为数字字面量增加下划线,如1_000_000(或0b1111_0110_1101_0011)表示一百万,下划线的作用是为了增加易读性。

3.3.2 浮点型#

浮点类型用来表示有小数部分的数。

类型 存储需求(字节数) 取值范围
float 4 6-7位有效数字
double 8 15位有效数字

float ± 3.40282347 * 1038

double ± 1.79769313486231570 * 10308

double表示这种类型的数值精度是float的两倍,所以也被称为双精度数,很多时候float的精度并不能满足要求,所以除非必须使用单精度数否则基本上都使用double。float类型的数有一个后缀F或f(如3.14F),没有后缀的浮点数值总是默认为double类型。可选地,也可以在double类型的数后加上后缀D或d(如3.14D)。

[!CAUTION]

可以使用十六进制表示浮点数字面量,例如0.123=2^{-3}次方可以写成0x1.0p-3,在十六进制表示法中,使用p表示指数而不是e,因为e是十六进制数位。其中尾数采用十六进制,指数采用十进制。指数的基数是2,而不是10.

所有浮点数计算都遵循IEEE754规范,具体来说,有三个特殊的浮点数值表示溢出和出错情况:

  • 正无穷大

  • 负无穷大

  • NaN(不是一个数)

    [!CAUTION]

    常量Double.POSITIVE_INFINITY和Double.NEGATIVE_INFINITY和Double.NaN(以及相应的float类型常量)分别表示这三个特殊的值。其中所有NaN的值都认为是不相同的故x==Double.NaN is never true,只能通过Double.isNaN(x)来检测是否是Double.NaN

[!WARNING]

浮点数值不适用于无法接受舍入误差的金融计算,例如System.out.println(2.0-1.1)将打印出0.89999999999,而非我们期望中的0.9。这样的舍入误差主要原因是浮点数采用二进制表示,所以无法精确地表示1/10,好比十进制无法精确地表示1/3。如果需要精确的数值计算,不允许舍入误差,则应该使用BigDecimal类。

3.3.3 char型#

char类型的字面量值要用单引号括起来,例如,’A’是编码值为65的字符常量。它与”A”(包含一个字符的字符串)不同。char类型的值可以表示为十六进制值,其范围从000-,例如,122表示商标符号™ ,3C0表示希腊字母π。

除了转义序列,还有一些用来表示特殊字符的转义序列。

转义序列 名称 Unicode值
退格 008
制表 009
换行 00a
回车 00d
换页 00c
 “(中间没有空格,添加空格防止在此处被转义) 双引号 022
 ’(中间没有空格,添加空格防止在此处被转义) 单引号 027
  (中间没有空格,添加空格防止在此处被转义) 反斜线 05c
空格,在文本块中用来保留末尾空白符 020
只在文本块中使用,用来连接这一行和下一行 ——

可以在加引号的字符字面量或字符串中使用这些转义序列,例如’122’或”Hello“。转义序列(而其他所有转义序列不可以),如public static void main(String05B05D args)(但我自己在IDE上使用JAVA8编译报错,故此处存疑),其中05B和05D是 [ 和 ] 的编码

[!WARNING]

Unicode转义序列会在解析代码前处理。例如“022+022”会得到一个空串(解析为”“+”“)。更隐秘地,一定要注意注释中的例如// 00A is a newline(00A会替换为一个换行符)会产生语法错误,// look inside c:()也会产生一个语法错误。

3.3.4 Unicode和char型#

不同的编码机制标准:美国的ASCII,西欧语言的ISO 8859-1,俄罗斯的KOI-8,中国的GB 18030和BIG-5等

码点(code point)是指与一个编码表中的某个字对应的代码值。在Unicode标准中,码点采用十六进制书写,并加上前缀U+,例如U+0041为拉丁字母A的码点。Unicode的码点可以氛围17个代码平面(code plane)。第一个代码平面称为基本多语言平面,范围为U+0000-U+FFFF,其余十六个平面从U+10000到U+10FFFF,包含各种辅助字符

UTF-16编码采用不同长度的代码来表示所有Unicode码点,在基本多语言平面中,每个字符用16位表示,通常称为代码单元;而辅助字符编码为一对连续的代码单元,采用这种编码对表示的每个值都属于基本多语言平面中未用的2048个值范围,通常称为替代区域(U+D800-U+DBFF用于第一个代码单元,U+DC00-U+DFFF用于第二个代码单元)。

[!TIP]

强烈建议不要使用char类型,除非确实需要处理UTF-16代码单元,最好将字符串作为抽象数据类型来处理

3.3.5 boolean类型#

boolean类型有两个值:true和false,用来判定逻辑条件,整型值和布尔值之间不能互相转换。(在C++中,数值甚至指针可以代替布尔值,if(x=0)在C++中始终为false,而在Java中无法通过编译,因为整数表达式x=0不能转换为布尔值)

3.4变量与常量#

常量就是值不变的变量。

3.4.1声明变量#

声明是一个完整的JAVA语句,先指定变量类型,然后是变量名,每个声明都以分号结束

double salary;

作为变量名的标识符由字母、数字、货币符号以及”标点连接符”组成第一个字符不能是数字,‘+’、空格等不能出现,字母区分大小写不能使用Java关键字作为变量名,可以一行声明多个变量,但推荐分别声明以此来提高程序可读性

[!NOTE]

如果想要知道标识符中可以使用哪些Unicode字符,可以使用Charater类的isJavaIdentifierStartisJavaIdentifierPart来检查

3.4.2初始化变量#

声明变量后必须显式地使用赋值语句初始化变量,不能使用未初始化的变量。

double salary;
salary=10000;
double salary=10000;

[!TIP]

变量的声明应尽可能靠近第一次使用这个变量的地方

从Java10开始,如果可以从变量的初始值推断出其类型,则无需声明类型,只需要使用关键字var

var salary=10000;
var greeting="hello";

声明与定义#

声明:不分配内存(变量)/不提供具体实现(方法函数)

定义则反之

3.4.3常量#

可以使用final关键字来指示,表示该变量只能被赋值一次,一旦赋值就无法更改,习惯上,常量名使用全大写

final double pai=3.14;

类常量(class constant):在一个类的多个方法中使用,使用static final设置 ,定义位于main函数之外,如果一个类常量声明为public,那么其他类也可以使用这个常量,通过类名.类常量名的形式调用

public static final double pai=3.14;

[!NOTE]

const是Java保留的关键字,目前并没有使用,在Java中必须使用final声明常量

3.4.4枚举类型#

一个变量只包含有限的一组值。例如

enum Size{SMALL,MEDIUM,LARGE,EXTRA_LARGE};
Size s=Size.MEDIUM;

Size类型的变量只能存储这个类型声明中所列的某个值,或者特殊值null,null表示这个变量没有设置任何值

3.5运算符#

运算符用于连接值。

3.5.1算术运算符#

+,-,*,/ 加减乘除 求余%

当参与/运算的两个操作数都为整数时,/表示整数除法,否则这表示浮点数除法 15/2=7 15.0/2=7.5

整数被零除将产生一个异常,而浮点数被零除将会得到一个无穷大或NaN结果 x/0

3.5.2 数学函数与常量#

Math类中包含了各种常用的数学函数

要想计算一个数的平方根,可以使用sqrt函数

double x=4;
double y=Math.sqrt(x);
System.out.println(y);//prints 2.0

[!NOTE]

println方法处理System.out对象,而Math类中的sqrt方法并不处理任何对象,这样的方法被称为静态方法(static method)

在Java中,没有完成幂运算的运算符,故必须使用Math类中的pow方法,pow方法有两个double类型的参数,其返回类型也为double

double y=Math.pow(x,a);//将y设置为x的a次幂

floorMod方法是为了解决整数余数问题(n%2当n为负奇数时结果为-1)

最优规则(欧几里得规则):余数总是大于等于0

计算一个时钟时针的位置,这里要做一个调整,归一化为一个0-11之间的数,若使用式(position+adjustment)%12,当调整为负数时可能会得到一个负数,若使用((position+adjustment)%12+12)%12则很麻烦,使用floorMod(position+adjustment,12)总会得到一个0-11之间的数(但对于负除数floorMod会得到负数结果)

常用三角函数

函数名称 用处
Math.sin 求正弦
Math.cos 求余弦
Math.tan 求正切
Math.atan 求反正切值
Math.atan2 求y/x的反正切值

指数函数及对数函数(指数函数的反函数)

函数名称 用处
Math.exp 指数函数
Math.log 求解自然对数
Math.log10 求解以10为底的对数

以上方法参数及返回值均为double类型,且均为静态方法,可直接通过Math类调用,无需创建Math对象

常用常量

函数名称 用处
Math.PI π
Math.E e

[!TIP]

可以使用静态导入,这样就不用在方法名和常量名前添加前缀Math

import static java lang.Math.*
System.out.println(sqrt(PI));

如果得到一个完全可预测的结果比运行速度更重要的话,应该使用StrictMath类,确保在所有平台上得到相同的结果

[!CAUTION]

Math类提供了一些方法让整数运算更加安全,如果一个计算溢出,数学运算只是悄悄地返回错误的结果而不做任何提醒,使用Math.multiplyExact(10 0000 0000,3)就会生成一个异常,使得你可以捕获这个异常并进行处理,还存在其他方法(abbExact,subtractExact,incrementExact,decrementExact,negateExact,absExact)也可以正确地处理int和long参数

3.5.3数值类型之间的转换#

其中实线类型表示无信息丢失的转换,而另有三个虚线箭头,表示有精度损失的转换

int n=123456789;
float f=n;//f is 1.23456792E8

当用一个二元运算符连接两个值时,需要先将两个操作数转换为同一种类型,然后再进行计算

  • 如果有一个是double类型,那么另一个就会转换为double类型
  • 否则,如果有一个是float类型,那么另一个就会转换为float类型
  • 否则,如果有一个是long类型,那么另一个就会转换为long类型
  • 否则,两个都被转换为int类型

即优先转换为精度高的浮点数类型

3.5.4强制类型转换#

可能丢失信息的转换要通过强制类型转换来完成,确保开发者知道转换后果

强制类型转换(cast)的语法格式是在圆括号中指定想要转换的目标类型,后面紧跟待转换的变量名

double x=9.997;
int nx=(int)x;//nx is 9

强制类型转换通过截断小数部分将浮点值转换为整型

若想舍入(round)一个浮点数来得到一个最接近的整数,可以使用Math.round方法

double x=9.997;
int nx=(int)Math.round(x);//nx is 10

之所以还需要进行强制类型转换是因为round方法的返回值为long类型

[!WARNING]

如果试图将一个数从一种类型强制转换为另一种类型而又超出了目标类型的表示范围,结果就会截断成一个完全不同的值,如(byte)300=44

不要在boolean类型和任何类型之间进行强制类型转换,若需要将boolean类型转换成一个数,可以使用条件表达式b?1:0

3.5.5赋值#

可以在赋值中使用二元运算符 += *= %= …

x+=4;//等价于x=x+4

[!WARNING]

如果运算符得到一个值,其类型与左侧操作数的类型不同,就会发生强制类型转换

int x;
...
x+=3.5;//x被设置为(int)(x+3.5)

在Java中,赋值是一个表达式,即表达式有一个值(所赋的值)

int x=1;
int y= x+=4;//x+=4的值是5

但不推荐这样写,嵌套赋值容易混淆

3.5.6自增与自减运算符#

类型 作用
n++ 先使用n原来的值,然后n自增1
++n n先自增1,然后使用n的值
n– 先使用n原来的值,然后n自减1
–n n先自减1,然后使用n的值
int m=7;
int n=7;
int a=2*(++m);//now a is 16,m is 8
int b=2*(n++);//now b is 14,n is 8

但在Java中较少使用

3.5.7关系和boolean运算符#

==,!=,<,>,<=,>=关系运算符 3==7值为false

&&逻辑与运算 ||逻辑或运算 !逻辑非运算 &&与||是按照短路方式来求值的,即如果第一个操作数已经能够确定表达式的值,那么第二个操作数就不会计算

如exp1&&exp2,若exp1真值为false,就不会计算exp2,可以利用这种行为来避免错误

如x!=0&&1/x>x+y//no division by 0 如果x=0,那么第二部分就不会计算,exp1||exp2同理

3.5.8 条件运算符#

condition?exp1:exp2 如果condition为真,表达式计算为exp1的值,反之则为exp2的值

如x<y?x:y 会返回x、y中较小的一个

3.5.9 switch表达式#

需要在两个以上的值中做出选择时,可以使用switch表达式(Java14后引入)

        String seasonName=switch(seasonCode)
        {
            case 0-> "Spring";
            case 1-> "Summer";
            case 2-> "Autumn";
            case 3-> "Winter";
            default -> "Error";
        };

case标签还可以是字符串或者是枚举类型常量

与所有表达式一样,switch表达式也有一个值

可以为各个case提供多个标签

        int numLetters=switch (seasonName)
        {
            case "Spring","Summer","Winter" -> 1;
            case "Fall" -> 2;
            default -> -1;
        };

switch表达式中使用枚举常量时,不需要为各个标签提供枚举名

enum Size{SMALL,MEDIUM,LARGE,EXTRA_LARGE};
...
Size itemSize=...;
String label=switch(itemSize)
{
    case SMALL -> "S";//no need to use Size.SMALL
    case MEDIUM -> "M";
    case LARGE -> "L";
    case EXTRA_LARGE -> "XL";
};

在该例子中完全可以省略default,因为每一个可能的值都有相应的一个case

[!WARNING]

使用整数或者String操作数的switch表达式必须有一个default,因为不论操作数值是什么,这个表达式都必须生成一个值;如果操作数为null,那么会抛出一个NullPointerException

3.5.10 位运算符#

处理整数类型时,有一些运算符可以直接处理组成整数的各个位,这意味着可以使用掩码技术得到一个数中的各个位

位运算符包括:&(and) |(or) ^(xor) -(not)

如果n是一个整数变量,而且n的二进制表示中从右边数第四位为1,则

int fourthBitFromRight=(n & 0b1000)/0b1000;

会返回1,否则返回0。利用&并结合适当的2的幂,可以屏蔽其他位,而只留下其中的某一位

[!CAUTION]

应用在布尔值上时,&和|也会得到一个布尔值,这些运算符和&&与||很像,不过&和|不采用短路方式来求值,也就是说计算结果之前两个操作数都需要计算

另外还有<<和>>运算符可以将位模式左移或右移。

int fourthBitFromRight=(n &(1<<3))>>3

运算符>>>会用0填充高位,>>则会用符号位填充高位。不存在<<<运算符> [!WARNING] > > 移位运算符的右操作数要完成模32的运算,除非左操作数是long类型,此时模64,例如1<<35的值等同于1<<3g或8

[!NOTE]

C/C++中,不能保证>>是完成算术移位(扩展符号位)还是逻辑移位(填充0),Java消除了这种不确定性

3.5.11括号与运算符级别#

同一级别的运算符按从左到右的运算次序进行计算(右结合运算符除外)

a && b||c由于&&的优先级比||高,所以等价于(a&&b)||c

因为+=是右结合运算符,所以a+=b+=c等价于a+=(b+=c)

运算符 结合性
[].()(方法调用) 从左向右
!~++–+(一元运算)-(一元运算)()(强制类型转换)new 从右向左
*/% 从左向右
+- 从左向右
<< >> >>> 从左向右
< <= > >= instanceof 从左向右
== != 从左向右
& 从左向右
^ 从左向右
| 从左向右
&& 从左向右
|| 从左向右
?: 从右向左
= += -= *= /= %= &= |= ^= <<= >>= >>>= 从右向左

3.6字符串#

Java字符串就是Unicode字符序列Java没有内置的字符串类型,而是标准Java类库中提供了一个预定义类String,每个用双引号括起来的字符串都是String类的一个实例。

3.6.1 子串#

String greeting="Hello";
String s=greeting.substring(0,3);//创建一个由字符”Hel"组成的字符串

[!NOTE]

类似于C/C++,Java字符串中的代码单元和码点从0开始计数

substring的第二个参数是你不想复制的第一个位置,[a,b),这样也有一个优点:很容易计算子串长度——b-a

3.6.2 拼接#

Java允许使用+号拼接字符串,当将一个字符串与一个非字符串进行拼接时,后者会被转换成字符串(任何一个Java对象都可以转换成字符串)

int age=13;
String rating="PG"+age;//rating is "PG13"

此特性常用于输出

System.out.println("The answer is "+answer);

如果需要把多个字符串放在一起,用一个界定符分隔,可以使用静态join方法

String all=String.join("/","S","M","L","XL");//all is the string "S/M/L/XL"

Java11中,还提供了repeat方法

String repeated="java".repeat(3);//repeated is "javajavajava"

3.6.3 字符串不可变#

String类没有提供任何方法来修改字符串中的某个字符,可以提取想要保留的子串,再与希望替换的字符拼接

String greeting="Hello";
greeting=greeting.substring(0,3)+"p!";//"Help!"

文档中将String类对象称为是不可变的,如同数字3永远是数字3,字符串”Hello”永远包含字符H、e、l、l、o的代码单元序列。如上修改的方式实质是指向了另一个字符串

不可变字符串有一个很大的优点:编译器可以让字符串共享。工作原理:可以想象各个字符串存放在一个公共存储池中,字符串变量指向存储池中的相应位置,如果复制一个字符串变量,原始字符串和复制的字符串共享相同的字符。故Java设计者认为共享带来的高效率远远超过编辑字符串(提取子串和拼接字符串)所带来的低效率。

[!NOTE]

Java字符串与C++中的字符数组char a[]不同,但与char* a指针类似,即a指向某个字符串。C++中的字符串是可修改/可变的

3.6.4 检测字符串是否相等#

使用equals方法检测字符串是否相等

s.equals(t);//其中s、t可以是字符串变量(greeting)也可以是字符串字面量("Hello"),若相等返回true,不等返回false

若要检测两个字符串是否相等而不区分大小写,可以使用equalsIgnoreCase方法

"Hello".equalsIgnoreCase("hello");//true

不能使用==运算符检测两个字符串是否相等,这样只能检测两个字符串是否放在同一个位置,但是完全可能将多个相等的字符串副本放在不同的地方

String greeting="Hello";
if(greeting=="Hello")...//probably true
if(greeting.substring(0,3)=="Hel")...//probably false

[!NOTE]

如果虚拟机总是共享相等的字符串,那么可以使用==。但实际上只有字符串字面量会共享,而+或substring等操作得到的字符串并不共享,因此,千万不要使用==测试字符串的相等性

[!CAUTION]

C++的string类重载了==运算符,从而能检测字符串内容的相等性。Java字符串的外观像数值,但是进行相等性测试时,则表现得类似于指针。C语言使用str函数进行比较,Java中也提供了类似的compareTo方法

if(greeting.compareTo("Hello")==0)...

但还是推荐使用更为清晰的equals方法

3.6.5空串与Null串#

空串”“是长度为0的字符串,是一个Java对象,有自己的长度(0)和内容(空)

String变量还可以存在一个特殊的值,名为null,表示目前没有任何对象与该变量关联

若要检查一个字符串既不是null也不是空串,可以使用

if(str!=null && str.length()!=0)

首先要检查str不为null,因为如果在一个null值上调用方法,会出现错误,所以上述检查顺序不可调换

3.6.6 码点与代码单元#

length()方法将返回采用UTF-16编码表示给定字符串所需要的代码单元个数。

String greeting="Hello";
int n=greeting.length();//n is 5

要想得到实际长度,即码点个数,调用:

int cpCount=greeting.codePointerCount(0,greeting.length());

调用s.charAt(n)将返回位置n的代码单元,n介于0~s.length()-1之间

char first=greeting.charAt(0);//"H"
char last=greeting.charAt(4);//"o"
String sentence="😈 is a sentence";

int n=sentence.length();
int cpCount=sentence.codePointCount(0, sentence.length());

int index=sentence.offsetByCodePoints(0, 0);
int cp=sentence.codePointAt(index);//得到第0个码点

System.out.println(n);//16 😈占用两个代码单元
System.out.println(cpCount);//15
System.out.println(cp);//128520

不能忽略在U+FFFF以上的奇怪字符,喜欢emoji表情符号的用户可能会在字符串中下入类似🍺的字符

要想遍历一个字符串并且一次查看每一个码点,可以使用以下语句

int cp=sentence.codePointAt(i);
i+=Character.charCount(cp);

可以使用以下语句实现反向遍历

i--
if(Character.isSurrogate(sentence.charAt(i))) i--;//判断是否是辅助字符/代理字符
int cp=sentence.codePointAt(i);

使用codePoints方法更为简易,它会生成int值的一个流,每个int值对应一个码点,可以将流转换为一个数组,再完成遍历

int[] codePoints=str.codePoints().toArray();

反之要把一个码点数组转换为字符串,可以使用构造器

String str=new String(codePoints,0,codePoints.length);

要把单个码点转换成字符串,可以使用Character.toString(int)方法

int codePoint=0x1F37A;
str=Character.toString(codePoint);

[!NOTE]

虚拟机不一定把字符串实现为代码单元序列,在Java9中使用了一个跟为紧凑的表示,如果只包含单字节代码单元,字符串使用Byte数组实现,所有其他字符串使用char数组

3.6.7 String API#

API(Application Programming Interface):应用程序编程接口

类 java.lang.String 1.0(引入版本号,下同)

  • char charAt(int index)

    返回给定位置的代码单元,除非对底层代码感兴趣,否则不需要

  • int codePointAt(int index) 5

    返回从给定位置开始的码点

  • int offsetByCodePoints(int startIndex,int cpCount) 5

    返回从startIndex码点开始,cpCount个码点后的码点索引

  • int compareTo(String other)

    按照字典顺序,如果字符串位于other之前,返回一个负数;如果相等,返回0;如果位于之后,返回一个正数

  • IntStream codePoints() 8

    将这个字符串的码点作为一个流返回,调用toArray将它们放在数组中

  • new Stirng(int[] codePoints,int offset,int count) 5

    用数组中从offset开始的count个码点构造一个字符串

  • boolean isEmpty()

    boolean isBlank() 11

    如果字符串为空/由空白符组成,返回true

  • boolean equals(Object other)

    如果字符串与other相等,返回true

  • boolean equalsIgnoreCase(String other)

    如果字符串与other相等(忽略大小写),返回true

  • boolean startsWith(String prefix)

    boolean endsWith(String suffix)

    如果字符串以prefix开头或者以suffix结尾返回true

  • int indexOf(String str)

  • int indexOf(String str,int fromIndex)

  • int indexOf(int cp)

  • int indexOf(int cp,int fromIndex)

    返回与字符串str或码点cp相等的第一个子串的开始位置,从索引0或fromIndex开始匹配,如果str或cp不在字符串中,则返回-1

  • int lastIndexOf(String str)

  • int lastIndexOf(String str,int fromIndex)

  • int lastIndexOf(int cp)

  • int lastIndexOf(int cp,int fromIndex)

    返回与字符串str或码点cp相等的最后一个子串的开始位置,从字符串末尾或fromIndex开始匹配,如果str或cp不在字符串中,则返回-1

  • int length()

    返回字符串代码单元的个数

  • int codePointCount(int startIndex,int endIndex) 5

    返回从startIndex到endIndex-1之间的码点个数

  • String replace(CharSequence oldString,CharSequence newString)

    返回一个新字符串,这是用newString替换原始字符串中与oldString匹配的所有子串得到的,可以用String或者StringBuilder对象作为CharSequence参数

  • String substring(int beginIndex)

  • String substring(int beginIndex,int endIndex)

    返回一个新字符串,这个字符串包含原始字符串从beginIndex开始到字符串末尾或endIndex-1的所有代码单元

  • String toLowerCase()

  • String toUpperCase()

    返回一个新字符串,这个字符串包含原始字符串中的所有字符,不过将原始字符串中的大写字母全部小写,或将小写字母全部大写

  • String strip() 11

    String stripLeading() 11

    String stripTrailing() 11

    返回一个新字符串,这个字符串要删除原始原始字符串头部和尾部或者只是头部或尾部的空白符。要用这些方法而不是使用古老的trim方法删除小于等于U+0020的字符

  • String join(CharSequence delimiter,CharSequence… elements) 8

    返回一个新字符串,用给定的定界符连接所有元素

  • String repeat(int count)

    返回一个字符串,将当前字符串重复count次

[!IMPORTANT]

在API注释中,有一些CharSequence类型的参数,这是一种接口类型,所有字符串都属于这种接口,当看到一个CharSequence形参(parameter)时,完全可以传入String类型的实参(argument)

3.6.8 联机API文档***#

类与方法数量过多,不可能全部记住,必须学会阅读联机API文档

Overview (Java SE 17 & JDK 17)

3.6.9 构建字符串#

采用字符串拼接的方式将较短的字符串构建长字符串效率低,使用StringBuilder类可以避免此问题

StringBuilder builder = new StringBuilder();//构建一个空的字符串构建器
builder.append(ch);//appends a single character
builder.append(str);//appends a string
String completedString=builder.toString();//得到一个包含构建器中字符序列的String对象

[!TIP]

StringBuffer类的效率不如StringBuilder类,但是它允许多线程的方式添加或删除字符串,如果所有字符串编辑都在单个线程中操作(通常情况),则应当使用StringBuilder类。这两个类的API是一样的

java.lang.StringBuilder 5

  • StringBuilder()

    构造一个空的字符串构建器

  • int length()

    返回构建器或缓冲器中的代码单元数量

  • StringBuilder append(String str)

    追加一个字符串并返回this

  • StringBuilder append(char c)

    追加一个代码单元并返回this

  • StringBuilder appendCodePoint(int cp)

    追加一个码点,将它转换为一个或两个代码单元并返回this

  • void setCharAt(int i,char c)

    将第i个代码单元设置为c

  • StringBuilder insert(int offset,String str)

    在offset位置插入一个字符串并返回this

  • StringBuilder insert(int offset,char c)

    在offset位置插入一个代码单元并返回this

  • StringBuilder delete(int startIndex,int endIndex)

    删除从startIndex到endIndex-1的代码单元并返回this

  • String toString()

    返回一个字符串,其数据与构建器或缓冲器内容相同

3.6.10 文本块#

利用java15新增的文本块特性,可以很容易地提供跨多行的字符串字面量,文本块以"""开头,后面是一个换行符,并以另一个"""结束

        String greeting= """
                Hello
                World
                """;

特点:易于读写

这个字符串有两个,一个在Hello后面,一个在World后面,开始"""后面的换行符不作为字符串字面量的一部分

文本块很适合包含其他语言编写的代码,如SQL或HTML

        String html= """
                <div class="warning">
                    Beware of those who say "Hello" to the world.
                </div>
                """;

只在以下两种对引号转义:

  1. 文本块以一个引号结尾
  2. 文本块中包含三个或更多引号所组成的一个序列

所有反斜线,有一个转义序列只能在文本块中使用,行尾的

String html= """
                Hello,my name is lyh.\
                Please enter your name:""";

文本块会对行结束符进行标准化,删除末尾的空白符,并把Windows 的行结束符()改为简单的换行符()。假如需要保留末尾的空格,请把最后一个空格转换为一个

        String html= """
                <div class="warning">
                    Beware of those who say "Hello" to the world.
                </div>
                """;

文本块将去除所有行的公共缩进

实际字符串为”
Beware of those who say “Hello” to the world.

去除缩进过程中不考虑空行,不过,结束”““前面的空白符很重要,一定要缩进到想要去除的空白符末尾

3.6输入与输出#

简单介绍如何使用基本控制台来实现输入输出而非使用GUI

3.7.1 读取输入#

要想读取控制台输入,首先要构造一个与“标准输入流”System.in关联的Scanner对象

Scanner in=new Scanner(System.in);

然后使用Scanner类的各种方法读取输入,如nextLine方法将读取一行输入

String name=in.nextLine();

在这里使用nextLine方法是因为输入行中可能含有空格,要想读取一个单词(以空白符为分隔符),可以调用next方法

String firstName=in.next();

要想读取一个整数,使用nextInt方法

int age=in.nextInt();

类似地,使用nextDouble读取下一个浮点数

Scanner类在java.util包中定义,使用请先导入(import)

扩展:util:utility package 工具包/实用程序包

[!CAUTION]

因为输入对所有人都可见,所以Scanner类不适用于从控制台读取密码,可以使用Console类打到读取密码的目的

Console cons=System.console();
String username=cons.readLine("Enter your name: ");
char[] password=cons.readPassword("Enter your password: ");

为什么使用字符数组char[]存储密码比使用字符串更安全?

一旦创建了一个String对象,它的内容就不能被修改,意味着,密码一旦被存储为String,它就会一直存在于内存中,直到被垃圾回收器(GC)回收,同时由于String是不可变的,Java 中的字符串常量池(String Pool)可能会永久保留这个密码,增加被恶意访问的风险。

完成对密码的处理后,应该马上用一个填充值覆盖数组元素。

使用Console对象处理输入不如使用Scanner来的方便,必须一次读取一行输入,而且Console类没有提供方法来读取单个单词或数字

java.util.Scanner 5 常用方法

  • Scanner(InputStream in)

    用给定的输入流构造一个Scanner对象

  • String nextLine()

    读取下一行输入

  • String next()

    读取输入的下一个单词(以空白符为分隔符)

  • int nextInt()

  • double nextDouble()

    读取并转换下一个表示整数或浮点数的字符序列

  • boolean hasNext()

    检测输入中是否还有其他单词

  • boolean hasNextInt()

  • boolean hasNextDouble

    检测下一个字符序列是否表示一个整数或一个浮点数

java.lang.System 1.0

  • static Console console() 6

    如果有可能进行交互操作,就通过控制台窗口为交互的用户返回一个Console对象,否则返回null,对于任何一个在控制台窗口启动的程序,都可使用Console对象。否则,是否可用取决于使用的系统

java.io.Console 6

  • static char[] readPassword(String prompt,Object… args)(在命令行/终端中使用不显示输入)

  • static String readLine(String prompt,Object… args)

    显示提示符(prompt)并读取用户输入,直到输入行结束,可选的args参数用来提供格式参数

3.7.2格式化输出#

可以使用System.out.println(x)语句将数值x输出到控制台,这个命令将以x的类型所允许的最大非零位数打印x。可以使用printf方法解决某些格式问题

double x=10000.0/3.0;
System.out.printf("%8.2f",x);

打印x时字段宽度为8个字符,精度为2个字符,结果为” 3333.33”,包含一个前导空格

可以为printf提供多个参数

System.out.printf("Hello,%s.Next year,you'll be %d years old.\n",name,age);

每一个以%开头的格式说明符都替换为相应的参数,格式说明符末尾的转换字符指示要格式化的数值的类型:f表示浮点数,s表示字符串,d表示十进制整数

用于printf的转换字符如下

转换字符 类型 示例
d 十进制整数 156
x或X 十六进制整数,要想对十六进制整数进行更多的操作,可以使用HexFormat类 9f
e 八进制整数 237
f或F 定点浮点数 15.9
e或E 指数浮点数 1.59e+01
g或G 通用浮点数(e和f中比较短的一个) ——
a或A 十六进制浮点数 0x1.fccdp3
s或S 字符串 Hello
c或C 字符 H
b或B 布尔 true
h或H 散列码 42628b2
tx或TX 遗留的日期时间格式化,应当改为java.time类 ——
% 百分号(即%%=%) %
n 与平台有关的行分隔符 ——

大写形式会生成大写字母,如”%8.2E”将3333.33格式化为3.33E+03

[!NOTE]

可以使用s转换字符格式化任意的对象,如果一个任意对象实现了Formattable接口,格式化时调用这个对象的formatTo方法,否则,会调用toString方法将这个对象转换为一个字符串

另外,还可以指定控制格式化输出外观的各种标志(flag)例如,逗号标志会增加分组分隔符

System.out.println("%,.2f"10000.0/3.0)//打印3,333.33

可以使用多个标志,例如”%,(.2f”会使用分组分隔符,并将负数包围在括号内

标志 作用 示例
+ 打印正数或负数的符号 +3333.33
空格 在正数前面增加一个空格 | 3333.33|
0 增加前导0 003333.33
- 字段左对齐 |3333.33|
( 将负数包围在括号内 (3333.33)=-3333.33
, 增加分组分隔符 3,333.33
#(对于f格式) 总是包含一个小数点 3,333.
#(对于x或0格式) 添加前缀0x或0 0xcafe
$ 指定要格式化的参数索引,例如%1$x将以十六进制打印第一个参数 159 9F
< 格式化前面指定的同一个值,%d<%x将以十进制和十六进制打印同一个数 159 9F

可以使用静态的String.format方法创建一个格式化的字符串而不打印输出

String message=String.format("Hello,%s.Next year,you'll be %d years old.\n",name,age+1);

在Java15中,可以使用formatted方法

String message="Hello,%s.Next year,you'll be %d years old.\n".formatted(name,age+1);

[!CAUTION]

格式化规则是特定于本地环境的,例如在德国分组分隔符是点号而不是逗号,因此要学习如何控制应用的国际化行为

3.7.3文件输入与输出#

要想读取一个文件,需要先构造一个Scanner对象

Scanner in=new Scanner(Path.of("myfile.txt"), StandardCharsets.UTF_8);

如果文件名中包含反斜线符号,要在每个反斜线符号之前加上一个额外的反斜线进行转义

然后可以使用跟之前的任何Scanner方法读取文件

[!NOTE]

上面指定了UTF-8字符编码,这对于互联网上的文件很常见,但并不一定完全适用,所以在读取一个文本文件时,要知道它的字符编码,若省略字符编码则会使用运行这个JAVA程序的机器的默认编码,这并不是是一个好主意,因为这会导致在不同的机器上运行这个程序可能会有不同的表现

[!WARNING]

可以提供一个字符串参数来构造一个Scanner,但这个Scanner会把字符串解析为数据而非文件名,例如

Scanner in=new Scanner("myfile.txt");

这个Scanner会将参数看作是包含十个字符的数据

要想写入文件,需要构造一个PrintWriter

PrintWriter out = new PrintWriter("myfile.txt", StandardCharsets.UTF_8);

如果文件不存在,则会先创建该文件,可以像输出到System.out一样使用println、print、printf等命令

[!NOTE]

当指定一个相对文件名时,例如”myfile.txt”“../myfile.txt”,文件将相对于启动JAVA虚拟机的那个目录放置

如果从一个命令Shell执行一下命令启动程序

java MyProg

启动目录就是命令shell的当前目录,不过如果使用集成开发环境,那么启动目录将由IDE(Integrated Development Environment)控制,可以使用下面的调用找到这个目录的位置

String dir=System.getProperty("user.dir");

如果觉得文件定位太麻烦,建议使用绝对路径,如”/home/me/mydirectory/myfile.txt”等(也可使用反斜线,但要注意转义)

如果用一个不存在的文件构造一个Scanner,或者用一个无法创建的文件名构造一个PrintWriter,就会产生异常,JAVA编译器认为这些异常比”被零除”更严重

    public static void main(String[] args) throws IOException {//处理异常
        Scanner out = new Scanner(Path.of("myfile.txt"), StandardCharsets.UTF_8);
    }

[!CAUTION]

从命令shell启动一个程序时,可以利用shell的重定向语法将任意文件关联到System.in和System.out

java MyProg<myfile.txt>output.txt//将标准输入重定向到 myfile.txt 文件,同时将标准输出重定向到 output.txt 文件

这样就不用担心处理IOException异常了(Java程序本身不需要显式地处理这些IO异常。这是因为重定向是由shell处理的,而不是由Java程序直接处理的)

java.util.Scanner 5

  • Scanner(Path p,String encoding)

    构造一个Scanner使用给定字符编码从给定路径读取数据

  • Scanner(String data)

    构造一个Scanner从给定字符串读取数据

java.io.PrintWriter 1.1

  • PrintWriter(String fileName)

    构造一个PrintWriter将数据写入指定文件

java.nio.file.Path

  • static Path of(String pathname) 11

    由指定路径名构造一个Path

3.8控制流程#

与任何程序设计语句一样,java支持使用条件语句和循环结构来控制流程

[!NOTE]

Java的控制流程结构与C/C++基本相同,只有很少几个例外。Java中没有goto语句,但break语句可以带标签,可以利用它从嵌套循环中跳出(C中可能就要使用goto语句了)。以及还有一种变形的for循环,有点类似于C++中基于范围的for循环和C#中的foreach循环

3.8.1块作用域#

块(即复合语句)由若干条Java语句组成,并用一对大括号括起来,块确定了变量的作用域,一个块可以嵌套在另一个块中

不能在嵌套的两个块中声明同名变量

public static void main(String[] args)
{
    int n;
    ...
    {
        int k;
        int n;//ERROR--can't redeclare n in inner block
        ...
    }
}

[!NOTE]

在C++中,可以在嵌套的块中重定义一个变量,在内层定义的变量会屏蔽在外层定义的变量,这就有可能带来编程错误,因此Java中不允许这样做

3.8.2条件语句#

条件语句的形式为if(condition) statement

其中statement可以是块语句

if(condition)
{
    statement1
    statement2
    ...
}

更一般的条件语句 if(condition) statement1 else statement2 同样可以替换为块语句

else子句与最邻近的if构成一组

if(x<=0)if(x==0) sign=0;else sign=-1;

else与第二个if配对,但建议使用大括号让代码更加清晰

if(x<=0){if(x==0) sign=0;else sign=-1;}

反复使用if…else if…也很常见

if(){}
else if(){}
else if(){}
...
else{}

3.8.3 循环#

while循环会在条件为true时反复执行一个(块)语句,如果开始时循环条件就为false,那么while循环一次也不执行

如果希望循环体至少循环一次,需要使用do/while循环将检测放到最后 do statement while(condition)

这种循环先执行语句(块),然后再检查循环条件,如果条件为true,重复执行语句,然后继续检测,以此类推

示例程序1

import java.util.*;

/**
 * This program demonstrates a <code>while</code> loop.
 * @version 1.20 2004-02-10
 * @author Cay Horstmann
 */
public class Retirement
{
   public static void main(String[] args)
   {
      // read inputs
      Scanner in = new Scanner(System.in);

      System.out.print("How much money do you need to retire? ");
      double goal = in.nextDouble();

      System.out.print("How much money will you contribute every year? ");
      double payment = in.nextDouble();

      System.out.print("Interest rate in %: ");
      double interestRate = in.nextDouble();

      double balance = 0;
      int years = 0;

      // update account balance while goal isn't reached
      while (balance < goal)
      {
         // add this year's payment and interest
         balance += payment;
         double interest = balance * interestRate / 100;
         balance += interest;
         years++;
      }

      System.out.println("You can retire in " + years + " years.");
   }
}

示例程序2

import java.util.*;

/**
 * This program demonstrates a <code>do/while</code> loop.
 * @version 1.20 2004-02-10
 * @author Cay Horstmann
 */
public class Retirement2
{
   public static void main(String[] args)
   {
      Scanner in = new Scanner(System.in);

      System.out.print("How much money will you contribute every year? ");
      double payment = in.nextDouble();

      System.out.print("Interest rate in %: ");
      double interestRate = in.nextDouble();

      double balance = 0;
      int year = 0;

      String input;

      // update account balance while user isn't ready to retire
      do
      {
         // add this year's payment and interest
         balance += payment;
         double interest = balance * interestRate / 100;
         balance += interest;

         year++;

         // print current balance
         System.out.printf("After year %d, your balance is %,.2f%n", year, balance);

         // ask if ready to retire and get input
         System.out.print("Ready to retire? (Y/N) ");
         input = in.next();
      }
      while (input.equals("N"));
   }
}

3.8.4 确定性循环#

for循环语句是支持迭代的一种通用结构,它由一个计数器或类似的变量控制迭代次数,每次迭代后更新变量。

for(int i=1;i<=10;i++)
    System.out.println(i);

for语句的第一部分通常是对计数器初始化,第二部分给出每次新一轮循环执行前要检测的循环条件;第三部分指定如何更新计数器,建议三个部分使用相同的计数器变量,否则可能循环晦涩难懂

[!WARNING]

在循环中检测两个浮点数是否相等需要格外小心

for(double x=0;x!=10;x+=0.1)...

这个循环永远不会结束,由于存在舍入误差,可能永远无法达到精确的最终值,上述循环中由于0.1无法精确地用二进制表示,x将从9.99999999999998跳到10.0999999999998

如果变量在for语句内部声明,就只能在循环体内使用,若希望在外部使用则需要在外部声明

可以在不同的for循环中定义同名变量

for循环只是while循环的一种简化形式

示例程序

import java.util.*;

/**
 * This program demonstrates a <code>for</code> loop.
 * @version 1.20 2004-02-10
 * @author Cay Horstmann
 */
public class LotteryOdds
{
   public static void main(String[] args)
   {
      Scanner in = new Scanner(System.in);

      System.out.print("How many numbers do you need to draw? ");
      int k = in.nextInt();

      System.out.print("What is the highest number you can draw? ");
      int n = in.nextInt();

      /*
       * compute binomial coefficient n*(n-1)*(n-2)*...*(n-k+1)/(1*2*3*...*k)
       */

      int lotteryOdds = 1;
      for (int i = 1; i <= k; i++)
         lotteryOdds = lotteryOdds * (n - i + 1) / i;

      System.out.println("Your odds are 1 in " + lotteryOdds + ". Good luck!");
   }
}

3.8.5 多重选择:switch语句#

适用于处理同一个表达式的多个选项

      String input="";
      switch(input.toLowerCase())
      {
         case "yes","y"->
            ...
         case "no","n"->
            ...
         default ->
            ...
      }

switch的经典形式可以追溯到C语言

int chOice=...;
switch(choice)
{
    case 1:
        ...
        break;
    case 2:
        ...
        break;
    ...
    default://bad input
        ...
        break;
}

switch语句从与 选项值相匹配的case标签开始执行,直到遇到下一个break语句,或者执行到switch语句结束。如果没有匹配的case标签,则执行default子句(若有)。

[!WARNING]

有可能会触发多个分支。如果忘记在一个分支末尾加上break语句,就会接着执行下一个分支,这很容易引发错误

为了检测这种问题,编译代码时可以加上-Xlint:fallthrough选项

javac -Xlint:fallthrough Test.java

这样一来如果某个分支最后缺少一个break语句编译器就会给出一个警告

如果确实要使用这种直通式行为(执行多个分支),可以为其外围方法添加一个注解@SuppressWarnings(“fallthrough”),就不会生成警告了(注解是为编译器或处理Java源文件或类文件的工具提供信息的一种机制)

switch表达式没有直通式行为,但为了对称,Java14引入了有直通行为的switch表达式

有直通行为的以冒号结束,无直通行为则以箭头结束,二者不能混用

表达式

无直通行为

      int numLetters=switch(seasonName)
      {
         case "Spring" ->
         {
           System.out.print("spring time!");//增加日志语句
           yield 6;//yield:终止执行,并生成表达式的值
         }
         case "Summer","Winter"->5;
         case "Fall" ->4;
         default -> -1;
      };

有直通行为

      int numLetters=switch(seasonName)
      {
         case "Spring" :
         {
           System.out.print("spring time!");
         }
         case "Summer","Winter":
            yield 5;
         case "Fall" :
            yield 4;
         default:
            yield -1;
      };

语句

无直通行为

      int numLetters;
      switch (seasonName)
      {
         case "Spring"->
         {
            System.out.print("spring time");
            numLetters=6;
         }
         case "Summer","Winter"->
            numLetters=5;
         case "Fall"->
            numLetters=4;
         default -> 
            numLetters=-1;
      }

有直通行为

int numLetters;
switch (seasonName)
{
   case "Spring":
   {
      System.out.print("spring time");
   }
   case "Summer","Winter":
      numLetters=5;
      break;
   case "Fall":
      numLetters=4;
      break;
   default :
      numLetters=-1;
}

switch表达式必须为每个可能的case生成一个值(经常需要default),但是语句则不用

[!NOTE]

完全可以在switch表达式 的一个分支中抛出异常

default->throw new IllegalArgumentException("Not a valid season");

[!WARNING]

switch表达式的关键在于生成一个值(或者产生一个异常而失败),不允许跳出表达式

default->{return -1}//Error

不能在switch表达式中使用return,break,continue语句

switch表达式优于switch语句,只有在确实需要直通式行为,或者必须为一个switch表达式增加语句时,才需要使用break或yield

3.8.6中断控制流程的语句#

尽管Java的设计者将goto仍作为一个保留字,但实际上并不打算在语言中包含goto。通常使用goto会被认为是一种拙劣的编码风格,无限制地使用goto语句很容易导致错误,但在有些情况下偶尔使用goto跳出循环还是很有益处的。

不带标签的break#

退出循环(最内层)

带标签的break(与C++不同,新增)#

允许跳出多重嵌套的循环(有时希望跳出所有循环)

标签必须放在你想跳出的最外层循环之前,并且紧跟一个冒号

Scanner in=new Scanner(System.in);
int n;
read_data:
while(...)//this loop statement is tagged with the label
{
    ...
    for(...)//this inner loop is not labeled
    {
        System.out.print("...");
        n=.in.nextInt();
        if(n<0)
            break read_data;//break out of read_data loop
        ...
    }
}
//this statement is executed immediately after the label break
//即如果输入有误,执行带标签的break会跳转到带标签的语句块末尾
//与任何使用break语句的代码一样,接下来需要检测循环是正常退出的还是由于break提前退出
if(n<0)//check for bad situation
{
    //deal with bad situation
}
else
{
    //carry out normal situation
}

实际上,可以将标签应用到任何语句,包括if语句或块语句。但只能跳出语句块,而不能跳入语句块

continue语句(不带标签)#

将控制转移到最内层外围循环首部,如果在for循环中使用会跳转到for循环的更新部分

带标签的continue语句#

3.9大数#

使用java.math包中两个很有用的类:BigIntegerBigDecimal可以处理包含任意长度数字序列的数值,BigInteger实现任意精度的整数运算,BigDecimal类实现任意精度的浮点数运算

使用静态的valueOf方法可以将一个普通的数转换成大数

BigInteger a=BigInteger.valueOf(100);

对于更长的数,可以使用一个带字符串参数的构造器:

BigInteger reallyBig=new BigInteger("65659874454564561654165413146515614231685456212348954");

一些常量:BigInteger.ZERO,BigInteger.ONE,BigInteger.TEN,java 9之后还新增BigInteger.TWO

[!WARNING]

对于BigDecimal类,总是应当使用一个带字符串参数的构造器,还有一个BigDecimal(double)构造器,但是这个构造器本质上很容易产生舍入误差,例如new BigDecimal(0.1)会得到以下数位:0.1000000000000000055511151231257827021181583404541015625

不能使用熟悉的算术运算符如+、*等来组合大数,而需要使用 大数类中的add和multiply方法

BigInteger c=a.add(b);//c=a+b
BIgInteger d=c.mutiply(b.add(BigInteger.valueOf(2)));//d=c*(b+2)

[!NOTE]

与C++不同,Java不能通过编程实现运算符重载,使用BigInteger类的程序员无法重定义+和*运算符来提供BigInteger类的add和mutiply运算。Java设计者重载了+运算符来完成字符串拼接,但没有重载其他运算符,也没有给Java程序员重载的机会

示例程序

import java.math.*;
import java.util.*;

/**
 * This program uses big numbers to compute the odds of winning the grand prize in a lottery.
 * @version 1.21 2021-09-03
 * @author Cay Horstmann
 */
public class BigIntegerTest
{
   public static void main(String[] args)
   {
      Scanner in = new Scanner(System.in);

      System.out.print("How many numbers do you need to draw? ");
      int k = in.nextInt();

      System.out.print("What is the highest number you can draw? ");
      BigInteger n = in.nextBigInteger();

      /*
       * compute binomial coefficient n*(n-1)*(n-2)*...*(n-k+1)/(1*2*3*...*k)
       */

      BigInteger lotteryOdds = BigInteger.ONE;

      for (int i = 1; i <= k; i++)
         lotteryOdds = lotteryOdds
            .multiply(n.subtract(BigInteger.valueOf(i - 1)))
            .divide(BigInteger.valueOf(i));

      System.out.printf("Your odds are 1 in %s. Good luck!%n", lotteryOdds);
   }
}

java.math.BigInteger 1.1

  • BigInteger add(BigInteger other)

  • BigInteger subtract(BigInteger other)

  • BigInteger mutiply(BigInteger other)

  • BigInteger divide(BigInteger other)

  • BigInteger mod(BigInteger other)

    返回这个大整数和另一个大整数other的和、差、积、商和余数

  • BigInteger sqrt() 9

    得到这个BigInteger的平方根

  • int compareTo(BigInteger other)

    如果和另一个大整数other相等,返回0;如果小于other,返回负数;否则返回正数

  • static BigInteger valueOf(long x)

    返回 值等于x的大整数

java.math.BigDecimal 1.1

  • BigDecimal(String digits)

    用给定数位构造一个大实数

  • BigDecimal add(BigDecimal other)

  • BigDecimal subtract(BigDecimal other)

  • BigDecimal mutiply(BigDecimal other)

  • BigDecimal divide(BigDecimal other)

  • BigDecimal divide(BigDecimal other,RoundingMode mode)

    返回这个大实数与另一个大实数other的和差积商。如果商是一个无限小数,第一个divide方法会抛出一个异常,要得到一个舍入的结果,就要使用第二个方法。RoundingMode.HALF_UP是四舍五入,其他舍入方法详见API文档

  • int compareTo(BigDecimal other)

    如果和另一个大实数other相等,返回0;如果小于other,返回负数;否则返回正数

3.10数组#

数组存储相同类型值的序列

3.10.1 声明数组#

数组是一种数据结构,用来存储同一类型值的集合,通过一个整型下标(index)可以访问数组中的每一个值

int[] a;//声明数组
int[] a=new int[100];//or var a=new int[100];//初始化

数组长度不要求是常数:new int[n]会创建一个长度为n的数组->根据n的当前值,并不会跟着n动态变化

一旦创建了数组,就不能再改变它的长度,如果程序运行过程中经常需要扩展数组的长度,就应该使用另一种数据结构——数组列表(array list)

[!TIP]

int[] a和int a[]均正确,但推荐使用int[] a,因为他可以将类型(整数数组)和变量名清晰地分开

Java还提供了一种创建数组对象并提供初始值的简写形式,不需要new,也不需要指明数组长度

int[] Primes={2,3,5,7};
String[] authors=
{
    "nailong",
    "James Gosling",
}

最后一个值后面允许有逗号,方便后续为数组增加值

可以使用以下语法重新初始化一个数组而无需创建新变量

smallPrimes=new int[] {2,3,5,7};

[!IMPORTANT]

在Java中,允许有长度为0的数组,如果编写一个结果为数组的方法时碰巧结果为空,可以构造如下数组:

new elementType[0]//or new elementType[]{}

长度为0的数组与null并不同

3.10.2 访问数组元素#

数组元素从0开始编号,最后一个合法索引为数组长度-1

创建一个数字数组时,所有元素都初始化为0;boolean数组的元素会初始化为false;对象数组(如字符串)的元素则初始化为一个特殊值null,表示还未存放任何对象

[!CAUTION]

C/C++中若没有初始化数组可能为乱码

如果创建一个包含一百个元素的数组然后访问a[100]就会出现”array index out of bounds”(数组索引越界)异常

要想获取数组中元素个数可以使用array.length

3.10.3 for each循环#

增强的for循环形式(遍历数组而不用考虑指定索引值)

for(variable:collection)statement//循环collection中的每一个元素

variable:集合中的每一个元素

collection:数组或是一个实现了Iterable接口的对象(例如ArrayList)

优点:简洁;不易出错;不必考虑起始和终止索引值,避免越界访问

缺点:只能遍历整个数组;无法在循环内部使用索引值(此时使用传统for循环更佳)

[!NOTE]

利用Arrays类中的toString方法可以更容易地打印数组中的所有值,调用Arrays.toString(a)会返回一个包含数组元素的字符串,这些元素包含在中括号中,并用逗号分隔,如”[2,3,5,7]“(并非逐个输出,而是整体输出)

3.10.4数组拷贝#

在Java中,允许将一个数组变量拷贝到另一个数组变量,此时两个变量将引用同一个数组(指针)

int[] luckyNumbers=smallPrimes;

如果只希望复制值,就要使用Arrays类的copyOf方法

int[] copiedLuckyNumbers=Arrays.copyOf(luckyNumbers,luckNumbers.length);//第二个参数为新数组长度

可以利用此方法来增加数组长度

luckyNumbers=Arrays.copyOf(luckyNumbers,2*luckNumbers.length);

如果数组元素是数值型,那么新增元素将填入0;如果是布尔型,填入false。如果新数组长度小于原数组,只拷贝前面的值。

[!CAUTION]

Java数组与堆栈上的C++数组有很大不同,但基本上与在堆上的分配的数组指针一样

也就是说

int[] a=new int[100];

不同于

int a[100];

而等同于

int* a=new int[100];

Java中的[]运算符预定义为会完成越界检查(编写代码时便会出现越界警告)。另外,没有指针运算,意味着不能通过a+1得到数组中的下一个元素

3.10.5 命令行参数#

String args[]表明main方法接受一个字符串数组,也就是命令行上指定的参数

public class Message{
   public static void main(String[] args)
   {
    if(args.length==0||args[0].equals("-h"))
        System.out.print("Hello,");
    else if(args[0].equals("-g"))
        System.out.print("Goodbye,");
    for(int i=1;i< args.length;i++)
        System.out.print(" "+args[i]);
    System.out.println("!");
   }
}

如果如下调用程序

java Message -g cruel world

args数组将包含以下内容:args[0]:“-g” args[1]:“cruel” args[2]:“world”

程序将会显示:Goodbye,cruel world!

[!NOTE]

在Java程序的main方法中,程序名并不存储在args数组中,例如

java Message -h world

args[0]是”-h”,而不是”Message”或者”java”

3.10.6 数组排序#

想对数值型数组排序可以使用Arrays类中的sort方法,这个方法使用了优化的快速排序算法

示例程序

import java.util.*;

/**
 * This program demonstrates array manipulation.
 * @version 1.20 2004-02-10
 * @author Cay Horstmann
 */
public class LotteryDrawing
{
   public static void main(String[] args)
   {
      Scanner in = new Scanner(System.in);

      System.out.print("How many numbers do you need to draw? ");
      int k = in.nextInt();

      System.out.print("What is the highest number you can draw? ");
      int n = in.nextInt();

      // fill an array with numbers 1 2 3 . . . n
      int[] numbers = new int[n];
      for (int i = 0; i < numbers.length; i++)
         numbers[i] = i + 1;

      // draw k numbers and put them into a second array
      int[] result = new int[k];
      for (int i = 0; i < result.length; i++)
      {
         // make a random index between 0 and n - 1
         int r = (int) (Math.random() * n);

         // pick the element at the random location
         result[i] = numbers[r];

         // move the last element into the random location
         numbers[r] = numbers[n - 1];
         n--;
      }

      // print the sorted array
      Arrays.sort(result);
      System.out.println("Bet the following combination. It'll make you rich!");
      for (int r : result)
         System.out.println(r);
   }
}

其中Math.random方法将返回一个0到1之间(包含0而不包含1)的随机浮点数,用n乘以这个浮点数就可以得到从0到n-1的一个随机数

int r=(int)(Math.random()*n);

java.util.Arrays 12

  • static String toString(xxx[] a) 5

    返回一个字符串,其中包含a中的元素,这些元素用中括号包围。并用逗号分隔。在这个方法和后面 的方法中,数组元素类型xxx可以是int,long,short,char,byte,boolean,float,double

  • static xxx[] copyOf(xxx[] a,int end) 6

  • static xxx[] copyOfRange(xxx[] a,int start,int end) 6

    返回与a类型相同的一个数组,其长度为end或end-start,并填入a中对应的值,如果end大于a.length,结果会填充0或false

  • static void sort(xxx[] a)

    使用优化的快排算法排序

  • static int binarysearch(xxx[] a,xxx v)

  • static int binarysearch(xxx[] a,int start,int end,xxx v) 6

    使用二分查找算法在有序数组a中查找值v,如果找到v,返回对应索引;否则返回一个负数值r,-r-1是v应该插入的位置

  • static void fill(xxx[] a,xxx v)

    将所有数组元素设置为v

  • static boolean equals(xxx[] a,xxx[] b)

    如果两个数组长度相同且相同索引对应元素相同返回true

3.10.7多维数组#

创建、初始化、访问与一维数组类似

[!CAUTION]

for each循环语句不会自动循环处理二维数组的所有元素,他会循环处理行,而这些行本身是一维数组,要想访问二维数组a的所有元素,需要使用两个嵌套循环

for(double[] row:a)
  for(double value:row)
      do something with value

[!NOTE]

要想快速打印一个二维数组的元素列表,可以调用:

System.out.println(Arrays.deepToString(a));

输出格式为:

[[16,3,2,13],[5,10,11,8],[9,6,7,12],[4,15,14,1]]

/**
 * This program shows how to store tabular data in a 2D array.
 * @version 1.41 2023-11-28
 * @author Cay Horstmann
 */
public class CompoundInterest
{
   public static void main(String[] args)
   {
      final double STARTRATE = 5;
      final int NRATES = 6;
      final int NYEARS = 10;

      // set interest rates to 5 . . . 10%
      double[] interestRate = new double[NRATES];
      for (int j = 0; j < interestRate.length; j++)
         interestRate[j] = (STARTRATE + j) / 100.0;

      double[][] balances = new double[NYEARS][NRATES];

      // set initial balances to 10000
      for (int j = 0; j < balances[0].length; j++)
         balances[0][j] = 10000;

      // compute interest for future years
      for (int i = 1; i < balances.length; i++)
      {
         for (int j = 0; j < balances[i].length; j++)
         {
            // get last year's balances from previous row
            double oldBalance = balances[i - 1][j];

            // compute interest
            double interest = oldBalance * interestRate[j];

            // compute this year's balances
            balances[i][j] = oldBalance + interest;
         }
      }

      // print one row of interest rates
      for (int j = 0; j < interestRate.length; j++)
         System.out.printf("%9.0f%%", 100 * interestRate[j]);

      System.out.println();

      // print balance table
      for (double[] row : balances)
      {
         // print table row
         for (double b : row)
            System.out.printf("%10.2f", b);

         System.out.println();
      }
   }
}

3.10.8 不规则数组#

Java实际上没有多维数组,只有一维数组,被解释为”数组的数组”

由于可以单独地访问数组的某一行,所以可以让两行交换。

double[] temp =balance[i];
balance[i]=balance[i+1];
balance[i+1]=temp;

还可以构造一个不规则数组,即数组的每一行有不同的长度

[!CAUTION]

在C++中,Java声明

double[][] balances=new double[10][6];

不同于

double balances[10][6];

也不同于

double (*balances)[6]=new double[10][6];

而是分配了一个包含十个指针的数组

double** balances=new double*[10];

这个指针数组的每一个元素会填充一个包含六个数字的数组

for(i=0;i<10;i++)
  balances[i]=new double[6];

在需要不规则的数组时,需要单独地分配行数组

示例程序

/**
 * This program demonstrates a triangular array.
 * @version 1.20 2004-02-10
 * @author Cay Horstmann
 */
public class LotteryArray
{
   public static void main(String[] args)
   {
      final int NMAX = 10;

      // allocate triangular array
      int[][] odds = new int[NMAX + 1][];
      for (int n = 0; n <= NMAX; n++)
         odds[n] = new int[n + 1];

      // fill triangular array
      for (int n = 0; n < odds.length; n++)
         for (int k = 0; k < odds[n].length; k++)
         {
            /*
             * compute binomial coefficient n*(n-1)*(n-2)*...*(n-k+1)/(1*2*3*...*k)
             */
            int lotteryOdds = 1;
            for (int i = 1; i <= k; i++)
               lotteryOdds = lotteryOdds * (n - i + 1) / i;

            odds[n][k] = lotteryOdds;
         }

      // print triangular array
      for (int[] row : odds)
      {
         for (int odd : row)
            System.out.printf("%4d", odd);
         System.out.println();
      }
   }
}

第四章 对象与类#

4.1 面向对象程序设计概述#

面向对象程序设计(Object-Oriented Programming,OOP)是当今主流的程序设计类型

面向对象的程序是由对象组成的,每个对象包含对用户公开的特定功能和隐藏的实现

OOP将数据放在第一位,然后再考虑操作数据的算法

规模较小的问题将其分解为过程的做法是合适的,规模较大的问题则更适合分解为对象

4.1.1 类#

类指定了如何构造对象,由一个类构造对象的过程称为创建这个类的一个实例

封装(encapsulation,有时称为信息隐藏):将数据和行为组合在一个包中,并对对象的使用者隐藏具体的实现细节

对象中的数据称为实例字段(instance field),操作数据的过程称为方法,作为一个类的实例,一个特定对象有一组特定的实例字段值,这些值的集合就是这个对象的当前状态,调用方法便可能导致状态改变

实现封装的关键在于:绝对不能让其他类中的其他方法直接访问这个类的实例字段,程序只能通过对象的方法与对象数据进行交互。封装为对象赋予了”黑盒”性质

OOP的另一原则:可以通过扩展其他类来构建新类 所有Java类都扩展自Object类 扩展一个已有的类时,新类具有被扩展的类的全部属性和方法

通过一个类来得到另一个新类被称为继承

4.1.2 对象#

对象的三个主要特性:

  • 行为:可以对这个对象进行哪些操作

    同一个类的所有实例对象具有一种家族相似性,他们都支持相同的行为。一个对象的行为由所能调用的方法来定义

  • 状态:调用那些方法时对象会如何响应

    对象状态的改变必然是调用方法的结果,如果不经过方法调用就可以改变对象状态,说明破坏了封装性

  • 标识:如何区分可能有相同行为和状态的不同对象

    对象的状态并不能完全描述一个对象,因为每个对象都有一个唯一的标识。作为同一个类的实例,每个对象的标识总是不同的,状态也通常有所不同

4.1.3 识别类#

设计一个面向对象系统时,首先从识别类开始,然后为各个类添加方法

识别类的一个简单经验是在分析问题的过程中寻找名词,而方法对应动词

4.1.4 类之间的关系#

  • 依赖(“uses-a”)dependence

    如果一个类的方法要使用或操作另一个类的对象,我们就说前一个类依赖于后一个类,是最明显也是最一般的关系

    应当尽可能减少相互依赖的类,用软件工程术语来说就是要减少类之间的耦合(coupling)

  • 聚合(“has-a”)aggregation

    意味着一个类的对象包含另一个类的对象

  • 继承(“is-a”)

    表示一个更特殊的类与一个更一般的类之间的关系

    很多程序员采用UML(Unified Modeling Language,统一建模语言)绘制类图,来描述类之间的关系。类用矩形表示,类之间的关系用带有各种修饰的箭头表示。

4.2 使用预定义类#

4.2.1 对象与对象变量#

要想使用对象,必须先构造对象,并指定其初始状态,然后对对象应用方法。在Java中,要使用构造器构造对象,构造器始终与类同名

Date rightNow=new Date();

对象变量可以引用其所对应类型的对象,但其并非一个对象

startTime=new Date();
startTime=rightNow;

对象变量并不实际包含一个对象,而是只是引用一个对象

Java中,任何对象变量的值都是一个引用,指向存储在另一个地方的某个对象,new操作符的返回值也是一个引用

null指示当前对象还没有引用任何对象

4.2.2 Java类库中的LocalDate类#

用于表示日期,使用静态工厂方法构造对象

LocalDate now = LocalDate.now();
LocalDate writeTime=LocalDate.of(2025,7,14);
int year= writeTime.getYear();//2025
int month=writeTime.getMonthValue();//7
int day=writeTime.getDayOfMonth();//14
LocalDate aThousandsLater=writeTime.plusDays(1000);

[!NOTE]

JDK提供了jdeprscan工具来检查代码中是否使用了Java API中已经废弃的特性。The jdeprscan Command

4.2.3 更改器方法与访问器方法#

只访问对象而不修改对象的方法称为访问器方法,如LocalDate中的plusDays方法会生成一个新的对象

会修改对象的方法称为更改器方法,如GregorianCalendar中的add反法

GregorianCalendar someday=new GregorianCalendar(2025, 6,14);//6代表7月,0-11
someday.add(Calendar.DAY_OF_MONTH, 1000);

给出一个能显示当前月的日历的示例程序,格式如下

import java.time.*;
import java.util.Calendar;
import java.util.GregorianCalendar;

/**
 * @version 1.5 2015-05-08
 * @author Cay Horstmann
 */
public class CalendarTest
{
   public static void main(String[] args)
   {
      LocalDate date = LocalDate.now();
      int month = date.getMonthValue();
      int today = date.getDayOfMonth();

      date = date.minusDays(today - 1); // set to start of month
      DayOfWeek weekday = date.getDayOfWeek();
      int value = weekday.getValue(); // 1 = Monday, . . . , 7 = Sunday

      System.out.println("Mon Tue Wed Thu Fri Sat Sun");
      for (int i = 1; i < value; i++)
         System.out.print("    ");
      while (date.getMonthValue() == month)
      {
         System.out.printf("%3d", date.getDayOfMonth());
         if (date.getDayOfMonth() == today)
            System.out.print("*");
         else
            System.out.print(" ");
         date = date.plusDays(1);
         if (date.getDayOfWeek().getValue() == 1) System.out.println();
      }
      if (date.getDayOfWeek().getValue() != 1) System.out.println();
   }
}

API java.time.LocalDate 8

  • static LocalDate now()
  • static LocalDate of(int year,int month,int day)
  • int getYear()
  • int getMonthValue()
  • int getDayOfMonth()
  • DayOfWeek getDayOfWeek()
  • LocalDate plusDays(int n)
  • LocalDate minusDays(int n)

4.3 自定义类#

4.3.1 Employee类#

最简单类定义形式

class ClassName
{
    field1
    field2
    ...
    constructor1
    constructor2
    ...
    method1
    method2
    ...
}
import java.time.*;

/**
 * This program tests the Employee class.
 * @version 1.13 2018-04-10
 * @author Cay Horstmann
 */
public class EmployeeTest
{
   public static void main(String[] args)
   {
      // fill the staff array with three Employee objects
      Employee[] staff = new Employee[3];

      staff[0] = new Employee("Carl Cracker", 75000, 1987, 12, 15);
      staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
      staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);

      // raise everyone's salary by 5%
      for (Employee e : staff)
         e.raiseSalary(5);

      // print out information about all Employee objects
      for (Employee e : staff)
         System.out.println("name=" + e.getName() + ",salary=" + e.getSalary() + ",hireDay=" 
            + e.getHireDay());
   }
}

class Employee
{
   private String name;
   private double salary;
   private LocalDate hireDay;//为什么不能使用Date

   public Employee(String n, double s, int year, int month, int day)
   {
      name = n;
      salary = s;
      hireDay = LocalDate.of(year, month, day);
   }

   public String getName()
   {
      return name;
   }

   public double getSalary()
   {
      return salary;
   }

   public LocalDate getHireDay()
   {
      return hireDay;
   }

   public void raiseSalary(double byPercent)
   {
      double raise = salary * byPercent / 100;
      salary += raise;
   }
}

源文件名为EmployeeTest.java,这是因为文件名必须与public类的名字相匹配,一个源文件只能有一个公共类,但可以有任意数目的非公共类

4.3.2 使用多个源文件#

推荐将各个类放在一个单独的源文件中

4.3.3 剖析Employee类#

标记为public代表任何类的任何方法都可以调用,private则说明只有Employee类本身能够调用(实例字段)

类经常包含类类型的实例字段,如String类

4.3.4 从构造器开始#

   public Employee(String n, double s, int year, int month, int day)
   {
      name = n;
      salary = s;
      hireDay = LocalDate.of(year, month, day);
   }

构造器总是结合new操作符来调用,不能对一个已经存在的对象调用构造器来重新设置实例字段

几个有关构造器的内容

  • 构造器与类同名
  • 每个类可以有一个以上的构造器
  • 构造器可以有0/1/多个参数
  • 构造器没有返回值
  • 构造器总是结合new操作符调用

不要引入与实例字段同名的局部变量

4.3.5 用var声明局部变量#

从java10开始,如果可以从变量的初始值推导出它们的类型,可以用var关键字声明局部变量而无需指定类型

Employee harry=new Employee("Harry Hacker",50000,1989,10,1);
var harry=new Employee("Harry Hacker",50000,1989,10,1);

不要对数值类型使用var,如long/int/double,否则难以区分0、0L、0.0之间的区别

var关键字只能用于方法中的局部变量,参数和字段的类型必须声明

4.3.6 使用null引用#

使用null值要非常小心,虽然这是一种处理特殊情况的便捷机制,但是比如对null值应用方法时,会产生NullPointerException异常

定义一个类时,最好请楚地知道哪些字段可能为null,然后进行处理

两种解决方法

  • 宽容方法:把null参数转换为一个适当的非null值

    if(n==null)name="unknown";else name=n;

    Objects类为此提供了一个便利方法

    name=Objects.requireNonNullElse(n,"unknown");
  • 严格方法:拒绝null参数

    name=Objects.requireNonNull(n,"The name can not be null");

    好处:

    1. 异常报告会提供这个问题的描述
    2. 异常报告会准确地指出问题所在位置,否则NullPointerException异常会出现在其他地方,而很难追踪到真正导致问题的构造器参数

如果要接受一个对象引用作为构造参数,就要弄清楚是否接受可有可无的值,如果不是,那么严格方法更适用

4.3.7 隐式参数与显式参数#

public void raiseSalary(double byPercent)
   {
      double raise = salary * byPercent / 100;
      salary += raise;
   }

raiseSalary方法有两个参数,出现在方法名前的Employee类型的对象为隐式(implicit)参数,位于方法名后面括号中的数值为显式(explicit)参数,隐式参数也称为方法调用的目标或接收者

显式参数显式地列在方法声明中,隐式参数则没有出现

在每一个方法中,关键字this指示隐式参数

public void raiseSalary(double byPercent)
   {
      double raise = this.salary * byPercent / 100;
      this.salary += raise;
   }

这样的风格能将实例字段与局部变量明显地区分开来

内联方法(Inline Method)是一种编程中的代码重构技术,用于将一个方法的实现直接嵌入到调用该方法的代码中,从而去除该方法的定义。这种方法通常用于简化代码结构、提高性能或减少方法调用的开销

4.3.8 封装的优点#

   public String getName()
   {
      return name;
   }

   public double getSalary()
   {
      return salary;
   }

   public LocalDate getHireDay()
   {
      return hireDay;
   }

这些都是典型的访问器方法,由于只返回实例字段的值,所以又被称为字段访问器

若想获得或设置实例字段的值,需要以下三项内容:

  1. 一个私有的实例字段
  2. 一个公共的字段访问器方法
  3. 一个公共的字段更改器方法

好处:

  • 只读字段不会受到外界破坏
  • 改变内部实现而不影响该类之外的任何其它代码
  • 更改器方法可以完成错误检查,只对字段进行赋值的代码不会费心这么做,如setSalary方法可以检查工资是否小于0

4.3.9 基于类的访问权限#

方法可以访问调用这个方法的对象的私有数据,一个类的方法可以访问这个类的所有对象的私有数据

4.3.10 私有方法#

public->private 多为辅助方法

4.3.11 final实例字段#

这样的字段必须在构造对象时初始化,并且之后不能再修改修改这个字段

final修饰符对类型为基本类型或不可变类(如String)的字段尤其有效

对于可变类,使用final修饰符可能会造成混乱

考虑以下字段

private final StringBuilder evaluations;

它在Employee构造器中初始化为

evaluations=new StringBuilder();

final关键字只是表示存储在evaluations变量中的对象引用不会再指示另一个不同的StringBuilder对象,不过该对象可以更改

evaluations.append("123456");

4.4 静态字段与静态方法#

有关static修饰符

4.4.1 静态字段#

如果将一个字段定义为static,那么该字段并不出现在每个类的对象中,每个静态字段只有一个副本,可以认为静态字段属于类而不属于单个对象

class Employee
{
    private static int nextId=1;
    private int id;
    ...
}

每个Employee对象只有自己的id字段,但这个类的所有实例对象共享一个nextId字段

即使没有Employee对象,静态字段nextId字段也存在,它属于类而不属于任何对象

[!NOTE]

在一些OOP语言中,静态字段被称为类字段,术语静态只是沿用C++叫法,并无实际意义

假设我们构造了一个对象harry,harry的id字段被设置为静态字段nextId的当前值,并将静态字段nextId的值+1

harry.id=Employee.nextId;
Employee.nextId++;

4.4.2 静态常量#

如Math类中定义的静态常量PI,使用Math.PI 访问,另一经常使用的静态常量为System.out

public class System
{
    ...
    public static final PrintStream out=...;
    ...
}

4.4.3 静态方法#

静态方法是不操作对象的方法,即这个方法没有隐式参数

Employee类的静态方法不能访问id实例字段,因为它并不操作对象,但是它可以访问静态字段

public static int advanceId()
{
    int r=nextId;
    nextId++;
    return r;
}

要调用这个方法需要提供类名

int n=Employee.advanceId();

若省略关键字static,则需要使用对象来调用方法

以下情况可以使用静态方法

  • 方法不需要访问对象状态,所需参数都由显式参数提供
  • 方法只需要访问类的静态字段

4.4.4 工厂方法#

类似LocalDate和NumberFormat的类使用静态工厂方法来构造对象,如LocalDate.now和LocalDate.of

      NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance();
      NumberFormat percentFormatter = NumberFormat.getPercentInstance();
      double x=0.1;
      System.out.println(currencyFormatter.format(x));
      System.out.println(percentFormatter.format(x));

为什么NumberFormat类不使用构造器

  1. 无法为构造器命名,构造器的名字要与类名相同,这里希望有两个不同的名字分别得到货币实例和百分比实例
  2. 使用构造器方法时无法改变所构造对象的类型,而工厂方法实际将返回DecimalFormat类的对象,这是继承NumberFormat的一个子类

4.4.5 main方法#

main方法也是一个静态方法,不对任何对象进行操作

/**
 * This program demonstrates static methods.
 * @version 1.03 2021-09-03
 * @author Cay Horstmann
 */
public class StaticTest
{
   public static void main(String[] args)
   {
      // fill the staff array with three Employee objects
      var staff = new Employee[3];

      staff[0] = new Employee("Tom", 40000);
      staff[1] = new Employee("Dick", 60000);
      staff[2] = new Employee("Harry", 65000);

      // print out information about all Employee objects
      for (Employee e : staff)
      {
         System.out.println("name=" + e.getName() + ",id=" + e.getId() + ",salary="
            + e.getSalary());
      }

      int n = Employee.advanceId(); // calls static method
      System.out.println("Next issued id=" + n);
   }
}

class Employee
{
   private static int nextId = 1;

   private String name;
   private double salary;
   private int id;

   public Employee(String n, double s)
   {
      name = n;
      salary = s;
      id = advanceId();
   }

   public String getName()
   {
      return name;
   }

   public double getSalary()
   {
      return salary;
   }

   public int getId()
   {
      return id;
   }

   public static int advanceId()
   {
      int r = nextId; // obtain next available id
      nextId++;
      return r;
   }

   public static void main(String[] args) // runs demo,unit test
   {
      var e = new Employee("Harry", 50000);
      System.out.println(e.getName() + " " + e.getSalary());
   }
}

API java.util.Objects 7

  • static < T > void requireNonNull(T obj)

  • static < T > void requireNonNull(T obj,String message)

  • static < T > void requireNonNull(T obj,Supplier< String > messageSupplier) 8

    如果obj为null,这些方法会抛出一个NullPointerException异常而没有任何消息或者有给定的消息

  • static < T > T requireNonNullElse(T obj,T defaultObj) 9

  • static < T > T requireNonNullElseGet(T obj,Supplier< T > defaultSupplier) 9

    如果obj不为null则返回obj,或者如果obj为null则返回默认对象

4.5 方法参数#

按值调用(call by value)表示方法接收的是调用者提供的值,按引用调用(call by reference)表示方法接收的是调用者提供的变量位置,所以可以修改按引用传递的变量的值,而不能修改按值传递的变量的值

按…调用是一个标准的计算机术语,用来描述各种程序设计语言中方法参数的行为,还有一种古老的方式——按名调用已经成为历史

java程序设计语言总是采用按值调用,方法会得到所有参数值的一个副本,所以无法修改传递给它的任何参数变量的值

public static void tripleValue(double x)// doesn't work
{
    x=3*x;
}
double percent=10;
tripleValue(percent);//the value of percent is still 10

x=30但percent仍为10,且方法结束后参数变量x不再使用

有两种不同类型的方法参数:

  1. 基本数据类型(数字/布尔值)
  2. 对象引用

若为对象参数则不同

public static void tripleSalary(Employee x)// works
{
    x.raiseSalary(200);
}
harry=new Employee(...);
tripleSalary(harry);

如何证明java对对象采用的不是按引用调用?

下面来编写一个交换Employee对象的方法

public static void swap(Employee x,Employee y)//doesn't work
{
    Employee temp=x;
    x=y;
    y=temp;
}

如果Java对对象采用的是引用传递,那么这个方法应该能够实现交换

var a=new Employee("Alice",...);
var a=new Employee("Bob",...);
swap(a,b);

这个方法并没有改变存储在a,b中的对象引用,swap方法的参数x和y初始化为两个对象引用的副本,交换的是这两个副本

//x refers to Alice,y refers to Bob
Employee temp=x;
x=y;
y=temp;
//now x refers to Bob,y to Alice

以上说明了java程序设计语言对对象采用的不是按引用调用的,实际上,对象引用是按值传递的

java中能对方法参数做什么/不能做什么?

  • 不能修改基本数据类型的参数
  • 可以改变对象参数的状态
  • 不能让一个对象参数引用一个新对象
/**
 * This program demonstrates parameter passing in Java.
 * @version 1.01 2018-04-10
 * @author Cay Horstmann
 */
public class ParamTest
{
   public static void main(String[] args)
   {
      /*
       * Test 1: Methods can't modify numeric parameters
       */
      System.out.println("Testing tripleValue:");
      double percent = 10;
      System.out.println("Before: percent=" + percent);
      tripleValue(percent);
      System.out.println("After: percent=" + percent);

      /*
       * Test 2: Methods can change the state of object parameters
       */
      System.out.println("\nTesting tripleSalary:");
      var harry = new Employee("Harry", 50000);
      System.out.println("Before: salary=" + harry.getSalary());
      tripleSalary(harry);
      System.out.println("After: salary=" + harry.getSalary());

      /*
       * Test 3: Methods can't attach new objects to object parameters
       */
      System.out.println("\nTesting swap:");
      var a = new Employee("Alice", 70000);
      var b = new Employee("Bob", 60000);
      System.out.println("Before: a=" + a.getName());
      System.out.println("Before: b=" + b.getName());
      swap(a, b);
      System.out.println("After: a=" + a.getName());
      System.out.println("After: b=" + b.getName());
   }

   public static void tripleValue(double x) // doesn't work
   {
      x = 3 * x;
      System.out.println("End of method: x=" + x);
   }

   public static void tripleSalary(Employee x) // works
   {
      x.raiseSalary(200);
      System.out.println("End of method: salary=" + x.getSalary());
   }

   public static void swap(Employee x, Employee y)
   {
      Employee temp = x;
      x = y;
      y = temp;
      System.out.println("End of method: x=" + x.getName());
      System.out.println("End of method: y=" + y.getName());
   }
}

class Employee // simplified Employee class
{
   private String name;
   private double salary;

   public Employee(String n, double s)
   {
      name = n;
      salary = s;
   }

   public String getName()
   {
      return name;
   }

   public double getSalary()
   {
      return salary;
   }

   public void raiseSalary(double byPercent)
   {
      double raise = salary * byPercent / 100;
      salary += raise;
   }
}

4.6 对象构造#

Java提供了多种编写构造器的机制

4.6.1 重载#

var messages=new StringBuilder();
var todoList=new StringBuilder("to do");

如果多个方法有相同的方法名但有不同的参数,便出现了重载,编译器必须挑选出具体调用哪个方法,它用各个方法首部中的参数类型与特定方法调用中所使用 的值的类型进行匹配来选出正确的方法,不存在匹配就会产生编译时错误,这个查找匹配的过程称为重载解析

4.6.2 默认字段初始化#

构造器中若没有显式地为一个字段设置初始值,就会为它设置为默认值:数值将设置为0,布尔值为false,对象引用为null

4.6.3 无参数构造器#

对象状态设置为默认值

public Employee()
{
    name="";
    salary=0;
    hireDay=LocalDate.now();
}

如果你写的类没有构造器,就会默认为你提供一个无参数构造器,这个构造器将会进行默认字段初始化

如果类中提供了至少一个构造器,但是没有提供无参数构造器,这个时候使用无参数构造器就是不合法的

4.6.4 显式字段初始化#

如果一个类的所有构造器都需要把某个特定的实例字段设置为同一个值,可以在类定义中直接为其赋值,在执行构造器之前会完成这个赋值,初始值不一定是常量值,例如

class Employee
{
    private static int nextId;
    private int id =advanceId();
    ...
    private static int advanceId()
    {
        int r=nextId;
        nextId++;
        return r;
    }
    ...
}

4.6.5 参数名#

出发点:提高易读性(避免xyz),简洁,不会出错

public Employee(String aName,double aSalary)
{
    name=aName;
    salary=aSalary;
}

还有一种常见技巧,基于:参数变量会遮蔽同名实例字段,但是可以利用this关键字

public Employee(String name,double salary)
{
    this.name=name;
    this.salary=salary;
}

4.6.6 调用另一个构造器#

关键字this指示一个方法的隐式参数

如果构造器的一个语句形如this(…),这个构造器将调用同一个类的另一个构造器

public Employee(double s)
{
    //calls Employee(String,double)
    this("Employee #"+nextId,s);
    nextId++;
}

4.6.7 初始化块#

class Employee
{
    private static int nextId;
    private int id;
    private String name;
    private double salary;
    //object initialization block
    {
        id=nextId;
        nextId++;
    }
    ...
}

在以上示例中,无论使用哪个构造器构造对象,id字段都会在对象初始化块中初始化,首先运行初始化块,然后才运行构造器的主体部分

建议将初始化块放在字段定义之后

调用构造器的具体处理步骤

  1. 如果构造器的第一行调用了另一个构造器,则基于所提供的参数执行第二个构造器

  2. 否则,

    a)所有实例字段初始化为其默认值

    b)按照在类声明中出现的顺序,执行所有字段初始化方法和初始化块

  3. 执行构造器主体代码

如果类的静态字段需要很复杂的初始化代码,那么可以使用静态初始化块

private static Random generator=new Random();
//static initialization block
static
{
    nextId=generator.nextInt(10000);
}//将员工Id的起始值赋值为一个小于10000的随机整数

此例子采用Random类来生成随机数,从JDK 17开始,java.util.random包提供了考虑多种因素的强算法的实现

RandomGenerator generator=RandomGenerator.of("L64X128MixRandom");
import java.util.random.*;

/**
 * This program demonstrates object construction.
 * @version 1.02 2018-04-10
 * @author Cay Horstmann
 */
public class ConstructorTest
{
   public static void main(String[] args)
   {
      // fill the staff array with three Employee objects
      var staff = new Employee[3];

      staff[0] = new Employee("Harry", 40000);
      staff[1] = new Employee(60000);
      staff[2] = new Employee();

      // print out information about all Employee objects
      for (Employee e : staff)
         System.out.println("name=" + e.getName() + ",id=" + e.getId() + ",salary="
            + e.getSalary());
   }
}

class Employee
{
   private static int nextId;

   private int id;
   private String name = ""; // instance field initialization
   private double salary;

   private static RandomGenerator generator = RandomGenerator.getDefault();
   
   // static initialization block
   static
   {
      // set nextId to a random number between 0 and 9999
      nextId = generator.nextInt(10000);
   }

   // object initialization block
   {
      id = nextId;
      nextId++;
   }

   // three overloaded constructors
   public Employee(String n, double s)
   {
      name = n;
      salary = s;
   }

   public Employee(double s)
   {
      // calls the Employee(String, double) constructor
      this("Employee #" + nextId, s);
   }

   // the default constructor
   public Employee()
   {
      // name initialized to ""--see above
      // salary not explicitly set--initialized to 0
      // id initialized in initialization block
   }

   public String getName()
   {
      return name;
   }

   public double getSalary()
   {
      return salary;
   }

   public int getId()
   {
      return id;
   }
}

API java.util.Random 1.0

  • Random()

    构造一个新的随机数生成器

API java.util.Random.RandomGenerator 17

  • int nextInt(int n)

    返回一个0~n-1的随机数

  • static RandomGenerator of(String name)

    由给定算法名生成一个随机数生成器,算法L64X128MixRandom对大多数应用都适用

4.6.8 对象析构与finalize方法#

java会完成自动的垃圾回收,不需要人工回收内存,所以java不支持析构器

当然,如果某些对象使用了内存之外的其他资源,如文件或使用系统资源的另一个对象的句柄,在这种情况下当资源不再需要时回收再利用就十分重要。如果一个资源一旦使用完就需要立即关闭,那么应当提供一个close方法来完成必要的清理工作(第七章中将介绍如何确保自动调用这个方法)

如果可以等到虚拟机退出,那么可以使用方法Runtime.addShutdownHook增加一个关闭钩,在java9中可以使用Cleaner类注册一个动作,当对象不再可达时(除了清洁器还能访问,其他对象都无法再访问这个对象)就会完成这个动作,可在API文档中了解这两种方法的详细内容

[!WARNING]

不要使用finalize方法进行清理,这个方法原本要在垃圾回收器清理对象之前调用,不过你并不能知道这个方法到底什么时候调用,而且该方法已经废弃

4.7 记录#

考虑一个类Point,这个类描述平面上的一个点,有x和y坐标

public class Point {
    private final double x;
    private final double y;
    public Point(double x, double y) {this.x = x;this.y = y;}
    public double getX() {return x;}
    public double getY() {return y;}
    public String toString() { return "Point[x=%d, y=%d]".formatted(x, y);}
    //More methods
}

为了更简洁地表示这些类,JDK14引入了一个预览特性:“记录”

4.7.1 记录概念#

记录是一种特殊形式的类,其状态不可变,而且公共可读

可以如下将Point定义为一个记录

record Point(double x,double y){}

其结果是有以下实例字段的类

    private final double x;
    private final double y;

一个记录的实例字段被称为组件

这个类有一个构造器和以下访问器方法

Point(double x,double y)
public double x()//不是GetX/GetY
public double y()

Java中实例字段和方法同名和合法的

var p=new Point(3,4);
System.out.println(p.x()+""+p.y());

除了字段访问器方法,每个记录有三个自动定义的方法:toString、equals、hashCode

可以为一个记录增加静态字段和方法,但是不能增加实例字段

record Point(double x,double y)
{
    public double distanceFromOrigin(){return Math.hypot(x,y);}
    public static Point ORIGIN = new Point(0,0);
    public static double distance(Point p1, Point p2)
    {
        return Math.hypot(p1.x - p2.x,p1.y - p2.y);
    }
    private double r;//Error
}

4.7.2 构造器:标准、自定义和简洁#

自动定义地设置所有实例字段的构造器称为标准构造器

还可以定义另外的自定义构造器这种构造器的第一个语句必须调用另一个构造器,所以最终会调用标准构造器

record Point(double x,double y)
{
    public Point(){this(0,0);}
}

这个记录有两个构造器:标准构造器和一个生成原点的无参数构造器

如果标准构造器需要完成额外的工作那么可以提供你自己的实现

record Range(int from, int to)
{
    public Range(int from, int to)
    {
        if(from <= to)
        {
            this.from = from;
            this.to = to;
        }
        else
        {
            this.from = to;
            this.to = from;
        }
    }
}

不过实现标准构造器时建议使用一种简洁形式,不用指定参数列表

record Range(int from, int to)
{
    public Range//简洁形式
    {
        if(from > to)//交换上下界
        {
            int temp = from;
            from = to;
            to = temp;
        }
    }
}

简洁形式的主体是标准构造器的“前奏”,它只是在为实例字段this.from和this.to赋值之前修改参数变量from和to不能在简介构造器的主体中读取或修改实例字段

import java.util.*;

/**
 * This program demonstrates records.
 * @version 1.0 2021-05-13
 * @author Cay Horstmann
 */
public class RecordTest
{
   public static void main(String[] args)
   {
      var p = new Point(3, 4);
      System.out.println("Coordinates of p: " + p.x() + " " + p.y());
      System.out.println("Distance from origin: " + p.distanceFromOrigin());
      // Same computation with static field and method
      System.out.println("Distance from origin: " + Point.distance(Point.ORIGIN, p));

      // A mutable record
      var pt = new PointInTime(3, 4, new Date());
      System.out.println("Before: " + pt);
      pt.when().setTime(0);
      System.out.println("After: " + pt);

      // Invoking a compact constructor

      var r = new Range(4, 3);
      System.out.println("r: " + r);
   }
}

record Point(double x, double y)
{
   // A custom constructor
   public Point() { this(0, 0); }
   // A method
   public double distanceFromOrigin()
   {
      return Math.hypot(x, y);
   }   
   // A static field and method
   public static Point ORIGIN = new Point();
   public static double distance(Point p, Point q)
   {
      return Math.hypot(p.x - q.x, p.y - q.y);
   }
}

record PointInTime(double x, double y, Date when) { }

record Range(int from, int to)
{
   // A compact constructor 
   public Range
   {
      if (from > to) // Swap the bounds
      {
         int temp = from;
         from = to;
         to = temp;
      }
   }
}

运行结果

4.8 包#

Java允许使用包(package)将类组织在一个集合中,借助包可以方便地组织你的代码,并将你自己的代码和别人提供的代码库分开

4.8.1 包名#

使用包的主要原因是确保类名的唯一性。假如两个程序员不约而同地提供了Employee类,只要他们将自己的类放置在不同的包中,就不会产生冲突。事实上,为了保证包名的绝对唯一性,可以使用一个因特网域名(这显然是唯一的)以逆序的形式作为包名,然后对于不同的项目使用不同的子包。例如,考虑域名lyh.com,如果逆序来写,就得到了com.lyh,然后可以追加一个项目名,如com.lyh.project,如果再把某个类放在这个包里,那么这个类的“完全限定”名就是com.lyh.project.className

[!NOTE]

从编译器的角度来看,嵌套的包之间没有任何关系,例如java.util包与java.util.jar包毫无关系,每一个包都是独立的类的集合

4.8.2 包的导入#

一个类可以使用一个所属包的所有类以及其他包中的公共类

可以采用两种方式访问另一个包中的公共类

  1. 使用完全限定名(包名+类名)

    java.time.LocalDate today = java.time.LocalDate.now();
  2. 可以使用import语句导入一个特定的类或整个包。import语句应该位于源文件的顶部(但位于package语句的后面)

    package语句用于定义源文件所属的包

    import java.time.*;//import java.time.LocalDate;

    有了上述import语句以后就可以使用

    LocalDate today=LocalDate.now();

只能使用*导入一个包,不能使用import java.*import java.*.*导入以java为前缀的所有包

导入包或具体的类并无显著差异,不过导入具体使用的类能更加准确地让代码的读者知道你使用了哪些类

在大多数情况下,可以只导入你需要的包,但在发生命名冲突时要注意包,例如java.utiljava.sql包都有Date类,在使用Date类时编译器无法确定你想使用的是哪一个Date类,可以增加一个特定的import语句来解决这个问题

import java.util.*
import java.sql.*;
import java.util.Date;

如果这两个类都需要使用,就在每个类名的前面加上完整的包名

var startTime=new java.util.Date();
var today=new java.sql.Date(...);

在包中定位类是编译器的工作,类文件中的字节码总是使用完整的包名来引用其他类

4.8.3 静态导入#

有一种import语句允许导入静态方法和静态字段,而不只是类

例如,如果在源文件最上面添加

import static java.lang.System.*;

就可以使用System类的静态方法和静态字段而不必加类名前缀

out.println("sfdsa");//i.e.,System.out
exit(0);//i.e.,System.exit

另外还可以导入特定的方法和字段

import static java.lang.System.out;

实际上,是否有很多程序员想要简写System.out或Syetem.exit很让人怀疑,这样写出来的代码很不清晰,不过像sqrt(pow(x,2)+pow(y,2))看起来则比Math.sqrt(Math.pow(x,2)+Math.pow(y,2))简洁得多

4.8.4 在包中增加类#

要想将类放入包中,就必须将包名放在源文件的开头,即放在定义这个包中各个类的代码之前

package com.lyh.project;
public class RecordTest 
{
    ...
}

如果没有在源文件中放置package语句,那么这个源文件中的类就属于无名包,无名包没有包名,到目前为止我们定义的所有类都在无名包中

将源文件放到与完整包名匹配的子目录中,例如,com.lyh.project包中的所有源文件应该放置在子目录com/lyh/project中(Windows中则是com)编译器将类文件也放在相同的目录结构中

import com.horstmann.corejava.*;
// the Employee class is defined in that package

import static java.lang.System.*;

/**
 * This program demonstrates the use of packages.
 * @version 1.11 2004-02-19
 * @author Cay Horstmann
 */
public class PackageTest
{
   public static void main(String[] args)
   {
      // because of the import statement, we don't have to use 
      // com.horstmann.corejava.Employee here
      var harry = new Employee("Harry Hacker", 50000, 1989, 10, 1);

      harry.raiseSalary(5);

      // because of the static import statement, we don't have to use System.out here
      out.println("name=" + harry.getName() + ",salary=" + harry.getSalary());
   }
}
package com.horstmann.corejava;

// the classes in this file are part of this package

import java.time.*;

// import statements come after the package statement

/**
 * @version 1.11 2015-05-08
 * @author Cay Horstmann
 */
public class Employee
{
   private String name;
   private double salary;
   private LocalDate hireDay;

   public Employee(String name, double salary, int year, int month, int day)
   {
      this.name = name;
      this.salary = salary;
      hireDay = LocalDate.of(year, month, day);
   }

   public String getName()
   {
      return name;
   }

   public double getSalary()
   {
      return salary;
   }

   public LocalDate getHireDay()
   {
      return hireDay;
   }

   public void raiseSalary(double byPercent)
   {
      double raise = salary * byPercent / 100;
      salary += raise;
   }
}

以上两个程序分别放在两个包中:PackageTest类属于无名包;Employee类属于com.horstman.corejava包,因此,Employee.java文件必须在子目录com/horstman/corejava中。换句话说,目录结构如下

要想编译这个程序,只需切换到基目录,并运行以下命令

javac PackageTest.java

编译器就会自动地查找文件com/horstman/corejava/Employee.java并进行编译

下面看一个更实际的例子,在这里没有使用无名包,而是将类分别放在不同的包中(com.horstman.corejava和com.mycompany)

在这种情况下,仍然要从基目录编译和运行类,即包含com目录的目录:

javac com/mycompany/PayrollApp.java
java com.mycompany.PayrollApp

编译器处理文件(带有文件分隔符和扩展名.java的文件),而Java解释器加载类(带有.分隔符)

4.8.5 包访问#

标记为public的部分可以由任意类使用;标记为private的部分只能由定义它们的类使用。如果没有指定public或private,这个部分(类/方法/变量)可以由同一个包中的所有方法访问(即默认包可访问

记得添加访问修饰符,尤其是对于变量必须显示地声明为private,不然默认为包可访问,会破坏封装性

由于包不是封闭的实体,任何人都可以向包中添加更多的类,当然,有恶意或糟糕的程序员很可能利用包访问添加一些能修改变量的代码

从1.2版本开始,JDK的实现者修改了类加载器,明确地禁止加载包名以”java.”开头的用户自定义的类,当然,用户自定义的类无法从这种保护中受益,另一种机制是让JAR文件声明包为密封的,以防止第三方修改,但这种机制已经过时。现在应当使用模块封装包,我们将会在卷二的第九章详细讨论

4.8.6 类路径#

类存储在文件系统的子目录中,类的路径必须与包名匹配

另外类文件还可以存储在JAR(Java归档)文件中。在一个JAR文件中,可以包含多个压缩格式的类文件和子目录,这样既可以节省空间又可以改善性能。在程序中用到第三方的库时,你通常会得到一个或多个需要包含的JAR文件。

[!NOTE]

JAR文件使用ZIP格式组织文件和子目录,可以使用任何ZIP工具查看JAR文件

为了使类能够被多个程序共享,需要做到以下几点:

  1. 把类文件放到一个目录中,例如/home/user/classdir,需要注意,这个目录是包树状结构的基目录,如果希望增加com.lyh.project.className类,那么className.class类文件就必须位于子目录/home/user/classdir/com/lyh/project中
  2. 将JAR文件放在一个目录中,例如/home/user/archives
  3. 设置类路径,类路径是所有包含类文件的路径的集合

在UNIX环境中,类路径中的各项之间:分隔/home/user/classdir:.:/home/user/archives/archive.jar

而在Windows环境中,则;分隔c:\classdir;.;c:\archives\archive.jar

不论是UNIX还是Windows,都用句号.表示当前目录

类路径包括:

  • 基目录/home/user/classdirc:\classdir
  • 当前目录(.
  • JAR文件/home/user/archives/archive.jarc:\archives\archive.jar

从Java6开始,可以为JAR文件目录下指定一个通配符,如/home/user/archives/*,在UNIX中,*必须转义防止shell扩展,archives目录中的所有JAR文件(但不包括.class文件)都包含在这个类路径中

由于总是会搜索Java API的类,所以不必显式地包含在类路径中

类路径所列出的目录和归档文件是搜寻类的起始点

下面看一个类路径示例

/home/user/classdir:.:/home/user/archives/archive.jar

假定虚拟机要搜寻com.horstman.corejava.Employee类的类文件,它首先要查看Java API类,显然,在那里找不到相应的类文件,所以转而查看类路径,它会查找以下文件:

  • /home/user/classdir/com/horstman/corejava/Employee.class
  • com/horstman/corejava/Employee.class(从当前目录开始)
  • com/horstman/corejava/Employee.class(/home/user/archives/archive.jar中)

编译器查找文件要比虚拟机复杂得多,如果引用了一个类而没有指定这个类的包,那么编译器首先查找包含这个类的包。它会查看所有import指令,确定其中是否包含着类,例如假定源文件包含以下指令:

import java.util.*;
import com.horstman.corejava.*;

并且源文件引用了Employee类,编译器将尝试寻找java.lang.Employee(因为总是会默认导入java.lang包)、java.util.Employee、com.horstman.corejava.Employee和当前包中的Employee,它会在类路径所有位置上搜索以上各个类。如果找到一个以上的类,就会产生编译时错误(因为完全限定类名必须是唯一的,所以import语句的次序并不重要)

编译器的任务并不止这些,它还要查看源文件是否比类文件新,如果是的话,那么源文件就会自动地重新编译,在前面已经知道,只可以导入其他包中的公共类,一个源文件只能包含一个公共类,而且文件名必须与公共类名相匹配。因此,编译器很容易找到公共类的源文件。不过,还可以从当前包中导入非公共类,这些类有可能在与类名不同的源文件中定义。如果从当前包中导入一个类,那么编译器就要搜索当前包中的所有源文件,查看哪个源文件定义了这个类。

4.8.7 设置类路径#

最好使用-classpath(或-cp,或Java 9中的 –class-path)选项指定类路径

java -classpath /home/user/classdir:.:/home/user/archives/archive.jar MyProg

java -classpath c:\classdir;.;c:\archives\archive.jar MyProg

整个指令必须写在一行,将这样一个很长的命令行放在一个shell脚本或一个批处理文件中是个很不错的主意

还有一种方法是通过设置CLASSPATH环境变量来指定类路径,具体细节依赖于所使用的shell,在Bourne Again shell(bash)中,命令如下

export CLASSPATH=/home/user/classdir:.:/home/user/archives/archive.jar

在Windows shell中

set CLASSPATH=c:\classdir;.;c:\archives\archive.jar 

直到退出shell为止,类路径设置均有效

[!CAUTION]

在Java 9 中,还可以从模块路径加载类

4.9 JAR文件#

在将应用程序打包时,你希望只向用户提供一个单独的文件,而不是一个包含大量类文件的目录结构,Java归档(JAR)文件就是为此目的而设计的,JAR文件既可以包含类文件,也可以包含诸如图像和声音等其他类型的文件,此外,JAR文件是压缩的,它使用了我们熟悉的ZIP压缩格式

4.9.1 创建JAR文件#

可以使用jar工具制作JAR文件(在默认的JDK安装中,这个工具位于jdk/bin目录下),创建一个新JAR文件最常用的命令使用以下语法

jar cvf jarFileName file1 file2 ...

例如

jar cvf CalculatorClasses.jar *.class icon.gif

通常jar命令的格式为jar options file1 file2 ...

下表列出了jar程序的所有选项,它们类似于UNIX tar命令的选项

可以将应用程序和代码库打包在JAR文件中,例如,如果想在一个Java程序中发送邮件,可以使用打包在javax.mail.jar中的一个库

4.9.2 清单文件#

除了类文件、图像和其他资源外,每个JAR文件还包含一个清单文件(manifest),用于描述归档文件的特殊特性

清单文件被命名为MANIFEST.MF,它位于JAR文件的一个特殊的META-INF子目录中,合法的最小清单文件极其简单:Manifest-Version: 1.0

复杂的清单文件可能包含更多条目。这些清单条目被分组为多个节,第一节被称为主节,它作用于整个JAR文件,随后的条目可以指定命名实体的属性,如单个文件/包/URL,它们都必须以一个Name条目开始,节与节之间用空行分开

image-20250715163856658

要想编辑清单文件,需要将希望添加到清单文件中的行放到文本文件中,然后运行jar cfm jarFileName manifestFileName ...

例如,要创建一个包含清单文件的JAR文件,应该运行jar cfm MyArchive.jar manifest.mf com/mycompany/mypkg/*.class

要想更新一个已有的JAR文件的清单,则需要将增加的部分放置到一个文本文件中,然后执行命令 jar ufm MyArchive.jar manifest-additions.mf

想获取更多有关JAR文件和清单文件格式的信息可查阅 JAR File Specification

4.9.3 可执行JAR文件#

可以使用jar命令中的e选项指定程序的入口点,即通常调用java执行程序时指定的类:

jar cvfe MyProgram.jar com.mycompany.mypkg.MainAppClass files to add

或者可以在清单文件中指定程序的主类,包括以下形式的语句:

Main-Class:com.mycompany.mypkg.MainAppClass

不要为主类名加扩展名.class

[!WARNING]

清单文件的最后一行必须以换行符结束,否则将无法正确地读取清单文件,常见的一个错误是创建了一个只包含Main-Class行而没有行结束符的文本文件

不论采用哪种方式用户都可以简单地通过下面的命令来启动程序

java -jar MyProgram.jar

取决于操作系统的配置,用户甚至可以通过双击JAR文件的图标来启动应用程序

  • 在Windows平台中,Java运行时安装程序将为”.jar”扩展名创建一个文件关联,会用javaw -jar命令启动文件(与java命令不同,javaw命令不打开shell窗口)
  • 在Mac OS X平台中,操作系统能够识别“.jar”扩展名文件,双击JAR文件就会执行Java程序

JAR文件中的Java程序与原生应用仍有区别,可以使用第三方包装器工具将JAR文件转换成Windows可执行文件。包装器是一个Windows程序,有大家熟悉的扩展名.exe,它可以查找和加载java虚拟机(JVM),或者在没有找到JVM时告诉用户应该做些什么。有许多商业的和开源的产品,如Launch4j - Cross-platform Java executable wrapperIzPack - Package once. Deploy everywhere.

4.9.4 多版本JAR文件#

随着模块和包强封装的引入,之前可以访问的一些内部API不再可用,这可能要求库提供商为不同java版本发布不同的代码,为此,Java 9引入了多版本JAR文件

为了保证向后兼容,特定于版本的类文件放在META-INF/versions目录中

假设Application类使用了CssParser类,那么遗留版本的Application.class文件可以使用com.sun.javafx.css.CssParser,而Java 9版本可以使用javafx.css.CssParser

Java 8完全不知道META-INF/versions目录,它只会加载遗留的类,Java 9读取这个JAR文件时则会使用新版本

要增加不同版本的类文件,可以使用--release标志

jar uf MyProgram.jar --release 9 Application.class

要从头构建一个多版本JAR文件,可以使用-C选项,对应每个版本要切换到一个不同的类文件目录

jar cf MyProgram.jar -C bin/8 . --release 9 -C bin/9 Application.class

面向不同版本编译时,要使用--release-d标志来指定输出目录

javac -d bin/8 --release 8 ...

在Java 9 中,-d选项会创建这个目录(如果原先该目录不存在)

--release标志也是Java 9 新增的,在较早的版本中使用-source,-target,-bootclasspath。JDK现在为之前的两个API版本提供了符号文件,在Java 9中,编译时可以将--release设置为9、8、7

多版本JAR并不适用于不同版本的程序或库,对于不同的版本,所有类的公共API都应该是一样的,多版本JAR的唯一作用是使你的某个特定版本的程序或库能够使用多个不同的JDK版本。如果你增加了功能或改变了一个API,就应当提供一个新版本的JAR

4.9.5 关于命令行选项的说明#

Java开发包(JDK)的命令行选项一直都使用单个短横线加多字母选项名的形式,但jar命令是个例外,这个命令遵循经典的tar命令选项格式,而没有短横线

java -jar ...
jar cvf ...

从Java 9开始,Java工具转向一种更常用的选项格式,多字母选项名前加两个短横线,另外对于常用的选项可以使用单字母快捷方式

例如

ls --human-readable
ls -h 

JEP 293: Guidelines for JDK Command-Line Tool Options

标准化选项参数 带–和多字母选项的参数用空格或一个等号分隔

javac --class-path /home/user/classdir ...
javac --class-path=/home/user/classdir ...

单字母选项的参数可以用空格分隔或直接跟在选项后面

javac -p moduledir ...
javac -pmoduledir ...

[!WARNING]

后一种方式现在不能使用,而且也不是一个好主意,如果模块目录刚好是arameters或rocessor,很容易与遗留选项(parameters或processor)发生冲突

无参数的单字母选项可以组合在一起

jar -cvf MyProgram.jar -e mypackage.MyProgram */*.class

[!WARNING]

目前不能使用这种方式,这肯定会带来混乱,假设javac有一个-c选项,那么javac -cp指的是java -c -p还是-cp?

最好安全地使用jar命令的长选项:

jar --create --verbose --file jarFileName file1 file2 ...

对于单字母选项,如果不组合,也是可以使用的

jar -c -v -f jarFileName file1 file2 ...

4.10 文档注释#

JDK有一个很有用的工具javadoc,它可以由一个源文件生成一个HTML文档,第三章介绍的联机API文档其实就是通过对标准Java类库的源代码运行javadoc生成的

4.10.1 注释的插入#

javadoc实用工具将从下面几项中抽取信息:

  • 模块
  • 公共类与接口
  • 公共的和受保护的字段
  • 公共的和受保护的构造器及方法

可以(且应该)为以上各个特性编写注释,各个注释放置在所描述特性的前面,注释以/**开始,并以*/结束,每个/** ... */文档注释包含标记以及之后紧跟着的自由格式文本,标记以@开始,如@since@param

自由格式文本的第一个句子应该是一个概要陈述,javadoc工具自动地将这些句子抽取出来生成概要页

在自由格式文本中,可以使用HTML修饰符,如用于强调的<em>...</em>、用于着重强调的<strong>...</strong>、用于项目符号列表的<ul>/<li>以及包含图像的<img...>等,要键入等宽代码,需要使用{@code ...}而不是<code>...</code>,这样就不用操心对代码中的<字符转义了

4.10.2 类注释#

类注释必须放在import语句之后,class定义之前

/**
 * A {@code Card} object represents a playing card,such
 * as "Queen of Hearts".A card has a suit (Diamond, Heart, 
 * Spade or Club) and a value (1=Ace,2...10,11=Jack,
 * 12=Queen,13=King)
 */
public class Card
{
    ...
}

4.10.3 方法注释#

每个方法注释必须放在所描述的方法之前,除了通用标记以外,还可以使用标记

  • @param variable description

这个标记给当前方法的“parameters”部分添加一个条目,这个描述可以占据多行,并且可以使用HTML标记,一个方法的所有@param标记必须放在一起

  • @return description将给当前方法添加“return”部分,这个描述同样可以跨多行并使用HTML标记

  • @throws class description这个标记添加一个注释,表示这个方法可能抛出异常

    /**
     * Raises the salary of an employee.
     * @param byPercent the percentage by which to raise the salary (e.g.,10 means 10%)
     * @return the amount of the raise
     */
    public double raiseSalary(double byPercent)
    {
        double raise = salary*byPercent / 100;
        salary+=raise;
        return raise;
    }

4.10.4 字段注释#

只需要对公共字段(通常指的是静态常量)增加文档注释

/**
 * The "Hearts" card suit
 */
public static final int HEARTS=1;

4.10.5 通用注释#

标记@since text会创建一个“since”(始于)条目,text可以是对引入这个特性的版本的描述,如@since 1.7.1

类文档注释中可以使用下面的标记

  • @author name

    这个标记将创建一个“author”条目,可以有多个@author标记,每个对应一个作者,并不是非得使用此标记,你的版本控制系统能更好地跟踪作者

  • @version text

    这个标记将创建一个“version”条目,这里的text可以是对当前版本的任何描述

    通过@see@link标记,可以使用超链接连接到javadoc文档的相关部分或者外部文档

    通过@see reference标记将在“see also(参见)”部分增加一个超链接,它可以用于类中也可以用于方法中,这里的reference可以有以下选择

    package.class#feature label
    <a href="...">label</a>
    "text"

    第一种情况是最有用的,只要提供类、方法或变量的名字,javadoc就在文档中插入一个超链接,例如

    @see com.horstman.corejava.Employee#raiseSalary(double)

    会建立一个超链接,链接到com.horstman.corejava.Employee类的raiseSalary(double)方法,可以省略包名甚至连类名一起省去,这样一来这会位于当前包或当前类

    需要注意,一定要使用#,而不要使用句号(.)分隔类名与方法名(或类名与变量名)。Java编译器自身可以熟练地确定句点在分隔包、子包、类、内部类以及方法和变量时的不同含义。但是javadoc工具就没有那么聪明了,因此必须对它提供帮助。

    如果@see标记后面有个<字符,就需要指定一个超链接,可以超链接到任何URL

    @see <a href="www.horstman.com/corejava.html">The Core Java home page</a>

    在上述各种情况下都可以指定一个可选的标签label,这会显示为链接锚。如果省略了标签,则用户看到的锚就是目标代码名或者是URL

    如果@see标记后面有一个""字符,文本就会显示在”see also”部分

    @see "Core Java 2 volume 2"

    可以为一个特性添加多个@see标记,但必须将它们放在一起。

    如果愿意,可以在任何文档注释中放置指向其他类或方法的超链接,可以在注释中的任何位置插入一个形式如下的特殊标记

    {@link package.class#feature label}

    这里的特性描述规则与@see一致

    最后,在Java 9 中还可以使用{@index entry}标记为搜索框添加一个条目

4.10.6 包注释#

可以直接将类、方法、变量的注释放在源文件中,只要用/** */文档注释界定就可以,但是要想产生包注释,就需要在包目录中添加一个单独的文件,有如下两种选择:

  1. 提供一个名为package-info.java的Java文件,这个文件必须包含一个初始的javadoc注释,以/***/界定,后面是一个package语句,它不能包含更多的代码和注释
  2. 提供一个名为package.html的HTML文件,抽取标记<body>...</body>之间的所有文本

4.10.7 注释提取#

假设你希望HTML文件位于名为docDirectory目录下,执行以下步骤:

  1. 切换到源文件目录,其中包含想要生成文档的源文件。如果有嵌套的包要生成文档,例如com.horstman.corejava,就必须切换到包含子目录com的目录(如果提供overview.html文件的话,这就是这个文件所在目录)

  2. 如果是一个包,应该运行命令

    javadoc -d docDirectory nameOfPackage

    或者如果要为多个包生成文档,运行

    javadoc -d docDirectory nameOfPackage1 nameOfPackage2

    如果你的文件在无名包中,则应该运行

    javadoc -d docDirectory *.java

    如果省略了-d docDirectory选项,HTML文件就会提取到当前目录下,这样就很可能会混乱,故不提倡

可以使用很多命令行选项对javadoc程序进行微调,例如可以使用-author-verison选项在文档中包含@author@verison标记(默认情况下这些标记会被省略)。另一个很有用的选项是-link,用来为标准类添加超链接,例如,如果使用命令

javadoc -link http://docs.oracle.com/javase/9/docs/api *.java

那么所有的标准类库的类都会自动地链接到Oracle网站的文档

如果使用-linksource选项,那么每个源文件将会转换为HTML文件(不对代码着色,但会显示行号),并且每个类和方法名将会变为指向源代码的超链接

还可以为所有源文件提供一个概要注释,把它放在一个类似overview.html的文件中,运行javadoc工具,并提供命令行选项-overview filename,将抽取<body>...</body>之间的所有文本,当用户从导航栏中选择“Overview”时就会显示这些内容

有关其他选项,可查看Javadoc

4.11 类设计技巧#

  1. 一定要保证数据私有#

    避免破坏封装性

    数据的表示形式很可能会改变,但它们的使用方式却不会经常变化。但数据保持私有时,表示形式的变化不会对类的使用者产生影响,而且也更容易检测bug

  2. 一定要初始化数据#

    Java不会为你初始化局部变量,但是会对对象的实例字段进行初始化,最好不要依赖系统的默认值,而是应该显式地初始化所有变量,可以提供默认值,也可以在所有构造器中设置默认值

  3. 不要在类中使用过多的基本类型#

    其想法是要用其他的类,而不是使用多个相关的基本类型。这样会使类更易于理解,也更易于修改。例如,可以用一个名为Address的新类替换一个Customer类中的以下实例字段

    private String street;
    private String city;
    private String state;
    private int zip;

    这样一来,可以很容易地处理地址的变化,例如,可能需要处理国际地址

  4. 不是所有的字段都需要单独的字段访问器和更改器#

    你可能需要获得或设置员工的工资,而一旦构造了员工对象,肯定不需要更改雇用日期,另外在对象中常常包含一些不希望别人获得或设置的实例字段,例如Address类中的州缩写数组

  5. 分解有过多职责的类#

    如果明显地可以把一个复杂的类分解成两个概念上更为简单的类,就应该进行分解,但另一方面也不能走极端,如果设计十个类而每个类只有一个方法,那就有些矫枉过正了

    下面是一个反面的设计示例

    public class CardDeck
    {
        private int[] value;
        private int[] suit;
    
        public CardDeck(){}
        public void shuffle(){}
        public int getTopValue(){}
        public int getTopSuit(){}
        public void draw(){}
    }

    实际上这个类实现了两个独立的概念:一副牌(包含shuffle和draw方法)和一张牌(包括查看面值和花色的方法)。最好引入表示一张牌的Card类。

    现在有两个类,每个类分别完成自己的职责:

    public class CardDeck
    {
        public Card[] cards;
    
        public CardDeck(){}
        public void shuffle(){}
        public Card getTopCard(){}
        public void draw(){}
    }
    
    public class Card
    {
        private int value;
        private int suit;
    
        public Card(int aValue,int aSuit){}
        public int getValue(){return value;}
        public int getSuit(){return suit;}
    }
  6. 类名和方法名要能够体现它们的职责#

    变量应该有一个能够反映其含义的名字,类似地,类也应该如此(在标准类库中确实有一些含义不明确的例子,如Date类实际上是一个描述时间的类)

    对此有一个很好的惯例:类名应该是一个名词,或者是前面有形容词修饰的名词,或者是有动名词修饰的名词。对于方法来说,要遵循标准惯例:访问器方法用小写get开头,更改器方法用小写的get开头

  7. 优先使用不可变的类#

    LocalDate类以及java.time包中的其他类是不可变的——没有方法能够修改对象的状态。类似plusDays的方法不会更改对象,而是会返回一个状态已修改的新对象。

    如果多个线程试图同时更新一个对象,就会发生并发更改,其结果是不可预料的,如果类是不可变的,就可以安全地在多个线程间共享其对象。

    因此要尽可能让类是不可变的,对于表示值的类,如一个字符串或一个时间点这尤其容易,计算会生成新值而不是更新原来的值

    当然,并不是所有类都应当是不可变的,如果员工加薪时返回一个新的Employee对象,这会很奇怪

第5章 继承#

5.1 类、超类和子类#