Erlang 和代码风格

对防御性编程风格的思考

正确的 Erlang 用法要求您不要编写任何类型的防御性代码。这称为意图编程(intentional programming)。您为预期代码采用的意图控制流路径编写代码。而且您不会为您认为不可能的路径编写任何代码。此外,您不会为数据流编写代码,这不是程序的意图。

这是一个愚蠢的效果

如果 Erlang 程序出错,它就会崩溃。假设我们正在打开一个文件。我们可以像这样进行文件打开调用:

{ok, Fd} = file:open(Filename, [raw, binary, read, read_ahead]),

如果文件不存在会怎样?好吧,这个过程崩溃了。但请注意,我们不必为该路径编写任何代码。Erlang 的默认设置是在匹配无效时崩溃。我们收到错误匹配错误,原因是我们无法打开文件。

进程崩溃不是问题。该程序仍在运行和监督(Erlang 中一个重要的容错概念),将确保我们稍后再试一次。假设我们偶然在文件打开时引入了竞争条件。如果它很少发生,即使文件打开不时失败,程序仍然会运行。

您经常会看到如下代码:

ok = foo(...),
ok = bar(...),
ok = ...

然后断言这些调用中的每一个都运行良好,如果控制和数据流不是预期的,确保代码崩溃。

请注意这完全缺乏错误处理。我们不写以下代码

case foo(...) of
    ok -> case bar(...) of ... end;
    {error, Reason} -> throw({error, Reason})
end,

并且我们也不会落入 Go 编程语言的陷阱,并写下如下代码:

res, err := foo(...) 
if err != nil { 
    panic(...) 
} 
res2, err := bar(...) 
if err != nil { 
    panic(...) 
}

因为这也很愚蠢,写起来既乏味又麻烦。

关键是我们在 Erlang 解释器中有一个崩溃效果,我们可以调用它,如果出现问题,默认是崩溃进程,并清理另一个进程。好的 Erlang 代码尽可能多地使用了这个事实。

故意的?

注意故意这个词。在某些情况下,我们确实希望调用失败。所以我们只是像其他人一样处理它,但是由于我们可以在 Erlang 中模拟 sum-types,我们可以比没有 sum-type 概念的语言做得更好:

case file:open(Filename, [raw, read, binary]) of
    {ok, Fd} -> ...;
    {error, enoent} -> ...
end,

在这里,我们写下了该文件可能不存在的意图。然而:

  • 我们担心文件不存在。
  • 如果我们在eaccess上崩溃,这意味着由于权限导致的访问错误。
  • eisdir 、enotdir、enospc 也是如此。

为什么?

更精简的代码,这就是原因。

我们可以跳过很多防御性代码,这些代码通常会使项目的代码大小减少一半以上。需要维护的代码要少得多,因此当我们重构时,我们也需要操作更少的代码。

我们的代码没有与“正常”代码流无关的乱七八糟的东西。这使得阅读代码和确定发生了什么变得容易得多。

Erlang 进程崩溃会在某些东西死亡时提供大量信息。对于正确的 OTP 进程,我们会在进程死亡之前获取进程的状态以及发送给它的触发崩溃的消息。在大约 50% 的情况下,这样的转储就足够了,您只需查看故障转储即可重现错误。实际上,这消除了许多愚蠢的日志记录代码。

数据流防御性编程

另一种弄乱 Erlang 程序的常见方法是通过模式匹配来破坏传入的数据。像下面这样的东西:

convert(I) when is_integer(I) -> I;
convert(F) when is_float(F) -> round(F);
convert(L) when is_list(L) -> list_to_integer(L).

该函数会将“任何东西”转换为整数。然后你继续使用它:

process(Anything) -> I = convert(Anything), ...I...

这里的问题不在于流程功能,而在于流程功能的调用点。每个调用点对该代码中传递的数据有不同的看法。这导致每个子系统都处理此类转换的情况。

这种反模式有几种伪装。这是另一种味道:

convert({X, Y}) -> {X, Y};
convert(B) when is_binary(B) ->
    [X, Y] = binary:split(B, <<"-">>),
    {X, Y}.

这是字符串化编程,其中所有数据都被推入一个字符串,然后在每个调用者处手动解构。这会导致很多丑陋的代码,以后几乎没有扩展性。

与其尝试处理不同的类型,不如在 api 早期强制执行不变量:

process(I) when is_integer(I) -> ...

然后永远不要在子系统内部测试正确性。dialyzer擅长推断I作为整数的用途。使用is_integer守卫进行测试会搞乱你的代码,并且不会带来任何好处。如果你的子系统有问题,代码会崩溃,你可以去处理错误。

这里有一些关于静态类型的东西要说,它会让你很容易地离开这个统一的世界。在静态类型语言中,我仍然可以获得相同的东西,但是我必须按照 (* Standard ML code follow *) 的方式定义一些东西。

datatype anything = INT of int
                  | STRING of string
                  | REAL of real

像这样,很快就变得难以为其编写模式匹配,因此人们只在真正需要时才定义任何类型。(Gilad Bracha 认为对值(value)进行运行时检查是部分正确的,但他忽略了一个事实,即程序员应避免昂贵的运行时检查——一次又一次,Gilad ☺)。

未定义(undefined)造成的祸害

另一个重要的坏代码味道是未定义值。通常的场景是使用 undefined 编写 Option/Maybe monad。也就是说,我们有类型

-type option(A) :: undefined | {value, A}.

[静态化地看待这个问题:Erlang确实有一个基于成功类型的类型系统来找出错误,上面就是这样一种类型定义]

将反射/具体化定义为这些的异常效果很简单。Jakob Sievers stdlib2 库已经做到了这一点,并定义了称为do的 monadic 助手(尽管 monad 是Error-Type而不是Option)。

但我见过以下代码:

-spec do_x(X) -> ty() | undefined
  when X :: undefined | integer().
do_x(undefined) -> undefined;
do_x(I) -> ...I....

这导致复杂的代码。您需要 100% 控制哪些值可以失败,哪些值不能。像上面这样的结构会默默地传递 undefined 。这有它的用途——但是当你看到这样的代码时要小心。未定义的值本质上是NULL。这些是 C.A.R Hoare 犯下的十亿美元级的错误。

问题是上面的代码是可以为空的。Erlang 的默认设置是你永远不会有类似 NULL 的值。再次引入它们应该谨慎使用。您将不得不仔细考虑,因为一旦一个值可以为空,您就需要一直检查它。这往往会使代码错综复杂。最好预先测试这些东西,然后尽可能将其排除在代码库的主要部分之外。

“开放”的数据表示

每当您拥有数据结构时,就会有一组模块了解并操作该数据结构。如果只有一个模块,您可以模拟标准 ML 或 OCaml 的通用模式,其中具体的数据结构表示对于大多数程序来说是抽象的,并且只有一个模块可以对抽象类型进行操作。

这在 Erlang 中并不完全正确,任何人都可以内省任何数据。但是保持这种错觉对于可维护性很方便。

可以操作数据结构的模块越多,更改该数据结构的难度就越大。将记录放入头文件时请考虑这一点。可能的问题有两个级别:

  • 您将record定义放在src的头文件中。在这种情况下,只有应用程序本身可以看到记录,因此它们不会泄漏。
  • 您将record定义放在头文件的include中。在这种情况下,记录可能会从应用程序中泄露出来,而且经常会泄露出去。

一个很好的例子是 HTTP 服务器cowboy,它的请求对象通过cowboy_req模块进行操作。这意味着内部表示可以改变,同时保持其他地方在模块 API 上的稳定。

在某些情况下,导出record是有意义的。但在这样做之前请三思。如果一条记录由多个模块操作,那么通过重新考虑程序的结构,您很可能会收获更多。

true、false和原子类型

最后一点,我经常看到这样的代码:

f(X, Y, true, false, true, true),

这很难阅读。由于这是 Erlang,您可以为真值(true)和假值(false)使用更好的名称。只需选择一个有意义的原子,然后生成该原子。如果参数被意外交换,它还具有尽早捕获更多错误的优势。另请注意,您可以通过传递元组将信息绑定到结果。关于布尔盲的概念有很多话要说,在典型的程序中意味着过度依赖 boolean() 值。问题是,如果你得到一个真实的说法,你不知道为什么它是真的。你需要关于其真实性的证据。这可以通过在一个元组中传递这个证据来实现。例如,我们可以有这样的函数:

case api:resource_exists(ID) of
    true -> Resource = api:fetch_resource(ID), ...;
    false -> ...
end.

但我们也可以用更直接的方式来写它:

case api:fetch_resource(ID) of
    {ok, Resource} -> ...;
    not_found -> ...
end.

从长远来看,这不太容易出错。我们不能偶然调用fetch_resource函数,如果我们查找资源,我们也会获得资源是什么的证据。如果我们真的不想使用资源,我们可以把它扔掉。

结束语

经验法则有被打破的情况。所以某些情况下,他们必须被打破。但是,如果您碰巧来到这里,我希望您学到了一些东西或者不得不停下来思考一些事情。成为一个更好的程序员的方法是学习别人的风格。

Related Posts

2021 年你需要知道的关于 Erlang 的一切

今天,我们将看一个相当古老且有些古怪的东西。 你们大多数人可能没有注意到的语言。 虽然 Erlang 不像某些现代编程语言那样流行,但它安静地运行着 WhatsApp 和微信等每天为大量用户提供服务的应用程序。 在这篇文章中,我将告诉你关于这门语言的更多事情、它的历史,以及你是否应该考虑自己学习它。 ## 什么是 Erlang,它在哪里使用? Erl

Read More

Erlang JIT中基于类型的优化

这篇文章探讨了 Erlang/OTP 25 中基于类型的新优化,其中编译器将类型信息嵌入到 BEAM 文件中,以帮助JIT(即时编译器)生成更好的代码。 ## 两全其美 OTP 22 中引入的基于SSA的编译器处理步骤进行了复杂的类型分析,允许进行更多优化和更好的生成代码。然而,Erlang 编译器可以做什么样的优化是有限制的,因为 BEAM 文件必须

Read More

Erlang JIT之路

自从Erlang 存在,就一直有让它更快的需求和野心。这篇博文是一堂历史课,概述了主要的 Erlang 实现以及如何尝试提高 Erlang 的性能。 ## Prolog 解释器 Erlang 的第一个版本是在 1986 年在 Prolog 中实现的。那个版本的 Erlang 对于创建真正的应用程序来说太慢了,但它对于找出Erlang语言的哪些功能有用,哪

Read More