查看源代码 设计相关反模式
本文档概述了与您的模块、函数及其在代码库中所起作用相关的潜在反模式。
备选返回类型
问题
此反模式指的是接收选项(通常作为关键字列表参数)的函数,这些选项会极大地改变其返回类型。由于选项是可选的,有时是动态设置的,如果它们也改变了返回类型,则可能很难理解该函数实际上返回了什么。
示例
此反模式的一个示例(如下所示)是,当一个函数具有许多备选返回类型时,这取决于作为参数接收到的选项。
defmodule AlternativeInteger do
@spec parse(String.t(), keyword()) :: integer() | {integer(), String.t()} | :error
def parse(string, options \\ []) when is_list(options) do
if Keyword.get(options, :discard_rest, false) do
Integer.parse(string)
else
case Integer.parse(string) do
{int, _rest} -> int
:error -> :error
end
end
end
end
iex> AlternativeInteger.parse("13")
13
iex> AlternativeInteger.parse("13", discard_rest: true)
13
iex> AlternativeInteger.parse("13", discard_rest: false)
{13, ""}
重构
要重构此反模式,如下一步所示,为每个返回类型添加一个特定的函数(例如,parse_discard_rest/1
),不再将此委托给作为参数传递的选项。
defmodule AlternativeInteger do
@spec parse(String.t()) :: {integer(), String.t()} | :error
def parse(string) do
Integer.parse(string)
end
@spec parse_discard_rest(String.t()) :: integer() | :error
def parse_discard_rest(string) do
case Integer.parse(string) do
{int, _rest} -> int
:error -> :error
end
end
end
iex> AlternativeInteger.parse("13")
{13, ""}
iex> AlternativeInteger.parse_discard_rest("13")
13
布尔迷恋
问题
当使用布尔值而不是原子来编码信息时,就会出现此反模式。布尔值本身的使用并不构成反模式,但每当使用多个布尔值且状态重叠时,用原子(或复合数据类型,如元组)替换布尔值可能会导致更清晰的代码。
这是原始迷恋的一种特殊情况,特定于布尔值。
示例
此反模式的一个示例是一个接收两个或多个选项的函数,例如editor: true
和admin: true
,以重叠的方式配置其行为。在下面的代码中,:editor
选项在设置:admin
时无效,这意味着:admin
选项的优先级高于:editor
,并且它们最终是相关的。
defmodule MyApp do
def process(invoice, options \\ []) do
cond do
options[:admin] -> # Is an admin
options[:editor] -> # Is an editor
true -> # Is none
end
end
end
重构
除了使用多个选项之外,上面的代码可以重构为接收一个名为:role
的单个选项,该选项可以是:admin
、:editor
或:default
。
defmodule MyApp do
def process(invoice, options \\ []) do
case Keyword.get(options, :role, :default) do
:admin -> # Is an admin
:editor -> # Is an editor
:default -> # Is none
end
end
end
此反模式也可能发生在我们的数据结构中。例如,我们可以定义一个User
结构体,它具有两个布尔值字段:editor
和:admin
,而一个名为:role
的单个字段可能更可取。
最后,值得注意的是,即使只有一个布尔值参数/选项,使用原子也可能更可取。例如,考虑一个发票,它可以设置为已批准/未批准。一种选择是提供一个期望布尔值的函数
MyApp.update(invoice, approved: true)
但是,使用原子可能更易读,并且在将来添加更多状态(例如,待处理)变得更加简单。
MyApp.update(invoice, status: :approved)
记住布尔值在内部表示为原子。因此,一种方法相对于另一种方法没有性能损失。
用于控制流的异常
问题
此反模式指的是使用Exception
用于控制流的代码。异常处理本身并不代表反模式,但开发人员应该优先使用case
和模式匹配来改变其代码的流程,而不是try/rescue
。反过来,库作者应该为开发人员提供 API 来处理错误,而无需依赖异常处理。当开发人员没有自由来决定错误是否异常时,这被认为是一种反模式。
示例
此反模式的一个示例(如下所示)是使用try/rescue
来处理文件操作。
defmodule MyModule do
def print_file(file) do
try do
IO.puts(File.read!(file))
rescue
e -> IO.puts(:stderr, Exception.message(e))
end
end
end
iex> MyModule.print_file("valid_file")
This is a valid file!
:ok
iex> MyModule.print_file("invalid_file")
could not read file "invalid_file": no such file or directory
:ok
重构
要重构此反模式,如下一步所示,使用File.read/1
,它返回元组,而不是在无法读取文件时引发异常。
defmodule MyModule do
def print_file(file) do
case File.read(file) do
{:ok, binary} -> IO.puts(binary)
{:error, reason} -> IO.puts(:stderr, "could not read file #{file}: #{reason}")
end
end
end
这只有在File
模块提供使用元组作为结果读取文件的 API(File.read/1
)以及引发异常的版本(File.read!/1
)的情况下才有可能。感叹号(惊叹号)实际上是Elixir 命名约定的部分。
鼓励库作者遵循相同的做法。实际上,感叹号变体是在非引发版本的代码之上实现的。例如,File.read!/1
的实现方式如下
def read!(path) do
case read(path) do
{:ok, binary} ->
binary
{:error, reason} ->
raise File.Error, reason: reason, action: "read file", path: IO.chardata_to_string(path)
end
end
社区遵循的一个常见做法是使非引发版本返回{:ok, result}
或{:error, Exception.t}
。例如,HTTP 客户端可能在成功情况下返回{:ok, %HTTP.Response{}}
,在失败情况下返回{:error, %HTTP.Error{}}
,其中HTTP.Error
是作为异常实现的。这使得任何人都可以通过简单地调用Kernel.raise/1
来引发异常。
其他备注
此反模式以前称为使用异常进行控制流。
原始迷恋
问题
当过度使用 Elixir 基本类型(例如,整数、浮点数和字符串)来携带结构化信息时,就会出现此反模式,而不是创建可以更好地表示域的特定复合数据类型(例如,元组、映射和结构体)。
示例
此反模式的一个示例是使用单个字符串来表示Address
。一个Address
比简单的基本(也称为原始)值更复杂。
defmodule MyApp do
def extract_postal_code(address) when is_binary(address) do
# Extract postal code with address...
end
def fill_in_country(address) when is_binary(address) do
# Fill in missing country...
end
end
虽然您可能会从数据库、Web 请求或第三方接收address
作为字符串,但如果您发现自己经常操作或从字符串中提取信息,那么这是一个很好的指示,您应该将地址转换为结构化数据。
此反模式的另一个示例是使用浮点数来模拟货币和货币,当应该更喜欢更丰富的数据结构时。
重构
解决此反模式的可能解决方案是使用映射或结构体来模拟我们的地址。下面的示例创建了一个Address
结构体,通过复合类型更好地表示此域。此外,我们引入了一个parse/1
函数,它将字符串转换为Address
,这将简化其余函数的逻辑。通过此修改,我们可以根据需要单独提取此复合类型的每个字段。
defmodule Address do
defstruct [:street, :city, :state, :postal_code, :country]
end
defmodule MyApp do
def parse(address) when is_binary(address) do
# Returns %Address{}
end
def extract_postal_code(%Address{} = address) do
# Extract postal code with address...
end
def fill_in_country(%Address{} = address) do
# Fill in missing country...
end
end
不相关的多子句函数
问题
使用多子句函数是一个强大的 Elixir 功能。但是,一些开发人员可能会滥用此功能来分组不相关的功能,这是一种反模式。
示例
这种使用多子句函数的常见示例是,当开发人员将不相关的业务逻辑混合到同一个函数定义中时,以一种方式,每个子句的行为与其他子句完全不同。此类函数通常具有过于广泛的规范,使得其他开发人员难以理解和维护它们。
一些开发人员可能会使用文档机制(例如@doc
注释)来弥补代码可读性差,但文档本身可能最终充满条件语句,以描述函数在每个不同参数组合下的行为方式。这是一个很好的指示,表明子句最终是不相关的。
@doc """
Updates a struct.
If given a product, it will...
If given an animal, it will...
"""
def update(%Product{count: count, material: material}) do
# ...
end
def update(%Animal{count: count, skin: skin}) do
# ...
end
如果更新动物与更新产品完全不同,并且需要一组不同的规则,那么可能值得将它们拆分成不同的函数,甚至不同的模块。
重构
如下一步所示,解决此反模式的一种可能解决方案是在简单的函数中分解在一个不相关的多子句函数中混合在一起的业务规则。每个函数可以具有特定的名称和@doc
,描述其行为和接收的参数。虽然此重构听起来很简单,但它可能会影响函数的调用者,因此请小心!
@doc """
Updates a product.
It will...
"""
def update_product(%Product{count: count, material: material}) do
# ...
end
@doc """
Updates an animal.
It will...
"""
def update_animal(%Animal{count: count, skin: skin}) do
# ...
end
这些函数仍然可以使用多个子句实现,只要子句对相关功能进行分组即可。例如,update_product
实际上可以按如下方式实现
def update_product(%Product{count: 0}) do
# ...
end
def update_product(%Product{material: material})
when material in ["metal", "glass"] do
# ...
end
def update_product(%Product{material: material})
when material not in ["metal", "glass"] do
# ...
end
您可以在 Elixir 本身中看到这种模式。操作符+/2
可以将Integer
和Float
加在一起,但不能将String
加在一起,后者使用操作符<>/2
。从这个意义上说,在同一个操作中处理整数和浮点数是合理的,但字符串不相关到足以拥有自己的函数。
您还将在 Elixir 中找到与任何结构体一起工作的函数的示例,这看似是这种反模式的出现,例如struct/2
iex> struct(URI.parse("/foo/bar"), path: "/bar/baz")
%URI{
scheme: nil,
userinfo: nil,
host: nil,
port: nil,
path: "/bar/baz",
query: nil,
fragment: nil
}
这里不同的是,函数struct/2
对给定的任何结构体都具有完全相同的功能,因此无需考虑函数如何处理不同的输入。如果所有输入的行为清晰且一致,则不会发生反模式。
对库使用应用程序配置
问题
可以使用应用程序环境来参数化可以在 Elixir 系统中使用的全局值。这种机制非常有用,因此本身不被认为是反模式。但是,库作者应该避免使用应用程序环境来配置他们的库。原因恰恰是应用程序环境是一个全局状态,因此在应用程序中,每个键只能在环境中有一个值。这使得依赖同一个库的多个应用程序无法以不同的方式配置库的相同方面。
示例
模块DashSplitter
代表一个库,它通过全局应用程序环境配置其函数的行为。这些配置集中在config/config.exs文件中,如下所示
import Config
config :app_config,
parts: 3
import_config "#{config_env()}.exs"
库DashSplitter
实现的函数之一是split/1
。此函数旨在将通过参数接收的字符串分成一定数量的部分。在split/1
中用作分隔符的字符始终是"-"
,字符串被分成多少部分由应用程序环境全局定义。此值通过split/1
函数通过调用Application.fetch_env!/2
来检索,如下所示
defmodule DashSplitter do
def split(string) when is_binary(string) do
parts = Application.fetch_env!(:app_config, :parts) # <= retrieve parameterized value
String.split(string, "-", parts: parts) # <= parts: 3
end
end
由于库DashSplitter
使用的此参数化值,所有依赖它的应用程序只能使用具有关于字符串分隔生成的部分数量的相同行为的split/1
函数。目前,此值等于 3,正如我们在下面显示的用例中看到的
iex> DashSplitter.split("Lucas-Francisco-Vegi")
["Lucas", "Francisco", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi")
["Lucas", "Francisco", "da-Matta-Vegi"]
重构
为了消除这种反模式,应该使用传递给函数的参数来执行这种类型的配置。下面显示的代码通过接受关键字列表作为新的可选参数来对split/1
函数进行重构。有了这个新参数,就可以在调用时修改函数的默认行为,从而允许在同一个应用程序中使用多种不同的split/2
方式。
defmodule DashSplitter do
def split(string, opts \\ []) when is_binary(string) and is_list(opts) do
parts = Keyword.get(opts, :parts, 2) # <= default config of parts == 2
String.split(string, "-", parts: parts)
end
end
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi", [parts: 5])
["Lucas", "Francisco", "da", "Matta", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi") #<= default config is used!
["Lucas", "Francisco-da-Matta-Vegi"]
当然,并非所有库对应用程序环境的使用都是错误的。一个例子是使用配置来用另一个必须具有完全相同行为的组件(或依赖项)替换库的组件。假设一个库需要解析 CSV 文件。库作者可以选择一个包作为默认解析器,但允许其用户通过应用程序环境切换到不同的实现。最终,选择不同的 CSV 解析器不应改变结果,库作者甚至可以通过定义行为来强制执行这一点,这些行为具有他们期望的精确语义。
其他说明:监督树
在实践中,库可能需要除了关键字列表之外的额外配置。例如,如果一个库需要启动一个监督树,那么库的用户如何自定义它的监督树呢?鉴于监督树本身是全局的(因为它属于库),库作者可能会再次尝试使用应用程序配置。
一个解决方案是让库提供它自己的子规范,而不是自己启动监督树。这允许用户在其自己的监督树下启动所有必要的进程,并在初始化期间可能传递自定义配置选项。
你可以在像Nx和DNS Cluster这样的项目中看到这种模式的实际应用。这些库要求你在自己的监督树下列出进程。
children = [
{DNSCluster, query: "my.subdomain"}
]
在这种情况下,如果DNSCluster
的用户需要按环境配置 DNSCluster,他们可以是读取应用程序环境的人,而不是库强迫他们这样做。
children = [
{DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore}
]
一些库,比如Ecto,允许你将你的应用程序名称作为选项传递(称为:otp_app
或类似的选项),然后自动从你的应用程序中读取环境。虽然这解决了应用程序环境是全局的这个问题,因为它们从每个单独的应用程序中读取,但与上面的例子相比,它需要一些间接操作,在上面的例子中,用户在需要时显式地从自己的代码中读取应用程序环境。
其他说明:编译时配置
类似的讨论涉及编译时配置。如果库作者需要在编译时提供一些配置怎么办?
再次,与其强迫你的库用户提供编译时配置,你可能希望允许你的库用户自己生成代码。这是像Ecto这样的库所采取的方法。
defmodule MyApp.Repo do
use Ecto.Repo, adapter: Ecto.Adapters.Postgres
end
Ecto 允许其用户定义任意数量的存储库,而不是强迫开发人员共享一个单一存储库。鉴于:adapter
配置是在编译时需要的,它是在use Ecto.Repo
上的一个必需值。如果开发人员希望按环境配置适配器,那么这是他们的选择。
defmodule MyApp.Repo do
use Ecto.Repo, adapter: Application.compile_env(:my_app, :repo_adapter)
end
另一方面,代码生成有它自己的反模式,必须仔细考虑。也就是说,虽然不建议使用应用程序环境来编写库,尤其是编译时配置,但在某些情况下,它们可能是最佳选择。例如,考虑一个库需要解析 CSV 或 JSON 文件来根据数据文件生成代码。在这种情况下,最好提供合理的默认值并通过应用程序环境使它们可定制,而不是要求你的库的每个用户生成完全相同的代码。
其他说明:Mix 任务
对于 Mix 任务和相关工具,可能需要提供每个项目的配置。例如,假设你有一个:linter
项目,它支持设置输出文件和详细程度。你可以选择通过应用程序环境对其进行配置。
config :linter,
output_file: "/path/to/output.json",
verbosity: 3
但是,Mix
允许任务通过Mix.Project.config/0
读取每个项目的配置。在这种情况下,你可以在mix.exs
文件中直接配置:linter
。
def project do
[
app: :my_app,
version: "1.0.0",
linter: [
output_file: "/path/to/output.json",
verbosity: 3
],
...
]
end
此外,如果 Mix 任务可用,你也可以接受这些选项作为命令行参数(参见OptionParser
)。
mix linter --output-file /path/to/output.json --verbosity 3