11. Additional Java Features
Over the past three lectures, we have surveyed three central features of object-oriented programming: encapsulated class design, subtype polymorphism, and inheritance. As a wrap-up for this module of the course, this lecture will highlight some miscellaneous Java features. First, we’ll talk about how Java handles exceptional behavior, with a particular focus on checked exceptions. Next, we’ll talk a bit more about the final keyword and immutability in Java. Finally, we’ll take a look at two methods of the Object class, with a focus on notions of equality in Java.
Exceptions
Sometimes when a method is executing, it may be unable to reach its post-condition due to exceptional circumstances.
- A calculation can fail to produce a result when an undefined mathematical operation is attempted, such as division by 0.
- Within a method, there may be code to invoke a method on a variable, but that variable may hold
nullinstead of an object reference. - The client may invoke a method to locate an object in a data structure, but such an object may not exist.
- A method that relies on some I/O (input/output) operations such as reading from a file may be denied access to the required I/O resources by the operating system due to missing permissions.
- Some user input processed by the application may have been provided in the incorrect format, making it impossible to parse (for example, they use an improper format when typing an email address).
In many cases, these issues are discovered at runtime, while a method is executing. Returning normally from the method would send a signal to the client that the post-conditions have been met, which is likely untrue in these cases. As an alternative, we’d like a way to signal to the client that something has gone awry and leave them with the responsibility for deciding how to proceed (for example, by trying an operation again, alerting the user to the issue and requesting a modified input, or perhaps crashing the program if there is no sensible way to recover). Java uses exceptions for this alternate signaling mechanism.
An exception is an object that signals that an atypical circumstance has arisen at runtime.
Suppose we execute the following buggy code (try to identify the issue).
ExceptionDemo1.java
|
|
|
|
We receive the output,
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0 at ExceptionDemo1.mean(ExceptionDemo1.java:11) at ExceptionDemo1.main(ExceptionDemo1.java:4)
This message informs us that a java.lang.ArrayIndexOutOfBoundsException, an object that models a type of runtime exception, was produced while executing line 11 of the method ExceptionDemo1.mean() (the initialization of sum), which we got to from line 4 of the main() method (the print() statement that calls mean()). The first line of this output tells us the type of exception with an explanatory message (“Index 0 out of bounds for length 0”), and the subsequent lines contain a stack trace, listing the line number and file of the line of code that was in the process of executing for each call frame on the runtime stack.
When an exception propagates out of the main() method of our program, a stack trace is printed in an error message that summarizes the state of the program's execution at the time when the exception was generated.
Stack traces are a useful debugging tool. They identify the exact location in our code where exceptional behavior was first encountered (the top line of the stack trace), and they provide the context for this exceptional behavior in the lower call frames. To understand how the stack trace is produced, we will need to explore different facets of Java’s exception handling framework. Broadly, this includes three components:
- A
throwstatement is written by a method’s implementer to produce an exception and alert the client to exceptional behavior. - A
throwsclause is included by the implementer in their method’s signature to document the possibility of exceptional behavior. - A
try/catchblock is included in the client’s code to prepare for the possibility of an exception.
Next, we’ll describe each of these components in detail.
throw statements
A throw statement is used to signal that exceptional behavior has occurred. This “signal” is packaged up in an object, specifically an instance of some subclass of Java’s Throwable class.
For an example of a throw statement, suppose we are writing a method that locates a local maximum in an int[] array.
|
|
|
|
In Lecture 3, we had this method return the sentinel value 0 when a local maximum was not found. An alternate (arguably more principled) approach is to treat this case with an exception. We’ll define a bespoke exception type called NoLocalMaxException that will alert this case.
NoLocalMaxException.java
|
|
|
|
While it may appear that we have omitted the body of this class, this is not the case. In this case, the state and behaviors that are provided by Java's Exception class (a subclass of Throwable) are sufficient for our purposes; inheritance takes care of the class definition for us! The only thing that we need from this class definition is a new type name, NoLocalMaxException, that helps us signal the exact exception situation that has arisen. We will see why this new type becomes useful when we discuss try/catch blocks soon.
Now, let’s develop the body of findLocalMax(). We can meet its specifications by writing a simple loop over the interior values of a (a(..)) and checking if each is a local max. If we find a local max, we can immediately return its index. In this way, exiting the loop signals the absence of a local max, at which point we can throw our NoLocalMaxException.
|
|
|
|
Within our throw statement, we construct a new Throwable object (in this case, a NoLocalMaxException), which has state that models the cause and location of the exception. As soon as a throw statement executes within a method, we immediately exit that method (just as we do when we encounter a return statement). Intuitively, a method throws an exception when it identifies that it is no longer able to perform its primary function; our findLocalMax() method definitely won’t find a local maximum once it knows that there isn’t one. At that point, its best course of action is to stop what it’s doing and pass control back to its client, who may have a better sense of how to proceed.
throws clauses
A throws clause provides a method a way to alert other classes to the possibility that it will throw an exception. To discuss the semantics of throws clauses, we need to take a brief detour to discuss a subtle distinction between two types of exceptional behavior, unchecked and checked. To do this, we’ll need to take a look at some classes in the Throwable type hierarchy:
Broadly, there are two categories of Throwables, Errors and Exceptions.
Errors model serious issues with little chance for recovery (such as AssertionErrors that model logical inconsistencies within the program and VirtualMachineErrors that model situations like running out of memory). We almost always want the program to crash when we encounter an Error.
Exceptions typically model less serious situations where recovery is possible. RuntimeExceptions model exceptional behavior that results because of particular values of variables at runtime, such as NullPointerExceptions (a reference turned out to be null), ClassCastExceptions (a variable referenced an object with an unexpected dynamic type), and ArithmeticException (our calculation included a value we don’t know how to handle).
We also typically don’t try to handle RuntimeExceptions, as doing so would be pretty burdensome (imagine having to write code to patch a faulty division by 0 each time you wanted to divide, or addressing the possibility of null each time that you called an instance method). Together, Errors and RuntimeExceptions are deemed unchecked, since the compiler allows them to arise without any warning. All other Exceptions that are not subtypes of RuntimeExceptions are checked exceptions, and must be addressed explicitly in our code.
Methods must explicitly alert their clients of the possibility that they will propagate a checked exception. This name comes from the fact that checked exception declarations are a code soundness property that is statically checked at compile time.
We do this “alerting” by adding a throws clause to the signature of any method that may propagate a checked exception, listing the type(s) of checked exceptions that are possible. In our findLocalMax(int[] a) method signature, we will need a throws clause to alert to the possibility of a NoLocalMaxException. We should also update the documentation with a “Throws” clause to specify when this exception will occur.
|
|
|
|
Here, we have been very careful in our choice of words when describing the semantics of a throws clause. It must be included when a checked exception can be "propagated" by a method, not just when it can be "thrown". Here, the word "propagate" refers to any time that an exception causes an "abnormal" return from a method. This includes the possibility of throwing a "fresh" exception, but also the possibility of not handling an exception that propagated out to this method, which will lead to its further propagation.
try/catch blocks
As a final component of the exception handling framework, we’ll need a way to respond to exceptions that have been thrown. We do this with a try/catch block:
|
|
|
|
Within the try block, we place the code (e.g., method calls) that may result in an exception. By surrounding code in a try block, we are telling Java to be ready for an incoming exception (I think about it as telling Java to put on its “baseball glove” to be ready for something to be thrown). When an exception propagates out to the try block, its dynamic type is matched against the catch blocks. The first (and only the first) catch block whose static type matches (i.e., is a supertype of) the caught exception gets executed. For example, if the call to scaryCalculation() in the following code snippet propagates an ArithmeticException, then the second catch block would be entered;
|
|
|
|
Within the scope of the catch block, we place the code to process the exception. This may involve logic to handle the exception (for example, by retrying an I/O routine or asking the user to revise their input), print() statements that offer more detailed debugging information that can help diagnose issues in the code, or another throw statement. It can even be empty, if we want to ignore the exception and proceed with the client code.
Let’s write some client code to see this exception handling in action. Suppose we define the following method to decrease a local maximum in an array by 1.
|
|
|
|
If we try to compile this method, we receive the error message
unreported exception NoLocalMaxException; must be caught or declared to be thrown".
Since NoLocalMaxException is a checked exception that may propagate to reduceMax(), we must take one of two actions.
- If we don’t want to handle this exception, we can do nothing and allow it to continue to propagate. Information from the
reduceMax()invocation is added to the exception’s stack trace, and we immediately exit fromreduceMax(). When such an exception propagates all the way out of themain()method, this is when the stack trace is printed and the program crashes. The compiler requires that we account for this possible behavior by adding a “throws NoLocalMaxException” clause toreduceMax().
If we select this option, we should add a “Throws” clause to the specifications to further alert to this possibility.
|
|
|
|
- If we feel equipped to handle this exception, we can surround the assignment statement calling
findLocalMax()in atry-block and include acatchblock with an exception matchingNoLocalMaxException.
We’ll take the latter route, and choose to have an empty catch block. In this case, our method will have no effect when a does not have a local max; all entries will remain unchanged. The catch block ensures that a NoLocalMaxException will not propagate out of reduceMax(), so a throws clause is unneeded. We must also include the decrement statement “a[maxLoc]--;” within the try block since this is the scope of maxLoc. Finally, we should update the specifications to explain the method’s behavior when no local max is present.
|
|
|
|
Exceptions and Specifications
Exceptions add a new layer of possibilities to our previous discussion of specifications, so let’s take this opportunity to reopen this discussion. Here, we emphasize two points that are common misconceptions for students:
A lot of times, violating preconditions (e.g., providing input values that are out of range, supplying a null argument in violation of an implicit non-null pre-condition) will result in runtime exceptions as the method executes. However, don’t feel responsible for generating these runtime exceptions yourself; throwing your own runtime exceptions can be seen a bad coding practice (if you are aware of an issue at runtime, why not correct it?). If you want to do something, we prefer using defensive programming assert statements to check pre-conditions.
While we use exceptions as a tool to handle atypical situations, the presence of an exception does not automatically alert that something has gone wrong. Instead, exceptions make up part of a method’s specifications. Checked exceptions that may propagate are documented both with a throws clause and in the specifications. If you throw an exception to exit a method, you are handing back control to the client, so you must ensure that the class invariant is met.
Since exceptions are often documented in a method’s specifications, we may wish to write unit tests that verify their correct propagation. We can do this using JUnit’s assertThrows() methods. The arguments to assertThrows() require a special syntax. For example, the following method will check that findLocalMax() (our first implementation that does not include a try/catch block) throws a NoLocalMaxException:
|
|
|
|
This syntax allows us to “package up” the call to findLocalMax() so that JUnit can execute it once it is ready (i.e., from within its own try/catch block that is set up to check for the correct type of Throwable). We’ll explore the mechanics of this special lambda expression syntax in a few lectures.
Immutability
So far, most classes we have written have been mutable, they expose to the client behaviors that can modify their internal state. For example, our account classes from the past two lectures allowed users to deposit and transfer funds, which modified both the balance and transactions fields. Mutability adds complexity to a class. Whenever the state of an object can be modified, there is the possibility that the class invariant becomes violated. The implementer must account for these possibilities and ensure that the class invariant is maintained. Additionally, it can be more challenging to reason formally about mutable objects within our code; properties that we can assert on one line of code about the state of an object may not hold on later lines. For these reasons, mutability increases the possibility for bugs in large programs.
While in many cases mutable objects are a pragmatic choice, sometimes classes do not need mutable state. Their objects serve as containers to organize some data, but this data may not need to change during the object’s lifetime. We have already seen an example of this. Strings are immutable objects that represent a sequence of characters. Once they are initialized, Strings cannot be modified. Instead, they provide methods that can query certain properties of the string (charAt(), length(), etc.) and methods that can construct and return new String objects with different properties (substring(), toUpperCase(), etc.).
A class is mutable if it includes methods that modify its objects' states after construction. When it is not possible to modify an object's state, we say that its class is immutable.
The Java language does not include mechanisms to natively support or enforce immutability. Instead, it is the responsibility of an implementor to document the guarantee of immutability in a class’ specifications and to enforce this guarantee through its method definitions.
As an example, let’s develop an immutable Point class that represents a point in the 2D coordinate plane. The state of a Point consists of an x-coordinate (a double x) and a y-coordinate (a double y) which are fixed at construction. We’ll add accessor methods x() and y() for these fields.
Point.java
|
|
|
|
We can add additional methods to support other behaviors of points. For example, we can add a method that returns the distance to another point.
Point.java
|
|
|
|
Notice that in this code, we can access the fields other.x and other.y even though they are private. Remember, the private visibility modifier restricts access to the class where the member is defined, not to the instance of that class. An object can access the private members of other objects of its type.
We can also add methods that perform transformations on a point, for example a method to reflect a point over a given line (useful for graphics applications). Since Point is an immutable class, we won’t modify its coordinates to carry out this reflection. Instead, we’ll return a new Point object with the new coordinates.
|
|
|
|
The final Keyword
A related, but separate, notion to immutability in Java is its final keyword. We have already seen final used in two ways in the previous lecture.
- When we mark a method as
final, we prevent it from being overridden in any subclasses (enforced by the compiler). Similarly, when we mark aclassasfinal, we prevent it from being extended. - We add the
static finalmodifier to a variable declared at class scope to model a constant in Java.
Today, we’ll talk about a third use of final, as a modifier for an instance variable. When an instance variable is declared as final, it cannot be reassigned after its initialization. This is another static property that is enforced by the compiler. Since our Point class is immutable, we know that its x and y fields will not be modified after they initialized in the Point() constructor. Thus, we can mark them as final.
|
|
|
|
By doing this, we allow the compiler to help us enforce immutability by warning us about any reassignment of these fields. For example, if we wrote reflectOver() to modify the coordinates rather than constructing a new Point,
|
|
|
|
the compiler gives the error message
cannot assign a value to final variable x
Marking variables as final also allows the compiler to perform optimizations to your code to improve its performance that are only possible once it can guarantee that a variable will not be reassigned. Because of this optimization possibility, IDEs like IntelliJ will often detect variables that can be made final and suggest this. Accepting these suggestions is a good programming practice.
Here, we caution that the semantics of final are not the same as being immutable when dealing with variables with reference types. Just because a variable cannot be reassigned doesn’t mean that its contents will not change. For example, suppose that we instead modeled our Point class with a final double[] array of length 2.
|
|
|
|
If we make the same mistake and modify the coordinates in reflectOver(), the compiler will not catch this.
|
|
|
|
In this case, we are not reassigning the field coord to reference a new array object. Rather, we are modifying the entries of the original array object to store new values. Said succinctly, the final keyword does not enforce that every aspect of an object remains unmodified (i.e., it does not enforce immutability); it only guards the top-level reference stored in the field. It is incumbent on you, as the implementer to document and guarantee immutability.
Object Methods
In the previous lecture, we introduced the Object class as the “superest of all classes”, the top class in Java’s type hierarchy. Today, we’ll look at two instance methods of Object (i.e., two behaviors that every single object in Java supports).
toString()
The toString() method of the Object class has the signature “public String toString()”. It “[r]eturns a string representation of the object”. This is a very useful method for debugging; it allows us to print out a representation of an object at some point in our program to better understand what our code is doing. Since our Point class does not declare an explicit superclass (with an extends clause), it is a direct subclass of Object. Therefore, it inherits Object’s toString() definition. Let’s see what this does. If we run the client code,
|
|
|
|
we obtain output
Point@6f496d9f
This default toString() definition in Object includes the name of the class and a reference to the memory address where it is stored on the heap. This is not a particularly useful String representation, so let’s override toString() in the Point class.
|
|
|
|
Now, our client code from above prints
Point (1.0,3.5)
equals()
Suppose we execute the following client code:
|
|
|
|
Note that the String concatenation operator + on line 4 implicitly calls p2.toString() to obtain the String representation of p2 (and similar for p3 on line 5) Thus, the first two lines of output are:
p2: Point (4.0,1.0) p3: Point (4.0,1.0)
What do you expect the third line of the output to be?
View the program output.
How can we explain this?
The == operator compares the values of the expressions on both sides of it. These expressions are Point variables, whose values are references (i.e., memory addresses) of Point objects. Although they have the same state, the Point objects referenced by p2 and p3 are different; one (p2) was constructed in the client code and the other (p3) was constructed in the reflectOver() method. Thus, the values stored in variables p2 and p3 are different.
Sometimes, comparing memory addresses (i.e., checking reference equality) is desired, so using the == operator on reference types is warranted. Other times, we want to instead compare the states of the objects. For this, Java defines an equals() method in the Object class with signature “public boolean equals(Object obj)” that “Indicates whether some other object is “equal to” this one.” The default behavior of equals() is the same as == (reference equality), but we can override equals() to encode a more natural notion of equality for a class we are defining.
Pay careful attention to the type of the parameter of the equals method. It is Object, so it must be Object in the signature of our equals() definition. A common mistake is to replace Object with the class we are writing, but this defines a new method that does not override Object.equals(). This can lead to some undesired behavior in code that leverages Object's equals() method. In short, your equals() methods must be able to compare to any other Object, regardless of its type.
In light of the above remark, we typically start our equals methods with a standard “template” that checks to make sure that the parameter Object has the same type as the target. If this is not the case, we return false (an object that is not a Point should not be equal to a Point).
|
|
|
|
getClass() is another Object method that returns a representation of the dynamic type of an object. Once we’ve completed this check, we can safely cast the obj parameter to the class’ type and proceed with our equality check. In our example, two Points are equal when they have the same coordinates.
|
|
|
|
Note that we can compare this.x and other.x with == because they are primitive types; these fields directly store their values. Sometimes, you might need to make an inner call to equals() to compare fields that are reference types.
The Object.equals() specifications require that three properties are guaranteed by any overriding equals() definition.
- Reflexivity: For any object
o,o.equals(o)must be true. - Symmetry: For any objects
o1ando2, ifo1.equals(o2)is true, theno2.equals(o1)must also be true. - Transitivity: For any objects
o1,o2, ando3, ifo1.equals(o2)is true ando2.equals(o3)is true, theno1.equals(o3)must also be true.
These properties establish an equivalence relation over the set of all objects of a given type (you’ll discuss equivalence relations more in CS 2800), and ensures that equality functions the way that you expect in your code. You can verify that our definition above meets these three properties. We explore this idea more in Exercise 11.10.
If we replace the last line of our client code with
|
|
|
|
the code now reports that p2 and p3 are equal.
Record Classes
Altogether, our immutable Point class contains a lot of code that will be standard for many immutable classes:
|
|
|
|
To cut down on the need to rewrite this fairly boilerplate code, recent versions of Java have added a feature called a record class to model objects with all final fields. Record classes automatically generate many common methods such as the constructor that initializes the fields, accessors for each of the fields, and definitions of toString(), equals(), and hashCode() (another Object method that we will discuss soon). You can read more about record classes on Java’s documentation page.
If we refactor our Point implementation as a record class, we can obtain the same behavior with much less code. Note that we must separately override toString() to obtain our desired String representation.
|
|
|
|
Main Takeaways:
- Exceptions provide a mechanism for a method to signal an abnormal situation to its client. A
throwstatement is used to generate an exception, which immediately propagates outward, building up a stack trace until it is caught in atry/catchblock. - Checked exceptions are tracked by the compiler. If a method has the possibility of propagating a checked exception, it must declare this with a throws clause in its signature.
- An immutable object does not expose behaviors that can modify its state after initialization. Immutability is enforced through implementer discipline and specifications.
- When a field is marked as
final, the compiler will not allow this field to be reassigned after it is initialized. This differs from immutability in reference types, whose state may be modified even when a variable is markedfinal. - The
==operator checks reference equality between objects. To compare the states of the objects, override and use theObject.equals()method.
Exercises
Consider a Java program consisting of the following static methods:
|
|
|
|
ArithmeticException will be thrown. Which statement will be executed immediately after the exception is thrown?The constructor for FileOutputStream <: OutputStream declares that it throws FileNotFoundException (observe that FileNotFoundException <: IOException <: Exception). You are implementing a method currently defined as follows:
|
|
|
|
writeScores() would individually be sufficient to allow it to compile?Consider the following partial class definition.
|
|
|
|
equals() method uphold?
|
|
|
|
|
|
|
|
|
|
|
|
Account.java class in last lecture. In both depositFunds() and transferFunds(), we require that amount > 0 as part of the precondition. Let's refactor these methods to throw exceptions instead.
NegativeValueException that extends Exception.
depositFunds() and transferFunds() to throw NegativeValueException when a client passes in a negative value for amount. Modify the specifications accordingly. Note that NegativeValueException is checked.
SavingsAccount class, which doesn’t override any of the above methods. Write client code to deposit a negative amount in a try block. Handle this exception in a catch block that prints a helpful statement.
Point
Point class. For each of the following, implement the method according to its specifications and write specifications if not given.
Add a method to return the midpoint between two points. The midpoint \((x_m, y_m) \in \mathbb{R}^2 \) between two points \((x_1, y_1), (x_2, y_2) \in\mathbb{R}^2\) has coordinates given by the equations,
\[ x_m=\frac{x_1+x_2}{2} \hspace{50pt} y_m=\frac{y_1+y_2}{2} \]
|
|
|
|
Given some point \( (x_0, y_0) \in \mathbb{R}^2 \) , its rotation about \( (x_c, y_c) \in \mathbb{R}^2 \) for \( \theta \in \mathbb{R} \) radians counterclockwise is \( (x_1, y_1) \in \mathbb{R}^2 \), where
\[ \begin{align*} x_1 &= (x_0-x_c)\cos(\theta) - (y_0-y_c)\sin(\theta) + x_c\\ y_1 &= (x_0-x_c)\sin(\theta) - (y_0-y_c)\cos(\theta) + y_c \end{align*} \]
|
|
|
|
Implement these methods using the rotate() and reflectOver() methods defined above.
|
|
|
|
These transformations can be generally written in the form of a matrix times a vector. Given a matrix \( A \in \mathbb{R}^{2\times2} \), we apply the transformation to a point \( (x, y) \in \mathbb{R}^2\) by computing:
\[ A \vec{x} = \begin{bmatrix} a & b \\ c & d \end{bmatrix} \begin{bmatrix} x\\ y \end{bmatrix} = \begin{bmatrix} ax+by\\ cx+dy \end{bmatrix} \]Note that InvalidArgumentException <: RuntimeException.
|
|
|
|
NegativeValueException is thrown in the modified depositFunds() and transferFunds() methods of Account.java that you wrote in Exercise 11.3.b.
IllegalArgumentExceptions are thrown according to the specs for the method in Exercise 11.4.d.
Lines and Points
Line class that works closely with Point.
|
|
|
|
static methods and which make sense as instance methods.
slope() of a Line.
yIntercept() of a Line. How will you handle lines that do not have a y-intercept?
equals() method in the Line record class so that two Lines are equal when they have the same slope and intersect at least one point.
Point and a slope, return the Line given by the point-slope formula.
Lines, return their Point of intersection. How will you handle two lines with infinite or zero points of intersection?
Points, return the Line between them.
Fractions
|
|
|
|
private helper method assertInv() to assert the class invariants. For more information about gcd(), view Discussion 1.
Fractions. Keep in mind the class invariant!
Fractions. Can you use the methods defined in the last subproblem to simplify the implementation?
toString() and equals() methods from Object. The implementation details are up to you, but remember to refine the specifications and properties of equals()!
Fraction to use a record class.
Accounts as Strings
toString() method in the Account class and its subclasses to produce a sensible String representation.
|
|
|
|
Override the equals() method according to its specifications.
|
|
|
|
URL objects have different elements in its path field but evaluate to true. Is this acceptable?
Override the toString() method according to its specifications. The formatted URL is given by the following strings concatenated together: scheme, “://”, hostname, “:”, port, “/”, path. The formatted path is the elements of path joined together with "/".
|
|
|
|
equals()
equals() determine if it is a valid override. If it is not, state which property or properties are violated by the implementation.
|
|
|
|
|
|
|
|