有许多方法可以测试 effect,包括使用 free monad,使用 tagless-final 和使用环境 effect。尽管所有这些方法都与 ZIO 兼容,但最简单,最符合习惯的是 环境 effect。 本节介绍 环境 effect,并向您展示如何使用它们来编写可测试的功能代码。 环境 ZIO 数据类型有一个类型参数 R,它用来描述 effect 所需的环境类型。 ZIO effects 可以使用 ZIO.environment 来访问环境,通过它直接得到 R 类型的环境值: 环境不必是整数等原始类型。它可能要复杂得多,比如可以是一个 trait 或 case class。 如果环境带有属性字段,则可以通过 ZIO.access 单个调用直接访问环境的给定部分: 甚至 effect 本身也可以存储在环境中!在这种情况下,要访问和执行 effect,可以使用 ZIO.accessM 方法。 如上例所示,从环境访问 effect 时,该效果称为 environment effect。 稍后,我们将看到环境 effect 是怎样提供一种简便的方法来测试 ZIO 应用程序的。 提供 Environments 必须先为 effect 提供(providing)环境,然后它们才能运行。 最简单的为一个 effect 提供所需环境的方法是使用 ZIO#provide 函数: 您为 effect 提供了所需的环境后,如果其返回的 effect 的环境类型为 Any (UIO[_]),这表明其要求已完全得到满足。 ZIO.accessM 和 ZIO#provide 的组合对于充分地利用环境 effect 来使得测试变得简单是必需的。 环境化的 Effects 环境 effect 背后的基本思想是面向接口编程,而非面向实现。对于函数式语言 Scala 而言,接口不包含任何具有副作用的方法,但是它们可能包含返回函数化了的effect 的方法。 与其在整个代码库中手动地实现依赖注入或者使用不连贯的隐式来传递接口,不如使用 ZIO Environment 来进行繁重的工作,从而使代码优雅,可推断且轻松自如。 在本节中,我们将通过开发可测试的数据库服务来探索如何使用环境 effect。 定义服务(Service) 我们以模块的形式定义数据库服务,该模块是仅包含单个字段的接口,该字段提供对服务的访问。 在这个例子中,Database 代表 模块,它包含了 Database.Service 服务。 这个 服务 只是一个普通的接口,定义在模块的伴随对象中,包含了该服务提供的功能函数。 辅助函数 为了简化访问数据库服务的环境 effect,我们将定义一些辅助函数来调用 ZIO.accessM。 虽然这些辅助函数不是必须的,因为我们可以直接通过 ZIO.accessM 来访问数据库模块,但是这些帮助程序易于编写并且使得调用端的代码看上去更简单。 调取服务 我们已经定义了一个模块和辅助函数,现在我们准备构建一个使用数据库服务的示例: 在此示例中,effect 仅能通过环境与数据库进行交互,在这种情况下,环境就成为一个提供对数据库服务的访问的模块。 要实际运行这个 effect,我们仅需要提供数据库模块的实现。 实现服务实体 现在我们将实现数据库模块的服务实体,它将切实负责与我们的生产厂数据库的交互: 在以上这段代码片段中,我们不打算提供这两个数据库方法的实现,因为这将需要超出本教程的范围。 运行数据库 Effect 现在我们有了一个数据库模块,和于数据库模块交互的辅助方法,可以一个数据库模块的具体实现。 我们现在使用 ZIO.provide 将数据库模块的实现提交给我们的应用程序: 自此,我们对产生出的 effect 没有了进一步的要求,所以现在可以将它提交给运行时去执行了。 实现服务的测试案例 为了测试代码于数据库的交互,我们不需要提供一个真实的数据库,因为这样的话我们的测试将会很慢很脆弱,并且即便应用的逻辑都是正确的也可能产生随机性的错误。 虽然我们可以使用模拟(mocking)库来创建测试模块,但是在本节中,我们将直接直接创建一个测试模块,以表明这里面没有任何魔术: 由于此模块仅在测试中使用,因此我们通过硬编码读取和更新 map 中的数据来模拟于数据库的交互。为了测试模块具有纤程安全性,可以使用 Ref 而不是 var 来定义 map。 测试数据库代码 现在要测试请求数据库的代码,我们只需要为它提供这个用于测试的数据库模块就可以了: 我们的应用程序代码可以像测试数据库模块一样使用在生产数据库模块上。 Next Steps 如果您对测试效果感到满意,那么下一步就是学习运行 effects。
ZIO 的底层使用了 纤程 来支持多并发。 纤程非常强大,但是它们运行在底层,为了提高效率,ZIO 提供了基于纤程的上层操作。 如果可能,你应该总是选择使用上层操作,而不是直接和纤程打交道。为了完整起见,本节将介绍两种纤程以及在其上构建的一些上层操作。 纤程 Fibers ZIO 的并发是构建在 纤程 上的,它是一种由 ZIO 运行时系统实现的轻量级的“绿色线程”。 和操作系统线程不同,纤程几乎不占用内存,具有可伸缩的堆栈,不会浪费资源,并且如果纤程处于挂起和无法访问状态,则会自动被垃圾回收。 纤程是由 ZIO 运行时调度的,并且会相互协作产生,即使在单线程环境(例如JavaScript,甚至是只配置一个线程的 JVM)中运行时,也可以实现多任务处理。 ZIO 中的所有效果都是由某个纤程执行的。如果你没有创建过纤程,那么纤程将由正在当前的操作(如果该操作是并发的或并行的)或由 ZIO 运行时系统自动创建。 即使您仅编写“单线程”代码,没有并行或并发操作,也将至少有一个纤程,作为执行 effect 的“主”纤程。 Fiber 数据类型 每条 ZIO 纤程都负责执行着某个 effect,并且由 ZIO 的 Fiber 数据类型来代表该运行计算的“句柄”。Fiber 数据类型于 Scala 的 Future 数据类型很类似。 Fiber[E, A] 数据类型在 ZIO 中有两个参数: E Failure Type. 如果纤程运行失败,将返回这种类型的值。 A Success Type. 如果纤程运行成功,则返回这种类型的值。 纤程并没有代表环境类型的 R 参数,以为它们执行的 effects 已经处于运行状态了,并且也已经为他们提供了所需的环境。 效果分支 创建纤程的最基本方法是对一个已存在的 effect 执行 fork 操作。 从概念上讲,效果分叉后会在新的纤程上开始执行,并且返回对新创建的 Fiber 具柄。 下面的代码创建了一个纤程来执行 fib(100): Joining Fibers 从 Fiber 中返回一个 effect 的方法之一,是使用 Fiber#join。由方法 Fiber#join 返回的纤程可以是成功的,也可以是失败的: Awaiting Fibers 另一种纤程返回的方法是 Fiber#await,它返回一个包含返回值的 effect,该值提供了有关纤程如何完成的完整信息。 纤程的中断 可以中断不再需要结果的纤程从而立即终止纤程的执行,安全地释放所有资源并运行所有的终结器。 和 await 一样, Fiber#interrupt 返回一个纤程如何完成退出的说明。 根据设计,由 Fiber#interrupt 返回的 effect 将不可恢复直到纤程彻底完成后。如果不需要恢复它,则可以对中断本身使用 fork: 纤程的组合 ZIO 允许您使用 Fiber#zip 或 Fiber#zipWith 来组合纤程。 这些方法将两个纤程合并为一个单个的纤程,并产生两者的结果。如果任一个纤程发生失败,则合成的纤程也将失败。 另一个组合纤程的方法是使用 Fiber#orElse 函数。如果第一个纤程成功,那么组合将返回该成功值,否则组合将执行并返回第二个纤程的返回值。(无论是否成功) 并行 ZIO 提供了很多操作用于实现 effect 的并行。这些方法都以 Par 作为后缀以有助于您判断何时将采用并行。 例如, 普通的 ZIO#zip 方法将两个 effect 按序列运算并合并在一起。但是还有一个 ZIO#zipPar 方法,可以将两个 effect 并行执行并合并在一起。 下表总结了一些顺序操作及其对应的并行版本: Description Sequential Parallel Zips two effects into one ZIO#zip ZIO#zipPar Zips two effects into one ZIO#zipWith ZIO#zipWithPar Collects from many effects ZIO.collectAll ZIO.collectAllPar Effectfully loop over values ZIO.foreach ZIO.foreachPar Reduces many values ZIO.reduceAll ZIO.reduceAllPar Merges many values ZIO.mergeAll ZIO.mergeAllPar 对于所有并行操作,如果一个 effect 失败,则其他 effect 也将被中断,以最大程度地减少不必要的计算。 如果不希望这种快速失败的行为,则可以先使用 ZIO#either 或 ZIO#option […]
本章节探讨使用 ZIO 来管理资源的通常的方法。 ZIO的资源管理功能包含了同步,异步,并发和其他 effect 类型,即使在应用程序出现故障,中断或缺陷的情况下,也能提供有力的保证。 Finalizing 通过 ZIO#ensuring 方法,ZIO 提供了类似 try / finally 的机制。 类似于 try / finally 一样, ensuring 函数可确保“终结器(finalizer)”将在一个 effect 开始执行然后终止(无论出于何种原因)后开始执行。 终结器是不允许失败的,这意味着它必须在内部处理所有的错误。 和 try / finally 一样,终结器可以嵌套,并且任何来自内部终结器的错误不会影响到它的外部终结器。嵌套终结器的执行顺序是反向,并且线性的(不是并行的)。 和 try / finally 不同的是,ensuring 可以工作于所有的 effect 类型,包括异步和并发 effect。 Bracket 通常 try / finally 被用于安全地获取和释放资源,例如一个新的 socket 的连接或一个文件的打开: ZIO 将这种通用模式封装在 ZIO#bracket 中,它允许您自动一个acquire effect 用于获取资源;一个 release effect 用于释放它;和一个 use effect 用于使用资源。 运行时系统将保证 release effect 的执行,哪怕存在错误或中断。 和 ensuring 一样,brackets 具有语意成分,所以如果一个 bracket 嵌套在另一个 bracket 里面,那么当外部 bracket 获取资源的时候,也就意味着这个外部 bracket’s 的 release 函数也将会被执行,无论它的内嵌 bracket 的释放失败与否。 Next Steps 如果您对资源处理感到满意,那么下一步就是学习基本并发处理。
首先让我们从一段最简单的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. […]