查看源代码 类型规格参考

Elixir 提供了一种用于声明类型和规格的符号。本文档介绍了它们的用法和语法。

Elixir 是一种动态类型语言,因此编译器永远不会使用类型规格来优化或修改代码。但是,使用类型规格仍然有用,因为

  • 它们提供了文档(例如,ExDoc 等工具在文档中显示类型规格)
  • 它们被 Dialyzer 等工具使用,这些工具可以分析带有类型规格的代码以查找类型不一致和可能的错误

类型规格(通常称为类型规格)在不同的上下文中使用以下属性定义

  • @type
  • @opaque
  • @typep
  • @spec
  • @callback
  • @macrocallback

此外,您可以使用 @typedoc 来记录自定义的 @type 定义。

有关定义类型和类型规格的更多信息,请参阅下面的“用户定义类型”和“定义规格”子部分。

一个简单的例子

defmodule StringHelpers do
  @typedoc "A word from the dictionary"
  @type word() :: String.t()

  @spec long_word?(word()) :: boolean()
  def long_word?(word) when is_binary(word) do
    String.length(word) > 8
  end
end

在上面的示例中

  • 我们声明了一个新的类型(word()),它等效于字符串类型(String.t())。

  • 我们使用 @typedoc 描述了该类型,它将被包含在生成的文档中。

  • 我们指定了 long_word?/1 函数接受一个 word() 类型的参数,并返回一个布尔值(boolean()),即 truefalse

类型及其语法

Elixir 提供的类型规格语法类似于 Erlang 中的语法。Erlang 中提供的大多数内置类型(例如,pid())都以相同的方式表示:pid()(或简写为 pid)。参数化类型(如 list(integer))也受支持,远程类型(如 Enum.t())也是如此。整数和原子字面量允许作为类型(例如,1:atomfalse)。所有其他类型都是从预定义类型的并集构建的。一些简写形式是允许的,例如 [...]<<>>{...}

表示类型并集的符号是管道符 |。例如,类型规格 type :: atom() | pid() | tuple() 创建了一个可以是 atompidtuple 的类型 type。这通常在其他语言中称为 总类型

基本类型

type ::
      any()                     # the top type, the set of all terms
      | none()                  # the bottom type, contains no terms
      | atom()
      | map()                   # any map
      | pid()                   # process identifier
      | port()                  # port identifier
      | reference()
      | tuple()                 # tuple of any size

                                ## Numbers
      | float()
      | integer()
      | neg_integer()           # ..., -3, -2, -1
      | non_neg_integer()       # 0, 1, 2, 3, ...
      | pos_integer()           # 1, 2, 3, ...

                                                                      ## Lists
      | list(type)                                                    # proper list ([]-terminated)
      | nonempty_list(type)                                           # non-empty proper list
      | maybe_improper_list(content_type, termination_type)           # proper or improper list
      | nonempty_improper_list(content_type, termination_type)        # improper list
      | nonempty_maybe_improper_list(content_type, termination_type)  # non-empty proper or improper list

      | Literals                # Described in section "Literals"
      | BuiltIn                 # Described in section "Built-in types"
      | Remotes                 # Described in section "Remote types"
      | UserDefined             # Described in section "User-defined types"

字面量

以下字面量也支持在类型规格中

