[Python-3000] Sane transitive adaptation

Tim Hochberg tim.hochberg at ieee.org
Thu Apr 6 21:36:04 CEST 2006


Nick Coghlan wrote:
> One issue with generic functions and adaptation as currently being discussed 
> (and something Tim mentioned a while back), it that it is very focused on 
> dispatching based solely on obj.__class__.__mro__.
> 
> That's all well and good, but (to use an example of Tim's), suppose we have a 
> couple of frameworks we're using, where those frameworks have each defined 
> their own version of a "wibbly-wobbly" object:
> 
>    frmwrk_A.IWibble
>    frmwrk_B.IWobble
> 
> The methods and signatures included in these protocols may be the same, 
> IWibble may be a subset of IWobble, or vice versa.
> 
> Both frameworks have defined their interfaces (input and output) in terms of 
> these protocols, rather than in terms of concrete types.
> 
> As Tim pointed out, pure type-based dispatch would require that every type be 
> registered with both protocols, even if the two protocols are identical. Doing 
> that manually would be a serious pain if the frameworks were non-trivial.
> 
> The first thought may be to allow adaptation between protocols - but the issue 
> with that is that at adaptation time, we only have the object to work with, 
> and won't know what protocols it claims to implement (this is where either 
> __conform__ or a global registry comes in with PEP 246).
> 
> Even if we did know, the cost of doing a search on all equivalent protocols on 
> each call would add up. We don't really want to be finding ways to make 
> functions calls even slower than usual ;)
> 
> There is, however, an alternative, which would be to include a mechanism for 
> telling a protocol about other equivalent protocols, and updating the 
> registration mechanism to distribute any registration updates to the 
> equivalent protocols.
> 
[Implementation snipped]

In the "Adaptation vs. Generic Functions" thread I proposed that the 
registry be implemented using a ChainedDict that searched it's parent 
for any keys that it didn't have itself. This allows extending existing 
protocols using:

     extended_protocol = Protocol(base_protocol)

[Note that the name parameter has gone away. I'll include the Protocol 
and ChainedDict implementation at the end of this post.]

It's a relatively short step from here to allowing the extending of 
multiple protocols. And a relatively short step from there to an 
alternative implementation of register_subprotocol.


> 
> Using such a mechanism, the interface mismatch above could be addressed in one 
> of the following ways:
> 
> Suppose framework A specifies fewer methods than framework B, but those 
> methods match. Then you can write:
> 
>    frmwrk_B.IWobble.register_subprotocol(frmwrk_A.IWibble)
>    # Now registration for framework B also registers you for the narrower
>    # interface in framework A

Given the above, if framework A is created with knowledge of framework 
B, we can use:

     # in frmwrk_A
     IWibble = Protocol(frmwrk_B.I_Wobble)

If instead we want to make IWibble a subprotocol of IWobble after the 
fact, we can instead do:

     frmwrk_B.IWobble.registry.parents.append(frmwrk_A.IWibble.registry)

This may need some sugar to make it more palatable. For instance, one 
could have still have register_subprotocol as a method, or perhaps a 
helper function, that implemented the above. I'll try add_parent and see 
how that looks:

     class Protocol:
         #...
         def add_parent(self, protocol):
             self.registry.parents.append(protocol.registr)

Then we can dynamically make IWibble a subprotocol of IWobble using:

     frmwrk_B.IWobble.add_parent(frmwrk_A.IWibble)

> 
> You can turn that around, if A is the one that is more prescriptive:
> 
>    frmwrk_A.IWibble.register_subprotocol(frmwrk_B.IWobble)
>    # Now it is registration for framework A that registers you for the narrower
>    # interface in framework B
> 
> And finally, if the two interfaces are identical:
> 
>    frmwrk_A.IWibble.register_subprotocol(frmwrk_B.IWobble)
>    frmwrk_B.IWobble.register_subprotocol(frmwrk_A.IWibble)
>    # Now registration for either framework registers you for both

ChainedDict is careful not to loop, so this case can be spelled as:

     frmwrk_A.IWibble.registry.parents.append(frmwrk_A.IWobble.registry)
     frmwrk_B.IWobble.registry.parents.append(frmwrk_B.IWibble.registry)

or perhaps more readably as:

     frmwrk_B.IWobble.add_parent(frmwrk_A.IWibble)
     frmwrk_A.IWibble.add_parent(frmwrk_B.IWobble)

> Now, suppose, however, that mapping from A to B required a slight tweak to A's 
> interface - one of the method signatures didn't line up right (e.g. one of A's 
> methods has the wrong name). This can be handled with an explicit protocol 
> adapter, which would provide a modified update method like so:
> 
>    class ProtocolAdapter(object):
>        def __init__(self, src, target):
>            self.target = target
>            src.register_subprotocol(self)
> 
>        def _update(self, updates, updated=None):
>            if self.target in updated:
>                return
>            wrapped_updates = {}
>            for signature, adapter in updates.iteritems():
>                def wrapped_adapter(*args, **kwds):
>                     return self.adapt(adapter(*args, **kwds))
>                wrapped_updates[signature] = wrapped_adapter
>            self.target._update(wrapped_updates, updated)
> 
>        def __call__(self, adapter):
>            self.adapt = adapter
> 
>    class AdaptWibbletoWobble(object):
>        def __init__(self, obj):
>            self.obj = obj
>        def __iter__(x, y, z):
>            return self.obj.method2(x, y, z)
> 
>    ProtocolAdapter(frmwork_A.IWibble, frmwrk_B.IWobble)(AdaptWibbleToWobble)


Clever. The naming makes that last statement pretty opaque until you 
squint at the code for a while though. I don't think this would be a 
problem to layer on top of chained dict if needed. I think something 
like the following would work:

     WrappedDict(object):
         def __init__(self, obj, wrapper):
             self._obj = obj
             self._wrapper = wrapper
         def get(self, key, default=None):
             try:
                 return self[key]
             except KeyError:
                 return default
         def __getitem___(self, key):
             return self._wrapper(self._obj[key])
         def __contains__(self, key):
             return key in self._obj

     class Protocol:
         #....
         def add_parent(self, protocol, wrapper=None):
             registry = protocol.registry
             if wrapper is not None:
                 registry = WrappedDict(registry)
             self.registry.parents.append(protocol.registry)


I'm not sure this stuff needs to be methods -- it might be better 
relegated to helper functions to keep the core Protocol object simple.


> The equivalent of the above for generic functions is the case where "generic 
> function A" does a very similar thing to "generic function B", and you want to 
> be able to do a wholesale delegation from A to B of everything that B handles 
> more specifically than A.
> 
> Aside from the fact that I think any such transitivity mechanism should be 
> observer based, and that the updates should happen at registration time rather 
> than lookup time, I'm not really wedded to any of the implementation details 
> above. But I figured it was something worth throwing out there :)

I can see why you would want to this for performance reasons. However, 
the implementation crosses the threshold into too complex in my mind. In 
a sense, I've just pushed the complexity off into ChainedDict, but it's 
easier for me to digest in smaller chunks. I can also see some caching 
strategies that would make ChainedDict as fast as the observer strategy 
as long as we don't need to worry too much about people removing keys 
from the registries.

Regards,

-tim


======================================================


_missing = object()

class ChainedDict(dict):
     """A dict like object that forwards item requests to its parent if 
neeeded."""
     def __init__(self, *parents):
         dict.__init__(self)
         self.parents = list(parents)
     def __contains__(self, key):
         return self.get(key, _missing) is not _missing
     def __getitem__(self, key):
         x = self.get(key, _missing)
         if x is _missing:
             raise KeyError('not found')
         return x
     def _get(self, key, visited):
         # Get a value from self or parents. Return _missing on failure
         # visited contains the ids of objects searched so far.
         myid = id(self)
         if myid in visited:
             return _missing
         visited.add(myid)
         x = dict.get(self, key, _missing)
         if x is not _missing:
             return x
         for p in self.parents:
             if isinstance(p, ChainedDict):
                 x = p._get(key, visited)
             else:
                 x = p.get(key, _missing)
             if x is not _missing:
                 return x
         return _missing
     def get(self, key, default=None):
         x = self._get(key, set())
         if x is _missing:
             return default
         return x


def null_adapter(*args):
      """Adapter used when adaptation isn't actually needed"""
      if len(args) > 1:
          return args
      else:
          return args[0]

class Protocol(object):
     """Declare a protocol object that subclasses parents if provided"""
     def __init__(self, *parents):
         self.registry = ChainedDict(*(x.registry for x in parents))

     def register(self, *args):
         """Function decorator to register as an adapter for given keys"""
         if len(args) == 1:
             args = args[0]
         def helper(adapter):
             if adapter is None:
                 adapter = null_adapter
             self.registry[args] = adapter
             return adapter
         return helper

     def signatures(self, *args):
         """Find signatures for given call arguments"""
         # Default behaviour dispatches on the type of the first argument
         if len(args) != 1:
             raise TypeError("%s expected 1 argument, got %s" %
                             (self, len(args)))
         return type(args[0]).__mro__

     def default_adapter(self, *args):
         """Call result when no adapter was found"""
         raise TypeError("Can't adapt <%s> to %s" %
                         (', '.join(x.__class__.__name__ for x in args),
                          self.__class__.__name__))

     def __call__(self, *args):
        """Adapt supplied arguments to this protocol"""
        for key in self.signatures(*args):
            adapter = self.registry.get(key, _missing)
            if adapter is not _missing:
                return adapter(*args)
        return self.default_adapter(*args)



More information about the Python-3000 mailing list