你能听到鼓声吗,Erlando?
我们 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 中,对名为 return
或 fail
的函数的任何函数调用都会自动重写为调用当前 monad 中的 return
或 fail
。
一些熟悉 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 中,没有提及 nothing
或 just
:它们被 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 中 get
和 set
(或只是普通的 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/2
和 sequence/2
。我们还实现了 monad_plus
,它适用于具有明显的零和加意义的 monads(目前是 Maybe-monad、List-monad 和 Omega-monad)。相关的函数 guard
、msum
和 mfilter
在 monad_plus
模块中可用。
在许多情况下,从 Haskell 到 Erlang 的相当机械的翻译是可能的,因此在许多情况下,转换其他 monads 或 combinators 应该是直接的。但是,Erlang 中缺少类型类是有限制的。