ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

解密 Magnolia 源码解析:一文读懂 join 与 split 如何完成类型类自动派生

解密 Magnolia 源码解析:一文读懂 join 与 split 如何完成类型类自动派生 解密 Magnolia 源码解析一文读懂 join 与 split 如何完成类型类自动派生【免费下载链接】magnoliaEasy, fast, transparent generic derivation of typeclass instances项目地址: https://gitcode.com/gh_mirrors/ma/magnoliaMagnolia是一个主打快速、透明的 Scala 类型类自动派生库你只需要告诉它怎么拼装它就能在编译期为任意 case class 与 sealed trait 生成对应的类型类实例。本文将从源码入手带你一步步看清join与split这两个核心引擎的完整实现原理。为什么我们需要一个自动派生的魔法写过 Scala 类型类的同学都有过这种体验为case class手写Eq、Show实例时代码几乎是复制粘贴——每加一个字段就要改一遍方法体。更痛苦的是遇到递归的 ADT代数数据类型手写实例的代码量会呈爆炸式增长。如果能把这些模板代码交给编译器让它自动完成读取字段 → 取每个字段的类型类 → 组合成整体类型类的过程会怎样这正是 Magnolia 的出发点。它抽象出两个接口产品类型product的拼装 → 交给join和类型sum的分发 → 交给split你先别急着记概念我们先看一个实际效果再倒回去拆解。先感受魔法一个简单的 Print 示例假设我们有一个递归定义的树sealed trait Tree case class Leaf(value: Int) extends Tree case class Node(left: Tree, right: Tree) extends Tree再看examples子项目里的Print类型类见examples/src/main/scala/magnolia1/examples/print.scalatrait Print[T]: def print(t: T): String object Print extends AutoDerivation[Print]: // 基础类型的手工实例作为递归的终点 given Print[String] identity(_) given Print[Int] _.toString此时只要一行代码val tree: Tree Node(Leaf(1), Leaf(2)) println(tree.print) // 输出形如 Node(Leaf(1),Leaf(2))Print[Tree]的实例没有被任何人手写它是编译器现造出来的。造它的过程就发生在join和split这两个回调里。下面我们逐一揭开它们的真面目。入口探秘derived与编译器反射一切派生的起点在core/src/main/scala/magnolia1/magnolia.scala的Derivation特质中inline def derivedA: Typeclass[A] derivedMirror[A] inline def derivedMirrorA: Typeclass[A] inline mirror match case sum: Mirror.SumOf[A] derivedMirrorSumA // 和类型走 split case product: Mirror.ProductOf[A] derivedMirrorProductA // 产品类型走 join这里的Mirror.Of[A]是 Scala 3 内置的编译期反射入口。编译器为每个 case class / sealed trait 都悄悄生成了一个Mirror它携带了MirroredElemTypes成员类型元组等信息。Magnolia 再用inline关键字让mirror的具体类型在编译期就被解析从而把运行时才能做的事提前到编译期完成。核心结论derived本身不做派生它只做分诊——根据 Mirror 是产品型还是和类型把工作分别交给join与split。第一台引擎join 是如何把参数类型类拼接起来的它解决的场景与签名join面对的是由若干字段组合而成的类型比如 case class。它的任务可以概括为三步收集从CaseClass上下文里取出全部参数每个Param都带 label、索引、注解和默认值取实例对每个参数获得该参数类型对应的Typeclass实例存在param.typeclass字段里组合把如何从整体对象中取出字段值 对字段值调用类型类打包成一个完整的Typeclass[T]它在CommonDerivation中的签名很简洁def joinT: Typeclass[T]CaseClass这个抽象类定义在core/src/main/scala/magnolia1/interface.scala可以把它理解为一份关于该 case class 的完整体检报告。而真正把Mirror变成这份报告的是CaseClassDerivation.fromMirrorimpl.scala中。代码演示Print 的 join 实现回到print.scala我们看它的join写得多直白def joinT: Print[T] value // 先检查是不是值类AnyVal 包装是的话只处理唯一参数 if ctx.isValueClass then val param ctx.params.head param.typeclass.print(param.deref(value)) else ctx.params .map { param // deref 负责从 value 中取出该字段的值 // typeclass 负责取出该字段类型的 Print 实例 param.typeclass.print(param.deref(value)) } .mkString(s${ctx.typeInfo.short}(, ,, ))观察这段代码你会发现join里没有任何与具体字段类型相关的信息。不管字段是Int还是String它都统一通过param.typeclass去拿实例。这正是泛型派生的精髓——写一次处处通用。实现位置速查抽象接口core/src/main/scala/magnolia1/magnolia.scalaCommonDerivation.join元数据构造core/src/main/scala/magnolia1/impl.scalaCaseClassDerivation可运行示例examples/src/main/scala/magnolia1/examples/print.scala、eq.scala、decode.scala第二台引擎split 的分发机制在何时触发与 join 完全不同的挑战sealed trait 的问题和 case class 恰好相反我们不知道运行时拿到的一个值到底是哪个子类型。所以split的职责不是拼装而是分发——它要产出一个能识别运行时类型并路由到对应子类型实例的类型类。它在Derivation中的签名如下def splitT: Typeclass[T]SealedTrait里装的是所有子类型的元信息。在impl.scala的SealedTraitDerivation中subtypesFromMirror会把MirroredElemTypes元组逐个展开为每个子类型建一个Subtype对象——每个Subtype自带isType判断值是否属于该子类型和asType安全地向下转型两个函数。分发动作发生在choosesplit自己不做 if/else 的判断逻辑判断逻辑被封装在SealedTrait.choose里interface.scaladef chooseReturn(handle: Subtype[_] Return): Return tailrec def rec(ix: Int): Return if ix subtypes.length then val sub subtypes(ix) if sub.cast.isDefinedAt(value) then handle(SealedTrait.SubtypeValue(sub, value)) else rec(ix 1) // 不是这个子类型继续试下一个 else throw new IllegalArgumentException(s不是 $typeInfo 的合法子类型) rec(0)用大白话翻译choose是一个按顺序尝试的分诊台它会遍历所有子类型的isType检查命中第一个匹配的就回调handle。代码演示Print 的 split 实现override def splitT: Print[T] ctx.choose(_) { sub // sub.value 把值安全地转型为具体子类型 // sub.typeclass 拿到该子类型自己的 Print 实例 sub.typeclass.print(sub.value) }再看eq.scala里的Eq.split它演示了如何在choose内部同时处理两个值override def splitT: Eq[T] (v1, v2) ctx.choose(v1) { sub sub.typeclass.equal(sub.value, sub.cast(v2)) }实现位置速查抽象接口core/src/main/scala/magnolia1/magnolia.scalaDerivation.split子类型遍历core/src/main/scala/magnolia1/impl.scalaSealedTraitDerivation分发实现core/src/main/scala/magnolia1/interface.scalaSealedTrait.choose可运行示例examples/src/main/scala/magnolia1/examples/print.scala、csv.scala、decode.scala两条引擎是如何咬合运转的现在把两个引擎放进同一台机器里看整体运转。下面的调用链就是一次完整派生的解剖图derived[Tree]拿到 Mirror │ ├─ Tree 是 sealed trait ──► derivedMirrorSum │ │ │ └─► split(SealedTrait) │ │ 对每个子类型 Leaf / Node │ │ 尝试 summon 它们的 Typeclass找不到就递归 derived │ ▼ │ ctx.choose 按运行时类型路由到具体子类型 │ └─ Leaf / Node 是 case class ──► derivedMirrorProduct │ └─► join(CaseClass) │ Leafsummon Print[Int] │ Nodesummon Print[Tree]回到顶端 ▼ 组合出完整的 Print[T]观察这个链条你会发现一个关键机制——互相递归split派生子类型时如果子类型是 case class会再去调joinjoin组合参数时如果参数本身是 sealed trait比如Node的left是Tree又会回到split正是这种你中有我、我中有你的递归让任意深度的嵌套 ADT 都能被自动覆盖。递归为什么不会死循环这是最容易被忽略却最精妙的一环。看impl.scala中参数实例的获取方式val tc new SerializableFunction0[Typeclass[p]]: override def apply(): Typeclass[p] summonInline[Typeclass[p]] // 用惰性求值包裹而不是立即计算 CallByNeed.createLazy(tc)CallByNeed实现了按需求值Node的参数类型是Tree但Print[Tree]的实例不会在构造Print[Node]时立刻求值而是先存放一个将来能取到实例的懒引用。只有当真正打印一个Node值、需要调用Print[Tree]时才去求值——而那时整个派生早已完成。这一层懒封装让递归派生在编译期和运行期都不会陷入无限循环。实战还原Print[Tree] 的完整派生之旅我们把前面的Tree例子放回源码语境一步一步还原编译器经历了什么第一步初始化。用户代码tree.print触发隐式搜索编译器找到Print.autoDerived生成Print[Tree]的请求。第二步分诊。derivedMirror检查到Tree是Mirror.SumOf进入derivedMirrorSum调用split。第三步建表。SealedTraitDerivation从MirroredElemTypes (Leaf, Node)中为两个子类型创建Subtype对象并尝试获取它们的Print实例——此时Leaf、Node都没有现成实例于是递归调用derived[Leaf]、derived[Node]。第四步join 登场。Leaf是产品类型进入derivedMirrorProductCaseClassDerivation.fromMirror解析出参数value: Intparam.typeclass取到已存在的Print[Int]基础类型实例递归的终点。join于是能拼出Leaf( value )。Node同理但它的参数类型是Tree所以param.typeclass拿到的是一个懒引用——指向尚未完全求值的Print[Tree]自身。第五步组装。split拿到两个子类型的实例后生成最终的Print[Tree]打印时先choose判断运行时类型命中Leaf走join产物命中Node走join产物并在递归处展开。至此一个零手写代码的Print[Tree]实例正式诞生。进阶视角join / split 与宏、反射的关系很多初学者会混淆这三个概念这里帮你理清Mirror编译期反射提供类型结构——有哪些字段、有哪些子类型这是派生的数据来源Macro宏见core/src/main/scala/magnolia1/macro.scala通过quotes.reflect额外收集注解、默认值、类型信息等Mirror给不了的东西。比如defaultValue[T]就是通过反射找到伴生对象里编译器生成的init$default$N方法把默认值捞出来join/split用户回调只关心如何用拿到的信息组装类型类是策略层三者分工明确前两者负责知道join/split负责做到。这也是 Magnolia 把join/split设计成开放方法的原因——Derivation特质本身不包含任何具体业务逻辑你实现的两个方法就是唯一的业务入口。总结与延伸学习回顾全文可以把关键点浓缩成四句话derived是总调度靠Mirror判断产品型/和类型分别路由到join与splitjoin是组合器遍历ctx.params用deref取值、用param.typeclass取实例拼出一个整体类型类split是分发器靠ctx.choose按运行时类型定位子类型再委托给子类型的实例二者靠互相递归 CallByNeed惰性求值共同撑起任意嵌套、递归 ADT 的自动派生想进一步深挖推荐从这些地方入手源码核心core/src/main/scala/magnolia1/目录下的magnolia.scala、interface.scala、impl.scala、macro.scala实战示例examples/src/main/scala/magnolia1/examples/里的csv.scalafoldLeft 拼装、decode.scala配合construct重建对象、default.scala默认值派生行为验证test/src/test/scala/magnolia1/tests/下的ProductsTests.scala、SumsTests.scala、RecursiveTypesTests.scala分别覆盖产品类型、和类型与递归场景的边界情况如果想要亲手实验将仓库https://gitcode.com/gh_mirrors/ma/magnoliaclone 到本地后直接在examples工程里新增你自己的join/split实现跑一遍测试你会真正理解模板代码消失的快乐。【免费下载链接】magnoliaEasy, fast, transparent generic derivation of typeclass instances项目地址: https://gitcode.com/gh_mirrors/ma/magnolia创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表