在面向对象模型中添加双向关系的最佳实践

13

我在尝试为面向对象模型添加双向关系时遇到了困难。假设有一个客户可以下多个订单,也就是说客户和订单类之间存在一对多的关联需要能够双向遍历:对于一个客户,应该能够列出他们下的所有订单;对于一个订单,应该能够知道属于哪个客户。

这里有一段Java代码片段,虽然问题不限于任何特定编程语言:

class Customer {
 private Set orders = new HashSet<Order> ();

        public void placeOrder (Order o) {
     orders.add(o);
            o.setCustomer(this);
 }
}

class Order {
 private Customer customer;
        public void setCustomer (Customer c) {
  customer = c;
 }
}

让我感到困扰的是,有了这个模型,某些人很容易调用:

o.setCustomer(c);

应该使用,而不是“正确的”

c.placeOrder(o);

需要形成单向链接而不是双向链接。

仍在学习面向对象编程,是否有人能提供一种惯用且实用的解决方法,而不需求助于“反射”或花哨的框架(这些框架仍会依赖于反射)。

P.S. 有一个类似的问题:Managing bidirectional associations in my java model,然而我觉得它并没有回答我的请求。

P.S.S. 如有任何实现基于db4o的业务模型的真实项目源代码链接,将不胜感激!

5个回答

7
这是一个非常有趣的问题,它对面向对象编程的理论和实践有着深远的影响。首先我会告诉你如何快速而简单地(几乎)实现您所请求的内容。一般来说,我不建议使用这种解决方案,但由于没有人提到它,并且(如果我的记忆没有出错)马丁·福勒(Martin Fowler)的书籍《UML精简》中确实提到了它,因此值得讨论;您可以更改setCustomer方法的定义:
public void setCustomer (Customer c) {
    customer = c;
}

到:

void setCustomer (Customer c) {
    customer = c;
}

确保CustomerOrder在同一个包中。如果不指定访问修饰符,setCustomer默认为package可见性,这意味着只能从同一包内的类中访问它。显然,这并不能保护您免受来自同一包内除Customer外其他类的非法访问。此外,如果您决定将CustomerOrder移动到两个不同的包中,则代码将无法正常运行。

在Java中,通常会容忍包可见性;我觉得在C++社区中,尽管其目的类似,但friend修饰符并不像Java中的包可见性那样被容忍。我无法理解其中的原因,因为friend更加有选择性:基本上,对于每个类,您都可以指定其他友元类和函数,它们将能够访问第一个类的私有成员。

然而,毫无疑问,Java的包可见性和C++的友元都不是面向对象编程(OOP)的良好代表,甚至不是面向对象编程(OOP)的良好代表(OOP基本上是OBP加上继承和多态性; 从现在开始我将使用术语OOP)。 OOP的核心方面是存在被称为“对象”的实体,并且它们通过向彼此发送消息来进行通信。 对象具有内部状态,但是该状态只能由对象本身更改。 状态通常是结构化的,即基本上是一组字段,例如名称,年龄和订单。 在大多数语言中,消息是同步的,它们不能像邮件或UDP数据包一样被意外丢弃。 当您编写c.placeOrder(o)时,这意味着“sender”(即this)正在向c发送消息。 此消息的内容是placeOrder和o。
当一个对象接收到一条消息时,它必须处理它。Java、C++、C#以及许多其他语言都假设一个对象只有在其类定义了一个适当名称和形式参数列表的方法时才能处理消息。一个类的方法集称为其接口,而像Java和C#这样的语言也有一个适当的构造,即接口来模拟一组方法的概念。对于消息c.placeOrder(o)的处理程序是该方法:
public void placeOrder(Order o) {
    orders.add(o);
    o.setCustomer(this);
}

