Pipe: Another approach for chaining methods in C#

A colleague recently linked me to this post and we got talking about how Ramda‘s pipe method could be implemented in C#. We already have similar functionality using the Map operator I wrote about here. However, this was a fun exercise and may be preferred by some people.

Piping

The pipe function in Ramda takes a number of functions and produces a function which, when called, executes the given functions from left to right passing the output of the previous function as the input of the next function.

Pipe is a little difficult to write in C# because of the stricter typing. We need a way of preserving types through the pipe without making it inconvenient to use or hard to read.

I decided to start with a 3 function pipe and worry about extending it out at a later date. My first attempt looked like this:

public static Func<TIn, TOut> Pipe<TIn, TInOut1, TInOut2, TOut>(
    Func<TIn, TInOut1> func1,
    Func<TInOut1 , TInOut2> func2,
    Func<TInOut2 , TOut> func3)
    => input => func3(func2(func1(input)));

This does work but has a bit of a drawback. When used in code you get this:

var pipeFunc = Pipe(
    (Func<string, int>)int.Parse,
    Negate,
    Increment);
pipeFunc("123"); // Returns -122

As you can see, the first function needs to be of type Func. This is because C# struggles to infer types from method groups. This isn’t the worst thing in the world but it could be far better.

I knew how I could resolve this but it does lose a little bit of functionality. If we make this an extension method then TIn can be inferred without the cast. However, this means that we don’t get a function out so can’t just call Pipe once and then re-use the function.

The extension method looks like this:

public static TOut Pipe<TIn, TInOut1, TInOut2, TOut>(
    this TIn input,
    Func<TIn, TInOut1> func1,
    Func<TInOut1, TInOut2> func2,
    Func<TInOut2, TOut> func3)
	=> func3(func2(func1(input)));

Which looks like this when called:

"123".Pipe(
    int.Parse,
    Negate,
    Increment); // Returns -122

I personally prefer the look of this due to the lack of a cast, but it does have the previously mentioned problem.

Multiple parameters

Both approaches above recreate some of the functionality of Ramda’s pipe, but still lack a major component. Ramda’s pipe is able to call the first function with any number of parameters. The example given in Ramda’s documentation is:

const f = R.pipe(Math.pow, R.negate, R.inc);

f(3, 4); // -(3^4) + 1

The C# code as it stands couldn’t cope with this. However, it’s perfectly possible to extend the functions.

The static function looks like this:

public static Func<TIn1, TIn2, TOut> Pipe<TIn1, TIn2, TInOut1, TInOut2, TOut>(
    Func<TIn1, TIn2, TInOut1> func1,
    Func<TInOut1, TInOut2> func2,
    Func<TInOut2, TOut> func3)
	=> (input1, input2) => func3(func2(func1(input1, input2)));

var pipeFunc = Pipe(
    (Func<double, double, double>)Math.Pow,
    Negate,
    Increment);
pipeFunc(3, 4); // Returns -80.0

And the extension method looks like this:

public static class PipeExtensionMethods
{
    public static TOut Pipe<TIn1, TIn2, TInOut1, TInOut2, TOut>(
        this (TIn1, TIn2) input,
        Func<TIn1, TIn2, TInOut1> func1,
        Func<TInOut1, TInOut2> func2,
        Func<TInOut2, TOut> func3)
	=> func3(func2(func1(input.Item1, input.Item2)));
}

(3.0, 4.0).Pipe(
    Math.Pow,
    Negate,
    Increment); // Returns -80.0

As you can see, the static method still has the issue with requiring a cast. However, it otherwise works much like Ramda’s pipe.

The extension method once again doesn’t require the cast but parameters now need passing in via a tuple.

Different numbers of functions and parameters

So far I’ve just been dealing with 3 functions with the first function taking 1 or 2 parameters. However, Ramda can deal with any number of functions with any number of parameters.

It’d be great if we could just use C#’s params keyword to take in any number of functions. However, this wouldn’t allow us to type the functions which would lead to all sorts of trouble when trying to use Pipe. So, we need multiple function overrides.

We can’t provide unlimited number of functions in a pipe, but let’s assume that no-one’s going to want more than 20 – above that (even getting up to that) the code would start to become difficult to read. And when it comes to parameters let’s assume that no-one’s going to want to use a function with more than 10 for the same reason. This means that we need to have 200 functions for each type of Pipe function. Now imagine we need to make a small change – that’s not going to be fun.

T4 text templates

The solution to this (at least in Visual Studio) is to use a text template to generate the code for us. Normally I shy away from these, but I think in this scenario they’re justified.

The template for the static functions looks like this:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
namespace awsxdr.Pipe
{
    using System;

    public static class PipeOperators
    {
    <# for(var i = 0; i < 20; ++i) { #>
        <# for(var j = 0; j < 10; ++j) { #>
        public static Func<<# WriteInTypes(j); #>TOut> Pipe<<# WriteInTypes(j); #><# for(var k = 0; k < i; ++k) { #>TInOut<#= k+1 #>, <# } #>TOut>(<# for(var k = -1; k < i; ++k) { #>Func<<# if(k == -1) { if(j == 0) { #>TIn, <# } else { for(var l = 0; l <= j; ++l) { #>TIn<#= l + 1 #>, <# } } } else { #>TInOut<#= k + 1 #>, <# } if(k == i - 1) { #>TOut<# } else { #>TInOut<#= k + 2 #><# } #>> func<#= k + 2 #><# if (k < i - 1) { #>, <# } #><# } #>)
            => (<# WriteInputVariables(j); #>) => <# for(var k = 0; k <= i; ++ k) { #>func<#= (i - k) + 1 #>(<# } WriteInputVariables(j); for(var k = 0; k <= i; ++k) { #>)<# } #>;
        <# } #>
    <# } #>
    }
}
<#+
    private void WriteInTypes(int j)
    {
        if(j == 0) 
        { 
            #>TIn, <#+
        } 
        else
        { 
            for(var k = 0; k <= j; ++k)
            {
                #>TIn<#= k + 1 #>, <#+
            }
        }
    }

    private void WriteInputVariables(int j)
    {
        if(j == 0) 
        {
            #>input<#+
        } 
        else
        { 
            for(var k = 0; k <= j; ++k)
            { 
                #>input<#= k + 1 #><#+
                if(k < j) { #>, <#+ }
            }
        }
    }
#>

And the template for the extension methods looks like this:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
namespace Pipe
{
    using System;

    public static class PipeExtensionMethods
    {
    <# for(var i = 0; i < 20; ++i) { #>
        <# for(var j = 0; j < 10; ++j) { #>
        public static TOut Pipe<<# WriteInTypes(j); for(var k = 0; k < i; ++k) { #>TInOut<#= k+1 #>, <# } #>TOut>(this <# WriteInTypesForTuple(j); #> input, <# for(var k = -1; k < i; ++k) { #>Func<<# if(k == -1) { WriteInTypes(j); } else { #>TInOut<#= k + 1 #>, <# } if(k == i - 1) { #>TOut<# } else { #>TInOut<#= k + 2 #><# } #>> func<#= k + 2 #><# if (k < i - 1) { #>, <# } #><# } #>)
            => <# for(var k = 0; k <= i; ++ k) { #>func<#= (i - k) + 1 #>(<# } WriteInputVariables(j); for(var k = 0; k <= i; ++k) { #>)<# } #>;
        <# } #>
    <# } #>
    }
}
<#+
    private void WriteInTypes(int j)
    {
        if(j == 0) 
        { 
            #>TIn, <#+
        } 
        else
        { 
            for(var k = 0; k <= j; ++k)
            {
                #>TIn<#= k + 1 #>, <#+
            }
        }
    }

    private void WriteInTypesForTuple(int j)
    {
        if(j == 0) 
        { 
            #>TIn<#+
        } 
        else
        { 
            #>(<#+
            for(var k = 0; k <= j; ++k)
            {
                #>TIn<#= k + 1 #><#+
                if(k < j) { #>, <#+ }
            }
            #>)<#+
        }
    }

    private void WriteInputVariables(int j)
    {
        if(j == 0) 
        {
            #>input<#+
        } 
        else
        { 
            for(var k = 0; k <= j; ++k)
            { 
                #>input.Item<#= k + 1 #><#+
                if(k < j) { #>, <#+ }
            }
        }
    }
#>

Conclusion

I personally prefer using Map and Tee, but this provides another alternative to piping functions. C#’s typing constraints means that we can’t be quite as expressive as in ES, but this approximates what Ramda’s pipe function does.

For future work it could be possible to look at allowing currying in these pipes. I believe that this would be possible but would massively increase the number of overloads.

Leave a Reply

Your email address will not be published. Required fields are marked *