news 2026/10/12 13:49:45

面向对象设计核心概念与SOLID原则:从类与对象到多态、重构实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
面向对象设计核心概念与SOLID原则:从类与对象到多态、重构实践

1. 面向对象设计到底在解决什么问题

1.1 从一段"能跑就行"的代码说起

先看一段很常见的过程式写法,模拟一个电商订单的计价逻辑:

double totalPrice = 0; for (Item item : order.items) { if (item.type == ItemType.BOOK) { totalPrice += item.price * item.count * 0.9; } else if (item.type == ItemType.ELECTRONIC) { totalPrice += item.price * item.count * 0.85; if (item.price > 10000) { totalPrice -= 200; } } else if (item.type == ItemType.FOOD) { totalPrice += item.price * item.count * 0.95; if (item.expireDays < 3) { totalPrice *= 0.8; } } }

这段代码有问题吗?单看功能,完全没问题。但两周之后,产品经理过来说:我们要新增一种"会员专享商品",折扣规则不一样,还要支持"满两件打八折"。这个时候你打开这段代码,往里面再塞两个if分支。三个月后,这段计价逻辑膨胀到两百行,里面塞满了各类商品的特殊规则,没人敢动它,因为改一个分支就可能影响另外三种商品的折扣。

这就是面向对象设计要解决的第一个问题:当业务规则持续增长时,如何让代码保持可读、可扩展、可维护。面向对象不是让你把代码写得更花哨,而是让你面对变化时,改动范围足够小,小到可以预估风险。

1.2 面向对象设计追求的三个目标

我习惯把面向对象的目标总结成三个词,对应三种实际场景:

可读。你三个月后回来看自己的代码,或者一个新同事接手你的代码,不需要从头捋一遍业务流程就能知道某个类、某个方法在干什么。类名和方法名本身就是文档,这一点比注释可靠得多。

可扩展。新增一种商品类型时,不需要改动已有的逻辑,而是通过新增一个类来扩展系统。这就是常说的"对扩展开放,对修改关闭"。做到这一点的直接收益是:你不需要重新测试所有老功能,只需要测试新类。

可维护。某个逻辑出了bug时,你能迅速定位到具体类中的具体方法,而不是在长长的过程式代码里来回翻找。面向对象的封装性天然把"什么东西归谁管"划分清楚,这就像公司里每个部门职责分明,出了问题不会扯皮。

这三个目标听起来很虚,但落到代码里都有非常具体的检验方式。后面我会用实际例子一条条对应上。

1.3 不是所有场景都需要面向对象

有一点必须说在前面:面向对象设计不是万能药。写工具脚本、做数据处理管道、写一次性分析的脚本,用函数式或过程式写法反而更直接。我见过一些人,写一个只有两个方法的工具类也要硬套模板方法模式,结果代码绕了三层,真出问题的时候要找五个文件。

面向对象最适用的场景是业务规则复杂、存在明显角色分工、需求会持续演进的系统。也就是说,你判断未来半年内这个模块会被频繁改动、或者有多个相似角色需要统一管理时,才值得引入面向对象这套设计思路。

2. 四个基础概念,一次讲透

2.1 类与对象:模板和实例的关系

类和对象的关系最容易用"图纸和房子的关系"来理解。类是图纸,规定了房子有几个房间、几个门、什么风格;对象是照着图纸建出来的具体房子,每栋房子可以有自己不同的装修。

