【问题标题】:Writing the function "once" in Elixir在 Elixir 中编写函数“一次”
【发布时间】:2018-05-07 05:20:39
【问题描述】:

我来到 Elixir 的主要背景是 Javascript。在 JS 中,可以编写一个高阶函数“once”,它返回一个函数,该函数只调用传入的函数一次,并在后续调用中返回先前的结果——诀窍是操纵通过闭包捕获的变量:

var once = (func) => {
    var wasCalled = false, prevResult;
    return (...args) => {
        if (wasCalled) return prevResult;
        wasCalled = true;
        return prevResult = func(...args);
    }
}

在我看来,无法在 Elixir 中创建此函数,因为它具有不同的变量重新绑定行为。有没有其他聪明的方法可以通过模式匹配或递归来做到这一点,还是不可能?如果没有宏,我想那些可能会启用它。谢谢

【问题讨论】:

  • 顺便说一下,为了您将来的参考,您所要求的称为“memoization”。以防万一您对此有更多疑问,最好了解未来 Google 搜索的正确术语。

标签: functional-programming elixir immutability memoization


【解决方案1】:

使用当前进程字典:

defmodule A do
  def once(f) do
    key = make_ref()
    fn ->
      case Process.get(key) do
        {^key, val} -> val
        nil -> 
          val = f.()
          Process.put(key, {key, val})
          val
      end
    end
  end
end

或者如果函数将跨进程传递,则可以使用ets 表:

# ... during application initialization
:ets.new(:cache, [:set, :public, :named_table])


defmodule A do
  def once(f) do
    key = make_ref()
    fn ->
      case :ets.lookup(:cache, key) do
        [{^key, val}] -> val
        [] -> 
          val = f.()
          :ets.insert(:cache, {key, val})
          val
      end
    end
  end
end

Application.put_env / Application.get_env 也可用于保持全局状态,但通常用于配置设置。

【讨论】:

  • 有趣,我不熟悉这个。这会被认为是惯用语吗?还是有一种完全不同的方法来解决这个函数要解决的问题(缓存昂贵函数的结果),这对 Elixir 来说更典型/更适合?谢谢。
  • 惯用语?不。我认为惯用的方法是定义一个 GenServer,它公开一个用于计算函数的客户端 API,并在 GenServer 的内部状态中维护任何缓存的结果。
  • @MikeBuhot 完全正确。我之前没有看到这条评论,因为我正忙着写我的答案:)
【解决方案2】:

在大多数情况下它不被认为是惯用的,但您可以使用 Agent 来做到这一点:

defmodule A do
  def once(fun) do
    {:ok, agent} = Agent.start_link(fn -> nil end)
    fn args ->
      case Agent.get(agent, & &1) do
        nil ->
          result = apply(fun, args)
          :ok = Agent.update(agent, fn _ -> {:ok, result} end)
          result
        {:ok, result} ->
          result
      end
    end
  end
end

现在如果你运行这个:

once = A.once(fn sleep ->
  :timer.sleep(sleep)
  1 + 1
end)

IO.inspect once.([1000])
IO.inspect once.([1000])
IO.inspect once.([1000])
IO.inspect once.([1000])

您会看到第一行在 1 秒后打印,但接下来的 3 行会立即打印,因为结果是从代理获取的。

【讨论】:

  • 我想知道为什么这不会被认为是惯用的。 Agent 只是一个 GenServer 实例,它专门用来做你正在使用它的事情(保存一个值),所以我实际上认为这是一个比 mudasobwa 更好的解决方案。它也是最精确的 JS 代码翻译,不依赖进程字典之类的“脏”东西。对我来说,唯一的缺点是 start_link 是由任何首先调用它的随机进程调用的,所以 ETS 版本可能是最干净的。
【解决方案3】:

虽然已经给出的两个答案都完全有效,但您的 javascript 中最精确的翻译如下所示:

defmodule M do
  use GenServer

  def start_link(_opts \\ []) do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def init(_args) do
    Process.sleep(1_000)
    {:ok, 42}
  end

  def value() do
    start_link()
    GenServer.call(__MODULE__, :value)
  end

  def handle_call(:value, _from, state) do
    {:reply, state, state}
  end
end

(1..5) |> Enum.each(&IO.inspect(M.value(), label: to_string(&1)))

使用与@Dogbert 的答案相同的度量:第一个值延迟打印,所有后续值都立即打印。

这是使用GenServer 阶段的记忆函数的精确模拟。 GenServer.start_link/3 返回以下之一:

{:ok, #PID<0.80.0>}
{:error, {:already_started, #PID<0.80.0>}}

也就是说,如果它已经启动,它不会重新启动。我懒得检查返回的值,因为无论如何我们都设置好了:如果是初始启动,我们调用重函数,如果我们已经启动,则 vaklue 已经在 state 中。

【讨论】:

  • 见下文 - 我认为代理版本更惯用。它基本上是相同的代码,但更好。
  • @cdegroot Agent 绝对是多余的实体,可能是在 Elixir 早期创建的,以吸引害怕“无处不在的不变性”范式的新手。当Agent 的使用是合理的并且Agent 无论如何都比普通的老好GenServer 更好时,没有一种情况。我在剃须时使用的工具与奥卡姆相同。
  • 代理 GenServer。只是一个专门的。使用更专业的库代码是合适的——它更短。说它是多余的就是说 Elixir 是多余的,因为你可以直接写 BEAM 字节码。
  • @cdegroot 确定它是合适的,它只是没有意义。检查AgentEcto(剧透:1 次,4 年前,用于迁移)和Phoenix(剧透:0 次)中使用了多少次。猜猜为什么。
  • 我猜这两个库都不需要记忆,然后;-)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-10-16
  • 2019-11-03
  • 2019-12-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多