Java OOP练习题解析:从封装继承多态到可扩展设计

发布时间:2026/9/9 5:00:56
Java OOP练习题解析:从封装继承多态到可扩展设计 带新人这几年我有个很深的感触很多Java基础看起来都会甚至能把“封装、继承、多态”这几句定义倒背如流但真让他们写一道完整的OOP练习题写出来的代码要么一堆public字段要么用instanceof到处判断类型要么继承关系设计得让人头皮发麻。这不是个例而是“背八股”和“会写代码”之间最常见的断层。如果你正在准备java面试、java笔试我特别建议你认真做几道有质量的OOP练习题。面试官面前“封装继承多态”的定义背得再顺也不如一张写得干净、能体现设计思路的代码截图有说服力。java八股文能帮你过初筛但真正拉开差距的是你能不能把一个业务场景抽象成合理的类结构。这篇文章就围绕我自己给新人设计的练习题目展开每一道都带解析和踩坑记录希望能帮你把Java的OOP基础从“会念”变成“会用”。1. 先理清目标OOP练习题到底在练什么1.1 看似简单为什么有人一写就废我见过太多人对着题目第一反应是“这不就是定义几个类、写几个方法吗”等代码交上来问题就全暴露了。举个例子让写一个用户类要求有姓名、年龄、邮箱。很多人会这么写public class User { public String name; public int age; public String email; }这段代码单从语法看完全没问题但它几乎违背了封装的所有原则。字段全部public意味着任何外部代码都可以直接给age赋一个-10给email赋一串乱码。类本身没有任何自我保护能力数据的合法性完全依赖调用方自觉。这种代码出现在练习里还能勉强跑通一旦进入真实项目它就是线上脏数据的温床。所以我在设计练习题的时候从来不把“能跑通”当作验收标准。一道合格的OOP练习题应当能逼着你在代码里做设计决策哪些状态需要隐藏、哪些行为应该由对象自己负责、将来加新需求时哪些地方需要留扩展点。这是从面向过程思维转向面向对象思维的分水岭。1.2 我给练习题划的四个阶段练习不能一上来就谈设计模式那样会把新手劝退。我习惯把OOP练习分成四个阶段每个阶段只聚焦一个核心能力阶段核心能力验收标准第一阶段语法正确性类、对象、方法能正确声明和调用变量作用域不出错第二阶段封装意识字段私有化、提供受控访问、构造器校验、防御性拷贝第三阶段继承与多态应用代码能通过父类引用统一处理子类不依赖类型判断第四阶段设计可扩展性新需求到来时新增代码为主而不是修改旧代码为主很多自学的人直接跳到第二阶段之后的内容结果第一阶段的基础并不牢。比如有人连“重载”和“重写”都分不清就去研究策略模式写出来的代码自然是空中楼阁。练习题的顺序非常重要我在下文给出的题目也是严格按照这个阶梯来排的建议一步一步来不要跳。2. 第一梯队题目把封装练成条件反射2.1 题一带安全边界的银行账户题目描述设计一个BankAccount类包含账号、户主姓名、余额三个属性。要求不能允许余额为负数存款金额必须大于0取款金额必须大于0且不能超过当前余额。提供查询余额的方法但不允许外部直接修改余额字段。这道题我用了很多年它检验的就是最基本也最核心的封装能力。第一次做的人可能会写成这样public class BankAccount { private String accountNo; private String ownerName; private double balance; public BankAccount(String accountNo, String ownerName, double initialBalance) { this.accountNo accountNo; this.ownerName ownerName; this.balance initialBalance; } public void deposit(double amount) { if (amount 0) { throw new IllegalArgumentException(存款金额必须大于0); } this.balance amount; } public void withdraw(double amount) { if (amount 0) { throw new IllegalArgumentException(取款金额必须大于0); } if (amount this.balance) { throw new IllegalArgumentException(余额不足); } this.balance - amount; } public double getBalance() { return balance; } }代码看着不多但里面藏了好几个容易被忽略的关键点。第一个是构造器里的校验。有些版本会在构造器里直接给balance赋值但当initialBalance传的是负数时对象依然被成功创建了。这相当于让一个非法状态的账户出生了。我在指导新人时总强调“对象一旦创建就应该处于合法状态”所以构造器里需要自己判断public BankAccount(String accountNo, String ownerName, double initialBalance) { if (accountNo null || accountNo.isBlank()) { throw new IllegalArgumentException(账号不能为空); } if (ownerName null || ownerName.isBlank()) { throw new IllegalArgumentException(户主姓名不能为空); } if (initialBalance 0) { throw new IllegalArgumentException(初始余额不能为负数); } this.accountNo accountNo; this.ownerName ownerName; this.balance initialBalance; }第二个是setter的问题。有新人会问“既然余额需要通过deposit和withdraw来改那还要不要写setBalance”我的回答是在业务约束下setBalance往往不需要暴露因为余额的变更只有存款和取款两种合法途径。练习题的设计目标并不是把每个字段都配上getter/setter而是要“暴露有价值的行为隐藏不必要的细节”。如果一个setter允许外部直接绕过业务规则来改余额那封装就形同虚设。这个练习做完之后建议再扩展一个场景打印账户信息。你可以在BankAccount里重写toString方法。toString的价值并非只是在调试时能看到内容它也是在定义这个类的“对外展示方式”。练习时把toString写好后面学日志、学测试都顺手很多。2.2 题二不可变配置对象与防御性拷贝第二个封闭训练我经常用“系统配置”或“课程列表”来做。比如设计一个Course类里面有一门课程的名称、价格和一个List 类型的标签列表。要求外部不能通过getter拿到内部List之后随意增删数据。这个题目的核心考点其实是“引用传递的陷阱”。很多初级开发者不知道getter返回一个集合的时候返回出去的不是副本而是内部那个集合对象的引用。外部拿到之后执行add或者remove类内部的数据跟着就变了。最典型的翻车现场是这样public class Course { private String name; private ListString tags; public Course(String name, ListString tags) { this.name name; this.tags tags; // 隐患直接持有外部传入的List引用 } public ListString getTags() { return tags; // 隐患外部可以通过返回值修改内部数据 } }外部只需要一行代码就能往课程里塞一个不属于它的标签Course course new Course(Java OOP, List.of(编程)); course.getTags().add(被外部篡改);这不是危言耸听真实项目里很多诡异的“数据被改了但找不到是谁改的”问题源头就是某个集合的getter没有做保护。练习中应该引入“防御性拷贝”的做法public class Course { private final String name; private final ListString tags; public Course(String name, ListString tags) { this.name name; this.tags new ArrayList(tags); // 拷贝外部传入的列表 } public ListString getTags() { return Collections.unmodifiableList(tags); // 返回不可修改视图 } }这里有两个细节值得展开。第一个是构造器里的new ArrayList(tags)它把外部传入的列表复制了一份。这样即使外部代码后续再去修改原来的tags列表Course内部的数据也不会受影响。这叫构造阶段的防御性拷贝。第二个是getter里的Collections.unmodifiableList(tags)它返回的是一个“只读视图”。注意它不是把数据复制了一份而是给原列表加了一道禁止修改的屏障。外部如果尝试调用add或remove会直接抛UnsupportedOperationException。这样做的好处是内部仍然复用同一个列表不浪费内存坏处是外部虽然改不了结构但如果列表里的元素本身是可变对象那还是有被间接修改的风险。如果列表里装的是自定义对象通常还要考虑更深一层的拷贝。这道题能做到前两层就已经在练习题层面具备很好的封装意识了。3. 第二梯队题目继承和多态要会“写对”3.1 题三员工薪资计算——用抽象类搭骨架题目描述某公司有多种员工类型全职员工按月发固定工资兼职员工按小时计费实习生的工资则是一个固定津贴。请设计员工类体系并实现一个方法能够统一计算不同员工当月的工资。很多第一次接触这道题的人会进入两种极端。一种是把所有逻辑塞进一个Employee类用type字段区分然后一个巨大的switch另一种是知道要用继承但完全没有公共抽象每个子类各写各的方法调用方还要用instanceof判断类型再强转调用。这两种代码都算不上“面向对象”而只是穿了对象外衣的过程式写法。比较好的起点是定义一个抽象父类或接口。这里我倾向于用抽象类因为所有员工共有姓名、工号等属性这些状态和行为骨架放父类里是合理的public abstract class Employee { private String id; private String name; public Employee(String id, String name) { this.id id; this.name name; } public abstract double calculateSalary(); public String getInfo() { return 工号 id , 姓名 name; } }之后让全职、兼职、实习生分别继承它public class FullTimeEmployee extends Employee { private double monthlySalary; public FullTimeEmployee(String id, String name, double monthlySalary) { super(id, name); this.monthlySalary monthlySalary; } Override public double calculateSalary() { return monthlySalary; } } public class PartTimeEmployee extends Employee { private double hourlyRate; private double hours; public PartTimeEmployee(String id, String name, double hourlyRate, double hours) { super(id, name); this.hourlyRate hourlyRate; this.hours hours; } Override public double calculateSalary() { return hourlyRate * hours; } } public class InternEmployee extends Employee { private double stipend; public InternEmployee(String id, String name, double stipend) { super(id, name); this.stipend stipend; } Override public double calculateSalary() { return stipend; } }写到这里我一般会停顿一下让新人看一个关键点如果调用方手里拿的是Employee类型的引用那么它并不需要知道自己调用的到底是哪种子类。只需要public class SalaryManager { public double calculateTotal(ListEmployee employees) { double total 0; for (Employee employee : employees) { total employee.calculateSalary(); } return total; } }这里真正发生了多态JVM在运行时会根据对象的实际类型去调用对应子类里的calculateSalary方法。这就叫动态绑定。很多面试者把“重写”和“多态”混在一起讲其实重写是语法机制多态是借助继承和重写产生的运行时效果。当你能写出无判断的统一遍历才说明你真的理解了多态。3.2 题四支付渠道切换——用接口定义行为前面的员工题适合用抽象类接下来这道题就该练接口了。题目描述系统需要支持支付宝、微信、银行卡三种支付方式每种支付方式都有支付和退款两个动作。请设计一套结构让业务层可以自由切换支付渠道而不需要修改核心下单代码。这道题的难点不在于写一个接口和三个实现类而在于习惯“面向接口编程”。很多人在代码里定义了接口但实际调用时还是免不了写if (channel.equals(alipay)) { new AlipayService().pay(amount); } else if (channel.equals(wechat)) { new WechatService().pay(amount); }这种写法虽然用了接口但本质还是过程式。问题在于每新增一个渠道都要去改调用处的if逻辑。真正的接口设计应当让调用方不感知具体实现。定义接口后配合一个简单的工厂来创建对象public interface PaymentService { void pay(double amount); void refund(double amount); }public class AliPayService implements PaymentService { Override public void pay(double amount) { System.out.println(支付宝支付 amount); } Override public void refund(double amount) { System.out.println(支付宝退款 amount); } }public class PaymentFactory { public static PaymentService create(String channel) { return switch (channel) { case alipay - new AliPayService(); case wechat - new WechatService(); case card - new CardService(); default - throw new IllegalArgumentException(未知支付渠道: channel); }; } }这样业务层调用处就非常干净PaymentService paymentService PaymentFactory.create(channel); paymentService.pay(100);把渠道选择收敛到工厂里是练习里很实用的一步。虽然这里还只是一个简单的switch但扩展新渠道时只需要改工厂一个地方核心业务代码完全不动。等后续学习用Spring管理Bean时连工厂里的switch都可以去掉系统会通过依赖注入自动选择实现。到时候你会感谢当初认真练过“面向接口编程”的自己。4. 第三梯队题目完整的综合题复盘4.1 综合题订单折扣系统的第一版是什么样单点知识练完以后我总是安排一道综合题把封装、继承、多态、接口全部串起来。这里分享一道我带新人练过的订单折扣题。题目描述一个电商系统在下单时要计算最终付款金额。商品原价合计为totalAmount。如果用户是普通会员不打折如果是黄金会员打95折如果是白金会员打9折。另外如果订单原价满500元优惠后再减30元。要是碰上活动期间有额外的折扣码比如输入“SAVE20”可以在优惠基础上再打8折。请设计计算逻辑。我把第一版先发出来大家看着一定眼熟因为很多人一上来就这么写public class OrderService { public double calculate(String memberLevel, double totalAmount, boolean isActivityDay, String couponCode) { double payAmount totalAmount; if (GOLD.equals(memberLevel)) { payAmount * 0.95; } else if (PLATINUM.equals(memberLevel)) { payAmount * 0.9; } if (payAmount 500) { payAmount - 30; } if (isActivityDay SAVE20.equals(couponCode)) { payAmount * 0.8; } return payAmount; } }写得很诚实的说这段代码能跑也把业务规则实现了。但它最大的问题在于所有规则都叠在一个方法里面顺序完全写死了。比如“满500减30”究竟是按原价判断还是按会员折扣后的价格判断不同版本需求可能截然不同。将来再加入新的优惠券规则此方法就会越来越臃肿。你能顺着if继续加但代码的可读性和可测试性会越来越差。这种实现还暴露了一个面向对象层面的问题会员等级和打折规则全部是字符串和裸数值极其容易传错。调用方传一个“gold”小写折扣就不生效了。在练习中我们要有意识地把这些隐含的“类型信息”建模成对象。4.2 重构用多态和组合替换if-else链路我在带练时一定会给新人演示一种改法。第一步把折扣策略抽象成接口public interface DiscountStrategy { boolean shouldApply(OrderContext context); double apply(double amount, OrderContext context); }OrderContext可以装下单用户的会员等级、订单原价、是否活动日、折扣码等信息。这样每个具体策略都能独立判断自己是否需要生效public class GoldMemberDiscount implements DiscountStrategy { Override public boolean shouldApply(OrderContext context) { return GOLD.equals(context.getMemberLevel()); } Override public double apply(double amount, OrderContext context) { return amount * 0.95; } } public class FullReductionDiscount implements DiscountStrategy { Override public boolean shouldApply(OrderContext context) { return context.getOriginalAmount() 500; } Override public double apply(double amount, OrderContext context) { return Math.max(0, amount - 30); } } public class CouponDiscount implements DiscountStrategy { Override public boolean shouldApply(OrderContext context) { return context.isActivityDay() SAVE20.equals(context.getCouponCode()); } Override public double apply(double amount, OrderContext context) { return amount * 0.8; } }这样设计之后每种打折规则都是独立对象各自内部封装自己的判断逻辑和计算逻辑。OrderService里不再需要一长串if括号它只需要按顺序执行一组策略即可public class OrderService { private final ListDiscountStrategy strategies; public OrderService(ListDiscountStrategy strategies) { this.strategies strategies; } public OrderResult calculate(OrderContext context) { double payAmount context.getOriginalAmount(); ListString appliedRules new ArrayList(); for (DiscountStrategy strategy : strategies) { if (strategy.shouldApply(context)) { payAmount strategy.apply(payAmount, context); appliedRules.add(strategy.getClass().getSimpleName()); } } return new OrderResult(payAmount, appliedRules); } }这个版本最大的变化在于后续新增促销策略时不需要改动OrderService只需新增一个DiscountStrategy实现类并把它加进策略列表。这就是所谓的对修改关闭、对扩展开放。新人们看到这里往往会有一种“原来如此”的感觉书上说的开闭原则其实在练习题里就能落地。我并没有要求每个人第一次就写出这样的结构。说得直白点能重构到这个程度需要大量经验的累积。练习的目标是先见过好的设计再在自己动手时有意识地朝这个方向靠。哪怕第一版还是if-else只要第二步能说出“这里有变化点可以抽接口”高阶能力已经在萌芽了。4.3 这个练习里容易被忽略的三个细节一是金额计算不能直接用double。练习里大家都写得轻松但订单金额如果用double参与多次乘除会积累精度误差。真实项目一般用BigDecimal或者以“分”为单位的整数。我建议练习时就养成用BigDecimal的习惯哪怕只是几步运算这也是好习惯的一部分。后续写金融相关项目时这个习惯能帮你少踩很多坑。二是策略生效顺序的问题。上面的List 是有顺序的这个顺序直接决定了满减是按原价判断还是按会员折扣后价格判断。练习里要能明确指出“顺序是业务规则的一部分”把它做成可配置的而不是写死在代码里。很多人重构时只想着抽接口恰恰忘了顺序本身也是一条规则。三是OrderResult要尽量设计成不可变对象。如果你返回一个能随意修改内部字段的结果对象调用方就有可能把支付金额改成任何值。综合练习做完以后强烈建议回头审视一下自己的返回对象确保它只有getter并且所有字段在构造时就固定下来。对封装的理解到了这一步才算真正落到实处。5. 常见错误与面试向复盘5.1 六个特别典型的OOP低级错误练习题做得多了以后我发现许多错误是反复出现的完全可以整理成一份避坑清单。这里列六个我在带人时遇到过的高频问题。第一字段权限控制太随意。很多练习代码为了省事直接把字段声明成public或者在private字段旁配一个无脑setter。整个对象就像一扇敞开的门谁能进来都能翻一圈。请记住一句话能不给外界篡改机会的状态就不给不是所有字段都需要setter。第二重载和重写傻傻分不清。重载是同一个类里方法名相同但参数列表不同编译期就能确定调用谁重写是子类对父类方法的重新实现参数列表必须一致。练习题里如果为了模仿多态写了一个“看起来同名但参数列表不同”的假重写运行时根本不会发生动态绑定。这种bug相当隐蔽建议在类上加上Override注解编译器能帮你发现不少问题。第三equals重写了却没有重写hashCode。练习中比较两个对象是否相等时很多人会自己写equals却忘了hashCode。一旦把对象放进HashSet或HashMap里就会出现“已经包含了同一个对象但contains返回false”的诡异现象。OOP练习不只是练类设计还要配合常用集合类这条规则几乎必考也必错。第四构造器里调用了可重写方法。有些新人喜欢在父类构造器里调一个public方法然后在子类里重写这个方法。他们不知道父类构造器执行时子类对象还没完全构造好。此时调用子类重写方法可能读到null字段或默认值。这类错误在面试聊天里也经常被追问如果能用自己的代码说明白会显得非常扎实。第五用instanceof来“找类型”。当代码中出现连续多个instanceof判断时基本说明多态性没有被利用。比如拿到一个Employee先if (e instanceof FullTimeEmployee)再强转取工资再else if判断兼职。这种操作完全可以被抽象方法calculateSalary()替代。出现大量instanceof意味着你对“把行为下沉到子类”这个思路还没形成直觉。第六继承被滥用导致耦合过深。练习里有人把鸟和企鹅放在一个继承体系下Bird类里定义了fly方法企鹅继承后只能抛异常或空实现。正确做法往往是把“会飞”抽象成接口Flyable让会飞的鸟实现它。练习时不要为了用继承而用继承组合和接口常常是更轻的选择。我整理了一份对照表方便快速自查错误表现本质问题正确方向字段public没有封装private 受控方法看到子类就强转调用多态性利用不足把差异行为抽到父类或接口一个方法三个if分支判断类型过程式思维策略接口 多态分发构造器调可重写方法对象未完全创建用private方法或工厂消除风险equals和hashCode不配对违反对象约定同时重写并理解为什么为了复用代码强行继承继承耦合优先考虑组合和接口5.2 练习做完后怎么问自己才算有效复盘我常对新人说做OOP练习题的价值不在于做出一个正确结果而在于做完之后的复盘。很多同学把题目提交、看到输出正确就觉得自己会了这个习惯其实挺亏的。一道练习做完以后应该逼自己回答几个问题。第一个问题如果需求增加一种新类型我要改哪几个文件比如员工薪资题里新增“外聘顾问”如果只需要新增一个子类不用动SalaryManager那么你的设计是健康的如果还要在Manager里补一个if分支就说明扩展点找得不够准。第二个问题我的类是解决“是什么”还是解决“能干什么”这是个很微妙的判断。比如做一个Car类它是什么它有引擎、车型、颜色它能干什么能启动、能加速、能刹车。如果类里塞了一大堆只有特定场景才用的方法职责就已经不清晰了练习时要果断拆分。第三个问题这段代码能单测吗如果一条方法里链路太长、依赖太多很难为它单独写测试。面向对象的很多设计原则最终都在为可测试性服务。你能为某个策略类单独写一组测试本身就是设计成功的表现。我判断一个新人是不是真正入门了Java的对象思维通常不是看他能默写出多少概念而是看他能不能对着自己刚写的练习代码讲清楚上面三个问题的答案。这比单纯背十道java面试八股文要有效得多。面试官如果问你“你做过什么练手的项目”你与其说“我看了某某教程”不如把这类设计过程和改了几版代码的真实经历讲出来。对方一听就知道你是有代码体感的人不是只会复制粘贴。最后再分享一个我自己的习惯练OOP题不要只做一遍。第一遍做对以后放在一边第二天不要看旧代码重新从头实现一遍然后把第二版和第一版做对比。哪里写得更顺了哪里还是绕不过来那些不顺畅的位置往往就是你脑子里对面向对象理解还模糊的地方。反复几次之后写类的时候哪些字段该私有、哪些行为该下沉、哪个扩展点该抽接口就会变成一种本能。本能的背后才是真正的Java基础。