对象与类
1. 面向对象程序设计(OOP)概述
面向对象程序设计(OOP)是当今主流的程序设计范型,它已经取代了 20 世纪 70 年代的「结构化」过程化程序设计开发技术。Java 是完全面向对象的。
1.1 类
类
类(class)是构造对象的模板或蓝图,由类构造(construct)对象的过程称为创建类的实例(instance)。
封装
封装(encapsulation,有时称为数据隐藏)是与对象有关的一个重要概念,它将数据和行为组合在一个包中,并对对象的使用者隐藏了数据的实现方式。
对象中的数据称为实例域(instance field),操纵数据的过程称为方法(method)。对于每个特定的类实例(对象)都有一组特定的实例域值,这些值的集合就是这个对象的当前状态(state)。
实现封装的关键在于绝对不能让类中的方法直接访问其他类的实例域。程序仅通过对象的方法与对象数据进行交互。
继承
可以通过扩展一个类来建立另外一个新的类,在扩展一个已有的类时,扩展后的新类具有所扩展的类的全部属性和方法,这个过程称为继承(inheritance)。
在 Java 中,所有的类都源自于超类 Object。
1.2 对象
对象的三个主要特性:
- 对象的行为(behavior):可以对对象施加哪些方法
- 对象的状态(state):当施加方法时,对象如何响应
- 对象的标识(identity):如果辨别具有相同行为与状态的不同对象
1.3 识别类
识别类的简单规则是在分析问题的过程中寻找名词,而方法对应着动词。
1.4 类之间的关系
在类之间,最常见的关系有:
- 依赖(uses-a):应该尽可能的将相互依赖的类减至最少,即让类之间的耦合度最小
- 聚合(has-a):聚合关系意味着类 A 的对象包含类 B 的对象
- 继承(is-a):用于表示特殊与一般之间的关系
2. 使用预定义类
2.1 对象与对象变量
要想使用对象,首先要构造对象,并指定其初始状态,然后对对象应用方法。
使用对象变量之前必须首先初始化。可以用新构造的对象初始化这个变量:
Date deadline = new Date();
也可以让这个变量引用一个已经存在的对象:
Date birthday = new Date();
Date deadline = birthday;
现在,这两个变量将引用同一个对象:
注意:一个对象变量并没有实际包含一个对象,而仅仅引用一个对象!
在 Java 中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用。new 操作符的返回值也是一个引用。
可以显示的将对象变量设置为 null,表明这个对象变量目前没有引用任何对象:
deadline = null;
...
if (deadline != null) {
System.out.println(deadline);
}
局部变量不会自动的初始化为 null,而必须通过调用 new 或将它们设置为 null 进行初始化。
为了易于理解,可以将 Java 的对象变量看作 C++ 的对象指针。例如:
int id; // Java
实际上,等同于:
int* id; // C++
在 Java 中的 null 引用对应 C++ 中的 NULL 指针。如果把一个变量的值赋给另一个变量,两个变量就指向同一个日期,即它们是同一个对象的指针。
所有的 Java 对象都存储在堆中。当一个对象包含另一个对象变量时,这个变量依然包含着指向另一个堆对象的指针。另外,在 Java 中,必须使用 clone 方法获得对象的完整拷贝。
2.2 Java 类库中的 LocalDate 对象
Java 标准类库中的 Date 类的实例有一个状态,即特定的时间点。时间使用距离一个固定时间点的毫秒数(可正可负)来表示,这个点就是所谓的纪元(epoch)。它是 UTC 时间1970 年 1 月 1 日 00:00:00
。
UTC 是 Coordinated Universal Time 的缩写,与 GMT(Greenwich Mean Time,格林威治时间)一样,是一种具有实践意义的科学标准时间。
Java 的类库设计者决定将保存时间与给时间点命名分开,所以标准 Java 类库分别包含了两个类:一个是用来表示时间点的 Date 类,另一个是用来表示日历表示法的 LocalDate 类。
LocalDate now = LocalDate.now();
LocalDate newYearsEve = LocalDate.of(1999, 12, 31);
int year = newYearsEve.getYear(); // 1999
int month = newYearsEve.getMonth(); // 12
int day = newYearsEve.getDay(); // 31
新日期对象也可以通过计算获得:
LocalDate aThousandDaysLater = newYearsEve.plusDays(1000);
year = aThousandDaysLater.getYear(); // 2002
month = aThousandDaysLater.getMonth(); // 09
day = aThousandDaysLater.getDay(); // 26
2.3 更改器与访问器方法
更改对象状态的方法称为更改器方法(mutator method),只访问对象而不修改对象的方法称为访问器方法(accessor method)。
import java.time.*;
/**
* @author Cay Horstmann
* @version 1.5 2015-05-08
*/
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();
}
}
------
Mon Tue Wed Thu Fri Sat Sun
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18* 19 20
21 22 23 24 25 26 27
28 29 30 31
3. 用户自定义类
要想创建一个完整的 Java 程序,应该将若干类组合在一起,其中只有一个类有main
方法。
3.1 Employee 类
import java.time.*;
/**
* This program tests the Employee class.
*
* @author Cay Horstmann
* @version 1.12 2015-05-08
*/
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;
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;
}
}
------
name=Carl Cracker,salary=78750.0,hireDay=1987-12-15
name=Harry Hacker,salary=52500.0,hireDay=1989-10-01
name=Tony Tester,salary=42000.0,hireDay=1990-03-15
3.2 多个源文件的使用
在上面的示例中,一个源文件包含了两个类。许多程序员习惯于将每一个类存在一个单独的源文件中。例如,将 Employee 类存放在文件Employee.java
中, 将 EmployeeTest 类存放在文件EmployeeTest.java
中。
这种情况就有两种编译源程序的方法:
> javac Employee*.java
# or
> javac EmployeeTest.java
虽然第二种方法并没有显式的编译
Employee.java
,但当 Java 编译器发现EmployeeTest.java
使用了 Employee 类时会查找名为Employee.class
的文件。如果没有找到,就会自动搜索Employee.java
,然后对它进行编译。更重要的是,如果Employee.java
版本较已有的Employee.class
文件版本新,Java 编译器就会自动的重新编译这个文件。
3.3 剖析 Employee 类
Employee 类包含 1 个构造器、4 个方法以及 3 个实例域:
// 构造器
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 String name;
private double salary;
private LocalDate hireDay;
3.4 从构造器开始
先来看看 Employee 类的构造器:
public Employee(String n, double s, int year, int month, int day) {
name = n;
salary = s;
hireDay = LocalDate.of(year, month, day);
}
可以看到,构造器与类同名。在构造 Employee 类的对象时,构造器会运行,以便将实例域初始化为所希望的状态。
构造器总是伴随着 new 操作符的执行被调用,而不能对一个已经存在的对象调用构造器,来达到重新设置实例域的目的。
3.5 隐式参数与显式参数
方法用于操作对象以及存取它们的实例域。例如:
public void raiseSalary(double byPercent) {
double raise = salary * byPercent / 100;
salary += raise;
}
将调用这个方法的对象的 salary 实例域设置为新值。看下面这个调用:
number007.raiseSalary(5);
具体将执行下列指令:
double raise = number007.salary * 5 / 100;
number007.salary += raise;
raiseSalary 方法有两个参数。第一个参数称为隐式(implicit)参数,是出现在方法名前的 Emploee 类对象number007
。第二个参数是位于方法名后面括号中的数值,是一个显式(explicit)参数。
在每一个方法中,关键字 this 表示隐式参数。如果需要的话,可以使用下列方式编写 raiseSalary 方法:
public void raiseSalary(double byPercent) {
double raise = this.salary * byPercent / 100;
this.salary += raise;
}
这样可以将实例域与局部变量明显的区分开来。
3.6 封装的优点
public String getName() {
return name;
}
public double getSalary() {
return salary;
}
public LocalDate getHireDay() {
return hireDay;
}
这些都是典型的访问器方法。由于它们只返回实例域值,因此又称为域访问器。
当需要获得或设置实例域值的时候,应该提供以下三项内容:
- 一个私有的数据域
- 一个公有的域访问器方法
- 一个公有的域更改器方法
这样做有下列明显的好处:
- 可以改变内部实现,除了该类的方法之外,不会影响其他代码
- 更改器方法可以执行错误检查,然而直接对域进行赋值将不会进行这些处理
3.7 基于类的访问权限
方法可以访问所调用对象的私有数据,还可以访问其所属类的所有对象的私有数据。
3.8 私有方法
在实现一个类时,由于公有数据非常危险,所以应该将所有的数据域都设置为私有的。
在 Java 中,要实现一个私有的方法,只需将关键字 public 改为 private 即可。
3.9 final 实例域
可以将实例域定义为 final,构建对象时必须初始化这样的域。也就是说,必须确保在每一个构造器执行之后,这个域的值被设置,并且在后面的操作中,不能再对它进行修改。
class Employee {
private final String name;
...
}
final 修饰符大都应用于基本(primitive)类型域,或不可变(immutable)类的域。例如,String 类就是一个不可变的类。
4. 静态域与静态方法
4.1 静态域
如果将域定义为 static,每个类中只有一个这样的域,而每一个对象对于所有的实例域却都有自己的一份拷贝。例如,假定需要给每一个雇员赋予唯一的标识码。这里给 Employee 类添加一个实例域id
和一个静态域nextId
:
class Employee {
private static int nextId = 1;
private int id;
}
现在,每一个 Employee 对象都有一个自己的id
域,但这个类的所有实例将共享一个nextId
域。即使没有一个 Employee 对象,静态域nextId
也存在。它属于类,而不属于任何独立的对象。
在绝大多数的面向对象程序设计语言中,静态域也被称为类域。
4.2 静态常量
例如,在 Math 类中定义了一个静态常量PI
:
public class Math {
...
public static final double PI = 3.14159265358979323846;
...
}
另一个多次使用的静态常量是System.out
:
public class System {
...
public static final PrintStream out = ...;
}
4.3 静态方法
静态方法是一种不能向对象实施操作的方法。例如,Math 类的pow
方法就是一个静态方法:
Math.pow(x, a);
可以认为静态方法是没有 this 参数的方法,另外静态方法可以访问自身类中的静态域。
4.4 工厂方法
静态方法还有另外一种常见的用途,类似 LocalDate 和 NumberFormat 的类使用静态工厂方法(factory method)来构造对象:
NumberFormat currencyFormatter = NumberFormat.getCurrencylnstance();
NumberFormat percentFormatter = NumberFormat.getPercentlnstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // prints $0.10
System.out.println(percentFomatter.format(x)); // prints 10%
之所以 NumberFormat 类不利用构造器完成这些操作,是因为:
- 无法命名构造器。构造器名字必须与类名相同,但是这里希望得到的货币实例和百分比实例采用不同的名字。
- 当使用构造器时,无法改变所构造的对象类型。而 Factory 方法将返回一个 DecimalFormat 类对象,这是 NumberFormat 的子类。
4.5 main 方法
main 方法也是一个静态方法,它不对任何对象进行操作。
事实上,在启动程序时还没有任何一个对象。静态的 main 方法将执行并创建程序所需要的对象。
5. 方法参数
按值调用(call by name)表示方法接收的是调用者提供的值,而按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。
Java 程序设计语言总是采用按值调用。
6. 对象构造
Java 提供了多种编写构造器的机制。
6.1 重载
如果多个方法有相同的名字、不同的参数,编译器必须挑选出具体执行那个方法,这种特征叫做重载(overloading)。
StringBuilder messages = new StringBuilder();
StringBuilder todoList = new StringBuilder("To do:\n");
如果编译器找不到匹配的参数,就会产生编译时错误,这个过程被称为重载解析(overloading resolution)。
6.2 默认域初始化
如果在构造器中没有显式的给域赋予初值,那么就会被自动的赋给默认值:数值为0
,布尔值为 false,对象引用为 null。
6.3 无参数的构造器
public Employee() {
name = "";
salary = 0;
hireDay = LocalDate.now();
}
如果在编写一个类时没有编写构造器, 那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值。
如果类中提供了至少一个构造器, 但是没有提供无参数的构造器, 则在构造对象时如果没有提供参数就会被视为不合法。
6.4 显式域初始化
初始值不一定是常量值,可以调用方法对域进行初始化:
class Employee {
private static int nextId;
private int id = assignId();
...
private static int assignId() {
int r = nextId;
nextId++;
return r;
}
...
}
6.5 参数名
public Employee(String aName, double aSalary) {
name = aName;
salary = aSalary;
}
当参数变量和实例域同名时,可以通过 this 访问实例域:
public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
}
6.6 调用另一个构造器
如果构造器的第一个语句形如this(...)
,这个构造器将调用同一个类的另一个构造器:
public Employee(double s) {
// calls Employee(String, double)
this("Employee #" + nextId, s);
nextId++;
}
采用这种方式使用 this 关键字非常有用,这样对公共的构造器代码部分只编写一次即可。
6.7 初始化块
之前已经提到两种初始化数据域的方法:
- 在构造器中设置值
- 在声明中赋值
事实上,Java 还有第三种机制,称为初始化块(initialization block)。在一个类的声明中,可以包含多个代码块。只要构造类的对象,这些块就会被执行。
class Employee {
private static int nextId;
private int id;
private String name;
private double salary;
// object initialization block
{
id = nextId;
nextId++;
}
public Employee(String n, double s) {
name = n;
salary = s;
}
public Employee() {
name = "";
salary = 0;
}
...
}
6.8 对象析构与 finalize 方法
由于 Java 有自动的 GC,不需要人工回收内存,所以 Java 不支持析构器。
可以为任何一个类添加 finalize 方法,它将在垃圾回收器清除对象之前调用。
7. 包
Java 允许使用包(package)将类组织起来。标准的 Java 类库分布在多个包中,包括 java.lang、java.util、java.net 等。标准的 Java 包具有一个层次结构,如同硬盘的目录嵌套一样,所有标准的 Java 包都处于 java 和 javax 包层次中。
使用包的主要原因是确保类名的唯一性,建议将公司的因特网域名以逆序的形式作为包名,并且对于不同的项目使用不同的子包,例如com.horstmann.corejava
。
7.1 类的导入
java.time.LocalDate today = java.time.LocalDate.now();
// or
import java.util.LocalDate;
// or
import java.util.*;
7.2 静态导入
import 语句不仅可以导入类,还增加了导入静态方法和静态域的功能:
import static java.lang.System.*;
...
out.println("Hello, world!"); // i.e., System.out
exit(0); // i.e., System.exit
7.3 将类放入包中
要想将一个类放入包中,就必须将包的名字放在源文件的开头,定义类的代码之前。
如果没有在源文件中放置 package 语句, 这个源文件中的类就被放置在一个默认包 (defaulf package) 中。默认包是一个没有名字的包。
需要将包中的文件放到与完整的包名匹配的子目录中,编译器将类文件也放在相同的目录结构中。
编译器在编译源文件的时候不检查目录结构。如果包与目录不匹配,虚拟机就找不到类。
7.4 包作用域
当没有将类定义为 public 时,默认只有同一个包中的其他类才可以访问该类。
8. 类路径
采用-classpath
或-cp
选项指定类路径:
java -classpath /home/usr/classdir:.:/home/user/archieves/archive.jar MyProg
9. 文档注释
JDK 中包含了一个很有用的工具 javadoc,它可以由源文件生成一个 HTML 文档,以专用的定界符/**...*/
标记。
javadoc -d docDirectory nameOfPackage
javadoc -d docDirectory nameOfPackage1 nameOfPackage2 ...
// 文件在默认包中
javadoc -d docDirectory *.java
10. 类设计技巧
- 一定要保证数据私有
- 一定要对数据初始化
- 不要在类中使用过多的基本类型
- 不是所有的域都需要独立的域访问器和域更改器
- 将职责过多的类进行分解
- 类名和方法名要能够体现它们的职责
- 优先使用不可变的类