ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

吃透Java面向对象四大基石:类、封装、继承、多态实战指南

吃透Java面向对象四大基石:类、封装、继承、多态实战指南

写 Java 的人应该都有过这么一段:键盘敲得飞快,类一个接一个往外蹦,可回过头看自己的代码,总觉得哪里不对劲。说不上是坏,但就是“味道”不对。后来能带新人了,我才慢慢想明白,问题不是语法不熟,而是四个底子没打牢——类、封装、继承、多态。这四个词每个初学者都能背,但真正写代码时能把它们当“设计工具”而不是“考试知识点”的人,太少了。

这篇不是教科书复读。我想站在一个写了多年 Java、也带过不少新人的老开发的角度,把这四个基石拆开讲透:它们解决什么问题、底层怎么运作、写项目时怎么用,以及新手最容易在哪里翻车。无论你是刚装好 JDK、跑通第一个 HelloWorld,还是写了一点代码但总被老同事说“不像工程代码”,这篇都值得你按顺序看完。每一段的坑,都是我亲眼见过别人(也包括我自己)踩过的。

1. 类和对象:一切从类开始

1.1 类是模板,对象是有血有肉的实例

很多教材喜欢用“类是模板,对象是实例”这句话一笔带过,但新手往往在这个地方就开始迷糊:我写了一个类,怎么还不能用?非得 new 一下才能用,这个 new 到底干了什么?

打个比方。类是建筑图纸,对象是照着图纸盖出来的房子。你画了一张三室两厅的图纸,这本身不给你提供任何居住空间;只有拿着图纸去施工,造出一套套具体的房子,你才能住进去。图纸能反复使用,盖多少套房子都行;每套房子的门牌号不同、里面住的家庭不同,但户型结构完全一致。

在 Java 里,class 定义的就是这种“图纸”。它声明了数据(字段)和行为(方法),但是你在没有 new 之前,这些只是静态的东西。只有通过 new 关键字,JVM 才会在内存里真正给你造出一个对象。

定义一个类通常长这样:

public class Person { String name; int age; void sayHello() { System.out.println("大家好,我叫" + name + ",今年" + age + "岁"); } }

然后创建对象:

Person p1 = new Person(); p1.name = "小李"; p1.age = 25; p1.sayHello();

p1 是一个引用变量,它指向堆内存里那坨真实存在的 Person 对象。这里顺带提醒一句:Java 没有“对象变量存对象”的说法。你声明的只是引用,而不是对象本体。后面讲多态、讲参数传递,这个认知会反复回来找你。

1.2 内存视角:new 一个对象到底经历了什么

理解 new 的过程,能帮你解释很多玄学问题——为什么对象默认值是 0/null?为什么 String 比较用 equals 而不用 ==?为什么方法传参改不了外面的值?

一行Person p = new Person();背后大约干了四件事:

  1. 类加载检查:JVM 先看 Person 这个类有没有被加载过,没有的话就先加载。类元信息存到方法区(现在的元空间)。
  2. 分配堆内存:在堆上划一块足够大的空间,大小由类结构决定。
  3. 零值初始化:内存里的字段先全部设成默认值,int 是 0,boolean 是 false,引用是 null。
  4. 执行构造器:调用构造器方法体,给字段赋你传入的值。

最后,JVM 把这块堆内存的首地址交给 p 这个引用变量。

这就是为什么你哪怕完全不赋值,也能访问到 age 的 0、name 的 null。很多人以为“new 了以后字段就是空的”,准确说法是“先给零值,再走构造器覆盖”。

搞清楚这条链路后,一个经典问题就迎刃而解了:为什么p1 == p2比较的是引用而不是内容?

Person p1 = new Person(); Person p2 = new Person(); System.out.println(p1 == p2); // false,两个不同堆地址

因为 == 比较的是栈上引用变量里存的地址。要比较两个对象的内容,需要重写 equals 方法,这是后面封装部分会一起讲的联动知识点。

1.3 构造器里那些隐晦规则

新手写类,最容易被构造器绊一跤。Java 有个看似贴心的设计:如果你没写任何构造器,编译器自动给你补一个无参构造器。一旦你写了一个有参构造器,这个默认的无参构造器就消失。典型的崩溃现场是这样的:

