Hey all! I'd like to talk about my biggest frustration with Elixir as she
is wrote: packaging default callback implementations with behaviours.
The Problem
Today this is the almost universally used idiom:
defmodule Custom.Behaviour do
@callback foo :: atom
defmacro __using__(_opts \\ []) do
quote do
@behaviour unquote(__MODULE__)
def foo, do: :bar
end
end
end
That is, creating a __using__ macro in the behaviour module with a quote
block that just defines all the default implementations for the callbacks.
I firmly believe this is a bad pattern:
- There is no concrete implementation of them so they can only be tested
through a layer of indirection, defining new modules within the test suite
- If the project is a library that provides a behaviour it doesn't
itself use, then tests are the earliest opportunity to get useful compiler
warnings about those default implementations
- Errors raised in these default implementations have an inscrutable
backtrace
- The way the default callback functions work have no good place to be
documented
- The default implementation is far from its callback declaration and
easy to get out of sync
The last point is remedied by the new @impl annotation but I don't see that
being used as often as I'd like.
I also rarely see the macro-generated implementations of the callbacks
provide their own documentation, and often the behaviour's moduledoc
doesn't go into depth or cover the workings of all default implementations.
Furthermore, this situation is a lot of newcomer's first exposure to
writing their own macros, and this idiom teaches them the opposite of good
macro practices
<http://elixir-lang.github.io/getting-started/meta/macros.html>, such as:
- not writing them unless necessary (this really is a boilerplate-common
scenario)
- keeping them slim (the macro grows linearly with number of callbacks,
bad for wide-surface-area behaviours)
- having macros make as many calls to local functions as possible so
they can be tested (I've never seen this done for this default-behaviour
use-case)
The Fix
My proposal is to eliminate usage of this pattern, by providing a better
way of accomplishing the same thing, preferably within core.
That's a whole discussion on its own, but I would also like to suggest what
I think is a superior pattern for handling this we could use as the
recommended happy path: defining the default implementation of a callback
on the behaviour module itself, immediately alongside its callback
declaration, and creating a __using__ macro that instead generates a
delegate to this implementation. This has many virtues and fixes all of my
frustrations:
- It gives a concrete implementation of these functions so they can
issue compiler warnings
- and be documented
- and be tested
- The implementation is alongside its callback definition to stay sync
(indeed, the impl's @spec should always mirror the @callback above it)
- Errors will reference the offending line within the implementation
instead of the __using__ macro call-site
- Using delegates theoretically reduces compiled code size, assuming the
behaviour is used more than once and the implementations aren't oneliners
The main downside is that this pattern requires a little more boilerplate
(doing the delegation), and sadly the delegates themselves are far from
their implementations and can still easily get out of sync, although they
are more likely to raise an error in this case.
By creating a macro that does this boilerplate correctly for you, this
would be an entirely positive change, and if we put it in core, it will
hopefully supplant the old idiom as people update their source.
This is the heart of my proposal: let's provide a better way to do this out
of the box, with as little as boilerplate as possible.
One Implementation
To instigate discussion, I have put up the crude implementation of this
feature I've been toying with in one of my libraries here
<https://gist.github.com/christhekeele/917c430a57bc6eccc3227f90928f396c>.
It's a module with a __using__ macro that will generate a __using__ macro
for your behaviour modules.
It does more manual AST munging than I would like and steers clear of some
of the harder questions of what to do with types and specs, simply copying
the documentation over for functions and providing a delegate
implementation. Ideally it would only do this for functions flagged as
callbacks within the behaviour module, though my first take simply copies
over all public functions.
It can be instructed to suppress documentation, and provide inline
implementations rather than delegates. It sets the @behaviour for you,
provides the @impl annotation for all functions, and sets them all as
overridable. The behaviour module can still be used normally with
@behaviour so applying these default implementations is entirely optional.
Other considerations
My particular approach (having a __using__ generate a __using__ macro for
your behaviour) is certainly not the only way to go about this; defining
which functions are default implementations within a behaviour could be
done with an explicit macro, declaring that you want those default
implementations inside your using module could be an explicit macro. I
prefer this solution because it requires the least effort possible, using
the __using__ mechanism to not even need explicit requiring, and therefore
is more likely to be adopted.
This technique doesn't work for modules like GenServer that need a
different API than their callbacks. For most common cases my approach is
viable but the implementations could be tucked away inside a .Defaults
namespace or something instead to support variant APIs.
I like the idea of un-deprecating and repurposing the Behaviour module to
hold this feature, since its moduledocs could absorb the behaviours
<https://hexdocs.pm/elixir/behaviours.html#content> page, where I think it
would be easier to find organically in the docs, just because I find the
pages section often gets overlooked.
One might argue that having both "@behaviour FooBar" and "use FooBar" again
could be confusing. This feature could be implemented instead as macros
that take a behaviour module as an argument, appearing in Macro instead, or
be provided as an unadorned defdefaults or something, though those seem
less elegant to me and it would complicate the implementation (at least for
inlining, where having an @on_definition hook in the behaviour module is
essential).
But, this is all pretty subjective and ancillary to the main point: *should
we offer a better way to do this out of the box? *It's quite possible I'm
the only person who is driven crazy by the current vogue idiom.
--
You received this message because you are subscribed to the Google Groups
"elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email
to [email protected].
To view this discussion on the web visit
https://groups.google.com/d/msgid/elixir-lang-core/3701e507-b002-4e05-9b54-c979f2d3275f%40googlegroups.com.
For more options, visit https://groups.google.com/d/optout.