幻灯片:cs61b 2021 lec9 inheritance2 - Google 幻灯片

Reading:4.2 Extends, Casting, Higher Order Functions · Hug61B (gitbooks.io)

Extends关键字

现在,您已经了解了如何使用 implements 关键字来定义与接口的层次结构关系。如果我们想定义类之间的层次结构关系怎么办?

  • implements 关键字:定义类与接口的层次结构关系
  • extends关键字:定义类之间的层次结构关系

通过使用 extends 关键字,子类继承父类的所有成员。 “成员”包括:

  • 所有实例变量和静态变量
  • 所有方法
  • 所有嵌套类

请注意,构造函数不是继承的,子类不能直接访问私有成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/** 请注意,当有人调用 removeLast SLList 时,它会丢弃该值 - 再也看不到了。但是,如果那些被移除的价值观离开并开始对我们进行大规模的反抗呢?在这种情况下,我们需要记住那些被删除的(或者更确切地说是有缺陷的>:()值是什么,以便我们可以追踪它们并在以后终止它们。

我们创建了一个新类 VengefulSLList,它记住了所有被 removeLast 放逐的元素。*/
public class VengefulSLList<Item> extends SLList<Item> {
SLList<Item> deletedItems;

public VengefulSLList() {
deletedItems = new SLList<Item>();
}

@Override
public Item removeLast() {
Item x = super.removeLast();
deletedItems.addLast(x);
return x;
}

/** Prints deleted items. */
public void printLostItems() {
deletedItems.print();
}
}

surper()

我们继承过来的不包含构造函数,虽然构造函数不是继承的,但 Java 要求所有构造函数都必须从调用其超类的构造函数之一开始。当我们调用子类的构造函数的时候,会隐形地调用基类的构造函数,也可以在构造函数中加入surper()语句来显性调用基类的构造函数

1
2
3
4
public VengefulSLList() {
super();//或者删掉
deletedItems = new SLList<Item>();
}

这样子看起来surper()好像没有存在的必要

但是如果我们需要调用带参数的构造函数,如果纯隐形调用基类的构造函数的话,我们的传入的参数并没有什么用,于是我们需要将参数传入surper()中,即

1
2
3
4
public VengefulSLList(Item x) {
super(x);
deletedItems = new SLList<Item>();
}

对象类(The Object Class)

Java 中的每个类都是 Object 类或 extends Object 类的后代。即使类中没有显式类,它们仍然隐式 extends 扩展 Object 类。

image-20240130003225738

虚线表示隐式继承

Documentation for Object class: Object (Java SE 9 & JDK 9 ) (oracle.com)

封装(Encapsulation)

封装是面向对象编程的基本原则之一,也是我们作为程序员用来抵抗我们最大的敌人:复杂性的方法之一。管理复杂性是我们在编写大型程序时必须面对的主要挑战之一。

我们可以用来对抗复杂性的一些工具包括分层抽象(抽象障碍!)和称为“为变革而设计”的概念。这围绕着这样一种想法,即程序应该被构建成模块化的、可互换的部分,这些部分可以在不破坏系统的情况下进行交换。此外,在管理大型系统时,隐藏其他人不需要的信息是另一种基本方法。

封装的根源在于这种向外部隐藏信息的概念。观察它的一种方法是了解封装如何类似于人类细胞。细胞的内部可能非常复杂,由染色体、线粒体、核糖体等组成,但它被完全封装在一个模块中——抽象出内部的复杂性。

img

在计算机科学术语中,模块可以定义为一组方法,这些方法作为一个整体协同工作以执行一项任务或一组相关任务。这可能类似于表示列表的类。现在,如果一个模块的实现细节在内部被隐藏,并且与它交互的唯一方法是通过一个记录的接口,那么该模块就被称为封装。

ArrayDeque 类为例。外部世界能够 ArrayDeque 通过其定义的方法(如 addLast 和 )利用和 removeLast 与之交互。但是,他们不需要了解数据结构如何实现的复杂细节,以便能够有效地使用它。

抽象障碍(Abstraction Barriers)

理想情况下,用户应该无法观察他们正在使用的数据结构的内部工作原理。幸运的是,Java 使实施抽象壁垒变得容易。在 Java 中使用关键字 private ,几乎不可能查看对象内部 - 确保底层复杂性不会暴露给外界。

继承如何破坏封装

假设我们在 Dog 类中有以下两种方法。我们本可以实现 barkbarkMany 像这样:

1
2
3
4
5
6
7
8
9
public void bark() {
System.out.println("bark");
}

public void barkMany(int N) {
for (int i = 0; i < N; i += 1) {
bark();
}
}

或者,我们可以这样实现它:

1
2
3
4
5
6
7
8
9
public void bark() {
barkMany(1);
}

public void barkMany(int N) {
for (int i = 0; i < N; i += 1) {
System.out.println("bark");
}
}

从用户的角度来看,这些实现中的任何一个的功能都是完全相同的。但是,如果我们要定义一个 Dog called VerboseDog 的子类,并按如下方式覆盖其方法,则观察其 barkMany 效果:

1
2
3
4
5
6
7
@Override
public void barkMany(int N) {
System.out.println("As a dog, I say: ");
for (int i = 0; i < N; i += 1) {
bark();
}
}

正如你所看到的,使用第一个实现,输出是 As a dog, I say: bark bark bark,而使用第二个实现,程序陷入无限循环。对 bark() 的调用将调用 ,它调用 barkMany(1) bark() ,无限多次重复该过程。

类型检查和铸造

在讨论类型和转换之前,让我们回顾一下动态方法选择。回想一下,动态方法查找是根据对象的动态类型确定在运行时执行的方法的过程。具体来说,如果 SLList 中的方法被 VengefulSLList 类重写,则在运行时调用的方法由该变量的运行时类型或动态类型确定。

