dialyzer not detecting guard violation when function is exported

487 views Asked by At

Dialyzer version 2.9. Erts 7.3. OTP 18.

In the following contrived erlang code:

-module(dialBug).
-export([test/0]).
%-export([f1/1]). % uncomment this line

test() ->
    f1(1).

f1(X) when X > 5 ->
    X*2.

When dialyzer is run over the above code it warns that the code won't work as the guard test (X > 5) can never succeed.

However, when I uncomment the 3rd line and export the f1/1 function dialyzer no longer issues any warnings.

I realise that when f1/1 is exported it is impossible for dialyzer to know that the guard clause will fail as external clients can use it. However, why can it no longer determine that test/0 is using f1/1 incorrectly?

1

There are 1 answers

3
zxq9 On

Dialyzer have a few limitations as a type checker. Dialyzer is not a strict typer, it is a loose typer. What this means is that it will only give you a warning when it finds something that is clearly wrong with the way a function is declared as opposed to a certain case where it infers that a caller may be doing something bad.

It will try to infer things about calling sites, but it cannot go beyond what basic typespec declarations can convey. So an integer value can be defined as a neg_integer(), a pos_integer(), a non_neg_integer(), or any integer(), but unless you have clearly defined boundaries to the legal value there is no way to define an arbitrary range from, say, 5..infinity, but you can define a range such as 5..10 and get the result you expect.

The odd part of this is that while guards provide some information to Dialyzer, because it is a permissive/loose typer the real burden is on the coder to spec functions with tight enough definitions that errors at calling sites can be detected.

Here is how these things play out in actual code + Dialyzer output (bear with me, its a bit of a long screen to show all of this completely, but nothing demonstrates the relevant issue better than code):

Original problem

-module(dial_bug1).
-export([test/0]).
%-export([f/1]).

test() ->
    f(1).

f(X) when X > 5 ->
    X * 2.

Dialyzer days:

dial_bug1.erl:5: Function test/0 has no local return
dial_bug1.erl:8: Function f/1 has no local return
dial_bug1.erl:8: Guard test X::1 > 5 can never succeed
 done in 0m1.42s
done (warnings were emitted)

So in a closed world we can see Dialyzer will backtrack to the caller because it has a limited case.

Second variant

-module(dial_bug2).
-export([test/0]).
-export([f/1]).

test() ->
    f(1).

f(X) when X > 5 ->
    X * 2.

Dialyzer says:

done (passed successfully)

In an open world, where the caller could be anybody sending anything, there is no effort to backtrack and check an undeclared, unbounded range.

Third variant

-module(dial_bug3).
-export([test/0]).
-export([f/1]).

-spec test() -> integer().

test() ->
    f(-1).


-spec f(X) -> Result
    when X      :: pos_integer(),
         Result :: pos_integer().

f(X) when X > 5 ->
    X * 2.

Dialyzer says:

dial_bug3.erl:7: Function test/0 has no local return
dial_bug3.erl:8: The call dial_bug3:f(-1) breaks the contract (X) -> Result when X :: pos_integer(), Result :: pos_integer()
 done in 0m1.28s
done (warnings were emitted)

In an open world where we have a declarable open range (in this case, the set of positive integers) the offending calling site will be found.

Fourth variant

-module(dial_bug4).
-export([test/0]).
-export([f/1]).

-spec test() -> integer().

test() ->
    f(1).


-spec f(X) -> Result
    when X      :: pos_integer(),
         Result :: pos_integer().

f(X) when 5 =< X, X =< 10  ->
    X * 2.

Dialyzer says:

done (passed successfully)

In an open world where we have a guarded but still undeclared range we find that Dialyzer will once again not find the offending caller. This is the most significant variant of all, in my opinion -- because we know that Dialyzer does take hints from guards that check types, but evidently it does not take hints from numeric range checking guards. So let's see if we declare a bounded, but arbitrary, range...

Fifth variant

-module(dial_bug5).
-export([test/0]).
-export([f/1]).

-spec test() -> integer().

test() ->
    f(1).


-spec f(X) -> Result
    when X      :: 5..10,
         Result :: pos_integer().

f(X) when 5 =< X, X =< 10  ->
    X * 2.

Dialyzer says:

dial_bug5.erl:7: Function test/0 has no local return
dial_bug5.erl:8: The call dial_bug5:f(1) breaks the contract (X) -> Result when X :: 5..10, Result :: pos_integer()
 done in 0m1.42s
done (warnings were emitted)

And here we see that if we spoon-feed Dialyzer it will do its job as expected.

I am not really sure whether this is considered a "bug" or "a constraint of Dialyzer's looseness". The main point of pain Dialyzer addresses are failed native types, and not numeric bounds.

All that said...

When I have ever had this problem in actual, working code in an actual project useful in the real world -- I already know well in advance whether I am dealing with valid data or not, and in the very few cases I don't I would always write this to either:

  1. Crash outright (Never return a bad result! Just die instead! This is our religion for a reason.)
  2. Return a wrapped value of the form {ok, Value} | {error, out_of_bounds} and let the caller decide what to do with it (this gives them better information in every case).

A guarded example is relevant -- the final example above that has a bounded guard would be the proper version of a crashable function.

-spec f(X) -> Result
    when X      :: 5..10,
         Result :: {ok, pos_integer()}
                 | {error, out_of_bounds}.

f(X) 5 =< X, X =< 10 ->
    Value = X * 2,
    {ok, Value};
f(_) ->
    {error, out_of_bounds}.