4
\$\begingroup\$

Inspired by this question and its answers, I've made my own version.

  /// <summary>
  /// A one-one relation bidirectional map.
  /// <para>
  /// A one-one relation means that each entry of type <typeparamref name="TFirst"/> can correspond to exactly one 
  /// entry of type <typeparamref name="TSecond"/> and visa versa.
  /// </para>
  /// The map doesn't support null objects because each element is both key and value in its relation and keys can't be null.
  /// </summary>
  /// <typeparam name="TFirst">Any type</typeparam>
  /// <typeparam name="TSecond">Any type</typeparam>
  public class BidirectionalMap<TFirst, TSecond> : IEnumerable<KeyValuePair<TFirst, TSecond>>
  {
    private readonly Dictionary<TFirst, TSecond> primary;
    private readonly Dictionary<TSecond, TFirst> secondary;

    public BidirectionalMap()
    {
      primary = new Dictionary<TFirst, TSecond>();
      secondary = new Dictionary<TSecond, TFirst>();
    }

    /// <summary>
    /// Creates a BidirectionalMap initialized with the specified <paramref name="capacity"/>.
    /// </summary>
    /// <param name="capacity">The desired capacity for the map.</param>
    /// <exception cref="ArgumentOutOfRangeException">If capacity is out of range (&lt; 0)</exception>
    public BidirectionalMap(int capacity)
    {
      primary = new Dictionary<TFirst, TSecond>(capacity);
      secondary = new Dictionary<TSecond, TFirst>(capacity);
    }

    /// <summary>
    /// Creates a BidirectionalMap with the specified equality comparers.
    /// </summary>
    /// <param name="firstComparer">Equality comparer for <typeparamref name="TFirst"/>. If null, the default comparer is used.</param>
    /// <param name="secondComparer">Equality comparer for <typeparamref name="TSecond"/>. If null, the default comparer is used.</param>
    public BidirectionalMap(IEqualityComparer<TFirst> firstComparer, IEqualityComparer<TSecond> secondComparer)
    {
      primary = new Dictionary<TFirst, TSecond>(firstComparer);
      secondary = new Dictionary<TSecond, TFirst>(secondComparer);
    }

    /// <summary>
    /// Creates a BidirectionalMap from the <paramref name="source"/> dictionary.
    /// </summary>
    /// <param name="source">The source dictionary from which to create a one-one relation map</param>
    /// <exception cref="ArgumentException">If <paramref name="source"/> contains doublets in values</exception>
    /// <exception cref="ArgumentNullException">If <paramref name="source"/> contains null values</exception>
    public BidirectionalMap(IDictionary<TFirst, TSecond> source)
    {
      primary = new Dictionary<TFirst, TSecond>(source);
      secondary = source.ToDictionary(kvp => kvp.Value, kvp => kvp.Key);
    }

    /// <summary>
    /// Creates a BidirectionalMap from the <paramref name="inverseSource"/> dictionary.
    /// </summary>
    /// <param name="inverseSource">The source dictionary from which to create a one-one relation map</param>
    /// <exception cref="ArgumentException">If <paramref name="inverseSource"/> contains doublets in values</exception>
    /// <exception cref="ArgumentNullException">If <paramref name="inverseSource"/> contains null values</exception>
    public BidirectionalMap(IDictionary<TSecond, TFirst> inverseSource)
    {
      primary = inverseSource.ToDictionary(kvp => kvp.Value, kvp => kvp.Key);
      secondary = new Dictionary<TSecond, TFirst>(inverseSource);
    }

    public int Count => primary.Count;

    public ICollection<TFirst> PrimaryKeys => primary.Keys;
    public ICollection<TSecond> SecondaryKeys => secondary.Keys;

    // This should be useful only for enumeration by TSecond as key
    public IReadOnlyDictionary<TSecond, TFirst> Inverse => secondary;

    public TSecond this[TFirst first]
    {
      get { return primary[first]; }
      set { Set(first, value); }
    }

    public TFirst this[TSecond second]
    {
      get { return secondary[second]; }
      set { Set(value, second); }
    }

    private void Set(TFirst first, TSecond second)
    {
      // Remove both the entries related to first and second if any
      Remove(first);
      Remove(second);
      // Now it should be safe to add the new relation.
      Add(first, second);
    }

    public void Add(TFirst first, TSecond second)
    {
      try
      {
        primary.Add(first, second);
      }
      catch (ArgumentNullException)
      {
        // If first is null, we end here and can rethrow with no harm done.
        throw new ArgumentNullException(nameof(first));
      }
      catch (ArgumentException)
      {
        // If the key is present in primary, then we end here and can rethrow with no harm done.
        throw new ArgumentException(nameof(first), $"{first} already present in the dictionary");
      }

      try
      {
        secondary.Add(second, first);
      }
      catch (ArgumentNullException)
      {
        // If second is null, we end here, and primary must be rolled back - because first was added successfully
        primary.Remove(first);
        throw new ArgumentNullException(nameof(second));
      }
      catch (ArgumentException)
      {
        // If second exists in secondary, secondary throws, and primary must be rolled back - because first was added successfully
        primary.Remove(first);
        throw new ArgumentException(nameof(second), $"{second} already present in the dictionary");
      }
    }

    public bool Remove(TFirst first)
    {
      if (primary.TryGetValue(first, out var second))
      {
        secondary.Remove(second);
        primary.Remove(first);
        return true;
      }

      return false;
    }

    public bool Remove(TSecond second)
    {
      if (secondary.TryGetValue(second, out var first))
      {
        primary.Remove(first);
        secondary.Remove(second);
        return true;
      }

      return false;
    }

    public bool TryGetValue(TFirst first, out TSecond second)
    {
      return primary.TryGetValue(first, out second);
    }

    public bool TryGetValue(TSecond second, out TFirst first)
    {
      return secondary.TryGetValue(second, out first);
    }

    public bool Contains(TFirst first)
    {
      return primary.ContainsKey(first);
    }

    public bool Contains(TSecond second)
    {
      return secondary.ContainsKey(second);
    }

    public void Clear()
    {
      primary.Clear();
      secondary.Clear();
    }

    public IEnumerator<KeyValuePair<TFirst, TSecond>> GetEnumerator()
    {
      return primary.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
      return GetEnumerator();
    }
  }

