想象一家快递分拣中心。以前,只要有一个包裹掉在地上,整条流水线就会拉响警报、全线停摆,工人们翻遍监控去找“到底哪儿出了问题”,而其他本来能按时送达的包裹也被一起耽误了——这就是传统异常处理(try-catch)的写照。如今分拣中心换了套思路:每个包裹都经过一个“双槽分拣口”,完好的包裹滑向右边绿色通道(Right),有问题的包裹滑向左边红色通道(Left),通道口还贴着一张标签,写明“哪里破损、破损原因是什么”。坏包裹不再让全厂停摆,而是变成了一个带标签的“值”,可以继续被传递、被分类、被修复。这套思路正是 Scala 里的 Either 与 Validated:把错误当成普通的值来处理,而不是让异常劫持整个程序的运行流程。本篇文章会带你一步步参观这个“分拣中心”里的所有设备。

岗位关联

掌握 Either 与 Validated,你写出的 Scala 业务代码将不再“害怕出错”,而是把错误变成可组合、可累积、可预期的数据——这是通往健壮与优雅的必经之路。

目录
  1. 可视化演示
  2. 1. 异常处理局限
  3. 2. Either类型基础
  4. 3. for推导组合
  5. 4. 对比Try与Option
  6. 5. Validated错误累积
  7. 6. 自定义错误类型
  8. 7. Cats校验管道
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示Scala函数式错误处理Ei的核心机制,通过输入/处理/存储/输出四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Scala函数式错误处理Ei:Eith Either右偏语义与for推 Validated错误累积与并 核心机制可视化 Scala函数式错误处理Ei整体架构与关键组件

1. 异常处理局限

分拣中心以前有个老规矩:传送带边缘装了振动传感器,任何包裹轻微撞到护栏,全厂立刻响警报、切断电源,整条流水线停转。一次急停可能只是为了一张弄皱的面单,却让三百件包裹全部滞留。包裹没有“表达问题”的机会,只有全厂的混乱。

Scala/Java 世界的异常与此如出一辙。方法内部一旦 throw,当前调用栈立即中断,控制权被抛到上层的 catch 分支;调用方若忘了 catch,程序直接崩溃。从函数式角度看,异常是一种“非局部跳转”——错误没有作为返回值交给调用方,而是越过层层代码直飞栈顶。

这种设计带来三个痛点:其一,错误隐藏在堆栈深处,排查靠运气;其二,流程中断,一个校验失败,后续与失败无关的操作也被取消;其三,异常无法被组合——你无法把“可能失败的片段”拼接成更大的片段。函数式社区的口号是“错误即值”:让错误成为函数签名的一部分,像普通数据一样参与组合、传递、测试。而 throw 的静态类型是底部类型 Nothing,它在类型层面无法被约束。

异常像警报:不知道红标在哪就必须全场停工。而“错误即值”的思路,是给每个包裹装一张问题标签——这正是后文双槽分拣口(Either)的雏形。

throw e:Nothing,Nothing<:T  (∀T)\displaystyle \mathrm{throw}\ e : \mathrm{Nothing},\qquad \mathrm{Nothing} <: T\ \ (\forall T)
这条公式说明:throw 表达式的类型是 Nothing,而 Nothing 是所有类型的子类型,所以异常可以伪装成任何返回类型——这正是它“无处不在又无法被类型拦住”的原因。
  1. 异常表达式的类型throw e 本身是一个表达式,它的静态类型是 Nothing,代表“永远不会产生正常的返回结果”。
    throw e:Nothing\displaystyle \mathrm{throw}\ e : \mathrm{Nothing}
  2. Nothing 是所有类型的子类型集合论中空集是任何集合的子集,同理 Nothing 可以出现在任意表达式的位置上,编译器无法在类型层面拦截它。
    Nothing<:T(∀T)\displaystyle \mathrm{Nothing} <: T \quad (\forall T)
  3. 与纯函数对比纯函数 A => B 承诺“输入合法必有返回”;可能抛异常的函数却悄悄撕毁承诺,调用方永远不知道返回值是否会到来。
    f:A→B却可能无返回\displaystyle f: A \to B \quad \text{却可能} \quad \text{无返回}

2. Either类型基础

