面向对象真正管理的,不是对象,而是依赖

你自己可能见过蛮多代码,一边写着面向对象,一边把所有业务逻辑塞进Service,对象只剩下一堆getter和setter。也见过为了一个配置查询,搞出接口、工厂、策略、单例一整套结构,代码看起来很「OO」,维护起来却越来越复杂。

这类代码的问题不是面向对象用错了,而是方向就偏了。

面向对象真正管理的,不是对象,而是依赖。这里说的依赖不是Maven里的依赖包,而是软件中谁必须知道谁、谁必须跟着谁一起改。封装、抽象、多态、设计模式,这些手段最终都指向同一件事,让这些依赖关系变得可控。

我们可以从OO的诞生历史开始看,是不是这么回事。

OO从哪里来

面向对象的思想比面向对象的语言更早。

很多人以为Smalltalk是第一个面向对象语言,其实不是。第一个OO语言是Simula 67,由Dahl和Nygaard开发的。他们在做运筹学研究,需要精确描述和仿真复杂的人机系统。他需要一种语言,既能编译成计算机程序,也能让人读懂系统的结构。1967年Simula引入了继承机制,从一种仿真语言变成了通用编程语言。对象、类、继承、虚函数,这些OO的核心概念在Simula里都出现了。

几年后,Alan Kay在犹他大学读研究生时接触到了Simula的源码。他发现Simula的class结构很有意思,于是写了Smalltalk。但Kay的目标不是做一种OO语言,他要做的是Dynabook:一个给儿童用的个人计算媒介。Smalltalk是他实现这个目标的工具。

Kay对OO的定义和今天大多数人的理解完全不同。他说OOP对他来说只意味着三件事:消息传递、状态的本地保护与隐藏、极致的晚期绑定。他还说过一句话:我发明了object-oriented这个术语,但我心里想的不是C++或Java。

到了80年代,Stroustrup把Simula的关键概念引入了C语言,创造了C++。

从Simula到Smalltalk到C++,OO这条线索的脉络就很清楚了:Simula为仿真而生,Smalltalk是个人计算媒介的工具,C++是系统编程的需要。OO从来都是解决问题的手段,不是目的本身。

这个历史细节很重要。它告诉我们,面向对象不是某种终极真理,它只是几个不同团队在解决各自问题时,不约而同采用的一种思路。无论是Simula、Smalltalk还是C++,它们关注的都不是「对象」本身,而是如何组织一个越来越复杂的软件系统。于是,一个问题自然出现了:OO真正解决的,到底是什么?

OO到底在解决什么问题

很多人会觉得面向对象的核心是三大特性:封装、继承、多态。这话说得没错,但仔细想想,封装并不是OO独有的。C语言用struct加配套函数,本质上也在做类似的事情:把相关的数据和处理数据的函数组织在一起。这是编程的基本习惯,不是OO的发明。

对于今天主流的Java、C#开发来说,让OO区别于其他范式的最有价值的能力,是多态。

多态解决的核心问题是:调用方和实现方之间的耦合。举个例子,你在写一个开关,要控制电器。不用多态的话,你得在开关里写if-else判断电器类型:

Java 复制代码
public class Switch {
    public void turnOn(Light light, Fan fan, int type) {
        // 每增加一种电器,这个方法就得改
        if (type == 1) light.on();
        else if (type == 2) fan.start();
    }
}

每增加一种电器,开关的代码就得改。用多态的话,开关只依赖一个标准接口:

Java 复制代码
public class Switch {
    public void turnOn(Switchable device) {
        // 新增电器不用改这段代码
        device.on();
    }
}

开关不需要知道接入的是灯还是风扇,它只知道device.on()一定能被调用。新增一种电器,只要实现Switchable接口,开关的代码一行都不用动。

这个能力的本质是源码依赖方向的反转。不用多态时,调用方(Switch)的源码直接依赖实现方(Light、Fan)的源码。用多态后,调用方只依赖接口(Switchable),不再依赖具体实现。实现方怎么变,调用方都不受影响。这就是面向对象真正在做的事情:管理依赖。SOLID原则里的依赖倒置原则,讲的就是同一件事。

不过话说回来,这个能力不是免费的。如果只有一种电器,引入接口就是多余的复杂度。多态的价值体现在:一种操作需要对接多种不同的实现,而且这些实现会持续增加。

纯正的OO vs 变样的OO

理解了依赖管理这个视角,再来看实际项目中的代码,就会发现很多写法虽然用了面向对象的语法,但没有在管理依赖上起到任何作用。

做了这么多年Java项目,我见过太多代码看起来是面向对象的,实际上跟面向对象的核心思想没什么关系。下面用三组对比来说明。

贫血模型 vs 充血模型

先看一个最常见的写法。一个Order类,里面有totalAmount、discount这些属性,加上一堆getter和setter,没有任何业务方法。所有的折扣计算、库存校验、价格逻辑都写在OrderService里,OrderService从Order对象里取数据,在外面做判断,再把结果写回去。

这是典型的贫血模型。Order看起来是个对象,实际上只是个数据容器。所有逻辑都在Service里,本质上是在用面向对象的语言写过程式代码。当领域规则复杂时,这种做法的问题会很明显:花了OO的成本,复杂的映射、多层抽象,得到的却是过程式编程的结果。

充血模型的写法不一样。Order自己负责业务逻辑:

Java 复制代码
public class Order {
    private BigDecimal totalAmount;

    public BigDecimal calculateFinalPrice() {
        // 折扣规则、满减逻辑都在对象内部
    }
}

// Service只负责协调和调度
orderService.save(order.calculateFinalPrice());

数据和行为绑在一起了。Order自己知道怎么算价格,Service只负责把结果持久化。这才是面向对象想解决的问题:数据和操作数据的行为放在一起,谁改数据规则就改这个对象,不用满世界找Service。

不过也不是说充血模型就是唯一正确的写法。当业务逻辑涉及到跨多个对象的协调时,把逻辑放在Service层是合理的。

数据和行为的协同定位是一种设计考量,不是铁律。

数据驱动 vs 过度OO

再看一个场景。需要根据操作系统名称返回对应的提示信息。有人写出这样的代码:

Java 复制代码
// 接口 + Factory + Singleton + 5个实现类
BoxSpecifier spec = OSDiscriminator.getBoxSpecifier();
String msg = spec.getStatement();

为了一个配置查询,搞出了Factory、Singleton、接口、一堆实现类。每新增一个操作系统,要加一个类,还要去Factory里注册。

同样的需求,一个Map就解决了:

Java 复制代码
Map<String, String> osMessages = Map.of(
    "Linux", "This is a UNIX box.",
    "Windows", "This is a Windows box."
);
String msg = osMessages.getOrDefault(osName, "Unknown OS");

新增一个操作系统,加一行数据就完事了。代码量少,扩展容易,可读性也好。

有时候数据就是数据,函数就是函数,没必要什么都套进对象里。很多过度OO的设计,一个共同特点就是:本该用数据表达的东西,被拆成了一堆类型。

继承滥用 vs 组合优先

继承被滥用的情况也非常普遍。为了复用代码,有人建了很深的继承链:

Java 复制代码
// 为了复用公共逻辑,所有导出器都继承BaseExporter
class PdfExporter extends BaseExporter { }
class ExcelExporter extends BaseExporter { }
// 改BaseExporter,所有子类全受影响

父类一改,子类全受影响。而且Java只支持单继承,想同时复用两个父类的逻辑就不行了。

用组合替代继承,关系灵活得多:

Java 复制代码
// ExportService持有Formatter,负责格式化
// PdfExporter和ExcelExporter组合不同的Formatter
// 改Formatter不影响导出器,改导出器不影响Formatter

GoF在《设计模式》开篇就提出了这个原则:优先使用组合而不是继承。继承表达的是is-a关系,组合表达的是has-a关系。现实中,has-a的关系比is-a的关系更常见,也更容易组合。

OO真正擅长的场景

说了这么多不适合的场景,那面向对象到底擅长什么?回到依赖管理这个视角来看,就比较容易回答了。

当一种操作需要对接多种不同的实现,而且这些实现会持续增加时,多态的价值就体现出来了。比如支付系统要对接支付宝、微信、银联、Apple Pay,用接口加多态是最合适的做法。新增一种支付方式,只要加一个实现类,调用方的代码不用动。

当数据和行为紧密耦合,而且某个数据的处理规则复杂、会频繁变化时,把数据和行为放在一起更方便维护。比如金融系统里的交易对象,状态流转规则复杂,把这些规则放在对象内部,比分散在好几个Service里更不容易出错。

不适合面向对象的场景也很明确。配置映射关系不适合,一个Map或者配置文件就够了,没必要给每个配置项建一个类。纯算法和数据流处理不适合,排序、查找、数据转换这些操作,用函数比用对象更直接。简单的CRUD业务不适合,一个事务脚本就够了,搞一套领域模型出来纯属折腾。一次性脚本更不适合,写个脚本跑完就完事了,设计接口和抽象类毫无意义。

不是所有概念都自然地适合继承层次结构。很多关系根本不是层级的,硬套进去只会让代码更难理解。

Unix的启示

Unix不是面向对象的系统,但它在很多方面比OO系统更好地体现了面向对象的设计原则。组合优于继承、依赖接口不依赖实现、高内聚低耦合,这些OO的设计准则和Unix的设计哲学(单一、简洁、模块、拼装)完全一致。

Unix的管道就是Decorator模式,ps -elf | grep java,grep修饰了ps的输出,没有侵入ps的逻辑。Shell是Proxy和Facade,代理了系统调用并提供统一的交互入口。/dev目录下把硬件设备适配成文件,让你用read和write就能操作任意设备,这是Adapter和Factory。

思路和GoF那23个经典模式完全一样,但Unix做得更优雅。管道就是一个竖线,没有接口、实现类、工厂类这些层层包装。核心差异在胶合层的厚度。Unix的胶合层极薄,薄得很优雅。而很多OO项目,找一个业务逻辑要穿透七八层接口和抽象类,点进去全是interface和abstract class。软件的复杂度有三个维度:代码量、接口复杂度、实现复杂度。OO主要增加了接口复杂度。这恰好说明,优秀的软件设计不依赖OO语法,依赖的是同样的设计原则。

OO最大的坑

如果OO的目标是管理依赖,那它最大的坑就在于太容易被用过头,反而制造出更多的依赖。

很多团队花了OO的成本,建了完整的类层次结构、接口体系、抽象层,结果所有逻辑都堆在Service里,对象变成了纯粹的数据搬运工。这是最常见的一种情况:用了OO的形式,没用到OO的好处。

还有一种是把简单问题复杂化。一个配置查询,本来一个Map就能搞定,非要搞一套Factory加Strategy加Observer出来。写的人觉得自己很专业,维护的人苦不堪言。

很多麻烦来自于对OO的理解不够深,只学了形式。知道要用接口,就到处定义接口。知道要用设计模式,就硬套设计模式。知道要分层,就分了七八层。至于为什么用接口、为什么用这个模式、为什么分这么多层,说不清楚。

面向对象没有错,但它容易用过头。就像一把锋利的刀,能切菜也能伤手。知道什么时候用、什么时候不用,比知道怎么用更重要。

什么时候该用OO,什么时候不该用

判断维度 适合OO 不适合OO
数据与行为的关系 关系复杂且会持续变化 简单映射,数据就是数据
是否需要多态 一种操作对接多种实现,且实现会增加 只有一种实现,或很少变化
领域模型复杂度 有丰富的业务规则和状态流转 简单的CRUD,事务脚本足够
变更频率 处理规则经常变化,需要局部修改 逻辑稳定,很少变更
依赖管理 需要调用方和实现方解耦 调用关系简单直接
代码生命周期 长期维护的大型系统 一次性脚本、临时工具

这张表可以当作选型时的速查参考。遇到一个新的设计决策时,对照一下这几个维度,就能大致判断该不该用面向对象的方式来做。

小结

面向对象的价值不在于它提供了一种编程方式,而在于它提供了一种管理依赖的思路。面向对象真正管理的,从来不是对象,而是软件中的依赖关系。多态让调用方和实现方解耦,封装让数据和行为的变更局限在一个范围内,组合让模块之间的依赖更灵活。这些手段最终指向同一件事,而管理依赖的目的,是让变化尽可能局限在一个小范围内。软件设计真正要管理的,从来都是变化。

技术选型没有标准答案,只有适不适合。面向对象也好,函数式也好,过程式也好,最终服务的都是解决实际问题。多态、继承、设计模式,这些都是手段,不是目的。手段为目的服务,不是为了手段而手段。

遇到一个新问题,先想想这个问题本身的特点,再决定用什么工具,这个判断顺序比什么都重要。

相关推荐
9i编程2 小时前
5.Nacos 配置中心化改造与短信 Debug 模式动态切换实践
后端·spring cloud
PinkSun2 小时前
上线第一天,Redis和Docker联手给我上了一课
后端
清秋冷雨2 小时前
Harness Engineer系列(三):Harness工程化——AI编程的最后一里地
后端
Csvn2 小时前
itertools —— Python 最强迭代器工具箱实战
后端
冰暮流星2 小时前
flask之定义URL
后端·python·flask
剑挑星河月2 小时前
94.二叉树的中序遍历
java·算法·leetcode
2301_801184752 小时前
docker 工作
java·docker·容器
老前端的功夫3 小时前
【Java从入门到入土】47:构建工具:Maven与Gradle的战争
java·python·maven
aaPIXa6223 小时前
C++模板元编程:在编译期完成计算,实现零运行时开销
java·开发语言·c++