It doesn't implement IDictionary<T, S> for either directions, but has just about the same public interface for both. The directions should therefore be regarded as equal.


Here are a set of unit tests - not complete but covering the most parts:

  [TestClass]
  public class BidirectionalMapTests
  {
    class TestObject<T>
    {
      public TestObject(T value)
      {
        Value = value;
      }

      public T Value { get; }

      public static implicit operator T(TestObject<T> to) => to.Value;
      public static implicit operator TestObject<T>(T value) => new TestObject<T>(value);

      public override string ToString()
      {
        return Value?.ToString() ?? "";
      }
    }

    [TestMethod]
    public void InitializeFromSourceDictionary()
    {
      Dictionary<string, int> source = new Dictionary<string, int>
      {
        { "a", 1 },
        { "b", 2 }
      };

      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>(source);

      Assert.AreEqual(1, map["a"]);
      Assert.AreEqual("b", map[2]);
    }

    [TestMethod]
    public void InvalidInitializeFromSourceDictionary()
    {
      TestObject<string> one = new TestObject<string>("1");

      Dictionary<string, TestObject<string>> source = new Dictionary<string, TestObject<string>>
      {
        { "a", one },
        { "b", one }
      };

      BidirectionalMap<string, TestObject<string>> map = null;

      Assert.ThrowsException<ArgumentException>(() => map = new BidirectionalMap<string, TestObject<string>>(source));

      Dictionary<TestObject<string>, string> inverseSource = new Dictionary<TestObject<string>, string>
      {
        { "a", "1" },
        { "b", "1" }
      };

      Assert.ThrowsException<ArgumentException>(() => map = new BidirectionalMap<string, TestObject<string>>(inverseSource));

      source = new Dictionary<string, TestObject<string>>
      {
        { "a", null },
        { "b", "1" }
      };

      Assert.ThrowsException<ArgumentNullException>(() => map = new BidirectionalMap<string, TestObject<string>>(source));
    }

    [TestMethod]
    public void Add()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      Assert.AreEqual(1, map["a"]);
      Assert.AreEqual("b", map[2]);
      Assert.AreEqual(2, map.Count);
    }

    [TestMethod]
    public void InvalidAdd()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      Assert.ThrowsException<ArgumentException>(() => map.Add("a", 2));
      Assert.ThrowsException<ArgumentException>(() => map.Add("b", 1));
      Assert.AreEqual(1, map["a"]);
    }

    [TestMethod]
    public void AddNull()
    {
      BidirectionalMap<string, string> map = new BidirectionalMap<string, string>();

      Assert.ThrowsException<ArgumentNullException>(() => map.Add(null, "a"));
      Assert.ThrowsException<ArgumentNullException>(() => map.Add("a", null));
      Assert.AreEqual(0, map.Count);
    }

    [TestMethod]
    public void Remove()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);
      Assert.AreEqual(2, map.Count);

      map.Remove("a");
      Assert.AreEqual(1, map.Count);

      map.Remove(2);
      Assert.AreEqual(0, map.Count);
    }

    [TestMethod]
    public void RemoveNonExistingValue()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      Assert.IsFalse(map.Remove("c"));
      Assert.AreEqual(2, map.Count);
    }

    [TestMethod]
    public void Set()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      map["a"] = 3;

      Assert.AreEqual(2, map.Count);
      Assert.IsTrue(map.TryGetValue("a", out int second));
      Assert.AreEqual(3, second);
      Assert.IsTrue(map.TryGetValue(3, out string first));
      Assert.AreEqual("a", first);

      Assert.IsFalse(map.TryGetValue(1, out _));
    }

    [TestMethod]
    public void SetWithExistingSecondValue()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      map["a"] = 2;

      Assert.AreEqual(1, map.Count);
      Assert.IsTrue(map.TryGetValue("a", out int second));
      Assert.AreEqual(2, second);
      Assert.IsTrue(map.TryGetValue(2, out string first));
      Assert.AreEqual("a", first);

      Assert.IsFalse(map.TryGetValue("b", out _));
    }

    [TestMethod]
    public void TryGetValue()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>
      {
        { "a", 1 },
        { "b", 2 }
      };

      Assert.IsTrue(map.TryGetValue("a", out int second));
      Assert.AreEqual(1, second);

      Assert.IsTrue(map.TryGetValue(2, out string first));
      Assert.AreEqual("b", first);

      Assert.IsFalse(map.TryGetValue("c", out _));
      Assert.IsFalse(map.TryGetValue(3, out _));
    }
  }

