ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

封装继承多态:Java面向对象设计的灵魂与实战避坑指南

封装继承多态:Java面向对象设计的灵魂与实战避坑指南 封装、继承、多态这三个词任何一个Java开发者都能说上几句。但我这些年面试过不少人能把概念背得滚瓜烂熟一追问“为什么需要多态”“继承和组合到底怎么选”就露馅了。这道题难吗不难。难的是大家一直拿教科书上那套定义来理解从来没想过这三个设计在真实工程里到底承担什么角色。这篇文章我不打算念概念而是把Java面试和日常开发中最容易卡壳的地方一次性捋清楚封装到底在封装什么、继承在继承什么、多态为什么是面向对象设计的灵魂以及你真正写代码时很容易踩进去的坑。无论你是准备Java面试还是写了两三年业务代码想彻底理清基础逻辑这篇都值得花十分钟看完。我尽量用代码说话配合大量实操场景把那些“面试题标准答案之外”的东西一并讲透。1. 三大特性的整体设计它们到底在解决什么问题1.1 封装、继承、多态不是三个孤立知识点很多教程喜欢把三大特性拆开讲讲完封装讲继承讲完继承讲多态听着很有条理。但这样做的后遗症是面试时一问“封装做了什么”能答上来一问“为什么有了封装还需要继承”“多态和继承是什么关系”就卡壳。这不是你学习不努力而是知识组织方式出了问题。我们得先把这三个概念放回同一个坐标系里看。Java是面向对象语言面向对象的核心诉求是什么是管理系统的复杂度让代码能应对变化。一个程序写出来不是一锤子买卖后面有需求变更、有bug修复、有不同的业务方接入。三大特性的本质是围绕“对抗变化”这件事提出的三套不同的武器封装把变化的细节关在黑盒子里外部只看到稳定的接口。继承把公共的、不变的部分提取到父类让子类在父类基础上变化。多态让外部用统一的方式操作一群“会变化”的对象具体表现谁来做运行时再决定。这样看就清楚多了封装修的是“别人怎么用你”继承管的是“你怎么复用和扩展别人”多态管的是“你如何让一堆不同的东西共用同一套调用方式”。三者的目标一致手段不同组合起来才是一个完整的设计体系。1.2 面向对象的核心诉求让代码能“应对变化”举一个大家都熟悉的支付场景。早期系统里只有支付宝支付你写了一个AliPay类里面有一个pay()方法参数是订单金额。后来要接微信支付你怎么办最原始的写法是再加一个WeChatPay类然后在业务代码里这么判断if (payType.equals(ali)) { new AliPay().pay(amount); } else if (payType.equals(wechat)) { new WeChatPay().pay(amount); }每接入一种新支付方式业务代码就要加一个if-else。这就是没有面向对象设计能力的典型坏味道。如果一开始就定义了一个Payment接口或者抽象类把pay()作为统一入口不同的支付渠道各自实现自己的逻辑业务代码只面对Payment这一个类型那么新增支付方式时业务代码一行都不用改。这件事能做到靠的正是封装各家自己的实现细节不暴露、继承/接口统一契约、多态运行时真实掉进对应的实现三者的配合。所以别再孤立地背“封装是什么、继承是什么、多态是什么”了。你先要有问题意识我这套代码以后会怎么变围绕这个“变”字去思考三大特性各自在哪里发力才算真正看上懂。1.3 三者之间的协同关系把三大特性看作一条流水线你会发现它们之间有天然的依赖顺序。先有封装一个类的内部状态、内部实现细节都藏起来对外只提供稳定、语义清晰的方法。这样外部代码和这个类之间的耦合就被降到了最低。这是整个体系的基石没有封装后续的继承和多态都建立在沙滩上。再有继承多个类之间有共同的行为和属性我们把公共部分上提到父类子类基于父类扩展差异。继承让类型之间形成层级关系为什么需要这个层级因为只有存在类型层级后面多态的“向上转型”才有意义——父类引用持有子类对象才能用同一个变量调出不同的行为。最后是多态因为有了继承体系和接口契约调用方可以完全面向抽象编程。对象到底是哪个具体实现在运行时才绑定。这一下就把“做什么”接口和“怎么做”实现彻底解耦了。一句话总结三者的协同**封装保证每个类自身可靠继承让类与类之间形成合理的关系多态让这个关系在运行时爆发出灵活性。**三者缺一个面向对象设计都会塌半边天。2. 封装从“字段私有”到“接口契约”2.1 封装的本质不是getter/setter封装可能是三个概念里被误解最深的。大家默认“private字段 getter/setter 封装”但这只是实现手段不是目的。我见过太多实体类长这样public class User { private String name; private int age; private String idCard; public String getName() { return name; } public void setName(String name) { this.name name; } public int getAge() { return age; } public void setAge(int age) { this.age age; } public String getIdCard() { return idCard; } public void setIdCard(String idCard) { this.idCard idCard; } }每个字段都有getter和setter内部状态随便改。这哪是封装这是给外部把所有后门都焊开了。封装的真正含义是信息隐藏对象对外暴露的是行为而不是内部状态。外部调用者不需要也不应该知道对象内部有哪些字段、怎么存储的他只需要调用一个表达业务意图的方法。拿银行账户举例外部不应该能直接setBalance(99999)那是抢劫。正确的封装是提供deposit()和withdraw()这样的行为方法把业务规则比如余额不能为负、取款金额不能大于余额守住在对象内部外部永远碰不到原始字段public class BankAccount { private final String accountNo; private BigDecimal balance; public BankAccount(String accountNo, BigDecimal initialBalance) { this.accountNo accountNo; this.balance initialBalance; } public String getAccountNo() { return accountNo; } public BigDecimal getBalance() { return balance; } public void deposit(BigDecimal amount) { if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(存款金额必须大于0); } this.balance this.balance.add(amount); } public void withdraw(BigDecimal amount) { if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(取款金额必须大于0); } if (amount.compareTo(this.balance) 0) { throw new IllegalArgumentException(余额不足); } this.balance this.balance.subtract(amount); } }调用者拿不到balance的直接操作权他只能“存款”或“取款”。余额怎么加减、规则怎么校验全部封装在方法内部。就算以后银行改了规则比如取款需要收手续费调用方代码一行不用动改的只是withdraw()的实现。这就是封装的真正价值让变化的成本被限制在一个类内部。2.2 访问控制每个修饰符背后都是一种设计意图Java给封装提供了四种访问控制级别很多新手只记住了“私有就不能访问”其实每个修饰符都对应一种设计意图访问修饰符当前类同包子类任意类典型用途privateyesnonono真正的内部状态外部一律不可见default不写yesyesnono包内协作包外隔离protectedyesyesyesno专门留给子类扩展的钩子publicyesyesyesyes对外暴露的稳定接口这里有两个点经常有人搞混。第一个是protected和default的区别default允许同包访问但不允许不同包的子类访问protected允许不同包的子类访问。换句话说protected是为继承准备的。第二个点是能用private就不用default能用default就不用protected能不用public就不用public。访问范围越窄后续重构的自由度越大。每次把一个字段或方法声明成public你实际上是在对外承诺“这东西我以后不会随便改”。我团队里做code review时最常提的意见就是getter也不是不能有关键是看返回值暴露的是不是内部可变状态。比如返回一个List如果你直接把内部的ArrayList返回出去外部就能list.add()往里塞数据封装就被绕过了。正确的做法是返回一个不可变视图或者做一份拷贝private final ListString hobbies; public ListString getHobbies() { return Collections.unmodifiableList(hobbies); }2.3 不可变设计与防御性拷贝封装的高级形态很多人以为不可变对象immutable object跟封装没关系其实不可变是封装的一种极致表现对象一旦创建内部状态完全不可变所有字段在构造时就固定下来。这样一来对象自带线程安全属性在任何地方共享都不会出问题。设计一个不可变类有几个硬性要求类用final修饰防止子类重写方法破坏不可变性。所有字段用final修饰。不提供任何setter。构造时对可变字段做防御性拷贝。getter返回的如果是可变对象也要返回拷贝或不可变视图。防御性拷贝是很多人容易漏的一环。看这个例子public final class Person { private final String name; private final ListString hobbies; public Person(String name, ListString hobbies) { this.name name; this.hobbies new ArrayList(hobbies); // 防御性拷贝 } public ListString getHobbies() { return new ArrayList(hobbies); // 返回拷贝 } }如果不做构造时的拷贝外部传入的List被修改之后Person对象内部状态也跟着变了等于给了外部一个后门。如果不做getter时的拷贝外部拿到内部List的引用后依然可以往里面塞东西。这两处都得守住不可变设计才真的成立。实际业务中不可能所有对象都做成不可变但思路是通用的**尽可能减少外部可操作内部状态的路径路径越少你的类越安全、越容易演进。**这也是封装这门手艺最核心的心法。3. 继承类型体系的构建与“is-a”关系的代价3.1 继承到底继承了什么字段、方法、类型身份继承在Java里是用extends关键字实现的一个子类只能继承一个父类但一个接口可以继承多个接口一个类可以实现多个接口。这个“单继承多实现”的设计是Java作者深思熟虑的结果后面我们再展开谈为什么。很多人问继承到底得到了什么表面上看子类会得到父类中所有非private的字段和方法。但比代码复用更关键的是**子类同时获得了父类的类型身份。**一个Dog继承了Animal那么Dog不仅是Dog它还是一个Animal。这意味着任何接受Animal类型参数的方法都可以传Dog进去。这种“is-a”关系让代码能够针对父类编程而实际处理各种各样的子类多态就建立在这层类型身份之上。但这里有个特别多人栽过的坑字段隐藏field hiding。如果子类里定义了和父类同名的字段两个字段其实是各自独立的类型都是“Animal”的引用访问该名字的字段时拿到的是Animal那个类型是“Dog”的引用访问同名字段时拿到的是Dog那个。这很容易造成诡异的行为所以实际编码建议是永远不要在子类里定义与父类同名的字段。继承的正确使用姿势是把公共的、稳定的、被多个子类共享的代码放父类把差异化的行为留给子类去Override。简单说父类负责“骨架”子类负责“血肉”。3.2 初始化顺序与构造器链一个让很多人翻车的细节面试里最常考、也让很多人答不上来的一个细节是子类对象创建时初始化顺序到底是怎样的。先说结论一个类从加载到实例化顺序是父类静态块 → 子类静态块 → 父类实例块 → 父类构造器 → 子类实例块 → 子类构造器。注意两点静态块只在类第一次被加载时执行一次实例块和构造器每次new都会执行。看这段代码class Animal { static { System.out.println(Animal 静态块); } { System.out.println(Animal 实例块); } public Animal() { System.out.println(Animal 构造器); } } class Dog extends Animal { static { System.out.println(Dog 静态块); } { System.out.println(Dog 实例块); } public Dog() { System.out.println(Dog 构造器); } } public class InitOrderTest { public static void main(String[] args) { System.out.println(--- 第一次创建 ---); new Dog(); System.out.println(--- 第二次创建 ---); new Dog(); } }输出结果是--- 第一次创建 --- Animal 静态块 Dog 静态块 Animal 实例块 Animal 构造器 Dog 实例块 Dog 构造器 --- 第二次创建 --- Animal 实例块 Animal 构造器 Dog 实例块 Dog 构造器为什么父类构造器会在子类构造器之前执行因为子类构造器的第一行会隐式调用super()也就是父类的无参构造器。如果父类没有无参构造器子类构造器就必须显式super(参数)否则编译直接报错。所以写继承体系时一个最基本的原则是要么给父类保留一个无参构造器要么让所有子类都明确调用父类的有参构造器。3.3 重写规范与里氏替换继承不是拿来“随便用”的子类可以对父类的方法进行重写但重写不是随意的有几个硬性规则方法签名必须一致方法名、参数列表完全相同。返回类型必须相同或者是原返回类型的子类型协变返回类型。访问修饰符不能比父类更严格。抛出的受检异常不能比父类更宽泛。举个例子父类方法返回Number且是public子类方法返回Integer且是public这个重写是合法的因为Integer是Number的子类、权限也没收紧。反过来如果子类把public改成protected编译直接报错。因为一旦调用方拿着父类类型来操作他预期这个方法是public可用的结果真实对象是子类、方法级别被降低了这在运行时会造成访问冲突。Java编译器干脆在编译期就拦住这种设计。比语法规则更重要的是设计层面的约束也就是里氏替换原则所有能使用父类对象的地方必须能无缝替换成任意子类对象且不改变程序的正确性。经典的负面例子是“鸟会飞”。定义Bird类有一个fly()方法然后让Penguin继承Bird。企鹅不会飞啊你只好在Penguin.fly()里抛一个UnsupportedOperationException。这时调用方拿着Bird引用调用fly()程序就乱了。这个继承设计从根上就是错的。正确的做法是把飞行能力抽象成一个独立接口Flyable只有真正会飞的动物去实现它。所以在判断“要不要用继承”时先问一句子类和父类之间真的是严格的“is-a”关系吗如果有一个子类无法履行父类的所有承诺这个继承体系就该拆掉。3.4 组合优先什么时候真的该用继承职业开发者基本都听过这句话“优先使用组合而不是继承”。为什么因为Java的继承有一些天生的限制和副作用继承关系在编译期就固定了运行时不能动态改变。子类继承了父类所有非私有成员可能被迫接受一堆用不到的东西。继承会破坏封装子类可以访问protected成员父类内部实现一改子类可能跟着遭殃。这就是著名的“脆弱基类问题”。组合的思路则是一个类内部持有另一个类的引用通过调用被组合对象的方法来实现功能而不是通过继承去“变成”它。比如一辆车有引擎但车不是一个“引擎”车和引擎之间是“has-a”关系应该用组合class Engine { void start() { System.out.println(引擎启动); } } class Car { private final Engine engine new Engine(); void start() { engine.start(); // 组合把功能委托给被组合对象 } }那什么时候该用继承我的判断标准有三条子类和父类之间存在严格的“is-a”关系并且所有子类都能兑现父类的全部承诺。子类确实大量复用父类的公共实现而不只是想要父类的类型身份。父类的设计本来就打算被继承有受保护的扩展点而且你不会频繁去改父类内部。如果只有一条满足优先考虑接口 组合。接口只定义契约不带来实现依赖比继承结构更灵活。这也是为什么Java允许一个类实现多个接口接口暴露的是“能力契约”一个类同时是Runnable、Serializable、Comparable完全合理但一个类同时“是”两个东西在类型体系上就很难自洽单继承阻止了这种混乱。4. 多态动态绑定是Java面向对象设计的灵魂4.1 编译时多态和运行时多态别再混为一谈多态这个词被用得有点滥很多人口中“一个接口多种实现”其实只是冰山一角。严格来说Java里的多态分成两类编译时多态方法重载Overload。同一个类里方法名相同、参数列表不同在编译阶段编译器就能确定该调用哪个版本。这是静态绑定。运行时多态方法重写Override。子类重写了父类方法编译期根本不知道实际对象是谁要等程序运行起来JVM根据对象的真实类型去决定调用哪个方法。这是动态绑定。面试里我最爱问的一个入门题就是下面这段代码greet()到底调用的是哪个版本class Animal { void greet() { System.out.println(Animal greet); } } class Dog extends Animal { Override void greet() { System.out.println(Dog greet); } } public class Demo { public static void main(String[] args) { Animal a new Dog(); a.greet(); } }答案很多人都会说调用的是Dog的greet()。因为变量的类型虽然是Animal但真实对象是Dog动态绑定会在运行时去寻找Dog里的实现。这个例子简单但它背后是整个多态机制的核心逻辑编译看左边类型运行看右边对象。真正容易翻车的是重载情况。看下面的代码public class OverloadDemo { void print(String s) { System.out.println(print String); } void print(Object o) { System.out.println(print Object); } public static void main(String[] args) { OverloadDemo demo new OverloadDemo(); Object obj hello; demo.print(obj); // 输出 print Object因为编译期obj是Object类型 } }变量obj的编译类型是Object局部变量的多态性在这里失效重载匹配发生在编译期跟着编译类型走所以调用的是print(Object)。除非你把obj强转成String再传。这一题能刷掉一票自称“精通多态”的候选人。4.2 动态绑定机制JVM是怎么找到正确方法的既然叫“动态”绑定那JVM到底是怎么在运行时找到正确方法的这里简单讲一下底层机制不涉及太深的虚拟机源码但理解了它对排查问题极有帮助。JVM在类加载时会给每个类生成一张虚方法表vtable记录该类所有可被动态分派的方法入口。方法调用时不直接绑到变量类型的方法上而是根据对象的真实类去这张表里查找对应的方法地址。因为子类重写方法时会替换虚方法表中对应槽位的入口地址所以运行时一查表天然指向的就是子类的实现。这个过程对调用方是完全透明的你拿父类引用调用方法JVM自动按真实对象去找实现这就是“一个接口多种实现”能在底层跑起来的原因。也正因如此父类里写的方法只要被子类重写了哪怕是在父类构造器里调用实际执行的也是子类版本。这个知识点直接引出后面要讲的面试大坑。Java方法调用还有一种情况是静态绑定包括静态方法、private方法、构造器以及重载方法的调用。这些方法在编译期就能确定目标JVM不需要虚方法表分派直接调用即可。所以本质上多态只存在于“虚方法”的调用里——父类引用指向子类对象时方法分派交给运行时虚方法表完成。4.3 重载重写对比与经典设计模式场景把“重载”和“重写”放在一张表里对比面试答题和日常写代码都会清晰很多对比项重载Overload重写Override发生位置同一个类内父类和子类之间方法名相同相同参数列表必须不同必须相同返回类型可相同可不同必须相同或协变访问修饰符无限制不能更严格绑定时机编译期静态绑定运行期动态绑定多态在真实工程里的价值最典型的体现是策略模式和模板方法模式。策略模式的意图正是“让算法的变化独立于使用它的客户”。定义一个接口不同策略类实现不同算法运行时选择具体策略对象注入使用方。整个过程使用方只依赖接口完全不感知策略内部怎么实现。比如一个订单折扣系统public interface DiscountStrategy { BigDecimal calculate(BigDecimal amount); } public class VipDiscount implements DiscountStrategy { Override public BigDecimal calculate(BigDecimal amount) { return amount.multiply(new BigDecimal(0.8)); } } public class NormalDiscount implements DiscountStrategy { Override public BigDecimal calculate(BigDecimal amount) { return amount; } } public class OrderService { private final DiscountStrategy strategy; public OrderService(DiscountStrategy strategy) { this.strategy strategy; } public BigDecimal settle(BigDecimal amount) { return strategy.calculate(amount); } }将来要加一个“新客立减50”的规则只需要新增一个DiscountStrategy实现类在创建OrderService时换一个策略对象OrderService本身一行不改。这种扩展能力靠的就是多态OrderService绑定的不是某个具体策略而是抽象的DiscountStrategy。你可以在main里用不同的策略对象跑同一个settle方法得到完全不同的结果。这里还有个容易被忽略的点**多态需要和接口/抽象类配合而不是直接依赖具体类。**如果OrderService构造器里直接写new VipDiscount()那就又退化成了强耦合。多态的打开方式是“面向接口编程”具体实现通过构造器、工厂或者Spring的依赖注入传进来而不是散落在业务代码里。5. 高频面试题与实战避坑实录5.1 面试中最容易“开口就翻车”的几个问题我以面试官角度整理了三个深度问题每一个都对应前面讲过的知识点但很多人一答就错。问题一子类构造器里不写super()会怎样默认情况下子类构造器第一行会隐式调用父类的无参构造器super()。如果父类没有无参构造器子类必须显式调用一个有参版本否则编译报错。很多人背了这句话但不知道为什么。原因是你创建子类对象时父类部分的字段和状态也必须初始化完整一个对象才能成立。这是构造器链的基本要求不是Java故意为难你。问题二父类构造器里调用了一个被子类重写的方法会发生什么这是最经典的陷阱。因为动态绑定父类构造器执行时实际调用的是子类重写后的版本。但此时子类的字段还没初始化通常处于默认值null/0状态。如果子类重写的方法访问了这些字段那你看到的不是预期的值而是默认值甚至NullPointerException。看这段class Base { Base() { init(); } void init() { System.out.println(Base init); } } class Child extends Base { private String field child field; Child() { super(); } Override void init() { System.out.println(Child init, field field); } } public class TrapTest { public static void main(String[] args) { new Child(); } }输出结果是Child init, field null而不是child field。根本原因在于super()执行时子类字段还没初始化而动态绑定让init()跑的是子类版本。解决办法只有一个不要在构造器里调用任何非final、非private的可重写方法。把这种“生命周期回调”放到专门的初始化方法里由外部在对象构造完成后再显式调用。问题三静态方法、private方法、final方法能被重写吗三个都“不能”以重写的方式存在但细节不同。静态方法可以被子类定义同签名方法这叫方法隐藏不是重写。你通过父类引用调用时依然调用的是父类的静态方法子类的版本不会被触发。private方法父类的private方法子类根本看不见在子类里写一个同名同签名方法只是恰好同名的全新方法不存在重写关系。final方法被final修饰的方法在父类里已经定型子类不能覆盖这是父类强行规定“这道行为不许变更”。这三连问基本能把一个人对多态边界的理解测出来。真懂的人在答这些问题时都会强调“编译期 vs 运行期”这个视角。5.2 实战中的典型问题从Lombok到继承体系设计理论说完了落回实际项目。我在日常代码里见过太多因为三大特性使用不当导致线上问题的情况挑几个高频的来讲。实战问题一Lombok的EqualsAndHashCode在继承里的坑。很多团队爱用Lombok的Data但Data默认生成的equals和hashCode只比较当前类的字段。如果一个Employee继承Person两个Employee的父类字段不同、子类字段相同equals会错误地返回true。解决办法是加EqualsAndHashCode(callSuper true)Data EqualsAndHashCode(callSuper true) class Employee extends Person { private String company; }这算一个很隐蔽的问题写的时候没感觉等集合去重、业务比较的时候才会冒出莫名其妙的现象。实战问题二子类重写后行为不一致破坏了父类的原有保证。父亲类里有一个calculate()方法里面做了前置校验和日志记录然后调用一个可重写的doCalculate()。子类重写doCalculate()时把校验触发了但没处理或者直接把整个calculate()重写丢掉了校验逻辑。这种问题的本质是**父类的方法本来是一个完整的业务协定子类重写后打破了协定而外部调用方依然拿父类类型在调用完全感知不到风险。**这种设计上的裂痕就是里氏替换被违反的体现。修复手段要么把不可变的部分放在final方法里不让子类越过要么把“可扩展点”设计得非常明确并配合完善的文档和测试。实战问题三强依赖具体类导致新需求来了只能改老代码。这个几乎天天发生。比如报表模块一开始只支持ExcelReport方法签名里直接出现ExcelReport类型。后来要支持PdfReport你只能去改调用方的代码加分支。如果一开始就定义Report接口export()是多态入口新增报表导出方式只需新增一个类业务层纹丝不动。5.3 三大特性在真实项目中的组合应用最后放一个综合案例把我们前面讲的所有东西串起来。场景一个内容平台的消息通知系统。通知渠道有短信、站内信、邮件每种渠道发送逻辑完全不同但都要先做“构造通知内容”这件事。抽象设计public abstract class Notifier { // 模板方法定义发送流程骨架子类不能随意重写 public final void notifyUser(String content) { String formatted formatContent(content); send(formatted); log(formatted); } protected String formatContent(String content) { return 【通知】 content; } // 各渠道的差异点子类必须实现 protected abstract void send(String content); // 所有渠道共用的日志逻辑 private void log(String content) { System.out.println(发送内容 content); } } public class SmsNotifier extends Notifier { Override protected void send(String content) { System.out.println(短信渠道发送 content); } } public class EmailNotifier extends Notifier { Override protected void send(String content) { System.out.println(邮件渠道发送 content); } }这个设计里Notifier用抽象类定义了发送流程骨架notifyUser()是final的外部调用方无法破坏流程。这是封装的一部分。formatContent()是protected的扩展点子类可以定制内容格式但默认实现也有兜底。这是protected访问级别存在的意义。send()是抽象方法逼着每个子类各自实现渠道发送逻辑。这就是继承 模板方法模式。外部业务代码只需持有Notifier引用运行时到底走短信还是邮件完全由注入的对象决定。这就是多态。最后用起来public class MessageService { private final Notifier notifier; public MessageService(Notifier notifier) { this.notifier notifier; } public void sendMessage(String content) { notifier.notifyUser(content); } } // 使用方只需切换策略对象 new MessageService(new SmsNotifier()).sendMessage(您的验证码是1234); new MessageService(new EmailNotifier()).sendMessage(您的验证码是5678);将来要接一个App推送只需要写一个AppPushNotifier extends Notifier实现send()MessageService一行不用动。这就是封装、继承、多态三者合力带来的可扩展性封装守住内部边界继承复用公共骨架并留出扩展点多态让整个调用链面向抽象而不是面向具体。我最后再分享一个实际带队时的体会很多团队代码腐烂不是技术难度高而是基础概念用歪了。滥用继承导致类关系混乱忽略封装导致状态随处可改不理解多态导致满屏if-else分支。三大特性不是面试走个过场它们在项目里每时每刻都在替你决定代码未来的死活。下次设计一个类之前先花十秒钟想三件事内部状态谁有权改哪些代码应该被复用调用方需要面对接口还是具体类想清楚这三件你就真的把封装、继承、多态拿透了。
返回列表