Erlang的单子


在哈布雷(Habré)上,您可以找到许多出版物,这些出版物揭示了Monad的理论及其应用实践。 这些文章大多数都是关于Haskell的。 我不会第n次重述该理论。 今天,我们将讨论一些Erlang问题,使用monad解决问题的方法,部分使用功能以及来自erlando的语法糖(来自RabbitMQ团队的一个很棒的库)。


引言


Erlang具有不变性,但没有monads * 。 但是由于语言中的parse_transform功能和erlando实现,仍然有可能在Erlang中使用monad。


关于故事一开始的豁免权,我并不是偶然地讲的。 免疫几乎无处不在,而且永远都是-Erlang的主要思想之一。 功能的免疫力和纯度使您可以专注于特定功能的开发,而不必担心副作用。 但是,例如来自Java或Python的Erlang新手发现,很难理解和接受Erlang的想法。 特别是当您回想起Erlang语法时。 那些尝试开始使用Erlang的人可能会注意到它的不寻常和独立性。 无论如何,我已经积累了很多来自初学者的反馈,“奇怪”的语法在评分中居于领先地位。


厄兰多


Erlando是一个Erlang扩展集,可为我们提供:


  • 部分使用/带有类似方案的削减功能
  • 类似于Haskell的符号
  • import-as-从其他模块导入功能的语法糖。

注意:我以下面的代码示例为例,从Matthew Sackman的演讲中说明了erlando的功能,并通过我的代码和说明对它们进行了部分稀释。


抽象切


直奔重点。 考虑实际项目中的几个功能:


info_all(VHostPath, Items) -> map(VHostPath, fun (Q) -> info(Q, Items) end). backing_queue_timeout(State = #q{ backing_queue = BQ }) -> run_backing_queue( BQ, fun (M, BQS) -> M:timeout(BQS) end, State). reset_msg_expiry_fun(TTL) -> fun (MsgProps) -> MsgProps #message_properties{ expiry = calculate_msg_expiry(TTL)} end. 

所有这些功能旨在将参数替换为简单表达式。 实际上,这是部分应用程序,因为在调用之前某些参数将是未知的。 这些功能加上灵活性,给我们的代码增加了噪音。 通过稍微更改语法-输入cut-可以改善情况。


值_


  • _可以在模板中使用
  • 剪切使您可以使用_外部模式
  • 如果它在模板之外,它将成为其所在表达式的参数
  • 在同一表达式中多次使用_会导致此表达式中多个参数的替换
  • Cut不能替代瓶盖(熔炉)
  • 在cut函数之前先评估参数。

Cut在表达式中使用_指示应在何处应用抽象。 Cut仅在表达式中包装最接近的级别,但不禁止嵌套cut。
例如list_to_binary([1, 2, math:pow(2, _)]). list_to_binary([1, 2, fun (X) -> math:pow(2, X) end]).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重写上面的示例:


 info_all(VHostPath, Items) -> map(VHostPath, fun (Q) -> info(Q, Items) end). info_all(VHostPath, Items) -> map(VHostPath, info(_, Items)). 

 backing_queue_timeout(State = #q{ backing_queue = BQ }) -> run_backing_queue( BQ, fun (M, BQS) -> M:timeout(BQS) end, State). backing_queue_timeout(State = #q{backing_queue = BQ}) -> run_backing_queue(BQ, _:timeout(_), State). 

 reset_msg_expiry_fun(TTL) -> fun (MsgProps) -> MsgProps #message_properties { expiry = calculate_msg_expiry(TTL) } end. reset_msg_expiry_fun(TTL) -> _ #message_properties { expiry = calculate_msg_expiry(TTL) }. 

参数计算顺序


为了说明参数的计算顺序,请考虑以下示例:


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

由于参数是在cut函数之前求值的,因此将显示以下内容:


 test line 2 test line 1 in f1 

减少各种类型和代码模式的抽象


  • 元组
     F = {_, 3}, {a, 3} = F(a). 
  • 清单
     dbl_cons(List) -> [_, _ | List]. test() -> F = dbl_cons([33]), [7, 8, 33] = F(7, 8). 
  • 记录
     -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). 
  • 案例
     F = case _ of N when is_integer(N) -> N + N; N -> N end, 10 = F(5), ok = F(ok). 
  • 地图
     test() -> GetZ = maps:get(z, _), 7 = GetZ(#{ z => 7 }), SetX = _#{x => _}, V = #{ x := 5, y := 4 } = SetX(#{ y => 4 }, 5). 
  • 匹配列表和构造二进制数据
     test_cut_comprehensions() -> F = << <<(1 + (X*2))>> || _ <- _, X <- _ >>, %% Note, this'll only be a /2 ! <<"AAA">> = F([a,b,c], [32]), F1 = [ {X, Y, Z} || X <- _, Y <- _, Z <- _, math:pow(X,2) + math:pow(Y,2) == math:pow(Z,2) ], [{3,4,5}, {4,3,5}, {6,8,10}, {8,6,10}] = lists:usort(F1(lists:seq(1,10), lists:seq(1,10), lists:seq(1,10))). 

优点


  • 代码变得更小,因此更易于维护。
  • 代码变得更加简单和整洁。
  • 有趣的声音消失了。
  • 对于Erlang的初学者来说,编写Get / Set函数更加方便。

缺点


  • 提高了经验丰富的Erlang开发人员的入门门槛,同时降低了初学者的入门门槛。 现在,团队需要了解cut并了解另一种语法。

做记号


软逗号是一种计算绑定构造。 Erlang没有惰性计算模型。 让我们想象一下,如果Erlang像Haskell一样懒惰会发生什么


 my_function() -> A = foo(), B = bar(A, dog), ok. 

为了保证执行顺序,我们需要通过定义逗号来显式链接计算。


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

继续转换:


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

根据结论,逗号/ 2是惯用函数>>=/2 。 monad仅需要三个函数: >>=/2return/1fail/1
一切都会好的,但是语法很糟糕。 我们使用erlando语法转换erlando


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

单声道的类型


由于do-block是参数化的,因此我们可以使用各种类型的monad。 在do块内部,将调用return/1fail/1部署到Monad:return/1Monad:fail/1


  • 身份单子。
    相同的monad是最简单的monad,它不更改值的类型并且不参与计算过程的控制。 它与变压器一起使用。 执行链接表达式-上面讨论的软件逗号。


  • 也许是单子。
    计算缺失值的单子计算。 将参数与参数化的计算相关联是将参数转移到计算,将不存在的参数链接到参数化的计算则是缺少结果。
    考虑一个maybe_m的例子:


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

    如果未返回任何内容,则表达式的求值停止。


     {just, 6} = if_safe_div_zero(10, 5, _+4) ## 10/5 = 2 -> 2+4 -> 6 nothing = if_safe_div_zero(10, 0, _+4) 

  • 错误单子。
    与maybe_m类似,仅具有错误处理。 有时,“崩溃让它”原理不适用,并且必须在错误发生时进行处理。 在这种情况下,案例中的阶梯通常会出现在代码中,例如:


     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. 


读起来很不愉快,看起来像JS中的回调面。 Error_m可以解决:


 write_file(Path, Data, Modes) -> Modes1 = [binary, write | (Modes -- [binary, write])], do([error_m || Bin <- make_binary(Data), Hdl <- file:open(Path, Modes1), Result <- return(do([error_m || file:write(Hdl, Bin), file:sync(Hdl)])), file:close(Hdl), Result]). make_binary(Bin) when is_binary(Bin) -> error_m:return(Bin); make_binary(List) -> try error_m:return(iolist_to_binary(List)) catch error:Reason -> error_m:fail(Reason) end. 

  • List-monad。
    值是可以解释为一次计算的几种可能结果的列表。 如果一个计算依赖于另一个,则对第一个结果的每个结果执行第二个计算,并将结果(第二个计算)收集在一个列表中。
    考虑带有经典毕达哥拉斯三元组的示例。 我们计算它们时不包含monad:
     P = [{X, Y, Z} || Z <- lists:seq(1,20), X <- lists:seq(1,Z), Y <- lists:seq(X,Z), math:pow(X,2) + math:pow(Y,2) == math:pow(Z,2)]. 

与list_m相同:


 P = do([list_m || Z <- lists:seq(1,20), X <- lists:seq(1,Z), Y <- lists:seq(X,Z), monad_plus:guard(list_m, math:pow(X,2) + math:pow(Y,2) == math:pow(Z,2)), return({X,Y,Z})]). 

  • 状态单子。
    有状态计算的Monad。
    在本文的开头,我们讨论了初学者在使用可变状态时遇到的困难。 该代码通常看起来像这样:
     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), ... 

使用转换符和剪切符号,可以更紧凑和易读的形式重写此代码:


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

  • 欧米茄单子。
    类似于list_m monad。 但是,通道是对角线的。

隐藏的错误处理


可能是我最喜欢的error_m monad功能之一。 无论错误发生在何处,monad始终会返回{ok, Result}{error, Reason} 。 一个示例说明此行为:


 do([error_m || Hdl <- file:open(Path, Modes), Data <- file:read(Hdl, BytesToRead), file:write(Hdl, DataToWrite), file:sync(Hdl), file:close(Hdl), file:rename(Path, Path2), file:delete(Path), return(Data)]). 

Import_as


对于小吃,我们有语法import_as sugar。 -import / 2属性的标准语法使您可以将其他函数导入本地模块。 但是,此语法不允许您为导入的函数分配替代名称。 Import_as解决了此问题:


 -import_as({my_mod, [{size/1, m_size}]}) -import_as({my_other_mod, [{size/1, o_size}]}) 

这些表达式分别扩展为实际的局部函数:


 m_size(A) -> my_mod:size(A). o_size(A) -> my_other_mod:size(A). 

结论


当然,通过monad,您可以通过更具表现力的方法来控制计算过程,节省代码和时间来支持它。 另一方面,它们为未经培训的团队成员增加了额外的复杂性。


* -实际上,在埃尔朗(Erlang)中,单子存在而没有erlando。 逗号分隔表达式是线性化和链接计算的构造。


PS最近,作者将erlando图书馆标记为档案。 我一年多以前写了这篇文章。 但是,与现在一样,那时在哈布雷(Habré)还没有关于埃尔朗(Erlang)单子的信息。 为了纠正这种情况,尽管有些迟,我还是发表了这篇文章。
要在erlang> = 22中使用erlando,您需要使用已弃用的erlang来解决该问题:get_stacktrace / 0。 可以在我的fork中找到一个修复示例: https : //github.com/Vonmo/erlando/commit/52e23ecedd2b8c13707a11c7f0f14496b5a191c2


感谢您的宝贵时间!

Source: https://habr.com/ru/post/zh-CN466697/


All Articles