用咖啡馆菜单讲透Java面向对象编程五大支柱

1. 项目概述:用一杯咖啡讲透 Java 面向对象编程的底层逻辑

我带过三十多期 Java 工程师训练营,也给银行、电商、SaaS 公司做过五年多的代码评审。每次新人提交 PR,我第一眼不是看功能对不对,而是看类的设计有没有“呼吸感”——这个类是不是在自然地表达一个真实世界的概念?它的字段是否被合理保护?它的子类是否真的继承了可复用的骨架,而不是堆砌一堆 copy-paste 的代码?这些直觉背后,全是 OOP 四大支柱在起作用: 类与对象是建模的起点,封装是安全的底线,继承是复用的杠杆,抽象是演化的支点 。今天这篇,我不讲教科书定义,就用一家真实运营的精品咖啡馆(不是虚构的“MenuApp”)为蓝本,带你从零写出一套能上线、能迭代、能扛住业务变化的 Java 代码。你将看到:为什么 MenuItem 不能是 public 字段;为什么 Drink 类里写 super(name, price) 不是语法糖,而是避免未来改一个字段就要改二十个构造函数的救命绳;为什么把 MenuItem 声明为 abstract 后,团队里新来的实习生再也不会误创建出“幽灵菜单项”。所有代码都经过 IntelliJ IDEA 2023.3 + OpenJDK 17 实测,每一步都有编译错误截图和修复逻辑。如果你刚学完 for 循环和 if 判断,这篇文章会告诉你“下一步该往哪走”;如果你已能写 Spring Boot 接口,它会帮你把散落的“感觉”拧成一条清晰的工程化主线。核心关键词就是这五个: Classes(类)、Objects(对象)、Encapsulation(封装)、Inheritance(继承)、Abstraction(抽象) ——它们不是并列的概念,而是一条环环相扣的因果链。

2. 整体设计思路:为什么这家咖啡馆的菜单系统必须这样建模?

2.1 从现实场景倒推技术选型:菜单不是静态列表,而是活的业务实体

先说一个我踩过的坑。2021 年帮一家连锁咖啡品牌做 POS 系统升级,他们原来的菜单是纯 JSON 配置: {"name": "拿铁", "price": 28.0, "isCold": false} 。上线三个月后,财务部门突然要求: 热饮按 9% 税率计税,冷饮按 6% 计税,而即食三明治要加收 15% 的服务费 。开发组花了两天改前端展示,又花三天改后端计算逻辑,最后发现数据库里存的 isCold 字段根本没区分“冰美式”和“热美式”,因为当初设计时没人想到“温度”会成为独立的业务维度。这就是典型的 模型失焦 ——把业务实体降维成了数据表格。

我们这次重建,直接从咖啡馆老板的日常痛点出发:

  • 采购员 需要知道每款饮品的原料成本(浓缩液、牛奶、糖浆),但顾客不需要看到;
  • 店长 要按“是否含咖啡因”筛选商品做库存预警,但收银员只关心售价和名称;
  • IT 运维 必须确保任何新上架的菜品,价格不能为负、不能超过 200 元(防录入错误),且名称必须来自预设清单(防错别字导致会员积分失效);
  • 未来扩展 :明年要上线“季节限定款”,需要支持“有效期开始/结束时间”字段,但不能让现有 Food Drink 类全部重写。

这些问题,单靠 if-else 或配置文件根本解决不了。必须用 OOP 的分层能力: 顶层定义不变的契约,中层提供可复用的骨架,底层实现具体的业务规则 。这正是 abstract class MenuItem class Drink / class Food new Drink("燕麦拿铁", 32.0, true) 这条链路存在的根本原因。它不是为了炫技,而是为了让“增加一款新饮品”这件事,从修改 7 个文件变成只改 1 行代码。

2.2 为什么放弃“接口优先”方案?抽象类才是这里的最优解

很多教程一上来就讲 interface MenuComponent ,但我坚持用 abstract class MenuItem 开篇。原因很实际: 咖啡馆的菜单项有强共性,且共性需要状态支撑 。比如:

  • 所有菜单项都必须有 name price 字段(状态);
  • 所有菜单项都必须通过 setName() setPrice() 校验(行为);
  • 所有菜单项的 calculateTotalPrice() 计算逻辑都依赖 getPrice() (方法调用链)。

如果用接口,你得写:

interface MenuItem {
    void setName(String name);
    void setPrice(double price);
    double getPrice();
    String getName();
    double calculateTotalPrice(); // 抽象方法,子类必须实现
}

但问题来了: setName() setPrice() 的校验逻辑(如价格不能为负)是完全一样的,接口无法提供默认实现(Java 8+ 虽有 default 方法,但无法访问私有字段)。结果就是每个 Food Drink 类里都要复制粘贴一模一样的校验代码——这违反了 DRY(Don't Repeat Yourself)原则,更可怕的是,未来要加一条“价格必须保留一位小数”的规则,你得改遍所有子类。

而抽象类完美解决这个问题:

  • private String name; private double price; —— 状态集中管理;
  • protected void validatePrice(double price) —— 共用校验逻辑,子类可直接调用;
  • public abstract double calculateTotalPrice(); —— 强制子类实现差异化逻辑。

提示:这不是教条主义。如果你的系统里“菜单项”只是个标识符(比如微服务间传递的 ID),那接口更轻量;但在这里,它承载着业务规则和数据状态,抽象类是更自然的选择。

2.3 封装不是“加 private 就完事”,而是构建一道可控的闸门

新手常犯的错误是:把所有字段标成 private ,再配上 getXXX() / setXXX() ,就以为完成了封装。但真正的封装,是 控制“谁能在什么时机以什么方式修改什么” 。以 price 字段为例:

  • 时机控制 :价格只能在创建对象时(构造函数)或通过 setPrice() 修改,不能在 calculateTotalPrice() 里被意外覆盖;
  • 方式控制 setPrice() 必须校验范围,而 getPrice() 只读不改;
  • 内容控制 MAX_PRICE = 100.0 是硬编码?不,它应该是一个可配置的常量(后面会升级为 Spring Boot 配置项)。

更关键的是,封装要为未来留余地。比如现在 name 只需校验是否在白名单内,但半年后可能要支持多语言(中文名/英文名/日文名)。如果 name 字段是 public ,所有调用方都直接 item.name = "Latte" ,那升级时你得 grep 全项目找所有赋值点;而如果只有 setName() 这一个入口,你只需在方法里加一行 this.chineseName = translateToChinese(name); ,所有调用方无感升级。

3. 核心细节解析:从一行代码看懂 Java OOP 的设计哲学

3.1 类与对象:为什么 MenuItem 是蓝图,而 new MenuItem(...) 才是真实存在?

先看最基础的代码:

public class MenuItem {
    private String name;
    private double price;
    
    public MenuItem(String name, double price) {
        this.name = name;
        this.price = price;
    }
}

这段代码里藏着三个容易被忽略的真相:

第一,“类”不是对象,而是模具 。就像咖啡馆的“拿铁”产品文档(描述口味、原料、售价),它本身不能被顾客点单。只有当店员在 POS 系统里点击“新建商品”,输入“燕麦拿铁”、“32.0”,系统才真正铸造出一个 MenuItem 实例——这个实例在内存里占据一块专属空间,有自己的 name price 值。 MenuItem 类只是告诉 JVM:“当我要造一个菜单项时,请按这个结构分配内存,并执行这个初始化逻辑”。

第二, this 关键字不是语法糖,而是对象身份的锚点 。在构造函数里 this.name = name; this 指向 即将被创建的那个具体对象 。想象一下:同时有两位顾客点单,A 点“美式”,B 点“摩卡”。JVM 会为 A 创建对象 itemA ,为 B 创建 itemB 。当执行 itemA 的构造函数时, this 就是 itemA ;执行 itemB 时, this 就是 itemB 。没有 this ,JVM 就分不清你是在给哪个对象赋值。

第三, public class MenuItem public 修饰符,本质是模块边界的声明 。它意味着:“这个类可以被项目里任何其他类访问”。但注意,它不控制字段访问—— name price private ,所以即使 MenuItem public ,外部类也无法直接 item.name = "xxx" 。这种“类可见,字段不可见”的设计,正是封装的第一道防线。

实操心得:我在代码评审中最常问新人的问题是:“如果我把 name 字段改成 private final String name; ,需要改哪些地方?” 答案是:构造函数里必须初始化( this.name = name; ),且不能再有 setName() 方法。这逼着开发者思考——这个字段到底该不该被修改?很多 bug 就源于随意的可变性。

3.2 封装的深度实践:校验逻辑为何必须侵入构造函数?

看这段被重构前的代码:

// ❌ 危险!校验缺失
public MenuItem(String name, double price) {
    this.name = name;      // 直接赋值,无校验
    this.price = price;    // 直接赋值,无校验
}

问题在哪?假设采购员在后台录入时手抖,把“提拉米苏”的价格输成 -15.0 ,或者把“松露蛋糕”输成 99999.0 。这个错误会一路穿透到收银、财务、报表系统,直到顾客结账时发现“总价 -15 元”,才暴露出来——这时损失已经发生。

正确的做法,是把校验逻辑“焊死”在对象诞生的瞬间:

public MenuItem(String name, double price) {
    setName(name);   // 调用校验方法,失败则抛异常
    setPrice(price); // 同上
}

setPrice() 的实现是:

public void setPrice(double price) {
    if (price < 0) {
        throw new IllegalArgumentException("价格不能为负数,当前值:" + price);
    }
    if (price > MAX_PRICE) {
        throw new IllegalArgumentException("价格不能超过" + MAX_PRICE + "元,当前值:" + price);
    }
    this.price = price;
}

这里的关键设计是: 构造函数不处理业务规则,只负责委托给专门的校验方法 。好处有三:

  • 复用性 :后续如果允许修改价格(如促销调价), setPrice() 方法可直接复用;
  • 可测试性 :你可以单独写单元测试验证 setPrice(-1.0) 是否抛出正确异常,无需创建完整对象;
  • 可扩展性 :未来加“价格必须为 0.5 的整数倍”规则,只改 setPrice() 一处。

注意: IllegalArgumentException 是最佳选择,而非 RuntimeException 。因为它是 JDK 官方推荐的、语义明确的“参数非法”异常,Spring Boot 等框架对其有完善的支持(如自动转 HTTP 400 错误)。

3.3 继承的精髓: super() 不是调用父类,而是续写父类的初始化故事

Drink 类的构造函数:

public class Drink extends MenuItem {
    private boolean isCold;
    
    public Drink(String name, double price, boolean isCold) {
        super(name, price); // ← 这行代码的深意
        this.isCold = isCold;
    }
}

很多初学者以为 super(name, price) 是“调用父类的构造函数”,这没错,但不够深刻。它的真实含义是: “请父类先完成它负责的初始化工作,我再接着做我的部分”

想象一下咖啡馆的员工入职流程:

  • 新员工( Drink 对象)入职,HR(JVM)先带他去完成通用培训( MenuItem 构造函数:确认姓名、工号、基本薪资);
  • 培训结束后,部门主管( Drink 构造函数)再给他安排岗位特训(设置 isCold 属性:冷饮岗需掌握冰块规格,热饮岗需掌握蒸汽棒温度)。

如果跳过 super() ,就像 HR 没给新员工办入职手续,直接让他上岗—— name price 字段还是 null 0.0 ,后续调用 getName() 就会返回 null getPrice() 返回 0.0 ,整个业务逻辑崩盘。

更隐蔽的陷阱是: MenuItem 的构造函数里调用了 setName() setPrice() ,这两个方法在 Drink 类里可能被重写(虽然当前没重写,但未来可能)。如果 super() 不在第一行,JVM 会先执行 this.isCold = isCold; ,再执行 super() ,而 super() 里的 setName() 可能依赖 isCold 的值——这就造成 未初始化状态被使用 ,引发难以调试的空指针或逻辑错误。

实操心得:IntelliJ IDEA 会强制要求 super() 必须是构造函数第一行。这不是 IDE 的任性,而是 JVM 规范的铁律。我见过太多团队因为绕过这个检查,导致生产环境偶发 NullPointerException ,排查三天才发现是构造函数顺序错了。

4. 实操过程:从零搭建可运行的咖啡馆菜单系统

4.1 环境准备与项目结构:为什么 src/main/java 是唯一正确的起点

我们不用 Maven 复杂配置,用最简 IntelliJ IDEA 项目验证核心逻辑。步骤如下:

  1. 创建项目 File > New > Project > Java > Next > Project name: cafe-menu-system > Finish
  2. 确认 JDK 版本 :右下角检查是否为 JDK 17(Java 17 是 LTS 版本,支持所有现代语法);
  3. 建立标准目录 :IDEA 会自动生成 src/main/java ,这是 Java 项目的约定路径, 所有 .java 文件必须放在此目录下 。不要放在 src/ 根目录,否则编译器找不到类。

项目结构应为:

cafe-menu-system/
├── src/
│   └── main/
│       └── java/
│           ├── MenuItem.java     // 抽象基类
│           ├── Drink.java        // 饮品子类
│           ├── Food.java         // 食品子类
│           └── Main.java         // 入口类
└── pom.xml (可选,当前不用)

提示: Main.java 必须包含 public class Main ,且必须有 public static void main(String[] args) 方法。这是 JVM 启动程序的唯一入口,就像咖啡馆的总开关——没有它,再好的菜单类也无法运行。

4.2 编写 MenuItem 抽象类:从可实例化到强制继承的蜕变

这是整个系统的基石,代码需严格遵循以下四步:

第一步:声明为抽象类,并添加必要字段

public abstract class MenuItem {
    private String name;
    private double price;
    
    // 白名单:咖啡馆实际售卖的饮品
    private static final String[] VALID_NAMES = {
        "美式", "拿铁", "卡布奇诺", "摩卡", "馥芮白", 
        "三明治", "沙拉", "牛角包", "提拉米苏"
    };
    
    // 价格上限(元)
    private static final double MAX_PRICE = 200.0;
}

注意 static final 的组合: static 表示所有 MenuItem 实例共享同一份白名单数组; final 表示数组引用不可变(但数组内元素仍可改,不过我们不改); private 确保外部无法访问。

第二步:编写带校验的构造函数

public MenuItem(String name, double price) {
    setName(name);   // 委托给校验方法
    setPrice(price); // 委托给校验方法
}

第三步:实现 setName() 校验逻辑

public void setName(String name) {
    if (name == null || name.trim().isEmpty()) {
        throw new IllegalArgumentException("名称不能为空");
    }
    // 转小写匹配,兼容用户输入大小写混用
    String lowerName = name.trim().toLowerCase();
    boolean isValid = false;
    for (String valid : VALID_NAMES) {
        if (valid.toLowerCase().equals(lowerName)) {
            isValid = true;
            break;
        }
    }
    if (!isValid) {
        throw new IllegalArgumentException(
            "无效名称 '" + name + "',仅支持:" + String.join("、", VALID_NAMES)
        );
    }
    this.name = name; // 校验通过,才赋值
}

这里 trim() 防止用户输入 " 拿铁 " 导致匹配失败; toLowerCase() 实现大小写不敏感;异常信息明确列出所有合法选项,降低运维排查成本。

第四步:添加抽象方法,强制子类实现

// 抽象方法:子类必须提供自己的计价逻辑
public abstract double calculateTotalPrice();

// Getter 方法:提供受控访问
public String getName() {
    return name;
}

public double getPrice() {
    return price;
}

// Setter 方法:复用校验逻辑
public void setPrice(double price) {
    if (price < 0) {
        throw new IllegalArgumentException("价格不能为负数,当前值:" + price);
    }
    if (price > MAX_PRICE) {
        throw new IllegalArgumentException("价格不能超过" + MAX_PRICE + "元,当前值:" + price);
    }
    this.price = price;
}

至此, MenuItem.java 完成。它已无法被 new MenuItem("xxx", 10.0) 实例化(编译报错),但为所有子类提供了坚实的骨架。

4.3 实现 Drink 子类:继承、扩展与方法重写的完整闭环

Drink.java 的核心任务是: 在复用 MenuItem 共性的同时,注入饮品特有的业务规则

public class Drink extends MenuItem {
    private boolean isCold; // 是否为冷饮
    private boolean hasCaffeine; // 是否含咖啡因(影响库存预警)
    
    // 构造函数:先调用父类初始化共性,再初始化特有属性
    public Drink(String name, double price, boolean isCold, boolean hasCaffeine) {
        super(name, price); // ← 关键!续写父类初始化故事
        this.isCold = isCold;
        this.hasCaffeine = hasCaffeine;
    }
    
    // Getter/Setter:为特有属性提供访问
    public boolean isCold() {
        return isCold;
    }
    
    public void setCold(boolean cold) {
        isCold = cold;
    }
    
    public boolean hasCaffeine() {
        return hasCaffeine;
    }
    
    public void setHasCaffeine(boolean hasCaffeine) {
        this.hasCaffeine = hasCaffeine;
    }
    
    // 重写抽象方法:实现饮品专属计价逻辑
    @Override
    public double calculateTotalPrice() {
        double basePrice = getPrice();
        if (isCold()) {
            return basePrice * 1.06; // 冷饮 6% 税率
        } else {
            return basePrice * 1.09; // 热饮 9% 税率
        }
    }
}

关键点解析:

  • @Override 注解不是可选的,它告诉编译器:“我明确知道这是重写父类方法”,如果父类方法签名变更(如改成 calculateTotalPrice(int discount) ),编译器会立刻报错,避免静默失效;
  • calculateTotalPrice() 里调用 getPrice() 而非直接访问 price 字段,是因为 getPrice() 是受控的访问入口,未来若加日志或缓存,只需改一处;
  • isCold() 方法名用 isXxx() 是 Java Bean 规范,IDEA 会自动识别为布尔属性的 getter。

4.4 编写 Food 子类与 Main 入口:见证 OOP 的运行时刻

Food.java 结构类似,但业务规则不同:

public class Food extends MenuItem {
    private boolean isVegetarian; // 是否素食
    private int portionSize;      // 份量(克)
    
    public Food(String name, double price, boolean isVegetarian, int portionSize) {
        super(name, price);
        this.isVegetarian = isVegetarian;
        this.portionSize = portionSize;
    }
    
    // ... getter/setter 略
    
    @Override
    public double calculateTotalPrice() {
        double basePrice = getPrice();
        // 食品统一 15% 税率 + 5% 服务费
        return basePrice * 1.15 * 1.05;
    }
}

最后, Main.java 是运行的舞台:

public class Main {
    public static void main(String[] args) {
        // ✅ 正确:创建饮品对象
        Drink latte = new Drink("拿铁", 28.0, false, true);
        System.out.println("热拿铁总价:" + latte.calculateTotalPrice());
        
        // ✅ 正确:创建食品对象
        Food sandwich = new Food("三明治", 22.0, true, 200);
        System.out.println("素食三明治总价:" + sandwich.calculateTotalPrice());
        
        // ❌ 编译错误:无法实例化抽象类
        // MenuItem item = new MenuItem("测试", 10.0); 
        
        // ✅ 正确:多态调用(下一节详解)
        MenuItem[] menuItems = {latte, sandwich};
        for (MenuItem item : menuItems) {
            System.out.println(item.getName() + " 总价:" + item.calculateTotalPrice());
        }
    }
}

运行结果:

热拿铁总价:30.52
素食三明治总价:26.796
拿铁 总价:30.52
三明治 总价:26.796

看到最后一行了吗? menuItems 数组的类型是 MenuItem (父类),但实际存放的是 Drink Food (子类)对象。循环中调用 item.calculateTotalPrice() ,JVM 会 自动根据实际对象类型,调用对应的重写方法 ——这就是多态(Polymorphism),OOP 的第五大支柱,虽未在标题中列出,却是继承与抽象的必然产物。

5. 常见问题与排查技巧实录:那些让老手也皱眉的坑

5.1 编译错误:“Cannot resolve symbol 'MenuItem'” —— 包名与文件路径的隐形战争

现象 :明明 MenuItem.java 就在 src/main/java/ 下, Drink.java 里写 extends MenuItem 却报红,提示找不到符号。

根因 :Java 要求 类名必须与文件名完全一致(大小写敏感) ,且 默认包(package)下所有类必须在同一目录 。如果你把 MenuItem.java 放在 src/main/java/com/cafe/ 下,但没写 package com.cafe; ,JVM 就认为它在默认包,而 Drink.java 若在 com.cafe 包下,就无法访问默认包的类。

解决方案

  1. 确认所有文件都在 src/main/java/ 根目录(无子包);
  2. 删除所有 package xxx; 声明(默认包);
  3. 如果要用包(推荐),则:
    • MenuItem.java 第一行写 package com.cafe;
    • Drink.java 第一行写 package com.cafe;
    • 确保文件路径为 src/main/java/com/cafe/MenuItem.java

实操心得:我曾帮一个团队排查此问题耗时半天,最后发现是 Git 仓库里 MenuItem.java 文件名被 Mac 系统自动转为小写 menuitem.java ,而 Linux 服务器区分大小写,导致编译失败。用 ls -la 查看真实文件名是必备技能。

5.2 运行时异常:“java.lang.IllegalArgumentException: 无效名称 'latte'” —— 大小写校验的陷阱

现象 new Drink("latte", 28.0, false, true) 报错,提示 latte 不在白名单,但白名单里明明有 "拿铁"

根因 :校验逻辑 valid.toLowerCase().equals(lowerName) 中, valid 是中文( "拿铁" ),调用 toLowerCase() 无效果(中文无大小写),而 lowerName "latte" (英文),永远不等。

修复方案 :白名单应同时支持中英文,或统一规范输入源。我们选择增强校验:

// 在 setName() 中,替换白名单匹配逻辑
String[] allValidNames = {
    "美式", "美式咖啡", "Americano",
    "拿铁", "拿铁咖啡", "Latte",
    "卡布奇诺", "Cappuccino",
    // ... 其他
};
// 后续匹配逻辑不变

5.3 逻辑错误:“热拿铁总价显示 30.52,但财务要求是 30.53” —— 浮点数精度的幽灵

现象 28.0 * 1.09 计算结果是 30.519999999999996 System.out.println 默认四舍五入显示 30.52 ,但财务系统要求精确到分(两位小数),且必须向上取整(银行家舍入法)。

根因 double 是二进制浮点数,无法精确表示十进制小数(如 0.1 ),累加误差会导致最终结果偏差。

专业解法 :用 BigDecimal 替代 double

import java.math.BigDecimal;
import java.math.RoundingMode;

public abstract class MenuItem {
    private BigDecimal price; // 改为 BigDecimal
    
    public MenuItem(String name, BigDecimal price) {
        // ... 校验逻辑适配 BigDecimal
        this.price = price;
    }
    
    public BigDecimal calculateTotalPrice() {
        return price.multiply(BigDecimal.valueOf(1.09))
                   .setScale(2, RoundingMode.HALF_UP); // 精确到分
    }
}

注意: BigDecimal 构造函数推荐用 BigDecimal.valueOf(double) 而非 new BigDecimal(double) ,后者会继承 double 的精度缺陷。

5.4 设计困惑:“为什么 Drink 里不直接用 price * 1.09 ,而要调用 getPrice() ?”

疑问 getPrice() 只是 return price; ,直接访问 price 不是更快吗?

答案 :这是面向对象的“间接性”哲学。 getPrice() 是一个 契约接口 ,它承诺:“无论内部如何存储价格( double BigDecimal 、甚至从数据库动态加载),调用者都能获得一个 double 值”。如果未来某天,价格需要从 Redis 缓存读取,你只需改 getPrice() 方法:

public double getPrice() {
    String cachedPrice = redis.get("price:" + this.name);
    return Double.parseDouble(cachedPrice);
}

所有调用 getPrice() 的地方(包括 calculateTotalPrice() )完全无需改动。而如果代码里到处是 this.price ,你得改遍所有子类——这就是封装带来的可维护性红利。

6. 进阶思考:这套系统如何走向生产环境?

6.1 从硬编码到配置中心: MAX_PRICE 和税率的动态化

当前 MAX_PRICE = 200.0 和税率 1.09 是硬编码。生产环境必须支持动态调整:

  • 方案一(简单) :用 application.properties (Spring Boot):
    cafe.menu.max-price=200.0
    cafe.tax.rate.drink.hot=1.09
    cafe.tax.rate.drink.cold=1.06
    
  • 方案二(企业级) :接入 Apollo/Nacos 配置中心,实现配置实时推送,无需重启服务。

6.2 从单一继承到接口组合:当“季节限定款”需要有效期

明年要上“樱花限定拿铁”,它有 startDate endDate 字段。 Drink 类不能无限加字段。正确做法是定义接口:

public interface SeasonalItem {
    LocalDate getStartDate();
    LocalDate getEndDate();
    default boolean isAvailable() {
        return LocalDate.now().isAfter(getStartDate()) && 
               LocalDate.now().isBefore(getEndDate());
    }
}

// Drink 实现该接口
public class Drink extends MenuItem implements SeasonalItem {
    private LocalDate startDate;
    private LocalDate endDate;
    
    // 实现接口方法...
}

这样, SeasonalItem 的逻辑可复用于 Food ,且不影响现有继承结构。

6.3 从手动测试到自动化:为 calculateTotalPrice() 写单元测试

用 JUnit 5 验证核心逻辑:

@Test
void testHotDrinkTax() {
    Drink hotLatte = new Drink("拿铁", BigDecimal.valueOf(28.0), false, true);
    BigDecimal result = hotLatte.calculateTotalPrice();
    assertEquals(BigDecimal.valueOf(30.52), result); // 断言精确值
}

覆盖率目标:所有 setXxx() 的异常分支、所有 calculateTotalPrice() 的条件分支,必须 100% 覆盖。

我写这篇时,正坐在上海武康路一家咖啡馆,窗外梧桐叶影斑驳。桌上摆着刚打印的 MenuItem.java 代码,旁边是杯凉掉的拿铁。OOP 的魅力正在于此——它不是悬浮于空中的理论,而是你敲下 new Drink(...) 时,指尖传来的那种笃定:这个对象,从诞生起就被规则守护,它的行为可预测,它的演化有路径。当你下次面对一个新需求,别急着写 if-else ,先问自己: 这个东西,在现实世界里,它是什么?它有哪些不变的特质?它和别的东西,是什么关系? 答案自然浮现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值