面向对象综合练习:继承、多态与接口的实战解析 练面向对象练到 day17 这种节点最明显的感觉是单个概念都懂一合起来就懵。封装、继承、多态、接口、抽象类单独拿出来都能背真让写一个包含十几个类的小系统立刻不知道怎么摆。这篇是面向对象综合练习的下半场上一轮我们练完了类与对象的基本功这一轮专门啃硬骨头——继承体系怎么搭、方法重写怎么设计、接口和抽象类什么时候用哪个以及怎么用对象数组把一组对象组织起来跑多态。无论你走的是 Java 路线还是 Python 路线这几件事都是绕不开的核心尤其适合那种语法背完、缺乏实战的学习阶段。1. 把综合练习下拆开这轮到底在练什么1.1 上篇结束时你应该停在什么位置前面十几天的练习核心是类的基本构造字段、构造器、getter/setter、封装、static、对象数组的简单使用。上篇的练习题一般是设计一个学生类能算平均分设计一个银行账户类能存取款这类单类题目。这类题目有个明显的局限写出来的是一个一个孤立的类彼此之间没有关系。很多同学在这个阶段会产生一个错觉——面向对象就是把数据和操作它的方法放进同一个 class 里。这个理解不算错但太浅了。真正的面向对象设计大量工作是在类与类之间的关系上哪些类是父子关系哪些类只是共用一份行为标准哪些类应该组合而不是继承。综合练习的下半场就是专门把这些关系练熟的。1.2 下篇的五项训练目标我在设计这轮练习时给自己列了一个目标清单你也可以照着自查通过继承抽取公共代码而不是每个类各写一份重复字段和方法用父类引用指向子类对象靠方法重写实现多态而不是用 if-else 判断对象类型搞清楚接口和抽象类的边界知道这题为什么不能用接口硬顶或者为什么不能抽象类一把梭用对象数组管理一组不同类型的对象遍历时统一调用同一个方法名养成动手前先画一个简单类图的习惯哪怕只是在纸上画三个方框三条线。这五项如果不专门练后面一写真实项目就会原形毕露。我在带新人时见过太多这样的代码一个类里塞了七八个判断分支扩展新类型就要回去改旧代码就是因为没把多态和接口的练习补上。1.3 语言选择与练习方式这轮练习的示例代码我用 Java 写。原因很简单Java 的静态类型让继承和多态的关系暴露得很直白父类引用指向子类对象这件事在编译期就有明确约束对初学者友好。Python 也能练这些概念但鸭子类型会把父类引用这层意义模糊掉等 Java 跑通了再回 Python 写你反而能意识到动态语言的灵活是拿什么换的。不管你用哪个语言练习方式我建议保持一致先读题再动手画类图最后写代码跑测试。跑不出预期结果了回到类图上去找原因而不是在代码里瞎试。2. 练习一商品体系——继承与方法重写的完整链路2.1 需求背景第一个综合练习我选了一个电商商品管理的场景。需求是这样系统里需要管理图书和电子产品两类商品它们有公共属性编号、名称、价格、库存又各有特殊属性——图书有作者和折扣率电子产品有品牌和保修月数。结算时要能拿到每件商品的实际支付价格展示时要把商品信息完整打印出来。这个需求很典型因为它天然分出了两层公共部分放父类特殊部分放子类。如果你见过真实项目的商品模块会发现它复杂得多但骨架就是这么起的。2.2 父类怎么写把一定有的和可能变的分开父类Product我这样写public class Product { protected String id; protected String name; protected double price; protected int stock; public Product(String id, String name, double price, int stock) { this.id id; this.name name; this.price price; this.stock stock; } public double getFinalPrice() { return price; } public String describe() { return name | 单价 price; } }注意两个设计点。第一字段我用了protected而不是private。在这个练习里这样做是为了让子类能直接访问price字段代码看起来简洁但我必须提醒你protected意味着所有子类都能改这些字段一旦类层次变深这会是灾难。真实项目里我一般优先用private加getter让子类通过方法访问。练习阶段为了聚焦继承和多态可以先protected但心里要清楚这只是权宜之计。第二getFinalPrice()和describe()这两个方法我明确设计成可被重写的扩展点。父类给出默认实现不打折、通用描述子类如果行为不同就重写。这就是可能变的部分。写父类时最忌讳把所有方法都写成 final 锁死那子类就没有任何发挥空间了。2.3 子类Book 和 Electronic 的差异化实现图书类public class Book extends Product { private String author; private double discount; // 0.85 表示打八五折 public Book(String id, String name, double price, int stock, String author, double discount) { super(id, name, price, stock); this.author author; this.discount discount; } Override public double getFinalPrice() { return price * discount; } Override public String describe() { return super.describe() | 作者: author | 折扣: (int) (discount * 100) 折; } }电子产品类public class Electronic extends Product { private String brand; private int warrantyMonths; public Electronic(String id, String name, double price, int stock, String brand, int warrantyMonths) { super(id, name, price, stock); this.brand brand; this.warrantyMonths warrantyMonths; } Override public double getFinalPrice() { return price; // 电子产品不打折沿用父类逻辑 } Override public String describe() { return super.describe() | 品牌: brand | 保修: warrantyMonths 个月; } }这里有两个习惯值得养成。第一子类构造器第一行必须用super(...)把父类必须的公共属性传上去千万别在子类里重复给id、name赋值——那等于把父类的初始化逻辑又复制了一遍以后父类构造器一改所有子类全部要跟着改。第二Electronic.getFinalPrice()其实可以完全不写直接继承父类的实现。我特意写出来并加上Override是为了让阅读代码的人明确知道这里我考虑过了电子产品就是不打折。这种显式覆盖在练习里多写一笔不亏它能训练你逐个方法确认行为的好习惯。2.4 父类引用与多态最关键的一次验证现在写一个测试场景Product p1 new Book(B001, 设计模式, 68.0, 30, GoF, 0.8); Product p2 new Electronic(E001, 蓝牙耳机, 299.0, 50, 某国产品牌, 12); Product[] products {p1, p2}; for (Product p : products) { System.out.println(p.describe() 实付 p.getFinalPrice()); }输出结果设计模式 | 单价 68.0 | 作者:GoF | 折扣:80折实付 54.4 蓝牙耳机 | 单价 299.0 | 品牌:某国产品牌 | 保修:12个月实付 299.0这段代码是整个练习一的灵魂p的静态类型是Product但它实际指向的对象有时是Book有时是Electronic。调用p.describe()和p.getFinalPrice()时Java 虚拟机在运行期根据实际对象类型去调用对应的方法——这就是多态叫动态绑定或虚方法调用。你要特别注意一件事多态只对实例方法生效对字段不生效。假设我在父类和子类里各自声明了一个同名字段name用p.name访问时拿到的是父类的字段跟实际对象是哪个子类没关系。所以练习时别为了偷懒在子类里再声明一个同名字段遮住父类字段那会让代码极其难排查。字段就应该只在父类里定义一份子类通过super访问。2.5 这个练习真正想让你记住的设计点第一个点是扩展点意识。getFinalPrice()就是商品系统的一个扩展点以后来了新商品类型比如虚拟商品、二手商品只需新增子类重写这个方法完全不用改动已经存在的类。这种能力叫对扩展开放对修改关闭你现在可能没感觉等你维护过一个三千行的业务类就会懂了。第二个点是复用要复用行为不要复用字段。很多初学者看到父类有price就高兴觉得继承就是白拿字段。但继承真正的价值是子类能复用父类的方法实现并且能通过重写调整行为。如果你的目的只是拿几个字段组合一个成员变量进去比继承干净得多。3. 练习二结算优惠——接口与抽象类同时登场3.1 需求背景练习一跑通后立刻加需求结算时商品要支持多种优惠策略——满减满 300 减 50、会员折扣会员打九折、优惠券减 20 元。这些优惠策略之间没有is-a关系它们只是能对价格做计算的不同实现。这题就是很多人卡住的地方。搜面向对象接口找资料的同学八成就是在纠结这里满减、会员折扣、优惠券这些东西之间没有任何继承关系强行造一个父类优惠出来字段和方法凑不到一起去。正确的做法是给它们定义一个共同的行为标准也就是接口。3.2 为什么这题必须用接口而不是抽象类先看接口定义public interface Discountable { double applyDiscount(double originalPrice); String rule(); }接口里只声明你要能算优惠你要能描述规则具体怎么算、规则怎么写接口一概不管。满减类和会员折扣类之间除了都实现了Discountable没有任何其他共同点——这正是接口的意义它不要求实现者有血缘关系只要求行为达标。抽象类在这里不合适。你可以试想让FullReduction和MemberDiscount都继承一个抽象优惠类这个抽象类里能放什么公共字段满减需要threshold和reduction会员折扣需要rate优惠券需要amount——字段完全不同公共代码根本抽不出来。硬抽一个抽象类里面全是空转最后每个子类还是各写各的那这个抽象类就是纯摆设。我的判断标准很简单如果多个类之间只是行为相同而没有结构相同用接口如果多个类之间有公共字段、公共构造逻辑并且你想复用一大段方法实现用抽象类。3.3 两个具体策略类的实现满减类public class FullReduction implements Discountable { private double threshold; private double reduction; public FullReduction(double threshold, double reduction) { this.threshold threshold; this.reduction reduction; } Override public double applyDiscount(double originalPrice) { return originalPrice threshold ? originalPrice - reduction : originalPrice; } Override public String rule() { return 满 threshold 减 reduction; } }会员折扣类public class MemberDiscount implements Discountable { private double rate; // 0.9 表示九折 public MemberDiscount(double rate) { this.rate rate; } Override public double applyDiscount(double originalPrice) { return originalPrice * rate; } Override public String rule() { return 会员 ((int) (rate * 100)) 折; } }你观察这两个类它们没有任何继承关系也没有共同字段纯粹是因为都能算钱才走到一起。一个类能不能实现某个接口不取决于这个类是什么而取决于它能做什么。这是面向对象里接口即契约的思想Python 里的鸭子类型本质上也只是把这道显式契约变成了隐式约定。3.4 把策略接进结算流程有了接口结算逻辑就能写得很干净public class Checkout { public static double settle(Product product, Discountable[] policies) { double price product.getFinalPrice(); for (Discountable policy : policies) { price policy.applyDiscount(price); } return price; } }调用double total Checkout.settle(p1, new Discountable[]{ new FullReduction(300, 50), new MemberDiscount(0.9) });这个设计的好处一眼就能看出来以后要加满三件打七折新人首单减 99只需要新增一个实现Discountable的类Checkout一行不用改。这就是练习一结尾说的对扩展开放现在你看到了接口版本的样子。3.5 抽象类在这里还有没有用武之地有但角色要摆对。如果你发现好几个策略类都存在相同的规则描述前缀可以用一个抽象类实现接口的一部分方法把公共逻辑收上来。比如public abstract class BaseDiscount implements Discountable { protected String tag; public BaseDiscount(String tag) { this.tag tag; } Override public String rule() { return [ tag ] ; } }然后FullReduction extends BaseDiscount只需实现applyDiscountrule()可以直接用或者再追加内容。这样一个组合就很典型了接口定义能做什么抽象类实现公共部分长什么样具体类补完各自特有的逻辑。我很少让具体业务类直接继承一个抽象类而不实现接口。原因是 Java 只能继承一个类却可以实现多个接口如果一上来就把抽象类占了以后这个类还想实现别的接口比如Comparable就被堵死了一半路。先接口后抽象类的顺序能给你留出最多的腾挪空间。4. 练习时的真实翻车记录五个高频坑的排查过程综合练习的价值一半在写对一半在改错。下面五个问题都是我这轮练习里实际踩过、或者帮别人排查过的每个都直接给出根因和修法。4.1 构造器里调用可重写方法导致拿到空值我一开始写Product构造器时在尾部加了一句init()想在初始化时打印最终价格。结果创建Book对象时打印出来的价格是 0。排查了很久才发现根因Java 创建对象的顺序是——先给字段分配默认值数字是 0引用是 null再调用构造器子类构造器执行前会先调用父类构造器。也就是说当父类构造器里的init()被执行时子类的author和discount字段还没有被赋值连Book构造器都还没进入。此时如果init()是重写方法被多态分发到子类版本读取到的子类字段全是默认值。修法有两个构造器里一律不调用可重写方法改用子类构造器结束后再显式调用或者使用静态工厂方法在对象完整创建后再做初始化动作。这是 Java 规范里明确提示过的坑但只有写崩一次才会真正记住。4.2 把重载当重写练习一中我给Book加了一个方法public double getFinalPrice(double coupon)以为这样调用p.getFinalPrice()时会智能地走到子类。实际运行发现p.getFinalPrice()返回的还是没有减券的价格。这里混淆了重载和重写。重写要求方法签名完全一致方法名、参数类型、参数个数都一样而且前提是父类里有这个方法重载是在同一个类里放多个同名但参数不同的方法它们之间没有父子关系。我加的那个带coupon参数的方法是重载跟父类的getFinalPrice()根本不构成覆盖用Product p new Book(...)时自然调不到。排查方法很简单在疑似重写的方法上写Override注解编译器会立刻告诉你写的是不是真的重写。我建议从练习阶段起所有重写方法都强制加这个注解。4.3 equals 没重写集合查找直接失败我试着把商品放进ArrayListProduct然后调用list.contains(new Book(B001, ...))结果返回 false。原因是默认的equals比较的是引用地址两个对象的内容完全一样也是两个不同地址当然找不到。正确做法是按业务主键这个场景是id重写equals并且同时重写hashCode保证相等对象的哈希值一致。如果只重写一个HashMap 这类数据结构会出更诡异的问题——containsKey找不到、重复插入等。真实项目里这种 bug 极其隐蔽因为代码不会报错只是行为不对。练习阶段养成随写随重写 equals/hashCode的习惯后面能省很多事。4.4 向下转型前不做类型检查练习一我写过一个方法接收Product想取Book独有的author字段于是直接写成Book book (Book) product;如果传入的product是个Electronic运行期直接抛ClassCastException。正确写法是先判断if (product instanceof Book book) { System.out.println(book.getAuthor()); } else { System.out.println(不是图书无法读取作者信息); }这里更值得反思的是为什么需要向下转型因为你在用父类引用却又依赖子类特有方法。这种代码在多态设计里是味道——正确做法是把显示作者信息作为方法放进多态体系里让父类引用直接调用而不是调到子类再转回来。练习时可以向下转型但要意识到这是在打补丁真正的好设计不需要频繁补丁。4.5 直接把可变对象引用暴露出去我给Product加了一个getDiscountPolicy()方法返回的是内部Discountable对象的引用。然后测试代码里顺手把它改成了别的策略结果商品的价格计算全乱了。这个问题的本质是破坏了封装外部拿着内部对象的引用就能绕过你定义好的修改接口直接改内部状态。修法有三种返回不可变对象返回一个新对象副本干脆不让外部拿到内部策略只提供changePolicy(policy)这样的方法。练习一里我说用protected是为了聚焦教学但对象引用暴露这个坑没有任何教学豁免从练习第一天开始就必须防。5. 练习收尾把这些 demo 往真实项目上靠一靠5.1 你在练习里其实已经摸到了设计原则很多人觉得 SOLID 是工作好几年才需要学的理论其实这轮练习已经在反复用了。练习二的优惠策略就是单一职责每个策略类只管一种优惠计算满减不会去管会员折扣的事。练习一的商品体系就是开闭原则加新商品类型不改旧代码。练习四的 instanceof 反思就是在写依赖倒置——与其让调用方依赖具体子类不如依赖抽象。我建议你把这几个原则的名字记下来但不要背定义。等你在自己写的小系统里因为某个类一大坨改不动而痛苦时回来翻一翻这几个词会突然明白它们说的是什么。5.2 一个随时能做的 mini 重构消灭结算里的 if-else如果练习二你写累了可以试着把结算逻辑先写成坏代码再重构成好代码。坏代码长这样if (discountType.equals(fullReduction)) { // 满减计算 } else if (discountType.equals(member)) { // 会员折扣计算 } else if (discountType.equals(coupon)) { // 优惠券计算 }每加一种优惠策略这个分支就多一个而且商品类里还得存一个discountType字符串配合它。重构的目标就是把这一坨 if-else 换成Discountable接口加上策略对象数组。这个重构过程本身比任何讲解都有说服力做完你就明白接口为什么存在。5.3 下一轮练习可以往哪走这轮综合练习的核心是把继承、多态、接口、抽象类串起来。顺着这个体系下一轮我建议练三件事给商品类写单元测试每个子类的行为都要覆盖用泛型替换对象数组让ProductService能处理各种商品列表尝试在商品和优惠策略之间加入组合关系——比如一个订单包含多个商品和一套优惠策略体会has-a和is-a在真实建模中的差别。最后分享一个我自己的练习习惯每道综合题写完我会把自己的类图画一遍贴在代码注释最上方。过两周再打开这个项目光是看注释里的类图就能在三秒内回忆起整个设计脉络。面向对象的练习成果不应该只留在代码里更应该留在你画图时的那种把关系理清楚的感觉里。