共计 11684 个字符,预计需要花费 30 分钟才能阅读完成。
《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 P1
Date deadline;
deadline.toString();
当一个对象变量只是声明但是没有具体的引用对象时,调用其方法会产生运行时错误(通常为 java.lang.NullPointerException)。
// Error test P2
Date 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(); // 1996
int month = birthday.getMonthValue(); // 6
int day = birthday.getDayOfMonth(); // 30
int dayOfWeek = birthday.getDayOfWeek().getValue(); // 7
需要计算某个日期时:
LocalDate someday = birthday.plusDays(708);
int year = someday.getYear(); // 1998
int month = someday.getMonthValue(); // 6
int day = someday.getDayOfMonth(); // 8
// 当然还有 minusDays 方法
更改器方法与访问器方法
更改器方法(mutator method):调用后,对象的状态会改变。
访问器方法(accessor method):只访问对象而不修改对象状态的方法。
用户自定义类
简单类定义
Java 简单类的形式:
class ClassName
{
filed1
field2
…
constructor1
constructor2
…
method1
method2
…
}
一个使用简单类的程序例子:
// File EmployeeTest.java
public 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 方法的代码。
<!– ### 多个源文件的使用
当习惯把各类单独放在一个文件中时,比如上面的程序中,创建一个文件 Employee.java 单独存放这个类,但是在编译时有两种方法。
一是使用通配符将所有文件编译:
javac Employee*.java
一种是编译包含 main 方法的类:
javac EmployeeTest.java
后一种方法并没有显式地编译 Employee.java,当 Java 编译器发现 EmployeeTest.java 使用了 Employee 类时,会查找名为 Employee.class 的文件,如果没有找到,就会自动搜索 Employee.java,然后自动的进行编译。
并且当 Employee.java 文件更新后,Java 编译器在编译时会自动地重新编译该文件。–>
构造器
刚才所使用类中的构造器:
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 其实还要更进一步:一个方法可以访问所属类的所有对象的私有数据。
// class
class Employee
{
public boolean equals(Employee other) {
return name.equals(other.name);
}
}
…
// main
if(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();
// import
import 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 <code> setVisible(true) </code> 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 <a href=”m«w.horstmann.com/corejava.htinl”>The Core ]ava home page</a>
// 此处可以使用 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 实例域
静态域与静态方法
按值调用
重载
默认域的初始化
无参数构造器
显式域初始化
初始化块
包
文档注释
类设计技巧
个人静态博客:
气泡的前端日记:https://rheabubbles.github.io