ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

OOP存在的真正理由:管理复杂状态与变化边界

OOP存在的真正理由:管理复杂状态与变化边界 我刚开始写代码的时候对 OOP面向对象编程一直有两三个说不清楚的疑问如果函数能复用为什么还要再包一层类如果数组什么都能装为什么还要定义一个对象直到我在一个老 PHP 项目里连续几个晚上都在同一个 “页面错误!请稍后再试” 上面打转才突然意识到OOP 出现的原因根本不是“让代码看起来像现实世界”而是它给出了一个解决复杂状态的思路。那次项目里的代码并不复杂登录、订单、商品就是最常见的业务。麻烦的是所有数据都被放在各种全局数组和函数之间来回传。某个函数在某个分支里改了一个字段另一个完全不相关的功能输出就变了。我修好了 A 模块B 模块开始报错。按行打断点、加日志查了半天最后发现罪魁祸首是一段看起来人畜无害的全局变量赋值。那段时间我重新理解了为什么早期的 PHP 框架会打出 “fast simple oop php framework” 这样的口号。因为当你的代码从一个文件变成几十个文件之后仅靠“函数调用函数”已经撑不住了。你真正需要的不是一个新语法而是一种新的“状态管理方式”。所以这篇不打算复述教科书里的概念定义。我会顺着“为什么 OOP 会存在”这条线讲清楚它到底解决了什么问题、为什么很多人用不好以及在一个真实项目里应该怎么落地。最后你会得到一个判断框架知道哪些场景适合 OOP哪些场景硬套反而更痛苦。1. 为什么单打独斗的过程式脚本到一定规模就撑不住了1.1 混乱通常不是“函数太多”而是“状态太散”过程式编程的逻辑很简单函数是动作的集合数据放在变量里函数接收数据处理完再返回。这种方式在脚本任务里非常顺手。写一个导入工具、处理一个临时报表、从一个接口拉数据再落到另一个接口函数式写法足够清晰。但到了真实业务系统里问题就变了。登录模块不仅要校验用户名和密码还要维护 session、记录登录日志、更新用户状态。订单模块更是复杂下单时扣库存、计算价格、生成快照、发送通知。这些操作并不是互相独立的它们共享一个共同的“业务状态”。传统做法是定义一堆全局变量或者通过参数把数据一层层传下去。全局变量的问题是任何函数在任何地方都能修改它。你可能写了一个updateUserStatus($uid, 2)结果另一个同事在别的地方也调用了同一个函数传入的状态值完全不同线上就出现了一个难以追踪的 BUG。参数传递稍微好一点但当一个函数需要十个参数的时候调用关系已经乱得没法维护。我印象很深的是有一次排查一个订单金额不对的问题。数据在十几个函数之间传了一遍每个函数都做了一点格式处理有的函数修改了数组引用有的函数复制了一份。最后谁也说不清金额是在哪个环节被改的。这不是“代码能力不足”而是过程式的结构天然就会把状态分散到各处。OOP 出现的原因之一就是想把这种散落的状态重新收拢起来。它把一个业务对象内部的数据和修改这些数据的方法放到同一个边界里。别的代码不能直接改内部状态只能通过公开的方法去操作。状态不再属于“全局”而是属于某一个对象。1.2 函数负责“怎么算”但没人负责“数据现在处于什么状态”过程式模型里函数是主演员数据是“食材”。一个函数切一切另一个函数炒一炒最后出锅。问题在于中间如果任何一个环节出错了你很难马上知道是哪一步把鱼香肉丝做成了番茄炒蛋。面向对象换了一个视角对象才是有生命力的东西数据和方法是它的一部分。比如一个Order对象它知道自己有几个订单项、总价是多少、目前处于什么状态。外部要加一个商品不是把这个数组拖出来 push 一下而是调用addItem方法。总价是不是要变、库存是不是要扣、订单状态是不是要更新都由对象自己决定。这不是什么玄学就是把“业务规则”和“状态变化”绑定在一起。过程式编程里你经常要自己去维护数据之间的关联关系在 OOP 里这些关系被封装进对象的方法里。只要调用方式正确对象会保证自己的状态始终是合理的。很多人觉得这是多此一举。“我直接改数组不也一样吗”确实在一两个小任务里是一样的。但系统一旦变大状态变化的次数会爆炸式增长。没有边界的修改最后就会变成你在老项目里看到的样子没人敢动一段代码因为不知道会不会踩坏其他逻辑。这个过程我写过太多次也劝过别人很多次先别急着批判 OOP 太啰嗦。你先写一个超过三千行的订单处理模块再来判断“类是不是多余的”。大部分时候你缺的不是代码技巧而是“让状态有归属”的意识。2. 面向对象的核心不是“把代码封装起来”而是“把变化装进边界里”2.1 封装不是数据隐藏是“一致性守卫”教科书上说封装就是“隐藏内部实现只暴露必要接口”。这个说法没有错但很容易让新手误以为封装的目的就是保密。实际上封装最大的价值是保证对象内部状态的正确性。举个例子。一个用户对象里有一个age字段如果外部可以直接赋值那user.age -20这样的代码就能跑通。但人的年龄不可能是负数。如果没有封装你需要在整个项目里到处检查if ($age 0)如果有封装setAge方法内部只需要做一次校验所有入口都被拦截了。这看起来是小事但它决定了系统能不能长期维护。业务规则越多这种“一致性校验”就越重要。订单状态不能从“未支付”直接跳成“已发货”账户余额不能因为一次取款操作变成负数优惠券不能重复使用。这些规则如果散落在各个控制器和脚本里总有一天会漏掉某一个入口。封装让“谁能改状态”“怎么改状态”变得更受控。它不能保证你写出没有 BUG 的代码但它能让你更早发现状态被改坏的地方。对象内部保存的数据通过方法开放修改通道所有规则都在同一个位置维护。这是过程式代码很难做到的。2.2 多态让调用者不关心具体实现让变化不炸开OOP 里有一个很常见的场景你有多个实现类都实现了同一个接口。调用方只知道自己拿到的是一个“接口类型”并不需要关心背后到底是哪一个实现。这种能力为什么会存在因为它解决了软件开发里最头疼的问题变化。想象一个支付模块。一开始只有支付宝你写了一个payByAlipay()。后来接入微信支付你说不改原来的代码于是加了一个payByWechat()。下单流程里就出现了一堆if ($method alipay)。再后来接入银行卡支付条件分支越来越多每次都要改动同一个地方。如果使用多态你会定义一个PaymentGateway接口里面有pay()方法。每个支付方式都是一个实现类。下单流程只依赖接口具体用哪个实现通过配置或工厂决定。以后新增支付方式不需要动核心流程只要新增一个类。这就是多态存在的意义它把一个“可能变化”的点变成可以替换的边界。调用方不需要了解所有细节实现方也不需要被调用方绑定。系统因此变得更灵活也更容易扩展。很多人学 OOP 时觉得“多态只是语法问题”。恰恰相反多态是一种项目管理工具。它把“什么时候换实现”和“怎么使用实现”分离了。你的代码不再因为“具体支付方式有哪些”而膨胀也不会因为新增需求而东改西改。2.3 继承一种曾经最显眼、如今最需要慎用的复用关系继承是 OOP 里最容易被误解的特性。教科书里喜欢画动物继承图猫是动物狗是动物所以它们都继承Animal。听起来顺理成章但放到真实项目里就很容易翻车。继承的本质是“一个类在另一个类的基础上扩展或覆写”。它能实现代码复用但代价是子类和父类之间产生了强耦合。父类修改一个方法所有子类都可能受影响子类覆写了某个方法又可能导致父类的预期行为被破坏。更麻烦的是业务对象之间的“is-a”关系往往不像动物分类那么纯粹。一个“员工”是不是一个“用户”一个“游客订单”是不是一个“订单”真实业务中常有交叉和特殊情况硬用继承去表达很快就得在一个类里堆满分支。所以现在的工程实践更推荐组合。不是“员工是一个用户”而是“员工拥有一个用户账号”。不是“线上订单是订单的一种”而是“订单拥有一个支付渠道”。组合让关系更清楚也让变化影响范围变小。继承并没有被淘汰只是它更适合用在“行为真正稳定”的层级而不是用来描述所有业务概念。OOP 的价值不在于“你必须用继承”而在于它提供了一整套工具让你能把变化用不同的方式控制住。3. 为什么 OOP 总是被骂因为很多项目根本没用对3.1 最常见的错误用 OOP 去复刻现实世界而不是建模职责很多初学者接触 OOP 时学的第一课是“把现实世界映射成代码”。狗会叫猫会叫于是设计一个动物类子类分别实现叫()。这个例子用来理解语法没问题但把它当成设计方法论就是灾难。因为真实业务系统里百分之八十的类并不对应现实世界的“物品”。它们对应的是一些更抽象的概念订单、账单一、库存流水、权限策略、接口调用记录。这些概念没有长长的毛也不会发出声音但它们拥有状态和规则。一个真正有用的类核心是“它承担什么职责”而不是“它叫什么名字”。Order类的职责是维护订单数据的一致性和订单流转规则。PaymentGateway接口的职责是表达“支付方式可以被替换”这个变化点。设计类的时候先问“谁需要对状态负责”“哪个变化点会被扩展”比先想“这对应现实世界的什么”要可靠得多。用 OOP 复刻现实世界很容易得到一个“像模像样但完全没用”的继承树。你以为自己在用面向对象实际上只是用类这种语法重新写了一遍过程式逻辑。3.2 第二个错误一上来就搞“万能体系”过度抽象如果说复刻现实世界是新手容易踩的坑那过度抽象就是有一点经验之后更容易踩的坑。我见过一个内部项目团队花了两周时间设计了一套“可插拔”的消息路由系统。每个处理器都有接口每个接口都有抽象基类每个基类又派生出几十个子类。看起来非常优美所有人都称赞它“设计模式用得很到位”。但问题是整个项目实际只有三种消息类型。用了十多个类维护成本远远超过了直接写三个方法。最后新增一种消息类型时要同时改四个文件。所谓的灵活根本没有兑现。面向对象设计有一个原则叫“最小可用抽象”。当你的系统里还没有出现真正的变化点时不要提前留接口。接口、抽象类、依赖注入都是为了应对“变化”而存在的。如果变化还没有发生就别制造抽象。等你真的需要支持新的支付方式、新的消息类型再抽取接口也不迟。一个判断标准如果你现在无法说清楚新增一个功能时“哪里会变、哪里不变”那就不要急着做抽象设计。先写具体实现跑通流程再在重构中引入 OOP 的边界。3.3 OOP 不适合哪些任务什么时候过程式、函数式更合适OOP 不是银弹。它适合的是“有显式状态、有状态变化规则、有复杂协作”的系统。但有很多任务用过程式或函数式反而更舒服。比如一次性数据清洗脚本。你只需要读一个文件做格式转换输出到另一个文件。数据是流过管道的没有那么多状态需要维护。这时候硬造一个FileParser类、一个DataTransformer接口反而是多余的。再比如无状态的纯计算。给一个金额算税、给两个日期算差值。这些函数没有任何内部状态输入一样输出永远一样。用普通函数是最好的选择。把它包到类里如果不是为了依赖注入、不是为了策略替换那只是多了一层没有价值的壳。还有并发场景。OOP 里如果多个线程共享同一个对象就会引入大量的锁和同步问题。函数式编程强调“不可变数据”在这类场景里天然更容易并发安全。所以OOP 的适用边界很明确当项目里有“多个对象、多个规则、状态需要在变化中保持一致”时它才能真正发挥价值。如果你的项目只是一个流程式脚本那就坦然使用过程式写法。用一个合适的工具做合适的事比信仰某一种范式重要得多。4. 真正可落地的 OOP围绕业务规则和变化点建模4.1 一个类存在的第一理由它拥有一组规则和一个必须守住的状态写类的时候不要先想“我要不要拆一个 User 类”。先想这个模块里有哪些规则比如“订单总金额不能小于零”“已支付的订单不能被删除”“退款金额不能超过已支付金额”。这些规则不会自己跑它们需要被放在某个地方。把它们放在一个对象的方法里并且让这个对象保存相关的数据这个类就有存在价值。再比如“用户等级由累计消费金额决定”“升级之后要发通知”。这类跨字段、跨状态变化逻辑天然适合放在用户对象或领域服务中。你不需要一个万能类只需要一个类来承接“用户等级”这个业务规则。判断一个类该不该拆一个简单办法是看它的责任是否单一。如果这个类里有余额、有头像、有收货地址、有日志记录大概率是在用一个类硬装多个不相干的职责。把职责拆开让每个类只守一组规则复杂度会明显下降。4.2 设计时不先从“名词”入手先从“消息和职责”入手很多面向对象设计失败的根源是第一步就错了。先建“对象列表”然后再给对象塞方法。最后方法越塞越多对象变成了一个装东西的容器。更好的思路是从“消息”出发也就是“对象之间需要协作完成什么”。比如“用户点击取消订单”这个动作涉及的不只是Order这个对象还可能是库存、支付渠道、优惠券。你不需要创建一个“订单操作处理器”然后把所有逻辑都塞进去吗可以。但更重要的是先梳理哪些对象需要收到这个消息它们各自需要做什么状态如何变化这种思路有点接近最初“对象消息传递”的核心对象之间不是通过直接操作对方的字段协作而是通过发送消息触发行为。代码读起来会变成“订单收到取消指令然后通知支付退款通知库存回滚”。比起“在订单控制器里把所有代码写一遍”这种方式更容易测试也更容易理解。如果你发现自己每天都在写getXxx然后做判断那这个设计大概率偏贫血了。一个真正的对象不应该只是一个数据袋。它应该有一定的方法去维护自己的规则。4.3 选型清单什么情况下值得用 OOP什么情况下硬套反而危险下面这个表格不一定适合所有项目但可以帮你快速做出判断。场景特征使用 OOP 的好处可能的风险多个模块共享同一份状态状态边界清晰修改路径受控如果类设计过大又会变成新的中心化瓶颈业务规则频繁变化多态和接口能隔离变化点过早抽象会引入不必要的复杂度多人协作的大型项目类和接口提供契约团队能并行开发沟通成本高接口定义不完整会返工一次性脚本、纯数据处理收益很小增加文件数量调试时需要来回跳转无状态并发计算收益很小共享可变对象容易带来线程安全问题传统框架遗留项目可以从一个 Service 类开始渐进重构如果侵入太深可能影响现有调用链这张表的核心意思是OOP 不是“写了类”就算用了而是“你有没有真的把状态和变化点管理起来”。如果只是把函数搬到类里面那只是一个更啰嗦的过程式代码而已。4.4 组合优先于继承前面提过继承的耦合问题。具体落地上我会先默认选择组合。比如支付场景先定义一个Payable或者PaymentGateway接口不同支付方式实现同一个接口。订单类只需要持有这个接口类型并不需要关心具体实现。这比让“微信订单”继承“支付宝订单”要安全得多。如果发现两个类确实有重复代码优先看看能不能抽出一个公共的辅助类或服务让它们组合这个辅助类。只有当它们有明确的“is-a”关系并且父类行为足够稳定时才考虑继承。组合的好处是你可以在运行时替换组件。明天想从微信支付换成 PayPal只需要传入另一个实现。继承做不到这一点因为子类的类型在编译期就固定了。这个原则我不只用在业务代码里就算是写基础设施代码也会先问一句我是真的需要继承还是只需要把几个通用的行为放进去大多数时候答案是后者。5. 我建议的落地顺序从最小的面向对象习惯开始5.1 不要把所有状态散在全局先让状态变成一个显式对象以前端项目举例。早期的 jQuery 代码里经常有大量$(#cart)操作。购物车数据存在哪里可能在全局变量里可能散落在 DOM 属性里。后来前端框架引入了 Store其实就是把状态集中到一个对象里然后定义 mutation 和 action 去修改它。这个思路和 OOP 的封装思想几乎是同构的。后端也一样。两个接口需要共享一个订单状态别把它放在全局数组里。先建一个Order类把状态放进去通过方法修改。即使你现在只有一个方法这个方向也是对的。从最小步骤开始不是让你一上来就画 UML 图而是把一个全局变量改成私有属性把散在四个地方的赋值改成调用一个方法。不需要几天只需要一个小时。5.2 依赖通过构造注入而不是在类里面 new这是很多从“半面向对象”走过来的项目最难改的一个习惯。如果你在类里面直接new OrderRepository()这个类就和具体实现绑死了。以后想替换存储、写单元测试都要去改类内部代码。更合理的做法是通过构造函数把依赖传进来。# 更接近依赖注入的写法示例结构 class OrderService: def __init__(self, repository, payment_gateway): self.repository repository self.payment_gateway payment_gateway传入的是什么由外层组装。这样OrderService不关心支付渠道具体是谁只要它实现了payment()方法即可。测试时也可以传入一个假的支付网关。这个改动带来的好处是类之间从“直接互相 new”变成“由入口统一组装”依赖关系一目了然测试也变得容易。5.3 先让最小用例和测试引导再补接口面向对象设计不应该是纯前期的“大设计”。我的经验是先写一个能跑通的最小实现再在第二个真实需求出现时抽出接口或抽象。举个例子。你第一个版本只需要支付宝。那就写一个AlipayService方法名就叫pay。等到微信支付真的来了再引入PaymentGateway接口。此时你很清楚两个类都需要pay接口的出现就是水到渠成。如果一开始就定义接口你很容易猜错。接口方法名不对参数不对等到实际写实现时再来回调整反而浪费更多时间。测试也能帮你判断设计坏没坏。如果每加一个新功能你都要改很多已有测试或者测试特别难写说明类的边界设计有问题。面向对象设计得好测试通常也比较清晰。因为每个对象都有自己的职责你很容易用一个替身去替代它。5.4 老代码改造如何在传统框架里渐进引入 OOP很多人面对一个老项目会觉得重构无从下手。其实不需要推倒重来。拿老 PHP 框架那类项目打比方。Controller 太胖Model 里塞满了业务规则。你可以先把业务流程的一部分提取到一个独立 Service 类比如OrderService。Controller 只负责接收请求和返回结果具体业务逻辑调用OrderService。Model 只保留数据表和简单关联复杂规则逐步移动到 Service 或领域对象里。这个过程的重点是“每次只迁移一个动作”比如先迁移“取消订单”。等到团队熟悉了这种写法再迁移下一个动作。不要一次性把所有代码全部重构否则回归风险极大也不容易评审。渐进式改造也不是 OOP 的特权但在面向对象体系里这种迁移是最自然的。因为类天然提供了一个边界你可以把一个函数从 Controller 搬到 Service不需要改变 Controller 对外暴露的方式上层调用方完全无感。6. 为什么 OOP 会存在一个更底层的判断回顾整个线索。OOP 不是为了让代码里有类不是为了让你用this和self显得专业也不只是为了满足“代码要像现实世界”的直觉。它存在的原因是软件开发到了一定复杂度之后“状态如何变化”会变成比“功能如何实现”更困难的问题。过程式代码擅长描述“做事”但不擅长描述“做这件事时整个系统会一起变成什么样”。OOP 给出的方案是把数据和修改数据的规则绑在一起不让状态满天飞把可能变化的部分放到接口和实现之间让替换变得安全把一个大功能拆成一组能相互协作的对象让每次改动的影响范围都有限。这套思想当然有额外成本。它需要你设计边界、设计接口、管理依赖。它也在一些无状态场景里显得多余。但只要你面对的是一个长期演进、多人协作、业务规则不断变化的系统这些成本就是值得的。所以我最后想写给新手也一再提醒自己的建议是不要从“我要用 OOP”开始设计而是从“这段代码里谁的状态会变、谁最容易变、谁在改它”开始思考。你自然会发现某些地方需要按对象切分某些地方需要接口某些地方一个普通函数就够了。面向对象只是一个工具。理解它为什么存在比熟练背出它的每一个特性更有价值。下次再看到老项目里那个模糊的 “页面错误!请稍后再试”我希望你想到的不是“是不是 OOP 的问题”而是“这里一定有一个状态没有被管理好”。把状态和规则关进同一个笼子里这就是 OOP 存在的理由。
返回列表