CS61B 课程笔记(Lecture 09 Extends, Casting, Higher Order Functions)

继承与类型转换:高阶函数

继承
记住,继承允许子类重用已经定义类的代码。

1
public class RotatingSLList<Item> extends SLList<Item>

extends 关键字让我们保留 SLList 的原有功能,同时允许我们进行修改和添加额外的功能。

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

  • 所有实例和静态变量
  • 所有方法
  • 所有嵌套类
    请注意,构造函数不被继承,且私有成员无法被子类直接访问。
  • 构造函数

    • 构造函数不被继承,子类必须定义自己的构造函数。
    • 子类的构造函数可以使用 super 关键字调用父类的构造函数,以确保父类的初始化逻辑被执行。
    1
    2
    3
    4
    5
    public class RotatingSLList<Item> extends SLList<Item> {
    public RotatingSLList() {
    super(); // 调用 SLList 的构造函数
    }
    }
  • 私有成员

    • 子类无法直接访问父类中的私有成员(即以 private 关键字定义的变量和方法)。
    • 若需要访问父类的私有成员,通常可以通过公共方法(如 getter 和 setter)来实现。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SLList<Item> {
private Item head;

// 公共方法可以访问私有成员
public Item getHead() {
return head; // 子类可以通过此方法访问 head
}
}

public class RotatingSLList<Item> extends SLList<Item> {
public void someMethod() {
Item item = getHead(); // 可以通过公共方法访问父类的私有成员
}
}

VengefulSLList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}

/** 打印已删除的项。 */
public void printLostItems() {
deletedItems.print();
}
}

构造函数不被继承
虽然构造函数不被继承,但 Java 要求所有构造函数必须以调用其超类的构造函数开始。
因此,我们可以显式调用超类的构造函数,使用 super 关键字,或者,如果我们选择不这样做,Java 会自动调用超类的无参数构造函数。

Object 类
Java 中的每个类都是 Object 类的后代,或者说扩展了 Object 类。
Object 类提供了每个对象应该能够执行的操作,比如 .equals(Object obj).hashCode().toString()

Object 类提供了几个核心方法。

  1. equals(Object obj)

    • 功能:比较两个对象的内容是否相等。

    • 默认实现:比较两个对象的内存地址(即它们是否是同一个对象)。

    • 常用场景:在类中重写该方法以实现逻辑相等比较。

    • 示例:

      1
      2
      3
      4
      5
      6
      7
      @Override
      public boolean equals(Object obj) {
      if (this == obj) return true; // 同一对象
      if (!(obj instanceof MyClass)) return false; // 类型检查
      MyClass other = (MyClass) obj;
      return this.field.equals(other.field); // 自定义比较逻辑
      }
  2. hashCode()

    • 功能:返回对象的哈希码,通常用于在哈希表中定位对象。

    • 说明:如果重写 equals 方法,通常也应该重写 hashCode 方法,以确保相等的对象具有相同的哈希码。

    • 示例:

      1
      2
      3
      4
      @Override
      public int hashCode() {
      return Objects.hash(field); // 根据字段生成哈希码
      }
  3. toString()

    • 功能:返回对象的字符串表示,通常用于调试和日志记录。

    • 默认实现:返回对象的类名及其哈希码的十六进制表示。

    • 常用场景:重写该方法以提供更具可读性的对象信息。

    • 示例:

      1
      2
      3
      4
      @Override
      public String toString() {
      return "MyClass{" + "field='" + field + '\'' + '}';
      }
  4. getClass()

    • 功能:返回运行时类的 Class 对象,表示对象的类型。

    • 示例:

      1
      Class<?> clazz = obj.getClass(); // 获取对象的类
  5. clone()

    • 功能:创建并返回对象的副本(浅拷贝)。

    • 说明:实现 Cloneable 接口的类可以使用此方法。默认实现是浅拷贝。

    • 示例:

      1
      2
      3
      4
      @Override
      protected Object clone() throws CloneNotSupportedException {
      return super.clone(); // 调用父类的 clone 方法
      }

