Upgrade to Pro — share decks privately, control downloads, hide ads and more …

大変だよ、Tagless-final パターン

大変だよ、Tagless-final パターン

2020-09-25 scala.tokyo - オンライン LT1
https://scala-tokyo.connpass.com/event/187140/

Naoki Aoyama - @aoiroaoino

September 25, 2020
Tweet

More Decks by Naoki Aoyama - @aoiroaoino

Other Decks in Programming

Transcript

  1. Agenda ➢ Tagless-final パターンとメリット ◦ そもそも Tagless-final とは ◦ Tagless-final

    パターンの使い方と便利なところ ➢ Tagless-final パターンの悩みどころ ◦ 実際に活用してみての悩みどころ、課題感など ➢ Conclusion ◦ これらを踏まえて積極的に活用すべきか否か
  2. Tagless-final とは ❖ 型安全な言語内 DSL を構築する手法の一つ ➢ ホスト言語上で定義される DSL ➢

    自前で型システムを定義する必要がない(ホスト言語の物を利用できる) ❖ 高階抽象構文が扱える ➢ 実は Monad 型クラスの制約は必須ではない ❖ プログラム(定義した DSL の記述)と実行の分離 ➢ インタプリタを定義して実行する
  3. “いわゆる” Tagless-final パターン ❖ Tagless-final の Symantics (Syntax + Semantic)

    の表現を利用 ➢ 返り値型を高カインド型パラメータ F[_] で包む ❖ 高階抽象構文を定義せず、Monad の制約をかけて for 式で合成する ➢ もはや一種のデザインパターンのようなもの ❖ 実装の都合を高カインド型パラメータによって極限まで分離  => “いわゆる” Tagless-final パターンを実践投入した際の悩みどころのお話 ※あくまで今回呼び分けのために導入した名称であることに注意
  4. 抽象的に処理の構築(合成)を実現 abstract class AccountRepositoryAlg[F[_]] { // 返り値を F[_] で包む def

    resolveById(id: AccountId): F[Option[Account]] } object AccountRepositoryAlg { // implicitly[A] のエイリアス def apply[F[_]](implicit F: AccountRepositoryAlg[F]): AccountRepositoryAlg[F] = F }
  5. 抽象的に処理の構築(合成)を実現 abstract class AccountRepositoryAlg[F[_]] { // 返り値を F[_] で包む def

    resolveById(id: AccountId): F[Option[Account]] } object AccountRepositoryAlg { // implicitly[A] のエイリアス def apply[F[_]](implicit F: AccountRepositoryAlg[F]): AccountRepositoryAlg[F] = F }
  6. 抽象的に処理の構築(合成)を実現 abstract class AccountRepositoryAlg[F[_]] { // 返り値を F[_] で包む def

    resolveById(id: AccountId): F[Option[Account]] } object AccountRepositoryAlg { // implicitly[A] のエイリアス def apply[F[_]](implicit F: AccountRepositoryAlg[F]): AccountRepositoryAlg[F] = F }
  7. 抽象的に処理の構築(合成)を実現 final class GetOwnerAccountOfItem[F[_]: Monad: AccountRepositoryAlg: ItemRepositoryAlg] { def run(itemId:

    ItemId): F[Result] = for { maybeItem <- ItemRepositoryAlg[F].resolveById(itemId) result <- maybeItem match { case Some(item) => AccountRepositoryAlg[F].resolveById(item.ownerAccountId) .map(_.fold(Result.AccountNotFound, Result.Found(_))) case None => Monad[F].pure(Result.ItemNotFound) } } yield result }
  8. 抽象的に処理の構築(合成)を実現 final class GetOwnerAccountOfItem[F[_]: Monad: AccountRepositoryAlg: ItemRepositoryAlg] { def run(itemId:

    ItemId): F[Result] = for { maybeItem <- ItemRepositoryAlg[F].resolveById(itemId) result <- maybeItem match { case Some(item) => AccountRepositoryAlg[F].resolveById(item.ownerAccountId) .map(_.fold(Result.AccountNotFound, Result.Found(_))) case None => Monad[F].pure(Result.ItemNotFound) } } yield result }
  9. 抽象的に処理の構築(合成)を実現 final class GetOwnerAccountOfItem[F[_]: Monad: AccountRepositoryAlg: ItemRepositoryAlg] { def run(itemId:

    ItemId): F[Result] = for { maybeItem <- ItemRepositoryAlg[F].resolveById(itemId) result <- maybeItem match { case Some(item) => AccountRepositoryAlg[F].resolveById(item.ownerAccountId) .map(_.fold(Result.AccountNotFound, Result.Found(_))) case None => Monad[F].pure(Result.ItemNotFound) } } yield result }
  10. 実装の都合を全て F[_] に押し込める abstract class AccountRepository { // 実装側の都合がメソッドのシグネチャに現れてしまう def

    resolveById(id: AccountId)( implicit session: DBSession, ec: ExecutionContext ): Future[Option[Account]] }
  11. 実装の都合を全て F[_] に押し込める abstract class AccountRepository { // 実装側の都合がメソッドのシグネチャに現れてしまう def

    resolveById(id: AccountId)( implicit session: DBSession, ec: ExecutionContext ): Future[Option[Account]] }
  12. 実装の都合を全て F[_] に押し込める abstract class AccountRepositoryAlg[F[_]] { def resolveById(id: AccountId):

    F[Option[Account]] } type DBIO[A] = (DBSession, ExecutionContext) => Future[A] // AccountRepository の定義とほぼ等価 abstract class AccountRepositoryOnDB extends AccountRepositoryAlg[DBIO] { def resolveById(id: AccountId): DBIO[Option[Account]] }
  13. 実装の都合を全て F[_] に押し込める // type Id[A] = A final class

    AccountRepositoryOnId extends AccountRepositoryAlg[Id] { def resolveById(id: AccountId): Id[Option[Account]] = ??? } // type DBIO[A] = (DBSession, ExecutionContext) => Future[A] final class AccountRepositoryOnDB(...) extends AccountRepositoryAlg[DBIO] { def resolveById(id: AccountId): DBIO[Option[Account]] = ??? } final class AccountRepositoryOnAPI(...) extends AccountRepositoryAlg[Future] { def resolveById(id: AccountId): Future[Option[Account]] = ??? }
  14. 実装の都合を全て F[_] に押し込める // type Id[A] = A final class

    AccountRepositoryOnId extends AccountRepositoryAlg[Id] { def resolveById(id: AccountId): Id[Option[Account]] = ??? } // type DBIO[A] = (DBSession, ExecutionContext) => Future[A] final class AccountRepositoryOnDB(...) extends AccountRepositoryAlg[DBIO] { def resolveById(id: AccountId): DBIO[Option[Account]] = ??? } final class AccountRepositoryOnAPI(...) extends AccountRepositoryAlg[Future] { def resolveById(id: AccountId): Future[Option[Account]] = ??? }
  15. Monad は Scalaz と Cats のどっちを使う? ❖ Scala は標準ライブラリに Monad

    型クラスを持たない ➢ 外部ライブラリへの依存が発生する ➢ 選択肢としては Scalaz または Cats のほぼ二択 ❖ 実装のタイミングで困る ➢ F[_] に制約をかけて合成するタイミングではどちらを使用しても特に困ることはない ➢ どちらを選ぶかによって実装時に使用できる外部ライブラリがほぼ決まってしまう ❖ 自前で実装する選択肢もある ➢ 外部ライブラリに依存しなくて済むが、実装時の具体的な F[_] に対して、 これの型クラスインスタンスも自前で定義しなければならない ➢ Monad 則のチェックという手間も ...
  16. 制約は Monad で十分ですか? ❖ Monad は例外のハンドリングができない ➢ Monad[F[_]] よりも MonadError[F[_],

    Throwable] を使用することを検討したくなる ❖ より強い制約をかけると使用可能な具象型が減る ➢ 例えば、例外のハンドリング目的でエラー型を Throwable に固定した MonadError[F[_], Throwable] を制約とすると Option が使えなくなる ※ MonadError[Option, Throwable] インスタンスは存在しない
  17. 制約は Monad で十分ですか? abstract class FooService[F[_]: Monad] { def run():

    F[Result] } // F[_]: Monad の制約では Future#recover のような処理ができない FooService[F].run().recover { case NonFatal(err) => someRecoverProc }
  18. 制約は Monad で十分ですか? abstract class FooService[F[_]: MonadError[*, Throwable]] { def

    run(): F[Result] } // F[_]: MonadError[*, Throwable] にすることで Future#recover のような処理が可能となる FooService[F].run().recover { case NonFatal(err) => someRecoverProc }
  19. 実装都合を押し込められた F[_] の合成 ❖ 具体的な F[_] の型が異なる場合は揃えなければならない ➢ 新たに実装を定義する ➢

    自然変換を用いて既存の実装を流用する ❖ 複数の異なる F[_] の場合それらが合成可能な事を示さなければならない ➢ 異なる Monad は合成できない ➢ 合成のタイミングで F[_], G[_] と定義した場合は以下の方針がある ▪ この二つが合成可能 (等しい型)であることを示す ▪ または、一方へ変換可能であることを示す
  20. 実装都合を押し込められた F[_] の合成 // DB へ専用の ExecutionContext を用いてアクセスする IO 型

    type DBIO[A] = (DBSession, ExecutionContext) => Future[A] // S3 のクライアントを用いて非同期にデータを取得する IO 型 type S3IO[A] = S3Client => Future[A] // Redis へのコネクションプールを用いて同期的にデータを取得する IO 型 type RedisIO[A] = RedisCP => Try[A]
  21. 実装都合を押し込められた F[_] の合成 type DBIO[A] = (DBSession, ExecutionContext) => Future[A]

    type S3IO[A] = S3Client => Future[A] // 引数をフラットに列挙してしまうパターン type DBS3IO_1[A] = (DBSession, ExecutionContext, S3Client) => Future[A] // 関係ない引数を使えないようにするパターン type DBS3IO_2[A] = Either[(DBSession, ExecutionContext), S3Client] => Future[A] // いっそ引数を汎用的にした自前の IO 型にしてしまうパターン abstract class Context type MyIO[A] = Context => Future[A]
  22. 高機能な IO 型に揃えてからの F[_] の合成 ❖ 実装側では実行した結果を共通の IO 型にしてしまうという案 ➢

    Cats Effect, Monix, ZIO など ➢ Future も有力候補の一つ ▪ Monad 則を満たすか否かの議論はあるが、 実用上は Scalaz, Cats 共に Monad のインスタンスが定義されている ➢ 具体的な F[_] がほぼ一つに決まってしまうので、実装都合を意識しすぎてしまう場面も ❖ Tagless-final パターンである必要性は薄れてしまう ➢ インターフェースでも上記の高機能な具象 IO 型にしてしまう ➢ 外部ライブラリへの依存が発生する可能性があるが、現実的な妥協案 ➢ 具体的な F[_] の型合わせについて悩む必要がなくなるメリットはある
  23. 高機能な IO 型に揃えてからの F[_] の合成 final class AccountRepositoryOnDB( dbSessionProvider: DBSessionProvider,

    blockingExecutor: ExecutionContext ) extends AccountRepositoryAlg[Future] { // 返り値型は DBIO[A] ではなく Future[A] にする def resolveById(id: AccountId): Future[Option[Account]] = ??? } final class ItemRepositoryOnS3(s3Client: S3Client) extends ItemRepositoryAlg[Future] { // 返り値型は S3IO[A] ではなく Future[A] にする def resolveById(id: ItemId): Future[Option[Item]] = ??? }
  24. 高機能な IO 型に揃えてからの F[_] の合成 final class AccountRepositoryOnDB( dbSessionProvider: DBSessionProvider,

    blockingExecutor: ExecutionContext ) extends AccountRepositoryAlg[Future] { // 返り値型は DBIO[A] ではなく Future[A] にする def resolveById(id: AccountId): Future[Option[Account]] = ??? } final class ItemRepositoryOnS3(s3Client: S3Client) extends ItemRepositoryAlg[Future] { // 返り値型は S3IO[A] ではなく Future[A] にする def resolveById(id: ItemId): Future[Option[Item]] = ??? }
  25. 高機能な IO 型に揃えてからの F[_] の合成 // 依存性の定義。その他に Monad[Future] のインスタンスなどがスコープ内に定義されている必要がある implicit

    lazy val accountRepository: AccountRepositoryAlg[Future] = new AccountRepositoryOnDB(...) implicit lazy val itemRepository: ItemRepositoryAlg[Future] = new ItemRepositoryOnS3(...) // class GetOwnerAccountOfItem[F[_]: Monad: AccountRepositoryAlg: ItemRepositoryAlg] val getOwnerAccountOfItem = new GetOwnerAccountOfItem[Future] getOwnerAccountOfItem.run(itemId) // e.g. Future(Success(Some(Account(...))))
  26. F[_] の具体的な型を意識しすぎた例... final class FooService[F[_]: Monad: BarService: BazService: QuxService] {

    def run(): F[Result] = { // Future を前提とした処理の合成... val barF = BarService[F].getBar() val bazF = BazService[F].getBaz() val quxF = QuxService[F].getQux() for { bar <- barF baz <- bazF qux <- quxF } yield proc(bar, baz, qux) } }
  27. 大規模アプリケーション開発には向いていない? ❖ 複数人でのチーム開発で活用するには懸念が多い ➢ 実装での型の揃え方等コーディング規約の整備、レビューでのチェックが重要に ▪ Linter で機械的に検出しにくい ➢ DI

    ライブラリとの食い合わせが良くない ▪ 高カインド型のサポートが DI ライブラリで提供されていない場合がある ▪ Tagless-final パターン自体が型クラスを用いた DI の機能を内包している ❖ 型の変換や合成のために知識が必要 ➢ 高カインド型、型クラス、 Monad、自然変換など、使いこなすにはそれなりに知識が必要 ❖ コストを払ってまで得たい抽象度? ➢ チームの構成メンバーやスケジュール感によっては過剰な場合も ➢ implicit parameter が数十数百のオーダーで定義されることによる compile 時間への影響
  28. 小規模/個人開発では威力を発揮する? ❖ 型クラスによる DI のパターンが便利 ➢ DI ライブラリ不要 ➢ 同じく標準ライブラリで実現できる

    Cake Pattern よりも(記述量の観点で)軽量 ➢ implicit parameter で注入されるので自前で Constructor Injection よりも(記述量の観点で)軽量 ❖ ほぼ標準ライブラリのみでアプリケーションの開発を進められる ➢ ミドルウェア等外部へのアクセスが必要になりそうな処理を型クラスとして定義し、 それを後回しにしてロジックやユースケースの実装をどんどん進められるという 開発体験がなかなかに秀逸(個人の感想) ❖ 参考事例 ➢ Scala Steward - https://github.com/scala-steward-org/scala-steward
  29. まとめ ➢ Tagless-final パターンの使い方とモチベーションを説明した ◦ 抽象度の高い処理の記述方法を提供してくれる ➢ 活用する上で実装の方法や方針で悩みどころがそれなりに発生する ◦ 合成や型の扱いに一定以上の知識が求められる

    ◦ メンバーのスキルセットやチームの形態にも左右されそう ◦ 自由度が高い分考えることが多いが、解決自体はそう難しくはない ➢ 大規模アプリケーションよりも小規模/個人開発向けで活きてくる ◦ あくまで実用してみての個人的な感想 用法・用量を守って正しくお使いください