Updating Objects
So far, the methods we have written have all returned simple types like boolean or int. We haven’t written methods that return objects or that try to update the fields within objects. Seeing how such methods work is a useful precursor to understanding lists (and how they differ between Racket and Java).
1 An Example: Libraries and Library Books
Let’s create a class for library books, working our way up to developing classes for a library (as we get to lists). We start with a class for Books, each of which has a title, the call number (actually the string that you use to find the book on the shelves), the number of times it has been checked out before, and whether the book is currently available (as opposed to being checked out by someone). Here is the class before we add methods:
class Book { |
String title; |
String callNum; |
int timesOut; |
boolean isAvailable; |
|
Book(String title, String callNum, |
int timesOut, boolean isAvailable) { |
this.title = title; |
this.callNum = callNum; |
this.timesOut = timesOut; |
this.isAvailable = isAvailable; |
} |
} |
We’re going to build on this class as we address several issues.
2 Handling Fields with Fixed Initial Values
First, let’s set up some examples of Books (I’ve left out the constructor, to focus the presentation):
class Examples { |
Book javaBook = |
new Book("Effective Java", "QA76.73 J38 B57", 0, true); |
Book aliceBook = |
new Book("Alice in Wonderland", "PR4611 A7", 0, true); |
} |
If we think about it, any time we create a brand new book, it won’t have been checked out before and it is available (since nobody can have checked out a book that didn’t exist). It feels silly to have to include the same (fixed) values for the timesOut and isAvailable fields each time we make a book. Can’t we just fix these in the class, and have the constructor only take the fields that should be different?
Yes, we can adjust the constructor to only take values for the fields that need to be different. We can modify the constructor and the examples as follows:
class Book { |
String title; |
String callNum; |
int timesOut; |
boolean isAvailable; |
|
Book(String title, String callNum) { |
this.title = title; |
this.callNum = callNum; |
this.timesOut = 0; |
this.isAvailable = true; |
} |
} |
|
class Examples { |
Book javaBook = new Book("Effective Java", "QA76.73 J38 B57"); |
Book aliceBook = new Book("Alice in Wonderland", "PR4611 A7"); |
} |
Another way to do this is to provide initial values for the fields when you create them at the top of the class. If you use this approach, then the constructor can ignore the fields with fixed values completely:
class Book { |
String title; |
String callNum; |
int timesOut = 0; |
boolean isAvailable = true; |
|
Book(String title, String callNum) { |
this.title = title; |
this.callNum = callNum; |
} |
} |
Either of these two approaches is fine. There’s no particular reason to use one over the other.
Actually, a class can have multiple constructors. This is useful if you want to provide some fields some times but not others. So in the case of our Book class we could have two constructors as follows:
class Book { |
String title; |
String callNum; |
int timesOut = 0; |
boolean isAvailable = true; |
|
// constructor that takes just two inputs |
Book(String title, String callNum) { |
this.title = title; |
this.callNum = callNum; |
} |
|
// constructor that takes all four inputs |
// the constructor runs after initial values are |
// set in the field definitions, so any settings |
// in the constructor override those in the field definitions |
Book(String title, String callNum, |
int timesOut, boolean isAvailable) { |
this.title = title; |
this.callNum = callNum; |
this.timesOut = timesOut; |
this.isAvailable = isAvailable; |
} |
} |
If you have multiple constructors, they must have different input types. Java will use whichever type matches the inputs you provided to the constructor.
3 Methods that Return Objects
In a library, users sometimes request books that they find in the catalog (for the library to hold, or for them to be notified about when a desired book is returned). Let’s create a method for a request, then add a method to our book class. First, the request class:
// a class for requests for books by users |
class Request { |
Book forBook; |
int byCardNum; |
|
Request(Book forBook, int byUser) { |
this.forBook = forBook; |
this.byCardNum = byUser; |
} |
} |
Next, the method. We want a method called makeRequest that takes the library card number of the person who wants the book and returns a request for the book from that card number. (For conciseness in the notes, I’ve omitted the constructor.)
class Book { |
String title; |
String callNum; |
int timesOut; |
boolean isAvailable; |
|
// create request for book by a specific user |
Request makeRequest(int byCardNum){ |
return (new Request(this, byCardNum)); |
} |
} |
Note here that the return type of the method is Request – we can use the name of any class, abstract class or interface as the return type of a method (as a general rule, use the most flexible return type that you can, such as a class name over an interface name).
Inside the method, we simply use new to create the new object (using this as the book in the request). This example shows that you can use new inside a method if that method needs to return a new object.
Sanity check: what does the memory map look like if we run the following lines of code:
Book aliceBook = new Book("Alice in Wonderland", "PR4611 A7"); |
Request reqAlice1 = aliceBook.makeRequest(1435); |
You can find the map in slide 2 of this file.
4 Methods that Update Objects
Now, let’s write a method that checks out a book. When a book gets checked out, it becomes unavailable and the number of times it has been checked out increases by 1. Here’s a first cut at that method (this would go into the Book class):
Book checkOut() { |
return new Book(this.title, this.callNum, |
this.timesOut + 1, false); |
} |
Stop and Think: Is this a good way to write this method? Why or why not?
This approach yields a book with the right values in the fields, but it might not have the impact that we want. Consider the following sequence of statements:
Book aliceBook = new Book("Alice in Wonderland", "PR4611 A7"); |
aliceBook.checkOut(); |
aliceBook.timesOut |
What do you expect to see as a result here? We’d expect the number of times that aliceBook has been checked out to increase, but that doesn’t appear to have happened. Why not? Write out the memory map again – how many Book objects do we have with the title "Alice in Wonderland"? We have two objects, but the known name aliceBook refers to the original, not the new one. In this specific case, it would seem much better to have only one object for the "Alice in Wonderland" book, since there is only one physical copy of the book in the library (if the library had multiple copies, then multiple objects would make sense).
The checkOut method is an example of a computation that has history. We expect the number of times that the book has been checked out to change over time. If we want some information about an object to change over time, then we have to figure out how to update the field within a single object, rather than create a new object.
Here’s how the method looks instead if we want to update the field within the existing object:
// to check out a book, mark it unavailable and |
// increment the number of times it has been checked out |
Book checkOut() { |
this.isAvailable = false; |
this.timesOut = this.timesOut + 1; |
return this; |
} |
The = sign can be read as "changes to". So the first line within the method reads "this.isAvailable changes to false". Under the hood, Java literally replaces the old value of timesOut with the new one when you use =.
Now if we try our sequence of statements again, we see that timesOut did indeed change.
Book aliceBook = new Book("Alice in Wonderland", "PR4611 A7"); |
aliceBook.checkOut(); |
aliceBook.timesOut |
Sanity Check: Try drawing the memory maps for both versions of checkOut. Do you see the difference in the map? What differences do you notice?
You can find the maps in slides 3 and 4 of this file.
4.1 But Why Return the Object?
You might be wondering why we bother to return the object at all. If the original object gets updated, does returning the object give any advantage?
Here’s a version of checkOut that does NOT return the object, but makes the same internal change:
// to check out a book, mark it unavailable and |
// increment the number of times it has been checked out |
void checkOut() { |
this.isAvailable = false; |
this.timesOut = this.timesOut + 1; |
} |
Note that method now returns void. This signals that nothing gets returned, but instead the method just makes changes within existing data. Since the method doesn’t return any value, there is no return statement.
To see the downside to this approach, consider the following statements:
Book aliceBook = new Book("Alice in Wonderland", "PR4611 A7"); |
aliceBook.checkOut().timesOut; |
This yields a compiler error. Why? Since checkOut returns void, there is no object in which to find the timesOut field. So if you like chaining statements together, the previous version (that returns the Book object) is much more useful. As a general rule, returning the object tends to be useful, so it is a good practice to get into.
5 Summary
This lecture introduced you to the idea of returning objects from methods and updating values within objects. Updating values within objects makes sense when your computation has a history that needs to be preserved over multiple calls to a method. Not all computations have this property. We will come back to this point many times during the term. For now, you should understand three things:
If you want to change the value of a field, use =.
If you use =, the old value is no longer accessible.
If a method returns void, you can’t chain together multiple operations on it with a single expression.
You should also be comfortable with how the memory map changes depending on whether you create new objects or use =.
If updating is so useful, why did 1101 have you create new data all the time rather than update existing data? In 1101, most of the exercises did NOT require history. If a computation doesn’t require history, you are much safer creating new data. If a computation needs history, you have no choice but to update the existing data. We will see examples later in the course in which updating data that lacks history complicates programming. We therefore teach you to create new data in 1101, then teach you to recognize the need to update in late 1101 and 2102.