public class DiscountCalculator { private double rate; // 折扣率 private double reduce; // 立减金额 public DiscountCalculator(double rate, double reduce) { this.rate = rate; this.reduce = reduce; } public double calculate(double originalPrice) { return originalPrice * rate - reduce; } } // 创建两个具体的对象 DiscountCalculator bookDiscount = new DiscountCalculator(0.9, 0); DiscountCalculator newUserDiscount = new DiscountCalculator(0.8, 50);

这里有个非常容易踩的认知误区:初学者总以为"类就是用来放方法的",于是一口气写一个巨大的类,把所有打折逻辑放进同一个类里。这是把类当成了"功能模块",而不是"业务角色的抽象"。正确的思考方式是:先找业务里的角色,一个角色对应一个类。在订单场景里,角色是"折扣计算器"、"商品"、"订单"、"支付方式",而不是"订单处理工具"这种四不像。

2.2 封装:为什么字段必须私有

封装这个概念被解释烂了,但很少有人讲清楚"封装到底在保护什么"。表面上看,封装是把字段设为private、提供getter/setter。本质上,封装是在保护不变式——也就是"这个对象在任何时候都必须满足的规则"。

举个例子。一个账户类,余额永远不能为负。如果字段是public的,外部代码可以随意赋值,这个约束就失效了:

public class Account { private double balance; public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("存款金额必须为正数"); } balance += amount; } public void withdraw(double amount) { if (amount > balance) { throw new IllegalStateException("余额不足"); } balance -= amount; } public double getBalance() { return balance; } }

如果balance是public字段,哪怕你写一万条注释"请不要直接修改balance",也拦不住某个半夜加班赶需求的同事直接写account.balance = 99999。而封装之后,所有修改余额的行为都必须经过deposit和withdraw方法,你可以在方法入口统一校验规则。

我的建议是:不要写多余的getter/setter。很多人习惯给每个字段都生成一对getter/setter,觉得"以后用得上"。但这是典型的"为了封装而封装",反而把内部状态完全暴露了。真正的封装不是"提供一个访问入口",而是"只暴露必要的行为"。

2.3 继承:用对是复用,用错是灾难

继承是面向对象里被误解最深的概念。很多人以为继承的目的是"代码复用"——子类继承父类的方法,省得重复写。这个理解有一定道理,但非常危险。如果把"复用代码"作为使用继承的唯一理由,很快就会写出一个巨大的基类,里面塞满各种方法,子类继承过来却用不到一半。

继承的正确用途是表达"is-a"关系,并且子类能够被当成父类来使用。这就是里氏替换原则的核心:子类对象应该能替换父类对象而不改变程序的正确性。

我举一个典型的反例:

// 错误的继承:鸵鸟不是会飞的鸟 public class Bird { public void fly() { System.out.println("飞翔"); } } public class Ostrich extends Bird { @Override public void fly() { throw new UnsupportedOperationException("鸵鸟不会飞"); } }

这段代码的问题在于:任何接收Bird类型参数的方法,理论上都可以调用fly方法。传入Ostrich时就会在运行时爆炸。这就是继承使用不当的典型案例——你用继承强行建立了错误的层级关系。

正确的做法通常是组合优先于继承。把"会飞"这个能力提取成接口,而不是放进基类:

