Section10.7Generic Types: Parameterizing Classes and Interfaces
We’ve established the need for a mechanism in Java that allows us to write reusable code (like a single Box class) while maintaining compile-time type safety (unlike the ObjectBox which required risky runtime casts). Generics provide exactly this solution by allowing us to define classes and interfaces that are parameterized by type. Instead of fixing the type (like String in StringBox) or using the overly general Object, we use a placeholder. Let’s see how this works.
Generics introduce the concept of a type parameter, which acts as a placeholder for an actual type that will be specified later when the generic class or interface is used. These type parameters are declared within angle brackets (< and >) immediately after the class or interface name.
Think of T or E as a variable, but instead of holding a value at runtime, it holds a type that is determined at compile time based on how you use the generic class.
Subsection10.7.2Syntax for Declaring Generic Classes and Interfaces
Now that we understand the concept of a type parameter like T, let’s look at the precise syntax – the "grammar" – for defining our own generic classes and interfaces. The key is the placement of the type parameter list.
[access_modifier] class ClassName<TypeParameterList> {
// Class body using the parameters from TypeParameterList
// Example field: private T data;
// Example method: public T getData() { ... }
}
// Example with one type parameter T:
public class Box<T> { /* ... body uses T ... */ }
// Example with two type parameters K and V:
public class Pair<K, V> {
private K key;
private V value;
// ... body uses K and V ...
}
The TypeParameterList contains one or more type parameter declarations (like T, or K, V), separated by commas. These declared parameters (T, K, V) can then be used just like regular types within the class body {...} – for fields, method parameters, return types, and even local variables inside methods.
[access_modifier] interface InterfaceName<TypeParameterList> {
// Interface body using parameters from TypeParameterList in method signatures
// Example method signature: E next();
// Example method signature: void add(E element);
}
// Example with one type parameter E:
public interface List<E> {
boolean add(E element);
E get(int index);
// ... other methods using E ...
}
// Example you might have seen (simplified):
public interface Comparable<T> {
int compareTo(T other); // Uses T in a method parameter
}
Again, the interface name (List, Comparable) is followed immediately by the type parameter list (<E> or <T>). These parameters can then be used as types within the abstract method signatures defined by the interface.
Understanding this basic grammar – placing the type parameter declaration <...> immediately after the name – is the key to creating your own generic types. Now let’s apply this to define our reusable, type-safe Box.
Subsection10.7.3Defining a Generic Class: The Box<T> Example
Let’s apply the syntax we just learned to our Box example from Section 6. Instead of using Object or creating separate StringBox, IntegerBox classes, we define a single, generic Box class using the type parameter T:
// A Generic Box class applying the syntax: public class Box<T>
public class Box<T> {
// The field 'item' is now of the placeholder type T
private T item;
// The parameter of the set method is also type T
public void set(T newItem) {
this.item = newItem;
}
// The return type of the get method is also T
public T get() {
return item;
}
}
The class Box<T> isn’t tied to any single type yet; it’s a blueprint for creating boxes that can hold any reference type T, where T will be specified when we create an instance.
An important point: Generic type parameters in Java cannot directly represent primitive types (like int, double, boolean). They only work with reference types (objects). If you need to store primitives in a generic structure, you must use their corresponding wrapper classes (Integer, Double, Boolean, etc.). For example, you would create a Box<Integer>, not a Box<int>. Java’s autoboxing feature often makes this seamless (automatically converting between int and Integer), but it’s crucial to remember that generics fundamentally operate on objects. This limitation is related to how generics are implemented via type erasure, which we’ll touch upon later.
Subsection10.7.4Using a Generic Class: Instantiation and Type Arguments
To use our generic Box<T>, we need to provide an actual type argument inside the angle brackets when we declare a variable and create an instance. This tells the compiler what specific type the placeholder T should represent for that particular box instance.
// Create a Box specifically for Strings
// We provide String as the type argument for T
Box<String> stringBox = new Box<String>();
// Create a Box specifically for Integers
// We provide Integer (the wrapper class) as the type argument for T
Box<Integer> integerBox = new Box<Integer>();
// Create a Box specifically for Player objects
Box<Player> playerBox = new Box<Player>(); // Assuming Player class exists
Here, Box<String> represents a Box specialized to hold only String objects. The compiler effectively treats T as String for all operations related to stringBox. Similarly, integerBox is specialized for Integer objects.
You might notice that repeating the type argument in the constructor call (new Box<String>()) feels a bit redundant. Java introduced the diamond operator (<>) as a shorthand. If the compiler can infer the type argument from the variable declaration, you can use empty angle brackets in the constructor call:
Now comes the crucial benefit. Because we specified the type argument when creating each Box, the compiler can enforce type safety at compile time for each specific instance:
Box<String> stringBox = new Box<>();
Box<Integer> integerBox = new Box<>();
// Using stringBox: Compiler expects Strings
stringBox.set("Safe and Sound"); // OK
// stringBox.set(123); // Compile Error! Expected String, got int.
// Using integerBox: Compiler expects Integers
integerBox.set(42); // OK (int 42 is autoboxed to Integer)
// integerBox.set("Incorrect"); // Compile Error! Expected Integer, got String.
The compiler catches the type mismatches immediately, preventing us from putting an Integer into a Box<String> or a String into a Box<Integer>. This is exactly the compile-time safety we lost when using ObjectBox.
Furthermore, when retrieving items, no casting is needed! The compiler knows the exact type returned by the get() method based on the box’s instantiation:
String s = stringBox.get(); // OK! Compiler knows get() returns String for Box<String>
Integer i = integerBox.get(); // OK! Compiler knows get() returns Integer for Box<Integer>
System.out.println("Retrieved String: " + s.toUpperCase());
System.out.println("Retrieved Integer: " + i * 2);
Compare this to the examples in the previous section. With generics, the compiler prevents exactly those risky casts associated with the ObjectBox approach and eliminates the need for duplicated classes like StringBox and IntegerBox. Generics give us that powerful combination of type safety and reusability we were looking for.
Just as classes can be generic, interfaces can also be parameterized with type parameters, following the syntax we discussed earlier. You have likely already used one of the most common generic interfaces: java.util.List<E>, where E stands for the type of Element the list will hold.
// Simplified view of the List interface definition
public interface List<E> {
boolean add(E element); // Method parameter uses the type parameter E
E get(int index); // Return type uses the type parameter E
int size();
// ... other methods
}
// ArrayList implements the generic List interface
public class ArrayList<E> implements List<E> {
// Internal implementation using type E...
@Override
public boolean add(E element) { /* ... */ return true; }
@Override
public E get(int index) { /* ... */ return null; } // Placeholder
@Override
public int size() { /* ... */ return 0; } // Placeholder
// ...
}
// We specify String as the type argument E for this List
List<String> names = new ArrayList<>();
names.add("Alice"); // OK
names.add("Bob"); // OK
// names.add(123); // Compile Error! Expected String, got int.
String firstName = names.get(0); // OK! No cast needed, compiler knows it's a String.
You are providing String as the type argument for E. The compiler then ensures, throughout your use of the names variable, that you only addString objects and that the get method returns a String (without needing a cast). This demonstrates the power of generic interfaces combined with generic classes, providing robust compile-time type safety for collections and other parameterized structures you commonly use.
Subsection10.7.7Summary: The Power of Generic Types
Generic types, declared using type parameters like <T> or <E>, allow us to write class and interface definitions that act as blueprints parameterized by type. When we instantiate these generic types (e.g., Box<String>, List<Player>), we provide specific type arguments. The Java compiler then uses these arguments to enforce type safety at compile time, ensuring that only compatible types are used with that specific instance and eliminating the need for manual casting during retrieval. This elegantly solves the conflict between code reusability and type safety that we encountered with pre-generic approaches, allowing us to write safer, more flexible, and more maintainable code.
To specify that the class can only hold objects of type T, which must be defined elsewhere.
Incorrect. T is a placeholder; the actual type (like String or Integer) is specified when an instance is created, not when T itself is defined.
To provide a default type of Object if no type argument is given.
Incorrect. Using the raw type Box defaults to Object-like behavior due to erasure, but T itself is a placeholder, not a default type.
To restrict the class to only work with primitive types.
Incorrect. Generic type parameters work with reference types (objects), not directly with primitive types (like int). Wrapper classes (Integer) must be used.
To act as a placeholder for an actual type that will be provided when an instance of the class is created (e.g., Box<String>).
Correct. T serves as a parameter for a type, allowing the class blueprint to be defined once and used safely with various specific types provided later.
Box<Integer> uses less memory because it knows the type.
Incorrect. Due to type erasure, the runtime object structure is very similar. The main advantage relates to compile-time checks.
Box<Integer> allows storing both Integer and int types directly.
Incorrect. Generics work with reference types; primitives like int are handled via autoboxing to Integer, not stored directly as primitives within the generic structure.
Box<Integer> provides compile-time type safety, preventing non-Integer types from being added and eliminating the need for casting on retrieval.
Correct. The compiler enforces that only Integers (or compatible types via autoboxing) are used, and get() returns Integer directly, preventing ClassCastExceptions and removing cast boilerplate.
Box<Integer> can be used polymorphically where a Box<Number> is expected.
Incorrect. Due to invariance, Box<Integer> is not a subtype of Box<Number>. Wildcards are needed for that kind of flexibility (covered later).
Incorrect. int is a primitive type, not an interface or a class.
Because Integer provides more methods than int.
While Integer does provide methods, the primary reason for using it with generics is the limitation of generics themselves.
To ensure backward compatibility with older Java versions.
Incorrect. Backward compatibility (via type erasure) is related to how generics work internally, but the direct reason for using wrapper classes is a limitation of the generic mechanism itself.
Because Java generics work only with reference types (objects), and Integer is the wrapper class for the primitive type int.
Correct. Generic type parameters must be bound to reference types. Primitives like int cannot be used directly as type arguments; their corresponding wrapper classes must be used instead.