Guard test breaks the opacity of its argument

I have the following code where I test for the existence of an ETS table:

case :ets.whereis(:metrics) do
      :undefined                  -> {:error, :table_not_created}
      tab when is_reference(tab)  -> {:ok, :ready}
      _                           -> {:error, :unable_to_veify_table}
end

However, the clause is giving a weird error:

Guard test is_reference(_tab@1::ets:tid()) breaks the opacity of its argument

What does this mean and how can I fix it?

You can’t really fix it. :ets.whereis returns the opaque type tid() but your guard clause does typecheck on it. This means you’re dealing with internals you’re not supposed to deal with.

3 Likes

I just read about opaque types yesterday in Programming Erlang and my understanding is a little hazy.

In this case, :ets.whereis returns an opaque value, and my assumption is that this value can only be poked and prodded by other functions in the :ets module.

My nascent mental model is akin to handling radioactive materials: the material is locked away in a safe box and we can only handle it by sticking our hands into the gloves attached to the box.

Am I off base with my thinking?

1 Like

In my mind an opaque type is the equivalent to the OO design guideline that all instance data should have non-public access.

In OO one motivating factor is that you don’t want anything but the instance methods mutating the instance data. In a world where everything is immutable by default that really isn’t an issue which explains to some degree why functional languages can rely on plain data structures so much.

However direct access of the client to the data to the internal organization of the data structure still couples the client to that specific organization.

An opaque type breaks that type of coupling as the client is now forced to use the functions that are supplied with the data structure to extract any information.

When done right an opaque type can give the freedom to radically change the implementation while keeping the API of client facing functions stable (example - here names could be typed as @opaque).

2 Likes

I understand that argument, but since :ets.whereis is part of the public API, shouldn’t the result not be opaque? My understanding is that it’s fine if I depend on the result a given public function returns me, because that’s part of the public API and not an implementation detail.

Am I missing something?

It’s complaining about is_reference - right now tab is a reference but in the future that reference may be buried deeper inside another data structure (now representing an ets table) altogether.

It actually used to be an integer not very long ago, so :ets is a very good example of this.

4 Likes

@peerreynders
If you change the return value of a public function, you change the public API of the module.

Yes, I defend my code should not depend on internal data structures. That’s why modules usually don’t expose their internals to the world and that’s also the why of getters and setters. But if I can’t even rely on the results of your public API, what can I rely on ?

Values returned from a public API are part of the API. Just like if someone changes :ets.whereis to receive 10 parameters instead of 1, that is a change to the public API.

Do we agree on this? I feel I need to establish some common ground before moving on.


@garazdawi

Interesting feedback, thanks for the info !

You can rely on the fact that all the :ets functions work correctly if you pass them the result of :ets.new, regardless of what the actual return value is. As long as you do that, and don’t assume anything about the actual data in the tid type, you shouldn’t experience any breaking changes if the tid type is changed.

3 Likes

I am sorry but I seem to be understanding something among the lines of “don’t bother with the return values of the public API and you will be fine”.

Which sounds completely … well… let’s say unreasonable. Am I the only one that sees it this way?

You can rely on the documentation. For ets, the documentation says that you will get a tid() or undefined back. The documentation never states what tid() is, so you cannot assume in your code what it is.

The same is true for any opaque datatypes, i.e. dict(), array() etc etc.

1 Like

Alright so what can I do with a tid() then?
Are there any special uses for tid()s I am not aware of besides checking it’s a value != from :undefined?

It’s unreasonable when viewed with a static typing mindset. To some degree that is what The Dark Path is about.

It doesn’t mean static typing is evil but if the type system isn’t up to snuff static typing can lead to unnecessary coupling.


Then again it’s no different than being handed an instance reference in OO. The internal structure of the instance is unknown to the client - only the instance methods need to know what the internal structure of the instance is.

You can pass it to any function in ets that takes a tid().

1 Like

… and the :ets module will know which table you are talking about.

I don’t think this is a static vs dynamic typing thing. Statically typed languages wrap built in types in new types all the time which effectively creates opaque types.

@Fl4m3Ph03n1x The return value of the api is tid(). You can use it for any function that takes a tid(). Opaqueness just means that you shouldn’t look deeper into it to try to figure out what a tid() is composed of because tid() doesn’t make any promises about that.

It’s a little like a MapSet. MapSet.new([1,2,3]) returns a MapSet.t and you can pass it to functions that accept that type. You shouldn’t try to do break apart the MapSet and grab the internal map out of it to try to mess with. That might change.

2 Likes

It depends on the situation. In Java a custom class can wrap anything but with generics details leak all the time leading to coupling - sometimes necessary, other times not so much. In some circumstances this leads to overuse of strings to avoid types.

In languages like Haskell and OCaml there are ways to keep that coupling under control - if you know what you are doing.

Yeah, opaque types in OCaml and C++ are super common. Like in OCaml the *.ml file:

type t = string

And the *.mli file:

type t

So inside the ml file they know it is a string, outside the file it is opaque and any other module that gets it can only store it around or pass it into some function that accepts it, but doesn’t know that it is a string nor can do anything with it that is not exposed from the original module.

Opaque types are a super static type thing, not a dynamic type thing.

1 Like

The point of opaque types is that the module, which defines the type has functions, which know how to handle the data. In the case of ets the :ets module knows what the data behind tid() is and what to do with it. new does return it, while lots of other functions use it. It does not matter if the current implementation of tid is an integer or a reference, as any code which is not inside of :ets is just supposed to hold on to the value, but not look at what’s inside. This allows :ets to refactor what tid() actually is without breaking client code.

Another example of an opaque type is MapSet.t. Behind the scenes mapsets are implemented using a map. But a map alone cannot guarantee the uniqueness required for sets. Therefore the datatype needs to be opaque so that (in a perfect world) nobody ever directly manipulates the data, but people need to use functions on the MapSet module for manipulation. The module can then guarantee that the constraints of a set datatype are adhered to.

Like mentioned for :ets the implementation for MapSet did already change over the livetime of elixir as well.

There are also tradeoffs to be made when using opaque types:

Only the single module defining the opaque type can actually do something with the data it holds. So the module needs to have functions for everything a client might want to do with said data. No other module can enhance or alter the behaviour around it without breaking the type contract.

Another caveat for opaque types is that you need to be careful with longterm storage of such types. If the runtime updates, but the stored value doesn’t it can result in failures.

4 Likes