查看源代码 与代码相关的反模式

本文档概述了与代码以及 Elixir 特定习语和功能相关的潜在反模式。

过度使用注释

问题

过度使用注释或注释自解释的代码会使代码可读性降低

示例

# Returns the Unix timestamp of 5 minutes from the current time
defp unix_five_min_from_now do
  # Get the current time
  now = DateTime.utc_now()

  # Convert it to a Unix timestamp
  unix_now = DateTime.to_unix(now, :second)

  # Add five minutes in seconds
  unix_now + (60 * 5)
end

重构

尽可能使用清晰且自解释的函数名、模块名和变量名。在上面的示例中,函数名很好地解释了函数的作用,因此您可能不需要在它之前添加注释。代码还通过变量名和清晰的函数调用很好地解释了操作。

您可以像这样重构上面的代码

@five_min_in_seconds 60 * 5

defp unix_five_min_from_now do
  now = DateTime.utc_now()
  unix_now = DateTime.to_unix(now, :second)
  unix_now + @five_min_in_seconds
end

我们删除了不必要的注释。我们还添加了一个@five_min_in_seconds 模块属性,它还可以为“神奇”数字60 * 5命名,使代码更清晰、更有表现力。

其他说明

Elixir 清晰地区分了文档和代码注释。该语言通过@doc@moduledoc等提供了对文档的内置一等支持。有关更多信息,请参阅“编写文档”指南。

with中的复杂else语句

问题

此反模式是指将所有错误子句扁平化为单个复杂else块的with语句。这种情况会损害代码的可读性和可维护性,因为很难知道错误值来自哪个子句。

示例

如下所示,此反模式的一个示例是函数open_decoded_file/1,它从文件中读取 Base64 编码的字符串内容并返回解码的二进制字符串。此函数使用一个with语句,该语句需要处理两种可能的错误,所有这些错误都集中在一个复杂的else块中。

def open_decoded_file(path) do
  with {:ok, encoded} <- File.read(path),
       {:ok, decoded} <- Base.decode64(encoded) do
    {:ok, String.trim(decoded)}
  else
    {:error, _} -> {:error, :badfile}
    :error -> {:error, :badencoding}
  end
end

在上面的代码中,不清楚<-左侧的每个模式如何与其最终的错误相关联。with中的模式越多,代码就越不清楚,并且不相关的失败越有可能相互覆盖。

重构

在这种情况下,与其将所有错误处理集中在一个复杂的else块中,不如在特定私有函数中规范化返回值类型。这样,with就可以专注于成功案例,并且错误在发生地附近被规范化,从而导致代码组织得更好,可维护性更强。

def open_decoded_file(path) do
  with {:ok, encoded} <- file_read(path),
       {:ok, decoded} <- base_decode64(encoded) do
    {:ok, String.trim(decoded)}
  end
end

defp file_read(path) do
  case File.read(path) do
    {:ok, contents} -> {:ok, contents}
    {:error, _} -> {:error, :badfile}
  end
end

defp base_decode64(contents) do
  case Base.decode64(contents) do
    {:ok, decoded} -> {:ok, decoded}
    :error -> {:error, :badencoding}
  end
end

子句中的复杂提取

问题

当我们使用多子句函数时,可以在子句中提取值以供进一步使用以及进行模式匹配/保护检查。提取本身并不代表反模式,但是,当您在多个子句和同一函数的多个参数中进行跨子句提取时,就很难知道哪些提取部分用于模式/保护,哪些仅在函数体中使用。此反模式与无关的多子句函数有关,但具有其自身的含义。它以不同的方式损害代码的可读性。

示例

多子句函数drive/1正在提取%User{}结构的字段,以供在子句表达式 (age) 中使用,以及在函数体 (name) 中使用

def drive(%User{name: name, age: age}) when age >= 18 do
  "#{name} can drive"
end

def drive(%User{name: name, age: age}) when age < 18 do
  "#{name} cannot drive"
end

虽然上面的示例很小,并没有构成反模式,但它是一个混合提取和模式匹配的示例。如果drive/1更复杂,有更多的子句、参数和提取,那么很难一眼就看出哪些变量用于模式/保护,哪些变量没有用。

