9. Interfaces and Polymorphism
When we drive a car, we know that we should push down on the accelerator pedal to make the car move faster and turn the steering wheel to change its direction, but most of us lack the knowledge of the underlying mechanical systems that make this happen. When we plug an appliance into a wall outlet or use an HDMI cable to connect our computer to a projector, we can access power or transmit data without needing to worry about the intricacies of the electrical grid or the signaling protocol. These physical devices present an interface to us, an agreed upon standard for how the two sides (the driver and the car, the outlet and the appliance, the computer and the projector) should work together.
A similar idea applies in encapsulated object-oriented programs, which maintain a clear boundary between the implementer and client of a class. As the author of the class, the implementer has full knowledge of its inner workings, including how it represents its state with fields and how it interacts with this state in the definitions of each of its instance methods. The client does not need all of this information; having access to it would in many cases create more of a cognitive burden than a benefit. Instead, the client needs to know what behaviors are provided by a class so they can leverage them in their code.
This separation between what (high-level behaviors) and how (fine-grained implementation details), an abstraction barrier, is one of the key benefits of object orientation. In today’s lecture, we’ll expand on this idea, introducing a Java construct called an interface, which helps us formalize the distinction between declaring (promising) and defining (implementing) behaviors. Interfaces are perhaps the most important and powerful features in Java, and a lot of newer Java syntax and language features are built around interfaces. We will use interfaces heavily throughout the rest of the course because of their close relationship with abstract data types.
In the latter portion of today’s lecture, we’ll see that interfaces establish subtype relationships and enable polymorphism. Just as the physical driving interface (accelerator pedal, steering wheel, turn signals, etc.) can be adopted by many different automobiles (from gas to hybrid to electric cars, compact cars to large trucks), allowing a person who learns to drive one vehicle to easily transition to any other vehicle, the object-oriented principle of polymorphism allows us to leverage subtype relationships so that one piece of code can seamlessly interact with objects of many different types.
Interfaces in Java
As a running example throughout today’s lecture, let us imagine that we are designing a personal finance application that will allow users to view and manage different types of accounts (checking accounts, savings accounts, CDs, investment accounts, etc.). There are some common actions that a user should be able to take for each of these accounts:
- A user should be able to see the current value of the account, along with basic account information such as its account type, owner, and creation date.
- A user should be able to add funds to an account, withdraw funds, or transfer funds from one account to another.
- A user should be able to view a list of recent account transactions and access an account statement.
Since the accounts can be managed by different teams at a bank (or even different banks or financial institutions), how can we be sure that all of the accounts will support these operations? One solution is to define an Account
interface that all of the different account types will implement.
An interface is a Java construct that creates a new type, just as a class does. Interfaces guarantee the presence of certain behaviors (i.e., method signatures) for this type, but do not specify how they should be implemented. Unlike a class, an interface does not have fields and usually does not provide definitions for its instance methods, only signatures.
In our personal finance application, we’d like our code to interact with an Account
type, which we model as an interface. Just like classes, interfaces are defined in their own file with a matching name and use similar syntax to declare their scope.
Account.java
|
|
|
|
Declaring Methods in an Interface
Within the interface, we will write the method signatures (names, return types, parameters, and of course specifications) for all of the behaviors that Account
must provide. Rather than enclosing a method in a { ... }
block delimiting the method’s scope, we’ll simply end the signature with a semicolon (;
). The function of the interface is to promise the availability of a method, but not to provide its implementation details.
The terminology around creating and implementing interfaces becomes somewhat subtle, so we stop to emphasize one important distinction.
- When we use the word declare, this roughly means "introduce the name of". This is the word we used for a variable declaration like "
int i;
", a piece of code that tells the compiler thati
will be anint
variable within this scope but does not assign a value toi
. Similarly, an interface declares many methods since it tells us the names of methods that can be accessed for its type. - When we use the word define, this roughly means "provides the details of". Think about a dictionary definition, that tells us exactly how the word works. An interface doesn't define methods since it is missing their implementation details. Soon, we'll see that the method definitions are provided by a class implementing the interface.
As noted in the above definition, our interface declaration will not include any fields. Let’s add some method signatures to our Account
interface that support some of the desired behaviors listed above.
Account.java
|
|
|
|
One can imagine expanding this interface to include other useful account features.
Notice that we did not include visibility modifiers on any of the methods we declared in the interface
. All methods in an interface
must be public
, so this visibility is added by default.
In this introduction to interface
s, we are eliding many of their more technical details and corner cases, such as their interaction with static
methods, the possibility of adding default
method definitions, etc. We may introduce some of these as the course progresses, but for now, it is reasonable to think of interface
s as just a collection of method declarations under a new type name.
Implementing an Interface
Just from this interface, we have information that we need to interact with Account
s in client code. Let’s suppose that we have an Account
variable checking
that refers to a checking account with $550 and an Account
variable savings
that refers to a savings account with
$2,300. Suppose we write the code:
|
|
|
|
Then, the Account
method specifications tell us we should see a reported initial balance of
$550 in the checking account, a
$40 deposit, a
$100 outgoing transfer, and a final balance of
$490. How can this code actually work? Where are the method definitions? Even before this, how can we construct these “Account
objects” when the interface provides no information about an Account
’s fields? The answer to these questions is that interfaces cannot be instantiated directly. Instead, a class provides the definition of an interface’s methods, and we use an instance of this class to realize the interface. For example, we can define a CheckingAccount
class to model checking accounts, with the following syntax.
CheckingAccount.java
|
|
|
|
Notice the use of the new keyword implements
, in the clause “implements Account
” of this class declaration. This clause tells us that the CheckingAccount
class guarantees to provide definitions for all of the methods promised in the Account
interface. If you write this class definition in IntelliJ, you will see an error appear with a suggestion to add these method signatures. In this way, the implements
clause is a powerful piece of the class’ specifications; it assures the existence of a set of behaviors within the implementing class (that all satisfy the specifications outlined in the interface).
Aside from the required inclusion of certain methods, we’ll define a class implementing an interface as usual. First, we’ll decide on its state representation (and class invariant), and then we’ll add method definitions to model the behaviors that the class supports. In the case of our CheckingAccount
, we’ll have fields for the account’s name
(a String
), its current balance
(an int
), and a StringBuilder
object, transactions
, that stores a log of its transactions.
Our modeling of a checking account is far from ideal. At this point in the course, we have not yet introduced data structures that we'd use in a more principled design. For example, storing transactions in a StringBuilder
doesn't give us an easy way to locate a particular transaction, categorize or filter transactions by date, etc. Data structures provide these convenient behaviors. The purpose of this code example is less about the design decisions of our personal finance application. Rather, it is meant to illustrate the syntax for using interface
s and motivate the principles of polymorphic design that we will discuss later in the lecture.
Our completed definition of the CheckingAccount
class is given below.
CheckingAccount.java
|
|
|
|
Let’s make some observations about this code.
- As usual, we expect Javadoc specifications for the class, all of its fields, and all of its methods. Rather than providing full specifications for some of the methods (in particular, all of the methods declared in the
Account
interface), we’ve simply added the “@Override
” annotation. This annotation signals that this method’s signature is pulled “down” from the interface that it implements, and it points the client to refer to the interface for its specifications. When the specifications of a method exactly match what is documented in the interface, this “@Override
” annotation is sufficient. Otherwise, if the class definition refines the specifications in some way (i.e., adds additional information beyond what was given in the interface), new complete specifications should be provided. For example, a deposit into aCheckingAccount
will always be successful, so the specifications ofdepositFunds()
are refined to reflect this. - We’ve included additional
private
helper methodsresetTransactionLog()
andcentsToString()
in theCheckingAccount
class that were not mentioned in the interface. This is alright, since an interface does not preclude us from adding methods. In fact, we’ll soon see that we can even add additionalpublic
methods to a class implementing an interface.
We can similarly define a SavingsAccount
class that also implements
the Account
interface. A SavingsAccount
has additional state that models its interest rate
, and it earns interest that compounds each month based on its current balance. An additional public
method interestRate()
provides the client access to this field, and we add interest to the balance from within the transactionReport()
method by calling a private
helper method accrueMonthlyInterest()
. We’ll note in passing that much of the code in the SavingsAccount
and CheckingAccount
classes is identical, and we’ll revisit this in our next lecture.
SavingsAccount.java
|
|
|
|
Interfaces in Client Code
Now that we’ve defined the SavingsAccount
and CheckingAccount
classes, let’s use them to fill in the details of our client code. To initialize the Account
variables checking
and savings
, we can call the CheckingAccount
and SavingsAccount
constructors. Then, we can copy the remaining client code from above, placing this all in a main()
method in a FinanceSimulation
class to complete a simple application.
FinanceSimulation.java
|
|
|
|
When we run this application, we get the expected output:
Checking Initial Balance: $550.00 - Deposit $40.00: Check from John - Transfer $100.00 to Savings Final Balance: $490.00 Savings Initial Balance: $2300.00 - Deposit $100.00: Transfer from Checking - Deposit $6.00: Monthly interest @3.0% Final Balance: $2406.00
From this example, we can see the role of an interface
as an intermediary between the implementer code and client code. Here, after the initial construction of the objects referenced by checking
and savings
, the client code never directly interacts with the CheckingAccount
and SavingsAccount
classes. Instead, it only calls methods from the Account
interface, so the client only needs to be aware of the specifications of this one interface to develop their code. By implementing the Account
interface, the CheckingAccount
and SavingsAccount
authors offered this simpler perspective on their classes to the client. For the rest of today’s lecture, we’ll formalize this notion of a “simpler perspective” by introducing subtype relationships.
Subtype Relationships
Dynamic vs Static Types
How would we visualize the initialization “Account checking = new CheckingAccount("Checking", 55000);
” in a memory diagram? The RHS is a new
expression that calls the CheckingAccount
constructor, so it allocates a CheckingAccount
object on the heap. We store the address of this object into a variable checking
with type Account
.
This is the first example that we have seen of a type “mismatch” that was allowed in the code. We declared a variable checking
with one type (Account
) that references an object that has a different type (CheckingAccount
). To talk about these different types both relating to the variable checking
, we’ll introduce some refined terminology.
The static type of a variable is the type included in its declaration statement. This type is known at compile time and restricts which object references can be assigned to that variable.
The dynamic type of an object is determined by the constructor that was initially called during the creation of that object. The dynamic type of an object referenced by a variable is not known at compile time.
We must be very precise with this terminology. Notice that these two notions of type are used to describe different entities in our programs. Variables (and expressions) have static types, whereas objects have dynamic types. The word “static” means unchanging, or inherently present. Since the type of a variable is declared once and persists throughout its lifetime, this is a static property. The fact that it appears explicitly in the variable’s declaration makes the static type accessible to the compiler at compile time. We can contrast this with a dynamic type. Dynamic means changing. While a variable’s (static) type persists throughout its lifetime, it may be reassigned to reference different objects with different dynamic types. Since objects exist only at runtime, the compiler cannot determine dynamic types at compile time (we’ll see an example of why soon).
Subtype Substitution
Does it seem reasonable to reference a CheckingAccount
object from an Account
variable? By declaring the static type of checking
to be Account
, we are signaling that checking
should be able to store a reference to any Account
object. Do CheckingAccounts
count as Account
objects? We can argue, yes! Anything that I want to do with an Account
object makes sense to do with a CheckingAccount
, since they accommodate all of the same behaviors; this is enforced by the fact that CheckingAccount
implements the Account
interface. Because of this, we say that CheckingAccount
is a subtype of Account
.
A reference type S
is a subtype of a reference type T
(equivalently, T
is a supertype of S
) if it makes sense to use the type S
in any place where the type T
was expected. In other words, S
is a subtype of T
if S
is substitutable for T
.
We often denote this supertype/subtype relationship using the notation S
<: T
or T
:> S
.
Implementing an interface is one way to establish a subtype relationship (the implementing class type is a subtype of the interface type). We’ll talk about a second mechanism, inheritance in our next lecture. Let’s drill down on what is meant by substitutability. There are four primary ways that we interact with a variable of (static) type T
in our code.
First, we can access a behavior of a T
by invoking a method on a variable with static type T
. To make this invocation substitutable, it must be the case that every method signature that exists for supertype T
also exists for subtype S
. Interfaces enforce this by requiring that the implementing (subtype) class include definitions of all methods declared in the interface.
Next, we can assign an expression with (static) type T
to a variable t
with static type T
. For this to be substitutable with a subtype S
, we must also be able to assign an expression with static type S
to t
.
If
t
is a variable with static type T
and S
<: T
, then we should be able to assign any expression with static type S
to t
.
It is this subtype substitution rule that allows us to assign a reference to a CheckingAccount
object to our Account
variable.
Next, we can specify that a parameter of a method has static type T
, meaning any expression with type T
can be passed in as an argument to that method. For this to be substitutable with a subtype S
, we must also be able to pass in an expression with type S
.
If
f()
is a method that accepts a parameter with type T
and S
<: T
, then we should be able to pass in any expression with static type S
to f()
to serve as this parameter.
Our example code made use of this rule in the line checking.transferFunds(savings, 10000);
, which passed savings
, a reference to a SavingsAccount
object to serve as the receivingAccount
parameter with type Account
.
Finally, we can declare that T
is the return type of a method, which promises that the method will return an expression with type T
. For this to be substitutable with a subtype S
, we must also be able to return an expression with type S
.
If
f()
is a method with return type T
and S
<: T
, then f()
should be able to return an expression with static type S
.
We haven’t seen an example of this yet, but we will soon when we introduce factory methods.
Reading these rules and definitions can be a bit challenging because of all of the symbols. A more intuitive notation of subtype/supertype relations is that every object belonging to a subtype should also have the supertype, meaning the supertype describes a superset of objects of the subtype. In still other words, the subtype refines the description of the supertype.
It's also helpful to work through the rules with some familiar "types" from the real world; when we do this, the rules should hopefully seem almost "obvious". Let's think about the subtype relationship Cat
<: Animal
(it's true that every cat is an animal, though not every animal is a cat). The first substitution rule says, "a variable that can store any animal should be able to store any cat." The second substitution rule says, "if a method can accept any animal as an argument, it should be able to accept any cat as this argument." The third substitution rule says, "if a method promises to return an animal, it will fulfill this obligation by returning a cat." To cement this idea, try swapping "cat" and "animal" in the preceding sentences to confirm that these assertions no longer make sense.
We visualize subtypes using a picture called a “type hierarchy”. In this picture, we write out the names of all of the types, with supertypes appearing generally above their subtypes. Types derived from interfaces are italicized. We draw an arrow from each subtype to its supertype. We use a dashed arrow to indicate that this subtype relationship is established via implementation of an interface. For our Account
example, we have the following type hierarchy.
Polymorphism
You might be wondering the benefit of interfaces. In our client code, couldn’t we have just declared checking
to have static type CheckingAccount
and savings
to have static type SavingsAccount
? In this case, yes, we could have done this and arrived at the same result. However, this approach can quickly lose tractability. While we’ve considered a case where there were two subtypes of an interface, there could potentially be hundreds or thousands of subtypes (imagine all of the different accounts one can open across many different banks). Let’s identify a couple places where dealing with all these account types separately would be impractical.
- Currently, the
transferFunds()
method takes anAccount
as one of its parameters. This allows the method to process a transfer to any type of account. If we removed theAccount
interface, we’d need a separate method to transfer to each type of account (transferFundsToChecking()
,transferFundsToSavings()
, etc.). Writing all of these methods for all of these account types that all include nearly identical logic would be tedious. Every time we want to support a new account type, we’ll need to go back and add a new transfer method to all existing account types. - In our client code, we will want to associate to each user (perhaps in a
User
object) a collection of all of their accounts. They could have more than just one checking account and one savings account. What if they have multiple accounts of one type and none of another. How should we model this? Maybe we could have a separate array for each account type that stores all of thisUser
’s accounts of that type? This may require adding hundreds of separate fields to theUser
class, most of which would be empty or unused. We’d also need to modify theUser
’s state representation whenever a new account type is added. Instead, a singleAccount[]
array (or other collection ofAccount
objects) can collect all types of accounts in a single place.
In short, subtype substitution improves code flexibility, and interfaces are a method for establishing subtype relationships. The ability for a single piece of code to accommodate many different subtypes is called polymorphism, derived from the Greek roots poly meaning “many” and morph meaning “shape”.
We say that a piece of code is polymorphic if it can correctly handle data with potentially many different shapes, meaning that statements within the code are written in a way that accommodates different types of data.
Specifically, interfaces allow for subtype polymorphism. We’ll discuss another type of polymorphism, parametric polymorphism, in a few lectures. The support for polymorphism (e.g., by realizing the subtype substitution rules) is one of the central features of object-oriented languages, and leveraging polymorphism is a great way to become a better programmer. Next, we’ll look at two important concepts for handling types that are important when developing polymorphic code.
Dynamic Dispatch
Observe that the transactionReport()
methods in CheckingAccount
and SavingsAccount
have slightly different behaviors, evident from the refined specifications on SavingsAccount.transactionReport()
(shorthand that we will use for the transactionReport()
method of the SavingsAccount
class). The SavingsAccount
calculates the interest accrued during that month and adds this as a final transaction on the report. In the output of our FinanceSimulation
application, we see that an interest accrual is printed for the savings account but not the checking account. While this may seem unsurprising, let’s stop to think about what happened behind the scenes. For the first print statement, we invoked the transactionReport()
method on the object referenced by checking
, a variable with static type Account
. For the second print statement, we invoked the transactionReport()
method on the object referenced by savings
, another variable with static type Account
. How was Java able to distinguish these, calling CheckingAccount.transactionReport()
in the first case and SavingsAccount.transactionReport()
in the second case? The answer is that it uses the dynamic types of the objects referenced by checking
and savings
. Since checking
points to a CheckingAccount
object, Java knows to invoke CheckingAccount
’s definition of transactionReport()
. Similarly, since savings
points to a SavingsAccount
object, Java knows to invoke SavingsAccount
’s definition of transactionReport()
. Moreover, all of this happens at runtime since the compiler is unable to determine dynamic types at compile time.
Why is this latter statement true? In this example, it is clear that checking
was assigned a reference to a CheckingAccount
object and savings
was assigned a reference to a SavingsAccount
object only a couple lines up. However, in other cases, the situation is not as straightforward. Suppose that we add the following method to our client code that can be used to create new accounts.
FinanceSimulation.java
|
|
|
|
We call this a factory method because it is used to construct and return new objects. Notice that this method leverages the third subtype substitution rule since it has a declared return type of Account
(the supertype) but actually returns a reference to a CheckingAccount
or a SavingsAccount
(the subtypes). We can modify our client code to make use of this factory method:
FinanceSimulation.java
|
|
|
|
This results in the same output as our previous client code. In this case, the compiler doesn’t have an easy way to detect the dynamic types, as doing so would require the execution of the createAccount()
method; the compiler doesn’t execute code, it only translates it to byte code.
If you are still not convinced, you can imagine a program that asks the user to select the new account type (either with keyboard input, clicking a button, etc.), in which case the dynamic type can definitely not be determined until the program is run.
We call this use of the dynamic type to locate a method at runtime dynamic dispatch.
The principle of dynamic dispatch, which is followed by Java, says that an object's dynamic type is used to locate the definition of a method for which it is the target.
We will continue our discussion of the specifics of dynamic dispatch in the next lecture. Dynamic dispatch enables subtype polymorphism, since it allows a common line of code executed on a supertype variable to invoke different behaviors based on which (dynamic) type of object it actually references.
The Compile-Time Reference Rule
Recall that the type of an expression (including a variable) determines which operations/behaviors can be invoked on that expression. The compiler uses static type analysis to enforce this. In polymorphic code, where the static type of a variable and the dynamic type of the object that it references can be different, we need to be careful about which methods we attempt to call on a variable. Consider the following code:
|
|
|
|
What should be the result when this code is executed? You might expect that this code will print “Interest Rate: 3.0%” since it calls the interestRate()
accessor method in the SavingsAccount
class, which will return the 3% interest value that was initialized by the constructor on the first line. In actuality, this code does not compile, and we receive the error message
Cannot resolve method 'interestRate' in 'Account'
Since a
was declared with the static type Account
, the compiler will only allow our code to invoke methods a
that are declared for the Account
type. Since interestRate()
is declared in the SavingsAccount
class but not the Account
class, we receive an error at compile time. We call this restriction to methods declared for the static type the compile-time reference rule.
The compile-time reference rule states that the methods that we may invoke on a variable are determined by the static type of that variable. Violations of the compile-time reference rule are identified by the compiler and prevent the compilation of the code.
The compile-time reference rule is one of the most important rules in the course, as it underscores object-oriented design. It can also be challenging for students to grasp at first, since it defies our intuition: “we know that a
references a SavingsAccount
object, which has an interestRate()
method, so why can’t we call it?". This statement that a
“references a SavingsAccount
object” is describing a dynamic type, which is unknown to the compiler. Since the compiler’s job is to ensure type safety, it must use the information it has available (the static type) to decide what method calls to allow. Not every Account
variable will reference an account with an interestRate()
, so the compiler disallows this call to prevent the case where it fails.
It can be helpful to think about the static type of a variable as describing the perspective or point-of-view that it looks at an object from. Two variables, one with static type Account
and one with static type SavingsAccount
can both store a reference to (i.e., look at) a SavingsAccount
object, but they will “see” different methods. Since the SavingsAccount
is a more detailed description of the object than Account
(i.e., it is a subtype), the SavingsAccount
variable can access behaviors that the Account
variable cannot.
Together, dynamic dispatch and the compile-time reference rule describe the following dichotomy of how object behaviors are managed in Java.
Reference Type Coercion
We first introduced coercion when we were discussing primitive types. Coercion is a mechanism to alter the type of an expression to change which operations can be applied to it. For primitive types, we saw that this coercion was sometimes implicit, as in the type-widening of the second argument in 1.0 / 2
from int
to double
to allow double
division. Other times, it needed to be explicit with a cast, such as in (double) 1 / 2
which coerces the int
expression 1
to the double
expression 1.0
to prevent integer division. These coercions change the underlying memory representation of their expressions; for example, the cast (int) (1.0 / 2)
converts the double
value 0.5
to the int
value 0
, a different number entirely!
Coercions of reference types share some similarities to those of primitive types but also have some differences. The main difference is that reference type coercions do not change the underlying object. The coercion of a reference type expression changes the static type of that expression, which has the effect of viewing the referenced object from a different “perspective”.
Similar to primitive type coercions, sometimes reference type coercions are implicit. Consider the following code.
|
|
|
|
In the assignment statement on the second line, we are assigning c
, which is a reference to a CheckingAccount
object to a
, a variable with static type Account
. These types are mismatched, but the first subtype substitution rule says that such an assignment should be allowed (CheckingAccount
<: Account
). Therefore, Java does an implicit type coercion, which allows a
to view the CheckingAccount
object referenced by c
from the perspective of an Account
. We often refer to these implicit coercions as up-casts since they adjust the perspective to a static type that is higher in the type hierarchy.
In other cases, an explicit coercion (i.e., a cast) is required. Consider our code from above.
|
|
|
|
This code violated the compile-time reference rule, so did not compile. The static type of a
is Account
, and this is the perspective from which a
views the object that it references. Shifting perspective to view this object as a SavingsAccount
and access the interestRate()
method is not automatic; there’s a possibility that a
could reference an object that is not a SavingsAccount
(e.g., it could refer to a CheckingAccount
object), which would cause an error at runtime. Let’s add a cast to this code:
|
|
|
|
Here, the expression (SavingsAccount)a
says to the compiler, “trust me, it’s alright to view the object referenced by a
from the perspective of a SavingsAccount
.” Once the compiler views a
from this new perspective (i.e., assigns the static type SavingsAccount
to the expression (SavingsAccount)a
), the compile-time reference rule is no longer violated, and the code will both compile and execute without error. We often refer to these explicit coercions as down-casts since they adjust the perspective to a static type that is lower in the type hierarchy.
Explicit casts can result in undesired behaviors. Consider the following code.
|
|
|
|
Since the compiler has no information about the dynamic type of the object referenced by a
, the best it can do is trust that the cast (SavingsAccount)a
will work (after all, it will work whenever a
truly references a SavingsAccount
). This code will compile successfully, but face a problem at runtime, when the “perspective shift” that arises from the cast is determined to be impossible and a ClassCastException
is thrown, crashing the program. The compiler was unable to detect this error. These examples expose some subtlety in how casts are handled. It’s best to reason about casts in two separate stages:
- Compile Time: The compiler asks, “Is this cast possible?” In other words, is it possible for a variable with the “starting” static type to refer to an object whose dynamic type can be viewed from the perspective of (i.e., is a subtype of) the “ending” static type? If it’s possible, then the compiler will assume the cast works correctly and continue the compilation.
In the preceding example, it was possible for the Account
variable a
to refer to a SavingsAccount
object, so the compilation was successful.
- Runtime: When the program executes the cast, it looks at the dynamic type of the object and asks, “Will the cast actually succeed?” In other words, is the dynamic type of the object a subtype of the “ending” static type, such that the object can be viewed from the “perspective” of this “ending” static type? If the cast fails, then a
ClassCastException
is thrown at runtime, most likely crashing the program.
In the preceding example, the dynamic type CheckingAccount
is not a subtype of SavingsAccount
, so the cast fails. There are more examples of casts for you to practice working through this two-stage reasoning in the lecture exercises.
Since casting admits the possibility that our code compiles but crashes at runtime, we seek to avoid it whenever possible. Rather, we prefer to use dynamic dispatch to leverage subtype-specific behaviors from within polymorphic code. Soon, we will introduce another tool, dynamic type queries, that can be used to add guardrails around casts and prevent runtime errors.
Main Takeaways:
- Interfaces are a Java construct that allow us to define a type with a specified set of behaviors without committing to the state representation or implementation of these behaviors. Interfaces cannot be instantiated directly.
- When a class implements an interface, it provides a definition for each of the interface's methods. This establishes a subtype relationship between the class and the interface, which can be illustrated in a type hierarchy.
- The subtype substitution rules describe when Java will allow a subtype in place of a declared (static) type.
- Polymorphic code is written to accommodate objects with different types. Subtype polymorphism is enabled through dynamic dispatch, which determines (at runtime) the "version" of a method to invoke on a target based on its dynamic type.
- The compile-time reference rule says that the methods that can be invoked on an object are determined by the static type of the target variable referencing that object.
- Coercion of reference types is done with casting. Up-casting is handled automatically by subtype substitution. Down-casting must be done explicitly and can fail for different reasons either at compile time or runtime. Avoid explicit casts when you can.
Exercises
Consider this piece of code that uses Account
and SavingsAccount
from the above lecture notes.
|
|
|
|
R <: S
and S <: T
, then it is not necessarily the case that you can assign an expression of static type R
to a variable of static type T
.Assume the following subtype relationships.
Cat <: Pet
Manx <: Cat
Birman <: Cat
|
|
|
|
c
?c
?Account
Interface
Account
interface. You may need to define extra methods to support behaviors specific to these accounts.
|
|
|
|
When an interface extend
s another interface, the child interface inherits all of the parent interface’s method declarations. This notion of inheritance will be explored more thoroughly in the next lecture.
|
|
|
|
A class can also implement several interfaces to combine multiple capabilities.
|
|
|
|
HybridAccount
implements interfaces that both have the method signature void withdraw(double amount)
. Will this cause an issue at a compile time level? If not, might this cause issues from a different perspective?
|
|
|
|
main()
method of some class) will compile. If you answer "no", explain why not.
|
|
|
|
|
|
|
|
|
|
|
|
Suppose you are implementing the summon
method for Summoner
.
|
|
|
|
NPC.attack()
".
|
|
|
|
Assume that the implementation of summon()
in Witch
is:
|
|
|
|
|
|
|
|
default
Methods in Interfaces
|
|
|
|
We want to define another method in the Shape
interface called isLargerThan
.
|
|
|
|
Shape
interface. Suppose the four classes defined have implementations for the existing methods in the interface. Edit the four classes to align with this new interface. How many classes did you have to edit?
Instead, we can choose to use default
methods. Consider the updated Shape
interface. Note that in the default method, we can call methods defined within the same interface. This allows us to treat interfaces as small toolkits to build more complex methods from the abstract ones.
|
|
|
|
isLargerThan()
as a default
method, all classes that implement Shape
will have this implementation by default. Do any methods still need to override isLargerThan()
to meet the specifications? How many files in total would you need to edit?
Shape
. Why would a developer use default
methods?
static
Methods in Interfaces
default
methods, static
methods in interfaces can provide existing implementations of methods. However, they don't require an instance of the interface to invoke the method. We'll explore this by adding a factory method to Shape
.
|
|
|
|
Implement createQuadrilateral
assuming the following constructors in the partial class definitions.
|
|
|
|
static
methods usually lend themselves to utility or factory helper methods. Why is this the case? Why might a developer want to use static
methods in their interface?
|
|
|
|
|
|
|
|
attack()
method doesn’t modify any objects.
King
and Rook
classes have default (no-argument) constructors.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|