[Python-ideas] Method chaining notation

Andrew Barnert abarnert at yahoo.com
Fri Feb 21 23:43:48 CET 2014


From: Chris Angelico <rosuav at gmail.com>

Sent: Friday, February 21, 2014 9:30 AM


> Yeah, I'm insane, opening another theory while I'm busily championing
> a PEP. But it was while writing up the other PEP that I came up with a
> possible syntax for this.
> 
> In Python, as in most languages, method chaining requires the method
> to return its own object.
> 
> class Count:
>     def __init__(self): self.n = 0
>     def inc(self):
>         self.n += 1
>         return self
> 
> dracula = Count()
> dracula.inc().inc().inc()
> print(dracula.n)
> 
> It's common in languages like C++ to return *this by reference if
> there's nothing else useful to return. It's convenient, it doesn't
> cost anything much, and it allows method chaining. The Python
> convention, on the other hand, is to return self only if there's a
> very good reason to, and to return None any time there's mutation that
> could plausibly return a new object of the same type (compare
> list.sort() vs sorted()). Method chaining is therefore far less common
> than it could be, with the result that, often, intermediate objects
> need to be separately named and assigned to.

I think that's intentional, as a way of discouraging (mutable) method chaining and similar idioms—and that Python code ultimately benefits from it.

In Python, each statement generally mutates one thing one time. That makes it simpler to skim Python code and get an idea of what it does than code in languages like C++ or JavaScript.

On top of that, it's the lack of method chaining that means lines of Python code tend to be just about the right length, and don't need to be continued very often. If you break that, you lose most of the readability benefits of Python's whitespace-driven syntax. In JavaScript or Ruby, a function call is often a dozen lines long. Readable programs use indentation conventions for expressions, just as they do for block statements, but those expression indentation conventions do not map to indent tokens in Python (and indentation rules in Python-friendly editors) the same way the block statement conventions do.

> I pulled up one file from
> Lib/tkinter (happened to pick filedialog) and saw what's fairly
> typical of Python GUI code:

Tkinter has its own weird idioms that aren't necessarily representative of Python in general. And PyQt/PySide and PyGTK/GObject have their own _different_ weird idioms. Partly this is because they're mappings to Python of idioms from Tcl, C++, and C (and/or Vala), respectively. But whatever the reason, I'm not sure it's reasonable to call any of them typical.

> So here's the proposal. Introduce a new operator to Python, just like
> the dot operator but behaving differently when it returns a bound
> method. We can possibly use ->, or maybe create a new operator that
> currently makes no sense, like .. or .> or something. Its semantics
> would be:
> 
> 1) Look up the attribute following it on the object, exactly as per
> the current . operator
> 2) If the result is not a function, return it, exactly as per current.

Why? Why not just use x.y for those cases, and make it a TypeError if you use x->y for a data attribute? It seems pretty misleading to "chain" through something that isn't a function call—especially since it doesn't actually chain in that case.


> 3) If it is a function, though, return a wrapper which, when called,
> calls the inner function and then returns self.

For normal methods, the result will _not_ be a function, it will be a bound method. It will only be a function for classmethods, staticmethods, functions you've explicitly added to self after construction, and functions returned by __getattr__ or custom __getattribute__.

And this isn't just nit-picking; it's something you can take advantage of: bound methods have a __self__, so your wrapper can just be:

    def wrap_method(method):
        @wraps(method)

        def wrapper(*args, **kwargs):
            method(*args, **kwargs)
            return method.__self__
        return wrapper

Or, alternatively, you've already got the self from the lookup, so you could just use that—in which case you can even make it work on static and class methods if you want, although you don't have to if you don't want.

And, depending on where you hook in to attribute lookup, you may be able to distinguish methods from data attributes before calling the descriptor's __get__, as explained toward the bottom, making this even simpler.

> This can be done with an external wrapper, so it might be possible to

> do this with MacroPy. It absolutely must be a compact notation,
> though.
> 
> This probably wouldn't interact at all with __getattr__ (because the
> attribute has to already exist for this to work),

Why? See below for details.

> and definitely not
> with __setattr__ or __delattr__ (mutations aren't affected). How it
> interacts with __getattribute__ I'm not sure; whether it adds the
> wrapper around any returned functions or applies only to something
> that's looked up "the normal way" can be decided by ease of
> implementation.
> 
> Supposing this were done, using the -> token that currently is used
> for annotations as part of 'def'. Here's how the PyGTK code would
> look:
> 
> import pygtk
> pygtk.require('2.0')
> import gtk
> 
> def callback(widget, data):
>     print "Hello again - %s was pressed" % data
> 
> def delete_event(widget, event, data=None):
>     gtk.main_quit()
>     return False
> 
> window = (gtk.Window(gtk.WINDOW_TOPLEVEL)
>     ->set_title("Hello Buttons!")
>     ->connect("delete_event", delete_event)
>     ->set_border_width(10)
>     ->add(gtk.HBox(False, 0)
>         ->pack_start(
>             gtk.Button("Button 1")->connect("clicked", 
> callback, "button 1"),
>             True, True, 0)
>         ->pack_start(
>             gtk.Button("Button 1")->connect("clicked", 
> callback, "button 1"),
>             True, True, 0)
>     )
>     ->show_all()
> )
> 
> gtk.main()

I personally think this looks terrible, and unpythonic, for exactly the reasons I suspected I would. I do not want to write—or, more importantly, read—15-line expressions in Python.

Maybe that's just me.

> Again, the structure of the code would match the structure of the
> window. Unlike the Pike version, this one can even connect signals as
> part of the method chaining.
> 
> Effectively, x->y would be equivalent to chain(x.y):
> 
> def chain(func):
>     def chainable(self, *args, **kwargs):
>         func(self, *args, **kwargs)
>         return self
>     return chain able

With this definition, it definitely works with __getattr__, __getattribute__, instance attributes, etc., not just normal methods.

The value of x.y is the result of x.__getattribute__('y'), which, unless you've overridden it, does something similar to this Python code (slightly simplified):

    try: return x.__dict__['y']
    except KeyError: pass
    for cls in type(x).__mro__:
        try: return cls.__dict__['y'].__get__(x)
        except KeyError: pass
    return x.__getattr__('y')

By the time you get back x.y, you have no way of knowing whether it came from a normal method, a method in the instance dict, a __getattr__ call, or some funky custom stuff from __getattribute__. And I don't see why you have any reason to care, either.

However, if you want the logic you suggested, as I mentioned earlier, you could implement x->y from scratch, which means you can hook just normal methods and nothing else, instead of switching on type or callability or something. For example:

    for cls in type(x).__mro__:
        try:
            descr = cls.__dict__['y']
        except KeyError:
            pass
        else:
            if hasattr(descr, '__set__'):
                return descr.__get__(x) # data descriptor
            else:
                return wrap_method(descr.__get__(x)) # non-data descriptor

> Could be useful in a variety of contexts.
> 
> Thoughts?
> 
> ChrisA
> _______________________________________________
> Python-ideas mailing list
> Python-ideas at python.org
> https://mail.python.org/mailman/listinfo/python-ideas
> Code of Conduct: http://python.org/psf/codeofconduct/
> 


More information about the Python-ideas mailing list