重构

如下所示,解决此反模式的一种可能解决方案是在您有很多参数或多个子句时,仅在签名中提取与模式/保护相关的变量

def drive(%User{age: age} = user) when age >= 18 do
  %User{name: name} = user
  "#{name} can drive"
end

def drive(%User{age: age} = user) when age < 18 do
  %User{name: name} = user
  "#{name} cannot drive"
end

动态原子创建

问题

一个Atom 是 Elixir 的基本类型,其值就是它自己的名称。原子通常用于标识资源或表示操作的状态或结果。动态创建原子本身并不是反模式;但是,原子不会被 Erlang 虚拟机垃圾回收,因此这种类型的值在软件的整个执行生命周期中都驻留在内存中。默认情况下,Erlang VM 将应用程序中可以存在的原子数量限制为1_048_576,这足以涵盖程序中定义的所有原子,但旨在作为对通过动态创建“泄漏原子”的应用程序的早期限制。

因此,当开发人员无法控制软件执行期间将创建多少个原子时,动态创建原子可能被视为一种反模式。这种不可预测的情况可能会使软件暴露于由于过度使用内存或甚至达到原子数量上限而导致的意外行为。

示例

想象一下,您正在实现将字符串值转换为原子的代码。这些字符串可能是从外部系统接收的,无论是作为对我们应用程序的请求的一部分,还是作为对我们应用程序的响应的一部分。这种动态且不可预测的情况构成了安全风险,因为这些不受控制的转换可能会触发内存不足错误。

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: String.to_atom(status), message: message}
  end
end
iex> MyRequestHandler.parse(%{"status" => "ok", "message" => "all good"})
%{status: :ok, message: "all good"}

当我们使用String.to_atom/1 函数动态创建原子时,它本质上获得了创建我们系统中任意原子的潜在能力,这会导致我们失去对遵守 BEAM 建立的限制的控制。这个问题可能会被某人利用,创建足够的原子以关闭系统。

重构

为了消除这种反模式,开发人员必须要么通过将字符串映射到原子来执行显式转换,要么用String.to_existing_atom/1 替换String.to_atom/1。显式转换可以按如下方式完成

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: convert_status(status), message: message}
  end

  defp convert_status("ok"), do: :ok
  defp convert_status("error"), do: :error
  defp convert_status("redirect"), do: :redirect
end
iex> MyRequestHandler.parse(%{"status" => "status_not_seen_anywhere", "message" => "all good"})
** (FunctionClauseError) no function clause matching in MyRequestHandler.convert_status/1

通过显式列出所有受支持的状态,您保证只能发生有限数量的转换。传递无效状态将导致函数子句错误。

另一种选择是使用String.to_existing_atom/1,它只会在原子已存在于系统中时将字符串转换为原子

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: String.to_existing_atom(status), message: message}
  end
end
iex> MyRequestHandler.parse(%{"status" => "status_not_seen_anywhere", "message" => "all good"})
** (ArgumentError) errors were found at the given arguments:

  * 1st argument: not an already existing atom

在这种情况下,传递未知状态将引发异常,只要状态在系统中的任何地方都没有被定义为原子。但是,假设status可以是:ok:error:redirect,您如何保证这些原子存在?您必须确保这些原子在调用String.to_existing_atom/1同一个模块中存在。例如,如果您有以下代码

defmodule MyRequestHandler do
  def parse(%{"status" => status, "message" => message} = _payload) do
    %{status: String.to_existing_atom(status), message: message}
  end

  def handle(%{status: status}) do
    case status do
      :ok -> ...
      :error -> ...
      :redirect -> ...
    end
  end
end

所有有效状态都定义为同一模块中的原子,这足够了。如果您想要明确,您还可以创建一个列出它们的函数

def valid_statuses do
  [:ok, :error, :redirect]
end

但是,请记住,使用模块属性或在模块体中定义原子(在函数之外)是不够的,因为模块体仅在编译期间执行,并且不一定是在运行时加载的已编译模块的一部分。

长参数列表

问题

在 Elixir 这样的函数式语言中,函数倾向于明确接收所有输入并返回所有相关的输出,而不是依赖于变异或副作用。随着函数的复杂性增加,它们需要处理的参数(参数)数量可能会增加,以至于函数的接口变得混乱,并且在使用时容易出错。

示例

在以下示例中,loan/6 函数接受的参数过多,导致其接口混乱,并可能导致开发人员在调用此函数时引入错误。

defmodule Library do
  # Too many parameters that can be grouped!
  def loan(user_name, email, password, user_alias, book_title, book_ed) do
    ...
  end
end

重构

为了解决这种反模式,可以使用键值数据结构(如映射、结构,甚至在可选参数的情况下使用关键字列表)对相关参数进行分组。这有效地减少了参数数量,并且键值数据结构增加了调用者的清晰度。

对于这个特定示例,loan/6 的参数可以分组到两个不同的映射中,从而将其元数减少到loan/2

defmodule Library do
  def loan(%{name: name, email: email, password: password, alias: alias} = user, %{title: title, ed: ed} = book) do
    ...
  end
end

在某些情况下,参数过多的函数可能是私有函数,这给了我们更多关于如何分离函数参数的灵活性。对于这种情况,一个可能的建议是将参数分成两个映射(或元组):一个映射保存可能更改的数据,另一个映射保存不会更改的数据(只读)。这给了我们一种机械的方法来重构代码。

其他时候,一个函数可能真正需要六个或更多个完全不相关的参数。这可能表明该函数试图做太多事情,最好将其分解成多个函数,每个函数负责整体责任中的一小部分。

命名空间入侵

问题

此反模式出现在包作者或库在其“命名空间”之外定义模块时。库应该使用其名称作为所有模块的“前缀”。例如,名为:my_lib 的包应该在MyLib 命名空间内定义其所有模块,例如MyLib.UserMyLib.SubModuleMyLib.Application 以及MyLib 本身。

这一点很重要,因为 Erlang VM 只能加载一个模块的实例。因此,如果有多个库定义了同一个模块,那么由于此限制,它们彼此不兼容。通过始终使用库名称作为前缀,它可以避免由于唯一前缀而导致的模块名称冲突。

示例

这个问题通常在编写另一个库的扩展时出现。例如,想象一下,您正在编写一个名为:plug_auth 的包,它为Plug 添加身份验证。您必须避免在Plug 命名空间内定义模块

defmodule Plug.Auth do
  # ...
end

即使Plug 目前没有定义Plug.Auth 模块,它也可能在将来添加这样的模块,这最终会导致与plug_auth 的定义冲突。

重构

鉴于该包名为:plug_auth,它必须在PlugAuth 命名空间内定义模块

defmodule PlugAuth do
  # ...
end

其他说明

此反模式有一些已知的例外情况

  • 协议实现 按照设计,在协议命名空间下定义

  • 在某些情况下,命名空间所有者可能允许此规则的例外。例如,在 Elixir 本身中,您通过将它们放置在Mix.Tasks 命名空间下(例如Mix.Tasks.PlugAuth)来定义自定义 Mix 任务

  • 如果您是plugplug_auth 的维护者,那么您可以允许plug_auth 定义使用Plug 命名空间的模块,例如Plug.Auth。但是,您有责任避免或管理将来可能出现的任何冲突

非断言映射访问

问题

在 Elixir 中,可以通过静态或动态方式访问 Map 中的值,Map 是键值数据结构。

当一个键预计存在于一个 map 中时,必须使用 map.key 语法进行访问,这样可以让开发人员(和编译器)清楚地知道键必须存在。如果键不存在,就会抛出异常(在某些情况下还会产生编译器警告)。这也被称为静态语法,因为键在编写代码时就已经知道了。

当一个键是可选的时,必须使用 map[:key] 语法。这样,如果提供的键不存在,就会返回 nil。这是动态语法,因为它也支持动态键访问,例如 map[some_var]

当使用 map[:key] 访问一个始终存在于 map 中的键时,会使代码对于开发人员和编译器来说不够清晰,因为他们现在需要根据键可能不存在的假设进行工作。这种不匹配也可能使追踪某些错误变得更加困难。如果键意外丢失,系统中会传播 nil 值,而不是在 map 访问时抛出异常。

示例

函数 plot/1 试图绘制一个图形来表示一个点在笛卡尔平面上的位置。该函数接收一个 Map 类型的参数,其中包含点的属性,可以是二维或三维笛卡尔坐标系的点。该函数使用动态访问来检索 map 键的值。

defmodule Graphics do
  def plot(point) do
    # Some other code...
    {point[:x], point[:y], point[:z]}
  end
end
iex> point_2d = %{x: 2, y: 3}
%{x: 2, y: 3}
iex> point_3d = %{x: 5, y: 6, z: 7}
%{x: 5, y: 6, z: 7}
iex> Graphics.plot(point_2d)
{2, 3, nil}
iex> Graphics.plot(point_3d)
{5, 6, 7}

鉴于我们想要绘制二维和三维点,上面的行为是预期的。但是,如果我们忘记传递一个包含 :x:y 键的点,会发生什么?

iex> bad_point = %{y: 3, z: 4}
%{y: 3, z: 4}
iex> Graphics.plot(bad_point)
{nil, 3, 4}

上面的行为是不可预期的,因为我们的函数不应该处理没有 :x 键的点。这会导致一些难以发现的错误,因为我们现在可能会传递 nil 给另一个函数,而不是在早期抛出异常。

重构

为了消除这种反模式,我们必须根据我们的要求使用动态 map[:key] 语法和静态 map.key 语法。我们期望 :x:y 始终存在,但 :z 不存在。下面的代码展示了 plot/1 的重构,消除了这种反模式。

defmodule Graphics do
  def plot(point) do
    # Some other code...
    {point.x, point.y, point[:z]}
  end
end
iex> Graphics.plot(point_2d)
{2, 3, nil}
iex> Graphics.plot(bad_point)
** (KeyError) key :x not found in: %{y: 3, z: 4} # <= explicitly warns that
  graphic.ex:4: Graphics.plot/1                  # <= the :x key does not exist!

总的来说,map.keymap[:key] 的使用对你的数据结构编码了重要的信息,使开发人员能够清楚地表达他们的意图。请参阅 MapAccess 模块文档以获取更多信息和示例。

重构这种反模式的另一种方法是使用模式匹配,为二维点和三维点定义显式的子句。

defmodule Graphics do
  # 3d
  def plot(%{x: x, y: y, z: z}) do
    # Some other code...
    {x, y, z}
  end

  # 2d
  def plot(%{x: x, y: y}) do
    # Some other code...
    {x, y}
  end
end

模式匹配在对多个键进行匹配,以及同时对值本身进行匹配时特别有用。

另一个选择是使用结构体。默认情况下,结构体只支持对其字段的静态访问。在这种情况下,你可以考虑为二维点和三维点定义结构体。

defmodule Point2D do
  @enforce_keys [:x, :y]
  defstruct [x: nil, y: nil]
end

一般来说,结构体在跨模块共享数据结构时很有用,但需要在这些模块之间添加编译时依赖关系。如果模块 A 使用模块 B 中定义的结构体,则如果结构体 B 中的字段发生变化,则必须重新编译 A

其他说明

这种反模式以前被称为 访问不存在的 map/struct 字段

非断言式模式匹配

问题

总的来说,Elixir 系统由许多受监督的进程组成,因此错误的影响仅限于单个进程,不会传播到整个应用程序。一个主管进程会检测到失败的进程,报告它,并可能重启它。这种反模式出现在开发人员编写防御性或不精确的代码时,这些代码能够返回未计划的错误值,而不是通过模式匹配和守卫以断言式风格进行编程。

示例

函数 get_value/2 试图从 URL 查询字符串的特定键中提取一个值。由于它没有使用模式匹配实现,所以 get_value/2 始终会返回一个值,无论调用中作为参数传递的 URL 查询字符串的格式是什么。有时返回的值是有效的。但是,如果调用中使用了格式意外的 URL 查询字符串,get_value/2 将从中提取不正确的值。

defmodule Extract do
  def get_value(string, desired_key) do
    parts = String.split(string, "&")

    Enum.find_value(parts, fn pair ->
      key_value = String.split(pair, "=")
      Enum.at(key_value, 0) == desired_key && Enum.at(key_value, 1)
    end)
  end
end
# URL query string with the planned format - OK!
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "lab")
"ASERG"
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "university")
"UFMG"
# Unplanned URL query string format - Unplanned value extraction!
iex> Extract.get_value("name=Lucas&university=institution=UFMG&lab=ASERG", "university")
"institution"   # <= why not "institution=UFMG"? or only "UFMG"?

重构

为了消除这种反模式,可以使用模式匹配对 get_value/2 进行重构。因此,如果使用了意外的 URL 查询字符串格式,函数将崩溃,而不是返回一个无效的值。这种行为(如下所示)允许客户端决定如何处理这些错误,并且不会给出一个错误的印象,即在提取意外值时代码正常工作。

defmodule Extract do
  def get_value(string, desired_key) do
    parts = String.split(string, "&")

    Enum.find_value(parts, fn pair ->
      [key, value] = String.split(pair, "=") # <= pattern matching
      key == desired_key && value
    end)
  end
end
# URL query string with the planned format - OK!
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "name")
"Lucas"
# Unplanned URL query string format - Crash explaining the problem to the client!
iex> Extract.get_value("name=Lucas&university=institution=UFMG&lab=ASERG", "university")
** (MatchError) no match of right hand side value: ["university", "institution", "UFMG"]
  extract.ex:7: anonymous fn/2 in Extract.get_value/2 # <= left hand: [key, value] pair
iex> Extract.get_value("name=Lucas&university&lab=ASERG", "university")
** (MatchError) no match of right hand side value: ["university"]
  extract.ex:7: anonymous fn/2 in Extract.get_value/2 # <= left hand: [key, value] pair

Elixir 和模式匹配提倡一种断言式的编程风格,在这种风格中,你处理已知的案例。一旦出现意外情况,你可以根据实际情况决定如何处理它,或者得出结论,这种情况确实无效,异常是期望的选择。

case/2 是 Elixir 中另一个重要的构造,它可以帮助我们编写断言式代码,通过匹配特定模式。例如,如果一个函数返回 {:ok, ...}{:error, ...},最好明确地匹配这两种模式。

case some_function(arg) do
  {:ok, value} -> # ...
  {:error, _} -> # ...
end

特别是,避免只匹配 _,如下所示。

case some_function(arg) do
  {:ok, value} -> # ...
  _ -> # ...
end

匹配 _ 的意图不够清晰,如果 some_function/1 在将来添加了新的返回值,可能会隐藏错误。

其他说明

这种反模式以前被称为 推测性假设

非断言式真值性

问题

Elixir 提供了真值性的概念:nilfalse 被认为是“假值”,所有其他值都被认为是“真值”。语言中的许多构造,例如 &&/2||/2!/1 处理真值和假值。使用这些运算符不是一种反模式。但是,当所有操作数都应该是布尔值时使用这些运算符,可能是一种反模式。

示例

这种反模式最简单的体现是在条件语句中,例如。

if is_binary(name) && is_integer(age) do
  # ...
else
  # ...
end

鉴于 &&/2 的两个操作数都是布尔值,代码比必要情况更通用,而且可能不清楚。

重构

为了消除这种反模式,我们可以用 and/2or/2not/1 分别替换 &&/2||/2!/1。这些运算符至少断言它们的第一个参数是一个布尔值。

if is_binary(name) and is_integer(age) do
  # ...
else
  # ...
end

当与 Erlang 代码一起工作时,这种技术可能特别重要。Erlang 没有真值性的概念。它永远不会返回 nil,而是它的函数可能会返回 :error:undefined,而在 Elixir 开发人员中会返回 nil 的地方。因此,为了避免意外地将 :undefined:error 解释为真值,你可能更愿意使用 and/2or/2not/1,尤其是在与 Erlang API 交互时。