Skip to content

面向对象、泛型与异常

这篇笔记聚焦 Java 语言层最容易“会写但没讲透”的几条主线:

  1. 面向对象
  2. 对象比较与相等性判断
  3. 泛型
  4. 异常机制

它们看起来像基础语法,但真正影响的是:代码怎么组织、类型边界怎么表达、错误怎么被处理和传递。


1. 面向对象的具体释义是什么

面向对象 可以看成 用对象来组织程序,把数据和行为放在一起,再通过对象之间的协作完成业务

这里最容易被误解的点是:

面向对象 不是“会写 class”,也不是“把函数包进类里”。

更具体一点,它是一种:建模和组织代码的方式。

所谓 建模,可以理解成 把现实业务里的角色、状态、行为和关系,转换成程序中的对象、属性、方法和协作关系

例如在订单系统里,常见会有:

  1. 用户
  2. 商品
  3. 订单
  4. 支付

这些都不是简单的一组变量,而是可以进一步抽象成对象。
对象里通常会有两类东西:

  1. 属性:它有什么,例如订单号、金额、状态
  2. 行为:它能做什么,例如创建订单、取消订单、支付订单

所以面向对象真正强调的是:先想系统里有哪些对象,它们各自负责什么,再想这些对象如何协作。


2. 面向对象到底在解决什么问题

如果没有面向对象,代码很容易退化成:

  1. 数据散在各处
  2. 处理逻辑散在各处
  3. 谁能改什么边界不清楚
  4. 系统一变大就越来越难维护

面向对象主要在解决这些问题:

  1. 业务概念如何抽象成代码结构
  2. 数据和行为如何绑定到一起
  3. 职责应该交给哪个对象负责
  4. 模块之间如何降低耦合
  5. 修改和扩展时如何减少牵一发动全身

可以用一句更直白的话理解:它是在回答:这个事情到底应该由哪个对象负责,而不是把所有逻辑堆成一条大流程。


3. 为什么叫“面向对象”

因为它思考问题的起点,不是“先执行第 1 步、第 2 步、第 3 步”,而是先问:

  1. 系统里有哪些对象
  2. 每个对象掌握什么状态
  3. 每个对象应该负责什么行为
  4. 对象之间如何互相配合

这和 面向过程 的思路不太一样。

3.1 面向过程更关注什么

面向过程 更偏:先做什么,再做什么,最后做什么。

它的重点在流程步骤本身。

3.2 面向对象更关注什么

面向对象 更偏:这个动作应该交给哪个对象来完成。

它的重点在职责划分和对象协作。

例如“下单”这个场景:

  1. 订单对象负责订单自身状态
  2. 库存对象负责扣减库存
  3. 支付对象负责支付行为

这种写法的好处是:业务职责不会都堆在一个超长方法里。


4. 对象、类和抽象分别是什么意思

4.1 对象是什么

对象 可以理解成 现实世界或业务世界中某个具体事物在程序里的表示

例如:

  1. 一个具体用户
  2. 一笔具体订单
  3. 一个具体商品

这些都可以是对象。

4.2 类是什么

可以理解成 创建对象的模板或蓝图

它描述的是:

  1. 这一类对象通常有哪些属性
  2. 这一类对象通常有哪些行为

所以可以这样记:

  1. :模板
  2. 对象:具体实例

4.3 抽象是什么

抽象 可以理解成 从很多具体事物里提取出共性,只保留当前问题真正关心的部分

例如“支付”这个概念里,微信支付和支付宝支付都不一样,但都可以抽象成:支付能力

这就是为什么面向对象很强调:先抽象,再实现。


5. 封装、继承、多态到底该怎么理解

这三个词常被称为面向对象的核心特征,但如果只是背定义,很容易变成空话。

更好的理解方式是:

  1. 封装 解决“对象内部该怎么藏、外部该怎么看”
  2. 继承 解决“已有能力怎么复用”
  3. 多态 解决“面向抽象时,不同实现怎么替换”

5.1 封装

封装 可以理解成 把对象的状态和行为放在一起,并且控制哪些细节对外暴露

它解决的是:

  1. 降低外部直接操作内部状态的风险
  2. 让对象自己维护自己的不变量
  3. 给内部实现留出修改空间

这里的 不变量 可以理解成 对象在任何正常状态下都应该成立的约束

例如账户余额不能随便变成负数,这就不应该由外部直接随意改字段。

5.2 继承

继承 可以理解成 子类复用父类已有能力,并在此基础上扩展或改写行为

它适合表达:一种明确的“是一个”关系。

但它不是“代码复用的默认首选”。

要注意的是:继承会建立更强的结构耦合。

所以工程里很常强调:组合优于继承

也就是更倾向于通过对象组合来复用能力,而不是层层继承。

5.3 多态

多态 可以理解成 同一个抽象类型,在不同实现对象上表现出不同实际行为

它解决的是:

  1. 面向抽象编程
  2. 提升扩展性
  3. 降低调用方对具体实现的依赖

例如你用接口类型接收对象时,调用方更关心“它会不会支付”,而不是“它具体是微信支付还是支付宝支付”。


6. interface 和 abstract class 有什么区别

这是 Java 面向对象里最常见的边界问题之一。

对比项interfaceabstract class
更偏什么抽象能力约定抽象父类骨架
是否能有状态一般不持有实例状态可以持有成员变量和共用实现
设计意图定义能做什么定义是什么,以及部分共性实现
更常见场景能力契约、解耦复用公共逻辑

可以这样记:

interface 更像能力接口,abstract class 更像半成品父类。


7. equalshashCode 到底在解决什么问题

这是 Java 对象模型里非常重要、也非常容易“会写代码但讲不清”的一组方法。

很多人第一次接触它们,只会记住:

  1. equals 用来比较对象
  2. hashCodeHashMap 有关

这样记太薄了。

更具体一点:

  1. equals 负责定义对象在逻辑意义上是否相等
  2. hashCode 负责支撑对象在哈希结构中的高效定位

它们一起解决的是:对象应该如何被判断为“同一个逻辑对象”,以及这种判断如何在 HashMap、HashSet 这类结构里高效工作。

7.1 ==equals 不是一回事

对引用类型来说:

  1. == 比较的是两个引用是否指向同一个对象
  2. equals 比较的是两个对象在逻辑意义上是否相等

这里的“逻辑意义上相等”,可以理解成 虽然它们不一定是同一个对象实例,但它们代表的业务内容相同

例如:

java
String a = new String("abc");
String b = new String("abc");

System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true

这段代码里:

  1. ab 不是同一个对象
  2. 但它们表示的字符串内容相同

7.2 Object 默认的 equals 做了什么

equals 方法定义在 Object 类中。

默认情况下,它的行为更接近:比较两个引用是不是同一个对象。

也就是说,如果你不重写 equals,那很多自定义对象的“相等”判断,本质上还是在看:

是不是同一个实例。

但很多业务对象更关心的往往不是“是不是同一个实例”,而是:关键字段是否相同。

这就是为什么很多领域对象、值对象会重写 equals

7.3 hashCode 是什么

hashCodeObject 里的另一个方法,它会返回一个 int 类型的哈希值。

这个哈希值不是用来直接判断两个对象是否相等的。

它更像是:对象进入哈希结构时的一个快速定位线索。

例如在 HashMapHashSet 里,通常不是上来就把所有元素一个个拿出来做 equals 比较,而是:

  1. 先计算 hashCode
  2. 根据哈希值决定大概落在哪个桶
  3. 再在那个桶里用 equals 做精确判断

所以:

  1. hashCode 负责先缩小查找范围
  2. equals 负责最终确认是否真的相等

7.4 为什么重写 equals 时通常必须重写 hashCode

因为 Java 对这两个方法有一个非常重要的约定:如果两个对象 equals 为 true,那么它们的 hashCode 必须相同。

也就是:

java
if (a.equals(b)) {
    a.hashCode() == b.hashCode();
}

这条规则不能反过来理解。

也就是说:hashCode 相同,不代表 equals 一定为 true。

这叫 哈希冲突,是允许发生的。

真正容易出问题的是:

  1. 你重写了 equals
  2. 却没有重写 hashCode

这样对象放进 HashSet 或作为 HashMap 的 key 时,就会出现逻辑不一致。

7.5 一个最典型的错误场景

java
class User {
    private String name;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return Objects.equals(name, user.name);
    }
}

如果此时:

java
User u1 = new User("Tom");
User u2 = new User("Tom");

那么:

java
u1.equals(u2)

会得到 true

但如果把它们放进 HashSet

java
Set<User> set = new HashSet<>();
set.add(u1);
set.add(u2);

结果却可能仍然保留两份。

原因不是 HashSet 出错了,而是:

HashSet 先依赖 hashCode 做定位,如果逻辑相等的对象没有给出一致的 hashCode,它们就可能进入不同桶,根本不会走到你预期的 equals 语义。

7.6 equals 应该满足哪些约定

一个合理的 equals,通常应该满足这些性质:

  1. 自反性:x.equals(x) 必须是 true
  2. 对称性:如果 x.equals(y)true,那么 y.equals(x) 也必须为 true
  3. 传递性:如果 x.equals(y)y.equals(z) 都为 true,那么 x.equals(z) 也应该为 true
  4. 一致性:如果参与比较的状态没变,多次调用结果应该一致
  5. null 比较时必须返回 false

这些规则不是为了背定义,而是为了保证:对象相等性的判断不会在不同场景下前后矛盾。

7.7 hashCode 应该满足哪些约定

至少要记住这两条:

  1. 同一个对象,在参与计算的状态不变时,多次调用 hashCode 结果应该一致
  2. 如果两个对象 equalstrue,那么它们的 hashCode 必须相同

但反过来并不成立:两个对象 hashCode 一样,不代表它们一定 equals。

7.8 一个更标准的重写方式

java
import java.util.Objects;

public class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return age == user.age && Objects.equals(name, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

这段代码里最重要的不是模板本身,而是背后的原则:

  1. 用哪些字段定义“逻辑相等”,就让哪些字段同时参与 equals
  2. 这些字段也必须同时参与 hashCode

7.9 getClass()instanceof 怎么选

这是重写 equals 时经常会碰到的边界问题。

getClass()

表示:只有完全相同的类,才允许参与相等判断。

这种方式更严格。

instanceof

表示:只要类型兼容,就允许继续比较。

这种方式更宽松,但在继承体系里更容易引发对称性和传递性问题。

所以很多业务对象里,更常见的稳妥写法是:优先使用 getClass() 保持相等性边界明确。

7.10 工程上最常见的几个坑

1. 只重写 equals,不重写 hashCode

这是最典型的问题。

2. 把可变字段用于 equals / hashCode

例如对象已经放进 HashSet,你又修改了参与哈希计算的字段,就可能出现:

  1. 查找不到
  2. 删除异常
  3. 逻辑行为混乱

3. 在继承体系里随意扩展 equals

很容易破坏:

  1. 对称性
  2. 传递性
  3. 类边界一致性

7.11 一句话总结

equalshashCode 这一组方法,本质上是在解决:对象怎样被定义为逻辑相等,以及这种相等性如何在哈希结构中以正确且高效的方式工作。


8. 泛型到底在解决什么问题

泛型 可以理解成 把类型信息参数化,让同一套逻辑可以在不同具体类型上复用,同时保留类型检查能力

如果没有泛型,集合这类容器会非常依赖强制类型转换,代码既不安全,也不清晰。

例如:

java
List<String> names = new ArrayList<>();

这里的 String 就是在告诉编译器:这个列表里应该放的就是 String。


9. 泛型里的几个高频名词是什么意思

5.1 类型参数

TEKV 这类写法,通常叫 类型参数

它们不是固定有特殊魔法,只是约定俗成的命名方式。

例如:

  1. T:Type
  2. E:Element
  3. K:Key
  4. V:Value

5.2 泛型擦除

泛型擦除 可以理解成 Java 的泛型主要在编译期发挥作用,编译后很多具体泛型信息不会完整保留到运行期

它解决的是历史兼容问题,但也带来了某些限制。

例如你不能直接:

  1. new T()
  2. T.class
  3. new List<String>[10]

这些限制的背后,很多都和泛型擦除有关。

5.3 上界和下界

例如:

  1. <? extends Number>
  2. <? super Integer>

可以这样记:

  1. extends:上界,表示某个类型或其子类
  2. super:下界,表示某个类型或其父类

它们主要解决的是:在类型复用和类型安全之间做更细的边界控制。


10. 异常机制到底在解决什么问题

异常 可以理解成 程序运行过程中出现异常情况时,用来中断当前正常流程并携带错误信息的一套机制

它解决的是:

  1. 错误如何被显式传递
  2. 出问题时程序如何停止当前流程
  3. 调用方如何决定继续处理、转换还是向上抛出

11. checked exception 和 unchecked exception 有什么区别

7.1 checked exception

checked exception 可以理解成 编译器要求你显式处理或声明抛出的异常

例如常见的 IOException

7.2 unchecked exception

unchecked exception 通常指运行时异常,也就是 RuntimeException 及其子类。

它可以理解成 编译器不强制你显式处理,但运行时仍然可能抛出的异常

例如:

  1. NullPointerException
  2. IllegalArgumentException
  3. IndexOutOfBoundsException

12. try-catch-finally 和 throw / throws 分别在干什么

8.1 try-catch-finally

它解决的是:

  1. 哪段代码可能出问题
  2. 出问题后怎么处理
  3. 是否有无论是否异常都要执行的收尾逻辑

8.2 throw

throw 表示:在当前代码位置主动抛出一个异常对象。

8.3 throws

throws 表示:声明当前方法可能把某类异常继续往上交给调用方处理。


13. 一个更像工程实践的理解

在项目里,异常机制真正要关注的不是“语法会不会写”,而是:

  1. 哪些异常应该就地处理
  2. 哪些异常应该向上抛
  3. 哪些是业务异常
  4. 哪些是系统异常
  5. 日志应该在哪里记

如果所有地方都随手 catch(Exception e),最后通常会把错误边界弄得很乱。


14. 工程上最常见的几个误区

14.1 误区一:继承天然比组合好

不一定。

继承方便,但也更容易把类层次耦合得越来越重。

14.2 误区二:泛型只是为了少写强转

这只是表面作用。
更重要的是它把类型边界显式表达出来了。

14.3 误区三:异常就是“报错信息”

异常真正重要的是:它定义了错误传播和处理的边界。

14.4 误区四:equals 只要写了就行,hashCode 可有可无

不对。

一旦对象可能进入 HashMapHashSet 这类结构,equalshashCode 就必须协同设计。


11. 一句话总结

面向对象、泛型与异常这条主线,本质上是在解决:

代码如何被抽象组织、类型边界如何被安全表达,以及错误在系统里应该如何被传递和处理。

基于 VitePress 构建的个人技术笔记。