title: 类与对象
tag: 标签名
categories: 分类
comment: 是否允许评论(true or false)
description: 描述
top_img: https://z3.ax1x.com/2021/10/06/4xq2s1.png
cover: https://z3.ax1x.com/2021/10/06/4xq2s1.png
面向对象程序设计概述
面向对象程序设计(OOP)是当今主流的程序设计规范。
类
类(class)是构造对象的模板或蓝图。我们可以将类想象成制作小甜饼的切割机,将对象想象为小甜饼。由类构造对象的过程称为创建类的实例。用Java编写的所有代码都位于某个类的内部。标准的Java库提供了几千个类,可以用于用户界面设计、日期、日历和网络程序设计。
在扩展一个已有的类时,这个扩展后的新类具有扩展的类的全部属性和方法。在新类中,只需要提供适合这个新类的新方法和数据域就可以了。通过扩展一个类来建立另外一个类的过程称为继承(inheritance).
对象
要想使用OOP,一定要清楚对象的三个主要特性:
- 对象的行为:可以对对象施加哪些操作,或可以对对象施加哪些方法?
- 对象的状态:当施加那些方法时,对象如何响应?
- 对象标识:如何辨别具有相同行为于状态的不同对象。
识别类
传统的过程化程序设计,必须从顶部的main函数开始编写程序。在面向对象程序设计时没有所谓的“顶部”。首先从设计类开始,然后再往每个类中添加方法。
识别类的简单规则时再分析问题的过程中寻找名词,而方法对应着动词。
类之间的关系
在类之间,最常见的关系有:
-
依赖(uses-a),是一种最明显的、最常见的关系。如:Order类使用Account类是因为Order对象需要访问Account对象查看信用状态。但是Item类不依赖于Account类,这是因为Item对象于客户账户无关。如果一个类的方法操纵另一个类的对象,我们就说一个类依赖于另一个类。我们应该尽可能的减少依赖。如果类A不知道B的存在,它就不会关心B的任何改变。(这意味着B的改变不会导致A产生任何bug).
-
聚合(aggregation):“has-a"关系,是一种具体且易于理解的关系。如:一个Order对象包含一些Item对象。聚合意味着类A的对象包含着类B的对象。
-
继承(inheritance):即"is-a"关系,是一种用于表示特殊与一般关系的。如:RushOrder类是由Order类继承而来。在具有特殊性的RushOrder类中包含了一些用于优先处理的特殊方法,以及一个计算运费的不同方法;而其他的方法,如添加商品、生成账单等都是从Order类继承来的。
使用预定义类
在Java中,没有类就无法做任何事情,但是,并不是所有的类都具有面向对象的特征。如在Math类,我们可以使用Math类的方法,比如Math.random,并且只需要知道方法名和参数,而不必了解它的具体实现过程。
对象与对象变量
要使用对象,就必须先构造对象,并指定其初始状态。然后,对对象应用方法。
在Java程序设计语言中,使用**构造器(constructor)**构造新实例。构造器是一种特殊的方法,用来构造并初始化对象。
构造器的名字应该与类名相同。Date类的构造器名为Date。要想构造一个Date对象,需要在构造器前面加上一个new操作符,如下:
new Date() // 这个表示式构造了一个新对象。这个对象被初始化为当前的日期和时间。
也可以将一个方法用于刚刚创建的对象。
String s = new Date().toString();
有时候希望构造的对象可以多次使用,因此,需要将对象存放在一个变量中:
Date birthday = new Date();
一定要认识到:一个对象变量并没有实际包含一个对象,而仅仅引用一个对象。
在Java中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用。new 操作符的返回值也是一个引用。下列语句:
Date deadline = new Date();
// 有两个部分。表达式new Date()构造了一个Date类型的对象,并且它的值是对新创建对象的引用。这个引用存储在变量deadline中。
可以显示将对象变量设置为null,表明这个对象变量目前没有引用任何对象。
deadline = null;
....
if(deadline != null)
System.out.println(deadline);
如果将一个方法应用于一个值为null的对象上,那么就会产生运行时错误。
Java类库中的LocalDate类
Date类的实例有一个状态,即特定的时间点。
标准Java类库分别包含了两个类:一个是用来表示时间点的Date类;另一个是用来表示大家熟悉的日历表示法的LocalDate类。
不用使用构造器来构造LocalDate类的对象。实际上,应当使用静态工厂方法(factory method)代表你调用构造器。下面的表达式
LocalDate.now()
当然,通常都希望将构造的对象保存在一个对象变量中:
LocalDate newYearEve = localDate.of(1999,12,31);
// 在拥有LocalDate对象后,可以用方法getYear、getMonthValue和getDayOfMonth得到年、月、日
int year = newYearEve.getYear(); // 1999
int month = newYearEve.getMonthValue(); //12
int day = newYearEve.getDayOfMonth(); //31
plusDays方法会得到一个新的LocalDate,如果把应用这个方法对象称为当前对象,这个新日期对象则是距当前对象指定天数的一个新日期。
LocalDate aThousandDaysLater = newYearsEve.plusDays(1000);
year = aThousandDaysLater.getYear(); // 2002
month = aThousandDaysLater.getMonthValue(); //09
day = aThousandDaysLater.getDayOfMonth(); // 26
java.time.LocalDate8 API
static LocalTime now()
// 构造一个表示当前日期的对象
static LocalTime of(int year, int month, int day)
// 构造一个表示日期的制定对象
int getYear()
int getMonthValue()
int getDayOfMonth()
// 得到当前日期的年、月、日
DayOfWeek getDayOfWeek
// 得到当前日期是星期几,作为DayOfWeek类的一个实例返回。
LocalDate plusDays(int n)
LocalDate minusDays(int n)
// 生成当前日期之后或之前n天的日期
用户自定义类
学习如何设计复杂应用程序所需要的的各种主力类,通常,这些类没有main方法,却有自己的实例域和实例方法。要想创建一个完整的程序,应该将若干类组合在一起,其中只有一个类有main方法。
Employee类
在Java中,最简单的类定义形式为:
class ClassName{
filed1
filed2
...
constructor1
constructor2
...
method1
method2
...
}
一个简单的EmploeeTest类
/**
* 文件描述
*
* @Author: QJS
* @CreateDate: 2021/12/6 14:41
**/
public class EmployeeTest {
public static void main(String[] args) {
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", 45000, 1990, 3, 15);
// 调用Employee类的raiseSalary方法将每个雇员的薪水提高5%
for (Employee e:staff) {
e.raiseSalary(5);
}
for (Employee e: staff) {
System.out.println("name=" + e.getName() + ",salary=" + e.getSalary() + ",hireday=" + e.getHireday());
}
}
static class Employee{
// 关键字表示确保只有Employee类自身的方法能够访问这些实例域。
private String name;
private double salary;
private LocalDate hireday;
public Employee(String n, double s, int year, int month, int day) {
this.name = n;
this.salary = s;
this.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;
}
}
}
在一个源文件中,有且仅有一个公有类,但可以有任意数目的非公有类。
从构造器开始
public Employee(String name, double salary, int year, int month, int day) {
this.name = name;
this.salary = salary;
this.hireday = LocalDate.of(year,month,day);
}
可以看到,构造器与类同名。在构造Employee类的对象时,构造器会运行,以便将实例域初始化为所希望的状态。例如
new Employee( "Carl Cracker", 75000, 1987, 12, 15);
构造器和其他的方法有一个重要的不同,构造器总是伴随着new 操作符的执行被调用,而不能对一个已经存在的对象调用构造器来达到重新设置实例域的目的。
构造器的几个点要记住:
- 构造器与类同名
- 每个类可以有一个以上的构造器
- 构造器可以有0个,1个或多个参数
- 构造器没有返回值
- 构造器总是伴随着new操作一起调用
要记住Java中的对象都是在堆中构造的。
封装的优点
public String getName() {
return name;
}
public double getSalary() {
return salary;
}
public LocalDate getHireday() {
return hireday;
}
典型的访问器方法。由于它们只返回实例域值,因此又称为域访问器
基于类的访问权限
方法可以访问所调用对象的私有数据。一个方法可以访问所属类的所有对象的私有数据。
Class Employee
{
...
public boolean equals(Employee other){
return name.equals(other.name);
}
}
// 典型的调用
if(harry.equals(boss))...
这个方法访问harry的私有域,并且它还访问了boss的私有域。原因是boss是Employee类对象,而Employee类的方法可以访问Employee类的任何一个对象的私有域。
final 实例域
可以将实例域定义为fianl。构建对象时必须初始化这样的域。也就是说,必须确保在每一个构造器执行之后,这个域的值被设置,并且在后面的操作中,不能够再对它进行修改。
class Employee{
private final String name;
}
final修饰符大都应用于基本类型域,或不可变类的域。
静态域与静态方法
静态域
如果将域定义为static,每个类中只有一个这样的域。而每一个对象对于所有的实例域却都有自己的一份拷贝。
class Employee
{
private static int nextId = 1;
private int id;
...
}
现在的每一个雇员都有自己的id域,但这个类的所有实例都将共享一个nextId域,换句话说,如果有1000个Employee类的对象,则有1000个实例域id.但是,只有一个静态域nextId.即使没有一个雇员对象,静态域nextId也存在。它属于类,不属于任何对象。
静态变量
静态变量使用得比较少,但静态常量却使用的比较多。如在Math类中定义一个静态常量
public class Math
{
...
public static final double PI = 3.1415926535879323846;
}
静态方法
静态方法是一种不能向对象实施操作的方法。例如,Math类的pow方法就是一个静态方法。表达式
Math.pow(x,a);
// 在运算时,不使用任何Math对象。换句话说,没有隐式的参数。
// 可以认为静态方法是没有this参数的方法。
在下面两种情况下使用静态方法:
- 一个方法不需要访问对象状态,其所需参数都是通过显示参数提供
- 一个方法只需要访问类的静态域。
工厂方法
静态方法还有另外一种常见的用途。类似LocalDate和NumberFormat的类使用静态工厂方法来构造对象。如已经使用过的LocalDate.now和LocalDate.of。
NumberFormat currencyFormatter = numberFormat.getCurrentcyInstance();
NumberFormat percentFormatter = NumberFormat.getPrecentInstace();
double x = 0.1;
System.out.println(currencyFormatter.format(x));// 0.1
为什么NumberFormat类不利用构造器完成这些操作呢,主要有两个原因:
- 无法命名构造器。构造器的名字必须与类相同。
- 当使用构造器时,无法改变所构造的对象类型。而Factory方法将返回一个DecimalFormat类对象,这是NumberFormat的子类。
main方法
main方法不对任何对象进行操作。事实上,在启动程序时还没有任何一个对象。静态的main方法将执行并创建程序所需要的对象。
方法参数
按值调用表示方法接收的是调用者提供的值。而按引用调用表示方法接收的是调用者提供的变量地址。
Java程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,特别是,方法不能修改传递给它的任何参数变量的内容。
double percent = 10;
harry.raiseSalary(percent);
// 在方法调用之后,percent的值还是10
我们尝试用一个方法将一个参数增加至3倍:
public static void tripleValue(double x){
x = 3 * x;
}
然后调用这个方法:
double percent = 10;
tripleValue(percent);
// 但是我们发现在调用这个方法之后,我们的percent还是10这个值,并没有改变
x被初始化为percent值的一个拷贝,x被乘以3后是30。但是percent仍然是10.这个方法结束之后,参数变量x不再使用。
方法参数共有两种类型:
- 基本数据类型
- 对象引用
读者可以看到一个方法不可能修改一个基本数据类型的参数。而对象引用作为参数就不同了。
public static void tripleSalary(Employee x){
x.raiseSalary(200);
}
当调用
harry = new Employee(...)
tripleSalary(harry);
具体的执行过程为:
- x被初始化为harry值的拷贝,这里是一个对象的引用
- raiseSalary方法应用于这个对象的引用。x和harry同时引用的那个Employee对象的薪金提高了200%
- 方法结束后,参数变量x不再使用。但是对象变量harry继续引用那个薪金增至3倍的雇员对象。
总结一下Java方法参数的使用情况:
- 一个方法不能修改一个基本数据类型的参数
- 一个方法可以改变一个对象参数的状态
- 一个方法不能让对象参数引用一个新的对象
对象构造
重载
有些类有多个构造器。如,可以如下构造一个空的StringBuilder对象:
StringBuilder messages = new StringBuilder();
或者,可以指定一个初始字符串:
StringBuilder todoList = new StringBuilder("To do:\n");
这个特征叫做重载。如果多个方法由相同的名字、不同的参数,便产生了重载。编译器必须挑选出具体执行的哪个方法,它可以通过各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出不同相应的方法。
Java允许重载任何方法,而不只是构造器方法。
默认域初始化
如果在构造器中没有显式地给域赋予初值,那么就会被自动第赋值为默认值:数值为0、布尔值为false、对象引用为null。但是只有缺少设计经验的人才会这么做。
无参数的构造器
很多类都包含一个无参数的构造函数,对象由无参数构造函数创建时,其状态会设置为适当的默认值。
public Employee(){
name = "";
salary = 0;
hireDay = LocalDate,now();
}
如果在编写一个类时没有编写构造器,那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值。于是,实例域中的数值型数据设置为0、布尔型数据设置为false、所有对象变量将设置为null.
如果类中提供了至少一个构造器,但是没有提供无参数的构造器,则在构造对象时如果没有提供参数就会被视为不合法。
显示域初始化
通过重载类的构造器方法,可以采用多种形式设置类的实例域的初始化状态。确保不管怎样调用构造器,每个实例域都可以被设置为一个有意义的初值,这是一个很好的设计习惯。
class Employee{
private String name = "";
...
}
在执行构造器之前,先执行赋值操作。当一个类的所有构造器都希望把相同的值赋予某个特定的实例域时,这种方式特别有用。
初始值不一定是常量值。也可以调用方法对域进行初始化。
class Employee{
private static int nextId;
private int id = assignId();
...
private static int assignId(){
int r = nextId;
nextId++.;
return r;
}
}
参数名
通常,参数用单个字符命名:
public Employee(String n, double s){
name = n;
salary = s;
}
但是这样做的缺陷是:只有在阅读代码时候才能够了解参数n和参数s的含义。
有些程序员在每个参数前面加上一个前缀“a”:
public Employee(String aName, double aSalary){
name = aName;
salary = aSalary;
}
还有一种技巧,参数变量用同样的名字将实例域屏蔽起来。如可以采用this.salary的形式访问实例域。this指示隐式参数,也就是所构造的对象。
public Employee(String name, double salary){
this.name = name;
this.salary = salary;
}
调用另一个构造器
关键字this引用方法的隐式参数。这个关键字还有另外的一个含义。
如果构造器的第一个语句形如this(…),这个构造器将调用同一个类的另一个构造器。如下所示:
public Employee(double s){
this("Employee #" + nextId,s);
nextId++;
}
当我们调用new Employee(6000)时 ,Employee(double) 构造器将调用Employee(String,double)构造器。
采用这种方式使用this关键字非常有用,这样对公共的构造器代码部分只编写一次即可。
初始化块
前面已经讲过两种初始化数据域的方法:
- 在构造器中设置值
- 在声明中赋值
在Java中还有第三种机制,称为初始化块。在一个类的声明中,可以包含多个代码块。只要构造类的对象,这些块就会被执行。如
class Employee{
private static int nextId;
private int id;
private String name;
private double salary;
{
id = nextId;
nextId++;
}
public Employee(String n, double s){
name = n;
salary = s;
}
public Employee(){
name = "";
salary = 0;
}
}
在这个示例中,无论使用哪个构造器构造对象,id域都在对象初始化块中被初始化。首先运行初始化块,然后才运行构造器的主题部分。这种机制不是必需的,也是不常见的。通常会直接将初始化代码放在构造器中。
由于初始化数据域有多种途径,所以列出构造过程中的所有路径可能相当混乱。下面是调用构造器的具体处理步骤:
- 所有数据域被初始化为默认值(0、false或null)
- 按照在类声明中出现的次序,依次执行所有域初始化语句和初始化块。
- 如果构造器第一行调用了第二个构造器,则执行第二个构造器主体
- 执行这个构造器的主体。
如果对类对的静态域进行初始化的代码比较复杂,那么可以使用静态的初始化块。
将代码放在一个块中,并标记关键字static。下面为一个示例。其功能将雇员ID的起始值赋予一个小于10000的随机整数。
static{
Random generator = new Random();
nextId = generator.nextInt(10000);
}
在类第一次加载的时候,将会进行静态域的初始化。是实例域一样,除非将它们显式地设置成其他值,否则默认的初始值为0、false或null。所有的静态初始化语句块都将依照定义的顺序执行。
包
Java允许使用包将类组织其起来。借助于包可以方便地组织自己的代码,并将自己的代码与别人提供的代码库分开管理。
标准的Java类库分布在多个包中,包括java.lang、java.util和java.net等。标准的java包具有一个层次结构。
使用包的主要原因是确保类的唯一性。假如两个程序员不约而同地建立了Employee类。只要将这些类放置在不同的包中,就不会产生冲突。为了保证包名的唯一性,Sun公司建议将公司的因特网域名以逆序的形式作为包名,并且对于不同的项目使用不同的子包。如:horstmann.com。逆序的形式为com.horstmann。这个包还可以进一步被划分成为子包。com.horstmann.corejava。
从编译器的角度看,嵌套的包之间没有任何关系。
类的导入
一个类可以使用所属包中的所有类,以及其他包中的公有类。
可以使用import语句导入一个特定的类或者整个包。import语句应该位于源文件的顶部。
import java.util.*;
如果同时导入java.util和java.sql包都有的日期类。此时我们就需要使用哪个包中的类,就必须将类的名称全部导入。
import java.util.Date
静态导入
import语句不仅可以导入类,还增加了导入静态方法和静态域的功能。
在源文件的顶部,添加一条指令:
import static java.lang.System.*
就可以使用System类的静态方法和静态域,而且不必加类名前缀。
exit(0); // i.e.,System.exit
但是大多数程序员不会选择这样写。这种编写形式不利于代码的清晰度。
将类放入包中
要想将一个类放入包中,就必须将包的名字放在源文件的开头,包中定义类的代码之前。
包作用域
之前我们已经使用和接触过public和private。标记为public的部分可以被任意的类使用;标记为private的部分只能被定义它们的类使用。如果没有指定public或private,这个部分可以被同一个包中的所有方法访问。
文档注释
JDK包含一个很有用的工具,叫做javadoc,它可以由源文件生成一个HTML文档。如果在源代码中添加以专用的定界符/**开始的注释,那么可以很容易地生成一个看上去具有专业水准的文档。
注释的插入
javadoc使用程序从下面几个特性抽取信息:
- 包
- 公有类与接口
- 公有的和受保护的构造器及方法
- 公有的和受保护的域
注释应该放置在所描述特性的前面
注释以/**开始。以*/结尾
每个/**...*/文档注释在标记之后紧跟着*格式文本。标记由@开始,如@author或@param.
类注释
类注释必须放在import语句之后,类定义之前。
方法注释
每一个方法注释必须放在所描述的方法之前。除了通用标记之外,还可以使用下面的标记:
@param变量描述
这个标记将对当前方法的"param"(参数)部分添加一个条目。这个描述可以占据多行,并可以使用HTML标记。一个方法的所有@param标记必须放在一起。
@return描述
这个标记将对当前方法添加"return"(返回)部分。这个描述可以跨越多行,并可以使用HTML标记。
@throws类描述
这个标记将添加一个注释,用于表示这个方法有可能抛出异常。
域注释
只需要对公有域建立文档。如
/**
*The "Hearts" card suit
*/
public static final int HEARTS = 1;
通用注释
下面的标记可以用在类文档的注释中:
@author 姓名
这个标记将产生一个"author"条目。可以使用多个@author标记,每个@author标记对应一个作者。
@version文本
这个标记将产生一个"version"条目。这里的文本可以是对当前版本的任何描述
@since文本
这个标记将产生一个"since"条目。这里的text可以是对引入特性的版本描述。
@deprecated文本
这个标记将对类、方法或变量添加一个不再使用的注释。
@see引用
这个标记将在"see also"部分增加一个超级链接。它可以用于类中,也可以用于方法中。
类设计技巧
1.一定要保证数据私有
这是最重要的;绝对不要破坏封装性。当数据保持私有时,它们的表示形式的变化不会对类的使用者产生影响,即使出现bug也易于检测。
2.一定要对数据进行初始化
Java不对局部变量进行初始化,但是会对对象的实例域进行初始化。最好不要依赖于系统的默认值,而是应该显式地初始化所有的数据,具体的初始化可以是提供默认值,也可以在所有构造器中设置默认值。
3.不要在类中使用过多的基本类型
就是说,用其他的类代替多个相关的基本类型的使用。这样会使类更加容易理解易于修改。
4.不是所有的域都需要独立的域访问器和域更改器
5.将职责过多的类进行分解
这样说似乎有点含糊不清,究竟多少算是“过多”,不同人的看法是不一样的,如果明显地将一个复杂的类分解成两个更为简单的类,就应该将其分解。
6.类名和方法名要能够体现它们的职责
与变量应该有一个能够反映其含义的名字一样,类也应该如此。
命名类名的良好习惯是采用一个名词、前面有形容词修饰的名词或动名词。
7.优先使用不可变的类
更改对象的问题在于,如果多个线程试图同时更新一个对象,就会发生并发更改。其结果是不可预料的。如果类时不可变的,就可以安全地在多个线程间共享其对象。
这个标记将产生一个"version"条目。这里的文本可以是对当前版本的任何描述
@since文本
这个标记将产生一个"since"条目。这里的text可以是对引入特性的版本描述。
@deprecated文本
这个标记将对类、方法或变量添加一个不再使用的注释。
@see引用
这个标记将在"see also"部分增加一个超级链接。它可以用于类中,也可以用于方法中。
## 类设计技巧
1.一定要保证数据私有
这是最重要的;绝对不要破坏封装性。当数据保持私有时,它们的表示形式的变化不会对类的使用者产生影响,即使出现bug也易于检测。
2.一定要对数据进行初始化
Java不对局部变量进行初始化,但是会对对象的实例域进行初始化。最好不要依赖于系统的默认值,而是应该显式地初始化所有的数据,具体的初始化可以是提供默认值,也可以在所有构造器中设置默认值。
3.不要在类中使用过多的基本类型
就是说,用其他的类代替多个相关的基本类型的使用。这样会使类更加容易理解易于修改。
4.不是所有的域都需要独立的域访问器和域更改器
5.将职责过多的类进行分解
这样说似乎有点含糊不清,究竟多少算是“过多”,不同人的看法是不一样的,如果明显地将一个复杂的类分解成两个更为简单的类,就应该将其分解。
6.类名和方法名要能够体现它们的职责
与变量应该有一个能够反映其含义的名字一样,类也应该如此。
命名类名的良好习惯是采用一个名词、前面有形容词修饰的名词或动名词。
7.优先使用不可变的类
更改对象的问题在于,如果多个线程试图同时更新一个对象,就会发生并发更改。其结果是不可预料的。如果类时不可变的,就可以安全地在多个线程间共享其对象。