Why is TryGetValue on a Dictionary who's key is also a dictionary returning Null?

751 views Asked by At

Goal: get a value from a dictionary. Said value has a dictionary as a key.

What I'm doing: I'm creating a second dictionary that has the exact same values as the key whose value I'm trying to get. Using TryGetValue

Result: Expecting a value but getting null;

Context: I'm trying to make a crafting functionality in Unity. This is what the class for a crafting ingredient looks like (ICombinable looks the exact same right now):

public class Ingredient : ICombinable
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public Effect Effect { get; set; }
    }

In practice I want a user to be able to drag objects of type ICombinable onto the UI(not implemented) and press a button to combine them into a new item. For example, 2 herbs and 1 glass of water return a healing potion(a new item).

Behind the surface, I will store the dragged/selected objects in a Dictionary<ICombinable, int> where int is the amount per ICombinable.

In another class, I am storing another Dictionary which is going to hold all the recipes.

public class Combiner
{
        private Dictionary<Dictionary<ICombinable, int>, ICraftable> _recipebook;

        public Combiner()
        {
            _recipebook = new Dictionary<Dictionary<ICombinable, int>, ICraftable>(); // <Recipe, Item it unlocks>
        }

        public void AddRecipe(Dictionary<ICombinable, int> recipe, ICraftable item) =>  _recipebook.Add(recipe, item);
        
        public ICraftable Craft(Dictionary<ICombinable, int> ingredientsAndAmount) =>
            _recipebook.TryGetValue(ingredientsAndAmount, out var item) == false ? null : item;
        //FirstOrDefault(x => x.Key.RequiredComponents.Equals(givenIngredients)).Value;
    }

The key of _recipebook is the actual recipe comprised of ingredients and their amounts. ICraftable is the object/item that corresponds with that recipe. In the example I gave earlier ICraftable would be the healing potion and the 2 sticks and the glass of water would each be an entry in the Dictionary that is the key of that value.

And lastly, the Craft method takes a dictionary(in other words a list of ingredients and their amounts) and I want it to check in the _recipebook which item corresponds with the given dictionary. If the combination of ingredients is valid it should return an item, otherwise null.

How I am testing this functionality: I just started the project so I wanted to start with unit testing. Here is the setup:

[Test]
    public void combiner_should_return_healing_potion()
    {
        // Use the Assert class to test conditions
        var combiner = new Combiner();
        var item = new Item
        {
            Name = "Healing Potion",
            Unlocked = false
        };

    
        combiner.AddRecipe(new Dictionary<ICombinable, int>
        {
            {new Ingredient {Name = "Herb", Description = "Has healing properties", Effect = Effect.Heal}, 3},
            {new Ingredient {Name = "Water", Description = "Spring water", Effect = default}, 1},
            {new Ingredient {Name = "Sugar", Description = "Sweetens", Effect = default}, 2}
        },
        item);

        var actualItem = combiner.Craft(new Dictionary<ICombinable, int>
        {
            {new Ingredient { Name = "Herb", Description = "Has healing properties", Effect = Effect.Heal} , 3},
            {new Ingredient {Name = "Water", Description = "Spring water", Effect = default}, 1},
            {new Ingredient {Name = "Sugar", Description = "Sweetens", Effect = default}, 2}
        });
        
        Assert.That(actualItem, Is.EqualTo(item));

    }

Result:

combiner_should_return_healing_potion (0.023s)
---
Expected: <Models.Item>
  But was:  null
---

I am creating an item called healing potion and a dictionary that should be its recipe. I add these to the recipe book. After that, I am creating a second dictionary to "simulate" a user's input. The dictionary has the exact same content as the one I am adding to the recipe book with Add recipe(). How come that TryGetValue does not see these two dictionaries as equal?

What can I do to get it working?

2

There are 2 answers

0
Aziz On BEST ANSWER

SOLVED:

I tried to make my own IEqualitycomparer but couldn't get my dictionaries to use the Equals I defined in there. So I made a Recipe class instead and overrode the Equals there.

public class Recipe : ICraftable
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public Dictionary<ICombinable, int> RequiredComponents { get; set; }
        public bool Unlocked { get; set; }

        public override bool Equals(object obj)
        {
            var otherDict = obj as Dictionary<ICombinable, int>;
            if (ReferenceEquals(otherDict, RequiredComponents)) return true;
            if (ReferenceEquals(otherDict, null)) return false;
            if (ReferenceEquals(RequiredComponents, null)) return false;
            if (otherDict.GetType() != RequiredComponents.GetType()) return false;
            return otherDict.Count == RequiredComponents.Count && AreValuesEqual(otherDict, RequiredComponents);
        }
        
        private bool AreValuesEqual(Dictionary<ICombinable, int> x, Dictionary<ICombinable, int> y)
        {
            var matches = 0;
            //Goal is to check if all ingredients have the same name on both sides. Then I'll check if they have the same amount
            foreach (var xKvp in x)
            {
                foreach (var yKvp in y)
                {
                    if (xKvp.Key.Name == yKvp.Key.Name && xKvp.Value == yKvp.Value)
                        matches++;
                }
            }
            return matches == x.Count;
        }
    }

I copied the first 4 lines from the default Equals you get with an IEqualityComparer and made a custom one for the 5th. My AreValuesEqual bool just checks if all the ingredients are present in the other dictionary by name and count.

I changed my Combiner class to use a Recipe object as key instead of a Dictionary and adjusted the methods for AddRecipe and Craft accordingly:

public class Combiner
    {
        private Dictionary<Recipe, ICraftable> _recipebook;

        public Combiner()
        {
            _recipebook = new Dictionary<Recipe, ICraftable>(); // <Recipe, Item it unlocks>
        }

        public void AddRecipe(Recipe recipe, ICraftable item) =>  _recipebook.Add(recipe, item);
        
        public ICraftable Craft(Dictionary<ICombinable, int> ingredientsAndAmount) =>
            _recipebook.FirstOrDefault(kvp=> kvp.Key.Equals(ingredientsAndAmount)).Value;
    }

And this is how I set up my unit test:

[Test]
    public void combiner_should_return_healing_potion()
    {
        // Use the Assert class to test conditions
        var combiner = new Combiner();
        var potion = new Item
        {
            Name = "Healing Potion",
            Unlocked = false
        };

        combiner.AddRecipe(new Recipe
            {
                Name = "Healing potion recipe",
                Description = "Invented by some sage",
                RequiredComponents = new Dictionary<ICombinable, int>
                {
                    {new Ingredient() { Name = "Herb", Description = "Has healing properties", Effect = Effect.Heal} , 3},
                    {new Ingredient {Name = "Water", Description = "Spring water", Effect = default}, 1},
                    {new Ingredient {Name = "Sugar", Description = "Sweetens", Effect = default}, 2}

                }
            },
            potion);

        var userGivenIngredientsAndAmount = combiner.Craft(new Dictionary<ICombinable, int>()
        {
            {new Ingredient() { Name = "Herb", Description = "Has healing properties", Effect = Effect.Heal} , 3},
            {new Ingredient {Name = "Water", Description = "Spring water", Effect = default}, 1},
            {new Ingredient {Name = "Sugar", Description = "Sweetens", Effect = default}, 2}
        });
        
        Assert.That(userGivenIngredientsAndAmount, Is.EqualTo(potion));
    }

And it is functioning as intended. Changing the Name or count in one of the dictionaries causes it to return null like it should. It's probably very inefficient! But I'm still at the 'make it work' stage. I will get to'make it nice, make it fast' soon.

Thanks everyone for putting me on the right track! I'd be happy to accept any advice on making this more efficient. But since it's working as intended, I'm marking this question as solved tomorrow.

0
Ben Voigt On

Because the object you're looking up with doesn't exist in the Dictionary.

You can convince yourself of this quite easily, just loop through the Keys collection and use == or Equals to compare each key dictionary to the searched-for dictionary.

That is

_recipebook.Count(x => x.Key == ingredientsAndAmount)

You will find zero matches.

The solution is to provide your own implementation of Equals and HashCode, either via IEqualityComparer as suggested in the comments, or by wrapping the key dictionary into a Recipe class that provides them, and using that Recipe as the actual key.