新设备的名字叫“双槽分拣口”。传送带末端分成两条滑道:右边绿色、标明“合格包裹”;左边红色、标明“问题包裹+原因标签”。每个包裹经过时,既不炸也不停,只是安静地滑向属于自己的那一边。问题信息没有消失,而是贴在包裹上继续流转,修复人员可以集中处理。

Scala 的 Either[A, B] 就是这个分拣口。Right[B] 装成功值,Left[A] 装错误值,类型参数 A 描述错误类型、B 描述成功类型。构造方式直白:Right(42) 或 Left("余额不足")。习惯上把右边当作成功,是因为后续所有便捷方法都偏向右边工作——这叫 right-biased(右偏)。

右偏意味着什么?在 Scala 2.12 及以上,Left 与 Right 直接拥有 map、flatMap、filter 等方法。Right(b).map(f) 开心地把 b 交给 f;Left(a).map(f) 则哼都不哼一声,原样返回自己。配合模式匹配,错误处理变得干净:result match { case Right(v) => s"成功: $v"; case Left(e) => s"失败: $e" }。至此,错误第一次拥有了“实体”,可以被存入列表、写入日志、塞进消息队列。

双槽分拣口解决了“错误有处安放”,但一个包裹要经过多道工序(称重、贴单、装车),如何让它们依次衔接而不是写一堆嵌套 match?该传送带登场了。

Either[A,B]=Left[A]∪Right[B],Right(b).map(f)=Right(f(b)),Left(a).map(f)=Left(a)\displaystyle \mathrm{Either}[A, B] = \mathrm{Left}[A] \cup \mathrm{Right}[B],\qquad \mathrm{Right}(b).\mathrm{map}(f) = \mathrm{Right}(f(b)),\qquad \mathrm{Left}(a).\mathrm{map}(f) = \mathrm{Left}(a)
这组公式说明 Either 是一个“二分并集”:Left 与 Right 互斥共存;map 只加工右值,遇见左值就原样放行。
  1. 构造定义Either[A,B] 是所有 Left[A] 与所有 Right[B] 的并集,一个计算实例只会命中其中一种形态,二者不会同时存在。
    Either[A,B]=Left[A]∪Right[B]\displaystyle \mathrm{Either}[A, B] = \mathrm{Left}[A] \cup \mathrm{Right}[B]
  2. map 对右值生效Right(b).map(f) 把成功值解包、经 f 变换后重新打包成新的 Right,绿色通道继续加工。
    Right(b).map(f)=Right(f(b))\displaystyle \mathrm{Right}(b).\mathrm{map}(f) = \mathrm{Right}(f(b))
  3. map 对左值原样放行Left(a).map(f) 完全不调用 f,原地返回自身,红标包裹不再接受任何工序。
    Left(a).map(f)=Left(a)\displaystyle \mathrm{Left}(a).\mathrm{map}(f) = \mathrm{Left}(a)

3. for推导组合

分拣中心的传送带是串联工序的骨架。传送带控制器内置一条铁律:只要某个包裹亮起红标,后面所有工位自动跳过它,直接送进退货口——这就是“短路”。工人们不必在每一个工位前停下来检查红标,铁律替他们做了判断。

Scala 的 for 推导正是这条传送带。for { a <- fa; b <- fb } yield f(a, b) 会被编译器展开为 fa.flatMap(a => fb.map(b => f(a, b)))。由于 Either 右偏,flatMap 天然具备“右值继续、左值短路”的语义——只要某个环节返回 Left,整个 for 推导立即终止,并以该 Left 为最终结果。

来看一个真实的业务链:校验输入 → 查询账户 → 执行扣款。传统写法要用 try-catch 层层包裹、逐层 rethrow;而 Either 版本只是三段平铺的推导:for { user <- validate(input); acc <- findAccount(user.id); _ <- deduct(acc, amount) } yield "扣款成功"。任何一步失败,推导直接返回对应的 Left;成功时返回 Right("扣款成功")。调用方拿到的是一个可以继续组合的值,而不是一次失控的栈跳跃。

传送带让“串联”变得优雅,但也暴露一个问题:它是“第一个红灯就停车”。如果寄件人同时犯了三个错,他得跑三趟才能全部改完。质检科的老张看不下去了:“把差错一次列全,行不行?”——这就要说到 Validated。

