Dark Dwarf Blog background

Java 基础

Java 基础

这是对之前在学习 CS61B 时的笔记的整理。生成:Gemini 2.5 pro。整理:fyerfyer。

1. 面向对象基础:继承与实现

a.a. extends (继承)

extends关键字用于类与类之间的继承关系。当一个类B extends 另一个类A时,我们说B是A的子类(Subclass),A是B的父类(Superclass)或基类

继承代表一种”is-a”(是一个)的关系。例如,Dog is an Animal。这意味着子类天然地拥有了父类所有非私有(public, protected)的属性和方法,就像儿子继承父亲的财产一样。

Java 的继承有如下的特性:

  • 代码复用:子类可以直接使用父类的方法和属性,无需重新编写
  • 方法重写 (Override):子类可以根据自己的需求,重新实现父类的方法,以表现出特定的行为。
  • 单继承:这是 Java 的一个重要规定。一个类最多只能 extends 一个父类。Java 不支持像 C++ 那样的多重类继承。

下面是一个简单的例子:

// 父类 Animal
class Animal {
    String name;

    public Animal(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + " is eating.");
    }

    public void sleep() {
        System.out.println(name + " is sleeping.");
    }
}

// 子类 Dog 继承 Animal
// Dog "is an" Animal
class Dog extends Animal {
    
    public Dog(String name) {
        // 调用父类的构造函数
        super(name); 
    }

    // Dog特有的方法
    public void bark() {
        System.out.println(name + " is barking: Woof! Woof!");
    }

    // 重写(Override)父类的eat方法
    @Override
    public void eat() {
        System.out.println(name + " is eating dog food.");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog myDog = new Dog("Buddy");
        myDog.eat();    // 输出: Buddy is eating dog food. (调用了子类重写的方法)
        myDog.sleep();  // 输出: Buddy is sleeping. (调用了从父类继承的方法)
        myDog.bark();   // 输出: Buddy is barking: Woof! Woof! (调用了子类自己的方法)
    }
}

b.b. implements

implements 关键字用于类与接口之间的关系。当一个类 implements 一个或多个接口时,这个类就必须实现这些接口中定义的所有抽象方法。

接口定义了一组行为规范或“契约”(contract),但它不关心你如何实现这些行为。implements代表一种”can-do”(能做)的关系。例如,Bird can FlyAirplane can also Fly。鸟和飞机是完全不同的事物,但它们都具备“飞”这个能力。

Java 的接口有如下的特性:

  • 定义契约:接口定义了类必须遵守的一套方法签名。任何实现了该接口的类,我们都可以肯定它具有这些方法。
  • 多实现:这是implements最强大的地方。一个类可以implements多个接口。这弥补了Java单继承的限制,让一个类可以拥有多种“能力”。
  • 实现多态:通过接口,我们可以实现更灵活的多态,让完全不相关的类可以用同一种类型来引用。

下面是一个简单的例子:

// 定义一个“可飞行的”接口
interface Flyable {
    // 接口中的方法默认是 public abstract 的
    void fly(); 
}

// 定义一个“可游泳的”接口
interface Swimmable {
    void swim();
}

// Bird类继承了Animal,同时实现了Flyable接口
// Bird "is an" Animal, and it "can" Fly.
class Bird extends Animal implements Flyable {
    public Bird(String name) {
        super(name);
    }

    // 必须实现Flyable接口中定义的所有方法
    @Override
    public void fly() {
        System.out.println(name + " is flapping its wings to fly.");
    }
}

// Airplane类没有继承任何东西,但它也实现了Flyable接口
// Airplane "can" Fly.
class Airplane implements Flyable {
    @Override
    public void fly() {
        System.out.println("Airplane is using its engines to fly.");
    }
}

// Duck类既能飞,也能游泳
class Duck extends Animal implements Flyable, Swimmable {
    public Duck(String name) {
        super(name);
    }

    @Override
    public void fly() {
        System.out.println(name + " is flying, not very high.");
    }

    @Override
    public void swim() {
        System.out.println(name + " is swimming in the pond.");
    }
}


