technology from back to front

Intercession without source changes

Methods in a Smalltalk object live in the method dictionary of its class. A method dictionary maps Symbols to CompiledMethods. From the virtual machine’s perspective, anything that understands #run:with:in is compatible with a CompiledMethod, in the sense that the VM sends this message to things that it will execute.

As a result, it’s easy enough to put arbitrary objects (that understand #run:with:in) in a class’s method dictionary. In fact, there’s a library for it.

With this hammer in hand, it becomes trivial to perform all manner of intercessions on code, without instrumenting code through a rewrite+compile cycle: wrap the CompiledMethod in an ObjectAsMethodWrapper (or several) and away you go. What might you do? Pre- and post-condition checking, flagging which methods execute for coverage analysis, profiling, and so on. Today we’re going to turn a method into a memoised one.

ObjectAsMethodWrapper subclass: #MemoizingWrapper
    instanceVariableNames: 'usedArgs'
    classVariableNames: ''
    poolDictionaries: ''
    category: 'ObjectAsMethodWrapper-Extra'

ObjectAsMethodWrapper >> initialize
    super initialize.
    usedArgs := Dictionary new.

ObjectAsMethodWrapper >> run: aSelector with: arguments in: aReceiver
    | key |
    key := {aReceiver}, aSelector, arguments.
    usedArgs at: key ifPresent: [:value | ^ value].
    ^ usedArgs
        at: key
        put: (super run: aSelector with: arguments in: aReceiver)

With that in hand, let’s see how it behaves:

TestCase subclass: #MemoizingWrapperTest
    instanceVariableNames: 'transcript'
    classVariableNames: ''
    poolDictionaries: ''
    category: 'ObjectAsMethodWrapper-Extra-Tests'.

MemoizingWrapperTest >> setUp
    transcript := OrderedCollection new.

MemoizingWrapperTest >> recordingMethod: anObject
    transcript add: ('Executed #recordingMethod: with {1}' format: {anObject}).
    ^ anObject.

MemoizingWrapperTest >> testWrapperMemoizesCalls
    | w |
    "Wrap a single method."
    w := MemoizingWrapper installOn: self class selector: #recordingMethod:.
    ["Sanity check"
    self assert: transcript isEmpty.
    "Wrapper leaves method result unchanged"
    self assert: 1 equals: (self recordingMethod: 1).
    "First execution, so of course the method's executed."
    self assert: 1 equals: transcript size.
    self recordingMethod: 1.
    "Second execution with same argument returns memoized value
     rather than executing the method again."

    self assert: 1 equals: transcript size.
    "New execution's result also unchanged."
    self assert: 2 equals: (self recordingMethod: 2).
    "And no further executions of the method have been logged."
    self assert: 2 equals: transcript size.] ensure: [w uninstall]

This technique permits one to install arbitrary before/after/around method changing things around methods that you might not otherwise be able to change. It does, however, have a few drawbacks:

  • Changes in behaviour are not reflected in changes in source.
  • Like annotations, you can’t look at a method’s source and understand exactly how it will behave.
  • Unlike annotations, without some kind of inspector you have no idea that a method is wrapped, or what it might do. At least it’s easy to find wrapped methods, if you thought to check for their presence: ObjectAsMethodWrapper allInstances will let you iterate over all the wrappers.
Frank Shearar

4 × = twenty four

2000-14 LShift Ltd, 1st Floor, Hoxton Point, 6 Rufus Street, London, N1 6PE, UK+44 (0)20 7729 7060   Contact us