Lecture 2: Methods for simple classes and exceptions
Overview
Writing simple methods for classes
Methods for classes with containment
Methods that return objects
Examples and Testing with JUnit
Dealing with exceptions
In the last lecture, we created simple classes to represent persons and books. However our data has been utterly inert, and we have not defined any computations that work with our data. In this lecture, we’ll define methods, which are the object-oriented analogue of the functions we’ve seen before (though they are subtly and importantly different!) and build up to methods that consume and produce additional objects.
2.1 How to begin writing methods
2.1.1 Signature and purpose
Every method definition consists of the following parts:
A purpose statement. This consists of a single sentence that describes the purpose of this method. A longer description of the method may follow.
The type of the value returned from the method, known as the return type
The method name, where the standard naming convention starts with a lowercase letter and uses “camelCase” to distinguish words within the name
A parenthesized argument list, consisting of the type and name of each argument, separated by commas
The method body, surrounded by braces; this is the code to execute when the method is invoked
2.2 Example 1: an object in String form
It is often handy to have a string representation of an object. This string may be used to print the contents of the object in a simple way, or used for other purposes. We will write a method called toString for this purpose.
2.2.1 Method signature
How should we define the method toString? What should its signature be? We know it needs information about the fields of the class, but nothing else. For the Person class we need the string to contain only the first and the last name as a single sentence (e.g. "Tom Cruise"). Thus it needs to know the first name and last name, nothing else. Recall the change in metaphor for Java methods: we are asking the object to operate on itself, not a function that is external to the data. Thus we mean to say “Person, convert yourself into a string and return it”. Since every method inside an object has access to this (the object used to call the method), we have all the data for the person that we need. Lastly this method should return the string. Thus our signature will look like this:
/** Returns a string representation of this person with first and last name @return a formatted string */ public String toString() { ... }
2.2.2 What can we use?
It is helpful to make a list of all the variables we can use inside this method, as well as all the methods that we can call from inside this method. Such a list is a handy reference when we implement the method (think of it as “making a specific dish using only these ingredients”). When writing a method, we can:
Access the parameters of this method
Access any attributes (instance variables) declared in the class that contains this method (using this)
Call any other methods within the class that contains this method
Call any methods of objects accessible in the first two steps, provided they have the appropriate access modifiers (public, etc.)
We explicitly make such a list in comments as a handy reference (this is more for learning purposes: normally this list is not part of our documentation/comments). For the toString method this list is as follows:
/* Fields: this.firstName: String this.lastName: String this.yearOfBirth: int */
Note that we do not include any methods that we have not already defined, because we cannot rely on them yet.
2.2.3 Method Body
/** Returns a string representation of this person with first and last name @return a formatted string */ public String toString() { return "" + firstName + " " + lastName; }
Do Now!
Why is this method public?
2.2.4 Testing
How do we test this method? We must create a person with a specific first and last name, call its toString method and compare the String returned by it with the expected string.
The following JUnit test class shows such a test:
import org.junit.Before; import org.junit.Test; import static org.junit.Assert.*; /** * JUnit test class for books */ public class BookTest { private Person pat; private Book beaches; @Before public void setUp() { pat = new Person("Pat","Conroy", 1948); // example of books beaches = new Book("Beaches", this.pat, 20); } @Test public void testPersonString() { assertEquals("Pat Conroy",pat.toString()); } }
2.2.5 toString for the Book class
We want the toString method of the Book class to return a string that contains its title, author (first and last name) and the price, one on each line. Since the price must be a monetary amount we want it to be formatted accordingly (two decimal places).
The signature of the toString method will remain the same as that of the Person class (why?). Let us write the template.
/* Fields: this.title: String this.author: Person this.price: float */
The title is simple: it is already a string.
The author must be available as a single “first-name last-name”. Although we can create such a string using the Person object’s getter methods, we can reuse the toString method we wrote for the Person class since it returns exactly the string we want!
Getting the price is simple, but we must format it correctly.
We must determine a way to put the above three pieces of information in separate lines.
The method can be implemented as follows:
public String toString() { String str; str = "Title: " + this.title + "\n" + "Author: " + this.author.toString() + "\n"; str = str + String.format("Price: %.2f", price); return str; }
The method signature public String toString() is chosen deliberately, because it has a special meaning in Java. Every Java class is guaranteed to have a method with this exact signature in it, and so we have just “redefined” it above for the Person and Book classes. Whenever Java needs to convert an object to a String, it uses its (guaranteed to be available) toString method. You can see this in action: in the method body of the toString for the Book class above, change this.author.toString() to simply this.author. It is surprising that although this.author is a Person object, Java is OK with adding it to a string. This is because it is using its toString method automatically to convert!
The String class has a method format that takes a string that explains the required format. This string has “placeholders” that are replaced by the actual parameters passed to it in order. For example above we require the price (a decimal number) to be formatted such that there should be exactly two numbers after the decimal point. The way to say this is “%.2f”. %f normally means “placeholder for a decimal number”, and “%.2f” means “placeholder for a decimal number that should be formatted with 2 numbers after the decimal point”. Notice also how this method is called by using the name of the class String instead of a String object. This is a static method, and we will see this in more detail later.
A Java String can be created by adding two strings using the + operator, just like numbers.
When we want a line break in a string, we insert the character “\n” at the appropriate place.
Here is how we can test this method:
@Test public void testBookString() { String expected; expected = "Title: Beaches\nAuthor: Pat Conroy\n" + "Price: 20.00"; assertEquals(expected,beaches.toString()); }
2.3 Example 2: the discounted price of a book
As is often the case a book is offered at a discount, expressed as a percentage of the price of the book. We must now write a method salePrice that computes and returns the discounted price of a book.
2.3.1 Method signature
How should we define the method salePrice? What should its signature be? We know it needs a discount rate, and it should operate on a book. Recall the change in metaphor for Java methods: we are asking the object to operate on itself, not a function that is external to the data. Thus we mean to say “Book, compute your discounted price and return it”. Since every method inside an object has access to this (the object used to call the method), we have all the data for the book that we need. Lastly this method should return the discounted price. Thus our signature will look like this:
/** * Compute and return the price of this book with the given discount (as a * percentage) * * @param discount the percentage discount to be applied * @return the discounted price of this book */ public float salePrice(float discount) { ... }
2.3.2 What can we use?
/** * Compute and return the price of this book with the given discount (as a * percentage) * * @param discount the percentage discount to be applied * @return the discounted price of this book */ public float salePrice(float discount) { /* Fields: this.title: String this.author: Person this.price: float Methods: this.toString(): String Parameters: discount: float */ ... }
2.3.3 Method Body
Now that the preparatory work is done, defining the method is fairly straightforward.
/** * Compute and return the price of this book with the given discount (as a * percentage) * * @param discount the percentage discount to be applied * @return the discounted price of this book */ public float salePrice(float discount) { return this.price - (this.price * discount) / 100; }
Do Now!
Why is this method public?
2.3.4 Testing
How do we test this method? We must create a book with a specific price, then call its salePrice method passing it a specific discount, and then verify that the discounted price returned by it matches our expected discounted price.
The following JUnit test class shows such a test:
import org.junit.Before; import org.junit.Test; import static org.junit.Assert.*; /** * JUnit test class for books */ public class BookTest { private Person pat; private Book beaches; @Before public void setUp() { pat = new Person("Pat","Conroy", 1948); // example of books beaches = new Book("Beaches", this.pat, 20); } @Test public void testDiscount() { float discountedPrice = beaches.salePrice(20); assertEquals(16.0f,discountedPrice,0.01); } }
In this test class, we first create the object for the author, and then use it to create the Book object. In the testDiscount test method we store the result returned by the salePrice method for a 20% discount and verify that, for the original price of 20, the discounted price is 16. This verification is done by the assertEquals method. Interestingly this method takes 3 parameters. This is because it is unwise to compare two decimal numbers directly due to precision errors (15.9999999 is not equal to 16). So this version checks if the expected value (first parameter) is equal to the actual value (second parameter) within the error threshold passed as the third parameter.
2.4 Example 3: Methods with objects as arguments
Given two books, we would like to determine if they have the same authors. What does “same author” mean? For our purpose, two authors are the same if they have the same name and the same year of birth.
2.4.1 Method signature
We can envision a method sameAuthor. Since we are going to compare two books, we need two book objects. Should they be arguments to this method? Let us briefly imagine how we would like to use this method:
Book book1 = new Book(...); Book book2 = new Book(...); //verify if book1 and book2 have the same authors //option 1: book1.sameAuthor(book1,book2);
It may seem tempting to infer that since sameAuthor must work with two Book objects, it must have two Book objects as arguments. However since we are writing this method in the Book class itself, we need to call it using a Book object. It seems odd that book1 shows up twice on that line. Recall that the method will have access to this, the object used to call that method. Thus we need to pass it only the other Book object, not both.
Book book1 = new Book(...); Book book2 = new Book(...); //verify if book1 and book2 have the same authors //option 2: book1.sameAuthor(book2);
We can read this as “book1, check if you have the same author as book2”. Notice again how we have made this verification an operation of the book, not an external function on two books.
Thus the signature of this method can be:
/** * check if this book has the same author as another * and return true if so, false otherwise * @param other the other book * @return true if the two books have the same author, false otherwise */ public boolean sameAuthor(Book other) { ... }
2.4.2 What can we use?
The list of things we can use is:
/** * check if this book has the same author as another * and return true if so, false otherwise * @param other the other book * @return true if the two books have the same author, false otherwise */ public boolean sameAuthor(Book other) { /* Fields: this.title: String this.price: float this.author: Person Methods: salePrice(float): float toString: String Parameters: other: Book Fields of parameters: other.title: String other.author: Person other.price: float Methods of parameters: other.getTitle(): String other.getAuthor(): Person other.getPrice(): float other.salePrice(float): float */ }
2.4.3 Method body
We need only the authors of the two books to complete this method. Recall what it means for two authors to be “the same”: their names and their years of birth should be the same.
We may implement this method as follows: get the two Person objects for the two authors, and then compare their first and last names and their years of birth through the getter methods, all in this sameAuthor method. While it may work, is it appropriate?
When we re-read the above paragraph, we notice that comparing two authors requires data only from the respective Person objects. Thus this comparison can be performed fully within the Person class itself, as a method! The sameAuthor method in the Book class can then simply use it as “author 1, check if you are the same as author 2” without worrying about the details of that “sameness”. In other words, it can delegate computing sameness to the Person class.
//Person.java /** * check if this person is the same * as the person in the argument. * two persons are the same iff they * have the same first and last names * and the same years of birth * @param other the other person to be compared to * @return true if this person is the same as other, false otherwise */ public boolean same(Person other) { return this.firstName.equals(other.firstName) && this.lastName.equals(other.lastName) && this.yearOfBirth == other.yearOfBirth; } //Book.java /** * check if this book has the same author as another * and return true if so, false otherwise * @param other the other book * @return true if the two books have the same author, false otherwise */ public boolean sameAuthor(Book other) { /* TEMPLATE: fields: this.title: String this.price: float this.author: Person methods: salePrice(float): float toString(): String parameters: other: Book methods of parameters: other.getTitle(): String other.getAuthor(): Person other.getPrice(): float other.salePrice(float): float */ //get the two authors and delegate return this.author.same(other.author); }
Two aspects that may be new:
The String class offers a method equals that allows us to compare two String objects. Read about it in the Java 8 documentation
The && are the logical AND operators.
2.4.4 Testing
We wrote two new methods: so we must test both of them accordingly.
same(Person other): This method can return one of two possible answers: true or false. There are several ways in which two authors may not be the same: different first names, different last names and/or different years of birth. Therefore we need several cases to test for correctness:
//PersonTest.java @Test public void testSamePerson() { Person benlerner = new Person("Ben","Lerner",1982); Person benaffleck = new Person("Ben","Affleck",1982); Person timlerner = new Person("Tim","Lerner",1982); Person anotherbenlerner = new Person("Ben","Lerner",1983); Person identicaltwin = new Person("Ben","Lerner",1982); assertFalse(benlerner.same(benaffleck)); assertFalse(benlerner.same(timlerner)); assertFalse(benlerner.same(anotherbenlerner)); assertTrue(benlerner.same(identicaltwin)); }
Do Now!
Articulate in one sentence each what assertTrue and assertFalse do.
sameAuthor(Book other): This method can return one of two possible answers: true or false. As above, there are several ways in which two books may not have the same authors. But all of these cases have been tested above already! Thus if we can assume that the above test passes, we can write a simple test for this method.
@Test public void testSameAuthors() { Person anotherauthor = new Person("Pat", "Conroy II", 1948); Book pirateBeaches = new Book("Beaches", anotherauthor, 1948); Book sequel = new Book("Some more beaches",this.pat,1952); assertTrue(beaches.sameAuthor(sequel)); assertFalse(beaches.sameAuthor(pirateBeaches)); }
2.5 Example 4: Methods that return objects
Instead of merely computing the discounted price, suppose we wanted the result as an actual Book object whose details are the same as the original book, except its price is the discounted price. This situation may arise if we are keeping track of the books we sell, and therefore need records of actual books.
2.5.1 Method signature
We can envision a method discountBook. This method, as with salePrice would need the percentage discount. However we want it to return a Book object instead of just the discounted price. Thus its method signature would be:
/** * Compute the sale price of this Book given using * the given discount rate (as a percentage) and * return a version of this book with the discounted price * @param discount the percentage discount to be applied to this book * @return the new book that is identical to this book except the price is * discounted */ // public Book discountBook(float discount) { ... }
2.5.2 What can we use?
The list of things we can use is:
/** * Compute the sale price of this Book given using * the given discount rate (as a percentage) and * return a version of this book with the discounted price * @param discount the percentage discount to be applied to this book * @return the new book that is identical to this book except the price is * discounted */ // public Book discountBook(float discount) { /* Fields: this.title: String this.author: Person this.price: float Parameters: discount: float Constructor: Book(String,Person,float) Methods: this.salePrice(float): float this.toSring(): String this.sameAuthor(Book): boolean */ }
2.5.3 Method body
We already know how to compute the discounted price. We must then create and return a new Book object with the same title and author as this one, but with the discounted price.
/** * Compute the sale price of this Book given using * the given discount rate (as a percentage) and * return a version of this book with the discounted price * @param discount the percentage discount to be applied to this book * @return the new book that is identical to this book except the price is * discounted */ // public Book discountBook(float discount) { /* Fields: this.title: String this.author: Person this.price: float constructor: Book(String,Person,float) methods: this.salePrice(float): float this.toString(): String this.sameAuthor(Book): boolean */ float discountedPrice = this.salePrice(discount); return new Book(this.title, this.author, discountedPrice); }
Notice that we used the method salePrice to calculate the discounted price, instead of replicating the math. While replicating would not have been difficult, it is unadvisable. Whenever possible avoid replication of code (replicate code and thou shall replicate errors!).
2.5.4 Testing
How do we test the discountBook method? We would have to verify that the book returned by this method has the same title and author as the original book, but the discounted price.
@Test public void testDiscountBook() { Book discountedBook = beaches.discountBook(20); //verify that both books have the same author assertTrue(beaches.sameAuthor(discountedBook)); //verify that both books have the same title assertEquals(beaches.getTitle(),discountedBook.getTitle()); //verify the discounted price of the new book assertEquals(16,discountedBook.getPrice(),0.01); }
Note how assertEquals is used to compare titles. Titles are String, so how does assertEquals compare them? It uses the equals method of the String class that we used before. The equals method is special: each Java class has guaranteed to have one (just like toString above). Thus assertEquals is capable of working with any Java objects: whether it will do what is expected depends on what the relevant equals method does.
How does Java pull off this magic of guaranteeing that every class has methods called equals and toString? We shall see later.
2.6 Writing for unexpected situations
Let us look at the salePrice(float) method again. What would happen if we used it as follows:
Book beaches = new Book(...); float discountPrice = beaches.salePrice(-10);
Looking at our math in this method it would return us a negative discounted price. However a negative percentage discount does not make sense in the given context. Java was not able to catch this error because -10 is a valid number. Ideally this method should inform its caller “thou shall not pass me a negative number for the discount”, instead of using the number and returning an answer that is invalid. Exceptions allow us to do that.
An exception occurs when something unexpected happens, whether it be invalid input, an operation that cannot be completed (e.g. the square root of a negative number) or even something that is beyond our control (e.g. attempting to read from a file that no longer exists). Exceptions offer us a dignified way of aborting a method and sending a message to its caller that something went wrong.
2.6.1 Writing a method with exceptions
In the salePrice method, an exception should occur if a negative number is passed as the percentage discount. We can change the method to the following:
/** * Compute and return the price of this book with the given discount (as a * percentage) * * @param discount the percentage discount to be applied * @return the discounted price of this book * @throws IllegalArgumentException if a negative discount is passed as an * argument */ public float salePrice(float discount) throws IllegalArgumentException { /* this.price: float this.author: Person this.title: String Parameters: discount: float */ if (discount<0) { throw new IllegalArgumentException("Discount cannot be negative"); } return this.price - (this.price * discount) / 100; }
Java has many kinds of exceptions. Since our problem here is an invalid argument, we use the IllegalArgumentException.
The method signature explicitly declares that it may throw an IllegalArgumentException. A method can throw multiple types of exceptions, declared in its signature separated by commas.
The Javadoc-style comments document this possibility.
Before using the parameter discount we check if it is negative and if so, we throw an exception. This involves creating an IllegalArgumentException with a helpful message in it and throwing it.
If the argument discount is a positive number, the method does not throw an exception and returns the discounted price, as before.
If the argument discount is a negative number, the method would abort on the line throw new IllegalArgumentException(...);. It will not return anything.
2.6.2 Calling methods that may throw exceptions
Let us test this method, specifically by passing it a negative discount. Whenever a method is called that may throw one or more exceptions we can enclose it in a try-catch block as follows:
try { book.salePrice(-10); } catch (IllegalArgumentException e) { //This will be executed only if an IllegalArgumentException is thrown by the above method call }
Thus we try to call such a method, and if an exception is thrown we catch it. If no exception is thrown then the catch block is ignored.
2.6.3 Testing methods with exceptions
We can test whether a method throws exceptions when expected using the try-catch blocks as above
@Test public void testIllegalDiscount() { float discountedPrice; try { discountedPrice = beaches.salePrice(20); assertEquals(16.0f,discountedPrice,0.01); } catch (IllegalArgumentException e) { fail("An exception should not have been thrown"); } try { discountedPrice = beaches.salePrice(-20); fail("An exception should have been thrown"); } catch (IllegalArgumentException e) { } }
We expect the first method call beaches.salePrice(20); to work correctly. If an exception is thrown the catch block will fail this test case. If the method returns a value without throwing an exception then the following assertEquals(16.0f,discountedPrice,0.01); checks if the discounted price is correct (as before) and the catch block is ignored.
We expect the second method call beaches.salePrice(-20); to throw an exception. If an exception was indeed thrown then the try block will abort and the statement fail("An exception should have been thrown"); will not be executed. Catching the (expected) exception allows this test to pass. If an exception was not thrown then the fail("An exception should have been thrown"); fails the test case.
JUnit allows us to test for exceptions in a more concise way. We must divide the above test case into two.
@Test public void testDiscount() { float discountedPrice = beaches.salePrice(20); assertEquals(16.0f, discountedPrice, 0.01); } @Test(expected = IllegalArgumentException.class) public void testIllegalDiscount() { float discountedPrice; discountedPrice = beaches.salePrice(-20); }
The first test is as before, checking if the salePrice method returns the correct discounted price, if a valid discount is passed to it. The second test has this annotation: @Test(expected = IllegalArgumentException.class). This declares that this test expects the IllegalArgumentException to be thrown, and thus will abort and pass when it encounters this exception for the first time during its execution. If this method ends without an IllegalArgumentException thrown even once, this test fails.
2.6.4 Using exceptions in general
Java has several inbuilt exception types, which are all classes. Exceptions provide us with a way to handle errors. Follow this procedure when you write a new method:
Write the purpose statement
Write a method signature
Using the template write the method body so that it works correctly under ideal circumstances.
Carefully think about all situations that can occur, other than ideal circumstances. This includes possibly invalid inputs, invalid computations or results. These are possible errors.
- For each error:
If there is a way to prevent the error from happening, do it. This is the best kind of error handling. Else go to the next step.
If there is a way to recover from the error in this method itself, do it. There is no need to use exceptions as the recovery happens in the same method as the error. Else go to the next step.
Choose an appropriate exception type for the error. Declare that this method throws this exception in its signature, and throw this exception appropriately.
Do Now!
Look at all the other methods in the Book and Person classes, and see if there is need to use exceptions. Even constructors can throw exceptions.