type ::                               ## Atoms
      :atom                           # atoms: :foo, :bar, ...
      | true | false | nil            # special atom literals

                                      ## Bitstrings
      | <<>>                          # empty bitstring
      | <<_::size>>                   # size is 0 or a positive integer
      | <<_::_*unit>>                 # unit is an integer from 1 to 256
      | <<_::size, _::_*unit>>

                                      ## (Anonymous) Functions
      | (-> type)                     # zero-arity, returns type
      | (type1, type2 -> type)        # two-arity, returns type
      | (... -> type)                 # any arity, returns type

                                      ## Integers
      | 1                             # integer
      | 1..10                         # integer from 1 to 10

                                      ## Lists
      | [type]                        # list with any number of type elements
      | []                            # empty list
      | [...]                         # shorthand for nonempty_list(any())
      | [type, ...]                   # shorthand for nonempty_list(type)
      | [key: value_type]             # keyword list with optional key :key of value_type

                                              ## Maps
      | %{}                                   # empty map
      | %{key: value_type}                    # map with required key :key of value_type
      | %{key_type => value_type}             # map with required pairs of key_type and value_type
      | %{required(key_type) => value_type}   # map with required pairs of key_type and value_type
      | %{optional(key_type) => value_type}   # map with optional pairs of key_type and value_type
      | %SomeStruct{}                         # struct with all fields of any type
      | %SomeStruct{key: value_type}          # struct with required key :key of value_type

                                      ## Tuples
      | {}                            # empty tuple
      | {:ok, type}                   # two-element tuple with an atom and any type

内置类型

以下类型也由 Elixir 提供,作为对上述基本类型和字面量类型的简写。

内置类型定义为
term()any()
arity()0..255
as_boolean(t)t
binary()<<_::_*8>>
nonempty_binary()<<_::8, _::_*8>>
bitstring()<<_::_*1>>
nonempty_bitstring()<<_::1, _::_*1>>
boolean()true | false
byte()0..255
char()0..0x10FFFF
charlist()[char()]
nonempty_charlist()[char(), ...]
fun()(... -> any)
function()fun()
identifier()pid() | port() | reference()
iodata()iolist() | binary()
iolist()maybe_improper_list(byte() | binary() | iolist(), binary() | [])
keyword()[{atom(), any()}]
keyword(t)[{atom(), t}]
list()[any()]
nonempty_list()nonempty_list(any())
maybe_improper_list()maybe_improper_list(any(), any())
nonempty_maybe_improper_list()nonempty_maybe_improper_list(any(), any())
mfa(){module(), atom(), arity()}
module()atom()
no_return()none()
node()atom()
number()integer() | float()
struct()%{:__struct__ => atom(), optional(atom()) => any()}
timeout():infinity | non_neg_integer()

as_boolean(t) 用于向用户发出信号,表明给定值将被视为布尔值,其中 nilfalse 将被评估为 false,其他所有内容将被评估为 true。例如,Enum.filter/2 具有以下规格:filter(t, (element -> as_boolean(term))) :: list

远程类型

任何模块都可以定义自己的类型,Elixir 中的模块也不例外。例如,Range 模块定义了一个 t/0 类型来表示范围:此类型可以被称为 Range.t/0。类似地,字符串是 String.t/0,等等。

映射

映射中的键类型允许重叠,如果重叠,则最左边的键优先。如果映射值包含不在允许的映射键中的键,则该值不属于该类型。

如果要表示允许以前在映射中未定义的键,则通常用 optional(any) => any 结束映射类型。

请注意,map() 的语法表示是 %{optional(any) => any},而不是 %{}。符号 %{} 指定空映射的单例类型。

关键字列表

除了 keyword()keyword(t) 之外,为预期关键字列表组合规格可能会有所帮助。例如

@type option :: {:name, String.t} | {:max, pos_integer} | {:min, pos_integer}
@type options :: [option()]

这清楚地表明,只允许这些选项,没有选项是必需的,顺序无关紧要。

它还允许与现有类型组合。例如

type option :: {:my_option, String.t()} | GenServer.option()

@spec start_link([option()]) :: GenServer.on_start()
def start_link(opts) do
  {my_opts, gen_server_opts} = Keyword.split(opts, [:my_option])
  GenServer.start_link(__MODULE__, my_opts, gen_server_opts)
end

用户定义类型

可以使用 @type@typep@opaque 模块属性来定义新类型

@type type_name :: type
@typep type_name :: type
@opaque type_name :: type

使用 @typep 定义的类型是私有的。使用 @opaque 定义的不透明类型是一种类型,其中类型的内部结构将不可见,但该类型仍然是公开的。

通过将变量定义为参数来参数化类型;这些变量随后可用于定义类型。

@type dict(key, value) :: [{key, value}]

定义规格

函数的规格可以按如下方式定义

@spec function_name(type1, type2) :: return_type

可以使用守卫来限制作为函数参数给出的类型变量。

@spec function(arg) :: [arg] when arg: atom

如果要指定多个变量,请用逗号隔开它们。

@spec function(arg1, arg2) :: {arg1, arg2} when arg1: atom, arg2: integer

可以使用 var 定义没有限制的类型变量。

@spec function(arg) :: [arg] when arg: var

此守卫符号仅适用于 @spec@callback@macrocallback

您也可以使用 arg_name :: arg_type 语法在类型规格中命名参数。这在文档中特别有用,因为它可以区分具有相同类型的多个参数(或类型定义中具有相同类型的多个元素)

@spec days_since_epoch(year :: integer, month :: integer, day :: integer) :: integer
@type color :: {red :: integer, green :: integer, blue :: integer}

规格可以重载,就像普通函数一样。

@spec function(integer) :: atom
@spec function(atom) :: integer

行为

Elixir(和 Erlang)中的行为是一种将组件的通用部分(成为行为模块)与特定部分(成为回调模块)分隔和抽象的方式。

行为模块定义了一组函数和宏(称为回调),实现该行为的回调模块必须导出这些函数和宏。此“接口”标识了组件的特定部分。例如,GenServer 行为和函数抽象了“服务器”进程可能想要实现的所有消息传递(发送和接收)和错误报告,以及特定部分,例如此服务器进程必须执行的操作。

假设我们要实现一些解析器,每个解析器解析结构化数据:例如,一个 JSON 解析器和一个 MessagePack 解析器。这两个解析器都将表现相同:它们都将提供一个 parse/1 函数和一个 extensions/0 函数。 parse/1 函数将返回结构化数据的 Elixir 表示形式,而 extensions/0 函数将返回可用于每种数据类型的文件扩展名列表(例如,JSON 文件的 .json)。

我们可以创建一个 Parser 行为

defmodule Parser do
  @doc """
  Parses a string.
  """
  @callback parse(String.t) :: {:ok, term} | {:error, atom}

  @doc """
  Lists all supported file extensions.
  """
  @callback extensions() :: [String.t]
end

