devxlogo

Build a Generic Range Class with .NET 2.0

Build a Generic Range Class with .NET 2.0

am a big fan of objects. It’s so convenient to work with programming entities that you can ask to do something and they just do it. The shift that we developers have made from procedural style to OO style allows us to express ideas in much richer ways than were possible using procedural methods. Knowing how to identify areas where a new object can be introduced to simplify the code in your application is a talent that many spend years improving (I can’t say perfect, because software development is truly an evolutionary process).

How many times in your applications do you write logic that deals with ranges of values? For example, consider an order entry system that allows “special” orders to be processed only on certain weekdays?and then only between certain hours. Systems such as this make heavy use of range checks to ensure that accepted orders meet validity constraints. It’s not uncommon to see code such as this, which is an NUnit test that checks whether a date lies within a specified range:

   [Test]   public void ShouldBeAbleToSeeIfDateExistsInRange()   {      DateTime startOfJanuary2005 = new          DateTime(2005,1,1);      DateTime endOfJanuary2005 = new          DateTime(2005,1,31);      DateTime middleOfJanuary2005 = new          DateTime(2005,1,15);      DateTime startOfFebruary2005 = new          DateTime(2005,2,1);                     Assert.IsTrue(middleOfJanuary2005 >=          startOfJanuary2005 &&          middleOfJanuary2005 =          startOfJanuary2005 &&          startOfFebruary2005 

Often you'll find this type of range-checking code scattered throughout multiple parts of an application. Of course, these checks are usually not localized to one specific type; sometimes you may want to perform range checks on types other than dates. In the context of an order entry application, there may be times when you want to calculate an order discount based on the product amount purchased in a particular order. The following code shows a test for a class that encapsulates applicability for discounts.

   [Test]   public void ShouldTestForEligibilityOfDiscount()   {      IDiscount between100And300DollarDiscount =          new DiscountWithoutRange(100, 300, .10m);      Assert.IsFalse(between100And300DollarDiscount.         IsApplicableTo(99));      Assert.IsTrue(between100And300DollarDiscount.         IsApplicableTo(100));      Assert.IsTrue(between100And300DollarDiscount.         IsApplicableTo(205));      Assert.IsTrue(between100And300DollarDiscount.         IsApplicableTo(300));      Assert.IsFalse(between100And300DollarDiscount.         IsApplicableTo(301));   }

The test is fairly self-explanatory. After creating a DiscountWithoutGenerics object, you can ask if it can be applied to a particular order amount. Listing 1 shows the full implementation of the DiscountWithoutGenerics class. Here's the code that actually performs the range check:

   return orderAmount >= eligibleAmountStart &&       orderAmount 

Notice that other than the type (Decimal vs. DateTime) the logic for determining whether a value exists within a range is the same as the date example; in other words, it answers the question: "Does the value I care about fall between the starting and ending values of the range?"

There Must Be A Better Way
One great thing about code is that if you see something you don't like, you can refactor it. Range-checking in applications is a prime candidate for refactoring. Martin Fowler originally introduced the concept of a Range pattern in this section of his book "Analysis Patterns." He described a Range object as "a single object to represent the range as a whole, [which] then provides the relevant operations to test to see if values fall in the range and to compare ranges."

The DiscountWithoutGenerics class fits that description of a Range. It encapsulates the logic required to determine if a value (the order amount) falls within the range of values for which the discount is applicable. Clients of the DiscountWithoutGenerics class don't need to perform any explicit boundary checks; those are all encapsulated within the "Range" class itself.

While using this pattern is a great way to encapsulate Range checks within your applications, it does have one big flaw: Right now range-checking is localized to a very specific class of the application. Worse, the DiscountWithoutGenerics class can work only with decimal values.

Ultimately the basic operations on any Range are the same:

  • What is the start value?
  • What is the end value?
  • Does it contain "this" value?

To make the effort of building a Range class truly worthwhile, you need a way to create a Range class that can create ranges of any type. In other words, you should be able to create one Range class that can work with with ints, doubles, decimals, dates etc, and even our own custom types. The rest of this article discusses how to use Test-driven Development (TDD) to build a Range class that you can use with any type.

Building The Range Class
The key to creating a class that will be able to work with any type that you wish lies in the new generic language enhancements added to C# (and VB.NET) in .NET 2.0. Most people who have seen examples of the new collection classes in .NET have already had some exposure to the new generic language enhancements. Remember back to the ancient days of .NET 1.1, where if you needed a custom collection of "Order" objects you would have to create your own custom collection class?and you would usually end up using an ArrayList as a private backing store. The purpose of creating the custom collection class was to ensure type safety, not to provide people with the ability to add anything they wanted to your private collection. With .NET 2.0 and generics you can achieve the same result with much less code:

   IList orders = new List();   orders.Add(new Order());   

Take a look at the C# definition for the generic IList interface:

   public interface IList : ICollection,       IEnumerable, IEnumerable

I should take a quick second to point out that there is no special significance to the use of the letter "T." The compiler doesn't enforce any particular naming convention for generic type parameters. The designers of the framework could just have easily defined the interface as follows:

   IList : ICollection,      IEnumerable, IEnumerable   

What's important to remember is that the T or SomeItem is a placeholder for a type. Notice all the T parameters in the interface definition. This is the magic that allows the generic IList interface to work with any type. When you want to construct an implementation of a generic IList (the most common implementation being List), you have to provide it with a type to use in place of the generic type parameter T. The previous code segment constructed a List of that constrained all the methods so they accept only Order types. The creators of the generic List class didn't actually care what type T was, leaving it up to the class consumer to decide what type the List should actually work with.

This is the same concept that you will have to apply to the formation of a generic Range class. The Range should be flexible enough to work with any type. Start by writing a test to see if you can create a Range of integers:

   [Test]   public void ShouldCreateARangeOfIntegers()   {     IRange rangeOfIntegers = new         Range(20, 30);     Assert.AreEqual(20,range.Start);     Assert.AreEqual(30,range.End);   }

When you write code from a TDD perspective, try to write from the point of view of a client programmer utilizing your library. Ask yourself "What is the ideal way for a client to consume and work with this object?" You then express your expectations in code as a test; get the test compiling, get the test passing, and then cleanup any mess you may have created in the process. If you are following along and coding with me, then your code will currently be in a non-compilable state. First, get the test to compile by adding the required classes and interfaces shown below:

   public interface IRange   {      T Start{get;}      T End{get;}   }   public class Range : IRange   {      public Range(T start, T end)      {         throw new NotImplementedException();      }            public T Start      {         get { throw new NotImplementedException(); }      }         public T End      {         get { throw new NotImplementedException(); }      }   }

Note that you've implicitly made some pretty big design decisions by writing this one test.

  • The Range class will implement an IRange interface.
  • The Range class will have one constructor that takes the start and end values of the range.
  • The Range class will expose its start and end values as properties.

Coding to the Test
For the most part, the current implementation of the interface and class are very simple?sufficient to get the code to compile. But if you run the test right now it should fail. That's the point however! So far, you've created a generic interface called IRange that requires one generic type argument?the type for which you want to create the range. The Range class implements the IRange interface, satisfying the IRange interface argument using the type argument that a client provides upon construction. Take a look at the arguments to the constructor:

   public Range(T start, T end)   {     throw new NotImplementedException();   }   

Remember, you don't know what types the clients are going to want to use the Range class for, so you must substitute the generic type parameter (T) wherever you need to use the actual type the range is created for. The constructor tells clients that they must provide a new Range with a start value and an end value. For example to create a IRange of integers you would do the following:

   IRange rangeOfIntegers = new Range(20, 30);   

Note that when you construct a Range that you can pass only integer values to the constructor. Any method in the class that accepts or returns T is now strongly typed to the type provided to the constructor as a generic argument (in this case integers).

Let's get back to making the test pass. Update the code in the Range class to match the code below. After doing that, it should pass the test.

   public class Range : IRange   {     private readonly T start;     private readonly T end;        public Range(T start, T end)     {        this.start = start;        this.end = end;                 }        public T Start     {        get { return this.start; }     }        public T End     {        get { return this.end; }     }   }

Now you can add a second test for the integer range:

?
Figure 1. NUnit Test Failure: The figure shows the NUnit failure output caused by running the new Range test.
    [Test]   public void       ShouldCreateRangeWithStartAndEndAssignedCorrectly()   {     IRange rangeOfIntegers = new         Range(30, 20);     Assert.AreEqual(20,rangeOfIntegers.Start);     Assert.AreEqual(30,rangeOfIntegers.End);   }   

This test ensures that the Range will assign the correct values to the start and end of the range, even if the client of the Range provides it with mixed up values upon construction. Figure 1 shows the NUnit failure caused by the running the new test:

The test fails because the Range does not yet implement the logic to correctly rearrange and assign values passed to the constructor. You would logically think that you could implement the assignment code using the following logic:

   public Range(T start, T end)   {     if (start 

However, if you write the above code you will quickly see that it does not compile but gives you the following error message:

?
Figure 2. Non-specific Generic Type Methods: The four methods shown are the only methods available to all generic types.
   "Cannot apply operator '

Why are you getting this error? Remember, you are coding your Range class so that it can work with any type. This actually raises a problem. How can you be sure that all types that would want to consume your class actually provide a operator. The answer is: You can't. This puts you in an interesting predicament. Figure 2 shows the methods at your disposal when accessing arguments of type T?and that's it. In other words, you have nothing more to work with than the same methods available to all descendants of Object (which is every class in the framework).

Who Said Constraints Were A Bad Thing?
Thankfully, the designers of generics were two steps ahead of us. And they realized that sometimes you would need to expect a certain level of functionality from the types that consume your generic classes. In .NET 2.0, you accomplish this using generic constraints. The limited number of methods at your disposal on the T arguments in the range class provide no way to perform any comparison operation between two T types. But you can constrain the possible types to those capable of making such comparisons. Make the following small change to your IRange interface:

   public interface IRange where T : IComparable   

The generic interface now makes use of an interface derivation constraint. This tells the compiler that the only types that can satisfy T must implement IComparable. In other words, you can compare any valid type T to other valid T types? Fortunately, most of the value types in the framework (int, decimal, double etc) implement IComparable for their respective types.

?
Figure 3. IComparable.CompareTo Return Values: This table shows the valid return values and their meanings from calling the CompareTo method of an object that implements the IComparable interface.

You will need to duplicate the constraint in all implementors of the IRange interface (currently the Range class). Unfortunately, in the current generics implementation, you can't simply define constraints on the interface, they must be placed on both the interface and on any interface implementations.

Let's get back to getting the failing non-compiling test to pass. You now know that all valid types must support IComparable operations against other T's. The IComparable interface has one method:

   Int CompareTo (T other)

Chances are you have already made use of the non-generic IComparable interface in prior versions of the framework. The table in Figure 3 (from the MSDN documentation) details the return values of the method:

Armed with that knowledge you can complete the constructor code for the Range class.

   public Range(T start, T end)   {     if (start.CompareTo(end) 

Using the interface derivation constraint lets you invoke any method exposed by the IComparable interface on the T generic type parameter (in other words, CompareTo). You can use this to check whether the values coming into the constructor need to be rearranged before assignment to the private fields of the class.

What's In A Range!
So far, this is a fairly non-functional Range. You need to beef it up with its most important method?asking it whether it contains a particular value. Write a test to describe your intention:

   [Test]   public void      ShouldBeAbleToTestForTheExistenceOfANumberInARange()   {     IRange rangeOfIntegers = new         Range(20, 30);     Assert.IsFalse(rangeOfIntegers.Contains(19));     for (int i = 20; i 

Those of you coding along will once again be in non-compilable state, because the Range does not expose a Contains method. Get the code to compile by adding the method with no functionality. Remember, you are making use of interfaces, so add the method to the IRange interface first. Then you can add the unimplemented method to the Range class.

   public interface IRange where T : IComparable   {     T Start{get;}     T End{get;}     bool Contains(T valueToFind);   }

Then, in the Range class, add the method:

   public bool Contains(T valueToFind)   {      throw new NotImplementedException();   }

Now the unit test should fail with a NotImplementedException. To implement the method you once again can make use of the fact that you can call CompareTo on the valueToFind parameter, so the resulting code is very simple:

   public bool Contains(T valueToFind)   {      return valueToFind.CompareTo(Start) >= 0 &&        valueToFind.CompareTo(End) 

The Contains method implementation plainly shows the range-checking code that you often see scattered throughout applications. But now, using the Range class, that code is nicely encapsulated in one place.

One Range To Rule Them All!
You need to prove that you now truly have a Range class that will work will "almost" any type. I say almost because the derivation constraint limits the class to types that implement IComparable. That's not a large constraint because most if not all the common value types in the framework implement IComparable. And let's be honest, implementing IComparable for custom types is definitely not rocket science. Listing 2 shows the use of our Range class to perform Range checking with dates, which is a problem often encountered in myriads of applications.

Although you've been working with integers throughout the process of creating the Range class, you should notice that you didn't need to alter the class or create any new classes to get the test for a date range to pass! I could carry on and demonstrate the functionality against other types in the framework, or even other custom types that implement IComparable, but I think you can see the point. The source code that accompanies this article also contains a rewrite of the Discount class that makes use of an IRange internally to perform discount applicability checking.

The generic range class discussed in this article really just scratches the surface of the functionality that you can bundle into a range implementation. You now have the tools and knowledge to build a full-blown implementation of the Range pattern such as Martin discussses in this section of his Analysis Patterns book. You are also now armed with a bit more knowledge that you can use to apply generics pragmatically in your own applications. And I hope that you won't have to code an explicit Range check for a long time!

devxblackblue

About Our Editorial Process

At DevX, we’re dedicated to tech entrepreneurship. Our team closely follows industry shifts, new products, AI breakthroughs, technology trends, and funding announcements. Articles undergo thorough editing to ensure accuracy and clarity, reflecting DevX’s style and supporting entrepreneurs in the tech sphere.

See our full editorial policy.

About Our Journalist