Search code examples
c#.netdesign-patternsdynamictype-safety

Is it possible to create a Builder like this in C#?


var pipeline = new PipelineBuilder<string>
    .AddInitial(n => n*12)
    .AddStep(n => n.ToString())
    .Build();

Executing `pipeline(2)' should return "24".

Also, the type parameter in the builder <string> indicates the type of the result (return type of final step).

Is it even possible? I've fought with the type system of C# and I'm done.

The closer I've got to it is this code:

public class PipelineBuilder<TInitial, TResult>
{
    private List<Func<dynamic, dynamic>> functions = new();

    public PipelineBuilder<TInitial, TOutput, TResult> AddInitial<TOutput>(Func<TInitial, TOutput> step)
    {
        functions.Add(o => step(o));
        return new PipelineBuilder<TInitial, TOutput, TResult>(functions);
    }

    public PipelineBuilder<TInput, TOutput, TResult> AddStep<TInput, TOutput>(Func<TOutput, TInput> step)
    {
        functions.Add(a => step(a));
        return new PipelineBuilder<TInput, TOutput, TResult>(functions);
    }
}

public class PipelineBuilder<TInput, TOutput, TResult>
{
    private List<Func<dynamic, dynamic>> functions;

    public PipelineBuilder(List<Func<dynamic, dynamic>> functions)
    {
        this.functions = functions;
    }

    public PipelineBuilder<TInput, TNewOutput, TResult> AddStep<TNewOutput>(Func<TOutput, TNewOutput> step)
    {
        functions.Add(a => step(a));
        return new PipelineBuilder<TInput, TNewOutput, TResult>(functions);
    }

    public Func<TInput, TResult> Build()
    {
        return input =>
        {
            dynamic result = input;
            foreach (var step in functions)
            {
                result = step(result);
            }
            return (TResult)(object)result;
        };
    }
}

The problem with this approach is that I've found no way to match the output type of the final step with the return type of the builder (TResult).

If the types don't match, all I get is a runtime exception. I can't even know if they match when the Build method is execute due to functions being Func<dynamic, dynamic>.


Solution

  • You can do something very close to what you want by chaining together lambdas as follows:

    public class PipelineBuilder<TFinal>
    {
        public PipelineBuilder<TInitial, TCurrent, TFinal> AddInitial<TInitial, TCurrent>(Func<TInitial, TCurrent> step)
            => new PipelineBuilder<TInitial, TCurrent, TFinal> { Step  = step };
    }
    
    public class PipelineBuilder<TInitial, TCurrent, TFinal>
    {
        // Represents an intermediate step in the pipeline.
        internal Func<TInitial, TCurrent> Step { get; init; }
    
        public PipelineBuilder<TInitial, TNext, TFinal> AddStep<TNext>(Func<TCurrent, TNext> step)
            => new PipelineBuilder<TInitial, TNext, TFinal> {  Step = (c) => step(Step(c)) };
    }
    
    public static class PipelineBuilderExtensions
    {
        public static Func<TInitial, TFinal> Build<TInitial, TFinal>(this PipelineBuilder<TInitial, TFinal, TFinal> current) 
            => current.Step;
    }
    

    And then use it as follows:

    var pipeline = new PipelineBuilder<string>()
        .AddInitial((int n) => n*12) // (int n) is necessary to declare the initial type
        .AddStep(n => n.ToString())
        .Build();
    
    string result = pipeline(2); // Here I declare result as string to demonstrate that the returned value is of the expected type.  Normally one would use var/
    
    Console.WriteLine(result); // Prints 24.
    

    Demo fiddle #1 here.

    Notes:

    • In your design, there is no way for the compiler to infer the type of the initial argument n:

      var pipeline = new PipelineBuilder<string>
          .AddInitial(n => n*12)  // Is n an int, a TimeSpan, a string, or what?
      

      Adding a type (int n) to the lambda argument resolves the ambiguity.

    • If the current step result type is not equal to the required final step result type, the extension method .Build() will not be found, and you will get a compiler error:

      'PipelineBuilder<int, int, string>' does not contain a definition for 'Build' and no accessible extension method 'Build' accepting a first argument of type 'PipelineBuilder<int, int, string>' could be found (are you missing a using directive or an assembly reference?)
      

      The error isn't particularly descriptive, but it's still a proper compiler error and not a runtime exception.

      Demo fiddle #2 here.

    • By chaining together lambdas as steps are added, you eliminate the need for the List<Func<dynamic, dynamic>> functions member (and any other use of dynamic).

    • All that being said, I don't recommend this design. It feels awkward and inconsistent with the LINQ programming style to define the final result type at the beginning when the final type will also be defined by the final AddStep() call. Eliminating TFinal from the initial declaration makes things much simpler:

      public class PipelineBuilder
      {
          public PipelineBuilder<TInitial, TCurrent> AddInitial<TInitial, TCurrent>(Func<TInitial, TCurrent> step)
              => new PipelineBuilder<TInitial, TCurrent> { Step  = step };
      }
      
      public class PipelineBuilder<TInitial, TCurrent>
      {
          // Represents an intermediate step in the pipeline.
          internal Func<TInitial, TCurrent> Step { get; init; }
      
          public PipelineBuilder<TInitial, TNext> AddStep<TNext>(Func<TCurrent, TNext> step)
              => new PipelineBuilder<TInitial, TNext> {  Step = (c) => step(Step(c)) };
      
          public Func<TInitial, TCurrent> Build() => Step;
      }
      

      Demo fiddle #3 here.