0

I need some help with design logic, illustrated by an example. My question below.

public class ChangeSet { }
public class NodeChangeSet : ChangeSet { }

public abstract class Base
{
   protected abstract Action<ChangeSet> Do { get; }
}

public class Node:Base
{
   private void doIt(NodeChangeSet change) { }

   protected override Action<NodeChangeSet> Do => (s)=> doIt(s); //Error

}

My intent is that Node should only ever care about handling a NodeChangeSet and should never need to understand or be aware that an abstracted ChangeSet exists. Node should be the only class that cares about the Type of NodeChangeSet, i.e. it should be properly abstracted in the base class and elsewhere; nobody should know or care about the NodeChangeSet type.

P.S. I am aware this might be possible in C# 9.0, so my question is: what workaround would you have implemented prior to C# 9.0?

10
  • 1
    The Node is necessarily aware of the ChangeSet because it inherits an Action<ChangeSet> property from its base. There would be nothing inherently wrong with leaving that type as it is, and maybe adding a new property if access to the more specific NodeChangeSet type is required. Alternatively, you might have to do something like class Base<CS> where CS: ChangeSet { ... } but that is likely to make other stuff more difficult. Hmm, but since your property is an Action I think only this second approach would be correct – your code as written is unsound and can never type-check!
    – amon
    Jan 21 at 19:30
  • 4
    To expand on this, Action<NodeChangeSet> is not a subtype of Action<ChangeSet> since I wouldn't be able to invoke the action with SomeOtherChangeSet as argument. You should think carefully about whether you want to use interfaces for OOP inheritance (cannot work here as you expect), or interfaces as generic type constraints.
    – amon
    Jan 21 at 19:33
  • Agree about type checking. But consider that if Node is a standalone class with no inheritance, all the parameters of the methods can be explicitly typed. The resulting class is pure in the sense that it can 100% accomplish it's design goal. I'm trying to understand why the language cannot "aid" the class in maintaining the design goal and must introduce some "confusion" like type checking.
    – NWoodsman
    Jan 21 at 19:50
  • I would say covariance would be the most basic fundamental requirement of any abstraction of types, be it through abstract base classes or interfaces. "I trust my inheritors to handle their own types, but at any time I can access those properties abstractly." The language seems unable to fulfill that basic logic.
    – NWoodsman
    Jan 21 at 19:57
  • 4
    @NWoodsman I agree that things would be fine if Node were a standalone class. But it isn't. That override is the critical part of this question. I think the Action indirection is distracting from the main problem. Your example boils down to this simpler case in which the LSP violation is more apparent: class Base { public void Do(ChangeSet s) {} } class Node: Base { public override void Do(NodeChangeSet s) {} }. The compiler is correct to reject this, regardless of return type covariance.
    – amon
    Jan 21 at 21:34

1 Answer 1

2

C# (and any language with similar type requirements) does not allow to make input of an overridden implementation narrower (to exclude some types that base class supports as valid parameters) and output wider (to return values that no longer compatible with return of the base class method) - this will break inheritance expectations - LSP.

Your code wants to limit types your derived class take as argument (as "in" parameter of action delegate) to just subset of what base class supports - caller of that method can't guarantee that only subset of types will be used there. In particular someClassDerivedFromBase.Do(new ChangeSet()); should work for all derived types - the way you setting up the Node type would have no way to handle parameter of ChangeSet or any other type that is not derived from NodeChangeSet and the missing part of the code would need to be somehow created. While some imaginary compiler could come up with solution to throw something like "NotSupportedException" in this particular case inventing desired behavior is not something compilers usually do.

So your options:

  • respect the base class method signature - override the method as-is (Action<ChangeSet>) and do necessary type checking at run-time
  • change base class to actually take real type of the change and use that in the abstract method (note that this will make Base<SomeChangeSet> and Base<OtherChangeSet> to no longer be related via inheritance and hence can't be used interchangeably): Base<TChangeSet> where TChangeSet:ChangeSet

The code that could work with variance is either narrowing return type ("out") in the derived type:

protected abstract Func<ChangeSet, BaseResult> Do { get; }

// in derived you can return narrower results as it will be always BaseResult
protected override Func<ChangeSet, DerivedResult> Do { get;}

or widening argument types ("in"):

protected abstract Action<DerivedChangeSet> Do { get; }

// in derived accept wider set of types for the action:
protected override Action<BaseChangeSet> Do { get; }

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.