I've created the snippet below based on this tutorial. The last two lines (feed_squid(FeederRP)
and feed_red_panda(FeederSquid)
) are obviously violating the defined constraints, yet Dialyzer finds them okay. This is quite disappointing, because this is exactly the type of error I want to catch with a tool performing static analysis.
There is an explanation provided in the tutorial:
Before the functions are called with the wrong kind of feeder, they're first called with the right kind. As of R15B01, Dialyzer would not find an error with this code. The observed behaviour is that as soon as a call to a given function succeeds within the function's body, Dialyzer will ignore later errors within the same unit of code.
What is the rationale for this behavior? I understand that the philosophy behind success typing is "to never cry wolf", but in the current scenario Dialyzer plainly ignores the intentionally defined function specifications (after it sees that the functions have been called correctly earlier). I understand that the code does not result in a runtime crash. Can I somehow force Dialyzer to always take my function specifications seriously? If not, is there a tool that can do it?
-module(zoo).
-export([main/0]).
-type red_panda() :: bamboo | birds | eggs | berries.
-type squid() :: sperm_whale.
-type food(A) :: fun(() -> A).
-spec feeder(red_panda) -> food(red_panda());
(squid) -> food(squid()).
feeder(red_panda) ->
fun() ->
element(random:uniform(4), {bamboo, birds, eggs, berries})
end;
feeder(squid) ->
fun() -> sperm_whale end.
-spec feed_red_panda(food(red_panda())) -> red_panda().
feed_red_panda(Generator) ->
Food = Generator(),
io:format("feeding ~p to the red panda~n", [Food]),
Food.
-spec feed_squid(food(squid())) -> squid().
feed_squid(Generator) ->
Food = Generator(),
io:format("throwing ~p in the squid's aquarium~n", [Food]),
Food.
main() ->
%% Random seeding
<<A:32, B:32, C:32>> = crypto:rand_bytes(12),
random:seed(A, B, C),
%% The zoo buys a feeder for both the red panda and squid
FeederRP = feeder(red_panda),
FeederSquid = feeder(squid),
%% Time to feed them!
feed_squid(FeederSquid),
feed_red_panda(FeederRP),
%% This should not be right!
feed_squid(FeederRP),
feed_red_panda(FeederSquid).
Minimizing the example quite a bit I have these two versions:
First one that Dialyzer can catch:
Then the one with no warnings:
Dialyzer's warnings for the version it can catch are:
... and is a case of preferring to trust its own judgement against a user's tighter spec.
For the version it doesn't catch, Dialyzer assumes that the
feed_squid/1
argument's typefun() -> bamboo
is a supertype offun() -> none()
(a closure that will crash, which, if not called withinfeed_squid/1
, is still a valid argument). After the types have been inferred, Dialyzer cannot know if a passed closure is actually called within a function or not.Dialyzer still gives a warning if the option
-Woverspecs
is used:... warning that nothing prevents this function to handle the other feeder or any given feeder! If that code did check for the closure's expected input/output, instead of being generic, then I am pretty sure that Dialyzer would catch the abuse. From my point of view, it is much better if your actual code checks for erroneous input instead of you relying on type specs and Dialyzer (which never promised to find all the errors anyway).
WARNING: DEEP ESOTERIC PART FOLLOWS!
The reason why the error is reported in the first case but not the second has to do with the progress of module-local refinement. Initially the function
feed_squid/1
has success typing(fun() -> any()) -> any()
. In the first case the functionfeed_squid/1
will first be refined with just theFeederRP
and will definitely returnbamboo
, immediately falsifying the spec and stopping further analysis ofmain/0
. In the second, the functionfeed_squid/1
will first be refined with just theFeederSquid
and will definitely returnsperm_whale
, then refined with bothFeederSquid
andFeederRP
and returnsperm_whale
ORbamboo
. When then called withFeederRP
the expected return value success-typing-wise issperm_whale
ORbamboo
. The spec then promises that it will besperm_whale
and Dialyzer accepts it. On the other hand, the argument should befun() -> bamboo | sperm_whale
success-typing-wise, it isfun() -> bamboo
so that leaves it with justfun() -> bamboo
. When that is checked against the spec (fun() -> sperm_whale
), Dialyzer assumes that the argument could befun() -> none()
. If you never call the passed function withinfeed_squid/1
(something that Dialyzer's type system doesn't keep as information), and you promise in the spec that you will always returnsperm_whale
, everything is fine!What can be 'fixed' is for the type system to be extended to note when a closure that is passed as an argument is actually used in a call and warn in cases where the only way to 'survive' some part of the type inference is to be
fun(...) -> none()
.