public class Main {
    public static void main(String[] args) {
        Flyable flyer1 = new Bird("Sparrow");
        Flyable flyer2 = new Airplane();
        Flyable flyer3 = new Duck("Donald");

        flyer1.fly(); // 输出: Sparrow is flapping its wings to fly.
        flyer2.fly(); // 输出: Airplane is using its engines to fly.
        flyer3.fly(); // 输出: Donald is flying, not very high.
    }
}

c.c. 核心区别与对比

特性extendsimplements
关系对象extendsimplements 接口
关系类型is-a (是一个),强烈的归属关系can-do (能做),定义一种能力或契约
数量限制单继承:一个类只能继承一个父类多实现:一个类可以实现多个接口
继承内容继承父类的具体实现和状态(属性和方法)继承接口的行为规范(抽象方法签名)
目的代码复用,建立类族的层次结构定义标准,实现解耦,让类获得多种能力

一个简单的比喻:

  • extends:你继承了你父亲的基因(身高、肤色)和财产。你只有一个亲生父亲。(是什么)
  • implements:你学会了开车、游泳和编程。这些是你可以做的技能,你可以同时拥有很多技能。(能做什么)

同时设计继承与接口是为了解决多重继承的复杂性,特别是著名的“菱形问题”(Diamond Problem)。

想象一下:

  1. 有一个A类。
  2. B类和C类都继承了A类。
  3. B和C都重写了A中的某个方法doSomething()
  4. 现在,D类如果可以同时继承B和C,那么当D调用doSomething()时,它应该用B的版本还是C的版本?这就产生了歧义。

Java通过“单类继承 + 多接口实现”的设计完美地避开了这个问题:

  • 通过extends,你只能继承一个父类的具体实现,所以永远不会有实现上的冲突
  • 通过implements,你可以获得多个接口的方法定义,但实现是由你自己来写的,所以决定权在你手中,不会有歧义。

这种设计既保证了类继承结构的清晰,又通过接口提供了高度的灵活性。

2. 抽象类与接口

a.a. 抽象类

抽象类可以看作是一个不完整的“设计蓝图”。它定义了一类事物的共同特征和行为,但其中一些具体的行为它自己无法确定,需要由它的子类来完成。它使用 abstract 关键字来定义。

Java 的抽象类有如下的特性:

  • 它是一个模板,既包含了已经实现好的功能(具体方法),也规定了必须被实现的功能(抽象方法)。
  • 它代表一种”is-a”(是一个)的关系,但它本身太“抽象”或“通用”,以至于不能直接被实例化。

使用 Java 的抽象类需要注意如下事项:

  1. 不能被实例化:你不能使用 new 关键字直接创建一个抽象类的对象。
  2. 必须被继承:抽象类的唯一用途就是被其他类 extends
  3. 子类的责任:一个类如果继承了抽象类,它必须实现父类中所有的抽象方法,否则该子类也必须被声明为抽象类。
  4. 构造方法:抽象类可以有构造方法,但它不能用来创建对象,只能被子类的构造方法通过 super() 调用
  5. 可以没有抽象方法:一个类被声明为 abstract,即使它内部没有任何抽象方法,也是合法的。这样做通常是为了防止这个类被实例化。

Java 中设计抽象类的目的如下:

  • 代码复用:所有子类都具有的共同行为和属性可以放在抽象类中实现一次。
  • 强制规范:通过抽象方法,抽象类可以强制所有子类必须提供某个功能的具体实现。

下面是一个简单的例子

// 1. 定义一个抽象类 Shape
public abstract class Shape {
    private String color;

    public Shape(String color) {
        this.color = color;
    }

    // 具体方法:所有图形都有颜色,获取颜色的方式是相同的
    public String getColor() {
        return this.color;
    }

    // 抽象方法:如何计算面积?每种图形的算法都不同,由子类自己实现
    public abstract double getArea();
}

// 2. 创建具体子类 Circle
public class Circle extends Shape {
    private double radius;

    public Circle(String color, double radius) {
        super(color); // 调用父类的构造方法
        this.radius = radius;
    }

    // 必须实现父类的抽象方法 getArea()
    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }
}

// 3. 创建具体子类 Rectangle
public class Rectangle extends Shape {
    private double width;
    private double height;

