Programming Concepts - Generics

Introduction to Generics

Put simply, generics are a way specify that a piece of code such as a function can be used with a variety of different types, rather than being tied to the single type that it is defined for. Generics allows us to write robust and readable code and help detect errors at compile time rather than at runtime.

Generics Motivation

Suppose we are building a datatype such as a Stack that can store any object in it and return any object type. Since all classes and objects in Java have the Object class as their superclass, we can write the following method to add items to our linked list.

public void push(Object e) {
    // Store the element (e) on the top of the stack
}

public Object pop() {
    // Return the element from the top of the stack

}

Here’s how we’d use our datatype to store items:

MyStack stack = new MyStack();
stack.add("codeahoy")
String s = (String) stack.pop() // Cast the Object back to String type

Note that we do a typecase on the last line from Object to String because the pop() method returned an Object not a string. This:

  • is unnecessary because we know exactly what was put into the list (a String object.)
  • introduces the potential for a runtime error e.g. if we mistakenly cast it to the wrong type e.g. (Integer) stack.pop() or put incorrect type e.g. stack.push(new Integer(1)). (This may not be obvious in the example but quite common in large programs.)

This is where generics come in: they allow developers to express their intent and mark function parameters to be restricted to a certain type. We can do the following and poof, the issues we had go away:

MyStack<String> stack = new MyStack(); // Express the intent that we only accept and return Strings
stack.add("codeahoy")
String s = stack.pop() // No need for cast!

// ERROR: This line will result in a Compile time error!
stack.add(new Integer(0))

In the above code, we got rid of the clutter. But there’s also another very big advantage: the compiler can now detect our obvious mistakes (such as putting the wrong datatype in the Stack) at compile time vs runtime. This is a big improvement especially for large programs where such a mistakes are not obvious and result in an error only when a user performs an action.

In short, using generics allow us to express our intent that a piece of code is to be used with a variety of different types instead of being tied to a single type but they do so in a way that allows the compiler to detect mistakes and errors at compile time.

Generic Examples

Generics are supported in many different programming languages. We’ll look at Java and TypeScript to see that while syntax is different, the concepts are the same in each language.

Defining Generics

Here’s how to define a simple generic class in Java.

// Define a generic class with a type parameter "T"
public class Box<T> {
  // The type parameter is used to specify the type of the value stored in the box
  private T value;
  
  public Box(T value) {
    this.value = value;
  }
  
  public T getValue() {
    return value;
  }
}

// To use the generic class, you must specify the type argument when you create an instance
Box<Integer> intBox = new Box<>(123);
int x = intBox.getValue();

Box<String> stringBox = new Box<>("hello");
String s = stringBox.getValue();

For example, here’s couple of methods from the Linked List class in java:

public class LinkedList<E> {
    boolean	add(E e);
    E get(int index)
}

The <E> is the type parameter. Type parameters are used everywhere where you’d use ordinary types like String or Integer.

In TypeScript, we’ll define the same class as:

// Define a generic interface with a type parameter "T"
interface Box<T> {
  value: T;
}

// To use the generic interface, you must specify the type argument when you create an instance
let intBox: Box<number> = { value: 123 };
let x = intBox.value;

let stringBox: Box<string> = { value: "hello" };
let s = stringBox.value;

In both examples, the generic class or interface is defined with a type parameter, which is a placeholder for a specific type that will be specified later. When you create an instance of the class or interface, you must specify the type argument, which is the actual type that will be used in place of the type parameter. This allows you to use the same class or interface with multiple types, as long as they are compatible with the operations defined in the class or interface.

I hope this helps to give you a basic understanding of generics in Java and TypeScript. Let me know if you have any questions or need further clarification.

You can extend or constrain generics to allow only certain types to be used as the type argument. This is useful when you want to ensure that a generic is used only with a specific set of types, or when you want to provide additional type information or behavior for a specific type.

Here are some examples of how you might extend or constrain generics in Java and TypeScript:

// Define a generic class with a type parameter "T" that is constrained to the Number type
public class NumberBox<T extends Number> {
  private T value;
  
  public NumberBox(T value) {
    this.value = value;
  }
  
  public T getValue() {
    return value;
  }
}

// You can now use the NumberBox class with any type that is a subtype of Number, such as Integer or Double
NumberBox<Integer> intBox = new NumberBox<>(123);
int x = intBox.getValue();

NumberBox<Double> doubleBox = new NumberBox<>(3.14);
double y = doubleBox.getValue();

// But you cannot use the NumberBox class with a non-numeric type, such as String
// This will cause a compile-time error
NumberBox<String> stringBox = new NumberBox<>("hello"); // error: String is not a subtype of Number

Here’s similar code written in TypeScript:

// Define a generic interface with a type parameter "T" that is constrained to the Number type
interface NumberBox<T extends Number> {
  value: T;
}

// You can now use the NumberBox interface with any type that is a subtype of Number, such as number or BigInt
let intBox: NumberBox<number> = { value: 123 };
let x = intBox.value;

let bigIntBox: NumberBox<BigInt> = { value: 123 };
let y = bigIntBox.value;

// But you cannot use the NumberBox interface with a non-numeric type, such as string
// This will cause a compile-time error
let stringBox: NumberBox<string> = { value: "hello" }; // error: string is not a subtype of number

In both examples, the type parameter is constrained to the Number type using the “extends” keyword. This means that the type argument must be a subtype of Number in order to be used with the generic class or interface. If you try to use a non-numeric type as the type argument, you will get a compile-time error.

You can also constrain generics to multiple types by using a union type, or to any type that satisfies certain conditions by using a type predicate. For more information about these advanced features, you can refer to the Java or TypeScript documentation.



Speak Your Mind