Right(b).flatMap(f)=f(b),Left(a).flatMap(f)=Left(a)\displaystyle \mathrm{Right}(b).\mathrm{flatMap}(f) = f(b),\qquad \mathrm{Left}(a).\mathrm{flatMap}(f) = \mathrm{Left}(a)
这条公式刻画了 Either 的串联语义:右值会被传进下一段流程,左值则终止整条链——“短路”正是从这里来的。
  1. 右值展开Right(b).flatMap(f) 解包 b 并交给 f,f 的返回必须仍是 Either,这样链条才能继续。
    Right(b).flatMap(f)=f(b)\displaystyle \mathrm{Right}(b).\mathrm{flatMap}(f) = f(b)
  2. 左值短路Left(a).flatMap(f) 直接返回自身,f 完全不执行,链条就地终止。
    Left(a).flatMap(f)=Left(a)\displaystyle \mathrm{Left}(a).\mathrm{flatMap}(f) = \mathrm{Left}(a)
  3. for 脱糖for { x <- e1; y <- e2 } yield r 等价于 e1.flatMap(x => e2.map(y => r)),两个表达式经 flatMap/map 串成一条流水线。
    for {x←e1; y←e2} ⇒ e1.flatMap(x↦e2.map(y↦r))\displaystyle \text{for}\ \{ x \leftarrow e_1;\ y \leftarrow e_2 \}\ \Rightarrow\ e_1.\mathrm{flatMap}\big(x \mapsto e_2.\mathrm{map}(y \mapsto r)\big)

4. 对比Try与Option

分拣中心还有两件老设备。救援站(Try):专门接住运输途中突然摔裂的包裹,它们不是“校验出来的问题”,而是纯粹意外;查询窗口(Option):负责处理“这个包裹在不在”的询问,只答“在”或“不在”,不解释为什么不在。加上双槽分拣口(Either),三套工具各管一摊。

类型语义上,Try[T] 只有 Success(value) 与 Failure(Throwable);Option[T] 只有 Some(value) 与 None;Either[E, A] 只有 Right(value) 与 Left(error)。三者的共同点是:错误状态都成了普通对象,都能 map/flatMap。区别在于信息量:None 不带原因;Failure 只带系统级异常;Left 可以携带任意自定义业务错误。

选型的时候可以这样想:不关心失败原因(如缓存未命中,重查即可)用 Option;调用外部 Java 库、有异常需要“收编”时用 Try(如 Try(remote.call()).toEither);业务校验与流程控制用 Either 或 Validated。另外 Try 与 Either 之间可以互转:toEither / toTry;Option 与 Either 之间也有 toRight / toLeft 方法。

三套工具并存不悖:意外事故找救援站,查无此物找查询窗口,合规检查找双槽分拣口。可老张还是觉得不够——双槽分拣口一次只贴一个问题标签,能不能一块儿列出来?质检单(Validated)应运而生。

Try[T]=Success[T]∪Failure[Throwable],Option[T]=Some[T]∪None\displaystyle \mathrm{Try}[T] = \mathrm{Success}[T] \cup \mathrm{Failure}[\mathrm{Throwable}],\qquad \mathrm{Option}[T] = \mathrm{Some}[T] \cup \mathrm{None}
这条公式把两个老容器放进同一个坐标系:Try 装系统异常,Option 只装“有/无”,信息量都比 Either 少一截。
  1. Try 的形态Success 装结果 T,Failure 装一个 Throwable——它是“捕获异常之后的 Either”。
    Try[T]=Success[T]∪Failure[Throwable]\displaystyle \mathrm{Try}[T] = \mathrm{Success}[T] \cup \mathrm{Failure}[\mathrm{Throwable}]
  2. Option 的形态Some 装值 T,None 表示缺失——它是“没有错误信息的 Either”。
    Option[T]=Some[T]∪None\displaystyle \mathrm{Option}[T] = \mathrm{Some}[T] \cup \mathrm{None}
  3. 信息量梯度无原因(Option)→ 系统原因(Try)→ 业务原因(Either),越靠右越适合描述复杂业务失败。
    Option<Try<Either(表达力)\displaystyle \mathrm{Option} < \mathrm{Try} < \mathrm{Either} \quad \text{(表达力)}

5. Validated错误累积

老张的质检单长这样:一栏一个检查项,最后一栏是“全部问题”。他不會像传送带那样看到第一个问题就停车,而是把整批货验完,把三个毛病一起填在单子上交回寄件人。“一次整改到位”的效率,是单点报错永远比不上的。

Validated[E, A] 正是这张质检单。形态上它与 Either 同构:Invalid 对应 Left,Valid 对应 Right。但它的组合语义截然不同:Either 的 flatMap 是“第一个错误即停”(Monad 行为),Validated 的组合子是“所有错误一并收集”(Applicative 行为)。Cats 中通常把错误类型定义为 NonEmptyList 或 Chain,以便拼接多条错误。

用法示例(Cats):val checked = (checkName(name), checkEmail(email), checkAge(age)).mapN(User.apply)(三个校验结果类型均为 Validated[NonEmptyList[String], ...])。任一检查失败时,checked 是 Invalid,错误全部累积;全部通过时得到 Valid(user)。注意:Validated 刻意没有 flatMap,因为“累积语义”与“后一步依赖前一步结果”天然冲突——这正是它和 Either 的分工基础。

质检单解决了“一次列全”,但太容易写坏——如果错误信息是散装字符串,寄件人还是不知道填啥。老张需要一份标准单据模板:什么错误类别、附带什么字段,都在手册里写死。这正是自定义错误类型。

Validated[E,A]=Invalid[E]∪Valid[A],Invalid(e1)⊕Invalid(e2)=Invalid(e1⊕e2)\displaystyle \mathrm{Validated}[E, A] = \mathrm{Invalid}[E] \cup \mathrm{Valid}[A],\qquad \mathrm{Invalid}(e_1) \oplus \mathrm{Invalid}(e_2) = \mathrm{Invalid}(e_1 \oplus e_2)
这条公式展示了 Validated 的“合并法则”:两个失败通过半群运算 ⊕ 合成一个失败,错误像盖章一样逐条累积。
  1. 形态同构Validated 与 Either 一样是两个互斥变体的并集,只是名字不同、语义不同。
    Validated[E,A]=Invalid[E]∪Valid[A]\displaystyle \mathrm{Validated}[E, A] = \mathrm{Invalid}[E] \cup \mathrm{Valid}[A]
  2. 错误合并规则两个 Invalid 通过半群运算 ⊕ 合成一个 Invalid,错误按顺序累积进同一条链表(如 NonEmptyList)。
    Invalid(e1)⊕Invalid(e2)=Invalid(e1⊕e2)\displaystyle \mathrm{Invalid}(e_1) \oplus \mathrm{Invalid}(e_2) = \mathrm{Invalid}(e_1 \oplus e_2)
  3. 成功与失败相遇Valid ⊕ Invalid 结果为 Invalid,任何一方失败则整体失败,成功方不会强行通过。
    Valid(a)⊕Invalid(e)=Invalid(e)\displaystyle \mathrm{Valid}(a) \oplus \mathrm{Invalid}(e) = \mathrm{Invalid}(e)

6. 自定义错误类型

老张整理质检单时发现,如果随手写“错误”“不行”“你这不对”,寄件人根本无从整改。中心于是颁布了《质检单填写手册》:每种错误都要有标准代号和附带数据——“包裹破损(部位:左上角)”“面单缺失(单号:SF123)”。手册之外的错误写法,不允许出现。

Scala 中这份“手册”就是 sealed trait(密封特质)。把所有业务错误定义在一个密闭的继承层次里:sealed trait UserError; case object EmptyName extends UserError; case class InvalidAge(age: Int) extends UserError; case class EmailTaken(address: String) extends UserError。编译器知道全套“手册内容”,模式匹配时若漏掉某个分支会给出警告——“手册之外的错误”根本写不出来,安全性由编译器背书。

自定义错误类型的三大收益:可读性——Left(InvalidAge(age)) 自带上下文数据,比 Left("错误!") 信息量大多了;可维护性——新增错误只需在 sealed trait 下扩展一个 case,编译器会提醒你所有需要处理新分支的地方;可组合性——配合 Either/Validated 的错误聚合、JSON 序列化、国际化文案映射,全部由类型驱动。若用 Cats 的 NonEmptyList[UserError],错误累积时还能保留“先校验到谁”的顺序。

手册有了、质检单有了、传送带有了、双槽分拣口有了——万事俱备,只欠一套把它们串起来的“总控台”。分拣中心决定上马 Cats 校验管道,让平行质检与全量列错自动完成。

UserError=EmptyName ∪ InvalidAge(Z) ∪ EmailTaken(String)\displaystyle \mathrm{UserError} = \mathrm{EmptyName}\ \cup\ \mathrm{InvalidAge}(\mathbb{Z})\ \cup\ \mathrm{EmailTaken}(\mathrm{String})
这条公式把“错误类型”建模为一个封闭集合:所有失败情形在编译期就已知,且每个变体可以携带数据字段。
  1. 封闭集合sealed trait 的所有子类型在编译期确定,外部模块无法新增分支——手册内容固定。
    UserError=EmptyName ∪ InvalidAge(Z) ∪ EmailTaken(String)\displaystyle \mathrm{UserError} = \mathrm{EmptyName}\ \cup\ \mathrm{InvalidAge}(\mathbb{Z})\ \cup\ \mathrm{EmailTaken}(\mathrm{String})
  2. 携带数据InvalidAge(age: Int) 这类 case class 把“错误发生时的值”一并封装进错误,信息完整。
    InvalidAge:Z→UserError\displaystyle \mathrm{InvalidAge}: \mathbb{Z} \to \mathrm{UserError}
  3. 穷尽匹配因为集合封闭,match 可以核对所有分支,编译器在漏写时给出警示,杜绝“漏掉某种错误”。
    match(e:UserError)⇒所有分支均已穷尽\displaystyle \text{match}(e: \mathrm{UserError}) \Rightarrow \text{所有分支均已穷尽}

7. Cats校验管道

新总控台是一块大屏幕:左边并排显示多项质检结果,右边是一个“汇总结果”框。所有质检同时进行,互不等待;只要有任何一项不合格,总控台就把所有红标合成一张完整问题清单。寄件人拿到的永远是最新、最全的报告。

Cats 的 Validated.mapN 正是这块总控台。它接收任意多个 Validated,全部 Valid 时解包并调用用户函数合成成功值;存在 Invalid 时,把所有错误通过 Semigroup[E] 合并。由于 mapN 来自 Applicative(而非 Monad),各检查项之间不存在依赖,甚至可以用 .parMapN 做真正的并行校验——并行排队互不阻塞,这正是“总控台”的吞吐优势。

一个真实注册接口长这样:type Result[A] = Validated[NonEmptyList[UserError], A]; val checked = (checkName(input.name), checkEmail(input.email), checkAge(input.age)).mapN(User.apply)。后端把 Invalid(errs) 返回给前端,前端一次渲染全部字段错误——用户不必“填错-提交-改-再提交”循环往复。这也是函数式校验管道在微服务网关、表单接口、批处理场景中最经典的应用。

至此,分拣中心完成了错误处理体系的全面升级:警报让位于标签、单点报错升级为全量质检、散装字符串收编为密封手册。当你回到自己的 Scala 项目,面对那些不断抛出异常的旧代码时,你会知道——真正的健壮性不是“永不犯错”,而是“错误以值的形式被看见、被聚合、被修复”。

