3
votes

Is casting to any() a good solution for having Dialyzer accept ETS match patterns?

Dialyzer and match specifications don't play well together, and there doesn't seem to be a standard solution:

Here's a complete example of the solution I'm considering. If matcher('_') on the last line is changed to '_' then Dialyzer complains about a bad record construction, but with the matcher/1 function all seems to be well:


-module(sample).
-record(rec, {field :: number}).
-export([main/1]).

-type matchvar() :: '$1' | '$2' | '$3' | '$4' | '$5' | '$6' | '$7' | '$8' | '$9' | '$10' | '$11' | '$12' | '$13' | '$14' | '$15' | '$16' | '$17' | '$18' | '$19' | '$20' | '$21' | '$22'.


-spec matcher('_' | matchvar()) -> any().
matcher(X) ->
    case node() of
        '$ will never match' -> binary_to_term(<<>>);
        _ -> X
    end.


main(_Args) ->
    ets:match('my_table', #rec{field = matcher('$1')}, 1).

This works because Dialyzer can't tell statically that the unreachable first clause of matcher/1 is unreachable. Since binary_to_term/1 returns any(), Dialyzer infers the return type of matcher/1 to be any().

Is this trick a good way of keeping Dialyzer happy when working with match specs? By "good," I mean:

  • low runtime cost
  • few footguns
  • there isn't a better (safer, faster, more ergonomic) way

I peeked at the implementation of node() and think it's just a pointer dereference, so cost should be low. And '$ will never match' will really never match because node() always returns an atom with an @ in it. But there must be a better way.

There are really two questions here, that I've combined to avoid the X Y Problem:

  1. Is the technique above a good way to get Dialyzer to treat something as any()?
  2. Is getting Dialyzer treat matcher('_') as any() a good solution for working with match specifications?
1

1 Answers

3
votes

I don't think this is a good solution because you're doing useless work (however small) to satisfy something at compile time, and you're tricking dialyzer by doing so.

When this situation arises, I usually extend the record to include the match variables and live with it (usually my records are -opaque so the fields types are controlled in the constructor).

You can always export only a subtype of the actual type instead of using -opaque (elaborating on Pierre Krafft's comment from ERL-892):

-module(sample).

-record(rec, {field :: number() | '_'}).
-type rec() :: #rec{field :: number()}.
-export_type([rec/0]).

-export([main/1]).

-spec main(rec()) -> {[[rec()]], ets:continuation()} | '$end_of_table'.
main(Rec) ->
    ets:match('my_table', Rec#rec{field = '_'}, 1).

-module(sample_user).

-export([main_ok/0, main_error/0]).
main_ok() ->
    sample:main({rec, 1}).

main_error() ->
    sample:main({rec, '_'}).
sample_user.erl
   7: Function main_error/0 has no local return
   8: The call sample:main({'rec', '_'}) breaks the contract (rec()) -> {[[rec()]],ets:continuation()} | '$end_of_table'