DEV Community

loading...
Cover image for C# OOP: Generics

C# OOP: Generics

aromig profile image Adam Romig 🇵🇭 Originally published at romig.dev Updated on ・8 min read

Jump to:

What is a Generic?

Generics in OOP allow us to define a specification of a class or method that can be used with any data type. When we design a generic, the data types of the method parameters or class isn't known - not until it is called or instantiated.

Defining a Generic

A generic class or method can be defined using angle brackets < > and an identifier. The standard identifier used for generics is a capital letter T i.e. <T> but any letter can be used - even a word. If it makes sense to use an identifier other than T, feel free. It's not a rule, just a guideline that most programmers follow.

public static void Method<T>(T param1) { }
Enter fullscreen mode Exit fullscreen mode

We can also use multiple generic types if needed, typically the second one is denoted as <U>.

public static void Method<T, U>(T param1, U param2) { }
Enter fullscreen mode Exit fullscreen mode

Generic Method

See the below example of an overloaded method that swaps two integers or strings values via reference.

using System;

public class Maths {

  public static void Swap(ref int a, ref int b) {
    int temp = a;
    a = b;
    b = temp;
  }

  public static void Swap(ref string a, ref string b) {
    string temp = a;
    a = b;
    b = temp;
  }
}

public class Program {
  public static void Main() {
    int twenty = 20;
    int thirty = 30;
    string forty = "forty";
    string fifty = "fifty";

    Maths.Swap(ref twenty, ref thirty);
    Console.WriteLine("twenty = {0}", twenty);
    Console.WriteLine("thirty = {0}", thirty);

    Maths.Swap(ref forty, ref fifty);
    Console.WriteLine("forty = {0}", forty);
    Console.WriteLine("fifty = {0}", fifty);

  }
}
Enter fullscreen mode Exit fullscreen mode

Output:

twenty = 30
thirty = 20
forty = fifty
fifty = forty
Enter fullscreen mode Exit fullscreen mode

→ dotnetfiddle

Works like a charm, of course. But what if we added another overload for floats? And another for booleans. And another for class objects. This overloaded method is getting big. Now what if we needed to make a fundamental change to all of the overloads? Wouldn't it be nicer and easier to just have one method to rule them all?

using System;

public class Maths {

  public static void Swap<T>(ref T a, ref T b) {
    T temp = a;
    a = b;
    b = temp;
  }
}

public class Program {
  public static void Main() {
    int twenty = 20;
    int thirty = 30;
    string forty = "forty";
    string fifty = "fifty";

    Maths.Swap<int>(ref twenty, ref thirty);
    Console.WriteLine("twenty = {0}", twenty);
    Console.WriteLine("thirty = {0}", thirty);

    Maths.Swap<string>(ref forty, ref fifty);
    Console.WriteLine("forty = {0}", forty);
    Console.WriteLine("fifty = {0}", fifty);

  }
}
Enter fullscreen mode Exit fullscreen mode

Output:

twenty = 30
thirty = 20
forty = fifty
fifty = forty
Enter fullscreen mode Exit fullscreen mode

→ dotnetfiddle

Instead of two separate overloaded methods, we can trim it down to just one. The major difference is the <T> right after the method name and replacing the data type for the parameters and temp variable with T as well.

When we call the method from the Main program, we can specify the data type for T. In the first part, we're passing integers so the method is called as Swap<int>(...) instead of just Swap(...). In the second part, we do the same thing but since we're passing strings, the method is called as Swap<string>(...).

Although, we can actually omit specifying the type when calling the generic method because the compiler will infer the data type from the arguments being passed.

// This works too!
Maths.Swap(ref twenty, ref thirty);
Maths.Swap(ref forty, ref fifty);
Enter fullscreen mode Exit fullscreen mode

Generic Class

Classes can be generic along the same lines of thinking. A common use for a generic class is for defining collectios of items, where adding & removing are performed in the same way regardless of data type.

One common generic class is a collection called a stack, where items are pushed (added to the top of the stack) and popped (removed from the top of the stack). This is referred to as LIFO (Last In, First Out) since the item that is removed on a pop operation is teh last one that was recently added.