方法的主体是您编写指令以更改对象c的状态(如果必要)的地方。在此示例中,修改了orders字段。
这本质上就是面向对象编程的含义。面向对象编程是在模拟环境中开发的,在该环境中,您基本上有很多相互通信的黑盒子,每个盒子都负责自己的内部状态。
大多数现代语言完全遵循此方案,但仅限于私有字段和公共/受保护方法。不过还有一些需要注意的地方。例如,在Customer类的方法中,您可以访问另一个Customer对象的私有字段,如orders。
您提供的链接页面上的两个答案实际上都非常好,我已经点赞了。然而,我认为,就面向对象编程而言,像您所描述的具有真正双向关联是完全合理的。原因是,要向某人发送消息,您必须拥有对他的引用。这就是为什么我将尝试概述问题以及我们面向对象编程人员有时会遇到的困难。长话短说,真正的面向对象编程有时很繁琐,非常类似于复杂的形式化方法。但它能产生更易于阅读、修改和扩展的代码,并且通常可以使您免除许多头痛。我一直想写下这些内容,我认为您的问题是一个好的借口。
主要的问题在于面向对象编程技术会在一组对象必须同时改变内部状态时出现,这是由“业务逻辑”所决定的外部请求引起的。例如,当一个人被聘用时,会发生很多事情。1)必须将员工配置为指向他的部门;2)他必须添加到部门中已雇用员工的列表中;3)其他一些东西必须添加到其他地方,比如合同的副本(甚至是扫描件)、保险信息等等。我提到的前两个动作恰好是建立(和维护,当员工被解雇或调动时)双向关联的例子,就像你描述客户和订单之间的关联一样。
在过程式编程中,PersonDepartmentContract将成为结构体,一个全局过程,例如hirePersonInDepartmentWithContract与用户界面中的按钮关联,将通过三个指针操作这些结构的3个实例。整个业务逻辑都在此函数内部,并且在更新这三个对象的状态时必须考虑到每一个可能的特殊情况。例如,当您单击雇用某人的按钮时,有可能他已经在另一个部门工作,甚至更糟的是在同一个部门工作。计算机科学家知道特殊情况很糟糕。雇用一个人基本上是一个非常复杂的用例,有许多扩展,不经常发生,但必须考虑。

真正的面向对象编程要求对象必须通过交换消息来完成任务。业务逻辑分散在几个对象的责任之间。CRC cards是一种在面向对象编程中研究业务逻辑的非正式工具。

要从John失业的有效状态到达他是研发部门的项目经理的另一个有效状态,需要经过至少一个无效状态。因此,存在一个初始状态、一个无效状态和一个最终状态,以及至少两个消息在人员和部门之间交换。您还可以确定,部门必须接收一个消息,以便有机会改变其内部状态,而另一个消息必须被人员接收,出于同样的原因。中间状态在实际世界中不存在,或者可能存在但不重要。然而,您应用程序中的逻辑模型必须以某种方式跟踪它。

基本上的想法是,当人力资源负责人填写“新员工”JFrame并点击“雇用”JButton时,所选部门将从JComboBox中检索出来,而该JComboBox可能已经从数据库中填充,并且根据各种JComponents内部的信息创建一个新的Person。也许会创建一个包含职位名称和薪水的工作合同。最后,适当的业务逻辑将所有对象连接在一起,并触发所有状态的更新。这个业务逻辑由类Department中定义的名为hire的方法触发,该方法接受一个Person和一个Contract作为参数。所有这些可能发生在JButtonActionListener中。
Department department = (Department)cbDepartment.getSelectedItem();
Person person = new Person(tfFirstName.getText(), tfLastName.getText());
Contract contract = new Contract(tfPositionName.getText(), Integer.parseInt(tfSalary.getText()));
department.hire(person, contract);

我想强调的是,在面向对象编程术语中,第4行发生了什么。在我们的情况下,this(即ActionListener)向department发送一条消息,告诉他们必须雇用contract下的person。让我们来看看这三个类的可能实现。 Contract是一个非常简单的类。
package com.example.payroll.domain;

public class Contract {

    private String mPositionName;
    private int mSalary;

    public Contract(String positionName, int salary) {
        mPositionName = positionName;
        mSalary = salary;
    }

    public String getPositionName() {
        return mPositionName;
    }

    public int getSalary() {
        return mSalary;
    }

    /*
        Not much business logic here. You can think
        about a contract as a very simple, immutable type,
        whose state doesn't change and that can't really
        answer to any message, like a piece of paper.
    */
}

Person更有趣。

package com.example.payroll.domain;

public class Person {

    private String mFirstName;
    private String mLastName;
    private Department mDepartment;
    private boolean mResigning;

    public Person(String firstName, String lastName) {
        mFirstName = firstName;
        mLastName = lastName;
        mDepartment = null;
        mResigning = false;
    }

    public String getFirstName() {
        return mFirstName;
    }

    public String getLastName() {
        return mLastName;
    }

    public Department getDepartment() {
        return mDepartment;
    }

    public boolean isResigning() {
        return mResigning;
    }

    // ========== Business logic ==========

    public void youAreHired(Department department) {
        assert(department != null);
        assert(mDepartment != department);
        assert(department.isBeingHired(this));

        if (mDepartment != null)
            resign();

        mDepartment = department;
    }

    public void youAreFired() {
        assert(mDepartment != null);
        assert(mDepartment.isBeingFired(this));

        mDepartment = null;
    }

    public void resign() {
        assert(mDepartment != null);

        mResigning = true;
        mDepartment.iResign(this);
        mDepartment = null;
        mResigning = false;
    }
}

部门相当酷。

package com.example.payroll.domain;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

public class Department {

    private String mName;
    private Map<Person, Contract> mEmployees;
    private Person mBeingHired;
    private Person mBeingFired;

    public Department(String name) {
        mName = name;
        mEmployees = new HashMap<Person, Contract>();
        mBeingHired = null;
        mBeingFired = null;
    }

    public String getName() {
        return mName;
    }

    public Collection<Person> getEmployees() {
        return mEmployees.keySet();
    }

    public Contract getContract(Person employee) {
        return mEmployees.get(employee);
    }

    // ========== Business logic ==========

    public boolean isBeingHired(Person person) {
        return mBeingHired == person;
    }

    public boolean isBeingFired(Person person) {
        return mBeingFired == person;
    }

    public void hire(Person person, Contract contract) {
        assert(!mEmployees.containsKey(person));
        assert(!mEmployees.containsValue(contract));

        mBeingHired = person;
        mBeingHired.youAreHired(this);
        mEmployees.put(mBeingHired, contract);
        mBeingHired = null;
    }

    public void fire(Person person) {
        assert(mEmployees.containsKey(person));

        mBeingFired = person;
        mBeingFired.youAreFired();
        mEmployees.remove(mBeingFired);
        mBeingFired = null;
    }

    public void iResign(Person employee) {
        assert(mEmployees.containsKey(employee));
        assert(employee.isResigning());

        mEmployees.remove(employee);
    }
}

我定义的信息,至少有非常生动的名称; 在实际应用中,您可能不想使用这些名称,但在本例的上下文中,它们有助于以有意义和直观的方式建模对象之间的交互。 部门可以接收以下信息:
  • isBeingHired: 发送者想知道特定人员是否正在被该部门雇用。
  • isBeingFired: 发送者想知道特定人员是否正在被该部门解雇。
  • hire: 发送者希望该部门雇用具有指定合同的人员。
  • fire: 发送者希望该部门解雇一个员工。
  • iResign: 发送者可能是一名员工,并告诉部门他辞职了。
个人可以接收以下信息:
  • youAreHired:部门发送此消息以通知人员已被录用。
  • youAreFired:部门发送此消息以通知员工已被解雇。
  • resign:发送者希望该人从当前职位辞职。请注意,被另一个部门录用的员工可以向自己发送 resign 消息以退出旧工作。