mapN(v1,v2,…,vn)(f)={Valid(f(a1,…,an))全部 vi=Valid(ai)Invalid(e1⊕e2⊕⋯⊕ek)存在 vi=Invalid(ei)\displaystyle \mathrm{mapN}(v_1, v_2, \dots, v_n)(f) = \begin{cases} \mathrm{Valid}\big(f(a_1,\dots,a_n)\big) & \text{全部 } v_i = \mathrm{Valid}(a_i)\\[2mm] \mathrm{Invalid}(e_1 \oplus e_2 \oplus \cdots \oplus e_k) & \text{存在 } v_i = \mathrm{Invalid}(e_i) \end{cases}
这条公式是校验管道的行为总纲:全绿则放行并合成结果,有红则把每条错误用 ⊕ 拼成完整清单。
  1. 全部成功分支n 个 Valid 值同时解包,送入 f,得到新的 Valid——绿灯放行。
    mapN(v1,…,vn)(f)=Valid(f(a1,…,an))\displaystyle \mathrm{mapN}(v_1, \dots, v_n)(f) = \mathrm{Valid}(f(a_1, \dots, a_n))
  2. 存在失败分支把所有 Invalid 的 e 用 ⊕ 依次合并,红标全量呈现——一张完整质检单。
    mapN(v1,…,vn)(f)=Invalid(e1⊕e2⊕⋯⊕ek)\displaystyle \mathrm{mapN}(v_1, \dots, v_n)(f) = \mathrm{Invalid}(e_1 \oplus e_2 \oplus \cdots \oplus e_k)
  3. 并行能力Applicative 结构允许各参数并行求值,.parMapN 可并发执行所有检查,互不等待。
    v1∥v2∥⋯∥vn→parMapN汇总\displaystyle v_1 \parallel v_2 \parallel \cdots \parallel v_n \xrightarrow{\text{parMapN}} \text{汇总}
  4. 分工原则前后依赖的流程用 Either + for 推导;平行无依赖的校验用 Validated + mapN。
    Either÷串联,Validated÷平行\displaystyle \text{Either} \div \text{串联},\qquad \text{Validated} \div \text{平行}

AI 应用场景

Web 表单全字段校验

阶段:数据预处理与输入校验

解决的问题:传统表单校验逐个字段抛异常,用户提交一次只能看到第一个错误,反复提交体验极差。用 Validated + mapN 把用户名、邮箱、年龄等平行校验汇聚,一次性返回全部错误,前端渲染一张完整错误清单,如同质检单一次列全所有问题。

具体案例:某注册系统用 Cats Validated 校验表单,后端把 Invalid(NonEmptyList[UserError]) 转成 JSON 返回前端,用户平均提交次数从 3.2 次降到 1.1 次,接口错误率下降 60%。

微服务网关错误聚合

阶段:服务编排与容错

解决的问题:网关并行调用多个下游服务时,一个服务失败不应掩盖其他失败。用 Either 表达依赖链路(鉴权→限流→路由),用 Validated/parMapN 并行聚合各下游结果,把各服务的错误码统一打包成响应体——全厂不再因一个坏包裹停摆。

具体案例:电商平台订单服务并行调用库存、优惠券、风控三个下游,任一失败即返回聚合错误列表,用户与运维都能一次看清所有失败原因,排障时间缩短约一半。

数据管道批处理

阶段:数据清洗

解决的问题:Spark/Flink 作业解析脏数据时遇错即崩,会让整个作业失败、全部数据丢失。用 Try 接住解析异常,用 Validated 把一批数据的所有解析错误累积写进错误主题,好数据继续清洗——救援站管意外,质检单管累积。

具体案例:日志清洗管道每批次 10 万条记录,用 Validated[NonEmptyList[ParseError], LogEntry] 累积所有畸形行,清洗完成后统一回放错误样本用于修复解析器。

Python 演示

python84 行
"""Python 演示 Scala Either/Validated 的思想: 错误短路 vs 错误累积""" class Left:    """Either 的左值: 代表失败, 携带错误信息"""    def __init__(self, value):        self.value = value     def map(self, f):        return self  # 失败后什么都不做, 短路     def flat_map(self, f):        return self  class Right:    """Either 的右值: 代表成功, 携带计算结果"""    def __init__(self, value):        self.value = value     def map(self, f):        return Right(f(self.value))     def flat_map(self, f):        return f(self.value)  class Invalid:    """Validated 的失败侧: 携带错误列表, 支持累积"""    def __init__(self, errors):        self.errors = list(errors)     def add(self, other):        if isinstance(other, Invalid):            return Invalid(self.errors + other.errors)        return self     def __repr__(self):        return "Invalid(%r)" % self.errors  class Valid:    """Validated 的成功侧: 携带计算结果"""    def __init__(self, value):        self.value = value     def add(self, other):        if isinstance(other, Invalid):            return other        return self     def __repr__(self):        return "Valid(%r)" % self.value  def validate_name(name):    if name and name.strip():        return Valid(name)    return Invalid(["姓名不能为空"])  def validate_age(age):    if 0 <= age <= 150:        return Valid(age)    return Invalid(["年龄超出合理范围"])  def main():    # Either 风格: 遇到 Left 立即短路, 后续计算全部跳过    chain = Right(8).flat_map(lambda n: Right(n * 2))    chain = chain.flat_map(lambda n: Left("除零错误"))    chain = chain.map(lambda n: n + 100)    print("Either 短路结果:", chain)     # Validated 风格: 把所有错误一次累积起来    combined = validate_name("").add(validate_age(-3))    print("Validated 累积结果:", combined)     # 全部通过的情况    ok = validate_name("小明").add(validate_age(25))    print("Validated 全部通过:", ok)  if __name__ == "__main__":    main()
目的:用纯 Python 模拟 Scala 的 Either 与 Validated,对比“错误短路”与“错误累积”两种函数式错误处理风格。
  1. def flat_map(self, f):Either 链条的衔接点,把成功值交给下一步函数并拼出新链条
  2. chain = chain.flat_map(lambda n: Left("除零错误"))把业务失败装进 Left,触发“短路”
  3. chain.map(lambda n: n + 100)短路后这步根本不执行,最终结果为 Left("除零错误")
  4. def add(self, other):Validated 的错误合并核心,决定两条错误如何累积
  5. return Invalid(self.errors + other.errors)两条错误列表拼成一条,实现“一次列全”
  6. combined = validate_name("").add(validate_age(-3))两个校验失败合并,一次拿到所有错误
  7. if __name__ == "__main__":程序入口,运行时会同时看到短路与累积两种结果

常见误区

误区我以为用 Either 之后,代码里所有异常都会自动消失,`throw` 再也不用写了。

正解Either 并不捕获异常,它只是把“业务失败的结果”表达成一个值。真正会抛出的异常(如 IO 错误、空指针)仍然需要 try-catch 或 Try 来兜底收编。正确姿势是:边界处用 Try 捕获外部异常,中间业务用 Either/Validated 流转错误值。就像分拣中心:救援站负责接住意外摔碎的包裹,双槽分拣口负责分流业务不合格的包裹。

为何常见:因为教程总说“函数式不用异常”,初学者容易把“表达失败”与“捕获异常”混为一谈。

误区既然 Validated 既能累积错误又能组合,那 Either 是不是可以退休了?

正解恰恰相反。Validated 是 Applicative,刻意不提供 flatMap——因为“错误累积”与“后一步依赖前一步结果”天然冲突。需要按依赖顺序串联的流程(先登录再查订单)必须用 Either + for 推导;平行无依赖的校验场景才用 Validated。传送带管串联,质检单管平行,缺一不可。

为何常见:只看到 Validated 比 Either“多会一项”,忽略了二者的代数结构差异。

误区反正 Left 里可以放任何类型,直接用 `Either[String, Int]`,错误信息写个提示语不就行了?

正解String 无法穷尽匹配、容易被拼写错误污染、无法携带结构化上下文。用 sealed trait 定义错误层级,让编译器替你检查所有分支——漏写处理就给出警告。质检单的标准格式,比随手写的便签可靠得多。

为何常见:快速原型阶段习惯了“字符串即错误”,没有养成用类型建模错误的习惯。

误区Left 和 Right 语义完全自由,我可以规定 Left 成功、Right 失败。

正解语言层面确实不强制,但标准库、Cats 以及社区约定都是 Right 代表成功、Left 代表失败,且右偏操作围绕 Right 展开。如果你反过来用,所有 map/flatMap 的语义都会颠倒,代码可读性灾难。双槽分拣口可以设计成左绿右红,但机场不会因为你一个人的习惯改航道。

为何常见:对“约定优于配置”的感受不深,忽视了生态一致性。

误区在 for 推导里,前面的 Left 不会再被后续代码碰到,错误信息不就丢了吗?

正解不会丢。短路返回的正是原来的 Left 值本身——它被原样传回给调用方。for 推导的最终结果若不是 Right,就是第一个 Left,错误伴随始终。就像传送带把红标包裹原封不动送进退货口,标签始终贴在包裹上。

为何常见:混淆了“后续代码不再处理错误”与“错误信息消失”两件事。

面试题

Q1. Scala 中 Either[A, B],按社区约定哪边代表成功?

单选题 · easy

  • A.Left 代表成功
  • B.Right 代表成功← 正确答案
  • C.由编译器决定
  • D.两边语义等价

答案:B。社区约定与标准库实现都是 Right 载成功、Left 载错误,且右偏操作围绕 Right 展开。就像分拣中心右边绿色通道放行、左边红色通道分流,约定必须一致,代码才可读。

Q2. 调用 Left(a).map(f) 的返回结果是什么?

单选题 · easy

  • A.Left(f(a))
  • B.Left(a)← 正确答案
  • C.Right(a)
  • D.Right(f(a))

答案:B。Either 右偏,map 只对 Right 生效。Left 遇到 map 原样返回自身,f 完全不执行——红标包裹不再接受任何加工工序。

Q3. 下列哪个场景最适合用 Option 处理?

单选题 · easy

  • A.缓存未命中只需重查← 正确答案
  • B.需要详细错误原因
  • C.需要聚合多个错误
  • D.需要捕获系统异常

答案:A。Option 只有 Some/None,不携带原因。缓存未命中“重查即可”,不需要解释为什么没有,正合适;需要错误原因用 Either,聚合错误用 Validated,捕获异常用 Try。

Q4. 下列哪项是 Either 与 Validated 的本质区别?

单选题 · mid

  • A.Validated 错误能够累积← 正确答案
  • B.Validated 不支持 map 操作
  • C.Either 能够捕获所有异常
  • D.Either 不携带错误信息

答案:A。Validated 通过半群运算把所有 Invalid 合并成完整错误清单,Either 则遇错短路。Validated 支持 map;Either 不捕获异常、必须携带错误信息,因此 B/C/D 均错误。

Q5. Try 与 Either 的关键差异是什么?

单选题 · mid

  • A.Try 专门接住 Throwable← 正确答案
  • B.Try 只能用于业务校验
  • C.Either 不能表达成功
  • D.Either 会抛出运行时异常

答案:A。Try 的 Failure 装的是 Throwable,是“捕获异常后的 Either”,适合在调用外部库的边界收编异常;Either 承载自定义业务错误,二者定位互补而非替代。

Q6. Cats 中 Validated 的错误累积依赖哪个类型类?

单选题 · mid

  • A.Semigroup 类型类← 正确答案
  • B.Monad 类型类
  • C.Functor 类型类
  • D.Foldable 类型类

答案:A。错误累积依赖 Semigroup(半群)定义“两条错误如何合并”,如 NonEmptyList 的拼接。Monad 是 Either 串联的根基,Functor 只提供 map,Foldable 与累积无关。

Q7. Scala 中 for { x <- e1; y <- e2 } yield r 的脱糖展开是哪个?

单选题 · hard

  • A.e1.flatMap(x => e2.map(y => r))← 正确答案
  • B.e1.map(x => e2.flatMap(y => r))
  • C.e2.flatMap(y => e1.map(x => r))
  • D.e1.flatMap(x => e2.flatMap(y => r))

答案:A。两个生成器的 for 推导展开为“外层 flatMap + 内层 map”:e1 先 flatMap 取 x,再对 e2 map 取 y 并应用 r。B 颠倒了 map/flatMap 的角色,D 用了两个 flatMap,C 交换了执行顺序,均不等价。

Q8. sealed trait 自定义错误类型最核心的收益是什么?

单选题 · hard

  • A.编译期穷尽所有失败分支← 正确答案
  • B.错误永远不出现
  • C.程序运行速度更快
  • D.不再需要写模式匹配

答案:A。sealed trait 定义封闭集合,编译器知道全部子类型,模式匹配漏分支会警告——“手册之外的错误写不出来”。它不能消除错误,也不提升性能,匹配依然要写。

Q9. 下列哪些容器属于 Scala 函数式错误处理?

多选题 · mid

  • A.Either 容器← 正确答案
  • B.Try 容器← 正确答案
  • C.Option 容器← 正确答案
  • D.List 容器

答案:ABC。Either(业务错误)、Try(系统异常)、Option(值缺失)都是错误处理容器,都能 map/flatMap。List 是集合容器,没有“成功/失败”语义。

Q10. 用 Validated 构建表单校验管道时,正确的做法有哪些?

多选题 · hard

  • A.用 mapN 聚合多个校验← 正确答案
  • B.错误容器选用 NonEmptyList← 正确答案
  • C.用 parMapN 并行执行校验← 正确答案
  • D.用 flatMap 串联依赖校验

答案:ABC。mapN 聚合平行校验,NonEmptyList 保证错误列表非空,parMapN 利用 Applicative 并行执行——三者都符合 Validated 的累积语义。D 错误:Validated 刻意不提供 flatMap,“依赖串联”应改用 Either + for 推导。