Java 基础
这是对之前在学习 CS61B 时的笔记的整理。生成:Gemini 2.5 pro。整理:fyerfyer。
1. 面向对象基础:继承与实现
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! (调用了子类自己的方法)
}
}
implements
implements
关键字用于类与接口之间的关系。当一个类 implements
一个或多个接口时,这个类就必须实现这些接口中定义的所有抽象方法。
接口定义了一组行为规范或“契约”(contract),但它不关心你如何实现这些行为。implements
代表一种”can-do”(能做)的关系。例如,Bird
can Fly
,Airplane
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.
}
}
核心区别与对比
特性 | extends | implements |
---|---|---|
关系对象 | 类 extends 类 | 类 implements 接口 |
关系类型 | is-a (是一个),强烈的归属关系 | can-do (能做),定义一种能力或契约 |
数量限制 | 单继承:一个类只能继承一个父类 | 多实现:一个类可以实现多个接口 |
继承内容 | 继承父类的具体实现和状态(属性和方法) | 继承接口的行为规范(抽象方法签名) |
目的 | 代码复用,建立类族的层次结构 | 定义标准,实现解耦,让类获得多种能力 |
一个简单的比喻:
extends
:你继承了你父亲的基因(身高、肤色)和财产。你只有一个亲生父亲。(是什么)implements
:你学会了开车、游泳和编程。这些是你可以做的技能,你可以同时拥有很多技能。(能做什么)
同时设计继承与接口是为了解决多重继承的复杂性,特别是著名的“菱形问题”(Diamond Problem)。
想象一下:
- 有一个A类。
- B类和C类都继承了A类。
- B和C都重写了A中的某个方法
doSomething()
。 - 现在,D类如果可以同时继承B和C,那么当D调用
doSomething()
时,它应该用B的版本还是C的版本?这就产生了歧义。
Java通过“单类继承 + 多接口实现”的设计完美地避开了这个问题:
- 通过
extends
,你只能继承一个父类的具体实现,所以永远不会有实现上的冲突。 - 通过
implements
,你可以获得多个接口的方法定义,但实现是由你自己来写的,所以决定权在你手中,不会有歧义。
这种设计既保证了类继承结构的清晰,又通过接口提供了高度的灵活性。
2. 抽象类与接口
抽象类
抽象类可以看作是一个不完整的“设计蓝图”。它定义了一类事物的共同特征和行为,但其中一些具体的行为它自己无法确定,需要由它的子类来完成。它使用 abstract
关键字来定义。
Java 的抽象类有如下的特性:
- 它是一个模板,既包含了已经实现好的功能(具体方法),也规定了必须被实现的功能(抽象方法)。
- 它代表一种”is-a”(是一个)的关系,但它本身太“抽象”或“通用”,以至于不能直接被实例化。
使用 Java 的抽象类需要注意如下事项:
- 不能被实例化:你不能使用
new
关键字直接创建一个抽象类的对象。 - 必须被继承:抽象类的唯一用途就是被其他类
extends
。 - 子类的责任:一个类如果继承了抽象类,它必须实现父类中所有的抽象方法,否则该子类也必须被声明为抽象类。
- 构造方法:抽象类可以有构造方法,但它不能用来创建对象,只能被子类的构造方法通过
super()
调用。 - 可以没有抽象方法:一个类被声明为
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;
}
}
接口
接口是行为的抽象,它定义了一组方法签名(一个契约),但完全不关心这些行为如何实现。
接口是一个编译时的强制合同。如果一个类声明实现了一个接口,却没有提供所有方法的实现,那么这个类本身将无法通过编译。
抽象类 vs. 接口
特性 | 抽象类 (Abstract Class) | 接口 (Interface) |
---|---|---|
关系 | is-a (是一个),强烈的父子继承关系 | can-do (能做),定义一种能力或行为 |
继承 | extends 关键字,单继承 | implements 关键字,多实现 |
成员变量 | 可以有各种类型的成员变量(private , protected 等) | 只能有 public static final 的常量 |
方法 | 可以有抽象方法和具体方法 | Java 8之前只能有抽象方法,Java 8后可以有 default (默认)方法和 static (静态)方法 |
构造方法 | 有构造方法(用于被子类调用) | 没有构造方法 |
设计目的 | 代码复用和定义通用模板 | 定义行为规范,实现完全解耦 |
最佳实践
- 优先考虑使用接口:这是“面向接口编程”的核心。当你想要定义一个“行为规范”,而这个规范可以被完全不相关的类实现时,接口是最佳选择。
- 选择使用抽象类:当你想在多个相关的子类中共享代码(复用具体方法或成员变量),并且类之间存在明显的层级和”is-a”关系时,才选择使用抽象类。
3. 多态与方法调用
子类型多态 (Subtype Polymorphism)
多态是OOP的另一大支柱,它意味着“多种形态”。子类型多态允许我们使用父类类型的引用来指向子类的对象。
// 静态类型是 Animal,动态类型是 Dog
Animal myAnimal = new Dog();
Java 的子类型多态有如下的特性:
- 静态类型 (Static Type):变量在声明时使用的类型(
Animal
)。它在编译时确定。 - 动态类型 (Dynamic Type):变量在运行时实际指向的对象的类型(
Dog
)。
方法调用的两条黄金法则
- 编译器看 “静态类型”:编译器非常“保守”,它只根据变量的静态类型来检查代码是否合法。它会检查静态类型中是否包含你要调用的方法**。如果没有,编译器就会报错。
myAnimal.eat(); // 编译通过。因为编译器检查 Animal 类,发现它有 eat() 方法。
// myAnimal.bark(); // 编译失败!因为编译器只看静态类型 Animal,而 Animal 类没有 bark() 方法。
- 运行时看 “动态类型”:当代码运行起来后,对于被重写的方法(overridden),JVM 会检查这个变量实际指向的对象(即它的动态类型),并调用该动态类型中定义的方法版本。
// 假设 Dog 重写了 eat() 方法
myAnimal.eat(); // 运行时,JVM发现myAnimal实际是Dog,于是调用Dog类中重写的eat()方法。
类型转换 (Casting)
类型转换是指将一个对象从一种类型强制转换成另一种类型。有下面两种类型转换:
- 向上转型 (Upcasting):将子类对象转换为父类类型。这是自动发生且绝对安全的。
Dog myDog = new Dog();
Animal myAnimal = myDog; // 自动向上转型
- 向下转型 (Downcasting):将父类对象转换回子类类型。这必须手动强制转换,并且有风险。它的主要目的是为了调用子类特有的方法。
// 在转换前,最好用 instanceof 检查,确保安全
if (myAnimal instanceof Dog) {
Dog anotherDog = (Dog) myAnimal; // 手动强制向下转型
anotherDog.bark(); // 成功调用!
}
如果转换的对象实际类型不匹配,运行时会抛出 ClassCastException
错误。
4. 封装及其挑战
基本概念
封装是OOP的基本原则之一,核心在于信息隐藏 (Information Hiding)。它将对象的内部状态(数据)和行为(方法)捆绑在一起,并对外界隐藏其内部实现的复杂细节。
- 目的:对抗复杂性,让代码模块化、易于管理和维护。
- 实现:通过
private
等访问修饰符建立“抽象屏障 (Abstraction Barriers)”,只暴露一个公开的接口(public
方法)供外部使用。 - 例子:我们使用
ArrayList
时,只需要调用add()
、get()
等方法,而无需关心它内部是如何通过数组实现的。
继承如何破坏封装
继承在提供代码复用的同时,也可能削弱甚至破坏封装。因为子类与父类之间形成了一种非常紧密的耦合关系,子类的行为可能会依赖于父类的内部实现细节。
假设 Dog
类有两种 bark
/barkMany
的实现方式,从外部使用者来看,功能完全一样。我们有下面两种实现方法:
barkMany
调用bark
public void bark() {
System.out.println("bark");
}
public void barkMany(int N) {
for (int i = 0; i < N; i += 1) {
bark();
}
}
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
。
上面的错误的根源是父类的内部实现细节(bark
和 barkMany
谁调用谁)泄露给了子类,并直接影响了子类的正确性。这就是“继承破坏封装”的含义。因此,现代软件设计中有一条重要原则:“优先使用组合,而非继承”。
5. 函数式编程思想
高阶函数
高阶函数 (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
}
}
子类型多态和高阶函数的区别
子类型多态和高阶函数是两种实现“同样功能,不同行为”的强大思想,但它们的实现哲学完全不同。
特性 | 子类型多态 (Subtype Polymorphism) | 高阶函数 (Higher-Order Functions) |
---|---|---|
核心思想 | 行为是对象固有的一部分 | 行为是独立传递的参数 |
数据要求 | 数据类必须实现特定接口 (e.g., Comparable ) | 数据类可以是简单的“哑”数据容器 |
灵活性 | 比较逻辑固定在类中,不灵活 | 比较逻辑在调用时注入,非常灵活 |
编程范式 | 面向对象编程 (OOP) | 函数式编程 (FP) |
Java实现 | extends , implements , @Override | 函数式接口, Lambda表达式 |
6. Java类型系统
泛型
泛型 (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); // 取出时也无需转换
泛型有如下的优点:
- 更强的类型安全:在编译时就能发现类型不匹配的错误。
- 消除强制类型转换:代码更简洁、更易读。
- 代码复用:可以编写出更通用的算法。
自动装箱与拆箱
自动装箱与拆箱 (Autoboxing and Unboxing) 是Java 5引入的一个“语法糖”,用于在“基本类型”(如 int
)和“包装类型”(如 Integer
)之间自动转换。
- 自动装箱:基本类型 -> 包装类型。
Integer myInteger = 10; // 自动装箱
- 自动拆箱:包装类型 -> 基本类型。
int myInt = myInteger; // 自动拆箱
int sum = myInteger + 5; // 自动拆箱后运算
自动装箱与拆箱有以下注意事项和陷阱:
NullPointerException
:自动拆箱时,如果包装对象是null
,会抛出空指针异常。
Integer a = null;
int b = a; // 运行时抛出 NullPointerException
- 性能问题:在循环中进行大量自动装箱/拆箱会创建很多不必要的临时对象。
==
与.equals()
:比较包装类型的值时,永远使用.equals()
。==
比较的是对象地址。Java会对-128到127之间的Integer
对象进行缓存。
不可变性
不可变性 (Immutability) 的定义是:对象一旦被创建,其内部状态就永远不能被改变。如果需要修改,必须创建一个新的对象。Java中最著名的不可变类就是 String
。
不可变性数据有如下的优点:
- 线程安全 (Thread-Safe):可以被多个线程安全地共享,无需同步。
- 可预测性:不用担心对象状态被意外修改。
- 可作为
HashMap
的键:hashCode()
值固定,能保证在集合中的一致性。
创建一个不可变类的过程如下:
- 将类声明为
final
。 - 所有成员变量都声明为
private
和final
。 - 不提供任何“setter”方法。
- 所有成员变量都在构造方法中一次性初始化。
- 如果成员变量是可变对象(如
Date
,ArrayList
),必须进行防御性拷贝 (Defensive Copy),在构造函数传入时和getter方法返回时都创建新对象。
7. 代码组织与命名空间
包与规范化命名
为了解决类名冲突问题,Java引入了**包 (Package)**机制,它是一个命名空间,用来组织和管理相关的类与接口。它有如下的特性与规定:
- 规范化名称 (Canonical Name):一个事物独一无二的、全局唯一的表示方式。在Java中,一个类的完全限定名
包名.类名
就是它的规范化名称。 - 包的命名约定:为了保证包名的全球唯一性,通用约定是使用你的网址(反向书写) 作为包名的前缀。例如,如果你的网站是
example.com
,你的包名就应该是com.example.project
。
import
语句
每次都写长长的完全限定名非常麻烦。Java提供了 import
语句,在文件开头做一个“声明”,告诉编译器本文中提到的某个类具体是指哪个包下的类。
- 明确导入:
import java.util.ArrayList;
// ...
ArrayList list = new ArrayList();
- 通配符导入:
import java.util.*;
使用星号 *
可以一次性导入一个包里的所有公共类。但这种做法通常不被推荐,因为它可能导致命名冲突和编译错误,降低代码的可读性。
静态导入 (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
这样的测试工具类,静态通配符导入是可接受甚至被推荐的,因为它的方法名辨识度高,且能极大提高测试代码的简洁性和可读性。