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:
@peerObject
tag in the Javadoc comment for field team
and a @peerObjects
tag in the Javadoc comment for field members
. The latter means that for any Team
object T, each element of T.members
is a peer object of T.@peerObject
tag in the Javadoc comment for getter getTeam()
and a @peerObjects
tag in the Javadoc comment for getter getMembers()
.team == null || team.members.contains(this)
declared in class ProjectCourseStudent
is not well-defined if team.members
is null. This invariant should be guarded by another invariant that says that team.members
is not null, and that is checked first. There is a representation invariant members != null
in class Team
. We can use it to guard the invariant in class ProjectCourseStudent
by defining the order of checking the representation invariants of a peer group as follows: first, the Phase 1 representation invariant of each peer object is checked; then, the Phase 2 representation invariant of each peer object is checked; and so on, until all representation invariants have been checked. We simply define the Phase 1 representation invariant of an object to be the first representation invariant defined in the object’s class, and so on. This is why we insert a dummy representation invariant true
into class ProjectCourseStudent
; this ensures that the invariant that relies on team.members
being non-null is a Phase 2 invariant.LogicalSet.plus
and LogicalSet.minus
from logicalcollections
to concisely specify the effect of join
and leaveTeam
on the team’s set of members.@mutates_properties | this.getTeam(), team.getMembers()
clause to conveniently specify that joinTeam(team)
mutates the peer group of this
and the peer group of team
, and that for any object O in either of these peer groups, and for any basic getter M of O, we have either that (O, M) is in the set {(this
, getTeam
), (team
, getMembers
)} or Objects.equals(O.M(), old(O.M()))
. That is, from the client’s point of view, joinTeam(team)
mutates only this.getTeam()
and team.getMembers()
and leaves all other properties of both peer groups involved (as given by the peer group objects’ basic getters) unchanged. (A getter is basic if it does not have a postcondition of the form result == E
or result.equals(E)
or Objects.equals(result, E)
for some expression E.)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:
@peerObject
and @peerObjects
tags in the Javadoc comments for the class’s private fields; the package-level peer group is defined by the @peerObject (package-level)
and @peerObjects (package-level)
tags in the Javadoc comments for the package’s package-accessible getters. In the example, the objects have no class-level peer groups (or, equivalently, the class-level peer group of O is just the singleton {O}).@peerObject (class-level)
and/or @peerObjects (class-level)
clauses in the Javadoc comments for the class’ package-accessible getters.@invar
tags in the Javadoc comments for the class’ private fields.@post
tags in the Javadoc comments for the class’ package-accessible getters.@invar
tags in the Javadoc comments for the package’s package-accessible fields or getters.@invar
tags in the Javadoc comments for the package’s classes and/or using @post
tags in the Javadoc comments for the package’s public getters.addMember
and removeMember
into class Team
to allow methods join
and leaveTeam
in class ProjectCourseStudent
to restore the consistency of the package-level bidirectional association.