ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java面向对象编程核心:封装、继承与多态实战解析

Java面向对象编程核心:封装、继承与多态实战解析

如果只能从 Java 学习路线里挑一个最容易被低估的知识点,我会选“面向对象编程”。很多初学者会把这一章当成语法课:记一下 class、extends、implements,然后就去刷集合题。等到真正写一个稍大的功能,发现改一个父类方法整个工程跟着报错,或者一个新需求要复制粘贴一大段代码,才会想起来当初没把封装、继承、多态这几个词吃透。这篇内容就是用来补这一课的。

我会按我自己写代码时的习惯来讲:先理解类和对象在内存里怎么落位,再把封装、继承、多态、抽象和接口逐个拆开,最后聊几个我真实踩过的坑。适合刚学完变量、循环、方法的初学者,也适合有点基础但总觉得面向对象是“背概念”的开发者。你想把一个模块设计得像组装积木一样清晰,而不是靠复制粘贴硬堆功能,那这几个概念就是你的底层工具。

1. 类与对象:一张图纸和无数套房子

1.1 类是模板,对象是模板造出来的真实个体

很多人把类理解成“一个文件里定义的代码块”,这样记问题不大,但会错过最本质的东西。我更愿意把类说成“数据结构 + 行为”的模板:它告诉你这个类型有哪些属性、能做什么操作;对象则是按照这份模板在内存里真正生成出来的一个独立个体。

类比很好用:类是户型图,对象是实体房子。户型图不会漏水,房子会;户型图改一间卧室不影响任何真实住户,但每一套真实房子都有自己的客厅朝向和装修状态。代码里的new Student()就相当于拿着户型图去盖了一套带门牌号的房子,每次 new 都是一次独立的施工。

另一个容易忽视的点:Java 里一切自定义类型本质上都是类,像 String、ArrayList 这些官方提供的类型也一样。所以你早就在和类打交道,只是在面向对象这一章里,你要开始自己设计模板了。

1.2 从字段到方法:动手写一个最简单的类

先看一个非常基础的学生类:

public class Student { private String name; private int score; public Student(String name, int score) { this.name = name; this.score = score; } public void showInfo() { System.out.println(name + " 当前成绩: " + score); } }

这段代码里有三个常见要素:字段、构造器、普通方法。name和score是这个类的字段,它们描述了对象的状态;构造器负责在 new 的时候把外部参数赋给字段;showInfo()是这个类能对外提供的操作。

使用它的地方也简单:

Student s = new Student("A同学", 88); s.showInfo();

这一步里发生过很多次s指向对象的引用传递,但很多初学者只把它理解为“创建了一个对象”。实际上,new这个动作会触发 JVM 分配内存、给字段默认值、执行构造器代码,然后把对象的地址交给左侧变量。这个流程理解得越早,你后面看引用传参、垃圾回收就越轻松。

1.3 对象在内存里到底是怎么待着的

我建议每个学面向对象的人都把“栈”和“堆”这个背景知识补上。Java 的内存管理虽然不需要你手动释放,但你不理解对象存在哪,排查问题时会很被动。

简单说:局部变量和方法调用信息在栈区;真正 new 出来的对象在堆区。基本类型字段直接存在对象内部,引用类型字段保存的是另一个对象的地址。当你在代码里写Student s = new Student(...)时,s是栈上的一个引用变量,它指向堆上的 Student 对象本身。

所以两个对象比较用==,比较的是栈上的引用地址,而不是对象内容。这就是为什么业务里要比较两个对象是否相等时要重写equals()。对象失去所有引用连接后,会被垃圾回收器在某个不确定的时机回收。你不需要手动删对象,但你对“还有没有引用指向它”要保持敏感,因为这是内存泄漏和有些诡异空指针的根源。

2. 封装:不是私有字段加 getter/setter 那么简单

2.1 封装到底保护的是什么

一提到封装,很多人第一反应是 IDE 自动生成 private 字段、getter、setter。但这是结果,不是目的。封装的本质是:对象自己管理自己的状态,对外只暴露有限的操作入口,外部不能绕过这些入口随意改动内部数据。

举个例子,一个账户余额如果直接是 public 字段,那任何代码都能把它改成 -100,甚至改成 99999999。这会让业务规则完全失效。封装要做的是把数据藏起来,同时提供带校验的操作方法,让每次修改都必须经过规则检查。

所以封装不是“为了不让别人访问所以全 private”,而是“我要保证这个对象在任何时候都处于合法状态”。字段私有只是手段,规则内聚才是核心。

2.2 四个权限修饰符,一次看明白

Java 提供了四种访问控制级别,很多人只记住了“private 私有,public 公有”,中间的 protected 和默认包访问权限经常被忽略。

修饰符同类同包子类任意位置
private可访问不可不可不可
默认(不写)可访问可访问不可不可
protected可访问可访问可访问不可
public可访问可访问可访问可访问

默认包访问权限是个容易被忽略的边界:不写修饰符时,只有同一个包下的类能访问。protected 则是“给继承体系开的口子”,但它不等于安全,因为同包类也能访问。我自己写代码时的默认策略是:字段一律 private,对外方法按需 public,帮助子类扩展的钩子方法才用 protected。

2.3 一个会主动保护自己状态的账户类

直接看一个封装到位的示例:

public class BankAccount { private double balance; public BankAccount(double initBalance) { if (initBalance < 0) { throw new IllegalArgumentException("初始金额不能为负"); } this.balance = initBalance; } public void withdraw(double amount) { if (amount <= 0) { throw new IllegalArgumentException("取款金额必须大于0"); } if (amount > balance) { throw new IllegalStateException("余额不足"); } balance -= amount; } public double getBalance() { return balance; } }

注意,这个类里没有setBalance()。余额不该被外部随意重置,要改变余额只能通过存款、取款这些业务方法。如果你确实需要一个“运营调整余额”的功能,也应该单独设计一个adjustBalance(String reason, double delta)方法,把调整原因、操作人记下来,而不是开放一个通用 setter。这样代码后续才能追责、审计。

2.4 可变集合是封装最容易漏掉的口子

字段用 private、提供 getter,不代表封装就做到了。我最常看到的问题是这个:

private List<String> tags = new ArrayList<>(); public List<String> getTags() { return tags; }

看起来没问题,但实际上外部拿到这个 List 后,可以随便add、remove,内部 tags 被改得面目全非。正确的做法是暴露一个不可变视图,或者返回拷贝:

public List<String> getTags() { return Collections.unmodifiableList(tags); }

构造器里接收外部传入的集合时也一样,要先copyOf或new ArrayList<>(传入值),否则外部改动同一个集合,你的对象内部状态也会被偷偷改掉。这个坑排查起来很耗时间,因为不是你这边代码改了,而是“不知道谁在别处改了同一个 list”。

3. 继承与组合:代码复用究竟该靠谁

3.1 继承到底继承了些什么

继承是面向对象非常重要的一个特性,但也是被滥用得最严重的一个。Dog extends Animal意味着 Dog 是一个 Animal,这种“is-a”关系才适合用继承。继承能带来的好处是复用父类的字段和方法,也能让子类在父类的基础上扩展或重写行为。

可“继承”这件事并不只是抄代码。父类的 public、protected 成员会暴露给子类,default 同包也能访问,private 成员虽然存在于子类对象里,但子类代码无法直接触碰。构造器不参与继承:父类有什么构造器,子类并不会自动获得同名构造器,子类必须在自己的构造器里通过super(...)去调用父类构造器。

每当你写extends时,都要先问一句:这个子类真的能算作父类的一种吗?如果答案只是“我想用里面那个方法”,那大概率应该用组合,而不是继承。

3.2 super 关键字和构造器的规则

Java 里有一条强制规则:子类构造器第一行必须调用父类构造器。如果你不写,编译器会默认补一个super()。一旦父类没有无参构造器,编译就会报错:Implicit super constructor undefined。

所以正确的写法很明确:

public class Base { private String name; public Base(String name) { this.name = name; } } public class Child extends Base { public Child(String name) { super(name); } }

this(...)和super(...)都必须是构造器里的第一行,因此它俩不能同时出现。这个限制的底层理由是:子类对象里先有父类部分,才能有子类部分;你不会希望在一个还没构建完父类结构的对象上去操作子类字段。

3.3 组合优先于继承:一种更稳妥的复用方式

继承看着很方便,但它把父子两个类死死绑在了一起。父类改一个方法签名或加一个字段,可能让所有子类跟着编译失败或行为变化。如果你只是想复用某个能力,更稳妥的思路是组合:把一个对象作为另一个对象的字段,通过调用它的方法来实现功能。

比如一个订单功能需要调用物流服务,正确的做法不是让OrderService extends LogisticsService,而是:

public class OrderService { private final LogisticsService logisticsService; public OrderService(LogisticsService logisticsService) { this.logisticsService = logisticsService; } public void ship(Order order) { logisticsService.ship(order); } }

这样 OrderService 和 LogisticsService 是“has-a”的关系,而不是“is-a”。后续换物流实现、加缓存、加日志都不影响外部对 OrderService 的使用。我个人的习惯是:除非子类真的有清晰的类型层次,否则默认先组合,只有确定需要向上转型和多态替换时才考虑继承。

4. 多态:面向抽象编程的核心能力

4.1 同一句话,不同对象有自己的做法

多态这个名词有点吓人,但它描述的现象非常日常:同一个方法调用,作用在不同类型的对象上,会执行不同版本的逻辑。

看这个例子:

Animal a = new Dog(); a.speak(); // 如果 Dog 重写了 speak,这里就是狗叫 Animal b = new Cat(); b.speak(); // 这里就是猫叫

这里有一个很关键的规则:编译器看左边声明的类型,决定你能调用哪些方法;运行时看右边真实对象的类型,决定方法具体怎么执行。也就是说,如果Animal这个类没有speak()方法,即使Dog里有,Animal a = new Dog(); a.speak();也是编译不通过的。

这条规则帮我解决过很多困惑:面向接口编程时,我们总是用接口类型声明变量,调用的是接口里定义的方法,真正干活的是实现类,这就是多态的日常形态。

4.2 重载和重写是两码事

多态经常和重载、重写混在一起,但两者的分工完全不同。

对比项重载(Overload)重写(Override)
位置同一个类中的多个方法子类重写父类/接口方法
方法签名参数列表必须不同方法名、参数列表必须一致
返回类型可以不同,与重载无关相同或协变返回类型
访问权限不限不能比父类更严格
决定时机编译期运行期
常见注解不需要@Override

很多人会把“重载”和“重写”都叫“多态”。严格讲,重写和接口实现才是真正体现运行期多态;重载更多是语法层面的便利。你只要记住:重载看参数,重写看子类实现,表达的是两件事。

4.3 用一个真实场景理解多态的价值

假如你写了一个下单收银功能,支持银行卡支付和积分支付。如果不用多态,代码会写成一长串 if-else:

if (channel.equals("card")) { cardPay.pay(...); } else if (channel.equals("points")) { pointsPay.pay(...); }

每新增一个渠道就要改这段判断。改用接口和多态之后,结构会清爽很多:

public interface PaymentChannel { boolean pay(long orderId, int amount); } public class CardPay implements PaymentChannel { @Override public boolean pay(long orderId, int amount) { // 银行卡支付逻辑 return true; } } public class PointsPay implements PaymentChannel { @Override public boolean pay(long orderId, int amount) { // 积分抵扣逻辑 return true; } } public class CheckoutService { private PaymentChannel channel; public CheckoutService(PaymentChannel channel) { this.channel = channel; } public void checkout(long orderId, int amount) { if (!channel.pay(orderId, amount)) { throw new IllegalStateException("支付失败"); } } }

以后新增一个支付渠道,只需要再写一个实现PaymentChannel的类,完全不改动CheckoutService。这就是多态配合接口带来的“开闭效果”:对扩展开放,对修改关闭。代码里少一堆 if-else 只是表象,真正的好处是每个分支都被隔离在自己的类里,各自能独立测试和修改。

5. 抽象类和接口:两种“边界约定”

5.1 抽象类:把公有的底座留住,把变化点留给孩子

抽象类是介于普通类和接口之间的一种设计:它可以有字段、构造器、普通方法,也可以有只有签名没有方法体的抽象方法。子类必须实现那些抽象方法,否则子类也得声明成抽象类。

public abstract class Animal { protected String name; protected Animal(String name) { this.name = name; } public abstract void speak(); public void showName() { System.out.println(name); } }

这里showName()是公共逻辑,speak()是每个子类不一样的地方。用抽象类的好处是:公共状态和模板流程可以先落实,差异点通过抽象方法强制子类去补。适合的场景通常是“它们本质上是一类东西,只是某些行为不同”。

5.2 接口:定义“能做什么”的契约

接口从一开始到现在,定位越来越清晰:它描述的是一个类型能做什么,而不是它是什么。实现接口的类不一定要有共同的父类,只要它具备接口要求的能力即可。

接口可以多实现。一个类可以同时实现Flyable、Runnable、Serializable,但只能继承一个父类。所以设计时,如果你只是想“暴露能力给别人调用”,接口是更轻的约束。

从 Java 8 开始,接口里可以有 default 方法,这给接口增加了一些复杂度,但底层逻辑没变:default 方法通常是一个所有实现类都通用的便捷入口,真正核心的操作仍然由实现类完成。

public interface Logger { void log(String message); default void logWithTime(String message) { System.out.println(LocalDateTime.now() + " " + message); log(message); } }

5.3 到底选抽象类还是接口,我有一套判断顺序

做设计时不用背教条,按这几个问题判断就够了。

  • 多个类之间是否有“is-a”的关系,并且需要共享字段、构造器或者公共方法体?优先抽象类。
  • 你只是想定一个调用规范,让不同来源的类都能被同一段代码使用?优先接口。
  • 将来可能有多能力组合的需求?优先接口,因为 Java 只支持单继承。
  • 既要有公共代码,又要定义规范性能力?可以考虑“抽象类实现接口”,把公共实现放到抽象类里,外部面向接口编程。

有一个常见的坏味道是“上帝接口”:把所有可能用到的方法都塞进一个接口,实现类被迫实现一堆用不到的方法。接口的能力边界应该小而明确,宁可多拆几个接口,也不要建一个万能类型。

6. 对象生命周期、static 和那些我排查很久的坑

6.1 对象从创建到回收,中间发生了什么

每new一次,JVM 就会在堆上分配一块空间。流程大概是这样:先给对象里的所有字段设置默认值(数字是 0,引用是 null),再执行构造器中的赋值逻辑,最后把对象的地址返回给外部引用。

Java 没有delete,对象的销毁由垃圾回收器完成。判断一个对象能不能被回收,核心是“还有没有强引用指向它”。当一个对象已经没有任何可达引用时,它就成了垃圾。这里有个容易忽略的点:局部变量在超出作用域后,栈上的引用会消失,但堆上的对象还要等 GC;如果这个对象被某个静态集合一直持有,它就不会被回收。这就是所谓的“内存泄漏”。

6.2 static 成员和实例成员分清楚,能省很多编译错误

static 成员属于类本身,不依赖任何对象。实例成员属于每个具体的对象。这两者的访问规则,很多初学者一笔糊涂账:

public class Counter { private static int totalCount; private int instanceCount; public void increment() { totalCount++; instanceCount++; } public static void showTotal() { System.out.println(totalCount); // System.out.println(instanceCount); // 编译错误 } }

increment()是实例方法,它能访问 static 字段,也能访问实例字段,因为它背后有一个确定的 this 对象。但showTotal()是静态方法,它没有 this,所以无法直接访问instanceCount。看到“Cannot make a static reference to non-static field”这个编译错误,大概率就是这里踩线了。

static 变量很适合做常量、计数器、类级别的共享数据,但并发场景下要特别小心,多线程同时读写同个 static 字段容易出问题。能用局部变量先解决,就别急着上 static。

6.3 Java 里引用是“按值传递”的?

这是个从面试到实战都会遇到的点。Java 方法参数的传递,严格说是“按值传递”,但引用类型传的是“引用的值”,也就是对象地址的拷贝。你拿着这个拷贝可以修改原对象,但重新赋值参数不会影响外部变量。

void changeName(Student s) { s.setName("新名字"); // 外部对象被改了 } void rebind(Student s) { s = new Student("另一个"); // 外部引用没有变 }

如果你发现一个方法改了对象但外部看不到变化,先检查是不是把参数重新赋值了新对象。另外,这段逻辑也解释了空指针的常见来源:参数传进来是 null,方法里直接s.setName(...)就会抛 NullPointerException。我现在的习惯是,方法入口对关键参数做快速校验,该抛异常就早点抛,别让 null 在代码里到处渗透。

6.4 构造器里调用可重写方法,是我见过最隐蔽的坑

先看这个例子:

abstract class Base { Base() { print(); } abstract void print(); } class Child extends Base { private String value = "hello"; @Override void print() { System.out.println(value); } } new Child(); // 结果输出 null

我一开始也很不理解:子类的字段明明赋值了“hello”,为什么父类构造器里调用的 print() 结果是 null?原因是调用顺序:父类构造器先执行,此时子类对象还没走到字段赋值那一步,字段 value 仍然是最初的默认值 null。虽然调用的方法是子类重写后的 print(),但子类状态还没初始化好。

这个问题的诊断方向很明确:不要在构造器中调用可能被子类重写的方法。如果必须有公共初始化流程,把它定义成 private 或 final 方法,避免被多态干扰。

7. 面向对象实战中的常见问题与自查清单

7.1 equals 和 hashCode 是绑在一起的

把对象放进HashSet或作为HashMap的 key 时,equals 和 hashCode 必须一起重写。只重写 equals,不重写 hashCode,会造成两个逻辑相等的对象出现在同一个 Set 里,或者从 Map 里取不到值。

一个常规写法是这样的:

public class User { private String id; private String name; @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof User)) return false; User other = (User) o; return id.equals(other.id); } @Override public int hashCode() { return Objects.hash(id); } }

核心契约只有三句话:equals 相同的对象,hashCode 必须相同;hashCode 相同的对象,equals 不一定相同;重写 equals 就必须重写 hashCode。你要是图省事用 IDE 自动生成,也要确认它选中的字段是真正的业务唯一标识。

7.2 面向对象设计的快速自查清单

我每次 review 别人代码和自己代码时,都会在脑内过一遍这份清单,你不妨也保存下来。

