第五章 对象和类
5.1 面向对象程序设计概述
面向对象程序设计(简称OOP)是当今主流的程序设计范型, 它已经取代了20世纪70年代的“结构化” 过程化程序设计开发技术。Java 是完全面向对象的, 必须熟悉OOP才能 够编写Java程序。
5.1.1 面向过程和面向对象
面向过程编程,一般上,解决一个问题的过程就是从条件出发,经过一定的步骤,不断接近,直到求出结果的过程. 这样的解决问题方式就是面向过程的.通过定义一系列操作达到预期的目的.
面向对象编程,是对问题空间的建模,首先描述问题中涉及的模型,以及模型的属性,行为,然后将各个模型组合,求解问题.
例如: 一个五子棋游戏,面向过程的解决方案一般如下:

这样的方式符合一般的数学上的程序定义,但当问题的规模逐渐增大,问题的复杂性增长,求解问题的步骤就要增多,面向过程的编程就会逐渐体现出劣势.
面向对象的方式就应运而生了,面向对象的编程方式是面向过程的一种扩展,将问题域中的数据和方法抽象成对象的属性和行为
例如: 上面的五子棋游戏给出面向对象的解决方案,即抽象出两个玩家、一个棋盘。自然的两个玩家各自知道自己的棋子颜色,他们可以向棋盘落子,并且轮流落子;而棋盘保存了玩家的落子信息,并且在落子后可以给出玩家是否胜利这样的判断,因为他拥有所有子的信息。
5.1.2 类
类(class)是构造对象的模板或蓝图。由类构造(construct)对象的过程称为创建类的实例(instance)。
5.1.3 对象
对象是根据类创建出来的实体,
5.1.4 类之间的关系
在类之间,最常见的关系有
- 依赖(
uses-a) - 聚合(
has-a) - 继承(
is-a)
5.2 使用预定义类
在 Java 中, 没有类就无法做任何事情,前面接触过几个类如String,Integer等.这些类都不我们自己定义的,是Java提供的系统库中的类,分布在rt.jar tools.jar和dt.jar中. 这些系统库中预定义的类包含了大量的功能,是我们开发Java项目离不开的东西.
5.2.1 对象和对象变量
要想使用对象,就必须先构造对象,并指定其初始状态.然后,对对象应用方法.
在Java程序设计语言中,使用构造器(constructor)构造新实例.构造器是一种特殊的方法,用来构造并初始化对象.
下面看一个例子。在标准 Java 库中包含一个 Date 类。它的 对象将描述一个时间点, 例如:Tue Jul 30 19:26:01 CST 2019
构造器的名字应该跟类名相同.因此Date的构造器名为Date,想要构造一个Date对象需要在构造器前面加上new关键字,如下
new Date();
这个表达式构造了一个新对象.这个对象被初始化为当前的日期和时间.
如果需要的话也可以将这个对象传递给一个方法:
System.out.println(new Date());
或者,也可以将一个方法应用到刚创建的对象.
String s = new Date().toString();
上面几个例子中,构造的对象仅使用了一次.通常我们希望构造的对象可以多次使用,因此,需要将对象保存在一个变量中;
Date today = new Date();
如下图所示,对象变量today引用了新建的对象

在对象与对象变量之间存在这一个重要的区别.例如,语句
Date birthday; //birthday doesn't refer to any object
定义了一个对象变量birthday,它可以引用Date类型的对象.但是,一定要认识到:变量birthday不是一个对象,实际上也没有引用对象.此时,不能将任何Date方法应用于这个变量上.语句
String s = birthday.toString();
上面的语句会产生编译错误.
必须实现初始化birthday,这里有两个选择.
1.构造新的对象初始化这个变量
birthday = new Date();
2.让这个变量引用一个已存在的对象:
birthday = today;
现在,这两个变量引用同一个对象

