第4章 要点速记
第4章 对象与类
这一章主要介绍
- 面向对象程序设计的基本思想
- 如何创建标准Java类库中的类对象
- 如何编写自己的类
4.1 面向对象程序设计概述
- Java是完全面向对象的。
- OOP中,不必关心对象的具体实现,只要能满足用户需求即可。
- 结构化程序设计中,算法+数据结构 = 程序。而在OOP中,将数据放在第一位,然后再考虑操作数据的算法。
- 小规模问题适合使用面向过程的程序设计。但是当问题规模很大,例如需要成千上万种方法时,使用OOP将使思路更清晰。
4.1.1 类
概念:
- 类是构造对象的模板或蓝图。
- 由类构造对象的过程称为创建类的实例。
- 封装又叫数据隐藏,是指将数据和行为组合在一个“包”中,并对对象的使用者隐藏了具体的实现方式。
- 封装的关键在于:决不能让其他类中的方法直接访问(尤其是修改)其他类的实例域(一些不变的特殊常量除外)。程序仅通过对象的方法与之进行交互。1
- 对象中的数据部分称为实例域,操纵数据的过程称之为方法。
- 每个特定的类实例(对象)都有一组特定的实例域值,这些值的集合就是这个对象当前的状态。无论何时,只要向对象发送一个消息,它的状态就有可能发生变化。
OOP的另一个原则会让自定义Java类轻而易举,那就是继承。事实上,Java中所有的类都源自与一个超类:Object类。
在扩展一个已有的类时,这个类的全部属性和方法都将被继承,只需要提供适用于这个新类的新方法和数据域即可。
4.1.2 对象
OOP中,对象最重要的3个特性:
- 对象的行为——可以对对象施加哪些方法?
- 对象的状态——当施加方法时,对象如何响应?
- 对象的标识——如何区分具有相同状态的不同对象?
注意:
- 对象状态的改变不是自发的,必须通过方法的调用而改变。如果不经过方法调用就改变其状态,说明封装性遭到了破坏。
- 对象的状态不能完全描述一个对象,每个对象都有一个唯一的身份(Identity)。
- 对象的状态和行为是相互影响的。
4.1.3 识别类
属性对应实例域,行为对应方法。
4.1.4 类之间的关系
类之间有3种常见的关系:
- 依赖(“use-a”)
- 聚合(“has-a”)
- 继承(“is-a”)
关联关系:
依赖(Dependency)关系是类与类之间的一种弱关联关系。它表示一种“使用”的关系,例如司机使用汽车。常见表现形式是局部变量、方法的形参,或者对所依赖的类的静态方法的调用。
聚合(Aggregation)关系是类与类之间的一种强关联关系。它表示一种整体和部分的关系,例如订单由商品聚合而成。常见表现形式是实例域的变量引用。2
非关联关系:
继承(Inheritance)关系是类与类之间用于表示特殊与一般关系的(特殊继承自一般)。例如大学生类继承于学生类,加急订单继承自订单类。
可以使用UML(Unified Modeling Language,统一建模语言)绘制类图,用来描述类之间的关系。
4.2 使用预定义类
Java中,没有类就无法做任何事情。
不是所有的类都具有面向对象的一切特征。例如工具类:Math类只封装了功能,却没有隐藏数据(因为这样的工具类中,几乎没有需要隐藏的实例域变量)。
4.2.1 对象与对象变量
概念:
- Java中,使用构造器(Constructor)构造新实例。构造器是一种特殊的方法,用于构建并初始化对象。
- 一定要区分对象和对象变量,这是两个完全不同的概念。对象变量是对对象的一个引用,这类似于C/C++中的指针;而对象才是真正的对象。
- 对象变量必须进行显式的初始化后才能使用:赋值为一个对象的引用或null。
- Java中所有对象都存储在堆中,当一个对象包含另一个对象变量时,其实只是包含着指向另一个堆对象的指针(引用数据类型)。
- C++中,稍不小心,就容易创建一个错误的指针,或是造成内存溢出。但是在Java中不会,如果使用了一个空引用,将会抛出一个异常,而非产生一个随机的结果。
- C++中,可以通过拷贝构造函数和复制操作符实现对象的“深拷贝”。在Java中,可以调用Object类的clone()方法获得对象的完整拷贝。
4.2.2 Java类库中的LocalDate类
LocalDate类和Date类主要的区别如下:
- Date是用来保存时间的类,可以看做是时间。Date实例有一个状态:即特定的时间点。它的时间是用距离一个固定时间点的毫秒数表示的。
- LocalDate是用来给时间点命名的类,可以看做是日历。也就是给某个时间点的命名。
使用方法的区别:
- Date实例通常使用构造器进行构造。
- LocalDate实例需要使用静态工厂方法代表调用构造器。无法直接调用其构造器,因为它的构造器是private的。
设计模式中值得借鉴的地方:
- 将时间与日历分开是一种很好的面向对象设计。
- 类库设计值后来意识到:应当单独提供类来处理日历。虽然Date类也有getDay(), getMonth, getYear()等方法,但已经是Deprecated了。
4.2.3 更改器方法和访问器方法
- 更改器方法:可以修改对象当前的状态。
- 访问器方法:只能访问而不修改的方法。
在C++中,带有const后缀的是访问器方法,默认是更改器方法。Java中,访问器与更改器在语法上无明显区别。
4.3 用户自定义类
要创建一个完整的程序,应该将若干类组合在一起,其中只有一个类有main方法。
4.3.1 Employee类
要点:
- 一个.java文件中可以有多个类,但是只能由一个public类,且类名须与源文件名字母和大小写均一致。
- 当编译一个源文件时,如果一个源文件中有不止一个类(包括内部类),将会产生多个.class文件。
4.3.2 多个源文件的使用
当Java编译器编译一个文件时,如果发现使用了其他的类,就会去寻找其他类的.class文件和.java文件。如果没找到.class或者发现.java文件版本较新,则Java编译器会自动的重新编译这个文件。
4.3.3 剖析Employee类
- 通常方法标记为public,是对外提供的接口。
- 通常实例域标记为private,仅类内部方法可以直接访问和修改。除了一些特殊的静态常量外,如果标记为public,将破坏封装性。
- 实例域本身也可以是一个对象的引用,例如name域是String类对象,hireDay是LocalDate类对象,这种情形十分常见,可以认为两个类之间是是关联关系中的聚合(组合)关系。
4.3.4 从构造器开始
- 构造器不能直接调用,而是通过new关键字调用。
- 不能对一个已经存在的对象调用构造器来达到重新设置实例域的目的,否则将会出现编译错误。
- 最好不要在任何方法中命名一个与实例域同名的变量。
要点:
- 构造器与类同名
- 构造器没有返回值
- 构造器可以重载
- 构造器总是伴随着new而被调用
4.3.5 隐式参数与显示参数
- 隐式参数:方法的调用目标或接收者(调用该方法的对象),可以用this表示。
- 显示参数:方法的形参列表中指出的参数。
注意:
在Java中,所有的方法都必须定义在类的内部,但并不表示它们是内联方法。是否设为内联是Java虚拟机的任务。JIT会监视调用那些简洁、经常被调用、没有被重载以及可优化的方法。
4.3.6 封装的优点
- 访问器方法因为只返回实例域值,因此又称为域访问器。
- 对于只读域,如果将其设置为public,将有可能遭到篡改。
- 修改器方法因为只修改实例域值,因此又称为域修改器。
- 对于非只读域,只能调用修改器改变其值。一旦阈值出现错误,只要调试它的修改器即可。倘若将其设置为public,则破坏这个阈值的捣乱者有可能会出现在任何地方。
通常,应提供下面三项内容:
- 一个私有数据域
- 一个公有的域访问器方法
- 一个公有的与更改器方法
这样做,虽然看起来比提供一个简单的公有数据域复杂,但是有着明显的好处:
- 可以随意改变方法的内部实现方式,而不影响其他代码。
- 更改器方法可以对传入的参数执行错误检查。
警告:
注意不要编写返回可变引用对象的访问器方法,例如:
class Employee {
private Date hireDay;
......
public Date getHireDay() {
return hireDay; // Bad
}
......
}
这样做会导致类的封装性遭到破坏!因为返回的对象变量和实例域中的对象变量都指向同一个对象,而该对象可能在外部被篡改!
正确的做法是:
class Employee {
......
public Date getHireDay() {
return (Date)hireDay.clone(); // Ok
}
......
}
4.3.7 基于类的访问权限
与C++一样,某个对象的方法可以访问所属类的所有对象的私有特性,而不仅限于访问隐式参数的私有特性。
4.3.8 私有方法
虽然大多数方法是公有的,但是,当希望将一个计算代码划分成若干独立的辅助方法,但是这些辅助方法又只与当前实现机制有关而不应该被外界调用时,最好将这样的方法设计为private的。
原因如下:
如果未来改用其他方法去实现相应的操作,就不必保留原来的private方法。因为我们确信,它一定不会被外部类调用,所以可以将其删去。但如果是公有的,就不能直接删除,因为外界代码有可能依赖它。
4.3.9 final实例域
可以将实例域定义为final。构建对象时必须初始化这样的域。也就是说必须确保在每个构造器执行之后,这个域的值被设置,并不得再更改。
final修饰符通常应用于基本(primitive)类型域,或不可变(immutable)类的域。如果类中的每个方法都不会改变对象的状态,则称之为不可变的类。
注意:
对于可变的类,使用final修饰符会对读者造成混乱。因为final关键字只是表示对象变量不会重新指向其他对象,但是这个对象的状态依然可以更改。
4.4 静态域与静态方法
4.4.1 静态域(类域)
- 如果将域定义为static,则每个类中只有一个这样的域。
- 即使没有一个类的对象,静态域依然存在,因为它属于类而非任何独立的对象。
4.4.2 静态常量
静态变量使用比较少,而静态常量则使用的比较多。
通常,域不应当是public的,因为每个类都可以对公有域进行修改。但是,公有常量却没问题。3
4.4.3 静态方法
静态方法45是一种不能向对象实施操作的方法(没有隐式参数)。
- 静态方法不能访问实例域,但是可以访问类的静态域。
- 建议使用类名调用静态方法。虽然也可以用对象调用静态方法,但是这样做容易让人产生误解,因为静态方法和调用它的对象之间毫无关系。
通常在下面两种情况(与特定对象无关)时,使用静态方法:
- 方法不需要访问对象状态,所有参数都是显式提供(例如Math.pow())。
- 方法仅仅需要访问静态域,而不访问实例域。
4.4.4 工厂方法
静态方法还有一种常见的用途:使用(静态)工厂方法构造对象。例如,NumberFormat.getCurrencyInstance()
或NumberFormat.getPercentInstance();
那为什么不直接使用构造器构造对象呢?原因有二:
- 构造器无法命名。构造器的名字必定与类名相同,而我们希望对于想生产的不同对象可以调用不同名字的方法。那我可以将要生产的对象的名字以参数的形式传入构造器呀?(这样做虽然相比直接命名方法要麻烦一点点)
- 当使用构造器时,无法改变所构造的对象类型。这是最主要的一点。而工厂方法可以返回一个其他类型例如子类的对象。
3.4.5 main方法
静态方法不需要使用对象调用,而main方法也恰好不对任何对象进行操作。事实上,在程序启动时也没有任何对象,此时静态的main方法将执行,并创建程序所需要的对象。
提示:
每个类都可以有一个main方法,这将方便我们进行单元测试。
4.5 方法参数
有两种主流的将参数传递给方法的专业术语:按值调用(call by value)和按引用调用(call by reference)。曾经Algol语言有过按名调用(call by name),不过已经成为历史。
Java总是按值调用。方法参数共有两种类型:基本数据类型(数字、boolean)和对象引用。
总有人误解认为:在Java中,对象是按引用调用,实则不然。这里的引用并非类似C++中的引用"&"6,而是在方法调用时,拷贝了“对象引用”这种Java数据类型。虽然对对象状态的更改在方法退出后是有效的(因为“对象引用”所引用的对象还是原来的对象),但是如果将“对象引用”的这个值本身进行修改(例如引用另外一个对象),回到调用处之后是起不到任何修改效果的。所以说,实际上,对象引用是按值传递的。
总结一下Java方法参数的使用情况:
- 一个方法不能修改基本数据类型的参数。
- 一个方法可以改变一个对象参数的状态。
- 一个方法不能让对象参数引用一个新的对象。
4.6 对象构造
Java提供了多种编写构造器的机制。
4.6.1 重载
- 重载的定义:方法名称相同,参数列表的长度或类型不同(方法签名不同)。编译器会进行重载解析,如果没有与之匹配的方法,就会编译失败。
- 除了构造器可以重载,Java还允许重载任何方法。
4.6.2 默认域初始化
如果在构造器和静态代码块中显式地给域赋初值,就会被自动赋予默认值:数值为0、boolean为false、对象引用为null。只有缺少程序设计经验的人才这样做,因为这样会影响代码的可读性。
这是域与局部变量的主要不同点。局部变量必须显式的初始化,否则将无法通过编译。
4.6.3 无参数的构造器
在无参构造方法中,应该将对象的状态设为适当的默认值。如果没有提供任何构造方法,编译器会自动产生一个无参构造方法;如果提供了有参构造器,却没提供无参构造器,系统将不会产生无参构造器,如果调用无参构造器将编译错误。
4.6.4 显式域初始化
除了在构造器中进行初始化,如果多个构造器都希望把相同的值赋予某个特定实例域时,可以使用静态代码块。
初始值不一定是常量,可以调用方法对域进行初始化。7
4.6.5 参数名
常用技巧:this.name = name;
4.6.6 调用另一个构造器
在笔者阅读源码的过程中,无数次的发现,参数少的构造器往往最终规约调用到参数多的构造器,并在调用多参数的构造器时赋予多出来的参数的初值。
**这样做有什么好处呢?**这样做的话,公共的代码只编写一次即可,提高了代码的重用率。8
注意:Java语法规定调用另一个构造器必须出现在当前构造器的第一个语句处。
4.6.7 初始化块
实际上除了在构造器和声明中赋予初值,Java还有第三种机制:初始化块。
一个类可以有多个初始化块,当构造类的时候,初始化块就会被执行。这种机制不是必须的,也不常见。9
调用构造器(初始化域)的具体处理步骤:
- 所有数据域被初始化为默认值(0, false, null)
- 按照在类声明中出现的次序依次执行所有与初始化语句和初始化块。
- 如果构造器第一行调用了第二个构造器,那么转向执行第二个构造器主体后再返回。
- 执行当前构造器主体,然后返回。
注意:
- 如果数据域的初始化结果依赖于构造器的调用顺序,这样会显得很奇怪,易错,且晦涩难懂。
- 在JDK6之前,可以编写没有main方法的HelloWorld,就是通过静态代码块调用
System.out.println(“Hello, World!”)
即可。但是从JavaSE7之后,Java会首先检查有没有一个main方法。
4.6.8 对象析构与finalize方法
在C++中,有显式的析构器方法。其中放置一些当对象不再使用时需要执行的清理代码,例如回收分配给对象的存储空间。
在Java中,有GC,所以不需要人工回收,故没有析构器。
但是如果Java对象使用了内存之外的资源例如文件资源或其他系统资源,就需要及时回收。可以重写Object的finalize方法,这个方法将在GC回收对象之前被调用。但是,通常不使用它回收任何短缺的资源,因为很难知道这个方法何时才被调用。通常,我们会人工编写一个close()方法并调用之,来完成相应的清理操作。
4.7 包
Java中,使用包(package)将类组织起来。如同硬盘的目录嵌套一样,也可以使用嵌套层次组织包。所有标准的Java包都位于java和javax包层次中。
使用包的原因是确保类名的唯一性。假若两个程序员都建立了Employee类,那么只要放在不同的包中,就不会冲突。为了保证包名的绝对唯一性,Sun公司建议使用因特网域名的逆序形式作为包名。
从编译器的角度看,嵌套的包之间没有任何关系。例如java.util包与java.util.jar包之间毫无关系,每个包都拥有独立的类集合。
4.7.1 类的导入
要点:
- 类的访问权限有两种:public和默认权限。每个源文件中最多有一个public类。
- 一个类可以使用所属包中的所有类,而无需导包。
- 一个类可以使用其他包中的公有类,但需要导包(或在代码中类名之前添加完整的包名)。
- 导包要使用import语句,可以导入整个包(使用通配符 * ),也可以导入特定的类。import语句通常紧跟在package语句后面。
- 如果导入的两个包中含有名称相同的public类,有两种办法:
- 如果只使用其中一个类
import java.util.*;
import java.sql.*;
import java.util.Date;
- 如果两个类都要用到
java.util.Date deadline = new java.util.Date();
java.sql.Date today = new java.sql.Date();
在C++中,与包类似的机制是命名空间(namespace)。在Java中,package与import语句类似C++中的namespace和using指令。
注意:
- 使用星号只能导入一个包,而不能导入这个包中所包含的其他子包。
- 在包中定位类是编译器的工作。class文件中字节码使用的一定是完整的包名来引用其他类。
4.7.2 静态导入(常常被忽略)
虽然很少见到,但事实上import除了可以导入类,也可以导入静态方法和静态域,例如:
// 导入Math类的静态方法,可以直接使用sqrt()
import static java.lang.Math.*;
// 导入System的静态域out,可以直接使用out.println()
import static java.lang.System.out;
4.7.3 将类放入包中
要想将一个类放入包中,就必须将包的名字放在源文件开头,同时将文件放到与完整的包名匹配的子目录中,例如:
package com.horstman.corejava
import ......;
public class Employee {
......
}
如果没有package语句,则这个源文件将被放在默认包(default package)中。
编译器会将class文件也放在相同的目录结构中。
注意:
- 编译器对文件进行操作。所以程序的入口参数是带有斜杠分隔符且扩展名为.java的文件。
- 解释器负责加载类。所以入口参数中使用的是点分隔符和类名。
警告:
- 编译器在编译源文件时,不会检查目录结构。即使包名与所在目录不匹配,依然能够编译成功。但是虚拟机加载类时,包与目录必须匹配,否则虚拟机将找不到类。
4.7.4 包作用域
- public:任意类都可以访问
- protected:自己、子类、同包类
- default:自己、同包类
- private:仅自己
4.8 类路径
类可以存储在文件系统的子目录中(类路径要与包名匹配)。此外,类也可以存储在JAR中。在一个JAR中,可以包含多个压缩形式的类文件和子目录,既节省空间,也改善性能。Jar文件使用ZIP格式组织文件和子目录,因此可以使用压缩软件查看其中的内容。
为了使类能够被多个程序共享,需要做到以下几点:
- 尽可能将树状结构的类的基目录放在目录①中。
- 将JAR另外放在目录②中。
- 设置类路径。类路径是所有包含类文件的路径的集合。类路径通常包含当前路径(.)、路径①以及路径②(可以使用通配符,同时包含多个JAR)。
类路径示例(archives目录中的所有JAR文件都将包含在类路径中):
- Windows
c:\classdir;.;c:\archives\*
- UNIX(禁止使用 * 以防止进一步扩展)
/home/user/classdir:.:/home/user/archives/'*'
虚拟机加载类的顺序
假定命令虚拟机加载com.horstman.corejava.Employee类。
- 先查看在jre/lib和jre/lib/ext目录下的归档文件中存放的系统类文件,显然没有。
- 然后查看类路径:先在基目录中寻找;然后到当前目录中寻找;最后到JAR中寻找。
编译器定位文件的方式
如果引用了一个没有指出所在包的类,那么编译器将查询所有import语句引入的类以及默认的java.lang包中的类。如果一个都没有,则编译失败;如果有不止一个,也将编译失败。
编译器的任务不止这些,他还要查看源文件是否比类文件新,如果源文件新的话将重新编译。当查找其他包的类时,只要根据源文件名查找即可(因为public类必定和源文件名相同);然鹅当查找同包的类时,就必须搜索当前包中所有的源文件,以便确定哪个源文件定义了这个类。
4.8.1 设置类路径
可以使用java -classpath
或java-cp
命令指定类路径,也可以直接在环境变量中设置CLASSPATH,这样将会在加载类时自动添加CLASSPATH指定的类路径,避免重复劳动。
4.9 文档注释(略)
JDK包含一个非常有用的工具:javadoc,他可以由源文件自动生成一个HTML文档。如果源代码中添加了以专用界定符 /** 开始的注释,就可以使用javadoc命令生成一个注释文档。
如果将文档存入独立文件中,可能在代码修改后注释得不到更新。但是文档注释与源代码在同一个源文件中,所以只要重新运行javadoc
命令就可以轻松保持一致性。
4.9.1 注释的插入
javadoc可以冲下面几个特性中抽取信息:
- 包
- 公有类与接口
- 公有的和受保护的构造器及方法
- 公有的和受保护的域
应该为上面几部分编写注释。文档注释的格式是自由格式文本,时间原因不再深究。
4.9.2 类注释
位于import语句之后,类定义之前。
4.9.3 方法注释
除了方法描述和通用标记外,还可以使用下面的标记:
- @param
- @return
- @throws
4.9.4 域注释
只需要对公有域(通常是静态常量)建立文档
4.9.5 通用注释
用于类文档的注释,位于源文件最最开始处。
4.9.6 包与概述注释
要想产生包注释,必须在包目录中添加单独的注释文件。可以有两种选择:
- 提供一个命名为package.html的文件,将注释写在<body>标签内。
- 提供一个以package-info.java命名的Java文件。这个文件仅仅包含package声明,以及紧随其后的注释,而不应当有其他多余内容。
如果像为所有源文件提供一个概述性的注释,需要将overview.html放在所有源文件的父目录中,并将注释写在<body>标签内。
4.9.7 注释的抽取(javadoc命令的使用)
假设HTML要生成在docDirectory目录下。步骤如下:
- 切换到想要生成文档的源文件树的父目录中(即存放overview.html文件的目录中)。
- 使用javadoc命令生成文档
- 如果都在一个包下:
- java -d docDirectory nameOfPackage
- 如果对于多个包:
- java -d docDirectory nameOfPackage1 nameOfPackage2 …
- 如果文件在默认包中:
- java -d docDirectory *.java
如果省略 -d 参数,则html文件会被提取到当前目录下。
4.10 类设计技巧
- 一定要保证数据私有——不要破坏封装性
- 一定要对数据初始化——不要依赖系统默认值
- 不要在类中使用过多的基本类型——将它们抽象为一个类
- 不是所有类都需要域访问器或域修改器——有些域仅设置一次,有些域无需被外界知晓
- 将职责过多的类进行分解——不要在一个类中有多且混乱的方法
- 类名和方法名要能体现其功能——类名通常使用名词/动名词/形容词修饰的名词,方法即getter和setter。
- 优先使用不可变的类——更改对象的问题在于,多线程同时更新一个对象,其结果是不可预料的。但如果是不可变类,则可以安全地在多线程中共享。
小博同学,2020年7月12日于小破邮
这样做的优点在于:即便内部的实现重新进行了修改,无论是方法的具体实现或者实例域的修改,只要对外接口不变,就不会影响到其他外部代码的正常执行,其他对象也不会知道或者“介意”所发生的变化。 ↩︎
组合是一种比聚合更强的关联关系。它要求代表整体的对象管理代表部分的对象的生命周期,甚至是“同生死”“共存亡”。 ↩︎
System.out虽然被声明为final,但却有一个setOut()方法。原因是setOut是本地方法,而不是用Java语言实现的。本地方法可以绕过Java语言的存取控制机制。 ↩︎
C++中,使用::操作符访问自身作用域之外的静态域和静态方法,例如Math::PI。 ↩︎
术语“static”有一段不同寻常的历史。起初,C引入关键字static是为了表示退出一个块后依然存在的局部变量;随后,static在C中有了第二种含义,即不能被其他文件访问的全局变量或函数;最后,C++第三次重用了这个关键字,解释为:属于类且不属于对象的变量和函数,这个含义与Java相同。 ↩︎
因为在C++中,引用是指对象的别名,所有对引用的操作全部施加在实参上。 ↩︎
在C++中,不能直接初始化类的实例域,所有的域都要在构造函数中设置,此外C++有一个“初始化列表”语法。在Java中没有这种必要,因为Java对象中不包含子对象,只有指向其他对象的对象引用。 ↩︎
在C++中,一个构造器不能调用另一个构造器,而必须将抽取出的公共初始化代码编写成一个独立的方法,然后分别调用。 ↩︎
即使在类的后面定义域,仍然可以在初始化块中设置域。为了避免循环定义,不要读取在后面初始化的域。具体规则参照Java语言规范的8.3.2.3节(https://docs.oracle.com/javase/specs)。规则很复杂,因此建议将初始化块放在域定义之后。 ↩︎