说实话,我见过太多人学Java语法时觉得什么都懂了:继承会写,接口会定义,多态也知道是怎么回事。结果一到动手做项目,写出来的代码全是if-else堆功能,类之间互相牵着走,改一个地方炸一片。这个“Java 面向对象实战:智能家居控制系统”的练习,就是专门来治这个毛病的。
这个项目本身不大,但麻雀虽小五脏俱全:有设备、有控制逻辑、有状态管理、有批量操作,正好能把封装、继承、多态、接口、抽象类这些面向对象的核心概念全部串起来用一遍。练完之后你再回去看面试题里那些“什么是多态”“接口和抽象类有什么区别”,会发现自己不用背了,因为代码里已经写明白了。
如果你刚学完Java基础语法,正愁没有合适的综合练习,或者学完面向对象但不知道怎么落地,这套思路可以直接照着做。下面我把整个项目的设计思路、代码实现、踩坑经验完整拆开讲。
1. 智能家居为什么是练面向对象的好场景
1.1 需求天然契合面向对象
很多人练手项目选错了方向,比如做一个学生管理系统,代码写到最后全是ArrayList和Scanner的排列组合,面向对象的思想一点没练到。智能家居这边场景就完全不同了。
先想想一个智能家居系统里有什么:客厅灯、卧室空调、书房风扇、窗帘、电视、空气净化器。这些设备功能各不相同,但行为模式高度相似——每个设备都有开启、关闭、查询状态这几个操作。这就是典型的“多个具体类共享同一套行为规范”的场景,天生适合用继承和多态来建模。
我在做这个练习时首先定义了一个需求清单:设备能开、能关、能查状态,控制器能登记设备、能同时操作所有设备,还要能对设备做简单的排序统计。有了需求清单再动手设计,而不是上来就写类。这个习惯特别重要,很多人代码写一半发现抽象层设计不对,就是因为跳过了需求分析这一步。
1.2 这个项目要解决的核心痛点
如果不用面向对象,这个系统也能写出来,无非是定义三个类:Light、AirConditioner、Fan,然后各自写各自的开关方法。但问题马上就来了:控制器需要持有所有设备的引用,每加一个设备就要改一次控制器的代码,这就是耦合。耦合一旦高了,项目就没法维护。
面向对象设计要解决的核心问题,就是让控制器依赖抽象而不是依赖具体。控制器只需要知道“我有一个设备列表,每个设备都能开关、能查状态”,至于这个设备到底是灯还是空调,控制器完全不用关心。这就是依赖倒置的思想在小型项目里的第一次落地。后面我会详细拆代码,你会看到那个List<SmartDevice>泛型声明到底是怎么解放生产力的。
2. 核心类设计:接口、抽象类与控制中心
2.1 接口SmartDevice:先定契约再写实现
项目第一步是定义设备接口。这个接口就是整套系统的“契约”:无论是灯、空调还是风扇,只要实现了这个接口,就必须具备这些能力。
public interface SmartDevice { String getName(); double getPower(); void turnOn(); void turnOff(); boolean isOn(); String getStatus(); }接口里为什么只有方法声明没有字段?因为每种设备的内部状态不一样:灯有亮度,空调有温度,风扇有挡位。如果强行在接口里定义字段,等于把所有设备绑死在同一种状态模型上,这违背了面向对象的初衷。接口只定义行为能力,具体怎么实现由各个子类自己决定。
我在设计接口时有意把getStatus()放进去,这个方法的返回值是字符串,不是直接输出。为什么要这样做?因为查询状态这个动作的结果需要交给上层处理:主程序要打印,控制器要统一汇总,将来可能还要写进日志文件。让方法把结果返回而不是自己打印,是“单一职责”思想在方法级别上的体现。
2.2 抽象类BaseDevice:消灭重复代码
接口定义好了,接下来遇到一个问题:灯、空调、风扇都是要记录开关状态和功率的,这个逻辑一模一样。如果每个类都自己写一遍,代码就重复了。这时候抽象类登场。
public abstract class BaseDevice implements SmartDevice { protected String name; protected boolean on; protected double power; public BaseDevice(String name, double power) { this.name = name; this.power = power; this.on = false; } @Override public String getName() { return name; } @Override public double getPower() { return power; } @Override public void turnOn() { if (!on) { on = true; System.out.println(name + " 已开启"); } else { System.out.println(name + " 已经是开启状态"); } } @Override public void turnOff() { if (on) { on = false; System.out.println(name + " 已关闭"); } else { System.out.println(name + " 已经是关闭状态"); } } @Override public boolean isOn() { return on; } @Override public String getStatus() { return name + ",状态=" + (on ? "开启" : "关闭") + ",功率=" + power + "W"; } }注意turnOn()和turnOff()里的状态判断,这个细节很重要。如果设备已经开了,再次调turnOn()应该给出提示而不是静默无视,这样在批量操作时能清楚知道哪些设备真正执行了动作,哪些是重复指令。很多入门项目里开关就是简单把on字段赋值成true,完全不考虑幂等性。等到后面接真实硬件或语音助手时你会发现,状态不幂等会造成指令风暴。
为什么这里用抽象类而不是让每个设备直接实现接口?因为抽象类可以把公共的实现细节沉淀下来,子类只需要关心自己的特有行为。这就是继承的意义:不是拿来实现“是什么”的等级关系,而是拿来做代码复用。
2.3 子类扩展:每个设备只写自己的差异化逻辑
有了抽象基类,三个设备类就非常清爽了。先看灯:
public class Light extends BaseDevice { private int brightness; public Light(String name, double power) { super(name, power); this.brightness = 0; } public void setBrightness(int brightness) { if (brightness < 0 || brightness > 100) { throw new IllegalArgumentException("亮度范围必须为0~100"); } this.brightness = brightness; System.out.println(name + " 亮度已调节至 " + brightness); } @Override public String getStatus() { return super.getStatus() + ",亮度=" + brightness; } }再来看空调:
public class AirConditioner extends BaseDevice { private int temperature = 26; public AirConditioner(String name, double power) { super(name, power); } public void setTemperature(int temperature) { if (temperature < 16 || temperature > 30) { throw new IllegalArgumentException("温度范围必须为16~30"); } this.temperature = temperature; System.out.println(name + " 温度已设置至 " + temperature + "℃"); } @Override public String getStatus() { return super.getStatus() + ",温度=" + temperature + "℃"; } }风扇和空调结构类似,只是调节的参数变成了挡位,取值范围是0到3挡。这三个类加起来每个只有二三十行,但每个都完整地体现了“复用基类 + 扩展特有行为”的设计。
我在这些子类里特意加了参数校验。亮度、温度、挡位都有各自的合法范围,非法参数直接抛异常而不是默默接受。实际写设备控制逻辑时,参数校验是对硬件最基本的保护,如果把温度设置成100度,空调压缩机直接报废。练习时养成校验参数的习惯,后面写业务代码会少踩很多坑。
@Override注解建议每个重写的方法都加上。这个注解不是摆设,它能在编译阶段帮你检查方法签名是否真的和父类或接口一致。我就见过有人把getStatus写成getstatus,编译直接报错找不到重写方法,还一脸懵。注解是给编译器看的提示,也是给未来的维护者看的标记。
2.4 控制中心SmartHomeController:单例与设备管理
接下来是系统的调度中枢,也就是控制器。它负责登记设备、批量开关、打印全屋状态。
import java.util.ArrayList; import java.util.List; public class SmartHomeController { private final List<SmartDevice> devices = new ArrayList<>(); private static SmartHomeController instance; private SmartHomeController() {} public static SmartHomeController getInstance() { if (instance == null) { instance = new SmartHomeController(); } return instance; } public void registerDevice(SmartDevice device) { devices.add(device); System.out.println("设备登记成功:" + device.getName()); } public void turnAllOn() { for (SmartDevice device : devices) { device.turnOn(); } } public void turnAllOff() { for (SmartDevice device : devices) { device.turnOff(); } } public void showAllStatus() { System.out.println("==== 全屋设备状态 ===="); for (SmartDevice device : devices) { System.out.println(device.getStatus()); } System.out.println("======================"); } public List<SmartDevice> getDevices() { return devices; } }控制中心用了单例模式。原因很简单:一个家庭只需要一套控制系统,设备列表全局共享。如果有人通过new创建了多个控制器,每个控制器管理各自的设备列表,就会出现“左边控制器开的灯,右边控制器不知道”的荒谬场景。
这里单例实现是懒汉式,没有考虑线程安全。练习阶段够用,但如果你打算扩展成多线程版本,比如定时任务轮询设备状态,就得把getInstance方法改成双重检查锁定或者直接用静态内部类方式。这个演进点也值得跟面试官聊聊,属于典型的加分项。
turnAllOn()里那一行device.turnOn()就是多态的精髓。循环遍历的是SmartDevice列表,调用同一个接口方法,但每次执行的实际代码是灯的开灯逻辑、空调的开机逻辑、风扇的开扇逻辑。编译时看的是SmartDevice,运行时执行的是具体的Light或AirConditioner。这就是“编译看左边,运行看右边”,很多Java面试八股文里都考这一点,但光背结论记不牢,自己写过一遍之后就刻在脑子里了。
3. 实操过程:完整代码与核心逻辑
3.1 类结构总览
动手敲代码之前,先看一眼整个项目的类关系,方便对照着理解:
| 类/接口 | 类型 | 职责 | 关键成员 |
|---|---|---|---|
SmartDevice | 接口 | 设备统一契约 | 开关、状态查询、功率查询 |
BaseDevice | 抽象类 | 公共字段与默认实现 | name、on、power |
Light | 普通类 | 灯设备 | brightness亮度调节 |
AirConditioner | 普通类 | 空调设备 | temperature温度调节 |
Fan | 普通类 | 风扇设备 | speed挡位调节 |
SmartHomeController | 普通类 | 设备注册、批量控制 | 单例、设备列表 |
SmartHomeDemo | 普通类 | 主程序入口 | main方法演示全流程 |
这个结构图其实就是一个新手友好版的“类图”。面试时被问到系统设计,能把这种层次感画出来讲清楚,就已经超过很多只会在 LeetCode 里刷题的候选人了。
3.2 主程序演示
核心代码写完之后,主程序来走一遍完整流程:
public class SmartHomeDemo { public static void main(String[] args) { SmartHomeController controller = SmartHomeController.getInstance(); Light livingRoomLight = new Light("客厅灯", 10); AirConditioner bedroomAc = new AirConditioner("卧室空调", 2200); Fan studyFan = new Fan("书房风扇", 75); controller.registerDevice(livingRoomLight); controller.registerDevice(bedroomAc); controller.registerDevice(studyFan); System.out.println("--- 初始状态 ---"); controller.showAllStatus(); System.out.println("--- 全部开启 ---"); controller.turnAllOn(); // 注意这里:通过控制器拿到设备对象后,才能调用子类特有方法 Light light = (Light) livingRoomLight; light.setBrightness(80); AirConditioner ac = (AirConditioner) bedroomAc; ac.setTemperature(24); Fan fan = (Fan) studyFan; fan.setSpeed(2); System.out.println("--- 调节后的状态 ---"); controller.showAllStatus(); System.out.println("--- 全部关闭 ---"); controller.turnAllOff(); } }运行这段程序,控制台输出大致如下:
设备登记成功:客厅灯 设备登记成功:卧室空调 设备登记成功:书房风扇 --- 初始状态 --- ==== 全屋设备状态 ==== 客厅灯,状态=关闭,功率=10.0W 卧室空调,状态=关闭,功率=2200.0W 书房风扇,状态=关闭,功率=75.0W ====================== --- 全部开启 --- 客厅灯 已开启 卧室空调 已开启 书房风扇 已开启 --- 调节后的状态 --- ==== 全屋设备状态 ==== 客厅灯,状态=开启,功率=10.0W,亮度=80 卧室空调,状态=开启,功率=2200.0W,温度=24℃ 书房风扇,状态=开启,功率=75.0W,挡位=2 ====================== --- 全部关闭 --- 客厅灯 已关闭 卧室空调 已关闭 书房风扇 已关闭看到没有,控制器里的showAllStatus()遍历了三种不同类型的设备,每一行都正确输出了各自的完整状态。父类提供的getStatus()被子类覆写后,通过多态调用时自动执行的是子类的版本。这就是面向对象中“同一个接口,不同表现”的直接体现。
3.3 多态真正生效的位置
很多初学者学了多态之后会觉得迷茫:“这玩意到底用在哪了?”回到这个项目,多态生效的位置有三处。
第一处是设备列表的声明:List<SmartDevice>。这个列表里装的是Light、AirConditioner、Fan三种对象,但它们都以SmartDevice“身份”存放在列表里。所有批量操作只需要针对SmartDevice编写,完全不需要知道具体子类。
第二处是turnOn()的调用。前面已经说过,运行时动态绑定到具体子类的方法实现。
第三处是getStatus()的覆写。基类提供默认状态输出,子类在其基础上追加各自的参数信息。控制器调用getStatus()时拿到的是完整的最新状态。
为了让你更直观地感受到“如果不这样设计会怎样”,试着想象一下去掉多态后控制器代码会变成什么样:需要先instanceof判断类型,然后强转成具体类,再分别调用各自的开关方法。设备种类一旦从3种变成10种,控制器就会膨胀成几十层判断。这么一对比你就明白了,多态不是炫技,是实实在在降低复杂度的工具。
3.4 新增设备时到底要不要改旧代码
这是整个练习最能验证设计质量的一步。假设现在要新增一个智能窗帘,它除了开关之外,还支持开合百分比控制。你只需要两步:写一个Curtain类继承BaseDevice,加一个setOpenRatio方法,然后在主程序里注册它。
看清楚了,控制器代码一行都不用改动。registerDevice接受的是SmartDevice,窗帘已经继承了BaseDevice,BaseDevice实现了SmartDevice,类型天然兼容。批量开关和状态汇总的逻辑自动覆盖新设备。
这是面向对象设计最爽的时刻,也是我要求每个做这个练习的人都必须亲自动手扩展一次新设备的原因。只有在你的代码里添加新设备时不用改动旧代码,你才真正体会到了“开闭原则”:对扩展开放,对修改关闭。八股文背一万遍不如亲手加一个类来得深刻。
4. 常见问题与避坑实录
4.1 子类特有方法调用不到,怎么办
最典型的场景是:你从设备列表里取出了一个设备,想调节亮度,发现没有setBrightness方法。因为你拿到的引用类型是SmartDevice,接口里只定义了通用能力。解决方案就是instanceof判断后向下转型。
if (device instanceof Light) { ((Light) device).setBrightness(80); }这里有一个非常容易踩的坑:必须先判断instanceof再强转,否则代码运行时会抛ClassCastException。我在给学生改代码时发现,有人会图省事直接强转,程序运行到空调或风扇时直接崩溃。记住,向下转型永远是“存在风险”的操作,能避开尽量避开。
那么有没有更好的设计?可以。如果在需求阶段就明确知道“要对灯进行亮度调节”,完全可以把setBrightness也定义到接口里,或者定义更细分的Dimmable接口。但这样做也有代价——接口方法越多,实现类的负担越重。实际项目里一般遵循接口隔离原则,把能力拆分得细一些。练习阶段用instanceof方案把逻辑跑通,然后思考“为什么要避免这种写法”,反而比一开始就追求完美更有收获。
4.2 状态管理为什么会乱
初写这个项目最常见的错误是开关状态没有做幂等处理。直接this.on = true不是不行,但批量操作后你看不到设备“先前是否已经开着”的信息。比如控制器连续调两次turnAllOn(),第一次确实全部打开了,第二次遍历时又把每个设备打印了一遍“已开启”,输出看着会觉得有什么东西被重置了,其实什么都没有发生。
我在BaseDevice的turnOn()里加了状态判断,一旦设备已经处于开启状态,就直接提示“已经是开启状态”。这个细节成本极低,但对调试的友好度提升巨大。后续做自动化场景联动时,控制器可能在每10秒轮询一次设备状态,如果没有幂等保护,每次轮询都会输出一屏重复信息,日志直接刷爆。
4.3 到底该用接口还是抽象类
这两者的区别是Java面试题库里的常驻题目,但在代码里它们的分工其实很明确。抽象类允许保存共享字段——我们的name、on、power都存在这里。接口只能定义常量和方法签名,不能保存实例字段。所以这个项目的结构是“接口 + 抽象基类”两个一起用:接口规定契约,抽象基类提供默认实现和公共成员。
如果这里改用纯接口,会出现什么情况?每个设备类都要自己定义name、on、power字段,然后各自实现turnOn()、turnOff(),重复代码量迅速上升。如果反过来只保留抽象类不定义接口,控制器就得依赖具体抽象类,将来如果想支持第三方设备接入,耦合就很重了。接口和抽象类各有各的作用,这个项目里最妙的点就是它让两者的优势同时发挥了出来。
4.4 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 调用子类特有方法编译失败 | 引用类型是父类/接口 | instanceof判断后向下转型 |
运行时报ClassCastException | 强转前未判断实际类型 | 判断类型后再转,或重新设计接口 |
| 批量开关输出大量重复信息 | turnOn/turnOff未做幂等判断 | 先检查状态再执行切换动作 |
| 新增设备需要修改控制器 | 控制器依赖具体设备类 | 控制器只依赖SmartDevice接口 |
getStatus()输出缺少子类信息 | 子类未覆写getStatus | 在子类中覆写并调用super.getStatus() |
| 参数越界导致状态异常 | 亮度、温度未校验 | 在setter中加入范围校验并抛异常 |
4.5 这些代码在面试里怎么聊
做这个项目不只是为了练手,它还能变成你面试时的谈资。比如面试官问“你了解设计模式吗”,你完全可以把控制器单例拆开说:单例模式保证全局只有一个控制中台,设备列表不出现多副本。面试官再问“接口和抽象类的区别”,你直接拿SmartDevice和BaseDevice举例,比背书里的定义生动得多。
再比如问“什么是开闭原则”,你就是现成的案例:新设备加入系统时,扩展了一个类,但没有改动任何已有控制器代码。把这些亲身实践讲出来,面试官感受到的不只是你记住了概念,而是你真的写代码时会有意识地在应用这些原则。这就是一个练习项目能带来的最大附加价值。
我个人在带这个练习时,最后都会加一个小任务:给控制器写一个按功率从低到高排序输出的方法。思路是冒泡排序,交换的是List里的SmartDevice对象。排序的过程和结果都能加深对引用类型和比较逻辑的理解,这也是很多学习路线里“掌握基础排序”和“面向对象实战”产生交叉的最佳落点。
按功率排序的方法写起来并不难,核心就是交换列表里的对象引用。但要注意一点,排序会改变设备在集合里的顺序,如果你依赖寄存器顺序,排序后就得谨慎处理。实际项目里通常会用副本列表排序或者直接用Comparator,这些都是从这个练习延伸出去的优化方向。
结尾
做完这个项目,我最大的体会是:面向对象不是背出来的,是改出来的。第一次写出来肯定有各种别扭,比如某个类职责过多、某个地方不该用继承却用了继承。不要怕,把代码推倒重来一次,你会在第二次重写时真正理解设计的取舍。我自己第一版把控制器写成了“上帝类”,所有逻辑全部堆在控制器里,重写时才意识到抽象层的重要性。
如果还想继续深化,可以尝试给设备添加观察者模式:灯光开关时窗帘自动调整,温度超过阈值时空调自动启动。再进阶就是引入多线程定时巡检设备状态,哪怕只是用ScheduledExecutorService做定时输出,也够你研究一阵子了。等你把这个项目彻底吃透,再去看Spring Boot写的东西,会发现那些注解越用越本质。