字段Person.mResigningDepartment.isBeingHiredDepartment.isBeingFired是我用来编码上述无效状态的方式:当它们中的任何一个为“非零”时,应用程序处于无效状态,但正在走向有效状态。

还要注意,这里没有设置方法;这与处理JavaBeans的常见做法形成了对比。实质上,JavaBeans非常类似于C结构,因为它们往往针对每个私有属性具有一对set/get(或针对布尔值的set/is)方法。但是它们确实允许设置验证,例如您可以检查传递给set方法的字符串不是空值并且不为空,并最终引发异常。
我在不到一个小时的时间里编写了这个小库。然后我编写了一个驱动程序,并在第一次运行时使用JVM -ea开关(启用断言)正确工作。
package com.example.payroll;

import com.example.payroll.domain.*;

public class App {

    private static Department resAndDev;
    private static Department production;
    private static Department[] departments;

    static {
        resAndDev = new Department("Research & Development");
        production = new Department("Production");
        departments = new Department[] {resAndDev, production};
    }

    public static void main(String[] args) {

        Person person = new Person("John", "Smith");

        printEmployees();
        resAndDev.hire(person, new Contract("Project Manager", 3270));
        printEmployees();
        production.hire(person, new Contract("Quality Control Analyst", 3680));
        printEmployees();
        production.fire(person);
        printEmployees();
    }

    private static void printEmployees() {

        for (Department department : departments) {
            System.out.println(String.format("Department: %s", department.getName()));

            for (Person employee : department.getEmployees()) {
                Contract contract = department.getContract(employee);

                System.out.println(String.format("  %s. %s, %s. Salary: EUR %d", contract.getPositionName(), employee.getFirstName(), employee.getLastName(), contract.getSalary()));
            }
        }

        System.out.println();
    }
}

事实上,它能工作并不是很酷,而酷的是只有招聘或解雇部门被授权向被雇用或解雇的人发送youAreHiredyouAreFired消息;同样地,只有辞职的员工才能向其部门发送iResign消息,并且仅限于该部门;从main发送的任何其他非法消息都会触发断言。在真正的程序中,您应该使用异常而不是断言。
这是否有点过度?这个例子确实有些极端。但我觉得这就是OOP的本质。对象必须相互合作以实现特定目标,即根据预定的业务逻辑(在这种情况下是招聘、解雇和辞职)改变应用程序的全局状态。一些程序员认为业务问题不适合OOP,但我不同意;业务问题基本上是工作流,并且它们本身是非常简单的任务,但它们涉及许多参与者(即对象),它们通过消息进行通信。继承、多态和所有模式都是受欢迎的扩展,但它们不是面向对象过程的基础。特别是,基于引用的关联经常比实现继承更受青睐。
请注意,通过使用静态分析、契约式设计和自动定理证明器,您可以验证您的程序在任何可能的输入情况下是否正确,而无需运行它。面向对象编程是使您能够以这种方式思考的抽象框架。它不一定比过程化编程更紧凑,并且它不会自动导致代码重用。但我坚持认为它更易于阅读、修改和扩展;让我们看一下这种方法:
    public void youAreHired(Department department) {
        assert(department != null);
        assert(mDepartment != department);
        assert(department.isBeingHired(this));

        if (mDepartment != null)
            resign();

        mDepartment = department;
    }

使用案例相关的业务逻辑在最后进行分配;if语句是一种扩展,只有在人员已经在另一个部门担任员工时才会出现特殊情况。前三个断言描述了禁止的特殊情况。如果有一天我们想要禁止自动辞职前一个部门,只需要修改这个方法即可:
    public void youAreHired(Department department) {
        assert(department != null);
        assert(mDepartment == null);
        assert(department.isBeingHired(this));

        mDepartment = department;
    }

