Elixir 有一个 for 的特殊形式,称为“列表推导式 (list comprehension)”,大多数人对它的全部能力并不完全理解。它可以做的不仅仅是一个简单的 for 循环,实际上它有点像 Enum 和 Stream 模块聚在一起并且有一个宏。我想让你知道这件事!

这是一个标准的Elixir示例,从文件中读取行,并对其做映射,然后过滤,最后将其转换为一个 Map:

File.read!("my_lines.txt") # key=val\n
|> Enum.filter(fn str -> "" == str end)
|> Enum.map(fn line -> 
      [key, val] = String.split(line, "=", trim: true)
        {key, val}
    end)
|> Enum.into(%{})

这是标准的 Elixir 程序,将每个步骤分解为管道(pipeline)流,然后做你所期望的操作。但是存在一个缺点,此迭代的每个步骤都会创建相对应的更改的列表副本。在大多数情况下,这并不会产生什么问题,Erlang VM 可以轻松地对其进行垃圾回收。

但是,如果 my_lines.txt 是一个大文件,我们可能会遇到内存问题。这时候,我们可以用 Stream 来进行处理:

File.stream!("my_lines.txt") # key=val\n
|> Stream.filter(fn str -> "" == str end)
|> Stream.map(fn line -> 
      [key, val] = String.split(line, "=", trim: true)
        {key, val}
    end)
|> Enum.into(%{})

在编程术语中,Stream 就是我们所说的“惰性(Lazy)”容器,这意味着直到我们在上面的代码中调用 Enum.into(%{}) ,代码才会被执行。Enum 函数不是惰性的(eager),因此每一步调用都会被立即执行。Stream 仅仅是创建了一个结构,它构建了要应用于列表的操作,当执行时,它会遍历创建流的输入列表、范围、枚举或函数。

如果我们使用 for 重新实现:

for line <- File.stream!("my_lines.txt"), line != "", into: %{} do
  [key, val] = String.split(line, "=", trim: true)
    {key, val}
end

第一行完成了此代码中的大部分工作,因此让我们将其分解:

  • line <- File.stream(..), 是我们的迭代源,因为它具有时髦的左指向箭头,在我们的例子中是流式传输文件。 for 不是惰性的,所以它将完全执行类似于 Enum 的流。
  • 迭代后的下一个参数, line != "" 是一个保护函数 (guard function),相当于我们上面的 Enum.filter
  • 最后一个参数 into: %{} 允许我们使用 Collectable 协议将结果收集到 Map 中。它在功能上等同于上面的 Enum.into

这个语句的美妙之处在于它的工作方式类似于 Stream,它对列表只进行一次迭代,同时我们不需要混合和匹配使用 Enum/Stream 函数。我们甚至可以使用 Stream 来启动它!其主要的缺点就是清晰度,这段代码很冗长,并且不如我们的管道操作清晰。

Reduce

我们也可以在这里使用 reduce 关键字而不是 into ,如下所示:

for line <- File.stream!("my_lines.txt"), line != "", reduce: %{} do
  acc ->
      [key, val] = String.split(line, "=", trim: true)
        Map.put(acc, key, val)
end

关键的变化是,在 do 块之后,我们需要有一个 var_name -> 的右箭头来命名累加器并返回一个新的累加器。这个例子并不是 reduce 的用法的全部,但它让我们可以精细地控制累加器的更新方式。

更多?

我们还可以遍历多个迭代器,如下所示:

for x <- [1, 2], y <- [2, 3] do
    x * y 
end 
# [2, 3, 4, 6]

这个代码来自 Elixir Docs,这证明了此功能的使用频率非常高。我认为在进行代码挑战或实现高级 FizzBuzz 时它可能会很方便!如果你有一个很好的用例,请告诉我!

讨论

for 特殊形式是迭代的宝贵工具,不容忽视!也就是说,它也有点锋利...能够为未来的编码人员生成真正不可读的代码行,同时让您感觉自己像个天才。

递归

Elixir在递归方面也非常出色,事实上,Erlang社区使用它的频率比我们高得多,尽管它确实需要重新思考我们应该如何编写它!这里是它的代码:

def parse(), do: parse(File.read!("my_lines.txt"), %{})
def parse([], acc), do: acc # End Condition
def parse(["" | rest], acc), do: parse(rest, acc) # Skip empty
def parse([line | rest], acc) do # Default Case
  [key, val] = String.split(line, "=", trim: true)
    parse(rest, Map.put(acc, key, val))
end

在这里我非常倾向于模式匹配,以及从上到下阅读的最佳方式:

  • 将文本读入行列表中,并设置一个空 Map 作为我们的累加器
  • 如果列表为空,操作结束,并返回累加器
  • 如果该行为空,忽略该行并继续解析,也可以在此处使用守卫 Guard。
  • 最后解析行,将其添加到累加器并继续解析。

这被称为“尾递归”,因为它在我们函数的“尾部”或“末端”进行递归。Eralng VM 将其优化为一个有效的循环,以避免栈溢出。你可能想知道为什么 Erlang 社区可能更喜欢这种风格?

  • 一方面,它是社区的文化,人们被教育成这样做,他们也是这样做的。任何的规范总是如此。
  • 它可以精细控制分配发生的情况,如何 filter/map/reduce,您不需要任何这些概念,您只需编写代码即可。
  • 最后,它们没有 do/enddef,因此它们的函数头不会显得很冗长。