    public Rectangle(String color, double width, double height) {
        super(color);
        this.width = width;
        this.height = height;
    }

    // 必须实现父类的抽象方法 getArea()
    @Override
    public double getArea() {
        return width * height;
    }
}

b.b. 接口

接口是行为的抽象,它定义了一组方法签名(一个契约),但完全不关心这些行为如何实现。

接口是一个编译时的强制合同。如果一个类声明实现了一个接口,却没有提供所有方法的实现,那么这个类本身将无法通过编译。

c.c. 抽象类 vs. 接口

特性抽象类 (Abstract Class)接口 (Interface)
关系is-a (是一个),强烈的父子继承关系can-do (能做),定义一种能力或行为
继承extends 关键字,单继承implements 关键字,多实现
成员变量可以有各种类型的成员变量(private, protected 等)只能有 public static final 的常量
方法可以有抽象方法和具体方法Java 8之前只能有抽象方法,Java 8后可以有 default(默认)方法和 static(静态)方法
构造方法有构造方法(用于被子类调用)没有构造方法
设计目的代码复用和定义通用模板定义行为规范,实现完全解耦

d.d. 最佳实践

  • 优先考虑使用接口:这是“面向接口编程”的核心。当你想要定义一个“行为规范”,而这个规范可以被完全不相关的类实现时,接口是最佳选择。
  • 选择使用抽象类:当你想在多个相关的子类中共享代码(复用具体方法或成员变量),并且类之间存在明显的层级和”is-a”关系时,才选择使用抽象类。

3. 多态与方法调用

a.a. 子类型多态 (Subtype Polymorphism)

多态是OOP的另一大支柱,它意味着“多种形态”。子类型多态允许我们使用父类类型的引用来指向子类的对象。

// 静态类型是 Animal,动态类型是 Dog
Animal myAnimal = new Dog(); 

Java 的子类型多态有如下的特性:

  • 静态类型 (Static Type):变量在声明时使用的类型(Animal)。它在编译时确定。
  • 动态类型 (Dynamic Type):变量在运行时实际指向的对象的类型(Dog)。

b.b. 方法调用的两条黄金法则

  1. 编译器看 “静态类型”:编译器非常“保守”,它只根据变量的静态类型来检查代码是否合法。它会检查静态类型中是否包含你要调用的方法**。如果没有,编译器就会报错。
myAnimal.eat(); // 编译通过。因为编译器检查 Animal 类,发现它有 eat() 方法。
// myAnimal.bark(); // 编译失败!因为编译器只看静态类型 Animal,而 Animal 类没有 bark() 方法。
  1. 运行时看 “动态类型”:当代码运行起来后,对于被重写的方法(overridden),JVM 会检查这个变量实际指向的对象(即它的动态类型),并调用该动态类型中定义的方法版本。
// 假设 Dog 重写了 eat() 方法
myAnimal.eat(); // 运行时,JVM发现myAnimal实际是Dog,于是调用Dog类中重写的eat()方法。

c.c. 类型转换 (Casting)

类型转换是指将一个对象从一种类型强制转换成另一种类型。有下面两种类型转换:

  1. 向上转型 (Upcasting):将子类对象转换为父类类型。这是自动发生且绝对安全的。
Dog myDog = new Dog();
Animal myAnimal = myDog; // 自动向上转型
  1. 向下转型 (Downcasting):将父类对象转换回子类类型。这必须手动强制转换,并且有风险。它的主要目的是为了调用子类特有的方法
// 在转换前,最好用 instanceof 检查,确保安全
if (myAnimal instanceof Dog) {
    Dog anotherDog = (Dog) myAnimal; // 手动强制向下转型
    anotherDog.bark(); // 成功调用!
}

如果转换的对象实际类型不匹配,运行时会抛出 ClassCastException 错误。

4. 封装及其挑战

a.a. 基本概念

