Seeking feedback on Chainables before registering it

Hi all

I think Chain.jl is a phenomenal package.

I’ve submitted a package called Chainables.jl to the General registry, and a registry maintainer suggested I bring up the idea here to get feedback and thoughts.

The goal of the package is to make it easier to keep @chain pipelines going and improve readability in cases where functions expect function/lambda arguments first.

Chainables provides macro versions of common functions with reversed argument order, so the data can appear first in the pipeline. For example:

map(f, x)  →  @map(x, f)

This allows functions to be used more naturally inside @chain blocks, and there are utilities to make other functions more @chain-able as well.

Repo:

Example

Current way

@chain 1:10 begin
   zip(21:30)
   collect
   filter(t -> t[2] <= 25, _)
   map(t -> t[1], _)
end

Chainables way

@chain 1:10 begin
    zip(21:30)
    collect
    @filter @unpack (a, b) -> b <= 25
    @map @unpack (a, b) -> a
end

# can use Iterators.filter instead
@chain 1:10 begin
    zip(21:30)
    @filteriter @unpack (a, b) -> b <= 25
    @map @unpack (a, b) -> a
end

I’m mainly interested in hearing:

  • whether people are interested in this kind of utility
  • whether similar functionality already exists elsewhere in the ecosystem

One design choice worth mentioning is that the macros intentionally use the same function names, e.g. map becomes @map.

Keen to hear your thoughts!

1 Like

Hi @TyronCameron , I’m the maintainer of FunctionChains.jl. I wonder if there might be some synergy potential here, like (optionally) generating FunctionChain instances from chains with a compatible structure?

Hi @oschulz, thanks for reaching out. I haven’t dabbled much with FunctionChains.jl but I strongly think that function composition is the most important part of coding.

Synergy is possible. fchain is of course already compatible with @chain in some sense because one can write

@chain foo fchain(bar) # foo first, then bar

If one wanted a similar style of pleasant code, one could do

# In the package
#---

using FunctionChains: fchain 

macro fchain(expr...)
    function_list = Expr(:tuple, reduce(vcat, _flatten_functions.(expr))...)
    quote
        $fchain($(function_list)...)
    end |> esc 
end 

function _flatten_functions(expr)
    expr isa Expr && expr.head ∈ (:tuple, :block) && 
        return reduce(vcat, _flatten_functions.(expr.args))
    (expr isa Symbol || expr isa Expr) &&
        return [expr]
    return []
end

# Usage
#---

foo(x) = x^2 
bar(x) = 3x

foo_then_bar = @fchain begin
    foo 
    bar 
end

another_foo_then_bar = @fchain foo bar 

@assert fchain(foo, bar)(4) == foo_then_bar(4) == another_foo_then_bar(4) == 48 

(could neaten that up / make it more robust, but just the idea stands for now).

In general, I’m supportive of the concept of adding something like that.

I think in Chainables.jl as it stands, I’ve focused on manipulating the inputs to functions – putting them into different argument positions, allowing function arguments to be written in do-block syntax, and packing and unpacking args from tuples / zips.

The reverse idea has been mostly untouched, which is how to combine functions (fchain), pack and unpack functions (fcprod, I think?), and I like the with_intermediate_results touch!

I probably need to learn a bit about FunctionChains.jl itself (and consequently understand if there’s benefit in synergy).

What do you think?

Totally share the overall goal of making data manipulation more convenient in Julia :slight_smile: But creating a macro for each function one may want to use doesn’t really scale… Even for Iterators.filter you had to create another name that one needs to remember, in addition to the underlying function.

There’s already DataPipes.jl (disclaimer: I’m the author) that’s stable and has been around for ~5 yrs.
DataPipes is specifically designed to be a lightweight code transformation that makes all common data manipulation functions in Julia basically boilerplate-free. Not just Base.xxx, or Iterators.xxx, but all functions that follow the Julian argument order:

With DataPipes.jl:

@p let
   1:10
   zip(21:30)
   collect
   filter(_[1] <= 25)
   map(_[2])
end

Same with Iterators.filter, of course:

@p let
   1:10
   zip(21:30)
   Iterators.filter(_[1] <= 25)
   map(_[2])
end

The transformation is purely syntactic – basically, just two operations, pass the previous step result and transform _ into lambda. No special handling of any functions ever!