In object-oriented programming, it's tempting to overuse built-in types. For example, if you think of an object representing a vehicle, that vehicle's model year may be represented as a 32-bit integer, and it's make as a string. For many purposes, this is OK, but defining strong types for those values within your domain can make your code more expressive and allow the compiler become a powerful ally in verifying that your code is being used the way you intended it to.
However, if you look at built-in types, there are a lot of hidden features they implement which can add value. A simple implementation of a domain-specific value-object will likely miss some of these features and eventually lead to frustration for the users of that code. We'll look at some ways to make a true, fully featured value object with all the bells and whistles you've come to expect from .NET types.
As an example, let's use a simple Coordinate with an X and Y value.
class Coordinate
{
public int X { get; set; }
public int Y { get; set; }
}
Upgrade 1: Immutability
Not all types should be immutable, but any type which fundamentally represents a value can benefit from immutability. Immutability will generally simplify logic and clarify how your type is supposed to be used.
public class Coordinate
{
public Coordinate(int x, int y) => (X,Y) = (x,y);
public int X { get; }
public int Y { get; }
}
Notice the constructor? This is just for style. Feel free to use standard assignments, but tuple assignments make for a simple one-liner. If you have more than 2 or 3 values to assign, it's probably better to use classic assignments.
Upgrade 2: Make it a struct
Structs have a number of advantages over classes for types which fundamentally represent a single value. They're passed by value and usually only allocated on the stack instead of a reference to the heap so in many cases they'll avoid allocations and reduce garbage collection, thus improving performance. Structs are best avoided for mutable types because of how they're passed, but for a small immutable type like a coordinate point, a struct is the way to go. See more about when to use a struct vs a class here. You can also use the readonly
keyword so the compiler will enforce that the type is immutable.
public readonly struct Coordinate
Upgrade 3: Equatability
A good value type should be equatable by it's values. I.E. a coordinate (1,2) should equal another coordinate (1,2). Along with the Equals
and GetHashCode
methods, we should make sure that you can use the ==
and !=
operators and implements IEquatable<Coordinate>
.
public override bool Equals(object obj) => obj is Coordinate coordinate && Equals(coordinate);
public bool Equals(Coordinate other) => (X, Y) == (other.X, other.Y);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(Coordinate left, Coordinate right) => left.Equals(right);
public static bool operator !=(Coordinate left, Coordinate right) => !(left == right);
Notice some interesting syntax in the Equals
method. Comparing 2 tuples is an easy way to compare a number of properties. This is again a style choice and feel free to stick with a classic equality check for each property.
Upgrade 4: Formattability
We'It's common to need to specify a way of formatting a value to a string. It's always good practice to add a ToString()
method, but there are often a variety of ways to represent any value as a string. In our case, we'll consider the numbers which make up the coordinate and allow them to be formatted like any other number value. To make this work, we'll add the IFormattable
interface to our class. You could probably imagine a more sophisticated implementation that allowed for custom delimiter, etc. but for simplicity, we'll just forward the format onto each value.
public string ToString(string format, IFormatProvider formatProvider)
{
return X.ToString(format, formatProvider) + "," + Y.ToString(format, formatProvider);
}
This way, you can use it in interpolated strings such as $"coordinates: {c:00}"
to result in "01,02"
;
Upgrade 5: Parsability
If you notice, most built-in types have a static Parse
and TryParse
method. These are useful, and implementing them following the established pattern will make it easy for users.
We'll skip the details of actually parsing a value for brevity
public static Coordinate Parse(string s) => CoordinateParser.Parse(s);
public static bool TryParse(string s, out Coordinate c) => CoordinateParser.TryParse(s, out c);
Upgrade 6: Convertibility
Being able to easily convert between types is crucial to making a user-friendly type.
We can add implicit
and explicit
conversions to and from other types. This will allow the c# compiler to automatically translate these types either with or without an explicit cast.
For our coordinate class, let's imagine we also had an Vector
type with a magnitude and a direction.
public static explicit operator Vector(Coordinate c) => Vector.FromCoordinate(c);
Upgrade 7: Operators
We've already added equality operators, and conversions are also considered by c# to be operators, but depending on the structure, consider adding additional operators specific to your case. In our instance, we'll just add some basic math operations, but you can also consider use cases like DateTime - DateTime = TimeSpan
where the types are representative of the actual meaning of the object.
public static Coordinate operator +(Coordinate c) => c; // doesn't do anything, but complements minus
public static Coordinate operator -(Coordinate c) => new Coordinate(-c.X, -c.Y);
public static Coordinate operator ++(Coordinate c) => new Coordinate(c.X+1, c.Y+1);
public static Coordinate operator --(Coordinate c) => new Coordinate(c.X-1, c.Y-1);
public static Coordinate operator +(Coordinate left, Coordinate right) => new Coordinate(left.X + right.Y, left.X + right.Y);
public static Coordinate operator -(Coordinate left, Coordinate right) => new Coordinate(left.X - right.X, left.Y - right.Y);
public static Coordinate operator *(Coordinate left, Coordinate right) => new Coordinate(left.X * right.Y, left.X * right.Y);
public static Coordinate operator /(Coordinate left, Coordinate right) => new Coordinate(left.X / right.Y, left.X / right.Y);
public static Coordinate operator %(Coordinate left, Coordinate right) => new Coordinate(left.X % right.Y, left.X % right.Y);
Upgrade 8: Deconstruction
If we want to easily deconstruct values out of our type, we can add a Deconstruct
method.
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
This allows us to pull values out using the same syntax as tuple deconstruction.
var (x, y) = new Coordinate(1,2);
Upgrade 9: Debuggability
This one is a bit niche, but can be quite helpful for providing a view of a value geared specifically towards developers. If you inspect a value in the debugger, it'll show you the value from ToString()
, but you can customize this by adding a DebuggerDisplayAttribute
. In this case, we'll keep it mostly the same as the ToString()
, but you could add other useful debugger information or context here as well.
[DebuggerDisplay("({X},{Y})")]
public readonly struct Coordinate : IEquatable<Coordinate>, IFormattable
Upgrade 10: Documentation
This one may go without saying, but once you're done adding features, make sure to take the time to put good XML comments on everything public. Styles and coding guidelines vary, but in general, I tend to go with the principal of only commenting when necessary and to add clarity, but if you're writing a library to be used by others, it's best to take the time to comment everything.
Also, take advantage of more than just the <summary>
tag. There are a number of other useful tags for XML comments which are especially valuable if you use tools like DocFX to automatically generate a documentation website with details and examples.
/// <summary>
/// Converts a string into a coordinate object
/// </summary>
/// <param name="s">A string containing a coordinate to convert</param>
/// <returns>A coordinate equivelant to the string contained in <paramref name="s"/></returns>
/// <exception cref="FormatException">A format exception will be thrown if the structure of the string is unexpected</exception>
/// <example>
/// This demonstrates basic usage of the Parse method:
/// <code>
/// var c = Coordinate.Parse("1,2");
/// </code>
/// </example>
public Coordinate Parse(string s)
Putting it all together
We started with a simple class:
public class Coordinate
{
public int X { get; set; }
public int Y { get; set; }
}
This might be fine for a simple serialization bucket, but once our upgrades are all done, we've got a class worthy of inclusion in a world-class .NET library. Here's the end result (with comments omitted for brevity):
[DebuggerDisplay("({X},{Y})")]
public readonly struct Coordinate : IEquatable<Coordinate>, IFormattable
{
public Coordinate(int x, int y) => (X, Y) = (x, y);
public int X { get; }
public int Y { get; }
public override bool Equals(object obj) => obj is Coordinate coordinate && Equals(coordinate);
public bool Equals(Coordinate other) => (X, Y) == (other.X, other.Y);
public override int GetHashCode() => HashCode.Combine(X, Y);
public override string ToString() => ToString(null, null);
public string ToString(string format, IFormatProvider formatProvider)
{
return X.ToString(format, formatProvider) + "," + Y.ToString(format, formatProvider);
}
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
public static Coordinate Parse(string s) => CoordinateParser.Parse(s);
public static bool TryParse(string s, out Coordinate c) => CoordinateParser.TryParse(s, out c);
public static explicit operator Vector(Coordinate c) => Vector.FromCoordinate(c);
public static bool operator ==(Coordinate left, Coordinate right) => left.Equals(right);
public static bool operator !=(Coordinate left, Coordinate right) => !(left == right);
public static Coordinate operator +(Coordinate c) => c; // doesn't do anything, but complements minus
public static Coordinate operator -(Coordinate c) => new Coordinate(-c.X, -c.Y);
public static Coordinate operator ++(Coordinate c) => new Coordinate(c.X+1, c.Y+1);
public static Coordinate operator --(Coordinate c) => new Coordinate(c.X-1, c.Y-1);
public static Coordinate operator +(Coordinate left, Coordinate right) => new Coordinate(left.X + right.Y, left.X + right.Y);
public static Coordinate operator -(Coordinate left, Coordinate right) => new Coordinate(left.X - right.X, left.Y - right.Y);
public static Coordinate operator *(Coordinate left, Coordinate right) => new Coordinate(left.X * right.Y, left.X * right.Y);
public static Coordinate operator /(Coordinate left, Coordinate right) => new Coordinate(left.X / right.Y, left.X / right.Y);
public static Coordinate operator %(Coordinate left, Coordinate right) => new Coordinate(left.X % right.Y, left.X % right.Y);
}
Ultimately, how far down the road you want to go is a judgement call. For some cases, the first version of the type is all that's required and exactly what's needed. But, if you're writing a library or core domain code, it's probably worth breaking out all the stops to make the experience smoother and more predictable for your users.
Top comments (1)
A well made struct is a thing of beauty. Great breakdown of an underappreciated feature!