Slide 1

Slide 1 text

大変だよ、 Tagless-final パターン 2020-09-25 scala.tokyo - オンライン LT1 Naoki Aoyama - @aoiroaoino

Slide 2

Slide 2 text

❖ Naoki Aoyama ❖ Twitter/GitHub: @aoiroaoino ❖ Working at: $ whoami

Slide 3

Slide 3 text

Agenda ➢ Tagless-final パターンとメリット ○ そもそも Tagless-final とは ○ Tagless-final パターンの使い方と便利なところ ➢ Tagless-final パターンの悩みどころ ○ 実際に活用してみての悩みどころ、課題感など ➢ Conclusion ○ これらを踏まえて積極的に活用すべきか否か

Slide 4

Slide 4 text

Tagless-final パターンとメリット

Slide 5

Slide 5 text

Tagless-final とは ❖ 型安全な言語内 DSL を構築する手法の一つ ➢ ホスト言語上で定義される DSL ➢ 自前で型システムを定義する必要がない(ホスト言語の物を利用できる) ❖ 高階抽象構文が扱える ➢ 実は Monad 型クラスの制約は必須ではない ❖ プログラム(定義した DSL の記述)と実行の分離 ➢ インタプリタを定義して実行する

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

抽象的に処理の構築(合成)を実現 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 }

Slide 8

Slide 8 text

抽象的に処理の構築(合成)を実現 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 }

Slide 9

Slide 9 text

抽象的に処理の構築(合成)を実現 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 }

Slide 10

Slide 10 text

抽象的に処理の構築(合成)を実現 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 }

Slide 11

Slide 11 text

抽象的に処理の構築(合成)を実現 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 }

Slide 12

Slide 12 text

抽象的に処理の構築(合成)を実現 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 }

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

実装の都合を全て F[_] に押し込める abstract class AccountRepositoryAlg[F[_]] { def resolveById(id: AccountId): F[Option[Account]] }

Slide 16

Slide 16 text

実装の都合を全て F[_] に押し込める abstract class AccountRepositoryAlg[F[_]] { def resolveById(id: AccountId): F[Option[Account]] } type DBIO[A] = (DBSession, ExecutionContext) => Future[A]

Slide 17

Slide 17 text

実装の都合を全て 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]] }

Slide 18

Slide 18 text

実装の都合を全て 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]] = ??? }

Slide 19

Slide 19 text

実装の都合を全て 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]] = ??? }

Slide 20

Slide 20 text

Tagless-final パターンの悩みどころ

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

実装都合を押し込められた F[_] の合成 ❖ 具体的な F[_] の型が異なる場合は揃えなければならない ➢ 新たに実装を定義する ➢ 自然変換を用いて既存の実装を流用する ❖ 複数の異なる F[_] の場合それらが合成可能な事を示さなければならない ➢ 異なる Monad は合成できない ➢ 合成のタイミングで F[_], G[_] と定義した場合は以下の方針がある ■ この二つが合成可能 (等しい型)であることを示す ■ または、一方へ変換可能であることを示す

Slide 26

Slide 26 text

実装都合を押し込められた 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]

Slide 27

Slide 27 text

実装都合を押し込められた 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]

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

高機能な 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]] = ??? }

Slide 30

Slide 30 text

高機能な 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]] = ??? }

Slide 31

Slide 31 text

高機能な 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(...))))

Slide 32

Slide 32 text

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) } }

Slide 33

Slide 33 text

Conclusion

Slide 34

Slide 34 text

大規模アプリケーション開発には向いていない? ❖ 複数人でのチーム開発で活用するには懸念が多い ➢ 実装での型の揃え方等コーディング規約の整備、レビューでのチェックが重要に ■ Linter で機械的に検出しにくい ➢ DI ライブラリとの食い合わせが良くない ■ 高カインド型のサポートが DI ライブラリで提供されていない場合がある ■ Tagless-final パターン自体が型クラスを用いた DI の機能を内包している ❖ 型の変換や合成のために知識が必要 ➢ 高カインド型、型クラス、 Monad、自然変換など、使いこなすにはそれなりに知識が必要 ❖ コストを払ってまで得たい抽象度? ➢ チームの構成メンバーやスケジュール感によっては過剰な場合も ➢ implicit parameter が数十数百のオーダーで定義されることによる compile 時間への影響

Slide 35

Slide 35 text

小規模/個人開発では威力を発揮する? ❖ 型クラスによる DI のパターンが便利 ➢ DI ライブラリ不要 ➢ 同じく標準ライブラリで実現できる Cake Pattern よりも(記述量の観点で)軽量 ➢ implicit parameter で注入されるので自前で Constructor Injection よりも(記述量の観点で)軽量 ❖ ほぼ標準ライブラリのみでアプリケーションの開発を進められる ➢ ミドルウェア等外部へのアクセスが必要になりそうな処理を型クラスとして定義し、 それを後回しにしてロジックやユースケースの実装をどんどん進められるという 開発体験がなかなかに秀逸(個人の感想) ❖ 参考事例 ➢ Scala Steward - https://github.com/scala-steward-org/scala-steward

Slide 36

Slide 36 text

まとめ ➢ Tagless-final パターンの使い方とモチベーションを説明した ○ 抽象度の高い処理の記述方法を提供してくれる ➢ 活用する上で実装の方法や方針で悩みどころがそれなりに発生する ○ 合成や型の扱いに一定以上の知識が求められる ○ メンバーのスキルセットやチームの形態にも左右されそう ○ 自由度が高い分考えることが多いが、解決自体はそう難しくはない ➢ 大規模アプリケーションよりも小規模/個人開発向けで活きてくる ○ あくまで実用してみての個人的な感想 用法・用量を守って正しくお使いください