I am trying to write some code to exemplify the power of behaviours in Elixir.
As a result, I am trying to implement this diagram:
Basically, I have a Car module, which depends on a behaviour (interface) Car.Brake. There are two possible implementations of this behaviour, the Car.Brake.ABS and Car.Brake.BBS respectively.
The way I modeled this concept into code is as follows:
lib/brake.ex
:
defmodule Car.Brake do
@callback brake :: :ok
end
lib/brake/abs.ex
defmodule Car.Brake.Abs do
@behaviour Car.Brake
@impl Car.Brake
def brake, do: IO.puts("Antilock Breaking System activated")
end
lib/brake/bbs.ex
defmodule Car.Brake.Bbs do
@behaviour Car.Brake
@impl Car.Brake
def brake, do: IO.puts("Brake-by-wire activated")
end
lib/car.exs
defmodule Car do
@type brand :: String.t()
@type brake_system :: module()
@type t :: %__MODULE__{
brand: brand(),
brake_system: brake_system()
}
@enforce_keys [:brand, :brake_system]
defstruct [:brand, :brake_system]
@spec new(brand(), brake_system()) :: t()
def new(brand, brake) do
%__MODULE__{
brand: brand,
brake_system: brake
}
end
@spec brake(t()) :: :ok
def brake(car), do: car.brake_system.brake()
@spec change_brakes(t(), brake_system()) :: t()
def change_brakes(car, brake), do: Map.put(car, :brake_system, brake)
end
The idea here is that I can create a Car and decide (and even change) which breaking system is used at runtime:
> c = Car.new("Honda", Car.Brake.Bbs)
%Car{
brand: "Honda",
breaking_system: Car.Brake.Bbs
}
c = Car.change_brakes(c, Car.Brake.Abs)
%Car{
brand: "Honda",
breaking_system: Car.Brake.Abs
}
So far so good. But now comes the problem. I am using dialyzer to check the types. And while the above code sample will not trigger an error in the compiler (nor in dialyzer) the following code sample will also not trigger any errors, while still being obviously wrong:
c = Car.new("honda", Car.WingsWithNoBreaks)
The issue here is that Car.WingsWithNoBrakes
does not implement the Car.Brakes
behaviour, but no one complains because of my lax typespec @type brake_system :: module()
With this in mind, I have several questions:
My current understanding after asking in the community is that none of this is possible in Elixir, but I would like confirmation/refutation of my belief.
As others have pointed out, my main objective of having more specific specs is not possible using Elixir nor dialyzer.
You can try a possible workaround, as mentioned by @Aleksei matiushkin and use nimble options but this goes against the core of the idea, which is to use typespecs and to help the compiler identify possible issues.
There is a great collection of posts from the community that specify why this is not possible in Elixir.
To quote some of them:
Ultimately the assurances of statically typed languages cannot be given in a dynamic system, because the very system itself has the potential to change. So to reiterate what others have said, there isn’t really a way to achieve what you’re wanting to do, and most of the things that might seem to help give these guarantees, will only give you false confidence.
in Elixir, it’s possible for the very behaviour itself to change at runtime! So how can a compiler give you any assurances against a system which is completely malleable after the point which it would give those assurances.
by Pejrich.
Other users also completed the picture, by providing additional reasoning on why none of this is currently possible, not is it likely to be:
So all in all: (...)
- If attempted it would be full of footguns and false positives.
- Even if you do what can feasably be done today you’re still checking just a subset of error cases, so you cannot get rid of handling error cases at runtime anyways
- Some refactoring like extracting the module name to a variable or even another function makes this problem vastly more difficult outside of a typesystem, given how common that is it’s questionable if the effort to implement the above is worthwhile at all
by LostKobrakai
So all in all, the main reason such a static check like this is not possible, is due to the dynamic nature of the language. You can use some partial workarounds, but this too have to be done at runtime which then raises the question of how the code should behave in such a scenario.