C# tutorials > Core C# Fundamentals > Data Structures and Collections > How do you implement custom collection classes?

How do you implement custom collection classes?

Creating custom collection classes in C# allows you to tailor data structures to your specific needs. This tutorial provides a comprehensive guide on implementing custom collection classes, covering various aspects from basic implementation to advanced considerations. We'll explore the necessary interfaces, methods, and best practices for building efficient and maintainable custom collections.

Basic Implementation using `IEnumerable` and `IEnumerator`

This code demonstrates a simple custom collection class `PeopleCollection` that stores a list of `Person` objects. It implements `IEnumerable` to allow iteration using a `foreach` loop. The `PeopleEnumerator` class is a nested class that implements `IEnumerator`, providing the logic for iterating through the collection.

`IEnumerable`: This interface defines a single method, `GetEnumerator()`, which returns an `IEnumerator` object. It's the foundation for supporting iteration over a collection.

`IEnumerator`: This interface provides the methods required for iterating through a collection: `MoveNext()` (advances to the next element), `Current` (gets the current element), and `Reset()` (resets the enumerator to its initial position). The `Dispose()` method is used to release any resources held by the enumerator.

Explanation of Key Parts:

  • `PeopleCollection`: This class holds the list of `Person` objects and implements `IEnumerable`, making the collection iterable.
  • `PeopleEnumerator`: This class implements `IEnumerator` and manages the iteration state (the current position). It provides the implementation for `MoveNext()`, `Current`, and `Reset()`.
  • `GetEnumerator()`: The `PeopleCollection` class's `GetEnumerator()` method creates and returns a new `PeopleEnumerator` object, initialized with the collection's data.

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

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

public class PeopleCollection : IEnumerable<Person>
{
    private List<Person> _people = new List<Person>();

    public void Add(Person person)
    {
        _people.Add(person);
    }

    public IEnumerator<Person> GetEnumerator()
    {
        return new PeopleEnumerator(_people);
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    private class PeopleEnumerator : IEnumerator<Person>
    {
        private List<Person> _people;
        private int _position = -1;

        public PeopleEnumerator(List<Person> people)
        {
            _people = people;
        }

        public Person Current
        {
            get
            {
                if (_position < 0 || _position >= _people.Count)
                {
                    throw new InvalidOperationException();
                }
                return _people[_position];
            }
        }

        object IEnumerator.Current => Current;

        public void Dispose()
        {
            //No resources to release
        }

        public bool MoveNext()
        {
            _position++;
            return _position < _people.Count;
        }

        public void Reset()
        {
            _position = -1;
        }
    }
}

public class Example
{
    public static void Main(string[] args)
    {
        PeopleCollection people = new PeopleCollection();
        people.Add(new Person("Alice", 30));
        people.Add(new Person("Bob", 25));
        people.Add(new Person("Charlie", 35));

        foreach (Person person in people)
        {
            Console.WriteLine($"Name: {person.Name}, Age: {person.Age}");
        }
    }
}

Concepts Behind the Snippet

The core concept involves separating the collection's data from the iteration logic. `IEnumerable` defines that a class can be iterated over, while `IEnumerator` handles how the iteration is performed. This separation of concerns makes the code more modular and maintainable.

Key principles:

  • Abstraction: `IEnumerable` provides a high-level interface for iteration, hiding the underlying implementation details.
  • Separation of Concerns: The collection manages the data, and the enumerator manages the iteration logic.
  • Flexibility: You can create different enumerators for the same collection, each providing a different iteration strategy.

Real-Life Use Case Section

Imagine you're building a customer relationship management (CRM) system. You might have a collection of `Customer` objects. You could create a custom collection class for managing these customers. You might want to iterate through customers based on specific criteria (e.g., active customers, customers in a specific region). A custom collection allows you to encapsulate this logic and provide a clean, type-safe way to access your data.

Example:

A gaming application that displays a list of available games. The custom collection would maintain this list and handle tasks such as filtering, sorting, and updating the list dynamically.

Best Practices

  • Implement `IDisposable` for `IEnumerator` if necessary: If your enumerator uses any resources (e.g., file handles, database connections), implement the `IDisposable` interface to ensure that these resources are properly released. In our example, there were no unmanaged resource to dispose of.
  • Consider thread safety: If your collection might be accessed by multiple threads, ensure that your implementation is thread-safe to prevent data corruption. This might involve using locks or other synchronization mechanisms.
  • Use `yield return` for simpler enumerator implementation: For simpler collections, you can use the `yield return` keyword to simplify the implementation of the `GetEnumerator()` method. This approach automatically generates the `IEnumerator` implementation for you.
  • Follow naming conventions: Use clear and descriptive names for your classes and methods.
  • Handle edge cases: Consider what happens if the collection is empty or if the enumerator is used after the collection has been modified. Throw exceptions or handle these cases gracefully.
  • Test thoroughly: Write unit tests to ensure that your custom collection behaves as expected in all scenarios.

Interview Tip

When asked about custom collections in interviews, highlight the importance of understanding `IEnumerable` and `IEnumerator`. Explain how these interfaces enable iteration and how custom implementations allow you to tailor data structures to specific application requirements. Be prepared to discuss the benefits of using custom collections (e.g., type safety, encapsulation) and the potential drawbacks (e.g., increased complexity). Also, be ready to discuss thread safety considerations.

When to Use Them

Use custom collections when:

  • You need a data structure that is not provided by the standard .NET Framework collections.
  • You need to enforce specific constraints or behaviors on your data.
  • You need to optimize performance for specific use cases.
  • You want to encapsulate data access logic within a single class.

Memory Footprint

The memory footprint of a custom collection depends on the underlying data structure used to store the data. For example, if you use a `List`, the memory footprint will be similar to that of a standard `List`. However, you should consider the overhead of any additional fields or methods you add to your custom collection. It is crucial to profile your collection's memory usage in performance-critical scenarios to identify any potential bottlenecks.

Alternatives

Alternatives to creating custom collections include:

  • Using existing .NET Framework collections: The .NET Framework provides a wide range of collection classes that may be suitable for your needs. Consider using these before creating a custom collection.
  • Using third-party collection libraries: Several third-party libraries provide advanced collection classes and data structures.
  • Using LINQ to filter and transform data: LINQ provides a powerful way to query and manipulate data in collections without creating custom collection classes.

Pros

  • Type safety: Custom collections can enforce type safety, preventing accidental insertion of incorrect data types.
  • Encapsulation: Custom collections can encapsulate data access logic, making your code more modular and maintainable.
  • Performance optimization: Custom collections can be optimized for specific use cases, improving performance.
  • Control: Full control over how data is managed and manipulated.

Cons

  • Increased complexity: Creating custom collections can add complexity to your code.
  • Development effort: Developing and testing custom collections requires significant development effort.
  • Maintenance: Custom collections require ongoing maintenance to ensure they remain bug-free and performant.
  • Potential for errors: Manually implementing collections can introduce subtle bugs that are difficult to track down.

Implementing `ICollection` for Full Functionality

Implementing `ICollection` provides a more complete set of functionalities for your custom collection. It includes methods for adding, removing, clearing, and checking for the presence of elements. The above example implements `ICollection` using a `List` as the underlying data structure. Notice how some methods can directly leverage the functionalities already provided by `List`, which makes the implementation simpler and more efficient.

Key aspects of `ICollection`:

  • `Count`: Gets the number of elements in the collection.
  • `IsReadOnly`: Gets a value indicating whether the collection is read-only.
  • `Add(T item)`: Adds an item to the collection.
  • `Clear()`: Removes all items from the collection.
  • `Contains(T item)`: Determines whether the collection contains a specific item.
  • `CopyTo(T[] array, int arrayIndex)`: Copies the elements of the collection to an array, starting at a particular index.
  • `Remove(T item)`: Removes the first occurrence of a specific object from the collection.

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

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

public class PeopleCollection : ICollection<Person>
{
    private List<Person> _people = new List<Person>();

    public int Count => _people.Count;

    public bool IsReadOnly => false; // Or true if you want it read-only

    public void Add(Person person)
    {
        _people.Add(person);
    }

    public void Clear()
    {
        _people.Clear();
    }

    public bool Contains(Person person)
    {
        return _people.Contains(person);
    }

    public void CopyTo(Person[] array, int arrayIndex)
    {
        _people.CopyTo(array, arrayIndex);
    }

    public bool Remove(Person person)
    {
        return _people.Remove(person);
    }

    public IEnumerator<Person> GetEnumerator()
    {
        return _people.GetEnumerator(); // Use List's built-in enumerator
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }


    public class Example
    {
        public static void Main(string[] args)
        {
            PeopleCollection people = new PeopleCollection();
            people.Add(new Person("Alice", 30));
            people.Add(new Person("Bob", 25));
            people.Add(new Person("Charlie", 35));

            Console.WriteLine($"Count: {people.Count}");

            foreach (Person person in people)
            {
                Console.WriteLine($"Name: {person.Name}, Age: {person.Age}");
            }

            Person[] personArray = new Person[people.Count];
            people.CopyTo(personArray, 0);

            Console.WriteLine($"Array Length: {personArray.Length}");

            people.Remove(new Person("Bob",25));

            Console.WriteLine($"Count after Remove: {people.Count}");

            people.Clear();

            Console.WriteLine($"Count after Clear: {people.Count}");
        }
    }
}

FAQ

  • What interfaces are essential for implementing custom collections in C#?

    The most essential interfaces are `IEnumerable` and `IEnumerator`. `IEnumerable` enables iteration using `foreach` loops, while `IEnumerator` provides the actual iteration logic. Implementing `ICollection` adds support for common collection operations like `Add`, `Remove`, and `Clear`.
  • How do I ensure thread safety in a custom collection?

    Thread safety can be achieved by using locking mechanisms (e.g., `lock` keyword) to synchronize access to the collection's data. Alternatively, you can use thread-safe collection classes provided by the .NET Framework (e.g., `ConcurrentBag`, `ConcurrentDictionary`).
  • Can I use `yield return` to simplify the implementation of `GetEnumerator()`?

    Yes, `yield return` provides a concise way to implement the `GetEnumerator()` method. It automatically generates the `IEnumerator` implementation for you, simplifying the code and improving readability. This is often the preferred way for simple enumerations.
  • What are the main differences between `ICollection` and `IList`?

    `ICollection` is a more general interface representing a collection of objects, providing basic operations like `Add`, `Remove`, `Contains`, and `CopyTo`. `IList`, on the other hand, extends `ICollection` and represents an ordered collection of objects accessible by index, adding functionalities like `Insert`, `RemoveAt`, and indexer access.