Any comments are welcome, but the implementation of Add(), Set() and Remove() are the most vulnerable parts.

I think that the naming TFirst, TSecond, primary and secondary could be better in order to reflect their equal status, but I didn't find any better. May be you have any suggestions?

\$\endgroup\$
4
  • 2
    \$\begingroup\$ This solution is an example of how enthusiasm can make one blind about the obvious pitfalls - as mentioned by Peter in his answer - embarrassing. \$\endgroup\$ – user73941 Sep 5 '19 at 8:18
  • 2
    \$\begingroup\$ I know this feeling. Code Review is sometimes cruel ;-P \$\endgroup\$ – t3chb0t Sep 5 '19 at 8:32
  • \$\begingroup\$ for the naming: en.wikipedia.org/wiki/Bijection they mention X and Y. But I'm not sure we should use these mathematical names as member names in C#. What do you think? \$\endgroup\$ – dfhwze Sep 5 '19 at 11:44
  • \$\begingroup\$ @dfhwze: I don't like single char names, but they are at least neutral. I'll consider that. \$\endgroup\$ – user73941 Sep 5 '19 at 12:14
4
\$\begingroup\$

If TFirst and TSecond are the same, nearly all of the API becomes useless because the compiler can't disambiguate the method calls.

I think a better design would be for Inverse to be a BidirectionalMap<TSecond, TFirst>, so that the methods don't need to be duplicated. Then one obvious unit test would be ReferenceEquals(map, map.Inverse.Inverse). And I think the obstacle to implementing at least IReadOnlyDictionary<TFirst, TSecond> would have been removed.


Add seems overly complicated and slightly fragile. I don't think the tiny performance improvement justifies the complexity over

    public void Add(TFirst first, TSecond second)
    {
      if (first == null) throw new ArgumentNullException(nameof(first));
      if (primary.ContainsKey(first)) throw new ArgumentException(nameof(first), $"{first} already present in the dictionary");
      if (second == null) throw new ArgumentNullException(nameof(second));
      if (secondary.ContainsKey(second)) throw new ArgumentException(nameof(second), $"{second} already present in the dictionary");

      primary.Add(first, second);
      secondary.Add(second, first);
    }
