1. 拿到题目先别急着写代码先想清楚这几点“Java 面向对象设计题3”这类题目在面试和课程设计里出现的频率非常高。很多初学者一看到“设计题”三个字下意识就是开IDE、建类、写属性、写方法噼里啪啦敲完一堆代码结果要么是逻辑漏洞百出要么是类与类之间的关系混乱完全经不起推敲。实际上这类题目考察的核心从来不是“你会不会写Java语法”而是你有没有面向对象的思维方式——能不能把现实世界的事物抽象成类和对象能不能用继承、封装、多态去处理复杂的关系能不能在需求变化时让代码结构足够优雅地扩展。以我个人的经验这类题适合三类人第一是准备Java面试的求职者尤其是初中级岗位设计题几乎是必考的环节而且面试官往往会在你写完代码后追问“你觉得哪里还能改进”这一问就能筛掉一批只会背语法的人第二是正在做课程设计或毕业设计的在校学生很多题目听起来像“图书管理系统”“停车收费系统”这种本质考察的就是面向对象设计能力第三是想提升代码设计水平的开发人员哪怕工作了两三年如果平时不怎么思考抽象建模看到这类题依然会下意识用面向过程的思维去写。这篇文章我就以一道典型的“停车场计费系统”设计题为例完整带你把从需求分析、类设计、代码实现到扩展优化的全过程走一遍。讲到的东西不局限于这道题本身而是面向对象设计题通用的解题方法论你掌握这套思路之后不管遇到哪种题目都能举一反三。2. 把需求翻译成“类”是设计题最核心的一步2.1 题目到底在考什么很多人以为设计题考的是“写代码”其实完全不是。在动手写任何一行代码之前最先要做的事情是需求分析。所谓需求分析就是搞清楚题目里到底有哪些实体、哪些行为、哪些规则。这一步做不好后面全是空中楼阁。拿停车场计费系统来说粗略一读你可能觉得无非就是“车进来记住时间车出去算钱”。但稍微往深了想就会发现几个关键问题停车场里有哪几种车小车、大货车、摩托车收费一样吗收费规则是按小时、按半小时还是按次有没有免费停车时长如果停车超时跨天怎么办夜间收费有没有单独标准车位满了车还能进吗需不需要记录哪些车位是空的一辆车重复进出是重新计费还是累计计费这些问题如果不提前想清楚写出来的代码就只能是“玩具代码”。而面试官或老师要看的恰恰是你能不能在没有被明确告知所有规则的情况下通过合理的假设和设计把系统建得既完整又灵活。说白了这就是在借题目考察你“面对模糊需求的应对能力”。我在实际评审一些课程设计时发现绝大多数学生的代码死就死在需求没分析透就开始写。比如他默认所有车收费规则一样结果题目里明明写了“小车x元/小时货车y元/小时”他压根没看见。这种低级失误完全可以通过前期梳理需求来避免。2.2 实体识别先把“名词”找出来面向对象设计的第一步是找出系统中的候选实体。有一个非常朴素的方法把需求描述里的所有名词圈出来。停车场、车辆、车位、入场时间、出场时间、收费规则、收费记录……这些名词就是候选的类。但并不是每个名词都适合做成类。比如“入场时间”它更像是车辆对象的一个属性而不是独立的类。判断一个名词要不要做成类有一个简单的标准它的状态和行为是否可被独立描述是否会被多处引用。车辆有车牌号、入场时间、车型等多个属性所以车辆是类。停车记录需要把入场出场时间、费用、车牌都存下来方便后续查询所以停车记录也是一个类。而“入场时间”单独拎出来没有任何意义它就是车辆或记录的一个字段而已。这里我特别想强调一个初学者经常搞混的点类和对象的区别。类是模板对象是根据模板创建出来的具体实例。如果你的代码里只有类没有对象那说明你只是在“画模板”还没让系统真正跑起来。遇到设计题类设计完之后一定还要有主程序创建对象、调用方法才算完整。这一点倒是很多学生在设计题里容易忽略的——画了一堆类结果主程序里全是空壳。2.3 关系梳理继承、组合还是接口实体找出来之后下一步就是梳理实体之间的关系。关系无非三种继承is-a、组合has-a、依赖uses-a。很多设计题的核心考点就在这里。拿停车场系统继续举例。车辆有不同种类这就是典型的继承关系——小车、货车、摩托车都是车辆它们有共同属性车牌号、入场时间但又有各自的行为差异比如不同车型的收费系数。用继承实现就是抽一个父类Vehicle定义公共属性和方法子类继承父类并重写或扩展。车位和停车场之间则是组合关系停车场包含多个车位。写代码时这种“包含”关系通常用集合来表示比如停车场类里有一个List 字段。Java里常见的组合关系就是通过成员变量来实现的这也体现了“低耦合高内聚”的设计原则一个类的内部结构不应该暴露给外部外部只需要调用停车场对外开放的方法即可。依赖关系相对简单比如停车场需要调用收费计算器来计算费用这个调用就是一种依赖。在实际代码里表现为一个类的方法参数或局部变量引用另一个类。依赖关系讲究的是方向要清晰、依赖要尽量少。后面我会详细讲怎么通过接口来降低依赖的耦合度。3. 核心细节解析与实操要点从一个父类、一个接口和一个工具类说起3.1 父类怎么设计抽公共属性和公共行为先看核心代码。父类Vehicle的设计是整个继承结构的根基它的质量直接决定了子类写起来是否顺手public abstract class Vehicle { protected String plateNumber; protected LocalDateTime enterTime; public Vehicle(String plateNumber, LocalDateTime enterTime) { this.plateNumber plateNumber; this.enterTime enterTime; } public String getPlateNumber() { return plateNumber; } public LocalDateTime getEnterTime() { return enterTime; } // 计算基础停车费由子类实现 public abstract double calculateBaseFee(long minutes); }注意几个细节。第一字段用protected修饰而不是private。这是很多初学者犹豫的地方——按教科书上说封装应该用private但这里用protected有一个实际好处子类可以直接访问父类的字段不必写一堆getter。那为什么不全用public呢因为public会让所有外部类都能直接修改字段破坏封装性。protected是“中间路线”只对本类、同包类、子类可见适合这种要被子类频繁访问字段的场景。第二calculateBaseFee方法定义为abstract。这涉及一个非常重要的设计原则——父类定义行为规范子类负责具体实现。父类不需要知道“小车具体收多少钱”那是子类的事父类只需要告诉外部“所有车辆都能计算停车费用”具体怎么算子类自己决定。这就是面向对象里的“模板方法思想”的简化版。3.2 子类怎么实现每个车型的差异都要能独立变化父类设计好之后子类的代码就非常简洁了public class Car extends Vehicle { private static final double BASE_FEE 3.0; public Car(String plateNumber, LocalDateTime enterTime) { super(plateNumber, enterTime); } Override public double calculateBaseFee(long minutes) { return Math.max(1, (minutes / 60) 1) * BASE_FEE; } }这就是多态的体现。外部调用方完全不需要知道当前处理的是Car还是Truck只需要调用calculateBaseFee系统会自动找到对应子类的实现。进一步抽象可以把计费逻辑抽成独立的策略接口实现“策略模式”。这也是一个非常经典的优化方向如果收费标准经常变化比如临时搞活动打八折那就不应该把费率写死在子类里面应该把“计费策略”独立出来在运行时动态指定。这种设计思路在面上叫“把变化的部分封装起来”——这是设计模式里最高频的原则之一。如果一道设计题里收费标准是固定不变的那你用继承实现的子类重写就够了但如果需求强调“不同时段不同价格、不同车型不同折扣”那就必须引入策略模式或至少是接口多态的做法。3.3 要思考“怎么算”更要思考“谁来算”很多人设计这类系统时习惯把费用计算逻辑直接塞进停车场类比如在ParkingLot里写一个方法内部if-else判断车型然后套不同的公式。这个写法能跑但非常不优雅。原因在于停车场类的主要职责是“管理车位和设备”收费计算不应该成为它的核心责任。更好的做法是单独抽象出一个计算费用的服务类或者用前面说的策略接口去做。这里要先判断一下如果你发现某个类里出现了大量的if-else分支来判断类型那基本可以断定设计有问题。面向对象的优势就是通过各种机制继承、多态、策略把if-else消灭掉把分支转换成类之间的多态协作。4. 实操过程与核心环节实现一套可落地的停车场计费系统4.1 先画类图再写代码效率翻倍不管多简单的设计题我建议都先在纸上画一画类图。没必要用专业的建模工具哪怕手画一个草图都行。类图画出来每个类有哪些字段、哪些方法、类之间什么关系一目了然写代码的时候就只需要“照图施工”。我做这道题时的类图大致是这样的Vehicle抽象基类车牌号、入场时间、计算费用抽象方法Car、Truck、MotorcycleVehicle的子类各自实现计费ParkingLot容器类管理车位和已停车辆ParkingFeeCalculator策略接口定义统一的计费入口FlatFeeStrategy、HourlyFeeStrategy策略实现类多种计费规则ParkingService门面服务类对外提供入场、出场、结费的统一接口ParkingService这里多提一句在实际项目中这层叫门面Facade或者服务层。它的作用是给调用方比如主程序、Controller层提供一个干净的入口调用方不用关心底层车位的管理细节也不需要关心用的是哪种计费策略。这个设计在实际开发里非常常见面试中也算一个加分项。4.2 费用计算与结算流程的实现先定义一个策略接口public interface ParkingFeeCalculator { double calculateFee(Vehicle vehicle, LocalDateTime exitTime); }然后实现一个标准计费策略public class StandardFeeStrategy implements ParkingFeeCalculator { Override public double calculateFee(Vehicle vehicle, LocalDateTime exitTime) { long minutes Duration.between(vehicle.getEnterTime(), exitTime).toMinutes(); if (minutes 15) { return 0; // 免费15分钟 } return vehicle.calculateBaseFee(minutes); } }注意到没有StandardFeeStrategy里调用了vehicle.calculateBaseFee(minutes)但具体是Car的3块钱一小时还是Motorcycle的1块钱一小时这个类完全不知道。因为Vehicle的子类重写了该方法Java动态绑定机制会自动选择正确的那一个。这就是多态在真实业务场景中的落地。再看停车场的核心逻辑public class ParkingLot { private final int capacity; private final MapString, Vehicle parkedVehicles new HashMap(); public ParkingLot(int capacity) { this.capacity capacity; } public boolean isFull() { return parkedVehicles.size() capacity; } public boolean park(Vehicle vehicle) { if (isFull()) { return false; } parkedVehicles.put(vehicle.getPlateNumber(), vehicle); return true; } public Vehicle removeVehicle(String plateNumber) { return parkedVehicles.remove(plateNumber); } }使用Map存储车辆而不是List是因为车辆进场、出场频繁用Map可以用车牌号作为key实现O(1)级别的查找。这也是一个实用性细节很多初学者会下意识用List结果出场时要遍历查找数据量一大就慢了。入口服务层的代码长这样public class ParkingService { private final ParkingLot parkingLot; private final ParkingFeeCalculator feeCalculator; public ParkingService(int capacity, ParkingFeeCalculator feeCalculator) { this.parkingLot new ParkingLot(capacity); this.feeCalculator feeCalculator; } public boolean enter(String plateNumber, String vehicleType, LocalDateTime enterTime) { Vehicle vehicle createVehicle(plateNumber, vehicleType, enterTime); boolean success parkingLot.park(vehicle); if (!success) { System.out.println(车位已满无法入场 plateNumber); } return success; } public double exit(String plateNumber, LocalDateTime exitTime) { Vehicle vehicle parkingLot.removeVehicle(plateNumber); if (vehicle null) { throw new IllegalArgumentException(未找到对应车辆 plateNumber); } double fee feeCalculator.calculateFee(vehicle, exitTime); System.out.println(车辆 plateNumber 出场停车费用 fee 元); return fee; } }由于ParkingService的构造器接收的是一个ParkingFeeCalculator接口意味着以后想加入夜间优惠策略只需要新建一个类实现ParkingFeeCalculator接口就行ParkingService完全不需要改动。这就是面向接口编程带来的扩展性优势。4.3 主程序跑通全链路写一个主程序测试一下效果public class Main { public static void main(String[] args) { ParkingService service new ParkingService(100, new StandardFeeStrategy()); service.enter(京A12345, car, LocalDateTime.now().minusHours(2)); service.enter(沪B88888, truck, LocalDateTime.now().minusMinutes(30)); service.exit(京A12345, LocalDateTime.now()); service.exit(沪B88888, LocalDateTime.now()); } }运行结果会输出对应车辆的停车费用。整个代码只用了四个核心类加一个接口但结构层次分明逻辑清晰每一层只做自己的事情互不越界。这就是设计题的理想状态——代码不多但每个类都说得清楚“我是谁、我在哪、我要干什么”。5. 常见问题与排查技巧实录这些坑我踩过不想你再踩5.1 继承用得越多越好不是有些同学学了继承之后喜欢什么都抽象一层搞出“交通工具vehicle类下面再分陆地交通工具、水上交通工具、空中交通工具”这样的结构。落到具体的停车场系统里就是Vehicle下面分MotorVehicleMotorVehicle下面再分Car和Truck。大多数情况下这完全是在过度设计。继承是一把双刃剑。抽象层级太深代码的可读性会急剧下降一个方法调用要沿着继承链一层层找。更重要的是Java的继承是单继承如果你把继承层级用完了以后想增加另一个维度的抽象比如“新能源车”“燃油车”就非常困难。在设计题里除非题目明确要求否则最多两层继承就够了。把“多变的部分”用接口组合而不是继承往往更灵活。一颗比较实用的判断标准如果子类之间几乎没有行为差异只有参数不同那就没必要建子类用一个类加 type 字段就够了。比如不同车型的收费系数如果只是数值不同用一个Vehicle类加一个vehicleType属性就能解决不需要搞Car、Truck、Motorcycle三个子类。很多面试官问“什么时候用继承、什么时候用组合”本质就是这道题。5.2 equals、hashCode、时间比较这些“边角料”别翻车设计题里容易被忽视的是基础类的规范写法。比如Vehicle类如果作为Map的key就必须重写equals和hashCode。而如何判断两辆车是同一辆大概率靠车牌号。但如果忘记重写equalsMap按引用比较会导致你在停车场里找“京A12345”那辆车时永远找不到——因为那是在栈上new出来的另一个引用。这类问题特别隐蔽我帮同学排查代码时遇到过好几次。排查技巧也很简单进停车场打印vehicleMap.size()发现进出几次之后Map里越来越多就是equals没用好。还有一个坑是时间计算。Java里计算时间差值要用Duration或Period但如果两辆车入场时间在同一分钟内用仅到分钟的getTime()相减可能得到0计费会出问题。建议入场出场时间用LocalDateTime记录包含时分秒甚至纳秒即便同一分钟进出也能算到秒级别。不要在时间字段上用Date和long混用很容易在算费时被绕晕。5.3 业务逻辑放错位置类就“脏”了这是评审代码时最常指出的问题之一。很多人会把“是否免费停车15分钟”的判断直接写在停车场park方法里把“计算停车费用”的逻辑直接写在Vehicle类的getter中。这种代码运行没问题但类与类之间的边界被搅浑了。停车场只管车位管理车辆只管自身属性和基础计费免费时长、费率这些业务规则应该收敛在计费策略中。如果你的每个类都能用一句简洁的话概括职责你的设计就已经比较好了。5.4 面试官追问“如何扩展”时的回答套路如果这是面试场景写完代码之后面试官大概率会问“如果现在停车场需要支持24小时封顶收费、新能源车前2小时免费你打算怎么改”千万不要直接说“在代码里加个if-else”。你要展示的是你的设计能通过新增类来应对变化。我的回答思路是新增进入策略类的领域——建一个CapFeeDecorator包装现有的计费策略实现封顶逻辑再建一个NewEnergyVehicle子类在calculateBaseFee里覆盖免费逻辑。核心代码零改动只加新类这就是开闭原则。多说一句把“24小时封顶”做成装饰器模式有点高级面试中加分但在课程设计中不一定需要。如果只是为了交作业直接在策略类里加个if判断也可以不要为了模式而模式反而把简单问题复杂化。5.5 基础语法细节决定代码能不能直接跑起来说个平时经常见的启动报错就是这个项目里很典型的“反面教材”。很多人在自己的电脑上运行Java代码没问题但外带或交作业后别人用另一个JDK版本一跑就报错java: 警告: 源发行版 17 需要目标发行版 17这个问题十有八九是IDE里Project Structure的SDK版本和Language Level不匹配。尤其是拷贝别人代码或打开老项目时项目编译级别默认1.8但JDK装了17运行就报这种警告甚至Errorjava源发行版不支持。解决办法很简单在IDEA里点击File - Project Structure - Project把Project SDK和Language Level统一成同一个版本比如都设成17没有特殊原因就别混合设置。如果你用的是命令行编译那就在javac后面明确指定-source和-target参数。说到底同一个项目里的源版本、目标版本、SDK版本、模块设置要一致这个基础问题解决不了设计得再好代码也跑不起来。排查时有个小命令。如果你不确定当前环境用的JDK版本java -version javac -version两个版本不一致比如java是1.8而javac是11说明你的JAVA_HOME和PATH顺序有问题改环境变量即可。总之基础环境一致化是能写完代码的前提。6. 更进一步的思考设计题做完了还可以往这几个方向挖如果你不是急着交作业而是真想通过这道题提升设计能力我强烈建议你在现有代码基础上再做三件事。第一件事把用继承实现的车型收费改成用策略接口实现。你不需要删掉现有代码而是新增一个FeeStrategy接口再为每种车型实现一个计费类然后让Vehicle持有一个FeeStrategy字段。这样就理解了“组合优于继承”这条设计原则这个知识点在面试里被问到的概率非常高。第二件事引入设计模式。当前的代码里其实已经有两个模式雏形门面模式和策略模式。你可以继续扩展一个观察者模式比如每次有车辆入场出场都通知“停车场门口的大屏幕”刷新剩余车位数量。这是观察者模式的典型使用场景而且和停车场的业务自然结合。课程设计如果能讲到这一层评委普遍会觉得你的设计是有思考的。第三件事考虑并发问题。两个入口同时有一辆车要进场你的ParkingLot.park方法会不会出事HashMap不是线程安全的车位数量count也不是原子操作。即便不做题目要求你写文章时顺便提一嘴线程安全也已经体现了思维的差异。面试时如果面试官追问多线程场景你能答出来什么时候用ConcurrentHashMap、什么时候用锁基本就很加分了。7. 关于这类设计题的个人体会做设计题这几年我自己最大的感受是面向对象设计不像写算法题没有“唯一标准答案”但一定有一个“更符合设计原则的最优解”。同样一个停车场计费系统有人用三个类和一堆if-else也能实现但扩展起来会非常痛苦有人用一整套策略模式加门面模式结构清爽但可能偏重展示成本偏高。关键在于找到合适的抽象粒度不为了模式而模式也不为了简单而简单。我把这类题的评价标准归纳成一句话每一个类都能用一句话说清楚它负责什么每一处变动都能定位到明确的修改点。如果你的代码能做到这一点不管它有没有用到某个设计模式它都是一个合格的面向对象设计。希望这篇文章能给你一些启发。如果你也正在准备Java设计题建议拿你手头的题目按我上面的思路先花二十分钟画一画类图再开始写代码。这套方法我用了很多年不管是在面试里还是在日常项目设计中都帮我理清了不少思路。