public interface Flyable { void fly(); } public class Sparrow implements Flyable { public void fly() { System.out.println("麻雀飞翔"); } } public class Ostrich { // 鸵鸟没有fly方法,不需要实现Flyable }

在实际项目里,我见过太多因为滥用继承导致的悲剧。三层继承是常见病,五层继承不是没有。每一层继承都新增几个字段和方法,最底层的类变得无比臃肿。等你发现需要把父类的一个方法改成abstract的时候,才知道什么叫牵一发动全身。

2.4 多态:让代码面向接口而不是面向实现

多态是面向对象里最有威力、也最需要刻意练习的概念。一句话解释:不同的对象,对同一个消息做出不同的响应。

public interface DiscountStrategy { double applyDiscount(double originalPrice); } public class BookDiscount implements DiscountStrategy { @Override public double applyDiscount(double originalPrice) { return originalPrice * 0.9; } } public class ElectronicDiscount implements DiscountStrategy { @Override public double applyDiscount(double originalPrice) { double price = originalPrice * 0.85; if (originalPrice > 10000) { price -= 200; } return price; } } public class DiscountService { public double calculate(Book book, DiscountStrategy strategy) { return strategy.applyDiscount(book.getPrice()); } }

看到关键点了吗?DiscountService.calculate方法接收的是接口类型,它完全不知道具体是哪一种折扣策略。将来新增一种"会员折扣",只需要再写一个MemberDiscount实现DiscountStrategy接口即可,DiscountService一行都不用改。

这就是"面向接口编程"的核心思想:高层代码依赖抽象,不依赖具体实现。用生活化的比喻来说,你的充电器是Type-C接口,不管你是手机、平板还是耳机,只要有Type-C接口都能充电。充电器不需要知道插上去的是什么设备。

3. 设计原则落地:SOLID在真实场景里的取舍

3.1 单一职责原则:类什么时候该拆

单一职责原则(SRP)常被简称为"一个类只做一件事"。但这个"一件事"的粒度很难把握。我见过有人把一个类拆到只有两个方法还觉得不够,也有人一个类写了三千行还在说"我遵守了单一职责"。

我自己的判断标准很简单:如果一个类能说出两个及以上的改动理由,就说明它承担了多个职责。比如"ReportService"既负责生成报表数据又负责格式化输出,那么当"报表数据来源变化"和"输出格式变化"时都要改它,这就是两个改动理由。

实操中,我会先考虑什么情况下不拆。一个类只有二十行、改动频率极低,哪怕它"看起来"做了两件事,也没必要为了原则而原则去拆。单一职责是为了降低改动风险,不是为了让类数量显得很多。

3.2 开闭原则:新增功能不碰老代码

开闭原则(OCP)说的是:对扩展开放,对修改关闭。这是面向对象设计最核心的收益之一。

我举个例子,假设你的系统里有一个报表导出器,最初只支持导出PDF:

public class ReportExporter { public void export(ReportData data, String format) { if ("PDF".equals(format)) { exportPDF(data); } } }

现在要支持Excel,按照"修改"的做法,你会加一个else分支:

if ("PDF".equals(format)) { exportPDF(data); } else if ("EXCEL".equals(format)) { exportExcel(data); }

这看起来没什么,但当支持十种格式时,这个方法会有十个分支,而且每次新增格式都要改动这个类。符合开闭原则的写法是:

public interface ReportExporter { void export(ReportData data); } public class PdfExporter implements ReportExporter { @Override public void export(ReportData data) { // PDF导出逻辑 } } public class ExcelExporter implements ReportExporter { @Override public void export(ReportData data) { // Excel导出逻辑 } } // 使用方 public class ReportService { private ReportExporter exporter; public ReportService(ReportExporter exporter) { this.exporter = exporter; } public void export(ReportData data) { exporter.export(data); } }

新增格式时,写一个新的实现类,然后通过配置或工厂方法把它注入进去。老代码一个字节都不用改。

3.3 依赖倒置:高层不要依赖细节

依赖倒置原则(DIP)表达起来很绕,我换算成大白话:你的业务逻辑不应该依赖具体的技术实现。举个例子,你的订单模块需要发短信通知用户。最直觉的写法是:

public class OrderService { private SmsSender smsSender = new SmsSender(); public void createOrder(Order order) { // 业务逻辑 smsSender.send(order.getUserId(), "您的订单已创建"); } }

这个写法最大的问题是:OrderService直接依赖了SmsSender这个具体类。如果明天要换成邮件通知、App推送,或者同一个接口对接多个短信服务商,你必须修改OrderService。

依赖倒置的写法是:

public interface Notifier { void send(String userId, String message); } public class SmsNotifier implements Notifier { // 短信实现 } public class OrderService { private Notifier notifier; public OrderService(Notifier notifier) { this.notifier = notifier; } public void createOrder(Order order) { // 业务逻辑 notifier.send(order.getUserId(), "您的订单已创建"); } }

看到区别了吗?OrderService现在只依赖Notifier这个抽象接口,完全不关心具体怎么通知。换实现的时候,只需要调整组装的地方(依赖注入),OrderService不需要再动了。

4. 实战:重构一个订单计价模块

4.1 原始过程式代码的问题分析

拿文章开头那个计时逻辑来完整分析。假设现在有一个需求变化:每一种商品类型都有自己的折扣策略,并且未来每个月都可能新增策略。过程式代码会遇到几个核心问题:

第一,计价逻辑集中在一个方法里,随着策略增多,方法体越来越大。第二,增加新策略必须改动核心方法,改动的风险波及到所有已有策略。第三,各种策略的规则互相穿插,比如"食品临期打八折"和"电子商品满减",每一个规则都硬编码在if分支中。

用思维导图的方式来看,过程式代码是把所有策略平铺在一个维度上:

  • 商品类型判断
  • 折扣率规则
  • 额外满减规则
  • 临期特殊处理

这些规则混在一起,本质上是把规则和数据耦合在了一个流程里。

4.2 面向对象重构后的结构

重构思路是这样的:把"每种商品的折扣计算方式"抽象成策略接口,每种具体规则一个类。这样增加规则就不再修改核心计价逻辑,而是添加一个新的策略类。

类的结构如下:

  • DiscountStrategy接口:定义折扣计算方法
  • BookDiscountStrategy图书折扣策略
  • ElectronicDiscountStrategy电子商品折扣策略
  • FoodDiscountStrategy食品折扣策略
  • OrderCalculator订单计价器,核心入口

我额外增加一个规则:每种策略可以返回一个"折扣详情",方便做日志和用户展示。

public interface DiscountStrategy { DiscountResult calculate(Item item); }

DiscountResult是一个简单的值对象,包含折扣后的价格、优惠说明:

public class DiscountResult { private final double discountedPrice; private final String description; public DiscountResult(double discountedPrice, String description) { this.discountedPrice = discountedPrice; this.description = description; } public double getDiscountedPrice() { return discountedPrice; } public String getDescription() { return description; } }

4.3 关键代码实现

public class BookDiscountStrategy implements DiscountStrategy { @Override public DiscountResult calculate(Item item) { double price = item.getPrice() * item.getCount() * 0.9; return new DiscountResult(price, "图书类商品统一9折"); } } public class ElectronicDiscountStrategy implements DiscountStrategy { @Override public DiscountResult calculate(Item item) { double price = item.getPrice() * item.getCount() * 0.85; String description = "电子商品统一85折"; if (item.getPrice() > 10000) { price -= 200; description = "电子商品85折,满10000立减200"; } return new DiscountResult(price, description); } } public class FoodDiscountStrategy implements DiscountStrategy { @Override public DiscountResult calculate(Item item) { double price = item.getPrice() * item.getCount() * 0.95; String description = "食品统一95折"; if (item.getExpireDays() < 3) { price *= 0.8; description = "临期食品折扣价"; } return new DiscountResult(price, description); } }

核心计价器变得非常简洁:

public class OrderCalculator { private final Map<ItemType, DiscountStrategy> strategyMap; public OrderCalculator() { strategyMap = new HashMap<>(); strategyMap.put(ItemType.BOOK, new BookDiscountStrategy()); strategyMap.put(ItemType.ELECTRONIC, new ElectronicDiscountStrategy()); strategyMap.put(ItemType.FOOD, new FoodDiscountStrategy()); } public double calculateOrderPrice(Order order) { double total = 0; for (Item item : order.getItems()) { DiscountStrategy strategy = strategyMap.get(item.getType()); if (strategy == null) { throw new IllegalArgumentException("不支持的商品类型: " + item.getType()); } DiscountResult result = strategy.calculate(item); total += result.getDiscountedPrice(); System.out.println("商品[" + item.getName() + "] " + result.getDescription()); } return total; } }

现在的扩展路径非常清晰。如果要新增"会员商品",需要做三件事:定义MemberDiscountStrategy实现DiscountStrategy接口,在构造函数里注册ItemType.MEMBER -> new MemberDiscountStrategy(),完事。核心的calculateOrderPrice方法一行没改。

这是面向对象设计最直观的收益:面对变化,你要么新增一个类,要么修改一行注册代码,而不是在两百行的if-else里找一个合适的位置插入新分支。

5. 常见问题与实操经验

5.1 过度设计的陷阱

学了面向对象之后,很多人容易走向另一个极端:一上来就用抽象工厂、建造者模式、策略模式、装饰器模式,把一个小需求包装得密不透风。我见过一个下单接口,调用链路里有七个参与者,每个参与者都有接口和两个以上实现类,而实际业务只有一个实现。

我的经验是用面向对象设计,但保持克制。每次引入一个抽象之前,先问自己:这个抽象现在就有多个实现吗?如果没有,能不能等到第二个实现出现时再抽象?比起提前抽象,我更倾向于"第一次写具体、第二次抽象、第三次模式化"。

不过,基础的封装和职责划分不应该省。类的职责是否清晰、方法命名是否准确、字段是否被过度暴露,这些从第一天就应该做好。

5.2 数据类与行为类的区分

很多时候我们把类写成了"纯数据容器"——只有一堆getter/setter,没有任何行为。这种对象被称为"贫血模型",它在实际项目中会带来一个问题:相关的业务逻辑被散落在各个服务类里。

比如一个Order类,里面只有订单状态、金额、商品列表的getter/setter。而"订单是否允许取消"这个判断逻辑写在OrderService里,订单状态流转更新也写在OrderService里。时间一长,OrderService变成了上帝类,所有业务逻辑都堆在它身上,Order变成了纯数据载体。

我并不是主张所有逻辑都要塞进实体类。但那些只依赖自身内部状态的判断逻辑,放在实体类里是更自然的选择:

public class Order { private OrderStatus status; public boolean canCancel() { return status == OrderStatus.PENDING || status == OrderStatus.PAID; } public void cancel() { if (!canCancel()) { throw new IllegalStateException("当前状态不允许取消"); } this.status = OrderStatus.CANCELED; } }

这样不仅代码更内聚,而且测试变得非常简单——不需要构造一堆服务依赖,直接new一个Order然后调用方法即可。

5.3 判断设计好坏的三条快速检查

每次写完一个类或模块,我会用三个问题快速自查:

这个类能一句话说清楚它是干什么的吗?如果说不清楚,说明职责可能过多了。OrderManager这个类名就是典型反例——"订单管理"到底管哪些事?创建、取消、支付、退款、发货,哪个不属于管理?这个类迟早膨胀。

这个类的字段和方法是不是都有着密不可分的联系?如果发现有一半字段只被一半方法使用,而且这些字段和方法之间没什么关系,那这个类很可能是两个类被强行缝在了一起。

如果明天加一个新功能,我需要改哪些类?这是最实用的检测方法。加一个商品类型,如果只需要加一个类,设计是好的。如果需要改动三个类、两处配置、一个工厂方法,就要怀疑设计是否合理了。

5.4 接口与抽象类的选择

这是初学者最容易纠结的问题:什么时候用interface,什么时候用abstract class?

我的选择标准很直接:

