[Python-ideas] Partial operator (and 'third-party methods' and 'piping') [was Re: Function composition (was no subject)]

Steven D'Aprano steve at pearwood.info
Mon May 11 03:44:12 CEST 2015


On Sun, May 10, 2015 at 11:06:21PM +0300, Koos Zevenhoven wrote:

> So, -> would be an operator with a precedence similar to .attribute 
> access (but lower than .attribute):

Dot . is not an operator. If I remember correctly, the docs describe it 
as a delimiter.

>  # The simple definition of what it does:
>  arg->func   # equivalent to functools.partial(func, arg)

I believe you require that -> is applied before function application, so 

arg->func  # returns partial(func, arg)
arg->func(x)  # returns partial(func, arg)(x)
arg->(func(x))  # returns partial(func(x), arg)

> This would allow for instance:
>  arg -> spam() -> cheese(kind = 'gouda') -> eggs()

I am having a lot of difficulty seeing that as anything other than "call 
spam with no arguments, then apply arg to the result". But, teasing it 
apart with the precedence I established above:

arg->spam()  # returns partial(spam, arg)() == spam(arg)
""" -> cheese  # returns partial(cheese, spam(arg))

""" (kind='gouda')  # returns partial(cheese, spam(arg))(kind='gouda')
                    # == cheese(spam(arg), kind='gouda')

""" -> eggs  # returns partial(eggs, cheese(spam(arg), kind='gouda'))

""" ()  # calls the previous partial, with no arguments, giving:
        # partial(eggs, cheese(spam(arg), kind='gouda'))()
        # == eggs(cheese(spam(arg), kind='gouda'))


> which would be equivalent to eggs(cheese(spam(arg), kind = 'gouda'))

Amazingly, you are correct! :-)

I think this demonstrates an abuse of partial and the sort of thing that 
gives functional idioms a bad name. To tease this apart and understand 
what it does was very difficult to me. And I don't understand the point 
of creating partial applications that you are then immediately going to 
call, that just adds an extra layer of indirection to slow the code 
down. If you write partial(len, 'foo')() instead of just len('foo'), 
something has gone drastically wrong.

So instead of

arg->spam()->cheese(kind='gouda')->eggs()

which includes *three* partial objects which are immediately called, 
wouldn't it be easier to just call the functions in the first place?

eggs(cheese(spam(arg), kind='gouda'))

It will certainly be more efficient!


Let's run through a simple chain with no parens:

a -> b  # partial(b, a)
a -> b -> c  # partial(c, partial(b, a))
a -> b -> c -> d  # partial(d, partial(c, partial(b, a)))

I'm not seeing why I would want to write something like that.


Let's apply multiple arguments:

a -> func  # partial(func, a)
b -> (a -> func)  # partial(partial(func, a), b)
c -> (b -> (a -> func))  # partial(partial(partial(func, a), b), c)

Perhaps a sufficiently clever implementation of partial could optimize 
partial(partial(func, a), b) to just a single layer of indirection 
partial(func, a, b), so it's not *necessarily* as awful as it looks. (I 
would expect a function composition operator to do the same.)

Note that we have to write the second argument first, and bracket the 
second arrow clause. Writing it the "obvious" way is wrong:

a -> b -> func  # partial(func, partial(b, a))


I think this is imaginative but hard to read, hard to understand, 
hard to use correctly, inefficient, and even if used correctly, there 
are not very many times that you would need it.


> Or even together together with the proposed @ composition:
>  rms = root @ mean @ square->map     # for an iterable non-numpy argument

I think that a single arrow may be reasonable as syntactic sugar for 
partial, but once you start chaining them, it all falls apart into a 
mess. That, in my mind, is a sign that the idea doesn't scale. We can 
chain dots with no problem:

fe.fi.fo.fum

and function calls in numerous ways:

foo(bar(baz()))
foo(bar)(baz)

and although they can get hard to read just because of the sheer number 
of components, they are not conceptually difficult. But chaining arrows 
is conceptually difficult even with as few as two arrows.

I think the problem here is that partial application is an N-ary 
operation. This is not Haskell where single-argument currying is 
enforced everywhere! You're trying to perform something which 
conceptually takes N arguments partial(func, 1, 2, 3, ..., N) using only 
a operator which can only take two arguments a->b. Things are going to 
get messy.


> And here's something I find quite interesting. Together with 
> @singledispatch from 3.4 (or possibly an enhanced version using type 
> annotations in the future?), one could add 'third-party methods' to 
> classes in other libraries without monkey patching. A dummy example:
> 
> from numpy import array
> my_list = [1,2,3]
> my_array = array(my_list)
> my_mean = my_array.mean()  # This currently works in numpy
> 
> from rmslib import rms
> my_rms = my_array->rms()  # efficient rms for numpy arrays
> my_other_rms = my_list->rms()  # rms that works on any iterable

That looks cute, but isn't very interesting. Effectively, you've 
invented a new (and less efficient) syntax for calling a function:

spam->eggs(cheese)  # eggs(spam, cheese)

It's less efficient because it builds a partial object first, so instead 
of one call you end up with two, and a temporary object that gets thrown 
away immediately after it is used. Yes, you could keep the partial 
object around, but as your example shows, you don't. And because it is 
cute, people will write:

a->func(), b->func(), c->func()

and not realise that it creates three partial functions before calling 
them. Writing:

func(a), func(b), func(c)

will avoid that needless overhead.



-- 
Steve


More information about the Python-ideas mailing list