1

I've recently just noticed that IDictionary does not implement IReadOnlyDictionary. I'm using two third-party libraries, one of which provides a ToDictionary() method which returns an IDictionary containing the contents, and another which consumes an IReadOnlyDictionary. In order to make this work, I've had to write the following ugly code:

// IDictionary does not implement IReadOnlyDictionary, so we have to call .ToDictionary()
// again to get a concrete dictionary implementation.
var errors = result.ToDictionary().ToDictionary(x => x.Key, x => x.Value);

From looking at the two interfaces, it seems that the methods in IReadOnlyDictionary are a direct subset of IDictionary:

// Represents a generic read-only collection of key/value pairs.
public interface IReadOnlyDictionary<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IReadOnlyCollection<KeyValuePair<TKey, TValue>>
{
    TValue this[TKey key] { get; }
    IEnumerable<TKey> Keys { get; }
    IEnumerable<TValue> Values { get; }
    bool ContainsKey(TKey key);
    bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value);
}

// Represents a generic collection of key/value pairs.
public interface IDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable
{
    TValue this[TKey key] { get; set; }
    ICollection<TKey> Keys { get; }
    ICollection<TValue> Values { get; }
    void Add(TKey key, TValue value);
    bool ContainsKey(TKey key);
    bool Remove(TKey key);
    bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value);
}

Some notable differences:

  • The array index operator is get in the read-only version, and get/set in the writeable version. Would it be possible to specify this if inheriting from IReadOnlyDictionary?
  • The Keys and Values are IEnumerable in the read-only version, and ICollection in the writeable version. This also doesn't make sense to me. When would you add a key to a dictionary without a value, or add a value to a dictionary without a key? I feel like it would make more sense for the writeable dictionary to also return IEnumerable or IReadOnlyCollection for these properties.

The .Net team put a lot of thought into the core classes, so I assume the interfaces are separate by design. Does anyone know why the interfaces were created this way?

  • I’m voting to close this question because this is not the place to discuss design decisions made by the .NET team. – Rik D Jul 10 '23 at 21:50
  • 3
    If a IDictionary was also a IReadOnlyDictionary, then I could have a IDictionary, pass it to some class as a IReadOnlyDictionary and suppose that class relies on it's read-only-ness, now I take my IDictionary reference and write to it and upset my class. The 'ugly' code you had to write is a good thing, you were forced to make a new dictionary and so mutating the original one doesn't mutate your read only one. – JustSomeGuy Jul 10 '23 at 22:00
  • "Why does IDictionary not implement IReadOnlyDictionary" – Because it is writeable and not read-only? – Jörg W Mittag Jul 11 '23 at 01:19
  • I think it becomes more obvious if you were to call out the writability in the name with the same prominence as the read-only-ness. "Should `IWriteableDictionary` be a subtype of `IReadOnlyDictionary`? No, clearly not :) – Alexander Jul 11 '23 at 01:38
  • @JustSomeGuy _Dictionary_ is IReadOnlyDictionary, yet I can mutate it and do exactly what you are saying shouldn't be possible. The interface allows you to guarantee that _consumers_ will not modify the dictionary, but it doesn't guarantee the dictionary is immutable - that comes from the 'IsReadOnly' property – Andrew Williamson Jul 11 '23 at 02:29
  • @AndrewWilliamson Ohhhhh intersting. My answer is based on an incorrect premise then! "IReadOnlyDictionary" means "a dictionary that guarantees _at least_ the ability to be read" not "an immutable dictionary that _can only_ be read". – Alexander Jul 11 '23 at 13:26

2 Answers2

6

Do not confuse "read-only" with "immutable" in this case. With respect to most collections in .NET, the "read-only" moniker is fancy-talk for "cannot add or remove items". Nothing more.

Conceptually and academically, there is nothing wrong with IDictionary deriving from IReadOnlyDictionary. As a thought experiment, replace "Dictionary" with "BlogPostRepository":

public interface IReadonlyBlogRepository
{
    BlogPost Find(int id);
}

public interface IBlogRepository : IReadonlyBlogRepository
{
    void Add(BlogPost post);
    void Delete(BlogPost post);
}

One constrains consumers so they can only query for blog data. The other allows consumers to read and write data. This is a pretty typical breakdown in responsibilities across many domains.

The different interfaces allow your code to express that it needs to modify a collection versus when it does not need to modify the collection.

As for why IDictionary in .NET does not derive from IReadOnlyDictionary? This is a matter of timing. The IDictionary<TKey, TValue> interface was introduced in .NET 2.0. The IReadOnlyDictionary<TKey, TValue> interface was introduced in .NET 4.5.

Had the .NET team modified the original IDictionary interface, not all code that compiled using IDictionary in .NET 4.5 would have been usable in .NET 2. It may have been an issue of backwards compatibility between .NET versions, but this is pure conjecture on my part.

Greg Burghardt
  • 34,276
  • 8
  • 63
  • 114
  • I'm trying to think of an example of code that would break if a core interface were updated to implement a new interface in a later version. I can't think of any static examples, but perhaps code using reflection which accesses the interface implementations by a constant index, instead of searching with a predicate? That would be a rare case and badly implemented code, but I guess the .Net team have to be pretty confident the changes they make won't have any adverse effects at all. Is this still a problem in .Net Core, or is backwards compatibility less of an issue with side-by-side versions? – Andrew Williamson Jul 11 '23 at 20:59
  • @AndrewWilliamson: Microsoft has pretty stringent requirements around modifying the .NET framework. The "why did they do this" part of the question is unanswerable, unless you work for Microsoft designing the v2.0 through v4.5 versions of the framework. I honestly would not be surprise if this question were closed as being opinion-based, because only Microsoft can provide the answer. Instead, I tried tackling the answerable aspect of the question: could IDictionary derive from IReadOnlyDictionary and still be sound OO design? Yes. – Greg Burghardt Jul 11 '23 at 21:05
  • By chance, I happened to find an issue on GitHub referring to this exact problem: https://github.com/dotnet/runtime/issues/31001 – Andrew Williamson Aug 01 '23 at 10:57
  • Ok. That confirms my suspicions, @AndrewWilliamson. I wondered if it would be a breaking change somehow. See this comment for why: I https://github.com/dotnet/runtime/issues/31001#issuecomment-536230844 – Greg Burghardt Aug 01 '23 at 11:21
2

Preface: this answer is predicated on a misunderstanding that IReadOnlyDictionary means "A dictionary that is read-only". It actually means "a Dictionary that's at least readable (but perhaps also writable).

From an academic perspective, mutable collections can never (correctly) be subtypes of their immutable counterparts.

While they might seem to satisfy the Liskov substitution principle in that they support all the methods of the immutable containers, there's more to LSP than just a list of methods. To be correct, objects of the subtype must be fully substitutable where objects of the supertype are expected. This isn't possible here, because the callers might rely on the read-only aspect of immutable collections, which mutable collections obviously can't provide.

As a concrete example, imagine a piece of code which takes a read-only dictionary, and provides some aggregate statistics about the values. Authors of this code might choose to cache these stats, so that they're only calculated once, and the same results can be quickly regurgitated on demand. If a Dictionary is allowed to be passed in, this would no longer work, as the aggregate stats can change unbeknownst to this code (which would need to know to invalidate its cache), leading the caches to go stale, and give incorrect results.

You might also be interested why in software, a (mutable) square actually isn't a (mutable) rectangle. Why would Square inheriting from Rectangle be problematic if we override the SetWidth and SetHeight methods?

