ogp-notes

Multi-class entity-relationship abstractions

In Entity-relationship abstractions, we introduced the concept of entity-relationship abstractions. The examples we discussed there involved entity graphs consisting of only a single type of entity: OOPStudent. Such abstractions are built from groups of instances of class OOPStudent. We refer to such abstractions as single-class entity-relationship abstractions.

In this note, we consider the task of designing and implementing abstractions that store entity graphs involving multiple entity types. We will declare a separate class for each entity type. Such abstractions are therefore multi-class entity-relationship abstractions.

To illustrate the concepts, we will consider a generalization of the OOP team composition graphs example: we will consider graphs of project course students and the teams they belong to. For simplicity, a team may have arbitrarily many members, but each student may be a member of only one team.

To implement an abstraction for storing such graphs, we will introduce a class ProjectCourseStudent to represent students and a class Team to represent teams:

</img>

Here is an initial attempt at implementing these classes:

package bigteams;

public class ProjectCourseStudent {
    
    private Team team;
    
    public Team getTeam() { return team; }
    
    public ProjectCourseStudent() {}

    public void join(Team team) {
        if (this.team != null)
            throw new IllegalStateException("this student is already in a team");
        
        this.team = team;
        team.members.add(this);
    }

    public void leaveTeam() {
        if (this.team == null)
            throw new IllegalStateException("this student is not in a team");
        
        team.members.remove(this);
        team = null;
    }
}
package bigteams;

public class Team {
    
    private HashSet<ProjectCourseStudent> members = new HashSet<>();
    
    public Set<ProjectCourseStudent> getMembers() { return Set.copyOf(members); }

    public Team() {}
    
}
package bigteams;

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

class BigTeamsTest {

    @Test
    void test() {
        ProjectCourseStudent student1 = new ProjectCourseStudent();
        ProjectCourseStudent student2 = new ProjectCourseStudent();
        Team team = new Team();
        
        student1.join(team);
        assertEquals(team, student1.getTeam());
        assertEquals(Set.of(student1), team.getMembers());
        
        student2.join(team);
        assertEquals(team, student2.getTeam());
        assertEquals(Set.of(student1, student2), team.getMembers());
        
        student1.leaveTeam();
        assertEquals(Set.of(student2), team.getMembers());
        
        student2.leaveTeam();
        assertEquals(Set.of(), team.getMembers());
    }

}

Notice that we use a HashSet to store the members of a team. Class HashSet is a generic class; it takes the type of its elements as a type parameter. In the parameterized type HashSet<ProjectCourseStudent>, ProjectCourseStudent is the type argument for generic class HashSet.

Type HashSet<T> is a subtype of type Set<T>. This means that every instance of HashSet<T> is also an instance of Set<T>.

Notice also that we implement a bidirectional association between classes ProjectCourseStudent and class Team, and we make sure that its consistency is preserved at all times: if a ProjectCourseStudent instance S refers to a Team instance T, then T also refers to S, and vice versa.

Unfortunately, the code above is rejected by Java’s static type checker. Indeed, class ProjectCourseStudent accesses class Team’s private field members. The private fields of a class may be accessed by the class itself only. We could make field members public but this would break the abstraction’s encapsulation: clients would see the abstraction’s internal implementation details and could modify the field without respecting the abstraction’s representation invariants. For example, a client could set the members field of some Team instance T to null or could add a ProjectCourseStudent instance S to its HashSet without updating S’s team field to point to T, thus breaking the consistency of the bidirectional association and the validity of the abstraction’s representation.

What we need is some way to make field members accessible to class ProjectCourseStudent without making it accessible to clients. In Java, this can be done by putting classes ProjectCourseStudent and Team into a package by themselves, and making field members package-accessible. A Java program element is package-accessible by default; that is, if you do not specify any accessibility modifier (such as private or public) for a program element, the program element is package-accessible. This means it may be accessed by any code in the same package.

We conclude that to implement multi-class abstractions, we generally need to implement them using a package as the unit of encapsulation rather than a class. We apply this principle to the example by moving the example client (class BigTeamsTest) out of package bigteams and by making the fields of class ProjectCourseStudent and Team package-accessible. Analogously to class-encapsulated abstractions, we specify package representation invariants (using @invar clauses in the fields’ Javadoc comments) and package abstract state invariants (using @invar clauses in the classes’ Javadoc comments and/or @post clauses in the Javadoc comments for the public inspectors):

package bigteams;

import logicalcollections.LogicalSet;

/**
 * Each instance of this class represents a student in a project course,
 * as part of a student-team graph.
 * 
 * @invar If a student is in a team, it is among its members.
 *    | getTeam() == null || getTeam().getMembers().contains(this)
 */
public class ProjectCourseStudent {
    
    /**
     * @invar | true  // Phase 1 representation invariant
     * @invar | team == null || team.members.contains(this)  // Phase 2 rep. inv.
     * 
     * @peerObject
     */
    Team team;
    
    /**
     * Returns this student's team, or {@code null} if they are not in a team.
     * 
     * @peerObject
     */
    public Team getTeam() { return team; }
    
    /**
     * Initializes this object as representing a student who is not in a team.
     */
    public ProjectCourseStudent() {}

    /**
     * Make this student a member of the given team.
     *
     * @throws IllegalArgumentException if {@code team} is null.
     *    | team == null
     * @throws IllegalStateException if this student is already in a team.
     *    | getTeam() != null
     * 
     * @mutates_properties | this.getTeam(), team.getMembers()
     * 
     * @post The given team's members equal its old members plus this student.
     *    | team.getMembers().equals(LogicalSet.plus(old(team.getMembers()), this))
     */
    public void join(Team team) {
        if (team == null)
            throw new IllegalArgumentException("team is null");
        if (this.team != null)
            throw new IllegalStateException("this student is already in a team");
        
        this.team = team;
        team.members.add(this);
    }

    /**
     * Make this student no longer be a member of their team.
     * 
     * @throws IllegalStateException if this student is not in a team.
     *    | getTeam() == null
     * 
     * @mutates_properties | this.getTeam(), this.getTeam().getMembers()
     * 
     * @post This student is not in a team.
     *    | getTeam() == null
     * @post This student's old team's members are its old members minus this
     *       student.
     *    | old(getTeam()).getMembers().equals(
     *    |     LogicalSet.minus(old(getTeam().getMembers()), this))
     */
    public void leaveTeam() {
        if (this.team == null)
            throw new IllegalStateException("this student is not in a team");
        
        team.members.remove(this);
        team = null;
    }
}
package bigteams;

import java.util.HashSet;
import java.util.Set;

/**
 * Each instance of this class represents a team in a student-team graph.
 * 
 * @invar Each of this team's members has this team as its team.
 *    | getMembers().stream().allMatch(s -> s != null && s.getTeam() == this)
 */
public class Team {
    
    /**
     * @invar | members != null  // Phase 1 representation invariant
     * @invar | members.stream().allMatch(s ->
     *        |     s != null && s.team == this)  // Phase 2 rep. inv.
     * 
     * @representationObject
     * @peerObjects
     */
    HashSet<ProjectCourseStudent> members = new HashSet<>();
    
    /**
     * Returns this team's set of members.
     * 
     * @post | result != null
     * @creates | result
     * @peerObjects
     */
    public Set<ProjectCourseStudent> getMembers() { return Set.copyOf(members); }

    /**
     * Initializes this object as representing an empty team.
     * 
     * @post This team has no members.
     *    | getMembers().isEmpty()
     */
    public Team() {}
    
}
package bigteams.tests;

import static org.junit.jupiter.api.Assertions.*;

import java.util.Set;

import org.junit.jupiter.api.Test;

import bigteams.ProjectCourseStudent;
import bigteams.Team;

class BigTeamsTest {

    @Test
    void test() {
        ProjectCourseStudent student1 = new ProjectCourseStudent();
        ProjectCourseStudent student2 = new ProjectCourseStudent();
        Team team = new Team();
        
        student1.join(team);
        assertEquals(team, student1.getTeam());
        assertEquals(Set.of(student1), team.getMembers());
        
        student2.join(team);
        assertEquals(team, student2.getTeam());
        assertEquals(Set.of(student1, student2), team.getMembers());
        
        student1.leaveTeam();
        assertEquals(Set.of(student2), team.getMembers());
        
        student2.leaveTeam();
        assertEquals(Set.of(), team.getMembers());
    }

}

Notice the following:

Advanced: Nesting class-encapsulated and package-encapsulated abstractions

This section presents highly advanced material that is intended only for students aiming for a 20/20 score. To obtain a score of 19/20, you need not apply this material.

Making the fields of a module package-accessible is probably fine for small modules such as the example module, but for larger modules this quickly becomes error-prone. Therefore, it is preferrable to always declare fields as private, and to encapsulate them into a class-level abstraction, even if the class is part of a multi-class abstraction as well. We call this a case of nested abstractions. In such cases of class-level abstractions nested within a package-level abstraction, each class of the package is a client of the class-level abstractions implemented by the other classes of the package.

When doing so, we can take the opportunity to move as many of the package’s representation invariants as possible into the individual class-level abstractions. In the example, we can move the invariants stating that members is not null and that the elements of members are not null into the Team class-level abstraction:

