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
null
instead 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 the 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
throw
statement is written by a method’s implementer to produce an exception and alert the client to exceptional behavior. - A
throws
clause is included by the implementer in their method’s signature to document the possibility of exceptional behavior. - A
try
/catch
block 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 throw
s 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 Throwable
s, Error
s and Exception
s.
Error
s model serious issues with little chance for recovery (such as AssertionError
s that model logical inconsistencies within the program and VirtualMachineError
s that model situations like running out of memory). We almost always want the program to crash when we encounter an Error
.
Exception
s 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 NullPointerException
s (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 RuntimeException
s, 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, Error
s and RuntimeException
s are deemed unchecked, since the compiler allows them to arise without any warning. All other Exception
s that are not subtypes of RuntimeException
s 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 throw
n). 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 acatch
block 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; throw
ing 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 ensuring 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. String
s are immutable objects that represent a sequence of characters. Once they are initialized, String
s 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 aclass
asfinal
, we prevent it from being extended. - We add the
static final
modifier 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 Point
s 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
o1
ando2
, 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
throw
statement is used to generate an exception, which immediately propagates outward, building up a stack trace until it is caught in atry
/catch
block. - 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.
IllegalArgumentException
s are thrown according to the specs for the method in Exercise 11.4.d.
Line
s and Point
s
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 Line
s 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.
Line
s, return their Point
of intersection. How will you handle two lines with infinite or zero points of intersection?
Point
s, return the Line
between them.
Fraction
s
|
|
|
|
private
helper method assertInv()
to assert the class invariants. For more information about gcd()
, view Discussion 1.
Fraction
s. Keep in mind the class invariant!
Fraction
s. 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.
Account
s as String
s
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.
|
|
|
|
|
|
|
|