《OnJava8》精读(三) 封装、复用与多态_构造器

@


目录

​第六章 初始化和清理​​第七章 封装​​第八章 复用​​第九章 多态​​总结​


介绍


《On Java 8》是什么?

它是《Thinking In Java》的作者Bruce Eckel基于Java8写的新书。里面包含了对Java深入的理解及思想维度的理念。可以比作Java界的“武学秘籍”。任何Java语言的使用者,甚至是非Java使用者但是对面向对象思想有兴趣的程序员都该一读的经典书籍。目前豆瓣评分9.5,是公认的编程经典。

为什么要写这个系列的精读博文?

由于书籍读起来时间久,过程漫长,因此产生了写本精读系列的最初想法。除此之外,由于中文版是译版,读起来还是有较大的生硬感(这种差异并非译者的翻译问题,类似英文无法译出唐诗的原因),这导致我们理解作者意图需要一点推敲。再加上原书的内容很长,只第一章就多达一万多字(不含代码),读起来就需要大量时间。

所以,如果现在有一个人能替我们先仔细读一遍,筛选出其中的精华,让我们可以在地铁上或者路上不用花太多时间就可以了解这边经典书籍的思想那就最好不过了。于是这个系列诞生了。

一些建议

推荐读本书的​​英文版原著​​。此外,也可以参考本书的​​中文译版​​。我在写这个系列的时候,会尽量的保证以“陈述”的方式表达原著的内容,也会写出自己的部分观点,但是这种观点会保持理性并尽量少而精。本系列中对于原著的内容会以引用的方式体现。

最重要的一点,大家可以通过博客平台的评论功能多加交流,这也是学习的一个重要环节。

第六章 初始化和清理


本章总字数:19000

关键词:


  • 构造器
  • this关键词
  • 垃圾回收机制
  • 构造器初始化
  • 数组初始化

构造器

在本章的开始,作者提到了导致编程“不安全”的两个主要原因:初始化和清理。C语言中的不少bug就是程序员没有初始化导致的。在C++里加入了构造器的概念。Java沿用了这种机制。并且新加入了垃圾回收机制。

在Java中,如果一个类有构造器,“那 Java 会在用户使用对象之前(即对象刚创建完成)自动调用对象的构造器方法,从而保证初始化”。

// housekeeping/SimpleConstructor2.java
// Constructors can have arguments

class Rock2 {
Rock2(int i) {
System.out.print("Rock " + i + " ");
}
}

public class SimpleConstructor2 {
public static void main(String[] args) {
for (int i = 0; i < 8; i++) {
new Rock2(i);
}
}
}

结果:

Rock 0 Rock 1 Rock 2 Rock 3 Rock 4 Rock 5 Rock 6 Rock 7


构造器没有返回值,它是一种特殊的方法。但它和返回类型为 void 的普通方法不同,普通方法可以返回空值,你还能选择让它返回别的类型;而构造器没有返回值,却同时也没有给你选择的余地(new 表达式虽然返回了刚创建的对象的引用,但构造器本身却没有返回任何值)。如果它有返回值,并且你也可以自己选择让它返回什么,那么编译器就还得知道接下来该怎么处理那个返回值(这个返回值没有接收者)。


有的情况下,我们创建的类(class)没有为其设置构造器。那Java会为我们自动创建一个无参的构造器。如果你显示的创建了一个构造器,无论是否有参,编译器就不再默认生成。

this关键词

this,从字面上就可以理解,代表“(当前)这个”。表示当前的对象。当你在程序中写下 this时表示对当前对象的引用

示例:

// housekeeping/Flower.java
// Calling constructors with "this"

public class Flower {
int petalCount = 0;
String s = "initial value";

Flower(int petals) {
petalCount = petals;
System.out.println("Constructor w/ int arg only, petalCount = " + petalCount);
}

Flower(String ss) {
System.out.println("Constructor w/ string arg only, s = " + ss);
s = ss;
}

Flower(String s, int petals) {
this(petals);
//- this(s); // Can't call two!
this.s = s; // Another use of "this"
System.out.println("String & int args");
}

Flower() {
this("hi", 47);
System.out.println("no-arg constructor");
}

void printPetalCount() {
//- this(11); // Not inside constructor!
System.out.println("petalCount = " + petalCount + " s = " + s);
}

public static void main(String[] args) {
Flower x = new Flower();
x.printPetalCount();
}
}

结果:

Constructor w/ int arg only, petalCount = 47
String & int args
no-arg constructor
petalCount = 47 s = hi


记住了 this 关键字的内容,你会对 static 修饰的方法有更加深入的理解:static 方法中不会存在 this。你不能在静态方法中调用非静态方法(反之可以)。


垃圾回收机制

