As we saw in the previous section, polymorphism, enabled by inheritance and interfaces, offers significant flexibility. It allows us to write reusable methods like simulateMovement(Vehicle v) or renderOnScreen(Drawable d) that can handle various related types uniformly. This is a powerful feature of object-oriented programming.
However, this flexibility, born from Java’s strict static type checking, has inherent boundaries. These limitations aren’t design oversights; they stem from the very nature of ensuring type safety through fixed contracts. Understanding these boundaries is key to appreciating why a necessary evolution like Generics was added to Java.
Subsection10.5.1Limitation 1: Only Works for Related Types
The core mechanism of polymorphism relies on a shared contract defined by a common superclass or interface. The simulateMovement method works for Car and Bicycle because they both share the Vehicle contract. The renderOnScreen method works for Circle and FancyButton because they both share the Drawable contract.
But what if we want to write reusable code that operates on types that don’t share a predefined relationship? For example, imagine needing a simple container class that can hold a single item, sometimes a String, sometimes an Integer, and sometimes a Player object. These types don’t naturally share a useful common superclass or interface, unlike our examples in the previous section (vehicles or drawable shapes). While we could technically use Object as a common superclass since all classes inherit from it, doing so forces us to use casts constantly and eliminates all the practical compile-time type safety benefits we discussed earlier, leading directly to the problems detailed in the next limitations. Polymorphism based on meaningful hierarchies or interfaces doesn’t directly help us write a single, type-safe container for these unrelated types.
Subsection10.5.2Limitation 2: Loss of Specific Type Information
When we use a superclass or interface reference to achieve polymorphism (like Vehicle v = new Car(); or Drawable shape = new Circle();), the compiler only "sees" the contract of the variable’s declared type (Vehicle or Drawable). While this ensures safety for calling common methods, it means we lose access to the specific methods of the actual object’s type (Car or Circle) without resorting to casting.
Moreover, frequent casting makes code harder to read, debug, and maintain. Each cast is essentially a small "trust me" statement to the compiler, reducing its ability to catch type-related mistakes early. If your code requires many casts, it’s usually a sign that the type system isn’t being leveraged effectively.
This loss of specific type information becomes particularly inconvenient when you know more about the types than the compiler does based on the general variable type. For instance, if you manually ensure that a List<Vehicle> only contains Car objects, you still need to cast each element back to Car every time you want to call a Car-specific method like openTrunk(). Wouldn’t it be better if the compiler could know and enforce that the list only contains cars?
Subsection10.5.3Limitation 3: Weak Type Enforcement in Collections (with Object)
Polymorphism allows a List<Vehicle> to hold Cars, Bicycles, etc. This is often useful. However, what if we needed a collection that could hold any kind of object? Before generics, the only way to do this was to use the ultimate superclass, Object.
// Pre-Generics way to hold anything (using ArrayList for example)
// Note: Using 'raw' ArrayList like this is discouraged in modern Java.
java.util.List anythingList = new java.util.ArrayList<Object>(); // Holds Objects
anythingList.add("Hello"); // String is an Object - OK
anythingList.add(Integer.valueOf(123)); // Integer is an Object - OK
anythingList.add(new Car(...)); // Car is an Object - OK
// The problem comes when retrieving items:
Object item = anythingList.get(0); // Compiler only knows it's an Object
String s = (String) item; // Requires a cast - potentially unsafe!
// What if we get the wrong item by mistake?
// Integer i = (Integer) anythingList.get(0); // Runtime ClassCastException!
Using Object provides maximum flexibility (you can put literally anything in the list), but it completely sacrifices compile-time type safety for the elements within the collection. You lose all information about what specific types are actually stored, forcing casts upon retrieval and pushing type error detection entirely to runtime via potential ClassCastExceptions. While you can partially guard against these runtime type errors using instanceof checks before casting, doing so adds significant complexity and overhead, and still doesn’t solve the fundamental problem: the compiler cannot verify type correctness for the collection’s contents, leaving potential errors hidden until your program runs. There’s no way for the compiler to guarantee that a list declared to hold Objects actually contains only Strings or only Integers at a particular point.
Using raw types (like ArrayList or List without the angle brackets and type parameters, e.g., <String>) is strongly discouraged in modern Java precisely because it bypasses the type-safety benefits provided by generics, leading back to the problems of casting and potential ClassCastExceptions described here. You might encounter raw types in older codebases, but understanding these problems helps us appreciate why generics were introduced and why modern Java code almost always uses them with collections like ArrayList.
Cannot directly handle unrelated types in a type-safe, reusable way.
Supertype Reference Hides Specific Type
Requires casting to access subclass methods; verbose, runtime risk (ClassCastException).
Using Object for Generality
Sacrifices compile-time type safety; requires casting, risks ClassCastException.
Programmers needed a way to write code that was both reusable across different types (like using Object) and type-safe (like using specific types), allowing the compiler to verify correctness and prevent runtime errors. This capability would reduce debugging time, simplify code maintenance, and enhance readability-critical factors in creating robust, maintainable programs. This exact need led to the development of Generics in Java. In the next section, we’ll see concrete examples of how using Object falls short and how code duplication arises, directly motivating the generic solution.
A List<String> can be safely used wherever a List<Object> is expected.
Incorrect. Invariance means precisely the opposite; even though String is an Object, List<String> is NOT a subtype of List<Object>.
Generic types cannot change after they are created.
Incorrect. This describes immutability. Invariance relates to the subtype relationship between generic types with different type arguments.
Even if type A is a subtype of B, GenericType<A> is generally not a subtype of GenericType<B>.
Correct. Invariance means the subtype relationship between the type arguments (like String and Object) does not carry over to the generic types themselves (like List<String> and List<Object>).
The type argument (like String in List<String>) cannot vary and must always be Object.
Incorrect. The type argument can be varied (that’s the point of generics), but the relationship between different parameterizations like List<String> and List<Object> is invariant.
What is a primary drawback of using Object as the element type in collections (e.g., pre-generics ArrayList or List<Object>) to store different types of items?
It prevents adding objects of different types to the same collection.
Incorrect. Using Object specifically allows adding any type of object, which is part of the problem regarding type safety.
It sacrifices compile-time type safety, requiring explicit casting on retrieval and risking runtime ClassCastExceptions.
Correct. The compiler cannot guarantee the type of elements retrieved, forcing unsafe casts and pushing error detection to runtime.
It significantly reduces the storage capacity of the collection.
Incorrect. The element type declaration doesn’t directly impact the collection’s capacity in this way.
It prevents the use of polymorphism within the collection.
Incorrect. You can still leverage polymorphism with Object, but you lose the specific type information needed for safer operations beyond those defined in Object.
Because it prevents the compiler from checking any method calls on the variable.
Incorrect. The compiler does check method calls, but only against the contract of the declared type (e.g., Vehicle), not the specific subtype (Car).
Because the object actually changes its type to the superclass type.
Incorrect. The object retains its specific runtime type (Car); only the reference variable (v) has the more general compile-time type (Vehicle).
Because accessing subclass-specific methods requires casting, which is verbose and carries a runtime risk (ClassCastException).
Correct. While you gain flexibility by using the supertype reference, you lose direct, type-safe access to methods unique to the subclass without resorting to risky runtime casts.
Because it violates the DRY principle.
Incorrect. Using polymorphism often *supports* the DRY principle by allowing common methods to handle multiple types. The limitation is about accessing specific methods, not necessarily about duplication.