LIFO Example, credit Wikipedia

LIFO Example, credit Wikipedia

While the below example is not the Stack<> class exactly (as defined in the .Net Framework), it should show what we're talking about in regards to a use of a generic class.

using System;

public class Stack<T> {
  readonly int max_size;
  int index = 0;
  public int size { get; set; }
  T[] items = null;

  public Stack() : this(100) { } // default max_size of 100

  public Stack(int size) {
    max_size = size;
    items = new T[max_size];
  }

  // Push - Adds item to the top of the stack
  public void Push(T item) {
    if (size >= max_size)
      throw new StackOverflowException();

    items[index++] = item; // adds item in next spot
    size++; // increments actual size
  }

  // Pop - returns item from top and then removes it from the stack
  public T Pop() {
    if (size <= 0)
      throw new InvalidOperationException("Stack is empty, cannot Pop");

    return items[--index]; // decrements by 1 and returns value at index
  }

  // Peek - returns the last item in the listt
  public T Peek() {
    return items[index - 1];
  }

  // Get - returns the value of the specified index
  public T Get(int idx) {
    return items[idx];
  }
}

public class Program
{
  public static void Main() {
    Stack<string> StarWars = new Stack<string>();
    StarWars.Push("The Phantom Menace");
    StarWars.Push("Attack of the Clones");
    StarWars.Push("Revenge of the Sith");
    StarWars.Push("Rogue One");
    StarWars.Push("Solo");
    StarWars.Push("A New Hope");
    StarWars.Push("The Empire Strikes Back");
    StarWars.Push("Return of the Jedi");
    StarWars.Push("The Force Awakens");
    StarWars.Push("The Last Jedi");
    StarWars.Push("The Rise of Skywalker");

    Console.WriteLine("Size of Stack: {0}", StarWars.size);
    Console.WriteLine(StarWars.Peek()); // The Rise of Skywalker
    Console.WriteLine(StarWars.Pop());  // The Rise of Skywalker
    Console.WriteLine(StarWars.Pop());  // The Last Jedi
    Console.WriteLine(StarWars.Peek()); // The Force Awakens
    Console.WriteLine(StarWars.Get(5)); // A New Hope
    Console.WriteLine("Size of Stack: {0}", StarWars.size);
  }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Size of Stack: 11
The Rise of Skywalker
The Rise of Skywalker
The Last Jedi
The Force Awakens
A New Hope
Size of Stack: 9
Enter fullscreen mode Exit fullscreen mode

→ dotnetfiddle

Remember the Coords struct?

struct Coords {
  public int x;
  public int y;

