曾经有人说,作为Java程序员如果没有卷过这本书,就算不上是真正的Java程序员,那么我就也来卷卷它吧。下面是我的读书摘录笔记。
4.1 面向对象程序设计概述
面向对象程序设计(object-oriented programming, OOP)
面向对象的程序是由对象组成的,每个对象包含对用户公开的特定功能部分和隐藏的实现部分。
传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题。一旦确定了这些过程,就要开始考虑存储数据的适当方式。这就是 Pascal 语言的设计者 Niklaus Wirth 将其著作命名为《算法 + 数据结构 = 程序》的原因。
在 Wirth 的这个书名中,算法是第一位的,数据结构是第二位的,这就明确地表述了程序员的工作方式。首先要确定如何操作数据,然后再决定如何组织数据的结构,以便于操作数据。而 OOP 却调换了这个次序,将数据放在第一位,然后再考虑操作数据的算法。
4.1.1 类
类(class)是构造对象的模板或蓝图
由类构造(construct)对象的过程称为创建类的实例(instance)
用 Java 编写的所有代码都位于某个类里面
封装(encapsulation)是处理对象的一个重要概念。从形式上看,封装就是将数据和行为组合在一个包中,并对对象的使用者隐藏具体的实现方式。对象中的数据称为实例字段(instance field),操作数据的过程称为方法(method)。作为一个类的实例,特定对象都有一组特定的实例字段值。这些值的集合就是这个对象的当前状态(state)。只要在对象上调用一个方法,它的状态就有可能发生改变。
封装的关键在于,绝对不能让类中的方法直接访问其他类的实例字段。程序只能通过对象的方法与对象数据进行交互。
可以通过扩展其他类来构建新类。
所有其他类都扩展自这个 Object 类
在扩展一个已有的类时,这个扩展后的新类具有被扩展类的全部属性和方法。你只需要在新类中提供适用于这个新类的新方法和数据字段就可以了。通过扩展一个类来建立另外一个类的过程称为继承(inheritance)
4.1.2 对象
对象的三个主要特性:
对象的行为(behavior)
对象的状态(state)
对象的标识(identity)
4.1.3 识别类
首先从识别类开始,然后再为各个类添加方法
识别类的一个经验是在分析问题的过程中寻找名词,而方法对应着动词
对于每一个动词,都要识别出负责完成相应动作的对象
4.1.4 类之间的关系
在类之间,最常见的关系有
依赖(uses-a)
聚合(has-a)
继承(is-a)
依赖(dependence),即“uses-a”关系,是一种 最明显的、最常见的关系。
如果一个类的方法使用或操纵另一个类的对象,我们就说一个类依赖于另一个类。
应该尽可能地将相互依赖的类减至最少。关键是,如果类 A 不知道 B 的存在,它就不会关心 B 的任何改变。用软件工程的术语来说,就是尽可能减少类之间的耦合。
聚合(aggregation),即“has-a”关系。包容关系意味着类 A 的对象包含类 B 的对象。
继承(inheritance),即“is-a”关系,表示一个更特殊的类与一个更一般的类之间的关系。如果类 A 扩展类 B,类 A 不但包含从类 B 继承的方法,还会有一些额外的功能。
UML Unified Modeling Language 统一建模语言
4.2 使用预定义类
Math 类只封装了功能,它不需要也不必隐藏数据。由于没有数据,因此也不必考虑创建对象和初始化它们的实例字段,因为根本没有实例字段。
4.2.1 对象与对象变量
要想使用对象,首先必须构造对象,并指定其初始状态。
使用构造器(constructor)构造新实例。构造器是一种特殊的方法,用来构造并初始化对象。
内置的(built-in)类型
适应性如何
设置任务就交给了类库的设计者。如果类设计得不完善,其他的程序员可以很容易地编写自己的类,以便增强或替代(replace)系统提供的类
要想构造一个 Date 对象,需要在构造器前面加上 new 操作符
new Date();
这个表达式构造了一个新对象。这个对象被初始化为当前的日期和时间。
可以对刚刚创建的对象应用一个方法。对新构造的 Date 对象应用 toString 方法
Stirng s = new Date().toString;
希望构造器可以多次使用,因此,需要将对象存在在一个变量中:
Date birthday = new Date();
对象变量 birthday ,它引用了新构造的对象
对象与对象变量之间存在着一个重要的区别
Date deadline;
定义了一个对象变量 deadline,它可以引用 Date 类型的对象。变量 deadline 不是一个对象,而且实际上它也没有引用任何对象。此时还不能在这个变量上使用任何 Date 方法。下面的语句
s = deadline.toString();
将产生编译错误。
必须首先初始化变量 deadline,这有两个选择,可以初始化这个变量,让它引用一个新构造的对象:
deadline = new Date();
也可以设置这个变量,让它引用一个已有的对象。
deadline = birthday;
现在,这两个变量都引用同一个对象。
要认识到重要的一点:对象变量并没有实际包含一个对象,它只是引用一个对象。
在 Java 中,任何对象变量的值都是对存储在另外一个地方的某个对象的引用。
Date deadline = new Date();
有两部分。表达式 new Date() 构造了一个 Date 类型的对象,它的值是对新创建对象的一个引用。这个引用存储在变量 deadline 中。
可以显式地将对象变量设置为 null,指示这个对象变量目前没有引用任何对象。
所有的 Java 对象都存储在堆中。当一个对象包含另一个对象变量时,它只是包含着另一个堆对象的指针。
在 Java 中,必须使用 clone 方法获得对象的完整副本。
4.2.2 Java 类库中的 LocalDate 类
Date 类的实例有一个状态,即特定的时间点
时间是用距离一个固定时间点的毫秒数表示的,这个时间点就是所谓的纪元(epoch),它是 UTC 时间 1970 年 1 月 1 日 00:00:00 。UTC 就是 Coordinated Universal Time (国际协调时间),与大家熟悉的 GMT (即 Greenwich Mean Time 格林尼治时间)一样,是一种实用的科学标准时间
类库设计者决定将保存时间与给时间点命名分开。所以标准 Java 类库分别包含类两个类:一个是用来表示时间点的 Date 类;另一个是用大家熟悉的日历表示法表示日期的 LocalDate 类。
不要使用构造器来构造 LocalDate 类的对象。实际上,应当使用静态工厂方法(factory method),它会代表你调用构造器。
LocalDate.now()
会构造一个新对象,表示构造这个对象时的日期
可以提供年、月、日来构造对应一个特定日期的对象:
LocalDate.of(1999,12,31)
通常我们都希望将构造的对象保存在一个对象变量中:
LocalDate newYearsEve = LocalDate.of(1999, 12, 31);
一旦有来一个 LocalDate 对象,可以用方法 getYear、getMonthValue 和 getDayOfMonth 得到年、月、日。
Date 类也有得到日、月、年的方法,分别是 getDay、getMonth 以及 getYear,不过这些方法已经废弃。
JDK 提供了 jdeprscan 工具来检查你的代码中是否使用了 Java API 已经废弃的特性
4.2.3 更改器方法与访问器方法
plusDays 方法会生成一个新的 LocalDate 对象
更改器方法(mutator method)
访问器方法(accessor method)
date = date.minusDays(today - 1); DayOfWeek weekday = date.getDayOfWeek(); int value = weekday.getValue();
这里遵循国际管理,即周末是一周的末尾,星期一就返回 1,星期二返回 2,依此类推。星期日则返回 7。
import java.time.DayOfWeek; import java.time.LocalDate; public class CalendarTest { public static void main(String[] args) { LocalDate date = LocalDate.now(); int month = date.getMonthValue(); int today = date.getDayOfMonth(); date = date.minusDays(today - 1); DayOfWeek dayOfWeek = date.getDayOfWeek(); int value = dayOfWeek.getValue(); System.out.println("Mon Tue Wed Thu Fri Sat Sun"); for (int i = 0; i < value; i ++) { System.out.print(" "); } while (date.getMonthValue() == month) { System.out.printf("%3d", date.getDayOfMonth()); if (date.getDayOfMonth() == today) { System.out.print("*"); } else { System.out.print(" "); } date = date.plusDays(1); if (date.getDayOfWeek().getValue() == 1) { System.out.println(""); } } } }
4.3 用户自定义类
主力类(workhorse class)
4.3.1 Employee类
最简单的类定义形式为:
class ClassName { field1; field2; ... constructor1; constructor2; ... method1; method2; ... }
程序清单 4-2 EmployeeTest/EmployeeTest.java
import java.time.LocalDate; 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", 40000, 1990, 3, 15); for (Employee e : staff) { e.raiseSalary(5); } for (Employee e : staff) { System.out.println("name = " + e.getName() + ", salary = " + e.getSalary() + ", hireDay = " + e.getHireDay()); } } } class Employee { private String name; private double salary; private LocalDate hireDay; public Employee(String n, double s, int year, int month, int day) { name = n; salary = s; 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; } }
源文件名是 EmployeeTest.java,这是因为文件名必须与 public 类的名字相匹配。在一个源文件中,只能有一个公共类,但可以有任意数目的非公共类。
当编译这段代码的时候,编译器将在目录下创建两个类文件:EmployeeTest.class 和 Employee.class。
将程序中包含 main 方法的类名提供给字节码解释器,以启动这个程序:
java EmployeeTest
字节码解释器开始运行 EmployeeTest 类的 main 方法中的代码。
4.3.2 多个源文件的使用
将 Employee 类存放在文件 Employee.java 中,将 EmployeeTest 类存放在文件 EmployeeTest.java 中
使用通配符调用 Java 编译器:
javac Employee*.java
这样一来,所有与通配符匹配的源文件都将被编译成类文件。
键入以下命令:
javac EmployeeTest.java
使用第二种方式时并没有显式地编译 Employee.java。不过,当 Java 编译器发现 EmployeeTest.java 使用 Employee 类时,它会查找名为 Employee.class 的文件。如果没有找到这个文件,就会自动地搜索 Employee.java。然后,对它进行编译。更重要的是:如果 Employee.java 版本较已有的 Employee.class 文件版本更新,Java 编译器就会自动地重新编译这个文件。
4.3.3 剖析 Employee 类
关键字 public 意味着任何类的任何方法都可以调用这些方法
关键字 private 确保只有类自身都方法能够访问这些实例字段,而其他类的方法不能够读写这些字段
可以用 public 标记实例字段,但这是一种很不好的做法。public 数据字段允许程序中的任何方法对其进行读取和修改,这就完全破坏了封装。任何类的任何方法都可以修改 public 字段
类包含都实例字段通常属于某个类类型
4.3.4 从构造器开始
构造器与类同名
在构造类的对象时,构造器会运行,从而将实例字段初始化为所希望的初始状态
构造器总是结合 new 运算符来调用的。不能对一个已经存在的对象调用构造器来达到重新设置实例字段的目的。
构造器
构造器与类同名
每个类可以有一个以上的构造器
构造器可以有 0 个、1 个或多个参数
构造器没有返回值
构造器总是伴随着 new 操作符一起调用
所有的 Java 对象都是在堆中构造的,构造器总是结合 new 操作符一起使用
4.3.5 用 var 声明局部变量
在 Java 10 中,如果可以从变量的初始值推导出它们的类型,那么可以用 var 关键字声明局部变量,而无须指定类型
不会对数值类型使用 var
var 关键字只能用于方法中的局部变量。参数和字段的类型必须声明。
4.3.6 使用 null 引用
一个对象变量包含一个对象的引用,或者包含一个特殊值 null,后者表示没有引用任何对象。
如果对 null 值应用一个方法,会产生一个 NullPointerException 异常
程序并不捕获这些异常,而是依赖程序员从一开始就不要带来异常
对此有两种解决方法。“宽容型”方法是把 null 参数转换为一个适当的非 null 值:
if (n == null) name = "unknow"; else name = n;
在 Java 9 中, Object 类对此提供类一个便利方法:
public Employee(String n, double s, int year, int month, int day) { name = Object.requireNonNullElse(n, "unknown"); ... }
“严格型”方法则是干脆拒绝 null 参数
public Employee(String n, double s, int year, int month, int day) { Object.requireNonNull(n, "The name cannot be null"); name = n; ... }
这种方法有两种好处:
异常报告会提供这个问题的描述
异常报告会准确地指出问题所在的位置,否则 NullPointerException 异常可能在其他地方出现,而很难追踪到真正导致问题的这个构造器参数。
如果要接受一个对象引用作为构造参数,就要问问自己:是不是真的希望接受可有可无的值。如果不是,那么“严格型”方法更合适。
4.3.7 隐式参数与显式参数
方法用于操作对象以及存取它们的实例字段
public void raiseSalary(double byPercent) { double raise = salary * byPercent / 100; salary += raise; } number007.raiseSalary(5);
这个调用执行以下指令:
double raise = number007.salary * 5 / 100; number007.salary += raise;
raiseSalary 方法有两个参数。第一个参数称为隐式(implicit)参数,是出现在方法名前的类型对象。第二个参数是位于方法名后面括号中的数值,这是一个显式(explicit)参数。(有人把隐式参数称为方法调用的目标或接收者)。
显式参数显式地列在方法声明中,隐式参数没有出现在方法声明中。
在每一个方法中,关键字 this 指示隐式参数。
public void raiseSalary(double byPercent) { double raise = this.salary * byPercent / 100; this.salary += raise; }
有些程序员更偏爱这样的风格,因为这样可以将实例字段与局部变量明显地区分开来。
在 Java 中,所有的方法都必须在类的内部定义,但并不表示它们是内联方法。是否将某个方法设置为内联方法是 Java 虚拟机的任务。即时编译器会监视那些简短、经常调用而且没有被覆盖的方法调用,并进行优化。
4.3.8 封装的优点
访问器方法
只返回实例字段值,因此又称为字段访问器
想要获得或设置实例字段的值,需要提供下面三项内容
一个私有的数据字段
一个公共的字段访问器方法
一个公共的字段更改器方法
这样做有着下列明显的好处:
首先,可以改变内部实现,而除了该类的方法之外,这不会影响其他代码
第二点好处:更改器方法可以完成错误检查,而只对字段赋值的代码可能没有这个麻烦
不要编写返回可变对象引用的访问器方法
Date 对象是可变的,这一点就破坏了封装性
Employee harry = ...; Date d = harry.getHireDay(); double tenYearsInMilliSeconds = 10 * 365.25 * 24 * 60 * 60 * 1000; d.setTime(d.getTime() - (long)tenYearsInMilliSeconds);
出错原因很很微妙。d 和 harry.hireDay 引用同一个对象。对 d 调用更改器方法就可以自动地改变这个 Employee 对象的私有状态
如果需要返回一个可变对象的引用,首先应该对它进行克隆(clone)。对象克隆是指存放在另一个新位置上的对象的副本。
class Employee { public Date getHireDay() { return (Date)hireDay.clone() } }
4.3.9 基于类的访问权限
方法可以访问调用这个方法的对象的私有数据
4.3.10 私有方法
在 Java 中,要实现私有方法,只需将关键字 public 改为 private 即可。
4.3.11 final 实例字段
可以将实例字段定义为 final。这样的字段必须在构造对象时初始化。
必须确保在每一个构造器执行之后,这个字段的值已经设置,并且以后不能再修改这个字段。
final 修饰符对于类型为基本类型或者不可变类的字段尤其有用。(如果类中的所有方法都不会改变其对象,这样的类就是不可变的类。例如,String 类)
对于可变的类,使用 final 修饰符可能会造成混乱
第 4 章 对象与类(下):https://developer.aliyun.com/article/1391471