If we follow along with the OOP logic, it could be argued that a buffer could (or should) hold a reference to its associated BufferSoundFileView. For this, we'd need to add another subclass, like so:
ViewEnhancedBuffer : Buffer { var view; view { |parent| view = BufferSoundFileView.new(parent, nil, this); ^view; } }
Okay, but we still have our RecEnhancedBuffer from before:
RecEnhancedBuffer : Buffer { var recSynth, <>recEnd; startRec { // ... } stopRec { // ... } }
What if I wanted to have a buffer that supports both capabilities (providing a view and recording into it)? Now I've fallen into a classic inheritance trap, because the only way to achieve this would be by creating yet another subclass that combines both behaviors:
ViewAndRecEnhancedBuffer : Buffer { var view, recSynth, <>recEnd; view { // ... } startRec { // ... } stopRec { // ... } }
If you think about it, the more capabilities you add this way, the more permutations of subclasses you'll get. Want to add another method? Now you need to create (at least) 4 new subclasses of Buffer and its descendants. Clearly, this is far from ideal. What is happening here?
Inheritance vs Composition
Maybe you've heard about the composition over inheritance paradigm, and this is a good case in point. We're adding features to the Buffer class by extending it into a large hierarchy tree. But before we check out what we can do to mitigate this, let's examine a rule of thumb for when to use either strategy:
- the two entities entertain a "is a?" relationship => use inheritance. (the go-to example here is that of animals: Animal => Rodent => Squirrel, but we can also use a more Supercollider-esque one: Pattern => ListPattern => Pseq)
- the two entities entertain a "uses a?" relationship => use composition. (this will be the go-to choice in most of the cases, with the added benefit of being able to configure it at runtime as we shall see)
Now if we go back to our example above, we need to simply ask ourselves? Is a Buffer a View? Is a Buffer a Recorder? I'd vote for NO in both cases. Now let's see what can be done to improve this code structure:
The Decorator Pattern
In Supercollider, decorator is kind of reserved for dealing with UI Layouts, but it is actually a pretty common and general design pattern.
What does it do, how does it help? Wrapper being an alternative name, this also explains its usage pattern best. What, as an end result, we want to be able to do is this:
What does it do, how does it help? Wrapper being an alternative name, this also explains its usage pattern best. What, as an end result, we want to be able to do is this:
~decorated = BufferViewDecorator.new(BufferRecorderDecorator.new(~some_buffer)); // ~decorated now acts like a Buffer, but also exposes the view, startRec and stopRec methods: ~decorated.play; ~decorated.view; // => A BufferSoundFileView ~decorated.startRec; ~decorated.stopRec;
How do we achieve this? With a tiny bit of metaprogramming: Supercollider objects generally boast two methods available for inspecting its capabilities as well as responding to failures: respondsTo and doesNotUnderstand.
First, in the constructor we are simply storing the wrapped object in an instance variable.
BufferRecorderDecorator { var wrapped; *new { |wrapped| ^super.newCopyArgs(wrapped); } }
If we now, e.g. call play on an instance of BufferRecorderDecorator, it will tell us that it does not understand it (naturally):
ERROR: Message 'play' not understood. RECEIVER: Instance of BufferRecorderDecorator
Being simply an instance derived from the general base class Object, we can override doesNotUnderstand:
BufferRecorderDecorator { // ... doesNotUnderstand { |selector ... args| if(wrapped.respondsTo(selector)) { ^wrapped.performList(selector, args); }; ^this.superPerformList(\doesNotUnderstand, selector, args); } }
This method is called whenever any message arrives that isn't understood, and can be used to customize the response (see here). What we're doing here is asking the wrapped object if it knows how to respond to the message (selector) - if so, just perform it, else hand it up to the superclass (in our case Object).
We are almost done with implementing this pattern, but there's a tiny bit of glue code missing. If we wrap this decorator in another one (BufferViewDecorator), our instance of BufferRecorderDecorator becomes the wrapped object, and upon being asked if it respondsTo play, for example, will respond with false. So what do we need to do? Yes, exactly, delegate this call to the next level, i.e. the Buffer wrapped in BufferRecorderDecorator:
BufferRecorderDecorator { // ... respondsTo { |aSymbol| ^(super.respondsTo(aSymbol) || wrapped.respondsTo(aSymbol)); } }
This takes account of two cases: either the decorator itself knows how to respond to a message (e.g. view), OR the wrapped object maybe does. This also restores the default behavior, i.e. in case neither of them understands aSymbol it will correctly return false.
We could now duplicate the above structure for BufferViewDecorator, but in contrast to the use case we're discussing here, this is actually a perfectly valid example for when inheritance makes sense.
A Common Delegator Base Class
Decorator is already reserved in Supercollider, so I called this base class Delegator, which is what they're called in Ruby (my "home language") and sounds as telling:
Delegator { var wrapped; *new { |wrapped| ^super.newCopyArgs(wrapped); } respondsTo { |aSymbol| ^(super.respondsTo(aSymbol) || wrapped.respondsTo(aSymbol)); } doesNotUnderstand { |selector ... args| if(wrapped.respondsTo(selector)) { ^wrapped.performList(selector, args); }; ^this.superPerformList(\doesNotUnderstand, selector, args); } }
Both BufferViewDecorator and BufferRecorderDecorator can now extend this class and only need to add their respective methods. This pattern is quite elegant, because it actually does not depend on the type of wrapped, i.e. you can essentially reuse this for any decorator structure you want to construct. And with that, our example from above just works:
~decorated = BufferViewDecorator.new(BufferRecorderDecorator.new(~some_buffer)); // ~decorated now acts like a Buffer, but also exposes the view, startRec and stopRec methods: ~decorated.play; ~decorated.view; // => A BufferSoundFileView ~decorated.startRec; ~decorated.stopRec;
You can find the final source code in this gist: https://gist.github.com/2c54c8254b690c6c552977a21127e05a