《Java核心技术 卷Ⅰ》 第4章 对象与类
- 面向对象程序设计
- 创建标准Java类库中的类对象
- 如何编写自己的类
OOP
传统的结构化程序设计:首先确定如何操作数据,再决定如何组织数据。
面向对象程序设计:将数据放在第一位,再考虑操作数据的算法。
类
类(class)是构造对象的模板或蓝图,
由类构造(construct)对象的过程称为创建类的实例(instance)。
封装(encapsulation),也称数据隐藏,封装将数据和行为组合在一个包中,并对对象使用者隐藏数据实现方式,对象中的数据域称为实例域(instance field),操作数据的过程称为方法(method)。
对于每个特定的类实例(对象)都有一组特定的实例域值,这些值的集合就是这个对象的当前状态(state),只要向对象发送一个消息,它的状态就有可能发生改变。
实现封装的关键:绝对不能让类中的方法直接地访问其他类的实例域。
OOP的另一个原则:可以通过扩展一个类来建立另外一个新的类。在Java中,所有类都源于一个超类——Object
。
在扩展一个已有类时,新类具有这个类的全部属性和方法,在新类中,只需要提供适用于这个新类的新方法和数据域就可以了,这个过程称为继承(inheritance)。
对象
对象的三个主要特性:
- 行为:可以让对象做什么?
- 状态:被使用时,如何响应对应的行为?
- 标示:如何辨别具有相同行为与状态的不同对象?
识别类
识别类的简单规则:
- 使用的名词:类
- 使用的动词:类的方法
类之间的关系
常见的关系有:
- 依赖(use-a):如果一个类的方法操纵另一个类的对象,我们说一个类依赖另一个类(即没有这个类就无法完成指定的方法),比如消费者想要支付,TA需要操作手机去完成具体的支付方式,即
Customer "use-a" MobilePhone
。 - 聚合(has-a):聚合关系意味着类A的对象包含B的对象,比如程序员要喝咖啡,TA有一个杯咖啡,即
Programmer "has-a" Coffee
。 - 继承(is-a):类A扩展类B,要做学生,先要做人,即
Student "is-a" Person.
。
使用预定义类
对象与对象变量
想要使用对象,就必须首先构造对象,并指定其初始状态, 然后对对象应用方法。
在Java中,使用构造器(constructor)构造新实例,它是一种特殊的方法,用于构造并初始化对象。
Date birthday = new Date();String s = birthday.toString();复制代码
对象变量并没有实际包含一个对象,而仅仅是一种引用,在Java中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用,new操作符的返回值也是一个引用。
当一个对象变量只是声明但是没有具体的引用对象时,调用其方法会在编译时产生变量未初始化错误。
// Error test P1Date deadline;deadline.toString();复制代码
当一个对象变量只是声明但是没有具体的引用对象时,调用其方法会产生运行时错误(通常为java.lang.NullPointerException
)。
// Error test P2Date deadline = null;deadline.toString();复制代码
上面两个例子说明,Java中的局部变量并不会自动地初始化为null
,而必须通过调用new
或将他们设置为null
进行初始化。
LocalDate类
Date类的实例有一个状态,即特定的时间点。
时间是距离纪元(epoch)的毫秒数(可正可负),纪元是UTC(Coordinated Universal Time)时间1970年1月1日 00:00:00。
类库设计者把保存时间与给时间点命名分开,所以标准Java类库分别包含了两个类:
- 表示时间点的
Date
类 - 日历表示法的
LocalDate
类
不要使用构造器来构造LocalDate类的对象,应用静态工厂方法(factory method)代表调用构造器。
// 当前时间的对象LocalDate.now();// 指定时间的对象LocalDate.of(1996, 6, 30);// 保存对象LocalDate birthday = LocalDate.of(1996, 6, 30);复制代码
有了对象就可以使用方法获得年、月、日。
int year = birthday.getYear(); // 1996int month = birthday.getMonthValue(); // 6int day = birthday.getDayOfMonth(); // 30int dayOfWeek = birthday.getDayOfWeek().getValue(); // 7复制代码
需要计算某个日期时:
LocalDate someday = birthday.plusDays(708);int year = someday.getYear(); // 1998int month = someday.getMonthValue(); // 6int day = someday.getDayOfMonth(); // 8// 当然还有minusDays方法复制代码
更改器方法与访问器方法
- 更改器方法(mutator method):调用后,对象的状态会改变。
- 访问器方法(accessor method):只访问对象而不修改对象状态的方法。
用户自定义类
简单类定义
Java简单类的形式:
class ClassName{ filed1 field2 ... constructor1 constructor2 ... method1 method2 ...}复制代码
一个使用简单类的程序例子:
// File EmployeeTest.javapublic class EmployeeTest{ public static void main(String[] args) { Employee[] staff = new Employee[3]; staff[0] = new Employee("Bob Hacker", 75000, 1996, 6, 30); ... }}class Employee{ // instance fields private String name; private double salary; private LocalDate hireDay; // constructor public Employee(String n, double s, int year, int month, int day) { name = n; salary = s; hireDay = LocalDate.of(year, month, day); } // methods public String getName() { return name; } ...}复制代码
注意,这个程序中包含两个类:
Employee
类- 带有
public
访问修饰符的EmployeeTest
类
源文件名是EmployeeTest.java
,这是因为文件名必须与public类的名字相匹配,在一个源文件中,只能有一个公有类,但可以有任意个非公有类。
当编译这段源码时,编译器会在目录下生成两个类文件:EmployeeTest.class
和 Employee.class
。
将程序中包含main方法的类名提供给字节码解释器,启动程序:
java EmployeeTest复制代码
字节码解释器开始运行其中的main方法的代码。
构造器
刚才所使用类中的构造器:
class 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
操作符的执行被调用,并且不能对已经存在的对象调用构造器来重新设置实例域。
Employee bob = new Employee("Bob", 47000, ...);bob.Employee("Bob", 47500, ...);// Compiler Error: Can't find symbol of method Person(String, int, ...)复制代码
构造器基础的简单总结:
- 构造器与类同名
- 构造器可以有任意个参数,甚至没有参数
- 构造器没有返回值
- 构造器伴随new操作一起调用
- 每个类可以有一个以上的构造器
- 多个构造器时,根据调用new的参数类型来进行选择
隐式参数与显式参数
方法用于操作对象以及存取他们的示例域。
public void raiseSalary(double byPercent){ double raise = salary * byPercent / 100; salary += raise;}复制代码
当对象调用方法时
bob.raiseSalary(5);复制代码
raiseSalary
方法有两个参数。
- 隐式(implicit)参数,这里指的是现在方法名前的
Employee
类的对象。 - 显示(explicit)参数,这里指的是位于方法名后括号中的数据。
在每一个方法中,关键字this
表示隐式参数,上面的方法也可以写为:
public void raiseSalary(double byPercent){ // double raise = salary * byPercent / 100; double raise = this.salary * byPercent / 100; // salary += raise; this.salary += raise;}复制代码
有些人偏爱这样写(包括我),虽然费事点,但是可以将实例域与局部变量明显的区分开来。
封装的优点
封装对于直接简单的公有数据而言,提供了更多对公有数据保护的途径。
对于访问器来说,它们只返回实例域的值,并且在处理可引用的返回对象时,要通过clone
来创建新的对象来作为返回值的载体,如果将可引用对象直接返回,并且该对象恰有一个可修改值的方法时,任何外部对这个返回值的处理都将会直接影响到这个对象内部的对象(Java引用在这部分的情况类似与C中的指针)。
对于更改器来说,它们在被调用时可以主动的执行数据合法性的检查,从而避免破坏数据的合法性。
基于类的访问权限
方法可以访问所调用对象的私有数据。
但是Java其实还要更进一步:一个方法可以访问所属类的所有对象的私有数据。
// classclass Employee{ public boolean equals(Employee other) { return name.equals(other.name); }}...// mainif(harry.equals(boss)) ...复制代码
这个方法访问harry
的私有域,同时它还访问了boss
的私有域,这是合法的,boss
也是Employee
类对象,Employee
类的方法可以访问Employee
类的任何一个对象的私有域。
私有方法
有时候为了完成任务需要写一些辅助方法,这些辅助方法不应该称为公有接口的一部分,这是由于它们与当前的实现机制非常紧密,最好将这样的方法设计为private
。
简单来说,为了更好地封装性,不在公有接口范围内的方法都应该设计为private
。
final实例域
类中可以定义实例域为final
,但是必须确保在每一个构造器执行之后,这个域的值会被设置,并在后面的操作中不能再对其进行修改。
但是这里的不能修改大都应用于基本(primitive)类型和不可变(immutable)类型的域(如果类中每个方法都不会改变对象状态,则类就是不可变的类,例如String
类)。
对于可变的类(比如之前的StringBuilder
类),使用final
修饰符只是表示该变量的对象引用不会再指示其他的对象,但其对象本身是可以更改的(比如StringBuilder
类的对象执行append
方法)。
静态域与静态方法
静态域
如果将一个域定义为
static
,每个类中只有这样的一个域。
通俗来讲,如果一个域被定义为static
,那么这个域属于这个类,而不属于任何这个类的对象,这些对象同时共享这个域(有点像类的一个全局变量域)。
一个简单的静态域用法:
// class Employee...// 可以在类定义中直接对静态域赋予一个初值。private static int nextId = 1;private int id;...public void setId(){ id = nextId; nextId++;}复制代码
静态常量
静态常量相比于静态变量使用的要多一些。
例如Math
类中的PI
:
public class Math{ ... publuc static final double PI = 3.14159265358979323846; ...}复制代码
程序通过Math.PI
的形式获得这个常量。
静态方法
静态方法是一种不能向对象实施操作的方法。
静态方法在调用时,不使用任何实例对象,换句话说就是没有隐式参数。
需要使用静态方法的情况:
- 一个方法不需要访问对象状态,参数都是显示参数提供
- 一个方法只需要访问类的静态域
工厂方法
比如之前LocalDate
类使用的静态工厂方法(factory method)来构造对象。
不利用构造器完成这个操作的两个原因:
- 无法命名构造器。构造器的名字必须与类名相同。
- 当使用构造器时,无法改变所构造的对象类型。
main方法
main
方法不对任何对象进行操作,因为事实上在启动程序时还没有任何一个对象,静态的main
方法将随着执行创建程序所需要的对象。
同时,每一个类都可以有一个main
方法,常用于进行类的单元测试。
方法参数
在程序设计语言中有关**参数传递给方法(函数)**的一些专业术语:
- 按值调用(call by value):表示方法接收的是调用者提供的值。
- 按引用调用(call by reference):标识方法接收的是调用者提供的变量地址。
Java程序设计语言总是采用按值调用,即方法得到的只是参数值的一个拷贝,不能修改传递给它的任何参数变量的内容。
但是当对象引用作为参数时,情况就不同了,方法获得的是对象引用的拷贝,对象引用和其他拷贝同时引用同一个对象。
但是这并不是引用调用。
public static void swap(Obejct a, Obejct b){ Object tmp = a; a = b; b = tmp;}复制代码
如果Java在对象参数时采用的是按引用调用,上述方法就能实现交换数据的效果。
但是这里的swap
方法并没有改变存储在调用参数中的对象引用,swap
方法的参数a
和b
被初始化为两个对象引用的拷贝,这个方法交换的是这两个拷贝的引用。
Java中方法参数总结:
- 方法不能修改基本数据类型的参数
- 方法可以改变对象参数的状态
- 方法不能让对象参数引用一个新的对象
对象构造
重载
有些类有多个多个构造器。
这种特征叫做重载(overloading),如果多个方法有相同的名字、不同的参数,便产生了重载。
编译器通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法,如果编译器找不到匹配的参数,就会产生编译时错误。
Java允许重载任何方法,并不只是构造器,因此要完整地描述一个方法,需要指出方法名以及参数类型,这叫方法的签名(signature)。
// 方法重载的签名举例indexOf(int)indexOf(int int)indexOf(String)复制代码
可以看出,返回类型并不是方法签名的一部分,即不能有两个名字相同、参数类型相同但是却返回不同类型值的方法。
默认域初始化
如果域没有在构造器中被赋予初值,则会被自动地赋予默认值:
- 数值: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;}复制代码
这样的缺陷是失去了代码可读性,也可以采用加前缀的方法:
public Employee(String aName, double aSalary){ name = aName; salary = aSalary;}复制代码
当然还有一种技巧,参数变量用同样的名字将实例域屏蔽起来:
public Employee(String name, double salary){ this.name = name; this.salary = salary;}复制代码
调用另一个构造器
如果构造器的第一个语句形如this(...)
,这个构造器将调用同一个类的另一个构造器。
public Employee(double s){ // calls Employee(String, double) this("Employee #" + nextId, s); nextId++;}复制代码
初始化块
除了前面提到的两种初始化数据域的方法:
- 在构造器中设置值
- 在声明中赋值
还有第三种,称为初始化块(initialization block),在类定义中可以包含多个代码块,只要构造类的对象,这些块就会被执行。
class Employee{ private static int nextId = 0; private int id; { id = nextId; nextId++; } ...}复制代码
无论哪个构造器构造对象,初始化块都会执行,首先运行初始化块,然后才运行构造器的主体部分。
调用构造器的具体处理步骤:
- 所有数据域被初始化为默认值
- 按照出现次序执行初始化语句和初始化块
- 如果构造器调用了第二个构造器,则执行第二个构造器主体
- 执行这个构造器主体
初始化块比较常用于代码比较复杂的静态域初始化:
static{ Random generator = new Random(); nextId = generator.nextInt(10000);}复制代码
包
Java允许使用包(package)将类组织起来。
借助于包可以方便地组织自己的代码,并将自己的代码与别人提供的代码库分开管理。
标准的Java类库分布在多个包中,包括java.lang
、java.util
和java.net
等。
使用包的主要原因是确保类名的唯一性。
假如两个程序员都建立了Employee
类,只要将类放置在不同的包中,就不会产生冲突。
从编译器角度来说,嵌套的包之间没有任何关系。例如java.util
包与java.util.jar
包毫无关系。
类的导入
一个类可以使用所属包的所有类,以及其他包中的公有类(public class)。
可以使用两种方式访问另一个包中的公有类。
- 在类名前添加完整地包名
- 使用
import
语句,可以导入一个特定的类或者整个包。
// 添加包名java.time.LocalDate today = java.time.LocalDate.now();// importimport java.util.*;// or import java.time.LocalDate 引入特定类LocalDate today = LocalDate.now();复制代码
大多数情况下导入包即可,但是在发生命名冲突的时候,就要注意了,
import java.util.*;import java.sql.*;Date today; // Error复制代码
因为这两个包都有Date
类,编译器无法确定是哪一个包的Date
类,所以这个时候可以增加一个指定特定类的import
语句。
如果两个类都要使用时,就在每个类名前加上完整地包名。
静态导入
import
语句还增加了导入静态方法和静态域的功能。
import static java.lang.System.*;// 然后可以使用System类的静态方法和静态域而不必加前缀out.println("Hohoho!");// System.out.println()复制代码
另外,还可以导入特定的方法或域:
import stattic java.lang.System.out;out.println("Hohoho!");复制代码
将类放入包中
想将一个类放入包中,就必须将包的名字放在源文件的开头。
package com.horstmann.corejava;public clas Employee{ ...}复制代码
如果没有在源文件中放置package
语句,源文件中的类被放置在默认包(default package)中,默认包是一个没有名字的包。
一般需要把包中的文件放到与完整的包名匹配的子目录中。
例如package com.horstmann.corejava
包中的所有源文件,应该被放置在子目录com/horstmann/corejava
中。
包作用域
- 标记为
public
的类、方法、变量可以被任意的类使用 - 标记为
private
的类、方法、变量只能被定义他们的类使用 - 如果没有指定,则他们可以被同一个包中的所有方法访问
文档注释
JDK包含一个很用有的工具——javadoc
,它可以由源文件生成一个HTML文档。
在源代码中添加以专用的定界符/**
开始的注释,则可以容易地生成形式上专业的文档,相比于把文档和代码单独存放,修改代码的同时修改文档注释再重新运行javadoc
,就不会出现不一致的问题。
注释的插入
javadoc
从下面几个特性中抽取信息:
- 包
- 公有类与接口
- 公有的和受保护的构造器及方法
- 公有的和受保护的域
应该为这几部分编写注释,注释应该放在所描述特性的前面。
注释以/**
开始,以*/
结束。
每个/**...*/
文档注释中使用自由格式文本(free-form text),标记由@
开始。
类注释
类注释必须放在import
语句之后,类定义之前。
/** * Just some comment words here * another comment line * what is this class for? */public class Card{ ...}复制代码
方法注释
方法注释放在描述的方法前,除了通用标记,还可以使用下面的标记:
@param
变量 描述:用于标记当前方法的参数部分的一个条目@return
描述:用于标记方法的返回部分@throws
类 描述:表示方法有可能抛出异常
/** * Buy one coffee. * @param money the cost of coffee * @param coffeeTpye which coffee * @return coffee one hot coffee * @throws NoMoreCoffee */public buyCoffee(double money, CoffeeType coffeeTpye){ ...}复制代码
域注释
只需要对公有域(通常是静态常量)建议文档。
/** * The ratio of a circle's circumference to its diameter */public static final double PI = 3.1415926...;复制代码
通用注释
可用在类文档的注释的标记:
-
@author 姓名:可以使用多个
-
@version 文本:版本条目
-
@since 文本:始于...条目,这里的文本可以是对版本的描述
-
@deprecated 文本:标记对类、方法或变量不再使用,例如:
@deprecated Use
setVisible(true)
instead复制代码 -
@see 引用:增加一个超链接,可以用于类、方法中,引用有以下情况:
- package.class#feature label
// 建立一个连接到com.horstmann.corejava.Employee类的raiseSalary(double)方法的超链接@see com.horstmann.corejava.Employee#raiseSalary(double)// 可以省略包名,甚至把包名和类名省去@see Employee#raiseSalary(double)// 此时链接定位于当前包 @see raiseSalary(double)// 此时连接定位于当前类复制代码
<a href="...">label</a>
@see The Core ]ava home page// 此处可以使用label标签属性来添加用户看到的锚名称复制代码
- "text"
@see "Core Java 2 volume 2n"复制代码
如果愿意的话,还可以在注释的任何位置放置指向其他类和方法的超链接:
{ @link package.class#feature label }// 这里的描述规则与@see标记规则一样复制代码
包与概述注释
如果想要包的注释,就要在每一个包的目录中添加一个单独的文件。
- 提供一个以
package.html
命名的文件,在<body>...</body>
之间的所有文本会被抽取。 - 提供一个以
package-info.java
命名的文件,这个文件包含一个初始的以/**
和*/
界定的Javadoc
注释,跟随在一个包语句之后。
还可以为所有的源文件提供一个概述性的注释,这个注释将被放置在一个名为overview.html
的文件中,这个文件位于包含所有源文件的父目录中,标记<body>...</body>
之间的所有文本会被抽取。当用户选择overview时,就会查看到这些注释内容。
类设计技巧
应用这些技巧可以设计出更具有OOP专业水准的类。
一定要保证数据私有
绝对不要破坏封装性,这是最重要的。
数据的表示形式很可能会改变,但是它们的使用方式却不会经常发生变化,当数据保持私有时,它们的表示形式的变化不会对类的使用者产生影响,即使出现bug也易于检测。
一定要对数据初始化
Java不对局部变量进行初始化,但是会对对象的实例域进行初始化。
但是也最好不要依赖系统的默认值,应该用构造器或者是提供默认值的方式来显式地初始化所有的数据。
不要在类中使用过多的基本类型
用其他的类代替多个相关的基本类型的使用。
这样会使类更加易于理解和修改。
比如用一个Address
的类来代替下面的实例域:
private String street;private String city;private String state;private int zip;复制代码
这样更容易理解和处理表示地址的域,而使用这些域的类并不用去关心这些域是怎么具体变化的。
不是所有的域都需要独立的域访问和更改器
- 有些域在对象被构造出来后,在类的设计上,可能不再允许被修改
- 在对象中有时候包含一些不希望别人获得或设置的实例域
将职责过多的类进行分解
虽然这里的“过多”对于个人来说是一个含糊的概念,但是如果明显地可以将一个复杂的类分解成两个更为简单的类,则应该进行分解。
类名和方法名要能体现职责
命名类名的良好习惯是采用:
- 名词
Order
- 前面有形容词修饰的名词
RushOrder
- 动名词修饰的名词
BillingAddress
对于方法来说:
- 访问器用小写的
get
开头 - 更改起用小写的
set
开头
优先使用不可变的类
更改对象的问题在于:如果多个线程视图同时更新一个对象,就会发生并发更改,其结果是不可预料的。如果类时不可变的,就可以安全地在多个线程间共享其对象。
Java对象与类总结
- OOP的简要概念
- 类与对象
- 类之间的关系
- 对象与对象变量
- 更改器与访问器
- 自定义类
- 构造器
- 隐式参数与显式参数
- 封装
- 基于类的访问权限
- 私有方法
- final实例域
- 静态域与静态方法
- 按值调用
- 重载
- 默认域的初始化
- 无参数构造器
- 显式域初始化
- 初始化块
- 包
- 文档注释
- 类设计技巧
个人静态博客:
- 气泡的前端日记: