“抗锈”的表现型系统

2020-10-25 09:01:21

在这里,我与大家分享一位经验丰富的开发人员首次涉足现实世界的锈蚀项目。它密切关注类型系统,以及我开始思考铁锈所需要的关键洞察力。它是为那些掌握这门语言的人编写的,如果您的背景不是函数式编程,它将特别有用。

虽然最初设想为第2部分(共2部分),但可以将其作为独立条目读取。在为普通读者编写的第1部分中,我详细介绍了Maker项目、一个Raspberry Pi Tide时钟,以及它起源背后的温馨故事。

作为一名精通几种主流语言(如Java、C#、javascript、python等)的程序员,我们有理由期待,在熟悉了一些怪癖和语法之后,您会发现自己的工作效率有所提高。如果你了解铁锈,当我说我的经历是……时,你不会感到惊讶。令人羞愧。

我上一次感到如此愚蠢可能是在十多年前,当时我还是一名程序员新手,自学面向对象编程(OOP)。我认为这就是重点,新的范例需要努力为其建立一个有效的心理模型。根据你在阶梯上的起点,你可能有相当多的精神重建工作要做。

铁锈故意让人厌烦。也就是说,它的DNA大量借用了现有技术。如果您熟悉手动内存管理(C、C++、Obj-C),您可能会理解Rust中的指针。如果您熟悉函数式语言(Haskell、Elm、Clojure、OCaml),您将立即开始识别类型系统。它的目标是枯燥乏味的原因是因为它将整个新奇预算花在一个独特的想法上:基于范围的自动内存分配(也称为借用检查器)。这是击倒一切的特性:C语言的性能与垃圾收集语言的安全性。

关于所有权和借阅支票的问题,已经写了很多墨水。它遵循了人们谈论得最多的最史无前例的功能。似乎有一种常见的说法:一开始真的很难,但要坚持下去。你会看到,它改变了一切。听起来像是Vim或Emacs的用户(请不要和我争)。这是应许之地,还是仅仅是斯德哥尔摩综合症的严重病例?

尽管如此,广泛的阅读让我准备好与借阅检查员进行一场激烈的争斗。我们确实发生了争执,但比预想的要好。具有讽刺意味的是,经过多年的C#游戏开发,性能问题教会了我很多关于内存寿命的内部对话:";这是在堆栈还是堆上分配的?这是否会造成分配(垃圾收集)压力?这是在原地复制数据还是改变数据?";

从这个意义上说,当借阅检查员抱怨时,我至少可以欣赏它试图保护我免受的伤害。不出所料,我可能还需要一段时间才能完全摸索人生。然而,有争议的是,铁锈的表现型系统进行了一场比我预期的更艰难的斗争。

在任何人拿起干草叉之前,这并不是在抱怨类型系统不必要的负担。在我的职业生涯中,我一直偏爱静态和强类型的编程语言。我相信正确性,而工具应该会帮助您做到这一点。这就是铁锈让我兴奋的原因。

作为最终结论,我们来到了Haskell和其他纯函数式编程(FP)语言。FP';的自负是用性能来换取正确性。作为一名忧心忡忡的游戏开发者,我从来没有觉得这是一笔可以讨价还价的东西。这并不是说它阻止了FP习惯用法渗入其他编程语言的地下水(参见C#中的LINQ或JavaScript中的map()filter()filter()Reduce()作为示例)。

您也可以在铁锈上看到这种功能DNA的迹象,默认情况下,不变性是最明显的。然而,“铁锈”达到了惊人的务实平衡。命令式编程并不是非法的,而且存在许多逃生舱。据我所知,Rust是第一种允许您将函数概念部署为零成本抽象的语言。如果借阅检查器是显而易见的热门单曲,那么这就是真正的专辑卧铺热门。

我认为我的失败只是因为我对等价物抱有错误的期望。我对我的类型识别力很有信心,如果有必要的话,我可以深入讨论Java设计模式。我从来没有追求过一种恰当的功能语言,但我认识到它们的影响。我认为表现型系统是我已经知道的内容的超集。在某些方面是这样的,但在另一些方面,它是根本不同的。我想和大家分享我的一些瞬间,因为我还没有看到太多用这个镜头写的东西。

对于任何追随我脚步的人:为代码智能和类型提示设置您的开发环境是我最好的建议。我特意在这里提供它,以免任何代码样本让您目瞪口呆并错过它。

在撰写本文时(2020年10月),我发现带有锈蚀分析器扩展的Visual Studio代码是最可靠的途径。我只是在幼稚地安装了Rust Extension之后才学会了这一点,它为早期的RLS和更现代的防锈分析仪提供了一个前端。然而,根据我的经验,本地的防锈分析仪要好得多。

如果您可以将某些内容表示为类型,则编译器可以为您提供有关它的保证。富于表现力的类型系统允许您对以前不可能实现的领域进行建模,通常也使用更简单的机器。拉斯特接受了这一启示,并随波逐流。类型用于所有事情,我的意思是说所有事情都是类型。很多问题都没有多大意义,直到我学会认识到它们的本质:打字错误。

然而,一开始,我把一些明显的不同之处归入了怪癖类别。他们看起来既陌生又陌生,但适应很快就会到来。我只为那些从未见过“锈”的人简单介绍一下。在“铁锈”一书和其他地方都有详尽的报道。

Pub struct{pub x:f32,//f32=32位浮点数类型pub y:f32,pub z:f32}。

Pub struct{pub x:f32,pub y:f32,pub z:f32}impl{pub FN init(x:f32,y:f32,z:f32)->;{//创建并返回新结构{x,y,z,}。

好吧,分开的Iml积木看起来是个奇怪的选择。有点让我想起C头文件...";

调用类型中定义的方法使用类似于C++的语法,例如Type::Method_Name()。但是,一旦实例存在(也就是分配了内存),就会使用常规的点语法访问成员,例如instance.method_name():

Fn main(){let position=::init(0.0,1.0,0.0);//类型分辨率let y=position.y;//实例分辨率}。

Pub struct{pub x:f32,pub y:f32,pub z:f32}//定义共享API特征{fn raw(&;self);}//在MyData struct Impll上实现具体行为,{//self类似于';this';。有点像是..。//不同之处在于我们必须手动声明FN Draw(&;self){//使用self.x,self.y,self.z}绘制项目}。

好的,那么LIKE特征只是一种不同的接口拼写方式?抓到你了。但是没有遗产吗?那太疯狂了。但是组合胜过继承,我说得对吗?

既然我们已经实现了可绘制特征,我们就可以在我们的struct上调用它的方法了:

现在这已经足够了,但是我们知道还有很多其他值得称赞的类型功能,我们稍后会讨论其中的一些。

巨大的单块类被认为是一种代码气味。这是一个信号,表明责任需要被分成不同的关注点。尽管如此,OOP仍然保持了分立单元的感觉。数据和行为包含在类的胞壁中,或者至少包含在继承链中。

相比之下,生锈给人的感觉是由里到外,零星零碎。本来可能是单个类的东西反而是结构和特征的累积,每个结构和特征都定义了自己的数据和行为的狭窄窗口。在这次演讲中,另一种函数式语言Clojure的创建者将这种特性描述为即席多态性。在你需要的时候,只使用你需要的东西。这种颗粒状的方法最大化了不变的表面积,易变性被限制在它需要的地方。

在激烈的战争中,OOP是一个巨大的错误吗?Rust是否允许你编写OOP代码是值得商榷的。我现在理解这并不是完全拒绝:抽象和封装仍然是必要的工具。然而,铁锈迫使你留下狂热者认为有害的特征,继承就是一个明显的例子。如果你愿意,可以称之为“好点子”(the Good Bits&34;OOP)。

组合重于继承或将数据与行为分离等思想在OOP世界中已经存在很长一段时间了。然而,FP鼓励新的原则、决定论和避免副作用。让我们来看一个例子。

零指针的发明者托尼·霍尔(Tony Hoare)称这是他数十亿美元的错误。任何程序员都知道查找散布着Null引用异常的错误日志的痛苦。但零值是基本的,就像我们呼吸的氧气一样。每当我试图考虑另一种选择时,我都会遭遇想象力的失败。";是否没有空值?很远的人。";

有一段时间,在以前的语言中,我试图通过用缺省值而不是NULL来初始化变量来回答这个问题。当缺省值有意义时,这是有效的,否则,它只是将垃圾数据输送到系统中。尽管如此,在成功的案例中,我开始欣赏它提供的保证。任何下游代码都可以相信变量是有效的,并且不会因为意外的异常而使自身变得混乱。

不幸的是,软件总是会有资源初始化或访问失败的情况。FP在这里为我们提供了一个更完整的答案。它正确地将有效|无效或成功|失败识别为独立于数据引用的关注点。它通过将责任转移到一个单独的类型来做到这一点。

Var Item:Item=GetItem();If(Item!=null){//处理资源print(Item);}函数GetItem:Item(){if(/*Success*/){var newItem=new();return newItem;}return null;}。

让我们在“铁锈”中重写这一点。为了便于比较,我使用了一种非惯用风格:

让Item_Option=Get_Item();Match Item_Option{(Item)=>;{//使用资源println!(";{}&34;,Item);}=>;{}};fn get_Item()->;<;>;{if/*Success*/{let new_item={};return(New_Item);}返回;}。

这是怎么回事?如果我们查看get_resource()函数,我们会看到它在失败时返回NONE。这只是另一种拼写null的方式吗?不完全是,关键是返回类型选项<;item>;。类型项定义了我们关心的位,但是围绕它的选项是什么?Option是Rust附带的通用结构,为了理解它,让我们仔细看看:

我们可以看到这非常简单,它只是一个有两个值的枚举,一些(T)和没有。通用占位符T允许我们在有效负载中进行组合,就像我们对选项<;Item>;所做的那样。类似地,我们可以看到get_item()在返回一些(NewItem)时执行了一个具体的替换,newItem是Item的一个实例。

从本质上说,我们有一个包装器来包装我们的值,说明它是否有效。项目不再知道其有效性。它的存在足以证明消除了空态。然而,要拿到它,我们首先需要撕下包装纸。这就是Match构造的全部内容:

Match Item_Option{(Item)=>;{//使用资源println!(";{}&34;,Item);}=>;{}};

它被称为模式匹配,它是一个switch语句,但是针对的是类型。在这里,Some分支是唯一具有对Item的有效引用的代码路径。编译器不会允许您访问超出此范围的项。这就是表现型系统修复数十亿美元错误的方式。

编辑:根据读者反馈,最好给出一个惯用代码可能是什么样子的示例。声明性代码与其说是关于风格,不如说是关于承诺这件事的行为是一致的,并且是可知的。首先,我们可以直接匹配get_item()函数调用:

我们也可以在其他函数中使用此模式。如果我们想要解锁可组合函数,那么这样做是值得的:

Fn PROCESS_ITEM(INPUT:<;>;)->;<;>;{匹配输入{(Item)=>;{/*修改项目*/(Item)},=>;}}。

Rust还提供了速记(如果let is语法糖),这是一种表达对Match语句的单个分支感兴趣的简明方式。把所有这些放在一起,我们可以用一个表达式来描述整个问题:

If let(Item)=Process_Item(get_Item()){println!(";Success,Item is{}&34;,Item);}fn Get_Item()->;<;>;{if/*Success*/{let new_Item={};return(New_Item);}return;}fn process_Item(input:<;>;)->;<;>;{匹配输入{(Item)=>;{/*修改项目*/(Item)},=>;}}。

就像所有的东西一样,这两种风格都有优点和缺点。确切的讨论哪个更好,什么时候更好,为什么更好,可以改天再讨论。但是,通过使用单一的函数表达式,如果我们知道这些函数是纯函数,我们就可以对程序的正确性做出一些强有力的保证。据我所知,这就是FP';的全部障碍。我要补充的是,由于mut和突变,铁锈不像哈斯克尔那样默认是纯净的。以同样的方式,你可以问是铁锈OOP吗,你可以理所当然地问是铁锈FP吗?

希望它清楚为什么选项<;物品和物品是不同的类型。如果您试图互换使用它们,编译器会给您输入错误。当您重新使用您编写的方法(例如get_item()),并且您知道它返回一个选项时,这是不言而喻的。然而,在使用STD库或第三方机箱时,一些无伤大雅的场景让我大吃一惊。考虑以下问题:

定义一个list[1,2,3],并使用first()方法获取对第一个元素的引用。用任何其他语言写都是完全合理的。如果我启用了锈蚀分析器,它会通过在第一个变量后面添加一个类型提示来警告我这个问题:

因为不可能返回空数组的第一个元素,所以first()返回一个选项<;&;I32>;,&;I32用一个";包装表示安全吗?";。只有当数组被填充时,它的值才会是一些(&;I32),否则它将解析为None。因此,将其作为&;I32(也称为引用32位的i整数)直接使用的尝试将失败。这正是我们在上面的断言中发生的事情:

//第一个元素my_list[0]是否等于my_list.first()?Assert_eq!(&;my_list[0],first);错误[E0277]:可以将`&;{INTEGER}`与`<;&;{INTEGER}>;`|5|ASSERT_EQ!{INTEGER}>;`。

铁锈到处都是这种花招,到处都是。被迫处理这件事感觉就像吃了你的西兰花。但是,吃蔬菜是FP程序员经常引用的温暖模糊感觉的基础:如果代码编译成功,您可以相信代码将会运行。生锈造成了类似的情绪,尽管如果你走后门作弊,你会削弱这一承诺。

在上面的示例中,与前面一样,您可以使用Match语句撕下选项包装纸。或者,您可以通过使用UnWrap()来欺骗:

这里,unWrap()是去除选项包装器的一种快捷方式。但是,它通过选择忽略无路径来执行此操作。这意味着我们面临空列表导致运行时恐慌的危险。不足为奇的是,我的大部分(如果不是全部)运行时错误都来自于选择偷偷摸摸的便利性,而不是僵化的安全性。

以下结构与选项<;T>;密切相关,可用于任何可能需要返回显式错误消息的操作,如磁盘IO。

OK(T)在语义上等同于某些(T),而Err(E)是一个包含E类型错误的None。学习用于操作这些模式的工具箱是学习Rust的基本部分。然而,有一个关键的特点我想指出。

OK(T)和ERR(E),就像某些(T)和NONE一样,是不同的变体。在Java或C#中,这是不可能的,首先,枚举不允许使用泛型;其次,支持字段(或相关数据)必须是相同的底层类型,通常是int。在Rust中,创建一个变体为不同类型的枚举是完全合理的:

这是对具有非常不同的分支逻辑的复杂函数的结果进行建模的一种强大方式。能够在一条语句中匹配所有可能性可以更容易地理解此代码的结果。这是另一个刻痕铁锈类型的系统工具带的表现力。

我从铁锈类型中得到的感觉有一个微妙的不同,这是很难解释的。在OOP语言中,对象往往是类型的累积。每个超类和接口都在您正在使用的类型之上进行聚合和修饰。对象的实例为您提供每个公共或继承成员的王国的密钥。还有一组健壮的内置工具可以从一种类型转换为另一种类型。默认情况下,它是包含式的。

这种期望是“铁锈”中最初令人沮丧的原因。我花了很多时间试图通过将结果投射到我需要的目的地来转换结果。但是,由于我在前面提到的itty-bitty风格,所以在缺省情况下通常是独占的。

让我们用一个现实世界的例子进一步解释这一点。Rppal板条箱为树莓PI的40个GPIO引脚提供接口,这些引脚用于与硬件外设通信。要使LED闪烁,只需将连接的引脚上的电压设置为高或低即可。同样,引脚也可以用来通过测量传感器的输入电压来接收来自外部世界的数据。重要的是要知道,读取和写入具有相互排斥的功能,这需要在软件中建模。

RPPAL板条箱很有趣,因为它的历史详细说明了重构为更惯用的pin API。我将在这里展示一个私生子的玩具版本,因为我想把重点放在进化上,而不是具体的细节上。第一,老办法:

常量LED:U8=5;常量传感器:U8=6;//配置LET LED_PIN:=GPIO。GetPin(LED);led_pin。Set_mode();让SENSOR_PIN:=GPIO。GetPin(传感器);Sensor_pin。Set_mode();//写入outputed_pin。WRITE(::);LED_PIN。Write(::);//读取输入LET LEVEL=SENSOR_PIN。Read();

看起来合理吗?GetPin()根据编号的id收集PIN。Set_mode将其配置为输出或输入。Read()和write(),然后执行我们要执行的业务。但请注意,一旦设置了个人识别码模式,开发商就有责任维护合同。没有什么可以阻止以下情况:

根据情况的不同,后果从无害到永久性硬件损坏不等。这种妥协经常发生在厨房的水槽里。

.