1. 概要
オブジェクト指向プログラミングと関数型プログラミングのどちらを使用する場合でも、すべてのパラダイムには、大きなプログラムを管理するときにモジュール間の依存関係を管理するための独自のパターンがあります。 近年、機能プログラマーはタグレスファイナルパターンを好みます。
Scalaでそれを実装する方法を見てみましょう。
2. 依存関係
記事の中で、CatsおよびCatsEffectライブラリのいくつかの機能タイプを浅く参照します。 これらのライブラリを使用するには、SBTに依存関係をインポートする必要があります。
libraryDependencies += "org.typelevel" %% "cats-effect_2.12" % "2.1.4"
3. 課題
経験豊富な機能開発者として、参照透過性、ラムダ計算、ファンクター、モナドなどの機能プログラミングの基本概念を学ぶことは、最初のステップにすぎないことを私たちは知っています。
初めて重要なプログラムに取り組む必要があるときは、機能的な大きな泥だんごを避けるためにモジュール間の制約を管理する方法を理解する必要があります。
たとえば、eコマースサイトのショッピングカートを扱うプログラムを作成しましょう。 まず最初に、ドメインモデルの表現が必要です。
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ライブラリは開発者を許可しますいくつかの宣言型コンテキスト内で副作用を生成する操作の説明をカプセル化します。 このようなコンテキストはエフェクトと呼ばれます。 関数のタイプを使用して、関数が生成する値と、効果パターンを通じて副作用を記述することができます。 したがって、副作用の説明はその実行から分離されます。 Cats Effects、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]
エフェクトライブラリを使用することの最良の特性は、プログラムを純粋関数として推論し続けることです。 ただし、テスト段階で影響に対処する必要がありますが、これは必ずしも明らかではありません。
ショッピングカートの関数に関する2番目の問題は、それらを実装するコードをどこに配置するかがわからないことです。 単一のコンクリートクラスを使用する必要がありますか? または、抽象特性と個別の実装を使用する必要がありますか?
タグレスファイナルパターンを入力して、上記の問題の解決策を示してみましょう。
4. タグレスの最終パターン
Scalaでは、上記の問題に答えるために、長年にわたって多くのパターンが適用されてきました。 フリーパターンはその1つです。 ただし、ここ数年、多くの開発者が別のパターン、タグレスファイナルパターンを選択しています。
一部の開発者はパターンを批判、または少なくともすべての場合にそのブラインドアプリケーションを批判しますが、タグなしの最終パターンは上記の問題に対するエレガントな解決策を提供します。
まず最初に、タグなしの最終的なエンコーディングパターンは何ですか? Haskellコミュニティで生まれたこのパターンにより、DSLをホスト言語に組み込むことができます。 セマンティックはScalaのオリジナルとは異なりますが、 パターンの主な目的は、可能な限りインターフェースを使用することです。 パターンの専門用語では、このようなインターフェースを代数と呼びます。
4.1. 代数
代数は、モデル化するドメインに固有のDSLを表します。 それらは、それらの関数のタイプとシグニチャーを通じて、DSLの構文とセマンティクスの両方を表します。
代数は純粋に抽象的でなければなりません。 この例では、 IO 効果を導入して、関数によって実行される副作用をモデル化しました。 したがって、代数の IO タイプは、効果の具体的な実装であるため、直接参照することはできません。
この問題を克服するために、より種類の多い型を使用して、関数の定義を再度抽象化することができます。
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ドメインモデルで3つの操作を定義します。
- 識別子を指定して、新しいショッピングカートを作成する
- 特定の識別子に関連付けられたショッピングカートを検索する
- 特定のショッピングカートに商品を追加する
野生では、代数は多くの異なる規則を使用して呼び出されます。 たとえば、一部の開発者は、 ShoppingCart Service、ShoppingCartAlgebra、ShoppingCartAlgなどのサフィックスを使用します。 代わりに、ドメインモデルの複数形を優先しました。
4.2. 通訳者
次に、具体的な動作を実装します。 代数が純粋な抽象オブジェクトを表す場合、インタープリターと呼ばれるそれらの実装が必要です。 インタプリタは、代数がその条件でどのように動作するかを定義します:その関数の入力と出力。
したがって、インタプリタは関数の具体的な実装を扱います。 さらに、具体的な効果を F [_] 型コンストラクターにバインドするか、抽象化するかを決定できます。 最後に、目的の機能を実現するために必要な状態または依存関係をカプセル化します。
通常、代数には、プロダクションインタープリターとテストインタープリターの少なくとも2つのインタープリターがあります。 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. プログラム
代数プログラムのクライアントをと呼びます。 プログラムという用語は正式にはパターンの一部ではありませんが、開発者の間で非常に頻繁に使用されています。 プログラムは、代数とインタープリターを使用してビジネスロジックを実装するコードです。
多くの代数の関数を組み合わせることができるため、プログラムは抽象代数の F[_]型コンストラクターにいくつかの制約を追加する必要がある場合があります。 たとえば、新しいショッピングカートを作成する関数を開発すると、すぐに新しい商品が追加されるとします。 したがって、ShoppingCarts代数で定義されている2つの操作を順番に実行する必要があります。 それらを並列化する方法はありません。
ご存知のように、エフェクトのシーケンス操作を可能にする機能構造はmonadです。 したがって、プログラムのコンテキストで使用できるようにするには、型クラス Monad[F]のインスタンスが必要です。 Scalaには、タイプ制約と呼ばれるそのような状況専用の構文があります。
def createAndAddToCart[F[_] : Monad] = ???
型構築子に制約を適用するときは、常に最小電力の原則を覚えておく必要があります。 関数開発者として、私たちは常にそのシグネチャを見ることによってのみ関数の意味を推測する必要があるため、緩すぎる制約を要求すると、この重要な理解ツールが失われます。
Monad の代わりに、 IO[F]タイプへの制約を要求するとします。
def createAndToCart[F[_] : IO] = ???
この関数について何が言えますか? IO モナドは、例外の発生から核ミサイルの発射まで、副作用を要求するすべての操作をモデル化します。
5.1. 暗黙のオブジェクト解決
次に、具体的なインタプリタのインスタンスを取得する必要があります。 インタプリタをプログラムで使用できるようにする方法は、基本的に2つあります。暗黙的なオブジェクト解決とスマートコンストラクタです。
最初のケースでは、上記の例のように、インタープリターは暗黙オブジェクトを実装します。 次に、プログラムは代数を参照する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コンパイラーは、 Fの型制約を変換し、関数に暗黙のパラメーターを追加して、前に示したものと同様の署名を取得します。 ただし、ShoppingCarts代数関数を呼び出すための名前が関連付けられた具体的なパラメーターはありません。 この問題をどのように克服できますか?
考えられる解決策は、代数のコンパニオンオブジェクトでapplyメソッドをオーバーライドすることで構成される召喚値パターンを使用することです。
object ShoppingCarts {
def apply[F[_]](implicit sc: ShoppingCarts[F]): ShoppingCarts[F] = sc
}
総称関数を使用して、インタプリタの暗黙の解決を専用関数に移動しました。 このように、applyメソッドを呼び出すコンストラクト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. スマートコンストラクタ
代数インスタンスを必要とするプログラムのもう1つのオプションは、それを入力パラメーターとして明示的に渡すことです。 今回は暗黙的解像度を使用しません。 したがって、スマートコンストラクターパターンを適用します。
まず、インスタンス化プロセスを完全に制御したいので、インタプリタをオブジェクトだけでなくクラスにする必要があります。 したがって、コンストラクターをprivateにします。 通常のクラスであるため、最終的にコンストラクターで依存関係を渡すことができます。
class ShoppingCartsInterpreter private(repo: ShoppingCartRepository)
extends ShoppingCarts[ScRepoState] {
// Functions implementation
}
次に、ファクトリメソッドを使用してインタプリタをインスタンス化します。 このようにして、必要に応じて、入力に対して任意の検証を実装できます。 したがって、ファクトリメソッドをインタプリタコンパニオンオブジェクトに配置し、makeのようなサウンド名を付けます。
object ShoppingCartsInterpreter {
def make(): ShoppingCartsInterpreter = {
new ShoppingCartsInterpreter(repository)
}
private val repository: ShoppingCartRepository = Map()
}
この場合、ファクトリメソッドは入力パラメータを受け取らず、コンパニオンオブジェクト内のインタプリタの唯一の依存関係を解決します。 別のアプローチでは、make関数のパラメーターとしてそのような依存関係を直接与える必要があります。
プログラムはどうですか? インタプリタの暗黙的な解決を回避するには、依存関係をパラメータとして直接提供する必要があります。 createAndAddToCart 関数に明示的なパラメーターを使用する代わりに、classを使用してプログラムのモジュールをモデル化できます。
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のタグなしの最終パターンを紹介しました。 このパターンでは、代数、インタープリター、およびプログラムの概念を使用して、モジュール間のコードの責任を整理します。 このようにして、コードはクリーンな状態に保たれ、進化、再利用、およびテストが容易になります。
いつものように、コードはGitHubでから入手できます。