Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

IIndexable: A read-only list interface

4.84/5 (15 votes)
22 Sep 2014CDDL4 min read 32.8K  
An interface not present in .NET BCL, IIndexable exposes methods for iterating and indexing a read-only collection.

NOTE: As of .NET 4.5, this article is obsolete. .NET version 4.5 introduced the IReadOnlyList<T> interface with exactly the same members, making this article obsolete. Also, the introduced covariance and contravariance of generic classes was applied to base interfaces like IEnumerable<T>, which grearly simplifies some usage patterns. If you are targeting .NET 4.5 or newer and want to create your own wrappers and extensions of a read-only list, you should use the official interface instead, since collections List<T> implement the read-only interface out of the box. I am also migrating my new projects to use it. 

Introduction 

There are many situations when it is handy to make a collection read-only. A read-only list of items can be passed to other parts of an application knowing that it will not be accidentally or maliciously mutated. IIndexable<T> is a simple interface which does exactly that: it merely exposes an iterator (IEnumerable<T>), a read-only indexer (with only a getter), and length information. However, this simplicity gives it some interesting properties, and various possibilities for future LINQ-like extensions.

Background

Using .NET BCL types only, making a list read-only is usually accomplished by wrapping it into a ReadOnlyCollection<T>. Since that is a light wrapper around the list (source data is not copied), it is efficient and serves its purpose well. Unfortunately, it only achieves read-only semantics by throwing a NotSupportedException for each method which would otherwise modify the source list, which means that such errors are not found at compile time. Additionally, it can be freely passed to any code which accepts an IList (obviously because it implements that contract), which makes the calling code falsely presume that it can modify its contents.

IIndexable, on the other hand, represents an actual read-only contract for collection indexing. Since the data is not copied, but merely wrapped, it can also be efficiently projected (like the LINQ Select method), offset (similar to LINQ Skip), or limited (similar to the Take extension method). Also, due to its simplicity, it is very easy to create a custom implementation if the provided ones are not sufficient.

Using the code

First of all, the interface itself

As stated in the Introduction, the interface is fairly simple:

C#
public interface IIndexable<T> : IEnumerable<T>
{
    /// <summary>
    /// Gets the value of the item at the specified index.
    /// </summary>
    /// <value></value>
    T this[int index] { get; }

    /// <summary>
    /// Gets the length of this collection.
    /// </summary>
    /// <value>The length.</value>
    int Length { get; }
}

Creating a read-only list

We start by wrapping a IList<T> or an Array (T[]) into an IIndexable<T>, using an extension method called AsIndexable():

C#
// source data is an array
var data = new int[] { 1, 2, 3, 4, 5 };

// using the extension method, a ListIndexable
// wrapper is created around the supplied array
IIndexable<int> readonlyData = data.AsIndexable();

// underlying data can be accessed
Console.WriteLine(readonlyData.Length);  // prints 5
Console.WriteLine(readonlyData[3]);      // prints 4

// new object is only a *wrapper*, which means
// changes to the source list are reflected in the
// read-only list
data[3] = 100;
Console.WriteLine(readonlyData[3]);      // prints 100

// but the read-only collection cannot be changed
readonlyData[0] = 5; // this will not compile

Exposing a limited read-only window of a list

Sometimes it is useful to trim the data a bit before passing it to other parts of code, and expose only a partial "window". There are extension methods for that also, and the nice thing about them is that the data is not copied, but the index offsets are rather calculated as they are accessed (on the fly):

C#
// we have a range of numbers in IIndexable
var list = Enumerable.Range(0, 100).ToList();
var items = list.AsIndexable();

// using the extension method, we can limit the 
// read-only list to contain only items from 20th to 30th
var window = items.Offset(20, 10);
Console.WriteLine(window.Length);  // prints 10
Console.WriteLine(window[5]);      // prints 25

// we can do this as many times we need 
// (each call to Offset creates a new wrapper)
var evenSmaller = window.Offset(3);    // length can be omitted
Console.WriteLine(evenSmaller.Length);  // prints 7
Console.WriteLine(evenSmaller[0]);      // prints 23

// again, these are merely light wrappers;
// changing the original list will change all
// derived windows
list[23] = 200;
Console.WriteLine(evenSmaller[0]);      // prints 200

// note that data cannot be changed by callers, and that all
// data outside the specified window is invisible to them

Projecting the data

One of the most important things that LINQ provides is the ability to project items as they are iterated (using the IEnumerable.Select method). The same principle is used here, which allows us to lazy-instantiate objects as needed by the calling code, and still provide the length information and list indexing which is missing in LINQ (IEnumerable):

C#
// start by defining a read only collection
var words = new ListIndexable<string>("the", "quick", "brown", "fox", "jumps");

