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:
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.