[Python-3000] Adaptation vs. Generic Functions
Phillip J. Eby
pje at telecommunity.com
Sat Apr 8 10:28:24 CEST 2006
At 10:57 AM 4/7/2006 -0700, Guido van Rossum wrote:
>Here I'm not so sure. Are you sure those results are unexpected? It
>seems you are saying that if I have a class C deriving from A and B
>(in that order) where both A and B implement a method foo(), and C
>doesn't, the call C().foo() would be ambiguous. But in fact the whole
>point of having a defined MRO is to foster the expectation that it
>will call A's foo(), because that one comes first in the MRO.
>
>So at least for the single-argument variant I would think that my
>algorithm gives results consistent with the definition of inheritance
>in new-style classes.
The ambiguity is only in context, or rather, the requirement for
context. If you use the argument's MRO, then you can't statically
determine the dominance hierarchy, because whether A or B dominates depends
on the argument you're actually testing at runtime! This makes it harder
for you to tell ahead of time whether you've defined the right methods.
In particular, note that if you have cases for A and for B, you can easily
be misled into thinking that one (let's say A) dominates the other because
you currently only have class C(A,B). And you can then conclude that your
list of cases is comprehensive and unambiguous -- until somebody creates
class D(B,A) and suddenly your rules are ambiguous again.
By the way, my rewrite of dominates() actually had a bug in it also, as did
your original. Yours used an 'is' to compare the tuple signatures where it
should've used ==, and mine checked individual types with 'is' instead of
comparing the entire tuple with ==. Instead of:
all(d is not s and issubclass(d,s) for d,s in zip(dom, sub))
I should've written:
dom != sub and all(issubclass(d,s) for d,s in zip(dom, sub))
> > Unfortunately, this entire approach is subject to similar scale
> > issues as PyProtocols/RuleDispatch.
>
>Even with the cache I put in? The hairy algorithm doesn't get invoked
>more than once per actual signature (type tuple).
You've measured it now, but yes, my own measurements of similar techniques
in RuleDispatch showed the same effect, and as with your measurement, it is
the Python code to build the tuple, rather than the tuple allocation
itself, that produces the delays. The current version of RuleDispatch goes
to some lengths to avoid creating any temporary data structures (if
possible) for this reason.
>I like the idea of analyzing the methods for dominance relationships
>ahead of time. But I'm not sure why we should then resort to
>generating code. Shouldn't it be just as easy to create a data
>structure that can be walked by fixed code? (Just like a
>recursive-descent parser can be written as a fixed "machine" that
>interprets a data structure representing the parse information.)
That's what RuleDispatch does currently, but it's again considerably slower
than an AST-manipulation approach would be, and is also much slower when it
has to handle expressions other than "plain old arguments".
Calling it "generating code", however, is a misleading way of thinking
about it, in that it implies something much more complex than is
necessary. The data structure created to do dispatching is not much
different from an AST, and in fact RuleDispatch uses its own simple AST for
processing expressions anyway. The parts of RuleDispatch that process
expressions are effectively recapitulating the Python interpreter, so I
would much rather in a future version just take an AST right off of a
function, like this:
@some_function.when(lambda: x<27 and isinstance(y,int))
def twenty_seven_and_int(x,y):
...
to replace my current use of string parsing for these conditions. At that
point RuleDispatch would just basically be performing relatively simple AST
pruning and grafting, to take the conditional expression(s) and function
bodies and graft them into a larger dispatch tree. Since we already have
the mechanisms to manage such ASTs and generate bytecode, and since
alternative implementations are going to have to provide the same AST APIs
at some point, ISTM that this is the natural way to go for the long term,
and provides the most opportunities for customized dispatching techniques,
such as RuleDispatch's custom method combinations. (Which allow you to
control such things as whether there are "next methods" or not, among other
things.)
Note that such AST prune-and-graft operations could also potentially
implement things like a 'next_method()' call, by substituting the
function's previous AST. This would also allow you to do a simple
monkeypatching style of overloading (i.e, just replacing the existing
function body and chaining) for the very simplest cases with only a handful
of methods.
And, setting a function's AST needn't immediately cause compilation; it
could be delayed until the function is actually called, thus nullifying any
cost besides the tree grafting itself, for functions that aren't used in a
particular program run. Granted, this definition-time cost might still be
higher than the defintion-time cost of a more limited dispatch facility,
such as the "tuple of types" approach you've been prototyping.
In truth, my thoughts on AST munging are still essentially speculation;
they reflect my design for the next version of RuleDispatch, rather than an
implemented and tested design at this point.
I should also clarify that I think of dispatch from a slightly different
perspective, since RuleDispatch allows tests on arbitrary conditions and
expressions. As a result, there are constraints on ordering of tests,
because of things like a condition for "x>0 and y/x<27", where the second
expression would produce an error if it was evaluated out of
order. RuleDispatch already has the necessary machinery to manage these
issues and build a single dispatch tree for interpretation, however, and
it's actually not that complex: RuleDispatch in total is only about 4000
lines of Python, not including tests, but including all those blank lines I
put between screenfuls of code. :)
It does not reuse any of the stdlib compiler library, either, as I found it
too slow and too big for the relatively simple task of processing logical
expressions -- the expression processor is ~700 lines, or ~1100 if you
include the concrete syntax->abstract syntax converter. Some of this
machinery wouldn't be needed if there were a nice fast AST API and I could
pull the ASTs directly from functions instead of parsing them.
That leaves about 2900 lines of "meat": basically the index classes for
handling class lookups and other comparison operators, and the code to
build and interpret dispatch trees. The interpretation part would of
course go away under an AST regime, while the rest would stay about the same.
Naturally, if your goals are more modest, dealing only with actual
arguments, no expressions, no operators other than 'isinstance()', no
ability to apply boolean logic to the conditions (e.g. "isinstance(arg1,A)
and isinstance(arg1,B)", or perhaps "not isinstance(arg1,C)!"), then the
logic for an interpreter is indeed much simpler. In that case, you can
just use a series of dictionaries that each argument is checked against,
one at a time, and you can build those dictionaries in a straightforward
way at registration time.
In that approach, the lookup loop could look something like:
reg = self.root_registry
for arg in args:
for cls in type(arg).__mro__: # not exactly, but sort of
if cls in reg:
reg = reg[cls]
break
else:
raise NoMatch # (or fall back to default)
# reg is now a function or a placeholder indicating ambiguity
You wouldn't actually use __mro__, in order to avoid the unstable dominance
hierarchy problem, but you get the gist. If you use RuleDispatch and
restrict what operations you use, you'll actually get almost exactly the
above; it's just that RuleDispatch may not evaluate the args in strictly
left-to-right order, depending on how "good" an argument is for
dispatching. (RuleDispatch evaluates the "goodness" of expressions (such
as arguments) by how well they fan out and flatten the dispatch tree, so it
may decide that the last argument is in fact a better thing to check first.
This approach of course has more hash table lookups than your approach, so
these are all tradeoffs to be considered, and more importantly,
measured. But in general I tend to see this space through the lens of
"arbitrary boolean expressions" and thus may occasionally intuit wrong
answers for the more limited scope you're contemplating here. Actually, I
keep feeling like you're "cheating" because you can get away with so little
code -- and I keep having to remind myself that it's because there are so
many things that the cached type tuple approach cannot. (The approach
which you are using, and which other folks wrote papers about, prior to the
Chambers & Chen paper on using dispatch trees to handle arbitrary predicates.)
Anyway, I just got home from a 6+ hour flight, and I'm probably not being
very coherent right now, so I'm going to go get some sleep. :)
More information about the Python-3000
mailing list