Alexander
  • 3,562
  • 1
  • 19
  • 24
  • Ah, that makes sense. I misinterpreted 'IReadOnlyDictionary' as a 'read-indexable' type, rather than as a contract specifying immutability. I've just had a look at 'ICollection' and 'IReadOnlyCollection' and see that they are disjoint as well, presumably for the same reason – Andrew Williamson Jul 11 '23 at 02:17
  • 1
    I've had a bit more thought about this - see my last comment on the original question. Everyone seems to assume that `IReadOnlyXX` is a contract that implies the object implementing it is immutable. That's not the case for `Dictionary` and `List`, they implement the 'IReadOnlyXX' interfaces but have a separate `IsReadOnly` property to indicate immutability. There's a lot of overlap between `immutable` and `read-only`, but they aren't the same thing so perhaps you can see where the confusion comes from – Andrew Williamson Jul 11 '23 at 02:37
  • 1
    A relevant consideration here is that this is only a problem because of pass-by-reference. If a dictionary was pass-by-value, then when I pass something into a consumer that expects a read-only dictionary, any future changes I make to my dictionary would not have an effect on the consumer. The consumer would have its own readonly dictionary that would not be subject to change from outside influences. – Flater Jul 11 '23 at 02:48
  • 1
    LSP does not apply here. This is not about inheritance, it is about partial implementation. The read-only part of an implementation is just that, there is no academic objection for any read/write collection to have interfaces defined for read and write parts separately. The interfaces do not impact the object in any way thus cannot violate anything in regard to the object. So although being valid statements I don't think this answer is helpful in regard to the question. – Martin Maat Jul 11 '23 at 07:47
  • @MartinMaat Hmmm I don't quite understand your perspective. LSP isn't narrowly about inheritance, it generalizes to "subtyping" of any form (including implementing interfaces). However, I did learn that my answer is based on an incorrect premise. "IReadOnlyDictionary" means "a dictionary that guarantees at least the ability to be read" not "an immutable dictionary that can only be read". Still, if the interface was `IImmutableDictionary`, all my remarks about LSP would be correct, I believe. – Alexander Jul 11 '23 at 13:28
  • @Alexander LSP states that you should be able to use a subclass instance without the user ever being the wiser. The subclass must be at least as rich as its parent. This does not go for interfaces, an interface typically only defines a subset of its implementer's behavior and properties. – Martin Maat Jul 11 '23 at 14:37
  • @MartinMaat "an interface typically only defines a subset of its implementer's behavior and properties" That's fine, but interfaces are not just dumb bags of method requirements. That's how they manifest in source code, but interfaces can also have meaning and invariants which aren't surfaced explicitly in source, but are nonetheless important. For example, `IComparable` requires you to return an integer, with specific meanings for negative, positive and zero. You can implement that interface and not respect those meanings, but it would be incorrect to do so. – Alexander Jul 11 '23 at 14:47
  • @MartinMaat Ignoring implementation details for a moment, I would summarize LSP as "can be correctly used polymorphically". For example, It's not sufficient that two objects implement `IComparable`, with a `CompareTo()` method that return an Int, if the semantics of their int results isn't consistent. Despite being able to make the call, I won't be able to use these objects polymorhpically. Likewise with a hypothetical `IImmutableDictionary`. I like jgauffin's wording: ["LSP applies to the contract. The contract may be a class or an interface."](https://stackoverflow.com/a/12252419/3141234). – Alexander Jul 11 '23 at 14:51
  • @Alexander I would go with Liskov's definition rather than anyone else's interpretation :-). Regarding "it's not sufficient to", yes it is, that's exactly what polymorphism means: different behavior behind the same signature. Semantics are not part of the deal, really. It would be stupid of course to have an implementer that makes CompareTo sort in an unexpected way but it is not against SOLID. It may violate some business logic rule but business logic is subjective anyway. Whether an object implements an interface or not is not subjective. Think of dummies that return zeros and empty strings. – Martin Maat Jul 11 '23 at 15:19
  • Let us [continue this discussion in chat](https://chat.stackexchange.com/rooms/147160/discussion-between-alexander-and-martin-maat). – Alexander Jul 11 '23 at 15:24
  • Sorry to remove the 'accept', but as you've noted the answer is based on an incorrect premise. Thank you for your contribution though – Andrew Williamson Jul 11 '23 at 20:53
  • @AndrewWilliamson No worries, that's the correct thing to do! – Alexander Jul 11 '23 at 21:30