一定要认识到:一个对象变量并没有实际包含一个对象,而仅仅引用一个对象.
在Java中,任何对象变量的值都是对存储在另一个地方的一个对象的引用.new 操作符的返回值也是一个引用.
注意 : 局部变量不会自动初始化为null,而必须通过new或将他们设置为null进行初始化
5.3 用户自定义类
上面我们已经编写过一些简单的类,这些类包含一个简单的main方法.也使用过也写Java提供给我们的预定义类.但是大多数情况下我们都需要写一些自定义的类,来满足特殊的要求.现在开始学习如何设计复杂应用程序所需的各种主力类.通常,这些类没有main方法,却有自己的实例域和实例方法.要想创建一个完整的程序,应该将若干类组合在一起,其中只有一个类有 main 方法.
5.3.1 实例 - Student 类
在Java中,最简单的类定义形式为:
class ClassName{
field1
field2
...
constructor1
constructor2
...
method1
method2
...
}
下面看一个非常简单的Student类.在编写教务管理系统是可能会用到.
class Student{
// instance fields
private String name;
private double score;
private LocalDate admissionDay;
// constructor
public Student(String n,double s,int y,int m, int d){
name=n;
score=s;
admissionDay=LocalDate.of(y,m,d);
}
// a method
public String getName(){
return name;
}
// more methods
public void setScore(double s){
score=s;
}
...
}
5.3.2 多个源文件的使用
上面的Student类没有Java程序的入口main方法,我们可以选择在Student类上添加一个main方法.也可以在一个新的类里新建main方法.
许多程序员习惯将每个类存在一个单独的源文件中.例如,将Student类存放在文件Student.java中,将StudentTest类存放在StudentTest.java中.
如果喜欢这组织文件,将可以有两种编译源程序的方式
1. 使用通配符调用编译器
javac Student*.java
2. 直接编译包含main方法的类
javac StudentTest.java
第二种方法没有显式的包含Student.java但是Student.java也被编译了,因为javac发现StudentTest类种使用了Student类就会去查找Student.class没有找到时就会去寻找Student.java,然后对其进行编译, 如果Student.java比已有的Student.class版本新,就会重新编译Student.java替换旧的Student.class
5.3.3 Student类的结构
开始部分是一些变量的声明,叫做实例域,这些变量保存了类的实例的当前状态,private确保只有Student自身能够访问这些实例域,而其他类不能读写这些域
private String name;
private double score;
private LocalDate admissionDay;
后面是一个构造器,构造器由虚拟机调用,用来创建类的实例.
public Student(String n,double s,int y,int m, int d)
再后是一些实例方法,用来改变类的状态,或者是执行类的动作
public String getName(){/*...some code...*/}
5.3.4 构造器
构造器是与类名相同的一类特殊的方法.在构造对象时,构造器会被调用 ,以便将实例域初始化为所希望的状态. 例如:
new Student("James Bond",86.5,2019,7,31);
构造器与其他的方法由一个重要的不同.构造器总是伴随着new操作符的执行被调用,而不能对一个已经存在的对象调用构造器来达到重新设置实例域的目的,例如,
james.Employee("James Bond",86.5,2019,7,31) //ERROR
上面的代码将产生编译时错误.
5.3.5 实例方法
实例方法用于操作对象以及存取他们的实例域.例如,方法:
public void setScore(double s){
score=s;
}
调用这个方法可以修改score实例域的值,如:
String name = james.setScore(92.5);
setScore方法由两个参数.第一个参数成为隐式(implicit)参数,是出现在方法名前的Student对象.第二个参数是位于方法名后面括号中的值,这是一个显式(explicit)参数.(有些人把隐式参数称为方法调用的目标或接收者。)
显式参数是明显地列在方法声明中的,隐式参数没有出现在方法声明中.
在每个方法中,关键字this表示隐式参数.如果需要的话,可以用下列方式编写setScore方法:
public void setScore(double s){
this.score=s;
}
这样可以将实例域与局部变量明显的区别开.
5.3.6 封装
最后来看一下Student的其他方法
public String getName(){
return name;
}
这是一个简单的访问器方法.由于它们只返回实例域值, 因此又称为域访问器。
将name标记为public,以此来取代独立的访问器方法会不会更好呢?
想象如下问题,如果name是一个只读属性,name的值在初始化时指定之后就不能进行更改了,那么把name声明为public,name就可以被更改,而造成不可预知的错误.
在有些时候,需要获得或设置实例域的值。因此,应该提供下面三项内容:
- 一个私有的数据域;
- 一个公有的域访问器方法;
- 一个公有的域更改器方法。
这样做要比提供一个简单的公有数据域复杂些, 但是却有着下列明显的好处:
- 可以改变内部实现,除了该类的方法之外,不会影响其他代码。
- 更改器方法可以执行错误检查
通过这样的封装,不对外部提供直接访问数据域,而提供修改数据域的方法,可以防止数据被随意改动而不被感知.
5.3.7 访问修饰符
访问修饰符可修饰的成分
| 类(外部类) | 方法 | 成员变量 | |
|---|---|---|---|
| public | Y | Y | Y |
| default | Y | Y | Y |
| protected | N | Y | Y |
| private | N | Y | Y |
访问修饰符的权限
- public:对所有类可见
- default:对同一包中的类可见、对同一包中的子类可见
- protected:对同一包中的类可见、对同一包及不同包中的子类可见
- private:仅对类本身可见
- default修饰符即不加修饰符时的状态
- 可见是可访问的意思,即由这些修饰符修饰的成分(类,方法,成员变量)可以被其他类访问.对子类可见即子类可以继承.
各访问修饰符的访问权限
| 访问权限 | 类 | 同一包 | 同一包中的子类 | 不同包 | 不同包中的子类 |
|---|---|---|---|---|---|
| public | √ | √ | √ | √ | √ |
| default | √ | √ | √ | × | × |
| protected | √ | √ | √ | × | √ |
| private | √ | × | × | × | × |
5.3.8 非访问修饰符
修饰成分表
| 类(外部类) | 方法 | 成员变量 | 接口 | |
|---|---|---|---|---|
| abstract | Y | Y | Y | Y |
| static | N | Y | Y | N |
| final | Y | Y | Y | N |
修饰符的含义
- abstract:表示为一个抽象类,
- static:表示静态,不用创建变量只用变量名即可访问或调用.
- final:表示不可以被继承
5.4 静态域与静态方法
在前面给出的示例程序中,main 方法都被标记为 static 修饰符。下面讨论一下这个修饰 符的含义。
5.4.1 静态域
如果将域定义为static,每个类中只有一个这样的域.这个域将和类存储在一起,类的对象没有这个域的拷贝,而是共享这个域.
5.4.2 静态常量
静态变量使用的比较少,但静态常量使用的比较多,他们之间的区别是变量是可以更改的,而常量是不可以更改的.final关键字为我们提供了这样的限制,如: Math类中由关于圆周率的定义
public class Math{
...
public static final double PI = 3.14159265358979323846;
...
}
在程序中,可以采用Math.PI的形式获得这个常量
5.4.3 静态方法
静态方法是一种不能向对象实施操作的方法(即没有隐式参数).例如Math类的pow方法
Math.pow(x,a);
计算幂 $x^a$ ,调用pow方法时没有使用Math类的实例.
5.4.4 工厂方法
在Java中,获得一个类实例最简单的方法就是使用new关键字,通过构造函数来实现对象的创建. 例如:
Date date = new Date();
不过在实际使用中,我们经常还用另外一种方式获取类的实例:
Calender calender = Calender.getInstance();
//or
Integer number =Integer.valueOf("3");
像这样的:不通过 new,而是用一个静态方法来对外提供自身实例的方法,即为我们所说的静态工厂方法(Static factory method).
5.4.5 为什么使用静态工厂方法
1. 静态工厂方法有名字
由于语言的特性,Java 的构造函数都是跟类名一样的。这导致的一个问题是构造函数的名称不够灵活,经常不能准确地描述返回值,在有多个重载的构造函数时尤甚,如果参数类型、数目又比较相似的话,那更是很容易出错。
比如:Date类的六个构造函数.
Date 类有很多重载函数,对于开发者来说,假如不是特别熟悉的话,恐怕是需要犹豫一下,才能找到合适的构造函数的。而对于其他的代码阅读者来说,估计更是需要查看文档,才能明白每个参数的含义了。
(当然,Date 类在目前的 Java 版本中,只保留了一个无参和一个有参的构造函数,其他的都已经标记为 @Deprecated 了)
而如果使用静态工厂方法,就可以给方法起更多有意义的名字,比如前面的 valueOf、newInstance、getInstance 等,对于代码的编写和阅读都能够更清晰。
2. 可以控制是否生成类的新实例
有时候外部调用者只需要拿到一个实例,而不关心是否是新的实例;又或者我们想对外提供一个单例时 —— 如果使用工厂方法,就可以很容易的在内部控制,防止创建不必要的对象,减少开销。
在实际的场景中,单例的写法也大都是用静态工厂方法来实现的。
3. 可以返回原返回类型的子
这条不用多说,设计模式中的基本的原则之一——『里氏替换』原则,就是说子类应该能替换父类。
显然,构造方法只能返回确切的自身类型,而静态工厂方法则能够更加灵活,可以根据需要方便地返回任何它的子类型的实例。
5.4.6 main方法
在Java中,main()方法是Java应用程序的入口方法,也就是说,程序在运行的时候,第一个执行的方法就是main()方法,这个方法和其他的方法有很大的不同,比如方法的名字必须是main,方法必须是public static void 类型的,方法必须接收一个字符串数组的参数等等。
它是一个静态方法,程序开始运行时还没有任何类.main方法将执行并创建程序所需要的对象.
5.5 方法参数
Java 程序设计语言总是采用按值调用。也就是说, 方法得到的是所有参数值的一个拷 贝,特别是,方法不能修改传递给它的任何参数变量的内容。
例如:假定一个方法试图将一个参数值增加至 3 倍
public static void tripleValue(double x){
x=x*3;
}
然后调用这个方法:
double percent = 10;
tripleValue(percent);
不过结果并没有成功.调用这个方法之后,percent的值还是10. 具体的执行过程如下图

