DEV Community

mohamed Tayel
mohamed Tayel

Posted on

Completing the Paginator Class in C#

Learn how to design and implement an efficient Paginator class in C# for sorting and paginating large datasets. Discover lazy evaluation, read-only collections, and custom page handling to optimize performance and scalability. Perfect for developers handling complex data operations!

The Paginator class represents a powerful collection for paginated data. It allows users to sort, segment, and work with data efficiently through pages. In this article, we'll discuss how to finalize the design and implementation of this class, including how to address key issues related to performance and usability.


Final Features of the Paginator

  1. Page Size and Page Count:

    • The paginator calculates the total number of pages based on the list size and page size.
  2. Bounds Checking:

    • The paginator ensures that page offsets are within valid bounds, preventing out-of-range access.
  3. Lazy Pagination:

    • Pages are constructed on demand, ensuring efficient memory usage and minimal overhead for unused pages.
  4. Enumeration:

    • The paginator implements IEnumerable<IPage<T>> to allow iteration over pages.

Class Implementation

Let’s walk through the complete implementation of the Paginator class, including its key components.

Paginator Class

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

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

    public Paginator(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.");

            // Create a new page on demand
            return new ProjectingPage<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

ProjectingPage Class

The ProjectingPage class represents a view into the shared, sorted list, exposing a segment defined by its offset and size.

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

    public ProjectingPage(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 offsets
        _offset = (ordinal - 1) * pageSize;
        _upperOffset = Math.Min(_offset + pageSize, _source.Count);

        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; }

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

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

Performance Analysis

The current design is efficient for iterating through all pages, but issues arise when only a subset of the data is accessed.

Problem: Cost of Full Evaluation

  • Scenario: Accessing the first page results in sorting the entire dataset, even if no additional pages are accessed.
  • Impact: The first page bears the full cost of sorting and initializing the entire collection.

Solution: Deferred Execution

  • Consider deferring sorting and page construction to occur only when needed.
  • Use smarter caching mechanisms to reduce redundant computations.

Example Usage

Here’s how you can use the paginator with a custom dataset:

class Program
{
    static void Main()
    {
        var workers = new List<Worker>
        {
            new Worker { Name = "Alice", PayRate = 50 },
            new Worker { Name = "Bob", PayRate = 40 },
            new Worker { Name = "Charlie", PayRate = 60 },
            new Worker { Name = "Diana", PayRate = 55 },
            new Worker { Name = "Edward", PayRate = 45 }
        };

        var paginator = new Paginator<Worker>(
            workers,
            pageSize: 2,
            sortKeySelector: w => w.PayRate // Sort by pay rate
        );

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

        foreach (var page in paginator)
        {
            Console.WriteLine($"Page {page.Ordinal}:");
            foreach (var worker in page)
            {
                Console.WriteLine($"{worker.Name}: ${worker.PayRate}");
            }
        }
    }
}

public class Worker
{
    public string Name { get; set; }
    public decimal PayRate { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Output

Total Pages: 3
Page 1:
Bob: $40
Edward: $45
Page 2:
Alice: $50
Diana: $55
Page 3:
Charlie: $60
Enter fullscreen mode Exit fullscreen mode

Performance Testing

To stress-test the paginator:

  1. Replicate the dataset to create a large number of workers.
  2. Measure time taken to fetch the first page and iterate through subsequent pages.
  3. Adjust page size and observe the impact on performance.

Example Stress Test

var replicatedWorkers = workers.Replicate(100000, w => 
    new Worker 
    { 
        Name = w.Name, 
        PayRate = w.PayRate * (decimal)(1 + (new Random().NextDouble() - 0.5) * 0.1) 
    });

var paginator = new Paginator<Worker>(
    replicatedWorkers,
    pageSize: 100,
    sortKeySelector: w => w.PayRate
);

var timer = Stopwatch.StartNew();

foreach (var page in paginator)
{
    Console.WriteLine($"Page {page.Ordinal} - Average PayRate: {page.AveragePayRate()}");
    timer.Restart();
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Flexibility:

    • The paginator supports any data type and sorting criteria, making it highly reusable.
  2. Efficiency:

    • Lazy evaluation ensures sorting happens only when required, and short-lived objects minimize garbage collection overhead.
  3. Challenges:

    • The cost of sorting can be significant for large datasets when only a subset is required.
  4. Future Enhancements:

    • Explore caching strategies for frequently accessed pages.
    • Optimize sorting for scenarios where only the top results are needed.

The Paginator class demonstrates the power of encapsulating complex operations like sorting and pagination into a simple, reusable abstraction.

Top comments (0)