After creating VocabularyWord last time, our next step is to create VocabularySet, a container for many words.

We could take a shortcut and just use a set of words wherever needed - passing around a string (for the name of the set) and an ImmutableHashSet<VocabularyWord> containing all the words.

There are a couple of reasons I’m not going down this path.

  • It’s almost always worthwhile to define a semantic type to represent core domain concepts. In this case, we’re building an application to tutor spelling words. A list of those words is a pretty foundational concept.

  • We already have two properties for a VocabularySet - a name and a list of words - and the chances of adding additional properties later on is high. When you start routinely passing around two or more properties together, that’s a good indication that there’s an underlying type begging to be freed.

Plus, I’ve got a pretty good idea that we’ll have specialised behavour for our set of words. Failing to create a dedicated type up front will just mean that behaviour accumulates in other places.

Properties

For now, we’ll define our VocabularySet as having two properties:

  • A descriptive Name that end users can specify so they can tell different lists apart; and
  • A set of the Words contained by the set.
public class VocabularySet : IEquatable<VocabularySet>
{
    private readonly ImmutableHashSet<VocabularyWord> _words;

    public string Name { get; }
    public IImmutableSet<VocabularyWord> Words => _words;
}

We treat the spelling words as a set because they don’t have any implicit ordering. Note that we couldn’t have used an ImmutableHashSet<> here if we’d skiped out on implementing Equals() and GetHashCode() last time.

Creating a vocabulary set

To create a new VocabularySet, we’ll follow the established pattern used for immutable collections in .NET and provide a static property called Empty, along with a suitable private constructor:

public static VocabularySet Empty { get; } = new VocabularySet();

private VocabularySet()
{
    Name = string.Empty;
    _words = ImmutableHashSet<VocabularyWord>.Empty;
}

Changing the name

To change the name of a VocabularySet, we declare a With... method that makes the requested change, returning a new instance of VocabularySet:

private static readonly StringComparer _nameComparer 
    = StringComparer.CurrentCulture;

public VocabularySet WithName(string name)
{
    if (_nameComparer.Equals(Name, name))
    {
        return this;
    }

    return new VocabularySet(
        this,
        name: name ?? throw new ArgumentNullException(nameof(name)));
}

The cached _nameComparer gives us a convenient way to ensure all our methods that work withName are mutually consistent. We’ll see this reused later on for Equals() and GetHashCode().

We’ll encounter transformation methods like this quite frequently as we build up our model.

For this method to work, we need a new constructor - one that lets us make a clone of an existing VocabularySet, but with a particular change.

private VocabularySet(
    VocabularySet original,
    string name = null,
    ImmutableHashSet<VocabularyWord> words = null)
{
    Name = name ?? original.Name;
    _words = words ?? original._words;
}

I’ve used this pattern before, to very good effect. By using a default value of null for each property, we can fall-back to the matching value from original except where a particular value is provided. Admittedly, this pattern can fall down if null is ever a value we legitimately want to use; hopefully this won’t hit us.

Adding a word

Adding a new word into the set follows a predictable pattern, with most of the code being validation checks of one kind or another.

public VocabularySet Add(VocabularyWord word)
{
    if (word == null)
    {
        throw new ArgumentNullException(nameof(word));
    }

    if (_words.Contains(word))
    {
        return this;
    }

    if (_words.Any(w => w.HasSpelling(word.Spelling)))
    {
        throw new ArgumentException(
            $"A word with spelling {word.Spelling} already exists in this set.",
            nameof(word));
    }

    var words = _words.Add(word);
    return new VocabularySet(this, words: words);
}

Note the short circuit - if we’re adding a word that is already present (same spelling, same pronunciation, and so on) then we don’t need to raise an error; instead we just return the existing set.

Removing a word

Similarly, the process for removing a word from the set is also predictable.

public VocabularySet Remove(VocabularyWord word)
{
    if (word == null)
    {
        throw new ArgumentNullException(nameof(word));
    }

    if (!Words.Contains(word))
    {
        throw new ArgumentException(
            $"The word with spelling {word.Spelling} does not exist in this set.",
            nameof(word));
    }

    var words = _words.Remove(word);
    return new VocabularySet(this, words: words);
}