  • 如果你要定义的是一组行为规范,不同实现之间没有共享代码,用接口。比如DiscountStrategy、Notifier。
  • 如果你要定义的是一个有公共状态和公共逻辑的骨架,并且子类一定会共享这些字段和方法,用抽象类。比如一个BaseHttpClient,它包含timeout、headers这些公共字段,以及sendRequest的公共流程,只有parseResponse留给子类实现。

Java 8以后接口可以有default方法,但这不代表接口应该用来放共享代码。接口的语义是"能做什么",抽象类/继承的语义是"是什么"。从语义出发选型,比从技术能力出发选型可靠得多。

6. 系列开篇收尾:这套设计思路要往哪里延伸

面向对象设计不是一个一次性的知识点,它是一整套不断打磨的思维习惯。第一篇文章到这,我们把类与对象、封装、继承、多态和SOLID的前三条讲透了,并且用订单计价的例子走了一遍从过程式到面向对象重构的完整路径。

我写过不少项目,也review过不少同事的代码,一个很真实的感受是:面向对象设计能力不是看几篇博客就能学会的,它需要你在实际项目里反复踩坑、反复重构,才能形成肌肉记忆。第一次写代码的时候,不要指望一步到位设计得完美,我几乎每次都要在业务变化之后回头重构两三遍,才慢慢逼近那个"加新功能不用改老代码"的状态。

如果你刚开始接触面向对象,我的建议是不要急着一口气学完所有设计模式。先把这篇文章里的四个基础概念吃透,把SOLID的前三条原则应用到自己最近写的代码里,然后留意那些"改一处牵全身"的瞬间。那些瞬间就是你的设计需要改进的信号。

下一篇我会接着讲SOLID剩余的两条原则、依赖注入如何落地,以及如何用UML类图来表达你的设计。但在此之前,建议你先把手上的某个过程式模块,用这篇文章的思路做一次面向对象重构。重构完你才会真正体会:面向对象设计不是给别人看的炫技,而是你自己维护代码时最省心的方式。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 13:40:16

open-code-review 开放代码审查:从设计到落地的完整指南

1. 从标题拆解 open-code-review 的真实意图第一次看到open-code-review这个标题&#xff0c;我脑子里蹦出来的第一个判断是&#xff1a;这大概率不是一个单纯的“代码审查工具”&#xff0c;而是一套围绕开放协作场景设计的审查流程或框架。为什么这么说&#xff1f;因为“ope…

作者头像 李华
网站建设 2026/10/12 13:40:10

ARM64离线部署Harbor v2.13.1:信创环境私有镜像仓库实战指南

简介&#xff1a;这份资源是 Harbor 最新 v2.13.1 的 ARM64 架构离线安装包&#xff0c;面向在 ARM64 服务器或 Kubernetes 集群上部署私有镜像仓库的运维与 DevOps 人员&#xff0c;解决国产化平台、信创环境或边缘节点无法直接拉取官方 x86 镜像、在线安装受网络限制的问题。…

作者头像 李华
网站建设 2026/10/12 13:40:09

TongSearch配置读取与生效机制原理解析

TongSearch配置读取与生效机制原理解析 1. 为什么配置系统会成为搜索引擎的第一道命门 做了这几年检索相关的开发&#xff0c;我越来越觉得一个搜索系统的工程质量&#xff0c;往往不是看它的倒排索引写得有多快、召回算法调得有多准&#xff0c;而是看它底层的配置体系是否扛…

作者头像 李华
网站建设 2026/10/12 13:34:54

Spring AI Alibaba停更,Java开发者如何用Spring AI+Ollama搭建本地RAG问答

看到这个标题点进来的&#xff0c;估计都是这两天在技术群里听到风声的Java开发者。说实话&#xff0c;“Spring AI Alibaba已停更”这个说法刚传开的时候&#xff0c;我心里也咯噔了一下&#xff0c;毕竟去年大家还在讨论怎么用Spring AI统一接入各家大模型&#xff0c;结果官…

作者头像 李华
网站建设 2026/10/12 13:33:21

校招笔试真题解析:数据结构与算法的工程化思维

1. 这不是刷题指南&#xff0c;而是一份“笔试现场还原手记”“经典笔试题-数据结构、算法及计算机基础篇”——看到这个标题&#xff0c;我第一反应不是打开LeetCode&#xff0c;而是翻出抽屉里那叠泛黄的A4纸。那是五年前我带过的三届校招实习生留下的手写笔记&#xff0c;每…

作者头像 李华