跳到主要内容

你能听到鼓声吗,Erlando?

·16 分钟阅读
Matthew Sackman

我们 RabbitMQ 总部的大多数人除了 Erlang 之外,还花时间研究过许多函数式语言,例如 Haskell、Scheme、Lisp、OCaml 或其他语言。虽然 Erlang 有很多优点,例如它的 VM/模拟器,但不可避免地,我们都会怀念其他语言的一些特性。就我而言,在回到 RabbitMQ 阵营之前,我曾在 Haskell 工作了几年,各种各样的特性都“缺失”了,例如惰性求值、类型类、额外的中缀运算符、指定函数优先级的能力、更少的括号、部分应用、更一致的标准库和 do-notation。这是一个相当长的清单,我需要一段时间才能在 Erlang 中实现所有这些特性,但这里有两个入门特性。

简介

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

  • Cut:这为 Erlang 添加了对 cuts 的支持。这些 cuts 的灵感来源于 Scheme 形式的 cuts。Cuts 可以被认为是轻量级的抽象形式,与部分应用(或柯里化)类似。
  • Do:这为 Erlang 添加了对 do-syntax 和 monads 的支持。这些深受 Haskell 的启发,并且 monads 和库几乎是 Haskell GHC 库的机械翻译。

使用

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

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

然后,在编译 test.erl 时,您必须通过使用 -pa-pz 参数将合适的路径传递给 erlc,以确保 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-calculus 意义上)的使用频率很高,并且声明 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 在表达式中使用 _ 来指示应该发生抽象的位置。从 cuts 进行的抽象总是最浅的封闭表达式上执行。例如

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.

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

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).

对 cuts 进行 cuts 是完全合法的,因为 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

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

Cuts 不仅限于函数调用。它们可以用于任何有意义的表达式中

元组

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]]]

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

完全相同(从 Erlang 解析器开始)。即,当子列表位于尾部位置时,它们不会形成子表达式。因此

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).

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

请注意,不允许在 cut 的结果只能通过与评估范围交互才能有用的地方使用 cuts。例如

F = begin _, _, _ end.

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

Do

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

不可避免的 Monad 教程

逗号的机制

接下来是对 monads 的简要和机械的介绍。它与许多 Haskell monad 教程不同,因为它们倾向于将 monads 视为在 Haskell 中实现操作序列化的一种手段,这很有挑战性,因为 Haskell 是一种惰性语言。Erlang 不是一种惰性语言,但是使用 monads 可能实现的强大抽象仍然非常值得。虽然这是一个非常机械的教程,但应该有可能看到更高级的抽象。

假设我们有以下三行代码

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 也有可爱的类型类,我们专门为 monads 伪造了类型类。因此,使用 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 会被调用吗?如果会,用什么值?

许多不同类型的 Monads

所以现在我们有了一些相对不错的语法来使用 monads,我们能用它们做什么呢?此外,在代码中

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

Monad 的可能值是什么?

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

上面,我们介绍了三个 monadic 运算符之一,>>=/2。其他的是

  • return/1:这会将一个值提升到 monad 中。我们很快就会看到示例。

  • fail/1:这接受一个描述遇到的错误的术语,并通知当前使用的任何 monad 发生了一些类型的错误。

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

一些熟悉 Haskell monads 的人可能期望看到第四个运算符 >>/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。

如果我们确实检查传递给 sequencing combinators 的值,我们可以做什么?一种可能性导致 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,即使在后续操作中发生错误也是如此。这是通过用 return/1 调用包装嵌套的 do-block 来实现的:即使内部 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,它适用于具有明显的意义的 monads(目前是 Maybe-monad、List-monad 和 Omega-monad)。相关的函数 guardmsummfiltermonad_plus 模块中可用。

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

© . All rights reserved.