ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

封装、继承、多态深度拆解:从JVM虚方法表到薪资系统实战

封装、继承、多态深度拆解:从JVM虚方法表到薪资系统实战 “封装、继承、多态”这六个字我在面试里问过不下几百遍。你要是去问任何一个写过两三年Java的人他都能背出来这是面向对象的三大特性。但你再追问一句你自己的项目里哪个地方真正用上了多态不少人就开始支支吾吾了。这其实挺有意思的——天天写代码的人未必真吃透了这几个最基础的概念。我这次想换个讲法不是照着教科书念定义而是从“当时为什么会有这些东西”出发把封装、继承、多态这三个概念拆开揉碎再串起来讲。内容以Java为主必要时用C对照说明因为这两门语言在继承和多态的实现方式上有非常典型的差异搞清楚之后再看其他面向对象语言基本都能触类旁通。不管你是刚学完语法正在准备面试的初学者还是写了两三年业务代码但总觉得基础不牢的老手这篇文章都值得你花二十分钟慢慢看。1. 封装先把数据和操作捆在一起再把细节锁起来1.1 封装到底在封装什么很多人一听到“封装”就条件反射地说“把字段设为private然后提供getter和setter”。这句话不算错但只停留在语法层面。封装的本质不是“私有化”而是把一组相关的数据以及操作这组数据的方法绑定成一个整体并且对外只暴露必要的入口。字段私有化只是实现这个目标的手段之一。打个比方你站在ATM机前面取钱只需要插卡、输密码、按几个按钮就能拿到现金。你不需要知道机器内部怎么验证卡片、怎么联网、怎么点钞、怎么记账这些细节被锁在一个黑盒里。更重要的是银行哪天升级了内部系统把点钞模块换成更快的版本你取钱的操作方式完全不受影响——因为对外接口没变。类也是一样的道理。我写过很多业务系统深有体会一个设计良好的类内部实现随便你怎么改只要公开方法的签名不变调用方就感知不到变化。这就是封装的真正价值隔离变化保护数据完整性降低模块间的耦合。1.2 访问权限四个修饰符怎么选Java里控制访问权限的是四个修饰符从宽到窄分别是public、protected、default就是什么都不写、private。很多人记混protected的意义这里重点说一下。修饰符同类同包子类任何地方private可以不行不行不行default可以可以不行不行protected可以可以可以不行public可以可以可以可以这里有一个很多人会踩的坑protected不是“包内可见子类可见”这么简单。在Java里protected成员的可见范围是“同包内所有类 不同包下的子类”。但注意跨包子类访问protected成员时只能通过该子类自身或其子类的对象来访问不能通过父类的引用去访问。这个细节经常出现在各种八股文里当初我也被绕晕过。实际项目中我的经验是字段默认private常量用public static final提供给外部调用的方法用public仅供子类扩展用的钩子方法用protected同包协作的内部工具方法用default。不要一上来就全public那是给自己埋雷。1.3 getter/setter不是银弹很多框架比如MyBatis、Jackson、Spring依赖getter/setter做映射和注入所以你不得不在POJO上写一堆getter/setter这没问题。但如果是业务类无脑给每个字段加getter/setter反而是封装的反面教材。举个例子你写一个员工类里面有个age字段。如果你直接public int age;那谁都可以往里面赋值一个负数你的业务规则就崩了。但如果你用private加setter就能在赋值入口做校验public class Employee { private int age; public void setAge(int age) { if (age 18 || age 65) { throw new IllegalArgumentException(年龄必须在18到65之间); } this.age age; } }这才是封装的正确用法用方法来保护字段的状态保证对象永远处于合法状态。我这里再提醒一个经常被忽略的细节如果类里面有集合类型的字段getter不要直接把内部集合的引用返回出去否则调用方就能绕过你的控制去修改集合内容。正确做法是返回一个不可修改的视图拷贝public ListString getTags() { return Collections.unmodifiableList(tags); }这个坑我踩过不止一次。线上出过事故排查到最后发现是某个同事把内部list直接返回别的模块往里加了一堆脏数据。从那以后我给自己定了一条规矩凡是暴露出去的集合一律套一层不可变视图。2. 继承复用代码可以但别让层级变成蜘蛛网2.1 继承真正要解决的问题是什么继承解决的第一个问题是代码复用。比如你有三个类狗、猫、鸟它们都需要吃饭、睡觉、呼吸。如果每个类都写一遍这些方法代码冗余不说将来想改逻辑还得改三个地方。这时你可以抽象出一个Animal父类把这些公共行为放进去三个子类通过继承自动获得。但继承要解决的第二个问题才是它真正的价值所在建立is-a关系为多态铺路。狗是一种动物猫也是一种动物。只有建立了这种父子关系后面才谈得上“用一个Animal类型的变量去引用一个Dog对象”。Java的继承用extends关键字C则用了三种继承方式public继承、protected继承和private继承。Java里没有这种区分默认就是C里的public继承语义。C作者当年设计这三种继承方式本意是让继承既能表达“is-a”也能表达“has-a”的复用但实践下来protected继承和private继承用得极少反而把语言搞复杂了。Java直接砍掉它们只保留is-a这一种语义对绝大多数业务开发场景来说是更务实的选择。2.2 重写、重载与super的使用要点继承关系里最容易混淆的是一对概念重写和重载。重写是子类对父类方法的重新实现要求方法签名完全一致返回类型可以相同也可以是父类返回类型的子类型。重写时可以缩小访问权限不行。只能扩大或维持原访问权限不能缩小。比如父类方法是public子类重写时就不能改成protected或private。这是语法规定也是里氏替换原则的要求——凡是能用父类对象的地方换成子类对象也必须能正常工作权限如果缩小了调用方在父类引用上调用的方法在子类身上反而访问不到逻辑就崩了。重载是在同一个类里方法名相同但参数列表不同跟返回值无关。很多人面试时被问“重写和重载的区别”一紧张就答反了。这里记住一句话就行重写是父子类之间的纵向关系重载是同类内部的横向关系。再看super关键字。子类构造器里第一行要么是super(...)要么是this(...)如果都不写编译器会自动插入一个无参的super()。这意味着子类对象在创建时会先触发父类构造器再触发子类构造器一路向上到Object为止。所以如果父类没有无参构造器子类构造器里就必须显式调用super(参数)否则编译直接报错。这个我当年刚学Java时经常忘。2.3 里氏替换原则与“组合优于继承”继承用好了是利器用不好就是灾难。里氏替换原则Liskov Substitution PrincipleLSP是判断继承是否合理的试金石。它的核心就一句话任何父类能出现的地方换成子类对象程序行为不能出错逻辑不能被破坏。举例来说如果你有一个方法接收Animal参数传入Dog或Cat都应该能正常工作。如果你发现某个地方传子类进去会触发特殊判断、甚至抛异常那说明这个继承关系本身就设计歪了。经典的反面教材是“正方形继承矩形”这个案例。矩形有宽高可以分别设置正方形要求宽高相等。如果你让正方形继承矩形就必须在setWidth的时候顺带改height或者在setHeight的时候改width。这会导致调用方按矩形的逻辑去操作正方形时行为完全不可预测。更好的做法是让它们都继承一个Shape抽象类谁也别当谁的子类。还有一个我自己的经验法则如果两个类之间只是“需要复用代码”的关系而不是真正的is-a关系优先用组合。组合就是在一个类里持有另一个类的引用public class Car { private Engine engine; // 汽车有一个引擎而不是汽车继承引擎 public Car(Engine engine) { this.engine engine; } }组合的好处是松耦合。引擎换实现汽车类不用改。而继承是紧耦合的父类一改所有子类都得跟着遭殃。业界有个公认的建议继承层级最好不要超过三层超过之后整个体系会变得极难维护。我看过很多老项目的代码那继承链长得像蜘蛛网想找一个方法到底从哪个父类继承来的要一路翻十几个类真正让人头大。2.4 sealed关键字让继承重新变得更可控Java这么多年一直在鼓吹继承但OpenJDK团队也看到了滥用继承的问题所以从Java 17开始引入了sealed关键字允许你显式声明一个类可以被子类继承但只能由哪些类继承public sealed class Animal permits Dog, Cat { } public final class Dog extends Animal { }这样一来继承范围被锁死既保留了继承的扩展能力又避免了任何人都能随便继承这个类导致体系失控。我强烈建议新项目在设计核心领域模型的时候用sealed来约束继承范围这能在编译期拦截掉一大批不该出现的继承关系。3. 多态同一个动作不同对象各自的表现3.1 多态的前提条件多态是三大特性里最抽象的一个也是面试最爱深挖的一个。它的定义很简短同一类型的引用指向不同的对象调用同一个方法产生不同的行为。要实现多态需要满足两个前提第一必须有继承关系或者实现关系第二子类必须重写了父类的方法。两者缺一不可。举一个最经典的例子Animal a1 new Dog(); Animal a2 new Cat(); a1.speak(); // 输出汪汪汪 a2.speak(); // 输出喵喵喵声明类型是Animal实际对象分别是Dog和Cat。调用的都是speak()但因为实际对象不同执行结果不同。这就是运行期多态。生活化的类比也很简单你站在讲台上说一句“开始”运动员会跑钢琴家会弹厨师会切菜。同一个命令不同的人有不同的响应方式。你不需要知道面前站着的是谁只要发出“开始”这个指令就行。放在代码里就是你不用关心对象的具体类型只需要按父类定义的接口去调用具体怎么执行由对象自己决定。3.2 编译期多态与运行期多态先澄清一个概念多态其实分成两种。编译期多态说的是方法重载。你在一个类里写了int add(int a, int b)和double add(double a, double b)调用的时候编译器根据参数类型在编译期就确定要调用哪一个方法。这个在运行期之前就已经决定了所以叫编译期多态也叫静态绑定。运行期多态说的才是我们通常讲的多态也就是上面例子里的场景。它在编译期只知道你是Animal引用但具体调用哪个speak()要等程序跑起来、确定了实际对象之后才能确定。这个叫动态绑定也叫晚绑定。我之前面试过一些候选人让他们说多态十个有七八个只说重写不提重载。严格来说这两个都属于多态范畴但面试官通常问的多态默认指运行期多态。建议你回答时先把两者区分开再重点讲运行期多态会显得你基础很扎实。3.3 JVM里多态是怎么实现的虚方法表这件事我当年学了之后专门去翻过HotSpot的文档看完真的有种“原来如此”的感觉。Java实现运行期多态的核心机制是虚方法表vtable。每个类在加载到JVM时都会生成一张表里面记录了类中所有方法的实际入口地址。当一个类继承父类并重写了某个方法子类的虚方法表中这个方法对应的入口就会指向子类的实现。调用时JVM根据对象的实际类型去查对应的虚方法表找到方法的真实地址然后执行。你可以在代码里验证一下多态确实走的是“查表”逻辑Animal a new Dog(); a.speak();编译之后字节码里invokevirtual指令后面跟着一个常量池索引指向Animal.speak()这个符号引用。运行期JVM拿到对象a的实际类型Dog去Dog的虚方法表里找到speak的入口执行Dog的版本。整个过程编译器完全不知道最后会执行哪个方法。C的多态实现方式也是虚函数表但跟Java有两个显著区别。第一C里只有标记了virtual的成员函数才参与动态绑定普通成员函数默认静态绑定。第二C的虚函数表是在编译期就为每个类生成一份静态数据Java的虚方法表则是运行期由JVM类加载器动态生成的。这也是为什么Java天然所有非final实例方法都是虚方法的原因。3.4 接口多态比继承更解耦的选择说完类继承的多态再聊接口。Java的接口多态本质上跟类继承多态一样都是“同一个接口方法多个实现类各自实现”。不一样的地方在于接口定义的是能力契约不关心实现类从哪来也不要求它们之间有血缘关系。比如你要设计一个支付模块public interface Payable { void pay(BigDecimal amount); } public class WechatPay implements Payable { Override public void pay(BigDecimal amount) { // 调用微信支付SDK } } public class Alipay implements Payable { Override public void pay(BigDecimal amount) { // 调用支付宝SDK } } public class PaymentService { private Payable payable; public PaymentService(Payable payable) { this.payable payable; } public void pay(BigDecimal amount) { payable.pay(amount); } }调用方只需要一个Payable引用具体是微信支付还是支付宝由你在别处决定并注入进来。这就是接口多态带来的松耦合。以后要接银联新建一个UnionPay类实现Payable就能接入PaymentService一行都不用改。这种扩展方式在类继承里也能做但接口多态的好处是实现类可以来自完全不同的继承树灵活性高得多。实际工作中我遵循一个原则能用接口建模就不依赖抽象类能用抽象类就不依赖具体类。接口定义“能做什么”抽象类定义“是什么并且部分默认怎么做”具体类负责真正实现。这个原则写代码的时候每时每刻都在用。4. 三者协同实战用员工薪资系统把三个特性串成一条线4.1 需求设计与类结构理论讲再多不如写一个能跑的案例。我设计一个员工薪资系统需求如下公司有普通员工和经理两种角色未来可能加总监、实习生等。所有员工有姓名、工号、入职年份。普通员工工资 基础工资 工龄工资每年加200。经理工资 基础工资 工龄工资 管理津贴。系统需要遍历所有员工统一计算总薪资并打印每个人的薪资明细。这个需求天然适合三大特性用封装把员工的属性和计算逻辑绑在一起把字段隐藏起来用继承建立Employee和Manager的父子关系复用公共字段和公共方法用多态让系统遍历时用统一的Employee引用调用salary()实际执行各自的计算逻辑。4.2 完整实现与关键点讲解先看父类public abstract class Employee { private final String name; private final String empId; private final int joinYear; private final BigDecimal baseSalary; public Employee(String name, String empId, int joinYear, BigDecimal baseSalary) { this.name name; this.empId empId; this.joinYear joinYear; this.baseSalary baseSalary; } public BigDecimal getBaseSalary() { return baseSalary; } protected int getWorkYears(int currentYear) { return currentYear - joinYear; } public abstract BigDecimal salary(int currentYear); Override public String toString() { return Employee{ name name \ , empId empId \ }; } }注意几个设计细节name、empId、joinYear声明为private final只能通过构造器赋值保证了员工创建后基本信息不可变。getWorkYears是protected因为它是给子类扩展用的计算工具不对外暴露。salary是抽象方法父类不知道子类到底怎么算工资强制子类必须实现。再看两个子类public class Developer extends Employee { private final int projectBonus; public Developer(String name, String empId, int joinYear, BigDecimal baseSalary, int projectBonus) { super(name, empId, joinYear, baseSalary); this.projectBonus projectBonus; } Override public BigDecimal salary(int currentYear) { BigDecimal seniority new BigDecimal(getWorkYears(currentYear) * 200); return getBaseSalary().add(seniority).add(new BigDecimal(projectBonus)); } } public class Manager extends Employee { private final BigDecimal managementAllowance; public Manager(String name, String empId, int joinYear, BigDecimal baseSalary, BigDecimal managementAllowance) { super(name, empId, joinYear, baseSalary); this.managementAllowance managementAllowance; } Override public BigDecimal salary(int currentYear) { BigDecimal seniority new BigDecimal(getWorkYears(currentYear) * 200); return getBaseSalary().add(seniority).add(managementAllowance); } }这两个子类都继承了Employee的公共字段和工具方法各自的salary重写了工资算法。注意子类构造器第一行调用super(...)把参数传给父类构造器这是Java继承中构造链的标准写法。最后看多态的应用import java.math.BigDecimal; import java.util.List; public class PayrollSystem { public static void main(String[] args) { ListEmployee employees List.of( new Developer(张三, E001, 2020, new BigDecimal(8000), 5000), new Manager(李四, E002, 2018, new BigDecimal(15000), new BigDecimal(8000)) ); BigDecimal total BigDecimal.ZERO; for (Employee emp : employees) { BigDecimal salary emp.salary(2025); System.out.println(emp 本月应发工资: salary); total total.add(salary); } System.out.println(公司本月薪资总额: total); } }这段代码里employees列表的声明类型是ListEmployee但里面装的实际对象一个是Developer、一个是Manager。遍历的时候emp.salary(2025)这一行就是多态的体现编译期编译器只知道emp是Employee类型运行期实际执行的是Developer或Manager各自的salary方法。这个案例最大的价值在于将来新增一个岗位类型比如总监Director只需要写一个Director类继承Employee实现salary方法然后往集合里加一个对象。PayrollSystem主流程的代码一行都不用改。这就是面向对象设计的核心收益对扩展开放对修改关闭。4.3 为什么BigDecimal而不是double细心的读者可能注意到了案例里所有金额都用BigDecimal而不是double。这个选择背后有个经典教训浮点数在计算机里是二进制表示的像0.1这种十进制小数无法被二进制精确表示所以double计算金额时会出现0.10.20.30000000000000004这种诡异结果。做薪资计算、金融交易一分钱的误差都不能忍必须用BigDecimal精确保存每一位小数。另外构造BigDecimal时一定要用字符串构造器new BigDecimal(8000)而不是new BigDecimal(8000)。原因说来也简单new BigDecimal(8000)底层会把8000这个整数先转成double再转成BigDecimal转换过程中可能引入精度误差而字符串构造器能保证原样解析。这也是很多初学者一开始就踩的坑。5. 高频翻车点与排查经验实录5.1 父类构造器中调用了可重写的方法这是Java继承里最经典的坑也是很多线上事故的元凶。先看代码public class Parent { public Parent() { init(); } protected void init() { System.out.println(Parent init); } } public class Child extends Parent { private final String name child; Override protected void init() { System.out.println(Child init, name name); } }创建new Child()时子类构造器会触发父类构造器父类构造器里的init()因为多态机制实际调用的是子类重写后的init()。但此时子类的name字段还没初始化因为子类构造器还没执行完字段初始化在构造器开头位置才完成所以你会看到Child init, namenull。如果init()里还做了空指针判断直接NPE也是常有的事。解决办法很简单不要在父类构造器里调用任何可被重写的方法。如果父类的初始化逻辑需要扩展点用protected final方法或者在构造器外提供一个显式的初始化方法由调用方手动调用。5.2 重写时访问权限、返回类型和异常范围的限制重写一个方法时有三个隐性约束违反任何一个都会编译报错约束项规则反例访问权限不能比父类更严格父类public子类改protected返回类型可以是父类返回类型的子类型父类返回Object子类返回String异常范围抛出的受检异常不能比父类更宽父类不抛异常子类抛IOException这三个约束背后的设计思路是一致的重写后的方法必须能安全替代父类方法。如果子类把访问权限收紧了、或者抛出了更多受检异常那调用方按照父类接口写出来的代码在子类对象身上就可能失效。5.3 static方法不参与重写静态方法属于类而不属于对象所以static方法不参与重写。如果你在子类里写了一个与父类static方法签名完全相同的方法这不是重写而是隐藏。调用哪个方法取决于引用类型而不是实际对象类型Parent p new Child(); p.staticMethod(); // 调的是Parent的staticMethod不是Child的 Child c new Child(); c.staticMethod(); // 调的是Child的staticMethod同理private方法因为子类不可见也不存在重写一说。final方法禁止重写是设计者刻意锁定的扩展点。这三个“不参与重写”的方法在实际代码审查里经常会遇到“以为自己重写了但实际没生效”的诡异问题排查的时候第一反应就是去看方法是不是static、private或final。5.4 向下转型必须配合instanceof检查多态允许向上转型也就是把子类对象赋给父类引用这个隐式转换没有风险。但反向的向下转型也就是把父类引用强制转成子类类型属于显式强转如果实际对象类型不匹配运行期直接抛ClassCastException。Animal a new Dog(); Cat c (Cat) a; // 运行期报错ClassCastException安全写法是先判断再转换if (a instanceof Cat) { Cat c (Cat) a; // 安全 }从Java 16开始instanceof还支持模式匹配可以直接在分支里拿到目标类型的变量if (a instanceof Cat cat) { System.out.println(cat.meow()); }我做代码审查时有一条硬性要求任何向下转型之前必须有instanceof判断禁止裸转。裸转像定时炸弹总有一天会在你意想不到的数据里炸开。5.5 重写equals不重写hashCode这个坑看起来跟三大特性关系不大但实际影响很大。Java里hashCode和equals有约定两个对象equals相等hashCode必须相等反之不要求。如果你只重写equals不重写hashCode对象放进HashSet或HashMap时哈希值是用Object默认的地址值算的即使两个对象equals逻辑上相等它们的哈希值不同会被当作不同的key存放。这会导致集合里出现重复元素、缓存查不到数据等诡异问题。重写equals时记得同时重写hashCode并且保证equals中比较的字段变化时hashCode也能跟着变化。实践中最简单的做法是用Objects.hash(...)Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Employee employee (Employee) o; return Objects.equals(empId, employee.empId); } Override public int hashCode() { return Objects.hash(empId); }这里用empId做唯一标识多少工龄、什么职位都不影响等值判断业务上也是说辞——员工只要有相同工号哪怕其他属性不同也应该视为同一个人。5.6 继承层级过深导致的重构方案最后聊一个架构层面的问题如果你接手的老项目里存在一个八层深的继承链怎么处理第一步画继承关系图确认每个继承是否满足is-a关系。不满足的直接拆掉改成组合。比如“JSONUtil”继承“HTTPClient”这种一看就是“为了复用而继承”必须拆成“JSONUtil持有HTTPClient引用”的组合结构。第二步把公共代码下沉到接口。很多时候类之间只是“都有某个行为”而不是“本质上是同一种东西”。这种情况更适合用interface抽取共同行为契约用默认方法提供通用实现让每个类各取所需实现。第三步能加sealed就加上。对核心领域类的继承范围做个硬性约束防止后面的同事乱继承、乱扩展。我改过的项目里只要把继承范围锁住后续维护负担立刻降一个量级。写在最后的一点个人建议如果你刚学完面向对象基础最有效的巩固方式不是刷八股文而是自己动手做一个带继承体系的小项目。不需要多复杂一个动物类体系、一个图形面积计算、一个员工薪资系统把三大特性都用上跑通再写几个测试用例验证多态行为比死记硬背强十倍。我面试别人的时候最喜欢问的一个问题是你能不能从自己的项目里找出一个真正用上了多态的场景讲给我听能答上来的人通常不是背了几道面试题而是真的在自己的代码里体会到过“新加一个类型老代码一行没改”的爽快感。这种体会只有亲手写过才拿得走。
返回列表