图中的main()表示的是main方法它表示一个栈帧
- main方法中调用tripleValue()并把percent值的拷贝传给tripleValue栈帧中的x
- x被扩大为3倍
- tripleValue()方法执行完毕栈帧弹出栈顶,x的值就没了
- percent值并未被改变
5.6 对象构造
前面已经学习了编写简单的构造器,可以定义对象的初始状态。但是,由于对象构造非 常重要,所以 Java 提供了多种编写构造器的机制。下面将详细地介绍这些机制。
5.6.1 重载
有些类有多个构造器,例如,可以如下构造一个空的 StringBuilder 对象:
StringBuilder todoList = new StringBuilder();
或者,可以指定一个初始化字符串(“To do:\n”);
StringBuilder todoList = new StringBuilder("To do:\n");
这种同名的特征叫做重载(overloading).。如果多个方法有 相同的名字、不同的参数,便产生了重载
注意 :
- Java 允许重载任何方法, 而不只是构造器方法。
- 要完整地描述一个方法,需要指出方法名以及参数列表。这叫做方法的签名(signature)。
- 方法的返回值不属于方法签名的一部分。
返回类型不是方法签名的一部分。也就是说, 不能有两个名字相同、 参数类型也相同却返回不同类型值的方法。
5.6.2 默认域初始化
如果在构造器中没有显式地给域赋予初值,那么就会被自动地赋为默认值: 数值为 0、 布尔值为 false、 对象引用为null。然而,只有缺少程序设计经验的人才会这样做。确实, 如 果不明确地对域进行初始化,就会影响程序代码的可读性。
注意 : 局部变量不会被初始化为特定的初始值
5.6.3 无参数的构造器
很多类都包含一个无参数的构造函数,如果在编写一个类时没有编写构造器, 那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值.
如果类中提供了至少一个构造器,Java就不会再创建默认的无参构造器了.
5.6.4 显式域初始化
通过重载类的构造器方法,可以采用多种形式设置类的实例域的初始状态。确保不管怎 样调用构造器,每个实例域都可以被设置为一个有意义的初值,这是一种很好的设计习惯。
5.6.5 调用另一个构造器
可以通过this关键字可以调用当前类的应一个构造器,如:
public class Person(){
public Person() {
// some init code
}
public Person(String name) {
this();
this.name = name;
}
// some other code
}
上面Person类中的第二个构造器调用了当前类的无参构造器。
5.6.6 初始化块
前面已经讲过两种初始化数据域的方法:
- 在构造器中设置值
- 在声明中赋值
实际上,Java 还有第三种机制, 称为初始化块(initializationblock)。在一个类的声明中, 可以包含多个代码块。只要构造类的对象,这些块就会被执行。例如:
class Student{
private static int nextId = 1;
private int id;
{
id = nextId;
nextId++;
}
}
在这个示例中,无论使用哪个构造器构造对象,id域都在对象初始化块中被初始化.首先运行初始化块,然后才运行构造器的主体部分.
这种机制不是必需的,也不常见。通常会直接将初始化代码放在构造器中。
5.6.7 初始化代码执行顺序
- 所有数据域被初始化为默认值(0、false 或 null)。
- 按照在类声明中出现的次序, 依次执行所有域初始化语句和初始化块.
- 如果构造器第一行调用了第二个构造器,则执行第二个构造器主体.
- 执行这个构造器的主体.
5.6.8 静态初始化块
静态初始化块就是在初始化块前加上static关键字,但是它的意义就完全改变了
静态初始化块将在类被加载到内存时被执行(早于实例化类),且只执行一次.
5.7 包
Java使用包(package)将类组织起来,借助于包可以方便的组织自己的代码,并将自己代码与别人提供的代码库分开管理.
使用包的主要原因是确保类名的唯一性。假如两个程序员不约而同地建立了 Employee 类。只要将这些类放置在不同的包中, 就不会产生冲突。事实上,为了保证包名的绝对 唯一性, Sun 公司建议将公司的因特网域名(这显然是独一无二的)以逆序的形式作为包 名,并且对于不同的项目使用不同的子包。
从编译器的角度来看, 嵌套的包之间没有任何关系。例如,java.utU 包与java.util.jar 包 毫无关系。每一个都拥有独立的类集合。
例如导入java.util.*并不会导入java.util.jar包下的内容
5.7.1 类的导入
一个类可以使用所属包中的所有类,以及其他包中的共有类我们可以采用两种方式访问另一个包中的公有类
1. 使用每个类的全限定类名
java.time.LocalDate today = java.time.LocalDate.now();
这显然很繁琐
2. 使用import语句
import java.util.*;
然后就可以使用
LocalDate today = LocalDate.now();
5.7.2 静态导入
import 语句不仅可以导人类,还增加了导人静态方法和静态域的功能。
例如:
import static java.lang.System.*;
就可以使用System中的静态方法和静态域,而不必加上类前缀:
out.println("Hello world!");
exit(0);
5.7.3 使用包
要想使用包,就必须将包的名字放在源文件的开头,然后将包中的文件放到与完整的包名匹配的子目录中.
例如:
package cn.ntboy.entity;
public class Student{
//...
}
这个文件应该放到子目录 cn/ntboy/entity 中
5.8 类路径
类文件可以存储在JAR文件中,JAR文件可能被存储在任何位置,那么如何告诉java虚拟机去哪里找到我们需要的JAR文件呢?
- 存储在当前文件夹 将jar文件存储在当前类包的根目录下jvm会自动搜索这个目录,但是这显然不是一个灵活的方式
- 使用-classpath选项
运行java命令 时,可以加上一个选项
-classpath或者-cp,这两个选项时等价的,这样就可以告诉jvm去哪里搜索类文件,可以使用绝对路径,相对路径,以及系统变量. - 使用CLASSPATH环境变量(java1.5 之后不用再配置CLASSPATH了)
5.9 文档注释
JDK 包含一个很有用的工具,叫做javadoc,它可以由源文件生成一个 HTML 文档.
像java的标准库的文档就是javadoc生成的例如javase-12-docs-api
5.9.1 注释的插入
注释以/**开始,并以*/结束.
每个 /**...*/文档注释在标记之后紧跟着自由格式文本(free-form text)。标记由@开始, 如@author或@param。 自由格式文本的第一句应该是一个概要性的句子。javadoc 实用程序自动地将这些句子抽取出来形成概要页。
5.9.2 类注释
类注释必须放在 import 语句之后,类定义之前。
5.9.3 方法注释
每一个方法注释必须放在所描述的方法之前。除了通用标记之外, 还可以使用下面的标记:
@param变量描述 这个标记将对当前方法的参数部分添加一个条目这个描述可以占据多行, 并可以使用 HTML 标记。一个方法的所有@param 标记必须放在一起。@return返回值描述 这个标记将对当前方法添加“ return” (返回)部分。这个描述可以跨越多行, 并可以 使用 HTML 标记。@throws异常描述 这个标记将添加一个注释, 用于表示这个方法有可能抛出异常。
5.9.4 域注释
只需要对公有域(通常指的是静态常量)建立文档。
5.9.5 通用注释
下面的标记可以用在类文档的注释中。
@author 姓名这个标记将产生一个 “author” (作者)条目。可以使用多个@author 标记,每个@author标记对应一个作者@version 版本这个标记将产生一个“ version”(版本)条目。这里的文本可以是对当前版本的任何描述。 面的标记可以用于所有的文档注释中。@since 文本这个标记将产生一个 “since” (始于)条目。这里的 text 可以是对引人特性的版本描 述。例如,@since version 1.7.10@deprecated这个标记将对类、方法或变量添加一个不再使用的注释。文本中给出了取代的建议。 例如,@deprecated Use <code> setVIsible(true)</code> instead通过@see和@link标记,可以使用超级链接, 链接到 javadoc 文档的相关部分或外部文档。@see 引用这个标记将在 “see also” 部分增加一个超链接。它可以用于类中,也可以用于方法中。 这里的引用可以选择下列情形之一:
package.class#feature label
<a href="...">label/a>
text
第一种情况是最常见的。只要提供类、方法或变量的名字,javadoc 就在文档中插入一个超链接。 例如,
@see com.horstraann.corejava.Employee#raiseSalary(double)
建立一个链接到 com.horstmann.corejava.Employee 类的 raiseSalary(double) 方法的超链接。 可以省略包名, 甚至把包名和类名都省去,此时,链接将定位于当前包或当前类
需要注意,一定要使用井号(#),而不要使用句号(.)分隔类名与方法名,或类 名与变量名。 Java 编译器本身可以熟练地断定句点在分隔包、 子包、类、内部类与方 法和变量时的不同含义。 但是javadoc 实用程序就没有这么聪明了,因此必须对它提供帮助。 如果@see 标记后面有一个 < 字符,就需要指定一个超链接。可以超链接到任何 URL。
例如:
@see <a href="www.horstmann.com/corejava.html">The Core Java home page</a>
在上述各种情况下, 都可以指定一个可选的标签(label)作为链接锚(link anchor). 如果省略了 label, 用户看到的锚的名称就是目标代码名或 URL。 如果@see 标记后面有一个双引号(")字符,文本就会显示在 “see also” 部分。
例如,
@see "Core Java 2 volume 2
可以为一个特性添加多个@see标记,但必须将它们放在一起。
- 如果愿意的话,还可以在注释中的任何位置放置指向其他类或方法的超级链接, 以及 插人一个专用的标记, 例如,
{@link package.class#feature label}
这里的特性描述规则与@see标记规则一样。
5.9.6 包与概述注释
可以直接将类、方法和变量的注释放置在 Java 源文件中,只要用 /** . . . */ 文档注释界 定就可以了。但是, 要想产生包注释,就需要在每一个包目录中添加一个单独的文件。可以 有如下两个选择:
- 提供一个以 package.html 命名的 HTML 文件。在标记
<body> - </body>之间的所有文本都会被抽取出来。 - 提供一个以 package-info.java命名的 Java 文件。这个文件必须包含一个初始的以
/**和*/界定的 Javadoc 注释, 跟随在一个包语句之后。它不应该包含更多的代码或注释。
还可以为所有的源文件提供一个概述性的注释。这个注释将被放置在一个名为 overview.html 的文件中,这个文件位于包含所有源文件的父目录中。标记 <body>... </body> 间的所 有文本将被抽取出来。当用户从导航栏中选择“ Overview” 时,就会显示出这些注释内容。
5.9.7 注释的抽取与doclet
有关细节内容请查阅javadoc-guide
5.10 类设计技巧
- 一定要保证数据私有,如果不清楚是否要公开那就标记为私有
- 一定要对数据初始化
- 不要在类中使用过多的基本类型
- 不是所有的域都需要独立的域访问器和域更改器
- 将职责过多的类进行分解
- 类名和方法名要能够体现他们的职责
- 优先使用不可变的类
最后修改于 2021-10-25