Lecture 20: Mutable data structures
Removing items from a list, sentinels, and wrappers
In the last lecture, we considered phone lists and dealt with the possibility that people might move and change their phone numbers. In this lecture, we deal with a related but more subtle situation, where we lose contact with someone altogether, and need to remove them from a phone list. Once again, we’ll start with the non-generic ILoPerson type to focus on the essential new aspects of the problem; generalizing to IList<Person> is straightforward.
20.1 Removing items from a list: the setup
// In ILoPerson // EFFECT: modifies this list and removes the person with the given name from it void removePerson(String name);
// In ExamplePhoneLists // Tests removing the first person in a list void testRemoveFirstPerson(Tester t) { this.initData(); ILoPerson list1 = new ConsLoPerson(this.anne, new ConsLoPerson(this.clyde, new ConsLoPerson(this.henry, new MtLoPerson()))); ILoPerson list2 = new ConsLoPerson(this.anne, new ConsLoPerson(this.dana, new ConsLoPerson(this.gail, new MtLoPerson()))); // Check initial conditions t.checkExpect(list1.contains("Anne"), true); t.checkExpect(list2.contains("Anne"), true); // Modify list1 list1.removePerson("Anne"); // Check that list1 has been modified... t.checkExpect(list1.contains("Anne"), false); // ...but that list2 has not t.checkExpect(list2.contains("Anne"), true); } // Tests removing a middle person in a list void testRemoveMiddlePerson(Tester t) { this.initData(); ILoPerson list1 = new ConsLoPerson(this.anne, new ConsLoPerson(this.clyde, new ConsLoPerson(this.henry, new MtLoPerson()))); ILoPerson list2 = new ConsLoPerson(this.dana, new ConsLoPerson(this.clyde, new ConsLoPerson(this.gail, new MtLoPerson()))); // Check initial conditions t.checkExpect(list1.contains("Clyde"), true); t.checkExpect(list2.contains("Clyde"), true); // Modify list1 list1.removePerson("Clyde"); // Check that list1 has been modified... t.checkExpect(list1.contains("Clyde"), false); // ...but that list2 has not t.checkExpect(list2.contains("Clyde"), true); } // Tests removing the last person in a list void testRemoveLastPerson(Tester t) { this.initData(); ILoPerson list1 = new ConsLoPerson(this.anne, new ConsLoPerson(this.clyde, new ConsLoPerson(this.henry, new MtLoPerson()))); ILoPerson list2 = new ConsLoPerson(this.dana, new ConsLoPerson(this.gail, new ConsLoPerson(this.henry, new MtLoPerson()))); // Check initial conditions t.checkExpect(list1.contains("Henry"), true); t.checkExpect(list2.contains("Henry"), true); // Modify list1 list1.removePerson("Henry"); // Check that list1 has been modified... t.checkExpect(list1.contains("Henry"), false); // ...but that list2 has not t.checkExpect(list2.contains("Henry"), true); }
Do Now!
These examples are not complete. What other edge cases might there be?
20.2 Removing a person by name, part 1
// In MtLoPerson void removePerson(String name) { return; }
// In ConsLoPerson void removePerson(String name) { if (this.first.name.equals(name)) { ??? } else { this.rest.removePerson(name); } }
Exercise
Well...maybe we don’t need to remove this ConsLoPerson from the list —maybe we just need to remove its data. Why couldn’t we just “move” the data from this.rest.first into this.first, and then do the same thing to this.rest, moving the items up one by one to eliminate the current one? Give three technical reasons why this approach fails. (Hint: consider types, aliasing, and any base cases.)
(It’s tempting to think that perhaps all we need to do is “set this to this.rest”,
and then we’d be done. But this is not a variable, it’s a keyword. It is a pronoun,
referring to the current object, and we cannot modify it to mean something else. Instead,
we have to find where the references to the current object are being held—
// In ILoPerson void removePerson(String name); void removePersonHelp(String name, ConsLoPerson prev);
// In MtLoPerson void removePerson(String name) { return; } void removePersonHelp(String name, ConsLoPerson prev) { return; }
// In ConsLoPerson void removePersonHelp(String name, ConsLoPerson prev) { if (this.first.name.equals(name)) { prev.rest = this.rest; // Modify the previous node to bypass this node } else { this.rest.removePersonHelp(name, this); // this is the previous node of this.rest } }
// In ConsLoPerson void removePerson(String name) { return this.rest.removePersonHelp(name, this); }
Do Now!
There is a serious but subtle problem with this single line of code. What is it?
20.3 Aliasing, again, and removing items from a list
// In ExamplePhoneLists void initData() { // ... initialize Anne, Bob, etc... this.work = new ConsLoPerson(this.bob, new ConsLoPerson(this.clyde, new ConsLoPerson(this.dana, new ConsLoPerson(this.eric, new ConsLoPerson(this.frank, new MtLoPerson()))))); // We're friends with everyone at work, and also with other people this.friends = new ConsLoPerson(this.anne, new ConsLoPerson(this.gail, new ConsLoPerson(this.henry, this.work); }
// In ExamplePhoneLists void testRemoveCoworker(Tester t) { this.initData(); // Test that Eric is a coworker and a friend t.checkExpect(this.work.findPhoneNum("Eric"), this.eric.num); t.checkExpect(this.friends.findPhoneNum("Eric"), this.eric.num); // Remove Eric from coworkers this.work.removePerson("Eric"); // Check that Eric is no longer a coworker t.checkExpect(this.work.findPhoneNum("Eric"), -1); // Check that Eric is still a friend t.checkExpect(this.friends.findPhoneNum("Eric"), this.eric.num); }
Do Now!
Do these tests pass? Why or why not? Draw an object diagram to illustrate the situation.
+------+ +------+ +-------+ +------+ +-------+ +-------+ +------+ +-------+ +-------+ +---------+ | Anne | | Bob | | Clyde | | Dana | | Eric | | Frank | | Gail | | Henry | | Irene | | Jenny | | 1234 | | 3456 | | 6789 | | 1357 | | 12469 | | 7924 | | 9345 | | 8602 | | 91302 | | 8675309 | +------+ +------+ +-------+ +------+ +-------+ +-------+ +------+ +-------+ +-------+ +---------+ ^ ^ ^ ^ ^ ^ ^ ^ | | | | | +----------|---------|-----------------+ | | | | +--------------------|---------|------+ | | | | +---------------------------|-----+ | | | | | +---------------------------------+ | | | | | | +-------------------------------+ | | | | | | | | | | | | | | | +-----------------------------|----------|----+ | | | | | | +------------------|----------|----------|---+ | | +--|----+ +--|----+ +--|----+ | | | | | | first | | first | | first | +--|----+ +--|----+ +--|----+ +--|----+ +--|----+ ++ | rest --->| rest --->| rest ----------->| first | | first | | first | | first | | first | || +-------+ +-------+ +-------+ | rest --->| rest --->| rest --->| rest --->| rest --->|| ^ +-------+ +-------+ +-------+ +-------+ +-------+ ++ | ^ friends | work
+------+ +------+ +-------+ +------+ +-------+ +-------+ +------+ +-------+ +-------+ +---------+ | Anne | | Bob | | Clyde | | Dana | | Eric | | Frank | | Gail | | Henry | | Irene | | Jenny | | 1234 | | 3456 | | 6789 | | 1357 | | 12469 | | 7924 | | 9345 | | 8602 | | 91302 | | 8675309 | +------+ +------+ +-------+ +------+ +-------+ +-------+ +------+ +-------+ +-------+ +---------+ ^ ^ ^ ^ ^ ^ ^ | | | | +----------|---------|-----------------+ | | | | | | | | | | +---------------------------|-----+ | | | | +---------------------------------+ | | | | | +-------------------------------+ | | | | | | | | | | | | | +-----------------------------|----------|----+ | | | | | +------------------|----------|----------|---+ | +--|----+ +--|----+ +--|----+ | | | | | first | | first | | first | +--|----+ +--|----+ +--|----+ +--|----+ ++ | rest --->| rest --->| rest ----------->| first | | first | | first | | first | || +-------+ +-------+ +-------+ | rest --->| rest --->| rest -------------->| rest --->|| ^ +-------+ +-------+ +-------+ +-------+ ++ | ^ friends | work
There is no way to avoid this problem, except by avoiding unintended aliasing in mutable data structures. (For immutable data structures, sharing like this is perfectly fine, because there will never be a modification that could expose the sharing.) When constructing values of mutable data types, pay extra attention to whether objects are being reused, and whether that reuse could become problematic later in the design of the program.
20.4 Removing from a list, part 2: removing the first item
// In ConsLoPerson void removePerson(String name) { return this.rest.removePersonHelp(name, this); }
work.removePersonHelp("Bob", work)
If the problem with removing the first item of the list is that it’s the first item of the list, and therefore there is no reference to it from some previous cell, then the only way forward is to make it not be the first item of the list!
20.5 Revising our data structure: Introducing sentinels
+------+ +------+ +-------+ | Anne | | Gail | | Henry | | 1234 | | 9345 | | 8602 | +------+ +------+ +-------+ ^ ^ ^ +--|----+ +--|----+ +--|----+ ++ | first | | first | | first | || | rest --->| rest --->| rest --->|| +-------+ +-------+ +-------+ ++ ^ | friends
+------+ +------+ +-------+ | Anne | | Gail | | Henry | | 1234 | | 9345 | | 8602 | +------+ +------+ +-------+ ^ ^ ^ +-------+ +--|----+ +--|----+ +--|----+ ++ | rest --->| first | | first | | first | || +-------+ | rest --->| rest --->| rest --->|| ^ +-------+ +-------+ +-------+ ++ | friends
This particular technique of introducing an extra object between what we have (the variable friends) and what we want (the data in the list) is called adding a layer of indirection. In courses on algorithms and data structures, you will see many, many more examples of using indirection to solve problems that seem difficult or impossible, otherwise.
Do Now!
Before adding the sentinel node, we could access the first data item of a non-empty list using theList.first. Now that we have a sentinel node, how can we access the first item of the list?
How can we define this Sentinel class, and how can we use it? At the very least, it must have a rest field capable of containing a ConsLoPerson, or else we couldn’t place it at the front of the list. And Sentinel and ConsLoPerson must share a common base class, because the node before a ConsLoPerson could now be either a ConsLoPerson or a Sentinel, but either way, removePersonHelp needs a way to access the rest field of such an object.
+------------------------------------------------+ | ILoPerson | +------------------------------------------------+ | void removePerson(String name) | | void removePersonHelp(String name, ANode prev) | +------------------------------------------------+ /_\ /_\ | | +------------+ | | ANode | | +------------+ | | ANode rest | | +------------+ | /_\ /_\ | | | | +--------+ | +--------+ | | | +----------+ +--------------+ +------------+ | Sentinel | | ConsLoPerson | | MtLoPerson | +----------+ +--------------+ +------------+ +----------+ | Person data | +------------+ +--------------+
Worse, users of this data type will have to create Sentinels instead of
just creating ConsLoPersons and MtLoPersons—
So now we have a data type that we can manipulate correctly, but that doesn’t implement the interface the way we want it, and that isn’t pleasant to use!
20.6 Revising our data structure: introducing wrappers
If the problem is that we want to ensure that users of our data structure can’t mistakenly forget to create a Sentinel, perhaps we should create another class that manages the Sentinel for them. We call this a wrapper class, since it wraps around the fiddly details of managing Sentinels and ConsLoPersons and such. A user merely has to create an instance of this wrapper class, and can then ignore the implementation details inside it.
We’re introducing another extra object between what we have to manipulate (the Sentinel object) and what we want to manipulate (the list overall). This is adding another layer of indirection. Looks like we’re seeing another example of this much sooner than we thought!
+---------------------------------------+ | MutablePersonList | +---------------------------------------+ | void removePerson(String name) | | void addPerson(String name, int num) | +-------- Sentinel sentinel | | +---------------------------------------+ | | +------------------------------------------------+ | | APersonList | | +------------------------------------------------+ | | void removePersonHelp(String name, ANode prev) | | +------------------------------------------------+ | /_\ /_\ | | | | +------------------+ | | | ANode | | | +------------------+ | | | APersonList rest | | | +------------------+ | | /_\ /_\ | | | | | | +--------+ | | V | | | +----------+ +--------------+ +------------+ | Sentinel | | ConsLoPerson | | MtLoPerson | +----------+ +--------------+ +------------+ +----------+ | Person data | +------------+ +--------------+
Our users of this data type will construct a new MutablePersonList(). Given that object, they can invoke addPerson to add new contacts by name and number. And again given that object, they can invoke removePerson to remove a person by name. We will implement removePerson to delegate to removePersonHelp in such a way that we will always take care of the Sentinel properly. Let’s see how it all works.
20.6.1 Implementing the nodes of the list
// Represents a sentinel at the start, a node in the middle, // or the empty end of a list class APersonList { abstract void removePersonHelp(String name, ANode prev); APersonList() { } // nothing to do here }
// Represents a node in a list that has some list after it class ANode extends APersonList { APersonList rest; ANode(APersonList rest) { this.rest = rest; } }
// Represents the empty end of the list class MtLoPerson extends APersonList { MtLoPerson() { } // nothing to do void removePersonHelp(String name, ANode prev) { return; } }
// Represents a data node in the list class ConsLoPerson extends ANode { Person data; ConsLoPerson(Person data, APersonList rest) { super(rest); this.data = data; } void removePersonHelp(String name, ANode prev) { if (this.first.name.equals(name)) { prev.rest = this.rest; } else { this.rest.removePersonHelp(name, this); } } }
// Represents the dummy node before the first actual node of the list class Sentinel extends ANode { Sentinel(APersonList rest) { super(rest); } void removePersonHelp(String name, ANode prev) { throw new RuntimeException("Can't try to remove on a Sentinel!"); } }
20.6.2 Implementing MutablePersonList itself
class MutablePersonList { Sentinel sentinel; MutablePersonList() { this.sentinel = new Sentinel(new MtLoPerson()); } }
// In MutablePersonList void removePerson(String name) { this.sentinel.rest.removePersonHelp(name, this.sentinel); }
(It’s basically ok to use the field-of-field access pattern here, because we know exactly how this.sentinel works, since the MutablePersonList class is the only place that we manipulate sentinels.)
Exercise
Try implementing addPerson that inserts a person with the given name and number onto the front of the list. How does having a sentinel help here?
20.7 Discussion
Removing an item from a list is a surprisingly tricky operation. There was only one case that our initial, simple attempt couldn’t handle: when the item to be removed was the first item of the list. But handling that case gracefully is hard: we were forced to add a sentinel node to “make the first node not be first”, and then were driven to add a wrapper around that, to hide the details that were getting somewhat messy. But now that it’s done, we have a data structure that’s rather elegant: the methods available on it are precisely the ones we want, and none of the helper methods or helper classes are visible to users.
Exercise
Try eliminating the Sentinel class, while maintaining the method signatures for MutablePersonList exactly as they currently are.
20.8 Generalizing from MutablePersonLists to mutable lists of arbitrary data
interface IMutableList<T> { // adds an item to the (front of) the list void add(T t); // removes an item from list void remove(T t); }
Do Now!
Which notion of equality—extensional or intensional— could we possibly use here that works on values of arbitrary type T?
interface IMutableList<T> { // adds an item to the (front of) the list void addToFront(T t); // adds an item to the end of the list void addToEnd(T t); // removes an item from list (uses intensional equality) void remove(T t); // removes the first item from the list that passes the predicate void remove(IPred<T> whichOne); // gets the numbered item (starting at index 0) of the list T get(int index); // sets (i.e. replaces) the numbered item (starting at index 0) with the given item void set(int index, T t); // inserts the given item at the numbered position void insert(int index, T t); // returns the length of the list int size(); }
As it turns out, we will not have to implement this interface ourselves. Java defines several classes that implement (roughly) this interface for us. In the next lecture, we’ll start to see how they work, and see how the index-based methods drive us towards yet another language feature in Java.