1. 概述
无论我们偏好面向对象编程还是函数式编程,每种编程范式都有其管理大型程序中模块依赖关系的模式。近年来,函数式程序员更倾向于使用 Tagless Final 模式。
本文将带你了解如何在 Scala 中实现这一模式。
2. 依赖
在本文中,我们会引用一些来自 Cats 和 Cats Effect 库的函数式类型。要使用这些库,我们需要在 SBT 中添加以下依赖:
libraryDependencies += "org.typelevel" %% "cats-effect_2.12" % "2.1.4"
3. 面临的挑战
作为有经验的函数式开发者,我们知道学习函数式编程的基础概念(如引用透明性、λ 演算、函子和单子)只是第一步。
当我们第一次面对一个非平凡的程序时,我们需要理解如何管理模块之间的依赖关系,避免陷入函数式的大泥球(big ball of mud)。
比如,我们要开发一个电商网站的购物车系统。首先,我们需要定义领域模型:
case class Product(id: String, description: String)
case class ShoppingCart(id: String, products: List[Product])
但这还不够,我们还需要一些操作这些模型的函数:
def create(id: String): Unit
def find(id: String): Option[ShoppingCart]
def add(sc: ShoppingCart, product: Product): ShoppingCart
这些函数的第一个问题是:它们显然会产生副作用。比如,create
可能会将购物车信息写入数据库,add
也类似。而 find
则可能从数据库中读取购物车,操作还可能失败。
幸运的是,许多 Scala 库允许我们将产生副作用的操作描述封装在一些声明式上下文中,这些上下文被称为 effects(效应)。通过效应类型,我们可以在函数签名中描述其返回值和副作用,从而将副作用的描述与执行分离。例如 Cats Effect、Monix 或 ZIO 都提供了这样的能力。
以 Cats 中的通用 IO[T]
效应为例,我们的函数可以改写为:
def create(id: String): IO[Unit]
def find(id: String): IO[Option[ShoppingCart]]
def add(sc: ShoppingCart, product: Product): IO[ShoppingCart]
✅ 使用效应库的最大好处是,我们可以继续将程序视为纯函数进行推理。
但随之而来的问题是:在测试阶段,我们必须处理这些效应,这并不总是那么简单。
另一个问题是:这些函数的实现应该放在哪里?是使用一个具体的类,还是抽象 trait 并分离实现?
接下来,我们将通过 Tagless Final 模式来解决这些问题。
4. Tagless Final 模式
在 Scala 中,多年来出现了多种解决上述问题的模式。其中 Free 模式是早期的一种,但近年来越来越多开发者选择 Tagless Final 模式。
尽管有人批评这一模式,或至少反对在所有场景中盲目使用,但 Tagless Final 模式仍为上述问题提供了一个优雅的解决方案。
4.1. Algebras(代数)
首先,什么是 Tagless Final 编码模式?这个模式源自 Haskell 社区,它允许我们将一个 DSL 嵌入到宿主语言中。虽然在 Scala 中语义有所演变,但其核心目标是尽可能使用接口(即 trait)。在模式术语中,我们将这些接口称为 algebras(代数)。
代数代表了我们要建模的领域特定语言(DSL)。它们通过类型和函数签名,同时表达了 DSL 的语法和语义。
✅ 代数应该是纯抽象的。在我们的例子中,我们引入了 IO
效应来建模副作用,因此不能在代数中直接引用 IO
类型,因为它是一个具体实现。
为了解决这个问题,我们可以使用 高阶类型(higher-kinded types),再次使函数定义保持抽象:
trait ShoppingCarts[F[_]] {
def create(id: String): F[Unit]
def find(id: String): F[Option[ShoppingCart]]
def add(sc: ShoppingCart, product: Product): F[ShoppingCart]
}
这里,F[_]
是一个类型构造器,代表任何具有单个类型参数的泛型类型。例如它可以是 Either[String, T]
,也可以是 IO[T]
等。
✅ 最佳实践是:在定义代数时,不对 F[_]
添加任何约束。这样我们可以在实现代数时保持最大的灵活性。
所以,ShoppingCarts
代数的 DSL 定义了对 ShoppingCart
领域模型的三种操作:
- 根据 ID 创建一个新的购物车
- 根据 ID 查找购物车
- 向购物车中添加商品
在实际项目中,代数的命名方式有很多,比如:
ShoppingCartService
ShoppingCartAlgebra
ShoppingCartAlg
我们这里选择使用领域模型的复数形式:ShoppingCarts
。
4.2. Interpreters(解释器)
现在,我们需要实现具体行为。如果代数是纯抽象的,那么我们需要它们的实现,这些实现被称为 解释器。
✅ 解释器定义了代数的行为方式,包括函数的输入输出。
解释器处理函数的具体实现,我们可以选择绑定具体的效应类型(如 IO
),也可以继续保持抽象。此外,它们还封装了实现功能所需的任何状态或依赖。
通常,每个代数至少有两个解释器:生产环境解释器和测试解释器。
下面是一个针对 ShoppingCarts
代数的测试解释器示例。为简化起见,我们使用 Map[String, ShoppingCart]
来模拟持久层,并使用 State
单子来以函数式风格处理状态:
type ShoppingCartRepository = Map[String, ShoppingCart]
type ScRepoState[A] = State[ShoppingCartRepository, A]
ScRepoState[A]
表示从 ShoppingCartRepository
到另一个状态的转换,并可能产生类型为 A
的值。等价于函数:ShoppingCartRepository -> (ShoppingCartRepository, A)
。
然后,我们将 ScRepoState[A]
作为代数中抽象类型 F[_]
的具体绑定:
implicit object TestShoppingCartInterpreter extends ShoppingCarts[ScRepoState] {
override def create(id: String): ScRepoState[Unit] =
State.modify { carts =>
val shoppingCart = ShoppingCart(id, List())
carts + (id -> shoppingCart)
}
override def find(id: String): ScRepoState[Option[ShoppingCart]] =
State.inspect { carts =>
carts.get(id)
}
override def add(sc: ShoppingCart, product: Product): ScRepoState[ShoppingCart] =
State { carts =>
val products = sc.products
val updatedCart = sc.copy(products = product :: products)
(carts + (sc.id -> updatedCart), updatedCart)
}
}
通过代数与解释器的对偶性,我们解决了两个问题:
✅ 抽象了副作用,使业务逻辑更易于测试
✅ 明确了如何组织实现代码
但代数的使用者(即客户端)仍然需要一种方式来获取解释器的具体实例。虽然原始模式中没有特别提到客户端,但它们在实际应用中扮演了重要角色,尤其决定了我们如何让解释器变得可用。
5. Programs(程序)
✅ 我们将代数的使用者称为 Programs(程序)。虽然“程序”这个术语并不是模式的一部分,但在开发者中广泛使用。
✅ Program 是使用代数和解释器来实现业务逻辑的一段代码。
由于 Program 可能组合多个代数的函数,它可能需要对代数的 F[_]
类型构造器添加一些约束。
比如,我们想开发一个函数,它先创建购物车,然后立即添加商品。这两个操作都定义在 ShoppingCarts
代数中,且必须顺序执行。
我们知道,函数式中用于顺序执行效应的结构是 Monad(单子)。因此,我们需要在 Program 的上下文中提供 Monad[F]
的实例。Scala 提供了专门的语法来处理这种情况,即 类型约束(type constraints):
def createAndAddToCart[F[_] : Monad] = ???
⚠️ 在对类型构造器添加约束时,我们必须始终遵循最小权力原则。作为函数式开发者,我们应当仅通过签名就能理解函数的能力。如果我们请求了过于宽松的约束,就会失去这种重要的理解工具。
比如,如果我们不使用 Monad
,而是直接约束为 IO[F]
:
def createAndToCart[F[_] : IO] = ???
这个函数能做什么?IO
单子可以表示任何副作用操作,从抛出异常到发射核弹。
5.1. 隐式对象解析
现在,我们需要获取解释器的具体实例。有两种主要方式:隐式对象解析 和 智能构造器。
第一种方式中,解释器是一个 implicit object
(如上面的例子),Program 中声明一个 implicit
参数来引用代数:
def createAndAddToCart[F[_] : Monad](product: Product, cartId: String)
(implicit shoppingCarts: ShoppingCarts[F]): F[Option[ShoppingCart]] =
for {
_ <- shoppingCarts.create(cartId)
maybeSc <- shoppingCarts.find(cartId)
maybeNewSc <- maybeSc.traverse(sc => shoppingCarts.add(sc, product))
} yield maybeNewSc
通过隐式解析机制,Scala 编译器会在上下文中查找可用的 ShoppingCarts
解释器实例。
⚠️ 尽管这看起来像是类型类模式,但本质不同。类型类用于为抽象效应添加能力,而解释器不是。
我们可能会尝试像使用类型类一样使用代数,比如将 ShoppingCarts
作为 F[_]
的类型约束:
def createAndToCart[F[_] : Monad : ShoppingCarts](product: Product, cartId: String): Unit = ???
Scala 编译器会将其翻译为带有隐式参数的函数签名,但我们没有具体的参数名来调用代数函数。怎么解决?
一种方式是使用 Summoned Value 模式,在代数的伴生对象中重写 apply
方法:
object ShoppingCarts {
def apply[F[_]](implicit sc: ShoppingCarts[F]): ShoppingCarts[F] = sc
}
这样,我们就可以通过 ShoppingCarts[F]
直接调用代数函数:
def createAndToCart[F[_] : Monad : ShoppingCarts](product: Product, cartId: String): F[Option[ShoppingCart]] =
for {
_ <- ShoppingCarts[F].create(cartId)
maybeSc <- ShoppingCarts[F].find(cartId)
maybeNewSc <- maybeSc.traverse(sc => ShoppingCarts[F].add(sc, product))
} yield maybeNewSc
⚠️ 但正如之前所说,代数和解释器并不是类型类模式,它们的作用域不同。因此,很多开发者认为将代数作为效应类型约束是一种不良实践。
✅ 经验法则:只有当模块是类型类或基础设施(如日志)时才使用隐式解析,它们不应包含任何业务逻辑。
5.2. 智能构造器
另一种方式是显式传递代数实例。这次我们不使用隐式解析,而是使用 智能构造器(Smart Constructor) 模式。
首先,我们需要将解释器从 object
改为 class
,因为我们要控制实例化过程。将构造函数设为私有,并可在构造函数中传入依赖:
class ShoppingCartsInterpreter private(repo: ShoppingCartRepository)
extends ShoppingCarts[ScRepoState] {
// 函数实现
}
然后,使用工厂方法来实例化解释器。这样我们可以在实例化时进行输入验证。将工厂方法放在伴生对象中,命名为 make
:
object ShoppingCartsInterpreter {
def make(): ShoppingCartsInterpreter = {
new ShoppingCartsInterpreter(repository)
}
private val repository: ShoppingCartRepository = Map()
}
如果需要外部依赖,可以将依赖作为 make
的参数传入。
对于 Program,为了避免隐式解析,我们可以显式传递解释器。比如使用一个 class
来建模 Program 模块:
case class ProgramWithDep[F[_] : Monad](carts: ShoppingCarts[F]) {
def createAndToCart(product: Product, cartId: String): F[Option[ShoppingCart]] = {
for {
_ <- carts.create(cartId)
maybeSc <- carts.find(cartId)
maybeNewSc <- maybeSc.traverse(sc => carts.add(sc, product))
} yield maybeNewSc
}
}
客户端代码通过智能构造器提供解释器:
val program: ProgramWithDep[ScRepoState] = ProgramWithDep {
ShoppingCartWithDependencyInterpreter.make()
}
program.createAndToCart(Product("id", "a product"), "cart1")
6. 总结
在这篇文章中,我们介绍了 Scala 中的 Tagless Final 模式。该模式通过 代数(algebras)、解释器(interpreters) 和 程序(programs) 来组织模块间的职责。
✅ 这样做的好处是:
- 代码结构清晰
- 易于演进、复用和测试
一如既往,本文中的代码可以在 GitHub 上找到。