在真正开始探讨 Java 的 类和对象 之前,我们需要先厘清几个概念:
首先,什么是面向对象程序设计?
面向对象程序设计(简称 OOP)是当今主流的程序设计范型。Java 是完全面向对象的,必须熟悉 OOP 才能够编写 Java 程序。
面向对象的程序是由对象组成的,每个对象包含对用户公开的特定功能部分和隐藏的实现部分。程序中的很多对象来自标准库,当然,还有一些是自定义的。
从根本上讲,只要对象能够满足要求,就不必关心其功能的具体实现过程。在 OOP 中,不必关心对象的具体实现过程,只要能够满足用户的需求即可。
传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题。一旦确定这些过程,就要开始考虑存储数据的方式。对于一些规模较小的问题,将其分解为过程的开发方式比较理想。面向对象则更适合解决规模较大的问题。
什么是类?
类(class)是构造对象的模板或蓝图。由类构造对象的过程称为创建类的实例(instance)。
什么是封装?
封装(encapsulation)是与对象有关的一个重要概念。从形式上看,封装不过是将数据和行为组合在一个包中,并对对象的使用者隐藏了数据的实现方式。对象中的数据称为 实例域(instance field),操纵数据的过程称为 方法。对于每个特定的类实例(对象)都有一组特定的实例域值。这些值的集合就是这个对象的当前状态(state)。
实现封装的关键在于绝对不能让类中的方法直接访问其他类的实例域。程序仅通过对象的方法与对象的数据进行交互。封装给对象赋予了 黑箱 特性,这是提高重用性和可靠性的关键。
在 Java 中,所有的类都源自于一个“神通广大的超类”,它就是 Object。这个以后会探讨。
什么是对象?
要想使用 OOP,一定要清楚对象的三个主要特性:
- 对象的行为(behavior) —— 可以对对象施加哪些操作, 或可以对对象施加哪些方法?
- 对象的状态(state) —— 当施加那些方法时,对象如何相应?
- 对象标识(identity) —— 如何辨别具有相同行为与状态的不同对象?
同一个类的所有对象实例,由于支持相同的行为而具有家族式的相似性。对象的行为是用可调用的方法定义的。需要注意,作为一个类的实例,每个对象的标识永远是不同的,状态常常也存在差异。
使用预定义类
要想使用对象,就必须首先构造对象,并指定其初始状态。然后,对对象应用方法。
在 Java 程序设计语言中,使用 构造器(constructor) 构造新实例。构造器是一种特殊的方法,用于构造并初始化对象。下面用标准 Java 类库中的 Date 类来举例。它的对象将描述一个时间点,例如:“December 31,1999,23:59:59 GMT”。
构造器的名字应该与类名相同。因此 Date 类的构造器名为 Date 。想要构造一个 Date 对象,需要在构造器前面加上 new 操作符:
new Date()
这个表达式构造了一个新对象。这个对象被初始化为当前日期和时间。
如果需要的话,也可以将这个对象传递给一个方法:
System.out.println(new Date());
或者,将将一个方法应用于刚刚创建的对象。Date 类中有一个 toString 方法。这个方法将返回日期的字符串描述:
String s = new Date().toString();
在这两个例子中,构造的对象仅使用一次。如果希望构造的对象可以多次使用,需要将对象存放在一个变量中:
Date birthday = new Date();
注意: 定义对象变量的时候,一定要引用它所对应的类的对象,否则不能通过这个变量调用任何该类的方法。
看下面这段代码:
Date deadline;
deadline = birthday;
这个 deadline 变量引用了一个已经存在的对象。现在,这两个变量引用同一个对象,是一种荣辱与共的关系。
一定要认识到:一个对象变量并没有实际包含一个对象,而仅仅引用一个对象。
可以显示地将对象变量设置为 null,表明这个对象变量目前没有引用任何对象。
deadline = null;
...
if(deadline != null)
System.out.println(deadline);
在设计和日期有关的类库时,设计者决定将保存时间与时间点命名分开。所以标准 Java 类库包含两个类:一个是用来表示时间点的 Date 类;另一个是用来表示大家熟悉的日历表示法的 LocalDate 类。当然还有其他的,但是我们现在不关心那些。
在前面的例子中,使用了 Java 标准类库中的 Date 类。下面我们来谈 LocalDate 类。
不要使用构造器来构造 LocalDate 的对象。实际上,应该使用静态工厂方法(factory method)代表你调用构造器。下面表达式
LocalDate.now()
会构造一个新对象,表示构造这个对象时的日期。
我们来提供年、月、日来构造对应一个特定日期的对象,并且把构造的对象保存在一个对象变量中:
LocalDate newYearsEve = LocalDate.of(1999,12,31);
再看下面的代码:
LocalDate aThousandDaysLater = newYearsEve.plusDays(1000);
year = aThousandDaysLater.getYear(); // 2002
month = aThousandDaysLater.getMonthValue(); //09
day = aThousandDaysLater.getDayOfMonth(); //26
上面代码中的 plusDays 方法会得到一个新的 LocalDate,如果把应用这个方法的对象称为当前对象,这个新日期对象则是距当前对象指定天数的一个新日期。
用户自定义类
我们来用一个非常简单的 Employee 方法来讲述用户如何自定义类:
package emplyeetext;
import java.time.*;
/**
* This program tests the Employee class
* @version 1.8 2018-2-3
* @author ShenXueYan
*/
public class EmployeeText {
public static void main(String[] args) {
//fill the staff array with three Employee objects
Employee[] staff = new Employee[3];
staff[0] = new Employee("Cal 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() +
", hirDay=" + 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;
}
}
在这个程序中,构造了一个 Employee 数组,并填入了三个雇员对象:
Employee[] staff = new Employee[3];
staff[0] = new Employee("Cal 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);
接下来,利用 Employee 类的 raiseSalary 方法将每个雇员的薪水提高 5%:
//raise everyone's salary by 5%
for(Employee e : staff) {
e.raiseSalary(5);
}
接下来我们来剖析这个 Employee 类:
这个类包含一个构造器和四个方法:
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;
最后,请注意,有两个实例域本身就是对象:name 域是 String 类的对象, hireDay 域是 LocalDate 类对象。这种情形是十分常见的。
我们来看 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 操作符的执行被调用,而不能对一个已经存在的对象调用构造器来达到重新设置实例域的目的。例如,
james.Employee("James Bond",250000,1950,1,1) // ERROR
将产生编译错误。
一定要记住:构造器没有返回值!!! 构造器总是伴随着 new 操作一起调用!!!
警告: 请注意,不要再构造器中定义与实例域重名的局部变量。例如,下面的构造器将无法设置 salary:
public Employee(String n,double s,...){ String name = n; double salary = s; ... }
这个构造器声明了局部变量 name 和 salary。这些变量只能在构造器内部访问。这些变量屏蔽了同名的实例域。这种错误很难被检查出来,因此必须小心。
下面这段代码中的方法:
public String getName() {
return name;
}
public double getSalary() {
return salary;
}
public LocalDate getHireDay() {
return hireDay;
}
都是典型的访问器方法。由于它们只返回实例域值,因此又称为域访问器。当然还有一个叫做域更改器的方法。
警告: 不要编写返回引用可变对象的方法。在 Employee 类中就违反了这个设计原则。如果需要返回一个可变对象的引用,首先应该对它进行 克隆(clone)。对象 clone 是指存放在另一个位置上的对象副本。有关clone 内容这里不介绍。
final 实例域
可以将实例域定义为 final。构建对象时必须初始化这样的域。也就是说,必须确保在每个构造器执行之后,这个域的值被设置,并在后面的操作中,不能再对它进行修改。例如,可以将 Employee 类中的 name 域声明为 final,因为在对象构建之后,这个值不会再被修改,即没有 setName 方法。
class Employee{
private final String name;
...
}
final 修饰符大多都应用于基本类型域,或不可变类型域(如果类中的每个方法都不会改变其对象,这种类就是不可变的类。例如,String 类。
静态域与静态方法
静态域:
这里给 Employee 类添加一个实例域 id 和一个静态域 nextId:
class Emplyee{
private static int nextId = 1;
...
private int id;
...
现在,每一个雇员对象都有一个自己的 id 域,但这个类的所有实例将共享一个 nextId 域。即使没有一个雇员对象,静态域 nextId 也存在。它属于类,而不属于任何独立的对象。
静态方法:
employee 类的静态方法不能访问 Id 实例域,因此它不能操作对象。但是,静态方法可以访问自身类中的静态域。下面是实例:
public static int getNextId(){
return nextId; // return static field
}
可以通过类名调用方法:
int n = Employee.getNextId();
当然,也可以通过对象调用静态方法。但是,由于某些原因,建议使用类名,而不是对象来调用这个方法。
方法参数
首先回顾一下在程序设计语言中有关将参数传递给方法(函数)的一些专业术语。按值调用(call by value)表示方法接受的是调用者提供的值。而按引用调用(call by reference)表示方法接受的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。
很多程序语言(特别是 C++ 和 Pascal)提供了两种参数传递方式: 值调用 和 引用调用 。但是,Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,特别是,方法不能修改传递给它的任何参数变量的内容。
对象构造
前面我们已经简单介绍了构造器,可以定义对象的初始状态。但是,由于它非常重要,所以 Java 提供了多种编写构造器的机制。下面就详细介绍:
重载
有些类有多个构造器。例如,可以如下构造一个空的 StringBuilder 对象:
StringBuilder messages = new StringBuilder();
或者,可以指定一个初始字符串:
StringBuilder todoList = new StringBuilder("To do:\n");
这种特征叫做重载(overloading)。如果多个方法有相同的名字、不同的参数,便产生了重载。编译器挑选出具体执行哪个方法的过程被称为 重载解析(overloading resolution)。
Java允许重载任何方法,而不只是构造器方法。因此,要完整地描述一个方法,需要指出方法名以及参数类型。这叫做方法的签名(signature)。例如,String 类有 4 个称为 indexOf 的共有方法。它们的签名是
indexOf(int)
indexOf(int,int)
indexOf(String)
indexOf(String,int)
返回类型不是方法签名的一部分。也就是说,不能有两个名字相同的、参数类型也相同却返回不同参数类型值的方法。
默认域初始化
如果在构造器中没有显示地给域赋初值,那么就会被自动地赋为默认值:数值为 0、布尔值为 false、对象引用为 null。这是域与局部变量的主要不同点。值得一提的是,这并不是一个好的编程习惯。
无参数的构造器
很多类都包含一个无参数的构造函数,对象由无参数的构造函数创建时,其状态会设置为适当的默认值。例如,以下是 Employee 类的无参数默认构造函数:
public Employee(){
name = "";
salary = 0;
hireDay = LocalDate.now();
}
如果再编写一个类时没有编写构造器,那么系统会提供一个无参数构造器。
如果类中提供了至少一个构造器,但是没有提供无参数构造器,则在构造对象时如果煤油提供参数就会被视为不合法。例如,构造器:
Employee(String name,double salary,int y,int d)
对于这个类,构造默认的雇员属于不合法。也就是,调用
e = new Employee();
将会产生错误。
警告: 请记住,仅当类没有提供任何构造器的时候,系统才会提供一个默认的构造器。
参数名
通常,残数用单个字符命名:
public Employee(String n,double s){
name = n;
salary = s;
}
这样做的缺陷是:只有阅读代码才能了解参数 n 和参数 s 的含义。
换一种方法: 在每个参数前面加上一个前缀 “a”:
public Employee(String aName,double aSalary){
name = aName;
salary = aSalary;
}
这样就很清晰,可以一眼看懂参数的含义。
当然,也可以用 this 。这里就不再赘述了。