isaacschemm: Drawing of myself as a snail (snail)
isaacschemm ([personal profile] isaacschemm) wrote in [community profile] snailsharp2022-12-01 09:29 pm
Entry tags:

My favorite single line of code: sortable ProductVersion type for .NET

Sometimes I need to take a version number - something like 5 or 6.4 or 2.25.1 - and see whether it's newer or older than another version number, or maybe just take a list of version numbers like this and put them in order.

The projects I'm working on usually have at least a bit of F# code, or a dependency on an F# library (even if most of the code is C#), and once I've taken the dependency on FSharp.Core, I might as well use it to create an equatable, sortable, and immutable "version number" type.

This might be absolute favorite line of code:

type ProductVersion = { components: int list }

That's literally all you need to make a type that stores a version number, and can be compared and sorted in the proper order (so that 10.25 comes after 10.4, for example). This is because the F# record type implements comparison using its underlying fields, in order, and the one field in this record is an F# list, which implements comparison through its elements, in order - which is exactly what we want.

Of course, you'll still want some code to convert to and from strings. Here's the full code:

type ProductVersion = { components: int list }
with
    override this.ToString() = this.components |> Seq.map string |> String.concat "."
    member this.IsGreaterThan(x) = this > x
    member this.IsLessThan(x) = this < x
    static member Parse(str: string) = { components = [ for s in str.Split('.') do int s ] }
    static member Empty = { components = [] }

The ToString method replaces the default printed representation of the record type (just like in C#); here we use it to print out the version number in the standard format, as integers separated by periods. Parse is a static method to convert a string to a ProductVersion, which will fail if the format doesn't match (for example, if the version number includes letters or spaces). And Empty can be useful if you need a reference for a hypothetical version number that would come before all others.

And if you're wondering why I added IsGreaterThan and IsLessThan - it's because F# implements its greater-than and less-than operators using the .NET IComparable interface, while in C# they would need to be overridden manually, just like how the C# equality operator doesn't automatically call Equals.

You can use the type from C# like this:

var obj1 = ProductVersion.Parse("1.1");
var obj2 = ProductVersion.Parse("1.1");
Console.WriteLine(obj1 == obj2); // False
Console.WriteLine(obj1.Equals(obj2)); // True

var obj3 = ProductVersion.Parse("1.1.0");
Console.WriteLine(obj2.Equals(obj3)); // False
Console.WriteLine(obj2); // 1.1
Console.WriteLine(obj3); // 1.1.0

var obj4 = ProductVersion.Parse("1.01");
Console.WriteLine(obj4); // 1.1
Console.WriteLine(obj4.Equals(obj2)); // True

var list = new List
{
    ProductVersion.Parse("3.12"),
    ProductVersion.Parse("3.0"),
    ProductVersion.Parse("3"),
    ProductVersion.Parse("3.0.0"),
    ProductVersion.Parse("3.3")
};
list.Sort();
Console.WriteLine(string.Join(", ", list)); // 3, 3.0, 3.0.0, 3.3, 3.12