Elixir是一种动态类型语言。Elixir中的类型在程序运行时检查,而不是在编译时检查。如果它们不匹配,则会引发异常。在静态类型语言中,类型是在编译时检查的。这可以帮助我们编写正确、可理解和可重构的代码。但它也引入了对类型的某种关注,将其作为应用程序的基础。一个有趣的概念是使用类型来为您的业务领域建模。在 Haskell、F# 和 OCaml 等语言中,这通常是使用代数数据类型 (ADT) 完成的——它们通过将类型与乘积 (AND) 和总和 (OR) 类型聚合来构建复合数据类型。在静态分析工具 Dialyzer 的帮助下,您可以使用 ADT 来限制应用程序允许状态的数量。这减少了错误溜进来的机会。

Elixir 中使用 Dialyzer 进行类型声明

Elixir(和其他 BEAM 语言)中,检查类型规范通常使用Dialyzer完成。Dialyzer 不同于 Haskell、OCaml 甚至 TypeScript 的类型系统。Dialyzer 需要向您证明您的代码不正确,而不是您向编译器证明您的代码是正确的。

这使得 Dialyzer 的要求相当宽松。如果类型有办法工作,Dialyzer 会假设您知道自己在做什么,并且类型确实可以工作。但是推理类型和捕捉偶尔出现的错误仍然很有用。

Elixir中透析器的快速介绍

你可以在 Elixir 中使用 Dialyzer,通过为你的函数添加类型规范@spec

#          (1)      (2)           (3)
  @spec plus_one(integer) :: integer
 
  def plus_one(x), do: x + 1
end

在这里,我们只写函数的名称 (1) 及其类型作为参数 (2),然后是函数的返回类型 (3)。

您可以在 Elixir 的文档中找到要使用的基本类型列表。

您还可以通过创建自己的类型别名@type。为此,您需要提供别名的名称 (1) 及其类型 (2)。

#         (1)        (2)
  @type counter :: integer

如果您使用 Elixir 语言服务器,这就是您需要做的所有事情:您的插件/扩展将通知您 Dialyzer 不喜欢的任何规范。否则,您需要运行mix dialyzer任务来检查您的类型。

你可以在 Elixir 的网站和Elixir 的 typespec 文档中找到更多关于Dialyzer 的使用。

现在我们可以指定我们的 Elixir 代码,让我们深入研究代数数据类型。

代数数据类型

虽然这个名字听起来很吓人(哦,代数?),但代数数据类型相对简单。

本节将重点介绍 ADT 的两个主要部分:乘积 (AND) 和和 (OR) 类型。

产品类型

产品类型无处不在。产品类型只是具有两个或多个字段的类型,每个字段都包含一个数据类型。您也可以将它们视为 AND 类型。

例如,元组是一种产品类型:

@type tuple(a, b) :: {a, b}

它采用两种数据类型——ab——并返回一个包含这两种数据类型的类型。

Elixir 中,我们还使用带有命名字段的产品类型——结构。

defmodule Person do
  defstruct first_name: "Gints", last_name: "Dreimanis"
end

让我们看看这个例子中的类型:

@type t() :: %__MODULE__{
        first_name: String.t(),
        last_name: String.t()
      }

通常,您可以将类型视为可能值的集合。例如,布尔值有两个可能的值::true:false。交通灯颜色的类型具有以下三个可能值之一::green:yellow:red

如果我们有两个 sizeab的类型,那么这些类型的 product 类型将包含a * b值——这些类型的 size 的乘积。如果您制作布尔值和交通灯类型的产品类型——例如,将布尔值和交通灯放在一个元组中——您将有2 * 3 = 6可能的值。

{:green, :true}
{:yellow, :true}
{:red, :true}
{:green, :false}
{:yellow, :false}
{:red, :false}

总和类型

历史上不太常见的类型是 sum 类型。

与产品类型相比,总和类型为您提供两个(或更多)选项之一。您也可以将它们视为 OR 类型。

我们也在 Elixir 中使用这些。例如,结果元组是求和类型。

@type result(a, b) :: {:error, a} | {:ok, b}

在结果元组中,我们可能会遇到类型错误a 类型成功b

或者,例如,我们可以为可选值设置一个 sum 类型。

@type optional(a) :: :error | {:ok, a}

但它也可以仅用于制作替代品列表。

@type direction :: :north | :west | :south | :east

如果您在 sum 类型中列出两种类型,则生成的类型可以从一组值或另一组值中选择一个类型。因此,它的大小通常是这些类型的大小之和。

在使用 Dialyzer 时,上述情况可能并不总是正确的:您可以将两个重叠的集合放在一起。为了使陈述成立,它们需要用它们来自的集合进行标记——我们上面定义的结果类型就是一个很好的例子。

这就是人们通常所说的代数数据类型。

代数数据类型还有更多内容

通过将总和和乘积放在一起,我们就有了类似于我们在学生时代就知道和喜爱的代数:乘法、总和和变量。

当然,代数类型不仅限于此:还有递归、指数等。如果您想更深入地研究该主题,请查看它们在 Haskell 等语言中的外观。

代数数据类型如何帮助领域建模

我们 Elixir 程序员通常不会考虑求和类型。在 Elixir 中对域建模的主要工具是 struct,这是一种具有命名字段的产品类型。

虽然这对大多数事情来说已经足够了,但有时使用 sum 类型也是有益的。

让我们看一个例子。

自定义看板

假设我们需要创建一个自定义看板问题的表示。

