吃透Java面向对象四大基石:类、封装、继承、多态实战指南
发布时间:2026/10/15 23:23:42 锦皓数字建站

写 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();背后大约干了四件事类加载检查JVM 先看 Person 这个类有没有被加载过没有的话就先加载。类元信息存到方法区现在的元空间。分配堆内存在堆上划一块足够大的空间大小由类结构决定。零值初始化内存里的字段先全部设成默认值int 是 0boolean 是 false引用是 null。执行构造器调用构造器方法体给字段赋你传入的值。最后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。然后你把它放进一个 HashSetSetProduct 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继承自BaseObjectUserDao继承自BaseDaoSpecialUserDao继承自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 堆业务、能怎么写就怎么写的项目然后用封装、继承、多态的思路重新整理一遍。改完之后再回头对比你会突然看懂原来那些“老手代码”为什么长那样。四个基石吃透了后面的集合、泛型、并发、框架源码都会学得轻松很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。