  • 字段是不是都 private?可变集合有没有暴露内部引用?
  • 构造器有没有对关键参数做合法性校验?
  • 当前这个关系是“is-a”还是“has-a”?如果不是前者,就改用组合。
  • 方法参数和返回值是用接口/抽象类,还是直接暴露具体实现类?
  • 代码里是不是出现大量if (x instanceof 某种类型)?如果是,多半可以靠多态重构。
  • 对象要放进集合时,equals/hashCode 是否都重写并且使用一致的字段?

把这几条养成习惯,比背十个设计模式更有用。我见过不少“懂设计模式名词但代码里全是 setter 和 if-else”的情况,问题往往就出在基本功上。

7.3 常见问题速查表

现象可能原因排查思路
编译报错:Implicit super constructor undefined父类没有无参构造器,子类没显式调用 super(...)在子类构造器第一行调用父类带参构造器
构造器里打印子类字段为 null/0构造器调用了可重写方法把相关方法设为 private/final,或把初始化逻辑移到普通方法
HashSet 里出现重复对象只重写 equals,没重写 hashCode用相同业务字段重写 hashCode
方法里改了对象,外部却没变化方法参数被重新赋值为新对象检查是否对新参数执行了obj = new ...
内部集合被外部改动getter 直接返回了可变集合返回 unmodifiableList 或拷贝
静态方法里访问实例字段编译失败静态成员没有 this把方法改为实例方法,或通过对象引用访问

这六类问题是我在工作里最常见的,前三个尤其容易在刚学完面向对象、开始第一次写小项目时出现。排查思路其实都很固定,关键是要建立“先看对象在内存里的布局,再看调用链上的类型”这种思维习惯。

最后说一点我自己的体会。面向对象不是语法,它是一种“责任划分”的思路:每个类只负责自己该负责的事,对外用干净的接口沟通,对内好好保护自己的状态。我接手新代码时,会先看类的字段是不是都被保护好,再看继承层级是不是只有一层,再看方法签名是依赖接口还是具体实现。这三个地方顺了,后面改起来会省很多力气。你如果刚开始学,也别急着把所有原则都用上,先从字段设 private、用接口声明变量、遇到复用需求不要第一反应是 extends 开始,慢慢就会形成肌肉记忆。等你哪一天因为改父类导致一堆子类跟着炸掉,就会理解我现在为什么这么强调封装和组合了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进