从生锈到打字

2020-09-12 23:20:36

我是在2018年被介绍给铁锈的,从那以后就一直被迷住了。Rust是一种系统编程语言,与C++非常相似。不过,与C++不同的是,它相对较新,它的语言设计更现代、更复杂。使用写作会让人感觉更像是在写打字稿或哈斯克尔(Haskell)。这并不令人惊讶,因为尽管它是一种运行时间非常短且没有GC的语言,但它派生了许多函数式编程原则,如不变性、类型推理、高阶函数、模式匹配等。在对Rust进行修补的过程中,我意识到编写Rust代码使我成为其他语言的更好的程序员。这篇文章将描述我如何在不改变语言本身的情况下,将Rust的语言设计中最好的部分推到我的主要语言Tyescript中。

我第一次学习Rust的第一件事就是,用Rust编写类似异常的代码没有简单明了的方法。对于在使用了许多语言(如C++、Java、JavaScript和TypeScript)之后习惯了异常的人来说,这似乎是一种不完整的语言。但铁锈缺乏直截了当的例外,这实际上是事先考虑好的。

例外在你第一次理解的时候会感觉很整洁。引发的异常会跳过代码执行进入Catch块。通过这样做,您可以忽略函数中的其余代码。函数中的其余代码表示正大小写,当发生错误时,这可能看起来无关紧要。下面是一段代码:

函数foo(ome Number:number){if(ome Number=0){抛出新错误(";error:foo";);}return number+1;}函数栏(ome Number:number){return foo(Ome Number)+1;}函数baz(ome Number:number){Return bar(Ome Number)+bar;}baz();

当调用baz时,它将抛出一个未捕获的异常。通过阅读代码,您知道foo抛出了一个错误。调试这段代码是小菜一碟,对吧?

现在让我们看看另一个片断。在下面的代码片段中,您正在导入一个可能引发异常的函数。

从";ome Package";;try{//此行可能引发错误CALLME();}catch(异常){//如果错误变成空值,此行可能会意外再次引发错误。//未捕获类型错误:无法读取未定义控制台的属性';消息';。错误(异常。消息);}。

与第一个代码段相比,这个代码段更难调试。看起来是这样的,因为1)。您不知道CALLME是否会抛出错误。2.)。如果它抛出异常,您将不知道该异常的类型。代码的模块化程度越高,调试异常就越困难。

为什么会这样呢?如果您注意到,在第一个代码片段中,您知道哪个函数抛出了什么,因为抛出和调用者在一个文件中。你不需要切换文件,甚至不需要滚动那么远就能看到哪里出了问题。此外,与其他语言(如java)不同的是,不会键入文字异常。因此,与返回值不同,编译器无法推断函数是否会抛出异常,这很糟糕。结合异常向上传播直到它们被捕获的事实。这意味着异常可以是n级深的。它来自的越深,调试就越困难。

如果您熟悉静态类型的语言,那么将编译时应该做的事情推迟到运行时是不好的。它将问题从编译器移交给用户。

编写try/Throw/Catch有一些最佳实践,比如开发人员应该抛出错误对象,而不是空值。但是,推动每个人应用最佳实践是不够的。您不能断言对外部依赖项中正在写入的内容的控制。在找不到执法者的情况下,引入法律的意义不大。

只有当您处理完复杂的代码库时,您才会理解为什么我们不应该使用Exception进行流控制。

那么拉斯特是如何做到这一点的呢?Rust通过提供一个名为Result的数据类型鼓励我们返回错误,而不是抛出错误。它是一个带标记的联合,可以是OK(IdeData)或Err(NonIdeData)。有了这一点,函数就可以将a调用的情况传达给它的调用者。例如:

Fn main(){让number:result<;u8,_>;=";1";。Parse();let non_number:result<;u8,_>;=";a";。Parse();println!(";{:?}";,&;number);//确定(1)println!(";{:?}";,&;non_number);//err(ParseIntError{Kind:InvalidDigit})}。

函数的作用是:将字符串转换为U8。有些情况下,将字符串解析为U8可能会导致失败,一种情况是如果解析的字符串不是数字字符串。因为.parse()有一个负案例,所以它返回结果<;u8,_>;。

在Rust中,Err不会像异常那样跳跃。无论结果是OK还是ERR,程序都将简单地执行下一行。为了处理错误,程序员必须检查返回值是OK还是Err。鼓励早点回来,而不是扔掉。铁锈有没有?语法有助于使早期返回更易于写入和读取。

为了模拟这种行为,我还将使用带标记的联合。我找到了这个有用的图书馆,fp-ts。通过利用TypeScript的类型系统,它涵盖了大量的函数式编程经验,但我将只使用其中的一小部分。

Fp-ts具有<;Left、Right>;数据类型。它类似于铁锈的结果,但更灵活。这两个都是带标签的联合值容器。要么是左,要么是右,通常右表示理想情况,左表示非理想情况,但这两种情况都可以与任何类型一起使用。下面是两者的类型定义。

键入<;L,R>;=Left<;L>;|Right<;R>;;Type Left<;L>;={_Tag:";Left";;Left:l};键入Right<;R>;={_Tag:";Right";;Right:R};

两者都很酷的地方在于,TypeScript实际上支持从其标签中推导出带标签的联合。例如,在此块中,如果(某个._tag=";Left";){}类型脚本知道某个字符是左侧的。如果在该块中有返回,则TypeScript将推断对于功能块其余部分,某个数据类型是正确的,并且不再是正确的。

让我们做一个叫tryCatch的游戏吧。它用于将函数包装到任何一个中。Fp-ts实际上提供了这个功能,但是让我们来编写它,这样我们就可以知道引擎盖下面是什么了。

Const tryCatch=<;T,E>;(fn:()=>;T,onError:(错误:未知)=>;E)=>;{try{//right()换行值为右返回right(fn());}catch(Error){//Left()将值换行为左返回左(onError(Error));}};

例如,使用我们新创建的tryCatch,可以这样从字符串创建URL。

导出类InvalidURLError扩展错误{}const makeURLFromString=(str:string)=>;tryCatch(()=>;new URL(Str),()=>;new InvalidURLError());

调用该函数将如下所示。下面的代码片段接收一个名为maybeUrl的字符串。如果maybeUrl不是有效的URL,则返回错误。如果URL没有调用";example.com";,则返回错误。否则,它将从FETCH调用返回承诺。

导出类NotExampleDotComError扩展错误{}const callThisMaybeUrlIfItIncludesExampleDotCom=(maybeUrl:string):<;InvalidURLError|NotExampleDotComError,Promise<;Any>;=>;{const urlResult=makeURLFromString(MaybeUrl);//arly如果。左;const url=urlResult。对;如果(url.。Origin!==";example.com";)return Left(new NotExampleDotComError());Return Right(FETCH(UrlResult));};

这里有更多的代码行,但是很棒的是,TypeScript编译器现在可以推断出错误类型,并且您可以更好地控制流程。对于打印不同的错误等情况,您甚至可以推断变量error是instanceof NotExampleDotComError还是InvalidURLError。

如果你玩过21世纪初的FPS游戏,并试图作弊,通常你会发现noclip命令。它能让你的头像穿过墙壁和地面。不过,有时您会走捷径并触发错误的脚本。当这种情况发生时,你不能完成你的关卡,必须重新启动才能让它再次工作。

这两个功能解决了类似的问题。在Rust的案例中,它使开发人员能够临时解锁处理原始指针的能力,以防有人需要它。在TypeScript的例子中,可能有人需要逃脱到自由键入的JavaScript世界。在这两种情况下,程序员都有责任在程序返回安全系统之前确保一切正常。

不安全到安全生锈就像任何类型的打字稿件一样。与铁锈不同,TypeScript的类型只有在您将其编译成JavaScript代码后才能使用。Rust具有TryInto/TryFrom特性,可将原始数据转换为数据类型。但在TypeScript的情况下,如果您注入的值与TypeScript的类型注释不匹配,则TypeScript无法执行任何操作。这是因为TypeScript团队决定让它只是JavaScript之上的一层薄层。他们认为最好是让人们编写类似JavaScript的打字脚本,并且编译结果看起来与源代码几乎相同。

我的方法是保护应用程序的所有外部漏洞,它们是:

当我尝试这种方法时,我很幸运地找到了由与FP-TS相同的作者创建的io-ts。这个库帮助我创建了一个运行时类型检查器,也就是这个库的作者所说的编解码器。与其写下这句话,不如说:

同样,这是更长的,但如果我们想保护这些洞,这是非常有用的。

导出类InvalidTypeError扩展错误{}导出类Fetch4xxError扩展错误{}导出类Fetch5xxError扩展错误{}导出类FetchUnnownError扩展错误{}导出类FetchNetworkError扩展错误{}const fetchCurrentUser=():Promise<;任一<;|InvalidTypeError|Fetch4xxError|Fetch5xxError|Fetch5xxError。Https://some/url/to/fetch/current/user";)。则(异步(分辨率)=>;{IF(分辨率。状态>;=200&;&;分辨率。Status<;=299){const maybeJSON=await tryCatchAsync(()=>;res.。Json(),()=>;null);如果(!IsLeft(MaybeJSON))return Left(new InvalidTypeError());//不是JSON,Left<;InvalidTypeErorr>;const可能是用户=可能是JSON。对;如果(!用户编解码器。Is(MaybeUser))return Left(new InvalidTypeError());//NOT USER,LEFT<;InvalidTypeErorr>;Return Right(MaybeUser);//Right<;User>;}if(res.。状态>;=400&;&;res。Status<;=499)return Left(new Fetch4xxError());//Left<;Fetch4xxError>;if(res.。状态>;=500&;&;res。Status<;=599)Return Left(new Fetch5xxError());//Left<;Fetch5xxError>;Return Left(new FetchUnnownError());//Left<;FetchUnnownError>;})。Catch(()=>;Left(new FetchNetworkError();//Left<;FetchNetworkError>;

上面的代码已经包括对4xx、5xx、非JSON字符串、网络错误、我们没有指定的任何其他错误、非用户错误和正例的错误检查。由于只有15行代码,这些函数已经涵盖了详尽的案例。您不必担心未定义的行为。

此外,您还可以使用这些丰富的信息向用户展示许多东西。例如,在Reaction中,我们可以这样写它:

Render(){const{userResult}=this。状态;返回({isRight(UserResult)&;&;={userResult。Right}}{isLeft(UserResult)&;&;(userResult.。左例提取错误)&我们这边有个错误。请与我们的管理员联系。}{isLeft(UserResult)&;&;(userResult.。InvalidTypeError的左侧实例)&;&;找不到用户。您是否单击了正确的链接?}//等等)}。

同时采用结果和类型安全性还有另一个价值。如果您注意到,fetchCurrentUser函数几乎看起来像是声明性的和/或功能性的。以这种方式编写会给出语义值,这是一个返回更多信息的更简明但更具可读性的函数。

对于那些来自动态类型语言(如JS)的类型,类型被认为很难管理。采用打字有它的障碍,而且大多数时间类型都是这些障碍的原因。编译器可能很难处理,在编译时会产生错误。但是,如果操作正确,类型可以成为保护业务对象和逻辑安全的有力盟友,它可以利用编译器进行逻辑检查,而不是成为它的负担。

该方法将类型作为业务对象,其灵感来自于Rust的结构枚举和模式匹配。枚举和模式匹配提供了一种编写多态对象及其方法的简单方法。两者都来自FP的影响。

下面是多态枚举形状的代码片段,可以是矩形、三角形或圆形。

枚举形状{矩形{宽度:F64,高度:F64},三角形{底边:F64,高度:F64},圆形{半径:F64}}。

找出形状的面积和周长的函数可以写为。

实施形状{fn get_Area(Self:&;Self)->;F64{匹配自身{Shape::Rectangle{width,Height}=>;Width*Height,Shape::三角形{base,Height}=>;base*Height/2 F64,Shape::Circle{Radius}=>;Std::f64::consts::Pi*RADIUS*RADIUS,}。

正如您可以从FP特性中预期的那样,枚举和模式匹配可以使计算看起来像是声明性的。

如上所述,在打字脚本中,我们可以使用标记的联合来模拟铁锈的枚举。事实证明,带标签的联合非常适合描述应用程序的状态。我将使用一个使用Reaction的示例。

例如:交互式登录页面。交互式登录页面有3种状态:IDLE、LOGING IN、LOGINSUCCESSFUL。一种常见的做法是使用TypeScript的字符串枚举来描述州。我更多地利用了一点类型化系统。

//不是此枚举StateEnum{IDLE,LOGGING_IN,LOGIN_SUCCESS},而是键入PageState={state:StateEnum;lastError:Error;UserName:String;Password:String;};//我这样写它:PageInput={userName:String;Password:String};键入PageState=|({Kind:";Idle";;Error:Error}&;PageInput)|({Kind:";Logging_IN";}&;PageInput)|{Kind:";Login_Successful";};

具有正确类型的状态后,您将能够编写类似模式匹配的Render()函数。

LoginPage类扩展了组件{//剩余的代码Render(){return({state.。KIND=";IDLE&34;&;&;状态。错误&;&;<;ErrorComp错误={STATE。错误}/>;}{状态。Kind=";IDLE";&;&;<;SubmitButton/>;}{状态。Kind=";Logging_IN";&;&;<;SubmitButton Disabled={true}/>;}{状态。Kind=";LOGIN_SUCCESS";&;&;<;SuccessNotice/>;});}}。

具有正确类型的状态还可以让编译器提醒您不要偏离业务逻辑。例如,你不能不小心写下...。

此外,使用类似部分模式匹配的代码编写页面的关键部分也更容易。

类LoginPage扩展了Reaction{//其余代码temptLogin=async()=>;{const pageState=this。州政府。PageState;if(pageState。KIND!==";IDLE&34;)返回;等待尝试登录(用户名,密码)。则(res=>;{if(isRight(Res)返回此。SetState({KIND:";LOGIN_SUCCESS";});返回此。SetState({pageState:{pageState,错误:res.。Left}});})}//代码的其余部分}。

该模式有效地将编译器、编辑器、IDE以及扫描ASP的任何东西都变成了业务逻辑的守卫。

在Rust中,块与内存分配有着特殊的关系。在其他语言中,块仅用作顺序代码存在的位置。在Rust中,当块结束时,块中分配的变量将被删除。

{设SOME_VARIABLE=GET_SOME_VALUE();//...。Do Stuff//此处将删除某些_Variable}

将数据移出当前块并非易事。您必须指示Rust设置为1。)。首先将变量设为引用,或2。)。将变量移出,并在以下行中使其不可用。对于来自TypeScript/JavaScript(其中每个非原始值实际上都是引用)的人来说,这种行为对来自TypeScript/JavaScript的人是非常有限制的。但这实际上是在TypeScript和JavaScript中使用的一个很好的模式,因为您将整个故事放在一个块中,并且您可以肯定,如果一个块在它的末尾,那么里面的变量将经过GC';编辑。

在我适应该模式之前,下面是我在Reaction App中编写回调的方式。

类SomeComponent扩展了组件{Valid(){//执行一些验证。SetState({Validation});}Submit(){const{data}=state;FETCH(omeUrl,{body:omehowConvertToFormData(Data)})}onHandleSubmit(){this。VALIDATE();这。Submit();}//Render Render(){return(={this.。OnHandleSubmit}//...);}}。

在我习惯了这种模式之后,我就是这样在Reaction App中编写回调的。

类SomeComponent扩展组件{async submitImpl(){//data start here const{data}=this。State;//数据应由Valid()作为参数传递//Validation不需要访问此参数//因此它不需要是类方法Const Validation=Valid(Data);if(Validation。IsValid()){等待获取(omeUrl,{body:data});}这个。SetState({Validation})//数据在此结束}异步提交(){if(this.。州政府。正在提交)退货;//此为关键部分。SetState({isSubmission:true});这。SubmitImpl();这。SetState({isSubmission:false});}//Render(){return(={this.。Submit()}//...);}}。

您可以在submitImpl中看到整个提交场景。数据来源是单一的,从submitImpl开始。验证函数接收数据并生成验证对象,而不是产生副作用。阅读这篇文章的开发人员会像阅读顺序叙述一样阅读它。他们不必在函数之间跳转就能知道它在做什么。

我在前面提到的TryFrom和TryInto对于业务对象的序列化和反序列化非常方便。我把这个模式用在。

.