ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java面向对象三大特性:封装、继承、多态实战与面试陷阱

Java面向对象三大特性:封装、继承、多态实战与面试陷阱 本来我不太想专门把“面向对象二”单独拎出来写一篇因为网上讲封装、继承、多态的文章实在太多了随便一搜就是一堆。但最近在带实习生、帮朋友看简历、做面试模拟的过程中我发现一个很典型的问题很多同学能把“面向对象”这几个字背得滚瓜烂熟知道什么是类、什么是对象甚至能说出“封装就是隐藏属性继承就是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继承BC继承AD继承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的编译类型是AnimalAnimal里没有定义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 重载与重写别再混淆了面试里几乎必问的一个问题就是“重载和重写的区别”。我用一张表说清楚对比项方法重载方法重写英文名OverloadOverride发生位置同一个类里子类和父类之间方法名相同相同参数列表必须不同必须相同返回类型不要求相同或协变访问权限不限不能比父类更严格绑定时机编译期决定调用哪个运行期动态绑定特别注意“重载发生在编译期”编译器根据你传入的参数个数和类型在编译阶段就决定调用哪个重载方法。而重写发生在运行期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-elsepublic 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 classinterface继承方式单继承多实现构造器有无字段可以有实例字段只能是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半天猜来猜去要高效得多。面向对象这块内容一轮学完只能算“知道”真正变成自己的东西至少要经历几个项目的捶打。多写、多重构、多问自己“这段代码改动时哪些地方会跟着变”慢慢就能体会到封装、继承、多态各自的价值边界了。
返回列表