【问题标题】:In Elixir, why not use case statements instead of multiple function overloading?在 Elixir 中,为什么不使用 case 语句而不是多函数重载呢?
【发布时间】:2016-03-20 21:01:55
【问题描述】:

我正在学习 Elixir,有点困惑为什么我们必须使用同一函数的多个定义而不是使用 case 语句进行分支。这是Elixir in Action,第一版第 81 页中的一个示例,用于计算文件中的行数:

defmodule LinesCounter do
  def count(path) do
    File.read(path)
    |> lines_num
  end

  defp lines_num({:ok, contents}) do
    contents
    |> String.split("\n")
    |> length
  end

  defp lines_num({:error, _}), do: "error"
 end 

所以我们有两个 defplines_num 实例来处理 :ok 和 :error 的情况。但是以下不是做同样的事情吗?可以说是用一种更简洁、更简洁的方式,并且只使用一个函数而不是三个函数?

defmodule LinesCounterCase do
  def count(file) do
    case File.read(file) do
      {:ok, contents} -> contents |> String.split("\n") |> length
      {:error, _} -> "error"
    end
  end
end

两者的工作方式相同。

我不想在开始使用 Elixir 的旅程时学习不正确的习语,因此我正在寻找以这种方式使用 case 语句的缺点。

【问题讨论】:

  • 我也更喜欢案例版 ;-)

标签: elixir


【解决方案1】:

书中的代码不是很惯用,它试图在示例中显示多个函数子句和管道,但并不是最好的。

第 1 部分:关注点分离。

首先,一般约定说管道应该以这样的“原始”变量开头:

def count(path) do
  path
  |> File.read
  |> lines_num
end

第二件事是这段代码确实混合了职责。有时对函数返回的类型也有好处。如果我看到 lines_num 返回整数或字符串,我真的会摸不着头脑。为什么lines_num 在读取文件时应该关心错误?答案是:不应该。它应该接受一个字符串并返回它的计算结果:

defp lines_num(contents) do #skipping the tuple here
  contents
  |> String.split("\n")
  |> length
end

现在您的计数函数有两个选项。您可以在文件出现问题时让它崩溃或处理错误。在此示例中,仅返回字符串“error”,因此最惯用的方法是完全跳过它:

def count(path) do
  path
  |> File.read! #note the "!" it means it will return just content instead {:ok, content} or rise an error
  |> lines_num
  end
end

Elixir 几乎总是提供func! 版本,正是出于这个原因 - 让管道变得更容易。

如果你想处理错误,case 语句是最好的。 Unix 管道也不鼓励分支。

def count(path) do
  case File.read(path) do
    {:ok, contents} -> lines_num(contents)
    {:error, reason} -> do_something_on_error(reason)
  end
end

第 2 部分:多个函数子句在哪里有意义?

多函数子句优于 case 语句的主要情况有两种:递归和多态。还有一些其他的,但对于初学者来说应该足够了。

多态性

假设您想让lines_num 更通用以处理字符表示列表:

defp lines_num(contents) when is_binary(contents) do
  ...
end
defp lines_num(contents) when is_list(contents) do
  contents
  |> :binary.list_to_bin #not the most efficient way!
  |> lines_num
end

实现可能不同,但最终结果将是相同的:不同类型的行数:"foo \n bar"'foo \n bar'

递归

def factorial(0), do: 0
def factorial(n), do: n * factorial(n-1)

def map([], _func), do: []
def map([head, tail], func), do: [func.(head), map(tail)]

(警告:示例不是尾递归)对此类函数使用 case 的可读性/惯用性会大大降低。

结论:

  1. 除非您知道自己在做什么,否则不要将函数头用于分支逻辑。
  2. 如果您有分支逻辑,最好拆分管道。
  3. 对多态性和递归使用函数子句。

【讨论】:

  • 可能需要添加的东西:如果你有一个嵌套的 case 语句,你需要将它分解成单独的函数。嵌套的 case 语句很快就变成一团乱七八糟的泥球。
  • 确实如此,但我认为这是一个单独的问题,并没有回答何时使用案例与函数子句的问题。
【解决方案2】:

在这种特殊情况下,您采用哪种方式可能不会有所不同。没有任何东西说你“必须”使用模式匹配函数子句。

case 语句版本更类似于其他语言的做法,因此作者可能一直在引入 Elixir 特定的概念,以期以后更多地使用它。

我绝对更喜欢多函数子句版本,但可能是因为我已经看了一段时间的 Erlang 和 Elixir 代码并且已经习惯了。

我在 Elixir Slack 频道上询问了选择函数而不是 case 语句的原因,建议观看此视频:https://www.youtube.com/watch?v=CQyt9Vlkbis

case 语句上使用函数子句的主要论点是,您可以为正在做出的决定命名。这个问题中给出的例子在这一点上并不那么引人注目,但视频很好地说明了这一点。

【讨论】:

    【解决方案3】:

    您可以在这里指出,这里的三体版本更擅长生成具有单一职责的功能。我认为这是软件设计中最重要的原则之一,适用于 OO 中的类(和类似结构)以及 FP 中的函数。 case-statement 版本更短更简洁,但确实将文件读取和行计数结合在一起。尝试为它编写一个测试,然后为拆分的版本编写一个测试。

    当然,这是一个设计问题。 YMMV。但我认为一本书需要在安全方面犯错,在应用 SRP 和简洁之间有一个不错的权衡。

    【讨论】:

    • 由于lines_num 函数是私有的,我会说测试参数无效。 SRP 是个好点。
    • @Patrick Oscity。不过,我看不到 SRP 在重载版本中是如何维护的。它并没有将任务完全分解为独立的动作并将它们分摊给不同的功能。相反,我们在一个函数中调用文件打开行计数,然后在其他函数中进行文件存在错误情况检查行计数实现。它们都相互交织在一起,在我看来这违反了 SRP。
    • @ThomasBrowne 很抱歉造成混淆,您说的完全正确。我的意思是 SRP 总的来说是一件好事。在移动设备上,我现在打字有点懒惰:)
    猜你喜欢
    • 1970-01-01
    • 2017-09-27
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-12-08
    • 1970-01-01
    相关资源
    最近更新 更多