[PEAK] Jumping Aspects

Phillip J. Eby pje at telecommunity.com
Sat Jul 31 01:44:29 EDT 2004


No, it's not a new saying of Batman's sidekick (Holy jumping aspects, 
Batman!), it's a tricky design problem in creating interceptors for the new 
workspace-based system.

I'd like for multiple schema components to be able to contribute behavior 
to a workspace's customized classes.  Mostly, this will consist of putting 
various kinds of wrappings around methods, either on a model class, or on 
one of its features.  So, there needs to be a sensible way to order these 
wrappings with respect to one another.

However, some domain features or methods are defined in terms of *other* 
features or methods.  For a simple example, multi-valued features usually 
offer methods like 'add()', 'remove()', 'replace()' and so on.  These 
methods are implemented in terms of the "link" and "unlink" primitives 
which add or remove a single reference.

In principle, we could simply put wrappers around the "link" and "unlink" 
primitives, and use them to drive any storage methods or validation events 
or UI notifications.  But this leads to potential performance and other 
issues.  For example, given the way features currently work, simply 
assigning *the same list* to an attribute could result in perhaps hundreds 
of link/unlink events being generated.  If the events are being used to 
drive a UI, this could be disastrous from a user-experience point of 
view.  If the events are being used to update a database, it could be 
equally problematic, even if the updates are batched.

In general, then, we would like to be able to intercept a higher-level 
operation, without having to intercept every one of its lower-level 
components.  But, if a lower-level operation is invoked directly, we still 
need our lower-level interception to occur.  This is known in the AOP 
literature as a "jumping aspect": one where the desired point of 
interception can vary depending on the system's control flow.

One of the classic solutions to this issue can be found in GUI frameworks, 
where there is usually some mechanism to suppress screen updates 
temporarily, preventing the screen from "thrashing" as a bunch of low-level 
events occur.  In essence, one uses a flag.  This approach works nicely 
when there are a limited number of things needing flags.

For our purposes, however, the situation is a bit more complex.  Each 
schema component that wants to modify the behavior of some high-level 
operation needs its own per-object flag to indicate whether lower-level 
operations should also be intercepted, or else there needs to be some sort 
of stack-tracing mechanism to check where the operation was called 
from.  Both approaches seem complex, to say the least.

One approach from the AOP literature is to use a proxy to add 
wrappers.  The wrapping occurs in the proxy's methods, and those methods 
then call down to the "real" object.  When a method is defined in terms of 
another, calls made from inside the "real" object's code will call 
unwrapped "real" methods.  Thus, already-wrapped methods will not be called 
again from inside the object.

Unfortunately, this doesn't help with the situation where code in one of 
those methods may "call out" to other objects, passing the "real" 
object.   It also doesn't address the problem of multiple schema components 
contributing wrappers.  For example, a DB aspect that's implemented in 
low-level calls, *and* a UI aspect that's implemented in high-level 
calls.  Thus, the simple proxy approach does not support composable 
aspects, at least not without creating a chain of proxies.

Consider an interface with methods A and B, where A is implemented by 
calling B.  If there are two aspects that want to advise these methods, and 
aspect 1 has advice for both A and B, and aspect 2 only has advice for B, 
then this can be implemented with two proxies.  The first proxy wraps both 
A and B with aspect 1's advice, and method B with aspect 2's 
advice.  Method A of the first proxy calls method A on the second 
proxy.  Method B of the first proxy calls method B on the original object, 
but on the second proxy it first implements aspect 2's advice.

The net result of this arrangement is that as long as an overall ordering 
of aspects' advice can be seen to exist, it can be implemented using a 
number of proxies that is between 1 and the total number of aspects 
advising.  Unless...  what if we just changed the object's class on-the-fly?

In essence, we would be creating a "state machine" of classes for each 
model class.  In the simplest case, there would be only one: the one you 
normally get from the workspace.  However, if there are aspects (schema 
components) that intercept subsets of other aspects' interceptions, then 
there would be additional classes.

It seems sort of weird, but basically what would happen is that if you 
called someObject.setFoo(42), then there would be some functions getting 
called to do DB-specific or UI operations, following which the __class__ of 
'someObject' would change to the abstract domain class, and the original 
"abstract" setFoo(42) would execute.  Finally, the class would return to 
being the workspace-specific class.

I'm starting to like this, though.  It doesn't require a bunch of flags on 
the object, and it doesn't even use any *space* in the object that wasn't 
already needed.  The logic required to construct the classes is a bit 
complex, though.  And I'm not sure that you can always construct them in 
the general case.  It seems to me that there must be cases that would 
result in conflict.

Well, let's work it out and see.  We essentially want to say that a given 
piece of advice applies to some method M, possibly excluding calls nested 
from some set of other methods that make the advice moot.  The starting 
class includes all advice, indicating a state where no exclusions are 
possible because no methods have been called.  For each method, we can then 
construct a follow-up state after removing all the advice that no longer 
applies (due to exclusion by that method).  Thus for any state and method, 
the follow-up state will have a smaller amount of applicable 
advice.  Therefore, the generation of states will eventually terminate with 
either the zero-advice state (the original class), or a state consisting of 
all the methods which are advised regardless of calling context.  So, 
although it's not always possible to create a purely linear organization of 
classes, the required state machine is always finite.

It would appear, however, that I made a mistake in my earlier supposition 
that the number of proxy states/classes would max out at the number of 
schema components offering advice.  It would appear instead that the 
theoretical maximum number of states is in fact quite large, and the 
maximum number that can be passed through in sequence is actually the same 
as the number of methods which have *any* advice applied to them.

But, I don't think this is a big worry in practice.  Memoizing the class 
generation should prevent redundant classes from being generated, and most 
domain objects should have relatively shallow hierarchies of 
self-delegation, making the state machine relatively simple.  If additional 
speed is needed, it probably also won't be that complicated to add a C 
implementation of the class-swapping code.

Oh crap.  I just realized something.  This arrangement only handles 
"jumping aspects" that apply to a *single object*.  It does nothing for 
jumping across objects.  For example, if some domain operation on a 
"Customer" object involves lots of changes to "Invoice" objects, there's no 
way to deal with that automatically in this model.

Ah well.  I think the need for such interceptions is going to be relatively 
infrequent in the system as a whole, and there are several ways to tackle 
them manually when they do arise (such as temporarily changing each 
Invoice's class as it's processed!).  Anyway, I think that at least 80-90% 
of the "jumping aspects" in PEAK will revolve around feature-level 
modifications to objects, not domain-specific methods.  So, if these simple 
things are taken care of, and complex things are still possible, I think 
we're probably okay.

The only other problem is that technically speaking, some of the methods 
we're intercepting aren't on the class itself, but on features of the 
class.  This makes the class generation process a lot hairier, since we 
have to generate fresh features wherever the advice occurs.  On the bright 
side, we can use unadvised features without modification from the original 
class.

Or can we?  What if the advice is some type of class replacement?  What 
about non-method advice?  Or should we limit all advice to behavioral 
triggers?  I don't see that as a real limitation, so if it simplifies 
implementation I think we should go ahead with it.  It does bring to mind, 
however, that features have lots of bindings whose values are determined on 
a per-class basis, and thus subclassing features for purposes of creating 
"advised" versions is therefore going to be tricky.  (For example, 
'feature.typeObject' and the various convenience methods that delegate to it.)

Indeed, wherever metaclass-defined class attributes exist, there are 
potential issues with subclassing as a means of generating an advised 
version of the class.  For example, the 'mdl_isAbstract' flag is 
automatically cleared in subclasses unless it's explicitly set in the subclass.

I don't know what to do about that, exactly.  The only option that will 
guarantee consistency is to completely rebuild the "new" classes as clones 
of the old, but not as subclasses.  However, this also breaks domain-model 
code that makes explicit reference to the original classes.  (On the other 
hand, code that does that will break under the workspace model anyway!)

I think that the only "safe" way to generate a subclass of either a feature 
or element class is to create a subclass of the original, that has the same 
dictionary contents that the original did *when it was created* (i.e., 
leaving out subsequent setattrs or computed bindings).  While I can 
envision metaclass hierarchies where this would turn out badly, I don't 
think peak.model's stuff falls into that category.  At any rate, I think 
this approach should be a good place to start, although it does impose a 
cost of an extra dictionary per class.




More information about the PEAK mailing list