Saturday, June 11, 2016

ValueTypeAssertions

The Problem

Primitive Obsession is one of the most pervasive code smells out there. You can address it by moving a primitive in to a simple class.


// A primitive
string emailAddress = "jay@example.com";
// Better
class EmailAddress
{
public EmailAddress(string value) { this.Value = value; }
public string Value { get; }
}
EmailAddress emailAddress = new EmailAddress("jay@example.com");

I call the resulting class a "value type", but don't confuse it with C#'s notion of a value type, which doesn't get its own heap allocation, and is passed-by-value to other methods, and is a source of bugs if it's mutable. I mean "a type that represent a value in some domain".
If you want to implement equality on that class, there are a lot of tricky details that are easy to get wrong, at least in C#. For example:


class Point
{
override bool Equals(object other)
{
return this.X == ((Point)other).X && this.Y == ((Point)other).Y;
}
}
new Point(...).Equals(new Bar(...))

This will throw an exception when trying to cast the Bar to a Point. So you try to fix it:


class Point
{
override bool Equals(object other)
{
if (other.GetType() != this.GetType()) return false;
return this.X == ((Point)other).X && this.Y == ((Point)other).Y;
}
}
new Point(...).Equals(null)

This will throw when trying to call null.GetType(). Uggh.

You probably want to override operator==() as well.


class Point
{
public static bool operator==(Point left, Point right)
{
return left.Equals(right);
}
}

The compiler tells you to implement operator!=() to go with it, so you copy/paste and change the method name:


class Point
{
public static bool operator==(Point left, Point right)
{
return left.Equals(right);
}
public static bool operator!=(Point left, Point right)
{
return left.Equals(right);
}
}

Oops, you forgot to negate the check. Bug.

If the value in question is a case-insensitive identifier of some sort, it's important that the GetHashCode() is implemented correctly. Don't do this:


class CustomerId
{
public CustomerId(string value)
{
this.Value = value;
}
public readonly string Value;
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (obj.GetType() != GetType()) return false;
return string.Equals(this.Value, ((CustomerId)obj).Value, StringComparison.OrdinalIgnoreCase);
}
public override int GetHashCode()
{
return this.Value.GetHashCode();
}
}
new CustomerId("ABC") == new CustomerId("abc") // True; that's a good thing
new HashSet<CustomerId>
{
new CustomerId("ABC")
}.Contains(new CustomerId("abc")); // False! Bad!

Maybe you want to implement IEquatable<>, too, and you better get these details right there, too.

Many programmers don't test these details at all, or they test a few but not all, and they have to repeat the same set of tests each time they introduce a new class. If you discover a new rule (ToString() should follow equality, right?) you have to update all the tests.

Prior Art

Assertion libraries typically have an equality assertion. For example, in NUnit:

    Assert.AreEqual( new Point(7,8), new Point(7,8) );

That is insufficient. It only tells you that one of the equality checks you've written is correct, and doesn't catch all the other cases listed above.

The Solution

ValueTypeAssertions addresses all the mistakes I have ever made, or seen made, or can imagine when implementing equality in C#. Grab it from NuGet, and write a unit test like this:

    ValueTypeAssertions.HasValueEquality(new NtfsPath("foo.txt"), new NtfsPath("foo.txt"));


This says "these two objects should equal, in every way that C# recognizes equality".

  ValueTypeAssertions.HasValueInequality(new NtfsPath("foo.txt"), new NtfsPath("bar.txt"));


Which says the same thing about not being equal.

If some part of your value should be case insensitive, just add another assertion:

  ValueTypeAssertions.HasValueEquality(new NtfsPath("foo.txt"), new NtfsPath("FOO.TXT"));

If you wrap two values, assert the combinations:

  ValueTypeAssertions.HasValueInequality(new Point(1, 2), new Point(1, 8));
  ValueTypeAssertions.HasValueInequality(new Point(1, 2), new Point(0, 2));

You can find the source on GitHub.

Feedback

Do you find this useful?

What change would make it more useful to you?

Is there a name for this that would be more obvious?

No comments: