为什么元组在 Elixir 中不可枚举?

Why tuples are not enumerable in Elixir?

我需要一个高效的数组结构,用于包含数千个相同类型的元素并能够进行随机访问。

虽然 list 在迭代和前置方面效率最高,但在随机访问方面太慢,因此不符合我的需求。

地图效果更好。然而,它会导致一些开销,因为它适用于键可以是任何东西的键值对,而我需要一个索引从 0 到 N 的数组。因此我的应用程序使用地图时运行速度太慢。我认为对于像处理随机访问的有序列表这样的简单任务来说,这是不可接受的开销。

我发现元组是 Elixir 中对我的任务最有效的结构。与我机器上的地图相比,它更快

  1. 迭代 - 1_000 为 1.02x,1_000_000 个元素为 1.13x
  2. 随机访问 - 1_000 为 1.68 倍,1_000_000
  3. 为 2.48 倍
  4. 以及复制 - 1_000 为 2.82 倍,1_000_000 为 6.37 倍。

因此,我的元组代码比地图上的相同代码快 5 倍。可能不需要解释为什么 tuple 比 map 更有效。目的达到了,但是大家都说"don't use tuples for a list of similar elements",没人能解释这个规则(这种情况的例子)。

顺便说一句,Python 中有元组。它们也是不可变的,但仍然是可迭代的。

所以,

1.为什么 元组在 Elixir 中不可枚举?是否有任何技术或逻辑限制?

2. 为什么 我不应该将它们用作相似元素的列表?有什么缺点吗?

请注意:问题是"why",而不是"how"。上面的解释只是一个例子,其中元组比列表和映射更有效。

由于您确定需要在那里使用元组,因此您可能会以编译时间为代价来实现所请求的功能。下面的解决方案将编译很长时间(考虑 ≈100s for @max_items 1000.)编译后的执行时间会让你很高兴。在 Elixir 核心中使用相同的方法来构建最新的 UTF-8 字符串匹配器。

