Inheritance
Table of contents
We’ve often declared our data structures using the following syntax, prefering to use the interface type wherever possible.
List<Integer> numbers = new ArrayList<Integer>();
In this lesson, we’ll learn how this works in Java and why code sometimes fails to compile.
Terminology
Inheritance is a way to form an is-a relationship between two classes. The class declaration public class B extends A
states that B
is a sub-class of A
(and that A
is a super-class of B
).
This relationship allows the programmer to use an object of type B
anywhere the parameter or variable type A
appears. For example, SoftwareEngineer
could extend Engineer
since a software engineer is a specialized version of an engineer: software engineers share many of the same behaviors as engineers in general, but they also have specialized behaviors as well.
By default, a sub-class inherits all of the fields and methods defined in its super-class. If a method is not defined in the sub-class, Java will fallback to the method found in the super-class. The sub-class is able to override methods (or add entirely new methods) to provide its own specialized behaviors.
A class can only extend one super-class. All classes, by default, extend the Object
class. This is why all Java objects have a toString
and equals
method: they’re inherited from Object.toString
and Object.equals
.
Non-static method calls are polymorphic. This means that the method call is always called on the actual type of the object, so a call to method
will always use the method
in the actual type of the object (or the method
inherited from the nearest super-class).
A sub-class can access the methods of its super-class. super.method()
calls the super-class method
with no arguments. super
method calls are not polymorphic.
Mystery
Consider the following classes.
class Denny extends John {
public void method1() {
System.out.print("denny 1 ");
}
public String toString() {
return "denny " + super.toString();
}
}
class Cass {
public void method1() {
System.out.print("cass 1 ");
}
public void method2() {
System.out.print("cass 2 ");
}
public String toString() {
return "cass";
}
}
class Michelle extends John {
public void method1() {
System.out.print("michelle 1 ");
}
}
class John extends Cass {
public void method2() {
method1();
System.out.print("john 2 ");
}
public String toString() {
return "john";
}
}
What is the output of the following code snippet?
Cass[] elements = {new Cass(), new Denny(), new John(), new Michelle()};
for (int i = 0; i < elements.length; i++) {
elements[i].method1();
System.out.println();
elements[i].method2();
System.out.println();
System.out.println(elements[i]);
System.out.println();
}
Solving inheritance mysteries
It’s important to develop a process for managing the information presented in this problem. Start by writing out the inheritance hierarchy.
Cass
John
Denny
Michelle
Then, build a method lookup table based on the hierarchy. If a class does not define a method, it gets a copy of its super class’ method.
method1 | method2 | toString | |
---|---|---|---|
Cass | p("cass 1 "); | p("cass 2 "); | "cass" |
John | p("cass 1 "); | method1(); p("john 2 "); | "john" |
Denny | p("denny 1 "); | method1(); p("john 2 "); | "denny john" |
Michelle | p("michelle 1 "); | method1(); p("john 2 "); | "john" |
Building up the table is normally the hard part, now all we have to do is use the table to answer the questions for the output. For each object in elements
, give the result of calling method1
, method2
, and toString
.
What is the output for the element at index 0 (Cass)?
cass 1
cass 2
cass
What is the output for the element at index 1 (Denny)?
denny 1
denny 1 john 2
denny john
What is the output for the element at index 2 (John)?
cass 1
cass 1 john 2
john
What is the output for the element at index 3 (Michelle)?
michelle 1
michelle 1 john 2
john
Music players case study
Before running a Java program, it needs to be compiled using the javac
command. When Java compiles a piece of code, it will reference this hierarchy internally with the goal of checking to ensure that the code can run. We’ve probably seen dozens of times where the compiler catches mistakes like assigning values of the wrong type to a variable or calling a method that doesn’t exist.
Stack<Integer> s = new Stack<Integer>();
s.containsKey(5); // Stack cannot call containsKey
The compilation process is not as simple with inheritance. Let’s consider the following inheritance hierarchy representing real-world music players.
Object
MusicPlayer
MP3Player
iPod
iPhone
Zune
TapeDeck
CDPlayer
As we saw with interface types, the left-hand side type (promise type) does not need to be the same as the right-hand side type (actual type). Likewise, we can declare a MusicPlayer
as the promise type with its value as a reference to the actual type iPhone
, for example.
MusicPlayer p = new iPhone();
Java checks this line against the inheritance hierarchy.
Is
iPhone
aMusicPlayer
?
Since iPhone
is a MusicPlayer
, we can call p.play()
to play music.
However, we cannot call p.phoneCall()
even though the actual type is iPhone
. By declaring the promise type as MusicPlayer
, the Java compiler can only be guaranteed access to the methods and fields of MusicPlayer
and its super-classes. The intuition behind this is that someone might write a method that returns a MusicPlayer
, but randomly picks between an MP3Player
, a TapeDeck
, or a CDPlayer
as the actual type, so there’s no way for Java to know until running the code the actual type of p
.
MusicPlayer p = new iPhone();
p.play(); // plays music
p.phoneCall(); // compilation error: MusicPlayer cannot call phoneCall
However, we know that p
has an actual type of iPhone
.
Casting
Use a cast to temporarily change the promised type of a value. This is typically used in inheritance to temporarily change the promised type from a more general type to a more specific type. For instance, (iPhone) p
casts the value of a variable p
(with actual type iPhone
) to the promised type iPhone
.
MusicPlayer p = new iPhone();
((iPhone) p).phoneCall();
The Java compiler performs a set of consistency checks at compile time to confirm that the cast is possible, and then another set of consistency checks at runtime when actually evaluating the line of code.
- Compile-time check
- Is it possible for the value of a variable
p
with promised typeMusicPlayer
to actually store a reference to aniPhone
(or a subtype ofiPhone
)? - Runtime check
- Check to make sure that the actual type works with the promised type, otherwise throw a
ClassCastException
.
The runtime check is necessary since it’s possible that the actual type of p
is actually MP3Player
, TapeDeck
, or some other sub-class of MusicPlayer
that isn’t an iPhone
.
MusicPlayer p = new MP3Player();
((iPhone) p).phoneCall();
This code will compile since it’s possible that p
has an actual type of iPhone
based on the relationship between MusicPlayer
and iPhone
, but it will throw a ClassCastException
at runtime.
- Note
- Casting does not change the actual type of an object, but rather only changes the promised type of the expression that evaluates to the object! It’s not possible to change the actual type of an object using casting!
Summary
Java code needs to be compiled before it can be executed. This process is fairly complicated!
- Compilation
- Determine the promised type of the object in an expression. Validate any casting by only inspecting promised types and use the cast’s promised type if it is valid.
- Determine the compile-time method signature, which is the combination of method name and parameters. Start looking in the promised type’s declared class and then look in its super-classes. The chosen method signature must exactly match in name. If there are multiple methods in the class that could work with the promised types, choose the method signature that most closely matches the promised types.
- Runtime
- If the expression involves casting, validate the cast by double checking that the actual type fits with the promised type. If the cast is invalid, throw a
ClassCastException
. - If the method is a
static
method or if it is accessed through thesuper
keyword, call the method determined during compilation. For all other methods (polymorphic methods), based on the method signature determined at compile time, call the method closest to the actual type of the object. The called method must exactly match the method signature determined at compile time. (Note that it’s not possible to have a runtime error in this step since we determined that at least some method could be called during compilation.)
- If the expression involves casting, validate the cast by double checking that the actual type fits with the promised type. If the cast is invalid, throw a