如上面的示例所示,定义回调是定义该回调的规格问题,该规格由以下部分组成

  • 回调名称(示例中的 parseextensions
  • 回调必须接受的参数(String.t
  • 回调返回值的预期类型

采用 Parser 行为的模块必须实现所有使用 @callback 属性定义的函数。如您所见,@callback 期望一个函数名,但也期望一个函数规格,就像我们上面在 @spec 属性中使用的规格一样。

实现行为

实现行为非常简单

defmodule JSONParser do
  @behaviour Parser

  @impl Parser
  def parse(str), do: {:ok, "some json " <> str} # ... parse JSON

  @impl Parser
  def extensions, do: [".json"]
end
defmodule CSVParser do
  @behaviour Parser

  @impl Parser
  def parse(str), do: {:ok, "some csv " <> str} # ... parse CSV

  @impl Parser
  def extensions, do: [".csv"]
end

如果采用给定行为的模块没有实现该行为要求的某个回调,则会生成编译时警告。

此外,使用 @impl,您还可以以显式方式确保您正在从给定行为中实现正确的回调。例如,以下解析器同时实现了 parseextensions。但是,由于输入错误,BADParser 正在实现 parse/0 而不是 parse/1

defmodule BADParser do
  @behaviour Parser

  @impl Parser
  def parse, do: {:ok, "something bad"}

  @impl Parser
  def extensions, do: ["bad"]
end

此代码会生成一个警告,让您知道您错误地实现了 parse/0 而不是 parse/1。您可以在 模块文档 中了解更多有关 @impl 的信息。

使用行为

行为很有用,因为你可以将模块作为参数传递,然后你可以*回调*到行为中指定的任何函数。例如,我们可以有一个接收文件名和多个解析器的函数,并根据文件扩展名解析文件。

@spec parse_path(Path.t(), [module()]) :: {:ok, term} | {:error, atom}
def parse_path(filename, parsers) do
  with {:ok, ext} <- parse_extension(filename),
       {:ok, parser} <- find_parser(ext, parsers),
       {:ok, contents} <- File.read(filename) do
    parser.parse(contents)
  end
end

defp parse_extension(filename) do
  if ext = Path.extname(filename) do
    {:ok, ext}
  else
    {:error, :no_extension}
  end
end

defp find_parser(ext, parsers) do
  if parser = Enum.find(parsers, fn parser -> ext in parser.extensions() end) do
    {:ok, parser}
  else
    {:error, :no_matching_parser}
  end
end

你也可以直接调用任何解析器:CSVParser.parse(...)

注意,你不需要定义行为来动态分发模块,但这些功能通常是相辅相成的。

可选回调

可选回调是回调模块可以选择实现的回调,但不是必需的。通常,行为模块根据配置知道是否应该调用这些回调,或者它们使用 function_exported?/3macro_exported?/3 检查回调是否已定义。

可选回调可以通过 @optional_callbacks 模块属性定义,该属性必须是一个关键字列表,其中函数或宏名称作为键,参数个数作为值。例如

defmodule MyBehaviour do
  @callback vital_fun() :: any
  @callback non_vital_fun() :: any
  @macrocallback non_vital_macro(arg :: any) :: Macro.t
  @optional_callbacks non_vital_fun: 0, non_vital_macro: 1
end

Elixir 标准库中可选回调的一个例子是 GenServer.format_status/2.

检查行为

@callback@optional_callbacks 属性用于在定义模块上创建 behaviour_info/1 函数。此函数可用于检索该模块定义的回调和可选回调。

例如,对于上面“可选回调”中定义的 MyBehaviour 模块

MyBehaviour.behaviour_info(:callbacks)
#=> [vital_fun: 0, "MACRO-non_vital_macro": 2, non_vital_fun: 0]
MyBehaviour.behaviour_info(:optional_callbacks)
#=> ["MACRO-non_vital_macro": 2, non_vital_fun: 0]

使用 iex 时,还提供 IEx.Helpers.b/1 助手。

陷阱

使用类型规范时,有一些已知的陷阱,这些陷阱将在下面介绍。

string() 类型

Elixir 不鼓励使用 string() 类型。 string() 类型指的是 Erlang 字符串,在 Elixir 中被称为“字符列表”。它们不指的是 Elixir 字符串,Elixir 字符串是 UTF-8 编码的二进制文件。为了避免混淆,如果你尝试使用 string() 类型,Elixir 会发出警告。你应该根据需要使用 charlist()nonempty_charlist()binary()String.t(),或者使用这些类型的几种文字表示形式。

注意 String.t()binary() 对分析工具来说是等价的。尽管对于阅读文档的人来说,String.t() 表示它是 UTF-8 编码的二进制文件。

引发错误的函数

类型规范不需要表明函数可以引发错误;任何函数在给定无效输入时都可能随时失败。在过去,Elixir 标准库有时使用 no_return() 来表示这一点,但这些用法已被移除。

no_return() 类型也不应该用于返回但其目的是“副作用”的函数,例如 IO.puts/1。在这些情况下,预期的返回类型是 :ok

相反,no_return() 应该用作永远不能返回值的函数的返回类型。这包括永远循环调用 receive 的函数,或者专门用于引发错误的函数,或者关闭 VM 的函数。