DEV Community

Cover image for C# Alchemy: KeyedCollection for Effortless Data Organization
MJ Harmon
MJ Harmon

Posted on • Edited on

C# Alchemy: KeyedCollection for Effortless Data Organization

C# has been declared the Programming Language of 2023 so it is the perfect time to explore some features of the language and of .Net. In this post which serves to kick-off a series titled C# Alchemy, we'll take a look at KeyedCollection, an abstract base class from the Collections framework. KeyedCollection is useful when you need to map a key to value but the key itself is embedded inside the value, for example, a library catalog where the ISBN of each book serves as the key.

The Basics

To illustrate the usefulness of KeyedCollection, let's build a Pokédex—one of the most important advancements in modern technology! First we define our Pokemon class:

public class Pokemon {

    public Pokemon(string name, int hp, string type) {
        Name=name;
        Type=type;
        HP=hp;
    }

    public readonly string Name;

    public readonly int HP;

    public readonly string Type;

}
Enter fullscreen mode Exit fullscreen mode

Since each species of Pokémon has a unique name, we'll use it as the key for our Pokédex. To create the Pokédex, we will extend KeyedCollection. Notice from the class definition of KeyedCollection that it has two type parameters, one for the data type of the key and one for the data type of the data itself:

public abstract class KeyedCollection<TKey,TItem> : 
  System.Collections.ObjectModel.Collection<TItem>
Enter fullscreen mode Exit fullscreen mode

Assuming you are familiar with using Dictionary for key to value mappings, the immediate benefit of KeyedCollection when the key is a member of the value should be clear. To define the Pokédex, extend KeyedCollection using string and Pokemon as the type parameters:

using System.Collections.ObjectModel;

public class Pokedex : KeyedCollection<string,Pokemon> {

    override protected string GetKeyForItem(Pokemon pokemon) {
        return pokemon.Name;
    }
}
Enter fullscreen mode Exit fullscreen mode

As I am neither a Pokémon Master nor Trainer, I deferred to my kids for a starter pack of some Pokémon to add to our Pokédex:

List<Pokemon> myPokemon = new() {
    new Pokemon("Eevee", 55, "Normal"),
    new Pokemon("Pikachu", 35, "Electric"),
    new Pokemon("Vaporeon", 130, "Water"),
    new Pokemon("Lilligant", 70, "Grass"),
    new Pokemon("Floragato", 61, "Grass")
};

Pokedex pokedex = new();
myPokemon.ForEach(
    pokemon => pokedex.Add(pokemon));

Console.Out.WriteLine(pokedex.Count());
Enter fullscreen mode Exit fullscreen mode
5
Enter fullscreen mode Exit fullscreen mode

Lets add more:

pokedex.Add(new Pokemon("Sandaconda", 72, "Ground"));
pokedex.Add(new Pokemon("Eevee", 55 , "Normal"));
Enter fullscreen mode Exit fullscreen mode

Since Eevee is already in the Pokédex and KeyedCollection follows the conventions for a Collection, an exception is thrown when trying to add a Pokémon that is already in the Pokédex:

Error: System.ArgumentException: An item with the same key has already been added. Key: Eevee

Enter fullscreen mode Exit fullscreen mode

Use the Contains method to check if the Pokémon is recorded in the Pokédex:

bool havePikachu = pokedex.Contains("Pikachu");
Console.Out.WriteLine(havePikachu);
Enter fullscreen mode Exit fullscreen mode
True
Enter fullscreen mode Exit fullscreen mode

Remove a Pokémon from the Pokédex with Remove:

pokedex.Remove("Pikachu");
bool havePikachu = pokedex.Contains("Pikachu");
Console.Out.WriteLine(havePikachu);
Enter fullscreen mode Exit fullscreen mode
False
Enter fullscreen mode Exit fullscreen mode

Pokémon can be accessed by name. Lets add an extension method to the Pokemon class for printing it and demonstrate retrieval. You may also iterate over every Pokémon in the Pokédex:

public static void Print(this Pokemon pokemon) {
    Console.Out.WriteLine($"{pokemon.Name}");
    Console.Out.WriteLine($"HP: {pokemon.HP}");
    Console.Out.WriteLine($"{pokemon.Type} Type");
}

Pokemon eevee = pokedex["Eevee"];
eevee.Print();
Enter fullscreen mode Exit fullscreen mode
Eevee
HP: 55
Normal Type
Enter fullscreen mode Exit fullscreen mode
foreach(Pokemon pokemon in pokedex) {
    Console.Out.WriteLine("---");
    pokemon.Print();
}
Enter fullscreen mode Exit fullscreen mode
---
Eevee
HP: 55
Normal Type
---
Vaporeon
HP: 130
Water Type
---
Lilligant
HP: 70
Grass Type
---
Floragato
HP: 61
Grass Type
---
Sandaconda
HP: 72
Ground Type
Enter fullscreen mode Exit fullscreen mode

LINQ operations may also be used on the Pokédex to easily filter, query, and transform data:

Count the number of Pokémon with HP larger than 70:

pokedex.Where(pokemon=>pokemon.HP > 70).
    Count()
Enter fullscreen mode Exit fullscreen mode
2
Enter fullscreen mode Exit fullscreen mode

Return the value of the highest HP in the Pokédex:

pokedex.Max(pokemon=>pokemon.HP)
Enter fullscreen mode Exit fullscreen mode
130
Enter fullscreen mode Exit fullscreen mode

Using Generics

The type parameters may also take advantage of generics to extend capabilities. Here, we'll create an interface to encapsulate our Pokémon and use it to specialize based on the type of the Pokémon species. The Pokédex will still use the name of a Pokémon as its key, but the data type of the Pokémon entry is deferred until the Pokédex is created. We'll constrain the type parameter of the Pokémon using the interface type:

public interface IPokemon {

    string Name {get;}

    int HP {get;}

    string Type {get;}

}

public class WaterPokemon : IPokemon {

    private readonly string _name;
    private readonly int _hp;

    public WaterPokemon(string name, int hp) {
        _name=name;
        _hp = hp;
    }

    public string Name => _name;

    public int HP => _hp;

    public string Type => "Water";

}

public class GrassPokemon : IPokemon {

    private readonly string _name;
    private readonly int _hp;

    public GrassPokemon(string name, int hp) {
        _name=name;
        _hp = hp;
    }

    public string Name => _name;

    public int HP => _hp;

    public string Type => "Grass";

}
Enter fullscreen mode Exit fullscreen mode
public class Pokedex<TPokemon> : KeyedCollection<string,TPokemon>
 where TPokemon : IPokemon {

    override protected string GetKeyForItem(TPokemon pokemon) {
        return pokemon.Name;
    }  
 }
Enter fullscreen mode Exit fullscreen mode
    WaterPokemon vaporeon = new WaterPokemon("Vaporeon", 130);
    GrassPokemon lilligant = new GrassPokemon("Lilligant", 70);
    GrassPokemon florogato = new GrassPokemon("Floragato", 61);

    Pokedex<WaterPokemon> waterPokemon = new();
    Pokedex<GrassPokemon> grassPokemon = new();
    Pokedex<IPokemon> allPokemon = new();

    waterPokemon.Add(vaporeon);

    grassPokemon.Add(lilligant);
    grassPokemon.Add(florogato);

    allPokemon.Add(vaporeon);
    allPokemon.Add(lilligant);
    allPokemon.Add(florogato);
Enter fullscreen mode Exit fullscreen mode

Final Thoughts and Caveats

KeyedCollection provides a simple, expressive way to do a key to value mapping when the key is a member of the value itself. Operations on the collection are constant time and uniqueness is guaranteed and enforced by the API. The unique key is a natural identifier for the data, allowing for cleaner, more readable and intuitive code. It doesn't come with any major drawbacks but if you were paying attention to the code then you might have noticed the use of the readonly modifier. KeyedCollection does come with one big caveat - immutability. When using KeyedCollection you want to make sure that your keys are immutable.

Now go make some KeyedCollection magic. You can find a Notebook with the code here.

Top comments (0)