Implementing the List Projection Class in C#

moh_moh701

mohamed Tayel

Posted on November 24, 2024

Implementing the List Projection Class in C#

Learn how to implement an efficient and safe pagination system in C# using a projection-based design. Discover how to create immutable, read-only pages with dynamic offsets, leveraging IReadOnlyList<T> for consistent and scalable data handling. Perfect for optimizing large datasets!

In this article, we’ll implement a list projection design for pagination, where each page acts as a "window" into a shared, immutable list. This approach ensures efficiency by avoiding unnecessary copying of list segments and provides safety by leveraging read-only collections.


Key Concepts

  1. Projection Design:

    • Each page is a view of a portion of a shared list, defined by its starting (lower offset) and ending (upper offset) positions.
    • Pages share the same underlying list but expose different sections of it based on their ordinal position.
  2. Read-Only List:

    • To prevent accidental mutation of the shared list, we use a read-only wrapper.
    • This ensures consistency across all pages and avoids potential bugs caused by list modifications.
  3. Efficient Calculations:

    • Page metadata (e.g., Count, offsets) is calculated dynamically, ensuring that page objects are lightweight.
  4. Lazy Enumeration:

    • Items in a page are enumerated lazily using yield return, providing efficient access to the underlying list.

Implementation Plan

We will:

  1. Create a class Page<T> implementing the IPage<T> interface.
  2. Use a read-only wrapper for the shared list to ensure immutability.
  3. Calculate offsets dynamically based on the page’s ordinal position and page size.
  4. Implement lazy enumeration for accessing items within the page.

Code Implementation

The Page Class

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

public class Page<T> : IPage<T>
{
    private readonly IReadOnlyList<T> _source;
    private readonly int _offset;
    private readonly int _upperOffset;

    public Page(IReadOnlyList<T> source, int ordinal, int pageSize)
    {
        if (source == null) throw new ArgumentNullException(nameof(source));
        if (ordinal < 1) throw new ArgumentOutOfRangeException(nameof(ordinal), "Ordinal must be 1 or greater.");
        if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than zero.");

        _source = source;

        // Calculate lower and upper offsets
        _offset = (ordinal - 1) * pageSize;
        _upperOffset = Math.Min(_offset + pageSize, _source.Count);

        // Ensure offset is valid
        if (_offset >= _source.Count)
        {
            _offset = _source.Count;
            _upperOffset = _source.Count;
        }

        Ordinal = ordinal;
        PageSize = pageSize;
    }

    public int Ordinal { get; }
    public int Count => _upperOffset - _offset;
    public int PageSize { get; }

    // Lazy enumeration of items
    public IEnumerator<T> GetEnumerator()
    {
        for (int i = _offset; i < _upperOffset; i++)
        {
            yield return _source[i];
        }
    }

    // Non-generic enumerator
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
Enter fullscreen mode Exit fullscreen mode

Explanation of the Code

  1. Read-Only List:

    • The _source is an IReadOnlyList<T>, ensuring that the underlying list cannot be modified.
    • This guarantees that all page objects reflect the same, immutable dataset.
  2. Offset Calculations:

    • _offset: The starting index for the current page, calculated as (Ordinal - 1) * PageSize.
    • _upperOffset: The exclusive upper index, capped at the size of the list to handle cases where the page exceeds the list's bounds.
  3. Count Calculation:

    • The Count property dynamically calculates the number of items in the page as _upperOffset - _offset.
  4. Lazy Enumeration:

    • The GetEnumerator method uses yield return to lazily return items within the calculated offsets.
    • This approach avoids creating unnecessary collections and ensures efficient memory usage.

Integrating with the Paginated Collection

The SortedListPaginator class will create instances of Page<T> as projections onto the shared, sorted list.

Updated SortedListPaginator Class

public class SortedListPaginator<T> : IPaginatedCollection<T>
{
    private readonly Lazy<IReadOnlyList<T>> _sortedData;
    private readonly int _pageSize;

    public SortedListPaginator(IEnumerable<T> source, int pageSize, Func<T, object> sortKeySelector)
    {
        if (source == null) throw new ArgumentNullException(nameof(source));
        if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than zero.");
        if (sortKeySelector == null) throw new ArgumentNullException(nameof(sortKeySelector));

        _pageSize = pageSize;

        // Lazy initialization of sorted read-only list
        _sortedData = new Lazy<IReadOnlyList<T>>(() => source.OrderBy(sortKeySelector).ToList().AsReadOnly());
    }

    public int PageCount => (int)Math.Ceiling((double)_sortedData.Value.Count / _pageSize);

    public IPage<T> this[int index]
    {
        get
        {
            if (index < 0 || index >= PageCount)
                throw new ArgumentOutOfRangeException(nameof(index), "Page index is out of range.");

            return new Page<T>(_sortedData.Value, index + 1, _pageSize);
        }
    }

    public IEnumerator<IPage<T>> GetEnumerator()
    {
        for (int i = 0; i < PageCount; i++)
        {
            yield return this[i];
        }
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
Enter fullscreen mode Exit fullscreen mode

Example Usage

class Program
{
    static void Main()
    {
        // Sample data: unsorted numbers
        var data = new List<int> { 5, 3, 1, 4, 2, 10, 9, 8, 7, 6 };

        // Create a SortedListPaginator
        var paginator = new SortedListPaginator<int>(
            data,
            pageSize: 3,
            sortKeySelector: x => x // Sort by value
        );

        Console.WriteLine($"Total Pages: {paginator.PageCount}");

        foreach (var page in paginator)
        {
            Console.WriteLine($"Page {page.Ordinal}:");
            foreach (var item in page)
            {
                Console.WriteLine(item);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Output

Total Pages: 4
Page 1:
1
2
3
Page 2:
4
5
6
Page 3:
7
8
9
Page 4:
10
Enter fullscreen mode Exit fullscreen mode

Key Benefits of the Projection Design

  1. Efficiency:

    • No copying of data into new collections.
    • Pages directly reference segments of the shared list.
  2. Safety:

    • The use of IReadOnlyList<T> ensures immutability, preventing accidental modifications.
  3. Simplicity:

    • Offsets and counts are dynamically calculated, reducing complexity and avoiding indexing errors.
  4. Scalability:

    • This approach works seamlessly with large datasets and integrates well with other components.

Conclusion

The projection-based pagination design provides a powerful, efficient, and safe way to handle paginated data. By sharing a read-only list among page objects, we minimize memory usage and maintain consistency. This approach ensures that sorting and pagination remain encapsulated, offering a clean, reusable solution for developers.

💖 💪 🙅 🚩
moh_moh701
mohamed Tayel

Posted on November 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Implementing the List Projection Class in C#