  // constructor
  public Coords(int p1, int p2) {
    x = p1;
    y = p2;
  }
}
Enter fullscreen mode Exit fullscreen mode

Use a Generic struct (yes, structs too!) instead and it's no longer limited to integers.

using System;

struct Coords<T> {
  public T X;
  public T Y;
}

public class Program
{
  public static void Main()
  {
    Coords<int> intCoords;
    intCoords.X = 1;
    intCoords.Y = 2;
    Console.WriteLine("intCoords = {0}, {1}", intCoords.X, intCoords.Y);

    Coords<float> floatCoords;
    floatCoords.X = 1.23f;
    floatCoords.Y = 4.56f;
    Console.WriteLine("floatCoords = {0}, {1}", floatCoords.X, floatCoords.Y);

    Coords<string> stringCoords;
    stringCoords.X = "Over there";
    stringCoords.Y = "A bit closer";
    Console.WriteLine("stringCoords = {0}, {1}", stringCoords.X, stringCoords.Y);
  }
}
Enter fullscreen mode Exit fullscreen mode

Output:

intCoords = 1, 2
floatCoords = 1.23, 4.56
stringCoords = Over there, A bit closer
Enter fullscreen mode Exit fullscreen mode

→ dotnetfiddle


Constraints

We can also place a restriction on a generic to only accept a certain type, such as a struct, class or specific class/interface.

A constraint is implemented on a generic by using the where keyword followed by the generic type variable (e.g. T), a colon, and the constraint type.

There are 6 types of constraints:

where T : struct Type argument must be a value type
where T : class Type argument must be a reference type
where T : new() Type argument must have a public parameterless constructor
where T : Type argument must inherit from class
where T : Type argument must implement from interface
where T : U There are two type arguments T and U. T must be inheritted from U.

Maybe the above example with the Coords struct doesn't make sense for its X & Y values to be strings. Let's add a constraint to it.

struct Coords<T> where T : struct {
  public T X;
  public T Y;
}
Enter fullscreen mode Exit fullscreen mode

Neat! But why are we constraining with a struct? The table above shows that using a struct constraint will restrict the type argument to value types. Since strings are reference types, this should allow this struct to not accept a string. It will; however, accept a single character though. Unfortunately a generic cannot be constrained to individual data types but this is close enough to demonstrate a constraint.

using System;

struct Coords<T> where T : struct {
  public T X;
  public T Y;
}

public class Program
{
  public static void Main()
  {
    Coords<int> intCoords;
    intCoords.X = 1;
    intCoords.Y = 2;
    Console.WriteLine("intCoords = {0}, {1}", intCoords.X, intCoords.Y);

    Coords<float> floatCoords;
    floatCoords.X = 1.23f;
    floatCoords.Y = 4.56f;
    Console.WriteLine("floatCoords = {0}, {1}", floatCoords.X, floatCoords.Y);

    Coords<string> stringCoords;
    stringCoords.X = "Over there";
    stringCoords.Y = "A bit closer";
    Console.WriteLine("stringCoords = {0}, {1}", stringCoords.X, stringCoords.Y);
  }
}
Enter fullscreen mode Exit fullscreen mode

→ dotnetfiddle

This would result in an error on the line instantiating stringCoords.

Compilation error (line 22, col 12): The type 'string' must be a non-nullable value type in order to use it as parameter 'T' in the generic type or method 'Coords<T>'
Enter fullscreen mode Exit fullscreen mode

Collections

As mentioned above, there are collections of generic classes like Stack<>. Another common one is List<>.

List<> Function
.Add() Adds an item at the end of the list
.Clear() Removes all items from the list
.Contains() Checks for an item; returns a boolean
.Count Returns the number of items in the list
.Insert() Adds an item at a specified index

Below is a small example of using the List<> collection.

using System;
using System.Collections.Generic;

public class Program
{
  public static void Main()
  {
  List<string> colors = new List<string>();
  colors.Add("Red");
  colors.Add("Green");
  colors.Add("Blue");
  colors.Remove("Green");

  Console.WriteLine("# of Colors: {0}", colors.Count);
  foreach (var color in colors) {
    Console.WriteLine(color);
  }
  }
}
Enter fullscreen mode Exit fullscreen mode

Output:

# of Colors: 2
Red
Blue
Enter fullscreen mode Exit fullscreen mode

→ dotnetfiddle

.Net has other several built-in collection classes in the System.Collections.Generic namespace, such as Queues, LinkedLists, Dictionaries, etc. The types of collections available in this namespace is fairly diverse so if one of those will provide the functionality of your needs, use them! However if it will make sense to create your own generic collection, go right ahead.


Benefits

  • Reusability: A single generic type definition can be used for multiple purposes with the same code without any alterations. It's much easier to maintain one version of the method/class than multiple overloads.
  • Type Safety: Generic data types provide better type safety, especially when used with collections since we need to specify the type of objects that are passed to them. Type checking is done at complile time instead of run-time, allowing bugs to be caught before release.
  • Performance: Methods & classes utilizing generics provide better performance since they reduce the need for boxing/unboxing (conversion from value type to object and vice versa), and typecasting of the objects used. The actual code for data typed versions of a generic is done on demand instead of multiple typed versions that may not be used.

Awesome stuff!

Generics are a powerful feature of C# that allows us to create more versatile data structures and methods. Using generics can provide great abstraction and extensibility, allowing for code reuse, type-safety, and performance gains. Finding where to use them can take some practice and forethought as it may not be readily apparent. No problem though! Keep the concepts in mind for refactoring opportuntities at the least.

Discussion (0)

Forem Open with the Forem app