Navigation
Search
|
Java polymorphism and its types
Tuesday August 20, 2024. 10:30 AM , from InfoWorld
Polymorphism refers to the ability of some entities to occur in different forms. It is popularly represented by the butterfly, which morphs from larva to pupa to imago. Polymorphism also exists in programming languages, as a modeling technique that allows you to create a single interface to various operands, arguments, and objects. In Java, polymorphism results in code that is more concise and easier to maintain.
This tutorial provides an overview of the four types of Java polymorphism, but our focus is subtype polymorphism. You’ll learn about upcasting and late binding, abstract classes (which cannot be instantiated), and abstract methods (which cannot be called). You’ll also learn about downcasting and runtime-type identification, and you’ll get a first look at covariant return types. What you’ll learn in this Java tutorial The four types of Java polymorphism About subtype polymorphism Examples with upcasting and late binding Examples with abstract classes and methods Examples with downcasting and runtime-type identification First look at covariant return types download Get the code Download the source code for examples in this tutorial. Created by Jeff Friesen. The four types of polymorphism in Java While this tutorial focuses on subtype polymorphism, there are several other types you should know about. Coercion is an operation that serves multiple types through implicit-type conversion. For example, you divide an integer by another integer or a floating-point value by another floating-point value. If one operand is an integer and the other operand is a floating-point value, the compiler coerces (implicitly converts) the integer to a floating-point value to prevent a type error. (There is no division operation that supports an integer operand and a floating-point operand.) Another example is passing a subclass object reference to a method’s superclass parameter. The compiler coerces the subclass type to the superclass type to restrict operations to those of the superclass. Overloading refers to using the same operator symbol or method name in different contexts. For example, you might use + to perform integer addition, floating-point addition, or string concatenation, depending on the types of its operands. Also, multiple methods having the same name can appear in a class (through declaration and/or inheritance). Parametric polymorphism stipulates that within a class declaration, a field name can associate with different types and a method name can associate with different parameter and return types. The field and method can then take on different types in each class instance (object). For example, a field might be of type Double (a member of Java’s standard class library that wraps a double value) and a method might return a Double in one object, and the same field might be of type String and the same method might return a String in another object. Java supports parametric polymorphism via generics, which I’ll discuss in a future article. Subtype means that a type can serve as another type’s subtype. When a subtype instance appears in a supertype context, executing a supertype operation on the subtype instance results in the subtype’s version of that operation executing. For example, consider a fragment of code that draws arbitrary shapes. You can express this drawing code more concisely by introducing a Shape class with a draw() method; by introducing Circle, Rectangle, and other subclasses that override draw(); by introducing an array of type Shape whose elements store references to Shape subclass instances; and by calling Shape‘s draw() method on each instance. When you call draw(), it’s the Circle‘s, Rectangle‘s or other Shape instance’s draw() method that gets called. We say that there are many forms of Shape‘s draw() method. Ad-hoc vs. universal polymorphism Like many developers, I classify coercion and overloading as ad-hoc polymorphism, and parametric and subtype as universal polymorphism. While valuable techniques, I don’t believe coercion and overloading are true polymorphism; they’re more like type conversions and syntactic sugar. Subtype polymorphism: Upcasting and late binding Subtype polymorphism relies on upcasting and late binding. Upcasting is a form of casting where you cast up the inheritance hierarchy from a subtype to a supertype. No cast operator is involved because the subtype is a specialization of the supertype. For example, Shape s = new Circle(); upcasts from Circle to Shape. This makes sense because a circle is a kind of shape. After upcasting Circle to Shape, you cannot call Circle-specific methods, such as a getRadius() method that returns the circle’s radius, because Circle-specific methods are not part of Shape‘s interface. Losing access to subtype features after narrowing a subclass to its superclass seems pointless, but is necessary for achieving subtype polymorphism. Suppose that Shape declares a draw() method, its Circle subclass overrides this method, Shape s = new Circle(); has just executed, and the next line specifies s.draw();. Which draw() method is called: Shape‘s draw() method or Circle‘s draw() method? The compiler doesn’t know which draw() method to call. All it can do is verify that a method exists in the superclass, and verify that the method call’s arguments list and return type match the superclass’s method declaration. However, the compiler also inserts an instruction into the compiled code that, at runtime, fetches and uses whatever reference is in s to call the correct draw() method. This task is known as late binding. Late binding vs. early binding Late binding is used for calls to non-final instance methods. For all other method calls, the compiler knows which method to call. It inserts an instruction into the compiled code that calls the method associated with the variable’s type and not its value. This technique is known as early binding. I’ve created an application that demonstrates subtype polymorphism in terms of upcasting and late binding. This application consists of Shape, Circle, Rectangle, and Shapes classes, where each class is stored in its own source file. Listing 1 presents the first three classes. Listing 1. Declaring a hierarchy of shapes class Shape { void draw() { } } class Circle extends Shape { private int x, y, r; Circle(int x, int y, int r) { this.x = x; this.y = y; this.r = r; } // For brevity, I've omitted getX(), getY(), and getRadius() methods. @Override void draw() { System.out.println('Drawing circle (' + x + ', '+ y + ', ' + r + ')'); } } class Rectangle extends Shape { private int x, y, w, h; Rectangle(int x, int y, int w, int h) { this.x = x; this.y = y; this.w = w; this.h = h; } // For brevity, I've omitted getX(), getY(), getWidth(), and getHeight() // methods. @Override void draw() { System.out.println('Drawing rectangle (' + x + ', '+ y + ', ' + w + ',' + h + ')'); } } Listing 2 presents the Shapes application class whose main() method drives the application. Listing 2. Upcasting and late binding in subtype polymorphism class Shapes { public static void main(String[] args) { Shape[] shapes = { new Circle(10, 20, 30), new Rectangle(20, 30, 40, 50) }; for (int i = 0; i < shapes.length; i++) shapes[i].draw(); } } The declaration of the shapes array demonstrates upcasting. The Circle and Rectangle references are stored in shapes[0] and shapes[1] and are upcast to type Shape. Each of shapes[0] and shapes[1] is regarded as a Shape instance: shapes[0] isn’t regarded as a Circle; shapes[1] isn’t regarded as a Rectangle. Late binding is demonstrated by the shapes[i].draw(); expression. When i equals 0, the compiler-generated instruction causes Circle‘s draw() method to be called. When i equals 1, however, this instruction causes Rectangle‘s draw() method to be called. This is the essence of subtype polymorphism. Assuming that all four source files (Shapes.java, Shape.java, Rectangle.java, and Circle.java) are located in the current directory, compile them via either of the following command lines: javac *.java javac Shapes.java Run the resulting application: java Shapes You should observe the following output: Drawing circle (10, 20, 30) Drawing rectangle (20, 30, 40, 50) Abstract classes and methods When designing class hierarchies, you’ll find that classes nearer the top of these hierarchies are more generic than classes that are lower down. For example, a Vehicle superclass is more generic than a Truck subclass. Similarly, a Shape superclass is more generic than a Circle or a Rectangle subclass. It doesn’t make sense to instantiate a generic class. After all, what would a Vehicle object describe? Similarly, what kind of shape is represented by a Shape object? Rather than code an empty draw() method in Shape, we can prevent this method from being called and this class from being instantiated by declaring both entities to be abstract. Java provides the abstract reserved word to declare a class that cannot be instantiated. The compiler reports an error when you try to instantiate this class. abstract is also used to declare a method without a body. The draw() method doesn’t need a body because it is unable to draw an abstract shape. Listing 3 demonstrates. Listing 3. Abstracting the Shape class and its draw() method abstract class Shape { abstract void draw(); // semicolon is required } Abstract cautions The compiler reports an error when you attempt to declare a class abstract and final. For example, the compiler complains about abstract final class Shape because an abstract class cannot be instantiated and a final class cannot be extended. The compiler also reports an error when you declare a method abstract but don’t declare its class abstract. Removing abstract from the Shape class’s header in Listing 3 would result in an error, for instance. This would be an error because a non-abstract (concrete) class cannot be instantiated when it contains an abstract method. Finally, when you extend an abstract class, the extending class must override all of the abstract methods, or else the extending class must itself be declared to be abstract; otherwise, the compiler will report an error. An abstract class can declare fields, constructors, and non-abstract methods in addition to or instead of abstract methods. For example, an abstract Vehicle class might declare fields describing its make, model, and year. Also, it might declare a constructor to initialize these fields and concrete methods to return their values. Check out Listing 4. Listing 4. Abstracting a vehicle abstract class Vehicle { private String make, model; private int year; Vehicle(String make, String model, int year) { this.make = make; this.model = model; this.year = year; } String getMake() { return make; } String getModel() { return model; } int getYear() { return year; } abstract void move(); } You’ll note that Vehicle declares an abstract move() method to describe the movement of a vehicle. For example, a car rolls down the road, a boat sails across the water, and a plane flies through the air. Vehicle‘s subclasses would override move() and provide an appropriate description. They would also inherit the methods and their constructors would call Vehicle‘s constructor. Downcasting and RTTI Moving up the class hierarchy, via upcasting, entails losing access to subtype features. For example, assigning a Circle object to Shape variable s means that you cannot use s to call Circle‘s getRadius() method. However, it’s possible to once again access Circle‘s getRadius() method by performing an explicit cast operation like this one: Circle c = (Circle) s;. This assignment is known as downcasting because you are casting down the inheritance hierarchy from a supertype to a subtype (from the Shape superclass to the Circle subclass). Although an upcast is always safe (the superclass’s interface is a subset of the subclass’s interface), a downcast isn’t always safe. Listing 5 shows what kind of trouble could ensue if you use downcasting incorrectly. Listing 5. The problem with downcasting class Superclass { } class Subclass extends Superclass { void method() { } } public class BadDowncast { public static void main(String[] args) { Superclass superclass = new Superclass(); Subclass subclass = (Subclass) superclass; subclass.method(); } } Listing 5 presents a class hierarchy consisting of Superclass and Subclass, which extends Superclass. Furthermore, Subclass declares method(). A third class named BadDowncast provides a main() method that instantiates Superclass. BadDowncast then tries to downcast this object to Subclass and assign the result to variable subclass. In this case the compiler will not complain because downcasting from a superclass to a subclass in the same type hierarchy is legal. That said, if the assignment was allowed the application would crash when it tried to execute subclass.method();. In this case the JVM would be attempting to call a nonexistent method, because Superclass doesn’t declare method(). Fortunately, the JVM verifies that a cast is legal before performing a cast operation. Detecting that Superclass doesn’t declare method(), it would throw a ClassCastException object. (I’ll discuss exceptions in a future article.) Compile Listing 5 as follows: javac BadDowncast.java Run the resulting application: java BadDowncast You should observe the following output: Exception in thread 'main' java.lang.ClassCastException: class Superclass cannot be cast to class Subclass (Superclass and Subclass are in unnamed module of loader 'app') at BadDowncast.main(BadDowncast.java:17) Runtime type identification The JVM’s cast verification in Listing 5 illustrates runtime type identification (or RTTI, for short). Cast verification performs RTTI by examining the type of the cast operator’s operand to see whether the cast should be allowed or not. In this scenario, the cast should not be allowed. Another form of RTTI involves the instanceof operator. This operator checks the left operand to see whether or not it’s an instance of the right operand and returns true when this is the case. The following example introduces instanceof to Listing 5, to prevent the ClassCastException: if (superclass instanceof Subclass) { Subclass subclass = (Subclass) superclass; subclass.method(); } The instanceof operator detects that variable superclass‘s instance was not created from Subclass and returns false to indicate this fact. As a result, the code that performs the illegal cast will not execute. Because a subtype is a kind of supertype, instanceof will return true when its left operand is a subtype instance or a supertype instance of its right operand supertype. The following example demonstrates: Superclass superclass = new Superclass(); Subclass subclass = new Subclass(); System.out.println(subclass instanceof Superclass); // Output: true System.out.println(superclass instanceof Superclass); // Output: true This example assumes the class structure shown in Listing 5 and instantiates Superclass and Subclass. The first System.out.println() method call outputs true because subclass‘s reference identifies an instance of a subclass of Superclass; the second System.out.println() method call outputs true because superclass‘s reference identifies an instance of Superclass. Don’t overuse instanceof Overusing the instanceof operator can indicate poor software design. For example, suppose you decide to use multiple instanceof expressions to determine whether a shape object is a Square, a Circle, or some other Shape subtype. When you introduce a new Shape subtype, you might forget to include an instanceof test to see if shape is an instance of that type, which would lead to a bug. Minimize your reliance on instanceof to special cases. Most of the time, you’ll be better off using subtype polymorphism. Covariant return types A covariant return type is a method return type that, in the superclass’s method declaration, is the supertype of the return type in the subclass’s overriding method declaration. I’ve created a small application that demonstrates this language feature. Check out Listing 6 for the source code. Listing 6. Demonstrating covariant return types class BaseReturnType { @Override public String toString() { return 'base class return type'; } } class DerivedReturnType extends BaseReturnType { @Override public String toString() { return 'derived class return type'; } } class BaseClass { BaseReturnType createReturnType() { return new BaseReturnType(); } } class DerivedClass extends BaseClass { @Override DerivedReturnType createReturnType() { return new DerivedReturnType(); } } public class CRTDemo { public static void main(String[] args) { BaseReturnType brt = new BaseClass().createReturnType(); System.out.println(brt); DerivedReturnType drt = new DerivedClass().createReturnType(); System.out.println(drt); } } Listing 6 declares BaseReturnType and BaseClass superclasses and DerivedReturnType and DerivedClass subclasses. Each of BaseClass and DerivedClass declares a createReturnType() method. BaseClass‘s method has its return type set to BaseReturnType, whereas DerivedClass‘s overriding method has its return type set to DerivedReturnType, a subclass of BaseReturnType. Covariant return types minimize upcasting and downcasting. For example, DerivedClass‘s createReturnType() method doesn’t need to upcast its DerivedReturnType instance to its DerivedReturnType return type. Furthermore, this instance doesn’t need to be downcast to DerivedReturnType when assigning to variable drt. Compile Listing 6 as follows: javac CRTDemo.java Run the resulting application: java CRTDemo You should observe the following output: base class return type derived class return type In the absence of covariant return types, you would end up with Listing 7. Listing 7. Demonstrating the absence of covariant return types class BaseReturnType { @Override public String toString() { return 'base class return type'; } } class DerivedReturnType extends BaseReturnType { @Override public String toString() { return 'derived class return type'; } } class BaseClass { BaseReturnType createReturnType() { return new BaseReturnType(); } } class DerivedClass extends BaseClass { @Override BaseReturnType createReturnType() { return new DerivedReturnType(); } } public class CRTDemo { public static void main(String[] args) { BaseReturnType brt = new BaseClass().createReturnType(); System.out.println(brt); DerivedReturnType drt = (DerivedReturnType) new DerivedClass().createReturnType(); System.out.println(drt); } } In Listing 7, the first bolded code reveals an upcast from DerivedReturnType to BaseReturnType, and the second bolded code uses the required (DerivedReturnType) cast operator to downcast from BaseReturnType to DerivedReturnType before the assignment to drt. Conclusion Polymorphism lets you program in the abstract by creating uniform interfaces to different kinds of operands, arguments, and objects. In this article, you discovered subtype polymorphism, in which a type can serve as another type’s subtype. You also learned that subtype polymorphism relies on upcasting and late binding; that classes that describe these uniform interfaces are declared abstract and can contain abstract methods; that downcasting relies on the cast operator and can result in ClassCastExceptions (which is why you can verify the target type via the instanceof operator—a form of RTTI); and that covariant return types minimize upcasting and downcasting in a method return type context.
https://www.infoworld.com/article/2244198/java-101-polymorphism-in-java.html
Related News |
25 sources
Current Date
Nov, Thu 21 - 11:44 CET
|