public class Dog { String name; public Dog(String name) { this.name = name; } } public class Main { public static void main(String[] args) { Dog d = new Dog(); // 编译错误 } }

你可能会问:我没写无参构造器,为什么不能 new Dog()?因为编译器只在你一个构造器都没写的时候才补默认无参构造器;你手写了有参的,编译器就默认“你清楚自己需要什么”,不再多管闲事。

这个行为背后有它的设计逻辑:一个类的构造器代表着“创建这个对象需要什么必要条件”。如果狗必须得有名字,那无参构造就不该存在,否则一个没有名字的 Dog 对象就会在系统里到处乱窜。

实操建议:如果你确实希望允许无参创建,就显式写一个无参构造器,并在里面给字段设置合理的默认值。别赌编译器,也别赌队友。代码是写给人看的,越明确越省心。

2. 封装:把变量藏起来

2.1 为什么新人总喜欢直接 public 字段

我见过大量新人的第一版实体类长这样:

public class Account { public double balance; }

然后主程序里到处写:

account.balance = -100; account.balance = account.balance * 2;

四五个类都在直接改 balance,改到后面没人知道余额被谁动过,出了负值也查不到源头。这就是不封装的直接后果:字段对所有人敞开,任何代码都能随意篡改,而且完全没有拦截和校验的机会。

封装不是让你多敲几个字,而是给你设置“关卡”的机会。你可以选择哪些字段对外可见、哪些行为对外可调,以及这些行为在真正执行前要经过什么检查。

继续拿余额举例,经过封装后:

public class Account { private double balance; public double getBalance() { return balance; } public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("存款金额必须为正数"); } balance += amount; } public void withdraw(double amount) { if (amount <= 0) { throw new IllegalArgumentException("取款金额必须为正数"); } if (amount > balance) { throw new IllegalArgumentException("余额不足"); } balance -= amount; } }

调用方不再直接接触 balance,只能通过 deposit 和 withdraw 操作。所有非法金额都在这两个“关卡”被拦住。以后改规则,也只需要改这两个方法,调用方的代码一行都不用动。

这就是封装的第一个价值:可控性。把危险操作收进狭小的入口,集中校验,集中管理。

封装的第二个价值是隐藏实现细节。对外暴露的是“做什么”,而不是“怎么做”。你调用账户的存款接口,不需要关心内部是不是做了日志、是不是做了数据库同步、是不是同步通知了其他模块。

2.2 getter/setter 是不是真的越多越好

这里必须泼一盆冷水。有些人学会了封装以后,走向另一个极端:把所有字段都 private,然后无脑生成 getter/setter,一个都不敢少。这种代码叫“属性壳”,看着像封装,实际和 public 字段没有本质区别。

真正的封装,是只暴露“对外的业务能力”,而不是把内部状态一对一变公开。比如一个咖啡机类,对外只需要makeCoffee(CoffeeType type)这个方法,水温、水箱容量、研磨粒度这些字段都应该私有,而且连 getter 都不一定要有。外面的人想知道水温干嘛?那是调试时才需要看的信息。

所以 setter 并不是必须的。如果一个字段在对象创建后就不允许改变,那就干脆只提供 getter,甚至放到构造器里一次性传进去。可变状态越少,并发问题和逻辑 bug 就越少。这一点等你接触多线程时会体会更深。

2.3 访问控制四个级别的实际取舍

Java 的访问控制有四级,按开放程度从大到小排列:

修饰符同包内子类任意类你的语义
private否否否只有类自己知道
默认(什么都不写)是否否同一个包内的朋友知道
protected是是否信任子类
public是是是对外承诺,不能轻易变

我见过不少项目里 protected 被严重误用。新手觉得 protected 是“public 和 private 之间取个中”,于是到处用。实际上 protected 的语义是“我把实现细节开放给子类”,而继承本身就容易造成强耦合,再配合 protected,父子类之间的内部状态就越缠越紧。

一个更稳妥的设计倾向是:优先 private,然后考虑包私有(默认),最后才考虑 protected/public。这个顺序不是在限制你,而是在保护你未来的重构空间。public 成员是 API 承诺,一旦别人用了,你就不能随便删改。private 成员说收就收,说改就改,完全不影响外部。

3. 继承:代码复用的双刃剑

3.1 继承语法与 is-a 关系

继承解决的是“共性提取”的问题。当你发现两个类有大量相同字段和方法时,可以把公共部分上提到父类,让子类 extends 它。

public class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat() { System.out.println(name + " 正在吃东西"); } } public class Cat extends Animal { public Cat(String name) { super(name); } public void meow() { System.out.println(name + " 喵喵叫"); } }

这里有一个判断标准:只有符合“is-a”关系,才适合用继承。猫是一种动物(Cat is an Animal),所以继承合理;但如果你有一个User类和一个DbUtils类,它们绝对不是 is-a 关系,那就可能不该靠继承硬凑。

新手最常见的错误,是为了省几行代码去继承。比如看到 A 类有个方法挺方便,就让 B 类 extends A 去复用这个方法。结果 B 和 A 八竿子打不着,逻辑上完全不成立。这种继承叫“实现继承的滥用”,它会让你未来的每一次修改都变得心惊胆战:改 A 里的一个方法,B 的行为莫名其妙跟着变。

3.2 super 关键字与构造器链的执行顺序

继承关系里有一个特别容易被忽略的执行顺序问题。创建子类对象时,父类的构造器会先执行。

public class Animal { public Animal() { System.out.println("Animal 构造器执行"); } } public class Cat extends Animal { public Cat() { System.out.println("Cat 构造器执行"); } } new Cat(); // 输出: // Animal 构造器执行 // Cat 构造器执行

为什么必须先执行父类构造器?因为子类对象“包含”父类定义的那部分字段和状态。如果父类字段还没初始化,子类构造器里用到它们就会出错。JVM 为了保证“先有基础,再扩展”,强制子类构造器必须在第一行调用super()或super(参数)。

如果你不在子类构造器第一行写 super,编译器会隐式插入一个无参的super()。这里就埋了一个雷:如果父类没有无参构造器,子类编译直接报错。