我们的问题可能处于以下状态之一:

  • 搜索受让人:在这种情况下,它应该既没有受让人也没有审阅者
  • 尚未开始:在此和以下状态下,它应该有一个受理人但没有审阅者
  • 进行中
  • In review:在这个和下面的状态下,应该有一个assignee和一个reviewer
  • 完毕

所有问题也都有名称和描述。

一开始,人们可能会想使用一个简单的结构。

defmodule Issue do
  defstruct name: "",
            description: "",
            state: :searching_for_assignee
            assignee: nil,
            reviewer: nil
end

但是正如我们在产品类型部分看到的,一个简单的产品类型有很多可能的值,其中一些可能不符合我们的要求。

例如,我们可以创建一个搜索受让人但仍有受让人和审阅者的问题。

iex(1)> %Issue{name: "wrong issue", description: "not good at all", state: :searching_for_assignee, assignee: "Jorge Luis Borges", reviewer: "Gabriel García Márquez"}
%Issue{
  assignee: "Jorge Luis Borges",
  description: "not good at all",
  name: "wrong issue",
  reviewer: "Gabriel García Márquez",
  state: :searching_for_assignee
}

虽然通常可以避免这样做,但让它变得不可能更简单。我们对此有句俗语:“使非法国家无法代表”。

为此,我们需要创建一个 sum 类型,涵盖我们想要允许的所有状态。它将使我们能够通过用值的总和代替值的乘积来消除一些错误的状态。

首先,让我们将stateassigneereviewer字段合并为一个字段:state

defstruct name: "",
          description: "",
          state: :searching_for_assignee

之后,让我们定义一个 sum 类型state,它将包含我们指定的选项。

让我们再看看他们。我们的问题可能处于以下状态之一:

  • 寻找受让人
  • 尚未开始,但有受让人
  • 进行中并与受让人
  • 与受让人和审阅者一起审阅
  • 完成,受让人和审阅者留作历史记录

定义一个几乎像这样读取的类型非常容易:

@type state ::
        :searching_for_assignee
        | {:not_started, String.t()}
        | {:in_progress, String.t()}
        | {:in_review, String.t(), String.t()}
        | {:done, String.t(), String.t()}

为了更容易理解,我们可以为受理人和审阅者创建别名。

@type assignee :: String.t()
@type reviewer :: String.t()

现在,该类型看起来与我们的规则列表完全一样。

@type state ::
        :searching_for_assignee
        | {:not_started, assignee}
        | {:in_progress, assignee}
        | {:in_review, assignee, reviewer}
        | {:done, assignee, reviewer}

剩下的就是Issue使用我们的状态类型为模块 struct () 创建一个类型规范。

@type t() :: %__MODULE__{
        name: String.t(),
        description: String.t(),
        state: state
      }

这是完整的模块代码:

defmodule Issue do
  defstruct name: "",
            description: "",
            state: :searching_for_assignee
 
  @type assignee :: String.t()
  @type reviewer :: String.t()
  @type state ::
          :searching_for_assignee
          | {:not_started, assignee}
          | {:in_progress, assignee}
          | {:in_review, assignee, reviewer}
          | {:done, assignee, reviewer}
 
  @type t() :: %__MODULE__{
          name: String.t(),
          description: String.t(),
          state: state
        }
end

现在我们可以测试这种类型规范是否可以阻止我们犯逻辑错误。

我们将创建一个为问题添加审阅者的函数,但我们会在其中添加一个错误:它不会改变问题的状态。我们还将添加一个类型规范。

@spec add_assignee(Issue.t(), assignee) :: Issue.t()
def add_assignee(%{state: :searching_for_assignee} = issue, assignee_name) do
  %{issue | state: {:searching_for_assignee, assignee_name}}
end

Dialyzer 将在此处正确返回类型错误:

lib/issue.ex:21:invalid_contract
The @spec for the function does not match the success typing of the function.
 
Function:
Issue.add_assignee/2
 
Success typing:
@spec add_assignee(%{:state => :searching_for_assignee, _ => _}, _) :: %{
  :state => {:searching_for_assignee, _},
  _ => _
}

这有点神秘,但它基本上意味着Issue.add_assignee没有编译,我们应该调查一下!?

如您所见,代数数据类型使我们免于犯错。事实证明,他们并不是真正的可怕怪物,而是朋友。

Elixir应用程序的代数数据类型的好处

Elixir 应用程序采用代数数据类型是一个两步决策过程。

第一步是选择使用 Dialyzer 和 typespecs。Dialyzer提供了任何具有静态类型的语言的大部分好处:

  • 更容易发现你在代码中犯的错误。
  • 类型提供了关于代码的额外信息:它做什么以及它操作什么值。这在尝试理解代码时很有帮助。
  • 编写代码后,类型可以确保代码仍然执行相同的操作(类似于测试),因此更容易重构。

一旦您将 Dialyzer 用于您的代码库,就应该自然而然地考虑代数数据类型,并带来一些好处:

  • 正如我们在示例中看到的那样,sum 类型尤其可以让您减少可能的状态并使非法状态无法表示。
  • 在你的词汇表中使用 AND 和 OR 可以帮助你以一种即使对于非开发人员(领域专家)也很直观和易于理解的方式构建复合类型。

当然,ADT 只是软件正确性的一种工具——绝对不是灵丹妙药。但总的来说,ADT 对任何使用使用 Dialyzer 的 Elixir 代码库的人来说都是一个有用的概念。

如果您的代码库不使用 Dialyzer,那么您的首要目标应该是引入它,这比在编写类型规范时更改类型的方式要大得多。不幸的是,这项工作超出了本文的范围。