is-a vs. has-a

extends 关键字定义了“是一个”(is-a)或超义词关系。
一个常见的错误是把它用于“拥有一个”(has-a)或部分关系。

has-a 关系表示一个类包含另一个类的实例,即类与类之间的组合关系。这通常用于表示“拥有”的属性,而不是继承。

封装
封装是面向对象编程的基本原则之一,也是我们作为程序员抵抗最大敌人:复杂性的方法之一。
此外,隐藏其他人不需要的信息也是管理大型系统时的一种基本方法。
封装的根本在于隐藏外部信息的概念。
在计算机科学中,模块可以定义为一组共同工作以执行任务或一组相关任务的方法。
如果一个模块的实现完全隐藏,并且只能通过文档化的接口访问,则称其为封装。

一些管理复杂性的工具:

分层抽象

  • 通过创建多个抽象层,将系统分为不同的层次,明确各层之间的责任和接口。这使得开发人员可以在不同层次上进行工作,而不必考虑整个系统的细节。

“为变化设计”(D. Parnas):

  • 设计时考虑到未来可能的变化,通过将可变部分与固定部分分离,使得系统能够适应变化而不需要进行大规模重构。

以对象为中心组织程序

  • 将程序结构围绕对象进行组织,利用对象的封装特性来实现数据和方法的结合,从而提高系统的模块化。

让对象决定如何处理事情

  • 对象应当负责处理自己的状态和行为,而不是依赖外部代码进行操作。这种自我管理的能力提升了代码的独立性和可维护性。

隐藏其他人不需要的信息

  • 通过访问修饰符(如 privateprotected)控制对类内部数据的访问,只暴露必要的公共方法,减少外部对内部状态的影响。

抽象边界
使用 Java 的 private 关键字,几乎不可能查看对象内部,确保底层复杂性不暴露给外部世界。

以下是关于继承如何破坏封装的详细笔记,包括代码示例和解释。


继承如何破坏封装

概述

在面向对象编程中,继承允许子类重用父类的代码,这在很多情况下是有益的。然而,继承也可能导致封装被破坏,特别是在方法重写和调用链中,可能会引发意想不到的问题,例如无限循环。以下通过代码示例来说明这一点。

示例代码分析

  1. 基础方法定义
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();
}
}
  • 在这个示例中,bark() 方法简单地打印 "bark"。
  • barkMany(int N) 方法调用 bark() 方法,重复 N 次,确保不会有循环调用的问题。
  1. 方法重写导致的无限循环
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");
}
}
  • 在这里,bark() 方法被重写,直接调用 barkMany(1)
  • 这样一来,barkMany(1) 内部又会调用 bark(),导致了无限循环。
  1. 覆盖方法的影响
1
2
3
4
5
6
7
@Override
public void barkMany(int N) {
System.out.println("作为一只狗,我说:");
for (int i = 0; i < N; i += 1) {
bark();
}
} // 第一个正常工作,但第二个则陷入无限循环
  • 此方法重写了父类中的 barkMany 方法,并在内部调用了 bark()
  • bark() 被调用时,它又会调用重写后的 barkMany(1),从而导致无限循环。

封装破坏的原因

  • 方法调用链:当子类重写父类的方法时,如果不清楚父类方法的实现,可能会意外地引入无限循环。
  • 对内部状态的依赖:继承使得子类可以访问父类的私有成员,这种可见性可能导致子类在设计时无意中依赖于父类的内部实现,破坏了封装。
  • 代码可读性和维护性:由于继承和方法重写的复杂性,代码的可读性和可维护性受到影响,容易产生混淆。

解决方法

  • 使用组合而不是继承:通过组合方式来构建功能,避免子类过度依赖父类的实现。
  • 谨慎使用方法重写:在重写父类的方法时,确保不会引入对父类实现的依赖,特别是涉及到方法调用的场景。
  • 明确方法调用的意图:在设计类的接口时,清晰地记录方法的预期行为和调用关系,防止不必要的循环调用。

类型检查与类型转换

请记住,编译器根据对象的静态类型确定某事是否有效。
由于编译器只看到 sl 的静态类型是 SLList,它不允许 VengefulSLList “容器” 持有它。

1
2
3
VengefulSLList<Integer> vsl = new VengefulSLList<Integer>(9);
SLList<Integer> sl = vsl;
VengefulSLList<Integer> vsl2 = sl; // 编译错误

表达式
使用 new 关键字的表达式也有编译时类型。

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

表达式右侧的编译时类型是 VengefulSLList。编译器检查 VengefulSLList 是否“是一个” SLList,并允许此赋值。

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

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

方法调用的编译时类型等于其声明的类型。

1
2
3
4
5
6
7
public static Dog maxDog(Dog d1, Dog d2) { ... }

Poodle frank = new Poodle("Frank", 5);
Poodle frankJr = new Poodle("Frank Jr.", 15);

Dog largerDog = maxDog(frank, frankJr);
Poodle largerPoodle = maxDog(frank, frankJr); // 不编译!右侧有编译时类型 Dog

类型转换

通过类型转换,我们可以告诉编译器将一个表达式视为不同的编译时类型。

1
2
Poodle largerPoodle = (Poodle) maxDog(frank, frankJr);
// 编译通过!右侧在转换后具有编译时类型 Poodle

类型转换是一种强大但危险的工具。
本质上,类型转换是在告诉编译器不要执行类型检查,让它相信你并按你想要的方式工作。

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

Poodle largerPoodle = (Poodle) maxDog(frank, frankSr); // 运行时异常!

maxDog 在运行时返回 Malamute,而我们尝试将 Malamute 转换为 Poodle 时,遇到运行时异常 - ClassCastException
你需要确保你要转换的类型是可以且确实会发生的。有一些规则可以使用。

高阶函数

一流函数
高阶函数是将其他函数视为数据的函数。
在老版 Java(Java 7 及之前),内存盒(变量)无法包含指向函数的指针。
为了绕过这一点,我们可以利用接口继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface IntUnaryFunction {
int apply(int x);
}

public class TenX implements IntUnaryFunction {
/* 返回参数的十倍。 */
public int apply(int x) {
return 10 * x;
}
}

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

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

继承速查表

继承关系

  • 当我们定义一个类 VengefulSLList 继承自 SLList 时,表示 VengefulSLListSLList 的一种特殊化,即“是一个”关系。
1
public class VengefulSLList<Item> extends SLList<Item>

继承的成员

VengefulSLList 继承了 SLList 的所有成员,包括:

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

注意事项

  • 构造函数:构造函数不被继承,子类的构造函数必须首先调用超类的构造函数。可以通过 super 关键字来实现这一点。
1
2
3
public VengefulSLList() {
super(); // 调用父类的构造函数
}

覆盖方法

当子类重写(覆盖)父类的方法时,有两个重要的规则:

  1. 编译器的保守策略

    • 编译器在进行类型检查时,仅依据静态类型来决定允许的操作。静态类型是指声明时的类型。
    1
    SLList<Item> sl = new VengefulSLList<Item>(); // sl 的静态类型是 SLList
  2. 动态类型

    • 对于覆盖的方法(与重载的方法不同),实际调用的方法取决于调用表达式的动态类型。动态类型是指在运行时对象的实际类型。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public void bark() {
    System.out.println("Dog barks");
    }

    @Override
    public void bark() {
    System.out.println("Vengeful dog barks");
    }

    SLList<Item> sl = new VengefulSLList<Item>();
    sl.bark(); // 实际调用的是 VengefulSLList 的 bark 方法

类型转换

  • 类型转换:可以使用类型转换来超越编译器的类型检查。通过强制转换,程序员可以告诉编译器将一个对象视为另一种类型。
1
VengefulSLList<Item> vsl = (VengefulSLList<Item>) sl; // 强制转换
  • 风险:使用类型转换时要确保转换的对象确实是目标类型,否则会在运行时抛出 ClassCastException