Chapter 10 Aspects
SourceAn aspect is a capability you want available throughout a program without signing for it at every call site. Logging, tracing, metrics, localisation, the current random-number generator, the current time source for testing — all of them are things that plenty of words need and that nobody wants to list as an effect on the signature of every word in between.
Aspects fill that gap. They look like effects from a declaration-site perspective, but they don't need to appear in call-site signatures. Instead, every aspect operation has a default implementation (usually a no-op), and you can override the implementation for a dynamically scoped region with :with.
If you've used AspectJ or Common Lisp's :around methods, this is the same idea in a typed-stack form. If you've used Koka, aspects are Silo's un-annotated version of its algebraic effects — less tracking at call sites, more flexibility for cross-cutting concerns.
Declaring an aspect
:aspect declares a named capability with one or more operations. Each operation gets a default body, separated from its signature by a space and ending with ;:
:aspect Log
.log ( Str LogLevel -> ) drop drop ;
:end
Log has one operation, .log, which pops a LogLevel (top of stack) and a Str (underneath). The default drop drop discards both — the no-op case. Every aspect operation must have a default, because code calls the operation without first checking that a handler is installed.
Implementing a handler
An aspect handler is a value whose type implements the aspect for its own case:
:record StdoutLogger :end
:impl Log StdoutLogger
.log ( Str LogLevel -> ) + Console
"{}: {}" format print ;
:end
StdoutLogger is a zero-field record used as a tag — in this case the handler carries no state. The :impl block implements Log for StdoutLogger, replacing the default .log body with one that prints. Notice that this handler's .log declares +Console: a handler can do whatever work it needs, with whatever effects it needs, independent of what the call site sees.
Handlers with state are records with fields:
:record RingLogger
.buffer ( Vec ( Pair Str LogLevel ) 128 )
.pos U8
:end
and an :impl that reads/updates the state appropriately.
Importing an aspect
The :use block imports both the aspect itself and the operations it exports:
:use
:aspect logging Log
:end
The first name (logging) is the package containing the aspect; the second (Log) is the aspect's name. After this import, the operations — here .log — are available as ordinary methods.
Calling an aspect operation
Call sites don't need any annotation:
:fn do-work ( -> )
"working" LInfo .log # no +Log effect declared anywhere
:end
The .log call resolves against whichever handler is currently installed. If no :with block is active, it dispatches to the default (the aspect's built-in no-op body).
This is the crucial property of aspects. do-work has a pure signature. You can call it from anywhere without worrying about whether the caller has a +Log capability, because there's no such effect to propagate. The logging happens if a handler is installed; it silently doesn't otherwise.
Installing a handler — :with
:with installs a handler for a dynamically scoped region:
:main + Console
StdoutLogger :with Log
do-work # .log calls go to StdoutLogger
:end
do-work # .log calls are no-ops again
:end
Read StdoutLogger :with Log … :end as "for the body, use StdoutLogger as the handler for the Log aspect." The handler value sits on the stack immediately before :with; the body runs with the handler installed; after :end, the previous handler (or the default) is restored.
Because :with is dynamic, nested :with blocks nest:
:main + Console
StdoutLogger :with Log
do-work # StdoutLogger
RingLogger :with Log
do-work # RingLogger, inside the inner :with
:end
do-work # back to StdoutLogger
:end
:end
That's what makes aspects practical for tests and experiments: wrap a region in a mock handler and the code inside runs unchanged, dispatching to the mock.
Aspects versus effects
The two feel related because both concern cross-cutting behaviour, and both use :with for handler installation. The differences:
| Dimension | Effect | Aspect |
|---|---|---|
| Declaration keyword | :effect |
:aspect |
| Handler required? | Yes (host or :with) |
No — every op has a default |
| Shows in signatures? | Yes (+Effect) |
No |
| Call-site ceremony | Caller must declare/discharge | Call freely, dispatches to default/handler |
| Runtime representation | Same vtable-backed handler install | Same vtable-backed handler install |
Use an effect when the operation has to succeed — if there's no handler, something has gone wrong and the program shouldn't compile. Use an aspect when the operation is cross-cutting — nice to have everywhere, with a sensible no-op default when nobody's listening.
When to reach for an aspect
A word needs aspects if, hypothetically, you could replace every one of its operations with a no-op and the program would still produce the right answer — just without the side observation. Logging passes that test. So does metrics collection, tracing, and "the current locale" (the default is the system locale; a :with overrides).
A word that genuinely needs the capability — reading a file, making a network call — should be an effect, not an aspect. Aspects are for behaviour that's worth providing but isn't essential to correctness.
Key points
- An aspect is a capability with a default. Operations are callable anywhere, no
+Aspectannotation needed. :aspectdeclares;:impl (Aspect Type)provides a handler;:withinstalls a handler for a dynamically scoped region.- Handlers are values. Stateless handlers are zero-field records used as tags; stateful handlers are records with fields.
- Nested
:withblocks nest dynamically — the innermost handler wins for the scope of its body. - Aspects and effects have the same handler-install machinery but different call-site stories: effects demand, aspects invite.
Next: collections — the standard library's data structures, traversal traits, and iteration patterns.