跳至主内容

你能听到鼓声吗,Erlando?

·15 分钟阅读
Matthew Sackman

我们 RabbitMQ 总部的大多数人都曾在 Erlang 之外的许多函数式语言工作过,例如 Haskell、Scheme、Lisp、OCaml 等。虽然 Erlang 有很多值得称道的地方,比如它的虚拟机/模拟器,但不可避免地会有我们从其他语言中怀念的功能。对我来说,在回到 RabbitMQ 之前,我在 Haskell 工作了几年,结果发现有各种各样的功能“缺失”,比如惰性求值、类型类、额外的中缀运算符、指定函数优先级的能力、更少的括号、部分应用、更一致的标准库和 do-notation。这是一个不错的列表,我需要花点时间才能在 Erlang 中全部实现它们,但这里有两个作为开端。

简介

Erlando 是一组 Erlang 的语法扩展。目前它包含两个语法扩展,它们都以 parse transformers 的形式存在。

  • Cut:这为 Erlang 增加了对 cut 的支持。这些 cut 的灵感来源于 Scheme 形式的 cut。Cut 可以被认为是抽象的一种轻量级形式,与偏函数应用(或柯里化)有相似之处。
  • Do:这为 Erlang 增加了对 do-syntax 和 monad 的支持。这些的灵感主要来自 Haskell,并且 monad 和库几乎是 Haskell GHC 库的机械翻译。

使用

要使用这些 parse transformer 中的任何一个,您都必须将必要的 -compile 属性添加到您的 Erlang 源文件中。例如:

-module(test).
-compile({parse_transform, cut}).
-compile({parse_transform, do}).

然后,在编译 test.erl 时,您必须确保 erlc 可以通过 -pa-pz 参数将合适的路径传递给 erlc 来找到 cut.beam 和/或 do.beam。例如:

erlc -Wall +debug_info -I ./include -pa ebin -o ebin  src/cut.erl
erlc -Wall +debug_info -I ./include -pa ebin -o ebin src/do.erl
erlc -Wall +debug_info -I ./include -pa test/ebin -pa ./ebin -o test/ebin test/src/test.erl

注意,如果您正在使用 QLC,您可能会发现需要注意 parse transforms 的顺序:我发现 -compile({parse_transform, cut}). 必须出现在 -include_lib("stdlib/include/qlc.hrl"). 之前。

Cut

动机

Cut 的动机是 Erlang 中简单抽象(在 lambda 演算的意义上)使用的频率,以及声明 fun 的相对繁琐的性质。例如,经常会看到如下代码:

with_resource(Resource, Fun) ->
case lookup_resource(Resource) of
{ok, R} -> Fun(R);
{error, _} = Err -> Err
end.

my_fun(A, B, C) ->
with_resource(A, fun (Resource) ->
my_resource_modification(Resource, B, C)
end).

也就是说,一个 fun 被非常简单地创建,以便从其周围的作用域捕获变量,但留下用于提供更多参数的“空位”。使用 cut,函数 my_fun 可以重写为:

my_fun(A, B, C) ->
with_resource(A, my_resource_modification(_, B, C)).

定义

通常,变量 _ 只能出现在模式中:即在发生匹配的地方。这可以在赋值、case 语句和函数头中。例如:

{_, bar} = {foo, bar}.

Cut 在表达式中使用 _ 来指示抽象发生的位置。来自 cut 的抽象 **总是** 在最浅的封闭表达式上执行。例如:

list_to_binary([1, 2, math:pow(2, _)]).

将创建表达式:

list_to_binary([1, 2, fun (X) -> math:pow(2, X) end]).

而不是:

fun (X) -> list_to_binary([1, 2, math:pow(2, X)]) end.

在一个表达式中使用多个 cut 是可以的,并且创建的抽象的参数将与表达式中找到 _ 变量的顺序相匹配。例如:

assert_sum_3(X, Y, Z, Sum) when X + Y + Z == Sum -> ok;
assert_sum_3(_X, _Y, _Z, _Sum) -> {error, not_sum}.

test() ->
Equals12 = assert_sum_3(_, _, _, 12),
ok = Equals12(9, 2, 1).

对 cut 进行 cut 是完全合法的,因为 cut 创建的抽象是一个普通的 fun 表达式,因此可以根据需要重新 cut。

test() ->
Equals12 = assert_sum_3(_, _, _, 12),
Equals5 = Equals12(_, _, 7),
ok = Equals5(2, 3).

请注意,因为 cut 构造的是一个简单的 fun,所以参数在 cut 函数被调用之前就被求值了。例如:

f1(_, _) -> io:format("in f1~n").

test() ->
F = f1(io:format("test line 1~n"), _),
F(io:format("test line 2~n")).

将打印出:

test line 2
test line 1
in f1

这是因为 cut 创建了 fun (X) -> f1(io:format("test line 1~n"), X) end。因此,很明显 X 必须先被求值,然后才能调用 fun

当然,没有人会疯狂到在函数参数表达式中有副作用,所以这永远不会造成任何问题!

Cut 不限于函数调用。它们可以在任何有意义的表达式中使用:

元组

F = {_, 3},
{a, 3} = F(a).

列表

dbl_cons(List) -> [_, _ | List].

test() ->
F = dbl_cons([33]),
[7, 8, 33] = F(7, 8).

请注意,如果您在 Erlang 中将列表嵌套为列表尾部,它仍然被视为一个表达式。例如:

A = [a, b | [c, d | [e]]]

与以下内容完全相同(从 Erlang 解析器开始):

A = [a, b, c, d, e]

也就是说,当这些子列表位于尾部位置时,它们 **不** 形成子表达式。因此:

F = [1, _, _, [_], 5 | [6, [_] | [_]]],
%% This is the same as:
%% [1, _, _, [_], 5, 6, [_], _]
[1, 2, 3, G, 5, 6, H, 8] = F(2, 3, 8),
[4] = G(4),
[7] = H(7).

但是,请非常清楚 ,| 之间的区别:列表的尾部 **仅** 在 | 之后定义。在 , 之后,您只是定义另一个列表元素。

F = [_, [_]],
%% This is **not** the same as [_, _] or its synonym: [_ | [_]]
[a, G] = F(a),
[b] = G(b).

记录

-record(vector, { x, y, z }).

test() ->
GetZ = _#vector.z,
7 = GetZ(#vector { z = 7 }),
SetX = _#vector{x = _},
V = #vector{ x = 5, y = 4 } = SetX(#vector{ y = 4 }, 5).

Case

F = case _ of
N when is_integer(N) -> N + N;
N -> N
end,
10 = F(5),
ok = F(ok).

有关更多示例,包括在列表推导式和二进制构造中使用 cut,请参见 test_cut.erl

请注意,cut 不允许在 cut 的结果只能通过与求值作用域交互才有用的地方使用。例如:

F = begin _, _, _ end.

这是不允许的,因为 F 的参数必须在调用其主体之前被求值,而那时它们已经完全求值了,所以它们将没有任何效果。

Do

Do parse transformer 允许在 Erlang 中使用 Haskell 风格的 do-notation,这使得使用 monad 和 monad transformer 成为可能且容易。没有 do-notation,monad 往往看起来像很多行噪声。

不可避免的 Monad 教程

逗号的机制

下面是对 monad 的简短而机械的介绍。它与许多 Haskell monad 教程不同,因为它们倾向于将 monad 视为在 Haskell 中实现操作序列化的手段,这在 Haskell 是惰性语言的情况下是具有挑战性的。Erlang 不是惰性语言,但通过使用 monad 可以实现的强大抽象仍然非常有价值。虽然这是一个非常机械的教程,但应该能够看到更高级的抽象。

假设我们有三行代码:

A = foo(),
B = bar(A, dog),
ok.

这三条简单的语句会连续求值。Monad 让你能够控制语句之间发生的事情:在 Erlang 中,它是一个程序化的逗号。

如果您想实现一个程序化的逗号,您会怎么做?您可能会从类似这样的东西开始:

A = foo(),
comma(),
B = bar(A, dog),
comma(),
ok.

但这还不够强大,因为除非 comma/0 抛出某种异常,否则它实际上无法阻止后续表达式被求值。大多数时候,我们可能希望 comma/0 函数能够作用于当前作用域中的一些变量,而在这里也不可能。所以我们应该扩展函数 comma/0,使其接受前一个表达式的结果,并能够选择后续表达式是否应该被求值。

comma(foo(),
fun (A) -> comma(bar(A, dog),
fun (B) -> ok end)).

因此,函数 comma/2 接受来自前一个表达式的所有结果,并控制它们如何以及是否传递给下一个表达式。

按照定义,comma/2 函数就是 monadic 函数 >>=/2

现在通过 comma/2 函数阅读程序相当困难(尤其因为 Erlang 烦人地不允许我们定义新的中缀函数),这就是为什么需要一些特殊的语法。Haskell 有它的 do-notation,所以我们借鉴了它并滥用了 Erlang 的列表推导式。Haskell 还有漂亮的类型类,我们已经为 monad 专门“伪造”了它们。因此,通过 Do parse transformer,您可以在 Erlang 中编写:

do([Monad ||
A <- foo(),
B <- bar(A, dog),
ok]).

这可读且直接,但会被转换为:

Monad:'>>='(foo(),
fun (A) -> Monad:'>>='(bar(A, dog),
fun (B) -> ok end)).

这里并不打算让后一种形式比 comma/2 形式更具可读性——它不是。然而,应该清楚的是,函数 Monad:'>>='/2 现在对发生的事情拥有完全的控制权:右侧的 fun 是否被调用?如果被调用,用什么值?

多种多样的 Monad

现在我们有了相对不错的 monad 使用语法,我们可以用它们做什么?另外,在代码中:

do([Monad ||
A <- foo(),
B <- bar(A, dog),
ok]).

Monad 的可能值是什么?

第一个问题的答案是 几乎任何东西;第二个问题的答案是 实现 monad 行为的任何模块名称

上面,我们介绍了三个 monad 操作符之一,>>=/2。其他的是:

  • return/1:这会将一个值“提升”到 monad 中。我们稍后会看到它的例子。

  • fail/1:这接受一个描述所遇到错误的项,并通知当前使用的 monad 发生了某种错误。

请注意,在 do-notation 中,任何调用名为 returnfail 的函数,都会被自动重写为在当前 monad 中调用 returnfail

一些熟悉 Haskell monad 的人可能会期待看到第四个操作符 >>/2。有趣的是,事实证明,在严格语言中,除非您所有的 monad 类型都构建在函数之上,否则您无法实现 >>/2。这是因为在严格语言中,函数的参数在调用函数之前被求值。对于 >>=/2,第二个参数在调用 >>=/2 之前才被简化为一个函数。但是 >>/2 的第二个参数不是一个函数,所以在严格语言中,它会在调用 >>/2 之前被完全简化。这是有问题的,因为 >>/2 操作符旨在控制后续表达式是否被求值。唯一的解决方案是将基本 monad 类型设置为函数,然后这意味着 >>=/2 的第二个参数将成为一个函数到函数到结果的函数!但是,要求 '>>'(A, B) 的行为与 '>>='(A, fun (_) -> B end) 相同,所以我们这样做:每当我们遇到一个 do([Monad || A, B ]) 时,我们将其重写为 '>>='(A, fun (_) -> B end) 而不是 '>>'(A, B)。效果是 >>/2 操作符不存在。

最简单的 Monad 是 Identity-monad:

-module(identity_m).
-behaviour(monad).
-export(['>>='/2, return/1, fail/1]).

'>>='(X, Fun) -> Fun(X).
return(X) -> X.
fail(X) -> throw({error, X}).

这使得我们的程序化逗号的行为与 Erlang 的正常逗号相同。bind 操作符(>>=/2)不检查传递给它的值,并且总是调用后续的表达式 fun。

如果我们检查传递给序列组合子的值,会发生什么?一种可能性是 Maybe-monad:

-module(maybe_m).
-behaviour(monad).
-export(['>>='/2, return/1, fail/1]).

'>>='({just, X}, Fun) -> Fun(X);
'>>='(nothing, _Fun) -> nothing.

return(X) -> {just, X}.
fail(_X) -> nothing.

因此,如果前一个表达式的结果是 nothing,则后续表达式不会被求值。这意味着我们可以编写非常简洁的代码,一旦遇到任何失败就会立即停止。

if_safe_div_zero(X, Y, Fun) ->
do([maybe_m ||
Result <- case Y == 0 of
true -> fail("Cannot divide by zero");
false -> return(X / Y)
end,
return(Fun(Result))]).

如果 Y 等于 0,那么 Fun 将不会被调用,if_safe_div_zero 函数调用的结果将是 nothing。如果 Y 不等于 0,那么 if_safe_div_zero 函数调用的结果将是 {just, Fun(X / Y)}

我们在这里看到,在 do-block 中,没有提到 nothingjust:它们被 Maybe-monad 抽象掉了。因此,可以在不重写任何进一步代码的情况下更改使用的 monad。

使用像 Maybe-monad 这样的 monad 的一个常见地方是,您本会遇到大量嵌套的 case 语句来检测错误。例如:

write_file(Path, Data, Modes) ->
Modes1 = [binary, write | (Modes -- [binary, write])],
case make_binary(Data) of
Bin when is_binary(Bin) ->
case file:open(Path, Modes1) of
{ok, Hdl} ->
case file:write(Hdl, Bin) of
ok ->
case file:sync(Hdl) of
ok ->
file:close(Hdl);
{error, _} = E ->
file:close(Hdl),
E
end;
{error, _} = E ->
file:close(Hdl),
E
end;
{error, _} = E -> E
end;
{error, _} = E -> E
end.

make_binary(Bin) when is_binary(Bin) ->
Bin;
make_binary(List) ->
try
iolist_to_binary(List)
catch error:Reason ->
{error, Reason}
end.

可以转换为短得多的:

write_file(Path, Data, Modes) ->
Modes1 = [binary, write | (Modes -- [binary, write])],
do([error_m ||
Bin <- make_binary(Data),
{ok, Hdl} <- file:open(Path, Modes1),
{ok, Result} <- return(do([error_m ||
ok <- file:write(Hdl, Bin),
file:sync(Hdl)])),
file:close(Hdl),
Result]).

请注意,我们有一个嵌套的 do-block,这样,与非 monadic 代码一样,我们可以确保一旦文件打开,即使后续操作发生错误,我们也会调用 file:close/1。这是通过将嵌套的 do-block 包装在 return/1 调用中来实现的:即使内部 do-block 出错,错误也会被“提升”为外部 do-block 中的非错误值,从而继续执行到后续的 file:close/1 调用。

这里我们使用的是一个 Error-monad,它与 Maybe-monad 非常相似,但符合 Erlang 中指示错误的典型做法,即使用 {error, Reason} 元组。

-module(error_m).
-behaviour(monad).
-export(['>>='/2, return/1, fail/1]).

'>>='({error, _Err} = Error, _Fun) -> Error;
'>>='(Result, Fun) -> Fun(Result).

return(X) -> {ok, X}.
fail(X) -> {error, X}.

Monad Transformers

Monads 可以通过将 do-blocks 嵌套在 do-blocks 中来“嵌套”,并通过将 monad 定义为另一个内部 monad 的转换来“参数化”。State Transform 是一个非常常用的 monad transformer,并且对于 Erlang 尤其重要。因为 Erlang 是一个单赋值语言,所以经常会出现大量代码来逐步编号变量:

State1 = init(Dimensions),
State2 = plant_seeds(SeedCount, State1),
{DidFlood, State3} = pour_on_water(WaterVolume, State2),
State4 = apply_sunlight(Time, State3),
{DidFlood2, State5} = pour_on_water(WaterVolume, State4),
{Crop, State6} = harvest(State5),
...

这双重令人讨厌,不仅因为它看起来糟糕,而且因为每当添加或删除一行时,您都必须重新编号许多变量和引用。如果我们能够抽象出 State,那不是很好吗?然后我们可以有一个 monad 来封装状态,并将其提供给(并从)我们希望运行的函数。

我们对 monad-transformers(如 State)的实现使用了 Erlang 分布的一个“隐藏功能”,称为参数化模块。这些在 Erlang 中的参数化模块 中进行了描述。

State-transform 可以应用于任何 monad。如果我们将其应用于 Identity-monad,我们就会得到我们想要的。State transformer 为我们提供的关键额外功能是能够从内部 monad 中 getset(或直接 modify)状态。如果我们同时使用 Do 和 Cut parse transformers,我们可以编写:

StateT = state_t:new(identity_m),
SM = StateT:modify(_),
SMR = StateT:modify_and_return(_),
StateT:exec(
do([StateT ||

StateT:put(init(Dimensions)),
SM(plant_seeds(SeedCount, _)),
DidFlood <- SMR(pour_on_water(WaterVolume, _)),
SM(apply_sunlight(Time, _)),
DidFlood2 <- SMR(pour_on_water(WaterVolume, _)),
Crop <- SMR(harvest(_)),
...

]), undefined).

我们首先创建一个基于 Identity-monad 的 State-transform。

这是实例化参数化模块的语法。StateT 是引用模块实例的变量,在这种情况下,它是一个 monad。

我们为运行仅修改状态或修改状态返回结果的函数设置了两个快捷方式。虽然需要做一些簿记工作,但我们达到了我们的目标:现在不需要在每次更改时重新编号状态变量:我们使用 cut 在函数中留下 State 应该被注入的“空洞”,并且我们遵守如果函数同时返回结果和状态,它应该以 {Result, State} 元组的形式出现的协议。State-transform 会处理其余的事情。

超越 Monads

monad 模块中提供了一些标准的 monad 函数,如 join/2sequence/2。我们还实现了 monad_plus,它适用于那些具有明显的概念的 monad(目前是 Maybe-monad、List-monad 和 Omega-monad)。相关的函数 guardmsummfiltermonad_plus 模块中可用。

在许多情况下,从 Haskell 到 Erlang 的相当机械的翻译是可能的,因此在许多情况下转换其他 monad 或组合子应该是直接的。但是,Erlang 中缺乏类型类是有限制的。

© . This site is unofficial and not affiliated with VMware.