1. 序章

このチュートリアルでは、最もよく知られている光学系のいくつかを使用して、Scalaのネストされたケースクラスに簡潔かつエレガントな方法でアクセスして変更する方法を探ります。 よく知られているScala光学ライブラリであるMonocleを使用します。

2. 光学とは何ですか?なぜそれらが必要なのですか?

モノクル定義から引用するには:

光学は、不変オブジェクトを操作(取得、設定、変更など)するための純粋に機能的な抽象化のグループです。

Scalaでネストされたケースクラスを変更すると、非常に冗長になる可能性があり、多くの定型コードが必要になるため、コードを理解するのが難しくなります。次のセクションで、使用時にコードがどの程度冗長になるかを示す例を示します。純粋なScala。

光学は、複雑なデータ構造を簡潔に操作する方法を提供することにより、この問題を解決します。

build.sbtファイルにMonocleの依存関係を追加することから始めましょう。

val monocleVersion = "2.0.4"

libraryDependencies ++= Seq(
  "com.github.julien-truffaut" %%  "monocle-core"  % monocleVersion,
  "com.github.julien-truffaut" %%  "monocle-macro" % monocleVersion,
  "com.github.julien-truffaut" %%  "monocle-law"   % monocleVersion % "test"
)

ライブラリはScala2.12と2.13用にクロスビルドされ、 MavenCentralに公開されます。

3. 利用可能な光学系

Monocleは、さまざまな目的に使用できるいくつかの光学系を提供します。 この記事では、主なもののそれぞれに可能なアプリケーションを示します。

単純なドメインモデルを使用して、光学系がどのように機能するかを示します。 私たちのモデルはネストされた構造であり、ユーザーとカートの間の過度に単純化された関係を記述し、単一のタイプの 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から始めます。

たとえば、ユーザーが商品を購入したときに、在庫のあるアイテムの数を更新するとします。

バニラスカラでは、ネストされた構造の各レベルに非常に冗長な方法でアクセスして変更する必要があります。

def updateStockWithoutLenses(user: User): User = {
  user.copy(
    cart = user.cart.copy(
      item = user.cart.item.copy(leftInStock = user.cart.item.leftInStock - 1)
    )
  )
}

Lensがこのケースでどのように役立つか見てみましょう。 レンズは2つの機能を提供します:

get(s: S): A
set(a: A): S => S

上記のメソッド定義では、 SはいわゆるProduct(この例では User ケースクラス)です。 A は、 S アイテム内の要素です。

前の例を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メソッドを使用すると、その名前が示すように、さまざまなレンズを一緒に構成し、データ構造のネストされた各レベルにズームインできます。

3.2. オプション

同様にレンズ オプションで、データ構造にズームインできます。 ただし、焦点を当てている要素は存在しない可能性があります。

オプションを特徴付ける2つの方法:

getOption: S => Option[A]
set: A => S => S

前の光学部品と同様に、 Sは製品であり、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)
}

オプション光学部品の使用法を示すいくつかのテストを見てみましょう。

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は、データモデルの一部のみを選択できる光学系です。次の2つの方法があります。

getOption: S => Option[A]
reverseGet: A => S

この場合、 Sは合計(この場合は割引のようなADT)であり、Aは合計の一部です。 合計のどの部分とも一致しない可能性があるゲッターのオプションに注意してください。

割引率のみを更新する関数を書いてみましょう。

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 は別のタイプの光学部品であり、同じデータを異なる方法で表現しようとする場合に便利です

価格をユーロと英ポンドで表したいとしましょう。

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から入手できます。