在 OO 模型中添加双向关系的最佳实践

2022-09-04 21:32:44

我正在努力想出一种在OO模型中添加双向关系的好方法。假设有一个客户可以下许多订单,也就是说,客户和订单类之间存在一对多关联,需要在两个方向上可遍历:对于特定客户,应该可以告诉所有订单,对于订单,应该可以告诉客户。

下面是一段 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);

形成单向链路而不是双向链路。

仍在学习OOP,任何人都可以帮助解决这个问题的惯用和实用方法,而无需诉诸“反射”或花哨的框架(无论如何都会依赖于反射)。

附言:有一个类似的问题:在我的java模型中管理双向关联,但我不觉得它回答了我的请求。

附言任何在db4o之上实现业务模型的现实项目源代码的链接都非常感谢!


答案 1

这是一个非常有趣的问题,对OOP的理论和实践有着深远的影响。首先,我将告诉您(几乎)完成您要求的快速而肮脏的方法。总的来说,我不推荐这个解决方案,但是由于没有人提到它,并且(如果记忆没有让我失望的话)它在Martin Fowler(UML Distilled)的一本书中提到过,它可能值得一提;您可以从以下位置更改 setCustomer 方法的定义:

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

自:

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

并确保客户订单在同一包装中。如果未指定访问修饰符,则 setCustomer 默认为可见性,这意味着只能从同一包中的类访问它。显然,这并不能保护您免受同一软件包中客户以外的类的非法访问。此外,如果您决定将“客户”“订单”移动到两个不同的包中,则代码将中断。

在Java的常见编程实践中,包可见性在很大程度上是可以容忍的。我觉得在C++社区中,朋友修饰符并不像Java中的包可见性那样被容忍,尽管它具有类似的目的。我真的不明白为什么,因为朋友更具选择性:基本上对于每个类,你可以指定其他朋友类和函数,这些类和函数将能够访问第一个类的私有成员。

然而,毫无疑问,Java的包可见性和C++的朋友都不是OOP含义的良好代表,甚至不是基于对象的编程的含义(OOP基本上是OBP加上继承和多态性;从现在开始,我将使用术语 OOP)。OOP的核心方面是存在称为对象的实体,它们通过相互发送消息进行通信。对象具有内部状态,但此状态只能由对象本身更改。状态通常是结构化的,即它基本上是名称年龄订单字段的集合。在大多数语言中,消息是同步的,它们不能被错误地丢弃,例如邮件或UDP数据包。当你写c.placeOrder(o)时,这意味着发送者,就是这样,正在向c发送一条消息。此消息的内容为 placeOrdero

当对象收到消息时,它必须处理它。Java、C++、C# 和许多其他语言都假定,只有当对象的类定义了具有适当名称和形式参数列表的方法时,该对象才能处理消息。一个类的方法集称为它的接口,Java和C#等语言也有一个合适的构造,即接口来对一组方法的概念进行建模。消息 c.placeOrder(o) 的处理程序是以下方法:

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

该方法的主体是编写指令的位置,如有必要,这些指令将更改对象 c 的状态。在此示例中,订单字段被修改。

从本质上讲,这就是OOP的含义。OOP是在模拟的背景下开发的,在模拟中,你基本上有很多相互通信的黑匣子,每个黑匣子都对自己的内部状态负责。

大多数现代语言完全遵循此方案,但前提是您将自己限制在私有字段和公共/受保护的方法中。不过,也有一些陷阱。例如,在类 Customer 的方法中,您可以访问另一个 Customer 对象的私有字段,如订单

你链接的页面上的两个答案实际上非常好,我都投了赞成票。但是,我认为,对于OOP来说,正如您所描述的那样,具有真正的双向关联是完全合理的。原因是,要向某人发送消息,您必须参考他。这就是为什么我将尝试概述问题所在,以及为什么我们OOP程序员有时会为此而苦恼。长话短说,真正的OOP有时很乏味,非常类似于复杂的形式化方法。但它生成的代码更易于阅读,修改和扩展,并且通常可以避免很多麻烦。我一直想把这个写下来已经有一段时间了,我认为你的问题是一个很好的借口。

OOP 技术的主要问题出现在一组对象必须同时更改内部状态时,这是由业务逻辑决定的外部请求的结果。例如,当一个人被雇用时,会发生很多事情。1)员工必须配置为指向其部门;2)必须将其添加到部门雇用的员工名单中;3)必须在其他地方添加其他东西,例如合同的副本(甚至可能是它的扫描件),保险信息等等。我引用的前两个操作正是建立(并在员工被解雇或转移时维护)双向关联的一个例子,就像您描述的客户和订单之间的关联一样。

