ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Scala类型参数化实战:从泛型到类型类,打造通用数据访问层

Scala类型参数化实战:从泛型到类型类,打造通用数据访问层 前两周在重构一个老项目的数据访问层我盯着屏幕里三份几乎一模一样的 DAO 代码看了很久。UserDao、OrderDao、ProductDao除了类型不同insert、update、findById 的逻辑长得跟三胞胎似的唯一区别就是把User换成Order、把Order换成Product然后复制粘贴再全局替换。这种代码能跑但每次加一个实体就要再复制一次每次改一个公共逻辑就要同步改三处。也就是在这个节点我决定把 Scala 类型系统和类型参数化的机制彻底用起来用一套通用组件解决这一整类问题。这篇文章就围绕这个主题展开从泛型、变型、类型边界到类型类再到抽象类型成员和隐式机制我会用实际代码和踩坑记录把 Scala 类型参数化从入门到进阶讲透。无论你是刚接触 Scala 泛型的初学者还是已经在写通用组件但经常被编译错误卡住的老手这篇都能给你一份可以直接照着做的参考。1. 为什么类型参数化是 Scala 代码通用性的地基1.1 没有泛型时我们的代码是怎么变烂的先看一段没有做类型参数化的 DAO 代码。假设现在有User和Order两个实体类你要为他们各写一个仓库类case class User(id: Long, name: String) case class Order(id: Long, amount: BigDecimal) class UserDao { def findById(id: Long): Option[User] { // 查询 SQL然后把结果集包装成 User Some(User(id, default)) } def save(u: User): Unit { // 插入 SQL字段从 u 身上取 } } class OrderDao { def findById(id: Long): Option[Order] { Some(Order(id, BigDecimal(0))) } def save(o: Order): Unit { // 插入 SQL字段从 o 身上取 } }每个方法里真正不同的只有三四处表名、字段映射、返回类型。剩下的流程骨架完全一致。但因为你把类型写死了这段公共逻辑就被迫复制了 N 遍。以后如果再冒出来一个ProductDao、CartDao你只能继续复制。这种代码我当时写的时候不觉得有问题直到某次在save方法里改一个日志逻辑改完UserDao忘了同步OrderDao线上数据日志格式不一致排查了一下午。从那之后我下定决心凡是遇到“流程相同、类型不同”的代码一律考虑用类型参数化收拢。1.2 类型参数化的三板斧类型参数、类型边界、变型类型参数化说白了就是把“类型”本身当成一个可以变化的量在定义类或方法时先留一个占位符等具体使用时再传进来。Scala 里用中括号[T]表示类型参数例如List[Int]、Option[String]。有了类型参数你就可以写一个对所有类型通用的组件class Box[T] { private var content: T _ def put(x: T): Unit content x def get: T content }Box不知道T具体是什么但它能保证放进Box[String]里的东西一定是String取出来也一定是String。这个“编译期的保证”比任何注释都可靠。但仅仅有类型参数还不够。有些通用组件需要对类型做更多约束。比如你的仓储层要求所有实体都带id字段那就得让调用者不能随便传一个没有id的类型进来。这时候就需要类型边界也就是用:和:来限定类型参数的范围。再比如当你设计容器时需要明确“一个List[String]能不能传给要求List[Any]的方法”这涉及到变型协变、逆变、不变的设计。这三样东西——类型参数、类型边界、变型——是 Scala 类型参数化的地基。后面的所有高级技巧都是在这三块基石上长出来的。1.3 同样是泛型Scala 比 Java 强在哪用过 Java 泛型的读者可能觉得上面这些东西似曾相识。确实Java 从 JDK 1.5 起也有了泛型但 Scala 的泛型在表达能力上强得多。Java 的泛型是“使用点变型”就是说你在调用时通过? extends T或? super T来临时声明容器类型之间的关系声明起来啰嗦而且很容易写错。Scala 则是在定义类时直接声明变型比如List[A]里的表示 List 对泛型参数是协变的一处声明所有使用场景自动生效。还有一点很关键Java 的类型擦除非常彻底你拿不到任何运行时的泛型信息写一个new T[]的通用数组工厂基本不可能。Scala 虽然也基于 JVM 的类型擦除但它提供了ClassTag和TypeTag来保留必要类型信息配合上下文边界能实现很多 Java 里做不了或者做起来极不方便的通用组件。另外Scala 的隐式参数和隐式机制可以与泛型组合出“类型类”模式这是 Java 泛型完全覆盖不到的领域。这些差异正是 Scala 类型参数化值得深入学习的理由。2. 泛型、变型与类型边界三个基本功2.1 泛型类与泛型方法从最小形态开始先看泛型类。Scala 里类的类型参数写在类名后面的中括号中可以写多个case class Pair[K, V](key: K, value: V) val p1 Pair(name, 18) // 类型推导为 Pair[String, Int]没必要手写类型参数泛型方法则是把类型参数写在方法名前面def identity[T](x: T): T x def firstOf[T, U](t: T, u: U): T t这里值得注意的一点是 Scala 对泛型参数的命名习惯。像 Java 一样单个参数惯用T、U、A、B但当你写 Scala 库时更多人习惯用A、B表示“任意类型”因为 Scala 标准库大量使用A作为占位符比如List[A]、Option[A]。这在小规模代码里无所谓但写公共组件时最好保持一致读者能少花很多理解成本。泛型方法最典型的使用场景是“类型参数只出现在参数与返回值的对应关系中”。比如def toMap[K, V](keys: Seq[K], values: Seq[V]): Map[K, V] keys.zip(values).toMap这个方法不关心K和V具体是什么只关心“两个序列按位置配对并生成对应类型的 Map”。如果你用Any来写编译期就无法保证传入的Seq[String]和Seq[Int]被正确关联你可能在运行时才能发现Map[Any, Any]里的键值类型是乱的。泛型方法的价值就在这种“关系保持”上。2.2 协变、逆变与不变为什么 List[A] 而 Array[T] 不变变型variance是整个 Scala 类型系统里最容易绕晕的概念。我用最直白的话解释协变List[String]也是List[Any]因为 String 是 Any 的子类List[String] 自然也是 List[Any] 的子类型。Scala 中用A表示协变。逆变Function1的参数类型是逆变的。一个Int String的函数能当作Any String用吗不能。反过来一个Any String的函数却可以当作Int String用因为任何Int都是Any所以能处理Any的函数必然能处理Int。也就是说参数类型要求越宽泛越好子类型关系反过来了。Scala 中用-A表示逆变。不变Array[T]既不协变也不逆变。Array[String]不是Array[Any]Array[Any]也不是Array[String]。因为数组是可变的容器你往里写东西就可能破坏类型安全。Scala 标准库里最经典的例子是List[A]。因为 List 是不可变的你可以安全地把一个List[String]当作List[Any]传递没有人能往里面塞一个Int去破坏类型安全。而Array[T]不变是因为数组允许元素赋值如果Array[String]能被当作Array[Any]代码就可以往里面塞Int运行时就会出类型错误。在自己的类里声明变型要比调用时理解更深一层。比如你定义一个协变容器class Container[A](val content: A) { def get: A content }这样没问题get返回A是协变位置。但如果你尝试加一个“放入”的方法class Container[A](val content: A) { def put(x: A): Unit ??? // 编译错误 }编译器会报contravariant position错误。原因很好理解参数位置是逆变位置而你当前声明的是协变类型参数。如果编译器允许这段代码一个Container[String]就能被当成Container[Any]往上转然后在别人手里往里面put(100)那个“Int”就会混入本该全是 String 的容器。为了阻止这种灾难编译器直接拒绝了你。明白这个道理后再看到“covariant type A occurs in contravariant position”编译错误你就知道不是编译器在找茬而是它替你在底层守住了类型安全。2.3 类型边界上界与下界以及它们如何修复变型类型边界给类型参数加约束。上界用:表示类型参数必须是某个类型的子类型trait Entity { def id: Long } case class User(id: Long, name: String) extends Entity def findById[T : Entity](id: Long): Option[T] ???这里T : Entity保证了你只对“是 Entity 子类”的类型调用findById编译期就挡住了乱传类型的行为。这比在方法里判断isInstanceOf[Entity]要高明得多——约束发生在编译期而不是运行时。下界用:表示类型参数必须是某个类型的父类型。下界最常见的妙用是修变型的错误。拿List[A]的::在头部添加元素方法举例sealed trait List[A] { def ::[B : A](elem: B): List[B] }你可能会想List都协变了A怎么还能出现在参数位置关键就在引入了一个新的类型参数B且约束B : A。你把prepend的参数类型声明为B而B的最小下界是A这样B实际可以是A的任意父类型。此时参数类型并不直接是A所以协变约束没有被破坏但返回值变成了List[B]。这既保证了你能往List[String]头部加一个新的 StringB String也保证了你把一个 Int 加进List[String]时结果自动变成List[Any]类型信息不被破坏。这种“用下界换协变兼容”的技巧在写通用不可变数据结构时几乎天天用。2.4 上下文边界与隐式参数让类型参数携带能力有些通用逻辑不仅要求类型参数满足某个父类关系还要求它能支持某些操作。比如你要写一个通用求大值函数def maxOf[T](x: T, y: T): T if (x y) x else y这编译不过因为你没有证据证明T支持运算。Scala 的解法是把“比较能力”作为隐式参数传进去def maxOf[T](x: T, y: T)(implicit ord: Ordering[T]): T if (ord.gt(x, y)) x else y这种写法有点啰嗦所以 Scala 提供了一种语法糖叫上下文边界def maxOf[T: Ordering](x: T, y: T): T { val ord implicitly[Ordering[T]] if (ord.gt(x, y)) x else y }[T: Ordering]等价于声明“存在一个隐式的Ordering[T]实例”在方法体里用implicitly[Ordering[T]]取出来。这个语法对泛型能力的第一轮扩展至关重要因为很多通用组件需要隐式提供“辅助能力”比如序列化、比较、解析。有了它你就能写出“要求 T 必须有某种能力”但不指定具体如何实现的高复用代码。在后面设计通用数据访问层时[T: EntityOps]这种上下文边界会直接派上用场。3. 实战设计一套通用的数据访问层3.1 需求背景三个重复 DAO 引发的重构回到开头说的场景。我有User、Order、Product三个实体每个都有类似 DAO逻辑骨架相同但表名、字段映射、结果集包装方式不同。我希望做出一个GenericRepository[T]让所有实体共用同一套插入、查询、更新逻辑。但同时不同类型对“如何把行数据包装成对象”有各自的实现方式。这里有两个需求点类型层面需要限定T必须是Entity的子类型保证有id。类型层面需要提供一种“如何把T映射到 SQL、如何从结果集恢复T”的策略而且这个策略不能侵入实体内部。我选择用 type class 模式来承载“策略”部分。事实证明这种设计不仅消灭了重复 DAO还让后面接入新实体变得非常轻量。3.2 定义基础类型与 EntityOps 类型类先定义实体基类和实体操作接口trait Entity { def id: Long } case class User(id: Long, name: String) extends Entity case class Order(id: Long, amount: BigDecimal) extends Entity case class Product(id: Long, title: String) extends Entity然后定义一个EntityOps[T]作为“类型 T 的持久化描述”trait EntityOps[T : Entity] { def tableName: String def columns(e: T): Seq[(String, Any)] def fromRow(row: Map[String, Any]): T } object EntityOps { def apply[T : Entity](implicit ev: EntityOps[T]): EntityOps[T] ev implicit val userOps: EntityOps[User] new EntityOps[User] { val tableName users def columns(e: User): Seq[(String, Any)] Seq(id - e.id, name - e.name) def fromRow(row: Map[String, Any]): User User(row(id).asInstanceOf[Long], row(name).asInstanceOf[String]) } implicit val orderOps: EntityOps[Order] new EntityOps[Order] { val tableName orders def columns(e: Order): Seq[(String, Any)] Seq(id - e.id, amount - e.amount) def fromRow(row: Map[String, Any]): Order Order(row(id).asInstanceOf[Long], row(amount).asInstanceOf[BigDecimal]) } }这里的核心是implicit val当代码中请求EntityOps[User]时Scala 编译器会在EntityOps的伴生对象里找到userOps实例。这一机制为类型参数化提供了“按类型自动分发策略”的能力是 type class 模式的关键。3.3 用上界、上下文边界和 ClassTag 实现 GenericRepository有了EntityOps[T]通用仓储类就顺理成章了class GenericRepository[T : Entity : EntityOps] { private def ops implicitly[EntityOps[T]] def findById(id: Long)(implicit ct: ClassTag[T]): Option[T] { // 这里演示如何利用 ClassTag 在运行时构造正确的类型 val clazz ct.runtimeClass // 实际项目里要真正查询数据库然后取回 row val row: Map[String, Any] Map(id - id, name - some-name) Some(ops.fromRow(row)) } def save(e: T): Unit { val table ops.tableName val cols ops.columns(e) // 生成 INSERT 语句执行数据库操作 println(sINSERT INTO $table (${cols.map(_._1).mkString(,)}) sVALUES (${cols.map(_._2).mkString(,)})) } }这个类同时用了三个前面的基础概念T : Entity类型上界保证 T 有 id。[T : EntityOps]上下文边界编译器帮忙传入隐式EntityOps[T]。ClassTag[T]运行时保留类型信息解决 JVM 泛型擦除后无法获取runtimeClass的问题。使用起来是这样val userRepo new GenericRepository[User] userRepo.save(User(1, Alice)) // 实际输出 INSERT 语句编译期就保证类型安全 val productRepo new GenericRepository[Product] // 如果没有任何 EntityOps[Product] 隐式实例这里直接编译失败注意最后这句话假如你还没有为Product定义EntityOps那么new GenericRepository[Product]会在编译期报“找不到隐式值”。这比运行时抛异常友好太多——类型系统把“该类型不可持久化”的事实提前暴露了。3.4 类型类模式对比继承模板方法为什么更好用写到这有人会问我写一个abstract class BaseRepository[T]把公共方法放进去让每个子类实现columns、fromRow不也能达到类似效果吗确实能但有几个明显劣势。第一继承体系是单根的一个类一旦继承了BaseRepository就无法再继承别的而 type class 不要求实体类型做任何改造User不需要继承某个特定父类。第二模板方法要求每个具体类型继承一次并实现抽象方法实例很多时类数量膨胀type class 只需要为类型提供一个隐式实例不需要额外创建子类。第三模板方法把“如何持久化”的职责绑定到类本身那同一类型想用不同持久化策略就做不到了type class 可以针对同一个类型定义多个不同的隐式实例比如一个走 MySQL一个走内存调用时按作用域选择。用一个表格看更清楚维度继承模板方法type class对实体类型的侵入性必须继承基类无侵入外部定义实例同一类型多种策略极难实现方式固定容易按隐式作用域切换编译期能力校验满足继承关系即可找不到隐式实例直接编译失败类数量每个实体多一个子类只需多定义若干隐式实例组合性差单继承限制好多种 type class 可叠加说白了模板方法适合“行为基本固定、只是参数不同”的场景当你需要“针对不同类型甚至不同运行环境切换实现策略”时type class 就是更合适的选择。Scala 标准库里的Ordering、Numeric、ClassTag全是 type class 思想可以说这是 Scala 通用组件设计的默认解。3.5 进一步延伸用类型参数化做一个通用序列化组件数据访问层只是类型参数化的一种应用。同一套方法打个包换个壳就能用在序列化上。比如trait JsonEncoder[T] { def encode(t: T): String } object JsonEncoder { def apply[T](implicit ev: JsonEncoder[T]): JsonEncoder[T] ev implicit val stringEncoder: JsonEncoder[String] (t: String) \ t \ implicit val longEncoder: JsonEncoder[Long] (t: Long) t.toString } def toJson[T: JsonEncoder](value: T): String { val encoder implicitly[JsonEncoder[T]] {\value\: encoder.encode(value) } } toJson(hello) // 输出 {value: hello}[T: JsonEncoder]让toJson能接住任何“可编码”类型而无需把判断逻辑堆在函数里。以后给新类型加序列化能力只要提供一个JsonEncoder隐式实例就行toJson方法本身一行都不用改。这种体验就是类型参数化带来的“通用性红利”通用组件写好了长期收益非常明显。4. 进阶机制抽象类型成员、F-bounded 多态与隐式解析4.1 抽象类型成员与路径依赖类型泛型参数是把类型“从外面传进来”而抽象类型成员是面向对象风格里把类型“作为类内部的一部分声明”。看下面的例子trait Service { type Input type Output def process(x: Input): Output } object IntToString extends Service { type Input Int type Output String def process(x: Int): String x.toString }这里Service不知道Input和Output是什么它只是声明了这两个类型成员。子类或实例必须给出具体定义。抽象类型成员和泛型参数在能力上有很多重叠但有一个非常特别的性质路径依赖类型。访问IntToString.Input得到的是一个与IntToString实例绑定的类型其他实例即使结构相同也不是同一个类型val svc1 new Service { type Input Int; type Output String; /* ... */ } val svc2 new Service { type Input Int; type Output String; /* ... */ } def consume(s: Service)(x: s.Input): s.Output s.process(x) consume(svc1)(5) // 编译通过参数类型是 svc1.Input // consume(svc2)(5)这个 5 本应该是 svc2.Input类型不匹配会编译失败路径依赖类型非常强大它能在编译期表达出“这个值是绑定到那个实例的”这种语义常用于依赖注入和领域建模。但也正因为它强新手经常被它绕进去明明结构一样的两个实例却无法互相赋值。遇到这种情况最简单的解法是改用泛型参数T设计接口让类型信息由外部统一传递避免路径依赖带来的不匹配问题。4.2 F-bounded 多态让类型参数能引用自身F-bounded 多态指的是A : F[A]这种形式比如A : Ordered[A]。考虑一个比较排序函数def sort[A : Ordered[A]](xs: List[A]): List[A] { xs.sorted // 实际上标准库的 sorted 用 Ordering这里演示概念 }关键是这个A必须实现“和同类比较”的能力。Ordered[A]类型参数就是这个“同类”。这种自引用式的约束能表达非常精确的领域规则“A 只能和 A 自己比不能和别的类型比。”这样编译期就不会出现user.compare(order)这种荒谬调用。不过在实际项目里F-bounded 不是首选因为它会给类型参数增加很强的耦合性——你要求所有类型都继承Ordered[A]就限制了类型的自由。更常见的是用 type classOrdering[A]解耦它不要求实体自身实现任何接口只在需要比较时提供一个隐式实例。两者对照可以这样理解F-bounded 是“能力内置”type class 是“能力外挂”。我在设计通用组件时优先用 type class除非你明确需要“类型的定义本身就承诺了某种能力”的语义。4.3 隐式转换、隐式类与类型类边界怎么划Scala 2 里有一整套隐式机制容易让人混淆的有三种implicit def做隐式转换、implicit class做扩展方法、implicit val/object提供隐式实例type class。先说隐式转换。它的语法很简单implicit def strToInt(s: String): Int s.length有了它编译器可以在需要 Int 时自动把 String 转过去。但隐式转换是把双刃剑它会在你完全没预期的地方发生造成特别隐晦的 bug。我有一次排查了很久才定位到一个问题一个本该报类型错误的地方编译器因为某个隐式转换悄悄把参数改了类型导致判断逻辑在运行时才出错。后来我发现Scala 社区对隐式转换的态度越来越保守除非是做第三方库互操作否则尽量别用全局隐式转换。implicit class则是给已有类型“续写方法”的便捷方式implicit class RichString(s: String) { def shout(): String s.toUpperCase ! } hello.shout() // 编译器找到隐式类生成 RichString(hello)这在工具方法里很常用比隐式转换安全得多因为它的触发条件明确是用例清晰。至于 type class我们已经在上一节里演示过它是把隐式实例当作“能力字典”按类型查询并自动装配。如果想在这三种方式之间做选择我的实践经验是扩展行为优先用implicit class或 type class别用隐式转换去“省”类型标注更不要依赖隐式转换来填补跨层传递的参数。4.4 类型擦除与 ClassTag/TypeTag如何在运行时拿到类型信息JVM 的泛型是通过擦除实现的。List[String]在运行时的形态和List[Int]没有区别都是List。这意味着下面这段代码编译不过def check[T](x: Any): Boolean x match { case _: T true // 编译错误Cannot use type T in a pattern match }编译器没法在运行时检查x是不是T因为T的类型信息已经被擦除了。解决方案是使用ClassTagimport scala.reflect.ClassTag def check[T: ClassTag](x: Any): Boolean x match { case _: T true case _ false } check[String](abc) // true check[String](42) // falseClassTag[T]会在运行时保存 T 的基本类信息让模式匹配和数组创建都变成可用。但如果 T 本身还是一个泛型比如你写了check[List[String]]ClassTag可能只能匹配到List[_]内部元素类型的信息依然是有限保留。这时需要更重的TypeTagimport scala.reflect.runtime.universe._ def fullTypeName[T: TypeTag](x: T): String typeOf[T].toStringTypeTag携带完整类型树能让你拿到List[String]这样的完整信息代价是必须依赖 Scala 反射库性能开销也更大。我的习惯是通用组件里优先用ClassTag它足够轻只有当你要做完整类型转换或反射时才上TypeTag。4.5 Scala 3 中类型参数化的关键变化Scala 3 是 Scala 新编译器的设计其中 type class 写法、隐式机制、泛型细节都有调整值得对它有个正确预期。最明显的变化是把implicit val、implicit def等拆成了given和using// Scala 2 implicit val stringEncoder: JsonEncoder[String] ... def toJson[T: JsonEncoder](t: T) ... // Scala 3 given stringEncoder: JsonEncoder[String] with def encode(t: String): String \ t \ def toJson[T: JsonEncoder](t: T) ...上下文边界的语法在 Scala 3 里依然是[T: JsonEncoder]但implicitly换成了特定的summon。另外Scala 3 用?替代了存在类型的_写法像List[?]表示未知类型参数。这些变化并不改变类型参数化的核心思路只改变书写形式和解析规则。如果你在维护老 Scala 2 项目就需要了解迁移路径上的这些差异避免把旧代码一股脑搬上去之后编译崩掉。5. 常见问题与避坑实录5.1 常见问题速查表下面这张表是我在实践中最常遇到的问题汇总你可以先收藏遇到报错再来对照。现象可能原因排查思路编译报错Cannot create Array[T]泛型擦除导致运行时无法确定 T 的具体类型给泛型方法加[T: ClassTag]上下文边界编译报错covariant type A occurs in contravariant position在协变类的方法参数里用了泛型参数 A改用下界[B : A]包装参数或去掉A编译报错No implicit found for ...作用域里没有对应的隐式实例检查伴生对象、导入作用域、局部覆盖定义编译报错type mismatch: found x.Y, required y.Y两个实例的路径依赖类型不同确保使用同一个实例或改用泛型参数设计类型测试 / 模式匹配结果总是不对类型擦除和ClassTag只能覆盖一层泛型需要完整类型信息时使用TypeTagScala 3 迁移后隐式解析失败implicit和given/using的优先级规则不同查询迁移文档把隐式定义改为 given调用处改为 using5.2 协变逆变编译错误的真实案例与修复我在做一个通用事件总线时曾经写过这样一段代码trait Event case class UserCreated(name: String) extends Event case class OrderPaid(amount: BigDecimal) extends Event class Bus[E] { def publish(e: E): Unit ??? // 编译错误 }报错信息是covariant type E occurs in contravariant position in type E of value e。这时候我意识到一个协变事件总线如果允许直接发布E就违反了协变安全约束Bus[UserCreated]会被当成Bus[Event]然后有人往里发布OrderPaid全局就乱了。修复方案可以有两种。一是把Bus改成不变去掉E这样类型安全性最高但灵活性下降。二是用下界引入一个新的逆变点兼容参数class Bus[E] { def publish[B : E](e: B): Unit ??? }这种写法的意思是“你可以发布 E 或 E 的父类型”语义也变得合理了总线里的事件可以向上转型发布不会破坏类型安全。我最后采用了下界的方案因为在事件总线的业务语义里“更宽泛的事件也能发布”是合理的。遇到这类编译错误先别急着去掉变型符号想想业务上是否能接受B : E这种语义。5.3 隐式解析歧义为什么编译器说找不到又说有歧义最磨人的不是“没有隐式值”而是“同时有好几个候选编译器不知道该选哪个”。比如你在两个伴生对象里各放了一个EntityOps[User]又在局部作用域里 import 了一个调用GenericRepository[User]时编译器就会报ambiguous implicit values。这时候我记着一个简单原则局部作用域优先于伴生对象中的隐式伴生对象中的隐式优先于导入的隐式实际上 Scala 的隐式搜索范围有一个固定优先级序列局部定义和显式导入的优先级最高然后是伴生对象、包对象等。所以如果你想要“一个默认行为 一个局部特化行为”把默认的放在伴生对象里把特化的放在局部定义或 import 进来让局部作用域的优先命中。如果你同时 import 了两个同名隐式自然就歧义了。另一个实用技巧是给隐式定义起不同的名字比如userOps、richUserOps靠作用域而不是靠重名去区分。有一点我要提醒在 Scala 3 里given 的解析规则和 Scala 2 的隐式是不完全一致的。如果你在做迁移同名的两个 given 在重叠作用域里可能会解析出不同结果代码迁移后很有必要把“隐式解析”相关的单元测试重新跑一遍。5.4 路径依赖类型不匹配一个实例一堵墙我第一次使用抽象类型成员时就被路径依赖坑过。当时我写了一个通用的状态机trait FSM { type State def init: State def next(s: State): State } def runLoop(fsm: FSM): Unit { var s fsm.init while (true) { s fsm.next(s) // 运行得很好 } }看起来没问题s的类型是fsm.Statenext的输入类型也是fsm.State编译器能推导出来。但一旦我把逻辑分成两个方法问题就来了def step(fsm: FSM, s: fsm.State): fsm.State fsm.next(s) def runLoopDisplay(fsm: FSM): Unit { var s fsm.init while (true) { s step(fsm, s) // OK因为 s 的类型确实是 fsm.State } }这其实能编译因为路径依赖类型在单线程流里是一致的。真正翻车的情况是这样的你明明有svc1.State Int和svc2.State Int但由于它们是不同实例的路径依赖类型你没法直接把svc1的状态传给svc2val svc1: FSM new FSM { type State Int; def init 0; def next(i: Int) i 1 } val svc2: FSM new FSM { type State Int; def init 0; def next(i: Int) i 1 } // svc2.next(svc1.init) // 编译错误svc1.State 不是 svc2.State结构上它俩都是Int但路径依赖类型把“哪个实例产生的 Int”也编码进了类型。如果需要在不同实例之间传递值我建议直接把类型提升为泛型参数比如def run[State](fsm: FSM[State], input: State): State。这样State是同一个类型参数不同实例之间就能传值了。记住抽象类型成员适合表达“类型属于某个实例”的强关联语义而“类型只是通用占位符”时用泛型更省心。写在最后这几周重构通用数据访问层的过程让我对 Scala 类型参数化的理解又深了一层。回头看我最早写的那个只带一个类型参数的Box[T]再到现在能组合使用T : Entity、[T : EntityOps]、ClassTag[T]的GenericRepository[T]最大的感受是类型参数化不是让代码“看起来抽象”而是把“底层保证”尽量前置到编译期。很多以前运行时才暴露的雷现在在编辑器里就被标红了。如果你也想上手我的建议是先从一个泛型方法开始试着把两段重复代码收敛成一个带类型参数的公共方法再用上下文边界给它加上行为约束最后在自己的项目里试着设计第一个 type class。我个人一直保留的习惯是任何新的通用类型约束都先用一个小例子验证编译通过再放进大项目里等踩够几次编译错误的坑你对类型系统怎么守卫代码安全会有非常直观的理解。
返回列表