带新人这几年,我有个很深的感触:很多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 List<String> tags; public Course(String name, List<String> tags) { this.name = name; this.tags = tags; // 隐患:直接持有外部传入的List引用 } public List<String> getTags() { return tags; // 隐患:外部可以通过返回值修改内部数据 } }外部只需要一行代码,就能往课程里塞一个不属于它的标签:
Course course = new Course("Java OOP", List.of("编程")); course.getTags().add("被外部篡改");这不是危言耸听,真实项目里很多诡异的“数据被改了但找不到是谁改的”问题,源头就是某个集合的getter没有做保护。练习中应该引入“防御性拷贝”的做法:
public class Course { private final String name; private final List<String> tags; public Course(String name, List<String> tags) { this.name = name; this.tags = new ArrayList<>(tags); // 拷贝外部传入的列表 } public List<String> 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(List<Employee> 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 List<DiscountStrategy> strategies; public OrderService(List<DiscountStrategy> strategies) { this.strategies = strategies; } public OrderResult calculate(OrderContext context) { double payAmount = context.getOriginalAmount(); List<String> 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基础。