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:
public interface IIndexable<T> : IEnumerable<T>
{
T this[int index] { get; }
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():
var data = new int[] { 1, 2, 3, 4, 5 };
IIndexable<int> readonlyData = data.AsIndexable();
Console.WriteLine(readonlyData.Length); Console.WriteLine(readonlyData[3]);
data[3] = 100;
Console.WriteLine(readonlyData[3]);
readonlyData[0] = 5;
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):
var list = Enumerable.Range(0, 100).ToList();
var items = list.AsIndexable();
var window = items.Offset(20, 10);
Console.WriteLine(window.Length); Console.WriteLine(window[5]);
var evenSmaller = window.Offset(3); Console.WriteLine(evenSmaller.Length); Console.WriteLine(evenSmaller[0]);
list[23] = 200;
Console.WriteLine(evenSmaller[0]);
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):
var words = new ListIndexable<string>("the", "quick", "brown", "fox", "jumps");
var uppercaseWords = words
.Offset(2)
.Select(w => w.ToUpper());
Console.WriteLine(uppercaseWords[0]);
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:
public static class IIndexableExt
{
public static IIndexable<T> AsIndexable<T>(this IList<T> list)
{
return new ListIndexable<T>(list);
}
}
This basic wrapper (ListIndexable<T>) is implemented trivially:
public class ListIndexable<T> : IIndexable<T>
{
private readonly IList<T> _src;
#region Constructor overloads
public ListIndexable(IList<T> src)
{ _src = src; }
public ListIndexable(params T[] src)
{ _src = src; }
#endregion
#region IIndexed<T> Members
public T this[int index]
{ get { return _src[index]; } }
public int Length
{ get { return _src.Count; } }
#endregion
#region IEnumerable<T> Members
public IEnumerator<T> GetEnumerator()
{
return new IndexableEnumerator<T>(this);
}
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:
public class OffsetIndexable<T> : IIndexable<T>
{
private readonly IIndexable<T> _src;
private readonly int _offset;
private readonly int _length;
#region Constructor overloads
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
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];
}
}
public int Length
{
get { return _length; }
}
#endregion
#region IEnumerable<T> Members
#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.