对于垃圾回收机制,作者提到了两概念:


  1. 对象可能不被垃圾回收。
  2. 垃圾回收不等同于析构。

Java中没有析构的概念,如果想要在垃圾回收执行时清除某些对象,可以使用finalize()。但是需要注意finalize()不是析构,Java里没有析构。finalize()只会在垃圾回收执行的时候触发执行。但是finalize()不保证一定会被执行。

你可能会有疑问:为什么不保证执行?


记住,无论是"垃圾回收"还是"finalize",都不保证一定会发生。如果 Java 虚拟机(JVM)并未面临内存耗尽的情形,它可能不会浪费时间执行垃圾回收以恢复内存。


一般只有在内存将要耗尽的时候才可能触发垃圾回收。这是之所以“不保证执行”的主要原因。

作者提到了finalize()一种用法:


例如,如果对象代表了一个打开的文件,在对象被垃圾回收之前程序员应该关闭这个文件。只要对象中存在没有被适当清理的部分,程序就存在很隐晦的 bug。finalize() 可以用来最终发现这个情况,尽管它并不总是被调用。如果某次 finalize() 的动作使得 bug 被发现,那么就可以据此找出问题所在。


关于垃圾回收机制的原理,作者进行了详细说明。其主要的原理在于“引用计数”概念。

“每个对象中含有一个引用计数器,每当有引用指向该对象时,引用计数加 1。当引用离开作用域或被置为 null 时,引用计数减 1。因此,管理引用计数是一个开销不大但是在程序的整个生命周期频繁发生的负担。垃圾回收器会遍历含有全部对象的列表,当发现某个对象的引用计数为 0 时,就释放其占用的空间。”

这让我想起了C#与之有相似的GC垃圾回收理念。具体可以看《简说GC垃圾回收》。

构造器初始化

读过前两章内容我们就知道,在Java中使用一个没有赋值的对象时会出错。为了便于管理,也为了更好的逻辑性。可以在构造器中初始化。

示例:

// housekeeping/Counter.java

public class Counter {
int i;

Counter() {
i = 7;
}
// ...
}


无论创建多少个对象,静态数据都只占用一份存储区域。static 关键字不能应用于局部变量,所以只能作用于属性(字段、域)。如果一个字段是静态的基本类型,你没有初始化它,那么它就会获得基本类型的标准初值。如果它是对象引用,那么它的默认初值就是 null。


数组初始化

数组是一系列相同类型的数据统称。一般由以下几种初始化数组的方式:

array = new int[ ]{1,2,3,4,5};
int[ ] array = {1,2,3,4,5};
int[ ] array = new int[10]; // 动态初始化数组

其中,动态初始化时,将会用该类型的默认值填充数组。

第七章 封装


本章总字数:11000

关键词:


  • 变与不变的区分
  • 包的概念
  • 访问控制

我们在读旧代码的时候经常会有一种感觉——这代码怎么写的这么烂——一看注释原来是自己写的。会有这种感觉是因为我们的能力在提升,对代码的理解也在改变。如何在不改变代码功能的前提下优化以往的代码?这是程序员需要面对的问题。也是重构的由来。


通常,一些用户(客户端程序员)希望你的代码在某些方面保持不变。所以你想修改代码,但他们希望代码保持不变。由此引出了面向对象设计中的一个基本问题:“如何区分变动的事物和不变的事物”。


包的概念

包由一系列类组成。这些类有着相同的命名空间。


例如,标准 Java 发布中有一个工具库,它被组织在 java.util 命名空间下。java.util 中含有一个类,叫做 ArrayList。使用 ArrayList 的一种方式是用其全名 java.util.ArrayList。


当然,我们也可以用前文提到的import 关键词:

import java.util.ArrayList;
import java.util.* //导入其中所有的类

包的命名有着自己的一套规律。在之前的章节我们提到过URL反转命名。现在我们要创建一个属于自己的“独一无二”的包,该如何做?


比如说我的域名 MindviewInc.com,将之反转并全部改为小写后就是 com.mindviewinc,这将作为我创建的类的独一无二的全局名称。我决定再创建一个名为 simple 的类库,从而细分名称。


package com.mindviewinc.simple;//注意:package 语句必须是文件的第一行非注释代码

有了独一无二的包名之后我们还有一个问题,类名冲突。假如我们在com.mindviewinc.simple 中创建了一个Vector 类。再作出以下引用

import com.mindviewinc.simple.*;
import java.util.*;

之后使用Vector 类:

Vector v = new Vector();

这时候就会出现报错——“因为 java.util.* 也包含了 Vector 类”。因为Java没有别名的概念,所以需要用以下方式区分:

java.util.Vector v = new java.util.Vector();

访问控制

