Skip to content

Instantly share code, notes, and snippets.

@BrianWill
Last active December 3, 2021 22:50
Show Gist options
  • Save BrianWill/42007fe44310bd4102a54e921ec01b00 to your computer and use it in GitHub Desktop.
Save BrianWill/42007fe44310bd4102a54e921ec01b00 to your computer and use it in GitHub Desktop.
C# generics for beginners

Generic Class

Consider a simple class:

class PetOwner {
    public Pet pet;
    
    public void SetPet(Pet p) {
        this.pet = p;
    }
    
    static void Main() {
        PetOwner owner = new PetOwner();
        
        owner.pet = new Cat();     
        owner.pet = new Dog();
        
        owner.SetPet(new Cat());
        
        Pet p = owner.pet;
        Cat c = (Cat)owner.pet;    // the compile-time type of owner.p is Pet, so we must downcast here to Cat
    }
}

Say that in some cases we wish to create PetOwner instances where the Pet field is constrained to be a specific kind of Pet, say a Cat or Dog. That is what making the class 'generic' allows us to do:

// wherever type parameter T is used in the class, the actual type is Pet
class PetOwner<T> where T : Pet {
    public T pet;
    
    public void SetPet(T p) {
        this.pet = p;            // OK: assign T to T
    }
    
    static void Main() {
        PetOwner<Cat> catOwner = new PetOwner<Cat>();    // for this instance, Cat stands in for T
        PetOwner<Dog> dogOwner = new PetOwner<Dog>();    // for this instance, Dog stands in for T
        PetOwner<Pet> petOwner = new PetOwner<Pet>();    // for this instance, Pet stands in for T
        
        petOwner.pet = new Cat();    // OK
        petOwner.pet = new Dog();    // OK
        
        catOwner.pet = new Cat();    // OK
        catOwner.pet = new Dog();    // compile error: compile-time type of catOwner.pet is Cat
        
        catOwner.SetPet(new Cat());  // OK
        catOwner.SetPet(new Dog());  // compile error: expected compile-time type of argument is Cat
        
        Cat c = catOwner.pet;        // OK (notice that we don't need to cast)
        Pet p = catOwner.pet;        // OK
        Dog d = catOwner.pet;        // compile error: compile-time type of catOwner.pet is Cat
    }
}

Functionally, a generic class doesn't let us do anything we couldn't without generics, but now we can have the compiler enforce our intention that particular instances have more specific field, method parameter, and method return types than what is declared in the class itself. A PetOwner<Cat> instance, for example, will be guaranteed by the compiler to only store a Cat, never a Dog or other kind of Pet. So the term 'generics' is arguably misleading: the language feature doesn't allow us to make a class more generic but rather allows us to make its instances more specific. (The sense in which the name 'generic' fits is that we otherwise would have to create separate CatOwner and DogOwner classes to get the same type safety rather than just having one 'generic' class.)

Moreover, an instance of a generic class is a bit more convenient to use because it can spare us from casts that would otherwise be required. (Cutting down on casts also makes the code a bit more efficient because it means performing fewer runtime type checks.)

type parameter equivalence

For method calls and most operations, an expression of a paremeter type is treated like its actual type:

class PetOwner<T> where T : Pet {
    public T pet;
    
    public void SetPet(T p) {
        this.pet = p;  
        p.sleep();              // OK (assuming Pet has a sleep() method)
        p.meow();               // compile error (assuming Pet has no meow() method)
    }
}

However, the compiler does not consider a type parameter's actual type to be a subtype of the type parameter, e.g. Pet is not a subtype of T:

class PetOwner<T> where T : Pet {
    public T pet;
    
    public void SetPet(Pet p) {
        this.pet = p;            // compile error: cannot assign Pet to T
        p = this.pet;            // OK: can assign T to Pet
    }
    
    static void Main() {
        PetOwner<Cat> catOwner = new PetOwner<Cat>(); 
        
        // if the compiler allowed the assignment in SetPet(), this call would
        // be allowed, such that our Cat owner would erroneously end up owning a Dog
        catOwner.SetPet(new Dog());    
    }
}

Generic Method

Individual methods can be made generic with type parameters, such that its parameter types and return type can be constrained to be more specific:

static void Foo<T>(T a, T b) where T : Pet {
    // ...
}

static void Main() {
    Foo<Cat>(new Cat(), new Cat());      // OK
    Foo<Cat>(new Dog(), new Cat());      // compile error
}

The generic method above is arguably a bit more type safe because the compiler will complain if we pass the call something other than a Cat. The more clear utility of generic methods, though, is that they can spare us from casting the return value:

static T Foo<T>(T a) where T : Pet {
    // ...
}

static void Main() {
    Cat c = Foo<Cat>(new Cat());          // OK
    c = Foo(new Cat());                   // OK: don't need to write <Cat> because 
                                          // it's inferred from the argument
    c = Foo(new Dog());                   // compile error: the return type is Dog, which 
                                          // cannot be assigned to a Cat variable
}

typeof(T)

The actual type of a type parameter is inspectable with typeof:

static T Foo<T>(T a) where T : Pet {
    Type t = typeof(T);      // gets the type argument to T, not T itself
    Console.WriteLine(t);
    // ...
}

static void Main() {
    Foo(new Cat());           // prints Cat
    Foo(new Dog());           // prints Dog
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment