
手写一百个case class的equals之后我翻开了Magnolia的源码【免费下载链接】magnoliaEasy, fast, transparent generic derivation of typeclass instances项目地址: https://gitcode.com/gh_mirrors/ma/magnolia一个下雨的周五晚上我又在给新加的十几个case class补写equals和hashCode。这类样板代码长得一模一样唯一的区别是字段名不同。就在那一刻我意识到如果连比较两个值是否相等这种高度规律的动作都要人手写那一定有什么地方可以自动化。Magnolia就是为这件事而生的——一个主打Easy, fast, transparent的Scala类型类自动派生库它让我第一次体会到类型类的实例原来是可以让编译器算出来的。本文不打算堆概念而是跟着一条真实的推导路径走当你写下given Eq[MyType] Eq.derived时编译器内部到底发生了什么我们直接从源码里找答案。先问一句类型类到底能不能按需生成在深入源码之前先做个小小的思想实验。假设我们要为任意case class自动生成相等比较器最朴素的想法是什么// 伪代码如果编译期能把字段拆出来…… def autoEqual[T]: (T, T) Boolean { // 对每个字段递归调用它的 equal // 再把所有结果 AND 起来 }关键点有两个一是编译器得知道这个类型有哪些字段、每个字段是什么类型二是每个字段的类型还得已经存在对应的比较器。这两件事恰恰是Magnolia内部最核心的两步动作。它把前者交给Scala 3内建的Mirror机制把后者藏进两个叫join和split的方法里——一个管把零件拼成整机一个管从整机分拣出零件。你写下一行 derived 时编译器已经排好了一条流水线所有推导的入口都收敛在Derivation特质里。让我们打开核心源码core/src/main/scala/magnolia1/magnolia.scala找到这段只有十来行的总调度inline def derivedMirrorA: Typeclass[A] inline mirror match case sum: Mirror.SumOf[A] derivedMirrorSumA case product: Mirror.ProductOf[A] derivedMirrorProductA inline def derivedA: Typeclass[A] derivedMirror[A]这段代码解决什么问题分流。Scala编译器会为每个数据类型自动生成一个Mirror实例而它恰好有两种形态若是ProductOfcase class、普通类说明这是一个产品类型字段可以像搭积木一样逐个拼起来 → 走derivedMirrorProduct若是SumOf密封特质、枚举说明这是一个和类型必须按子类型分情况讨论 → 走derivedMirrorSum如果你把整个派生过程想象成一条流水线进料Mirror提供了类型的元信息字段名、字段类型、子类型列表分流derivedMirror根据Mirror形态决定走哪条产线产出join/split把字段级的类型类实例组合成一个整体细心的你可能已经发现了这里用的是inline。这意味着整个分流过程发生在编译期运行时根本没有if-else的分支判断最终生成的代码就是针对你这个具体类型量身定制的。这也是Magnolia敢自称transparent的底气——展开后的代码和你手写的几乎一样高效。join把字段的答案像串珠子一样串起来现在我们把镜头对准产品类型的产线。Magnolia在examples/src/main/scala/magnolia1/examples/eq.scala里给了一个教科书级的Eq实现它的join只有一行核心逻辑def joinT: Eq[T] (v1, v2) ctx.params.forall { p p.typeclass.equal(p.deref(v1), p.deref(v2)) }这行代码在做什么逐个字段比较。拆开看ctx.params是编译期收集好的所有参数信息每个p身上挂着三样东西p.label字段名比如namep.typeclass这个字段类型已经推导好的Eq实例比如Eq[String]p.deref(v1)从整个case class实例中取出该字段的值的取件器所以整段逻辑翻译成人话就是遍历所有字段对每个字段都问一句两边相等吗只要有一个不相等就返回false。forall负责短路字段越多代码越长但逻辑始终是这个循环——而你一行都不用写。这里最妙的地方在于p.typeclass。它意味着join不需要关心字段的具体类型Eq[String]、Eq[Int]、甚至嵌套的Eq[Address]一律通过同一个抽象接口要过来然后把它们组装成Eq[整个case class]。组装而非实现这就是join这个名字的由来。split面对密封特质它是怎么认出你是谁的产品类型解决了剩下的是和类型。当T是一个密封特质时我们面对的不是比较哪些字段而是该走哪个子类的逻辑。同一份eq.scala里的split回答了这个问题override def splitT: Eq[T] (v1, v2) ctx.choose(v1) { sub sub.typeclass.equal(sub.value, sub.cast(v2)) }对比join的人人有份split更像一个分拣员拿到一个实际对象v1先判断它到底属于密封特质的哪个子类型再把处理权交给那个子类型的Eq实例。ctx.choose(v1)的底层逻辑在core/src/main/scala/magnolia1/interface.scala里总共也就十几行def 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( sThe given value $value is not a sub type of $typeInfo ) rec(0)逻辑一目了然逐个试sub.cast.isDefinedAt(value)谁接得住就把活交给谁全试完还不行就抛出异常。这里有个值得品味的细节每个Subtype本质是一个PartialFunctionisDefinedAt背后是a.isInstanceOf[s A]这种类型判断cast则是a.asInstanceOf[s A]这种安全转型。判断和转型成对出现既确认了身份又保证了类型安全还利用了尾递归优化——三个细节一次到位。藏在细节里的三处匠心读Magnolia源码除了一目了然的join/split主干还有几处细节值得停下来看看它们解释了为什么派生不会卡死或者跑得很慢。第一处惰性求值。在impl.scala里每个字段的类型类实例都被包进了一个CallByNeed容器val tc new SerializableFunction0[Typeclass[p]]: override def apply(): Typeclass[p] summonInline[Typeclass[p]] // ... CallByNeed.createLazy(tc)summonInline会在编译期尝试寻找现成的类型类实例找不到就递归触发推导。用CallByNeed包一层是为了延迟到真正使用时才求值——递归派生树再深只要运行时没走到那个分支就不会白算也避免了循环引用时的死循环。第二处递归退出的规则。在subtypesFromMirrorStep里编译器先试探有没有已存在的实例实在没有才往下推导summonFrom { case tc: Typeclass[s] tc case _ deriveSubtype(summonInline[Mirror.Of[s]]) }这就是递归的底座基础类型String、Int的实例由你用given提供复合类型才触发新一轮派生。没有这一层递归就会变成无限循环。第三处子类型排序。subtypesFromMirror的收尾会做一次distinctBy(_.typeInfo).sortBy(_.typeInfo.full)。别小看这个排序它保证了推导结果与子类型声明的顺序无关同一类型在任何地方派生出来的实例结构都是确定性的。这为序列化、缓存、跨模块复用扫清了大坑。两个容易踩的坑自动 vs 半自动以及你是谁家derived的用熟了之后你会遇到Magnolia里最容易混淆的两个选择源码里其实写得明明白白。坑一自动派生和半自动派生。回头再看magnolia.scala文件末尾躺着两个不同的特质trait Derivation[TypeClass[_]]: inline given derivedA: Typeclass[A] derivedMirror[A] trait AutoDerivation[TypeClass[_]] extends Derivation[TypeClass]: inline given autoDerivedA: TypeClass[A] derived区别就在AutoDerivation多了一个autoDerived它会无条件为任意类型尝试推导适合全局兜底而纯Derivation要求你显式写given X X.derived。显式的版本编译更快、错误信息更友好代价是每个类型都要你点一次名。我们示例里的Eq用的是AutoDerivation而semiauto.scala里的SemiPrint就是半自动的典型。坑二不同模块、不同包下的derived不是同一个实例。因为derived是given实例的解析遵循Scala的作用域规则。你在object A里派生出来的Eq[Foo]和object B里派生出来的是两个不同的值。这在绝大多数场景下无害但在做缓存或序列化注册时要格外留意。动手试试让编译器替你写第一个类型类读到这里你已经掌握了Magnolia的全部核心心智模型Mirror负责提供类型结构join/split负责组装与分派inline保证这一切在编译期完成。剩下的就是亲手写一个属于自己的类型类了。建议的练习路径先跑通仓库里现成的示例从eq.scala、show.scala开始它们逻辑最短、最直白模仿HasDefaultexamples/src/main/scala/magnolia1/examples/default.scala写一个能给出空值的类型类你会第一次用到ctx.constructMonadic挑战decode.scala或csv.scala体会join与split在反序列化场景下如何配合进阶方向研究macro.scala里的CollectAnnotations看Magnolia如何把注解信息也带进派生过程——这对写JSON序列化库、ORM映射器极其有用想动手的话把仓库拉下来本地跑一跑git clone https://gitcode.com/gh_mirrors/ma/magnolia下次再遇到给二十个case class补样板代码的场景希望你想起这篇文章的结论类型类的推导本质就是一场编译期的流水线作业——而Magnolia把流水线的图纸已经画好放在core/src/main/scala/magnolia1/里了。【免费下载链接】magnoliaEasy, fast, transparent generic derivation of typeclass instances项目地址: https://gitcode.com/gh_mirrors/ma/magnolia创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考