我们还可以通过将 youAreHired 设为布尔函数来扩展应用程序,仅在旧部门同意新员工加入时返回 true。显然,我们可能需要改变其他一些东西,在我的情况下,我将 Person.resign 设为布尔函数,这又可能需要将 Department.iResign 设为布尔函数:
    public boolean youAreHired(Department department) {
        assert(department != null);
        assert(mDepartment != department);
        assert(department.isBeingHired(this));

        if (mDepartment != null)
            if (!resign())
                    return false;

        mDepartment = department;

        return true;
    }

现在,当前雇主有最终决定权来确定员工是否可以被调到另一个部门。当前部门可以将这一责任委托给策略,该策略可能考虑员工参与的项目、截止日期和各种合同限制。
实质上,向客户添加订单确实是业务逻辑的一部分。如果需要双向关联,而反射不是选项,并且此问题和链接问题中提出的解决方案都不令人满意,我认为唯一的解决方案是类似于这样的东西。

3
虽然对实际问题的扩展有时是必要和受欢迎的,但这会偏离主题。请保持你的回答准确无误,把其他内容留到你的博客中。 - Anthony Pegram
@Anthony Pegram:感谢您的评论。我强烈怀疑答案有点太详细了。事实上,我曾经多次研究过这个主题,并意识到很可能没有确立的“最佳实践”来做Steve Furrow所问的事情,因此我需要提供一些支持。如果负面反馈继续,我很乐意编辑或缩短答案或改进它。 - damix911
@damix911 我和你的观点一致,非常喜欢这种方法!你有博客或者其他关于这个策略的信息吗? - Marvin

6

首先,除非您计划在客户之间移动订单,否则我认为不应提供setCustomer()方法,客户应该是构造函数的参数,并保持不变。

其次,构造函数不应该对用户可见,只能使用Owner的工厂方法。


+1. 对于大多数情况而言,这是最安全的解决方案(虽然不总是最简单的)。如果您正在使用Hibernate ORM,则可以将setter方法设置为私有,或使用@AccessType(value="field")。 - RMorrisey
Javier,如果我想要将Order构造函数对除了Customer以外的所有人隐藏起来,我需要将Order作为Customer的内部类,并定义public Order接口,是这样吗?如果Customer是Order对象的唯一来源,那么这样做或许可行。 - Steve Furrow
另外一个问题是,如果我需要一个多对多的双向链接,比如:问题和标签?当我需要在现有的问题和标签对象中添加更多关联时,我将无法使用构造函数技巧。 - Steve Furrow

0

如果您在 Customer.placeOrder(Order) 中维护了双向关系,为什么不在 Order.setCustomer(Customer) 中做同样的事情呢?

class Order {
    private Customer customer;
    public void setCustomer (Customer c) {
        customer = c;
        c.getOrders().add(this);
        // ... or Customer.placeOrder(this)
    }
}

看起来是在重复代码,但它解决了问题。不过,更简单的做法是尽量避免双向关联。


如果setCustomer调用placeOrder,将会发生无限递归(已注释的选项)。如果Customer有一个公共的getOrders方法,那么客户端代码仍然有可能通过执行**c.getOrders().add(this)**来破坏模型,这是关联的另一侧的破坏(未注释的选项)。 - damix911

0

我认为在这种情况下最好的方法是将连接责任委托给另一个类:

class OrderManager {
    void placeOrder(Customer c, Order o){
        c.addOrder(o);
        o.setCustomer(c);
    }
}

class Customer {
    private Set<Order> orders = new LinkedHashSet<Order>();
    void addOrder(Order o){ orders.add(o); }
}

class Order {
    private Customer customer;
    void setCustomer(Customer c){ this.customer=c; }
}

0

没有一个单一的答案。这真的取决于所涉及的类。在您的情况下,显然不希望给人们提供执行无效操作的选项,因此我会摆脱Order.SetCustomer。

但并非总是如此。就像我说的,这取决于所涉及的类。


1
Justin,如果我去掉Order.setCustomer方法,那么Customer对象如何向Order对象发出信号以添加关联的另一端? - Steve Furrow

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接