DEV Community

loading...
Cover image for Generics - [OOP & Java #5]

Generics - [OOP & Java #5]

Liu YongLiang
Here's my attempt to contribute:)
Updated on ・5 min read

A slightly more difficult concept, but it links fundamental ideas in previous articles together and presents a neater image of type awareness in Java.


In my previous article on Array, I talked about this need to put things into collections. In Python, a list stores integers, strings, etc. In Java, a primitive array stores a certain type of object based on the type declaration.

int[] arrayOfInts = new int[2];
String[] arrayOfStrings = new String[2];
Enter fullscreen mode Exit fullscreen mode

Back then, I said that in Python you seem to have this freedom to put integers and strings into the same container. There is another degree of freedom that I missed out. While keeping the type of content within a container equal, in Python you can first create a "general" list and then decide whether it is used as a list of integers or a list of strings. In Java, a primitive array is created with a type declaration and will not change afterward. This is why we need Generics.

The Evolution

Suppose you have a drawer. A drawer can contain many things. Being a disorganized person, you don't want to label the drawers, making them specific to only certain objects (a drawer for clothes, another for files, etc). In Java, since you have to state a type in compile-time, you may go for the following approach:

A drawer can contain an Object.

class Drawer {
  Object obj;
  Drawer(Object obj) {
    this.obj = obj;
  }

  Object get() {
    return this.obj;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that you can create a drawer, you may realize that the compiler does not seem to care about what you put into a drawer:

// store a shirt into the drawer
Drawer drawer = new Drawer("shirt");
// store integers into the drawer
Drawer drawer = new Drawer(123);

drawer.get() // returns an "Object"
Enter fullscreen mode Exit fullscreen mode

How do I get something out of the drawer? Now anything stored into the drawer will be of type Object. Whenever you get something out of the drawer, you have to be responsible for type conversion.

In Java, the compile-time type determines what your objects can do, at least before runtime. This means that any method calls to the object taken out of the drawer has to be compatible with the type. If the item is of type Object, then the compiler only knows that it is safe to do Object level things such as toString().

Drawer drawer = new Drawer("shirt");
Drawer drawer = new Drawer(123);

// ERROR: incompatible types: .. Object cannot be converted to String
String shirt = drawer.get();

// Error: ClassCastException Integer cannot be String
String shirt = (String)drawer.get();

Drawer drawer = new Drawer("shirt");
// SAFE: type cast Object to String before assignment
String shirt = (String)drawer.get();
Enter fullscreen mode Exit fullscreen mode

The above illustrates two issues.

  1. You have to typecast the item taken out of the drawer into the correct type for assignment/chaining other method calls.
  2. Due to point 1, calling methods without typecasting may lead to cannot find symbol error. This is the compiler saying "I didn't know this method is available for this type of object!"
// ERROR: what I get out is an Object, which does not
// have a 'length()' method
new Drawer("string").get().length()

// ERROR: order of casting is wrong
(String)(new Drawer("string").get()).length()

// SAFE
((String)new Drawer("string").get()).length()
Enter fullscreen mode Exit fullscreen mode

This approach is clumsy, error-prone, and requires purposeful typecasting every time you take something out of the drawer.

Birth of Generics

To address the issues and requirements I mentioned in the above example, namely:

  • A drawer should be flexible enough to turn into a container for clothes, files, etc without defining similar classes that only differ in type.
  • A drawer has to allow objects to be taken out and retain their type to avoid compulsory typecasting.
  • With all the convenience, a drawer should still make full use of the compiler to do type checking whenever possible.

Which lead us to the following ideas:

  • Don't state what type of object is going into a drawer during "manufacturing".
  • Have the freedom of a template system. Put in placeholders and during actual use, replace placeholders with real values.

With generics,

  • Include type parameters in class/interface/method definitions.
  • The "placeholder" type parameters are in different scope according to where you put them, such as class/method body respectively.
  • Later on, you will replace the type parameters with concrete type arguments, hence a display of polymorphism.

Terminology: type parameters and type arguments

// the "T" and "U" below are type parameters
// generic class declaration
class Car<T> {}
// generic method declaration
public static <U> void doThis(U item) {}

// The "String" below is a type argument
new Car<String>();
Enter fullscreen mode Exit fullscreen mode

Drawer redefined with Generics

class Drawer<T> {
  T obj;
  Drawer(T obj) {
    this.obj = obj;
  }
  T get() {
    return this.obj;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above class definition, we have T within the <> to mean that we are defining a "placeholder". The letter "T" is trivial, you can change it to other strings such as "U", "O" etc. In this sense, you can treat "T" as a variable, it will store a type that you are going to specify later on. Note that you can have multiple placeholders.

Typical Type Parameter Names in Java
Name Meaning
E Element
K Key
V Value
N Number
T Type

Now, the retrieval of objects is easy.

// PREVIOUSLY
Drawer drawer = new Drawer("string");
((String)drawer.get()).length()

// CURRENT
Drawer<String> drawer = new Drawer<String>("string");
drawer.get().length()
Enter fullscreen mode Exit fullscreen mode

Following the ideas of type parameters on class definitions, we can do the same for methods.

// type parameter | return type | method name | method arguments
<U> Drawer<U> customDrawer(U obj) {
  return new Drawer<U>(obj);
}
Enter fullscreen mode Exit fullscreen mode

For generic methods, the type parameter is scoped to the method itself. If this method is within a generic class, the method's type parameter has nothing to do with the class's type parameters.

We can place the generic method into the generic class as well. Again, note that the two type parameters are in different scope.

class Drawer<T> {
  T obj;
  Drawer(T obj) {
    this.obj = obj;
  }
  T get() {
    return this.obj;
  }
  static <U> Drawer<U> customDrawer(U u) {
    return new Drawer<U>(u);
  }
}

// calling the static method like this
Drawer<Integer> drawer = Drawer.customDrawer(123);
drawer.get() // outputs 123
Enter fullscreen mode Exit fullscreen mode

Auto-boxing and unboxing

Usually, the discussion on how Java automatically processes primitive data types into reference types is introduced early in a Java course. Generics revisit this concept:

  • Generics only accept reference types as type arguments.
// ERROR, does not accept primitive types
Drawer<int> drawer = new Drawer<int>(123);
Enter fullscreen mode Exit fullscreen mode
  • Generics support standard auto-boxing & unboxing behavior between primitive and reference data types
// auto-boxing
Drawer<Interger> drawer = new Drawer<Integer>(123);

// retrieve the Integer within the drawer 
Integer x = drawer.get();

// auto-unboxing
int x = drawer.get();
Enter fullscreen mode Exit fullscreen mode

Variance of types

Covariance Contravariant Invariant
Subtype relationship Preserved Reversed Neither preserved nor reversed

Don't be afraid of these technical words, let's go through some examples.

Covariance

A subclass relationship can be extended to complex data types such as Array.

// since Integer is a subtype/subclass of Object
Object o = new Integer(123);
// this relationship is preserved in Java arrays
Object[] arr = new int[1];
Enter fullscreen mode Exit fullscreen mode

Contravariant

The opposite of Covariance. Will be addressed in details when we discuss wildcards in the following article 😂

Invariant

// ERROR
Drawer<Object> drawer = new Drawer<Integer>(123);

// SAFE
Drawer<Integer> drawer = new Drawer<Integer>(123);
Enter fullscreen mode Exit fullscreen mode

Generics are invariant. Hence the type arguments substituted in the left-hand side and the right-hand side of the equal sign must always be the same. Thus, we can leave out the right-hand side Integer.

Drawer<Integer> drawer = new Drawer<>(123);
Enter fullscreen mode Exit fullscreen mode

Java will be able to infer the correct type.


Next up: Wildcards 💨

P.S. Written with reference to NUS CS2030S Lecture on Generics

Discussion (1)

Collapse
jouo profile image
Jashua

Awesome post, very informative, please keep them coming! :)