Java basics: Generics
Table of Contents
What are Generics?
What if we could write a single sort method that sort the elements in an Integer array, a String array, or an array of any type that supports ordering?
Java Generics enable programmers to specify, with a single method declaration, a set of related methods, or with a single class declaration, a set of related types.
Generics also provide compile-time type safety that allows programmers to catch invalid types at compile time.
You will find them most commonly with collections, although they can be used with more than just collections. Typically, though, when they're used with classes like collections, they allow us to constrain the types of data that can be associated with the class that we are using the generics on.
So in general, generics allow us to kind of constrain what types of data we can associate with a with a class. Additionally, because they allow us to specify those data types, more specifically, they also enable us to avoid, in many cases, needing to cast types any more. We can use generics on methods and things that return data types as well. And in that case, if you use a generic for those methods that can return something, then you have a better shot at not needing to cast whatever the data type was that comes off of those methods.
And then finally, they can protect us from certain types of runtime errors because if you're not using generics, but you're working with code that is expecting to work with certain data types, we don't have a really strong way of enforcing that. Then you're likely going to be casting data into the assumed types that you need to work with. And if the objects that you're casting aren't actually cast the ball into the data type that you're expecting, you're going to get errors.
Example Szenario
Lets assume we have the following Java Class:
import java.util.ArrayList;
import java.util.List;
public class Repository {
private List<String> records = new ArrayList<>();
List<String> findAll() {
return records;
}
String save(String record) {
records.add(record);
return record;
}
String findById(long id) {
return records.get(Long.valueOf(id).intValue());
}
public static void main(String[] args) {
Repository repo = new Repository();
repo.save("house");
repo.save("tree");
repo.save("boat");
System.out.println(repo.findAll());
}
}
The Challenge
So there we have our collection of Strings like house, tree and boat. What if we wanted this repository to be capable of working with virtually any data type there is right now? Currently it can only store strings.
The bad way
One way that we could do it would be to remove the <String> notation in the private List declaration and maybe either just not have any generic type at all. Or similarly, just change <String> to <Object>.
I think those two approaches would be functionally similar to each other, if not identical. So what would be the pros and cons to doing that? Well, one potential con of that approach would be that when we retrieve items using the findById in particular, we would then have to cast them to whatever their actual data type was. That kind of opens us up to some runtime errors if we are making assumptions as to what data type we stored in there.
There's a better way
So the better thing to do would be to make our own classes generic, so we can actually make this repository class generic pretty much exactly the way that the list is set to Type String.
Note: We can make this for a given instance of the repository. Now this is key for a given instance of the repository. We can only use one data type. So if you want to use another data type you have to use, you have to create another instance of the repository.
So let's see what that would look like: We use a letter typically to refer to our generic, our generic data type. And the most common letter that you will find when doing this is T standing for type. By doing this, we are telling Java that this class is generic. We must use this T in various key places now.
All we need to do is replacing each String declaration or Type with the Letter T:
import java.util.ArrayList;
import java.util.List;
public class RepositoryGeneric<T> {
private List<T> records = new ArrayList<>();
List<T> findAll() {
return records;
}
T save(T record) {
records.add(record);
return record;
}
T findById(long id) {
return records.get(Long.valueOf(id).intValue());
}
public static void main(String[] args) {
RepositoryGeneric repo = new RepositoryGeneric();
repo.save("house");
repo.save("tree");
repo.save("boat");
System.out.println(repo.findAll());
}
}
It still works. Nothing broke. Why? Because Java is replacing <T> with <Object>. 🤣
If we want this repository instance to be truly generic, however, we can go ahead and do that. All we need to do is defining a repository of string like so:
RepositoryGeneric<String> repo = new RepositoryGeneric<>();
Now we're creating a repository of string. Obviously, this isn't bringing a whole lot of value in this particular example where we were already working with string, right?
But what if we also have a record Class that models a Person:
record Person(String fristName, String lastName){};
Lets create another instance of a repository now, but this one will work for person instances, we call it pRepo. If I try to add a String like in the "house" in the existing Repo, I'm getting an error now, and if I hover over this, it's basically just telling me that, I'm trying to supply a string, but I'm really expecting a person:
So therefore, whatever I pass in here has to be a person. If we change this and add new Person's everything works, no matter if we have a Repo of Strings or a Repo of Persons:
import java.util.ArrayList;
import java.util.List;
public class RepositoryGeneric<T> {
record Person(String fristName, String lastName){};
private List<T> records = new ArrayList<>();
List<T> findAll() {
return records;
}
T save(T record) {
records.add(record);
return record;
}
T findById(long id) {
return records.get(Long.valueOf(id).intValue());
}
public static void main(String[] args) {
RepositoryGeneric<String> repo = new RepositoryGeneric<>();
repo.save("house");
repo.save("tree");
repo.save("boat");
RepositoryGeneric<Person> pRepo = new RepositoryGeneric<>();
pRepo.save(new Person("Chuck", "Norris"));
pRepo.save(new Person("Max", "Müller"));
pRepo.save(new Person("Hans", "Wurst"));
System.out.println(repo.findAll());
System.out.println(pRepo.findAll());
}
}
output:
[house, tree, boat]
[Person[fristName=Chuck, lastName=Norris], Person[fristName=Max, lastName=Müller], Person[fristName=Hans, lastName=Wurst]]
Process finished with exit code 0
Associate an ID
So now that we have the ability to pass in more complex objects like this person class, a very common thing that we will encounter as professional developers using frameworks that implement the repository pattern is that our objects that will interact with the repository typically will have an ID associated with them.
So let's add an ID property to the person class and execute the program again:
import java.util.ArrayList;
import java.util.List;
public class RepositoryGeneric<T> {
record Person(String fristName, String lastName, Long id){};
private List<T> records = new ArrayList<>();
List<T> findAll() {
return records;
}
T save(T record) {
records.add(record);
return record;
}
T findById(long id) {
return records.get(Long.valueOf(id).intValue());
}
public static void main(String[] args) {
RepositoryGeneric<String> repo = new RepositoryGeneric<>();
repo.save("house");
repo.save("tree");
repo.save("boat");
RepositoryGeneric<Person> pRepo = new RepositoryGeneric<>();
pRepo.save(new Person("Chuck", "Norris", 10L));
pRepo.save(new Person("Max", "Müller", 20L));
pRepo.save(new Person("Hans", "Wurst", 30L));
Person foundPerson = pRepo.findById(30L);
System.out.println(foundPerson);
System.out.println(repo.findAll());
System.out.println(pRepo.findAll());
}
}
output:
Exception in thread "main" java.lang.IndexOutOfBoundsException: Index 30 out of bounds for length 3
at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:64)
at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:70)
at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:266)
at java.base/java.util.Objects.checkIndex(Objects.java:359)
at java.base/java.util.ArrayList.get(ArrayList.java:427)
at section11_loose_ends.datastore.RepositoryGeneric.findById(RepositoryGeneric.java:20)
at section11_loose_ends.datastore.RepositoryGeneric.main(RepositoryGeneric.java:34)
Process finished with exit code 1
Wow - we got an index out of bounds exception because we ask for Item 30 and there's only three items in here. This is because we implemented findById very simplistic.
But we can not just change finyById to grab the Persons ID, because to allow this we would need to hardcode the Type to Person, which would not bring us more than when we initially had the hardcoded List Type of String.
Implement a Get ID method
We could introduce an interface that implements a Get ID method like this:
interface IDable<T> {
T id();
}
To ensure that T needs to be something that implements the IDable interface, I can now come up here and constrain our class a little bit, like so:
public class RepositoryGeneric<T extends RepositoryGeneric.IDable> {
Now our IDable interface is itself generic, and that means that we could pass in a generic type here as well. But the question would be, what should we pass in? The answer is that up here, we don't have to actually decide a specific data type. We can leave it generic. And this brings us to another little lesson that I want to point out, which is when we are creating generic classes are generic classes or interfaces, whatever. Our generic types can have more than one parameter. So right now, our our repository class has just one parameter, one generic parameter, which is T, but we can actually have multiple generic parameters. And in fact, some of the functional interfaces do in fact, work with two or even three parameters. Like any of the functional interfaces that are called by or binary or something like that, frequently they're going to take at least two parameters, right? So we can add another parameter to the repository class, and that parameter will be used to specify what the data type is for our IDs.
So the way we can do that is I'm going to put a comma here and then I'm going to introduce another parameter and I will call this V. We can call it whatever we want:
public class RepositoryGeneric_v2<T extends RepositoryGeneric_v2.IDable<V>, V> {
Note that person is not a class and string are not classes that extend from IDable.
Now I can tell you now we're never going to make it happy for string, and that's by design. We're done with string, so I'm going to go ahead and delete all String Stuff. That's never going to work because we didn't write the string class, nor would I care for it to work anyway. But the person class we can do something about.
Implement IDable in person class
And so it is appropriate and correct to actually go ahead and state the actual type that we want tied to this interface here as being type long. This interface, when used in the context of the person class, will have an ID method that is actually showing up as a returning type long:
record Person(String fristName, String lastName, Long id) implements IDable<Long>{};
So now that we've done that, let's come back down to the Person Repo, here we need to pass another argument, since we need two type arguments now. And that makes sense because we now are saying that to create a repository, you need to pass in T and you also have to pass in the right type of IDs. In this case it's long.
RepositoryGeneric_v2<Person, Long> pRepo = new RepositoryGeneric_v2<>();
Lets summarize what we've achieved so far:
import java.util.ArrayList;
import java.util.List;
public class RepositoryGeneric_v2<T extends RepositoryGeneric_v2.IDable<V>, V> {
record Person(String fristName, String lastName, Long id) implements IDable<Long>{};
interface IDable<U> {
U id();
}
private List<T> records = new ArrayList<>();
List<T> findAll() {
return records;
}
T save(T record) {
records.add(record);
return record;
}
T findById(long id) {
return records.stream().filter(p -> p.id().equals(id)).findFirst().orElseThrow();
}
public static void main(String[] args) {
RepositoryGeneric_v2<Person, Long> pRepo = new RepositoryGeneric_v2<>();
pRepo.save(new Person("Chuck", "Norris", 10L));
pRepo.save(new Person("Max", "Müller", 20L));
pRepo.save(new Person("Hans", "Wurst", 30L));
Person foundPerson = pRepo.findById(30L);
System.out.println(foundPerson);
// System.out.println(pRepo.findAll());
}
}
output:
Person[fristName=Hans, lastName=Wurst, id=30]
Process finished with exit code 0
Use generics on static methods
So we're going to create a static method that honestly isn't going to really have anything to do with this existing class. We're going to make a static method that is going to simulate the ability to encode data. So you're going to pass in some data of a type. And then this method will return an encrypted representation of that data. Now, we're not really going to make any real encryption stuff here. This will be fake. But this will allow us to see how we can make generic static methods, which actually can be quite useful at times:
static <T,V> V encrypt(T data, Function<T, V> func) {
return func.apply(data);
}
Important to note: The generic type T on the static method has nothing to do with the generic types that may exist on their enclosing class.
This allows us to make things like this:
System.out.println(RepositoryGeneric_v3.<String, String>encrypt("Hello", m -> m.toUpperCase()));
System.out.println(RepositoryGeneric_v3.<String, Integer>encrypt("Test", m -> m.hashCode()));
output:
HELLO
2603186
Process finished with exit code 0