publicprotectedprivate是Java常用的访问修饰。由于原著中没有给出详细的直观展示,在此我列出一个表格内容。此处的表格参考了Janeiro的内容。

访问权限

同类

同包

子类

其他包

备注

public

任何人都能访问

protect

×

继承的类可以访问

default

×

×

包访问权限,即在整个包内均可被访问

private

×

×

×

除类内部方法外都不能访问的元素


访问控制通常被称为隐藏实现(implementation hiding)。将数据和方法包装进类中并把具体实现隐藏被称作是封装(encapsulation)。其结果就是一个同时带有特征和行为的数据类型。如果在一组程序中使用接口,而客户端程序员只能向 public 接口发送消息的话,那么就可以自由地修改任何不是 public 的事物(例如包访问权限,protected,或 private 修饰的事物),却不会破坏客户端代码。


第八章 复用


本章总字数:13000

关键词:


  • 组合与继承
  • 委托
  • final关键字

组合与继承

在之前的章节已经了解了组合与继承的关系。在前文中,作者曾经推荐使用组合而不是继承。在本章作者着重解释两者的区别。


你仅需要把对象的引用(object references)放置在一个新的类里,这就使用了组合。


继承是OOP的一个重要部分。在第三章的时候,我们就已经了解到“万物皆对象”,所有的类都隐式的继承自同一个根Object 。也就意味着我们所创建的所有类实际上都是继承类。

面对组合,我们可以认为是A使用了B。而继承更多的是,A很像B。

示例:

// reuse/Cartoon.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Constructor calls during inheritance

class Art {
Art() {
System.out.println("Art constructor");
}
}
class Drawing extends Art {
Drawing() {
System.out.println("Drawing constructor");
}
}
public class Cartoon extends Drawing {
public Cartoon() {
System.out.println("Cartoon constructor");
}
public static void main(String[] args) {
Cartoon x = new Cartoon();
}
}

结果:

Art constructor
Drawing constructor
Cartoon constructor


Java 自动在派生类构造函数中插入对基类构造函数的调用。


class Game {
Game(int i) {
System.out.println("Game constructor");
}
}

class BoardGame extends Game {
BoardGame(int i) {
super(i);
System.out.println("BoardGame constructor");
}
}


如果没有无参数的基类构造函数,或者必须调用具有参数的基类构造函数,则必须使用 super 关键字和适当的参数列表显式地编写对基类构造函数的调用


委托

首先,Java不支持委托,但是我们可以使用委托的思想作出类似的功能。

示例:

public class SpaceShipControls {
void left(int velocity) {}
void right(int velocity) {}
}
public class SpaceShipDelegation {
private String name;
private SpaceShipControls controls =
new SpaceShipControls();
public SpaceShipDelegation(String name) {
this.name = name;
}
// Delegated methods:
public void left(int velocity) {
controls.left(velocity);
}
public void right(int velocity) {
controls.right(velocity);
}
public static void main(String[] args) {
SpaceShipDelegation protector =
new SpaceShipDelegation("NSEA Protector");
protector.left(100);
}
}

这种方式,使用了SpaceShipControls 的方法,而又不用继承自它,更像是将将 left和right 的控制交给了SpaceShipControls 。

组合与继承的选择

作者认为,大多数时候两者是搭配使用的。但是也需要了解两者的不同,在需要选择的时候知道如何取舍。


组合和继承都允许在新类中放置子对象(组合是显式的,而继承是隐式的)。
当你想在新类中包含一个已有类的功能时,使用组合,而非继承。


作者在本章再次强调在实际项目中,继承不是必须的,甚至应该尽量少的使用。尽量使用组合而非继承去实现功能。只有当一个类需要抽出一个基类时才使用它。


一种判断使用组合还是继承的最清晰的方法是问一问自己是否需要把新类向上转型为基类。如果必须向上转型,那么继承就是必要的,但如果不需要,则要进一步考虑是否该采用继承。


final关键字

final 有三中使用场景:数据方法

数据中使用 final ,代表该值是不能被改变的。但是当修饰的数据是一个对象时则比较特殊,比如下文的v2,“因为它是引用,所以只是说明它不能指向一个新的对象”。数组也一样,因为数组也是一种引用类型。

private final int i4 = 111;
static final int INT_5 =222;
private final Value v2 = new Value(22);

final 也可以使用在参数修饰中。如下文的 with方法是不可以对参数 g进行 new 操作的。

void with(final Gizmo g) {
//-g = new Gizmo(); // 非法 -- g is final
}

void without(Gizmo g) {
g = new Gizmo(); // 合法 -- g is not final
g.spin();
}

//void f(final int i) { i++; } // Can't change
// You can only read from a final primitive
int g(final int i) {
return i + 1;
}

方法中使用final 是为了明确禁止覆写。防止被子类改变方法中的行为。


类中所有的 private 方法都隐式地指定为 final。因为不能访问 private 方法,所以不能覆写它。可以给 private 方法添加 final 修饰,但是并不能给方法带来额外的含义。


中使用final 表示该类不能被继承。因为有些时候出于安全或者特殊情况考虑,有些类需要永远杜绝被继承影响,这时候就用到了final 。但是作者强调,这种方法是有风险的,因为你很难判断什么情况才真的需要杜绝继承。

第九章 多态


本章总字数:10900

关键词:


  • 方法绑定
  • 可拓展性
  • 构造器与多态
  • 继承设计

方法绑定

方法绑定是一个很有趣的名词,或许在这之前你可能都没有听说过。如果提到方法绑定分为“前期绑定”和“后期绑定”就更是云里雾里。说实话我也是在本章内容里第一次了解到编程语言底层的部分实现方式。

作者提到,在C语言中,方法(C里面叫函数)是只有一种绑定方式的——前期绑定。前期绑定的意义是,在程序运行之前编译器已经绑定了对方法(函数)的引用

相同的道理,后期绑定就是在程序运行开始后,编译器对方法的绑定。而Java同时包含这两种绑定方式。

举一个例子:

Animal m;
...
m=new Cat();//Cat 是Animal的实现
m.eat();

在程序运行之初,编译器并不知道m将来可能所调用的方法是具体那一种实现。假设 Animal类的实现有很多种:Cat、Dog、Sheep,每种都有 eat,只有在你初始化之后,编译器才会绑定eat方法对应的引用——即Cat的 eat。


Java 中除了 static 和 final 方法(private 方法也是隐式的 final)外,其他所有方法都是后期绑定。这意味着通常情况下,我们不需要判断后期绑定是否会发生——它自动发生。


可拓展性

在一个良好的OOP程序中,很多方法会遵循一个完整的标准模型。

举个例子:

《OnJava8》精读(三) 封装、复用与多态_java_02

所有的乐器都实现自基类Instrument。并且重写属于自己的演奏方法。只有在保持基类一致的前提下,在演奏时,我们可以不需要关注每一个派生类的实现细节,而只需要调用具体的演奏方法即可。这就是多态所带来的强拓展性。

Instrument[] orchestra = {
new Wind(),
new Percussion(),
new Stringed(),
new Brass(),
new Woodwind()
};
for (Instrument i: orchestra) {
i.play();
}

构造器与多态


在普通的方法中,动态绑定的调用是在运行时解析的,因为对象不知道它属于方法所在的类还是类的派生类。 如果在构造器中调用了动态绑定方法,就会用到那个方法的重写定义。然而,调用的结果难以预料因为被重写的方法在对象被完全构造出来之前已经被调用,这使得一些 bug 很隐蔽,难以发现。


在本节作者抛出了一个疑问:如果在构造器中调用了正在构造的对象的动态绑定方法,会发生什么?

我们尝试一下:

// polymorphism/PolyConstructors.java
// Constructors and polymorphism
// don't produce what you might expect
class Glyph {
void draw() {
System.out.println("Glyph.draw()");
}

Glyph() {
System.out.println("Glyph() before draw()");
draw();
System.out.println("Glyph() after draw()");
}
}

class RoundGlyph extends Glyph {
private int radius = 1;

RoundGlyph(int r) {
radius = r;
System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
}

@Override
void draw() {
System.out.println("RoundGlyph.draw(), radius = " + radius);
}
}

public class PolyConstructors {
public static void main(String[] args) {
new RoundGlyph(5);
}
}

结果:

Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5


Glyph 的 draw() 被设计为可重写,在 RoundGlyph 这个方法被重写。但是 Glyph 的构造器里调用了这个方法,结果调用了 RoundGlyph 的 draw() 方法,这看起来正是我们的目的。输出结果表明,当 Glyph 构造器调用了 draw() 时,radius 的值不是默认初始值 1 而是 0。


事实证明,在构造器中调用动态绑定的方法会出现难易捕捉的bug。这就说明我们在构造器内尽量不要像示例那样使用类中的方法。“在基类的构造器中能安全调用的只有基类的 final 方法”。

总结

本篇博文是目前涉及内容最多的一篇(原著这几章一共5万多字)。从晚上写到凌晨再到第二天校对。对原著相关内容读了两、三遍。六至九章的内容主要以多态和封装、重载为主。都是OOP最基础但又最关键的内容。也因此将这四章放在一起写。对OOP的基础需要多理解才能融会贯通。建议大家多自己敲一敲明白其中的内容。



作者:Mr.Jimmy

如有疑问欢迎讨论,转载请注明出处