That’s cool, @tomekowal! I wonder if nested modules would work? I’m sure the boilerplate code can be reduced significantly with macros. And the dialyzer error messages being cryptic… well, our brains get used to it after some time and we don’t try to understand the words anymore and just take the line number and try to fix the issue there . (Of course, if you never started out with dialyzer it’s gonna be tough. )
Thanks for the pointer to video!
Edit: Yep, it works also when you move the Title & Artist modules into the Song module.
Just made a reduced example that works wonderfully.
defmodule Song do
@opaque title :: binary
@spec title!(binary) :: title()
def title!("" <> t), do: t
@opaque artist :: binary
@spec artist!(binary) :: artist()
def artist!("" <> a), do: a
@type t :: %{artist: artist, title: title}
@spec create(artist, title) :: t()
def create(artist, title), do: %{artist: artist, title: title}
end
defmodule Test do
def test() do
# No error:
Song.create(Song.artist!("Artist"), Song.title!("Title"))
# Wrong order of arguments causes Dialyzer error:
Song.create(Song.title!("Title"), Song.artist!("Artist"))
end
end
Edit: Song.t() can be a normal type, no need for it to be opaque.
Just to make it obvious (because I was fooled by it) @tomekowal’s code causes the dialyzer errors for actually mismatched types, not for actually correct code. I totally didn’t see the comment in Test.test/0.
Before you jump around euphorically and introduce this idea in your code base, here are some issues you should be aware of:
defmodule Zero_Cost_Type_Wrapper_Issues do
def issues() do
song = Song.create(Song.artist!("Artist"), Song.title!("Title"))
# warning Function issues/0 has no local return
_issue1 = "#{inspect(song.title)} by #{song.artist}"
_fix1a = "#{inspect(song.title)} by #{as_string(song.artist)}"
_fix1b = "#{inspect(song.title)} by #{song.artist <> ""}"
case song.title do
# warning The attempt to match a term of type
# 'Elixir.Song':title() against the pattern
# <<65,_/binary>> breaks the opacity of the term
"A" <> _issue2 -> :A
_ -> nil
end
# Fix:
case as_string(song.title) do
"A" <> _fix2 -> :A
_ -> nil
end
end
@spec as_string(any) :: binary
def as_string(s), do: s
end
These are 2 issues I could discover immediately. It’s very likely there are a few others. Another solution is to simply use binary as the type of the struct/map attributes and only give the opaque types to the function parameters.
Yeah, that is the strength of opaque type. It enforces proper encapsulation. Basically, you cannot rely anymore on the type being binary. You need song!\1 or Song.new\1 to tell Dialyzer "I am explicitly converting binary to song. Then, you’d have to do the other way round. Song.get(song) :: binary. That’s why I’d rather define each opaque type as its own separate module. You’ll need constructor and getters.
It is great when you have a struct with private fields that you want to ensure, that nobody else uses. Or if you think the internal implementation can change (e.g. using map vs keyword list) and you don’t want users to rely on it. It is also great when you have some kind of “handle” that can be a PID or an in-memory struct.
However, it is not exactly the same as zero-cost abstraction in other langs.
After reading more I finally came up with a small macro that can be used as a proof of concept, a trivial implementation of the NewType in Elixir.
While it is not a zero cost abstraction, it is very lightweight and I seriously doubt anyone would run into considerable performance issues using it.
I expanded over the idea of @tomekowal and used tuples, mainly to check for type validation. I am sure there is another way of doing the same is_type?/2 function without using tuples, but I think this is fast enough and the code is clear enough, so no harm done:
defmodule NewType do
defmacro deftype(name, type) do
quote do
defmodule unquote(name) do
@opaque t :: {unquote(name), unquote(type)}
@spec new(value :: unquote(type)) :: t
def new(value), do: {unquote(name), value}
@spec extract(new_type :: t) :: unquote(type)
def extract({unquote(name), value}), do: value
end
end
end
@spec is_type?(data :: {atom, any}, new_type :: atom) :: boolean
def is_type?({type, _data}, new_type) when type == new_type, do: true
def is_type?(_data, _new_type), do: false
end
Personally I do not see much value in this over just using a tagged tuple directly.
Though it seems you want to have it like this, as it suites some usecase you have, I am totally fine with that.
Still I have some nitpick about the is_type?/2 predicates naming. has_ and is_ prefixes are only used for guards, while ? suffix is left out for guards. As you have a non guard function, you should therefore name it type?.
Also the function might be more idiomatically written by fully using pattern match rather than quards:
This is quite an interesting conversation. I very much like the example with opaque Dialyzer types which @tomekowal provided.
One thing that might be tried, is adding @compile :inline to the module(s) in which these functions (like Song.new/1 and Song.get/1) are used. This might* convince the Erlang compiler to inline their definitions, which would truly make them “zero cost” at runtime.
*: The rules for when particular functions are and aren’t inlined are a bit vague. Local functions are very easily inlined. Remote functions only sometimes, and I am not entirely sure when (the documentation is not very clear on this, or rather, what I see in practice does not seems to always follow the documentation. If some expert can shed light on it, I’d be very grateful!)