封装是OOP的基本原则之一,核心在于信息隐藏 (Information Hiding)。它将对象的内部状态(数据)和行为(方法)捆绑在一起,并对外界隐藏其内部实现的复杂细节。

  • 目的:对抗复杂性,让代码模块化、易于管理和维护。
  • 实现:通过 private 等访问修饰符建立“抽象屏障 (Abstraction Barriers)”,只暴露一个公开的接口(public 方法)供外部使用。
  • 例子:我们使用 ArrayList 时,只需要调用 add()get() 等方法,而无需关心它内部是如何通过数组实现的。

b.b. 继承如何破坏封装

继承在提供代码复用的同时,也可能削弱甚至破坏封装。因为子类与父类之间形成了一种非常紧密的耦合关系,子类的行为可能会依赖于父类的内部实现细节。

假设 Dog 类有两种 bark/barkMany 的实现方式,从外部使用者来看,功能完全一样。我们有下面两种实现方法:

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

现在,我们定义一个子类 VerboseDog,它重写了 barkMany 方法:

@Override
public void barkMany(int N) {
    System.out.println("As a dog, I say: ");
    for (int i = 0; i < N; i += 1) {
        bark(); // 注意:这里调用的是 bark() 方法
    }
}

对于实现一,vd.barkMany(3) 调用子类的 barkMany,然后循环调用父类的 bark(),输出 “As a dog, I say: ” 和三声 “bark”。行为正常。

对于基于实现二,vd.barkMany(3) 调用子类的 barkMany,然后循环调用父类的 bark()。但父类的 bark() 又会调用 barkMany(1)。由于多态性,它调用的将是子类 VerboseDog 重写的 barkMany 方法,从而形成无限递归,导致 StackOverflowError

上面的错误的根源是父类的内部实现细节(barkbarkMany 谁调用谁)泄露给了子类,并直接影响了子类的正确性。这就是“继承破坏封装”的含义。因此,现代软件设计中有一条重要原则:“优先使用组合,而非继承”

5. 函数式编程思想

a.a. 高阶函数

高阶函数 (Higher-Order Functions - HOFs)是一个源自函数式编程的概念。Java 8通过Lambda表达式和函数式接口对其提供了很好的支持。在函数式编程中,函数也是“一等公民”,可以像普通变量一样被传来传去。

在Java中,“函数”通常由函数式接口(只有一个抽象方法的接口)的实例来表示。

下面是一个使用 Java 预定义的函数接口 java.util.function.Function 的例子。

import java.util.function.Function;

public class HofExample {
    // 这是一个高阶函数,因为它接受一个 Function 类型的“函数”作为参数
    public static void printResult(int value, Function<Integer, Integer> operation) {
        int result = operation.apply(value);
        System.out.println("The result is: " + result);
    }

    public static void main(String[] args) {
        // 定义一个“加10”的行为
        Function<Integer, Integer> addTen = (x) -> x + 10;
        // 定义一个“乘以5”的行为
        Function<Integer, Integer> multiplyByFive = (x) -> x * 5;

        // 将值和“行为”一起传递给高阶函数
        printResult(5, addTen);         // 输出: The result is: 15
        printResult(5, multiplyByFive); // 输出: The result is: 25
    }
}

b.b. 子类型多态和高阶函数的区别

子类型多态和高阶函数是两种实现“同样功能,不同行为”的强大思想,但它们的实现哲学完全不同。

特性子类型多态 (Subtype Polymorphism)高阶函数 (Higher-Order Functions)
核心思想行为是对象固有的一部分行为是独立传递的参数
数据要求数据类必须实现特定接口 (e.g., Comparable)数据类可以是简单的“哑”数据容器
灵活性比较逻辑固定在类中,不灵活比较逻辑在调用时注入,非常灵活
编程范式面向对象编程 (OOP)函数式编程 (FP)
Java实现extends, implements, @Override函数式接口, Lambda表达式

6. Java类型系统

a.a. 泛型

泛型 (Generics) 是Java 5引入的重大特性,它带来了类型安全 (Type Safety)

泛型的思想是将“类型”参数化。允许在定义类、接口和方法时,使用一个“类型占位符”(如 <T>),然后在创建实例时再指定具体的类型。

  • 泛型之前:集合只能存储 Object,存入时没有类型检查,取出时需要手动强制转换,容易在运行时出错 (ClassCastException)。
  • 泛型之后
