本来我不太想专门把“面向对象(二)”单独拎出来写一篇,因为网上讲封装、继承、多态的文章实在太多了,随便一搜就是一堆。但最近在带实习生、帮朋友看简历、做面试模拟的过程中,我发现一个很典型的问题:很多同学能把“面向对象”这几个字背得滚瓜烂熟,知道什么是类、什么是对象,甚至能说出“封装就是隐藏属性,继承就是extends,多态就是重写”,但一到具体的代码场景里就露馅了。你问他重写和重载的区别,他能说出来;可你要是甩给他一段代码,让他判断这行方法调用到底走的是编译期绑定还是运行期绑定,他大概率要开始猜。
所以这篇文章我就接着“Java基础——面向对象”的节奏,把第二阶段的内容摊开讲清楚。默认你已经知道怎么定义类、怎么new对象、怎么调方法,我们把重心放到面向对象的三大特性:封装、继承、多态,以及它们在实际工程中的设计考量上。说白了,这一篇不是给你背八股文的,而是帮你把封装、继承、多态这些概念真正理解透,顺手解决面试里那些高频陷阱。
1. 面向对象的三大特性:先建立整体认知
1.1 为什么一定要把封装、继承、多态放在一起理解
很多初学者学三大特性是分开学的:先学封装,知道字段要private、方法要public;再学继承,知道子类能拿父类的东西;最后学多态,知道父类引用可以指向子类对象。学完之后每个都懂,但一写项目就不知道该用哪个、怎么配合。这个问题的根源在于,三大特性从来不是三个独立的知识点,它们是一套环环相扣的设计思路。
你可以把封装理解成“定义清楚谁负责什么”。一个类把自己的内部状态藏起来,只对外暴露必要的方法,这样外部调用者不需要关心内部实现,类自己也能在内部自由调整逻辑而不影响外部。这是整个面向对象设计的地基。
继承是在封装基础上做“复用和扩展”。子类继承父类,拿到父类的属性和方法,同时可以补充自己特有的内容,或者重写父类的方法来改变行为。但继承如果滥用,类与类之间就会变成一张纠缠不清的关系网,所以后来大家才强调“组合优先于继承”。
多态则是在继承的前提下,让代码“面向抽象编程”。你写代码的时候对着父类或接口写,运行的时候真正执行的是某个子类的实现。这样新增一个子类,调用方的代码一行都不用改。
一句话总结:封装管好内部,继承管好复用,多态管好扩展。三者配合起来,代码才真正具备可维护性和可扩展性。把这三个东西当成一个整体去理解,比你单独背任何一个概念都重要。
1.2 这段内容该怎么学:从“能写代码”到“能说清楚”
我见过很多人的学习方式是看一遍视频、抄一遍代码,觉得自己会了。但面试或者实际开发里,真正拉开差距的是“能不能把设计逻辑讲清楚”。比如你写了一个动物类Animal,一个子类Dog,然后写Animal a = new Dog(),你得能说清楚:a的编译类型是谁,运行时类型是谁,a能调用哪些方法,调用时执行的是哪份代码。这些才是多态的核心。
所以建议的学习路径是这样的:
- 第一步,把封装做好:给类的每个字段思考一下访问权限,别一上来就public。
- 第二步,动手写继承:至少写一组“父类-子类”代码,感受构造方法之间的调用关系。
- 第三步,刻意练习多态:写一个父类引用指向不同子类对象的例子,观察方法调用行为。
- 第四步,把抽象类和接口用起来:不光是会用extends和implements,还要能说出什么时候选抽象类、什么时候选接口。
- 第五步,回到面试题:拿高频八股文来检验自己能不能“一句结论+一段解释+一个例子”地讲清楚。
这个顺序其实就是从机械记忆走向理解设计的过程。后面几个章节我就按照这个顺序,一个特性一个特性地拆。
2. 封装:把不该让别人动的数据藏起来
2.1 访问修饰符使用心得
封装落地最核心的工具就是访问控制。Java提供了四个访问级别,很多人背过那张表,但真正写代码的时候全是凭感觉。我直接说实战里怎么用。
private:字段默认用这个,只有类自己能访问。这是封装的第一道防线。你想让外部读,就提供getter;想让外部改,就提供setter。如果字段不允许改,那setter都不要写。
默认(包私有):同一个包里的类能访问。这个级别挺容易被忽略的,但在同一个包内部协作时很有用,尤其在写一些结构紧凑的组件时,包私有能减少不必要的getter/setter暴露,让代码更清爽。
protected:子类和同包类可以访问。这个用得少,但要注意,protected不是一个“对外开放”的口子,它是对“继承体系内”开放的口子。如果子类不在继承体系里,它同样不能通过创建父类对象来访问protected成员。
public:对外API才用。类、方法、常量,需要暴露给外部使用的时候才用public。能不放public就不放,这是控制复杂度的基本手段。
我见过不少新手写代码,不管三七二十一全给public,效果就是类的封装性形同虚设,外部随便改内部状态,改到非法值也没人知道。比如一个账户类,余额字段是public,外部一行代码account.balance = -100就完蛋了。如果balance是private,再有setter做校验,非法数据就能被拦截住。
2.2 getter/setter 的正确写法
关于getter/setter,有个挺常见的误区:以为封装就是加getter/setter,于是所有private字段都配一对getter和setter,结果数据还是被外面随便改,只是改的方式绕了一圈而已。这种“伪封装”比不封装还难受。
正确的做法是:setter里要有业务校验,getter里可以做防御性拷贝,而且能不给setter就不给。我举个例子:
public class User { private String name; private int age; public User(String name, int age) { this.name = name; setAge(age); } public String getName() { return name; } public int getAge() { return age; } public void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("age must be between 0 and 150"); } this.age = age; } }这个类只对外暴露了年龄的修改入口,而且setAge里做了校验,年龄不合法直接抛异常。名字干脆不给setter,初始化之后就不让改了。这样外部想塞一个-1的年龄进来,连门都没有。
还有一点值得注意:如果类里有List、Map这类引用类型字段,getter直接返回内部引用,外部就能通过这个引用偷偷改你内部的数据。稳妥的做法是返回一个不可修改的副本。这个细节在很多java面向对象面试题里会被拿来考,叫“对象深拷贝与防御性拷贝”。至于深拷贝和浅拷贝的坑,后面我专门放到面试陷阱部分讲。
2.3 封装不是万能的:过度封装的代价
封装也有反作用。如果一个类把所有东西都藏起来,外部想做一点扩展都要改原类代码,这个类就会变得又臃肿又难用。比如工具类,明明可以只用public静态方法,你非要设计成先new一个实例再调用,那纯属给使用者添堵。
我自己的经验是:封装的对象是“变化点”和“数据一致性”,不是所有成员。能确定不变的配置、工具方法,直接暴露静态方法没问题;但是有内部状态、需要保证状态合法的对象,一定要把字段藏好,把修改入口控制住。
提示:判断封装是否过度,就看外部使用者能不能用最少的心智负担完成他的任务。如果使用者必须read你的源码才能搞懂怎么调用,那就是封装失败。
3. 继承:代码复用与类层次设计
3.1 extends 背后的内存模型与初始化顺序
继承是Java里复用代码最直观的手段。子类通过extends拿到父类的非私有属性和方法,同时还能定义自己的内容。但继承有个很容易踩坑的点:对象的初始化顺序。
我直接说结论,然后给代码验证。一个子类对象被创建的时候,执行顺序是这样的:
- 加载父类的静态代码块和静态变量
- 加载子类的静态代码块和静态变量
- 执行父类实例变量初始化和实例代码块(按代码顺序)
- 执行父类构造方法
- 执行子类实例变量初始化和实例代码块(按代码顺序)
- 执行子类构造方法
很多初学者以为new子类对象就是直接调子类构造器,其实子类构造器第一行会隐式调用父类的无参构造器,也就是super()。如果父类没有无参构造器,子类构造器里必须显式调用父类的某个有参构造器。
class Animal { private String name; public Animal(String name) { this.name = name; System.out.println("Animal构造方法执行"); } public void eat() { System.out.println(name + " 在吃东西"); } } class Dog extends Animal { public Dog() { super("小黑"); System.out.println("Dog构造方法执行"); } public void bark() { System.out.println("汪汪"); } }输出顺序是:
Animal构造方法执行 Dog构造方法执行看到没有,先父后子。这个顺序保证了子类对象在使用父类字段的时候,父类部分已经被完整初始化好了。如果父类没有无参构造器,而子类又没有显式调super(参数),编译器会直接报错。这个报错的解决办法就是先看看父类定义了哪些构造器,然后在子类构造器第一行匹配调用。
3.2 方法重写的规则与 @Override 的价值
继承里最核心的动作是重写。子类可以重写父类的方法,提供自己的实现。但重写不是想怎么写就怎么写,有五个规则必须遵守:
- 方法名必须相同。
- 参数列表必须相同。
- 返回值类型可以相同,也可以是父类方法返回类型的子类型(这叫协变返回类型)。
- 访问权限不能比父类更严格,即子类方法的访问权限必须大于或等于父类方法的访问权限。
- 抛出的受检异常不能比父类方法更多、更宽泛。
第4条是很多人容易忽略的。父类方法是public,子类重写时就不能改成protected或默认权限,否则编译器会报错。至于为什么,你可以这么理解:子类对象本质上是父类对象的一种,外部拿着父类类型的地方都能调用这个public方法,如果你在子类里把它藏起来,那就破坏了“子类能替代父类”的规则。
写重写方法的时候,强烈建议加@Override注解。这个注解有两个作用:一是让编译器帮你检查重写规则,如果你的方法签名写错了,编译器直接报错;二是代码阅读者一眼就能看出这是重写方法,不是新方法。不加这个注解,方法签名一写错,你以为在重写,实际上新建了一个方法,运行的时候根本没走你预期的那段逻辑,找bug能找一晚上。
class Dog extends Animal { @Override public void eat() { System.out.println("小黑 在啃骨头"); } }这种就对了。但如果父类方法叫eat,你写成了eats,没加注解编译器根本不报错,硬是产生一个看起来很像但不是重写的方法。这就是注释和多写一个字母的代价。
3.3 继承不是银弹:组合优先于继承
继承用起来爽,但滥用起来也很吓人。最经典的场景就是“点状继承”:A继承B,C继承A,D继承C,最后改一次父类,子类集体遭殃。实际上,Java只支持单继承,一个类只能有一个父类,这个限制就是为了避免菱形继承那种复杂度。
更合理的做法是优先考虑组合。组合就是把另一个类的对象作为自己的字段,通过调用它的方法来复用能力。组合的好处是关系明确,不会隐式继承一堆不想要的方法;缺点是要多写一点代码,不能像继承那样直接拿来用。
判断什么时候用继承、什么时候用组合,有一个简单的测试:如果A是B的一种(is-a),考虑继承,比如Dog is an Animal;如果A拥有B的能力(has-a),考虑组合,比如Car has an Engine,而不是让Car extends Engine。
比如要写一个ArrayList版本的线程安全集合,正确的做法是组合:内部持有一个List对象,对每个方法加锁后再转发。如果你继承ArrayList再重写方法,那外部只要通过父类型List去调用,绕过你的重写方法,线程安全就白做了。
4. 多态:同一个方法,不同的表现
4.1 向上转型和动态绑定
多态的前提是继承和方法重写。最常见的表现形式就是把子类对象赋值给父类类型的引用,这叫向上转型。
Animal a = new Dog(); a.eat();如果你问我“a.eat()到底调用谁的实现”,答案是:编译时看的是Animal这个类型有没有eat方法;运行时会动态绑定到实际对象Dog的eat方法上。这就是“编译看左边,运行看右边”。
这里面有一个超级常见的坑:很多初学者以为把这个赋值写出来就算多态了,然后试图用a去调用Dog独有的bark方法,比如a.bark()。结果编译报错:找不到符号。原因很简单,a的编译类型是Animal,Animal里没有定义bark方法,编译器不会让你通过这个引用调用子类特有的方法。如果你非要调用,就得向下转型:
if (a instanceof Dog) { Dog d = (Dog) a; d.bark(); }这里用instanceof判断一下再转型,是防止ClassCastException最稳妥的做法。Java 16之后,你还可以用instanceof模式匹配来简写:
if (a instanceof Dog d) { d.bark(); }4.2 重载与重写:别再混淆了
面试里几乎必问的一个问题就是“重载和重写的区别”。我用一张表说清楚:
| 对比项 | 方法重载 | 方法重写 |
|---|---|---|
| 英文名 | Overload | Override |
| 发生位置 | 同一个类里 | 子类和父类之间 |
| 方法名 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 |
| 返回类型 | 不要求 | 相同或协变 |
| 访问权限 | 不限 | 不能比父类更严格 |
| 绑定时机 | 编译期决定调用哪个 | 运行期动态绑定 |
特别注意“重载发生在编译期”:编译器根据你传入的参数个数和类型,在编译阶段就决定调用哪个重载方法。而重写发生在运行期,JVM根据对象的实际类型来调用对应版本。理解了这一点,很多看似“诡异”的代码输出你就能猜对了。
来,我甩个经典例子:
public class demo { public static void main(String[] args) { Animal a = new Dog(); helper(a); } public static void helper(Animal animal) { System.out.println("animal"); } public static void helper(Dog dog) { System.out.println("dog"); } }你猜输出什么?是“animal”。因为调用helper的时候,参数a的静态类型是Animal,编译器在重载选择阶段看到的就是Animal,根本不会去选带Dog参数的版本。这就是为什么我说重载是编译期行为。如果你希望它调用Dog版本,你得先强转。
4.3 多态的实际收益:面向扩展的设计
学习多态不能停留在语法上,要体会到它的实际价值。我给你一个最经典的例子:动物园饲养员喂食。
不用多态的时候,你可能写出这样一坨if-else:
public void feed(Animal a) { if (a instanceof Dog) { System.out.println("喂骨头"); } else if (a instanceof Cat) { System.out.println("喂鱼"); } else if (a instanceof Panda) { System.out.println("喂竹子"); } }每次加一种新动物,你都要改这个feed方法,加一个else if。这违背了开闭原则。用多态就漂亮多了:
public void feed(Animal a) { a.eat(); }新加动物的时候,你只需要写一个新子类重写eat方法,主流程一行都不用动。这就是多态的核心收益:把变化隔离在新增的类里,而不是散落在原有的业务逻辑中。
5. 抽象类与接口:面向抽象编程的两把钥匙
5.1 抽象类与接口的区别
说完了三大特性,必然要提到抽象类和接口。它们不是三大特性本身,但它们是你在Java里实现面向对象设计最重要的工具。
抽象类是“不完整的类”,它可以有抽象方法,也可以有具体方法,甚至可以有字段和构造器。接口刚开始是“完全的抽象”,Java 8之后有了默认方法和静态方法,Java 9之后还可以有私有方法,所以接口的能力边界也放宽了很多。
我直接放一张对比表:
| 对比项 | 抽象类 | 接口 |
|---|---|---|
| 关键字 | abstract class | interface |
| 继承方式 | 单继承 | 多实现 |
| 构造器 | 有 | 无 |
| 字段 | 可以有实例字段 | 只能是public static final常量 |
| 普通方法 | 可以有具体实现 | Java 8后可以有default/static方法 |
| 抽象方法 | 可以有 | 可以有 |
| 设计语义 | is-a,强调“是什么” | can-do,强调“能干什么” |
选择的原则很简单:如果多个类之间有本质的层级关系,公共代码很多,用抽象类;如果只是想约定能力,让天南海北的类都能统一调用,用接口。
5.2 接口默认方法带来的变化
Java 8引入默认方法之后,接口不再是纯抽象的了。比如你要给一个接口新加一个方法,如果加的是抽象方法,所有实现类都必须改;如果加的是default方法,实现类可以选择继承默认实现。
默认方法也能解决一个经典难题:多实现接口时的行为复用。但随之而来的问题是“菱形冲突”:一个类实现两个接口,这两个接口有同名的default方法,实现类必须显式重写这个方法,否则编译报错。
我建议在实际工程里,default方法只用来提供“渐进式接口扩展”或者“公共的兜底实现”,不要把它当成主要的手段去发挥。核心的业务逻辑还是放在实现类里更清晰。
5.3 抽象类与接口的实战组合
真正写项目的时候,抽象类和接口经常一起用。我举一个支付场景的例子:
public interface Payment { void pay(double amount); default void refund(double amount) { System.out.println("默认退款逻辑,金额:" + amount); } } public abstract class AbstractPayment implements Payment { protected String merchantId; public AbstractPayment(String merchantId) { this.merchantId = merchantId; } @Override public void pay(double amount) { if (amount <= 0) { throw new IllegalArgumentException("金额必须大于0"); } doPay(amount); } protected abstract void doPay(double amount); } public class WechatPay extends AbstractPayment { public WechatPay(String merchantId) { super(merchantId); } @Override protected void doPay(double amount) { System.out.println("微信支付:" + amount); } }这个结构里,接口定义能力,抽象类定义公共流程和字段,具体子类只负责实现差异部分。扩展新支付方式时,只要新增一个子类,主流程不用改。这就是面向对象设计在实际项目里常见的落地姿势。
6. 面试高频陷阱与实战排错
6.1 面向对象高频面试题速答
“java面向对象”相关的面试题太多了,我把几个实战中高频的整理一下,不是让你死记硬背,而是帮你抓重点。
第一个是“构造方法能不能重写”。答案是不能。构造方法名必须和类名相同,子类方法名不可能和父类同名,所以没有重写的概念。但构造方法可以重载,一个类可以有多个不同参数列表的构造器。
第二个是“父类私有方法能不能被重写”。不能。private方法对子类不可见,子类写一个同名同参方法只是在定义自己的方法,不算重写。类似地,static方法也不算重写,它只是被隐藏了。
第三个是“String能被继承吗”。不能,String是final类。这也是Java面试常考的“底层设计”问题:字符串被大量使用,不允许被继承是为了保证字符串对象的安全性和不可变性。
第四个是“==和equals的区别”。这是一个被问了无数遍的题。==比较的是引用是否指向同一个对象;equals是Object类里的方法,默认也是比较引用,但很多类重写了它,比如String的equals比较的是内容。所以写代码时要清楚你操作的是引用还是值。
第五个是“对象深拷贝和浅拷贝”。浅拷贝只拷贝最外层的对象,里面的引用类型字段依然指向同一个对象;深拷贝要连同内部引用对象一起拷贝。实现深拷贝要么重写clone方法并逐层拷贝,要么用序列化、要么用构造函数手动new新的内部对象。在面试里能把这个讲明白,是很加分的。
6.2 常见报错与排查思路
写Java面向对象代码最常见的报错大概有这几类:
ClassCastException,类型转换异常。一般发生在向下转型时,对象实际类型和要转的类型不匹配。排查思路是转型前先用instanceof判断。
NullPointerException,空指针。常见场景是递归调用重写方法时内部状态没初始化,或者父类构造方法里调用了被子类重写的方法,而此时子类字段还没初始化。这类问题在继承体系里特别容易出现,建议父类构造器里不要调用可能被重写的方法。
StackOverflowError,栈溢出。常见于继承体系里的递归调用,比如重写equals方法时,在里面又调用另一个对象的equals,如果两个对象互相引用或者逻辑没写好,就可能无限递归。
NoSuchMethodError。经常出现在依赖版本不一致的场景里,但也有一种情况是你以为的重写方法实际上没重写成功,编译器IDE报错提醒你没加@Override的时候一定要重视。
6.3 我给初学者的避坑清单
- 字段一律先private,除非有明确理由暴露。
- 所有重写方法都加@Override注解。
- 父类构造方法里不要调用可被重写的方法。
- 扩容方法时,优先考虑接口和组合,不要一上来就继承。
- 写equals()就必须同时重写hashCode(),这是硬性约定。
- 定义了有参构造器,记得考虑无参构造器是否需要保留,很多框架和反序列化工具要用到无参构造器。
- 使用Lombok的时候要注意,@Data可能生成你根本不想要的setter,破坏封装。
最后再分享一个我自己用得最多的调试技巧:多态环境下搞不清方法走哪个实现时,直接在IDEA里给方法打断点,用调试模式看调用栈,马上就能知道是哪个类的哪个方法被执行了。这比你在代码里println半天猜来猜去要高效得多。
面向对象这块内容,一轮学完只能算“知道”,真正变成自己的东西至少要经历几个项目的捶打。多写、多重构、多问自己“这段代码改动时,哪些地方会跟着变”,慢慢就能体会到封装、继承、多态各自的价值边界了。