![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
![[community profile]](https://www.dreamwidth.org/img/silk/identity/community.png)
When working in .NET, it can be awkward to figure out whether two
objects are "equal". With reference types, two variables might
refer to the same object on the heap, or to two different objects
with identical properties. In the picture below, changing Snail
C's shirt would also change Snail B's, because they are the same
snail, while the otherwise identical Snail A is unchanged.
Value types are more like C structs. So giving Snail C a jacket
and hat does not affect Snail B. It wouldn't make sense to do a
"reference comparison" on a value type; the objects are always
stored in different places (setting aside explicit pointers to
objects in memory, of course - something C# also allows for).

Immutable types with built-in structural equality checks are a great way to work around this issue, but there's still the question of what to do for collection types - the standard .NET HashSet, List and Array types aren't immutable, and even ImmutableList and ImmutableSet don't consider two lists or two sets with the same items to be "equal". But there's a very easy way to handle this problem: you can use the collection types in the F# standard library (FSharp.Core), even without using F#.
When you want to check whether two variables are "the same",
it's hard for the language to know if you want to check if it's
the same object, or if you want to know if two objects are
identical. It can also be difficult to know what kind of
equality check your code is actually doing. It often helps to
remove all doubt by making them do the same thing whenever
possible - and as part of that, you usually want to make the
type immutable. There's no question which variables your changes
will impact when it's impossible to make changes in the first
place! A lot of built-in .NET types, including DateTime (a value
type) and String (a reference type), are built this way: as
immutable types that compare the actual data they contain,
whether you use ==, Equals, or IEquatable<T>.
In C# 9, records
are a great way to build an immutable type, especially in
conjunction with the new required
keyword in C# 11, which lets you define a field that must
be initialized with an object initializer. (This is actually
really nice; when I reference F#
records from C# projects, I have to put all the fields in
the constructor, which looks a bit awkward when your types get
more complex). Like F# records, C# records automatically
implement structural equality (but not comparison, although
that's really only needed in certain
situations). It means you can do things like this:
// This is the type used in the database
public class Item {
public string Field1 { get; set; }
public string Field2 { get; set; }
}
// This is the type exposed to the API
public record ApiItemObject {
public required string Field1 { get; init; }
public required string Field2 { get; init; }
}
static ApiItemObject Convert(this Item x) {
return new ApiItemObject {
Field1 = x.Internal1,
Field2 = x.Internal2
};
}
ApiItemObject Get(int id) {
Item dbObject = database.Get(id);
return dbObject.Convert();
}
void Update(int id, ApiItemObject after) {
Item dbObject = database.Get(id);
if (dbObject.Convert().Equals(after)) {
// Avoid a write operation
return;
}
dbObject.Internal1 = after.Field1;
dbObject.Internal2 = after.Field2;
database.Save();
// Notify external services, CDNs, etc. of the update here
}
This way, you can use the API schema itself to see if a change
needs to be made or not: if the user's sending the same thing
with PUT that they'd receive with GET, might as well quit early
and skip whatever stuff needs to be done after a change. But I
run into a problem when I want to use an API schema like this:
{
"Conference": "WIAC",
"Sport", "Women's Ice Hockey",
"Teams": [{ "Name": "UW-River Falls", }, { "Name: "UW-Eau Claire" }]
}
One way to represent this would be:
record ApiTeam {
public required string Name { get; init; }
}
record ApiLeague {
public required string Conference { get; init; }
public required string Sport { get; init; }
public required List<ApiTeam> Teams { get; init; }
}
The issue is that the "record-ness" of this type - the
immutability, etc. - only goes one step deep: we still have a
Teams collection that's mutable and doesn't implement structural
equality for us. Using an array instead doesn't help (its length
is immutable but the items can still be changed), and neither
does an interface like IReadOnlyList<string>. What we
really want is a collection type that implements the "Equals"
method and IEquatable<T>, and that doesn't let you replace
any of its items after the collection has been created.
Something whose default equality check mirrors what happens when
you call LINQ's SequenceEquals - which we could do ourselves,
sure, but we want it to happen as part of the parent type's
equality check generated by the compiler.
My solution is pretty simple: just use the F# core library from
within C#.
There's not much you have to do; you can reference FSharp.Core
from a C# or VB.NET app just like any other .NET library
(usually by referencing an F# project that uses it or by getting
it from NuGet). The namespace FSharp.Collections includes a
variety of options, but two in particular are really useful: FSharpList<T>
and FSharpSet<T>.
An F# list can be created in many ways, even from outside F#,
but the two to remember are in ListModule (modules, in both F#
and VB.NET, are essentially static classes from a C#
perspective). ListModule.OfSeq<T>(...) converts an
IEnumerable<T>, and ListModule.Empty<T>()
gets an empty list for you (useful if you want to specify a
default value - ideally, a collection type should never be null;
it just makes your life easier to just deal with empty lists
instead).
Comparing two F# lists with Equals or IEquatable<T> will
return true if the lists are the same length, and the
corresponding items in each list all return true when checked
for equality themselves - it's that simple. (F# sets are
similar, using SetModule.OfSeq<T>(...) and SetModule.Empty;
sets are unordered, and duplicate items - according to the
item's type's equality check - are removed when creating one).
One thing I like to do is create extension methods, similar to
LINQ's ToList or ToArray, for F# lists and sets:
internal static class Extensions {
public static FSharpList<T> ToFSharpList(this IEnumerable<T> seq) => ListModule.OfSeq(seq);
public static FSharpSet<T> ToFSharpSet(this IEnumerable<T> seq) => SetModule.OfSeq(seq); }
So if we want to use the League type, then the API code from
above might look something like this:
// These are the types used in the database
public class League {
public int Id { get; set; }
public string Conference { get; set; }
public string Sport { get; set; }
}
public class Team {
public int LeagueId { get; set; }
public string Name { get; set; }
}
// These types are exposed to the API
public record ApiTeam {
public required string Name { get; init; }
}
public record ApiLeague {
public required string Conference { get; init; }
public required string Sport { get; init; }
public required FSharpList<ApiTeam> Teams { get; init; }
}
static ApiLeague Convert(this League x) {
return new ApiLeague {
Conference = x.Conference,
Sport = x.Sport,
Teams = x.Teams.Select(t => new ApiTeam { Name = x.Name }).ToFSharpList()
};
}
ApiLeague Get(int id) {
League dbObject = database.Get(id);
return dbObject.Convert();
}
void Update(int id, ApiLeague after) {
League dbObject = database.Get(id);
if (dbObject.Convert().Equals(after)) {
// Avoid a write operation
return;
}
List<Team> dbTeams = database.GetTeams(id);
dbObject.Conference = after.Conference;
dbObject.Sport = after.Sport;
var oldTeamNames = dbTeams.Select(x => x.Name);
var newTeamNames = after.Teams.Select(x => x.Name);
foreach (var oldTeam in dbTeams) {
var newTeam = after.Teams.SingleOrDefault(x => x.Name == oldTeam.Name);
if (newTeam == null) database.Remove(oldTeam);
}
foreach (var newTeam in after.Teams) {
var oldTeam = dbTeams.SingleOrDefault(x => x.Name == oldTeam.Name);
if (oldTeam == null) {
database.Add(new DbTeam { LeagueId = id, Name = newTeam.Name });
}
}
database.Save();
// Notify external services, CDNs, etc. of the update here
}
One of the best things about F# lists and sets is that you can
get benefits from them in a C# or VB.NET project very easily, as
long as you're willing to pull in the F# core library. The
built-in equality and comparison, as well as the friendly
ToString, are available to you practically for free. Just
remember that the F# collection types don't override the
equality operator for you (since I'm pretty sure F#'s own
equality operator uses IEquatable<T>). This is pretty easy
to fix by just wrapping it in a record type, actually:
public record RegionSet {
public required readonly FSharpSet<string> Items { get; init; }
}
var x = new RegionSet {
Items = SetModule.OfArray(new[] { "Wisconsin", "Nebraska" })
};
var y = new RegionSet {
Items = SetModule.OfArray(new[] { "Wisconsin", "Nebraska" })
};
Assert.IsTrue(x.Items.Equals(y.Items));
Assert.IsFalse(x.Items == y.Items);
Assert.IsTrue(x == y);