☰
面向对象综合练习:继承、多态与接口的实战解析
2026/10/1 14:49:27 网站建设 项目流程

练面向对象练到 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,只需实现applyDiscount,rule()可以直接用,或者再追加内容。这样一个组合就很典型了:接口定义"能做什么",抽象类实现"公共部分长什么样",具体类补完"各自特有的逻辑"。

我很少让具体业务类直接继承一个抽象类而不实现接口。原因是 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 没重写,集合查找直接失败

我试着把商品放进ArrayList<Product>,然后调用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"在真实建模中的差别。

最后分享一个我自己的练习习惯:每道综合题写完,我会把自己的类图画一遍贴在代码注释最上方。过两周再打开这个项目,光是看注释里的类图,就能在三秒内回忆起整个设计脉络。面向对象的练习成果,不应该只留在代码里,更应该留在你画图时的那种"把关系理清楚"的感觉里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询