Should derived classes hide the Default and Create static members inherited from Comparer<T>?
Asked Answered
K

2

9

I am writing an IComparer<T> implementation by deriving from the Comparer<T> class, as recommended by MSDN. For example:

public class MyComparer : Comparer<MyClass>
{
    private readonly Helper _helper;

    public MyComparer(Helper helper)
    {
         if (helper == null)
             throw new ArgumentNullException(nameof(helper));

        _helper = helper;
    }

    public override int Compare(MyClass x, MyClass y)
    {
        // perform comparison using _helper
    }
}

However, under this approach, the MyComparer class inherits the Default and Create static members from the Comparer<T> class. This is undesirable, since the implementation of the said members is unrelated to my derived class, and may lead to misleading behaviour:

// calls MyClass.CompareTo or throws InvalidOperationException
MyComparer.Default.Compare(new MyClass(), new MyClass());

My comparer cannot have a default instance due to the required Helper argument, nor can it initialize itself from a Comparison<T>, so I cannot hide the inherited static members with meaningful implementations.

What is the recommended practice for such situations? I'm considering three options:

  • Implement IComparer<T> manually, rather than deriving from Comparer<T>, so as to avoid inheriting the said static members

  • Leave the inherited static members in place, and assume consumers will know not to use them

  • Hide the inherited static members with new implementations that throw InvalidOperationException:

    public static new Comparer<MyClass> Default
    {
        get { throw new InvalidOperationException(); }
    }
    
    public static new Comparer<MyClass> Create(Comparison<MyClass> comparison)
    {
        throw new InvalidOperationException();
    }
    
Kaylyn answered 11/3, 2016 at 12:33 Comment(19)
So just to be sure: you could simply implement the IComparer<T> interface and you would only have to copy/paste the IComparer.Compare method instead of subclassing a class that clearly was built for something else?Jorrie
Default is always a default instance. You don't need to override it. When someone uses it, they really wants to use a default instance and not your own. Why bother shadowing the members? Also you can do nothing when user is using Comparer<MyClass>.Default as it will call the property in base class. Btw I see no reason to inherit from Comparer<T>.Lavallee
@xanatos: What was the class clearly built for? MSDN explicitly recommends Comparer<T>: "We recommend that you derive from the Comparer<T> class instead of implementing the IComparer<T> interface, because the Comparer<T> class provides an explicit interface implementation of the IComparer.Compare method and the Default property that gets the default comparer for the object." The former use-case fits my bill; the latter doesn't.Kaylyn
@Kaylyn It is a 50%/50% solution... half good and half bad. Considering that the 50% good is 10 lines of code, it is better to ignore the suggestion.Jorrie
@xanatos: Yes, I agree with that. I'm thinking of implementing my own ComparerBase<T> that provides IComparer.Compare but not Default. (I need to have several IComparer<T> implementations, and those 10 lines of code quickly add up.)Kaylyn
How is this any different to.... every other base class that has static members? Yes, they are technically accessible through the derived class and no, you wouldn't normally be expected to "hide" them or provide new alternatives.Takeover
@Douglas: that would make even less sense - it's a single method interface. Anyway, recommendation provided by MSDN makes no sense. No reasonable code will use MyComparer.Default anyway, and Comparer<MyClass>.Default returns a different instance.Upmost
@LousyCoder: ...which requires precise semantics on null-handling that are easy to get wrong without reference to the documentation.Kaylyn
@Kaylyn You mean without access to the relevant coreclr source? :-) Written by Microsoft and licensed under MIT license...Jorrie
@Douglas: how exactly did Microsoft ensure these "precise semantics" by allowing you to override that one method? It's not like you are overriding a protected method, and the public is sealed. If the latter was the case, you could argue it might be helpful, but this is borderline ridiculous.Upmost
@xanatos: I'm thinking more in terms of DRY. Sample implementations are provided even in the MSDN examples, but I'd rather not duplicate them within each IComparer<T> implementation.Kaylyn
@LousyCoder: I said "requires", not "ensures". Your implementation is expected to conform to the documented contract. The compiler won't stop you from not doing so.Kaylyn
@Douglas: exactly, hence the difference between overriding the public method and implementing it from the scratch boils down to the same boilerplate repeated code in both methods. Where is the DRY in that?Upmost
@Kaylyn I'm totally ok with your implementation of ComparerBase<T>. I thought your problem was that your "perfect" implementation of ComparerBase<T>.IComparer.Comparer wasnt "perfect" enough. To solve that problem, looking at coreclr is ok.Jorrie
@LousyCoder: The DRY is in inheriting the IComparer.Compare(object,object) implementation which calls the overridden Compare(T,T).Kaylyn
@Douglas: but, you are aware that you still need to have null-checks in the public Compare(T, T) method, also? Because the only time Compare(object, object) will be called if you use non-generic classes, or cast to plain IComparable? And, that in that case, implementing the non-generic method boils down to simply calling the generic one? So, the end result is that by implementing the abstract Comparer<T>, you actually end up with twice the amount of null-checking when the IComparer.Compare(object, object) method is called (which is btw, rarely, since .NET 2 was released).Upmost
@LousyCoder: It's the last part that I want to reuse. It's not a simple one-line call; you need to check for nulls, attempt the type cast, throw ArgumentException for incorrect types, and then call the generic Compare.Kaylyn
@Douglas: and then your generic compare needs to check for nulls again. So, from having to implement a single-method generic interface (IComparer<T>), you ended up overriding an abstract class, just because it also implements an additional single-method non-generic interface, which you didn't need in the first place.Upmost
@LousyCoder: I agree that the repeated null checks are unfortunate. You could refactor the implementation such that Compare(object,object) and Compare(T,T) call a common private method that skips the null checks, but that would incur an extra method call in the Compare(T,T) hot path. And I agree that the need of implementing IComparer is questionable.Kaylyn
W
7