\$\endgroup\$
2
  • \$\begingroup\$ Oh, I just realized that you mentioned the same bug but with different examples hehe. \$\endgroup\$ – t3chb0t Sep 5 '19 at 8:10
  • 1
    \$\begingroup\$ @HenrikHansen then let it be a secret review ;) \$\endgroup\$ – t3chb0t Sep 5 '19 at 8:21
4
\$\begingroup\$

Set and Remove are compact and clean code. Add is convoluted with these exception handlers:

  try
  {
    primary.Add(first, second);
  }
  catch (ArgumentNullException)
  {
    // If first is null, we end here and can rethrow with no harm done.
    throw new ArgumentNullException(nameof(first));
  }
  catch (ArgumentException)
  {
    // If the key is present in primary, then we end here and can rethrow with no harm done.
    throw new ArgumentException(nameof(first), $"{first} already present in the dictionary");
  }

I would opt for calling sand-box methods TryAdd and Remove instead:

public void Add(TFirst first, TSecond second)
{
    if (!primary.TryAdd(first, second) && !secondary.TryAdd(second, first))
    {
        primary.Remove(first);
        throw new InvalidOperationException("The tuple violates the bijective constraint");
    }
}

You can make the body a bit more verbose if you which to notify the caller which of the arguments violates the constraint.

public void Add(TFirst first, TSecond second)
{
    if (!primary.TryAdd(first, second))
    {
        throw new InvalidOperationException("The first arg violates the bijective constraint");
    }

    if (!secondary.TryAdd(second, first))
    {
        primary.Remove(first);
        throw new InvalidOperationException("The second arg violates the bijective constraint");
    }
}

You could even check for a combination if you really need to:

public void Add(TFirst first, TSecond second)
{
    var primaryAdded = primary.TryAdd(first, second);
    var secondaryAdded = secondary.TryAdd(second, first);

    if (primaryAdded && !secondaryAdded)
    {
        primary.Remove(first);
        throw new InvalidOperationException("The second arg violates the bijective constraint");
    }

    if (!primaryAdded && secondaryAdded)
    {
        secondary.Remove(second);
        throw new InvalidOperationException("The first arg violates the bijective constraint");
    }

    if (!primaryAdded && !secondaryAdded)
    {
        throw new InvalidOperationException("Both args violate the bijective constraint");
    }
}
\$\endgroup\$
1
  • 1
    \$\begingroup\$ I didn't know about TryAdd() as it is still in preview for .NET Standard. But I could maybe make an extension. My argument for the convoluted approach was to let the dictionaries handle invalid input for performance reasons. Personally I don't find it that convoluted - the workflow and logic is easy followed IMO. I'll make a self-answer later in the evening that - I think - will build on the idea of an Inverse. \$\endgroup\$ – user73941 Sep 5 '19 at 11:58
2
\$\begingroup\$

My question is a disaster, and I don't know where my thoughts were while writing it. Funny enough I managed to write a lot of unittest even with the same type for the two keys without getting any compiler errors. Anyway below is a new version that builds on the Inverse concept, and it seems to do the trick all the way - but I'm sure someone can find something to criticize. I have experimented with the naming, but I'm not sure if they are final.

  /// <summary>
  /// A one-one relation bidirectional map.
  /// <para>
  /// A one-one relation means that each entry of type <typeparamref name="TPrimary"/> can correspond to exactly one 
  /// entry of type <typeparamref name="TSecondary"/> and visa versa.
  /// </para>
  /// The map doesn't support null elements because each element is both key and value in its relation and keys can't be null.
  /// </summary>
  /// <typeparam name="TPrimary">Any type</typeparam>
  /// <typeparam name="TSecondary">Any type</typeparam>
  public sealed class BidirectionalMap<TPrimary, TSecondary> : IEnumerable<KeyValuePair<TPrimary, TSecondary>>
  {
    private readonly Dictionary<TPrimary, TSecondary> map;
    private readonly Dictionary<TSecondary, TPrimary> inverseMap;

    /// <summary>
    /// Creates a BidirectionalMap from the provided dictionaries.
    /// Should be used only to create the Inverse.
    /// </summary>
    /// <param name="map"></param>
    /// <param name="inverseMap"></param>
    private BidirectionalMap(BidirectionalMap<TSecondary, TPrimary> inverse, Dictionary<TPrimary, TSecondary> map, Dictionary<TSecondary, TPrimary> inverseMap)
    {
      this.map = map;
      this.inverseMap = inverseMap;
      Inverse = inverse;
    }

    private BidirectionalMap(int capacity, IEqualityComparer<TPrimary> primaryComparer, IEqualityComparer<TSecondary> secondaryComparer)
    {
      map = new Dictionary<TPrimary, TSecondary>(capacity, primaryComparer);
      inverseMap = new Dictionary<TSecondary, TPrimary>(capacity, secondaryComparer);
      Inverse = new BidirectionalMap<TSecondary, TPrimary>(this, inverseMap, map);
    }

    public BidirectionalMap()
    {
      map = new Dictionary<TPrimary, TSecondary>();
      inverseMap = new Dictionary<TSecondary, TPrimary>();
      Inverse = new BidirectionalMap<TSecondary, TPrimary>(this, inverseMap, map);
    }

    /// <summary>
    /// Creates a BidirectionalMap initialized with the specified <paramref name="capacity"/>.
    /// </summary>
    /// <param name="capacity">The desired capacity for the map.</param>
    /// <exception cref="ArgumentOutOfRangeException">If capacity is out of range (&lt; 0)</exception>
    public BidirectionalMap(int capacity) : this(capacity, null, null)
    {
    }

    /// <summary>
    /// Creates a BidirectionalMap with the specified equality comparers.
    /// </summary>
    /// <param name="mapComparer">Equality comparer for <typeparamref name="TPrimary"/>. If null, the default comparer is used.</param>
    /// <param name="inverseComparer">Equality comparer for <typeparamref name="TSecondary"/>. If null, the default comparer is used.</param>
    public BidirectionalMap(IEqualityComparer<TPrimary> mapComparer, IEqualityComparer<TSecondary> inverseComparer)
      : this(0, mapComparer, inverseComparer)
    {
    }

    /// <summary>
    /// Creates a BidirectionalMap from the <paramref name="source"/> dictionary.
    /// </summary>
    /// <param name="source">The source dictionary from which to create a one-one relation map</param>
    /// <exception cref="ArgumentException">If <paramref name="source"/> contains doublets in values</exception>
    /// <exception cref="ArgumentNullException">If <paramref name="source"/> contains null keys and/or values</exception>
    public BidirectionalMap(IDictionary<TPrimary, TSecondary> source)
    {
      map = new Dictionary<TPrimary, TSecondary>(source);
      inverseMap = new Dictionary<TSecondary, TPrimary>(source.ToDictionary(kvp => kvp.Value, kvp => kvp.Key));
      Inverse = new BidirectionalMap<TSecondary, TPrimary>(this, inverseMap, map);
    }

    /// <summary>
    /// Creates a BidirectionalMap from the <paramref name="inverseSource"/> dictionary.
    /// </summary>
    /// <param name="inverseSource">The source dictionary from which to create a one-one relation map</param>
    /// <exception cref="ArgumentException">If <paramref name="inverseSource"/> contains doublets in values</exception>
    /// <exception cref="ArgumentNullException">If <paramref name="inverseSource"/> contains null keys and/or values</exception>
    public BidirectionalMap(IDictionary<TSecondary, TPrimary> inverseSource)
    {
      map = new Dictionary<TPrimary, TSecondary>(inverseSource.ToDictionary(kvp => kvp.Value, kvp => kvp.Key));
      inverseMap = new Dictionary<TSecondary, TPrimary>(inverseSource);
      Inverse = new BidirectionalMap<TSecondary, TPrimary>(this, inverseMap, map);
    }

    public BidirectionalMap<TSecondary, TPrimary> Inverse { get; }
    public int Count => map.Count;

    public ICollection<TPrimary> Keys => map.Keys;
    public ICollection<TSecondary> InverseKeys => inverseMap.Keys;

    public TSecondary this[TPrimary key]
    {
      get { return map[key]; }
      set { Set(key, value); }
    }

    public void Set(TPrimary primary, TSecondary secondary)
    {
      // Remove both the entries related to primary and secondary if any
      Remove(primary, secondary);
      // Now it should be safe to add the new relation.
      Add(primary, secondary);
    }

    public void Add(TPrimary primary, TSecondary secondary)
    {
      if (primary == null) throw new ArgumentNullException(nameof(primary));
      if (map.ContainsKey(primary)) throw new ArgumentException(nameof(primary), $"{primary} already present in the dictionary");
      if (secondary == null) throw new ArgumentNullException(nameof(secondary));
      if (inverseMap.ContainsKey(secondary)) throw new ArgumentException(nameof(secondary), $"{secondary} already present in the dictionary");

      map.Add(primary, secondary);
      inverseMap.Add(secondary, primary);
    }

    private bool Remove(TPrimary primary, TSecondary secondary)
    {
      bool result = false;

      if (map.TryGetValue(primary, out var primaryValue))
      {
        inverseMap.Remove(primaryValue);
        map.Remove(primary);
        result = true;
      }

      if (inverseMap.TryGetValue(secondary, out var secondaryValue))
      {
        map.Remove(secondaryValue);
        inverseMap.Remove(secondary);
        result = true;
      }

      return result;
    }

    public bool Remove(TPrimary primary)
    {
      if (map.TryGetValue(primary, out var secondary))
      {
        inverseMap.Remove(secondary);
        map.Remove(primary);
        return true;
      }

      return false;
    }

    public bool TryGetValue(TPrimary primary, out TSecondary secondary)
    {
      return map.TryGetValue(primary, out secondary);
    }

    public bool ContainsKey(TPrimary primary)
    {
      return map.ContainsKey(primary);
    }

    public void Clear()
    {
      map.Clear();
      inverseMap.Clear();
    }

    public IEnumerator<KeyValuePair<TPrimary, TSecondary>> GetEnumerator()
    {
      return map.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
      return GetEnumerator();
    }
  }

The corresponding unittests:

  [TestClass]
  public class BidirectionalMapTests
  {
    class TestObject<T>
    {
      public TestObject(T value)
      {
        Value = value;
      }

      public T Value { get; }

      public static implicit operator T(TestObject<T> to) => to.Value;
      public static implicit operator TestObject<T>(T value) => new TestObject<T>(value);

      public override string ToString()
      {
        return Value?.ToString() ?? "";
      }
    }

    [TestMethod]
    public void InitializeFromSourceDictionary()
    {
      Dictionary<string, int> source = new Dictionary<string, int>
      {
        { "a", 1 },
        { "b", 2 }
      };

      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>(source);

      Assert.AreEqual(1, map["a"]);
      Assert.AreEqual("b", map.Inverse[2]);
    }

    [TestMethod]
    public void InvalidInitializeFromSourceDictionary()
    {
      TestObject<string> one = new TestObject<string>("1");

      Dictionary<string, TestObject<string>> source = new Dictionary<string, TestObject<string>>
      {
        { "a", one },
        { "b", one }
      };

      BidirectionalMap<string, TestObject<string>> map = null;

      Assert.ThrowsException<ArgumentException>(() => map = new BidirectionalMap<string, TestObject<string>>(source));

      Dictionary<TestObject<string>, string> inverseSource = new Dictionary<TestObject<string>, string>
      {
        { "a", "1" },
        { "b", "1" }
      };

      Assert.ThrowsException<ArgumentException>(() => map = new BidirectionalMap<string, TestObject<string>>(inverseSource));

      source = new Dictionary<string, TestObject<string>>
      {
        { "a", null },
        { "b", "1" }
      };

      Assert.ThrowsException<ArgumentNullException>(() => map = new BidirectionalMap<string, TestObject<string>>(source));
    }

    [TestMethod]
    public void Add()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      Assert.AreEqual(1, map["a"]);
      Assert.AreEqual("b", map.Inverse[2]);
      Assert.AreEqual(2, map.Count);
    }

    [TestMethod]
    public void InvalidAdd()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      Assert.ThrowsException<ArgumentException>(() => map.Add("a", 2));
      Assert.ThrowsException<ArgumentException>(() => map.Add("b", 1));
      Assert.AreEqual(1, map["a"]);
    }

    [TestMethod]
    public void AddNull()
    {
      BidirectionalMap<string, string> map = new BidirectionalMap<string, string>();

      Assert.ThrowsException<ArgumentNullException>(() => map.Add(null, "a"));
      Assert.ThrowsException<ArgumentNullException>(() => map.Add("a", null));
      Assert.AreEqual(0, map.Count);
    }

    [TestMethod]
    public void Remove()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);
      Assert.AreEqual(2, map.Count);

      map.Remove("a");
      Assert.AreEqual(1, map.Count);

      map.Inverse.Remove(2);
      Assert.AreEqual(0, map.Count);
    }

    [TestMethod]
    public void RemoveNonExistingValue()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      Assert.IsFalse(map.Remove("c"));
      Assert.AreEqual(2, map.Count);
    }

    [TestMethod]
    public void Set()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      map["a"] = 3;

      Assert.AreEqual(2, map.Count);
      Assert.IsTrue(map.TryGetValue("a", out int second));
      Assert.AreEqual(3, second);
      Assert.IsTrue(map.Inverse.TryGetValue(3, out string first));
      Assert.AreEqual("a", first);

      Assert.IsFalse(map.Inverse.TryGetValue(1, out _));
    }

    [TestMethod]
    public void SetWithExistingSecondValue()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();

      map.Add("a", 1);
      map.Add("b", 2);

      map["a"] = 2;

      Assert.AreEqual(1, map.Count);
      Assert.IsTrue(map.TryGetValue("a", out int second));
      Assert.AreEqual(2, second);
      Assert.IsTrue(map.Inverse.TryGetValue(2, out string first));
      Assert.AreEqual("a", first);

      Assert.IsFalse(map.TryGetValue("b", out _));
    }

    [TestMethod]
    public void TryGetValue()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>
      {
        { "a", 1 },
        { "b", 2 }
      };

      Assert.IsTrue(map.TryGetValue("a", out int second));
      Assert.AreEqual(1, second);

      Assert.IsTrue(map.Inverse.TryGetValue(2, out string first));
      Assert.AreEqual("b", first);

      Assert.IsFalse(map.TryGetValue("c", out _));
      Assert.IsFalse(map.Inverse.TryGetValue(3, out _));
    }

    [TestMethod]
    public void Indexer()
    {
      BidirectionalMap<string, string> map = new BidirectionalMap<string, string>
      {
        { "a", "1" },
        { "b", "2" }
      };

      Assert.AreEqual("1", map["a"]);
      Assert.AreEqual("b", map.Inverse["2"]);
    }

    [TestMethod]
    public void Inverse()
    {
      BidirectionalMap<string, int> map = new BidirectionalMap<string, int>();
      Assert.AreEqual(map, map.Inverse.Inverse);

      map = new BidirectionalMap<string, int>(10);
      Assert.AreEqual(map, map.Inverse.Inverse);

      map = new BidirectionalMap<string, int>(EqualityComparer<string>.Default, EqualityComparer<int>.Default);
      Assert.AreEqual(map, map.Inverse.Inverse);

      map = new BidirectionalMap<string, int>(EqualityComparer<string>.Default, EqualityComparer<int>.Default);
      Assert.AreEqual(map, map.Inverse.Inverse);

      Dictionary<string, int> source = new Dictionary<string, int>
      {
        { "a", 1 },
        { "b", 2 }
      };
      map = new BidirectionalMap<string, int>(source);
      Assert.AreEqual(map, map.Inverse.Inverse);

      Dictionary<int, string> inverseSource = new Dictionary<int, string>
      {
        { 1, "a" },
        { 2, "b" }
      };
      map = new BidirectionalMap<string, int>(inverseSource);
      Assert.AreEqual(map, map.Inverse.Inverse);



    }
  }
