Andy Lamb

Header image

F# Models in C# Applications

.NET icon

A few days ago I saw a tweet from a guy called Elliot Brown.

"If you're on .NET, build your objects in F#. I'm serious."

His argument was that F# records give you sooo much by default, compared to C# classes:

  • IEquatable<T> and IStructuralEquatable are implemented.
  • IComparable, IComparable<T>, and IStructuralComparable are implemented.
  • As such: GetHashCode, Equals, and CompareTo methods are implemented, including overloads.
  • A full constructor for instantiation.
  • A nice ToString implementation.
  • And it's immutable by default.

"A four-line record becomes a quality class."

I had to check this out...


All .NET languages transpile to IL code so C#, VB.NET, and F# are natively interoperable. So there's no reason not to mix .NET languages to make the best use of each of them. Below is a simple domain, modelling the properties of, and relationship between, a resident and their address in C# and F#. I've made both immutable for simplicity. (Make an F# record mutable using [<CLIMutable>])

C# domain models:
namespace CSharpModels
{
    public class Address
    {
        public Address(string line1,
                       string line2,
                       string line3)
        {
            this.Line1 = line1;
            this.Line2 = line2;
            this.Line3 = line3;
        }

        public string Line1 { get; }
        public string Line2 { get; }
        public string Line3 { get; }
    }

    public class Resident
    {
        public Resident(string firstName,
                        string lastName, 
                        Address address)
        {
            this.FirstName = firstName;
            this.LastName = lastName;
            this.Address = address;
        }

        public string FirstName { get; }
        public string LastName { get; }
        public Address Address { get;  }
    }
}
F# domain models:
namespace FSharpModels

type Address =
    {
        Line1: string;
        Line2: string;
        Line3: string;
    }

type Resident =
    {
        FirstName: string;
        LastName: string;
        Address: Address;
    }

The F# implementation is already more terse as there's no need to add a constructor.
I can now reference both these domains from a C# console application, instantiate a model from each implementation, and invoke the ToString methods.

class Program
{
    static void Main(string[] args)
    {
        var csResident = new CSharpModels.Resident("Bob", "Smith", 
                             new CSharpModels.Address("2 Low Street", "Glasgow", "GL2 5GL"));
        var fsResident = new FSharpModels.Resident("Bob", "Smith", 
                             new FSharpModels.Address("2 Low Street", "Glasgow", "GL2 5GL"));

        Console.WriteLine("C#:");
        Console.WriteLine(csResident.ToString());
        Console.WriteLine();
        Console.WriteLine("F#:");
        Console.WriteLine(fsResident.ToString());
    }
}

Which results in the following output at the console:

C#:
CSharpModels.Resident

F#:
{ FirstName = "Bob"
  LastName = "Smith"
  Address = { Line1 = "2 Low Street"
              Line2 = "Glasgow"
              Line3 = "GL2 5GL" } }

You can see the 'sensible' ToString implementation you get for free with F# compared to the C# default implementation from the implicit object base class.
The next question that occurs to me is: What would a C# domain model look like, if it included all the features of the F# domain model? To attempt to answer that question I've decompiled the F# assembly to C# using the brilliant IL Spy tool.

To recap, the F# Resident record looks like this (yes, that's a single line):

type Resident = { FirstName: string; LastName: string; Address: Address; }

And here's the same Resident domain model decompiled to C# (~200 lines):

using FSharpModels;
using Microsoft.FSharp.Core;
using System;
using System.Collections;
using System.Diagnostics;
using System.Runtime.CompilerServices;

[Serializable]
[CompilationMapping(SourceConstructFlags.RecordType)]
public sealed class Resident : IEquatable, IStructuralEquatable, IComparable, IComparable, IStructuralComparable
{
	[DebuggerBrowsable(DebuggerBrowsableState.Never)]
	internal string FirstName@;

	[DebuggerBrowsable(DebuggerBrowsableState.Never)]
	internal string LastName@;

	[DebuggerBrowsable(DebuggerBrowsableState.Never)]
	internal Address Address@;

	[CompilationMapping(SourceConstructFlags.Field, 0)]
	public string FirstName => FirstName@;

	[CompilationMapping(SourceConstructFlags.Field, 1)]
	public string LastName => LastName@;

	[CompilationMapping(SourceConstructFlags.Field, 2)]
	public Address Address => Address@;

	public Resident(string firstName, string lastName, Address address)
	{
		FirstName@ = firstName;
		LastName@ = lastName;
		Address@ = address;
	}

	[CompilerGenerated]
	public override string ToString()
	{
		return ExtraTopLevelOperators.PrintFormatToString(new PrintfFormat, Unit, string, string, Resident>("%+A")).Invoke(this);
	}

	[CompilerGenerated]
	public sealed override int CompareTo(Resident obj)
	{
		if (this != null)
		{
			if (obj != null)
			{
				IComparer genericComparer = LanguagePrimitives.GenericComparer;
				int num = string.CompareOrdinal(FirstName@, obj.FirstName@);
				if (num < 0)
				{
					return num;
				}
				if (num > 0)
				{
					return num;
				}
				genericComparer = LanguagePrimitives.GenericComparer;
				int num2 = string.CompareOrdinal(LastName@, obj.LastName@);
				if (num2 < 0)
				{
					return num2;
				}
				if (num2 > 0)
				{
					return num2;
				}
				genericComparer = LanguagePrimitives.GenericComparer;
				Address address@ = Address@;
				Address address@2 = obj.Address@;
				return address@.CompareTo(address@2, genericComparer);
			}
			return 1;
		}
		if (obj != null)
		{
			return -1;
		}
		return 0;
	}

	[CompilerGenerated]
	public sealed override int CompareTo(object obj)
	{
		return CompareTo((Resident)obj);
	}

	[CompilerGenerated]
	public sealed override int CompareTo(object obj, IComparer comp)
	{
		Resident resident = (Resident)obj;
		if (this != null)
		{
			if ((Resident)obj != null)
			{
				int num = string.CompareOrdinal(FirstName@, resident.FirstName@);
				if (num < 0)
				{
					return num;
				}
				if (num > 0)
				{
					return num;
				}
				int num2 = string.CompareOrdinal(LastName@, resident.LastName@);
				if (num2 < 0)
				{
					return num2;
				}
				if (num2 > 0)
				{
					return num2;
				}
				Address address@ = Address@;
				Address address@2 = resident.Address@;
				return address@.CompareTo(address@2, comp);
			}
			return 1;
		}
		if ((Resident)obj != null)
		{
			return -1;
		}
		return 0;
	}

	[CompilerGenerated]
	public sealed override int GetHashCode(IEqualityComparer comp)
	{
		if (this != null)
		{
			int num = 0;
			num = -1640531527 + (Address@.GetHashCode(comp) + ((num << 6) + (num >> 2)));
			num = -1640531527 + ((LastName@?.GetHashCode() ?? 0) + ((num << 6) + (num >> 2)));
			return -1640531527 + ((FirstName@?.GetHashCode() ?? 0) + ((num << 6) + (num >> 2)));
		}
		return 0;
	}

	[CompilerGenerated]
	public sealed override int GetHashCode()
	{
		return GetHashCode(LanguagePrimitives.GenericEqualityComparer);
	}

	[CompilerGenerated]
	public sealed override bool Equals(object obj, IEqualityComparer comp)
	{
		if (this != null)
		{
			Resident resident = obj as Resident;
			if (resident != null)
			{
				if (string.Equals(FirstName@, resident.FirstName@))
				{
					if (string.Equals(LastName@, resident.LastName@))
					{
						Address address@ = Address@;
						Address address@2 = resident.Address@;
						return address@.Equals(address@2, comp);
					}
					return false;
				}
				return false;
			}
			return false;
		}
		return obj == null;
	}

	[CompilerGenerated]
	public sealed override bool Equals(Resident obj)
	{
		if (this != null)
		{
			if (obj != null)
			{
				if (string.Equals(FirstName@, obj.FirstName@))
				{
					if (string.Equals(LastName@, obj.LastName@))
					{
						return Address@.Equals(obj.Address@);
					}
					return false;
				}
				return false;
			}
			return false;
		}
		return obj == null;
	}

	[CompilerGenerated]
	public sealed override bool Equals(object obj)
	{
		Resident resident = obj as Resident;
		if (resident != null)
		{
			return Equals(resident);
		}
		return false;
	}
}

We can ignore the CompilerGenerated, DebuggerBrowsable, and CompilationMapping attributes, they're added by the F# compiler when generating the IL code. The model class gets a Serializable attribute, the constructor we used in the example application, a ToString implementation making use of some helper class, and the methods needed to implement the IEquatable and IComparable interfaces. The implementation of GetHashCode is robust and suitable for most applications although it's not perfect. You'll also see the class is sealed, preventing sub classes inheriting from it. This encourages polymorphism via composition.

The weight of this evidence makes a pretty powerful argument for using F# to model the domain, regardless of the .NET language used to code the application. The boilerplate code needed to provide the same functionality in C# is significant. This together with the functional traits of immutability and composition over inheritence give us a very good domain for building applications on.

It's a good lesson in general... Different languages have different strengths, we need to be aware of them and make use of them. Just because an application is based on one language, doesn't mean other languages shouldn't be used where appropriate, especially where interoperability isn't a concern. We must remember the law of the instrument:

If all you have is a hammer, every problem looks like a nail.

© 2024 Andy Lamb