ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

面向对象关联关系详解:单向、双向、聚合与组合的代码实现

面向对象关联关系详解:单向、双向、聚合与组合的代码实现 1. 关联关系到底在解决什么问题类与类之间如果只有孤立的定义那面向对象就退化成了“带方法的函数集合”。真正让代码具备表达力的是类与类之间的关系。而在所有关系里关联关系是最基础、最常见、也最容易被初学者忽略其细节的一种。我在带新人的时候经常遇到一个场景需求里写“订单属于某个用户”“学生选修了多门课程”“一个部门有多个员工”很多人第一反应是直接在一个类里塞一个字段把另一个类当作类型。这个做法本身没错但它背后其实对应着不同的关联语义——是单向还是双向是一对一还是一对多是普通关联还是聚合、组合如果这些没想清楚代码写出来能跑但后期改需求时就会到处漏改。关联关系描述的是两个类之间“一个对象持有另一个对象的引用”这种结构性连接。它和继承is-a不同关联表达的是 has-a 或者 uses-a 的语义。比如“汽车有一个发动机”这是关联“汽车是一种交通工具”这是继承。把这两者混为一谈是设计层面最常见的错误来源之一。这篇文章我会从关联关系的本质讲起把单向关联、双向关联、自关联、多重性、聚合与组合的边界全部拆开再落到代码实现、UML 类图画法、常见踩坑点上。适合正在学面向对象、准备画类图、或者写业务代码时总觉得“关系理不清”的读者。看完你至少能做到拿到一个需求能判断出该用哪种关联以及为什么。2. 关联关系的核心概念与分类拆解2.1 关联、依赖、继承的边界在哪里先把三个最容易混淆的概念摆在一起对比这样后面讲关联时才不会串味。关系类型语义典型表现生命周期耦合依赖临时使用方法参数、局部变量无关联长期持有成员变量引用弱到中继承是一种extends / implements强依赖是“我用一下就走”比如一个方法接收某个对象作为参数方法执行完关系就结束了。关联是“我一直记着它”体现为成员变量。继承是“我本身就是它的一种”。判断标准很简单如果 A 对象在创建后需要长期记住 B 对象那就是关联如果只是某个方法执行期间临时用到那就是依赖。这个判断在实际设计时非常有用因为很多人会把依赖写成关联导致对象持有了一堆本不该长期持有的引用增加了内存占用和耦合度。2.2 单向关联与双向关联的取舍单向关联指的是只有一方持有另一方的引用。比如订单持有用户引用但用户不持有订单引用。双向关联则是双方互相持有。我个人的经验是默认用单向除非业务确实需要反向导航。原因有三点。第一双向关联维护成本高任何一方修改都要同步另一方容易出现数据不一致。第二双向关联在序列化时容易造成循环引用JSON 序列化直接栈溢出。第三双向关联让类之间的耦合度翻倍单元测试更难隔离。那什么时候必须用双向典型场景是“父子结构需要互相访问”比如树节点需要访问父节点做回溯或者评论和回复需要互相定位。这时候可以用双向但一定要在代码里明确维护规则比如只在 setter 里同步避免手动赋值造成断裂。2.3 多重性一对一、一对多、多对多的表达多重性Multiplicity是关联关系里最容易被低估的部分。它描述的是一个对象能关联多少个另一端的对象。一对一一个用户对应一个身份证一对多一个部门对应多个员工多对多一个学生对应多门课程一门课程对应多个学生在代码里一对多通常用集合表达比如ListEmployee。多对多则需要中间结构或者双方都持有集合。这里有个实操细节多对多关系在数据库层面必须有一张中间表在代码层面如果双方都直接持有对方集合删除时要特别小心否则会出现“删了一边另一边还留着”的脏数据。2.4 自关联自己关联自己的特殊形态自关联指的是同一个类内部的对象之间建立关联。最典型的是树形结构一个节点持有子节点集合同时可能持有父节点引用。还有社交场景里的“用户关注用户”也是自关联。自关联的难点在于递归处理。比如计算树的深度、遍历所有子孙节点如果不用递归或者栈很容易写成死循环。我在实际项目里处理组织架构树时就遇到过因为数据里存在环形引用导致遍历卡死的情况。后来加了一个 visited 集合做去重才解决。3. 聚合与组合关联关系的两种强化形态3.1 聚合弱拥有的典型场景聚合Aggregation是一种特殊的关联表达“整体-部分”关系但部分可以独立于整体存在。比如“班级和学生”班级解散了学生依然存在。UML 里用空心菱形表示菱形指向整体。代码层面聚合通常表现为整体持有部分的引用但部分对象的创建和销毁不由整体控制。也就是说部分对象可以在外部创建后传进来。public class Classroom { private ListStudent students; public void addStudent(Student student) { students.add(student); } }这里的Student是在外部 new 出来的Classroom只是持有引用。这就是聚合的典型写法。3.2 组合强拥有与生命周期绑定组合Composition是更强的关联部分的生命周期完全由整体控制。整体销毁部分也跟着销毁。比如“订单和订单项”订单删了订单项没有独立存在的意义。UML 里用实心菱形表示。public class Order { private ListOrderItem items new ArrayList(); public Order() { items.add(new OrderItem()); } }注意这里OrderItem是在Order内部创建的外部拿不到独立引用。这就是组合的关键特征部分对象的创建权归整体所有。3.3 聚合与组合的判断标准很多人分不清聚合和组合我给一个实操判断法问自己“部分对象能不能脱离整体单独存在并被其他整体复用”。能就是聚合不能就是组合。再补一个细节组合关系在数据库里通常表现为级联删除聚合关系则不会。这个对应关系在设计表结构时非常有用。维度聚合组合菱形空心实心生命周期独立绑定创建权外部整体内部数据库无级联级联删除典型例子班级-学生订单-订单项4. 关联关系在代码中的落地实现4.1 Java 中的关联实现与常见写法Java 里关联关系就是成员变量。但写法上有几个细节值得注意。第一能用接口类型就别用具体类型。比如private ListStudent students比private ArrayListStudent students更好因为后期换实现类不用改声明。第二集合字段一定要初始化。我见过太多空指针异常是因为List字段声明了但没 new调用add直接崩。可以在声明时初始化也可以在构造函数里初始化。第三双向关联的维护要封装。不要暴露 setter 让外部随便改而是在方法里同时维护两边。public class Department { private ListEmployee employees new ArrayList(); public void addEmployee(Employee emp) { employees.add(emp); emp.setDepartment(this); } }这样调用方只需要调一次addEmployee两边关系自动同步。4.2 Python 中的关联表达Python 没有编译期的类型约束关联关系靠约定和文档。但正因为灵活更容易写出问题。class Department: def __init__(self, name): self.name name self.employees [] def add_employee(self, emp): self.employees.append(emp) emp.department selfPython 里要特别注意循环引用导致的垃圾回收问题。虽然 Python 有 GC 能处理循环引用但__del__方法存在时可能导致对象无法回收。所以双向关联在 Python 里要更谨慎。4.3 C 中的关联与指针选择C 里关联关系涉及指针和引用的选择。我的经验是如果关联对象可能为空用指针如果一定不为空且生命周期由外部保证用引用。但引用成员必须在初始化列表里赋值灵活性差所以实际项目里指针用得更多。class Department { private: std::vectorEmployee* employees; public: void addEmployee(Employee* emp) { employees.push_back(emp); } };C 里还要考虑所有权问题。如果 Department 负责释放 Employee那就要在析构函数里 delete这就是组合语义。如果不负责就是聚合。智能指针出现后unique_ptr表达组合shared_ptr或裸指针表达聚合语义更清晰。4.4 UML 类图中关联关系的画法用 StarUML 或者 IDEA 的类图插件画关联时几个要点普通关联用实线单向关联加箭头双向关联不加箭头或两端都加聚合用空心菱形指向整体组合用实心菱形指向整体多重性标在两端比如1、0..*、1..*我见过很多人把聚合和组合的菱形方向画反。记住一句话菱形永远指向“整体”那一端。订单和订单项菱形在订单这边。5. 实操中的典型问题与排查技巧5.1 循环引用导致序列化失败这是双向关联最常见的坑。A 持有 BB 持有 AJSON 序列化时无限递归。解决方案有三种一是用JsonIgnore忽略一端二是用 DTO 转换不直接序列化实体三是用JsonManagedReference和JsonBackReference配对。我一般推荐 DTO 方案因为实体直接暴露给前端本身就不是好习惯。5.2 集合关联的懒加载与性能问题在 ORM 框架里一对多关联默认可能是懒加载。如果循环里访问未加载的集合会触发 N1 查询。排查方法是打开 SQL 日志看是不是每个对象都单独查了一次关联数据。解决方案是批量抓取或者 join fetch。5.3 关联对象为空导致的空指针关联关系里被关联对象可能为 null。比如订单的 user 字段如果业务允许匿名下单那就是可空的。这时候所有访问点都要判空。我的做法是能不给 null 就不给用空对象模式替代。比如返回一个空的 User 对象而不是 null调用方就不用到处判空。5.4 常见问题速查表问题现象可能原因排查方向序列化栈溢出双向关联循环引用检查两端是否互相持有N1 查询懒加载集合被循环访问打开 SQL 日志统计查询次数空指针关联对象未初始化检查字段是否 new数据不一致双向关联只更新了一边检查是否封装了同步方法内存泄漏组合关系未释放部分对象检查析构或 close 逻辑6. 关联关系设计的经验总结关联关系看起来简单但它是面向对象设计的地基。我自己的体会是先把关系理清楚再写代码比写完再改关系要省十倍时间。每次拿到需求先画类图标清楚哪些是关联、哪些是聚合、哪些是组合多重性是多少单向还是双向。这张图定下来代码结构基本就定了。还有一个实用建议关联关系不要设计得太深。如果 A 关联 BB 关联 CC 关联 D那访问 D 的数据要穿过三层这种链式导航会让代码非常脆弱。能扁平化就扁平化必要时引入中间层做聚合。最后分享一个小技巧在 IDEA 里可以用CtrlAltU快速生成类图改完代码后对比类图变化能直观看到关联关系有没有被意外改动。这个习惯帮我避免了好几次“改了一个字段结果影响了一片”的事故。
返回列表