Java咖啡售卖系统实战
Java面向对象实战——咖啡售卖系统作者没有四次元口袋的蓝胖日期2026-06-10标签Java, 咖啡售卖系统, 面向对象实战一、需求分析先想清楚再动手系统要做什么模拟一个咖啡店的点单流程用控制台交互完成。五个功能功能描述输入输出查看菜单展示所有可选咖啡无咖啡编号、名称、单价加入购物车输入编号数量加入购物车咖啡编号、购买数量添加成功/失败提示查看购物车展示已选咖啡明细无编号、名称、数量、小计、总价结账清空购物车并结算无总价、感谢语退出退出程序无退出提示为什么先分析需求很多新手拿到题目就写main方法写到一半发现数据结构不合适又推翻重来。先理清有哪些数据、怎么流转再决定用什么结构存代码才不会反复改。二、整体设计拆分职责2.1 需要哪些类面向对象的核心是每个类管好自己的事。按职责拆类职责为什么单独成类Coffee一杯咖啡的信息编号、名称、单价咖啡是独立实体菜单和购物车都要用到MenuItem或直接用数组菜单数据菜单是固定数据源和购物逻辑分开CartItem购物车中一条记录咖啡数量购物车项 ≠ 咖啡本身需要额外存数量和小计CoffeeShop主程序负责交互流程把交互和数据处理分开逻辑清晰关键设计决策为什么需要CartItem类不直接复用CoffeeCoffee只描述一杯咖啡是什么编号、名称、单价但购物车里还需要买了多少杯和这一项总价多少——这些是购物行为的信息不属于咖啡本身。如果把数量塞进Coffee那同一个咖啡买两杯就得创建两个对象不合理。所以单独设计CartItemCoffee 编号1, 名称美式, 单价15 CartItem coffee上面的Coffee, quantity3, subtotal45这体现了面向对象的单一职责原则一个类只做一件事。2.2 数据怎么流转菜单Coffee数组 │ │ 用户选择编号数量 ▼ 购物车CartItem列表 │ │ 结账计算总价、清空购物车 ▼ 订单完成2.3 数据结构选择数据结构原因菜单Coffee[]数组菜单固定不变数组简单够用购物车ArrayListCartItem购物车动态增删数组要手动扩容太麻烦为什么购物车用ArrayList不用数组用户可能加1件也可能加10件数量不确定。数组长度固定每次加元素都要新建更大的数组再拷贝而ArrayList内部自动扩容省心。三、逐步实现3.1 Coffee类——最基础的数据载体publicclassCoffee{privateintid;// 编号privateStringname;// 咖啡名称privatedoubleprice;// 单价// 构造方法创建咖啡时必须提供这三个信息publicCoffee(intid,Stringname,doubleprice){this.idid;this.namename;this.priceprice;}// Getter方法外部需要读取数据但不允许修改菜单是只读的publicintgetId(){returnid;}publicStringgetName(){returnname;}publicdoublegetPrice(){returnprice;}}为什么用private getterprivate外部不能直接改值防止菜单被意外篡改getter提供只读访问这是封装的体现不提供setter菜单数据初始化后不应该被修改为什么需要构造方法创建一个咖啡对象时三个属性缺一不可。构造方法强制调用者必须提供完整信息避免创建出没有名字的咖啡这种非法对象。3.2 CartItem类——购物车的一条记录publicclassCartItem{privateCoffeecoffee;// 哪种咖啡privateintquantity;// 买几杯publicCartItem(Coffeecoffee,intquantity){this.coffeecoffee;this.quantityquantity;}publicCoffeegetCoffee(){returncoffee;}publicintgetQuantity(){returnquantity;}// 同一编号咖啡的小计 单价 × 数量publicdoublegetSubtotal(){returncoffee.getPrice()*quantity;}}为什么subtotal用方法而不是字段两种方案方案Aprivate double subtotal;存为字段加入购物车时计算好方案BgetSubtotal()方法每次调用时实时计算选方案B的原因数据一致性。如果将来支持修改数量quantity变了字段subtotal就要同步更新容易忘。用方法实时计算quantity改了subtotal自动正确不会出现数量改了但小计没变的bug。这是派生数据不存储的原则能从已有数据算出来的值不要单独存。3.3 菜单初始化// 在主类中初始化菜单Coffee[]menu{newCoffee(1,美式咖啡,15.0),newCoffee(2,拿铁,18.0),newCoffee(3,卡布奇诺,20.0),newCoffee(4,摩卡,22.0),newCoffee(5,浓缩咖啡,12.0)};为什么用数组存菜单菜单是固定的程序运行期间不会增删数组最简单。如果将来需要动态增删菜单项改成ArrayList也很方便。编号为什么从1开始而不是0给用户看的编号从1开始更自然“请输入1-5选择咖啡”但数组下标从0开始。后面查找时用menu[input - 1]转换。3.4 查看菜单功能publicstaticvoidshowMenu(Coffee[]menu){System.out.println( 咖啡菜单 );System.out.println(编号\t名称\t\t单价);System.out.println(------------------------);for(Coffeec:menu){System.out.println(c.getId()\tc.getName()\t\tc.getPrice());}System.out.println();}为什么用增强for循环遍历数组每个元素不需要操作下标增强for更简洁。只有需要下标时如for (int i 0; i menu.length; i)才用普通for。方法为什么是static查看菜单只是遍历数组打印不需要访问对象的状态声明为static可以直接通过类名调用不需要创建对象。当然如果整个程序设计成面向对象的风格也可以不用static把菜单作为成员变量——两种方式都可以这里选简单的。3.5 加入购物车功能——核心逻辑publicstaticvoidaddToCart(Coffee[]menu,ArrayListCartItemcart,Scannersc){System.out.print(请输入咖啡编号);intidsc.nextInt();// 步骤1校验编号是否合法if(id1||idmenu.length){System.out.println(无效编号请重新选择);return;// 编号不对直接返回不继续往下执行}// 步骤2根据编号找到对应的咖啡Coffeeselectedmenu[id-1];// 用户输入1-5数组下标0-4// 步骤3输入购买数量System.out.print(请输入购买数量);intquantitysc.nextInt();// 步骤4校验数量if(quantity0){System.out.println(数量必须大于0);return;}// 步骤5检查购物车中是否已有同编号咖啡for(CartItemitem:cart){if(item.getCoffee().getId()selected.getId()){// 已有累加数量不用新增条目item.addQuantity(quantity);System.out.println(已更新购物车selected.getName() xitem.getQuantity());return;}}// 步骤6购物车中没有新建CartItem加入cart.add(newCartItem(selected,quantity));System.out.println(已加入购物车selected.getName() xquantity);}逐步拆解——每一步为什么这么做步骤1输入校验用户可能输入0、-1、999这些非法编号。不校验的话menu[id - 1]会数组越界程序直接崩溃。永远不要信任用户输入先校验再使用。步骤2编号到对象的映射用户输入的是编号1、2、3…程序需要的是Coffee对象。通过menu[id - 1]转换——这就是为什么编号从1开始要减1。步骤3-4数量校验数量为0或负数没有意义。校验后直接return避免非法数据进入购物车。步骤5重复咖啡的处理——这是最容易忽略的点如果不处理重复用户两次选择同一咖啡购物车里会有两条记录1 美式 2杯 30 1 美式 1杯 15虽然功能上没问题但不符合常规认知——同一商品应该合并。所以遍历购物车找到同编号的就累加数量。需要给CartItem加一个修改数量的方法publicvoidaddQuantity(intquantity){this.quantityquantity;}为什么这里可以修改quantity不用setter而之前Coffee不允许修改Coffee是菜单数据初始化后不应该变CartItem是购物车数据用户操作过程中数量会变所以需要提供修改能力。关键不是能不能改而是该不该改。3.6 查看购物车功能publicstaticvoidshowCart(ArrayListCartItemcart){if(cart.isEmpty()){System.out.println(购物车是空的先去选几杯咖啡吧);return;}System.out.println( 购物车 );System.out.println(编号\t名称\t\t数量\t小计);System.out.println(------------------------);doubletotalPrice0;// 累加所有项的小计for(CartItemitem:cart){System.out.println(item.getCoffee().getId()\titem.getCoffee().getName()\t\titem.getQuantity()\titem.getSubtotal());totalPriceitem.getSubtotal();}System.out.println(------------------------);System.out.println(总计totalPrice);System.out.println();}totalPrice的计算逻辑遍历每个CartItem累加getSubtotal()的返回值。这和数据库里的行级小计 → 总计是同一个思路。为什么要先判断isEmpty空购物车时展示空表格没意义直接友好提示更好。这是基本的用户体验——虽然是控制台程序但好习惯要从现在养成。3.7 结账功能publicstaticvoidcheckout(ArrayListCartItemcart){if(cart.isEmpty()){System.out.println(购物车是空的无法结账);return;}// 计算总价doubletotalPrice0;for(CartItemitem:cart){totalPriceitem.getSubtotal();}System.out.println( 结账 );System.out.println(消费总额totalPrice);System.out.println(感谢光临欢迎下次再来);System.out.println();// 清空购物车cart.clear();}为什么结账后要清空购物车结账意味着这笔交易完成购物车应该归零等下一笔。不清空的话下一个用户进来看到的是上一个人的购物车——数据污染。为什么用cart.clear()而不是cart new ArrayList()两种都能清空但clear()操作的是同一个对象外部引用仍然有效。new一个新对象的话如果调用方持有的还是旧引用清空就不生效。这是Java的引用语义问题。3.8 主流程——循环菜单驱动publicstaticvoidmain(String[]args){// 初始化Coffee[]menu{newCoffee(1,美式咖啡,15.0),newCoffee(2,拿铁,18.0),newCoffee(3,卡布奇诺,20.0),newCoffee(4,摩卡,22.0),newCoffee(5,浓缩咖啡,12.0)};ArrayListCartItemcartnewArrayList();ScannerscnewScanner(System.in);booleanrunningtrue;// 控制主循环while(running){System.out.println(\n 咖啡售卖系统 );System.out.println(1. 查看菜单);System.out.println(2. 加入购物车);System.out.println(3. 查看购物车);System.out.println(4. 结账);System.out.println(5. 退出);System.out.print(请选择功能);intchoicesc.nextInt();switch(choice){case1:showMenu(menu);break;case2:addToCart(menu,cart,sc);break;case3:showCart(cart);break;case4:checkout(cart);break;case5:System.out.println(再见欢迎下次光临);runningfalse;break;default:System.out.println(无效选择请输入1-5);}}sc.close();// 关闭Scanner释放资源}为什么用while switch而不是if-else主菜单是循环显示的用户可以反复操作直到选择退出。while负责一直转switch负责每次转到哪。用if-else的话循环一次就结束了不符合交互逻辑。为什么用running标志而不是breakbreak只能跳出switch不能跳出while。设置running false让while条件变假循环自然结束比break label更清晰可读。为什么最后要sc.close()Scanner打开了System.in这个输入流用完应该关闭释放资源。虽然程序结束后OS会自动回收但养成谁打开谁关闭的习惯很重要——实际项目中不关闭可能导致资源泄漏。四、面向对象知识点对照这个小项目把之前学的面向对象知识基本串了一遍知识点在项目中的体现类与对象Coffee、CartItem都是类menu数组中的每个元素都是对象封装属性private getter外部只能读不能改构造方法new Coffee(1, 美式, 15.0)创建时必须提供完整信息引用类型CartItem持有Coffee的引用不是拷贝一份方法设计每个功能一个方法职责单一数组 vs 集合菜单用数组固定购物车用ArrayList动态循环while主循环 for遍历 增强for条件判断输入校验、重复检查五、常见坑点与面试思考坑点1编号和下标的关系用户输入1对应menu[0]很多新手直接用menu[id]导致第一项永远取不到、最后一项越界。凡是给用户看的编号从1开始数组访问要减1。坑点2购物车重复项不合并如果不在加入购物车时检查重复同一种咖啡会出现多条记录。虽然总价不会错小计加起来一样但显示不符合直觉用户会觉得我明明加了3杯为什么出现两条坑点3double精度问题0.10.20.30000000000000004// 不是0.3咖啡单价和总价用double可能有精度误差。正式项目中应该用BigDecimalBigDecimalpricenewBigDecimal(15.0);BigDecimalquantitynewBigDecimal(3);BigDecimalsubtotalprice.multiply(quantity);// 精确的45.0面试中问为什么不用double算钱就是考这个点。这里用double是因为简易系统面试回答时要提BigDecimal。坑点4Scanner的nextInt()后接nextLine()问题本项目只用了nextInt()没踩这个坑。但如果将来加输入咖啡名称的功能intnsc.nextInt();// 读数字Stringssc.nextLine();// 期望读一行文字实际读到空行// 原因nextInt()只读数字回车符\n留在缓冲区// nextLine()读到\n就结束了所以拿到空字符串// 解决在nextInt()后面多加一个nextLine()吃掉回车intnsc.nextInt();sc.nextLine();// 吃掉回车Stringssc.nextLine();// 正确读到一行这是Java入门的经典坑面试也可能问。六、可能的扩展方向学会了基础版可以尝试以下扩展来加深理解会员折扣——加一个会员类结账时根据等级打折练习多态库存管理——Coffee加库存属性加入购物车时检查库存练习状态管理订单记录——结账后保存到ArrayList支持查看历史订单练习对象持久化思想文件存储——菜单和订单存到文件下次启动还在练习IO流异常处理——输入字母而非数字时的容错练习try-catch思维导图速览咖啡售卖系统 ├── 设计思路 │ ├── 职责拆分Coffee / CartItem / 主程序 │ ├── 数据结构菜单用数组购物车用ArrayList │ └── 数据流转菜单 → 加入购物车 → 查看 → 结账 ├── 核心类 │ ├── Coffee编号名称单价只读封装 │ ├── CartItem咖啡引用数量小计实时计算 │ └── 主类whileswitch驱动交互 ├── 关键逻辑 │ ├── 加入购物车校验 → 查找 → 重复合并/新增 │ ├── 查看购物车遍历小计累加总价 │ └── 结账计算总价 → cart.clear()清空 ├── 面向对象映射 │ ├── 封装 private getter │ ├── 构造方法 强制提供完整信息 │ ├── 引用 CartItem持有Coffee引用 │ └── 单一职责 每个类管好自己的事 └── 常见坑点 ├── 编号≠下标-1转换 ├── 重复项要合并 ├── double精度 → 用BigDecimal └── nextInt()后nextLine()吞回车写在最后咖啡售卖系统虽然简单但它完整展示了一个面向对象程序从需求分析→类设计→逐步实现→踩坑总结的全过程。核心收获先设计再编码——不是打开IDE就写main方法而是先想清楚有哪些类、每个类管什么、数据怎么流转CartItem的设计是面向对象思维的体现购物车的条目 ≠ 咖啡本身需要独立建模输入校验无处不在——永远不信任用户输入每个外部数据进来都要先验证double算钱的精度问题——面试高频考点回答时一定要提BigDecimal