上一篇文章里,我们把“类”和“对象”的地基打完了。但说句实话,真正让人抓耳挠腮的从来不是“面向对象四大特性”这八个字,而是“new一个对象之后到底发生了什么”、“为什么对象没有被回收”、“为什么明明两个对象内容一样却判不相等”这类问题。今天这篇就是把下半场的内容整理出来。
我尽量不用教科书式的废话,直接拿典型场景和踩坑记录来讲。你会发现,“类和对象(下)”这六个字,其实覆盖了从小白到中高级工程师的很多核心分歧点:类加载、对象创建、构造方法、抽象类、深浅拷贝、对象去重、甚至对象存储……每一块都能单独拉出来写一篇文章,但今天我想把它们串在一个“对象生命周期”的叙事里,让你看完之后能真正把知识点串联起来。
这个话题适合谁?如果你已经把类和对象的基本语法过了一遍,但写代码时还是不太清楚“为什么这样设计”,或者项目里一遇到对象复制、判空、类路径报错就心里没底,那这篇恰好是你需要的。也可以说,这篇是“理论到工程”之间的一次补课。
1. 为什么我说“类和对象(下)”才是分水岭
你去看现在市面上的教程,上半场几乎都在讲“类是模板、对象是实例”“类有属性和方法”之类的基础,照着敲一遍,好像都懂了。但类与对象真正的难点和乐趣,恰恰在下半场:对象创建过程中发生了什么、对象和对象之间怎么协作、对象什么时候消失、对象的边界放在哪里。很多人写了好几年代码,问起这些还是一愣一愣的。
1.1 从“会写类”到“懂对象”之间的距离
“会写类”其实是一件很容易的事情。定义一个class,放几个字段,写两个方法,最多再加点构造器重载,这就是很多新手眼中的面向对象了。可一旦进入真实项目,你会发现事情完全没那么简单:你要决定这个类是不可变的还是可变的,要不要在构造阶段就校验参数,对象的集合是该暴露还是该封装,一个对象传到别的方法里去,会不会被悄悄改成别的样子。
我见过一个很典型的案例。有个同事在做订单模块时,直接把Order对象传到工具方法里做一系列处理,工具方法内部改了几十个字段,最后返回订单状态。看起来逻辑很顺,结果线上出现了一个诡异的问题:同一笔订单,在并发请求里被改写成了不同状态。排查下来发现,就是因为那个工具方法接收的是订单对象本身,而不是把需要的参数传进去或者返回一个新对象。这个问题的本质,就是对“对象是可共享的还是独占的”没有判断清楚。
所以在下半场,我特别想强调一个观点:对象不只是一坨字段加方法的集合,它是有生命周期、有状态边界的实体。类负责定义蓝图,而对象在程序运行过程中真的会经历“出生、使用、死亡”的过程。
1.2 一门通、门门通?多语言中的类和对象
很多人在学完Java后,会想当然地认为C++、Python、JavaScript里的对象也一样。确实,底层的抽象思想是相通的,但具体执行细节差别非常大。我整理了一个简单的对照表,方便你在不同语言之间快速切换思路。
| 语言 | 对象创建方式 | 继承机制 | 对象销毁 | 特点 |
|---|---|---|---|---|
| Java | new+ 构造方法 | 单继承 + 接口 | JVM GC 自动回收 | 强类型、纯面向对象 |
| Python | 类名()触发__new__/__init__ | 多继承 | 引用计数 + 循环回收 | 动态属性,对对象非常宽容 |
| C++ | 栈上自动构造,或new堆上构造 | 多继承 | 栈对象自动析构,new对象需delete | 半面向对象,struct与class差别极小 |
| JavaScript | new 构造函数()或class语法糖 | 基于原型链 | JS 引擎 GC 自动回收 | 类是构造函数的语法糖 |
为什么要比较这些?因为你在搜“类和对象”相关内容时,会看到 Python 类对象、C++ 类前置声明、JavaScript 合并两个对象、Java 对象组成这些关键词,它们表面上是同一个知识点,实际上踩的坑完全不同。
比如 JavaScript 里的对象,本质上是一个动态可增删属性的 Map 集合,所以你可以随手给对象加一个字段,而不需要在类里提前声明。这在 Java 里是不可想象的,Java 的类一旦编译,对象的字段布局基本就固定了(不考虑反射和Unsafe的情况)。理解了语言底层对对象的处理方式,你在切换技术栈时就不会很痛苦。
2. 对象的完整生命周期,值得你反复咀嚼
如果说上半场是“认识对象”,那下半场第一件事就是“观察对象的一生”。从创建、初始化、使用到释放,每一个环节都是潜在的坑。
2.1 对象的创建:new到底做了什么
以 Java 为例,我们写一行Dog dog = new Dog("旺财"),看着简单,背后其实是一套固定的流程:
- 检查
Dog类是否已经加载,如果没有加载则触发类加载机制; - 为对象在堆内存中分配空间,所有字段先赋默认值(引用类型为 null、数字为 0、布尔为 false);
- 执行父类构造器、实例初始化块、实例变量初始化;
- 执行当前类构造器中的代码;
- 把栈上的引用变量指向这个新对象。
这里的第 1 点非常关键,很多人听说“类加载”就觉得是 JVM 底层的东西,离自己很远。实际上,你遇到的 “ClassNotFoundException” 或 “找不到或无法加载主类”,本质就是第 1 步出了问题。我后面会单独开一节来聊排查方法。
Python 里则多了一个有趣的机制:__new__负责创建对象,__init__负责初始化对象。__new__的第一个参数是类本身,它返回的是一个实例对象,而__init__接收的就是那个实例。你如果只重写__init__而不重写__new__,就只是在“初始化已有对象”,并没有控制“创建对象”的过程。单例模式里通常会重写__new__,就是因为要在源头挡住多个实例的产生。
C++ 的对象创建跟 Java 有本质区别:栈上的对象会在离开作用域时自动调用析构函数,堆上的对象则必须手动delete,否则就会内存泄漏。这个“手动释放”的意识,是 C++ 新手最容易忽略的。
2.2 构造方法:对象诞生的第一道关卡
构造方法的命名、重载、修饰符,看似简单,却经常能问倒人。有个热搜词问“Java 构造方法修饰符跟类一样吗”,答案是否定的,如果你没有显式写构造方法,编译器提供的默认无参构造方法在访问权限上会和类保持一致;但如果你自己写了构造方法,你完全可以把类设为 public,却把构造方法设为 private,比如单例模式就是这么做的。
构造方法还有一个必修课叫“构造器调用链”。Java 中所有类都继承自 Object,所以new Dog()时,一定会先从 Object 的构造器开始,依次向下执行。只要父类构造器抛了异常,子类的对象就不可能创建成功。这一点在做一些资源初始化类时尤其重要:如果父类打开了一个文件流、数据库连接,子类构造到一半失败了,父类那部分资源有没有正常释放?很多人根本不会往这个方向考虑,直到线上连接数暴涨才发现问题。
在写业务代码时,我有一个建议,尽量用静态工厂方法代替直接new。比如把一个构造函数设为 private,然后提供一个of()或create()方法,就可以在创建对象时做参数校验、缓存实例、返回子类型等操作。这不算什么高深技巧,但真的能避免很多“new完对象才发现参数不合法”的尴尬。
2.3 对象的内存布局与引用传递
你可能听过“Java 对象组成”这个词,但不太清楚实际指什么。一个 Java 对象在堆中的布局大致分为三块:
- 对象头:存储 Mark Word、类指针、数组长度(如果是数组);
- 实例数据:对象真正的字段值;
- 对齐填充:为了满足 8 字节对齐而补的空位。
对象头里的 Mark Word 在没有锁竞争的时候,甚至可以记录偏向锁、GC 年龄等信息。理解这些,对你看懂 JVM 调优和并发编程非常有帮助,但我不打算在这里往太底层钻。我更想强调的是引用传递这件事。
Java 里有基本类型和引用类型,方法参数传递时,基本类型是值传递,引用类型传的是“引用地址的值”。所以在方法内部修改传入对象的字段,外部会看到变化。这种“引用共享”是双刃剑,用得好能节省资源,用不好就是各种灵异 Bug。
我看到一个真实的例子:A 服务把用户的地址实体类取出来,在某个工具方法里顺手把省市区字段拼接成字符串赋给了另一个字段,结果整个方法栈里的所有地方都看到了这个被修改过的地址对象,导致另一个模块读出了“拼接后的大字符串”,数据库里却还没保存这个字段。调试了很久才发现是共享引用被污染了。遇到这种问题,最稳的办法就是:在需要修改对象时,先明确这个对象是否可以被外部共享;如果不能确认,就复制一份再改。
3. 类与对象的高频场景,真刀真枪写代码
上半场学语法,下半场就要去解决实际开发中的高频需求。这里我挑几个最常见、也最容易被热词搜索出来的场景,逐一拆开讲。
3.1 抽象类和普通类的区别,以及什么时候用抽象类
抽象类是不能实例化的类,它可以拥有抽象方法,也可以拥有普通方法。普通类则可以被直接实例化。这是最基础的区别,但实际设计时“到底用抽象类还是接口”才是最让人纠结的地方。
我的经验是,抽象类适合表达“is-a”的关系,它更强调代码复用和模板设计。比如一个支付场景,所有支付渠道都需要经过“验签 -> 下单 -> 回调 -> 对账”这个流程,但每个渠道的验签和下单逻辑不同,这时候就可以用一个AbstractPayService,把公共流程写成模板方法,把差异点留给子类实现。子类之间共享了大量结构和逻辑,这就是抽象类的价值。
接口则更适合表达“can-do”的能力契约,比如Serializable、Comparable、Runnable,它不关注你是谁,只关注你能不能做某件事。所以如果你的类本身职责不同,但都具备同一种可被调用的能力,就优先考虑接口。Java 8 之后接口也能写默认方法,但要注意别把接口当成“鸡肋的多继承工具”,那会让代码很难维护。
3.2 对象数组去重:一个高频但容易翻车的场景
“对象数组去重”是一个搜索热度很高的关键词,也确实是开发中容易被写错的点。原因很简单:基本类型的数组去重,直接比较值就行,但对象数组去重时,默认情况下比较的是对象的引用,也就是“是不是同一个对象”,而不是“内容是否相同”。
拿 Java 举例,如果你直接写new HashSet<>(list),会期望它把内容相同的对象去掉,但结果是几乎做不到——因为 Object 的equals()比较的就是引用地址,hashCode()也没按业务字段计算。所以必须重写equals()和hashCode(),或者用流式去重。
下面这段代码是按用户 ID 去重的常见姿势:
List<User> distinctUsers = list.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(User::getId))), ArrayList::new ));如果使用的是 Lombok 的@Data,它会自动生成equals()和hashCode(),基于所有非静态字段,这样用HashSet去重就行。但要注意,如果对象里有集合、数组这类字段,生成的 equals 会变得“太重”,可能导致性能问题。更推荐的做法是根据实际业务主键去重,简单又稳定。
在 JavaScript 里,对象数组去重也很常见。由于对象是引用类型,用new Set(arr)同样无法去重,常见做法是用Array.filter结合findIndex,或者用 Map 的 key 来做标记:
const arr = [{ id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 1, name: 'c' }]; const map = new Map(); const unique = arr.filter(item => !map.has(item.id) && map.set(item.id, item)); // 或 const uniqueByMap = [...new Map(arr.map(item => [item.id, item])).values()];这两种方式都能解决“按某个字段去重”的需求。真正需要注意的是性能:数组特别长时,filter + findIndex是 O(n^2) 的复杂度,最好用 Map 方式降维到 O(n)。
3.3 判断对象为空:不只是null
“判断对象为空”这个动作听着简单,写起来也简单,但工程里真正的难点在于“什么样的对象算空”。有一种空是引用为 null,还有一种空是业务意义上的空,比如集合长度为 0、字符串是空白字符、对象的所有字段都是默认值。
我建议把判断逻辑收敛成一个工具方法,或者使用 Java 8 的Optional来辅助。比如:
Optional.ofNullable(user) .map(User::getAddress) .map(Address::getCity) .orElse("未知");Optional的优点是可以串行处理多个可能为空的对象,让代码不再是一堆if (xxx != null)嵌套。但也要注意,Optional不适合作为字段类型,也不适合放在集合里,它更应该是方法返回值的语义化包装。
不过在 Python 里判空就要小心了:None、空字符串、空列表、空字典都不是一回事,不能全用if not obj去判断。比如数字0用if not obj判断也是 True,这很容易掩盖真正的业务状态。最好的做法是根据业务语义显式判断,甚至在函数入口就raise异常,避免后续调用链越走越偏。
3.4 对象转 QueryWrapper、单对象转 List 这类实用技巧
这个关键词一看就是 MyBatis Plus 的场景。开发中经常遇到“前端传了一个对象,后端要根据对象里的非空字段做查询”,这时最方便的做法是让对象转成QueryWrapper:
// 方式一:手动构造 LambdaQueryWrapper LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.isNotBlank(user.getName()), User::getName, user.getName()) .eq(user.getAge() != null, User::getAge, user.getAge()); // 方式二:使用实体对象作为查询条件 LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery(user);方式二确实方便,但要小心,它默认会把对象里所有非 null 字段作为相等条件。如果你希望某些字段只做模糊查询或范围查询,还是要手动构造。这个点我在实际项目中吃过亏:前端传了一个带默认状态的status=0的对象,结果查询把所有 status 为 0 的数据都查出来了,业务上却只想查某一种状态。原因就是没有理解“实体对象自动转查询条件”的边界。
“单个对象转 List”在 Java 里也很常用,尤其是需要批量接口时。可以用Collections.singletonList(obj)或 Java 9 的List.of(obj)快速包一层。但要注意,List.of返回的是不可变列表,不能后续add、set。如果用Collections.singletonList,也是不可变的。如果你需要一个可以修改的单元素列表,就老老实实new ArrayList<>()再加进去。
3.5 用 IDEA、Eclipse、StarUML 查看类图
类图是理解和设计类和对象关系的最好工具之一。你可能在课本上见过类图,但在实际 IDE 里查看类图反而很多新手不会用。
IDEA 的操作非常简单:选中一个类或包,右键 -> Diagrams -> Show Diagram Popup,快捷键是Ctrl+Alt+U。这时候会出现一张类图,把继承关系、实现接口、字段和方法都画出来。你还可以在图上右键继续展开子类、父类、依赖类,快速看清整个模块的结构。我第一次在 IDEA 里生成类图时,突然就理解了为什么某个基类设计了那么多 protected 方法,因为从图上可以一眼看到哪些子类在用。
Eclipse 查看类图不像 IDEA 那么原生,早期需要安装 ObjectAid UML Explorer 之类的插件,安装后选中类右键 -> Show in ObjectAid UML Explorer 就能生成类图。StarUML 则是一个独立的建模工具,适合在需求阶段画类图,然后再让代码去对齐设计。如果代码已经写完,再在 StarUML 里对着类图“逆向重构”,那大概率画出来的图跟代码脱节,不如直接在 IDE 里看。
4. 常见问题与排查技巧实录:那些让你想砸键盘的报错
写类和对象相关代码时,遇到的报错往往就集中在“类加载”、“类型不匹配”、“对象复制不彻底”、“可见性”这四类。我把高频问题整理成了一张表,再逐个展开讲。
| 报错/问题 | 常见原因 | 解决方案 |
|---|---|---|
| 找不到或无法加载主类 | class 文件不存在、classpath 不对、包名拼错 | clean + recompile,检查路径 |
| ClassNotFoundException | 类加载时在 classpath 里找不到类 | 检查依赖和部署目录 |
| 表达式必须包含类类型 | 用“类名.XX”或者“对象.XX”混用 | 区分静态成员与实例成员 |
| 不同对象内容一样却不相等的错觉 | 没重写 equals/hashCode | 按业务主键重写比较逻辑 |
| 对象被莫名修改 | 引用共享,方法内修改了传入对象 | 拷贝或明确定义对象所有权 |
| 深拷贝后依然互相影响 | 只做了浅拷贝,内部还有引用字段 | 使用深度拷贝或序列化 |
4.1 找不到或无法加载主类:类加载环节的坑
这个报错几乎每一个 Java 开发者都遇到过,而且搜索“找不到或无法加载主类”后面还会跟一堆后缀,比如org.apache.dolphinscheduler.standaloneserver、org.apache.catalina.startup.Bootstrap。看起来像框架问题,其实本质都是一样的:JVM 在启动时,没有在 classpath 下找到你指定的主类。
排查步骤我一般是这样走的:
- 先看报错的主类名跟你配置的启动类是否完全一致,包名、类名不能差一个字母;
- 执行一次 clean + rebuild,把 target 目录下的旧 class 清掉,防止编译缓存干扰;
- 如果是命令行启动,确认当前的 classpath 是否包含了编译输出目录和所有依赖 jar。常见错误是只加了源码目录,没加编译输出目录;
- 如果是 IDEA 或 Eclipse 启动,检查 Run Configuration 里的 module、classpath、Working directory 是否正确;
- 有时候是配置文件里把主类写错了,比如名字带中文或空格,这种错误很玄幻,但确实存在。
我在一个 Maven 多模块项目里就栽过一次:A 模块依赖 B 模块,B 模块的 class 文件没有重新 install 到本地仓库,启动时一直报 ClassNotFoundException。后来发现不是代码问题,是本地仓库里的 B 模块还是旧版本。这种时候优先想到“依赖版本和本地仓库不一致”,会比反复检查代码高效得多。
4.2 表达式必须包含类类型:静态上下文误用
“表达式必须包含类类型”这个报错,通常出现在 C# 或 C++ 里,Java 里可能报“无法从静态上下文引用非静态变量/方法”。本质是在写代码时混淆了“类”和“对象”两个层面的访问。
比如你写了一个实例方法String getName(),然后试图用User.getName()去调用,编译器就会告诉你这个方法不能直接通过类名访问;反过来说,如果你写了一个静态方法static void print(),却用user.print()来调用,虽然很多语言允许这么写,但读代码的人可能就会困惑“这个方法到底跟对象状态有没有关系”。
我建议用一个简单的规则来判断:如果方法没有访问任何实例字段,也不依赖对象的状态,就定义成静态方法;否则就是实例方法。不要在静态方法里偷偷访问实例字段,也不要为了让调用方便,把明明应该是实例的方法改成静态的。这样类与对象的边界就清晰了。
4.3 深拷贝还是浅拷贝:对象复制出错
对象的复制问题,是“类和对象(下)”里最容易让人崩溃的章节,没有之一。很多人以为Object.clone()就是深度拷贝,其实 Java 默认的clone()是浅拷贝:如果你对象里有一个Address address字段,浅拷贝出来的新对象,它的address还是指向原来那个 Address 对象。
解决深拷贝的办法不少:
- 重写
clone(),对每个引用字段手动拷贝,但嵌套多了会写死人; - 使用序列化方式:把对象序列化成字节流再反序列化,Java 里用
ObjectOutputStream,或直接借助 JSON 库(Jackson、Gson)转字符串再转回对象; - 很多框架提供了 BeanUtils、MapStruct 之类的映射工具,但要注意它们大多数也是浅拷贝。
Python 里更简单:copy.copy()是浅拷贝,copy.deepcopy()是深拷贝。但deepcopy也不是万能的,如果对象里有文件句柄、数据库连接、线程这类无法复制的资源,deepcopy 会报错或者产生莫名其妙的连接失效。所以在做深拷贝之前,先想想“这个对象里的引用型字段,哪些是应该共享的资源,哪些是需要隔离的数据”。
JavaScript 则经历了Object.assign、展开运算符到structuredClone的演进。Object.assign和{...obj}都只是浅拷贝,嵌套对象还是共享引用。structuredClone是浏览器和 Node 17+ 原生支持的深拷贝,能处理大多数类型,但函数、类实例、DOM 节点等支持有限。所以还是那句话:拷贝之前先明确边界。
4.4 ES6+ 提取数组对象一部分、合并两个对象
这个关键词出现在热搜里说明大家日常写对象处理代码时经常需要“取一部分字段”或“合并两个对象”。ES6 的解构赋值和展开运算符是绕不开的。
提取数组对象一部分字段,可以用数组的map配合对象解构:
const users = [ { id: 1, name: '张三', password: '123' }, { id: 2, name: '李四', password: '456' } ]; const safeUsers = users.map(({ password, ...rest }) => rest); // safeUsers: [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]这一招在清理不需要下发给前端的字段时很常用,比手动delete obj.password干净得多。
合并两个对象时,展开运算符的覆盖规则要记清楚:后面的覆盖前面的。对于一层对象来说,写起来很舒服:
const base = { a: 1, b: 2 }; const patch = { b: 3, c: 4 }; const merged = { ...base, ...patch }; // { a: 1, b: 3, c: 4 }但如果是嵌套对象,这就是浅合并,base里的子对象会被整体替换,而不是逐字段合并。要实现深度合并,要么自己写递归,要么用 lodash 的merge。社区里很多人提倡“嵌套对象不要混日子,直接扁平化或使用不可变数据结构”,敏捷开发时这个建议相当实用——浅拷贝通常更好预测。
4.5 使用 Chrome DevTools 排查类和对象问题
如果你是前端,平时调试对象时,重点要会用 Chrome DevTools 的 Console 和 Sources 面板。Console 里直接输出对象时,默认显示的可能是引用,也就是你展开时看到的值可能已经是运行到那个时刻的最新值,而不是打印语句时的值。
想看对象当时的状态,有两个办法:
console.log(JSON.parse(JSON.stringify(obj)))把对象做一次快照(深拷贝),适合简单的纯数据对象;- 在对象上右键选择 “Store as global variable”,它会生成
temp1这样的变量,你就能在控制台里去访问它的瞬时状态。
另外,console.dir()可以展示对象的所有属性和方法,包括原型链和不可枚举属性;document.querySelector返回的 DOM 对象可以用$0引用,方便你直接查看当前选中的元素对象。对于追踪对象属性变化,Chrome DevTools 的 Sources 面板里可以用Object.defineProperty或者直接打 Deployment 来做观察,但如果你只是想快速确认一个对象的结构,console.dir已经够用了。
5. 工程中的“类与对象”再进阶:类加载、对象存储与设计思考
最后这一部分,我不打算再讲基础语法,而是想跳到工程视角,看看“类与对象”在真实系统里还能延展到哪里。
5.1 类加载机制:什么时候类会被载入?
类加载是一个很神奇的机制。很多人以为程序一启动,所有类都会被加载进内存,其实不是。JVM 在用到某个类时才会触发加载,而且一个类通常只会被特定的类加载器加载一次。
加载时机一般包括:
- 使用
new创建对象; - 访问类的静态变量或静态方法;
- 使用反射访问类;
- 初始化一个类的子类,会先触发父类加载初始化。
这就能解释很多性能问题:为什么启动很快,但第一次调用某个接口时特别慢?因为那个接口相关的类可能到首次调用时才被加载和初始化。所以 JVM 参数里的-XX:+TraceClassLoading可以帮你跟踪到底哪些类被加载了,这也是排查莫名其妙 ClassNotFound 的有力工具。
Python 里没有“类加载器”这一说,但模块的import机制也会决定类定义何时生效。C++ 则通过头文件和前置声明来控制编译依赖。这个对比很有意思,但核心都指向同一个思想:类定义什么时候可见、什么时候生效,是系统工程必须关心的事。
5.2 对象存储服务:分布式环境下“对象”的另一种含义
聊到“对象存储服务”,很多新人会把它跟面向对象里的“对象”搞混。分布式存储里的“对象”通常是指数据文件、元数据和唯一标识的组合,比如一张图片、一个视频、一个日志文件,都被抽象成对象存储到对象存储系统里,例如 MinIO、阿里云 OSS、AWS S3。它跟类与对象没有直接关系,但名字里都有“对象”,容易让人混淆。
在企业开发里,MinIO 是一个很常见的开源对象存储方案,Java 接入时一般依赖minioSDK,配置 endpoint、accessKey、secretKey,然后通过minioClient.putObject()上传。这套东西对面等面向对象编程的“类与对象”学习没有直接帮助,但我还是要提醒大家:搜索引擎里搜“对象”时,会出现“对象存储服务”“对象数组去重”“对象转QueryWrapper”这些完全不同的语义,要自己提前分清上下文,否则容易越看越晕。
这其实也是现代工程的一种常态:同一个词在不同领域有不同含义。“类”在机器学习和 NLP 里是“分类”,在 D 类功放里是“功率放大类型”,在机械制图里是“圆盘类锻件”的三视图分类。所以,搞清楚“这个对象到底指什么”,是解决一切问题的大前提。
5.3 面向对象设计原则与演进
学了类与对象的语法、生命周期、常见用法之后,我还想分享一点设计层面的思考。
面向对象设计的最大价值不是“把代码放进类里”,而是“管理变化”。你要让新需求来的时候,尽量少改动旧代码。常见的做法包括:
- 面向接口而不是面向实现编程:方法参数、字段类型尽量用接口或抽象类,这样将来替换实现类时不至于推翻重来;
- 多用组合,少用继承:继承会增加层级耦合,一旦父类方法变动,所有子类都受影响。组合则可以把某一类能力放到一个专门的对象里,按需持有;
- 封装变化:把容易变化的部分隔离出来,用多态去应对变化,而不是写一堆 if-else。
这些原则听起来很空,但你在处理真正的业务时会有体感。我见过一个项目,所有模型对象都是从同一个 BaseEntity 继承来的,BaseEntity 里放了创建时间、更新时间、创建人等字段。一开始很舒服,后来发现有的领域模型根本没有“修改人”这个字段,有的又需要“审核人”,于是大家只能在子类里硬塞字段或者忽略父类字段,设计越来越别扭。这就是“继承树设计得太急”的典型案例。
更重要的是,不要为了“显得抽象”而过度设计。如果一个类只有两个字段和一个 getter,你硬给它套一个抽象类和三个实现,那只是给自己制造麻烦。面向对象设计要讲究边界感,先让代码直白、单一职责,再考虑抽象和扩展。类和对象这两个概念在真实项目中能玩出很多花样,但归根结底,是要为可读性和可维护性服务的。
最后再分享一个我自己的习惯
每次接到一个不熟悉的模块,我都会先在 IDE 里调出类图,把核心类的继承和依赖看完,再顺着消息调用链走一遍对象的生命周期。这种做法看起来很学术,但真的能帮你快速进入状态:你不仅知道“有哪些类”,还知道“这些对象如何被创建、如何被替换、何时被回收”。踩过几次坑之后,你会越来越重视对象的边界:谁创建它,谁修改它,谁销毁它,说到底,这就是软件工程里的“所有权”意识。
如果你现在正在学类和对象,或者刚接触某个新语言里的对象模型,我希望你这篇不只是当“复习提纲”看,而是真的打开项目代码,把里面一个核心业务对象从创建到销毁的路径走一遍,再顺手在 IDE 里看看类图。完成这一步,你对“类和对象(下)”的理解,就会比大多数停留在语法层面的人,深出整整一个层次。