defmodule Tuple.Enumerable do
  defimpl Enumerable, for: Tuple do
    @max_items 1000

    def count(tuple), do: tuple_size(tuple)

    def member?(_, _), do: false # for the sake of compiling time

    def reduce(tuple, acc, fun), do: do_reduce(tuple, acc, fun)

    defp do_reduce(_,       {:halt, acc}, _fun),   do: {:halted, acc}
    defp do_reduce(tuple,   {:suspend, acc}, fun)  do
      {:suspended, acc, &do_reduce(tuple, &1, fun)}
    end
    defp do_reduce({},      {:cont, acc}, _fun),   do: {:done, acc}
    defp do_reduce({value}, {:cont, acc}, fun)     do
      do_reduce({}, fun.(value, acc), fun)
    end

    Enum.each(1..@max_items-1, fn tot ->
      tail = Enum.join(Enum.map(1..tot, & "e_★_#{&1}"), ",")
      match = Enum.join(["value"] ++ [tail], ",")
      Code.eval_string(
        "defp do_reduce({#{match}}, {:cont, acc}, fun) do
           do_reduce({#{tail}}, fun.(value, acc), fun)
         end", [], __ENV__
      )
    end)

    defp do_reduce(huge,    {:cont, _}, _) do
      raise Protocol.UndefinedError, 
            description: "too huge #{tuple_size(huge)} > #{@max_items}",
            protocol: Enumerable,
            value: Tuple
    end
  end
end

Enum.each({:a, :b, :c}, fn e ->  IO.puts "Iterating: #{e}" end)
#⇒ Iterating: a
#  Iterating: b
#  Iterating: c

上面的代码明确避免了 member? 的实现,因为当您只请求迭代时编译会花费更多时间。

1。不为 Tuple

实现 Enumerable 的原因

来自 retired Elixir talk 邮件列表:

If there is a protocol implementation for tuple it would conflict with all records. Given that custom instances for a protocol virtually always are defined for records adding a tuple would make the whole Enumerable protocol rather useless.

-- Peter Minten

I wanted tuples to be enumerable at first, and even eventually implemented Enumerable on them, which did not work out.

-- Chris Keele

这如何破坏协议?我会尝试把事情放在一起,从技术角度解释问题。

元组。 元组的有趣之处在于它们主要用于一种 duck typing using pattern matching. You are not required to create new module for new struct every time you want some new simple type. Instead of this you create a tuple - a kind of object of virtual type. Atoms are often used as first elements as type names, for example {:ok, result} and {:error, description}. This is how tuples are used almost anywhere in Elixir, because this is their purpose by design. They are also used as a basis for "records" that comes from Erlang. Elixir has structs for this purpose, but it also provides module Record for compatibility with Erlang. So in most cases tuples represent single structures of heterogenous data which are not meant to be enumerated. Tuples should be considered like instances of various virtual types. There is even @type 指令,允许根据元组定义自定义类型。但请记住它们是虚拟的,并且 is_tuple/1 对于所有这些元组仍然 returns 为真。

协议。 另一方面,Elixir 中的协议是一种 type classes which provide ad hoc polymorphism. For those who come from OOP this is something similar to superclasses and multiple inheritance。协议为您做的一件重要事情是自动类型检查。当您将一些数据传递给协议函数时,它会检查数据是否属于此 class,即该协议是针对此数据类型实现的。如果不是那么你会得到这样的错误:

** (Protocol.UndefinedError) protocol Enumerable not implemented for {}

通过这种方式,Elixir 可以避免您的代码出现愚蠢的错误和复杂的错误,除非您做出了错误的架构决策

总计。 现在假设我们为 Tuple 实现 Enumerable。它所做的是让所有的元组都可枚举,而 Elixir 中 99.9% 的元组并不是这样设计的。所有支票都坏了。悲剧就好像世界上所有的动物都开始嘎嘎叫一样。如果一个元组意外地传递给 Enum 或 Stream 模块,那么您将看不到有用的错误消息。取而代之的是,您的代码将产生意想不到的结果、不可预知的行为并可能导致数据损坏。

2。不使用元组作为集合的原因

好的健壮的 Elixir 代码应该包含 typespecs that help developers to understand the code, and give Dialyzer ability to check the code for you. Imagine you want a collection of similar elements. The typespec for lists and maps may look like this:

@type list_of_type :: [type]
@type map_of_type :: %{optional(key_type) => value_type}

但是你不能为元组编写相同的类型规范,因为 {type} 意味着 "a tuple of single element of type type"。您可以为像 {type, type, type} 这样的预定义长度的元组或像 tuple() 这样的任何元素的元组编写类型规范,但是没有办法仅仅通过设计为类似元素的元组编写类型规范。因此,选择元组来存储您的元素集合意味着您失去了使代码健壮的这种很好的能力。

结论

不使用元组作为相似元素列表的规则是一条经验法则,它解释了在大多数情况下如何在 Elixir 中选择正确的类型。违反此规则可能被视为不良设计选择的可能信号。当人们说 "tuples are not intended for collections by design" 时,这不仅意味着 "you do something unusual",而且意味着 "you can break the Elixir features by doing wrong design in your application".

如果出于某种原因你真的想将元组用作集合并且你确定你知道自己在做什么,那么将它包装成一些 struct 是个好主意。您可以为您的结构实现 Enumerable 协议,而不会有破坏元组周围所有事物的风险。值得注意的是,Erlang 使用元组作为 arraygb_treesgb_sets

内部表示的集合
iex(1)> :array.from_list ['a', 'b', 'c']
{:array, 3, 10, :undefined,
 {'a', 'b', 'c', :undefined, :undefined, :undefined, :undefined, :undefined,
  :undefined, :undefined}}

不确定是否有任何其他技术原因不能将元组用作集合。如果有人能对Record和Enumerable协议之间的冲突提供另一个很好的解释,欢迎他改进这个答案。