跳到主要内容

你听到鼓声了吗,Erlando?

·阅读时间:16分钟
Matthew Sackman

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

简介

Erlando 是 Erlang 的一组语法扩展。目前它包含两个语法扩展,它们都以 解析器转换器 的形式出现。

  • **Cut**: 它为 Erlang 添加了对 cut 的支持。这些 cut 的灵感来自 Scheme 中的 cut 形式。可以将 cut 视为一种轻量级的抽象形式,与部分应用(或柯里化)类似。
  • **Do**: 它为 Erlang 添加了对 do 语法和单子的支持。这些灵感主要来自 Haskell,其单子和库几乎是从 Haskell GHC 库中直接翻译过来的。

使用

要使用任何这些解析器转换器,您必须在 Erlang 源文件中添加必要的 -compile 属性。例如

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

然后,在编译 test.erl 时,您必须确保 erlc 可以通过 -pa-pz 参数传递合适的路径来找到 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,您可能需要小心解析转换器的顺序:我发现 -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).

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

注意,不允许在 cut 的结果只能通过与求值作用域交互才能使用的情况下使用 cut。例如

F = begin _, _, _ end.

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

Do

Do 解析器转换器允许在 Erlang 中使用 Haskell 风格的do 语法,这使得使用单子和单子转换器变得可能且容易。如果没有do 语法,单子往往看起来像很多杂乱的代码。

不可避免的单子教程

逗号的机制

下面是对单子进行简要的机械介绍。它与许多 Haskell 单子教程有所不同,因为这些教程倾向于将单子视为在 Haskell 中实现操作顺序的一种手段,而这在 Haskell 中是一个挑战,因为 Haskell 是一种惰性语言。Erlang 不是惰性语言,但使用单子实现的强大抽象仍然非常值得关注。虽然这是一个非常机械的教程,但应该可以看出更高级的抽象是可能的。

假设我们有以下三行代码

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

它们是三个简单的语句,按顺序求值。单子赋予您的能力是对语句之间发生的事情进行控制:在 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 函数是单子函数 >>=/2

现在,使用 comma/2 函数来阅读程序就比较困难了(尤其是在 Erlang 中不能定义新的中缀函数的情况下),这就是为什么需要一些特殊的语法的原因。Haskell 有其do 语法,因此我们借鉴了这一点,并滥用 Erlang 的列表推导。Haskell 还拥有可爱的类型类,我们专门针对单子对它们进行了模拟。因此,使用 Do 解析器转换器,您可以在 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 是否会被调用?如果调用,使用什么值?

许多不同类型的单子

现在我们有了相对友好的语法来使用单子,我们能用它们做什么?另外,在代码中

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

Monad 的可能值是什么?

第一个问题的答案是几乎所有东西;第二个问题的答案是任何实现了单子行为的模块名

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

  • return/1:它将一个值提升到单子中。我们将在后面看到这方面的示例。

  • fail/1:它接收一个描述遇到的错误的项,并告知当前正在使用的单子发生了某种错误。

注意,在do 语法中,对名为 returnfail 的函数的任何函数调用都会自动重写为在当前单子中调用 returnfail

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

最简单的单子是 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 块中,没有提到 nothingjust:它们被 Maybe-monad 抽象掉了。因此,可以更改使用的单子,而无需重写任何其他代码。

使用 Maybe-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 块,以便与非单子代码一样,我们确保一旦文件打开,我们总是调用 file:close/1,即使后续操作中出现错误。这是通过将嵌套的 do 块用 return/1 调用包装来实现的:即使内部 do 块出现错误,错误也会提升到外部 do 块中的非错误值,因此执行将继续到后续的 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}.

单子转换器

单子可以通过在 do 块中嵌套 do 块来进行嵌套,并且可以通过将单子定义为另一个内部单子的转换来进行参数化。State Transform 是一个非常常用的单子转换器,它与 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,那不是很棒吗?然后我们可以有一个单子封装状态并将它提供给(并从中收集)我们想要运行的函数。

我们对单子转换器(如 State)的实现使用 Erlang 分发的一个“隐藏特性”,称为参数化模块。这些在 Parameterized Modules in Erlang 中有描述。

State 转换可以应用于任何单子。如果我们将它应用于 Identity-monad,那么我们就会得到我们想要的东西。State 转换器为我们提供的关键额外功能是能够从内部单子中获取和设置(或仅仅修改)状态。如果我们同时使用 Do 和 Cut 解析转换器,我们可以编写

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 转换。

这是实例化参数化模块的语法。StateT 是一个变量,它引用一个模块实例,在本例中是一个单子。

我们为运行仅修改状态或修改状态返回结果的函数设置了两个简写。虽然有一些簿记要做,但我们实现了我们的目标:现在没有需要在每次更改时重新编号的状态变量:我们使用 cut 在函数中留下 State 应该被输入的空位,并且我们遵守协议,如果函数返回结果和状态,它应该以 {Result, State} 元组的形式出现。State 转换器完成其余的工作。

超越单子

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

在许多情况下,从 Haskell 到 Erlang 的相当机械的转换是可能的,因此在许多情况下,转换其他单子或组合器应该是直截了当的。然而,Erlang 中缺乏类型类是有限制的。

© 2024 RabbitMQ. All rights reserved.