1. 面向对象程序设计概述
面向对象的程序是由 对象(object) 组成的,每个对象包含对用户公开的特定功能和隐藏的实现。程序中的很多对象是来自标注类库的成品,也有一些是自定义的。
传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题,确定过程以后要考虑数据存储的方式,即“算法 + 数据结构 = 程序”。
面向过程的思维方式首先确定操作数据的过程,然后再确定如何组织数据的结构;而面向对象则正好相反,先确定组织数据的结构,再确定操作数据的过程。
对于一些规模较小的问题,将其分解为过程更合适,而对于规模很大的问题(比如浏览器的实现),则更适合使用对象。
1) 类
类(class):指定如何构造对象。可以将类想象成制作小甜饼的模具,将对象想象成小甜饼,由一个类 构造(construct) 对象的过程称为构建该类的一个 实例(instance)。标准 Java 类库提供了几千个类。
封装(encapsulation):将数据和行为组合在一个包中,并对对象的使用者隐藏具体的实现细节。
实例字段(instance field):对象中的数据。封装的关键在于不允许其他类中的方法直接访问某个类的实例字段。
方法(method):操作数据的过程。
状态(state):一个类的实例所拥有的一组特定的实例字段值的集合。在对象上调用一个方法时,其状态可能改变。
Object 类:OOP 可以通过扩展其他类来构建新类,Java 提供了一个“神通广大的超类”,名为 Object,所有其他类都扩展自 Object。
继承(inheritance):扩展一个类得到新类的过程。
过程式程序必须从最上面的 main() 开始编程;而对象式程序没有所谓的“最上面”,应从识别类开始,并为各个类添加方法。
识别类的一个简单经验是寻找分析问题的过程中出现的名词,而方法则对应动词。
2) 对象
使用 OOP 需要清楚对象的…
- 行为:可以对该对象做哪些操作、应用哪些方法。
- 状态:调用方法时,对象将如何响应。
- 标识:如何区分可能具有相同行为和状态的不同对象。
3) 类的关系
类之间的关系:
- 依赖(uses-a):如果一个类的方法要使用或操作另一个类的对象,就称前一个类依赖于后一个类。应尽可能减少类之间的依赖,即应尽可能减少类之间的 耦合(coupling)。
- 聚合(has-a):一个类的对象包含另一个类的对象。
- 继承(is-a):表示一个较特殊的类与一个较一般的类之间的关系。
2. 预定义类
之前使用的 Math 类仅仅封装了功能,而不包含数据,下面以更典型的 Date 类为例。
1) 对象与对象变量
Date 类:标准 Java 类库中包含的一个类,其对象描述一个时间点,例如“December 31, 1999, 23:59:59 GMT”。
构造器(constructor):一种特殊的方法,用以构造并初始化对象。构造器与类同名,因此 Date 的构造器名为 Date()。
操作符 new:构造类的对象时,添加在构造器前面,例如调用 new Date() 将由 Date 构造它的一个新对象,初始化为当前的日期和时间。
可以将对象传递给方法,例如 System.out.println(new Date()),也可以在其上应用方法,例如 String date = new Date().toString()。
对象变量:为了保留所构造的对象,需要将其存放在变量中,例如在 Date rightNow = new Date() 中,表达式 new Date() 构造一个 Date 类型的对象,其值是该对象的一个引用,Date rightNow = 将该引用存储于 Date 类型的对象变量 rightNow。
引用:对象变量并不包含对象,其值只是一个引用,指向存储在别处的某个对象。
可以显式地将对象变量设置为 null,表示这个变量目前没有引用任何对象。
2) LocalDate 类
Date 实例的状态是一个时间点。时间以距离 纪元(epoch)(国际协调时间(UTC)1970年1月1日00:00:00)的毫秒数(可负)表示。
但是 Date 类采用的是大多数地区的历法,对于其它历法(如中国阴历)则没有用处,于是类库设计者决定将对时间的计量与对时间点的命名分开,前者的实现为表示时间点的 Date 类,后者的实现为用日历表示法表示日期的 LocalDate 类。
构造 LocalDate 类的对象时,不要使用构造器,而应使用 静态工厂方法(factory method),它将代表你调用构造器。调用 LocalDate.now() 将构造一个新对象,表示其被构造的日期。
可以提供年月日来构造对应一个指定日期的对象,例如 LocalDate newYearsEve = LocalDate.of(1999, 12, 31)。可以对一个 LocalDate 对象调用方法 getYear()、getMonthValue()、getDayOfMonth() 获取年、月、日,例如 int year = newYearsEve.getYear()。
方法 plusDays():向对象加上指定的天数,生成一个新的 LocalDate 对象,并赋给指定变量,例如 LocalDate aThousandDaysLater = newYearsEve.plusDays(1000)。
3) Getter 与 Setter
更改器方法(Setter):调用后改变对象的状态的方法,例如 GregorianCalendar.add()。
访问器方法(Getter):只访问、不改变对象状态的方法,例如 plusDays()。
实例:打印本月日历并标注今日
import java.time.DayOfWeek;
import java.time.LocalDate;
class CalendarTest {
public static void main(String[] args) {
LocalDate date = LocalDate.now();
int month = date.getMonthValue(), today = date.getDayOfMonth();
date = date.minusDays(today - 1); // 设置为月初
DayOfWeek weekday = date.getDayOfWeek();
int value = weekday.getValue(); // 1 为 Mon, ..., 7 为 Sun
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();
}
}3. 自定义类
之前编写的一些简单的类都只包含一个简单的 main(),现在开始自己编写主力类,它们有自己的实例字段和实例方法。
1) Employee 类
class *ClassName {
/* 实例字段 */
...
/* 构造器 */
...
/* 方法 */
...
}class Employee {
/* 实例字段 */
private String name;
private double salary;
private LocalDate hireDay;
/* 构造器 */
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;
}
}class EmployeeTest {
public static void main(String[] args) {
Employee[] staff = new Employee[3];
staff[0] = new Employee("Jason", 4000, 2023, 1, 2);
staff[1] = new Employee("Mary", 5500, 2022, 5, 14);
staff[2] = new Employee("Willson", 7000, 2019, 3, 12);
for (Employee e : staff) e.raiseSalary(5);
for (Employee e : staff) {
System.out.println("name = " + e.getName() + ", salary = " + e.getSalary() + ", hireDay = " + e.getHireDay());
}
}
}2) 多个源文件
可以将各个自定义类分离出来放入一个单独的源文件,例如将上例中的 Employee 类放入单独的源文件 Employee.java,而将包含 main() 的 EmployeeTest 类放入文件 EmployeeTest.java。
有两种编译程序的方法:
- 使用通配符调用
javac Employee*.java,因为两个源文件名称都以Employee开头。 javac EmployeeTest.java,这种方法并没有显式地编译Employee.java,当编译器发现EmployeeTest.java中使用Employee类时,它将查找同名文件并编译。
3) 剖析 Employee
private String name;
private double salary;
private LocalDate hireDay;
public Employee(String n, double s, int year, int month, int day)
public String getName()
public double getSalary()
public LocalDate getHireDay()
public void raiseSalary(double byPercent)对实例字段,关键字 private 标记只能由 Employee 的成员访问。语法上,将它们标记 public 也合法,但是将完全破坏封装,任何类的成员都能读写它们,存在极大的安全隐患,所以实例字段必须标记 private。
对方法,关键字 public 标记可以由任何类的成员访问。
name 是 String 类对象的引用,hireDay 是 LocalDate 类对象的引用,它们本身也是对象。
4) 构造器
public Employee(String n, double s, int year, int month, int day) {
name = n;
salary = s;
hireDay = LocalDate.of(year, month, day);
}构造器与类同名,构造 Employee 类的对象时,构造器将运行,初始化实例字段。
Employee harry = new Employee("Harry Hacker", 5000, 2020, 10, 7);
// 可以用 var 简化为
var harry = new Employee("Harry Hacker", 5000, 2020, 10, 7);var 只能用于方法中的局部变量,参数和字段的类型必须声明。
构造器总是结合 new 使用,不能对已存在的对象调用构造器来重新设置实例字段。
小结:构造器…
- 与类同名。
- 可以有多个。
- 可以有任意个参数。
- 没有返回值。
- 总是结合操作符
new使用。
5) null 引用
空指针异常(NullPointerException):用方法操作 null 值时产生的异常,将导致程序终止。
定义类时,必须清楚地知道哪些字段可能为 null,在 Employee 类中…
salary为double,是一个基本类型,不可能为null。hireDay字段初始化为一个新的LocalDate对象,不可能为null。name为String,可能为null,如果调用构造器时为n提供的实参为null,name将为null。
name 可能为 null,要处理这样的风险,有“宽容”与“严格”两种方式。
“宽容”:将 null 转换为一个适当的非 null 值。
if (n == null) name = "unknown";
else name = n;Objects 类对此提供了一个简便方法 requireNonNullElse()。
public Employee(String n, double s, int year, int month, int day) {
name = Objects.requireNonNullElse(n, "unknown");
}“严格”:直接拒绝 null。
public Employee(String n, double s, int year, int month, int day) {
name = Objects.requireNonNull(n, "The name cannot be null");
}6) 隐式参数与显式参数
public void raiseSalary(double byPercent) {
double raise = salary * byPercent / 100;
salary += raise;
}调用 number007.raiseSalary(10) 将 number007.salary 增加 10%。
raiseSalary() 有两个参数:
- 隐式参数(目标/接收者):调用时位于方法名前的
Employee类对象。 - 显式参数:位于方法名后括号中的数值,显式地列在方法声明中。
关键字 this:在方法的定义中指代隐式参数,将实例字段与局部变量明显区分。
public void raiseSalary(double byPercent) {
double raise = this.salary * byPercent / 100;
this.salary += raise;
}7) 封装
public String getName() {
return name;
}
public double getSalary() {
return salary;
}
public LocalDate getHireDay() {
return hireDay;
}它们为典型的访问器方法,返回实例字段的值。由于实例字段均为 private,其他类的成员无法访问,所以要单独编写各自的访问器方法。
不要编写返回可变对象引用的访问器方法,反例:
private LocalDate hireDay;
public LocalDate getHireDay() {
return hireDay;
}
// 将 LocalDate 改为 Date
private Date hireDay;
public Date getHireDay() {
return hireDay;
}更改后的代码违反了上述原则,因为 LocalDate 没有更改器方法,而 Date 有更改器方法 setTime,所以 Date 对象可能被修改,破坏了封装性。
Employee harry = ...;
Date d = harry.getHireDay();
double tenYearsInMilliseconds = 10 * 365.25 * 24 * 60 * 60 * 1000;
d.setTime(d.getTime() - (long)tenYearsInMilliseconds);出错的原因是,d 与 harry.hireDay 引用同一个对象,对 d 调用更改器方法将破坏该 Employee 对象的私有状态。
有时确实需要返回一个可变对象的引用,那么应先对其克隆(第 4 章)。
public Date getHireDay() {
return (Date) hireDay.clone();
}8) 类方法的访问权限
类中的方法可以访问其所有对象的字段。
public boolean equals(Employee other) {
return name.equals(other.name);
}
if (harry.equals(jack)) ...调用 equals() 时它访问 harry 的私有字段,这很正常,不过它还访问 jack 的私有字段,这是合法的,因为 jack 也是 Employee 类对象。
9) 私有方法
有时将方法标记 private 很有用:
- 可能希望将一个复杂的计算代码块分解成若干个独立的辅助方法,这些方法往往与当前实现关系非常紧密,不应成为
public接口的一部分。 - 有时随着数据更改,某些方法不再需要,类的设计者可以安全地将
private方法删除。如果是public方法,有可能由类使用者调用,则不能安全删除。
10) final 实例字段
标记 final 的实例字段必须在构造对象时初始化,并且以后无法修改,final 对于基本类型或不可变类(如 String)的字段尤其有用。
对于可变类,final 可能造成混乱,例如:
private final StringBuilder evaluations;它在 Employee 构造器中初始化为:
evaluations = new StringBuilder();此处,final 表示存储在 evaluations 中的对象引用不会指向另一个对象,但是该对象的状态可以改变:
evaluations.append("can be modified!");4. 静态字段与静态方法
1) 静态字段
如果在类中将一个字段标记 static,那么该字段即为 静态字段,它并不出现在类的每个对象中。可以认为静态字段属于类,而不属于单个对象。
class Employee {
private static int nextId = 1;
private int id;
...
}如果有 1000 个 Employee 类对象,则有 1000 个实例字段 id,但只有 1 个静态字段 nextId,即使没有 Employee 对象,nextId 也存在,它不属于任何一个对象。
在构造器中,为新 Employee 对象分配下一个可用的 ID,然后将 nextId +1。
id = nextId;
nextId++;2) 静态常量
静态变量使用较少,但 静态常量 很有用,比如 Math 类中声明了一个静态常量 PI:
public class Math {
...
public static final double PI = 3.14159265358979323846;
...
}在自己的程序中使用 Math.PI 即可访问它,如果省略关键字 static,PI 就变成 Math 类的一个实例字段,需要通过 Math 类的一个对象才能访问,并且每一个 Math 类的对象都有自己的 PI 副本。
System 类中声明了一个静态常量 System.out:
public class System {
...
public static final PrintStream out = ...
...
}上文强调过不要将字段标记 public,不过常量可以标记 public,因为它无法被修改。
3) 静态方法
静态方法 是不操作对象的方法,例如方法 Math::pow,它不操作任何 Math 对象,即没有隐式参数。
由于静态方法不操作对象,它不能访问实例字段,只能访问静态字段。
为 Employee 类新编写一个静态方法:
public static int advanceId() {
int r = nexeId;
nextId++;
return r;
}要调用方法 advanceId(),需要提供类名:int id = Employee.advanceId()。
可以使用静态方法的情况:
- 方法不需要访问对象状态,因为它所需的参数都由显式参数提供(如
Math::pow)。 - 方法只需要访问类的静态字段(如
Employee::advanceId)。
还可以通过对象调用静态方法(即使不必),例如 harry 是一个 Employee 对象,那么语句 harry.advanceId() 也合法,但是这样写容易造成混淆,因为方法 advanceId() 计算的结果与 harry 毫无关系,仅仅是通过 harry 调用它,所以最好使用类名而非对象来调用静态方法。
术语“静态”的历史:
起初,C 引入关键字
static,表示退出一个块后仍然存在的局部变量。后来,
static在 C 中有了第二种含义,表示不能从其他文件访问的全局变量和函数。最后,C++ 重用了
static,表示属于类而不属于任何特定类对象的变量和函数。
4) 工厂方法
LocalDate 与 NumberFormat 等类使用静态工厂方法来构造对象。
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance();
NumberFormat percentFormatter = NumberFormat.getPercentInstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // $0.10
System.out.println(percentFormatter.format(x)); // 10%不使用构造器来创建对象的原因:
- 构造器需与类同名,但是此处有两个不同的名字,分别得到货币实例和百分比实例,无法为构造器命名。
- 使用构造器时,无法改变所构造对象的类型。而工厂方法实际上将返回
DecimalFormat类的对象,它是继承NumberFormat的一个子类(第 3 章)。
5) main() 方法
main() 也是一个静态方法,它不对任何对象进行操作,实际上启动程序时还没有任何对象,将执行 main() 并构造所需要的对象。
每个类都可以有一个 main(),以提供类的演示功能。
为 Employee 类编写的 main(),用于演示其作用:
class Employee {
...
public static void main(String[] args) {
var e = new Employee("Romeo", 5000, 2019, 8, 9);
e.raiseSalary(10);
System.out.println(e.getName() + " " + e.getSalary);
}
...
}要查看 Employee 类的演示,只需执行:java Employee。
如果 Employee 类是更大的应用 Application 的一部分,执行 java Application 时,Employee 类的方法 main 不执行。
5. 方法参数
传递基本类型参数时,Java 总是按值传递(而不是按引用传递),传递给方法的实质上是参数值的一个副本,方法不能修改传递给它的任何参数变量的值。
不过对象参数则不同,传递给方法的仍然是参数值的一个副本,不过这个副本和原本的对象引用都引用了同一个对象。方法结束后参数不再使用,但是所引用对象的值已然修改。
总结:
- 方法不能修改基本数据类型(数值型、布尔型)的参数。
- 方法可以改变对象参数的状态。
- 方法不能允许一个对象参数引用一个新对象。
6. 对象构造
1) 重载
重载(overloading):多个方法有相同的方法名,但有不同的参数。编译器必须挑选出具体调用哪个方法,如果无法匹配参数则无法编译,可能因为根本不存在匹配,或者没有一个相对更好的方法。
// 两个同名但不同的构造器方法, 调用时将出现重载
var messages = new StringBuilder();
var todoList = new StringBuilder("To do:\n");签名(signature):方法名以及参数类型,用以区分多个同名方法。返回值类型不属于方法的签名。
// String 类的 4 个同名方法
indexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)2) 默认字段初始化
如果在构造器中没有显式地为一个字段设置初始值,它将被设置为默认值:数值设置为 0,布尔值为 false,对象引用为 null。
方法中的局部变量必须被明确地初始化,而类中的字段将自动被设置为默认值。
3) 无参构造器
很多类都包含无参数的构造器,由无参数构造器创建对象时,对象的状态被设置为默认值。
如果自定义类没有构造器,程序将自动提供一个无参数构造器,将所有的实例字段设置为默认值。
如果类中提供了至少一个构造器且没有提供无参数构造器,那么构造对象时必须提供参数。
4) 显式字段初始化
通过重载类的构造器方法,可以采用多种形式设置类实例字段的初始状态,只要确保无论调用哪个构造器,每个实例字段都能被设置为一个有意义的初始值。
可以在类定义中直接为任何字段赋值:
class Employee {
private String name = "";
...
}这样,在执行构造器之前就完成了字段的赋值,如果一个类的所有构造器都要把某个字段设置为同一个值,那么这种写法很有用。
初始值不一定是常量值,还可以利用方法调用初始化一个字段:
class Employee {
private static int nextId;
private int id = advanceId();
...
private static int advanceId() {
int r = nextId;
nextId++;
return r;
}
...
}5) 参数名
很多程序员喜欢的命名风格是在构造器参数名前加上冠词:
public Employee(String aName, double aSalary) {
name = aName;
salary = aSalary;
}参数变量将遮蔽同名的实例字段,例如将参数命名为 salary,那么 salary 将指向这个参数而不是实例字段,结合关键字 this 指示隐式参数,可以写出另一种命名风格:
public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
}6) 调用其他构造器
关键字 this 的另一个作用:如果构造器的第一个语句形如 this(...),那么这个构造器将调用同一个类的另一个构造器。
public Employee(double s) {
this("Employee #" + nextId, s);
nextId++;
}调用表达式 new Employee(60000),Employee(double) 构造器将调用 Employee(String, double) 构造器,这样只需要写一次公共构造代码。
7) 初始化块
学习过的两种初始化实例字段的方法:
- 在构造器中设置值。
- 在声明中赋值。
还有另一种机制,称为 初始化块。在一个类的声明中可以包含任意的代码块,构造这个类的对象时这些块将执行。
class Employee {
private static int nextId;
private int id;
private String name;
private double salary;
{ // 初始化块
id = nextId;
nextId++;
}
...
}无论使用哪个构造器,字段 id 都将在初始化块中初始化。
如果类的静态字段需要很复杂的初始化代码,可以使用 静态初始化块,即在初始化块前标记 static:
private static Random generator = new Random();
static {
nextId = generator.nextInt(10000);
}8) 对象析构
Java 将自动完成垃圾回收,不需要人工回收内存,所以不支持析构器。
某些对象使用了内存之外的其他资源,此时回收和再利用就非常重要。
第 5 章将介绍方法 close() 及其自动调用。
方法 finalize() 已经废弃。
7. 记录
1) 记录
记录(record):一种特殊形式的类,其状态不可变,且公共可读。
组件(component):记录的实例字段。
record Point(double x, double y){ }Point 是一个有以下实例字段、构造器和方法的类:
private final double x;
private final double y;
Point(double x, double y)
public double x()
public double y()访问器方法名为 x() 和 y(),而不是 getX() 和 getY()。
除了字段访问器方法,每个记录还有 3 个自动定义的方法:toString()、equals()、hashCode()。也可以为一个记录增加自己的方法,不过不能增加实例字段。
2) 构造器
标准构造器:自动定义地设置所有实例字段的构造器。
自定义构造器:第一个语句调用另一个构造器的构造器(最终将调用标准构造器)。
如果标准构造器需要完成额外任务,可以提供自己的实现:
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(int from, int to) {
if (from > to) {
int temp = from;
from = to;
to = temp;
}
}
}8. 包
包(package):将类组织在一个集合中,以便方便地组织自己的代码,并将自己的代码与其他代码库区分开。
1) 包名
假设两个程序员不约而同地提供了 Employee 类,只要将各自的类放在不同的包中,就不会产生冲突。
为了保证包名的唯一性,可以使用一个因特网域名以逆序的形式作为包名,例如 com.oracle,然后追加项目名 com.oracle.corejava。如果把 Employee 类放在这个包中,那么 Employee 类的 完全限定名 为 com.oracle.corejava.Employee。
2) 类的导入
一个类可以使用所属包中的所有类,以及其他包中的公共类。
访问另一个包中的公共类的两种方式:
- 使用完全限定名:
java.time.LocalDate today = java.time.LocalDate.now()。 - 使用
import语句:import java.time.*,LocalDate today = LocalDate.now()。
用星号(*)只能导入一个包,而不能使用 import java.* 或 import java.*.*。
包名冲突:java.util 包和 java.sql 包都有 Date 类,如果同时导入了这两个包,使用 Date 类时将产生错误。增加一个特定的 import 语句来解决此问题:import java.util.Date。如果这两个 Date 类都需要使用,需要在每次使用时加上完整包名。
3) 静态导入
import 语句允许导入静态方法和静态字段,而不只是类,例如:import static java.lang.System.*,就可以在调用 System 类的静态方法和静态字段时省略 System:out.println("")。
也可以导入特定的方法或字段:import static java.lang.System.out。
4) 在包中增加类
package 语句:在包名放在源文件的开头:package com.oracle.corejava,并将此源文件放在与包名匹配的子目录中(com\oracle\corejava,其中文件夹 com 与 .java 文件并列放置)。没有此语句的源文件中的类属于无名包。
编译器负责处理文件(.java 文件),解释器负责加载类。
5) 包访问
public 与 private:标记 public 的部分可以由任意类访问,标记 private 的部分只能由定义它的类访问,没有标记的部分可以由同一个包中的所有类访问。
变量必须显式地标记 private。
6) 类路径
P144-145
7) 设置类路径
P146
9. JAR 文件
P146-151
10. 文档注释
P151-155
11. 类设计技巧
P155-157