Combining Protocol and Behaviour - how to call a a function on a Module that inherits the function from another Module?

I am trying to provide a way to create something like inheritance.
i want to be able to call a a function on a Module that inherits the function from another Module. Is the following the most straightforward way?

defprotocol Proto do
  @spec foo(t()) :: charlist()
  def foo(item)
end

defmodule SpecializedProto do
  @callback info() ::  charlist()
end

defimpl Proto, for: Atom do
  @impl true
  def foo(item) do
    apply(item,:foo,[item])
  end
end

defimpl Proto, for: SpecializedProto do
  @impl true
  def foo(item) do
    apply(item,:info,[])
  end
end

defmodule Test do
  @behaviour SpecializedProto
  def info(), do: "test"
  defdelegate foo(x), to: Proto.SpecializedProto
end

Proto.foo(Test)

I believe you should start with describing the problem you are trying to solve, so we can give you ideas and alternative approaches. Inheritance is just one way to do things. There are others as well.

hm difficult to break down, but the library I am working on is trying to provide abstractions for embedded UI. Combinations of button presses tigger actions and combinations of leds. so i am trying to create a widget library that abstract different kind of behaviors.

it works ok well with only functions but then i need to keep track of the state initialization separately. So instead of composing functions i would rather compose very specialized modules (Test in my example) that implement a protocool (Proto) that specifies an additional init function, but then the modules use again to more generalized functions (SpecializedProto in my example).

This isn’t what protocols are for, and probably (I have not run it) doesn’t work like you really want.

For the above declaration to make sense, you’d need to be calling Proto.foo with a %SpecializedProto{} struct.

Protocols solve a particular problem: how to make a function handle many data types that aren’t known by the function’s original authors. A simple example from the stdlib is String.Chars, which defines to_string and is used by the kernel to_string function.

Without protocols, the only way to write String.Chars.to_string would be with many heads:

defmodule String.Chars 
  # non-protocol version
  def to_string(v) when is_integer(v), do: ...
  def to_string(v) when is_map(v), do: ...
  def to_string(v) when is_list(v), do: ...
  # etc
end

However, having custom formatting for user-defined structs would require adding new clauses to String.Chars.to_string which isn’t supported (or particularly practical).

Protocols let you do that kind of dispatching because defimpls don’t have to be supplied all-at-once like defs.


A general suggestion when prototyping something like this: start out by writing a straightforward implementation that does what you want and uses the components you’re creating. Copy-paste code FREELY during this initial part, but try to keep changes to copied code clearly labeled.

Once you’ve got things sketched out enough, only then start thinking about how to DRY things out / reduce boilerplate / do macro-fu / etc. It will be a lot more obvious where you need flexibility like protocols with working code.

2 Likes

This isn’t what protocols are for, and probably (I have not run it) doesn’t work like you really want.

it does work because Test is a atom. so it uses:

defimpl Proto, for: Atom do

so it would be in this case what is in oo speak a singleton

i realized i maybe simplified my example to much so now it want it to be possible with a struct or a Modul-Atom in the call:

defprotocol Proto do
  @spec foo(t()) :: charlist()
  def foo(item)
end

defprotocol SpecializedProto do
  @spec info(t()) ::  charlist()
  def info(t)
end

defimpl Proto, for: Atom do
  @impl true
  def foo(item) do
    apply(item,:foo,[item])
  end
end

defimpl SpecializedProto, for: Atom do
  @impl true
  def info(item) do
    apply(item,:info,[item])
  end
end

defimpl Proto, for: SpecializedProto do
  @impl true
  def foo(item) do
    SpecializedProto.info(item)
  end
end

defmodule Test do
  defstruct []
  defdelegate info(x), to: SpecializedProto.Test
  defdelegate foo(x), to: Proto.Test
end

defimpl SpecializedProto, for: Test  do
  def info(_), do: "test"
end

defimpl Proto, for: Test  do
  defdelegate foo(x), to: Proto.SpecializedProto
end


#IO.puts(Proto.foo(Test))
#IO.puts(Proto.foo(%Test{}))

what is not nice is the use of apply and defdelegate because the implementation is positioned in another module again.

Your example is more readable if some of the reuse of the name SpecializedProto is removed (here are the changed clauses):

defimpl Proto, for: UnimportantName do
  @impl true
  def foo(item) do
    SpecializedProto.info(item)
  end
end

defimpl Proto, for: Test  do
  defdelegate foo(x), to: Proto.UnimportantName
end

I’m still not understanding the intent of passing an atom versus a struct in these functions, but I suspect that’s because that argument never gets used.

For instance, think about the path that a call like SpecializedProto.info(Test) takes:

  • dispatched to the Atom implementation, calls Test.info(Test)
  • Test.info(Test) delegates to SpecializedProto.Test.info(Test)
  • the SpecializedProto implementation for Test structs then receives the atom Test instead of a struct

Perhaps something like this would be better:

defimpl SpecializedProto, for: Atom do
  @impl true
  def info(item) do
    apply(item,:info,[struct(item)])
  end
end

Then you don’t need the defdelegate info(...) on Test, and SpecializedProto.Test always gets the struct that it expects.