使用“with”宏更好地控制执行流程

从Elixir1.2开始就可以用with宏来写出更有表现力的流程控制语句。与需要深度嵌套的 case 和 if/else 语句不同,你可以只使用一个 with 语句来表达一样的逻辑,而且方式上更加优雅可读性也更好。下面我将探索如何利用它们来改进你的代码。

with 的基础

with 后跟一个句子列表,这些句子将按顺序执行。如果所有句子的结果都是正常,那么 do 后面的语句将被执行。当其中一个句子的结果有问题,则 do 后面的句子就不执行,而且相关错误值将返回给调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
iex> with {int, _} <- Integer.parse("10") do
...> 10 * int
...> end
100
iex> with {int, _} <- Integer.parse("foo") do
...> 10 * int
...> end
:error
iex> with {int, _} <- Integer.parse("9"),
...> true <- Integer.is_even(int) do
...> 10 * int
...> end
false

你也可以在那些句子中使用when子句。

1
2
3
4
iex> with {int, _} when int != 0 <- Integer.parse("9"),
...> 99 / int
...> end
11.0

另外,你可以使用else来捕获相关可能的异常。

1
2
3
4
5
6
7
8
iex> with {int, _} <- Integer.parse("9"),
...> true <- Integer.is_even(int) do
...> 10 * int
...> else
...> :error -> {:error, :not_an_int} # error for bad parsing
...> false -> {:error, :not_even} # error for odd number
...> end
{:error, :not_even}

或者,你可以忽略所有错误值,只返回一个统一的错误。

1
2
3
4
5
6
7
iex> with {int, _} <- Integer.parse("9"),
...> true <- Integer.is_even(int) do
...> 10 * int
...> else
...> _ -> {:error, :invalid_value}
...> end
{:error, :invalid_value}

你甚至可以在这些句子中赋值。不过要小心,如果你进行了错误的赋值,可能会得到 MatchError 错误。

1
2
3
4
5
6
7
8
9
iex> with {int, _} <- Integer.parse("9"),
...> squared = int * int,
...> false <- Integer.is_even(int) do
...> squared + 1
...> end
82
iex> with 1 = "1", do: :ok
** (MatchError) no match of right hand side value: "1"

with的语法请看官方的详细文档

一个实际的例子

让我们来看看一个的例子。你可能遇到这样的情况,你需要在一个控制器里修改一个已经存在的记录并且发送一些通知消息出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def update(conn, params \\ %{}) do
case Documents.get(params["id"]) do
{:ok, document} ->
case Documents.update(document, params) do
{:ok, document} ->
Notifications.push_document_updated(document)
json(conn, document)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, ErrorView, :"400", changeset)
end
{:error, :not_found} ->
render(conn, ErrorView, :"404")
end
end

我们很明显地看到我们用嵌套case语句来获得我们需要的执行路径。我们用 with 来重写的话,将会使代码简洁明了得多。

1
2
3
4
5
6
7
8
9
10
11
12
13
def update(conn, params \\ %{}) do
with {:ok, document} <- Documents.get(params["id"]),
{:ok, updated_document} <- Documents.update(document, params) do
Notifications.push_document_updated(updated_document)
json(conn, document)
else
{:error, :not_found} ->
render(conn, ErrorView, :"404")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, ErrorView, :"400", changeset)
end
end

通过用 with 来重写代码,我们可以很清晰地看到我们期望的执行路径以及捕获的具体错误是什么。

更进一步

通常,你会发现自己需要在具有相似错误值的数据中进行匹配,这样会使得处理错误情况更加复杂。我喜欢用的一个小技巧是,使用像 {:my_atom, “expected_value”} 这样的有唯一标识的键值对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def update(conn, params \\ %{}) do
user = conn.assigns.user
with {:ok, document} <- Document.get(params["id"]),
{:can_view?, true} <- {:can_view?, Authorizer.can_view?(document, user)},
{:can_edit?, true} <- {:can_edit?, Authorizer.can_edit?(document, user)}
{:ok, updated_document} <- Documents.update(document, params) do
Notifications.push_document_updated(updated_document)
json(conn, document)
else
{:error, :not_found} ->
render(conn, ErrorView, :"404")
{:can_view?, false} ->
render(conn, ErrorView, :"404")
{:can_edit?, false} ->
render(conn, ErrorView, :"403")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, ErrorView, :"400", changeset)
end
end

增加一个唯一原子使得更易于标识特定的错误然后返回适当的结果。

总结

with 帮助我们在不牺牲错误处理功能和可读性上写出更加整洁,更富表达性的代码。任何时候,当你需要处理复杂的逻辑流程的时候,都记得使用这个宏的优点。最后别忘了仔细阅读官方关于 with 的文档

原文链接: https://dockyard.com/blog/2018/03/30/better-control-flow-using-the-with-macro