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),AS 中的某个元素(如 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

同样地,SProduct,而 AS 中的某个元素

下面是一个获取折扣值的例子,仅当折扣存在时返回值:

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

在这种情况下,SSum(如我们的 ADT Discount),而 ASum 的一部分。注意 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 方法也支持函数式组合

我们的 PrismonlyPctDiscount)只会更新 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 上找到。


原始标题:Unsigned Integers in Kotlin