ARTICLE DETAIL

资讯详情

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

Java面向对象三大特性详解:封装、继承与多态的底层原理与实战

Java面向对象三大特性详解:封装、继承与多态的底层原理与实战 面试的时候面试官十有八九会抛出一个问题“你讲讲 Java 面向对象的三大特性” 很多同学能脱口而出封装、继承、多态。紧接着再问一句“那你说说多态的底层原理是什么继承和组合怎么选封装是不是就是把字段设为 private 再写一堆 getter/setter” 这时候不少人就开始含糊了。我在带新人和做代码评审时见过太多“会用但说不清”的情况也见过不少因为三大特性设计不合理把一个本该清爽的项目搞得牵一发动全身的例子。这篇博文我想把 JavaSE 里这三大特性从头到尾拆开揉碎讲一遍不光是语法层面更重要的是讲清楚它们各自的边界、代价和底层机制以及怎么在真实项目里把它们组合起来用。适合正在学 Java 的初学者、准备面试的求职者以及写了两三年代码但想回头把基础夯实的开发者。1. 封装把数据关进笼子里1.1 封装到底在解决什么问题很多人理解封装第一反应就是“私有化字段提供 getter/setter”。这个理解不能算错但太表面了。封装的本质是信息隐藏和操作可控。信息隐藏解决的是“内部细节暴露带来的风险”操作可控解决的是“外部可以随意修改数据导致状态非法”的问题。举个例子你写一个 Person 类里面有个 age 字段如果用 public 直接暴露外部代码可以随手写person.age -100。逻辑上年龄不可能为负数但因为没有一层把关的机制脏数据就这么进去了。等到后面算平均年龄、做数据统计的时候各种诡异问题就冒出来了。而封装之后年龄的修改必须通过setAge(int age)方法你就可以在方法内部做校验年龄太小太大就直接抛异常或者拒绝赋值。这里有个生活化的类比封装就像银行卡。你不可能直接伸手到银行金库里去拿钱必须通过 ATM 或者柜台这个“接口”来操作。ATM 会验证你的密码、检查余额不合法、不安全的操作在入口就被拦住了。如果没有这层封装所有人拿着公钥就能随便改余额整个系统就崩了。从设计层面看封装还带来一个巨大的好处内部实现可以随便改只要外部接口不变调用方就无感。比如你原来用数组存数据后面发现数据量大了要换成 ArrayList只要对外提供的方法签名不变调用方完全不用关心你内部怎么改。这就是“接口封装”思想的基础工作中做 API 封装、SDK 封装本质上都是在用封装这个特性把复杂度和变化隔离开。1.2 访问控制符Java 提供的一把锁要谈封装必须先聊 Java 的访问控制符。Java 提供了四个级别的访问权限从大到小分别是 public、protected、默认包私有、private。我见过很多人对 protected 的理解是模糊的这里必须说清楚。访问修饰符同类同包子类不同包任意类public✅✅✅✅protected✅✅✅❌默认✅✅❌❌private✅❌❌❌public 是最大开放private 是最大封闭这两个没什么好争议的。容易踩坑的是默认权限和 protected。默认权限在中国教材里常写成“friendly”其实 Java 里没有这个关键字它指的就是什么都不写的情况范围是“同包可见”。这意味着不同包的两个类哪怕有继承关系父类的默认权限成员子类也访问不到。protected 的细节比很多人以为的要复杂。它在同包内相当于 public在不同包的子类中只能通过“子类自己的引用”访问不能通过“父类的引用”访问一个父类实例的 protected 成员。这话有点绕举个例子子类 Sub extends Base在 Sub 的方法里可以this.protectedField但如果创建一个 Base 对象再访问base.protectedField编译器会报错。这个点面试里出现过不少次值得留意。1.3 动手写一个规范的封装类光说不练假把式写一个规范的封装类来看看。以用户实体为例public class User { private Long id; private String username; private String email; private Integer age; public User() {} public User(Long id, String username, String email, Integer age) { this.id id; this.username username; this.email email; setAge(age); // 通过 setter 校验而不是直接赋值 } public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getUsername() { return username; } public void setUsername(String username) { if (username null || username.trim().isEmpty()) { throw new IllegalArgumentException(用户名不能为空); } this.username username; } public String getEmail() { return email; } public void setEmail(String email) { if (email null || !email.contains()) { throw new IllegalArgumentException(邮箱格式不正确); } this.email email; } public Integer getAge() { return age; } public void setAge(Integer age) { if (age ! null (age 0 || age 150)) { throw new IllegalArgumentException(年龄必须在0~150之间); } this.age age; } }注意构造器里调用了 setAge 而不是直接赋值这是一个细节。如果构造器直接this.age age等于绕过了 setter 里的校验逻辑还是可能把非法数据放进来。这个细节我在代码评审里给不少人提过改完之后代码的健壮性会明显上一个台阶。很多人会质疑封装后代码量变多了getter/setter 不就是脱裤子放屁吗这种质疑在初学阶段很常见但等你真正维护过一个被内部字段直接操作搞出线上事故的项目就会明白这层“屁”有多值钱。当然我也要泼一盆冷水不是所有字段都必须有 getter/setter。如果一个字段只在类内部使用外部根本不应该知道它的存在那应该直接 private不提供任何访问入口。有些类里的字段是要通过业务方法去修改的比如setPassword这个行为背后可能是加密存储、密码强度校验、记录修改时间这时候提供 raw setter 反而破坏了封装。业务操作优先用“行为方法”而不是“属性修改器”这是封装设计里很重要的一条原则。1.4 封装容易踩的坑封装说起来简单但实际项目里翻车的场景我见过不少。第一个坑是直接暴露集合引用。比如你的类里有一个private ListString tags;然后提供了一个public ListString getTags() { return tags; }。外部拿到这个 List 后可以直接user.getTags().add(恶意数据)。你辛辛苦苦做的封装等于白做了外部还是能绕过你的校验直接改内部数据。正确做法是返回不可变视图或拷贝副本public ListString getTags() { return Collections.unmodifiableList(tags); // 或者 return new ArrayList(tags); }第二个坑是拿 getter/setter 当万能药硬生生把一个业务对象做成纯数据袋子。我见过一些老项目实体类里全是 getter/setter业务逻辑全堆在 Service 层什么校验都放 Service 里做实体类彻底变成了一个干活不思考的容器。这种代码不是说跑不起来但一阵子之后 Service 层就会变得无比臃肿而且概念上非常散。有些内聚的校验和行为放在实体类自己身上反而是更合理的封装。第三个坑是把所有字段都设成 public static final用一堆常量模拟“全局可访问”。这确实在某些场景下方便但代价是失去了替换和扩展的能力。你能改的值全被编译期写死了代码是“硬”的不是“活”的。2. 继承站在父类肩膀上复用代码2.1 继承解决的问题与语法继承解决的核心问题是代码复用和“is-a”关系建模。你写了 Dog 类里面有 name、age 字段和 eat() 方法过两天又要写 Cat 类发现又有 name、age、eat()。如果把公共部分抽出来放到 Animal 父类里Dog 和 Cat 都去继承它公共的字段和方法就被复用了一份子类只需关心自己特有的部分。继承的语法非常简单用 extends 关键字public class Animal { protected String name; public void eat() { System.out.println(name 正在吃东西); } } public class Dog extends Animal { public void bark() { System.out.println(name 正在汪汪叫); } }判断要不要用继承最经典的标准就是“is-a”关系Dog 是一种 Animal所以用继承没问题。如果两个类之间只是有共同代码是“has-a”关系比如 Car 有 Engine那就该用组合而不是继承。这个判断标准我后面还会细说因为它是设计层面最容易走偏的地方。使用继承也有代价子类和父类是强绑定的。父类改一个方法签名所有子类全都得跟着改父类加一个字段所有子类实例都会多一块内存。如果继承体系设计得不好到后期每改动一次父类就像推倒一个多米诺骨牌会引发连锁反应。所以继承不是“免费的复用”它是“有负债的复用”用之前想清楚。2.2 方法重写与 super 关键字继承之后子类可以对父类的方法进行重写Override。重写不是覆盖代码而是“替换实现逻辑”的一种方式。重写时一行Override注解建议一定要写这不仅仅是注释它能让编译器帮你校验“父类里确实有这个方法”。如果父类没有这个方法或者签名对不上编译器直接报错能早发现早点改。重写有四个规则记住它们基本就能避开所有坑访问权限不能比父类更小。父类是 public子类重写就不能是 protected 或 private否则调用方通过父类引用调用时会因为权限问题崩掉。返回类型可以相同也可以是父类返回类型的子类型这叫协变返回类型。抛出的受检异常不能比父类更宽泛但可以不抛异常或抛更具体的异常。方法名、参数列表必须完全一致。super 关键字是用来访问父类成员的。super.方法名()可以在子类里调用父类被重写的方法super.字段名可以访问父类字段。这里有个细节如果是“子类有的方法调用父类版本”用 super 没问题但如果父类的方法内部调用了另一个被子类重写的方法这里的行为要特别注意因为 Java 的动态绑定会导致父类方法内部调用的是子类的重写版本这个话题我留到多态章节细讲。2.3 构造器调用链从父到子每次 new 一个子类对象构造器的执行顺序是有严格规定的先执行父类的静态代码块和静态变量初始化再执行子类的静态代码块然后执行父类的实例代码块和实例变量初始化再执行父类构造器最后执行子类的实例代码块、实例变量初始化和子类构造器。简单记就是静态先行父类先于子类实例块先于构造器。public class Animal { static { System.out.println(Animal 静态块); } { System.out.println(Animal 实例块); } public Animal() { System.out.println(Animal 构造器); } } public class Dog extends Animal { static { System.out.println(Dog 静态块); } { System.out.println(Dog 实例块); } public Dog() { super(); System.out.println(Dog 构造器); } }执行顺序输出Animal 静态块 → Dog 静态块 → Animal 实例块 → Animal 构造器 → Dog 实例块 → Dog 构造器。子类构造器里super()这行代码就算你不写编译器也会自动加上。所以父类必须有一个可访问的无参构造器否则子类编译都过不去。如果父类只定义了有参构造器子类构造器里必须显式调用super(参数)。这块是初学者最容易撞的编译错误之一记住一个原则父类“有参”子类“必参”。还有一个进阶坑父类构造器调用了一个被子类重写的方法。比如父类构造器里调用了init()而子类重写了init()并且依赖子类的字段但子类字段此时还没初始化运行起来就会拿到 null 或者默认值。这种问题在真实项目里是很隐蔽的 Bug排查起来费劲。最佳实践就是不要在构造器里调用可被重写的方法父类构造器只做父类自己字段的初始化如果要扩展能力用模板方法模式在特定的扩展点去做。2.4 Java 为什么只有单继承很多学过 C 的人刚接触 Java 时会问Java 为什么不支持多继承因为多继承会带来著名的“菱形继承问题”。假设 A 类有个run()方法B 和 C 都继承 A 并各自重写了run()然后 D 同时继承 B 和 C。请问 D 的run()到底该用 B 的版本还是 C 的版本如果允许逻辑上就无法自洽如果强行定规则又会带来巨大的理解和维护成本。Java 选择用接口来替代多继承的能力一个类可以 implements 多个接口这就规避了多继承的方法冲突。Java 8 之后接口里允许有 default 方法很多需要多继承的场景用接口 default 方法就能解决。但要注意如果一个类 implements 的两个接口里有同名同参的 default 方法这个类必须自己重写一遍否则编译器会报错。这个规则面试偶尔会问到提前准备一下有好处。实际开发里我更建议严格控制继承层次。三层以上的继承体系就开始闻起来有坏味道了。父类改一点东西影响面往往是整棵继承树改动风险成倍放大。很多团队干脆规定“继承不超过三层优先用组合”。组合的做法是在类里持有另一个类的对象引用通过调用它的方法实现功能。比如“鸟”不一定要继承“飞行动物”才能飞可以让 Bird 类内部组合一个 FlyBehavior 对象想让它怎么飞就替换什么实现。这个叫“组合优先于继承”是设计模式里非常核心的一条原则。从职责分配的角度看继承会把父类不需要的能力强塞给所有子类而组合让每个类更聚焦自己的职责。3. 多态一个接口多种实现3.1 多态的本质动态绑定多态是三大特性里最抽象也最强大的一个。它的定义是同一操作作用于不同的对象可以有不同的解释产生不同的执行结果。用 Java 的术语说就是父类引用指向子类对象调用方法时运行期动态绑定到子类的实现。多态需要三个必要条件继承或实现、方法重写、父类引用指向子类对象。光有继承不重写谈不上多态重写了但没用父类引用去接收也体验不到多态的价值。public class Animal { public void speak() { System.out.println(动物发出声音); } } public class Dog extends Animal { Override public void speak() { System.out.println(汪汪汪); } } public class Cat extends Animal { Override public void speak() { System.out.println(喵喵喵); } } // 使用处 Animal d new Dog(); Animal c new Cat(); d.speak(); // 汪汪汪 c.speak(); // 喵喵喵这段代码里d 和 c 的静态类型是 Animal但运行时实际类型是 Dog 和 Cat。调用 speak() 的时候JVM 根据对象的实际类型去找对应的方法实现而不是看引用变量的声明类型。这就叫动态绑定也叫运行时多态。编译期看左边运行期看右边。这句话是理解多态的核心口诀。左边是引用类型决定了你能调用哪些方法右边才是实际对象决定了方法真正执行的是谁的逻辑。3.2 向上转型与向下转型多态的使用必然涉及向上转型把子类对象赋值给父类引用。向上转型是自动的、隐式的因为子类一定拥有父类的全部公开能力把 Dog 转成 Animal 是完全安全的就是收窄了看待这个对象的“视角”。向上转型的代价是失去了子类特有的方法。比如 Dog 有 bark()但你把它存成 Animal 引用后animal.bark()编译都过不去。如果你确定这个对象的真实类型是 Dog可以用强制类型转换把它“转回去”这就是向下转型Animal a new Dog(); if (a instanceof Dog) { Dog dog (Dog) a; dog.bark(); }向下转型是不安全的因为编译期无法确定对象的实际类型。如果直接(Dog) a而 a 实际指向的是 Cat就会抛出 ClassCastException。所以向下转型之前务必用 instanceof 做类型检查。Java 16 之后还有instanceof模式匹配可以在判断的同时完成类型转换写法更简洁if (a instanceof Dog dog) { dog.bark(); }顺手提一个计算机常识Java 语言规范里instanceof 左边如果是 null不会抛异常只会返回 false。这避免了很多不必要的空指针判断。3.3 重载和重写两个层面的多态多态可以分成两种编译时多态和运行时多态。方法重载Overload是编译时多态的典型代表。重载是指在同一个类中定义多个同名方法但参数列表不同。调用的时候编译器在编译期就根据你传入的参数个数、类型决定到底调用哪一个方法。这个决定是在编译期就定死的所以叫编译时多态。方法重写Override则是运行时多态它发生在继承关系中子类重写父类的方法。调用时 JVM 在运行期根据对象的实际类型决定执行哪个版本的方法。重载和重写完全不同的两码事但很多初学者会搞混。它们的核心区别可以用一张表说清维度方法重载Overload方法重写Override发生位置同一个类父类与子类方法签名参数列表必须不同方法名和参数列表必须完全一致返回类型可以不同不影响重载判定必须与父类相同或为其子类型访问权限无限制不能比父类更严格绑定时机编译期运行期典型用途同类中方法参数的灵活扩展子类定制父类行为重载还有一个实际开发的建议能用重载解决的问题尽量少用继承去搞。试想一个sendMessage(String msg)和一个sendMessage(String msg, int retryCount)这比搞一套继承体系去区分“发送一次的消息”和“可以重试的消息”轻巧得多。重载让 API 在同一个类内保持一致性调用方通过直观的参数差异就能找到适合的版本。3.4 多态的实际应用面向接口编程多态的价值不在于炫技而在于让代码“开闭原则”落地对扩展开放对修改关闭。一个最常见的场景就是支付。假设系统支持支付宝、微信、银联三种支付方式没有多态的时候你得写一堆 if-else 去区分public void pay(String channel, double amount) { if (alipay.equals(channel)) { // 支付宝支付逻辑 } else if (wechat.equals(channel)) { // 微信支付逻辑 } else if (unionpay.equals(channel)) { // 银联支付逻辑 } }这个写法不光丑而且每次新增支付渠道都要改这个方法逻辑越长越容易出错。用多态重构后先是定义一个统一接口public interface PayStrategy { void pay(double amount); }再写各自的实现类public class AliPayStrategy implements PayStrategy { Override public void pay(double amount) { System.out.println(通过支付宝支付 amount 元); } } public class WechatPayStrategy implements PayStrategy { Override public void pay(double amount) { System.out.println(通过微信支付 amount 元); } }调用侧只需要拿到 PayStrategy 引用然后一条payStrategy.pay(amount)就够了。至于实际执行的是哪个实现完全由运行时注入的策略对象决定。这就是策略模式的雏形它的扩展方式也极友好新增一种支付只需要加一个实现类不用改任何老代码。这就是面向接口编程的意义。外部依赖接口接口后面的实现可以自由替换。接口本身也是一种封装它把“能做什么”和“怎么做”隔离开来这和封装章节讲的思想是一脉相承的。3.5 多态的底层机制虚方法表面试时一旦问到多态底层很多人就卡壳。这里我尽量用通俗的方式讲清楚。Java 中方法的调用分为静态绑定和动态绑定。static 方法、private 方法、final 方法还有一些构造器的调用都是静态绑定编译期就能确定要执行哪个方法JVM 直接执行调用指令即可。但普通实例方法的调用是动态绑定JVM 无法在编译期确定具体执行哪个类的方法只能到运行期去找。JVM 为每个类维护了一个虚方法表vtable里面记录了该类所有虚方法可被重写的实例方法的入口地址。子类继承父类时如果重写了某个方法虚方法表里对应条目就会指向子类自己的实现没重写的话就继续指向父类的实现。调用的时候JVM 根据对象的实际类型找到对应的虚方法表再通过方法索引定位到真正的方法入口。用大白话说方法调用像查字典先找到对象真实类型对应的方法表再按方法签名去表里查“这个词的解释”查到的是哪个实现就执行哪个实现。理解了虚方法表就能想明白一件事多态的代价是有一点性能损耗的。相比于编译期就确定地址的直接调用动态绑定多了查表、寻址的步骤。现代 JVM 的 JIT 做了大量内联和优化这个损耗通常微乎其微。但如果你写了一段每秒被调用几十万次的热点代码避免在热路径上滥用多态、通过 final 或静态方法避免动态绑定仍是值得考虑的优化手段。对大多数业务系统来说代码的清晰性和可维护性远重要于这点性能差别先把设计做对再去调优。4. 三大特性组合实战一个小型员工薪资系统4.1 需求与设计单独讲三大特性容易让人觉得它们是三座孤岛。实际开发里封装、继承、多态往往是糅合在一起用的。我用一个经典的小型员工薪资系统来演示它们怎么配合。需求是这样的公司有三种员工——正式员工、经理、实习生他们的月薪计算逻辑完全不同。正式员工是固定月薪经理在固定月薪基础上还要加上所管团队绩效奖金实习生按天计薪工作多少天算多少天。系统要能做到不管哪种员工调用方只需要统一调用一个计算薪资的方法就能得到正确结果。用面向对象思路来分析这个需求设计分为三步第一步用封装来定义数据模型。员工的基本信息姓名、工号、基本工资这些字段设为 private通过构造器和业务方法赋值防止外部乱改。第二步用继承抽共性。所有员工都是“员工”把 name、employeeId、baseSalary 抽到父类 Employee 中子类继承这些公共字段同时各自定义自己特有的属性比如经理的 teamPerformanceBonus、实习生的 workDays 和 dailySalary。第三步用多态统一行为。在 Employee 中定义一个calculateSalary()方法三个子类各自重写。调用方只面向 Employee 父类编程运行时动态执行各自的计算逻辑。这个设计思路背后有个决策点值得解释为什么不用一个 Employee 类加一个类型字段然后在方法里写 if-else 分支那样做当然能实现功能但每新增一种员工类型就得在该方法里加一个分支这个类的代码会被不断蚕食最终变成“上帝类”。用继承加多态新增员工类型时只需新增一个子类完全不用动已有代码这才是“对修改关闭对扩展开放”的体现。4.2 核心代码实现先看父类 Employeepublic abstract class Employee { private String name; private String employeeId; private double baseSalary; public Employee(String name, String employeeId, double baseSalary) { this.name name; this.employeeId employeeId; this.baseSalary baseSalary; } public String getName() { return name; } // 抽象方法强制子类必须实现各自的薪资计算逻辑 public abstract double calculateSalary(); public double getBaseSalary() { return baseSalary; } }把calculateSalary()设计成抽象方法强制要求每个子类都给出自己的实现。这样做的好处是把“必须实现的行为”通过语法层面固化下来漏实现编译都过不了。再看三个子类public class RegularEmployee extends Employee { public RegularEmployee(String name, String employeeId, double baseSalary) { super(name, employeeId, baseSalary); } Override public double calculateSalary() { return getBaseSalary(); } } public class Manager extends Employee { private double teamPerformanceBonus; public Manager(String name, String employeeId, double baseSalary, double teamPerformanceBonus) { super(name, employeeId, baseSalary); this.teamPerformanceBonus teamPerformanceBonus; } Override public double calculateSalary() { return getBaseSalary() teamPerformanceBonus; } } public class Intern extends Employee { private int workDays; private double dailySalary; public Intern(String name, String employeeId, double dailySalary, int workDays) { super(name, employeeId, 0); this.dailySalary dailySalary; this.workDays workDays; } Override public double calculateSalary() { return dailySalary * workDays; } }子类的各自字段用了 private 封装计算逻辑也都在自己内部完成外部完全不需要关心 Manager 的奖金怎么算、Intern 的日薪怎么乘。主程序里用父类引用统一处理public class PayrollSystem { public static void main(String[] args) { ListEmployee employees new ArrayList(); employees.add(new RegularEmployee(张三, E001, 8000)); employees.add(new Manager(李四, E002, 12000, 5000)); employees.add(new Intern(王五, E003, 200, 20)); for (Employee employee : employees) { System.out.println(employee.getName() 的月薪是 employee.calculateSalary()); } } }仔细看这段代码循环体里完全没有类型判断。不管列表里装的是什么类型的员工employee.calculateSalary()都会在运行期精准地找到对应子类的实现。这就是多态在生产代码里最美的样子。4.3 这套设计好在哪这套设计最直接的好处是扩展性。假设过两个月公司招了一批外包员工薪资计算方式是“时薪 x 工时”。我只需要新增一个 OutsourcedEmployee 子类实现calculateSalary()然后在主程序里 add 进去就行。PayrollSystem 的循环逻辑一行都不用改。这套设计第二个好处是“数据安全 行为内聚”。员工的姓名工号基本工资都在 setter 或构造器里做了校验和赋值薪资计算逻辑在自己所属的类内部完成而不是散落在 Service 层的一堆 if-else 里。各司其职出了问题也容易定位。第三个好处是便于测试。每个子类都是独立的单元可以单独写测试用例验证它的薪资算法。如果用 if-else 那种写法你得不停构造不同类型的对象来覆盖每一条分支测试路径复杂得多。这里也要说一个反面教训继承不是用得越多越好。假设你只是给 Intern 加一个 workDays 字段就建一个 Intern 类没问题但如果所有子类里只有 Manager 有奖金字段而将来很可能出现“没有奖金的经理”那就该考虑把奖金抽出来用组合而不是继续加深继承树。设计没有一个绝对正确的答案最关键的是保持对代码味道的敏感一旦发现父类被改动的频率越来越高、子类间的差异越来越大就该动手重构。5. 常见问题与排查技巧实录5.1 高频面试题速答在我面试候选人和被面试的双重经历里有几道关于三大特性的题出现频率极高。这里把标准回答思路整理成表格方便记忆。问题核心回答要点面向对象三大特性是什么封装信息隐藏操作可控继承代码复用is-a 关系建模多态同一操作不同对象有不同的执行结果重写和重载的区别重载是编译期多态发生在同类中参数列表不同重写是运行期多态发生在父子类间参数列表一致权限不能更小构造器能被重写吗不能。构造器名称必须与类名一致子类构造器与父类构造器名称不同不是重写关系静态方法能被重写吗严格意义上不能。子类声明同名同参的静态方法叫隐藏不是重写调用时按引用类型静态绑定不走动态绑定多态访问不了子类特有方法怎么办需要向下转型转型前用 instanceof 判断类型父类构造器能否调用子类重写的方法语法上可以但强烈不建议因为此时子类字段可能尚未初始化容易引发 NullPointerException 等隐蔽问题补充一个细节静态方法为什么不能多态多态的核心是运行期根据对象实际类型去“动态查表”但静态方法属于类不属于实例JVM 在编译期就能确定该调用哪个类的方法。所以在多态场景下用父类引用调静态方法实际执行的是父类的静态方法而不是子类的。这个点很多人踩坑之后才反应过来。5.2 代码层面的典型案例与排查我在实际带项目和 review 代码时经常遇到三类与三大特性相关的典型问题每个都值得展开说一下。第一类是“属性隐藏”造成的诡异问题。子类声明了一个与父类同名的字段这不会报错但会导致this.field和super.field是两个不同的变量读写互不相干。看这段代码class Parent { String name parent; } class Child extends Parent { String name child; } Parent p new Child(); System.out.println(p.name); // parent方法会多态字段不会多态。字段访问按编译期类型决定p.name访问的是 Parent 的字段。最好的规避方式就是子类不要声明和父类同名的字段遇到这种需求先思考是不是设计有问题。第二类是向下转型没做 instanceof 判断导致 ClassCastException。这类问题在把 JSON 反序列化的对象强转成业务类时特别常见。排查思路很简单在转型之前打印出对象的实际类型不要猜直接看运行时类型。用object.getClass()看一眼就知道真实类型是什么。别用toString()碰运气getClass() 给出的信息准确又完整。第三类是 equals 方法在继承体系里的坑。如果父类定义了 equals子类重写后没遵守对称性就会出现a.equals(b)为 true 但b.equals(a)为 false 的荒唐结果。这个问题根源也是多态equals 方法会被动态绑定而接收参数类型不同会走到不同的重载版本。最简单的建议要么在父类把 equals 设为 final让所有子类共享同一套相等逻辑要么每个子类都严格遵守“同一个类才相等”的规则并且保证 hashCode 与 equals 一致。继承和多态给了你灵活性但灵活性是有代价的使用前必须想清楚约定。5.3 三大特性设计层面的避坑清单最后整理一份我在项目里实际沉淀下来的设计避坑清单每一条都是踩过或见人踩过的继承层级尽量控制在三层以内。超过三层理解成本和改动风险都会指数上升优先思考是否能通过组合或接口重新抽取。所有字段默认使用 private需要被子类访问的才用 protected对外暴露的接口才用 public。不要图省事直接全部 public。getter 返回集合类型时返回不可变视图或副本防止外部修改内部状态。构造器里不要调用可被重写的方法。初始化逻辑如果需要子类参与使用模板方法模式的扩展点但要清楚扩展点执行时子类字段可能尚未就绪。父类定义了抽象方法子类实现时加上 Override 注解。这不仅是好习惯更是编译器帮你做校验的保险丝。重写 equals 必须重写 hashCode这是 Java 规范和集合类正常工作的前提。多态调用时如果所有子类都要执行同一个前置逻辑这个前置逻辑放在父类方法里通过让子类调用 super.xxx() 来完成而不是在每个子类里重复写。这些点单看都不难难的是在写代码的过程中时刻保持意识。我自己的做法是每次写完一个类都会问自己三个问题数据有没有被恶意修改的可能子类扩展时会不会被父类的实现绑架调用方如果不关心具体类型能不能直接用父类引用统一操作这三个问题对应封装、继承、多态三个维度也是对自己设计的一次快速审查。关于这三大特性我个人在实际操作中最大的体会是不要为了用而用。刚学多态的时候我特别沉迷于抽象类和接口一个简单的工具类也要搞出三层继承结果后面加需求的时候改一个父类要连带改无数个子类痛苦到怀疑人生。后来慢慢明白封装让你管的住自己继承让你走的稳多态让你变得灵活但真正让代码好维护的不是用了多少“高级特性”而是每个特性都用在了合适的边界上。如果你正在学这块建议别急着刷题先拿手头一个小模块试着重构成“封装数据 继承抽公共 多态做扩展”的结构对比一下改动前后的差异那种感觉比你背十道面试题都来得实在。等到写多了再回头看这个标题里的“详解”两个字应该会涌现出更多属于你自己的理解。
返回列表