在程序编程中,人员部门合同将是结构,并且与单击用户界面中的按钮相关联的全局过程(如hirePersonInDepartmentWithContract)将通过三个指针操作这些结构的3个实例。整个业务逻辑都在此函数中,在更新这三个对象的状态时,它必须考虑每个可能的特殊情况。例如,当您单击按钮雇用某人时,他可能已经受雇于另一个部门,甚至更糟的是在同一部门。计算机科学家知道特殊情况很糟糕。雇用一个人基本上是一个非常复杂的用例,有很多扩展并不经常发生,但必须考虑这一点。

真正相反,OOP 要求对象必须交换消息才能完成此任务。业务逻辑在多个对象的职责之间分配。CRC 卡是研究 OOP 中业务逻辑的非正式工具。

要从约翰失业的有效状态,到他是研发部门项目经理的另一个有效状态,有必要经历一些无效状态,至少一个。因此,有一个初始状态,一个无效状态和一个最终状态,以及一个人和一个部门之间至少交换的两条消息。您还可以确保该部门必须接收一条消息,以使其有机会改变其内部状态,并且出于同样的原因,该人必须收到另一条消息。中间状态是无效的,因为它在现实世界中并不真正存在,或者可能存在但并不重要。但是,应用程序中的逻辑模型必须以某种方式对其进行跟踪。

基本上,这个想法是,当人力资源人员填写“新员工”JFrame并单击“雇用”JButton时,将从JComboBox中检索所选部门,而JComboBox又可能已从数据库中填充,并且根据各种JComponents中的信息创建了一个新的Person。.也许创建的工作合同至少包含职位名称和薪水。最后,有适当的业务逻辑将所有对象连接在一起并触发所有状态的更新。此业务逻辑由类部门中定义的名为 hire 的方法触发,该方法将人员合同作为参数。所有这些都可能发生在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行正在发生的事情,用OOP术语来说;(在我们的例子中是ActionListener,正在向部门发送消息,说他们必须根据合同雇用人员。让我们来看看这三个类的合理实现。

合同是一个非常简单的类。

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.
    */
}

更有趣。

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:发件人想知道某个人是否正在被该部门解雇。
  • 雇用:发件人希望该部门雇用具有指定合同的人员。
  • 火灾:发件人希望该部门解雇员工。
  • iResign:发件人可能是员工,并且正在告诉部门他正在辞职。

用户可以接收以下消息:

  • youAreHired:部门发送此消息以通知该人他被雇用。
  • youAreFired:部门发送此消息以通知员工他被解雇。
  • 辞职:发件人希望该人从其当前职位辞职。请注意,被其他部门雇用的员工可以向自己发送辞职消息,以便退出旧工作。

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

另请注意,没有固定的方法;这与使用JavaBeans的常见做法形成鲜明对比。JavaBean 本质上与 C 结构非常相似,因为它们往往为每个私有属性都有一个 set/get(或 set/is for boolean)对。但是,它们确实允许验证 set,例如,您可以检查传递给 set 方法的 String 是否为 null 且不为空,并最终引发异常。

我在不到一个小时的时间内写了这个小图书馆。然后我写了一个驱动程序,它在第一次运行时与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();
    }
}

不过,它起作用的事实并不是一件很酷的事情。很酷的事情是,只有招聘或解雇部门有权向您发送AreHiredyouAreFired消息给被雇用或被解雇的人;同样,只有辞职的员工才能将iResign消息发送到其部门,并且只能发送到该部门;从 main 发送的任何其他非法消息都会触发断言。在实际程序中,您将使用异常而不是断言。

所有这些是不是太过分了?诚然,这个例子有点极端。但我觉得这就是OOP的本质。对象必须合作才能实现某个目标,即根据预定的业务逻辑更改应用程序的全局状态,在这种情况下,雇用解雇辞职。一些程序员认为业务问题不适合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;
    }

现在,现任员工在决定员工是否可以调到另一个部门方面拥有了最终决定权。目前的部门可以将确定这一点的责任委托给一项战略,而战略又可以考虑雇员参与的项目、他们的最后期限和各种合同限制。

从本质上讲,向客户添加订单实际上是业务逻辑的一部分。如果需要双向关联,并且反思不是一种选择,并且在这个问题上和链接的问题上提出的解决方案都不令人满意,我认为唯一的解决方案是这样的。


答案 2

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

然后,构造函数不应该可供用户访问,只能使用 的工厂方法。Owner