Sharing and Reusing Elixir Callback Functions between Modules
TL;DR: Sharing and Reusing Functions in Elixir is easy. Unless its a Callback.
There’s a problem?
Using a public Elixir function defined in one module (e.g. DonorA) in another (e.g. Recipient1) is easy: just call it with the fully qualified name:
1
2
3
4
5
6
7
8
9
10
defmodule DonorA do
def donor_a_fun_j do
:donor_a_fun_j
end
end
defmodule Recipient1 do
def recipient_a_fun_p do
DonorA.donor_a_fun_j
end
end
Proof of the pudding:
1
2
3
test "test_donor_a_recipient_1_donor_a_fun_j" do
assert :donor_a_fun_j = Recipient1.recipient_a_fun_p
end
Fine. Good. Job Done. End of Post. Thanks for reading.
No wait …
If you get fed up having to type the fully qualified name all the time you can import the donor function(s) into the recipient module.
1
2
3
4
5
6
defmodule Recipient2 do
import DonorA
def recipient_2_fun_p do
donor_a_fun_j
end
end
1
2
3
test "test_donor_a_recipient_2_donor_a_fun_j" do
assert :donor_a_fun_j = Recipient2.recipient_2_fun_p
end
Yawn! Trivial stuff! Where’s the beef???
When you import a function, the recipient module can only use the function internally – it is private – and will not be visible externally - for that you need to defdelegate:
1
2
3
defmodule Recipient3 do
defdelegate donor_a_fun_j(), to: DonorA
end
1
2
3
test "test_donor_a_recipient_3_donor_a_fun_j" do
assert :donor_a_fun_j = Recipient3.donor_a_fun_j
end
Oh do get on with it! We all know that!
Lets try that with some callback functions
If your donor and recipient modules are GenServers they will have one or more `handle_call/3` or `handle_cast/2` callbacks, usually “front-ended” by a client API functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
defmodule DonorBGenServer do
use GenServer
def donor_b_genserver_state_get(pid) do
GenServer.call(pid, :state_get)
end
def handle_call(:state_get, _fromref, state) do
{:reply, state, state}
end
end
defmodule DonorCGenServer do
use GenServer
def donor_c_genserver_state_put(pid, new_state) do
GenServer.call(pid, {:state_put, new_state})
end
def handle_call({:state_put, new_state}, _fromref, state) do
# return the old state but update to the new_state
{:reply, state, new_state}
end
end
Lets defdelegate the client API and `handle_call/3` functions into a new GenServer recipient module:
The `handle_call/3` functions have to be visible externally so GenServer can call them so import wont work.
1
2
3
4
5
6
7
8
9
defmodule Recipient4GenServer do
use GenServer
# client API functions
defdelegate donor_b_genserver_state_get(pid), to: DonorBGenServer
defdelegate donor_c_genserver_state_put(pid, new_state), to: DonorCGenServer
# handle_call functions
defdelegate handle_call(pid, fromref, state), to: DonorBGenServer
defdelegate handle_call(pid, fromref, state), to: DonorCGenServer
end
Lets check the state_get client API call.
1
2
3
4
5
test "test_donors_bc_genserver_recipient_4_state_get" do
state = :initial_state
{:ok, pid} = GenServer.start_link(Recipient4GenServer, state)
assert ^state = Recipient4GenServer.donor_b_genserver_state_get(pid)
end
Yep. How about the state_put?
1
2
3
4
5
6
7
test "test_donors_bc_genserver_recipient_4_state_put" do
state = :initial_state
{:ok, pid} = GenServer.start_link(Recipient4GenServer, state)
assert_raise FunctionClauseError, fn ->
Recipient4GenServer.donor_c_genserver_state_put(pid, :new_state)
end
end
Nope. The error message is interesting:
(FunctionClauseError) no function clause matching in DonorBGenServer.handle_call/3
Its looking in DonorBGenServer for the `handle_call/3` for DonorCGenServer’s donor_c_genserver_state_put; the second defdelegate for `handle_call/3` to DonorCGenServer has been ignored (as Elixir does).
Note although the test is wrapped in an assert_raise, the FunctionClauseError is not caught. I don’t know why. Anybody?
So whilst you can defdelegate callbacks to another module, it only works when all of the implementations for a specific callback are in one defdelegate-d donor module.
Cor blimey! All this time wasted explaining the obvious! Tell us something new!
Sharing and Reusing Callbacks
There is nothing special about callback functions in the Elixir sense, the “specialness” is the way the (e.g. GenServer) callback mechanism works: to allow pattern matching to recognise them as different implementations of the same callback function they have to be compiled together in the callback module.
So, if you need to use multiple callbacks from different modules you have to copy’n’paste their sources into the callback module and then compile them all together.
Or maybe not.
Cut to the Chase: Siariwyd
Siariwyd allows you to register (save) the implementations of callbacks defined in donor modules, and include them in recipient modules at compilation time.
See the documentation on Hex for the full monty but here is a quick example following on from those above.
Lets define two new donors and register their `handle_call/3` implementations using Siariwyd:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
defmodule DonorPGenServer do
use GenServer
use Plymio.Setup.Client.Minimal
# register (save) the source for all handle_calls in the compiled file
use Siariwyd, register: :handle_call
def donor_p_genserver_state_get(pid) do
GenServer.call(pid, :state_get)
end
def handle_call(:state_get, _fromref, state) do
{:reply, state, state}
end
end
defmodule DonorQGenServer do
use GenServer
use Plymio.Setup.Client.Minimal
# register (save) the source for all handle_calls in the compiled file
use Siariwyd, register: :handle_call
def donor_q_genserver_state_put(pid, new_state) do
GenServer.call(pid, {:state_put, new_state})
end
def handle_call({:state_put, new_state}, _fromref, state) do
# return the old state but update to the new_state
{:reply, state, new_state}
end
end
Now use Siariwyd to include the source of the donors’ `handle_call/3` implementations into the recipient:
1
2
3
4
5
6
7
8
9
10
11
defmodule Recipient5GenServer do
use GenServer
# client API functions delegated as before
defdelegate donor_p_genserver_state_get(pid), to: DonorPGenServer
defdelegate donor_q_genserver_state_put(pid, new_state), to: DonorQGenServer
# use Siariwyd to include the sources of the handle_call functions from the donor modules
use Siariwyd, module: DonorPGenServer, include: :handle_call
use Siariwyd, module: DonorQGenServer, include: :handle_call
end
We can now try the same test that failed above:
1
2
3
4
5
6
7
8
9
10
test "test_donors_pq_genserver_recipient_5_state_get" do
state = :initial_state
{:ok, pid} = GenServer.start_link(Recipient5GenServer, state)
assert ^state = Recipient5GenServer.donor_p_genserver_state_get(pid)
end
test "test_donors_pq_genserver_recipient_5_state_put" do
state = :initial_state
{:ok, pid} = GenServer.start_link(Recipient5GenServer, state)
^state = Recipient5GenServer.donor_q_genserver_state_put(pid, :new_state)
end
Sucess!
Few More Things To Note
Saving the Callbacks’ Implementation Definitions
Siariwyd uses Module’s compiler callback @on_definition to collect and save the callbacks’ implementation definitions.
A callback’s implementation definition is a map holding most of the arguments passed to `Module` compiler callback @on_definition, together with the `:file` and `:line` fields from the `env` argument.
BTW a previous post of mine goes into more details on using @on_definition and how to persist data in the module’s compiled (BEAM) file.
Default is to Include all Implementations for all Registered Functions
If neither `:include` nor `:register` options are given, the default is to include all implementations for all registered functions in the donor module. e.g.
1
use Siariwyd, module: DonorPGenServer
Filtering the Included Implementations
Optionally, Siariwyd can take a filter function to refine the implementations to include. The filter function is passed an implementation definition and must return truthy or falsy.
Here is a silly example filter where the handle_call responding to :state_put is discarded (and all other register functions are also discarded).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use Siariwyd, module: DonorPGenServer, include: :handle_call,
filter: fn
# match handle_call definitions
%{name: :handle_call, args: args} = _implementation_definition ->
# args is the list of quoted arguments to the implementation
# e.g. handle_call(:state_get, fromref, state)
case args |> List.first do
:state_get -> false
_ -> true
end
# not handle_call - discard
_ -> false
end
If you run the tests in the examples to go with this post, you’ll see the expected error: there is no longer a handle_call implementation responding to :state_get
(FunctionClauseError) no function clause matching in Recipient6GenServer.handle_call/3 (siariwyd_examples) lib/recipient_6_genserver.ex:24: Recipient6GenServer.handle_call(:state_get, {#PID<0.167.0>, #Reference<0.0.2.132>}, :initial_state)
Transforming the included implementations
Siariwyd optionally can take an mapper function to transform the original, donor implementation before inclusion into the recipient. The mapper function is passed an implementation definition and must return the definition, normally with the `:ast` key updated.
A mapper function is more complicated because the definition must be edited and the complete implementation rebuilt. Siariwyd provides a convenience function `Siariwyd.reconstruct_function/1` to rebuild the `:ast` from the (updated) definition.
Another silly example, this time to change the handle_call’s response from :state_get to :hello_world. (The examples has a full example showing how a new client API function can use the :hello_world implementation.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use Siariwyd, module: DonorPGenServer, include: :handle_call,
mapper: fn
# select handle_call definition
%{name: :handle_call, args: args, ast: ast} = implementation_definition ->
# args is the list of quoted arguments to the implementation
# e.g. handle_call(:state_get, fromref, state)
[arg | rest_args] = args
case arg do
:state_get ->
# change the args to respond to :hello_world and reconstruct the implementation
implementation_definition
|> Map.put(:args, [:hello_world | rest_args])
# reconstruct the complete implementation using the convenience function
|> Siariwyd.reconstruct_function
# nothing to do
x -> x
end
# passthru
x -> x
end
Again the examples has a test to show the error for the “missing” handle_call for :state_get, and how a new client API function recipient_7_hello_world calls the mapped :state_get handle_call.
Not just callbacks
Finally, although focused on callbacks, Siariwyd should work with any function that has multiple implementations that need to be compiled together.
On Hex and Github
1
2
3
4
cd /tmp
git clone git@github.com:ianrumford/siariwyd_examples.git
cd siariwyd_examples
mix test