第四章 对象与类
对象构造
包
算法 + 数据结构 = 程序
结构化程序设计通过设计一系列的过程(即算法)来求解问题。
一旦确定了这些过程,
就要开始考虑存储数据的方式。
算法是第一位的,数据
结构是第二位的。
OOP与面向过程相比调换了一个次序,将数据放在第一位,然后再考虑数据的算法。
类是
构造对象的
模板或蓝图。我们可以将类想象成制作
小甜饼的
切割机,将
对象想象成
小甜饼。
Java库中提供了几千个类,可以用于界面设计、日期、日历和网络程序设计。尽管如此,还是需要Java在Java程序中创建一些自己的类,以
便描述
应用程序中所对应的问题域中的对象
。
封装:有时也称为数据隐藏,从形式上看是将数据(实例域)和行为组合在一个包中,并对对象的使用者隐藏了数据(实例域)的实现方式。对象中的数据称为实例域,操作数据的过程称为方法。
对于某个特定的类实例都有一组特定的实例域值。这些值的集合就是这个对象的当前状态。无论何时,只要向对象发送一条消息,它的状态就有可能发生改变。
实现封装的关键在于 绝对不能 让类中的方法直接访问其他类的实例域。封装给对象提供了“黑盒特征”,这是提高重用性和可靠性的关键。
这也就意味着一个类可以全面的改变存储数据的方式,只要仍旧使用同样的方法操作数据,其他对象就不会知道或介意所发生的变化。
对象:要想使用OOP,一定要清楚对象的三个主要特性:
对象的行为(behavior)
--可以对对象施加哪些操作。
对象的状态(state)
--当施加那些方法时,对象如何响应.(对象的域值)
对象标识(identity)
--即如何辨别具有相同行为与状态的不同对象。
同一个类的所有对象实例,由于支持相同的行为而具有家族式的相似性。
对象的行为是用可调用的方法定义的。
每个对象都保存着
描述当前特征的信息。这就是
对象的状态。对象的状态可能会随着时间改变,但这种改变
不会是自发的。对象状态的
改变
必须通过调用方法实现
,否则只能说明封装性遭到了破坏。
对象的状态并不能完全描述一个对象,每个对象都有一个唯一的身份(identity)。例如,在一个订单系统中,任何两个订单存在着不同之处,即使所订购的货物完全相同也是如此。需要注意,作为一个类的实例,每个对象的
标识符永远是不同的,
状态也常常存在差异。
对象的这些关键特性在彼此之间相互影响。
例如,
对象的状态影响它的行为
(如果一个订单“已送货”或“已付款”,就应该拒绝调用具有增删订单中条目的方法。反过来,如果订单是空的,即还没有加入预订的商品,这个订单就不应该进入已送货状态)
传统的过程化程序设计,必须从顶部的main函数开始编写程序。
在面向对象的程序设计时没有所谓的顶部。答案是:从
设计类开始,然后再往每个类中添加方法。
识别类的简单规则是在分析问题的过程中
寻找名词,而
方法对应着动词。
★例如,在订单处理系统中,有这样一些名词:项目(Item)、订单(Order)、送货地址(Shipping Address)、付款(payment)、账户(account)这些名词很可能成为类Item、Order等。
接下来查看动词:物品项目被添加到订单中,订单被发送或取消,订单货款被支付。
对于每一个动词如:添加、发送、取消、支付。都要标识出 主要负责完成相应动作的 对象。例如,当一个新的条目添加到订单中时,
那个订单对象就是
被指定的对象(完成了添加条目这个动作),因为它知道如何存储条目以及如何对条目进行排序。也就是说,add应该是Order类的一个方法,而Item对象是一个参数(add方法的参数)。
类之间的关系
依赖(dependence):use-a关系。是一种最明显最常见的关系。例如,Order类使用Account类是因为Order对象需要访问Account对象查看信用状态。但是Item类不依赖于Account类,这是因为Item对象与客户账户无关。因此,
如果一个类的方法操纵另一个类的对象,我们就说一个类依赖于另一个类。
应该尽可能地将相互依赖的类减至最少。如果类A不知道B的存在,它就不会关心B的任何改变(这意味着B的改变不会引起A的任何bug)。用软件工程学术语来说,就是让类的耦合度最小。
聚合(aggregation):has-a关系。一种具体且易于理解的关系。例如,一个Order对象包含一些Item对象。聚合关系意味着
类A包含类B的对象。
继承(inheritance
):即is-a关系,是一种用于
表示特殊与一般关系的。例如,Rush Order类由Order类继承而来。在具有特殊性的RushOrder类中包含了一些用于优先处理的特殊方法,以及一个计算运费的不同方法;而其他的方法,如添加条目、生成账单都是从Order
类继承来的。
★要想使用对象,必须先构造对象,并指定其初始状态(state对应着对象的域值)
。
构造器的名字应该与类名相同,因此Date类的构造器名为Date。要想构造对象,需要在构造器前面加上new 操作符。如下所示
new Date();这个表达式构造了一个新的对象,这个对象被初始化为当前的日期和时间。
如果需要的话,也可以将这个对象传递给一个方法:System.out.println(new Date());相反也可以将一个方法应用于刚刚创建的对象上。
String s = new Date().toString();
在上面两个例子中构造的对象仅使用了一次
,通常,
希望构造的对象可以多次使用,
因此需要将对象存放在一个变量中:
Date birthday = new Date();
在
对象与对象变量之间存在一个重要的区别。例如语句Date deadline不是一个对象,实际上也没有引用对象。此时,如果将任何Date方法
应用于这个变量上。语句s = deadline.toString();//not yet 将产生编译错误。
★必须首先初始化变量deadline,这里有两个选择
。
1)
可以用新构造的对象初始化这个变量
:deadline = new Date()
2)
也可以让这个变量引用一个已存在的对象
。deadline = birthday。
现在这两个变量引用同一个对象。
一定要认识到,一个对象并没有实际的包含一个对象,而仅仅引用一个对象。
在Java中,任何
对象变量的值都是对存储在另一个地方的一个
对象的
引用。new操作符的返回值也是一个引用。
Date deadline = new Date();有两个部分。表达式new Date()构造了一个Date类型的对象,并且它的值是
对新创建的对象的引用。这个引用存储在变量deadline中。
可以显示的将对象设置为null,表明这个对象目前没有引用任何对象。
如果将一个方法应用与一个值为null的对象上,那么就会产生运行错误。
局部变量
(函数里面定义的变量)不会自动初始化为null,
而必须用new或将它们设置为null进行初始化
。如图
Date类只提供了少量的方法来
比较两个时间点
。例如
before()和
after()方法分别表示一个时间点是否早于或晚于另一个时间点。
if(
today.before(birthday))
System.out.println("Still time to shop for a gift.");
GregorianCalendar类所包含的方法要比Date类多得多。特别是有几个很有用的构造器。表达式new
GregorianCalendar()构造一个新对象,用于表示对象构造时的日期和时间。
另外,还可以通过提供年、月、日构造一个表示某个特定日期午夜的日历对象:new
GregorianCalendar(1999,11,31),有些怪异的是月份是从0计数。因此11表示12月。为了清晰起见,也可以使用常量,如:Calendar.DECEMBER。
new
GregorianCalendar(1999,
Calendar.DECEMBER,31
),还可以设置时间
new
GregorianCalendar(1999,
Calendar.DECEMBER,31,23,59,59
).
将对象存储在变量中
GregorianCalendar deadline = new
GregorianCalendar(. . .);
★GregorianCalendar 类
封装了实例域,这些
实例域保存着设置的日期时间信息。
不查看源代码不可能知道日期在类中的具体表达方式。
当然
这一点并不重要,重要的是要知道类向外界开放的方法。
类的方法被标记为public。意味着
任何类的
任何方法(在别的类的方法内部)都可以调用这些方法。
关键字
private确保
只有
该类自身的方法能够
访问
这些实例域,而
其他类的方法不能够读写这些域。
实例域本身就是对象是很常见的,类通常包含类型属于某个类类型的实例域。
构造器与其他方法比有一个重要的不同。它总是伴随着new操作符的执行被调用,而不能对一个已经存在的对象调用构造器来达到重新
设置实例域的目的。
构造器和类同名,没有返回值,伴随着new操作符一起使用。
必须注意:在所有的方法中不要命名与实例域同名的变量。
隐式参数和显示参数
public void raiseSalary(double byPercent)
{
double raise = salary * byPercent/100;
salary += raise;
}
调用它
number007.raiseSalary(5);
将调用这个方法的salary实例设置新值。该方法实际有两个参数。
是出现在方法名前的Employee类对象。第二个参数位于方法明后面括号
中的数值。这是一个显示参数。
如果需要返回一个可变对象的引用,应该首先对它进行克隆。对象clone是指
存放在另一个位置的对象副本。
class Employee
{
private Date hireDay;
public Date getHireday
{
return hireDay.clone();
}
}
基于类的访问权限
类中的方法 可以访问 所属类的 所有对象的 私有数据
class Employee
{
public boolean equals(Employee other)
{
return name.equals(other.name);
}
}
调用方式是if(harry.equals(boss))这个方法访问harry的私有域,并不奇怪,然而它还访问了boss的私有域,这是合法的,其原因是boss是
Employee类对象,而Employee类的方法可以访问Employee的任何一个对象的私有域。
私有方法
有时,可能希望将一个计算代码划分成若干个独立的辅助方法。通常这些辅助方法不应该成为公有接口的一部分,这是由于它们往往与当前的实现机制非常紧密,最好将这些方法设计为private的。
只要方法是私有的,类的设计者就可以确信:它不会被外部的其他类操作调用。
★final实例域
可以将实例域定义为final
。
构建对象时必须初始化这样的域
。也就是说,必须在一个构造器执行之后,这个域的值被设置,并且在后面的操作中不能对它进行更改。例如可以将Employee类中的name声明为final,因为在构建对象后,这个值不会在被修改,即没有setName方法。
对比:无final关键字
有final关键字时,构造器执行后,未赋值的话编译报错。
在构建对象后,这个值不会在被修改,即没有setName
final修饰符大都应用于
基本类型域
,或不可变类的域(如果类中的每个方法都不会改变其对象,这种类就是不可变类。例如,String类就是一个不可变的类)。
静态域(类域)
:如果将域定义为static,
每一个类只有只有一个这样的域,
它属于类,而
不属于任何对象。
静态变量使用的比较少,但
静态常量却使用的比较多。
public class Math
{
public static final double PI = 3.1415926;
}
在程序中可以采用Math.PI的形式获得这个常量。
前面提到过,由于每个类的对象都可以对公有域进行修改,所以最好不要将域设计为public。
然而公有常量(即final域)却没问题。
★静态方法(静态方法没有this指针(根本原因),无法调用本类的一般方法和实例域)
静态方法是一种不能操作自己所属类的对象的方法
。例如Math类的pow方法就是一个静态方法。表达式Math.pow(x,a)。计算X
a
在运算时,不使用任何Math对象。换句话说,没有隐式参数。
可以认为静态方法是没有this参数的方法。因为静态方法不能操作自己所属类的对象,所以不能在静态方法中访问实例域。但是,静态方法
可以访问自身类中的静态域
。
建议使用类名调用静态方法。
★下面两种情况下使用静态方法:
★下面两种情况下使用静态方法:
1)一个方法不需要去访问对象状态(对象的域),其所需要的参数都是显式参数提供。
2)一个方法只需要访问类的静态域。
工厂方法
静态方法还有一种常见的用途。NubmberFormat类
使用工厂方法产生不同风格的格式对象。
NumberFormat currencyFormatter = NumberFormat.getC
urrencyInstance();
NumberFormat percentFormatter = NumberFormat.getP
ercentInstance();
double x = 0.1;
System.out.println(
currencyFormatter
.format(x)
);
System.out.println(percent
Formatter
.format(x)
);
为什么
NubmberFormat类不利用构造器完成这些操作呢?主要有两个原因
1)无法命名构造器
2)当使用构造器时,无法改变所构造的对象类型。
main方法
在启动程序时还没有任何一个对象。静态main方法将执行并创建程序所需要的对象。
每一个类可以有一个main方法。这是一个常用于对类进行单元测试的技巧。如,可以在Employee类中添加一个main方法。
一个源文件中包含两个类,每个类有一个main方法。其中非public类的main方法用于单元测试。在eclipse中可以右击选择 Run as...
方法参数
按值调用
:
表示方法接收的是调用者提供的值。
按引用调用:表示方法接受的是调用者提供的
变量地址。
一个方法可以 修改 传递
引用所对应的
变量值
,而不能修改传递值调用所对应的变量值。
java程序设计语言总是采用按值调用
。也就是说方法得到的是所有参数值的一个拷贝,特别是,
方法不能修改传递给它的任何参数变量的内容
。
例如
:
double percent = 10;
harry.raiseSalary(percent);
不必理睬这个方法的具体实现,在方法调用后,percent的值还是10.
★
总结一下Java程序设计语言中放法参数的使用情况:
1)
一个方法不能修改一个基本数据类型的参数(数值型和布尔型)
2)
一个方法可以改变一个对象参数的状态
3)
一个方法不能让一个对象参数指向一个新的对象
对象构造
重载
:
如果多个方法有相同的名字、不同的参数、便产生了重载。
编译器必须挑选出具体执行哪个方法,
它通过用各个方法给出的 参数类型 与 特定方法调用所使用的值类型 进行匹配 来挑选出相应的方法。如果匹配不到,或者匹配到多个都会产生编译时错误。
默认域初始化
:
如果在构造器中没有显示地给域赋初始值
,那么就会被自动的赋为默认值:
数值为0
,
布尔值为false
,
对象引用为null
。然而只有缺少程序设计经验的人才会这么做。确实,如果不明确地对域进行初始化,就会影响程序代码的可读性。这是域与局部变量的主要的不同点。必须明确的初始化方法中的局部变量。但是,如果没有初始化类中的域,将会被初始化为默认值。
无参数的构造器:对象由无参构造函数创建时,其状态会设置为适当的默认值。如:
public Employee()
{
name = "";
salary =0;
hireDay = new Date();
}
1)如果编写一个类时没有编写构造器,那么系统就会提供一个无参构造器,这个构造器将所有的实例域设置为默认值。
2)如果类中提供了至少一个构造器,但是没有提供无参的构造器,则在构造对象时如果没有提供参数就会被视为不合法。
public Employee(String n, double s)
对于这个类,调用e = new Employee();将会产生错误。
仅当类没有提供任何构造器的时候,系统才会提供一个默认的构造器。
如果在编写类的时候,给出了一个构造器,哪怕是简单的,要想让这个类的用户能够采用下列方式构造实例。new ClassName();就必须提供一个默认的构造器。
当然如果希望所有域都被赋予默认值,
可以采用下列格式。
public ClassName()
{
}
显式域初始化:由于类的构造器的方法可以重载,所以可以采用多种形式设置类的实例域的初始状态。
确保不管怎样调用构造器,每个实例域都可以被设置为一个有意义的初值。这是一种很好的设计习惯。
可以在类定义中,直接将一个值赋给任何域。例如:
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 aName,double aSalary)
{
name = aName;
salary = aSalary;
}
技巧二:
public Employee(String name,double salary)
{
this.name = name;
this.salary = salary;
}
调用另一个构造器:关键字this引用方法的隐式参数。然而这个关键字还有另外一个含义。
如果 构造器 的第一个语句形如 this(...),这个构造器将调用同一个类的另一个构造器。
public Employee(String name,double salary)
{
this("Employee #" + nextId,s);
nextId ++;
}
采用这种方式使用this关键字非常有用,这样对公共的构造器代码部分只编写一次即可。
初始化块:前面已经讲过两种初始化数据域的方法:在构造器中设置值,在声明中设置值。实际上,Java还有第三种机制,称为
“初始化块”。在一个类的声明中,可以包含多个代码块。
只要构造这些类的对象,这些块就会被执行。
class Employee
{
private static int nextId;
private int id;
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;
}
}
调用构造器的具体处理步骤:
1)所有数据域被初始化为默认值(先找显式域初始化)
2)按照在类声明中出现的次序,依次执行所有域初始化语句和
初始化块
3)如果构造器第一行调用了第二个构造器,则执行第二个构造器主体
4)执行这个构造器的主体。
★
如果对类的静态域进行初始化的代码比较复杂,那么可以使用 静态的初始化块。
将代码放在一个块中,并标记关键字static。下面是一个实例。其功能是将雇员ID的起始值赋予一个小于10000的随机整数。
//static initialization block
static
{
Random generator = new Random();
nextId = generator.nextInt(1000);
}
在类第一次加载的时候,将会进行静态域的初始化(即静态初始化块只执行一次在第一个new 出现的时候)。与实例域一样,除非将它们显式地设置成其他值,否则默认的初始值是0、false、或null所有的静态初始化语句以及静态初始化块都将按照类定义的顺序执行
Java有自动的垃圾回收器,不需要人工回收内存,所以Java不支持析构器。
包
Java允许使用包(package)将类组织起来。并将自己的代码与别人提供的代码库分离。标准库的Java包都处于Java和Javax包层次中。
使用包的主要原因是确保类名的唯一性。
为了保证包名的绝对唯一性,建议将公司的因特网域名以逆序的形式作为包名,且对于不同项目使用的不同的子包。
从编译器的角度来看,嵌套的包之间没有任何关系。例如java.util与java.util.jar包毫无关系。每一个都拥有独立的类集合。
一个类可以使用
所属包中的所有类,以及
其他包的公有类。
有两种方式可以访问其他包的公有类,第一种在每个类名前添加完整包名如java.util.Date today = new java.util.Date();
更简单常用的是使用import语句,它位于源文件顶部,package语句的后面。
技巧:在eclipse中,可以使用Source →Organize Imports,将会自动的扩展指定的导入列表。如
import java.util.*;被扩展为在类中实际用到的
import java.util.ArrayList;
import java.util.Date;
注意:只能使用星号(*)导入一个包,而不能使用import java.*或者import java.*.*导入以java为前缀的所有包。
在发生命名冲突时,就不能不注意包的名字了。例如,java.util和java.sql包都有日期类Date。如果在程序中导入了两个包
import java.util.*;
import java.sql.*;
在程序使用Date类的时候,就会出现一个编译错误:Date today;//java.util.Date or java.sql.Date
可以采用增加一个特定的import语句来解决这个问题import java.util.Date;
如果这两个Date类都要使用,那就老老实实的在每个类前加上完整的包名。
静态导入
import语句不仅可以导入类,还增加了导入静态方法和静态域的功能。
例如在源文件的顶部,添加一条指令import static java.lang.System.*;就可以使用System类的静态方法和静态域,而不必加类名前缀。
out.println("Goodbye,world!");//等价于 System.out
exit(0);//等价于System.exit
要想将一个类放入包中,就必须将包的名字放在源文件的开头,包中定义类的代码之前。如果没有在源文件中配置package语句,这个源文件中的类就被放置在一个默认的包
中(default package)。默认包是一个没有名字的包。
将包中的文件放到与完整的包名匹配的子目录中。
例如
com.hortsman.corejava包中所有的源文件应该被放置到子目录com/horstmann/corejava中编译器将类文件也放在相同的目录结构中。
编译器对文件进行操作。而Java解释器加载类(带有
.
分隔符)。
警告:
编译器在编译源文件的时候不检查目录结构。
例如,假定一个源文件开头有下列语句:package com.mycompany即使这个源文件没有在子目录com/mycompany下,也可以进行编译。如果它不依赖于其他包,就不会出现错误。但是,最终程序将无法运行,这是因为
虚拟机找不到类文件。
包作用域:
标记为public的部分可以被任意的类使用。标记为private的部分只能被定义它们的类使用。如果没有指定public或private,这个部分(类,方法或变量)可以
被同一个包中的所有方法访问。
类路径(class path):类存储在文件系统的子目录中。类的路径必须与包名匹配。
为了使类能够被多个程序共享,需要做到下面几点:
1)把类放到一个目录中,这个目录是包树状结构的基目录。例如home/use/classdir。如果希望将com.horstmann.corejava.Employee添加到其中,这个Employee.class类文件
就必须位于子目录
home/use/classdir/come/
horstmann/corejava中。
2)将jar文件放在一个目录中。例如home/user/archives
3)设置类路径(class path)。类路径是所有包含类文件的路径的集合。
当前目录(.):指 项目 本身所在路径。
获取当前目录的简易方法:System.getProperty("user.dir");
警告:javac编译器总是在当前的目录中查找文件,但java虚拟机仅在 类路径 中有“
.”目录的时候才查看当前目录。如果没有设置类路径,那也不会产生什么问题,默认的类路径包含“
.”目录。然而如果设置了类路径却忘记了包含“."目录,则程序仍然可以通过编译,但不能运行。
★
类路径所列出的目录和归档文件是搜寻类的起始点。
假定虚拟机要搜寻 com.horstmann.corejava.Employee 类文件。
1)它首先要查看存储在
jre/lib和jre/lib/ext目录下的归档文件(jar文件)中存放的系统内文件。显然,在那里找不到相应的类文件,然后再查看类路径。于是查看
--
/home/user/classdir/com/horstmann/corejava/Employee.class
--
com/horstmann/corejava/Employee.class 从当前目录开始
--
com/horstmann/corejava/Employee.class inside home/user/archives/archieve.jar
★编译器定位文件要比虚拟机复杂得多。如果引用了一个类,而没有指出这个类所在的包,
那么编译器将首先查找包含这个类的包,并询查所有的import指令,确定其中是否包含被引用的类。
例如:假定源文件包含指令:
import java.util.*;
import com.horstmann.corejava.*;
并且源代码引用了Employee类。
编译器将试图查找
1 java.lang.Employee(因为lang包是默认导入的)
2 java.util.Employee
3 com.horstmann.corejava.Employee
4 当前包中的Employee。
对这个类路径的所有位置中所列出的每一个类进行逐一查看。如果找到了一个以上的类,就会产生编译错误(因为类必须是唯一的)。
编译器的任务不止这些,它还要查看源文件是否比类文件新。如果是这样的话,那么源文件就会被自动地重新编译。在前面已经知道,
仅可以导入其他包中的公有的类。一个源文件只能包含一个公有类,并且文件名必须与公有类匹配。因此编译器很容易定位公有类所在的源文件。
如果从当前包中导入一个类,
编译器就要搜索
当前包中的所有源文件,以便确定哪个源文件定义了这个类。
类设计技巧
1)一定要保证数据私有
2)一定要对数据初始化
3)
不要在类中使用过多的基本类型
eg:用一个称为Address的新的类变量(包含下述的实例域)替换一个Customer类中以下的实例域
private String street;private String city;private String state;private int zip;
4) 不是所有的域都需要独立的域访问器和域更改器
5)将职责过多的类进行分解
6)类名和方法名要能够体现他们的职责
良好习惯是采用一个名词(Order)、前面有形容词修饰的名词(RushOrder)或动名词(有-ing后缀)修饰名词(BillingAddress)。