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

Siariwyd in on Hex.

Repo is on Github.

Examples above are on Github.

1
2
3
4
cd /tmp
git clone git@github.com:ianrumford/siariwyd_examples.git
cd siariwyd_examples
mix test