我之前听说过Erlang和BEAM没有打包静态类型系统,因为他们的创建者“无法构建它”。
虽然构建一个复杂、可靠和可伸缩的类型检查器绝对不是在公园里散步,但构建一个工作正常的类型检查器并不难。
如果这是可以做到的,它就会被完成,而且它会通过与OTP强化相同的过程来强化,并变成今天的样子:可靠性黄金标准。
因此,我决定编写一个Erlang类型检查器来真正理解输入Erlang的困难之处。我知道透析器的存在,这太棒了,我只是想从零开始对它有一个更好的理解。
我突然意识到,这里有一个分裂,有时很难注意到,这让世界变得截然不同。有一种名为Standard Erlang的Erlang语言,还有一种Erlang运行时,即BEAM。沿着相同的路线,首先是Java语言,然后是JVM。
我们倾向于把这两件事当作一件事来谈论,但是如果我们把语言从运行时中分离出来,我们会发现它们表现出非常不同的行为。
特别是,运行库给表分配和热代码加载带来了麻烦,这使得输入内容成为分布式系统的问题。是的,很有趣,但非常不切实际。
在接下来的文章中,我将对该语言的操作语义、如何实现您自己的类型检查器、运行库的分布式和动态特性以及它们如何相互交互做一个更全面、更正式的论述。
在这里,我将回顾一下我所学到的用更表面的方式输入Standard Erlang所需的知识,以及我们将会遇到的一些棘手的部分。
Erlang是一种动态的强类型语言。它只比典型的函数式编程语言多了一点内容。总体而言,Erlang不是那么大的语言。
我的粗略估计是它大约是OCaml的三分之一大小,大约是Java的五分之一大小。
%an Erlang模块-模块(HELLO_WORLD).-EXPORT([hi/1])。%<;--这是一个编译器注释hi(Noone)->;ad;hi(Name)->;<;";Hello,";,(Name)/inary,";!";>;>;。
一个Erlang程序由许多模块组成,每个模块只是一个文件,其中包含一系列函数和一些注释,这些注释用于告诉编译器导出了什么、模块作者是谁、函数类型应该是什么等等。
在Erlang中可以编写很多相对常见的内容:
还有一些事情根本就不常见:
但总的来说,语言的表现力来自于它相当简单。大多数问题都是通过使函数相互调用来解决的,大多数函数模式与它们需要的数据完全匹配,循环是通过递归实现的,然后我们就完成了。
异常非常常见,因为模式匹配失败将导致异常。为此,该语言支持CATCH和Try..Catch..After表达式,但是使用它们是非常简单的。
Erlang以监控而闻名的卓越正常运行时间实际上包含在运行时中,而不是语言中。语言本身没有进程、链接或发送消息的概念。它唯一拥有的就是用于消费消息的ReceiveExpression。
在编写类型检查器的过程中,我发现了一些值得提出来的事情:
可能还有其他语言结构对静态分析不友好。以后我会试着写更多的文章和报道更多的内容。
一个Erlang值可以有一个类型,但我们希望该类型只在运行时才知道。这意味着在我们运行函数之前,函数的输入类型和返回类型实际上都是未知的。
因此,无论何时您看到类似于上述hi/1的函数,调用它实际上都可能返回不同类型的值,这取决于函数是如何编写的。
例如,呼叫hi(Noone)将返回原子悲伤。相反,调用hi(<;<;&34;>;>;)会返回字符串<;<;&34;Hello,Joe!>;。原子Sad和弦有不同的类型。调用hi(0)将导致异常。
如果我们要写下这个函数的类型,我们必须这样写:
这在所有其他动态语言中都存在:LISP、Lua、Javascript、Python、Ruby、Smalltalk,应有尽有。
编写一个处理这个问题的类型检查器当然是可能的,就像以前在Flow和TypeScrip这样的工具中做过的那样,但是如何做当然不是很明显。
为了更好地理解如何处理这个问题,我们需要谈谈UnionTypes。
联合类型允许我们表示一个值可以同时属于多个不同类型中的一个。例如,1是一个数字,但它也是一个(数字|bool)。同样,True是bool,但它也是(number|bool)。
这里的关键是,值本身并没有关于它实际属于联合的类型的显式信息,我们一定会通过改进来发现这一点。
通过使用守卫和模式匹配,我们可以通过两种方式进行这些细化。
通过使用保护,我们可以将值的实际类型细化为单一类型。例如:
%the臭名昭著的Left_Pad函数Left_Pad(A,B)When is_Binary(A)->;<;(A)/Binary,(B)/Binary>;>;;Left_Pad(A,B)When is_Number(A)->;Left_Pad(Binary:Copy(<;<;";";&>;&>;A),B)。
但多亏了IS_BINARY/1和IS_NUMBER/1保护,我们才能将输入类型细化为字符串或数字。
这使得可以说BINARY:COPY(<;<;";>;>;,A)是安全的,因为在这一点上我们知道is_number(A)是真的,因此A的类型已经从字符串|Number改进为只有Number。否则守卫就会是假的。
然而,我们不能使用任何功能作为警卫。我们只能使用Erlang运行时附带的内置函数。
通过模式匹配,我们可以做一些非常相似的事情,并建立我们对价值观的期望。例如:
%a旧的列表长度计数(A=[_|_])->;count(A,0).count([],C)->;C;count([_|T],C)->;count(T,C+1)。
在这种情况下,我们可以保证,如果我们的函数模式匹配,那么它将是一个列表。
我们真的不知道List元素是什么,但我们当然可以尝试继续模式匹配和使用保护,直到我们对其进行“足够”的提炼。
这两种方法虽然可以组合在一起,但它们有一个不幸的特性,它们不能推广到所有类型的数据。尽管如此,它们应该能够很好地工作:原子、文字,以及任何有警卫可用的东西。
地图、元组和记录可以使用它,但是它的伸缩性不是很好。实际上,每次需要支持新的地图/元组/记录名称时,都必须将其添加到联合中。即使你只需要他们中的一个领域。
在下一节中,我们将看到一种处理这些数据的不同方法,这种方法的可扩展性要好得多。
在使用将多个值打包为一个值的函数(与记录、映射或元组绑定)作为输入时,我们面临着一个不同的问题:只要输入类型中包含我们需要的内容,我们就不关心输入类型是什么。
例如,如果我们有两个共享某些东西的可能地图,它们都可以被认为是各自独立的类型,而这两种类型会有一些共同之处。
%This的类型为';Character';Character=#{Name=&>;&34;Alexander Hamilton";,Play=&>34;Hamilton";}。%This的类型为';Performer';Performer=#{Name=&>;";Lin-Manuel Miranda";}。
事实上,我想说它对于任何映射都可以很好地工作,只要它有一个字符串的命名键。
所以我们的函数类型不完全是(Character|Performer)。它实际上是“任何地图,只要它有一个名称键是一个字符串”。为此,我将使用此符号:#{name}。
一些类型系统通过结构子类型来支持这一点,而另一些类型系统则使用行多态性。主要区别在这个getter函数中并不明显,但在下一个函数setter中变得很明显:
结构子类型化方法会说,无论您放入什么,现在都知道它只有一个名称键。因此,您的所有其他键都消失了(在类型系统的眼中),如果您想恢复它们,就必须进行某种强制转换。这要归因于一种称为包容的子类型行为,以及TypeScrip处理这一问题的方式。
行多态方法没有信息丢失,而是携带M拥有的其余字段。这就是OCaml和PureScripths.com如何处理这个问题。在PureScript中,这适用于记录类型,而在OCaml中,这适用于面向对象系统。
这两种方法中的任何一种都适用于使用输入元组、记录或映射的所有字段的情况。
这比#1更容易处理,因为现在我们有了更多的信息来知道值的实际类型:标记。
%我们工作函数的类型(ATOM(";OK";)|ATOM(";NOT_OK&34;))->;{ATOM(";OK";),ATOM(";成功";)}|{ATOM(";错误";),字符串}。
它现在在四元组的左侧有这些常量值(ok,error),我们可以用它来进一步缩小预期类型的范围。
这是Erlang生态系统中的一种成熟模式,大多数可能失败的函数都会返回带有ok或error标签的结果元组。
公平地说,这仍然存在#1的问题。我们可以创建一个函数,返回一个ok标记的值,该值也是类型的无标记联合。
%a我们工作功能的更复杂版本work(Ok)->;{ok,go_on};work(Done)->;{ok,<;>;";伟大的工作!";>;>;};work(Not_Ok)->;{错误,<;&34;出现问题";>;>;}。
%我们将忽略In类型,因为我们将重点放在输出类型(In)->;{ATOM(";OK";),ATOM(";GO_ON";)|STRING}|{ATOM(";Error";),STRING}。
如果您得到的是{ok,value},则键入value的问题与我们上面讨论的完全相同。
但是,此模式有助于我们输入内容,因为我们始终可以一直向下标记数据:
%与上一个工作函数相同,但带有标签!Work(Ok)->;{ok,go_on};Work(Done)->;{ok,{Message,<;<;";Good Job!";>;&>;};Work(Not_OK)->;{错误,<;<;";&>出现问题。
这有点繁琐,但也更易于进行类型检查,因为类型检查器可以将特定的标记与特定的类型相关联。
语言中存在一种不对称性:在语言层面上,我们只能接收信息。没有用于发送消息的特殊构造。要发送消息,我们只需使用Erlang:Send/2函数或其操作员版本!
因此,在任何函数中,当我们遇到接收表达式时,我们实际上是在说两件事:
下面是辅助输入--这个函数将接收一个不是直接参数的值,它可以像使用一个参数一样使用它。
如果有超时(后面的部分),那么也有一个截止日期。从字面上讲,因为如果这是一个进程,它就会被扼杀。
我们现在不会太在意截止日期,但我们会看看我们收到的这些信息。以下是此表达式的一个示例:
我们对刚刚发生的模式匹配非常感兴趣,因为模式匹配本质上告诉我们可以接收什么消息。
如此之多,以至于我们可以将这个RECEIVE表达式看作是内置于语言中的一个函数,我们可以用自己的函数来调用它。
%将RECEIVE转换为函数调用器()->;Receive{X,Y}->;X+Y End。Adder()->;Receive(Fun({X,Y})->;X+Y End)。
现在我们可以用分析上面的点1、点2和点3的相同方式来分析这个内部函数。
如果我们使用加法器/0来启动进程,那么重新构造接收表达式实际上是让类型检查器能够理解进程可以接收到什么消息的关键。
另一个完全不同的地方是语言的动态部分,它允许我们在运行时通过特殊的语法来决定要运行什么代码。
Erlang支持完全动态地指定要在该模块中调用的模块和函数。
%M是我们的模块名称em=Hello。%F是我们的函数名称F=world。M:F()=:=Hello:world()。
但是,如果我们不准确跟踪M和F的值,我们怎么知道M:F()在这里会是什么类型呢?
我们必须计算M以获取实际的模块名称,并计算FT值以了解要查找的函数,然后查找该函数的实际类型。
如果M比上例中的变量绑定稍微复杂一些,我们可能无法知道它是什么。想象一下下面的场景:
%当IS_ATOM(名称)->;名称结束时,我们将以MessageM=接收名称的形式接收模块名称。M:Run()。
我们需要等待收到消息后才能知道要查找哪个模块。这是不可信的,因为我们甚至不知道是谁在发送信息,也不知道信息是否会被发送。
当然,现实世界中的Erlang倾向于依靠行为来确保某些类型存在,因此注释预期的模块行为可能会有所帮助:
%同上,但类型-注解M:Runnable Behavior=Receive Name When is_atom(Name)->;name end.M:run()。
这样,我们就能够分析这一点,并知道Run应该具有什么类型,或者立即抱怨Run实际上没有在该行为中实现,因此不能保证存在。
我希望我今天讨论的这几件事能让您了解输入Erlang语言的困难之处。这并不是不可能的,事实上,有些部分的打字相当简单,但它肯定有一些棘手的部分,我们应该小心处理。
在未来,我将写更多关于语言类型的内容,特别是像二进制字符串模式匹配这样的部分,以及运行库及其属性,例如热代码重载、确定进程类型以及如何检查类型安全的消息传递。
感谢Pontus Nagy、Manuel Rubio、Malcolm Matalka和Calin Capitanu抽出时间审阅本文的早期草稿。