img

对于这段代码

1
sl.printLostItems();

上面的这一行会导致编译时错误。请记住,编译器根据对象的静态类型来确定某些内容是否有效。由于 sl 是静态类型 SLList,并且 printLostItems 未在 SLList 类中定义,因此即使 sl 的运行时类型是 VengefulSLList,也不允许运行代码。

1
VengefulSLList<Integer> vsl2 = sl;

出于类似的原因,上面的这一行也会导致编译时错误。通常,编译器只允许基于编译时类型的方法调用和赋值。由于编译器只看到 的静态类型 sl 是 SLList,因此它不允许 VengefulSLList“容器”保存它。

总结:

  • 基类无法调用子类的方法
  • 子类指针无法存储基类地址

表达 式

与上面的变量一样,使用关键字的 new 表达式也具有编译时类型。

1
SLList<Integer> sl = new VengefulSLList<Integer>();

上面,表达式右侧的编译时类型是 VengefulSLList。编译器检查以确保 VengefulSLList “is-a” SLList,并允许此赋值,

1
VengefulSLList<Integer> vsl = new SLList<Integer>();

上面,表达式右侧的编译时类型是 SLList。编译器检查 SLList “is a”VengefulSLList,并非在所有情况下都是如此,因此会导致编译错误。

此外,方法调用具有与其声明类型相等的编译时类型。假设我们有这个方法:

1
public static Dog maxDog(Dog d1, Dog d2) { ... }

由于返回类型 maxDog 为 Dog,因此任何调用 maxDog 都将具有编译时类型 Dog。

1
2
3
4
5
Poodle frank = new Poodle("Frank", 5);
Poodle frankJr = new Poodle("Frank Jr.", 15);

Dog largerDog = maxDog(frank, frankJr);
Poodle largerPoodle = maxDog(frank, frankJr); //does not compile! RHS has compile-time type Dog

将 Dog 对象分配给 Poodle 变量(如在 SLList 案例中)会导致编译错误。贵宾犬“是”一只狗,但更一般的狗对象可能并不总是贵宾犬,即使它显然是你和我(我们知道这一点 frank ,并且 frankJr 都是贵宾犬!当我们确定分配会起作用时,有什么办法可以解决这个问题吗?

转换(Casting)

Java 有一种特殊的语法,您可以在其中告诉编译器特定表达式具有特定的编译时类型。这称为“转换”。通过强制转换,我们可以告诉编译器将表达式视为不同的编译时类型。

回顾上面失败的代码,既然我们知道并且 frank frankJr 都是贵宾犬,我们可以强制执行:

1
Poodle largerPoodle = (Poodle) maxDog(frank, frankJr); // compiles! Right hand side has compile-time type Poodle after casting

注意:转换是一种强大但危险的工具。从本质上讲,强制转换是告诉编译器不要执行其类型检查职责 - 告诉它信任你并按照你希望的方式行事。以下是可能出现的问题

1
2
3
4
Poodle frank = new Poodle("Frank", 5);
Malamute frankSr = new Malamute("Frank Sr.", 100);

Poodle largerPoodle = (Poodle) maxDog(frank, frankSr); // runtime exception!

在这种情况下,我们比较贵宾犬和雪橇犬。如果没有强制转换,编译器通常不允许调用编译 maxDog ,因为右侧的编译时类型是 Dog,而不是 Poodle。但是,强制转换允许此代码通过,并且当在运行时返回雪橇犬时,我们尝试将雪橇犬强制转换为贵宾犬时,我们遇到了运行时 maxDog 异常 - a ClassCastException .

高阶函数

高阶函数是将其他函数视为数据的函数。例如,以这个 Python 程序 do_twice 为例,它接受另一个函数作为输入,并将其应用于输入 x 两次。

1
2
3
4
5
def tenX(x):
return 10*x

def do_twice(f, x):
return f(f(x))

调用 print(do_twice(tenX, 2)) 会将 tenX 应用于 2,然后再次将 tenX 应用于其结果 20,从而得到 200。我们如何在 Java 中做这样的事情?

在老式的 Java(Java 7 及更早版本)中,内存框(变量)不能包含指向函数的指针。这意味着我们无法编写具有“Function”类型的函数,因为根本没有函数类型。

为了解决这个问题,我们可以利用接口继承。让我们编写一个接口来定义任何接受整数并返回整数的函数 - 一个 IntUnaryFunction .

1
2
3
public interface IntUnaryFunction {
int apply(int x);
}

现在我们可以编写一个类来 implements IntUnaryFunction 表示一个具体的函数。让我们创建一个函数,它接受一个整数并返回该整数的 10 倍。

1
2
3
4
5
6
public class TenX implements IntUnaryFunction {
/* Returns ten times the argument. */
public int apply(int x) {
return 10 * x;
}
}

在这一点上,我们已经用 Java 编写了 tenX 该函数的 Python 等价物。让我们现在写 do_twice 吧。

1
2
3
public static int do_twice(IntUnaryFunction f, int x) {
return f.apply(f.apply(x));
}

在 Java 中调用 print(do_twice(tenX, 2)) 如下所示:

1
System.out.println(do_twice(new TenX(), 2));

继承备忘单

VengefulSLList extends SLList 表示 VengefulSLList “is-an” SLList,并继承 SLList 的所有成员:

  • 变量、方法、嵌套类
  • 不是构造函数 子类构造函数必须首先调用超类构造函数。该 super 关键字可用于调用重写的超类方法和构造函数。

重写方法的调用遵循两个简单的规则:

  • 编译器是安全的,只允许我们根据静态类型做事。
  • 对于重写的方法(非重载方法),调用的实际方法基于调用表达式的动态类型
  • 可以使用强制转换来否决编译器类型检查。