public class Animal { private String name; public Animal(String name) { // 只有有参构造器 this.name = name; } } public class Cat extends Animal { public Cat(String name) { // 没有显式super(name),编译错误 } }

解决办法是显式调用父类的有参构造器:

public Cat(String name) { super(name); }

我的建议是:给类设计构造器时,要么都显式写出来,要么保持父类始终有一个无参构造器。千万别依赖“编译器帮我补”,这种隐式依赖会在工程规模变大后变成排查不完的坑。

3.3 方法重写的三个纪律

子类可以重写父类的方法,但有三条硬规则,违反一条就是编译错误:

第一,重写的方法访问权限不能比父类更严格。父类是 public,子类就必须是 public;父类是 protected,子类可以 protected 或 public,但不能降级成默认或 private。原因是继承要保证“子类在任何父类能用的场景下都可用”,权限收窄就破坏了这种替代性。

第二,返回类型必须是父类方法返回类型的相同类型或其子类型,这叫协变返回。父类方法返回 Animal,子类可以返回 Cat,但不能返回 Object。

第三,不能抛出比父类方法更宽泛的受检异常。父类抛 IOException,子类抛 Exception 就不行;抛不抛 Runtime 类型(运行时异常)倒无所谓。

除了规则,我强烈建议重写方法时都加上@Override注解。它最大的价值是让编译器帮你检查你到底有没有真的“重写”而不是一不小心“重载”了。有一次我们项目里就出过这种事故:某开发者把父类的handle(String s)重写成了handle(Object o),参数列表变了,加号没打,直接变成了重载。运行时调用的还是父类逻辑,排查了大半天才发现问题。有@Override在,这种错误当场就能被编译器拦住。

还有一个知识点值得记住:静态方法、私有方法、构造器都不能被重写。静态方法的同名同参方法出现在子类里叫“隐藏”,调用哪个取决于引用类型,而不是对象实际类型。刚开始理解这部分会有点绕,我的建议是少用静态方法的多态,这也是后文要讲的设计倾向。

4. 多态:让代码面向未来

4.1 向上转型与动态绑定

多态是四大基石里最值钱的一个,也是新手最懵的一个。核心概念用一个例子讲清楚:

Animal a = new Cat("豆豆"); a.eat();

这里a的声明类型是 Animal,实际对象的类型是 Cat。程序运行时,调用 eat() 方法时,JVM 会找到 Cat 类里重写过的版本并执行它。这种“编译期看左边,运行期看右边”的机制叫动态绑定。

为什么需要这种机制?往大处说,多态让你的代码可以面向抽象编程。

举个例子,模拟一个喂食系统,没有任何多态时的代码长这样:

public void feed(Cat cat) { cat.eat(); } public void feed(Dog dog) { dog.eat(); } public void feed(Bird bird) { bird.eat(); }

每来一种新动物,你就要新增一个同名方法。而用多态改造后:

public void feed(Animal animal) { animal.eat(); }

不管以后来了猪、牛、马,只要它们 extends Animal 并重写 eat(),这个 feed 方法一次都不用改。这就是传说中的“开闭原则”的雏形:对扩展开放,对修改关闭。新增功能不求改老代码,只求加新类。

这一点放到框架源码里更明显。Spring 里到处是面向接口的编程;MyBatis 的插件机制、Spring 的 BeanPostProcessor,核心都是让主流程依赖抽象,具体实现在运行期才绑定。你在新手阶段理解“抽象 + 多态”,后面读这些框架源码会觉得顺很多。

4.2 重载与重写的纠结点

多态相关的两个概念,经常被新手混为一谈:重载(Overload)和重写(Override)。

对比维度重载 Overload重写 Override
发生位置同一个类里父类与子类之间
判断依据方法名相同,参数列表不同方法签名完全一致
访问权限随便改不能更严格
绑定时机编译期静态决定运行期动态绑定
返回值可以不同,但仅靠返回值不同不算重载需要相同或子类型(协变)

重载是编译期的静态行为:编译器根据你传入的实参类型,在编译时就确定调哪个方法。重写是运行期的动态行为:运行时才根据对象的实际类型找方法。

这里有一个实际影响:如果你在一个类里重载了几十个方法,调用时就要小心歧义。比如重载了print(String)和print(Object),你传 null 进去,编译器会优先选更具体的 String 版本,但如果你还重载了print(Integer),那传 null 就直接编译报错了——编译器无法判断 null 应该是哪个。

4.3 多态的实践价值:少写 if-else

很多人对多态的价值没有实感,觉得自己平时写代码都用不上。那我换一个场景,你立刻就能感受到差距。

假设你在做一个订单系统,要把订单推送给不同渠道,渠道有短信、邮件、App 推送。没有多态的版本:

public void push(Order order) { String channel = order.getChannel(); if ("sms".equals(channel)) { sendSms(order); } else if ("email".equals(channel)) { sendEmail(order); } else if ("app".equals(channel)) { sendAppNotification(order); } }

每接入一个新渠道,都要往这个长长的 if-else 里加一段。代码越来越长,而且改动时极容易误伤其他渠道的分支。

用多态重构后的版本:

public interface PushChannel { void push(Order order); } public class SmsChannel implements PushChannel { @Override public void push(Order order) { // 发送短信 } } public class EmailChannel implements PushChannel { @Override public void push(Order order) { // 发送邮件 } }

推送服务只需要持有接口引用:

public void push(PushChannel channel, Order order) { channel.push(order); }

以后加一个小程序推送,就新增一个类实现 PushChannel,老代码一行不动。配合一个工厂方法把渠道标识映射到实现类,整个新增流程变成:加类、注册、完事。

这就是“面向接口编程”的精髓。多态不要求你写多么复杂的代码,相反,它让你能写出更简单、更稳定的代码。好的设计不是炫技,是让未来的变更成本变低。

5. 新手最容易踩的坑

5.1 构造器继承的坑

前文已经提过父类没有无参构造器的情形,这里再补一个更隐蔽的版本。父类构造器里调用了可重写的方法:

public class Base { public Base() { init(); // 调用可重写方法 } protected void init() { System.out.println("Base init"); } } public class Sub extends Base { private int value = 10; @Override protected void init() { System.out.println("Sub init, value = " + value); } } new Sub(); // 输出:Sub init, value = 0

原因在于:父类构造器先执行,此时子类的 value 字段还没被赋值为 10,重写的 init() 读取到的还是默认零值 0。这就是“在构造器中调用可重写方法”的经典陷阱。

我的避坑建议很简单:构造器里只做最简单的字段初始化,绝不调用可重写的方法,更不能依赖子类实现。如果确实需要子类参与初始化,考虑用模板方法模式配合一个显式回调,也要注意调用时机放在字段赋值之后。

5.2 equals 与 hashCode 的坑

这是一个经常和封装、对象比较一起出现的问题。你以为你重写了 equals 就能正确比较对象,但如果你没同时重写 hashCode,开发时会遇到更隐蔽的问题。

先看例子:

public class Product { private String sku; public Product(String sku) { this.sku = sku; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; Product other = (Product) obj; return sku.equals(other.sku); } }

这个 Product 只重写了 equals,没有重写 hashCode。

然后你把它放进一个 HashSet:

Set<Product> products = new HashSet<>(); products.add(new Product("A-1001")); products.add(new Product("A-1001")); System.out.println(products.size()); // 竟然是 2

逻辑上两个产品的 sku 完全相同,应该只算一个,但 HashSet 里确实出现了两个元素。因为 HashSet 判断重复的逻辑是:先算 hashCode 定位桶,再用 equals 比较桶内元素。你没有重写 hashCode,所以两个 Product 的 hashCode 来自 Object 的默认实现(基于内存地址),它们被放进了不同的桶,equals 压根不会被调用。

正确的配套实现是:

@Override public int hashCode() { return sku.hashCode(); }

规则说起来也简单:两个对象 equals 相等,hashCode 必须相等;hashCode 相等,equals 可以不相等。反向不成立。这个约束不是为了刁难你,而是为了救你,尤其是把对象放进 HashMap、HashSet、HashTable 这类散列集合时。

5.3 继承层数过深与组合优先

继承能复用代码,但是继承树一旦深了,就是灾难。

我见过一个老项目,类层次是这样的:BaseDao继承自BaseObject,UserDao继承自BaseDao,SpecialUserDao继承自UserDao,一共四层。最顶层的 BaseObject 里改一个方法,下面三层全部受影响;底层子类要排查一个 bug,得把三层父类的代码全部读一遍。每次改动都像在抽掉积木底层,谁都不敢动。

业界有一个非常值得新手抄进笔记的强原则:组合优先于继承。

两个类如果只是“拥有”关系,那就用组合。比如Car有Engine,正确写法是:

public class Car { private Engine engine; public Car(Engine engine) { this.engine = engine; } }

而不是让Car extends Engine。组合的好处是耦合更少,你可以随时更换引擎的实现,而不用改变 Car 类的继承结构。

什么时候真的该用继承?当且仅当确实存在严格的 is-a 关系,而且父类抽象出来的是稳定的共性行为时,才考虑继承。其他时候,先想组合、先想接口、先想封装。

5.4 final 关键字的三重身份

final 在四大基石里算是个辅助角色,但初学者对它误解最深。

final 修饰变量,代表“只能赋值一次”。注意这个说法比“常量”更准确。比如:

public class Config { private final int maxRetryCount = 3; }

maxRetryCount 后面不能被重新赋值。这里的“常量”指的是引用不能变,如果它指向一个对象,对象的内部字段仍然可以变。

final 修饰方法,代表“子类不能重写这个方法”。如果你设计了一个父类,某个流程步骤不希望被改变,就可以把它定成 final。

final 修饰类,代表“这个类不能被继承”。最典型的是java.lang.String,它被设计成 final 类,就是怕有人继承它、篡改不可变性,引发字符串相关的一系列安全问题。你写工具类时也可以把类声明成 final,配合私有构造器,禁止继承和实例化。

最后提醒一点:在多线程环境下,final 字段还有一个特别的价值——可以保证发布安全。final 字段在构造器完成后,对所有线程都可见,不会出现半个对象被共享的诡异状态。不过这属于并发编程的范畴,新手阶段先记住“final 字段初始化后不能改”就够用了。

我个人带过不少实习生,也面试过很多候选人,发现一个规律:能把类、封装、继承、多态这四个概念放到真实代码里去理解的人,写出来的工程代码明显更抗折腾。相反,只知道背定义的人,一遇到框架源码就懵,一改业务就手忙脚乱。这些基石不是考试题,它们就是你每天都写在代码里的设计判断。

最后分享一个小习惯:每学一个概念,不要刷题,而是动手改造一段自己的烂代码。找一段以前写的全是 public 字段、if-else 堆业务、能怎么写就怎么写的项目,然后用封装、继承、多态的思路重新整理一遍。改完之后再回头对比,你会突然看懂原来那些“老手代码”为什么长那样。四个基石吃透了,后面的集合、泛型、并发、框架源码,都会学得轻松很多。

RELATED READING

延伸阅读

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