现在是时候直接跳进代码啦,而 Scala 3 无疑是绝佳的切入口!这门语言的新主版本带来了许多激动人心的新特性,本章我们会结合实用的函数式库,一起做深度探索。
我们会先从领域建模开始,包括类型类派生(typeclass derivation)、新类型(newtypes)、精化类型(refinement types)以及“孤儿实例”(orphan instances)等相关内容。
接着,会简要介绍类型类、能力特质(capability traits)和 HTTP 路由的用法。最后,我们还将实践一个利用 Scala 3 全新特性的设计模式。
4.1 领域建模(Domain modeling)
这个话题在 PFPS(Practical FP in Scala)一书里已经有很全面的介绍,尤其是新类型(newtypes)和精化类型(refinement types)的结合。本节主要讲解这些内容在 Scala 3 里的用法——因为有些在 Scala 2 推荐的库在 Scala 3 已经不再受支持了。
4.1.1 类型类派生(Typeclass derivation)
在 Scala 2,我推荐用 Derevo 做类型类派生。但很可惜它依赖于实验性宏,这个特性在 Scala 3 已经被移除了。
不过,好消息是 Shapeless 3(Kittens 基于它实现)正在积极发展,能很好支持类型类派生。
下面是常见类型类的派生示例:
import cats.*
import cats.derived.*
case class Person(
name: String,
age: Int
) derives Eq, Order, Show
4.1.1.1 JSON 编解码器(JSON codecs)
Circe 最新版也实验性支持 Scala 3 的类型类派生:
import io.circe.Codec
case class Address(
streetName: String,
streetNumber: Int,
flat: Option[String]
) derives Codec.AsObject
这会自动生成 Decoder[Address] 和 Encoder[Address],如:
import io.circe.parser.decode
import io.circe.syntax.*
object Demo:
@main def run =
val address = Address("Baker", 221, Some("B"))
val json = address.asJson.spaces2
assert(decode[Address](json) == Right(address))
打印出来的 JSON 是:
{
"streetName" : "Baker",
"streetNumber" : 221,
"flat" : "B"
}
另一个很棒的 JSON 库是 jsoniter-scala。它需要先声明 codec 的生成方式,然后就能利用 Scala 3 的类型类派生机制:
import com.github.plokhotnyuk.jsoniter_scala.core.*
import com.github.plokhotnyuk.jsoniter_scala.macros.*
sealed trait JsonCodec[A] extends JsonValueCodec[A]
object JsonCodec:
inline def derived[A]: JsonCodec[A] = new:
private val impl = JsonCodecMaker.make[A](
CodecMakerConfig.withDiscriminatorFieldName(Some("field"))
)
export impl._
例如:
case class Person(
age: Int,
name: String
) derives JsonCodec
enum Digits derives JsonCodec:
case One
case Two(name: String)
验证:
val person = Person(40, "Joe")
val one = Digits.One
val two = Digits.Two("dos")
def show[A: JsonCodec](a: A): String =
new String(writeToArray(a))
show(person) // {"age":40,"name":"Joe"}
show(one) // {"field":"One"}
show(two) // {"field":"Two","name":"dos"}
我们会在交易系统中用 Circe,当然你也可以用其它库。
4.1.2 新类型(Newtypes)
在 Scala 2 里我推荐 Newtype 库,可以零成本包装底层类型:
object domain {
@newtype case class Age(value: Int)
}
可惜它也 heavily 依赖宏,Scala 3 无法使用。但 Scala 3 原生支持“opaque type”,可以用来实现 newtype。
一种灵感来自 Monix Newtypes 的写法(本书实战项目会用):
abstract class Newtype[A](using
eqv: Eq[A],
ord: Order[A],
shw: Show[A],
enc: Encoder[A],
dec: Decoder[A]
):
opaque type Type = A
inline def apply(a: A): Type = a
protected inline final def derive[F[_]](using ev: F[A]): F[Type] = ev
extension (t: Type) inline def value: A = t
given Wrapper[A, Type] with
def iso: Iso[A, Type] =
Iso[A, Type](apply(_))(_.value)
given Eq[Type] = eqv
given Order[Type] = ord
given Show[Type] = shw
given Encoder[Type] = enc
given Decoder[Type] = dec
given Ordering[Type] = ord.toOrdering
用法如下:
type Name = Name.Type
object Name extends Newtype[String]
type Age = Age.Type
object Age extends Newtype[Int]
虽然没有宏注解写法简洁,但也已经很实用了。
Wrapper typeclass 提供从底层类型和新类型的等价关系(同构):
trait Wrapper[A, B]:
def iso: Iso[A, B]
还可以实现常用的 UUID newtype:
abstract class IdNewtype extends Newtype[UUID]:
given IsUUID[Type] = derive[IsUUID]
trait IsUUID[A]:
def iso: Iso[UUID, A]
object IsUUID:
def apply[A: IsUUID]: IsUUID[A] = summon
given IsUUID[UUID] with
def iso: Iso[UUID, UUID] =
Iso[UUID, UUID](identity)(identity)
数字类型的 newtype 还可以加扩展方法:
abstract class NumNewtype[A](using
eqv: Eq[A],
ord: Order[A],
shw: Show[A],
enc: Encoder[A],
dec: Decoder[A],
num: Numeric[A]
) extends Newtype[A]:
extension (x: Type)
inline def -[T](using inv: T =:= Type)(y: T): Type =
apply(num.minus(x.value, inv.apply(y).value))
inline def +[T](using inv: T =:= Type)(y: T): Type =
apply(num.plus(x.value, inv.apply(y).value))
4.1.2.1 新类型到底值不值?
用这种写法确实比直接 case class 多一行,比如:
type Name = Name.Type
object Name extends NewtypeWrapped[String]
对比
case class Name(value: String)
如果只考虑类型类派生,差别就更明显:
type Surname = Surname.Type
object Surname extends NewtypeWrapped[String]:
given Eq[Surname] = derive
given Order[Surname] = derive
given Show[Surname] = derive
// 而 case class 一行就能搞定
case class Surname(value: String) derives Eq, Order, Show
当然,case class 不是零成本包装,但除非极端内存优化场景,性能影响其实微乎其微。
最重要的是,newtype 能避免滥用基础类型带来的混乱。开发体验最重要,可以根据实际权衡选用。
4.1.3 精化类型(Refinement types)
Scala 2 推荐 Refined 库,不过它只支持运行时校验,未来会补齐编译期校验。
在 Scala 3 下,推荐用 Iron 库,专为 Scala 3 设计。用法如:
def log(x: Double :| Greater[0.0]): Double =
Math.log(x)
log(-1d) // 编译期报错
本书第 6 章的 Symbol 类型也用到了精化类型:
type SymbolR = DescribedAs[
Match["^[a-zA-Z0-9]{6}$"],
"A Symbol should be an alphanumeric of 6 digits"
]
type Symbol = Symbol.Type
object Symbol extends Newtype[String :| SymbolR]
自定义精化类型的方法:
final class Positive
given Constraint[Int, Positive] with
override inline def test(value: Int): Boolean = value > 0
override inline def message: String = "Should be strictly positive"
// 编译报错:Should be strictly positive
val x: Int :| Positive = 0
不过自定义 String 的约束无法做到编译期校验(除非写宏),所以只能运行时校验。详情请参考官方文档。
Iron 还支持 Cats、Circe 等库的自动推导。例:
type AgeR = DescribedAs[
Greater[0] & Less[151],
"Valid alien's age between 1 and 150"
]
type NameR = DescribedAs[
Alphanumeric & MinLength[1] & MaxLength[50],
"Valid alien's name, alphanumeric max 50 letters"
]
case class Alien(
name: String :| NameR,
age: Int :| AgeR
) derives Codec.AsObject, Eq, Show
val alien1 = Alien("Bob", 120)
val alien2 = Alien("Bob", 500) // 编译错误:无效年龄
val alien3 = Alien("", 50) // 编译错误:无效姓名
对于需要运行时校验的场景,可以用 refine* 方法:
object Alien:
def make(
name: String,
age: Int
): EitherNel[String, Alien] =
(
name.refineNel[NameR],
age.refineNel[AgeR]
).parMapN(Alien.apply)
4.1.3.1 copy 方法陷阱
case class 自带的 copy 方法如果不配合精化类型,很容易绕过之前的校验:
case class Pet(name: String)
object Pet:
def make(name: String): Either[String, Pet] =
if (name != "") then Pet(name).asRight
else "Pet name must be non-blank!".asLeft
Pet.make("Czela").map(_.copy(name = ""))
如果用精化类型则不会有此问题:
case class Pet(name: String :| Not[Blank])
若坚持用基础类型,还可以用如下两种写法:
case class Pet1 private (name: String)
object Pet1:
def make(name: String): Either[String, Pet1] =
Pet1(name).asRight
sealed abstract case class Pet2(name: String)
object Pet2:
def make(name: String): Either[String, Pet2] =
Right(new Pet2(name) {})
Pet1、Pet2 都无法自动合成 copy 方法。
def tryMe(pet: Pet2): Pet2 =
pet.copy(name = "By-pass?") // 编译错误
不过这样写有些冗余,精化类型是更好的方案。
4.1.4 Orphan 实例(Orphan instances)
Orphan instance 指的是给第三方类型实现 typeclass 实例(比如 Instant),在 Scala 2 可以写 trait,然后在包对象或 domain 统一混入:
package object domain extends OrphanInstances
trait OrphanInstances {
implicit val instantEq: Eq[Instant] =
Eq.by(_.getEpochSecond)
implicit val instantOrder: Order[Instant] =
Order.by(_.getEpochSecond)
implicit val instantShow: Show[Instant] =
Show.show[Instant](_.toString)
}
Scala 3 可以直接用 export,比如:
object OrphanInstances:
given Eq[Instant] = Eq.by(_.getEpochSecond)
given Order[Instant] = Order.by(_.getEpochSecond)
given Show[Instant] = Show.show[Instant](_.toString)
不过对象里的实例,需要时要 import 进来。Scala 3 取消了 package object,可以在 domain.scala 里顶层定义新类型,然后:
package domain
export OrphanInstances.given
type Timestamp = Timestamp.Type
object Timestamp extends Newtype[Instant]
这样,import domain.given 就能自动带上所有 Orphan 实例,非常优雅。
另外,如果你用 Ciris 配置库,所有相关实例都集中在一个文件里,这样还能保证 Scala.js 兼容性。具体细节见项目代码。
4.2 类型类(Typeclasses)
说到类型类,Scala 各版本之间其实差别不大。Scala 3 主要是语法更简洁。
下面是一个 Time effect 的例子(在 PFPS 也见过),我们后续交易系统也会用到:
trait Time[F[_]]:
def timestamp: F[Timestamp]
object Time:
def apply[F[_]](using ev: Time[F]): Time[F] = ev
given forSync[F[_]](using F: Sync[F]): Time[F] with
def timestamp: F[Timestamp] =
F.delay(Instant.now()).map(t => Timestamp(t))
相比 Scala 2,Scala 3 语法更精炼,using 和 given 替代了原来的 implicit,定义本质不变。
Scala 3 还支持上下文约束(context bound)写法,可以重写为:
object Time:
def apply[F[_]: Time]: Time[F] = summon
given forSync[F[_]: Sync]: Time[F] with
def timestamp: F[Timestamp] =
Sync[F].delay(Instant.now()).map(t => Timestamp(t))
apply 方法(俗称 summoner)现在可以直接用 summon,完全取代了老的 implicitly。
而且 given 实例可以不用命名,匿名写法更简洁:
given [F[_]: Sync]: Time[F] with
def timestamp: F[Timestamp] =
Sync[F].delay(Instant.now()).map(t => Timestamp(t))
我个人更喜欢这种写法,所以本书后续都会采用这种风格。
4.3 HTTP 路由
在 Scala 2 里,我习惯把 HTTP 路由定义为 final case class,主要是为了不用专门写一个 new MyRoutes 语法来实例化。例如:
final case class HealthRoutes[F[_]: Monad]() extends Http4sDsl[F] {
val routes: HttpRoutes[F] = HttpRoutes.of {
case GET -> Root / "health" => Ok()
}
}
val rt: HttpRoutes[F] = HealthRoutes[F]().routes
好消息是,Scala 3 引入了“通用 apply 方法”(universal apply),我们可以把路由直接定义成普通类,写法如下:
final class HealthRoutes[F[_]: Monad] extends Http4sDsl[F]:
val routes: HttpRoutes[F] = HttpRoutes.of {
case GET -> Root / "health" => Ok()
}
调用时可以直接这样实例化:
val rt: HttpRoutes[F] = HealthRoutes[F].routes
太棒了!Scala 3 让代码更简洁、易读。不管是类定义还是实例化,都不需要再写空括号了。
4.4 带副作用的上下文(Effectful context)
在中大型应用中,跨不同组件传递一堆依赖是家常便饭,依赖注入的 wiring(组装)往往很麻烦。
我一直推荐、也是自己常用的做法,是把依赖分模块管理(traits 或 class 都可以)。
还有一种常见依赖不是 typeclass,但我们依然按“能力特质”(capability trait)来传递,因为它的实例化通常涉及副作用操作。
比如 Supervisor,可以作为隐式依赖传递,这样只要少数组件用到时引用就行,无需手动穿透整个应用:
Supervisor[IO].use { implicit sp =>
restOfTheProgram
}
如果 Supervisor 是应用中唯一需要共享的副作用依赖,那直接像 typeclass 一样穿透传递就够了:
def foo[F[_]: Supervisor]: F[Unit] = ???
def bar[F[_]](using sp: Supervisor[F]): F[Unit] = ???
但应用规模一大,副作用依赖通常不止一个,还可能有其它信息需要全局可用。这时还是推荐用模块聚合。
不过,Scala 3 新增了一个有趣特性,可以简化这种场景——上下文函数(context functions) 。
比如,我们想让应用具备上下文感知能力,先定义上下文类型:
final class Log(ref: Ref[IO, List[String]]):
def add(str: => String): IO[Unit] = ref.update(_ :+ str)
def get: IO[List[String]] = ref.get
final case class Ctx(
id: UUID,
sp: Supervisor[IO],
log: Log
)
我们有一个唯一的应用 ID、Supervisor 和 Log。为简化,全部定死 IO,你当然可以抽象成 F[_]。
传统写法,是把 context 实例作为隐式依赖传递到所有需要的地方:
def p1(using ctx: Ctx): IO[Unit] =
IO.println("Running program 1") *> p2
def p2(using ctx: Ctx): IO[Unit] =
IO.println("Running program #2") *>
ctx.sp
.supervise {
ctx.log.add(s"Start: ${ctx.id}") >>
IO.sleep(1.second) >>
ctx.log.add(s"Done: ${ctx.id}")
}
.flatMap { fb =>
ctx.log.add(s"Waiting: ${ctx.id}") >>
fb.join.void
}
def p3(using ctx: Ctx): IO[Unit] =
IO.sleep(100.millis) *> IO.println("Running program 3") *> p4
def p4(using ctx: Ctx): IO[Unit] =
IO.println(s"Running program 4: ${ctx.id.show}")
val mkCtx = for
id <- Resource.eval(IO(UUID.randomUUID()))
sp <- Supervisor[IO]
lg <- Resource.eval(Ref.of[IO, List[String]](List.empty))
yield Ctx(id, sp, Log(lg))
val run: IO[Unit] =
mkCtx.use { implicit ctx =>
(p1 &> p3) *>
ctx.log.get.flatMap(_.traverse_(IO.println))
}
也可以用 Scala 3 的 pattern-bound given 写法:
mkCtx.use { case given Ctx => ??? }
运行后类似这样输出:
Running program #1
Running program #2
Running program #3
Running program #4: 447c6c84-6d7b-4acd-8574-95145878c820
Start: 93dd53d1-ed2f-4d54-96b9-f446a0e503ff
Waiting: 93dd53d1-ed2f-4d54-96b9-f446a0e503ff
Done: 93dd53d1-ed2f-4d54-96b9-f446a0e503ff
这种写法没毛病,也兼容 Scala 2。但可以看到 p1 和 p3 虽然用不到 context,也要 everywhere 写 using ctx: Ctx。如果不是必须隐式 context,这些方法其实可以直接写成 val。
Scala 3 的**上下文函数(context function)**提供了另一种思路。你可以用箭头 ?=> 把程序声明为“需要上下文”但本身不显示依赖的函数:
val p1: Ctx ?=> IO[Unit] =
IO.println("Running program 1") *> p2
p1 可以写成 val 而不是 def。依此模式,整个程序可以这样重写——先建个上下文初始化辅助方法:
def withCtx(f: Ctx ?=> IO[Unit]): IO[Unit] =
mkCtx.use { ctx =>
f(using ctx) *> ctx.log.get.flatMap(_.traverse_(IO.println))
}
这样可以把主逻辑和前后处理解耦,比如结尾打印日志。
接下来是声明剩下的程序片段:
val p1: Ctx ?=> IO[Unit] =
IO.println("Running program #1") *> p2
def p2(using ctx: Ctx): IO[Unit] =
IO.println("Running program #2") *> ???
val p3: Ctx ?=> IO[Unit] =
IO.sleep(100.millis) *> IO.println("Running program #3") *> p4
def p4(using ctx: Ctx): IO[Unit] =
IO.println(s"Running program 4: ${ctx.id.show}")
val run: IO[Unit] =
withCtx {
p1 &> p3
}
p2、p4 也可以写成 val,只要用 summon[Ctx] 取出 context:
val p2: Ctx ?=> IO[Unit] =
val ctx = summon[Ctx]
restOfTheProgram
也可以在 Ctx 伴生对象加个 summoner 辅助方法。
这种写法非常适合做 DSL,比如官方文档里的多层嵌套:
table {
row {
cell("top left")
cell("top right")
}
row {
cell("bottom left")
cell("bottom right")
}
}
当然,用于依赖注入场景可能有点“杀鸡用牛刀”,但这种特性值得了解和尝试!
4.5 依赖类型(Dependent types)
我们要介绍的最后一个新特性是 Match Types(模式类型):
type Elem[X] = X match
case String => Char
case Array[t] => t
case Iterable[t] => t
Match Types 让 Scala 拥有了依赖类型(dependent typing),在第 5 章实现有限状态机(FSM)声明依赖时,我们就会用到它。
先看个例子。假设我们有一个 graph 类型,里面定义了状态 St、依赖 Dep、输入 In 和输出 Out:
abstract class Graph[F[_], St, Dep, In, Out]:
val dep: Dep
还定义了几种具体的状态类型:
type CatState = Map[String, String]
type DogState = Set[Int]
type FoxState = Array[Long]
然后可以针对每种动物建一个 graph:
val cat = new Graph[IO, CatState, List[String], String, Unit]:
val dep = List.empty
val dog = new Graph[IO, DogState, Vector[Int], Int, Unit]:
val dep = Vector.empty
val fox = new Graph[IO, FoxState, Set[Long], Long, Unit]:
val dep = Set.empty
从这些例子可以看出,几种类型之间其实有隐式的“约定”:比如输入类型是 String 时,状态类型就是 CatState,依赖类型是 List[String]。不过,现在还可以随意“乱搭”:
val wrongCat = new Graph[IO, FoxState, Boolean, String, Unit]:
val dep = List.empty
这种写法不能约束类型之间的强关联。为更清晰地表达意图,我们可以用 match types 强化这种类型对应关系——假设每个输入类型只对应一种状态和依赖类型:
type GraphSt[In] = In match
case String => CatState
case Int => DogState
case Long => FoxState
type GraphDep[In] = In match
case String => List[String]
case Int => Vector[Int]
case Long => Set[Long]
然后声明一个综合类型,并在 Graph 伴生对象里加智能构造器:
type Graf[In] = Graph[IO, GraphSt[In], GraphDep[In], In, Unit]
object Graph:
def make[In](_dep: GraphDep[In]): Graf[In] = new:
val dep = _dep
建议把抽象类的构造器设为 private。这样以后实例化时类型安全且直观:
val _cat = Graph.make[String](List.empty)
val _dog = Graph.make[Int](Vector.empty)
val _fox = Graph.make[Long](Set.empty)
如果实例类型不符,则直接编译报错:
// TypeError: Match type reduction failed...
val no1 = Graph.make[Long](List.empty)
val no2 = Graph.make[Int]("wrong")
这样还能享受类型推断,写起来非常丝滑。
Match types 本质上帮助我们实践 FP 世界那句名言:“让非法状态不可表达”(Make illegal state unrepresentable)。本书结尾章节会看到其在 tracing 状态机里的实际应用。
4.6 小结
本章我们深入探讨了领域建模,从通过 Kittens 库实现类型类派生,到用自定义编码方式实现 newtype。此外,还介绍了精化类型(refinement types)和孤儿实例(orphan instances)的用法。
我们还了解了类型类和 HTTP 路由在编码上的细微变化,最后通过带副作用的上下文(effectful context)和依赖类型(dependent types),发现了 Scala 3 的一批新特性。
需要注意的是,虽然 Scala 3 这个主版本非常令人兴奋,但它仍在不断演进中,所以本章涉及的许多特性在后续版本中可能还会发生变化。