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 上找到。


原始标题:The Tagless Final Pattern in Scala