isaacschemm: A cartoon of myself as a snail (snail8)
[personal profile] isaacschemm posting in [community profile] snailsharp

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.

A points to one drawing, B and C both point to another        identical drawing; A is not equal to B, but B is equal to C

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).

A      and B point to separate but identical drawings, and are equal; C      points to a different drawing, where the snail has a hat, and is      not equal to B

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);
This account has disabled anonymous posting.
If you don't have an account you can create one now.
HTML doesn't work in the subject.
More info about formatting

Snail#

A programming blog where the gimmick is that I pretend to be a snail.

Expand Cut Tags

No cut tags

Style Credit

Page generated Jun. 10th, 2025 06:37 pm
Powered by Dreamwidth Studios