Don't inherit from Comparer<T>. This is a classic misuse of inheritance for code sharing. Inheritance is supposed to be used to implement the Liskov Substitution Principle (LSP). Inheriting for code reuse is a hack because, as you found it, it exposes "junk" in your public API surface.

This is not an LSP violation because no base type contracts are being broken. Yet, it is a misuse of inheritance. The problem is that internals are being exposed in a way that API users might mistakenly rely on. It also hinders implementation changes in the future because removing the base class might break users.

Whether you can tolerate that dirtiness depends on the quality standards that you have for your public API surface. If you don't care about that then go ahead and adhere to DRY while not adhering to LSP. If a billion lines of code depend on your class you certainly don't want to expose a dirty base class. The issue here becomes a trade-off between encapsulation (consumers shouldn't need to know about the comparer's implementation) and saving work when creating the class.

You bring up the DRY principle. I'm not sure this is an instance of a violation of DRY. Dry tries to prevent duplicated code becoming inconsistent and it tries to prevent duplicates maintenance effort. Since the duplicate code here can never change (null ordering is contractual) I don't see this is a meaningful DRY violation. Rather, it's simply about saving work when creating the implementation.

Implementing IComparer<T> is easy enough, so do that. I don't see the need to implement IComparer anymore. The default implementation does not much. If you care about null inputs you will have to replicate that logic anyway in your own compare method. The code reuse you achieve is next to nothing.

I'm thinking of implementing my own ComparerBase

That would be a case of the same problem. Maybe you can instead make a static helper method that implements the boilerplate null and type handling. That static helper would not be exposed to API users. This is "composition over inheritance".

Hiding the static members is really confusing. Depending on subtle changes at the call site different methods will be called. Also, none of them are useful.

I'm not too concerned with the fact that static methods are now available through a different type name. These methods are not really inherited. They are just available as a C# feature. I believe this is for versioning resiliency. It is never recommended and various tools generate warnings around this. I would not be too concerned about that. For example, every Stream "inherits" certain static members such as Stream.Null and Stream.Synchronized or whatever they are called. Nobody considers this a problem.

Weihs answered 11/3, 2016 at 13:12 Comment(11)
Thanks for mentioning Liskov Substitution; it's a valid point. In this case, it does however conflict with DRY; there's no way of reusing the standard IComparer.Compare implementation other than subclassing. I never call IComparer.Compare either from my code, but it's good practice to have it implemented as well so as to provide consistent behaviour should anyone call the non-generic version.Kaylyn
I have just added a suggestion of how to do that. If you submitted your Comparer<T> deriving code to the BCL today they would reject your pull request based on the grounds of what I explained. Whether you can tolerate that dirtiness depends on the quality standards that you have for your public API surface. If you don't care about that then go ahead and adhere to DRY while not adhering to LSP. If a billion lines of code depend on your class you certainly don't want to expose a dirty base class.Weihs
I think the final paragraph is the most important here. There's nothing special here about the static members in question.Takeover
@Takeover right, I think that's mostly a red herring. I consider the first one to be most important :) Because it has architectural impact.Weihs
@usr: Yes, I understand your point about LSP, and I agree with you.Kaylyn
However, I have a suspicion that this LSP violation is benign. LSP's requirement is that instances of a derived type may substitute instances of its base type without altering any of the desirable properties of the base type. In this case (ComparerBase<T>), the base type is only intended to facilitate reuse, so it has no desirable properties other than to provide some (undefined) sort order – a condition that will be satisfied by all its derived types. Since the entire public API of the base type will be relevant to all of its derived type, there is no junk being exposed.Kaylyn
You also mention the "composition over inheritance" principle, which is indeed violated by ComparerBase<T>. But I feel that, on its own, composition over inheritance is a weaker principle than LSP – the issue here becomes a trade-off between encapsulation (consumers shouldn't need to know about the concrete comparer's relationship to ComparerBase<T>) and DRY.Kaylyn
Now that you say it I don't see an LSP violation either. Rather, the problem is that internals are being exposed in a way that API users might mistakenly rely on. It also hinders implementation changes in the future because removing the base class might break users. Your last sentence is exactly right. Will rework the answer now.Weihs
OK, I have never stated that the LSP principle is violated in the first place but I have pulled up a lot of discussion from the comments and cleaned up the answer.Weihs
I agree more with your updated answer. As you acknowledge, it's a trade-off, and the existence of an abstract base class is, for me, a small price to pay if it helps promote DRY. I follow a stricter interpretation of DRY: There should only be one authoritative implementation of any given functionality. Duplicated code, even if never meant to change, is still a violation, as it still requires maintenance. (Company might impose new standards on XML documentation, unit tests, performance tests, or Code Analysis conformance that would require all the duplicated implementations to be revisited.)Kaylyn
I ended up favouring DRY over cleanness of API – I stuck to using a base class for maximizing reuse. However, this answer was the first one posted, and it does address my concern about inherited static members in its final paragraph, so I'm accepting it on merit of that.Kaylyn
P
1

In my opinion, do nothing about it, i.e. your own option:

Leave the inherited static members in place, and assume consumers will know not to use them

Your class also inherits from System.Object, hence possesses stuff like

// static method overload inherited from System.Object:
MyComparer.Equals(new MyClass(), new MyClass());
// also inherited from System.Object:
MyComparer.ReferenceEquals(new MyClass(), new MyClass());

An you can never avoid that, since object is a base class of any type you write.

You must assume that developers that consume your code, understand how static members (properties, methods, etc.) work in C#, also in the context of inheritance.

Good developer tools (IDEs) should complain about int.ReferenceEquals, MyComparer.ReferenceEquals, MyComparer.Default and so on, because these are misleading ways to write the calls.

Hiding members with new is almost always a bad idea. It confuses developers much more, in my experience. Avoid the new modifier (on type members) when possible.

Unlike usr (see other answer) I think Comparer<> is a great base class to use.

Pede answered 11/3, 2016 at 15:10 Comment(2)
You make a very good point about Equals and ReferenceEquals. In fairness, usr's last paragraph agrees with your answer on static members. Their disapproval of Comparer<T> stems from the leaky abstraction of the existence of a base class.Kaylyn
We must assume that developers know that there is no "dynamic dispatch" for inherited static members in C#, so MyDerivedClass.Run is just a misleading way of writing MyBaseClass.Run when Run is a static member on MyBaseClass. So I think I address what was originally asked. You should not worry about inherited static members here. I still think Comparer<> is a perfect base class to use, and if it had not existed, I would probably have written it myself (like you say, create your own ComparerBase). But since it exists, it would be wasteful to write an identical class yourself.Pede

© 2022 - 2024 — McMap. All rights reserved.