// skip 2 items, then project
var uppercaseWords = words
    .Offset(2)
    .Select(w => w.ToUpper()); 

// note that the result of this projection is NOT
// an IEnumerable, but an IIndexable, which means
// we still have the length information and the
// posibility to index the collection
    
Console.WriteLine(uppercaseWords[0]); // prints "BROWN" in uppercase

Implementation details

For most actions, a static class IIndexableExt provides extension methods, which mostly just instantiate appropriate wrappers. For example, to wrap a List into an IIndexable, there is the previously mentioned AsIndexable method:

C#
public static class IIndexableExt
{
    /// <summary>
    /// Wraps this list into an IIndexable of same generic type.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="list">The list to wrap.</param>
    /// <returns></returns>
    public static IIndexable<T> AsIndexable<T>(this IList<T> list)
    {
        // simply instantiates the readonly wrapper
        return new ListIndexable<T>(list);
    }
}

This basic wrapper (ListIndexable<T>) is implemented trivially:

C#
/// <summary>
/// IIndexable wrapper for a IList object.
/// </summary>
/// <typeparam name="T"></typeparam>
public class ListIndexable<T> : IIndexable<T>
{
    private readonly IList<T> _src;

    #region Constructor overloads

    // default constructor accepts an IList instance,
    // which is referenced in the private _src field.
    public ListIndexable(IList<T> src)
    { _src = src; }

    // for convenience, there is also the "params"
    // constructor, which allows creating an array 
    // of items by comma separating them
    public ListIndexable(params T[] src)
    { _src = src; }

    #endregion

    #region IIndexed<T> Members

    // indexer only exposes the "get" accessor
    public T this[int index]
    { get { return _src[index]; } }

    // length is taken from the source list
    public int Length
    { get { return _src.Count; } }

    #endregion

    #region IEnumerable<T> Members

    // this is where IEnumerable is implemented,
    // by creating a dedicated IndexableEnumerator
    // for each caller
    public IEnumerator<T> GetEnumerator()
    {
        return new IndexableEnumerator<T>(this);
    }

    // non-generic interface needs to be implemented also
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return new IndexableEnumerator<T>(this);
    }

    #endregion
}

Similarly, the mentioned Offset extension method creates an instance of OffsetIndexable, which does nothing more than offset the index whenever the list is accessed:

C#
// Creates a wrapper around an IIndexable, with 
// a fixed offset.
public class OffsetIndexable<T> : IIndexable<T>
{
    private readonly IIndexable<T> _src;
    private readonly int _offset;
    private readonly int _length;

    #region Constructor overloads

    // when creating the offset, length does not have to
    // be explicitly set (if not, remainder of the list is 
    // used as the final length)

    public OffsetIndexable(IIndexable<T> source, int offset)
    {
        source.ThrowIfNull("source");
        if (offset < 0)
            throw new ArgumentOutOfRangeException(
              "offset", "offset cannot be negative");

        _src = source;
        _offset = offset;
        _length = source.Length - offset;

        if (_length < 0)
            throw new ArgumentOutOfRangeException(
              "length", "length cannot be negative");
    }

    public OffsetIndexable(IIndexable<T> source, int offset, int length)
    {
        source.ThrowIfNull("source");
        if (length < 0)
            throw new ArgumentOutOfRangeException(
              "length", "length cannot be negative");

        _src = source;
        _offset = offset;
        _length = length;
    }

    #endregion

    #region IIndexer<T> Members

    // this is where all the work gets done:
    // - index is checked to see whether it's within bounds
    // - source array is indexed by offseting the specified index
    public T this[int index]
    {
        get
        {
            if (index < 0 || index >= _length)
                throw new ArgumentOutOfRangeException(
                  "index",
                  "index must be a positive integer smaller than length");

            return _src[_offset + index];
        }
    }

    // length is precalculated inside constructor
    public int Length
    {
        get { return _length; }
    }

    #endregion

    #region IEnumerable<T> Members

    // this part is taken from ListIndexable

    #endregion
}

Conclusion

I have encountered the need for a true read-only collection a couple of times myself, and encountered similar questions on a couple of .NET forums. The subject did feel a bit trivial for a full-fledged article, but this simple solution found its purpose for me and I felt that I should share it nevertheless (and it looked like a great way to post my first article with little pain).

One task where this collection proved especially handy was for parsing binary data. A parser interface which only accepts an IIndexable<byte> allowed me to wrap a binary FIFO queue (or any other data source) into a IIndexable<byte>, and reuse various parsers along the stream by simply offsetting and limiting the data before feeding them. This might be a subject for a different article.

History

  • 2011-09-30: Initial revision. Added some implementation details.

License

This article, along with any associated source code and files, is licensed under The Common Development and Distribution License (CDDL)