A pipe operator for Lua

Modified:

Category: open-source

Tags: lua elixir

I have recently been getting into Elixir, and one nice feature it has is the pipe operator. It allows us to express a pipeline of function through which an object will be dragged. This got me thinking: with how flexible Lua is, would it be possible to add something similar to Lua as well?

Piping in Elixir

Let's first consider how one would write code without piping. The official language guide gives the following example:

Enum.sum(Enum.filter(Enum.map(1..100_000, &(&1 * 3)), odd?))

This code takes a list of numbers, multiplies each one by three, removes all that are not odd, and finally computes the sum. Here is the equivalent code in Lua, assuming that we have a range, map, filter, is_odd and sum function:

sum(filter(map(range(1, 100000), function(x) return x * 3 end), is_odd))

In order to understand this line you need to read the code inside-out. It's not even a simple matter of reading from right to left since the right-most symbol is the argument to filter.

Elixir has the pipe operator |> which allows us to write the expression as follows:

1..100_000 |> Enum.map(&(&1 * 3)) |> Enum.filter(odd?) |> Enum.sum

We can now clearly read the expression from left to right: the left-most symbol is the original object, and every subsequent symbol is the next step of the transformation pipeline. Here is what I want to be able to write in Lua:

pipe(1, 100000) {
    range
    function(xs) return map(xs, function(x) return x * 3 end) end,
    function(xs) return filter(xs, is_odd) end,
    sum
}

There are a couple of differences: since Lua can handle multiple values the first function (range) can take two arguments, and since Lua does not perform automatic currying we have to wrap map and filter each inside an anonymous function. Also note that Lua lets us omit the parentheses around the table literal.

Piping in Lua

There will be a function called pipe which takes in any number of arguments and returns a new function. This new function takes a pipeline (list of functions to apply in consecutive order) and returns the result of the pipeline.

function pipe(...)
    -- The arguments will get packed and unpacked repeatedly
    local args = table.pack(...)

    return function (pipeline)
        -- intermediate result, will be updated frequently
        local results = args

        for _, f in ipairs(pipeline) do
            -- unpacking and packing lets us deal with multiple values
            results = table.pack(f(table.unpack(results)))
        end

        -- a pipe can return multiple values
        return table.unpack(results)
    end
end

Let's try it out! The below pipeline will print a message as a side effect and evaluate to 6.

pipe(3) {
    -- Keep the number and generate a message
    function(x) return x, string.format('The number is %d') end
    -- Print the message, return the number (message gets dropped)
    function(x, msg) print(msg); return x end
    -- double the number
    function(x) return x * 2 end
}

As we can see, the number of values between steps in the pipeline can change. The first function receives one argument but returns two values, and the second function receives two arguments but returns one value. We can also store the pipeline in a variable and use it multiple times:

local pipeline = {foo, bar, baz}  -- list items are some functions

-- Run 1, 2 and 3 all through the same pipeline one after the other
pipe(1)(pipeline)
pipe(2)(pipeline)
pipe(3)(pipeline)

-- Pipe a value inside a pipeline
pipe('hello') {
    function(s) #s end,                -- get length of string
    function(n) pipe(n)(pipeline) end  -- pipe the length through the pipeline
}

Conclusion

This Lua implementation uses closures and works without additional syntax. Unlike the Elixir implementation the pipeline is just a regular value and can thus be stored in a variable and get passed around. The number of values is not fixed and can even change between steps.

However, it has one big disadvantage: the Elixir pipeline lets your write Enum.filter(odd?) and the compiler will treat it as a function which takes the current value as the argument for us (fn x -> Enum(x, odd?) end). In Lua this is not possible, we have to manually wrap the code inside an anonymous function manually. We can store the functions in a variable and reference the variable inside the pipeline, but that's just moving the problem one level up.

local function with_message(x) return x, string.format('The number is %d') end
local function push_message(x, msg) print(msg); return x end
local function double(x) return x * 2 end

pipe(3) {
    with_message,
    push_message,
    double
}

I will let the reader decide which is better. This simple piping implementation lacks a mechanism for aborting the pipeline prematurely, that is something that would need to be handled by the functions themselves. I should also point out that these examples are very contrived, it would have been easier to just write a loop instead. Piping pays off when we have large pipelines made up mostly of functions which get used often.

Update

In another article it has been pointed out that my implementation suffers from poor performance. That is true, at every step of the pipeline I pack and unpack the arguments, which creates a new table that will become garbage immediately afterwards. The author works around the issue by gluing strings together to effectively rewrite the pipeline into one nested function call. This does avoid the overhead of packing and unpacking at the cost of an uglier implementation. I definitely recommend reading the article.

I admit, I was not paying attention to performance. My focus was just on exploring the idea of how to retrofit a new feature from another language for the sake of novelty. In a real use-case I would have just written code as follows:

local result = range(1, 100000) {
result = return map(result, function(x) return x * 3 end)
result = filter(result, is_odd)
result = sum(result)

Yes, it is not in the functional style, but it gets the job done out of the box.

Update 2

I fixed the URL to the other article.