Changing a word

We’ll provide two ways to modify a word in the list. First, we have Replace, which removes one word and substitutes a replacement:

public VocabularySet Replace(VocabularyWord existing, VocabularyWord replacement)
{
    if (existing == null)
    {
        throw new ArgumentNullException(nameof(existing));
    }

    if (replacement == null)
    {
        throw new ArgumentNullException(nameof(replacement));
    }

    if (!Words.Contains(existing))
    {
        throw new ArgumentException(
            $"A word with spelling {existing.Spelling} does not exist in this set.",
            nameof(existing));
    }

    if (existing.Equals(replacement))
    {
        return this;
    }

    var words = _words.Remove(existing)
        .Add(replacement);

    return new VocabularySet(this, words: words);
}

Even here we can short circuit things - if the existing word and its replacement are equal, we can skip the rest of the method.

Now that we have Replace, we can implement Update, which allows us to make a prescribed change to an existing word.

public VocabularySet Update(string word, Func<VocabularyWord, VocabularyWord> transform)
{
    if (word is null)
    {
        throw new ArgumentNullException(nameof(word));
    }

    if (transform is null)
    {
        throw new ArgumentNullException(nameof(transform));
    }

    var original = Words.FirstOrDefault(w => w.HasSpelling(word));
    if (original is null)
    {
        throw new ArgumentException(
            $"A word with spelling {word} does not exist in this set.",
            nameof(word));
    }

    var replacement = transform(original);
    return Replace(original, replacement);
}

We identify the word we want to modify by supplying its spelling; once found, we use the supplied transform to create a new value that’s used as a replacement.

Checking for spelling

You might have noticed the use of HasSpelling() to find the word we want, in both Add() and Update(). Adding this as a convenience method to VocabularyWord makes our code more declarative, with the added bonus of ensuring consistency across the application. (If some parts of the app were case sensitive and others case insensitive, subtle bugs would emerge to confuse users.)

public bool HasSpelling(string spelling)
    => string.Equals(
        spelling ?? throw new ArgumentNullException(nameof(spelling)),
        Spelling,
        StringComparison.CurrentCultureIgnoreCase);

We use CurrentCulture instead of OrdinalCulture in order to align with the user of the app - if they’re running in a locale with different rules for letter equivalence to New Zealand (or American) English, we want to respect those.

Equality and HashCodes

Implementing IEquatable<VocabularySet>, is relatively familiar territory, with only the use of SetEquals() out of the ordinary, if only a little.

public bool Equals(VocabularySet other)
{
    if (other is null)
    {
        return false;
    }

    if (ReferenceEquals(this, other))
    {
        return true;
    }

    return _nameComparer.Equals(Name, other.Name)
        && _words.SetEquals(other._words);
}

The override of Equals(object) is trivial, so I’ll skip over it.

On the other hand, the override of GetHashCode() is decidedly non-trivial.

It turns out that ImmutableHashSet<> doesn’t provide an implementation for GetHashCode(), likely because it would be difficult to provide a single implementation that always provided good results. This means we need to do it ourselves, and given that the calculation is somewhat expensive, caching the result is a good idea.

private readonly Lazy<int> _hashCode;

public override int GetHashCode() => _hashCode.Value;

private int GetHashCodeCore()
{
    unchecked
    {
        int hash = 17;
        hash = hash * 23 + Name.GetHashCode();
        foreach (var w in _words.OrderBy(w => w.Spelling))
        {
            hash = hash * 23 + w.GetHashCode();
        }

        return hash;
    }
}

// Add this to both constructors
_hashCode = new Lazy<int>(GetHashCodeCore, LazyThreadSafetyMode.ExecutionAndPublication);

Since the enumeration order of a set is not deterministic, we need to explicitly specify the order of words as we iterate through them.

Phew! That’s quite a lot of code for a simple container class. Next time we’ll set up our commandline builds.

Prior post in this series:
Vocabulary Word
Next post in this series:
Application Model

Comments

blog comments powered by Disqus