Friday, October 19, 2007

Breaking a list into columns using custom iterators

I've been working on a web application that has lots of lists of items in columns. The existing code was pretty ugly; it repeated the same pattern over and over again, iterating through the list of whatever items while keeping a count that triggered the move over to the next column. Lots of error prone state variables and conditionals. In obedience to the programming god DRY, I decided to factor out the column building. It turned out to be a great application for the custom iterators that came with .NET 2.0.

First of all, here's the test that shows how it's going to work. Yes, yes, I know this isn't  a proper unit test, there are no assertions and it simply outputs the results to the console, but it serves its purpose here.

using System;
using System.Collections.Generic;
using NUnit.Framework;

namespace Mike.ColumnDemo
{
    [TestFixture]
    public class ColumnTests
    {
        [Test]
        public void BreakIntoColumns()
        {
            // create a list of strings
            List<string> lines = new List<string>();
            for (int i = 0; i < 20; i++)
            {
                lines.Add(string.Format("item {0}", i));
            }

            foreach (Column<string> column in ColumnBuilder.Get(3).ColumnsFrom(lines))
            {
                Console.WriteLine("\nColumn\n");
                foreach (string line in column)
                {
                    Console.WriteLine(line);
                }
            }
        }
    }
}

Isn't that much nicer? Do you like the line segment 'ColumnBuilder.Get(3).ColumnsFrom(lines)'? I've become a big fan of DSL-ish APIs, they're much more readable, even if you do need to do slightly more work up front to make them happen.

Here's the ColumnBuilder class. The nested class ColumnCounter actually does most of the work of working out the column boundaries. It yields a Column object for each column (of course). By the way, I love the Math class, it's well worth digging into even if you don't do a lot of maths in your applications.

using System;
using System.Collections.Generic;

namespace Mike.ColumnDemo
{
    public static class ColumnBuilder
    {
        // return a ColumnBuilder from Get() so that we can write this:
        // foreach(Column<Person> column in ColumnBuilder.Get(4).ColumnsFrom(People)) { ... }
        internal static ColumnCounter Get(int numberOfColumns)
        {
            return new ColumnCounter(numberOfColumns);
        }

        public class ColumnCounter
        {
            int numberOfColumns;

            public ColumnCounter(int numberOfColumns)
            {
                this.numberOfColumns = numberOfColumns;
            }

            // Break the items into the given number of columns
            internal IEnumerable<Column<T>> ColumnsFrom<T>(IList<T> items)
            {
                int itemsPerColumn = (int)Math.Ceiling((decimal)items.Count / (decimal)this.numberOfColumns);
                for (int i = 0; i < this.numberOfColumns; i++)
                {
                    yield return new Column<T>(items, i * itemsPerColumn, ((i + 1) * itemsPerColumn) - 1);
                }
            }
        }
    }
}

Finally, here's the column class. It simply iterates through the list of items and only yields the ones in the start-end range.

using System;
using System.Collections.Generic;

namespace Mike.ColumnDemo
{
    // represents a single column
    public class Column<T> : IEnumerable<T>
    {
        IEnumerable<T> items;
        int start;
        int end;

        public Column(IEnumerable<T> items, int start, int end)
        {
            this.items = items;
            this.start = start;
            this.end = end;
        }

        public IEnumerator<T> GetEnumerator()
        {
            int index = 0;
            foreach (T item in items)
            {
                if (index >= start && index <= end)
                {
                    yield return item;
                }
                index++;
            }
        }

        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
        {
            return (System.Collections.IEnumerator)GetEnumerator();
        }
    }
}

No comments: