1. 引言
在这篇教程中,我们将探索如何使用一些最知名的光学(Optics)工具,以简洁优雅的方式访问和修改 Scala 中的嵌套 case class。我们将使用 Monocle,一个广为人知的 Scala 光学库。
2. 什么是光学?为什么我们需要它?
引用 Monocle 官方定义:
光学是一组纯函数式抽象,用于操作(获取、设置、修改等)不可变对象。
在 Scala 中修改嵌套的 case class 可能会非常啰嗦,需要大量样板代码,导致代码难以阅读和维护。我们将在下一部分通过一个例子展示纯 Scala 实现的冗余程度。
光学通过提供操作复杂数据结构的简洁方式,解决了这一问题。
我们先从添加 Monocle 依赖开始,将其加入你的 build.sbt 文件中:
val monocleVersion = "2.1.0"
libraryDependencies ++= Seq(
"com.github.julien-truffaut" %% "monocle-core" % monocleVersion,
"com.github.julien-truffaut" %% "monocle-macro" % monocleVersion,
"com.github.julien-truffaut" %% "monocle-law" % monocleVersion % "test"
)
该库支持 Scala 2.12 和 2.13,并已发布到 Maven Central。
3. 支持的光学类型
Monocle 提供了多种光学工具,适用于不同的场景。本文将介绍其中几种主要光学的典型用法。
我们使用一个简单的领域模型来演示光学的使用方式。*我们的模型是一个嵌套结构,描述了一个 User 及其 Cart 的关系,其中 Cart 包含一种类型的 Item:*
case class User(name: String, cart: Cart)
case class Cart(id: String, item: Item, quantity: Int)
case class Item(sku: String, price: Double, leftInStock: Int, discount: Discount)
然后我们定义了一个 ADT(代数数据类型),用于描述可能的折扣类型:
trait Discount
case class NoDiscount() extends Discount
case class PercentageOff(value: Double) extends Discount
case class FixPriceOff(value: Double) extends Discount
3.1. Lens
我们从最著名的光学工具 Lens 开始,它提供了一种聚焦数据结构的方式。
举个例子,当我们需要在用户购买商品时更新库存数量:
在原生 Scala 中,我们必须逐层访问并复制每一层结构,非常冗长:
def updateStockWithoutLenses(user: User): User = {
user.copy(
cart = user.cart.copy(
item = user.cart.item.copy(leftInStock = user.cart.item.leftInStock - 1)
)
)
}
现在看看 Lens 是如何简化这个过程的。Lens 提供了一对函数:
get(s: S): A
set(a: A): S => S
在上述定义中,S 是所谓的 Product(如我们的 User case class),A 是 S 中的某个元素(如 Item 的字段)。
使用 Lens 重写上述代码如下:
def updateStockWithLenses(user: User): User = {
val cart: Lens[User, Cart] = GenLens[User](_.cart)
val item: Lens[Cart, Item] = GenLens[Cart](_.item)
val leftInStock: Lens[Item, Int] = GenLens[Item](_.leftInStock)
(cart composeLens item composeLens leftInStock).modify(_ - 1)(user)
}
是不是简洁多了?✅ composeLens
方法顾名思义,允许我们将多个 Lens 组合起来,逐层深入数据结构。
3.2. Optional
与 Lens 类似,Optional 也用于聚焦数据结构,但它所聚焦的元素可能并不存在。
Optional 的两个核心方法如下:
getOption: S => Option[A]
set: A => S => S
同样地,S 是 Product,而 A 是 S 中的某个元素。
下面是一个获取折扣值的例子,仅当折扣存在时返回值:
def getDiscountValue(discount: Discount): Option[Double] = {
val maybeDiscountValue = Optional[Discount, Double] {
case pctOff: PercentageOff => Some(pctOff.value)
case fixOff: FixPriceOff => Some(fixOff.value)
case _ => None
} { discountValue => discount =>
discount match {
case pctOff: PercentageOff => pctOff.copy(value = discountValue)
case fixOff: FixPriceOff => fixOff.copy(value = discountValue)
case _ => discount
}
}
maybeDiscountValue.getOption(discount)
}
我们可以通过以下测试来验证 Optional 的行为:
it should "return the Fix Off discount value" in {
val value = 3L
assert(getDiscountValue(FixPriceOff(value)) == Some(value))
}
it should "return no discount value" in {
assert(getDiscountValue(NoDiscount()) == None)
}
3.3. Prism
Prism 是一种光学工具,用于从数据模型中选择特定的部分。它提供两个方法:
getOption: S => Option[A]
reverseGet: A => S
在这种情况下,S 是 Sum(如我们的 ADT Discount),而 A 是 Sum 的一部分。注意 getOption
的返回值是 Option
,因为可能无法匹配到任何部分。
接下来我们实现一个只更新百分比折扣的方法:
def updateDiscountedItemsPrice(cart: Cart, newDiscount: Double): Cart = {
val discountLens: Lens[Item, Discount] = GenLens[Item](_.discount)
val onlyPctDiscount = Prism.partial[Discount, Double] {
case PercentageOff(p) => p
}(PercentageOff)
val newItem =
(discountLens composePrism onlyPctDiscount set newDiscount)(cart.item)
cart.copy(item = newItem)
}
与 Lens 类似,composePrism 方法也支持函数式组合。
我们的 Prism(onlyPctDiscount
)只会更新 PercentageOff 类型的折扣:
it should "update discount percentage values" in {
val originalDiscount = 10L
val newDiscount = 5L
val updatedCart = updateDiscountedItemsPrice(
Cart("abc", Item("item123", 23L, 1, PercentageOff(originalDiscount)), 1),
newDiscount
)
assert(updatedCart.item.discount == PercentageOff(newDiscount))
}
it should "not update discount fix price values" in {
val originalDiscount = 10L
val newDiscount = 5L
val updatedCart = updateDiscountedItemsPrice(
Cart("abc", Item("item123", 23L, 1, FixPriceOff(originalDiscount)), 1),
newDiscount
)
assert(updatedCart.item.discount == FixPriceOff(originalDiscount))
}
3.4. Iso
Iso 是另一种光学工具,适用于需要以不同方式表示相同数据的场景。
假设我们希望在欧元(EUR)和英镑(GBP)之间进行价格转换:
case class PriceEUR(value: Double)
case class PriceGBP(value: Double)
我们可以编写如下的 Iso 来实现货币转换:
val tranformCurrency = Iso[PriceEUR, PriceGBP] { eur =>
PriceGBP(eur.value * 0.9)
}{ gbp =>
PriceEUR(gbp.value / 0.9)
}
然后用于转换:
it should "transform GBP to EUR correctly" in {
val x = tranformCurrency.modify(gbp => gbp.copy(gbp.value + 90L))(PriceEUR(1000L))
assert(x.value == 1100L)
}
4. 总结
当我们需要遍历和修改嵌套的数据结构,或者需要以不同方式表示相同数据时,光学工具可以帮助我们写出更简洁的代码。
将遍历和转换数据结构的样板代码委托给光学库,可以让我们专注于代码中更重要的逻辑部分。✅
一如既往,本文的代码示例可以在 GitHub 上找到。