Dialyzer 无法使用多态类型识别函数中的错误

Dialyzer cannot recognize error in function using polymorphic types

背景

我正在尝试使用透析器进行多态输入。例如,我使用的是著名的 Option 类型(又名,Maybe Monad),现在在许多其他语言中都很流行。

defmodule Test do
  @type option(t) :: some(t) | nothing
  @type some(t) :: [{:some, t}]
  @type nothing :: []

  @spec validate_name(String.t()) :: option(String.t())
  def validate_name(name) do
    if String.length(name) > 0 do
      [{:some, name}]
    else
      nil
    end
  end
end

如您所见,函数 validate_name 应该 return(根据规范定义)[{:some, String.t}] | [].

这里的问题是,实际上,函数是 returning [{:some, String.t}] | nilnil 与空列表 [] 不同。

问题

考虑到这个问题,我希望透析器会抱怨。但是它很乐意接受这个错误的规范:

$ mix dialyzer
Compiling 1 file (.ex)
Finding suitable PLTs
Checking PLT...
[:compiler, :currying, :elixir, :gradient, :gradualizer, :kernel, :logger, :stdlib, :syntax_tools]
PLT is up to date!
No :ignore_warnings opt specified in mix.exs and default does not exist.

Starting Dialyzer
[
  check_plt: false,
  init_plt: '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/dialyxir_erlang-24.2.1_elixir-1.13.2_deps-dev.plt',
  files: ['/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.Book.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.DealingWithListsOfLists.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.Event.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.FlatMapsVSForComprehensions.beam',
   '/home/user/Workplace/fl4m3/grokking_fp/_build/dev/lib/grokking_fp/ebin/Elixir.ImmutableValues.beam',
   ...],
  warnings: [:unknown]
]
Total errors: 0, Skipped: 0, Unnecessary Skips: 0
done in 0m1.09s
done (passed successfully)

此外,无论我在 else 分支中放入什么,结果总是一个“快乐的透析器”。

问题

在这一点上,我能想到的唯一合乎逻辑的解决方案是透析器 与快乐之路有关。意思是,它将忽略我的 else 分支。

如果 dialzyer 只关心快乐的路径,那么这就可以解释问题(毕竟它被称为成功输入),但这也意味着它会完全错过我的代码中的一堆错误。

also means it will totally miss a bunch of errors in my code.

您的理解是正确的,dialyzer 不是静态类型系统,它只能检测到导致确认类型冲突的错误子集。 detailed article 解释了透析器的设计和 trade-offs。

虽然有一些警告标志,例如 underspecs / overspecs / specdiffs,但可以启用它们来检测更多类别的错误:可以找到列表 here and dialyxir supports them as command line options.

如果运行 mix dialyzer --overspecs(或--specdiffs),你应该得到:

your_file.ex:6:missing_range
The type specification is missing types returned by function.

Function:
Test.validate_name/1

Type specification return types:
[{:some, binary()}]

Missing from spec:
nil

运行 mix dialyzer.explain missing_range:

Function spec declares a list of types, but function returns value
outside stated range.

This error only appears with the :overspecs flag.

编辑:从 OTP 25 开始,Dialyzer 将引入 two new flagsmissing_returnextra_return,分别类似于 overspecsunderspecs,但更少误报,在实践中更有用。

missing_return 会捕获上面的 missing_range 示例,但不会像 overspecs 那样返回很多您可能并不真正关心的嘈杂 contract_subtype 警告。

总结

免责声明:这是我为寻找此问题的答案而冒险的总结。对于短版本,检查@sabiwara's .

在与许多人交谈后,我开始明白,只要我的代码中的 1 条路径导致成功执行并与我提供的规范兼容,dialyzer 就不会抱怨。

可能有很多路径中断,但只要 1 条有效,dialzyer 就会很高兴。

在我的具体情况下,因为我有一个 returns [{:some, String.t}] 透析器不抱怨的分支,因为我有 1 个成功的分支。

这可以在 Type Specifications and Eralng 的引用中得到更好的总结:

The other option is then to have a type system that will not prove the absence of errors, but will do a best effort at detecting whatever it can. You can make such detection really good, but it will never be perfect. It's a tradeoff to be made.

事实上,上面提到的文章,有一个例子和我自己的非常相似:

main() ->
    X = case fetch() of
        1 -> some_atom;
        2 -> 3.14
    end,
    convert(X).

convert(X) when is_atom(X) -> {atom, X}.

这也不会触发透析器。根据文章:

From our point of view, it appears that at some point in time, the call to convert/1 will fail. (...) Dialyzer doesn't think so. (...) because there is the possibility that the function call to convert/1 succeeds at some point, Dialyzer will keep silent. No type error is reported in this case.

这很有启发性,我相信这就是我的情况。

透析器会明白发生了什么吗?

公平地说,如果我们使用一些标志,即 --overspecs(对于这种情况)及其姊妹 --underspecs(对于这种情况,我们不需要),透析器可以捕获此错误例)。

经过一番研究,我找到了一个邮件列表,其中以数学格式详细说明了这些标志的行为:

来自它:

Let SpecIn be a set of @spec inputs and RealIn be a set of inputs as inferred by Dialyzer from real code, then:

  • The Input Contract is satisfied when SpecIn <= RealIn (where <= is a non-strict subset operation). See over_in in demo code below.
  • The Input Contract violation is detected by -Wunderspecs option when SpecIn > RealIn. See under_in below.

It is easy to see in the code:

  • It’s OK for over_in to declare that it only accepts :a and :b while it also happens to accept :c. Maybe suboptimal, but fine.
  • It’s NOT OK for under_in to claim that it accepts :a, :b and :c and break if :c is passed. Rejecting :c would break the caller.

Let SpecOut be a set of @spec outputs and RealOut be a set of outputs as inferred by Dialyzer from real code, then:

  • The Output Contract is satisfied when SpecOut >= RealOut (where >= is a non-strict superset operation). See under_out below.
  • The Output Contract violation is detected by -Woverspecs option when SpecOut < RealOut. See over_out below.

It is easy to see in the code:

  • It’s OK for under_out to declare that it returns :a, :b and :c, while currently it only returns :a and :b. Maybe future implementations will return :c as well.
  • It’s NOT OK for over_out to declare that it returns :a and :b, but to also return :c sometimes. Returning :c would break the caller.

确实,如果我 运行 mix dialyzer --overspecs 使用此示例,dialzyer 确实会抱怨,因为:

The Output Contract violation is detected by -Woverspecs option when SpecOut < RealOut. See over_out below.

其中 RealOut 为 [] | [t] | nil ,SpecOut 为 [] | [t]。 因此,检测到违反合同。

显示错误:

lib/test.ex:6:missing_range
The type specification is missing types returned by function.

Function:
Test.validate_name/1

Type specification return types:
[{:some, binary()}]

Missing from spec:
nil

这是一次穿越 Dialyzer 的疯狂旅程,老实说,我非常需要一次修订。在整个磨难过程中,我了解了几个透析器标志,并刷新了成功输入的记忆(我绝对需要这样做)。

感谢大家的参与!