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?
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()
, apos_integer()
, anon_neg_integer()
, or anyinteger()
, 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 as5..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
Dialyzer days:
So in a closed world we can see Dialyzer will backtrack to the caller because it has a limited case.
Second variant
Dialyzer says:
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
Dialyzer says:
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
Dialyzer says:
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
Dialyzer says:
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:
{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.