Implementing Generic Interfaces in C# Data Structures

Implementing generic interfaces for custom data structures in C# enables strong type safety, eliminates the performance overhead of boxing and unboxing, and integrates seamlessly with the standard .NET ecosystem, including LINQ. This guide covers how to define custom generic interfaces, implement standard .NET collection interfaces like IEnumerable<T>, apply generic type constraints, and build a fully functional custom generic collection.

Defining a Custom Generic Interface

Creating a custom generic interface establishes a contract for how data can be added, removed, or accessed in your collection, independent of the underlying storage mechanism.

public interface ICustomStack<T>
{
    void Push(T item);
    T Pop();
    T Peek();
    int Count { get; }
    bool IsEmpty { get; }
}

The type parameter T acts as a placeholder for the actual data type that the consumer specifies upon instantiation.

Implementing Common .NET Interfaces

To make a custom data structure idiomatic in C#, you should implement standard framework interfaces. The most crucial is IEnumerable<T>, which enables the foreach loop and LINQ queries.

Implementing IEnumerable<T> requires satisfying two methods:

  1. IEnumerator<T> GetEnumerator(): The generic enumerator.
  2. IEnumerator IEnumerable.GetEnumerator(): The non-generic explicit implementation required for backward compatibility.

Complete Implementation: Custom Generic Stack

The following example demonstrates a custom linked stack that implements both a custom interface (ICustomStack<T>) and the standard IEnumerable<T>.

using System;
using System.Collections;
using System.Collections.Generic;

public class Node<T>
{
    public T Value { get; set; }
    public Node<T>? Next { get; set; }

    public Node(T value)
    {
        Value = value;
    }
}

public class LinkedStack<T> : ICustomStack<T>, IEnumerable<T>
{
    private Node<T>? _top;
    
    public int Count { get; private set; }
    public bool IsEmpty => _top == null;

    public void Push(T item)
    {
        var newNode = new Node<T>(item)
        {
            Next = _top
        };
        _top = newNode;
        Count++;
    }

    public T Pop()
    {
        if (IsEmpty)
        {
            throw new InvalidOperationException("The stack is empty.");
        }

        T value = _top!.Value;
        _top = _top.Next;
        Count--;
        return value;
    }

    public T Peek()
    {
        if (IsEmpty)
        {
            throw new InvalidOperationException("The stack is empty.");
        }

        return _top!.Value;
    }

    // Generic enumerator using an iterator block
    public IEnumerator<T> GetEnumerator()
    {
        var current = _top;
        while (current != null)
        {
            yield return current.Value;
            current = current.Next;
        }
    }

    // Explicit non-generic implementation
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

Applying Generic Type Constraints

If your data structure relies on specific operations—such as comparison, serialization, or object construction—you can restrict generic types using the where keyword on both the interface and the implementation.

// Constraining T to types that implement IComparable<T>
public interface IPriorityQueue<T> where T : IComparable<T>
{
    void Enqueue(T item);
    T Dequeue();
}

public class PriorityQueue<T> : IPriorityQueue<T> where T : IComparable<T>
{
    public void Enqueue(T item)
    {
        // Comparison logic using item.CompareTo(...)
    }

    public T Dequeue()
    {
        // Retrieval logic based on priority
        throw new NotImplementedException();
    }
}

Common constraints include:

  • where T : struct: Value types only.
  • where T : class: Reference types only.
  • where T : notnull: Non-nullable types.
  • where T : new(): Must have a public parameterless constructor.
  • where T : InterfaceName: Must implement the specified interface.

Usage Example

Once implemented, the custom structure functions just like built-in .NET collections:

var stack = new LinkedStack<int>();
stack.Push(10);
stack.Push(20);
stack.Push(30);

// Using LINQ via IEnumerable<T>
bool containsTwenty = stack.Any(x => x == 20);

// Iterating using foreach
foreach (var item in stack)
{
    Console.WriteLine(item);
}