Tips for Defining Behaviors

Disclaimer / Introduction

Same as last time: the following tips are all heuristics, not rules. There are exceptions and situations in which is much more convenient to bend or break these guidelines to better accommodate to your needs.

1. Definition & Usage Together

As a general rule: behaviors should be defined (i.e. -callback attributes should be written) in the generic module.

There is an analogous limitation to the fact that all callbacks should be implemented in one module: all callbacks should be defined in one module as well, and there is no mixer library to help you here (I never needed one, anyway). In principle then, the generic module is the only one who knows about those callbacks.

Also remember that for a callback to be used, at some point a dynamic call should be evaluated (something like Module:the_callback(Param) ). Dynamic calls are (at the time of writing this article) impossible to analyze by tools like dialyzer or xref . That’s why elvis has a rule to warn you if you use dynamic calls on modules that don’t define behaviors.

If you really only need to define a behavior to be sure that all your types provide similar interfaces, don’t be afraid to add functions like the one below to your generic modules:

-module(figure). -type t() :: #{module := module(), _ => _}. -callback area(t()) -> float(). -export([area/1]). -spec area(t()) -> float().

area(Figure = #{module := Module}) ->

Module:area(Figure).

As you can see, the function area/1 is just a middleman. Its only goal is to allow other modules to evaluate figure:area(…) with any figure created in a callback module that implements the figure behavior. The alternative would be to force callers to figure out the callback module and evaluate the dynamic call themselves.

2. Callback Specs

Put lots of effort on having proper -callback specs. This will be easier if you’re doing deduction because you will clearly know the types that you need to accept/provide. But it’s even more important if you’re doing induction since specially in that case, they should act as documentation and also allow dialyzer to correctly type-check callback modules.

The following callback provides no documentation and almost no type specification for dialyzer:

-callback init(term()) -> {ok, term()} | {stop, term()}.

You can add documentation like this:

-callback init(Args :: term()) ->

{ok, State :: term()} | {stop, Reason :: term()}.

And that’s how gen_server defines its callbacks. I personally prefer to do a bit more:

-type init_arg() :: term().

-type callback_state() :: term().

-type reason() :: term().

-callback init(init_arg()) ->

{ok, callback_state()} | {error, reason()}.

That way if we later need to use the same type for other callbacks (and that’s generally the case with callback_state/0 ), we can precisely indicate that it’s in fact the very same type and not just another term() . This practice also has the added benefit that, in the callback modules, the specs will look like this:

-spec init(gen_mod:init_arg()) ->

{ok, gen_mod:callback_state()} | {error, gen_mod:reason()}.

That way, dialyzer can check that the types exist and, if we ever change the definition of any of them (e.g. we may want to use maps for init/1 argument) we only need to change it in one location (on gen_mod ). We don’t need to change every callback module spec.

3. Testing

Going back to what I said on the previous article: In general, the generic module only works if it has a counterpart (i.e. the callback module), the generic module is not useful on its own. So, to test your generic module, you’ll need to create at least one implementation of the behavior and test it in black box mode. This is clearly easier when you’re deducting since you should already have tests for the modules that you’re refactoring.

It’s more complex for induction scenarios. In those, you might want to generate a fake callback module (or even more than one) and use them for your tests. You can also provide a default implementation of your behavior and test this one. This is particularly useful if you’re writing a library. Check sumo_db, for instance: This library provides multiple behaviors to extend the different layers of its architecture (i.e. repository, store, backend, etc.) but it also comes with an implementation of all of them (the ones based on mnesia). That way, other developers may have a clearer reference on how they’re supposed to implement their adapters. This is information that goes beyond what the callback specs can provide.

Not only that, but sumo_db also provides test helpers that developers can use to ensure compliance and test coverage of their modules without the need to copy&paste the existing tests that are used for the mnesia versions.

4. Optional Callbacks

Erlang/OTP 18.0 introduced optional callbacks. Those who follow my blog should know that I am a huge fan of them :) — but I’m here to tell you that they should be used with extreme caution.

If you mark a callback as optional you need to make sure that your generic module works well with a callback module that does not export said callback. That means that if your default or fake module (the one you’re using for your tests) does implement the callback, then you need to use another fake callback module without the function and test your generic module with it, too.

Just in case that you need it, remember that you can use erlang:function_exported/3 to find if the callback is implemented, but only after the callback module is loaded. If you’re using releases (and we all are!) all application modules will be automatically loaded on startup. That’s great, but if you’re writing a library, you can’t be 100% sure that your users will use releases to run their Erlang systems, can you? ;)

5. Using Existing Behaviors

Finally, remember that you don’t need to start from scratch every time. The same module can be a callback module for a behavior and define another. If an existing behavior takes you half the way there, you can just build your new behavior on top of it, like OTP does ;)