Polymorphism
Table of Contents
Introduction
In biology, polymorphism is defined as the phenomenom where different phenotypes can appear within the population of a species—i.e., the difference between jaguars and panthers (panthers being melanated jaguars). It is a combination of the greak πολύ (polý), or "many", and μορφή (morfí), or "form". Computer scientists eventually repurposed the phrase to fit the phenomena in complex type systems whereby a single variable can take on many different type forms. Polymorphism is deeply embedded in the Java type system and is something that every Java programmer must be intimately familiar with.
The Object Class
It turns out that every single object in Java is a subclass of the Object class. If we take a look at the standard library documentation for Object we'll find that there are a handful of methods which all objects get for free: clone(), equals(...), hashCode(), etc. This means that every single non-Object object in Java is inherently a polymorphic type. It contains within it the type Object and whatever else derived type was defined via the class definition. For example, let's go back to our Animal classes from the previous chapter:
1: class Animal{ 2: String name; 3: Animal(String n){ 4: name = new String(n); 5: } 6: void eat(){ 7: System.out.printf("%s eats!%n", name); 8: } 9: } 10: class Mammal extends Animal{ 11: Mammal(String n){ 12: super(n); 13: } 14: void breathe(){ 15: System.out.printf("%s breathes!%n", name); 16: } 17: } 18: final class Dog extends Mammal{ 19: Dog(String n){ 20: super(n); 21: } 22: void bark(){ 23: System.out.printf("%s barks!%n", name); 24: } 25: }
We can also throw in a few more:
1: final class Cow extends Mammal{ 2: Cow(String n){ 3: super(n); 4: } 5: void moo(){ 6: System.out.printf("%s moos!%n", name); 7: } 8: } 9: final class Pig extends Mammal{ 10: Pig(String n){ 11: super(n); 12: } 13: void oink(){ 14: System.out.printf("%s oinks!%n", name); 15: } 16: }
Because Java allows for subtyping polymorphism, I can store three distinct Animal-derived instances in a single Object-based container:
1: public class Farm{ 2: public static void main(String[] args){ 3: Object[] arr = {new Dog("Lassie"), new Cow("Bessie"), new Pig("Porky")}; 4: } 5: }
If you send this code through the javac compiler you'll find no errors are thrown. This is because Dog, Cow, and Pig are ultimately all derived from Object and can therefore be stored as Objects. We'll investigate this a bit more in the section on polymorphic subtyping.
Method Overriding
One of the most significant advantages of polymorphism in object-oriented programming language is the ability to override ancestral methods. Let's alter the example shown in Listing 3 to try to have each animal make their trademark sound:
1: public class Farm{ 2: public static void main(String[] args){ 3: Animal[] arr = {new Dog("Lassie"), new Cow("Bessie"), new Pig("Porky")}; 4: for(Animal a : arr){ 5: if(a instanceof Dog){ 6: ((Dog) a).bark(); 7: }else if(a instanceof Cow){ 8: ((Cow) a).moo(); 9: }else if(a instanceof Pig){ 10: ((Pig) a).oink(); 11: } 12: } 13: } 14: }
Yuck! But if we compile and run it we will find that it does the job just fine:
josephraskind@stargazer:/tmp/polymorphism$ java Farm.java Lassie barks! Bessie moos! Porky oinks!
We can see that Listing 4 works well enough, but it makes for some rather tiresome coding. Furthermore, anytime we define a new Animal we'll need to adjust Farm to check for another possible derived instance. A way to skirt that is to utilize method overriding. We can reconfigure our class definitions to allow for the technique:
1: class Animal{ 2: String name; 3: Animal(String n){ 4: name = new String(n); 5: } 6: void eat(){ 7: System.out.printf("%s eats!%n", name); 8: } 9: void makeSound(){ 10: System.out.printf("%s makes a noise!%n", name); 11: } 12: } 13: class Mammal extends Animal{ 14: Mammal(String n){ 15: super(n); 16: } 17: void breathe(){ 18: System.out.printf("%s breathes!%n", name); 19: } 20: } 21: final class Dog extends Mammal{ 22: Dog(String n){ 23: super(n); 24: } 25: void makeSound(){ 26: System.out.printf("%s barks!%n", name); 27: } 28: } 29: final class Cow extends Mammal{ 30: Cow(String n){ 31: super(n); 32: } 33: void makeSound(){ 34: System.out.printf("%s moos!%n", name); 35: } 36: } 37: final class Pig extends Mammal{ 38: Pig(String n){ 39: super(n); 40: } 41: void makeSound(){ 42: System.out.printf("%s oinks!%n", name); 43: } 44: }
Method overriding occurs when the exact same method (return type, name, and arguments) is defined in a subclass. The Java runtime will invoke whicher is the most recent definition of the method within the inheritance hierarchy. We can see how this plays out with an updated Farm class:
1: public class Farm{ 2: public static void main(String[] args){ 3: Animal[] arr = {new Dog("Lassie"), new Cow("Bessie"), new Pig("Porky")}; 4: for(Animal a : arr) 5: a.makeSound(); 6: } 7: }
After compiling and running we get the exact same results as Listing 5! How can that be the case? Whenever a method is invoked in Java, it will be checked to see if it has been overridden. If it has, then the "closest" definition relative to the instance type will be used. So even though the compile-time type of a on Line 5 is Animal the actual, underlying runtime-type is Dog, Cow, or Pig. This does not mean that Animal can at all times be a stand-in for any derived type or treated as such. For example, if Dog contained an instance method called fetch then a could not reference it as it would not also be shared by Animal.
Overriding vs Overloading
Students often get confused between method overriding and method overloading. Overriding occurs when a subtype reimplements a method defined above it in its class hierarchy—this requires declaring a method with the exact same method header. Overloading occurs whenever a method is declared with the same name, but a different set of arguments. It is always best practice to add an @Override annotation before functions you are trying to override. If the annotation is used and the function is not being overridden, but rather overloaded, the compiler will throw an error. Otherwise, the compiler will let you make the mistake of overloading instead of overriding a function! Below is a classic example taken from Effective Java (3rd Edition):
1: public class Bigram { 2: private final char first; 3: private final char second; 4: public Bigram(char first, char second) { 5: this.first = first; 6: this.second = second; 7: } 8: public boolean equals(Bigram b) { 9: return b.first == first && b.second == second; 10: } 11: public int hashCode() { 12: return 31 * first + second; 13: } 14: public static void main(String[] args) { 15: Set<Bigram> s = new HashSet<>(); 16: for (int i = 0; i < 10; i++) 17: for (char ch = 'a'; ch <= 'z'; ch++) 18: s.add(new Bigram(ch, ch)); 19: System.out.println(s.size()); 20: } 21: }
Although it looks like equals is overriding Object's equals method, it is not. This is because equals should take an Object type not a Bigram type. Java will have no problem letting this mistake be made. The solution? adding an @Override tag will prevent the above code from compiling. I leave it as an exercise to the reader to test that out.
Polymorphic Subtyping
As mentioned above, the ability for objects in Java to shift between types in the class hierarchy is called polymorphic subtyping. It is often a difficult topic for students to conceptualize as it appears to break the strong type system that has be presented heretofore. This sort of polymorphism is not magic neither is it completely lawless. You can follow a simple algorithm to determine whether or not a class can be treated like another class:
- A class may be treated as a superclass
- A superclass may be treated as a subclass
Examples and Oddities
Below are a few exampels of explicit conversions between different object types:
1: public class Farm{ 2: public static void main(String[] args){ 3: Dog d = new Dog("Lassie"); 4: Cow c = new Cow("Bessie"); 5: 6: Animal a = (Animal) c; // Legal conversion: subclass to superclass 7: a = (Animal) d; // Legal conversion: subclass to superclass 8: d = (Dog) a; // Legal conversion: superclass to subclass (same instance) 9: 10: try{ 11: Dog fake_d = (Dog) ((Animal) c); // Illegal conversion: superclass to subclass (different instance) 12: }catch(ClassCastException e){ // (Runtime Error) 13: System.out.println(e); 14: } 15: try{ 16: Cow fake_c = (Cow) a; // Illegal conversion: superclass to subclass (different instance) 17: }catch(ClassCastException e){ // (Runtime Error) 18: System.out.println(e); 19: } 20: try{ 21: Cow fake_c = (Cow) d; // Illegal conversion: subclass to unrelated subclass 22: }catch(ClassCastException e){ // (Compile-time Error) 23: System.out.println(e); 24: } 25: 26: } 27: }
The compiler will trust that the subtype conversions being performed are valid knowing that the runtime system will eventually catch any mistakes.
Why Polymorphism?
The largest advantage of polymorphism can be witnessed in Listing 7, we can store many different subtypes in a single container and have each perform a unique action using an overridden method. This behavior simplifies interfaces/APIs and makes it easier to reuse code wherever applicable. Polymorphism also grants the ability to replicate real-world "is-a" relationships: a Student is-a Person, a Book "is-a" Item, a Pig "is-a" Animal, etc.
Why Not Polymorphism?
Again, much like inheritance, polymorphism presents as a double-edged sword. It becomes difficult to trust the types being supplied when they can be absolutely any subtype. It also is unlikely that all subtypes perfectly reflect the is-a relationship that might be given by the parent (a Square "is-a" Rectangle, but you cannot independently set the length and height). Finally, there are major performance overheads associated with managing a runtime typechecking system (which is mandatory in a type-safe polymorphic language)1.
Exercises
- The chapter introduces method overriding. Add an
eatmethod toDogthat overrides the one defined inAnimaland prints"[name] wolfs down food!". Then add aCatclass that extendsMammaland also overrideseatto print"[name] nibbles delicately!". Write amainthat creates anAnimal[]containing one instance each ofAnimal,Mammal,Dog, andCat, and callseaton each through the array. Which version ofeatis called for each element? What does this confirm about how Java resolves method calls at runtime? - The chapter shows that
@Overrideis optional but recommended. Remove@OverridefromDog'seatmethod and introduce a typo — change it topublic void eatt(). Does it compile? Does it behave as expected? Now restore@Overrideand introduce the same typo. What does the compiler tell you? What does this demonstrate about the value of@Override? - The chapter states that polymorphism allows a single method to operate on any subtype. Write a static method
makeEat(Animal a)that callsa.eat(). Call it with instances ofAnimal,Mammal, andDog. Which version ofeatis called in each case? Explain why in terms of dynamic dispatch. - The chapter shows that a method defined in a parent class is inherited by all subclasses. Add a
toStringmethod toAnimalthat returns aStringdescribing the animal (e.g."Animal: Rex"). CallSystem.out.printlnon instances ofAnimal,Mammal, andDog— Java automatically callstoStringwhen an object is passed toprintln. What prints for each instance? What does this confirm about how inherited methods behave across the hierarchy? - The chapter shows that a subclass can call its parent's constructor using
super. Add a second fieldString soundtoAnimaland update its constructor to accept bothnameandsound. Trace the changes needed throughMammalandDogto keep everything compiling. Then print thesoundfield from aDoginstance to verify it was correctly passed up the chain. - The chapter states that a reference of a parent type can hold an instance of any subtype. Write a program that assigns a
Doginstance to anAnimalreference and attempt to callbarkthrough that reference. What does the compiler say? What does this tell you about the tradeoff between generality and specificity when using parent type references? - The chapter shows that overriding allows subclasses to specialize inherited behavior. Add a
speakmethod toAnimalthat prints"...", override it inMammalto print"[name] makes a sound!", and override it again inDogto print"[name] barks!". Create one instance of each class, store them all in anAnimal[], and callspeakon each. Verify the most specific version is called in each case. - Write a static method
describeAnimal(Animal a)that prints the name of the animal and callseat. Call it with instances ofAnimal,Mammal, andDog. Which version ofeatis called in each case and why? What does this demonstrate about the value of writing methods that accept parent types rather than specific subtypes? - The chapter introduces polymorphism as a way to write more general code. Without polymorphism, a program that needed to feed every animal on a farm would require a separate method for each type:
feedDog,feedCat,feedBird, and so on. Write both versions — one with a separate method per type and one with a singlefeed(Animal a)method — for at least three animal types. Count the number of lines of code in each version. What happens to the non-polymorphic version when you add a fourth animal type? What happens to the polymorphic version? - The chapter presents polymorphism as resolving the tension between generality and specificity. Revisit the composition-based hierarchy you wrote in the inheritance exercises and consider: could you achieve the same polymorphic behavior (storing different types in a single array and calling a shared method) using composition alone, without inheritance or interfaces? Try it and describe what you find. What does this tell you about when polymorphism through subtyping is genuinely necessary?