package bigteams;

import logicalcollections.LogicalSet;

/**
 * Each instance of this class represents a student in a project course,
 * as part of a student-team graph.
 * 
 * @invar If a student is in a team, it is among its members.
 *    | getTeam() == null || getTeam().getMembers().contains(this)
 */
public class ProjectCourseStudent {
    
    private Team team;

    /**
     * @invar | getTeamInternal() == null
     *        | || getTeamInternal().getMembersInternal().contains(this)
     * 
     * @peerObject (package-level)
     */
    Team getTeamInternal() { return team; }
    
    /**
     * Returns this student's team, or {@code null} if they are not in a team.
     * 
     * @peerObject
     */
    public Team getTeam() { return getTeamInternal(); }
    
    /**
     * Initializes this object as representing a student who is not in a team.
     */
    public ProjectCourseStudent() {}

    /**
     * Make this student a member of the given team.
     *
     * @throws IllegalArgumentException if {@code team} is null.
     *    | team == null
     * @throws IllegalStateException if this student is already in a team.
     *    | getTeam() != null
     * 
     * @mutates_properties | this.getTeam(), team.getMembers()
     * 
     * @post The given team's members equal its old members plus this student.
     *    | team.getMembers().equals(LogicalSet.plus(old(team.getMembers()), this))
     */
    public void join(Team team) {
        if (team == null)
            throw new IllegalArgumentException("team is null");
        if (this.team != null)
            throw new IllegalStateException("this student is already in a team");
        
        this.team = team;
        team.addMember(this);
    }

    /**
     * Make this student no longer be a member of their team.
     * 
     * @throws IllegalStateException if this student is not in a team.
     *    | getTeam() == null
     * 
     * @mutates_properties | this.getTeam(), this.getTeam().getMembers()
     * 
     * @post This student is not in a team.
     *    | getTeam() == null
     * @post This student's old team's members are its old members minus this
     *       student.
     *    | old(getTeam()).getMembers().equals(
     *    |     LogicalSet.minus(old(getTeam().getMembers()), this))
     */
    public void leaveTeam() {
        if (this.team == null)
            throw new IllegalStateException("this student is not in a team");
        
        team.removeMember(this);
        team = null;
    }
}
package bigteams;

import java.util.HashSet;
import java.util.Set;

import logicalcollections.LogicalSet;

/**
 * Each instance of this class represents a team in a student-team graph.
 * 
 * @invar Each of this team's members has this team as its team.
 *    | getMembers().stream().allMatch(s -> s != null && s.getTeam() == this)
 */
public class Team {

    /**
     * @invar | members != null
     * @invar | members.stream().allMatch(s -> s != null)
     * 
     * @representationObject
     */
    private HashSet<ProjectCourseStudent> members = new HashSet<>();
    
    /**
     * Returns this team's set of members.
     * 
     * @invar | getMembersInternal().stream().allMatch(s ->
     *        |     s.getTeamInternal() == this)
     * 
     * @post | result != null && result.stream().allMatch(s -> s != null)
     * @creates | result
     * @peerObjects (package-level)
     */
    Set<ProjectCourseStudent> getMembersInternal() { return Set.copyOf(members); }
    
    /**
     * Returns this team's set of members.
     * 
     * @post | result != null
     * @creates | result
     * @peerObjects
     */
    public Set<ProjectCourseStudent> getMembers() { return getMembersInternal(); }

    /**
     * Initializes this object as representing an empty team.
     * 
     * @post This team has no members.
     *    | getMembers().isEmpty()
     */
    public Team() {}
    
    /**
     * Adds the given student to this team's set of students.
     * 
     * @throws IllegalArgumentException if {@code student} is null
     *    | student == null
     * @mutates | this
     * @post This team's set of members equals its old set of members plus the
     *       given student.
     *    | getMembersInternal().equals(
     *    |     LogicalSet.plus(old(getMembersInternal()), student))
     */
    void addMember(ProjectCourseStudent student) {
        if (student == null)
            throw new IllegalArgumentException("student is null");
        
        members.add(student);
    }
    
    /**
     * Removes the given student from this team's set of students.
     * 
     * @throws IllegalArgumentException if {@code student} is null
     *    | student == null
     * @mutates | this
     * @post This team's set of members equals its old set of members minus the
     *       given student.
     *    | getMembersInternal().equals(
     *    |     LogicalSet.minus(old(getMembersInternal()), student))
     */
    void removeMember(ProjectCourseStudent student) {
        if (student == null)
            throw new IllegalArgumentException("student is null");
        
        members.remove(student);
    }
    
}

(We do not show class BigTeamsTest again because it is unchanged.)

Notice the following: