在这篇文章中,我们将研究Gleam的类型系统(称为幻像类型)的更高级用法。希望到本文结束时,您将拥有另一个工具,可以帮助您在程序中更好地建模数据。不用担心,因为许多语言都支持幻像类型(大多数常用的函数式编程语言都支持它们,但是Rust,TypeScript甚至PHP等其他语言也支持幻像类型),因此您可以将这些知识应用于其他地方!
幻像类型是一种类型参数,它显示在类型定义的左侧,但不显示在右侧。换句话说,它是一个类型参数,任何类型的构造函数都不会使用它。
在Example类型中,我们有一个类型参数phantom,它在类型的构造函数中未使用。幻像类型可用于为值提供额外的安全性或上下文,而无需支付携带额外数据的运行时成本。一切都在编译时处理!
💡在某些语言中,当类型具有未使用的类型参数时,编译器可能会发出警告(或完全拒绝编译)。通常,有针对特定语言的解决方案,例如Rust中的PhantomData或TypeScript中不可能的字段。
本文的其余部分将使我们了解到幻像类型可以发挥作用的四种不同情况。整篇文章有点长,因此,如果有任何示例描述了一个非常适合您的方案,请直接跳转至此!
为了理解为什么幻象类型可能有用,让我们从一个常见的场景开始。想象我们正在建立一个像dev.to或medium.com这样的社交博客平台。我们希望支持不同的用户和博客文章,因此我们为所有这些事物分配了唯一的ID。
我们是一家蓬勃发展的快速发展的公司,因此我们实现了用于管理ID的最简单的系统:只需键入别名Ints即可使事情顺利进行。
pub type Id = Intpub fn next(id:Id)-> ID {id + 1}
我们的平台支持Reddit风格的帖子上载或喜欢,我们有一个专门的功能。它接受要投票的帖子的ID和对其进行投票的用户的ID,并做了一些魔术使投票得以实现。
pub fn upvote_post(用户ID:Id,post_id:Id)-> Nil {//从数据库中获取帖子并对其进行投票。 ...}
这可以让我们着急进行生产,但是到目前为止,您可能已经发现我们所拥有的潜在问题。直到有人以错误的方式获取参数,而现在一个完全不相关的用户才投票支持一个随机帖子,这只是时间问题。
一种解决方案是停止为Int别名,并改为为PostId和UserId定义新类型。
现在,两种id类型已成功脱节,但是结果,我们将得到大量重复的代码。我们必须有单独的next方法来增加每个id类型,类似地,如果我们要解开类型,我们将需要单独的to_int函数,并且to_string的情况也是如此,依此类推。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。
实际上,无论我们如何使用,Id的基础表示形式都保持不变。相反,我们喜欢使用ID来确定其是否有效的上下文。
现在,我们重新定义了ID类型,以包括类型参数a,但是请注意,该参数在类型构造函数中的使用方式:这是名称phantom type的来源。注意的第二件事是我们的新函数返回一个带有该泛型参数的id。这使函数的调用者可以确定a的类型。
现在,foo,bar和baz彼此不兼容。例如,因为类型不匹配,所以我们无法检查它们是否相等。幻像类型的基础是没有运行时组件的事实,可以将foo注释为Id(String),但在运行时不存在这样的字符串,对于Id(Float)或任何其他参数也是如此。
之所以强大,是因为我们可以告诉编译器有关特定ID的事实,并且编译器将做额外的工作以确保我们不会犯任何错误。
pub类型User {User(用户(id:Id(User),名称:字符串)}} pub类型Post {Post(id:Id(Post),内容:String)} pub fn upvote_post(user_id:Id(User),post_id:Id (发布))-> Nil {//从数据库中获取帖子并对其进行投票。 ...}
在上面的代码段中,我们定义了用户和帖子类型,并相应地设置了ID类型。了解到目前为止我们对幻影类型的了解,我们可以看到upvote_post的新类型签名将防止我们意外地交换ID的顺序。
let user = User(id:new(),名称:" pd-andy")let post = Post(id:new(),内容:" Gleam中的幻像类型...&# 34;)upvote_post(post.id,user.id)//哦,编译错误!
即使我们的幻像类型使我们可以专门使用Id类型,我们仍然可以通过将type参数保留为变量来编写适用于所有id的函数。
限制或开放功能以使用不同ID的能力是幻像类型强大功能的来源。
让我们考虑另一种情况。我们希望构建一个可以处理货币交易的应用程序,以便我们可以轻松使用相同类型的货币,但是必须先通过汇率明确转换不同的货币,然后才能一起使用它们。
类似于我们的id方案,货币值的基本表示形式始终相同。
和以前一样,我们的Currency类型只有一个类型参数,我们将使用它来标记具有特定货币类型的值。我们还需要定义一些类型来充当我们的货币标签。由于这些类型仅用于注释,因此我们将其设为不透明。
目前,我们有一些货币价值,但是我们不能对它们做很多事情。当它们被包装成我们的Currency类型时,我们不能使用任何算术运算符或将这些值传递给需要Floats的函数。
让我们补救一下,通过编写两个函数进行更新和合并。我们将使用update将功能应用于由Currency换行的值,而我们将使用Combine将功能应用于两个Currency值。
💡对于其他数据结构,这些功能可能称为map和map2。这些意味着类型可以更改,例如list.map可用于将List(a)转换为List(b)。
因为我们要保留类型(因此我们不能将Currency(USD)转换为Currency(GBP)),所以我们为这些函数指定了不同的名称,因此不会出现任何不匹配的期望。
pub fn update(a:Currency(a),f:fn(Float)-> Float)-> Currency(a){让Currency(x)= a x |> f |> from_float} pub fn Combine(a:Currency(a),b:Currency(a),f:fn(Float,Float)-> Float)->;货币(a){令货币(x)=令货币(y)= b f(x,y)|> from_float}
因为Currency的类型参数不变(这些函数采用Currency(a)并返回Currency(a)),所以它们不能更改传入的任何货币的标记。
update更新和合并都是高阶函数的示例。也就是说,它们是将其他函数作为其参数之一的函数(或自己返回一个新函数)。
我们可以使用这两个函数来定义更多的函数,以便实际上可以使用货币值来进行操作,例如将内容加倍或将两种货币加在一起。
pub fn double(a:Currency(a))-> Currency(a){update(a,fn(x){x * 2})} pub fn add(a:Currency(a),b:Currency(a))->货币(a){组合(a,b,fn(x,y){x + y}}}}
我们可以使用任何一种货币来调用这些函数,但是对于诸如add之类的东西,我们将获得编译时安全性,以确保我们仅添加两种相同类型的货币。
但是,如果我们想将两种货币加在一起怎么办?为此,我们需要一种以汇率将一种货币转换为另一种货币的方法。我们可以在这里再次使用幻像类型来定义
pub不透明类型Exchange(从,到){Exchange(Float)} pub fn exchange_rate(r:Float)->交换(从,到){交换(r)}
现在,就像我们对货币所做的一样,我们可以将汇率定义为从GBP到USD(反之亦然)。
使用我们对幻像类型的所有了解,我们可以定义一个类型安全的convert函数;我们将永远无法传递错误的汇率,因为所有幻像类型都必须匹配!
pub fn convert(a:Currency(from),e:Exchange(from,to))-> Currency(to){let Currency(x)= let Exchange(r)= e x *。 r |> from_float}
尽管我们的模块提供了充当货币标签的USD和GBP类型,但是我们编写的功能是所有货币通用的,但保留其类型安全性。如果该模块的使用者想要定义另一种货币,则可以这样做,并且我们的功能仍将起作用。
到目前为止,我们已经在Id和Currency中看到的两个示例已用于为共享相同基础表示形式的类型提供通用API。调用者已经能够通过提供类型注释简单地向编译器断言某种事物的类型。这样,编译器将停止在错误的位置使用两个不相交的值。
但是我们可以将幻像类型用于相反的目的,以限制消费者可以创建的值的类型,并通过我们的验证代码来推送它们。
pub opaque类型密码(未验证){密码(字符串)} pub opaque类型无效{无效} pub opaque类型有效{Valid} pub fn from_string(s:String)->密码(无效){密码}
与前面的示例不同,这里的from_string函数返回一个Password(Invalid)而不是调用者可以手动断言的常规类型。这是幻像类型的另一个强大方面。在此示例中,密码类型是不透明的,因此,如果该模块的使用者想要创建密码,则必须通过from_string函数。
这样,他们将创建一个无效的密码。我们可以围绕这个事实设计其余的API,编写仅对有效密码有效的函数,并通过验证逻辑来推动用户。
pub fn validate(p:Password(a))->结果(密码(有效),密码(无效)){让密码= p大小写is_valid {True-> Ok(p)错误->错误(p)}}
我们可以最终得到一个使用Invalid,Valid或具有以下功能的密码的API:
pub fn create_account(p:密码(有效),电子邮件:字符串)-> Userpub fn proposal_better(p:密码(无效))-> Stringpub fn to_string(p:密码(任意))->细绳
在现实世界中,您可能不会像这样处理密码(对吗?对吗?),但是这种想法会转移到您可能要验证的任何类型的数据上。
最近的讨论出现在Gleam Discord服务器上(如果还没有,应该完全加入),在该服务器上,用户试图围绕现有的Erlang库编写包装,这可能会引发来自不同功能的各种错误。
在Gleam中,通常使用Result类型和特定的Error类型对这些引发错误的函数进行建模,该错误类型描述了该函数可能失败的所有可能原因。当两个函数(接受和侦听)可能引发不同的错误,但在两者之间共享了一个错误时,出现了问题。
pub类型AcceptError = {SystemLimit关闭超时Posix(inet.Posix)} pub类型listenError = {SystemLimit Posix(inet.Posix)}
同一模块中的不同类型不可能具有相同的名称(否则,编译器将如何知道SystemLimit的含义!),因此我们需要以不同的方式解决该问题。我们有几种选择:
将所有构造函数重命名为Accept或Listen前缀以消除它们的歧义。我们最终得到了构造函数AcceptSystemLimit和ListenSystemLimit,它们肯定会让编译器满意,但感觉有些多余。它还可能使API混乱或失去重点。
为这两个函数创建单独的模块,这将避免类型构造函数彼此冲突。但是,这样做会使我们的API更加难以使用,并且如果需要共享类型或其他功能,可能会使事情更加复杂。
放弃特定于函数的类型,而是为整个模块/ API创建单个错误类型。我们失去了表达特定于函数的错误的能力,但是我们已经获得了简单性以及在函数之间共享错误类型的方式。
如果应用我们现在对幻影类型的了解,则可以扩展第三个选项,以包含一个幻影类型,该类型可以提示错误来自何功能。
pub类型错误(来自){SystemLimit关闭超时Posix(inet.Posix)} pub不透明类型AcceptFn {AcceptFn} pub不透明类型ListenFn {ListenFn} pub fn accept(...)-> Error(AcceptFn){...} pub fn listen(...)->错误(ListenFn){...}
尽管此方法不会给我们带来任何额外的安全性,但确实为使用此功能的开发人员提供了上下文线索。当处理由监听引发的错误时,他们知道他们可以安全地忽略Closed和Timeout错误,而只关注相关的错误。
💡在类型系统甚至更高级的语言中,我们可以使用一种称为[一般代数数据类型(GADT)]的东西(en.wikibooks.org/wiki/Haskell/GADT)**以实现相同的目的但具有类型安全性开机!
实际上,GADT也被称为一流的幻像类型。 Gleam不支持它们,目前尚不清楚它是否会支持,但是如果您对这种事情感兴趣,您可能需要查看OCaml或Haskell。
通过幻像类型提供上下文线索可能并不总是最佳的设计决策,但有时它可以在简单性和表达能力之间找到适当的平衡。
在这一点上,您可能想将幻影类型应用于所有代码并从中获得额外的编译时安全性,但是在代码中使用幻影类型有一个主要警告。
我们无法基于幻像类型分支功能的行为。为了说明这一点,请考虑对我们的Currency类型实现to_string函数的不可能实现。
pub fn to_string(a:Currency(a))->字符串{让Currency(val)=一种情况a.phantom_type {USD-> string.concat(" $&#34 ;, float.to_string(val))GBP-> string.concat("£&#34 ;, float.to_string(val))...}}
我们已经达到了幻像类型可以帮助我们表达的极限。因为to_string函数必须对Currency(a)中a的所有值通用,所以我们不能基于a的类型更改行为。
在总结和巩固我们对幻象类型的了解之前,我想简要介绍一下一些使幻象类型更加凉爽(略)的语言的属性。在某些语言中,围绕另一种类型的简单包装器类型可以在运行时完全删除装箱。使用模式匹配对类型进行包装和解包的过程保持不变,但在运行时仅保留包装的值。
有一个进行中的拉取请求,用于通过内联关键字为Gleam添加对此类型的支持。这与幻像类型有什么关系?目前,我们需要为Gleam中的这些包装类型支付少量性能成本,因为我们必须不断对它们进行装箱和拆箱。使用建议的内联修饰符,可以在编译时将这个(取消)装箱以及我们的幻像类型注释一起删除。我们将获得所有类型上的好处,并且无需支付任何运行时费用!
幻像类型是一种类型变量,它出现在类型定义的左侧,但未在右侧使用。
我们可以使用幻像类型来消除具有相同基础结构的值的歧义:Id(a)或Currency(code)。
我们可以使用幻像类型向开发人员提供有关特定值来自何处或可能出现哪些值的上下文线索:Error(from)。
就是这样。我们已经介绍了幻影类型的主要用例,但还有其他一些用例,例如用于小语言的解释器或构建器模式的类型安全的实现。如果您仍然感到困惑,可以在Gleam不和谐(您已经加入了,对吗?)上放一条消息,我很可能会看到它。
互联网上散布着许多讨论幻像类型的文章。他们中的许多人通常使用与我在此处使用的示例相同的示例,但是如果我的写作没有真正使这个想法成为现实,那么看到别人解释的相同内容可能会很有益。以下是我认为写得特别好的文章集:
如果您只是刚开始使用Gleam,而您偶然发现了这篇文章,那么首先,请做好这篇文章。其次,如果您对这意味着什么有些挠头,以下是我们使用的一些语言功能: