pipetools icon indicating copy to clipboard operation
pipetools copied to clipboard

foreach_i

Open tfga opened this issue 3 years ago • 6 comments

I'd like to propose a new util: foreach_i.

Motivation

You know how JS's Array.map also passes the element index as a 2nd parameter to the function?

> ['a', 'b', 'c'].map((x, i) => `Element ${i} is ${x}`)
[ 'Element 0 is a', 'Element 1 is b', 'Element 2 is c' ]

That's exactly what I'm trying to do here. The only difference is that the index would be passed as the 1st param:

def test_foreach_i():
    
    r = ['a', 'b', 'c'] > (pipe
                          | foreach_i(lambda i, x: f'Element {i} is {x}')
                          | list
                          )
    
    assert r == [ 'Element 0 is a'
                , 'Element 1 is b'
                , 'Element 2 is c'    
                ]

(Naïve) Implementation

from pipetools.utils import foreach, as_args
from typing import Callable, TypeVar


A = TypeVar('A')
B = TypeVar('B')

def foreach_i(f: Callable[[int, A], B]):
    
    return enumerate | foreach(as_args(f))

The same could be done for foreach_do.

tfga avatar Jun 19 '22 06:06 tfga

Hey!

there are a few issues with this I'd like to think about first.

  1. Giving the index as a separate argument forces you to use a lambda expression. Whereas using a single argument tuple let's you use auto string formatting or destructuring:
In [2]: [1,2,3] > enumerate | foreach("Element {0} is {1}") | list
Out[2]: ['Element 0 is 1', 'Element 1 is 2', 'Element 2 is 3']

In [3]: [1,2,3] > enumerate | foreach({X[0]: X[1] * 2}) | list
Out[3]: [{0: 2}, {1: 4}, {2: 6}]
  1. The ordering of index and element is not clear, because JavaScript and C# have it reversed - but in Python we should stick to what enumerate does. But just looking at foreach_i it might not be clear what the ordering is.

  2. I'm not loving the name, but can't really think of anything obvious to call it.

All in all not sure if it's worth it adding this one, since it will only save you adding enumerate in your pipe - which in turn makes it obvious to the reader what's going on and what shape the argument is going to be in.

0101 avatar Jun 21 '22 16:06 0101

Hi, @0101! :wave: Thank you for your reply.

it will only save you adding enumerate in your pipe

Not really: enumerate and as_args.

At lease in my use case.

This:

    | enumerate
    | foreach(as_args(func))

is equivalent to:

    | foreach_i(func)

I'm not loving the name

foreach_index, maybe? 🤔

tfga avatar Jun 24 '22 21:06 tfga

Not really: enumerate and as_args.

At lease in my use case.

Which brings us back to point 1. 🙂 It would mean adding a utility function that does not behave like the other ones.

0101 avatar Jun 27 '22 08:06 0101

@tfga do you maybe have some more specific examples of how you'd use it?

0101 avatar Jun 28 '22 08:06 0101

Not really.

Every once in a while I find myself in a situation where I want to do a "foreach with index". The particular piece of code that prompted me to write this issue was:

# (i, td) => (td, colWidth[i])
def pairWithWidth(i, td): return td, colWidth[i]

tds = tr >  (pipe
            | enumerate
            | foreach(as_args(pairWithWidth))
            | foreach(as_args(addPadding))
            | ' '.join
            )

With foreach_i, it would become:

# (i, td) => (td, colWidth[i])
def pairWithWidth(i, td): return td, colWidth[i]

tds = tr >  (pipe
            | foreach_i(pairWithWidth)
            | foreach(as_args(addPadding))
            | ' '.join
            )

tfga avatar Jun 29 '22 23:06 tfga

I see. Technically, you can instead do:

tds = tr > enumerate | foreach((X[1], X[0] | colWidth.__getitem__))

But it's quite unreadable.


I think what we could do is detect if a two-parameter function is being passed in and then pass two separate parameters - and otherwise pass in the tuple so things like foreach_i("Element {0} is {1}") still work as expected.

0101 avatar Jul 01 '22 15:07 0101