Easily and clearly separate interface from implementation in Erlang
In OOP there is a concept of separating interface from the implementation. To be more precise, let's define the problem in terms of Erlang.
A project has a module clientmodule
and servermodule
. The clientmodule
has the following piece of code:
func(Server) ->
servermodule:callme(Server).
So far so good, but one day your want to use another module servermodule2
as a server. Moreover, you want to decide, which module to use somewhere outside of the clientmodule
scope. And having such a code the clientmodule
will be aware which implementation it calls.
Here comes what one calls "polymorphism".
Quick goooogling for "erlang polymorphism" gives the impression that the question is raised from time to time, but there is no well-defined and clear solution yet.
From the links above one could quickly figure out two possible solutions:
Solution 1: servermodule:callme(Server)
should send a message to Erlang's process Server.
servermodule.erl:
callme(Server) ->
Server ! {callme, self()},
receive
{response, Response} ->
Response
end.
The instances of Server from servermodule
and servermodule2
could be realized as a process, which handles {callme, From} message and react accordingly.
You definitely nailed it! The client should not be changed. But there are also downsides.
-
The implementation of the server is now limited to a process with handles those messages. So you can't write
servermodule:callme/1
which just quickly fetches something from ETS table without message passing. -
You can't benefit from the server being a
gen_server
and implement callme as agen_server:call/2,3
. Of course you could rewrite it to:callme(Server) -> . gen_server:call(Server, callme).
But now the implementation of the server must be a
gen_server
and not agen_statem
. -
If one day you are to add another function
servermodule:callme2
the compiler will not help you to find all the implementation modules for adjusting to the change. The same is true if you want to change yourcallme
method, for example, to accept another parameter. This is an important feature of strictly typed and OOP languages like C++ or Java: as you add new method to the interface or change signature of existing method, any non-conforming implementation don't compile.
Solution 2: Replace Server with a tuple {Module,Context}
func(_Server = {Module,Context}) ->
Module:callme(Context).
Much better but still smells.
Advantage(s):
- The implementation is now not limited to a process
- The Behaviors feature of Erlang could be introduced to specify the
servermodule
's interface, so all the implementations could be checked through the specification.
Disadvantage(s):
-
The client should be changed and re-compiled. This one can be avoided by introducing the concept from the very beginning.
-
A reader of your code has no clue what kind of Module is that. If you have a dozen of such {Module,Context} "interfaces" scattered over the project, it is impossible to figure out, which one of them the Module implements in any particular case. Code comments could help (if you write them everywhere and keep them up-to-date). But there is no native language tools to overcome.
The second Solution could be improved by introducing dedicated interface module with a set of delegating functions plus one factory method unifying the creating process.
Solution 2.1: Introduce delegating interface module.
servermodule.erl:
-export([callme/1]).
% servermodule could be used as a behaviour thus
% compile-time check of an implementation could be used
-callback callme() -> ok.
% Next one unifies the process of creating instances of 'servermodule'
create(Module, Arguments) ->
Context = Module:create(Arguments),
{?MODULE, Module, Context}.
callme({?MODULE, Module,Context}) ->
Module:callme(Context).
servermodule_imp_1.erl
% Compile-time check if the implementation conforms interface
-behavior(servermodule).
-export([callme/1]).
callme(Context) ->
ok.
clientmodule.erl:
func(Server) ->
servermodule:callme(Server).
somewhere.erl:
Server = servermodule:create(servermodule_imp_1, no_arguments),
clientmodule:func(Server).
This one is far better from a code's reader point of view. Everything is clear now.
But there is still a subtle disadvantage exists:
-
For each interface module in your project you should duplicate callbacks and delegating methods. Moreover, you should write pretty boring delegating functions with risk of typos.
-
For each interface module in your project you should write the same
create
function delegating the instance creating to the passedModule
and then wrapping them together into{?MODULE, Module, Instance}
tuple.
Those disadvantages definitely could be overcome with some kind of code-generating.
Having all those thoughts in mind let's try to establish our requirements of an assumed solution.
- Unify the process of creating Interfaces separated from their Implementations in Erlang
- Don't limit the way the Implementation could be embodied.
- Support any kind of compile-time check
- Minimize duplication of code.
- An interface is defined as a behavior with a number of callback functions (as in Solution 2.1);
- Each Implementation is validated via
-behaviour(interface).
(as in Solution 2.1); - Having defined callbacks the delegating functions are generated by parse_transform feature of the Erlang compiler;
- Each Implementation follows special Behaviour with factory method callback. This one unifies creating of instances.
- Factory method
interface:create/2
is generated by parse_transform
Suppose you have two implementations of key-value storage.
The storage's interface has two operations: get(Storage, Key)
and set(Storage, Key, Value)
.
You want to use the storage abstractly without knowing the exact implementation module.
ets_storage.erl:
-export([create/0, set/3, get/2, delete/1]).
create() ->
{ok, ets:new(?MODULE, [public])}.
get(Tab, Key) ->
case ets:lookup(Tab, Key) of
[{_,Value}] ->
Value;
[] ->
undefined
end.
set(Tab, Key, Value) ->
ets:insert(Tab, {Key, Value}).
delete(Tab) ->
ets:delete(Tab).
redis_storage.erl:
-export([create/1, set/3, get/2, delete/1]).
create({RedisHost, RedisPort}) ->
{ok, open_redis_connection(RedisHost, RedisPort)}.
get(Conn, Key) ->
redis_command(Conn, ["GET", Key]).
set(Conn, Key, Value) ->
redis_command(Conn, ["SET", Key, Value]).
delete(Conn)
redis_close(Conn).
storage.erl:
-compile({parse_transform, epolymorph_interface_pt}). % (1)
-callback get(term(), term()) -> term(). % (2)
-callback set(term(), term(), term()) -> ok|error. % (3)
The epolymorph_interface_pt
transformation (1)
walk through the callbacks (2), (3)
and generates exported delegating methods to storage
in the form of:
get({?MODULE, Module,Instance}, Key, Value) ->
Module:get(Instance, Key, Value).
Also it generates exported functions create/2
which expects the name of the module implementing epolymorph_instance_spec
as a first parameter and arbitrary data passed to the implementation factory method as a second parameter. delete/1
is also generated as opposite to create/2
.
create(Module, Arg) ->
case Module:epolymorph_create(Arg) of
{ok, Instance} ->
{ok, {?MODULE, Module, Instance}};
{error, Reason} ->
{error, Reason}
end.
Prepare your modules to be instances of abstract storage.
Each instance is to follow epolymorph_instance_spec
behaviour which defines factory method epolymorph_create/1
and epolymorph_delete/1
callbacks.
ets_storage.erl:
-behaviour(epolymorph_instance_spec).
-export([epolymorph_create/1, epolymorph_delete/1]).
-behaviour(storage).
-export([set/3, get/2]).
epolymorph_create(_) ->
{ok, ets:new(?MODULE, [public])}.
epolymorph_delete(Tab) ->
ets:delete(Tab).
get(Tab, Key) ->
case ets:lookup(Tab, Key) of
[{_,Value}] ->
Value;
[] ->
undefined
end.
set(Tab, Key, Value) ->
ets:insert(Tab, {Key, Value}).
redis_storage.erl:
-behaviour(epolymorph_instance_spec).
-export([epolymorph_create/1, epolymorph_delete/1]).
-behaviour(storage).
-export([set/3, get/2]).
epolymorph_create({RedisHost, RedisPort}) ->
{ok, open_redis_connection(RedisHost, RedisPort)}.
epolymorph_delete(Conn) ->
close_redis_connection(Conn).
get(Conn, Key) ->
redis_command(Conn, ["GET", Key]).
set(Conn, Key, Value) ->
redis_command(Conn, ["SET", Key, Value]).
Going on!
-
Add polymorph as a dependency to your rebar.config:
{deps, [ {epolymorph, ".*", {git, "https://github.com/eldarko/epolymorph.git", {branch, "master"}}} ]}.
-
./rebar get-deps compile
# git clone https://github.com/eldarko/epolymorph.git
# cp src/*.erl <your_project>/src
set_single_value(Storage) ->
storage:set(Storage, "key1", "value1").
{ok, Storage1} = storage:create(ets_storage, ignored),
{ok, Storage2} = storage:create(redis_storage, {"127.0.0.1", 6379}),
set_single_value(Storage1),
set_single_value(Storage2),
storage:delete(Storage1),
storage:delete(Storage2).
Define abstract connection with send/2 and close/1 methods. Add two implementations of the connection - one using UDP and one using TCP. The client shouldn't be aware which implementation is used.
# git clone https://github.com/eldarko/epolymorph.git
# cd epolymorph
# wget https://s3.amazonaws.com/rebar3/rebar3 && chmod +x rebar3
# ./rebar3 as examples compile
# ./rebar3 as examples shell
1> connection_example:main().
ok
2>
Look at examples/connection_example.erl
:)