简体   繁体   中英

Fluent builder with inherited specialisation and specialised method returns (curiously recurring template)

I am attempting to write a data pipeline builder that other devs can use fluently. It might be easiest if I start with an example of the intended result:

var b = new Builder();
b.Batch().BatchedOperation().Unbatch().UnbatchedOperation().etc.

I have been able to create an abstract builder which can be specialised by derived builders using the curiously recurring template pattern.

The key point that I am struggling with is that some operations should only be possible after certain other operations. Specifically, there are two main types of operations in the pipeline: operations that work on sets of instances, and operations that work on single instances. As such, it is only valid to perform a BatchedOperation on a pipeline which has been Batched , and an UnbatchedOperation on a pipeline which has been Unbatched .

For the sake of the example code below I am treating the pipeline as being in either of two forms at any one time: the Foo form, or the Bar form. This basically equates to batched or unbatched, but it cuts the code down to the core issue, without getting hung up on what exactly batched or unbatched mean, and eliminates the clutter.

First, suppose I start with something like this, the basic CRTP:

public abstract class Builder<TBuilder> where TBuilder : Builder<TBuilder>
{
    protected TBuilder builder;
    internal Builder() => builder = (TBuilder)this;
    public TBuilder Bar() => builder;
    public TBuilder Foo() => builder;
}

I can the create some specialization of this builder, like so:

public class SpecialBuilder : Builder<SpecialBuilder>
{
    public SpecialBuilder() : base()    { }
    public SpecialBuilder Special() => builder;
}

However, the problem with this is that it allows me to do something like:

var b = new SpecialBuilder();
b.Foo().Foo().etc

That's no good, because once a pipeline has been Foo() d, it should not be possible to Foo() it again, because it's now in the Bar() able state. To be clear, this would throw a runtime error, but it's not caught at compile time (or, in particular, by intellisense).

I can restrict the results of the pipeline operations using interfaces:

public interface IBar<T> { IFoo<T> Bar(); }
public interface IFoo<T> { IBar<T> Foo(); }

public abstract class Builder<TBuilder> : IFoo<TBuilder>, IBar<TBuilder>
where TBuilder : Builder<TBuilder>
{
    protected TBuilder builder;
    internal Builder() => builder = (TBuilder)this;
    public IFoo<TBuilder> Bar() => builder;
    public IBar<TBuilder> Foo() => builder;
}

However, this puts be back at a non-inheritable builder, because now my derived builder doesn't function once it has been Foo() d. At that point it can no longer be Special() d, since it's now an IBar , not a SpecialBuilder .

It seems I need additional specialized interfaces:

public interface ISpecialFoo<T> : IFoo<T> { T Special(); }
public interface ISpecialBar<T> : IBar<T> { T Special(); }

But of course, the abstract Builder still can't specify that Bar() returns in IFoo<TBuilder> , because that's still not a SpecialBuilder , and thus can't be Special() d. So it seems like the interface return types themselves also need to follow the curiously recurring template pattern.

This is where my brain starts to hurt. I thought maybe this:

public abstract class Builder<TBuilder, TFoo, TBar> 
    : IFoo<TBuilder>, IBar<TBuilder> // errors for both of these
    where TBuilder : Builder<TBuilder, TFoo, TBar>, IFoo<TBuilder>, IBar<TBuilder>, TFoo, TBar
    where TFoo : IFoo<TBuilder>
    where TBar : IBar<TBuilder>
{
    protected TBuilder builder;
    internal Builder() => builder = (TBuilder)this;
    public TFoo Bar() => builder;
    public TBar Foo() => builder;
}

But that gives me two symmetrical errors for the attempts to inherit from the interfaces as indicated above:

'Builder<TBuilder, TFoo, TBar>' does not implement interface member IFoo.Foo()'. 'Builder<TBuilder, TFoo, TBar>.Foo()' cannot implement 'IFoo.Foo()' because it does not have thematching return type of 'IBar'

'Builder<TBuilder, TFoo, TBar>' does not implement interface member 'IBar.Bar()'. 'Builder<TBuilder, TFoo, TBar>.Bar()' cannot implement 'IBar.Bar()' because it does not have the matching return type of 'IFoo'.

Is this even possible to do? I would really like to give users of this code compile time assistance, so that intellisense, for example, only shows the valid operations for the state of the builder. But their gain is obviously my pain here.

Here's a complete console application which demonstrates the closest I've gotten:

public interface ISpecial { SpecialBuilder Special(); }
public interface ISpecialFoo : IFoo<SpecialBuilder>, ISpecial { }
public interface ISpecialBar : IBar<SpecialBuilder>, ISpecial { }
public interface IBar<T> { IFoo<T> Bar(); }
public interface IFoo<T> { IBar<T> Foo(); }

internal class Program
{
    private static void Main(string[] args)
    {
        var b = new SpecialBuilder();
        b.Special().Foo().Special().Bar();
    }
}

public abstract class Builder<TBuilder, TFoo, TBar>
    where TBuilder : Builder<TBuilder, TFoo, TBar>, TFoo, TBar
    where TFoo : IFoo<TBuilder>
    where TBar : IBar<TBuilder>
{
    protected TBuilder builder;
    internal Builder() => builder = (TBuilder)this;
    public TFoo Bar() => builder;
    public TBar Foo() => builder;
}

public class SpecialBuilder : Builder<SpecialBuilder, ISpecialFoo, ISpecialBar>, ISpecialFoo, ISpecialBar
{
    public SpecialBuilder() : base() { }
    public SpecialBuilder Special() => builder;
}

It's possible by simply hanging the type information everywhere.

// I moved the generic interfaces inside the CRTP base
// to save some typing for the CRTP consumers.
// You can also move IState1 and IState2 outside, and
// they will need generic parameters TIState1, TIState2.
public abstract class Builder<TBuilder, TIState1, TIState2>
  : Builder<TBuilder, TIState1, TIState2>.IState1,
    Builder<TBuilder, TIState1, TIState2>.IState2
  // This constraint surprisingly (or not) compiles.
  where TBuilder : Builder<TBuilder, TIState1, TIState2>, TIState1, TIState2
  where TIState1 : Builder<TBuilder, TIState1, TIState2>.IState1
  where TIState2 : Builder<TBuilder, TIState1, TIState2>.IState2
{
  public interface IState1 { TIState2 OpState1(); }
  public interface IState2 { TIState1 OpState2(); }

  private TBuilder that;
  protected Builder() { that = (TBuilder)this; }

  public TIState2 OpState1() { return that; }
  public TIState1 OpState2() { return that; }
}

public sealed class MyBuilder
  : Builder<MyBuilder, MyBuilder.IMyState1, MyBuilder.IMyState2>,
    MyBuilder.IMyState1, MyBuilder.IMyState2
{
  public interface IMySpecial { MyBuilder OpSpecial(); }
  public interface IMyState1 : IState1, IMySpecial { }
  public interface IMyState2 : IState2, IMySpecial { }

  public MyBuilder OpSpecial() { return this; }
}

Now if you try new MyBuilder().OpState1(). , since the return type is IMyState2 , you will only see OpSpecial , OpState2 , and object methods.

However, the approach of restricting method visibility using interfaces has awful implication on performance, since interface dispatching is very slow.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM