首先让我们从一段最简单的Java代码开始。 这是再简单不过的一段代码,相信绝大多数的人都是从类似这样的代码开始学习编程的。如果我们仅仅从功能的角度来看的话,这段代码本身并不包含逻辑上的错误,但是我们今天要介绍的是泛函编程,那么这段代码就有很多可以商榷的地方了。我将尽可能避免将泛函编程宏大的世界观一次性地展开,以免读者感觉太复杂而难以适从。相反,我将从一些小的方面,一点一点地将每一个问题逐步介绍出来,以便于我们由浅入深地去慢慢品味那别样的编程哲学。 现在我们以泛函的角度来重新构建这段代码,依然使用Java的话,它看上去大致会是这样的: 比较这两段代码,最大的不同在我们构建 HelloWorld 这个类的实例的时候,第一段代码执行了一个缺省的空构建,因为这个 HelloWorld 实例在创建之前它就已经知道了自己将会做什么,它具有与生俱来的某些动作,比如 greeting() 方法;而第二段代码中,我们则是将一个包含运算的 Lambda 传递给了它,于是这个HelloWorld 被注入了某种外来的“内涵”。当我们执行下一条指令的时候,第一个实例忠实的执行了在它被定义时就注定的动作,打印出 “Hello, World!”,而第二个例子则执行的是被延迟到构建的时候才定义的 Lambda 中的动作。 我们不难发现,在第二个例子中,HelloWorld 只是被当作一个容器来使用,它本身除了一个作为“触发器”作用的 run() 方法和它的参数类型外,并不具有任何有实质意义的动作,它的计算完全取决于那个 Lambda。到此我们产生了两个疑问:1)我们为什么需要这样一个容器?2)既然函数的参数和打印动作几乎发生在同一时间,为什么还需要将运算交给 Lambda,而不是立刻就开始打印呢? 回答这两个问题之前,我们需要先明确两个简单的概念:“定义时”和“执行时”。如果我们把 System.out.println() 动作抽象成某种“运算”(实际上在计算机中也确实就是运算)来看的话,那么很显然,第一个例子中这个运算的过程在定义时就已经决定了,执行时只是忠实地执行了既定的计算策略而已。而第二个例子中,对计算的定义被推迟到了最终的那一刻。也就是说,虽然我们在定义时就知道了将会为计算提供什么样的参数,但是究竟会如何对待这个参数,却是在执行时才知道的。这种延迟对于计算来讲究竟具有什么样非凡的意义呢? 它至少给我们带来以下三个方面的好处: 第一:有利于优化。 这几天我恰好在辅导上中学的儿子复习代数,加拿大的中学数学虽然比国内的在题型上要简单很多,但是就理论层面来说却是差不多的。我们首先从一道简单的代数开始,假设我们有这样一道一元二次方程: F(x) = x^2 + 2x + 1 求: F(2) = ? 很显然我们的解题过程不会立刻就将 2 带入表达式得到 2^2 + 2*2 + 1,然后再一步一步解出答案的。相反我们会先对表达式进行一定程度的简化,比如变形成 (x+1)^2 的形式,直到不能进一步简化后,才会将x=2带入,然后求出最终解。是的,在这里我们“延迟”了带入求解的时机,而是采取了“分析->化简->带入”三个步骤来简化我们的计算,并且,这种解题方式不仅仅体现在代数领域,实际上它程贯穿了几乎所有的数学领域,甚至在泛函编程领域,我们也沿用了这样的基本思路。理解了这个思路,那么之前的两个问题也就迎刃而解了。 现在让我们回头来看看这个例子,HelloWorld 就好比是函数 F,而打印动作则是函数体,它们分别在等号的两边,F本身除了签名外,不拥有任何实体,而等号的右边所代表的运算允许我们灵活地进行变形,只要保证变形前后是“等效”的即可。设想如果我们在定义命题F的时候就固化了函数体的所有既定动作的话,那么我们就会失去很多对它进行优化的机会。我们可以将执行时,也就是等号右边所发生的事情看作是“活”的,它能够解决许多编译时不确定的问题,各种满足计算的最终条件都会在执行时被确定下来,比如我们有这样一个运算:F() = a-b+c,很显然如果 a=b,那么这个运算就可以简化成只有c,而这个信息在编译时是很难确定的,延迟这个过程将允许运行时编译器根据当时的上下文做出最有利于计算的优化,从而取得更高的计算效率。 其次:有利于CPU执行效率的统筹 虽然运行时编译器未必一定会对因为 a=b 而将计算直接简化成c,它还取决于许多其他条件(比如函数的“纯净度”和即时优化的级别等),但是紧凑的代码可以更有效地利用CPU的时空局限性,我们知道当 CPU 在执行运算的时候,因为高速缓存的存在和多内核并行,我们应该尽可能将指令连贯地放在一起,不仅如此,CPU在执行的过程中也会主动尝试将指令重新排序,因此如果我们将计算拆分成可以自由组合的小的计算单元,然后将它们实际发生运算的时机和位置延迟到运行时来决定,那么将大大有利于时空局限性带来的效率提升。 更甚至,当我们将程序装入容器后,我们就可以以容器为单位有目的地编排代码的执行。如果我们将CPU现象成一条货轮,而指令就是一件件即将被转载上船的货物。最有效地安排船上的空间的方法就是先将货物分门别类地装入集装箱,然后我们就可以安排吊车以或者平行、或者循序渐进地方式将它们一个一个的吊装到货轮上去。在传统的编程中,代码必须自己来管理自己的执行顺序和执行条件,每一条代码都处于各自为阵的状态,这实际上无利于CPU的执行效率。并且使得业务代码和控制代码混在一起,难以管理。如果我们将业务代码(货物)和控制代码(集装箱及其吊装)分开看待,那么我们将可能为程序的执行提供更专业和更有效率的调动。 第三:这可能也是最重要的一条:专注类型,而不是过程可以让程序变得更加安全。 这一条可能很难通过罗列代码来演示,只能靠你去理解。在阿拉丁的神话中,我们常常看到这样一个场景:某人得到了一个莫名其妙的瓶子,他不小心拧开了瓶盖,于是魔鬼现世了。每当看到这个场景的时候,我总是会有一个疑问,如果我在瓶子的外面再套一层瓶子的话,那会怎样呢?如果我有一个又一个的瓶子,让魔鬼从一个瓶子中出来的同时又装进另一个瓶子的话,哪故事又会是怎样的呢?当魔鬼装在瓶子(容器)中的时候,它始终是安全的(哪怕过了一千年),我们可以仔细地去观察它,评估它可能带来的破坏力,及早做好准备。是的,程序就如同魔鬼,如果你不小心地加以控制,那么它可能具有可怕的破坏力,最安全程序就是不执行程序。所以,我们应该尽可能让程序“静止”下来。将代码装入容器中,是一个有效的让程序“静止”的办法。当一个程序处于静止状态的时候,我们所能做的就是通过它的输入输出类型,包括可能抛出的异常,来期待它的可能的表现。这样一来,我们就从动态地于魔鬼共舞的世界里解脱出来,静态地为魔鬼准备跳舞的容器。在泛函的世界里有一句非常著名的话:“to the end of the wold”(和本博客的标题巧合相同)指的就是这么一种思想:当你没有准备好运行程序的时候,就不要执行它,一直到拆开最后一个容器的那一刻。 现在让我们以 Scala 语言来重新编写以上第二段代码,之所以使用 Scala 是因为Java 语言并不是一门泛函语言,它本身的表达方式并不那么直观,如果我们以泛函语言 Scala 来编写的话,那么它的形式会是这样的: 我们可以看到当我们使用了正真的泛函语言来进行编程的话,代码的篇幅大大地缩短了,甚至比第一个例子还短。不仅如此,而且它的表达方式也更贴近数学字面意义上的“函数”。(这种贴近也是这门编程思想被称为泛“函”的原因之一)。 综上所述,当你开始尝试将代码装入“容器”里,并将它延迟到实际需要运算的时候才取出来执行时,你就解锁了泛函编程的正确打开姿势。
类型类的形式 类型类在泛函编程中被广泛用于需要实现重载(overload)的场合,它本质上是一个带有类型参数的trait,它包含了所有对该类型的方法的合集。比如 Monoid就是一个类型类: 由此可见type class的出发点是将数据类型和数据类型的处理方法分离,它通过类型参数提供不同的实现实体来对应不同的重载需求。它为我们提供了以下几个主要的优点: 类型类的优点 1)方法独立于数据,避免了当数据实体不存在时无法进行运算的问题: 传统的OOP,所有的方法都建立在数据实体对象上(静态方法除外),假设我们现在要对一个Int列表求和,如果这个列表中存在数据,那么我们就直接基于数据进行计算,但是如果这个列表是空的,那么它的应该直接返回初始值0本身(实际上即便列表不是空的,我们也需要一个不存在列表中的初始值作为计算的起点)但是初始值本身来自什么地方呢?在OOP中,所有的方法都必须基于对象,如果对象本身不存在,那么我们将没有办法得到这个初始值,因此在OOP中我们必须单独提供一个特定的实体,比如数字“0”作为初始“零值”,但这样一来就违背了依赖倒置原则,让方法和具体类型产生了偶合。而类型类则允许我们将方法和类型剥离,基于抽象类型提供特定方法,例如 Monoid[A] 的 empty 方法,通过它我们可以为类型提供一个产生“零值”的方法。 具体例子可以参考 Cats 的 type class 文档。 2)实现编译时类型安全: 通过类型类的特质签名,我们可以看出它属于参数多态。它和传统的多态主要区别在于,传统的多态允许运行时改变数据的类型,因此编译器无法保证运行时安全,比如: 以上的输出结果是:“I’m Son”。 我们看到虽然我们声明类型为 Parent,但是我们实际上得到的是Son。因为编译器无法预知它在运行时被分配的实际类型,也就无法保证它的安全。而类型类将数据实体从对象中分离出去后,就可以使用泛型来表达它,这样就将它转变成了参数多态,参数多态属于静态指派,静态指派发生在编译期,所以类型类允许我们利用静态分派机制来实现动态类型的安全检查。(参考Monoid的定义) 3)解藕类型递归 同样得益于数据类型和函数的解偶,使得我们可以通过代码递归来解决类型递归带来的问题。 假设我们存在Monoid[T] 类型类,现在我们希望它的某个实现能够处理Monoid[Pair[A, B]],从逻辑上来讲,我们就需要在 Monoid[Pair[A,B]] 的实现内支持对Monoid[T] 的递归处理(以下例子参考自Cats 的 type class 文档): 如果我们通过继承来使用类型类,则我们对Pair[A, B]的处理需要的就不是 Monoid[Pair[A, B]] 而是 Monoid[Pair[A <: Monoid[A], B <: Monoid[B]]]。 这样不仅使得签名变得复杂,而且还强制要求开发者必须先提供Monoid[T]的实现。这是因为 OOP(subtyping) 完全基于(数据)对象自身的能力来计算,因此需要对象对自身数据类型(成员变量 first和second)具备完备的认知,因此签名会陷入逻辑递归。而类型类的数据对象来自函数的参数而不是自身,因此它可以用函数递归来代替签名递归,而函数体则可以进一步通过隐式将处理代理给外部依赖,这样就在函数体内解藕了类型的递归。 类型类的用法 类型类的用法一般直接产生一个(隐式)实例,然后将它作为函数的隐式参数传递给作用的数据实体,比如将作用的数据实体通过隐式转换成它的操作类,然后将类型类的实例作为隐式参数传递给操作函数,然后在函数内将实体作为参数传递给类型类中的函数。 定义实体: 定义类型类: 定义隐式转换(Vehicle -> VehicleOps): drive函数谋求一个类型类隐式实例,然后将Vehicle实体作为参数传递给类型类。 最后在上下文中声明类型类的隐式实例即可完成依赖注入:
我们知道在面向对象编程中有这么一条军规:向修改说No,向扩展说Yes。基于这条军规,我们实现了“继承”。客观地说,当我们从具体功能的角度审视这条军规时,它在大多数情况下都能良好地发挥作用,但是如果我们将视角从具体的功能实现提升到基于类型安全的角度,那么这条军规依然能够完美地约束我们对多态的要求吗?让我们来看一个简单的例子: 假设我们要实现一个“交通灯”应用,基于这条军规我们可能会先实现一个名为 Light的接口(或抽象基类),然后通过扩展这个接口(或基类)实现“RedLight”,“YellowLight”和“GreenLight”三个子类分别代表“红”“黄”“绿”三种灯: 接下来我们通常会编写一个信号灯控制器(LightController)来控制灯的点亮和熄灭: 从这段代码中我们可以看到几个明显的问题:首先:我们需要在强制转换之前先使用 instanceof 关键词来判断当前Light的子类型。因为在OO中,我们只被允许安全地向“上”,而不是向“下”进行类型的转换,当我们要将一个类型转换到它的子类型时,我们就必须自己完成类型检查,而编译器无法对我们行为的安全性负责。这就是继承带来的第一个局限性。 那么有没有一种方法使得我们的类型也可以向下负责呢?也就是说,我们是否可以让编译器通过有限的枚举来自动识别出正确的子类型呢?从传统的OO原理出发,因为继承的开放性,也就是我们之前提到的那条军规,让我们无法将Light的子类限定在仅仅只有这三种可选项内,虽然从常识上我们可能永远也不会去尝试实现一个粉色的交通灯,但是编译器并不具备这个常识,因此它不可能为我们实现这种我们所期待的类型检查。甚至,为了防止在未来某种未知的状态下发生低级错误,我们可能还需要给 if…elseif…语句块在最后加上一个else来为所有未知的情形提供处理。这就犹如脱裤子放屁一样,但是却是必须的。由此可见如何依据父类型对子类型实现安全检查,这就是我们要解决的由OO带来的第一个问题。 在离散代数中我们知道有一个“集合”的概念,一个集合是所有满足定义域的解的全集,所以如果我们把接口(或基类)视为某个定义域,而它可能的子类视为它的“解”的话,那么我们就会得到这样一个代数式: Light ={RedLight, YellowLight, GreenLight} 所以如果我们能够实现一个类,它的子类只能是花括号中的三种,那么我们也就能够告诉编译器它可以通过枚举这三种子类型来找到我们所需要的类型。因此在泛函编程中,我们引入了一个名为“和(sum)类型”的概念,顾名思义,也就是说,某个类型是它所有子类型的全集。在Scala 中,我们用 sealed 关键词来标识这个类,于是我们得到以下代码: 在这样一个实现中 sealed trait Light 被明确限定为其下三种子类型的父类,它不可能在别的地方被继承,并且它的子类也不能被继承,也就是说它的所有可能的“解”都被封印了起来,于是我们可以安全地使用 match…case 来进行类型匹配了: 并且,因为编译器已经得到足够的信息来保证Light的子类型不会超出以上三种,因此这三个case已经完全覆盖了所有的可能性,我们也没必要再实现一个 default来处理超出期待的情况。这样的代码不仅安全而且简洁明了。 第二个问题是:我们知道交通灯的信号组合永远只可能有一盏灯是处于点亮的状态,因此在实现的过程中,我们必须考虑他们的组合以防止不合理的状态出现,那么我们能否让编译器为我们检查所有的排列组合呢?很遗憾,回答依然是做不到,因为如果我们要让编译器检查每一种组合,那么我们需要通过某种方式明确地告知编译器所有可能的参数和它们的形式,而没有一种传统手段可以做到这一点,因此我们只能自行在程序中通过if…else…来枚举所有的情况,如果稍有不慎漏掉了某种情况,那么编译器是不会发现错误的,等待你的也许是灾难性的结果。 但是在离散代数中,我们知道在一个确定的数据全集中,其中的任何一个或多个成员的组合都是这个全集的子集。而一个非无穷全集中的子集是可以穷举的!基于这一点,我们以RedLight这个类为例,它的数据公民有两个,分别是true和false,并且它们出现在子集中的机会是排它性的,因此它可能的结果也就只有两个。如果我们进一步将组合扩大到三个灯,那么我们就可以得到他们不同的组合,因此现在我们只需要通过某种方式将这个组合告诉编译器就可以,答案就隐藏在 case class 中,我们只需要定义一个包含全部三个类型的灯的LightController case class就可以做到这一点: 这个LigjtController类包含了三个参数,现在编译器只需要观察这三个参数(包括递归每个参数的两种结果)就可以覆盖所有可能的组合情况,因此如果我们有以下代码: 编译器就可以给出警告我们的代码是不完整的,因为很显然我们只枚举了三种合理的情况,除非我们提供一个缺省的方法来覆盖全部情况: 与“和类型”一样,这样的 case class 也有一个称呼叫做“积(product)类型”,顾名思义就是它表示一个集合中所有解的“积”结果。而对应不同的“积”的处理函数称为“偏函数”,这个概念和代数中的偏函数是一致的。 由于以上的“和类型”和“积类型”都来自代数,因此它们被统称为“代数类型”。代数类型在泛函编程中发挥了重要的作用,尤其在类型安全领域,它弥补了OO的缺憾,为继承带来更安全的编程体验。可喜的是今天的Java也逐步接受了代数类型,在最新的Java14中已经出现了 Record类,其实就是积类型,而在即将发布的Java15中也将引入sealed关键词用于实现和类型。我们很高兴看到Java正在一步一步走向OO和FP的结合,虽然这一天的到来有点慢。
本章节探讨一些常用的检测和处理错误的方法。 Either 您可以使用 ZIO#either 来处理故障,方法是使用 ZIO[R, E, A] 并产生 ZIO[R, Nothing, Either[E, A]]。 您可以使用 ZIO.absolve 来将(Either 类型的)失败反转成 ZIO,这与上例相反,它将 ZIO[R, Nothing, Either[E, A]] 转换为 ZIO[R, E, A]: 捕获所有错误 如果您想捕获所有类型的错误并有效地尝试恢复,则可以使用 catchAll 方法: 在传递给 catchAll的回调中,您可以返回具有不同错误类型(或可能为Nothing)的 effect,并且它将被反映在 catchAll 返回的 effect 类型中。 捕获特定的错误 如果您只想捕获并恢复某些类型的异常,则可以使用 catchSome 方法: 于 catchAll 不同, catchSome 不能减少或消除所能包含的错误类型,但是它可以将错误类型扩展为更广泛的错误类别。 失败回退 您可以使用 orElse 组合器让它尝试一种 effect,如果它失败了,那么继续尝试执行另一个 effect。 Folding Scala 的 Option 和 Either 数据类型都具有 fold 方法,它可以让您同时处理成功和失败。ZIO effect 也具有类似的几种方式可以让您处理失败和成功。 第一种方法是使用 fold,通过为每种情况提供一个非效果的函数,让您以 “非效果” 的方式同时处理成功和失败: 第二种方式是使用 foldM 函数,它允许您通过为每种情况提供效果化的(纯净的)处理程序,来处理失败和成功: 几乎所有错误处理方法都是根据 foldM 定义的,因为它既强大又快速。 在下面的例子中,foldM 被同时用于处理 readUrls 方法的成功和失败的: 重试 ZIO数据类型上有许多有用的方法可以重试失败的 effect。 ZIO#retry 是其中最基本的用法,它接收一个 Schedule 并返回一个会在前次执行失败后,基于指定的策略尝试重新执行的 effect: 另一个强大的函数是 ZIO#retryOrElse,它允许指定一个失败回退函数,如果效果指定的策略也无法取得成功,则执行回退函数: 最后,ZIO#retryOrElseEither 函数允许返回失败回退函数返回一个不同的类型: 有关如何新建一个 schedules,请参考 Schedule. Next Steps 如果您对基本的错误处理感到满意,那么下一步就是学习安全的 资源管理。
在翻译 ZIO 文档的时候有一个比较头疼的问题是有关如何翻译 effect 和 side effect。在许多文章中 effect 有时被翻译成“作用”,而有的时候又被翻译成“效果”。side effect 则在多数时候被翻译成“副作用”,很少见到被翻译成“副效果”的,因为中文中似乎没有这样一个词汇。当我第一次看到“副作用”这个词的时候的第一反应是该吃药了。但是不管怎么翻译,这都很容易产生理解上的偏差,甚至有时不管用哪个词都不正确。所以为了更准确地表达原文,最后我决定还是尽可能不对这两个词做翻译,保持它们的英文原文。其实只要准确理解了 effect 和 side effect 的内涵并不影响阅读,就当是两个专有名词好了。 effect 和 side effect 是一对反义词(貌似废话),只要理解了一个,另一个自然也就理解了。他俩的关键在如何理解什么是 side effect。简单地说,side effect 指的是那些任何主动或被动地对“非当前局部环境变量”的访问的操作。 注意,“非当前局部环境变量”不仅仅包括访问全局变量和静态变量。这个操作可能非常广泛,甚至不局限于变量,还包括访问外部 IO,执行某些特殊指令,以非正常返回的方式引起的主动或被动出栈(比如异常)等行为。总之,它包含了大部分的栈外资源,要特别强调的是哪怕对本地变量的操作也可能引发栈外动作,比如除 0 运算,哪怕被除数是一个本地变量,但是因为它会触发异常,这也被视为 side effect。总之一切除了正常返回外,任何可能引入栈外因数的行为,都被称为 side effect。当然,还需要加一个前提是这些行为必须是“可观测”到的,如果你适当地将引起异常的运算用 try…cache…包裹起来并不将异常直接弹出,那么可以被认为消除了由此带来的 side effect。但是这种消除 side effect 的手段非常有限,绝大多数的对当前环境之外的资源的访问都是可以被观测到的,比如对网络的访问,它几乎无法被隐藏。 为什么要将这些操作单独区分出来呢?因为这些操作对程序的正常运行可能产生“危害“,这里的所谓“危害”并不是贬义词,而是指它可能导致函数结果的不可预测(不纯),甚至可能产生运行时中断。 什么叫函数的不纯?想象一下当我们用一张纸来解答一道存粹的数学函数的时候,我们最终得到的答案将完全依赖当前这张纸上的这道函数的输入参数,并且我们也一定会得到一个确定的结论(包括“无解”和那些尚未被数学家解答出来的“猜想”,它们也最终会有一个确定的结论),并且这个结论的改变只依赖如上参数条件。这就是函数的存粹性。函数的纯粹性有一个非常可贵的特点,就是一个存粹的函数不受任何外部宇宙条件的影响,它都会而且只会得到一个仅仅与参数有关的确定答案。无法想象我们换一张纸来答题,或者到火星上就会让 1+1得到一个非2的答案。也就是说,一个函数的存粹性取决于两个必要条件:1)是否所有的求解条件都由参数来传递?2)是否返回一个确定的结论,并且这个结论只和参数相关? 在计算机中满足了以上两个必要条件还不能称为纯函数,它还必须保证求解的过程不产生主动中断。side effect 除了会引入域外因数(外部变量)外,还有一个重要的原因是许多 side effect 的行为都会引起 CPU 在求解的过程中产生让程序脱离执行序列的中断。比如在执行的过程中插入 sleep 指令,这条指令将中断当前任务并将它调离执行序列进入睡眠状态。在CPU的执行过程中有许多类似的主动或被动的中断事件,包括在多线程环境中访问共享变量,设立临界区,这可能导致大量线程陷入等待中断。side effect 主要关注的是那些由程序内部引发的中断,包括但不限于 IO 中断(不包括缺页中断)。因为这些中断会大大地降低代码的执行效率。 除了 side effect 之外剩下的(在概念上非常局限的)那一部分,就称为 effect,也就是泛函编程中极力追求的目标。effect 是保证我们写出纯函数的必要(非充分)条件,而纯函数是泛函编程“高效率”,“低故障”,“可验证”的重要保障,因此我们要非常清楚如何正确地利用或避免 side effect 来构建我们的程序以达到我们希望的目的。 最后要强调一点,明确认识 side effect 并非让我们“惧怕”使用它们,一个程序最终还是要通过 IO 和人或设备打交道,因此一个功能的程序几乎不可避免用到 side effect,认识 side effect 只是让我们能够更好地组织我们的代码,避免在不必要的地方使用,让程序运行的更加高效可靠。
Mapping 您可以通过调用 ZIO#map 方法让 effect 在成功通道上进行映射。这使您可以转换effect 的成功值。 您可以通过调用 ZIO#mapError 方法让效果在错误通道上进行映射。这使您可以转换 effect 的失败值。 注意,在 effect 的成功或错误通道上进行映射不会更改效果的成功或失败,就如同 Either,在任何一个通道上映射都不会改变它是 Left 还是 Right。 链式调用 您可以使用 flatMap 方法串联执行两个 effects,这要求您提供一个回调函数,该回调接收第一个 effect 的输出作为它的输入,最终的返回取决于此第二个 effect 的结果: 如果第一个 effect 失败了,那么传递给 flatMap 的回调将不会被执行,并且 flatMap 返回的合成效果也将失败。 在 任何 的效果链中,第一个失败将会直接导致整个调用链立即终止,就像抛出异常会提早从一系调用中退出一样。 For Comprehensions 因为 ZIO 数据类型同时支持 flatMap 和 map 调用,所以你可以使用 Scala 的 for comprehensions 来构建 effect 的执行序列: For comprehensions 提供了用于组合效果链的更具过程性的语法。 Zipping 您可以使用 ZIO#zip 方法将讲个 effect 合并成一个单个的 effect,产生的 effect 是一个包含了两个 effect 的成功值的元组: 要注意 zip 运算的顺序:左边效果的运算先于右边。 在任何一个 zip 运算中,如果左边或右边中的任意一边失败,那么整个effect组合都将失败,因为生成元组需要两个值。 有时候一个成功的 effect 的返回值是无用的(比如 Unit),那么可以使用 ZIO#zipLeft 或 ZIO#zipRight 函数来方便地舍弃,这个些函数先执行 zip,然后执行映射(map)来丢弃一边: zipRight 和 zipLeft 函数分别可以使用符号别名 *> 和 <* 来代替。有些开发者认为以下代码的可读性更高: Next Step 如果您对ZIO effect 的基本操作感到满意,那么下一步就是学习错误处理.
本章节将探讨从直接量、常见的 Scala 类型,和同步、异步 side effect 中创建 ZIO effect 的一些常用方法。 必定成功的值 使用 ZIO.succeed 方法,你可以为一个特定值建立一个成功的 effect。: 也可以在 ZIO 的别名中使用这些方法: succeed 方法接收一个 by-name 参数以确保在构造该值时产生的任何 side-effect 都可以被 ZIO 的运行时适当地管理。但是 succeed 倾向于被用在没有任何 side-effect 的求值。如果你确定这个值会产生 side-effect,请考虑使用 ZIO.effectTotal 来声明: 这个成功效果的构造器 ZIO.effectTotal 只在必要的时候才会产生出值。 必定失败的值 使用 ZIO.fail 来为失败效果建模。 ZIO 数据类型不限定错误的类型,您可以使用适合您的应用程序的字符串,异常或自定义数据类型作为错误类型。 许多程序使用扩展自 Throwable 或 Exception 的类来为错误建模: 需要注意的是,不同于其它效果伴随对象,UIO 的伴随对象没有 UIO.fail,因为由 UIO 表达的 effect 不会失败。 取自 Scala 类型 很多来自 Scala 标准库的数据类型都可以被转换成 ZIO effect。 Option 可以通过 ZIO.fromOption 将 Option 转换成 ZIO effect: 它返回的错误效果类型是 Option[Nothing],它没有提供有关为何不存在该值的信息。你可以通过 ZIO#mapError 将 Option[Nothing] 映射到特定的错误类型: 你还可以轻松地组合不同的运算并保持“可选性”的结果 (与 OptionT 类似): Either 通过 ZIO.fromEither 可以将 Either 转换成 ZIO effect: 得到的 effect 中的错误类型将是任意的左值(Left)类型,而成功类型将是任意的右值(Right)类型。 Try 通过 ZIO.fromTry 可以将 Try 转换成 ZIO effect: 得到的 effect 中的错误类型总是 Throwable,因为 Try 只会以 Throwable 作为失败类型。 Function 通过 ZIO.fromFunction 可以将函数 A => B 转换成 ZIO effect 该 effect 的环境类型是 A (等于函数的输入类型),因为为了让该 effect 的到执行,我们必须提供该类型的输入值。 Future 通过 ZIO.fromFuture 可以将一个 Future 转换成 ZIO effect: 这个函数将一个 ExecutionContext 传递 fromFuture 函数,它将允许 ZIO 管理 Future 的运行 (当然,你也可以忽略该 ExecutionContext). 返回效果的错误类型总是 Throwable,因为 Future 只会以 Throwable 作为失败类型。 取自副作用(Side-Effects) ZIO可以将同步和异步 side-effect 都转换为ZIO effect(纯值)。 以下这些函数可用于包装过程代码,从而使您可以将ZIO的所有功能与遗留的 Scala 和 Java 代码以及第三方库无缝地结合使用。 同步 side-effect ZIO.effect 可以用于将有 side-effect 的同步代码转换成 ZIO: 结果的错误类型总是 Throwable,因为 side-effect 可能会引发任何类型的 Throwable 类型的异常。 如果给出的 side-effect 代码不会抛出任何异常,那么它可以用 ZIO.effectTotal 来转换。 当你使用 ZIO.effectTotal 的时候要特别小心 —— 如果你不能完全确定没有 side-effect,那么请使用 ZIO.effect 。 如果您希望细化效果的错误类型(将其他错误视为致命错误),那么可以使用 ZIO#refineToOrDie: 异步 side-effect ZIO.effectAsync 可以将一个基于回调 API 的异步 side-effect 转化成 ZIO effect: 与基于回调的API相比,异步 ZIO effect 更易于使用,并且它将受益于 ZIO 提供的功能,例如中断、资源安全和出色的错误处理。 阻塞的同步 Side-Effects 有一些 side-effect 代码使用阻塞 IO 或以某总方式使线程进入等待状态,如果你不小心加以管理,这些 side-effect 代码可能会耗尽应用程序主线程池中的线程,从而导致资源匮乏。 ZIO 提供了 zio.blocking 包,可以用于将阻塞 side-effects 安全地转换到 ZIO effects. […]
ZIO是一个基于纯函数式编程的的异步并发库。 有关如何在纯函数编程中处理“效果“,比如输入输出的背景知识,请参阅此章节:背景知识. ZIO 的核心是 ZIO,这是一种源自Haskell 的 IO monad的强大的效果类型。通过此数据类型,您可以通过简单的、类型安全的、可测试和可组合地的代码来解决复杂的问题。 ZIO ZIO[R, E, A] 数据类型有三个参数: R – Environment Type. 该效果通过一个类型 R 用以标示“环境”。如果此类型参数为Any,则表示对该效果没有要求,因为您可以使用任何值(例如单位值())。 E – Failure Type. 如果该类型有可能失败,以类型 E 来标示。有些应用中可以使用 Throwable. 如果该类型是 Nothing, 它标示该效果不可能失败,因为 Nothing 标示没有值可以提供。 A – Success Type. 该效果成功执行的返回类型标记为类型 A. 如果该类型参数返回 Unit, 这表示该效果没有产生有用的信息,如果返回的是 Nothing, 标示该效果将永远执行下去(直到失败为止)。 例如, 一个 ZIO[Any, IOException, Byte] 类型的效果表示它没有输入参数,可能导致 IOException 类型的失败,或成功返回 Byte 类型的返回值。 ZIO[R, E, A] 类型的值类似以下函数类型: 这个函数要求一个 R 类型的输入参数,将可能产生或者 E 类型的失败,或一个 A 类型的成功返回值。但是 ZIO 不完全等于是一个实际的函数,它是面向复杂的效果的模型,比如并发和并行效果。 类型别名 ZIO 数据类型是 ZIO 中唯一的效果数据类型.。但是,有一系列类型别名和伴随对象可以在不同情况下简化它的表达形式: UIO[A] — 这是 ZIO[Any, Nothing, A] 的别名,它表示一个对输入类型没有要求,并且不可能失败,并且能够成功返回一个 A 类型返回值的效果。 URIO[R, A] — 这是 ZIO[R, Nothing, A] 类型的别名,它表示输入要求为 R 类型,并且不可能失败,并能攻成功返回 A 类型的效果。 Task[A] — 这是 ZIO[Any, Throwable, A]的别名,表示一个对输入类型没有要起,可能导致 Throwable 类型的失败,或成功返回 A 类型的效果。 RIO[R, A] — 这是 ZIO[R, Throwable, A] 的别名,它表示一个输入类型为 R,可能导致 Throwable 类型的计算失败,或成功返回 A 类型的效果. IO[E, A] — 这是 ZIO[Any, E, A] 的别名,它表示对输入没有要求,并且可能导致 E 类型的失败,或成功返回 A 类型的效果。 这些别名都有一个伴随对象,并且这些对象具有可用于构造适当类型的值的方法。 如果您对函数的效果不熟悉,我们建议您从 Task 类型开始,它只有一个参数,并且与 Scala 标准库的 Future 最接近。 如果您正在使用 Cats Effect 库,则 RIO 类型可能会很有用,因为它允许您通过第三方库和应用程序对环境进行线程化。 无论您在应用程序中使用哪种类型的别名,UIO 都可用于描述不可能失败的效果,包括结果出自对错误的处理函数。 最后,如果您是经验丰富的函数式程序员,则建议直接使用 ZIO 数据类型,虽然您可能最终会在应用程序的不同部分中创建自己的类型别名。 下一步 如果您对ZIO数据类型及其类型别名系列感到满意,那么下一步就是学习如何 创建 effects.
在项目的 build.sbt 文件中添加以下配置来使用 zio 如果你想使用 ZIO streams,需要添加以下配置: Main 您的应用程序可以扩展自App,它提供一个完整的运行时(runtime)系统,并允许您使用 ZIO 编写整个应用程序。 run 方法应返回一个 ZIO 值,该值包括负责处理所有的错误, 在 ZIO 的术语环境中,它代表一个不会产生异常的 ZIO 值。 一种实现方法是在 ZIO 值上调用 fold,以获的非异常的 ZIO 值。这需要两个具柄函数:eh: E => B(错误处理具柄),和 ah: A => B(成功处理具柄)。 如果 myAppLogic 失败,则将使用 eh 从 e: E 获得 b: B;如果成功,则将使用 ah 从 a: A 获得 b: B。 以上为例,myAppLogic 产生一个非异常的 ZIO 值,其中 B 为 Int。 如果 myAppLogic 失败,将得到 1;否则如果成功,则将得到 0。 如果要使用依赖注入,将 ZIO 集成到现有应用程序中,或者不打算直接编写 main 函数,那么可以显式创建运行时(runtime)以执行 ZIO 程序: 理想情况下,您的应用程序应该只有一个运行时,因为每个运行时都有自己的资源(包括线程池和未处理的错误报告)。 控制台(Console) ZIO 提供了用于与控制台进行交互的模块。您可以使用以下代码片段将此模块中的功能导入. 如果需要将文本打印到控制台,则可以使用 putStr 和 putStrLn: 如果需要从控制台读取输入,则可以使用 getStrLn: