Good day.
A few words about yourself first. I have been writing on Erlang for about 10 years and I welcome schemes and diagrams that have appeared recently. But I remember what kind of upheaval in my code caused the application of behaviors, and I think that this is an interesting topic for complex products.
Why do we need behaviors? Behavior is the essence of the interface definition. Establish a contract between the caller and the implementation. Well, all that follows from this in cases of the usual definition of a class interface. Only in this case we define the interface of the module.
')
A module can perform more than one behavior, but you need to be careful to keep it from overlapping.
If the behaviors declare a function that matches the name and the number of parameters, then a logical warning appears when you compile the conflicting behaviors.
The syntactic side is very simple. I will put the code for an example and then continue the description.
Behavior code:
-module(sample_behavoiur). -export([default/2]). -callback init(Args :: list())-> {ok, State :: term()}. -callback action(State :: term())-> {ok, ActionResult :: term(), State::term()} | {error, ErrorInfo :: term() }. -callback default(State :: term() )-> {ok, DefaultResult :: term() }. -optional_callbacks([default/1]). -spec default(Mod :: atom(), State :: term())-> {ok, DefaultResult :: term() }. default(Mod, State)-> case erlang:function_exported(Mod, default, 1) of true -> Mod:default(State); false -> {ok, default} end.
And two modules that perform these behaviors:
-module(implement_1). -behaviour(sample_behavoiur). -export([init/1, action/1 ]). -record(state,{list :: [integer()]}). init(Args) -> {ok, #state{list = Args}}. action(State = #state{list = []})-> {ok, empty, State}; action(State = #state{list = [Head|Rest]})-> {ok, Head, State#state{list = Rest}}. -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). simple_test()-> {ok, Opaq1} = implement_1:init([1,2,3]), {ok, 1, Opaq2} = implement_1:action(Opaq1), {ok, 2, Opaq3} = implement_1:action(Opaq2), {ok, 3, Opaq4} = implement_1:action(Opaq3), {ok, empty, Opaq5} = implement_1:action(Opaq4), {ok, default} = sample_behavoiur:default(implement_1, Opaq4). -endif.
-module(implement_2). -behaviour(sample_behavoiur). -export([init/1, action/1, default/1 ]). -record(state,{list :: [integer()]}). init(Args) -> {ok, #state{list = Args}}. action(State = #state{list = []})-> {ok, empty, State}; action(State = #state{list = [Head|Rest]})-> {ok, Head, State#state{list = Rest}}. default(_State = #state{list = []})-> {ok, empty}; default(_State = #state{list = [Head|_]})-> {ok, Head}. -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). simple_test()-> {ok, Opaq1} = implement_2:init([1,2,3]), {ok, 1, Opaq2} = implement_2:action(Opaq1), {ok, 2} = sample_behavoiur:default(implement_2, Opaq2), {ok, 2, Opaq3} = implement_2:action(Opaq2), {ok, 3, Opaq4} = implement_2:action(Opaq3), {ok, empty, Opaq5} = implement_2:action(Opaq4), {ok, empty} = sample_behavoiur:default(implement_2, Opaq4) -endif.
As you can see, the behavior declares 3 functions, one of which is optional. An optional function is usually wrapped in a safe handler in the body of the behavior itself, which checks for the presence of a function in the executing module. The function declaration includes the specification, and it is recommended that the specification be as detailed as possible. This, in turn, will make static code checking easier with dialyzer.
In the modules implementing the behavior, and they are almost identical in the example, except for the default function, it is clear that the declaration of functions repeats those that are in the definition of behavior. In reality, the declaration can be any subset of the original function, if the concrete implementation does not create all possible scenarios.
A more interesting case is the creation of a chain of behaviors when the behavior module itself in turn performs another behavior itself. An example can be seen, in particular, in the standard Erlang library, when the
supervisor behavior in turn executes the gen_server behavior. In this case, the code is divided into two parts - the first fulfills its contractual obligations, the second provides service functions for other modules under the new contract.
In a functional code, it is not always necessary to define new behaviors. And the criterion of necessity will be simple - two or more modules having the same semantics or role require an interface definition and unification. The elapsed time will facilitate both testing and future code expansion. Because if there are 2-3 modules with one role, there is a high chance of the appearance of several more.