\$\endgroup\$
11
  • 2
    \$\begingroup\$ This would be cool if you named the dictionaries map and pam for the inverted one :-P The code would nicely align then in a couple of places. \$\endgroup\$ – t3chb0t Sep 5 '19 at 18:17
  • 1
    \$\begingroup\$ I also think that now with single APIs for each operation it'd should be save to actually implement the IDictionary interface. \$\endgroup\$ – t3chb0t Sep 5 '19 at 18:25
  • 2
    \$\begingroup\$ I'm shocked that they actually allow the comparer to be null. I first thought it was a bug in your code but then checked the docs and indeed, it is ok to pass a null :-\ everytime I think I know the framework they have to surprise me with new (un)consistency conventions. \$\endgroup\$ – t3chb0t Sep 5 '19 at 18:50
  • 1
    \$\begingroup\$ @t3chb0t: Initially I actually threw ArgumentNullException in a null check, but also realized that it was unnecessary. \$\endgroup\$ – user73941 Sep 5 '19 at 19:00
  • 2
    \$\begingroup\$ I think we can, I would leave only one of the constructors TPrimary, TSecondary in order to be consistent with the other APIs and add a factory method FromInverse instead, to still support what the now removed cunstructor allowed. \$\endgroup\$ – t3chb0t Sep 5 '19 at 19:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy