DEV Community

loading...
Cover image for Bounded Generic Types & Wildcards - [OOP & Java #6]

Bounded Generic Types & Wildcards - [OOP & Java #6]

tlylt profile image Liu YongLiang Updated on ・8 min read

Continuing on the topic of Generics:

  • Bounded generic types
<T extends String> void print(T t){}
Enter fullscreen mode Exit fullscreen mode
  • Wildcards
Drawer<?> drawer = new Drawer<String>("abc"1);
Enter fullscreen mode Exit fullscreen mode

Bounded Generic Types

Think of the word "bounded" literally - limit something to a range of possible values.

Now link back to the point made in the previous article that whenever we use Generics, we still want as much compile-time type checking as possible. This is a helpful strategy because compile-time errors are easier to catch than run-time errors. If we make sure the right type of objects are being passed around, we have one less thing to worry about after compilation.

// for illustration purposes
class Clothing {};
class Shirt extends Clothing {};
class Tshirt extends Shirt {};

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

Reusing the example of a Drawer object, and also classes that represent Clothing, Shirt and Tshirt, let's take a look at how bounded type parameters work.

The original implementation is without the bound. The placeholder T can technically take in any possible type of object.

new Drawer<Clothing>(new Clothing());
new Drawer<String>(new String());
new Drawer<Double>(new Double());
Enter fullscreen mode Exit fullscreen mode

However, this may not be ideal because

There may be times when you want to restrict the types that can be used as type arguments in a parameterized type.

Thinking along the line of OOP, we may not want our Drawer to take in something that does not belong (e.g. a Drawer of Human?!). So, we may very well intend to bound the placeholder to an upper bound such as Clothing and therefore only accept substitutions of the stated bound and any of the subclasses. For example, Since <T extends Clothing>, T can be substituted with Clothing or Shirt or even Tshirt (Because both Shirt and Tshirt are Clothing, by way of inheritance relationship).

class Drawer<T extends Clothing> {
  T obj;
  Drawer(T obj) {
    this.obj = obj;
  }
  T get() {
    return this.obj;
  }
}

// usage
new Drawer<Double>(new Double()); // ERROR
new Drawer<Clothing>(new Clothing()); // SAFE
new Drawer<Shirt>(new Shirt()); // SAFE
new Drawer<Tshirt>(new Tshirt()); // SAFE
Enter fullscreen mode Exit fullscreen mode

Bounded Generic types are implemented by using the extends keyword. Note that multiple bounds are also possible.

// has the same effect of only extending Clothing
Drawer<T extends Clothing & Shirt & Tshirt> {
  T obj;
  Drawer(T obj) {
    this.obj = obj;
  }
  T get() {
    return this.obj;
  }
}
Enter fullscreen mode Exit fullscreen mode

Another point on bounded type is that bounds increase the number of permitted method calls during compile-time. Without the bound, the compiler only knows that a placeholder can be one of the possible types. Therefore, calling any methods on an object with a placeholder type cannot be guaranteed to succeed.

Imagine you specified a method that takes in an argument of T t and you tried writing t.length(). Not all objects have length() method and hence it is unsafe for the compiler to compile the code. UNLESS, given that all objects are subclasses of Object, any object in Java will support the methods specified in the Object class. This means calling toString() or equals() on t are fine.

With bounded types, now the compiler is aware that the object going into the placeholder is going to be one of the types/subtypes. Then, it is safe to call any methods specified in the bound.


Wildcards

Before we delve into wildcards, we need to review the concept of complex vs simple types

// examples of simple
String s;
Clothing c;
Shirt st;

// examples of complex
String[] s;
Clothing[] c;
Shirt[] st;
Enter fullscreen mode Exit fullscreen mode

Java arrays are complex and because arrays are covariant, we get the following characteristics:

Clothing c = new Shirt(); // SAFE
Clothing[] c = new Shirt[1](); // SAFE
Enter fullscreen mode Exit fullscreen mode

When we use the example of a Drawer, we are also working with this notion of a container. So, just like arrays that store items into a collection, does the following work?

Clothing c = new Shirt();
Drawer<Clothing> c = new Drawer<Shirt>(new Shirt());
Enter fullscreen mode Exit fullscreen mode

Previously, we mentioned that Generics are invariant, meaning the subtyping relationship of simple types does not extend to complex types. This is also why we can omit the type on the Right-Hand-Side of the equal sign.

// SAFE
Drawer<Clothing> c = new Drawer<Clothing>(new Clothing); 
// ALSO SAFE
Drawer<Clothing> c = new Drawer<>(new Clothing): 
Enter fullscreen mode Exit fullscreen mode

If the discussion ended here, then we will not need wildcards. However, the subtyping relationship seems reasonable enough. Given that we have it for Java arrays and they are kind of helpful in many circumstances to support polymorphism, we can make use of wildcards to achieve the same effects for Generics.

A drawer for clothes is ultimately also a drawer for shirts.
We will use wildcards, or expressed as ?, to workaround the restriction.

// SAFE
Drawer<?> d = new Drawer<Clothing>(new Clothing); 
d = new Drawer<Shirt>(new Shirt());

// ALSO SAFE
Drawer<Shirt> s = new Drawer<Shirt>(new Shirt());
Drawer<?> anyDrawer = s;
Enter fullscreen mode Exit fullscreen mode

For understanding, the ? can be thought of as "ANY". So, any drawer can be a drawer for clothing, and any drawer can also be a drawer for shirts.

Bounded wildcards

When we do use <?>, we lose the number of method calls we can perform when we take things out of the drawer. Because the drawer might be of any kind, the only guarantee we get of the things that come out of the drawer is that they are of type Object. Therefore, we can only call Object level methods such as toString() or equals(). A familiar phrase?

Adding a bound to wildcards gives us the ability to first restrict the range of types that can go into the placeholder, and then increase the number of permitted method calls.

// upper-bounded wildcard
// SAFE
Drawer<? extends Shirt> drawerOfShirt;
drawerOfShirt = new Drawer<Shirt>(new Shirt());
Shirt s = drawerOfShirt.get();
drawerOfShirt = new Drawer<Tshirt>(new Tshirt());
Shirt s = drawerOfShirt.get();

// ERROR
drawerOfShirt = new Drawer<Clothing>(new Clothing());
Shirt s = drawerOfShirt.get();
Enter fullscreen mode Exit fullscreen mode

Drawer<? extends Shirt> allows only objects of type Shirt and any of the sub-types. This is quite reasonable because we can imagine a drawer of T-shirts is still a drawer of shirts. Hence when we take things out of the drawer, it can be a T-shirt, which is still a shirt.

Since Clothing is not of type Shirt or a child of Shirt, by the restriction of bound it will not work. Logically, we cannot allow it to be true because if a pair of pants is taken out of the Drawer<Clothing>, it is still a piece of clothing, but it is not a shirt.

We can also do the same for a lower-bound using the keyword super.

// suppose Drawer contains an update method
class Drawer<T> {
  T obj;
  //...
  void update(T obj) {
    this.obj = obj;
  }
}

// lower-bounded wildcard
// SAFE
Drawer<? super Shirt> drawer= new Drawer<Shirt>(new Shirt());
drawer.update(new Shirt());
drawer = new Drawer<Clothing>(new Clothing);
drawer.update(new Shirt()); // still safe
Enter fullscreen mode Exit fullscreen mode

Now the drawer can be a drawer for Shirt, Clothing, and even Object.

A shirt can go into the drawer no matter which reference is it pointing at. This is because a Shirt is a piece of clothing and going into a drawer of clothing is also reasonable.


Get And Put principle

Some observations:

Covariance

  • Shirt is a subtype of Clothing
  • Drawer<Shirt> is a subtype of Drawer<? extends Clothing>

Contravariance

  • Shirt is a subtype of Clothing
  • Drawer<Clothing> is a subtype of Drawer<? super Shirt>

Discussion on the equals method

A typical @Override of equals method in Java:

  • Check if the argument object is exactly the same object as itself
  • If it is of the same type, perform customized comparison based on certain methods available, require typecasting before calling available methods
  • If not of the same type, conclude not equal
// suppose a string is equal to another string if 
// they have the same length
@Override
public boolean equals(Object obj) {
  if (this == obj) {
    return true;
  } else if (obj instanceof String) {
    String s = (String) obj;
    return this.length() == s.length();
  } else {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

With Generics, it is logical to follow up with the following implementation that is not exactly correct.

// suppose a drawer is equal to another drawer if
// they have the same content
@Override
public boolean equals(Object obj) {
  if (this == obj) {
    return true;
  } else if (obj instanceof Drawer<T>) {
    Drawer<T> anyDrawer = (Drawer<T>) obj;
    return this.get().equals(anyDrawer.get());
  } else {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

We have two errors for the above code:

  • illegal generic type for instanceof
    • Generic types are not accessible during runtime due to type erasure, which is the fact that generic types are removed after checking. Suffice to say that during runtime, Drawer<Clothing> is the same as Drawer without the type (also known as raw type). The reason for this is to allow backward compatibility with Java code that existed before the introduction of Generics. Read more about this here
  • unchecked or unsafe operations
    • The previous error tells us that we cannot check if the object being compared with is of type Drawer<T>, T here can be thought of as a specific type. If the if condition is valid and we do a cast
Drawer<T> otherDrawer = (Drawer<T>) obj;
Enter fullscreen mode Exit fullscreen mode

The Right-Hand-Side can be thought of as casting an Object to the raw type Drawer instead of Drawer<T> (again because of type erasure). This can be dangerous because suppose we have Drawer<Stationary>, then we know that it is of type Drawer by doing instanceof Drawer. Now if we cast it to Drawer<T> when T is Clothing, the compiler might just go on and do the cast because to the compiler, we are casting Drawer to Drawer. However, we have Drawer<Clothing> otherDrawer pointing to Drawer<Stationary>, which is not OK. Note that we did not use any bounds so standard invariant characteristics of Generics should apply.

The solution here is to use wildcards.

@Override
public boolean equals(Object obj) {
  if (this == obj) {
    return true;
  } else if (obj instanceof Drawer) {
    Drawer<?> anyDrawer = (Drawer<?>) obj;
    return this.get().equals(anyDrawer.get());
  } else {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The reason why it works now is that ? here means any type ranging from Object to any of your custom types. Saying that it casts to any type is slightly different from saying that it casts to a specific type. It is safe to say that a specific drawer is one of the possible drawers. Any of the possible drawers may not be that one specific drawer.

Again, focusing on the Right-Hand-Side, we know that obj is instanceof Drawer. We have no idea it is a drawer of what. But in the cast, we let it cast to a Drawer that can contain any possible types, which is almost similar to saying that "let it cast to something, as long as that something is a Drawer". This is a reasonable conversion. The compiler won't complain now because you are being very general here. The side effect is that after casting, you can only call the most common methods (Object level methods such as toString() and equals()) on anyDrawer.

Exercises

For the following statements, do they compile?

// for illustration purposes
class Clothing {};
class Shirt extends Clothing {};
class Tshirt extends Shirt {};

class Drawer<T> {
  Drawer(){};
}

1. Drawer<? extends Shirt> s = new Drawer<Tshirt>();
2. Drawer<? super Clothing> s = new Drawer<Shirt>();
3. Drawer<?> s = new Drawer<Double>();
4. Drawer<Clothing> s = new Drawer<>();
5. Drawer<Shirt> s = new Drawer<Clothing>();
6a. Drawer<Tshirt> ts = new Drawer<Tshirt>();
6b. ts = new Drawer<Shirt>();
7a. Drawer<?> ws = new Drawer<Clothing>();
7b. ws = new Drawer<Tshirt>();
Enter fullscreen mode Exit fullscreen mode

try-gif


Answers as follows:

  1. Yes 6a. Yes
  2. No 6b. No
  3. Yes 7a. Yes
  4. Yes 7b. Yes
  5. No

Closing thoughts

A lot to cover in this article and there's more left unmentioned. I think the understanding of Generics will come with increased contact. I did not go into detail about type erasure and raw types. Also, explaining Generics in the context of Java Collections Framework is possibly very helpful and I do intend to talk about that later on.

One resource for further reading is Java Generics FAQs

Discussion (0)

Forem Open with the Forem app