Lecture 18: Mutation inside structures
Designing methods to modify objects, testing mutation methods, and indirect cycles
In the last lecture, we successfully constructed a Book and an Author that referred to each other directly, creating a cyclic data structure. But we only managed to construct that cycle in the examples class, which isn’t particularly useful if we want to use these cyclic data structures anywhere else in our program. In this lecture, we’ll see a better way to construct these cycles that is more reusable, more testable, and less error-prone.
18.1 Keeping mutation contained in the class where it matters
// In ExamplesClass boolean testBookAuthorCycle(Tester t) { Author knuth = new Author("Donald", "Knuth", 1938, null); Book taocp = new Book("The Art of Computer Programming (volume 1)", 100, 2, knuth); knuth.book = taocp; return t.checkExpect(knuth.book.author, knuth); }
class ExampleBooks { Author knuth = new Author("Donald", "Knuth", 1938, null); // no books yet Book taocp1 = new Book("The Art of Computer Programming (volume 1)", 100, 2, knuth); Book taocp2 = new Book("The Art of Computer Programming (volume 2)", 100, 2, knuth); boolean testTaocp1(Tester t) { ... } boolean testTaocp2(Tester t) { ... } }
// In ExampleBooks boolean testTaocp1(Tester t) { this.knuth.book = this.taocp1; return t.checkExpect(this.knuth.book.author, this.knuth); } boolean testTaocp2(Tester t) { this.knuth.book = this.taocp2; return t.checkExpect(this.knuth.book.author, this.knuth); }
The first question we have to address is, what should this helper method do? We want it to modify the Author to update its book field. Second, where should this method be defined? It isn’t really a method that belongs in the examples class; it applies to Authors and we want to be able to use it throughout our program, so the Author class seems the most appropriate.
Third, what value should this method return? We have the Author object we want to modify.
We know what Book we want to use. Unlike every single method we have defined so far,
we really don’t need this method to construct a new value for us. All we want it to do is
to have the side effect of modifying the Author —
// In Author // EFFECT: modifies this Author's book field to refer to the given Book void updateBook(Book b) { this.book = b; }
// In ExampleBooks boolean testTaocp1(Tester t) { this.knuth.updateBook(this.taocp1); return t.checkExpect(this.knuth.book.author, this.knuth); } boolean testTaocp2(Tester t) { this.knuth.updateBook(this.taocp2); return t.checkExpect(this.knuth.book.author, this.knuth); }
Notice that the method invocation this.knuth.updateBook(this.taocp1) is just standing by itself on a
line of its own. We are treating this method call as a statement, something that runs
and computes no value —
These tests are now much better-written code, in that we know (by looking at the effect statement for updateBook) what we expect the tests to accomplish. But are these tests good enough?
18.2 Testing methods with side effects: test fixtures
The tests above only check that after calling updateBook, there is a cycle from the author to its book back to the author. But how do we know that the cycle was actually created by updateBook? Perhaps the cycle was already there (created at some earlier point in our program’s execution), and updateBook actually did nothing? How can we be certain that the side effect we wanted to happen actually did happen?
First, ensure the initial conditions of the test are in a known state,
Second, run the code being tested and let it modify those initial conditions, and
Last, test that the expected changes to the state of the program have occurred.
// In ExampleBooks boolean testTaocp1(Tester t) { // 1. Check that the initial conditions are as expected boolean initialConditions = t.checkExpect(this.knuth.book, null); // 2. Modify them this.knuth.updateBook(this.taocp1); // 3. Check that the expected changes have occurred boolean finalConditions = t.checkExpect(this.knuth.book, this.taocp1) && t.checkExpect(this.knuth.book.author, this.knuth); return initialConditions && finalConditions; }
It’s still not thorough enough: to really be complete, we ought to test that nothing else in the program has changed. That’s impractical, though, so we use our judgement and the purpose and effect statements of the code being tested to guide us in writing effective tests.
// In ExampleBooks boolean testTaocp2(Tester t) { // 1. Check that the initial conditions are as expected boolean initialConditions = t.checkExpect(this.knuth.book, null); // 2. Modify them this.knuth.updateBook(this.taocp2); // 3. Check that the expected changes have occurred boolean finalConditions = t.checkExpect(this.knuth.book, this.taocp2) && t.checkExpect(this.knuth.book.author, this.knuth); return initialConditions && finalConditions; }
Bizarrely, if we run these tests in IntelliJ it reports test failures. Even weirder, if we run the tests repeatedly, it might report different failures each time! What’s happening?
The tester library creates an instance of our examples class, and then runs our test methods one after another. When the examples instance is created, our code creates the Author object and the two Book objects. Suppose testTaocp1 runs first. It checks that knuth.book is null (which it is), then updates it and checks that the update happened correctly (which it does). Then testTaocp2 runs. When it checks the initial conditions, we see that knuth.book is not null: it’s taocp1, just as we set it in the previous test method! Moreover, because the tester library (deliberately) does not guarantee what order it runs our test methods in, we might see that testTaocp2 runs first, and so the test failure happens in testTaocp1.
class ExampleBooks { Author knuth; Book taocp1, taocp2; // EFFECT: Sets up the initial conditions for our tests, by re-initializing // knuth, taocp1 and taocp2 void initTestConditions() { this.knuth = new Author("Donald", "Knuth", 1938, null); this.taocp1 = new Book("The Art of Computer Programming (volume 1)", 100, 2, this.knuth); this.taocp2 = new Book("The Art of Computer Programming (volume 2)", 120, 3, this.knuth); } boolean testTaocp1(Tester t) { // 1. Set up the initial conditions this.initTestConditions(); // 2. Modify them this.knuth.updateBook(this.taocp1); // 3. Check that the expected changes have occurred return t.checkExpect(this.knuth.book, this.taocp1) && t.checkExpect(this.knuth.book.author, this.knuth); } boolean testTaocp2(Tester t) { // 1. Set up the initial conditions this.initTestConditions(); // 2. Modify them this.knuth.updateBook(this.taocp2); // 3. Check that the expected changes have occurred return t.checkExpect(this.knuth.book, this.taocp2) && t.checkExpect(this.knuth.book.author, this.knuth); } }
18.3 Subtleties of mutable data
Do Now!
How could we revise our data types to accommodate more prolific authors?
// In ExampleBooks boolean testTwoBooks(Tester t) { this.initTestConditions(); // Test 1: check that knuth hasn't written any books yet boolean test1 = t.checkExpect(this.knuth.book, null); // Modify knuth to know about volume 1 this.knuth.updateBook(this.taocp1); // Test 2: check that knuth's book was written by knuth boolean test2 = t.checkExpect(this.knuth.book.author, this.knuth); // Modify knuth to know about volume 2 this.knuth.updateBook(this.taocp2); // Test 3: check that knuth's new book was written by knuth boolean test3 = t.checkExpect(this.knuth.book.author, this.knuth); // Test 4: check that both books' authors wrote those books boolean test4 = t.checkExpect(this.taocp1.author.book, this.taocp1) && t.checkExpect(this.taocp2.author.book, this.taocp2); return test1 && test2 && test3 && test4; }
Do Now!
Which of these tests pass, and which of these tests fail?
// In Author // EFFECT: modifies this Author's book field to refer to the given Book void updateBook(Book b) { if (this.book != null) { throw new RuntimeException("trying to add second book to an author"); } else { this.book = b; } }
// In ExampleBooks boolean testBookAuthors(Tester t) { this.initTestConditions(); // Test 1: check that knuth hasn't written any books yet boolean test1 = t.checkExpect(this.knuth.book, null); // Modify knuth to refer to volume 1 this.knuth.updateBook(this.taocp1); // Test 2: check that knuth's book was written by knuth boolean test2 = t.checkExpect(this.knuth.book.author, this.knuth); // Try to modify knuth to refer to volume 2 this.knuth.updateBook(this.taocp2); // Crashes with an exception }
// In ExampleBooks boolean testBookAuthors(Tester t) { this.initTestConditions(); // Test 1: check that knuth hasn't written any books yet boolean test1 = t.checkExpect(this.knuth.book, null); // Modify knuth to refer to volume 1 this.knuth.updateBook(this.taocp1); // Test 2: check that knuth's book was written by knuth boolean test2 = t.checkExpect(this.knuth.book.author, this.knuth); // Test 3: check that modifying knuth to refer to volume 2 fails with exception boolean test3 = t.checkException( new RuntimeException("trying to add second book to an author"), this.knuth, "updateBook", this.taocp2); // Test 4: check that knuth has not been modified boolean test4 = t.checkExpect(this.knuth.book, this.taocp1); return test1 && test2 && test3 && test4; }
18.4 Interlude: Using void for test methods
All along, our test methods have been returning booleans, but we’ve never
actually been particularly interested in the actual return values —
// In ExampleBooks void testBookAuthors(Tester t) { this.initTestConditions(); // Test 1: check that knuth hasn't written any books yet t.checkExpect(this.knuth.book, null); // Modify knuth to refer to volume 1 this.knuth.updateBook(this.taocp1); // Test 2: check that knuth's book was written by knuth t.checkExpect(this.knuth.book.author, this.knuth); // Test 3: check that modifying knuth to refer to volume 2 fails with exception t.checkException( new RuntimeException("trying to add second book to an author"), this.knuth, "updateBook", this.taocp2); // Test 4: check that knuth has not been modified t.checkExpect(this.knuth.book, this.taocp1); }
18.5 More error handling: Books written by the wrong Authors
// In examples class void testBookAuthors(Tester t) { this.initTestConditions(); Author shakespeare = new Author("William", "Shakespeare", 1564, null); Book tcoe = new Book("The Comedy of Errors", 42, 1, shakespeare); // Test 1: check that neither knuth nor shakespear have written any books yet t.checkExpect(this.knuth.book, null); t.checkExpect(shakespeare.book, null); // Test 2: check that setting shakespeare's book to taocp fails t.checkException( new RuntimeException("book was not written by this author"), shakespeare, "updateBook", this.taocp1); }
Do Now!
Try to modify updateBook yourself to detect this mistake and throw the appropriate error.
// In Author // EFFECT: modifies this Author's book field to refer to the given Book void updateBook(Book b) { if (this.book != null) { throw new RuntimeException("trying to add second book to an author"); } else if (!b.author.sameAuthor(this)) { throw new RuntimeException("book was not written by this author"); } else { this.book = b; } }
Once again, defining helper methods has unexpected benefits: by being the one and only place in our code where we actually do the mutation, we therefore only have one place to fix if we decide to change how that mutation should be done.
18.6 Automatically creating the cycles
Do Now!
Revise the constructor for Author so that it does not take a Book parameter, but still initializes the book field to null.
class Book { String title; int price; int quantity; Author author; Book(String title, int price, int quantity, Author ath) { this.title = title; this.price = price; this.quantity = quantity; this.author = ath; // NEW! Fix up the author for us, using *this* newly-constructed Book this.author.updateBook(this); } }
Do Now!
If we change our constructor to include this improvement, what of our earlier example code breaks?
18.7 Indirect cycles
Do Now!
Revise the Author class to have an IList<Book> field instead of merely a single Book field. In your constructor, what value should you use instead of null to initialize this new books field?
class Author { String first; String last; int yob; IList<Book> books; Author(String fst, String lst, int yob) { this.first = fst; this.last = lst; this.yob = yob; this.books = new MtList<Book>(); } }
Do Now!
Revise updateBook to be a new method, addBook, that adds the new book to the list of books.
// In Author // EFFECT: modifies this Author's book field to refer to the given Book void addBook(Book b) { if (!b.author.sameAuthor(this)) { throw new RuntimeException("book was not written by this author"); } else { this.books = new ConsList<Book>(b, this.books); } }
Do Now!
At first glance, it might look like we are creating a cycle in the book list itself: it seems like we’re creating a new Cons whose first is the given book, and whose rest is this.books, which is a Cons whose first is the given book, and whose rest is this.books, ... What’s wrong with this reasoning?
We can now use addBook in the constructor of Book (instead of our now-defunct updateBook method), and now our initTestConditions method can successfully create taocp1 and taocp2.
Do Now!
Draw the object diagram that’s described above.
18.8 Discussion
Using mutation well can be a subtle and error-prone process. We’ve seen how how to define helper methods whose reason for existence is their side effects, i.e., they return nothing (a void return type) and mutate some object. Defining these methods then requires testing them, and testing methods with side effects requires test fixtures to provide consistent initial conditions for the test to be reliable. Finally, having our mutations abstracted away into these helper methods makes it far easier to revise our data definitions as our design requirements change.
Exercise
Design a data representation for your registrar’s office. Information about a course includes a department name (a String), a course number, an instructor, and an enrollment, which you should represent with a list of students. For a student, the registrar keeps track of the first and last name and the list of courses for which the student has enrolled. For an instructor, the registrar also keeps track of the first and last name as well as a list of currently assigned courses. Construct examples of at least three courses, at least two professors (one of whom teaches more than one course) and at least four students (at least one of whom is enrolled in more than one class).
In the next lecture, we’ll see some additional consequences of having mutation present as an operation in our language, and see that one concept we had thought we’d covered is in fact more subtle than we’d thought.