// 在创建时就指定这个列表只能存储 String 类型
ArrayList<String> list = new ArrayList<>(); 
    
list.add("Hello");
// list.add(10); // 编译错误!编译器在编译时就阻止了你犯错
    
String s = list.get(0); // 取出时也无需转换

泛型有如下的优点:

  1. 更强的类型安全:在编译时就能发现类型不匹配的错误。
  2. 消除强制类型转换:代码更简洁、更易读。
  3. 代码复用:可以编写出更通用的算法。

b.b. 自动装箱与拆箱

自动装箱与拆箱 (Autoboxing and Unboxing) 是Java 5引入的一个“语法糖”,用于在“基本类型”(如 int)和“包装类型”(如 Integer)之间自动转换。

  • 自动装箱:基本类型 -> 包装类型。
Integer myInteger = 10; // 自动装箱
  • 自动拆箱:包装类型 -> 基本类型。
int myInt = myInteger; // 自动拆箱
int sum = myInteger + 5; // 自动拆箱后运算

自动装箱与拆箱有以下注意事项和陷阱:

  1. NullPointerException:自动拆箱时,如果包装对象是 null,会抛出空指针异常。
Integer a = null;
int b = a; // 运行时抛出 NullPointerException
  1. 性能问题:在循环中进行大量自动装箱/拆箱会创建很多不必要的临时对象。
  2. ==.equals():比较包装类型的值时,永远使用 .equals()== 比较的是对象地址。Java会对-128到127之间的 Integer 对象进行缓存。

c.c. 不可变性

不可变性 (Immutability) 的定义是:对象一旦被创建,其内部状态就永远不能被改变。如果需要修改,必须创建一个新的对象。Java中最著名的不可变类就是 String

不可变性数据有如下的优点:

  1. 线程安全 (Thread-Safe):可以被多个线程安全地共享,无需同步。
  2. 可预测性:不用担心对象状态被意外修改。
  3. 可作为 HashMap 的键hashCode() 值固定,能保证在集合中的一致性。

创建一个不可变类的过程如下:

  1. 将类声明为 final
  2. 所有成员变量都声明为 privatefinal
  3. 不提供任何“setter”方法。
  4. 所有成员变量都在构造方法中一次性初始化。
  5. 如果成员变量是可变对象(如 Date, ArrayList),必须进行防御性拷贝 (Defensive Copy),在构造函数传入时和getter方法返回时都创建新对象。

7. 代码组织与命名空间

a.a. 包与规范化命名

为了解决类名冲突问题,Java引入了**包 (Package)**机制,它是一个命名空间,用来组织和管理相关的类与接口。它有如下的特性与规定:

  • 规范化名称 (Canonical Name):一个事物独一无二的、全局唯一的表示方式。在Java中,一个类的完全限定名 包名.类名 就是它的规范化名称。
  • 包的命名约定:为了保证包名的全球唯一性,通用约定是使用你的网址(反向书写) 作为包名的前缀。例如,如果你的网站是 example.com,你的包名就应该是 com.example.project

b.b. import 语句

每次都写长长的完全限定名非常麻烦。Java提供了 import 语句,在文件开头做一个“声明”,告诉编译器本文中提到的某个类具体是指哪个包下的类。

  • 明确导入
import java.util.ArrayList;
// ...
ArrayList list = new ArrayList();
  • 通配符导入
import java.util.*;

使用星号 * 可以一次性导入一个包里的所有公共类。但这种做法通常不被推荐,因为它可能导致命名冲突和编译错误,降低代码的可读性。

c.c. 静态导入 (import static)

常规的 import 是用来导入类的。而 import static 允许你直接导入一个类里的静态成员(通常是静态方法或静态常量),从而在调用时省略类名。

  • 不使用静态导入
import org.junit.Assert;
// ...
Assert.assertEquals(5, 5); 
  • 使用静态导入
import static org.junit.Assert.assertEquals;
// ...
assertEquals(5, 5); 
  • 静态通配符导入
import static org.junit.Assert.*;

对于 org.junit.Assert 这样的测试工具类,静态通配符导入是可接受甚至被推荐的,因为它的方法名辨识度高,且能极大提高测试代码的简洁性和可读性。